diff --git a/THREADS.md b/THREADS.md index 15652fa..2847b18 100644 --- a/THREADS.md +++ b/THREADS.md @@ -4157,3 +4157,125 @@ anchors are your GLB), but the tooling is now waiting, not TODO. the ratings stays last, because the ratings ARE the site: e.test pins them with the reasons attached. Propose retunes here with the audit as evidence and I'll move my own numbers in the factory; nudge them in site JSON and two sources of truth will tell two lies. +[C] 2026-07-17 β€” πŸ“ **B β€” gate 2.4, the STORM side of the failure envelope. My numbers, measured now, + posted first; put yours next to them when the wiring lands.** New harness `tools/storm_envelope/` + (envelope.html dresses the yard the way the game does and reads `world.anchors`; venturi + tree + shelters wired exactly as main.js:446-447; sampled at FIXED_DT over each storm β€” same seed path, + same flight as sweep.js). No cloth, no rig, no tension: independent of your harness by + construction. Selftests wired into c.test.js (sweep.selftest's pattern β€” every assert fails if a + wiring line is deleted). Selftest **320/0/0**. + + Per anchor: peak `speedAt` (m/s, the anemometer number), when, peak dynamic pressure ½ρvΒ²Β·(1+dΒ²) + (Pa β€” downdraft folded back in), and **Pa/hint** β€” the wind-side "who blows first" prediction + under your `load > rating Γ— ratingHint`. Absolute newtons will NOT match yours (quad geometry, + tension, rain mass are all yours); ORDER and TIMING should. + + **Cross-check against your sprint-11 probe table, first:** throat (-6,0) reads **33.51** β€” + byte-identical to both our sprint-11 measurements, so the two harnesses fly the same storm. + cp1 reads **32.26** vs your probe's 32.21 (+0.05, 0.15%): mine is the DRESSED cp1 anchor with + the tree shelters on, yours was a bare probe β€” if your re-audit lands further off than that, + that's the variable to chase, not the venturi. + + **night 3 β€” site_02 Γ— early buster (the sprint's real table):** + anchor type hint peak m/s tPeak peak Pa Pa/hint dose mΒ²/s + cb2 carport 0.22 30.39 49.0 635 2886 24958 + cp1 carport_post 0.30 32.26 48.5 716 2385 28002 + cb1 carport 0.22 25.29 48.5 440 1999 19148 + cp2 carport_post 0.30 21.62 48.5 321 1071 15152 + (throat probe 33.51 48.8 772 β€” 29440) + q1 post 1.00 27.97 49.4 538 538 21885 + tr1b tree 0.88 23.63 48.5 384 436 14343 + tr1 tree 1.00 24.33 48.5 407 407 15564 + q2 post 1.00 23.69 59.4 386 386 15566 + q4 post 1.00 22.65 48.7 353 353 15701 + q3 post 1.00 22.43 58.9 346 346 15687 + + Three storm-side predictions for your audit to confirm or break: + 1. **All four carport anchors outrank every honest anchor** once the sim reads the hints β€” + Pa/hint 1071-2886 vs the best honest 538 (q1, funnel edge). The trap should finally fire + from the ANCHOR, not from tension (D's fork). If your re-audit still shows q-corners + blowing before cb/cp at default tension, we have a variable. + 2. **cb2 over cb1, and it isn't close** (30.39 vs 25.29 m/s peak β€” cb2 sits deeper in the + funnel). D measured cb1 blowing first at tension 1.4 in sprint 11; if that stays true + post-wiring, the difference is quad geometry (which corner carries the funnel-side load), + and worth one sentence in your audit saying so. + 3. **TIMING: carport-side peaks all land tβ‰ˆ48.5-49.4** (the post-change funnel-alignment + window), q2/q3 at ~59. Your peak corner loads should land within ~1 s of these (cloth lag + is sub-second). A peak that lands at a different time is sampling a different storm. + + **backyard_01, the week's other four nights (fascia + the tree surprise):** + Β· h1-h3 (hint 0.35) top EVERY night's Pa/hint ranking the moment hints are read: + gentle 266 Β· southerly 946-1037 Β· wild night 2093-2505 Β· ice night 1844-1933 (vs best + honest-post β‰ˆ 94 / 340 / 810 / 739). Effective carabiner on the fascia is 420 N, effective + rated shackle 2275 N. The wild night delivers ~880 Pa at h3 β€” every backyard quad that + touches the house moves in your re-audit, exactly as SPRINT12 predicted. + Β· ⚠️ **The tree branch anchors are NOT hint 1.0.** SPRINT12's gate-2 text says "tree 1.0"; + the dressed anchors say t1/t2/tr1 = 1.0 but **t1b/t2b/tr1b = 0.88, t1c = 0.76** β€” E baked a + 1.0βˆ’0.12Β·i ladder up the branches (build_yard_assets.py:575). Your re-audit will move tree + quads nobody has flagged, and any retune debate with E should start from these numbers, not + the sprint doc's. (t1 on the southerly also reads only 15.45 m/s β€” branch anchors shadow + each other through setSheltersFromTrees, the same as in-game. Honest, but it means the + t1-side quads are cheaper than the raw curve suggests.) + + Full five-night tables are one click: `tools/storm_envelope/envelope.html` (week mode, and + `window.__envelope` is machine-readable). If our envelopes disagree anywhere, we find the + variable together BEFORE anyone touches E's numbers β€” the rule exists because both of us have + now been the drifted harness once. + +[C] 2026-07-17 β€” 🌩 **D (and A) β€” gate 3.4 is IN: the change announces itself. Judge it.** Every + `windchange` event now has an in-world tell in skyfx: a dark front wall standing low on the + horizon in the quarter the new wind comes FROM, rising from a smudge to a wall across the 12 s + before the change, clearing overhead as the swing completes β€” and a distant low rumble that + swells with it. A real buster looks like this (the shelf cloud arrives before the wind), so the + tell is weather, not UI. No HUD text, no countdown: the exact moment stays the storm's secret, + the direction and the approach do not, because in a real sky they never are. + + Why it serves the corner block's thesis: night 2 (storm_03, change at 30 s) TEACHES the wall β€” + it starts rising ~18 s in, southerly arrives, lesson filed. Night 3 looks like night 2 β€” same + peak, same rain, same pea hail β€” except the SAME wall is already standing at tβ‰ˆ6, before you've + tied off the second corner. "Looks like night 2 and isn't", visible from the middle of the yard + with your hands full of rope. + + Deterministic off (dt, t), like everything here: progress is a pure function of t; the wall's + azimuth is dirAt(change + over + 4) + Ο€, sampled at a fixed instant so gust wander can't swing + the wall; two skyfx instances agree to the bit (asserted). Pinned in c.test.js: nothing before + the lead window opens, monotonic rise to ~full at the change, gone after the fade, stands in the + SOUTHERN sky (+Z from-vector β€” the open fence side), mesh really rotated there, and the gentle + day never shows one. Selftest **322/0/0**. + + **D, the ten-second judging rig: `web/world/dev_skyfx.html`** β€” the new skyfx bench (one storm, + bare ground, scrubbable clock; `?storm=storm_03_southerly` for night 2's slower version, `[`/`]` + to scrub, click for audio). Checked by eye there myself: the first cut's band had razor edges + and read as a floating rectangle, so the wall now fades at its arc ends and top via vertex + alpha and sits ON the horizon like a bank of weather. What I want your eyes on in PLAY: (a) at + the corner block, do you LOOK SOUTH in the first ten seconds and change your rigging order β€” + without being told; (b) does it spoil β€” does knowing feel like reading the data, or like + reading the sky; (c) night 5 (ice night, change ~60 s per storm_02b) now shows a wall too β€” is + that a bonus or noise. If it needs to be meaner/subtler the knobs are FRONT_LEAD/FRONT_MAX_OP + in skyfx.js and they are one-line tunes. + + (Deliberately NOT done: a fake wind lull before the change. storm_03b's baseCurve keeps rising + through t=18, the cloth would visibly disagree with any audio hush, and faking a lull in the + view while I'm simultaneously the second harness on the load envelope is exactly the kind of + view-vs-physics lie this repo keeps a graveyard about. If design wants a REAL lull it's a + baseCurve dip β€” storm data, B's audit and my envelope both re-run, next sprint's call, not a + quiet Friday edit.) + +[C] 2026-07-17 β€” βœ… **B β€” your wiring landed (9700b40); first cross-harness readings, and one + pre-emption so nobody sounds a false alarm.** Read via origin/lane/b, nothing merged here. + + Β· **Prediction 1 CONFIRMED at the qualitative level**: your in-suite assert has a cb corner + blowing before any q corner at DEFAULT tension on the carport line, with the hint-forced-to-1 + control holding 4/4. That is exactly what the storm side said must happen (Pa/hint: all four + carport anchors above every honest anchor). The trap now fires from the ANCHOR. D's fork is + next β€” their replay, not ours. + Β· **The hints agree bit-for-bit between harnesses**: your live-dumped 0.35 fascia / + 1.0 / 0.88 / 0.76 branches are exactly what my envelope read off the dressed anchors. The + "trees are not 1.0" surprise is now double-confirmed and E should treat it as settled fact. + Β· **Do NOT read your "cb blows t~29 s" against my "peaks tβ‰ˆ48.5-49" as a disagreement β€” they + are different quantities.** Yours is the first threshold CROSSING (load first exceeds + ratingΓ—0.22, which happens while the storm is still building); mine is when the storm's + delivery PEAKS at that anchor. A corner that breaks at 29 never survives to feel the 49 s + peak. The peak-timing comparison in my prediction 3 applies to your resetPeaks peak-load + tables on lines that HOLD β€” when you post the full re-audit of both yards, that's the number + to put beside my tPeak column. If THOSE disagree, then we hunt the variable. diff --git a/tools/storm_envelope/envelope.html b/tools/storm_envelope/envelope.html new file mode 100644 index 0000000..3b62ea3 --- /dev/null +++ b/tools/storm_envelope/envelope.html @@ -0,0 +1,140 @@ + + + +storm_envelope + +

storm_envelope β€” the wind side of the failure envelope

+
loading…
+
+ + + diff --git a/tools/storm_envelope/envelope.js b/tools/storm_envelope/envelope.js new file mode 100644 index 0000000..a6424fe --- /dev/null +++ b/tools/storm_envelope/envelope.js @@ -0,0 +1,107 @@ +/** + * envelope.js β€” the STORM-side failure envelope, per anchor. [Lane C, SPRINT12] + * + * SPRINT12 gate 2.4: B measures the post-ratingHint failure envelope through the + * cloth sim (site_audit: settle, fly the storm, read peak corner loads in N). + * This is the SECOND harness on the same envelope, measured from the other side: + * no cloth, no rig, no tension β€” just what the STORM delivers at each anchor's + * position, through the same wind everyone samples (createWind + the site's + * venturi + the tree shelters, exactly as main.js wires them at site load). + * + * Two harnesses, one number β€” the repo rule. What must agree: + * Β· ORDERING: anchors ranked by wind-side pressure/hint should rank the same + * way B's peak corner loads do, once quad geometry is accounted for. An + * anchor whose load outranks its wind exposure has a variable to find. + * Β· TIMING: B's peak corner load should land near the wind-side tPeak (cloth + * lag is under a second; the venturi's alignment window is tens of seconds). + * What will NOT agree, by construction: absolute newtons. Corner load carries + * quad geometry, cloth porosity, tension and rain mass β€” all B's side. This + * harness deliberately knows none of it, which is what makes it independent. + * + * Per anchor, per storm: + * peak highest local horizontal wind speed over the storm, m/s + * (wind.speedAt β€” the anemometer number, same units as every probe + * table in THREADS) + * tPeak when it happened, s + * peakPa peak dynamic pressure, Pa: ½ρ·vΒ²Β·(1+downFracΒ²) β€” the (1+dΒ²) folds + * the downdraft back in, since cloth feels the full vector while + * speedAt deliberately reads horizontal-only + * dose ∫ vΒ² dt over the whole storm, mΒ²/s β€” exposure Γ— time, so a long + * grind and a single spike stop looking alike + * paPerHint peakPa / ratingHint β€” the wind-side "who blows first" ranking. + * B's wiring is `load > hw.rating * ratingHint`, so dividing the + * delivered pressure by the hint predicts failure ORDER for equal + * hardware and equal geometry. Prediction, not measurement β€” B's + * harness is the one that owns the geometry. + * + * Sampling matches sweep.js's flight exactly: FIXED_DT steps at t = iΒ·dt over + * the storm's duration, same seed path (createWind(def) β€” def.seed), so the two + * harnesses fly the SAME storm, not merely the same JSON. + */ + +import { createWind } from '../../web/world/js/weather.js'; +import { FIXED_DT, HARDWARE } from '../../web/world/js/contracts.js'; + +export const ENVELOPE = { + RHO: 1.225, // kg/mΒ³, sea-level air β€” the constant, named once + DT: FIXED_DT, +}; + +/** + * Measure the storm at every anchor. + * + * @param {object} o + * @param {Array} o.anchors resolved anchors: { id, type, pos:{x,y,z}, ratingHint? } + * β€” pass the DRESSED world.anchors (browser) or a + * verified dump; graybox positions measure a yard + * that does not ship (site_audit's lesson). + * @param {object} o.stormDef parsed storm JSON + * @param {Array} [o.venturi] the SITE's funnel zones (siteDef.wind.venturi) β€” + * a sweep without it flies an easier yard than ships + * @param {Array} [o.probes] extra { id, pos } points (bed centre, throat…) + * measured alongside, hint fixed at 1 + * @param {number} [o.dt] + * @returns {{ rows, downFrac, duration }} rows sorted by paPerHint, descending + */ +export function stormEnvelope({ anchors, stormDef, venturi = [], probes = [], dt = FIXED_DT }) { + const wind = createWind(stormDef); + wind.setVenturi(venturi); + // exactly main.js:447 β€” every tree anchor casts a shadow, defaults and all + wind.setSheltersFromTrees(anchors.filter((a) => a.type === 'tree')); + const downFrac = wind.core.downFrac; + const vecFold = 1 + downFrac * downFrac; // |v|Β² = hΒ²Β·(1+dΒ²) when vy = -dΒ·h + + const rows = [ + ...anchors.map((a) => ({ + id: a.id, type: a.type, probe: false, + hint: Number.isFinite(a.ratingHint) ? a.ratingHint : 1, + pos: { x: a.pos.x, z: a.pos.z }, + peak: 0, tPeak: 0, dose: 0, + })), + ...probes.map((p) => ({ + id: p.id, type: 'probe', probe: true, hint: 1, + pos: { x: p.pos.x, z: p.pos.z }, + peak: 0, tPeak: 0, dose: 0, + })), + ]; + + const duration = stormDef.duration ?? 90; + const n = Math.round(duration / dt); + for (let i = 0; i <= n; i++) { + const t = i * dt; + for (const r of rows) { + const s = wind.speedAt(r.pos, t); + if (s > r.peak) { r.peak = s; r.tPeak = t; } + r.dose += s * s * dt; + } + } + + for (const r of rows) { + r.peakPa = 0.5 * ENVELOPE.RHO * r.peak * r.peak * vecFold; + r.paPerHint = r.peakPa / (r.hint > 0 ? r.hint : 1); + // what B's wiring makes each shop tier hold HERE: rating Γ— hint, in N + r.effN = HARDWARE.map((h) => h.rating * r.hint); + } + rows.sort((a, b) => b.paPerHint - a.paPerHint); + return { rows, downFrac, duration }; +} diff --git a/tools/storm_envelope/envelope.selftest.js b/tools/storm_envelope/envelope.selftest.js new file mode 100644 index 0000000..28922e4 --- /dev/null +++ b/tools/storm_envelope/envelope.selftest.js @@ -0,0 +1,100 @@ +/** + * envelope.selftest.js β€” the second harness proves it measures. [Lane C, SPRINT12] + * + * Same shape as sweep.selftest.js: [name, fn] pairs, run under Lane A's + * selftest.html via js/tests/c.test.js. sweep.selftest exists because site_audit + * flew the corner block with the funnel switched off for two sprints; this file + * exists so the harness built to CHECK that tool can't quietly do the same. + * Every assert here is written to fail if a wiring line is deleted: + * Β· drop setVenturi β†’ the throat anchor's funnelled/unfunnelled peaks + * collapse to equal and the strict < goes red + * Β· drop setSheltersFromTrees β†’ the sheltered anchor stops reading lower + * Β· reseed per call β†’ the determinism byte-compare goes red + * Β· flip paPerHint to Γ— β†’ the weak-anchor-ranks-first assert goes red + */ + +import { stormEnvelope } from './envelope.js'; + +const TESTS = []; +const test = (name, fn) => TESTS.push([name, fn]); +const assert = (cond, msg) => { if (!cond) throw new Error(msg); }; + +/** + * A synthetic storm, flat on purpose: constant 10 m/s, dir 0 (blowing +X), no + * gusts, no wander, no spatial noise β€” so every local effect is the ONLY thing + * moving a number, and the asserts can be strict instead of statistical. + */ +const FLAT = { + name: 'flat_test', seed: 7, duration: 20, + baseCurve: [[0, 10], [20, 10]], + dirCurve: [[0, 0], [20, 0]], + dirWander: { amp: 0, rate: 0 }, + spatial: { amp: 0 }, + gusts: { firstAt: 999, minGap: 6, maxGap: 12, powBase: 0, powRand: 0, powRamp: 0, downdraftOfTotal: 0.35 }, +}; + +const A = (id, x, z, o = {}) => ({ id, type: o.type ?? 'post', pos: { x, y: 4, z }, ...o }); + +test('envelope: deterministic β€” same def, same anchors, same numbers to the bit', () => { + const anchors = [A('a', 0, 0), A('b', 5, 3)]; + const def = JSON.parse(JSON.stringify(FLAT)); + const r1 = stormEnvelope({ anchors, stormDef: def }); + const r2 = stormEnvelope({ anchors, stormDef: JSON.parse(JSON.stringify(FLAT)) }); + for (let i = 0; i < r1.rows.length; i++) { + assert(r1.rows[i].peak === r2.rows[i].peak && r1.rows[i].dose === r2.rows[i].dose + && r1.rows[i].tPeak === r2.rows[i].tPeak, + `run 2 disagrees with run 1 at ${r1.rows[i].id}: ${r1.rows[i].peak} vs ${r2.rows[i].peak}`); + } +}); + +test('envelope: the venturi is wired β€” throat anchor reads the funnel, a far one does not', () => { + const anchors = [A('throat', 0, 0), A('far', 100, 0)]; + const venturi = [{ x: 0, z: 0, axis: 0, gain: 1.5, radius: 5, sharp: 3 }]; + const off = stormEnvelope({ anchors, stormDef: FLAT }); + const on = stormEnvelope({ anchors, stormDef: FLAT, venturi }); + const g = (res, id) => res.rows.find((r) => r.id === id); + // strict: delete envelope.js's setVenturi call and this collapses to equal + assert(g(on, 'throat').peak > g(off, 'throat').peak, + `funnel did not raise the throat: ${g(on, 'throat').peak} vs ${g(off, 'throat').peak} β€” setVenturi is not wired`); + assert(Math.abs(g(on, 'throat').peak - 15) < 0.01, + `gain 1.5 on 10 m/s dead-aligned should read 15 at the throat, got ${g(on, 'throat').peak}`); + assert(g(on, 'far').peak === g(off, 'far').peak, + 'the funnel reached an anchor 100 m away β€” radial falloff is broken'); +}); + +test('envelope: tree shelters are wired β€” a downwind anchor reads the shadow', () => { + // tree at origin, wind blowing +X: 'lee' sits 4 m downwind inside the shadow + const withTree = [A('tree', 0, 0, { type: 'tree' }), A('lee', 4, 0)]; + const without = [A('lee', 4, 0)]; + const sheltered = stormEnvelope({ anchors: withTree, stormDef: FLAT }).rows.find((r) => r.id === 'lee'); + const open = stormEnvelope({ anchors: without, stormDef: FLAT }).rows.find((r) => r.id === 'lee'); + // strict: delete envelope.js's setSheltersFromTrees call and these read equal + assert(sheltered.peak < open.peak, + `the tree cast no shadow: lee reads ${sheltered.peak} with it, ${open.peak} without β€” setSheltersFromTrees is not wired`); +}); + +test('envelope: paPerHint ranks the weak steel first at equal wind', () => { + // identical position, identical wind β€” only the hint differs (E's beam 0.22 + // vs a clean post at 1.0). The wind-side prediction must rank the beam first. + const anchors = [A('beam', 2, 2, { ratingHint: 0.22 }), A('clean', 2, 2, { ratingHint: 1 })]; + const { rows } = stormEnvelope({ anchors, stormDef: FLAT }); + assert(rows[0].id === 'beam', + `equal wind, hint 0.22 vs 1.0 β€” 'beam' must rank first, got ${rows.map((r) => r.id).join(',')}`); + assert(Math.abs(rows[0].paPerHint - rows[1].paPerHint / 0.22) < 1e-9, + 'paPerHint is not peakPa/hint β€” the ranking formula drifted'); + assert(Math.abs(rows[0].effN[0] - 1200 * 0.22) < 1e-9, + `effN must be ratingΓ—hint: carabiner on the beam should read ${1200 * 0.22} N, got ${rows[0].effN[0]}`); +}); + +test('envelope: peaks land inside the storm and doses are finite and positive', () => { + const anchors = [A('a', -3, 1), A('b', 6, -2)]; + const { rows, duration } = stormEnvelope({ anchors, stormDef: FLAT }); + for (const r of rows) { + assert(r.tPeak >= 0 && r.tPeak <= duration, `${r.id} peaked at t=${r.tPeak}, outside 0..${duration}`); + assert(Number.isFinite(r.dose) && r.dose > 0, `${r.id} dose is ${r.dose}`); + // flat 10 m/s for 20 s β†’ dose β‰ˆ 100Β·20; a wildly-off dose means the loop drifted + assert(Math.abs(r.dose - 2000) < 20, `${r.id} dose ${r.dose.toFixed(0)} β€” flat 10 m/s Γ— 20 s should be ~2000`); + } +}); + +export const ENVELOPE_TESTS = TESTS; diff --git a/web/world/dev_skyfx.html b/web/world/dev_skyfx.html new file mode 100644 index 0000000..6e71b2b --- /dev/null +++ b/web/world/dev_skyfx.html @@ -0,0 +1,100 @@ + + + +dev_skyfx + +
loading…
+ + diff --git a/web/world/js/skyfx.js b/web/world/js/skyfx.js index 10e74c9..2b0db1e 100644 --- a/web/world/js/skyfx.js +++ b/web/world/js/skyfx.js @@ -13,7 +13,7 @@ import * as THREE from '../vendor/three.module.js'; import { rng } from './contracts.js'; -import { valueNoise2 , hailBlockFor } from './weather.core.js'; +import { valueNoise2 , hailBlockFor, smoothstep } from './weather.core.js'; const lerp = (a, b, k) => a + (b - a) * k; const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v); @@ -348,6 +348,7 @@ function createAudio(seed = 1) { let rainGain, rainFilter; let gustGain, gustFilter; let hailGain, hailFilter, drumGain, drumFilter; + let frontGain, frontFilter; let noiseBuf = null; let creakNext = 0, flogNext = 0; let started = false; @@ -444,6 +445,14 @@ function createAudio(seed = 1) { drumGain.connect(master); loop(noiseBuf, drumGain, drumFilter); + // the change front's distant rumble: very low, very slow β€” a storm you + // can hear over the fence but not yet feel (SPRINT12 gate 3.4) + frontGain = ctx.createGain(); frontGain.gain.value = 0; + frontFilter = ctx.createBiquadFilter(); + frontFilter.type = 'lowpass'; frontFilter.frequency.value = 85; + frontGain.connect(master); + loop(noiseBuf, frontGain, frontFilter); + started = true; }, @@ -496,6 +505,13 @@ function createAudio(seed = 1) { drumFilter.frequency.setTargetAtTime(110 + (2 - size) * 45, now, 0.2); }, + /** @param {number} level 0..1 change-front progress β€” the approaching rumble. */ + setFront(level) { + if (!started) return; + // slow time-constant on purpose: a front swells, it doesn't tick + frontGain.gain.setTargetAtTime(level * 0.2, ctx.currentTime, 0.5); + }, + /** Telegraph cue: you hear it coming before you feel it. */ whoosh(power, eta) { if (!started) return; @@ -604,6 +620,66 @@ export function createSkyFx(o = {}) { dome.renderOrder = -1; if (scene) scene.add(dome); + // ------------------------------------------------------ the change front + // SPRINT12 gate 3.4 β€” "the change announces itself." Every windchange event + // gets an IN-WORLD tell: a dark front wall on the horizon, standing in the + // quarter the new wind will come FROM, rising from a smudge to a wall across + // the FRONT_LEAD seconds before the change and clearing overhead after it. + // A real southerly buster looks exactly like this β€” the shelf cloud arrives + // before the wind does β€” so the tell is weather, not UI. + // + // Why this telegraphs without spoiling: it's a continuous growth with no + // discrete pop, so a player watching the YARD learns "that wall means the + // swing" on night 2 (storm_03, change at 30 s, wall from ~18 s) β€” and on + // night 3 the SAME wall is already standing at tβ‰ˆ6, which is the corner + // block's whole thesis ("looks like night 2 and isn't") made visible. The + // exact moment stays the storm's secret; the direction and the approach do + // not, because in the real sky they never are. + // + // Deterministic off (dt, t) like everything here: progress is a pure + // function of t, and the wall's azimuth comes from dirAt() sampled at a + // fixed post-change instant β€” def + seed in, same wall out, every run. + const FRONT_LEAD = 12; // s before the change the wall starts rising + const FRONT_FADE = 8; // s after the swing completes for it to clear + const FRONT_ARC = 2.2; // radians of horizon the wall spans (~126Β°) + const FRONT_MAX_OP = 0.85; + const frontEvents = (def.events || []) + .filter((e) => e.type === 'windchange' && Number.isFinite(e.t)) + .map((e) => ({ t0: e.t, over: e.over ?? 6, az: null })); + // Band from ~34Β° above the horizon down to it, centred (in local space) on + // azimuth Ο€: SphereGeometry's (x,z) = (-cosΟ†, sinΟ†)Β·sinΞΈ, so Ο†=0 sits at + // atan2(z,x) = Ο€. rotation.y = Ο€ βˆ’ A then carries the centre to azimuth A. + const frontGeo = new THREE.SphereGeometry(170, 24, 8, -FRONT_ARC / 2, FRONT_ARC, Math.PI * 0.30, Math.PI * 0.20); + { + // Soft edges via vertex alpha, or the band reads as a floating rectangle + // (checked by eye on dev_skyfx.html β€” the hard phi/theta cut was exactly + // that). Full at the horizon and the arc's centre; fading to nothing at + // the arc ends and across the top, so it sits ON the horizon like a bank + // of weather instead of hanging in the sky like a screen. + // Sphere uv: x runs 0..1 across the arc, y is 1 at the band top, 0 at the + // horizon edge. + const uv = frontGeo.attributes.uv; + const rgba = new Float32Array(uv.count * 4); + for (let i = 0; i < uv.count; i++) { + const across = Math.pow(Math.sin(Math.PI * uv.getX(i)), 0.75); + const up = 1 - smoothstep(0.45, 1, uv.getY(i)); + rgba[i * 4] = 1; rgba[i * 4 + 1] = 1; rgba[i * 4 + 2] = 1; + rgba[i * 4 + 3] = across * up; + } + frontGeo.setAttribute('color', new THREE.BufferAttribute(rgba, 4)); + } + const frontMesh = new THREE.Mesh( + frontGeo, + new THREE.MeshBasicMaterial({ + color: 0x0c1016, side: THREE.BackSide, transparent: true, vertexColors: true, + opacity: 0, depthWrite: false, fog: false, + }), + ); + frontMesh.renderOrder = -1; // with the dome; nearer radius wins the overlay + frontMesh.visible = false; + if (scene) scene.add(frontMesh); + let frontLevel = 0, frontRise = 0, frontAz = null; + // Remember what world.js handed us, so dispose() puts it back exactly. // Fog is captured BY VALUE, not by reference: step() mutates that very object // in place, so `scene.fog = original.fog` restores the object we just spent a @@ -636,10 +712,15 @@ export function createSkyFx(o = {}) { const w = new THREE.Vector3(); const fx = { - rain, audio, dome, shadow, hailShadow, + rain, audio, dome, shadow, hailShadow, front: frontMesh, get flash() { return flash; }, /** 0..1 hail intensity right now β€” for the HUD ("HAIL" banner) and asserts. */ get hailAmount() { return hailAmt; }, + /** 0..1 β€” how far risen the change-front wall is (gate 3.4). Pure in t. */ + get changeFront() { return frontLevel; }, + /** Azimuth (radians, XZ) the front stands in β€” the quarter the new wind + * comes FROM β€” or null while no front is up. For D's judging and asserts. */ + get changeFrontAz() { return frontAz; }, /** * 0..1 of a ground rect the sail is keeping dry, right now. @@ -809,6 +890,23 @@ export function createSkyFx(o = {}) { hailShadow.update(world.sail, hailDir.x / hl, hailDir.y / hl, hailDir.z / hl); } + // --- the change front: progress is pure in t; the view applies it below. + // Computed above the render gate so a headless HUD (or a test) can read + // changeFront the same way it reads hailAmount. + frontLevel = 0; frontRise = 0; frontAz = null; + for (const ev of frontEvents) { + const rise = smoothstep(ev.t0 - FRONT_LEAD, ev.t0, t); + const lvl = rise * (1 - smoothstep(ev.t0 + ev.over, ev.t0 + ev.over + FRONT_FADE, t)); + if (lvl > frontLevel) { + // the settled post-change heading, sampled at a FIXED instant so the + // wall doesn't wander with the gusts; +Ο€ = the quarter it comes FROM + if (ev.az == null && typeof wind.dirAt === 'function') { + ev.az = wind.dirAt(ev.t0 + ev.over + 4) + Math.PI; + } + frontLevel = lvl; frontRise = rise; frontAz = ev.az; + } + } + // Past here is the VIEW and the NOISE β€” drops to place, a dome to tint, a // gale to hear. All of it needs somewhere to stand. A headless harness has // the numbers it came for and can stop here. @@ -848,6 +946,19 @@ export function createSkyFx(o = {}) { domeTex.offset.x = (domeTex.offset.x + scroll * dt * (0.4 + speed * 0.05)) % 1; domeTex.offset.y = (domeTex.offset.y + scroll * dt * 0.12) % 1; + // --- the front wall (progress computed above the render gate) --- + // Grows upward as it rises (scale.y keys the RISE, so it keeps its full + // height while fading through the overhead pass), darkens as it comes. + // Deliberately a silhouette: no flash tint β€” lightning brightens the sky + // BEHIND it, which is what makes a shelf cloud read as a wall. + frontMesh.visible = frontLevel > 0.002; + if (frontMesh.visible) { + frontMesh.position.copy(camPos); + if (frontAz != null) frontMesh.rotation.y = Math.PI - frontAz; + frontMesh.scale.y = 0.3 + 0.7 * frontRise; + frontMesh.material.opacity = frontLevel * FRONT_MAX_OP; + } + // --- rain + hail drops (the grids they read were built above) --- rain.step(dt, camPos, w, intensity, shadow); hail.step(dt, camPos, hailDir, hailAmt, stone, hailShadow); @@ -858,6 +969,9 @@ export function createSkyFx(o = {}) { // --- audio --- audio.setLevels(speed, intensity); audio.setHail(hailAmt, onCloth, stone); + // the front carries its own distant rumble β€” you HEAR the change coming + // the way you see it, and both are the same pure function of t + audio.setFront(frontLevel); const tg = wind.gustTelegraph(t); if (tg && tg !== lastTelegraph) { // fires once per gust, right as the telegraph opens @@ -886,6 +1000,7 @@ export function createSkyFx(o = {}) { scene.remove(rain.mesh); scene.remove(hail.mesh); scene.remove(dome); + scene.remove(frontMesh); scene.background = original.background; if (ownsFog) { scene.fog = null; // we brought it; we take it @@ -902,6 +1017,8 @@ export function createSkyFx(o = {}) { hail.dispose(); dome.geometry.dispose(); dome.material.dispose(); + frontMesh.geometry.dispose(); + frontMesh.material.dispose(); domeTex.dispose(); audio.dispose(); }, diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index 03e7140..ee6a125 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -21,6 +21,7 @@ import { createDebris } from '../debris.js'; 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'; // 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 @@ -41,6 +42,11 @@ export default async function run(t) { const { cases } = weatherCases(storms); for (const c of cases) t.test(c.name, c.fn); + // --- SPRINT12 gate 2.4: the storm-side envelope harness audits itself --- + // Same pattern as sweep.selftest in b.test.js: the tool that second-guesses + // B's audit must not be the thing that drifts. See envelope.selftest.js. + for (const [name, fn] of ENVELOPE_TESTS) t.test(name, fn); + // --- the bits that need the THREE adapter, not just the core --- t.test('weather.js satisfies the wind contract', () => { @@ -271,6 +277,92 @@ export default async function run(t) { assert(gentle.flashes === 0, `the gentle storm flashed ${gentle.flashes} times β€” it has no lightning at all`); }); + // --- SPRINT12 gate 3.4: the change announces itself --- + // The corner block's thesis is "looks like night 2 and isn't" β€” storm_03b's + // change lands at 18 s, not 30. The tell is a dark front wall standing in the + // quarter the new wind comes FROM, rising across the 12 s before the change + // and clearing after the swing. These asserts pin: the window (nothing before + // the lead opens, gone after the fade), the rise (monotonic, reaches ~full), + // the DIRECTION (the wall stands in the southern sky β€” +Z, the open fence + // side β€” and the mesh really is rotated there), and that a changeless storm + // never raises it. D judges whether it READS; this pins that it exists, + // where it stands, and when. + t.test('gate 3.4: the front wall rises before the buster, stands in the south, clears after', () => { + const scene = new THREE.Scene(); + const wind = createWind(storms.storm_03b_earlybuster); + const sky = createSkyFx({ scene, camera: new THREE.PerspectiveCamera(), wind }); + const ev = storms.storm_03b_earlybuster.events.find((e) => e.type === 'windchange'); + const t0 = ev.t, over = ev.over ?? 6; // 18, 6 + + let prev = 0, peak = 0, azAtChange = null, rotAtChange = null, opAtChange = 0, visAtChange = false; + fixedLoop(40, FIXED_DT, (dt, time) => { + sky.step(dt, time, {}); + const f = sky.changeFront; + assert(Number.isFinite(f) && f >= 0 && f <= 1, `front out of range at t=${time.toFixed(2)}: ${f}`); + if (time < t0 - 12.01) assert(f === 0, `front up at t=${time.toFixed(2)} β€” before its lead window opens`); + if (time > t0 - 12 && time <= t0) { + assert(f >= prev - 1e-9, `front FELL while rising: ${prev.toFixed(3)} β†’ ${f.toFixed(3)} at t=${time.toFixed(2)}`); + prev = f; + if (f > peak) peak = f; + } + if (Math.abs(time - t0) < FIXED_DT) { + azAtChange = sky.changeFrontAz; + rotAtChange = sky.front.rotation.y; + opAtChange = sky.front.material.opacity; + visAtChange = sky.front.visible; + } + if (time > t0 + over + 8.1) assert(f === 0, `front still up at t=${time.toFixed(2)} β€” the swing has long cleared`); + }); + + assert(peak > 0.98, `front only reached ${peak.toFixed(3)} by the change β€” it should stand near full`); + assert(visAtChange && opAtChange > 0.7, `wall not visible at the change (visible=${visAtChange}, opacity=${opAtChange})`); + + // direction: the wall stands where the new wind comes FROM. dirAt is the + // heading the wind blows TOWARD, so from = settled post-change dir + Ο€. + const expected = wind.dirAt(t0 + over + 4) + Math.PI; + assert(Math.abs(azAtChange - expected) < 1e-9, + `front azimuth ${azAtChange} is not the settled post-change upwind quarter ${expected}`); + // and that quarter is the SOUTH: the buster blows toward -Z (the house), + // so it comes from +Z, the open fence side. sin(az) is the from-vector's z. + assert(Math.sin(azAtChange) > 0.3, + `the "southerly" front stands at azimuth ${azAtChange.toFixed(2)} β€” from-vector z=${Math.sin(azAtChange).toFixed(2)}, not the southern sky`); + // the mesh is really rotated there (local band centre sits at azimuth Ο€, + // so rotation.y = Ο€ βˆ’ az carries it to az; see skyfx geometry note) + assert(Math.abs(rotAtChange - (Math.PI - azAtChange)) < 1e-9, + `wall rotation ${rotAtChange} does not place the band at azimuth ${azAtChange}`); + sky.dispose(); + }); + + t.test('gate 3.4: night 2 teaches the same tell, a changeless storm never shows it, and it is pure in t', () => { + // storm_03 (night 2): same wall, later β€” up but not full at t=20 (change 30) + const mk = (name) => createSkyFx({ + scene: new THREE.Scene(), camera: new THREE.PerspectiveCamera(), + wind: createWind(storms[name]), + }); + const a = mk('storm_03_southerly'), b = mk('storm_03_southerly'); + let mid = 0; + fixedLoop(21, FIXED_DT, (dt, time) => { + a.step(dt, time, {}); b.step(dt, time, {}); + // determinism: two instances at the same t agree to the bit β€” no hidden + // clock, no per-instance random draw in the tell + assert(a.changeFront === b.changeFront, + `two skyfx disagree on the front at t=${time.toFixed(2)}: ${a.changeFront} vs ${b.changeFront}`); + mid = a.changeFront; + }); + assert(mid > 0.05 && mid < 0.95, + `night 2's wall should be RISING at t=21 (change at 30), reads ${mid.toFixed(3)}`); + a.dispose(); b.dispose(); + + // the gentle day has no windchange: no wall, ever + const calm = mk('storm_01_gentle'); + fixedLoop(storms.storm_01_gentle.duration, FIXED_DT, (dt, time) => { + calm.step(dt, time, {}); + assert(calm.changeFront === 0 && !calm.front.visible, + `a changeless storm raised the front at t=${time.toFixed(2)}`); + }); + calm.dispose(); + }); + // --- SPRINT5 decision 13: hail makes the garden score respond to the rig --- // The whole reason hail exists: a perfect rig scored 54% vs 48% for no rig, // because honest rain walks under a sail. Steep hail doesn't β€” a sail over the