diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 73ff76d33..fc3eb79dd 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -285,6 +285,19 @@ public abstract class Entity // subscribe with whatever Target already holds, which is harmless -- TargetItem starts null anyway. _subscriptions.Add(Target.Subscribe(_ => TargetItem.Value = null)); + // 9.3 (docs/fire-control-cut.md, Soul C3): a Target surviving Deactivate can point at an entity + // this zone removed while inactive -- Deactivate disposes the Zone.Entities.ObserveRemove + // subscription that is the only thing that nulls a stale Target, and EntityInfoGathered was just + // rebuilt above from the zone's *current* membership, not from whatever Target still remembers. + // Reconcile here, now that both the fresh EntityInfoGathered and the Target-change subscription + // above are live (so this write also clears TargetItem through it): an active entity's Target + // must always be an entity it has info on. Activate owns this reconciliation rather than + // Deactivate nulling Target outright, because nulling on every dock would throw away a target + // that is still alive and simply never changed while docked -- the common case, and not the one + // that crashed. + if (Target.Value != null && !EntityInfoGathered.ContainsKey(Target.Value)) + Target.Value = null; + if(WeaponGroups.All(wg=>!wg.items.Any())) GenerateWeaponGroups(); } diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 058dff41c..d147dcb4b 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -344,6 +344,22 @@ public static class FireControl // so whether a gated shot draws is unobservable from anywhere. The claim this comment used to make is // gone with the stream it was about, and the test that asserted it became vacuous the same moment; what // replaced it pins the live rule instead (combat touches no shared stream, on any path). + // 9.1 (docs/fire-control-cut.md): fmix32, MurmurHash3's 32-bit finalizer -- xor-shift, multiply, + // xor-shift, multiply, xor-shift. Diffuses a structured seed (here, a zone seed XORed with a small, + // low-bits-only ShotId) across every bit before the first xorshift draw reads it, so two seeds that + // differ only in ShotId's low bits still land on uncorrelated first outputs. Caller-side mixing only + // -- CultMath.Random itself is unchanged (see Commit's own note on why that follow-up is a separate + // CultLib cut). + private static uint MixSeed(uint seed) + { + seed ^= seed >> 16; + seed *= 0x85ebca6bu; + seed ^= seed >> 13; + seed *= 0xc2b2ae35u; + seed ^= seed >> 16; + return seed; + } + private static ShotOutcome Commit(Zone zone, PendingShot shot, float now) { // Cut 6b, 6.1 (Soul finding 6): a shot's dice belong to the shot, not to whatever else happened to @@ -353,7 +369,16 @@ public static class FireControl // no matter what the UI drew from the shared stream in between. This generator is local and dies with // the call: nothing outside Commit may seed or advance a combat draw, and Commit may not read or write // a shared stream. - var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u); + // 9.1 (docs/fire-control-cut.md): the per-shot seed is unchanged -- a pure function of + // (zone identity, shot id), Cut 6b's own rule -- but CultMath.Random draws only one xorshift + // round for NextFloat's first call, and one round does not diffuse a seed that differs from its + // neighbours only in ShotId's low bits. Unmixed, every zone's first draw was a near-constant + // function of ShotId (measured span ~0.125), making a shot a step function of p fixed at zone + // creation, not a roll (Soul C1). MixSeed is a standard fmix32 finalizer (MurmurHash3): this is + // a caller's obligation, not a CultMath defect -- seeding xorshift with a structured value and + // drawing once is the documented way to get a correlated first output, and a follow-up to give + // CultMath.Random a mixed-seed entry point of its own is recorded for a separate CultLib cut. + var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u)); var p = shot.PBase; if (p > 0f && shot.Target != null) { @@ -486,7 +511,16 @@ public static class FireControl // Higher Precision (a tighter group) gives a smaller sigma; the 1e-3 floor only guards a stray zero or // negative authored value; no authored Precision should ever reach it (GameplaySettings.UnaidedPrecision // is the deliberately-bad floor Precision(Entity) itself falls back to, same shape as UnaidedAccuracy). - private static float Sigma(float precision) => 1f / max(precision, 1e-3f); + // + // 9.2 (docs/fire-control-cut.md, Soul C2): SigmaFloor guards a second, unrelated failure the 1e-3 + // floor above does nothing about. HullKernel approximates a continuous 2D Gaussian with a discrete + // sum over occupied cells; that sum badly undershoots the continuous integral once sigma drops much + // below one cell against an aim point that is not exactly on a cell centre -- true of every shipped + // hull, none of whose centres of mass are integral. Past that point pOnHull collapsed toward zero as + // Precision kept climbing, so a better targeting system made a shot un-fireable. Clamping sigma at + // half a cell keeps the discrete sum a faithful share of the kernel over the whole Precision domain. + private const float SigmaFloor = 0.5f; + private static float Sigma(float precision) => max(1f / max(precision, 1e-3f), SigmaFloor); // w(cell) = exp(-d^2 / 2*sigma^2) over every occupied hull cell, `d` the planar cell-space distance from // the aim point, plus pOnHull = (sum of those weights) / (2*pi*sigma^2) -- the share of the full continuous @@ -529,9 +563,14 @@ public static class FireControl // A single weighted draw over the kernel's own occupied-cell weights -- the only way Commit picks a cell // once a shot has already passed the roll (R3: one roll decides damage). totalWeight is passed in rather - // than resummed so this reads the identical mass HullKernel just computed. The trailing return guards only - // floating-point rounding at the very end of the accumulation; it can never fall through for a shot that - // reaches here, because the roll already established this hull has nonzero on-hull mass. + // than resummed so this reads the identical mass HullKernel just computed. + // + // 9.2 (docs/fire-control-cut.md, Soul C2): the trailing return used to carry a claim that it "can + // never fall through," which was false on its own terms -- it falls through whenever totalWeight == 0, + // returning a fixed corner cell, and was unreachable only as a side effect of pOnHull collapsing to + // zero and gating PBase to zero first. Now that Sigma floors sigma so pOnHull no longer collapses, + // that accidental gate is gone too; the return stays as an explicit floating-point-rounding guard, not + // a documented invariant. private static int2 WeightedPick(int2[] cells, float[] weights, float totalWeight, Random random) { var roll = random.NextFloat() * totalWeight; diff --git a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs new file mode 100644 index 000000000..3b5b9c7ee --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs @@ -0,0 +1,488 @@ +/* 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 9 (docs/fire-control-cut.md, "the die is not a die"): pins 9.1 (the hit roll must be uniform on +// [0,1), not a step function fixed by CombatSeed alone -- Cut 6b's own prescription, shipped through three +// cuts, never once measured), 9.2 (pOnHull must stay monotonic in Precision over the whole domain instead of +// collapsing to zero past a cliff no shipped hull's non-integral centre of mass can avoid) and 9.3 (a Target +// surviving a dock/die/undock cycle must not crash LockWeapon). Builds its own fixtures, the same convention +// every earlier cut's test file establishes. +public sealed class FireControlCut9Tests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut9-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public FireControlCut9Tests() => 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 = .3f, + 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; + } + + // --------------------------------------------------------------------------------------------------- + // 9.1: the die itself. No weapon or targeting gear -- these tests drive FireControl.Commit directly + // through synthetic PendingShots (FireSynthetic below), bypassing Fire() entirely, so PBase and ShotId + // are exact test inputs rather than products of another cut's own Accuracy/Resolution/Spread/pOnHull + // arithmetic. + // --------------------------------------------------------------------------------------------------- + + private sealed class DieEngagement + { + public ItemManager Items; + public Zone Zone; + public Ship Shooter; + public Ship Target; + } + + // zoneName seeds Zone.CombatSeed (StableHash of the GalaxyZone's Name) -- distinct names give distinct + // zone seeds, the way TheDieIsUniform sweeps several of them. + private DieEngagement BuildDieEngagement(GameplaySettings settings, string zoneName) + { + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = SolidShape(5, 5), Durability = 100000, 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); + // A bare, inert item -- not read by anything this test cares about, but Entity.Update's + // `foreach (var item in _orderedEquipment)` NREs if an entity was never equipped with anything at + // all (_orderedEquipment stays null until TryEquip/RemoveEquip first assign it), the same reason + // FireControlCut8Tests.BuildLockScenario equips its own bystander with a token gun. + cache.Upsert(new GearData + { + Name = "Marker", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1000000, + 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 }; + 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 = zoneName, Owner = null }, null); + + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var markerRef = items.ItemData.RefOf(items.ItemData.GetByName("Marker")); + + // Durability high enough to survive the thousands of direct hits TheDieIsUniform's bisection fires + // at it -- an entity whose hull dies partway through would stop taking further hits and bias + // whatever's left of the sample. + var shooterHull = new EquippableItem { Data = hullRef, Durability = 1000000, Lot = 1 }; + var shooter = new Ship(items, zone, shooterHull, new EntitySettings()); + var shooterMarker = new EquippableItem { Data = markerRef, Durability = 1000000, Lot = 3 }; + Assert.True(shooter.TryFindSpace(shooterMarker, out var shooterMarkerPos)); + Assert.True(shooter.TryEquip(shooterMarker, shooterMarkerPos)); + + var targetHull = new EquippableItem { Data = hullRef, Durability = 1000000, Lot = 2 }; + var target = new Ship(items, zone, targetHull, new EntitySettings()); + var targetMarker = new EquippableItem { Data = markerRef, Durability = 1000000, Lot = 4 }; + Assert.True(target.TryFindSpace(targetMarker, out var targetMarkerPos)); + Assert.True(target.TryEquip(targetMarker, targetMarkerPos)); + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + shooter.Position = float3.zero; + target.Position = float3(0, 0, 100); + + return new DieEngagement { Items = items, Zone = zone, Shooter = shooter, Target = target }; + } + + // Fires one synthetic shot with an exact ShotId and PBase, bypassing FireControl.Fire's own Accuracy/ + // Resolution/Spread/pOnHull arithmetic -- 9.1's tests own the roll, not the factors that feed it. + // FireTargetPosition/Velocity match the target's actual (stationary) position and FireTime equals the + // zone's own time, so DeviationProbability commits to exactly 1 and PBase is the one number Commit's die + // reads. CommitTime/ArrivalTime are set in the past so the shot commits and resolves inside the very + // next zone.Update call, synchronously, before this method returns. + private bool FireSynthetic(DieEngagement e, int shotId, float pBase, float precision = 1000f) + { + bool? hit = null; + using var sub = e.Zone.ShotResolved.Subscribe(o => + { + if (o.ShotId == shotId) hit = o.Hit; + }); + var shot = new PendingShot + { + ShotId = shotId, + Source = e.Shooter, + Target = e.Target, + Damage = 1f, + Penetration = 0f, + DamageSpread = 0f, + DamageType = DamageType.Kinetic, + PBase = pBase, + Tracking = 1e6f, + Precision = precision, + FireTargetPosition = e.Target.Position, + FireTargetVelocity = float3.zero, + FireTime = e.Zone.Time, + CommitTime = e.Zone.Time - 1f, + ArrivalTime = e.Zone.Time - 1f + }; + e.Zone.PendingShots.Add(shot); + e.Zone.Update(0.001f); + if (hit == null) throw new InvalidOperationException($"synthetic shot {shotId} did not resolve"); + return hit.Value; + } + + // Bisects the exact roll (the p at which Hit flips from false to true) for one shot id: since Commit's + // die is `hit = random.NextFloat() < p` with random seeded purely from (zone, shotId), the threshold p + // this converges to *is* that shot's draw, recovered through the public Commit/ShotResolved surface + // rather than by reaching into FireControl's private RNG plumbing. + private float BisectRoll(DieEngagement e, int shotId) + { + float lo = 0f, hi = 1f; + for (var i = 0; i < 24; i++) + { + var mid = (lo + hi) / 2f; + if (FireSynthetic(e, shotId, mid)) hi = mid; else lo = mid; + } + return (lo + hi) / 2f; + } + + // 9.1 (Soul C1, blocking): the defect this whole cut exists for. A single xorshift round does not + // diffuse a seed that differs from its neighbours only in its low bits (shot.ShotId), so every zone's + // first draw was a near-constant function of ShotId, span ~0.125 -- a shot was a step function of p, + // fixed at zone creation, not a roll. Mutation: delete the fmix32 finalizer FireControl.Commit applies + // before constructing its Random. Must die on both assertions below (a mixed-in seed produces both a + // wide roll span and a hit fraction that tracks p; the pre-fix code fails both). + [Fact] + public void TheDieIsUniform() + { + var settings = TestSettings(); + // Several distinct zone seeds (Zone.CombatSeed = GalaxyZone.Name.StableHash()) -- 9.1's defect is a + // property of the diffusion, not of any one unlucky seed, so a single zone passing would prove + // nothing about the fix. + var zoneNames = new[] { "Cut9-Alpha", "Cut9-Beta", "Cut9-Gamma", "Cut9-Delta", "Cut9-Epsilon" }; + var intermediatePs = new[] { .3f, .6f }; + + foreach (var zoneName in zoneNames) + { + var e = BuildDieEngagement(settings, zoneName); + + // Roll span across a dense run of *consecutive* shot ids -- 9.1's defect is specifically that + // ShotId only ever perturbs the seed's low bits, so this is the range that actually exercises + // it (a step of 877 up to 20000 flips high bits of ShotId itself and gets a wide span even + // pre-fix, proving nothing about the diffusion). + var rolls = new List(); + for (var shotId = 0; shotId < 300; shotId++) + rolls.Add(BisectRoll(e, shotId)); + var span = rolls.Max() - rolls.Min(); + Console.WriteLine($"[TheDieIsUniform] zone={zoneName} roll span={span:F4} min={rolls.Min():F4} max={rolls.Max():F4} n={rolls.Count}"); + Assert.True(span > 0.2f, + $"zone {zoneName}: roll span {span:F4} across {rolls.Count} shot ids is not substantially more than 0.2 -- the die is still a step function fixed at zone creation"); + + // Hit fraction at an intermediate p must track p, not sit near 0 or 1 (9.1's step-function + // symptom: a zone either always hits or never hits once p crosses its one fixed threshold). + foreach (var p in intermediatePs) + { + const int n = 500; + var hits = 0; + for (var shotId = 0; shotId < n; shotId++) + if (FireSynthetic(e, 1_000_000 + shotId, p)) hits++; + var fraction = hits / (float) n; + Console.WriteLine($"[TheDieIsUniform] zone={zoneName} p={p} hit fraction={fraction:F3} ({hits}/{n})"); + Assert.True(Math.Abs(fraction - p) < 0.08f, + $"zone {zoneName}, p={p}: observed hit fraction {fraction:F3} over {n} shots is not near p -- die is not uniform"); + } + } + } + + // --------------------------------------------------------------------------------------------------- + // 9.2: pOnHull must stay monotonic in Precision over the whole domain. A neutral shooter/target pair + // (Accuracy 1, huge Resolution/Tracking, zero weapon Spread), the same isolation convention + // FireControlCut6dTests.Build uses, so HitProbability's PBase collapses to exactly pOnHull. + // --------------------------------------------------------------------------------------------------- + + private sealed class KernelEngagement + { + public ItemManager Items; + public Zone Zone; + public Ship Shooter; + public Ship Target; + public Weapon Weapon; + } + + private KernelEngagement BuildKernelEngagement(GameplaySettings settings, Shape hullShape, float precision) + { + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = hullShape, Durability = 100000, 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(10), 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(1f), Resolution = Constant(1000f), Precision = Constant(precision), Tracking = Constant(100000f) + } } + }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 5; 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 = "Cut9-Kernel", Owner = null }, null); + + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooterHull = new EquippableItem { Data = hullRef, Durability = 1000000, 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(x => x.EquippableItem == gun); + var weapon = (Weapon) 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 = 3 }; + Assert.True(shooter.TryFindSpace(targeting, out var tpos)); + Assert.True(shooter.TryEquip(targeting, tpos)); + + var targetHull = new EquippableItem { Data = hullRef, Durability = 1000000, 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 = 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 = float3(0, 0, 100); + shooter.Target.Value = target; + shooter.SetIff(target, true); + shooter.EntityInfoGathered[target] = 1f; // full reveal: pSensor = 1 + + zone.Update(0f); // warm-up: resolves weapon/targeting stats before Inspect reads them + + return new KernelEngagement { Items = items, Zone = zone, Shooter = shooter, Target = target, Weapon = weapon }; + } + + // 9.2 (Soul C2, blocking): pOnHull collapsed to zero once sigma (1/Precision) fell below about half a + // cell, because the discrete kernel sum badly undershoots the continuous 2*pi*sigma^2 denominator + // against an aim point that is not exactly on a cell centre -- true of every shipped hull (no centre of + // mass is integral). A 7x13 solid hull reproduces the same off-integer-centre shape without needing the + // real catalog open in this project. Mutation: delete the sigma floor -- pOnHull at Precision 1000 + // collapses back toward zero without it. + [Fact] + public void POnHullNeverCollapsesAboveThePrecisionCliff() + { + var settings = TestSettings(); + var precisions = new[] { .1f, .3f, .6f, 1f, 1.19f, 2f, 3f, 4f, 5f, 6f, 8f, 10f, 15f, 25f, 50f, 100f, 250f, 500f, 1000f }; + // Even extents: CenterOfMass is the raw mean of integer cell coordinates (ItemData.cs), with no + // +0.5 cell-centre offset -- an odd extent's mean lands exactly on an occupied cell (no cliff to + // reproduce), while an even one lands on the half-cell boundary between two cells, the same + // off-integer shape every shipped hull's real (irregular) footprint produces (LonginusX 2.5/6.697, + // Zenith 5.5/5.5, Turret 3.5/3.5). + var hullShape = SolidShape(6, 12); + + var samples = new List<(float Precision, float POnHull)>(); + foreach (var precision in precisions) + { + var e = BuildKernelEngagement(settings, hullShape, precision); + var diagnostic = FireControl.Inspect(e.Weapon, e.Shooter, e.Target); + samples.Add((precision, diagnostic.POnHull)); + } + + Console.WriteLine("[POnHullNeverCollapsesAboveThePrecisionCliff] " + + string.Join(", ", samples.Select(s => $"P={s.Precision}:{s.POnHull:F4}"))); + + foreach (var (precision, pOnHull) in samples) + Assert.True(pOnHull > 0.05f, $"Precision {precision}: pOnHull collapsed to {pOnHull:F4} -- a weapon this well-aimed must still be able to hit the hull"); + + // Monotonic non-collapse over the *whole* domain: once precision climbs (sigma shrinks) pOnHull must + // not fall back toward zero the way the undamped continuous-integral denominator did past the cliff. + // (Below Precision ~1, pOnHull legitimately rises as the kernel tightens onto the aim point; the + // regression this pins is the second half of the curve heading back down to nothing.) + var peakIndex = samples.Select(s => s.POnHull).ToList().IndexOf(samples.Max(s => s.POnHull)); + for (var i = peakIndex + 1; i < samples.Count; i++) + Assert.True(samples[i].POnHull > samples[peakIndex].POnHull * 0.5f, + $"pOnHull fell from {samples[peakIndex].POnHull:F4} at Precision {samples[peakIndex].Precision} to {samples[i].POnHull:F4} at Precision {samples[i].Precision} -- the cliff is back"); + } + + // --------------------------------------------------------------------------------------------------- + // 9.3: a Target surviving a dock/die/undock cycle must not crash LockWeapon. Same fixture shape as + // FireControlCut8Tests.BuildLockScenario -- this is that cut's own crash, still reachable. + // --------------------------------------------------------------------------------------------------- + + private sealed class LockScenario + { + public ItemManager Items; + public Zone Zone; + public Ship Locker; + public Ship Bystander; + } + + private LockScenario BuildLockScenario(GameplaySettings settings) + { + var hullData = 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() } } + }; + + 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 = "Cut9-Lock", 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 }; + } + + // 9.3 (Soul C3, high): Cut 8 fixed a real liveness defect (LockWeaponOnDeactivatedEntityDoesNotThrow) + // but not the operator's actual crash -- that guard only ever runs on an *active* entity. The live + // precondition is Target.Value set but absent from EntityInfoGathered on an active entity, and docking + // still reaches it: Deactivate disposes the Zone.Entities.ObserveRemove subscription that is the only + // thing that nulls a stale Target, and Activate reseeds EntityInfoGathered from the zone's *current* + // membership without reconciling Target against it. Dock, let the target leave, undock: the first + // LockWeapon tick throws. Mutation: delete Entity.Activate's Target reconciliation. Must die. + [Fact] + public void TargetSurvivingDockDieUndockDoesNotCrashLockWeapon() + { + var s = BuildLockScenario(TestSettings()); + s.Locker.Update(.1f); // lock behavior runs once while both are live and visible, same as normal play + + s.Locker.Deactivate(); // dock + Assert.False(s.Locker.Active); + + // the target dies or leaves while the locker is inactive -- its own Zone.Entities.ObserveRemove + // subscription (the only thing that would have nulled Target) was disposed by Deactivate above, so + // nothing reacts to this removal yet. + s.Zone.Entities.Remove(s.Bystander); + + s.Locker.Activate(); // undock + Assert.True(s.Locker.Active); + // Assert.Null(entity) would hand a live Entity into xUnit's reflection-based failure formatter, + // which recurses through Zone -> Entities -> Ship -> Zone forever; a plain boolean with its own + // message sidesteps that entirely. + Assert.True(s.Locker.Target.Value == null, "the invariant: an active entity's Target is always a live entity it has info on -- a stale reference to the departed Bystander survived Activate's reconciliation"); + + var ex = Record.Exception(() => s.Locker.Update(.1f)); + Assert.Null(ex); + } +} diff --git a/tests/mutation_tests_fire_control_cut3.sh b/tests/mutation_tests_fire_control_cut3.sh index b32acb159..66a882167 100644 --- a/tests/mutation_tests_fire_control_cut3.sh +++ b/tests/mutation_tests_fire_control_cut3.sh @@ -7,7 +7,7 @@ # # OutOfArcConsumesNoDraw no longer exists. It asserted against ItemManager.Random (`e.Items.Random`), but # Cut 6b (6.1) moved every shot's roll onto its own freshly-seeded, immediately-discarded -# `new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u)` local to Commit -- a deliberate, +# `new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u))` local to Commit -- a deliberate, # good change (reproducibility independent of unrelated draws elsewhere) that also meant Commit stopped # reading or writing ItemManager.Random on any path, arc or no arc, which made the old test's own declared # mutation ("roll first and multiply by zero") pass for a reason that had nothing to do with arcs. Cut 7 @@ -197,7 +197,7 @@ check_mutation \ check_mutation \ "RollsAreSeeded: Commit's draw must be a pure function of (zone, shot id), not fresh entropy per call" \ "$FIRE_CONTROL_CS" \ - 'var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u);' \ + 'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));' \ 'var random = new Random((uint) System.Guid.NewGuid().GetHashCode() | 1u);' \ "FireAuthorityTests.RollsAreSeeded" \ "red" @@ -350,8 +350,8 @@ check_mutation \ check_mutation \ "CombatNeverDrawsFromTheSharedStream: Commit must never read ItemManager's shared stream, on any path" \ "$FIRE_CONTROL_CS" \ - 'var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u);' \ - 'var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u); shot.Source.ItemManager.Random.NextFloat();' \ + 'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));' \ + 'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u)); shot.Source.ItemManager.Random.NextFloat();' \ "FireAuthorityTests.CombatNeverDrawsFromTheSharedStream" \ "red" diff --git a/tests/mutation_tests_fire_control_cut6.sh b/tests/mutation_tests_fire_control_cut6.sh index 175769cc4..1de667cc2 100644 --- a/tests/mutation_tests_fire_control_cut6.sh +++ b/tests/mutation_tests_fire_control_cut6.sh @@ -194,8 +194,8 @@ check_mutation() { check_mutation \ "control (no-op round trip)" \ "$FIRE_CONTROL_CS" \ - 'var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u);' \ - 'var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u);' \ + 'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));' \ + 'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));' \ "FireControlCut6Tests.SameFightRollsSameThroughUnrelatedDraws" \ "green" @@ -206,7 +206,7 @@ check_mutation \ echo "" echo "=== 6.1 Commit must not read or write the shared ItemManager.Random stream (expect red) ===" if replace_unique "$FIRE_CONTROL_CS" \ - 'var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u);' \ + 'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));' \ 'var random = shot.Source.ItemManager.Random;' \ && replace_unique "$FIRE_CONTROL_CS" \ 'return MakeOutcome(shot, hit, shielded, shieldBroken, cell, aimed, now);' \ @@ -224,8 +224,8 @@ fi check_mutation \ "6.1 ShotId must reach the seed" \ "$FIRE_CONTROL_CS" \ - 'var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u);' \ - 'var random = new Random((zone.CombatSeed * 2654435761u) | 1u);' \ + 'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));' \ + 'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) | 1u));' \ "FireControlCut6Tests.ShotIdDecidesTheDie" \ "red" diff --git a/tests/mutation_tests_fire_control_cut9.sh b/tests/mutation_tests_fire_control_cut9.sh new file mode 100644 index 000000000..7263e72a8 --- /dev/null +++ b/tests/mutation_tests_fire_control_cut9.sh @@ -0,0 +1,252 @@ +#!/usr/bin/env bash +# Mutation tests for docs/fire-control-cut.md's Cut 9 ("the die is not a die"). +# +# Three mutations against tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs, each from the spec's own +# Verification block, one per named finding: +# +# 1. TheDieIsUniform (9.1, blocking) -- delete FireControl.Commit's fmix32 finalizer, restoring the bare +# seed straight into `new Random(...)`. One xorshift round does not diffuse a seed that differs from its +# neighbours only in ShotId's low bits, so the roll goes back to being a near-constant step function of +# p, fixed per zone at zone creation. +# 2. POnHullNeverCollapsesAboveThePrecisionCliff (9.2, blocking) -- delete the SigmaFloor clamp from +# FireControl.Sigma, restoring sigma = 1/Precision unclamped. Past Precision ~4-5 (LonginusX-shaped +# centres of mass), the discrete kernel sum undershoots the continuous 2*pi*sigma^2 denominator and +# pOnHull collapses back to zero. +# 3. TargetSurvivingDockDieUndockDoesNotCrashLockWeapon (9.3, high) -- delete Entity.Activate's Target +# reconciliation. A Target that outlived a Zone.Entities removal while the entity was inactive (docked) +# survives Activate unchanged, and the next LockWeapon tick throws indexing EntityInfoGathered with it. +# +# 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_cut9.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" + +FIRE_CONTROL_CS="$REPO_ROOT/Assets/Scripts/ServerShared/FireControl.cs" +ENTITY_CS="$REPO_ROOT/Assets/Scripts/ServerShared/Entity.cs" + +# --- byte-exact backup/restore: preserves line endings exactly, no text-mode translation. --- +declare -A ORIGINALS +declare -A ORIGINAL_HASH +FILES=("$FIRE_CONTROL_CS" "$ENTITY_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)" \ + "$FIRE_CONTROL_CS" \ + 'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));' \ + 'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));' \ + "FireControlCut9Tests.TheDieIsUniform" \ + "green" + +# --- 9.1: the die must be uniform, not a step function. Mutation: delete the fmix32 finalizer -- the bare, +# structured (zone, shot id) seed goes straight into Random again, and one xorshift round does not diffuse a +# seed that differs from its neighbours only in ShotId's low bits. --- +check_mutation \ + "9.1 Commit must mix the per-shot seed (fmix32) before drawing, not feed it to Random raw" \ + "$FIRE_CONTROL_CS" \ + 'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));' \ + 'var random = new Random((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u);' \ + "FireControlCut9Tests.TheDieIsUniform" \ + "red" + +# --- 9.2: pOnHull must stay monotonic in Precision over the whole domain. Mutation: delete the SigmaFloor +# clamp -- sigma is 1/Precision again, unclamped, so pOnHull collapses back to zero past the cliff every +# shipped (non-integral-centred) hull sits close to. --- +check_mutation \ + "9.2 Sigma must floor at half a cell, not shrink unbounded with Precision" \ + "$FIRE_CONTROL_CS" \ + 'private static float Sigma(float precision) => max(1f / max(precision, 1e-3f), SigmaFloor);' \ + 'private static float Sigma(float precision) => 1f / max(precision, 1e-3f);' \ + "FireControlCut9Tests.POnHullNeverCollapsesAboveThePrecisionCliff" \ + "red" + +# --- 9.3: a Target surviving a dock/die/undock cycle must not crash LockWeapon. Mutation: delete Activate's +# reconciliation -- a Target the zone removed while this entity was inactive survives Activate unchanged, and +# the next LockWeapon tick indexes EntityInfoGathered with a dictionary key that is no longer there. --- +check_mutation \ + "9.3 Activate must reconcile a stale Target against the reseeded EntityInfoGathered" \ + "$ENTITY_CS" \ + $' if (Target.Value != null && !EntityInfoGathered.ContainsKey(Target.Value))\r\n Target.Value = null;\r\n\r\n if(WeaponGroups.All(wg=>!wg.items.Any()))' \ + $' if(WeaponGroups.All(wg=>!wg.items.Any()))' \ + "FireControlCut9Tests.TargetSurvivingDockDieUndockDoesNotCrashLockWeapon" \ + "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