ShieldManager's fallback (no ShieldEnvelope yet, e.g. every ship prefab before the operator hand-rigs it per Q5) hard-failed to a fixed Vector3.forward direction with a per-hit Debug.LogError. That silently changed behaviour for every ship in the game until the operator edits Longinus.prefab/Djinni.prefab on their own schedule -- worse than either the deleted code or a loud one-time failure. Extract ShieldEnvelope's local-direction primitive into two static, no-component-required entry points -- SurfaceDirection(Transform, Vector3) and LegacyLocalSurfacePoint(Transform, Vector3) -- so both FieldDriver's and ShieldManager's fallbacks go through one owner instead of each re-deriving the pre-Cut-2 formula by hand. ShieldManager's fallback now reproduces the deleted normalize(shield.transform.InverseTransformPoint(point)) formula exactly via the shared static, logging once (a warning, not a per-hit error). FieldDriver's fallback is rewired onto the same static rather than its own inline copy of the maths. Documented, not silently assumed: FieldDriver's "envelope present" branch (envelope.ProjectToSurface, world-space) and its fallback (the legacy local- space formula) are only numerically identical when the carrier's transform has zero position and identity rotation -- true of the one FieldDriver carrier in the tree today, not true in general. This is a characteristic of Cut 2 as scoped by the map (FieldDriver.cs:125 reads envelope.ProjectToSurface directly, per the map's own text), not something this fix introduces or resolves; flagged so nobody assumes the two branches are interchangeable for a future non-origin, non-identity FieldDriver carrier. ShieldPanelCut2Verify.CheckLegacyFallbackFormulas pins both static entry points against an independent re-derivation of the exact pre-Cut-2 formulas, on a carrier with a non-trivial transform (nonzero position, non-identity rotation) so the check isn't vacuous. mutation_tests_shield_panel_cut2.py gained two cases: SurfaceDirection collapsing back to a fixed Vector3.forward (the exact regression fixed here), and LegacyLocalSurfacePoint collapsing to the raw, untransformed world point -- both correctly fail the probe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
213 lines
12 KiB
Python
213 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Mutation tests for docs/shield-panel-cut.md Cut 2 (the ShieldEnvelope landing).
|
|
|
|
Assets/Scripts/Editor/ShieldPanelCut2Verify.cs is the batchmode probe Cut 2 added, for the same
|
|
reason Cut 1 has one (the map's Q7): Tests.asmdef cannot see Assembly-CSharp, so ShieldEnvelope
|
|
(pure, testable math though it is) has no NUnit path. A probe nobody can break is a probe nobody
|
|
can trust, so this mutates the *real* source in specific ways the cut's own text calls out as the
|
|
plausible wrong answers, runs ShieldPanelCut2Verify.Run in Unity batchmode, asserts it goes red
|
|
for the reason the cut names, then restores the source exactly. A control case first proves the
|
|
unmutated tree passes.
|
|
|
|
The mutation this file exists for is the one the map calls out by name: "the ellipsoid normal is
|
|
exactly the kind of arithmetic that has a plausible-looking wrong answer" — SurfaceNormal
|
|
collapsing to normalize(p), which is only correct for a sphere and silently wrong (but
|
|
plausible-looking) for Longinus's 4:1 ellipsoid. Two more mutations cover the other two
|
|
correctness-load-bearing lines this cut introduced: the per-axis mesh-extents factor collapsing
|
|
to a flat guess, and the covector (inverse-scale) transform collapsing to the more "obvious" but
|
|
wrong TransformDirection.
|
|
|
|
This is a separate script rather than an addition to tests/mutation_tests.py (the item-provenance
|
|
cut's headless dotnet-test runner, with no path to driving a Unity batchmode check) or
|
|
tests/mutation_tests_shield_panel_cut1.py (that one owns Cut 1's rules; this one touches only
|
|
Cut 2's file, ShieldEnvelope.cs).
|
|
|
|
Run from the repo root: python tests/mutation_tests_shield_panel_cut2.py
|
|
Exit code 0 only if every case behaved as Cut 2's verification section demands.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
UNITY_EXE = r"C:\Program Files\Unity\Hub\Editor\6000.3.24f1\Editor\Unity.exe"
|
|
|
|
SHIELD_ENVELOPE_CS = REPO_ROOT / "Assets" / "Scripts" / "Gameplay" / "ShieldEnvelope.cs"
|
|
|
|
|
|
def run_probe(log_path: Path) -> tuple[int, str]:
|
|
proc = subprocess.run(
|
|
[UNITY_EXE, "-batchmode", "-nographics", "-quit", "-projectPath", str(REPO_ROOT),
|
|
"-executeMethod", "Aetheria.EditorTools.ShieldPanelCut2Verify.Run", "-logFile", str(log_path)],
|
|
timeout=600,
|
|
)
|
|
text = log_path.read_text(encoding="utf-8", errors="replace")
|
|
return proc.returncode, text
|
|
|
|
|
|
def replace_unique(path: Path, anchor: str, mutated: str) -> str:
|
|
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")
|
|
return original
|
|
|
|
|
|
def main() -> int:
|
|
scratch = REPO_ROOT / "tests" / "_scratch_shield_panel_cut2"
|
|
scratch.mkdir(parents=True, exist_ok=True)
|
|
failures: list[str] = []
|
|
|
|
original_envelope = SHIELD_ENVELOPE_CS.read_text(encoding="utf-8")
|
|
|
|
try:
|
|
# --- control: the real, unmutated tree must pass ---
|
|
print("\n=== control: unmutated tree ===")
|
|
code, text = run_probe(scratch / "control.log")
|
|
if code != 0 or "[ShieldPanelCut2Verify] PASS" not in text:
|
|
failures.append("control: expected exit 0 and PASS on the unmutated tree")
|
|
else:
|
|
print("control OK: exit 0, PASS")
|
|
|
|
# --- the mutation the map calls out by name: SurfaceNormal collapsing to normalize(p).
|
|
# This is the "plausible-looking wrong answer" — correct for a sphere, silently wrong for
|
|
# a non-uniform ellipsoid — that the map's Cut 2 verification section says must have a
|
|
# test that fails under its own mutation. ---
|
|
print("\n=== mutation: SurfaceNormal degenerates to normalize(p) ===")
|
|
anchor = (
|
|
" var s = transform.lossyScale;\n"
|
|
" var gradientOverScale = new Vector3(\n"
|
|
" s.x != 0 ? gradient.x / s.x : gradient.x,\n"
|
|
" s.y != 0 ? gradient.y / s.y : gradient.y,\n"
|
|
" s.z != 0 ? gradient.z / s.z : gradient.z);\n"
|
|
" var worldGradient = transform.rotation * gradientOverScale;"
|
|
)
|
|
mutated = (
|
|
" // mutation_tests_shield_panel_cut2.py: reintroduce normalize(p) as the normal —\n"
|
|
" // correct for a sphere, plausible-looking, wrong for a non-uniform ellipsoid.\n"
|
|
" var worldGradient = transform.TransformDirection(local);"
|
|
)
|
|
try:
|
|
replace_unique(SHIELD_ENVELOPE_CS, anchor, mutated)
|
|
code, text = run_probe(scratch / "normalize_p.log")
|
|
caught = "SurfaceNormal equals normalize(p)" in text or "disagrees with the finite-difference gradient" in text
|
|
if code == 0 or not caught:
|
|
failures.append("normalize(p) regression: probe did not go red naming the sphere-vs-ellipsoid distinction")
|
|
else:
|
|
print("OK: caught — probe exited non-zero, sphere-vs-ellipsoid distinction check fired")
|
|
finally:
|
|
SHIELD_ENVELOPE_CS.write_text(original_envelope, encoding="utf-8")
|
|
|
|
# --- the covector-transform bug this cut's own Hands run hit and fixed: dividing by scale
|
|
# is required (a normal is a covector), multiplying (TransformDirection) is the "obvious"
|
|
# but wrong choice for a non-uniform scale. ---
|
|
print("\n=== mutation: SurfaceNormal multiplies by scale instead of dividing (TransformDirection) ===")
|
|
anchor = (
|
|
" var s = transform.lossyScale;\n"
|
|
" var gradientOverScale = new Vector3(\n"
|
|
" s.x != 0 ? gradient.x / s.x : gradient.x,\n"
|
|
" s.y != 0 ? gradient.y / s.y : gradient.y,\n"
|
|
" s.z != 0 ? gradient.z / s.z : gradient.z);\n"
|
|
" var worldGradient = transform.rotation * gradientOverScale;"
|
|
)
|
|
mutated = (
|
|
" // mutation_tests_shield_panel_cut2.py: the \"obvious\" wrong transform — a normal\n"
|
|
" // is a covector and must be scaled by the INVERSE of lossyScale, not lossyScale.\n"
|
|
" var worldGradient = transform.TransformDirection(gradient);"
|
|
)
|
|
try:
|
|
replace_unique(SHIELD_ENVELOPE_CS, anchor, mutated)
|
|
code, text = run_probe(scratch / "transformdirection.log")
|
|
caught = "disagrees with the finite-difference gradient" in text
|
|
if code == 0 or not caught:
|
|
failures.append("TransformDirection regression: probe did not go red naming the finite-difference disagreement")
|
|
else:
|
|
print("OK: caught — probe exited non-zero, finite-difference gradient check fired")
|
|
finally:
|
|
SHIELD_ENVELOPE_CS.write_text(original_envelope, encoding="utf-8")
|
|
|
|
# --- the mesh-extents factor collapsing to a flat, wrong guess (the map's own draft
|
|
# guessed 0.5; this checks the fix stays measured-per-axis, not re-flattened). ---
|
|
print("\n=== mutation: Radii factor flattened to a guessed 0.5 instead of the measured mesh extents ===")
|
|
anchor = "_localExtents = mesh != null ? mesh.bounds.extents : Vector3.one;"
|
|
mutated = "_localExtents = mesh != null ? new Vector3(0.5f, 0.5f, 0.5f) : Vector3.one;"
|
|
try:
|
|
replace_unique(SHIELD_ENVELOPE_CS, anchor, mutated)
|
|
code, text = run_probe(scratch / "flat_half.log")
|
|
caught = "does not match lossyScale*measuredExtents" in text
|
|
if code == 0 or not caught:
|
|
failures.append("flat-0.5 regression: probe did not go red naming the Radii mismatch")
|
|
else:
|
|
print("OK: caught — probe exited non-zero, Radii mismatch check fired")
|
|
finally:
|
|
SHIELD_ENVELOPE_CS.write_text(original_envelope, encoding="utf-8")
|
|
|
|
# --- the regression the coordinator flagged directly: ShieldManager's (and FieldDriver's)
|
|
# missing-envelope fallback must reproduce the deleted pre-Cut-2 formula, not silently swap
|
|
# in a fixed direction. This mutates the shared static SurfaceDirection entry point back to
|
|
# the fixed Vector3.forward every hit direction degraded to before the fix. ---
|
|
print("\n=== mutation: SurfaceDirection fallback collapses to a fixed Vector3.forward ===")
|
|
anchor = (
|
|
" public static Vector3 SurfaceDirection(Transform transform, Vector3 worldPoint)\n"
|
|
" => LocalUnitDirection(transform, worldPoint);"
|
|
)
|
|
mutated = (
|
|
" // mutation_tests_shield_panel_cut2.py: reintroduce the exact regression the\n"
|
|
" // coordinator flagged -- a fallback that silently changes behaviour instead of\n"
|
|
" // reproducing the deleted formula.\n"
|
|
" public static Vector3 SurfaceDirection(Transform transform, Vector3 worldPoint)\n"
|
|
" => Vector3.forward;"
|
|
)
|
|
try:
|
|
replace_unique(SHIELD_ENVELOPE_CS, anchor, mutated)
|
|
code, text = run_probe(scratch / "fixed_forward.log")
|
|
caught = "SurfaceDirection(transform, p) fallback diverged" in text
|
|
if code == 0 or not caught:
|
|
failures.append("fixed-Vector3.forward regression: probe did not go red naming the SurfaceDirection divergence")
|
|
else:
|
|
print("OK: caught — probe exited non-zero, legacy fallback pin fired")
|
|
finally:
|
|
SHIELD_ENVELOPE_CS.write_text(original_envelope, encoding="utf-8")
|
|
|
|
# --- the other fallback static, mutated to the "wrong transform" shape the coordinator
|
|
# named: dropping the local-frame projection and returning the raw world point instead. ---
|
|
print("\n=== mutation: LegacyLocalSurfacePoint fallback returns the raw world point ===")
|
|
anchor = (
|
|
" public static Vector3 LegacyLocalSurfacePoint(Transform transform, Vector3 worldPoint)\n"
|
|
" => Vector3.Scale(LocalUnitDirection(transform, worldPoint), transform.localScale);"
|
|
)
|
|
mutated = (
|
|
" // mutation_tests_shield_panel_cut2.py: the \"wrong transform\" mutation -- skip the\n"
|
|
" // local-frame projection entirely and hand back the untransformed world point.\n"
|
|
" public static Vector3 LegacyLocalSurfacePoint(Transform transform, Vector3 worldPoint)\n"
|
|
" => worldPoint;"
|
|
)
|
|
try:
|
|
replace_unique(SHIELD_ENVELOPE_CS, anchor, mutated)
|
|
code, text = run_probe(scratch / "wrong_transform.log")
|
|
caught = "LegacyLocalSurfacePoint(transform, p) fallback diverged" in text
|
|
if code == 0 or not caught:
|
|
failures.append("raw-world-point regression: probe did not go red naming the LegacyLocalSurfacePoint divergence")
|
|
else:
|
|
print("OK: caught — probe exited non-zero, legacy fallback pin fired")
|
|
finally:
|
|
SHIELD_ENVELOPE_CS.write_text(original_envelope, encoding="utf-8")
|
|
|
|
finally:
|
|
# belt-and-suspenders: never leave a mutated file behind even if something above threw
|
|
SHIELD_ENVELOPE_CS.write_text(original_envelope, encoding="utf-8")
|
|
|
|
print("\n=== summary ===")
|
|
if failures:
|
|
for f in failures:
|
|
print(f"FAIL: {f}")
|
|
return 1
|
|
print(f"All cases behaved as Cut 2's verification section demands. Logs: {scratch}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|