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