#!/usr/bin/env python3 """Mutation tests for the Cut B and Cut C provenance rules (docs/item-provenance-cut.md). For each rule this applies its named mutation to an exact, unique anchor of source text, runs the one test that should catch it, restores the file byte-for-byte, and reports whether the mutant was killed. A no-op control mutation (anchor == replacement) proves the read/replace/write/restore path itself is transparent: every test it touches must stay green. Usage: python tests/mutation_tests.py --cultlib-root Requires the CultLib worktree described in docs/item-provenance-cut.md section 6. Only touches files under this repository; every mutation is reversed before the script exits, including on failure. """ from __future__ import annotations import argparse import subprocess import sys from dataclasses import dataclass from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent TEST_PROJECT = REPO_ROOT / "tests" / "Aetheria.Shared.Tests" @dataclass class Mutation: rule: str file: str anchor: str mutated: str test: str expect: str # "red" (mutant must be killed) or "green" (the no-op control) MUTATIONS: list[Mutation] = [ # --- No-op control: proves the anchor/replace/restore mechanism is transparent --- Mutation( rule="control (no-op round trip)", file="Assets/Scripts/ServerShared/Provenance.cs", anchor=' [Key(0)] public int NextLot = 1;', mutated=' [Key(0)] public int NextLot = 1;', test="RunSaveTests.MissingLotIsLoud", expect="green", ), # --- MissingLotIsLoud: the indexer and Reachable must throw on an absent lot --- Mutation( rule="ProvenanceLedger indexer throws on an absent id", file="Assets/Scripts/ServerShared/Provenance.cs", anchor=( " public Lot this[int id]\n" " {\n" " get\n" " {\n" " if (!Lots.TryGetValue(id, out var lot))\n" " throw new InvalidOperationException($\"Lot {id} is not in the provenance ledger.\");\n" " return lot;\n" " }\n" " }" ), mutated=( " public Lot this[int id]\n" " {\n" " get\n" " {\n" " Lots.TryGetValue(id, out var lot);\n" " return lot;\n" " }\n" " }" ), test="RunSaveTests.MissingLotIsLoud", expect="red", ), Mutation( rule="Reachable throws on a missing root instead of skipping it", file="Assets/Scripts/ServerShared/Provenance.cs", anchor=" var lot = this[id];\n reached[id] = lot;", mutated=" if (!Lots.TryGetValue(id, out var lot)) continue;\n reached[id] = lot;", test="RunSaveTests.MissingLotIsLoud", expect="red", ), # --- MatchingHardpointsShareALot: the second unit must mint from the first unit's lot --- Mutation( rule="matching hardpoints share a lot, not a fresh mint", file="Assets/Scripts/ServerShared/LoadoutGenerator.cs", anchor=( " // Matching hardpoints carry matching units: same lot\n" " var item = (previousItem != null\n" " ? ItemManager.CreateInstance(previousItem.EquippableItem.Lot)\n" " : ItemManager.CreateInstance(entry.product)) as EquippableItem;" ), mutated=( " // Matching hardpoints carry matching units: same lot\n" " var item = ItemManager.CreateInstance(entry.product) as EquippableItem;" ), test="LoadoutTests.MatchingHardpointsShareALot", expect="red", ), # --- FirstAvailableProductInKeyOrderBuildsTheSlot: Brand and Materialize must both respect key order --- Mutation( rule="Brand matches the lot's maker, not just the design", file="Assets/Scripts/ServerShared/ItemManager.cs", anchor=( " var product = ItemData.GetAll()\n" " .Where(p => p.Manufacturer.Key.Equals(maker.Key) && p.Design.Key.Equals(item.Data.Key))" ), mutated=( " var product = ItemData.GetAll()\n" " .Where(p => p.Design.Key.Equals(item.Data.Key))" ), test="LoadoutTests.FirstAvailableProductInKeyOrderBuildsTheSlot", expect="red", ), Mutation( rule="Materialize resolves a design's product in record-key order", file="Assets/Scripts/ServerShared/Loadout.cs", anchor="var products = cache.GetAll().OrderBy(p => cache.RefOf(p).Key.Value, StringComparer.Ordinal).ToArray();", mutated="var products = cache.GetAll().ToArray();", test="LoadoutTests.FirstAvailableProductInKeyOrderBuildsTheSlot", expect="red", ), # --- BrandPicksFirstProductInKeyOrderForSameMakerAndDesign: Brand's own tie-break must be ascending key order --- Mutation( rule="Brand's tie-break among a maker's products for one design is ascending record-key order", file="Assets/Scripts/ServerShared/ItemManager.cs", anchor=" .OrderBy(p => ItemData.RefOf(p).Key.Value, StringComparer.Ordinal)", mutated=" .OrderByDescending(p => ItemData.RefOf(p).Key.Value, StringComparer.Ordinal)", test="LoadoutTests.BrandPicksFirstProductInKeyOrderForSameMakerAndDesign", expect="red", ), # --- StatsReadTheLot: stats must read the lot, including its per-role fills --- Mutation( rule="Evaluate reads the lot's per-role fill for a role stat", file="Assets/Scripts/ServerShared/ItemManager.cs", anchor="var quality = pow(lot.QualityForRole(stat.FromRole), stat.QualityExponent);", mutated="var quality = pow(lot.Quality, stat.QualityExponent);", test="LoadoutTests.StatsReadTheLot", expect="red", ), Mutation( rule="QualityForRole falls back to the lot's own workmanship, not a fixed value", file="Assets/Scripts/ServerShared/Provenance.cs", anchor=" if (string.IsNullOrEmpty(role) || Roles == null) return Quality;", mutated=" if (string.IsNullOrEmpty(role) || Roles == null) return 1f;", test="LoadoutTests.StatsReadTheLot", expect="red", ), # --- F2 (item-provenance-cut.md): the GC root walk must cover every EntityPack root --- Mutation( rule="Items yields the Equipment root, not just the hull", file="Assets/Scripts/ServerShared/EntitySerializer.cs", anchor=( " yield return pack.Hull;\n" " foreach (var (_, item) in pack.Equipment) yield return item;" ), mutated=" yield return pack.Hull;", test="LoadoutTests.MaterializedLotsSurviveSaveAndReload", expect="red", ), # --- F3: GetTier and CreateInstance(int) must read/preserve the lot's real quality --- Mutation( rule="GetTier reads the lot's quality, not a fixed value", file="Assets/Scripts/ServerShared/ItemManager.cs", anchor=" var quality = GetLot(item).Quality;", mutated=" var quality = .999f;", test="LoadoutTests.StatsReadTheLot", expect="red", ), Mutation( rule="CreateInstance(int) does not re-roll the lot's quality", file="Assets/Scripts/ServerShared/ItemManager.cs", anchor=( " public CraftedItemInstance CreateInstance(int lot)\n" " {\n" " var l = Lots[lot];" ), mutated=( " public CraftedItemInstance CreateInstance(int lot)\n" " {\n" " var l = Lots[lot];\n" " l.Quality = RollQuality();" ), test="LoadoutTests.StatsReadTheLot", expect="red", ), # --- F4: GetPrice, the durability/thermal exponent paths, role fills, Produced brand, multi-zone roots --- Mutation( rule="GetPrice reads the lot's quality, not a fixed value", file="Assets/Scripts/ServerShared/ItemManager.cs", anchor=" return (int) (GameplaySettings.QualityPriceModifier.Evaluate(GetLot(item).Quality) * data.Price);", mutated=" return (int) (GameplaySettings.QualityPriceModifier.Evaluate(.5f) * data.Price);", test="LoadoutTests.GetPriceReadsLotQuality", expect="red", ), Mutation( rule="Evaluate's durability exponent reads the lot's quality", file="Assets/Scripts/ServerShared/ItemManager.cs", anchor=( " var durabilityExponent = lerp(\n" " GameplaySettings.DurabilityQualityMin,\n" " GameplaySettings.DurabilityQualityMax,\n" " pow(lot.Quality, GameplaySettings.DurabilityQualityExponent));" ), mutated=( " var durabilityExponent = lerp(\n" " GameplaySettings.DurabilityQualityMin,\n" " GameplaySettings.DurabilityQualityMax,\n" " .5f);" ), test="LoadoutTests.EvaluateDurabilityExponentReadsLotQuality", expect="red", ), Mutation( rule="EquippedItem's thermal exponent reads the lot's quality", file="Assets/Scripts/ServerShared/Entity.cs", anchor=( " ThermalExponent = lerp(\n" " ItemManager.GameplaySettings.ThermalQualityMin,\n" " ItemManager.GameplaySettings.ThermalQualityMax,\n" " pow(Lot.Quality, ItemManager.GameplaySettings.ThermalQualityExponent));" ), mutated=( " ThermalExponent = lerp(\n" " ItemManager.GameplaySettings.ThermalQualityMin,\n" " ItemManager.GameplaySettings.ThermalQualityMax,\n" " .5f);" ), test="LoadoutTests.EquippedItemThermalExponentReadsLotQuality", expect="red", ), Mutation( rule="CreateLot(product) fills roles from the product's own spread, not the design default", file="Assets/Scripts/ServerShared/ItemManager.cs", anchor=" var build = product.Roles?.FirstOrDefault(b => b.Role == role.Name) ?? new ProductRole();", mutated=" var build = new ProductRole();", test="LoadoutTests.CreateLotFillsRolesFromProductSpread", expect="red", ), Mutation( rule="Brand reads the maker off a Produced origin too, not only Attributed", file="Assets/Scripts/ServerShared/ItemManager.cs", anchor=( " var maker = lot.Origin switch\n" " {\n" " Attributed attributed => attributed.Faction,\n" " Produced produced => produced.Faction,\n" " _ => default\n" " };" ), mutated=( " var maker = lot.Origin switch\n" " {\n" " Attributed attributed => attributed.Faction,\n" " _ => default\n" " };" ), test="LoadoutTests.BrandReadsProducedFaction", expect="red", ), Mutation( rule="RunSave.Commit takes GC roots from every zone, not just the first", file="Assets/Scripts/ServerShared/SavedGame.cs", anchor=" var roots = zones\n .SelectMany(zone => zone.Contents?.Entities ?? new List())", mutated=" var roots = zones.Take(1)\n .SelectMany(zone => zone.Contents?.Entities ?? new List())", test="RunSaveTests.CommitTakesRootsFromEveryZone", expect="red", ), # --- Cut C: the manufacturer moves from the design to the product; the catalog fixture and its # round-trip assertion (FactionIsASingletonInstance) must actually depend on that wiring. --- Mutation( rule="the catalog fixture's product carries the maker, not an unset reference", file="tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs", anchor=( 'cache.Upsert(new FactionProductData { Name = "Lance", ' "Design = new CultRecordRef(lance.Key), Manufacturer = cache.RefOf(faction) });" ), mutated=( 'cache.Upsert(new FactionProductData { Name = "Lance", ' "Design = new CultRecordRef(lance.Key), Manufacturer = default });" ), test="AetheriaStoresTests.FactionIsASingletonInstance", expect="red", ), # --- Hand-testing combat (2026-09-17): IFF overrides, grudge, detection-gating, and the shooter- # stance weapon fire gate on Entity/Weapon (docs not yet written; see IffAndCombatTests.cs). --- Mutation( rule="an IFF override decides IsHostileTo outright at the top level", file="Assets/Scripts/ServerShared/Entity.cs", anchor=" if (!recursive && _iffOverrides.TryGetValue(other, out var overrideHostile))\n return overrideHostile;", mutated=" if (false)\n return default;", test="IffAndCombatTests.OverrideDecidesOutrightOverDerivedRule", expect="red", ), Mutation( rule="the recursive reciprocity sub-query skips overrides (no mirroring another entity's stance)", file="Assets/Scripts/ServerShared/Entity.cs", anchor=" if (!recursive && _iffOverrides.TryGetValue(other, out var overrideHostile))\n return overrideHostile;", mutated=" if (_iffOverrides.TryGetValue(other, out var overrideHostile))\n return overrideHostile;", test="IffAndCombatTests.DerivedReciprocityDoesNotMirrorAnotherEntitysOverride", expect="red", ), Mutation( rule="leaving the zone clears this entity's IFF overrides on the departing entity", file="Assets/Scripts/ServerShared/Entity.cs", anchor=" _iffOverrides.Remove(remove.Value);\n if (_grudgeSubscriptions.TryGetValue(remove.Value, out var grudgeSubs))", mutated=" if (_grudgeSubscriptions.TryGetValue(remove.Value, out var grudgeSubs))", test="IffAndCombatTests.LeavingZoneClearsOverrides", expect="red", ), Mutation( rule="an override change refreshes EntityHostility immediately (event-driven, no tick required)", file="Assets/Scripts/ServerShared/Entity.cs", anchor=" _subscriptions.Add(_iffOverrides.ObserveAdd().Subscribe(add => RefreshHostilityFromOverride(add.Key)));\n _subscriptions.Add(_iffOverrides.ObserveReplace().Subscribe(replace => RefreshHostilityFromOverride(replace.Key)));", mutated=" // _subscriptions.Add(_iffOverrides.ObserveAdd().Subscribe(add => RefreshHostilityFromOverride(add.Key)));\n // _subscriptions.Add(_iffOverrides.ObserveReplace().Subscribe(replace => RefreshHostilityFromOverride(replace.Key)));", test="IffAndCombatTests.OverrideDecidesOutrightOverDerivedRule", expect="red", ), Mutation( rule="non-player entities wire a grudge watch; player-controlled entities never do", file="Assets/Scripts/ServerShared/Entity.cs", anchor=" if (!IsPlayerControlled) WatchForGrudge(entity);", mutated=" WatchForGrudge(entity);", test="IffAndCombatTests.PlayerControlledEntitiesNeverAutoGrudge", expect="red", ), Mutation( rule="grudge only takes hold while this entity currently detects the marker", file="Assets/Scripts/ServerShared/Entity.cs", anchor=" if (otherIsHostileToThis && VisibleEntities.Contains(other) && !_iffOverrides.ContainsKey(other))", mutated=" if (otherIsHostileToThis && !_iffOverrides.ContainsKey(other))", test="IffAndCombatTests.NonPlayerEntityGrudgesOnlyWhileItDetectsTheHostileMarker", expect="red", ), Mutation( rule="detecting an already-hostile entity grudges it immediately (VisibleEntities.ObserveAdd wiring)", file="Assets/Scripts/ServerShared/Entity.cs", anchor=( " VisibleEntities.ObserveAdd()\n" " .Where(add => add.Value == other)\n" " .Subscribe(_ =>\n" " {\n" " if (other.EntityHostility.TryGetValue(this, out var hostile))\n" " CheckGrudge(hostile);\n" " })" ), mutated=( " VisibleEntities.ObserveAdd()\n" " .Where(add => false)\n" " .Subscribe(_ =>\n" " {\n" " if (other.EntityHostility.TryGetValue(this, out var hostile))\n" " CheckGrudge(hostile);\n" " })" ), test="IffAndCombatTests.NonPlayerEntityGrudgesOnlyWhileItDetectsTheHostileMarker", expect="red", ), Mutation( rule="a grudge is sticky: it never re-evaluates (mirrors current stance) once already grudged", file="Assets/Scripts/ServerShared/Entity.cs", anchor=" if (otherIsHostileToThis && VisibleEntities.Contains(other) && !_iffOverrides.ContainsKey(other))\n SetIff(other, true);", mutated=" if (VisibleEntities.Contains(other))\n SetIff(other, otherIsHostileToThis);", test="IffAndCombatTests.GrudgeIsStickyAndDoesNotForgiveWhenMarkerGoesNeutral", expect="red", ), Mutation( rule="PerceivedStanceOf is unknown (null) until this entity detects the other", file="Assets/Scripts/ServerShared/Entity.cs", anchor=( " public bool? PerceivedStanceOf(Entity other) =>\n" " VisibleEntities.Contains(other) && other.EntityHostility.TryGetValue(this, out var hostile)\n" " ? hostile\n" " : (bool?) null;" ), mutated=( " public bool? PerceivedStanceOf(Entity other) =>\n" " other.EntityHostility.TryGetValue(this, out var hostile)\n" " ? hostile\n" " : (bool?) null;" ), test="IffAndCombatTests.PerceivedStanceIsUnknownUntilDetectedThenReadsTheActualStance", expect="red", ), Mutation( rule="Weapon.StanceAllowsFire gates on the shooter's own stance, not an unconditional true", file="Assets/Scripts/ServerShared/Behaviors/Weapon.cs", anchor=" public bool StanceAllowsFire => Entity.Target.Value == null || Entity.IsHostileTo(Entity.Target.Value);", mutated=" public bool StanceAllowsFire => true;", test="IffAndCombatTests.WeaponDoesNotFireWhenShooterIsNotHostileToItsTarget", expect="red", ), Mutation( rule="InstantWeapon.Trigger is safed by StanceAllowsFire before it starts a burst", file="Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs", anchor=" if (!StanceAllowsFire) return;", mutated=" if (false) return;", test="IffAndCombatTests.WeaponDoesNotFireWhenShooterIsNotHostileToItsTarget", expect="red", ), Mutation( rule="LockWeapon builds lock on the shooter's own hostility toward the target, not the reverse", file="Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs", anchor=" if (Entity.Target.Value != null && Entity.IsHostileTo(Entity.Target.Value))", mutated=" if (Entity.Target.Value != null && Entity.Target.Value.IsHostileTo(Entity))", test="IffAndCombatTests.LockWeaponBuildsLockOnTheShootersOwnStanceNotTheTargets", expect="red", ), Mutation( rule="ZoneGenerator's neutral-wanderer pool excludes the zone owner and the nearest faction", file="Assets/Scripts/ServerShared/ZoneGenerator.cs", anchor="\tpublic static Faction[] EligibleWandererFactions(Faction[] factions, Faction owner, Faction nearest) =>\n\t\tfactions.Where(f => f != owner && f != nearest).ToArray();", mutated="\tpublic static Faction[] EligibleWandererFactions(Faction[] factions, Faction owner, Faction nearest) =>\n\t\tfactions.ToArray();", test="IffAndCombatTests.EligibleWandererFactionsExcludesOwnerAndNearest", expect="red", ), # --- Capability event contract (docs/headless-playground-cut.md fork L, 2026-09-17): each event # kind must reach only its own subscribers. --- Mutation( rule="control (no-op round trip) for the capability event routing test", file="Assets/Scripts/ServerShared/CapabilityEvents.cs", anchor=" public void PublishAbsorb(AbsorbEvent e) => _absorb.OnNext(e);", mutated=" public void PublishAbsorb(AbsorbEvent e) => _absorb.OnNext(e);", test="CapabilityEventsTests.EachEventKindReachesOnlyItsOwnSubscribers", expect="green", ), Mutation( rule="PublishAbsorb notifies only the Absorb stream, not Thrust as well", file="Assets/Scripts/ServerShared/CapabilityEvents.cs", anchor=" public void PublishAbsorb(AbsorbEvent e) => _absorb.OnNext(e);", mutated=" public void PublishAbsorb(AbsorbEvent e) { _absorb.OnNext(e); _thrust.OnNext(default); }", test="CapabilityEventsTests.EachEventKindReachesOnlyItsOwnSubscribers", expect="red", ), # --- Cut 0 (docs/settings-globals-cut.md): Entity must copy the EntitySettings it is given, so no # entity aliases GameplaySettings.DefaultEntitySettings (or, once settings are a catalog global, the # cached record itself). --- Mutation( rule="control (no-op round trip) for the entity-settings-copy test", file="Assets/Scripts/ServerShared/Entity.cs", anchor=" Settings = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(settings));", mutated=" Settings = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(settings));", test="LoadoutTests.EntitiesDoNotShareDefaultEntitySettings", expect="green", ), Mutation( rule="Entity's constructor copies the settings it is given, instead of aliasing the template", file="Assets/Scripts/ServerShared/Entity.cs", anchor=" Settings = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(settings));", mutated=" Settings = settings;", test="LoadoutTests.EntitiesDoNotShareDefaultEntitySettings", expect="red", ), ] def run_test(cultlib_root: str, filter_expr: str) -> tuple[str, str]: """Runs the one filtered test. Returns ("ERROR", ...) when the build itself failed (a compile error is never a legitimate kill or survival — it means the mutation broke something the test never got a chance to exercise), otherwise ("PASS"|"FAIL", output).""" proc = subprocess.run( [ "dotnet", "test", str(TEST_PROJECT), f"-p:CultLibRoot={cultlib_root}", "--filter", f"FullyQualifiedName~{filter_expr}", "--nologo", ], cwd=REPO_ROOT, capture_output=True, text=True, ) output = proc.stdout + proc.stderr # A build failure never reaches "Test run for ..."/"Passed!"/"Failed!"; it reports # "Build FAILED." and one or more "error CS..." lines instead. if "Build FAILED" in output or "error CS" in output: return "ERROR", output passed = proc.returncode == 0 and "Failed!" not in output return ("PASS" if passed else "FAIL"), output def detect_newline(original: bytes) -> bytes: return b"\r\n" if b"\r\n" in original else b"\n" def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--cultlib-root", required=True) args = parser.parse_args() results = [] failures = [] for m in MUTATIONS: path = REPO_ROOT / m.file original = path.read_bytes() newline = detect_newline(original).decode("ascii") # Anchors are authored with plain "\n"; translate to the file's own line ending # before matching, so CRLF-checked-out files (this repo's default) still match. anchor = m.anchor.replace("\n", newline) mutated = m.mutated.replace("\n", newline) text = original.decode("utf-8") count = text.count(anchor) if count != 1: print(f"[ABORT] anchor for '{m.rule}' matches {count} times in {m.file}, expected exactly 1") failures.append(m.rule) continue mutated_text = text.replace(anchor, mutated, 1) path.write_bytes(mutated_text.encode("utf-8")) try: status_code, output = run_test(args.cultlib_root, m.test) finally: # Reverse write: restore the exact original bytes regardless of outcome. path.write_bytes(original) restored = path.read_bytes() if restored != original: print(f"[FATAL] {m.file} did not restore byte-exact after '{m.rule}'!") return 2 if status_code == "ERROR": status = "ERROR (build failure)" failures.append(m.rule) elif m.expect == "red": killed = status_code == "FAIL" status = "KILLED" if killed else "SURVIVED (bad)" if not killed: failures.append(m.rule) else: # control: must stay green passed = status_code == "PASS" status = "GREEN (ok)" if passed else "RED (bad: round trip corrupted something)" if not passed: failures.append(m.rule) results.append((m.rule, m.test, status)) print(f"{status:22} | {m.test:55} | {m.rule}") print() print("Summary:") for rule, test, status in results: print(f" {status:22} {test:55} {rule}") if failures: print(f"\n{len(failures)} mutation(s) did not behave as expected:") for f in failures: print(f" - {f}") return 1 print(f"\nAll {len(results)} mutations behaved as expected.") return 0 if __name__ == "__main__": sys.exit(main())