/** * garden_bench — WHERE DOES THE PROTECTION ACTUALLY GO? [Lane C, SPRINT13 gate 1] * * The QA pass found one variable sighted three times: the sim's garden outcome * barely responds to whether a sail is up (sail DEAD at t=3 on the funnelled * buster still finished 83%). A's GARDEN_DRAIN comment names the suspect and * says the lever is the shadow GEOMETRY, not the constant. This bench is the * measurement that has to come before any fix. * * It drives the REAL chain — real site JSON, real world.js yard, real SailRig, * real skyfx exposure helpers, main.js's exact drain arithmetic — because a * bench that reimplements the drain measures a copy and proves nothing (the * lesson balance.test.js paid for twice: the fictional yard, and the missing * camera). * * For each yard × storm it flies two conditions and reports where they differ: * HELD — an honest bed-covering quad on RATED steel, re-repaired every step * so a corner failure can never contaminate a GEOMETRY measurement * BARE — no sail at all, the floor * ...and buckets rain/hail shadow-over-bed by wind speed, which is the actual * question: protection is not one number, it decays as the wind builds. * * Deliberately a browser tool: the scoring chain needs `document`. Run it at * tools/garden_bench/bench.html; it prints a table and dumps JSON to console. */ 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 { 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'; const [CARABINER, SHACKLE, RATED] = HARDWARE; // main.js's exact numbers. Duplicated ONLY so a divergence names itself; the // bench asserts they still match main.js at the bottom of the report. const GARDEN_DRAIN = 0.9; const W_HAIL = 5.0; const W_RAIN = 0.25; export const STORMS = [ 'storm_01_gentle', 'storm_02_wildnight', 'storm_03_southerly', 'storm_03b_earlybuster', 'storm_02b_icenight', ]; /** * The honest bed-covering line in each yard — NOT the carport trap, NOT a rig * that misses. backyard's is balance.test's COVER_QUAD (C's sweep, the only * buyable one). site_02's is the site JSON's own _design note: the honest three * posts + the gum tree, 58% cover at 32.6 m². */ export const YARDS = [ { site: 'backyard_01', quad: ['p1', 'p2', 'p3', 'p4'] }, { site: 'site_02_corner_block', quad: ['q1', 'q2', 'q3', 'tr1'] }, ]; /** * 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(); let current = { sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4), speedAt: () => 4, 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 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; return { anchors, bed: world.gardenBed, ids: anchors.map((a) => a.id), sunDir: world.sunDir, heightAt: world.heightAt, siteDef, use: (w) => { current = w; }, }; } /** * Fly one condition and record the geometry as a time series. * @param {'held'|'bare'} mode */ async function fly(yard, stormName, mode) { const def = await loadStorm(stormName); // 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') { const s = new RiggingSession({ anchors: yard.anchors, budget: 9999 }); for (const id of yard.quad) { const r = s.rig(id); if (!r.ok) return { skipped: `cannot rig ${id}: ${r.reason ?? 'refused'}` }; } // RATED on every corner: this is a geometry bench, so buy the strongest // steel in the game and ignore the $80 budget on purpose. Money is B's // question; this one is "where does the shadow land". for (const id of yard.quad) { const r = s.setHardware(id, RATED); if (!r.ok) throw new Error(`setHardware ${id} refused: ${r.reason} — flying hardware it did not buy`); } s.setTension(1.0); rig = s.commit(new SailRig({ anchors: yard.anchors, gridN: 10 })); } // 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); const sky = createSkyFx({ wind, night: true, camera }); const bed = yard.bed; const probe = new THREE.Vector3(bed.x, 1.7, bed.z); let hp = 100, byHail = 0, byRain = 0, repairs = 0; const rows = []; const steps = Math.round(def.duration / FIXED_DT); for (let i = 0; i < steps; i++) { const t = i * FIXED_DT; if (rig) { rig.step(FIXED_DT, wind, t); // HELD means held. A corner that lets go turns this into a hardware // measurement instead of a geometry one, so put it straight back. for (let k = 0; k < rig.corners.length; k++) { if (rig.corners[k].broken) { rig.repairCorner(k, RATED); repairs++; } } } sky.step(FIXED_DT, t, rig ? { sail: rig } : {}); const hailExp = sky.gardenHailExposure(bed, t); const rainExp = sky.gardenExposure(bed, t); // main.js's exact drain const dHail = W_HAIL * hailExp * GARDEN_DRAIN * FIXED_DT; const dRain = W_RAIN * rainExp * GARDEN_DRAIN * FIXED_DT; const total = Math.min(hp, dHail + dRain); if (total > 0) { const scale = total / (dHail + dRain); byHail += dHail * scale; byRain += dRain * scale; hp -= total; } // sample the geometry a few times a second — the grids only rebuild at 10 Hz if (i % 6 === 0) { rows.push({ t, speed: wind.speedAt(probe, t), rainShadow: sky.rainShadowOver(bed), hailShadow: sky.hailShadowOver(bed), rainExp, hailExp, hail: sky.hailAmount, }); } } sky.dispose?.(); return { hp: Math.round(hp * 10) / 10, byHail: Math.round(byHail * 10) / 10, byRain: Math.round(byRain * 10) / 10, repairs, rows, // the SUN coverage — reported for context only. It is emphatically not the // rain/hail geometry, and the gap between them is half of what this bench // exists to show. cover: rig ? rig.coverageOver(bed, yard.sunDir, yard.heightAt) : 0, }; } /** Bucket a time series by wind speed — protection is not one number. */ function byWind(rows) { const buckets = [[0, 10], [10, 15], [15, 20], [20, 25], [25, 99]]; return buckets.map(([lo, hi]) => { const r = rows.filter((x) => x.speed >= lo && x.speed < hi); if (!r.length) return { band: `${lo}-${hi}`, n: 0 }; const mean = (f) => r.reduce((a, b) => a + f(b), 0) / r.length; return { band: `${lo}-${hi}`, n: r.length, kmh: `${Math.round(lo * 3.6)}-${Math.round(hi * 3.6)}`, rainShadow: mean((x) => x.rainShadow), hailShadow: mean((x) => x.hailShadow), }; }).filter((b) => b.n > 0); } const f2 = (v) => (v == null ? ' — ' : v.toFixed(2).padStart(6)); const f1 = (v) => (v == null ? ' — ' : v.toFixed(1).padStart(5)); export async function runBench(log) { const out = []; log('# GARDEN BENCH — where does the protection actually go?'); log('# HELD = honest bed-covering quad, RATED steel, re-repaired every step (geometry only)'); log('# BARE = no sail. Drain is main.js exact: hail×5.0 + rain×0.25, ×0.9.\n'); for (const y of YARDS) { const yard = await buildYard(y.site); // 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'); if (held.skipped) { log(` ${storm}: SKIPPED — ${held.skipped}`); continue; } const bare = await fly({ ...yard, quad: y.quad }, storm, 'bare'); const sep = bare.hp === 0 && held.hp === 0 ? 0 : held.hp - bare.hp; out.push({ site: y.site, storm, held, bare, sep }); log(`\n ### ${storm} cover ${(held.cover * 100).toFixed(0)}% repairs ${held.repairs}`); log(` GARDEN HP held ${f1(held.hp)} bare ${f1(bare.hp)} SEPARATION ${f1(sep)}`); log(` held damage: hail ${f1(held.byHail)} rain ${f1(held.byRain)}`); log(` bare damage: hail ${f1(bare.byHail)} rain ${f1(bare.byRain)}`); log(' wind band km/h rainShadow(held) hailShadow(held)'); for (const b of byWind(held.rows)) { log(` ${b.band.padStart(6)} m/s ${b.kmh.padStart(8)} ${f2(b.rainShadow)} ${f2(b.hailShadow)}`); } } } log('\n\n# THE HEADLINE'); log('# site storm held bare sep'); for (const r of out) { log(`# ${r.site.padEnd(26)} ${r.storm.padEnd(22)} ${f1(r.held.hp)} ${f1(r.bare.hp)} ${f1(r.sep)}`); } console.log('GARDEN_BENCH_JSON', JSON.stringify(out.map((r) => ({ site: r.site, storm: r.storm, held: r.held.hp, bare: r.bare.hp, sep: r.sep, heldHail: r.held.byHail, heldRain: r.held.byRain, bareHail: r.bare.byHail, bareRain: r.bare.byRain, cover: r.held.cover, })))); return out; }