` : ''}
+ ${/* SPRINT16 gate 2.2 [B] β when the fabric bet provably mattered the
+ verdict page says so, one quiet line under the verdict (same
+ pattern as A's repline: the verdict div itself stays byte-
+ untouched). fabricNoteFor's thresholds decide; null = silent.
+ (Keep-both at integration, per B's own merge notes β the two
+ lines are siblings by design.) */''}
+ ${r.fabricNote ? `
${r.fabricNote}
` : ''}
${w?.client ? `
Payable 14 days. ${w.collateral ? 'Theyβll want that made right by Friday.' : 'Thank you for your business.'}
` : ''}
`;
diff --git a/web/world/js/main.js b/web/world/js/main.js
index 6e275b3..c04319a 100644
--- a/web/world/js/main.js
+++ b/web/world/js/main.js
@@ -23,7 +23,7 @@ import { createPlayer } from './player.js';
import { Interact, wireYardActions } from './interact.js';
import { createDebris } from './debris.js';
import { createSkyFx } from './skyfx.js';
-import { createRiggingUI } from './rigging.js';
+import { createRiggingUI, fabricNoteFor } from './rigging.js';
import { createHud } from './hud.js';
import { createWeek, NIGHTS, nightAt } from './week.js';
import { createGarden } from './garden.js';
@@ -885,7 +885,22 @@ export async function boot(opts = {}) {
const { verdict, mode } = verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped,
beyondSaving: week.job.gardenBeyondSaving });
+ // SPRINT16 gate 2.2 [Lane B] β the fabric bet on the record. The F key
+ // landed in the fresh-eyes review; this is the paperwork half: the invoice
+ // carries the fabric every night (r.fabric β hud's rows), and the verdict
+ // gets ONE extra sentence when the bet provably mattered (fabricNoteFor β
+ // pure, thresholded, silent when a sentence would be a guess). The
+ // FORECAST half β arguing the bet before you rig β is C's gate 3.3.
+ const fabric = rigging.session.fabric;
+ const fabricNote = fabricNoteFor({
+ fabric,
+ hailSize: defs[week.stormKey]?.hail?.size ?? 0,
+ dmg, pondPeak,
+ });
+
return {
+ fabric: { id: fabric.id, name: fabric.name },
+ fabricNote,
hp,
cornersLost: lost.length,
cornersTotal: rig.corners.length || 4,
diff --git a/web/world/js/rigging.js b/web/world/js/rigging.js
index add616c..bdcde17 100644
--- a/web/world/js/rigging.js
+++ b/web/world/js/rigging.js
@@ -14,6 +14,7 @@
import { HARDWARE, START_BUDGET, SPARE_COST, FIXED_DT } from './contracts.js';
import { SailRig, orderRing, TENSION_MIN, TENSION_MAX } from './sail.js';
+import { hailBlockFor } from './weather.core.js';
export { START_BUDGET, SPARE_COST };
export const MAX_CORNERS = 4;
@@ -45,11 +46,68 @@ export const DEFAULT_TENSION = 1.0;
* weight, membrane earns a price and this comment is the place to start.
*/
export const FABRIC = [
- { id: 'cloth', name: 'shade cloth', porosity: 0.30, cost: 0, blurb: 'wind blows through β half the load, sheds water, leaks the finest hail' },
+ // Blurb honesty (SPRINT16): "sheds water" deleted β C measured that ponding
+ // is fabric-blind (_applyPonding never reads porosity; both cloths pond
+ // identically), and a player-facing claim the sim doesn't cash is the worst
+ // kind. Ruled a known simplification in sail.js setFabric's comment.
+ { id: 'cloth', name: 'shade cloth', porosity: 0.30, cost: 0, blurb: 'wind blows through β half the load, leaks the finest hail' },
{ id: 'membrane', name: 'waterproof membrane', porosity: 0, cost: 0, blurb: 'stops rain and every stone β full wind load, and it ponds' },
];
export const DEFAULT_FABRIC = FABRIC[0];
+/**
+ * SPRINT16 gate 2.2 β the verdict's fabric sentence, ONLY when the bet
+ * provably mattered. The invoice records the fabric every night (the record is
+ * unconditional); this is the extra line the aftermath gets to say about it,
+ * and it must never guess: dmg.hail can't be split into through-the-weave vs
+ * fell-beside-the-sail after the fact, so the note speaks only when the
+ * MODEL's own numbers make the claim safe.
+ *
+ * Β· Cloth on a night whose stones pass the weave (hailBlockFor < 1 β C's
+ * 2 mm-weave ruling) with real hail damage: name the leak. That's the
+ * spec's own case, "a membrane night survived in cloth should say what the
+ * leak cost". The phrasing claims what is true β that much hail REACHED
+ * the bed and the membrane stops those stones β not that membrane would
+ * have saved exactly that many HP (some of it may have fallen past the
+ * sail's edge, which no fabric fixes).
+ * Β· Membrane that actually ponded (the HUD's own >80 kg 'ponded' bar): name
+ * the trade β the pond IS the membrane's price, and the verdict prose for
+ * ponded/broomed nights doesn't say which cloth bought it.
+ * Β· Everything else: null. Big-hail nights (both fabrics block 100%), dry
+ * nights, clean wins β the row on the invoice is the whole record.
+ *
+ * Pure, headless-testable; thresholds are data on the function so the asserts
+ * and the wiring read the same numbers.
+ *
+ * @param {object} o
+ * @param {object} o.fabric the FABRIC entry flown (session.fabric / summary.fabric)
+ * @param {number} [o.hailSize] storm def's hail.size (1.0 β 1.5 cm stone); 0/absent = no hail tonight
+ * @param {object} [o.dmg] garden.damage {hail, rain}, HP
+ * @param {number} [o.pondPeak] peak kg of water the sail held tonight
+ * @returns {string|null} one sentence for the aftermath card, or null
+ */
+export function fabricNoteFor({ fabric, hailSize = 0, dmg = { hail: 0, rain: 0 }, pondPeak = 0 } = {}) {
+ if (!fabric || !FABRIC.some((f) => f.id === fabric.id)) return null;
+ if (fabric.porosity > 0) {
+ const leaks = hailSize > 0 && hailBlockFor(hailSize, fabric.porosity) < 1;
+ if (leaks && (dmg.hail ?? 0) >= fabricNoteFor.LEAK_MATTERS_HP) {
+ return `Flown in ${fabric.name} β stones this fine rattle through the weave, and `
+ + `${Math.round(dmg.hail)} HP of hail reached the bed. The membrane stops every stone.`;
+ }
+ return null;
+ }
+ if ((pondPeak ?? 0) > fabricNoteFor.POND_MATTERS_KG) {
+ return `Flown in ${fabric.name} β it stopped the stones and the rain, and held `
+ + `${Math.round(pondPeak)} kg of what it stopped. That is the membrane's price.`;
+ }
+ return null;
+}
+/** The leak has to have cost real HP before the verdict brings it up. */
+fabricNoteFor.LEAK_MATTERS_HP = 5;
+/** verdictFor's own 'ponded' threshold (main.js pondPeak > 80), same number β
+ * the note and the verdict must agree on when a pond is worth a sentence. */
+fabricNoteFor.POND_MATTERS_KG = 80;
+
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
const OK = { ok: true };
diff --git a/web/world/js/rigging.selftest.js b/web/world/js/rigging.selftest.js
index 018c5a9..1f0d041 100644
--- a/web/world/js/rigging.selftest.js
+++ b/web/world/js/rigging.selftest.js
@@ -6,7 +6,7 @@
* js/tests/b.test.js) and node.
*/
-import { RiggingSession, FABRIC } from './rigging.js';
+import { RiggingSession, FABRIC, fabricNoteFor } from './rigging.js';
import { SailRig, TENSION_MIN, TENSION_MAX } from './sail.js';
import { HARDWARE, START_BUDGET, SPARE_COST } from './contracts.js';
@@ -359,6 +359,39 @@ test('fabric is a bet the F key can actually place (SPRINT15 β the dead mechan
return 'cloth -> membrane -> cloth, $0 both ways, hessian refused';
});
+// --- SPRINT16 gate 2.2: the fabric on the record ---------------------------
+// The invoice row is unconditional wiring (main.js/hud.js, verified live);
+// what this pins is the VERDICT's fabric sentence β fabricNoteFor speaks only
+// when the bet provably mattered, and stays silent everywhere a sentence
+// would be a guess. Storm hail sizes are the shipped ones: southerly pea hail
+// 0.7 (cloth blocks 74% β hailBlockFor's measured number), wild night 1.3 and
+// ice night 1.4 (both fabrics block 100%).
+
+test('fabricNoteFor: speaks only when the bet mattered, silent when a sentence would guess', () => {
+ const [CLOTH, MEMBRANE] = FABRIC;
+ // the spec's case: a pea-hail night flown in cloth, hail cost the bed real HP
+ const leak = fabricNoteFor({ fabric: CLOTH, hailSize: 0.7, dmg: { hail: 22, rain: 3 } });
+ assert(leak && leak.includes(CLOTH.name), `leak note must name the fabric flown, got: ${leak}`);
+ assert(leak.includes('22'), `leak note must say what the leak cost, got: ${leak}`);
+ // big stones cannot pass the weave β cloth blocked 100%, hail damage came
+ // from where the sail wasn't, and blaming the weave for it would be a lie
+ assert(fabricNoteFor({ fabric: CLOTH, hailSize: 1.3, dmg: { hail: 40, rain: 5 } }) === null,
+ 'cloth blamed for big-stone hail β hailBlockFor(1.3, 0.3) is 1.0, nothing leaked');
+ // a leak that cost under the threshold is not worth a verdict sentence
+ assert(fabricNoteFor({ fabric: CLOTH, hailSize: 0.7, dmg: { hail: fabricNoteFor.LEAK_MATTERS_HP - 1, rain: 0 } }) === null,
+ 'a trivial leak got a sentence β the threshold is not being read');
+ // membrane's price is the pond, and only a pond the verdict itself would name
+ const pond = fabricNoteFor({ fabric: MEMBRANE, hailSize: 0.7, dmg: { hail: 0, rain: 2 }, pondPeak: 240 });
+ assert(pond && pond.includes(MEMBRANE.name) && pond.includes('240'), `pond note must name membrane and the kg held, got: ${pond}`);
+ assert(fabricNoteFor({ fabric: MEMBRANE, hailSize: 0.7, dmg: { hail: 0, rain: 2 }, pondPeak: 40 }) === null,
+ 'membrane got a sentence over a pond the verdict would not even mention');
+ // no hail data (a dry night), unknown fabric, missing fabric: silence, not a throw
+ assert(fabricNoteFor({ fabric: CLOTH, dmg: { hail: 30, rain: 0 } }) === null, 'no hail tonight but the weave got blamed');
+ assert(fabricNoteFor({ fabric: { id: 'hessian', name: 'hessian', porosity: 0.5 } }) === null, 'unknown fabric must be silent');
+ assert(fabricNoteFor({}) === null && fabricNoteFor() === null, 'missing fabric must be silent, not a throw');
+ return `leak note fires at 0.7-size stones + >=${fabricNoteFor.LEAK_MATTERS_HP} HP; pond note at >${fabricNoteFor.POND_MATTERS_KG} kg; all else silent`;
+});
+
export const RIGGING_TESTS = TESTS;
export function runRiggingSelftest() {
diff --git a/web/world/js/sail.js b/web/world/js/sail.js
index 341c6c1..e5a9b54 100644
--- a/web/world/js/sail.js
+++ b/web/world/js/sail.js
@@ -529,6 +529,11 @@ export class SailRig {
/**
* Rain lands, runs downhill, and pools where it can't get out. Called after
* the wind pass (which fills _nodeFlat / _nodeArea).
+ *
+ * β FABRIC-BLIND, on purpose and on the record (SPRINT16, C's finding):
+ * nothing below reads `this.porosity`, so an open weave ponds exactly like a
+ * membrane. Ruled a known simplification β see setFabric's comment for the
+ * ruling and the re-measure bill a real through-weave leak would incur.
* @param {number} mmPerHour wind.rainMmPerHour(t) β REAL-world rate, Lane C's data
*/
_applyPonding(mmPerHour, dt) {
@@ -921,6 +926,7 @@ export class SailRig {
* undefined` is `load > NaN`: always false, i.e. an UNBREAKABLE anchor.
*/
_checkFailure(dt) {
+ let diverged = false;
for (let k = 0; k < 4; k++) {
const c = this.corners[k];
if (c.broken) continue;
@@ -938,6 +944,7 @@ export class SailRig {
this.invMass[this.cornerIdx[k]] = 1 / this.nodeMass;
this.dumpPond('corner blew');
this.events.emit('break', { type: 'break', corner: c, anchorId: c.anchorId, hw: c.hw.name, t: this.t, reason: 'divergence' });
+ diverged = true;
continue;
}
if (c.load > c.hw.rating * (c.anchor.ratingHint ?? 1)) c.overload += dt;
@@ -957,9 +964,94 @@ export class SailRig {
this.events.emit('break', { type: 'break', corner: c, anchorId: c.anchorId, hw: c.hw.name, t: this.t });
}
}
+ // SPRINT16 gate 2.1 β THE HEAL. The break above made divergence detectable
+ // (the fresh-eyes review's fix); this makes it survivable to LOOK at. The
+ // freed corner otherwise keeps integrating from the NaN the solver left in
+ // 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.
+ if (diverged) this._healNonFinite();
if (this._dirtyRest) { this._applyRestLengths(); this._dirtyRest = false; }
}
+ /**
+ * Reset every non-finite node from its nearest finite neighbour (grid rings,
+ * straight neighbours before diagonals β _nbr's own order), or from the
+ * corners' anchors when the whole cloth is poisoned. `prev` is matched to
+ * `pos` on every healed node so verlet reads zero velocity out of the heal
+ * instead of inventing a launch. Non-finite WATER is zeroed too: a NaN pond
+ * re-poisons the healed positions on the very next substep through the
+ * weight term (F += GRAVITY * w), so healing positions alone heals nothing.
+ *
+ * Deterministic (fixed iteration order, no randomness, no clock), and pure
+ * repair: a rig with fully finite state is untouched byte-for-byte β the
+ * negative control in sail.selftest.js asserts a clean storm never emits
+ * 'heal'. Emits 'heal' {nodes, t} so a log can count what a bang cost.
+ *
+ * @returns {number} nodes healed
+ */
+ _healNonFinite() {
+ const pos = this.pos, prev = this.prev, w = this.water;
+ const n = this.invMass.length;
+ // A NaN pond is poison whether or not its node's position survived. On
+ // the divergence path today this is a SECOND wire β the break's
+ // dumpPond('corner blew') already fill(0)s the whole pond β but the heal's
+ // own contract must not depend on the dump's side effect: if divergence
+ // ever stops dumping (a "the cloth healed, keep the water" retune), the
+ // NaN pond would corrupt pondMass/centroid/broom forever. Unit-asserted
+ // directly in sail.selftest.js for exactly that reason.
+ for (let i = 0; i < n; i++) if (!Number.isFinite(w[i])) w[i] = 0;
+
+ const sick = new Uint8Array(n); // 1 = position needs a donor
+ let sickCount = 0, anyFinite = false, healed = 0;
+ for (let i = 0; i < n; i++) {
+ const x = i * 3;
+ if (Number.isFinite(pos[x]) && Number.isFinite(pos[x + 1]) && Number.isFinite(pos[x + 2])) {
+ anyFinite = true;
+ // finite position, poisoned velocity: match prev, keep the position
+ if (!Number.isFinite(prev[x]) || !Number.isFinite(prev[x + 1]) || !Number.isFinite(prev[x + 2])) {
+ prev[x] = pos[x]; prev[x + 1] = pos[x + 1]; prev[x + 2] = pos[x + 2];
+ healed++;
+ }
+ } else { sick[i] = 1; sickCount++; }
+ }
+ if (sickCount) {
+ // Whole cloth gone: re-seed the corners from their anchors β the one
+ // world position a rig always knows β then flood from there.
+ if (!anyFinite) {
+ for (let k = 0; k < 4; k++) {
+ const ci = this.cornerIdx[k], x = ci * 3;
+ const p = this._anchorPos(this.corners[k].anchor, this.t);
+ pos[x] = prev[x] = p.x; pos[x + 1] = prev[x + 1] = p.y; pos[x + 2] = prev[x + 2] = p.z;
+ if (sick[ci]) { sick[ci] = 0; sickCount--; healed++; }
+ }
+ }
+ // Ring-by-ring flood: each pass, a sick node bordering the finite region
+ // copies that neighbour and joins it, so positions march inward from the
+ // nearest healthy cloth. Terminates: the grid is connected, so every
+ // pass with sick nodes left heals at least one.
+ let changed = true;
+ while (sickCount && changed) {
+ changed = false;
+ for (let i = 0; i < n; i++) {
+ if (!sick[i]) continue;
+ for (let k = 0; k < 8; k++) {
+ const m = this._nbr[i * 8 + k];
+ if (m < 0 || sick[m]) continue;
+ const x = i * 3, mx = m * 3;
+ pos[x] = prev[x] = pos[mx];
+ pos[x + 1] = prev[x + 1] = pos[mx + 1];
+ pos[x + 2] = prev[x + 2] = pos[mx + 2];
+ sick[i] = 0; sickCount--; healed++; changed = true;
+ break;
+ }
+ }
+ }
+ }
+ if (healed) this.events.emit('heal', { type: 'heal', nodes: healed, t: this.t });
+ return healed;
+ }
+
// --- Lane D's seam (SPRINT2 decision 4) --------------------------------
// D landed first and duck-typed these against the rig, so B conforms to D's
// spelling rather than the other way round. Thin aliases on purpose: the
@@ -1018,10 +1110,22 @@ export class SailRig {
}
/**
- * What the sail is made of. Porosity scales the wind pressure term and, since
- * rain lands on the horizontal projection of a face, an open weave sheds the
- * water it can't hold β so a porous cloth also ponds far less. Set BEFORE
- * attach(); RiggingSession.commit() does that.
+ * What the sail is made of. Porosity scales the wind pressure term β and, as
+ * of SPRINT16, that is ALL it scales. The previous version of this comment
+ * claimed an open weave "sheds the water it can't hold β so a porous cloth
+ * also ponds far less", and C's gate-3 measurement caught the cheque the sim
+ * doesn't cash: _applyPonding never reads porosity, so cloth and membrane
+ * pond IDENTICALLY (the $75 soaker ring's q2 gap, 3.24 vs 3.32 kN, is wind
+ * term only). RULED a known simplification rather than silently fixed
+ * ([B] THREADS 2026-07-20): every pinned cloth number in the repo β the
+ * backyard separation block, the Β§7 gates, C's storm_06 pins, the S15
+ * zero-delta lattice β was measured under fabric-blind ponding, and a pond-
+ * physics change mid-TEETH would move all of them against the sprint's own
+ * one-variable law. The fix (porosity-scaled intake + through-weave leak)
+ * is real, wants its own gate with a full gauntlet re-measure, and C's
+ * soaker is its ready-made test case; rigging.js's membrane-pricing comment
+ * is the same conversation. Set BEFORE attach(); RiggingSession.commit()
+ * does that.
* @param {{porosity:number}|number} f a FABRIC entry or a raw porosity
*/
setFabric(f) {
diff --git a/web/world/js/sail.selftest.js b/web/world/js/sail.selftest.js
index 5fffc0d..81838b5 100644
--- a/web/world/js/sail.selftest.js
+++ b/web/world/js/sail.selftest.js
@@ -219,6 +219,114 @@ test('divergence BREAKS on the production path β a NaN corner cannot hold fore
return `${broke.filter((e) => e.reason === 'divergence').length}/4 corners let go by divergence, HUD reads 0`;
});
+// --- SPRINT16 gate 2.1: THE HEAL -------------------------------------------
+// The break above (fresh-eyes review) made divergence detectable; these pin
+// that it is also SURVIVABLE TO LOOK AT. Before the heal, the freed corner
+// integrated from the NaN the solver left in the spring network and the cloth
+// after a bang was a shredded mess, not a cloth. The three tests are one
+// mechanism, three poison shapes: position NaN, velocity (prev) NaN via the
+// pond weight, and the negative control (honest breaks must never heal).
+
+test('divergence heal: after the bang every node is finite within 1 s, prev matched', () => {
+ // Shipped path: watchDivergence OFF, same rig shape as the break test above.
+ const scenario = () => {
+ const r = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 });
+ r.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0);
+ const broke = [], heals = [];
+ r.events.on('break', (e) => broke.push(e));
+ r.events.on('heal', (e) => heals.push(e));
+ const w = makeStubWind({ stormLen: 90 });
+ runStorm(r, w, 2);
+ assert(heals.length === 0, 'a healthy cloth healed something β the heal must be pure repair, never maintenance');
+ const poisoned = r.N + 1; // interior node, review's shape
+ r.pos[poisoned * 3 + 1] = NaN;
+ // step one substep at a time so the break substep is observable exactly
+ let steps = 0;
+ while (!broke.some((e) => e.reason === 'divergence') && steps < Math.round(2 / SIM_DT)) {
+ r.step(SIM_DT, w, (2 + steps * SIM_DT)); steps++;
+ }
+ assert(broke.some((e) => e.reason === 'divergence'), 'poison never broke a corner β the review\'s break regressed');
+ assert(heals.length > 0 && heals[0].nodes > 0, 'the divergence break fired and the heal did not');
+ // Immediately after the break substep: finite, and the healed node's prev
+ // is MATCHED β verlet must read zero velocity out of the heal, not a launch.
+ for (const v of r.pos) assert(Number.isFinite(v), 'node position still non-finite right after the heal substep');
+ for (const v of r.prev) assert(Number.isFinite(v), 'node prev still non-finite right after the heal substep');
+ for (let k = 0; k < 3; k++) {
+ assert(r.pos[poisoned * 3 + k] === r.prev[poisoned * 3 + k],
+ 'healed node has pos != prev β the heal invented velocity for verlet to integrate');
+ }
+ // ...and the spec's bound: still finite a full second of sim later.
+ runStorm(r, w, 1);
+ for (const v of r.pos) assert(Number.isFinite(v), 'cloth went non-finite again inside 1 s β the heal did not hold');
+ for (const v of r.water) assert(Number.isFinite(v), 'water went non-finite inside 1 s of the heal');
+ return { pos: Float64Array.from(r.pos), heals: heals.length };
+ };
+ const a = scenario(), b2 = scenario();
+ assert(a.pos.length === b2.pos.length, 'determinism runs differ in size');
+ for (let i = 0; i < a.pos.length; i++) assert(a.pos[i] === b2.pos[i], `heal is nondeterministic: node component ${i} differs across identical runs`);
+ return `poison β break β healed (${a.heals} heal event(s)), finite 1 s on, byte-equal across two runs`;
+});
+
+test('divergence heal: the NaN pond is zeroed with the positions, and stays zeroed under live rain', () => {
+ // Poison shape 2: divergence with the WATER poisoned too. This is what a real
+ // blow-up leaves behind while it rains β NaN face normals feed NaN into
+ // _nodeFlat and the rain-add writes it into the water field β so the test
+ // injects both halves deterministically rather than racing the rain clock.
+ // A heal that only fixed positions would leave pondMass() NaN forever: the
+ // weight guard's `w[n] > 0` happens to filter NaN today, but the HUD's
+ // ponding line, the broom's centroid and the conservation asserts all read
+ // the water field directly, and one refactor of that guard makes a NaN pond
+ // live poison. The heal zeroes it at the same moment it heals the cloth.
+ const r = rig(HEIGHTS_FLAT, { hw: UNBREAKABLE });
+ r.watchDivergence = false; // shipped path, as above
+ const broke = [], heals = [];
+ r.events.on('break', (e) => broke.push(e));
+ r.events.on('heal', (e) => heals.push(e));
+ const w = realWind(); // real storm: rain keeps landing after the heal
+ runStorm(r, w, 40);
+ assert(r.pondMass() > 50, `only ${r.pondMass().toFixed(0)} kg ponded before the poison β the water half of this test is vacuous`);
+ const belly = Math.floor(r.N / 2) * r.N + Math.floor(r.N / 2);
+ r.water[belly] = NaN;
+ r.pos[belly * 3 + 1] = NaN;
+ runStorm(r, w, 2);
+ assert(broke.some((e) => e.reason === 'divergence'), 'the poisoned belly never broke a corner β the review\'s break regressed');
+ assert(heals.length > 0, 'the poison broke a corner and nothing healed');
+ runStorm(r, w, 1);
+ for (const v of r.pos) assert(Number.isFinite(v), 'cloth non-finite 1 s after the heal β the pond re-poisoned it');
+ for (const v of r.water) assert(Number.isFinite(v), 'water still non-finite after the heal β the heal is not zeroing the pond');
+ assert(Number.isFinite(r.pondMass()), 'pondMass still NaN after the heal β the HUD broom line would read NaN');
+ // Honest disclosure, measured: on the storm path above the water is ALREADY
+ // clean by heal time, because the divergence break's dumpPond('corner blew')
+ // fill(0)s the pond first β so the asserts above cannot isolate the heal's
+ // own water pass. Pin that contract at the unit level instead: the heal must
+ // zero non-finite water WITHOUT a dump in front of it, so the second wire is
+ // live the day the dump's semantics change.
+ r.water[belly] = NaN;
+ const healedNodes = r._healNonFinite();
+ assert(r.water[belly] === 0, 'heal left NaN water in place β it is leaning on dumpPond to clean the pond');
+ assert(Number.isFinite(r.pondMass()), 'pondMass still NaN after a direct heal');
+ return `poisoned belly + pond β divergence break at t=${broke.find((e) => e.reason === 'divergence').t.toFixed(1)}s β healed, ` +
+ `pond finite under live rain; direct heal zeroes NaN water (${healedNodes} position(s) touched)`;
+});
+
+test('divergence heal: negative control β honest overload breaks never trigger it', () => {
+ // The heal must be IN ADDITION to the break, not a hand that touches every
+ // failure: a cheap rig blowing corners honestly (finite loads, real weather)
+ // must ride the whole storm without one heal event. If this goes red, the
+ // heal has started editing healthy state, which is a sim change wearing a
+ // repair's name.
+ const w = makeStubWind({ seed: 11, stormLen: 90 });
+ const r = rig(HEIGHTS_FLAT, { hw: HARDWARE[0], tension: 1.3 }); // the flogging test's rig: it blows corners
+ const broke = [], heals = [];
+ r.events.on('break', (e) => broke.push(e));
+ r.events.on('heal', (e) => heals.push(e));
+ runStorm(r, w, 90);
+ assert(broke.length > 0, 'nothing blew β the control is vacuous, pick a cheaper rig');
+ assert(broke.every((e) => e.reason !== 'divergence'), 'an honest overload break carried reason divergence');
+ assert(heals.length === 0, `${heals.length} heal event(s) on honest breaks β the heal is touching finite state`);
+ return `${broke.length} honest break(s), zero heals β the heal only answers divergence`;
+});
+
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.
@@ -359,6 +467,65 @@ test('determinism: variable frame dt matches fixed dt', () => {
return 'ragged frame times converge on the fixed-dt trace';
});
+// SPRINT16 pool (B) β ROADMAP's "the >83 ms frame determinism bound
+// (documented, un-tested)", now tested β and MEASURED SHARPER than documented.
+// step() burns wall time in SIM_DT chunks up to MAX_SUBSTEPS(5) per call, and
+// `if (n === MAX_SUBSTEPS) this._acc = 0` fires whenever a call CONSUMES five
+// substeps β including when the leftover residual was a perfectly legal
+// sub-SIM_DT remainder. So the unconditionally-lossless frame bound is
+// (MAX_SUBSTEPS β 1) Γ SIM_DT β 66.7 ms (a 67β83 ms frame may drop up to one
+// SIM_DT of residual depending on accumulator phase; β₯ 83 ms always drops).
+// Pinned AS MEASURED, not retuned: every trace ever recorded was made under
+// this guard, and no real frame lives in the 67β83 ms gap on purpose. If the
+// guard is ever refined to `&& this._acc >= SIM_DT` (making 83 ms the true
+// bound), move SAFE_MAX with it in the same commit β this header is the
+// documented waiver ROADMAP asked for, THREADS [B] SPRINT16 has the numbers.
+test('determinism bound: frames under 66.7 ms hold the trace; a hitch past 83 ms drops time by design', () => {
+ const SAFE_MAX = 0.066; // just under (MAX_SUBSTEPS β 1) Γ SIM_DT β see header
+ const w1 = makeStubWind({ seed: 5, stormLen: 20 });
+ const fixed = rig(HEIGHTS_HYPAR);
+ for (let i = 0; i < Math.round(20 / SIM_DT); i++) fixed.step(SIM_DT, w1, i * SIM_DT);
+
+ // Half 1: ragged frames spanning the WHOLE legal range converge on the
+ // fixed trace (the existing 4β24 ms test's promise, extended to the bound).
+ const w2 = makeStubWind({ seed: 5, stormLen: 20 });
+ const ragged = rig(HEIGHTS_HYPAR);
+ const rand = rng(31);
+ let acc = 0;
+ while (acc < 19.5) {
+ const dt = 0.004 + rand() * (SAFE_MAX - 0.004);
+ ragged.step(dt, w2, acc);
+ acc += dt;
+ }
+ // Compare at equal SIM clocks, not equal wall clocks: a random frame sum
+ // overshoots the target by up to SAFE_MAX β up to 3 extra substeps of honest
+ // evolution, an off-by-N comparison rather than a determinism failure. Top
+ // up with fixed steps until both rigs have burned identical sim time.
+ while (ragged.t < fixed.t - 1e-9) { ragged.step(SIM_DT, w2, acc); acc += SIM_DT; }
+ assert(ragged.t === fixed.t, `sim clocks differ after top-up: ${ragged.t} vs ${fixed.t}`);
+ for (let k = 0; k < 4; k++) {
+ const d = Math.abs(fixed.corners[k].load - ragged.corners[k].load);
+ assert(d < 1e-6, `corner ${k} drifted ${d.toFixed(6)} N on frames inside the lossless bound β MAX_SUBSTEPS shrank?`);
+ }
+ assert(acc - ragged.t < SIM_DT + 1e-9,
+ `${((acc - ragged.t) * 1000).toFixed(1)} ms of wall time went missing on legal frames β the guard is dropping time early`);
+
+ // Half 2: one 120 ms hitch loses exactly the time past 5 substeps β the sim
+ // shrugs, it does not catch up, and the clock says so. This is the bound
+ // BEING a bound; if this half goes red the spiral guard is gone.
+ const w3 = makeStubWind({ seed: 5, stormLen: 20 });
+ const hitched = rig(HEIGHTS_HYPAR);
+ const HITCH = 0.120, dropped = HITCH - 5 * SIM_DT; // β 36.7 ms lost
+ let fed = 0;
+ hitched.step(HITCH, w3, 0); fed += HITCH;
+ while (fed < 20) { hitched.step(SIM_DT, w3, fed); fed += SIM_DT; }
+ const deficit = fed - hitched.t;
+ assert(deficit > dropped - SIM_DT - 1e-9 && deficit < dropped + SIM_DT + 1e-9,
+ `fed ${fed.toFixed(3)} s of wall time, sim clock reads ${hitched.t.toFixed(3)} β deficit ${deficit.toFixed(4)} s, ` +
+ `expected β${dropped.toFixed(4)} s (one hitch's overflow). The spiral guard moved`);
+ return `frames β€${(SAFE_MAX * 1000).toFixed(0)} ms: byte-faithful, no time lost; one 120 ms hitch drops ${(deficit * 1000).toFixed(1)} ms as designed`;
+});
+
test('re-attach resets the clock: night 2 flies the same storm as night 1', () => {
// SPRINT13. main.js builds ONE SailRig at boot and re-attach()es it every
// night, and step() runs in EVERY phase once rigged β so the clock ran on