diff --git a/THREADS.md b/THREADS.md
index 6b6852e..cd0c917 100644
--- a/THREADS.md
+++ b/THREADS.md
@@ -7706,3 +7706,144 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
Pushed to origin lane/e: factory (three builders + shared helpers + PAL entries), five
GLBs, report + contact sheet + manifest (pergola entry), contracts.js widening, sixteen
e.test pins, this entry.
+[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.
+
+[C] 2026-07-20 β π **GATE 3.1 CODA β B's FULL AUDIT flown over the soaker, closing the "best
+ cloth line" claim across the whole candidate space, not my seven probes.** audit.html,
+ site_02 Γ storm_06_soaker, funnel ON, 66 quads swept, 13 lines garden-flown (5 affordable
+ lines past the 12-flight cap unflown β the tool discloses it, so I do too):
+ Β· **winnability: 15 clean affordable lines β the night's STEEL is fair.** Cheapest clean
+ $50 (+$15 spare = $65, inside budget). Two marginal flagged, priced over the ceiling.
+ Β· **the garden, cloth-side: bare 17.5 DEAD (byte-identical to my probe β two harnesses,
+ one number) Β· best flown clean line 32 DEAD Β· sail worth +14.5 HP.** Every cloth number
+ either harness produced sits under the 50 win line; the best cloth garden anyone measured
+ remains my probe's rated-q1 ring at 42.3. The rig survives the soaker easily β the
+ garden does not, unless the fabric changes. That division of failure is the night.
+ Β· Note for D's cold-play: the audit's SCORE IT card on this night will read "no clean
+ buyable line saves the garden" β that is CORRECT on cloth and it is the design; the
+ membrane answer lives on the F key, which the sweep (cloth-fixed, by its own charter)
+ cannot see. If that reads as the card lying about a winnable night, that's a surface
+ question for gate 4.3's verdict, not a balance bug β flag it and we'll argue surfaces.
diff --git a/tools/fabric_bet/probe.html b/tools/fabric_bet/probe.html
new file mode 100644
index 0000000..4dd0b4c
--- /dev/null
+++ b/tools/fabric_bet/probe.html
@@ -0,0 +1,172 @@
+
+
+
+
fabric_bet probe
+
+fabric_bet β the same line, both fabrics, the real chain
+loadingβ¦
+
+
+
+
+
diff --git a/tools/site_audit/gardenfly.js b/tools/site_audit/gardenfly.js
index 67890c7..33ccbd8 100644
--- a/tools/site_audit/gardenfly.js
+++ b/tools/site_audit/gardenfly.js
@@ -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) {
diff --git a/web/world/data/storms/storm_06_soaker.json b/web/world/data/storms/storm_06_soaker.json
new file mode 100644
index 0000000..c36c0bd
--- /dev/null
+++ b/web/world/data/storms/storm_06_soaker.json
@@ -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 }
+}
diff --git a/web/world/js/editor_storm.js b/web/world/js/editor_storm.js
index 9311808..e0a6e1c 100644
--- a/web/world/js/editor_storm.js
+++ b/web/world/js/editor_storm.js
@@ -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 };
diff --git a/web/world/js/stormgen.js b/web/world/js/stormgen.js
new file mode 100644
index 0000000..79250d9
--- /dev/null
+++ b/web/world/js/stormgen.js
@@ -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 };
+}
diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js
index 38a20ce..fd70a10 100644
--- a/web/world/js/tests/c.test.js
+++ b/web/world/js/tests/c.test.js
@@ -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`);
+ });
}
diff --git a/web/world/js/tests/weather.selftest.js b/web/world/js/tests/weather.selftest.js
index 5b5da0e..15c3c4c 100644
--- a/web/world/js/tests/weather.selftest.js
+++ b/web/world/js/tests/weather.selftest.js
@@ -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 };
}
diff --git a/web/world/js/weather.core.js b/web/world/js/weather.core.js
index b21cd0a..4351df6 100644
--- a/web/world/js/weather.core.js
+++ b/web/world/js/weather.core.js
@@ -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,
};
}
diff --git a/web/world/js/weather.js b/web/world/js/weather.js
index a635f47..502c144 100644
--- a/web/world/js/weather.js
+++ b/web/world/js/weather.js
@@ -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,