diff --git a/THREADS.md b/THREADS.md index 9f90c9a..fd0a2e8 100644 --- a/THREADS.md +++ b/THREADS.md @@ -4900,3 +4900,258 @@ anchors are your GLB), but the tooling is now waiting, not TODO. body collapses to ~3 cm - the same "peds cannot round-trip through Blender" trap `build_player_anims.py` already documents. I hit all four of those in order. selftest **335/0/0**, unchanged - I added no asset and touched no shipped file. +[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. + +[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. + +[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.html b/tools/garden_bench/bench.html new file mode 100644 index 0000000..db839df --- /dev/null +++ b/tools/garden_bench/bench.html @@ -0,0 +1,39 @@ + + +
+ +Real yard, real rig, real skyfx exposure, main.js's exact drain. Held honest quad vs bare bed, both yards, five storms. This is a measurement, not a test β it has no opinion about what the numbers should be.
+runningβ¦ (builds two yards and flies ten storms twice β give it a few seconds)+ + + + diff --git a/tools/garden_bench/bench.js b/tools/garden_bench/bench.js new file mode 100644 index 0000000..0c56e34 --- /dev/null +++ b/tools/garden_bench/bench.js @@ -0,0 +1,267 @@ +/** + * garden_bench β WHERE DOES THE PROTECTION ACTUALLY GO? [Lane C, SPRINT13 gate 1] + * + * The QA pass found one variable sighted three times: the sim's garden outcome + * barely responds to whether a sail is up (sail DEAD at t=3 on the funnelled + * buster still finished 83%). A's GARDEN_DRAIN comment names the suspect and + * says the lever is the shadow GEOMETRY, not the constant. This bench is the + * measurement that has to come before any fix. + * + * It drives the REAL chain β real site JSON, real world.js yard, real SailRig, + * real skyfx exposure helpers, main.js's exact drain arithmetic β because a + * bench that reimplements the drain measures a copy and proves nothing (the + * lesson balance.test.js paid for twice: the fictional yard, and the missing + * camera). + * + * For each yard Γ storm it flies two conditions and reports where they differ: + * HELD β an honest bed-covering quad on RATED steel, re-repaired every step + * so a corner failure can never contaminate a GEOMETRY measurement + * BARE β no sail at all, the floor + * ...and buckets rain/hail shadow-over-bed by wind speed, which is the actual + * question: protection is not one number, it decays as the wind builds. + * + * Deliberately a browser tool: the scoring chain needs `document`. Run it at + * tools/garden_bench/bench.html; it prints a table and dumps JSON to console. + */ + +import * as THREE from '../../web/world/vendor/three.module.js'; +import { RiggingSession } from '../../web/world/js/rigging.js'; +import { SailRig } from '../../web/world/js/sail.js'; +import { createSkyFx } from '../../web/world/js/skyfx.js'; +import { loadStorm, windForSite } from '../../web/world/js/weather.js'; +import { createWorld, loadSite } from '../../web/world/js/world.js'; +import { HARDWARE, FIXED_DT } from '../../web/world/js/contracts.js'; + +const [CARABINER, SHACKLE, RATED] = HARDWARE; + +// main.js's exact numbers. Duplicated ONLY so a divergence names itself; the +// bench asserts they still match main.js at the bottom of the report. +const GARDEN_DRAIN = 0.9; +const W_HAIL = 5.0; +const W_RAIN = 0.25; + +export const STORMS = [ + 'storm_01_gentle', 'storm_02_wildnight', 'storm_03_southerly', + 'storm_03b_earlybuster', 'storm_02b_icenight', +]; + +/** + * The honest bed-covering line in each yard β NOT the carport trap, NOT a rig + * that misses. backyard's is balance.test's COVER_QUAD (C's sweep, the only + * buyable one). site_02's is the site JSON's own _design note: the honest three + * posts + the gum tree, 58% cover at 32.6 mΒ². + */ +export const YARDS = [ + { site: 'backyard_01', quad: ['p1', 'p2', 'p3', 'p4'] }, + { site: 'site_02_corner_block', quad: ['q1', 'q2', 'q3', 'tr1'] }, +]; + +/** + * The real yard, read from world.js β never a copied anchor table, and never a + * FROZEN one. Two landmines this builder now closes (SPRINT13, both caught by + * D replaying through the real UI): + * 1. The SITE def rides along because its wind block is half the yard's + * weather: the venturi lives HERE, not in any storm def. Measured without + * it, site_02 is a different, easier yard (third harness to learn this; + * windForSite is the fix and the story). + * 2. The anchors are world.anchors THEMSELVES. The old remap to a static + * `sway: () => pos` froze the gum tree β a rig on a tree eats the tree's + * sway as dynamic load, and stripping it under-read every tr1 corner. + * The world is built on a re-pointable wind proxy (main.js-router style) + * so the sway closures sample whichever storm is currently flying. + */ +async function buildYard(siteId) { + const scene = new THREE.Scene(); + let current = { + sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4), + speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0, + gustTelegraph: () => null, eventsBetween: () => [], + }; + const proxy = { + sample: (p, t, o) => current.sample(p, t, o || new THREE.Vector3()), + speedAt: (p, t) => current.speedAt(p, t), + rainAt: (t) => current.rainAt(t), + rainMmPerHour: (t) => (current.rainMmPerHour ? current.rainMmPerHour(t) : 0), + gustTelegraph: (t) => current.gustTelegraph(t), + eventsBetween: (a, b) => current.eventsBetween(a, b), + setSheltersFromTrees() {}, + get seed() { return current.seed ?? 1; }, + get def() { return current.def ?? {}; }, + }; + const siteDef = await loadSite(siteId); + const world = createWorld(scene, { wind: proxy, site: siteDef }); + if (world.dress) { try { await world.dress(); } catch { /* graybox anchors are enough */ } } + const anchors = world.anchors; + return { + anchors, bed: world.gardenBed, ids: anchors.map((a) => a.id), + sunDir: world.sunDir, heightAt: world.heightAt, + siteDef, + use: (w) => { current = w; }, + }; +} + +/** + * Fly one condition and record the geometry as a time series. + * @param {'held'|'bare'} mode + */ +async function fly(yard, stormName, mode) { + const def = await loadStorm(stormName); + // windForSite, NOT createWind + storm-def venturi. The old line here read + // `def.wind.venturi` off the STORM def, where a venturi never lives β it + // LOOKED right and never fired, so every site_02 number this bench produced + // before 2026-07-18 was flown with the funnel OFF (D caught it replaying the + // $80 line through the real UI: 91.5 FULL became 39.8 TATTERED). Same bug + // B's site_audit shipped in Sprint 11. The helper carries the story. + const wind = windForSite(def, yard.siteDef, yard.anchors); + yard.use(wind); // point the yard's tree-sway closures at this flight's wind + + let rig = null; + if (mode === 'held') { + const s = new RiggingSession({ anchors: yard.anchors, budget: 9999 }); + for (const id of yard.quad) { + const r = s.rig(id); + if (!r.ok) return { skipped: `cannot rig ${id}: ${r.reason ?? 'refused'}` }; + } + // RATED on every corner: this is a geometry bench, so buy the strongest + // steel in the game and ignore the $80 budget on purpose. Money is B's + // question; this one is "where does the shadow land". + for (const id of yard.quad) { const r = s.setHardware(id, RATED); + if (!r.ok) throw new Error(`setHardware ${id} refused: ${r.reason} β flying hardware it did not buy`); } + s.setTension(1.0); + rig = s.commit(new SailRig({ anchors: yard.anchors, gridN: 10 })); + } + + // NO calm settle (correction, 2026-07-18): in the real game the rig does not + // exist before commit, and commit β attach β advance() β storm land in one + // keypress (sail.js attach, B's clock-reset comment). The attach transient + // rides the storm's own calm opening exactly as it does for a player β and + // because SailRig samples wind at its INTERNAL clock, a 12 s settle here + // skewed every storm sample 12 s off the authored curve. + + const camera = new THREE.PerspectiveCamera(60, 1.6, 0.1, 400); + camera.position.set(0, 2, 8); + const sky = createSkyFx({ wind, night: true, camera }); + + const bed = yard.bed; + const probe = new THREE.Vector3(bed.x, 1.7, bed.z); + let hp = 100, byHail = 0, byRain = 0, repairs = 0; + const rows = []; + + const steps = Math.round(def.duration / FIXED_DT); + for (let i = 0; i < steps; i++) { + const t = i * FIXED_DT; + if (rig) { + rig.step(FIXED_DT, wind, t); + // HELD means held. A corner that lets go turns this into a hardware + // measurement instead of a geometry one, so put it straight back. + for (let k = 0; k < rig.corners.length; k++) { + if (rig.corners[k].broken) { rig.repairCorner(k, RATED); repairs++; } + } + } + sky.step(FIXED_DT, t, rig ? { sail: rig } : {}); + + const hailExp = sky.gardenHailExposure(bed, t); + const rainExp = sky.gardenExposure(bed, t); + // main.js's exact drain + const dHail = W_HAIL * hailExp * GARDEN_DRAIN * FIXED_DT; + const dRain = W_RAIN * rainExp * GARDEN_DRAIN * FIXED_DT; + const total = Math.min(hp, dHail + dRain); + if (total > 0) { + const scale = total / (dHail + dRain); + byHail += dHail * scale; byRain += dRain * scale; + hp -= total; + } + + // sample the geometry a few times a second β the grids only rebuild at 10 Hz + if (i % 6 === 0) { + rows.push({ + t, + speed: wind.speedAt(probe, t), + rainShadow: sky.rainShadowOver(bed), + hailShadow: sky.hailShadowOver(bed), + rainExp, hailExp, + hail: sky.hailAmount, + }); + } + } + sky.dispose?.(); + return { + hp: Math.round(hp * 10) / 10, + byHail: Math.round(byHail * 10) / 10, + byRain: Math.round(byRain * 10) / 10, + repairs, + rows, + // the SUN coverage β reported for context only. It is emphatically not the + // rain/hail geometry, and the gap between them is half of what this bench + // exists to show. + cover: rig ? rig.coverageOver(bed, yard.sunDir, yard.heightAt) : 0, + }; +} + +/** Bucket a time series by wind speed β protection is not one number. */ +function byWind(rows) { + const buckets = [[0, 10], [10, 15], [15, 20], [20, 25], [25, 99]]; + return buckets.map(([lo, hi]) => { + const r = rows.filter((x) => x.speed >= lo && x.speed < hi); + if (!r.length) return { band: `${lo}-${hi}`, n: 0 }; + const mean = (f) => r.reduce((a, b) => a + f(b), 0) / r.length; + return { + band: `${lo}-${hi}`, + n: r.length, + kmh: `${Math.round(lo * 3.6)}-${Math.round(hi * 3.6)}`, + rainShadow: mean((x) => x.rainShadow), + hailShadow: mean((x) => x.hailShadow), + }; + }).filter((b) => b.n > 0); +} + +const f2 = (v) => (v == null ? ' β ' : v.toFixed(2).padStart(6)); +const f1 = (v) => (v == null ? ' β ' : v.toFixed(1).padStart(5)); + +export async function runBench(log) { + const out = []; + log('# GARDEN BENCH β where does the protection actually go?'); + log('# HELD = honest bed-covering quad, RATED steel, re-repaired every step (geometry only)'); + log('# BARE = no sail. Drain is main.js exact: hailΓ5.0 + rainΓ0.25, Γ0.9.\n'); + + for (const y of YARDS) { + const yard = await buildYard(y.site); + // Print the funnel in the header, B's audit.html pattern: a reader must be + // able to SEE which wind these numbers were flown in. + const vl = yard.siteDef.wind?.venturi ?? []; + const vtxt = vl.length + ? vl.map((v) => `throat (${v.x},${v.z}) gain ${v.gain} r${v.radius}`).join(' Β· ') + : 'none'; + log(`\n## ${y.site} bed ${yard.bed.w}Γ${yard.bed.d} at (${yard.bed.x}, ${yard.bed.z}) line ${y.quad.join('+')} venturi: ${vtxt}`); + + for (const storm of STORMS) { + const held = await fly({ ...yard, quad: y.quad }, storm, 'held'); + if (held.skipped) { log(` ${storm}: SKIPPED β ${held.skipped}`); continue; } + const bare = await fly({ ...yard, quad: y.quad }, storm, 'bare'); + const sep = bare.hp === 0 && held.hp === 0 ? 0 : held.hp - bare.hp; + out.push({ site: y.site, storm, held, bare, sep }); + + log(`\n ### ${storm} cover ${(held.cover * 100).toFixed(0)}% repairs ${held.repairs}`); + log(` GARDEN HP held ${f1(held.hp)} bare ${f1(bare.hp)} SEPARATION ${f1(sep)}`); + log(` held damage: hail ${f1(held.byHail)} rain ${f1(held.byRain)}`); + log(` bare damage: hail ${f1(bare.byHail)} rain ${f1(bare.byRain)}`); + log(' wind band km/h rainShadow(held) hailShadow(held)'); + for (const b of byWind(held.rows)) { + log(` ${b.band.padStart(6)} m/s ${b.kmh.padStart(8)} ${f2(b.rainShadow)} ${f2(b.hailShadow)}`); + } + } + } + + log('\n\n# THE HEADLINE'); + log('# site storm held bare sep'); + for (const r of out) { + log(`# ${r.site.padEnd(26)} ${r.storm.padEnd(22)} ${f1(r.held.hp)} ${f1(r.bare.hp)} ${f1(r.sep)}`); + } + console.log('GARDEN_BENCH_JSON', JSON.stringify(out.map((r) => ({ + site: r.site, storm: r.storm, held: r.held.hp, bare: r.bare.hp, sep: r.sep, + heldHail: r.held.byHail, heldRain: r.held.byRain, + bareHail: r.bare.byHail, bareRain: r.bare.byRain, + cover: r.held.cover, + })))); + return out; +} diff --git a/tools/garden_bench/probe.html b/tools/garden_bench/probe.html new file mode 100644 index 0000000..f81a7ea --- /dev/null +++ b/tools/garden_bench/probe.html @@ -0,0 +1,99 @@ + + +
runningβ¦+ + + diff --git a/tools/garden_bench/probe2.html b/tools/garden_bench/probe2.html new file mode 100644 index 0000000..2eddad7 --- /dev/null +++ b/tools/garden_bench/probe2.html @@ -0,0 +1,79 @@ + + +
runningβ¦+ + + diff --git a/tools/garden_bench/probe3.html b/tools/garden_bench/probe3.html new file mode 100644 index 0000000..4a68d94 --- /dev/null +++ b/tools/garden_bench/probe3.html @@ -0,0 +1,135 @@ + + +
runningβ¦+ + + diff --git a/tools/garden_bench/probe4.html b/tools/garden_bench/probe4.html new file mode 100644 index 0000000..5f53a8a --- /dev/null +++ b/tools/garden_bench/probe4.html @@ -0,0 +1,153 @@ + + +
runningβ¦+ + + diff --git a/tools/garden_bench/sweep.html b/tools/garden_bench/sweep.html new file mode 100644 index 0000000..0ce1e14 --- /dev/null +++ b/tools/garden_bench/sweep.html @@ -0,0 +1,132 @@ + + +
runningβ¦+ + + diff --git a/tools/garden_bench/sweep2.html b/tools/garden_bench/sweep2.html new file mode 100644 index 0000000..9cd9a43 --- /dev/null +++ b/tools/garden_bench/sweep2.html @@ -0,0 +1,125 @@ + + +
runningβ¦+ + + 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 cbbae45..4c2110c 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 /** @@ -190,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); @@ -342,8 +348,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 +389,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 +417,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); @@ -641,15 +661,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 @@ -657,11 +687,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; @@ -695,6 +729,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(); @@ -706,16 +742,20 @@ 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(); 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; }, + /** 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 @@ -792,6 +832,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 @@ -821,6 +871,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)) { @@ -913,7 +973,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) { @@ -922,8 +982,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; @@ -937,7 +1007,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); @@ -1015,7 +1085,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 ee6a125..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'; @@ -136,6 +137,112 @@ 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'); + }); + + // 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 + // 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 @@ -172,6 +279,86 @@ 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(); + }); + + // 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)'); + }); + + // 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 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