HardYards/tools/garden_bench/bench.js
type-two a495fcdba6 Lane C S13 gate 1: the shadow model is right — the LINE was wrong; garden_bench proves it
The gate-1 diagnosis the sprint chartered, inverted by measurement: the shadow
geometry is exact (probe2: raycast and rasteriser agree 1.0000/1.0000), the
canonical measuring lines just don't cover the bed against vertical hail, and
site_02's tr1+q1+q3+q4 at exactly $80 already separates 91.5 FULL vs 35.7 bare
(sep 55.8, 0/4 lost) with no model change. Full findings + separation-target
proposal in THREADS.

Re-verified after the authoring session died un-pushed: all headline numbers
reproduce; the bench drives RiggingSession.commit(rig) -> rig.attach(), not the
bypassed-attach seam D's landmine names; balance.test's canonical shade-cloth
peaks reproduce per-anchor to the last digit. One fix over the original staging:
probe3/probe4 labelled per-corner peaks in plan order, but rig.corners is ring
order — labels now come from corners[k].anchorId (values were always true; the
$80 line's two RATED corners read swapped). Selftest 335/0/0.

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

242 lines
10 KiB
JavaScript
Raw 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 { 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;
}