Add mutation tests proving Cut B's new rules fail under their own mutation
tests/mutation_tests.py applies each named mutation from the cut map's
Cut B table to an exact, unique source anchor, runs the one test that
must catch it, restores the file byte-for-byte, and reports kill/survive.
A no-op control (anchor rewritten to itself) proves the read/replace/
restore path is transparent. Covers the four rules whose tests currently
pass: MissingLotIsLoud (indexer and Reachable throw on an absent lot),
MatchingHardpointsShareALot (same lot, not a fresh mint), Brand's and
Materialize's record-key order, and StatsReadTheLot (Evaluate reads the
lot's role fills, QualityForRole's fallback). All 8 mutations (7 rules
+ control) behave as expected.
MaterializedLotsSurviveSaveAndReload and CommitKeepsOnlyReachableLots
are not yet reached: they fail before any mutation is applied, blocked
by the CultLib int2 dictionary-key gap reported in the previous commit
and the landing report.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 04:22:29 +02:00
|
|
|
#!/usr/bin/env python3
|
2026-09-18 05:07:23 +02:00
|
|
|
"""Mutation tests for the Cut B and Cut C provenance rules (docs/item-provenance-cut.md).
|
Add mutation tests proving Cut B's new rules fail under their own mutation
tests/mutation_tests.py applies each named mutation from the cut map's
Cut B table to an exact, unique source anchor, runs the one test that
must catch it, restores the file byte-for-byte, and reports kill/survive.
A no-op control (anchor rewritten to itself) proves the read/replace/
restore path is transparent. Covers the four rules whose tests currently
pass: MissingLotIsLoud (indexer and Reachable throw on an absent lot),
MatchingHardpointsShareALot (same lot, not a fresh mint), Brand's and
Materialize's record-key order, and StatsReadTheLot (Evaluate reads the
lot's role fills, QualityForRole's fallback). All 8 mutations (7 rules
+ control) behave as expected.
MaterializedLotsSurviveSaveAndReload and CommitKeepsOnlyReachableLots
are not yet reached: they fail before any mutation is applied, blocked
by the CultLib int2 dictionary-key gap reported in the previous commit
and the landing report.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 04:22:29 +02:00
|
|
|
|
2026-09-18 05:07:23 +02:00
|
|
|
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.
|
Add mutation tests proving Cut B's new rules fail under their own mutation
tests/mutation_tests.py applies each named mutation from the cut map's
Cut B table to an exact, unique source anchor, runs the one test that
must catch it, restores the file byte-for-byte, and reports kill/survive.
A no-op control (anchor rewritten to itself) proves the read/replace/
restore path is transparent. Covers the four rules whose tests currently
pass: MissingLotIsLoud (indexer and Reachable throw on an absent lot),
MatchingHardpointsShareALot (same lot, not a fresh mint), Brand's and
Materialize's record-key order, and StatsReadTheLot (Evaluate reads the
lot's role fills, QualityForRole's fallback). All 8 mutations (7 rules
+ control) behave as expected.
MaterializedLotsSurviveSaveAndReload and CommitKeepsOnlyReachableLots
are not yet reached: they fail before any mutation is applied, blocked
by the CultLib int2 dictionary-key gap reported in the previous commit
and the landing report.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 04:22:29 +02:00
|
|
|
|
2026-09-18 05:07:23 +02:00
|
|
|
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.
|
Add mutation tests proving Cut B's new rules fail under their own mutation
tests/mutation_tests.py applies each named mutation from the cut map's
Cut B table to an exact, unique source anchor, runs the one test that
must catch it, restores the file byte-for-byte, and reports kill/survive.
A no-op control (anchor rewritten to itself) proves the read/replace/
restore path is transparent. Covers the four rules whose tests currently
pass: MissingLotIsLoud (indexer and Reachable throw on an absent lot),
MatchingHardpointsShareALot (same lot, not a fresh mint), Brand's and
Materialize's record-key order, and StatsReadTheLot (Evaluate reads the
lot's role fills, QualityForRole's fallback). All 8 mutations (7 rules
+ control) behave as expected.
MaterializedLotsSurviveSaveAndReload and CommitKeepsOnlyReachableLots
are not yet reached: they fail before any mutation is applied, blocked
by the CultLib int2 dictionary-key gap reported in the previous commit
and the landing report.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 04:22:29 +02:00
|
|
|
"""
|
2026-09-18 05:07:23 +02:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import argparse
|
Add mutation tests proving Cut B's new rules fail under their own mutation
tests/mutation_tests.py applies each named mutation from the cut map's
Cut B table to an exact, unique source anchor, runs the one test that
must catch it, restores the file byte-for-byte, and reports kill/survive.
A no-op control (anchor rewritten to itself) proves the read/replace/
restore path is transparent. Covers the four rules whose tests currently
pass: MissingLotIsLoud (indexer and Reachable throw on an absent lot),
MatchingHardpointsShareALot (same lot, not a fresh mint), Brand's and
Materialize's record-key order, and StatsReadTheLot (Evaluate reads the
lot's role fills, QualityForRole's fallback). All 8 mutations (7 rules
+ control) behave as expected.
MaterializedLotsSurviveSaveAndReload and CommitKeepsOnlyReachableLots
are not yet reached: they fail before any mutation is applied, blocked
by the CultLib int2 dictionary-key gap reported in the previous commit
and the landing report.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 04:22:29 +02:00
|
|
|
import subprocess
|
|
|
|
|
import sys
|
2026-09-18 05:07:23 +02:00
|
|
|
from dataclasses import dataclass
|
Add mutation tests proving Cut B's new rules fail under their own mutation
tests/mutation_tests.py applies each named mutation from the cut map's
Cut B table to an exact, unique source anchor, runs the one test that
must catch it, restores the file byte-for-byte, and reports kill/survive.
A no-op control (anchor rewritten to itself) proves the read/replace/
restore path is transparent. Covers the four rules whose tests currently
pass: MissingLotIsLoud (indexer and Reachable throw on an absent lot),
MatchingHardpointsShareALot (same lot, not a fresh mint), Brand's and
Materialize's record-key order, and StatsReadTheLot (Evaluate reads the
lot's role fills, QualityForRole's fallback). All 8 mutations (7 rules
+ control) behave as expected.
MaterializedLotsSurviveSaveAndReload and CommitKeepsOnlyReachableLots
are not yet reached: they fail before any mutation is applied, blocked
by the CultLib int2 dictionary-key gap reported in the previous commit
and the landing report.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 04:22:29 +02:00
|
|
|
from pathlib import Path
|
|
|
|
|
|
2026-09-18 05:07:23 +02:00
|
|
|
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)."""
|
Add mutation tests proving Cut B's new rules fail under their own mutation
tests/mutation_tests.py applies each named mutation from the cut map's
Cut B table to an exact, unique source anchor, runs the one test that
must catch it, restores the file byte-for-byte, and reports kill/survive.
A no-op control (anchor rewritten to itself) proves the read/replace/
restore path is transparent. Covers the four rules whose tests currently
pass: MissingLotIsLoud (indexer and Reachable throw on an absent lot),
MatchingHardpointsShareALot (same lot, not a fresh mint), Brand's and
Materialize's record-key order, and StatsReadTheLot (Evaluate reads the
lot's role fills, QualityForRole's fallback). All 8 mutations (7 rules
+ control) behave as expected.
MaterializedLotsSurviveSaveAndReload and CommitKeepsOnlyReachableLots
are not yet reached: they fail before any mutation is applied, blocked
by the CultLib int2 dictionary-key gap reported in the previous commit
and the landing report.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 04:22:29 +02:00
|
|
|
proc = subprocess.run(
|
2026-09-18 05:07:23 +02:00
|
|
|
[
|
|
|
|
|
"dotnet", "test", str(TEST_PROJECT),
|
|
|
|
|
f"-p:CultLibRoot={cultlib_root}",
|
|
|
|
|
"--filter", f"FullyQualifiedName~{filter_expr}",
|
|
|
|
|
"--nologo",
|
|
|
|
|
],
|
|
|
|
|
cwd=REPO_ROOT,
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
Add mutation tests proving Cut B's new rules fail under their own mutation
tests/mutation_tests.py applies each named mutation from the cut map's
Cut B table to an exact, unique source anchor, runs the one test that
must catch it, restores the file byte-for-byte, and reports kill/survive.
A no-op control (anchor rewritten to itself) proves the read/replace/
restore path is transparent. Covers the four rules whose tests currently
pass: MissingLotIsLoud (indexer and Reachable throw on an absent lot),
MatchingHardpointsShareALot (same lot, not a fresh mint), Brand's and
Materialize's record-key order, and StatsReadTheLot (Evaluate reads the
lot's role fills, QualityForRole's fallback). All 8 mutations (7 rules
+ control) behave as expected.
MaterializedLotsSurviveSaveAndReload and CommitKeepsOnlyReachableLots
are not yet reached: they fail before any mutation is applied, blocked
by the CultLib int2 dictionary-key gap reported in the previous commit
and the landing report.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 04:22:29 +02:00
|
|
|
)
|
2026-09-18 05:07:23 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
2026-09-18 05:07:23 +02:00
|
|
|
def detect_newline(original: bytes) -> bytes:
|
|
|
|
|
return b"\r\n" if b"\r\n" in original else b"\n"
|
Add mutation tests proving Cut B's new rules fail under their own mutation
tests/mutation_tests.py applies each named mutation from the cut map's
Cut B table to an exact, unique source anchor, runs the one test that
must catch it, restores the file byte-for-byte, and reports kill/survive.
A no-op control (anchor rewritten to itself) proves the read/replace/
restore path is transparent. Covers the four rules whose tests currently
pass: MissingLotIsLoud (indexer and Reachable throw on an absent lot),
MatchingHardpointsShareALot (same lot, not a fresh mint), Brand's and
Materialize's record-key order, and StatsReadTheLot (Evaluate reads the
lot's role fills, QualityForRole's fallback). All 8 mutations (7 rules
+ control) behave as expected.
MaterializedLotsSurviveSaveAndReload and CommitKeepsOnlyReachableLots
are not yet reached: they fail before any mutation is applied, blocked
by the CultLib int2 dictionary-key gap reported in the previous commit
and the landing report.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 04:22:29 +02:00
|
|
|
|
|
|
|
|
|
2026-09-18 05:07:23 +02:00
|
|
|
def main() -> int:
|
|
|
|
|
parser = argparse.ArgumentParser()
|
|
|
|
|
parser.add_argument("--cultlib-root", required=True)
|
|
|
|
|
args = parser.parse_args()
|
Add mutation tests proving Cut B's new rules fail under their own mutation
tests/mutation_tests.py applies each named mutation from the cut map's
Cut B table to an exact, unique source anchor, runs the one test that
must catch it, restores the file byte-for-byte, and reports kill/survive.
A no-op control (anchor rewritten to itself) proves the read/replace/
restore path is transparent. Covers the four rules whose tests currently
pass: MissingLotIsLoud (indexer and Reachable throw on an absent lot),
MatchingHardpointsShareALot (same lot, not a fresh mint), Brand's and
Materialize's record-key order, and StatsReadTheLot (Evaluate reads the
lot's role fills, QualityForRole's fallback). All 8 mutations (7 rules
+ control) behave as expected.
MaterializedLotsSurviveSaveAndReload and CommitKeepsOnlyReachableLots
are not yet reached: they fail before any mutation is applied, blocked
by the CultLib int2 dictionary-key gap reported in the previous commit
and the landing report.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 04:22:29 +02:00
|
|
|
|
2026-09-18 05:07:23 +02:00
|
|
|
results = []
|
Add mutation tests proving Cut B's new rules fail under their own mutation
tests/mutation_tests.py applies each named mutation from the cut map's
Cut B table to an exact, unique source anchor, runs the one test that
must catch it, restores the file byte-for-byte, and reports kill/survive.
A no-op control (anchor rewritten to itself) proves the read/replace/
restore path is transparent. Covers the four rules whose tests currently
pass: MissingLotIsLoud (indexer and Reachable throw on an absent lot),
MatchingHardpointsShareALot (same lot, not a fresh mint), Brand's and
Materialize's record-key order, and StatsReadTheLot (Evaluate reads the
lot's role fills, QualityForRole's fallback). All 8 mutations (7 rules
+ control) behave as expected.
MaterializedLotsSurviveSaveAndReload and CommitKeepsOnlyReachableLots
are not yet reached: they fail before any mutation is applied, blocked
by the CultLib int2 dictionary-key gap reported in the previous commit
and the landing report.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 04:22:29 +02:00
|
|
|
failures = []
|
|
|
|
|
|
2026-09-18 05:07:23 +02:00
|
|
|
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
|
|
|
|
2026-09-18 05:07:23 +02:00
|
|
|
mutated_text = text.replace(anchor, mutated, 1)
|
|
|
|
|
path.write_bytes(mutated_text.encode("utf-8"))
|
Add mutation tests proving Cut B's new rules fail under their own mutation
tests/mutation_tests.py applies each named mutation from the cut map's
Cut B table to an exact, unique source anchor, runs the one test that
must catch it, restores the file byte-for-byte, and reports kill/survive.
A no-op control (anchor rewritten to itself) proves the read/replace/
restore path is transparent. Covers the four rules whose tests currently
pass: MissingLotIsLoud (indexer and Reachable throw on an absent lot),
MatchingHardpointsShareALot (same lot, not a fresh mint), Brand's and
Materialize's record-key order, and StatsReadTheLot (Evaluate reads the
lot's role fills, QualityForRole's fallback). All 8 mutations (7 rules
+ control) behave as expected.
MaterializedLotsSurviveSaveAndReload and CommitKeepsOnlyReachableLots
are not yet reached: they fail before any mutation is applied, blocked
by the CultLib int2 dictionary-key gap reported in the previous commit
and the landing report.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 04:22:29 +02:00
|
|
|
try:
|
2026-09-18 05:07:23 +02:00
|
|
|
status_code, output = run_test(args.cultlib_root, m.test)
|
Add mutation tests proving Cut B's new rules fail under their own mutation
tests/mutation_tests.py applies each named mutation from the cut map's
Cut B table to an exact, unique source anchor, runs the one test that
must catch it, restores the file byte-for-byte, and reports kill/survive.
A no-op control (anchor rewritten to itself) proves the read/replace/
restore path is transparent. Covers the four rules whose tests currently
pass: MissingLotIsLoud (indexer and Reachable throw on an absent lot),
MatchingHardpointsShareALot (same lot, not a fresh mint), Brand's and
Materialize's record-key order, and StatsReadTheLot (Evaluate reads the
lot's role fills, QualityForRole's fallback). All 8 mutations (7 rules
+ control) behave as expected.
MaterializedLotsSurviveSaveAndReload and CommitKeepsOnlyReachableLots
are not yet reached: they fail before any mutation is applied, blocked
by the CultLib int2 dictionary-key gap reported in the previous commit
and the landing report.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 04:22:29 +02:00
|
|
|
finally:
|
2026-09-18 05:07:23 +02:00
|
|
|
# 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}")
|
Add mutation tests proving Cut B's new rules fail under their own mutation
tests/mutation_tests.py applies each named mutation from the cut map's
Cut B table to an exact, unique source anchor, runs the one test that
must catch it, restores the file byte-for-byte, and reports kill/survive.
A no-op control (anchor rewritten to itself) proves the read/replace/
restore path is transparent. Covers the four rules whose tests currently
pass: MissingLotIsLoud (indexer and Reachable throw on an absent lot),
MatchingHardpointsShareALot (same lot, not a fresh mint), Brand's and
Materialize's record-key order, and StatsReadTheLot (Evaluate reads the
lot's role fills, QualityForRole's fallback). All 8 mutations (7 rules
+ control) behave as expected.
MaterializedLotsSurviveSaveAndReload and CommitKeepsOnlyReachableLots
are not yet reached: they fail before any mutation is applied, blocked
by the CultLib int2 dictionary-key gap reported in the previous commit
and the landing report.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 04:22:29 +02:00
|
|
|
|
|
|
|
|
if failures:
|
2026-09-18 05:07:23 +02:00
|
|
|
print(f"\n{len(failures)} mutation(s) did not behave as expected:")
|
Add mutation tests proving Cut B's new rules fail under their own mutation
tests/mutation_tests.py applies each named mutation from the cut map's
Cut B table to an exact, unique source anchor, runs the one test that
must catch it, restores the file byte-for-byte, and reports kill/survive.
A no-op control (anchor rewritten to itself) proves the read/replace/
restore path is transparent. Covers the four rules whose tests currently
pass: MissingLotIsLoud (indexer and Reachable throw on an absent lot),
MatchingHardpointsShareALot (same lot, not a fresh mint), Brand's and
Materialize's record-key order, and StatsReadTheLot (Evaluate reads the
lot's role fills, QualityForRole's fallback). All 8 mutations (7 rules
+ control) behave as expected.
MaterializedLotsSurviveSaveAndReload and CommitKeepsOnlyReachableLots
are not yet reached: they fail before any mutation is applied, blocked
by the CultLib int2 dictionary-key gap reported in the previous commit
and the landing report.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 04:22:29 +02:00
|
|
|
for f in failures:
|
2026-09-18 05:07:23 +02:00
|
|
|
print(f" - {f}")
|
Add mutation tests proving Cut B's new rules fail under their own mutation
tests/mutation_tests.py applies each named mutation from the cut map's
Cut B table to an exact, unique source anchor, runs the one test that
must catch it, restores the file byte-for-byte, and reports kill/survive.
A no-op control (anchor rewritten to itself) proves the read/replace/
restore path is transparent. Covers the four rules whose tests currently
pass: MissingLotIsLoud (indexer and Reachable throw on an absent lot),
MatchingHardpointsShareALot (same lot, not a fresh mint), Brand's and
Materialize's record-key order, and StatsReadTheLot (Evaluate reads the
lot's role fills, QualityForRole's fallback). All 8 mutations (7 rules
+ control) behave as expected.
MaterializedLotsSurviveSaveAndReload and CommitKeepsOnlyReachableLots
are not yet reached: they fail before any mutation is applied, blocked
by the CultLib int2 dictionary-key gap reported in the previous commit
and the landing report.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 04:22:29 +02:00
|
|
|
return 1
|
|
|
|
|
|
2026-09-18 05:07:23 +02:00
|
|
|
print(f"\nAll {len(results)} mutations behaved as expected.")
|
Add mutation tests proving Cut B's new rules fail under their own mutation
tests/mutation_tests.py applies each named mutation from the cut map's
Cut B table to an exact, unique source anchor, runs the one test that
must catch it, restores the file byte-for-byte, and reports kill/survive.
A no-op control (anchor rewritten to itself) proves the read/replace/
restore path is transparent. Covers the four rules whose tests currently
pass: MissingLotIsLoud (indexer and Reachable throw on an absent lot),
MatchingHardpointsShareALot (same lot, not a fresh mint), Brand's and
Materialize's record-key order, and StatsReadTheLot (Evaluate reads the
lot's role fills, QualityForRole's fallback). All 8 mutations (7 rules
+ control) behave as expected.
MaterializedLotsSurviveSaveAndReload and CommitKeepsOnlyReachableLots
are not yet reached: they fail before any mutation is applied, blocked
by the CultLib int2 dictionary-key gap reported in the previous commit
and the landing report.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 04:22:29 +02:00
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
sys.exit(main())
|