'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, RAIN_TIME_COMPRESSION, stormStats, forecastFor, hailBlockFor, } 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'); }); // ---- 10. the ponding story (SPRINT4 decision 10) ---- // Lane B owns water-on-cloth; these assert the RAIN DATA can tell the story // their mass model needs, using their own arithmetic, before their cloth lands. // B measured: 5 cm over a 25 m² flat sail = 1250 kg = 3.1 kN/corner, against a // storm_02 wind load of only 0.2–1.1 kN. So depth is the whole mechanic. const FLAT_AREA = 25; // m², B's reference flat sail const KILL_DEPTH_MM = 50; // 5 cm — B's 3.1 kN/corner /** Water on a flat sail over a whole storm, at decision 10's compression. */ const stormDepthMm = (def) => { const f = createWindField(def); return f.rainDepthMm(0, f.duration) * RAIN_TIME_COMPRESSION; }; /** B's arithmetic: mm over an area → kN per corner (4 corners, 1000 kg/m³). */ const kNPerCorner = (mm) => (mm / 1000) * FLAT_AREA * 1000 * 9.81 / 4 / 1000; test('storm_02 rain can drown a flat rig; storm_01 rain cannot', () => { const wild = stormDepthMm(storms.storm_02_wildnight); const gentle = stormDepthMm(storms.storm_01_gentle); metrics['storm_02.pondDepth_mm'] = +wild.toFixed(1); metrics['storm_02.pond_kN_per_corner'] = +kNPerCorner(wild).toFixed(2); metrics['storm_01.pondDepth_mm'] = +gentle.toFixed(2); metrics['storm_01.pond_kN_per_corner'] = +kNPerCorner(gentle).toFixed(3); assert(wild >= KILL_DEPTH_MM * 0.9, `storm_02 only delivers ${wild.toFixed(1)} mm — Lane B needs ~${KILL_DEPTH_MM} mm to drown a flat rig`); // and it must dwarf the wind it's competing with (B: 0.2–1.1 kN/corner) assert(kNPerCorner(wild) > 1.5, `storm_02 ponding is only ${kNPerCorner(wild).toFixed(2)} kN/corner — no stronger than the wind`); // the gentle storm must be unable to hurt anything, or the ramp is a lie assert(kNPerCorner(gentle) < 0.3, `storm_01 ponds ${kNPerCorner(gentle).toFixed(2)} kN/corner — a light shower must not threaten a rig`); assert(wild > gentle * 20, 'the wild night should deliver vastly more water than a shower'); }); test('storm_03 ponding sits between the other two', () => { const mid = stormDepthMm(storms.storm_03_southerly); const wild = stormDepthMm(storms.storm_02_wildnight); const gentle = stormDepthMm(storms.storm_01_gentle); metrics['storm_03.pondDepth_mm'] = +mid.toFixed(1); metrics['storm_03.pond_kN_per_corner'] = +kNPerCorner(mid).toFixed(2); assert(mid > gentle * 3 && mid < wild * 0.5, `storm_03 delivers ${mid.toFixed(1)} mm — wanted a real middle rung between ${gentle.toFixed(1)} and ${wild.toFixed(1)}`); // it should threaten a carabiner (1.2 kN) once wind is added, not a shackle (3.2) assert(kNPerCorner(mid) > 0.25 && kNPerCorner(mid) < 1.2, `storm_03 ponds ${kNPerCorner(mid).toFixed(2)} kN/corner — should tease a cheap rig, not drown a decent one`); }); test('rain depth integrates monotonically and matches its curve', () => { const f = createWindField(storms.storm_02_wildnight); // depth only ever accumulates let prev = 0; for (let t = 1; t <= f.duration; t += 1) { const d = f.rainDepthMm(0, t); assert(d >= prev - 1e-9, `rain depth went BACKWARDS at t=${t}: ${d} < ${prev}`); prev = d; } // splitting the interval must give the same water (no double-count, no gap) const whole = f.rainDepthMm(0, 90); const split = f.rainDepthMm(0, 30) + f.rainDepthMm(30, 60) + f.rainDepthMm(60, 90); assert(Math.abs(whole - split) < 0.05, `depth(0,90)=${whole.toFixed(3)} but the three thirds sum to ${split.toFixed(3)}`); // dead calm before the rain starts assert(f.rainDepthMm(0, 0) === 0, 'zero-length interval delivered water'); assert(f.rainDepthMm(10, 5) === 0, 'a backwards interval delivered water'); }); test('rainMmPerHour tracks rainAt against the storm scale', () => { for (const [name, def] of defs) { if (!def.rain || !def.rain.curve) continue; const f = createWindField(def); const peak = def.rain.peakMmPerHour; assert(Number.isFinite(peak), `${name} has a rain curve but no peakMmPerHour`); for (let t = 0; t <= f.duration; t += 2.5) { const want = f.rainAt(t) * peak; assert(Math.abs(f.rainMmPerHour(t) - want) < 1e-9, `${name}: rainMmPerHour ${f.rainMmPerHour(t)} != rainAt×peak ${want} at t=${t}`); } // rainAt stays a 0..1 intensity — skyfx uses it for drop count and opacity for (let t = 0; t <= f.duration; t += 2.5) { const r = f.rainAt(t); assert(r >= 0 && r <= 1, `${name}: rainAt ${r} outside 0..1 at t=${t}`); } } }); test('validator rejects a rain curve with no scale, and a silly scale', () => { const noScale = JSON.parse(JSON.stringify(storms.storm_02_wildnight)); delete noScale.rain.peakMmPerHour; assert(!validateStorm(noScale, 'x').ok, 'validator accepted a rain curve with no mm/hr scale — ponding would silently use the default'); for (const mm of [-1, 5000, NaN]) { const d = JSON.parse(JSON.stringify(storms.storm_02_wildnight)); d.rain.peakMmPerHour = mm; assert(!validateStorm(d, 'x').ok, `validator accepted peakMmPerHour=${mm}`); } const hot = JSON.parse(JSON.stringify(storms.storm_02_wildnight)); hot.rain.curve = [[0, 0], [45, 3], [90, 0]]; assert(!validateStorm(hot, 'x').ok, 'validator accepted rain intensity above 1 — the scale is peakMmPerHour, not the curve'); }); // ---- 11. hail (SPRINT5 decision 13) ---- // The wind-side of hail: intensity timeline and its ramp across the campaign. // The garden-damage half (steep shadow, ≥2× rig response) needs skyfx + // SailRig, so it lives in c.test.js. test('hail ramps across the campaign: none / mild / a wild-night burst', () => { const peakAndArea = (def) => { const f = createWindField(def); let peak = 0, peakT = 0, area = 0; for (let t = 0; t <= f.duration; t += DT) { const h = f.hailAt(t); if (h > peak) { peak = h; peakT = t; } area += h * DT; } return { peak, peakT, area }; }; const g = peakAndArea(storms.storm_01_gentle); const m = peakAndArea(storms.storm_03_southerly); const w = peakAndArea(storms.storm_02_wildnight); metrics['storm_01.hailPeak'] = +g.peak.toFixed(2); metrics['storm_03.hailPeak'] = +m.peak.toFixed(2); metrics['storm_02.hailPeak'] = +w.peak.toFixed(2); metrics['storm_02.hailSeconds'] = +w.area.toFixed(1); assert(g.peak === 0, `the gentle storm hailed (${g.peak}) — storm_01 must have no hail`); assert(m.peak > 0.2 && m.peak < 0.8, `storm_03 hail peak ${m.peak.toFixed(2)} — wanted a mild middle rung`); assert(w.peak >= 0.95, `storm_02 hail peak only ${w.peak.toFixed(2)} — the wild night needs a proper burst`); assert(w.area > m.area * 3, 'the wild night should carry vastly more hail than the mild storm'); }); test('storm_02 hail lands ON the southerly change', () => { const change = (storms.storm_02_wildnight.events.find((e) => e.type === 'windchange')).t; const f = createWindField(storms.storm_02_wildnight); // find the authored-burst peak (near the change), not just any gust-synced tick let peak = 0, peakT = 0; for (let t = change - 3; t <= change + 8; t += DT) { const h = f.hailAt(t); if (h > peak) { peak = h; peakT = t; } } assert(peak >= 0.95, `hail near the change only reached ${peak.toFixed(2)}`); assert(Math.abs(peakT - change) < 6, `hail peaks at t=${peakT.toFixed(1)}, the change is at t=${change} — they should coincide`); }); test('hail is silent through a gust telegraph and rides its own gust', () => { // the gust-synced bursts must not fire during the telegraph window, or hail // would arrive before the gust it belongs to — the opposite of the drama const def = storms.storm_02_wildnight; const f = createWindField(def); const thresh = def.hail.withGustsAbove; for (const g of f.gusts) { if (g.pow < thresh) continue; // during the telegraph (first GUST.TELEGRAPH s) the gust-synced part is 0. // an authored burst may still overlap, so only check gusts clear of t=55±8. if (Math.abs(g.t0 - 55) < 10) continue; for (let t = g.t0 + 0.05; t < g.t0 + GUST.TELEGRAPH; t += DT) { assert(f.hailAt(t) < 1e-9, `hail fell during a gust's telegraph at t=${t.toFixed(2)} — it should wait for the gust`); } } }); test('hail does not re-time the storm (zero draws of its own)', () => { const base = storms.storm_02_wildnight; const a = createWindField(base); for (const gi of [0, 0.3, 0.85, 1]) { const d = JSON.parse(JSON.stringify(base)); d.hail.gustBurstIntensity = gi; d.hail.bursts = []; // even removing the authored burst mustn't move gusts const b = createWindField(d); assert(a.gusts.length === b.gusts.length, `hail change moved the gust count`); a.gusts.forEach((g, i) => { assert(g.t0 === b.gusts[i].t0 && g.pow === b.gusts[i].pow, `hail change re-timed gust ${i}`); }); assert(a.speedAt(3, -2, 47.3) === b.speedAt(3, -2, 47.3), 'hail change altered the wind'); } }); test('validator rejects broken hail blocks', () => { const base = () => JSON.parse(JSON.stringify(storms.storm_02_wildnight)); const cases = [ ['size 0', (d) => { d.hail.size = 0; }], ['size huge', (d) => { d.hail.size = 20; }], ['intensity > 1', (d) => { d.hail.bursts[0].intensity = 1.5; }], ['negative fade', (d) => { d.hail.bursts[0].fade = -1; }], ['burst after the storm', (d) => { d.hail.bursts[0].t = 200; }], ['gustBurstIntensity > 1', (d) => { d.hail.gustBurstIntensity = 2; }], ['neither bursts nor gusts', (d) => { delete d.hail.bursts; delete d.hail.withGustsAbove; }], ]; for (const [label, mutate] of cases) { const d = base(); mutate(d); assert(!validateStorm(d, 'broken').ok, `validator accepted broken hail: ${label}`); } // a hail-free storm (no block at all) must still validate const none = base(); delete none.hail; assert(validateStorm(none, 'nohail').ok, 'validator rejected a storm with no hail — hail is optional'); }); // ---- 12. forecast uncertainty (SPRINT6 §Lane C) ---- // DESIGN.md's partial-information canon. The one rule that matters: a vague // forecast is fine, a WRONG one isn't. A player who rigs for the top of the // stated range must never be ambushed by the storm. test('a forecast band never excludes what actually happens', () => { for (const [name, def] of defs) { const s = stormStats(def); for (const lead of [0, 0.25, 0.5, 0.75, 1]) { const f = forecastFor(def, lead); const inside = (b, v, what) => assert(v >= b.lo - 1e-9 && v <= b.hi + 1e-9, `${name} @lead ${lead}: ${what} truth ${v.toFixed(2)} outside forecast ${b.lo.toFixed(2)}–${b.hi.toFixed(2)}`); inside(f.sustained, s.sustained, 'sustained'); inside(f.gustPeak, s.gustPeak, 'gustPeak'); inside(f.rain, s.rainPeak, 'rain'); inside(f.hail.seconds, s.hailSeconds, 'hailSeconds'); if (s.changeAt != null) inside(f.changeAt, s.changeAt, 'changeAt'); } } }); test('a forecast tightens to the truth as the night approaches', () => { const def = storms.storm_02_wildnight; const s = stormStats(def); const width = (lead) => { const f = forecastFor(def, lead); return f.gustPeak.hi - f.gustPeak.lo; }; const far = width(1), mid = width(0.5), near = width(0); metrics['forecast.gustBandWidth@lead1'] = +far.toFixed(1); metrics['forecast.gustBandWidth@lead0.5'] = +mid.toFixed(1); assert(far > mid && mid > near, `band must narrow with lead: ${far.toFixed(1)} → ${mid.toFixed(1)} → ${near.toFixed(1)}`); assert(near === 0, `tonight's forecast should be exact, band is ${near.toFixed(2)} wide`); const f0 = forecastFor(def, 0); assert(f0.gustPeak.lo === s.gustPeak && f0.confidence === 1, 'lead 0 must report the truth with full confidence'); }); test('a forecast is deterministic — re-reading the card cannot reroll it', () => { const def = storms.storm_02_wildnight; for (const lead of [0.3, 0.8]) { const a = forecastFor(def, lead), b = forecastFor(def, lead); assert(a.gustPeak.lo === b.gustPeak.lo && a.gustPeak.hi === b.gustPeak.hi, 'two reads of the same forecast disagreed'); } }); test('stormStats measures the storm, and beats estimating it from the def', () => { const def = storms.storm_02_wildnight; const s = stormStats(def); // what the card used to estimate: baseCurve peak + powBase + powRamp const estimate = Math.max(...def.baseCurve.map((p) => p[1])) + (def.gusts.powBase ?? 0) + (def.gusts.powRamp ?? 0); metrics['storm_02.gustPeak.measured'] = +s.gustPeak.toFixed(1); metrics['storm_02.gustPeak.oldEstimate'] = +estimate.toFixed(1); assert(Math.abs(s.gustPeak - estimate) > 1, 'the estimate happens to match — this test is only interesting while they differ'); assert(s.gustPeak > 30 && s.gustPeak < 35, `measured gust peak ${s.gustPeak.toFixed(1)} is not credible`); assert(s.changeAt === 55, `storm_02's change is at ${s.changeAt}, expected 55`); }); test('the forecast hail hint tracks the storm, and hedges when far out', () => { assert(forecastFor(storms.storm_01_gentle, 0).hail.chance === 'none', 'gentle storm forecast hail'); assert(forecastFor(storms.storm_02_wildnight, 0).hail.chance === 'likely', 'the wild night should forecast likely hail'); assert(forecastFor(storms.storm_02_wildnight, 1).hail.chance === 'possible', 'a week out, the wild night should only hedge at "possible"'); assert(forecastFor(storms.storm_03_southerly, 0).hail.chance === 'possible', 'storm_03 hails mildly'); }); // ---- 13. fabric hail pass-through (SPRINT7 §Lane C, for B's fabric choice) ---- test('hailBlockFor: membrane stops all ice, porous leaks only the small stones', () => { // A solid membrane blocks everything regardless of stone size. for (const size of [0.4, 0.7, 1.0, 1.3, 1.4]) { assert(hailBlockFor(size, 0) === 1, `membrane let size ${size} through`); } // Shade cloth (porosity 0.3) fully blocks the wild-night stones (1.3+) but // leaks the finest pea hail — the honest, size-gated difference. assert(hailBlockFor(1.3, 0.3) > 0.99, 'shade cloth failed to stop a 1.3 storm stone'); assert(hailBlockFor(1.4, 0.3) > 0.99, 'shade cloth failed to stop a 1.4 ice-night stone'); const pea = hailBlockFor(0.7, 0.3); assert(pea > 0.4 && pea < 0.9, `shade cloth blocks ${pea.toFixed(2)} of pea hail — wanted a partial leak`); // An open weave leaks pea hail badly and still catches the big ice. assert(hailBlockFor(0.7, 0.5) < 0.3, 'an open weave should let most pea hail through'); assert(hailBlockFor(1.3, 0.5) > 0.8, 'even an open weave should mostly stop a 1.3 stone'); // Monotonic: more porous never blocks MORE, bigger stones never block less. for (const size of [0.6, 1.0, 1.4]) { assert(hailBlockFor(size, 0.5) <= hailBlockFor(size, 0.3) + 1e-9, 'more porous blocked more hail'); } for (const por of [0.3, 0.5]) { assert(hailBlockFor(0.5, por) <= hailBlockFor(1.2, por) + 1e-9, 'a bigger stone passed more easily'); } metrics['fabric.shadecloth.blocksPeaHail'] = +pea.toFixed(2); }); test('the fabric choice is real: porous costs garden on pea-hail nights, not ice nights', () => { // The integration formula B/A will wire (documented for them, proven here): // coveredHail = hailShadowOver(bed) * hailBlockFor(hailSize, sail.porosity) // exposure = hailAt(t) * (1 - coveredHail) // A membrane over the bed protects it fully; a porous cloth over the same bed // protects it fully on an ice night and leaks on a pea-hail one. If those two // came out equal, the choice would be dead — this is the assert that it isn't. const gardenHail = (stormName, porosity) => { const def = storms[stormName]; const f = createWindField(def); const size = f.hailSize; let dmg = 0; const COVER = 1; // bed fully under the cloth for (let t = 0; t <= f.duration; t += DT) { const covered = COVER * hailBlockFor(size, porosity); dmg += f.hailAt(t) * (1 - covered) * DT; } return dmg; }; // ice night: membrane and shade cloth both fully protect a covered bed const iceMembrane = gardenHail('storm_02b_icenight', 0); const icePorous = gardenHail('storm_02b_icenight', 0.3); assert(Math.abs(iceMembrane - icePorous) < 0.2 && iceMembrane < 0.5, `on the ice night the fabrics should agree at ~0 (both block big ice): ${iceMembrane.toFixed(2)} vs ${icePorous.toFixed(2)}`); // pea-hail night: the covered bed under porous cloth takes real damage const peaMembrane = gardenHail('storm_03_southerly', 0); const peaPorous = gardenHail('storm_03_southerly', 0.3); metrics['fabric.storm03.gardenHail.membrane'] = +peaMembrane.toFixed(2); metrics['fabric.storm03.gardenHail.porous'] = +peaPorous.toFixed(2); assert(peaMembrane < 0.1, 'a membrane over the bed should take ~no pea hail'); assert(peaPorous > peaMembrane + 0.3, `porous should leak real pea hail: ${peaPorous.toFixed(2)} vs ${peaMembrane.toFixed(2)}`); }); 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 }; }