Lane C S16: makeStorm(seed) — seeded storms inside authored envelopes (gate 3.2)

stormgen.js: envelopes are authored data (every knob a range, knot times
fixed), a seed picks one storm via a single mulberry32 stream in a pinned
draw order. Same seed → byte-equal def; def carries its seed so the gust
timeline downstream is seeded by the same number. checkEnvelope() recomputes
membership FROM THE DEF (never trusts the generator) and also runs the storm
validator. One family ships: soaker_family, ranged around storm_06's measured
personality. Cases ride the shared node+browser list: byte-equal determinism,
100 seeds all valid + all inside (gustPeak spread 12.8-17.2 across seeds),
four named escape routes red-not-shrugged, valid-but-off-family storm red
(checkEnvelope is not the validator in a hat), change event === physics for
sampled seeds, and two fixed-seed generated storms ride the SAME telegraph/
continuity/validator gates as authored ones. Node suite 72/0. Mutation check:
rng → Math.random went red on the byte-equal case, restored, green.
This commit is contained in:
type-two 2026-07-20 20:11:34 +10:00
parent 92a8e870f2
commit d6af6ba5a2
2 changed files with 378 additions and 1 deletions

291
web/world/js/stormgen.js Normal file
View File

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

View File

@ -14,6 +14,7 @@ import {
createWindField, validateStorm, validateSiteWind, gustEnvelope, GUST, RAIN_TIME_COMPRESSION, createWindField, validateStorm, validateSiteWind, gustEnvelope, GUST, RAIN_TIME_COMPRESSION,
stormStats, forecastFor, hailBlockFor, stormStats, forecastFor, hailBlockFor,
} from '../weather.core.js'; } from '../weather.core.js';
import { makeStorm, checkEnvelope, ENVELOPES } from '../stormgen.js';
const DT = 1 / 60; const DT = 1 / 60;
@ -44,7 +45,14 @@ export function weatherCases(storms) {
const test = (name, fn) => cases.push({ name, fn }); const test = (name, fn) => cases.push({ name, fn });
const assert = (cond, msg) => { if (!cond) throw new Error(msg); }; 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) ---- // ---- 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 // 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)}`); 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 }; return { cases, metrics };
} }