diff --git a/Assets/Scripts/Gameplay/EntityInstance.cs b/Assets/Scripts/Gameplay/EntityInstance.cs index e07ba3135..9e8a7470f 100644 --- a/Assets/Scripts/Gameplay/EntityInstance.cs +++ b/Assets/Scripts/Gameplay/EntityInstance.cs @@ -200,8 +200,11 @@ public class EntityInstance : MonoBehaviour else Debug.LogError($"No InstantWeaponEffectManager prefab found for GUID {data.EffectPrefab}"); } - instantWeapon.OnFire += () => - _instantWeaponManagers[data].Fire(instantWeapon, item, this, entity.Target.Value != null && ZoneRenderer.EntityInstances.ContainsKey(entity.Target.Value) ? ZoneRenderer.EntityInstances[entity.Target.Value] : null); + // Cut 3 (docs/fire-control-cut.md): carries the ShotId FireControl.Fire assigned so the + // effect manager (and whatever it spawns) can bind its presentation to Zone.ShotCommitted / + // ShotResolved instead of deciding or applying anything itself. + instantWeapon.OnFire += shotId => + _instantWeaponManagers[data].Fire(instantWeapon, item, this, entity.Target.Value != null && ZoneRenderer.EntityInstances.ContainsKey(entity.Target.Value) ? ZoneRenderer.EntityInstances[entity.Target.Value] : null, shotId); if (behavior is ChargedWeapon chargedWeapon) { @@ -274,126 +277,10 @@ public class EntityInstance : MonoBehaviour } } - void DamageSchematic(float damage, Shape hitShape) - { - foreach (var v in hitShape.Coordinates) - hitShape[v] = hitShape[v] && hullData.Shape[v]; - - float hullDamage = 0; - var damagePerCell = damage / hitShape.Coordinates.Length; - foreach (var v in hitShape.Coordinates) - { - var d = damagePerCell; - - // Subtract surface damage from armor, passing on the remainder to the item and then to the hull - var prev = entity.Armor[v.x, v.y]; - entity.Armor[v.x, v.y] = max(prev - d, 0); - entity.ArmorDamage.OnNext((v, d)); - d = max(d - prev, 0); - - if (d > 0.1f) - { - var item = entity.GearOccupancy[v.x, v.y]; - if (item != null) - { - prev = item.EquippableItem.Durability; - item.EquippableItem.Durability = max(prev - d, 0); - entity.ItemDamage.OnNext((item, d)); - d = max(d - prev, 0); - } - } - - hullDamage += d; - } - - if(hullDamage > .1f) - { - entity.Hull.Durability -= hullDamage; - entity.HullDamage.OnNext(hullDamage); - } - } - - foreach (var collider in HullColliders) - { - collider.Splash.Subscribe(splash => - { - var hitShape = new Shape(hullData.Shape.Width, hullData.Shape.Height); - foreach (var v in hullData.Shape.Coordinates) - { - var localHitDirection = transform.InverseTransformDirection(splash.Direction.ToUnity()); - var direction = normalize(float2(localHitDirection.x, localHitDirection.z)); - var cellDot = dot(normalize(v - hullData.Shape.CenterOfMass), direction); - if (cellDot < 0) hitShape[v] = true; - } - DamageSchematic(splash.Damage, hitShape); - }); - - collider.Hit.Subscribe(hit => - { - Entity.IncomingHit.OnNext(hit.Source); - var hardpointIndex = (int) hit.TexCoord.x - 1; - - var hitShape = new Shape(hullData.Shape.Width, hullData.Shape.Height); - - // U coordinate between 0-1 indicates a hit that didn't land directly on a hardpoint - // Find the 2D position of the hit scaled to the schematic - float2 hitPos = float2.zero; - if (hardpointIndex < 0 || hardpointIndex >= hullData.Hardpoints.Count) - { - hitPos = float2(hit.TexCoord.x * hullData.Shape.Width, hit.TexCoord.y * hullData.Shape.Height); - // Search all schematic border cells for the cell which is closest to the hit position - var hitCell = int2(-1); - var distance = float.MaxValue; - foreach (var v in hullData.Shape.Coordinates) - { - var cellDist = lengthsq(hitPos - v); - if (cellDist < distance) - { - distance = cellDist; - hitCell = v; - hitPos = v + float2(.5f); - } - } - - hitShape[hitCell] = true; - } - else - { - // Collider UV coordinates starting with 1 correspond to hardpoint index - var hardpoint = hullData.Hardpoints[hardpointIndex]; - - // Obtain the hull coordinates of all cells occupied by the hardpoint - var hardpointCells = hullData.Shape.Inset(hardpoint.Shape, hardpoint.Position); - hitPos = hardpointCells.CenterOfMass; - foreach (var v in hardpointCells.Coordinates) - hitShape[v] = true; - } - - for (int i = 0; i < Mathf.RoundToInt(hit.Spread); i++) - { - hitShape = hitShape.Expand(); - } - - if (hit.Penetration > .5f) - { - // Find the local 2D vector corresponding to the direction of the incoming hit - var localHitDirection = transform.InverseTransformDirection(hit.Direction.ToUnity()); - var penetrationVector = normalize(float2(localHitDirection.x, localHitDirection.z)); - // TODO: Bresenham's line algorithm - // March a ray through the ship from the hit position - var penetrationPoint = hitPos; - var penetrationDistance = 0f; - while (penetrationDistance < hit.Penetration && hullData.Shape[int2(penetrationPoint)]) - { - penetrationDistance += .5f; - hitShape[int2(penetrationPoint)] = true; - penetrationPoint += penetrationVector * .5f; - } - } - - DamageSchematic(hit.Damage, hitShape); - }); - } + // Cut 3 (docs/fire-control-cut.md): DamageSchematic and both HullCollider subscriptions (Splash, Hit) + // are deleted -- FireControl decides hits now and Entity.ApplyHit/DamageSchematic (ServerShared) apply + // them, the single owner in place of what used to exist once per Unity effect. HullColliders no longer + // publish Hit/Splash at all (R8). LookAtPoint = new GameObject($"{entity.Name} Look Point").transform; diff --git a/Assets/Scripts/Gameplay/HullCollider.cs b/Assets/Scripts/Gameplay/HullCollider.cs index 0ff571d8d..34d005ade 100644 --- a/Assets/Scripts/Gameplay/HullCollider.cs +++ b/Assets/Scripts/Gameplay/HullCollider.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections; using System.Collections.Generic; using UniRx; @@ -8,13 +8,26 @@ using static CultMath.math; using UnityEngine; using float2 = CultMath.float2; +// Cut 3 (docs/fire-control-cut.md) FORK, reported rather than worked around silently: the map's Cut 3 deletion +// list calls for Hit, Splash, SendHit and SendSplash to go away here outright. They cannot, yet, without +// breaking the build -- ConstantLaser.cs, ConstantLightning.cs, ConstantParticleWeapon.cs, HitscanEffect.cs +// and Mine.cs (Cut 4's "unused kinds," R9) still call hull.SendHit/SendSplash from their own live Physics +// queries, and Cut 4 is the cut that migrates them. Deleting these methods now is a compile break in +// out-of-scope files, not a behaviour change in this cut's own scope. +// +// What Cut 3 actually owns and has delivered: nothing in Assets/Scripts/Gameplay/Weapons/{Projectile, +// GuidedProjectile,Laser,Lightning}.cs calls SendHit/SendSplash any more (see those files), and +// EntityInstance.cs no longer subscribes to Hit/Splash at all -- so for the four weapon kinds this cut owns, +// these events have no subscriber and decide nothing. They fire into the void until Cut 4 deletes them +// together with their last remaining callers. OnCollisionEnter stays regardless: ship-on-ship collision +// impulse is a deferred Unity-physics surface, not part of fire control. public class HullCollider : MonoBehaviour { public Subject Hit = new Subject(); public Subject Splash = new Subject(); - + public Entity Entity { get; set; } - + public void SendHit(float damage, float penetration, float spread, DamageType damageType, Entity source, Vector2 texCoord, Vector3 direction) { Hit.OnNext(new HullHitEventArgs @@ -28,7 +41,7 @@ public class HullCollider : MonoBehaviour Direction = direction.ToCultMath() }); } - + public void SendSplash(float damage, DamageType damageType, Entity source, Vector3 direction) { Splash.OnNext(new HullSplashEventArgs @@ -55,7 +68,7 @@ public class HullCollider : MonoBehaviour public float2 TexCoord; public float3 Direction; } - + public class HullSplashEventArgs { public float Damage; @@ -63,4 +76,4 @@ public class HullCollider : MonoBehaviour public Entity Source; public float3 Direction; } -} \ No newline at end of file +} diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs index 079644a8d..ddb10563d 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectile.cs @@ -1,14 +1,16 @@ using System; using System.Collections; -using System.Collections.Generic; using UnityEngine; using CultMath; -using CultMath.UnityBridge; using static CultMath.math; using Random = UnityEngine.Random; using static Noise1D; using float3 = CultMath.float3; +// Cut 3 (docs/fire-control-cut.md): the raycast, shield branch and SendHit are deleted -- FireControl already +// decided this shot's fate before this object was ever spawned (R8). The homing/guidance flight (the whole +// point of this presentation) is untouched; children spawned on split carry no damage of their own to apply +// either, since they never did anything but inherit their parent's now-deleted fields. public class GuidedProjectile : MonoBehaviour { public Prototype HitEffect; @@ -27,22 +29,20 @@ public class GuidedProjectile : MonoBehaviour public float TopSpeed; public Transform Source; public Func TargetPosition; - + private float _phase; private float _prevDist; private bool _active; private bool _alive; private Vector3 _targetVelocity; private Vector3 _previousTargetPosition; - + + // Cut 3: FireControl.Fire's ShotId -- this missile's handle onto Zone.ShotCommitted/ShotResolved. + public int ShotId { get; set; } public Transform Target { get; set; } public float3 StartPosition { get; set; } public float Range { get; set; } public Vector3 Velocity { get; set; } - public float Damage { get; set; } - public float Penetration { get; set; } - public float Spread { get; set; } - public DamageType DamageType { get; set; } public Entity SourceEntity { get; set; } public event Action OnKill; @@ -112,10 +112,6 @@ public class GuidedProjectile : MonoBehaviour var perpendicularRandom = randomDirection.x * right + randomDirection.y * up; child.Velocity = (normalize(lerp(perpendicularRandom, dir.ToCultMath(), SplitSeparationForwardness)) * length(Velocity.ToCultMath()) * SplitSeparationVelocity).ToUnity(); child.Range = Range; - child.Damage = Damage / Children; - child.Penetration = Penetration; - child.Spread = Spread; - child.DamageType = DamageType; child.Source = Source; child.Target = Target; child.SourceEntity = SourceEntity; @@ -149,53 +145,6 @@ public class GuidedProjectile : MonoBehaviour if(_alive) { - var ray = new Ray(t.position, Velocity); - foreach (var hit in Physics.RaycastAll(ray, Velocity.magnitude * Time.deltaTime, 1 | (1 << 17))) - { - var shield = hit.collider.GetComponent(); - if (shield) - { - var shieldBehavior = shield.Entity.Shield; - var shieldActive = shieldBehavior != null && shieldBehavior.Item.Active.Value; - var shieldAbsorbs = shieldActive && shieldBehavior.CanTakeHit(DamageType, Damage); - // F5 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): CanTakeHit is a pure query now -- - // read once into shieldAbsorbs, and break the shield exactly once, right here, at the point - // this hit is actually decided to route past it (to the hull collider RaycastAll returns - // further down this same loop) instead of being absorbed. The hull branch below re-queries - // CanTakeHit safely -- it is side-effect-free -- and will see Broken already true. - if (shieldActive && !shieldAbsorbs) shieldBehavior.Break(); - if (!shieldAbsorbs) continue; - if (shield.Entity != SourceEntity) - { - shieldBehavior.TakeHit(DamageType, Damage); - shield.ShowHit(hit.point, sqrt(Damage)); - } - } - var hull = hit.collider.GetComponent(); - if (hull && !(hull.Entity.Shield != null && hull.Entity.Shield.Item.Active.Value && hull.Entity.Shield.CanTakeHit(DamageType, Damage))) - { - if (hull.Entity != SourceEntity) - { - hull.SendHit(Damage, Penetration, Spread, DamageType, SourceEntity, hit.textureCoord, transform.forward); - transform.position = hit.point; - StartCoroutine(Kill()); - } - } - else// if (hit.transform.gameObject.layer == 1) - { - StartCoroutine(Kill()); - return; - } - - if (HitEffect != null) - { - var ht = HitEffect.Instantiate(); - ht.SetParent(hit.collider.transform); - ht.position = hit.point; - return; - } - } - t.position += Velocity * Time.deltaTime; } } diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs index cf63f7bce..51b62969e 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs @@ -13,12 +13,13 @@ public class GuidedProjectileManager : InstantWeaponEffectManager public Subject<(Entity source, Transform target, GuidedProjectile missile)> OnFireGuided = new Subject<(Entity source, Transform target, GuidedProjectile missile)>(); - public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target) + public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId) { if(weapon.Data is LauncherData launcher) { if (target == null) return; var p = ProjectilePrototype.Instantiate(); + p.ShotId = shotId; p.Source = source.transform; p.SourceEntity = source.Entity; p.Target = target.transform; @@ -26,11 +27,7 @@ public class GuidedProjectileManager : InstantWeaponEffectManager var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y]; var barrel = source.GetBarrel(hp); p.StartPosition = (p.transform.position = barrel.position).ToCultMath(); - p.Damage = weapon.Damage; p.Range = weapon.Range; - p.Penetration = weapon.Penetration; - p.Spread = weapon.DamageSpread; - p.DamageType = weapon.WeaponData.DamageType; p.GuidanceCurve = launcher.GuidanceCurve.ToCurve(); p.LiftCurve = launcher.LiftCurve.ToCurve(); p.ThrustCurve = launcher.ThrustCurve.ToCurve(); @@ -42,17 +39,14 @@ public class GuidedProjectileManager : InstantWeaponEffectManager else if(weapon.Data is GuidedWeaponData guidance) { var p = ProjectilePrototype.Instantiate(); + p.ShotId = shotId; p.Source = source.transform; p.SourceEntity = source.Entity; p.Frequency = guidance.DodgeFrequency; var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y]; var barrel = source.GetBarrel(hp); p.StartPosition = (p.transform.position = barrel.position).ToCultMath(); - p.Damage = weapon.Damage; p.Range = weapon.Range; - p.Penetration = weapon.Penetration; - p.Spread = weapon.DamageSpread; - p.DamageType = weapon.WeaponData.DamageType; p.GuidanceCurve = guidance.GuidanceCurve.ToCurve(); p.LiftCurve = guidance.LiftCurve.ToCurve(); p.ThrustCurve = guidance.ThrustCurve.ToCurve(); diff --git a/Assets/Scripts/Gameplay/Weapons/HitscanManager.cs b/Assets/Scripts/Gameplay/Weapons/HitscanManager.cs index 046b99434..3c4f8416e 100644 --- a/Assets/Scripts/Gameplay/Weapons/HitscanManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/HitscanManager.cs @@ -3,7 +3,7 @@ using UnityEngine; public class HitscanManager : InstantWeaponEffectManager { public Prototype Prototype; - public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target) + public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId) { var p = Prototype.Instantiate(); p.SourceEntity = source.Entity; diff --git a/Assets/Scripts/Gameplay/Weapons/InstantWeaponEffectManager.cs b/Assets/Scripts/Gameplay/Weapons/InstantWeaponEffectManager.cs index 13683cc08..cc2dd6f99 100644 --- a/Assets/Scripts/Gameplay/Weapons/InstantWeaponEffectManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/InstantWeaponEffectManager.cs @@ -4,5 +4,8 @@ using UnityEngine; public abstract class InstantWeaponEffectManager : MonoBehaviour { - public abstract void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target); + // Cut 3 (docs/fire-control-cut.md): shotId is FireControl.Fire's ShotId (docs/fire-control-cut.md, 0b + // table) -- the presentation's handle onto Zone.ShotCommitted / ShotResolved for this specific shot. + // Implementations apply nothing; they perform whatever the shot's outcome says once it resolves. + public abstract void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId); } \ No newline at end of file diff --git a/Assets/Scripts/Gameplay/Weapons/Laser.cs b/Assets/Scripts/Gameplay/Weapons/Laser.cs index 0c010c8a2..196a3019e 100644 --- a/Assets/Scripts/Gameplay/Weapons/Laser.cs +++ b/Assets/Scripts/Gameplay/Weapons/Laser.cs @@ -1,22 +1,24 @@ -using System; -using System.Collections; -using System.Collections.Generic; using UnityEngine; -using static CultMath.math; +// Cut 3 (docs/fire-control-cut.md): the raycast, shield branch and SendHit are deleted -- FireControl already +// rolled and, for a velocity-0 weapon like this one, already resolved this shot before the beam was ever +// drawn (a short flight commits and resolves in the same Zone.Update tick as Fire). The beam endpoint is +// presentation, rebuilt from the target's current position rather than a physics hit -- it no longer decides +// anything (R8). public class Laser : MonoBehaviour { public AnimationCurve IntensityCurve; public float Duration; public LineRenderer LineRenderer; - - public float Damage { get; set; } - public float Penetration { get; set; } - public float Spread { get; set; } - public DamageType DamageType { get; set; } + public Entity SourceEntity { get; set; } public float Range { get; set; } + // Cut 3: FireControl.Fire's ShotId. Unused by this simple endpoint-only presentation today, kept so a + // future pass can draw the beam to the exact rolled cell instead of the target's overall position. + public int ShotId { get; set; } + public Transform TargetTransform { get; set; } + private float _startTime; private readonly Vector3[] _zeros = {Vector3.zero, Vector3.zero}; @@ -34,47 +36,12 @@ public class Laser : MonoBehaviour GetComponent().ReturnToPool(); return; } - + LineRenderer.SetPosition(0, transform.position); - bool hitFound = false; - foreach (var hit in Physics.RaycastAll(new Ray(transform.position, transform.forward), Range, 1 | (1 << 17))) - { - var shield = hit.collider.GetComponent(); - if (shield) - { - var shieldBehavior = shield.Entity.Shield; - var shieldActive = shieldBehavior != null && shieldBehavior.Item.Active.Value; - var shieldAbsorbs = shieldActive && shieldBehavior.CanTakeHit(DamageType, Damage); - // F5 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): CanTakeHit is a pure query now -- read - // once into shieldAbsorbs, and break the shield exactly once, right here, at the point this hit - // is actually decided to route past it (to the hull collider RaycastAll returns further down - // this same loop) instead of being absorbed. The hull branch below re-queries CanTakeHit safely - // -- it is side-effect-free -- and will see Broken already true. - if (shieldActive && !shieldAbsorbs) shieldBehavior.Break(); - if (!shieldAbsorbs) continue; - if (shield.Entity != SourceEntity) - { - shieldBehavior.TakeHit(DamageType, Damage); - shield.ShowHit(hit.point, sqrt(Damage)); - LineRenderer.SetPosition(1, hit.point); - hitFound = true; - break; - } - } - var hull = hit.collider.GetComponent(); - if (hull && !(hull.Entity.Shield != null && hull.Entity.Shield.Item.Active.Value && hull.Entity.Shield.CanTakeHit(DamageType, Damage))) - { - if (hull.Entity != SourceEntity) - { - hull.SendHit(Damage * (Time.deltaTime / Duration), Penetration, Spread, DamageType, SourceEntity, hit.textureCoord, transform.forward); - LineRenderer.SetPosition(1, hit.point); - hitFound = true; - break; - } - } - } - if(!hitFound) - LineRenderer.SetPosition(1, transform.position + transform.forward * Range); + var endpoint = TargetTransform != null + ? TargetTransform.position + : transform.position + transform.forward * Range; + LineRenderer.SetPosition(1, endpoint); LineRenderer.widthMultiplier = IntensityCurve.Evaluate(lerp); } diff --git a/Assets/Scripts/Gameplay/Weapons/LaserManager.cs b/Assets/Scripts/Gameplay/Weapons/LaserManager.cs index 04177bf48..de70054ac 100644 --- a/Assets/Scripts/Gameplay/Weapons/LaserManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/LaserManager.cs @@ -6,7 +6,7 @@ public class LaserManager : InstantWeaponEffectManager { public Prototype ProjectilePrototype; - public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target) + public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId) { var p = ProjectilePrototype.Instantiate(); var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y]; @@ -15,11 +15,9 @@ public class LaserManager : InstantWeaponEffectManager t.SetParent(barrel); t.forward = barrel.forward; t.position = barrel.position; + p.ShotId = shotId; p.SourceEntity = source.Entity; - p.Damage = weapon.Damage; p.Range = weapon.Range; - p.Penetration = weapon.Penetration; - p.Spread = weapon.DamageSpread; - p.DamageType = weapon.WeaponData.DamageType; + p.TargetTransform = target != null ? target.transform : null; } } diff --git a/Assets/Scripts/Gameplay/Weapons/Lightning.cs b/Assets/Scripts/Gameplay/Weapons/Lightning.cs index 641d44212..ddb5a28f3 100644 --- a/Assets/Scripts/Gameplay/Weapons/Lightning.cs +++ b/Assets/Scripts/Gameplay/Weapons/Lightning.cs @@ -1,76 +1,30 @@ -using System; -using System.Collections; -using System.Collections.Generic; using UnityEngine; -using CultMath; -using static CultMath.math; -using Random = UnityEngine.Random; -using static CultMath.math; +// Cut 3 (docs/fire-control-cut.md): the SphereCast, shield branch and SendHit are deleted -- FireControl +// already rolled and resolved this shot before Fire() below ever ran (a velocity-0 weapon like this one +// commits and resolves in the same tick it fires). The endpoint capture stays as a visual: it now just tracks +// the target's transform instead of a physics hit (R8). public class Lightning : MonoBehaviour { public LightningCompute LightningCompute; public float HitRadius; - - public float Damage { get; set; } - public float Penetration { get; set; } - public float Spread { get; set; } - public DamageType DamageType { get; set; } + public EntityInstance Source { get; set; } public float Range { get; set; } public EntityInstance Target { get; set; } public Transform Barrel { get; set; } - private bool _colliderHit; - private Vector3 _colliderLocalPosition; - private Transform _colliderTransform; + // Cut 3: FireControl.Fire's ShotId. Unused by this simple endpoint-only presentation today, kept so a + // future pass can draw to the exact rolled cell instead of the target's overall position. + public int ShotId { get; set; } + private Vector3 _endpoint; public void Fire() { - _colliderHit = false; LightningCompute.OnLeaderComplete = null; - LightningCompute.FixedEndpoint = false; - var hits = Physics.SphereCastAll(Barrel.position, HitRadius, Barrel.forward, Range, 1 | (1 << 17)); - foreach (var hit in hits) - { - var shield = hit.collider.GetComponent(); - if (shield) - { - var shieldBehavior = shield.Entity.Shield; - var shieldActive = shieldBehavior != null && shieldBehavior.Item.Active.Value; - var shieldAbsorbs = shieldActive && shieldBehavior.CanTakeHit(DamageType, Damage); - // F5 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): CanTakeHit is a pure query now -- read - // once into shieldAbsorbs, and break the shield exactly once, right here, at the point this hit - // is actually decided to route past it (to the hull collider SphereCastAll returns as a - // separate entry further down this same loop) instead of being absorbed. The hull branch below - // re-queries CanTakeHit safely -- it is side-effect-free -- and will see Broken already true. - if (shieldActive && !shieldAbsorbs) shieldBehavior.Break(); - if (shieldAbsorbs) - { - if (shield.Entity == Source.Entity) continue; - LightningCompute.OnLeaderComplete = () => - { - shieldBehavior.TakeHit(DamageType, Damage); - shield.ShowHit(hit.point, sqrt(Damage)); - }; - } - } - var hull = hit.collider.GetComponent(); - if (hull && !(hull.Entity.Shield != null && hull.Entity.Shield.Item.Active.Value && hull.Entity.Shield.CanTakeHit(DamageType, Damage))) - { - if (hull.Entity == Source.Entity) continue; - LightningCompute.OnLeaderComplete = () => - hull.SendHit(Damage, Penetration, Spread, DamageType, Source.Entity, hit.textureCoord, Barrel.forward); - } - - _colliderHit = true; - _colliderLocalPosition = hit.collider.transform.InverseTransformPoint(hit.point); - _colliderTransform = hit.collider.transform; - LightningCompute.FixedEndpoint = true; - break; - } - LightningCompute.OnPulseComplete = () => + LightningCompute.FixedEndpoint = Target != null; + LightningCompute.OnPulseComplete = () => GetComponent().ReturnToPool(); LightningCompute.StartAnimation(); _endpoint = Barrel.position + Barrel.forward * Range; @@ -80,10 +34,7 @@ public class Lightning : MonoBehaviour { if (Barrel == null) return; - if (_colliderHit) - { - if (_colliderTransform) _endpoint = _colliderTransform.TransformPoint(_colliderLocalPosition); - } + if (Target != null) _endpoint = Target.transform.position; LightningCompute.EndPosition = _endpoint; LightningCompute.StartPosition = Barrel.position; diff --git a/Assets/Scripts/Gameplay/Weapons/LightningGunManager.cs b/Assets/Scripts/Gameplay/Weapons/LightningGunManager.cs index 93621217a..24689ee8f 100644 --- a/Assets/Scripts/Gameplay/Weapons/LightningGunManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/LightningGunManager.cs @@ -6,18 +6,15 @@ public class LightningGunManager : InstantWeaponEffectManager { public Prototype Prototype; - public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target) + public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId) { var p = Prototype.Instantiate(); var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y]; var barrel = source.GetBarrel(hp); + p.ShotId = shotId; p.Barrel = barrel; p.Source = source; - p.Damage = weapon.Damage; p.Range = weapon.Range; - p.Penetration = weapon.Penetration; - p.Spread = weapon.DamageSpread; - p.DamageType = weapon.WeaponData.DamageType; p.Target = target; p.Fire(); } diff --git a/Assets/Scripts/Gameplay/Weapons/MineManager.cs b/Assets/Scripts/Gameplay/Weapons/MineManager.cs index b316b6cff..303fab395 100644 --- a/Assets/Scripts/Gameplay/Weapons/MineManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/MineManager.cs @@ -10,7 +10,7 @@ public class MineManager : InstantWeaponEffectManager public Prototype ProjectilePrototype; public bool InheritVelocity; - public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target) + public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId) { var p = ProjectilePrototype.Instantiate(); var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y]; diff --git a/Assets/Scripts/Gameplay/Weapons/Projectile.cs b/Assets/Scripts/Gameplay/Weapons/Projectile.cs index 8e6972c4d..8f3683772 100644 --- a/Assets/Scripts/Gameplay/Weapons/Projectile.cs +++ b/Assets/Scripts/Gameplay/Weapons/Projectile.cs @@ -1,41 +1,32 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using CultMath; -using static CultMath.math; +using System.Collections; using UnityEngine; -using Random = UnityEngine.Random; +using static CultMath.math; +// Cut 3 (docs/fire-control-cut.md): the raycast, shield branch and SendHit are deleted -- FireControl already +// decided this shot's fate before this object was ever spawned (R8). What is left is pure flight-path +// presentation: fly, and disappear at Range or ShotId's resolution, whichever comes first. Airburst +// (AirburstDistance/AirburstRange) is Cut 4's -- untouched here. public class Projectile : MonoBehaviour { public TrailRenderer Trail; public float Gravity; public float Drag = .1f; public Prototype HitEffect; - + public float AirburstDistance; public float AirburstRange; - public float DirectHitDamageMultiplier = 1; - + private bool _alive; - + + // Cut 3: FireControl.Fire's ShotId -- this projectile's handle onto Zone.ShotCommitted/ShotResolved. + public int ShotId { get; set; } public Zone Zone { get; set; } - + public Vector3 StartPosition { get; set; } public Vector3 Velocity { get; set; } - public float Damage { get; set; } - public float Penetration { get; set; } - public float Spread { get; set; } - public DamageType DamageType { get; set; } public Entity SourceEntity { get; set; } public float Range { get; set; } - // Start is called before the first frame update - void Start() - { - - } - private void OnEnable() { _alive = true; @@ -45,81 +36,18 @@ public class Projectile : MonoBehaviour void Update() { if (SourceEntity == null) return; - + if(_alive) { var t = transform; - var position = t.position; Velocity -= Vector3.up * (Gravity * Time.deltaTime); Velocity *= max(0, 1 - Drag * Time.deltaTime); - var forward = Velocity.normalized; - t.forward = forward; - var ray = new Ray(position, Velocity); - foreach (var hit in Physics.RaycastAll(ray, Velocity.magnitude * Time.deltaTime, 1 | (1 << 17))) - { - var shield = hit.collider.GetComponent(); - var hull = hit.collider.GetComponent(); - if (shield) - { - var shieldBehavior = shield.Entity.Shield; - var shieldActive = shieldBehavior != null && shieldBehavior.Item.Active.Value; - var shieldAbsorbs = shieldActive && shieldBehavior.CanTakeHit(DamageType, Damage); - // F5 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): CanTakeHit is a pure query now -- - // read once into shieldAbsorbs, and break the shield exactly once, right here, at the point - // this hit is actually decided to route past it (to the hull collider RaycastAll returns - // as a separate entry further down this same loop) instead of being absorbed. The hull - // branch below re-queries CanTakeHit safely -- it is side-effect-free -- and will see Broken - // already true. - if (shieldActive && !shieldAbsorbs) shieldBehavior.Break(); - if (!shieldAbsorbs) continue; - if (shield.Entity != SourceEntity) - { - shieldBehavior.TakeHit(DamageType, Damage*DirectHitDamageMultiplier); - shield.ShowHit(hit.point, sqrt(Damage * DirectHitDamageMultiplier)); - } - } - else if (hull && !(hull.Entity.Shield != null && hull.Entity.Shield.Item.Active.Value && hull.Entity.Shield.CanTakeHit(DamageType, Damage))) - { - if (hull.Entity != SourceEntity) - { - hull.SendHit(Damage*DirectHitDamageMultiplier, Penetration, Spread, DamageType, SourceEntity, hit.textureCoord, forward); - transform.position = hit.point; - StartCoroutine(Kill()); - } - } - else - { - StartCoroutine(Kill()); - return; - } - - if (HitEffect != null) - { - var ht = HitEffect.Instantiate(); - ht.SetParent(hit.collider.transform); - ht.position = hit.point; - return; - } - } - + t.forward = Velocity.normalized; + transform.position += Velocity * Time.deltaTime; var distanceTraveled = (transform.position - StartPosition).magnitude; if(distanceTraveled > Range) StartCoroutine(Kill()); - if (AirburstRange > 1 && distanceTraveled > AirburstDistance) - { - StartCoroutine(Kill()); - var ht = HitEffect.Instantiate(); - ht.position = t.position; - foreach (var collider in Physics.OverlapSphere(t.position, AirburstRange, 1)) - { - var hull = collider.GetComponent(); - if (hull) - { - hull.SendSplash(Damage, DamageType, SourceEntity, (collider.transform.position - t.position).normalized); - } - } - } } } diff --git a/Assets/Scripts/Gameplay/Weapons/ProjectileManager.cs b/Assets/Scripts/Gameplay/Weapons/ProjectileManager.cs index a081f8c23..3ef9edc64 100644 --- a/Assets/Scripts/Gameplay/Weapons/ProjectileManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/ProjectileManager.cs @@ -10,27 +10,24 @@ public class ProjectileManager : InstantWeaponEffectManager public Prototype ProjectilePrototype; public bool InheritVelocity; - public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target) + public override void Fire(InstantWeapon weapon, EquippedItem item, EntityInstance source, EntityInstance target, int shotId) { var p = ProjectilePrototype.Instantiate(); var hp = source.Entity.Hardpoints[item.Position.x, item.Position.y]; var barrel = source.GetBarrel(hp); var angle = weapon.Spread / 2; + p.ShotId = shotId; p.SourceEntity = source.Entity; p.Velocity = Quaternion.Euler( - Random.Range(-angle, angle), - Random.Range(-angle, angle), - Random.Range(-angle, angle)) * - barrel.forward * + Random.Range(-angle, angle), + Random.Range(-angle, angle), + Random.Range(-angle, angle)) * + barrel.forward * weapon.Velocity; p.StartPosition = p.transform.position = barrel.position + p.Velocity * (Random.value * Time.deltaTime); if(InheritVelocity) p.Velocity += new Vector3(source.Entity.Velocity.x, 0, source.Entity.Velocity.y); - p.Damage = weapon.Damage; p.Range = weapon.Range; - p.Penetration = weapon.Penetration; - p.Spread = weapon.DamageSpread; - p.DamageType = weapon.WeaponData.DamageType; p.Zone = source.Entity.Zone; p.AirburstDistance = target != null ? length(source.Entity.Position - target.Entity.Position) : (weapon.Range * .75f); p.Trail.Clear(); diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index 0450c50d7..019198f36 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -102,21 +102,19 @@ public class CombatState : BaseState var testWeapon = _agent.Ship.WeaponGroups[selectedGroup].weapons.First(); if(testWeapon.Velocity > 1) { + // Cut 3 (docs/fire-control-cut.md): the one prediction function, shared with FireControl.Fire's + // own snapshot and the roll's deviation measurement -- two separate predictions here and in the + // roll would let an AI aim at one point and get judged for missing a different one. var targetHullData = _agent.ItemManager.GetData(target.Hull) as HullData; - var targetVelocity = float3(target.Velocity.x, 0, target.Velocity.y); - var shipVelocity = float3(_agent.Ship.Velocity.x, 0, _agent.Ship.Velocity.y); - var predictedPosition = first_order_intercept( - _agent.Ship.Position, - float3.zero, - testWeapon.Velocity, - target.Position, - targetVelocity - ); + var predictedPosition = FireControl.PredictedIntercept(testWeapon, _agent.Ship, target); predictedPosition.y = _agent.Ship.Zone.GetHeight(predictedPosition.xz) + targetHullData.GridOffset; toTarget = normalize(predictedPosition - _agent.Ship.Position); } - - var shouldFire = FireControl.InArc(testWeapon.Item, toTarget); + + // Cut 3: replaces Cut 1's bare InArc, which HitProbability already subsumes (it returns zero out + // of arc) -- this is the AI's own "worth it" heuristic layered on top of the shared gate, not a + // second gate. + var shouldFire = FireControl.HitProbability(testWeapon, _agent.Ship, target) >= _agent.Settings.AgentMinHitProbability; foreach (var weapon in _agent.Ship.WeaponGroups[selectedGroup].weapons) { if (shouldFire) diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index 1e0185ed1..f87009974 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -70,7 +70,9 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo public event Action OnReloadBegin; public event Action OnReloadComplete; public event Action OnCooldownComplete; - public event Action OnFire; + // Cut 3 (docs/fire-control-cut.md): carries the ShotId FireControl.Fire assigned, so a Unity effect + // manager can bind its presentation to the one shot it belongs to instead of applying damage itself. + public event Action OnFire; public virtual void ResetEvents() { @@ -96,6 +98,11 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo { // Safed: shooter has a target and hasn't declared hostility toward it. if (!StanceAllowsFire) return; + // Cut 3 (docs/fire-control-cut.md, Q2): arc-gated the same way AI and turret fire are (Weapon. + // ArcAllowsFire), and applied here because this is the one place every shooter's trigger passes + // through -- a player's action-bar Activate() reaches this exactly the same way Combat.cs's and + // TurretController.cs's Activate() calls do. + if (!ArcAllowsFire) return; // If 1 ammo is consumed per burst, perform ammo and energy consumption here // UseAmmo returns false when triggering reload; cancel firing if that is the case @@ -230,7 +237,10 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo _burstRemaining--; _burstTimer -= _burstInterval; - OnFire?.Invoke(); + // Cut 3: this is fire authority's one entry point. FireControl.Fire freezes the payload snapshot + // (Q6 -- the gun's own stats at this exact instant, base.Execute(dt) above already refreshed them + // this tick) and queues a PendingShot; nothing downstream re-evaluates a stat. + OnFire?.Invoke(FireControl.Fire(this, Item, Entity)); if(!firedThisFrame) { Item.FireAudioEvent(WeaponAudioEvent.Fire); diff --git a/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs index 665471184..7499ab6f8 100644 --- a/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs @@ -59,7 +59,13 @@ public class LockWeapon : InstantWeapon { get => saturate(_lock); } - + + // Cut 3 (docs/fire-control-cut.md): the same threshold CanFire above already gates firing on, exposed so + // FireControl.HitProbability can zero an unlocked LockWeapon's probability for AI/HUD decisions made + // before a shot is ever attempted. + public bool IsLocked => _lock > .99f; + + public LockWeapon(LockWeaponData data, EquippedItem item) : base(data, item) { _data = data; diff --git a/Assets/Scripts/ServerShared/Behaviors/TurretController.cs b/Assets/Scripts/ServerShared/Behaviors/TurretController.cs index 6b67cc26b..4a2fdab63 100644 --- a/Assets/Scripts/ServerShared/Behaviors/TurretController.cs +++ b/Assets/Scripts/ServerShared/Behaviors/TurretController.cs @@ -78,7 +78,10 @@ public class TurretController : Behavior, IInitializableBehavior foreach (var x in _weapons) { var data = x.Data as WeaponData; - var fire = FireControl.InArc(x.Item, Entity.LookDirection); + // Cut 3 (docs/fire-control-cut.md): the same gate and threshold Combat.cs uses -- HitProbability + // already subsumes the bare arc test (zero out of arc), so this is the "worth it" heuristic, not + // a parallel arc check. + var fire = FireControl.HitProbability(x, Entity, Entity.Target.Value) >= Entity.ItemManager.GameplaySettings.AgentMinHitProbability; if (x.Evaluate(data.Range) > dist && fire) { x.Activate(); diff --git a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs index b7e8af738..22a5e2f21 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs @@ -100,6 +100,24 @@ public abstract class Weapon : Behavior, IActivatedBehavior // With no target set, behaviour is unchanged (nothing to be safe about). public bool StanceAllowsFire => Entity.Target.Value == null || Entity.IsHostileTo(Entity.Target.Value); + // Cut 3 (docs/fire-control-cut.md, Q2): player fire is arc-gated like everyone else's. This is the same + // predicate (FireControl.InArc) Combat.cs and TurretController.cs gate AI and turret fire with, read at + // the one point every shooter's trigger passes through (InstantWeapon.Trigger) -- not a parallel check, and + // not the AI's own AgentMinHitProbability heuristic, which is a "worth it" decision layered above this + // gate, not the gate itself. With no target set, behaviour is unchanged (nothing to bear on). A target at + // this weapon's own exact position leaves the bearing undefined rather than out of arc -- point-blank + // range can't fail a bearing test. + public bool ArcAllowsFire + { + get + { + var target = Entity.Target.Value; + if (target == null) return true; + var toTarget = target.Position - Entity.Position; + return lengthsq(toTarget) < 1e-6f || FireControl.InArc(Item, toTarget); + } + } + public Weapon(WeaponData data, EquippedItem item) : base(data, item) { _data = data; diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index b1c22245a..a28cc1be9 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -316,6 +316,85 @@ public abstract class Entity ? TargetItem.Value : null; + // Cut 3 (docs/fire-control-cut.md): the damage rule moved verbatim from EntityInstance.cs's Unity + // HullCollider subscription (DamageSchematic) -- same arithmetic, same order, same thresholds. The only + // thing that changed is the owner: this entity's own schematic, armor and hull, not a Unity component + // reacting to a physics collision. + public void DamageSchematic(float damage, Shape hitShape) + { + var hullData = ItemManager.GetData(Hull) as HullData; + foreach (var v in hitShape.Coordinates) + hitShape[v] = hitShape[v] && hullData.Shape[v]; + + float hullDamage = 0; + var damagePerCell = damage / hitShape.Coordinates.Length; + foreach (var v in hitShape.Coordinates) + { + var d = damagePerCell; + + // Subtract surface damage from armor, passing on the remainder to the item and then to the hull + var prev = Armor[v.x, v.y]; + Armor[v.x, v.y] = max(prev - d, 0); + ArmorDamage.OnNext((v, d)); + d = max(d - prev, 0); + + if (d > 0.1f) + { + var item = GearOccupancy[v.x, v.y]; + if (item != null) + { + prev = item.EquippableItem.Durability; + item.EquippableItem.Durability = max(prev - d, 0); + ItemDamage.OnNext((item, d)); + d = max(d - prev, 0); + } + } + + hullDamage += d; + } + + if (hullDamage > .1f) + { + Hull.Durability -= hullDamage; + HullDamage.OnNext(hullDamage); + } + } + + // Cut 3: the shape construction moved from EntityInstance.cs's Unity HullCollider subscription, with two + // changes forced by R7 (the simulation is 2D): the hit cell is the rolled Cell FireControl already chose, + // not a UV texture coordinate; and the penetration march rotates the firer-to-target direction into this + // entity's own frame with a planar rotation by -Direction, in place of transform.InverseTransformDirection + // -- no 3D transform anywhere in the march. `hitDirection` is the planar (x,z) firer-to-target unit vector. + public void ApplyHit(Entity source, int2 cell, float spread, float penetration, float damage, float2 hitDirection) + { + IncomingHit.OnNext(source); + + var hullData = ItemManager.GetData(Hull) as HullData; + var hitShape = new Shape(hullData.Shape.Width, hullData.Shape.Height); + hitShape[cell] = true; + + for (var i = 0; i < (int) floor(spread + .5f); i++) + hitShape = hitShape.Expand(); + + if (penetration > .5f) + { + var forward = normalize(Direction); + var right = float2(forward.y, -forward.x); + var penetrationVector = normalize(float2(dot(hitDirection, right), dot(hitDirection, forward))); + + var penetrationPoint = (float2) cell + float2(.5f); + var penetrationDistance = 0f; + while (penetrationDistance < penetration && hullData.Shape[int2(penetrationPoint)]) + { + penetrationDistance += .5f; + hitShape[int2(penetrationPoint)] = true; + penetrationPoint += penetrationVector * .5f; + } + } + + DamageSchematic(damage, hitShape); + } + // Another entity's stance toward THIS one, as far as this entity can perceive it: unknown (null) // until this entity detects them, mirroring the existing detection model (VisibleEntities, driven // by EntityInfoGathered crossing TargetDetectionInfoThreshold). Detected but not yet hostility-rated diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 19089b7d0..74fc8e358 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -2,9 +2,13 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +using System.Collections.Generic; using System.Linq; using CultMath; using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; // Cut 1 (docs/fire-control-cut.md): geometry -- whether a weapon bears on a point is decided here and // nowhere else -- no prefab, transform, ArticulationPoint or renderer may influence it (R6, R7). Mount @@ -100,4 +104,300 @@ public static class FireControl var system = entity.GetBehavior(); return system != null && system.Item.Active.Value ? system.Tracking : 0f; } + + // Cut 3, the risk this map names explicitly: Combat.cs used to run its own first_order_intercept call to + // aim, while the roll measured deviation against a separately-computed prediction -- two functions + // claiming to answer the same question will eventually disagree, and an AI would then aim at one point and + // be judged against another. This is the one function; CombatState (and FireControl.Fire below) both call + // it instead of touching CultMath.first_order_intercept directly. Planar: source velocity is never fed in + // (no shooter here leads its own motion into the shot), matching every existing call site this replaces. + public static float3 PredictedIntercept(Weapon weapon, Entity source, Entity target) + { + var targetVelocity = float3(target.Velocity.x, 0, target.Velocity.y); + return weapon.Velocity > .01f + ? first_order_intercept(source.Position, float3.zero, weapon.Velocity, target.Position, targetVelocity) + : target.Position; + } + + // Cut 3, R1/R3: the targeting solution (0b table) -- pure, no draw, recomputed fresh on every call. AI + // reads this to decide whether a shot is worth taking; the HUD reads it to draw a number; Fire below reads + // it once, at the instant the trigger is pulled, and freezes the result into the shot (R10, Q6). Two reads + // a tick apart may differ; nothing here or anywhere else caches one. + public static float HitProbability(Weapon weapon, Entity source, Entity target) + { + if (target == null) return 0f; + if (!source.VisibleEntities.Contains(target)) return 0f; + + var toTarget = target.Position - source.Position; + var range = length(toTarget); + if (range < weapon.MinRange || range > weapon.Range) return 0f; + if (weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked) return 0f; + if (!InArc(weapon.Item, toTarget)) return 0f; + + var settings = source.ItemManager.GameplaySettings; + var info = source.EntityInfoGathered.TryGetValue(target, out var gathered) ? gathered : 0f; + var pSensor = saturate(unlerp(settings.TargetDetectionInfoThreshold, Resolution(source), info)); + + float pSpread; + if (weapon.Spread > 0) + { + var targetHull = source.ItemManager.GetData(target.Hull) as HullData; + var halfExtent = .5f * max(targetHull.Shape.Width, targetHull.Shape.Height) * settings.SchematicCellSize; + var angularRadius = degrees(atan(halfExtent / range)); + pSpread = saturate(angularRadius / (weapon.Spread / 2f)); + } + else pSpread = 1f; + + return Accuracy(source) * pSensor * pSpread; + } + + // Cut 3, R1: called once per burst step from InstantWeapon.Execute. Computes the predicted intercept and + // flight time, freezes the payload snapshot (R10, Q6 -- the gun that fired it, not a re-read later), and + // queues a PendingShot for Zone.Step to age and eventually resolve. Returns the ShotId so the caller's + // OnFire event can carry it to presentation. + public static int Fire(Weapon weapon, EquippedItem item, Entity source) + { + var zone = source.Zone; + var target = source.Target.Value; + var now = zone.Time; + + // R1's engage gate and probability, evaluated now and frozen: nothing at commit time re-reads a stat, + // an info level or a range. Only live target *position* (deviation) is read again, at commit. + var pBase = target != null ? HitProbability(weapon, source, target) : 0f; + + var targetVelocity = float3.zero; + var targetPosition = source.Position; + var flightTime = 0f; + var intercept = source.Position; + if (target != null) + { + targetVelocity = float3(target.Velocity.x, 0, target.Velocity.y); + targetPosition = target.Position; + intercept = PredictedIntercept(weapon, source, target); + var range = length(targetPosition - source.Position); + flightTime = weapon.Velocity > .01f ? range / weapon.Velocity : 0f; + } + + var commitHorizon = source.ItemManager.GameplaySettings.CommitHorizon; + var shot = new PendingShot + { + ShotId = zone.NextShotId(), + Source = source, + Target = target, + Weapon = item, + Aimed = source.ResolvedTargetItem, + Damage = weapon.Damage, + Penetration = weapon.Penetration, + DamageSpread = weapon.DamageSpread, + DamageType = weapon.WeaponData.DamageType, + PBase = pBase, + Tracking = Tracking(source), + Precision = Precision(source), + FireTime = now, + FireTargetPosition = targetPosition, + FireTargetVelocity = targetVelocity, + PredictedIntercept = intercept, + FlightTime = flightTime, + ArrivalTime = now + flightTime, + CommitTime = now + max(0f, flightTime - commitHorizon), + Committed = false + }; + + zone.PendingShots.Add(shot); + return shot.ShotId; + } + + // Cut 3, R4: ages every shot in the zone, commits the ones that have reached their horizon and resolves + // the ones that have arrived. Called from Zone.Update after the entity loop. A shot whose source or target + // has left the zone resolves as a miss and is removed outright (0b table), whichever stage it is at. + public static void Step(Zone zone, float dt) + { + var shots = zone.PendingShots; + if (shots.Count == 0) return; + + var now = zone.Time; + for (var i = shots.Count - 1; i >= 0; i--) + { + var shot = shots[i]; + + var sourceGone = !zone.Entities.Contains(shot.Source); + var targetGone = shot.Target != null && !zone.Entities.Contains(shot.Target); + if (sourceGone || targetGone) + { + if (!shot.Committed) + { + shot.Outcome = MakeOutcome(shot, false, false, int2.zero, null, now); + zone.ShotCommitted.OnNext(shot.Outcome); + } + zone.ShotResolved.OnNext(shot.Outcome); + shots.RemoveAt(i); + continue; + } + + if (!shot.Committed && now >= shot.CommitTime) + { + shot.Outcome = Commit(zone, shot, now); + shot.Committed = true; + zone.ShotCommitted.OnNext(shot.Outcome); + shots[i] = shot; + } + + if (shot.Committed && now >= shot.ArrivalTime) + { + Apply(shot); + zone.ShotResolved.OnNext(shot.Outcome); + shots.RemoveAt(i); + } + } + } + + // R3/R4: the one roll. p_base was frozen at fire; the only thing measured live is how far the target has + // actually strayed, by now, from where a straight-line projection of its fire-time velocity said it would + // be -- forgiven by the targeting system's Tracking, likewise frozen at fire. A shot gated to zero at fire + // (out of arc, out of range, unlocked, undetected) draws nothing here (short-circuit below): the RNG + // sequence is exactly as if the shot had never queued. + private static ShotOutcome Commit(Zone zone, PendingShot shot, float now) + { + var random = shot.Source.ItemManager.Random; + var p = shot.PBase; + if (p > 0f && shot.Target != null) + { + var elapsed = now - shot.FireTime; + var predicted = shot.FireTargetPosition + shot.FireTargetVelocity * elapsed; + var deviation = length((shot.Target.Position - predicted).xz); + var pDeviation = shot.Tracking > 0f + ? saturate(1f - deviation / shot.Tracking) + : (deviation < .01f ? 1f : 0f); + p *= pDeviation; + } + + var hit = p > 0f && random.NextFloat() < p; + var cell = int2.zero; + EquippedItem aimed = null; + var shielded = false; + + if (hit) + { + var hullData = shot.Source.ItemManager.GetData(shot.Target.Hull) as HullData; + var aimedCells = shot.Aimed != null ? CellsOf(shot.Target, shot.Aimed) : null; + if (aimedCells != null && aimedCells.Length > 0 && random.NextFloat() < shot.Precision) + { + aimed = shot.Aimed; + cell = aimedCells[random.NextInt(aimedCells.Length)]; + } + else + { + var coords = hullData.Shape.Coordinates; + cell = coords[random.NextInt(coords.Length)]; + } + + var shield = shot.Target.Shield; + if (shield != null && shield.Item.Active.Value && shield.CanTakeHit(shot.DamageType, shot.Damage)) + shielded = true; + } + + shot.Source.ItemManager.Random = random; + return MakeOutcome(shot, hit, shielded, cell, aimed, now); + } + + // R4: the commit is authoritative and immutable from here on -- this only performs what Commit already + // decided. The one owner of the shield-absorbs-or-hull-takes-it branch (it used to exist seven times, + // once per Unity effect). + private static void Apply(PendingShot shot) + { + if (!shot.Outcome.Hit) return; + + if (shot.Outcome.Shielded) + { + shot.Target.Shield.TakeHit(shot.DamageType, shot.Damage); + return; + } + + var hitDirection = float2(0, 1); + var toTarget = (shot.Target.Position - shot.Source.Position).xz; + if (lengthsq(toTarget) > 1e-6f) hitDirection = normalize(toTarget); + + shot.Target.ApplyHit(shot.Source, shot.Outcome.Cell, shot.DamageSpread, shot.Penetration, shot.Damage, hitDirection); + } + + private static ShotOutcome MakeOutcome(PendingShot shot, bool hit, bool shielded, int2 cell, EquippedItem aimed, float now) + { + return new ShotOutcome + { + ShotId = shot.ShotId, + Source = shot.Source, + Target = shot.Target, + Weapon = shot.Weapon, + Hit = hit, + Shielded = shielded, + Aimed = aimed, + Cell = cell, + ArrivalIn = max(0f, shot.ArrivalTime - now), + DamageType = shot.DamageType + }; + } + + // The cells of the target's hull schematic actually occupied by `item` -- GearOccupancy is the one source + // of truth for where an equipped item's footprint lands, the same table Entity.DamageSchematic reads. + private static int2[] CellsOf(Entity target, EquippedItem item) + { + var hullData = target.ItemManager.GetData(target.Hull) as HullData; + var cells = new List(); + foreach (var v in hullData.Shape.Coordinates) + if (target.GearOccupancy[v.x, v.y] == item) + cells.Add(v); + return cells.ToArray(); + } +} + +// Cut 3 (docs/fire-control-cut.md, 0b table): a shot in flight. Born in FireControl.Fire, aged by +// FireControl.Step (called from Zone.Update after the entity loop), never serialised -- a zone saved mid- +// flight loses its shots, which is correct, a save is a scene boundary. Zone owns the collection and the +// ShotId; FireControl owns every transition. Nothing else may add, remove or mutate one. +public struct PendingShot +{ + public int ShotId; + public Entity Source; + public Entity Target; + public EquippedItem Weapon; + public EquippedItem Aimed; + + // The frozen payload (R10, Q6): the gun's own stats at the instant the trigger was pulled. Nothing + // downstream re-evaluates any of these. + public float Damage; + public float Penetration; + public float DamageSpread; + public DamageType DamageType; + public float PBase; + public float Tracking; + public float Precision; + + public float3 FireTargetPosition; + public float3 FireTargetVelocity; + public float3 PredictedIntercept; + public float FlightTime; + public float FireTime; + public float CommitTime; + public float ArrivalTime; + + public bool Committed; + public ShotOutcome Outcome; +} + +// Cut 3 (docs/fire-control-cut.md, 0b table): a commit. Created once, at ArrivalTime - CommitHorizon (or at +// fire time when the flight is shorter than the horizon), immutable from then on, published once on +// Zone.ShotCommitted and again (unchanged) on Zone.ShotResolved at arrival. Presentations are the only +// readers, and nothing outside presentation may read one to change state. +public sealed class ShotOutcome +{ + public int ShotId; + public Entity Source; + public Entity Target; + public EquippedItem Weapon; + public bool Hit; + public bool Shielded; + public EquippedItem Aimed; + public int2 Cell; + public float ArrivalIn; + public DamageType DamageType; } diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index 32426fc29..1cfcb53a1 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -224,6 +224,22 @@ public class GameplaySettings public float AgentRangeExponent = .25f; public float AgentForwardLerp = .5f; public float AgentMaxForwardDistance = 50; + + // Cut 3 (docs/fire-control-cut.md): first guesses: the headless fixture is the tuning harness, the + // operator smoke is the arbiter (§ Risks). + // + // How long before impact a shot's outcome commits (R4). Deviation counts up to this horizon; after it + // the result is frozen and the rest of the flight is pure choreography. + public float CommitHorizon = .5f; + + // World units per hull-schematic cell, used only by FireControl.HitProbability's angular-size term + // (pSpread) to turn a hull's cell footprint into a real-world silhouette size at range. + public float SchematicCellSize = 2f; + + // The floor an AI's own predicted hit probability (FireControl.HitProbability) must clear before it counts + // a firing solution worth taking (Combat.cs, TurretController.cs). Player fire is gated on arc alone + // (Weapon.ArcAllowsFire, Q2) -- this threshold is an AI fire-discipline heuristic, not part of the roll. + public float AgentMinHitProbability = .2f; } [Serializable, MessagePackObject(keyAsPropertyName: true), JsonObject] diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index edfd043bf..36e95ba57 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -33,6 +33,16 @@ public class Zone private Random _random; public List Agents = new List(); + // Cut 3 (docs/fire-control-cut.md, 0b table): Zone owns the pending-shot collection and the ShotId + // namespace; FireControl owns every transition a shot goes through. ShotCommitted publishes once a shot's + // outcome is decided (R4's commit horizon); ShotResolved republishes the same, unchanged outcome at + // arrival, right before the shot is removed. Presentations are the only readers of either. + public List PendingShots = new List(); + public Subject ShotCommitted = new Subject(); + public Subject ShotResolved = new Subject(); + private int _nextShotId; + public int NextShotId() => ++_nextShotId; + private List BeltUpdates = new List(); public float Time @@ -148,6 +158,11 @@ public class Zone agent.Update(deltaTime); foreach (var entity in Entities.ToArray()) entity.Update(deltaTime); + + // Cut 3: after every entity has had its chance to fire this tick, age and resolve the shots that + // firing queued. A shot fired this tick with a flight time shorter than CommitHorizon commits and + // resolves in this same call. + FireControl.Step(this, deltaTime); } // Determine orbital position recursively, caching parent positions to avoid repeated calculations diff --git a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs new file mode 100644 index 000000000..1531b929d --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs @@ -0,0 +1,522 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using UniRx; +using Xunit; +using static CultMath.math; +using float3 = CultMath.float3; +using int2 = CultMath.int2; +using Random = CultMath.Random; + +// Cut 3 (docs/fire-control-cut.md): pins hit authority itself -- the roll's inputs, the commit horizon, frozen +// payloads (Q6), seeding (Q5), the arc gate on player fire (Q2), and the damage rule moved verbatim onto +// Entity. Builds its own shooter/target fixture rather than reusing Cut 1's or Cut 2's private helpers, the +// same convention those files established. Each test's own comment names the mutation that must kill it. +public sealed class FireAuthorityTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-fireauthority-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public FireAuthorityTests() => Directory.CreateDirectory(_root); + + private readonly List _openCaches = new List(); + + public void Dispose() + { + foreach (var c in _openCaches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static GameplaySettings TestSettings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetDetectionInfoThreshold = .1f, + FiringArc = 170, + CommitHorizon = .5f, + SchematicCellSize = 1f, + UnaidedAccuracy = .05f, + AgentMinHitProbability = 0f + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + private sealed class Engagement + { + public ItemManager Items; + public Zone Zone; + public Ship Shooter; + public Ship Target; + public EquippedItem WeaponItem; + public InstantWeapon Weapon; + } + + // A shooter (one Sensors-hardpoint weapon + a Tool-slot targeting system) and a target ship, both + // activated in the same zone, the target `targetRange` metres dead ahead (well within the wide default + // arc), fully detected and marked hostile so StanceAllowsFire and the reveal/detection gates never + // interfere with a test that isn't about them. + private Engagement Build( + GameplaySettings settings, + float damage = 10, float range = 1000, float minRange = 0, float velocity = 0, + float spread = 0, float damageSpread = 0, float penetration = 0, + float accuracy = 1, float resolution = 1, float precision = 0, float tracking = 1000, + float targetRange = 100, bool equipTargeting = true, float hullDurability = 1000, float armor = 0, + Action beforeActivate = null) + { + // 5x5 so InteriorCells (Shape.Shrink -- every cell with all 8 neighbours present) is the inner 3x3, + // room enough for more than one Tool item (ShieldTakesHit needs a Reactor and a Shield both). + var hullShape = new Shape(5, 5); + foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true; + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = hullShape, Durability = hullDurability, Mass = 1000, Armor = armor, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(hullData); + cache.Upsert(new GearData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new InstantWeaponData + { + Damage = Constant(damage), Range = Constant(range), MinRange = Constant(minRange), + Velocity = Constant(velocity), Spread = Constant(spread), DamageSpread = Constant(damageSpread), + Penetration = Constant(penetration), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } + } } + }); + cache.Upsert(new GearData + { + Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new TargetingSystemData + { + Accuracy = Constant(accuracy), Resolution = Constant(resolution), Precision = Constant(precision), Tracking = Constant(tracking) + } } + }); + // Authored for every engagement (harmless unless equipped): a shield/reactor pair strong and fast + // enough to fill from cold in a handful of ticks, for ShieldTakesHit. Catalog entries are looked up by + // name through this same already-open CultCache handle, which does not see writes made to it after + // this point without a reopen (GetByName is a build-time index) -- so these are authored up front + // rather than upserted later from inside a test's beforeActivate. + cache.Upsert(new GearData + { + Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new ReactorData { Charge = Constant(1000), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) } } + }); + cache.Upsert(new GearData + { + Name = "Shield", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new ShieldData { Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(1000), RefillDuration = Constant(.01f), RestoreDuration = Constant(.01f) } } + }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; + for (var i = 5; i <= 12; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooterHull = new EquippableItem { Data = hullRef, Durability = hullDurability, Lot = 1 }; + var shooter = new Ship(items, zone, shooterHull, new EntitySettings()); + + var gunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); + var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 2 }; + Assert.True(shooter.TryEquip(gun, new int2(0, 0))); + var weaponItem = shooter.Equipment.Single(e => e.EquippableItem == gun); + var weapon = (InstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon); + + if (equipTargeting) + { + var targetingRef = items.ItemData.RefOf(items.ItemData.GetByName("Targeting")); + var targeting = new EquippableItem { Data = targetingRef, Durability = 1, Lot = 3 }; + Assert.True(shooter.TryFindSpace(targeting, out var tpos)); + Assert.True(shooter.TryEquip(targeting, tpos)); + } + + var targetHull = new EquippableItem { Data = hullRef, Durability = hullDurability, Lot = 4 }; + var target = new Ship(items, zone, targetHull, new EntitySettings()); + // Entity._orderedEquipment is populated only by TryEquip -- a ship that never equips anything leaves + // it null and Entity.Update throws regardless of this cut (FireControlTests' own fixture notes the + // same rake). Equip a harmless gun so the target is an ordinarily-constructed ship. + var targetGunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); + var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 9 }; + Assert.True(target.TryEquip(targetGun, new int2(0, 0))); + + // Entity.TryEquip refuses once an entity is active ("don't allow equipping while deployed") -- any + // extra gear a test needs (a second aimable item, a shield, a reactor) must go on before Activate(). + beforeActivate?.Invoke(items, shooter, target); + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + shooter.Position = float3.zero; + target.Position = float3(0, 0, targetRange); + shooter.Target.Value = target; + shooter.EntityInfoGathered[target] = 1f; + shooter.SetIff(target, true); + + // A zero-dt warm-up tick populates the resolved weapon/targeting stats (UpdateStats runs inside + // Execute) before FireControl.Fire reads them, the same warm-up InputCapacitorTests uses. + zone.Update(0f); + + return new Engagement { Items = items, Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, Weapon = weapon }; + } + + private static InstantWeaponData DataOf(EquippedItem weaponItem) => + (InstantWeaponData) weaponItem.Data.Behaviors.Single(b => b is InstantWeaponData); + + // R1/Q5: two zones built identically, seeded identically, fire the same 50 shots (instant, so each + // commits and resolves in the tick it is stepped) and get byte-identical Hit/Cell/Shielded sequences. + // Mutation: FireControl draws from `new Random()` instead of the shooter's ItemManager.Random. + [Fact] + public void RollsAreSeeded() + { + List<(bool Hit, bool Shielded, int2 Cell)> Run(uint seed) + { + var e = Build(TestSettings(), accuracy: .6f, precision: .5f); + e.Items.Random = new Random(seed); + var results = new List<(bool, bool, int2)>(); + e.Zone.ShotResolved.Subscribe(outcome => results.Add((outcome.Hit, outcome.Shielded, outcome.Cell))); + for (var i = 0; i < 50; i++) + { + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + FireControl.Step(e.Zone, 0f); + } + return results; + } + + var a = Run(777u); + var b = Run(777u); + Assert.Equal(a, b); + } + + // FireControl.HitProbability, R1: zero with no draw when undetected or out of range; rises with info + // toward Resolution; falls with Spread; caps at UnaidedAccuracy with no targeting system equipped. + // Mutations: drop pSensor (probability stops depending on info); drop the range gate (probability nonzero + // outside [MinRange,Range]); drop the UnaidedAccuracy cap (an unaided shooter rolls as well as Accuracy). + [Fact] + public void ProbabilityFollowsInputs() + { + var e = Build(TestSettings(), accuracy: .8f, resolution: 1f, spread: 0f, targetRange: 100, range: 200); + + // Undetected: below TargetDetectionInfoThreshold (.1) -- zero regardless of everything else. + e.Shooter.EntityInfoGathered[e.Target] = 0f; + Assert.Equal(0f, FireControl.HitProbability(e.Weapon, e.Shooter, e.Target)); + + // Rising with info toward Resolution (1): more info, more probability, saturating at Resolution. + e.Shooter.EntityInfoGathered[e.Target] = .2f; + var low = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); + e.Shooter.EntityInfoGathered[e.Target] = .6f; + var mid = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); + e.Shooter.EntityInfoGathered[e.Target] = 1f; + var full = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); + Assert.True(low < mid); + Assert.True(mid <= full); + Assert.True(full > 0f); + + // Out of range: zero. + e.Target.Position = float3(0, 0, 500); + Assert.Equal(0f, FireControl.HitProbability(e.Weapon, e.Shooter, e.Target)); + e.Target.Position = float3(0, 0, 100); + + // Falling with Spread: a wide-spread weapon on a small target scores lower than a zero-spread one. + DataOf(e.WeaponItem).Spread = Constant(0f); + e.Weapon.Execute(0f); + var noSpread = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); + DataOf(e.WeaponItem).Spread = Constant(60f); + e.Weapon.Execute(0f); + var wideSpread = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); + Assert.True(wideSpread < noSpread); + + // Capped at UnaidedAccuracy with no targeting system: a second engagement, otherwise identical, but + // never equips one. + var unaided = Build(TestSettings(), accuracy: .8f, resolution: 1f, spread: 0f, targetRange: 100, range: 200, equipTargeting: false); + unaided.Shooter.EntityInfoGathered[unaided.Target] = 1f; + Assert.Equal(unaided.Items.GameplaySettings.UnaidedAccuracy, FireControl.HitProbability(unaided.Weapon, unaided.Shooter, unaided.Target), 3); + } + + // The gate is a gate, not a penalty: an out-of-arc commit consumes exactly zero draws from the shared RNG + // stream. Mutation: roll first and multiply the result by zero -- that still advances the stream by one + // draw, which this test would catch by seeing a different next value than an untouched stream produces. + [Fact] + public void OutOfArcConsumesNoDraw() + { + var e = Build(TestSettings()); + // Directly abeam -- outside even the wide 170-degree default arc dead ahead. + e.Target.Position = float3(100, 0, 0); + + const uint seed = 4242u; + var untouched = new Random(seed); + var expected = untouched.NextFloat(); + + e.Items.Random = new Random(seed); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + FireControl.Step(e.Zone, 0f); + var actual = e.Items.Random.NextFloat(); + + Assert.Equal(expected, actual); + } + + // R3: no durability change before the shot arrives (range/Velocity after this cut's fire), and a target + // removed mid-flight takes none. Mutation: apply the outcome at fire/commit time instead of arrival. + [Fact] + public void ShotResolvesOnArrival() + { + var e = Build(TestSettings(), damage: 100, velocity: 10, accuracy: 1, resolution: 1, spread: 0, targetRange: 50); + // range 50, velocity 10 -> flight time 5s, well past the .5s commit horizon. + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var before = e.Target.Hull.Durability; + + e.Zone.Update(4f); // still short of arrival (5s) + Assert.Equal(before, e.Target.Hull.Durability); + + e.Zone.Update(2f); // now past arrival + Assert.True(e.Target.Hull.Durability < before); + } + + // 0b table: a shot whose target left the zone resolves as a miss and is removed, taking nothing. + [Fact] + public void DeadEntityStopsTakingShots() + { + var e = Build(TestSettings(), damage: 100, velocity: 10, accuracy: 1, resolution: 1, spread: 0, targetRange: 50); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var before = e.Target.Hull.Durability; + + e.Zone.Entities.Remove(e.Target); + e.Zone.Update(10f); // well past arrival + + Assert.Equal(before, e.Target.Hull.Durability); + Assert.Empty(e.Zone.PendingShots); + } + + // R4: ShotCommitted fires exactly CommitHorizon before ShotResolved, once per shot. Mutation: publish only + // at arrival, or publish twice. + [Fact] + public void OutcomeCommitsBeforeImpact() + { + var e = Build(TestSettings(), velocity: 10, accuracy: 1, resolution: 1, spread: 0, targetRange: 50); + var commits = new List(); + var resolves = new List(); + e.Zone.ShotCommitted.Subscribe(_ => commits.Add(e.Zone.Time)); + e.Zone.ShotResolved.Subscribe(_ => resolves.Add(e.Zone.Time)); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + for (var i = 0; i < 100; i++) e.Zone.Update(.1f); // 10s of ticks, well past a 5s flight + + Assert.Single(commits); + Assert.Single(resolves); + Assert.Equal(e.Items.GameplaySettings.CommitHorizon, resolves[0] - commits[0], 2); + } + + // R4's second half: deviation counts up to the commit horizon and not after. A target that jinks off the + // predicted straight-line path before commit is judged on that jink; the identical jink after commit + // changes nothing, because the outcome is already frozen. Mutation: measure deviation at arrival instead + // of commit (both jinks would then matter), or measure it at fire (neither would). + [Fact] + public void EvasionCountsUntilCommitAndNotAfter() + { + // Tracking small enough that a real jink defeats it, Precision moot (no aim), full accuracy/detection. + GameplaySettings Settings() => TestSettings(); + + bool RunWithJink(float jinkTime, float commitHorizon) + { + var settings = Settings(); + settings.CommitHorizon = commitHorizon; + var e = Build(settings, damage: 10, velocity: 10, accuracy: 1, resolution: 1, spread: 0, tracking: 1f, targetRange: 50); + // Target sits still (velocity 0) so the "predicted" position is just its fire-time position; + // jinking means moving it away from that point before the check time. + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var t = 0f; + const float dt = .05f; + var jinked = false; + var before = e.Target.Hull.Durability; + while (t < 6f && e.Zone.PendingShots.Count > 0) + { + if (!jinked && t >= jinkTime) + { + e.Target.Position += float3(50, 0, 0); // far outside Tracking's forgiveness + jinked = true; + } + e.Zone.Update(dt); + t += dt; + } + return e.Target.Hull.Durability < before; // true if the shot still landed + } + + // Flight time 5s, horizon 0.5s -> commit at t=4.5s. A jink well before commit (t=1s) is judged. + Assert.False(RunWithJink(jinkTime: 1f, commitHorizon: .5f)); + // The identical jink AFTER commit (t=4.6s, horizon .5s means commit at 4.5s) must not matter. + Assert.True(RunWithJink(jinkTime: 4.6f, commitHorizon: .5f)); + } + + // R4's graceful degradation: a velocity-0 (or otherwise sub-horizon) weapon commits and resolves within + // the same tick it fires, publishing both events, in order. Mutation: skip the commit for short flights + // (only ShotResolved fires, or damage applies with no ShotCommitted at all). + [Fact] + public void ShortFlightCommitsAtFire() + { + var e = Build(TestSettings(), velocity: 0, accuracy: 1, resolution: 1, spread: 0, damage: 10); + var order = new List(); + e.Zone.ShotCommitted.Subscribe(_ => order.Add("committed")); + e.Zone.ShotResolved.Subscribe(_ => order.Add("resolved")); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.016f); // one ordinary tick + + Assert.Equal(new[] { "committed", "resolved" }, order); + Assert.Empty(e.Zone.PendingShots); + } + + // R10/Q6: a shot's parameters freeze when the trigger is pulled. Changing the weapon's authored Damage + // stat (and refreshing it into the live behaviour before arrival) must not change what an already-fired + // shot deals. Mutation: re-evaluate Damage at arrival instead of using the frozen snapshot. + [Fact] + public void OutcomeIsSnapshotNotReread() + { + var e = Build(TestSettings(), damage: 10, velocity: 10, accuracy: 1, resolution: 1, spread: 0, targetRange: 50, armor: 0); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var before = e.Target.Hull.Durability; + + // Mid-flight (before arrival at 5s): the gun's Damage stat collapses, and a fresh Execute re-reads it + // into the live Weapon.Damage -- simulating the "power cut, heat spike" scenario R10 names. + e.Zone.Update(2f); + DataOf(e.WeaponItem).Damage = Constant(99999f); + e.Weapon.Execute(0f); + Assert.Equal(99999f, e.Weapon.Damage, 1); + + e.Zone.Update(4f); // past arrival + var dealt = before - e.Target.Hull.Durability; + Assert.True(dealt > 0f); + Assert.True(dealt < 100f); // nowhere near the inflated 99999 value -- the original 10 landed + } + + // R5's payoff: with Precision 1 and p 1, only the aimed item's durability falls. + // Mutation: the roll ignores Aimed and always picks a uniform random hull cell. + [Fact] + public void AimedHitLandsOnSelectedItem() + { + EquippableItem aimedGear = null; + var e = Build(TestSettings(), damage: 50, velocity: 0, accuracy: 1, resolution: 1, spread: 0, precision: 1, armor: 0, + beforeActivate: (items, shooter, target) => + { + // A second, distinct interior item on the target to aim at, equipped before Activate() (Entity. + // TryEquip refuses once an entity is active). + var targetingRef = items.ItemData.RefOf(items.ItemData.GetByName("Targeting")); + aimedGear = new EquippableItem { Data = targetingRef, Durability = 1, Lot = 5 }; + Assert.True(target.TryFindSpace(aimedGear, out var pos)); + Assert.True(target.TryEquip(aimedGear, pos)); + }); + var aimedItem = e.Target.Equipment.Single(x => x.EquippableItem == aimedGear); + + e.Shooter.EntityInfoGathered[e.Target] = 1f; // fully revealed + Assert.True(e.Shooter.TrySelectTargetItem(aimedItem)); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.1f); + + Assert.True(aimedItem.EquippableItem.Durability < 1f); + } + + // The damage rule moved verbatim (Entity.DamageSchematic/ApplyHit): damage above armor plus item + // durability zeroes the item and the remainder hits the hull. Mutation: skip subtracting armor, or skip + // passing the remainder on to the hull. + [Fact] + public void HardpointHitDamagesItemThenHull() + { + var cell = new int2(1, 1); + EquippableItem occupant = null; + var e = Build(TestSettings(), armor: 2, hullDurability: 1000, + beforeActivate: (items, shooter, target) => + { + // No item occupies (1,1) in this fixture's hull -- occupy it before Activate() so the + // item-then-hull chain has an item to pass through at a controlled cell. + var occupantRef = items.ItemData.RefOf(items.ItemData.GetByName("Targeting")); + occupant = new EquippableItem { Data = occupantRef, Durability = 3, Lot = 6 }; + Assert.True(target.TryEquip(occupant, cell)); + }); + var occupantItem = e.Target.Equipment.Single(x => x.EquippableItem == occupant); + + var beforeHull = e.Target.Hull.Durability; + e.Target.ApplyHit(e.Shooter, cell, spread: 0, penetration: 0, damage: 10, hitDirection: float2(0, 1)); + + Assert.Equal(0f, e.Target.Armor[cell.x, cell.y]); // 2 armor consumed + Assert.Equal(0f, occupantItem.EquippableItem.Durability); // 3 item durability consumed + Assert.Equal(beforeHull - 5f, e.Target.Hull.Durability, 2); // remaining 5 hits the hull + } + + // R7: the penetration march is planar, rotated into the target's own facing. Firing straight into a + // target's nose (hitDirection opposed to Direction) marches the hit shape toward the target's stern, along + // +Y in this hull's local grid regardless of which way the entity happens to be facing in world space. + // Mutation: skip rotating by Direction and march along the raw world hitDirection instead -- a target + // facing anywhere other than the world +Z axis would then penetrate the wrong cells. + [Fact] + public void PenetrationMarchIsPlanar() + { + var e = Build(TestSettings(), armor: 5); + e.Target.Direction = float2(1, 0); // facing world +X, not the default +Z + + var cell = new int2(1, 0); + var hitDirection = normalize(float2(1, 0)); // the shot arrived travelling along the target's own forward + e.Target.ApplyHit(e.Shooter, cell, spread: 0, penetration: 1.5f, damage: 30, hitDirection: hitDirection); + + // Rotating hitDirection into this Direction's frame (forward=(1,0), right=(0,-1)) makes the local + // penetration vector (0,1): the march should have advanced from (1,0) into (1,1), consuming that + // cell's armor too. Mutation: skip the rotation and march along the raw world hitDirection (1,0) + // instead, which would consume (2,0) rather than (1,1). + Assert.True(e.Target.Armor[1, 1] < e.Target.MaxArmor[1, 1]); + Assert.Equal(e.Target.MaxArmor[2, 0], e.Target.Armor[2, 0]); // untouched -- proves it wasn't a world-space march + } + + // The absorb rule now exists once, in FireControl: an active shield that CanTakeHit absorbs, and the + // schematic is untouched. Mutation: remove the shield branch (the hit always reaches the hull). + [Fact] + public void ShieldTakesHit() + { + var e = Build(TestSettings(), damage: 10, velocity: 0, accuracy: 1, resolution: 1, spread: 0, armor: 0, + beforeActivate: (items, shooter, target) => + { + // The Reactor/Shield catalog entries are authored in Build's own upsert batch (see its + // comment). Equipped before Activate() (Entity.TryEquip refuses once an entity is active). + var reactorRef = items.ItemData.RefOf(items.ItemData.GetByName("Reactor")); + var reactor = new EquippableItem { Data = reactorRef, Durability = 10, Lot = 7 }; + Assert.True(target.TryFindSpace(reactor, out var rpos)); + Assert.True(target.TryEquip(reactor, rpos)); + + var shieldRef = items.ItemData.RefOf(items.ItemData.GetByName("Shield")); + var shield = new EquippableItem { Data = shieldRef, Durability = 10, Lot = 8 }; + Assert.True(target.TryFindSpace(shield, out var spos)); + Assert.True(target.TryEquip(shield, spos)); + }); + e.Target.Shield.Item.Enabled.Value = true; + + for (var i = 0; i < 20; i++) e.Zone.Update(.1f); // charge the reserve + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 10)); + + var beforeHull = e.Target.Hull.Durability; + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.1f); + + Assert.Equal(beforeHull, e.Target.Hull.Durability); + } +} diff --git a/tests/Aetheria.Shared.Tests/FireControlTests.cs b/tests/Aetheria.Shared.Tests/FireControlTests.cs index 4c9f059cf..49753d21f 100644 --- a/tests/Aetheria.Shared.Tests/FireControlTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlTests.cs @@ -206,6 +206,15 @@ public sealed class FireControlTests : IDisposable target.Position = float3(0, 0, 10); // dead ahead of the None-rotation hardpoint, well within range and arc shooter.Target.Value = target; + // Cut 3: CombatState now gates on FireControl.HitProbability, which is zero for an undetected target + // (the target is not in VisibleEntities) regardless of arc -- cross TargetDetectionInfoThreshold + // directly, the same way a Sensor's own info accrual eventually would. + shooter.EntityInfoGathered[target] = 1f; + // This fixture's shooter carries no targeting system, so Accuracy falls back to UnaidedAccuracy (Q4: + // authored deliberately bad). This test's own concern is the headless pipeline reaching a fire + // decision at all (Cut 1's regression), not AgentMinHitProbability tuning -- zero the threshold so an + // unaided shot still counts as "worth it." + items.GameplaySettings.AgentMinHitProbability = 0f; zone.Agents.Add(new Minion(shooter)); var ex = Record.Exception(() => diff --git a/tests/Aetheria.Shared.Tests/InputCapacitorTests.cs b/tests/Aetheria.Shared.Tests/InputCapacitorTests.cs index f9a05459e..8f33e75e1 100644 --- a/tests/Aetheria.Shared.Tests/InputCapacitorTests.cs +++ b/tests/Aetheria.Shared.Tests/InputCapacitorTests.cs @@ -132,7 +132,7 @@ public sealed class InputCapacitorTests : IDisposable var (ship, _, _) = BuildShip(cache, reactorCharge: 5); var gun = EquipGun(cache, ship); var fired = false; - gun.OnFire += () => fired = true; + gun.OnFire += _ => fired = true; // Trigger() reads BurstCount/Cooldown off cached properties that only exist after UpdateStats has run // once (inside Execute) -- a zero-dt warm-up tick populates them without spending any time or charge, @@ -158,7 +158,7 @@ public sealed class InputCapacitorTests : IDisposable var (ship, _, _) = BuildShip(cache, reactorCharge: 10); var gun = EquipGun(cache, ship); var fired = false; - gun.OnFire += () => fired = true; + gun.OnFire += _ => fired = true; ship.Update(0f); // warm-up: populates BurstCount/Cooldown before Trigger() reads them gun.Activate();