From 5cb838b314acc32b50797f0d6ad047b28d263f83 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 15:30:00 +1000 Subject: [PATCH 1/3] =?UTF-8?q?Add=20validateSiteWind=20=E2=80=94=20the=20?= =?UTF-8?q?site=20JSON's=20wind=20block=20fails=20loud=20(SPRINT10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A builds world.js FROM DATA this sprint, and a site's `wind` block (venturi funnels, later per-site shelter overrides) is hand-authored data like a storm — so it validates like one. setVenturi already clamps defensively, but a clamp hides a typo where the repo's ethos is to name it: a gain < 1 (someone reaching for a shelter), a funnel with no throat coordinates, an axis typed "south". A calls this at site load next to validateStorm. A site with no wind block, an empty block, or an empty venturi list all validate — the funnel is opt-in. Node 56/0/0. Co-Authored-By: Claude Opus 4.8 --- web/world/js/tests/weather.selftest.js | 20 +++++++++++++++++++- web/world/js/weather.core.js | 26 ++++++++++++++++++++++++++ web/world/js/weather.js | 7 +++++-- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/web/world/js/tests/weather.selftest.js b/web/world/js/tests/weather.selftest.js index b4bd053..5b5da0e 100644 --- a/web/world/js/tests/weather.selftest.js +++ b/web/world/js/tests/weather.selftest.js @@ -11,7 +11,7 @@ // call it with the fetched storm defs. import { - createWindField, validateStorm, gustEnvelope, GUST, RAIN_TIME_COMPRESSION, + createWindField, validateStorm, validateSiteWind, gustEnvelope, GUST, RAIN_TIME_COMPRESSION, stormStats, forecastFor, hailBlockFor, } from '../weather.core.js'; @@ -324,6 +324,24 @@ export function weatherCases(storms) { assert(maxRatio > 1.3, `funnel didn't strengthen the downdraft (max ${maxRatio.toFixed(2)}×) — it should ride local speed`); }); + test('validateSiteWind: a good funnel passes, a bad one fails loud', () => { + const ok = validateSiteWind({ venturi: [{ x: -6, z: 4, axis: -1.08, gain: 1.4, radius: 5, sharp: 3 }] }, 's'); + assert(ok.ok, `a valid venturi was rejected: ${ok.errors.join('; ')}`); + assert(validateSiteWind(null, 's').ok, 'a site with no wind block should validate'); + assert(validateSiteWind({}, 's').ok, 'an empty wind block should validate'); + assert(validateSiteWind({ venturi: [] }, 's').ok, 'an empty venturi list should validate'); + for (const [label, bad] of [ + ['gain < 1 (a venturi speeds up, never slows)', { venturi: [{ x: 0, z: 0, gain: 0.7 }] }], + ['gain absurd', { venturi: [{ x: 0, z: 0, gain: 9 }] }], + ['no x,z', { venturi: [{ axis: 1, gain: 1.4 }] }], + ['axis not finite', { venturi: [{ x: 0, z: 0, axis: 'south' }] }], + ['radius <= 0', { venturi: [{ x: 0, z: 0, radius: 0 }] }], + ['venturi not an array', { venturi: { x: 0 } }], + ]) { + assert(!validateSiteWind(bad, 's').ok, `validateSiteWind accepted a bad funnel: ${label}`); + } + }); + test('an empty venturi list is a perfect no-op (backyard_01 is untouched)', () => { const def = storms.storm_02_wildnight; const bare = createWindField(def); diff --git a/web/world/js/weather.core.js b/web/world/js/weather.core.js index 1b2e478..b21cd0a 100644 --- a/web/world/js/weather.core.js +++ b/web/world/js/weather.core.js @@ -807,3 +807,29 @@ export function validateStorm(def, name = 'storm') { return { ok: errors.length === 0, errors }; } + +// ---------- site wind validator (SPRINT10 site-as-data) ---------- +// A site's `wind` block is hand-authored data like a storm, so it fails loud +// the same way. Lane A calls this at site load; setVenturi clamps defensively +// too, but a clamp hides a typo and this names it. Wind personality that lives +// in site JSON: venturi funnels (and later, per-site shelter overrides). +export function validateSiteWind(wind, name = 'site') { + const errors = []; + const bad = (m) => errors.push(`${name}.wind: ${m}`); + if (wind == null) return { ok: true, errors }; // a site with no wind block is fine + if (typeof wind !== 'object') { bad('must be an object'); return { ok: false, errors }; } + + if (wind.venturi != null) { + if (!Array.isArray(wind.venturi)) bad('venturi must be an array of funnel zones'); + else wind.venturi.forEach((v, i) => { + const at = `venturi[${i}]`; + if (!Number.isFinite(v.x) || !Number.isFinite(v.z)) bad(`${at} needs finite x,z (the throat centre)`); + if (v.axis != null && !Number.isFinite(v.axis)) bad(`${at}.axis must be radians (the direction the gap runs)`); + if (v.gain != null && !(v.gain >= 1)) bad(`${at}.gain must be >= 1 — a venturi speeds wind UP; use shelters to slow it (got ${v.gain})`); + if (v.gain != null && v.gain > 3) bad(`${at}.gain ${v.gain} is a wind tunnel, not a gap — cap ~2`); + if (v.radius != null && !(v.radius > 0)) bad(`${at}.radius must be > 0 metres`); + if (v.sharp != null && !(v.sharp >= 1)) bad(`${at}.sharp must be >= 1 (alignment falloff exponent)`); + }); + } + return { ok: errors.length === 0, errors }; +} diff --git a/web/world/js/weather.js b/web/world/js/weather.js index b49b92d..5b70807 100644 --- a/web/world/js/weather.js +++ b/web/world/js/weather.js @@ -11,11 +11,14 @@ import * as THREE from '../vendor/three.module.js'; import { - createWindField, validateStorm, GUST, RAIN_TIME_COMPRESSION, + createWindField, validateStorm, validateSiteWind, GUST, RAIN_TIME_COMPRESSION, hailBlockFor, stormStats, forecastFor, } from './weather.core.js'; -export { GUST, validateStorm, RAIN_TIME_COMPRESSION, hailBlockFor, stormStats, forecastFor }; +export { + GUST, validateStorm, validateSiteWind, RAIN_TIME_COMPRESSION, + hailBlockFor, stormStats, forecastFor, +}; const kmh = (ms) => ms * 3.6; const band = (b, fmt) => (b.hi - b.lo < 0.05 ? fmt(b.lo) : `${fmt(b.lo)}–${fmt(b.hi)}`); From 7fb30b33dc8e5dc7c59bd780a65b0d1d36d802b6 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 15:31:23 +1000 Subject: [PATCH 2/3] Post the site_02 venturi handoff: axis derived, gap coords needed Night 3 = corner block + storm_03b is the right pairing (the funnel teaches the southerly, 03b is the southerly). Its post-change wind blows toward -1.08 rad, so that's the venturi axis. Gave A/E the ready template needing only the two gap coordinates, and flagged B that the venturi is a balance lever their site_audit must include (gain is mine to tune to their sweep). Co-Authored-By: Claude Opus 4.8 --- THREADS.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/THREADS.md b/THREADS.md index 830c7f1..638de6c 100644 --- a/THREADS.md +++ b/THREADS.md @@ -2947,3 +2947,32 @@ Selftest: **277 passed, 0 failed, 0 skipped**, LANE BAL now visible. open on carports until landed), C's venturi (landed, waiting on site JSON shape), E's carport (landed as a data-driven trap, waiting on site_02), B's site_audit tool (from the quad sweep). Gate 3 (John plays the week) remains open — the game has never been more ready for it. + +[C] 2026-07-19 — 🌀 **SITE_02 VENTURI — everything but the two coordinates is ready. A/E, here's the axis; + give me the gap and I paste the list.** The venturi physics + `setVenturi` landed Sprint 9 (56/0/0); + this sprint I added `validateSiteWind(site.wind)` so the site JSON's wind block fails loud like a storm + (A: call it at site load next to validateStorm; a site with no wind block validates fine). What's left + is purely data, and it's a two-number question for whoever places the corner block's geometry. + **The axis is not a guess — it's the storm.** Night 3 is `storm_03b_earlybuster` (the early southerly + buster) and it moves to the corner block, which is the right pairing: the funnel exists to teach the + southerly, and 03b is the storm that HAS a southerly. Measured 03b's post-change wind — it settles and + peaks (21 m/s at t=49) blowing toward heading **≈ −1.08 rad (−62°, SSE)**. So the gap should run along + that line and the funnel screams exactly when the change lands at t=18. The venturi block, axis + pre-filled, everything else placeholder: + ```json + "wind": { "venturi": [ { "x": , "z": , "axis": -1.08, + "gain": 1.35, "radius": 5, "sharp": 3 } ] } + ``` + **A/E — the one thing I need from you: where is the gap?** Give me the two building edges that form it + (carport corner + fence line) and roughly where the rig sits, and I'll return exact `x,z` (throat + centred in the gap, positioned so the accelerated wind crosses the rigging zone) — or just drop my + template in with your gap coords and adjust axis if E orients the gap differently; the physics only + reads the number, so tell me the angle E built and I'll match it. + ⚠️ **B — the venturi is a BALANCE lever on site_02, so audit WITH it.** gain 1.35 boosts wind ×1.35 in + the throat when aligned (and the downdraft rides it, so cloth load scales too) — on an anchor-poor site + with a small sail that can tip winnable → not. Your `site_audit` loads the site JSON, so it'll include + the venturi automatically; if the sweep says no $80 line survives the funnel, ping me and I drop the + gain (1.35 → 1.2 is a one-number edit) rather than E moving the tree for a load I created. The gain is + mine to tune to your audit — the funnel should make the corner block MEAN, not impossible. + (Porosity/gardenHailExposure seam: already done — B took my one-liner in the fabric PR, cited the + ruling, wired it in skyfx exactly right. Nothing owed there.) From d5569327c5a2b59f88e2e9a40540a386767a2077 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 15:34:08 +1000 Subject: [PATCH 3/3] Measure the venturi's load cost for B's audit: gain 1.35 = +40%, forces shackles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Load goes as wind^2 but the funnel only fires while aligned, so gain 1.35 adds +40% not +82% — a small quad in the throat peaks 1.99 kN, above a carabiner (1.2) and under a shackle (3.2). The funnel punishes the cheap corner specifically without being unwinnable: the corner block teaching itself. Co-Authored-By: Claude Opus 4.8 --- THREADS.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/THREADS.md b/THREADS.md index 638de6c..828e08d 100644 --- a/THREADS.md +++ b/THREADS.md @@ -2976,3 +2976,21 @@ Selftest: **277 passed, 0 failed, 0 skipped**, LANE BAL now visible. mine to tune to your audit — the funnel should make the corner block MEAN, not impossible. (Porosity/gardenHailExposure seam: already done — B took my one-liner in the fabric PR, cited the ruling, wired it in skyfx exactly right. Nothing owed there.) + +[C] 2026-07-19 — 📏 **The venturi's load cost, measured — so B's audit has a number to check against, and + it turns out gain 1.35 is not just safe but DESIGNS the site well.** Load goes as wind², so I worried + ×1.35 wind → ×1.8 load. It doesn't: the funnel only fires while the wind is aligned with the axis, so + on storm_03b a small ~30 m² quad sitting in the throat peaks: + ``` + gain 1.00 (off) 1.42 kN + gain 1.20 1.74 kN (+22%) + gain 1.35 1.99 kN (+40%) ← proposed + gain 1.50 2.24 kN (+58%) + ``` + At **1.99 kN, gain 1.35 sits ABOVE the carabiner (1.2) and well UNDER the shackle (3.2).** So the + corner block's funnel does exactly what its personality says: it punishes the cheap corner + specifically — a carabiner in the throat blows, a shackle holds — without being unwinnable. "Nowhere + to tie off, and the wind funnels, so you can't cheap out on the tie-off you DO have" is the site + teaching itself. This is on the current yard as a proxy (site_02's real geometry is B's audit); but it + says 1.35 is a good starting gain, not a coin-flip. If your sweep on the real site disagrees, the + number to move is the gain and it's mine.