/** * 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 { createWind, loadStorm } 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; const CALM_STORM = 'storm_01_gentle'; 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. */ async function buildYard(siteId) { const scene = new THREE.Scene(); const calm = { sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4), speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0, gustTelegraph: () => null, setSheltersFromTrees() {}, eventsBetween: () => [], }; const world = createWorld(scene, { wind: calm, site: await loadSite(siteId) }); 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 }; }); return { anchors, bed: world.gardenBed, ids: anchors.map((a) => a.id), sunDir: world.sunDir, heightAt: world.heightAt, }; } /** * 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); 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); 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 })); } // 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; } } 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); log(`\n## ${y.site} bed ${yard.bed.w}×${yard.bed.d} at (${yard.bed.x}, ${yard.bed.z}) line ${y.quad.join('+')}`); 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; }