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>
242 lines
10 KiB
JavaScript
242 lines
10 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|