Merge remote-tracking branch 'origin/lane/c'

# Conflicts:
#	THREADS.md
This commit is contained in:
type-two 2026-07-18 14:26:16 +10:00
commit 68edc607e7
14 changed files with 1667 additions and 17 deletions

View File

@ -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 45 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.07.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 12. 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 ~515% 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.

View File

@ -0,0 +1,39 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>garden bench — where does the protection go?</title>
<style>
body { background:#11161a; color:#dde5ea; font:13px/1.45 ui-monospace, Menlo, monospace; padding:18px; }
h1 { font-size:15px; color:#7ee0ff; margin:0 0 4px; }
p.sub { color:#8ba0ad; margin:0 0 14px; }
pre { white-space:pre; margin:0; }
.sep { color:#ffb1ab; }
</style>
</head>
<body>
<h1>GARDEN BENCH — Lane C, Sprint 13 gate 1</h1>
<p class="sub">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.</p>
<pre id="out">running… (builds two yards and flies ten storms twice — give it a few seconds)</pre>
<script type="importmap">
{ "imports": {
"three": "../../web/world/vendor/three.module.js",
"three/addons/": "../../web/world/vendor/addons/"
} }
</script>
<script type="module">
import { runBench } from './bench.js';
const el = document.getElementById('out');
const lines = [];
const log = (s) => { lines.push(s); el.textContent = lines.join('\n'); };
el.textContent = '';
try {
await runBench(log);
document.title = 'garden bench — done';
} catch (e) {
log('\n\nBENCH THREW: ' + (e && e.stack || e));
document.title = 'garden bench — THREW';
}
</script>
</body>
</html>

267
tools/garden_bench/bench.js Normal file
View File

@ -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 .
*/
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;
}

View File

@ -0,0 +1,99 @@
<!doctype html>
<html>
<head><meta charset="utf-8"><title>shadow probe</title>
<style>body{background:#11161a;color:#dde5ea;font:13px/1.5 ui-monospace,Menlo,monospace;padding:18px}pre{white-space:pre}</style></head>
<body>
<pre id="out">running…</pre>
<script type="importmap">
{ "imports": {
"three": "../../web/world/vendor/three.module.js",
"three/addons/": "../../web/world/vendor/addons/"
} }
</script>
<script type="module">
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 { createWorld, loadSite } from '../../web/world/js/world.js';
import { FIXED_DT, HARDWARE } from '../../web/world/js/contracts.js';
const [,, RATED] = HARDWARE;
const el = document.getElementById('out');
const lines = []; const log = (s) => { lines.push(s); el.textContent = lines.join('\n'); };
el.textContent = '';
// TWO HARNESSES, ONE QUANTITY. rig.coverageOver(rect, straight-up sun) is the
// sail's true vertical projection over the bed, computed by raycast. And
// RainShadow.fractionOver(rect) with straight-down rain is the SAME quantity,
// computed by rasterising the sail's triangles into a grid. They must agree.
// If they don't, the shadow grid — which is what scores every rig — is lying.
try {
const scene = new THREE.Scene();
const calm = { 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: calm, site: await loadSite('backyard_01') });
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(`bed: centre (${bed.x}, ${bed.z}) size ${bed.w} x ${bed.d}\n`);
const s = new RiggingSession({ anchors, budget: 9999 });
for (const id of ['p1','p2','p3','p4']) s.rig(id);
for (const id of ['p1','p2','p3','p4']) { const r = s.setHardware(id, RATED);
if (!r.ok) throw new Error(`setHardware ${id} refused: ${r.reason}`); }
s.setTension(1.0);
const rig = s.commit(new SailRig({ anchors, gridN: 10 }));
// settle in dead air so the cloth is in steady state and nothing is moving
for (let i = 0; i < Math.round(12/FIXED_DT); i++) rig.step(FIXED_DT, calm, 0);
const corners = ['p1','p2','p3','p4'].map((id) => anchors.find((a)=>a.id===id));
log('quad corners:');
for (const c of corners) log(` ${c.id.padEnd(3)} (${c.pos.x.toFixed(2)}, ${c.pos.y.toFixed(2)}, ${c.pos.z.toFixed(2)})`);
// where is the cloth, really?
let minX=1e9,maxX=-1e9,minZ=1e9,maxZ=-1e9,minY=1e9,maxY=-1e9;
for (let i=0;i<rig.pos.length;i+=3){
minX=Math.min(minX,rig.pos[i]); maxX=Math.max(maxX,rig.pos[i]);
minY=Math.min(minY,rig.pos[i+1]); maxY=Math.max(maxY,rig.pos[i+1]);
minZ=Math.min(minZ,rig.pos[i+2]); maxZ=Math.max(maxZ,rig.pos[i+2]);
}
log(`\ncloth bbox: x ${minX.toFixed(2)}..${maxX.toFixed(2)} y ${minY.toFixed(2)}..${maxY.toFixed(2)} z ${minZ.toFixed(2)}..${maxZ.toFixed(2)}`);
log(`bed spans: x ${(bed.x-bed.w/2).toFixed(2)}..${(bed.x+bed.w/2).toFixed(2)} z ${(bed.z-bed.d/2).toFixed(2)}..${(bed.z+bed.d/2).toFixed(2)}`);
log(`tris: ${rig.tris.length/3} nodes: ${rig.pos.length/3}`);
// harness 1: raycast toward a sun straight overhead
const cov = rig.coverageOver(bed, { x:0, y:1, z:0 }, null);
// harness 2: rasterise down a straight-down rain vector
const sh = new RainShadow({ groundY: 0 });
sh.update(rig, 0, -1, 0);
const frac = sh.fractionOver(bed);
log(`\n--- THE SAME QUANTITY, TWO WAYS (vertical) ---`);
log(` rig.coverageOver(bed, straight-up sun) = ${cov.toFixed(4)}`);
log(` RainShadow.fractionOver(bed), rain down = ${frac.toFixed(4)}`);
log(` live=${sh.live} difference = ${Math.abs(cov-frac).toFixed(4)}`);
// how much of the grid did the rasteriser actually fill, vs the cloth's footprint?
let filled = 0; for (let i=0;i<sh.ceil.length;i++) if (sh.ceil[i]>0) filled++;
const cellA = (sh.half*2/sh.n)**2;
log(`\n grid cells filled: ${filled} => ${(filled*cellA).toFixed(1)} m2 of ground shadowed`);
log(` cloth footprint bbox area: ${((maxX-minX)*(maxZ-minZ)).toFixed(1)} m2`);
// sample the bed on a fine lattice both ways to see WHERE they differ
log(`\n--- bed lattice: 1 = shadowed, . = open (rows = z, cols = x) ---`);
for (let j=0;j<9;j++){
let rowS='', rowC='';
for (let i=0;i<13;i++){
const x = bed.x + ((i+0.5)/13 - 0.5)*bed.w;
const z = bed.z + ((j+0.5)/9 - 0.5)*bed.d;
const c = sh._idx(x,z);
rowS += (c>=0 && sh.ceil[c]>0) ? '1' : '.';
rowC += rig.coverageOver({x, z, w:0.001, d:0.001}, {x:0,y:1,z:0}, null) > 0.5 ? '1' : '.';
}
log(` grid ${rowS} raycast ${rowC}`);
}
} catch (e) { log('THREW: ' + (e && e.stack || e)); }
document.title = 'probe done';
</script>
</body></html>

View File

@ -0,0 +1,79 @@
<!doctype html>
<html>
<head><meta charset="utf-8"><title>shadow probe 2 — synthetic</title>
<style>body{background:#11161a;color:#dde5ea;font:13px/1.5 ui-monospace,Menlo,monospace;padding:18px}pre{white-space:pre}</style></head>
<body>
<pre id="out">running…</pre>
<script type="importmap">
{ "imports": {
"three": "../../web/world/vendor/three.module.js",
"three/addons/": "../../web/world/vendor/addons/"
} }
</script>
<script type="module">
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 { FIXED_DT, HARDWARE } from '../../web/world/js/contracts.js';
const [,, RATED] = HARDWARE;
const el = document.getElementById('out');
const lines = []; const log = (s) => { lines.push(s); el.textContent = lines.join('\n'); };
el.textContent = '';
// ISOLATE THE RASTERISER FROM THE LEVEL.
// probe.html showed the real yard's best quad sits over only 25% of the bed, so
// its shadow numbers can't tell a rasteriser bug from an anchor-placement fact.
// So: a SYNTHETIC square of anchors dead over the bed, at 4 m. This sail
// unambiguously covers the whole bed. Grid and raycast must BOTH read ~1.0.
// Anything less is the shadow model losing cloth it is standing under.
try {
const bed = { x: 0, z: 0, w: 6, d: 4 };
const mk = (id, x, z) => { const pos = { x, y: 4, z }; return { id, type: 'post', pos, sway: () => pos }; };
// a generous 10x8 square around a 6x4 bed — no edge effects, no excuses
const anchors = [mk('s1', -5, -4), mk('s2', 5, -4), mk('s3', 5, 4), mk('s4', -5, 4)];
const calm = { sample:(p,t,o)=>(o||new THREE.Vector3()).set(0,0,0), speedAt:()=>0, rainAt:()=>0,
rainMmPerHour:()=>0, gustTelegraph:()=>null, setSheltersFromTrees(){}, eventsBetween:()=>[] };
for (const gridN of [10, 16]) {
const s = new RiggingSession({ anchors, budget: 9999 });
for (const a of anchors) s.rig(a.id);
for (const a of anchors) { const r = s.setHardware(a.id, RATED);
if (!r.ok) throw new Error(`setHardware ${a.id} refused: ${r.reason}`); }
s.setTension(1.0);
const rig = s.commit(new SailRig({ anchors, gridN }));
for (let i = 0; i < Math.round(12/FIXED_DT); i++) rig.step(FIXED_DT, calm, 0);
let minY=1e9,maxY=-1e9;
for (let i=1;i<rig.pos.length;i+=3){ minY=Math.min(minY,rig.pos[i]); maxY=Math.max(maxY,rig.pos[i]); }
const cov = rig.coverageOver(bed, { x:0, y:1, z:0 }, null);
const sh = new RainShadow({ groundY: 0 });
sh.update(rig, 0, -1, 0);
const frac = sh.fractionOver(bed);
log(`\n=== gridN ${gridN} (${rig.tris.length/3} tris, ${rig.pos.length/3} nodes) cloth y ${minY.toFixed(2)}..${maxY.toFixed(2)} ===`);
log(` a 10x4 sail sitting dead over a 6x4 bed. Truth for both numbers is 1.00.`);
log(` rig.coverageOver(bed, straight up) = ${cov.toFixed(4)} ${cov > 0.99 ? 'ok' : '<-- LOSES CLOTH'}`);
log(` RainShadow.fractionOver(bed), down = ${frac.toFixed(4)} ${frac > 0.99 ? 'ok' : '<-- LOSES CLOTH'}`);
// where are the holes? print the grid cells across the bed's footprint
log(' grid cells over the bed (1 = shadowed):');
for (let j=0;j<7;j++){
let row='';
for (let i=0;i<15;i++){
const x = bed.x + ((i+0.5)/15 - 0.5)*bed.w;
const z = bed.z + ((j+0.5)/7 - 0.5)*bed.d;
const c = sh._idx(x,z);
row += (c>=0 && sh.ceil[c]>0) ? '1' : '.';
}
log(` ${row}`);
}
let filled=0; for (let i=0;i<sh.ceil.length;i++) if (sh.ceil[i]>0) filled++;
const cellA = (sh.half*2/sh.n)**2;
log(` cells filled ${filled} => ${(filled*cellA).toFixed(1)} m2 shadowed; the sail is 10x8 = 80 m2`);
}
} catch (e) { log('THREW: ' + (e && e.stack || e)); }
document.title = 'probe2 done';
</script>
</body></html>

View File

@ -0,0 +1,135 @@
<!doctype html>
<html>
<head><meta charset="utf-8"><title>probe3 — does the covering line hold?</title>
<style>body{background:#11161a;color:#dde5ea;font:13px/1.5 ui-monospace,Menlo,monospace;padding:18px}pre{white-space:pre}</style></head>
<body>
<pre id="out">running…</pre>
<script type="importmap">
{ "imports": {
"three": "../../web/world/vendor/three.module.js",
"three/addons/": "../../web/world/vendor/addons/"
} }
</script>
<script type="module">
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 { FIXED_DT, HARDWARE } from '../../web/world/js/contracts.js';
const [CARABINER, SHACKLE, RATED] = HARDWARE;
const el = document.getElementById('out');
const lines = []; const log = (s) => { lines.push(s); el.textContent = lines.join('\n'); };
el.textContent = '';
// THE DECIDING QUESTION.
// sweep.html found quads that cover 100% of the bed in BOTH yards, while the
// canonical line covers 25%. B's Sprint-9 sweep says every bed-covering quad has
// a corner over 6.5 kN — unholdable even on RATED, the best steel in the game —
// and that p1..p4 is the ONLY buyable line. If that's true, then "coverage" and
// "holdability" are anti-correlated by construction, the affordable rig can only
// ever shade a quarter of the bed, and NO shadow-model change can make the
// garden respond to rigging. That is a very different bug from the one the
// sprint chartered, so it has to be measured, not inherited.
//
// 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(world, ids, stormName, hw, siteDef, use) {
const def = await loadStorm(stormName);
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;
for (const id of ids) { const r = s.setHardware(id, 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 }));
// no calm settle: commit → attach → storm is one keypress in the real game
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 peak = ids.map(() => 0);
let hp = 100, lost = 0, hsum = 0, hn = 0, brokeAt = null;
const steps = Math.round(def.duration / FIXED_DT);
for (let i=0;i<steps;i++){
const t = i * FIXED_DT;
rig.step(FIXED_DT, wind, t);
sky.step(FIXED_DT, t, { sail: rig });
for (let k=0;k<rig.corners.length;k++) if ((rig.corners[k].load||0) > peak[k]) peak[k] = rig.corners[k].load;
if (brokeAt === null && rig.corners.some((c)=>c.broken)) brokeAt = t;
// the hail shadow WHILE it is hailing is the only one that scores
if (sky.hailAmount > 0) { hsum += sky.hailShadowOver(bed); hn++; }
const ex = W_HAIL*sky.gardenHailExposure(bed,t) + W_RAIN*sky.gardenExposure(bed,t);
if (ex > 0) hp = Math.max(0, hp - GARDEN_DRAIN*ex*FIXED_DT);
}
lost = rig.corners.filter((c)=>c.broken).length;
sky.dispose?.();
// Peaks are indexed by rig.corners — the RING order commit() reordered the
// picks into — so label them with corners[k].anchorId, NOT the input order.
// (Found at re-verify: p1..p4 flies as p4,p2,p3,p1; an input-order label
// attributes each corner's load to the wrong anchor.)
return { hp: Math.round(hp*10)/10, lost, peak: peak.map((p)=>p/1000),
cornerIds: rig.corners.map((c)=>c.anchorId),
hailShadow: hn ? hsum/hn : 0, brokeAt };
}
try {
for (const [siteId, lineSet] of [
['backyard_01', { cover100: ['h1','t1','t2b','p2'], cover100b: ['h1','h3','p1','p2'], canonical: ['p1','p2','p3','p4'] }],
['site_02_corner_block', { posts: ['q1','q2','q3','q4'], cover100: ['tr1','q1','q3','q4'], canonical: ['q1','q2','q3','tr1'] }],
]) {
const scene = new THREE.Scene();
const calm = { sample:(p,t,o)=>(o||new THREE.Vector3()).set(0,0,0), speedAt:()=>0, rainAt:()=>0,
rainMmPerHour:()=>0, gustTelegraph:()=>null, setSheltersFromTrees(){}, eventsBetween:()=>[] };
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 bed = world.gardenBed;
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(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];
log(` ${label.padEnd(10)} ${ids.join('+').padEnd(18)} ${storm.padEnd(22)} hp ${String(r.hp).padStart(5)}`
+ ` (bare ${bare}, sep ${(r.hp-bare).toFixed(1).padStart(5)}) lost ${r.lost}/4`
+ `${r.brokeAt!==null?` @t=${r.brokeAt.toFixed(0)}`:' '} hailShadow ${r.hailShadow.toFixed(2)} peak kN: ${over}`);
}
log('');
}
}
} catch (e) { log('THREW: ' + (e && e.stack || e)); }
document.title = 'probe3 done';
</script>
</body></html>

View File

@ -0,0 +1,153 @@
<!doctype html>
<html>
<head><meta charset="utf-8"><title>probe4 — the $80 line that saves the garden</title>
<style>body{background:#11161a;color:#dde5ea;font:13px/1.5 ui-monospace,Menlo,monospace;padding:18px}pre{white-space:pre}</style></head>
<body>
<pre id="out">running…</pre>
<script type="importmap">
{ "imports": {
"three": "../../web/world/vendor/three.module.js",
"three/addons/": "../../web/world/vendor/addons/"
} }
</script>
<script type="module">
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 { FIXED_DT, HARDWARE, START_BUDGET } from '../../web/world/js/contracts.js';
const [CARABINER, SHACKLE, RATED] = HARDWARE;
const el = document.getElementById('out');
const lines = []; const log = (s) => { lines.push(s); el.textContent = lines.join('\n'); };
el.textContent = '';
// 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 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
for (const [id] of plan) {
const r = s.rig(id);
if (!r.ok) return { err: `rig ${id}: ${r.reason}` };
}
for (const [id, hw] of plan) {
const r = s.setHardware(id, hw);
if (!r.ok) return { err: `setHardware ${id} ${hw.name}: ${r.reason}` };
}
s.setTension(1.0);
const spent = START_BUDGET - s.budget;
const rig = s.commit(new SailRig({ anchors, gridN: 10 }));
// NO calm settle: in the real game the rig does not exist until commit, and
// commit → attach → advance() → storm land in one keypress (B's clock-reset
// comment, sail.js attach). The attach transient rides the storm's own calm
// opening, exactly as it does for a player.
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 peak = plan.map(() => 0);
let hp = 100, hsum = 0, hn = 0;
const steps = Math.round(def.duration / FIXED_DT);
for (let i=0;i<steps;i++){
const t = i * FIXED_DT;
rig.step(FIXED_DT, wind, t);
sky.step(FIXED_DT, t, { sail: rig });
for (let k=0;k<rig.corners.length;k++) if ((rig.corners[k].load||0) > peak[k]) peak[k] = rig.corners[k].load;
if (sky.hailAmount > 0) { hsum += sky.hailShadowOver(bed); hn++; }
const ex = 5.0*sky.gardenHailExposure(bed,t) + 0.25*sky.gardenExposure(bed,t);
if (ex > 0) hp = Math.max(0, hp - 0.9*ex*FIXED_DT);
}
const lost = rig.corners.filter((c)=>c.broken).length;
sky.dispose?.();
// Peaks are indexed by rig.corners — the RING order commit() reordered the
// picks into — so label them with corners[k].anchorId, NOT the plan order.
// (Found at re-verify: the $80 line flies as q1,tr1,q3,q4 — a plan-order
// label swaps the two RATED corners' loads. q3/q4 were unaffected.)
return { hp: Math.round(hp*10)/10, lost, spent, peak: peak.map((p)=>p/1000),
cornerIds: rig.corners.map((c)=>c.anchorId), hailShadow: hn?hsum/hn:0 };
}
// main.js's own win rule
const WIN = (hp, lost) => hp >= 50 && lost < 2;
const state = (hp) => hp > 66 ? 'FULL' : hp > 33 ? 'tattered' : 'DEAD';
try {
const scene = new THREE.Scene();
// 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 bed = world.gardenBed;
const PLANS = {
'THE $80 LINE tr1=rated q1=rated q3=shackle q4=carabiner':
[['tr1',RATED],['q1',RATED],['q3',SHACKLE],['q4',CARABINER]],
'all-shackle tr1+q1+q3+q4 ($60)':
[['tr1',SHACKLE],['q1',SHACKLE],['q3',SHACKLE],['q4',SHACKLE]],
'canonical q1+q2+q3+tr1, best steel $80 can buy':
[['q1',RATED],['q2',SHACKLE],['q3',CARABINER],['tr1',RATED]],
};
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(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)}`
+ ` (bare ${String(bare).padStart(4)} ${state(bare)}) sep ${(r.hp-bare).toFixed(1).padStart(5)}`
+ ` lost ${r.lost}/4 hailShadow ${r.hailShadow.toFixed(2)} ${WIN(r.hp,r.lost)?'WIN ':'LOSS'}`
+ ` peak kN: ${r.peak.map((p,i)=>`${r.cornerIds[i]} ${p.toFixed(2)}`).join(' ')}`);
}
}
} catch (e) { log('THREW: ' + (e && e.stack || e)); }
document.title = 'probe4 done';
</script>
</body></html>

View File

@ -0,0 +1,132 @@
<!doctype html>
<html>
<head><meta charset="utf-8"><title>quad sweep — can ANY line cover the bed?</title>
<style>body{background:#11161a;color:#dde5ea;font:13px/1.5 ui-monospace,Menlo,monospace;padding:18px}pre{white-space:pre}</style></head>
<body>
<pre id="out">running…</pre>
<script type="importmap">
{ "imports": {
"three": "../../web/world/vendor/three.module.js",
"three/addons/": "../../web/world/vendor/addons/"
} }
</script>
<script type="module">
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 { createWorld, loadSite } from '../../web/world/js/world.js';
import { FIXED_DT, HARDWARE } from '../../web/world/js/contracts.js';
const [,, RATED] = HARDWARE;
const el = document.getElementById('out');
const lines = []; const log = (s) => { lines.push(s); el.textContent = lines.join('\n'); };
el.textContent = '';
// THE QUESTION probe.html forces: the yard's canonical "best bed-covering quad"
// sits over 25% of the bed vertically, and hail falls vertically — so the sail
// can never stop more than a quarter of what kills the garden. Is that the
// LEVEL's ceiling, or did the canonical line just pick badly?
//
// Sweep every 4-anchor combination. Stage 1 is a cheap polygon-overlap proxy
// (the cloth lives roughly inside its anchors' hull), stage 2 flies the real
// cloth + real shadow grid for the best candidates. Cheap-then-real, because
// C(n,4) settles would take an hour and the proxy only has to RANK.
const clip = (poly, rect) => {
// SutherlandHodgman against the bed rect, then shoelace. Exact for convex.
const [x0, x1] = [rect.x - rect.w/2, rect.x + rect.w/2];
const [z0, z1] = [rect.z - rect.d/2, rect.z + rect.d/2];
const edges = [
(p) => p.x >= x0, (p) => p.x <= x1, (p) => p.z >= z0, (p) => p.z <= z1,
];
const isect = [
(a, b) => ({ x: x0, z: a.z + (b.z - a.z) * (x0 - a.x) / (b.x - a.x) }),
(a, b) => ({ x: x1, z: a.z + (b.z - a.z) * (x1 - a.x) / (b.x - a.x) }),
(a, b) => ({ x: a.x + (b.x - a.x) * (z0 - a.z) / (b.z - a.z), z: z0 }),
(a, b) => ({ x: a.x + (b.x - a.x) * (z1 - a.z) / (b.z - a.z), z: z1 }),
];
let out = poly;
for (let e = 0; e < 4; e++) {
const inp = out; out = [];
for (let i = 0; i < inp.length; i++) {
const a = inp[i], b = inp[(i + 1) % inp.length];
const ain = edges[e](a), bin = edges[e](b);
if (ain && bin) out.push(b);
else if (ain && !bin) out.push(isect[e](a, b));
else if (!ain && bin) { out.push(isect[e](a, b)); out.push(b); }
}
if (!out.length) return 0;
}
let s = 0;
for (let i = 0; i < out.length; i++) {
const a = out[i], b = out[(i + 1) % out.length];
s += a.x * b.z - b.x * a.z;
}
return Math.abs(s) / 2;
};
// convex hull (the cloth spans its anchors' hull, so order the quad sanely)
const hull = (pts) => {
const p = pts.slice().sort((a, b) => a.x - b.x || a.z - b.z);
const cross = (o, a, b) => (a.x - o.x) * (b.z - o.z) - (a.z - o.z) * (b.x - o.x);
const lo = []; for (const q of p) { while (lo.length >= 2 && cross(lo[lo.length-2], lo[lo.length-1], q) <= 0) lo.pop(); lo.push(q); }
const up = []; for (const q of p.slice().reverse()) { while (up.length >= 2 && cross(up[up.length-2], up[up.length-1], q) <= 0) up.pop(); up.push(q); }
lo.pop(); up.pop(); return lo.concat(up);
};
try {
for (const siteId of ['backyard_01', 'site_02_corner_block']) {
const scene = new THREE.Scene();
const calm = { 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: calm, site: await loadSite(siteId) });
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;
const bedArea = bed.w * bed.d;
log(`\n\n######## ${siteId} bed ${bed.w}x${bed.d} @ (${bed.x}, ${bed.z}) = ${bedArea} m2 ${anchors.length} anchors`);
// stage 1: rank every 4-combo by hull-over-bed overlap
const cands = [];
const n = anchors.length;
for (let a=0;a<n;a++) for (let b=a+1;b<n;b++) for (let c=b+1;c<n;c++) for (let d=c+1;d<n;d++) {
const q = [anchors[a], anchors[b], anchors[c], anchors[d]];
const h = hull(q.map((x) => ({ x: x.pos.x, z: x.pos.z })));
if (h.length < 3) continue;
const over = clip(h, bed) / bedArea;
cands.push({ ids: q.map((x)=>x.id), over });
}
cands.sort((p, q) => q.over - p.over);
log(` swept ${cands.length} quads. Top 8 by hull-over-bed (proxy):`);
for (const c of cands.slice(0, 8)) log(` ${c.ids.join('+').padEnd(24)} hull covers ${(c.over*100).toFixed(0)}% of bed`);
// stage 2: fly the real cloth for the best few + the canonical line
const canonical = siteId === 'backyard_01' ? ['p1','p2','p3','p4'] : ['q1','q2','q3','tr1'];
const tryList = [];
for (const c of cands.slice(0, 5)) tryList.push(c.ids);
if (!tryList.some((t) => t.join()===canonical.join())) tryList.push(canonical);
log(`\n REAL cloth + REAL shadow grid (settled 12 s, dead air, vertical rain):`);
log(` line hull% coverageOver(up) RainShadow.fractionOver`);
for (const ids of tryList) {
const s = new RiggingSession({ anchors, budget: 9999 });
let ok = true;
for (const id of ids) if (!s.rig(id).ok) { ok = false; break; }
if (!ok) { log(` ${ids.join('+').padEnd(24)} REFUSED by the shop`); continue; }
for (const id of ids) { const r = s.setHardware(id, RATED);
if (!r.ok) throw new Error(`setHardware ${id} RATED refused: ${r.reason} — the bench is flying hardware it did not buy`); }
s.setTension(1.0);
const rig = s.commit(new SailRig({ anchors, gridN: 10 }));
for (let i=0;i<Math.round(12/FIXED_DT);i++) rig.step(FIXED_DT, calm, 0);
const cov = rig.coverageOver(bed, {x:0,y:1,z:0}, null);
const sh = new RainShadow({ groundY: 0 }); sh.update(rig, 0, -1, 0);
const frac = sh.fractionOver(bed);
const hp = hull(ids.map((id)=>{const a=anchors.find((z)=>z.id===id);return {x:a.pos.x,z:a.pos.z};}));
const over = clip(hp, bed)/bedArea;
const mark = ids.join()===canonical.join() ? ' <-- the canonical line' : '';
log(` ${ids.join('+').padEnd(24)} ${(over*100).toFixed(0).padStart(4)}% ${cov.toFixed(3).padStart(12)} ${frac.toFixed(3).padStart(18)}${mark}`);
}
}
} catch (e) { log('THREW: ' + (e && e.stack || e)); }
document.title = 'sweep done';
</script>
</body></html>

View File

@ -0,0 +1,125 @@
<!doctype html>
<html>
<head><meta charset="utf-8"><title>sweep2 — hold AND cover?</title>
<style>body{background:#11161a;color:#dde5ea;font:13px/1.5 ui-monospace,Menlo,monospace;padding:18px}pre{white-space:pre}</style></head>
<body>
<pre id="out">running…</pre>
<script type="importmap">
{ "imports": {
"three": "../../web/world/vendor/three.module.js",
"three/addons/": "../../web/world/vendor/addons/"
} }
</script>
<script type="module">
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 { 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;
const el = document.getElementById('out');
const lines = []; const log = (s) => { lines.push(s); el.textContent = lines.join('\n'); };
el.textContent = '';
// THE PARETO QUESTION, and the whole of gate 1.
//
// probe2 proved the shadow model is sound (a sail over the bed reads 1.000 by
// raycast AND by the grid). probe3 found the variable: _checkFailure compares
// load against hw.rating * anchor.ratingHint, and the HOUSE fascia's hint is
// 0.35 — so a $30 RATED shackle is worth 2.3 kN on the house and 6.5 kN on a
// post. Every quad that covers the bed hangs off the house.
//
// So: sweep every 4-anchor line on the BEST steel in the game, settle it the way
// prep does, and ask both questions at once — does it HOLD, and does it COVER?
// A quad that covers 100% while tearing itself apart in prep covers nothing.
// This is the table the garden's whole design rests on and nobody has ever
// printed it.
try {
for (const siteId of ['backyard_01', 'site_02_corner_block']) {
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 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;
const bed = world.gardenBed;
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.
// 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;
for (let a=0;a<n;a++) for (let b=a+1;b<n;b++) for (let c=b+1;c<n;c++) for (let d=c+1;d<n;d++) {
const ids = [anchors[a].id, anchors[b].id, anchors[c].id, anchors[d].id];
const s = new RiggingSession({ anchors, budget: 9999 });
let ok = true;
for (const id of ids) if (!s.rig(id).ok) { ok = false; break; }
if (!ok) continue;
for (const id of ids) { const r = s.setHardware(id, RATED);
if (!r.ok) throw new Error(`setHardware ${id} RATED refused: ${r.reason} — the bench is flying hardware it did not buy`); }
s.setTension(1.0);
let rig;
try { rig = s.commit(new SailRig({ anchors, gridN: 10 })); } catch { continue; }
// settle exactly like prep: calm day, running clock, 12 s
let prepT = 0, peak = 0;
for (let i=0;i<Math.round(12/FIXED_DT);i++){
rig.step(FIXED_DT, calmWind, prepT % 3); prepT += FIXED_DT;
for (const co of rig.corners) if ((co.load||0) > peak) peak = co.load;
}
const broke = rig.corners.filter((co)=>co.broken).length;
const cov = rig.coverageOver(bed, {x:0,y:1,z:0}, null); // vertical: what hail sees
const sh = new RainShadow({ groundY: 0 }); sh.update(rig, 0, -1, 0);
const frac = sh.fractionOver(bed);
rows.push({ ids, broke, cov, frac, peak: peak/1000 });
}
const survived = rows.filter((r) => r.broke === 0);
log(`\n ${rows.length} quads swept on RATED steel. ${survived.length} survive PREP on the calm day.`);
log(`\n --- best COVER among quads that actually HOLD (this is the real ceiling) ---`);
log(` line vert.cover shadow peak kN broke`);
for (const r of survived.sort((p,q)=>q.cov-p.cov).slice(0,10)) {
log(` ${r.ids.join('+').padEnd(22)} ${(r.cov*100).toFixed(0).padStart(7)}% ${r.frac.toFixed(3).padStart(6)} ${r.peak.toFixed(2).padStart(7)} ${r.broke}`);
}
log(`\n --- best COVER overall, holding or not (what the sprint assumed was available) ---`);
log(` line vert.cover shadow peak kN broke`);
for (const r of rows.slice().sort((p,q)=>q.cov-p.cov).slice(0,6)) {
log(` ${r.ids.join('+').padEnd(22)} ${(r.cov*100).toFixed(0).padStart(7)}% ${r.frac.toFixed(3).padStart(6)} ${r.peak.toFixed(2).padStart(7)} ${r.broke}${r.broke?' <-- tears in PREP':''}`);
}
const canonical = siteId === 'backyard_01' ? 'p1+p2+p3+p4' : 'q1+q2+q3+tr1';
const cn = rows.find((r)=>r.ids.join('+')===canonical);
if (cn) log(`\n the canonical line ${canonical}: cover ${(cn.cov*100).toFixed(0)}% shadow ${cn.frac.toFixed(3)} peak ${cn.peak.toFixed(2)} kN broke ${cn.broke}`);
}
} catch (e) { log('THREW: ' + (e && e.stack || e)); }
document.title = 'sweep2 done';
</script>
</body></html>

View File

@ -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);
}

View File

@ -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;
},
};

View File

@ -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();

View File

@ -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

View File

@ -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