Aetheria/Assets/Scripts/Editor/ShieldPanelCut5Verify.cs

550 lines
29 KiB
C#
Raw Normal View History

Cut 5: hold the rim, clamp v, fix fracture's clock, tune to spec R7 (operator ruling): the panel's emitters hold its edge, so the rim is no longer a free surface. Deletes the openW ghost term end to end -- its only consumer in ShieldSim.compute's Laplacian, and the accumulation loop in TilingBuilder that built it -- rather than zeroing it at runtime. A broken cell's own faces are untouched (KWaveStep's per-edge `solid` check already treats a broken/ungrown neighbour as free), so the dicing cascade survives. D9: v was unclamped while u was, so a hard hit drove v far past what one substep's u-clamp could absorb, pinning u in compression indefinitely (the measured collapse above energy ~60). Added `_VelClamp` and clamp v to it every substep in KWaveStep, exposed as ShieldPanel.velocityClamp (100). D10: KFracture dispatched once per frame but read `_Dt` still holding the substep dt from the wave loop, so damage and temper erosion accrued at 1/substeps of the intended rate. Fracture's clock is the frame it settles once per, not the substep, so it now gets the frame dt -- one line, not the five-extra-dispatches-per-panel alternative (dispatch count is the measured per-panel cost at up to 12 live panels). D15: zero-init BuildLine's output struct so the compute compiler's dataflow analysis stops flagging every branch as a potential non-initializer. Tuning (ShieldPanel.cs Material header): tensileStrength 0.9->0.2, temperErosion 2.2->12, damageGain 6->100, breakAt 0.75->0.08, storedEnergyGain 0.8->1 -- makes a solid hit dice a local cluster around impact, a weak hit leave nothing, and repeated weak hits erode temper substantially without breaking anything outright. Adds ShieldPanelCut5Verify (batchmode probe, same pattern as Cuts 1-3): pins monotonic peak tension against energy (no D9-style collapse), solid vs. weak hit outcomes, temper erosion under repeated sub-critical hits, breaks clustering at the impact rather than the rim (R7), and a broken cell's neighbours breaking more often than distant cells (the cascade). D9 and D10 mutations were manually reverted and confirmed to fail their respective checks; R7's reintroduced-rim mutation did not reliably fail the cluster check within this probe's energy/step budget -- noted as a known gap rather than papered over. The map's own operator-level check ("repeated hits on the same spot break through on a later hit than the first") is left to play: a closing hit landed after several prior weak hits on one live panel instance measured a materially lower peak tension than the same energy against a fresh panel, a reproducible multi-hit interaction this cut did not chase to ground. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 20:04:05 +02:00
using System.Linq;
using System.Reflection;
using ShieldField;
using UnityEditor;
using UnityEngine;
namespace Aetheria.EditorTools
{
/// <summary>
/// Batchmode probe for the shield-panel Cut 5 landing (docs/shield-panel-cut.md): R7 (the rim
/// is held, not free — the `openW` ghost term is gone), D9 (v was unclamped and pinned u in
/// compression above ~energy 60), D10 (KFracture ran once per frame but integrated with the
/// substep dt), and the tuning pass that makes the design's targets reachable.
///
/// Pins BEHAVIOUR RULES, not today's numbers, per the brief: monotonic peak tension against
/// energy, a solid hit breaks / a weak hit does not, repeated weak hits erode temper into an
/// eventual break, breaks cluster near the impact rather than the rim (R7's whole point), and
/// a broken cell's neighbours are more likely to break than distant cells (the dicing cascade).
///
/// Same reason as Cut 3's probe: this drives ShieldPanel's actual compute dispatches, so it
/// needs a real graphics device -- run WITHOUT -nographics, or every kernel lookup fails with
/// "Kernel 'KInit' not found", which reads like a broken shader and is not one.
///
/// Run via: Unity -batchmode -projectPath &lt;repo&gt; -executeMethod
/// Aetheria.EditorTools.ShieldPanelCut5Verify.Run -quit -logFile &lt;path&gt;
/// </summary>
public static class ShieldPanelCut5Verify
{
const string Tag = "[ShieldPanelCut5Verify]";
static readonly MethodInfo PanelUpdate =
typeof(ShieldPanel).GetMethod("Update", BindingFlags.NonPublic | BindingFlags.Instance);
public static void Run()
{
if (SystemInfo.graphicsDeviceType == UnityEngine.Rendering.GraphicsDeviceType.Null)
{
Debug.LogError($"{Tag} FAIL: no graphics device in this session. Rerun batchmode " +
"without -nographics (see ShieldPanelCut3Verify's note).");
EditorApplication.Exit(1);
return;
}
bool ok = true;
ok &= CheckMonotonicTensionSweep();
ok &= CheckSolidHitBreaksWeakDoesNot();
ok &= CheckRepeatedWeakHitsErodeIntoBreak();
Cut 5 follow-up: strengthen the echo so weak-hit tension actually clears zero Cut 5's own probe passed while breaking Cut 3's reuse rule: with R7 holding the rim, the through-thickness echo (KEcho/_EchoReflect) is the only remaining source of sub-critical tension for a weak hit, and its magnitude (-0.65) was weaker than the still-live residual compression velocity left by the direct KInject kick by the time the echo fired. The echo's own header comment says a free-surface reflection inverts with unit magnitude, matching the pre-R7 rim's Dirichlet condition -- -0.65 was a lossy fraction that could reduce but never flip the sign of u, so a weak hit's u never crossed positive and KFracture's `u <= 0` guard skipped the sub-critical erosion branch forever, no matter how many hits landed. Raised echoReflect from -0.65 to -0.9 (ShieldPanel.cs) -- enough margin to flip a weak hit's residual compression into net tension without needing a delay/seeding change; echoDelaySteps and KInject's seeding were not the defect. All four probes now pass, including Cut 3's reuse rule (temper 0.4180 -> 0.3997 on a reused panel). Also lands the multi-hit-reuse rule Cut 5's own report flagged as unpinned: CheckRepeatedWeakHitsBreakWhereOneDoesNot in ShieldPanelCut5Verify.cs. Energy 15 (not 8, whose peak tension of ~0.14 sits under tensileStrength 0.2 and can never break at any repetition) breaks on hit 2 of a live, reused panel while a cold single hit -- given equal idle time (720 steps, matching the multi-hit run's total budget, to rule out "just needed more time to ring out") -- never breaks. This is the Cut 3 reuse rule and the temper mechanism producing the operator-facing payoff together. Verified: ShieldPanelCut1Verify, Cut2Verify, Cut3Verify, Cut5Verify all PASS via batchmode (graphics device required, not -nographics). Operator watch: echoReflect at -0.9 is close to the physical "unit inversion" ceiling implied by the header comment; if later tuning raises hit energies or lowers damping, watch for the echo overshooting into an immediate break on a single moderate hit (observed at -1.4 and even -1.0 in earlier tuning passes) rather than the intended multi-hit erosion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 20:22:51 +02:00
ok &= CheckRepeatedWeakHitsBreakWhereOneDoesNot();
Cut 5: hold the rim, clamp v, fix fracture's clock, tune to spec R7 (operator ruling): the panel's emitters hold its edge, so the rim is no longer a free surface. Deletes the openW ghost term end to end -- its only consumer in ShieldSim.compute's Laplacian, and the accumulation loop in TilingBuilder that built it -- rather than zeroing it at runtime. A broken cell's own faces are untouched (KWaveStep's per-edge `solid` check already treats a broken/ungrown neighbour as free), so the dicing cascade survives. D9: v was unclamped while u was, so a hard hit drove v far past what one substep's u-clamp could absorb, pinning u in compression indefinitely (the measured collapse above energy ~60). Added `_VelClamp` and clamp v to it every substep in KWaveStep, exposed as ShieldPanel.velocityClamp (100). D10: KFracture dispatched once per frame but read `_Dt` still holding the substep dt from the wave loop, so damage and temper erosion accrued at 1/substeps of the intended rate. Fracture's clock is the frame it settles once per, not the substep, so it now gets the frame dt -- one line, not the five-extra-dispatches-per-panel alternative (dispatch count is the measured per-panel cost at up to 12 live panels). D15: zero-init BuildLine's output struct so the compute compiler's dataflow analysis stops flagging every branch as a potential non-initializer. Tuning (ShieldPanel.cs Material header): tensileStrength 0.9->0.2, temperErosion 2.2->12, damageGain 6->100, breakAt 0.75->0.08, storedEnergyGain 0.8->1 -- makes a solid hit dice a local cluster around impact, a weak hit leave nothing, and repeated weak hits erode temper substantially without breaking anything outright. Adds ShieldPanelCut5Verify (batchmode probe, same pattern as Cuts 1-3): pins monotonic peak tension against energy (no D9-style collapse), solid vs. weak hit outcomes, temper erosion under repeated sub-critical hits, breaks clustering at the impact rather than the rim (R7), and a broken cell's neighbours breaking more often than distant cells (the cascade). D9 and D10 mutations were manually reverted and confirmed to fail their respective checks; R7's reintroduced-rim mutation did not reliably fail the cluster check within this probe's energy/step budget -- noted as a known gap rather than papered over. The map's own operator-level check ("repeated hits on the same spot break through on a later hit than the first") is left to play: a closing hit landed after several prior weak hits on one live panel instance measured a materially lower peak tension than the same energy against a fresh panel, a reproducible multi-hit interaction this cut did not chase to ground. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 20:04:05 +02:00
ok &= CheckBreaksClusterNearImpactNotRim();
ok &= CheckNeighboursMoreLikelyToBreak();
Debug.Log(ok ? $"{Tag} PASS" : $"{Tag} FAIL — see errors above");
EditorApplication.Exit(ok ? 0 : 1);
}
// ==============================================================
static (GameObject go, ShieldPanel panel) BuildPanel(float panelRadius = 3f, float cellSize = 0.4f)
{
var sim = AssetDatabase.LoadAssetAtPath<ComputeShader>(
"Assets/Shaders/Compute/ShieldField/ShieldSim.compute");
var shader = AssetDatabase.LoadAssetAtPath<Shader>(
"Assets/Shaders/Compute/ShieldField/ShieldPanel.shader");
var go = new GameObject("Cut5Probe_Panel");
var panel = go.AddComponent<ShieldPanel>();
panel.tiling = TilingKind.Hex;
panel.panelRadius = panelRadius;
panel.cellSize = cellSize;
panel.sim = sim;
panel.panelShader = shader;
panel.Rebuild();
return (go, panel);
}
static void Step(ShieldPanel panel, int n = 1)
{
for (int i = 0; i < n; i++) PanelUpdate.Invoke(panel, null);
}
static void Strike(ShieldPanel panel, Vector2 localPos, float energy) =>
panel.Hit(panel.transform.position + new Vector3(localPos.x, localPos.y, 0),
Vector3.back, energy);
// Mirrors ShieldField.CellState's layout, kept local exactly as Cut 3's probe does (a
// future field reorder in the production struct is this probe's business to catch).
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
struct CellStateRaw
{
public Vector3 vel; public float spin; public Vector3 pos; public float rot;
public float damage; public float breakTime; public float glow; public float growth;
public float temper; public float released; public float peakTension; public float pad2;
}
static CellStateRaw[] ReadState(ShieldPanel panel)
{
var raw = new CellStateRaw[panel.DebugCellCount];
panel.DebugStateBuffer.GetData(raw);
return raw;
}
static Vector2[] Centers(ShieldPanel panel)
{
var c = new Vector2[panel.DebugCellCount];
for (int i = 0; i < c.Length; i++) c[i] = panel.DebugCellCenter(i);
return c;
}
// ==============================================================
// D9: peak tension must rise monotonically (within noise) with energy over the authored
// range, never collapse the way the unclamped-v defect measured past ~energy 60.
// ==============================================================
static bool CheckMonotonicTensionSweep()
{
float[] energies = { 5f, 10f, 20f, 30f, 45f, 60f, 80f, 100f, 140f, 180f, 240f };
float[] peak = new float[energies.Length];
for (int i = 0; i < energies.Length; i++)
{
var (go, panel) = BuildPanel();
try
{
// Isolate D9 (the wave's velocity clamp) from the fracture cascade: once cells
// start breaking, KFracture's neighbour feedback injects extra tension that has
// nothing to do with the wave integrator, which would confound this specific
// check. breakAt = 2 (above damage's [0,1] range) means nothing can ever cross
// it, so this measures pure elastic peak tension against energy.
panel.breakAt = 2f;
Strike(panel, Vector2.zero, energies[i]);
Step(panel, 150);
var st = ReadState(panel);
peak[i] = st.Max(s => s.peakTension);
int brokenDbg = st.Count(s => s.breakTime >= 0f);
if (brokenDbg > 0)
Debug.LogWarning($"{Tag} DIAG sweep E={energies[i]}: {brokenDbg} broken despite breakAt override!");
}
finally { UnityEngine.Object.DestroyImmediate(go); }
}
string trace = string.Join(", ", energies.Zip(peak, (e, p) => $"E={e:F0}->{p:F3}"));
Debug.Log($"{Tag} tension sweep: {trace}");
// This is a clamped nonlinear wave PDE sampled after a fixed settling window, not a
// strictly monotone function -- point-to-point wobble of a few tens of percent is
// expected numerical texture, not the defect. The defect this guards against has a
// specific, much larger shape: the map measured tension COLLAPSING above energy ~60,
// i.e. falling back toward the low-energy baseline once v pinned the field in
// compression. So the rule is a running-max floor: no sample may fall below half of
// the best result seen at any lower energy so far. That tolerates ordinary wobble
// (worst observed in tuning: ~55% of the running max) while still catching a real
// collapse (which drops toward the near-zero baseline, not to 50-99% of it).
bool ok = true;
float runningMax = 0f;
for (int i = 0; i < peak.Length; i++)
{
if (i > 0 && peak[i] < runningMax * 0.5f)
{
Debug.LogError($"{Tag} D9 not fixed: tension collapsed at energy {energies[i]:F0} " +
$"(peak {peak[i]:F3} vs running max {runningMax:F3}).");
ok = false;
}
runningMax = Mathf.Max(runningMax, peak[i]);
}
float overallMax = runningMax;
// Negative signature from the map: no energy in range may leave the field completely
// flat (maxPeakTension == 0), which would mean nothing is reaching the material at all.
if (overallMax <= 1e-4f)
{
Debug.LogError($"{Tag} D9 negative check failed: no energy in the sweep produced any " +
"measurable peak tension (the saturation-to-zero signature).");
ok = false;
}
if (ok) Debug.Log($"{Tag} D9 OK: tension never falls below half its running max across the " +
$"sweep (peak {overallMax:F3}) -- no collapse.");
return ok;
}
// ==============================================================
static bool CheckSolidHitBreaksWeakDoesNot()
{
bool ok = true;
var (weakGo, weakPanel) = BuildPanel();
try
{
Strike(weakPanel, Vector2.zero, 8f);
Step(weakPanel, 150);
int broken = ReadState(weakPanel).Count(s => s.breakTime >= 0f);
if (broken != 0)
{
Debug.LogError($"{Tag} weak hit broke {broken} cells; expected 0.");
ok = false;
}
else Debug.Log($"{Tag} weak hit OK: 0 cells broken.");
}
finally { UnityEngine.Object.DestroyImmediate(weakGo); }
var (strongGo, strongPanel) = BuildPanel();
try
{
int total = strongPanel.DebugCellCount;
Strike(strongPanel, Vector2.zero, 300f);
Step(strongPanel, 200);
int broken = ReadState(strongPanel).Count(s => s.breakTime >= 0f);
float frac = (float)broken / total;
if (broken < 2)
{
Debug.LogError($"{Tag} solid hit broke {broken} of {total} cells; expected a local " +
"cluster (>=2), not a single cell or nothing.");
ok = false;
}
else if (frac > 0.6f)
{
Debug.LogError($"{Tag} solid hit broke {broken}/{total} ({frac:P0}) — that's the whole panel, " +
"not a local shatter; tuning has overshot.");
ok = false;
}
else Debug.Log($"{Tag} solid hit OK: broke {broken}/{total} ({frac:P0}).");
}
finally { UnityEngine.Object.DestroyImmediate(strongGo); }
return ok;
}
// ==============================================================
// Temper erosion: repeated sub-critical hits must measurably wear the temper down toward
// the material's critical margin ("this is the multi-hit budget" -- ShieldSim.compute).
//
// NOTE ON SCOPE: the map's own text frames "a later hit breaks through where the first
// didn't" as an OPERATOR check (Cut 5's own Verification list marks it "operator:", not
// "test:"), and this probe tried to also pin it as an automated rule. It could not: a
// closing hit landed AFTER several prior weak hits on the SAME live panel instance
// measured a materially lower peak tension than the identical energy landed on a FRESH
// panel (e.g. energy 20 broke a fresh panel's control only one increment away at energy
// 21, yet produced no measurable rise over the weak hits' own peak when landed after three
// prior weak hits on one panel) -- a real, reproducible interaction between repeated
// strikes on one live instance that this cut did not chase to ground. Flagged for the
// operator's own repeated-hit play check and for a follow-up investigation; not silently
// asserted as passing. What IS pinned here, reliably: erosion itself measurably progresses.
Cut 5 follow-up: strengthen the echo so weak-hit tension actually clears zero Cut 5's own probe passed while breaking Cut 3's reuse rule: with R7 holding the rim, the through-thickness echo (KEcho/_EchoReflect) is the only remaining source of sub-critical tension for a weak hit, and its magnitude (-0.65) was weaker than the still-live residual compression velocity left by the direct KInject kick by the time the echo fired. The echo's own header comment says a free-surface reflection inverts with unit magnitude, matching the pre-R7 rim's Dirichlet condition -- -0.65 was a lossy fraction that could reduce but never flip the sign of u, so a weak hit's u never crossed positive and KFracture's `u <= 0` guard skipped the sub-critical erosion branch forever, no matter how many hits landed. Raised echoReflect from -0.65 to -0.9 (ShieldPanel.cs) -- enough margin to flip a weak hit's residual compression into net tension without needing a delay/seeding change; echoDelaySteps and KInject's seeding were not the defect. All four probes now pass, including Cut 3's reuse rule (temper 0.4180 -> 0.3997 on a reused panel). Also lands the multi-hit-reuse rule Cut 5's own report flagged as unpinned: CheckRepeatedWeakHitsBreakWhereOneDoesNot in ShieldPanelCut5Verify.cs. Energy 15 (not 8, whose peak tension of ~0.14 sits under tensileStrength 0.2 and can never break at any repetition) breaks on hit 2 of a live, reused panel while a cold single hit -- given equal idle time (720 steps, matching the multi-hit run's total budget, to rule out "just needed more time to ring out") -- never breaks. This is the Cut 3 reuse rule and the temper mechanism producing the operator-facing payoff together. Verified: ShieldPanelCut1Verify, Cut2Verify, Cut3Verify, Cut5Verify all PASS via batchmode (graphics device required, not -nographics). Operator watch: echoReflect at -0.9 is close to the physical "unit inversion" ceiling implied by the header comment; if later tuning raises hit energies or lowers damping, watch for the echo overshooting into an immediate break on a single moderate hit (observed at -1.4 and even -1.0 in earlier tuning passes) rather than the intended multi-hit erosion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 20:22:51 +02:00
// ==============================================================
// The operator-facing payoff Cut 5's own report could not pin (see the scope note on
// CheckRepeatedWeakHitsErodeIntoBreak above): N weak hits on one LIVE panel eventually
// break a cell where one weak hit -- even given the same total time to sit and do nothing
// -- does not. energy 15 is calibrated (not energy 8, which erodes temper to 0 but whose
// peak tension of ~0.14 never reaches tensileStrength=0.2, so no amount of repetition would
// ever cross the break threshold -- a genuine dead end at that energy, not a step-budget
// problem): at 15, single-hit peak tension (~0.26) sits below temperInit (0.55) so a cold
// panel absorbs it elastically, but once erosion (Cut 3's reuse mechanism) drops temper far
// enough, that same ~0.26 peak clears net >= tensileStrength and breaks.
static bool CheckRepeatedWeakHitsBreakWhereOneDoesNot()
{
const float weakEnergy = 15f;
const int settleSteps = 120;
const int maxHits = 6; // generous headroom over the 2 hits actually needed at this tuning
// Control first: one hit, then sit idle for the SAME total step budget the multi-hit
// run below could consume (maxHits * settleSteps) -- rules out "it just needed more
// time to ring out", which would be a timing artifact, not the reuse/erosion payoff.
var (controlGo, controlPanel) = BuildPanel();
int controlBroken;
float controlPeak;
try
{
Strike(controlPanel, Vector2.zero, weakEnergy);
Step(controlPanel, settleSteps * maxHits);
var raw = ReadState(controlPanel);
controlBroken = raw.Count(s => s.breakTime >= 0f);
controlPeak = raw.Max(s => s.peakTension);
}
finally { UnityEngine.Object.DestroyImmediate(controlGo); }
if (controlBroken > 0)
{
Debug.LogError($"{Tag} multi-hit control: a single energy-{weakEnergy:F0} hit broke " +
$"{controlBroken} cells even after {settleSteps * maxHits} idle steps -- " +
"not weak enough to be a control.");
return false;
}
// Repeated hits on ONE live panel, same energy, same strike point.
var (go, panel) = BuildPanel();
try
{
int hitsToBreak = -1;
int broken = 0;
float lastPeak = 0f;
for (int hit = 1; hit <= maxHits; hit++)
{
Strike(panel, Vector2.zero, weakEnergy);
Step(panel, settleSteps);
var raw = ReadState(panel);
broken = raw.Count(s => s.breakTime >= 0f);
lastPeak = raw.Max(s => s.peakTension);
if (broken > 0) { hitsToBreak = hit; break; }
}
Debug.Log($"{Tag} multi-hit reuse: control (1 hit + {settleSteps * maxHits} idle steps) " +
$"broke 0 cells (peak tension {controlPeak:F3}); on a live reused panel the " +
$"same energy-{weakEnergy:F0} hit broke through on hit {hitsToBreak} " +
$"(peak tension {lastPeak:F3}, {broken} cell(s) broken).");
if (hitsToBreak < 0)
{
Debug.LogError($"{Tag} multi-hit reuse not evident: {maxHits} repeated energy-" +
$"{weakEnergy:F0} hits on one live panel never broke a cell (control " +
"also did not break, so this is not merely 'nothing breaks at this " +
"energy' -- the reuse/erosion payoff did not materialise).");
return false;
}
if (hitsToBreak < 2)
{
Debug.LogError($"{Tag} multi-hit reuse not evident: the live panel broke on hit 1, " +
"same as a cold panel would -- this doesn't demonstrate reuse eroding " +
"toward a break, it demonstrates the energy alone already breaks fresh.");
return false;
}
Debug.Log($"{Tag} multi-hit reuse OK: hit 1 alone (and a cold panel given equal idle time) " +
$"do not break; hit {hitsToBreak} on the same live, reused panel does -- the Cut " +
"3 reuse rule and the temper mechanism together produce the operator payoff.");
return true;
}
finally { UnityEngine.Object.DestroyImmediate(go); }
}
Cut 5: hold the rim, clamp v, fix fracture's clock, tune to spec R7 (operator ruling): the panel's emitters hold its edge, so the rim is no longer a free surface. Deletes the openW ghost term end to end -- its only consumer in ShieldSim.compute's Laplacian, and the accumulation loop in TilingBuilder that built it -- rather than zeroing it at runtime. A broken cell's own faces are untouched (KWaveStep's per-edge `solid` check already treats a broken/ungrown neighbour as free), so the dicing cascade survives. D9: v was unclamped while u was, so a hard hit drove v far past what one substep's u-clamp could absorb, pinning u in compression indefinitely (the measured collapse above energy ~60). Added `_VelClamp` and clamp v to it every substep in KWaveStep, exposed as ShieldPanel.velocityClamp (100). D10: KFracture dispatched once per frame but read `_Dt` still holding the substep dt from the wave loop, so damage and temper erosion accrued at 1/substeps of the intended rate. Fracture's clock is the frame it settles once per, not the substep, so it now gets the frame dt -- one line, not the five-extra-dispatches-per-panel alternative (dispatch count is the measured per-panel cost at up to 12 live panels). D15: zero-init BuildLine's output struct so the compute compiler's dataflow analysis stops flagging every branch as a potential non-initializer. Tuning (ShieldPanel.cs Material header): tensileStrength 0.9->0.2, temperErosion 2.2->12, damageGain 6->100, breakAt 0.75->0.08, storedEnergyGain 0.8->1 -- makes a solid hit dice a local cluster around impact, a weak hit leave nothing, and repeated weak hits erode temper substantially without breaking anything outright. Adds ShieldPanelCut5Verify (batchmode probe, same pattern as Cuts 1-3): pins monotonic peak tension against energy (no D9-style collapse), solid vs. weak hit outcomes, temper erosion under repeated sub-critical hits, breaks clustering at the impact rather than the rim (R7), and a broken cell's neighbours breaking more often than distant cells (the cascade). D9 and D10 mutations were manually reverted and confirmed to fail their respective checks; R7's reintroduced-rim mutation did not reliably fail the cluster check within this probe's energy/step budget -- noted as a known gap rather than papered over. The map's own operator-level check ("repeated hits on the same spot break through on a later hit than the first") is left to play: a closing hit landed after several prior weak hits on one live panel instance measured a materially lower peak tension than the same energy against a fresh panel, a reproducible multi-hit interaction this cut did not chase to ground. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 20:04:05 +02:00
// ==============================================================
static bool CheckRepeatedWeakHitsErodeIntoBreak()
{
const int erosionHits = 6;
const float weakEnergy = 8f; // confirmed elsewhere: never breaks a fresh panel alone
var (go, panel) = BuildPanel();
try
{
float temperInit = panel.temperInit;
float temperAfterOne = float.NaN;
for (int hit = 1; hit <= erosionHits; hit++)
{
Strike(panel, Vector2.zero, weakEnergy);
Step(panel, 120); // let each weak hit's wave settle before the next
if (hit == 1) temperAfterOne = ReadState(panel).Min(s => s.temper);
}
var raw = ReadState(panel);
int broken = raw.Count(s => s.breakTime >= 0f);
float temperFinal = raw.Min(s => s.temper);
Debug.Log($"{Tag} temper erosion: min temper {temperInit:F3} -> {temperAfterOne:F3} " +
$"(after 1 hit) -> {temperFinal:F3} (after {erosionHits} hits); {broken} cells " +
"broken along the way (not required by this rule -- see the scope note above).");
if (broken > 0)
{
Debug.LogError($"{Tag} temper erosion: {broken} cells broke from weak hits alone -- " +
"energy 8 is not actually sub-critical at the current tuning.");
return false;
}
// The erosion rule itself: repeated sub-critical hits must cut temper by more than
// half of its starting budget -- a real, substantial erosion, not numerical noise.
if (temperFinal > temperInit * 0.5f)
{
Debug.LogError($"{Tag} temper erosion not evident: {erosionHits} weak hits only " +
$"eroded temper from {temperInit:F3} to {temperFinal:F3}.");
return false;
}
// And it must be MONOTONE progress, not a one-shot drop that then does nothing --
// hit 1 alone must not already have done everything hit 6 achieves.
if (temperFinal >= temperAfterOne - 1e-4f)
{
Debug.LogError($"{Tag} temper erosion not evident: temper after 1 hit ({temperAfterOne:F3}) " +
$"is not measurably above temper after {erosionHits} hits ({temperFinal:F3}) -- " +
"erosion isn't accumulating across repeated hits.");
return false;
}
Debug.Log($"{Tag} temper erosion OK: {erosionHits} weak hits eroded temper " +
$"{temperInit:F3} -> {temperFinal:F3} without breaking anything.");
return true;
}
finally { UnityEngine.Object.DestroyImmediate(go); }
}
// ==============================================================
// R7: strike an interior point that is neither the panel centre nor the rim, and confirm
// breaks cluster tightly around THAT point rather than smearing toward the boundary. Under
// the old free-rim term, a strong hit's compressive wave reflects off the whole boundary as
// tension, which (given enough travel time) preferentially loads the rim rather than the
// impact -- exactly the "shatters only near the rim" symptom the operator reported.
// ==============================================================
static bool CheckBreaksClusterNearImpactNotRim()
{
// Same panelRadius/cellSize as the solid-hit check, which is the configuration this
// cut's tuning was actually calibrated against (a bigger panel at the same cellSize
// pushes the growth wavefront's spawnDelay timing out for cells far from centre and
// starves the cascade of frames to work with inside this probe's fixed step budget).
const float panelRadius = 3f;
var impact = new Vector2(0.9f, 0.2f); // interior, well off both centre and rim (|impact| ~= 0.92 vs rim 3)
var (go, panel) = BuildPanel(panelRadius: panelRadius, cellSize: 0.4f);
try
{
Strike(panel, impact, 300f);
Step(panel, 200);
var state = ReadState(panel);
var centers = Centers(panel);
int total = state.Length;
var brokenDist = new System.Collections.Generic.List<float>();
for (int i = 0; i < total; i++)
if (state[i].breakTime >= 0f) brokenDist.Add(Vector2.Distance(centers[i], impact));
if (brokenDist.Count == 0)
{
Debug.LogError($"{Tag} R7 cluster check: the strong off-centre hit broke nothing; " +
"cannot evaluate clustering.");
return false;
}
float meanAll = 0f;
for (int i = 0; i < total; i++) meanAll += Vector2.Distance(centers[i], impact);
meanAll /= total;
float meanBroken = brokenDist.Average();
float maxBroken = brokenDist.Max();
Debug.Log($"{Tag} R7 cluster: {brokenDist.Count}/{total} broken, meanDistToImpact(broken)=" +
$"{meanBroken:F3}, meanDistToImpact(all)={meanAll:F3}, maxDistToImpact(broken)=" +
$"{maxBroken:F3}, panelRadius={panelRadius}.");
bool ok = true;
if (meanBroken > meanAll * 0.35f)
{
Debug.LogError($"{Tag} R7 not held: broken cells' mean distance to impact ({meanBroken:F3}) " +
$"is not tightly clustered against the panel-wide mean ({meanAll:F3}) — " +
"breaks are spreading rather than localising at the hit.");
ok = false;
}
// Distance from impact to the nearest rim point is panelRadius - |impact|; breaks
// reaching anywhere near that distance means the rim, not the impact, is spalling.
float distToRim = panelRadius - impact.magnitude;
if (maxBroken > distToRim * 0.6f)
{
Debug.LogError($"{Tag} R7 not held: a broken cell sits {maxBroken:F3} from the impact, " +
$"{maxBroken / distToRim:P0} of the way to the rim ({distToRim:F3} away) — " +
"the rim is still acting as a stress source.");
ok = false;
}
if (ok) Debug.Log($"{Tag} R7 OK: breaks cluster at the impact, not the rim.");
return ok;
}
finally { UnityEngine.Object.DestroyImmediate(go); }
}
// ==============================================================
// Dicing cascade: a broken cell dumps its released energy into its dual-graph neighbours
// (KFracture's feedback loop), so a neighbour of a broken cell should be measurably more
// likely to also be broken than a cell far from any break. Approximate "dual-graph
// neighbour" geometrically (nearest cell centres) since the probe has no access to the
// private edge list -- for a hex tiling that is the same adjacency the sim actually uses.
// ==============================================================
static bool CheckNeighboursMoreLikelyToBreak()
{
var (go, panel) = BuildPanel(panelRadius: 3f, cellSize: 0.4f);
try
{
Strike(panel, Vector2.zero, 150f);
Step(panel, 200);
var state = ReadState(panel);
var centers = Centers(panel);
int n = state.Length;
bool[] broken = new bool[n];
for (int i = 0; i < n; i++) broken[i] = state[i].breakTime >= 0f;
int brokenCount = broken.Count(b => b);
if (brokenCount == 0 || brokenCount == n)
{
Debug.LogError($"{Tag} cascade check: {brokenCount}/{n} broken — need a partial " +
"break to say anything about neighbours vs. distant cells.");
return false;
}
// Self-calibrate "adjacent" from the tiling's OWN geometry instead of guessing a
// multiple of cellSize (which does not equal true hex centroid spacing): take the
// median nearest-OTHER-cell distance across the whole panel as the typical
// dual-graph edge length, then classify relative to that.
float[] nearestOther = new float[n];
for (int i = 0; i < n; i++)
{
float best = float.MaxValue;
for (int j = 0; j < n; j++)
{
if (j == i) continue;
best = Mathf.Min(best, Vector2.Distance(centers[i], centers[j]));
}
nearestOther[i] = best;
}
float typicalSpacing = nearestOther.OrderBy(d => d).ElementAt(n / 2);
float nbrRadius = typicalSpacing * 1.2f;
float farRadius = typicalSpacing * 1.7f;
Debug.Log($"{Tag} cascade calibration: typical spacing={typicalSpacing:F3}, " +
$"nbrRadius={nbrRadius:F3}, farRadius={farRadius:F3}, broken={brokenCount}/{n}.");
// For every cell, find its distance to the nearest OTHER broken cell, then bucket
// it as "near a break" or "distant from every break" and see which bucket breaks
// more often. A cell counts toward its own bucket whether or not it is itself
// broken -- that's the point: does proximity to a break predict being broken.
int nbrTotal = 0, nbrBroken = 0, farTotal = 0, farBroken = 0;
for (int i = 0; i < n; i++)
{
float nearestOtherBrokenDist = float.MaxValue;
for (int j = 0; j < n; j++)
{
if (j == i || !broken[j]) continue;
nearestOtherBrokenDist = Mathf.Min(nearestOtherBrokenDist, Vector2.Distance(centers[i], centers[j]));
}
if (nearestOtherBrokenDist <= nbrRadius) { nbrTotal++; if (broken[i]) nbrBroken++; }
else if (nearestOtherBrokenDist >= farRadius) { farTotal++; if (broken[i]) farBroken++; }
}
if (nbrTotal == 0 || farTotal == 0)
{
Debug.LogError($"{Tag} cascade check: not enough cells in one bucket to compare " +
$"(neighbours={nbrTotal}, distant={farTotal}).");
return false;
}
float nbrRate = (float)nbrBroken / nbrTotal;
float farRate = (float)farBroken / farTotal;
Debug.Log($"{Tag} cascade: neighbour-of-a-break rate {nbrBroken}/{nbrTotal}={nbrRate:P1}, " +
$"distant rate {farBroken}/{farTotal}={farRate:P1}.");
if (nbrRate <= farRate)
{
Debug.LogError($"{Tag} cascade not evident: neighbours of a break ({nbrRate:P1}) are not " +
$"more likely to be broken than distant cells ({farRate:P1}).");
return false;
}
Debug.Log($"{Tag} cascade OK: neighbours break at {nbrRate:P1} vs {farRate:P1} for distant cells.");
return true;
}
finally { UnityEngine.Object.DestroyImmediate(go); }
}
}
}