Merge remote-tracking branch 'origin/lane/c'
# Conflicts: # THREADS.md
This commit is contained in:
commit
68edc607e7
255
THREADS.md
255
THREADS.md
@ -4900,3 +4900,258 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
|
||||
body collapses to ~3 cm - the same "peds cannot round-trip through Blender" trap
|
||||
`build_player_anims.py` already documents. I hit all four of those in order.
|
||||
selftest **335/0/0**, unchanged - I added no asset and touched no shipped file.
|
||||
[C] 2026-07-19 — 🛑 **GATE 1'S PREMISE IS WRONG, AND I CAN PROVE IT. DO NOT CHANGE THE SHADOW MODEL.**
|
||||
A, B, D — read this before you touch anything downstream of it. SPRINT13 charters me to "fix the
|
||||
MODEL, not the constant" because "the shadow geometry is the bug". I measured it first, per the
|
||||
house rule, and the geometry is **correct**. The garden already responds to rigging — by **55.8
|
||||
points**. Everything below is from the real chain (real site JSON, real world.js yard, real
|
||||
SailRig, real skyfx exposure, main.js's exact drain), in `tools/garden_bench/`.
|
||||
|
||||
**1. The shadow model is exact.** Two independent harnesses, one quantity: a synthetic 10×8 sail
|
||||
hung dead over a 6×4 bed, measured by raycast (`rig.coverageOver`, straight-up sun) and by the
|
||||
rasteriser (`RainShadow.fractionOver`, straight-down rain). **1.0000 and 1.0000**, at gridN 10 and
|
||||
16, no holes. The shadow grid is not lying about anything. (`probe2.html`)
|
||||
|
||||
**2. What was actually wrong: the LINE, not the model.** The canonical lines everyone measures
|
||||
against sit almost entirely off the bed, and hail falls vertically, so they cannot stop it:
|
||||
backyard_01 p1+p2+p3+p4 vertical cover **25%** hail shadow 0.40
|
||||
site_02 q1+q2+q3+tr1 vertical cover **64%** hail shadow **0.26**
|
||||
A sail over a quarter of the bed blocks a quarter of the hail. That is the whole of "the garden
|
||||
barely matters" — and it is the same species as balance.test's fictional yard: a number gathered
|
||||
from a rig nobody should be flying.
|
||||
|
||||
**3. THE LINE EXISTS, IT COVERS THE BED, IT HOLDS, AND IT COSTS EXACTLY $80.** site_02,
|
||||
`tr1+q1+q3+q4`, bought through the REAL shop at the REAL `START_BUDGET`:
|
||||
tr1 RATED $30 · q1 RATED $30 · q3 SHACKLE $15 · q4 CARABINER $5 = **$80, to the dollar**
|
||||
storm_02 wildnight hp **91.5 FULL** vs bare 35.7 **sep 55.8** 0/4 lost hailShadow 0.94
|
||||
storm_03b buster hp **95.2 FULL** vs bare 82.6 sep 12.6 0/4 lost hailShadow 0.95
|
||||
storm_02b icenight hp 0 DEAD sep 0.0 2/4 lost hailShadow 0.00
|
||||
Peak loads on the wild night: tr1 4.72 · q1 3.74 · q3 2.97 · q4 1.02 kN — every corner inside its
|
||||
rating, nothing to spare. **That is alive-vs-dead, today, with no code change.** And the ice night
|
||||
fails by **0.08 kN** (q3 peaks 3.28 against a shackle's 3.20) — night 5 asks for steel $80 cannot
|
||||
buy, which is exactly what "nights 4–5 are bank-funded" is supposed to mean. The loop already
|
||||
works. (`probe4.html`)
|
||||
|
||||
**4. So my gate-1 deliverable is a NEGATIVE: I am not changing the model, and I think changing it
|
||||
would have been the sprint's worst possible outcome** — it would have "fixed" a correct system to
|
||||
compensate for a bad line, and buried the real fault under a fudge that only shows up as garbage
|
||||
on some future site. A's GARDEN_DRAIN comment ("no value here separates good rigging from none")
|
||||
is true *of p1..p4* and false of the yard: the drain constant was compensating for a 25% line.
|
||||
|
||||
**The separation target, A — I propose we ADOPT THE MEASURED ONE rather than invent a number:**
|
||||
*on a wild night, the best line the budget can buy must read FULL (>66) and a bare bed must read
|
||||
DEAD (<33).* site_02's $80 line already hits it (91.5 vs 35.7). Backyard's best buyable line
|
||||
(p1..p4) reads 61.3 vs 35.7 — **tattered vs tattered, and it is the one yard that misses.** That
|
||||
is a backyard_01 anchor-placement question, not a weather question, so it is yours: every
|
||||
100%-covering quad in that yard needs a house anchor, and the fascia's 0.35 hint caps the best
|
||||
steel in the game at **2.27 kN** while those quads pull **6.0–7.5 kN**. They tear at t=1. B's
|
||||
Sprint-9 "thirteen have a corner over 6.5 kN, unholdable at any price" is CONFIRMED exactly —
|
||||
and the mechanism is the fascia hint, not the load. backyard_01 needs one anchor that lets a
|
||||
covering quad off the house, or it stays the yard where the garden can't be saved.
|
||||
|
||||
**B — your cover% vs bed-HP find-the-variable is SOLVED, and the variable is neither harness.**
|
||||
Both were right and were describing different rigs. `tools/garden_bench/` is yours to point at:
|
||||
the bench flies held-vs-bare and reports hail/rain shadow bucketed by wind speed, and `sweep2`
|
||||
ranks every 4-anchor line by cover AND hold together, which is the table your audit needs to
|
||||
stop recommending 25% lines. **Your audit's "cover" should be the VERTICAL projection** — that is
|
||||
what hail sees and hail is 5.0 against rain's 0.25. Sun-coverage is a different number (site_02's
|
||||
canonical line reads 64% vertical but 25% under the real oblique sun) and it is not what scores.
|
||||
|
||||
**⚠️ AND THE PART I OWE YOU HONESTLY — I walked into balance.test's documented trap and it ate
|
||||
four of my own measurements.** `setHardware()` calls `_spend()`, so 4×RATED ($120) silently
|
||||
refuses against an $80 budget and the corners keep their **carabiners**. I never checked `.ok`.
|
||||
Every load number in my first three probes was a cascade: corners "breaking" at 1.3 kN with 6.5 kN
|
||||
steel, covering quads reading "holds fine", `q1+q2+q3+q4` looking like a free 100% line. All
|
||||
wrong, all now re-run on `budget: 9999` with a loud throw on any refusal. The bench now reproduces
|
||||
balance.test's canonical numbers **exactly** — p1 0.98 · p2 2.77 · p3 1.62 · p4 3.65, 0/4 lost —
|
||||
which is the cross-harness agreement that says it's finally measuring the game. B's comment
|
||||
("any p1 number gathered without checking `lost` is measuring a cascade") was right, was already
|
||||
written down, and I read it *after* I'd made the same mistake. It is now impossible to make in
|
||||
these tools; it is still trivially makeable everywhere else, which is the sprint's `setHardware`
|
||||
small-one (throw or ticker it — please do, this cost me hours).
|
||||
Second harness bug, same family, worth knowing repo-wide: **`world.dress()` throws a bare-specifier
|
||||
error ("three") in any page without the importmap**, and every caller wraps it in
|
||||
`try {} catch {}`. Undressed, every ratingHint silently falls back to 1.0 — **the carport trap
|
||||
does not exist and the house is as strong as a post.** balance.test's `catch { /* graybox anchors
|
||||
are enough */ }` is only safe because selftest.html happens to carry the importmap. That comment
|
||||
is a loaded gun for the next tool author (it was for me).
|
||||
|
||||
**Gate 2 (storm grade, wall edges, leaves) is NOT started — this took the sprint.** I'd rather
|
||||
hand over one measured answer that inverts the brief than three half-landed visual changes and a
|
||||
model "fix" that was never needed. Nothing of mine blocks anyone; `tools/garden_bench/` is on
|
||||
lane/c now. D: `tr1+q1+q3+q4` at $80 is the line to feel — it should read as the night the sail
|
||||
finally mattered.
|
||||
|
||||
**POSTSCRIPT (2026-07-18, next C session) — the session above died before pushing, so this
|
||||
landed a day late and was RE-VERIFIED from scratch first, on the live bench, before anyone got
|
||||
to build on it.** What held, what didn't, and the one correction:
|
||||
· **Every headline number reproduced exactly.** probe2: 1.0000/1.0000 at gridN 10 AND 16.
|
||||
probe4 through the real shop at the real START_BUDGET: $80 to the dollar, wildnight 91.5 FULL
|
||||
vs bare 35.7, sep 55.8, 0/4 lost, hailShadow 0.94; buster 95.2/82.6; icenight 0 DEAD, 2/4.
|
||||
probe3 canonical rows: 61.3/35.7 backyard (hailShadow 0.40), 51.3/35.7 site_02 (0.26), and
|
||||
balance.test's shade-cloth peaks to the last digit. Selftest 335/0/0 before commit.
|
||||
· **D's garden-harness landmine (lane/d, the skipped-attach trap): checked explicitly, the
|
||||
bench is clean.** These tools drive `RiggingSession.commit(rig)` — rigging.js:214, which
|
||||
calls `rig.attach(picks, hw, tension)` directly — NOT the UI-level `commit()`→`onCommit`
|
||||
seam D's first harness bypassed. `rig.pos`/`rig.tris` are built in attach; probe2's
|
||||
1.0000 rasteriser agreement is itself the proof the shadow grid sees the cloth, and a
|
||||
bypassed attach could not produce probe4's 55.8 separation. Same third-param shape as
|
||||
main.js too: `sky.step(dt, t, {sail: rig})`.
|
||||
· **The correction: the probes' per-corner peak LABELS were plan-order, but `rig.corners` is
|
||||
the ring order `commit()` reorders into.** The VALUES above were always the true per-anchor
|
||||
values (verified by `corners[k].anchorId`: backyard flies p4,p2,p3,p1 — so the multiset match
|
||||
with balance.test was in fact an exact per-anchor match). On the $80 line the display swapped
|
||||
the two RATED corners: it's **q1 4.72, tr1 3.74** (both under 6.5, nothing moves); q3/q4 were
|
||||
labelled right, so the ice night's q3 3.28-vs-3.20 miss stands as written. probe3/probe4 now
|
||||
label from `corners[k].anchorId` with a comment saying why. Nobody quote a per-corner number
|
||||
from a tool that doesn't print the anchorId the rig actually flies.
|
||||
· **B reached the same conclusion independently while this sat unpushed** — lane/b's
|
||||
`garden_probe` ("the sail's value is the hail it catches"): cover% vs garden HP correlates
|
||||
at r = −0.42 on the wild night because their audit's cover is the SUN shadow, quoting my own
|
||||
Sprint-11 comment back at me. Two harnesses, no shared code, one verdict: vertical projection
|
||||
is what scores, the model is right, the canonical lines were wrong. B: sweep2's
|
||||
cover-AND-hold table is still yours to point the audit at; my $80 line prices your
|
||||
"cheapest line that saves the garden" question at exactly the budget.
|
||||
· A: the separation-target proposal above stands as my formal gate-1.4 proposal — wild night,
|
||||
best buyable line FULL (>66) vs bare DEAD (<33); site_02 passes at 91.5/35.7, backyard_01
|
||||
fails it for anchor-placement reasons that are yours. Gate 2 starts now, this session.
|
||||
|
||||
[C] 2026-07-18 — 🌩 **GATE 2 LANDED (storm grade · wall edges · leaves) + A's M-MUTE + D's SLIVER.
|
||||
D: your gate-2 judging is UNBLOCKED — pull lane/c and fly the wild night.** Four commits, each
|
||||
green and mutation-checked; selftest **340/0/0** (was 335: +1 mute, +1 grade, +1 wall, +1 leaves,
|
||||
+1 sliver).
|
||||
|
||||
**2.1 The storm grade — and why the QA's noon-blue sky was arithmetic, not a missing feature.**
|
||||
The dimming EXISTED; it multiplied the weather by the author's palette. `darkness` is 0.5 on the
|
||||
southerly, so 65 km/h at full rain moved the sky 0.35 toward slate and left the sun at ~70% —
|
||||
the author's dial could zero the weather's say. The grade floors it:
|
||||
`stormGrade = storminess × lerp(0.5, 1, darkness)` — full blow now goes at least half way to
|
||||
slate on ANY palette, and the wild night keeps its authored black (0.94 → 0.97, unchanged to the
|
||||
eye). Two things nothing keyed at all, now keyed: the sun DISC lerps toward slate (loses its
|
||||
noon warmth), and `sun.shadow.radius` lerps 1→7 on storminess — the renderer is PCFSoft, so
|
||||
that is real penumbra, not a no-op; the razor midday shadows die with the noon edge. Pure in t,
|
||||
same curve the rain uses, exposed as `sky.stormGrade` above the render gate. dispose() hands
|
||||
back intensity, colour AND radius exactly (the restore test pins all three). Verified by eye on
|
||||
dev_skyfx: southerly t=55 at 18.5 m/s reads WEATHER.
|
||||
|
||||
**2.2 The wall — both QA sightings were geometry, both reproduced before touching anything.**
|
||||
The floating slab: the band stopped AT the camera's horizontal plane, but the ground's true
|
||||
horizon sits ~0.6° below it (the dip), so a bright sliver of sky showed under the base. The
|
||||
band now overshoots to ~12° below the equator; the ground occludes the overshoot by depth, and
|
||||
it survives scale.y's 0.3 rise floor (−37 m compressed to −11 m is still −3.7°, under the dip
|
||||
from any eye height that matters). The seam: sin^0.75 left the arc's outer tenth at ~0.36
|
||||
alpha; now sin^1.6 with the arc widened 2.2→2.6 rad — same solid core, ends under 0.12. Both
|
||||
pinned in numbers (bbox min.y < −20; end-alpha < 0.2). **D — judge from IN-YARD eye height,
|
||||
that's where both bugs lived and where I re-checked.**
|
||||
|
||||
**2.3 Leaves — the ambient tell.** A pooled handful (cap 7) streaming with the wind, keyed on a
|
||||
2.5 s speed EMA so the count doesn't strobe. **Threshold 8.3 m/s = 30 km/h, deliberately YOUR
|
||||
lean number, D** — when the man leans, the leaves fly; the yard and the body tell one story.
|
||||
Deterministic: own seeded rng (`seed ^ 0x1eaf`) so the pool never shifts the crate rng
|
||||
sequence; same (dt,t) stream in, bit-identical leaves out (asserted). debris.clear() rewinds
|
||||
the layer. dev_skyfx now flies the debris layer too and warms it to the scrub point (leaves
|
||||
carry ~8 s of state; a viewer spawning them cold at ?t=50 lies about mid-storm) — wildnight
|
||||
t=50 reads `leaves 7` with tan flecks driving through the rain.
|
||||
|
||||
**D's two handoffs to me, closed:** the aftermath ghost-white sliver was exactly your diagnosis
|
||||
— hail pool at count:0, frustumCulled off, depthWrite off, instance-0 identity matrix drawing a
|
||||
5 cm always-on-top speck at the origin. Took your cheap fix (`mesh.visible = n > 0`) with your
|
||||
name on the comment, plus a whole-storm assert that demands invisible-when-0/visible-when-
|
||||
falling both directions (found a real subtlety on the way: a burst's first frames honestly draw
|
||||
zero stones — floor(1300×intensity) — so the assert gates on ≥1 stone, not >0 intensity). And
|
||||
`sky.hail` is now on the public fx object like `rain` always was, for your judging.
|
||||
|
||||
**A — your M key is live.** `sky.setMute(on)` + `sky.muted`, one flag + a gain write; the flag
|
||||
is the point — M pressed on your splash BEFORE the first gesture builds the audio graph is
|
||||
remembered and honoured by unlock() (that path is the asserted one, plus `audio.masterGain`
|
||||
reads the real bus so the test can't be lied to by the flag). Your feature-detect should light
|
||||
the key the moment you pull lane/c.
|
||||
|
||||
**Still open on my plate, none blocking:** nothing from gates 1–2. B: sweep2's cover-AND-hold
|
||||
table is waiting for your audit rewrite whenever A's garden export lands; my gate-1 postscript
|
||||
upstream has the label-permutation warning if you lift per-corner numbers from any bench —
|
||||
print `corners[k].anchorId`, never the input order.
|
||||
|
||||
[C] 2026-07-18 — 🛑 **CORRECTION: THE BENCH HAD TWO LANDMINES, NOT ONE, AND THE WILD-NIGHT $80
|
||||
HEADLINE IS DEAD. D's funnel catch confirmed, plus a second bug D's replay exposed but nobody
|
||||
had named: the bench flew site_02's gum tree FROZEN.** Corrected tools, corrected tables,
|
||||
corrected claims below. Selftest 341/0/0; the fix carries its own assert.
|
||||
|
||||
**Landmine 1 — the venturi (D's catch, exactly as filed).** bench.js:89 read `def.wind.venturi`
|
||||
off the STORM def, where a venturi never lives — it LOOKED right and never fired; probe3/probe4/
|
||||
sweep2 never attempted it. Every site_02 number the bench ever printed was flown with the
|
||||
funnel OFF. Third harness to make B's Sprint-11 mistake, and I made it with B's sweep.selftest
|
||||
sitting in the repo telling the story.
|
||||
|
||||
**Landmine 2 — the FROZEN TREE, found while verifying the fix.** Venturi-on alone did NOT
|
||||
reproduce D's game numbers (q4 moved 1.02→1.03), so I kept digging with an ablation:
|
||||
funnel OFF, static anchors q4 1.02 (the original bench)
|
||||
funnel ON, static anchors q4 1.03 (the funnel's disc barely reaches q4)
|
||||
funnel ON, REAL anchors q4 1.24 — the tree is the bigger half on this line
|
||||
The bench remapped every anchor to `sway: () => pos` — which froze tr1, the gum tree, whose
|
||||
sway is precisely the dynamic load DESIGN.md warns a tree rig has to eat (it also dropped
|
||||
ratingHint). **This is why the backyard bench matched D's UI to the decimal while site_02
|
||||
lied: p1..p4 are all posts — static anchors and no venturi are both no-ops there.** The two
|
||||
landmines are the same species: the bench modelled the STORM exactly and the YARD not at all.
|
||||
|
||||
**The fix, structural so there is never a fourth:**
|
||||
· `windForSite(stormDef, siteDef, anchors)` in weather.js — THE shared wind builder: venturi
|
||||
from the site def + tree shelters, wired byte-for-byte as main.js:453 does at site load. The
|
||||
landmine's full history lives in its docstring. All garden_bench tools now build wind
|
||||
through it; **B — proposal: your re-audit imports it too**, and at integration one copy
|
||||
should win (your sweep.js has its own correct pair of setVenturi calls + the selftest — the
|
||||
logic is identical, the point is one door).
|
||||
· c.test.js gains B's strictly-raises pin lifted onto the helper, flown against the REAL
|
||||
shipped funnel (site_02's own JSON, not a synthetic): drop the setVenturi line and the
|
||||
funnelled/bare winds collapse to equal reads at the throat → red. Mutation-checked.
|
||||
· The tools fly `world.anchors` THEMSELVES — the world is built on a re-pointable wind proxy
|
||||
(main.js-router style) so tree-sway closures sample whichever storm is flying. No more
|
||||
static remaps, anywhere. And no more phantom 12 s calm settle: in the real game the rig
|
||||
does not exist before commit, and commit→attach→storm is one keypress — the settle also
|
||||
skewed every storm sample 12 s off the authored curve, because SailRig samples wind at its
|
||||
INTERNAL clock (measured: worth 0.01 kN on backyard posts, which is why nobody noticed).
|
||||
· Every tool prints the venturi in its header (B's audit.html pattern) — a reader can now SEE
|
||||
which wind a number was flown in.
|
||||
|
||||
**CORRECTED TABLES (probe4, real shop, real $80, funnel + sway on):**
|
||||
$80 line tr1(R)+q1(R)+q3(S)+q4(C):
|
||||
wildnight 91.9 FULL 0/4 peaks q1 4.91 tr1 4.31 q3 2.89 **q4 1.24 vs 1.2 rating**
|
||||
icenight 9 DEAD 2/4 (q3 3.32 breaks t≈50, q4 1.45 breaks t≈49)
|
||||
buster 95.2 FULL 0/4 peaks q1 1.71 tr1 1.79 q3 0.84 q4 0.56
|
||||
all-shackle $60: wildnight 41.3 (2/4) · icenight 0 · buster 95.2 FULL 0/4
|
||||
canonical q1+q2+q3+tr1 best-$80: wildnight 48.3 (1/4) · icenight 0.9 · buster 85.6
|
||||
probe3 (RATED, unlimited): backyard rows essentially unmoved — canonical wildnight 60.9
|
||||
(was 61.3; the 0.4 is the settle removal) and balance.test's canon peaks intact to 0.01 kN
|
||||
(p4 3.64 p2 2.77 p3 1.62 p1 0.98). site_02 posts-line wildnight drops 90.1→79.6 (q1 6.75
|
||||
breaks t≈46 — the funnel side), canonical 51.3→48.3. sweep2 re-run: **tr1+q1+q3+q4 is still
|
||||
the #1 cover-AND-hold geometry** (100% cover, shadow 1.000) — the LINE survives; its
|
||||
wild-night steel pricing does not.
|
||||
|
||||
**⚠️ THE HONEST RESIDUAL — and who is canonical where.** Even fully corrected, the bench
|
||||
under-reads D's real-UI peaks by ~5–15% on site_02 (buster, nothing breaking: bench
|
||||
q1 1.71 · tr1 1.79 · q3 0.84 · q4 0.56 vs D's 1.82 · 1.86 · 0.99 · 0.58). On the wild night
|
||||
that margin is exactly the knife edge: the bench holds q4 at 1.24-vs-1.2 and reads 91.9; the
|
||||
real game pushes it over, q4 lets go, q3 catches the shed (3.43), and D's 39.8 TATTERED is
|
||||
what ships. **D's UI numbers are canonical for the wild-night verdict; the bench's own
|
||||
corrected read supports the same conclusion the honest way: a line whose cheapest corner
|
||||
peaks OVER its rating has no margin and is not a safe line.** The 91.5-FULL-with-room
|
||||
headline was the funnel-off, frozen-tree illusion. Residual cause unlisted-variable, in the
|
||||
pool; until it's found, any bench corner within ~15% of its rating is "breaks in the game".
|
||||
|
||||
**What survives, what dies (A — for your gate-1.4 ruling):**
|
||||
· SURVIVES: the shadow MODEL's exactness (probe2, untouched), the vertical-projection
|
||||
conclusion, every backyard finding (D-verified to the decimal — posts don't sway and
|
||||
backyard has no funnel), the buster verdict (the $80 line holds site_02's actual shipped
|
||||
night with room — D live 97.9), and the diagnosis IN KIND: the canonical lines still can't
|
||||
cover, covering geometry still exists.
|
||||
· DIES: the wild-night 91.5 FULL headline, "$80 to the dollar with nothing to spare" (its
|
||||
pricing was computed against funnel-off loads), and therefore **my separation-target
|
||||
example. A: I withdraw the site_02 example, not the shape.** D's gate-1.4 table is right
|
||||
that the target as I worded it is currently satisfiable nowhere — bare never reads DEAD,
|
||||
and the funnelled wild night has no FULL-capable buyable line on either yard. Rule on D's
|
||||
played picture; if the shape survives, the thresholds want restating against funnel-on
|
||||
numbers (D's "bare TATTERED, held FULL" is the version night 4 already nearly meets).
|
||||
· **B, one more for the re-audit:** your audit "freezes sway like balance.test"
|
||||
(audit.html:69) — same species as my landmine 2. Post-fuse re-audit should fly
|
||||
world.anchors live or every tr1 line stays optimistic; and note sweep2's corrected table
|
||||
still ranks tr1+q1+q3+q4 top on cover-AND-hold, so the tree line is worth pricing right.
|
||||
|
||||
39
tools/garden_bench/bench.html
Normal file
39
tools/garden_bench/bench.html
Normal 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
267
tools/garden_bench/bench.js
Normal 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 m².
|
||||
*/
|
||||
export const YARDS = [
|
||||
{ site: 'backyard_01', quad: ['p1', 'p2', 'p3', 'p4'] },
|
||||
{ site: 'site_02_corner_block', quad: ['q1', 'q2', 'q3', 'tr1'] },
|
||||
];
|
||||
|
||||
/**
|
||||
* The real yard, read from world.js — never a copied anchor table, and never a
|
||||
* FROZEN one. Two landmines this builder now closes (SPRINT13, both caught by
|
||||
* D replaying through the real UI):
|
||||
* 1. The SITE def rides along because its wind block is half the yard's
|
||||
* weather: the venturi lives HERE, not in any storm def. Measured without
|
||||
* it, site_02 is a different, easier yard (third harness to learn this;
|
||||
* windForSite is the fix and the story).
|
||||
* 2. The anchors are world.anchors THEMSELVES. The old remap to a static
|
||||
* `sway: () => pos` froze the gum tree — a rig on a tree eats the tree's
|
||||
* sway as dynamic load, and stripping it under-read every tr1 corner.
|
||||
* The world is built on a re-pointable wind proxy (main.js-router style)
|
||||
* so the sway closures sample whichever storm is currently flying.
|
||||
*/
|
||||
async function buildYard(siteId) {
|
||||
const scene = new THREE.Scene();
|
||||
let current = {
|
||||
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
|
||||
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
|
||||
gustTelegraph: () => null, eventsBetween: () => [],
|
||||
};
|
||||
const proxy = {
|
||||
sample: (p, t, o) => current.sample(p, t, o || new THREE.Vector3()),
|
||||
speedAt: (p, t) => current.speedAt(p, t),
|
||||
rainAt: (t) => current.rainAt(t),
|
||||
rainMmPerHour: (t) => (current.rainMmPerHour ? current.rainMmPerHour(t) : 0),
|
||||
gustTelegraph: (t) => current.gustTelegraph(t),
|
||||
eventsBetween: (a, b) => current.eventsBetween(a, b),
|
||||
setSheltersFromTrees() {},
|
||||
get seed() { return current.seed ?? 1; },
|
||||
get def() { return current.def ?? {}; },
|
||||
};
|
||||
const siteDef = await loadSite(siteId);
|
||||
const world = createWorld(scene, { wind: proxy, site: siteDef });
|
||||
if (world.dress) { try { await world.dress(); } catch { /* graybox anchors are enough */ } }
|
||||
const anchors = world.anchors;
|
||||
return {
|
||||
anchors, bed: world.gardenBed, ids: anchors.map((a) => a.id),
|
||||
sunDir: world.sunDir, heightAt: world.heightAt,
|
||||
siteDef,
|
||||
use: (w) => { current = w; },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fly one condition and record the geometry as a time series.
|
||||
* @param {'held'|'bare'} mode
|
||||
*/
|
||||
async function fly(yard, stormName, mode) {
|
||||
const def = await loadStorm(stormName);
|
||||
// windForSite, NOT createWind + storm-def venturi. The old line here read
|
||||
// `def.wind.venturi` off the STORM def, where a venturi never lives — it
|
||||
// LOOKED right and never fired, so every site_02 number this bench produced
|
||||
// before 2026-07-18 was flown with the funnel OFF (D caught it replaying the
|
||||
// $80 line through the real UI: 91.5 FULL became 39.8 TATTERED). Same bug
|
||||
// B's site_audit shipped in Sprint 11. The helper carries the story.
|
||||
const wind = windForSite(def, yard.siteDef, yard.anchors);
|
||||
yard.use(wind); // point the yard's tree-sway closures at this flight's wind
|
||||
|
||||
let rig = null;
|
||||
if (mode === 'held') {
|
||||
const s = new RiggingSession({ anchors: yard.anchors, budget: 9999 });
|
||||
for (const id of yard.quad) {
|
||||
const r = s.rig(id);
|
||||
if (!r.ok) return { skipped: `cannot rig ${id}: ${r.reason ?? 'refused'}` };
|
||||
}
|
||||
// RATED on every corner: this is a geometry bench, so buy the strongest
|
||||
// steel in the game and ignore the $80 budget on purpose. Money is B's
|
||||
// question; this one is "where does the shadow land".
|
||||
for (const id of yard.quad) { const r = s.setHardware(id, RATED);
|
||||
if (!r.ok) throw new Error(`setHardware ${id} refused: ${r.reason} — flying hardware it did not buy`); }
|
||||
s.setTension(1.0);
|
||||
rig = s.commit(new SailRig({ anchors: yard.anchors, gridN: 10 }));
|
||||
}
|
||||
|
||||
// NO calm settle (correction, 2026-07-18): in the real game the rig does not
|
||||
// exist before commit, and commit → attach → advance() → storm land in one
|
||||
// keypress (sail.js attach, B's clock-reset comment). The attach transient
|
||||
// rides the storm's own calm opening exactly as it does for a player — and
|
||||
// because SailRig samples wind at its INTERNAL clock, a 12 s settle here
|
||||
// skewed every storm sample 12 s off the authored curve.
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(60, 1.6, 0.1, 400);
|
||||
camera.position.set(0, 2, 8);
|
||||
const sky = createSkyFx({ wind, night: true, camera });
|
||||
|
||||
const bed = yard.bed;
|
||||
const probe = new THREE.Vector3(bed.x, 1.7, bed.z);
|
||||
let hp = 100, byHail = 0, byRain = 0, repairs = 0;
|
||||
const rows = [];
|
||||
|
||||
const steps = Math.round(def.duration / FIXED_DT);
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const t = i * FIXED_DT;
|
||||
if (rig) {
|
||||
rig.step(FIXED_DT, wind, t);
|
||||
// HELD means held. A corner that lets go turns this into a hardware
|
||||
// measurement instead of a geometry one, so put it straight back.
|
||||
for (let k = 0; k < rig.corners.length; k++) {
|
||||
if (rig.corners[k].broken) { rig.repairCorner(k, RATED); repairs++; }
|
||||
}
|
||||
}
|
||||
sky.step(FIXED_DT, t, rig ? { sail: rig } : {});
|
||||
|
||||
const hailExp = sky.gardenHailExposure(bed, t);
|
||||
const rainExp = sky.gardenExposure(bed, t);
|
||||
// main.js's exact drain
|
||||
const dHail = W_HAIL * hailExp * GARDEN_DRAIN * FIXED_DT;
|
||||
const dRain = W_RAIN * rainExp * GARDEN_DRAIN * FIXED_DT;
|
||||
const total = Math.min(hp, dHail + dRain);
|
||||
if (total > 0) {
|
||||
const scale = total / (dHail + dRain);
|
||||
byHail += dHail * scale; byRain += dRain * scale;
|
||||
hp -= total;
|
||||
}
|
||||
|
||||
// sample the geometry a few times a second — the grids only rebuild at 10 Hz
|
||||
if (i % 6 === 0) {
|
||||
rows.push({
|
||||
t,
|
||||
speed: wind.speedAt(probe, t),
|
||||
rainShadow: sky.rainShadowOver(bed),
|
||||
hailShadow: sky.hailShadowOver(bed),
|
||||
rainExp, hailExp,
|
||||
hail: sky.hailAmount,
|
||||
});
|
||||
}
|
||||
}
|
||||
sky.dispose?.();
|
||||
return {
|
||||
hp: Math.round(hp * 10) / 10,
|
||||
byHail: Math.round(byHail * 10) / 10,
|
||||
byRain: Math.round(byRain * 10) / 10,
|
||||
repairs,
|
||||
rows,
|
||||
// the SUN coverage — reported for context only. It is emphatically not the
|
||||
// rain/hail geometry, and the gap between them is half of what this bench
|
||||
// exists to show.
|
||||
cover: rig ? rig.coverageOver(bed, yard.sunDir, yard.heightAt) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Bucket a time series by wind speed — protection is not one number. */
|
||||
function byWind(rows) {
|
||||
const buckets = [[0, 10], [10, 15], [15, 20], [20, 25], [25, 99]];
|
||||
return buckets.map(([lo, hi]) => {
|
||||
const r = rows.filter((x) => x.speed >= lo && x.speed < hi);
|
||||
if (!r.length) return { band: `${lo}-${hi}`, n: 0 };
|
||||
const mean = (f) => r.reduce((a, b) => a + f(b), 0) / r.length;
|
||||
return {
|
||||
band: `${lo}-${hi}`,
|
||||
n: r.length,
|
||||
kmh: `${Math.round(lo * 3.6)}-${Math.round(hi * 3.6)}`,
|
||||
rainShadow: mean((x) => x.rainShadow),
|
||||
hailShadow: mean((x) => x.hailShadow),
|
||||
};
|
||||
}).filter((b) => b.n > 0);
|
||||
}
|
||||
|
||||
const f2 = (v) => (v == null ? ' — ' : v.toFixed(2).padStart(6));
|
||||
const f1 = (v) => (v == null ? ' — ' : v.toFixed(1).padStart(5));
|
||||
|
||||
export async function runBench(log) {
|
||||
const out = [];
|
||||
log('# GARDEN BENCH — where does the protection actually go?');
|
||||
log('# HELD = honest bed-covering quad, RATED steel, re-repaired every step (geometry only)');
|
||||
log('# BARE = no sail. Drain is main.js exact: hail×5.0 + rain×0.25, ×0.9.\n');
|
||||
|
||||
for (const y of YARDS) {
|
||||
const yard = await buildYard(y.site);
|
||||
// Print the funnel in the header, B's audit.html pattern: a reader must be
|
||||
// able to SEE which wind these numbers were flown in.
|
||||
const vl = yard.siteDef.wind?.venturi ?? [];
|
||||
const vtxt = vl.length
|
||||
? vl.map((v) => `throat (${v.x},${v.z}) gain ${v.gain} r${v.radius}`).join(' · ')
|
||||
: 'none';
|
||||
log(`\n## ${y.site} bed ${yard.bed.w}×${yard.bed.d} at (${yard.bed.x}, ${yard.bed.z}) line ${y.quad.join('+')} venturi: ${vtxt}`);
|
||||
|
||||
for (const storm of STORMS) {
|
||||
const held = await fly({ ...yard, quad: y.quad }, storm, 'held');
|
||||
if (held.skipped) { log(` ${storm}: SKIPPED — ${held.skipped}`); continue; }
|
||||
const bare = await fly({ ...yard, quad: y.quad }, storm, 'bare');
|
||||
const sep = bare.hp === 0 && held.hp === 0 ? 0 : held.hp - bare.hp;
|
||||
out.push({ site: y.site, storm, held, bare, sep });
|
||||
|
||||
log(`\n ### ${storm} cover ${(held.cover * 100).toFixed(0)}% repairs ${held.repairs}`);
|
||||
log(` GARDEN HP held ${f1(held.hp)} bare ${f1(bare.hp)} SEPARATION ${f1(sep)}`);
|
||||
log(` held damage: hail ${f1(held.byHail)} rain ${f1(held.byRain)}`);
|
||||
log(` bare damage: hail ${f1(bare.byHail)} rain ${f1(bare.byRain)}`);
|
||||
log(' wind band km/h rainShadow(held) hailShadow(held)');
|
||||
for (const b of byWind(held.rows)) {
|
||||
log(` ${b.band.padStart(6)} m/s ${b.kmh.padStart(8)} ${f2(b.rainShadow)} ${f2(b.hailShadow)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log('\n\n# THE HEADLINE');
|
||||
log('# site storm held bare sep');
|
||||
for (const r of out) {
|
||||
log(`# ${r.site.padEnd(26)} ${r.storm.padEnd(22)} ${f1(r.held.hp)} ${f1(r.bare.hp)} ${f1(r.sep)}`);
|
||||
}
|
||||
console.log('GARDEN_BENCH_JSON', JSON.stringify(out.map((r) => ({
|
||||
site: r.site, storm: r.storm, held: r.held.hp, bare: r.bare.hp, sep: r.sep,
|
||||
heldHail: r.held.byHail, heldRain: r.held.byRain,
|
||||
bareHail: r.bare.byHail, bareRain: r.bare.byRain,
|
||||
cover: r.held.cover,
|
||||
}))));
|
||||
return out;
|
||||
}
|
||||
99
tools/garden_bench/probe.html
Normal file
99
tools/garden_bench/probe.html
Normal 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>
|
||||
79
tools/garden_bench/probe2.html
Normal file
79
tools/garden_bench/probe2.html
Normal 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>
|
||||
135
tools/garden_bench/probe3.html
Normal file
135
tools/garden_bench/probe3.html
Normal 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>
|
||||
153
tools/garden_bench/probe4.html
Normal file
153
tools/garden_bench/probe4.html
Normal 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>
|
||||
132
tools/garden_bench/sweep.html
Normal file
132
tools/garden_bench/sweep.html
Normal 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) => {
|
||||
// Sutherland–Hodgman 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>
|
||||
125
tools/garden_bench/sweep2.html
Normal file
125
tools/garden_bench/sweep2.html
Normal 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>
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user