Merge remote-tracking branch 'origin/lane/b'
# Conflicts: # THREADS.md # web/world/js/hud.js
This commit is contained in:
commit
6508d53c89
174
THREADS.md
174
THREADS.md
@ -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_SUBSTEPS−1)×SIM_DT ≈ 66.7 ms; a 67–83 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 $40–120 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.
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 ? 'They’ll want that made right by Friday.' : 'Thank you for your business.'}</div>` : ''}
|
||||
</div>`;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user