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,