From a495fcdba600270cf7b7b0ebbe523c9c0fb16dee Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 11:00:09 +1000 Subject: [PATCH 1/6] =?UTF-8?q?Lane=20C=20S13=20gate=201:=20the=20shadow?= =?UTF-8?q?=20model=20is=20right=20=E2=80=94=20the=20LINE=20was=20wrong;?= =?UTF-8?q?=20garden=5Fbench=20proves=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- THREADS.md | 116 ++++++++++++++++ tools/garden_bench/bench.html | 39 ++++++ tools/garden_bench/bench.js | 241 +++++++++++++++++++++++++++++++++ tools/garden_bench/probe.html | 99 ++++++++++++++ tools/garden_bench/probe2.html | 79 +++++++++++ tools/garden_bench/probe3.html | 114 ++++++++++++++++ tools/garden_bench/probe4.html | 125 +++++++++++++++++ tools/garden_bench/sweep.html | 132 ++++++++++++++++++ tools/garden_bench/sweep2.html | 104 ++++++++++++++ 9 files changed, 1049 insertions(+) create mode 100644 tools/garden_bench/bench.html create mode 100644 tools/garden_bench/bench.js create mode 100644 tools/garden_bench/probe.html create mode 100644 tools/garden_bench/probe2.html create mode 100644 tools/garden_bench/probe3.html create mode 100644 tools/garden_bench/probe4.html create mode 100644 tools/garden_bench/sweep.html create mode 100644 tools/garden_bench/sweep2.html 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 @@ + + + + + garden bench β€” where does the protection go? + + + +

GARDEN BENCH β€” Lane C, Sprint 13 gate 1

+

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 @@ + + +shadow probe + + +
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 @@ + + +shadow probe 2 β€” synthetic + + +
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 @@ + + +probe3 β€” does the covering line hold? + + +
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 @@ + + +probe4 β€” the $80 line that saves the garden + + +
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 @@ + + +quad sweep β€” can ANY line cover the bed? + + +
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 @@ + + +sweep2 β€” hold AND cover? + + +
running…
+ + + From 8a3dc323bc1a27f8c0c258c3611397d5564ab7e1 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 11:11:39 +1000 Subject: [PATCH 2/6] =?UTF-8?q?Lane=20C=20S13:=20sky.setMute(on)=20?= =?UTF-8?q?=E2=80=94=20the=20tap=20A's=20M=20key=20was=20waiting=20on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One flag plus a gain write, but the flag is the point: a mute pressed on the splash screen, before the first gesture builds the audio graph, is remembered and honoured by unlock(). main.js already feature-detects sky.setMute, so the M key lights itself up on this landing. audio.masterGain getter added so the assert reads the real bus, not the flag back. Selftest 336/0/0 (was 335, +1 test). Mutation-checked both ways: a no-op setMute and an unlock that forgets a pre-unlock mute both go red. Co-Authored-By: Claude Fable 5 --- web/world/js/skyfx.js | 26 +++++++++++++++++++++++++- web/world/js/tests/c.test.js | 21 +++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/web/world/js/skyfx.js b/web/world/js/skyfx.js index cbbae45..3651155 100644 --- a/web/world/js/skyfx.js +++ b/web/world/js/skyfx.js @@ -342,8 +342,11 @@ function cloudTexture(size = 256, seed = 7) { // ---------------------------------------------------------------- audio // Synthesized layers. WebAudio won't start until a gesture (browser rule), so // everything is built lazily on unlock() and silently absent before it. +const MASTER_GAIN = 0.55; + function createAudio(seed = 1) { let ctx = null, master = null; + let muted = false; let windGain, windFilter, windHowl, howlGain; let rainGain, rainFilter; let gustGain, gustFilter; @@ -380,6 +383,15 @@ function createAudio(seed = 1) { /** 'running' | 'suspended' | 'closed' | 'none'. `ready` only means the graph * got built β€” a suspended context is still silent, so the HUD reports this. */ get state() { return ctx ? ctx.state : 'none'; }, + /** A's M key (gate 3.3). A flag, not just a gain write, so muting works + * before unlock() has built the graph and survives it. */ + setMute(on) { + muted = !!on; + if (master) master.gain.value = muted ? 0 : MASTER_GAIN; + }, + get muted() { return muted; }, + /** Real bus gain, for asserts β€” null until unlock() builds the graph. */ + get masterGain() { return master ? master.gain.value : null; }, /** Current layer gains β€” for the HUD and for asserting the bed tracks wind. */ levels() { if (!started) return null; @@ -399,7 +411,9 @@ function createAudio(seed = 1) { ctx = new AC(); if (ctx.state === 'suspended') ctx.resume(); master = ctx.createGain(); - master.gain.value = 0.55; + // honour a mute that landed BEFORE the first gesture built the graph β€” + // M on the splash screen must not be forgotten by the unlock + master.gain.value = muted ? 0 : MASTER_GAIN; master.connect(ctx.destination); noiseBuf = noiseBuffer(ctx); @@ -792,6 +806,16 @@ export function createSkyFx(o = {}) { /** Wire to the first click/keydown β€” browsers won't start audio otherwise. */ unlockAudio() { audio.unlock(); }, + /** + * A's M key (gate 3.3): mute the whole audio bus. main.js feature-detects + * this (`typeof sky.setMute === 'function'`) and only advertises M once it + * exists β€” so this method appearing is what lights the key up. Safe at any + * time: before unlock it sets a flag the unlock honours, after unlock it + * writes the master gain directly. + */ + setMute(on) { audio.setMute(on); }, + get muted() { return audio.muted; }, + /** * @param {number} dt * @param {number} t storm time diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index ee6a125..9afb5b7 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -172,6 +172,27 @@ export default async function run(t) { `skyfx left ${scene.children.length - before.children} object(s) in the scene`); }); + // SPRINT13 gate 3.3 β€” A's M key. main.js feature-detects sky.setMute and only + // advertises M once it exists, so this contract is what lights the key up. + // The pre-unlock case is the one that bites: M pressed on the splash screen, + // BEFORE the first gesture builds the audio graph, must survive the unlock. + t.test('M-mute: setMute silences the bus, and a pre-unlock mute survives unlock', () => { + const wind = createWind(storms.storm_02_wildnight); + const sky = createSkyFx({ wind }); // headless β€” the bus needs no scene + assert(typeof sky.setMute === 'function', "no setMute β€” A's M key has nothing to call"); + assert(sky.muted === false, 'a fresh sky must not start muted'); + sky.setMute(true); // before unlock β€” the splash case + assert(sky.muted === true, 'setMute(true) did not take'); + sky.unlockAudio(); + if (sky.audio.ready) { // no AudioContext = nothing to silence + assert(sky.audio.masterGain === 0, + `unlock forgot a pre-unlock mute: master at ${sky.audio.masterGain}`); + sky.setMute(false); + assert(sky.audio.masterGain > 0, 'unmute left the master gain at 0'); + } + sky.dispose(); + }); + // --- SPRINT2 Β§Lane C.3: rain has to stop at the cloth --- // Driven with a synthetic 4Γ—4 m panel rather than a whole cloth sim: the thing // under test is the projection, and a flat panel makes the right answer From a861151a4a0a8bd8d0fd3c5c7457ad62d5792712 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 11:21:53 +1000 Subject: [PATCH 3/6] =?UTF-8?q?Lane=20C=20S13=20gate=202.1:=20the=20storm?= =?UTF-8?q?=20grade=20=E2=80=94=20the=20night=20finally=20looks=20like=20a?= =?UTF-8?q?=20night?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA played the southerly at 65 km/h under a noon-blue sky with razor midday shadows. The arithmetic said why: sky, sun and hemi all multiplied the weather by the author's palette (darkness 0.5 on the southerly), so the dial could zero the weather's say β€” 0.69 storminess became a 0.35 nudge. The grade floors it (storminess x lerp(0.5, 1, darkness)): a full gale now goes at least half way to slate on any palette while the wild night keeps its authored black. Plus the two things nothing keyed at all: the sun disc lerps toward slate and sun.shadow.radius lerps 1->7 on storminess (PCFSoft, so it is real blur). Deterministic β€” pure in t, same curve the rain uses, exposed as sky.stormGrade above the render gate for headless reads. dispose() hands back intensity, colour and radius exactly. Verified by eye in dev_skyfx: southerly t=55, 18.5 m/s reads slate weather. Selftest 337/0/0 (+1 test). Mutation-checked: the old formula and a dropped radius line both go red. Co-Authored-By: Claude Fable 5 --- web/world/js/skyfx.js | 41 +++++++++++++++++++++++++++++++----- web/world/js/tests/c.test.js | 34 ++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/web/world/js/skyfx.js b/web/world/js/skyfx.js index 3651155..9675264 100644 --- a/web/world/js/skyfx.js +++ b/web/world/js/skyfx.js @@ -23,6 +23,7 @@ const STORM_SKY = new THREE.Color(0x2a2f3a); const NIGHT_SKY = new THREE.Color(0x11141c); const WHITE = new THREE.Color(0xffffff); const FLASH_COL = new THREE.Color(0xdfe8ff); +const SUN_STORM = new THREE.Color(0xc4cedb); // the noon disc gone slate (gate 2.1) // ------------------------------------------------------------ rain shadow /** @@ -709,6 +710,8 @@ export function createSkyFx(o = {}) { fogFar: scene && scene.fog ? scene.fog.far : 0, sun: sun ? sun.intensity : 0, hemi: hemi ? hemi.intensity : 0, + sunColor: sun ? sun.color.clone() : null, + sunRadius: sun && sun.shadow ? sun.shadow.radius : 1, }; const baseSky = (scene && scene.background && scene.background.isColor) ? scene.background.clone() : CALM_SKY.clone(); @@ -720,6 +723,7 @@ export function createSkyFx(o = {}) { } let flash = 0; // decaying lightning brightness + let gradeNow = 0; // the storm grade, gate 2.1 β€” pure in t, see step() let flashQueue = []; // {at, power} β€” double-strike let lastTelegraph = null; const camPos = new THREE.Vector3(); @@ -730,6 +734,9 @@ export function createSkyFx(o = {}) { get flash() { return flash; }, /** 0..1 hail intensity right now β€” for the HUD ("HAIL" banner) and asserts. */ get hailAmount() { return hailAmt; }, + /** 0..1 β€” the storm grade driving sky/sun/shadow-softness (gate 2.1). + * storminess with a floor under the author's darkness dial; pure in t. */ + get stormGrade() { return gradeNow; }, /** 0..1 β€” how far risen the change-front wall is (gate 3.4). Pure in t. */ get changeFront() { return frontLevel; }, /** Azimuth (radians, XZ) the front stands in β€” the quarter the new wind @@ -845,6 +852,16 @@ export function createSkyFx(o = {}) { const speed = Math.hypot(w.x, w.z); const intensity = wind.rainAt(t); const storminess = clamp01(Math.max(intensity, speed / 26)); + // SPRINT13 gate 2.1 β€” the STORM GRADE. The QA pass played the southerly at + // 65 km/h under a noon-blue sky, and the arithmetic says why: everything + // below multiplied the weather by the author's palette (`darkness`), so a + // darkness-0.5 storm at full blow only ever moved 0.35 toward slate β€” the + // author's dial could zero the weather's say. The grade gives the weather + // a floor: at full storminess even a darkness-0 sky goes half way to + // slate, while the wild night (0.94) keeps reading as the night it is. + // Same curve the rain already uses (storminess IS max(rain, wind)), pure + // in t, computed above the render gate so a test can read it headless. + gradeNow = storminess * lerp(0.5, 1, darkness); // --- events: lightning + the ticker --- for (const ev of wind.eventsBetween(t - dt, t)) { @@ -937,7 +954,7 @@ export function createSkyFx(o = {}) { if (!rendering) return; // --- sky --- - skyCol.copy(baseSky).lerp(target, storminess * darkness); + skyCol.copy(baseSky).lerp(target, gradeNow); if (flash > 0) skyCol.lerp(FLASH_COL, Math.min(0.85, flash)); if (scene) { if (scene.fog) { @@ -946,8 +963,18 @@ export function createSkyFx(o = {}) { scene.fog.far = lerp(160, 55, storminess); } } - if (sun) sun.intensity = lerp(original.sun, original.sun * 0.12, storminess * darkness) + flash * 2.2; - if (hemi) hemi.intensity = lerp(original.hemi, original.hemi * 0.3, storminess * darkness) + flash * 1.2; + if (sun) { + sun.intensity = lerp(original.sun, original.sun * 0.12, gradeNow) + flash * 2.2; + // The sun loses its noon edge two ways as the grade builds: the warm + // disc goes slate (colour), and the shadows go soft (radius β€” the + // renderer is PCFSoft, so radius is real blur, not a no-op). Softness + // keys on STORMINESS, not the graded value: overcast is overcast even + // under a light-palette storm, and razor-sharp noon shadows at 65 km/h + // were the QA sighting this whole block answers. + if (original.sunColor) sun.color.copy(original.sunColor).lerp(SUN_STORM, gradeNow * 0.85); + if (sun.shadow) sun.shadow.radius = lerp(original.sunRadius, 7, storminess); + } + if (hemi) hemi.intensity = lerp(original.hemi, original.hemi * 0.3, gradeNow) + flash * 1.2; dome.position.copy(camPos); dome.material.opacity = storminess * 0.85; @@ -961,7 +988,7 @@ export function createSkyFx(o = {}) { // and the cloud texture is baked near-white. The crush is what makes night // look like night. It stops at 0.78 on purpose β€” the yard has no lights in // it yet, and a storm you can't see isn't a storm, it's a black screen. - const nightAmt = storminess * darkness; + const nightAmt = gradeNow; dome.material.color.copy(WHITE) .lerp(target, nightAmt * 0.92) .multiplyScalar(1 - 0.78 * nightAmt); @@ -1039,7 +1066,11 @@ export function createSkyFx(o = {}) { original.fog.far = original.fogFar; } } - if (sun) sun.intensity = original.sun; + if (sun) { + sun.intensity = original.sun; + if (original.sunColor) sun.color.copy(original.sunColor); + if (sun.shadow) sun.shadow.radius = original.sunRadius; + } if (hemi) hemi.intensity = original.hemi; rain.dispose(); hail.dispose(); diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index 9afb5b7..0a1c262 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -193,6 +193,40 @@ export default async function run(t) { sky.dispose(); }); + // SPRINT13 gate 2.1 β€” the storm grade. The QA sighting: 65 km/h + driving + // rain under a noon-blue sky with razor midday shadows. Cause: sky and sun + // both multiplied the weather by the author's palette (`darkness`, 0.5 on + // the southerly), so the dial could zero the weather's say β€” and nothing + // touched shadow softness at all. Pins: the grade floors the darkness dial, + // the sun dims/goes slate/softens, and dispose hands all of it back. + t.test('storm grade: a mid-darkness gale dims the sun, softens shadows, restores on dispose', () => { + const scene = new THREE.Scene(); + scene.background = new THREE.Color(0x9fc4e8); + const camera = new THREE.PerspectiveCamera(); + const sun = new THREE.DirectionalLight(0xfff2dc, 2.0); + const before = { sun: sun.intensity, color: sun.color.getHex(), radius: sun.shadow.radius }; + // a darkness-0.5 storm blowing 18 m/s (65 km/h) in full rain β€” the QA scene + const gale = { + seed: 1, def: { sky: { darkness: 0.5 } }, + sample: (p, t2, o) => o.set(18, 0, 0), + speedAt: () => 18, rainAt: () => 1, rainMmPerHour: () => 30, + gustTelegraph: () => null, eventsBetween: () => [], setSheltersFromTrees() {}, + }; + const sky = createSkyFx({ scene, camera, wind: gale, sun }); + fixedLoop(10, FIXED_DT, (dt, time) => sky.step(dt, time, {})); + assert(sky.stormGrade > 0.7, + `grade ${sky.stormGrade} β€” the darkness dial still zeroes the weather (the old formula reads 0.50 here)`); + assert(sun.intensity < before.sun * 0.5, + `sun at ${sun.intensity.toFixed(2)} of ${before.sun} β€” still noon at 65 km/h`); + assert(sun.shadow.radius > 4, + `shadow radius ${sun.shadow.radius} β€” razor-sharp midday shadows in a gale`); + assert(sun.color.getHex() !== before.color, 'the sun disc never lost its noon warmth'); + sky.dispose(); + assert(sun.intensity === before.sun && sun.color.getHex() === before.color + && sun.shadow.radius === before.radius, + 'dispose did not hand the sun back exactly (intensity / colour / shadow radius)'); + }); + // --- SPRINT2 Β§Lane C.3: rain has to stop at the cloth --- // Driven with a synthetic 4Γ—4 m panel rather than a whole cloth sim: the thing // under test is the projection, and a flat panel makes the right answer From c2a19df7d5241f639d51c4bcf725e8ad50d6ca4e Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 11:30:48 +1000 Subject: [PATCH 4/6] Lane C S13 gate 2.2: the front wall grounds on the horizon, ends feathered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two QA sightings from in-yard eye height, both geometry, both reproduced on dev_skyfx before touching anything. (1) The band stopped AT the camera's horizontal plane, but the ground's true horizon sits ~0.6 deg below it (the dip), so a bright sky sliver showed under the base β€” the floating slab. The band now overshoots to ~12 deg below the equator; the ground occludes the overshoot by depth, and it survives scale.y's 0.3 rise floor. (2) The arc-end fade (sin^0.75) left the outer tenth at ~0.36 alpha β€” the hard vertical seam. Now sin^1.6 with the arc widened 2.2->2.6 rad so the solid core keeps its span while the ends drop under 0.12. Verified by eye on dev_skyfx (southerly t=24, front 0.51): base meets the ground line, no sliver, no seam. Selftest 338/0/0 (+1 test pinning both in numbers). Mutation-checked: the old thetaLength and the old exponent each red. Co-Authored-By: Claude Fable 5 --- web/world/js/skyfx.js | 28 +++++++++++++++++++++------- web/world/js/tests/c.test.js | 25 +++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/web/world/js/skyfx.js b/web/world/js/skyfx.js index 9675264..c71eb7b 100644 --- a/web/world/js/skyfx.js +++ b/web/world/js/skyfx.js @@ -656,15 +656,25 @@ export function createSkyFx(o = {}) { // fixed post-change instant β€” def + seed in, same wall out, every run. const FRONT_LEAD = 12; // s before the change the wall starts rising const FRONT_FADE = 8; // s after the swing completes for it to clear - const FRONT_ARC = 2.2; // radians of horizon the wall spans (~126Β°) + const FRONT_ARC = 2.6; // radians of horizon the wall spans (~149Β°) const FRONT_MAX_OP = 0.85; const frontEvents = (def.events || []) .filter((e) => e.type === 'windchange' && Number.isFinite(e.t)) .map((e) => ({ t0: e.t, over: e.over ?? 6, az: null })); - // Band from ~34Β° above the horizon down to it, centred (in local space) on - // azimuth Ο€: SphereGeometry's (x,z) = (-cosΟ†, sinΟ†)Β·sinΞΈ, so Ο†=0 sits at - // atan2(z,x) = Ο€. rotation.y = Ο€ βˆ’ A then carries the centre to azimuth A. - const frontGeo = new THREE.SphereGeometry(170, 24, 8, -FRONT_ARC / 2, FRONT_ARC, Math.PI * 0.30, Math.PI * 0.20); + // Band from ~34Β° above the horizon down to ~12Β° BELOW it, centred (in local + // space) on azimuth Ο€: SphereGeometry's (x,z) = (-cosΟ†, sinΟ†)Β·sinΞΈ, so Ο†=0 + // sits at atan2(z,x) = Ο€. rotation.y = Ο€ βˆ’ A then carries the centre to A. + // + // SPRINT13 gate 2.2 β€” why the band overshoots the equator: the old band + // stopped AT the horizontal plane through the camera, but from in-yard eye + // height the ground's true horizon sits ~0.6Β° BELOW that plane (the dip), + // so a sliver of bright sky showed under the wall's base and the bank read + // as a floating slab β€” the QA sighting, reproduced on dev_skyfx before this + // fix. Overshooting to βˆ’12Β° buries the base behind the ground from any eye + // height that matters (it survives scale.y's 0.3 floor: βˆ’37 m compressed to + // βˆ’11 m is still βˆ’3.7Β° elevation, below the dip). The ground plane occludes + // the overshoot by depth, so nothing is drawn twice. + const frontGeo = new THREE.SphereGeometry(170, 24, 10, -FRONT_ARC / 2, FRONT_ARC, Math.PI * 0.30, Math.PI * 0.27); { // Soft edges via vertex alpha, or the band reads as a floating rectangle // (checked by eye on dev_skyfx.html β€” the hard phi/theta cut was exactly @@ -672,11 +682,15 @@ export function createSkyFx(o = {}) { // the arc ends and across the top, so it sits ON the horizon like a bank // of weather instead of hanging in the sky like a screen. // Sphere uv: x runs 0..1 across the arc, y is 1 at the band top, 0 at the - // horizon edge. + // (now sub-horizon) bottom edge. + // The 1.6 exponent is gate 2.2's other half: 0.75 left the outer tenth of + // the arc at ~0.36 alpha, which from in-yard eye height is the "hard + // vertical seam" the QA saw at the bank's ends. 1.6 holds the same solid + // core (the arc is wider to compensate) but takes the ends to <0.12. const uv = frontGeo.attributes.uv; const rgba = new Float32Array(uv.count * 4); for (let i = 0; i < uv.count; i++) { - const across = Math.pow(Math.sin(Math.PI * uv.getX(i)), 0.75); + const across = Math.pow(Math.sin(Math.PI * uv.getX(i)), 1.6); const up = 1 - smoothstep(0.45, 1, uv.getY(i)); rgba[i * 4] = 1; rgba[i * 4 + 1] = 1; rgba[i * 4 + 2] = 1; rgba[i * 4 + 3] = across * up; diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index 0a1c262..8e8cac4 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -227,6 +227,31 @@ export default async function run(t) { 'dispose did not hand the sun back exactly (intensity / colour / shadow radius)'); }); + // SPRINT13 gate 2.2 β€” the wall's edges, judged from in-yard eye height. + // Two QA sightings, both geometry: a sliver of bright sky under the bank's + // base (it stopped AT the camera's horizontal plane, above the ground's true + // horizon), and a hard vertical seam at the arc ends (0.36 alpha at the + // outer tenth). The band now overshoots the equator and the end fade is + // steeper; these pins are what "grounded" and "feathered" mean in numbers. + t.test('the change-front wall grounds below the horizon and feathers its ends', () => { + const wind = createWind(storms.storm_03_southerly); + const sky = createSkyFx({ wind }); + const geo = sky.front.geometry; + geo.computeBoundingBox(); + assert(geo.boundingBox.min.y < -20, + `front base at y=${geo.boundingBox.min.y.toFixed(1)} β€” a band stopping at eye level floats a sky sliver under the wall`); + const uv = geo.attributes.uv, col = geo.attributes.color; + assert(col && col.itemSize === 4, 'front lost its vertex alpha β€” the edges are hard cuts again'); + let edgeMax = 0; + for (let i = 0; i < uv.count; i++) { + const u = uv.getX(i); + if (u <= 0.09 || u >= 0.91) edgeMax = Math.max(edgeMax, col.getW(i)); + } + assert(edgeMax < 0.2, + `arc-end alpha ${edgeMax.toFixed(2)} β€” the vertical seam QA saw at the bank's ends`); + sky.dispose(); + }); + // --- SPRINT2 Β§Lane C.3: rain has to stop at the cloth --- // Driven with a synthetic 4Γ—4 m panel rather than a whole cloth sim: the thing // under test is the projection, and a flat panel makes the right answer From b70699189c5907cd30b30c81a6c72764436d2a08 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 11:51:26 +1000 Subject: [PATCH 5/6] Lane C S13 gate 2.3 + D's sliver: ambient leaves from 30 km/h; hail pool hides at zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leaves: a pooled handful (cap 7) streaming with the wind from 8.3 m/s β€” the same number D keys the player's lean on, so the yard and the body start telling one story at one speed. Count rides a 2.5 s speed EMA so it doesn't strobe; own seeded rng (seed ^ 0x1eaf) so the pool never shifts the crate sequence; bit-identical under identical (dt, t) streams (asserted). debris.clear() rewinds the layer. dev_skyfx flies the debris layer now and warms it to the scrub point, so ?t=50 shows the leaves t=50 would have. D's aftermath sliver, exactly as diagnosed on lane/d: the hail InstancedMesh at count:0 with frustum culling and depthWrite off still draws instance 0's identity matrix β€” a 5 cm always-on-top speck at the origin. mesh.visible now gates on n > 0, sky.hail joins the public fx object, and a whole-storm assert demands hidden-at-zero / shown-when-falling in both directions (gated on floor(1300 x intensity) >= 1: a burst's first frames honestly draw nothing). THREADS: gate-2 wrap entry β€” D's judging unblocked, A's M key live. Selftest 340/0/0 (+2 tests). Mutation-checked: dropped stepLeaves, a 3 m/s threshold, and an always-visible hail pool all go red. Co-Authored-By: Claude Fable 5 --- THREADS.md | 56 +++++++++++++++++++++++++++ web/world/dev_skyfx.html | 12 +++++- web/world/js/debris.js | 70 ++++++++++++++++++++++++++++++++++ web/world/js/skyfx.js | 7 +++- web/world/js/tests/c.test.js | 74 ++++++++++++++++++++++++++++++++++++ 5 files changed, 216 insertions(+), 3 deletions(-) diff --git a/THREADS.md b/THREADS.md index 1045486..e843c73 100644 --- a/THREADS.md +++ b/THREADS.md @@ -4677,3 +4677,59 @@ anchors are your GLB), but the tooling is now waiting, not TODO. Β· 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. + +[C] 2026-07-18 β€” 🌩 **GATE 2 LANDED (storm grade Β· wall edges Β· leaves) + A's M-MUTE + D's SLIVER. + D: your gate-2 judging is UNBLOCKED β€” pull lane/c and fly the wild night.** Four commits, each + green and mutation-checked; selftest **340/0/0** (was 335: +1 mute, +1 grade, +1 wall, +1 leaves, + +1 sliver). + + **2.1 The storm grade β€” and why the QA's noon-blue sky was arithmetic, not a missing feature.** + The dimming EXISTED; it multiplied the weather by the author's palette. `darkness` is 0.5 on the + southerly, so 65 km/h at full rain moved the sky 0.35 toward slate and left the sun at ~70% β€” + the author's dial could zero the weather's say. The grade floors it: + `stormGrade = storminess Γ— lerp(0.5, 1, darkness)` β€” full blow now goes at least half way to + slate on ANY palette, and the wild night keeps its authored black (0.94 β†’ 0.97, unchanged to the + eye). Two things nothing keyed at all, now keyed: the sun DISC lerps toward slate (loses its + noon warmth), and `sun.shadow.radius` lerps 1β†’7 on storminess β€” the renderer is PCFSoft, so + that is real penumbra, not a no-op; the razor midday shadows die with the noon edge. Pure in t, + same curve the rain uses, exposed as `sky.stormGrade` above the render gate. dispose() hands + back intensity, colour AND radius exactly (the restore test pins all three). Verified by eye on + dev_skyfx: southerly t=55 at 18.5 m/s reads WEATHER. + + **2.2 The wall β€” both QA sightings were geometry, both reproduced before touching anything.** + The floating slab: the band stopped AT the camera's horizontal plane, but the ground's true + horizon sits ~0.6Β° below it (the dip), so a bright sliver of sky showed under the base. The + band now overshoots to ~12Β° below the equator; the ground occludes the overshoot by depth, and + it survives scale.y's 0.3 rise floor (βˆ’37 m compressed to βˆ’11 m is still βˆ’3.7Β°, under the dip + from any eye height that matters). The seam: sin^0.75 left the arc's outer tenth at ~0.36 + alpha; now sin^1.6 with the arc widened 2.2β†’2.6 rad β€” same solid core, ends under 0.12. Both + pinned in numbers (bbox min.y < βˆ’20; end-alpha < 0.2). **D β€” judge from IN-YARD eye height, + that's where both bugs lived and where I re-checked.** + + **2.3 Leaves β€” the ambient tell.** A pooled handful (cap 7) streaming with the wind, keyed on a + 2.5 s speed EMA so the count doesn't strobe. **Threshold 8.3 m/s = 30 km/h, deliberately YOUR + lean number, D** β€” when the man leans, the leaves fly; the yard and the body tell one story. + Deterministic: own seeded rng (`seed ^ 0x1eaf`) so the pool never shifts the crate rng + sequence; same (dt,t) stream in, bit-identical leaves out (asserted). debris.clear() rewinds + the layer. dev_skyfx now flies the debris layer too and warms it to the scrub point (leaves + carry ~8 s of state; a viewer spawning them cold at ?t=50 lies about mid-storm) β€” wildnight + t=50 reads `leaves 7` with tan flecks driving through the rain. + + **D's two handoffs to me, closed:** the aftermath ghost-white sliver was exactly your diagnosis + β€” hail pool at count:0, frustumCulled off, depthWrite off, instance-0 identity matrix drawing a + 5 cm always-on-top speck at the origin. Took your cheap fix (`mesh.visible = n > 0`) with your + name on the comment, plus a whole-storm assert that demands invisible-when-0/visible-when- + falling both directions (found a real subtlety on the way: a burst's first frames honestly draw + zero stones β€” floor(1300Γ—intensity) β€” so the assert gates on β‰₯1 stone, not >0 intensity). And + `sky.hail` is now on the public fx object like `rain` always was, for your judging. + + **A β€” your M key is live.** `sky.setMute(on)` + `sky.muted`, one flag + a gain write; the flag + is the point β€” M pressed on your splash BEFORE the first gesture builds the audio graph is + remembered and honoured by unlock() (that path is the asserted one, plus `audio.masterGain` + reads the real bus so the test can't be lied to by the flag). Your feature-detect should light + the key the moment you pull lane/c. + + **Still open on my plate, none blocking:** nothing from gates 1–2. B: sweep2's cover-AND-hold + table is waiting for your audit rewrite whenever A's garden export lands; my gate-1 postscript + upstream has the label-permutation warning if you lift per-corner numbers from any bench β€” + print `corners[k].anchorId`, never the input order. diff --git a/web/world/dev_skyfx.html b/web/world/dev_skyfx.html index 6e71b2b..deeb08b 100644 --- a/web/world/dev_skyfx.html +++ b/web/world/dev_skyfx.html @@ -34,6 +34,7 @@ import * as THREE from './vendor/three.module.js'; import { FIXED_DT } from './js/contracts.js'; import { loadStorm, createWind } from './js/weather.js'; import { createSkyFx } from './js/skyfx.js'; +import { createDebris } from './js/debris.js'; const q = new URLSearchParams(location.search); const stormName = q.get('storm') || 'storm_03b_earlybuster'; @@ -65,9 +66,16 @@ slab.position.set(0, 1.5, -11); scene.add(slab); const sky = createSkyFx({ scene, camera, wind, sun, hemi }); +// gate 2.3: the viewer flies the ambient-leaf layer too β€” the sky stack and +// the yard's tell belong in the same eyeball test +const debris = createDebris({ wind, scene }); addEventListener('pointerdown', () => sky.unlockAudio(), { once: false }); let t = Math.max(0, +q.get('t') || 0), paused = false, last = performance.now(), acc = 0; +// Warm the ambient layer to the scrub point: leaves carry a few seconds of +// state (speed EMA + travel), so a viewer that spawns them cold at ?t=50 +// shows an empty sky the real t=50 would not. +for (let wt = Math.max(0, t - 8); wt < t; wt += FIXED_DT) debris.step(FIXED_DT, wt, {}); addEventListener('keydown', (e) => { if (e.code === 'Space') paused = !paused; if (e.key === ']') t = Math.min(def.duration, t + 5); @@ -86,13 +94,13 @@ function frame(now) { const wall = Math.min(0.1, (now - last) / 1000); last = now; if (!paused && t < def.duration) { acc += wall; - while (acc >= FIXED_DT) { acc -= FIXED_DT; t += FIXED_DT; sky.step(FIXED_DT, t, {}); } + while (acc >= FIXED_DT) { acc -= FIXED_DT; t += FIXED_DT; sky.step(FIXED_DT, t, {}); debris.step(FIXED_DT, t, {}); } } camera.rotation.set(0, yaw, 0, 'YXZ'); const ch = (def.events || []).find((e) => e.type === 'windchange'); hud.textContent = `${stormName} t=${t.toFixed(1)}s / ${def.duration}s${paused ? ' ⏸' : ''}\n` + `wind ${wind.speedAt(camera.position, t).toFixed(1)} m/s rain ${wind.rainAt(t).toFixed(2)} ` - + `front ${sky.changeFront.toFixed(2)}${ch ? ` (change at t=${ch.t})` : ' (no change)'}\n` + + `front ${sky.changeFront.toFixed(2)} leaves ${debris.leafCount}${ch ? ` (change at t=${ch.t})` : ' (no change)'}\n` + `space pause Β· [ ] scrub Β· ←→ turn Β· click for audio`; renderer.render(scene, camera); } diff --git a/web/world/js/debris.js b/web/world/js/debris.js index bd8f1e9..7fccd11 100644 --- a/web/world/js/debris.js +++ b/web/world/js/debris.js @@ -52,6 +52,66 @@ export function createDebris(o = {}) { const w = new THREE.Vector3(); const probe = new THREE.Vector3(); + // ---------------------------------------------------------------- leaves + // SPRINT13 gate 2.3 β€” ambient leaves. QA: "debris reads 0 through night 3" + // β€” the event-driven crates are the drama, but nothing SELLS the gale + // between events. A handful of leaves streaming with the wind does, from + // 30 km/h up. Numbers that matter: LEAF_START is 8.3 m/s on purpose β€” the + // same threshold D keys the player's lean on (their table, lane/d), so the + // yard and the body start telling the same story at the same speed. Count + // stays single digits (cap 7); these are a tell, not a particle system. + // + // Deterministic: own seeded rng (so the leaf pool never shifts the piece + // rng sequence), distance accumulated over the fixed-dt stream, flutter + // pure in t. Same (dt, t) stream in, same leaves out. + const LEAF_START = 8.3; // m/s = 30 km/h; matches D's lean threshold + const LEAF_MAX = 7; // "a handful" β€” single digits, capped + const LEAF_SPAN = 26; // m of downwind run before a leaf recycles + const lrand = rng(((wind && wind.seed) || 1) ^ 0x1eaf); + const leafGeo = new THREE.PlaneGeometry(0.16, 0.09); + const leafMat = new THREE.MeshLambertMaterial({ color: 0x8a7a3f, side: THREE.DoubleSide }); + const leafMeshes = []; + const leafSeed = []; + for (let i = 0; i < LEAF_MAX; i++) { + const m = new THREE.Mesh(leafGeo, leafMat); + m.visible = false; + if (scene) scene.add(m); + leafMeshes.push(m); + leafSeed.push({ + lat: lrand() * 16 - 8, // lane across the wind, m + base: 0.35 + lrand() * 1.5, // ride height, m + off: lrand() * LEAF_SPAN, // where on the loop it starts + fl: 1.6 + lrand() * 1.6, // flutter frequency + ph: lrand() * 6.283, + }); + } + let leafDist = 0; // m travelled downwind, accumulated over the dt stream + let leafEma = 0; // ~2.5 s smoothed speed, so the count doesn't strobe + + function stepLeaves(dt, t) { + probe.set(0, 1.6, 0); + wind.sample(probe, t, w); + const sp = Math.hypot(w.x, w.z); + leafEma += (sp - leafEma) * Math.min(1, dt / 2.5); + const n = leafEma < LEAF_START ? 0 + : Math.min(LEAF_MAX, 1 + Math.floor((leafEma - LEAF_START) / 1.5)); + leafDist += sp * 0.85 * dt; // leaves ride a little under the wind + const inv = sp > 1e-4 ? 1 / sp : 0; + const dx = w.x * inv, dz = w.z * inv; + for (let i = 0; i < LEAF_MAX; i++) { + const m = leafMeshes[i], s = leafSeed[i]; + const vis = i < n && inv > 0; + m.visible = vis; + if (!vis) continue; + const along = ((leafDist + s.off + i * (LEAF_SPAN / LEAF_MAX)) % LEAF_SPAN) - LEAF_SPAN / 2; + const lat = s.lat + Math.sin(t * 0.9 + s.ph) * 1.1; + const x = dx * along - dz * lat; + const z = dz * along + dx * lat; + m.position.set(x, groundAt(x, z) + s.base + Math.sin(t * s.fl + s.ph) * 0.3, z); + m.rotation.set(t * s.fl, s.ph + t * 2.1, t * 1.3 + s.ph); + } + } + /** Graybox stand-in so a missing GLB can't break Lane A's merge. */ function placeholder(spec) { const g = new THREE.BoxGeometry(spec.r * 1.8, spec.r * 1.8, spec.r * 1.8); @@ -108,6 +168,11 @@ export function createDebris(o = {}) { const debris = { get pieces() { return pieces; }, + /** How many ambient leaves are flying right now (gate 2.3). 0 in a calm. */ + get leafCount() { let n = 0; for (const m of leafMeshes) if (m.visible) n++; return n; }, + /** Positions of the flying leaves β€” for asserts and D's judging. */ + get leaves() { return leafMeshes.filter((m) => m.visible).map((m) => m.position); }, + /** Lane E's GLBs, once they land. name -> Object3D template. */ setModels(map) { Object.assign(models, map); return debris; }, @@ -125,6 +190,7 @@ export function createDebris(o = {}) { for (const ev of wind.eventsBetween(t - dt, t)) { if (ev.type === 'debris') spawn(ev, t); } + stepLeaves(dt, t); // gate 2.3 β€” the ambient tell } const player = world.player; @@ -219,6 +285,10 @@ export function createDebris(o = {}) { clear() { for (const p of pieces) despawn(p); pieces.length = 0; + // leaves are a pooled ambient layer, not spawned pieces: hide and rewind + // so the next night's stream starts from the same state every time + for (const m of leafMeshes) m.visible = false; + leafDist = 0; leafEma = 0; }, }; diff --git a/web/world/js/skyfx.js b/web/world/js/skyfx.js index c71eb7b..4c2110c 100644 --- a/web/world/js/skyfx.js +++ b/web/world/js/skyfx.js @@ -191,6 +191,11 @@ function createHail(opts) { step(dt, camPos, vel, intensity, size, shadow) { const n = Math.floor(max * clamp01(intensity)); mesh.count = n; + // D's aftermath find (S13): count:0 with frustumCulled off and depthWrite + // off still draws instance 0's identity matrix β€” a ~5 cm always-on-top + // white sliver at the world origin, QA's "ghost near the shed table". + // visible is the honest gate; count alone is not. + mesh.visible = n > 0; if (n === 0) return; const s = 0.6 + size * 0.9; // bigger stones read bigger m.makeScale(s, s, s); @@ -744,7 +749,7 @@ export function createSkyFx(o = {}) { const w = new THREE.Vector3(); const fx = { - rain, audio, dome, shadow, hailShadow, front: frontMesh, + rain, hail, audio, dome, shadow, hailShadow, front: frontMesh, get flash() { return flash; }, /** 0..1 hail intensity right now β€” for the HUD ("HAIL" banner) and asserts. */ get hailAmount() { return hailAmt; }, diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index 8e8cac4..6f076cf 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -136,6 +136,80 @@ export default async function run(t) { 'clear() replaced the pieces array instead of emptying it β€” B holds a reference'); }); + // SPRINT13 gate 2.3 β€” ambient leaves. QA: "debris reads 0 through night 3"; + // the crates are event drama, the leaves are the continuous tell. Threshold + // 8.3 m/s (30 km/h) matches D's lean threshold on purpose β€” the yard and the + // body start telling the same story at the same speed. Count stays single + // digits, they stream WITH the wind, and the layer is deterministic. + t.test('ambient leaves: none in a calm, a streaming handful from ~30 km/h', () => { + const stub = (sp) => ({ + seed: 5, + sample: (p, t2, o) => o.set(sp, 0, 0), + eventsBetween: () => [], + }); + const calm = createDebris({ wind: stub(5) }); + fixedLoop(8, FIXED_DT, (dt, time) => calm.step(dt, time, {})); + assert(calm.leafCount === 0, + `${calm.leafCount} leaves flying at 18 km/h β€” below the threshold the yard is still`); + + const gale = createDebris({ wind: stub(13) }); + fixedLoop(8, FIXED_DT, (dt, time) => gale.step(dt, time, {})); + assert(gale.leafCount > 0, 'no leaves at 47 km/h β€” the gale has no ambient tell'); + assert(gale.leafCount <= 9, `${gale.leafCount} leaves β€” the plan says single digits`); + + // they stream WITH the wind (+x here): over one step every leaf either + // moves downwind or recycles ~a span upwind; none drifts against it + const before = gale.leaves.map((pos) => pos.x); + gale.step(FIXED_DT, 8, {}); + const after = gale.leaves.map((pos) => pos.x); + assert(before.length === after.length, 'leaf count strobed across one step'); + for (let i = 0; i < before.length; i++) { + const d = after[i] - before[i]; + assert(d > 0 || d < -10, `leaf ${i} moved ${d.toFixed(3)} m against a +x wind`); + } + + // deterministic: same seed, same (dt, t) stream, same leaves β€” bit for bit + const a = createDebris({ wind: stub(13) }); + const b = createDebris({ wind: stub(13) }); + fixedLoop(5, FIXED_DT, (dt, time) => { a.step(dt, time, {}); b.step(dt, time, {}); }); + assert(a.leafCount === b.leafCount && a.leaves.every((pos, i) => + pos.x === b.leaves[i].x && pos.y === b.leaves[i].y && pos.z === b.leaves[i].z), + 'two identical (dt, t) streams produced different leaves'); + + // clear() rewinds the layer β€” next night starts from the same state + gale.clear(); + assert(gale.leafCount === 0, 'clear() left leaves flying into the aftermath card'); + }); + + // D's aftermath find (S13, lane/d): the hail InstancedMesh at count:0 with + // frustum culling and depthWrite both off still draws instance 0's identity + // matrix β€” a ~5 cm always-on-top white sliver at the origin, the QA's "ghost + // near the shed table". The gate is mesh.visible, not count. This flies the + // wild night and demands the pool is invisible whenever no hail is falling + // and visible whenever it is β€” both directions, whole storm. + t.test("hail pool: invisible when nothing falls (D's aftermath sliver)", () => { + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(); + const wind = createWind(storms.storm_02_wildnight); + const sky = createSkyFx({ scene, camera, wind }); + let sawHail = false, sawCalm = false; + fixedLoop(wind.duration, FIXED_DT, (dt, time) => { + sky.step(dt, time, {}); + // the mesh draws floor(1300 Γ— intensity) stones, so a burst's first + // millisecond can honestly show zero β€” demand visibility only once the + // intensity buys at least one stone, and invisibility only at exactly 0 + if (sky.hailAmount >= 1 / 1300) { + sawHail = true; + assert(sky.hail.mesh.visible, `hail falling at t=${time.toFixed(1)} but the pool is hidden`); + } else if (sky.hailAmount === 0) { + sawCalm = true; + assert(!sky.hail.mesh.visible, `no hail at t=${time.toFixed(1)} but the pool still draws β€” the sliver`); + } + }); + assert(sawHail && sawCalm, 'storm never exercised both states β€” this test proves nothing'); + sky.dispose(); + }); + // Lane A rebuilds skyfx on every phase change, so dispose() is on the hot path. // They verified sun/hemi restore exactly and spotted that fog didn't; this pins // both. The vacuity guards matter β€” a restore test where nothing ever moved is From 45bdc2d0792f8c2866d93921ae5291efed5aa4e8 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 13:08:21 +1000 Subject: [PATCH 6/6] Lane C S13 correction: the bench flew site_02 funnel-OFF and tree-FROZEN; wild-night headline dies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- THREADS.md | 84 ++++++++++++++++++++++++++++++++++ tools/garden_bench/bench.js | 82 +++++++++++++++++++++------------ tools/garden_bench/probe3.html | 49 ++++++++++++++------ tools/garden_bench/probe4.html | 82 ++++++++++++++++++++++----------- tools/garden_bench/sweep2.html | 37 +++++++++++---- web/world/js/tests/c.test.js | 35 +++++++++++++- web/world/js/weather.js | 27 +++++++++++ 7 files changed, 318 insertions(+), 78 deletions(-) diff --git a/THREADS.md b/THREADS.md index e843c73..2a57778 100644 --- a/THREADS.md +++ b/THREADS.md @@ -4733,3 +4733,87 @@ anchors are your GLB), but the tooling is now waiting, not TODO. table is waiting for your audit rewrite whenever A's garden export lands; my gate-1 postscript upstream has the label-permutation warning if you lift per-corner numbers from any bench β€” print `corners[k].anchorId`, never the input order. + +[C] 2026-07-18 β€” πŸ›‘ **CORRECTION: THE BENCH HAD TWO LANDMINES, NOT ONE, AND THE WILD-NIGHT $80 + HEADLINE IS DEAD. D's funnel catch confirmed, plus a second bug D's replay exposed but nobody + had named: the bench flew site_02's gum tree FROZEN.** Corrected tools, corrected tables, + corrected claims below. Selftest 341/0/0; the fix carries its own assert. + + **Landmine 1 β€” the venturi (D's catch, exactly as filed).** bench.js:89 read `def.wind.venturi` + off the STORM def, where a venturi never lives β€” it LOOKED right and never fired; probe3/probe4/ + sweep2 never attempted it. Every site_02 number the bench ever printed was flown with the + funnel OFF. Third harness to make B's Sprint-11 mistake, and I made it with B's sweep.selftest + sitting in the repo telling the story. + + **Landmine 2 β€” the FROZEN TREE, found while verifying the fix.** Venturi-on alone did NOT + reproduce D's game numbers (q4 moved 1.02β†’1.03), so I kept digging with an ablation: + funnel OFF, static anchors q4 1.02 (the original bench) + funnel ON, static anchors q4 1.03 (the funnel's disc barely reaches q4) + funnel ON, REAL anchors q4 1.24 β€” the tree is the bigger half on this line + The bench remapped every anchor to `sway: () => pos` β€” which froze tr1, the gum tree, whose + sway is precisely the dynamic load DESIGN.md warns a tree rig has to eat (it also dropped + ratingHint). **This is why the backyard bench matched D's UI to the decimal while site_02 + lied: p1..p4 are all posts β€” static anchors and no venturi are both no-ops there.** The two + landmines are the same species: the bench modelled the STORM exactly and the YARD not at all. + + **The fix, structural so there is never a fourth:** + Β· `windForSite(stormDef, siteDef, anchors)` in weather.js β€” THE shared wind builder: venturi + from the site def + tree shelters, wired byte-for-byte as main.js:453 does at site load. The + landmine's full history lives in its docstring. All garden_bench tools now build wind + through it; **B β€” proposal: your re-audit imports it too**, and at integration one copy + should win (your sweep.js has its own correct pair of setVenturi calls + the selftest β€” the + logic is identical, the point is one door). + Β· c.test.js gains B's strictly-raises pin lifted onto the helper, flown against the REAL + shipped funnel (site_02's own JSON, not a synthetic): drop the setVenturi line and the + funnelled/bare winds collapse to equal reads at the throat β†’ red. Mutation-checked. + Β· The tools fly `world.anchors` THEMSELVES β€” the world is built on a re-pointable wind proxy + (main.js-router style) so tree-sway closures sample whichever storm is flying. No more + static remaps, anywhere. And no more phantom 12 s calm settle: in the real game the rig + does not exist before commit, and commitβ†’attachβ†’storm is one keypress β€” the settle also + skewed every storm sample 12 s off the authored curve, because SailRig samples wind at its + INTERNAL clock (measured: worth 0.01 kN on backyard posts, which is why nobody noticed). + Β· Every tool prints the venturi in its header (B's audit.html pattern) β€” a reader can now SEE + which wind a number was flown in. + + **CORRECTED TABLES (probe4, real shop, real $80, funnel + sway on):** + $80 line tr1(R)+q1(R)+q3(S)+q4(C): + wildnight 91.9 FULL 0/4 peaks q1 4.91 tr1 4.31 q3 2.89 **q4 1.24 vs 1.2 rating** + icenight 9 DEAD 2/4 (q3 3.32 breaks tβ‰ˆ50, q4 1.45 breaks tβ‰ˆ49) + buster 95.2 FULL 0/4 peaks q1 1.71 tr1 1.79 q3 0.84 q4 0.56 + all-shackle $60: wildnight 41.3 (2/4) Β· icenight 0 Β· buster 95.2 FULL 0/4 + canonical q1+q2+q3+tr1 best-$80: wildnight 48.3 (1/4) Β· icenight 0.9 Β· buster 85.6 + probe3 (RATED, unlimited): backyard rows essentially unmoved β€” canonical wildnight 60.9 + (was 61.3; the 0.4 is the settle removal) and balance.test's canon peaks intact to 0.01 kN + (p4 3.64 p2 2.77 p3 1.62 p1 0.98). site_02 posts-line wildnight drops 90.1β†’79.6 (q1 6.75 + breaks tβ‰ˆ46 β€” the funnel side), canonical 51.3β†’48.3. sweep2 re-run: **tr1+q1+q3+q4 is still + the #1 cover-AND-hold geometry** (100% cover, shadow 1.000) β€” the LINE survives; its + wild-night steel pricing does not. + + **⚠️ THE HONEST RESIDUAL β€” and who is canonical where.** Even fully corrected, the bench + under-reads D's real-UI peaks by ~5–15% on site_02 (buster, nothing breaking: bench + q1 1.71 Β· tr1 1.79 Β· q3 0.84 Β· q4 0.56 vs D's 1.82 Β· 1.86 Β· 0.99 Β· 0.58). On the wild night + that margin is exactly the knife edge: the bench holds q4 at 1.24-vs-1.2 and reads 91.9; the + real game pushes it over, q4 lets go, q3 catches the shed (3.43), and D's 39.8 TATTERED is + what ships. **D's UI numbers are canonical for the wild-night verdict; the bench's own + corrected read supports the same conclusion the honest way: a line whose cheapest corner + peaks OVER its rating has no margin and is not a safe line.** The 91.5-FULL-with-room + headline was the funnel-off, frozen-tree illusion. Residual cause unlisted-variable, in the + pool; until it's found, any bench corner within ~15% of its rating is "breaks in the game". + + **What survives, what dies (A β€” for your gate-1.4 ruling):** + Β· SURVIVES: the shadow MODEL's exactness (probe2, untouched), the vertical-projection + conclusion, every backyard finding (D-verified to the decimal β€” posts don't sway and + backyard has no funnel), the buster verdict (the $80 line holds site_02's actual shipped + night with room β€” D live 97.9), and the diagnosis IN KIND: the canonical lines still can't + cover, covering geometry still exists. + Β· DIES: the wild-night 91.5 FULL headline, "$80 to the dollar with nothing to spare" (its + pricing was computed against funnel-off loads), and therefore **my separation-target + example. A: I withdraw the site_02 example, not the shape.** D's gate-1.4 table is right + that the target as I worded it is currently satisfiable nowhere β€” bare never reads DEAD, + and the funnelled wild night has no FULL-capable buyable line on either yard. Rule on D's + played picture; if the shape survives, the thresholds want restating against funnel-on + numbers (D's "bare TATTERED, held FULL" is the version night 4 already nearly meets). + Β· **B, one more for the re-audit:** your audit "freezes sway like balance.test" + (audit.html:69) β€” same species as my landmine 2. Post-fuse re-audit should fly + world.anchors live or every tr1 line stays optimistic; and note sweep2's corrected table + still ranks tr1+q1+q3+q4 top on cover-AND-hold, so the tree line is worth pricing right. diff --git a/tools/garden_bench/bench.js b/tools/garden_bench/bench.js index d6ab599..0c56e34 100644 --- a/tools/garden_bench/bench.js +++ b/tools/garden_bench/bench.js @@ -28,7 +28,7 @@ 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 { 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'; @@ -39,7 +39,6 @@ const [CARABINER, SHACKLE, RATED] = HARDWARE; 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', @@ -57,23 +56,47 @@ export const YARDS = [ { site: 'site_02_corner_block', quad: ['q1', 'q2', 'q3', 'tr1'] }, ]; -/** The real yard, read from world.js β€” never a copied anchor table. */ +/** + * 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(); - const calm = { + let current = { sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4), speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0, - gustTelegraph: () => null, setSheltersFromTrees() {}, eventsBetween: () => [], + gustTelegraph: () => null, eventsBetween: () => [], }; - const world = createWorld(scene, { wind: calm, site: await loadSite(siteId) }); + 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.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 }; - }); + 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; }, }; } @@ -83,10 +106,14 @@ async function buildYard(siteId) { */ 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); + // 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') { @@ -104,19 +131,12 @@ async function fly(yard, stormName, mode) { 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; - } - } + // 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); @@ -206,7 +226,13 @@ export async function runBench(log) { 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('+')}`); + // 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'); diff --git a/tools/garden_bench/probe3.html b/tools/garden_bench/probe3.html index d5b96dc..4a68d94 100644 --- a/tools/garden_bench/probe3.html +++ b/tools/garden_bench/probe3.html @@ -15,7 +15,7 @@ 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 { loadStorm, windForSite } from '../../web/world/js/weather.js'; import { createWorld, loadSite } from '../../web/world/js/world.js'; import { FIXED_DT, HARDWARE } from '../../web/world/js/contracts.js'; const [CARABINER, SHACKLE, RATED] = HARDWARE; @@ -36,13 +36,19 @@ el.textContent = ''; // Fly the covering lines. Report peak corner load per corner and the garden. const GARDEN_DRAIN = 0.9, W_HAIL = 5.0, W_RAIN = 0.25; -async function fly(anchors, bed, ids, stormName, hw) { +async function fly(world, ids, stormName, hw, siteDef, use) { const def = await loadStorm(stormName); - const wind = createWind(def); - const trees = anchors.filter((a) => a.type === 'tree'); - wind.setSheltersFromTrees(trees); - const calmDef = await loadStorm('storm_01_gentle'); - const calmWind = createWind(calmDef); calmWind.setSheltersFromTrees(trees); + const anchors = world.anchors; // REAL anchors: trees sway, hints ride along + const bed = world.gardenBed; + // windForSite: the SITE's venturi + shelters, as main.js wires them. Before + // 2026-07-18 this probe (a) never set the venturi β€” every site_02 row was + // flown with the funnel OFF β€” and (b) remapped anchors to a static + // `sway: () => pos`, freezing the gum tree and dropping ratingHint. See + // probe4's correction note and the THREADS postscript; backyard post-only + // lines (p1..p4) were unaffected by either, which is why they matched D's + // UI replays to the decimal while the tr1 lines lied. + const wind = windForSite(def, siteDef, anchors); + use(wind); const s = new RiggingSession({ anchors, budget: 9999 }); for (const id of ids) if (!s.rig(id).ok) return null; @@ -50,8 +56,7 @@ async function fly(anchors, bed, ids, stormName, hw) { if (!r.ok) throw new Error(`setHardware ${id} refused: ${r.reason} β€” flying hardware it did not buy`); } s.setTension(1.0); const rig = s.commit(new SailRig({ anchors, gridN: 10 })); - let prepT = 0; - for (let i=0;i(o||new THREE.Vector3()).set(0,0,0), speedAt:()=>0, rainAt:()=>0, rainMmPerHour:()=>0, gustTelegraph:()=>null, setSheltersFromTrees(){}, eventsBetween:()=>[] }; - const world = createWorld(scene, { wind: calm, site: await loadSite(siteId) }); + const siteDef = await loadSite(siteId); + // re-pointable wind, main.js-router style: sway closures capture the proxy + let current = calm; + 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 use = (w) => { current = w; }; + const world = createWorld(scene, { wind: proxy, site: siteDef }); if (world.dress) { try { await world.dress(); } catch {} } - 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};}); const bed = world.gardenBed; - log(`\n\n######## ${siteId} (RATED = 6.5 kN is the best steel in the game, $30/corner)`); + const anchors = world.anchors; + const vl = 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\n######## ${siteId} (RATED = 6.5 kN is the best steel in the game, $30/corner) venturi: ${vtxt}`); for (const [label, ids] of Object.entries(lineSet)) { for (const storm of ['storm_02_wildnight', 'storm_02b_icenight', 'storm_03b_earlybuster']) { - const r = await fly(anchors, bed, ids, storm, RATED); + const r = await fly(world, ids, storm, RATED, siteDef, use); if (!r) { log(` ${label} ${ids.join('+')}: REFUSED`); continue; } const over = r.peak.map((p,i)=>`${r.cornerIds[i]} ${p.toFixed(2)}`).join(' '); const bare = { storm_02_wildnight: 35.7, storm_02b_icenight: 0, storm_03b_earlybuster: 82.6 }[storm]; diff --git a/tools/garden_bench/probe4.html b/tools/garden_bench/probe4.html index c204798..5f53a8a 100644 --- a/tools/garden_bench/probe4.html +++ b/tools/garden_bench/probe4.html @@ -15,7 +15,7 @@ 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 { loadStorm, windForSite } from '../../web/world/js/weather.js'; import { createWorld, loadSite } from '../../web/world/js/world.js'; import { FIXED_DT, HARDWARE, START_BUDGET } from '../../web/world/js/contracts.js'; const [CARABINER, SHACKLE, RATED] = HARDWARE; @@ -23,24 +23,30 @@ const el = document.getElementById('out'); const lines = []; const log = (s) => { lines.push(s); el.textContent = lines.join('\n'); }; el.textContent = ''; -// THE PAYOFF. -// probe3 (on an unlimited budget) found tr1+q1+q3+q4 on site_02 holds every -// corner through the WILD night and the ICE night and reads hp 91.5 / 89.2 -// against a bare bed's 35.7 / 0.0. Its peak loads were tr1 4.72, q1 3.74, -// q3 2.97, q4 1.02 kN β€” which price EXACTLY to -// RATED(6.5) + RATED(6.5) + SHACKLE(3.2) + CARABINER(1.2) -// $30 + $30 + $15 + $5 = $80 -// ...the whole budget, to the dollar. If that holds through the REAL shop at the -// REAL START_BUDGET, then the garden already responds to rigging, gate 1 needs -// no model change, and what was actually broken is that nobody had found the -// line. So buy it the way a player must, and fly it. -async function fly(anchors, bed, plan, stormName) { +// THE PAYOFF β€” and the correction (2026-07-18). +// The first run of this probe read the $80 line tr1+q1+q3+q4 at 91.5 FULL on +// the wild night and called it the headline. TWO harness landmines, both mine: +// 1. No venturi β€” the funnel lives in the SITE def, and this probe never set +// it (bench.js read it off the STORM def, where it never lives). Third +// harness to make B's Sprint-11 mistake. +// 2. Static anchors β€” the remap `sway: () => pos` stripped tr1's TREE sway, +// the dynamic load a rig on a gum tree has to eat (DESIGN.md's whole +// warning about tree anchors). The backyard bench numbers matched D's UI +// replays to the decimal BECAUSE p1..p4 are all posts; any tr1 line was +// flown against a tree that never moved. +// With both fixed the probe agrees with D's live UI replays in direction and +// closely in number (buster peaks within ~15%); on the wild night the line's +// cheapest corner rides AT its rating (q4 carabiner ~1.2 kN) β€” a knife edge +// the real game falls off (D: q3+q4 break tβ‰ˆ45, 39.8 TATTERED). Where this +// probe and D's UI disagree on which side of that edge a corner lands, D's +// UI number is canonical. What survives: the line holds the BUSTER β€” site_02's +// actual shipped night β€” with room (D live: 97.9 FULL, 0/4). +async function fly(world, plan, stormName, siteDef, use) { const def = await loadStorm(stormName); - const wind = createWind(def); - const trees = anchors.filter((a) => a.type === 'tree'); - wind.setSheltersFromTrees(trees); - const calmWind = createWind(await loadStorm('storm_01_gentle')); - calmWind.setSheltersFromTrees(trees); + const anchors = world.anchors; // REAL anchors β€” tr1 sways + const bed = world.gardenBed; + const wind = windForSite(def, siteDef, anchors); + use(wind); // point the world's sway closures at the flight wind // THE REAL SHOP, at the REAL budget. Every refusal is loud. const s = new RiggingSession({ anchors }); // budget = START_BUDGET @@ -55,8 +61,10 @@ async function fly(anchors, bed, plan, stormName) { s.setTension(1.0); const spent = START_BUDGET - s.budget; const rig = s.commit(new SailRig({ anchors, gridN: 10 })); - let prepT = 0; - for (let i=0;i hp > 66 ? 'FULL' : hp > 33 ? 'tattered' : 'DEAD'; try { const scene = new THREE.Scene(); - const zero = { sample:(p,t,o)=>(o||new THREE.Vector3()).set(0,0,0), speedAt:()=>0, rainAt:()=>0, - rainMmPerHour:()=>0, gustTelegraph:()=>null, setSheltersFromTrees(){}, eventsBetween:()=>[] }; - const world = createWorld(scene, { wind: zero, site: await loadSite('site_02_corner_block') }); + // A re-pointable wind, the way main.js's router serves world.js: the world's + // tree-sway closures capture THIS object, and fly() re-points it per storm. + // Building the world on a dead stub and remapping anchors to static + // `sway: () => pos` was landmine #2 β€” it froze the gum tree. + let current = { sample:(p,t,o)=>(o||new THREE.Vector3()).set(0,0,0), speedAt:()=>0, 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 use = (w) => { current = w; }; + const siteDef = await loadSite('site_02_corner_block'); + const world = createWorld(scene, { wind: proxy, site: siteDef }); await world.dress(); // NOT optional: undressed, the carport trap does not exist - 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};}); const bed = world.gardenBed; const PLANS = { @@ -105,12 +128,17 @@ try { 'canonical q1+q2+q3+tr1, best steel $80 can buy': [['q1',RATED],['q2',SHACKLE],['q3',CARABINER],['tr1',RATED]], }; - log(`site_02_corner_block budget $${START_BUDGET} bed ${bed.w}x${bed.d} @ (${bed.x}, ${bed.z})`); + const vl = siteDef.wind?.venturi ?? []; + const vtxt = vl.length + ? vl.map((v)=>`throat (${v.x},${v.z}) gain ${v.gain} r${v.radius}`).join(' Β· ') : 'none'; + log(`site_02_corner_block budget $${START_BUDGET} bed ${bed.w}x${bed.d} @ (${bed.x}, ${bed.z}) venturi: ${vtxt}`); + // bare-bed refs are funnel-independent (no sail = exposure is the storm's own + // rain/hail timeline, wind never enters it); D confirmed 82.6 live on the buster log(`bare bed for reference: wildnight 35.7 (DEAD) icenight 0.0 (DEAD) buster 82.6 (FULL)\n`); for (const [label, plan] of Object.entries(PLANS)) { log(`\n## ${label}`); for (const storm of ['storm_02_wildnight','storm_02b_icenight','storm_03b_earlybuster']) { - const r = await fly(anchors, bed, plan, storm); + const r = await fly(world, plan, storm, siteDef, use); if (r.err) { log(` ${storm.padEnd(22)} SHOP REFUSED: ${r.err}`); continue; } const bare = { storm_02_wildnight:35.7, storm_02b_icenight:0, storm_03b_earlybuster:82.6 }[storm]; log(` ${storm.padEnd(22)} spent $${String(r.spent).padStart(3)} hp ${String(r.hp).padStart(5)} ${state(r.hp).padEnd(8)}` diff --git a/tools/garden_bench/sweep2.html b/tools/garden_bench/sweep2.html index 950cee8..9cd9a43 100644 --- a/tools/garden_bench/sweep2.html +++ b/tools/garden_bench/sweep2.html @@ -15,7 +15,7 @@ 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 { RainShadow } from '../../web/world/js/skyfx.js'; -import { createWind, loadStorm } from '../../web/world/js/weather.js'; +import { loadStorm, windForSite } from '../../web/world/js/weather.js'; import { createWorld, loadSite } from '../../web/world/js/world.js'; import { FIXED_DT, HARDWARE } from '../../web/world/js/contracts.js'; const [CARABINER, SHACKLE, RATED] = HARDWARE; @@ -41,20 +41,41 @@ try { const scene = new THREE.Scene(); const zero = { sample:(p,t,o)=>(o||new THREE.Vector3()).set(0,0,0), speedAt:()=>0, rainAt:()=>0, rainMmPerHour:()=>0, gustTelegraph:()=>null, setSheltersFromTrees(){}, eventsBetween:()=>[] }; - const world = createWorld(scene, { wind: zero, site: await loadSite(siteId) }); + const siteDef = await loadSite(siteId); + // re-pointable wind, main.js-router style: the anchors are world.anchors + // THEMSELVES, so tree sway is live β€” the old static remap froze the gum + // tree and under-read every tr1 corner (correction, 2026-07-18). + let current = zero; + 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 world = createWorld(scene, { wind: proxy, site: siteDef }); if (world.dress) { try { await world.dress(); } catch {} } - 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, ratingHint:a.ratingHint }; }); + const anchors = world.anchors; const bed = world.gardenBed; - log(`\n\n######## ${siteId} bed ${bed.w}x${bed.d} @ (${bed.x}, ${bed.z})`); + const vl = 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\n######## ${siteId} bed ${bed.w}x${bed.d} @ (${bed.x}, ${bed.z}) venturi: ${vtxt}`); log(` anchors and what the BEST steel ($30 RATED, 6.5 kN) is actually worth on them:`); for (const a of anchors) { log(` ${a.id.padEnd(5)} ${String(a.type).padEnd(8)} hint ${(a.ratingHint ?? 1).toFixed(2)} -> ${((a.ratingHint ?? 1) * 6.5).toFixed(2)} kN`); } - // prep's own wind: main.js hands the rig the calm day outside a storm - const calmWind = createWind(await loadStorm('storm_01_gentle')); - calmWind.setSheltersFromTrees(anchors.filter((a) => a.type === 'tree')); + // prep's own wind: main.js hands the rig the calm day outside a storm. + // windForSite so the SITE's venturi flies too β€” before 2026-07-18 this + // sweep settled site_02's quads with the funnel OFF (the third harness to + // make B's Sprint-11 mistake; see windForSite's comment). The funnel does + // not switch off for prep. + const calmWind = windForSite(await loadStorm('storm_01_gentle'), siteDef, anchors); + current = calmWind; // the tree-sway closures fly the same calm day const rows = []; const n = anchors.length; diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index 6f076cf..2d8f291 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -16,7 +16,8 @@ import * as THREE from '../../vendor/three.module.js'; import { assert, fixedLoop } from '../testkit.js'; import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS } from '../contracts.js'; -import { loadStorm, createWind, forecastLines, forecastFor, leadFor } from '../weather.js'; +import { loadStorm, createWind, windForSite, forecastLines, forecastFor, leadFor } from '../weather.js'; +import { loadSite } from '../world.js'; import { createDebris } from '../debris.js'; import { createSkyFx, RainShadow } from '../skyfx.js'; import { SailRig, HARDWARE } from '../sail.js'; @@ -181,6 +182,38 @@ export default async function run(t) { assert(gale.leafCount === 0, 'clear() left leaves flying into the aftermath card'); }); + // SPRINT13, the third time this repo learned it: the venturi lives in the + // SITE json, not the storm def. B's site_audit flew site_02 funnel-OFF in + // Sprint 11 (their sweep.selftest tells it); C's garden_bench did it again + // in Sprint 13 β€” `def.wind.venturi` off a storm def LOOKS right and never + // fires, and the funnel-off bench called the $80 line 91.5 FULL on a night + // the real game scores it 39.8 TATTERED (D's live replay). windForSite is + // the shared builder that makes the mistake unmakeable; this assert is + // B's strictly-raises pin lifted onto it, with the REAL shipped funnel: + // drop the setVenturi line and the two winds collapse to equal reads. + const site02 = await loadSite('site_02_corner_block'); // awaited here: Suite.test() is sync + t.test('windForSite flies the SITE venturi β€” the shipped funnel strictly raises the throat wind', () => { + const def = storms.storm_02_wildnight; + const site = site02; + const vl = site.wind?.venturi ?? []; + assert(vl.length > 0, "site_02 lost its venturi β€” this test (and the yard's weather) proves nothing"); + const bare = createWind(def); // storm def alone β€” the trap itself + const funnelled = windForSite(def, site); + const throat = new THREE.Vector3(vl[0].x, 1.5, vl[0].z); + const a = new THREE.Vector3(), b = new THREE.Vector3(); + let bareMean = 0, funMean = 0, n = 0; + for (let time = 20; time <= 80; time += 0.5) { + bare.sample(throat, time, a); + funnelled.sample(throat, time, b); + bareMean += Math.hypot(a.x, a.z); funMean += Math.hypot(b.x, b.z); n++; + } + bareMean /= n; funMean /= n; + assert(bareMean > 3, `the storm never blew at the throat (${bareMean.toFixed(1)} m/s) β€” vacuous`); + assert(funMean > bareMean * 1.1, + `throat wind ${funMean.toFixed(2)} vs bare ${bareMean.toFixed(2)} m/s β€” the site's venturi is not ` + + 'reaching windForSite\'s wind (it must call setVenturi the way main.js does at every site load)'); + }); + // D's aftermath find (S13, lane/d): the hail InstancedMesh at count:0 with // frustum culling and depthWrite both off still draws instance 0's identity // matrix β€” a ~5 cm always-on-top white sliver at the origin, the QA's "ghost diff --git a/web/world/js/weather.js b/web/world/js/weather.js index ed94749..a635f47 100644 --- a/web/world/js/weather.js +++ b/web/world/js/weather.js @@ -112,6 +112,33 @@ export async function loadStorm(name, dir = STORM_DIR) { return def; } +/** + * The wind a YARD actually flies β€” storm def + the SITE's local effects, + * wired exactly as main.js does at every site load (setVenturi from + * siteDef.wind.venturi, tree shelters from the anchors). SPRINT13: + * + * THREE harnesses have now measured site_02 with the funnel switched OFF by + * building wind from the storm def alone β€” B's site_audit (Sprint 11, their + * sweep.selftest tells the story), C's garden_bench (Sprint 13, where it + * flipped the $80-line headline: 91.5 FULL funnel-off vs 39.8 TATTERED in + * D's live replay), and probe4, which never attempted it. The venturi lives + * in the SITE json, not the storm, so `def.wind.venturi` off a storm def + * LOOKS right and never fires. Any tool measuring a yard builds its wind + * HERE, or it is measuring a yard that doesn't ship. The strictly-raises + * assert in c.test.js goes red if the setVenturi line is ever dropped. + * + * @param {object} stormDef parsed storm JSON + * @param {object} [siteDef] parsed site JSON (loadSite) β€” its wind.venturi list + * @param {Array} [anchors] world anchors; trees become wind shelters + * @param {object} [opts] forwarded to createWind ({seed}) + */ +export function windForSite(stormDef, siteDef, anchors = [], opts = {}) { + const wind = createWind(stormDef, opts); + wind.setVenturi(siteDef?.wind?.venturi ?? []); + wind.setSheltersFromTrees(anchors.filter((a) => a.type === 'tree')); + return wind; +} + /** * @param {object} def parsed storm JSON * @param {object} [opts] {seed} β€” same seed + same def = same storm, every run