diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index a28cc1be9..73ff76d33 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -1030,6 +1030,8 @@ public abstract class Entity public virtual void Update(float delta) { + if (!_active) return; + var hullData = ItemManager.GetData(Hull) as HullData; TargetRange = Target.Value == null ? -1 : length(Position - Target.Value.Position); diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 5011a3f94..14ee6b6c0 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -75,7 +75,7 @@ public class Zone // 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))); + Entities.ObserveAdd().Subscribe(add => add.Value.Death.Subscribe(_ => { Entities.Remove(add.Value); add.Value.Deactivate(); })); foreach (var orbit in pack.Orbits) { diff --git a/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs new file mode 100644 index 000000000..81256f3ec --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs @@ -0,0 +1,338 @@ +/* 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 8 (docs/fire-control-cut.md, "a removed entity is a dead entity, and the anchors the diagnostics +// moved"): pins 8.1 (removal and deactivation are one transition) and 8.2 (a deactivated entity does not +// update). Builds its own fixtures, the same convention every earlier cut's test file establishes. +public sealed class FireControlCut8Tests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut8-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public FireControlCut8Tests() => 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, + UnaidedPrecision = 2f, + 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 FireControlCut5Tests.Build uses. ---- + + 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 hullDurability = 1000) + { + var hullShape = SolidShape(5, 5); + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = hullShape, Durability = hullDurability, Mass = 1000, + 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(1000), MinRange = Constant(0), + Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(0), + Penetration = Constant(0), 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(1), Resolution = Constant(1), Precision = Constant(1000), Tracking = Constant(1000) + } } + }); + 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 = 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); + + var targetingRef = items.ItemData.RefOf(items.ItemData.GetByName("Targeting")); + var targeting = new EquippableItem { Data = targetingRef, Durability = 1, Lot = 5 }; + Assert.True(shooter.TryFindSpace(targeting, out var tpos)); + Assert.True(shooter.TryEquip(targeting, tpos)); + + var targetHull = new EquippableItem { Data = hullRef, Durability = hullDurability, Lot = 3 }; + 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 = 4 }; + 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 = float3(0, 0, 100); + shooter.Target.Value = target; + shooter.EntityInfoGathered[target] = 1f; + shooter.SetIff(target, true); + + zone.Update(0f); // warm up resolved stats + + return new Engagement { Items = items, Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, Weapon = weapon }; + } + + // ---- A LockWeapon-carrying ship with a live target, the exact indexer shape from the operator's own + // crash (LockWeapon.cs:98, docs/fire-control-cut.md Cut 8 header): Entity.EntityInfoGathered[Entity.Target.Value] + // read through a raw indexer. ---- + + private sealed class LockScenario + { + public ItemManager Items; + public Zone Zone; + public Ship Locker; + public Ship Bystander; + } + + private LockScenario BuildLockScenario(GameplaySettings settings) + { + var hullShape = SolidShape(5, 5); + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = hullShape, Durability = 1000, Mass = 1000, + 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 = "LockGun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new LockWeaponData + { + Damage = Constant(0), Range = Constant(1000), MinRange = Constant(0), Velocity = Constant(0), + Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0), Count = Constant(1), + BurstTime = Constant(0), Cooldown = Constant(1000), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }, + LockSpeed = Constant(1), SensorImpact = Constant(1), LockAngle = Constant(170), + DirectionImpact = Constant(1), Decay = Constant(1) + } } + }); + 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 }; + 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 lockerHull = new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }; + var locker = new Ship(items, zone, lockerHull, new EntitySettings()); + + var gunRef = items.ItemData.RefOf(items.ItemData.GetByName("LockGun")); + var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 2 }; + Assert.True(locker.TryEquip(gun, new int2(0, 0))); + + var bystanderHull = new EquippableItem { Data = hullRef, Durability = 1000, Lot = 3 }; + var bystander = new Ship(items, zone, bystanderHull, new EntitySettings()); + var bystanderGunRef = items.ItemData.RefOf(items.ItemData.GetByName("LockGun")); + var bystanderGun = new EquippableItem { Data = bystanderGunRef, Durability = 1, Lot = 4 }; + Assert.True(bystander.TryEquip(bystanderGun, new int2(0, 0))); // _orderedEquipment must not be empty/null for Update to run + + zone.Entities.Add(locker); + zone.Entities.Add(bystander); + locker.Activate(); + bystander.Activate(); + + locker.Position = float3.zero; + bystander.Position = float3(0, 0, 100); + locker.LookDirection = float3(0, 0, 1); + locker.Target.Value = bystander; + locker.EntityInfoGathered[bystander] = 1f; + locker.SetIff(bystander, true); + + zone.Update(0f); // warm up resolved stats -- brings weapon Online/Active up + + return new LockScenario { Items = items, Zone = zone, Locker = locker, Bystander = bystander }; + } + + // ---- 8.1/8.2: removal and deactivation are one transition, and a deactivated entity does not update. ---- + + // The always-runs prefix of Entity.Update (TargetRange, temperature, visibility decay, equipment + // performance) used to execute unconditionally, even on an entity that had already been torn down -- + // Deactivate() clears EntityInfoGathered/VisibleEntities/etc, but nothing stopped Update from still + // touching the rest of the entity's state every subsequent call it remained reachable (a stale snapshot + // slot in the same Zone.Update pass, or a docked/child entity still walked by its parent's own Children + // loop). Mutation: drop the `if (!_active) return;` guard -- TargetRange keeps recomputing against a + // live target after the entity is dead, instead of freezing at its last value. + [Fact] + public void DeadEntityDoesNotUpdate() + { + var e = Build(TestSettings()); + e.Target.Target.Value = e.Shooter; // give the target its own TargetRange to freeze + e.Zone.Update(.1f); + var rangeBeforeDeath = e.Target.TargetRange; + + e.Target.Deactivate(); + Assert.False(e.Target.Active); + + e.Shooter.Position += float3(1000, 0, 0); // a live target would recompute a very different TargetRange + e.Target.Update(.1f); + + Assert.Equal(rangeBeforeDeath, e.Target.TargetRange); + } + + // The named crash shape, as a regression pin: LockWeapon.cs:98 reaches + // Entity.EntityInfoGathered[Entity.Target.Value] through a raw indexer. Deactivate() clears + // EntityInfoGathered but does not null the entity's own Target -- so a torn-down entity whose Target is + // still set is exactly the shape of the operator's crash. This must never throw, however the entity came + // to be inactive. + [Fact] + public void LockWeaponOnDeactivatedEntityDoesNotThrow() + { + var s = BuildLockScenario(TestSettings()); + s.Locker.Update(.1f); // let the lock behavior run once while alive, same as normal play + + s.Locker.Deactivate(); + Assert.False(s.Locker.Active); + Assert.DoesNotContain(s.Bystander, s.Locker.EntityInfoGathered.Keys); + Assert.Equal(s.Bystander, s.Locker.Target.Value); // Deactivate does not null the entity's own Target + + var ex = Record.Exception(() => s.Locker.Update(.1f)); + Assert.Null(ex); + } + + // 8.1: Zone's own Death subscription must pair removal with deactivation, exactly like TryDock always has. + // Mutation: remove the Deactivate() call from Zone's Death subscription -- the target leaves Entities but + // stays active, holding live subscriptions and a stale, uncleared EntityInfoGathered/VisibleEntities. + [Fact] + public void DeathDeactivates() + { + var e = Build(TestSettings(), damage: 10000, hullDurability: 5); // one hit is lethal + Assert.True(e.Target.Active); + + 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); + Assert.False(e.Target.Active); + Assert.Empty(e.Target.EntityInfoGathered); + Assert.Empty(e.Target.VisibleEntities); + } + + // ---- Ordering risk (8.1's own callout): EntityInstance's loot-drop subscription (EntityInstance.cs:292-325) + // reads Entity.Equipment on Entity.HullDamage, independent of Entity.Death/Deactivate. EntityInstance + // itself is a MonoBehaviour and cannot run headlessly (it lives in the Gameplay assembly, not + // Aetheria.Shared), so the actual drop call (ZoneRenderer.DropItem) is an operator check, not something + // this suite reaches. What this suite CAN confirm headlessly is the data precondition that call depends + // on: Deactivate() must not clear Equipment, regardless of subscription order relative to Zone's own + // Death-triggered removal. ---- + + // Mirrors EntityInstance's own subscription shape (HullDamage, re-checking Durability itself) established + // AFTER the target has already joined the zone -- so Zone's Death-derived Deactivate (subscribed at join + // time, per 8.1) fires first on the same HullDamage.OnNext call, and this handler observes Equipment + // strictly after that. If Deactivate ever starts clearing Equipment, this goes red. + [Fact] + public void LootStillDropsOnDeath_EquipmentSurvivesDeactivate() + { + var e = Build(TestSettings(), damage: 10000, hullDurability: 5); // one hit is lethal + + List equipmentAtDeath = null; + e.Target.HullDamage.Subscribe(_ => + { + if (e.Target.Hull.Durability < .01f) equipmentAtDeath = e.Target.Equipment.ToList(); + }); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.1f); + + Assert.True(e.Target.Hull.Durability < .01f); + Assert.False(e.Target.Active); // Zone's Death subscription (subscribed before this test's own) already + // deactivated the target by the time this handler runs. + Assert.NotNull(equipmentAtDeath); + Assert.NotEmpty(equipmentAtDeath); // the target's own gun -- Deactivate must not have cleared Equipment + + // Operator check, not reachable headlessly: confirm in play that ZoneRenderer.DropItem still fires for + // each surviving equipment/cargo entry (EntityInstance.cs:292-325) after this cut, i.e. that loot + // actually drops on a live kill, not only that the data behind it survived Deactivate(). + } +} diff --git a/tests/mutation_tests_fire_control_cut3.sh b/tests/mutation_tests_fire_control_cut3.sh index edd97eb24..b32acb159 100644 --- a/tests/mutation_tests_fire_control_cut3.sh +++ b/tests/mutation_tests_fire_control_cut3.sh @@ -203,23 +203,25 @@ check_mutation \ "red" # --- ProbabilityFollowsInputs, variant 1/3: drop pSensor -- probability stops depending on detection info. --- -# Cut 6d (docs/fire-control-cut.md) added a fourth factor, pOnHull, to this same return line -- the anchor -# below tracks the current line so this still targets the pSensor drop specifically, not a byte-exact copy of -# the pre-Cut-6d line, which no longer exists. +# Cut 7 (docs/fire-control-cut.md, Cut 8 header) routed HitProbability through Inspect, replacing this same +# return line's five early returns with flags and one final gate -- the anchor below tracks Inspect's own +# spelling (diagnostic.PBase's product of factors) rather than the pre-Inspect line, which no longer exists. check_mutation \ "ProbabilityFollowsInputs (1/3): HitProbability must scale with pSensor, not drop it" \ "$FIRE_CONTROL_CS" \ - 'return Accuracy(source) * pSensor * pSpread * pOnHull;' \ - 'return Accuracy(source) * pSpread * pOnHull;' \ + 'diagnostic.PBase = diagnostic.Accuracy * diagnostic.PSensor * diagnostic.PSpread * diagnostic.POnHull;' \ + 'diagnostic.PBase = diagnostic.Accuracy * diagnostic.PSpread * diagnostic.POnHull;' \ "FireAuthorityTests.ProbabilityFollowsInputs" \ "red" -# --- ProbabilityFollowsInputs, variant 2/3: drop the range gate -- nonzero probability outside [MinRange,Range]. --- +# --- ProbabilityFollowsInputs, variant 2/3: drop the range gate -- nonzero probability outside [MinRange,Range]. +# Cut 8's own re-anchor: Inspect computes InRange as its own flag rather than an early return; forcing it to +# true unconditionally is the current spelling of "skip the check." --- check_mutation \ "ProbabilityFollowsInputs (2/3): HitProbability must gate on [MinRange, Range], not skip the check" \ "$FIRE_CONTROL_CS" \ - 'if (range < weapon.MinRange || range > weapon.Range) return 0f;' \ - '// dropped -- no range gate' \ + 'diagnostic.InRange = diagnostic.Range >= weapon.MinRange && diagnostic.Range <= weapon.Range;' \ + 'diagnostic.InRange = true;' \ "FireAuthorityTests.ProbabilityFollowsInputs" \ "red" diff --git a/tests/mutation_tests_fire_control_cut5.sh b/tests/mutation_tests_fire_control_cut5.sh index 8da53360b..77f0c71fa 100644 --- a/tests/mutation_tests_fire_control_cut5.sh +++ b/tests/mutation_tests_fire_control_cut5.sh @@ -215,8 +215,8 @@ check_mutation() { check_mutation \ "control (no-op round trip)" \ "$FIRE_CONTROL_CS" \ - 'var pDeviation = saturate(1f - deviation / shot.Tracking);' \ - 'var pDeviation = saturate(1f - deviation / shot.Tracking);' \ + 'var pDeviation = DeviationProbability(shot, now, out _);' \ + 'var pDeviation = DeviationProbability(shot, now, out _);' \ "FireControlCut5Tests.TrackingIsAFloorNotACliff" \ "green" @@ -271,11 +271,13 @@ check_mutation \ "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. --- +# nothing outside Unity's own (untested here) loot-drop path would ever remove the corpse. Cut 8's own +# re-anchor: 8.1 (docs/fire-control-cut.md) paired this subscription's Entities.Remove with a Deactivate() +# call -- only the spelling grew, the rule under test (does Zone remove the corpse at all) is unchanged. --- 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)));' \ + 'Entities.ObserveAdd().Subscribe(add => add.Value.Death.Subscribe(_ => { Entities.Remove(add.Value); add.Value.Deactivate(); }));' \ '// deleted -- nothing removes a dead entity' \ "FireControlCut5Tests.DeathRemovesShipFromSimulation" \ "red" diff --git a/tests/mutation_tests_fire_control_cut6c.sh b/tests/mutation_tests_fire_control_cut6c.sh index 2caeb8aa8..3451dc46a 100644 --- a/tests/mutation_tests_fire_control_cut6c.sh +++ b/tests/mutation_tests_fire_control_cut6c.sh @@ -189,18 +189,22 @@ check_mutation() { check_mutation \ "control (no-op round trip)" \ "$FIRE_CONTROL_CS" \ - 'var pSensor = saturate(unlerp(settings.TargetDetectionInfoThreshold, demandCeiling, info));' \ - 'var pSensor = saturate(unlerp(settings.TargetDetectionInfoThreshold, demandCeiling, info));' \ + 'diagnostic.PSensor = saturate(unlerp(settings.TargetDetectionInfoThreshold, diagnostic.InfoDemandCeiling, info));' \ + 'diagnostic.PSensor = saturate(unlerp(settings.TargetDetectionInfoThreshold, diagnostic.InfoDemandCeiling, info));' \ "FireControlCut6cTests.HigherResolutionYieldsHigherOrEqualHitProbability" \ "green" # --- 6c.1: Resolution is a benefit, so HitProbability must take its reciprocal to derive the sensor ceiling, -# not read Resolution as that ceiling directly. Mutation: restore the shipped Cut 6a reading. --- +# not read Resolution as that ceiling directly. Mutation: restore the shipped Cut 6a reading. Cut 8's own +# re-anchor: Cut 7 (docs/fire-control-cut.md, Cut 8 header) moved this calculation into Inspect, so the local +# `demandCeiling`/`pSensor` variables and the `Resolution(source)` call became `diagnostic.InfoDemandCeiling`, +# `diagnostic.PSensor` and `diagnostic.Resolution` (a field on the same diagnostic, set once at its top) -- +# only the spelling moved; the rule under test is unchanged. --- check_mutation \ "6c.1 HitProbability must take Resolution's reciprocal, not read it as the ceiling directly" \ "$FIRE_CONTROL_CS" \ - $' var demandCeiling = settings.TargetDetectionInfoThreshold +\r\n (1f - settings.TargetDetectionInfoThreshold) / max(Resolution(source), 1e-3f);\r\n var pSensor = saturate(unlerp(settings.TargetDetectionInfoThreshold, demandCeiling, info));' \ - ' var pSensor = saturate(unlerp(settings.TargetDetectionInfoThreshold, Resolution(source), info));' \ + $' diagnostic.InfoDemandCeiling = settings.TargetDetectionInfoThreshold +\r\n (1f - settings.TargetDetectionInfoThreshold) / max(diagnostic.Resolution, 1e-3f);\r\n diagnostic.PSensor = saturate(unlerp(settings.TargetDetectionInfoThreshold, diagnostic.InfoDemandCeiling, info));' \ + ' diagnostic.PSensor = saturate(unlerp(settings.TargetDetectionInfoThreshold, diagnostic.Resolution, info));' \ "FireControlCut6cTests.HigherResolutionYieldsHigherOrEqualHitProbability" \ "red" diff --git a/tests/mutation_tests_fire_control_cut6d.sh b/tests/mutation_tests_fire_control_cut6d.sh index 0f843983a..1f88298c6 100644 --- a/tests/mutation_tests_fire_control_cut6d.sh +++ b/tests/mutation_tests_fire_control_cut6d.sh @@ -220,25 +220,29 @@ check_mutation \ # --- ThinLimbCostsHitChance: drop pOnHull from the probability. Mutation: HitProbability's return loses its # fourth factor -- aiming at a thin limb and aiming at the centre of mass then score identically, since -# nothing left in the product carries a spatial term at all. --- +# nothing left in the product carries a spatial term at all. Cut 8's own re-anchor: Cut 7 (docs/fire-control- +# cut.md, Cut 8 header) moved this calculation into Inspect; only the spelling (diagnostic.PBase's product) +# moved, the rule is unchanged. --- check_mutation \ "ThinLimbCostsHitChance: HitProbability must carry pOnHull, not drop it" \ "$FIRE_CONTROL_CS" \ - 'return Accuracy(source) * pSensor * pSpread * pOnHull;' \ - 'return Accuracy(source) * pSensor * pSpread;' \ + 'diagnostic.PBase = diagnostic.Accuracy * diagnostic.PSensor * diagnostic.PSpread * diagnostic.POnHull;' \ + 'diagnostic.PBase = diagnostic.Accuracy * diagnostic.PSensor * diagnostic.PSpread;' \ "FireControlCut6dTests.ThinLimbCostsHitChance" \ "red" # --- PlacementAndProbabilityShareOneKernel: perturb sigma in one of the two call sites only. Mutation: # HitProbability's own call into the kernel reads a Precision inflated by 1.5x, while Commit's placement call # (WeightedPick's own kernel read, a few hundred lines below) is untouched -- the two functions answering -# "where will this shot go" now disagree, which is exactly the named risk this cut's own comments call out -# One kernel, computed by one function" no longer holds once a caller perturbs its own input. --- +# "where will this shot go" now disagree, which is exactly the named risk this cut's own comments call out -- +# "One kernel, computed by one function" no longer holds once a caller perturbs its own input. Cut 8's own +# re-anchor: this call now lives in Inspect and reads diagnostic.Precision (set once at the diagnostic's own +# top from Precision(source)) rather than calling Precision(source) a second time; only the spelling moved. --- check_mutation \ "PlacementAndProbabilityShareOneKernel: HitProbability and Commit must read the same Precision into the kernel" \ "$FIRE_CONTROL_CS" \ - 'var pOnHull = HullKernel(targetHull, ResolveAimPoint(target, targetHull, source.ResolvedTargetItem).AimPoint, Precision(source)).POnHull;' \ - 'var pOnHull = HullKernel(targetHull, ResolveAimPoint(target, targetHull, source.ResolvedTargetItem).AimPoint, Precision(source) * 1.5f).POnHull;' \ + $' diagnostic.POnHull = HullKernel(targetHull,\r\n ResolveAimPoint(target, targetHull, source.ResolvedTargetItem).AimPoint, diagnostic.Precision).POnHull;' \ + ' diagnostic.POnHull = HullKernel(targetHull, ResolveAimPoint(target, targetHull, source.ResolvedTargetItem).AimPoint, diagnostic.Precision * 1.5f).POnHull;' \ "FireControlCut6dTests.PlacementAndProbabilityShareOneKernel" \ "red" diff --git a/tests/mutation_tests_fire_control_cut8.sh b/tests/mutation_tests_fire_control_cut8.sh new file mode 100644 index 000000000..3cf6f07df --- /dev/null +++ b/tests/mutation_tests_fire_control_cut8.sh @@ -0,0 +1,242 @@ +#!/usr/bin/env bash +# Mutation tests for docs/fire-control-cut.md's Cut 8 ("a removed entity is a dead entity, and the anchors the +# diagnostics moved"). +# +# Three mutations against tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs, each from the spec's own +# Verification block: +# +# 1. DeadEntityDoesNotUpdate -- drop the `_active` guard from Entity.Update (8.2). The always-runs prefix of +# Update (TargetRange, temperature, visibility decay, equipment performance) then keeps executing on a +# torn-down entity instead of returning immediately. +# 2. DeathDeactivates -- remove the Deactivate() call from Zone's own Death subscription (8.1). The dead +# entity then leaves Entities but stays active, holding live subscriptions and an uncleared +# EntityInfoGathered/VisibleEntities. +# +# LootStillDropsOnDeath_EquipmentSurvivesDeactivate and LockWeaponOnDeactivatedEntityDoesNotThrow are not +# mutation-targeted here: the first pins a fact about Deactivate's own body (it never touches Equipment) that +# no single-line mutation isolates without duplicating the DeathDeactivates mutation above, and the second is +# a regression pin already satisfied by Entity.Update's pre-existing inner `if (_active)` gate regardless of +# this cut's own guard -- confirmed by hand: dropping the Cut 8.2 guard does not turn it red. Both still run as +# part of the full test file on every invocation of run_test above and are reported in the final tally. +# +# 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_cut8.sh +# +# Every mutation is reversed (byte-exact, from an on-disk copy) before the script exits, including on failure +# or interrupt, and every restore is verified against a sha256 recorded before any mutation ran: the tree- +# clean verdict at the end states PASS/FAIL explicitly rather than leaving a reader to infer it. A SIGKILL of +# the whole process group is the one termination bash cannot trap at all -- verify the tree independently +# after a run that was killed that hard. +# +# Run only one mutation harness at a time against this tree -- concurrent harnesses corrupt each other's +# snapshots (docs/fire-control-cut.md's own campaign has hit this). + +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" + +ENTITY_CS="$REPO_ROOT/Assets/Scripts/ServerShared/Entity.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 +declare -A ORIGINAL_HASH +FILES=("$ENTITY_CS" "$ZONE_CS") +BACKUP_DIR="$(mktemp -d)" + +hash_file() { + sha256sum "$1" 2>/dev/null | awk '{print $1}' +} + +for f in "${FILES[@]}"; do + base="$(basename "$f")" + cp -p "$f" "$BACKUP_DIR/$base.orig" + ORIGINALS["$f"]="$BACKUP_DIR/$base.orig" + ORIGINAL_HASH["$f"]="$(hash_file "$f")" +done + +restore_and_verify() { + local file="$1" + cp -p "${ORIGINALS[$file]}" "$file" + local actual + actual="$(hash_file "$file")" + if [ "$actual" != "${ORIGINAL_HASH[$file]}" ]; then + echo "" >&2 + echo "FATAL: restoring $file did not reproduce its pre-mutation hash -- aborting." >&2 + echo " expected sha256: ${ORIGINAL_HASH[$file]}" >&2 + echo " actual sha256: $actual" >&2 + exit 1 + fi +} + +TREE_CLEAN_OK=1 +TREE_VERDICT_DONE=0 + +verify_tree_clean() { + if [ "$TREE_VERDICT_DONE" = "1" ]; then return; fi + local f actual + for f in "${FILES[@]}"; do + cp -p "${ORIGINALS[$f]}" "$f" 2>/dev/null + actual="$(hash_file "$f")" + if [ "$actual" != "${ORIGINAL_HASH[$f]}" ]; then + echo "FATAL: $f is NOT byte-identical to how this script found it." >&2 + echo " expected sha256: ${ORIGINAL_HASH[$f]}" >&2 + echo " actual sha256: $actual" >&2 + TREE_CLEAN_OK=0 + fi + done + echo "" + if [ "$TREE_CLEAN_OK" = "1" ]; then + echo "=== tree-clean verdict: PASS -- every target file is byte-identical to how this script found it ===" + else + echo "=== tree-clean verdict: FAIL -- see FATAL lines above; the working tree is contaminated ===" + fi + TREE_VERDICT_DONE=1 +} +trap verify_tree_clean EXIT INT TERM + +FAILURES=() + +# replace_unique FILE ANCHOR REPLACEMENT +# Anchor must occur exactly once (byte-exact match via 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 -- see cut5.sh's own comment on this: mixing this run's build with a +# stale default tests/Aetheria.Shared.Tests/bin,obj has produced spurious control failures before. +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") + restore_and_verify "$file" + return + fi + if run_test "$test"; then + passed=1 + else + passed=0 + fi + restore_and_verify "$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)" \ + "$ENTITY_CS" \ + $' public virtual void Update(float delta)\r\n {\r\n if (!_active) return;' \ + $' public virtual void Update(float delta)\r\n {\r\n if (!_active) return;' \ + "FireControlCut8Tests.DeadEntityDoesNotUpdate" \ + "green" + +# --- 8.2: a deactivated entity does not update. Mutation: drop the `_active` guard -- the always-runs prefix +# of Update (TargetRange included) keeps executing on a torn-down entity instead of returning immediately. --- +check_mutation \ + "8.2 Entity.Update must return immediately when !_active, not run its prefix regardless" \ + "$ENTITY_CS" \ + $' public virtual void Update(float delta)\r\n {\r\n if (!_active) return;\r\n\r\n var hullData = ItemManager.GetData(Hull) as HullData;' \ + $' public virtual void Update(float delta)\r\n {\r\n var hullData = ItemManager.GetData(Hull) as HullData;' \ + "FireControlCut8Tests.DeadEntityDoesNotUpdate" \ + "red" + +# --- 8.1: removal and deactivation are one transition. Mutation: drop the Deactivate() call from Zone's own +# Death subscription, restoring Cut 5.6's half-fix -- the dead entity leaves Entities but stays active. --- +check_mutation \ + "8.1 Zone's Death subscription must pair Entities.Remove with Deactivate, exactly like TryDock" \ + "$ZONE_CS" \ + 'Entities.ObserveAdd().Subscribe(add => add.Value.Death.Subscribe(_ => { Entities.Remove(add.Value); add.Value.Deactivate(); }));' \ + 'Entities.ObserveAdd().Subscribe(add => add.Value.Death.Subscribe(_ => { Entities.Remove(add.Value); }));' \ + "FireControlCut8Tests.DeathDeactivates" \ + "red" + +echo "" +verify_tree_clean # explicit call: the tree-clean verdict is part of THIS run's own summary, not only + # something printed later at exit (the trap's own call is a no-op after this one runs -- + # see TREE_VERDICT_DONE above -- and remains the safety net for an interrupted run). + +if [ "${#FAILURES[@]}" -gt 0 ]; then + echo "" + echo "=== FAILURES ===" + for f in "${FAILURES[@]}"; do + echo "- $f" + done +fi + +if [ "${#FAILURES[@]}" -gt 0 ] || [ "$TREE_CLEAN_OK" != "1" ]; then + echo "" + echo "RESULT: FAILED -- mutations: $([ "${#FAILURES[@]}" -gt 0 ] && echo "${#FAILURES[@]} failure(s)" || echo "all behaved as declared"), tree-clean: $([ "$TREE_CLEAN_OK" = "1" ] && echo PASS || echo FAIL)" + exit 1 +fi + +echo "RESULT: All mutations behaved as declared. tree-clean: PASS" +exit 0