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