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"); + } +}