Aetheria/tests/mutation_tests_shield_panel_cut2.py

213 lines
12 KiB
Python
Raw Normal View History

Collapse the two implicit shield ellipsoids into ShieldEnvelope (Cut 2) FieldDriver derived the shield ellipsoid from its own transform.localScale; ShieldManager derived it independently from the Shield prefab instance's transform, doing the same normalise-and-project by hand against a different object. Add ShieldEnvelope as the one reader of a transform's centre, radii, surface projection, and true outward normal (derived, not authored, per R6 -- no second copy of numbers the physical MeshCollider already owns), and switch both consumers onto it, deleting their hand-rolled projections. Radii read the attached collider mesh's own bounds.extents per axis rather than assuming a flat 0.5 or 1.0: Shield.prefab's Icosphere collider measures (0.0095106, 0.01, 0.01), not a uniform round number, and the cubesphere's collider measures exactly (1,1,1) -- confirmed by a throwaway batchmode probe against the actual mesh assets, not guessed. SurfaceNormal is not normalize(p): a non-uniform ellipsoid's true normal is the gradient of the implicit surface, transformed by rotation and the INVERSE of lossyScale (a normal is a covector). The first implementation used TransformDirection (rotation and forward scale) and the batchmode probe's finite-difference gradient check caught it immediately -- exactly the plausible-looking-wrong-answer failure mode the cut map called out by name. Both original consumers keep normalize(p)-equivalent behaviour via the new SurfaceDirection method, since they only ever wanted a direction. FieldDriver falls back to its pre-cut formulas (logged once) when no ShieldEnvelope is present, since it has no guaranteed carrier yet. ShieldManager does not: every object that carries it gets an envelope in this cut, so a missing one logs an error and degrades to a fixed direction rather than recomputing a second projection. No prefab or scene file is touched here (Q5: the operator rigs prefabs and scenes by hand); ShieldPanelCut2Verify's batchmode probe instead builds synthetic transforms carrying Longinus's and Djinni's actual override values plus the real Icosphere mesh, and proves ProjectToSurface lands exactly on the ellipsoid and SurfaceNormal matches a finite-difference gradient to 1e-3. mutation_tests_shield_panel_cut2.py mutates SurfaceNormal three ways (back to normalize(p), back to TransformDirection, and a flattened 0.5 extents factor) and confirms the probe goes red for each, naming the reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 06:33:38 +02:00
#!/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")
Fix ShieldManager's missing-envelope fallback regression 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>
2026-09-18 06:45:07 +02:00
# --- 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")
Collapse the two implicit shield ellipsoids into ShieldEnvelope (Cut 2) FieldDriver derived the shield ellipsoid from its own transform.localScale; ShieldManager derived it independently from the Shield prefab instance's transform, doing the same normalise-and-project by hand against a different object. Add ShieldEnvelope as the one reader of a transform's centre, radii, surface projection, and true outward normal (derived, not authored, per R6 -- no second copy of numbers the physical MeshCollider already owns), and switch both consumers onto it, deleting their hand-rolled projections. Radii read the attached collider mesh's own bounds.extents per axis rather than assuming a flat 0.5 or 1.0: Shield.prefab's Icosphere collider measures (0.0095106, 0.01, 0.01), not a uniform round number, and the cubesphere's collider measures exactly (1,1,1) -- confirmed by a throwaway batchmode probe against the actual mesh assets, not guessed. SurfaceNormal is not normalize(p): a non-uniform ellipsoid's true normal is the gradient of the implicit surface, transformed by rotation and the INVERSE of lossyScale (a normal is a covector). The first implementation used TransformDirection (rotation and forward scale) and the batchmode probe's finite-difference gradient check caught it immediately -- exactly the plausible-looking-wrong-answer failure mode the cut map called out by name. Both original consumers keep normalize(p)-equivalent behaviour via the new SurfaceDirection method, since they only ever wanted a direction. FieldDriver falls back to its pre-cut formulas (logged once) when no ShieldEnvelope is present, since it has no guaranteed carrier yet. ShieldManager does not: every object that carries it gets an envelope in this cut, so a missing one logs an error and degrades to a fixed direction rather than recomputing a second projection. No prefab or scene file is touched here (Q5: the operator rigs prefabs and scenes by hand); ShieldPanelCut2Verify's batchmode probe instead builds synthetic transforms carrying Longinus's and Djinni's actual override values plus the real Icosphere mesh, and proves ProjectToSurface lands exactly on the ellipsoid and SurfaceNormal matches a finite-difference gradient to 1e-3. mutation_tests_shield_panel_cut2.py mutates SurfaceNormal three ways (back to normalize(p), back to TransformDirection, and a flattened 0.5 extents factor) and confirms the probe goes red for each, naming the reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 06:33:38 +02:00
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())