diff --git a/web/world/data/storms/storm_01_gentle.json b/web/world/data/storms/storm_01_gentle.json index 91327d4..2bb1f32 100644 --- a/web/world/data/storms/storm_01_gentle.json +++ b/web/world/data/storms/storm_01_gentle.json @@ -24,7 +24,9 @@ "events": [], - "rain": { "curve": [[0, 0], [30, 0.15], [55, 0.2], [80, 0.08], [90, 0]] }, + "_rain_comment": "peakMmPerHour 8 = a light shower. Mean intensity ~0.12, so at decision 10's 40x a whole storm delivers ~0.9 mm — about a millimetre of water, ~20 kg over a 25 m2 flat sail. Ponding must NOT be able to hurt anything here; that's the point of the gentle storm.", + + "rain": { "peakMmPerHour": 8, "curve": [[0, 0], [30, 0.15], [55, 0.2], [80, 0.08], [90, 0]] }, "sky": { "darkness": 0.15, "cloudScroll": 0.02 } } diff --git a/web/world/data/storms/storm_02_wildnight.json b/web/world/data/storms/storm_02_wildnight.json index 1f5c84b..4c08ddb 100644 --- a/web/world/data/storms/storm_02_wildnight.json +++ b/web/world/data/storms/storm_02_wildnight.json @@ -38,7 +38,11 @@ { "t": 79, "type": "lightning", "power": 0.5 } ], - "rain": { "curve": [[0, 0], [10, 0.25], [35, 0.6], [55, 0.85], [70, 1.0], [90, 0.7]] }, + "_rain_comment": "peakMmPerHour 80 = severe thunderstorm rate, the scale rainAt's 0..1 is a fraction OF. Ponding (decision 10) reads it. Mean intensity over the storm is ~0.64, and at 40x compression a 90 s storm is one hour of rain, so depth ~= 80 * 0.64 = ~51 mm = 5.1 cm on a flat sail — Lane B's measured kill: 5 cm over 25 m2 = 1250 kg = 3.1 kN/corner, against a wind load of only 0.2-1.1 kN. A flat rig should drown here. A hypar pools nothing and doesn't care.", - "sky": { "darkness": 0.8, "cloudScroll": 0.09 } + "rain": { "peakMmPerHour": 80, "curve": [[0, 0], [10, 0.25], [35, 0.6], [55, 0.85], [70, 1.0], [90, 0.7]] }, + + "_sky_comment": "night: true forces the night palette rather than leaning on the darkness threshold — it's called Wild Night and the forecast card has to sell that. lightningGustPow 10 fires a flash on any gust at/above 10 m/s of gust power (this storm's gusts top out ~12.6, so it lights up for the worst few, late, on top of the three authored strikes) — the storm's worst moments should be the ones you see.", + + "sky": { "darkness": 0.94, "cloudScroll": 0.09, "night": true, "lightningGustPow": 10 } } diff --git a/web/world/data/storms/storm_03_southerly.json b/web/world/data/storms/storm_03_southerly.json index 0c4ecbe..9d26896 100644 --- a/web/world/data/storms/storm_03_southerly.json +++ b/web/world/data/storms/storm_03_southerly.json @@ -32,7 +32,9 @@ { "t": 62, "type": "lightning", "power": 0.4 } ], - "rain": { "curve": [[0, 0], [28, 0.05], [34, 0.4], [55, 0.55], [80, 0.3], [90, 0.15]] }, + "_rain_comment": "peakMmPerHour 30 = moderate. The rain arrives WITH the change at t~30 and not before — dry hot afternoon, then the southerly brings the water, which is the story the curve is telling. Mean intensity ~0.28, so at decision 10's 40x it delivers ~8 mm = 0.8 cm: ~200 kg on a 25 m2 flat sail, ~0.5 kN/corner. That is the middle rung on purpose — enough that a flat rig on carabiners (1.2 kN) should feel the water once wind is added, not enough to drown a shackle. Ponding teaches here; it kills in storm_02.", + + "rain": { "peakMmPerHour": 30, "curve": [[0, 0], [28, 0.05], [34, 0.4], [55, 0.55], [80, 0.3], [90, 0.15]] }, "sky": { "darkness": 0.5, "cloudScroll": 0.05 } } diff --git a/web/world/js/skyfx.js b/web/world/js/skyfx.js index f2f91e4..bbec07f 100644 --- a/web/world/js/skyfx.js +++ b/web/world/js/skyfx.js @@ -21,6 +21,8 @@ const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v); const CALM_SKY = new THREE.Color(0x9fc4e8); const STORM_SKY = new THREE.Color(0x2a2f3a); const NIGHT_SKY = new THREE.Color(0x11141c); +const WHITE = new THREE.Color(0xffffff); +const FLASH_COL = new THREE.Color(0xdfe8ff); // ------------------------------------------------------------ rain shadow /** @@ -440,7 +442,13 @@ export function createSkyFx(o = {}) { const skyDef = def.sky || {}; const darkness = skyDef.darkness ?? 0.7; const scroll = skyDef.cloudScroll ?? 0.06; - const target = (o.night ?? darkness > 0.6) ? NIGHT_SKY : STORM_SKY; + // The storm data gets a say: `sky.night` is the author's call, the darkness + // threshold is only a fallback for storms that never state one. + const target = (o.night ?? skyDef.night ?? darkness > 0.6) ? NIGHT_SKY : STORM_SKY; + // Fire a flash on any gust this strong (m/s of gust power). Storms that don't + // ask stay lit only by their authored strikes. + const lightningGustPow = skyDef.lightningGustPow ?? Infinity; + const firedGusts = new Set(); const rain = createRain({ groundY: o.groundY ?? 0 }); if (scene) scene.add(rain.mesh); @@ -510,6 +518,28 @@ export function createSkyFx(o = {}) { */ rainShadowOver(rect) { return shadow.fractionOver(rect); }, + /** + * Decision 7's garden-HP drain term, 0..1, in one call — the combined helper + * I offered Lane A. This is the whole of "is the bed getting hit right now": + * + * hp -= sky.gardenExposure(world.gardenBed, t) * DRAIN_PER_SEC * dt; + * + * 0 = bone dry (no rain, or the cloth is over it); 1 = full downpour on open + * ground. Both terms matter and neither is enough alone: a sail over the bed + * in a downpour is 0, and a clear sky with no sail is also 0. It uses the + * grid we already rebuild for the rain, so it costs a few array reads. + * + * Note it moves on its own during storm_02: the rain shadow follows the wind, + * so the southerly change walks the dry patch off the bed and the drain + * starts climbing without a single corner having failed. That's the mechanic, + * not a bug — worth not "fixing" if it looks surprising in the HUD. + */ + gardenExposure(rect, t) { + const rain = wind.rainAt(t); + if (rain <= 0) return 0; + return rain * (1 - shadow.fractionOver(rect)); + }, + /** Wire to the first click/keydown — browsers won't start audio otherwise. */ unlockAudio() { audio.unlock(); }, @@ -539,6 +569,24 @@ export function createSkyFx(o = {}) { o.onEvent(ev.text); } } + + // --- lightning on the biggest gusts --- + // The storm's worst moments should light up, not only the three authored + // strikes. Driven off the telegraph (which is also what the HUD banner and + // the audio whoosh read), so the flash lands WITH the gust that earned it. + // Deterministic: the gust timeline is, and each gust fires at most once. + const tgl = wind.gustTelegraph(t); + if (tgl && tgl.power >= lightningGustPow) { + // key on the ramp instant — stable across frames, unique per gust + const key = Math.round((t + tgl.eta) * 100); + if (!firedGusts.has(key)) { + firedGusts.add(key); + const p = Math.min(1, 0.45 + (tgl.power - lightningGustPow) * 0.06); + flashQueue.push({ at: t + tgl.eta, power: p }); + flashQueue.push({ at: t + tgl.eta + 0.08 + p * 0.06, power: p * 0.5 }); + flashQueue.push({ at: t + tgl.eta + 1.1, power: 0, thunder: p }); + } + } for (let i = flashQueue.length - 1; i >= 0; i--) { if (flashQueue[i].at <= t) { const f = flashQueue[i]; @@ -552,7 +600,7 @@ export function createSkyFx(o = {}) { // --- sky --- skyCol.copy(baseSky).lerp(target, storminess * darkness); - if (flash > 0) skyCol.lerp(new THREE.Color(0xdfe8ff), Math.min(0.85, flash)); + if (flash > 0) skyCol.lerp(FLASH_COL, Math.min(0.85, flash)); if (scene) { if (scene.fog) { scene.fog.color.copy(skyCol); @@ -565,6 +613,22 @@ export function createSkyFx(o = {}) { dome.position.copy(camPos); dome.material.opacity = storminess * 0.85; + // Tint the CLOUDS, not just the sky behind them. The dome covers the + // background at ~0.85 opacity, so darkening `scene.background` alone did + // nothing — a "wild night" still read as an overcast afternoon because the + // grey cloud texture was what you were actually looking at. + // + // Both a lerp AND a brightness crush: the lerp alone lands ~#717273 because + // it runs in linear space (84% toward night still reads mid-grey in sRGB) + // and the cloud texture is baked near-white. The crush is what makes night + // look like night. It stops at 0.78 on purpose — the yard has no lights in + // it yet, and a storm you can't see isn't a storm, it's a black screen. + const nightAmt = storminess * darkness; + dome.material.color.copy(WHITE) + .lerp(target, nightAmt * 0.92) + .multiplyScalar(1 - 0.78 * nightAmt); + // and lightning lights the cloud it's inside, which is the whole look + if (flash > 0) dome.material.color.lerp(FLASH_COL, Math.min(0.9, flash)); domeTex.offset.x = (domeTex.offset.x + scroll * dt * (0.4 + speed * 0.05)) % 1; domeTex.offset.y = (domeTex.offset.y + scroll * dt * 0.12) % 1; diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index d5f9732..e7e4e8c 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -21,7 +21,10 @@ import { createDebris } from '../debris.js'; import { createSkyFx, RainShadow } from '../skyfx.js'; import { weatherCases } from './weather.selftest.js'; -const STORMS = ['storm_01_gentle', 'storm_02_wildnight']; +// Keep in step with data/storms/. The node runner globs the directory, so this +// list going stale shows up here first — as it did when storm_03 landed and the +// ponding case reached for a storm the browser half had never loaded. +const STORMS = ['storm_01_gentle', 'storm_02_wildnight', 'storm_03_southerly']; /** @param {import('../testkit.js').Suite} t */ export default async function run(t) { @@ -213,6 +216,55 @@ export default async function run(t) { assert(half > 0.2 && half < 0.8, `a rect straddling the edge reads ${half}, want a partial`); }); + // --- decision 7: the drain helper Lane A wires garden HP to --- + t.test('gardenExposure is rain AND no cover, and needs both', () => { + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(); + const wind = createWind(storms.storm_02_wildnight); + const sky = createSkyFx({ scene, camera, wind }); + const bed = { x: 0, z: 0, w: 4, d: 3 }; + + // no sail: exposure is simply the rain + fixedLoop(1, FIXED_DT, (dt, time) => sky.step(dt, 70 + time, {})); + const open = sky.gardenExposure(bed, 70); + assert(Math.abs(open - wind.rainAt(70)) < 1e-9, + `open ground should be exposed exactly as hard as it rains: ${open} vs ${wind.rainAt(70)}`); + assert(open > 0.5, 'storm_02 at t=70 should be pouring'); + + // a panel over the bed dries it out + const panel = { pos: new Float32Array([-3, 3, -3, 3, 3, -3, 3, 3, 3, -3, 3, 3]), tris: [0, 1, 2, 0, 2, 3] }; + fixedLoop(1, FIXED_DT, (dt, time) => sky.step(dt, 70 + time, { sail: panel })); + const covered = sky.gardenExposure(bed, 70); + assert(covered < open * 0.5, `cloth over the bed barely helped: ${covered.toFixed(2)} vs open ${open.toFixed(2)}`); + + // and no rain means no drain, sail or not + assert(sky.gardenExposure(bed, 0) === 0, 'the bed is draining before the rain has started'); + sky.dispose(); + }); + + t.test('storm_02 lights up on its biggest gusts, storm_01 does not', () => { + const mk = (name) => { + const scene = new THREE.Scene(); + const wind = createWind(storms[name]); + const sky = createSkyFx({ scene, camera: new THREE.PerspectiveCamera(), wind }); + let flashes = 0, peak = 0; + let was = 0; + fixedLoop(wind.duration, FIXED_DT, (dt, time) => { + sky.step(dt, time, {}); + if (sky.flash > was + 0.05) flashes++; // a rising edge = a strike + was = sky.flash; + peak = Math.max(peak, sky.flash); + }); + sky.dispose(); + return { flashes, peak }; + }; + const wild = mk('storm_02_wildnight'); + const gentle = mk('storm_01_gentle'); + // 3 authored strikes + the worst gusts; must be more than the authored ones + assert(wild.flashes > 3, `wild night only flashed ${wild.flashes} times — the big gusts aren't lighting up`); + assert(gentle.flashes === 0, `the gentle storm flashed ${gentle.flashes} times — it has no lightning at all`); + }); + t.test('every storm in data/storms/ loads and validates', () => { // loadStorm throws on invalid, so reaching here with all of them is the pass assert(Object.keys(storms).length === STORMS.length, 'a storm failed to load'); diff --git a/web/world/js/tests/weather.selftest.js b/web/world/js/tests/weather.selftest.js index 1b29778..70abb7a 100644 --- a/web/world/js/tests/weather.selftest.js +++ b/web/world/js/tests/weather.selftest.js @@ -11,7 +11,7 @@ // call it with the fetched storm defs. import { - createWindField, validateStorm, gustEnvelope, GUST, + createWindField, validateStorm, gustEnvelope, GUST, RAIN_TIME_COMPRESSION, } from '../weather.core.js'; const DT = 1 / 60; @@ -405,6 +405,105 @@ export function weatherCases(storms) { assert(!validateStorm(legacy, 'legacy').ok, 'validator silently accepted the pre-SPRINT3 downdraft field'); }); + // ---- 10. the ponding story (SPRINT4 decision 10) ---- + // Lane B owns water-on-cloth; these assert the RAIN DATA can tell the story + // their mass model needs, using their own arithmetic, before their cloth lands. + // B measured: 5 cm over a 25 m² flat sail = 1250 kg = 3.1 kN/corner, against a + // storm_02 wind load of only 0.2–1.1 kN. So depth is the whole mechanic. + const FLAT_AREA = 25; // m², B's reference flat sail + const KILL_DEPTH_MM = 50; // 5 cm — B's 3.1 kN/corner + /** Water on a flat sail over a whole storm, at decision 10's compression. */ + const stormDepthMm = (def) => { + const f = createWindField(def); + return f.rainDepthMm(0, f.duration) * RAIN_TIME_COMPRESSION; + }; + /** B's arithmetic: mm over an area → kN per corner (4 corners, 1000 kg/m³). */ + const kNPerCorner = (mm) => (mm / 1000) * FLAT_AREA * 1000 * 9.81 / 4 / 1000; + + test('storm_02 rain can drown a flat rig; storm_01 rain cannot', () => { + const wild = stormDepthMm(storms.storm_02_wildnight); + const gentle = stormDepthMm(storms.storm_01_gentle); + metrics['storm_02.pondDepth_mm'] = +wild.toFixed(1); + metrics['storm_02.pond_kN_per_corner'] = +kNPerCorner(wild).toFixed(2); + metrics['storm_01.pondDepth_mm'] = +gentle.toFixed(2); + metrics['storm_01.pond_kN_per_corner'] = +kNPerCorner(gentle).toFixed(3); + + assert(wild >= KILL_DEPTH_MM * 0.9, + `storm_02 only delivers ${wild.toFixed(1)} mm — Lane B needs ~${KILL_DEPTH_MM} mm to drown a flat rig`); + // and it must dwarf the wind it's competing with (B: 0.2–1.1 kN/corner) + assert(kNPerCorner(wild) > 1.5, + `storm_02 ponding is only ${kNPerCorner(wild).toFixed(2)} kN/corner — no stronger than the wind`); + // the gentle storm must be unable to hurt anything, or the ramp is a lie + assert(kNPerCorner(gentle) < 0.3, + `storm_01 ponds ${kNPerCorner(gentle).toFixed(2)} kN/corner — a light shower must not threaten a rig`); + assert(wild > gentle * 20, 'the wild night should deliver vastly more water than a shower'); + }); + + test('storm_03 ponding sits between the other two', () => { + const mid = stormDepthMm(storms.storm_03_southerly); + const wild = stormDepthMm(storms.storm_02_wildnight); + const gentle = stormDepthMm(storms.storm_01_gentle); + metrics['storm_03.pondDepth_mm'] = +mid.toFixed(1); + metrics['storm_03.pond_kN_per_corner'] = +kNPerCorner(mid).toFixed(2); + assert(mid > gentle * 3 && mid < wild * 0.5, + `storm_03 delivers ${mid.toFixed(1)} mm — wanted a real middle rung between ${gentle.toFixed(1)} and ${wild.toFixed(1)}`); + // it should threaten a carabiner (1.2 kN) once wind is added, not a shackle (3.2) + assert(kNPerCorner(mid) > 0.25 && kNPerCorner(mid) < 1.2, + `storm_03 ponds ${kNPerCorner(mid).toFixed(2)} kN/corner — should tease a cheap rig, not drown a decent one`); + }); + + test('rain depth integrates monotonically and matches its curve', () => { + const f = createWindField(storms.storm_02_wildnight); + // depth only ever accumulates + let prev = 0; + for (let t = 1; t <= f.duration; t += 1) { + const d = f.rainDepthMm(0, t); + assert(d >= prev - 1e-9, `rain depth went BACKWARDS at t=${t}: ${d} < ${prev}`); + prev = d; + } + // splitting the interval must give the same water (no double-count, no gap) + const whole = f.rainDepthMm(0, 90); + const split = f.rainDepthMm(0, 30) + f.rainDepthMm(30, 60) + f.rainDepthMm(60, 90); + assert(Math.abs(whole - split) < 0.05, + `depth(0,90)=${whole.toFixed(3)} but the three thirds sum to ${split.toFixed(3)}`); + // dead calm before the rain starts + assert(f.rainDepthMm(0, 0) === 0, 'zero-length interval delivered water'); + assert(f.rainDepthMm(10, 5) === 0, 'a backwards interval delivered water'); + }); + + test('rainMmPerHour tracks rainAt against the storm scale', () => { + for (const [name, def] of defs) { + if (!def.rain || !def.rain.curve) continue; + const f = createWindField(def); + const peak = def.rain.peakMmPerHour; + assert(Number.isFinite(peak), `${name} has a rain curve but no peakMmPerHour`); + for (let t = 0; t <= f.duration; t += 2.5) { + const want = f.rainAt(t) * peak; + assert(Math.abs(f.rainMmPerHour(t) - want) < 1e-9, + `${name}: rainMmPerHour ${f.rainMmPerHour(t)} != rainAt×peak ${want} at t=${t}`); + } + // rainAt stays a 0..1 intensity — skyfx uses it for drop count and opacity + for (let t = 0; t <= f.duration; t += 2.5) { + const r = f.rainAt(t); + assert(r >= 0 && r <= 1, `${name}: rainAt ${r} outside 0..1 at t=${t}`); + } + } + }); + + test('validator rejects a rain curve with no scale, and a silly scale', () => { + const noScale = JSON.parse(JSON.stringify(storms.storm_02_wildnight)); + delete noScale.rain.peakMmPerHour; + assert(!validateStorm(noScale, 'x').ok, 'validator accepted a rain curve with no mm/hr scale — ponding would silently use the default'); + for (const mm of [-1, 5000, NaN]) { + const d = JSON.parse(JSON.stringify(storms.storm_02_wildnight)); + d.rain.peakMmPerHour = mm; + assert(!validateStorm(d, 'x').ok, `validator accepted peakMmPerHour=${mm}`); + } + const hot = JSON.parse(JSON.stringify(storms.storm_02_wildnight)); + hot.rain.curve = [[0, 0], [45, 3], [90, 0]]; + assert(!validateStorm(hot, 'x').ok, 'validator accepted rain intensity above 1 — the scale is peakMmPerHour, not the curve'); + }); + return { cases, metrics }; } diff --git a/web/world/js/weather.core.js b/web/world/js/weather.core.js index 8856a45..24b71e3 100644 --- a/web/world/js/weather.core.js +++ b/web/world/js/weather.core.js @@ -132,6 +132,25 @@ function sampleAngleCurve(curve, t) { // 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); @@ -378,6 +397,45 @@ export function createWindField(def, opts = {}) { if (r.curve) return Math.min(1, Math.max(0, sampleCurve(r.curve, t))); return Math.min(1, Math.max(0, r.intensity ?? 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; @@ -446,6 +504,21 @@ export function validateStorm(def, name = 'storm') { } 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)'); + } + } return { ok: errors.length === 0, errors }; } diff --git a/web/world/js/weather.js b/web/world/js/weather.js index 422395c..4377552 100644 --- a/web/world/js/weather.js +++ b/web/world/js/weather.js @@ -10,9 +10,9 @@ // determinism rule can't be broken by accident. import * as THREE from '../vendor/three.module.js'; -import { createWindField, validateStorm, GUST } from './weather.core.js'; +import { createWindField, validateStorm, GUST, RAIN_TIME_COMPRESSION } from './weather.core.js'; -export { GUST, validateStorm }; +export { GUST, validateStorm, RAIN_TIME_COMPRESSION }; // Resolved against this module, not the server root: server.py serves the repo // root (so the 2D prototype stays reachable), but the demo bench serves web/. @@ -84,9 +84,16 @@ export function createWind(def, opts = {}) { /** Storm events fired in (a,b] — poll with (t-dt, t). Deterministic. */ eventsBetween(a, b) { return field.eventsBetween(a, b); }, - /** 0..1 rain intensity. */ + /** 0..1 rain intensity — drives drop count and opacity. */ rainAt(t) { return field.rainAt(t); }, + /** Rain rate in real-world mm/hr. Ponding (decision 10) reads this. */ + rainMmPerHour(t) { return field.rainMmPerHour(t); }, + + /** Real-world mm of water delivered over (t0,t1]. Multiply by + * RAIN_TIME_COMPRESSION cloth-side — see weather.core. */ + rainDepthMm(t0, t1) { return field.rainDepthMm(t0, t1); }, + /** Direction (radians, XZ plane from +X toward +Z) ignoring local effects. */ dirAt(t) { return field.dirAt(t); },