Aetheria/tests/mutation_tests.py

555 lines
26 KiB
Python
Raw Normal View History

#!/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
Fix F5: mutation_tests.py normalizes line endings and stops counting compile errors as kills Anchors were authored with bare "\n" and this checkout is CRLF, so every multi-line anchor matched zero times and aborted before ever mutating anything. detect_newline() reads each file's own line ending and translates both anchor and replacement to it before matching, counting and writing back; the untouched bytes around the match are written through unchanged, so the restore stays byte-exact. run_test() now distinguishes a build failure ("Build FAILED"/"error CS" in the dotnet test output, which never reaches "Passed!"/"Failed!") from a genuine test failure. A build failure reports ERROR and is never counted as a kill, since the mutation may have broken something the target test never got a chance to exercise. Verified with a deliberate syntax-breaking mutation, run standalone and reverted (not committed). Added mutants for F1-F4's rules (docs/item-provenance-cut.md, Cut B, and its F1 ruling). Two of Cut 0's named F1 mutations ("pack drops data", "unpack ignores the lookup") are not included: no IBehavior implements IPersistentBehavior, so Pack's filter and Restore's lookup are always fed an empty set and mutating either body is unobservable through any live path. Recorded as not yet reached in the script's own comment rather than paired with a test that can't tell the difference. Ran on this checkout (CRLF, HEAD 080d3b49) against the CultLib a0813c6 worktree: all 17 mutations behaved as expected (16 KILLED, 1 GREEN control), run window 2026-09-17T03:18:14Z-03:20:46Z UTC. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 05:22:40 +02:00
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
Switch engine asset references from Resources paths to Addressables GUIDs Cut 2 of docs/addressables-cut.md. The asset move (git mv Resources/{Prefabs,Schematics,Sprites/Icons} -> Content/...) and the UnityHelpers.cs deletion already landed in 1fdf8151, picked up by a concurrent session sharing this working tree; this commit is the rest of Cut 2 landing on top of that shared state, atomically: - Bump CultLib to cultlib-unity-v1.0.60 / caching-unity-v1.4.0 (both 45c2f40) in Packages/manifest.json, packages-lock.json and Directory.Build.props, together with the 8 ServerShared [CultInspectorAssetPath] usages becoming [CultInspectorAssetGuid] - the .NET and Unity sides move together or nothing compiles. - Assets/Scripts/EngineAssets.cs replaces UnityHelpers: loads an Addressables key (bare GUID or guid[subAsset]) synchronously, loading the GameObject and taking the component for the four sites that load a component. ActionBarSlot, EntityInstance, ShipInstance and ZoneRenderer call it instead of Resources.Load/UnityHelpers; the inline "Assets/Resources/" prefix strips are gone with them. CultCacheDrawers' schematic preview goes through AssetDatabase.GUIDToAssetPath first. - Assets/Scripts/Editor/EngineAssetCheck.cs validates every stored [CultInspectorAssetGuid] value: 32 lowercase hex, resolves to a file, is addressable, and satisfies its member's consumer-type contract (Texture2D for icons/logos/schematics, a GameObject with the right component for prefabs) - table-driven, so an attributed member with no row fails loudly instead of being skipped. - GameData/Aetheria.cc: the scratch migration (Aetheria.Shared + the pinned CultLib revision, a throwaway console app, never landed) walked all 138 attributed field slots across 180 documents, rewriting the 30 non-empty Resources paths to their .meta GUIDs - 108 stayed empty, 0 unresolved, matching the map's census exactly. Verification: dotnet test tests/Aetheria.Shared.Tests 61/61 green against CultLib 45c2f40; Unity 6000.3.24f1 batchmode compiles clean; EngineAssetCheck.Run passes all 30 stored references; the map's negative greps are clean; tests/mutation_tests.py's 5 mutations (wrong type, non-addressable, random guid, an old path, and an untabled attributed member) are all caught and named, the control case and the post-revert recompile stay clean, and the catalog/source are restored byte-identical afterward. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 05:04:50 +02:00
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())