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