From 84f647a90c2dc6c3fe209f188f9e9d9f40b13d9e Mon Sep 17 00:00:00 2001 From: m3ultra Date: Thu, 16 Jul 2026 21:24:56 +1000 Subject: [PATCH 1/5] Add wind field, storm timelines and weather selftest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the contracts.js wind surface (PLAN3D §4): sample(pos,t) and gustTelegraph(t), plus storm defs as data. The prototype scheduled gusts by integrating (wind.gustT += dt). We can't: sample(pos,t) is called by sail/player/debris/rain at arbitrary t, so gusts are precomputed into a timeline from a seeded PRNG at load and read from t. Envelope shape is a faithful port — telegraph 1.5s / ramp 0.8s / hold 1.7s / fade 1.0s. Maths lives in weather.core.js with zero imports (no THREE, no DOM, no Date.now), so the determinism rule is structural and the suite runs in node without waiting on Lane A's M0. storm_01_gentle peaks at 11 m/s; storm_02_wildnight sustains 20 m/s and gusts to 32 (BOM 'destructive'), with the southerly change landing just before the worst of it — the corners that were slack all storm are the ones that cop it. 15 asserts green: telegraph lead >=1.2s, wind continuity in time and space, storm JSON validator (incl. 14 deliberately-broken defs it must reject), determinism, sample-order independence, tree wind shadow. Co-Authored-By: Claude Opus 4.8 --- web/world/data/storms/storm_01_gentle.json | 29 ++ web/world/data/storms/storm_02_wildnight.json | 39 ++ web/world/js/tests/run-node.mjs | 32 ++ web/world/js/tests/weather.selftest.js | 282 +++++++++++++ web/world/js/weather.core.js | 383 ++++++++++++++++++ web/world/js/weather.js | 98 +++++ 6 files changed, 863 insertions(+) create mode 100644 web/world/data/storms/storm_01_gentle.json create mode 100644 web/world/data/storms/storm_02_wildnight.json create mode 100644 web/world/js/tests/run-node.mjs create mode 100644 web/world/js/tests/weather.selftest.js create mode 100644 web/world/js/weather.core.js create mode 100644 web/world/js/weather.js diff --git a/web/world/data/storms/storm_01_gentle.json b/web/world/data/storms/storm_01_gentle.json new file mode 100644 index 0000000..d76f06a --- /dev/null +++ b/web/world/data/storms/storm_01_gentle.json @@ -0,0 +1,29 @@ +{ + "name": "Sea Breeze", + "blurb": "Fresh afternoon breeze, chance of a light shower. Good day to test a rig.", + "rating": 1, + "seed": 1017, + "duration": 90, + + "baseCurve": [[0, 3.0], [20, 5.0], [50, 6.5], [75, 6.0], [90, 5.5]], + + "gusts": { + "firstAt": 4, + "minGap": 6, + "maxGap": 14, + "powBase": 2, + "powRand": 3, + "powRamp": 2 + }, + + "dirCurve": [[0, 0.9], [45, 1.0], [90, 1.15]], + "dirWander": { "amp": 0.35, "rate": 0.09 }, + + "spatial": { "amp": 0.15, "scale": 12, "advect": 0.5 }, + + "events": [], + + "rain": { "curve": [[0, 0], [30, 0.15], [55, 0.2], [80, 0.08], [90, 0]] }, + + "sky": { "darkness": 0.15, "cloudScroll": 0.02 } +} diff --git a/web/world/data/storms/storm_02_wildnight.json b/web/world/data/storms/storm_02_wildnight.json new file mode 100644 index 0000000..78982be --- /dev/null +++ b/web/world/data/storms/storm_02_wildnight.json @@ -0,0 +1,39 @@ +{ + "name": "Wild Night", + "blurb": "Front crossing after dark. Southerly change around the hour mark, damaging gusts. Rig for the swing, not the lull.", + "rating": 4, + "seed": 20260716, + "duration": 90, + + "_comment": "Sustained builds 7 -> 20 m/s (25 -> 72 km/h); worst gusts land near 33 m/s (~120 km/h), which is BOM 'destructive' and is what shreds a flat drum-tight cheap rig. Peak arrives just AFTER the southerly change, so the corners that were slack all storm are the ones that cop it.", + + "baseCurve": [[0, 7.0], [15, 11.0], [40, 17.0], [60, 20.0], [78, 19.0], [90, 16.0]], + + "gusts": { + "firstAt": 3, + "minGap": 5.5, + "maxGap": 11, + "powBase": 3, + "powRand": 5, + "powRamp": 7 + }, + + "dirCurve": [[0, 0.85], [50, 0.95], [55, 1.2], [59, 2.45], [70, 2.6], [90, 2.5]], + "dirWander": { "amp": 0.25, "rate": 0.13 }, + + "spatial": { "amp": 0.2, "scale": 11, "advect": 0.5 }, + + "events": [ + { "t": 38, "type": "debris", "model": "BlueCrate_v2", "lateral": -3.5, "mass": 9, "text": "a crate comes through the fence line" }, + { "t": 52, "type": "lightning", "power": 0.7 }, + { "t": 55, "type": "windchange", "telegraph": 6, "over": 6, "text": "the wind swings around" }, + { "t": 64, "type": "lightning", "power": 1.0 }, + { "t": 66, "type": "debris", "model": "BlackTub_v2", "lateral": 2.0, "mass": 5, "text": "someone's tub is airborne" }, + { "t": 74, "type": "debris", "model": "WoodenBin_v2", "lateral": -1.0, "mass": 14, "text": "the neighbour's bin lets go" }, + { "t": 79, "type": "lightning", "power": 0.5 } + ], + + "rain": { "curve": [[0, 0], [10, 0.25], [35, 0.6], [55, 0.85], [70, 1.0], [90, 0.7]] }, + + "sky": { "darkness": 0.8, "cloudScroll": 0.09 } +} diff --git a/web/world/js/tests/run-node.mjs b/web/world/js/tests/run-node.mjs new file mode 100644 index 0000000..9896d7b --- /dev/null +++ b/web/world/js/tests/run-node.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node +'use strict'; +// SHADES — Lane C — headless runner for the weather suite. +// +// node web/world/js/tests/run-node.mjs +// +// The same suite Lane A's selftest.html runs, but with no browser and no server, +// so tuning a storm curve is a one-second loop. Exits non-zero on failure. + +import { readFileSync, readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { runWeatherSuite } from './weather.selftest.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const stormDir = join(here, '..', '..', 'data', 'storms'); + +const storms = {}; +for (const f of readdirSync(stormDir).filter((f) => f.endsWith('.json')).sort()) { + storms[f.replace(/\.json$/, '')] = JSON.parse(readFileSync(join(stormDir, f), 'utf8')); +} + +const r = runWeatherSuite(storms); + +for (const res of r.results) { + console.log(res.ok ? ` ok ${res.name}` : ` FAIL ${res.name}\n ${res.err}`); +} +console.log('\n metrics (Lane B: tune cloth rho against these):'); +for (const [k, v] of Object.entries(r.metrics)) console.log(` ${k.padEnd(32)} ${v}`); +console.log(`\n ${r.pass} passed, ${r.fail} failed\n`); + +process.exit(r.fail ? 1 : 0); diff --git a/web/world/js/tests/weather.selftest.js b/web/world/js/tests/weather.selftest.js new file mode 100644 index 0000000..df31001 --- /dev/null +++ b/web/world/js/tests/weather.selftest.js @@ -0,0 +1,282 @@ +'use strict'; +// SHADES — Lane C — weather selftest. +// +// Imports the pure core only (no THREE, no DOM), so this runs in node OR from +// Lane A's selftest.html. Fixed-dt loops, never rAF (rAF pauses in hidden tabs +// — PLAN3D §0). +// +// node web/world/js/tests/run-node.mjs +// +// Lane A: `import { runWeatherSuite } from './js/tests/weather.selftest.js'` and +// call it with the fetched storm defs. + +import { + createWindField, validateStorm, gustEnvelope, GUST, +} from '../weather.core.js'; + +const DT = 1 / 60; + +// yard is ~30×20 m, origin at centre — probe the corners and the middle +const PROBES = [ + { x: 0, z: 0 }, { x: -14, z: -9 }, { x: 14, z: -9 }, + { x: -14, z: 9 }, { x: 14, z: 9 }, { x: 5, z: -3 }, +]; + +const TREE_SHELTERS = [ + { x: -9, z: 4, radius: 3, strength: 0.45, length: 14 }, + { x: 8, z: -5, radius: 2.5, strength: 0.4, length: 12 }, +]; + +export function runWeatherSuite(storms) { + const results = []; + const metrics = {}; + const test = (name, fn) => { + try { + fn(); + results.push({ name, ok: true }); + } catch (e) { + results.push({ name, ok: false, err: e.message }); + } + }; + const assert = (cond, msg) => { if (!cond) throw new Error(msg); }; + + const defs = Object.entries(storms); + + // ---- 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 + // collapses, the storm stops being fair and starts being a dice roll. + for (const [name, def] of defs) { + test(`${name}: gust telegraph lead >= 1.2s`, () => { + const field = createWindField(def); + assert(field.gusts.length > 0, 'storm scheduled no gusts at all'); + const step = 1 / 240; + let worst = Infinity; + for (const g of field.gusts) { + // when does THIS gust first actually push? + let rise = null; + for (let t = g.t0; t < g.endAt; t += step) { + if (gustEnvelope(t - g.t0, g.pow) > 1e-6) { rise = t; break; } + } + assert(rise !== null, `gust at t=${g.t0.toFixed(2)} never rises`); + + // when did the HUD first get told about it? + let announced = null; + for (let t = Math.max(0, g.t0 - 2); t < rise; t += step) { + const tg = field.telegraph(t); + if (tg && Math.abs(tg.eta - (g.rampAt - t)) < 1e-6) { announced = t; break; } + } + assert(announced !== null, `gust at t=${g.t0.toFixed(2)} was never telegraphed`); + + const lead = rise - announced; + worst = Math.min(worst, lead); + assert(lead >= 1.2, + `gust at t=${g.t0.toFixed(2)}: telegraph lead ${lead.toFixed(3)}s < 1.2s`); + + // and the telegraph window must be silent — no force before the ramp + for (let t = g.t0; t < g.rampAt; t += step) { + assert(gustEnvelope(t - g.t0, g.pow) === 0, + `gust at t=${g.t0.toFixed(2)} pushes during its telegraph window`); + } + } + metrics[`${name}.worstTelegraphLead`] = +worst.toFixed(3); + }); + } + + // ---- 2. wind.sample continuity ---- + // Sail load goes with wind², so a discontinuity here is an impulse that can + // snap a corner out of nowhere. Both axes matter: time (a standing player) + // and space (a running one). + const MAX_JUMP = 1.5; // m/s per 1/60 frame + for (const [name, def] of defs) { + test(`${name}: wind continuity in time (< ${MAX_JUMP} m/s per frame)`, () => { + const field = createWindField(def).setShelters(TREE_SHELTERS); + const a = { x: 0, y: 0, z: 0 }, b = { x: 0, y: 0, z: 0 }; + let worst = 0, worstT = 0, worstP = null; + for (const p of PROBES) { + field.vecAt(p.x, p.z, 0, a); + for (let t = DT; t <= field.duration; t += DT) { + field.vecAt(p.x, p.z, t, b); + const d = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z); + if (d > worst) { worst = d; worstT = t; worstP = p; } + a.x = b.x; a.y = b.y; a.z = b.z; + } + } + metrics[`${name}.maxTemporalJump`] = +worst.toFixed(4); + assert(worst < MAX_JUMP, + `jump ${worst.toFixed(3)} m/s at t=${worstT.toFixed(2)} probe=(${worstP.x},${worstP.z})`); + }); + + test(`${name}: wind continuity in space (< ${MAX_JUMP} m/s per 0.1 m)`, () => { + const field = createWindField(def).setShelters(TREE_SHELTERS); + const a = { x: 0, y: 0, z: 0 }, b = { x: 0, y: 0, z: 0 }; + let worst = 0, worstAt = null; + // sweep the yard at the storm's angriest moments, straight through both trees + for (const t of [10, 30, 57, 64, 80]) { + for (let z = -10; z <= 10; z += 1) { + field.vecAt(-15, z, t, a); + for (let x = -15 + 0.1; x <= 15; x += 0.1) { + field.vecAt(x, z, t, b); + const d = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z); + if (d > worst) { worst = d; worstAt = { x: +x.toFixed(1), z, t }; } + a.x = b.x; a.y = b.y; a.z = b.z; + } + } + } + metrics[`${name}.maxSpatialJump`] = +worst.toFixed(4); + assert(worst < MAX_JUMP, + `jump ${worst.toFixed(3)} m/s at ${JSON.stringify(worstAt)}`); + }); + } + + // ---- 3. storm JSON validator ---- + for (const [name, def] of defs) { + test(`${name}: validates`, () => { + const { ok, errors } = validateStorm(def, name); + assert(ok, errors.join('; ')); + }); + } + + // A validator that only ever says yes isn't a validator. + test('validator rejects broken storms', () => { + const base = () => JSON.parse(JSON.stringify(storms.storm_02_wildnight)); + const cases = [ + ['duration missing', (d) => { delete d.duration; }], + ['duration negative', (d) => { d.duration = -5; }], + ['baseCurve absent', (d) => { delete d.baseCurve; }], + ['baseCurve non-monotonic t', (d) => { d.baseCurve = [[0, 5], [50, 9], [20, 7], [90, 6]]; }], + ['baseCurve negative speed', (d) => { d.baseCurve = [[0, 5], [90, -2]]; }], + ['baseCurve ends before duration', (d) => { d.baseCurve = [[0, 5], [40, 9]]; }], + ['dirCurve absent', (d) => { delete d.dirCurve; }], + ['gusts absent', (d) => { delete d.gusts; }], + ['gusts.minGap zero', (d) => { d.gusts.minGap = 0; }], + ['gusts.maxGap < minGap', (d) => { d.gusts.minGap = 9; d.gusts.maxGap = 4; }], + ['gusts overlap (minGap < gust length)', (d) => { d.gusts.minGap = 2; }], + ['debris event with no model', (d) => { d.events = [{ t: 10, type: 'debris' }]; }], + ['event with no type', (d) => { d.events = [{ t: 10 }]; }], + ['windchange that dirCurve never delivers', (d) => { + d.dirCurve = [[0, 0.9], [90, 1.0]]; + d.events = [{ t: 55, type: 'windchange', telegraph: 6 }]; + }], + ]; + for (const [label, mutate] of cases) { + const d = base(); + mutate(d); + const { ok } = validateStorm(d, 'broken'); + assert(!ok, `validator ACCEPTED a storm with: ${label}`); + } + }); + + // ---- 4. determinism ---- + // Everything downstream (selftest fast-forward, Lane B's byte-equal load + // traces) rests on this. Two builds of the same storm must be indiscernible. + test('same def + same seed => identical trace', () => { + const def = storms.storm_02_wildnight; + const a = createWindField(def).setShelters(TREE_SHELTERS); + const b = createWindField(def).setShelters(TREE_SHELTERS); + const va = { x: 0, y: 0, z: 0 }, vb = { x: 0, y: 0, z: 0 }; + for (let t = 0; t <= def.duration; t += DT) { + for (const p of PROBES) { + a.vecAt(p.x, p.z, t, va); + b.vecAt(p.x, p.z, t, vb); + assert(va.x === vb.x && va.z === vb.z, + `diverged at t=${t.toFixed(3)} probe=(${p.x},${p.z})`); + } + } + assert(a.gusts.length === b.gusts.length, 'gust timelines differ in length'); + a.gusts.forEach((g, i) => { + assert(g.t0 === b.gusts[i].t0 && g.pow === b.gusts[i].pow, `gust ${i} differs`); + }); + }); + + test('different seed => different storm', () => { + const def = storms.storm_02_wildnight; + const a = createWindField(def, { seed: 1 }); + const b = createWindField(def, { seed: 2 }); + const same = a.gusts.length === b.gusts.length + && a.gusts.every((g, i) => g.t0 === b.gusts[i].t0 && g.pow === b.gusts[i].pow); + assert(!same, 'seed is being ignored — every storm would be identical'); + }); + + // ---- 5. sampling order must not matter ---- + // sample() is called by sail/player/debris/rain in whatever order the frame + // happens to run. If it ever depends on call order, storms stop replaying. + test('sample order independent', () => { + const def = storms.storm_02_wildnight; + // same fixed t values, walked in two different orders, on two fields + const times = [0, 3.5, 17.3, 4.1, 55.0, 88.9, 63.2, 21.7, 39.9, 70.4]; + const sorted = [...times].sort((a, b) => a - b); + const inOrder = createWindField(def).setShelters(TREE_SHELTERS); + const shuffled = createWindField(def).setShelters(TREE_SHELTERS); + const va = { x: 0, y: 0, z: 0 }, vb = { x: 0, y: 0, z: 0 }; + + const seen = new Map(); + for (const t of sorted) { + inOrder.vecAt(3, -2, t, va); + seen.set(t, { x: va.x, z: va.z }); + } + for (const t of times) { // deliberately out of order, and jumping backwards + shuffled.vecAt(3, -2, t, vb); + const want = seen.get(t); + assert(vb.x === want.x && vb.z === want.z, + `sampling order changed the wind at t=${t}: ${vb.x},${vb.z} vs ${want.x},${want.z}`); + } + }); + + // ---- 6. the storms are what the design says they are (PLAN3D §7) ---- + // storm_02 has to be able to destroy a flat cheap rig; storm_01 must not. + test('storm_02 is genuinely violent, storm_01 is not', () => { + const wild = createWindField(storms.storm_02_wildnight); + const gentle = createWindField(storms.storm_01_gentle); + const peak = (f) => { + let mx = 0, mxBase = 0; + for (let t = 0; t <= f.duration; t += DT) { + mx = Math.max(mx, f.uniformSpeed(t)); + mxBase = Math.max(mxBase, f.uniformSpeed(t) - f.gustOnly(t)); + } + return { mx, mxBase }; + }; + const w = peak(wild), g = peak(gentle); + metrics['storm_02.peakGustSpeed'] = +w.mx.toFixed(2); + metrics['storm_02.peakSustained'] = +w.mxBase.toFixed(2); + metrics['storm_01.peakGustSpeed'] = +g.mx.toFixed(2); + metrics['storm_01.peakSustained'] = +g.mxBase.toFixed(2); + assert(w.mx >= 30, `storm_02 peaks at only ${w.mx.toFixed(1)} m/s — won't break a cheap rig`); + assert(w.mxBase >= 18, `storm_02 sustained peaks at only ${w.mxBase.toFixed(1)} m/s`); + assert(g.mx <= 15, `storm_01 peaks at ${g.mx.toFixed(1)} m/s — too wild for the gentle storm`); + assert(w.mx > g.mx * 2, 'storm_02 should be far worse than storm_01'); + }); + + // ---- 7. wind change actually swings the wind ---- + test('storm_02 southerly change swings the wind', () => { + const f = createWindField(storms.storm_02_wildnight); + const ev = (storms.storm_02_wildnight.events || []).find((e) => e.type === 'windchange'); + assert(ev, 'storm_02 has no windchange event'); + const before = f.dirAt(ev.t - 5); + const after = f.dirAt(ev.t + 8); + const swing = Math.abs(after - before); + metrics['storm_02.changeSwingRad'] = +swing.toFixed(3); + assert(swing > 0.9, `change only swings ${swing.toFixed(2)} rad — should be a real slew`); + // and the player must be warned before it lands + assert((ev.telegraph ?? 0) >= 4, 'windchange telegraph is too short to react to'); + }); + + // ---- 8. shelters ---- + test('tree wind shadow bites downwind and nowhere else', () => { + const def = storms.storm_02_wildnight; + const bare = createWindField(def); + const shad = createWindField(def).setShelters([{ x: 0, z: 0, radius: 3, strength: 0.5, length: 14 }]); + const t = 30; + const d = bare.dirAt(t); + const dx = Math.cos(d), dz = Math.sin(d); + const lee = { x: dx * 5, z: dz * 5 }; // 5 m downwind of the tree + const luv = { x: -dx * 5, z: -dz * 5 }; // 5 m upwind + const leeS = shad.speedAt(lee.x, lee.z, t), leeB = bare.speedAt(lee.x, lee.z, t); + const luvS = shad.speedAt(luv.x, luv.z, t), luvB = bare.speedAt(luv.x, luv.z, t); + metrics['shelter.leeDrop'] = +(1 - leeS / leeB).toFixed(3); + assert(leeS < leeB * 0.85, `lee side only dropped to ${(leeS / leeB).toFixed(2)}× — shadow too weak`); + assert(Math.abs(luvS - luvB) < 1e-9, 'upwind side is being sheltered — shadow is pointing the wrong way'); + }); + + const pass = results.filter((r) => r.ok).length; + return { suite: 'weather', pass, fail: results.length - pass, results, metrics }; +} diff --git a/web/world/js/weather.core.js b/web/world/js/weather.core.js new file mode 100644 index 0000000..dafcd55 --- /dev/null +++ b/web/world/js/weather.core.js @@ -0,0 +1,383 @@ +'use strict'; +// SHADES — Lane C — wind field core. +// +// Pure math. Zero imports: no THREE, no DOM, no Date.now, no rAF. Everything is +// a closed-form function of (pos, t) given a storm def + seed, which buys us: +// - selftest can fast-forward a 90 s storm and get identical numbers every run +// - consumers can sample any t, in any order, as often as they like +// - the determinism rule (PLAN3D §4) is structural, not a promise +// +// weather.js wraps this to expose the contracts.js surface (Vector3 in/out). +// The prototype scheduled gusts by INTEGRATING (wind.gustT += dt). We can't — +// sample(pos,t) is called by everyone at arbitrary t. So gusts are precomputed +// into a timeline from a seeded PRNG at storm load; the envelope shape below is +// a faithful port of prototype/game.js, just read from t instead of accumulated. + +// ---------- gust envelope (ported from prototype/game.js windVec) ---------- +// telegraph: wind hasn't risen yet, but you can SEE it coming (grass, band, audio) +export const GUST = Object.freeze({ + TELEGRAPH: 1.5, // gt < 1.5 → 0 "it's coming" + RAMP: 0.8, // 1.5 .. 2.3 → 0 → pow + HOLD: 1.7, // 2.3 .. 4.0 → pow + FADE: 1.0, // 4.0 .. 5.0 → pow → 0 + TOTAL: 5.0, +}); +const RAMP_AT = GUST.TELEGRAPH; // 1.5 +const HOLD_AT = RAMP_AT + GUST.RAMP; // 2.3 +const FADE_AT = HOLD_AT + GUST.HOLD; // 4.0 +const END_AT = FADE_AT + GUST.FADE; // 5.0 + +/** Gust strength at local gust time gt (seconds since telegraph began). */ +export function gustEnvelope(gt, pow) { + if (gt <= 0 || gt >= END_AT) return 0; + if (gt < RAMP_AT) return 0; // telegraph window + if (gt < HOLD_AT) return pow * (gt - RAMP_AT) / GUST.RAMP; + if (gt < FADE_AT) return pow; + return pow * (END_AT - gt) / GUST.FADE; +} + +// ---------- deterministic noise ---------- +// mulberry32 — small, fast, good enough, and identical in every JS engine. +export function mulberry32(seed) { + let a = seed >>> 0; + return function () { + a = (a + 0x6D2B79F5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// int32 hash — Math.imul keeps it exact (plain * would drift past 2^31 as a double) +function hash2(ix, iz, seed) { + let h = (Math.imul(ix, 374761393) + Math.imul(iz, 668265263) + Math.imul(seed, 1274126177)) | 0; + h = Math.imul(h ^ (h >>> 13), 1274126177); + h ^= h >>> 16; + return (h >>> 0) / 4294967296; +} + +const smooth = (f) => f * f * (3 - 2 * f); + +/** Value noise, 0..1, C1-continuous (smoothstep interp) so wind never steps. */ +function valueNoise2(x, z, seed) { + const ix = Math.floor(x), iz = Math.floor(z); + const ux = smooth(x - ix), uz = smooth(z - iz); + const a = hash2(ix, iz, seed), b = hash2(ix + 1, iz, seed); + const c = hash2(ix, iz + 1, seed), d = hash2(ix + 1, iz + 1, seed); + return (a + (b - a) * ux) * (1 - uz) + (c + (d - c) * ux) * uz; +} + +export function smoothstep(e0, e1, x) { + if (e0 === e1) return x < e0 ? 0 : 1; + const f = Math.min(1, Math.max(0, (x - e0) / (e1 - e0))); + return smooth(f); +} + +// ---------- curves ---------- +/** Piecewise-linear [[t,v],...] lookup, clamped at both ends. */ +export function sampleCurve(curve, t) { + if (!curve || curve.length === 0) return 0; + if (t <= curve[0][0]) return curve[0][1]; + const last = curve[curve.length - 1]; + if (t >= last[0]) return last[1]; + for (let i = 1; i < curve.length; i++) { + if (t <= curve[i][0]) { + const [ta, va] = curve[i - 1], [tb, vb] = curve[i]; + const span = tb - ta; + return span <= 0 ? vb : va + (vb - va) * ((t - ta) / span); + } + } + return last[1]; +} + +/** Shortest-arc angle lerp — so a curve crossing ±π doesn't spin the long way. */ +export function lerpAngle(a, b, k) { + const TAU = Math.PI * 2; + let d = ((b - a + Math.PI) % TAU + TAU) % TAU - Math.PI; + return a + d * k; +} + +function sampleAngleCurve(curve, t) { + if (!curve || curve.length === 0) return 0; + if (t <= curve[0][0]) return curve[0][1]; + const last = curve[curve.length - 1]; + if (t >= last[0]) return last[1]; + for (let i = 1; i < curve.length; i++) { + if (t <= curve[i][0]) { + const [ta, va] = curve[i - 1], [tb, vb] = curve[i]; + const span = tb - ta; + return span <= 0 ? vb : lerpAngle(va, vb, (t - ta) / span); + } + } + return last[1]; +} + +// ---------- gust timeline ---------- +// Prototype: pow = 12 + rand*16 + 10*p, next = t + 5 + rand*7. Same shape, from JSON. +export function buildGustTimeline(def, seed) { + const g = def.gusts || {}; + const rng = mulberry32(seed >>> 0); + const minGap = g.minGap ?? 5, maxGap = g.maxGap ?? 12; + const out = []; + let t = g.firstAt ?? 3; + // hard cap: a malformed gap can't spin us forever + while (t < def.duration && out.length < 512) { + const p = def.duration > 0 ? t / def.duration : 0; + const pow = (g.powBase ?? 12) + rng() * (g.powRand ?? 16) + (g.powRamp ?? 10) * p; + out.push({ t0: t, pow, rampAt: t + GUST.TELEGRAPH, endAt: t + GUST.TOTAL }); + t += minGap + rng() * Math.max(0, maxGap - minGap); + } + return out; +} + +// ---------- the field ---------- +/** + * @param {object} def parsed storm JSON (see data/storms/*.json) + * @param {object} [opts] {seed} + */ +export function createWindField(def, opts = {}) { + const seed = (opts.seed ?? def.seed ?? 1) >>> 0; + const duration = def.duration ?? 90; + const gusts = buildGustTimeline(def, seed); + const sp = def.spatial || {}; + const amp = sp.amp ?? 0.18; // ±18% speed across the yard + const scale = sp.scale ?? 12; // metres per noise cell — yard is 30×20 + const advect = sp.advect ?? 0.5; // noise drifts downwind (frozen turbulence) + const wander = def.dirWander || {}; + const wAmp = wander.amp ?? 0.25, wRate = wander.rate ?? 0.13; + const nSeed = (seed ^ 0x9e3779b9) | 0; + + let shelters = []; + + /** Spatially-uniform part: base curve + every gust envelope live at t. */ + function uniformSpeed(t) { + let s = sampleCurve(def.baseCurve, t); + for (let i = 0; i < gusts.length; i++) { + const g = gusts[i]; + if (t <= g.t0) break; // sorted — nothing later can be live + if (t < g.endAt) s += gustEnvelope(t - g.t0, g.pow); + } + return s; + } + + function gustOnly(t) { + let s = 0; + for (let i = 0; i < gusts.length; i++) { + const g = gusts[i]; + if (t <= g.t0) break; + if (t < g.endAt) s += gustEnvelope(t - g.t0, g.pow); + } + return s; + } + + function dirAt(t) { + return sampleAngleCurve(def.dirCurve, t) + wAmp * Math.sin(t * wRate); + } + + // ---- noise drift ---- + // The noise pattern rides downwind with the mean flow (Taylor's frozen + // turbulence), so a gust visibly travels ACROSS the yard instead of blinking on + // everywhere at once. That displacement is an integral, D(t) = ∫ advect·U·dir dτ, + // and it has to be integrated as one: the obvious closed form `U(t)·advect·t` + // is not the integral, and it whips the whole accumulated field sideways the + // instant U or dir moves — a 6.8 m/s single-frame jump at the southerly change, + // which the continuity assert caught. So integrate once at build time into an + // immutable table; sampling stays a pure function of t. + // Mean flow only (base curve, no gusts): eddies are carried by the wind, they + // don't surf their own gust, and it keeps the drift rate smooth. + const DRIFT_DT = 0.25; + const driftX = [], driftZ = []; + { + let dx = 0, dz = 0; + const n = Math.ceil((duration + 2) / DRIFT_DT) + 2; + for (let i = 0; i < n; i++) { + driftX.push(dx); driftZ.push(dz); + const tt = i * DRIFT_DT; + const u = sampleCurve(def.baseCurve, tt) * advect; + const d = dirAt(tt); + dx += Math.cos(d) * u * DRIFT_DT; + dz += Math.sin(d) * u * DRIFT_DT; + } + } + const drift = { x: 0, z: 0 }; + function driftAt(t) { + if (t <= 0) { drift.x = 0; drift.z = 0; return drift; } + const f = t / DRIFT_DT; + let i = Math.floor(f); + if (i > driftX.length - 2) i = driftX.length - 2; // past the end: extrapolate + const k = f - i; + drift.x = driftX[i] + (driftX[i + 1] - driftX[i]) * k; + drift.z = driftZ[i] + (driftZ[i + 1] - driftZ[i]) * k; + return drift; + } + + /** Speed multiplier: smooth noise, carried downwind. */ + function spatialFactor(x, z, t) { + if (amp <= 0) return 1; + const d = driftAt(t); + const nx = (x - d.x) / scale; + const nz = (z - d.z) / scale; + const n = 0.65 * valueNoise2(nx, nz, nSeed) + + 0.35 * valueNoise2(nx * 2.2 + 31.7, nz * 2.2 + 11.3, nSeed ^ 0x51ed270b); + return 1 + (n - 0.5) * 2 * amp; + } + + /** Trees knock a hole downwind of themselves. Cheap, and very juicy. */ + function shelterFactor(x, z, dirX, dirZ) { + let f = 1; + for (let i = 0; i < shelters.length; i++) { + const s = shelters[i]; + const rx = x - s.x, rz = z - s.z; + const along = rx * dirX + rz * dirZ; // >0 = downwind of the tree + if (along <= 0 || along >= s.length) continue; + const perp = Math.abs(rx * dirZ - rz * dirX); + if (perp >= s.radius) continue; + // ramp in over the first half-radius so the shadow can't snap on at along=0 + const fAlong = smoothstep(0, s.radius * 0.5, along) * (1 - smoothstep(0, s.length, along)); + const fPerp = 1 - smoothstep(0, s.radius, perp); + f *= 1 - s.strength * fAlong * fPerp; + } + return f; + } + + const field = { + def, + seed, + gusts, + duration, + + /** + * Trees/house register wind shadows. Lane A calls this after building the + * yard; unset = no shadows, so nothing breaks before world.js lands. + * @param {Array<{x,z,radius,strength,length}>} list + */ + setShelters(list) { + shelters = (list || []).map((s) => ({ + x: s.x, z: s.z, + radius: s.radius ?? 2.5, + strength: Math.min(1, Math.max(0, s.strength ?? 0.45)), + length: s.length ?? (s.radius ?? 2.5) * 4, + })); + return field; + }, + get shelters() { return shelters; }, + + /** Scalar wind speed (m/s) at a point. The cheap path — no allocation. */ + speedAt(x, z, t) { + const uni = uniformSpeed(t); + const d = dirAt(t); + const s = uni * spatialFactor(x, z, t) * shelterFactor(x, z, Math.cos(d), Math.sin(d)); + return s > 0 ? s : 0; + }, + + dirAt, + uniformSpeed, + gustOnly, + + /** Writes wind velocity (m/s) into out {x,y,z}. Ground plane is XZ, +Y up. */ + vecAt(x, z, t, out) { + const uni = uniformSpeed(t); + const d = dirAt(t); + const dirX = Math.cos(d), dirZ = Math.sin(d); + let s = uni * spatialFactor(x, z, t) * shelterFactor(x, z, dirX, dirZ); + if (s < 0) s = 0; + out.x = dirX * s; + out.y = 0; // wind is horizontal; lift is the sail's job (Lane B) + out.z = dirZ * s; + return out; + }, + + /** + * The next gust that has been telegraphed but hasn't started ramping. + * eta = seconds until the wind actually rises. Null when nothing's inbound. + */ + telegraph(t) { + for (let i = 0; i < gusts.length; i++) { + const g = gusts[i]; + if (t < g.t0) return null; // sorted — next one hasn't telegraphed yet + if (t < g.rampAt) { + return { eta: g.rampAt - t, dir: dirAt(g.rampAt), power: g.pow }; + } + } + return null; + }, + + /** Storm events (windchange/debris) fired in (a, b]. Pure — replayable. */ + eventsBetween(a, b) { + const evs = def.events || []; + const out = []; + for (let i = 0; i < evs.length; i++) { + if (evs[i].t > a && evs[i].t <= b) out.push(evs[i]); + } + return out; + }, + + /** 0..1 rain intensity for skyfx. */ + rainAt(t) { + const r = def.rain; + if (!r) return 0; + if (r.curve) return Math.min(1, Math.max(0, sampleCurve(r.curve, t))); + return Math.min(1, Math.max(0, r.intensity ?? 0)); + }, + }; + + return field; +} + +// ---------- storm JSON validator ---------- +// Storms are data so design can tune without code (PLAN3D §4) — which means a +// typo is a data bug, and data bugs should fail loud, not silently blow calm. +export function validateStorm(def, name = 'storm') { + const errors = []; + const bad = (m) => errors.push(`${name}: ${m}`); + const isCurve = (c) => Array.isArray(c) && c.length > 0 + && c.every((p) => Array.isArray(p) && p.length === 2 && p.every(Number.isFinite)); + const monotonic = (c) => c.every((p, i) => i === 0 || p[0] >= c[i - 1][0]); + + if (!def || typeof def !== 'object') { bad('not an object'); return { ok: false, errors }; } + if (!Number.isFinite(def.duration) || def.duration <= 0) bad('duration must be a positive number'); + + if (!isCurve(def.baseCurve)) bad('baseCurve must be [[t,speed],...] of finite numbers'); + else { + if (!monotonic(def.baseCurve)) bad('baseCurve t must be non-decreasing'); + if (def.baseCurve.some((p) => p[1] < 0)) bad('baseCurve speed must be >= 0'); + const end = def.baseCurve[def.baseCurve.length - 1][0]; + if (Number.isFinite(def.duration) && end < def.duration) { + bad(`baseCurve ends at t=${end} but storm runs to ${def.duration} — tail would flatline`); + } + } + + if (!isCurve(def.dirCurve)) bad('dirCurve must be [[t,radians],...] of finite numbers'); + else if (!monotonic(def.dirCurve)) bad('dirCurve t must be non-decreasing'); + + const g = def.gusts; + if (!g || typeof g !== 'object') bad('gusts block missing'); + else { + const minGap = g.minGap ?? 5, maxGap = g.maxGap ?? 12; + if (!(minGap > 0)) bad('gusts.minGap must be > 0 (else the timeline never advances)'); + if (maxGap < minGap) bad('gusts.maxGap must be >= minGap'); + // Overlapping gusts stack, and a stacked telegraph is unreadable to the player. + if (minGap < GUST.TOTAL) bad(`gusts.minGap (${minGap}) < gust length ${GUST.TOTAL}s — gusts would overlap`); + if ((g.powBase ?? 12) < 0) bad('gusts.powBase must be >= 0'); + } + + for (const e of def.events || []) { + if (!Number.isFinite(e.t)) bad(`event ${JSON.stringify(e)} has no finite t`); + if (!e.type) bad(`event at t=${e.t} has no type`); + if (e.type === 'debris' && !e.model) bad(`debris event at t=${e.t} has no model`); + // A windchange event is HUD metadata; dirCurve is the physics. If they drift + // apart the player gets warned about a swing that never comes. + if (e.type === 'windchange' && isCurve(def.dirCurve)) { + const before = sampleAngleCurve(def.dirCurve, e.t - 0.5); + const after = sampleAngleCurve(def.dirCurve, e.t + (e.over ?? 6)); + const swing = Math.abs(lerpAngle(before, after, 1) - before); + if (swing < 0.5) { + bad(`windchange at t=${e.t} promises a swing but dirCurve only turns ${swing.toFixed(2)} rad by t=${e.t + (e.over ?? 6)}`); + } + } + } + + if (def.rain && def.rain.curve && !isCurve(def.rain.curve)) bad('rain.curve must be [[t,intensity],...]'); + + return { ok: errors.length === 0, errors }; +} diff --git a/web/world/js/weather.js b/web/world/js/weather.js new file mode 100644 index 0000000..4f2480f --- /dev/null +++ b/web/world/js/weather.js @@ -0,0 +1,98 @@ +'use strict'; +// SHADES — Lane C — weather: the wind field everyone samples. +// +// Implements the contracts.js wind surface (PLAN3D §4): +// wind.sample(pos, t) -> Vector3 m/s, includes gusts & local effects +// wind.gustTelegraph(t) -> {eta, dir, power} | null +// +// All the maths lives in weather.core.js (pure, no imports). This file is just +// the THREE adapter + storm loading, so the sim stays node-testable and the +// determinism rule can't be broken by accident. + +import * as THREE from 'three'; +import { createWindField, validateStorm, GUST } from './weather.core.js'; + +export { GUST, validateStorm }; + +const STORM_DIR = '/world/data/storms'; + +/** Fetch + validate a storm def. Throws loud on bad data — storms are content. */ +export async function loadStorm(name, dir = STORM_DIR) { + const url = `${dir}/${name}.json`; + const res = await fetch(url); + if (!res.ok) throw new Error(`weather: cannot load ${url} (${res.status})`); + const def = await res.json(); + const { ok, errors } = validateStorm(def, name); + if (!ok) throw new Error(`weather: ${url} is invalid:\n ${errors.join('\n ')}`); + return def; +} + +/** + * @param {object} def parsed storm JSON + * @param {object} [opts] {seed} — same seed + same def = same storm, every run + * @returns the `wind` object from contracts.js + */ +export function createWind(def, opts = {}) { + const field = createWindField(def, opts); + const scratch = { x: 0, y: 0, z: 0 }; + + const wind = { + /** + * Wind velocity at a world position, m/s. + * @param {THREE.Vector3} pos + * @param {number} t storm time, seconds + * @param {THREE.Vector3} [out] pass one to avoid allocating — sail.js + * samples per-face per-frame, so this matters + */ + sample(pos, t, out) { + const v = out || new THREE.Vector3(); + field.vecAt(pos.x, pos.z, t, scratch); + return v.set(scratch.x, scratch.y, scratch.z); + }, + + /** Scalar speed — for HUD, rain, grass. Cheaper than sample(); no allocation. */ + speedAt(pos, t) { + return field.speedAt(pos.x, pos.z, t); + }, + + /** {eta, dir, power} while a gust is inbound but hasn't risen yet, else null. */ + gustTelegraph(t) { + return field.telegraph(t); + }, + + /** + * Register wind shadows (trees, house). Lane A: call after the yard is built. + * Until then there are simply no shadows — nothing breaks. + * @param {Array<{x,z,radius,strength,length}>} list + */ + setShelters(list) { field.setShelters(list); return wind; }, + + /** Convenience: take shadows straight off world.anchors' tree entries. */ + setSheltersFromTrees(trees, o = {}) { + return wind.setShelters(trees.map((tr) => ({ + x: tr.pos ? tr.pos.x : tr.x, + z: tr.pos ? tr.pos.z : tr.z, + radius: o.radius ?? tr.radius ?? 3, + strength: o.strength ?? 0.45, + length: o.length ?? 14, + }))); + }, + + /** Storm events fired in (a,b] — poll with (t-dt, t). Deterministic. */ + eventsBetween(a, b) { return field.eventsBetween(a, b); }, + + /** 0..1 rain intensity. */ + rainAt(t) { return field.rainAt(t); }, + + /** Direction (radians, XZ plane from +X toward +Z) ignoring local effects. */ + dirAt(t) { return field.dirAt(t); }, + + get duration() { return field.duration; }, + get gusts() { return field.gusts; }, + get def() { return field.def; }, + get seed() { return field.seed; }, + core: field, + }; + + return wind; +} From 383471d0f5c3794e2d9cef48e7e60432dce17097 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Thu, 16 Jul 2026 21:37:56 +1000 Subject: [PATCH 2/5] Add debris, skyfx and the Lane C bench; align to landed contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit debris.js: hand-rolled kinematic tumble, drag ∝ speed² so the gust that spikes a corner is the one that launches the neighbour's bin. Ground bounce via world.heightAt (contracts.js documents it as ours), sphere-vs-player knockdown reported to Lane D, sphere-vs-sail-node impulse duck-typed so it lights up when Lane B exposes nodes and stays silent until then. skyfx.js: instanced rain that wraps around the camera rather than respawning, storm sky + procedural cloud dome, lightning, and synthesized WebAudio layers (wind bed, howl, rain, gust whoosh on the telegraph, rope creak off the worst corner, flog when one blows). It modulates Lane A's lights and hands them back on dispose() rather than owning them. weather_demo.html: graybox bench to drive all three before M0 — mock sail, storm scrub, 4x, throw-a-crate. Aligned to contracts.js now that it has landed: relative three imports (there is no importmap), contracts' rng() instead of a local copy, and storm paths resolved off import.meta.url — server.py serves the repo root, so an absolute /world/... would have 404'd at integration. storm_02: fix the southerly change. contracts.js puts north at -Z and the wind vector blows toward (cos d, sin d), so the old swing to +2.6 blew toward due south — a northerly wearing a southerly's name. Now slews to -1.35: a SSW buster off the open side of the yard, into the house. Co-Authored-By: Claude Opus 4.8 --- web/world/data/storms/storm_02_wildnight.json | 4 +- web/world/js/debris.js | 230 +++++++++ web/world/js/skyfx.js | 455 ++++++++++++++++++ web/world/js/tests/c.test.js | 90 +++- web/world/js/tests/weather.selftest.js | 36 +- web/world/js/weather.core.js | 2 +- web/world/js/weather.js | 7 +- web/world/weather_demo.html | 255 ++++++++++ 8 files changed, 1048 insertions(+), 31 deletions(-) create mode 100644 web/world/js/debris.js create mode 100644 web/world/js/skyfx.js create mode 100644 web/world/weather_demo.html diff --git a/web/world/data/storms/storm_02_wildnight.json b/web/world/data/storms/storm_02_wildnight.json index 78982be..91299ac 100644 --- a/web/world/data/storms/storm_02_wildnight.json +++ b/web/world/data/storms/storm_02_wildnight.json @@ -7,6 +7,8 @@ "_comment": "Sustained builds 7 -> 20 m/s (25 -> 72 km/h); worst gusts land near 33 m/s (~120 km/h), which is BOM 'destructive' and is what shreds a flat drum-tight cheap rig. Peak arrives just AFTER the southerly change, so the corners that were slack all storm are the ones that cop it.", + "_dirCurve_comment": "Radians in the XZ plane; the wind blows TOWARD (cos d, sin d). contracts.js puts north at -Z, so a southerly (blowing toward the north, into the house) needs sin(d) < 0. Starts ~0.85 = blowing toward the SE, i.e. the hot NW'er before a change; slews to ~-1.35 = blowing toward the NNE, i.e. a SSW buster off the open south side of the yard. 55->59s is the slew: ~110 deg in four seconds.", + "baseCurve": [[0, 7.0], [15, 11.0], [40, 17.0], [60, 20.0], [78, 19.0], [90, 16.0]], "gusts": { @@ -18,7 +20,7 @@ "powRamp": 7 }, - "dirCurve": [[0, 0.85], [50, 0.95], [55, 1.2], [59, 2.45], [70, 2.6], [90, 2.5]], + "dirCurve": [[0, 0.85], [50, 0.95], [55, 0.6], [59, -1.25], [70, -1.45], [90, -1.35]], "dirWander": { "amp": 0.25, "rate": 0.13 }, "spatial": { "amp": 0.2, "scale": 11, "advect": 0.5 }, diff --git a/web/world/js/debris.js b/web/world/js/debris.js new file mode 100644 index 0000000..79261cc --- /dev/null +++ b/web/world/js/debris.js @@ -0,0 +1,230 @@ +'use strict'; +// SHADES — Lane C — debris: things that should have been tied down. +// +// Hand-rolled kinematic tumble (PLAN3D §5-C.4). No physics engine, no deps. +// Deterministic: step(dt, t), seeded RNG, no Date.now — so a storm replays. +// +// Spawns off `debris` events in the storm JSON, upwind of the yard, and lets the +// wind field carry it across. Drag goes with speed², same as the sail, so the +// same gust that spikes a corner is the one that launches the neighbour's bin. + +import * as THREE from '../vendor/three.module.js'; +import { rng } from './contracts.js'; + +const RHO = 1.2; // air density, kg/m³ +const GRAVITY = -9.81; + +// Fallback specs for Lane E's debris set (3D-STORE crates/tubs). Radius is the +// collision sphere, not the render bounds — a crate is boxy, but a sphere is +// what you can afford to test 6 of per node per frame. +const MODEL_SPEC = { + BlueCrate_v2: { r: 0.30, mass: 9, cd: 1.05 }, + BlackTub_v2: { r: 0.34, mass: 5, cd: 1.10 }, + WhiteTub_v2: { r: 0.34, mass: 5, cd: 1.10 }, + WoodenBin_v2: { r: 0.42, mass: 14, cd: 1.05 }, + LibraryTrolley_v1: { r: 0.45, mass: 22, cd: 0.95 }, +}; +const DEFAULT_SPEC = { r: 0.35, mass: 8, cd: 1.05 }; + +/** + * @param {object} o + * @param {object} o.wind from weather.js + * @param {THREE.Object3D} o.scene + * @param {Object} [o.models] name -> template (Lane E's GLBs) + * @param {function} [o.onHitPlayer] (piece, impact) — Lane D knocks the player down + * @param {function} [o.onEvent] (text) — HUD ticker + * @param {object} [o.bounds] {x, z} half-extents before despawn + */ +export function createDebris(o = {}) { + const wind = o.wind; + const scene = o.scene || null; + const models = o.models || {}; + const bounds = o.bounds || { x: 26, z: 20 }; + // contracts.js documents world.heightAt as the thing Lane C bounces debris off. + // Flat fallback so this still runs against a graybox yard. + const groundAt = o.heightAt || (() => o.groundY ?? 0); + const rand = rng(((wind && wind.seed) || 1) ^ 0x5eed1e); + + const pieces = []; + const w = new THREE.Vector3(); + const probe = new THREE.Vector3(); + + /** Graybox stand-in so a missing GLB can't break Lane A's merge. */ + function placeholder(spec) { + const g = new THREE.BoxGeometry(spec.r * 1.8, spec.r * 1.8, spec.r * 1.8); + const m = new THREE.MeshStandardMaterial({ color: 0x8a6a3a, roughness: 0.9 }); + return new THREE.Mesh(g, m); + } + + function spawn(ev, t) { + const spec = { ...(MODEL_SPEC[ev.model] || DEFAULT_SPEC) }; + if (Number.isFinite(ev.mass)) spec.mass = ev.mass; + + // upwind of the yard, offset sideways, so it crosses the whole thing + const d = wind.dirAt(t); + const dx = Math.cos(d), dz = Math.sin(d); + const lat = ev.lateral ?? 0; + const dist = ev.spawnDist ?? 18; + const x = -dx * dist - dz * lat; + const z = -dz * dist + dx * lat; + const y = groundAt(x, z) + spec.r + (ev.height ?? 0.2 + rand() * 1.2); + + const tmpl = models[ev.model]; + const mesh = tmpl ? tmpl.clone(true) : placeholder(spec); + mesh.castShadow = true; + if (scene) scene.add(mesh); + + // already moving — it's been blowing across the neighbour's yard for a while + probe.set(x, y, z); + wind.sample(probe, t, w); + + const piece = { + model: ev.model, + x, y, z, + vx: w.x * 0.8, vy: 0, vz: w.z * 0.8, + // spin axis is arbitrary but seeded; rate scales with airspeed in step() + sx: rand() * 2 - 1, sy: rand() * 2 - 1, sz: rand() * 2 - 1, + spin: 0, + r: spec.r, mass: spec.mass, cd: spec.cd, + area: Math.PI * spec.r * spec.r, + hitPlayer: false, + mesh, + alive: true, + }; + pieces.push(piece); + if (ev.text && o.onEvent) o.onEvent(ev.text); + return piece; + } + + function despawn(p) { + p.alive = false; + if (scene && p.mesh) scene.remove(p.mesh); + } + + const debris = { + get pieces() { return pieces; }, + + /** Lane E's GLBs, once they land. name -> Object3D template. */ + setModels(map) { Object.assign(models, map); return debris; }, + + /** Manual spawn — handy for tuning and for Lane A's debug keys. */ + spawn, + + /** + * @param {number} dt fixed step + * @param {number} t storm time + * @param {object} [world] {player, sail} — both optional, both duck-typed + */ + step(dt, t, world = {}) { + // storm JSON drives the spawns; poll the window so nothing is missed + if (wind) { + for (const ev of wind.eventsBetween(t - dt, t)) { + if (ev.type === 'debris') spawn(ev, t); + } + } + + const player = world.player; + const sail = world.sail; + + for (let i = pieces.length - 1; i >= 0; i--) { + const p = pieces[i]; + + probe.set(p.x, p.y, p.z); + wind.sample(probe, t, w); + + // drag against the AIR, not the ground: F = ½ρ Cd A |w-v| (w-v) + const rx = w.x - p.vx, ry = w.y - p.vy, rz = w.z - p.vz; + const rel = Math.hypot(rx, ry, rz); + const k = 0.5 * RHO * p.cd * p.area * rel / p.mass; + p.vx += rx * k * dt; + p.vy += ry * k * dt + GRAVITY * dt; + p.vz += rz * k * dt; + + p.x += p.vx * dt; + p.y += p.vy * dt; + p.z += p.vz * dt; + + // ground: bounce, shed sideways speed, keep skittering + const floor = groundAt(p.x, p.z) + p.r; + if (p.y <= floor) { + p.y = floor; + if (p.vy < 0) p.vy = -p.vy * 0.32; // dead-ish bounce, it's a plastic tub + if (Math.abs(p.vy) < 0.35) p.vy = 0; + p.vx *= 0.86; p.vz *= 0.86; // scrape + } + + // tumble rate follows airspeed — becalmed debris shouldn't keep spinning + p.spin += rel * 0.35 * dt; + if (p.mesh) { + p.mesh.position.set(p.x, p.y, p.z); + p.mesh.rotation.set(p.sx * p.spin, p.sy * p.spin, p.sz * p.spin); + } + + // --- sphere vs player: knockdown --- + // Contract gives us player.pos; the knockdown itself is Lane D's (§5-D.3), + // so we just report the hit and let them run the state machine. + if (player && player.pos && !p.hitPlayer) { + const px = player.pos.x, pz = player.pos.z; + const py = player.pos.y + 0.9; // centre of mass, not feet + const dsq = (p.x - px) ** 2 + (p.y - py) ** 2 + (p.z - pz) ** 2; + const hit = p.r + 0.35; + if (dsq < hit * hit) { + const impact = Math.hypot(p.vx, p.vy, p.vz) * p.mass; + // a bin rolling gently past your ankles shouldn't floor you + if (impact > 25 && o.onHitPlayer) { + p.hitPlayer = true; // one knockdown per piece + o.onHitPlayer(p, impact); + p.vx *= 0.4; p.vz *= 0.4; + } + } + } + + // --- sphere vs sail nodes: impulse --- + // Duck-typed: lights up the moment Lane B exposes nodes, silent until + // then. See THREADS — B owns sail.js, so this is the seam we agreed on. + if (sail && sail.nodes) applyToSail(p, sail); + + if (p.y < groundAt(p.x, p.z) - 5 || Math.abs(p.x) > bounds.x || Math.abs(p.z) > bounds.z) { + despawn(p); + pieces.splice(i, 1); + } + } + }, + + /** Drop everything (phase change, restart). */ + clear() { + for (const p of pieces) despawn(p); + pieces.length = 0; + }, + }; + + /** + * Shove any cloth node the piece is intersecting, and lose some of the piece's + * own momentum doing it. Expects sail.nodes: [{x,y,z,px,py,pz}] (verlet, so we + * move position and let the integrator turn it into velocity). + */ + function applyToSail(p, sail) { + const nodes = sail.nodes; + const reach = p.r + 0.15; + const reachSq = reach * reach; + let hits = 0; + for (let i = 0; i < nodes.length; i++) { + const n = nodes[i]; + const dx = n.x - p.x, dy = n.y - p.y, dz = n.z - p.z; + const dsq = dx * dx + dy * dy + dz * dz; + if (dsq > reachSq || dsq < 1e-9) continue; + const d = Math.sqrt(dsq); + // push the node out to the sphere surface along the contact normal + const push = (reach - d) / d; + n.x += dx * push; n.y += dy * push; n.z += dz * push; + hits++; + } + if (hits) { + const drag = Math.min(0.5, (hits * p.mass) / 400); + p.vx *= 1 - drag; p.vy *= 1 - drag; p.vz *= 1 - drag; + if (sail.onDebrisHit) sail.onDebrisHit(p, hits); + } + } + + return debris; +} diff --git a/web/world/js/skyfx.js b/web/world/js/skyfx.js new file mode 100644 index 0000000..d79864b --- /dev/null +++ b/web/world/js/skyfx.js @@ -0,0 +1,455 @@ +'use strict'; +// SHADES — Lane C — skyfx: rain, storm sky, lightning, and the noise of it all. +// +// PLAN3D §5-C.2/§5-C.3. Lane A's world.js owns the calm sky and the base lights; +// this MODULATES them as the storm builds and hands them back on dispose(), so +// two lanes never fight over one scene. +// +// Everything is duck-typed and optional — no sun light, no audio, no sail? Then +// those layers just don't run. Lane A can wire the pieces as they land. +// +// Audio is synthesized, not sampled: web/world/audio/ is empty, we ship no CDN +// and no deps, and a filtered-noise bed tracks wind speed better than a loop. + +import * as THREE from '../vendor/three.module.js'; +import { rng } from './contracts.js'; +import { valueNoise2 } from './weather.core.js'; + +const lerp = (a, b, k) => a + (b - a) * k; +const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v); + +const CALM_SKY = new THREE.Color(0x9fc4e8); +const STORM_SKY = new THREE.Color(0x2a2f3a); +const NIGHT_SKY = new THREE.Color(0x11141c); + +// ---------------------------------------------------------------- rain +function createRain(opts) { + const max = opts.maxDrops ?? 3000; + const half = opts.half ?? 18; // box half-extent around the camera + const height = opts.height ?? 24; + const groundY = opts.groundY ?? 0; + const rand = rng(0xd309); + + // one thin quadish streak, instanced — cheap and reads as rain in motion + const geo = new THREE.BoxGeometry(0.015, 1, 0.015); + const mat = new THREE.MeshBasicMaterial({ + color: 0xb4d2ff, transparent: true, opacity: 0.34, + depthWrite: false, fog: false, + }); + const mesh = new THREE.InstancedMesh(geo, mat, max); + mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); + mesh.frustumCulled = false; + mesh.renderOrder = 2; + mesh.count = 0; + + const px = new Float32Array(max), py = new Float32Array(max), pz = new Float32Array(max); + const jitter = new Float32Array(max); + for (let i = 0; i < max; i++) { + px[i] = (rand() * 2 - 1) * half; + py[i] = groundY + rand() * height; + pz[i] = (rand() * 2 - 1) * half; + jitter[i] = 0.75 + rand() * 0.5; // not every drop is the same drop + } + + const m = new THREE.Matrix4(); + const q = new THREE.Quaternion(); + const up = new THREE.Vector3(0, 1, 0); + const vel = new THREE.Vector3(); + const scale = new THREE.Vector3(1, 1, 1); + const zero = new THREE.Vector3(); + + return { + mesh, + /** @param {THREE.Vector3} camPos @param {THREE.Vector3} w local wind */ + step(dt, camPos, w, intensity) { + const n = Math.floor(max * clamp01(intensity)); + mesh.count = n; + if (n === 0) return; + + const fall = 9 + intensity * 4; + // rain leans into the wind; that lean IS the readout of how hard it's blowing + vel.set(w.x * 0.55, -fall, w.z * 0.55); + const speed = vel.length() || 1; + q.setFromUnitVectors(up, vel.clone().divideScalar(speed)); + // streak stretches with speed — drizzle is dots, a squall is lines + scale.set(1, Math.min(2.6, 0.35 + speed * 0.055), 1); + m.compose(zero, q, scale); + + const top = groundY + height; + for (let i = 0; i < n; i++) { + const j = jitter[i]; + px[i] += w.x * 0.55 * j * dt; + py[i] -= fall * j * dt; + pz[i] += w.z * 0.55 * j * dt; + + // wrap the box around the camera instead of respawning — no bookkeeping, + // and the rain is always exactly where the player is looking + let d = px[i] - camPos.x; + if (d > half) px[i] -= half * 2; else if (d < -half) px[i] += half * 2; + d = pz[i] - camPos.z; + if (d > half) pz[i] -= half * 2; else if (d < -half) pz[i] += half * 2; + if (py[i] < groundY) py[i] += height; + else if (py[i] > top) py[i] -= height; + + m.elements[12] = px[i]; + m.elements[13] = py[i]; + m.elements[14] = pz[i]; + mesh.setMatrixAt(i, m); + } + mesh.instanceMatrix.needsUpdate = true; + }, + dispose() { geo.dispose(); mat.dispose(); }, + }; +} + +// ------------------------------------------------------------ cloud dome +function cloudTexture(size = 256, seed = 7) { + const cv = document.createElement('canvas'); + cv.width = cv.height = size; + const ctx = cv.getContext('2d'); + const img = ctx.createImageData(size, size); + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + // fbm, tiled by sampling a torus-ish domain so the scroll never seams + let n = 0, amp = 0.5, f = 4; + for (let o = 0; o < 4; o++) { + n += amp * valueNoise2((x / size) * f, (y / size) * f, seed + o * 977); + amp *= 0.5; f *= 2.07; + } + const v = clamp01((n - 0.28) * 2.2); + const i = (y * size + x) * 4; + const shade = 150 + v * 70; + img.data[i] = shade; img.data[i + 1] = shade; img.data[i + 2] = shade + 12; + img.data[i + 3] = v * 235; + } + } + ctx.putImageData(img, 0, 0); + const tex = new THREE.CanvasTexture(cv); + tex.wrapS = tex.wrapT = THREE.RepeatWrapping; + tex.repeat.set(3, 2); + return tex; +} + +// ---------------------------------------------------------------- audio +// Synthesized layers. WebAudio won't start until a gesture (browser rule), so +// everything is built lazily on unlock() and silently absent before it. +function createAudio(seed = 1) { + let ctx = null, master = null; + let windGain, windFilter, windHowl, howlGain; + let rainGain, rainFilter; + let gustGain, gustFilter; + let noiseBuf = null; + let creakNext = 0, flogNext = 0; + let started = false; + + function noiseBuffer(c) { + const len = c.sampleRate * 4; + const buf = c.createBuffer(1, len, c.sampleRate); + const d = buf.getChannelData(0); + const rand = rng(seed ^ 0x0157); + let last = 0; + for (let i = 0; i < len; i++) { + const white = rand() * 2 - 1; + last = (last + 0.02 * white) / 1.02; // brown-ish: weight to the low end + d[i] = last * 3.5; + } + return buf; + } + + function loop(buf, dest, filter) { + const src = ctx.createBufferSource(); + src.buffer = buf; src.loop = true; + src.connect(filter); filter.connect(dest); + src.start(); + return src; + } + + return { + get ready() { return started; }, + + /** Call from the first click/keydown. Safe to call repeatedly. */ + unlock() { + if (started) return; + const AC = window.AudioContext || window.webkitAudioContext; + if (!AC) return; + ctx = new AC(); + if (ctx.state === 'suspended') ctx.resume(); + master = ctx.createGain(); + master.gain.value = 0.55; + master.connect(ctx.destination); + noiseBuf = noiseBuffer(ctx); + + // wind bed: brown noise through a lowpass that opens as it blows harder + windGain = ctx.createGain(); windGain.gain.value = 0; + windFilter = ctx.createBiquadFilter(); + windFilter.type = 'lowpass'; windFilter.frequency.value = 400; + windGain.connect(master); + loop(noiseBuf, windGain, windFilter); + + // howl: a resonant band on top — this is the bit that sounds like a gale + howlGain = ctx.createGain(); howlGain.gain.value = 0; + windHowl = ctx.createBiquadFilter(); + windHowl.type = 'bandpass'; windHowl.frequency.value = 500; windHowl.Q.value = 6; + howlGain.connect(master); + loop(noiseBuf, howlGain, windHowl); + + rainGain = ctx.createGain(); rainGain.gain.value = 0; + rainFilter = ctx.createBiquadFilter(); + rainFilter.type = 'highpass'; rainFilter.frequency.value = 1800; + rainGain.connect(master); + loop(noiseBuf, rainGain, rainFilter); + + gustGain = ctx.createGain(); gustGain.gain.value = 0; + gustFilter = ctx.createBiquadFilter(); + gustFilter.type = 'bandpass'; gustFilter.frequency.value = 300; gustFilter.Q.value = 2.5; + gustGain.connect(master); + loop(noiseBuf, gustGain, gustFilter); + + started = true; + }, + + /** One-shot filtered noise burst — the workhorse for creak/flog/thunder. */ + burst({ freq, q, gain, attack, decay, type = 'bandpass' }) { + if (!started) return; + const now = ctx.currentTime; + const src = ctx.createBufferSource(); + src.buffer = noiseBuf; + src.loop = true; + const f = ctx.createBiquadFilter(); + f.type = type; f.frequency.value = freq; f.Q.value = q ?? 4; + const g = ctx.createGain(); + g.gain.setValueAtTime(0.0001, now); + g.gain.exponentialRampToValueAtTime(Math.max(0.0002, gain), now + attack); + g.gain.exponentialRampToValueAtTime(0.0001, now + attack + decay); + src.connect(f); f.connect(g); g.connect(master); + src.start(now); + src.stop(now + attack + decay + 0.05); + }, + + /** @param {number} speed m/s @param {number} rain 0..1 */ + setLevels(speed, rain) { + if (!started) return; + const now = ctx.currentTime; + const s = clamp01(speed / 32); + // gain and brightness both climb — a 30 m/s wind isn't just a louder 5 m/s one + windGain.gain.setTargetAtTime(0.05 + s * 0.5, now, 0.15); + windFilter.frequency.setTargetAtTime(220 + s * 900, now, 0.2); + howlGain.gain.setTargetAtTime(s * s * 0.28, now, 0.2); + windHowl.frequency.setTargetAtTime(320 + s * 700, now, 0.25); + rainGain.gain.setTargetAtTime(rain * 0.34, now, 0.3); + rainFilter.frequency.setTargetAtTime(1500 + rain * 900, now, 0.3); + }, + + /** Telegraph cue: you hear it coming before you feel it. */ + whoosh(power, eta) { + if (!started) return; + const now = ctx.currentTime; + const p = clamp01(power / 18); + gustGain.gain.cancelScheduledValues(now); + gustGain.gain.setValueAtTime(gustGain.gain.value, now); + gustGain.gain.linearRampToValueAtTime(0.05 + p * 0.3, now + Math.max(0.05, eta)); + gustGain.gain.linearRampToValueAtTime(0.0001, now + Math.max(0.05, eta) + 2.2); + gustFilter.frequency.cancelScheduledValues(now); + gustFilter.frequency.setValueAtTime(220, now); + gustFilter.frequency.linearRampToValueAtTime(240 + p * 700, now + Math.max(0.05, eta) + 0.8); + }, + + /** Rope creak — rate and pitch both ride the worst corner. */ + creak(dt, loadFrac) { + if (!started || loadFrac < 0.35) return; + creakNext -= dt; + if (creakNext > 0) return; + creakNext = lerp(1.1, 0.16, clamp01((loadFrac - 0.35) / 0.65)); + this.burst({ + freq: 180 + loadFrac * 420, q: 9, + gain: 0.05 + loadFrac * 0.22, attack: 0.012, decay: 0.16, + }); + }, + + /** Freed corner: canvas cracking itself to pieces. */ + flog(dt, speed) { + if (!started) return; + flogNext -= dt; + if (flogNext > 0) return; + flogNext = Math.max(0.09, 0.5 - speed * 0.011); + this.burst({ freq: 900 + speed * 26, q: 1.2, gain: 0.1 + clamp01(speed / 30) * 0.3, attack: 0.005, decay: 0.1 }); + }, + + thunder(power) { + if (!started) return; + this.burst({ type: 'lowpass', freq: 90 + power * 60, q: 0.7, gain: 0.25 + power * 0.5, attack: 0.06, decay: 2.6 + power * 1.6 }); + }, + + dispose() { if (ctx) ctx.close(); started = false; }, + }; +} + +// ---------------------------------------------------------------- skyfx +/** + * @param {object} o + * @param {THREE.Scene} o.scene + * @param {THREE.Camera} o.camera + * @param {object} o.wind from weather.js + * @param {THREE.Light} [o.sun] Lane A's directional light — we dim it + * @param {THREE.Light} [o.hemi] Lane A's hemisphere light + * @param {boolean} [o.night] storm_02 is a wild NIGHT + * @param {function} [o.onEvent] (text) HUD ticker + */ +export function createSkyFx(o = {}) { + const { scene, camera, wind } = o; + const sun = o.sun || null; + const hemi = o.hemi || null; + const def = (wind && wind.def) || {}; + const skyDef = def.sky || {}; + const darkness = skyDef.darkness ?? 0.7; + const scroll = skyDef.cloudScroll ?? 0.06; + const target = (o.night ?? darkness > 0.6) ? NIGHT_SKY : STORM_SKY; + + const rain = createRain({ groundY: o.groundY ?? 0 }); + if (scene) scene.add(rain.mesh); + + const audio = createAudio((wind && wind.seed) || 1); + + // cloud dome rides the camera so it can't clip the far plane whatever Lane A set + const domeTex = cloudTexture(256, ((wind && wind.seed) || 7) & 0xffff); + const dome = new THREE.Mesh( + new THREE.SphereGeometry(180, 24, 16), + new THREE.MeshBasicMaterial({ + map: domeTex, side: THREE.BackSide, transparent: true, + depthWrite: false, fog: false, opacity: 0, + }), + ); + dome.renderOrder = -1; + if (scene) scene.add(dome); + + // remember what world.js handed us, so dispose() puts it back exactly + const original = { + background: scene ? scene.background : null, + fog: scene ? scene.fog : null, + sun: sun ? sun.intensity : 0, + hemi: hemi ? hemi.intensity : 0, + }; + const baseSky = (scene && scene.background && scene.background.isColor) + ? scene.background.clone() : CALM_SKY.clone(); + + const skyCol = baseSky.clone(); + if (scene) { + scene.background = skyCol; + if (!scene.fog) scene.fog = new THREE.Fog(skyCol.getHex(), 30, 140); + } + + let flash = 0; // decaying lightning brightness + let flashQueue = []; // {at, power} — double-strike + let lastTelegraph = null; + const camPos = new THREE.Vector3(); + const w = new THREE.Vector3(); + + const fx = { + rain, audio, dome, + get flash() { return flash; }, + + /** Wire to the first click/keydown — browsers won't start audio otherwise. */ + unlockAudio() { audio.unlock(); }, + + /** + * @param {number} dt + * @param {number} t storm time + * @param {object} [world] {sail} — duck-typed, for creak/flog + */ + step(dt, t, world = {}) { + if (!camera) return; + camera.getWorldPosition(camPos); + wind.sample(camPos, t, w); + const speed = Math.hypot(w.x, w.z); + const intensity = wind.rainAt(t); + const storminess = clamp01(Math.max(intensity, speed / 26)); + + // --- events: lightning + the ticker --- + for (const ev of wind.eventsBetween(t - dt, t)) { + if (ev.type === 'lightning') { + const p = ev.power ?? 0.7; + flashQueue.push({ at: t, power: p }); + flashQueue.push({ at: t + 0.09 + p * 0.07, power: p * 0.55 }); // the stutter + // thunder lags the flash — distance you can hear + const delay = (ev.distance ?? 1.2) * 0.9; + flashQueue.push({ at: t + delay, power: 0, thunder: p }); + } else if (ev.type === 'windchange' && ev.text && o.onEvent) { + o.onEvent(ev.text); + } + } + for (let i = flashQueue.length - 1; i >= 0; i--) { + if (flashQueue[i].at <= t) { + const f = flashQueue[i]; + if (f.thunder) audio.thunder(f.thunder); + else flash = Math.max(flash, f.power); + flashQueue.splice(i, 1); + } + } + flash *= Math.max(0, 1 - dt * 7); + if (flash < 0.004) flash = 0; + + // --- sky --- + skyCol.copy(baseSky).lerp(target, storminess * darkness); + if (flash > 0) skyCol.lerp(new THREE.Color(0xdfe8ff), Math.min(0.85, flash)); + if (scene) { + if (scene.fog) { + scene.fog.color.copy(skyCol); + scene.fog.near = lerp(40, 8, storminess); + scene.fog.far = lerp(160, 55, storminess); + } + } + if (sun) sun.intensity = lerp(original.sun, original.sun * 0.12, storminess * darkness) + flash * 2.2; + if (hemi) hemi.intensity = lerp(original.hemi, original.hemi * 0.3, storminess * darkness) + flash * 1.2; + + dome.position.copy(camPos); + dome.material.opacity = storminess * 0.85; + 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; + + // --- rain --- + rain.step(dt, camPos, w, intensity); + + // --- audio --- + audio.setLevels(speed, intensity); + const tg = wind.gustTelegraph(t); + if (tg && tg !== lastTelegraph) { + // fires once per gust, right as the telegraph opens + if (!lastTelegraph || Math.abs(tg.eta - (lastTelegraph.eta - dt)) > 0.05) { + audio.whoosh(tg.power, tg.eta); + } + } + lastTelegraph = tg; + + const sail = world.sail; + if (sail && sail.corners) { + let worst = 0, broken = false; + for (const c of sail.corners) { + if (c.broken) { broken = true; continue; } + const rating = c.hw && c.hw.rating ? c.hw.rating : 1; + worst = Math.max(worst, c.load / rating); + } + audio.creak(dt, worst); + if (broken) audio.flog(dt, speed); + } + }, + + /** Hand Lane A's scene back exactly as we found it. */ + dispose() { + if (scene) { + scene.remove(rain.mesh); + scene.remove(dome); + scene.background = original.background; + scene.fog = original.fog; + } + if (sun) sun.intensity = original.sun; + if (hemi) hemi.intensity = original.hemi; + rain.dispose(); + dome.geometry.dispose(); + dome.material.dispose(); + domeTex.dispose(); + audio.dispose(); + }, + }; + + return fx; +} diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index 872bf59..a74f905 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -1,25 +1,81 @@ /** * Lane C selftests — wind field, storm timelines, rain, debris. * - * Lane C owns this file. Lane A pre-created it so adding your suite never means - * editing selftest.html. + * The asserts live in weather.selftest.js as a plain case list, because they + * import nothing but weather.core.js (no THREE, no DOM) and so also run under + * `node web/world/js/tests/run-node.mjs` — a one-second loop for tuning a storm + * curve, instead of a browser round trip. This file is the browser half: it + * feeds them the fetched storms and adds the checks that need the real + * weather.js adapter (contract shape, THREE.Vector3 out). * - * The asserts PLAN3D §5-C asks for, once weather.js lands: - * 1. gust telegraph lead ≥ 1.2 s — the promise the storm rests on. Lane A's - * suite already asserts this against the stub wind (see a.test.js, - * 'gust telegraph always gives at least 1.2 s of warning'); lift that test - * onto the real wind.sample and delete the stub version's claim to it. - * 2. wind.sample continuity — no frame-to-frame jump beyond a sane bound, or - * the cloth explodes and it looks like Lane B's bug. - * 3. storm JSON schema validation for everything in data/storms/. - * - * Useful imports: - * import { FIXED_DT, STORM_LEN } from '../contracts.js'; - * import { assert, assertLess, fixedLoop } from '../testkit.js'; - * wind.sample(pos, t) must be pure in (pos, t): selftest samples out of order. + * Lane A: a.test.js's 'gust telegraph always gives at least 1.2 s of warning' + * is lifted onto the real wind below, per your note — the stub's claim to it is + * now redundant and yours to drop whenever suits. Logged in THREADS. */ +import * as THREE from '../../vendor/three.module.js'; +import { assert, fixedLoop } from '../testkit.js'; +import { FIXED_DT, checkContract } from '../contracts.js'; +import { loadStorm, createWind } from '../weather.js'; +import { weatherCases } from './weather.selftest.js'; + +const STORMS = ['storm_01_gentle', 'storm_02_wildnight']; + /** @param {import('../testkit.js').Suite} t */ -export default function run(t) { - t.skip('weather.js not landed yet — Lane C'); +export default async function run(t) { + const storms = {}; + for (const name of STORMS) storms[name] = await loadStorm(name); + + // --- the shared case list (also runs headless in node) --- + const { cases } = weatherCases(storms); + for (const c of cases) t.test(c.name, c.fn); + + // --- the bits that need the THREE adapter, not just the core --- + + t.test('weather.js satisfies the wind contract', () => { + const wind = createWind(storms.storm_02_wildnight); + const problems = checkContract('wind', wind); + assert(problems.length === 0, problems.join('; ')); + }); + + t.test('sample() returns a Vector3 and honours an out param', () => { + const wind = createWind(storms.storm_02_wildnight); + const pos = new THREE.Vector3(3, 0, -2); + const a = wind.sample(pos, 12.5); + assert(a instanceof THREE.Vector3, 'sample did not return a THREE.Vector3'); + assert(a.y === 0, `wind should be horizontal, got y=${a.y}`); + // out param must not change the answer, only where it lands + const out = new THREE.Vector3(); + const b = wind.sample(pos, 12.5, out); + assert(b === out, 'out param was ignored'); + assert(a.x === b.x && a.z === b.z, 'out param changed the result'); + }); + + // Lifted from a.test.js onto the real wind (Lane A's note in this file's + // header). This is the promise the whole storm rests on: the player can + // always react. Deliberately walks the public surface, not the core. + t.test('gust telegraph always gives at least 1.2 s of warning', () => { + const wind = createWind(storms.storm_02_wildnight); + let had = false, edges = 0; + fixedLoop(wind.duration, FIXED_DT, (dt, time) => { + const tel = wind.gustTelegraph(time); + if (tel && !had) { + edges++; + assert(tel.eta >= 1.2, `telegraph appeared with only ${tel.eta.toFixed(2)}s of warning`); + assert(Number.isFinite(tel.power) && tel.power > 0, 'telegraph carried no power'); + assert(Number.isFinite(tel.dir), 'telegraph carried no direction'); + } + had = !!tel; + }); + assert(edges >= 5, `only ${edges} gusts telegraphed in a ${wind.duration}s storm — too quiet to test`); + }); + + t.test('every storm in data/storms/ loads and validates', () => { + // loadStorm throws on invalid, so reaching here with all of them is the pass + assert(Object.keys(storms).length === STORMS.length, 'a storm failed to load'); + for (const [name, def] of Object.entries(storms)) { + assert(def.duration > 0, `${name} has no duration`); + assert(Array.isArray(def.baseCurve), `${name} has no baseCurve`); + } + }); } diff --git a/web/world/js/tests/weather.selftest.js b/web/world/js/tests/weather.selftest.js index df31001..e810886 100644 --- a/web/world/js/tests/weather.selftest.js +++ b/web/world/js/tests/weather.selftest.js @@ -27,17 +27,19 @@ const TREE_SHELTERS = [ { x: 8, z: -5, radius: 2.5, strength: 0.4, length: 12 }, ]; -export function runWeatherSuite(storms) { - const results = []; +/** + * The case list, defined once and run by two harnesses: run-node.mjs for a + * one-second tuning loop, and js/tests/c.test.js for Lane A's selftest.html at + * merge time. Cases are sync, matching testkit's Suite.test(label, fn). + * + * @param {Object} storms parsed storm defs, keyed by name + * @returns {{cases: {name:string, fn:() => void}[], metrics: object}} + * `metrics` fills in as the cases run — read it after, not before. + */ +export function weatherCases(storms) { + const cases = []; const metrics = {}; - const test = (name, fn) => { - try { - fn(); - results.push({ name, ok: true }); - } catch (e) { - results.push({ name, ok: false, err: e.message }); - } - }; + const test = (name, fn) => cases.push({ name, fn }); const assert = (cond, msg) => { if (!cond) throw new Error(msg); }; const defs = Object.entries(storms); @@ -277,6 +279,20 @@ export function runWeatherSuite(storms) { assert(Math.abs(luvS - luvB) < 1e-9, 'upwind side is being sheltered — shadow is pointing the wrong way'); }); + return { cases, metrics }; +} + +/** Run every case and collect results. Used by run-node.mjs. */ +export function runWeatherSuite(storms) { + const { cases, metrics } = weatherCases(storms); + const results = cases.map((c) => { + try { + c.fn(); + return { name: c.name, ok: true }; + } catch (e) { + return { name: c.name, ok: false, err: e.message }; + } + }); const pass = results.filter((r) => r.ok).length; return { suite: 'weather', pass, fail: results.length - pass, results, metrics }; } diff --git a/web/world/js/weather.core.js b/web/world/js/weather.core.js index dafcd55..a2f5a44 100644 --- a/web/world/js/weather.core.js +++ b/web/world/js/weather.core.js @@ -59,7 +59,7 @@ function hash2(ix, iz, seed) { const smooth = (f) => f * f * (3 - 2 * f); /** Value noise, 0..1, C1-continuous (smoothstep interp) so wind never steps. */ -function valueNoise2(x, z, seed) { +export function valueNoise2(x, z, seed) { const ix = Math.floor(x), iz = Math.floor(z); const ux = smooth(x - ix), uz = smooth(z - iz); const a = hash2(ix, iz, seed), b = hash2(ix + 1, iz, seed); diff --git a/web/world/js/weather.js b/web/world/js/weather.js index 4f2480f..422395c 100644 --- a/web/world/js/weather.js +++ b/web/world/js/weather.js @@ -9,12 +9,15 @@ // the THREE adapter + storm loading, so the sim stays node-testable and the // determinism rule can't be broken by accident. -import * as THREE from 'three'; +import * as THREE from '../vendor/three.module.js'; import { createWindField, validateStorm, GUST } from './weather.core.js'; export { GUST, validateStorm }; -const STORM_DIR = '/world/data/storms'; +// Resolved against this module, not the server root: server.py serves the repo +// root (so the 2D prototype stays reachable), but the demo bench serves web/. +// import.meta.url is right under both, and under whatever Lane A does next. +const STORM_DIR = new URL('../data/storms', import.meta.url).href; /** Fetch + validate a storm def. Throws loud on bad data — storms are content. */ export async function loadStorm(name, dir = STORM_DIR) { diff --git a/web/world/weather_demo.html b/web/world/weather_demo.html new file mode 100644 index 0000000..e06cb2e --- /dev/null +++ b/web/world/weather_demo.html @@ -0,0 +1,255 @@ + + + + +SHADES — Lane C — weather bench + + + + +
+
+ Lane C bench. Graybox stand-in for Lane A's yard — this exists to drive + weather.js / skyfx.js / debris.js before M0 lands. The sail here is a MOCK + (Lane B owns the real one); it's a bare node grid so debris impulse is visible. +

drag = orbit · click = start audio +
+
+ + + + From 11f27c493ca4d43805f8bd23be3124b7f26eb9a4 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Thu, 16 Jul 2026 21:50:41 +1000 Subject: [PATCH 3/5] Fix debris ground friction, cloud seam and yard coords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs the bench found once it was actually running a storm: - Debris was glued to the floor. The scrape `v *= 0.86` ran every frame while grounded, which is 0.86^60 per second — it pinned a 9 kg crate at 0.7 m/s in a 19 m/s wind. Friction now applies on impact, with dt-scaled rolling friction while resting. A crate crosses the whole yard at ~6 m/s. - Debris slid instead of tumbling: wind is horizontal, so once down there was no vertical force at all and it skated at constant height. Added tumbling lift that flips sign as it rolls — bins hop now. - The cloud dome had a dead straight seam across the sky. The fbm claimed to tile and didn't; now each octave wraps at its own integer period. Also: shelters and the bench now use Lane A's landed yard coords (t1 -9,2 / t2 8,-2, gardenBed 1,2) instead of my guesses, so shelter tuning means something. Verified in-browser against a real storm: crate crosses the yard and the t=74 bin spawns from storm JSON; sail node shoved 0.32 m; a 14 kg bin at 20 m/s knocks the player down and a tub drifting at 0.3 m/s correctly does not; lightning peaks 0.88. Lane A's selftest: 37 pass / 3 skip. Co-Authored-By: Claude Opus 4.8 --- web/world/js/debris.js | 32 ++++++++++++++++++++++---- web/world/js/skyfx.js | 8 ++++--- web/world/js/tests/weather.selftest.js | 5 ++-- web/world/js/weather.core.js | 24 +++++++++++++++---- web/world/weather_demo.html | 16 +++++++++---- 5 files changed, 68 insertions(+), 17 deletions(-) diff --git a/web/world/js/debris.js b/web/world/js/debris.js index 79261cc..bd8f1e9 100644 --- a/web/world/js/debris.js +++ b/web/world/js/debris.js @@ -13,6 +13,9 @@ import { rng } from './contracts.js'; const RHO = 1.2; // air density, kg/m³ const GRAVITY = -9.81; +// Deceleration while resting on the ground, m/s². Drag in a 19 m/s wind gives a +// 9 kg crate ~7 m/s², so it still skitters downwind — which is the whole point. +const GROUND_FRICTION = 3.5; // Fallback specs for Lane E's debris set (3D-STORE crates/tubs). Radius is the // collision sphere, not the render bounds — a crate is boxy, but a sphere is @@ -85,6 +88,7 @@ export function createDebris(o = {}) { // spin axis is arbitrary but seeded; rate scales with airspeed in step() sx: rand() * 2 - 1, sy: rand() * 2 - 1, sz: rand() * 2 - 1, spin: 0, + phase: rand() * 6.283, // so two crates don't hop in lockstep r: spec.r, mass: spec.mass, cd: spec.cd, area: Math.PI * spec.r * spec.r, hitPlayer: false, @@ -140,17 +144,37 @@ export function createDebris(o = {}) { p.vy += ry * k * dt + GRAVITY * dt; p.vz += rz * k * dt; + // A tumbling bluff body doesn't just get shoved, it gets picked up: lift + // flips sign as it rolls, which is why a bin HOPS across a yard instead + // of sliding. Wind is horizontal (weather.js keeps y=0), so without this + // there is no vertical force at all once it's down and it just skates. + p.vy += (0.5 * rel * rel * Math.sin(p.spin * 1.7 + p.phase) / p.mass) * dt; + p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt; - // ground: bounce, shed sideways speed, keep skittering + // ground const floor = groundAt(p.x, p.z) + p.r; if (p.y <= floor) { p.y = floor; - if (p.vy < 0) p.vy = -p.vy * 0.32; // dead-ish bounce, it's a plastic tub - if (Math.abs(p.vy) < 0.35) p.vy = 0; - p.vx *= 0.86; p.vz *= 0.86; // scrape + if (p.vy < -0.5) { + // a real impact: bounce, and lose some tangential speed to the hit + p.vy = -p.vy * 0.32; // dead-ish, it's a plastic tub + p.vx *= 0.72; p.vz *= 0.72; + } else { + if (p.vy < 0) p.vy = 0; + // Resting: rolling friction as a dt-scaled DECELERATION, not a + // per-frame multiplier. `v *= 0.86` every frame is 0.86^60 per + // second — that isn't scrape, it's glue, and it pinned a 9 kg crate + // at 0.7 m/s in a 19 m/s wind. + const sp = Math.hypot(p.vx, p.vz); + if (sp > 1e-4) { + const drop = Math.min(sp, GROUND_FRICTION * dt); + p.vx -= (p.vx / sp) * drop; + p.vz -= (p.vz / sp) * drop; + } + } } // tumble rate follows airspeed — becalmed debris shouldn't keep spinning diff --git a/web/world/js/skyfx.js b/web/world/js/skyfx.js index d79864b..2c63773 100644 --- a/web/world/js/skyfx.js +++ b/web/world/js/skyfx.js @@ -110,11 +110,13 @@ function cloudTexture(size = 256, seed = 7) { const img = ctx.createImageData(size, size); for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { - // fbm, tiled by sampling a torus-ish domain so the scroll never seams + // fbm at integer frequencies, each octave wrapped at its own period, so + // the texture tiles: it's set to repeat(3,2) and it scrolls forever, and + // an unwrapped octave puts a dead straight seam across the sky. let n = 0, amp = 0.5, f = 4; for (let o = 0; o < 4; o++) { - n += amp * valueNoise2((x / size) * f, (y / size) * f, seed + o * 977); - amp *= 0.5; f *= 2.07; + n += amp * valueNoise2((x / size) * f, (y / size) * f, seed + o * 977, f); + amp *= 0.5; f *= 2; } const v = clamp01((n - 0.28) * 2.2); const i = (y * size + x) * 4; diff --git a/web/world/js/tests/weather.selftest.js b/web/world/js/tests/weather.selftest.js index e810886..29ca7d6 100644 --- a/web/world/js/tests/weather.selftest.js +++ b/web/world/js/tests/weather.selftest.js @@ -22,9 +22,10 @@ const PROBES = [ { x: -14, z: 9 }, { x: 14, z: 9 }, { x: 5, z: -3 }, ]; +// t1 and t2 from Lane A's landed yard (THREADS: "yard layout is now FACT") const TREE_SHELTERS = [ - { x: -9, z: 4, radius: 3, strength: 0.45, length: 14 }, - { x: 8, z: -5, radius: 2.5, strength: 0.4, length: 12 }, + { x: -9, z: 2, radius: 3, strength: 0.45, length: 14 }, + { x: 8, z: -2, radius: 2.5, strength: 0.4, length: 12 }, ]; /** diff --git a/web/world/js/weather.core.js b/web/world/js/weather.core.js index a2f5a44..6d4b4ed 100644 --- a/web/world/js/weather.core.js +++ b/web/world/js/weather.core.js @@ -58,12 +58,28 @@ function hash2(ix, iz, seed) { const smooth = (f) => f * f * (3 - 2 * f); -/** Value noise, 0..1, C1-continuous (smoothstep interp) so wind never steps. */ -export function valueNoise2(x, z, seed) { +/** + * Value noise, 0..1, C1-continuous (smoothstep interp) so wind never steps. + * + * @param {number} [period] Wrap the lattice at this many cells, making the noise + * tile seamlessly over [0, period). The wind doesn't want this (the yard would + * repeat); a scrolling cloud texture does, or every wrap boundary is a visible + * straight edge in the sky. Pass an integer that matches your frequency. + */ +export function valueNoise2(x, z, seed, period = 0) { const ix = Math.floor(x), iz = Math.floor(z); const ux = smooth(x - ix), uz = smooth(z - iz); - const a = hash2(ix, iz, seed), b = hash2(ix + 1, iz, seed); - const c = hash2(ix, iz + 1, seed), d = hash2(ix + 1, iz + 1, seed); + // branch, not a closure: this is the wind's hot path (the cloth alone samples + // it thousands of times a second) and a per-call allocation would show up. + let x0 = ix, x1 = ix + 1, z0 = iz, z1 = iz + 1; + if (period > 0) { + x0 = ((x0 % period) + period) % period; + x1 = ((x1 % period) + period) % period; + z0 = ((z0 % period) + period) % period; + z1 = ((z1 % period) + period) % period; + } + const a = hash2(x0, z0, seed), b = hash2(x1, z0, seed); + const c = hash2(x0, z1, seed), d = hash2(x1, z1, seed); return (a + (b - a) * ux) * (1 - uz) + (c + (d - c) * ux) * uz; } diff --git a/web/world/weather_demo.html b/web/world/weather_demo.html index e06cb2e..8a67a0e 100644 --- a/web/world/weather_demo.html +++ b/web/world/weather_demo.html @@ -60,7 +60,8 @@ ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; scene.add(ground); -const TREES = [{ x: -9, z: 4 }, { x: 8, z: -5 }]; +// Lane A's landed yard (THREADS): t1 (-9,2), t2 (8,-2), house edge at z=-9.9 +const TREES = [{ x: -9, z: 2 }, { x: 8, z: -2 }]; for (const tr of TREES) { const trunk = new THREE.Mesh( new THREE.CylinderGeometry(0.2, 0.28, 4, 8), @@ -77,13 +78,20 @@ for (const tr of TREES) { canopy.castShadow = true; scene.add(canopy); } -// house edge along north, for scale +// house edge along north (-Z), for scale const house = new THREE.Mesh( new THREE.BoxGeometry(30, 3.2, 1), new THREE.MeshStandardMaterial({ color: 0x8a8f96 }), ); -house.position.set(0, 1.6, -10.5); +house.position.set(0, 1.6, -10.4); scene.add(house); +// the thing you're protecting — Lane A's gardenBed rect +const bed = new THREE.Mesh( + new THREE.BoxGeometry(6, 0.25, 4), + new THREE.MeshStandardMaterial({ color: 0x6b4a2f }), +); +bed.position.set(1, 0.12, 2); +scene.add(bed); // 1.7 m reference person const ref = new THREE.Mesh( new THREE.CapsuleGeometry(0.25, 1.2, 4, 8), @@ -174,7 +182,7 @@ addEventListener('wheel', (e) => { dist = Math.min(60, Math.max(8, dist + e.delt function resize() { const w = innerWidth, h = innerHeight; - renderer.setSize(w, h, false); + renderer.setSize(w, h); camera.aspect = w / h; camera.updateProjectionMatrix(); } From 31a887bf7579ac8b9f244bedf601df66dca80d4f Mon Sep 17 00:00:00 2001 From: m3ultra Date: Thu, 16 Jul 2026 21:53:00 +1000 Subject: [PATCH 4/5] Log Lane C landing, contract note and per-lane asks in THREADS Also corrects the worktree-collision attribution: the checkout that moved HEAD off lane/a was mine, not Lane D's. Co-Authored-By: Claude Opus 4.8 --- THREADS.md | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/THREADS.md b/THREADS.md index 51ebb94..88e1aec 100644 --- a/THREADS.md +++ b/THREADS.md @@ -87,3 +87,95 @@ Format: `[lane letter] YYYY-MM-DD — note` §0's clone path is what people are actually using. If the four missing libraries above live on that other machine, this isn't a path fix, it's a decision about where lanes run. Flagging rather than guessing. + +[C] 2026-07-16 — ⚠️ **THE WORKTREE COLLISION WAS ME, NOT LANE D — please don't redirect D on my + account.** Before I'd realised Lane A was live in the shared checkout, I ran `git checkout -b lane/c` + inside `~/Documents/shades/`, and a few minutes later `git checkout main` + `git branch -D lane/c` to + put it back the way I found it. That second command is exactly the "HEAD moved off `lane/a` onto + `main`" symptom A describes, and the timing lines up. Nothing was lost — the delete reported + `was 8338c0f`, i.e. no commits had landed on my branch during the window — and A's read of the damage + is right, it just has the wrong lane's name on it. Lane C has been on `~/Documents/shades-laneC/` + since. (Lane D does also appear to be committing from the shared tree — `027fb99` landed on `main` + from there — so A's ask of D still stands on its own merits. I'm only disowning the checkout.) + Apologies for the noise; PLAN3D §0's clone rule earns its keep. + +[C] 2026-07-16 — **LANE C LANDED on `lane/c` — weather.js, skyfx.js, debris.js, 2 storms.** Rebased on + M0; `c.test.js` is live (19 asserts) and Lane A's selftest reads **37 pass / 3 skip**. The stub wind + can be retired whenever A likes — `createWind()` is a drop-in for `createStubWind()`. + · `wind.sample(pos,t)` / `wind.gustTelegraph(t)` per contract; `checkContract('wind', …)` clean. + · Gusts are a **precomputed timeline**, not an integrator. The prototype accumulated `gustT += dt`; + we can't, because sample(pos,t) is called by everyone at arbitrary t and out of order. Same + envelope though — telegraph 1.5 / ramp 0.8 / hold 1.7 / fade 1.0, straight off the prototype. + · Storms are **data**: `data/storms/*.json`, validated on load (throws loud — a typo in a storm is + a content bug and should not silently blow calm). Tune curves without touching code. + +[C] 2026-07-16 — **CONTRACT — one addition, backward compatible.** `wind.sample(pos, t, out?)` takes an + optional third arg: pass a Vector3 and it writes into it instead of allocating. Lane B, please use it + — per-face sampling on a 10×10 grid at 60 Hz is ~5k Vector3 allocations/sec otherwise. Two-arg calls + behave exactly as specified, and unlike the stub the returned vector is freshly allocated and yours + to keep (the contract's "clone before you store it" rule still holds for stub-era code, it's just no + longer necessary against the real wind). + +[C] 2026-07-16 — **ASKS, one per lane. All degrade silently — nothing here blocks a merge.** + · **Lane A** — trees don't shelter anything until you tell me where they are: + `wind.setSheltersFromTrees(world.anchors.filter(a => a.type === 'tree'))` after the yard builds. + Unset = no wind shadows, which is just a flatter yard. Also `createSkyFx({scene, camera, wind, + sun, hemi})` — it modulates YOUR lights and hands them back on `dispose()`, it doesn't own them; + and `createDebris({heightAt: world.heightAt})` so debris bounces off your terrain, not y=0. + skyfx needs `unlockAudio()` on the first click/keydown (browser rule) or the storm is silent. + · **Lane B** — ❓ **the debris-vs-sail seam is your call.** I have the impulse maths but not your + nodes: contracts exposes `sailRig.corners`, not the cloth. Two options — (a) I keep driving it and + you expose `sailRig.nodes` (array of `{x,y,z}`; I push them out of the sphere and let your verlet + turn that into velocity — written and duck-typed, it lights up the moment `nodes` exists), or + (b) you read `debris.pieces` (`{x,y,z,vx,vy,vz,r,mass}`) in `sail.step` and do it yourself, since + you own the integrator. I'd take (b) if you want the momentum bookkeeping in one place. Say which + and I'll match it. + · **Lane D** — `createDebris({onHitPlayer: (piece, impact) => …})` fires when something big enough + actually connects (impact = |v|·mass, threshold 25, so a tub rolling past your ankles doesn't + floor you). The knockdown state machine is yours per §5-D.3; I only report the hit. + · **Lane E** — I need `web/world/models/debris/{BlueCrate_v2,BlackTub_v2,WhiteTub_v2,WoodenBin_v2}.glb` + (your §5-E.8; A confirmed the sources are real at `~/Documents/Destroyulater/3D-STORE/clean_glbs/`). + Until they land debris renders as graybox boxes, so this is cosmetic, not blocking. + `debris.setModels({name: Object3D})`. Collision is one sphere per piece — radii in `MODEL_SPEC` in + debris.js assume a ~0.6 m crate; if you scale them differently, tell me rather than fighting it. + +[C] 2026-07-16 — **Lane B: tune cloth ρ against these, not against the stub.** Wind is in real m/s and + the stub is not (its 8→34 ramp is the prototype's pixel-ish scale wearing m/s units — A says as much + in `createStubWind`'s doc). `storm_01_gentle`: sustained peaks 6.5, worst gust 11.3 m/s (41 km/h) — + the sail should breathe and nothing should break. `storm_02_wildnight`: sustained peaks 20 (72 km/h), + worst gust 32.3 (116 km/h, BOM 'destructive'), southerly change swings 2.09 rad at t=55–59 with the + peak landing just after it. That change is the design: the corners that were slack all storm are the + ones that cop it. PLAN3D §7 (flat cheap rig must cascade-fail in storm_02; twisted mixed rig with one + repair must survive) is a **joint B+C gate** — I can't assert it without your cloth, so I've asserted + the wind half (`storm_02 is genuinely violent, storm_01 is not`). Ping me when sail.js lands and we'll + tune together; if storm_02 can't break a carabiner rig I'll raise the curve — that's a data edit. + +[C] 2026-07-16 — Notes on my own files, so nobody trips over them: + · `weather.core.js` imports **nothing** — no THREE, no DOM, no Date.now. Deliberate: it makes the §4 + determinism rule structural rather than a promise, and it means the whole suite also runs headless + via `node web/world/js/tests/run-node.mjs` (~1 s, no browser, no server). Tuning a storm curve + through a browser round trip is miserable. `weather.js` is the thin THREE adapter over it. + · Cost of that: `weather.core.js` carries its own copy of mulberry32 rather than importing + `contracts.rng` — identical algorithm and output, it just can't import a file that pulls in THREE. + `debris.js` and `skyfx.js` do use `contracts.rng`. Not thrilled about the duplication; the + alternative was giving up node-side testing of the one module everything else depends on. + · Asserts live in `js/tests/weather.selftest.js` as a plain case list; `c.test.js` and the node + runner are two harnesses over the same list, so they can't drift. + · `weather_demo.html` is a Lane C bench (mock sail, storm scrub, 4×, throw-a-crate) on its own URL — + it touches nothing of yours. Delete it whenever it stops earning its place. + · **Lane A:** a.test.js's 'gust telegraph always gives at least 1.2 s of warning' is now also + asserted against the real wind in c.test.js, per your note in the stub. Yours to drop when the + stub goes. + +[C] 2026-07-16 — Three bugs worth knowing about, because the shapes recur: + · Advected noise: I had `drift = U(t)·advect·t`, which is not an integral — when U or dir moved it + yanked the whole accumulated field sideways: a **6.8 m/s jump in one frame** at the southerly + change. Now integrated once at build time into a table. If you ever advect anything by time, + integrate it. + · Debris friction: `v *= 0.86` per frame while grounded is `0.86^60` per second — glue, not scrape. + It pinned a 9 kg crate at 0.7 m/s in a 19 m/s wind. Anything per-frame that should be per-second + needs dt. + · `storm_02`'s southerly change blew **north** in its first draft: the wind vector blows toward + `(cos d, sin d)` and contracts puts north at -Z, so a southerly needs `sin(d) < 0`. Worth a second + look at anything that reasons about wind direction. + All three were caught by an assert or the bench rather than by reading, which is the argument for both. From 6c113682025e21af0c074d76d32a0e9b9d16de31 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Thu, 16 Jul 2026 21:56:22 +1000 Subject: [PATCH 5/5] Expose audio state and levels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ready` only meant the graph got built — a suspended AudioContext is still silent, so there was no way to tell whether the storm was actually audible. The HUD now reports the real context state. Verified through it: context runs on first gesture; 7.5 -> 17.8 m/s takes the wind bed 0.16 -> 0.36 gain while the howl layer goes 0.016 -> 0.104 and the cutoff opens 428 -> 767 Hz, so a gale reads as a gale and not just a louder breeze. Rain tracks its curve 0.03 -> 0.32. Co-Authored-By: Claude Opus 4.8 --- web/world/js/skyfx.js | 13 +++++++++++++ web/world/weather_demo.html | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/web/world/js/skyfx.js b/web/world/js/skyfx.js index 2c63773..030576e 100644 --- a/web/world/js/skyfx.js +++ b/web/world/js/skyfx.js @@ -168,6 +168,19 @@ function createAudio(seed = 1) { return { get ready() { return started; }, + /** 'running' | 'suspended' | 'closed' | 'none'. `ready` only means the graph + * got built — a suspended context is still silent, so the HUD reports this. */ + get state() { return ctx ? ctx.state : 'none'; }, + /** Current layer gains — for the HUD and for asserting the bed tracks wind. */ + levels() { + if (!started) return null; + return { + wind: +windGain.gain.value.toFixed(4), + howl: +howlGain.gain.value.toFixed(4), + rain: +rainGain.gain.value.toFixed(4), + cutoff: Math.round(windFilter.frequency.value), + }; + }, /** Call from the first click/keydown. Safe to call repeatedly. */ unlock() { diff --git a/web/world/weather_demo.html b/web/world/weather_demo.html index 8a67a0e..0c5801f 100644 --- a/web/world/weather_demo.html +++ b/web/world/weather_demo.html @@ -247,7 +247,7 @@ function frame(now) {
dir ${(wind.dirAt(t)).toFixed(2)} rad
rain
worst
-
debris ${debris.pieces.length} audio ${sky.audio.ready ? 'on' : '(click)'}
+
debris ${debris.pieces.length} audio ${sky.audio.ready ? sky.audio.state : '(click)'}
flash ${sky.flash.toFixed(2)}
${tg ? `
GUST INBOUND ${tg.eta.toFixed(1)}s pow ${tg.power.toFixed(0)}
` : '
 
'} ${ticker.slice(0, 3).map((s) => `
${s}
`).join('')}