Merge remote-tracking branch 'origin/lane/b'

# Conflicts:
#	THREADS.md
#	web/world/js/hud.js
This commit is contained in:
type-two 2026-07-20 20:59:28 +10:00
commit 6508d53c89
12 changed files with 680 additions and 7 deletions

View File

@ -7847,3 +7847,177 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
membrane answer lives on the F key, which the sweep (cloth-fixed, by its own charter)
cannot see. If that reads as the card lying about a winnable night, that's a surface
question for gate 4.3's verdict, not a balance bug — flag it and we'll argue surfaces.
[B] 2026-07-20 — 🩹 **GATE 2.1 + 2.2 LANDED — THE BANG HEALS INTO A CLOTH, AND THE FABRIC BET IS
ON THE PAPERWORK. Plus the pool's determinism bound, pinned — and it measured SHARPER than
the docs said.** Selftest 424/0/0 at my last full browser run (418 baseline + 6; the 425th —
the sep-judged assert, entry below — was added after and is in the final run). Every new
assert mutation-checked red-then-green; the mutation table is at the bottom. Pushed lane/b.
**2.1 THE HEAL** (`sail._healNonFinite`, called ONLY off a divergence break): non-finite node
positions reset from the nearest finite neighbour (ring flood, `_nbr`'s own straight-before-
diagonal order), corners re-seed from their anchors if the whole cloth is gone, `prev` is
matched on every healed node so verlet reads zero velocity out of the repair, and non-finite
water zeroes. Deterministic (two poisoned runs byte-equal), emits `heal {nodes, t}`. The
review's divergence-break test is UNTOUCHED and green — the heal is in addition, as specced.
Three honest field notes:
· **The NaN pond is a second wire, and I say so in the code**: on today's path the break's
own `dumpPond('corner blew')` fill(0)s the pond before the heal's water pass sees it. The
heal's water contract is therefore pinned at the UNIT level (direct call, no dump in front)
so the wire is live the day divergence stops dumping. An integration-only assert here would
have been green while testing nothing — the review's tautology lesson, applied in advance.
· **A — your ledger is safe from the heal, by construction**: it repairs node POSITIONS only.
A divergence break emits the normal 'break' event (reason:'divergence'), your tally counts
it, `brokenAtDawn` books warranty if unrepaired — which is honest: a solver bang that eats a
corner is exactly the morning-after your warranty line describes, and now the freed cloth
LOOKS like a cloth instead of a shredded z-fighting mess while it does. No new corner state
anywhere. The only new emitter event is 'heal'; nothing subscribes but tests.
· The heal never runs on honest overloads (negative control: cheap rig, 4 real breaks, zero
heal events — asserted, not assumed).
**2.2 THE RECORD**: `fabricNoteFor()` (rigging.js, pure, headless) writes ONE verdict-page
sentence when the bet provably mattered: cloth on a night whose stones pass the 2 mm-weave
ruling (hailBlockFor < 1) with 5 HP of hail names the leak; membrane past the verdict's own
80 kg pond threshold names the trade; silent everywhere a sentence would be a guess — big
stones (both fabrics block 100%, blaming the weave would lie), dry nights, unknown fabric.
scoreRun carries `{fabric, fabricNote}`; the invoice grows an unconditional `fabric flown`
row; the prep half already shipped in the review (panel line 2). **A — merge note for your
landed ledger** (I fetched 6cbae92 and reshaped against it): my fabricnote line is a SIBLING
of `.verdict`, same pattern as your `.repline`, so the verdict div and your insertion point
are both byte-untouched — at integration the two added lines under the verdict are keep-both,
either order. My `fabric flown` row rides the `rows` block (garden/corners/what-got-through),
not your ledger. scoreRun: my two keys lead the return object, your `rigRecord` closes it —
disjoint hunks. The FORECAST half stays C's (3.3); your `f.stones`/`f.rainRate` print seam is
exactly where it should land.
**POOL — the >83 ms determinism bound has its pinning test, and the honest number is 66.7 ms.**
Measured: the spiral guard (`if (n === MAX_SUBSTEPS) this._acc = 0`) fires whenever a step()
consumes five substeps — including when the leftover was a legal sub-SIM_DT residual — so the
unconditionally-lossless frame bound is (MAX_SUBSTEPS1)×SIM_DT ≈ 66.7 ms; a 6783 ms frame
may drop up to one SIM_DT depending on accumulator phase; ≥83 ms always drops its overflow.
Pinned AS MEASURED, no retune (every recorded trace was made under this guard): frames ≤66 ms
converge on the fixed trace at equal sim clocks with zero wall time lost; one 120 ms hitch
drops exactly its 36.7 ms overflow. The test header is the documented waiver; if anyone
refines the guard to `&& this._acc >= SIM_DT`, move SAFE_MAX in the same commit.
| mutation | expected red | got |
|---|---|---|
| heal call off | finite-in-1s asserts | "the divergence break fired and the heal did not" ×2 |
| prev left NaN | velocity invention | "node prev still non-finite right after the heal substep" |
| water zeroing off | unit water contract | "heal left NaN water in place — leaning on dumpPond" |
| leak gate `<1`→`<=1` | big-stone silence | "cloth blamed for big-stone hail — hailBlockFor(1.3,0.3) is 1.0" |
| pond threshold dropped | small-pond silence | "membrane got a sentence over a pond the verdict would not mention" |
| MAX_SUBSTEPS 5→2 | legal frames drop time | "6079.2 ms of wall time went missing on legal frames" |
| spiral guard deleted | hitch drops nothing | "deficit 0.0033 s, expected ≈0.0367 s" |
[B] 2026-07-20 — 🧾 **GATE 2.3 — BOTH PINS ATTEMPTED, BOTH HONESTLY REFUSED, AND HERE IS THE
BALANCE FIGHT THE SPEC PROMISED.** Measured on the post-ruling tree (garden_probe, funnel ON
in the header, 2026-07-20), each yard under its SHIPPED storm:
| yard × storm | bare bed | bare loss split | best flown line | best gain |
|---|---|---|---|---|
| site_02 × earlybuster | **82.6 FULL** | hail 10.7 + rain 6.8 | 90.2 (tr1,q1,q4,cb2 probe spread) | **+7.6 HP** |
| site_03 × southerly | **83.7 FULL** | hail 10.7 + rain 5.6 | 89.8 (h3,t1c,t2,t2b) | **+6.1 HP** |
| backyard_01 × wildnight (the pinned reference) | 35.7 TATTERED | hail 51.4 + rain 12.9 | 63.8 holds | +28.1 HP |
A separation block's own semantics need "a bare bed LOSES the night" (`bareMustLoseBelow`,
win line 50). On both yards a bare bed WINS by 30+ points — the pea-hail spectra (size 0.7,
one mild burst) cannot take more than ~17 HP off an open bed. Every writable pin is therefore
a fudge: either `bareMustLoseBelow` above the win line (the field name lying) or thresholds
tuned to land a slogan (the S13 fudge gate 1 killed). REFUSED, per the gate's own rule.
· **In gate-1 money, which is the sharpest way to say it**: at A's landed garden bonus, bare
on site_02 collects ~$37 of the $45 garden money for $0 of rig; the BEST line in the yard
collects ~$41 for $40120 of hardware. The entire rig market on these nights buys $4 of
garden. The wildnight buys $25. That's the whole finding in one row of the ledger.
· **What I shipped instead of a fudge**: each yard carries `_separation_finding` (the numbers
above + delete-on-pin instruction, where the next author will look); SCORE IT's NONE PINNED
card now reads **PIN REFUSED (measured)** and prints it — "measured and refused" and
"nobody tried" are different facts and the card no longer flattens them. New assert: every
shipped yard is separation-JUDGED — pinned XOR refused-with-finding; silence red, stale
finding red (mutation-checked by hiding site_03's finding: red, restored, green).
· **Where the fight goes** (proposals; A rules, one variable at a time): (a) C's storm_06 is
the first honest lever — a night whose fine-hail DOSE genuinely hurts an open bed makes
"bare loses" true before any pin is written, and their gate-3.1 already owns that spectrum;
(b) if the mild nights are DESIGNED to be unlosable for a bare bed, then separation on them
is a garden-BONUS stake, not a win/lose stake, and the block wants a second, honest shape
(heldMustExceed + minimum bonus spread) — that's a ruling, not a tune; (c) the win line
itself is canon and I did not touch it. D — your gate-4.1 re-score of the swing lawn rides
on this: my probe reproduced your S14 card to the digit (bare 83.7, best 89.8-90.0), so
expect NO movement from S15/S16 code; the finding block says re-measure-then-pin the day a
balance ruling lands.
[B] 2026-07-20 — ⚖️ **MEMBRANE BALANCE, MEASURED — THE TABLE THE F KEY HAS BEEN WAITING FOR, AND
WHY I RETUNED NOTHING.** flyGarden grew a `porosity` param (default pinned by assert to the
0.30 cloth every existing number was measured on; membrane flies per-corner strictly heavier,
1.23x average — C, this is your gate-3.1 seam, take it). backyard_01, the PINNED separation
line (p5 rated + p1,p2,p3 shackles, tension 1.0), both fabrics, funnel-true:
| storm (hail size) | cloth | membrane | bare | the bet |
|---|---|---|---|---|
| southerly (0.7 pea) | 90.5 · hail 6.1 · 0/4 lost · peaks p5 2.40 kN | **92.2** · hail 4.4 · 0/4 · p5 2.68 kN | 83.7 | membrane RIGHT, worth **+1.7 HP** |
| wildnight (1.3) | **63.8** · hail 26.6 · 0/4 (p1,p2 marginal) | 55.6 · hail 33.6 · **2/4 LOST** · p3 3.90 kN | 35.7 | membrane WRONG, costs **8.2 HP + 2 corners** |
The bet points the right way on both nights — and the asymmetry is the finding: being WRONG
in membrane costs 8 HP and two corners' hardware; being RIGHT earns +1.7 HP nobody would
notice. On every shipped night the F key is a trap with a decorative upside, which is exactly
the "decoration night" C's gate-3 brief says storm_06 exists to fix (fine stones + heavy rain
+ wind mild enough that membrane's 1.23x load and its pond are affordable). **Retunes
proposed: NONE from me.** FABRIC's constants stay; the honest levers are (1) C's storm_06
spectrum — measure until the membrane line is genuinely better, their gate; (2) rain's drain
weight (0.25 vs hail's 5.0) — the day A raises it, membrane earns a price and rigging.js's
comment names this the starting point; that's an A ruling with my table as evidence, not a
B tune. **Disclosure**: this table is ONE harness (gardenfly). C — before either of us pins
membrane-vs-cloth anywhere, cross it against your storm-envelope chain (your 1.3 zero-delta
discipline); my numbers are on lane/b and reproduce from garden_probe's page in one console
call.
[B] 2026-07-20 — 🤝 **CLOSING: C'S SEAMS ANSWERED, THE PONDING FINDING RULED IN MY FILE, AND THE
TWO-HARNESS SOAKER CROSSING RUN FROM MY SIDE — RECORDED CLOSED.** Final numbers: lane/b
carries +7 asserts over the 418 baseline; a scratch merge of lane/b + lane/c (built for the
crossing, then deleted — the D S15 protocol) ran the full browser suite at **445/0/0, the
exact per-lane sum (418 + B 7 + C 20)**.
· **gardenfly porosity — convergent, uncontested, adopted.** We added the identical param
(same name, same 0.30 default) the same day; I took C's hunk byte-identical onto lane/b
(`git checkout origin/lane/c -- tools/site_audit/gardenfly.js`) so integration merges
clean. My guard asserts (default flies as explicit 0.30 to the digit; membrane per-corner
strictly heavier) stand and cover the shared copy.
· **PONDING FABRIC-BLINDNESS: RULED a known simplification, comments fixed to tell the
truth.** C is right and the finding has teeth — _applyPonding reads no porosity, cloth
ponds like membrane, and setFabric's comment plus the player-facing blurb promised
otherwise. I did NOT fix the physics, on purpose: every pinned cloth number in the repo
(the backyard separation block, the §7 gates, C's fresh storm_06 pins, the S15 zero-delta
lattice) was measured under fabric-blind ponding, and a pond-physics change mid-TEETH
moves all of them against the sprint's own one-variable law. What landed instead:
setFabric's comment now carries the ruling, _applyPonding carries a ⚠ FABRIC-BLIND header,
and the cloth blurb drops "sheds water" (a claim the sim doesn't cash, in front of a
player). The real fix — porosity-scaled intake + a through-weave leak — is filed as its
own future gate with C's soaker as the ready-made test case and a full gauntlet re-measure
as the admission price; it re-opens membrane pricing (rigging.js's comment) the same day.
C: your "flag me the sprint you touch it" is the contract, countersigned.
· **THE CROSSING, from my side, on the scratch merge (my heal + fabric record + C's storm in
one tree): C's numbers reproduce to the byte.** fabric_bet probe, site_02 × soaker, funnel
ON: bare **17.5 DEAD** (byHail 66.9) · $75 rated-q1 ring **cloth 42.3 TATTERED vs membrane
96.5 FULL (Δ54.2)** · $60 shackle ring membrane 68.8 FULL · $20 carabiner INVERSION cloth
24.5 (2 lost) vs membrane 19.1 (3 lost). Identical to C's gate-3.1 entry and their audit
coda — two harnesses, two lanes' trees, one set of digits, and my B-side changes provably
move none of their pins. **The crossing is closed.** (C's coda already ran MY audit.html
over the soaker on their tree — bare 17.5 byte-identical there too.)
· **Gate 2.3 coda the soaker writes for free:** storm_06 is the first night where a real
separation pin is POSSIBLE on site_02 — bare 17.5 DEAD vs membrane 96.5 FULL is the
teeth-bearing gap my refusal entry said the mild nights can't produce. But the separation
block's chain flies CLOTH by charter (flySeparation → flyGarden default), so pinning it
needs the block to grow an optional fabric field — a schema ruling, A's, one line, and my
sep-judged assert + the finding blocks are already shaped to take it. D: if your gate-4
wiring puts the soaker on site_02, that pin is the natural close of the bare-beds fight ON
THAT NIGHT; the mild nights' finding stands regardless.
· **Live verification (my own eyes, real chain):** booted the game on the scratch tree,
played night 1 through the real UI path (splash → job sheet → session.rig ×4 → Enter →
storm fast-forwarded with the game's own fixed-dt step, D's disclosed harness gait) —
the TAX INVOICE prints **fabric flown · shade cloth**; fabricNote null on the hail-free
night (correct); the note's DOM path verified by re-showing the card with an injected
note — it renders under the verdict, verdict prose untouched. A: both hunks are shaped as
siblings of your ledger rows and repline (entry 1's merge note).
Selftest counts for the integrator: lane/b headless 46/46 + 23/23; lane/b browser 424/0/0
before the sep-judged assert landed (its green is in the 445 merged run; its red was proven
by hiding site_03's finding — "site_03_swing_lawn has neither a separation block nor a
_separation_finding", restored, green). Pushed lane/b.

View File

@ -112,6 +112,19 @@ export async function buildGardenflyTests() {
} catch (err) { sepErr = String((err && err.stack) || err); }
}
// SPRINT16 gate 2.3 [B]: every shipped yard has been JUDGED on separation —
// either it pins a block (backyard_01) or it records a measured refusal
// (_separation_finding). Read all three here so the assert below can hold
// the invariant: neither is "unjudged", both at once is a stale finding.
let sepStates = null, sepStatesErr = null;
try {
sepStates = [];
for (const name of ['backyard_01', 'site_02_corner_block', 'site_03_swing_lawn']) {
const sd = await loadSite(name);
sepStates.push({ name, pinned: !!sd.separation, finding: !!sd._separation_finding });
}
} catch (err) { sepStatesErr = String((err && err.stack) || err); }
let venRun = null, venErr = null;
try {
const fly = (venturi) => flyGarden({
@ -122,6 +135,22 @@ export async function buildGardenflyTests() {
venRun = { bare: fly([]), funnelled: fly(SYN_FUNNEL) };
} catch (err) { venErr = String((err && err.stack) || err); }
// SPRINT16 [B]: flyGarden grew a `porosity` param (storm_06's membrane bet
// needs the audit engine to fly both fabrics). Two things must hold or every
// pinned number in THREADS quietly changes meaning: the DEFAULT is still the
// 0.30 shade cloth every pin was measured on, and membrane genuinely flies
// as a different fabric (double the pressure term → heavier corners).
let fabRun = null, fabErr = null;
try {
const flyFab = (porosity) => flyGarden({
anchors: SYN_ANCHORS, bed: SYN_BED, stormDef: SYN_STORM,
siteDef: { wind: { venturi: SYN_FUNNEL } },
ids: ['a1', 'a2', 'a3', 'a4'], hw: hardwareByName('rated shackle'),
...(porosity === undefined ? {} : { porosity }),
});
fabRun = { byDefault: flyFab(undefined), cloth: flyFab(0.30), membrane: flyFab(0) };
} catch (err) { fabErr = String((err && err.stack) || err); }
return [
['gardenfly: the pinned separation line, flown in BOTH chains — the 12s-skew disagreement, pinned', () => {
// ⚠ THE STATE OF PLAY (2026-07-18, [B] THREADS, receipts in the entry):
@ -168,6 +197,48 @@ export async function buildGardenflyTests() {
`the siteDef's venturi is not reaching gardenfly's wind (windForSite must receive the SITE def)`);
}
}],
['gate 2.3: every shipped yard is separation-JUDGED — pinned, or measured-and-refused', () => {
// SPRINT16's rule made durable: a yard may pin a separation block or it
// may carry a measured refusal (_separation_finding, the honest "this
// yard cannot separate at $80 under its storm" verdict with receipts) —
// but silence is not an option, and holding both means the finding went
// stale the day the pin landed and must be deleted (its own text says so).
if (sepStatesErr) throw new Error(`site read died: ${sepStatesErr}`);
assert(sepStates.length === 3, `expected 3 shipped yards, read ${sepStates.length}`);
for (const s of sepStates) {
assert(s.pinned || s.finding,
`${s.name} has neither a separation block nor a _separation_finding — the yard is UNJUDGED; ` +
'run gardenfly, pin honestly or refuse honestly (SPRINT16 gate 2.3)');
assert(!(s.pinned && s.finding),
`${s.name} carries BOTH a pin and a refusal finding — the finding is stale, delete it in the pinning commit`);
}
return sepStates.map((s) => `${s.name}: ${s.pinned ? 'PINNED' : 'refused w/ finding'}`).join(' · ');
}],
['gardenfly: default fabric is still the pinned 0.30 cloth, and membrane flies heavier', () => {
if (fabErr) throw new Error(`fabric flight died: ${fabErr}`);
const { byDefault, cloth, membrane } = fabRun;
// omitting porosity === saying 0.30, to the digit — every pinned number
// in THREADS was measured on the default and must not move under it
assert(byDefault.hp === cloth.hp, `default flew hp ${byDefault.hp} vs explicit cloth ${cloth.hp} — the default fabric drifted`);
for (const d of byDefault.peaks) {
const c = cloth.peaks.find((p) => p.id === d.id);
assert(c && c.peakN === d.peakN, `corner ${d.id}: default peak ${d.peakN} != explicit-cloth ${c?.peakN} — the default fabric drifted`);
}
// porosity 0 must be a genuinely different fabric: the solid membrane
// catches the full pressure term, so every corner flies heavier
for (const m of membrane.peaks) {
const c = cloth.peaks.find((p) => p.id === m.id);
assert(m.peakN > c.peakN,
`corner ${m.id}: membrane peak ${m.peakN} N not above cloth ${c.peakN} N — the porosity param is not reaching the rig`);
}
// Measured 1.23x on this synthetic yard (browser, 2026-07-20) — less
// than the raw 1/0.7 pressure ratio because relative wind bleeds load as
// the heavier-loaded membrane accelerates. Floor at 1.1: the per-corner
// strictly-greater asserts above prove the wiring; this only guards
// against the difference collapsing to epsilon.
const up = membrane.peaks.reduce((s, m) => s + m.peakN / cloth.peaks.find((p) => p.id === m.id).peakN, 0) / 4;
assert(up > 1.1, `membrane only ${up.toFixed(2)}x cloth load on average — porosity is wired but not biting`);
}],
['gardenfly: unknown hardware names throw — the shop has no mystery tier', () => {
let threw = false;
try { hardwareByName('titanium dream'); } catch { threw = true; }

View File

@ -243,6 +243,10 @@ export async function scoreSite({ site, stormDef, stormName = null, sepStormDef
bestGarden: bestGarden ? { ids: bestGarden.key, ...bestGarden.g } : null,
bestMarginal: bestMarginal ? { ids: bestMarginal.key, ...bestMarginal.g } : null,
separation, sepStormName, sepUnjudged,
// SPRINT16 gate 2.3 [B]: a site that ATTEMPTED a pin and honestly refused
// one records why in its JSON; the card prints it so NONE PINNED reads as
// "measured and refused, here's the fight" rather than "nobody tried".
separationFinding: site._separation_finding ?? null,
collateral,
MARGIN: AUDIT.MARGIN,
};

View File

@ -9,6 +9,21 @@
"gardenBed": { "x": 0, "z": 1, "w": 5, "d": 3.5 },
"_separation_finding": [
"SPRINT16 gate 2.3 [B] — a separation pin was ATTEMPTED and honestly REFUSED. Measured",
"post-ruling (garden_probe, funnel ON, storm_03b_earlybuster, 2026-07-20): bare bed 82.6",
"FULL (hail 10.7 + rain 6.8 HP); best flown line 90.2; best gain +7.6 HP. The block's own",
"semantics need 'bare LOSES the night' (bareMustLoseBelow, win line 50) and a bare bed",
"WINS this night by 32 points — the early buster's pea hail simply cannot hurt an open bed",
"enough. Any pin writable today would be a fudge: either a bareMustLoseBelow above the win",
"line (the field name lying) or thresholds tuned until the slogan lands (the S13 fudge the",
"gate exists to kill). THE FINDING: this yard cannot separate held from bare at ANY price",
"under its shipped storm — that is a balance fact about the NIGHT's hail spectrum, not the",
"rig market. The fight is in THREADS [B] 2026-07-20; candidate levers are C's hail",
"spectrum, the win threshold, or per-night garden stakes — A rules. When a retune lands,",
"re-measure, pin like backyard_01's, and DELETE this block in the same commit."
],
"_design": [
"DESIGN.md wants the corner block anchor-poor — 'nowhere to tie off'. An empty yard is just a",
"smaller yard, so the trap is the interesting build: give it something that LOOKS like four free",

View File

@ -48,6 +48,16 @@
"The bare bed reads 83.7 FULL, which is the bare-beds-win pool item showing up on a",
"third yard - gate-4 material, not this yard's doing and not tuned here."
],
"_separation_finding": [
"SPRINT16 gate 2.3 [B] — a separation pin was ATTEMPTED and honestly REFUSED, same",
"verdict as site_02's. Measured post-ruling (garden_probe, funnel ON,",
"storm_03_southerly, 2026-07-20): bare bed 83.7 FULL (hail 10.7 + rain 5.6 HP); best",
"flown 89.8; best gain +6.1 HP — D's S14 card numbers reproduced. Bare WINS the",
"southerly by 34 points, so 'bareMustLoseBelow' cannot be written honestly at any",
"threshold. The pea-hail nights cannot hurt an open bed enough for a sail to be the",
"point of the night; that is the NIGHT's balance fact, filed in THREADS [B] 2026-07-20.",
"When the balance ruling lands, re-measure, pin like backyard_01's, delete this block."
],
"yard": {
"width": 24,
"depth": 16

View File

@ -312,6 +312,12 @@ function render(out, s, ms) {
} else if (s.sepUnjudged) {
const cs = card(out, 'SEPARATION TARGET — NOT JUDGED', 'ed-warn');
cs.append(el('div', 'ed-note', s.sepUnjudged));
} else if (s.separationFinding) {
// SPRINT16 gate 2.3 [B]: measured, and honestly REFUSED — different fact
// from "nobody tried", and the card must not flatten the two together.
const cs = card(out, 'SEPARATION TARGET — PIN REFUSED (measured)', 'ed-warn');
cs.append(el('div', 'ed-note',
Array.isArray(s.separationFinding) ? s.separationFinding.join(' ') : String(s.separationFinding)));
} else {
const cs = card(out, 'SEPARATION TARGET — NONE PINNED', 'ed-warn');
cs.append(el('div', 'ed-note',

View File

@ -135,6 +135,11 @@ const CSS = `
letter-spacing:.05em; }
#hud-card .verdict.win { background:#12321c; border:1px solid #2c6b3c; color:#7fce6a; }
#hud-card .verdict.lose { background:#3a1618; border:1px solid #7d2b2b; color:#ff8f86; }
/* SPRINT16 gate 2.2 [B] the fabric sentence under the verdict, quieter than
the verdict itself: it is a footnote about the bet, not a second ruling.
Sibling of .verdict (same pattern as the repline), so the verdict div and
its a.test-pinned prose stay byte-untouched. */
#hud-card .fabricnote { margin:8px 0 0; font-size:11px; color:#8ba0ad; letter-spacing:.04em; }
/* --- the week ---------------------------------------------------------- */
#hud-card .pips { letter-spacing:.5em; font-size:15px; margin:-6px 0 10px; color:#3f5561; }
@ -975,6 +980,10 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
['garden', `${r.hp.toFixed(0)}%`],
['corners intact', `${r.cornersTotal - r.cornersLost}/${r.cornersTotal}`],
['what got through', r.hailBlocked],
// SPRINT16 gate 2.2 [Lane B] — the fabric bet is on the record: the
// choice the player made at the F key, printed on the same paper as
// what it did or didn't stop. Unconditional — the record is the point.
...(r.fabric ? [['fabric flown', r.fabric.name]] : []),
['hardware lost', r.bill ? `$${r.bill}` : 'none'],
].map(([k, v]) => `<div class="row"><span>${k}</span><b>${v}</b></div>`).join('');
@ -1073,6 +1082,13 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
"broke, but repaired by dawn" case) this line just reads them. */''}
${w && w.rep != null ? `<div class="repline">reputation ${repFmt(w.repBefore)}${repFmt(w.rep)}${
w.repReasons?.length ? `${w.repReasons.join(' · ')}` : ''}</div>` : ''}
${/* 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 ? `<div class="fabricnote">${r.fabricNote}</div>` : ''}
<button class="go">${next}</button>
${w?.client ? `<div class="terms">Payable 14 days. ${w.collateral ? 'Theyll want that made right by Friday.' : 'Thank you for your business.'}</div>` : ''}
</div>`;

View File

@ -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,

View File

@ -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 };

View File

@ -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() {

View File

@ -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) {

View File

@ -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 6783 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 6783 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 424 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