Cut 8: a removed entity is a dead entity, and re-anchor the stranded mutations

The operator's play smoke crashed in LockWeapon reaching EntityInfoGathered
through a raw indexer as a ship vanished. Cut 5.6 moved death removal into the
simulation and did half the transition: Zone.Entities.Remove with no Deactivate,
where TryDock has always paired the two. The dead ship kept live subscriptions
and stale references while every survivor dropped it, and because Zone.Update
iterates Entities.ToArray() it received one more Update after the world had
forgotten it. Entity.Update never consulted _active; the flag existed and
nothing asked it.

Death now removes and deactivates in one transition, and a deactivated entity
does not update. Deliberately NOT a TryGetValue in LockWeapon: that silences one
call site and leaves every other behaviour running on a corpse.

Also re-anchors the seven mutations stranded by the diagnostics refactor
(72c0109c), which routed HitProbability through Inspect and rewrote the lines
four harnesses pin -- two of them no-op controls, so those harnesses were
defending nothing. The rules are unchanged; only the text moved. Anchors stay
strict: loudly stale is what surfaced this within one run.

The heat spike, motionless ships and shots that registered no hits remain
UNEXPLAINED and open. See the map's 8.3 -- unreproducible since the diagnostics
changed timing, so recorded with the unclamped dt as the place to probe rather
than patched speculatively.

226 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-22 00:50:57 +02:00
commit 8a87dec90f
8 changed files with 619 additions and 25 deletions

View file

@ -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);

View file

@ -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)
{

View file

@ -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<CultCache> _openCaches = new List<CultCache>();
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<ItemData>(items.ItemData.GetByName<HullData>("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<ItemData>(items.ItemData.GetByName<GearData>("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<ItemData>(items.ItemData.GetByName<GearData>("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<ItemData>(items.ItemData.GetByName<GearData>("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<ItemData>(items.ItemData.GetByName<HullData>("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<ItemData>(items.ItemData.GetByName<GearData>("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<ItemData>(items.ItemData.GetByName<GearData>("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<EquippedItem> 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().
}
}

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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 <path-to-CultLib-45c2f40-worktree>
#
# 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 <cultlib-root>" >&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