From a193853d34d07bf9152e454bf8351bcbb070f947 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 18:36:59 +0200 Subject: [PATCH] Fuse batch 5: agents fire per weapon, Solve owns the burst, refusal is free, arming is the blast radius Operator rulings 2026-09-30. * Combat and TurretController ask FireControl.AgentFires for each weapon. A fused weapon fires whenever its target is designated (Designated, the one visible / in-range / locked predicate PFire already used), whatever the arc; an agent never fires one at nothing. A direct-hit weapon keeps the AgentMinHitProbability threshold. The group's first weapon no longer speaks for the group, and the turret's own range test is gone. * FireControl.Solve is the one decision about what a round does, extracted from Fire: Direct, Burst at a reach, or Refused. Fire, Inspect and the weapons' refusal check all read it. Inspect gains Outcome and BurstReach and the debug HUD prints them for fused weapons. * A refused round costs nothing. InstantWeapon asks before its ammo and energy spend (at the trigger and at each round), ConstantWeapon folds the refusal into the gate its power request and Execute already share, so a refused beam draws no power, wear, heat or visibility and stops. * The arming distance is BlastRadius, measured from the shooter's position and blind to hull size. HullAhead's exit, ArmingDistance and PendingShot.ArmingDistance are deleted; HullAhead survives as HullEntry for the contact fuse. A round fired at a valid target inside the radius is pushed out along its line but keeps its target, so a departed target cancels it. * Step settles a shot's queue state before publishing about it, so a throwing presentation observer costs its own notification and cannot re-commit or re-resolve the shot on every later tick. Tests: FireControlPerWeaponTests (new; includes a fused-beam fixture), the arming tests in FireControlCut124Tests rewritten to the literal distance, the hull-inclusive probe premises deleted. Co-Authored-By: Claude Sonnet 5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 7 +- .../ServerShared/Agents/States/Combat.cs | 9 +- .../ServerShared/Behaviors/ConstantWeapon.cs | 15 +- .../ServerShared/Behaviors/InstantWeapon.cs | 15 +- .../Behaviors/TurretController.cs | 10 +- Assets/Scripts/ServerShared/FireControl.cs | 278 ++++++---- .../FireControlCut124Tests.cs | 142 +++-- .../FireControlPerWeaponTests.cs | 503 ++++++++++++++++++ 8 files changed, 803 insertions(+), 176 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 3f69563ed..040bd4644 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -1337,6 +1337,11 @@ public class ActionGameManager : MonoBehaviour ? "target: none" : $"gates vis {d.Visible} range {d.InRange} arc {d.InArc} lock {d.Locked}"; + // 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}" + : d.Outcome == FireOutcome.Refused ? "refused: arming distance exceeds range" + : $"base {d.PBase:P1}"; + DebugInfoText.text = $"FIRE CONTROL - {selectedItem.Data.Name}\n" + $"{gates}\n" + @@ -1344,7 +1349,7 @@ public class ActionGameManager : MonoBehaviour $"info {d.Info:F3}/{d.InfoDemandCeiling:F3} sensor {d.PSensor:F3}\n" + $"accuracy {d.Accuracy:F3} spread {d.PSpread:F3} hull {d.POnHull:F3}\n" + $"precision {d.Precision:F3} tracking {d.Tracking:F1}\n" + - $"base {d.PBase:P1}\n" + + $"{outcomeLine}\n" + $"{pendingLine}\n" + lastLine; } diff --git a/Assets/Scripts/ServerShared/Agents/States/Combat.cs b/Assets/Scripts/ServerShared/Agents/States/Combat.cs index 019198f36..41e2511da 100644 --- a/Assets/Scripts/ServerShared/Agents/States/Combat.cs +++ b/Assets/Scripts/ServerShared/Agents/States/Combat.cs @@ -111,13 +111,12 @@ public class CombatState : BaseState toTarget = normalize(predictedPosition - _agent.Ship.Position); } - // 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; + // Each weapon decides for itself (operator ruling 2026-09-30): a group shares a trigger, not a + // verdict. FireControl.AgentFires is the AI's "worth it" heuristic for a weapon that hits what it + // fires at, and "the target is designated" for a fused one. foreach (var weapon in _agent.Ship.WeaponGroups[selectedGroup].weapons) { - if (shouldFire) + if (FireControl.AgentFires(weapon, _agent.Ship, target)) weapon.Activate(); else if (weapon.Firing) weapon.Deactivate(); diff --git a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs index 14d6b4820..704635678 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs @@ -91,7 +91,13 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC // Cut 5 (docs/fire-control-cut.md, 5.3, Soul finding 9): StanceAllowsFire alone let a side-mounted beam // fire forward -- ArcAllowsFire joins it here, matching InstantWeapon.cs's player arc gate (Q2: manual and // programmatic are one truth, and a continuous weapon is no exception). - public float PowerRequest(float dt) => _firing && StanceAllowsFire && ArcAllowsFire ? EvaluateNominalPower(_data.Energy) * dt : 0f; + // Operator ruling 2026-09-30: a refused round is free, and a beam is a stream of rounds, so a fused beam whose + // arming distance exceeds its Range fires nothing and draws nothing -- no power, ammo, wear, heat or + // visibility. FireControl.Refuses is the one arming test; it reads the Range Execute last refreshed, the same + // one-tick lag every PowerRequest reads its stats with. + private bool MayFire => StanceAllowsFire && ArcAllowsFire && !FireControl.Refuses(this, Entity); + + public float PowerRequest(float dt) => _firing && MayFire ? EvaluateNominalPower(_data.Energy) * dt : 0f; // Cut 5 (docs/stats-and-power-cut.md ยง1.3, PowerTiers.cs): Low -- offense, same as InstantWeapon. public int DefaultPowerTier => PowerTiers.Low; @@ -99,10 +105,11 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC public override bool Execute(float dt) { base.Execute(dt); - if (_firing && !(StanceAllowsFire && ArcAllowsFire)) + if (_firing && !MayFire) { // Safed: shooter has a target and hasn't declared hostility toward it, or the target no longer - // bears (5.3: a beam obeys its arc exactly as InstantWeapon's trigger does). + // bears (5.3: a beam obeys its arc exactly as InstantWeapon's trigger does), or a fused round is + // refused. _firing = false; OnStopFiring?.Invoke(); return false; @@ -179,7 +186,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC // argument included, when OnBeamShot has no subscriber (true today, R9's "no design uses it // yet"), so the roll would silently never happen. Evaluate it into a local first. var shotId = FireControl.Fire(this, Item, Entity, Damage * interval); - if (shotId != 0) OnBeamShot?.Invoke(shotId); + OnBeamShot?.Invoke(shotId); } } return true; diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index 6de087ba0..e4f4618be 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -103,6 +103,9 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // 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; + // A refused round is free (operator ruling 2026-09-30): no ammo, energy or cooldown is spent on a burst + // 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 @@ -227,16 +230,21 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo _burstTimer += dt; while (_burstRemaining > 0 && _burstTimer > 0) { + // A fused round whose arming distance Range cannot reach is refused, per round, before anything is + // 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 (!_data.SingleAmmoBurst && (!TrySpendActivationEnergy() || !UseAmmo())) + if (!refused && !_data.SingleAmmoBurst && (!TrySpendActivationEnergy() || !UseAmmo())) { _burstRemaining = 0; return false; } - + _burstRemaining--; _burstTimer -= _burstInterval; + if (refused) continue; // 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. @@ -246,9 +254,6 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo // Unity EntityInstance wiring, docs/headless-playground-cut.md), so a shot silently never fired. // Evaluate it into a local first. var shotId = FireControl.Fire(this, Item, Entity); - // A fused round whose arming distance Range cannot reach is refused (FireControl.Fire returns 0): - // nothing was fired, so nothing sounds, wears or heats. - if (shotId == 0) continue; OnFire?.Invoke(shotId); if(!firedThisFrame) { diff --git a/Assets/Scripts/ServerShared/Behaviors/TurretController.cs b/Assets/Scripts/ServerShared/Behaviors/TurretController.cs index 4a2fdab63..48b4c6c4e 100644 --- a/Assets/Scripts/ServerShared/Behaviors/TurretController.cs +++ b/Assets/Scripts/ServerShared/Behaviors/TurretController.cs @@ -73,16 +73,12 @@ public class TurretController : Behavior, IInitializableBehavior } else Entity.LookDirection = normalize(diff); - var dist = length(diff); foreach (var x in _weapons) { - var data = x.Data as WeaponData; - // 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) + // The same per-weapon decision Combat.cs makes; the range test is part of it (a shot out of range + // is not designated and prices at zero). + if (FireControl.AgentFires(x, Entity, Entity.Target.Value)) { x.Activate(); } diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index adf1721e0..4a6afb5e5 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -79,15 +79,20 @@ public static class FireControl return FuseOf(weapon.Item, out _) != null || InArc(weapon.Item, target.Position - shooter.Position); } - // Operator ruling 2026-09-30 (arming distance): a round never bursts closer than its blast radius to where - // it was fired, and the disc must clear the shooter's own hull as it stood at fire. So the burst is at least - // BlastRadius beyond the far edge of the shooter's hull along the flight line. Close bursts are pushed out - // along the aim to this distance; a pilot who flies into their own distant burst takes the damage. - private static float ArmingDistance(Entity shooter, float2 direction, float blastRadius) - { - HullAhead(shooter, shooter.Position.xz, direction, out _, out var exit); - return exit + blastRadius; - } + // Whether the shooter's next round from this weapon is refused: the weapons ask before they spend anything, so + // a refused round costs no ammo, energy, sound, heat, wear or visibility (operator ruling 2026-09-30). The + // answer is Solve's own -- there is no second copy of the arming test. + public static bool Refuses(Weapon weapon, Entity shooter) => + Solve(weapon, weapon.Item, shooter, shooter.Target.Value).Outcome == FireOutcome.Refused; + + // 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; // 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 @@ -213,8 +218,24 @@ public static class FireControl private static float PFire(Weapon weapon, Entity source, Entity target, out float range) => PFire(weapon, source, target, out range, out _, out _); - // `designated` is the one answer to "does the shooter hold valid targeting data on this target": visible, in - // range and locked (for a LockWeapon). `inArc` is the bearing test, asked only once a target is designated. + // The one answer to "does the shooter hold valid targeting data on this target": visible, in range and locked + // (for a LockWeapon). PFire, Solve and the agents' trigger decision all read it here, and nowhere else. + // F8 correction (Soul's fix batch, 2026-09-24): `range` is computed for any non-null target, before any + // gate -- Fire reads it for flightTime, and flightTime must reflect the real distance even when a gate (not + // 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) + { + 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); + } + + // `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, @@ -226,20 +247,9 @@ public static class FireControl inArc = false; if (target == null) return 0f; - // F8 correction (Soul's fix batch, 2026-09-24): range is computed for any non-null target, before any - // gate -- Fire reads it through this same overload for flightTime, and flightTime must reflect the - // real distance even when a gate (not visible, out of arc) already prices the shot at PFire 0. The - // gates below still short-circuit the rest of the shooter-side probability in the same order as before. - // 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. - var toTarget = target.Position - source.Position; - range = length(toTarget.xz); - - if (!source.VisibleEntities.Contains(target)) return 0f; - if (range < weapon.MinRange || range > weapon.Range) return 0f; - if (weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked) return 0f; - designated = true; - inArc = InArc(weapon.Item, toTarget); + designated = Designated(weapon, source, target, out range); + if (!designated) return 0f; + inArc = InArc(weapon.Item, target.Position - source.Position); if (!inArc) return 0f; var settings = source.ItemManager.GameplaySettings; @@ -320,6 +330,9 @@ public static class FireControl MinRange = weapon.MinRange, MaxRange = weapon.Range }; + var solution = Solve(weapon, weapon.Item, source, target); + diagnostic.Outcome = solution.Outcome; + diagnostic.BurstReach = solution.BurstReach; if (target == null) return diagnostic; var toTarget = target.Position - source.Position; @@ -408,6 +421,82 @@ public static class FireControl return shot.Target == null ? 1f : saturate(1f - deviation / shot.Tracking); } + // The one decision about what a round does, made from the shooter's state and a target and nothing else: + // no draw, no side effect. Fire freezes it into a PendingShot; Inspect shows it to the HUD; the weapons ask + // it whether a round is refused before they spend anything -- so a refusal is decided once, by the same + // code that would have flown the round, and the HUD cannot forecast a burst the trigger will not fire. + // Direct: a round that resolves against its target (a weapon without a fuse). Burst: a fused round, with + // BurstReach the planar distance from the shooter at which it bursts. Refused: a fused round whose blast + // radius (its arming distance) exceeds the weapon's Range; it cannot be fired. + public static FireSolution Solve(Weapon weapon, EquippedItem item, Entity source, Entity target) + { + var pFire = PFire(weapon, source, target, out var fireRange, out var designated, out var inArc); + + // Cut 12.4(a) (Q12-6, "WeaponModifiers are labels, not behaviour"): a blast is a property of the + // weapon's data fields alone, resolved and frozen here like every other payload field -- never decided + // later by a Unity projectile, and never by a label. Solve decides "detonates" once: a fuse is frozen + // only when BlastRadius > 0, and null otherwise, so a radius without a fuse or a fuse without a radius + // is inert and resolves as a direct hit (nothing polices that). A zero-radius freeze is what Step below + // still reads as "resolve this shot with Apply." + var fuse = FuseOf(item, out var blastRadius); + + // Operator rulings 2026-09-30. A fused round flies along the shooter's aim (AimDirection, clamped to the + // mount's arc) and carries no target -- so nothing at resolution asks whether one is still around -- + // unless its target is designated (visible, in range, locked) and in arc: + // * no valid targeting data (no target, or one out of range, not visible or not locked) counts as no + // 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 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 + // blind to hull size -- a large hull can take splash from its own close burst. A round never bursts nearer + // than that, so a burst that would land nearer is pushed out along its flight line to it. A round fired at a + // valid target inside the radius is pushed out but stays a targeted round: it keeps its target, so a target + // that leaves the zone cancels it like any other. A round whose Range is shorter than the arming distance + // cannot be fired at all: it is refused, per round, where Range is read after Execute refreshed it (a + // burst's later rounds and a power-starved Range included). + var engaged = fuse != null && !(designated && inArc) ? null : target; + var origin = source.Position; + var travelDirection = engaged != null ? TravelDirection(weapon, source, engaged) : AimDirection(item, source); + + var arming = fuse != null ? blastRadius : 0f; + var burstPosition = engaged != null ? engaged.Position : origin; + var flightDistance = 0f; + if (fuse != null && engaged != null) + { + // Nothing bursts beyond max range: an intercept a receding target leads the round past Range is + // 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; + 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; + } + else if (engaged != null) flightDistance = fireRange; + + var solution = new FireSolution + { + Outcome = weapon.Range < arming ? FireOutcome.Refused : fuse != null ? FireOutcome.Burst : FireOutcome.Direct, + PFire = pFire, + FireRange = fireRange, + Engaged = engaged, + TravelDirection = travelDirection, + BurstPosition = burstPosition, + BurstReach = length((burstPosition - origin).xz), + FlightDistance = flightDistance, + Fuse = fuse, + BlastRadius = blastRadius + }; + return solution; + } + // 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 @@ -428,72 +517,26 @@ public static class FireControl // R1's engage gate and the shooter-side probability, evaluated now and frozen: nothing at commit time // re-reads a stat, an info level or a range. Cut 12.2: PFire no longer carries the spread/hull share -- // those are priced live at Commit, against the target's facing then, not now (Bearing timing, R10). - // F8 (Soul's fix batch, 2026-09-24): the `out range` overload is the one range HitProbability now - // reads too -- Fire used to recompute its own second copy of `target.Position - source.Position` for - // fireRange, right beside the copy PFire's gate already computed. - var pFire = PFire(weapon, source, target, out var fireRange, out var designated, out var inArc); - - // Cut 12.4(a) (Q12-6, "WeaponModifiers are labels, not behaviour"): a blast is a property of the - // weapon's data fields alone, resolved and frozen here like every other payload field -- never decided - // later by a Unity projectile, and never by a label. Fire decides "detonates" once: a fuse is frozen - // only when BlastRadius > 0, and null otherwise, so a radius without a fuse or a fuse without a radius - // is inert and resolves as a direct hit (nothing polices that). A zero-radius freeze is what Step below - // still reads as "resolve this shot with Apply." - var fuse = FuseOf(item, out var blastRadius); - - // Operator rulings 2026-09-30. A fused round flies along the shooter's aim (AimDirection, clamped to the - // mount's arc) and carries no target -- so nothing at resolution asks whether one is still around -- - // unless its target is designated (visible, in range, locked) and in arc: - // * no valid targeting data (no target, or one out of range, not visible or not locked) counts as no - // 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 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): a round never bursts closer than its blast radius past the - // shooter's hull, so a burst that would land nearer -- a target close in, or out of arc and inside the - // radius -- is pushed out along the aim to that distance, and the round carries no target. A round whose - // arming distance exceeds the weapon's Range cannot be fired at all: it is refused here, per round, where - // Range is read after Execute refreshed it (a burst's later rounds and a power-starved Range included). - var engaged = fuse != null && !(designated && inArc) ? null : target; + // Solve is the one range and the one burst decision Inspect and the weapons' refusal check read too. + var solution = Solve(weapon, item, source, target); + if (solution.Outcome == FireOutcome.Refused) return 0; + var pFire = solution.PFire; + var fireRange = solution.FireRange; + var engaged = solution.Engaged; + var travelDirection = solution.TravelDirection; + var burstPosition = solution.BurstPosition; + var fuse = solution.Fuse; + var blastRadius = solution.BlastRadius; var origin = source.Position; - var travelDirection = engaged != null ? TravelDirection(weapon, source, engaged) : AimDirection(item, source); - - var arming = 0f; - var burstPosition = engaged != null ? engaged.Position : origin; - if (fuse != null && engaged != null) - { - // Nothing bursts beyond max range: an intercept a receding target leads the round past Range is - // pulled back along its own line to Range. - var intercept = PredictedIntercept(weapon, source, engaged); - var interceptReach = length((intercept - origin).xz); - arming = ArmingDistance(source, travelDirection, blastRadius); - if (min(interceptReach, weapon.Range) < arming) engaged = null; - else burstPosition = interceptReach > weapon.Range ? origin + (intercept - origin) * (weapon.Range / interceptReach) : intercept; - } - if (fuse != null && engaged == null) - { - travelDirection = AimDirection(item, source); - arming = ArmingDistance(source, travelDirection, blastRadius); - var reach = max(designated ? fireRange : weapon.Range, arming); - burstPosition = origin + float3(travelDirection.x, 0, travelDirection.y) * reach; - } - if (arming > weapon.Range) return 0; var targetVelocity = float3.zero; var targetPosition = origin; - var flightTime = 0f; if (engaged != null) { targetVelocity = float3(engaged.Velocity.x, 0, engaged.Velocity.y); targetPosition = engaged.Position; - flightTime = weapon.Velocity > .01f ? fireRange / weapon.Velocity : 0f; - } - else if (fuse != null) - { - var flown = length((burstPosition - origin).xz); - flightTime = weapon.Velocity > .01f ? flown / weapon.Velocity : 0f; } + var flightTime = weapon.Velocity > .01f ? solution.FlightDistance / weapon.Velocity : 0f; var commitHorizon = source.ItemManager.GameplaySettings.CommitHorizon; @@ -526,7 +569,6 @@ public static class FireControl BurstPosition = burstPosition, Fuse = fuse, BlastRadius = blastRadius, - ArmingDistance = arming, Committed = false }; @@ -540,6 +582,9 @@ 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. public static void Step(Zone zone, float dt) { var shots = zone.PendingShots; @@ -559,9 +604,9 @@ public static class FireControl // the commit decided, while ShotResolved reports what actually happened -- nothing, because the // target is gone. Republishing the committed outcome here used to put a hit marker on a corpse. var miss = MakeOutcome(shot, ShotResult.Miss, false, false, int2.zero, float2.zero, 0f, now); + shots.RemoveAt(i); if (!shot.Committed) zone.ShotCommitted.OnNext(miss); zone.ShotResolved.OnNext(miss); - shots.RemoveAt(i); continue; } @@ -572,8 +617,8 @@ public static class FireControl // A contact burst short of max range arrives when the round has flown that far, not at the frozen // max-range time (CommitBurst). Never later than the frozen time. if (shot.Outcome.Result == ShotResult.Burst) shot.ArrivalTime = min(shot.ArrivalTime, now + shot.Outcome.ArrivalIn); - zone.ShotCommitted.OnNext(shot.Outcome); shots[i] = shot; + zone.ShotCommitted.OnNext(shot.Outcome); } if (shot.Committed && now >= shot.ArrivalTime) @@ -583,8 +628,8 @@ public static class FireControl // 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); - zone.ShotResolved.OnNext(shot.Outcome); shots.RemoveAt(i); + zone.ShotResolved.OnNext(shot.Outcome); } } } @@ -738,7 +783,7 @@ public static class FireControl 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.ArmingDistance)); + travelled = min(reach, max(travelled, shot.BlastRadius)); if (travelled < reach) { outcome.BurstPoint = shot.FireOrigin.xz + shot.TravelDirection * travelled; @@ -759,18 +804,17 @@ public static class FireControl foreach (var entity in zone.Entities) { if (entity == shot.Source) continue; - if (HullAhead(entity, shot.FireOrigin.xz, shot.TravelDirection, out var entry, out _)) nearest = min(nearest, entry); + nearest = min(nearest, HullEntry(entity, shot.FireOrigin.xz, shot.TravelDirection)); } return nearest; } - // Where the line from `origin` along the unit planar `direction` meets `entity`'s hull, in world distance - // from `origin`: `entry` to the first metal cell ahead (+infinity when the line meets none, and negative when the - // origin is already inside the hull: the arming distance floors every caller's use of it) and `exit` to the far edge of the last one (zero when none). 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. Contact fuses read `entry`; a round's arming - // distance reads `exit` of its own shooter. - private static bool HullAhead(Entity entity, float2 origin, float2 direction, out float entry, out float exit) + // Where the line from `origin` along the unit planar `direction` meets `entity`'s hull: the world distance from + // `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. + private static float HullEntry(Entity entity, float2 origin, float2 direction) { var hullData = entity.ItemManager.GetData(entity.Hull) as HullData; var cellSize = entity.ItemManager.GameplaySettings.SchematicCellSize; @@ -779,8 +823,7 @@ public static class FireControl var lateral = dot(start, Lateral(bearing)); var along = dot(start, bearing); - entry = float.PositiveInfinity; - exit = 0f; + var entry = float.PositiveInfinity; var buffer = ArrayPool.Shared.Rent(hullData.Shape.Coordinates.Length); try { @@ -788,10 +831,10 @@ public static class FireControl for (var i = 0; i < count; i++) { if (!(buffer[i].Exit > along)) continue; - if (float.IsPositiveInfinity(entry)) entry = (buffer[i].Entry - along) * cellSize; - exit = max(exit, (buffer[i].Exit - along) * cellSize); + entry = (buffer[i].Entry - along) * cellSize; + break; } - return !float.IsPositiveInfinity(entry); + return entry; } finally { @@ -1708,14 +1751,11 @@ public struct PendingShot // Cut 12.4(a): the blast payload, frozen at Fire alongside everything else above. BlastRadius is zero for // a weapon whose data does not carry one -- Step reads that zero as "resolve with Apply," not a separate - // bool. Fuse is frozen only when BlastRadius > 0 (Fire decides "detonates" once); it is unread until + // bool. Fuse is frozen only when BlastRadius > 0 (Solve decides "detonates" once); it is unread until // Apply's fuse switch (12.4(b)). public float3 BurstPosition; public WeaponFuse? Fuse; public float BlastRadius; - // How far along its flight line a fused round may first burst: BlastRadius past the far edge of the - // shooter's hull, frozen at Fire (a round outlives its shooter's pose, too). - public float ArmingDistance; // Frozen with the rest of the shooter's decision: where the round left from. A round outlives its shooter, so // nothing at resolution reads the shooter's position. @@ -1730,6 +1770,32 @@ public struct PendingShot public ShotOutcome Outcome; } +// What a round does when fired (FireControl.Solve): Direct resolves against its target, Burst detonates at a +// point, Refused cannot be fired -- its blast radius exceeds the weapon's Range. +public enum FireOutcome +{ + Direct, + Burst, + Refused +} + +// Solve's answer. Outcome and BurstReach (planar distance from the shooter to the burst point, read for Burst +// only) are what the HUD shows; the rest is what Fire freezes into the PendingShot. +public struct FireSolution +{ + public FireOutcome Outcome; + public float BurstReach; + // How far the round flies: to its burst point, or for a direct round to its target. + public float FlightDistance; + public float PFire; + public float FireRange; + public Entity Engaged; + public float2 TravelDirection; + public float3 BurstPosition; + public WeaponFuse? Fuse; + public float BlastRadius; +} + public struct FireControlDiagnostic { public bool HasTarget; @@ -1753,6 +1819,10 @@ public struct FireControlDiagnostic public float PSpread; public float POnHull; public float PBase; + // What a round from this weapon does (Solve): the forecast for a fused weapon, whose percentages above do + // not describe it. BurstReach is read only when Outcome is Burst. + public FireOutcome Outcome; + public float BurstReach; } // Cut 12.2 (docs/fire-control-cut.md): a merged interval of a hull's lateral shadow, in the schematic frame's diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index cb50782d7..ca50e475c 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -1410,24 +1410,22 @@ public sealed class FireControlCut124Tests : IDisposable // ==== Operator ruling 2026-09-30, batch 4: the arming distance ==== // - // A round never bursts closer than its blast radius past the shooter's own hull. The fixture's shooter hull is - // a 5x5 block of 2-unit cells centred on the shooter, so its far edge is 5 units out along the axis and - // 5 * sqrt(2) along a diagonal; with a blast of 30 the arming distance is 35 (or 30 + 7.07). - - private const float ShooterHullReach = 5f; + // A round never bursts closer than its blast radius (its arming distance), measured from the shooter's + // position and blind to hull size: the fixture's shooter hull is a 5x5 block of 2-unit cells, and a blast of 30 + // arms at 30, not 30 plus whatever the hull reaches. A shooter close to its own burst takes the splash. private static float Planar(float3 from, float3 to) => length((to - from).xz); - // A target inside the arming distance is not detonated on: the round flies along the aim and bursts at the - // arming distance, carrying no target, and the shooter that stood beside it is untouched (the probes measured - // 7.07 of self-damage for a target at 5, before this). The out-of-arc row is a target 10 away behind the - // mount, inside the blast radius, with the mount flipped so the arc is closed. Kills: no arming distance; a - // burst that still rides the target; an arming distance measured from the shooter's centre instead of the far - // edge of its hull (the burst would land 5 units short and the shooter would still be inside the disc). + // A target inside the arming distance is not detonated on: the round flies on to the arming distance, exactly + // the blast radius from the shooter (35 would be the hull-inclusive figure this replaced). An in-arc target + // stays the round's target and the burst is pushed out along the line to it; the out-of-arc row is a target 10 + // away behind the mount, inside the blast radius, with the mount flipped so the arc is closed: no target rides + // and the burst is on the aim. Kills: no arming distance; a hull term in it; an in-arc round demoted to no + // target; an out-of-arc round that keeps one. [Theory] [InlineData(5f, false)] [InlineData(10f, true)] - public void ACloseBurstIsPushedOutAlongTheAimToTheArmingDistance(float targetRange, bool outOfArc) + public void ACloseBurstIsPushedOutToTheBlastRadius(float targetRange, bool outOfArc) { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, weaponRange: 60f, targetRange: targetRange); @@ -1438,37 +1436,88 @@ public sealed class FireControlCut124Tests : IDisposable aim = float2(0, -1); } Aim(e, aim); - var burst = PointAlong(e, aim, 30f + ShooterHullReach); + var burst = PointAlong(e, aim, 30f); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var shot = SafeAssert.OnlyShot(e.Zone); + Assert.True((shot.Target == e.Target) == !outOfArc, "only a round with a target it can bear on keeps it"); + Assert.Equal(burst.x, shot.BurstPosition.x, 2); + Assert.Equal(burst.z, shot.BurstPosition.z, 2); + } + + // A round pushed out to its arming distance is still a targeted round (operator ruling 2026-09-30): if its target + // leaves the zone before it resolves, it does not burst. The target is 5 units away, inside a blast of 30, and + // the round flies 1.5 seconds; the target is gone after the first tick. The round resolves as a miss with no + // burst point, and the shooter standing inside the would-be disc is untouched. Kills: demoting a close round + // to no target (the round then bursts on schedule and hurts the shooter). + [Fact] + public void APushedOutRoundWhoseTargetLeavesTheZoneDoesNotBurst() + { + 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)); var hurt = 0f; using var a = e.Shooter.ArmorDamage.Subscribe(x => hurt += x.damage); using var h = e.Shooter.HullDamage.Subscribe(x => hurt += x); using var i = e.Shooter.ItemDamage.Subscribe(x => hurt += x.damage); + ShotOutcome resolved = null; + using var r = e.Zone.ShotResolved.Subscribe(o => resolved = o); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); - var shot = SafeAssert.OnlyShot(e.Zone); - Assert.True(shot.Target == null, "a burst pushed out to the arming distance carries no target"); - Assert.Equal(burst.x, shot.BurstPosition.x, 2); - Assert.Equal(burst.z, shot.BurstPosition.z, 2); - Assert.Equal(35f, shot.ArmingDistance, 2); + Assert.True(SafeAssert.OnlyShot(e.Zone).Target == e.Target, "fixture: the pushed-out round keeps its target"); e.Zone.Update(.01f); + e.Zone.Entities.Remove(e.Target); + e.Zone.Update(3f); + SafeAssert.NoShots(e.Zone); + Assert.NotNull(resolved); + Assert.Equal(ShotResult.Miss, resolved.Result); + Assert.False(resolved.HasBurstPoint, "a round whose target is gone does not burst"); Assert.Equal(0f, hurt); } - // The far edge is read along the flight line, not along an axis: aimed down a diagonal the 5x5 hull reaches - // 5 * sqrt(2) = 7.07 units, so the burst sits at 30 + 7.07. A close in-arc target makes the arming distance - // the burst reach. Kills: an extent read along the mount or the hull's own axes. - [Fact] - public void TheArmingDistanceIsMeasuredAlongTheFlightLine() - { - var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, - weaponRange: 60f, targetRange: 5f); - Aim(e, normalize(float2(1, 1))); + public enum Publication { Committed, Resolved } + // A presentation observer that throws costs its own notification and nothing more: the shot's queue state is + // settled before anything is published, so it is not committed or resolved a second time on every later tick + // (the queue used to wedge, re-throwing forever). The first tick throws; the second does not, the shot is gone + // or committed exactly once. Kills: publishing before the shot is committed in the list or removed from it. + [Theory] + [InlineData(Publication.Committed)] + [InlineData(Publication.Resolved)] + public void AThrowingObserverCannotWedgeTheShotQueue(Publication throwsOn) + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f); + var commits = 0; + var resolutions = 0; + using var c = e.Zone.ShotCommitted.Subscribe(_ => { commits++; if (throwsOn == Publication.Committed) throw new InvalidOperationException("observer"); }); + using var r = e.Zone.ShotResolved.Subscribe(_ => { resolutions++; if (throwsOn == Publication.Resolved) throw new InvalidOperationException("observer"); }); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); - var shot = SafeAssert.OnlyShot(e.Zone); - Assert.Equal(30f + ShooterHullReach * sqrt(2f), Planar(e.Shooter.Position, shot.BurstPosition), 1); + Assert.ThrowsAny(() => e.Zone.Update(.01f)); + var second = Record.Exception(() => e.Zone.Update(.01f)); + + Assert.Null(second); + Assert.Equal(1, commits); + Assert.True(resolutions <= 1, $"resolved {resolutions} times"); + } + + // The same for the miss a departed target publishes: the shot is removed before that notification, so an + // observer that throws on it cannot keep the shot in the queue to be cancelled, and thrown at, on every tick. + // Kills: publishing the miss before removing the shot. + [Fact] + public void AThrowingObserverOfADepartedTargetsMissCannotWedgeTheShotQueue() + { + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + using var c = e.Zone.ShotCommitted.Subscribe(_ => throw new InvalidOperationException("observer")); + e.Zone.Entities.Remove(e.Target); + + Assert.ThrowsAny(() => e.Zone.Update(.01f)); + var second = Record.Exception(() => e.Zone.Update(.01f)); + + Assert.Null(second); + SafeAssert.NoShots(e.Zone); } // A target beyond the arming distance is not pushed anywhere: the round still rides it and bursts at its @@ -1488,15 +1537,15 @@ public sealed class FireControlCut124Tests : IDisposable } // A contact round stops on the first hull it crosses, but never nearer than its arming distance: a blocker - // whose near face is 6 units out (centred 10) is passed, and the round bursts at 5 + 4 = 9. Kills: a contact - // point taken as the hull face regardless of arming. + // whose near face is 2 units out (centred 6) is passed, and the round bursts at its blast radius, 4. Kills: a + // contact point taken as the hull face regardless of arming. [Fact] public void AContactRoundNeverStopsNearerThanItsArmingDistance() { var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Contact, blastRadius: 4f, damage: 100f, weaponRange: NoLockRange); e.Shooter.Target.Value = null; Aim(e, float2(0, 1)); - AddShip(e, PointAlong(e, float2(0, 1), 10f), 1f, 300); + AddShip(e, PointAlong(e, float2(0, 1), 6f), 1f, 300); ShotOutcome outcome = null; using var c = e.Zone.ShotCommitted.Subscribe(o => outcome = o); @@ -1504,7 +1553,7 @@ public sealed class FireControlCut124Tests : IDisposable e.Zone.Update(.01f); Assert.NotNull(outcome); - Assert.Equal(e.Shooter.Position.z + ShooterHullReach + 4f, outcome.BurstPoint.y, 2); + Assert.Equal(e.Shooter.Position.z + 4f, outcome.BurstPoint.y, 2); } // A round fired from inside another hull is already in metal: its contact point is its arming distance. The @@ -1528,16 +1577,16 @@ public sealed class FireControlCut124Tests : IDisposable e.Zone.Update(.01f); Assert.NotNull(committed); - Assert.Equal(e.Shooter.Position.z + ShooterHullReach + 4f, committed.BurstPoint.y, 2); + Assert.Equal(e.Shooter.Position.z + 4f, committed.BurstPoint.y, 2); } - // A fused round is refused, per round and at the moment Fire reads Range, when its arming distance exceeds - // that Range: 34.5 against 35 refuses, 35.01 fires, and a power-starved Range (authored 100, forced to about + // A fused round is refused, per round and at the moment Fire reads Range, when its arming distance (the blast + // radius, 30) exceeds that Range: 29.5 refuses, 30.01 fires, and a power-starved Range (authored 100, forced to about // 10 the way Soul's probe did after the trigger has passed) refuses although the authored figure clears // it. Fire returns 0 and queues nothing. Kills: a gate that reads the authored Range; `>=` for `>`. [Theory] - [InlineData(34.5f, false, false)] - [InlineData(35.01f, false, true)] + [InlineData(29.5f, false, false)] + [InlineData(30.01f, false, true)] [InlineData(100f, true, false)] public void AFusedRoundWhoseArmingDistanceExceedsRangeIsRefusedByFire(float range, bool starvedOfPower, bool fires) { @@ -1550,7 +1599,7 @@ public sealed class FireControlCut124Tests : IDisposable typeof(EquippedItem).GetProperty("PowerSupply").GetSetMethod(true).Invoke(e.WeaponItem, new object[] { .1f }); e.Shooter.Resolver.InvalidateSource(e.WeaponItem, StatSource.PowerSupply); e.Weapon.GetType().GetMethod("UpdateStats", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(e.Weapon, null); - Assert.True(e.Weapon.Range < 35f, "fixture: the runtime Range must have fallen below the arming distance"); + Assert.True(e.Weapon.Range < 30f, "fixture: the runtime Range must have fallen below the arming distance"); } var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); @@ -1559,19 +1608,12 @@ public sealed class FireControlCut124Tests : IDisposable Assert.Equal(fires ? 1 : 0, e.Zone.PendingShots.Count); } - // The boundary itself: a Range exactly equal to the arming distance fires (the disc is tangent to the hull, - // which delivers nothing). The arming distance is read from a probe round on the same fixture, so the - // equality is exact rather than a decimal that float arithmetic could tip. Kills: `>=` for `>`. + // The boundary itself: a Range exactly equal to the arming distance (the blast radius) fires. Kills: `>=` for + // `>`. [Fact] public void ARangeExactlyEqualToTheArmingDistanceFires() { - var probe = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, weaponRange: 60f); - probe.Shooter.Target.Value = null; - Aim(probe, float2(0, 1)); - FireControl.Fire(probe.Weapon, probe.WeaponItem, probe.Shooter); - var arming = SafeAssert.OnlyShot(probe.Zone).ArmingDistance; - - var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, weaponRange: arming); + var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f, weaponRange: 30f); e.Shooter.Target.Value = null; Aim(e, float2(0, 1)); @@ -1582,7 +1624,7 @@ public sealed class FireControlCut124Tests : IDisposable // queues nothing, and the same weapon with Range to spare fires once. Kills: Execute still announcing a // refused round (Fire's 0 handed to the presentation as a shot id). [Theory] - [InlineData(34.5f, 0)] + [InlineData(29.5f, 0)] [InlineData(40f, 1)] public void ARefusedRoundIsNotAnnounced(float range, int fired) { diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs new file mode 100644 index 000000000..c1276745e --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -0,0 +1,503 @@ +/* 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 System.Reflection; +using CultMath; +using GameCult.Caching; +using UniRx; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; + +// Operator rulings 2026-09-30 (fuse batch 5): AI and turrets decide per weapon and a fused weapon needs a +// designated target; the HUD forecast shows a fused weapon's outcome and reads the same decision Fire flies; a +// refused round is free, for a discrete weapon and for a beam. Builds its own fixture, the convention every +// fire-control test file follows: its shooter carries several mounts and can hold either kind of weapon. +public sealed class FireControlPerWeaponTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolperweapon-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public FireControlPerWeaponTests() => 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(float minHit) => 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, + TargetArmorInfoThreshold = .2f, + TargetGearInfoThreshold = .8f, + FiringArc = 170, + CommitHorizon = .5f, + SchematicCellSize = 2f, + UnaidedAccuracy = .05f, + UnaidedTracking = 10f, + UnaidedPrecision = 1000f, + AgentMinHitProbability = minHit, + BeamResolveInterval = .1f + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + private static Shape SolidShape(int w, int h) + { + var shape = new Shape(w, h); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + return shape; + } + + private sealed class Gun + { + public EquippedItem Item; + public Weapon Weapon; + } + + private sealed class Rig + { + public Zone Zone; + public Ship Shooter; + public Ship Target; + public List Guns = new List(); + public Gun Gun => Guns[0]; + } + + // Mount 0 (None, arc 0) bears on a target dead ahead; (Clockwise, arc 0) is a side mount the default 170 + // degree arc cannot bring to bear on it. Fused: Proximity with `blast`, which is also its arming distance: blast 30 + // arms at 30, so a Range of 29.5 refuses and 40 fires. + 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) + { + mounts ??= new[] { (ItemRotation.None, 0f) }; + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = SolidShape(5, 4), Durability = 1000000, Mass = 1000, Armor = 0, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + var shooterHullData = new HullData + { + Name = "ShooterHull", HullType = HullType.Ship, Shape = SolidShape(5, 5), Durability = 1000000, Mass = 1000 + }; + for (var i = 0; i < mounts.Length; i++) + shooterHullData.Hardpoints.Add(new HardpointData + { + Type = HardpointType.Sensors, Position = new int2(i, 0), Shape = new Shape(), + Rotation = mounts[i].Rotation, FiringArc = mounts[i].Arc + }); + + WeaponData behavior; + if (beam) + behavior = new ConstantWeaponData(); + else if (lockWeapon) + behavior = new LockWeaponData + { + LockSpeed = Constant(1000), SensorImpact = Constant(1), LockAngle = Constant(0), DirectionImpact = Constant(1), Decay = Constant(1), + Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1) + }; + else + behavior = new InstantWeaponData { Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1), SingleAmmoBurst = singleAmmoBurst }; + behavior.Damage = Constant(100); + behavior.Range = Constant(range); + behavior.MinRange = Constant(0); + behavior.Velocity = Constant(0); + behavior.Spread = Constant(0); + behavior.DamageSpread = Constant(0); + behavior.Penetration = Constant(0); + behavior.Energy = Constant(energy); + behavior.Heat = Constant(heat); + behavior.Visibility = Constant(visibility); + behavior.MagazineSize = magazine; + behavior.DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }; + + 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(shooterHullData); + cache.Upsert(new WeaponItemData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Fuse = fuse, BlastRadius = blast, + Behaviors = { behavior } + }); + 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(1000f), Precision = Constant(1000f), Tracking = Constant(1000000f) } } + }); + 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 = "Turret", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new TurretControllerData() } + }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 100; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, TestSettings(minHit), _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "PerWeapon", Owner = null }, null); + + EquippableItem Make(string name, int lot, float durability) => new EquippableItem + { + Data = items.ItemData.RefOf(items.ItemData.GetByName(name)), Durability = durability, Lot = lot + }; + + var lot = 1; + var shooterHullRef = items.ItemData.RefOf(items.ItemData.GetByName("ShooterHull")); + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooter = new Ship(items, zone, new EquippableItem { Data = shooterHullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); + var rig = new Rig(); + for (var i = 0; i < mounts.Length; i++) + { + var gun = Make("Gun", lot++, 1); + Assert.True(shooter.TryEquip(gun, new int2(i, 0))); + 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) }); + } + var targeting = Make("Targeting", lot++, 1); + Assert.True(shooter.TryFindSpace(targeting, out var tpos)); + Assert.True(shooter.TryEquip(targeting, tpos)); + if (true) + { + var reactor = Make("Reactor", lot++, 10); + Assert.True(shooter.TryFindSpace(reactor, out var rpos)); + Assert.True(shooter.TryEquip(reactor, rpos)); + } + if (turret) + { + var t = Make("Turret", lot++, 1); + Assert.True(shooter.TryFindSpace(t, out var upos)); + Assert.True(shooter.TryEquip(t, upos)); + } + + var target = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); + Assert.True(target.TryEquip(Make("Gun", lot++, 1), new int2(0, 0))); + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + shooter.Position = float3(-53, 0, -101); + target.Position = shooter.Position + float3(0, 0, targetRange); + shooter.Target.Value = target; + shooter.EntityInfoGathered[target] = 1f; + shooter.SetIff(target, true); + + zone.Update(1f); + + rig.Zone = zone; + rig.Shooter = shooter; + rig.Target = target; + return rig; + } + + private static void Aim(Rig r, float2 direction) => r.Shooter.LookDirection = float3(direction.x, 0, direction.y); + + private static readonly (ItemRotation, float)[] SideThenForward = { (ItemRotation.Clockwise, 0f), (ItemRotation.None, 0f) }; + private static readonly (ItemRotation, float)[] SideMount = { (ItemRotation.Clockwise, 0f) }; + + // ==== Ruling 1: per weapon, and a fused weapon needs a designated target ==== + + // A weapon group shares a trigger, not a verdict: two guns of one type, the first on a side mount that cannot + // bear on the target and the second dead ahead. Only the second fires. Kills: the group's verdict read off its + // first weapon (Combat's old `testWeapon`), which silences the group. + [Fact] + public void CombatDecidesEachWeaponOfAGroupForItself() + { + var r = Build(SideThenForward); + var group = r.Shooter.WeaponGroups.Single(g => g.weapons.Count == 2).weapons; + Assert.True(ReferenceEquals(group[0], r.Guns[0].Weapon), "fixture: the side mount leads the group"); + r.Zone.Agents.Add(new Minion(r.Shooter)); + + r.Zone.Update(1f); + r.Zone.Update(1f); + + Assert.False(r.Guns[0].Weapon.Firing, "the side-mounted gun cannot bear"); + Assert.True(r.Guns[1].Weapon.Firing, "the forward gun bears and is worth firing"); + } + + // A fused weapon fires whenever its target is designated, whatever the arc: the same group, now fused, fires + // both guns, the side mount included. Kills: Combat reading HitProbability (zero out of arc) for a fused weapon. + [Fact] + public void CombatFiresAFusedWeaponAtADesignatedTargetOutOfArc() + { + var r = Build(SideThenForward, fuse: WeaponFuse.Proximity, blast: 4f); + r.Zone.Agents.Add(new Minion(r.Shooter)); + + r.Zone.Update(1f); + r.Zone.Update(1f); + + Assert.True(r.Guns[0].Weapon.Firing, "a designated target is enough, arc or no"); + Assert.True(r.Guns[1].Weapon.Firing); + } + + // The turret makes the same per-weapon decision. Kills: TurretController reading HitProbability, or its own + // range test, for a fused weapon. + [Fact] + public void ATurretFiresAFusedWeaponAtADesignatedTargetOutOfArc() + { + var r = Build(SideMount, fuse: WeaponFuse.Proximity, blast: 4f, turret: true); + + r.Zone.Update(1f); + r.Zone.Update(1f); + + Assert.True(r.Gun.Weapon.Firing, "the turret must fire a fused weapon at a designated target out of arc"); + } + + public enum Withheld { NoTarget, OutOfRange, NotVisible, Unlocked } + + // The AI never fires a fused weapon at nothing: a missing target, one out of range, one not visible and one + // an unlocked LockWeapon holds no lock on are each no reason to fish. The direct-hit control fires (true) on + // the same rig once the target is valid. Kills: `designated` dropped from the fused branch (null included). + [Theory] + [InlineData(Withheld.NoTarget)] + [InlineData(Withheld.OutOfRange)] + [InlineData(Withheld.NotVisible)] + [InlineData(Withheld.Unlocked)] + public void AnAgentNeverFiresAFusedWeaponAtNothing(Withheld reason) + { + var r = Build(fuse: WeaponFuse.Proximity, blast: 4f, range: 100f, + targetRange: reason == Withheld.OutOfRange ? 150f : 60f, lockWeapon: reason == Withheld.Unlocked); + var target = r.Target; + if (reason == Withheld.NoTarget) target = null; + if (reason == Withheld.NotVisible) r.Shooter.VisibleEntities.Remove(r.Target); + + Assert.False(FireControl.AgentFires(r.Gun.Weapon, r.Shooter, target)); + } + + [Fact] + public void AnAgentFiresAFusedWeaponAtAValidTargetWhateverItsHitChance() + { + // Accuracy 0 prices every direct hit at zero; the fused weapon is unmoved. + var r = Build(SideMount, fuse: WeaponFuse.Proximity, blast: 4f, accuracy: 0f); + + Assert.True(FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target)); + } + + // A weapon that fires at what it hits keeps the AgentMinHitProbability threshold, per weapon. + // Kills: the threshold dropped for a direct-hit weapon, or applied to a fused one. + [Theory] + [InlineData(.5f, .2f, true)] + [InlineData(.5f, .9f, false)] + public void AnAgentKeepsTheHitProbabilityThresholdForADirectHitWeapon(float accuracy, float minHit, bool fires) + { + var r = Build(accuracy: accuracy, minHit: minHit); + var p = FireControl.HitProbability(r.Gun.Weapon, r.Shooter, r.Target); + Assert.True(p > .3f && p < .7f, $"fixture: the shot must price near the accuracy, priced {p}"); + + Assert.Equal(fires, FireControl.AgentFires(r.Gun.Weapon, r.Shooter, r.Target)); + } + + // ==== Ruling 2: the HUD forecast is Fire's own decision ==== + + // Inspect's Outcome and BurstReach against what Fire then does with the same rig: Refused iff Fire returns 0, + // and a Burst's reach is the planar distance to the burst point Fire froze. Rows: a target beyond the arming + // distance (burst at its range), one inside it (pushed out to 30), no target (max range), out of arc, a + // Range that cannot reach the arming distance, and a weapon without a fuse. + // Kills: an Inspect that computes its own reach (ignoring the arming push or the Range clamp). + [Theory] + [InlineData(60f, 100f, false, false, FireOutcome.Burst, 60f)] + [InlineData(20f, 100f, false, false, FireOutcome.Burst, 30f)] + [InlineData(60f, 40f, true, false, FireOutcome.Burst, 40f)] + [InlineData(60f, 100f, false, true, FireOutcome.Burst, 60f)] + [InlineData(60f, 29.5f, false, false, FireOutcome.Refused, 0f)] + [InlineData(60f, 29.5f, true, false, FireOutcome.Refused, 0f)] + public void TheForecastIsTheOutcomeFireDelivers(float targetRange, float range, bool noTarget, bool outOfArc, FireOutcome outcome, float reach) + { + var r = Build(outOfArc ? SideMount : null, fuse: WeaponFuse.Proximity, blast: 30f, range: range, targetRange: targetRange); + if (noTarget) r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + + var d = FireControl.Inspect(r.Gun.Weapon, r.Shooter, r.Shooter.Target.Value); + var shotId = FireControl.Fire(r.Gun.Weapon, r.Gun.Item, r.Shooter); + + Assert.Equal(outcome, d.Outcome); + Assert.Equal(outcome != FireOutcome.Refused, shotId != 0); + if (outcome == FireOutcome.Refused) return; + Assert.Equal(reach, d.BurstReach, 2); + var shot = SafeAssert.OnlyShot(r.Zone); + Assert.Equal(d.BurstReach, length((shot.BurstPosition - shot.FireOrigin).xz), 2); + } + + // A weapon without a fuse forecasts a direct hit and keeps its percentages. Kills: Solve calling everything a burst. + [Fact] + public void AWeaponWithoutAFuseForecastsADirectHit() + { + var r = Build(); + + var d = FireControl.Inspect(r.Gun.Weapon, r.Shooter, r.Target); + + Assert.Equal(FireOutcome.Direct, d.Outcome); + Assert.True(d.PBase > 0f); + } + + // ==== Ruling 3: a refused round is free ==== + + private sealed class Costs + { + public int Ammo; + public float ChargeSpent; + public int Sounds; + public int Wears; + public float Heat; + public bool Visible; + public int Announced; + public int Shots; + } + + // 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) + { + 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; + Aim(r, float2(0, 1)); + var weapon = (InstantWeapon) r.Gun.Weapon; + var capacitor = (InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(weapon); + r.Gun.Item.SoundBank = new WwiseMetaSoundBank { IncludedEvents = new[] { new WwiseMetaObject { Id = 7, Name = "gun_fire" } } }; + var costs = new Costs(); + r.Gun.Item.AudioEvents.Subscribe(_ => costs.Sounds++); + r.Zone.ShotCommitted.Subscribe(_ => costs.Shots++); // a fused round with no flight commits and resolves in its own tick + r.Shooter.ItemDamage.Subscribe(t => { if (t.Item1 == r.Gun.Item) costs.Wears++; }); + weapon.OnFire += _ => costs.Announced++; + capacitor.AddCharge(1000f); // a full buffer, so the first round can be paid for + var chargeBefore = capacitor.Charge; + var heatBefore = r.Gun.Item.Temperature; + var ammoBefore = weapon.Ammo; + Assert.True(chargeBefore > 0f && ammoBefore == 5, "fixture: a full buffer and magazine"); + + weapon.Activate(); + r.Zone.Update(.01f); + + costs.Ammo = ammoBefore - weapon.Ammo; + costs.ChargeSpent = chargeBefore - capacitor.Charge; + costs.Heat = r.Gun.Item.Temperature - heatBefore; + costs.Visible = r.Shooter.VisibilitySources.TryGetValue(weapon, out var v) && v > 1f; + return costs; + } + + // 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. Kills, one per row of the assertions: a spend that precedes the refusal (Execute or + // Trigger), an announcement, a sound, wear, heat or visibility left ahead of the `continue`. + // `single` is a weapon that pays once per burst, at the trigger rather than at the round. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ARefusedDiscreteRoundCostsNothing(bool single) + { + var fired = Pull(40f, single); + Assert.True(fired.Shots == 1, $"shots {fired.Shots}"); + Assert.True(fired.Ammo == 1, $"ammo spent {fired.Ammo}"); + Assert.True(fired.ChargeSpent > 1f, "a fired round spends its energy"); + Assert.Equal(1, fired.Sounds); + Assert.True(fired.Wears > 0); + Assert.True(fired.Heat > 0f); + Assert.True(fired.Visible); + Assert.Equal(1, fired.Announced); + + var refused = Pull(29.5f, single); + Assert.Equal(0, refused.Shots); + Assert.Equal(0, refused.Ammo); + Assert.Equal(0f, refused.ChargeSpent); + Assert.Equal(0, refused.Sounds); + Assert.Equal(0, refused.Wears); + Assert.True(fired.Heat - refused.Heat > 5f, $"a refused round adds no heat ({refused.Heat} vs {fired.Heat})"); + Assert.False(refused.Visible); + Assert.Equal(0, refused.Announced); + } + + // The trigger and the round are separate decisions: a burst authorised at the trigger runs its rounds later, + // at whatever Range Execute has refreshed by then, and a round that Range no longer reaches is refused before + // its spend. The authorisation is set directly (the way a trigger that passed leaves it) on a weapon whose + // Range is short of its blast radius, so the Trigger check cannot be what stops it. Kills: Execute spending + // before it asks. + [Fact] + public void ARoundRefusedAfterTheTriggerPassedIsRefusedBeforeItsSpend() + { + var r = Build(fuse: WeaponFuse.Proximity, blast: 30f, range: 29.5f, magazine: 5); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (InstantWeapon) r.Gun.Weapon; + typeof(InstantWeapon).GetField("_burstRemaining", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(weapon, 1); + + r.Zone.Update(.01f); + + Assert.Equal(5, weapon.Ammo); + SafeAssert.NoShots(r.Zone); + Assert.Equal(0, (int) typeof(InstantWeapon).GetField("_burstRemaining", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(weapon)); + } + + // 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. + // 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) + { + var r = Build(beam: true, fuse: WeaponFuse.Proximity, blast: 30f, range: range, energy: 50f, heat: 5000f, visibility: 100f); + r.Shooter.Target.Value = null; + Aim(r, float2(0, 1)); + var weapon = (ConstantWeapon) r.Gun.Weapon; + var wears = 0; + var stops = 0; + var shots = 0; + r.Zone.ShotResolved.Subscribe(_ => shots++); + r.Shooter.ItemDamage.Subscribe(t => { if (t.Item1 == r.Gun.Item) wears++; }); + weapon.OnStopFiring += () => stops++; + var heatBefore = r.Gun.Item.Temperature; + + weapon.Activate(); + var request = weapon.PowerRequest(.1f); + for (var i = 0; i < 5; i++) r.Zone.Update(.1f); + + var visible = r.Shooter.VisibilitySources.TryGetValue(weapon, out var v) && v > 1f; + return (shots, wears, request, visible, r.Gun.Item.Temperature - heatBefore, stops, weapon.Firing); + } + + [Fact] + public void ARefusedBeamCostsNothing() + { + var fired = Burn(40f); + Assert.True(fired.Shots > 0, "control: the beam rolls shots"); + Assert.True(fired.Wears > 0); + Assert.True(fired.Request > 0f); + Assert.True(fired.Visible); + Assert.True(fired.Heat > 0f); + + var refused = Burn(29.5f); + Assert.Equal(0, refused.Shots); + Assert.Equal(0, refused.Wears); + 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"); + } +}