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/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index d147dcb4b..2729705fb 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -141,13 +141,37 @@ 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, 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; @@ -170,44 +194,45 @@ 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.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); - 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; - - 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; } + // 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; @@ -235,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; @@ -303,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/docs/fire-control-cut.md b/docs/fire-control-cut.md index f1e4a3577..84934cb75 100644 --- a/docs/fire-control-cut.md +++ b/docs/fire-control-cut.md @@ -1697,3 +1697,154 @@ 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 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 +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. + +### 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." 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 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 new file mode 100644 index 000000000..8e9d042c3 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs @@ -0,0 +1,449 @@ +/* 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(float schematicCellSize = 1f) => 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 = schematicCellSize, + 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, 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) -- + // 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(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) } } + } } + }); + 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(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 + { + 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; + 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); + 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); + // 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); + + 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)"); + 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. ---- + + // 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); + } + } + + // ---- 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 + // 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"); + } +} 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 3b5b9c7ee..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) } } } } @@ -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(); @@ -374,6 +377,99 @@ 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: 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 TheHudShowsTheFactorsTheSimulationMultiplies() + { + var settings = TestSettings(); + var checkedConfigurations = 0; + var sawPartialSpread = false; + foreach (var precision in new[] { .3f, 1.19f, 5f }) + { + // 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); + + foreach (var info in new[] { .2f, .5f, 1f }) + { + e.Shooter.EntityInfoGathered[e.Target] = 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; + + 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++; + } + + 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); + 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); + Closed("not visible", d => d.Visible); + + Assert.False(FireControl.Inspect(e.Weapon, e.Shooter, null).HasTarget); + Assert.Equal(0f, FireControl.HitProbability(e.Weapon, e.Shooter, null)); + } + 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 + // 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. 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 } + } +} 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())