From 576422e1f0dd12b8b099a019eec4379f2f0cd6c6 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 01:45:50 +1000 Subject: [PATCH 1/3] Downdraft as fraction of total wind (decision 8) + storm_03 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gust-only downdraft could not satisfy B's 60% no-free-lunch bar and the §7 twisted-survives gate together: the downdraft peaked at the gust peak, where the horizontal peaked too, so a flat sail never reached 60% of a pitched one's load without a spike so violent it also broke the twisted rig. The integrator measured the pincer (0.58 -> 48% and still breaks twisted). Fix: the downdraft is now a fraction of the LOCAL total wind speed, not of gust power (weather.core verticalAt = -frac * localHoriz). It presses a flat roof steadily across the whole storm — peak total 32.6 m/s dwarfs peak gust power 12.6 — so the ratio clears 60% at a gentle fraction, with no gust-peak spike. It rides the local speed, so a tree's wind shadow shelters from falling air too. speedAt() stays horizontal (a wind meter doesn't read falling air). Field renamed downdraft -> downdraftOfTotal; the validator rejects the old name rather than silently re-meaning it. The vertical now carries NO rng draws at all, so the determinism guarantee (tuning can't re-time gusts) is structural, not just separate-stream. Measured both gates myself with B's SailRig (8-direction flat-vs-pitched sweep + §7 legs). Target for storm_02 is 0.45: 69% of-max / 60% worst-heading on the bar, twisted rated rig survives with ~21% margin. HELD at 0.12 this commit — the current yard's only twisted quad ('h1,t2,p1,t1', ~190 m2) starts losing a corner near 0.15 in the exact solver, so 0.45 would red B's §7. 0.12 ~= the old gust-only 0.3 in peak downdraft (-4.2 vs -4.5 m/s). Bump to 0.45 is a one-number joint step once A lands decision-2 anchors (18-45 m2 quads) and B re-points §7. storm_03_southerly: campaign ramp between gentle and wildnight — peak gust 21 m/s, sustained 13, one moderate southerly change. Auto-swept by the suite. Selftest 170/0/0 (all three §7 legs green). Verified live: downdraft/horizontal ratio is exactly 0.12 in-game, rain occlusion still covers the bed. Co-Authored-By: Claude Opus 4.8 --- web/world/data/storms/storm_01_gentle.json | 2 +- web/world/data/storms/storm_02_wildnight.json | 4 +- web/world/data/storms/storm_03_southerly.json | 38 +++++ web/world/js/tests/c.test.js | 21 +-- web/world/js/tests/weather.selftest.js | 140 +++++++++++------- web/world/js/weather.core.js | 86 ++++++----- 6 files changed, 187 insertions(+), 104 deletions(-) create mode 100644 web/world/data/storms/storm_03_southerly.json diff --git a/web/world/data/storms/storm_01_gentle.json b/web/world/data/storms/storm_01_gentle.json index 259194e..91327d4 100644 --- a/web/world/data/storms/storm_01_gentle.json +++ b/web/world/data/storms/storm_01_gentle.json @@ -14,7 +14,7 @@ "powBase": 2, "powRand": 3, "powRamp": 2, - "downdraft": 0.18 + "downdraftOfTotal": 0.25 }, "dirCurve": [[0, 0.9], [45, 1.0], [90, 1.15]], diff --git a/web/world/data/storms/storm_02_wildnight.json b/web/world/data/storms/storm_02_wildnight.json index 0508b82..1f5c84b 100644 --- a/web/world/data/storms/storm_02_wildnight.json +++ b/web/world/data/storms/storm_02_wildnight.json @@ -11,7 +11,7 @@ "baseCurve": [[0, 7.0], [15, 11.0], [40, 17.0], [60, 20.0], [78, 19.0], [90, 16.0]], - "_gusts_comment": "downdraft = fraction of gust power that blows DOWN, per gust (each gust varies 0.6-1.4x this). A gust front is descending air, not just faster air; without it a flat horizontal sail sheds everything and ignoring the storm is the winning move. 0.3 here because a wild night should punish a flat rig hard.", + "_gusts_comment": "downdraftOfTotal = fraction of TOTAL wind speed that blows DOWN (SPRINT3 decision 8), present whenever it's windy, not only in gusts. TARGET is 0.45 — measured to clear B's 60% flat-horizontal:flat-pitched bar (69% of-max / 60% worst-heading) AND let a properly-sized twisted rated rig survive with ~21% margin, which gust-only semantics provably could NOT do together (0.58 gave 48% and still broke the twisted rig). HELD at 0.12 for now: on the current oversized yard the ONLY twisted quad ('h1,t2,p1,t1', ~190 m²) starts losing a corner around 0.15 in the exact solver, so 0.45 would turn B's §7 assert red. 0.12 fraction-of-total ~= the old gust-only 0.3 in peak downdraft (-4.2 vs -4.5 m/s), so storm_02 barely changes, and leaves ~23% load margin on that twisted rig. Bump to 0.45 is a ONE-NUMBER joint step once A lands decision-2 anchors (18-45 m2 quads) and B re-points §7. See THREADS [C] 2026-07-17.", "gusts": { "firstAt": 3, @@ -20,7 +20,7 @@ "powBase": 3, "powRand": 5, "powRamp": 7, - "downdraft": 0.3 + "downdraftOfTotal": 0.12 }, "dirCurve": [[0, 0.85], [50, 0.95], [55, 0.6], [59, -1.25], [70, -1.45], [90, -1.35]], diff --git a/web/world/data/storms/storm_03_southerly.json b/web/world/data/storms/storm_03_southerly.json new file mode 100644 index 0000000..0c4ecbe --- /dev/null +++ b/web/world/data/storms/storm_03_southerly.json @@ -0,0 +1,38 @@ +{ + "name": "Southerly Buster", + "blurb": "Hot still afternoon, then a southerly change rolls through around the half-hour. Gusty but not vicious — a fair test of a first real rig.", + "rating": 2, + "seed": 30717, + "duration": 90, + + "_ramp_comment": "The campaign's middle rung: sits between storm_01_gentle (peak ~11 m/s) and storm_02_wildnight (peak ~32). Sustained builds to ~13 (47 km/h), worst gust ~21 (76 km/h, BOM 'strong'). The change is real but slower and smaller than the wild night's, so a decent flat-ish rig can get away with it and a good twisted one is never in doubt — the storm that teaches the swing before the one that punishes it.", + + "baseCurve": [[0, 4.0], [12, 6.0], [30, 9.0], [45, 13.0], [65, 12.5], [90, 10.0]], + + "gusts": { + "firstAt": 5, + "minGap": 6, + "maxGap": 12, + "powBase": 3, + "powRand": 4, + "powRamp": 4, + "downdraftOfTotal": 0.35 + }, + + "_dir_comment": "Starts blowing toward the SE (a warm NW'er), swings to blow toward the NNE (a moderate southerly) across 30-36 s. ~90 deg, gentler slew than storm_02's buster.", + + "dirCurve": [[0, 0.8], [28, 0.9], [30, 0.55], [36, -0.7], [55, -0.85], [90, -0.75]], + "dirWander": { "amp": 0.3, "rate": 0.11 }, + + "spatial": { "amp": 0.18, "scale": 11, "advect": 0.5 }, + + "events": [ + { "t": 30, "type": "windchange", "telegraph": 6, "over": 6, "text": "here comes the change" }, + { "t": 48, "type": "debris", "model": "BlackTub_v2", "lateral": 2.5, "mass": 5, "text": "a tub skitters across the lawn" }, + { "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]] }, + + "sky": { "darkness": 0.5, "cloudScroll": 0.05 } +} diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index e82d024..d5f9732 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -54,11 +54,13 @@ export default async function run(t) { assert(a.x === b.x && a.y === b.y && a.z === b.z, 'out param changed the result'); }); - // This assert used to read `a.y === 0` — "wind should be horizontal". SPRINT2 - // decision 3 made that false on purpose: gusts now descend, which is what makes - // a flat sail pay. Keeping the useful half — y is downward-or-zero, never up, - // and never garbage — so player shove and rain angle can still trust the sign. - t.test('vertical wind is downward-only, and only during gusts', () => { + // This assert once read `a.y === 0` — "wind is horizontal". SPRINT2 decision 3 + // made that false on purpose (gusts descend, so a flat sail pays); SPRINT3 + // decision 8 made the descent a fraction of TOTAL wind, so it's present + // whenever it's windy, not only in gusts. Keeping the invariants that consumers + // (player shove, rain angle, HUD) rely on: y is down-or-zero, never up, never + // garbage, and speedAt() is horizontal-only. + t.test('vertical wind is downward-only and rides the wind', () => { const wind = createWind(storms.storm_02_wildnight); const pos = new THREE.Vector3(0, 1.7, 0); const v = new THREE.Vector3(); @@ -66,12 +68,13 @@ export default async function run(t) { fixedLoop(wind.duration, FIXED_DT, (dt, time) => { wind.sample(pos, time, v); assert(v.y <= 1e-9, `wind blew UP (y=${v.y.toFixed(3)}) at t=${time.toFixed(2)}`); - if (v.y < -1) sawDown = true; + assert(Number.isFinite(v.y), `vertical wind is not finite at t=${time.toFixed(2)}`); + if (v.y < -2) sawDown = true; }); assert(sawDown, 'never saw a downdraft worth the name in a whole wild night'); - // and the wind meter must stay horizontal — a falling gust shouldn't spike the HUD - const calm = wind.speedAt(pos, 0.5); - assert(Math.abs(calm - Math.hypot(wind.sample(pos, 0.5).x, wind.sample(pos, 0.5).z)) < 1e-9, + // the wind meter must stay horizontal — falling air shouldn't spike the HUD + const s = wind.sample(pos, 0.5); + assert(Math.abs(wind.speedAt(pos, 0.5) - Math.hypot(s.x, s.z)) < 1e-9, 'speedAt() is not the horizontal magnitude of sample()'); }); diff --git a/web/world/js/tests/weather.selftest.js b/web/world/js/tests/weather.selftest.js index e0e9ffb..1b29778 100644 --- a/web/world/js/tests/weather.selftest.js +++ b/web/world/js/tests/weather.selftest.js @@ -280,44 +280,75 @@ export function weatherCases(storms) { assert(Math.abs(luvS - luvB) < 1e-9, 'upwind side is being sheltered — shadow is pointing the wrong way'); }); - // ---- 9. vertical gust structure (SPRINT2 decision 3) ---- - // Cloth pressure goes with dot(wind, normal). A flat horizontal panel's normal - // points at the sky, so in a perfectly horizontal wind that dot is ~0 and the - // cheapest winning rig is "lie it flat and ignore the storm" — the opposite of - // the game. Gust fronts are descending air, and descending air hits a flat - // panel square on. Lane B owns the cloth-side assert; these are the wind side. - test('gusts carry a downdraft, and still air does not', () => { - const f = createWindField(storms.storm_02_wildnight); - let peakDown = 0, betweenMax = 0; - for (let t = 0; t <= f.duration; t += DT) { - const v = f.gustVertical(t); - assert(v <= 1e-12, `vertical wind went UP (${v.toFixed(2)}) at t=${t.toFixed(2)} — downdraft only`); - const live = f.gusts.some((g) => t > g.t0 && t < g.endAt); - if (live) peakDown = Math.min(peakDown, v); - else betweenMax = Math.max(betweenMax, Math.abs(v)); - } - metrics['storm_02.peakDowndraft'] = +peakDown.toFixed(2); - assert(betweenMax === 0, `air is falling between gusts (${betweenMax}) — downdraft must be a gust feature`); - assert(peakDown < -2, `peak downdraft only ${peakDown.toFixed(2)} m/s — a flat sail would still shrug it off`); - }); - - test('downdraft tracks its own gust and its JSON fraction', () => { + // ---- 9. vertical structure (SPRINT3 decision 8: fraction of TOTAL) ---- + // Cloth pressure goes with dot(wind, normal). A flat panel's normal points at + // the sky, so in a purely horizontal wind that dot is ~0 and "lie it flat and + // ignore the storm" wins — the opposite of the game. The downdraft is now a + // fraction of the LOCAL total wind speed (was: gust power), so a flat roof is + // pressed whenever it's windy, not only at gust peaks. Lane B owns the + // cloth-side no-free-lunch assert; these are the wind side. + test('downdraft is a fixed fraction of the local horizontal speed', () => { const def = storms.storm_02_wildnight; const f = createWindField(def); - const frac = def.gusts.downdraft; - for (const g of f.gusts) { - assert(g.down >= frac * 0.6 - 1e-9 && g.down <= frac * 1.4 + 1e-9, - `gust at t=${g.t0.toFixed(1)} has down=${g.down.toFixed(3)}, outside 0.6–1.4× of ${frac}`); - // minGap >= GUST.TOTAL means gusts never overlap, so at hold it's exactly this gust - const atHold = f.gustVertical(g.t0 + 3); - assert(Math.abs(atHold - -(g.pow * g.down)) < 1e-9, - `at gust hold vertical is ${atHold.toFixed(3)}, want ${(-g.pow * g.down).toFixed(3)}`); + const frac = def.gusts.downdraftOfTotal; + assert(Math.abs(f.downFrac - frac) < 1e-12, `field downFrac ${f.downFrac} != json ${frac}`); + const out = { x: 0, y: 0, z: 0 }; + let peakDown = 0; + for (const p of PROBES) { + for (let t = 0; t <= f.duration; t += DT) { + f.vecAt(p.x, p.z, t, out); + const horiz = Math.hypot(out.x, out.z); + assert(out.y <= 1e-9, `vertical went UP (${out.y.toFixed(3)}) at t=${t.toFixed(2)} — downdraft only`); + // out.y must be exactly -frac * horizontal, everywhere, always + assert(Math.abs(out.y + frac * horiz) < 1e-9, + `downdraft ${out.y.toFixed(3)} != -${frac}×${horiz.toFixed(3)} at t=${t.toFixed(2)}`); + peakDown = Math.min(peakDown, out.y); + } + } + metrics['storm_02.peakDowndraft'] = +peakDown.toFixed(2); + // Held at downdraftOfTotal 0.15 → ~-4.9 m/s; target 0.45 → ~-14.7. Floor at + // -3 so this proves "a real downdraft exists" across the whole transition + // range without false-failing when the joint step bumps the value. + assert(peakDown < -3, `peak downdraft only ${peakDown.toFixed(2)} m/s — a flat sail would still shrug it off`); + }); + + test('downdraft rides the wind: present when windy, gone when calm', () => { + // The point of fraction-of-total: it's not a gust-only feature any more. Some + // sustained-wind moment between gusts must still carry a real downdraft, and a + // hypothetically dead-calm field must carry none. + const f = createWindField(storms.storm_02_wildnight); + let sustainedDown = 0; + for (let t = 0; t <= f.duration; t += DT) { + const inGust = f.gusts.some((g) => t > g.t0 && t < g.endAt); + if (!inGust) sustainedDown = Math.min(sustainedDown, f.verticalAt(0, 0, t)); + } + assert(sustainedDown < -2, + `between gusts the downdraft peaks at only ${sustainedDown.toFixed(2)} — total-speed semantics should keep it pressing`); + + // dead calm → no downdraft (guards against a constant offset sneaking in) + const calm = createWindField({ + duration: 10, baseCurve: [[0, 0], [10, 0]], dirCurve: [[0, 0], [10, 0]], + gusts: { minGap: 6, maxGap: 6, powBase: 0, powRand: 0, powRamp: 0, downdraftOfTotal: 0.5 }, + }); + for (let t = 0; t <= 10; t += 0.1) { + assert(Math.abs(calm.verticalAt(0, 0, t)) < 1e-9, `air is falling in a dead calm at t=${t.toFixed(1)}`); } }); - test('downdraft 0 gives a perfectly horizontal wind', () => { + test('downdraft follows the tree shadow (shelters from falling air too)', () => { + const def = storms.storm_02_wildnight; + const f = createWindField(def).setShelters([{ x: 0, z: 0, radius: 3, strength: 0.5, length: 14 }]); + const t = 30; + const d = f.dirAt(t); + const dx = Math.cos(d), dz = Math.sin(d); + const leeDown = Math.abs(f.verticalAt(dx * 5, dz * 5, t)); // downwind of the tree + const openDown = Math.abs(f.verticalAt(-dx * 5, -dz * 5, t)); // upwind, unsheltered + assert(leeDown < openDown * 0.85, `lee downdraft ${leeDown.toFixed(2)} not sheltered vs open ${openDown.toFixed(2)}`); + }); + + test('downdraftOfTotal 0 gives a perfectly horizontal wind', () => { const def = JSON.parse(JSON.stringify(storms.storm_02_wildnight)); - def.gusts.downdraft = 0; + def.gusts.downdraftOfTotal = 0; const f = createWindField(def); const out = { x: 0, y: 0, z: 0 }; for (let t = 0; t <= f.duration; t += 0.05) { @@ -327,15 +358,16 @@ export function weatherCases(storms) { }); test('downdraft does not re-time the storm', () => { - // The vertical draws from its own RNG stream precisely so that adding or - // tuning it can't shift gust times or powers. Lane A hand-drove storm_02 and - // watched the carabiner blow at t=45.4 and p2 cascade at t=56; a downdraft - // tweak silently moving those would be a nasty way to lose an afternoon. + // The vertical carries NO rng draws of its own now (it's a pure function of + // local speed), so tuning it cannot possibly shift gust times or powers. Lane + // A hand-drove storm_02 and watched the carabiner blow at t=45.4 and p2 + // cascade at t=56; a downdraft tweak silently moving those would be a nasty + // way to lose an afternoon. Determinism is now structural, but still asserted. const base = storms.storm_02_wildnight; const a = createWindField(base); - for (const dd of [0, 0.1, 0.25, 0.5, 1]) { + for (const dd of [0, 0.1, 0.22, 0.5, 1]) { const d = JSON.parse(JSON.stringify(base)); - d.gusts.downdraft = dd; + d.gusts.downdraftOfTotal = dd; const b = createWindField(d); assert(a.gusts.length === b.gusts.length, `downdraft ${dd} changed the gust count`); a.gusts.forEach((g, i) => { @@ -343,32 +375,34 @@ export function weatherCases(storms) { `downdraft ${dd} moved gust ${i} from t=${g.t0.toFixed(3)} to ${b.gusts[i].t0.toFixed(3)}`); assert(g.pow === b.gusts[i].pow, `downdraft ${dd} changed gust ${i}'s power`); }); + // and the HORIZONTAL wind must be byte-identical regardless of downdraft + assert(a.speedAt(3, -2, 47.3) === b.speedAt(3, -2, 47.3), `downdraft ${dd} changed the horizontal wind`); } }); - test('at a gust peak the downdraft is a real fraction of the horizontal', () => { + test('speedAt stays horizontal — a wind meter does not read falling air', () => { const f = createWindField(storms.storm_02_wildnight); const out = { x: 0, y: 0, z: 0 }; - let bestRatio = 0, atT = 0; - for (let t = 0; t <= f.duration; t += DT) { - f.vecAt(0, 0, t, out); - const horiz = Math.hypot(out.x, out.z); - if (horiz < 1) continue; - const r = Math.abs(out.y) / horiz; - if (r > bestRatio) { bestRatio = r; atT = t; } + for (const p of PROBES) { + for (const t of [12, 40, 60, 75.3]) { + f.vecAt(p.x, p.z, t, out); + assert(Math.abs(f.speedAt(p.x, p.z, t) - Math.hypot(out.x, out.z)) < 1e-9, + `speedAt != horizontal magnitude of sample at t=${t}`); + } } - metrics['storm_02.peakVerticalRatio'] = +bestRatio.toFixed(3); - assert(bestRatio > 0.12, - `strongest downdraft is only ${(bestRatio * 100).toFixed(0)}% of the horizontal wind (t=${atT.toFixed(1)}) — a flat sail still shrugs`); }); - test('validator rejects a bad downdraft', () => { + test('validator rejects a bad downdraft and the renamed field', () => { for (const dd of [-0.1, 1.5, NaN, 'lots']) { const d = JSON.parse(JSON.stringify(storms.storm_02_wildnight)); - d.gusts.downdraft = dd; - const { ok } = validateStorm(d, 'broken'); - assert(!ok, `validator ACCEPTED downdraft = ${dd}`); + d.gusts.downdraftOfTotal = dd; + assert(!validateStorm(d, 'broken').ok, `validator ACCEPTED downdraftOfTotal = ${dd}`); } + // the old gust-only field must be rejected, not silently re-meant + const legacy = JSON.parse(JSON.stringify(storms.storm_02_wildnight)); + delete legacy.gusts.downdraftOfTotal; + legacy.gusts.downdraft = 0.3; + assert(!validateStorm(legacy, 'legacy').ok, 'validator silently accepted the pre-SPRINT3 downdraft field'); }); return { cases, metrics }; diff --git a/web/world/js/weather.core.js b/web/world/js/weather.core.js index 43cb02c..8856a45 100644 --- a/web/world/js/weather.core.js +++ b/web/world/js/weather.core.js @@ -130,29 +130,19 @@ function sampleAngleCurve(curve, t) { // ---------- gust timeline ---------- // Prototype: pow = 12 + rand*16 + 10*p, next = t + 5 + rand*7. Same shape, from JSON. -export const DEFAULT_DOWNDRAFT = 0.25; +export const DEFAULT_DOWNDRAFT = 0.22; export function buildGustTimeline(def, seed) { const g = def.gusts || {}; const rng = mulberry32(seed >>> 0); - // Vertical draws from its OWN stream, deliberately. Pulling it from `rng` - // would shift every subsequent (t0, pow) and silently re-time storms that are - // already tuned and hand-verified — A drove storm_02 and watched the carabiner - // blow at t=45.4 and cascade at t=56, one second after the change. Adding a - // downdraft shouldn't move that. - const rngV = mulberry32((seed ^ 0x0d0117) >>> 0); const minGap = g.minGap ?? 5, maxGap = g.maxGap ?? 12; - const downFrac = g.downdraft ?? DEFAULT_DOWNDRAFT; const out = []; let t = g.firstAt ?? 3; // hard cap: a malformed gap can't spin us forever while (t < def.duration && out.length < 512) { const p = def.duration > 0 ? t / def.duration : 0; const pow = (g.powBase ?? 12) + rng() * (g.powRand ?? 16) + (g.powRamp ?? 10) * p; - // Not every gust slams down the same: some roll through nearly flat, some - // are a proper little downburst. 0.6–1.4× the storm's fraction. - const down = downFrac * (0.6 + rngV() * 0.8); - out.push({ t0: t, pow, down, rampAt: t + GUST.TELEGRAPH, endAt: t + GUST.TOTAL }); + out.push({ t0: t, pow, rampAt: t + GUST.TELEGRAPH, endAt: t + GUST.TOTAL }); t += minGap + rng() * Math.max(0, maxGap - minGap); } return out; @@ -174,6 +164,11 @@ export function createWindField(def, opts = {}) { const wander = def.dirWander || {}; const wAmp = wander.amp ?? 0.25, wRate = wander.rate ?? 0.13; const nSeed = (seed ^ 0x9e3779b9) | 0; + // SPRINT3 decision 8: the downdraft is a fraction of TOTAL wind speed, not of + // gust power. `downdraftOfTotal` is the field name; `downdraft` is read as a + // legacy alias so an un-migrated storm doesn't silently lose its vertical. + const gd = def.gusts || {}; + const downFrac = gd.downdraftOfTotal ?? gd.downdraft ?? DEFAULT_DOWNDRAFT; let shelters = []; @@ -202,24 +197,37 @@ export function createWindField(def, opts = {}) { return sampleAngleCurve(def.dirCurve, t) + wAmp * Math.sin(t * wRate); } + /** Local horizontal wind speed (m/s) — base+gusts, spatial noise, tree shadow. + * The one place the local-speed maths lives; speedAt/vecAt/verticalAt share it. */ + function localHoriz(x, z, t) { + const uni = uniformSpeed(t); + const d = dirAt(t); + const s = uni * spatialFactor(x, z, t) * shelterFactor(x, z, Math.cos(d), Math.sin(d)); + return s > 0 ? s : 0; + } + /** - * Vertical wind, m/s. NEGATIVE = downward. Zero between gusts. + * Vertical wind, m/s. NEGATIVE = downward. A fraction of the LOCAL horizontal + * speed at this point and time. * - * A gust front is descending air, not just faster air. Without this the field - * is perfectly horizontal, and a horizontal sail is a free lunch: cloth - * pressure goes with dot(wind, normal), a flat panel's normal points at the - * sky, and the dot product is ~0 no matter how hard it blows. So the cheapest - * winning rig was "lie it flat and ignore the storm", which is the opposite of - * the game (SPRINT2 decision 3). A downdraft hits a flat panel square on. + * Why a horizontal sail must pay: cloth pressure goes with dot(wind, normal), + * a flat panel's normal points at the sky, so in a purely horizontal wind the + * dot is ~0 and "lie it flat and ignore the storm" wins — the opposite of the + * game. A descending component hits a flat panel square on. + * + * SPRINT3 decision 8 — fraction of TOTAL, not of gust power. Under gust-only + * semantics the downdraft peaked exactly at the gust peak, where the horizontal + * ALSO peaked, so a flat sail could never reach 60% of a pitched one's load + * (B measured 34%) without a downdraft so violent it also killed the twisted + * rig the §7 gate needs to survive. The two gates pincered. Riding total speed + * instead spreads the load across the whole storm: a flat roof is pressed + * steadily (peak total 32.6 m/s dwarfs peak gust power 12.6), so the ratio + * clears 60% at a gentle fraction, without a spike at the gust peak. It follows + * the LOCAL speed, so a tree's wind shadow shelters from falling air too. */ - function gustVertical(t) { - let v = 0; - for (let i = 0; i < gusts.length; i++) { - const g = gusts[i]; - if (t <= g.t0) break; // sorted — nothing later is live - if (t < g.endAt) v -= gustEnvelope(t - g.t0, g.pow) * g.down; - } - return v; + function verticalAt(x, z, t) { + if (downFrac <= 0) return 0; + return -downFrac * localHoriz(x, z, t); } // ---- noise drift ---- @@ -318,27 +326,22 @@ export function createWindField(def, opts = {}) { * The cheap path — no allocation. */ speedAt(x, z, t) { - const uni = uniformSpeed(t); - const d = dirAt(t); - const s = uni * spatialFactor(x, z, t) * shelterFactor(x, z, Math.cos(d), Math.sin(d)); - return s > 0 ? s : 0; + return localHoriz(x, z, t); }, dirAt, uniformSpeed, gustOnly, - gustVertical, + verticalAt, + get downFrac() { return downFrac; }, /** Writes wind velocity (m/s) into out {x,y,z}. Ground plane is XZ, +Y up. */ vecAt(x, z, t, out) { - const uni = uniformSpeed(t); const d = dirAt(t); const dirX = Math.cos(d), dirZ = Math.sin(d); - const m = spatialFactor(x, z, t) * shelterFactor(x, z, dirX, dirZ); - let s = uni * m; - if (s < 0) s = 0; + const s = localHoriz(x, z, t); out.x = dirX * s; - out.y = gustVertical(t) * m; // gust fronts descend — see gustVertical() + out.y = -downFrac * s; // the downdraft rides the local speed — see verticalAt() out.z = dirZ * s; return out; }, @@ -415,9 +418,14 @@ export function validateStorm(def, name = 'storm') { // Overlapping gusts stack, and a stacked telegraph is unreadable to the player. if (minGap < GUST.TOTAL) bad(`gusts.minGap (${minGap}) < gust length ${GUST.TOTAL}s — gusts would overlap`); if ((g.powBase ?? 12) < 0) bad('gusts.powBase must be >= 0'); - const dd = g.downdraft ?? DEFAULT_DOWNDRAFT; + // `downdraft` (gust-only, pre-SPRINT3) is still accepted but flagged, so an + // un-migrated storm loads visibly wrong rather than silently at a third power. + if (g.downdraft != null && g.downdraftOfTotal == null) { + bad('gusts.downdraft is the old gust-only field — rename to downdraftOfTotal (SPRINT3 decision 8); it now means a fraction of TOTAL wind speed'); + } + const dd = g.downdraftOfTotal ?? g.downdraft ?? DEFAULT_DOWNDRAFT; if (!Number.isFinite(dd) || dd < 0 || dd > 1) { - bad(`gusts.downdraft must be 0..1 — the fraction of gust power that blows DOWN — got ${dd}`); + bad(`gusts.downdraftOfTotal must be 0..1 — the fraction of TOTAL wind speed that blows DOWN — got ${dd}`); } } From 9a2abad1beb1cecfbd79f34194a4a8f232bab945 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 01:48:30 +1000 Subject: [PATCH 2/3] =?UTF-8?q?Retire=20weather=5Fdemo.html=20=E2=80=94=20?= =?UTF-8?q?the=20game=20is=20the=20bench=20now?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified all Sprint 3 weather work through the real game (SHADES.step) and a node harness, never the demo. The game hosts the full storm, and a second weather harness only drifts (hardcoded yard, mock sail). Sprint 2 item 6. Co-Authored-By: Claude Opus 4.8 --- web/world/weather_demo.html | 263 ------------------------------------ 1 file changed, 263 deletions(-) delete mode 100644 web/world/weather_demo.html diff --git a/web/world/weather_demo.html b/web/world/weather_demo.html deleted file mode 100644 index 0c5801f..0000000 --- a/web/world/weather_demo.html +++ /dev/null @@ -1,263 +0,0 @@ - - - - -SHADES — Lane C — weather bench - - - - -
-
- Lane C bench. Graybox stand-in for Lane A's yard — this exists to drive - weather.js / skyfx.js / debris.js before M0 lands. The sail here is a MOCK - (Lane B owns the real one); it's a bare node grid so debris impulse is visible. -

drag = orbit · click = start audio -
-
- - - - From 9b97bcfd00a76f0ed3110fd825af2db0de3a21ee Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 01:53:56 +1000 Subject: [PATCH 3/3] Log decision-8 landing, the 0.45 target proof, and the B re-point ask MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Semantic done + both-gates measurements + the finding that A's anchors alone don't unblock 0.45 (B's §7 rig is still 141 m²). storm_02 held at 0.12 to keep main green until B re-points to a small quad. Co-Authored-By: Claude Opus 4.8 --- THREADS.md | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/THREADS.md b/THREADS.md index 6869998..5a803d7 100644 --- a/THREADS.md +++ b/THREADS.md @@ -909,3 +909,60 @@ Format: `[lane letter] YYYY-MM-DD — note` Selftest on merged main: **169 pass / 0 fail**. Hand-driven check via SHADES.step: storm_02 with the default rig loses p1 (carabiner) + p2 by t=40 with downdraft live — cascade is earlier and meaner than A's pre-downdraft run, as C's numbers predicted. Screenshot of the merged storm going to DESIGN.md. + +[C] 2026-07-17 — **DECISION 8 LANDED — downdraft is now a fraction of TOTAL wind speed. Semantic done; + final VALUE is a joint step still blocked on B.** Selftest **173/0/0** on rebased main. + `weather.core.verticalAt(x,z,t) = -frac · localHoriz(x,z,t)` — the downdraft rides the local horizontal + speed, so it presses a flat roof steadily the whole storm (not just at gust peaks) and a tree's wind + shadow shelters from falling air too. `speedAt()` stays horizontal. Field renamed + `downdraft → downdraftOfTotal`; validator rejects the old name rather than silently re-meaning it. + The vertical now carries **zero** rng draws, so "tuning can't re-time gusts" is structural, not just + a separate stream. storm_03_southerly added (ramp between gentle and wildnight; peak gust 21 / sust 13). + weather_demo.html retired — the game is the bench. + +[C] 2026-07-17 — **The pincer is broken by the semantic, exactly as decision 8 predicted.** I measured + both gates myself with B's SailRig (8-heading flat-vs-16.7°-pitched sweep + §7 legs) on a + PROPERLY-SIZED ~40 m² synthetic twisted quad: + ``` + downdraftOfTotal 60%-bar (flat:pitched) §7 twisted-rated survival + 0.22 45% fail 4/4 (2928 N) + 0.40 63% PASS 4/4 (4617 N) + 0.45 69% of-max / 60% worst-head 4/4 (5142 N, 21% margin) ← TARGET + 0.60 78% PASS 3/4 DIES (6567 > 6500) + ``` + So **0.45 clears the 60% bar AND keeps a well-sized twisted rated rig alive** — the two gates + gust-only could not satisfy together (integrator measured 0.58 → 48% and still broke twisted). Decision + 8 works. (My harness reproduces B's scale: fraction-of-total 0.15 → 37%, matching B's gust-only 0.3 → + 34% at the same ~-4.5 m/s peak. Raising the downdraft lifts the PITCHED load too, so the ratio climbs + slower than a static estimate — you need ~0.4, not B's ~7.3 m/s single-point guess. That's a real note + for your assert, B.) + +[C] 2026-07-17 — ⚠️ **B — A's anchor rework alone does NOT unblock 0.45; your §7 rig is still oversized. + Re-point it and we finish gate 2.** I rebased onto A's decision-2 anchors and re-measured your exact + §7 twisted rig `['h1','t2','p1','t1']` against storm_02: it's **still a 141 m² quad** (h1 is house at + z≈-9.9, t2 at x≈8, p1 at x≈-4.9, t1 at x≈-9 — those four corners span the whole yard), and it dies at + 0.45 (3/4, peak 6410 N). A ADDED small quads (`p3` near (0,7.6), branch anchors `t1b/t2b`, posts moved + in to p1≈(-4.9,5.9)/p2≈(4.3,6.5)) — but `h1,t2,p1,t1` isn't one of them. **Your SPRINT3 item 2: swap + the §7 twisted rig to an 18-45 m² quad, confirm all three legs at 0.45, then bump storm_02 + `downdraftOfTotal` 0.12 → 0.45 (one number).** ❗ Heads-up from my sweep, flag for you + A: from the + near-bed anchors I could NOT find an 18-45 m² quad that both covers the bed ≥50% AND survives 0.45 with + a rated+shackle mix — the bed sits between the house (z≈-9.9) and the posts (z≈+6), so covering it + tends to want a biggish quad. A's a.test says ≥3 small quads DO shade the bed, so they exist and I'm + likely mis-enumerating (I don't own your area calc / tension intent) — but if the target 0.45 turns out + too hot for the real bed-covering rig, that's a joint call: nudge 0.45 down a touch, or accept the §7 + survivable rig is a bigger quad than 45 m². Your rig, your call; I'll match the wind to whatever lands. + +[C] 2026-07-17 — **Held storm_02 at `downdraftOfTotal: 0.12` so main stays GREEN until B re-points.** On + the current yard B's oversized §7 rig starts losing a corner near 0.15 in the exact solver, so 0.45 + would red the §7 assert (I saw it: 1 fail before I held). 0.12 fraction-of-total ≈ the old gust-only + 0.3 in peak downdraft (-4.2 vs -4.5 m/s), so storm_02's feel barely moves, with ~23% load margin on + that rig. storm_01 → 0.25, storm_03 → 0.35 (neither is in a §7 assert, so those are their real values). + Same call the integrator made last sprint: keep green, document the target, converge jointly. + +[C] 2026-07-17 — **A — decision 7 (garden HP on rain shadow): `skyfx.rainShadowOver(bed)` is ready, no + new work on my side.** Returns 0..1 of the bed the cloth is keeping dry, this frame; 0 when no sail or + no rain. It is NOT `rig.coverageOver(bed, sunDir)` — that's the SUN shadow (keep it as the daytime + readout). During a night storm the rain shadow is the one that says whether the bed is getting hit; it + follows the wind, so it walks off the bed at the southerly change. Suggest HP drain ∝ rain intensity × + (1 − rainShadowOver(bed)); the intensity term is yours (`wind.rainAt(t)`). Ping if you want a combined + helper and I'll add it.