diff --git a/THREADS.md b/THREADS.md index e843c73..2a57778 100644 --- a/THREADS.md +++ b/THREADS.md @@ -4733,3 +4733,87 @@ anchors are your GLB), but the tooling is now waiting, not TODO. table is waiting for your audit rewrite whenever A's garden export lands; my gate-1 postscript upstream has the label-permutation warning if you lift per-corner numbers from any bench β€” print `corners[k].anchorId`, never the input order. + +[C] 2026-07-18 β€” πŸ›‘ **CORRECTION: THE BENCH HAD TWO LANDMINES, NOT ONE, AND THE WILD-NIGHT $80 + HEADLINE IS DEAD. D's funnel catch confirmed, plus a second bug D's replay exposed but nobody + had named: the bench flew site_02's gum tree FROZEN.** Corrected tools, corrected tables, + corrected claims below. Selftest 341/0/0; the fix carries its own assert. + + **Landmine 1 β€” the venturi (D's catch, exactly as filed).** bench.js:89 read `def.wind.venturi` + off the STORM def, where a venturi never lives β€” it LOOKED right and never fired; probe3/probe4/ + sweep2 never attempted it. Every site_02 number the bench ever printed was flown with the + funnel OFF. Third harness to make B's Sprint-11 mistake, and I made it with B's sweep.selftest + sitting in the repo telling the story. + + **Landmine 2 β€” the FROZEN TREE, found while verifying the fix.** Venturi-on alone did NOT + reproduce D's game numbers (q4 moved 1.02β†’1.03), so I kept digging with an ablation: + funnel OFF, static anchors q4 1.02 (the original bench) + funnel ON, static anchors q4 1.03 (the funnel's disc barely reaches q4) + funnel ON, REAL anchors q4 1.24 β€” the tree is the bigger half on this line + The bench remapped every anchor to `sway: () => pos` β€” which froze tr1, the gum tree, whose + sway is precisely the dynamic load DESIGN.md warns a tree rig has to eat (it also dropped + ratingHint). **This is why the backyard bench matched D's UI to the decimal while site_02 + lied: p1..p4 are all posts β€” static anchors and no venturi are both no-ops there.** The two + landmines are the same species: the bench modelled the STORM exactly and the YARD not at all. + + **The fix, structural so there is never a fourth:** + Β· `windForSite(stormDef, siteDef, anchors)` in weather.js β€” THE shared wind builder: venturi + from the site def + tree shelters, wired byte-for-byte as main.js:453 does at site load. The + landmine's full history lives in its docstring. All garden_bench tools now build wind + through it; **B β€” proposal: your re-audit imports it too**, and at integration one copy + should win (your sweep.js has its own correct pair of setVenturi calls + the selftest β€” the + logic is identical, the point is one door). + Β· c.test.js gains B's strictly-raises pin lifted onto the helper, flown against the REAL + shipped funnel (site_02's own JSON, not a synthetic): drop the setVenturi line and the + funnelled/bare winds collapse to equal reads at the throat β†’ red. Mutation-checked. + Β· The tools fly `world.anchors` THEMSELVES β€” the world is built on a re-pointable wind proxy + (main.js-router style) so tree-sway closures sample whichever storm is flying. No more + static remaps, anywhere. And no more phantom 12 s calm settle: in the real game the rig + does not exist before commit, and commitβ†’attachβ†’storm is one keypress β€” the settle also + skewed every storm sample 12 s off the authored curve, because SailRig samples wind at its + INTERNAL clock (measured: worth 0.01 kN on backyard posts, which is why nobody noticed). + Β· Every tool prints the venturi in its header (B's audit.html pattern) β€” a reader can now SEE + which wind a number was flown in. + + **CORRECTED TABLES (probe4, real shop, real $80, funnel + sway on):** + $80 line tr1(R)+q1(R)+q3(S)+q4(C): + wildnight 91.9 FULL 0/4 peaks q1 4.91 tr1 4.31 q3 2.89 **q4 1.24 vs 1.2 rating** + icenight 9 DEAD 2/4 (q3 3.32 breaks tβ‰ˆ50, q4 1.45 breaks tβ‰ˆ49) + buster 95.2 FULL 0/4 peaks q1 1.71 tr1 1.79 q3 0.84 q4 0.56 + all-shackle $60: wildnight 41.3 (2/4) Β· icenight 0 Β· buster 95.2 FULL 0/4 + canonical q1+q2+q3+tr1 best-$80: wildnight 48.3 (1/4) Β· icenight 0.9 Β· buster 85.6 + probe3 (RATED, unlimited): backyard rows essentially unmoved β€” canonical wildnight 60.9 + (was 61.3; the 0.4 is the settle removal) and balance.test's canon peaks intact to 0.01 kN + (p4 3.64 p2 2.77 p3 1.62 p1 0.98). site_02 posts-line wildnight drops 90.1β†’79.6 (q1 6.75 + breaks tβ‰ˆ46 β€” the funnel side), canonical 51.3β†’48.3. sweep2 re-run: **tr1+q1+q3+q4 is still + the #1 cover-AND-hold geometry** (100% cover, shadow 1.000) β€” the LINE survives; its + wild-night steel pricing does not. + + **⚠️ THE HONEST RESIDUAL β€” and who is canonical where.** Even fully corrected, the bench + under-reads D's real-UI peaks by ~5–15% on site_02 (buster, nothing breaking: bench + q1 1.71 Β· tr1 1.79 Β· q3 0.84 Β· q4 0.56 vs D's 1.82 Β· 1.86 Β· 0.99 Β· 0.58). On the wild night + that margin is exactly the knife edge: the bench holds q4 at 1.24-vs-1.2 and reads 91.9; the + real game pushes it over, q4 lets go, q3 catches the shed (3.43), and D's 39.8 TATTERED is + what ships. **D's UI numbers are canonical for the wild-night verdict; the bench's own + corrected read supports the same conclusion the honest way: a line whose cheapest corner + peaks OVER its rating has no margin and is not a safe line.** The 91.5-FULL-with-room + headline was the funnel-off, frozen-tree illusion. Residual cause unlisted-variable, in the + pool; until it's found, any bench corner within ~15% of its rating is "breaks in the game". + + **What survives, what dies (A β€” for your gate-1.4 ruling):** + Β· SURVIVES: the shadow MODEL's exactness (probe2, untouched), the vertical-projection + conclusion, every backyard finding (D-verified to the decimal β€” posts don't sway and + backyard has no funnel), the buster verdict (the $80 line holds site_02's actual shipped + night with room β€” D live 97.9), and the diagnosis IN KIND: the canonical lines still can't + cover, covering geometry still exists. + Β· DIES: the wild-night 91.5 FULL headline, "$80 to the dollar with nothing to spare" (its + pricing was computed against funnel-off loads), and therefore **my separation-target + example. A: I withdraw the site_02 example, not the shape.** D's gate-1.4 table is right + that the target as I worded it is currently satisfiable nowhere β€” bare never reads DEAD, + and the funnelled wild night has no FULL-capable buyable line on either yard. Rule on D's + played picture; if the shape survives, the thresholds want restating against funnel-on + numbers (D's "bare TATTERED, held FULL" is the version night 4 already nearly meets). + Β· **B, one more for the re-audit:** your audit "freezes sway like balance.test" + (audit.html:69) β€” same species as my landmine 2. Post-fuse re-audit should fly + world.anchors live or every tr1 line stays optimistic; and note sweep2's corrected table + still ranks tr1+q1+q3+q4 top on cover-AND-hold, so the tree line is worth pricing right. diff --git a/tools/garden_bench/bench.js b/tools/garden_bench/bench.js index d6ab599..0c56e34 100644 --- a/tools/garden_bench/bench.js +++ b/tools/garden_bench/bench.js @@ -28,7 +28,7 @@ import * as THREE from '../../web/world/vendor/three.module.js'; import { RiggingSession } from '../../web/world/js/rigging.js'; import { SailRig } from '../../web/world/js/sail.js'; import { createSkyFx } from '../../web/world/js/skyfx.js'; -import { createWind, loadStorm } from '../../web/world/js/weather.js'; +import { loadStorm, windForSite } from '../../web/world/js/weather.js'; import { createWorld, loadSite } from '../../web/world/js/world.js'; import { HARDWARE, FIXED_DT } from '../../web/world/js/contracts.js'; @@ -39,7 +39,6 @@ const [CARABINER, SHACKLE, RATED] = HARDWARE; const GARDEN_DRAIN = 0.9; const W_HAIL = 5.0; const W_RAIN = 0.25; -const CALM_STORM = 'storm_01_gentle'; export const STORMS = [ 'storm_01_gentle', 'storm_02_wildnight', 'storm_03_southerly', @@ -57,23 +56,47 @@ export const YARDS = [ { site: 'site_02_corner_block', quad: ['q1', 'q2', 'q3', 'tr1'] }, ]; -/** The real yard, read from world.js β€” never a copied anchor table. */ +/** + * The real yard, read from world.js β€” never a copied anchor table, and never a + * FROZEN one. Two landmines this builder now closes (SPRINT13, both caught by + * D replaying through the real UI): + * 1. The SITE def rides along because its wind block is half the yard's + * weather: the venturi lives HERE, not in any storm def. Measured without + * it, site_02 is a different, easier yard (third harness to learn this; + * windForSite is the fix and the story). + * 2. The anchors are world.anchors THEMSELVES. The old remap to a static + * `sway: () => pos` froze the gum tree β€” a rig on a tree eats the tree's + * sway as dynamic load, and stripping it under-read every tr1 corner. + * The world is built on a re-pointable wind proxy (main.js-router style) + * so the sway closures sample whichever storm is currently flying. + */ async function buildYard(siteId) { const scene = new THREE.Scene(); - const calm = { + let current = { sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4), speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0, - gustTelegraph: () => null, setSheltersFromTrees() {}, eventsBetween: () => [], + gustTelegraph: () => null, eventsBetween: () => [], }; - const world = createWorld(scene, { wind: calm, site: await loadSite(siteId) }); + const proxy = { + sample: (p, t, o) => current.sample(p, t, o || new THREE.Vector3()), + speedAt: (p, t) => current.speedAt(p, t), + rainAt: (t) => current.rainAt(t), + rainMmPerHour: (t) => (current.rainMmPerHour ? current.rainMmPerHour(t) : 0), + gustTelegraph: (t) => current.gustTelegraph(t), + eventsBetween: (a, b) => current.eventsBetween(a, b), + setSheltersFromTrees() {}, + get seed() { return current.seed ?? 1; }, + get def() { return current.def ?? {}; }, + }; + const siteDef = await loadSite(siteId); + const world = createWorld(scene, { wind: proxy, site: siteDef }); if (world.dress) { try { await world.dress(); } catch { /* graybox anchors are enough */ } } - const anchors = world.anchors.map((a) => { - const pos = { x: a.pos.x, y: a.pos.y, z: a.pos.z }; - return { id: a.id, type: a.type, pos, sway: () => pos }; - }); + const anchors = world.anchors; return { anchors, bed: world.gardenBed, ids: anchors.map((a) => a.id), sunDir: world.sunDir, heightAt: world.heightAt, + siteDef, + use: (w) => { current = w; }, }; } @@ -83,10 +106,14 @@ async function buildYard(siteId) { */ async function fly(yard, stormName, mode) { const def = await loadStorm(stormName); - const wind = createWind(def); - const trees = yard.anchors.filter((a) => a.type === 'tree'); - wind.setSheltersFromTrees(trees); - if (def.wind && def.wind.venturi) wind.setVenturi(def.wind.venturi); + // windForSite, NOT createWind + storm-def venturi. The old line here read + // `def.wind.venturi` off the STORM def, where a venturi never lives β€” it + // LOOKED right and never fired, so every site_02 number this bench produced + // before 2026-07-18 was flown with the funnel OFF (D caught it replaying the + // $80 line through the real UI: 91.5 FULL became 39.8 TATTERED). Same bug + // B's site_audit shipped in Sprint 11. The helper carries the story. + const wind = windForSite(def, yard.siteDef, yard.anchors); + yard.use(wind); // point the yard's tree-sway closures at this flight's wind let rig = null; if (mode === 'held') { @@ -104,19 +131,12 @@ async function fly(yard, stormName, mode) { rig = s.commit(new SailRig({ anchors: yard.anchors, gridN: 10 })); } - // The settle: a player stands through ~12 s of prep on the calm day, so the - // cloth is in steady state at ENTER. A bench that rigs and storms in the same - // tick measures the attach transient (balance.test gate 0'). - if (rig) { - const calmDef = await loadStorm(CALM_STORM); - const calmWind = createWind(calmDef); - calmWind.setSheltersFromTrees(trees); - let prepT = 0; - for (let i = 0; i < Math.round(12 / FIXED_DT); i++) { - rig.step(FIXED_DT, calmWind, prepT % 3); - prepT += FIXED_DT; - } - } + // NO calm settle (correction, 2026-07-18): in the real game the rig does not + // exist before commit, and commit β†’ attach β†’ advance() β†’ storm land in one + // keypress (sail.js attach, B's clock-reset comment). The attach transient + // rides the storm's own calm opening exactly as it does for a player β€” and + // because SailRig samples wind at its INTERNAL clock, a 12 s settle here + // skewed every storm sample 12 s off the authored curve. const camera = new THREE.PerspectiveCamera(60, 1.6, 0.1, 400); camera.position.set(0, 2, 8); @@ -206,7 +226,13 @@ export async function runBench(log) { for (const y of YARDS) { const yard = await buildYard(y.site); - log(`\n## ${y.site} bed ${yard.bed.w}Γ—${yard.bed.d} at (${yard.bed.x}, ${yard.bed.z}) line ${y.quad.join('+')}`); + // Print the funnel in the header, B's audit.html pattern: a reader must be + // able to SEE which wind these numbers were flown in. + const vl = yard.siteDef.wind?.venturi ?? []; + const vtxt = vl.length + ? vl.map((v) => `throat (${v.x},${v.z}) gain ${v.gain} r${v.radius}`).join(' Β· ') + : 'none'; + log(`\n## ${y.site} bed ${yard.bed.w}Γ—${yard.bed.d} at (${yard.bed.x}, ${yard.bed.z}) line ${y.quad.join('+')} venturi: ${vtxt}`); for (const storm of STORMS) { const held = await fly({ ...yard, quad: y.quad }, storm, 'held'); diff --git a/tools/garden_bench/probe3.html b/tools/garden_bench/probe3.html index d5b96dc..4a68d94 100644 --- a/tools/garden_bench/probe3.html +++ b/tools/garden_bench/probe3.html @@ -15,7 +15,7 @@ import * as THREE from '../../web/world/vendor/three.module.js'; import { RiggingSession } from '../../web/world/js/rigging.js'; import { SailRig } from '../../web/world/js/sail.js'; import { createSkyFx } from '../../web/world/js/skyfx.js'; -import { createWind, loadStorm } from '../../web/world/js/weather.js'; +import { loadStorm, windForSite } from '../../web/world/js/weather.js'; import { createWorld, loadSite } from '../../web/world/js/world.js'; import { FIXED_DT, HARDWARE } from '../../web/world/js/contracts.js'; const [CARABINER, SHACKLE, RATED] = HARDWARE; @@ -36,13 +36,19 @@ el.textContent = ''; // Fly the covering lines. Report peak corner load per corner and the garden. const GARDEN_DRAIN = 0.9, W_HAIL = 5.0, W_RAIN = 0.25; -async function fly(anchors, bed, ids, stormName, hw) { +async function fly(world, ids, stormName, hw, siteDef, use) { const def = await loadStorm(stormName); - const wind = createWind(def); - const trees = anchors.filter((a) => a.type === 'tree'); - wind.setSheltersFromTrees(trees); - const calmDef = await loadStorm('storm_01_gentle'); - const calmWind = createWind(calmDef); calmWind.setSheltersFromTrees(trees); + const anchors = world.anchors; // REAL anchors: trees sway, hints ride along + const bed = world.gardenBed; + // windForSite: the SITE's venturi + shelters, as main.js wires them. Before + // 2026-07-18 this probe (a) never set the venturi β€” every site_02 row was + // flown with the funnel OFF β€” and (b) remapped anchors to a static + // `sway: () => pos`, freezing the gum tree and dropping ratingHint. See + // probe4's correction note and the THREADS postscript; backyard post-only + // lines (p1..p4) were unaffected by either, which is why they matched D's + // UI replays to the decimal while the tr1 lines lied. + const wind = windForSite(def, siteDef, anchors); + use(wind); const s = new RiggingSession({ anchors, budget: 9999 }); for (const id of ids) if (!s.rig(id).ok) return null; @@ -50,8 +56,7 @@ async function fly(anchors, bed, ids, stormName, hw) { if (!r.ok) throw new Error(`setHardware ${id} refused: ${r.reason} β€” flying hardware it did not buy`); } s.setTension(1.0); const rig = s.commit(new SailRig({ anchors, gridN: 10 })); - let prepT = 0; - for (let i=0;i(o||new THREE.Vector3()).set(0,0,0), speedAt:()=>0, rainAt:()=>0, rainMmPerHour:()=>0, gustTelegraph:()=>null, setSheltersFromTrees(){}, eventsBetween:()=>[] }; - const world = createWorld(scene, { wind: calm, site: await loadSite(siteId) }); + const siteDef = await loadSite(siteId); + // re-pointable wind, main.js-router style: sway closures capture the proxy + let current = calm; + const proxy = { + sample: (p,t,o) => current.sample(p, t, o || new THREE.Vector3()), + speedAt: (p,t) => current.speedAt(p, t), + rainAt: (t) => current.rainAt(t), + rainMmPerHour: (t) => (current.rainMmPerHour ? current.rainMmPerHour(t) : 0), + gustTelegraph: (t) => current.gustTelegraph(t), + eventsBetween: (a,b) => current.eventsBetween(a, b), + setSheltersFromTrees() {}, + get seed() { return current.seed ?? 1; }, + get def() { return current.def ?? {}; }, + }; + const use = (w) => { current = w; }; + const world = createWorld(scene, { wind: proxy, site: siteDef }); if (world.dress) { try { await world.dress(); } catch {} } - const anchors = world.anchors.map((a)=>{const pos={x:a.pos.x,y:a.pos.y,z:a.pos.z}; - return {id:a.id,type:a.type,pos,sway:()=>pos};}); const bed = world.gardenBed; - log(`\n\n######## ${siteId} (RATED = 6.5 kN is the best steel in the game, $30/corner)`); + const anchors = world.anchors; + const vl = siteDef.wind?.venturi ?? []; + const vtxt = vl.length ? vl.map((v)=>`throat (${v.x},${v.z}) gain ${v.gain} r${v.radius}`).join(' Β· ') : 'none'; + log(`\n\n######## ${siteId} (RATED = 6.5 kN is the best steel in the game, $30/corner) venturi: ${vtxt}`); for (const [label, ids] of Object.entries(lineSet)) { for (const storm of ['storm_02_wildnight', 'storm_02b_icenight', 'storm_03b_earlybuster']) { - const r = await fly(anchors, bed, ids, storm, RATED); + const r = await fly(world, ids, storm, RATED, siteDef, use); if (!r) { log(` ${label} ${ids.join('+')}: REFUSED`); continue; } const over = r.peak.map((p,i)=>`${r.cornerIds[i]} ${p.toFixed(2)}`).join(' '); const bare = { storm_02_wildnight: 35.7, storm_02b_icenight: 0, storm_03b_earlybuster: 82.6 }[storm]; diff --git a/tools/garden_bench/probe4.html b/tools/garden_bench/probe4.html index c204798..5f53a8a 100644 --- a/tools/garden_bench/probe4.html +++ b/tools/garden_bench/probe4.html @@ -15,7 +15,7 @@ import * as THREE from '../../web/world/vendor/three.module.js'; import { RiggingSession } from '../../web/world/js/rigging.js'; import { SailRig } from '../../web/world/js/sail.js'; import { createSkyFx } from '../../web/world/js/skyfx.js'; -import { createWind, loadStorm } from '../../web/world/js/weather.js'; +import { loadStorm, windForSite } from '../../web/world/js/weather.js'; import { createWorld, loadSite } from '../../web/world/js/world.js'; import { FIXED_DT, HARDWARE, START_BUDGET } from '../../web/world/js/contracts.js'; const [CARABINER, SHACKLE, RATED] = HARDWARE; @@ -23,24 +23,30 @@ const el = document.getElementById('out'); const lines = []; const log = (s) => { lines.push(s); el.textContent = lines.join('\n'); }; el.textContent = ''; -// THE PAYOFF. -// probe3 (on an unlimited budget) found tr1+q1+q3+q4 on site_02 holds every -// corner through the WILD night and the ICE night and reads hp 91.5 / 89.2 -// against a bare bed's 35.7 / 0.0. Its peak loads were tr1 4.72, q1 3.74, -// q3 2.97, q4 1.02 kN β€” which price EXACTLY to -// RATED(6.5) + RATED(6.5) + SHACKLE(3.2) + CARABINER(1.2) -// $30 + $30 + $15 + $5 = $80 -// ...the whole budget, to the dollar. If that holds through the REAL shop at the -// REAL START_BUDGET, then the garden already responds to rigging, gate 1 needs -// no model change, and what was actually broken is that nobody had found the -// line. So buy it the way a player must, and fly it. -async function fly(anchors, bed, plan, stormName) { +// THE PAYOFF β€” and the correction (2026-07-18). +// The first run of this probe read the $80 line tr1+q1+q3+q4 at 91.5 FULL on +// the wild night and called it the headline. TWO harness landmines, both mine: +// 1. No venturi β€” the funnel lives in the SITE def, and this probe never set +// it (bench.js read it off the STORM def, where it never lives). Third +// harness to make B's Sprint-11 mistake. +// 2. Static anchors β€” the remap `sway: () => pos` stripped tr1's TREE sway, +// the dynamic load a rig on a gum tree has to eat (DESIGN.md's whole +// warning about tree anchors). The backyard bench numbers matched D's UI +// replays to the decimal BECAUSE p1..p4 are all posts; any tr1 line was +// flown against a tree that never moved. +// With both fixed the probe agrees with D's live UI replays in direction and +// closely in number (buster peaks within ~15%); on the wild night the line's +// cheapest corner rides AT its rating (q4 carabiner ~1.2 kN) β€” a knife edge +// the real game falls off (D: q3+q4 break tβ‰ˆ45, 39.8 TATTERED). Where this +// probe and D's UI disagree on which side of that edge a corner lands, D's +// UI number is canonical. What survives: the line holds the BUSTER β€” site_02's +// actual shipped night β€” with room (D live: 97.9 FULL, 0/4). +async function fly(world, plan, stormName, siteDef, use) { const def = await loadStorm(stormName); - const wind = createWind(def); - const trees = anchors.filter((a) => a.type === 'tree'); - wind.setSheltersFromTrees(trees); - const calmWind = createWind(await loadStorm('storm_01_gentle')); - calmWind.setSheltersFromTrees(trees); + const anchors = world.anchors; // REAL anchors β€” tr1 sways + const bed = world.gardenBed; + const wind = windForSite(def, siteDef, anchors); + use(wind); // point the world's sway closures at the flight wind // THE REAL SHOP, at the REAL budget. Every refusal is loud. const s = new RiggingSession({ anchors }); // budget = START_BUDGET @@ -55,8 +61,10 @@ async function fly(anchors, bed, plan, stormName) { s.setTension(1.0); const spent = START_BUDGET - s.budget; const rig = s.commit(new SailRig({ anchors, gridN: 10 })); - let prepT = 0; - for (let i=0;i hp > 66 ? 'FULL' : hp > 33 ? 'tattered' : 'DEAD'; try { const scene = new THREE.Scene(); - const zero = { sample:(p,t,o)=>(o||new THREE.Vector3()).set(0,0,0), speedAt:()=>0, rainAt:()=>0, - rainMmPerHour:()=>0, gustTelegraph:()=>null, setSheltersFromTrees(){}, eventsBetween:()=>[] }; - const world = createWorld(scene, { wind: zero, site: await loadSite('site_02_corner_block') }); + // A re-pointable wind, the way main.js's router serves world.js: the world's + // tree-sway closures capture THIS object, and fly() re-points it per storm. + // Building the world on a dead stub and remapping anchors to static + // `sway: () => pos` was landmine #2 β€” it froze the gum tree. + let current = { sample:(p,t,o)=>(o||new THREE.Vector3()).set(0,0,0), speedAt:()=>0, rainAt:()=>0, + rainMmPerHour:()=>0, gustTelegraph:()=>null, eventsBetween:()=>[] }; + const proxy = { + sample: (p,t,o) => current.sample(p, t, o || new THREE.Vector3()), + speedAt: (p,t) => current.speedAt(p, t), + rainAt: (t) => current.rainAt(t), + rainMmPerHour: (t) => (current.rainMmPerHour ? current.rainMmPerHour(t) : 0), + gustTelegraph: (t) => current.gustTelegraph(t), + eventsBetween: (a,b) => current.eventsBetween(a, b), + setSheltersFromTrees() {}, + get seed() { return current.seed ?? 1; }, + get def() { return current.def ?? {}; }, + }; + const use = (w) => { current = w; }; + const siteDef = await loadSite('site_02_corner_block'); + const world = createWorld(scene, { wind: proxy, site: siteDef }); await world.dress(); // NOT optional: undressed, the carport trap does not exist - const anchors = world.anchors.map((a)=>{const pos={x:a.pos.x,y:a.pos.y,z:a.pos.z}; - return {id:a.id,type:a.type,pos,sway:()=>pos};}); const bed = world.gardenBed; const PLANS = { @@ -105,12 +128,17 @@ try { 'canonical q1+q2+q3+tr1, best steel $80 can buy': [['q1',RATED],['q2',SHACKLE],['q3',CARABINER],['tr1',RATED]], }; - log(`site_02_corner_block budget $${START_BUDGET} bed ${bed.w}x${bed.d} @ (${bed.x}, ${bed.z})`); + const vl = siteDef.wind?.venturi ?? []; + const vtxt = vl.length + ? vl.map((v)=>`throat (${v.x},${v.z}) gain ${v.gain} r${v.radius}`).join(' Β· ') : 'none'; + log(`site_02_corner_block budget $${START_BUDGET} bed ${bed.w}x${bed.d} @ (${bed.x}, ${bed.z}) venturi: ${vtxt}`); + // bare-bed refs are funnel-independent (no sail = exposure is the storm's own + // rain/hail timeline, wind never enters it); D confirmed 82.6 live on the buster log(`bare bed for reference: wildnight 35.7 (DEAD) icenight 0.0 (DEAD) buster 82.6 (FULL)\n`); for (const [label, plan] of Object.entries(PLANS)) { log(`\n## ${label}`); for (const storm of ['storm_02_wildnight','storm_02b_icenight','storm_03b_earlybuster']) { - const r = await fly(anchors, bed, plan, storm); + const r = await fly(world, plan, storm, siteDef, use); if (r.err) { log(` ${storm.padEnd(22)} SHOP REFUSED: ${r.err}`); continue; } const bare = { storm_02_wildnight:35.7, storm_02b_icenight:0, storm_03b_earlybuster:82.6 }[storm]; log(` ${storm.padEnd(22)} spent $${String(r.spent).padStart(3)} hp ${String(r.hp).padStart(5)} ${state(r.hp).padEnd(8)}` diff --git a/tools/garden_bench/sweep2.html b/tools/garden_bench/sweep2.html index 950cee8..9cd9a43 100644 --- a/tools/garden_bench/sweep2.html +++ b/tools/garden_bench/sweep2.html @@ -15,7 +15,7 @@ import * as THREE from '../../web/world/vendor/three.module.js'; import { RiggingSession } from '../../web/world/js/rigging.js'; import { SailRig } from '../../web/world/js/sail.js'; import { RainShadow } from '../../web/world/js/skyfx.js'; -import { createWind, loadStorm } from '../../web/world/js/weather.js'; +import { loadStorm, windForSite } from '../../web/world/js/weather.js'; import { createWorld, loadSite } from '../../web/world/js/world.js'; import { FIXED_DT, HARDWARE } from '../../web/world/js/contracts.js'; const [CARABINER, SHACKLE, RATED] = HARDWARE; @@ -41,20 +41,41 @@ try { const scene = new THREE.Scene(); const zero = { sample:(p,t,o)=>(o||new THREE.Vector3()).set(0,0,0), speedAt:()=>0, rainAt:()=>0, rainMmPerHour:()=>0, gustTelegraph:()=>null, setSheltersFromTrees(){}, eventsBetween:()=>[] }; - const world = createWorld(scene, { wind: zero, site: await loadSite(siteId) }); + const siteDef = await loadSite(siteId); + // re-pointable wind, main.js-router style: the anchors are world.anchors + // THEMSELVES, so tree sway is live β€” the old static remap froze the gum + // tree and under-read every tr1 corner (correction, 2026-07-18). + let current = zero; + const proxy = { + sample: (p,t,o) => current.sample(p, t, o || new THREE.Vector3()), + speedAt: (p,t) => current.speedAt(p, t), + rainAt: (t) => current.rainAt(t), + rainMmPerHour: (t) => (current.rainMmPerHour ? current.rainMmPerHour(t) : 0), + gustTelegraph: (t) => current.gustTelegraph(t), + eventsBetween: (a,b) => current.eventsBetween(a, b), + setSheltersFromTrees() {}, + get seed() { return current.seed ?? 1; }, + get def() { return current.def ?? {}; }, + }; + const world = createWorld(scene, { wind: proxy, site: siteDef }); if (world.dress) { try { await world.dress(); } catch {} } - const anchors = world.anchors.map((a) => { const pos={x:a.pos.x,y:a.pos.y,z:a.pos.z}; - return { id:a.id, type:a.type, pos, sway:()=>pos, ratingHint:a.ratingHint }; }); + const anchors = world.anchors; const bed = world.gardenBed; - log(`\n\n######## ${siteId} bed ${bed.w}x${bed.d} @ (${bed.x}, ${bed.z})`); + const vl = siteDef.wind?.venturi ?? []; + const vtxt = vl.length ? vl.map((v)=>`throat (${v.x},${v.z}) gain ${v.gain} r${v.radius}`).join(' Β· ') : 'none'; + log(`\n\n######## ${siteId} bed ${bed.w}x${bed.d} @ (${bed.x}, ${bed.z}) venturi: ${vtxt}`); log(` anchors and what the BEST steel ($30 RATED, 6.5 kN) is actually worth on them:`); for (const a of anchors) { log(` ${a.id.padEnd(5)} ${String(a.type).padEnd(8)} hint ${(a.ratingHint ?? 1).toFixed(2)} -> ${((a.ratingHint ?? 1) * 6.5).toFixed(2)} kN`); } - // prep's own wind: main.js hands the rig the calm day outside a storm - const calmWind = createWind(await loadStorm('storm_01_gentle')); - calmWind.setSheltersFromTrees(anchors.filter((a) => a.type === 'tree')); + // prep's own wind: main.js hands the rig the calm day outside a storm. + // windForSite so the SITE's venturi flies too β€” before 2026-07-18 this + // sweep settled site_02's quads with the funnel OFF (the third harness to + // make B's Sprint-11 mistake; see windForSite's comment). The funnel does + // not switch off for prep. + const calmWind = windForSite(await loadStorm('storm_01_gentle'), siteDef, anchors); + current = calmWind; // the tree-sway closures fly the same calm day const rows = []; const n = anchors.length; diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index 6f076cf..2d8f291 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -16,7 +16,8 @@ import * as THREE from '../../vendor/three.module.js'; import { assert, fixedLoop } from '../testkit.js'; import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS } from '../contracts.js'; -import { loadStorm, createWind, forecastLines, forecastFor, leadFor } from '../weather.js'; +import { loadStorm, createWind, windForSite, forecastLines, forecastFor, leadFor } from '../weather.js'; +import { loadSite } from '../world.js'; import { createDebris } from '../debris.js'; import { createSkyFx, RainShadow } from '../skyfx.js'; import { SailRig, HARDWARE } from '../sail.js'; @@ -181,6 +182,38 @@ export default async function run(t) { assert(gale.leafCount === 0, 'clear() left leaves flying into the aftermath card'); }); + // SPRINT13, the third time this repo learned it: the venturi lives in the + // SITE json, not the storm def. B's site_audit flew site_02 funnel-OFF in + // Sprint 11 (their sweep.selftest tells it); C's garden_bench did it again + // in Sprint 13 β€” `def.wind.venturi` off a storm def LOOKS right and never + // fires, and the funnel-off bench called the $80 line 91.5 FULL on a night + // the real game scores it 39.8 TATTERED (D's live replay). windForSite is + // the shared builder that makes the mistake unmakeable; this assert is + // B's strictly-raises pin lifted onto it, with the REAL shipped funnel: + // drop the setVenturi line and the two winds collapse to equal reads. + const site02 = await loadSite('site_02_corner_block'); // awaited here: Suite.test() is sync + t.test('windForSite flies the SITE venturi β€” the shipped funnel strictly raises the throat wind', () => { + const def = storms.storm_02_wildnight; + const site = site02; + const vl = site.wind?.venturi ?? []; + assert(vl.length > 0, "site_02 lost its venturi β€” this test (and the yard's weather) proves nothing"); + const bare = createWind(def); // storm def alone β€” the trap itself + const funnelled = windForSite(def, site); + const throat = new THREE.Vector3(vl[0].x, 1.5, vl[0].z); + const a = new THREE.Vector3(), b = new THREE.Vector3(); + let bareMean = 0, funMean = 0, n = 0; + for (let time = 20; time <= 80; time += 0.5) { + bare.sample(throat, time, a); + funnelled.sample(throat, time, b); + bareMean += Math.hypot(a.x, a.z); funMean += Math.hypot(b.x, b.z); n++; + } + bareMean /= n; funMean /= n; + assert(bareMean > 3, `the storm never blew at the throat (${bareMean.toFixed(1)} m/s) β€” vacuous`); + assert(funMean > bareMean * 1.1, + `throat wind ${funMean.toFixed(2)} vs bare ${bareMean.toFixed(2)} m/s β€” the site's venturi is not ` + + 'reaching windForSite\'s wind (it must call setVenturi the way main.js does at every site load)'); + }); + // D's aftermath find (S13, lane/d): the hail InstancedMesh at count:0 with // frustum culling and depthWrite both off still draws instance 0's identity // matrix β€” a ~5 cm always-on-top white sliver at the origin, the QA's "ghost diff --git a/web/world/js/weather.js b/web/world/js/weather.js index ed94749..a635f47 100644 --- a/web/world/js/weather.js +++ b/web/world/js/weather.js @@ -112,6 +112,33 @@ export async function loadStorm(name, dir = STORM_DIR) { return def; } +/** + * The wind a YARD actually flies β€” storm def + the SITE's local effects, + * wired exactly as main.js does at every site load (setVenturi from + * siteDef.wind.venturi, tree shelters from the anchors). SPRINT13: + * + * THREE harnesses have now measured site_02 with the funnel switched OFF by + * building wind from the storm def alone β€” B's site_audit (Sprint 11, their + * sweep.selftest tells the story), C's garden_bench (Sprint 13, where it + * flipped the $80-line headline: 91.5 FULL funnel-off vs 39.8 TATTERED in + * D's live replay), and probe4, which never attempted it. The venturi lives + * in the SITE json, not the storm, so `def.wind.venturi` off a storm def + * LOOKS right and never fires. Any tool measuring a yard builds its wind + * HERE, or it is measuring a yard that doesn't ship. The strictly-raises + * assert in c.test.js goes red if the setVenturi line is ever dropped. + * + * @param {object} stormDef parsed storm JSON + * @param {object} [siteDef] parsed site JSON (loadSite) β€” its wind.venturi list + * @param {Array} [anchors] world anchors; trees become wind shelters + * @param {object} [opts] forwarded to createWind ({seed}) + */ +export function windForSite(stormDef, siteDef, anchors = [], opts = {}) { + const wind = createWind(stormDef, opts); + wind.setVenturi(siteDef?.wind?.venturi ?? []); + wind.setSheltersFromTrees(anchors.filter((a) => a.type === 'tree')); + return wind; +} + /** * @param {object} def parsed storm JSON * @param {object} [opts] {seed} β€” same seed + same def = same storm, every run