diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 040bd4644..543e8f8ef 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -1335,7 +1335,7 @@ public class ActionGameManager : MonoBehaviour : "last: none"; var gates = target == null ? "target: none" - : $"gates vis {d.Visible} range {d.InRange} arc {d.InArc} lock {d.Locked}"; + : $"gates designated {d.Designated} arc {d.InArc}"; // A fused weapon's forecast is its outcome, not a hit chance: the burst point or the refusal. var outcomeLine = d.Outcome == FireOutcome.Burst ? $"burst at {d.BurstReach:F0}" diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index 41e2511da..d27a90ab1 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -57,10 +57,12 @@ public class CombatState : BaseState var group = _agent.Ship.WeaponGroups[i]; var dps = 0f; foreach (var weapon in group.weapons) - if (weapon.Item.Online.Value && - weapon.MinRange < targetDistance && - targetDistance < weapon.Range && - (weapon is ConstantWeapon || + // The range test is FireControl's (InReach, the part of Designated before the lock, which this + // loop is what acquires), and a weapon Solve refuses adds nothing to a group's damage. + if (weapon.Item.Online.Value && + FireControl.InReach(weapon, _agent.Ship, target, out _) && + !FireControl.Refuses(weapon, _agent.Ship) && + (weapon is ConstantWeapon || weapon is InstantWeapon instantWeapon && instantWeapon.CanFire)) { if(weapon is LockWeapon lockWeapon) diff --git a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs index 704635678..e9dab4ce6 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs @@ -194,7 +194,9 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC public override void Activate() { - if(!_firing && !_reloading) + // A refused beam never starts (operator ruling 2026-09-30): OnStartFiring drives the beam's visuals and + // audio, so starting it only for Execute to stop it a tick later would flash a beam that fires nothing. + if(!_firing && !_reloading && !FireControl.Refuses(this, Entity)) { _firing = true; OnStartFiring?.Invoke(); diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index e4f4618be..59b7f6344 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -41,6 +41,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo protected readonly InputCapacitor _capacitor = new InputCapacitor(); protected int _burstRemaining; + private bool _burstPaid; private float _burstTimer; private float _burstInterval; protected float _cooldown; // Normalized @@ -107,10 +108,10 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // whose first round FireControl would refuse. if (FireControl.Refuses(this, Entity)) 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 - if(_data.SingleAmmoBurst && (!TrySpendActivationEnergy() || !UseAmmo())) return; - + // A burst that pays once (SingleAmmoBurst) pays at its first round that is not refused, in Execute, judged + // with the Range that round flies with: paying here would judge with the Range of the previous tick, and a + // Range that fell in between would spend the ammo on rounds Fire then refuses. + _burstPaid = false; _burstRemaining = (int) BurstCount; _burstInterval = BurstTime / _burstRemaining; _burstTimer = 0; @@ -234,13 +235,14 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // spent (operator ruling 2026-09-30): it costs no ammo, energy, sound, heat, wear or visibility. // The decision is FireControl's; Fire makes the same one. var refused = FireControl.Refuses(this, Entity); - // If multiple ammo is consumed per burst, perform ammo and energy consumption here - // UseAmmo returns false when triggering reload; cancel firing if that is the case - if (!refused && !_data.SingleAmmoBurst && (!TrySpendActivationEnergy() || !UseAmmo())) + // Ammo and energy are consumed here, per round, or once per burst at its first unrefused round + // (SingleAmmoBurst). UseAmmo returns false when triggering reload; cancel firing if that is the case + if (!refused && !_burstPaid && (!TrySpendActivationEnergy() || !UseAmmo())) { _burstRemaining = 0; return false; } + if (!refused) _burstPaid = _data.SingleAmmoBurst; _burstRemaining--; _burstTimer -= _burstInterval; diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 4a6afb5e5..8bdada43b 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -88,11 +88,15 @@ public static class FireControl // The agents' trigger decision (Combat, TurretController), one weapon at a time (operator ruling 2026-09-30). // A weapon that fires at what it hits fires when the shot is worth taking. A fused weapon fires whenever its // target is designated, whatever the arc: the round is sent out of arc on purpose (Solve). An agent never - // fires a fused weapon at nothing -- no target, or one it holds no valid data on, is no reason to fish. - public static bool AgentFires(Weapon weapon, Entity shooter, Entity target) => - FuseOf(weapon.Item, out _) != null - ? Designated(weapon, shooter, target, out _) - : HitProbability(weapon, shooter, target) >= shooter.ItemManager.GameplaySettings.AgentMinHitProbability; + // fires a fused weapon at nothing -- no target, or one it holds no valid data on, is no reason to fish -- and + // never one Solve refuses: a refused weapon is not a weapon it can use. + public static bool AgentFires(Weapon weapon, Entity shooter, Entity target) + { + if (FuseOf(weapon.Item, out _) == null) + return HitProbability(weapon, shooter, target) >= shooter.ItemManager.GameplaySettings.AgentMinHitProbability; + var solution = Solve(weapon, weapon.Item, shooter, target); + return solution.Designated && solution.Outcome != FireOutcome.Refused; + } // Operator ruling 2026-09-30: a shot with nothing to bear on flies along where its shooter is aiming // (Entity.LookDirection -- the player's mouse or a turret's tracking), restricted by the mount's arc. The @@ -225,21 +229,25 @@ public static class FireControl // visible, out of range) already denies the target. Planar (R7: the simulation is 2D): the range gate, the // burst reach and the range clamp all measure the same distance, so a target's height never moves one of // them without the others. - public static bool Designated(Weapon weapon, Entity source, Entity target, out float range) + public static bool Designated(Weapon weapon, Entity source, Entity target, out float range) => + InReach(weapon, source, target, out range) && !(weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked); + + // Designated before the lock: the target is seen and within the weapon's planar range. A LockWeapon acquires + // its lock only while its shooter holds the trigger, so an agent asks this to know when to start locking; + // every other reader wants Designated. The one range test in the file. + public static bool InReach(Weapon weapon, Entity source, Entity target, out float range) { range = 0f; if (target == null) return false; range = length((target.Position - source.Position).xz); - return source.VisibleEntities.Contains(target) - && range >= weapon.MinRange && range <= weapon.Range - && !(weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked); + return source.VisibleEntities.Contains(target) && range >= weapon.MinRange && range <= weapon.Range; } // `designated` is the answer above. `inArc` is the bearing test, asked only once a target is designated. // PFire prices a shot at zero unless both hold. Fire reads them to decide what a fused round flies at // (operator rulings 2026-09-30): without valid data it has no target and bursts at max range; with data but - // out of arc it bursts at the target's range, along the aim. Inspect recomputes the four gates one by one, - // to show the HUD which is closed; it decides nothing. + // out of arc it bursts at the target's range, along the aim. The HUD reads Designated and the + // arc from Solve and Inspect and decides nothing. private static float PFire(Weapon weapon, Entity source, Entity target, out float range, out bool designated, out bool inArc) { range = 0f; @@ -337,9 +345,7 @@ public static class FireControl var toTarget = target.Position - source.Position; diagnostic.Range = length(toTarget.xz); - diagnostic.Visible = source.VisibleEntities.Contains(target); - diagnostic.InRange = diagnostic.Range >= weapon.MinRange && diagnostic.Range <= weapon.Range; - diagnostic.Locked = !(weapon is LockWeapon lockWeapon) || lockWeapon.IsLocked; + diagnostic.Designated = solution.Designated; diagnostic.InArc = InArc(weapon.Item, toTarget); var targetHull = source.ItemManager.GetData(target.Hull) as HullData; @@ -447,6 +453,9 @@ public static class FireControl // lock and bursts at the weapon's Range, the stat PFire's range gate reads: bomb fishing; // * valid data but out of arc bursts at the target's range, which the range gate has already held to // Range, along that same clamped aim: the arc is hoped to land the blast near enough to splash. + // Either way the point frozen here is the farthest the round can burst: whatever its fuse, it detonates at the + // first hull on its aim line short of it (CommitBurst; operator ruling 2026-09-30, "a no-lock round stops at + // the first hull"), and bomb fishing bursts at this point only when the line is clear. // Either way the round flies at the weapon's Velocity like a targeted shot flies to its intercept. A shot // without a fuse keeps its target as before. // Arming distance (operator ruling 2026-09-30): the blast radius, measured from the shooter's position and @@ -469,15 +478,15 @@ public static class FireControl // pulled back along its own line to Range. var intercept = PredictedIntercept(weapon, source, engaged); var interceptReach = length((intercept - origin).xz); - if (min(interceptReach, weapon.Range) < arming) burstPosition = origin + float3(travelDirection.x, 0, travelDirection.y) * arming; - else burstPosition = interceptReach > weapon.Range ? origin + (intercept - origin) * (weapon.Range / interceptReach) : intercept; + var point = interceptReach > weapon.Range ? origin + (intercept - origin) * (weapon.Range / interceptReach) : intercept; + burstPosition = Armed(origin, travelDirection, arming, point); flightDistance = max(fireRange, arming); } else if (fuse != null) { - var reach = max(designated ? fireRange : weapon.Range, arming); - burstPosition = origin + float3(travelDirection.x, 0, travelDirection.y) * reach; - flightDistance = reach; + var reach = designated ? fireRange : weapon.Range; + burstPosition = Armed(origin, travelDirection, arming, origin + float3(travelDirection.x, 0, travelDirection.y) * reach); + flightDistance = max(reach, arming); } else if (engaged != null) flightDistance = fireRange; @@ -486,6 +495,7 @@ public static class FireControl Outcome = weapon.Range < arming ? FireOutcome.Refused : fuse != null ? FireOutcome.Burst : FireOutcome.Direct, PFire = pFire, FireRange = fireRange, + Designated = designated, Engaged = engaged, TravelDirection = travelDirection, BurstPosition = burstPosition, @@ -497,6 +507,12 @@ public static class FireControl return solution; } + // The arming rule, once: a burst point nearer to `origin` than `arming` (planar, blind to hull size) is pushed + // out along the unit planar `direction` to it. Solve applies it to the point it plans; the detonation of a + // contact or delayed blast, whose point is found on the hull only at resolution, applies it to that point. + private static float3 Armed(float3 origin, float2 direction, float arming, float3 point) => + length((point - origin).xz) < arming ? origin + float3(direction.x, 0, direction.y) * arming : point; + // Cut 3, R1: called once per burst step from InstantWeapon.Execute. Computes 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 @@ -582,9 +598,10 @@ public static class FireControl // its shooter (operator ruling 2026-09-30): the shooter is only an identity a shot carries, never asked // for at resolution, so a shooter that died or left the zone in flight changes nothing about the shot. // Resolution reads the zone and the target; there is no credit or loot for a shooter to receive. - // A shot's queue state is settled before anything is published about it: it is committed in the list before - // ShotCommitted fires and removed before ShotResolved fires, so a presentation observer that throws costs - // that one notification and cannot leave the shot to be committed or resolved again on every later tick. + // A shot's queue state is settled before anything that can run user code: it is committed in the list before + // ShotCommitted fires and removed before its damage is applied (damage observers) or ShotResolved fires, so an + // observer that throws costs that one notification, or the rest of that blast, and cannot leave the shot to be + // committed, resolved or applied again on every later tick. public static void Step(Zone zone, float dt) { var shots = zone.PendingShots; @@ -627,8 +644,10 @@ public static class FireControl // which model a shot uses, through its own switch on the frozen Fuse -- Step has no damage // branch left. A direct hit, a proximity burst and a contact or delayed blast all arrive here // the same way; only Apply decides what that means. - Apply(zone, shot); + // Removed first: Apply reaches damage observers (user code), and a shot left in the queue by one + // that throws would resolve, and hurt, again on every later tick. shots.RemoveAt(i); + Apply(zone, shot); zone.ShotResolved.OnNext(shot.Outcome); } } @@ -772,9 +791,9 @@ public static class FireControl return outcome; } - // Operator rulings 2026-09-30. A fused round with no target bursts at the max-range point Fire froze along - // the aim line, whatever its fuse -- except a Contact round, which bursts at the first hull that line crosses - // before that point (a Proximity or Delayed fuse does not trip on a hull with nothing locked). The point is + // Operator rulings 2026-09-30. A fused round with no target bursts at the first hull its aim line crosses + // before the max-range point Fire froze, whatever its fuse (contact, proximity and delayed alike: a round with + // nothing locked stops on what is in its way), and at the max-range point when the line is clear. The point is // read from this tick's poses and frozen into the outcome (R4, the same timing Commit reads facing at). // Nothing is rolled or drawn, so determinism needs nothing beyond the frozen inputs. private static ShotOutcome CommitBurst(Zone zone, PendingShot shot, float now) @@ -782,8 +801,7 @@ public static class FireControl var outcome = MakeOutcome(shot, ShotResult.Burst, false, false, int2.zero, float2.zero, 0f, now); outcome.HasBurstPoint = true; var reach = length(shot.BurstPosition.xz - shot.FireOrigin.xz); - var travelled = shot.Fuse == WeaponFuse.Contact ? ContactDistance(zone, shot, reach) : reach; - travelled = min(reach, max(travelled, shot.BlastRadius)); + var travelled = min(reach, max(HullDistance(zone, shot, reach), shot.BlastRadius)); if (travelled < reach) { outcome.BurstPoint = shot.FireOrigin.xz + shot.TravelDirection * travelled; @@ -794,11 +812,11 @@ public static class FireControl return outcome; } - // The distance along a no-lock round's aim line to the first thing a contact fuse trips on, or `reach` (the + // The distance along a no-lock round's aim line to the first hull it crosses, or `reach` (the // max-range point) when it meets nothing first. The shooter's own hull is never a candidate. // SEAM FOR THE MINING CAMPAIGN: mining chunks are not Entities and are not on this branch. When they land, // whatever they are contributes its own entry distance to this minimum, and nothing else in the file changes. - private static float ContactDistance(Zone zone, PendingShot shot, float reach) + private static float HullDistance(Zone zone, PendingShot shot, float reach) { var nearest = reach; foreach (var entity in zone.Entities) @@ -813,7 +831,7 @@ public static class FireControl // `origin` to the first metal cell ahead, +infinity when the line meets none, and negative when the origin is // already inside the hull (the blast radius floors every caller's use of it). Read through Lane's own cell // admission (CellsOnLine), in the entity's schematic frame: the line's lateral offset is its own, and cells - // wholly behind the origin are skipped. Only a contact fuse asks. + // wholly behind the origin are skipped. Only a no-lock round asks. private static float HullEntry(Entity entity, float2 origin, float2 direction) { var hullData = entity.ItemManager.GetData(entity.Hull) as HullData; @@ -1025,7 +1043,10 @@ public static class FireControl // cancels the host's pose, so the host's own damage is fixed by the commit (R4 holds for it), while // every bystander is judged against where it actually is at arrival -- the same live rule a proximity // burst already applies to everyone. - var worldPoint = target.ToWorldPoint(schematicPoint); + // The arming rule applies to this point as it does to every other burst (Solve's Armed): a hull met nearer + // the muzzle than the blast radius is detonated on at the radius, along the round's own flight line. + var hullPoint = target.ToWorldPoint(schematicPoint); + var worldPoint = Armed(shot.FireOrigin, shot.TravelDirection, shot.BlastRadius, float3(hullPoint.x, shot.FireOrigin.y, hullPoint.y)).xz; Detonate(target.Zone, worldPoint, shot.BlastRadius, shot.Damage, shot.DamageType); } @@ -1789,6 +1810,8 @@ public struct FireSolution public float FlightDistance; public float PFire; public float FireRange; + // Whether the shooter holds valid targeting data on the target (Designated); the HUD and the agents read it here. + public bool Designated; public Entity Engaged; public float2 TravelDirection; public float3 BurstPosition; @@ -1799,9 +1822,7 @@ public struct FireSolution public struct FireControlDiagnostic { public bool HasTarget; - public bool Visible; - public bool InRange; - public bool Locked; + public bool Designated; public bool InArc; public float Range; public float MinRange; diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index ca50e475c..16f663b58 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -1520,6 +1520,103 @@ public sealed class FireControlCut124Tests : IDisposable SafeAssert.NoShots(e.Zone); } + public enum Observer { HullDamage, ArmorDamage, IncomingHit } + + // Nothing the queue does is left to a user callback that may throw: a shot is out of the queue before its + // damage is applied, so a damage observer that throws costs the rest of that blast, once, and the shot is not + // applied again on every later tick (its blast, and the observer, used to repeat forever). The observer throws + // on its first call; the following tick is clean and the observer has been called exactly once. Contact for + // the hit report (a roll can miss, so it retries), Proximity for the damage events. + // Kills: applying the shot's damage before removing it from the queue. + [Theory] + [InlineData(Observer.HullDamage)] + [InlineData(Observer.ArmorDamage)] + [InlineData(Observer.IncomingHit)] + public void AThrowingDamageObserverIsCalledOnceAndCannotWedgeTheShotQueue(Observer observer) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: observer == Observer.IncomingHit ? WeaponFuse.Contact : WeaponFuse.Proximity, + blastRadius: 4f, penetration: 1f, damage: 100f); + if (observer == Observer.ArmorDamage) ArmourEverything(e); + var calls = 0; + void Throw() { calls++; throw new InvalidOperationException("observer"); } + using var a = observer == Observer.ArmorDamage ? e.Target.ArmorDamage.Subscribe(_ => Throw()) : Disposable.Empty; + using var h = observer == Observer.HullDamage ? e.Target.HullDamage.Subscribe(_ => Throw()) : Disposable.Empty; + using var i = observer == Observer.IncomingHit ? e.Target.IncomingHit.Subscribe(_ => Throw()) : Disposable.Empty; + + for (var attempt = 0; attempt < 60 && calls == 0; attempt++) + { + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + Record.Exception(() => e.Zone.Update(.01f)); + } + Assert.Equal(1, calls); + var later = Record.Exception(() => e.Zone.Update(.01f)); + + Assert.Null(later); + Assert.Equal(1, calls); + SafeAssert.NoShots(e.Zone); + } + + // A round pushed out to its arming distance flies the arming distance: its arrival is that far off, not the + // target's own (5 units, a quarter second) range. 30 units at 20 units/s is 1.5 seconds. Kills: a flight + // distance of the target's range for a pushed-out round. + [Fact] + public void APushedOutRoundDoesNotArriveBeforeItHasFlownTheArmingDistance() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, + weaponRange: 60f, targetRange: 5f); + Aim(e, float2(0, 1)); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.True(shot.Target == e.Target, "fixture: the pushed-out round keeps its target"); + Assert.Equal(1.5f, shot.ArrivalTime - shot.FireTime, 3); + } + + // What the shooter's own hull takes from one round at `targetRange`, counting only rounds that hit when the + // fuse needs a hit (a roll can miss, so it retries). + private static float ShooterSplash(Engagement e, bool needsHit) + { + var taken = 0f; + using var a = e.Shooter.ArmorDamage.Subscribe(x => taken += x.damage); + using var h = e.Shooter.HullDamage.Subscribe(x => taken += x); + using var i = e.Shooter.ItemDamage.Subscribe(x => taken += x.damage); + ShotOutcome outcome = null; + using var r = e.Zone.ShotResolved.Subscribe(o => outcome = o); + for (var attempt = 0; attempt < 60; attempt++) + { + taken = 0f; + outcome = null; + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + if (!needsHit || outcome != null && outcome.Hit) return taken; + } + throw new InvalidOperationException("no round hit in 60 attempts"); + } + + // The arming ruling holds for every fuse, at the point a contact or delayed round detonates on a hull: no + // burst nearer to the shooter than the blast radius. A round that hits a target inside the radius (5 out), or + // one whose near face is inside it (a target 32 out has its face at 28), is detonated on at the radius (30), + // so the shooter takes what any burst 30 out on its line deals: the amount a proximity round at exactly 30 + // deals. Kills: a hull-point detonation that ignores the arming distance (the shooter then took 7.07 from a + // blast 30 at 5 out, more than the armed burst). + [Theory] + [InlineData(WeaponFuse.Contact, 5f)] + [InlineData(WeaponFuse.Delayed, 5f)] + [InlineData(WeaponFuse.Contact, 32f)] + public void AContactOrDelayedBlastNeverDetonatesNearerThanTheArmingDistance(WeaponFuse fuse, float targetRange) + { + var armed = ShooterSplash(Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, penetration: 1f, + damage: 100f, weaponRange: 60f, targetRange: 30f), needsHit: false); + Assert.True(armed > 0f, "fixture: the shooter's own hull takes splash from a burst at the radius"); + + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 30f, penetration: 1f, + damage: 100f, weaponRange: 60f, targetRange: targetRange); + var splash = ShooterSplash(e, needsHit: true); + + Assert.Equal(armed, splash, 3); + } + // A target beyond the arming distance is not pushed anywhere: the round still rides it and bursts at its // intercept. Kills: demoting every fused round to the aim. [Fact] @@ -1661,7 +1758,7 @@ public sealed class FireControlCut124Tests : IDisposable Assert.True(shot.Target == e.Target, "the target is inside Range in the plane, so it is valid data"); Assert.Equal(40f, Planar(e.Shooter.Position, shot.BurstPosition), 2); var diagnostic = FireControl.Inspect(e.Weapon, e.Shooter, e.Target); - Assert.True(diagnostic.InRange, "the HUD's range gate is planar too"); + Assert.True(diagnostic.Designated, "the HUD's designation is planar too"); Assert.Equal(40f, diagnostic.Range, 2); } @@ -1790,15 +1887,15 @@ public sealed class FireControlCut124Tests : IDisposable Assert.True(e.Zone.PendingShots[0].Committed, "and commits once its horizon is reached, with the round still in flight"); } - // Ruling 3: a Contact round with no lock stops on the first hull its aim line crosses before max range; a - // Proximity or Delayed round keeps the max-range burst. The blocker's near face sits 21 units out (a 5x4-cell - // hull of 2-unit cells centred 25 out), the witness at max range (60). Kills: a Contact fuse that ignores - // hulls; a Proximity or Delayed fuse that trips on one. + // Ruling 3 (operator, 2026-09-30): a no-lock round of any fuse stops on the first hull its aim line crosses + // before max range and detonates there; with the line clear it bursts at max range. The blocker's near face + // sits 21 units out (a 5x4-cell hull of 2-unit cells centred 25 out), the witness at max range (60). Kills: a + // fuse that ignores hulls (a Proximity or Delayed round used to fly on to max range). [Theory] - [InlineData(WeaponFuse.Contact, true)] - [InlineData(WeaponFuse.Proximity, false)] - [InlineData(WeaponFuse.Delayed, false)] - public void OnlyAContactRoundWithNoLockStopsOnTheFirstHullItCrosses(WeaponFuse fuse, bool stopsOnTheBlocker) + [InlineData(WeaponFuse.Contact)] + [InlineData(WeaponFuse.Proximity)] + [InlineData(WeaponFuse.Delayed)] + public void ANoLockRoundOfAnyFuseStopsOnTheFirstHullItCrosses(WeaponFuse fuse) { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: fuse, blastRadius: 4f, penetration: 1f, damage: 100f, weaponRange: NoLockRange); @@ -1813,11 +1910,11 @@ public sealed class FireControlCut124Tests : IDisposable e.Zone.Update(.01f); Assert.NotNull(outcome); - var expectedReach = stopsOnTheBlocker ? 21f : NoLockRange; + const float expectedReach = 21f; Assert.Equal(e.Shooter.Position.z + expectedReach, outcome.BurstPoint.y, 2); Assert.Equal(e.Shooter.Position.x, outcome.BurstPoint.x, 2); - Assert.Equal(!stopsOnTheBlocker, Alive(e, blocker)); - Assert.Equal(stopsOnTheBlocker, Alive(e, atMaxRange)); + Assert.False(Alive(e, blocker)); + Assert.True(Alive(e, atMaxRange), "the witness at max range is beyond the stop"); } // Ruling 3: a hull beyond max range is not on the round's way, and the shooter's own hull, which the line diff --git a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs index 2300108fd..d824d4278 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs @@ -428,7 +428,7 @@ public sealed class FireControlCut9Tests : IDisposable e.Shooter.EntityInfoGathered[e.Target] = info; var hud = FireControl.Inspect(e.Weapon, e.Shooter, e.Target); var hot = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); - Assert.True(hud.Visible && hud.InRange && hud.Locked && hud.InArc, $"info {info}: precondition, every gate open"); + Assert.True(hud.Designated && hud.InArc, $"info {info}: precondition, every gate open"); Assert.True(hot > 0f, $"info {info}, Precision {precision}: precondition, a shot that can land"); Assert.Equal(hot, hud.Accuracy * hud.PSensor * hud.PSpread * hud.POnHull, 5); sawPartialSpread |= hud.PSpread < .99f; @@ -445,7 +445,7 @@ public sealed class FireControlCut9Tests : IDisposable } e.Target.Position = origin + float3(0, 0, 5000); - Closed("out of range", d => d.InRange); + Closed("out of range", d => d.Designated); e.Target.Position = origin + float3(0, 0, -100); Closed("behind the mount", d => d.InArc); @@ -455,13 +455,13 @@ public sealed class FireControlCut9Tests : IDisposable e.Target.Position = float3(600, 0, 500); var far = FireControl.Inspect(e.Weapon, e.Shooter, e.Target); Assert.Equal(500f, far.Range, 3); - Assert.True(far.InRange && FireControl.HitProbability(e.Weapon, e.Shooter, e.Target) > 0f, "shooter far from the origin"); + Assert.True(far.Designated && FireControl.HitProbability(e.Weapon, e.Shooter, e.Target) > 0f, "shooter far from the origin"); checkedConfigurations++; e.Shooter.Position = origin; e.Target.Position = origin + float3(0, 0, 100); e.Shooter.VisibleEntities.Remove(e.Target); - Closed("not visible", d => d.Visible); + Closed("not visible", d => d.Designated); Assert.False(FireControl.Inspect(e.Weapon, e.Shooter, null).HasTarget); Assert.Equal(0f, FireControl.HitProbability(e.Weapon, e.Shooter, null)); diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 8ee6e70a4..4f9ffe282 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -75,6 +75,7 @@ public sealed class FireControlPerWeaponTests : IDisposable public Ship Target; public List Guns = new List(); public Gun Gun => Guns[0]; + public Gun Decoy; } // Mount 0 (None, arc 0) bears on a target dead ahead; (Clockwise, arc 0) is a side mount the default 170 @@ -83,7 +84,7 @@ public sealed class FireControlPerWeaponTests : IDisposable private Rig Build( (ItemRotation Rotation, float Arc)[] mounts = null, bool beam = false, WeaponFuse? fuse = null, float blast = 0f, float range = 100f, float targetRange = 60f, float accuracy = 1f, float minHit = .2f, bool lockWeapon = false, - bool turret = false, float energy = 0f, float heat = 0f, float visibility = 0f, int magazine = 0, bool singleAmmoBurst = false) + bool turret = false, float energy = 0f, float heat = 0f, float visibility = 0f, int magazine = 0, bool singleAmmoBurst = false, bool decoy = false) { mounts ??= new[] { (ItemRotation.None, 0f) }; var hullData = new HullData @@ -101,6 +102,14 @@ public sealed class FireControlPerWeaponTests : IDisposable Type = HardpointType.Sensors, Position = new int2(i, 0), Shape = new Shape(), Rotation = mounts[i].Rotation, FiringArc = mounts[i].Arc }); + // A second weapon type on its own mount (a group of its own, since groups are per item type), dead ahead + // and unfused, weaker than the gun: what a group pick falls back to when the gun's group is refused. + if (decoy) + shooterHullData.Hardpoints.Add(new HardpointData + { + Type = HardpointType.Sensors, Position = new int2(mounts.Length, 0), Shape = new Shape(), + Rotation = ItemRotation.None, FiringArc = 0f + }); WeaponData behavior; if (beam) @@ -138,6 +147,21 @@ public sealed class FireControlPerWeaponTests : IDisposable Fuse = fuse, BlastRadius = blast, Behaviors = { behavior } }); + cache.Upsert(new WeaponItemData + { + Name = "Decoy", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = + { + new InstantWeaponData + { + Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1), Damage = Constant(50), Range = Constant(100), + MinRange = Constant(0), Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0), + Energy = Constant(0), Heat = Constant(0), Visibility = Constant(0), + 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, @@ -180,6 +204,13 @@ public sealed class FireControlPerWeaponTests : IDisposable var item = shooter.Equipment.Single(x => x.EquippableItem == gun); rig.Guns.Add(new Gun { Item = item, Weapon = (Weapon) item.Behaviors.Single(b => b is Weapon) }); } + if (decoy) + { + var d = Make("Decoy", lot++, 1); + Assert.True(shooter.TryEquip(d, new int2(mounts.Length, 0))); + var item = shooter.Equipment.Single(x => x.EquippableItem == d); + rig.Decoy = new Gun { Item = item, Weapon = (Weapon) item.Behaviors.Single(b => b is Weapon) }; + } var targeting = Make("Targeting", lot++, 1); Assert.True(shooter.TryFindSpace(targeting, out var tpos)); Assert.True(shooter.TryEquip(targeting, tpos)); @@ -374,7 +405,7 @@ public sealed class FireControlPerWeaponTests : IDisposable // One discrete weapon, triggered once at no target, with every cost observable: a magazine, an energy charge, // a sound bank, wear events, heat and visibility. Range 29.5 refuses (arming 30); Range 40 fires. - private Costs Pull(float range, bool single, bool viaTrigger) + private Costs Pull(float range, bool single, bool viaTrigger, bool staleRange = false) { var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range, energy: 50f, heat: 5000f, visibility: 100f, magazine: 5, singleAmmoBurst: single); r.Shooter.Target.Value = null; @@ -393,6 +424,9 @@ public sealed class FireControlPerWeaponTests : IDisposable var ammoBefore = weapon.Ammo; Assert.True(chargeBefore > 0f && ammoBefore == 5, "fixture: a full buffer and magazine"); + // The Range the previous tick's Execute left, before this tick refreshes it: a power-dependent Range that + // has since fallen. Set through the property's own setter, so the trigger sees a Range the round will not. + if (staleRange) typeof(Weapon).GetProperty("Range").GetSetMethod(true).Invoke(weapon, new object[] { 100f }); if (viaTrigger) weapon.Activate(); else typeof(InstantWeapon).GetField("_burstRemaining", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, 1); r.Zone.Update(.01f); @@ -406,7 +440,7 @@ public sealed class FireControlPerWeaponTests : IDisposable // Control: the same weapon with Range to spare pays every cost, so a free refusal below is not a fixture that // never spends. Then the refusal costs nothing: no ammo, no energy, no sound, no wear, no heat, no - // visibility, no shot. `single` is a weapon that pays once per burst, at the trigger rather than at the round; + // visibility, no shot. `single` is a weapon that pays once per burst, at its first unrefused round rather than at each; // `viaTrigger` false authorises the burst directly (the way a trigger that passed leaves it) on a weapon whose // Range is short, so the Trigger check cannot be what stops the round and Execute must refuse it itself. // Kills, one per row of the assertions: a spend that precedes the refusal (Execute or Trigger), an @@ -440,7 +474,7 @@ public sealed class FireControlPerWeaponTests : IDisposable // The same rule for a beam: a fused beam whose arming distance exceeds Range fires nothing and draws // nothing. The control (Range 40) rolls shots, wears, draws power and shows; the refused beam does none of - // that, asks the bus for nothing, and stops itself the way an out-of-arc beam does. + // that, asks the bus for nothing, and never starts (ARefusedBeamNeverStarts). // Kills: ConstantWeapon spending (power request, wear, heat, visibility) before the refusal, or not // consulting FireControl at all. private (int Shots, int Wears, float Request, bool Visible, float Heat, int Stops, bool Firing) Burn(float range) @@ -481,6 +515,104 @@ public sealed class FireControlPerWeaponTests : IDisposable Assert.Equal(0f, refused.Request); Assert.False(refused.Visible); Assert.True(fired.Heat - refused.Heat > 5f, $"a refused beam adds no heat ({refused.Heat} vs {fired.Heat})"); - Assert.True(refused.Stops > 0 && !refused.Firing, "a refused beam stops itself, as an out-of-arc beam does"); + Assert.True(refused.Stops == 0 && !refused.Firing, "a refused beam never starts, so it never has to stop"); + } + + // A beam is a stream of rounds, so a refused beam never starts: OnStartFiring drives the beam's visuals and + // audio, and starting it for Execute to stop a tick later would flash a beam that fires nothing. The control + // (Range 40) starts once and keeps firing. Kills: Activate that starts a beam without asking FireControl. + [Theory] + [InlineData(40f, 1)] + [InlineData(29.5f, 0)] + public void ARefusedBeamNeverStarts(float range, int starts) + { + var r = Build(beam: true, fuse: WeaponFuse.Proximity, blast: 30f, range: range); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (ConstantWeapon) r.Gun.Weapon; + var started = 0; + weapon.OnStartFiring += () => started++; + + weapon.Activate(); + for (var i = 0; i < 5; i++) r.Zone.Update(.1f); + + Assert.Equal(starts, started); + Assert.Equal(starts == 1, weapon.Firing); + } + + // A refused weapon is not one an agent can use: a fused weapon whose arming distance (30) exceeds its Range + // (29.5) is not fired at a target inside that Range, where the same weapon with Range to spare is. + // Kills: AgentFires answering "designated" without asking whether Solve refuses. + [Theory] + [InlineData(29.5f, false)] + [InlineData(40f, true)] + public void AnAgentNeverFiresARefusedFusedWeapon(float range, bool fires) + { + var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: range, targetRange: 20f); + + Assert.Equal(fires, FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target)); + } + + // Combat's group pick counts only weapons that can fire: the refused gun's group out-damages the decoy's, so a + // pick that counted it would select a group whose weapons are then never fired, and the decoy would stay silent. + // Kills: Combat counting a refused weapon's damage toward its group. + [Fact] + public void CombatDoesNotPickAGroupWhoseWeaponIsRefused() + { + var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: 29.5f, targetRange: 20f, decoy: true); + Assert.True(r.Gun.Weapon.RangeDamagePerSecond(20f) > r.Decoy.Weapon.RangeDamagePerSecond(20f), "fixture: the refused gun is the stronger group"); + r.Zone.Agents.Add(new Minion(r.Shooter)); + + r.Zone.Update(1f); + r.Zone.Update(1f); + + Assert.False(r.Gun.Weapon.Firing, "a refused weapon is never activated"); + Assert.True(r.Decoy.Weapon.Firing, "the group that can fire is the one picked"); + } + + // Combat's range test is Designated's, not a second one: a target exactly at max range is in reach (the old + // private test was strict). Kills: a range comparison of Combat's own. + [Fact] + public void CombatCountsATargetExactlyAtMaxRangeAsInReach() + { + var r = Build(range: 60f, targetRange: 60f); + // The agent is driven directly, not through the zone, so the shooter stays exactly at max range: the first + // update leaves the root state for combat, the second is combat deciding. + var agent = new Minion(r.Shooter); + agent.Update(.01f); + agent.Update(.01f); + + Assert.True(r.Gun.Weapon.Firing); + } + + // The trigger's threshold is inclusive: a shot priced exactly at AgentMinHitProbability is worth taking, and + // one priced a hair under it is not. Kills: `>` for `>=`. + [Fact] + public void AnAgentFiresAtExactlyTheHitProbabilityThreshold() + { + var probe = Build(accuracy: .5f); + var price = FireControl.HitProbability(probe.Gun.Weapon, probe.Shooter, probe.Target); + Assert.True(price > 0f); + + var atThreshold = Build(accuracy: .5f, minHit: price); + var overThreshold = Build(accuracy: .5f, minHit: MathF.BitIncrement(price)); + + Assert.True(FireControl.AgentFires(atThreshold.Gun.Weapon, atThreshold.Shooter, atThreshold.Target)); + Assert.False(FireControl.AgentFires(overThreshold.Gun.Weapon, overThreshold.Shooter, overThreshold.Target)); + } + + // A round refused on a Range that fell between the trigger and the round is still free: the burst is paid + // (SingleAmmoBurst) at its first round that is not refused, judged with the Range that round flies with. + // The trigger saw a stale Range of 100; the round's Range is 29.5, under the arming distance. + // Kills: paying for the burst at the trigger. + [Fact] + public void ARefusedBurstThatPassedTheTriggerOnAStaleRangeCostsNothing() + { + var refused = Pull(29.5f, single: true, viaTrigger: true, staleRange: true); + + Assert.Equal(0, refused.Shots); + Assert.Equal(0, refused.Ammo); + Assert.Equal(0f, refused.ChargeSpent); + Assert.Equal(0, refused.Announced); } }