Compare commits
3 Commits
ed62c7d4be
...
d03712ae47
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d03712ae47 | ||
|
|
d6af6ba5a2 | ||
|
|
92a8e870f2 |
124
THREADS.md
124
THREADS.md
@ -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
172
tools/fabric_bet/probe.html
Normal 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>
|
||||
@ -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) {
|
||||
|
||||
52
web/world/data/storms/storm_06_soaker.json
Normal file
52
web/world/data/storms/storm_06_soaker.json
Normal 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 }
|
||||
}
|
||||
@ -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
291
web/world/js/stormgen.js
Normal 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 };
|
||||
}
|
||||
@ -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`);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user