A power request may not depend on power supply (Cut 6/F6), and continuous consumers brown out through a power term on their own performance stats (Cut 7). For Radiator (PumpedHeat) and AetherDrive (Torque) those were the same stat, making six shipped catalog records illegal and leaving six BrownoutTests cases skipped. Resolved per the operator's ruling: a power request is evaluated nominally -- what the item wants at full supply, not what it is currently managing. EquippedItem.EvaluateNominalPower (mirroring ConditionRatio's own NominalContext) pins only PowerSupplyFactor to 1; Heat/Durability stay live, since they are not the term a request would be circular through. Every PowerRequest/RefreshReserve/RefreshInputCapacitor implementation now reads a StatValidation.PowerRequestFields stat through it, so the same stat can carry a brownout curve and be a request field without the request depending on its own answer. StatValidation.ValidateNoPowerSupplyOnRequest (F6's static half of the rule, forbidding the term outright) is deleted -- nominal evaluation removes the circularity at the root instead. Only the dynamic modifier-chain half (StatModifier.ValidateNoPowerSupplyChain) still refuses anything, since EvaluateNominalPower forwards ScaleModifier/ ConstantModifier unchanged (real, not nominal). Un-skips BrownoutTests' six Radiator/AetherDrive cases and adds two new regression tests (a direct EvaluateNominalPower probe, and a two-tick Radiator demand check) backed by a new mutation harness. Updates the four PowerCurveTests/AetheriaStoresTests cases that pinned the now-superseded static refusal to instead assert acceptance. GameData/Aetheria.cc is unchanged and still validates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
201 lines
11 KiB
Python
201 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Mutation tests for docs/stats-and-power-cut.md Cut 6 (the power-supply term and the request-independence
|
|
rule).
|
|
|
|
Cut 6 is purely additive: StatSource.PowerSupply already existed as an identity everywhere (Cut 2), and the bus
|
|
already wrote EquippedItem.PowerSupply (Cut 3/5). This cut wires the identity into a real curve
|
|
(EquippedItem.PowerSupplyFactor), wires the bus's own per-tick write to invalidate the resolver
|
|
(PowerBus.AllocateTiers), and added the validation rule that a power request may not depend on power supply,
|
|
directly or through a modifier chain. This script mutates each rule's own line of code and asserts the named
|
|
test in PowerCurveTests.cs goes red for exactly that reason. A no-op control mutation proves the read/replace/
|
|
restore path is transparent.
|
|
|
|
Nominal-request ruling (docs/stats-and-power-cut.md, operator ruling 2026-09-19) deleted the direct half of the
|
|
request-independence rule (StatValidation.ValidateNoPowerSupplyOnRequest) -- PowerRequest now reads a request
|
|
field through EquippedItem.EvaluateNominalPower, which pins that stat's own PowerSupplyFactor to 1, so a direct
|
|
term can no longer make the request depend on its own answer. The three mutation cases that used to pin that
|
|
static check (and the two PowerCurveTests it targeted, since renamed to OpenAccepts.../UpsertAccepts... in
|
|
PowerCurveTests.cs) are removed below; see tests/mutation_tests_stats_power_nominal_request.py for that rule's
|
|
own replacement check. The modifier-chain half (StatModifier.ValidateNoPowerSupplyChain) is unchanged and its
|
|
two mutation cases below still apply.
|
|
|
|
Usage:
|
|
python tests/mutation_tests_stats_power_cut6.py --cultlib-root <path-to-CultLib-45c2f40-worktree>
|
|
|
|
Only touches files under this repository; every mutation is reversed before the script exits, including on
|
|
failure.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import subprocess
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
TEST_PROJECT = REPO_ROOT / "tests" / "Aetheria.Shared.Tests"
|
|
|
|
|
|
@dataclass
|
|
class Mutation:
|
|
rule: str
|
|
file: str
|
|
anchor: str
|
|
mutated: str
|
|
test: str
|
|
expect: str # "red" (mutant must be killed) or "green" (the no-op control)
|
|
|
|
|
|
MUTATIONS: list[Mutation] = [
|
|
# --- No-op control: proves the anchor/replace/restore mechanism is transparent ---
|
|
Mutation(
|
|
rule="control (no-op round trip)",
|
|
file="Assets/Scripts/ServerShared/Entity.cs",
|
|
anchor=" public float PowerSupplyFactor(float exponent) => pow(PowerSupply, exponent);",
|
|
mutated=" public float PowerSupplyFactor(float exponent) => pow(PowerSupply, exponent);",
|
|
test="PowerCurveTests.APowerSupplyTermedStatAtHalfGrantDegradesByItsAuthoredExponent",
|
|
expect="green",
|
|
),
|
|
|
|
# --- The curve itself: a PowerSupply term must actually read this tick's grant raised to its own exponent,
|
|
# --- not the identity Cut 3 left in place as a placeholder. Reverting to the pre-Cut-6 stub makes a half
|
|
# --- grant read exactly like a full one. ---
|
|
Mutation(
|
|
rule="PowerSupplyFactor must read PowerSupply raised to the term's exponent, not the identity",
|
|
file="Assets/Scripts/ServerShared/Entity.cs",
|
|
anchor=" public float PowerSupplyFactor(float exponent) => pow(PowerSupply, exponent);",
|
|
mutated=" public float PowerSupplyFactor(float exponent) => 1f;",
|
|
test="PowerCurveTests.APowerSupplyTermedStatAtHalfGrantDegradesByItsAuthoredExponent",
|
|
expect="red",
|
|
),
|
|
|
|
# --- The exponent itself must be honoured, not discarded -- a stat authored with a steep curve (exponent 2)
|
|
# --- must degrade faster than a linear one. Dropping the exponent from the pow() call collapses every
|
|
# --- PowerSupply term to the same linear response regardless of what was authored. ---
|
|
Mutation(
|
|
rule="PowerSupplyFactor must raise PowerSupply to the term's own exponent, not always to the first power",
|
|
file="Assets/Scripts/ServerShared/Entity.cs",
|
|
anchor=" public float PowerSupplyFactor(float exponent) => pow(PowerSupply, exponent);",
|
|
mutated=" public float PowerSupplyFactor(float exponent) => PowerSupply;",
|
|
test="PowerCurveTests.APowerSupplyTermedStatAtHalfGrantDegradesByItsAuthoredExponent",
|
|
expect="red",
|
|
),
|
|
|
|
# --- The missing half of the wiring: the bus must invalidate StatSource.PowerSupply every time it writes a
|
|
# --- grant, or a resolver entry that cached last tick's (or the pre-activation default) value never
|
|
# --- recomputes at all. Deleting the invalidate call leaves PowerSupply written but never re-read. ---
|
|
Mutation(
|
|
rule="the bus must invalidate StatSource.PowerSupply every time it writes a grant",
|
|
file="Assets/Scripts/ServerShared/PowerBus.cs",
|
|
anchor=" draw.Item.PowerSupply = ratios[draw.Tier];\n // Cut 6 (docs/stats-and-power-cut.md): the missing half of the wiring -- a stat with a PowerSupply\n // term must recompute when the grant actually moves. Called unconditionally, once per draw per tick,\n // the same shape as EquippedItem.UpdatePerformance's own Heat/Durability invalidation: \"recomputes at\n // most once per tick per (item, stat),\" not \"only when the value moved.\" An item with no PowerSupply\n // term pays nothing extra -- the resolver's per-source generation bookkeeping (StatResolver.Resolve)\n // only ever looks at sources a stat's own Terms declared.\n _entity.Resolver.InvalidateSource(draw.Item, StatSource.PowerSupply);",
|
|
mutated=" draw.Item.PowerSupply = ratios[draw.Tier];",
|
|
test="PowerCurveTests.APowerSupplyTermedStatAtHalfGrantDegradesByItsAuthoredExponent",
|
|
expect="red",
|
|
),
|
|
|
|
# --- The resolver's own generation scheme must still be keyed per-source: invalidating PowerSupply must never
|
|
# --- touch a stat that declared only Heat. Rerouting the bus's invalidate call to a different StatSource
|
|
# --- proves the wrong thing gets bumped -- the power-termed stat then never recomputes even though the grant
|
|
# --- moved, because nothing ever invalidates the source it actually declared. ---
|
|
Mutation(
|
|
rule="the bus must invalidate PowerSupply specifically, not some other source",
|
|
file="Assets/Scripts/ServerShared/PowerBus.cs",
|
|
anchor=" _entity.Resolver.InvalidateSource(draw.Item, StatSource.PowerSupply);",
|
|
mutated=" _entity.Resolver.InvalidateSource(draw.Item, StatSource.Heat);",
|
|
test="PowerCurveTests.APowerSupplyTermedStatAtHalfGrantDegradesByItsAuthoredExponent",
|
|
expect="red",
|
|
),
|
|
|
|
# --- The modifier-chain half: a modifier's own magnitude must be checked for a PowerSupply dependency before
|
|
# --- it is allowed to attach onto a known request stat. Skipping the taint check lets a booster with a
|
|
# --- power-dependent magnitude attach onto Drain's own EnergyDraw without complaint. ---
|
|
Mutation(
|
|
rule="a modifier chain reaching a request stat through a power-tainted magnitude must be refused",
|
|
file="Assets/Scripts/ServerShared/Behaviors/StatModifier.cs",
|
|
anchor=" if (!IsPowerTainted(data.Modifier, edges, new HashSet<PerformanceStat>()))\n return;",
|
|
mutated=" if (true)\n return;",
|
|
test="PowerCurveTests.ActivateRefusesAModifierChainThatWouldCorruptAPowerRequest",
|
|
expect="red",
|
|
),
|
|
|
|
# --- IsPowerTainted's own base case: a magnitude stat's Terms must actually be checked for PowerSupply, not
|
|
# --- treated as always clean. Without this, no magnitude is ever tainted and the chain check above never
|
|
# --- fires for any catalog. ---
|
|
Mutation(
|
|
rule="IsPowerTainted must check the stat's own Terms for a direct PowerSupply dependency",
|
|
file="Assets/Scripts/ServerShared/Behaviors/StatModifier.cs",
|
|
anchor=" foreach (var term in stat.Terms)\n if (term.Source == StatSource.PowerSupply)\n return true;",
|
|
mutated=" foreach (var term in stat.Terms)\n if (false)\n return true;",
|
|
test="PowerCurveTests.ActivateRefusesAModifierChainThatWouldCorruptAPowerRequest",
|
|
expect="red",
|
|
),
|
|
]
|
|
|
|
|
|
def replace_unique(path: Path, anchor: str, mutated: str) -> None:
|
|
original = path.read_text(encoding="utf-8")
|
|
count = original.count(anchor)
|
|
if count != 1:
|
|
raise SystemExit(f"{path}: anchor must occur exactly once, found {count}\n---\n{anchor}")
|
|
path.write_text(original.replace(anchor, mutated), encoding="utf-8")
|
|
|
|
|
|
def run_test(cultlib_root: str, test: str) -> tuple[int, str]:
|
|
proc = subprocess.run(
|
|
["dotnet", "test", str(TEST_PROJECT), f"-p:CultLibRoot={cultlib_root}",
|
|
"--filter", f"FullyQualifiedName~{test}"],
|
|
capture_output=True, text=True, timeout=300,
|
|
)
|
|
return proc.returncode, proc.stdout + proc.stderr
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--cultlib-root", required=True)
|
|
args = parser.parse_args()
|
|
|
|
failures: list[str] = []
|
|
originals: dict[str, str] = {}
|
|
for m in MUTATIONS:
|
|
if m.file not in originals:
|
|
originals[m.file] = (REPO_ROOT / m.file).read_text(encoding="utf-8")
|
|
|
|
def restore_all() -> None:
|
|
for file, text in originals.items():
|
|
(REPO_ROOT / file).write_text(text, encoding="utf-8")
|
|
|
|
try:
|
|
for m in MUTATIONS:
|
|
print(f"\n=== {m.rule} (expect {m.expect}) ===")
|
|
path = REPO_ROOT / m.file
|
|
replace_unique(path, m.anchor, m.mutated)
|
|
code, output = run_test(args.cultlib_root, m.test)
|
|
passed = "Passed!" in output and code == 0
|
|
if m.expect == "green":
|
|
if not passed:
|
|
failures.append(f"{m.rule}: expected green (test passing), got failure\n{output[-2000:]}")
|
|
else:
|
|
print(f"OK: {m.test} passed under a no-op mutation, as expected")
|
|
else:
|
|
if passed:
|
|
failures.append(f"{m.rule}: expected red (mutant killed), but {m.test} still passed")
|
|
else:
|
|
print(f"OK: {m.test} went red under the mutation, as expected")
|
|
# Restore this file immediately so the next mutation starts from a clean tree.
|
|
path.write_text(originals[m.file], encoding="utf-8")
|
|
finally:
|
|
restore_all()
|
|
|
|
if failures:
|
|
print("\n=== FAILURES ===")
|
|
for f in failures:
|
|
print(f"- {f}")
|
|
return 1
|
|
|
|
print("\nAll mutations behaved as declared.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|