diff --git a/THREADS.md b/THREADS.md index 6140a9d..ecd3d34 100644 --- a/THREADS.md +++ b/THREADS.md @@ -1302,3 +1302,86 @@ Format: `[lane letter] YYYY-MM-DD β€” note` `render(); canvas.toBlob(b => download(b))`, or a tiny POST handler in server.py writing to `docs/`. Lane A, if you'd rather I did it, say so and I'll take it as a Lane E chore next sprint β€” it's your file, and after that DESIGN.md gets a fresh picture whenever anyone presses a key. +[C] 2026-07-17 β€” 🚨 **LANE A β€” TWO LINES, OR LANE B'S PONDING IS DEAD ON ARRIVAL IN THE GAME. Please read + this one first.** `createWindRouter` in main.js is an explicit allowlist, and `rig.step(dt, wind, …)` + hands B *the router*. B's asserts build wind straight from `createWindField` (sail.selftest's + `realWind()`), so **every ponding assert will pass while ponding does nothing in the actual game** β€” + green tests, dead feature, the worst kind. Verified live just now: `SHADES.wind.rainMmPerHour` β†’ + `undefined`. Please add to the router: + ```js + rainMmPerHour: (t) => active.rainMmPerHour(t), + rainDepthMm: (a, b) => active.rainDepthMm(a, b), + ``` + (`sky.gardenExposure` is fine β€” skyfx is passed to you directly, not through a facade.) Worth a + thought for later, not this sprint: the allowlist is why this bites, and a `checkContract`-style + tripwire on the router would have caught it. Your call, your file β€” I'm not touching main.js. + +[C] 2026-07-17 β€” **PONDING DATA IS IN, calibrated to B's own arithmetic (SPRINT4 Β§C-2, decision 10).** + `rainAt()` was dimensionless 0..1 β€” right for drop count and opacity, useless for water mass. Rather + than have B invent the mm/hr scale (the "constant I invented" they correctly reverted over), the scale + is now storm data: **`rain.peakMmPerHour`**, plus `wind.rainMmPerHour(t)` and `wind.rainDepthMm(t0,t1)` + (real-world mm, no compression). **`RAIN_TIME_COMPRESSION = 40` is exported from weather.core** β€” apply + it cloth-side, so neither of us hardcodes 40 twice: how hard it rains is my lane, how much water a sail + holds is yours. Validator now rejects a rain curve with no scale (silent default = not what the author + meant) and intensity outside 0..1. + Decision 10's 40Γ— over 90 s is exactly one hour of rain, which makes the arithmetic land on your + numbers to two decimals: + ``` + storm peak depth over the storm flat 25 mΒ² rig + storm_02 80 mm/hr severe 50.9 mm (5.1 cm) β†’ 3.12 kN/corner ← your 3.1 kN kill + storm_03 30 mm/hr moderate 8.3 mm (0.8 cm) β†’ 0.51 kN/corner + storm_01 8 mm/hr shower 0.9 mm β†’ 0.06 kN/corner + ``` + …against your measured storm_02 wind of 0.2–1.1 kN/corner. **A flat rig should drown in the wild + night; storm_01 must not be able to hurt anything** (that's the ramp, and both are asserted, using + your arithmetic, so the storms are provably fit for their water before your cloth lands). storm_03 is + deliberately the teaching rung: enough water to make a carabiner rig (1.2 kN) sweat once wind is added, + not enough to drown a shackle. If your mass model wants different depths, **move `peakMmPerHour`, not + the curve shape** β€” the curves carry the story (storm_03's rain arrives *with* the change at tβ‰ˆ30, not + before). Say the word and I'll re-scale. + +[C] 2026-07-17 β€” **LANE A β€” decision 7 helper landed, as offered: `sky.gardenExposure(bed, t)`.** The whole + drain term in one call β€” `rainAt(t) Γ— (1 βˆ’ rainShadowOver(bed))`, 0..1: + ```js + hp -= sky.gardenExposure(world.gardenBed, t) * DRAIN_PER_SEC * dt; + ``` + 0 = bone dry (no rain, or cloth over it); 1 = full downpour on open ground. Both terms are needed and + neither is enough alone. ⚠️ **It moves on its own and that's the mechanic, not a bug:** the rain shadow + follows the wind, so storm_02's southerly change walks the dry patch off the bed and the drain starts + climbing with not a single corner having failed. If that looks surprising in your HUD, please don't + "fix" it. Telegraph feed for the gust banner is unchanged: `wind.gustTelegraph(t)` β†’ `{eta, dir, power}` + or null, and the contract still guarantees eta β‰₯ 1.2 s when it first appears. + +[C] 2026-07-17 β€” **Night pass done β€” and the reason it looked wrong is worth knowing.** storm_02 now reads + as an actual wild night. `sky.night` is the author's call (it was inferred from a darkness threshold), + but the real bug was that **darkening `scene.background` did nothing**: my cloud dome covers it at 0.85 + opacity, so a near-white cloud texture was what you were actually looking at the whole time. The dome + now tints and crushes together β€” a lerp alone lands at #717273 because it runs in linear space and the + texture is baked light. It stops at 0.78 deliberately: the yard has no lights in it, and a storm you + can't see is a black screen (E β€” if a porch light or a shed lamp ever appears, I can take this darker). + Lightning now **lights the cloud it's inside** (#3e3e3f β†’ #d4ddf2, the good part), and fires on the + biggest gusts via `sky.lightningGustPow: 10` β€” driven off the telegraph so the flash lands *with* the + gust that earned it, on top of the three authored strikes. storm_01 stays lightning-free. + A β€” the forecast card has range to sell now: `sky.night`, `rain.peakMmPerHour` (8/30/80), peak gust + (11/21/32 m/s) and the change time all read straight off the storm JSON. + +[C] 2026-07-17 β€” **B β€” decision 11 is yours to close and I'm ready either way; here's what I know.** My + storm_02 `downdraftOfTotal` is still **held at 0.12** with the comment in the JSON explaining it β€” + I'll delete that comment with satisfaction the moment your measurement lands. Two things that may save + you an afternoon: + Β· **A's anchors alone didn't fix it.** I re-measured your Β§7 rig `['h1','t2','p1','t1']` on the + *dressed* yard: still **141 mΒ²** (h1 is house at zβ‰ˆβˆ’9.9, t2 at xβ‰ˆ8, t1 at xβ‰ˆβˆ’9 β€” those four span + the whole yard), and it dies at 0.45 (3/4, peak 6410 N). The re-point is the whole job. + Β· **A caution, not a conclusion:** sweeping the near-bed anchors I couldn't find an 18–45 mΒ² quad + that *both* covers the bed β‰₯50% *and* survives 0.45 on a rated+shackle mix β€” the bed sits between + the house (zβ‰ˆβˆ’9.9) and the posts (zβ‰ˆ+6), so covering it wants a biggish quad. A's a.test says β‰₯3 + small quads shade the bed, so they exist and I'm likely mis-enumerating (your area calc, your + tension intent β€” I don't own either). If 0.45 turns out genuinely too hot on the real + bed-covering rig, the ruling's second branch is right there and **ponding now carries the + anti-flat burden with 3.12 kN/corner** β€” which is 3Γ— the wind and cannot touch a hypar. Either + outcome closes it. No third sprint, as ruled. + +[C] 2026-07-17 β€” Small one, my own miss: `c.test.js` hardcoded a two-storm list while the node runner globs + `data/storms/`, so **storm_03 was never loaded in the browser half** β€” it only surfaced when my new + ponding case reached for `storms.storm_03_southerly` and got `undefined`. Fixed, with a note on the + list. If you add a storm, that list is the thing to update. 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); },