From b15cbbe5de825f77802ce5db1dfb1b70fd34339a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 15:18:12 +0200 Subject: [PATCH 1/8] Cut 10: the hot path stops looking like, and costing like, a diagnostic The diagnostics refactor (72c0109c) kept the debug HUD and the simulation on one fire-control model by routing HitProbability THROUGH Inspect. That was the right invariant implemented at the wrong level: every AI and turret evaluation, per weapon per tick, built the full diagnostic struct and ran IsRevealed's sort, the aim-point cell walk and HullKernel's allocations and per-cell exp -- and only then checked whether the target was even in range. A Soul pass measured 9.87 us and 448 B per call on paths that used to bail in nanoseconds. Operator: it made the hot path look like a diagnostic one. One model is now shared at the level of its factors. HitProbability runs its cheap gates first and bails, exactly as it did before the refactor, and computes PSensor, PSpread and POnHull only for a shot that is actually possible. Inspect calls the same four factor functions and fills the struct unconditionally, because a HUD wants to see why a shot is impossible. The HUD panel and its one test consumer are unchanged. Two tests make the design enforced rather than conventional: - HitProbabilityMatchesInspect: exact equality across 21 configurations covering every gate the hot path bails on plus Precision and info sweeps. Dies when a factor changes in only one caller. - GatedOutHitProbabilityAllocatesNothing: 1000 out-of-range calls allocate zero bytes. Dies when HitProbability is routed back through Inspect. Both mutations verified RED and distinguished from a compile failure, with restores checked against a hash taken before mutating. 231 tests pass. Co-Authored-By: Claude Opus 5 --- Assets/Scripts/ServerShared/FireControl.cs | 93 ++++++++++++------- .../FireControlCut9Tests.cs | 74 +++++++++++++++ 2 files changed, 133 insertions(+), 34 deletions(-) diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index d147dcb4b..03ad291b4 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -141,13 +141,36 @@ public static class FireControl // reads this to decide whether a shot is worth taking; the HUD reads it to draw a number; Fire below reads // it once, at the instant the trigger is pulled, and freezes the result into the shot (R10, Q6). Two reads // a tick apart may differ; nothing here or anywhere else caches one. + // Cut 10 (docs/fire-control-cut.md): the hot path. Every AI and turret calls this per weapon per tick, + // and the common case is a target out of range or out of arc -- so the cheap gates run first and bail, + // exactly as they did before the diagnostics refactor. The factors are computed only for a shot that is + // actually possible, and they come from the same functions Inspect uses: one fire-control model, shared + // at the level of its factors rather than by routing the hot path through a diagnostic struct. + // HitProbabilityMatchesInspect pins that the two can never disagree. public static float HitProbability(Weapon weapon, Entity source, Entity target) { - return Inspect(weapon, source, target).PBase; + if (target == null) return 0f; + if (!source.VisibleEntities.Contains(target)) return 0f; + + var toTarget = target.Position - source.Position; + var range = length(toTarget); + if (range < weapon.MinRange || range > weapon.Range) return 0f; + if (weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked) return 0f; + if (!InArc(weapon.Item, toTarget)) return 0f; + + var settings = source.ItemManager.GameplaySettings; + var targetHull = source.ItemManager.GetData(target.Hull) as HullData; + var info = source.EntityInfoGathered.TryGetValue(target, out var gathered) ? gathered : 0f; + return Accuracy(source) + * PSensor(settings, Resolution(source), info) + * PSpread(weapon, targetHull, range, settings) + * POnHull(source, target, targetHull, Precision(source)); } - // Presentation-only inspection of the exact factors HitProbability owns. Keeping this calculation here - // prevents the debug HUD from growing a second, subtly different fire-control model. + // Presentation only: the debug HUD's view of exactly the factors HitProbability multiplies. It computes + // every factor unconditionally, gates included, because a HUD wants to see why a shot is impossible -- + // which is precisely why the hot path must not be routed through it. Both read the same factor + // functions below, so the HUD cannot grow a second, subtly different model. public static FireControlDiagnostic Inspect(Weapon weapon, Entity source, Entity target) { var settings = source.ItemManager.GameplaySettings; @@ -170,44 +193,46 @@ public static class FireControl diagnostic.Locked = !(weapon is LockWeapon lockWeapon) || lockWeapon.IsLocked; diagnostic.InArc = InArc(weapon.Item, toTarget); - var info = source.EntityInfoGathered.TryGetValue(target, out var gathered) ? gathered : 0f; - // Cut 6c, 6c.1 (operator ruling 2026-09-19): Resolution stays a benefit -- higher is better -- so the - // formula takes its reciprocal to derive the actual info ceiling instead of reading Resolution as - // that ceiling directly. Resolution 1 (the unaided fallback above) yields a ceiling of 1: needs - // complete information, the floor by construction. A higher Resolution pulls the ceiling down toward - // the detection threshold, needing less info before sensor state stops limiting hits. The max guards - // a zero or negative authored Resolution; no authored value should ever reach it. - diagnostic.Info = info; - diagnostic.InfoDemandCeiling = settings.TargetDetectionInfoThreshold + - (1f - settings.TargetDetectionInfoThreshold) / max(diagnostic.Resolution, 1e-3f); - diagnostic.PSensor = saturate(unlerp(settings.TargetDetectionInfoThreshold, diagnostic.InfoDemandCeiling, info)); - var targetHull = source.ItemManager.GetData(target.Hull) as HullData; - - diagnostic.PSpread = 1f; - if (weapon.Spread > 0) - { - var halfExtent = .5f * max(targetHull.Shape.Width, targetHull.Shape.Height) * settings.SchematicCellSize; - var angularRadius = degrees(atan(halfExtent / diagnostic.Range)); - diagnostic.PSpread = saturate(angularRadius / (weapon.Spread / 2f)); - } - - // Cut 6d (docs/fire-control-cut.md): the dart-throw kernel's other half. pSpread above prices whether - // the weapon's own barrel-dispersion cone even reaches the ship's silhouette at this range (weapon - // hardware, whole-hull bounding size, range-dependent, aim-point-blind). pOnHull prices whether the - // targeting system's own aim scatter, centred on whatever is actually aimed at, lands on the hull's - // real schematic footprint rather than open grid around it (targeting hardware, per-cell shape, - // range-blind, aim-point-aware). Different inputs, different failure modes; see Cut(0's report for the - // double-charging call). Aim point is source.ResolvedTargetItem, exactly what Fire freezes below -- - // HitProbability and Fire read the same reveal-gated aim point, never two. - diagnostic.POnHull = HullKernel(targetHull, - ResolveAimPoint(target, targetHull, source.ResolvedTargetItem).AimPoint, diagnostic.Precision).POnHull; + diagnostic.Info = source.EntityInfoGathered.TryGetValue(target, out var gathered) ? gathered : 0f; + diagnostic.InfoDemandCeiling = InfoDemandCeiling(settings, diagnostic.Resolution); + diagnostic.PSensor = PSensor(settings, diagnostic.Resolution, diagnostic.Info); + diagnostic.PSpread = PSpread(weapon, targetHull, diagnostic.Range, settings); + diagnostic.POnHull = POnHull(source, target, targetHull, diagnostic.Precision); if (diagnostic.Visible && diagnostic.InRange && diagnostic.Locked && diagnostic.InArc) diagnostic.PBase = diagnostic.Accuracy * diagnostic.PSensor * diagnostic.PSpread * diagnostic.POnHull; return diagnostic; } + // Cut 6c, 6c.1 (operator ruling 2026-09-19): Resolution stays a benefit -- higher is better -- so the + // formula takes its reciprocal to derive the actual info ceiling instead of reading Resolution as that + // ceiling directly. Resolution 1 (the unaided fallback) yields a ceiling of 1: needs complete + // information, the floor by construction. The max guards a zero or negative authored Resolution; no + // authored value should ever reach it. + private static float InfoDemandCeiling(GameplaySettings settings, float resolution) => + settings.TargetDetectionInfoThreshold + (1f - settings.TargetDetectionInfoThreshold) / max(resolution, 1e-3f); + + private static float PSensor(GameplaySettings settings, float resolution, float info) => + saturate(unlerp(settings.TargetDetectionInfoThreshold, InfoDemandCeiling(settings, resolution), info)); + + // Whether the weapon's own barrel-dispersion cone reaches the ship's silhouette at this range: weapon + // hardware, whole-hull bounding size, range-dependent, aim-point-blind. 1 for any zero-spread weapon. + private static float PSpread(Weapon weapon, HullData targetHull, float range, GameplaySettings settings) + { + if (weapon.Spread <= 0) return 1f; + var halfExtent = .5f * max(targetHull.Shape.Width, targetHull.Shape.Height) * settings.SchematicCellSize; + var angularRadius = degrees(atan(halfExtent / range)); + return saturate(angularRadius / (weapon.Spread / 2f)); + } + + // Cut 6d: whether the targeting system's own aim scatter, centred on whatever is actually aimed at, lands + // on the hull's real schematic footprint rather than open grid around it. The aim point is + // source.ResolvedTargetItem, exactly what Fire freezes -- HitProbability, Inspect and Fire read the same + // reveal-gated aim point, never two. + private static float POnHull(Entity source, Entity target, HullData targetHull, float precision) => + HullKernel(targetHull, ResolveAimPoint(target, targetHull, source.ResolvedTargetItem).AimPoint, precision).POnHull; + public static float DeviationProbability(PendingShot shot, float now, out float deviation) { var elapsed = now - shot.FireTime; diff --git a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs index 3b5b9c7ee..06909df68 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs @@ -374,6 +374,80 @@ public sealed class FireControlCut9Tests : IDisposable $"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"); } + // --------------------------------------------------------------------------------------------------- + // Cut 10 (docs/fire-control-cut.md): one fire-control model, shared at the level of its factors. + // HitProbability is the hot path -- every AI and turret, per weapon, per tick -- and Inspect is the + // debug HUD's view. The diagnostics refactor (72c0109c) kept them as one model by routing the hot path + // THROUGH Inspect, which made every call build the diagnostic struct and run the aim-point and kernel + // work before checking whether the target was even in range: 9.87 us and 448 B per call, measured by a + // Soul pass, on paths that used to bail in nanoseconds. Both now call the same factor functions. + // --------------------------------------------------------------------------------------------------- + + // The invariant the diagnostics refactor was protecting, made structural rather than conventional: the + // HUD can never report a probability the simulation does not use. Swept across every gate the hot path + // bails on -- no target, not visible, out of range, out of arc -- and across Precision and info levels + // for shots that pass. Mutation: change a factor in only one of the two callers (drop POnHull from + // HitProbability); the passing configurations then disagree. + [Fact] + public void HitProbabilityMatchesInspect() + { + var settings = TestSettings(); + var checkedConfigurations = 0; + foreach (var precision in new[] { .3f, 1.19f, 5f }) + { + var e = BuildKernelEngagement(settings, SolidShape(6, 12), precision); + + void Check(string label) + { + var hot = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); + var hud = FireControl.Inspect(e.Weapon, e.Shooter, e.Target).PBase; + Assert.True(hot == hud, $"{label}, Precision {precision}: HitProbability {hot} != Inspect.PBase {hud}"); + checkedConfigurations++; + } + + foreach (var info in new[] { 0f, .2f, .5f, 1f }) + { + e.Shooter.EntityInfoGathered[e.Target] = info; + Check($"in range, info {info}"); + } + e.Shooter.EntityInfoGathered[e.Target] = 1f; + + e.Target.Position = float3(0, 0, 5000); + Check("out of range"); + e.Target.Position = float3(0, 0, -100); + Check("behind the mount"); + e.Target.Position = float3(0, 0, 100); + + e.Shooter.VisibleEntities.Remove(e.Target); + Check("not visible"); + + var nullHot = FireControl.HitProbability(e.Weapon, e.Shooter, null); + var nullHud = FireControl.Inspect(e.Weapon, e.Shooter, null).PBase; + Assert.True(nullHot == nullHud, $"no target: {nullHot} != {nullHud}"); + } + Assert.Equal(21, checkedConfigurations); // 3 precisions x (4 info levels + out of range + behind + not visible) + } + + // The cost regression itself. A target out of range is the common case for every AI and turret, and it + // must bail before any factor is computed. Mutation: route HitProbability back through Inspect; the + // gated-out call then allocates the aim-point list and the kernel's arrays on every evaluation. + [Fact] + public void GatedOutHitProbabilityAllocatesNothing() + { + var e = BuildKernelEngagement(TestSettings(), SolidShape(6, 12), 1.19f); + e.Target.Position = float3(0, 0, 5000); // out of range, still visible and in arc + + for (var i = 0; i < 100; i++) FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); // JIT warm-up + + var before = GC.GetAllocatedBytesForCurrentThread(); + var sum = 0f; + for (var i = 0; i < 1000; i++) sum += FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.Equal(0f, sum); + Assert.True(allocated == 0, $"1000 gated-out HitProbability calls allocated {allocated} bytes; the hot path must bail before any factor work"); + } + // --------------------------------------------------------------------------------------------------- // 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. From 8ef3b34c9481cddeb1a2b32b50ea7034536b248c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 15:50:34 +0200 Subject: [PATCH 2/8] Adopt Stryker.NET as a repo-local tool, for the mutation-testing spike Pinned in dotnet-tools.json (dotnet-stryker 5.0.0) so any checkout reproduces it with `dotnet tool restore`. Reports land in StrykerOutput/, now ignored. Spike against FireControl.cs alone: 276 mutants in 7m18s, 208 killed, 67 survived, 4 uncovered, score 74.6%. It killed every mutation the hand-written anchor harnesses defended, compiles all mutants into one assembly and switches between them at runtime -- so it never edits a source file, has nothing to re-anchor after a refactor, and cannot leak a mutant into the tree. Among the survivors: Splash's handedness is untested (flipping the sign of `right` passes every test -- the operator's "damage on the wrong side of the ship"), deleting the discrete shield's TakeHit survives, and most of the rest trace to degenerate fixture values -- shooter at the origin, fire time zero, spread zero -- which make whole families of mutants arithmetically identical. Co-Authored-By: Claude Opus 5 --- .gitignore | 3 +++ dotnet-tools.json | 13 +++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 dotnet-tools.json diff --git a/.gitignore b/.gitignore index 75b9cad4f..2cbd223f9 100644 --- a/.gitignore +++ b/.gitignore @@ -109,3 +109,6 @@ tests/_scratch_* .nuget-sandbox/ .dotnet-sandbox/ .appdata-sandbox/ + +# Stryker.NET mutation reports (regenerated per run) +StrykerOutput/ diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 000000000..bd8661400 --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-stryker": { + "version": "5.0.0", + "commands": [ + "dotnet-stryker" + ], + "rollForward": false + } + } +} \ No newline at end of file From b305debd49f0be133463f4910ac17dd02ee008d2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 15:59:06 +0200 Subject: [PATCH 3/8] Cut 11 map: mutation testing moves to Stryker, and the survivors it found Co-Authored-By: Claude Opus 5 --- docs/fire-control-cut.md | 97 +++++++++++++++++++ .../Aetheria.Shared.Tests/stryker-config.json | 12 +++ 2 files changed, 109 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/stryker-config.json diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index f1e4a3577..5e5021d59 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -1697,3 +1697,100 @@ configuration, not the range it claims), plus the harness-audit items: the tree-clean verdict restores before it measures, a red result cannot distinguish a killed mutant from a compile failure, and the no-op control covers only one file per harness. + +--- + +## Cut 11: mutation testing moves to Stryker, and the survivors it found + +Date: 2026-09-22. Operator, on the hand-written anchor harnesses: "What is the +point of those mutation anchors? Tests should cover how the code behaves, not how +it is shaped." And on Stryker: "sounds good; we don't need to run with every +mutation active, right? > to >= on float thresholds is indeed petty." + +### Why + +The Eureka principle (2026-09-15) is sound and stays: every rule needs a test that +fails when the rule breaks. The mechanism that accreted on it over the following +week -- literal-text anchors, perl substitution, per-cut bash and Python harnesses +-- coupled mutation testing to code shape. It stranded anchors on every refactor +(`72c0109c`, Cut 9, Cut 10), and at Cut 10 an anchor kept matching exactly once +while silently re-targeting onto `Inspect`, mutating code the tests no longer ran. +Soul's harness audit also found the tree-clean verdict restoring before it +measured, and a compile failure counting as a kill. + +A Stryker spike against `FireControl.cs` alone: 276 mutants, 7m18s, 208 killed, +67 survived. It killed every mutation the hand-written harnesses defended, never +edits a source file, and has nothing to re-anchor. + +### 11.1 Retire the ServerShared harnesses + +Delete the 25 hand-rolled harnesses whose targets are all under +`Assets/Scripts/ServerShared` -- every `mutation_tests_fire_control_*`, every +`mutation_tests_stats_power_*`, `mutation_tests_shield_reserve.py`, +`mutation_tests_condition_ratio_cut8.py` and `mutation_tests.py`. Stryker's scope +covers all of their targets; the behavioural tests they measured stay. + +**Kept, recorded, not yet answered:** `mutation_tests_addressables_cut.py` and the +three `mutation_tests_shield_panel_cut*.py`. They target Unity Editor probes +(`EngineAssetCheck.cs`, `ShieldPanelCut*Verify.cs`) that Unity compiles outside +`Aetheria.Shared`, so Stryker cannot reach them. They carry the same anchor +fragility; the right answer for GPU and editor code is probably that the batchmode +probes are themselves the behavioural tests, but that is a separate decision. + +### 11.2 Scope, pinned + +`tests/Aetheria.Shared.Tests/stryker-config.json` scopes mutation to +`Assets/Scripts/ServerShared/**` and excludes plugins. Per cut, run against the +cut's own diff: + + dotnet tool restore + cd tests/Aetheria.Shared.Tests + CULTLIB_ROOT= dotnet stryker --since: + +**Float-threshold boundary flips are known-equivalent.** Stryker files `>`→`>=` +under the same Equality mutator as `==`→`!=`, and the latter catches real bugs, so +the mutator stays on. A survivor that only moves a float comparison across its +boundary is triaged as equivalent and never chased. For the same reason the raw +mutation score is not a gate; the survivor list is the signal. + +### 11.3 Degenerate fixtures + +Most of the 67 survivors trace to three constants shared across the fire-control +fixtures, each of which makes a family of mutants arithmetically identical to the +original -- the same defect class as the zero-velocity target and the durability +coincidence, found here as a pattern instead of one at a time: + +- **Shooter at `float3.zero`.** `target - source` equals `target + source`, so four + separate position mutations survive (`FireControl.cs` HitProbability, Inspect, + Fire, Apply). +- **Fire time zero.** `now - FireTime` equals `now + FireTime`, so the elapsed-time + mutation survives -- which means `EvasionCountsUntilCommitAndNotAfter` still does + not defend the rule Cut 5 gave it a moving target to defend. +- **Spread zero everywhere.** `PSpread` returns 1 on every fixture, so it is + effectively untested, and `*` versus `/` against it is indistinguishable. + +Move shooters off the origin, fire at nonzero times, and give some fixtures real +spread. Fix the fixture, never weaken the assertion. + +### 11.4 Behavioural tests the survivors point at + +- **Splash handedness.** Flipping the sign in `var right = float2(forward.y, + -forward.x)` passes every test. That is the operator's "damage on the wrong side + of the ship," and Self said on 2026-09-19 that Cut 6b would add a test pinning + that a port blast damages port cells; it never landed and nothing checked. Pin it. +- **The shield loses energy when it absorbs.** Deleting `Shield.TakeHit` from the + discrete path survives, because tests assert the hull was untouched and never + that the shield paid for it. +- **Splash's shield-absorb branch** has no coverage at all. +- **Zones roll differently.** `CombatSeed *` → `/` makes the seed zone-independent + and survives; `TheDieIsUniform` checks uniformity within a zone only. Tighten it + enough that the `MixSeed` weakenings that survive it (`>>`→`<<`, `^=`→`|=`) die. +- **A shot whose target left the zone resolves as a miss** (Soul C4). Today only + the uncommitted branch rewrites the outcome; a committed hit on a target that + died mid-flight is published as a hit. Fix the code and pin it. + +### Verification + +Stryker over `FireControl.cs` before and after: every non-equivalent survivor +above killed, the remainder triaged by name. 231 tests plus the new ones; Unity +batchmode clean. diff --git a/tests/Aetheria.Shared.Tests/stryker-config.json b/tests/Aetheria.Shared.Tests/stryker-config.json new file mode 100644 index 000000000..f1708b314 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/stryker-config.json @@ -0,0 +1,12 @@ +{ + "stryker-config": { + "project": "Aetheria.Shared.csproj", + "mutate": [ + "**/Assets/Scripts/ServerShared/**/*.cs", + "!**/Assets/Plugins/**" + ], + "mutation-level": "Standard", + "reporters": [ "progress", "html", "json" ], + "thresholds": { "high": 80, "low": 60, "break": 0 } + } +} From 724ec5e7d3183d87b250c5f2df9ec13cfc2d0a51 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 15:59:45 +0200 Subject: [PATCH 4/8] Cut 11.1: retire the hand-written mutation harnesses Stryker replaces Twenty-three harnesses, 5,816 lines, across the fire control, stats and power, shield reserve and condition ratio campaigns. Every one targeted code under Assets/Scripts/ServerShared, which Stryker's pinned scope now covers, and every one carried the same liabilities: literal-text anchors coupled to code shape, a restore that could leak a mutant into the tree, a tree-clean verdict that restored before it measured, and a red result that could not tell a killed mutant from a compile failure. The behavioural tests they measured all stay. Kept and recorded, not yet answered: the addressables harness and the three shield panel harnesses. They target Unity Editor probes that Unity compiles outside Aetheria.Shared, where Stryker cannot reach. Co-Authored-By: Claude Opus 5 --- docs/fire-control-cut.md | 2 +- tests/mutation_tests.py | 555 ------------------ tests/mutation_tests_condition_ratio_cut8.py | 194 ------ tests/mutation_tests_fire_control_cut1.sh | 277 --------- tests/mutation_tests_fire_control_cut2.sh | 276 --------- tests/mutation_tests_fire_control_cut3.sh | 376 ------------ tests/mutation_tests_fire_control_cut4.py | 201 ------- tests/mutation_tests_fire_control_cut5.sh | 353 ----------- tests/mutation_tests_fire_control_cut6.sh | 289 --------- tests/mutation_tests_fire_control_cut6c.sh | 241 -------- tests/mutation_tests_fire_control_cut6d.sh | 282 --------- tests/mutation_tests_fire_control_cut8.sh | 242 -------- tests/mutation_tests_fire_control_cut9.sh | 252 -------- tests/mutation_tests_shield_reserve.py | 218 ------- tests/mutation_tests_stats_power_cut0.py | 151 ----- tests/mutation_tests_stats_power_cut1.py | 295 ---------- tests/mutation_tests_stats_power_cut2.py | 270 --------- tests/mutation_tests_stats_power_cut3.py | 175 ------ tests/mutation_tests_stats_power_cut4.py | 203 ------- tests/mutation_tests_stats_power_cut5.py | 218 ------- tests/mutation_tests_stats_power_cut6.py | 201 ------- tests/mutation_tests_stats_power_cut7.py | 204 ------- tests/mutation_tests_stats_power_cut8.py | 182 ------ ...ation_tests_stats_power_nominal_request.py | 161 ----- 24 files changed, 1 insertion(+), 5817 deletions(-) delete mode 100644 tests/mutation_tests.py delete mode 100644 tests/mutation_tests_condition_ratio_cut8.py delete mode 100644 tests/mutation_tests_fire_control_cut1.sh delete mode 100644 tests/mutation_tests_fire_control_cut2.sh delete mode 100644 tests/mutation_tests_fire_control_cut3.sh delete mode 100644 tests/mutation_tests_fire_control_cut4.py delete mode 100644 tests/mutation_tests_fire_control_cut5.sh delete mode 100644 tests/mutation_tests_fire_control_cut6.sh delete mode 100644 tests/mutation_tests_fire_control_cut6c.sh delete mode 100644 tests/mutation_tests_fire_control_cut6d.sh delete mode 100644 tests/mutation_tests_fire_control_cut8.sh delete mode 100644 tests/mutation_tests_fire_control_cut9.sh delete mode 100644 tests/mutation_tests_shield_reserve.py delete mode 100644 tests/mutation_tests_stats_power_cut0.py delete mode 100644 tests/mutation_tests_stats_power_cut1.py delete mode 100644 tests/mutation_tests_stats_power_cut2.py delete mode 100644 tests/mutation_tests_stats_power_cut3.py delete mode 100644 tests/mutation_tests_stats_power_cut4.py delete mode 100644 tests/mutation_tests_stats_power_cut5.py delete mode 100644 tests/mutation_tests_stats_power_cut6.py delete mode 100644 tests/mutation_tests_stats_power_cut7.py delete mode 100644 tests/mutation_tests_stats_power_cut8.py delete mode 100644 tests/mutation_tests_stats_power_nominal_request.py diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index 5e5021d59..76c3742d6 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -1724,7 +1724,7 @@ edits a source file, and has nothing to re-anchor. ### 11.1 Retire the ServerShared harnesses -Delete the 25 hand-rolled harnesses whose targets are all under +Delete the 23 hand-rolled harnesses (5,816 lines) whose targets are all under `Assets/Scripts/ServerShared` -- every `mutation_tests_fire_control_*`, every `mutation_tests_stats_power_*`, `mutation_tests_shield_reserve.py`, `mutation_tests_condition_ratio_cut8.py` and `mutation_tests.py`. Stryker's scope diff --git a/tests/mutation_tests.py b/tests/mutation_tests.py deleted file mode 100644 index 9fdb06a1c..000000000 --- a/tests/mutation_tests.py +++ /dev/null @@ -1,555 +0,0 @@ -#!/usr/bin/env python3 -"""Mutation tests for the Cut B and Cut C provenance rules (docs/item-provenance-cut.md). - -For each rule this applies its named mutation to an exact, unique anchor of -source text, runs the one test that should catch it, restores the file -byte-for-byte, and reports whether the mutant was killed. A no-op control -mutation (anchor == replacement) proves the read/replace/write/restore path -itself is transparent: every test it touches must stay green. - -Usage: - python tests/mutation_tests.py --cultlib-root - -Requires the CultLib worktree described in docs/item-provenance-cut.md section 6. -Only touches files under this repository; every mutation is reversed before -the script exits, including on failure. -""" -from __future__ import annotations - -import argparse -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -TEST_PROJECT = REPO_ROOT / "tests" / "Aetheria.Shared.Tests" - - -@dataclass -class Mutation: - rule: str - file: str - anchor: str - mutated: str - test: str - expect: str # "red" (mutant must be killed) or "green" (the no-op control) - - -MUTATIONS: list[Mutation] = [ - # --- No-op control: proves the anchor/replace/restore mechanism is transparent --- - Mutation( - rule="control (no-op round trip)", - file="Assets/Scripts/ServerShared/Provenance.cs", - anchor=' [Key(0)] public int NextLot = 1;', - mutated=' [Key(0)] public int NextLot = 1;', - test="RunSaveTests.MissingLotIsLoud", - expect="green", - ), - # --- MissingLotIsLoud: the indexer and Reachable must throw on an absent lot --- - Mutation( - rule="ProvenanceLedger indexer throws on an absent id", - file="Assets/Scripts/ServerShared/Provenance.cs", - anchor=( - " public Lot this[int id]\n" - " {\n" - " get\n" - " {\n" - " if (!Lots.TryGetValue(id, out var lot))\n" - " throw new InvalidOperationException($\"Lot {id} is not in the provenance ledger.\");\n" - " return lot;\n" - " }\n" - " }" - ), - mutated=( - " public Lot this[int id]\n" - " {\n" - " get\n" - " {\n" - " Lots.TryGetValue(id, out var lot);\n" - " return lot;\n" - " }\n" - " }" - ), - test="RunSaveTests.MissingLotIsLoud", - expect="red", - ), - Mutation( - rule="Reachable throws on a missing root instead of skipping it", - file="Assets/Scripts/ServerShared/Provenance.cs", - anchor=" var lot = this[id];\n reached[id] = lot;", - mutated=" if (!Lots.TryGetValue(id, out var lot)) continue;\n reached[id] = lot;", - test="RunSaveTests.MissingLotIsLoud", - expect="red", - ), - # --- MatchingHardpointsShareALot: the second unit must mint from the first unit's lot --- - Mutation( - rule="matching hardpoints share a lot, not a fresh mint", - file="Assets/Scripts/ServerShared/LoadoutGenerator.cs", - anchor=( - " // Matching hardpoints carry matching units: same lot\n" - " var item = (previousItem != null\n" - " ? ItemManager.CreateInstance(previousItem.EquippableItem.Lot)\n" - " : ItemManager.CreateInstance(entry.product)) as EquippableItem;" - ), - mutated=( - " // Matching hardpoints carry matching units: same lot\n" - " var item = ItemManager.CreateInstance(entry.product) as EquippableItem;" - ), - test="LoadoutTests.MatchingHardpointsShareALot", - expect="red", - ), - # --- FirstAvailableProductInKeyOrderBuildsTheSlot: Brand and Materialize must both respect key order --- - Mutation( - rule="Brand matches the lot's maker, not just the design", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor=( - " var product = ItemData.GetAll()\n" - " .Where(p => p.Manufacturer.Key.Equals(maker.Key) && p.Design.Key.Equals(item.Data.Key))" - ), - mutated=( - " var product = ItemData.GetAll()\n" - " .Where(p => p.Design.Key.Equals(item.Data.Key))" - ), - test="LoadoutTests.FirstAvailableProductInKeyOrderBuildsTheSlot", - expect="red", - ), - Mutation( - rule="Materialize resolves a design's product in record-key order", - file="Assets/Scripts/ServerShared/Loadout.cs", - anchor="var products = cache.GetAll().OrderBy(p => cache.RefOf(p).Key.Value, StringComparer.Ordinal).ToArray();", - mutated="var products = cache.GetAll().ToArray();", - test="LoadoutTests.FirstAvailableProductInKeyOrderBuildsTheSlot", - expect="red", - ), - # --- BrandPicksFirstProductInKeyOrderForSameMakerAndDesign: Brand's own tie-break must be ascending key order --- - Mutation( - rule="Brand's tie-break among a maker's products for one design is ascending record-key order", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor=" .OrderBy(p => ItemData.RefOf(p).Key.Value, StringComparer.Ordinal)", - mutated=" .OrderByDescending(p => ItemData.RefOf(p).Key.Value, StringComparer.Ordinal)", - test="LoadoutTests.BrandPicksFirstProductInKeyOrderForSameMakerAndDesign", - expect="red", - ), - # --- StatsReadTheLot: stats must read the lot, including its per-role fills --- - Mutation( - rule="Evaluate reads the lot's per-role fill for a role stat", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor="var quality = pow(lot.QualityForRole(stat.FromRole), stat.QualityExponent);", - mutated="var quality = pow(lot.Quality, stat.QualityExponent);", - test="LoadoutTests.StatsReadTheLot", - expect="red", - ), - Mutation( - rule="QualityForRole falls back to the lot's own workmanship, not a fixed value", - file="Assets/Scripts/ServerShared/Provenance.cs", - anchor=" if (string.IsNullOrEmpty(role) || Roles == null) return Quality;", - mutated=" if (string.IsNullOrEmpty(role) || Roles == null) return 1f;", - test="LoadoutTests.StatsReadTheLot", - expect="red", - ), - # --- F2 (item-provenance-cut.md): the GC root walk must cover every EntityPack root --- - Mutation( - rule="Items yields the Equipment root, not just the hull", - file="Assets/Scripts/ServerShared/EntitySerializer.cs", - anchor=( - " yield return pack.Hull;\n" - " foreach (var (_, item) in pack.Equipment) yield return item;" - ), - mutated=" yield return pack.Hull;", - test="LoadoutTests.MaterializedLotsSurviveSaveAndReload", - expect="red", - ), - # --- F3: GetTier and CreateInstance(int) must read/preserve the lot's real quality --- - Mutation( - rule="GetTier reads the lot's quality, not a fixed value", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor=" var quality = GetLot(item).Quality;", - mutated=" var quality = .999f;", - test="LoadoutTests.StatsReadTheLot", - expect="red", - ), - Mutation( - rule="CreateInstance(int) does not re-roll the lot's quality", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor=( - " public CraftedItemInstance CreateInstance(int lot)\n" - " {\n" - " var l = Lots[lot];" - ), - mutated=( - " public CraftedItemInstance CreateInstance(int lot)\n" - " {\n" - " var l = Lots[lot];\n" - " l.Quality = RollQuality();" - ), - test="LoadoutTests.StatsReadTheLot", - expect="red", - ), - # --- F4: GetPrice, the durability/thermal exponent paths, role fills, Produced brand, multi-zone roots --- - Mutation( - rule="GetPrice reads the lot's quality, not a fixed value", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor=" return (int) (GameplaySettings.QualityPriceModifier.Evaluate(GetLot(item).Quality) * data.Price);", - mutated=" return (int) (GameplaySettings.QualityPriceModifier.Evaluate(.5f) * data.Price);", - test="LoadoutTests.GetPriceReadsLotQuality", - expect="red", - ), - Mutation( - rule="Evaluate's durability exponent reads the lot's quality", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor=( - " var durabilityExponent = lerp(\n" - " GameplaySettings.DurabilityQualityMin,\n" - " GameplaySettings.DurabilityQualityMax,\n" - " pow(lot.Quality, GameplaySettings.DurabilityQualityExponent));" - ), - mutated=( - " var durabilityExponent = lerp(\n" - " GameplaySettings.DurabilityQualityMin,\n" - " GameplaySettings.DurabilityQualityMax,\n" - " .5f);" - ), - test="LoadoutTests.EvaluateDurabilityExponentReadsLotQuality", - expect="red", - ), - Mutation( - rule="EquippedItem's thermal exponent reads the lot's quality", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=( - " ThermalExponent = lerp(\n" - " ItemManager.GameplaySettings.ThermalQualityMin,\n" - " ItemManager.GameplaySettings.ThermalQualityMax,\n" - " pow(Lot.Quality, ItemManager.GameplaySettings.ThermalQualityExponent));" - ), - mutated=( - " ThermalExponent = lerp(\n" - " ItemManager.GameplaySettings.ThermalQualityMin,\n" - " ItemManager.GameplaySettings.ThermalQualityMax,\n" - " .5f);" - ), - test="LoadoutTests.EquippedItemThermalExponentReadsLotQuality", - expect="red", - ), - Mutation( - rule="CreateLot(product) fills roles from the product's own spread, not the design default", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor=" var build = product.Roles?.FirstOrDefault(b => b.Role == role.Name) ?? new ProductRole();", - mutated=" var build = new ProductRole();", - test="LoadoutTests.CreateLotFillsRolesFromProductSpread", - expect="red", - ), - Mutation( - rule="Brand reads the maker off a Produced origin too, not only Attributed", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor=( - " var maker = lot.Origin switch\n" - " {\n" - " Attributed attributed => attributed.Faction,\n" - " Produced produced => produced.Faction,\n" - " _ => default\n" - " };" - ), - mutated=( - " var maker = lot.Origin switch\n" - " {\n" - " Attributed attributed => attributed.Faction,\n" - " _ => default\n" - " };" - ), - test="LoadoutTests.BrandReadsProducedFaction", - expect="red", - ), - Mutation( - rule="RunSave.Commit takes GC roots from every zone, not just the first", - file="Assets/Scripts/ServerShared/SavedGame.cs", - anchor=" var roots = zones\n .SelectMany(zone => zone.Contents?.Entities ?? new List())", - mutated=" var roots = zones.Take(1)\n .SelectMany(zone => zone.Contents?.Entities ?? new List())", - test="RunSaveTests.CommitTakesRootsFromEveryZone", - expect="red", - ), - # --- Cut C: the manufacturer moves from the design to the product; the catalog fixture and its - # round-trip assertion (FactionIsASingletonInstance) must actually depend on that wiring. --- - Mutation( - rule="the catalog fixture's product carries the maker, not an unset reference", - file="tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs", - anchor=( - 'cache.Upsert(new FactionProductData { Name = "Lance", ' - "Design = new CultRecordRef(lance.Key), Manufacturer = cache.RefOf(faction) });" - ), - mutated=( - 'cache.Upsert(new FactionProductData { Name = "Lance", ' - "Design = new CultRecordRef(lance.Key), Manufacturer = default });" - ), - test="AetheriaStoresTests.FactionIsASingletonInstance", - expect="red", - ), - # --- Hand-testing combat (2026-09-17): IFF overrides, grudge, detection-gating, and the shooter- - # stance weapon fire gate on Entity/Weapon (docs not yet written; see IffAndCombatTests.cs). --- - Mutation( - rule="an IFF override decides IsHostileTo outright at the top level", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" if (!recursive && _iffOverrides.TryGetValue(other, out var overrideHostile))\n return overrideHostile;", - mutated=" if (false)\n return default;", - test="IffAndCombatTests.OverrideDecidesOutrightOverDerivedRule", - expect="red", - ), - Mutation( - rule="the recursive reciprocity sub-query skips overrides (no mirroring another entity's stance)", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" if (!recursive && _iffOverrides.TryGetValue(other, out var overrideHostile))\n return overrideHostile;", - mutated=" if (_iffOverrides.TryGetValue(other, out var overrideHostile))\n return overrideHostile;", - test="IffAndCombatTests.DerivedReciprocityDoesNotMirrorAnotherEntitysOverride", - expect="red", - ), - Mutation( - rule="leaving the zone clears this entity's IFF overrides on the departing entity", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" _iffOverrides.Remove(remove.Value);\n if (_grudgeSubscriptions.TryGetValue(remove.Value, out var grudgeSubs))", - mutated=" if (_grudgeSubscriptions.TryGetValue(remove.Value, out var grudgeSubs))", - test="IffAndCombatTests.LeavingZoneClearsOverrides", - expect="red", - ), - Mutation( - rule="an override change refreshes EntityHostility immediately (event-driven, no tick required)", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" _subscriptions.Add(_iffOverrides.ObserveAdd().Subscribe(add => RefreshHostilityFromOverride(add.Key)));\n _subscriptions.Add(_iffOverrides.ObserveReplace().Subscribe(replace => RefreshHostilityFromOverride(replace.Key)));", - mutated=" // _subscriptions.Add(_iffOverrides.ObserveAdd().Subscribe(add => RefreshHostilityFromOverride(add.Key)));\n // _subscriptions.Add(_iffOverrides.ObserveReplace().Subscribe(replace => RefreshHostilityFromOverride(replace.Key)));", - test="IffAndCombatTests.OverrideDecidesOutrightOverDerivedRule", - expect="red", - ), - Mutation( - rule="non-player entities wire a grudge watch; player-controlled entities never do", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" if (!IsPlayerControlled) WatchForGrudge(entity);", - mutated=" WatchForGrudge(entity);", - test="IffAndCombatTests.PlayerControlledEntitiesNeverAutoGrudge", - expect="red", - ), - Mutation( - rule="grudge only takes hold while this entity currently detects the marker", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" if (otherIsHostileToThis && VisibleEntities.Contains(other) && !_iffOverrides.ContainsKey(other))", - mutated=" if (otherIsHostileToThis && !_iffOverrides.ContainsKey(other))", - test="IffAndCombatTests.NonPlayerEntityGrudgesOnlyWhileItDetectsTheHostileMarker", - expect="red", - ), - Mutation( - rule="detecting an already-hostile entity grudges it immediately (VisibleEntities.ObserveAdd wiring)", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=( - " VisibleEntities.ObserveAdd()\n" - " .Where(add => add.Value == other)\n" - " .Subscribe(_ =>\n" - " {\n" - " if (other.EntityHostility.TryGetValue(this, out var hostile))\n" - " CheckGrudge(hostile);\n" - " })" - ), - mutated=( - " VisibleEntities.ObserveAdd()\n" - " .Where(add => false)\n" - " .Subscribe(_ =>\n" - " {\n" - " if (other.EntityHostility.TryGetValue(this, out var hostile))\n" - " CheckGrudge(hostile);\n" - " })" - ), - test="IffAndCombatTests.NonPlayerEntityGrudgesOnlyWhileItDetectsTheHostileMarker", - expect="red", - ), - Mutation( - rule="a grudge is sticky: it never re-evaluates (mirrors current stance) once already grudged", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" if (otherIsHostileToThis && VisibleEntities.Contains(other) && !_iffOverrides.ContainsKey(other))\n SetIff(other, true);", - mutated=" if (VisibleEntities.Contains(other))\n SetIff(other, otherIsHostileToThis);", - test="IffAndCombatTests.GrudgeIsStickyAndDoesNotForgiveWhenMarkerGoesNeutral", - expect="red", - ), - Mutation( - rule="PerceivedStanceOf is unknown (null) until this entity detects the other", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=( - " public bool? PerceivedStanceOf(Entity other) =>\n" - " VisibleEntities.Contains(other) && other.EntityHostility.TryGetValue(this, out var hostile)\n" - " ? hostile\n" - " : (bool?) null;" - ), - mutated=( - " public bool? PerceivedStanceOf(Entity other) =>\n" - " other.EntityHostility.TryGetValue(this, out var hostile)\n" - " ? hostile\n" - " : (bool?) null;" - ), - test="IffAndCombatTests.PerceivedStanceIsUnknownUntilDetectedThenReadsTheActualStance", - expect="red", - ), - Mutation( - rule="Weapon.StanceAllowsFire gates on the shooter's own stance, not an unconditional true", - file="Assets/Scripts/ServerShared/Behaviors/Weapon.cs", - anchor=" public bool StanceAllowsFire => Entity.Target.Value == null || Entity.IsHostileTo(Entity.Target.Value);", - mutated=" public bool StanceAllowsFire => true;", - test="IffAndCombatTests.WeaponDoesNotFireWhenShooterIsNotHostileToItsTarget", - expect="red", - ), - Mutation( - rule="InstantWeapon.Trigger is safed by StanceAllowsFire before it starts a burst", - file="Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs", - anchor=" if (!StanceAllowsFire) return;", - mutated=" if (false) return;", - test="IffAndCombatTests.WeaponDoesNotFireWhenShooterIsNotHostileToItsTarget", - expect="red", - ), - Mutation( - rule="LockWeapon builds lock on the shooter's own hostility toward the target, not the reverse", - file="Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs", - anchor=" if (Entity.Target.Value != null && Entity.IsHostileTo(Entity.Target.Value))", - mutated=" if (Entity.Target.Value != null && Entity.Target.Value.IsHostileTo(Entity))", - test="IffAndCombatTests.LockWeaponBuildsLockOnTheShootersOwnStanceNotTheTargets", - expect="red", - ), - Mutation( - rule="ZoneGenerator's neutral-wanderer pool excludes the zone owner and the nearest faction", - file="Assets/Scripts/ServerShared/ZoneGenerator.cs", - anchor="\tpublic static Faction[] EligibleWandererFactions(Faction[] factions, Faction owner, Faction nearest) =>\n\t\tfactions.Where(f => f != owner && f != nearest).ToArray();", - mutated="\tpublic static Faction[] EligibleWandererFactions(Faction[] factions, Faction owner, Faction nearest) =>\n\t\tfactions.ToArray();", - test="IffAndCombatTests.EligibleWandererFactionsExcludesOwnerAndNearest", - expect="red", - ), - # --- Capability event contract (docs/headless-playground-cut.md fork L, 2026-09-17): each event - # kind must reach only its own subscribers. --- - Mutation( - rule="control (no-op round trip) for the capability event routing test", - file="Assets/Scripts/ServerShared/CapabilityEvents.cs", - anchor=" public void PublishAbsorb(AbsorbEvent e) => _absorb.OnNext(e);", - mutated=" public void PublishAbsorb(AbsorbEvent e) => _absorb.OnNext(e);", - test="CapabilityEventsTests.EachEventKindReachesOnlyItsOwnSubscribers", - expect="green", - ), - Mutation( - rule="PublishAbsorb notifies only the Absorb stream, not Thrust as well", - file="Assets/Scripts/ServerShared/CapabilityEvents.cs", - anchor=" public void PublishAbsorb(AbsorbEvent e) => _absorb.OnNext(e);", - mutated=" public void PublishAbsorb(AbsorbEvent e) { _absorb.OnNext(e); _thrust.OnNext(default); }", - test="CapabilityEventsTests.EachEventKindReachesOnlyItsOwnSubscribers", - expect="red", - ), - # --- Cut 0 (docs/settings-globals-cut.md): Entity must copy the EntitySettings it is given, so no - # entity aliases GameplaySettings.DefaultEntitySettings (or, once settings are a catalog global, the - # cached record itself). --- - Mutation( - rule="control (no-op round trip) for the entity-settings-copy test", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" Settings = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(settings));", - mutated=" Settings = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(settings));", - test="LoadoutTests.EntitiesDoNotShareDefaultEntitySettings", - expect="green", - ), - Mutation( - rule="Entity's constructor copies the settings it is given, instead of aliasing the template", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" Settings = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(settings));", - mutated=" Settings = settings;", - test="LoadoutTests.EntitiesDoNotShareDefaultEntitySettings", - expect="red", - ), -] - - -def run_test(cultlib_root: str, filter_expr: str) -> tuple[str, str]: - """Runs the one filtered test. Returns ("ERROR", ...) when the build itself failed (a - compile error is never a legitimate kill or survival — it means the mutation broke - something the test never got a chance to exercise), otherwise ("PASS"|"FAIL", output).""" - proc = subprocess.run( - [ - "dotnet", "test", str(TEST_PROJECT), - f"-p:CultLibRoot={cultlib_root}", - "--filter", f"FullyQualifiedName~{filter_expr}", - "--nologo", - ], - cwd=REPO_ROOT, - capture_output=True, - text=True, - ) - output = proc.stdout + proc.stderr - # A build failure never reaches "Test run for ..."/"Passed!"/"Failed!"; it reports - # "Build FAILED." and one or more "error CS..." lines instead. - if "Build FAILED" in output or "error CS" in output: - return "ERROR", output - passed = proc.returncode == 0 and "Failed!" not in output - return ("PASS" if passed else "FAIL"), output - - -def detect_newline(original: bytes) -> bytes: - return b"\r\n" if b"\r\n" in original else b"\n" - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--cultlib-root", required=True) - args = parser.parse_args() - - results = [] - failures = [] - - for m in MUTATIONS: - path = REPO_ROOT / m.file - original = path.read_bytes() - newline = detect_newline(original).decode("ascii") - # Anchors are authored with plain "\n"; translate to the file's own line ending - # before matching, so CRLF-checked-out files (this repo's default) still match. - anchor = m.anchor.replace("\n", newline) - mutated = m.mutated.replace("\n", newline) - text = original.decode("utf-8") - count = text.count(anchor) - if count != 1: - print(f"[ABORT] anchor for '{m.rule}' matches {count} times in {m.file}, expected exactly 1") - failures.append(m.rule) - continue - - mutated_text = text.replace(anchor, mutated, 1) - path.write_bytes(mutated_text.encode("utf-8")) - try: - status_code, output = run_test(args.cultlib_root, m.test) - finally: - # Reverse write: restore the exact original bytes regardless of outcome. - path.write_bytes(original) - restored = path.read_bytes() - if restored != original: - print(f"[FATAL] {m.file} did not restore byte-exact after '{m.rule}'!") - return 2 - - if status_code == "ERROR": - status = "ERROR (build failure)" - failures.append(m.rule) - elif m.expect == "red": - killed = status_code == "FAIL" - status = "KILLED" if killed else "SURVIVED (bad)" - if not killed: - failures.append(m.rule) - else: # control: must stay green - passed = status_code == "PASS" - status = "GREEN (ok)" if passed else "RED (bad: round trip corrupted something)" - if not passed: - failures.append(m.rule) - - results.append((m.rule, m.test, status)) - print(f"{status:22} | {m.test:55} | {m.rule}") - - print() - print("Summary:") - for rule, test, status in results: - print(f" {status:22} {test:55} {rule}") - - if failures: - print(f"\n{len(failures)} mutation(s) did not behave as expected:") - for f in failures: - print(f" - {f}") - return 1 - - print(f"\nAll {len(results)} mutations behaved as expected.") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/mutation_tests_condition_ratio_cut8.py b/tests/mutation_tests_condition_ratio_cut8.py deleted file mode 100644 index ba67066e9..000000000 --- a/tests/mutation_tests_condition_ratio_cut8.py +++ /dev/null @@ -1,194 +0,0 @@ -#!/usr/bin/env python3 -"""Mutation tests for Cut 8, the operator's condition-ratio ask (2026-09-19): "you can't currently tell when -[a thruster is] busted -- the particle system it spawns responds only to thrust intent. Should multiply that by -actual performance." This follows Cut 7's brownout work (mutation_tests_stats_power_cut7.py) in this repo's own -numbering. - -EquippedItem.ConditionRatio (Entity.cs) is the one number that answers "how healthy does this item actually -look": the item's resolved value for a stat against what the same item, same lot, same modifier stack would -produce with Heat/Durability/PowerSupply pinned to their identity (NominalContext, the private struct right -below it). Thruster.Condition and AetherDrive.Condition (Thruster.cs/AetherDrive.cs) are that ratio read against -each behaviour's own governing stat. This script mutates each rule's own changed line and asserts the matching -ConditionRatioTests.cs Fact goes red for exactly that reason. A no-op control mutation proves the -read/replace/restore path is transparent. - -Usage: - python tests/mutation_tests_condition_ratio_cut8.py --cultlib-root - -Only touches files under this repository; every mutation is reversed before the script exits, including on -failure. -""" -from __future__ import annotations - -import argparse -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -TEST_PROJECT = REPO_ROOT / "tests" / "Aetheria.Shared.Tests" - - -@dataclass -class Mutation: - rule: str - file: str - anchor: str - mutated: str - test: str - expect: str # "red" (mutant must be killed) or "green" (the no-op control) - - -MUTATIONS: list[Mutation] = [ - # --- No-op control: proves the anchor/replace/restore mechanism is transparent --- - Mutation( - rule="control (no-op round trip)", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" public float ConditionRatio(PerformanceStat stat) => saturate(Evaluate(stat) / stat.Evaluate(new NominalContext(this)));", - mutated=" public float ConditionRatio(PerformanceStat stat) => saturate(Evaluate(stat) / stat.Evaluate(new NominalContext(this)));", - test="ConditionRatioTests.APerfectItemsConditionIsOne", - expect="green", - ), - - # --- The ratio must divide actual by nominal, not the reverse. A stat pinned to the identity on the nominal - # --- side but degraded (half durability) on the actual side proves direction: dividend and divisor swapped - # --- turns "half sits strictly below full" into "both saturate to 1" (nominal/actual > 1 either way, and - # --- saturate clamps it there), which is exactly as wrong as it sounds and just as easy to write by accident. - Mutation( - rule="ConditionRatio must divide actual by nominal, not the reverse", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" public float ConditionRatio(PerformanceStat stat) => saturate(Evaluate(stat) / stat.Evaluate(new NominalContext(this)));", - mutated=" public float ConditionRatio(PerformanceStat stat) => saturate(stat.Evaluate(new NominalContext(this)) / Evaluate(stat));", - test="ConditionRatioTests.ConditionFallsWithDurabilityAlone", - expect="red", - ), - - # --- Durability must actually be one of the terms NominalContext pins to identity -- if it is left to read - # --- the item's own live DurabilityFactor instead, a half-durability item's nominal denominator shrinks right - # --- along with its numerator and the ratio never leaves 1. --- - Mutation( - rule="NominalContext must pin DurabilityFactor to 1, not forward the item's live durability", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" public float DurabilityFactor(float exponent) => 1f;", - mutated=" public float DurabilityFactor(float exponent) => _item.DurabilityFactor(exponent);", - test="ConditionRatioTests.ConditionFallsWithDurabilityAlone", - expect="red", - ), - - # --- Same shape of bug, on Heat: if NominalContext forwards the item's live thermal performance instead of - # --- pinning it to 1, an off-plateau item's own nominal denominator degrades with it and the ratio never - # --- moves off 1. --- - Mutation( - rule="NominalContext must pin HeatFactor to 1, not forward the item's live thermal performance", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" public float HeatFactor(float exponent) => 1f;", - mutated=" public float HeatFactor(float exponent) => _item.HeatFactor(exponent);", - test="ConditionRatioTests.ConditionFallsWithTemperatureAwayFromThePlateauAlone", - expect="red", - ), - - # --- Same shape again, on PowerSupply -- the exact term Cut 7's brownout curve depends on. --- - Mutation( - rule="NominalContext must pin PowerSupplyFactor to 1, not forward the item's live grant", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" public float PowerSupplyFactor(float exponent) => 1f;", - mutated=" public float PowerSupplyFactor(float exponent) => _item.PowerSupplyFactor(exponent);", - test="ConditionRatioTests.ConditionFallsWithAPartialPowerGrantAlone", - expect="red", - ), - - # --- Zero must land exactly on zero, not divide-by-zero into NaN, when the nominal denominator itself - # --- degenerates (a Max=0 stat: both actual and nominal evaluate to exactly 0). ConditionRatio relies on - # --- CultMath's own NaN-favoring math.min/max (0/0 saturates to a real 0f, not NaN) rather than a manual - # --- epsilon guard -- dropping the saturate() call removes that protection and 0/0 surfaces as raw NaN, which - # --- is not equal to 0f. ConditionIsZeroWhenTheItemProducesNothing cannot catch this by itself, since its - # --- nominal stays 100 and only the numerator goes to 0 there. --- - Mutation( - rule="ConditionRatio must saturate the division, not return the raw quotient", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" public float ConditionRatio(PerformanceStat stat) => saturate(Evaluate(stat) / stat.Evaluate(new NominalContext(this)));", - mutated=" public float ConditionRatio(PerformanceStat stat) => Evaluate(stat) / stat.Evaluate(new NominalContext(this));", - test="ConditionRatioTests.ConditionIsZeroNotNaNWhenTheNominalValueItselfDegeneratesToZero", - expect="red", - ), - - # --- Thruster.Condition must read Thrust, the stat that actually governs this behaviour's force -- reading - # --- some other stat (here, the item's Visibility) would silently condition against the wrong quantity. --- - Mutation( - rule="Thruster.Condition must condition against the Thrust stat, not an unrelated one", - file="Assets/Scripts/ServerShared/Behaviors/Thruster.cs", - anchor=" public float Condition => Item?.ConditionRatio(_data.Thrust) ?? 1f;", - mutated=" public float Condition => Item?.ConditionRatio(_data.Visibility) ?? 1f;", - test="ConditionRatioTests.ConditionFallsWithDurabilityAlone", - expect="red", - ), -] - - -def replace_unique(path: Path, anchor: str, mutated: str) -> None: - original = path.read_text(encoding="utf-8") - count = original.count(anchor) - if count != 1: - raise SystemExit(f"{path}: anchor must occur exactly once, found {count}\n---\n{anchor}") - path.write_text(original.replace(anchor, mutated), encoding="utf-8") - - -def run_test(cultlib_root: str, test: str) -> tuple[int, str]: - proc = subprocess.run( - ["dotnet", "test", str(TEST_PROJECT), f"-p:CultLibRoot={cultlib_root}", - "--filter", f"FullyQualifiedName~{test}"], - capture_output=True, text=True, timeout=300, - ) - return proc.returncode, proc.stdout + proc.stderr - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--cultlib-root", required=True) - args = parser.parse_args() - - failures: list[str] = [] - originals: dict[str, str] = {} - for m in MUTATIONS: - if m.file not in originals: - originals[m.file] = (REPO_ROOT / m.file).read_text(encoding="utf-8") - - def restore_all() -> None: - for file, text in originals.items(): - (REPO_ROOT / file).write_text(text, encoding="utf-8") - - try: - for m in MUTATIONS: - print(f"\n=== {m.rule} (expect {m.expect}) ===") - path = REPO_ROOT / m.file - replace_unique(path, m.anchor, m.mutated) - code, output = run_test(args.cultlib_root, m.test) - passed = "Passed!" in output and code == 0 - if m.expect == "green": - if not passed: - failures.append(f"{m.rule}: expected green (test passing), got failure\n{output[-2000:]}") - else: - print(f"OK: {m.test} passed under a no-op mutation, as expected") - else: - if passed: - failures.append(f"{m.rule}: expected red (mutant killed), but {m.test} still passed") - else: - print(f"OK: {m.test} went red under the mutation, as expected") - # Restore this file immediately so the next mutation starts from a clean tree. - path.write_text(originals[m.file], encoding="utf-8") - finally: - restore_all() - - if failures: - print("\n=== FAILURES ===") - for f in failures: - print(f"- {f}") - return 1 - - print("\nAll mutations behaved as declared.") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/mutation_tests_fire_control_cut1.sh b/tests/mutation_tests_fire_control_cut1.sh deleted file mode 100644 index 3789f6f72..000000000 --- a/tests/mutation_tests_fire_control_cut1.sh +++ /dev/null @@ -1,277 +0,0 @@ -#!/usr/bin/env bash -# Mutation tests for docs/fire-control-cut.md's Cut 1 ("Mount direction and arc become simulation data"). -# -# Five mutations, each against tests/Aetheria.Shared.Tests/FireControlTests.cs, the cut's own declared table: -# -# 1. ArcFollowsMountRotation -- MountDirection reads Entity.Direction directly instead of rotating it by the -# item's own authored rotation, so a side-mounted weapon aims like the hull instead of like its mount. -# 2. ArcBoundaryIsHalfWidth -- the bearing test compares against cos(radians(arc)) instead of -# cos(radians(arc/2)), so an authored FiringArc reads as a half-width arc. -# 3. ArcIsPlanar -- InArc dots the raw 3D vector instead of zeroing height first (R7). -# 4. HardpointOverrideBeatsDefault -- two variants, both against ArcFor: ignoring HardpointData.FiringArc -# entirely (both hardpoints then fall back to the default), and treating an authored 0 as a literal -# zero-width arc instead of "use the default." -# 5. CombatStateStepsHeadless -- the cut's own regression: Behaviors.cs:30-35's HardpointTransforms readback -# branch is gone from this tree (confirmed by negative grep, see below), so there is no literal text left -# to revert it to, and post-Cut-3 the AI's own gate (Combat.cs) reaches aim direction through -# FireControl.MountDirection, not the Behavior.Direction property Cut 1 itself touched -- a mutation there -# is provably off this test's actual path (verified: it survived). The equivalent-shape mutation instead -# throws from MountDirection itself, the same KeyNotFoundException shape a live Unity-transform readback -# threw headless before this cut, so the test's own Assert.Null(ex) still catches the regression the cut -# exists to prevent. -# -# 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. Modelled on -# tests/mutation_tests_fire_control_cut5.sh, the good example: byte-exact I/O, sha256-verified restore after -# every single mutation, and an explicit tree-clean verdict line at exit. -# -# Usage: -# tests/mutation_tests_fire_control_cut1.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. - -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" - -# --- byte-exact backup/restore: preserves line endings exactly, no text-mode translation. --- -declare -A ORIGINALS -declare -A ORIGINAL_HASH -FILES=("$FIRE_CONTROL_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 - 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" -} - -ARTIFACTS_PATH="$(mktemp -d)" - -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() { - 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" - - 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 --- -check_mutation \ - "control (no-op round trip)" \ - "$FIRE_CONTROL_CS" \ - 'public static float3 MountDirection(EquippedItem item)' \ - 'public static float3 MountDirection(EquippedItem item)' \ - "FireControlTests.ArcFollowsMountRotation" \ - "green" - -# --- ArcFollowsMountRotation: mount direction must come from the item's own authored rotation, not the -# hull's raw facing. --- -check_mutation \ - "ArcFollowsMountRotation must read the item's rotation, not the hull's raw facing" \ - "$FIRE_CONTROL_CS" \ - 'var itemDirection = item.Entity.Direction.Rotate(item.EquippableItem.Rotation);' \ - 'var itemDirection = item.Entity.Direction;' \ - "FireControlTests.ArcFollowsMountRotation" \ - "red" - -# --- ArcBoundaryIsHalfWidth: FiringArc is full width -- the bearing test must halve it before taking cos. --- -check_mutation \ - "ArcBoundaryIsHalfWidth: FiringArc must be full width (halved before cos), not read as a half-width arc" \ - "$FIRE_CONTROL_CS" \ - 'return dot(MountDirection(weapon), planarTarget) >= cos(radians(arc / 2f));' \ - 'return dot(MountDirection(weapon), planarTarget) >= cos(radians(arc));' \ - "FireControlTests.ArcBoundaryIsHalfWidth" \ - "red" - -# --- ArcIsPlanar (R7): target height must never enter the bearing test. --- -check_mutation \ - "ArcIsPlanar (R7): InArc must zero height, not dot the raw 3D vector" \ - "$FIRE_CONTROL_CS" \ - 'var planarToTarget = float3(toTarget.x, 0, toTarget.z);' \ - 'var planarToTarget = toTarget;' \ - "FireControlTests.ArcIsPlanar" \ - "red" - -# --- HardpointOverrideBeatsDefault, variant 1: ignore HardpointData.FiringArc entirely (both hardpoints in -# the test then fall back to the default, so the 360-authored one fails at 170 degrees too). --- -check_mutation \ - "HardpointOverrideBeatsDefault (variant 1): ArcFor must read HardpointData.FiringArc, not ignore it" \ - "$FIRE_CONTROL_CS" \ - $' var arc = hardpoint?.FiringArc ?? 0f;\r\n return arc > 0 ? arc : item.ItemManager.GameplaySettings.FiringArc;' \ - ' return item.ItemManager.GameplaySettings.FiringArc;' \ - "FireControlTests.HardpointOverrideBeatsDefault" \ - "red" - -# --- HardpointOverrideBeatsDefault, variant 2: an authored 0 must mean "use the default," not a literal -# zero-width arc -- flipping > to >= makes a 0-authored hardpoint fail even 5 degrees off. --- -check_mutation \ - "HardpointOverrideBeatsDefault (variant 2): an authored 0 must fall back to the default, not read as zero width" \ - "$FIRE_CONTROL_CS" \ - 'return arc > 0 ? arc : item.ItemManager.GameplaySettings.FiringArc;' \ - 'return arc >= 0 ? arc : item.ItemManager.GameplaySettings.FiringArc;' \ - "FireControlTests.HardpointOverrideBeatsDefault" \ - "red" - -# --- CombatStateStepsHeadless: the cut's own regression -- aim direction must never depend on a live Unity -# readback. HardpointTransforms and its readback branch are already fully deleted from this tree (see the -# negative grep below), so there is no literal "restore the read" text left to revert to, and post-Cut-3 the -# AI's own gate reaches aim direction through FireControl.MountDirection (Combat.cs calls -# FireControl.HitProbability, which calls InArc, which calls MountDirection), not the Behavior.Direction -# property this cut itself touched. The equivalent-shape mutation throws from MountDirection itself -- the -# exact KeyNotFoundException shape the old HardpointTransforms dictionary lookup threw headless, before this -# cut -- so the test's own Assert.Null(Record.Exception(...)) still catches the regression this cut exists to -# prevent. --- -check_mutation \ - "CombatStateStepsHeadless: aim direction must never throw when Unity never ran (HardpointTransforms shape)" \ - "$FIRE_CONTROL_CS" \ - 'return normalize(float3(itemDirection.x, 0, itemDirection.y));' \ - 'throw new System.Collections.Generic.KeyNotFoundException("HardpointTransforms readback restored");' \ - "FireControlTests.CombatStateStepsHeadless" \ - "red" - -echo "" -verify_tree_clean - -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 diff --git a/tests/mutation_tests_fire_control_cut2.sh b/tests/mutation_tests_fire_control_cut2.sh deleted file mode 100644 index 9e277ce84..000000000 --- a/tests/mutation_tests_fire_control_cut2.sh +++ /dev/null @@ -1,276 +0,0 @@ -#!/usr/bin/env bash -# Mutation tests for docs/fire-control-cut.md's Cut 2 ("Targeting system and reveal"). -# -# Six mutations, each against tests/Aetheria.Shared.Tests/TargetingSystemTests.cs, the cut's own declared -# table -- with two adaptations forced by later cuts changing the Body underneath this one (see the notes on -# each, and the report): -# -# 1. SelectionNeedsReveal -- IsRevealed returns true unconditionally instead of checking the tier. -# 2. RevealOrder -- drop the ranking (hardpoint-first, size-descending) entirely. -# 3. RevealSpansBothThresholds -- swap the two authored thresholds in the lerp. -# 4. UnaidedFiresWorse -- Accuracy/Precision/Resolution fall back to the (destroyed/offline) system's own -# stats instead of the unaided settings, i.e. drop the Active.Value gate. -# 5. StarvedTargetingRollsWorse -- the brownout ruling becomes a switch (PowerSupply <= 1e-4 ? 0 : 1) -# instead of the authored pow(PowerSupply, exponent) gradient. This is Entity.cs's PowerSupplyFactor, -# shared production code the brownout ruling (docs/stats-and-power-cut.md) applies well beyond Accuracy -# -- restored immediately after the single filtered test run, same as every other mutation here. -# 6. EveryArmedLoadoutGetsATargetingSystem -- the doc's own declared mutation ("drop required: true") no -# longer has a `required: true` to drop: Cut 6c.2 (LoadoutGenerator.cs, 2026-09-19) deliberately changed -# this call from required:true/throw to required:false/degrade-and-log, an operator ruling this harness -# must not fight. The adapted mutation targets the surviving acquisition attempt instead -- the predicate -# that finds a targeting-system seller never matches -- which is what "the required-equipment rule -# regresses to nothing" now looks like on this Body. See the report for the full account. -# -# 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. Modelled on -# tests/mutation_tests_fire_control_cut5.sh, the good example: byte-exact I/O, sha256-verified restore after -# every single mutation, and an explicit tree-clean verdict line at exit. -# -# Usage: -# tests/mutation_tests_fire_control_cut2.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. - -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" -LOADOUT_GENERATOR_CS="$REPO_ROOT/Assets/Scripts/ServerShared/LoadoutGenerator.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" "$LOADOUT_GENERATOR_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 -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 - 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" -} - -ARTIFACTS_PATH="$(mktemp -d)" - -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() { - 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" - - 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 --- -check_mutation \ - "control (no-op round trip)" \ - "$FIRE_CONTROL_CS" \ - 'return info >= tier;' \ - 'return info >= tier;' \ - "TargetingSystemTests.SelectionNeedsReveal" \ - "green" - -# --- SelectionNeedsReveal: IsRevealed must check the tier, not return true unconditionally. --- -check_mutation \ - "SelectionNeedsReveal: IsRevealed must check the tier, not return true unconditionally" \ - "$FIRE_CONTROL_CS" \ - 'return info >= tier;' \ - 'return true;' \ - "TargetingSystemTests.SelectionNeedsReveal" \ - "red" - -# --- RevealOrder: hardpoint-mounted items must rank before interior ones, larger before smaller. --- -check_mutation \ - "RevealOrder: IsRevealed's ranking must be hardpoint-first, size-descending, not equipment order alone" \ - "$FIRE_CONTROL_CS" \ - $' var ranked = target.Equipment\r\n .Where(e => e.Data.HardpointType != HardpointType.Hull && e.EquippableItem.Durability >= .01f)\r\n .OrderByDescending(e => e.Data.HardpointType != HardpointType.Tool)\r\n .ThenByDescending(e => e.Data.Shape.Coordinates.Length)\r\n .ToList();' \ - $' var ranked = target.Equipment\r\n .Where(e => e.Data.HardpointType != HardpointType.Hull && e.EquippableItem.Durability >= .01f)\r\n .ToList();' \ - "TargetingSystemTests.RevealOrder" \ - "red" - -# --- RevealSpansBothThresholds: the first item must reveal at TargetArmorInfoThreshold and the last at -# TargetGearInfoThreshold -- swap them and the ends flip. --- -check_mutation \ - "RevealSpansBothThresholds: the ranking's tiers must not be swapped" \ - "$FIRE_CONTROL_CS" \ - 'var tier = lerp(settings.TargetArmorInfoThreshold, settings.TargetGearInfoThreshold,' \ - 'var tier = lerp(settings.TargetGearInfoThreshold, settings.TargetArmorInfoThreshold,' \ - "TargetingSystemTests.RevealSpansBothThresholds" \ - "red" - -# --- UnaidedFiresWorse (Q4): a destroyed or disabled targeting system must fall back to the unaided figures, -# not keep supplying its own (now-meaningless) stats. Mutation: drop the Active.Value gate from Accuracy. --- -check_mutation \ - "UnaidedFiresWorse: Accuracy must gate on Active.Value, not just existence, before trusting the system" \ - "$FIRE_CONTROL_CS" \ - $' var system = entity.GetBehavior();\r\n return system != null && system.Item.Active.Value\r\n ? system.Accuracy\r\n : entity.ItemManager.GameplaySettings.UnaidedAccuracy;' \ - $' var system = entity.GetBehavior();\r\n return system != null\r\n ? system.Accuracy\r\n : entity.ItemManager.GameplaySettings.UnaidedAccuracy;' \ - "TargetingSystemTests.UnaidedFiresWorse" \ - "red" - -# --- StarvedTargetingRollsWorse: the brownout ruling on Accuracy (a shared PowerSupply term, F1 of -# docs/stats-and-power-cut.md) must be a gradient (pow(PowerSupply, exponent)), not a switch. This is Entity's -# own PowerSupplyFactor, read by every stat carrying a PowerSupply term, not just targeting -- restored -# immediately after this one filtered test run, same discipline as every mutation in this harness. --- -check_mutation \ - "StarvedTargetingRollsWorse: PowerSupplyFactor must be a gradient, not a >1e-4 on/off switch" \ - "$ENTITY_CS" \ - 'public float PowerSupplyFactor(float exponent) => pow(PowerSupply, exponent);' \ - 'public float PowerSupplyFactor(float exponent) => PowerSupply <= 1e-4f ? 0f : 1f;' \ - "TargetingSystemTests.StarvedTargetingRollsWorse" \ - "red" - -# --- EveryArmedLoadoutGetsATargetingSystem (Q4): every armed hull's loadout must still try to acquire a -# targeting system. The doc's own mutation ("drop required: true") no longer applies -- Cut 6c.2 already -# changed this call to required:false/degrade-and-log, an operator ruling (see the header comment and the -# report). The adapted mutation makes the acquisition predicate never match, which is what "the rule regresses -# to nothing" now looks like: FillInterior degrades exactly as it does for a galaxy with no seller, silently. --- -check_mutation \ - "EveryArmedLoadoutGetsATargetingSystem: FillInterior must still attempt to acquire a targeting system for an armed hull" \ - "$LOADOUT_GENERATOR_CS" \ - $' var (targetingProduct, targetingData) = RandomProduct(2, item =>\r\n item.Behaviors.Any(b => b is TargetingSystemData) &&\r\n item.Shape.FitsWithin(emptyShape, out _, out _));' \ - $' var (targetingProduct, targetingData) = RandomProduct(2, item => false);' \ - "TargetingSystemTests.EveryArmedLoadoutGetsATargetingSystem" \ - "red" - -echo "" -verify_tree_clean - -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 diff --git a/tests/mutation_tests_fire_control_cut3.sh b/tests/mutation_tests_fire_control_cut3.sh deleted file mode 100644 index 66a882167..000000000 --- a/tests/mutation_tests_fire_control_cut3.sh +++ /dev/null @@ -1,376 +0,0 @@ -#!/usr/bin/env bash -# Mutation tests for docs/fire-control-cut.md's Cut 3 ("Hit authority moves"). -# -# Fourteen check_mutation calls covering the cut's own thirteen declared tests (one, ProbabilityFollowsInputs, -# carries three sub-mutations from its own declared list, so fifteen mutations run in total) against -# tests/Aetheria.Shared.Tests/FireAuthorityTests.cs, plus one more against the test Cut 7 rewrote in its place: -# -# 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(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 -# (fire-control-cut.md, 7.2's own header comment, and FireAuthorityTests.cs's comment on the replacement) -# rewrote it as CombatNeverDrawsFromTheSharedStream: no combat path draws from the shared stream, in arc or -# out. That rule IS externally observable (Commit has no reference to ItemManager to draw from at all under -# the current design, so the only way to make it "touch the shared stream" is to add a read that was not -# there before) -- this harness's own mutation below does exactly that, through the one handle Commit does -# hold (`shot.Source.ItemManager`, already read elsewhere in this same function for hull data). -# -# 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. Modelled on -# tests/mutation_tests_fire_control_cut5.sh, the good example: byte-exact I/O, sha256-verified restore after -# every single mutation, and an explicit tree-clean verdict line at exit. -# -# Usage: -# tests/mutation_tests_fire_control_cut3.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. - -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 -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 - 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" -} - -ARTIFACTS_PATH="$(mktemp -d)" - -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() { - 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" - - 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 --- -check_mutation \ - "control (no-op round trip)" \ - "$FIRE_CONTROL_CS" \ - 'var elapsed = now - shot.FireTime;' \ - 'var elapsed = now - shot.FireTime;' \ - "FireAuthorityTests.EvasionCountsUntilCommitAndNotAfter" \ - "green" - -# --- RollsAreSeeded (Q5): a fight must be reproducible from its seed. Mutation: draw from an unseeded, -# non-reproducible generator instead of the pure function of (zone identity, shot id). A first attempt seeded -# from System.DateTime.UtcNow.Ticks survived: this whole 50-shot fixture runs faster than the clock's own tick -# resolution, so two runs a few milliseconds apart drew the identical "wall clock" value every time and the -# mutation was accidentally still deterministic. Guid.NewGuid() carries its own entropy source independent of -# clock resolution and reliably breaks reproducibility. --- -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(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));' \ - 'var random = new Random((uint) System.Guid.NewGuid().GetHashCode() | 1u);' \ - "FireAuthorityTests.RollsAreSeeded" \ - "red" - -# --- ProbabilityFollowsInputs, variant 1/3: drop pSensor -- probability stops depending on detection info. --- -# 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" \ - '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]. -# 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" \ - 'diagnostic.InRange = diagnostic.Range >= weapon.MinRange && diagnostic.Range <= weapon.Range;' \ - 'diagnostic.InRange = true;' \ - "FireAuthorityTests.ProbabilityFollowsInputs" \ - "red" - -# --- ProbabilityFollowsInputs, variant 3/3: drop the UnaidedAccuracy cap -- an unaided shooter rolls at full -# strength (1f) instead of the authored handicap. --- -check_mutation \ - "ProbabilityFollowsInputs (3/3): an unaided shooter must cap at UnaidedAccuracy, not roll at full strength" \ - "$FIRE_CONTROL_CS" \ - ': entity.ItemManager.GameplaySettings.UnaidedAccuracy;' \ - ': 1f;' \ - "FireAuthorityTests.ProbabilityFollowsInputs" \ - "red" - -# --- ShotResolvesOnArrival (R3): no durability change before arrival. Mutation: apply as soon as committed, -# not only once the shot has also arrived. --- -check_mutation \ - "ShotResolvesOnArrival: Step must apply only at ArrivalTime, not as soon as a shot commits" \ - "$FIRE_CONTROL_CS" \ - 'if (shot.Committed && now >= shot.ArrivalTime)' \ - 'if (shot.Committed)' \ - "FireAuthorityTests.ShotResolvesOnArrival" \ - "red" - -# --- DeadEntityStopsTakingShots (0b table): a shot whose target left the zone must resolve as a miss and be -# removed outright, not go on resolving against a target no longer in play. --- -check_mutation \ - "DeadEntityStopsTakingShots: Step must detect a target that left the zone, not resolve against it anyway" \ - "$FIRE_CONTROL_CS" \ - 'var targetGone = shot.Target != null && !zone.Entities.Contains(shot.Target);' \ - 'var targetGone = false;' \ - "FireAuthorityTests.DeadEntityStopsTakingShots" \ - "red" - -# --- OutcomeCommitsBeforeImpact (R4): ShotCommitted must fire exactly once per shot. Mutation: publish it -# twice from the same commit branch. --- -check_mutation \ - "OutcomeCommitsBeforeImpact: ShotCommitted must publish exactly once per shot, not twice" \ - "$FIRE_CONTROL_CS" \ - $' zone.ShotCommitted.OnNext(shot.Outcome);\r\n shots[i] = shot;\r\n }\r\n\r\n if (shot.Committed && now >= shot.ArrivalTime)' \ - $' zone.ShotCommitted.OnNext(shot.Outcome);\r\n zone.ShotCommitted.OnNext(shot.Outcome);\r\n shots[i] = shot;\r\n }\r\n\r\n if (shot.Committed && now >= shot.ArrivalTime)' \ - "FireAuthorityTests.OutcomeCommitsBeforeImpact" \ - "red" - -# --- EvasionCountsUntilCommitAndNotAfter (R4's second half): deviation must be measured against the elapsed -# time at commit, not at fire (elapsed pinned to 0, i.e. no drift accounted for at all). Cut 5's own harness -# already pins this test's OTHER declared mutation (elapsed pinned to the shot's fixed total duration, i.e. -# measured as if always at arrival) as a named survivor; this is the complementary variant from this cut's own -# table. --- -check_mutation \ - "EvasionCountsUntilCommitAndNotAfter: deviation must track elapsed time, not measure as if fired an instant ago" \ - "$FIRE_CONTROL_CS" \ - 'var elapsed = now - shot.FireTime;' \ - 'var elapsed = 0f;' \ - "FireAuthorityTests.EvasionCountsUntilCommitAndNotAfter" \ - "red" - -# --- ShortFlightCommitsAtFire (R4's graceful degradation): a sub-horizon flight must commit at fire time, in -# the same tick. Mutation: always require the full CommitHorizon regardless of how short the flight is, so a -# zero-velocity shot's CommitTime lands after its own ArrivalTime and the commit/resolve order breaks. --- -check_mutation \ - "ShortFlightCommitsAtFire: CommitTime must degrade to fire time for a sub-horizon flight, not always wait a full horizon" \ - "$FIRE_CONTROL_CS" \ - 'CommitTime = now + max(0f, flightTime - commitHorizon),' \ - 'CommitTime = now + commitHorizon,' \ - "FireAuthorityTests.ShortFlightCommitsAtFire" \ - "red" - -# --- OutcomeIsSnapshotNotReread (R10/Q6): Apply must use the frozen shot.Damage, not re-read the weapon's -# live (possibly since-changed) Damage at arrival. --- -check_mutation \ - "OutcomeIsSnapshotNotReread: Apply must use the frozen shot.Damage, not re-read the live weapon stat" \ - "$FIRE_CONTROL_CS" \ - 'shot.Target.ApplyHit(shot.Source, shot.Outcome.Cell, shot.DamageSpread, shot.Penetration, shot.Damage, hitDirection);' \ - 'shot.Target.ApplyHit(shot.Source, shot.Outcome.Cell, shot.DamageSpread, shot.Penetration, ((Weapon) shot.Weapon.Behaviors.First(b => b is Weapon)).Damage, hitDirection);' \ - "FireAuthorityTests.OutcomeIsSnapshotNotReread" \ - "red" - -# --- AimedHitLandsOnSelectedItem (R5's payoff): with Precision authored extremely tight (Cut 6d: the test now -# passes precision: 1000, a sigma a tiny fraction of one cell), a hit must land on the aimed item's own cell, -# not the hull's centre of mass. Cut 6d deleted the coin-flip branch this mutation used to sabotage -# (`random.NextFloat() < shot.Precision` picking between the aimed cell and a uniform-random one); the -# equivalent sabotage under the dart-throw kernel is feeding Commit's aim-point resolver a null Aimed, so it -# falls back to the centre-of-mass path unconditionally -- the same "aim is ignored" defect, on the new code. --- -check_mutation \ - "AimedHitLandsOnSelectedItem: the kernel's aim point must respect Aimed, not fall back to centre of mass" \ - "$FIRE_CONTROL_CS" \ - 'var (aimPoint, aimedCells) = ResolveAimPoint(shot.Target, hullData, shot.Aimed);' \ - 'var (aimPoint, aimedCells) = ResolveAimPoint(shot.Target, hullData, null);' \ - "FireAuthorityTests.AimedHitLandsOnSelectedItem" \ - "red" - -# --- HardpointHitDamagesItemThenHull: damage above armor plus item durability must pass its remainder on to -# the hull. Mutation: drop the remainder (it never reaches the hull). --- -check_mutation \ - "HardpointHitDamagesItemThenHull: the remainder after armor and item must reach the hull, not be dropped" \ - "$ENTITY_CS" \ - 'hullDamage += d;' \ - '// dropped -- remainder never reaches the hull' \ - "FireAuthorityTests.HardpointHitDamagesItemThenHull" \ - "red" - -# --- PenetrationMarchIsPlanar (R7): the march must rotate the hit direction into the target's own facing, not -# march along the raw world-space direction. --- -check_mutation \ - "PenetrationMarchIsPlanar: the penetration march must rotate into the target's own frame, not use raw world direction" \ - "$ENTITY_CS" \ - $' var forward = normalize(Direction);\r\n var right = float2(forward.y, -forward.x);\r\n var penetrationVector = normalize(float2(dot(hitDirection, right), dot(hitDirection, forward)));' \ - ' var penetrationVector = normalize(hitDirection);' \ - "FireAuthorityTests.PenetrationMarchIsPlanar" \ - "red" - -# --- ShieldTakesHit: the absorb rule must have one owner (FireControl.Commit). Mutation: remove the shield -# branch entirely -- shielded/shieldBroken never get set, so Apply always lets the hit reach the hull. --- -check_mutation \ - "ShieldTakesHit: Commit must decide the shield branch, not skip it (hit always reaches the hull)" \ - "$FIRE_CONTROL_CS" \ - $' var shield = shot.Target.Shield;\r\n var shieldActive = shield != null && shield.Item.Active.Value;\r\n if (shieldActive && shield.CanTakeHit(shot.DamageType, shot.Damage)) shielded = true;\r\n else if (shieldActive) shieldBroken = true;' \ - $' var shield = shot.Target.Shield;' \ - "FireAuthorityTests.ShieldTakesHit" \ - "red" - -# --- Cut 7's replacement for OutOfArcConsumesNoDraw: CombatNeverDrawsFromTheSharedStream. Mutation: Commit -# reads shot.Source.ItemManager.Random (the shared stream FireAuthorityTests.cs's own Build exposes as -# e.Items.Random) once per call, restoring exactly the kind of shared-stream touch Cut 6b (6.1) removed -- -# unconditionally, so both the theory's in-arc and out-of-arc cases go red. --- -check_mutation \ - "CombatNeverDrawsFromTheSharedStream: Commit must never read ItemManager's shared stream, on any path" \ - "$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)); shot.Source.ItemManager.Random.NextFloat();' \ - "FireAuthorityTests.CombatNeverDrawsFromTheSharedStream" \ - "red" - -echo "" -verify_tree_clean - -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 diff --git a/tests/mutation_tests_fire_control_cut4.py b/tests/mutation_tests_fire_control_cut4.py deleted file mode 100644 index 87fb19e22..000000000 --- a/tests/mutation_tests_fire_control_cut4.py +++ /dev/null @@ -1,201 +0,0 @@ -#!/usr/bin/env python3 -"""Mutation tests for docs/fire-control-cut.md's Cut 4 ("The unused kinds join the same path") -- the last five -weapon kinds (ConstantLaser, ConstantLightning, ConstantParticleWeapon, HitscanEffect, Mine) stop deciding -their own hits through live Physics queries and join FireControl, the same owner Cut 3 gave the other four. - -Four rules are pinned here, each against tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs: - - 1. BeamRollsPerInterval: a continuous weapon (ConstantWeapon) is a sequence of discrete rolls, one per - GameplaySettings.BeamResolveInterval, not a roll per tick and not one roll for the whole burst. - 2. InstantWeaponFiresWithoutAnyOnFireSubscriber / (implicitly) BeamRollsPerInterval again: FireControl.Fire - must run whether or not a presentation is listening -- a real Cut 3/4 defect this campaign found and - fixed. Inlining the call back into `OnFire?.Invoke(FireControl.Fire(...))` silently stops every shot from - firing in a headless run (no Unity EntityInstance ever subscribes OnFire/OnBeamShot), because C#'s - null-conditional short-circuits the whole expression, argument included, when the event has no - subscriber. Pinned on both InstantWeapon.cs and ConstantWeapon.cs since Cut 3 introduced the pattern once - and Cut 4 copied it a second time. - 3. SplashHitsEveryEntityInRadius: FireControl.Splash (mine/airburst blast) is one rule applied to every - entity within radius, not just the nearest. - 4. SplashIsDirectional: splash damage lands on the half of each target's own hull that faces the blast, not - the whole hull. - -A no-op control mutation proves the read/replace/restore path is transparent. - -Usage: - python tests/mutation_tests_fire_control_cut4.py --cultlib-root - -Only touches files under this repository; every mutation is reversed before the script exits, including on -failure. -""" -from __future__ import annotations - -import argparse -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -TEST_PROJECT = REPO_ROOT / "tests" / "Aetheria.Shared.Tests" -CONSTANT_WEAPON_CS = "Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs" -INSTANT_WEAPON_CS = "Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs" -FIRE_CONTROL_CS = "Assets/Scripts/ServerShared/FireControl.cs" - - -@dataclass -class Mutation: - rule: str - file: str - anchor: str - mutated: str - test: str - expect: str # "red" (mutant must be killed) or "green" (the no-op control) - - -MUTATIONS: list[Mutation] = [ - # --- No-op control: proves the anchor/replace/restore mechanism is transparent --- - Mutation( - rule="control (no-op round trip)", - file=CONSTANT_WEAPON_CS, - anchor=" while (_beamTimer >= interval)", - mutated=" while (_beamTimer >= interval)", - test="FireControlCut4Tests.BeamRollsPerInterval", - expect="green", - ), - - # --- A beam is a sequence of rolls, not a continuous truth: roll per tick (many more, much smaller - # --- outcomes over the same second) must be killed. --- - Mutation( - rule="ConstantWeapon must roll once per BeamResolveInterval, not once per tick", - file=CONSTANT_WEAPON_CS, - anchor=" while (_beamTimer >= interval)", - mutated=" while (true)", - test="FireControlCut4Tests.BeamRollsPerInterval", - expect="red", - ), - - # --- The opposite direction: rolling once for the whole burst (never draining the timer back below - # --- interval) must also be killed -- a single outcome instead of a sequence. --- - Mutation( - rule="ConstantWeapon must not roll once for the whole burst", - file=CONSTANT_WEAPON_CS, - anchor=" _beamTimer -= interval;", - mutated=" _beamTimer = float.NegativeInfinity;", - test="FireControlCut4Tests.BeamRollsPerInterval", - expect="red", - ), - - # --- The real defect this campaign found: FireControl.Fire must run whether or not OnBeamShot has a - # --- subscriber. Inlining it back into the null-conditional silently stops every beam from firing in a - # --- headless run (nothing ever subscribes OnBeamShot). --- - Mutation( - rule="ConstantWeapon.Execute must evaluate FireControl.Fire even with no OnBeamShot subscriber", - file=CONSTANT_WEAPON_CS, - anchor=" var shotId = FireControl.Fire(this, Item, Entity, Damage * interval);\n OnBeamShot?.Invoke(shotId);", - mutated=" OnBeamShot?.Invoke(FireControl.Fire(this, Item, Entity, Damage * interval));", - test="FireControlCut4Tests.BeamRollsPerInterval", - expect="red", - ), - - # --- The same defect, in InstantWeapon.cs (Cut 3's original copy of the pattern) -- caught only by this - # --- cut's own regression test, since FireAuthorityTests.cs always calls FireControl.Fire directly and - # --- never exercises Trigger()/Execute() with zero OnFire subscribers. --- - Mutation( - rule="InstantWeapon.Execute must evaluate FireControl.Fire even with no OnFire subscriber", - file=INSTANT_WEAPON_CS, - anchor=" var shotId = FireControl.Fire(this, Item, Entity);\n OnFire?.Invoke(shotId);", - mutated=" OnFire?.Invoke(FireControl.Fire(this, Item, Entity));", - test="FireControlCut4Tests.InstantWeaponFiresWithoutAnyOnFireSubscriber", - expect="red", - ), - - # --- Splash is one rule, not per-effect: damaging only the nearest entity in range instead of every one - # --- must be killed. --- - Mutation( - rule="FireControl.Splash must damage every entity in radius, not only the nearest", - file=FIRE_CONTROL_CS, - anchor=" var toTarget = (target.Position - position).xz;\n if (length(toTarget) > radius) continue;", - mutated=" var toTarget = (target.Position - position).xz;\n if (length(toTarget) > radius) continue;\n if (zone.Entities.Where(e => length((e.Position - position).xz) <= radius).OrderBy(e => length((e.Position - position).xz)).First() != target) continue;", - test="FireControlCut4Tests.SplashHitsEveryEntityInRadius", - expect="red", - ), - - # --- Splash is directional over each target's own hull: damaging the whole shape regardless of facing - # --- must be killed. --- - Mutation( - rule="FireControl.Splash must damage only the half of the hull facing the blast, not the whole shape", - file=FIRE_CONTROL_CS, - anchor=" if (dot(normalize((float2) v - hullData.Shape.CenterOfMass), localDirection) < 0)\n hitShape[v] = true;", - mutated=" hitShape[v] = true;", - test="FireControlCut4Tests.SplashIsDirectional", - expect="red", - ), -] - - -def replace_unique(path: Path, anchor: str, mutated: str) -> None: - original = path.read_text(encoding="utf-8") - count = original.count(anchor) - if count != 1: - raise SystemExit(f"{path}: anchor must occur exactly once, found {count}\n---\n{anchor}") - path.write_text(original.replace(anchor, mutated), encoding="utf-8") - - -def run_test(cultlib_root: str, test: str) -> tuple[int, str]: - proc = subprocess.run( - ["dotnet", "test", str(TEST_PROJECT), f"-p:CultLibRoot={cultlib_root}", - "--filter", f"FullyQualifiedName~{test}"], - capture_output=True, text=True, timeout=300, - ) - return proc.returncode, proc.stdout + proc.stderr - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--cultlib-root", required=True) - args = parser.parse_args() - - failures: list[str] = [] - originals: dict[str, str] = {} - for m in MUTATIONS: - if m.file not in originals: - originals[m.file] = (REPO_ROOT / m.file).read_text(encoding="utf-8") - - def restore_all() -> None: - for file, text in originals.items(): - (REPO_ROOT / file).write_text(text, encoding="utf-8") - - try: - for m in MUTATIONS: - print(f"\n=== {m.rule} (expect {m.expect}) ===") - path = REPO_ROOT / m.file - replace_unique(path, m.anchor, m.mutated) - code, output = run_test(args.cultlib_root, m.test) - passed = "Passed!" in output and code == 0 - if m.expect == "green": - if not passed: - failures.append(f"{m.rule}: expected green (test passing), got failure\n{output[-2000:]}") - else: - print(f"OK: {m.test} passed under a no-op mutation, as expected") - else: - if passed: - failures.append(f"{m.rule}: expected red (mutant killed), but {m.test} still passed") - else: - print(f"OK: {m.test} went red under the mutation, as expected") - # Restore this file immediately so the next mutation starts from a clean tree. - path.write_text(originals[m.file], encoding="utf-8") - finally: - restore_all() - - if failures: - print("\n=== FAILURES ===") - for f in failures: - print(f"- {f}") - return 1 - - print("\nAll mutations behaved as declared.") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/mutation_tests_fire_control_cut5.sh b/tests/mutation_tests_fire_control_cut5.sh deleted file mode 100644 index 77f0c71fa..000000000 --- a/tests/mutation_tests_fire_control_cut5.sh +++ /dev/null @@ -1,353 +0,0 @@ -#!/usr/bin/env bash -# Mutation tests for docs/fire-control-cut.md's Cut 5 ("the rules that landed in one path and not its twin"). -# -# Eleven mutations, each against tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs or -# tests/Aetheria.Shared.Tests/FireAuthorityTests.cs: -# -# 1-7. 5.1 through 5.7, each killing the test this cut wrote or extended to pin it: -# 5.1 UnaidedTrackingFallsBackToSettingNotZero / TrackingIsAFloorNotACliff -# 5.2 UnabsorbableHitBreaksShield -# 5.3 ConstantWeaponObeysArc -# 5.4 PointBlankBearingAlwaysBears -# 5.5 DestroyedItemStopsBeingAimPoint -# 5.6 DeathRemovesShipFromSimulation -# 5.7 PendingShotCarriesNoDeadFields -# 8-11. Four of the five mutations that survived 197 green tests in Soul's own pass (the fifth, -# freezing source.TargetItem.Value instead of ResolvedTargetItem, is Cut 7's): -# FireControl.cs -- delete shield.Break() from Splash -# Weapon.cs -- ArcAllowsFire hardcoded true -# InstantWeapon.cs -- delete the player's own arc gate -# FireControl.cs (Commit) -- elapsed pinned to the shot's own duration instead of tracking `now` -# (the map's own named mutation for EvasionCountsUntilCommitAndNotAfter; post-5.7 the spelling -# is `shot.ArrivalTime - shot.FireTime` rather than the deleted FlightTime field, same rule) -# -# A no-op control (a byte-identical rewrite of one target file through this script's own read/write path) -# proves the mechanism itself is transparent before any real mutation is trusted. -# -# Usage: -# tests/mutation_tests_fire_control_cut5.sh -# -# Every mutation is reversed (byte-exact, from an 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. - -set -u - -if [ $# -lt 1 ]; then - echo "Usage: $0 " >&2 - exit 2 -fi -CULTLIB_ROOT="$1" - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -TEST_PROJECT="$REPO_ROOT/tests/Aetheria.Shared.Tests" - -FIRE_CONTROL_CS="$REPO_ROOT/Assets/Scripts/ServerShared/FireControl.cs" -WEAPON_CS="$REPO_ROOT/Assets/Scripts/ServerShared/Behaviors/Weapon.cs" -INSTANT_WEAPON_CS="$REPO_ROOT/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs" -CONSTANT_WEAPON_CS="$REPO_ROOT/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs" -ZONE_CS="$REPO_ROOT/Assets/Scripts/ServerShared/Zone.cs" - -# --- byte-exact backup/restore: preserves line endings exactly, no text-mode translation. --- -# -# Cut 5b (5b.3): a restore that is not verified is a restore that can lie. Three runs of this script have -# left mutated files behind (a trap cannot survive a hard kill of the process group, and nothing before this -# cut ever checked that a `cp -p` restore actually reproduced the original bytes). Every target file's -# pre-mutation sha256 is recorded once here, before any mutation touches it; every restore below -- -# per-mutation and at exit -- re-hashes and refuses to let a mismatch pass silently. -declare -A ORIGINALS -declare -A ORIGINAL_HASH -FILES=("$FIRE_CONTROL_CS" "$WEAPON_CS" "$INSTANT_WEAPON_CS" "$CONSTANT_WEAPON_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 FILE -- restores one file from its recorded backup, then re-hashes it and aborts the -# whole script (not just this mutation) if the restore did not reproduce the pre-mutation hash exactly. A -# mismatch here means continuing would judge every later mutation against an already-contaminated baseline, -# which is strictly worse than stopping. -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 -- re-hashes every target file against its pre-mutation baseline and prints an explicit -# PASS/FAIL verdict. Called once explicitly at the bottom of a normal run (so the verdict is part of that -# run's own summary, not something a reader has to infer from silence) and again from the EXIT/INT/TERM trap -# as the safety net for every abnormal exit -- interrupted, failed, or killed in a way bash can still trap. -# (A SIGKILL of the whole process group is the one termination this cannot see at all; nothing running inside -# the killed process can run afterward, trap or not -- that gap is closed by re-checking independently after -# the fact, not by this function.) -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 awk/perl, not sed's line-oriented rewriting, so a -# multi-line anchor is matched as literal text). Writes the mutated content back byte-exact. -replace_unique() { - local file="$1" anchor="$2" replacement="$3" - local count - count=$(perl -0777 -e ' - my ($anchor, $file) = @ARGV; - local $/; open(my $fh, "<:raw", $file) or die $!; - my $text = <$fh>; close $fh; - my $n = () = $text =~ /\Q$anchor\E/g; - print $n; - ' "$anchor" "$file") - if [ "$count" != "1" ]; then - echo "ERROR: $file: anchor must occur exactly once, found $count" >&2 - echo "--- anchor ---" >&2 - echo "$anchor" >&2 - return 1 - fi - # No -i: on this filesystem perl's in-place-edit extension parsing has mangled a literal ":raw" suffix - # into stray sibling files before. Read, substitute once, write back byte-exact instead. - perl -0777 -e ' - my ($file, $anchor, $replacement) = @ARGV; - local $/; open(my $fh, "<:raw", $file) or die $!; - my $text = <$fh>; close $fh; - my $qa = quotemeta($anchor); - $text =~ s/$qa/$replacement/; - open(my $out, ">:raw", $file) or die $!; - print $out $text; close $out; - ' "$file" "$anchor" "$replacement" -} - -# A dedicated, isolated artifacts path -- the default tests/Aetheria.Shared.Tests/bin,obj can carry stale -# prebuilt CultLib assemblies from an unrelated earlier session's default-path build, and mixing those with a -# freshly compiled Aetheria.Shared.Tests.dll produced one observed spurious failure of a control (no-op) -# mutation. A private --artifacts-path sidesteps that shared, unowned build state entirely. -ARTIFACTS_PATH="$(mktemp -d)" - -# run_test TEST_FQN -> 0 (passed) or 1 (failed/errored) -run_test() { - local test="$1" - local out - out=$(dotnet test "$TEST_PROJECT" -p:CultLibRoot="$CULTLIB_ROOT" --artifacts-path "$ARTIFACTS_PATH" --filter "FullyQualifiedName~$test" 2>&1) - if echo "$out" | grep -q "Passed!" && ! echo "$out" | grep -q "Failed: [1-9]"; then - return 0 - fi - LAST_OUTPUT="$out" - return 1 -} - -# check_mutation NAME FILE ANCHOR MUTATED TEST EXPECT("red"|"green") -check_mutation() { - local name="$1" file="$2" anchor="$3" mutated="$4" test="$5" expect="$6" - echo "" - echo "=== $name (expect $expect) ===" - if ! replace_unique "$file" "$anchor" "$mutated"; then - FAILURES+=("$name: anchor replacement failed") - 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 pDeviation = DeviationProbability(shot, now, out _);' \ - 'var pDeviation = DeviationProbability(shot, now, out _);' \ - "FireControlCut5Tests.TrackingIsAFloorNotACliff" \ - "green" - -# --- 5.1: Tracking is a floor, not a cliff. Mutation: the unaided fallback reverts to a literal 0f (division -# by zero saturates the deviation term to 0 regardless of how forgivable the deviation actually is). --- -check_mutation \ - "5.1 Tracking must fall back to UnaidedTracking, not 0" \ - "$FIRE_CONTROL_CS" \ - 'return system != null && system.Item.Active.Value ? system.Tracking : entity.ItemManager.GameplaySettings.UnaidedTracking;' \ - 'return system != null && system.Item.Active.Value ? system.Tracking : 0f;' \ - "FireControlCut5Tests.UnaidedTrackingFallsBackToSettingNotZero" \ - "red" - -# --- 5.2: a shot the shield cannot absorb breaks it. Mutation: delete Apply's Break() call -- the hull still -# takes the remainder, but the shield itself never registers the overwhelming hit. --- -check_mutation \ - "5.2 Apply must Break() a shield the roll decided ShieldBroken" \ - "$FIRE_CONTROL_CS" \ - 'if (shot.Outcome.ShieldBroken) shot.Target.Shield.Break();' \ - '// deleted' \ - "FireControlCut5Tests.UnabsorbableHitBreaksShield" \ - "red" - -# --- 5.3: continuous weapons obey their arc. Mutation: ConstantWeapon's safety check drops ArcAllowsFire, -# restoring StanceAllowsFire-only gating (a side-mounted beam fires forward). --- -check_mutation \ - "5.3 ConstantWeapon.Execute must safe off on ArcAllowsFire too, not StanceAllowsFire alone" \ - "$CONSTANT_WEAPON_CS" \ - 'if (_firing && !(StanceAllowsFire && ArcAllowsFire))' \ - 'if (_firing && !StanceAllowsFire)' \ - "FireControlCut5Tests.ConstantWeaponObeysArc" \ - "red" - -# --- 5.4: one bearing test, including at zero range. Mutation: drop InArc's point-blank guard, restoring the -# NaN comparison a zero-length bearing used to fall through to. --- -check_mutation \ - "5.4 InArc must treat a zero-length bearing as bearing true, not fall through to NaN" \ - "$FIRE_CONTROL_CS" \ - $' if (lengthsq(planarToTarget) < 1e-6f) return true;\r\n var planarTarget = normalize(planarToTarget);' \ - ' var planarTarget = normalize(planarToTarget);' \ - "FireControlCut5Tests.PointBlankBearingAlwaysBears" \ - "red" - -# --- 5.5: a destroyed subsystem stops being the aim point. Mutation: drop the Durability filter from -# IsRevealed's ranking -- a destroyed item keeps its old tier and TargetItem keeps aiming at a corpse. --- -check_mutation \ - "5.5 IsRevealed's ranking must exclude a destroyed item, not just fail its own tier" \ - "$FIRE_CONTROL_CS" \ - '.Where(e => e.Data.HardpointType != HardpointType.Hull && e.EquippableItem.Durability >= .01f)' \ - '.Where(e => e.Data.HardpointType != HardpointType.Hull)' \ - "FireControlCut5Tests.DestroyedItemStopsBeingAimPoint" \ - "red" - -# --- 5.6: death removes the ship, in the simulation. Mutation: remove Zone's own Death subscription -- -# nothing outside Unity's own (untested here) loot-drop path would ever remove the corpse. 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); add.Value.Deactivate(); }));' \ - '// deleted -- nothing removes a dead entity' \ - "FireControlCut5Tests.DeathRemovesShipFromSimulation" \ - "red" - -# --- 5.7: delete the two carried-and-unread fields. Mutation: re-add PredictedIntercept to the struct. --- -check_mutation \ - "5.7 PendingShot must not carry PredictedIntercept back" \ - "$FIRE_CONTROL_CS" \ - $' public float3 FireTargetVelocity;\r\n public float FireTime;' \ - $' public float3 FireTargetVelocity;\r\n public float3 PredictedIntercept;\r\n public float FireTime;' \ - "FireControlCut5Tests.PendingShotCarriesNoDeadFields" \ - "red" - -# --- Named survivor 1: FireControl.cs -- delete shield.Break() from Splash. --- -check_mutation \ - "survivor: Splash must Break() a shield it does not absorb" \ - "$FIRE_CONTROL_CS" \ - 'if (shieldActive && !shieldAbsorbs) shield.Break();' \ - '// deleted' \ - "FireControlCut5Tests.SplashBreaksUnabsorbedShield" \ - "red" - -# --- Named survivor 2: Weapon.cs -- ArcAllowsFire hardcoded true. --- -check_mutation \ - "survivor: Weapon.ArcAllowsFire must defer to InArc's real answer" \ - "$WEAPON_CS" \ - 'return FireControl.InArc(Item, toTarget);' \ - 'return true;' \ - "FireControlCut5Tests.WeaponArcAllowsFireReflectsRealBearing" \ - "red" - -# --- Named survivor 3: InstantWeapon.cs -- delete the player's own arc gate (Q2, Cut 3's own deferred rule). --- -check_mutation \ - "survivor: InstantWeapon.Trigger must refuse to queue a burst out of arc" \ - "$INSTANT_WEAPON_CS" \ - 'if (!ArcAllowsFire) return;' \ - '' \ - "FireControlCut5Tests.PlayerTriggerObeysArcGate" \ - "red" - -# --- Named survivor 4: FireControl.cs (Commit) -- elapsed pinned to the shot's own fixed duration instead of -# tracking `now`, so deviation never grows past its value at the instant the shot was queued. This is the -# map's own declared mutation for EvasionCountsUntilCommitAndNotAfter (FireAuthorityTests.cs); post-5.7 the -# spelling is `shot.ArrivalTime - shot.FireTime` (constant) rather than the deleted FlightTime field. --- -check_mutation \ - "survivor: Commit must measure elapsed against now, not a fixed duration" \ - "$FIRE_CONTROL_CS" \ - 'var elapsed = now - shot.FireTime;' \ - 'var elapsed = shot.ArrivalTime - shot.FireTime;' \ - "FireAuthorityTests.EvasionCountsUntilCommitAndNotAfter" \ - "red" - -echo "" -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 diff --git a/tests/mutation_tests_fire_control_cut6.sh b/tests/mutation_tests_fire_control_cut6.sh deleted file mode 100644 index 1de667cc2..000000000 --- a/tests/mutation_tests_fire_control_cut6.sh +++ /dev/null @@ -1,289 +0,0 @@ -#!/usr/bin/env bash -# Mutation tests for docs/fire-control-cut.md's Cut 6b ("the dice stop being shared, and the flak cannon works -# again"). -# -# Four mutations, each against tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs, plus a no-op control and a -# negative-grep step: -# -# 1. 6.1 SameFightRollsSameThroughUnrelatedDraws -- restore Commit's generator to the shared -# ItemManager.Random stream (read AND write-back; CultMath.Random is a struct, so the old shared-stream -# behaviour needs both halves, not just the read). -# 2. 6.1 ShotIdDecidesTheDie -- drop ShotId from the local generator's seed. -# 3. 6.2 AirburstAndDiscreteNeverDoubleUp -- call Apply as well as Splash at arrival. -# 4. 6.2 NonAirburstNeverSplashes -- splash unconditionally instead of gating on BurstRadius. -# -# A no-op control (a byte-identical rewrite of the target file through this script's own read/write path) -# proves the mechanism itself is transparent before any real mutation is trusted. Modelled on -# tests/mutation_tests_fire_control_cut5.sh, the good example: byte-exact I/O, sha256-verified restore after -# every single mutation, and an explicit tree-clean verdict line at exit. -# -# Usage: -# tests/mutation_tests_fire_control_cut6.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. - -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" - -# --- byte-exact backup/restore: preserves line endings exactly, no text-mode translation. Every target file's -# pre-mutation sha256 is recorded once here, before any mutation touches it; every restore below -- per- -# mutation and at exit -- re-hashes and refuses to let a mismatch pass silently (docs/fire-control-cut.md Cut -# 5b.3's own lesson: a harness that can leak an un-restored mutation into the tree produces fictional -# verdicts). --- -declare -A ORIGINALS -declare -A ORIGINAL_HASH -FILES=("$FIRE_CONTROL_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 - 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" -} - -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 TEST EXPECT("red"|"green") -- applies whatever mutation the caller already staged -# via replace_unique calls made just before invoking this, runs TEST, restores FILE, and judges. Used by the -# single-anchor mutations below directly; the one multi-edit mutation (restoring the shared-stream generator) -# calls replace_unique twice itself before delegating here. -judge_mutation() { - local name="$1" file="$2" test="$3" expect="$4" - if run_test "$test"; then - passed=1 - else - passed=0 - fi - restore_and_verify "$file" # restore 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 -} - -# check_mutation NAME FILE ANCHOR MUTATED TEST EXPECT("red"|"green") -- single-anchor convenience wrapper. -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 - judge_mutation "$name" "$file" "$test" "$expect" -} - -# --- 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));' \ - "FireControlCut6Tests.SameFightRollsSameThroughUnrelatedDraws" \ - "green" - -# --- 6.1: a shot's dice belong to the shot. Mutation: restore Commit's generator to the shared -# ItemManager.Random stream -- both the read and the write-back, since CultMath.Random is a struct and the old -# shared-stream behaviour depended on writing the advanced copy back for the next Commit to see. Unrelated -# draws the test makes between shots then perturb the very stream Commit reads from. --- -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(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);' \ - 'shot.Source.ItemManager.Random = random; - return MakeOutcome(shot, hit, shielded, shieldBroken, cell, aimed, now);'; then - judge_mutation "6.1 shared-stream generator" "$FIRE_CONTROL_CS" \ - "FireControlCut6Tests.SameFightRollsSameThroughUnrelatedDraws" "red" -else - FAILURES+=("6.1 shared-stream generator: anchor replacement failed") - restore_and_verify "$FIRE_CONTROL_CS" -fi - -# --- 6.1: the roll is a function of the shot's own id. Mutation: drop ShotId from the seed -- every shot in a -# zone then builds the exact same local generator every time. --- -check_mutation \ - "6.1 ShotId must reach the seed" \ - "$FIRE_CONTROL_CS" \ - 'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));' \ - 'var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) | 1u));' \ - "FireControlCut6Tests.ShotIdDecidesTheDie" \ - "red" - -# --- 6.2: airburst resolves via Splash instead of Apply, never both. Mutation: call Apply as well as Splash at -# arrival -- the double-application Soul was told to hunt for. --- -check_mutation \ - "6.2 an airburst shot must not also Apply a discrete hit" \ - "$FIRE_CONTROL_CS" \ - $' if (shot.BurstRadius > 0f) Splash(zone, shot.BurstPosition, shot.BurstRadius, shot.Damage, shot.DamageType);\r\n else Apply(shot);' \ - $' if (shot.BurstRadius > 0f) Splash(zone, shot.BurstPosition, shot.BurstRadius, shot.Damage, shot.DamageType);\r\n Apply(shot);' \ - "FireControlCut6Tests.AirburstAndDiscreteNeverDoubleUp" \ - "red" - -# --- 6.2: the other side. Mutation: splash unconditionally instead of gating on BurstRadius -- Splash does not -# check Hit, so a shot that never carried the Airburst flag would still deal area damage. --- -check_mutation \ - "6.2 a non-airburst shot must never splash" \ - "$FIRE_CONTROL_CS" \ - $' if (shot.BurstRadius > 0f) Splash(zone, shot.BurstPosition, shot.BurstRadius, shot.Damage, shot.DamageType);\r\n else Apply(shot);' \ - ' Splash(zone, shot.BurstPosition, shot.BurstRadius, shot.Damage, shot.DamageType);' \ - "FireControlCut6Tests.NonAirburstNeverSplashes" \ - "red" - -echo "" -echo "=== negative greps ===" -if grep -q "ItemManager.Random" "$FIRE_CONTROL_CS"; then - FAILURES+=("negative grep: ItemManager.Random still appears in FireControl.cs") - echo "FAIL: ItemManager.Random appears in FireControl.cs" -else - echo "OK: ItemManager.Random does not appear in FireControl.cs" -fi - -if grep -rq "Airburst" "$REPO_ROOT/Assets/Scripts/Gameplay"; then - FAILURES+=("negative grep: Airburst still appears under Assets/Scripts/Gameplay") - echo "FAIL: Airburst appears under Assets/Scripts/Gameplay" - grep -rn "Airburst" "$REPO_ROOT/Assets/Scripts/Gameplay" -else - echo "OK: Airburst does not appear anywhere under Assets/Scripts/Gameplay" -fi - -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 diff --git a/tests/mutation_tests_fire_control_cut6c.sh b/tests/mutation_tests_fire_control_cut6c.sh deleted file mode 100644 index 3451dc46a..000000000 --- a/tests/mutation_tests_fire_control_cut6c.sh +++ /dev/null @@ -1,241 +0,0 @@ -#!/usr/bin/env bash -# Mutation tests for docs/fire-control-cut.md's Cut 6c ("the formula gets fixed, and the single point of -# failure"). -# -# Two mutations, each against tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs: -# -# 1. 6c.1 HigherResolutionYieldsHigherOrEqualHitProbability -- FireControl.cs's HitProbability reads -# Resolution as the sensor ceiling directly again (the shipped Cut 6a behaviour), instead of taking its -# reciprocal. That inverts the direction the operator ruling fixed: a higher-Resolution design would -# score lower than a cheaper one at low info, the exact defect 6c.1 exists to kill. -# 2. 6c.2 NoSellerDegradesToUnaidedInsteadOfThrowing -- LoadoutGenerator.FillInterior's targeting-system -# RandomProduct call goes back to `required: true` and the null case throws InvalidLoadoutException -# again, instead of degrading to an unaided entity and logging the gap. -# -# 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_cut6c.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. - -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" -LOADOUT_GENERATOR_CS="$REPO_ROOT/Assets/Scripts/ServerShared/LoadoutGenerator.cs" - -# --- byte-exact backup/restore: preserves line endings exactly, no text-mode translation. Same convention -# Cut 5's harness established (5b.3): a restore that is not verified is a restore that can lie. --- -declare -A ORIGINALS -declare -A ORIGINAL_HASH -FILES=("$FIRE_CONTROL_CS" "$LOADOUT_GENERATOR_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 FILE -- restores one file from its recorded backup, then re-hashes it and aborts the -# whole script (not just this mutation) if the restore did not reproduce the pre-mutation hash exactly. -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 -- re-hashes every target file against its pre-mutation baseline and prints an explicit -# PASS/FAIL verdict. Called once explicitly at the bottom of a normal run, and again from the EXIT/INT/TERM -# trap as the safety net for every abnormal exit. (A SIGKILL of the whole process group is the one -# termination this cannot see at all -- that gap is closed by re-checking independently after the fact.) -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 - 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 Cut 5's harness for why (stale prebuilt CultLib assemblies from -# an unrelated earlier session's default-path build produced one observed spurious control failure). -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" \ - '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. 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" \ - $' 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" - -# --- 6c.2: a galaxy with no available targeting-system seller must degrade the loadout, not throw. Mutation: -# restore `required: true` on the RandomProduct call and the InvalidLoadoutException throw. --- -check_mutation \ - "6c.2 FillInterior must degrade to unaided fire on no targeting seller, not throw" \ - "$LOADOUT_GENERATOR_CS" \ - $' var (targetingProduct, targetingData) = RandomProduct(2, item =>\r\n item.Behaviors.Any(b => b is TargetingSystemData) &&\r\n item.Shape.FitsWithin(emptyShape, out _, out _));\r\n if (targetingData == null)\r\n {\r\n ItemManager.Log("No targeting system available for armed entity; it will fire unaided.");\r\n }\r\n else\r\n {' \ - $' var (targetingProduct, targetingData) = RandomProduct(2, item =>\r\n item.Behaviors.Any(b => b is TargetingSystemData) &&\r\n item.Shape.FitsWithin(emptyShape, out _, out _), required: true);\r\n if (targetingData == null)\r\n {\r\n throw new InvalidLoadoutException("No compatible targeting system found for entity!");\r\n }\r\n else\r\n {' \ - "FireControlCut6cTests.NoSellerDegradesToUnaidedInsteadOfThrowing" \ - "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 diff --git a/tests/mutation_tests_fire_control_cut6d.sh b/tests/mutation_tests_fire_control_cut6d.sh deleted file mode 100644 index 1f88298c6..000000000 --- a/tests/mutation_tests_fire_control_cut6d.sh +++ /dev/null @@ -1,282 +0,0 @@ -#!/usr/bin/env bash -# Mutation tests for docs/fire-control-cut.md's Cut 6d ("where a hit lands is a dart throw, not a coin flip"). -# -# Four mutations, each from the spec's own Verification block, against -# tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs: -# -# 1. AimingAtTheSternHitsTheStern -- restore the uniform-random branch (ignore the kernel's weights when -# drawing a landing cell). -# 2. ThinLimbCostsHitChance -- drop pOnHull from HitProbability's return. -# 3. PlacementAndProbabilityShareOneKernel -- perturb sigma in one of the kernel's two call sites only -# (HitProbability's, leaving Commit's placement call untouched). -# 4. EveryHitLandsOnMetal -- draw the kernel's weights over the hull's full bounding box -# (Shape.AllCoordinates) instead of its occupied cells (Shape.Coordinates). Not named in the spec's own -# Verification block, but declared in this cut's own test file as the mutation that invariant is pinned -# against, so it is included here rather than left unrun. -# -# A no-op control (a byte-identical rewrite of the 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_cut6d.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" - -# --- byte-exact backup/restore: preserves line endings exactly, no text-mode translation. --- -declare -A ORIGINALS -declare -A ORIGINAL_HASH -FILES=("$FIRE_CONTROL_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 FILE -- restores one file from its recorded backup, then re-hashes it and aborts the -# whole script (not just this mutation) if the restore did not reproduce the pre-mutation hash exactly. A -# mismatch here means continuing would judge every later mutation against an already-contaminated baseline, -# which is strictly worse than stopping. -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 -- re-hashes every target file against its pre-mutation baseline and prints an explicit -# PASS/FAIL verdict. Called once explicitly at the bottom of a normal run (so the verdict is part of that -# run's own summary, not something a reader has to infer from silence) and again from the EXIT/INT/TERM trap -# as the safety net for every abnormal exit -- interrupted, failed, or killed in a way bash can still trap. -# (A SIGKILL of the whole process group is the one termination this cannot see at all; nothing running inside -# the killed process can run afterward, trap or not -- that gap is closed by re-checking independently after -# the fact, not by this function.) -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 awk/perl, not sed's line-oriented rewriting, so a -# multi-line anchor is matched as literal text). Writes the mutated content back byte-exact. -replace_unique() { - local file="$1" anchor="$2" replacement="$3" - local count - count=$(perl -0777 -e ' - my ($anchor, $file) = @ARGV; - local $/; open(my $fh, "<:raw", $file) or die $!; - my $text = <$fh>; close $fh; - my $n = () = $text =~ /\Q$anchor\E/g; - print $n; - ' "$anchor" "$file") - if [ "$count" != "1" ]; then - echo "ERROR: $file: anchor must occur exactly once, found $count" >&2 - echo "--- anchor ---" >&2 - echo "$anchor" >&2 - return 1 - fi - # No -i: on this filesystem perl's in-place-edit extension parsing has mangled a literal ":raw" suffix - # into stray sibling files before. Read, substitute once, write back byte-exact instead. - perl -0777 -e ' - my ($file, $anchor, $replacement) = @ARGV; - local $/; open(my $fh, "<:raw", $file) or die $!; - my $text = <$fh>; close $fh; - my $qa = quotemeta($anchor); - $text =~ s/$qa/$replacement/; - open(my $out, ">:raw", $file) or die $!; - print $out $text; close $out; - ' "$file" "$anchor" "$replacement" -} - -# A dedicated, isolated artifacts path -- the default tests/Aetheria.Shared.Tests/bin,obj can carry stale -# prebuilt CultLib assemblies from an unrelated earlier session's default-path build, and mixing those with a -# freshly compiled Aetheria.Shared.Tests.dll produced one observed spurious failure of a control (no-op) -# mutation elsewhere in this campaign. A private --artifacts-path sidesteps that shared, unowned build state -# entirely. -ARTIFACTS_PATH="$(mktemp -d)" - -# run_test TEST_FQN -> 0 (passed) or 1 (failed/errored) -run_test() { - local test="$1" - local out - out=$(dotnet test "$TEST_PROJECT" -p:CultLibRoot="$CULTLIB_ROOT" --artifacts-path "$ARTIFACTS_PATH" --filter "FullyQualifiedName~$test" 2>&1) - if echo "$out" | grep -q "Passed!" && ! echo "$out" | grep -q "Failed: [1-9]"; then - return 0 - fi - LAST_OUTPUT="$out" - return 1 -} - -# check_mutation NAME FILE ANCHOR MUTATED TEST EXPECT("red"|"green") -check_mutation() { - local name="$1" file="$2" anchor="$3" mutated="$4" test="$5" expect="$6" - echo "" - echo "=== $name (expect $expect) ===" - if ! replace_unique "$file" "$anchor" "$mutated"; then - FAILURES+=("$name: anchor replacement failed") - 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 sigma = Sigma(precision);' \ - 'var sigma = Sigma(precision);' \ - "FireControlCut6dTests.AimingAtTheSternHitsTheStern" \ - "green" - -# --- AimingAtTheSternHitsTheStern: restore the uniform-random branch. Mutation: Commit's weighted draw -# ignores the kernel's own weights and picks uniformly over the hull's occupied cells instead -- the shipped -# coin flip's "miss the aimed item" behaviour, which spreads evenly over the whole hull regardless of aim. --- -check_mutation \ - "AimingAtTheSternHitsTheStern: Commit must draw from the kernel's weights, not uniformly" \ - "$FIRE_CONTROL_CS" \ - 'cell = WeightedPick(cells, weights, totalWeight, random);' \ - 'cell = cells[random.NextInt(cells.Length)];' \ - "FireControlCut6dTests.AimingAtTheSternHitsTheStern" \ - "red" - -# --- 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. 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" \ - '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. 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" \ - $' 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" - -# --- EveryHitLandsOnMetal: draw the kernel's weights over the hull's full bounding box instead of its -# occupied cells. Mutation: HullKernel reads Shape.AllCoordinates (every grid cell in the bounding box, -# occupied or not) instead of Shape.Coordinates (only the cells actually part of the schematic) -- a hull with -# a hole in it can then have a hit resolve onto the hole. Not one of the spec's own four named mutations, but -# the one this cut's own test file declares against this invariant. --- -check_mutation \ - "EveryHitLandsOnMetal: the kernel must draw from the hull's occupied cells, not its full bounding box" \ - "$FIRE_CONTROL_CS" \ - 'var coords = hullData.Shape.Coordinates;' \ - 'var coords = hullData.Shape.AllCoordinates;' \ - "FireControlCut6dTests.EveryHitLandsOnMetal" \ - "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 diff --git a/tests/mutation_tests_fire_control_cut8.sh b/tests/mutation_tests_fire_control_cut8.sh deleted file mode 100644 index 3cf6f07df..000000000 --- a/tests/mutation_tests_fire_control_cut8.sh +++ /dev/null @@ -1,242 +0,0 @@ -#!/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 diff --git a/tests/mutation_tests_fire_control_cut9.sh b/tests/mutation_tests_fire_control_cut9.sh deleted file mode 100644 index 7263e72a8..000000000 --- a/tests/mutation_tests_fire_control_cut9.sh +++ /dev/null @@ -1,252 +0,0 @@ -#!/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 diff --git a/tests/mutation_tests_shield_reserve.py b/tests/mutation_tests_shield_reserve.py deleted file mode 100644 index 79fc2d462..000000000 --- a/tests/mutation_tests_shield_reserve.py +++ /dev/null @@ -1,218 +0,0 @@ -#!/usr/bin/env python3 -"""Mutation tests for the shield reserve ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19). - -Supersedes Cut 4's derived reserve sizing (mutation_tests_stats_power_cut4.py's own shield rule, still intact -and still pinned there against InputCapacitorTests.cs -- a hit within the reserve may draw a partial amount). -This script covers what changed on top of that: ShieldData now authors Capacity/RefillDuration/RestoreDuration -directly, a hit the reserve cannot fully cover breaks the shield instead of a silent, charge-preserving refusal, -the breaking hit itself passes through with no partial absorption, the reserve empties to zero on break (not -left at its pre-hit charge), and a broken shield restores on RestoreDuration, never RefillDuration. Each rule -mutates one exact, unique anchor of source text and asserts the named test in ShieldReserveTests.cs goes red for -exactly that reason. A no-op control mutation proves the read/replace/restore path itself is transparent. - -Usage: - python tests/mutation_tests_shield_reserve.py --cultlib-root - -Only touches files under this repository; every mutation is reversed before the script exits, including on -failure. -""" -from __future__ import annotations - -import argparse -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -TEST_PROJECT = REPO_ROOT / "tests" / "Aetheria.Shared.Tests" -SHIELD_CS = "Assets/Scripts/ServerShared/Behaviors/Shield.cs" - - -@dataclass -class Mutation: - rule: str - file: str - anchor: str - mutated: str - test: str - expect: str # "red" (mutant must be killed) or "green" (the no-op control) - - -MUTATIONS: list[Mutation] = [ - # --- No-op control: proves the anchor/replace/restore mechanism is transparent --- - Mutation( - rule="control (no-op round trip)", - file=SHIELD_CS, - anchor=" public void TakeHit(DamageType type, float damage)", - mutated=" public void TakeHit(DamageType type, float damage)", - test="ShieldReserveTests.HitWithinReserveIsAbsorbedAndSpendsCharge", - expect="green", - ), - - # --- A hit within the reserve is absorbed and spends charge: TakeHit must actually draw the reserve down, - # --- not just gate on CanTakeHit and leave Charge untouched. --- - Mutation( - rule="a hit within the reserve must spend charge, not just be permitted", - file=SHIELD_CS, - anchor=" if (Item != null) _reserve.TrySpend(damage * EnergyUsage);", - mutated=" if (Item != null) { }", - test="ShieldReserveTests.HitWithinReserveIsAbsorbedAndSpendsCharge", - expect="red", - ), - - # --- A hit beyond the reserve breaks the shield: refusing the hit is not enough on its own -- Broken must - # --- actually flip, or the shield is just Cut 4's silent refusal again with new authored numbers. --- - Mutation( - rule="a hit beyond the reserve must set Broken, not just refuse", - file=SHIELD_CS, - anchor=( - " if (_reserve.CanSpend(damage * EnergyUsage)) return true;\n" - " Broken = true;" - ), - mutated=" if (_reserve.CanSpend(damage * EnergyUsage)) return true;", - test="ShieldReserveTests.HitBeyondReserveBreaksTheShield", - expect="red", - ), - - # --- Operator's second ruling: the breaking hit passes through in full, no partial absorption -- TakeHit - # --- (and its TrySpend/AddHeat) must never run for the hit that broke the shield. Routing the breaking hit - # --- through TakeHit instead of a direct break-and-empty absorbs/processes it, which the ruling forbids. - # --- Caught by the restore-timing assertion: TrySpend's own atomicity refuses (cost > charge) and leaves - # --- Charge at its pre-hit value instead of the emptied reserve. - Mutation( - rule="the breaking hit must not be absorbed via TakeHit -- it passes through untouched", - file=SHIELD_CS, - anchor=( - " Broken = true;\n" - " _reserve.AddCharge(-_reserve.Charge); // emptied by the break, not left at its pre-hit charge\n" - " return false;" - ), - mutated=( - " Broken = true;\n" - " TakeHit(type, damage);\n" - " return false;" - ), - test="ShieldReserveTests.BreakingHitIsNotPartiallyAbsorbed", - expect="red", - ), - - # --- A broken shield absorbs nothing -- gated on Broken itself, not on the reserve happening to read empty. - # --- Deleting the Broken short-circuit lets a partially-restored (but still Broken) reserve answer purely - # --- on its own charge arithmetic, absorbing a hit it should still be refusing outright. - Mutation( - rule="a broken shield must refuse every hit regardless of reserve arithmetic", - file=SHIELD_CS, - anchor=( - " if (Item == null) return true;\n" - " if (Broken) return false;" - ), - mutated=" if (Item == null) return true;", - test="ShieldReserveTests.BrokenShieldAbsorbsNothing", - expect="red", - ), - - # --- A break empties the reserve to zero, not left at its pre-hit charge -- the restore duration must mean - # --- the same thing every time. Removing the drain leaves whatever charge the reserve held at the moment - # --- it broke, letting a shield that broke nearly full restore far faster than one that broke empty. - Mutation( - rule="a break must empty the reserve, not leave its pre-hit charge intact", - file=SHIELD_CS, - anchor=" Broken = true;\n _reserve.AddCharge(-_reserve.Charge); // emptied by the break, not left at its pre-hit charge", - mutated=" Broken = true;", - test="ShieldReserveTests.BreakEmptiesTheReserveRegardlessOfChargeHeldAtTheMoment", - expect="red", - ), - - # --- A broken shield restores on RestoreDuration, not RefillDuration -- the two must stay genuinely - # --- separate speeds through the one InputCapacitor mechanism. Feeding RefillDuration in both branches - # --- collapses the punish window: a shield would come back exactly as fast broken as it refills while up. - Mutation( - rule="a broken shield must derive its rate from RestoreDuration, not RefillDuration", - file=SHIELD_CS, - anchor=" var duration = Broken ? Evaluate(_data.RestoreDuration) : Evaluate(_data.RefillDuration);", - mutated=" var duration = Evaluate(_data.RefillDuration);", - test="ShieldReserveTests.BrokenShieldRestoresOnRestoreDurationNotRefillDuration", - expect="red", - ), - - # --- A partial grant from the bus stretches both durations proportionally -- Shield must scale its banked - # --- charge by Item.PowerSupply like every other IPowerConsumer, not assume it always receives the whole - # --- request regardless of what the bus actually granted. - Mutation( - rule="the reserve must only bank the fraction PowerBus actually granted, not the whole request", - file=SHIELD_CS, - anchor=" _reserve.AddCharge(_reserve.RequestedFill(dt) * Item.PowerSupply);", - mutated=" _reserve.AddCharge(_reserve.RequestedFill(dt));", - test="ShieldReserveTests.PartialGrantStretchesTheRefillDurationProportionally", - expect="red", - ), -] - - -def replace_unique(path: Path, anchor: str, mutated: str) -> None: - original = path.read_text(encoding="utf-8") - count = original.count(anchor) - if count != 1: - raise SystemExit(f"{path}: anchor must occur exactly once, found {count}\n---\n{anchor}") - path.write_text(original.replace(anchor, mutated), encoding="utf-8") - - -def run_test(cultlib_root: str, test: str) -> tuple[int, str]: - proc = subprocess.run( - ["dotnet", "test", str(TEST_PROJECT), f"-p:CultLibRoot={cultlib_root}", - "--filter", f"FullyQualifiedName~{test}"], - capture_output=True, text=True, timeout=300, - ) - return proc.returncode, proc.stdout + proc.stderr - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--cultlib-root", required=True) - args = parser.parse_args() - - failures: list[str] = [] - originals: dict[str, str] = {} - for m in MUTATIONS: - if m.file not in originals: - originals[m.file] = (REPO_ROOT / m.file).read_text(encoding="utf-8") - - def restore_all() -> None: - for file, text in originals.items(): - (REPO_ROOT / file).write_text(text, encoding="utf-8") - - try: - for m in MUTATIONS: - print(f"\n=== {m.rule} (expect {m.expect}) ===") - path = REPO_ROOT / m.file - replace_unique(path, m.anchor, m.mutated) - code, output = run_test(args.cultlib_root, m.test) - passed = "Passed!" in output and code == 0 - if m.expect == "green": - if not passed: - failures.append(f"{m.rule}: expected green (test passing), got failure\n{output[-2000:]}") - else: - print(f"OK: {m.test} passed under a no-op mutation, as expected") - else: - if passed: - failures.append(f"{m.rule}: expected red (mutant killed), but {m.test} still passed") - else: - print(f"OK: {m.test} went red under the mutation, as expected") - # Restore this file immediately so the next mutation starts from a clean tree. - path.write_text(originals[m.file], encoding="utf-8") - finally: - restore_all() - - if failures: - print("\n=== FAILURES ===") - for f in failures: - print(f"- {f}") - return 1 - - print("\nAll mutations behaved as declared.") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/mutation_tests_stats_power_cut0.py b/tests/mutation_tests_stats_power_cut0.py deleted file mode 100644 index ecae3f1cd..000000000 --- a/tests/mutation_tests_stats_power_cut0.py +++ /dev/null @@ -1,151 +0,0 @@ -#!/usr/bin/env python3 -"""Mutation tests for docs/stats-and-power-cut.md Cut 0 (one evaluation path). - -Cut 0 collapses three disagreeing PerformanceStat.Evaluate bodies (ItemManager's unequipped read, -ConsumableItemEffect's, EquippedItem's) into one shared function, PerformanceStat.Evaluate(IStatContext), -fed by three IStatContext implementations. This script mutates each context's *identity* factor -- the -constant it returns instead of computing a term it structurally cannot have -- and asserts the named -test goes red for exactly that reason. A no-op control mutation proves the read/replace/restore path is -transparent. - -Usage: - python tests/mutation_tests_stats_power_cut0.py --cultlib-root - -Only touches files under this repository; every mutation is reversed before the script exits, including -on failure. -""" -from __future__ import annotations - -import argparse -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -TEST_PROJECT = REPO_ROOT / "tests" / "Aetheria.Shared.Tests" - - -@dataclass -class Mutation: - rule: str - file: str - anchor: str - mutated: str - test: str - expect: str # "red" (mutant must be killed) or "green" (the no-op control) - - -MUTATIONS: list[Mutation] = [ - # --- No-op control: proves the anchor/replace/restore mechanism is transparent --- - Mutation( - rule="control (no-op round trip)", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor=" public float HeatFactor(PerformanceStat stat) => 1f;", - mutated=" public float HeatFactor(PerformanceStat stat) => 1f;", - test="LoadoutTests.UnequippedIgnoresHeatRegardlessOfTemperature", - expect="green", - ), - # --- Unequipped has no heat: UnequippedStatContext.HeatFactor is the fixed identity, not a real read --- - Mutation( - rule="unequipped must ignore heat (HeatFactor is the identity 1, not a computed value)", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor=" public float HeatFactor(PerformanceStat stat) => 1f;", - mutated=" public float HeatFactor(PerformanceStat stat) => .5f;", - test="LoadoutTests.UnequippedIgnoresHeatRegardlessOfTemperature", - expect="red", - ), - # --- Unequipped has no modifiers: ConstantModifier is the fixed identity 0 --- - Mutation( - rule="unequipped must ignore modifiers (ConstantModifier is the fixed identity 0)", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor=" public float ConstantModifier(PerformanceStat stat) => 0f;", - mutated=" public float ConstantModifier(PerformanceStat stat) => 0.1f;", - test="LoadoutTests.UnequippedAgreesWithEquippedAtFullHealthAndOptimalTemperature", - expect="red", - ), - # --- Consumable substitutes progress for heat unconditionally, not exponentiated by the stat's field --- - Mutation( - rule="consumable heat must not be exponentiated by HeatExponentMultiplier (that is the equipped shape)", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=( - " public float HeatFactor(PerformanceStat stat) =>\n" - " Data.Effectiveness.Evaluate((Data.Duration - RemainingDuration) / Data.Duration);" - ), - mutated=( - " public float HeatFactor(PerformanceStat stat) =>\n" - " pow(Data.Effectiveness.Evaluate((Data.Duration - RemainingDuration) / Data.Duration), stat.HeatExponentMultiplier);" - ), - test="LoadoutTests.ConsumableEvaluateSubstitutesProgressForHeatUnconditionally", - expect="red", - ), -] - - -def replace_unique(path: Path, anchor: str, mutated: str) -> None: - original = path.read_text(encoding="utf-8") - count = original.count(anchor) - if count != 1: - raise SystemExit(f"{path}: anchor must occur exactly once, found {count}\n---\n{anchor}") - path.write_text(original.replace(anchor, mutated), encoding="utf-8") - - -def run_test(cultlib_root: str, test: str) -> tuple[int, str]: - proc = subprocess.run( - ["dotnet", "test", str(TEST_PROJECT), f"-p:CultLibRoot={cultlib_root}", - "--filter", f"FullyQualifiedName~{test}"], - capture_output=True, text=True, timeout=300, - ) - return proc.returncode, proc.stdout + proc.stderr - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--cultlib-root", required=True) - args = parser.parse_args() - - failures: list[str] = [] - originals: dict[str, str] = {} - for m in MUTATIONS: - path = REPO_ROOT / m.file - if m.file not in originals: - originals[m.file] = path.read_text(encoding="utf-8") - - def restore_all() -> None: - for file, text in originals.items(): - (REPO_ROOT / file).write_text(text, encoding="utf-8") - - try: - for m in MUTATIONS: - print(f"\n=== {m.rule} (expect {m.expect}) ===") - path = REPO_ROOT / m.file - replace_unique(path, m.anchor, m.mutated) - code, output = run_test(args.cultlib_root, m.test) - passed = "Passed!" in output and code == 0 - if m.expect == "green": - if not passed: - failures.append(f"{m.rule}: expected green (test passing), got failure\n{output[-2000:]}") - else: - print(f"OK: {m.test} passed under a no-op mutation, as expected") - else: - if passed: - failures.append(f"{m.rule}: expected red (mutant killed), but {m.test} still passed") - else: - print(f"OK: {m.test} went red under the mutation, as expected") - # Restore this file immediately so the next mutation starts from a clean tree. - path.write_text(originals[m.file], encoding="utf-8") - finally: - restore_all() - - if failures: - print("\n=== FAILURES ===") - for f in failures: - print(f) - return 1 - - print("\nAll mutation cases behaved as Cut 0's verification section demands.") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/mutation_tests_stats_power_cut1.py b/tests/mutation_tests_stats_power_cut1.py deleted file mode 100644 index bc12f37df..000000000 --- a/tests/mutation_tests_stats_power_cut1.py +++ /dev/null @@ -1,295 +0,0 @@ -#!/usr/bin/env python3 -"""Mutation tests for docs/stats-and-power-cut.md Cut 1 (terms replace the four fixed exponents; R-heat). - -Cut 1 replaces PerformanceStat's four fixed exponent fields with a declared list of StatTerm (source + -exponent + role), and replaces EquippableItemData.HeatPerformanceCurve with an authored minimum, maximum, -optimum and plateau width. This script mutates each rule's own line of code and asserts the named test goes -red for exactly that reason. A no-op control mutation proves the read/replace/restore path is transparent. - -Usage: - python tests/mutation_tests_stats_power_cut1.py --cultlib-root - -Only touches files under this repository; every mutation is reversed before the script exits, including on -failure. -""" -from __future__ import annotations - -import argparse -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -TEST_PROJECT = REPO_ROOT / "tests" / "Aetheria.Shared.Tests" - - -@dataclass -class Mutation: - rule: str - file: str - anchor: str - mutated: str - test: str - expect: str # "red" (mutant must be killed) or "green" (the no-op control) - - -MUTATIONS: list[Mutation] = [ - # --- No-op control: proves the anchor/replace/restore mechanism is transparent --- - Mutation( - rule="control (no-op round trip)", - file="Assets/Scripts/ServerShared/ItemData.cs", - anchor=" StatSource.Quality => pow(context.Lot.QualityForRole(term.Role), term.Exponent),", - mutated=" StatSource.Quality => pow(context.Lot.QualityForRole(term.Role), term.Exponent),", - test="LoadoutTests.StatsReadTheLot", - expect="green", - ), - # --- A term's Role must actually route the quality read; Quality is not one universal reader --- - Mutation( - rule="a Quality term must read its own Role, not always the item's workmanship (unrolled)", - file="Assets/Scripts/ServerShared/ItemData.cs", - anchor=" StatSource.Quality => pow(context.Lot.QualityForRole(term.Role), term.Exponent),", - mutated=" StatSource.Quality => pow(context.Lot.QualityForRole(null), term.Exponent),", - test="LoadoutTests.StatsReadTheLot", - expect="red", - ), - # --- A Durability term must resolve through DurabilityFactor, not some other source's factor --- - Mutation( - rule="a Durability term must call DurabilityFactor, not HeatFactor", - file="Assets/Scripts/ServerShared/ItemData.cs", - anchor=" StatSource.Durability => context.DurabilityFactor(term.Exponent),", - mutated=" StatSource.Durability => context.HeatFactor(term.Exponent),", - test="LoadoutTests.EvaluateDurabilityExponentReadsLotQuality", - expect="red", - ), - # --- A stat with no declared terms is the identity (factor 1, i.e. Max) -- the zero-multiplier equivalence - # --- the migration depends on (docs/stats-and-power-cut.md Cut 1's "a zero multiplier becomes no term") --- - Mutation( - rule="an undeclared term set must resolve to the identity factor (1), not 0", - file="Assets/Scripts/ServerShared/ItemData.cs", - anchor=" var factor = 1f;", - mutated=" var factor = 0f;", - test="LoadoutTests.ConsumableProgressAppliesOnlyWhenDeclared", - expect="red", - ), - # --- ConsumableProgress must apply the term's own exponent, not a fixed pass-through --- - Mutation( - rule="a ConsumableProgress term must apply its own exponent", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=( - " public float ConsumableProgressFactor(float exponent) =>\n" - " pow(Data.Effectiveness.Evaluate((Data.Duration - RemainingDuration) / Data.Duration), exponent);" - ), - mutated=( - " public float ConsumableProgressFactor(float exponent) =>\n" - " Data.Effectiveness.Evaluate((Data.Duration - RemainingDuration) / Data.Duration);" - ), - test="LoadoutTests.ConsumableProgressAppliesOnlyWhenDeclared", - expect="red", - ), - # --- R-heat validation: an optimum outside its own bounds must be refused at load, naming the item --- - Mutation( - rule="StatValidation must refuse an OptimalTemperature outside its bounds", - file="Assets/Scripts/ServerShared/ItemData.cs", - anchor=( - " if (data.OptimalTemperature < data.MinimumTemperature || data.OptimalTemperature > data.MaximumTemperature)" - ), - mutated=" if (false)", - test="HeatResponseTests.LoadRefusesAnOptimumOutsideItsBounds", - expect="red", - ), - # --- R-heat validation: a negative plateau width must be refused at load, naming the item --- - Mutation( - rule="StatValidation must refuse a negative PlateauWidth", - file="Assets/Scripts/ServerShared/ItemData.cs", - anchor=" if (data.PlateauWidth < 0)", - mutated=" if (false)", - test="HeatResponseTests.LoadRefusesANegativePlateauWidth", - expect="red", - ), - # --- R-heat shape: performance must be full across the whole authored plateau, not only at the optimum --- - Mutation( - rule="Performance() must be 1 across the whole plateau, not only exactly at the optimum", - file="Assets/Scripts/ServerShared/ItemData.cs", - anchor=" var halfPlateau = PlateauWidth * 0.5f;", - mutated=" var halfPlateau = 0f;", - test="HeatResponseTests.PerformanceIsFullAcrossThePlateau", - expect="red", - ), - # --- The wear lever: inside the plateau, Wear's thermal term must be zero (Performance == 1 there) --- - Mutation( - rule="held inside the plateau must take no thermal wear (the operational-lifespan lever)", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=( - " Wear = (1 - pow(ThermalPerformance,\n" - " (1 - pow(Lot.Quality, ItemManager.GameplaySettings.QualityWearExponent)) *\n" - " ItemManager.GameplaySettings.ThermalWearExponent) +\n" - " deltaTemp * ItemManager.GameplaySettings.DeltaTempWearExponent \n" - " ) * Data.Durability / Data.ThermalResilience;" - ), - mutated=( - " Wear = (0.05f - pow(ThermalPerformance,\n" - " (1 - pow(Lot.Quality, ItemManager.GameplaySettings.QualityWearExponent)) *\n" - " ItemManager.GameplaySettings.ThermalWearExponent) +\n" - " deltaTemp * ItemManager.GameplaySettings.DeltaTempWearExponent \n" - " ) * Data.Durability / Data.ThermalResilience;" - ), - test="HeatResponseTests.HeldInsidePlateauTakesNoThermalWear", - expect="red", - ), - # --- The wear lever's other half: a fast swing across the plateau must still wear, from deltaTemp alone --- - Mutation( - rule="deltaTemp must still cost wear even when the swing lands back inside the plateau", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" var deltaTemp = math.abs(temp - oldTemperature);", - mutated=" var deltaTemp = 0f;", - test="HeatResponseTests.AFastSwingAcrossThePlateauStillWears", - expect="red", - ), - - # --- Soul's Cut 1 findings (docs/stats-and-power-cut.md), S7: four mutations that survived the original - # --- suite. Each gets a dedicated test here rather than reusing a coincidentally-sensitive one. --- - - # S7: out-of-bounds semantics. The old curve returned 1 at any temperature it had no data for; R-heat's whole - # point is that a design is dead at and beyond its bounds. Restoring the old "1" must fail loudly, at the - # boundary itself and strictly beyond it. - Mutation( - rule="a design must be dead (0), not immune (1), at and beyond its bounds", - file="Assets/Scripts/ServerShared/ItemData.cs", - anchor=" if (temperature <= MinimumTemperature || temperature >= MaximumTemperature) return 0f;", - mutated=" if (temperature <= MinimumTemperature || temperature >= MaximumTemperature) return 1f;", - test="HeatResponseTests.PerformanceIsZeroAtAndBeyondEachBound", - expect="red", - ), - # S7: the low-side plateau clamp -- StatValidation must refuse a plateau whose low edge pokes past - # MinimumTemperature, not just an optimum outside the bounds outright. - Mutation( - rule="StatValidation must refuse a plateau whose low edge pokes past MinimumTemperature", - file="Assets/Scripts/ServerShared/ItemData.cs", - anchor=" if (plateauLow < data.MinimumTemperature || plateauHigh > data.MaximumTemperature)", - mutated=" if (false || plateauHigh > data.MaximumTemperature)", - test="HeatResponseTests.UpsertRefusesAPlateauThatPokesPastItsBounds", - expect="red", - ), - # S7: the high-side plateau clamp, the other half of the same rule. - Mutation( - rule="StatValidation must refuse a plateau whose high edge pokes past MaximumTemperature", - file="Assets/Scripts/ServerShared/ItemData.cs", - anchor=" if (plateauLow < data.MinimumTemperature || plateauHigh > data.MaximumTemperature)", - mutated=" if (plateauLow < data.MinimumTemperature || false)", - test="HeatResponseTests.UpsertRefusesAPlateauThatPokesPastItsBounds", - expect="red", - ), - # S7: a Quality term must raise the lot's quality to its own declared Exponent. StatsReadTheLot uses - # Exponent = 1 throughout (pow(x, 1) == x), which cannot distinguish "read the term's exponent" from "always - # use 1" -- that is exactly how this mutation survived the original suite. - Mutation( - rule="a Quality term must apply its own exponent, not always 1", - file="Assets/Scripts/ServerShared/ItemData.cs", - anchor=" StatSource.Quality => pow(context.Lot.QualityForRole(term.Role), term.Exponent),", - mutated=" StatSource.Quality => pow(context.Lot.QualityForRole(term.Role), 1f),", - test="LoadoutTests.QualityTermAppliesItsOwnExponent", - expect="red", - ), - - # --- S5/S6: the validation gaps Soul found closed. Zero-span and NaN were silently accepted before. --- - Mutation( - rule="StatValidation must refuse a zero-span range (Tractor Beam's exact bug: Min == Max)", - file="Assets/Scripts/ServerShared/ItemData.cs", - anchor=" if (data.MinimumTemperature == data.MaximumTemperature)", - mutated=" if (false)", - test="HeatResponseTests.UpsertRefusesAZeroSpanRange", - expect="red", - ), - Mutation( - rule="StatValidation must refuse NaN in any of the four heat-response fields", - file="Assets/Scripts/ServerShared/ItemData.cs", - anchor=( - " if (float.IsNaN(data.MinimumTemperature) || float.IsNaN(data.MaximumTemperature) ||\n" - " float.IsNaN(data.OptimalTemperature) || float.IsNaN(data.PlateauWidth))" - ), - mutated=" if (false)", - test="HeatResponseTests.UpsertRefusesNaNInAnyHeatResponseField", - expect="red", - ), - # S6: the writable path used to trust Open()'s one-time check; a session that wrote an invalid record and - # committed it produced a catalog that only failed the *next* time somebody reopened it. Mutating away the - # Upsert-time validation call must fail a test that checks the record never reaches disk in the first place. - Mutation( - rule="CultRecordRefs.Upsert must validate an EquippableItemData's heat response before it reaches disk", - file="Assets/Scripts/ServerShared/AetheriaStores.cs", - anchor=" if (document is EquippableItemData data) StatValidation.ValidateHeatResponse(data);", - mutated=" if (false) { }", - test="HeatResponseTests.UpsertRefusesAnOptimumOutsideItsBounds", - expect="red", - ), -] - - -def replace_unique(path: Path, anchor: str, mutated: str) -> None: - original = path.read_text(encoding="utf-8") - count = original.count(anchor) - if count != 1: - raise SystemExit(f"{path}: anchor must occur exactly once, found {count}\n---\n{anchor}") - path.write_text(original.replace(anchor, mutated), encoding="utf-8") - - -def run_test(cultlib_root: str, test: str) -> tuple[int, str]: - proc = subprocess.run( - ["dotnet", "test", str(TEST_PROJECT), f"-p:CultLibRoot={cultlib_root}", - "--filter", f"FullyQualifiedName~{test}"], - capture_output=True, text=True, timeout=300, - ) - return proc.returncode, proc.stdout + proc.stderr - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--cultlib-root", required=True) - args = parser.parse_args() - - failures: list[str] = [] - originals: dict[str, str] = {} - for m in MUTATIONS: - path = REPO_ROOT / m.file - if m.file not in originals: - originals[m.file] = path.read_text(encoding="utf-8") - - def restore_all() -> None: - for file, text in originals.items(): - (REPO_ROOT / file).write_text(text, encoding="utf-8") - - try: - for m in MUTATIONS: - print(f"\n=== {m.rule} (expect {m.expect}) ===") - path = REPO_ROOT / m.file - replace_unique(path, m.anchor, m.mutated) - code, output = run_test(args.cultlib_root, m.test) - passed = "Passed!" in output and code == 0 - if m.expect == "green": - if not passed: - failures.append(f"{m.rule}: expected green (test passing), got failure\n{output[-2000:]}") - else: - print(f"OK: {m.test} passed under a no-op mutation, as expected") - else: - if passed: - failures.append(f"{m.rule}: expected red (mutant killed), but {m.test} still passed") - else: - print(f"OK: {m.test} went red under the mutation, as expected") - # Restore this file immediately so the next mutation starts from a clean tree. - path.write_text(originals[m.file], encoding="utf-8") - finally: - restore_all() - - if failures: - print("\n=== FAILURES ===") - for f in failures: - print(f) - return 1 - - print("\nAll mutation cases behaved as Cut 1's verification section demands.") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/mutation_tests_stats_power_cut2.py b/tests/mutation_tests_stats_power_cut2.py deleted file mode 100644 index 867e6a97f..000000000 --- a/tests/mutation_tests_stats_power_cut2.py +++ /dev/null @@ -1,270 +0,0 @@ -#!/usr/bin/env python3 -"""Mutation tests for docs/stats-and-power-cut.md Cut 2 (the resolver owns every value). - -Cut 2 deletes PerformanceStat's two per-entity modifier dictionaries (the leak, §0.3) and moves every resolved -(item, stat) value and every attached modifier into a new StatResolver owned by the Entity, one per entity. This -script mutates each rule's own line of code and asserts the named test goes red for exactly that reason. A no-op -control mutation proves the read/replace/restore path is transparent. - -Usage: - python tests/mutation_tests_stats_power_cut2.py --cultlib-root - -Only touches files under this repository; every mutation is reversed before the script exits, including on -failure. -""" -from __future__ import annotations - -import argparse -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -TEST_PROJECT = REPO_ROOT / "tests" / "Aetheria.Shared.Tests" - - -@dataclass -class Mutation: - rule: str - file: str - anchor: str - mutated: str - test: str - expect: str # "red" (mutant must be killed) or "green" (the no-op control) - - -MUTATIONS: list[Mutation] = [ - # --- No-op control: proves the anchor/replace/restore mechanism is transparent --- - Mutation( - rule="control (no-op round trip)", - file="Assets/Scripts/ServerShared/StatResolver.cs", - anchor=" var value = stat.Evaluate(context);", - mutated=" var value = stat.Evaluate(context);", - test="StatResolverTests.ResolveRecomputesOnlyAfterItsDeclaredSourceIsInvalidated", - expect="green", - ), - - # --- The leak (§0.3): a resolver reachable from a static field is reachable from the catalog's assembly for - # --- the life of the process, exactly like the two dictionaries it replaced. Both the leak test and the - # --- one-resolver-per-entity structural test die under this same mutation. --- - Mutation( - rule="Entity.Resolver must be an instance field: one resolver per entity, not a shared/static one", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" public readonly StatResolver Resolver = new StatResolver();", - mutated=" public static readonly StatResolver Resolver = new StatResolver();", - test="StatResolverTests.EachEntityOwnsItsOwnResolver", - expect="red", - ), - Mutation( - rule="a static resolver keeps every entity that ever evaluated a stat reachable for the life of the process", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" public readonly StatResolver Resolver = new StatResolver();", - mutated=" public static readonly StatResolver Resolver = new StatResolver();", - test="StatResolverTests.UnequippingAndDroppingAnEntityLeavesItCollectible", - expect="red", - ), - - # --- A resolved value is per (item, stat): a modifier must attach to the resolver entry it actually targets - # --- (the target item's own owner key), not to the modifying behaviour's own item. This is the real defect - # --- Hands found and fixed while wiring StatModifier to the resolver: attaching under the wrong owner leaves - # --- the target item's resolved value untouched. --- - Mutation( - rule="a modifier must attach under the target item's own owner, not the modifying behaviour's owner", - file="Assets/Scripts/ServerShared/Behaviors/StatModifier.cs", - anchor=" foreach (var target in _targets)\n Entity.Resolver.AttachModifier(target.Owner, target.Stat, this, _data.Type, value);", - # F8 (Soul pass over Cut 2): this mutant used to reach for a dedicated `StatOwner` property on Behavior - # that had no production caller -- added only to make this one mutation expressible. Deleted; `Entity` - # is already a real accessible symbol on Behavior and is just as wrong an owner key for this mutant - # (neither the modifying item's own owner nor the target's), so it needs no dead code to stand in for it. - mutated=" foreach (var target in _targets)\n Entity.Resolver.AttachModifier(Entity, target.Stat, this, _data.Type, value);", - test="StatResolverTests.AModifierOnOneEntityDoesNotReachAnotherEntitysResolvedValue", - expect="red", - ), - - # --- Self-dependency / modifier-chain cycles (Cut 2's Adds): a modifier whose magnitude stat is one of its - # --- own targets must be refused at equip, not silently accepted to resolve one tick behind itself. --- - Mutation( - rule="a modifier chain that reaches its own magnitude stat must be refused at equip", - file="Assets/Scripts/ServerShared/Behaviors/StatModifier.cs", - anchor=" if (ReferenceEquals(target, data.Modifier)) return true;", - mutated=" if (false) return true;", - test="StatResolverTests.AModifierThatTargetsItsOwnMagnitudeStatIsRefusedAtEquip", - expect="red", - ), - - # --- Recompute-on-change: a cached value must be reused while every source it declared is unchanged, and - # --- must be recomputed once any of them is invalidated. Each half gets its own mutation. --- - Mutation( - rule="a resolved value must stay cached until its own declared source is invalidated (never on every read)", - file="Assets/Scripts/ServerShared/StatResolver.cs", - anchor=" if (_cache.TryGetValue(entry, out var cached) && IsCurrent(owner, cached))", - mutated=" if (false)", - test="StatResolverTests.ResolveDoesNotRecomputeWhenNoDeclaredSourceMoved", - expect="red", - ), - Mutation( - rule="a resolved value must actually recompute once its declared source is invalidated (never stay cached forever)", - file="Assets/Scripts/ServerShared/StatResolver.cs", - anchor=" if (_cache.TryGetValue(entry, out var cached) && IsCurrent(owner, cached))", - mutated=" if (_cache.TryGetValue(entry, out var cached))", - test="StatResolverTests.ResolveRecomputesOnlyAfterItsDeclaredSourceIsInvalidated", - expect="red", - ), - # --- Invalidation must be scoped to the source that actually moved -- otherwise every stat recomputes on - # --- every tick regardless of the Terms it declared, which defeats the whole point of caching by source. --- - Mutation( - rule="invalidating one source must not bump every source's generation", - file="Assets/Scripts/ServerShared/StatResolver.cs", - anchor=" perSource[source] = GenerationOf(owner, source) + 1;", - mutated=" foreach (StatSource s in System.Enum.GetValues(typeof(StatSource))) perSource[s] = GenerationOf(owner, s) + 1;", - test="StatResolverTests.InvalidatingAnUnrelatedSourceDoesNotRecomputeAStatThatDidNotDeclareIt", - expect="red", - ), - # --- Attaching/detaching a modifier must invalidate immediately: a modifier is not one of the stat's - # --- declared Terms, so the source-generation scheme never sees it move on its own. --- - Mutation( - rule="attaching a modifier must invalidate its (owner, stat) entry immediately", - file="Assets/Scripts/ServerShared/StatResolver.cs", - anchor=( - " (type == StatModifierType.Constant ? set.Constant : set.Scale)[modifierKey] = value;\n" - " _cache.Remove(entry);" - ), - mutated=" (type == StatModifierType.Constant ? set.Constant : set.Scale)[modifierKey] = value;", - test="StatResolverTests.AttachingAModifierInvalidatesImmediately", - expect="red", - ), - - # --- Gate 1 (Soul pass over Cut 2), F1: nothing ever removed an owner from _cache/_modifiers/_generations, - # --- so the resolver's retained set grew without bound across unequip and consumable-expiry cycles even - # --- though §0b promises a resolver entry is "destroyed at unequip / entity teardown." Forget(owner) closes - # --- it; these two mutations delete each of its two call sites in turn. --- - Mutation( - rule="unequipping an item must forget it in the resolver (Entity.TryUnequip)", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=( - " // Cut 2 Gate 1 fix (docs/stats-and-power-cut.md): the resolver's \xa70b lifecycle promise -- a resolver\n" - " // entry is \"destroyed at unequip\" -- was never wired up. Without this, every unequipped item stayed\n" - " // reachable from the resolver's dictionaries for the entity's whole remaining life.\n" - " Resolver.Forget(item);" - ), - mutated=( - " // Cut 2 Gate 1 fix (docs/stats-and-power-cut.md): the resolver's \xa70b lifecycle promise -- a resolver\n" - " // entry is \"destroyed at unequip\" -- was never wired up. Without this, every unequipped item stayed\n" - " // reachable from the resolver's dictionaries for the entity's whole remaining life." - ), - test="StatResolverTests.UnequipReturnsTheResolversRetainedSetToBaseline", - expect="red", - ), - Mutation( - rule="an expired consumable must forget itself in the resolver (Entity.Update)", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=( - " // Cut 2 Gate 1 fix (docs/stats-and-power-cut.md): an expired consumable dropped out of this\n" - " // list without ever telling the resolver, leaving its generation/cache/modifier entries\n" - " // reachable (keyed by this ConsumableItemEffect instance) for the rest of the process.\n" - " Resolver.Forget(_activeConsumables[i]);\n" - " _activeConsumables.RemoveAt(i--);" - ), - mutated=( - " // Cut 2 Gate 1 fix (docs/stats-and-power-cut.md): an expired consumable dropped out of this\n" - " // list without ever telling the resolver, leaving its generation/cache/modifier entries\n" - " // reachable (keyed by this ConsumableItemEffect instance) for the rest of the process.\n" - " _activeConsumables.RemoveAt(i--);" - ), - test="StatResolverTests.ConsumableExpiryReturnsTheResolversRetainedSetToBaseline", - expect="red", - ), - - # --- Gate 2 (Soul pass over Cut 2), F2: _applied was never reset alongside the _targets recomputed on every - # --- Initialize, so a modifier that had already applied once stayed permanently latched after its target was - # --- unequipped, replaced and the entity reactivated -- Update saw `_executed && _applied` and neither branch - # --- of its own latch ever fired again. Deleting the detach-on-reinitialize line restores that split - # --- authority between Initialize and Update. --- - Mutation( - rule="Initialize must detach a stale attachment before new targets are computed, so Update's latch can reattach", - file="Assets/Scripts/ServerShared/Behaviors/StatModifier.cs", - anchor=( - " public void Initialize()\n" - " {\n" - " if (_applied)\n" - " RemoveModifier();\n" - " _targets = TargetsOf(Entity, _data);" - ), - mutated=( - " public void Initialize()\n" - " {\n" - " _targets = TargetsOf(Entity, _data);" - ), - test="StatResolverTests.ARefitTargetReattachesTheModifierAfterReactivation", - expect="red", - ), -] - - -def replace_unique(path: Path, anchor: str, mutated: str) -> None: - original = path.read_text(encoding="utf-8") - count = original.count(anchor) - if count != 1: - raise SystemExit(f"{path}: anchor must occur exactly once, found {count}\n---\n{anchor}") - path.write_text(original.replace(anchor, mutated), encoding="utf-8") - - -def run_test(cultlib_root: str, test: str) -> tuple[int, str]: - proc = subprocess.run( - ["dotnet", "test", str(TEST_PROJECT), f"-p:CultLibRoot={cultlib_root}", - "--filter", f"FullyQualifiedName~{test}"], - capture_output=True, text=True, timeout=300, - ) - return proc.returncode, proc.stdout + proc.stderr - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--cultlib-root", required=True) - args = parser.parse_args() - - failures: list[str] = [] - originals: dict[str, str] = {} - for m in MUTATIONS: - if m.file not in originals: - originals[m.file] = (REPO_ROOT / m.file).read_text(encoding="utf-8") - - def restore_all() -> None: - for file, text in originals.items(): - (REPO_ROOT / file).write_text(text, encoding="utf-8") - - try: - for m in MUTATIONS: - print(f"\n=== {m.rule} (expect {m.expect}) ===") - path = REPO_ROOT / m.file - replace_unique(path, m.anchor, m.mutated) - code, output = run_test(args.cultlib_root, m.test) - passed = "Passed!" in output and code == 0 - if m.expect == "green": - if not passed: - failures.append(f"{m.rule}: expected green (test passing), got failure\n{output[-2000:]}") - else: - print(f"OK: {m.test} passed under a no-op mutation, as expected") - else: - if passed: - failures.append(f"{m.rule}: expected red (mutant killed), but {m.test} still passed") - else: - print(f"OK: {m.test} went red under the mutation, as expected") - # Restore this file immediately so the next mutation starts from a clean tree. - path.write_text(originals[m.file], encoding="utf-8") - finally: - restore_all() - - if failures: - print("\n=== FAILURES ===") - for f in failures: - print(f) - return 1 - - print("\nAll mutation cases behaved as Cut 2's verification section demands.") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/mutation_tests_stats_power_cut3.py b/tests/mutation_tests_stats_power_cut3.py deleted file mode 100644 index b4184918c..000000000 --- a/tests/mutation_tests_stats_power_cut3.py +++ /dev/null @@ -1,175 +0,0 @@ -#!/usr/bin/env python3 -"""Mutation tests for docs/stats-and-power-cut.md Cut 3 (the power bus replaces TryConsumeEnergy). - -Cut 3 deletes Entity.CanConsumeEnergy/TryConsumeEnergy and Reactor's own capacitor handling, replacing them with -PowerBus: a single per-Entity step that turns every IPowerConsumer's request plus the tick's reactor generation -and stored capacitor charge into one shared grant ratio, refusing demand that supply cannot cover instead of -taxing the shortfall onto the reactor as heat. This script mutates each rule's own line of code and asserts the -named test in PowerBusTests.cs goes red for exactly that reason. A no-op control mutation proves the -read/replace/restore path is transparent. - -Usage: - python tests/mutation_tests_stats_power_cut3.py --cultlib-root - -Only touches files under this repository; every mutation is reversed before the script exits, including on -failure. -""" -from __future__ import annotations - -import argparse -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -TEST_PROJECT = REPO_ROOT / "tests" / "Aetheria.Shared.Tests" - - -@dataclass -class Mutation: - rule: str - file: str - anchor: str - mutated: str - test: str - expect: str # "red" (mutant must be killed) or "green" (the no-op control) - - -MUTATIONS: list[Mutation] = [ - # --- No-op control: proves the anchor/replace/restore mechanism is transparent --- - Mutation( - rule="control (no-op round trip)", - file="Assets/Scripts/ServerShared/PowerBus.cs", - anchor=" TotalGeneration = reactors.Sum(r => r.Generation(dt));", - mutated=" TotalGeneration = reactors.Sum(r => r.Generation(dt));", - test="PowerBusTests.GrantIsCappedAtGenerationPlusStoredCapacitorCharge", - expect="green", - ), - - # --- Cut 3 verification bullet 1 (§0.5): "a ship whose requests exceed generation gets a total grant equal - # --- to generation plus available capacitor charge, and not more." Dropping the capacitor draw on a deficit - # --- caps the grant at generation alone -- still capped, but at the wrong ceiling, which is exactly the kind - # --- of silent under-delivery a ceiling test has to catch as sharply as the old unlimited-reactor bug. --- - Mutation( - rule="a deficit must draw stored capacitor charge, not just generation", - file="Assets/Scripts/ServerShared/PowerBus.cs", - anchor=" var chargeDrawn = min(preCapacitorNet, availableCharge);", - mutated=" var chargeDrawn = 0f;", - test="PowerBusTests.GrantIsCappedAtGenerationPlusStoredCapacitorCharge", - expect="red", - ), - - # --- Cut 3 verification bullet 2 (§0.5): "a refused request produces no effect at all -- not a partial one, - # --- and not an overload-heat receipt." This is the map's own named mutation: "make the bus grant the full - # --- request and heat the reactor; it goes red" -- forcing overload to 0 finances the whole deficit for - # --- free, the exact HEAD bug ("every draw succeeds while one reactor is online") Cut 3 exists to remove. --- - Mutation( - rule="unmet demand after generation and stored charge must be refused (overload), never financed for free", - file="Assets/Scripts/ServerShared/PowerBus.cs", - anchor=" overload = preCapacitorNet - chargeDrawn;", - mutated=" overload = 0f;", - test="PowerBusTests.ZeroSupplyRefusesTheWholeRequest", - expect="red", - ), - - # --- Cut 3 verification bullet 3 (§0.5): "two ships with identical loadouts fitted in opposite equip order - # --- get identical grants. That is the death of the hidden priority." The invariant that makes equip order - # --- unable to matter is that every consuming item reads back its own tier's GrantRatio; restricting the - # --- write to only the first draw found reintroduces exactly the "whoever equipped first" priority the cut - # --- kills, just relocated from SortPosition into this loop. - # --- - # --- F8 (docs/stats-and-power-cut.md, Soul pass 2026-09-19) re-anchor: Cut 3's own flat "write GrantRatio to - # --- every consumer" loop (Step) was replaced by Cut 5's tiered AllocateTiers, whose closing loop writes each - # --- draw's own tier ratio instead -- the anchor below follows that move; the rule and the test it's pinned - # --- against (EveryConsumingItemReadsTheSameGrantRatio, a single-tier fixture) are unchanged. --- - Mutation( - rule="every consuming item must be written its own tier's GrantRatio, not just the first draw found", - file="Assets/Scripts/ServerShared/PowerBus.cs", - anchor=( - " foreach (var draw in draws)\n" - " {\n" - " draw.Item.PowerSupply = ratios[draw.Tier];" - ), - mutated=( - " foreach (var draw in draws.Take(1))\n" - " {\n" - " draw.Item.PowerSupply = ratios[draw.Tier];" - ), - test="PowerBusTests.EveryConsumingItemReadsTheSameGrantRatio", - expect="red", - ), - - # Entity.TrySpendCapacitorCharge/CanSpendCapacitorCharge -- the four instant draws' named, temporary - # exception this file used to pin -- died with their last caller in Cut 4 - # (docs/stats-and-power-cut.md, Cut 4). See mutation_tests_stats_power_cut4.py's InputCapacitor.TrySpend - # case for the same atomicity rule, now scoped to each behaviour's own buffer. -] - - -def replace_unique(path: Path, anchor: str, mutated: str) -> None: - original = path.read_text(encoding="utf-8") - count = original.count(anchor) - if count != 1: - raise SystemExit(f"{path}: anchor must occur exactly once, found {count}\n---\n{anchor}") - path.write_text(original.replace(anchor, mutated), encoding="utf-8") - - -def run_test(cultlib_root: str, test: str) -> tuple[int, str]: - proc = subprocess.run( - ["dotnet", "test", str(TEST_PROJECT), f"-p:CultLibRoot={cultlib_root}", - "--filter", f"FullyQualifiedName~{test}"], - capture_output=True, text=True, timeout=300, - ) - return proc.returncode, proc.stdout + proc.stderr - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--cultlib-root", required=True) - args = parser.parse_args() - - failures: list[str] = [] - originals: dict[str, str] = {} - for m in MUTATIONS: - if m.file not in originals: - originals[m.file] = (REPO_ROOT / m.file).read_text(encoding="utf-8") - - def restore_all() -> None: - for file, text in originals.items(): - (REPO_ROOT / file).write_text(text, encoding="utf-8") - - try: - for m in MUTATIONS: - print(f"\n=== {m.rule} (expect {m.expect}) ===") - path = REPO_ROOT / m.file - replace_unique(path, m.anchor, m.mutated) - code, output = run_test(args.cultlib_root, m.test) - passed = "Passed!" in output and code == 0 - if m.expect == "green": - if not passed: - failures.append(f"{m.rule}: expected green (test passing), got failure\n{output[-2000:]}") - else: - print(f"OK: {m.test} passed under a no-op mutation, as expected") - else: - if passed: - failures.append(f"{m.rule}: expected red (mutant killed), but {m.test} still passed") - else: - print(f"OK: {m.test} went red under the mutation, as expected") - # Restore this file immediately so the next mutation starts from a clean tree. - path.write_text(originals[m.file], encoding="utf-8") - finally: - restore_all() - - if failures: - print("\n=== FAILURES ===") - for f in failures: - print(f"- {f}") - return 1 - - print("\nAll mutations behaved as declared.") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/mutation_tests_stats_power_cut4.py b/tests/mutation_tests_stats_power_cut4.py deleted file mode 100644 index e61f973f1..000000000 --- a/tests/mutation_tests_stats_power_cut4.py +++ /dev/null @@ -1,203 +0,0 @@ -#!/usr/bin/env python3 -"""Mutation tests for docs/stats-and-power-cut.md Cut 4 (all draw is continuous -- input capacitors). - -Cut 4 closes Cut 3's named, temporary exception for the four instant draws (a burst, a shot, a ping, a hit -taken): each now spends from its own InputCapacitor, fed continuously by PowerBus like every other consumer, -instead of the entity's shared bus capacitors directly through the now-deleted -Entity.TrySpendCapacitorCharge/CanSpendCapacitorCharge. This script mutates each rule's own line of code and -asserts the named test in InputCapacitorTests.cs goes red for exactly that reason. A no-op control mutation -proves the read/replace/restore path is transparent. - -Usage: - python tests/mutation_tests_stats_power_cut4.py --cultlib-root - -Only touches files under this repository; every mutation is reversed before the script exits, including on -failure. -""" -from __future__ import annotations - -import argparse -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -TEST_PROJECT = REPO_ROOT / "tests" / "Aetheria.Shared.Tests" - - -@dataclass -class Mutation: - rule: str - file: str - anchor: str - mutated: str - test: str - expect: str # "red" (mutant must be killed) or "green" (the no-op control) - - -MUTATIONS: list[Mutation] = [ - # --- No-op control: proves the anchor/replace/restore mechanism is transparent --- - Mutation( - rule="control (no-op round trip)", - file="Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs", - anchor=" public void AddCharge(float amount) => Charge = clamp(Charge + amount, 0f, Capacity);", - mutated=" public void AddCharge(float amount) => Charge = clamp(Charge + amount, 0f, Capacity);", - test="InputCapacitorTests.AddChargeNeverExceedsCapacity", - expect="green", - ), - - # --- AddCharge must clamp to Capacity: an input capacitor is sized to exactly one activation, so nothing - # --- (a rounding surplus, a Capacity that shrank after charge had already banked) may ever report more - # --- charge than one activation could use. --- - Mutation( - rule="AddCharge must clamp to Capacity", - file="Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs", - anchor=" public void AddCharge(float amount) => Charge = clamp(Charge + amount, 0f, Capacity);", - mutated=" public void AddCharge(float amount) => Charge = Charge + amount;", - test="InputCapacitorTests.AddChargeNeverExceedsCapacity", - expect="red", - ), - - # --- TrySpend must refuse before moving anything it cannot fully cover -- the exact atomicity contract - # --- Entity.TrySpendCapacitorCharge had, now scoped to one behaviour's own buffer. Deleting the upfront - # --- check restores the old TryConsumeEnergy-class bug: a cost this buffer cannot fully pay would still - # --- partially drain it. --- - Mutation( - rule="TrySpend must refuse before spending anything it cannot fully cover", - file="Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs", - anchor=( - " if (cost < .01f) return true;\n" - " if (!CanSpend(cost)) return false;" - ), - mutated=" if (cost < .01f) return true;", - test="InputCapacitorTests.TrySpendMovesNothingWhenTheCostCannotBeFullyCovered", - expect="red", - ), - - # --- Default rate derives from Capacity/cooldown (Q4 ruling: "derived from energy and cooldown"). Dropping - # --- the division collapses the derived rate to Capacity itself (refill in 1 second regardless of the - # --- weapon's own cooldown), which RateOverrideReplacesTheDerivedDefault pins directly. --- - Mutation( - rule="the default rate must derive from Capacity/cooldown, not just Capacity", - file="Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs", - anchor=" Rate = rateOverride > 0f ? rateOverride : (cooldown > 0f ? Capacity / cooldown : Capacity);", - mutated=" Rate = rateOverride > 0f ? rateOverride : Capacity;", - test="InputCapacitorTests.RateOverrideReplacesTheDerivedDefault", - expect="red", - ), - - # --- Cut 4's own verification bullet: "a weapon whose input capacitor is partly filled does not fire a - # --- partial shot." A fire attempt must spend exactly Capacity -- never a lesser, possibly-partial amount - # --- -- so it only ever succeeds at full charge. Spending 0 instead bypasses the whole-shot requirement: - # --- every attempt would "succeed" regardless of charge. --- - Mutation( - rule="a fire attempt must spend exactly Capacity, never a lesser amount", - file="Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs", - anchor=" protected bool TrySpendActivationEnergy() => Item == null || _capacitor.TrySpend(_capacitor.Capacity);", - mutated=" protected bool TrySpendActivationEnergy() => Item == null || _capacitor.TrySpend(0f);", - test="InputCapacitorTests.WeaponDoesNotFireOnAPartialCharge", - expect="red", - ), - - # --- The other half of the same rule, at the fill site instead of the spend site: the capacitor must only - # --- ever bank the fraction PowerBus actually granted (Item.PowerSupply), never the whole request - # --- regardless of supply -- otherwise a weapon on half power would still reach full charge (and fire) on - # --- the very first tick, exactly the "every draw succeeds" bug Cut 3 exists to remove, just relocated. --- - Mutation( - rule="the capacitor must only bank the fraction PowerBus actually granted, not the whole request", - file="Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs", - anchor=" _capacitor.AddCharge(_capacitor.RequestedFill(dt) * Item.PowerSupply);", - mutated=" _capacitor.AddCharge(_capacitor.RequestedFill(dt));", - test="InputCapacitorTests.WeaponDoesNotFireOnAPartialCharge", - expect="red", - ), - - # --- Shield is the map's named exception: a hit is not a chosen activation, so its reserve is a continuous - # --- one that may absorb an arbitrary fraction of a hit's cost, unlike a weapon's shot (always spent - # --- whole). Requiring a hit to cover the WHOLE reserve before absorbing anything collapses that - # --- distinction back into an activation buffer, which the map explicitly says not to give it. - # --- - # --- F8 (docs/stats-and-power-cut.md, Soul pass 2026-09-19) re-anchor: the shield reserve ruling - # --- (2026-09-19) gave Shield a Broken state, splitting CanTakeHit's old single-expression body - # --- (`return Item == null || _reserve.CanSpend(...)`) into an Item-null check, a Broken check, and this - # --- CanSpend call as its own line; F5 (the same Soul pass) then made the whole method a pure query. The - # --- rule under test -- CanSpend reads the hit's own cost, not a full-reserve requirement -- is unchanged - # --- and still reachable from this same line. --- - Mutation( - rule="the shield reserve must allow a partial draw, not require a full reserve before absorbing a hit", - file="Assets/Scripts/ServerShared/Behaviors/Shield.cs", - anchor=" return _reserve.CanSpend(damage * EnergyUsage);", - mutated=" return _reserve.CanSpend(_reserve.Capacity);", - test="InputCapacitorTests.ShieldReserveAllowsAPartialDrawButRefusesAHitItCannotFullyCover", - expect="red", - ), -] - - -def replace_unique(path: Path, anchor: str, mutated: str) -> None: - original = path.read_text(encoding="utf-8") - count = original.count(anchor) - if count != 1: - raise SystemExit(f"{path}: anchor must occur exactly once, found {count}\n---\n{anchor}") - path.write_text(original.replace(anchor, mutated), encoding="utf-8") - - -def run_test(cultlib_root: str, test: str) -> tuple[int, str]: - proc = subprocess.run( - ["dotnet", "test", str(TEST_PROJECT), f"-p:CultLibRoot={cultlib_root}", - "--filter", f"FullyQualifiedName~{test}"], - capture_output=True, text=True, timeout=300, - ) - return proc.returncode, proc.stdout + proc.stderr - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--cultlib-root", required=True) - args = parser.parse_args() - - failures: list[str] = [] - originals: dict[str, str] = {} - for m in MUTATIONS: - if m.file not in originals: - originals[m.file] = (REPO_ROOT / m.file).read_text(encoding="utf-8") - - def restore_all() -> None: - for file, text in originals.items(): - (REPO_ROOT / file).write_text(text, encoding="utf-8") - - try: - for m in MUTATIONS: - print(f"\n=== {m.rule} (expect {m.expect}) ===") - path = REPO_ROOT / m.file - replace_unique(path, m.anchor, m.mutated) - code, output = run_test(args.cultlib_root, m.test) - passed = "Passed!" in output and code == 0 - if m.expect == "green": - if not passed: - failures.append(f"{m.rule}: expected green (test passing), got failure\n{output[-2000:]}") - else: - print(f"OK: {m.test} passed under a no-op mutation, as expected") - else: - if passed: - failures.append(f"{m.rule}: expected red (mutant killed), but {m.test} still passed") - else: - print(f"OK: {m.test} went red under the mutation, as expected") - # Restore this file immediately so the next mutation starts from a clean tree. - path.write_text(originals[m.file], encoding="utf-8") - finally: - restore_all() - - if failures: - print("\n=== FAILURES ===") - for f in failures: - print(f"- {f}") - return 1 - - print("\nAll mutations behaved as declared.") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/mutation_tests_stats_power_cut5.py b/tests/mutation_tests_stats_power_cut5.py deleted file mode 100644 index c554e3e3b..000000000 --- a/tests/mutation_tests_stats_power_cut5.py +++ /dev/null @@ -1,218 +0,0 @@ -#!/usr/bin/env python3 -"""Mutation tests for docs/stats-and-power-cut.md Cut 5 (priority tiers). - -Cut 5 replaces PowerBus's single shared GrantRatio with priority tiers (PowerTiers.cs): critical is fed in full -before the next tier sees anything, each following tier divides what remains, and a tier is a stored player -choice (EquippableItem.PowerTier) rather than something the bus derives every tick. This script mutates each -rule's own line of code and asserts the named test in PowerTierTests.cs goes red for exactly that reason. A -no-op control mutation proves the read/replace/restore path is transparent. - -Usage: - python tests/mutation_tests_stats_power_cut5.py --cultlib-root - -Only touches files under this repository; every mutation is reversed before the script exits, including on -failure. -""" -from __future__ import annotations - -import argparse -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -TEST_PROJECT = REPO_ROOT / "tests" / "Aetheria.Shared.Tests" - - -@dataclass -class Mutation: - rule: str - file: str - anchor: str - mutated: str - test: str - expect: str # "red" (mutant must be killed) or "green" (the no-op control) - - -MUTATIONS: list[Mutation] = [ - # --- No-op control: proves the anchor/replace/restore mechanism is transparent --- - Mutation( - rule="control (no-op round trip)", - file="Assets/Scripts/ServerShared/PowerBus.cs", - anchor=" if (demand <= 1e-4f) { ratios[tier] = 1f; continue; }", - mutated=" if (demand <= 1e-4f) { ratios[tier] = 1f; continue; }", - test="PowerTierTests.CriticalTierIsFedInFullWhileLowerTierStarves", - expect="green", - ), - - # --- Cut 5's headline rule: critical must be satisfied in full before the next tier receives anything. - # --- Walking tiers in descending order instead of ascending feeds Utility first, so the fixed generation - # --- that exactly covers Critical's own demand gets spent on Utility instead, starving Critical -- the - # --- opposite of the ruling ("critical is fed first"). --- - Mutation( - rule="tiers must be walked lowest-number (highest priority) first", - file="Assets/Scripts/ServerShared/PowerBus.cs", - anchor=" for (var tier = 0; tier < PowerTiers.Count; tier++)", - mutated=" for (var tier = PowerTiers.Count - 1; tier >= 0; tier--)", - test="PowerTierTests.CriticalTierIsFedInFullWhileLowerTierStarves", - expect="red", - ), - - # --- A satisfied tier must commit what it took, so a lower tier can never see it again. Skipping the - # --- subtraction lets every tier see the SAME remaining pool -- Utility would compute against the full 30, - # --- not the 0 left after Critical, so it would stop reading as fully starved. --- - Mutation( - rule="a fed tier must commit its grant against remaining, or a lower tier could see it twice", - file="Assets/Scripts/ServerShared/PowerBus.cs", - anchor=" remaining -= grant;", - mutated=" // remaining -= grant;", - test="PowerTierTests.CriticalTierIsFedInFullWhileLowerTierStarves", - expect="red", - ), - - # --- Within a tier, every consumer must ration by the SAME fraction of its OWN request -- that is what - # --- makes two unequal requests divide proportionally instead of splitting the tier's grant evenly per - # --- consumer regardless of how much each asked for. --- - Mutation( - rule="within a tier, the ratio must be grant/demand (proportional), not an even per-consumer split", - file="Assets/Scripts/ServerShared/PowerBus.cs", - anchor=" ratios[tier] = saturate(grant / demand);", - mutated=" ratios[tier] = saturate(grant / tierDemand.Length);", - test="PowerTierTests.TwoConsumersInOneTierAreRationedProportionally", - expect="red", - ), - - # --- An empty tier must read as fully met (ratio 1, remaining untouched) so its share passes straight to - # --- the next tier down. Forcing it to 0 instead makes an unused tier look starved and -- because nothing - # --- reduces remaining for a 0-demand tier either way -- has no effect on the real consumer, but the - # --- assertion on the empty tiers' own ratio catches the wrong value directly. --- - Mutation( - rule="a tier with no demand must read as fully met (1f), not starved (0f)", - file="Assets/Scripts/ServerShared/PowerBus.cs", - anchor=" if (demand <= 1e-4f) { ratios[tier] = 1f; continue; }", - mutated=" if (demand <= 1e-4f) { ratios[tier] = 0f; continue; }", - test="PowerTierTests.ATierWithNoDemandPassesItsShareDown", - expect="red", - ), - - # --- TotalGrant is the ceiling tiering allocates -- it must still reflect what generation and stored charge - # --- can actually cover, not the raw total request. Feeding the allocator TotalDemand instead (pretending - # --- supply always covers everything) hands Utility the untouched 80 instead of the 0 left after Critical's - # --- own 30 is committed, so it reads as fully fed instead of starved. --- - Mutation( - rule="tiering must allocate against TotalGrant (what supply covers), not TotalDemand (what was asked)", - file="Assets/Scripts/ServerShared/PowerBus.cs", - anchor=" var remaining = TotalGrant;", - mutated=" var remaining = TotalDemand;", - test="PowerTierTests.CriticalTierIsFedInFullWhileLowerTierStarves", - expect="red", - ), - - # --- The boundary rule itself: once a higher tier's own shortfall has consumed all of remaining, a lower - # --- tier must see 0, not some leftover computed from the higher tier's unmet demand. Handing every tier - # --- the ORIGINAL TotalGrant instead of the shrinking remaining pool would let Utility compute its own - # --- ratio against the full grant as if Critical had never been served first -- exactly the leak §1.3 - # --- forbids. --- - Mutation( - rule="each tier must divide what is actually left (remaining), not the original total grant again", - file="Assets/Scripts/ServerShared/PowerBus.cs", - anchor=" var grant = min(demand, remaining);", - mutated=" var grant = min(demand, TotalGrant);", - test="PowerTierTests.LowerTierGetsExactlyZeroWhenAHigherTierAloneStarves", - expect="red", - ), - - # --- The tier must be read fresh from the stored field every Step, not cached at equip time -- otherwise a - # --- player's tier change on the schematic UI would need an unequip/re-equip round trip to take effect, - # --- which the map's own verification bullet rules out. --- - Mutation( - rule="the bus must read the item's CURRENT stored PowerTier every Step, not one captured once at equip", - file="Assets/Scripts/ServerShared/PowerBus.cs", - anchor=" var tier = item.EquippableItem.PowerTier;", - mutated=" var tier = consumer.DefaultPowerTier;", - test="PowerTierTests.ATierChangeOnAnEquippedItemTakesEffectNextTickWithNoReEquip", - expect="red", - ), - - # --- EquippedItem's constructor must seed a kind default only once, the first time a unit is equipped - # --- (PowerTiers.Unassigned) -- never re-seeding it on every equip is what makes the tier a genuinely stored - # --- choice instead of one silently reset back to the kind default whenever a unit changes hands. Removing - # --- the guard reassigns Utility's own default even after a mutating test has moved it to Critical. - Mutation( - rule="the kind default must only be seeded when PowerTier is still Unassigned, never re-seeded", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" if (EquippableItem.PowerTier == PowerTiers.Unassigned)\n {\n var consumerTiers = Behaviors.OfType().Select(c => c.DefaultPowerTier).ToArray();\n if (consumerTiers.Length > 0)\n EquippableItem.PowerTier = consumerTiers.Min();\n }", - mutated=" {\n var consumerTiers = Behaviors.OfType().Select(c => c.DefaultPowerTier).ToArray();\n if (consumerTiers.Length > 0)\n EquippableItem.PowerTier = consumerTiers.Min();\n }", - test="PowerTierTests.ATierChangeOnAnEquippedItemTakesEffectNextTickWithNoReEquip", - expect="red", - ), -] - - -def replace_unique(path: Path, anchor: str, mutated: str) -> None: - original = path.read_text(encoding="utf-8") - count = original.count(anchor) - if count != 1: - raise SystemExit(f"{path}: anchor must occur exactly once, found {count}\n---\n{anchor}") - path.write_text(original.replace(anchor, mutated), encoding="utf-8") - - -def run_test(cultlib_root: str, test: str) -> tuple[int, str]: - proc = subprocess.run( - ["dotnet", "test", str(TEST_PROJECT), f"-p:CultLibRoot={cultlib_root}", - "--filter", f"FullyQualifiedName~{test}"], - capture_output=True, text=True, timeout=300, - ) - return proc.returncode, proc.stdout + proc.stderr - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--cultlib-root", required=True) - args = parser.parse_args() - - failures: list[str] = [] - originals: dict[str, str] = {} - for m in MUTATIONS: - if m.file not in originals: - originals[m.file] = (REPO_ROOT / m.file).read_text(encoding="utf-8") - - def restore_all() -> None: - for file, text in originals.items(): - (REPO_ROOT / file).write_text(text, encoding="utf-8") - - try: - for m in MUTATIONS: - print(f"\n=== {m.rule} (expect {m.expect}) ===") - path = REPO_ROOT / m.file - replace_unique(path, m.anchor, m.mutated) - code, output = run_test(args.cultlib_root, m.test) - passed = "Passed!" in output and code == 0 - if m.expect == "green": - if not passed: - failures.append(f"{m.rule}: expected green (test passing), got failure\n{output[-2000:]}") - else: - print(f"OK: {m.test} passed under a no-op mutation, as expected") - else: - if passed: - failures.append(f"{m.rule}: expected red (mutant killed), but {m.test} still passed") - else: - print(f"OK: {m.test} went red under the mutation, as expected") - # Restore this file immediately so the next mutation starts from a clean tree. - path.write_text(originals[m.file], encoding="utf-8") - finally: - restore_all() - - if failures: - print("\n=== FAILURES ===") - for f in failures: - print(f"- {f}") - return 1 - - print("\nAll mutations behaved as declared.") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/mutation_tests_stats_power_cut6.py b/tests/mutation_tests_stats_power_cut6.py deleted file mode 100644 index 74b7e7b3e..000000000 --- a/tests/mutation_tests_stats_power_cut6.py +++ /dev/null @@ -1,201 +0,0 @@ -#!/usr/bin/env python3 -"""Mutation tests for docs/stats-and-power-cut.md Cut 6 (the power-supply term and the request-independence -rule). - -Cut 6 is purely additive: StatSource.PowerSupply already existed as an identity everywhere (Cut 2), and the bus -already wrote EquippedItem.PowerSupply (Cut 3/5). This cut wires the identity into a real curve -(EquippedItem.PowerSupplyFactor), wires the bus's own per-tick write to invalidate the resolver -(PowerBus.AllocateTiers), and added the validation rule that a power request may not depend on power supply, -directly or through a modifier chain. This script mutates each rule's own line of code and asserts the named -test in PowerCurveTests.cs goes red for exactly that reason. A no-op control mutation proves the read/replace/ -restore path is transparent. - -Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19) deleted the direct half of the -request-independence rule (StatValidation.ValidateNoPowerSupplyOnRequest) -- PowerRequest now reads a request -field through EquippedItem.EvaluateNominalPower, which pins that stat's own PowerSupplyFactor to 1, so a direct -term can no longer make the request depend on its own answer. The three mutation cases that used to pin that -static check (and the two PowerCurveTests it targeted, since renamed to OpenAccepts.../UpsertAccepts... in -PowerCurveTests.cs) are removed below; see tests/mutation_tests_stats_power_nominal_request.py for that rule's -own replacement check. The modifier-chain half (StatModifier.ValidateNoPowerSupplyChain) is unchanged and its -two mutation cases below still apply. - -Usage: - python tests/mutation_tests_stats_power_cut6.py --cultlib-root - -Only touches files under this repository; every mutation is reversed before the script exits, including on -failure. -""" -from __future__ import annotations - -import argparse -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -TEST_PROJECT = REPO_ROOT / "tests" / "Aetheria.Shared.Tests" - - -@dataclass -class Mutation: - rule: str - file: str - anchor: str - mutated: str - test: str - expect: str # "red" (mutant must be killed) or "green" (the no-op control) - - -MUTATIONS: list[Mutation] = [ - # --- No-op control: proves the anchor/replace/restore mechanism is transparent --- - Mutation( - rule="control (no-op round trip)", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" public float PowerSupplyFactor(float exponent) => pow(PowerSupply, exponent);", - mutated=" public float PowerSupplyFactor(float exponent) => pow(PowerSupply, exponent);", - test="PowerCurveTests.APowerSupplyTermedStatAtHalfGrantDegradesByItsAuthoredExponent", - expect="green", - ), - - # --- The curve itself: a PowerSupply term must actually read this tick's grant raised to its own exponent, - # --- not the identity Cut 3 left in place as a placeholder. Reverting to the pre-Cut-6 stub makes a half - # --- grant read exactly like a full one. --- - Mutation( - rule="PowerSupplyFactor must read PowerSupply raised to the term's exponent, not the identity", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" public float PowerSupplyFactor(float exponent) => pow(PowerSupply, exponent);", - mutated=" public float PowerSupplyFactor(float exponent) => 1f;", - test="PowerCurveTests.APowerSupplyTermedStatAtHalfGrantDegradesByItsAuthoredExponent", - expect="red", - ), - - # --- The exponent itself must be honoured, not discarded -- a stat authored with a steep curve (exponent 2) - # --- must degrade faster than a linear one. Dropping the exponent from the pow() call collapses every - # --- PowerSupply term to the same linear response regardless of what was authored. --- - Mutation( - rule="PowerSupplyFactor must raise PowerSupply to the term's own exponent, not always to the first power", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" public float PowerSupplyFactor(float exponent) => pow(PowerSupply, exponent);", - mutated=" public float PowerSupplyFactor(float exponent) => PowerSupply;", - test="PowerCurveTests.APowerSupplyTermedStatAtHalfGrantDegradesByItsAuthoredExponent", - expect="red", - ), - - # --- The missing half of the wiring: the bus must invalidate StatSource.PowerSupply every time it writes a - # --- grant, or a resolver entry that cached last tick's (or the pre-activation default) value never - # --- recomputes at all. Deleting the invalidate call leaves PowerSupply written but never re-read. --- - Mutation( - rule="the bus must invalidate StatSource.PowerSupply every time it writes a grant", - file="Assets/Scripts/ServerShared/PowerBus.cs", - anchor=" draw.Item.PowerSupply = ratios[draw.Tier];\n // Cut 6 (docs/stats-and-power-cut.md): the missing half of the wiring -- a stat with a PowerSupply\n // term must recompute when the grant actually moves. Called unconditionally, once per draw per tick,\n // the same shape as EquippedItem.UpdatePerformance's own Heat/Durability invalidation: \"recomputes at\n // most once per tick per (item, stat),\" not \"only when the value moved.\" An item with no PowerSupply\n // term pays nothing extra -- the resolver's per-source generation bookkeeping (StatResolver.Resolve)\n // only ever looks at sources a stat's own Terms declared.\n _entity.Resolver.InvalidateSource(draw.Item, StatSource.PowerSupply);", - mutated=" draw.Item.PowerSupply = ratios[draw.Tier];", - test="PowerCurveTests.APowerSupplyTermedStatAtHalfGrantDegradesByItsAuthoredExponent", - expect="red", - ), - - # --- The resolver's own generation scheme must still be keyed per-source: invalidating PowerSupply must never - # --- touch a stat that declared only Heat. Rerouting the bus's invalidate call to a different StatSource - # --- proves the wrong thing gets bumped -- the power-termed stat then never recomputes even though the grant - # --- moved, because nothing ever invalidates the source it actually declared. --- - Mutation( - rule="the bus must invalidate PowerSupply specifically, not some other source", - file="Assets/Scripts/ServerShared/PowerBus.cs", - anchor=" _entity.Resolver.InvalidateSource(draw.Item, StatSource.PowerSupply);", - mutated=" _entity.Resolver.InvalidateSource(draw.Item, StatSource.Heat);", - test="PowerCurveTests.APowerSupplyTermedStatAtHalfGrantDegradesByItsAuthoredExponent", - expect="red", - ), - - # --- The modifier-chain half: a modifier's own magnitude must be checked for a PowerSupply dependency before - # --- it is allowed to attach onto a known request stat. Skipping the taint check lets a booster with a - # --- power-dependent magnitude attach onto Drain's own EnergyDraw without complaint. --- - Mutation( - rule="a modifier chain reaching a request stat through a power-tainted magnitude must be refused", - file="Assets/Scripts/ServerShared/Behaviors/StatModifier.cs", - anchor=" if (!IsPowerTainted(data.Modifier, edges, new HashSet()))\n return;", - mutated=" if (true)\n return;", - test="PowerCurveTests.ActivateRefusesAModifierChainThatWouldCorruptAPowerRequest", - expect="red", - ), - - # --- IsPowerTainted's own base case: a magnitude stat's Terms must actually be checked for PowerSupply, not - # --- treated as always clean. Without this, no magnitude is ever tainted and the chain check above never - # --- fires for any catalog. --- - Mutation( - rule="IsPowerTainted must check the stat's own Terms for a direct PowerSupply dependency", - file="Assets/Scripts/ServerShared/Behaviors/StatModifier.cs", - anchor=" foreach (var term in stat.Terms)\n if (term.Source == StatSource.PowerSupply)\n return true;", - mutated=" foreach (var term in stat.Terms)\n if (false)\n return true;", - test="PowerCurveTests.ActivateRefusesAModifierChainThatWouldCorruptAPowerRequest", - expect="red", - ), -] - - -def replace_unique(path: Path, anchor: str, mutated: str) -> None: - original = path.read_text(encoding="utf-8") - count = original.count(anchor) - if count != 1: - raise SystemExit(f"{path}: anchor must occur exactly once, found {count}\n---\n{anchor}") - path.write_text(original.replace(anchor, mutated), encoding="utf-8") - - -def run_test(cultlib_root: str, test: str) -> tuple[int, str]: - proc = subprocess.run( - ["dotnet", "test", str(TEST_PROJECT), f"-p:CultLibRoot={cultlib_root}", - "--filter", f"FullyQualifiedName~{test}"], - capture_output=True, text=True, timeout=300, - ) - return proc.returncode, proc.stdout + proc.stderr - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--cultlib-root", required=True) - args = parser.parse_args() - - failures: list[str] = [] - originals: dict[str, str] = {} - for m in MUTATIONS: - if m.file not in originals: - originals[m.file] = (REPO_ROOT / m.file).read_text(encoding="utf-8") - - def restore_all() -> None: - for file, text in originals.items(): - (REPO_ROOT / file).write_text(text, encoding="utf-8") - - try: - for m in MUTATIONS: - print(f"\n=== {m.rule} (expect {m.expect}) ===") - path = REPO_ROOT / m.file - replace_unique(path, m.anchor, m.mutated) - code, output = run_test(args.cultlib_root, m.test) - passed = "Passed!" in output and code == 0 - if m.expect == "green": - if not passed: - failures.append(f"{m.rule}: expected green (test passing), got failure\n{output[-2000:]}") - else: - print(f"OK: {m.test} passed under a no-op mutation, as expected") - else: - if passed: - failures.append(f"{m.rule}: expected red (mutant killed), but {m.test} still passed") - else: - print(f"OK: {m.test} went red under the mutation, as expected") - # Restore this file immediately so the next mutation starts from a clean tree. - path.write_text(originals[m.file], encoding="utf-8") - finally: - restore_all() - - if failures: - print("\n=== FAILURES ===") - for f in failures: - print(f"- {f}") - return 1 - - print("\nAll mutations behaved as declared.") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/mutation_tests_stats_power_cut7.py b/tests/mutation_tests_stats_power_cut7.py deleted file mode 100644 index e67cc5157..000000000 --- a/tests/mutation_tests_stats_power_cut7.py +++ /dev/null @@ -1,204 +0,0 @@ -#!/usr/bin/env python3 -"""Mutation tests for docs/stats-and-power-target.md's brownout ruling ("Continuous consumers brown out through -a power supply curve on their performance stats. An exponent is enough.") -- Cut 7 in this repo's own numbering, -following Cut 6 (mutation_tests_stats_power_cut6.py). - -Cut 6 wired a real curve into EquippedItem.PowerSupplyFactor and the bus already wrote a real fractional grant, -but every continuous consumer still gated its own effect on Item.PowerSupply >= 1f (Thruster.Execute, -AetherDrive.Execute, Radiator.Execute, ConstantWeapon.Execute, EnergyDraw.Execute) -- so a partial grant read -exactly like none, the flicking-off bug the ruling names. This cut removes each of those five gates (Radiator's -outright; the other four narrowed to a true-zero floor) and lets the already-curved performance stat (or, for -EnergyDraw, the plain pass/fail gate itself) carry the degradation instead. This script mutates each behaviour's -own changed line back toward the old all-or-nothing shape (or removes the newly-added epsilon floor) and asserts -the matching BrownoutTests.cs Fact goes red for exactly that reason. A no-op control mutation proves the -read/replace/restore path is transparent. - -Usage: - python tests/mutation_tests_stats_power_cut7.py --cultlib-root - -Only touches files under this repository; every mutation is reversed before the script exits, including on -failure. -""" -from __future__ import annotations - -import argparse -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -TEST_PROJECT = REPO_ROOT / "tests" / "Aetheria.Shared.Tests" - - -@dataclass -class Mutation: - rule: str - file: str - anchor: str - mutated: str - test: str - expect: str # "red" (mutant must be killed) or "green" (the no-op control) - - -MUTATIONS: list[Mutation] = [ - # --- No-op control: proves the anchor/replace/restore mechanism is transparent --- - Mutation( - rule="control (no-op round trip)", - file="Assets/Scripts/ServerShared/Behaviors/Thruster.cs", - anchor=" if(_input > .01f && Item.PowerSupply > 1e-4f)", - mutated=" if(_input > .01f && Item.PowerSupply > 1e-4f)", - test="BrownoutTests.ThrusterAtHalfGrantProducesReducedNotZeroNotFullThrust", - expect="green", - ), - - # --- Thruster: the gate must not demand a full grant any more. Reverting to the pre-cut ">= 1f" makes a - # --- half grant produce exactly nothing (the flicking-off bug), instead of a reduced, nonzero thrust. --- - Mutation( - rule="Thruster.Execute must not gate its effect on a full grant", - file="Assets/Scripts/ServerShared/Behaviors/Thruster.cs", - anchor=" if(_input > .01f && Item.PowerSupply > 1e-4f)", - mutated=" if(_input > .01f && Item.PowerSupply >= 1f)", - test="BrownoutTests.ThrusterAtHalfGrantProducesReducedNotZeroNotFullThrust", - expect="red", - ), - - # --- AetherDrive: the same rule, the same shape of mutation, on the rotor's own spin-up gate. --- - Mutation( - rule="AetherDrive.Execute must not gate its effect on a full grant", - file="Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs", - anchor=" if (Item.PowerSupply > 1e-4f)", - mutated=" if (Item.PowerSupply >= 1f)", - test="BrownoutTests.AetherDriveAtHalfGrantProducesReducedNotZeroNotFullSpinUp", - expect="red", - ), - - # --- Radiator: Cut 7 deletes the gate outright rather than narrowing it (PumpedHeat's own curve already - # --- reduces the pump, and already zeroes it at true zero supply). Re-inserting the old gate line makes a - # --- half grant refuse to pump at all, same as a full shortfall. --- - Mutation( - rule="Radiator.Execute must not gate its pump on a full grant", - file="Assets/Scripts/ServerShared/Behaviors/Radiator.cs", - anchor=" var pumpedHeat = PumpedHeat * max(itemTemperature - _data.TemperatureFloor, 0);", - mutated=" if (Item != null && Item.PowerSupply < 1f) return false;\n\n var pumpedHeat = PumpedHeat * max(itemTemperature - _data.TemperatureFloor, 0);", - test="BrownoutTests.RadiatorAtHalfGrantPumpsReducedNotZeroNotFullHeat", - expect="red", - ), - - # --- ConstantWeapon: the exact bug the ruling names -- a partial grant used to safe the weapon off entirely - # --- (_firing = false), not merely fire it for less. Reverting to "< 1f" makes a half grant stop the weapon - # --- outright instead of merely firing it for reduced damage. --- - Mutation( - rule="ConstantWeapon.Execute must not safe the weapon off on anything less than a full grant", - file="Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs", - anchor=" if (Item != null && Item.PowerSupply <= 1e-4f)", - mutated=" if (Item != null && Item.PowerSupply < 1f)", - test="BrownoutTests.ConstantWeaponAtHalfGrantKeepsFiringWithReducedDamage", - expect="red", - ), - - # --- EnergyDraw: the one continuous consumer with no performance stat of its own to curve -- its Execute - # --- return value IS its whole effect (Entity.cs's per-BehaviorGroup Execute chain). Reverting to ">= 1f" - # --- makes a half grant close the gate exactly like a full shortfall would. --- - Mutation( - rule="EnergyDraw.Execute must not demand a full grant to pass its gate", - file="Assets/Scripts/ServerShared/Behaviors/EnergyDraw.cs", - anchor=" return Item == null || Item.PowerSupply > 1e-4f;", - mutated=" return Item == null || Item.PowerSupply >= 1f;", - test="BrownoutTests.EnergyDrawAtHalfGrantStillPasses", - expect="red", - ), - - # --- The degradation must follow the authored exponent end-to-end through a newly-ungated consumer, not - # --- just in the abstract (mutation_tests_stats_power_cut6.py already pins this against a bare resolver - # --- read) -- dropping the exponent from PowerSupplyFactor collapses ConstantWeapon's own Damage curve to - # --- the identity-linear response regardless of what was authored. --- - Mutation( - rule="PowerSupplyFactor must raise PowerSupply to the term's own exponent for a real continuous consumer, not just a bare stat", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" public float PowerSupplyFactor(float exponent) => pow(PowerSupply, exponent);", - mutated=" public float PowerSupplyFactor(float exponent) => PowerSupply;", - test="BrownoutTests.ConstantWeaponDamageDegradesByItsAuthoredExponentNotLinearly", - expect="red", - ), - - # --- Regression: Cut 4's InputCapacitor all-or-nothing rule for instant items must survive this cut - # --- completely untouched. Neutering the refusal check proves the regression test actually catches a - # --- violation, not merely that it happens to pass today. --- - Mutation( - rule="InputCapacitor.TrySpend must still refuse a cost it cannot cover", - file="Assets/Scripts/ServerShared/Behaviors/InputCapacitor.cs", - anchor=" if (!CanSpend(cost)) return false;", - mutated=" if (false) return false;", - test="BrownoutTests.AnInputCapacitorStillRefusesToSpendOnAPartialCharge", - expect="red", - ), -] - - -def replace_unique(path: Path, anchor: str, mutated: str) -> None: - original = path.read_text(encoding="utf-8") - count = original.count(anchor) - if count != 1: - raise SystemExit(f"{path}: anchor must occur exactly once, found {count}\n---\n{anchor}") - path.write_text(original.replace(anchor, mutated), encoding="utf-8") - - -def run_test(cultlib_root: str, test: str) -> tuple[int, str]: - proc = subprocess.run( - ["dotnet", "test", str(TEST_PROJECT), f"-p:CultLibRoot={cultlib_root}", - "--filter", f"FullyQualifiedName~{test}"], - capture_output=True, text=True, timeout=300, - ) - return proc.returncode, proc.stdout + proc.stderr - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--cultlib-root", required=True) - args = parser.parse_args() - - failures: list[str] = [] - originals: dict[str, str] = {} - for m in MUTATIONS: - if m.file not in originals: - originals[m.file] = (REPO_ROOT / m.file).read_text(encoding="utf-8") - - def restore_all() -> None: - for file, text in originals.items(): - (REPO_ROOT / file).write_text(text, encoding="utf-8") - - try: - for m in MUTATIONS: - print(f"\n=== {m.rule} (expect {m.expect}) ===") - path = REPO_ROOT / m.file - replace_unique(path, m.anchor, m.mutated) - code, output = run_test(args.cultlib_root, m.test) - passed = "Passed!" in output and code == 0 - if m.expect == "green": - if not passed: - failures.append(f"{m.rule}: expected green (test passing), got failure\n{output[-2000:]}") - else: - print(f"OK: {m.test} passed under a no-op mutation, as expected") - else: - if passed: - failures.append(f"{m.rule}: expected red (mutant killed), but {m.test} still passed") - else: - print(f"OK: {m.test} went red under the mutation, as expected") - # Restore this file immediately so the next mutation starts from a clean tree. - path.write_text(originals[m.file], encoding="utf-8") - finally: - restore_all() - - if failures: - print("\n=== FAILURES ===") - for f in failures: - print(f"- {f}") - return 1 - - print("\nAll mutations behaved as declared.") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/mutation_tests_stats_power_cut8.py b/tests/mutation_tests_stats_power_cut8.py deleted file mode 100644 index 39a72bc52..000000000 --- a/tests/mutation_tests_stats_power_cut8.py +++ /dev/null @@ -1,182 +0,0 @@ -#!/usr/bin/env python3 -"""Mutation tests for docs/stats-and-power-cut.md's Cut 7 ("Roles authored") -- this repository's own Cut 8, -following Cut 7's brownout work (mutation_tests_stats_power_cut7.py). This is the content cut: designs declare -roles, stats name the role they read, and products author per-role quality so two products of one design differ -by being good at different parts. Three rules are pinned here: - - 1. ValidateRoleUsage (ItemData.cs) must refuse a StatSource.Quality term naming a role its design does not - declare -- and must not refuse one naming a role the design DOES declare (two mutations, opposite - directions, so neither "always refuse" nor "never refuse" can pass both). - 2. Lot.QualityForRole (Provenance.cs) must resolve the matching RoleFill's own quality, not fall back to the - lot's workmanship quality regardless of role. - 3. ItemManager.CreateLot (ItemManager.cs) must roll each role's quality from that product's own ProductRole - mean/deviation, not a fixed default -- so two products of one design with different means mint lots that - resolve a role-termed stat to different values. - -A no-op control mutation proves the read/replace/restore path is transparent. - -Usage: - python tests/mutation_tests_stats_power_cut8.py --cultlib-root - -Only touches files under this repository; every mutation is reversed before the script exits, including on -failure. -""" -from __future__ import annotations - -import argparse -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -TEST_PROJECT = REPO_ROOT / "tests" / "Aetheria.Shared.Tests" - - -@dataclass -class Mutation: - rule: str - file: str - anchor: str - mutated: str - test: str - expect: str # "red" (mutant must be killed) or "green" (the no-op control) - - -MUTATIONS: list[Mutation] = [ - # --- No-op control: proves the anchor/replace/restore mechanism is transparent --- - Mutation( - rule="control (no-op round trip)", - file="Assets/Scripts/ServerShared/ItemData.cs", - anchor=" if (!declared.Contains(term.Role))", - mutated=" if (!declared.Contains(term.Role))", - test="RoleAuthoringTests.UpsertRefusesAStatNamingARoleItsDesignLacks", - expect="green", - ), - - # --- ValidateRoleUsage must actually refuse an undeclared role, not merely appear to. Weakening the check to - # --- "never refuse" is the failure the ruling names directly: "validation already refuses a stat naming a - # --- role its design lacks." --- - Mutation( - rule="ValidateRoleUsage must refuse a StatSource.Quality term naming a role its design does not declare", - file="Assets/Scripts/ServerShared/ItemData.cs", - anchor=" if (!declared.Contains(term.Role))", - mutated=" if (false)", - test="RoleAuthoringTests.UpsertRefusesAStatNamingARoleItsDesignLacks", - expect="red", - ), - - # --- The opposite direction: a check that refuses every role-bearing stat, declared or not, would also pass - # --- the mutation above. This mutant refuses everything, and must kill the positive control instead. --- - Mutation( - rule="ValidateRoleUsage must not refuse a StatSource.Quality term naming a role the design DOES declare", - file="Assets/Scripts/ServerShared/ItemData.cs", - anchor=" if (!declared.Contains(term.Role))", - mutated=" if (true)", - test="RoleAuthoringTests.UpsertAcceptsAStatNamingADeclaredRole", - expect="red", - ), - - # --- Lot.QualityForRole must resolve the named role's own RoleFill, not fall back to the lot's workmanship - # --- regardless of which role (or none) a term names. Commenting out the match makes every role read the - # --- same number StatsReadTheLot's "noRole" case reads, collapsing the very distinction Cut 7 exists to buy. --- - Mutation( - rule="QualityForRole must resolve the matching RoleFill, not fall back to workmanship for every role", - file="Assets/Scripts/ServerShared/Provenance.cs", - anchor=" foreach (var fill in Roles)\n if (fill.Role == role) return fill.Quality;", - mutated=" foreach (var fill in Roles)\n if (false) return fill.Quality;", - test="LoadoutTests.StatsReadTheLot", - expect="red", - ), - - # --- ItemManager.CreateLot must roll each role from that product's own declared mean, not a constant. This - # --- is the mechanism "products author quality, designs author roles" depends on: without it, authoring a - # --- ProductRole spread on a design's role would do nothing at mint time. --- - Mutation( - rule="CreateLot must roll a role's quality from the product's own ProductRole.Mean, not a fixed default", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor=" Quality = clamp(Random.NextGaussian(build.Mean, build.StandardDeviation), .01f, 1)", - mutated=" Quality = clamp(Random.NextGaussian(.5f, build.StandardDeviation), .01f, 1)", - test="LoadoutTests.CreateLotFillsRolesFromProductSpread", - expect="red", - ), - - # --- The same rule, proved the way the operator's market-segmentation ruling states it: two products of one - # --- design with different role means must mint lots that resolve a role-termed stat to different values. --- - Mutation( - rule="Two products of one design with different role means must resolve a role-termed stat differently", - file="Assets/Scripts/ServerShared/ItemManager.cs", - anchor=" Quality = clamp(Random.NextGaussian(build.Mean, build.StandardDeviation), .01f, 1)", - mutated=" Quality = clamp(Random.NextGaussian(.5f, build.StandardDeviation), .01f, 1)", - test="RoleAuthoringTests.TwoProductsOfOneDesignWithDifferentRoleMeansResolveDifferentValues", - expect="red", - ), -] - - -def replace_unique(path: Path, anchor: str, mutated: str) -> None: - original = path.read_text(encoding="utf-8") - count = original.count(anchor) - if count != 1: - raise SystemExit(f"{path}: anchor must occur exactly once, found {count}\n---\n{anchor}") - path.write_text(original.replace(anchor, mutated), encoding="utf-8") - - -def run_test(cultlib_root: str, test: str) -> tuple[int, str]: - proc = subprocess.run( - ["dotnet", "test", str(TEST_PROJECT), f"-p:CultLibRoot={cultlib_root}", - "--filter", f"FullyQualifiedName~{test}"], - capture_output=True, text=True, timeout=300, - ) - return proc.returncode, proc.stdout + proc.stderr - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--cultlib-root", required=True) - args = parser.parse_args() - - failures: list[str] = [] - originals: dict[str, str] = {} - for m in MUTATIONS: - if m.file not in originals: - originals[m.file] = (REPO_ROOT / m.file).read_text(encoding="utf-8") - - def restore_all() -> None: - for file, text in originals.items(): - (REPO_ROOT / file).write_text(text, encoding="utf-8") - - try: - for m in MUTATIONS: - print(f"\n=== {m.rule} (expect {m.expect}) ===") - path = REPO_ROOT / m.file - replace_unique(path, m.anchor, m.mutated) - code, output = run_test(args.cultlib_root, m.test) - passed = "Passed!" in output and code == 0 - if m.expect == "green": - if not passed: - failures.append(f"{m.rule}: expected green (test passing), got failure\n{output[-2000:]}") - else: - print(f"OK: {m.test} passed under a no-op mutation, as expected") - else: - if passed: - failures.append(f"{m.rule}: expected red (mutant killed), but {m.test} still passed") - else: - print(f"OK: {m.test} went red under the mutation, as expected") - # Restore this file immediately so the next mutation starts from a clean tree. - path.write_text(originals[m.file], encoding="utf-8") - finally: - restore_all() - - if failures: - print("\n=== FAILURES ===") - for f in failures: - print(f"- {f}") - return 1 - - print("\nAll mutations behaved as declared.") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/mutation_tests_stats_power_nominal_request.py b/tests/mutation_tests_stats_power_nominal_request.py deleted file mode 100644 index b0ac88c2d..000000000 --- a/tests/mutation_tests_stats_power_nominal_request.py +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env python3 -"""Mutation tests for docs/stats-and-power-cut.md's nominal-request ruling (operator ruling 2026-09-19). - -Two landed rulings collided: a stat deciding a power request may not depend on power supply (Cut 6, widened by -F6 to name every stat a PowerRequest actually reads), and continuous consumers brown out through a power term on -their performance stats (Cut 7). For Radiator and AetherDrive those are the same stat (RadiatorData.PumpedHeat, -AetherDriveData.Torque), which made six shipped catalog records illegal and left six BrownoutTests.cs cases -skipped. - -The resolution: a power request is evaluated NOMINALLY -- what the item wants at full supply, not what it is -currently managing. EquippedItem.EvaluateNominalPower (Entity.cs) is the read every PowerRequest/RefreshReserve/ -RefreshInputCapacitor implementation now uses for a StatValidation.PowerRequestFields stat; it pins that stat's -own PowerSupplyFactor to 1 regardless of what Terms it declares, removing the circularity at the root instead of -forbidding the field (StatValidation.ValidateNoPowerSupplyOnRequest, the static half of Cut 6/F6's rule, is -deleted -- see mutation_tests_stats_power_cut6.py's own updated header). This script pins that EvaluateNominalPower -actually forces the pin (rather than silently falling back to the real, curved value) and that Radiator.PowerRequest -actually calls it instead of the ordinary Evaluate. AetherDrive.PowerRequest's own Torque read has no equivalent -black-box mutation here -- its torqueRatio term self-cancels Torque's own magnitude outside the rpm cap, so -reverting that one call site produces byte-identical demand in every fixture tried (see the comment in -BrownoutTests.cs, right after AetherDriveAtZeroGrantProducesNothing, for the empirical finding); its correctness -rests on the identical pattern to Radiator's own call site instead. - -Usage: - python tests/mutation_tests_stats_power_nominal_request.py --cultlib-root - -Only touches files under this repository; every mutation is reversed before the script exits, including on -failure. -""" -from __future__ import annotations - -import argparse -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -TEST_PROJECT = REPO_ROOT / "tests" / "Aetheria.Shared.Tests" - - -@dataclass -class Mutation: - rule: str - file: str - anchor: str - mutated: str - test: str - expect: str # "red" (mutant must be killed) or "green" (the no-op control) - - -MUTATIONS: list[Mutation] = [ - # --- No-op control: proves the anchor/replace/restore mechanism is transparent --- - Mutation( - rule="control (no-op round trip)", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" public PowerRequestContext(EquippedItem item) => _item = item;\n public Lot Lot => _item.Lot;\n public float HeatFactor(float exponent) => _item.HeatFactor(exponent);\n public float DurabilityFactor(float exponent) => _item.DurabilityFactor(exponent);\n public float ConsumableProgressFactor(float exponent) => _item.ConsumableProgressFactor(exponent);\n public float PowerSupplyFactor(float exponent) => 1f;", - mutated=" public PowerRequestContext(EquippedItem item) => _item = item;\n public Lot Lot => _item.Lot;\n public float HeatFactor(float exponent) => _item.HeatFactor(exponent);\n public float DurabilityFactor(float exponent) => _item.DurabilityFactor(exponent);\n public float ConsumableProgressFactor(float exponent) => _item.ConsumableProgressFactor(exponent);\n public float PowerSupplyFactor(float exponent) => 1f;", - test="BrownoutTests.EvaluateNominalPowerIgnoresTheItemsCurrentPowerSupplyGrant", - expect="green", - ), - - # --- The pin itself: EvaluateNominalPower's own context must actually return the identity for PowerSupply, - # --- not forward it to the real item -- otherwise EvaluateNominalPower is just Evaluate under another name, - # --- and every PowerRequest/RefreshReserve/RefreshInputCapacitor call site built on it stays exactly as - # --- circular as before this ruling (the oscillation Soul originally measured against the shipped catalog). - Mutation( - rule="PowerRequestContext.PowerSupplyFactor must return the identity (1), not forward to the real item", - file="Assets/Scripts/ServerShared/Entity.cs", - anchor=" public float ConsumableProgressFactor(float exponent) => _item.ConsumableProgressFactor(exponent);\n public float PowerSupplyFactor(float exponent) => 1f;\n public float ScaleModifier(PerformanceStat stat) => _item.ScaleModifier(stat);\n public float ConstantModifier(PerformanceStat stat) => _item.ConstantModifier(stat);\n }\n\n public float HeatFactor(float exponent)", - mutated=" public float ConsumableProgressFactor(float exponent) => _item.ConsumableProgressFactor(exponent);\n public float PowerSupplyFactor(float exponent) => _item.PowerSupplyFactor(exponent);\n public float ScaleModifier(PerformanceStat stat) => _item.ScaleModifier(stat);\n public float ConstantModifier(PerformanceStat stat) => _item.ConstantModifier(stat);\n }\n\n public float HeatFactor(float exponent)", - test="BrownoutTests.EvaluateNominalPowerIgnoresTheItemsCurrentPowerSupplyGrant", - expect="red", - ), - - # --- Radiator.PowerRequest must actually call the nominal read for PumpedHeat/WasteHeat/EnergyUsage, not the - # --- ordinary curved Evaluate. A single-tick fixture cannot distinguish the two (Item.PowerSupply defaults to - # --- 1 before the bus has ever run, so tick 1 reads the same value either way) -- this fixture runs two - # --- ticks, so tick 2 sees whatever tick 1 actually granted. Tuned so nominal PumpedHeat/WasteHeat (10/5 = 2) - # --- stays comfortably above tempRatio (pinned near 1) while the curved value at tick 1's partial grant - # --- (pow(.4, 1)*10/5 = .8) falls below it -- reverting to Evaluate collapses tick 2's demand through - # --- PowerRequest's own early-out gate instead of leaving it open. AetherDrive.PowerRequest's own Torque read - # --- has no equivalent black-box mutation: see the comment in BrownoutTests.cs above - # --- AetherDriveAtHalfGrantProducesReducedNotZeroNotFullSpinUp for why (torqueRatio self-cancels Torque's own - # --- magnitude outside the rpm cap, verified empirically while building this script). - Mutation( - rule="Radiator.PowerRequest must read PumpedHeat/WasteHeat/EnergyUsage nominally, not via the curved Evaluate", - file="Assets/Scripts/ServerShared/Behaviors/Radiator.cs", - anchor=" var pumpedHeat = EvaluateNominalPower(_data.PumpedHeat);\n var wasteHeat = EvaluateNominalPower(_data.WasteHeat);\n var tempRatio = max(RadiatorTemperature / Temperature, 1);\n if (tempRatio > pumpedHeat / wasteHeat) return 0f;\n return EvaluateNominalPower(_data.EnergyUsage) * tempRatio * dt;", - mutated=" var pumpedHeat = Evaluate(_data.PumpedHeat);\n var wasteHeat = Evaluate(_data.WasteHeat);\n var tempRatio = max(RadiatorTemperature / Temperature, 1);\n if (tempRatio > pumpedHeat / wasteHeat) return 0f;\n return Evaluate(_data.EnergyUsage) * tempRatio * dt;", - test="BrownoutTests.RadiatorSecondTickDemandIsUnaffectedByTheFirstTicksPartialGrant", - expect="red", - ), -] - - -def replace_unique(path: Path, anchor: str, mutated: str) -> None: - original = path.read_text(encoding="utf-8") - count = original.count(anchor) - if count != 1: - raise SystemExit(f"{path}: anchor must occur exactly once, found {count}\n---\n{anchor}") - path.write_text(original.replace(anchor, mutated), encoding="utf-8") - - -def run_test(cultlib_root: str, test: str) -> tuple[int, str]: - proc = subprocess.run( - ["dotnet", "test", str(TEST_PROJECT), f"-p:CultLibRoot={cultlib_root}", - "--filter", f"FullyQualifiedName~{test}"], - capture_output=True, text=True, timeout=300, - ) - return proc.returncode, proc.stdout + proc.stderr - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--cultlib-root", required=True) - args = parser.parse_args() - - failures: list[str] = [] - originals: dict[str, str] = {} - for m in MUTATIONS: - if m.file not in originals: - originals[m.file] = (REPO_ROOT / m.file).read_text(encoding="utf-8") - - def restore_all() -> None: - for file, text in originals.items(): - (REPO_ROOT / file).write_text(text, encoding="utf-8") - - try: - for m in MUTATIONS: - print(f"\n=== {m.rule} (expect {m.expect}) ===") - path = REPO_ROOT / m.file - replace_unique(path, m.anchor, m.mutated) - code, output = run_test(args.cultlib_root, m.test) - passed = "Passed!" in output and code == 0 - if m.expect == "green": - if not passed: - failures.append(f"{m.rule}: expected green (test passing), got failure\n{output[-2000:]}") - else: - print(f"OK: {m.test} passed under a no-op mutation, as expected") - else: - if passed: - failures.append(f"{m.rule}: expected red (mutant killed), but {m.test} still passed") - else: - print(f"OK: {m.test} went red under the mutation, as expected") - # Restore this file immediately so the next mutation starts from a clean tree. - path.write_text(originals[m.file], encoding="utf-8") - finally: - restore_all() - - if failures: - print("\n=== FAILURES ===") - for f in failures: - print(f"- {f}") - return 1 - - print("\nAll mutations behaved as declared.") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) From a6543af7416684d900c26e324d837809c915dbfe Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 16:06:33 +0200 Subject: [PATCH 5/8] Cut 11.4: behavioural tests for what Stryker found undefended, and Soul C4 Nine tests, each stating as behaviour a rule a real Stryker mutant survived: - A shield that absorbs must eventually run dry, on both the discrete path and Splash's (whose absorb branch had no coverage at all). Deleting Shield.TakeHit survived because earlier tests asserted only that the hull was untouched. - Splash damages the side the blast came from, across five facings and both sides. Flipping the sign of `right` survived every suite, and it survives at the default facing (0, 1) too: the flip only changes the damaged side when a ship faces more along world x than z. So the test spins the target through facings where it bites. The current code is correct; it can no longer regress. - Different zones roll differently. Replacing `CombatSeed *` with `/` dropped the zone from the seed and survived, because the die test checks one zone only. - A shot whose target left the zone resolves as a miss. That last one is a real defect (Soul C4), fixed here. Step rewrote the outcome only for uncommitted shots, so a hit committed inside the horizon and then orphaned by its target dying was republished as a hit -- a hit marker on a corpse. The resolution is now a fresh miss; the committed outcome is left untouched, since R4 makes it immutable. The fixture puts the shooter off the origin on purpose: at float3.zero, `target - source` equals `target + source`, which is how four position mutants survived. 240 tests pass. Co-Authored-By: Claude Opus 5 --- Assets/Scripts/ServerShared/FireControl.cs | 14 +- .../FireControlCut11Tests.cs | 352 ++++++++++++++++++ 2 files changed, 360 insertions(+), 6 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 03ad291b4..4ba600de2 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -328,12 +328,14 @@ public static class FireControl var targetGone = shot.Target != null && !zone.Entities.Contains(shot.Target); if (sourceGone || targetGone) { - if (!shot.Committed) - { - shot.Outcome = MakeOutcome(shot, false, false, false, int2.zero, null, now); - zone.ShotCommitted.OnNext(shot.Outcome); - } - zone.ShotResolved.OnNext(shot.Outcome); + // Cut 11 (Soul C4): a shot whose source or target has left the zone resolves as a miss whichever + // stage it is at (0b table). The resolution is published as a fresh miss rather than by + // rewriting shot.Outcome: a committed outcome is immutable (R4) and stays a true record of what + // the commit decided, while ShotResolved reports what actually happened -- nothing, because the + // target is gone. Republishing the committed outcome here used to put a hit marker on a corpse. + var miss = MakeOutcome(shot, false, false, false, int2.zero, null, now); + if (!shot.Committed) zone.ShotCommitted.OnNext(miss); + zone.ShotResolved.OnNext(miss); shots.RemoveAt(i); continue; } diff --git a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs new file mode 100644 index 000000000..7213f2f4b --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs @@ -0,0 +1,352 @@ +/* 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 float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; + +// Cut 11 (docs/fire-control-cut.md): behavioural tests for what a Stryker run against FireControl.cs found +// undefended. Every test here states a rule as behaviour, observed through the public surface, and is the +// test whose absence let a real mutant survive. Builds its own fixtures, the convention every cut's test +// file follows. +public sealed class FireControlCut11Tests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut11-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public FireControlCut11Tests() => 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; + } + + private sealed class Engagement + { + public Zone Zone; + public Ship Shooter; + public Ship Target; + public EquippedItem WeaponItem; + public InstantWeapon Weapon; + public EquippedItem PortMarker; + public EquippedItem StarboardMarker; + } + + // A shooter and a target. The shooter sits OFF the origin on purpose: a shooter at float3.zero makes + // `target - source` and `target + source` the same vector, which is exactly how four position mutants in + // FireControl.cs survived every earlier suite. + private Engagement Build( + string zoneName = "Cut11", + float damage = 5, float velocity = 0, float accuracy = 1, + bool equipShield = false, float shieldCapacity = 30, + bool markers = false) + { + // 5 wide x 3 tall: schematic x runs port (0) to starboard (4). Tool gear may only occupy interior + // cells, which on this hull is the middle row x = 1..3, so the markers sit at (1, 1) and (3, 1) -- + // one cell either side of the centre of mass (2, 1). + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = SolidShape(5, 3), Durability = 100000, Mass = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(2, 2), 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(velocity), 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(accuracy), Resolution = Constant(1000), Precision = Constant(1), Tracking = Constant(100000) + } } + }); + cache.Upsert(new GearData + { + Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new ReactorData { Charge = Constant(1000), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) } } + }); + cache.Upsert(new GearData + { + Name = "Shield", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new ShieldData { Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(shieldCapacity), RefillDuration = Constant(.01f), RestoreDuration = Constant(.01f) } } + }); + cache.Upsert(new GearData + { + Name = "Marker", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 100, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 16; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, TestSettings(), _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = zoneName, Owner = null }, null); + + EquippableItem Make(string name, int lot, float durability) => new EquippableItem + { + Data = items.ItemData.RefOf(items.ItemData.GetByName(name)), Durability = durability, Lot = lot + }; + + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooter = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 100000, Lot = 1 }, new EntitySettings()); + var gun = Make("Gun", 2, 1); + Assert.True(shooter.TryEquip(gun, new int2(2, 2))); + var weaponItem = shooter.Equipment.Single(x => x.EquippableItem == gun); + var targeting = Make("Targeting", 3, 1); + Assert.True(shooter.TryFindSpace(targeting, out var tpos)); + Assert.True(shooter.TryEquip(targeting, tpos)); + + var target = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 100000, Lot = 4 }, new EntitySettings()); + Assert.True(target.TryEquip(Make("Gun", 5, 1), new int2(2, 2))); + + EquippedItem port = null, starboard = null; + if (markers) + { + var p = Make("Marker", 6, 100); + var s = Make("Marker", 7, 100); + Assert.True(target.TryEquip(p, new int2(1, 1)), "port marker must fit an interior cell"); + Assert.True(target.TryEquip(s, new int2(3, 1)), "starboard marker must fit an interior cell"); + port = target.Equipment.Single(x => x.EquippableItem == p); + starboard = target.Equipment.Single(x => x.EquippableItem == s); + } + + if (equipShield) + { + var reactor = Make("Reactor", 8, 10); + Assert.True(target.TryFindSpace(reactor, out var rpos)); + Assert.True(target.TryEquip(reactor, rpos)); + var shield = Make("Shield", 9, 10); + Assert.True(target.TryFindSpace(shield, out var spos)); + Assert.True(target.TryEquip(shield, spos)); + } + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + shooter.Position = float3(37, 0, -11); + target.Position = float3(37, 0, 89); // 100 units straight ahead of the shooter + shooter.Target.Value = target; + shooter.EntityInfoGathered[target] = 1f; + shooter.SetIff(target, true); + if (equipShield) target.Shield.Item.Enabled.Value = true; + + zone.Update(0f); + + return new Engagement + { + Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, + Weapon = (InstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon), + PortMarker = port, StarboardMarker = starboard + }; + } + + private static void Charge(Engagement e) + { + for (var i = 0; i < 20; i++) e.Zone.Update(.1f); + } + + // ---- The shield pays for what it absorbs. ---- + + // Deleting Shield.TakeHit from FireControl.Apply survived every earlier suite, because they asserted only + // that an absorbed hit left the hull untouched -- never that the shield paid for it. A shield that absorbs + // must eventually run dry. Stepped with dt = 0 so the (near-instant) refill cannot top the reserve back up + // between shots. Mutation: delete TakeHit from the discrete path; the reserve never drains. + [Fact] + public void DiscreteShieldAbsorptionDrainsTheReserve() + { + var e = Build(damage: 5, equipShield: true, shieldCapacity: 30); + Charge(e); + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5)); + var hull = e.Target.Hull.Durability; + + var shots = 0; + while (e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5) && shots < 200) + { + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(0f); + shots++; + } + + Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5)); // the reserve ran dry + Assert.False(e.Target.Shield.Broken); // by paying, not by breaking + Assert.Equal(hull, e.Target.Hull.Durability); // and every hit was absorbed + } + + // The same rule on Splash's path, whose absorb branch had no test coverage at all. Mutation: delete + // TakeHit from Splash's absorb branch. + [Fact] + public void SplashShieldAbsorptionDrainsTheReserve() + { + var e = Build(equipShield: true, shieldCapacity: 30); + Charge(e); + Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5)); + var hull = e.Target.Hull.Durability; + + var blasts = 0; + while (e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5) && blasts < 200) + { + FireControl.Splash(e.Zone, e.Target.Position, radius: 20, damage: 5, damageType: DamageType.Kinetic); + blasts++; + } + + Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5)); + Assert.False(e.Target.Shield.Broken); + Assert.Equal(hull, e.Target.Hull.Durability); + } + + // ---- A shot whose target is gone resolves as a miss (Soul C4). ---- + + // The 0b table says a shot whose target has left the zone resolves as a miss "whichever stage it is at." + // Step only rewrote the outcome on the uncommitted branch, so a hit committed inside the horizon and then + // orphaned by the target dying was republished as a hit on ShotResolved -- a hit marker on a corpse. The + // committed outcome is not mutated (R4 keeps it immutable); the resolution is published as its own miss. + [Fact] + public void ShotAtTargetThatLeavesResolvesAsMiss() + { + var e = Build(velocity: 20); // 100 units at 20 u/s: flight 5 s, commits at 4.5 s + ShotOutcome committed = null, resolved = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + using var r = e.Zone.ShotResolved.Subscribe(o => resolved = o); + + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(4.7f); // past the commit, short of arrival + Assert.NotNull(committed); + Assert.True(committed.Hit, "precondition: this fixture's shot must commit as a hit"); + Assert.Null(resolved); + + e.Zone.Entities.Remove(e.Target); // the target is gone before the shot arrives + e.Zone.Update(1f); + + Assert.NotNull(resolved); + Assert.Equal(committed.ShotId, resolved.ShotId); + Assert.False(resolved.Hit, "a shot whose target left the zone must resolve as a miss, not publish its committed hit"); + Assert.True(committed.Hit, "the committed outcome itself is not rewritten (R4)"); + } + + // ---- Splash damages the side the blast came from. ---- + + // The operator's "damage on the wrong side of the ship." Flipping the sign in Splash's + // `right = float2(forward.y, -forward.x)` survived every earlier suite, and it survives this test too at + // the default facing (0, 1): the flip only changes which side is hit when a ship faces more along world x + // than z. So the target is spun through facings where the flip bites as well as ones where it does not, + // and blasted from each side in turn. The blast side is computed here independently of FireControl -- + // starboard is the facing rotated clockwise -- so this is not the code checking itself. + [Theory] + [InlineData(0f, 1f)] + [InlineData(1f, 0f)] + [InlineData(.8f, .6f)] + [InlineData(-.8f, -.6f)] + [InlineData(-.6f, .8f)] + public void SplashDamagesTheSideTheBlastCameFrom(float fx, float fy) + { + foreach (var fromStarboard in new[] { true, false }) + { + var e = Build(markers: true); + var facing = normalize(float2(fx, fy)); + e.Target.Direction = facing; + var starboard = float2(facing.y, -facing.x); + var side = fromStarboard ? starboard : -starboard; + var blast = e.Target.Position + float3(side.x, 0, side.y) * 10f; + + FireControl.Splash(e.Zone, blast, radius: 20, damage: 10, damageType: DamageType.Kinetic); + + var nearMarker = fromStarboard ? e.StarboardMarker : e.PortMarker; + var farMarker = fromStarboard ? e.PortMarker : e.StarboardMarker; + var label = $"facing ({fx}, {fy}), blast from {(fromStarboard ? "starboard" : "port")}"; + Assert.True(nearMarker.EquippableItem.Durability < 100, $"{label}: the marker facing the blast took no damage"); + Assert.Equal(100f, farMarker.EquippableItem.Durability); + } + } + + // ---- Zones roll differently. ---- + + // The roll is a function of (zone, shot id). Replacing `CombatSeed *` with `/` in Commit's seed drops the + // zone out of it -- integer division by a large constant is zero for almost every seed -- so every zone + // rolls the same sequence. That survived, because the die test only checks uniformity within one zone. + // Same shot ids in two zones must not produce the same hit sequence. + [Fact] + public void DifferentZonesRollDifferently() + { + bool[] Sequence(string zoneName) + { + var e = Build(zoneName: zoneName, accuracy: .5f); + var p = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); // accuracy times the other factors + var hits = new List(); + using var r = e.Zone.ShotResolved.Subscribe(o => hits.Add(o.Hit)); + for (var i = 0; i < 400; i++) + { + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(0f); + } + Assert.Equal(400, hits.Count); + var fraction = hits.Count(h => h) / 400f; + Assert.True(Math.Abs(fraction - p) < .08f, $"zone {zoneName}: hit fraction {fraction:F3} is not near p = {p:F3}"); + return hits.ToArray(); + } + + var a = Sequence("Rhea"); + var b = Sequence("Mimas"); + var differ = a.Zip(b, (x, y) => x != y).Count(d => d) / 400f; + // Two independent rolls at p ~ .4 disagree 2p(1-p) ~ 48% of the time; identical seeds disagree never. + Assert.True(differ > .3f, $"only {differ:P1} of shots differ between zones -- the zone is not reaching the roll"); + } +} From 1b187db45b35905b5af71b3194818b00387b271a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 16:20:45 +0200 Subject: [PATCH 6/8] Cut 11.3: fix the degenerate fixtures that made mutants indistinguishable Most of Stryker's surviving mutants in FireControl.cs traced to a handful of fixture constants that make a mutant compute exactly the same number as the original -- the same defect class as the zero-velocity target and the damage that happened to equal an item's durability, found here as a pattern. - The kernel fixture's shooter sat at float3.zero, where `target - source` and `target + source` are the same vector. HitProbabilityMatchesInspect gains a configuration with the shooter far from the origin: the correct separation is 500, in range; the mutated sum is 1300, out of range. - The kernel fixture's Resolution was 1000, which saturates pSensor to exactly 1 above the detection threshold, so multiplying and dividing by it agreed. Now 2. - EvasionCountsUntilCommitAndNotAfter fired at zone time 0, so `now - FireTime` equalled `now + FireTime`. Cut 5 gave this test a moving target to defend its own declared mutation and it still survived; the clock now advances first. - Every fixture used zero spread and a cell size of 1, leaving the whole spread term untested. PSpreadIsTheSilhouettesShareOfTheSpreadCone pins what pSpread means -- a silhouette filling the cone scores 1, half the cone scores .5, and a bigger one saturates -- rather than how it is computed. Triaged as equivalent, not chased: the float-threshold boundary flips, and the three MixSeed mutants that turn a right shift left -- measured, the die stays fair under all three (worst deviation from p .017-.030 against .022 correct), so killing them would mean pinning the mixer's bit arithmetic, not its behaviour. Recorded, not fixed: FireControl.Apply's derivation of hitDirection from the two positions has no geometry test through the FireControl path, and StatResolverTests.AModifierThatTargetsItsOwnMagnitudeStatIsRefusedAtEquip fails intermittently in the full suite -- likely cross-class pollution through CultCache's process-wide document registry. 241 tests pass. Co-Authored-By: Claude Opus 5 --- .../FireAuthorityTests.cs | 5 +++ .../FireControlCut11Tests.cs | 36 ++++++++++++++++--- .../FireControlCut9Tests.cs | 18 ++++++++-- 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs index ad9aaa2d1..e92de5a72 100644 --- a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs +++ b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs @@ -375,6 +375,11 @@ public sealed class FireAuthorityTests : IDisposable // tracking needed -- makes the mutation observable even with no jink at all: the mutant // mispredicts the intercept by Velocity * CommitHorizon, which the post-commit-jink assertion // below (expected to still hit) catches as a spurious miss. + // Cut 11: advance the zone clock before firing. Build leaves it at 0, so every shot fired with + // FireTime = 0 -- and `now - FireTime` equals `now + FireTime` when FireTime is 0. That is why the + // moving target Cut 5 added still left this test's own declared mutation (measure elapsed wrongly) + // alive: Stryker found `now + shot.FireTime` surviving. + e.Zone.Update(2.5f); e.Target.Velocity = float2(4, 0); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); var t = 0f; diff --git a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs index 7213f2f4b..a4d9172fc 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs @@ -34,7 +34,7 @@ public sealed class FireControlCut11Tests : IDisposable Directory.Delete(_root, true); } - private static GameplaySettings TestSettings() => new GameplaySettings + private static GameplaySettings TestSettings(float schematicCellSize = 1f) => new GameplaySettings { DefaultEntitySettings = new EntitySettings(), Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, @@ -44,7 +44,7 @@ public sealed class FireControlCut11Tests : IDisposable TargetGearInfoThreshold = .8f, FiringArc = 170, CommitHorizon = .5f, - SchematicCellSize = 1f, + SchematicCellSize = schematicCellSize, UnaidedAccuracy = .05f, UnaidedTracking = 10f, UnaidedPrecision = 2f, @@ -79,7 +79,7 @@ public sealed class FireControlCut11Tests : IDisposable string zoneName = "Cut11", float damage = 5, float velocity = 0, float accuracy = 1, bool equipShield = false, float shieldCapacity = 30, - bool markers = false) + bool markers = false, float spread = 0, float schematicCellSize = 1f) { // 5 wide x 3 tall: schematic x runs port (0) to starboard (4). Tool gear may only occupy interior // cells, which on this hull is the middle row x = 1..3, so the markers sit at (1, 1) and (3, 1) -- @@ -101,7 +101,7 @@ public sealed class FireControlCut11Tests : IDisposable Behaviors = { new InstantWeaponData { Damage = Constant(damage), Range = Constant(1000), MinRange = Constant(0), - Velocity = Constant(velocity), Spread = Constant(0), DamageSpread = Constant(0), + Velocity = Constant(velocity), Spread = Constant(spread), 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) } } } } @@ -136,7 +136,7 @@ public sealed class FireControlCut11Tests : IDisposable var ledger = new ProvenanceLedger(); for (var i = 1; i <= 16; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; - var items = new ItemManager(cache, ledger, TestSettings(), _ => { }); + var items = new ItemManager(cache, ledger, TestSettings(schematicCellSize), _ => { }); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = zoneName, Owner = null }, null); EquippableItem Make(string name, int lot, float durability) => new EquippableItem @@ -317,6 +317,32 @@ public sealed class FireControlCut11Tests : IDisposable } } + // ---- pSpread is the share of the spread cone the target's silhouette covers. ---- + + // Every earlier fixture used zero spread (pSpread is then 1 regardless) and a cell size of exactly 1 (so + // multiplying and dividing by it agree), which left the whole spread term untested. This pins what the + // number MEANS rather than how it is computed: a silhouette that exactly fills the cone scores 1, one half + // the cone's angular size scores .5, and one bigger than the cone saturates at 1. Here the 5-wide hull at a + // cell size of 2 has a half-extent of 5 world units, against a 10-degree cone (5-degree half-angle). + [Fact] + public void PSpreadIsTheSilhouettesShareOfTheSpreadCone() + { + const float halfExtent = 5f, halfCone = 5f; + float At(float range) + { + var e = Build(spread: 2 * halfCone, schematicCellSize: 2f); + e.Target.Position = e.Shooter.Position + float3(0, 0, range); + return FireControl.Inspect(e.Weapon, e.Shooter, e.Target).PSpread; + } + + var fills = halfExtent / (float) Math.Tan(halfCone * Math.PI / 180); // silhouette == cone + var half = halfExtent / (float) Math.Tan(halfCone / 2 * Math.PI / 180); // silhouette == half the cone + Assert.Equal(1f, At(fills), 2); + Assert.Equal(.5f, At(half), 2); + Assert.Equal(1f, At(fills / 3), 2); // bigger than the cone: saturates, never exceeds 1 + Assert.True(At(half * 2) < At(half)); // and falls away with range + } + // ---- Zones roll differently. ---- // The roll is a function of (zone, shot id). Replacing `CombatSeed *` with `/` in Commit's seed drops the diff --git a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs index 06909df68..acdeeb48d 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs @@ -285,7 +285,10 @@ public sealed class FireControlCut9Tests : IDisposable MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, Behaviors = { new TargetingSystemData { - Accuracy = Constant(1f), Resolution = Constant(1000f), Precision = Constant(precision), Tracking = Constant(100000f) + // Cut 11: a finite Resolution, so pSensor actually varies with info. At 1000 it saturated to exactly 1 + // for any info above the detection threshold, which made `x * pSensor` and `x / pSensor` the same + // number and let a mutant of Inspect's product survive HitProbabilityMatchesInspect. + Accuracy = Constant(1f), Resolution = Constant(2f), Precision = Constant(precision), Tracking = Constant(100000f) } } }); cache.FlushAsync().Wait(); @@ -418,6 +421,17 @@ public sealed class FireControlCut9Tests : IDisposable Check("behind the mount"); e.Target.Position = float3(0, 0, 100); + // Cut 11: a shooter far from the origin. Every other configuration keeps the shooter at float3.zero, + // where `target - source` and `target + source` are the same vector; here the correct separation is + // 500 (in range) while the mutated sum is 1300 (out of range), so a sign mutant in either caller's + // bearing makes the two disagree. + var home = e.Shooter.Position; + e.Shooter.Position = float3(600, 0, 0); + e.Target.Position = float3(600, 0, 500); + Check("shooter far from the origin"); + e.Shooter.Position = home; + e.Target.Position = float3(0, 0, 100); + e.Shooter.VisibleEntities.Remove(e.Target); Check("not visible"); @@ -425,7 +439,7 @@ public sealed class FireControlCut9Tests : IDisposable var nullHud = FireControl.Inspect(e.Weapon, e.Shooter, null).PBase; Assert.True(nullHot == nullHud, $"no target: {nullHot} != {nullHud}"); } - Assert.Equal(21, checkedConfigurations); // 3 precisions x (4 info levels + out of range + behind + not visible) + Assert.Equal(24, checkedConfigurations); // 3 precisions x (4 info levels + out of range + behind + far shooter + not visible) } // The cost regression itself. A target out of range is the common case for every AI and turret, and it From b0fd3cd06c7715040584f70a958ec9d714739bd5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 16:39:52 +0200 Subject: [PATCH 7/8] Cut 11 second round: one owner for PBase, and tests for what the 51 survivors showed Inspect.PBase is HitProbability's own answer instead of a second product of the factors. New behavioural tests pin a beam shot carrying its override damage, a fitted targeting system forgiving a jink, a shot orphaned before commit committing as a plain miss, and the HUD's arrival countdown. The tautological damage assertion in BeamRollsPerInterval is deleted. Stryker on FireControl.cs: 51 survivors to 43, all triaged in the map. Co-Authored-By: Claude Opus 5 --- Assets/Scripts/ServerShared/FireControl.cs | 8 +- .../FireControlCut11Tests.cs | 73 ++++++++++++++++- .../FireControlCut4Tests.cs | 3 +- .../FireControlCut9Tests.cs | 80 ++++++++++--------- 4 files changed, 121 insertions(+), 43 deletions(-) diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 4ba600de2..2729705fb 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -170,7 +170,8 @@ public static class FireControl // Presentation only: the debug HUD's view of exactly the factors HitProbability multiplies. It computes // every factor unconditionally, gates included, because a HUD wants to see why a shot is impossible -- // which is precisely why the hot path must not be routed through it. Both read the same factor - // functions below, so the HUD cannot grow a second, subtly different model. + // functions below, and PBase is HitProbability's own answer, so the gates and the product have one + // owner and the HUD cannot grow a second, subtly different model. public static FireControlDiagnostic Inspect(Weapon weapon, Entity source, Entity target) { var settings = source.ItemManager.GameplaySettings; @@ -200,8 +201,7 @@ public static class FireControl diagnostic.PSpread = PSpread(weapon, targetHull, diagnostic.Range, settings); diagnostic.POnHull = POnHull(source, target, targetHull, diagnostic.Precision); - if (diagnostic.Visible && diagnostic.InRange && diagnostic.Locked && diagnostic.InArc) - diagnostic.PBase = diagnostic.Accuracy * diagnostic.PSensor * diagnostic.PSpread * diagnostic.POnHull; + diagnostic.PBase = HitProbability(weapon, source, target); return diagnostic; } @@ -260,7 +260,7 @@ public static class FireControl // R1's engage gate and probability, evaluated now and frozen: nothing at commit time re-reads a stat, // an info level or a range. Only live target *position* (deviation) is read again, at commit. - var pBase = target != null ? HitProbability(weapon, source, target) : 0f; + var pBase = HitProbability(weapon, source, target); var targetVelocity = float3.zero; var targetPosition = source.Position; diff --git a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs index a4d9172fc..8e9d042c3 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs @@ -264,7 +264,8 @@ public sealed class FireControlCut11Tests : IDisposable { var e = Build(velocity: 20); // 100 units at 20 u/s: flight 5 s, commits at 4.5 s ShotOutcome committed = null, resolved = null; - using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + var commits = 0; + using var c = e.Zone.ShotCommitted.Subscribe(o => { committed = o; commits++; }); using var r = e.Zone.ShotResolved.Subscribe(o => resolved = o); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); @@ -272,6 +273,8 @@ public sealed class FireControlCut11Tests : IDisposable Assert.NotNull(committed); Assert.True(committed.Hit, "precondition: this fixture's shot must commit as a hit"); Assert.Null(resolved); + // What the HUD's arrival countdown reads: time left in flight at commit, 5 s - 4.7 s. + Assert.Equal(.3f, committed.ArrivalIn, 3); e.Zone.Entities.Remove(e.Target); // the target is gone before the shot arrives e.Zone.Update(1f); @@ -280,6 +283,74 @@ public sealed class FireControlCut11Tests : IDisposable Assert.Equal(committed.ShotId, resolved.ShotId); Assert.False(resolved.Hit, "a shot whose target left the zone must resolve as a miss, not publish its committed hit"); Assert.True(committed.Hit, "the committed outcome itself is not rewritten (R4)"); + Assert.Equal(1, commits); // a shot commits once; its orphaned resolution is not a second commit + } + + // The other stage of the same rule: a target gone before the shot even commits. The shot never got its + // commit, so the miss is published as the commit too -- a listener waiting on ShotCommitted for this + // ShotId must not wait forever -- and it is a plain miss, with no shield hit or shield break attached. + [Fact] + public void ShotAtTargetThatLeavesBeforeCommitCommitsAsAMiss() + { + var e = Build(velocity: 20, equipShield: true); // commits at 4.5 s + Charge(e); + var committed = new List(); + var resolved = new List(); + using var c = e.Zone.ShotCommitted.Subscribe(committed.Add); + using var r = e.Zone.ShotResolved.Subscribe(resolved.Add); + + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(1f); + Assert.Empty(committed); + + e.Zone.Entities.Remove(e.Target); + e.Zone.Update(1f); + + foreach (var outcome in new[] { Assert.Single(committed), Assert.Single(resolved) }) + { + Assert.Equal(shotId, outcome.ShotId); + Assert.False(outcome.Hit); + Assert.False(outcome.Shielded, "a shot at nothing did not hit a shield"); + Assert.False(outcome.ShieldBroken, "a shot at nothing did not break a shield"); + } + Assert.Empty(e.Zone.PendingShots); + } + + // ---- A targeting system's Tracking is what forgives a target that moves off its predicted line. ---- + + // Tracking fell back to GameplaySettings.UnaidedTracking under a mutant that ignored the fitted system, + // and nothing noticed: every fixture's target sat still, so deviation was 0 and Tracking divided nothing. + // Here the target jinks 5 units sideways mid-flight. The fitted system (Tracking 100000) shrugs it off; + // unaided Tracking (10) would halve the shot's chance. + [Fact] + public void AFittedTargetingSystemForgivesAJink() + { + var e = Build(velocity: 20); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(1f); + e.Target.Position += float3(5, 0, 0); + + var shot = e.Zone.PendingShots.Single(); + var p = FireControl.DeviationProbability(shot, e.Zone.Time, out var deviation); + Assert.Equal(5f, deviation, 3); + Assert.True(p > .99f, $"a 5-unit jink against Tracking 100000 must barely matter, got {p}"); + } + + // ---- A beam's shot carries the interval's damage, not the weapon's. ---- + + // ConstantWeapon fires one shot per BeamResolveInterval for Damage * interval, through Fire's + // damageOverride. Dropping the override froze the weapon's full Damage into every one of those shots -- + // a beam doing ten times its damage at the default interval -- and survived, because the beam test + // counted its rolls and multiplied the count by the expected damage itself. + [Fact] + public void ADamageOverrideIsWhatTheShotCarries() + { + var e = Build(damage: 5, velocity: 20); + var full = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var partial = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter, damageOverride: .5f); + + Assert.Equal(5f, e.Zone.PendingShots.Single(s => s.ShotId == full).Damage); + Assert.Equal(.5f, e.Zone.PendingShots.Single(s => s.ShotId == partial).Damage); } // ---- Splash damages the side the blast came from. ---- diff --git a/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs index efb66b5f2..2fbc5e52d 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs @@ -171,7 +171,7 @@ public sealed class FireControlCut4Tests : IDisposable } // A beam is a sequence of discrete rolls, not a continuous truth: over one second it produces exactly - // 1/BeamResolveInterval outcomes, each for Damage*BeamResolveInterval, totalling Damage. Interval (.25s) + // 1/BeamResolveInterval outcomes (the damage each carries is FireControlCut11Tests.ADamageOverrideIsWhatTheShotCarries). Interval (.25s) // and tick (.125s) are both exact dyadic fractions so float accumulation cannot drift the count -- and the // tick is strictly smaller than the interval, so the two are distinguishable from a "one roll per tick" // mutant (which would produce 8 outcomes here, not 4) and from a "one roll per burst" mutant (1, not 4). @@ -190,7 +190,6 @@ public sealed class FireControlCut4Tests : IDisposable Assert.Equal(4, outcomes.Count); // 1s / .25s per roll Assert.All(outcomes, o => Assert.True(o.Hit)); - Assert.Equal(10f, outcomes.Count * 10f * settings.BeamResolveInterval, 2); // Damage * interval, per roll } // ---- Regression: FireControl.Fire must run whether or not a presentation is listening. Both diff --git a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs index acdeeb48d..3e981bbf3 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs @@ -255,7 +255,7 @@ public sealed class FireControlCut9Tests : IDisposable public Weapon Weapon; } - private KernelEngagement BuildKernelEngagement(GameplaySettings settings, Shape hullShape, float precision) + private KernelEngagement BuildKernelEngagement(GameplaySettings settings, Shape hullShape, float precision, float spread = 0) { var hullData = new HullData { @@ -274,7 +274,7 @@ public sealed class FireControlCut9Tests : IDisposable Behaviors = { new InstantWeaponData { Damage = Constant(10), Range = Constant(1000), MinRange = Constant(0), - Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(0), + Velocity = Constant(0), Spread = Constant(spread), 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) } } } } @@ -386,60 +386,68 @@ public sealed class FireControlCut9Tests : IDisposable // Soul pass, on paths that used to bail in nanoseconds. Both now call the same factor functions. // --------------------------------------------------------------------------------------------------- - // The invariant the diagnostics refactor was protecting, made structural rather than conventional: the - // HUD can never report a probability the simulation does not use. Swept across every gate the hot path - // bails on -- no target, not visible, out of range, out of arc -- and across Precision and info levels - // for shots that pass. Mutation: change a factor in only one of the two callers (drop POnHull from - // HitProbability); the passing configurations then disagree. + // The invariant the diagnostics refactor was protecting: the HUD can never report a probability the + // simulation does not use. Inspect.PBase is HitProbability's own answer, so the gates and the product + // have one owner; what is left to defend is that the factors the HUD displays are the ones the + // simulation multiplies, and that a gate the HUD shows closed is one the simulation refuses on. Swept + // across Precision, info levels and a spread cone that does not fill the silhouette -- every factor + // strictly between 0 and 1 somewhere, so a factor dropped, doubled or divided in either caller shows. [Fact] - public void HitProbabilityMatchesInspect() + public void TheHudShowsTheFactorsTheSimulationMultiplies() { var settings = TestSettings(); var checkedConfigurations = 0; + var sawPartialSpread = false; foreach (var precision in new[] { .3f, 1.19f, 5f }) { - var e = BuildKernelEngagement(settings, SolidShape(6, 12), precision); + // 6 cells wide at cell size 1 subtends ~3.4 degrees at 100 units, inside an 8-degree cone. + var e = BuildKernelEngagement(settings, SolidShape(6, 12), precision, spread: 8); - void Check(string label) - { - var hot = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); - var hud = FireControl.Inspect(e.Weapon, e.Shooter, e.Target).PBase; - Assert.True(hot == hud, $"{label}, Precision {precision}: HitProbability {hot} != Inspect.PBase {hud}"); - checkedConfigurations++; - } - - foreach (var info in new[] { 0f, .2f, .5f, 1f }) + foreach (var info in new[] { .2f, .5f, 1f }) { e.Shooter.EntityInfoGathered[e.Target] = info; - Check($"in range, info {info}"); + var hud = FireControl.Inspect(e.Weapon, e.Shooter, e.Target); + var hot = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); + Assert.True(hud.Visible && hud.InRange && hud.Locked && hud.InArc, $"info {info}: precondition, every gate open"); + Assert.True(hot > 0f, $"info {info}, Precision {precision}: precondition, a shot that can land"); + Assert.Equal(hot, hud.Accuracy * hud.PSensor * hud.PSpread * hud.POnHull, 5); + sawPartialSpread |= hud.PSpread < .99f; + checkedConfigurations++; } e.Shooter.EntityInfoGathered[e.Target] = 1f; - e.Target.Position = float3(0, 0, 5000); - Check("out of range"); - e.Target.Position = float3(0, 0, -100); - Check("behind the mount"); - e.Target.Position = float3(0, 0, 100); + void Closed(string label, Func gate) + { + var hud = FireControl.Inspect(e.Weapon, e.Shooter, e.Target); + Assert.False(gate(hud), $"{label}: the HUD must show this gate closed"); + Assert.Equal(0f, FireControl.HitProbability(e.Weapon, e.Shooter, e.Target)); + checkedConfigurations++; + } - // Cut 11: a shooter far from the origin. Every other configuration keeps the shooter at float3.zero, - // where `target - source` and `target + source` are the same vector; here the correct separation is - // 500 (in range) while the mutated sum is 1300 (out of range), so a sign mutant in either caller's - // bearing makes the two disagree. - var home = e.Shooter.Position; + e.Target.Position = float3(0, 0, 5000); + Closed("out of range", d => d.InRange); + e.Target.Position = float3(0, 0, -100); + Closed("behind the mount", d => d.InArc); + + // A shooter far from the origin: at float3.zero `target - source` and `target + source` are the + // same vector, so a sign mutant in the bearing only shows here (500 in range vs 1300 out). e.Shooter.Position = float3(600, 0, 0); e.Target.Position = float3(600, 0, 500); - Check("shooter far from the origin"); - e.Shooter.Position = home; + var far = FireControl.Inspect(e.Weapon, e.Shooter, e.Target); + Assert.Equal(500f, far.Range, 3); + Assert.True(far.InRange && FireControl.HitProbability(e.Weapon, e.Shooter, e.Target) > 0f, "shooter far from the origin"); + checkedConfigurations++; + e.Shooter.Position = float3.zero; e.Target.Position = float3(0, 0, 100); e.Shooter.VisibleEntities.Remove(e.Target); - Check("not visible"); + Closed("not visible", d => d.Visible); - var nullHot = FireControl.HitProbability(e.Weapon, e.Shooter, null); - var nullHud = FireControl.Inspect(e.Weapon, e.Shooter, null).PBase; - Assert.True(nullHot == nullHud, $"no target: {nullHot} != {nullHud}"); + Assert.False(FireControl.Inspect(e.Weapon, e.Shooter, null).HasTarget); + Assert.Equal(0f, FireControl.HitProbability(e.Weapon, e.Shooter, null)); } - Assert.Equal(24, checkedConfigurations); // 3 precisions x (4 info levels + out of range + behind + far shooter + not visible) + Assert.True(sawPartialSpread, "fixture: the spread cone must not fill the silhouette, or PSpread is 1 and proves nothing"); + Assert.Equal(21, checkedConfigurations); // 3 precisions x (3 info levels + out of range + behind + far shooter + not visible) } // The cost regression itself. A target out of range is the common case for every AI and turret, and it From e8fbb6a6afd3fe7cb35d1a9b61be42549c9a61d8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 16:40:19 +0200 Subject: [PATCH 8/8] Cut 11 status: Stryker progression and the triage of the 43 survivors Co-Authored-By: Claude Opus 5 --- docs/fire-control-cut.md | 54 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/docs/fire-control-cut.md b/docs/fire-control-cut.md index 76c3742d6..84934cb75 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -1794,3 +1794,57 @@ spread. Fix the fixture, never weaken the assertion. Stryker over `FireControl.cs` before and after: every non-equivalent survivor above killed, the remainder triaged by name. 231 tests plus the new ones; Unity batchmode clean. + +### Status (2026-09-22) + +Landed on `codex/fire-control-10`: `8ef3b34c` (Stryker tool), `b305debd` (map, +config), `724ec5e7` (11.1), `a6543af7` (11.4 and the C4 fix), `1b187db4` (11.3), +`b0fd3cd0` (second round, below). 244 tests. + +Stryker over `FireControl.cs`, 278 mutants per run: + +| Run | Killed | Survived | Score | +|---|---|---|---| +| Spike, before Cut 11 | 208 | 67 | 74.6% | +| After 11.4 | 219 | 58 | 78.6% | +| After 11.3 fixtures | 225 | 51 | 81.1% | +| After the second round | 225 | 43 | 83.3% | + +**The second round** came from reading the 51. Four were real undefended +behaviour and one was a duplicated formula: + +- `Inspect` recomputed `PBase` as its own product of the factors, so the gates and + the product had two owners, and a `*`→`/` mutant of the HUD's copy survived the + agreement test (every fixture had spread 0, so `PSpread` was 1). `PBase` is now + `HitProbability`'s own answer; the test became + `TheHudShowsTheFactorsTheSimulationMultiplies`, over a spread cone that does not + fill the silhouette. `Fire`'s redundant `target != null ?` guard around + `HitProbability` went with it. +- A beam shot's `damageOverride` could be dropped: `BeamRollsPerInterval` computed + the expected damage from its own roll count, a tautology, now deleted. + `ADamageOverrideIsWhatTheShotCarries`. +- `Tracking` could ignore the fitted targeting system: no fixture's target ever + moved off its predicted line. `AFittedTargetingSystemForgivesAJink`. +- A shot orphaned before commit could skip `ShotCommitted`, or publish its miss as + shielded. `ShotAtTargetThatLeavesBeforeCommitCommitsAsAMiss`. +- `ArrivalIn` (the HUD countdown) was unchecked; asserted in the C4 test. + +**The 43 that remain, triaged:** + +- *Boundary flips, equivalent (23):* L52, 54, 56, 75, 135 (two), 157 (two), 193 + (two), 223, 273, 410, 418 (two), 465 (two), 503, 518, 524, 586, 605, 608. +- *`MixSeed` shift and xor weakenings, measured equivalent (4):* L382, 384, 386 + (two). 11.4 asked for them to die; measured, the die stays uniform and + zone-distinct under each, so there is no behaviour to pin. They change how the + mixer is built, not whether the die is fair. +- *Unreachable through the live callers (8):* L240 and L241 + (`DeviationProbability`'s null-target branch; the engine guards it, only the HUD + can pass null), L283 (three; airburst has no authored radius anywhere, recorded + since Cut 6b), L320 (empty-list early return, an optimisation), L410 `||` (a + nonzero `PBase` implies a target), L158 and L610 (no coverage: an unlocked lock + weapon reaching this line, and the last-cell fallback of the weighted pick). +- *Recorded gaps, not yet reached (8):* L135 ternary (two; no test that the AI in + `Combat.cs` leads a moving target), L435 (three; the aimed flag on an outcome), + L464 and L465 negate (the `Apply` hit direction through the `FireControl` + path), L585 and L587 (the aimed item's centroid as aim point). These are "not + yet reached," not "unreachable."