HardYards/web/world/js/weather.core.js
type-two d03712ae47 Lane C S16: gate 3 lands — the fabric bet pinned, forecast argues it, storm in the lists; selftest 438/0/0
Gate 3.1 pins (c.test): the soaker over site_02 through gardenfly, porosity
the only variable — bare <30 (17.5), best cloth line <50 (42.3, still >bare:
the leak is partial), same $75 ring on membrane >90 FULL (96.5) with lost<2
and cost<=80, $60 membrane ring wins too (68.8), separation >40 (54.2). The
inverse guard: membrane on carabiners reads WORSE than cloth (19.1/3 lost vs
24.5/2) — the F key is a bet, not an upgrade. Gate 3.3: forecastLines grows
stones + rainRate (worded, banded); stormStats.hailSize + forecastFor
hail.size band, spread 0.30, nesting/truth-holding pinned in the resolving
loop; existing card fields byte-identical (new band draws last). storm_06 in
STORM_KEYS + suite STORMS (sync assert is the tripwire). Envelope tool flown:
throat 21.55 @ 59.2, q1 17.78 (rated slot), q2 wind-coldest yet cloth-side
second-hottest — the pond's signature isolated between harnesses. Mutations,
separate runs, red-then-green: gardenfly seam hardcoded -> both 3.1 pins red;
STORM_KEYS drop -> list-sync red with diff; stormgen rng -> byte-equal red.
Browser selftest 438/0/0 (418 + 4 storm honesty + 12 stormgen + 4 gate-3).
THREADS: gate entries + D pairing receipt + B seam/finding notes.
2026-07-20 20:33:10 +10:00

849 lines
36 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
// SHADES — Lane C — wind field core.
//
// Pure math. Zero imports: no THREE, no DOM, no Date.now, no rAF. Everything is
// a closed-form function of (pos, t) given a storm def + seed, which buys us:
// - selftest can fast-forward a 90 s storm and get identical numbers every run
// - consumers can sample any t, in any order, as often as they like
// - the determinism rule (PLAN3D §4) is structural, not a promise
//
// weather.js wraps this to expose the contracts.js surface (Vector3 in/out).
// The prototype scheduled gusts by INTEGRATING (wind.gustT += dt). We can't —
// sample(pos,t) is called by everyone at arbitrary t. So gusts are precomputed
// into a timeline from a seeded PRNG at storm load; the envelope shape below is
// a faithful port of prototype/game.js, just read from t instead of accumulated.
// ---------- gust envelope (ported from prototype/game.js windVec) ----------
// telegraph: wind hasn't risen yet, but you can SEE it coming (grass, band, audio)
export const GUST = Object.freeze({
TELEGRAPH: 1.5, // gt < 1.5 → 0 "it's coming"
RAMP: 0.8, // 1.5 .. 2.3 → 0 → pow
HOLD: 1.7, // 2.3 .. 4.0 → pow
FADE: 1.0, // 4.0 .. 5.0 → pow → 0
TOTAL: 5.0,
});
const RAMP_AT = GUST.TELEGRAPH; // 1.5
const HOLD_AT = RAMP_AT + GUST.RAMP; // 2.3
const FADE_AT = HOLD_AT + GUST.HOLD; // 4.0
const END_AT = FADE_AT + GUST.FADE; // 5.0
/** Gust strength at local gust time gt (seconds since telegraph began). */
export function gustEnvelope(gt, pow) {
if (gt <= 0 || gt >= END_AT) return 0;
if (gt < RAMP_AT) return 0; // telegraph window
if (gt < HOLD_AT) return pow * (gt - RAMP_AT) / GUST.RAMP;
if (gt < FADE_AT) return pow;
return pow * (END_AT - gt) / GUST.FADE;
}
// ---------- deterministic noise ----------
// mulberry32 — small, fast, good enough, and identical in every JS engine.
export function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// int32 hash — Math.imul keeps it exact (plain * would drift past 2^31 as a double)
function hash2(ix, iz, seed) {
let h = (Math.imul(ix, 374761393) + Math.imul(iz, 668265263) + Math.imul(seed, 1274126177)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
const smooth = (f) => f * f * (3 - 2 * f);
/**
* Value noise, 0..1, C1-continuous (smoothstep interp) so wind never steps.
*
* @param {number} [period] Wrap the lattice at this many cells, making the noise
* tile seamlessly over [0, period). The wind doesn't want this (the yard would
* repeat); a scrolling cloud texture does, or every wrap boundary is a visible
* straight edge in the sky. Pass an integer that matches your frequency.
*/
export function valueNoise2(x, z, seed, period = 0) {
const ix = Math.floor(x), iz = Math.floor(z);
const ux = smooth(x - ix), uz = smooth(z - iz);
// branch, not a closure: this is the wind's hot path (the cloth alone samples
// it thousands of times a second) and a per-call allocation would show up.
let x0 = ix, x1 = ix + 1, z0 = iz, z1 = iz + 1;
if (period > 0) {
x0 = ((x0 % period) + period) % period;
x1 = ((x1 % period) + period) % period;
z0 = ((z0 % period) + period) % period;
z1 = ((z1 % period) + period) % period;
}
const a = hash2(x0, z0, seed), b = hash2(x1, z0, seed);
const c = hash2(x0, z1, seed), d = hash2(x1, z1, seed);
return (a + (b - a) * ux) * (1 - uz) + (c + (d - c) * ux) * uz;
}
export function smoothstep(e0, e1, x) {
if (e0 === e1) return x < e0 ? 0 : 1;
const f = Math.min(1, Math.max(0, (x - e0) / (e1 - e0)));
return smooth(f);
}
// ---------- curves ----------
/** Piecewise-linear [[t,v],...] lookup, clamped at both ends. */
export function sampleCurve(curve, t) {
if (!curve || curve.length === 0) return 0;
if (t <= curve[0][0]) return curve[0][1];
const last = curve[curve.length - 1];
if (t >= last[0]) return last[1];
for (let i = 1; i < curve.length; i++) {
if (t <= curve[i][0]) {
const [ta, va] = curve[i - 1], [tb, vb] = curve[i];
const span = tb - ta;
return span <= 0 ? vb : va + (vb - va) * ((t - ta) / span);
}
}
return last[1];
}
/** Shortest-arc angle lerp — so a curve crossing ±π doesn't spin the long way. */
export function lerpAngle(a, b, k) {
const TAU = Math.PI * 2;
let d = ((b - a + Math.PI) % TAU + TAU) % TAU - Math.PI;
return a + d * k;
}
function sampleAngleCurve(curve, t) {
if (!curve || curve.length === 0) return 0;
if (t <= curve[0][0]) return curve[0][1];
const last = curve[curve.length - 1];
if (t >= last[0]) return last[1];
for (let i = 1; i < curve.length; i++) {
if (t <= curve[i][0]) {
const [ta, va] = curve[i - 1], [tb, vb] = curve[i];
const span = tb - ta;
return span <= 0 ? vb : lerpAngle(va, vb, (t - ta) / span);
}
}
return last[1];
}
// ---------- gust timeline ----------
// Prototype: pow = 12 + rand*16 + 10*p, next = t + 5 + rand*7. Same shape, from JSON.
export const DEFAULT_DOWNDRAFT = 0.22;
/** Fallback rain scale, mm/hr at rainAt()==1. Storms should state their own. */
export const DEFAULT_PEAK_MM_PER_HOUR = 40;
/**
* SPRINT4 decision 10 — the time-compression fiat, in ONE place.
*
* Game rain accumulates ~40× real time. A 90 s storm is canonically a whole
* night (storm_02 telegraphs its change "around the hour mark"), so it should
* deliver a night's water: 90 s × 40 = 3600 s = one hour of rain. At storm_02's
* 80 mm/hr peak that lands ~5 cm on a flat sail — exactly Lane B's measured kill
* threshold (5 cm over 25 m² = 1250 kg = 3.1 kN/corner) against a wind load of
* only 0.21.1 kN. Ponding is 315× everything else, and it cannot pincer §7
* because a hypar has no flat to pool in.
*
* Exported so Lane B applies it cloth-side rather than either of us hardcoding
* 40 twice: how hard it rains is Lane C, how much water a sail holds is Lane B.
*/
export const RAIN_TIME_COMPRESSION = 40;
export function buildGustTimeline(def, seed) {
const g = def.gusts || {};
const rng = mulberry32(seed >>> 0);
const minGap = g.minGap ?? 5, maxGap = g.maxGap ?? 12;
const out = [];
let t = g.firstAt ?? 3;
// hard cap: a malformed gap can't spin us forever
while (t < def.duration && out.length < 512) {
const p = def.duration > 0 ? t / def.duration : 0;
const pow = (g.powBase ?? 12) + rng() * (g.powRand ?? 16) + (g.powRamp ?? 10) * p;
out.push({ t0: t, pow, rampAt: t + GUST.TELEGRAPH, endAt: t + GUST.TOTAL });
t += minGap + rng() * Math.max(0, maxGap - minGap);
}
return out;
}
// ---------- hail (SPRINT5 decision 13) ----------
// Hail, not rain, carries the garden score. Rain honestly walks under a sail
// (droplets terminal ~9 m/s, so a 30 m/s crosswind blows them in at atan(30/9)
// ≈ 73° off vertical — nearly sideways), which is why a perfect rig scored 54%
// vs 48% for no rig at all. Hailstones are dense: terminal velocity ~20 m/s for
// a 1 cm stone, so the SAME 30 m/s gale only leans them atan(30/20)≈56° — and
// that overstates it, because a dense stone's drag couples weakly to the
// horizontal air, so observed hail lean tops out ~15-20°. Steep hail is blocked
// by overhead cloth even in a gale, so the garden score becomes rig-responsive
// without faking the rain physics. The steepness lives in skyfx.hailVelocity();
// the intensity timeline lives here.
/** Envelope of one authored hail burst at local time `dt` (s since it began). */
export function hailBurstEnvelope(dt, ramp, hold, fade, peak) {
if (dt <= 0) return 0;
if (dt < ramp) return peak * (dt / ramp);
if (dt < ramp + hold) return peak;
if (dt < ramp + hold + fade) return peak * (1 - (dt - ramp - hold) / fade);
return 0;
}
/**
* What fraction of hail a cloth of this porosity STOPS (0..1). Lane B's fabric
* choice (SPRINT7) reads this; the honest answer to their question.
*
* The ruling first: porosity is about AIR and WATER, not ice. A knitted shade
* cloth's gaps are ~1-3 mm; a damaging hailstone is 6-45 mm, so it can't pass a
* mesh an order of magnitude finer than itself — porous and membrane block the
* big stones identically. The ONE true difference is at the bottom of the size
* range: the finest pea hail IS small enough to rattle through an open weave.
* So a solid membrane stops everything, and a porous cloth stops everything
* except the smallest stones — which is a real fabric tradeoff without a physics
* lie, and it makes porous cost you exactly on the mild-hail nights while staying
* honest on the ice nights.
*
* `size` is in hail.size units (1.0 ≈ a 1.5 cm stone; storms run 0.7 pea → 1.4).
* `porosity` matches SailRig's (0 = membrane, ~0.3 = knitted shade cloth).
*/
export function hailBlockFor(size, porosity = 0) {
if (!(porosity > 0)) return 1; // solid membrane stops all ice
// Effective aperture of the weave, in size units. A 70%-shade knit (porosity
// ~0.3) reads ~0.6 — it leaks only the finest hail; a very open 0.5 weave
// reads ~1.0 and lets small stones through too.
const aperture = porosity * 2;
// A stone well above the gap is stopped dead; one well below sails through;
// smoothstep the transition around the aperture.
return smoothstep(aperture * 0.5, aperture * 1.5, size);
}
// ---------- the field ----------
/**
* @param {object} def parsed storm JSON (see data/storms/*.json)
* @param {object} [opts] {seed}
*/
export function createWindField(def, opts = {}) {
const seed = (opts.seed ?? def.seed ?? 1) >>> 0;
const duration = def.duration ?? 90;
const gusts = buildGustTimeline(def, seed);
const sp = def.spatial || {};
const amp = sp.amp ?? 0.18; // ±18% speed across the yard
const scale = sp.scale ?? 12; // metres per noise cell — yard is 30×20
const advect = sp.advect ?? 0.5; // noise drifts downwind (frozen turbulence)
const wander = def.dirWander || {};
const wAmp = wander.amp ?? 0.25, wRate = wander.rate ?? 0.13;
const nSeed = (seed ^ 0x9e3779b9) | 0;
// SPRINT3 decision 8: the downdraft is a fraction of TOTAL wind speed, not of
// gust power. `downdraftOfTotal` is the field name; `downdraft` is read as a
// legacy alias so an un-migrated storm doesn't silently lose its vertical.
const gd = def.gusts || {};
const downFrac = gd.downdraftOfTotal ?? gd.downdraft ?? DEFAULT_DOWNDRAFT;
// Hail bursts: authored ones from the JSON, plus one synced to every gust at
// or above `withGustsAbove` power — so the biggest gusts arrive WITH hail, the
// storm's worst moment landing all at once. Gust-synced bursts key off the
// deterministic gust timeline and draw NO randomness, so tuning hail can't
// re-time the storm (same guarantee as the downdraft).
const hailDef = def.hail || null;
const hailBursts = (hailDef && Array.isArray(hailDef.bursts))
? hailDef.bursts.map((b) => ({
t: b.t, ramp: b.ramp ?? 0.8, hold: b.hold ?? 3, fade: b.fade ?? 1.5,
intensity: b.intensity ?? 1,
}))
: [];
let shelters = [];
let venturi = [];
/** Spatially-uniform part: base curve + every gust envelope live at t. */
function uniformSpeed(t) {
let s = sampleCurve(def.baseCurve, t);
for (let i = 0; i < gusts.length; i++) {
const g = gusts[i];
if (t <= g.t0) break; // sorted — nothing later can be live
if (t < g.endAt) s += gustEnvelope(t - g.t0, g.pow);
}
return s;
}
function gustOnly(t) {
let s = 0;
for (let i = 0; i < gusts.length; i++) {
const g = gusts[i];
if (t <= g.t0) break;
if (t < g.endAt) s += gustEnvelope(t - g.t0, g.pow);
}
return s;
}
function dirAt(t) {
return sampleAngleCurve(def.dirCurve, t) + wAmp * Math.sin(t * wRate);
}
/** Local horizontal wind speed (m/s) — base+gusts, spatial noise, tree shadow,
* site venturi. The one place the local-speed maths lives; speedAt/vecAt/
* verticalAt share it, so the downdraft rides the funnel too. */
function localHoriz(x, z, t) {
const uni = uniformSpeed(t);
const d = dirAt(t);
const s = uni * localFactor(x, z, t, Math.cos(d), Math.sin(d));
return s > 0 ? s : 0;
}
/**
* Vertical wind, m/s. NEGATIVE = downward. A fraction of the LOCAL horizontal
* speed at this point and time.
*
* Why a horizontal sail must pay: cloth pressure goes with dot(wind, normal),
* a flat panel's normal points at the sky, so in a purely horizontal wind the
* dot is ~0 and "lie it flat and ignore the storm" wins — the opposite of the
* game. A descending component hits a flat panel square on.
*
* SPRINT3 decision 8 — fraction of TOTAL, not of gust power. Under gust-only
* semantics the downdraft peaked exactly at the gust peak, where the horizontal
* ALSO peaked, so a flat sail could never reach 60% of a pitched one's load
* (B measured 34%) without a downdraft so violent it also killed the twisted
* rig the §7 gate needs to survive. The two gates pincered. Riding total speed
* instead spreads the load across the whole storm: a flat roof is pressed
* steadily (peak total 32.6 m/s dwarfs peak gust power 12.6), so the ratio
* clears 60% at a gentle fraction, without a spike at the gust peak. It follows
* the LOCAL speed, so a tree's wind shadow shelters from falling air too.
*/
function verticalAt(x, z, t) {
if (downFrac <= 0) return 0;
return -downFrac * localHoriz(x, z, t);
}
// ---- noise drift ----
// The noise pattern rides downwind with the mean flow (Taylor's frozen
// turbulence), so a gust visibly travels ACROSS the yard instead of blinking on
// everywhere at once. That displacement is an integral, D(t) = ∫ advect·U·dir dτ,
// and it has to be integrated as one: the obvious closed form `U(t)·advect·t`
// is not the integral, and it whips the whole accumulated field sideways the
// instant U or dir moves — a 6.8 m/s single-frame jump at the southerly change,
// which the continuity assert caught. So integrate once at build time into an
// immutable table; sampling stays a pure function of t.
// Mean flow only (base curve, no gusts): eddies are carried by the wind, they
// don't surf their own gust, and it keeps the drift rate smooth.
const DRIFT_DT = 0.25;
const driftX = [], driftZ = [];
{
let dx = 0, dz = 0;
const n = Math.ceil((duration + 2) / DRIFT_DT) + 2;
for (let i = 0; i < n; i++) {
driftX.push(dx); driftZ.push(dz);
const tt = i * DRIFT_DT;
const u = sampleCurve(def.baseCurve, tt) * advect;
const d = dirAt(tt);
dx += Math.cos(d) * u * DRIFT_DT;
dz += Math.sin(d) * u * DRIFT_DT;
}
}
const drift = { x: 0, z: 0 };
function driftAt(t) {
if (t <= 0) { drift.x = 0; drift.z = 0; return drift; }
const f = t / DRIFT_DT;
let i = Math.floor(f);
if (i > driftX.length - 2) i = driftX.length - 2; // past the end: extrapolate
const k = f - i;
drift.x = driftX[i] + (driftX[i + 1] - driftX[i]) * k;
drift.z = driftZ[i] + (driftZ[i + 1] - driftZ[i]) * k;
return drift;
}
/** Speed multiplier: smooth noise, carried downwind. */
function spatialFactor(x, z, t) {
if (amp <= 0) return 1;
const d = driftAt(t);
const nx = (x - d.x) / scale;
const nz = (z - d.z) / scale;
const n = 0.65 * valueNoise2(nx, nz, nSeed)
+ 0.35 * valueNoise2(nx * 2.2 + 31.7, nz * 2.2 + 11.3, nSeed ^ 0x51ed270b);
return 1 + (n - 0.5) * 2 * amp;
}
/** Trees knock a hole downwind of themselves. Cheap, and very juicy. */
function shelterFactor(x, z, dirX, dirZ) {
let f = 1;
for (let i = 0; i < shelters.length; i++) {
const s = shelters[i];
const rx = x - s.x, rz = z - s.z;
const along = rx * dirX + rz * dirZ; // >0 = downwind of the tree
if (along <= 0 || along >= s.length) continue;
const perp = Math.abs(rx * dirZ - rz * dirX);
if (perp >= s.radius) continue;
// ramp in over the first half-radius so the shadow can't snap on at along=0
const fAlong = smoothstep(0, s.radius * 0.5, along) * (1 - smoothstep(0, s.length, along));
const fPerp = 1 - smoothstep(0, s.radius, perp);
f *= 1 - s.strength * fAlong * fPerp;
}
return f;
}
/**
* A venturi is a shelter's opposite: a gap between buildings SPEEDS the wind up
* when it blows along the gap's axis (SPRINT9 site_02, the corner block funnels
* the southerly). Where a tree shadow depends on being downwind of the tree,
* a venturi depends on the wind being ALIGNED with a fixed axis the SITE owns —
* so it fires only when the storm's direction swings to match, which is the
* whole drama: the corner block is calm until the southerly comes through, then
* it screams. Multiplier ≥ 1, and inert (empty list) on any site that has no
* funnel, so backyard_01 is untouched.
*/
function venturiFactor(x, z, dirX, dirZ) {
let f = 1;
for (let i = 0; i < venturi.length; i++) {
const v = venturi[i];
const rx = x - v.x, rz = z - v.z;
const dist = Math.hypot(rx, rz);
if (dist >= v.radius) continue;
// how well the wind lines up with the gap's axis. |dot| because a gap
// funnels either way through it; ^sharp so only a well-aligned wind counts.
let align = Math.abs(dirX * v.axisX + dirZ * v.axisZ);
align = Math.pow(align, v.sharp);
const radial = 1 - smoothstep(v.radius * 0.4, v.radius, dist); // full in the throat, fades out
f *= 1 + (v.gain - 1) * align * radial;
}
return f;
}
/** Every spatial speed multiplier at a point, given the wind direction. */
function localFactor(x, z, t, dirX, dirZ) {
return spatialFactor(x, z, t) * shelterFactor(x, z, dirX, dirZ) * venturiFactor(x, z, dirX, dirZ);
}
const field = {
def,
seed,
gusts,
duration,
/**
* Trees/house register wind shadows. Lane A calls this after building the
* yard; unset = no shadows, so nothing breaks before world.js lands.
* @param {Array<{x,z,radius,strength,length}>} list
*/
setShelters(list) {
shelters = (list || []).map((s) => ({
x: s.x, z: s.z,
radius: s.radius ?? 2.5,
strength: Math.min(1, Math.max(0, s.strength ?? 0.45)),
length: s.length ?? (s.radius ?? 2.5) * 4,
}));
return field;
},
get shelters() { return shelters; },
/**
* A site's wind funnels (SPRINT9 site_02). Lane A calls this from the site
* JSON after building the yard, the same way it calls setSheltersFromTrees.
* Unset = no funnels, so backyard_01 is untouched. Each zone:
* { x, z } centre of the throat, metres
* axis direction the gap runs, RADIANS in the XZ plane
* gain peak speed multiplier when the wind is dead-on (>1)
* radius how far the acceleration reaches, metres
* sharp alignment falloff exponent — higher = only a wind almost
* exactly along the axis funnels (default 3)
*/
setVenturi(list) {
venturi = (list || []).map((v) => {
const axis = v.axis ?? 0;
return {
x: v.x, z: v.z,
axisX: Math.cos(axis), axisZ: Math.sin(axis),
gain: Math.max(1, v.gain ?? 1.4),
radius: v.radius ?? 4,
sharp: Math.max(1, v.sharp ?? 3),
};
});
return field;
},
get venturi() { return venturi; },
/**
* Scalar wind speed (m/s) at a point — HORIZONTAL only, which is what an
* anemometer reads and what the HUD, rain and grass want. The gust downdraft
* is deliberately not in here: a wind meter jumping because air is falling
* past it would read as a bug. Use vecAt/sample for the full 3D vector.
* The cheap path — no allocation.
*/
speedAt(x, z, t) {
return localHoriz(x, z, t);
},
dirAt,
uniformSpeed,
gustOnly,
verticalAt,
get downFrac() { return downFrac; },
/** Writes wind velocity (m/s) into out {x,y,z}. Ground plane is XZ, +Y up. */
vecAt(x, z, t, out) {
const d = dirAt(t);
const dirX = Math.cos(d), dirZ = Math.sin(d);
const s = localHoriz(x, z, t);
out.x = dirX * s;
out.y = -downFrac * s; // the downdraft rides the local speed — see verticalAt()
out.z = dirZ * s;
return out;
},
/**
* The next gust that has been telegraphed but hasn't started ramping.
* eta = seconds until the wind actually rises. Null when nothing's inbound.
*/
telegraph(t) {
for (let i = 0; i < gusts.length; i++) {
const g = gusts[i];
if (t < g.t0) return null; // sorted — next one hasn't telegraphed yet
if (t < g.rampAt) {
return { eta: g.rampAt - t, dir: dirAt(g.rampAt), power: g.pow };
}
}
return null;
},
/** Storm events (windchange/debris) fired in (a, b]. Pure — replayable. */
eventsBetween(a, b) {
const evs = def.events || [];
const out = [];
for (let i = 0; i < evs.length; i++) {
if (evs[i].t > a && evs[i].t <= b) out.push(evs[i]);
}
return out;
},
/** 0..1 rain intensity for skyfx. */
rainAt(t) {
const r = def.rain;
if (!r) return 0;
if (r.curve) return Math.min(1, Math.max(0, sampleCurve(r.curve, t)));
return Math.min(1, Math.max(0, r.intensity ?? 0));
},
/**
* 0..1 hail intensity at time t (SPRINT5 decision 13). Max over every live
* burst — authored plus gust-synced — because "how hard is it hailing right
* now" is a level, not a sum; two overlapping bursts don't hail at 1.6×.
* Zero for a storm with no `hail` block, so storm_01 simply never hails.
*/
hailAt(t) {
if (!hailDef) return 0;
let h = 0;
for (let i = 0; i < hailBursts.length; i++) {
const b = hailBursts[i];
if (t <= b.t || t >= b.t + b.ramp + b.hold + b.fade) continue;
const v = hailBurstEnvelope(t - b.t, b.ramp, b.hold, b.fade, b.intensity);
if (v > h) h = v;
}
const thresh = hailDef.withGustsAbove;
if (thresh != null) {
const gi = hailDef.gustBurstIntensity ?? 0.8;
for (let i = 0; i < gusts.length; i++) {
const g = gusts[i];
if (g.pow < thresh) continue;
if (t <= g.t0 || t >= g.endAt) continue;
// reuse the gust envelope: silent through the telegraph, then it hits
// as the gust ramps and holds — the hail lands WITH the gust.
// gustEnvelope(gt, pow) returns pow×fraction, so passing gi as "pow"
// gives the intensity-scaled 0..gi envelope directly.
const v = gustEnvelope(t - g.t0, gi);
if (v > h) h = v;
}
}
return h > 1 ? 1 : h;
},
/** Stone-size scalar (audio pitch, visual scale, damage weight). Default 1. */
get hailSize() { return hailDef ? (hailDef.size ?? 1) : 0; },
/**
* Rain rate in REAL-WORLD mm/hr. Same curve as rainAt(), with physical units
* on it — `rainAt` stays 0..1 because it drives drop count and opacity, and a
* renderer doesn't want millimetres.
*
* This exists for ponding (decision 10). Without it Lane B has to invent the
* mm/hr scale to turn intensity into water mass, which is exactly the
* "default-off code tuned by a constant I invented" they rightly reverted.
* The scale is storm data, so it lives here: `rain.peakMmPerHour`.
* For reference: 8 = light shower, 30 = moderate, 50 = heavy, 80+ = severe.
*/
rainMmPerHour(t) {
const r = def.rain;
if (!r) return 0;
return field.rainAt(t) * (r.peakMmPerHour ?? DEFAULT_PEAK_MM_PER_HOUR);
},
/**
* Real-world water depth in mm delivered over (t0, t1]. Deterministic
* trapezoid over the curve — no compression applied.
*
* Lane B multiplies by RAIN_TIME_COMPRESSION cloth-side: how much water a
* SAIL holds is theirs, how hard it rains is mine. Handy shape for a HUD
* "water delivered" readout too.
*/
rainDepthMm(t0, t1, stepS = 0.25) {
if (!(t1 > t0)) return 0;
let mm = 0;
const n = Math.max(1, Math.ceil((t1 - t0) / stepS));
const h = (t1 - t0) / n;
let prev = field.rainMmPerHour(t0);
for (let i = 1; i <= n; i++) {
const cur = field.rainMmPerHour(t0 + i * h);
mm += (prev + cur) * 0.5 * h / 3600; // mm/hr × seconds → mm
prev = cur;
}
return mm;
},
};
return field;
}
// ---------- forecasting ----------
// DESIGN.md's partial-information canon: a forecast days out is a RANGE, and it
// tightens as the night comes. Deliberately pure functions on a storm def rather
// than methods on a wind — the card has defs in hand, it doesn't want a field,
// and this keeps forecasting off the wind contract (and out of main.js's router).
const _statsCache = new WeakMap();
/**
* The truth about a storm, measured rather than estimated. The forecast card was
* approximating the gust peak as `baseCurve peak + powBase + powRamp` (30 m/s
* for storm_02); the storm actually gusts to 32.3, because gust power is drawn
* per gust and rides a ramp. If the card is going to sell the dread it may as
* well sell the real number. Cached per def — scanning is ~1800 samples.
*
* @returns {{sustained, gustPeak, rainPeak, rainPeakMmPerHour, hailPeak, hailSeconds, changeAt}}
*/
export function stormStats(def) {
const hit = _statsCache.get(def);
if (hit) return hit;
const f = createWindField(def);
const dur = def.duration ?? 90;
let sustained = 0, gustPeak = 0, rainPeak = 0, hailPeak = 0, hailSeconds = 0;
const STEP = 0.05;
for (let t = 0; t <= dur; t += STEP) {
const u = f.uniformSpeed(t);
if (u > gustPeak) gustPeak = u;
const base = u - f.gustOnly(t);
if (base > sustained) sustained = base;
const r = f.rainAt(t);
if (r > rainPeak) rainPeak = r;
const h = f.hailAt(t);
if (h > hailPeak) hailPeak = h;
hailSeconds += h * STEP;
}
const change = (def.events || []).find((e) => e.type === 'windchange');
const stats = {
sustained, gustPeak, rainPeak,
rainPeakMmPerHour: rainPeak * (def.rain?.peakMmPerHour ?? DEFAULT_PEAK_MM_PER_HOUR),
// Stone size (SPRINT16 gate 3.3): the fabric bet argues on the forecast,
// and the stone is the argument — a pea leaks through cloth, ice doesn't.
// 0 when the storm never hails, matching hailSeconds' convention.
hailPeak, hailSeconds, hailSize: f.hailSize,
changeAt: change ? change.t : null,
};
_statsCache.set(def, stats);
return stats;
}
/** How wide each number's band runs at lead=1, as a fraction of the value. */
const FORECAST_SPREAD = {
sustained: 0.30, gustPeak: 0.35, rain: 0.45, changeAt: 0.22, hailSeconds: 0.7,
// Stone size hedges less than duration: radar sees how big the cell is long
// before it knows how long it will sit on you.
hailSize: 0.30,
};
/**
* A forecast of `def` seen `lead` out — 0 = tonight (exact), 1 = the far end of
* the week (vague). Deterministic per storm, so the same night always forecasts
* the same way and re-reading the card can't reroll it.
*
* The band ALWAYS contains the truth. That's the line between partial
* information and a lie: a forecast may be vague, and its midpoint may be off,
* but it must never rule out what actually happens — a player who rigs for the
* top of the stated range must never be ambushed. Width and centre-wander both
* shrink to nothing as lead → 0.
*
* @param {object} def parsed storm JSON
* @param {number} lead 0..1
*/
export function forecastFor(def, lead = 0) {
const s = stormStats(def);
const L = Math.min(1, Math.max(0, lead));
const r = mulberry32(((def.seed ?? 1) ^ 0xf0eca57) >>> 0);
const band = (v, rel) => {
if (L <= 0 || !(v > 0)) return { lo: v, hi: v };
const w = v * rel * L;
const c = v + (r() * 2 - 1) * w * 0.6; // the centre wanders, seeded
return { lo: Math.max(0, Math.min(v, c - w)), hi: Math.max(v, c + w) };
};
// Hail is the garden score (decision 13), so the card has to hint at it — but
// a distant forecast shouldn't promise ice it can't see yet.
let chance = 'none';
if (s.hailSeconds > 0) {
if (L > 0.55) chance = 'possible';
else chance = s.hailSeconds > 6 ? 'likely' : 'possible';
}
return {
lead: L,
confidence: 1 - L,
sustained: band(s.sustained, FORECAST_SPREAD.sustained),
gustPeak: band(s.gustPeak, FORECAST_SPREAD.gustPeak),
rain: band(s.rainPeak, FORECAST_SPREAD.rain),
rainMmPerHour: band(s.rainPeakMmPerHour, FORECAST_SPREAD.rain),
changeAt: s.changeAt == null ? null : band(s.changeAt, FORECAST_SPREAD.changeAt),
hail: {
chance,
seconds: band(s.hailSeconds, FORECAST_SPREAD.hailSeconds),
// SPRINT16 gate 3.3 — the stone joins the card. Band, not a promise:
// contains the truth at every lead, exact at lead 0 (band() guarantees
// both). {lo:0, hi:0} for a storm that never hails.
size: band(s.hailSize, FORECAST_SPREAD.hailSize),
},
truth: s,
};
}
// ---------- storm JSON validator ----------
// Storms are data so design can tune without code (PLAN3D §4) — which means a
// typo is a data bug, and data bugs should fail loud, not silently blow calm.
export function validateStorm(def, name = 'storm') {
const errors = [];
const bad = (m) => errors.push(`${name}: ${m}`);
const isCurve = (c) => Array.isArray(c) && c.length > 0
&& c.every((p) => Array.isArray(p) && p.length === 2 && p.every(Number.isFinite));
const monotonic = (c) => c.every((p, i) => i === 0 || p[0] >= c[i - 1][0]);
if (!def || typeof def !== 'object') { bad('not an object'); return { ok: false, errors }; }
if (!Number.isFinite(def.duration) || def.duration <= 0) bad('duration must be a positive number');
if (!isCurve(def.baseCurve)) bad('baseCurve must be [[t,speed],...] of finite numbers');
else {
if (!monotonic(def.baseCurve)) bad('baseCurve t must be non-decreasing');
if (def.baseCurve.some((p) => p[1] < 0)) bad('baseCurve speed must be >= 0');
const end = def.baseCurve[def.baseCurve.length - 1][0];
if (Number.isFinite(def.duration) && end < def.duration) {
bad(`baseCurve ends at t=${end} but storm runs to ${def.duration} — tail would flatline`);
}
}
if (!isCurve(def.dirCurve)) bad('dirCurve must be [[t,radians],...] of finite numbers');
else if (!monotonic(def.dirCurve)) bad('dirCurve t must be non-decreasing');
const g = def.gusts;
if (!g || typeof g !== 'object') bad('gusts block missing');
else {
const minGap = g.minGap ?? 5, maxGap = g.maxGap ?? 12;
if (!(minGap > 0)) bad('gusts.minGap must be > 0 (else the timeline never advances)');
if (maxGap < minGap) bad('gusts.maxGap must be >= minGap');
// Overlapping gusts stack, and a stacked telegraph is unreadable to the player.
if (minGap < GUST.TOTAL) bad(`gusts.minGap (${minGap}) < gust length ${GUST.TOTAL}s — gusts would overlap`);
if ((g.powBase ?? 12) < 0) bad('gusts.powBase must be >= 0');
// `downdraft` (gust-only, pre-SPRINT3) is still accepted but flagged, so an
// un-migrated storm loads visibly wrong rather than silently at a third power.
if (g.downdraft != null && g.downdraftOfTotal == null) {
bad('gusts.downdraft is the old gust-only field — rename to downdraftOfTotal (SPRINT3 decision 8); it now means a fraction of TOTAL wind speed');
}
const dd = g.downdraftOfTotal ?? g.downdraft ?? DEFAULT_DOWNDRAFT;
if (!Number.isFinite(dd) || dd < 0 || dd > 1) {
bad(`gusts.downdraftOfTotal must be 0..1 — the fraction of TOTAL wind speed that blows DOWN — got ${dd}`);
}
}
for (const e of def.events || []) {
if (!Number.isFinite(e.t)) bad(`event ${JSON.stringify(e)} has no finite t`);
if (!e.type) bad(`event at t=${e.t} has no type`);
if (e.type === 'debris' && !e.model) bad(`debris event at t=${e.t} has no model`);
// A windchange event is HUD metadata; dirCurve is the physics. If they drift
// apart the player gets warned about a swing that never comes.
if (e.type === 'windchange' && isCurve(def.dirCurve)) {
const before = sampleAngleCurve(def.dirCurve, e.t - 0.5);
const after = sampleAngleCurve(def.dirCurve, e.t + (e.over ?? 6));
const swing = Math.abs(lerpAngle(before, after, 1) - before);
if (swing < 0.5) {
bad(`windchange at t=${e.t} promises a swing but dirCurve only turns ${swing.toFixed(2)} rad by t=${e.t + (e.over ?? 6)}`);
}
}
}
if (def.rain && def.rain.curve && !isCurve(def.rain.curve)) bad('rain.curve must be [[t,intensity],...]');
if (def.rain) {
if (def.rain.curve && isCurve(def.rain.curve)
&& def.rain.curve.some((p) => p[1] < 0 || p[1] > 1)) {
bad('rain.curve intensity must be 0..1 — the physical scale is rain.peakMmPerHour');
}
const mm = def.rain.peakMmPerHour;
if (mm != null && (!Number.isFinite(mm) || mm < 0 || mm > 300)) {
bad(`rain.peakMmPerHour must be 0..300 mm/hr (8 light, 30 moderate, 50 heavy, 80+ severe) — got ${mm}`);
}
// Ponding reads this; a storm that rains with no scale silently ponds at the
// default instead of what its author meant.
if (def.rain.curve && mm == null) {
bad('rain.curve without rain.peakMmPerHour — ponding needs the mm/hr scale (SPRINT4 decision 10)');
}
}
// ---- hail (SPRINT5 decision 13) ----
if (def.hail) {
const hd = def.hail;
if (hd.size != null && (!Number.isFinite(hd.size) || hd.size <= 0 || hd.size > 5)) {
bad(`hail.size must be a positive scalar up to ~5 (stones bigger than golf balls break the metaphor) — got ${hd.size}`);
}
if (hd.withGustsAbove != null && !Number.isFinite(hd.withGustsAbove)) {
bad('hail.withGustsAbove must be a finite gust-power threshold in m/s');
}
if (hd.gustBurstIntensity != null && (!(hd.gustBurstIntensity >= 0) || hd.gustBurstIntensity > 1)) {
bad(`hail.gustBurstIntensity must be 0..1 — got ${hd.gustBurstIntensity}`);
}
// A storm with a hail block but nothing to fire it never hails — that's a
// typo, not a design, so say so rather than shipping silent hail.
if (!Array.isArray(hd.bursts) && hd.withGustsAbove == null) {
bad('hail block has neither bursts[] nor withGustsAbove — it would never hail');
}
for (const b of hd.bursts || []) {
if (!Number.isFinite(b.t)) bad(`hail burst ${JSON.stringify(b)} has no finite t`);
if (b.t + 0 > (def.duration ?? 90)) bad(`hail burst at t=${b.t} starts after the storm ends`);
for (const k of ['ramp', 'hold', 'fade']) {
if (b[k] != null && !(b[k] >= 0)) bad(`hail burst at t=${b.t} has a negative ${k}`);
}
if (b.intensity != null && (!(b.intensity >= 0) || b.intensity > 1)) {
bad(`hail burst at t=${b.t} intensity must be 0..1 — got ${b.intensity}`);
}
}
}
return { ok: errors.length === 0, errors };
}
// ---------- site wind validator (SPRINT10 site-as-data) ----------
// A site's `wind` block is hand-authored data like a storm, so it fails loud
// the same way. Lane A calls this at site load; setVenturi clamps defensively
// too, but a clamp hides a typo and this names it. Wind personality that lives
// in site JSON: venturi funnels (and later, per-site shelter overrides).
export function validateSiteWind(wind, name = 'site') {
const errors = [];
const bad = (m) => errors.push(`${name}.wind: ${m}`);
if (wind == null) return { ok: true, errors }; // a site with no wind block is fine
if (typeof wind !== 'object') { bad('must be an object'); return { ok: false, errors }; }
if (wind.venturi != null) {
if (!Array.isArray(wind.venturi)) bad('venturi must be an array of funnel zones');
else wind.venturi.forEach((v, i) => {
const at = `venturi[${i}]`;
if (!Number.isFinite(v.x) || !Number.isFinite(v.z)) bad(`${at} needs finite x,z (the throat centre)`);
if (v.axis != null && !Number.isFinite(v.axis)) bad(`${at}.axis must be radians (the direction the gap runs)`);
if (v.gain != null && !(v.gain >= 1)) bad(`${at}.gain must be >= 1 — a venturi speeds wind UP; use shelters to slow it (got ${v.gain})`);
if (v.gain != null && v.gain > 3) bad(`${at}.gain ${v.gain} is a wind tunnel, not a gap — cap ~2`);
if (v.radius != null && !(v.radius > 0)) bad(`${at}.radius must be > 0 metres`);
if (v.sharp != null && !(v.sharp >= 1)) bad(`${at}.sharp must be >= 1 (alignment falloff exponent)`);
});
}
return { ok: errors.length === 0, errors };
}