Merge Cuts 10 and 11: the hot path gates first, and mutation testing moves to Stryker

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-22 16:41:11 +02:00
commit cd846916bc
32 changed files with 803 additions and 5864 deletions

3
.gitignore vendored
View file

@ -109,3 +109,6 @@ tests/_scratch_*
.nuget-sandbox/
.dotnet-sandbox/
.appdata-sandbox/
# Stryker.NET mutation reports (regenerated per run)
StrykerOutput/

View file

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

View file

@ -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=<admitted CultLib worktree> dotnet stryker --since:<base commit>
**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."

13
dotnet-tools.json Normal file
View file

@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-stryker": {
"version": "5.0.0",
"commands": [
"dotnet-stryker"
],
"rollForward": false
}
}
}

View file

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

View file

@ -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<CultCache> _openCaches = new List<CultCache>();
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<ItemData>(items.ItemData.GetByName<GearData>(name)), Durability = durability, Lot = lot
};
var hullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("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<ShotOutcome>();
var resolved = new List<ShotOutcome>();
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<bool>();
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");
}
}

View file

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

View file

@ -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<FireControlDiagnostic, bool> 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.

View file

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

View file

@ -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 <path-to-CultLib-a0813c6-worktree>
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<FactionProductData>()\n"
" .Where(p => p.Manufacturer.Key.Equals(maker.Key) && p.Design.Key.Equals(item.Data.Key))"
),
mutated=(
" var product = ItemData.GetAll<FactionProductData>()\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<FactionProductData>().OrderBy(p => cache.RefOf(p).Key.Value, StringComparer.Ordinal).ToArray();",
mutated="var products = cache.GetAll<FactionProductData>().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<EntityPack>())",
mutated=" var roots = zones.Take(1)\n .SelectMany(zone => zone.Contents?.Entities ?? new List<EntityPack>())",
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<CraftedItemData>(lance.Key), Manufacturer = cache.RefOf(faction) });"
),
mutated=(
'cache.Upsert(new FactionProductData { Name = "Lance", '
"Design = new CultRecordRef<CraftedItemData>(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<EntitySettings>(MessagePackSerializer.Serialize(settings));",
mutated=" Settings = MessagePackSerializer.Deserialize<EntitySettings>(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<EntitySettings>(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())

View file

@ -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 <path-to-CultLib-45c2f40-worktree>
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())

View file

@ -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 <path-to-CultLib-worktree>
#
# Every mutation is reversed (byte-exact, from an on-disk copy) before the script exits, including on failure
# or interrupt, and every restore is verified against a sha256 recorded before any mutation ran: the tree-
# clean verdict at the end states PASS/FAIL explicitly rather than leaving a reader to infer it. A SIGKILL of
# the whole process group is the one termination bash cannot trap at all -- verify the tree independently
# after a run that was killed that hard.
set -u
if [ $# -lt 1 ]; then
echo "Usage: $0 <cultlib-root>" >&2
exit 2
fi
CULTLIB_ROOT="$1"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
TEST_PROJECT="$REPO_ROOT/tests/Aetheria.Shared.Tests"
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

View file

@ -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 <path-to-CultLib-worktree>
#
# Every mutation is reversed (byte-exact, from an on-disk copy) before the script exits, including on failure
# or interrupt, and every restore is verified against a sha256 recorded before any mutation ran: the tree-
# clean verdict at the end states PASS/FAIL explicitly rather than leaving a reader to infer it. A SIGKILL of
# the whole process group is the one termination bash cannot trap at all -- verify the tree independently
# after a run that was killed that hard.
set -u
if [ $# -lt 1 ]; then
echo "Usage: $0 <cultlib-root>" >&2
exit 2
fi
CULTLIB_ROOT="$1"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
TEST_PROJECT="$REPO_ROOT/tests/Aetheria.Shared.Tests"
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<TargetingSystem>();\r\n return system != null && system.Item.Active.Value\r\n ? system.Accuracy\r\n : entity.ItemManager.GameplaySettings.UnaidedAccuracy;' \
$' var system = entity.GetBehavior<TargetingSystem>();\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<GearData>(2, item =>\r\n item.Behaviors.Any(b => b is TargetingSystemData) &&\r\n item.Shape.FitsWithin(emptyShape, out _, out _));' \
$' var (targetingProduct, targetingData) = RandomProduct<GearData>(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

View file

@ -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 <path-to-CultLib-worktree>
#
# Every mutation is reversed (byte-exact, from an on-disk copy) before the script exits, including on failure
# or interrupt, and every restore is verified against a sha256 recorded before any mutation ran: the tree-
# clean verdict at the end states PASS/FAIL explicitly rather than leaving a reader to infer it. A SIGKILL of
# the whole process group is the one termination bash cannot trap at all -- verify the tree independently
# after a run that was killed that hard.
set -u
if [ $# -lt 1 ]; then
echo "Usage: $0 <cultlib-root>" >&2
exit 2
fi
CULTLIB_ROOT="$1"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
TEST_PROJECT="$REPO_ROOT/tests/Aetheria.Shared.Tests"
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

View file

@ -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 <path-to-CultLib-45c2f40-worktree>
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())

View file

@ -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 <path-to-CultLib-45c2f40-worktree>
#
# Every mutation is reversed (byte-exact, from an on-disk copy) before the script exits, including on failure
# or interrupt, and every restore is verified against a sha256 recorded before any mutation ran: the tree-
# clean verdict at the end states PASS/FAIL explicitly rather than leaving a reader to infer it. A SIGKILL of
# the whole process group is the one termination bash cannot trap at all -- verify the tree independently
# after a run that was killed that hard.
set -u
if [ $# -lt 1 ]; then
echo "Usage: $0 <cultlib-root>" >&2
exit 2
fi
CULTLIB_ROOT="$1"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
TEST_PROJECT="$REPO_ROOT/tests/Aetheria.Shared.Tests"
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

View file

@ -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 <path-to-CultLib-worktree>
#
# Every mutation is reversed (byte-exact, from an on-disk copy) before the script exits, including on failure
# or interrupt, and every restore is verified against a sha256 recorded before any mutation ran: the tree-
# clean verdict at the end states PASS/FAIL explicitly rather than leaving a reader to infer it. A SIGKILL of
# the whole process group is the one termination bash cannot trap at all -- verify the tree independently
# after a run that was killed that hard.
set -u
if [ $# -lt 1 ]; then
echo "Usage: $0 <cultlib-root>" >&2
exit 2
fi
CULTLIB_ROOT="$1"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
TEST_PROJECT="$REPO_ROOT/tests/Aetheria.Shared.Tests"
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

View file

@ -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 <path-to-CultLib-45c2f40-worktree>
#
# Every mutation is reversed (byte-exact, from an on-disk copy) before the script exits, including on failure
# or interrupt, and every restore is verified against a sha256 recorded before any mutation ran: the tree-
# clean verdict at the end states PASS/FAIL explicitly rather than leaving a reader to infer it. A SIGKILL of
# the whole process group is the one termination bash cannot trap at all -- verify the tree independently
# after a run that was killed that hard.
set -u
if [ $# -lt 1 ]; then
echo "Usage: $0 <cultlib-root>" >&2
exit 2
fi
CULTLIB_ROOT="$1"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
TEST_PROJECT="$REPO_ROOT/tests/Aetheria.Shared.Tests"
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<GearData>(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<GearData>(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

View file

@ -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 <path-to-CultLib-45c2f40-worktree>
#
# Every mutation is reversed (byte-exact, from an on-disk copy) before the script exits, including on failure
# or interrupt, and every restore is verified against a sha256 recorded before any mutation ran: the tree-
# clean verdict at the end states PASS/FAIL explicitly rather than leaving a reader to infer it. A SIGKILL of
# the whole process group is the one termination bash cannot trap at all -- verify the tree independently
# after a run that was killed that hard.
#
# Run only one mutation harness at a time against this tree -- concurrent harnesses corrupt each other's
# snapshots (docs/fire-control-cut.md's own campaign has hit this).
set -u
if [ $# -lt 1 ]; then
echo "Usage: $0 <cultlib-root>" >&2
exit 2
fi
CULTLIB_ROOT="$1"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
TEST_PROJECT="$REPO_ROOT/tests/Aetheria.Shared.Tests"
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

View file

@ -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 <path-to-CultLib-45c2f40-worktree>
#
# Every mutation is reversed (byte-exact, from an on-disk copy) before the script exits, including on failure
# or interrupt, and every restore is verified against a sha256 recorded before any mutation ran: the tree-
# clean verdict at the end states PASS/FAIL explicitly rather than leaving a reader to infer it. A SIGKILL of
# the whole process group is the one termination bash cannot trap at all -- verify the tree independently
# after a run that was killed that hard.
#
# Run only one mutation harness at a time against this tree -- concurrent harnesses corrupt each other's
# snapshots (docs/fire-control-cut.md's own campaign has hit this).
set -u
if [ $# -lt 1 ]; then
echo "Usage: $0 <cultlib-root>" >&2
exit 2
fi
CULTLIB_ROOT="$1"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
TEST_PROJECT="$REPO_ROOT/tests/Aetheria.Shared.Tests"
ENTITY_CS="$REPO_ROOT/Assets/Scripts/ServerShared/Entity.cs"
ZONE_CS="$REPO_ROOT/Assets/Scripts/ServerShared/Zone.cs"
# --- byte-exact backup/restore: preserves line endings exactly, no text-mode translation. ---
declare -A ORIGINALS
declare -A ORIGINAL_HASH
FILES=("$ENTITY_CS" "$ZONE_CS")
BACKUP_DIR="$(mktemp -d)"
hash_file() {
sha256sum "$1" 2>/dev/null | awk '{print $1}'
}
for f in "${FILES[@]}"; do
base="$(basename "$f")"
cp -p "$f" "$BACKUP_DIR/$base.orig"
ORIGINALS["$f"]="$BACKUP_DIR/$base.orig"
ORIGINAL_HASH["$f"]="$(hash_file "$f")"
done
restore_and_verify() {
local file="$1"
cp -p "${ORIGINALS[$file]}" "$file"
local actual
actual="$(hash_file "$file")"
if [ "$actual" != "${ORIGINAL_HASH[$file]}" ]; then
echo "" >&2
echo "FATAL: restoring $file did not reproduce its pre-mutation hash -- aborting." >&2
echo " expected sha256: ${ORIGINAL_HASH[$file]}" >&2
echo " actual sha256: $actual" >&2
exit 1
fi
}
TREE_CLEAN_OK=1
TREE_VERDICT_DONE=0
verify_tree_clean() {
if [ "$TREE_VERDICT_DONE" = "1" ]; then return; fi
local f actual
for f in "${FILES[@]}"; do
cp -p "${ORIGINALS[$f]}" "$f" 2>/dev/null
actual="$(hash_file "$f")"
if [ "$actual" != "${ORIGINAL_HASH[$f]}" ]; then
echo "FATAL: $f is NOT byte-identical to how this script found it." >&2
echo " expected sha256: ${ORIGINAL_HASH[$f]}" >&2
echo " actual sha256: $actual" >&2
TREE_CLEAN_OK=0
fi
done
echo ""
if [ "$TREE_CLEAN_OK" = "1" ]; then
echo "=== tree-clean verdict: PASS -- every target file is byte-identical to how this script found it ==="
else
echo "=== tree-clean verdict: FAIL -- see FATAL lines above; the working tree is contaminated ==="
fi
TREE_VERDICT_DONE=1
}
trap verify_tree_clean EXIT INT TERM
FAILURES=()
# replace_unique FILE ANCHOR REPLACEMENT
# Anchor must occur exactly once (byte-exact match via perl, not sed's line-oriented rewriting, so a
# multi-line anchor is matched as literal text). Writes the mutated content back byte-exact.
replace_unique() {
local file="$1" anchor="$2" replacement="$3"
local count
count=$(perl -0777 -e '
my ($anchor, $file) = @ARGV;
local $/; open(my $fh, "<:raw", $file) or die $!;
my $text = <$fh>; close $fh;
my $n = () = $text =~ /\Q$anchor\E/g;
print $n;
' "$anchor" "$file")
if [ "$count" != "1" ]; then
echo "ERROR: $file: anchor must occur exactly once, found $count" >&2
echo "--- anchor ---" >&2
echo "$anchor" >&2
return 1
fi
# No -i: on this filesystem perl's in-place-edit extension parsing has mangled a literal ":raw" suffix
# into stray sibling files before. Read, substitute once, write back byte-exact instead.
perl -0777 -e '
my ($file, $anchor, $replacement) = @ARGV;
local $/; open(my $fh, "<:raw", $file) or die $!;
my $text = <$fh>; close $fh;
my $qa = quotemeta($anchor);
$text =~ s/$qa/$replacement/;
open(my $out, ">:raw", $file) or die $!;
print $out $text; close $out;
' "$file" "$anchor" "$replacement"
}
# A dedicated, isolated artifacts path -- see cut5.sh's own comment on this: mixing this run's build with a
# stale default tests/Aetheria.Shared.Tests/bin,obj has produced spurious control failures before.
ARTIFACTS_PATH="$(mktemp -d)"
# run_test TEST_FQN -> 0 (passed) or 1 (failed/errored)
run_test() {
local test="$1"
local out
out=$(dotnet test "$TEST_PROJECT" -p:CultLibRoot="$CULTLIB_ROOT" --artifacts-path "$ARTIFACTS_PATH" --filter "FullyQualifiedName~$test" 2>&1)
if echo "$out" | grep -q "Passed!" && ! echo "$out" | grep -q "Failed: [1-9]"; then
return 0
fi
LAST_OUTPUT="$out"
return 1
}
# check_mutation NAME FILE ANCHOR MUTATED TEST EXPECT("red"|"green")
check_mutation() {
local name="$1" file="$2" anchor="$3" mutated="$4" test="$5" expect="$6"
echo ""
echo "=== $name (expect $expect) ==="
if ! replace_unique "$file" "$anchor" "$mutated"; then
FAILURES+=("$name: anchor replacement failed")
restore_and_verify "$file"
return
fi
if run_test "$test"; then
passed=1
else
passed=0
fi
restore_and_verify "$file" # restore this file immediately, before judging, so a crash still leaves a clean tree
if [ "$expect" = "green" ]; then
if [ "$passed" = "1" ]; then
echo "OK: $test passed under a no-op mutation, as expected"
else
FAILURES+=("$name: expected green (test passing), got failure")
echo "$LAST_OUTPUT" | tail -40
fi
else
if [ "$passed" = "0" ]; then
echo "OK: $test went red under the mutation, as expected"
else
FAILURES+=("$name: expected red (mutant killed), but $test still passed")
fi
fi
}
# --- No-op control: a byte-identical round trip through this script's own replace_unique path, proving the
# anchor/replace/restore mechanism is transparent before any real mutation is trusted. ---
check_mutation \
"control (no-op round trip)" \
"$ENTITY_CS" \
$' public virtual void Update(float delta)\r\n {\r\n if (!_active) return;' \
$' public virtual void Update(float delta)\r\n {\r\n if (!_active) return;' \
"FireControlCut8Tests.DeadEntityDoesNotUpdate" \
"green"
# --- 8.2: a deactivated entity does not update. Mutation: drop the `_active` guard -- the always-runs prefix
# of Update (TargetRange included) keeps executing on a torn-down entity instead of returning immediately. ---
check_mutation \
"8.2 Entity.Update must return immediately when !_active, not run its prefix regardless" \
"$ENTITY_CS" \
$' public virtual void Update(float delta)\r\n {\r\n if (!_active) return;\r\n\r\n var hullData = ItemManager.GetData(Hull) as HullData;' \
$' public virtual void Update(float delta)\r\n {\r\n var hullData = ItemManager.GetData(Hull) as HullData;' \
"FireControlCut8Tests.DeadEntityDoesNotUpdate" \
"red"
# --- 8.1: removal and deactivation are one transition. Mutation: drop the Deactivate() call from Zone's own
# Death subscription, restoring Cut 5.6's half-fix -- the dead entity leaves Entities but stays active. ---
check_mutation \
"8.1 Zone's Death subscription must pair Entities.Remove with Deactivate, exactly like TryDock" \
"$ZONE_CS" \
'Entities.ObserveAdd().Subscribe(add => add.Value.Death.Subscribe(_ => { Entities.Remove(add.Value); add.Value.Deactivate(); }));' \
'Entities.ObserveAdd().Subscribe(add => add.Value.Death.Subscribe(_ => { Entities.Remove(add.Value); }));' \
"FireControlCut8Tests.DeathDeactivates" \
"red"
echo ""
verify_tree_clean # explicit call: the tree-clean verdict is part of THIS run's own summary, not only
# something printed later at exit (the trap's own call is a no-op after this one runs --
# see TREE_VERDICT_DONE above -- and remains the safety net for an interrupted run).
if [ "${#FAILURES[@]}" -gt 0 ]; then
echo ""
echo "=== FAILURES ==="
for f in "${FAILURES[@]}"; do
echo "- $f"
done
fi
if [ "${#FAILURES[@]}" -gt 0 ] || [ "$TREE_CLEAN_OK" != "1" ]; then
echo ""
echo "RESULT: FAILED -- mutations: $([ "${#FAILURES[@]}" -gt 0 ] && echo "${#FAILURES[@]} failure(s)" || echo "all behaved as declared"), tree-clean: $([ "$TREE_CLEAN_OK" = "1" ] && echo PASS || echo FAIL)"
exit 1
fi
echo "RESULT: All mutations behaved as declared. tree-clean: PASS"
exit 0

View file

@ -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 <path-to-CultLib-45c2f40-worktree>
#
# Every mutation is reversed (byte-exact, from an on-disk copy) before the script exits, including on failure
# or interrupt, and every restore is verified against a sha256 recorded before any mutation ran: the tree-
# clean verdict at the end states PASS/FAIL explicitly rather than leaving a reader to infer it. A SIGKILL of
# the whole process group is the one termination bash cannot trap at all -- verify the tree independently
# after a run that was killed that hard.
#
# Run only one mutation harness at a time against this tree -- concurrent harnesses corrupt each other's
# snapshots (docs/fire-control-cut.md's own campaign has hit this).
set -u
if [ $# -lt 1 ]; then
echo "Usage: $0 <cultlib-root>" >&2
exit 2
fi
CULTLIB_ROOT="$1"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
TEST_PROJECT="$REPO_ROOT/tests/Aetheria.Shared.Tests"
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

View file

@ -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 <path-to-CultLib-45c2f40-worktree>
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())

View file

@ -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 <path-to-CultLib-45c2f40-worktree>
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())

View file

@ -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 <path-to-CultLib-45c2f40-worktree>
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())

View file

@ -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 <path-to-CultLib-45c2f40-worktree>
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())

View file

@ -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 <path-to-CultLib-45c2f40-worktree>
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())

View file

@ -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 <path-to-CultLib-45c2f40-worktree>
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())

View file

@ -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 <path-to-CultLib-45c2f40-worktree>
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<IPowerConsumer>().Select(c => c.DefaultPowerTier).ToArray();\n if (consumerTiers.Length > 0)\n EquippableItem.PowerTier = consumerTiers.Min();\n }",
mutated=" {\n var consumerTiers = Behaviors.OfType<IPowerConsumer>().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())

View file

@ -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 <path-to-CultLib-45c2f40-worktree>
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<PerformanceStat>()))\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())

View file

@ -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 <path-to-CultLib-45c2f40-worktree>
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())

View file

@ -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 <path-to-CultLib-45c2f40-worktree>
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())

View file

@ -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 <path-to-CultLib-45c2f40-worktree>
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())