HardYards/tools/garden_bench/bench.js
type-two 45bdc2d079 Lane C S13 correction: the bench flew site_02 funnel-OFF and tree-FROZEN; wild-night headline dies
D's live replay caught landmine 1: bench.js read def.wind.venturi off the
STORM def where a venturi never lives (it is SITE data, main.js:453), and the
probes never attempted it — every site_02 bench number was funnel-off, the
third harness to make B's Sprint-11 mistake. Verifying the fix exposed
landmine 2: the tools remapped anchors to a static sway:()=>pos, freezing the
gum tree whose sway is the dynamic load a tr1 rig eats (ablation on the $80
line's q4: 1.02 funnel-off/static, 1.03 funnel-on/static, 1.24 funnel-on/real
anchors). Backyard numbers matched D's UI to the decimal precisely because
p1..p4 are posts — both landmines are no-ops there.

Fix: windForSite(stormDef, siteDef, anchors) in weather.js is the one shared
wind builder (venturi + shelters, exactly main.js's wiring); all garden_bench
tools build wind through it, fly world.anchors themselves via a re-pointable
wind proxy, print the venturi in their headers, and drop the phantom 12 s
settle (commit->attach->storm is one keypress, and the settle skewed storm
sampling via SailRig's internal clock). c.test.js pins the helper with B's
strictly-raises assert against the real shipped funnel (mutation-checked).

Corrected numbers: $80 line wildnight 91.9 with q4 at 1.24-vs-1.2 rating - a
knife edge the real game falls off (D UI: q3+q4 break, 39.8 TATTERED; D's UI
is canonical there, and a corner over its rating is not a safe line either
way). Buster verdict stands (95.2 bench / 97.9 D live). Icenight 9 DEAD 2/4.
Bench still under-reads UI peaks ~5-15% on site_02 - residual in the pool,
stated in THREADS. Separation-target site_02 example withdrawn to A.

Selftest 341/0/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 13:08:21 +10:00

268 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;
}