'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 }, ]; // t1 and t2 from Lane A's landed yard (THREADS: "yard layout is now FACT") const TREE_SHELTERS = [ { x: -9, z: 2, radius: 3, strength: 0.45, length: 14 }, { x: 8, z: -2, radius: 2.5, strength: 0.4, length: 12 }, ]; /** * 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) => cases.push({ name, fn }); 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'); }); // ---- 9. vertical structure (SPRINT3 decision 8: fraction of TOTAL) ---- // Cloth pressure goes with dot(wind, normal). A flat panel's normal points at // the sky, so in a purely horizontal wind that dot is ~0 and "lie it flat and // ignore the storm" wins — the opposite of the game. The downdraft is now a // fraction of the LOCAL total wind speed (was: gust power), so a flat roof is // pressed whenever it's windy, not only at gust peaks. Lane B owns the // cloth-side no-free-lunch assert; these are the wind side. test('downdraft is a fixed fraction of the local horizontal speed', () => { const def = storms.storm_02_wildnight; const f = createWindField(def); const frac = def.gusts.downdraftOfTotal; assert(Math.abs(f.downFrac - frac) < 1e-12, `field downFrac ${f.downFrac} != json ${frac}`); const out = { x: 0, y: 0, z: 0 }; let peakDown = 0; for (const p of PROBES) { for (let t = 0; t <= f.duration; t += DT) { f.vecAt(p.x, p.z, t, out); const horiz = Math.hypot(out.x, out.z); assert(out.y <= 1e-9, `vertical went UP (${out.y.toFixed(3)}) at t=${t.toFixed(2)} — downdraft only`); // out.y must be exactly -frac * horizontal, everywhere, always assert(Math.abs(out.y + frac * horiz) < 1e-9, `downdraft ${out.y.toFixed(3)} != -${frac}×${horiz.toFixed(3)} at t=${t.toFixed(2)}`); peakDown = Math.min(peakDown, out.y); } } metrics['storm_02.peakDowndraft'] = +peakDown.toFixed(2); // Held at downdraftOfTotal 0.15 → ~-4.9 m/s; target 0.45 → ~-14.7. Floor at // -3 so this proves "a real downdraft exists" across the whole transition // range without false-failing when the joint step bumps the value. assert(peakDown < -3, `peak downdraft only ${peakDown.toFixed(2)} m/s — a flat sail would still shrug it off`); }); test('downdraft rides the wind: present when windy, gone when calm', () => { // The point of fraction-of-total: it's not a gust-only feature any more. Some // sustained-wind moment between gusts must still carry a real downdraft, and a // hypothetically dead-calm field must carry none. const f = createWindField(storms.storm_02_wildnight); let sustainedDown = 0; for (let t = 0; t <= f.duration; t += DT) { const inGust = f.gusts.some((g) => t > g.t0 && t < g.endAt); if (!inGust) sustainedDown = Math.min(sustainedDown, f.verticalAt(0, 0, t)); } assert(sustainedDown < -2, `between gusts the downdraft peaks at only ${sustainedDown.toFixed(2)} — total-speed semantics should keep it pressing`); // dead calm → no downdraft (guards against a constant offset sneaking in) const calm = createWindField({ duration: 10, baseCurve: [[0, 0], [10, 0]], dirCurve: [[0, 0], [10, 0]], gusts: { minGap: 6, maxGap: 6, powBase: 0, powRand: 0, powRamp: 0, downdraftOfTotal: 0.5 }, }); for (let t = 0; t <= 10; t += 0.1) { assert(Math.abs(calm.verticalAt(0, 0, t)) < 1e-9, `air is falling in a dead calm at t=${t.toFixed(1)}`); } }); test('downdraft follows the tree shadow (shelters from falling air too)', () => { const def = storms.storm_02_wildnight; const f = createWindField(def).setShelters([{ x: 0, z: 0, radius: 3, strength: 0.5, length: 14 }]); const t = 30; const d = f.dirAt(t); const dx = Math.cos(d), dz = Math.sin(d); const leeDown = Math.abs(f.verticalAt(dx * 5, dz * 5, t)); // downwind of the tree const openDown = Math.abs(f.verticalAt(-dx * 5, -dz * 5, t)); // upwind, unsheltered assert(leeDown < openDown * 0.85, `lee downdraft ${leeDown.toFixed(2)} not sheltered vs open ${openDown.toFixed(2)}`); }); test('downdraftOfTotal 0 gives a perfectly horizontal wind', () => { const def = JSON.parse(JSON.stringify(storms.storm_02_wildnight)); def.gusts.downdraftOfTotal = 0; const f = createWindField(def); const out = { x: 0, y: 0, z: 0 }; for (let t = 0; t <= f.duration; t += 0.05) { f.vecAt(2, -1, t, out); assert(out.y === 0, `y=${out.y} at t=${t.toFixed(2)} with downdraft 0 — the opt-out leaks`); } }); test('downdraft does not re-time the storm', () => { // The vertical carries NO rng draws of its own now (it's a pure function of // local speed), so tuning it cannot possibly shift gust times or powers. Lane // A hand-drove storm_02 and watched the carabiner blow at t=45.4 and p2 // cascade at t=56; a downdraft tweak silently moving those would be a nasty // way to lose an afternoon. Determinism is now structural, but still asserted. const base = storms.storm_02_wildnight; const a = createWindField(base); for (const dd of [0, 0.1, 0.22, 0.5, 1]) { const d = JSON.parse(JSON.stringify(base)); d.gusts.downdraftOfTotal = dd; const b = createWindField(d); assert(a.gusts.length === b.gusts.length, `downdraft ${dd} changed the gust count`); a.gusts.forEach((g, i) => { assert(g.t0 === b.gusts[i].t0, `downdraft ${dd} moved gust ${i} from t=${g.t0.toFixed(3)} to ${b.gusts[i].t0.toFixed(3)}`); assert(g.pow === b.gusts[i].pow, `downdraft ${dd} changed gust ${i}'s power`); }); // and the HORIZONTAL wind must be byte-identical regardless of downdraft assert(a.speedAt(3, -2, 47.3) === b.speedAt(3, -2, 47.3), `downdraft ${dd} changed the horizontal wind`); } }); test('speedAt stays horizontal — a wind meter does not read falling air', () => { const f = createWindField(storms.storm_02_wildnight); const out = { x: 0, y: 0, z: 0 }; for (const p of PROBES) { for (const t of [12, 40, 60, 75.3]) { f.vecAt(p.x, p.z, t, out); assert(Math.abs(f.speedAt(p.x, p.z, t) - Math.hypot(out.x, out.z)) < 1e-9, `speedAt != horizontal magnitude of sample at t=${t}`); } } }); test('validator rejects a bad downdraft and the renamed field', () => { for (const dd of [-0.1, 1.5, NaN, 'lots']) { const d = JSON.parse(JSON.stringify(storms.storm_02_wildnight)); d.gusts.downdraftOfTotal = dd; assert(!validateStorm(d, 'broken').ok, `validator ACCEPTED downdraftOfTotal = ${dd}`); } // the old gust-only field must be rejected, not silently re-meant const legacy = JSON.parse(JSON.stringify(storms.storm_02_wildnight)); delete legacy.gusts.downdraftOfTotal; legacy.gusts.downdraft = 0.3; assert(!validateStorm(legacy, 'legacy').ok, 'validator silently accepted the pre-SPRINT3 downdraft field'); }); 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 }; }