diff --git a/THREADS.md b/THREADS.md index cd46a93..cea89fd 100644 --- a/THREADS.md +++ b/THREADS.md @@ -1608,3 +1608,46 @@ Format: `[lane letter] YYYY-MM-DD — note` `docs/yard_day.jpg`, left-hand gum. Standing offer from Sprint 4: I can taper and re-angle the limbs while pinning the `branch_anchor_*` tips so **not one anchor moves** and none of B's §7 numbers shift. It's contained and it's the most visible art problem in the hero shot. Say the word. +[C] 2026-07-17 — **HAIL LANDED — decision 13's engine. Selftest 216/0/0.** Storm hail carries the garden + score, and it works because hail falls STEEP where rain doesn't: a raindrop's terminal velocity is + ~9 m/s (a 30 m/s gale blows it in at ~73° off vertical, which is why it walks under a sail and a + perfect rig tied with no rig), a hailstone's is ~22 m/s and it couples weakly to the crosswind, so + even a gale leans it ≤20°. Overhead cloth blocks steep ice. No faked physics. + · **weather.core:** `hail` block in the storm JSON — authored `bursts` (envelopes) plus one synced + to every gust ≥ `withGustsAbove`, so the biggest gusts arrive WITH ice. `hailAt(t)` (max over live + bursts, 0..1), `hailSize`, validator. Gust-synced bursts draw ZERO randomness (key off the gust + timeline), so tuning hail can't re-time the storm — same guarantee as the downdraft, asserted. + · **storms:** storm_02 bursts ON the southerly change (peak 1.0 at t=56.5, 11.4 hail-seconds, size + 1.3) and adds ice to its late big gusts; storm_03 one mild 0.5 burst (size 0.7); storm_01 none. + · **skyfx:** `gardenHailExposure(bed, t)` in the gardenExposure mold (the feed A wires), a steep + second RainShadow, instanced falling stones (hidden under the cloth so you SEE the sail work), and + hail audio — ground clatter that fades as the sail intercepts, plus the cloth DRUM that rises as + the sail catches hail (the "my sail is earning its money" sound). Also `sky.hailAmount` getter. + +[C] 2026-07-17 — **DECISION 13 / GATE 2 PROVEN: no-sail garden takes 4.4× the hail of a well-covered bed** + over a full storm_02 (bar is ≥2×), through the real `gardenHailExposure` + B's SailRig. Asserted in + c.test.js. Verified live too: at the burst a bed-covering rig cuts hail exposure roughly in half, and + the stones fall visibly steeper than the rain beside them (screenshot for DESIGN.md). **A — your garden + score will now separate a good rig from no rig.** Wiring, per SPRINT5 §A-1: + `gardenDrain = sky.gardenHailExposure(bed, t) * HAIL_DAMAGE + sky.gardenExposure(bed, t) * SMALL_RAIN`. + Hail is the headline (aftermath "hail blocked" line reads off `sky.hailShadowOver(bed)` × how much hail + fell); rain stays the small honest drain that walks under the sail. `sky.hailAmount` (0..1) is your + "HAIL" banner trigger; the drum audio already tells the player when the sail is catching it. + +[C] 2026-07-17 — **A — I edited main.js's router (two lines): `hailAt`/`hailSize`. Flagging since it's your + file.** Your tripwire ('wind router forwards EVERYTHING') went red the instant weather.js grew hailAt — + which is the tripwire earning its keep; it even names hail as the case it's guarding. Your comment on + createWindRouter is the standing instruction ("Anything new on the wind contract must be added here + too"), so I followed it rather than leaving main red for you to find. Same pattern as the existing + forwards, tripwire green again, hail verified reaching skyfx in-game with no console patch. Thank you + for building it — it turned last sprint's silent-swallow into a red test that told me exactly what to + add. Revert my two lines only if you'd rather own them; the forward itself has to stay or decision 13 + is inert in the game. + +[C] 2026-07-17 — **E — hail juice hooks, when you're ready (SPRINT5 §E).** The stones are cheap instanced + cubes right now — deliberately minimal so your pips/decals are the detail layer. If you ship impact + pips-on-cloth + a ground decal ring, I'll spawn them where a stone meets the sail ceiling or the + ground (I already keep a hail-shadow grid that knows the ceiling height per cell, so a cloth-impact + point is cheap). Plant-shred puff wants a trigger: fire it when `gardenHailExposure(bed,t)` crosses a + threshold — that's the frame the bed actually takes a hit. Tell me the hook shape and I'll match it; + `sky.hailAmount` and `hailSize` are live for scaling pip rate/size. diff --git a/web/world/data/storms/storm_02_wildnight.json b/web/world/data/storms/storm_02_wildnight.json index b0b560a..e95237e 100644 --- a/web/world/data/storms/storm_02_wildnight.json +++ b/web/world/data/storms/storm_02_wildnight.json @@ -42,6 +42,17 @@ "rain": { "peakMmPerHour": 80, "curve": [[0, 0], [10, 0.25], [35, 0.6], [55, 0.85], [70, 1.0], [90, 0.7]] }, + "_hail_comment": "Hail carries the garden score (decision 13) — steep dense stones a sail actually blocks, unlike rain which walks under it. The authored burst lands ON the southerly change (t55) for maximum drama; withGustsAbove 10 then adds a burst to every late big gust (gusts top ~12.6, so the worst few carry ice). size 1.3 = decent damaging stones. A garden under a good rig should be MUCH better off than one in the open — asserted at >=2x.", + + "hail": { + "size": 1.3, + "withGustsAbove": 10, + "gustBurstIntensity": 0.85, + "bursts": [ + { "t": 55, "ramp": 1.5, "hold": 5, "fade": 2.5, "intensity": 1.0 } + ] + }, + "_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 9d26896..d9b3a50 100644 --- a/web/world/data/storms/storm_03_southerly.json +++ b/web/world/data/storms/storm_03_southerly.json @@ -36,5 +36,14 @@ "rain": { "peakMmPerHour": 30, "curve": [[0, 0], [28, 0.05], [34, 0.4], [55, 0.55], [80, 0.3], [90, 0.15]] }, + "_hail_comment": "One mild burst rolling in behind the change — small stones, half intensity. Enough that a bed under a sail visibly beats a bed in the open, but nothing like the wild night's ice. No gust-synced hail: this is the storm you can get away with a modest rig on. size 0.7 = pea hail.", + + "hail": { + "size": 0.7, + "bursts": [ + { "t": 36, "ramp": 1.5, "hold": 3, "fade": 2, "intensity": 0.5 } + ] + }, + "sky": { "darkness": 0.5, "cloudScroll": 0.05 } } diff --git a/web/world/js/main.js b/web/world/js/main.js index 8862900..1b2db23 100644 --- a/web/world/js/main.js +++ b/web/world/js/main.js @@ -172,6 +172,7 @@ export function createWindRouter(all) { rainAt: (t) => active.rainAt(t), rainMmPerHour: (t) => active.rainMmPerHour(t), rainDepthMm: (a, b) => active.rainDepthMm(a, b), + hailAt: (t) => active.hailAt(t), // SPRINT5 decision 13 — the garden score hangs off this dirAt: (t) => active.dirAt(t), setShelters(list) { @@ -188,6 +189,7 @@ export function createWindRouter(all) { }))); }, + get hailSize() { return active.hailSize; }, // SPRINT5 decision 13 get duration() { return active.duration; }, get gusts() { return active.gusts; }, get def() { return active.def; }, diff --git a/web/world/js/skyfx.js b/web/world/js/skyfx.js index bbec07f..2798a4e 100644 --- a/web/world/js/skyfx.js +++ b/web/world/js/skyfx.js @@ -130,6 +130,89 @@ function rainVelocity(w, intensity, out) { return out.set(w.x * 0.55, -(9 + intensity * 4), w.z * 0.55); } +// Hail falls STEEP, and that is the whole of decision 13. A 1 cm stone's +// terminal velocity is ~22 m/s (a raindrop's is ~9), and a dense stone couples +// only weakly to the crosswind, so even a 30 m/s gale leans it no more than +// ~20° off vertical — where a sail overhead still blocks it. (Rain at 9 m/s in +// the same gale comes in at atan(30/9) ≈ 73°, nearly sideways, which is why it +// walks under the sail and can't score the rig.) Do NOT re-open the rain-angle +// argument here; steep is the point. +const HAIL_FALL = 22; // m/s terminal, ~1 cm ice +const HAIL_LEAN_COUPLING = 0.3; // dense stones catch little wind +const HAIL_MAX_LEAN = Math.tan(20 * Math.PI / 180); // cap ~20° off vertical + +/** Hail velocity, m/s. Steep — see the note above. Used by stones and shadow. */ +function hailVelocity(w, out) { + const cap = HAIL_FALL * HAIL_MAX_LEAN; + let hx = w.x * HAIL_LEAN_COUPLING, hz = w.z * HAIL_LEAN_COUPLING; + const mag = Math.hypot(hx, hz); + if (mag > cap) { const s = cap / mag; hx *= s; hz *= s; } + return out.set(hx, -HAIL_FALL, hz); +} + +// ---------------------------------------------------------------- hail +// Instanced falling stones, same wrap-around-the-camera trick as the rain but +// fewer, whiter, faster and much steeper. Stones under the cloth are hidden so +// you SEE the sail doing its job. Count scales with intensity; a hail-free storm +// draws nothing. +function createHail(opts) { + const max = opts.maxStones ?? 1300; + const half = opts.half ?? 16; + const height = opts.height ?? 22; + const groundY = opts.groundY ?? 0; + const rand = rng(0x4a11); + + const geo = new THREE.BoxGeometry(0.05, 0.05, 0.05); // a little cube reads as a stone + const mat = new THREE.MeshBasicMaterial({ + color: 0xeaf2ff, transparent: true, opacity: 0.9, depthWrite: false, fog: false, + }); + const mesh = new THREE.InstancedMesh(geo, mat, max); + mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); + mesh.frustumCulled = false; + mesh.renderOrder = 3; + mesh.count = 0; + + const px = new Float32Array(max), py = new Float32Array(max), pz = new Float32Array(max); + const jit = new Float32Array(max); + for (let i = 0; i < max; i++) { + px[i] = (rand() * 2 - 1) * half; + py[i] = groundY + rand() * height; + pz[i] = (rand() * 2 - 1) * half; + jit[i] = 0.85 + rand() * 0.3; + } + + const m = new THREE.Matrix4(); + const HIDDEN = new THREE.Matrix4().makeScale(0, 0, 0); + const top = groundY + height; + + return { + mesh, + step(dt, camPos, vel, intensity, size, shadow) { + const n = Math.floor(max * clamp01(intensity)); + mesh.count = n; + if (n === 0) return; + const s = 0.6 + size * 0.9; // bigger stones read bigger + m.makeScale(s, s, s); + for (let i = 0; i < n; i++) { + const j = jit[i]; + px[i] += vel.x * j * dt; + py[i] += vel.y * j * dt; // vel.y is negative + pz[i] += vel.z * j * dt; + let d = px[i] - camPos.x; + if (d > half) px[i] -= half * 2; else if (d < -half) px[i] += half * 2; + d = pz[i] - camPos.z; + if (d > half) pz[i] -= half * 2; else if (d < -half) pz[i] += half * 2; + if (py[i] < groundY) py[i] += height; else if (py[i] > top) py[i] -= height; + if (shadow && shadow.occluded(px[i], py[i], pz[i])) { mesh.setMatrixAt(i, HIDDEN); continue; } + m.elements[12] = px[i]; m.elements[13] = py[i]; m.elements[14] = pz[i]; + mesh.setMatrixAt(i, m); + } + mesh.instanceMatrix.needsUpdate = true; + }, + dispose() { geo.dispose(); mat.dispose(); }, + }; +} + // ---------------------------------------------------------------- rain function createRain(opts) { const max = opts.maxDrops ?? 3000; @@ -264,6 +347,7 @@ function createAudio(seed = 1) { let windGain, windFilter, windHowl, howlGain; let rainGain, rainFilter; let gustGain, gustFilter; + let hailGain, hailFilter, drumGain, drumFilter; let noiseBuf = null; let creakNext = 0, flogNext = 0; let started = false; @@ -344,6 +428,22 @@ function createAudio(seed = 1) { gustGain.connect(master); loop(noiseBuf, gustGain, gustFilter); + // hail clatter: bright, hard highpass — ice on concrete + hailGain = ctx.createGain(); hailGain.gain.value = 0; + hailFilter = ctx.createBiquadFilter(); + hailFilter.type = 'highpass'; hailFilter.frequency.value = 3000; + hailGain.connect(master); + loop(noiseBuf, hailGain, hailFilter); + + // the DRUM: hail on taut cloth, a low resonant thrum. This is the "my sail + // is earning its money" sound — it only speaks when the sail is actually + // catching hail, so a rig over the bed sounds different from bare sky. + drumGain = ctx.createGain(); drumGain.gain.value = 0; + drumFilter = ctx.createBiquadFilter(); + drumFilter.type = 'bandpass'; drumFilter.frequency.value = 140; drumFilter.Q.value = 3; + drumGain.connect(master); + loop(noiseBuf, drumGain, drumFilter); + started = true; }, @@ -379,6 +479,23 @@ function createAudio(seed = 1) { rainFilter.frequency.setTargetAtTime(1500 + rain * 900, now, 0.3); }, + /** + * @param {number} intensity 0..1 hail + * @param {number} onCloth 0..1 of the hail the sail overhead is catching + * @param {number} size stone-size scalar — bigger stones drum lower + */ + setHail(intensity, onCloth, size) { + if (!started) return; + const now = ctx.currentTime; + // clatter fades as the cloth intercepts more of the storm — some ice still + // reaches the ground past the sail, but the open-ground roar drops + hailGain.gain.setTargetAtTime(intensity * (1 - onCloth * 0.7) * 0.3, now, 0.15); + hailFilter.frequency.setTargetAtTime(2400 + (2 - size) * 700, now, 0.2); + // the drum rises exactly as the sail catches hail — the payoff sound + drumGain.gain.setTargetAtTime(intensity * onCloth * 0.5, now, 0.12); + drumFilter.frequency.setTargetAtTime(110 + (2 - size) * 45, now, 0.2); + }, + /** Telegraph cue: you hear it coming before you feel it. */ whoosh(power, eta) { if (!started) return; @@ -456,6 +573,21 @@ export function createSkyFx(o = {}) { const rainDir = new THREE.Vector3(); let shadowTick = 0; + // hail rides its own steep shadow — that's the whole of decision 13: the sail + // blocks steep hail (shadow ≈ its footprint) where it can't block slanted rain. + const hail = createHail({ groundY: o.groundY ?? 0 }); + if (scene) scene.add(hail.mesh); + const hailShadow = new RainShadow({ groundY: o.groundY ?? 0 }); + const hailDir = new THREE.Vector3(); + const hailWind = new THREE.Vector3(); + let hailTick = 0, hailAmt = 0; + + // hailAt lives behind the wind router (Lane A's allowlist). If a stale router + // doesn't forward it, degrade to no hail rather than crashing — but it must be + // forwarded or the garden score (decision 13) is inert. Flagged to A in THREADS. + const hailIntensity = (t) => (wind && typeof wind.hailAt === 'function' ? wind.hailAt(t) : 0); + const hailStone = () => (wind && wind.hailSize != null ? wind.hailSize : 1); + const audio = createAudio((wind && wind.seed) || 1); // cloud dome rides the camera so it can't clip the far plane whatever Lane A set @@ -502,8 +634,10 @@ export function createSkyFx(o = {}) { const w = new THREE.Vector3(); const fx = { - rain, audio, dome, shadow, + rain, audio, dome, shadow, hailShadow, get flash() { return flash; }, + /** 0..1 hail intensity right now — for the HUD ("HAIL" banner) and asserts. */ + get hailAmount() { return hailAmt; }, /** * 0..1 of a ground rect the sail is keeping dry, right now. @@ -540,6 +674,27 @@ export function createSkyFx(o = {}) { return rain * (1 - shadow.fractionOver(rect)); }, + /** 0..1 of a rect the sail is keeping hail off, this frame (steep shadow). */ + hailShadowOver(rect) { return hailShadow.fractionOver(rect); }, + + /** + * Decision 13's garden-damage feed, 0..1, the gardenExposure mold but for + * HAIL — this is what makes the garden score respond to the rig. Steep stones + * mean the sail's shadow ≈ its footprint, so a rig over the bed genuinely + * shelters it even in a gale, unlike rain (which walks under and can't score + * the sail — the whole reason a perfect rig used to tie with no rig at all). + * + * hp -= sky.gardenHailExposure(bed, t) * HAIL_DAMAGE * dt + * + sky.gardenExposure(bed, t) * SMALL_RAIN_DRAIN * dt; // A wires both + * + * 0 = no hail, or the cloth is catching it; 1 = full burst on the open bed. + */ + gardenHailExposure(rect, t) { + const h = hailIntensity(t); + if (h <= 0) return 0; + return h * (1 - hailShadow.fractionOver(rect)); + }, + /** Wire to the first click/keydown — browsers won't start audio otherwise. */ unlockAudio() { audio.unlock(); }, @@ -644,8 +799,27 @@ export function createSkyFx(o = {}) { } rain.step(dt, camPos, w, intensity, shadow); + // --- hail --- + hailAmt = hailIntensity(t); + const stone = hailStone(); + // The hail shadow follows the STEEP hail vector, not the wind. It barely + // moves, so rebuild it slowly. A tenth of a second is fine; hail rides it. + hailTick -= dt; + if (hailTick <= 0) { + hailTick = 0.12; + hailWind.set(w.x, 0, w.z); + hailVelocity(hailWind, hailDir); + const len = hailDir.length() || 1; + hailShadow.update(world.sail, hailDir.x / len, hailDir.y / len, hailDir.z / len); + } + hail.step(dt, camPos, hailDir, hailAmt, stone, hailShadow); + // how much of the hail the sail overhead is catching, for the drum sound — + // sample right above the player/camera so it's "is it drumming over ME" + const onCloth = hailAmt > 0 ? hailShadow.fractionOver({ x: camPos.x, z: camPos.z, w: 3, d: 3 }) : 0; + // --- audio --- audio.setLevels(speed, intensity); + audio.setHail(hailAmt, onCloth, stone); const tg = wind.gustTelegraph(t); if (tg && tg !== lastTelegraph) { // fires once per gust, right as the telegraph opens @@ -672,6 +846,7 @@ export function createSkyFx(o = {}) { dispose() { if (scene) { scene.remove(rain.mesh); + scene.remove(hail.mesh); scene.remove(dome); scene.background = original.background; if (ownsFog) { @@ -686,6 +861,7 @@ export function createSkyFx(o = {}) { if (sun) sun.intensity = original.sun; if (hemi) hemi.intensity = original.hemi; rain.dispose(); + hail.dispose(); dome.geometry.dispose(); dome.material.dispose(); domeTex.dispose(); diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index e7e4e8c..c920de4 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -19,6 +19,7 @@ import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS } from '../contracts.js'; import { loadStorm, createWind } from '../weather.js'; import { createDebris } from '../debris.js'; import { createSkyFx, RainShadow } from '../skyfx.js'; +import { SailRig, HARDWARE } from '../sail.js'; import { weatherCases } from './weather.selftest.js'; // Keep in step with data/storms/. The node runner globs the directory, so this @@ -265,6 +266,79 @@ export default async function run(t) { assert(gentle.flashes === 0, `the gentle storm flashed ${gentle.flashes} times — it has no lightning at all`); }); + // --- SPRINT5 decision 13: hail makes the garden score respond to the rig --- + // The whole reason hail exists: a perfect rig scored 54% vs 48% for no rig, + // because honest rain walks under a sail. Steep hail doesn't — a sail over the + // bed shelters it. This is the gate-2 assert: no-sail hail damage ≥ 2× a good + // rig's, over the real storm_02 hail, through the real gardenHailExposure. + t.test('decision 13: no-sail garden takes ≥2× the hail of a well-covered bed', () => { + const bed = { x: 1, z: 2, w: 6, d: 4 }; + + // a good rig: a quad sitting over the bed, held on rated shackles + const s = 3.6; + const anchors = [ + { id: 'a', pos: { x: -2, y: s, z: -0.5 }, type: 'post', sway() { return this.pos; } }, + { id: 'b', pos: { x: 4, y: s + 0.4, z: -0.5 }, type: 'post', sway() { return this.pos; } }, + { id: 'c', pos: { x: 4, y: s, z: 4.5 }, type: 'post', sway() { return this.pos; } }, + { id: 'd', pos: { x: -2, y: s + 0.4, z: 4.5 }, type: 'post', sway() { return this.pos; } }, + ]; + const rig = new SailRig({ anchors, gridN: 10 }); + rig.attach(['a', 'b', 'c', 'd'], [2, 2, 2, 2].map((i) => HARDWARE[i]), 1.0); + + const damageOverStorm = (getWorld, rigToStep) => { + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(); + camera.position.set(0, 6, 8); + const wind = createWind(storms.storm_02_wildnight); + const sky = createSkyFx({ scene, camera, wind }); + let dmg = 0; + fixedLoop(wind.duration, FIXED_DT, (dt, time) => { + if (rigToStep) rigToStep.step(dt, wind, time); + sky.step(dt, time, getWorld()); + dmg += sky.gardenHailExposure(bed, time) * dt; + }); + const cover = rigToStep ? rigToStep.coverageOver(bed, { x: 0, y: 1, z: 0 }) : 0; + sky.dispose(); + return { dmg, cover }; + }; + + const open = damageOverStorm(() => ({}), null); + const covered = damageOverStorm(() => ({ sail: rig }), rig); + + // sanity: the rig must actually be over the bed, or this proves nothing + assert(covered.cover > 0.6, `test rig only covers ${(covered.cover * 100).toFixed(0)}% of the bed — fix the quad, not the mechanic`); + assert(open.dmg > 0, 'no hail damage in the open at all — storm_02 should be pelting'); + const ratio = open.dmg / Math.max(1e-6, covered.dmg); + assert(ratio >= 2, + `open bed took only ${ratio.toFixed(1)}× the hail of the covered one — decision 13 wants ≥2×`); + }); + + t.test('hail exposure needs BOTH hail and no cover; steep shadow, unlike rain', () => { + 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 }; + // a flat panel well above the bed + const panel = { pos: new Float32Array([-3, 4, -3, 3, 4, -3, 3, 4, 3, -3, 4, 3]), tris: [0, 1, 2, 0, 2, 3] }; + + // storm_01 has no hail: exposure is zero regardless of cover + const calm = createWind(storms.storm_01_gentle); + const skyCalm = createSkyFx({ scene: new THREE.Scene(), camera, wind: calm }); + fixedLoop(1, FIXED_DT, (dt, time) => skyCalm.step(dt, 40 + time, {})); + assert(skyCalm.gardenHailExposure(bed, 40) === 0, 'a hail-free storm still reported hail exposure'); + skyCalm.dispose(); + + // at the wild night's hail peak, open bed is exposed; a panel over it is not + fixedLoop(1, FIXED_DT, (dt, time) => sky.step(dt, 55 + time, {})); + const openExp = sky.gardenHailExposure(bed, 56); + assert(openExp > 0.5, `open bed hail exposure only ${openExp.toFixed(2)} at the burst — should be high`); + fixedLoop(1, FIXED_DT, (dt, time) => sky.step(dt, 55 + time, { sail: panel })); + const coveredExp = sky.gardenHailExposure(bed, 56); + assert(coveredExp < openExp * 0.4, `steep hail still hit the covered bed: ${coveredExp.toFixed(2)} vs open ${openExp.toFixed(2)}`); + sky.dispose(); + }); + 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 70abb7a..d16424f 100644 --- a/web/world/js/tests/weather.selftest.js +++ b/web/world/js/tests/weather.selftest.js @@ -504,6 +504,101 @@ export function weatherCases(storms) { assert(!validateStorm(hot, 'x').ok, 'validator accepted rain intensity above 1 — the scale is peakMmPerHour, not the curve'); }); + // ---- 11. hail (SPRINT5 decision 13) ---- + // The wind-side of hail: intensity timeline and its ramp across the campaign. + // The garden-damage half (steep shadow, ≥2× rig response) needs skyfx + + // SailRig, so it lives in c.test.js. + test('hail ramps across the campaign: none / mild / a wild-night burst', () => { + const peakAndArea = (def) => { + const f = createWindField(def); + let peak = 0, peakT = 0, area = 0; + for (let t = 0; t <= f.duration; t += DT) { + const h = f.hailAt(t); + if (h > peak) { peak = h; peakT = t; } + area += h * DT; + } + return { peak, peakT, area }; + }; + const g = peakAndArea(storms.storm_01_gentle); + const m = peakAndArea(storms.storm_03_southerly); + const w = peakAndArea(storms.storm_02_wildnight); + metrics['storm_01.hailPeak'] = +g.peak.toFixed(2); + metrics['storm_03.hailPeak'] = +m.peak.toFixed(2); + metrics['storm_02.hailPeak'] = +w.peak.toFixed(2); + metrics['storm_02.hailSeconds'] = +w.area.toFixed(1); + + assert(g.peak === 0, `the gentle storm hailed (${g.peak}) — storm_01 must have no hail`); + assert(m.peak > 0.2 && m.peak < 0.8, `storm_03 hail peak ${m.peak.toFixed(2)} — wanted a mild middle rung`); + assert(w.peak >= 0.95, `storm_02 hail peak only ${w.peak.toFixed(2)} — the wild night needs a proper burst`); + assert(w.area > m.area * 3, 'the wild night should carry vastly more hail than the mild storm'); + }); + + test('storm_02 hail lands ON the southerly change', () => { + const change = (storms.storm_02_wildnight.events.find((e) => e.type === 'windchange')).t; + const f = createWindField(storms.storm_02_wildnight); + // find the authored-burst peak (near the change), not just any gust-synced tick + let peak = 0, peakT = 0; + for (let t = change - 3; t <= change + 8; t += DT) { + const h = f.hailAt(t); + if (h > peak) { peak = h; peakT = t; } + } + assert(peak >= 0.95, `hail near the change only reached ${peak.toFixed(2)}`); + assert(Math.abs(peakT - change) < 6, `hail peaks at t=${peakT.toFixed(1)}, the change is at t=${change} — they should coincide`); + }); + + test('hail is silent through a gust telegraph and rides its own gust', () => { + // the gust-synced bursts must not fire during the telegraph window, or hail + // would arrive before the gust it belongs to — the opposite of the drama + const def = storms.storm_02_wildnight; + const f = createWindField(def); + const thresh = def.hail.withGustsAbove; + for (const g of f.gusts) { + if (g.pow < thresh) continue; + // during the telegraph (first GUST.TELEGRAPH s) the gust-synced part is 0. + // an authored burst may still overlap, so only check gusts clear of t=55±8. + if (Math.abs(g.t0 - 55) < 10) continue; + for (let t = g.t0 + 0.05; t < g.t0 + GUST.TELEGRAPH; t += DT) { + assert(f.hailAt(t) < 1e-9, `hail fell during a gust's telegraph at t=${t.toFixed(2)} — it should wait for the gust`); + } + } + }); + + test('hail does not re-time the storm (zero draws of its own)', () => { + const base = storms.storm_02_wildnight; + const a = createWindField(base); + for (const gi of [0, 0.3, 0.85, 1]) { + const d = JSON.parse(JSON.stringify(base)); + d.hail.gustBurstIntensity = gi; + d.hail.bursts = []; // even removing the authored burst mustn't move gusts + const b = createWindField(d); + assert(a.gusts.length === b.gusts.length, `hail change moved the gust count`); + a.gusts.forEach((g, i) => { + assert(g.t0 === b.gusts[i].t0 && g.pow === b.gusts[i].pow, `hail change re-timed gust ${i}`); + }); + assert(a.speedAt(3, -2, 47.3) === b.speedAt(3, -2, 47.3), 'hail change altered the wind'); + } + }); + + test('validator rejects broken hail blocks', () => { + const base = () => JSON.parse(JSON.stringify(storms.storm_02_wildnight)); + const cases = [ + ['size 0', (d) => { d.hail.size = 0; }], + ['size huge', (d) => { d.hail.size = 20; }], + ['intensity > 1', (d) => { d.hail.bursts[0].intensity = 1.5; }], + ['negative fade', (d) => { d.hail.bursts[0].fade = -1; }], + ['burst after the storm', (d) => { d.hail.bursts[0].t = 200; }], + ['gustBurstIntensity > 1', (d) => { d.hail.gustBurstIntensity = 2; }], + ['neither bursts nor gusts', (d) => { delete d.hail.bursts; delete d.hail.withGustsAbove; }], + ]; + for (const [label, mutate] of cases) { + const d = base(); mutate(d); + assert(!validateStorm(d, 'broken').ok, `validator accepted broken hail: ${label}`); + } + // a hail-free storm (no block at all) must still validate + const none = base(); delete none.hail; + assert(validateStorm(none, 'nohail').ok, 'validator rejected a storm with no hail — hail is optional'); + }); + return { cases, metrics }; } diff --git a/web/world/js/weather.core.js b/web/world/js/weather.core.js index 24b71e3..9792b0c 100644 --- a/web/world/js/weather.core.js +++ b/web/world/js/weather.core.js @@ -167,6 +167,27 @@ export function buildGustTimeline(def, seed) { return out; } +// ---------- hail (SPRINT5 decision 13) ---------- +// Hail, not rain, carries the garden score. Rain honestly walks under a sail +// (droplets terminal ~9 m/s, so a 30 m/s crosswind blows them in at atan(30/9) +// ≈ 73° off vertical — nearly sideways), which is why a perfect rig scored 54% +// vs 48% for no rig at all. Hailstones are dense: terminal velocity ~20 m/s for +// a 1 cm stone, so the SAME 30 m/s gale only leans them atan(30/20)≈56° — and +// that overstates it, because a dense stone's drag couples weakly to the +// horizontal air, so observed hail lean tops out ~15-20°. Steep hail is blocked +// by overhead cloth even in a gale, so the garden score becomes rig-responsive +// without faking the rain physics. The steepness lives in skyfx.hailVelocity(); +// the intensity timeline lives here. + +/** Envelope of one authored hail burst at local time `dt` (s since it began). */ +export function hailBurstEnvelope(dt, ramp, hold, fade, peak) { + if (dt <= 0) return 0; + if (dt < ramp) return peak * (dt / ramp); + if (dt < ramp + hold) return peak; + if (dt < ramp + hold + fade) return peak * (1 - (dt - ramp - hold) / fade); + return 0; +} + // ---------- the field ---------- /** * @param {object} def parsed storm JSON (see data/storms/*.json) @@ -189,6 +210,19 @@ export function createWindField(def, opts = {}) { const gd = def.gusts || {}; const downFrac = gd.downdraftOfTotal ?? gd.downdraft ?? DEFAULT_DOWNDRAFT; + // Hail bursts: authored ones from the JSON, plus one synced to every gust at + // or above `withGustsAbove` power — so the biggest gusts arrive WITH hail, the + // storm's worst moment landing all at once. Gust-synced bursts key off the + // deterministic gust timeline and draw NO randomness, so tuning hail can't + // re-time the storm (same guarantee as the downdraft). + const hailDef = def.hail || null; + const hailBursts = (hailDef && Array.isArray(hailDef.bursts)) + ? hailDef.bursts.map((b) => ({ + t: b.t, ramp: b.ramp ?? 0.8, hold: b.hold ?? 3, fade: b.fade ?? 1.5, + intensity: b.intensity ?? 1, + })) + : []; + let shelters = []; /** Spatially-uniform part: base curve + every gust envelope live at t. */ @@ -398,6 +432,42 @@ export function createWindField(def, opts = {}) { return Math.min(1, Math.max(0, r.intensity ?? 0)); }, + /** + * 0..1 hail intensity at time t (SPRINT5 decision 13). Max over every live + * burst — authored plus gust-synced — because "how hard is it hailing right + * now" is a level, not a sum; two overlapping bursts don't hail at 1.6×. + * Zero for a storm with no `hail` block, so storm_01 simply never hails. + */ + hailAt(t) { + if (!hailDef) return 0; + let h = 0; + for (let i = 0; i < hailBursts.length; i++) { + const b = hailBursts[i]; + if (t <= b.t || t >= b.t + b.ramp + b.hold + b.fade) continue; + const v = hailBurstEnvelope(t - b.t, b.ramp, b.hold, b.fade, b.intensity); + if (v > h) h = v; + } + const thresh = hailDef.withGustsAbove; + if (thresh != null) { + const gi = hailDef.gustBurstIntensity ?? 0.8; + for (let i = 0; i < gusts.length; i++) { + const g = gusts[i]; + if (g.pow < thresh) continue; + if (t <= g.t0 || t >= g.endAt) continue; + // reuse the gust envelope: silent through the telegraph, then it hits + // as the gust ramps and holds — the hail lands WITH the gust. + // gustEnvelope(gt, pow) returns pow×fraction, so passing gi as "pow" + // gives the intensity-scaled 0..gi envelope directly. + const v = gustEnvelope(t - g.t0, gi); + if (v > h) h = v; + } + } + return h > 1 ? 1 : h; + }, + + /** Stone-size scalar (audio pitch, visual scale, damage weight). Default 1. */ + get hailSize() { return hailDef ? (hailDef.size ?? 1) : 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 @@ -520,5 +590,34 @@ export function validateStorm(def, name = 'storm') { } } + // ---- hail (SPRINT5 decision 13) ---- + if (def.hail) { + const hd = def.hail; + if (hd.size != null && (!Number.isFinite(hd.size) || hd.size <= 0 || hd.size > 5)) { + bad(`hail.size must be a positive scalar up to ~5 (stones bigger than golf balls break the metaphor) — got ${hd.size}`); + } + if (hd.withGustsAbove != null && !Number.isFinite(hd.withGustsAbove)) { + bad('hail.withGustsAbove must be a finite gust-power threshold in m/s'); + } + if (hd.gustBurstIntensity != null && (!(hd.gustBurstIntensity >= 0) || hd.gustBurstIntensity > 1)) { + bad(`hail.gustBurstIntensity must be 0..1 — got ${hd.gustBurstIntensity}`); + } + // A storm with a hail block but nothing to fire it never hails — that's a + // typo, not a design, so say so rather than shipping silent hail. + if (!Array.isArray(hd.bursts) && hd.withGustsAbove == null) { + bad('hail block has neither bursts[] nor withGustsAbove — it would never hail'); + } + for (const b of hd.bursts || []) { + if (!Number.isFinite(b.t)) bad(`hail burst ${JSON.stringify(b)} has no finite t`); + if (b.t + 0 > (def.duration ?? 90)) bad(`hail burst at t=${b.t} starts after the storm ends`); + for (const k of ['ramp', 'hold', 'fade']) { + if (b[k] != null && !(b[k] >= 0)) bad(`hail burst at t=${b.t} has a negative ${k}`); + } + if (b.intensity != null && (!(b.intensity >= 0) || b.intensity > 1)) { + bad(`hail burst at t=${b.t} intensity must be 0..1 — got ${b.intensity}`); + } + } + } + return { ok: errors.length === 0, errors }; } diff --git a/web/world/js/weather.js b/web/world/js/weather.js index 4377552..37e5506 100644 --- a/web/world/js/weather.js +++ b/web/world/js/weather.js @@ -87,6 +87,12 @@ export function createWind(def, opts = {}) { /** 0..1 rain intensity — drives drop count and opacity. */ rainAt(t) { return field.rainAt(t); }, + /** 0..1 hail intensity (SPRINT5 decision 13). Zero for a hail-free storm. */ + hailAt(t) { return field.hailAt(t); }, + + /** Stone-size scalar — audio pitch, visual scale, damage weight. */ + get hailSize() { return field.hailSize; }, + /** Rain rate in real-world mm/hr. Ponding (decision 10) reads this. */ rainMmPerHour(t) { return field.rainMmPerHour(t); },