Compare commits

...

3 Commits

Author SHA1 Message Date
type-two
d03712ae47 Lane C S16: gate 3 lands — the fabric bet pinned, forecast argues it, storm in the lists; selftest 438/0/0
Gate 3.1 pins (c.test): the soaker over site_02 through gardenfly, porosity
the only variable — bare <30 (17.5), best cloth line <50 (42.3, still >bare:
the leak is partial), same $75 ring on membrane >90 FULL (96.5) with lost<2
and cost<=80, $60 membrane ring wins too (68.8), separation >40 (54.2). The
inverse guard: membrane on carabiners reads WORSE than cloth (19.1/3 lost vs
24.5/2) — the F key is a bet, not an upgrade. Gate 3.3: forecastLines grows
stones + rainRate (worded, banded); stormStats.hailSize + forecastFor
hail.size band, spread 0.30, nesting/truth-holding pinned in the resolving
loop; existing card fields byte-identical (new band draws last). storm_06 in
STORM_KEYS + suite STORMS (sync assert is the tripwire). Envelope tool flown:
throat 21.55 @ 59.2, q1 17.78 (rated slot), q2 wind-coldest yet cloth-side
second-hottest — the pond's signature isolated between harnesses. Mutations,
separate runs, red-then-green: gardenfly seam hardcoded -> both 3.1 pins red;
STORM_KEYS drop -> list-sync red with diff; stormgen rng -> byte-equal red.
Browser selftest 438/0/0 (418 + 4 storm honesty + 12 stormgen + 4 gate-3).
THREADS: gate entries + D pairing receipt + B seam/finding notes.
2026-07-20 20:33:10 +10:00
type-two
d6af6ba5a2 Lane C S16: makeStorm(seed) — seeded storms inside authored envelopes (gate 3.2)
stormgen.js: envelopes are authored data (every knob a range, knot times
fixed), a seed picks one storm via a single mulberry32 stream in a pinned
draw order. Same seed → byte-equal def; def carries its seed so the gust
timeline downstream is seeded by the same number. checkEnvelope() recomputes
membership FROM THE DEF (never trusts the generator) and also runs the storm
validator. One family ships: soaker_family, ranged around storm_06's measured
personality. Cases ride the shared node+browser list: byte-equal determinism,
100 seeds all valid + all inside (gustPeak spread 12.8-17.2 across seeds),
four named escape routes red-not-shrugged, valid-but-off-family storm red
(checkEnvelope is not the validator in a hat), change event === physics for
sampled seeds, and two fixed-seed generated storms ride the SAME telegraph/
continuity/validator gates as authored ones. Node suite 72/0. Mutation check:
rng → Math.random went red on the byte-equal case, restored, green.
2026-07-20 20:11:34 +10:00
type-two
92a8e870f2 Lane C S16: storm_06_soaker — the pea-hail soaker, measured over both yards; fabric_bet probe; gardenfly porosity opt
The first night the default fabric is the wrong answer, tuned by measurement
through the real chain (gardenfly, dressed anchors, funnel ON, post-ruling
tree): bare 17.5 DEAD, best cloth line 42.3 TATTERED (a loss at any price),
same $75 ring on membrane 96.5 FULL, $60 ring 68.8 FULL — one key, Δ54.2 HP.
Membrane on $20 carabiners inverts (19.1 < 24.5): the doubled load is real.
Measured over backyard_01 too: its geometry caps hail cover at ~31%, so the
night only works on site_02 — the pairing receipt for D. tools/fabric_bet/
probe.html is the measurement harness; flyGarden grows an additive porosity
opt (default 0.30 unchanged — B: contest at integration if wanted).
2026-07-20 20:06:12 +10:00
10 changed files with 972 additions and 11 deletions

View File

@ -7382,3 +7382,127 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
**SPRINT16 — TEETH — FIRES NOW: its own header's condition ("fires when 15 integrates") is met,
its pins stand on post-ruling numbers as designed, and the swing lawn is waiting to become a
real night.**
[C] 2026-07-20 — 🧊 **GATE 3.1 LANDED — STORM_06 IS REAL AND THE F KEY IS FINALLY A NIGHT. D: the
pairing is measured, not preferred — the soaker MUST debut over the corner block.** Selftest
438/0/0 (418 baseline + 20: 4 per-storm honesty gates for the new storm, 12 generator cases,
4 gate-3 pins). All numbers post-ruling, flown through gardenfly — THE audit chain, dressed
anchors, live sway, funnel state in every header.
**THE STORM: storm_06_soaker ("Pea-Hail Soaker", rating 3).** Personality, measured
(stormStats + fabric_bet probe): sustained 9.2 m/s · gust peak 15.2 (55 km/h — the mildest
wind of any hail night) · rain peak 44 mm/hr, raining from minute one to minute ninety ·
hail 14.9 s-equivalent at size 0.45 · soft change at t=40 (front wall rises, same tell as
every buster). The thesis in one line: hailBlockFor(0.45, cloth 0.30) = 15.6% — these
stones pass a 2 mm weave; membrane blocks 100%.
**THE BET'S SHAPE (site_02 × soaker, same $75 ring q1..q4 rated-on-q1, ONE variable):**
· bare bed 17.5 DEAD (byHail 66.9)
· best cloth line measured, any price: **42.3 TATTERED — a LOSS**, 7.7 under the win line
(and visibly above bare: the cloth leaks, it isn't absent)
· SAME line, membrane: **96.5 FULL** — Δ**54.2 HP on the F key alone**
· $60 shackle ring, membrane: 68.8 FULL — the bet doesn't need the last dollar
· $20 carabiner ring INVERTS: membrane 19.1 (3 lost) vs cloth 24.5 (2 lost) — membrane's
load cost is real; the F key without steel is worse than nothing. Pinned as the guard.
Two hot corners after the change — q1 (venturi, 4.2 kN cloth) takes the rated; q2 (the new
downwind pond corner, 3.2-3.4 kN) is the broom's job and may let go late in a broom-less
harness (gardenfly can't broom; D, your play is the acceptance here). Retune history in the
storm's own comments: 17.2 hail-seconds was a wall (bare 7.2), 55 mm/hr drowned the fabric
lesson in a rain lesson — both walked back to the one-thing storm.
**The envelope tool flew it (storm-side receipts, site_02 × soaker, dressed anchors):**
throat probe 21.55 m/s @ 59.2 s (the funnel fires on the settled post-change wind, as
authored) · q1 17.78 @ 71.9 — the hottest post, which is why the rated shackle lives there
· q3 16.34 · q4 16.29 · tr1 16.08 · **q2 15.63 — the WIND-coldest post, and the cloth-side
harness's second-hottest corner (3.2-3.4 kN)**. That disagreement is not an error, it is
the measurement: the wind harness deliberately knows no water, so q2's rank inversion
between the two harnesses is the pond's signature, isolated. Two harnesses, one night,
each telling the half it owns.
**Mutation receipts (each red-then-green in the browser harness, separate runs):**
(1) gardenfly's porosity seam hardcoded back to 0.30 → BOTH pins red with their own
diagnostics: "the SAME $75 ring on MEMBRANE reads 42.3 tattered — want >90 FULL" and
"membrane on carabiners (24.5 hp, 2 lost) should read WORSE than cloth (24.5 hp, 2 lost) —
the doubled load has stopped costing anything". (2) storm_06 dropped from STORM_KEYS →
the list-sync assert red with the two lists diffed in the message. (3) stormgen's rng
swapped for Math.random → the byte-equal case red. All restored, final run green.
**D — THE PAIRING (SPRINT16 one-variable rule), with the receipt:** I flew the soaker over
BOTH shipped yards before claiming this. backyard_01 cannot host it: its buyable geometry
caps hail cover over the bed at ~31% (measured — the p1..p4 quad's flown shadow), and its
full-cover quads (p4,p5,p1,p2) pond-tear at 3.3-3.7 kN even in this mild wind, BOTH fabrics.
Membrane can't save what the sail can't reach: best membrane line there reads 37.5 vs cloth
17.3 — a bet with no win in it. So: **storm_06 flies over site_02 (the yard night 3
taught), and your swing lawn debuts under a storm the player has survived** — southerly or
buster, your call, you own NIGHTS and the order. If you want the soaker anywhere else, we
re-measure first; the storm's JSON says this out loud.
**Files:** data/storms/storm_06_soaker.json (tuned, receipts in comments) ·
tools/fabric_bet/probe.html (the measurement harness — same line both fabrics through
gardenfly, funnel state in header) · STORM_KEYS + c.test STORMS grown (the list-sync assert
is the tripwire) · pins in c.test.js (thresholds carry margin, digits live here and in the
storm's comments).
[C] 2026-07-20 — 🎲 **GATE 3.2 LANDED — makeStorm(seed): storms are draws from authored
envelopes now, and the envelope can say no.** stormgen.js, pure (weather.core only, runs in
the node one-second loop).
· **The grammar:** an envelope is DATA — every knob a [lo,hi] range, curve knot times fixed
per family, telegraph fixed (honesty is not tunable), gust shape owned by weather.core.
One family ships as groundwork: `soaker_family`, ranged around storm_06's measured
personality. The generator draws in a PINNED order from one mulberry32(seed) stream; the
def carries its seed so buildGustTimeline downstream is seeded by the same number.
· **The change cannot lie by construction:** dirCurve is BUILT from the drawn change time,
so the windchange event and the physics agree for every seed — the validator's drift trap,
made structural, and asserted through createWindField for sampled seeds.
· **checkEnvelope(def) recomputes membership FROM THE DEF** — it never trusts the
generator, so a hand-edited def fails on its own merits. It also runs validateStorm:
inside-the-envelope implies loadable.
· **Selftest (12 cases):** same seed → byte-equal JSON; 100 seeds → all validator-green AND
all inside the envelope (gustPeak spread 12.8-17.2 — the ranges are real, the family stays
mild); four named escape routes go red with the escape in the message (base speed over
ceiling, pea family throwing ice, overlapping gusts, change re-timed); a storm that
VALIDATES but is off-family goes red (checkEnvelope is not the validator in a hat); two
fixed-seed generated storms ride the SAME telegraph/continuity/validator gates as authored
ones; and a generated storm flies the storm_envelope harness deterministically, hint
ranking intact. Mutation: rng → Math.random, byte-equal case red, restored, green.
· **No shipped night uses it** — ROADMAP arc 4 eats this; that sprint prices it.
[C] 2026-07-20 — 📇 **GATE 3.3 LANDED — the stone and the rain rate are on the card. A: two new
fields, wired and worded, yours to print.** `forecastLines(def, lead)` now returns
`stones` ('fine pea stones' / 'pea stones' / 'marble stones' / 'golf ball stones', '' for a
hail-free storm) and `rainRate` ('rain to 44 mm/hr', banded with a dash when hedged).
Underneath: stormStats grew `hailSize`, forecastFor's hail block grew `size` — a band like
every other number, spread 0.30 (radar sees how big a cell is before it knows how long it
sits on you), truth-holding and nesting pinned in the same resolving loop as
sustained/gustPeak/rain. Word map calibrated to shipped storms: storm_03's 0.7 reads "pea"
(its own comment's word), the soaker's 0.45 "fine pea", wildnight 1.3 and icenight 1.4
"golf ball" — the sizes cloth stops dead. Every existing forecast field is byte-identical
(the new band draws LAST from the seeded stream), so your current card renders unchanged
until you print the new lines. The soaker's card at lead 0: "fine pea stones · rain to
44 mm/hr · hail likely · sustained to 9 m/s" — the F key, legible before a dollar is spent,
which is gate 4.3's "is night 6's bet legible from the forecast alone" question given its
fair chance.
[C] 2026-07-20 — 🔧 **B — TWO SEAM NOTES AND A FINDING WITH TEETH, posted before either of us
ships a number the other invalidates (the S13 lesson, prospectively).**
· **flyGarden grew `porosity` (default 0.30 — every pre-S16 number unmoved).** Your file;
I landed the smallest door so the membrane pins ride THE driver instead of a copy, and
your S15 note ("membrane-balance flights ride the same driver unchanged") read like an
invitation. Contest at integration if you want a different shape — the pins don't care
which door, only that there's one.
· **THE FINDING: ponding is fabric-blind.** `_applyPonding` never reads porosity — water
intake is `_nodeFlat` geometry only, so cloth and membrane pond IDENTICALLY today, while
setFabric's own comment ("an open weave sheds the water it can't hold — so a porous cloth
also ponds far less") and the FABRIC blurb ("sheds water") promise otherwise. Measured
consequence on my night: the $75 ring's q2 peaks 3.24 kN on cloth vs 3.32 on membrane —
the gap is wind term only. The comments are writing cheques the sim doesn't cash.
**I did NOT touch it** — sail.js is yours, and rigging.js's membrane-pricing comment
("if Lane A ever raises rain's drain weight, membrane earns a price") is the same
neighbourhood: if you make porosity shed water, membrane's pond cost becomes real,
storm_06's numbers move (q2 becomes membrane-only-hot — arguably BETTER design), and the
free-fabric ruling may want re-opening. My re-measure is one probe page load
(tools/fabric_bet/probe.html); flag me the sprint you touch it.
· Your heal (gate 2.1) sits below flyGarden's chunk seam per your own S15 note — my pins
fly non-diverging rigs, so zero interaction expected; if your heal moves ANY gate-3 pin,
the heal is the suspect, not the storm.

172
tools/fabric_bet/probe.html Normal file
View File

@ -0,0 +1,172 @@
<!doctype html>
<!--
fabric_bet/probe.html — does the F key matter TONIGHT? [Lane C, SPRINT16 gate 3.1]
The measurement harness behind the pea-hail soaker. Every hail night before
storm_06 threw stones a 2 mm weave stops dead, so the fabric bet (SPRINT6)
never had a night where membrane was the right answer. This page flies the
SAME line on both fabrics through gardenfly.js — THE audit scoring chain
(windForSite → attach → skyfx → garden.js), dressed anchors, live sway — so
cloth-vs-membrane differ by exactly one variable: porosity.
Per line, per fabric: garden hp/state, byHail/byRain, corner peaks vs
effective ratings, marginal flags, cost. Plus the bare bed (the control) and
the storm's measured personality (stormStats — the numbers the forecast
card will sell).
Funnel state is printed in the header, per the discipline (three harnesses
in this repo's history flew site_02 funnel-off and believed it).
tools/fabric_bet/probe.html?site=backyard_01&storm=storm_06_soaker
-->
<meta charset="utf-8">
<title>fabric_bet probe</title>
<style>
body { background:#111; color:#ddd; font:13px/1.5 ui-monospace,Menlo,monospace; margin:0; padding:20px; }
h1 { font-size:15px; color:#fff; margin:0 0 2px; }
.sub { color:#888; margin-bottom:14px; white-space:pre-wrap; }
table { border-collapse:collapse; margin:8px 0; }
td, th { padding:2px 12px 2px 0; white-space:nowrap; text-align:left; }
th { color:#9ab; border-bottom:1px solid #333; }
.ok { color:#6c6; } .bad { color:#e66; } .warn { color:#dc6; }
.note { color:#888; margin-top:14px; white-space:pre-wrap; }
</style>
<h1>fabric_bet — the same line, both fabrics, the real chain</h1>
<div class="sub" id="sub">loading…</div>
<div id="out"></div>
<div class="note" id="note"></div>
<script type="importmap">
{ "imports": {
"three": "../../web/world/vendor/three.module.js",
"three/addons/": "../../web/world/vendor/addons/"
} }
</script>
<script type="module">
import * as THREE from '../../web/world/vendor/three.module.js';
import { createWorld, loadSite } from '../../web/world/js/world.js';
import { stormStats } from '../../web/world/js/weather.js';
import { hailBlockFor } from '../../web/world/js/weather.core.js';
import { flyGarden, hardwareByName } from '../site_audit/gardenfly.js';
const q = new URLSearchParams(location.search);
const siteName = q.get('site') || 'backyard_01';
const stormName = q.get('storm') || 'storm_06_soaker';
const el = (id) => document.getElementById(id);
const loadJSON = async (p) => (await fetch(p)).json();
// The candidate lines. COVER_QUAD is balance.test's canonical bed-covering
// quad; the others are cheaper/worse-covering controls so the bet's shape is
// visible across the price range, not just at one point.
const LINES = {
backyard_01: [
{ label: 'cover quad, all carabiners ($20)', ids: ['p1', 'p2', 'p3', 'p4'], hw: ['carabiner', 'carabiner', 'carabiner', 'carabiner'] },
{ label: 'cover quad, all shackles ($60)', ids: ['p1', 'p2', 'p3', 'p4'], hw: ['shackle', 'shackle', 'shackle', 'shackle'] },
{ label: 'wildnight $80 line (cara+rated+shackle+rated)', ids: ['p1', 'p2', 'p3', 'p4'], hw: ['carabiner', 'rated shackle', 'shackle', 'rated shackle'] },
{ label: 'separation-pin line p5+p1+p2+p3 ($75)', ids: ['p5', 'p1', 'p2', 'p3'], hw: ['rated shackle', 'shackle', 'shackle', 'shackle'] },
// The full-cover quads — unbuyable on the wildnight (6.9 kN, tears), which
// is exactly why the MILD night is where they come alive. p4+p5 span the
// bed north, p1+p2 south; the hull holds the whole bed.
{ label: 'FULL-COVER p4,p5,p1,p2 carabiners ($20)', ids: ['p4', 'p5', 'p1', 'p2'], hw: ['carabiner', 'carabiner', 'carabiner', 'carabiner'] },
{ label: 'FULL-COVER p4,p5,p1,p2 shackles ($60)', ids: ['p4', 'p5', 'p1', 'p2'], hw: ['shackle', 'shackle', 'shackle', 'shackle'] },
{ label: 'wide tree line t1,t2,p1,p2 shackles ($60)', ids: ['t1', 't2', 'p1', 'p2'], hw: ['shackle', 'shackle', 'shackle', 'shackle'] },
{ label: 'house span h2,h3,p1,p2 shackles ($60)', ids: ['h2', 'h3', 'p1', 'p2'], hw: ['shackle', 'shackle', 'shackle', 'shackle'] },
],
site_02_corner_block: [
{ label: 'S14 cheapest honest tr1,tr1b,q1,q3 ($40)', ids: ['tr1', 'tr1b', 'q1', 'q3'], hw: ['carabiner', 'carabiner', 'shackle', 'carabiner'] },
{ label: 'best clean tr1,tr1b,q1,q4 (shackles $60)', ids: ['tr1', 'tr1b', 'q1', 'q4'], hw: ['shackle', 'shackle', 'shackle', 'shackle'] },
// The bed ring — q1..q4 stand AROUND the bed, the yard's whole offer.
{ label: 'bed ring q1,q2,q3,q4 carabiners ($20)', ids: ['q1', 'q2', 'q3', 'q4'], hw: ['carabiner', 'carabiner', 'carabiner', 'carabiner'] },
{ label: 'bed ring q1,q2,q3,q4 shackles ($60)', ids: ['q1', 'q2', 'q3', 'q4'], hw: ['shackle', 'shackle', 'shackle', 'shackle'] },
{ label: 'bed ring, rated on the funnel corner q1 ($75)', ids: ['q1', 'q2', 'q3', 'q4'], hw: ['rated shackle', 'shackle', 'shackle', 'shackle'] },
{ label: 'bed ring $75, winched tight (tension 1.2)', ids: ['q1', 'q2', 'q3', 'q4'], hw: ['rated shackle', 'shackle', 'shackle', 'shackle'], tension: 1.2 },
{ label: 'bed ring $60, winched tight (tension 1.2)', ids: ['q1', 'q2', 'q3', 'q4'], hw: ['shackle', 'shackle', 'shackle', 'shackle'], tension: 1.2 },
],
};
async function run() {
const site = await loadSite(siteName);
const scene = new THREE.Scene();
let currentWind = {
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
gustTelegraph: () => null, eventsBetween: () => [],
};
const windProxy = {
sample: (p, t, o) => currentWind.sample(p, t, o || new THREE.Vector3()),
speedAt: (p, t) => currentWind.speedAt(p, t),
rainAt: (t) => currentWind.rainAt(t),
rainMmPerHour: (t) => (currentWind.rainMmPerHour ? currentWind.rainMmPerHour(t) : 0),
gustTelegraph: (t) => currentWind.gustTelegraph(t),
eventsBetween: (a, b) => currentWind.eventsBetween(a, b),
setSheltersFromTrees() {},
};
const use = (w) => { currentWind = w; };
const world = createWorld(scene, { wind: windProxy, site });
await world.dress();
const bed = world.gardenBed;
const anchors = world.anchors;
const venturi = site.wind?.venturi ?? [];
const stormDef = await loadJSON(`../../web/world/data/storms/${stormName}.json`);
const s = stormStats(stormDef);
const size = stormDef.hail?.size ?? 0;
const clothBlock = size ? hailBlockFor(size, 0.30) : 1;
el('sub').textContent =
`${site.name} · ${stormName} (${stormDef.duration}s) · bed ${bed.w}x${bed.d} @ (${bed.x},${bed.z})\n`
+ `FUNNEL: ${venturi.length ? venturi.map((v) => `(${v.x},${v.z}) gain ${v.gain} — ON`).join(' · ') : 'none authored on this site'}\n`
+ `storm truth (stormStats): sustained ${s.sustained.toFixed(1)} m/s · gust peak ${s.gustPeak.toFixed(1)} m/s `
+ `(${(s.gustPeak * 3.6).toFixed(0)} km/h) · rain peak ${s.rainPeakMmPerHour.toFixed(0)} mm/hr · `
+ `hail ${s.hailSeconds.toFixed(1)} s-equiv @ size ${size} · change at ${s.changeAt ?? '—'}s\n`
+ `hailBlockFor(${size}, cloth 0.30) = ${(clothBlock * 100).toFixed(1)}% blocked · membrane = 100%`;
const bare = flyGarden({ anchors, bed, stormDef, siteDef: site, use });
const tbl = document.createElement('table');
const head = tbl.insertRow();
for (const h of ['line', 'fabric', '$', 'garden', 'byHail', 'byRain', 'lost', 'marginal', 'peaks (kN vs eff)'])
{ const th = document.createElement('th'); th.textContent = h; head.appendChild(th); }
const rowFor = (label, g, fabric, cls) => {
const tr = tbl.insertRow();
const peaks = g.peaks.map((p) => `${p.id} ${(p.peakN / 1000).toFixed(2)}/${(p.effN / 1000).toFixed(2)}`).join(' ');
[label, fabric, `$${g.cost}`, `${g.hp.toFixed(1)} ${g.state.toUpperCase()}`,
g.byHail.toFixed(1), g.byRain.toFixed(1), String(g.cornersLost),
g.marginal.join(',') || '—', peaks || '—']
.forEach((c, i) => { const td = tr.insertCell(); td.textContent = c; if (i === 3) td.className = cls(g); });
};
const cls = (g) => (g.hp >= 66 ? 'ok' : g.hp >= 50 ? 'warn' : 'bad');
const results = { site: siteName, storm: stormName, bare, lines: [] };
for (const L of LINES[siteName] ?? []) {
const hw = L.hw.map(hardwareByName);
const tension = L.tension ?? 1.0;
const cloth = flyGarden({ anchors, bed, stormDef, siteDef: site, use, ids: L.ids, hw, tension, porosity: 0.30 });
const mem = flyGarden({ anchors, bed, stormDef, siteDef: site, use, ids: L.ids, hw, tension, porosity: 0 });
rowFor(L.label, cloth, 'cloth', cls);
rowFor('', mem, 'MEMBRANE', cls);
results.lines.push({ label: L.label, ids: L.ids, hw: L.hw, cloth, membrane: mem });
}
const tr = tbl.insertRow();
['BARE BED', '—', '$0', `${bare.hp.toFixed(1)} ${bare.state.toUpperCase()}`,
bare.byHail.toFixed(1), bare.byRain.toFixed(1), '4', '—', '—']
.forEach((c) => { const td = tr.insertCell(); td.textContent = c; td.className = 'warn'; });
el('out').appendChild(tbl);
const best = results.lines.reduce((b, r) => (r.membrane.hp > (b?.membrane.hp ?? -1) ? r : b), null);
el('note').textContent = best
? `best membrane line: "${best.label}" → ${best.membrane.hp.toFixed(1)} vs same line on cloth ${best.cloth.hp.toFixed(1)} `
+ `(Δ ${(best.membrane.hp - best.cloth.hp).toFixed(1)} HP) · bare ${bare.hp.toFixed(1)}`
: 'no lines authored for this site — add them to LINES';
window.__fabricBet = results;
document.title = `fabric_bet — ${stormName}`;
}
run().catch((e) => {
el('note').className = 'bad';
el('note').textContent = `probe crashed: ${e.message}\n${e.stack || ''}`;
window.__fabricBet = { error: e.message };
document.title = 'fabric_bet — ERROR';
});
</script>

View File

@ -89,9 +89,17 @@ export function hardwareByName(name) {
* @param {Array} [o.ids] 4 anchor ids; null/omitted = bare bed (the control)
* @param {Array|object} [o.hw] hardware per pick (aligned with ids), or one tier for all 4
* @param {number} [o.tension]
* @param {number} [o.porosity] fabric: 0.30 = shade cloth (the default every
* audit number to date was flown on unchanged),
* 0 = membrane. SPRINT16 gate 3.1 measures the
* fabric bet through THIS chain, same driver as
* every other garden number, so cloth-vs-membrane
* differ by exactly one variable. [Lane C addition,
* THREADS 2026-07-20 B: contest at integration if
* you want a different door.]
* @returns {{ hp, state, byHail, byRain, cornersLost, peaks, marginal, cost }}
*/
export function flyGarden({ anchors, bed, stormDef, siteDef = null, use = null, ids = null, hw = null, tension = 1.0 }) {
export function flyGarden({ anchors, bed, stormDef, siteDef = null, use = null, ids = null, hw = null, tension = 1.0, porosity = 0.30 }) {
const wind = windForSite(stormDef, siteDef, anchors);
use?.(wind);
@ -99,8 +107,9 @@ export function flyGarden({ anchors, bed, stormDef, siteDef = null, use = null,
if (ids) {
const hwArr = Array.isArray(hw) ? hw : Array(4).fill(hw ?? hardwareByName('rated shackle'));
cost = hwArr.reduce((s, h) => s + (h.cost || 0), 0);
// shade cloth (porosity 0.30): the fabric a competent player flies — same as sweep.js
rig = new SailRig({ anchors, gridN: 10, porosity: 0.30 }).attach(ids, hwArr, tension);
// shade cloth (porosity 0.30) unless the caller is measuring the fabric
// bet — same default as sweep.js, so nothing pre-SPRINT16 moves.
rig = new SailRig({ anchors, gridN: 10, porosity }).attach(ids, hwArr, tension);
// D's skipped-attach trap: a rig without its shadow mesh scores as a bare
// bed and the number LOOKS plausible. Refuse to fly it.
if (!rig.rigged || !rig.pos || !rig.tris || !rig.tris.length) {

View File

@ -0,0 +1,52 @@
{
"name": "Pea-Hail Soaker",
"blurb": "Warm, wet, and rattling — fine hail on and off all night under steady soaking rain. The wind never gets serious. The stones are small enough to matter.",
"rating": 3,
"seed": 61207,
"duration": 90,
"_why_comment": "SPRINT16 gate 3.1 — the first night the DEFAULT fabric is the wrong answer. Every prior hail night throws stones a 2 mm weave stops dead (storm_02 size 1.3, icenight 1.4 — cloth blocks 100%), so the F key never mattered. This storm's stones are size 0.45: UNDER the cloth aperture band (0.3..0.9 at porosity 0.30), so shade cloth blocks only 15.6% and leaks the rest onto the bed ALL NIGHT; membrane blocks everything. Wind is deliberately mild (sustained 9.2, gusts 15.2 m/s measured) so membrane's higher pressure load is affordable — the night's question is the fabric, per the one-variable rule. MEASURED on site_02 through gardenfly (fabric_bet probe, funnel ON, post-ruling tree, 2026-07-20): bare 17.5 DEAD · best cloth line at any price 42.3 TATTERED (a LOSS — 7.7 under the win line) · the SAME $75 ring on membrane 96.5 FULL · the $60 ring on membrane 68.8 FULL. Same line, one key, Δ54.2 HP. And membrane is NOT a free win: on $20 carabiners it reads 19.1 vs cloth's 24.5 — the doubled load tears cheap steel; bring shackles. This night was measured over BOTH shipped yards and it only works on the corner block: backyard_01's buyable geometry caps hail cover at ~31% (its full-cover quads pond-tear at 3.3+ kN even in this mild wind), so membrane cannot save what the sail cannot reach — the debut yard is site_02, and the pairing note is in THREADS for D.",
"baseCurve": [[0, 5.0], [15, 6.8], [30, 8.2], [50, 9.2], [70, 8.8], [90, 7.3]],
"gusts": {
"firstAt": 4,
"minGap": 6,
"maxGap": 11,
"powBase": 2.0,
"powRand": 3.0,
"powRamp": 2.0,
"downdraftOfTotal": 0.3
},
"_dir_comment": "A soft change at t=40 — later and gentler than the busters, swinging ~75 deg over 8 s. It keeps the series' one honest tell (the front wall rises in the quarter the new wind comes from) and it re-aims the yard: post-change the wind aligns with site_02's funnel axis, so q1 becomes the hot western corner (4.2 kN on cloth — the rated shackle's slot) while the pond load walks to the new downwind corner q2 (3.2-3.4 kN — the broom's slot). Two hot corners, one rated shackle in the budget: that is the night's rigging question once the F key is answered.",
"dirCurve": [[0, 0.6], [36, 0.7], [40, 0.4], [48, -0.6], [70, -0.7], [90, -0.65]],
"dirWander": { "amp": 0.2, "rate": 0.1 },
"spatial": { "amp": 0.18, "scale": 11, "advect": 0.5 },
"events": [
{ "t": 40, "type": "windchange", "telegraph": 6, "over": 8, "text": "the rain squares around" },
{ "t": 56, "type": "debris", "model": "BlackTub_v2", "lateral": 2.0, "mass": 4, "text": "a tub floats off the path" },
{ "t": 66, "type": "lightning", "power": 0.4 }
],
"_rain_comment": "peakMmPerHour 44 and it rains from the first minute to the last — mean intensity ~0.75, so at decision 10's 40x the night delivers ~3 cm on a flat catch: enough that the bed ring (four 4.0 m posts, near-flat) ponds toward a real corner load on EITHER fabric (ponding reads geometry, not porosity — see THREADS), and the broom stays a tool. First drafts ran 55 then 48: at 55 the pond alone tore the $60 membrane ring's corners and the fabric lesson drowned in a rain lesson — 44 keeps exactly one pond-hot corner on the winning line.",
"rain": { "peakMmPerHour": 44, "curve": [[0, 0.15], [10, 0.45], [25, 0.75], [40, 0.9], [60, 1.0], [80, 0.85], [90, 0.7]] },
"_hail_comment": "size 0.45 = true pea hail, the storm's entire thesis (see _why_comment). Four authored bursts spread t=16..79 so the leak is 'all night', not a moment — and NO gust-synced hail on purpose: the stones arrive on the RAIN's schedule, not the wind's, which keeps the mild wind honest and the forecast legible. Weighted hail-seconds 14.9 measured — between the southerly's ~2 and the icenight's ~18 — and TUNED not maxed (the icenight's own lesson): first draft ran 17.2 and read bare 7.2 / best-cloth 33, a wall wearing a bet's clothes; at 14.9 the best cloth line loses by a clear 7.7 HP and the membrane ring wins FULL, which is the storm doing exactly one thing.",
"hail": {
"size": 0.45,
"bursts": [
{ "t": 16, "ramp": 2, "hold": 5, "fade": 3, "intensity": 0.35 },
{ "t": 33, "ramp": 2, "hold": 6, "fade": 3, "intensity": 0.43 },
{ "t": 49, "ramp": 2, "hold": 8, "fade": 3, "intensity": 0.52 },
{ "t": 70, "ramp": 2, "hold": 5, "fade": 4, "intensity": 0.39 }
]
},
"sky": { "darkness": 0.85, "cloudScroll": 0.07, "night": true }
}

View File

@ -59,6 +59,7 @@ export const STORM_KEYS = [
'storm_02b_icenight',
'storm_03_southerly',
'storm_03b_earlybuster',
'storm_06_soaker',
];
export const DEFAULT_SEL = { storm: 'storm_03_southerly', time: 40 };

291
web/world/js/stormgen.js Normal file
View File

@ -0,0 +1,291 @@
'use strict';
// SHADES — Lane C — makeStorm(seed): seeded storms inside authored envelopes.
//
// SPRINT16 gate 3.2 — GROUNDWORK, not a shipped night. ROADMAP arc 4's
// roguelite ("procedurally generated storm sequences inside authored
// envelopes") eats this; nothing in NIGHTS uses it yet, and nothing should
// until that arc's own sprint prices it.
//
// The design rule, stated once: an ENVELOPE is authored (a designer says what
// this storm family is allowed to be — every knob a [lo, hi] range, every
// curve knot a fixed time with a ranged value), and a SEED picks one storm
// from inside it. The generator draws every value in a FIXED order from one
// mulberry32 stream, so:
// · same seed + same envelope → the SAME def, byte-equal under
// JSON.stringify (the determinism the whole sim is built on),
// · a generated def carries its seed, so the gust timeline downstream
// (buildGustTimeline reads def.seed) is seeded by the same number,
// · every draw is range-clamped by construction, and checkEnvelope() can
// verify conformance FROM THE DEF ALONE — it recomputes, it does not
// trust the generator, so a hand-edited def fails honestly.
//
// Pure on purpose: imports weather.core only (no THREE, no DOM, no fetch), so
// this runs under the node one-second loop exactly like the storm curves do.
//
// What an envelope deliberately does NOT range: knot TIMES (fixed per family —
// a family whose acts move is two families), telegraph (the honesty rule is
// not tunable), and GUST envelope shape (weather.core owns it). The change's
// dirCurve is BUILT from the drawn change time so the windchange event and the
// physics can never drift apart — the validator's own trap, made structural.
import { mulberry32, validateStorm, GUST } from './weather.core.js';
/** Round to 3 decimals keeps generated defs readable and diffs stable.
* Determinism does not depend on this (same ops same doubles); byte-equal
* JSON just reads better at 3 places than at 17. */
const r3 = (v) => Math.round(v * 1000) / 1000;
/**
* The authored envelopes, as data. One family ships as groundwork: the
* soaker family, ranged around storm_06's MEASURED personality (fabric_bet
* probe, THREADS 2026-07-20) so every member keeps the night's thesis
* pea stones under the cloth aperture, heavy rain, mild wind while the
* seed moves the acts around inside it.
*
* Envelope grammar (checkEnvelope enforces all of it):
* duration fixed seconds
* base { t: [knot times, first 0, last duration], v: [[lo,hi]] }
* gusts every field [lo,hi]; minGap.lo must be GUST.TOTAL or
* gusts could overlap (validator red)
* change { at:[lo,hi], over:[lo,hi], telegraph: fixed,
* startDir:[lo,hi], swing:[lo,hi] } swing is ADDED to
* the start direction; |swing| must be 0.6 everywhere
* in range (validator demands 0.5 rad of real turn)
* dirWander { amp:[lo,hi], rate:[lo,hi] }
* spatial fixed (spatial texture is family identity, not weather)
* rain { peakMmPerHour:[lo,hi], t: [knot times], v: [[lo,hi]] }
* hail null, or { size:[lo,hi], count:[lo,hi] (integer),
* window:[t0,t1], hold:[lo,hi], intensity:[lo,hi],
* ramp: fixed, fade: fixed }
* sky { darkness:[lo,hi], cloudScroll: fixed, night: fixed }
*/
export const ENVELOPES = {
soaker_family: {
name: 'soaker_family',
duration: 90,
base: {
t: [0, 15, 30, 50, 70, 90],
v: [[4, 6], [6, 7.5], [7.5, 9], [8.5, 10], [8, 9.5], [6.5, 8]],
},
gusts: {
firstAt: [3, 6], minGap: [5.5, 7], maxGap: [9, 12],
powBase: [1.5, 2.5], powRand: [2, 4], powRamp: [1.5, 2.5],
downdraftOfTotal: [0.25, 0.35],
},
change: {
at: [36, 52], over: [6, 10], telegraph: 6,
startDir: [0.5, 0.8], swing: [-1.5, -1.1],
},
dirWander: { amp: [0.15, 0.25], rate: [0.08, 0.12] },
spatial: { amp: 0.18, scale: 11, advect: 0.5 },
rain: {
peakMmPerHour: [38, 50],
t: [0, 10, 25, 40, 60, 80, 90],
v: [[0.1, 0.2], [0.35, 0.55], [0.65, 0.85], [0.8, 0.95], [0.9, 1.0], [0.75, 0.9], [0.6, 0.8]],
},
hail: {
size: [0.4, 0.55], count: [3, 5], window: [14, 78],
hold: [4, 9], intensity: [0.3, 0.55], ramp: 2, fade: 3,
},
sky: { darkness: [0.8, 0.9], cloudScroll: 0.07, night: true },
},
};
/**
* One storm from inside the envelope. Deterministic: same (seed, envelope)
* byte-equal def, proven by the selftest with JSON.stringify.
*
* @param {number} seed any uint32
* @param {object} [env] an ENVELOPES entry (defaults to the soaker family)
* @returns {object} a storm def that passes validateStorm and checkEnvelope
*/
export function makeStorm(seed, env = ENVELOPES.soaker_family) {
const rng = mulberry32(seed >>> 0);
const draw = ([lo, hi]) => lo + rng() * (hi - lo);
const drawInt = ([lo, hi]) => lo + Math.floor(rng() * (hi - lo + 1e-9));
// Draw order is the contract: base knots, gusts, change, wander, rain
// (peak then knots), hail (size, count, times, holds, intensities), sky.
// Reordering these lines is a breaking change to every seed ever noted.
const baseCurve = env.base.t.map((t, i) => [t, r3(draw(env.base.v[i]))]);
const g = env.gusts;
const gusts = {
firstAt: r3(draw(g.firstAt)),
minGap: r3(draw(g.minGap)),
maxGap: r3(draw(g.maxGap)),
powBase: r3(draw(g.powBase)),
powRand: r3(draw(g.powRand)),
powRamp: r3(draw(g.powRamp)),
downdraftOfTotal: r3(draw(g.downdraftOfTotal)),
};
const at = r3(draw(env.change.at));
const over = r3(draw(env.change.over));
const startDir = r3(draw(env.change.startDir));
const swing = r3(draw(env.change.swing));
const endDir = r3(startDir + swing);
// Built FROM the drawn change, so event and physics agree by construction:
// hold until just before the change, swing across [at, at+over], settle a
// touch past it, hold to the end. All knot times clamped inside duration.
const settle = Math.min(env.duration, at + over + 14);
const dirCurve = [
[0, startDir],
[r3(at - 4), startDir],
[at, r3(startDir - swing * 0.15)],
[r3(at + over), endDir],
[r3(settle), r3(endDir - 0.05)],
[env.duration, r3(endDir - 0.05)],
];
const dirWander = { amp: r3(draw(env.dirWander.amp)), rate: r3(draw(env.dirWander.rate)) };
const rain = {
peakMmPerHour: r3(draw(env.rain.peakMmPerHour)),
curve: env.rain.t.map((t, i) => [t, r3(draw(env.rain.v[i]))]),
};
let hail = null;
if (env.hail) {
const size = r3(draw(env.hail.size));
const count = drawInt(env.hail.count);
// Jittered slots: split the window into `count` equal slots and drop one
// burst inside each — spread guaranteed, spacing seeded, no sort needed
// (slot order IS time order, so draw order stays stable per burst).
const [w0, w1] = env.hail.window;
const slot = (w1 - w0) / count;
const bursts = [];
for (let i = 0; i < count; i++) {
const t = r3(w0 + slot * i + rng() * slot * 0.6);
bursts.push({
t,
ramp: env.hail.ramp,
hold: r3(draw(env.hail.hold)),
fade: env.hail.fade,
intensity: r3(draw(env.hail.intensity)),
});
}
hail = { size, bursts };
}
const sky = {
darkness: r3(draw(env.sky.darkness)),
cloudScroll: env.sky.cloudScroll,
};
if (env.sky.night) sky.night = true;
return {
name: `${env.name} seed ${seed >>> 0}`,
blurb: `Generated from the ${env.name} envelope — one of its storms, picked by seed ${seed >>> 0}.`,
_generated: { envelope: env.name, seed: seed >>> 0 },
seed: seed >>> 0,
duration: env.duration,
baseCurve,
gusts,
dirCurve,
dirWander,
spatial: { ...env.spatial },
events: [
{ t: at, type: 'windchange', telegraph: env.change.telegraph, over, text: 'the change comes through' },
],
rain,
...(hail ? { hail } : {}),
sky,
};
}
const inRange = (v, [lo, hi], eps = 1e-9) => Number.isFinite(v) && v >= lo - eps && v <= hi + eps;
/**
* Does this def sit inside this envelope? Recomputed from the DEF, never from
* remembering what the generator drew a hand-edited def must fail here on
* its own merits. Also runs validateStorm, so "inside the envelope" implies
* "loadable storm". Returns { ok, errors } like the validator.
*/
export function checkEnvelope(def, env = ENVELOPES.soaker_family) {
const errors = [];
const bad = (m) => errors.push(`${env.name}: ${m}`);
const v = validateStorm(def, def?.name ?? 'generated');
if (!v.ok) errors.push(...v.errors);
if (!def || typeof def !== 'object') return { ok: false, errors };
if (def.duration !== env.duration) bad(`duration ${def.duration} ≠ family's ${env.duration}`);
// base curve: same knot times, every value in range
const bc = def.baseCurve ?? [];
if (bc.length !== env.base.t.length) bad(`baseCurve has ${bc.length} knots, family has ${env.base.t.length}`);
else bc.forEach(([t, val], i) => {
if (t !== env.base.t[i]) bad(`baseCurve knot ${i} at t=${t}, family fixes t=${env.base.t[i]}`);
if (!inRange(val, env.base.v[i])) bad(`baseCurve[${i}] = ${val} outside [${env.base.v[i]}]`);
});
const g = def.gusts ?? {};
for (const k of ['firstAt', 'minGap', 'maxGap', 'powBase', 'powRand', 'powRamp', 'downdraftOfTotal']) {
if (!inRange(g[k], env.gusts[k])) bad(`gusts.${k} = ${g[k]} outside [${env.gusts[k]}]`);
}
if ((g.minGap ?? 0) < GUST.TOTAL) bad(`gusts.minGap ${g.minGap} < ${GUST.TOTAL} — family authored an overlap`);
// the change: one windchange event, inside the window, physics agreeing
const changes = (def.events ?? []).filter((e) => e.type === 'windchange');
if (changes.length !== 1) bad(`family storms carry exactly one windchange, got ${changes.length}`);
else {
const ev = changes[0];
if (!inRange(ev.t, env.change.at)) bad(`change at t=${ev.t} outside [${env.change.at}]`);
if (!inRange(ev.over ?? 6, env.change.over)) bad(`change.over ${ev.over} outside [${env.change.over}]`);
if ((ev.telegraph ?? 0) !== env.change.telegraph) bad(`change.telegraph ${ev.telegraph} ≠ family's ${env.change.telegraph}`);
const dc = def.dirCurve ?? [];
const start = dc[0]?.[1];
const end = dc[dc.length - 1]?.[1];
if (!inRange(start, env.change.startDir)) bad(`dirCurve start ${start} outside [${env.change.startDir}]`);
// net swing (allowing the settle's small overshoot), against the family's
const net = end - start;
const [sLo, sHi] = env.change.swing;
if (!inRange(net, [sLo - 0.1, sHi + 0.1])) bad(`net dir swing ${net?.toFixed?.(3)} outside [${sLo},${sHi}]±0.1`);
}
const w = def.dirWander ?? {};
if (!inRange(w.amp, env.dirWander.amp)) bad(`dirWander.amp ${w.amp} outside [${env.dirWander.amp}]`);
if (!inRange(w.rate, env.dirWander.rate)) bad(`dirWander.rate ${w.rate} outside [${env.dirWander.rate}]`);
const sp = def.spatial ?? {};
for (const k of ['amp', 'scale', 'advect']) {
if (sp[k] !== env.spatial[k]) bad(`spatial.${k} ${sp[k]} ≠ family's ${env.spatial[k]}`);
}
const rn = def.rain ?? {};
if (!inRange(rn.peakMmPerHour, env.rain.peakMmPerHour)) {
bad(`rain.peakMmPerHour ${rn.peakMmPerHour} outside [${env.rain.peakMmPerHour}]`);
}
const rc = rn.curve ?? [];
if (rc.length !== env.rain.t.length) bad(`rain.curve has ${rc.length} knots, family has ${env.rain.t.length}`);
else rc.forEach(([t, val], i) => {
if (t !== env.rain.t[i]) bad(`rain knot ${i} at t=${t}, family fixes t=${env.rain.t[i]}`);
if (!inRange(val, env.rain.v[i])) bad(`rain.curve[${i}] = ${val} outside [${env.rain.v[i]}]`);
});
if (env.hail) {
const h = def.hail;
if (!h) bad('family hails; def has no hail block');
else {
if (!inRange(h.size, env.hail.size)) bad(`hail.size ${h.size} outside [${env.hail.size}]`);
const bursts = h.bursts ?? [];
if (!inRange(bursts.length, env.hail.count)) bad(`${bursts.length} bursts outside [${env.hail.count}]`);
bursts.forEach((b, i) => {
if (!inRange(b.t, env.hail.window)) bad(`burst[${i}] at t=${b.t} outside window [${env.hail.window}]`);
if (!inRange(b.hold, env.hail.hold)) bad(`burst[${i}].hold ${b.hold} outside [${env.hail.hold}]`);
if (!inRange(b.intensity, env.hail.intensity)) bad(`burst[${i}].intensity ${b.intensity} outside [${env.hail.intensity}]`);
if (b.ramp !== env.hail.ramp) bad(`burst[${i}].ramp ${b.ramp} ≠ family's ${env.hail.ramp}`);
if (b.fade !== env.hail.fade) bad(`burst[${i}].fade ${b.fade} ≠ family's ${env.hail.fade}`);
});
if (h.withGustsAbove != null) bad('family hail is authored-bursts only; withGustsAbove is not in the envelope');
}
} else if (def.hail) {
bad('family does not hail; def carries a hail block');
}
const sk = def.sky ?? {};
if (!inRange(sk.darkness, env.sky.darkness)) bad(`sky.darkness ${sk.darkness} outside [${env.sky.darkness}]`);
return { ok: errors.length === 0, errors };
}

View File

@ -16,7 +16,7 @@
import * as THREE from '../../vendor/three.module.js';
import { assert, assertClose, fixedLoop } from '../testkit.js';
import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS, createStubWind } from '../contracts.js';
import { loadStorm, createWind, windForSite, forecastLines, forecastFor, leadFor } from '../weather.js';
import { loadStorm, createWind, windForSite, forecastLines, forecastFor, leadFor, hailBlockFor } from '../weather.js';
import { loadSite, createWorld } from '../world.js';
// GATE 2.3 — the GAME's own wind wiring, IMPORTED rather than re-typed. A pin
// that copied main.js's two lines would agree with a copy of the game forever,
@ -30,6 +30,11 @@ import { createSkyFx, RainShadow } from '../skyfx.js';
import { SailRig, HARDWARE } from '../sail.js';
import { weatherCases } from './weather.selftest.js';
import { ENVELOPE_TESTS } from '../../../../tools/storm_envelope/envelope.selftest.js';
// SPRINT16 gate 3: the membrane night's pin flies THE audit chain (gardenfly),
// and the seeded generator must satisfy the storm-side envelope harness.
import { flyGarden, hardwareByName } from '../../../../tools/site_audit/gardenfly.js';
import { stormEnvelope } from '../../../../tools/storm_envelope/envelope.js';
import { makeStorm } from '../stormgen.js';
// Keep in step with data/storms/. The node runner globs the directory, so this
// list going stale shows up here first — as it did when storm_03 landed and the
@ -39,6 +44,9 @@ const STORMS = [
// SPRINT6: the week's two variants — night 3 (same force, half the warning)
// and night 5 (less wind, 2.8× the hail).
'storm_03b_earlybuster', 'storm_02b_icenight',
// SPRINT16 gate 3.1: the pea-hail soaker — the first night the default
// fabric is the wrong answer. Its membrane-vs-cloth pin is below.
'storm_06_soaker',
];
/** @param {import('../testkit.js').Suite} t */
@ -719,13 +727,17 @@ export default async function run(t) {
['rain', (s) => s.rainPeak],
['rainMmPerHour', (s) => s.rainPeakMmPerHour],
];
// gate 3.3: the stone-size band obeys the same law as every other number
// on the card — nested, truth-holding, exact at lead 0. It lives at
// f.hail.size, so the key row carries its own accessor.
keys.push(['hail.size', (s) => s.hailSize, (f) => f.hail.size]);
for (const [name, def] of Object.entries(storms)) {
for (const [k, truthOf] of keys) {
for (const [k, truthOf, getBand] of keys) {
let prev = null;
// walk the week backwards: the far end -> tonight
for (let i = 40; i >= 0; i--) {
const f = forecastFor(def, i / 40);
const b = f[k];
const b = getBand ? getBand(f) : f[k];
const truth = truthOf(f.truth);
assert(b.lo <= truth + 1e-9 && b.hi >= truth - 1e-9,
`${name}.${k} @lead ${i / 40}: band ${b.lo}${b.hi} rules out the truth ${truth}`);
@ -738,8 +750,9 @@ export default async function run(t) {
}
// and it must ARRIVE: tonight is the truth, not merely a narrow band
const exact = forecastFor(def, 0);
assert(exact[k].lo === exact[k].hi,
`${name}.${k}: tonight should be exact, got ${exact[k].lo}${exact[k].hi}`);
const eb = getBand ? getBand(exact) : exact[k];
assert(eb.lo === eb.hi,
`${name}.${k}: tonight should be exact, got ${eb.lo}${eb.hi}`);
}
}
});
@ -1093,4 +1106,182 @@ export default async function run(t) {
+ 'different quantities that merely nearly coincide, and conflating them is the '
+ 'mistake THREADS records A and C both nearly making');
});
// =========================================================================
// SPRINT16 GATE 3.3 — the stone and the rain rate join the card
t.test('gate 3.3: forecast stones and rain rate — worded, banded, exact tonight, never NaN', () => {
const soaker = storms.storm_06_soaker;
const tonight = forecastLines(soaker, 0);
// tonight is exact: the soaker's truth, in the fabric bet's own words
assert(tonight.stones === 'fine pea stones',
`the soaker's stones read "${tonight.stones}" — size 0.45 is the fine pea the cloth leaks, `
+ 'and the card is where the player learns that before spending a dollar');
assert(tonight.rainRate === 'rain to 44 mm/hr',
`the soaker's rain rate reads "${tonight.rainRate}" — want the exact 44 mm/hr at lead 0`);
assert(/hail likely/.test(tonight.rain),
`the soaker (14.9 hail-seconds) should call its hail likely: "${tonight.rain}"`);
// the sizes cloth stops dead read as the big words
assert(forecastLines(storms.storm_02_wildnight, 0).stones === 'golf ball stones',
`wild night stones read "${forecastLines(storms.storm_02_wildnight, 0).stones}"`);
assert(forecastLines(storms.storm_03_southerly, 0).stones === 'pea stones',
`southerly stones read "${forecastLines(storms.storm_03_southerly, 0).stones}" — 0.7 is `
+ "storm_03's own comment's word");
// a hail-free storm advertises nothing
assert(forecastLines(storms.storm_01_gentle, 0).stones === '',
'the gentle storm advertised stones it does not have');
// far out, the rate hedges as a band (the stone word may or may not span
// two words — the BAND honesty is pinned in the resolving test above)
const far = forecastLines(soaker, 1);
assert(//.test(far.rainRate), `a week out the rain rate should hedge, got "${far.rainRate}"`);
assert(/fine pea/.test(far.stones), `the soaker a week out still forecasts pea-family stones, got "${far.stones}"`);
// no NaN/undefined reaches the card, any storm, any lead
for (const [name, d] of Object.entries(storms)) {
for (const lead of [0, 0.5, 1]) {
const f = forecastLines(d, lead);
for (const k of ['stones', 'rainRate']) {
assert(typeof f[k] === 'string' && !/NaN|undefined/.test(f[k]),
`${name} @lead ${lead}: ${k} reads "${f[k]}"`);
}
}
}
});
// =========================================================================
// SPRINT16 GATE 3.2 — the generator satisfies the storm-side harness
t.test('gate 3.2: a generated storm flies the storm_envelope harness, deterministically', () => {
const anchors = [
{ id: 'a', type: 'post', pos: { x: 0, y: 4, z: 0 } },
{ id: 'b', type: 'post', pos: { x: 5, y: 4, z: 3 }, ratingHint: 0.45 },
];
const r1 = stormEnvelope({ anchors, stormDef: makeStorm(7) });
const r2 = stormEnvelope({ anchors, stormDef: makeStorm(7) });
for (const r of r1.rows) {
assert(Number.isFinite(r.peak) && r.peak > 0, `vacuous envelope row for ${r.id}: peak ${r.peak}`);
assert(Number.isFinite(r.dose) && r.dose > 0, `vacuous dose for ${r.id}`);
assert(r.tPeak >= 0 && r.tPeak <= 90, `${r.id} peaked outside the storm at t=${r.tPeak}`);
}
r1.rows.forEach((r, i) => {
const s = r2.rows[i];
assert(r.peak === s.peak && r.tPeak === s.tPeak && r.dose === s.dose,
`same seed, two envelope runs disagree at ${r.id}: ${r.peak} vs ${s.peak}`);
});
// hint reaches the ranking exactly as it does for authored storms
assert(r1.rows[0].id === 'b',
`equal-ish wind, hint 0.45 vs 1.0 — 'b' must rank first, got ${r1.rows.map((r) => r.id).join(',')}`);
// and the seed is a real variable through the harness, not just the JSON
const other = stormEnvelope({ anchors, stormDef: makeStorm(8) });
assert(other.rows.some((r, i) => r.peak !== r1.rows[i].peak),
'seeds 7 and 8 measured identically through the harness — the seed is not reaching the wind');
});
// =========================================================================
// SPRINT16 GATE 3.1 — THE MEMBRANE NIGHT, PINNED THROUGH THE REAL CHAIN
//
// storm_06_soaker exists to make the F key matter: pea stones under the
// cloth aperture, heavy rain, mild wind. The pin flies gardenfly — THE
// audit scoring chain (windForSite → attach → skyfx → garden.js), dressed
// site_02 anchors, live sway, funnel ON — with porosity the ONLY variable
// between the cloth and membrane flights. Numbers measured 2026-07-20
// (fabric_bet probe, post-ruling tree): bare 17.5 · cloth $75 ring 42.3 ·
// membrane $75 ring 96.5 · membrane $60 ring 68.8. Thresholds pin the
// BOUNDARIES the night stakes, with margin, not the digits.
const soakerDef = storms.storm_06_soaker;
const soakerSite = site02; // loaded above for gate 2.3
let soakerFlights = null;
{
// garden_probe's proxy pattern: world.anchors fly THEMSELVES, and `use`
// re-points the sway closures at the storm actually flying (the frozen-
// tree landmine — tr1/tr1b are the pin's own corners, they must sway).
let cur = createStubWind({ calm: true });
const proxy = {
sample: (p, t2, o) => cur.sample(p, t2, o || new THREE.Vector3()),
speedAt: (p, t2) => cur.speedAt(p, t2),
rainAt: (t2) => (cur.rainAt ? cur.rainAt(t2) : 0),
rainMmPerHour: (t2) => (cur.rainMmPerHour ? cur.rainMmPerHour(t2) : 0),
gustTelegraph: (t2) => (cur.gustTelegraph ? cur.gustTelegraph(t2) : null),
eventsBetween: (a, b) => (cur.eventsBetween ? cur.eventsBetween(a, b) : []),
setSheltersFromTrees() {},
};
const use = (w) => { cur = w; };
try {
const world = createWorld(new THREE.Scene(), { wind: proxy, site: structuredClone(soakerSite) });
await world.dress(); // graybox would move the tree corners — fail loud instead
const bed = world.gardenBed;
const ring = ['q1', 'q2', 'q3', 'q4'];
const hw75 = ['rated shackle', 'shackle', 'shackle', 'shackle'].map(hardwareByName);
const hw60 = ['shackle', 'shackle', 'shackle', 'shackle'].map(hardwareByName);
const hw20 = Array(4).fill(hardwareByName('carabiner'));
const common = { anchors: world.anchors, bed, stormDef: soakerDef, siteDef: soakerSite, use };
soakerFlights = {
bare: flyGarden({ ...common }),
cloth75: flyGarden({ ...common, ids: ring, hw: hw75, porosity: 0.30 }),
mem75: flyGarden({ ...common, ids: ring, hw: hw75, porosity: 0 }),
mem60: flyGarden({ ...common, ids: ring, hw: hw60, porosity: 0 }),
cloth20: flyGarden({ ...common, ids: ring, hw: hw20, porosity: 0.30 }),
mem20: flyGarden({ ...common, ids: ring, hw: hw20, porosity: 0 }),
};
} catch (err) {
soakerFlights = { error: err };
}
}
t.test('GATE 3.1: the soaker flips the fabric bet — membrane wins the night cloth loses', () => {
if (soakerFlights?.error) throw soakerFlights.error;
const { bare, cloth75, mem75, mem60 } = soakerFlights;
// vacuity guards first: the night must actually threaten, through hail
assert(bare.byHail > 40,
`bare bed took only ${bare.byHail} HP of hail — the soaker has stopped soaking and this pin proves nothing`);
assert(bare.hp < 30, `bare bed survived at ${bare.hp} — the night has no teeth`);
// the primitive the whole night stands on: these stones pass a 2 mm weave
const size = soakerDef.hail.size;
assert(hailBlockFor(size, 0.30) < 0.30,
`cloth blocks ${(hailBlockFor(size, 0.30) * 100).toFixed(0)}% of size-${size} stones — the soaker's `
+ 'hail has drifted above the weave aperture and the bet is dead');
assert(hailBlockFor(size, 0) === 1, 'membrane stopped blocking — hailBlockFor broke');
// CLOTH LOSES: the best measured cloth line in the yard (this ring, $75)
// ends under the win line — the default fabric is the wrong answer.
assert(cloth75.hp < 50,
`the $75 ring on CLOTH reads ${cloth75.hp} — over the win line, so the default fabric wins the `
+ 'membrane night and storm_06 is a decoration (retune the hail spectrum, per the gate)');
// ...but visibly beats bare: the cloth is leaking, not absent
assert(cloth75.hp > bare.hp + 10,
`cloth ${cloth75.hp} vs bare ${bare.hp} — cloth is doing nothing; the leak should be partial`);
// MEMBRANE WINS, same line, one variable: the F key is the night
assert(mem75.hp > 90 && mem75.state === 'full',
`the SAME $75 ring on MEMBRANE reads ${mem75.hp} ${mem75.state} — want >90 FULL (measured 96.5)`);
assert(mem75.cornersLost < 2,
`membrane $75 lost ${mem75.cornersLost} corners — main.js's win rule (lost<2) fails, the load is not affordable`);
assert(mem75.cost <= 80, `the membrane line costs $${mem75.cost} — the $80 wallet cannot buy the night's answer`);
// and the bet is affordable a tier down: the $60 ring also wins on membrane
// (measured 68.8 FULL, one pond corner late — the broom's job in play)
assert(mem60.hp > 60 && mem60.cornersLost < 2,
`the $60 ring on membrane reads ${mem60.hp} with ${mem60.cornersLost} lost — the bet should not `
+ 'need the last dollar of the wallet');
// THE SEPARATION, pinned: same line, same steel, fabric alone is worth
// most of a garden (measured Δ54.2 — threshold carries real margin)
assert(mem75.hp - cloth75.hp > 40,
`membrane-vs-cloth separation ${(mem75.hp - cloth75.hp).toFixed(1)} HP on the same $75 line — `
+ 'under 40, the F key has stopped being the night');
});
t.test('GATE 3.1 guard: membrane is not a free win — on cheap steel it tears', () => {
// The inverse control, so the night cannot rot into "always press F":
// the doubled load must make membrane WORSE than cloth on carabiners.
// (Measured: cloth $20 ring 24.5 hp / 2 lost · membrane $20 ring 19.1 hp
// / 3 lost.) Without this, fabric would be a one-way upgrade and the bet
// a formality — the same decoration failure from the other side.
if (soakerFlights?.error) throw soakerFlights.error;
const { cloth20, mem20 } = soakerFlights;
assert(cloth20.cornersLost > 0 || mem20.cornersLost > 0,
'neither cheap ring lost a corner — carabiners hold the soaker and this control proves nothing');
assert(mem20.cornersLost > cloth20.cornersLost || mem20.hp < cloth20.hp,
`membrane on carabiners (${mem20.hp} hp, ${mem20.cornersLost} lost) should read WORSE than cloth `
+ `(${cloth20.hp} hp, ${cloth20.cornersLost} lost) — the doubled load has stopped costing anything`);
});
}

View File

@ -14,6 +14,7 @@ import {
createWindField, validateStorm, validateSiteWind, gustEnvelope, GUST, RAIN_TIME_COMPRESSION,
stormStats, forecastFor, hailBlockFor,
} from '../weather.core.js';
import { makeStorm, checkEnvelope, ENVELOPES } from '../stormgen.js';
const DT = 1 / 60;
@ -44,7 +45,14 @@ export function weatherCases(storms) {
const test = (name, fn) => cases.push({ name, fn });
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
const defs = Object.entries(storms);
// SPRINT16 gate 3.2: two generated storms ride the SAME per-storm honesty
// gates as the authored ones below (telegraph lead, continuity, validator).
// A family whose members can be dishonest is not an envelope, it's a slot
// machine — fixed seeds so the suite stays deterministic run to run.
const defs = [
...Object.entries(storms),
...[11, 47].map((s) => [`gen_soaker_${s}`, makeStorm(s)]),
];
// ---- 1. gust telegraph lead (PLAN3D §5-C.5: always >= 1.2 s before ramp) ----
// The whole gust read is "you SEE it coming, then it hits". If the lead ever
@ -707,6 +715,7 @@ export function weatherCases(storms) {
inside(f.gustPeak, s.gustPeak, 'gustPeak');
inside(f.rain, s.rainPeak, 'rain');
inside(f.hail.seconds, s.hailSeconds, 'hailSeconds');
inside(f.hail.size, s.hailSize, 'hailSize'); // gate 3.3: the stone is a band too
if (s.changeAt != null) inside(f.changeAt, s.changeAt, 'changeAt');
}
}
@ -814,6 +823,84 @@ export function weatherCases(storms) {
assert(peaPorous > peaMembrane + 0.3, `porous should leak real pea hail: ${peaPorous.toFixed(2)} vs ${peaMembrane.toFixed(2)}`);
});
// ---- 14. makeStorm(seed) — SPRINT16 gate 3.2, arc 4's groundwork ----
// A seeded generator inside authored envelopes. No shipped night uses it;
// these cases are what "inside" and "deterministic" MEAN.
test('makeStorm: same seed → byte-equal def; different seed → different storm', () => {
const a = JSON.stringify(makeStorm(1234));
const b = JSON.stringify(makeStorm(1234));
assert(a === b, 'two calls with the same seed produced different JSON — a hidden draw or a Date leaked in');
const c = JSON.stringify(makeStorm(1235));
assert(a !== c, 'seeds 1234 and 1235 produced the same storm — the seed is not reaching the draws');
// the def carries its seed, so the gust timeline downstream is seeded by
// the same number the caller noted down
assert(makeStorm(1234).seed === 1234, 'generated def does not carry its seed');
});
test('makeStorm: 100 seeds — every def validates AND sits inside its envelope', () => {
let gustLo = Infinity, gustHi = 0;
for (let s = 1; s <= 100; s++) {
const d = makeStorm(s);
const v = validateStorm(d, `gen_${s}`);
assert(v.ok, `seed ${s} failed the storm validator:\n ${v.errors.join('\n ')}`);
const e = checkEnvelope(d);
assert(e.ok, `seed ${s} escaped its envelope:\n ${e.errors.join('\n ')}`);
const st = stormStats(d);
gustLo = Math.min(gustLo, st.gustPeak); gustHi = Math.max(gustHi, st.gustPeak);
assert(st.hailSeconds > 2, `seed ${s} barely hails (${st.hailSeconds.toFixed(1)} s) — the family's thesis is pea hail all night`);
assert(d.hail.size < 0.6, `seed ${s} threw size-${d.hail.size} stones — over the family's pea range, cloth would block them`);
}
metrics['stormgen.gustPeak.lo'] = +gustLo.toFixed(1);
metrics['stormgen.gustPeak.hi'] = +gustHi.toFixed(1);
// the family is genuinely a FAMILY: seeds move the storm, inside a band
assert(gustHi - gustLo > 1, `100 seeds moved gustPeak only ${(gustHi - gustLo).toFixed(2)} m/s — the ranges are decoration`);
assert(gustHi < 20, `a soaker gusted to ${gustHi.toFixed(1)} m/s — the family has stopped being mild`);
});
test('checkEnvelope: a def outside the envelope goes red, not shrugged', () => {
// vacuity guard first: the def we are about to mutate passes as generated
const clean = makeStorm(7);
assert(checkEnvelope(clean).ok, 'the unmutated control failed — every red below would be noise');
// each mutation is one escape route, and each must be NAMED in the errors
const mutations = [
['base speed over the family ceiling', (d) => { d.baseCurve[3][1] = 12; }, /baseCurve\[3\]/],
['pea family throwing ice', (d) => { d.hail.size = 1.3; }, /hail\.size/],
['gusts overlapping (minGap under the gust length)', (d) => { d.gusts.minGap = 4; }, /minGap/],
['the change re-timed outside its window', (d) => { d.events[0].t = 20; d.dirCurve = [[0, 0.6], [16, 0.7], [28, -0.7], [90, -0.65]]; }, /change at t=20/],
];
for (const [label, mutate, want] of mutations) {
const d = makeStorm(7);
mutate(d);
const e = checkEnvelope(d);
assert(!e.ok, `checkEnvelope shrugged at: ${label}`);
assert(e.errors.some((m) => want.test(m)),
`${label}: red, but no error names the escape — got:\n ${e.errors.join('\n ')}`);
}
// and the one that separates checkEnvelope from validateStorm: a def that
// is a perfectly LOADABLE storm but is not a member of this family
const stranger = makeStorm(7);
stranger.rain.peakMmPerHour = 75; // a severe soaker — valid, off-family
assert(validateStorm(stranger, 'stranger').ok, 'the stranger should still validate as a storm');
assert(!checkEnvelope(stranger).ok, 'a valid-but-off-family storm passed the envelope — checkEnvelope is just the validator in a hat');
});
test('makeStorm: the change event and the physics agree by construction', () => {
// the validator's windchange/dirCurve drift trap, proven closed for every
// seed we ship gates on — the event's promised swing is real in dirCurve
for (const s of [3, 21, 88]) {
const d = makeStorm(s);
const ev = d.events.find((e) => e.type === 'windchange');
const f = createWindField(d);
const before = f.dirAt(ev.t - 6);
const after = f.dirAt(ev.t + (ev.over ?? 6) + 6);
const swing = Math.abs(after - before);
assert(swing > 0.6, `seed ${s}: the change at t=${ev.t} only swung ${swing.toFixed(2)} rad`);
}
});
return { cases, metrics };
}

View File

@ -638,7 +638,10 @@ export function stormStats(def) {
const stats = {
sustained, gustPeak, rainPeak,
rainPeakMmPerHour: rainPeak * (def.rain?.peakMmPerHour ?? DEFAULT_PEAK_MM_PER_HOUR),
hailPeak, hailSeconds,
// Stone size (SPRINT16 gate 3.3): the fabric bet argues on the forecast,
// and the stone is the argument — a pea leaks through cloth, ice doesn't.
// 0 when the storm never hails, matching hailSeconds' convention.
hailPeak, hailSeconds, hailSize: f.hailSize,
changeAt: change ? change.t : null,
};
_statsCache.set(def, stats);
@ -648,6 +651,9 @@ export function stormStats(def) {
/** How wide each number's band runs at lead=1, as a fraction of the value. */
const FORECAST_SPREAD = {
sustained: 0.30, gustPeak: 0.35, rain: 0.45, changeAt: 0.22, hailSeconds: 0.7,
// Stone size hedges less than duration: radar sees how big the cell is long
// before it knows how long it will sit on you.
hailSize: 0.30,
};
/**
@ -692,7 +698,14 @@ export function forecastFor(def, lead = 0) {
rain: band(s.rainPeak, FORECAST_SPREAD.rain),
rainMmPerHour: band(s.rainPeakMmPerHour, FORECAST_SPREAD.rain),
changeAt: s.changeAt == null ? null : band(s.changeAt, FORECAST_SPREAD.changeAt),
hail: { chance, seconds: band(s.hailSeconds, FORECAST_SPREAD.hailSeconds) },
hail: {
chance,
seconds: band(s.hailSeconds, FORECAST_SPREAD.hailSeconds),
// SPRINT16 gate 3.3 — the stone joins the card. Band, not a promise:
// contains the truth at every lead, exact at lead 0 (band() guarantees
// both). {lo:0, hi:0} for a storm that never hails.
size: band(s.hailSize, FORECAST_SPREAD.hailSize),
},
truth: s,
};
}

View File

@ -84,11 +84,32 @@ export function forecastLines(def, lead = 0) {
: '· no change forecast';
const hail = f.hail.chance === 'none' ? '' : `· hail ${f.hail.chance}`;
// SPRINT16 gate 3.3 — the stone and the rain RATE join the card (A prints
// them; this owns the wording, same split as every other line here). The
// stone is the fabric bet's whole argument: pea hail rattles through a
// 2 mm weave, anything bigger cannot — so the size word is what tells a
// player which fabric tonight wants, before a dollar is spent. Words map
// hail.size units (1.0 ≈ a 1.5 cm stone): storm_03's 0.7 reads "pea" (its
// own comment's word), the soaker's 0.45 "fine pea", the wild night's 1.3
// and the ice night's 1.4 read "golf balls" — the sizes cloth stops dead.
const stoneWord = (v) => (v < 0.6 ? 'fine pea' : v <= 0.9 ? 'pea' : v <= 1.2 ? 'marble' : 'golf ball');
const sz = f.hail.size;
const stones = f.hail.chance === 'none' ? '' : (
stoneWord(sz.lo) === stoneWord(sz.hi)
? `${stoneWord(sz.hi)} stones`
: `${stoneWord(sz.lo)}${stoneWord(sz.hi)} stones`
);
return {
name: (def.name ?? '').replace(/_/g, ' ').toUpperCase(),
night: (def.sky?.night ?? (def.sky?.darkness ?? 0) > 0.6),
wind: `sustained to ${sustained} m/s (${sustainedKmh} km/h) · gusts to ~${gusts} km/h`,
rain: `rain ${rainWord} ${change} ${hail}`.trim(),
// The gate-3.3 lines, separate fields so A's card can place them without
// re-deriving anything. Empty string = nothing to print, same rule as
// `confidence` below; both bands contain the truth at every lead.
rainRate: `rain to ${band(f.rainMmPerHour, i)} mm/hr`,
stones,
// Only worth showing when it isn't tonight — "CONFIDENCE 100%" is noise.
confidence: lead > 0 ? `forecast confidence ${Math.round(f.confidence * 100)}%` : '',
hail: f.hail,