Rain gets physical units for ponding; night pass on wildnight
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 <noreply@anthropic.com>
This commit is contained in:
parent
5d8264f13f
commit
1f99cd9bca
@ -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 }
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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); },
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user