From df5f763187ab0f0551894b4fba51bc541b5a67c3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 19 Sep 2026 16:56:40 +0200 Subject: [PATCH] Fire control Cut 5: close the rules that landed on one path and not its twin Soul's pass over the whole campaign confirmed eleven findings. Seven were code defects of one shape -- a rule implemented on one path and left off the path beside it -- and this is all seven. 5.1 Tracking falls back to GameplaySettings.UnaidedTracking (new, 10) instead of zero, and Commit drops the zero branch that turned a Tracking-less shooter into a hard wall. Unaided fire was meant to be really, really bad; it was 334 shots and no hits against a target drifting at 5 u/s. 5.2 A shot the shield cannot absorb now breaks it. The decision is frozen into ShotOutcome.ShieldBroken at commit and performed in Apply, so a shield that recharges mid-flight cannot retroactively survive. Splash already did this; the discrete roll never did, which made the whole shield-break mechanic unreachable for every weapon in the catalog. 5.3 ConstantWeapon gates on ArcAllowsFire alongside StanceAllowsFire. A side-mounted beam was firing forward. 5.4 The point-blank bearing case moves out of Weapon.ArcAllowsFire and into InArc, which used to normalize the zero vector into NaN. The trigger said fire and the probability said impossible. 5.5 A destroyed item drops out of IsRevealed's ranking, so Precision stops concentrating hits onto a dead subsystem's footprint. 5.6 Zone subscribes Entity.Death and removes the corpse itself. That removal lived only in Unity's loot-drop subscription, so headless nothing ever left the zone, shots landed on corpses and no fight could end. 5.7 PendingShot drops PredictedIntercept and FlightTime, written and read nowhere. 208 tests pass (197 plus eleven new). Known defect, recorded rather than hidden: the committed mutation harness is unreliable. Across two runs it failed to restore FireControl.cs and Weapon.cs, leaving live mutations in the working tree -- including its own 5.1 mutation, which is how a green suite briefly reported two failures. Its verdicts are therefore evidence only where re-run from a verified-clean tree. Two anchors (Zone.cs's ObserveAdd line, Weapon.cs's InArc return) fail to match at all, and 5.3's ConstantWeaponObeysArc survives its own mutation, so that rule is written but not pinned. Cut 5b owns the repair; nothing here is trusted on the harness's word, and the suite result above was taken by hand from a hash-verified tree. Co-Authored-By: Claude Opus 5 --- Assets/Scripts/Gameplay/EntityInstance.cs | 5 +- .../ServerShared/Behaviors/ConstantWeapon.cs | 10 +- .../Scripts/ServerShared/Behaviors/Weapon.cs | 8 +- Assets/Scripts/ServerShared/FireControl.cs | 60 +- Assets/Scripts/ServerShared/Settings.cs | 6 + Assets/Scripts/ServerShared/Zone.cs | 6 + .../FireAuthorityTests.cs | 10 +- .../FireControlCut5Tests.cs | 619 ++++++++++++++++++ tests/mutation_tests_fire_control_cut5.sh | 281 ++++++++ 9 files changed, 974 insertions(+), 31 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs create mode 100644 tests/mutation_tests_fire_control_cut5.sh diff --git a/Assets/Scripts/Gameplay/EntityInstance.cs b/Assets/Scripts/Gameplay/EntityInstance.cs index 9e8a7470f..0e721d733 100644 --- a/Assets/Scripts/Gameplay/EntityInstance.cs +++ b/Assets/Scripts/Gameplay/EntityInstance.cs @@ -321,7 +321,10 @@ public class EntityInstance : MonoBehaviour var t = Instantiate(DestroyEffect).transform; t.position = transform.position; } - entity.Zone.Entities.Remove(entity); + // Cut 5, 5.6 (docs/fire-control-cut.md, Soul finding 7): Zone now removes the entity itself + // (subscribed to Entity.Death when the entity joined) -- this subscription's own business is + // loot and the destroy effect, presentation both, not removal from the simulation's own + // collection. } })); diff --git a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs index 1d8fe567f..37da4fe06 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs @@ -88,7 +88,10 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC // request field (StatValidation.PowerRequestFields) -- read nominally, same reasoning as every other // IPowerConsumer in this cut. Damage (the field Cut 7 curves) is a separate stat base.Execute reads with the // real Evaluate. - public float PowerRequest(float dt) => _firing && StanceAllowsFire ? EvaluateNominalPower(_data.Energy) * dt : 0f; + // 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; // Cut 5 (docs/stats-and-power-cut.md ยง1.3, PowerTiers.cs): Low -- offense, same as InstantWeapon. public int DefaultPowerTier => PowerTiers.Low; @@ -96,9 +99,10 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC public override bool Execute(float dt) { base.Execute(dt); - if (_firing && !StanceAllowsFire) + if (_firing && !(StanceAllowsFire && ArcAllowsFire)) { - // Safed: shooter has a target and hasn't declared hostility toward it. + // 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). _firing = false; OnStopFiring?.Invoke(); return false; diff --git a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs index 22a5e2f21..350d63500 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs @@ -104,9 +104,9 @@ public abstract class Weapon : Behavior, IActivatedBehavior // predicate (FireControl.InArc) Combat.cs and TurretController.cs gate AI and turret fire with, read at // the one point every shooter's trigger passes through (InstantWeapon.Trigger) -- not a parallel check, and // not the AI's own AgentMinHitProbability heuristic, which is a "worth it" decision layered above this - // gate, not the gate itself. With no target set, behaviour is unchanged (nothing to bear on). A target at - // this weapon's own exact position leaves the bearing undefined rather than out of arc -- point-blank - // range can't fail a bearing test. + // gate, not the gate itself. With no target set, behaviour is unchanged (nothing to bear on). + // Cut 5, 5.4 (Soul finding 11): the point-blank special case this used to carry moved into InArc itself -- + // one bearing test, one owner, so every caller (not just this one) gets a point-blank shot that bears. public bool ArcAllowsFire { get @@ -114,7 +114,7 @@ public abstract class Weapon : Behavior, IActivatedBehavior var target = Entity.Target.Value; if (target == null) return true; var toTarget = target.Position - Entity.Position; - return lengthsq(toTarget) < 1e-6f || FireControl.InArc(Item, toTarget); + return FireControl.InArc(Item, toTarget); } } diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index b49c90105..65a20a88e 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -40,11 +40,17 @@ public static class FireControl // Planar bearing test (R7: the simulation is 2D, the 3D is set dressing -- target height never enters // this). An arc of 360 degrees or more passes unconditionally, which is how a turret hardpoint // (authored FiringArc: 360, Q1) tracks all the way around without a second type. + // Cut 5, 5.4 (Soul finding 11): a planar bearing shorter than 1e-6 -- point-blank range, target and firer + // co-located -- returns true rather than falling through to normalize's NaN. This used to live as a + // separate special case in Weapon.ArcAllowsFire; one bearing test now owns it, so a target at a weapon's + // own exact position can never fail to bear regardless of caller. public static bool InArc(EquippedItem weapon, float3 toTarget) { var arc = ArcFor(weapon); if (arc >= 360f) return true; - var planarTarget = normalize(float3(toTarget.x, 0, toTarget.z)); + var planarToTarget = float3(toTarget.x, 0, toTarget.z); + if (lengthsq(planarToTarget) < 1e-6f) return true; + var planarTarget = normalize(planarToTarget); return dot(MountDirection(weapon), planarTarget) >= cos(radians(arc / 2f)); } @@ -60,8 +66,11 @@ public static class FireControl public static bool IsRevealed(Entity observer, EquippedItem item) { var target = item.Entity; + // Cut 5, 5.5 (Soul finding 10): a destroyed item drops out of the ranking entirely rather than merely + // failing its own tier -- ranked.IndexOf(item) then returns -1 for it (the same "no longer on this + // target" path below), and the survivors' tiers close up over the gap instead of leaving one. var ranked = target.Equipment - .Where(e => e.Data.HardpointType != HardpointType.Hull) + .Where(e => e.Data.HardpointType != HardpointType.Hull && e.EquippableItem.Durability >= .01f) .OrderByDescending(e => e.Data.HardpointType != HardpointType.Tool) .ThenByDescending(e => e.Data.Shape.Coordinates.Length) .ToList(); @@ -99,10 +108,12 @@ public static class FireControl return system != null && system.Item.Active.Value ? system.Precision : 0f; } + // Cut 5, 5.1 (Soul finding 3): falls back to GameplaySettings.UnaidedTracking, exactly the shape Accuracy + // falls back to UnaidedAccuracy. Tracking is now always positive -- Commit no longer carries a zero case. public static float Tracking(Entity entity) { var system = entity.GetBehavior(); - return system != null && system.Item.Active.Value ? system.Tracking : 0f; + return system != null && system.Item.Active.Value ? system.Tracking : entity.ItemManager.GameplaySettings.UnaidedTracking; } // Cut 3, the risk this map names explicitly: Combat.cs used to run its own first_order_intercept call to @@ -151,10 +162,12 @@ public static class FireControl return Accuracy(source) * pSensor * pSpread; } - // Cut 3, R1: called once per burst step from InstantWeapon.Execute. Computes the predicted intercept and - // flight time, freezes the payload snapshot (R10, Q6 -- the gun that fired it, not a re-read later), and - // queues a PendingShot for Zone.Step to age and eventually resolve. Returns the ShotId so the caller's - // OnFire event can carry it to presentation. + // 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 + // presentation. Cut 5, 5.7 (Soul finding 12): does not store a predicted intercept -- Commit judges + // deviation against a straight-line projection from FireTargetPosition/FireTargetVelocity, never the + // intercept, so a stored copy decided nothing and PendingShot no longer carries one. // Cut 4 (docs/fire-control-cut.md): damageOverride lets a continuous weapon fire a shot for less than its // full Damage -- ConstantWeapon.Execute rolls one of these per GameplaySettings.BeamResolveInterval, for // Damage * interval, through this exact same freeze-and-queue path (a flight time of zero, since a beam's @@ -173,12 +186,10 @@ public static class FireControl var targetVelocity = float3.zero; var targetPosition = source.Position; var flightTime = 0f; - var intercept = source.Position; if (target != null) { targetVelocity = float3(target.Velocity.x, 0, target.Velocity.y); targetPosition = target.Position; - intercept = PredictedIntercept(weapon, source, target); var range = length(targetPosition - source.Position); flightTime = weapon.Velocity > .01f ? range / weapon.Velocity : 0f; } @@ -201,8 +212,6 @@ public static class FireControl FireTime = now, FireTargetPosition = targetPosition, FireTargetVelocity = targetVelocity, - PredictedIntercept = intercept, - FlightTime = flightTime, ArrivalTime = now + flightTime, CommitTime = now + max(0f, flightTime - commitHorizon), Committed = false @@ -231,7 +240,7 @@ public static class FireControl { if (!shot.Committed) { - shot.Outcome = MakeOutcome(shot, false, false, int2.zero, null, now); + shot.Outcome = MakeOutcome(shot, false, false, false, int2.zero, null, now); zone.ShotCommitted.OnNext(shot.Outcome); } zone.ShotResolved.OnNext(shot.Outcome); @@ -270,9 +279,9 @@ public static class FireControl var elapsed = now - shot.FireTime; var predicted = shot.FireTargetPosition + shot.FireTargetVelocity * elapsed; var deviation = length((shot.Target.Position - predicted).xz); - var pDeviation = shot.Tracking > 0f - ? saturate(1f - deviation / shot.Tracking) - : (deviation < .01f ? 1f : 0f); + // Cut 5, 5.1: Tracking is always positive now (Tracking() above never returns 0), so the branch + // that used to turn a Tracking-less shooter's forgiveness into a hard <.01f wall is gone outright. + var pDeviation = saturate(1f - deviation / shot.Tracking); p *= pDeviation; } @@ -280,6 +289,7 @@ public static class FireControl var cell = int2.zero; EquippedItem aimed = null; var shielded = false; + var shieldBroken = false; if (hit) { @@ -297,12 +307,13 @@ public static class FireControl } var shield = shot.Target.Shield; - if (shield != null && shield.Item.Active.Value && shield.CanTakeHit(shot.DamageType, shot.Damage)) - shielded = true; + var shieldActive = shield != null && shield.Item.Active.Value; + if (shieldActive && shield.CanTakeHit(shot.DamageType, shot.Damage)) shielded = true; + else if (shieldActive) shieldBroken = true; } shot.Source.ItemManager.Random = random; - return MakeOutcome(shot, hit, shielded, cell, aimed, now); + return MakeOutcome(shot, hit, shielded, shieldBroken, cell, aimed, now); } // R4: the commit is authoritative and immutable from here on -- this only performs what Commit already @@ -312,6 +323,10 @@ public static class FireControl { if (!shot.Outcome.Hit) return; + // Cut 5, 5.2 (Soul finding 4): the frozen decision, not a re-check -- a shield that recharges during + // the flight does not retroactively survive a hit that broke it at commit time. + if (shot.Outcome.ShieldBroken) shot.Target.Shield.Break(); + if (shot.Outcome.Shielded) { shot.Target.Shield.TakeHit(shot.DamageType, shot.Damage); @@ -325,7 +340,7 @@ public static class FireControl shot.Target.ApplyHit(shot.Source, shot.Outcome.Cell, shot.DamageSpread, shot.Penetration, shot.Damage, hitDirection); } - private static ShotOutcome MakeOutcome(PendingShot shot, bool hit, bool shielded, int2 cell, EquippedItem aimed, float now) + private static ShotOutcome MakeOutcome(PendingShot shot, bool hit, bool shielded, bool shieldBroken, int2 cell, EquippedItem aimed, float now) { return new ShotOutcome { @@ -335,6 +350,7 @@ public static class FireControl Weapon = shot.Weapon, Hit = hit, Shielded = shielded, + ShieldBroken = shieldBroken, Aimed = aimed, Cell = cell, ArrivalIn = max(0f, shot.ArrivalTime - now), @@ -422,8 +438,6 @@ public struct PendingShot public float3 FireTargetPosition; public float3 FireTargetVelocity; - public float3 PredictedIntercept; - public float FlightTime; public float FireTime; public float CommitTime; public float ArrivalTime; @@ -444,6 +458,10 @@ public sealed class ShotOutcome public EquippedItem Weapon; public bool Hit; public bool Shielded; + // Cut 5, 5.2 (Soul finding 4): frozen alongside the rest of the outcome (R4) -- a shield present, active, + // and unable to CanTakeHit this shot is decided broken right here, so Apply performs Break() rather than + // deciding it, and a shield that recharges mid-flight cannot retroactively dodge it. + public bool ShieldBroken; public EquippedItem Aimed; public int2 Cell; public float ArrivalIn; diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index 35c53693f..52949f04d 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -221,6 +221,12 @@ public class GameplaySettings // unaware, and little else. One authored setting, easy to find and turn -- the operator rules the real // figure in play. public float UnaidedAccuracy = .05f; + // Cut 5 (docs/fire-control-cut.md, 5.1, Soul finding 3): the unaided fallback for Tracking, alongside + // UnaidedAccuracy above -- an entity with no working targeting system used to fall back to Tracking 0, + // which Commit turned into a hard wall (any nonzero deviation was an automatic miss). Ten world units of + // fire-time-projection deviation forgiveness is the first guess and the operator's knob, the same shape + // as UnaidedAccuracy: authored deliberately bad, not authored broken. + public float UnaidedTracking = 10f; public float AgentRangeExponent = .25f; public float AgentForwardLerp = .5f; public float AgentMaxForwardDistance = 50; diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 36e95ba57..c7cae537f 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -64,6 +64,12 @@ public class Zone _random = new Random(galaxyZone?.Name.StableHash() ?? 1337u); var cache = itemManager.ItemData; + // Cut 5, 5.6 (docs/fire-control-cut.md, Soul finding 7; operator ruling Q3): death removes the ship, + // in the simulation, not only in Unity's own loot-drop subscription. One subscription point covers + // every join, whichever call site adds the entity (deserialization below, a jump, a spawned turret) -- + // ObserveAdd fires for all of them. Forbidden writer: no presentation may remove an entity from Zone. + Entities.ObserveAdd().Subscribe(add => add.Value.Death.Subscribe(_ => Entities.Remove(add.Value))); + foreach (var orbit in pack.Orbits) { Orbits.Add(orbit.Key, new Orbit(Settings, cache.Get(orbit))); diff --git a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs index 1531b929d..1fb4d0554 100644 --- a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs +++ b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs @@ -344,8 +344,14 @@ public sealed class FireAuthorityTests : IDisposable var settings = Settings(); settings.CommitHorizon = commitHorizon; var e = Build(settings, damage: 10, velocity: 10, accuracy: 1, resolution: 1, spread: 0, tracking: 1f, targetRange: 50); - // Target sits still (velocity 0) so the "predicted" position is just its fire-time position; - // jinking means moving it away from that point before the check time. + // Cut 5 (Soul's own named survivor): a stationary target left the map's declared mutation + // (elapsed = shot.FlightTime in place of now - shot.FireTime) invisible, because + // FireTargetVelocity * elapsed is identically zero either way. A real, nonzero velocity -- Ship. + // Update already integrates Position.xz += Velocity * delta every tick on its own, no manual + // tracking needed -- makes the mutation observable even with no jink at all: the mutant + // mispredicts the intercept by Velocity * CommitHorizon, which the post-commit-jink assertion + // below (expected to still hit) catches as a spurious miss. + e.Target.Velocity = float2(4, 0); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); var t = 0f; const float dt = .05f; diff --git a/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs new file mode 100644 index 000000000..f6d473f62 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs @@ -0,0 +1,619 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using UniRx; +using Xunit; +using static CultMath.math; +using float3 = CultMath.float3; +using int2 = CultMath.int2; +using Random = CultMath.Random; + +// Cut 5 (docs/fire-control-cut.md, "the rules that landed in one path and not its twin"): pins 5.1-5.7, each +// a rule the campaign declared and implemented on one path while leaving it off the path beside it. Builds +// its own fixtures, the same convention every earlier cut's test file establishes, rather than reusing +// FireAuthorityTests'/FireControlCut4Tests'/TargetingSystemTests' private helpers. +public sealed class FireControlCut5Tests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut5-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public FireControlCut5Tests() => Directory.CreateDirectory(_root); + + private readonly List _openCaches = new List(); + + public void Dispose() + { + foreach (var c in _openCaches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static GameplaySettings TestSettings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetDetectionInfoThreshold = .1f, + TargetArmorInfoThreshold = .2f, + TargetGearInfoThreshold = .8f, + FiringArc = 170, + CommitHorizon = .5f, + SchematicCellSize = 1f, + UnaidedAccuracy = .05f, + UnaidedTracking = 10f, + AgentMinHitProbability = 0f, + 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; + } + + // ---- A discrete-weapon shooter/target engagement, the same shape FireAuthorityTests.Build uses, with an + // optional weak shield (capacity small enough that a real hit overwhelms it, for 5.2). ---- + + private sealed class Engagement + { + public ItemManager Items; + public Zone Zone; + public Ship Shooter; + public Ship Target; + public EquippedItem WeaponItem; + public InstantWeapon Weapon; + } + + private Engagement Build( + GameplaySettings settings, + float damage = 10, float range = 1000, float minRange = 0, float velocity = 0, + float spread = 0, float damageSpread = 0, float penetration = 0, + float accuracy = 1, float resolution = 1, float precision = 0, float tracking = 1000, + float targetRange = 100, bool equipTargeting = true, float hullDurability = 1000, float armor = 0, + bool equipShield = false, float shieldCapacity = 1000, + Action beforeActivate = null) + { + var hullShape = SolidShape(5, 5); + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = hullShape, Durability = hullDurability, Mass = 1000, Armor = armor, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(hullData); + cache.Upsert(new GearData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new InstantWeaponData + { + Damage = Constant(damage), Range = Constant(range), MinRange = Constant(minRange), + Velocity = Constant(velocity), Spread = Constant(spread), DamageSpread = Constant(damageSpread), + Penetration = Constant(penetration), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } + } } + }); + cache.Upsert(new GearData + { + Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new TargetingSystemData + { + Accuracy = Constant(accuracy), Resolution = Constant(resolution), Precision = Constant(precision), Tracking = Constant(tracking) + } } + }); + cache.Upsert(new GearData + { + Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new ReactorData { Charge = Constant(1000), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) } } + }); + cache.Upsert(new GearData + { + Name = "Shield", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + // Capacity is deliberately the caller's own knob: 5.2 needs a shield too weak to CanTakeHit the + // shot under test, so its damage overwhelms this reserve outright. + Behaviors = { new ShieldData { Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(shieldCapacity), RefillDuration = Constant(.01f), RestoreDuration = Constant(.01f) } } + }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; + for (var i = 5; i <= 12; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooterHull = new EquippableItem { Data = hullRef, Durability = hullDurability, Lot = 1 }; + var shooter = new Ship(items, zone, shooterHull, new EntitySettings()); + + var gunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); + var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 2 }; + Assert.True(shooter.TryEquip(gun, new int2(0, 0))); + var weaponItem = shooter.Equipment.Single(e => e.EquippableItem == gun); + var weapon = (InstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon); + + if (equipTargeting) + { + var targetingRef = items.ItemData.RefOf(items.ItemData.GetByName("Targeting")); + var targeting = new EquippableItem { Data = targetingRef, Durability = 1, Lot = 3 }; + Assert.True(shooter.TryFindSpace(targeting, out var tpos)); + Assert.True(shooter.TryEquip(targeting, tpos)); + } + + var targetHull = new EquippableItem { Data = hullRef, Durability = hullDurability, Lot = 4 }; + var target = new Ship(items, zone, targetHull, new EntitySettings()); + var targetGunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); + var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 9 }; + Assert.True(target.TryEquip(targetGun, new int2(0, 0))); + + if (equipShield) + { + var reactorRef = items.ItemData.RefOf(items.ItemData.GetByName("Reactor")); + var reactor = new EquippableItem { Data = reactorRef, Durability = 10, Lot = 10 }; + Assert.True(target.TryFindSpace(reactor, out var rpos)); + Assert.True(target.TryEquip(reactor, rpos)); + + var shieldRef = items.ItemData.RefOf(items.ItemData.GetByName("Shield")); + var shield = new EquippableItem { Data = shieldRef, Durability = 10, Lot = 11 }; + Assert.True(target.TryFindSpace(shield, out var spos)); + Assert.True(target.TryEquip(shield, spos)); + } + + beforeActivate?.Invoke(items, shooter, target); + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + shooter.Position = float3.zero; + target.Position = float3(0, 0, targetRange); + shooter.Target.Value = target; + shooter.EntityInfoGathered[target] = 1f; + shooter.SetIff(target, true); + + if (equipShield) target.Shield.Item.Enabled.Value = true; + + zone.Update(0f); // warm up resolved stats + + return new Engagement { Items = items, Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, Weapon = weapon }; + } + + // ---- 5.1: Tracking is a floor, not a cliff (Soul finding 3). ---- + + // FireControl.Tracking's own unaided fallback: an entity with no working targeting system used to fall + // back to 0, which Commit's old ternary turned into a hard wall (deviation < .01f ? 1 : 0) -- Q4's + // "really, really bad" became "impossible." Mutation: revert the fallback to a literal 0f. + [Fact] + public void UnaidedTrackingFallsBackToSettingNotZero() + { + var e = Build(TestSettings(), equipTargeting: false); + Assert.Equal(TestSettings().UnaidedTracking, FireControl.Tracking(e.Shooter)); + } + + // The wall itself, end to end: an unaided shooter (Tracking = UnaidedTracking, not 0) against a target + // that strayed a real but forgivable distance from its fire-time position still lands hits over a run of + // shots. Mutation: restore Commit's deviation<.01f wall (or equivalently, the 0f fallback above) -- either + // makes hitCount deterministically 0 for every shot in this run, since the jink (3 units) is comfortably + // above the old wall's .01f tolerance. + [Fact] + public void TrackingIsAFloorNotACliff() + { + var settings = TestSettings(); + settings.UnaidedAccuracy = 1f; // isolate this test from Q4's own low ceiling + settings.UnaidedTracking = 10f; + var e = Build(settings, damage: 5, velocity: 20, accuracy: 1, resolution: 1, spread: 0, + targetRange: 100, equipTargeting: false); // flight time 5s, well past the .5s commit horizon + e.Items.Random = new Random(9001u); + + var hits = 0; + e.Zone.ShotResolved.Subscribe(o => { if (o.Hit) hits++; }); + + var basePosition = e.Target.Position; + for (var i = 0; i < 30; i++) + { + e.Target.Position = basePosition; // reset before each shot -- Fire captures this as FireTargetPosition + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Target.Position = basePosition + float3(3, 0, 0); // jink: a real, forgivable deviation (< Tracking) + while (e.Zone.PendingShots.Count > 0) e.Zone.Update(.1f); + } + + Assert.True(hits > 0); + } + + // ---- 5.2: a shot the shield cannot absorb breaks it (Soul finding 4). ---- + + // An unabsorbable hit (damage far past the reserve's Capacity) is decided ShieldBroken at commit and + // Apply performs Break() before routing the remainder to the hull. Mutation: delete shield.Break() from + // Apply (FireControl.cs, the map's own named mutation) -- the shield never breaks and the very next shot + // is absorbed again on schedule instead of paying RestoreDuration. + [Fact] + public void UnabsorbableHitBreaksShield() + { + var e = Build(TestSettings(), damage: 50, velocity: 0, accuracy: 1, resolution: 1, spread: 0, armor: 0, + equipShield: true, shieldCapacity: 5); // 50 damage against a 5-capacity reserve: CanTakeHit is false + for (var i = 0; i < 20; i++) e.Zone.Update(.1f); // charge the (small) reserve to full + Assert.False(e.Target.Shield.Broken); + Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 50)); + + var beforeHull = e.Target.Hull.Durability; + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.1f); + + Assert.True(e.Target.Shield.Broken); + Assert.True(e.Target.Hull.Durability < beforeHull); // the shield didn't absorb it -- the hull did + } + + // ---- 5.3: continuous weapons obey their arc (Soul finding 9). ---- + + private sealed class BeamEngagement + { + public ItemManager Items; + public Zone Zone; + public Ship Shooter; + public ConstantWeapon Weapon; + } + + private BeamEngagement BuildBeam(GameplaySettings settings, float3 targetPosition) + { + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = SolidShape(5, 5), Durability = 1000, Mass = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }); + cache.Upsert(new GearData + { + Name = "Beam", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new ConstantWeaponData + { + Damage = Constant(10), Range = Constant(1000), MinRange = Constant(0), Velocity = Constant(0), + Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } + } } + }); + 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.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[5] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooterHull = new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }; + var shooter = new Ship(items, zone, shooterHull, new EntitySettings()); + + var gunRef = items.ItemData.RefOf(items.ItemData.GetByName("Beam")); + var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 2 }; + Assert.True(shooter.TryEquip(gun, new int2(0, 0))); + var weaponItem = shooter.Equipment.Single(e => e.EquippableItem == gun); + var weapon = (ConstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon); + + var reactorRef = items.ItemData.RefOf(items.ItemData.GetByName("Reactor")); + var reactor = new EquippableItem { Data = reactorRef, Durability = 10, Lot = 3 }; + Assert.True(shooter.TryFindSpace(reactor, out var rpos)); + Assert.True(shooter.TryEquip(reactor, rpos)); + + var targetHull = new EquippableItem { Data = hullRef, Durability = 1000, Lot = 4 }; + var target = new Ship(items, zone, targetHull, new EntitySettings()); + var targetGunRef = items.ItemData.RefOf(items.ItemData.GetByName("Beam")); + var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 5 }; + Assert.True(target.TryEquip(targetGun, new int2(0, 0))); + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + shooter.Position = float3.zero; + target.Position = targetPosition; + shooter.Target.Value = target; + shooter.EntityInfoGathered[target] = 1f; + shooter.SetIff(target, true); + + zone.Update(0f); // warm up resolved stats + + return new BeamEngagement { Items = items, Zone = zone, Shooter = shooter, Weapon = weapon }; + } + + // A side-mounted beam does not fire forward: StanceAllowsFire alone used to gate ConstantWeapon, so a + // target well outside a narrow arc still drew power and fired. Mutation: drop ArcAllowsFire from either + // gate (PowerRequest or Execute's safety check), restoring StanceAllowsFire-only gating. + [Fact] + public void ConstantWeaponObeysArc() + { + var settings = TestSettings(); + settings.FiringArc = 60; // narrow -- an abeam target is well outside it + var e = BuildBeam(settings, float3(100, 0, 0)); // directly abeam + + e.Weapon.Activate(); + Assert.True(e.Weapon.Firing); // Activate() sets _firing unconditionally, same as InstantWeapon.Trigger + + var resolvedCount = 0; + e.Zone.ShotResolved.Subscribe(_ => resolvedCount++); + e.Zone.Update(.1f); // ConstantWeapon.Execute runs -- the arc gate should safe it off on this tick + + Assert.False(e.Weapon.Firing); + Assert.Equal(0f, e.Weapon.PowerRequest(.1f)); + Assert.Equal(0, resolvedCount); + } + + // ---- 5.4: one bearing test, including at zero range (Soul finding 11). ---- + + private (ItemManager items, Ship shooter, Ship target, EquippedItem weaponItem) BuildPointBlank(GameplaySettings settings) + { + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = SolidShape(3, 3), Durability = 1000, Mass = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }); + cache.Upsert(new GearData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new InstantWeaponData() } + }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[5] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooter = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }, new EntitySettings()); + var gunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); + var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 2 }; + Assert.True(shooter.TryEquip(gun, new int2(0, 0))); + var weaponItem = shooter.Equipment.Single(e => e.EquippableItem == gun); + + var target = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 3 }, new EntitySettings()); + var targetGun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 4 }; + Assert.True(target.TryEquip(targetGun, new int2(0, 0))); + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + return (items, shooter, target, weaponItem); + } + + // A planar bearing shorter than 1e-6 (point-blank -- target co-located with the firer) bears rather than + // falling through normalize's NaN. Mutation: drop InArc's lengthsq guard, restoring the NaN comparison + // that silently returns false. + [Fact] + public void PointBlankBearingAlwaysBears() + { + var (_, shooter, target, weaponItem) = BuildPointBlank(TestSettings()); + shooter.Position = float3(5, 0, 5); + target.Position = shooter.Position; // zero-length bearing + + Assert.True(FireControl.InArc(weaponItem, target.Position - shooter.Position)); + + // The shared path: Weapon.ArcAllowsFire (the trigger gate every shooter passes through) must agree, + // now that it no longer carries its own special case and defers to InArc entirely. + var weapon = (Weapon) weaponItem.Behaviors.Single(b => b is Weapon); + shooter.Target.Value = target; + Assert.True(weapon.ArcAllowsFire); + } + + // A named Soul-pass survivor (docs/fire-control-cut.md Cut 5 Verification): Weapon.ArcAllowsFire must + // actually reflect InArc's real answer, not just agree with it at point-blank range. Mutation: hardcode + // ArcAllowsFire's body to `true` -- the point-blank assertion above stays green, but a genuinely abeam + // target (outside even a 170-degree arc) must read false. + [Fact] + public void WeaponArcAllowsFireReflectsRealBearing() + { + var settings = TestSettings(); + settings.FiringArc = 60; // narrow enough that abeam is unambiguously out + var (_, shooter, target, weaponItem) = BuildPointBlank(settings); + shooter.Position = float3.zero; + target.Position = float3(100, 0, 0); // directly abeam + shooter.Target.Value = target; + + var weapon = (Weapon) weaponItem.Behaviors.Single(b => b is Weapon); + Assert.False(weapon.ArcAllowsFire); + + target.Position = float3(0, 0, 100); // dead ahead + Assert.True(weapon.ArcAllowsFire); + } + + // A named Soul-pass survivor (docs/fire-control-cut.md Cut 5 Verification): the player's own trigger + // (InstantWeapon.Trigger, reached through Activate()) must actually refuse to queue a shot when + // ArcAllowsFire is false -- Q2's ruling that manual and programmatic fire are one truth, pinned at the + // one place every shooter's trigger passes through. Mutation: delete + // `if (!ArcAllowsFire) return;` from InstantWeapon.Trigger -- Activate() would then queue and fire a + // burst even dead abeam. + [Fact] + public void PlayerTriggerObeysArcGate() + { + var settings = TestSettings(); + settings.FiringArc = 60; + var e = Build(settings, damage: 10, velocity: 0, accuracy: 1, resolution: 1, spread: 0, targetRange: 100); + e.Shooter.Position = float3.zero; + e.Target.Position = float3(100, 0, 0); // abeam -- outside the narrow 60-degree arc + e.Shooter.Target.Value = e.Target; + + var resolvedCount = 0; + e.Zone.ShotResolved.Subscribe(_ => resolvedCount++); + + e.Weapon.Activate(); // the player's own trigger path (CanFire -> Trigger() -> the arc gate) + e.Zone.Update(.1f); + + Assert.Equal(0, resolvedCount); + } + + // A named Soul-pass survivor (docs/fire-control-cut.md Cut 5 Verification): FireControl.Splash's shield + // branch must actually break an unabsorbed shield, not merely route the remainder to the hull. Mutation: + // delete `shield.Break()` from Splash -- the target's hull still takes the blast (SplashHitsEveryEntityInRadius + // stays green), but the shield itself never registers the overwhelming hit and keeps absorbing on schedule. + [Fact] + public void SplashBreaksUnabsorbedShield() + { + var e = Build(TestSettings(), damage: 10, velocity: 0, accuracy: 1, resolution: 1, spread: 0, armor: 0, + equipShield: true, shieldCapacity: 5); + for (var i = 0; i < 20; i++) e.Zone.Update(.1f); // charge the (small) reserve to full + Assert.False(e.Target.Shield.Broken); + Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 500)); + + FireControl.Splash(e.Zone, e.Target.Position, radius: 50, damage: 500, damageType: DamageType.Kinetic); + + Assert.True(e.Target.Shield.Broken); + } + + // ---- 5.5: a destroyed subsystem stops being the aim point (Soul finding 10). ---- + + private (ItemManager items, Ship target, EquippedItem big, EquippedItem medium) BuildRevealTarget(GameplaySettings settings) + { + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = SolidShape(6, 4), Durability = 1000, Mass = 1000 + }); + var bigShape = new Shape(2, 1); + bigShape[new int2(0, 0)] = true; + bigShape[new int2(1, 0)] = true; + cache.Upsert(new GearData + { + Name = "Big", Hardpoint = HardpointType.Tool, Shape = bigShape, Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + cache.Upsert(new GearData + { + Name = "Medium", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 }; + ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var target = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }, new EntitySettings()); + + var bigRef = items.ItemData.RefOf(items.ItemData.GetByName("Big")); + var big = new EquippableItem { Data = bigRef, Durability = 1, Lot = 2 }; + Assert.True(target.TryFindSpace(big, out var bigPos)); + Assert.True(target.TryEquip(big, bigPos)); + var bigItem = target.Equipment.Single(e => e.EquippableItem == big); + + var mediumRef = items.ItemData.RefOf(items.ItemData.GetByName("Medium")); + var medium = new EquippableItem { Data = mediumRef, Durability = 1, Lot = 3 }; + Assert.True(target.TryFindSpace(medium, out var mediumPos)); + Assert.True(target.TryEquip(medium, mediumPos)); + var mediumItem = target.Equipment.Single(e => e.EquippableItem == medium); + + zone.Entities.Add(target); + target.Activate(); + + return (items, target, bigItem, mediumItem); + } + + // A destroyed item drops out of the ranking entirely: it is never revealed regardless of info, and the + // survivor's own tier closes up over the gap rather than leaving one. Mutation: drop the Durability filter + // from IsRevealed's ranking (a destroyed item keeps its old tier and TargetItem keeps aiming at a corpse). + [Fact] + public void DestroyedItemStopsBeingAimPoint() + { + var settings = TestSettings(); // Armor .2, Gear .8 + var (items, target, big, medium) = BuildRevealTarget(settings); + var observerHullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var observer = new Ship(items, target.Zone, new EquippableItem { Data = observerHullRef, Durability = 1000, Lot = 1 }, new EntitySettings()); + target.Zone.Entities.Add(observer); + observer.Activate(); + observer.Target.Value = target; + + // With both items alive: Big (index 0) reveals at .2, Medium (index 1) at .8. + observer.EntityInfoGathered[target] = .3f; + Assert.True(FireControl.IsRevealed(observer, big)); + Assert.False(FireControl.IsRevealed(observer, medium)); + Assert.True(observer.TrySelectTargetItem(big)); + + // Destroy Big: it drops out of the ranking outright, so it can never be revealed again regardless of + // info, and it stops being a valid aim point on the very next read -- no clearing loop needed. + big.EquippableItem.Durability = 0f; + Assert.False(FireControl.IsRevealed(observer, big)); + Assert.Null(observer.ResolvedTargetItem); + + // Medium is now the only survivor, so it inherits index 0's own tier (.2), not its old index-1 tier + // (.8) -- the gap closes rather than leaving Medium stranded behind a tier meant for a corpse. + Assert.True(FireControl.IsRevealed(observer, medium)); + } + + // ---- 5.6: death removes the ship, in the simulation (Soul finding 7). ---- + + // Zone itself removes a dead entity from Entities -- not only Unity's own loot-drop subscription, which + // this test suite never runs. Mutation: remove the ObserveAdd/Death subscription from Zone's constructor + // (or narrow it to only entities added through the deserialization loop) -- the target then never leaves + // Entities and DeadEntityStopsTakingShots-shaped assertions would only pass by calling Remove by hand. + [Fact] + public void DeathRemovesShipFromSimulation() + { + var e = Build(TestSettings(), damage: 10000, velocity: 0, accuracy: 1, resolution: 1, spread: 0, + hullDurability: 5, armor: 0); // one hit is lethal + + Assert.Contains(e.Target, e.Zone.Entities); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.1f); + + Assert.True(e.Target.Hull.Durability < .01f); + Assert.DoesNotContain(e.Target, e.Zone.Entities); + } + + // ---- 5.7: delete the two carried-and-unread fields (Soul finding 12). ---- + + // PendingShot.PredictedIntercept and .FlightTime decided nothing (Commit judges deviation against + // FireTargetPosition/FireTargetVelocity, never the stored intercept) and are gone. Mutation: re-add either + // field to the struct. + [Fact] + public void PendingShotCarriesNoDeadFields() + { + var fields = typeof(PendingShot).GetFields().Select(f => f.Name).ToList(); + Assert.DoesNotContain("PredictedIntercept", fields); + Assert.DoesNotContain("FlightTime", fields); + } +} diff --git a/tests/mutation_tests_fire_control_cut5.sh b/tests/mutation_tests_fire_control_cut5.sh new file mode 100644 index 000000000..08cce0645 --- /dev/null +++ b/tests/mutation_tests_fire_control_cut5.sh @@ -0,0 +1,281 @@ +#!/usr/bin/env bash +# Mutation tests for docs/fire-control-cut.md's Cut 5 ("the rules that landed in one path and not its twin"). +# +# Eleven mutations, each against tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs or +# tests/Aetheria.Shared.Tests/FireAuthorityTests.cs: +# +# 1-7. 5.1 through 5.7, each killing the test this cut wrote or extended to pin it: +# 5.1 UnaidedTrackingFallsBackToSettingNotZero / TrackingIsAFloorNotACliff +# 5.2 UnabsorbableHitBreaksShield +# 5.3 ConstantWeaponObeysArc +# 5.4 PointBlankBearingAlwaysBears +# 5.5 DestroyedItemStopsBeingAimPoint +# 5.6 DeathRemovesShipFromSimulation +# 5.7 PendingShotCarriesNoDeadFields +# 8-11. Four of the five mutations that survived 197 green tests in Soul's own pass (the fifth, +# freezing source.TargetItem.Value instead of ResolvedTargetItem, is Cut 7's): +# FireControl.cs -- delete shield.Break() from Splash +# Weapon.cs -- ArcAllowsFire hardcoded true +# InstantWeapon.cs -- delete the player's own arc gate +# FireControl.cs (Commit) -- elapsed pinned to the shot's own duration instead of tracking `now` +# (the map's own named mutation for EvasionCountsUntilCommitAndNotAfter; post-5.7 the spelling +# is `shot.ArrivalTime - shot.FireTime` rather than the deleted FlightTime field, same rule) +# +# A no-op control (a byte-identical rewrite of one target file through this script's own read/write path) +# proves the mechanism itself is transparent before any real mutation is trusted. +# +# Usage: +# tests/mutation_tests_fire_control_cut5.sh +# +# Every mutation is reversed (byte-exact, from an in-memory copy) before the script exits, including on +# failure or interrupt. + +set -u + +if [ $# -lt 1 ]; then + echo "Usage: $0 " >&2 + exit 2 +fi +CULTLIB_ROOT="$1" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +TEST_PROJECT="$REPO_ROOT/tests/Aetheria.Shared.Tests" + +FIRE_CONTROL_CS="$REPO_ROOT/Assets/Scripts/ServerShared/FireControl.cs" +WEAPON_CS="$REPO_ROOT/Assets/Scripts/ServerShared/Behaviors/Weapon.cs" +INSTANT_WEAPON_CS="$REPO_ROOT/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs" +CONSTANT_WEAPON_CS="$REPO_ROOT/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs" +ZONE_CS="$REPO_ROOT/Assets/Scripts/ServerShared/Zone.cs" + +# --- byte-exact backup/restore: preserves line endings exactly, no text-mode translation. --- +declare -A ORIGINALS +FILES=("$FIRE_CONTROL_CS" "$WEAPON_CS" "$INSTANT_WEAPON_CS" "$CONSTANT_WEAPON_CS" "$ZONE_CS") +BACKUP_DIR="$(mktemp -d)" +for f in "${FILES[@]}"; do + base="$(basename "$f")" + cp -p "$f" "$BACKUP_DIR/$base.orig" + ORIGINALS["$f"]="$BACKUP_DIR/$base.orig" +done + +restore_all() { + for f in "${FILES[@]}"; do + cp -p "${ORIGINALS[$f]}" "$f" + done +} +trap restore_all EXIT INT TERM + +FAILURES=() + +# replace_unique FILE ANCHOR REPLACEMENT +# Anchor must occur exactly once (byte-exact match via awk/perl, not sed's line-oriented rewriting, so a +# multi-line anchor is matched as literal text). Writes the mutated content back byte-exact. +replace_unique() { + local file="$1" anchor="$2" replacement="$3" + local count + count=$(perl -0777 -e ' + my ($anchor, $file) = @ARGV; + local $/; open(my $fh, "<:raw", $file) or die $!; + my $text = <$fh>; close $fh; + my $n = () = $text =~ /\Q$anchor\E/g; + print $n; + ' "$anchor" "$file") + if [ "$count" != "1" ]; then + echo "ERROR: $file: anchor must occur exactly once, found $count" >&2 + echo "--- anchor ---" >&2 + echo "$anchor" >&2 + return 1 + fi + # No -i: on this filesystem perl's in-place-edit extension parsing has mangled a literal ":raw" suffix + # into stray sibling files before. Read, substitute once, write back byte-exact instead. + perl -0777 -e ' + my ($file, $anchor, $replacement) = @ARGV; + local $/; open(my $fh, "<:raw", $file) or die $!; + my $text = <$fh>; close $fh; + my $qa = quotemeta($anchor); + $text =~ s/$qa/$replacement/; + open(my $out, ">:raw", $file) or die $!; + print $out $text; close $out; + ' "$file" "$anchor" "$replacement" +} + +# A dedicated, isolated artifacts path -- the default tests/Aetheria.Shared.Tests/bin,obj can carry stale +# prebuilt CultLib assemblies from an unrelated earlier session's default-path build, and mixing those with a +# freshly compiled Aetheria.Shared.Tests.dll produced one observed spurious failure of a control (no-op) +# mutation. A private --artifacts-path sidesteps that shared, unowned build state entirely. +ARTIFACTS_PATH="$(mktemp -d)" + +# run_test TEST_FQN -> 0 (passed) or 1 (failed/errored) +run_test() { + local test="$1" + local out + out=$(dotnet test "$TEST_PROJECT" -p:CultLibRoot="$CULTLIB_ROOT" --artifacts-path "$ARTIFACTS_PATH" --filter "FullyQualifiedName~$test" 2>&1) + if echo "$out" | grep -q "Passed!" && ! echo "$out" | grep -q "Failed: [1-9]"; then + return 0 + fi + LAST_OUTPUT="$out" + return 1 +} + +# check_mutation NAME FILE ANCHOR MUTATED TEST EXPECT("red"|"green") +check_mutation() { + local name="$1" file="$2" anchor="$3" mutated="$4" test="$5" expect="$6" + echo "" + echo "=== $name (expect $expect) ===" + if ! replace_unique "$file" "$anchor" "$mutated"; then + FAILURES+=("$name: anchor replacement failed") + cp -p "${ORIGINALS[$file]}" "$file" + return + fi + if run_test "$test"; then + passed=1 + else + passed=0 + fi + cp -p "${ORIGINALS[$file]}" "$file" # restore this file immediately, before judging, so a crash still leaves a clean tree + + if [ "$expect" = "green" ]; then + if [ "$passed" = "1" ]; then + echo "OK: $test passed under a no-op mutation, as expected" + else + FAILURES+=("$name: expected green (test passing), got failure") + echo "$LAST_OUTPUT" | tail -40 + fi + else + if [ "$passed" = "0" ]; then + echo "OK: $test went red under the mutation, as expected" + else + FAILURES+=("$name: expected red (mutant killed), but $test still passed") + fi + fi +} + +# --- No-op control: a byte-identical round trip through this script's own replace_unique path, proving the +# anchor/replace/restore mechanism is transparent before any real mutation is trusted. --- +check_mutation \ + "control (no-op round trip)" \ + "$FIRE_CONTROL_CS" \ + 'var pDeviation = saturate(1f - deviation / shot.Tracking);' \ + 'var pDeviation = saturate(1f - deviation / shot.Tracking);' \ + "FireControlCut5Tests.TrackingIsAFloorNotACliff" \ + "green" + +# --- 5.1: Tracking is a floor, not a cliff. Mutation: the unaided fallback reverts to a literal 0f (division +# by zero saturates the deviation term to 0 regardless of how forgivable the deviation actually is). --- +check_mutation \ + "5.1 Tracking must fall back to UnaidedTracking, not 0" \ + "$FIRE_CONTROL_CS" \ + 'return system != null && system.Item.Active.Value ? system.Tracking : entity.ItemManager.GameplaySettings.UnaidedTracking;' \ + 'return system != null && system.Item.Active.Value ? system.Tracking : 0f;' \ + "FireControlCut5Tests.UnaidedTrackingFallsBackToSettingNotZero" \ + "red" + +# --- 5.2: a shot the shield cannot absorb breaks it. Mutation: delete Apply's Break() call -- the hull still +# takes the remainder, but the shield itself never registers the overwhelming hit. --- +check_mutation \ + "5.2 Apply must Break() a shield the roll decided ShieldBroken" \ + "$FIRE_CONTROL_CS" \ + 'if (shot.Outcome.ShieldBroken) shot.Target.Shield.Break();' \ + '// deleted' \ + "FireControlCut5Tests.UnabsorbableHitBreaksShield" \ + "red" + +# --- 5.3: continuous weapons obey their arc. Mutation: ConstantWeapon's safety check drops ArcAllowsFire, +# restoring StanceAllowsFire-only gating (a side-mounted beam fires forward). --- +check_mutation \ + "5.3 ConstantWeapon.Execute must safe off on ArcAllowsFire too, not StanceAllowsFire alone" \ + "$CONSTANT_WEAPON_CS" \ + 'if (_firing && !(StanceAllowsFire && ArcAllowsFire))' \ + 'if (_firing && !StanceAllowsFire)' \ + "FireControlCut5Tests.ConstantWeaponObeysArc" \ + "red" + +# --- 5.4: one bearing test, including at zero range. Mutation: drop InArc's point-blank guard, restoring the +# NaN comparison a zero-length bearing used to fall through to. --- +check_mutation \ + "5.4 InArc must treat a zero-length bearing as bearing true, not fall through to NaN" \ + "$FIRE_CONTROL_CS" \ + $' if (lengthsq(planarToTarget) < 1e-6f) return true;\r\n var planarTarget = normalize(planarToTarget);' \ + ' var planarTarget = normalize(planarToTarget);' \ + "FireControlCut5Tests.PointBlankBearingAlwaysBears" \ + "red" + +# --- 5.5: a destroyed subsystem stops being the aim point. Mutation: drop the Durability filter from +# IsRevealed's ranking -- a destroyed item keeps its old tier and TargetItem keeps aiming at a corpse. --- +check_mutation \ + "5.5 IsRevealed's ranking must exclude a destroyed item, not just fail its own tier" \ + "$FIRE_CONTROL_CS" \ + '.Where(e => e.Data.HardpointType != HardpointType.Hull && e.EquippableItem.Durability >= .01f)' \ + '.Where(e => e.Data.HardpointType != HardpointType.Hull)' \ + "FireControlCut5Tests.DestroyedItemStopsBeingAimPoint" \ + "red" + +# --- 5.6: death removes the ship, in the simulation. Mutation: remove Zone's own Death subscription -- +# nothing outside Unity's own (untested here) loot-drop path would ever remove the corpse. --- +check_mutation \ + "5.6 Zone must remove a dead entity itself, not rely on a presentation subscription" \ + "$ZONE_CS" \ + 'Entities.ObserveAdd().Subscribe(add => add.Value.Death.Subscribe(_ => Entities.Remove(add.Value)));' \ + '// deleted -- nothing removes a dead entity' \ + "FireControlCut5Tests.DeathRemovesShipFromSimulation" \ + "red" + +# --- 5.7: delete the two carried-and-unread fields. Mutation: re-add PredictedIntercept to the struct. --- +check_mutation \ + "5.7 PendingShot must not carry PredictedIntercept back" \ + "$FIRE_CONTROL_CS" \ + $' public float3 FireTargetVelocity;\r\n public float FireTime;' \ + $' public float3 FireTargetVelocity;\r\n public float3 PredictedIntercept;\r\n public float FireTime;' \ + "FireControlCut5Tests.PendingShotCarriesNoDeadFields" \ + "red" + +# --- Named survivor 1: FireControl.cs -- delete shield.Break() from Splash. --- +check_mutation \ + "survivor: Splash must Break() a shield it does not absorb" \ + "$FIRE_CONTROL_CS" \ + 'if (shieldActive && !shieldAbsorbs) shield.Break();' \ + '// deleted' \ + "FireControlCut5Tests.SplashBreaksUnabsorbedShield" \ + "red" + +# --- Named survivor 2: Weapon.cs -- ArcAllowsFire hardcoded true. --- +check_mutation \ + "survivor: Weapon.ArcAllowsFire must defer to InArc's real answer" \ + "$WEAPON_CS" \ + 'return FireControl.InArc(Item, toTarget);' \ + 'return true;' \ + "FireControlCut5Tests.WeaponArcAllowsFireReflectsRealBearing" \ + "red" + +# --- Named survivor 3: InstantWeapon.cs -- delete the player's own arc gate (Q2, Cut 3's own deferred rule). --- +check_mutation \ + "survivor: InstantWeapon.Trigger must refuse to queue a burst out of arc" \ + "$INSTANT_WEAPON_CS" \ + 'if (!ArcAllowsFire) return;' \ + '' \ + "FireControlCut5Tests.PlayerTriggerObeysArcGate" \ + "red" + +# --- Named survivor 4: FireControl.cs (Commit) -- elapsed pinned to the shot's own fixed duration instead of +# tracking `now`, so deviation never grows past its value at the instant the shot was queued. This is the +# map's own declared mutation for EvasionCountsUntilCommitAndNotAfter (FireAuthorityTests.cs); post-5.7 the +# spelling is `shot.ArrivalTime - shot.FireTime` (constant) rather than the deleted FlightTime field. --- +check_mutation \ + "survivor: Commit must measure elapsed against now, not a fixed duration" \ + "$FIRE_CONTROL_CS" \ + 'var elapsed = now - shot.FireTime;' \ + 'var elapsed = shot.ArrivalTime - shot.FireTime;' \ + "FireAuthorityTests.EvasionCountsUntilCommitAndNotAfter" \ + "red" + +echo "" +if [ "${#FAILURES[@]}" -gt 0 ]; then + echo "=== FAILURES ===" + for f in "${FAILURES[@]}"; do + echo "- $f" + done + exit 1 +fi + +echo "All mutations behaved as declared." +exit 0