diff --git a/web/world/js/sail.js b/web/world/js/sail.js index e5a9b54..d045147 100644 --- a/web/world/js/sail.js +++ b/web/world/js/sail.js @@ -970,6 +970,26 @@ export class SailRig { // the spring network, and the cloth a player sees after the bang is a // shredded z-fighting mess, not a cloth. Heal ONLY on a divergence break — // an honest overload break has finite state and must not be touched. + // + // SPRINT17 gate 0.2 — THE SECOND WIRE (D's poison poke, S16 filing). The + // loop above is the heal's only trigger and it SKIPS broken corners, so + // once all four are gone there is no corner load left to read NaN through: + // a fully-lost sail that diverged again stayed NaN forever and rendered as + // nothing. A lost sail is still cloth in the yard — it must LOOK lost, not + // vanish. The sentinel runs ONLY on a fully-broken rig (the corner wire + // covers every other case): sum the state — any NaN/±Inf makes the sum + // non-finite (Inf−Inf is NaN), and finite cloth cannot overflow it + // (positions are metres, prev within a step of pos). Branch-free O(n) + // adds, no mutation, so a finite lost sail is untouched byte-for-byte — + // the negative control in sail.selftest.js pins that. + if (!diverged + && this.corners[0].broken && this.corners[1].broken + && this.corners[2].broken && this.corners[3].broken) { + const pos = this.pos, prev = this.prev; + let s = 0; + for (let i = 0; i < pos.length; i++) s += pos[i] + prev[i]; + if (!Number.isFinite(s)) diverged = true; + } if (diverged) this._healNonFinite(); if (this._dirtyRest) { this._applyRestLengths(); this._dirtyRest = false; } } diff --git a/web/world/js/sail.selftest.js b/web/world/js/sail.selftest.js index 81838b5..034411d 100644 --- a/web/world/js/sail.selftest.js +++ b/web/world/js/sail.selftest.js @@ -327,6 +327,51 @@ test('divergence heal: negative control — honest overload breaks never trigger return `${broke.length} honest break(s), zero heals — the heal only answers divergence`; }); +test('divergence heal: re-arms on a fully-lost sail (D\'s second poison) — and stays silent on a finite one', () => { + // SPRINT17 gate 0.2, D's S16 filing verbatim: "with all four corners already + // divergence-broken, a SECOND poison has no unbroken corner left to arm the + // heal (_checkFailure skips broken corners), so a fully-lost sail that + // diverges again stays NaN and renders as nothing." The fix is the sentinel + // in _checkFailure — it watches ONLY when every corner is broken. This test + // is D's gait replayed: cascade, then poke the corpse. + const r = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 }); + r.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0); + const heals = []; + r.events.on('heal', (e) => heals.push(e)); + const w = makeStubWind({ stormLen: 90 }); + runStorm(r, w, 2); + // First poison: the cascade D observed — all four corners let go by + // divergence inside the run, the S16 heal answers it. + r.pos[(r.N + 1) * 3 + 1] = NaN; + runStorm(r, w, 2); + assert(r.corners.every((c) => c.broken), + `first poison broke only ${r.corners.filter((c) => c.broken).length}/4 corners — D's cascade did not happen and this test is not testing the edge`); + assert(heals.length > 0, 'the first divergence never healed — S16 gate 2.1 regressed, this test cannot reach the edge'); + const healsAfterCascade = heals.length; + // NEGATIVE CONTROL first: a fully-broken sail with FINITE state rides on + // with zero heals — the sentinel must never mistake "lost" for "sick". + runStorm(r, w, 5); + assert(heals.length === healsAfterCascade, + `${heals.length - healsAfterCascade} heal event(s) on a finite lost sail — the sentinel is touching healthy state`); + // The second poison: before the sentinel, this NaN had no wire to trip — + // no unbroken corner reads a load — and the corpse stayed poisoned forever. + r.pos[(r.N + 1) * 3 + 1] = NaN; + let steps = 0; + const cap = Math.round(1 / SIM_DT); + while (heals.length === healsAfterCascade && steps < cap) { + r.step(SIM_DT, w, 9 + steps * SIM_DT); steps++; + } + assert(heals.length > healsAfterCascade, + 'second poison on a fully-broken sail never healed — the re-arm edge is back (_checkFailure has no wire when all four corners are broken)'); + for (const v of r.pos) assert(Number.isFinite(v), 'node position still non-finite after the re-armed heal'); + for (const v of r.prev) assert(Number.isFinite(v), 'node prev still non-finite after the re-armed heal'); + // ...and it HOLDS: a full second of sim later the corpse is still cloth. + runStorm(r, w, 1); + for (const v of r.pos) assert(Number.isFinite(v), 'lost sail went non-finite again inside 1 s — the re-armed heal did not hold'); + for (const v of r.water) assert(Number.isFinite(v), 'water non-finite after the re-armed heal'); + return `cascade 4/4 → 5 s finite, no heal (control) → second poison healed in ${steps} substep(s), finite 1 s on`; +}); + test('ponding: consolidation cannot stack one node past the cap (post-flow clamp)', () => { // The rain-add clamp only ran WHILE raining; once rain stopped, downhill // flow could pile a basin node arbitrarily high and nothing trimmed it.