Cut 11.1: retire the hand-written mutation harnesses Stryker replaces

Twenty-three harnesses, 5,816 lines, across the fire control, stats and power,
shield reserve and condition ratio campaigns. Every one targeted code under
Assets/Scripts/ServerShared, which Stryker's pinned scope now covers, and every
one carried the same liabilities: literal-text anchors coupled to code shape, a
restore that could leak a mutant into the tree, a tree-clean verdict that
restored before it measured, and a red result that could not tell a killed
mutant from a compile failure. The behavioural tests they measured all stay.

Kept and recorded, not yet answered: the addressables harness and the three
shield panel harnesses. They target Unity Editor probes that Unity compiles
outside Aetheria.Shared, where Stryker cannot reach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-22 15:59:45 +02:00
commit 724ec5e7d3
24 changed files with 1 additions and 5817 deletions

View file

@ -1724,7 +1724,7 @@ edits a source file, and has nothing to re-anchor.
### 11.1 Retire the ServerShared harnesses
Delete the 25 hand-rolled harnesses whose targets are all under
Delete the 23 hand-rolled harnesses (5,816 lines) whose targets are all under
`Assets/Scripts/ServerShared` -- every `mutation_tests_fire_control_*`, every
`mutation_tests_stats_power_*`, `mutation_tests_shield_reserve.py`,
`mutation_tests_condition_ratio_cut8.py` and `mutation_tests.py`. Stryker's scope

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())