From 1f99cd9bca22b294ed5dd6fb612ee75b6b26803f Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 02:28:09 +1000 Subject: [PATCH 1/2] Rain gets physical units for ponding; night pass on wildnight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPRINT4 §Lane C 2/3/4. PONDING DATA (decision 10). rainAt() was a dimensionless 0..1 — fine for drop count and opacity, useless for water mass. Lane B would have had to invent the mm/hr scale, which is exactly the "default-off code tuned by a constant I invented" they rightly reverted. So the scale is storm data now: rain.peakMmPerHour (validated 0..300; a rain curve without one is a hard error, since ponding would silently use the default instead of what the author meant), plus wind.rainMmPerHour(t) and rainDepthMm(t0,t1). RAIN_TIME_COMPRESSION=40 is exported from weather.core so B applies it cloth-side rather than either of us hardcoding 40 twice: how hard it rains is mine, how much water a sail holds is theirs. Calibrated to B's own arithmetic, and the numbers land on it: storm_02 80 mm/hr severe -> 50.9 mm = 3.12 kN/corner (B predicted 3.1) storm_03 30 mm/hr moderate -> 8.3 mm = 0.51 kN/corner (teases a carabiner) storm_01 8 mm/hr shower -> 0.9 mm = 0.06 kN/corner (harmless, as designed) against a storm_02 wind load of 0.2-1.1 kN. A flat rig should drown in the wild night; a hypar pools nothing and won't notice. Asserted with B's arithmetic so the storms are provably fit for their water before their cloth lands. DECISION 7 helper for Lane A: sky.gardenExposure(bed, t) = rainAt x (1-shadow), the whole drain term in one call. Note it moves on its own — the rain shadow follows the wind, so the southerly change walks the dry patch off the bed and the drain climbs with no corner having failed. NIGHT PASS. sky.night is now the author's call (was: inferred from a darkness threshold), and darkening scene.background did nothing anyway — the cloud dome covers it at 0.85 opacity, so an overcast-grey texture was what you actually saw. The dome now tints AND crushes (a lerp alone lands #717273: it runs in linear space and the texture is baked near-white). Stops at 0.78 because the yard has no lights and a storm you can't see is a black screen. Lightning now lights the cloud it's inside (#3e3e3f -> #d4ddf2), and fires on the biggest gusts via sky.lightningGustPow, not just the three authored strikes — driven off the telegraph so the flash lands with the gust that earned it. Also: c.test's storm list was hardcoded to two storms while the node runner globs the directory, so storm_03 was untested in the browser half. Its own ponding assert caught it. Selftest 195/0/0 (was 184). Verified live: night reads as night, flash lights the cloud, exposure responds. Co-Authored-By: Claude Opus 4.8 --- web/world/data/storms/storm_01_gentle.json | 4 +- web/world/data/storms/storm_02_wildnight.json | 8 +- web/world/data/storms/storm_03_southerly.json | 4 +- web/world/js/skyfx.js | 68 +++++++++++- web/world/js/tests/c.test.js | 54 +++++++++- web/world/js/tests/weather.selftest.js | 101 +++++++++++++++++- web/world/js/weather.core.js | 73 +++++++++++++ web/world/js/weather.js | 13 ++- 8 files changed, 314 insertions(+), 11 deletions(-) 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); }, From acb35752ee4877f773e25bd4351b6a017f905231 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 02:29:17 +1000 Subject: [PATCH 2/2] Log ponding data, the router gap, decision 7 helper and the night pass Leads with the router allowlist gap: rig.step() gets the router, which doesn't forward rainMmPerHour, so B's ponding would pass every assert and do nothing in-game. Two lines in main.js. Co-Authored-By: Claude Opus 4.8 --- THREADS.md | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/THREADS.md b/THREADS.md index 1e8f450..ec693aa 100644 --- a/THREADS.md +++ b/THREADS.md @@ -1181,3 +1181,87 @@ Format: `[lane letter] YYYY-MM-DD — note` represents a whole night of story, so a storm delivers a night's water; that's the time-compression fiat B asked someone to own. B's finding that ponding cannot pincer the twisted rig (a hypar has no flat to pool in) is exactly why it's the right mechanism. + +[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.