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.
849 lines
36 KiB
JavaScript
849 lines
36 KiB
JavaScript
'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.2–1.1 kN. Ponding is 3–15× 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 };
|
||
}
|