diff --git a/web/world/js/stormgen.js b/web/world/js/stormgen.js new file mode 100644 index 0000000..79250d9 --- /dev/null +++ b/web/world/js/stormgen.js @@ -0,0 +1,291 @@ +'use strict'; +// SHADES — Lane C — makeStorm(seed): seeded storms inside authored envelopes. +// +// SPRINT16 gate 3.2 — GROUNDWORK, not a shipped night. ROADMAP arc 4's +// roguelite ("procedurally generated storm sequences inside authored +// envelopes") eats this; nothing in NIGHTS uses it yet, and nothing should +// until that arc's own sprint prices it. +// +// The design rule, stated once: an ENVELOPE is authored (a designer says what +// this storm family is allowed to be — every knob a [lo, hi] range, every +// curve knot a fixed time with a ranged value), and a SEED picks one storm +// from inside it. The generator draws every value in a FIXED order from one +// mulberry32 stream, so: +// · same seed + same envelope → the SAME def, byte-equal under +// JSON.stringify (the determinism the whole sim is built on), +// · a generated def carries its seed, so the gust timeline downstream +// (buildGustTimeline reads def.seed) is seeded by the same number, +// · every draw is range-clamped by construction, and checkEnvelope() can +// verify conformance FROM THE DEF ALONE — it recomputes, it does not +// trust the generator, so a hand-edited def fails honestly. +// +// Pure on purpose: imports weather.core only (no THREE, no DOM, no fetch), so +// this runs under the node one-second loop exactly like the storm curves do. +// +// What an envelope deliberately does NOT range: knot TIMES (fixed per family — +// a family whose acts move is two families), telegraph (the honesty rule is +// not tunable), and GUST envelope shape (weather.core owns it). The change's +// dirCurve is BUILT from the drawn change time so the windchange event and the +// physics can never drift apart — the validator's own trap, made structural. + +import { mulberry32, validateStorm, GUST } from './weather.core.js'; + +/** Round to 3 decimals — keeps generated defs readable and diffs stable. + * Determinism does not depend on this (same ops → same doubles); byte-equal + * JSON just reads better at 3 places than at 17. */ +const r3 = (v) => Math.round(v * 1000) / 1000; + +/** + * The authored envelopes, as data. One family ships as groundwork: the + * soaker family, ranged around storm_06's MEASURED personality (fabric_bet + * probe, THREADS 2026-07-20) so every member keeps the night's thesis — + * pea stones under the cloth aperture, heavy rain, mild wind — while the + * seed moves the acts around inside it. + * + * Envelope grammar (checkEnvelope enforces all of it): + * duration fixed seconds + * base { t: [knot times, first 0, last duration], v: [[lo,hi]] } + * gusts every field [lo,hi]; minGap.lo must be ≥ GUST.TOTAL or + * gusts could overlap (validator red) + * change { at:[lo,hi], over:[lo,hi], telegraph: fixed, + * startDir:[lo,hi], swing:[lo,hi] } — swing is ADDED to + * the start direction; |swing| must be ≥ 0.6 everywhere + * in range (validator demands ≥ 0.5 rad of real turn) + * dirWander { amp:[lo,hi], rate:[lo,hi] } + * spatial fixed (spatial texture is family identity, not weather) + * rain { peakMmPerHour:[lo,hi], t: [knot times], v: [[lo,hi]] } + * hail null, or { size:[lo,hi], count:[lo,hi] (integer), + * window:[t0,t1], hold:[lo,hi], intensity:[lo,hi], + * ramp: fixed, fade: fixed } + * sky { darkness:[lo,hi], cloudScroll: fixed, night: fixed } + */ +export const ENVELOPES = { + soaker_family: { + name: 'soaker_family', + duration: 90, + base: { + t: [0, 15, 30, 50, 70, 90], + v: [[4, 6], [6, 7.5], [7.5, 9], [8.5, 10], [8, 9.5], [6.5, 8]], + }, + gusts: { + firstAt: [3, 6], minGap: [5.5, 7], maxGap: [9, 12], + powBase: [1.5, 2.5], powRand: [2, 4], powRamp: [1.5, 2.5], + downdraftOfTotal: [0.25, 0.35], + }, + change: { + at: [36, 52], over: [6, 10], telegraph: 6, + startDir: [0.5, 0.8], swing: [-1.5, -1.1], + }, + dirWander: { amp: [0.15, 0.25], rate: [0.08, 0.12] }, + spatial: { amp: 0.18, scale: 11, advect: 0.5 }, + rain: { + peakMmPerHour: [38, 50], + t: [0, 10, 25, 40, 60, 80, 90], + v: [[0.1, 0.2], [0.35, 0.55], [0.65, 0.85], [0.8, 0.95], [0.9, 1.0], [0.75, 0.9], [0.6, 0.8]], + }, + hail: { + size: [0.4, 0.55], count: [3, 5], window: [14, 78], + hold: [4, 9], intensity: [0.3, 0.55], ramp: 2, fade: 3, + }, + sky: { darkness: [0.8, 0.9], cloudScroll: 0.07, night: true }, + }, +}; + +/** + * One storm from inside the envelope. Deterministic: same (seed, envelope) → + * byte-equal def, proven by the selftest with JSON.stringify. + * + * @param {number} seed any uint32 + * @param {object} [env] an ENVELOPES entry (defaults to the soaker family) + * @returns {object} a storm def that passes validateStorm and checkEnvelope + */ +export function makeStorm(seed, env = ENVELOPES.soaker_family) { + const rng = mulberry32(seed >>> 0); + const draw = ([lo, hi]) => lo + rng() * (hi - lo); + const drawInt = ([lo, hi]) => lo + Math.floor(rng() * (hi - lo + 1e-9)); + + // Draw order is the contract: base knots, gusts, change, wander, rain + // (peak then knots), hail (size, count, times, holds, intensities), sky. + // Reordering these lines is a breaking change to every seed ever noted. + const baseCurve = env.base.t.map((t, i) => [t, r3(draw(env.base.v[i]))]); + + const g = env.gusts; + const gusts = { + firstAt: r3(draw(g.firstAt)), + minGap: r3(draw(g.minGap)), + maxGap: r3(draw(g.maxGap)), + powBase: r3(draw(g.powBase)), + powRand: r3(draw(g.powRand)), + powRamp: r3(draw(g.powRamp)), + downdraftOfTotal: r3(draw(g.downdraftOfTotal)), + }; + + const at = r3(draw(env.change.at)); + const over = r3(draw(env.change.over)); + const startDir = r3(draw(env.change.startDir)); + const swing = r3(draw(env.change.swing)); + const endDir = r3(startDir + swing); + // Built FROM the drawn change, so event and physics agree by construction: + // hold until just before the change, swing across [at, at+over], settle a + // touch past it, hold to the end. All knot times clamped inside duration. + const settle = Math.min(env.duration, at + over + 14); + const dirCurve = [ + [0, startDir], + [r3(at - 4), startDir], + [at, r3(startDir - swing * 0.15)], + [r3(at + over), endDir], + [r3(settle), r3(endDir - 0.05)], + [env.duration, r3(endDir - 0.05)], + ]; + + const dirWander = { amp: r3(draw(env.dirWander.amp)), rate: r3(draw(env.dirWander.rate)) }; + + const rain = { + peakMmPerHour: r3(draw(env.rain.peakMmPerHour)), + curve: env.rain.t.map((t, i) => [t, r3(draw(env.rain.v[i]))]), + }; + + let hail = null; + if (env.hail) { + const size = r3(draw(env.hail.size)); + const count = drawInt(env.hail.count); + // Jittered slots: split the window into `count` equal slots and drop one + // burst inside each — spread guaranteed, spacing seeded, no sort needed + // (slot order IS time order, so draw order stays stable per burst). + const [w0, w1] = env.hail.window; + const slot = (w1 - w0) / count; + const bursts = []; + for (let i = 0; i < count; i++) { + const t = r3(w0 + slot * i + rng() * slot * 0.6); + bursts.push({ + t, + ramp: env.hail.ramp, + hold: r3(draw(env.hail.hold)), + fade: env.hail.fade, + intensity: r3(draw(env.hail.intensity)), + }); + } + hail = { size, bursts }; + } + + const sky = { + darkness: r3(draw(env.sky.darkness)), + cloudScroll: env.sky.cloudScroll, + }; + if (env.sky.night) sky.night = true; + + return { + name: `${env.name} seed ${seed >>> 0}`, + blurb: `Generated from the ${env.name} envelope — one of its storms, picked by seed ${seed >>> 0}.`, + _generated: { envelope: env.name, seed: seed >>> 0 }, + seed: seed >>> 0, + duration: env.duration, + baseCurve, + gusts, + dirCurve, + dirWander, + spatial: { ...env.spatial }, + events: [ + { t: at, type: 'windchange', telegraph: env.change.telegraph, over, text: 'the change comes through' }, + ], + rain, + ...(hail ? { hail } : {}), + sky, + }; +} + +const inRange = (v, [lo, hi], eps = 1e-9) => Number.isFinite(v) && v >= lo - eps && v <= hi + eps; + +/** + * Does this def sit inside this envelope? Recomputed from the DEF, never from + * remembering what the generator drew — a hand-edited def must fail here on + * its own merits. Also runs validateStorm, so "inside the envelope" implies + * "loadable storm". Returns { ok, errors } like the validator. + */ +export function checkEnvelope(def, env = ENVELOPES.soaker_family) { + const errors = []; + const bad = (m) => errors.push(`${env.name}: ${m}`); + + const v = validateStorm(def, def?.name ?? 'generated'); + if (!v.ok) errors.push(...v.errors); + if (!def || typeof def !== 'object') return { ok: false, errors }; + + if (def.duration !== env.duration) bad(`duration ${def.duration} ≠ family's ${env.duration}`); + + // base curve: same knot times, every value in range + const bc = def.baseCurve ?? []; + if (bc.length !== env.base.t.length) bad(`baseCurve has ${bc.length} knots, family has ${env.base.t.length}`); + else bc.forEach(([t, val], i) => { + if (t !== env.base.t[i]) bad(`baseCurve knot ${i} at t=${t}, family fixes t=${env.base.t[i]}`); + if (!inRange(val, env.base.v[i])) bad(`baseCurve[${i}] = ${val} outside [${env.base.v[i]}]`); + }); + + const g = def.gusts ?? {}; + for (const k of ['firstAt', 'minGap', 'maxGap', 'powBase', 'powRand', 'powRamp', 'downdraftOfTotal']) { + if (!inRange(g[k], env.gusts[k])) bad(`gusts.${k} = ${g[k]} outside [${env.gusts[k]}]`); + } + if ((g.minGap ?? 0) < GUST.TOTAL) bad(`gusts.minGap ${g.minGap} < ${GUST.TOTAL} — family authored an overlap`); + + // the change: one windchange event, inside the window, physics agreeing + const changes = (def.events ?? []).filter((e) => e.type === 'windchange'); + if (changes.length !== 1) bad(`family storms carry exactly one windchange, got ${changes.length}`); + else { + const ev = changes[0]; + if (!inRange(ev.t, env.change.at)) bad(`change at t=${ev.t} outside [${env.change.at}]`); + if (!inRange(ev.over ?? 6, env.change.over)) bad(`change.over ${ev.over} outside [${env.change.over}]`); + if ((ev.telegraph ?? 0) !== env.change.telegraph) bad(`change.telegraph ${ev.telegraph} ≠ family's ${env.change.telegraph}`); + const dc = def.dirCurve ?? []; + const start = dc[0]?.[1]; + const end = dc[dc.length - 1]?.[1]; + if (!inRange(start, env.change.startDir)) bad(`dirCurve start ${start} outside [${env.change.startDir}]`); + // net swing (allowing the settle's small overshoot), against the family's + const net = end - start; + const [sLo, sHi] = env.change.swing; + if (!inRange(net, [sLo - 0.1, sHi + 0.1])) bad(`net dir swing ${net?.toFixed?.(3)} outside [${sLo},${sHi}]±0.1`); + } + + const w = def.dirWander ?? {}; + if (!inRange(w.amp, env.dirWander.amp)) bad(`dirWander.amp ${w.amp} outside [${env.dirWander.amp}]`); + if (!inRange(w.rate, env.dirWander.rate)) bad(`dirWander.rate ${w.rate} outside [${env.dirWander.rate}]`); + + const sp = def.spatial ?? {}; + for (const k of ['amp', 'scale', 'advect']) { + if (sp[k] !== env.spatial[k]) bad(`spatial.${k} ${sp[k]} ≠ family's ${env.spatial[k]}`); + } + + const rn = def.rain ?? {}; + if (!inRange(rn.peakMmPerHour, env.rain.peakMmPerHour)) { + bad(`rain.peakMmPerHour ${rn.peakMmPerHour} outside [${env.rain.peakMmPerHour}]`); + } + const rc = rn.curve ?? []; + if (rc.length !== env.rain.t.length) bad(`rain.curve has ${rc.length} knots, family has ${env.rain.t.length}`); + else rc.forEach(([t, val], i) => { + if (t !== env.rain.t[i]) bad(`rain knot ${i} at t=${t}, family fixes t=${env.rain.t[i]}`); + if (!inRange(val, env.rain.v[i])) bad(`rain.curve[${i}] = ${val} outside [${env.rain.v[i]}]`); + }); + + if (env.hail) { + const h = def.hail; + if (!h) bad('family hails; def has no hail block'); + else { + if (!inRange(h.size, env.hail.size)) bad(`hail.size ${h.size} outside [${env.hail.size}]`); + const bursts = h.bursts ?? []; + if (!inRange(bursts.length, env.hail.count)) bad(`${bursts.length} bursts outside [${env.hail.count}]`); + bursts.forEach((b, i) => { + if (!inRange(b.t, env.hail.window)) bad(`burst[${i}] at t=${b.t} outside window [${env.hail.window}]`); + if (!inRange(b.hold, env.hail.hold)) bad(`burst[${i}].hold ${b.hold} outside [${env.hail.hold}]`); + if (!inRange(b.intensity, env.hail.intensity)) bad(`burst[${i}].intensity ${b.intensity} outside [${env.hail.intensity}]`); + if (b.ramp !== env.hail.ramp) bad(`burst[${i}].ramp ${b.ramp} ≠ family's ${env.hail.ramp}`); + if (b.fade !== env.hail.fade) bad(`burst[${i}].fade ${b.fade} ≠ family's ${env.hail.fade}`); + }); + if (h.withGustsAbove != null) bad('family hail is authored-bursts only; withGustsAbove is not in the envelope'); + } + } else if (def.hail) { + bad('family does not hail; def carries a hail block'); + } + + const sk = def.sky ?? {}; + if (!inRange(sk.darkness, env.sky.darkness)) bad(`sky.darkness ${sk.darkness} outside [${env.sky.darkness}]`); + + return { ok: errors.length === 0, errors }; +} diff --git a/web/world/js/tests/weather.selftest.js b/web/world/js/tests/weather.selftest.js index 5b5da0e..f71e5c2 100644 --- a/web/world/js/tests/weather.selftest.js +++ b/web/world/js/tests/weather.selftest.js @@ -14,6 +14,7 @@ import { createWindField, validateStorm, validateSiteWind, gustEnvelope, GUST, RAIN_TIME_COMPRESSION, stormStats, forecastFor, hailBlockFor, } from '../weather.core.js'; +import { makeStorm, checkEnvelope, ENVELOPES } from '../stormgen.js'; const DT = 1 / 60; @@ -44,7 +45,14 @@ export function weatherCases(storms) { const test = (name, fn) => cases.push({ name, fn }); const assert = (cond, msg) => { if (!cond) throw new Error(msg); }; - const defs = Object.entries(storms); + // SPRINT16 gate 3.2: two generated storms ride the SAME per-storm honesty + // gates as the authored ones below (telegraph lead, continuity, validator). + // A family whose members can be dishonest is not an envelope, it's a slot + // machine — fixed seeds so the suite stays deterministic run to run. + const defs = [ + ...Object.entries(storms), + ...[11, 47].map((s) => [`gen_soaker_${s}`, makeStorm(s)]), + ]; // ---- 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 @@ -814,6 +822,84 @@ export function weatherCases(storms) { assert(peaPorous > peaMembrane + 0.3, `porous should leak real pea hail: ${peaPorous.toFixed(2)} vs ${peaMembrane.toFixed(2)}`); }); + // ---- 14. makeStorm(seed) — SPRINT16 gate 3.2, arc 4's groundwork ---- + // A seeded generator inside authored envelopes. No shipped night uses it; + // these cases are what "inside" and "deterministic" MEAN. + + test('makeStorm: same seed → byte-equal def; different seed → different storm', () => { + const a = JSON.stringify(makeStorm(1234)); + const b = JSON.stringify(makeStorm(1234)); + assert(a === b, 'two calls with the same seed produced different JSON — a hidden draw or a Date leaked in'); + const c = JSON.stringify(makeStorm(1235)); + assert(a !== c, 'seeds 1234 and 1235 produced the same storm — the seed is not reaching the draws'); + // the def carries its seed, so the gust timeline downstream is seeded by + // the same number the caller noted down + assert(makeStorm(1234).seed === 1234, 'generated def does not carry its seed'); + }); + + test('makeStorm: 100 seeds — every def validates AND sits inside its envelope', () => { + let gustLo = Infinity, gustHi = 0; + for (let s = 1; s <= 100; s++) { + const d = makeStorm(s); + const v = validateStorm(d, `gen_${s}`); + assert(v.ok, `seed ${s} failed the storm validator:\n ${v.errors.join('\n ')}`); + const e = checkEnvelope(d); + assert(e.ok, `seed ${s} escaped its envelope:\n ${e.errors.join('\n ')}`); + const st = stormStats(d); + gustLo = Math.min(gustLo, st.gustPeak); gustHi = Math.max(gustHi, st.gustPeak); + assert(st.hailSeconds > 2, `seed ${s} barely hails (${st.hailSeconds.toFixed(1)} s) — the family's thesis is pea hail all night`); + assert(d.hail.size < 0.6, `seed ${s} threw size-${d.hail.size} stones — over the family's pea range, cloth would block them`); + } + metrics['stormgen.gustPeak.lo'] = +gustLo.toFixed(1); + metrics['stormgen.gustPeak.hi'] = +gustHi.toFixed(1); + // the family is genuinely a FAMILY: seeds move the storm, inside a band + assert(gustHi - gustLo > 1, `100 seeds moved gustPeak only ${(gustHi - gustLo).toFixed(2)} m/s — the ranges are decoration`); + assert(gustHi < 20, `a soaker gusted to ${gustHi.toFixed(1)} m/s — the family has stopped being mild`); + }); + + test('checkEnvelope: a def outside the envelope goes red, not shrugged', () => { + // vacuity guard first: the def we are about to mutate passes as generated + const clean = makeStorm(7); + assert(checkEnvelope(clean).ok, 'the unmutated control failed — every red below would be noise'); + + // each mutation is one escape route, and each must be NAMED in the errors + const mutations = [ + ['base speed over the family ceiling', (d) => { d.baseCurve[3][1] = 12; }, /baseCurve\[3\]/], + ['pea family throwing ice', (d) => { d.hail.size = 1.3; }, /hail\.size/], + ['gusts overlapping (minGap under the gust length)', (d) => { d.gusts.minGap = 4; }, /minGap/], + ['the change re-timed outside its window', (d) => { d.events[0].t = 20; d.dirCurve = [[0, 0.6], [16, 0.7], [28, -0.7], [90, -0.65]]; }, /change at t=20/], + ]; + for (const [label, mutate, want] of mutations) { + const d = makeStorm(7); + mutate(d); + const e = checkEnvelope(d); + assert(!e.ok, `checkEnvelope shrugged at: ${label}`); + assert(e.errors.some((m) => want.test(m)), + `${label}: red, but no error names the escape — got:\n ${e.errors.join('\n ')}`); + } + + // and the one that separates checkEnvelope from validateStorm: a def that + // is a perfectly LOADABLE storm but is not a member of this family + const stranger = makeStorm(7); + stranger.rain.peakMmPerHour = 75; // a severe soaker — valid, off-family + assert(validateStorm(stranger, 'stranger').ok, 'the stranger should still validate as a storm'); + assert(!checkEnvelope(stranger).ok, 'a valid-but-off-family storm passed the envelope — checkEnvelope is just the validator in a hat'); + }); + + test('makeStorm: the change event and the physics agree by construction', () => { + // the validator's windchange/dirCurve drift trap, proven closed for every + // seed we ship gates on — the event's promised swing is real in dirCurve + for (const s of [3, 21, 88]) { + const d = makeStorm(s); + const ev = d.events.find((e) => e.type === 'windchange'); + const f = createWindField(d); + const before = f.dirAt(ev.t - 6); + const after = f.dirAt(ev.t + (ev.over ?? 6) + 6); + const swing = Math.abs(after - before); + assert(swing > 0.6, `seed ${s}: the change at t=${ev.t} only swung ${swing.toFixed(2)} rad`); + } + }); + return { cases, metrics }; }