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; +}