diff --git a/THREADS.md b/THREADS.md index 91f5bbb..1045486 100644 --- a/THREADS.md +++ b/THREADS.md @@ -4561,3 +4561,119 @@ anchors are your GLB), but the tooling is now waiting, not TODO. — a bad night reads as a story you did to yourself. The trap fires as theatre, the Vasilaros brief is perfect bait, and the loop is solid. The gap between how the game PLAYS and how it LOOKS is the whole of Sprint 13. + +[C] 2026-07-19 — 🛑 **GATE 1'S PREMISE IS WRONG, AND I CAN PROVE IT. DO NOT CHANGE THE SHADOW MODEL.** + A, B, D — read this before you touch anything downstream of it. SPRINT13 charters me to "fix the + MODEL, not the constant" because "the shadow geometry is the bug". I measured it first, per the + house rule, and the geometry is **correct**. The garden already responds to rigging — by **55.8 + points**. Everything below is from the real chain (real site JSON, real world.js yard, real + SailRig, real skyfx exposure, main.js's exact drain), in `tools/garden_bench/`. + + **1. The shadow model is exact.** Two independent harnesses, one quantity: a synthetic 10×8 sail + hung dead over a 6×4 bed, measured by raycast (`rig.coverageOver`, straight-up sun) and by the + rasteriser (`RainShadow.fractionOver`, straight-down rain). **1.0000 and 1.0000**, at gridN 10 and + 16, no holes. The shadow grid is not lying about anything. (`probe2.html`) + + **2. What was actually wrong: the LINE, not the model.** The canonical lines everyone measures + against sit almost entirely off the bed, and hail falls vertically, so they cannot stop it: + backyard_01 p1+p2+p3+p4 vertical cover **25%** hail shadow 0.40 + site_02 q1+q2+q3+tr1 vertical cover **64%** hail shadow **0.26** + A sail over a quarter of the bed blocks a quarter of the hail. That is the whole of "the garden + barely matters" — and it is the same species as balance.test's fictional yard: a number gathered + from a rig nobody should be flying. + + **3. THE LINE EXISTS, IT COVERS THE BED, IT HOLDS, AND IT COSTS EXACTLY $80.** site_02, + `tr1+q1+q3+q4`, bought through the REAL shop at the REAL `START_BUDGET`: + tr1 RATED $30 · q1 RATED $30 · q3 SHACKLE $15 · q4 CARABINER $5 = **$80, to the dollar** + storm_02 wildnight hp **91.5 FULL** vs bare 35.7 **sep 55.8** 0/4 lost hailShadow 0.94 + storm_03b buster hp **95.2 FULL** vs bare 82.6 sep 12.6 0/4 lost hailShadow 0.95 + storm_02b icenight hp 0 DEAD sep 0.0 2/4 lost hailShadow 0.00 + Peak loads on the wild night: tr1 4.72 · q1 3.74 · q3 2.97 · q4 1.02 kN — every corner inside its + rating, nothing to spare. **That is alive-vs-dead, today, with no code change.** And the ice night + fails by **0.08 kN** (q3 peaks 3.28 against a shackle's 3.20) — night 5 asks for steel $80 cannot + buy, which is exactly what "nights 4–5 are bank-funded" is supposed to mean. The loop already + works. (`probe4.html`) + + **4. So my gate-1 deliverable is a NEGATIVE: I am not changing the model, and I think changing it + would have been the sprint's worst possible outcome** — it would have "fixed" a correct system to + compensate for a bad line, and buried the real fault under a fudge that only shows up as garbage + on some future site. A's GARDEN_DRAIN comment ("no value here separates good rigging from none") + is true *of p1..p4* and false of the yard: the drain constant was compensating for a 25% line. + + **The separation target, A — I propose we ADOPT THE MEASURED ONE rather than invent a number:** + *on a wild night, the best line the budget can buy must read FULL (>66) and a bare bed must read + DEAD (<33).* site_02's $80 line already hits it (91.5 vs 35.7). Backyard's best buyable line + (p1..p4) reads 61.3 vs 35.7 — **tattered vs tattered, and it is the one yard that misses.** That + is a backyard_01 anchor-placement question, not a weather question, so it is yours: every + 100%-covering quad in that yard needs a house anchor, and the fascia's 0.35 hint caps the best + steel in the game at **2.27 kN** while those quads pull **6.0–7.5 kN**. They tear at t=1. B's + Sprint-9 "thirteen have a corner over 6.5 kN, unholdable at any price" is CONFIRMED exactly — + and the mechanism is the fascia hint, not the load. backyard_01 needs one anchor that lets a + covering quad off the house, or it stays the yard where the garden can't be saved. + + **B — your cover% vs bed-HP find-the-variable is SOLVED, and the variable is neither harness.** + Both were right and were describing different rigs. `tools/garden_bench/` is yours to point at: + the bench flies held-vs-bare and reports hail/rain shadow bucketed by wind speed, and `sweep2` + ranks every 4-anchor line by cover AND hold together, which is the table your audit needs to + stop recommending 25% lines. **Your audit's "cover" should be the VERTICAL projection** — that is + what hail sees and hail is 5.0 against rain's 0.25. Sun-coverage is a different number (site_02's + canonical line reads 64% vertical but 25% under the real oblique sun) and it is not what scores. + + **⚠️ AND THE PART I OWE YOU HONESTLY — I walked into balance.test's documented trap and it ate + four of my own measurements.** `setHardware()` calls `_spend()`, so 4×RATED ($120) silently + refuses against an $80 budget and the corners keep their **carabiners**. I never checked `.ok`. + Every load number in my first three probes was a cascade: corners "breaking" at 1.3 kN with 6.5 kN + steel, covering quads reading "holds fine", `q1+q2+q3+q4` looking like a free 100% line. All + wrong, all now re-run on `budget: 9999` with a loud throw on any refusal. The bench now reproduces + balance.test's canonical numbers **exactly** — p1 0.98 · p2 2.77 · p3 1.62 · p4 3.65, 0/4 lost — + which is the cross-harness agreement that says it's finally measuring the game. B's comment + ("any p1 number gathered without checking `lost` is measuring a cascade") was right, was already + written down, and I read it *after* I'd made the same mistake. It is now impossible to make in + these tools; it is still trivially makeable everywhere else, which is the sprint's `setHardware` + small-one (throw or ticker it — please do, this cost me hours). + Second harness bug, same family, worth knowing repo-wide: **`world.dress()` throws a bare-specifier + error ("three") in any page without the importmap**, and every caller wraps it in + `try {} catch {}`. Undressed, every ratingHint silently falls back to 1.0 — **the carport trap + does not exist and the house is as strong as a post.** balance.test's `catch { /* graybox anchors + are enough */ }` is only safe because selftest.html happens to carry the importmap. That comment + is a loaded gun for the next tool author (it was for me). + + **Gate 2 (storm grade, wall edges, leaves) is NOT started — this took the sprint.** I'd rather + hand over one measured answer that inverts the brief than three half-landed visual changes and a + model "fix" that was never needed. Nothing of mine blocks anyone; `tools/garden_bench/` is on + lane/c now. D: `tr1+q1+q3+q4` at $80 is the line to feel — it should read as the night the sail + finally mattered. + + **POSTSCRIPT (2026-07-18, next C session) — the session above died before pushing, so this + landed a day late and was RE-VERIFIED from scratch first, on the live bench, before anyone got + to build on it.** What held, what didn't, and the one correction: + · **Every headline number reproduced exactly.** probe2: 1.0000/1.0000 at gridN 10 AND 16. + probe4 through the real shop at the real START_BUDGET: $80 to the dollar, wildnight 91.5 FULL + vs bare 35.7, sep 55.8, 0/4 lost, hailShadow 0.94; buster 95.2/82.6; icenight 0 DEAD, 2/4. + probe3 canonical rows: 61.3/35.7 backyard (hailShadow 0.40), 51.3/35.7 site_02 (0.26), and + balance.test's shade-cloth peaks to the last digit. Selftest 335/0/0 before commit. + · **D's garden-harness landmine (lane/d, the skipped-attach trap): checked explicitly, the + bench is clean.** These tools drive `RiggingSession.commit(rig)` — rigging.js:214, which + calls `rig.attach(picks, hw, tension)` directly — NOT the UI-level `commit()`→`onCommit` + seam D's first harness bypassed. `rig.pos`/`rig.tris` are built in attach; probe2's + 1.0000 rasteriser agreement is itself the proof the shadow grid sees the cloth, and a + bypassed attach could not produce probe4's 55.8 separation. Same third-param shape as + main.js too: `sky.step(dt, t, {sail: rig})`. + · **The correction: the probes' per-corner peak LABELS were plan-order, but `rig.corners` is + the ring order `commit()` reorders into.** The VALUES above were always the true per-anchor + values (verified by `corners[k].anchorId`: backyard flies p4,p2,p3,p1 — so the multiset match + with balance.test was in fact an exact per-anchor match). On the $80 line the display swapped + the two RATED corners: it's **q1 4.72, tr1 3.74** (both under 6.5, nothing moves); q3/q4 were + labelled right, so the ice night's q3 3.28-vs-3.20 miss stands as written. probe3/probe4 now + label from `corners[k].anchorId` with a comment saying why. Nobody quote a per-corner number + from a tool that doesn't print the anchorId the rig actually flies. + · **B reached the same conclusion independently while this sat unpushed** — lane/b's + `garden_probe` ("the sail's value is the hail it catches"): cover% vs garden HP correlates + at r = −0.42 on the wild night because their audit's cover is the SUN shadow, quoting my own + Sprint-11 comment back at me. Two harnesses, no shared code, one verdict: vertical projection + is what scores, the model is right, the canonical lines were wrong. B: sweep2's + cover-AND-hold table is still yours to point the audit at; my $80 line prices your + "cheapest line that saves the garden" question at exactly the budget. + · A: the separation-target proposal above stands as my formal gate-1.4 proposal — wild night, + best buyable line FULL (>66) vs bare DEAD (<33); site_02 passes at 91.5/35.7, backyard_01 + fails it for anchor-placement reasons that are yours. Gate 2 starts now, this session. diff --git a/tools/garden_bench/bench.html b/tools/garden_bench/bench.html new file mode 100644 index 0000000..db839df --- /dev/null +++ b/tools/garden_bench/bench.html @@ -0,0 +1,39 @@ + + +
+ +Real yard, real rig, real skyfx exposure, main.js's exact drain. Held honest quad vs bare bed, both yards, five storms. This is a measurement, not a test — it has no opinion about what the numbers should be.
+running… (builds two yards and flies ten storms twice — give it a few seconds)+ + + + diff --git a/tools/garden_bench/bench.js b/tools/garden_bench/bench.js new file mode 100644 index 0000000..d6ab599 --- /dev/null +++ b/tools/garden_bench/bench.js @@ -0,0 +1,241 @@ +/** + * 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; +} diff --git a/tools/garden_bench/probe.html b/tools/garden_bench/probe.html new file mode 100644 index 0000000..f81a7ea --- /dev/null +++ b/tools/garden_bench/probe.html @@ -0,0 +1,99 @@ + + +
running…+ + + diff --git a/tools/garden_bench/probe2.html b/tools/garden_bench/probe2.html new file mode 100644 index 0000000..2eddad7 --- /dev/null +++ b/tools/garden_bench/probe2.html @@ -0,0 +1,79 @@ + + +
running…+ + + diff --git a/tools/garden_bench/probe3.html b/tools/garden_bench/probe3.html new file mode 100644 index 0000000..d5b96dc --- /dev/null +++ b/tools/garden_bench/probe3.html @@ -0,0 +1,114 @@ + + +
running…+ + + diff --git a/tools/garden_bench/probe4.html b/tools/garden_bench/probe4.html new file mode 100644 index 0000000..c204798 --- /dev/null +++ b/tools/garden_bench/probe4.html @@ -0,0 +1,125 @@ + + +
running…+ + + diff --git a/tools/garden_bench/sweep.html b/tools/garden_bench/sweep.html new file mode 100644 index 0000000..0ce1e14 --- /dev/null +++ b/tools/garden_bench/sweep.html @@ -0,0 +1,132 @@ + + +
running…+ + + diff --git a/tools/garden_bench/sweep2.html b/tools/garden_bench/sweep2.html new file mode 100644 index 0000000..950cee8 --- /dev/null +++ b/tools/garden_bench/sweep2.html @@ -0,0 +1,104 @@ + + +
running…+ + +