Merge remote-tracking branch 'origin/lane/c'
# Conflicts: # THREADS.md
This commit is contained in:
commit
da35a954e0
83
THREADS.md
83
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/`.
|
`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
|
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.
|
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.
|
||||||
|
|||||||
@ -24,7 +24,9 @@
|
|||||||
|
|
||||||
"events": [],
|
"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 }
|
"sky": { "darkness": 0.15, "cloudScroll": 0.02 }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,7 +38,11 @@
|
|||||||
{ "t": 79, "type": "lightning", "power": 0.5 }
|
{ "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 }
|
{ "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 }
|
"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 CALM_SKY = new THREE.Color(0x9fc4e8);
|
||||||
const STORM_SKY = new THREE.Color(0x2a2f3a);
|
const STORM_SKY = new THREE.Color(0x2a2f3a);
|
||||||
const NIGHT_SKY = new THREE.Color(0x11141c);
|
const NIGHT_SKY = new THREE.Color(0x11141c);
|
||||||
|
const WHITE = new THREE.Color(0xffffff);
|
||||||
|
const FLASH_COL = new THREE.Color(0xdfe8ff);
|
||||||
|
|
||||||
// ------------------------------------------------------------ rain shadow
|
// ------------------------------------------------------------ rain shadow
|
||||||
/**
|
/**
|
||||||
@ -440,7 +442,13 @@ export function createSkyFx(o = {}) {
|
|||||||
const skyDef = def.sky || {};
|
const skyDef = def.sky || {};
|
||||||
const darkness = skyDef.darkness ?? 0.7;
|
const darkness = skyDef.darkness ?? 0.7;
|
||||||
const scroll = skyDef.cloudScroll ?? 0.06;
|
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 });
|
const rain = createRain({ groundY: o.groundY ?? 0 });
|
||||||
if (scene) scene.add(rain.mesh);
|
if (scene) scene.add(rain.mesh);
|
||||||
@ -510,6 +518,28 @@ export function createSkyFx(o = {}) {
|
|||||||
*/
|
*/
|
||||||
rainShadowOver(rect) { return shadow.fractionOver(rect); },
|
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. */
|
/** Wire to the first click/keydown — browsers won't start audio otherwise. */
|
||||||
unlockAudio() { audio.unlock(); },
|
unlockAudio() { audio.unlock(); },
|
||||||
|
|
||||||
@ -539,6 +569,24 @@ export function createSkyFx(o = {}) {
|
|||||||
o.onEvent(ev.text);
|
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--) {
|
for (let i = flashQueue.length - 1; i >= 0; i--) {
|
||||||
if (flashQueue[i].at <= t) {
|
if (flashQueue[i].at <= t) {
|
||||||
const f = flashQueue[i];
|
const f = flashQueue[i];
|
||||||
@ -552,7 +600,7 @@ export function createSkyFx(o = {}) {
|
|||||||
|
|
||||||
// --- sky ---
|
// --- sky ---
|
||||||
skyCol.copy(baseSky).lerp(target, storminess * darkness);
|
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) {
|
||||||
if (scene.fog) {
|
if (scene.fog) {
|
||||||
scene.fog.color.copy(skyCol);
|
scene.fog.color.copy(skyCol);
|
||||||
@ -565,6 +613,22 @@ export function createSkyFx(o = {}) {
|
|||||||
|
|
||||||
dome.position.copy(camPos);
|
dome.position.copy(camPos);
|
||||||
dome.material.opacity = storminess * 0.85;
|
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.x = (domeTex.offset.x + scroll * dt * (0.4 + speed * 0.05)) % 1;
|
||||||
domeTex.offset.y = (domeTex.offset.y + scroll * dt * 0.12) % 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 { createSkyFx, RainShadow } from '../skyfx.js';
|
||||||
import { weatherCases } from './weather.selftest.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 */
|
/** @param {import('../testkit.js').Suite} t */
|
||||||
export default async function run(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`);
|
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', () => {
|
t.test('every storm in data/storms/ loads and validates', () => {
|
||||||
// loadStorm throws on invalid, so reaching here with all of them is the pass
|
// 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');
|
assert(Object.keys(storms).length === STORMS.length, 'a storm failed to load');
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
// call it with the fetched storm defs.
|
// call it with the fetched storm defs.
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createWindField, validateStorm, gustEnvelope, GUST,
|
createWindField, validateStorm, gustEnvelope, GUST, RAIN_TIME_COMPRESSION,
|
||||||
} from '../weather.core.js';
|
} from '../weather.core.js';
|
||||||
|
|
||||||
const DT = 1 / 60;
|
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');
|
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 };
|
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.
|
// Prototype: pow = 12 + rand*16 + 10*p, next = t + 5 + rand*7. Same shape, from JSON.
|
||||||
export const DEFAULT_DOWNDRAFT = 0.22;
|
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) {
|
export function buildGustTimeline(def, seed) {
|
||||||
const g = def.gusts || {};
|
const g = def.gusts || {};
|
||||||
const rng = mulberry32(seed >>> 0);
|
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)));
|
if (r.curve) return Math.min(1, Math.max(0, sampleCurve(r.curve, t)));
|
||||||
return Math.min(1, Math.max(0, r.intensity ?? 0));
|
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;
|
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 && 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 };
|
return { ok: errors.length === 0, errors };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,9 +10,9 @@
|
|||||||
// determinism rule can't be broken by accident.
|
// determinism rule can't be broken by accident.
|
||||||
|
|
||||||
import * as THREE from '../vendor/three.module.js';
|
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
|
// 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/.
|
// 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. */
|
/** Storm events fired in (a,b] — poll with (t-dt, t). Deterministic. */
|
||||||
eventsBetween(a, b) { return field.eventsBetween(a, b); },
|
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); },
|
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. */
|
/** Direction (radians, XZ plane from +X toward +Z) ignoring local effects. */
|
||||||
dirAt(t) { return field.dirAt(t); },
|
dirAt(t) { return field.dirAt(t); },
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user