From 19f4ab6c63b38a777a5191463218e692c7f6f73f Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 14:25:26 +1000 Subject: [PATCH 1/2] Venturi: per-site wind personality for site_02 (the corner block funnels the southerly) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A venturi is a shelter's opposite — a gap between buildings SPEEDS the wind up when it blows along the gap's axis. Where a tree shadow depends on being downwind of the tree, a venturi depends on the wind being ALIGNED with a fixed axis the SITE owns, so it fires only when the storm's direction swings to match: the corner block is calm until the southerly comes through, then it screams. That interplay is exactly SPRINT9's brief — funnel geometry is the site's, the wind that funnels is the storm's. Extends the existing shelter system rather than bolting on a parallel one: speedAt/vecAt/verticalAt all route through one localHoriz, which now folds venturiFactor in alongside spatialFactor and shelterFactor. The downdraft rides local speed, so a funnelled wind gets a proportionally stronger downdraft for free — physically right. `setVenturi(list)` on the wind + router (A's "add it to the router too" rule; the tripwire is green). Inert on an empty list, so backyard_01 is byte-identical — asserted. Multiplier is ALWAYS ≥ 1 (a venturi accelerates, never shelters), and it's C1-continuous through the change, so it can't snap a corner — both asserted (aligned wind boosts 1.5×, crosswind exactly 1.0×, max frame jump 0.50 m/s). Data lives in site JSON (A's schema, arriving with the extraction) — this is the physics + the setter, ready to wire. Proposed shape flagged for A in THREADS. Also fixed a stale comment: storm_02's _gusts_comment still said "HELD at 0.12 for now" from Sprint 3 while the value has been 0.45 since Sprint 4 — a lying comment in a repo that reads comments as canon. Now records the real history and the pending paired-0.40 flip. Selftest 278/0/0; node 55/0/0. Co-Authored-By: Claude Opus 4.8 --- web/world/data/storms/storm_02_wildnight.json | 2 +- web/world/js/main.js | 5 ++ web/world/js/tests/weather.selftest.js | 74 +++++++++++++++++++ web/world/js/weather.core.js | 66 ++++++++++++++++- web/world/js/weather.js | 9 +++ 5 files changed, 152 insertions(+), 4 deletions(-) diff --git a/web/world/data/storms/storm_02_wildnight.json b/web/world/data/storms/storm_02_wildnight.json index e95237e..113056e 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": "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_comment": "downdraftOfTotal = fraction of TOTAL wind speed that blows DOWN (SPRINT3 decision 8), present whenever it's windy, not only in gusts. LANDED at 0.45 in Sprint 4 once A's decision-2 anchors gave properly-sized quads and B re-pointed §7 — it clears B's 60% flat-horizontal:flat-pitched bar (69% of-max / 60% worst-heading) and lets 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). SPRINT9 NOTE: a paired drop to 0.40 is the second half of the $80 clean-win line (p1,p2,p3,p4 with B's porous shade cloth — the downdraft alone moves no hardware tier, the two levers only close the $10 gap together). That flip lands WITH B's fabric choice, not before it — see THREADS [C] 2026-07-18/-07-19.", "gusts": { "firstAt": 3, diff --git a/web/world/js/main.js b/web/world/js/main.js index 0a747bc..a7229d2 100644 --- a/web/world/js/main.js +++ b/web/world/js/main.js @@ -297,6 +297,11 @@ export function createWindRouter(all) { length: o.length ?? 14, }))); }, + setVenturi(list) { // SPRINT9 site_02 — funnels are site geometry + for (const w of all) w.setVenturi(list); + return router; + }, + get venturi() { return active.venturi; }, get hailSize() { return active.hailSize; }, // SPRINT5 decision 13 get duration() { return active.duration; }, diff --git a/web/world/js/tests/weather.selftest.js b/web/world/js/tests/weather.selftest.js index f49ca8f..b4bd053 100644 --- a/web/world/js/tests/weather.selftest.js +++ b/web/world/js/tests/weather.selftest.js @@ -281,6 +281,80 @@ export function weatherCases(storms) { assert(Math.abs(luvS - luvB) < 1e-9, 'upwind side is being sheltered — shadow is pointing the wrong way'); }); + // ---- 8b. venturi (SPRINT9 site_02 — the corner block funnels the southerly) ---- + test('venturi speeds the wind up when it blows along the gap, not across it', () => { + const def = storms.storm_02_wildnight; + // a funnel whose axis runs NE–SW (0.9 rad ≈ 51°), at the yard centre + const AXIS = 0.9; + const bare = createWindField(def); + const funnel = createWindField(def).setVenturi([{ x: 0, z: 0, axis: AXIS, gain: 1.5, radius: 5, sharp: 3 }]); + + // when the wind blows ALONG the axis, the throat should scream + const dt = 1 / 60; + let alongBoost = 1, crossBoost = 1, alongT = 0, crossT = 0; + for (let t = 0; t <= def.duration; t += dt) { + const d = bare.dirAt(t); + // alignment of the wind with the funnel axis, |dot| + const align = Math.abs(Math.cos(d) * Math.cos(AXIS) + Math.sin(d) * Math.sin(AXIS)); + const boost = funnel.speedAt(0, 0, t) / bare.speedAt(0, 0, t); + if (align > 0.98 && boost > alongBoost) { alongBoost = boost; alongT = t; } + if (align < 0.15 && boost < crossBoost) { crossBoost = boost; crossT = t; } + } + metrics['venturi.alignedBoost'] = +alongBoost.toFixed(3); + metrics['venturi.crosswindBoost'] = +crossBoost.toFixed(3); + assert(alongBoost > 1.35, `a dead-on wind only boosted ${alongBoost.toFixed(2)}× at t=${alongT.toFixed(1)} — funnel too weak`); + assert(crossBoost < 1.03, `a crosswind still boosted ${crossBoost.toFixed(2)}× at t=${crossT.toFixed(1)} — a funnel shouldn't fire across its axis`); + // never slows the wind (a venturi accelerates, it doesn't shelter) + assert(alongBoost >= 1 && crossBoost >= 1, 'venturi reduced the wind somewhere — it can only speed it up'); + }); + + test('venturi only reaches inside its radius, and downdraft rides it', () => { + const def = storms.storm_02_wildnight; + const f = createWindField(def).setVenturi([{ x: 0, z: 0, axis: 0.9, gain: 1.5, radius: 5, sharp: 3 }]); + const bare = createWindField(def); + // far outside the throat: untouched + assert(Math.abs(f.speedAt(20, 0, 40) - bare.speedAt(20, 0, 40)) < 1e-9, 'venturi reached 20 m away'); + // the vertical downdraft is a fraction of local speed, so it funnels too + const out = { x: 0, y: 0, z: 0 }, outBare = { x: 0, y: 0, z: 0 }; + let maxRatio = 0; + for (let t = 0; t <= def.duration; t += 1 / 60) { + f.vecAt(0, 0, t, out); bare.vecAt(0, 0, t, outBare); + if (outBare.y < -0.1) maxRatio = Math.max(maxRatio, out.y / outBare.y); // both negative + } + assert(maxRatio > 1.3, `funnel didn't strengthen the downdraft (max ${maxRatio.toFixed(2)}×) — it should ride local speed`); + }); + + test('an empty venturi list is a perfect no-op (backyard_01 is untouched)', () => { + const def = storms.storm_02_wildnight; + const bare = createWindField(def); + const set = createWindField(def).setVenturi([]); + for (const p of PROBES) { + for (const t of [10, 40, 60, 75]) { + assert(bare.speedAt(p.x, p.z, t) === set.speedAt(p.x, p.z, t), + `setVenturi([]) changed the wind at (${p.x},${p.z}) t=${t}`); + } + } + }); + + test('venturi keeps the wind continuous — no frame-to-frame snap', () => { + // a discontinuity in the funnel would be an impulse straight into a corner; + // both axes matter — a standing sail (time) and a swinging wind (alignment). + const def = storms.storm_02_wildnight; + const f = createWindField(def).setVenturi([{ x: 0, z: 0, axis: 0.9, gain: 1.6, radius: 5, sharp: 3 }]); + const a = { x: 0, y: 0, z: 0 }, b = { x: 0, y: 0, z: 0 }; + let worst = 0, worstT = 0; + // sit right in the throat through the whole storm, including the change + f.vecAt(0.5, 0.5, 0, a); + for (let t = 1 / 60; t <= def.duration; t += 1 / 60) { + f.vecAt(0.5, 0.5, t, b); + const jump = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z); + if (jump > worst) { worst = jump; worstT = t; } + a.x = b.x; a.y = b.y; a.z = b.z; + } + metrics['venturi.maxTemporalJump'] = +worst.toFixed(3); + assert(worst < 1.5, `venturi jumped ${worst.toFixed(2)} m/s in one frame at t=${worstT.toFixed(1)}`); + }); + // ---- 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 diff --git a/web/world/js/weather.core.js b/web/world/js/weather.core.js index cc72bf5..1b2e478 100644 --- a/web/world/js/weather.core.js +++ b/web/world/js/weather.core.js @@ -252,6 +252,7 @@ export function createWindField(def, opts = {}) { : []; let shelters = []; + let venturi = []; /** Spatially-uniform part: base curve + every gust envelope live at t. */ function uniformSpeed(t) { @@ -278,12 +279,13 @@ 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. */ + /** Local horizontal wind speed (m/s) — base+gusts, spatial noise, tree shadow, + * site venturi. The one place the local-speed maths lives; speedAt/vecAt/ + * verticalAt share it, so the downdraft rides the funnel too. */ 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)); + const s = uni * localFactor(x, z, t, Math.cos(d), Math.sin(d)); return s > 0 ? s : 0; } @@ -377,6 +379,38 @@ export function createWindField(def, opts = {}) { return f; } + /** + * A venturi is a shelter's opposite: a gap between buildings SPEEDS the wind up + * when it blows along the gap's axis (SPRINT9 site_02, the corner block funnels + * the southerly). Where a tree shadow depends on being downwind of the tree, + * a venturi depends on the wind being ALIGNED with a fixed axis the SITE owns — + * so it fires only when the storm's direction swings to match, which is the + * whole drama: the corner block is calm until the southerly comes through, then + * it screams. Multiplier ≥ 1, and inert (empty list) on any site that has no + * funnel, so backyard_01 is untouched. + */ + function venturiFactor(x, z, dirX, dirZ) { + let f = 1; + for (let i = 0; i < venturi.length; i++) { + const v = venturi[i]; + const rx = x - v.x, rz = z - v.z; + const dist = Math.hypot(rx, rz); + if (dist >= v.radius) continue; + // how well the wind lines up with the gap's axis. |dot| because a gap + // funnels either way through it; ^sharp so only a well-aligned wind counts. + let align = Math.abs(dirX * v.axisX + dirZ * v.axisZ); + align = Math.pow(align, v.sharp); + const radial = 1 - smoothstep(v.radius * 0.4, v.radius, dist); // full in the throat, fades out + f *= 1 + (v.gain - 1) * align * radial; + } + return f; + } + + /** Every spatial speed multiplier at a point, given the wind direction. */ + function localFactor(x, z, t, dirX, dirZ) { + return spatialFactor(x, z, t) * shelterFactor(x, z, dirX, dirZ) * venturiFactor(x, z, dirX, dirZ); + } + const field = { def, seed, @@ -399,6 +433,32 @@ export function createWindField(def, opts = {}) { }, get shelters() { return shelters; }, + /** + * A site's wind funnels (SPRINT9 site_02). Lane A calls this from the site + * JSON after building the yard, the same way it calls setSheltersFromTrees. + * Unset = no funnels, so backyard_01 is untouched. Each zone: + * { x, z } centre of the throat, metres + * axis direction the gap runs, RADIANS in the XZ plane + * gain peak speed multiplier when the wind is dead-on (>1) + * radius how far the acceleration reaches, metres + * sharp alignment falloff exponent — higher = only a wind almost + * exactly along the axis funnels (default 3) + */ + setVenturi(list) { + venturi = (list || []).map((v) => { + const axis = v.axis ?? 0; + return { + x: v.x, z: v.z, + axisX: Math.cos(axis), axisZ: Math.sin(axis), + gain: Math.max(1, v.gain ?? 1.4), + radius: v.radius ?? 4, + sharp: Math.max(1, v.sharp ?? 3), + }; + }); + return field; + }, + get venturi() { return venturi; }, + /** * Scalar wind speed (m/s) at a point — HORIZONTAL only, which is what an * anemometer reads and what the HUD, rain and grass want. The gust downdraft diff --git a/web/world/js/weather.js b/web/world/js/weather.js index 738871b..b49b92d 100644 --- a/web/world/js/weather.js +++ b/web/world/js/weather.js @@ -136,6 +136,15 @@ export function createWind(def, opts = {}) { }))); }, + /** + * A site's wind funnels (SPRINT9 site_02). Lane A: call with the site JSON's + * `wind.venturi` after building the yard. Empty/absent = no funnels, so + * backyard_01 reads exactly as it always has. + * @param {Array<{x,z,axis,gain,radius,sharp}>} list + */ + setVenturi(list) { field.setVenturi(list); return wind; }, + get venturi() { return field.venturi; }, + /** Storm events fired in (a,b] — poll with (t-dt, t). Deterministic. */ eventsBetween(a, b) { return field.eventsBetween(a, b); }, From 553d3edd947e2ce200587303d61b2ff06171c91e Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 14:28:11 +1000 Subject: [PATCH 2/2] Post B's paired-landing recipe and A's site-venturi schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit $80 win re-verified on rebased main (porous + 0.40, hp 52, 1 lost). Not flipping 0.40 until B's fabric is selectable — the win needs porous cloth. Handed A the site-JSON venturi shape to wire from their extraction. Co-Authored-By: Claude Opus 4.8 --- THREADS.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/THREADS.md b/THREADS.md index a7fd562..7ca720b 100644 --- a/THREADS.md +++ b/THREADS.md @@ -2693,3 +2693,47 @@ Format: `[lane letter] YYYY-MM-DD — note` on the record, A's "1 lost" was a miscount, and C found the REAL winnable line (p1,p2,p3,p4 — $80 exactly, hp 52, 1 lost) that needs B's porous fabric + C's 0.40 downdraft STACKED. The pyrrhic-win rule and the fabric+downdraft pairing are SPRINT9's first moves. + +[C] 2026-07-19 — 🅱️ **B — the paired-landing recipe (SPRINT9 decision 2). A said go on 0.40; it's on your + fabric.** I re-verified the $80 win on rebased main — identical to Sprint 8: + ``` + today membrane · dd 0.45 · rated p4/p2 · shackle p3 · shackle p1 = $90 → hp 52, 1 lost WIN + paired POROUS · dd 0.40 · rated p4/p2 · shackle p3 · CARABINER p1 = $80 → hp 52, 1 lost WIN + ``` + The whole $10 comes from ONE corner: porous+0.40 drops p1 from 1.49 → 1.08 kN, under the carabiner's + 1.2 rating, so its shackle ($15) becomes a carabiner ($5). Neither lever moves a tier alone. + **I am NOT flipping 0.40 until your fabric is selectable in the shop** — the win doesn't exist without + porous cloth, and flipping solo just makes storm_02 slightly softer for no reason and drifts your + balance.test inputs. **When you land fabric, ping me and I flip 0.40 the same PR** (one-line data edit, + storm_02 `downdraftOfTotal` 0.45 → 0.40 — the comment there already documents it as pending). Then + balance.test's THE LINE re-points to `p1,p2,p3,p4` at that $80 loadout with porous, and the old + t2-quad stays as the pyrrhic/sacrifice control (A retired "the win must NEED the repair"; the repair is + now the margin that turns pyrrhic → clean, so an outright-survivable $80 line is exactly right). + Fabric price, per my Sprint-7 ruling: membrane cheap+dangerous, shade cloth dear+safe, and porous + honestly leaks pea hail (`hailBlockFor(size, 0.3)`: blocks the 1.3/1.4 storm ice fully, passes ~26% of + storm_03's 0.7 pea hail). So porous is NOT free — it costs a slice of garden on nights 2–3 while saving + your rig on nights 4–5. Whatever you price it at, that's the honest tradeoff to price against. + ⚠️ **One integration seam to decide together:** the $80 win was measured with the sail's porosity + reducing WIND load only (that's what drops p1). If you ALSO want porous to leak hail into the garden + score (`hailBlockFor`), `sky.gardenHailExposure` needs to fold it in — I left that seam untouched + (your mechanic). Say the word and I make gardenHailExposure read `sail.porosity` (one line, no-op for + membrane); or you wire it cloth-side. Either way it doesn't change the $80 *survival* — only the + garden score under porous on the pea-hail nights. + +[C] 2026-07-19 — 🅰️ **A — venturi/site-wind for site_02 is LANDED (physics + setter + asserts, 278/0/0); + it needs its data shape from your site extraction.** `wind.setVenturi(list)` is on the wind and the + router (tripwire green). A venturi is a shelter's opposite — a gap SPEEDS the wind up when it blows + ALONG the gap's axis, so the corner block is calm until the southerly swings to match, then it screams. + Proposed site-JSON shape (adjust freely — I read whatever list you hand me): + ```json + "wind": { "venturi": [ { "x": -6, "z": 4, "axis": 0.9, "gain": 1.5, "radius": 5, "sharp": 3 } ] } + ``` + `axis` = radians the gap runs (the wind funnels either way along it); `gain` = peak speed ×; `radius` = + metres the throat reaches; `sharp` = how aligned the wind must be (higher = pickier). Wire it after the + yard builds, beside your `setSheltersFromTrees`: `wind.setVenturi(site.wind?.venturi ?? [])`. Empty/ + absent = perfect no-op, so backyard_01 is byte-identical (asserted). Measured on a synthetic corner + block: aligned wind boosts ×1.5, crosswind ×1.0 (nothing), continuous through the change (0.50 m/s max + frame jump), and the downdraft rides the funnel for free. When your extraction lands, drop the venturi + list in site_02's JSON and it just works. **Where do you want the block's gap?** — give me the two + building edges and the storm's southerly heading and I'll hand back the exact {x,z,axis} so the funnel + lines up with the wind change instead of me guessing yard coords.