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