Compare commits

...

14 Commits

Author SHA1 Message Date
m3ultra
f2fe5915c8 Add Sprint 11 plan and lane prompts: prove the corner block, then the job sheet
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 16:44:11 +10:00
m3ultra
bb393566c5 Merge Sprint 10: sites are data, corner block on night 3
Selftest on merged main: 296 pass / 0 fail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 16:43:26 +10:00
m3ultra
df1fd4dd25 Merge remote-tracking branch 'origin/lane/d'
# Conflicts:
#	THREADS.md
2026-07-17 16:39:27 +10:00
m3ultra
a7b859fbbf Merge remote-tracking branch 'origin/lane/b'
# Conflicts:
#	THREADS.md
2026-07-17 16:39:26 +10:00
m3ultra
73e142947b Merge remote-tracking branch 'origin/lane/c'
# Conflicts:
#	THREADS.md
2026-07-17 16:39:26 +10:00
m3ultra
a71d67e390 Merge remote-tracking branch 'origin/lane/e' 2026-07-17 16:39:26 +10:00
m3ultra
def481d0c3 Post the site extraction; hand each lane its hook and B the setWorld ask
Records the venturi gap coordinates for C, the needsLadder mechanism-key for D,
the honest-quad geometry for B's audit, and the one Lane B API the site switch
still needs — rigging.setWorld(world) to re-point the clickable markers, already
called guarded from loadSiteInto.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:05:58 +10:00
m3ultra
0f5d69b32d The second site: corner block loads from JSON; night 3 moves to it
SPRINT10 gate 1b. A night is now {storm, site}; the week hands over a yard per
night and night three is the corner block. Nights 1/2/4/5 are the backyard; the
plain-string night form still works and defaults to backyard_01.

site_02_corner_block.json: a smaller 24x16 yard with E's carport as the trap it
was built to be — beam anchors rating_hint 0.22, posts 0.30, both below the
house fascia's 0.35, collateral "carport". The site IS winnable off the honest
anchors (one tree east, four ground posts): I enumerated the honest quads at
authoring time and there are four in-band ones covering the bed, so the carport
is temptation not necessity. Full load audit is Lane B's tools/site_audit.

The carport's beams are keyed work:"bracket" — D's field, the MECHANISM not the
type. A bracket up a bare beam is the one anchor you can't fake from the ground,
so it needs the ladder; keyed on type it would fail open (a carport isn't a
house) and the ladder mechanic would silently vanish, which is exactly the audit
D ran. C's venturi list is in the JSON with two gap coordinates they asked for.

The world is rebuilt on a site change, not re-pointed: createPlayer captures the
world into its ground clamp, collider, ladder and broom (Lane D's modules, no
setters), so a clean rebuild is honest — and a site change only happens at a
forecast, never mid-storm. world.dispose() frees the old yard so nothing leaks.
The re-point of rig + session lives inside loadSiteInto, keyed on the rebuild
itself, after a caller-side version proved fragile.

Verified end to end through real phase transitions: play night 1, hit the
aftermath, and the forecast into night 3 rebuilds into the corner block —
carport present, 10 anchors (cb/cp/q/tr, not h/t/p), smaller bed, player rebuilt,
no scene leak. Then back to the backyard, 12 anchors restored. Selftest 289/0/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:05:06 +10:00
m3ultra
d5569327c5 Measure the venturi's load cost for B's audit: gain 1.35 = +40%, forces shackles
Load goes as wind^2 but the funnel only fires while aligned, so gain 1.35 adds
+40% not +82% — a small quad in the throat peaks 1.99 kN, above a carabiner
(1.2) and under a shackle (3.2). The funnel punishes the cheap corner
specifically without being unwinnable: the corner block teaching itself.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:34:08 +10:00
m3ultra
7fb30b33dc Post the site_02 venturi handoff: axis derived, gap coords needed
Night 3 = corner block + storm_03b is the right pairing (the funnel teaches the
southerly, 03b is the southerly). Its post-change wind blows toward -1.08 rad,
so that's the venturi axis. Gave A/E the ready template needing only the two gap
coordinates, and flagged B that the venturi is a balance lever their site_audit
must include (gain is mine to tune to their sweep).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:31:23 +10:00
m3ultra
0cee198702 Price the carport trap and add its wreckage
The carport anchors have said collateral="carport" since Sprint 9, but the root
carried no collateral_value — so main.js, which reads world.gnome.collateralValue
for the gnome, had no number to reach for. A trap with no price is a warning
label. The root now carries it in the same shape and place, pinned above the
gnome's 25, with the proposed 180 and its reasoning as a named constant so Lane A
can argue with the number rather than the asset.

carport_01_wrecked is the payoff, on the intact one's origin for a mesh-for-mesh
swap: the pads stay (they were never the weak part), the frame racks over like a
parallelogram, the roof peels off downwind in one piece.

Two bugs found by measuring rather than reasoning, both now comments where the
numbers are: the beams were rotated when a beam spans the axis the frame racks
in, so it rides level — tilting it made the wreck taller than the carport; and
the roof sheets dipped below ground, which is what inflated the height. Asserts
now pin the wreck shorter than its twin and sitting on the grass.

291/0/0, Lane E 64 asserts, 40/40 output files byte-identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:30:37 +10:00
m3ultra
5cb838b314 Add validateSiteWind — the site JSON's wind block fails loud (SPRINT10)
A builds world.js FROM DATA this sprint, and a site's `wind` block (venturi
funnels, later per-site shelter overrides) is hand-authored data like a storm —
so it validates like one. setVenturi already clamps defensively, but a clamp
hides a typo where the repo's ethos is to name it: a gain < 1 (someone reaching
for a shelter), a funnel with no throat coordinates, an axis typed "south". A
calls this at site load next to validateStorm. A site with no wind block, an
empty block, or an empty venturi list all validate — the funnel is opt-in.

Node 56/0/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:30:00 +10:00
m3ultra
b285bbd1b0 Log the work-field landing, the type-enum flag for A, and the site_02 block
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:29:13 +10:00
m3ultra
13fc4bd14d Land needsLadder on the work field, measured against E's real carport
SPRINT10 gate 1. Adopts SPRINT10's spelling — `work: 'bracket' | 'cloth'` — as
canonical over the boolean I proposed in Sprint 9: naming the mechanism
positively beats a negation, and it's the field A is emitting. Sprint 9's
`worksAtBracket` is still honoured so nothing that already sets it breaks, and
`type === 'house'` remains the fallback, so site_01 is a no-op (verified live:
no anchor there carries either field, and fascia/post/tree verdicts are
unchanged).

Keyed to E's SHIPPED carport_01_v1.glb rather than my invented numbers, and it
confirms the audit exactly — it also carries BOTH mechanisms on one structure,
so a per-structure rule would still have been wrong:
    beam_anchor_01/02  y=2.36  anchor_type "carport"       rating_hint 0.22
    post_anchor_01/02  y=1.75  anchor_type "carport_post"  rating_hint 0.30
The beam sits over a 1.72 m person's 2.20 m reach and needs the ladder; the post
doesn't. Neither is typed 'house', so un-declared they BOTH fall back to "ground
work" and canReach waves a 2.36 m bracket through from the grass — the mechanic
deleting itself with no error. That regression is now pinned by a test.

E: the beam is the double trap — your worst rating in the game AND the one that
costs a ladder trip. That reads as a rule, not a bug, which is what §Lane D asks
for. A: the mapping you want is beam→bracket, post→cloth.

63 Lane D asserts headless, 288/0/0 in the browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:28:47 +10:00
20 changed files with 869 additions and 74 deletions

View File

@ -568,3 +568,47 @@ everyone else's site work is already on disk waiting for it.
> Support site_02 as it lands: dressing adjustments, contact sheets, and a
> carport-collateral aftermath line/card if the trap earns one. If B's audit
> says the site has no winnable line, moving the tree is yours.
---
---
# SPRINT 11 prompts (the corner block is mean, then the job sheet)
Same rules: own clone, own branch, rebase onto latest main FIRST. Read
THREADS' last [I] entry then SPRINT11.md.
## Lane A — Sprint 11
> You are Lane A on SHADES 3D, Sprint 11. Rebase onto main, read SPRINT11.md.
> Gate 1 rulings first (carport collateral number — E proposes $180 with
> reasoning in the asset; widen the Anchor.type enum D flagged; answer C's
> venturi-gap question). Then gate 2, the job sheet: each night gains client
> + brief + pay schedule as data; forecast card becomes the job sheet,
> aftermath becomes the invoice. No reputation system yet — one week, five
> jobs, the framing. Shepherd as always.
## Lane B — Sprint 11
> You are Lane B on SHADES 3D, Sprint 11. Rebase onto main, read SPRINT11.md.
> Fix site_audit's headless false-negative (your setWorld ask), run it on
> site_02 WITH the venturi, and post the winnable lines. If the funnel kills
> every $80 line, C drops the gain before E moves the tree. Then support A's
> pay schedule with numbers (what does a good night on each site actually
> bank — your audit already knows).
## Lane C — Sprint 11
> You are Lane C on SHADES 3D, Sprint 11. Rebase onto main, read SPRINT11.md.
> Reconcile the venturi with A (site ships axis 2.1/gain 1.5; you measured
> 1.08/1.35 — one exchange, one edit), tune the gain to B's audit verdict,
> and land per-night forecast lead on the job sheet (your forecastLines lead
> param, waiting two sprints).
## Lane D — Sprint 11
> You are Lane D on SHADES 3D, Sprint 11. Rebase onto main, read SPRINT11.md.
> Play site_02 cold at last — the carport traps should read tempting, the
> funnelled southerly should make the ladder trip the site's thesis. File
> feel notes in THREADS. Then playtest the job-sheet week as A lands it.
## Lane E — Sprint 11
> You are Lane E on SHADES 3D, Sprint 11. Rebase onto main, read SPRINT11.md.
> Client juice: job-sheet letterhead + invoice styling for A's cards, one
> per-client prop if cheap. Stand by on the tree-move if B's audit demands
> it (C's gain drop goes first).

58
SPRINT11.md Normal file
View File

@ -0,0 +1,58 @@
# SPRINT 11 — THE CORNER BLOCK IS MEAN, THEN THE JOB SHEET
*Sprint 10 delivered its headline: sites are data. Two yards load from JSON,
night 3 moves to the corner block, the ladder keys on mechanism not type, the
carport is a priced trap with its own wreck, and the fabric plumbing has an
end-to-end assert. 296/0/0. What's left on the site is TUNING and PROOF —
then the game grows its last missing organ: the job sheet.*
Read THREADS from the last [I] entry.
## Gate 1 — prove the corner block (the site seams, all short)
1. **C + A: reconcile the venturi.** Site JSON ships axis 2.1 / gain 1.5; C
measured storm_03b's post-change wind at 1.08 and proposed gain 1.35.
Either A oriented the gap differently (tell C the built angle, C matches)
or the numbers are placeholders (take C's). One THREADS exchange, one edit.
2. **B: fix site_audit's headless false-negative** (your setWorld ask — A is
reading THREADS), then run it on site_02 WITH the venturi. If no $80 line
survives the funnel, C drops the gain (their standing offer) before E
moves the tree.
3. **D: play site_02 cold** — third sprint waiting, now truly unblocked.
The carport traps should read tempting-not-buggy; the ladder trip through
a funnelled southerly is the site's whole thesis. Feel notes in THREADS.
4. **A: two small rulings** — the carport collateral number (E proposes $180
with reasoning in the asset; change the constant, not the asset) and the
Anchor.type enum widening D flagged (a carport typed 'post' silently joins
the sail-post family; typed 'carport' it breaks the contract — widen it).
## Gate 2 — THE JOB SHEET (A owns; the landscaper service arrives)
DESIGN.md's callout structure, minimum viable: the week becomes a JOB — a
client, a brief, and pay that reads like an invoice.
- Each night entry gains client + brief text ("the Hendersons want the
seedlings alive — they leave for Cairns Friday") and a pay schedule
(base + garden bonus + no-collateral bonus). The forecast card becomes the
job sheet; the aftermath becomes the invoice.
- Client flavor is data (site JSON or night entry), not code.
- Keep scope honest: no reputation system yet, no job select — one week, five
jobs, the framing. Sprint 12 decides if reputation/warranty (DESIGN.md's
consequence loop) is next.
**E**: client-facing juice — job-sheet letterhead, invoice styling, one
per-client prop if cheap (the Hendersons' kid's bike against the fence).
**C**: per-night forecast lead — the job sheet shows tomorrow's ± band
resolving (your forecastLines lead param has waited two sprints for this).
## Gate 3 — JOHN PLAYS THE WEEK (four sprints standing)
The corner block makes it five nights across two yards. `python3 server.py`,
play it with the mouse, three sentences in THREADS. Sprint 12's shape — more
sites vs reputation vs heatwave days — is genuinely undecidable without them.
```
gate 1: venturi reconciled · site_audit passes site_02 · D's notes filed ·
carport priced · enum widened
gate 2: five jobs with briefs and invoices; the week reads as a landscaper's
week, not a survival ladder
gate 3: John's three sentences
```

View File

@ -825,6 +825,46 @@ Format: `[lane letter] YYYY-MM-DD — note`
`pickup_anchor` empty, read that, else the table top is fine. That plus B's three lines above and
the §7 scenario is hand-playable.
[A] 2026-07-19 — 🏗️ **SITES ARE DATA — gate 1 landed, all of it. B/C/D/E: your parked work is unblocked.**
`data/sites/backyard_01.json` + `site_02_corner_block.json`; world.js is a builder now. backyard_01
is **byte-identical, and it's checkable not claimable** — E's branch-anchor tripwires (6 dp), my
quad-band asserts, D's ladder field and B's balance lines all pass unchanged. **289/0/0.** A night is
`{storm, site}`; the week hands over a yard per night; **night 3 is the corner block.** Verified end
to end through real phase transitions (play n1 → aftermath → the forecast into n3 rebuilds into the
corner block, carport present, 10 anchors, smaller bed, player rebuilt, no leak; back to backyard
restores 12). Each lane's hooks:
· **C**`wind.setVenturi(site.wind.venturi)` is wired after every yard build; empty/absent is a
no-op (backyard stays identical). **Your two gap coordinates are in site_02's JSON**: throat
centre **(-6, 0)**, axis **~2.1 rad** (the southerly after the change). Starting shape is in your
schema in the file; gain/radius/sharp are yours. Storm pairing is yours too — I put the southerly
family on night 3 so the venturi has a change to scream on; move it if that's wrong.
· **D** — anchors carry `work: "cloth" | "bracket"`, the MECHANISM. The carport beams are
`"bracket"` so needsLadder fires on them; keyed on type they'd fail open exactly as your audit
found. `needsLadder` should read `anchor.work === 'bracket'` now, not `type === 'house'`.
· **E** — the carport trap arrives intact from your GLB: beam 0.22 / post 0.30 / collateral
"carport", pinned below the fascia. site_02 wants your dressing pass (fences south+east, bed
5×3.5, gnome at 2.6,3.0) whenever it suits — and the reshoot you offered.
· **B** — site_02 is authored winnable off HONEST anchors (tree tr1 + posts q1-q4): I enumerated
four in-band bed-covering quads at authoring time, e.g. q1+q2+q3+q4 = 37.6 m² @ 100% cover, and
tr1+q1+q2+q4 = 36.4 @ 88%. **That's geometry, not load — your tools/site_audit is the real gate.
Run it; if the honest anchors can't make a $80 line, the fix is to move the tree or add a post,
NEVER to soften the carport (E's e.test pins those ratings).**
[A] 2026-07-19 — 🙏 **LANE B — one API, and it's the last piece of the site switch.** `createRiggingUI`
builds its clickable anchor markers + session from `world.anchors` at construction and has no way to
re-point them, so on a site change the corner block's markers don't exist and the panel still lists
the backyard. I re-point what I can from main.js — **`rig.anchors` and `session.anchors` follow, so
site_02 is riggable from code and from your audit** — but the CLICKABLE UI is yours. Please add
**`rigging.setWorld(world)`** (rebuild markers + repoint session from the new world.anchors); I call
it already inside `loadSiteInto`, guarded, so the day it exists the mouse-rig on site_02 lights up
with no change on my side. Same shape as `session.reset()` / `session.setBudget()` — I faked, asked,
you landed, I deleted the fake. Until then night 3 is fully playable via keyboard/debug, just not
mouse-clickable on the new markers.
[A] 2026-07-19 — 📌 the p4 "do not move this post" sweep table moved INTO backyard_01.json's `_why`, beside
the coordinate it guards — it's the number someone will be tempted to nudge, and it should travel with
the data, not sit in a file the data left. a.test's >45 m² floor is still the enforcement.
[A] 2026-07-18 — ⚖️ **RULING — the pyrrhic win. WIN = `hp >= 50`. Corners are PRICED, not gated. Wired,
asserted, on main.** B, D: you both got there independently with the numbers and you're right.
**The garden is the client's. The sail is yours.** Saving the bed with a rig that tore itself apart is
@ -2948,6 +2988,94 @@ Selftest: **277 passed, 0 failed, 0 skipped**, LANE BAL now visible.
(landed as a data-driven trap, waiting on site_02), B's site_audit tool (from the quad sweep).
Gate 3 (John plays the week) remains open — the game has never been more ready for it.
[E] 2026-07-18 — 💰 **LANE A — the carport trap was UNSCOREABLE and that's on me. Fixed.** I shipped the
anchors last sprint carrying `collateral="carport"`, but the root carried no `collateral_value` — so
the thing your aftermath reaches for (`world.gnome.collateralValue`, main.js:548) simply wasn't there
for the carport. A trap with no price isn't a trap, it's a warning label. It now carries
`collateral_value` + `collateral_label` on the root, same shape and same place as the gnome, and
e.test pins it above the gnome's 25.
**The number is YOURS — this is a design call, not an art one.** My proposal is **180**, and the
reasoning is in the code beside it so you can argue with it: the gnome is 25, a night's shop is 80, a
good week banks ~475. At 180 it's 2.25 nights' budget — it turns a good week into a broke one and is
felt for the rest of the run, without instantly ending a strong one. Below ~120 the trap is a shrug;
above ~250 tying off the carport once is a silent game over, which teaches nothing because the player
never gets to act on the lesson. It should be the worst bill on the site and still a week you can dig
out of. Change the constant, not the asset.
[E] 2026-07-18 — **`carport_01_wrecked_v1.glb`** — the payoff for the trap, same origin and footprint as
`carport_01` so you swap mesh-for-mesh like the gnome and the fence. 6.28 × 5.57 × **2.20 m** (the
intact one is 2.69 — a wreck must stand SHORTER than the thing it was, and there's an assert for that
now because my first pass came out taller). It fails the way a light structure actually does: the pads
stay put — they were never the weak part, which is the joke — the frame racks over like a
parallelogram, and the roof sheet peels off downwind in one piece. A carport doesn't shatter; it leans,
and then it's somewhere else. Copy for the line you asked about:
· collateral row: **"the carport — $180"**
· verdict/ticker: **"you took the carport with you"**
· if you want the knife in: *"They'll want that back up by Thursday."*
[E] 2026-07-18 — 🐞 two measured bugs on the way in, both caught by measuring instead of reasoning — the
exact lesson from my Sprint 9 phantom-post entry, applied earlier this time and it cost minutes rather
than an afternoon: (1) I rotated the wreck's beams 31° about Y, but a beam spans the short axis and the
frame racks in that SAME axis — both post tops travel together, so the beam rides across level. Tilting
it swung one end to 2.96 m and made the wreck taller than the carport. (2) The roof sheets dipped to
z = 0.41, i.e. through the ground, which is what inflated the measured height. Both are now comments
where the numbers are.
[E] 2026-07-18 — status: selftest **291/0/0**, Lane E is 64 asserts, **40/40 output files byte-identical**
across two full runs. §E dressing (fence lengths, bed size) is **blocked on gate 1**`data/sites/`
doesn't exist yet, and that's correct, it's yours from day one. **Ping me the moment
`site_02_corner_block.json` lands** and I'll fit the dressing to the real numbers and refresh the
sheets same session; the factory is site-agnostic and the carport was the only new geometry it needed.
**Lane B — audit the carport before it ships.** If the one tree plus ground anchors can't make an
in-band quad at $80, say so and **I'll move the tree, not the ratings** — the ratings are the site.
Carried, still yours either way: grass_atlas (**nine sprints, 0 refs** — bin it), the screenshot POST,
`moon.png`.
[C] 2026-07-19 — 🌀 **SITE_02 VENTURI — everything but the two coordinates is ready. A/E, here's the axis;
give me the gap and I paste the list.** The venturi physics + `setVenturi` landed Sprint 9 (56/0/0);
this sprint I added `validateSiteWind(site.wind)` so the site JSON's wind block fails loud like a storm
(A: call it at site load next to validateStorm; a site with no wind block validates fine). What's left
is purely data, and it's a two-number question for whoever places the corner block's geometry.
**The axis is not a guess — it's the storm.** Night 3 is `storm_03b_earlybuster` (the early southerly
buster) and it moves to the corner block, which is the right pairing: the funnel exists to teach the
southerly, and 03b is the storm that HAS a southerly. Measured 03b's post-change wind — it settles and
peaks (21 m/s at t=49) blowing toward heading **1.08 rad (62°, SSE)**. So the gap should run along
that line and the funnel screams exactly when the change lands at t=18. The venturi block, axis
pre-filled, everything else placeholder:
```json
"wind": { "venturi": [ { "x": <gap centre x>, "z": <gap centre z>, "axis": -1.08,
"gain": 1.35, "radius": 5, "sharp": 3 } ] }
```
**A/E — the one thing I need from you: where is the gap?** Give me the two building edges that form it
(carport corner + fence line) and roughly where the rig sits, and I'll return exact `x,z` (throat
centred in the gap, positioned so the accelerated wind crosses the rigging zone) — or just drop my
template in with your gap coords and adjust axis if E orients the gap differently; the physics only
reads the number, so tell me the angle E built and I'll match it.
⚠️ **B — the venturi is a BALANCE lever on site_02, so audit WITH it.** gain 1.35 boosts wind ×1.35 in
the throat when aligned (and the downdraft rides it, so cloth load scales too) — on an anchor-poor site
with a small sail that can tip winnable → not. Your `site_audit` loads the site JSON, so it'll include
the venturi automatically; if the sweep says no $80 line survives the funnel, ping me and I drop the
gain (1.35 → 1.2 is a one-number edit) rather than E moving the tree for a load I created. The gain is
mine to tune to your audit — the funnel should make the corner block MEAN, not impossible.
(Porosity/gardenHailExposure seam: already done — B took my one-liner in the fabric PR, cited the
ruling, wired it in skyfx exactly right. Nothing owed there.)
[C] 2026-07-19 — 📏 **The venturi's load cost, measured — so B's audit has a number to check against, and
it turns out gain 1.35 is not just safe but DESIGNS the site well.** Load goes as wind², so I worried
×1.35 wind → ×1.8 load. It doesn't: the funnel only fires while the wind is aligned with the axis, so
on storm_03b a small ~30 m² quad sitting in the throat peaks:
```
gain 1.00 (off) 1.42 kN
gain 1.20 1.74 kN (+22%)
gain 1.35 1.99 kN (+40%) ← proposed
gain 1.50 2.24 kN (+58%)
```
At **1.99 kN, gain 1.35 sits ABOVE the carabiner (1.2) and well UNDER the shackle (3.2).** So the
corner block's funnel does exactly what its personality says: it punishes the cheap corner
specifically — a carabiner in the throat blows, a shackle holds — without being unwinnable. "Nowhere
to tie off, and the wind funnels, so you can't cheap out on the tie-off you DO have" is the site
teaching itself. This is on the current yard as a proxy (site_02's real geometry is B's audit); but it
says 1.35 is a good starting gain, not a coin-flip. If your sweep on the real site disagrees, the
number to move is the gain and it's mine.
---
## SPRINT10 — Lane B — 2026-07-17
@ -3049,3 +3177,54 @@ corner. If it's the latter, that's your cue. I can't audit the carport until it'
anchors are your GLB), but the tooling is now waiting, not TODO.
**site_02 audit: still BLOCKED on the file existing** — but the wait is now one URL, not a build.
[D] 2026-07-18 — 🪜 **`needsLadder` LANDED on the work field (§gate 1), keyed to E's real carport.**
Took SPRINT10's spelling — **`work: 'bracket' | 'cloth'`** — over the boolean I proposed in
Sprint 9. Naming the mechanism positively beats a negation, and it's the field A is emitting;
`worksAtBracket` still resolves so nothing already setting it breaks, and `type === 'house'` stays
the fallback. **site_01 is a no-op, verified live**: no anchor there carries either field, and the
fascia/post/tree verdicts are identical. E's anchor tripwires stay green.
**Keyed to `carport_01_v1.glb` rather than my invented numbers — and E, your asset carries BOTH
mechanisms, which makes a per-structure rule wrong too:**
```
beam_anchor_01/02 y=2.36 "carport" rating_hint 0.22 → over the 2.20 m reach: BRACKET
post_anchor_01/02 y=1.75 "carport_post" rating_hint 0.30 → under it, reachable: CLOTH
```
**The beam is a double trap and I love it**: your worst rating in the game AND the corner that
costs a ladder trip in a southerly. That is a rule, not a bug — which is exactly what §Lane D asks
the traps to read as. Confirmed the regression too: **un-declared, both carport anchors fall back
to the type, read as "ground work", and `canReach` waves a 2.36 m bracket through from the
grass** — the mechanic deleting itself with no error. Pinned by a test now.
**Lane A — the mapping is `beam_anchor → work: "bracket"`, `post_anchor → work: "cloth"`.** Emit
`work` per anchor in the site JSON and the ladder follows with no code change. 288/0/0.
[D] 2026-07-18 — ❗ **LANE A — one thing to watch when you emit the site JSON, from the same audit.**
`contracts.js` types `Anchor.type` as a closed enum **`'house'|'tree'|'post'`**, and E's carport
ships `anchor_type: "carport"` / `"carport_post"`. Whatever you map those to, two things stay true
and they pull in opposite directions:
· `needsLadder` no longer cares — it reads `work` — so the ladder is safe either way.
· but `world.js` filters trees for `wind.setSheltersFromTrees(...)` on `type === 'tree'`, and
**C's venturi + B's audit both key off geometry that reads `type`.** A carport typed `'post'`
would quietly join the sail-post family; typed `'carport'` it falls outside the enum contract.
Not my call and not blocking me — flagging it before it becomes a merge-time discovery, because
"a rule keyed on a type string that a new site doesn't fit" is the exact shape of the bug I just
landed the fix for. If you widen the enum, B/C/E and I can all read `type` honestly again.
[D] 2026-07-18 — ⏳ **Playing site_02 cold: still blocked, third sprint — flagging, not nagging.** A's
site extraction hasn't started (lane/a is at the sprint-plan commit), so there is no site_02 to
play. Everything of mine that can be done ahead of it is done: the ladder field is landed and
tested against E's shipped GLB, and the moment `site_02_corner_block.json` exists my feel pass is
the only thing between it and gate 2. Nothing of mine blocks anyone. Douse guard still green, so
pond masses haven't moved.
[I] 2026-07-19 — **SPRINT 10 INTEGRATION (main).** All lanes merged clean; selftest **296/0/0**. Sites
are DATA: backyard_01 + site_02_corner_block load from JSON, night 3 is the early buster on the
corner block, needsLadder keys on the work field (beam=bracket trap, post=cloth), the carport has
a price proposal ($180, E's reasoning in the asset) and a wreck, and B's porosity plumbing has a
real end-to-end assert. Open seams carried to SPRINT11, none blocking boot:
· venturi numbers need the C↔A reconciliation — site JSON ships axis 2.1 / gain 1.5, C measured
the storm at 1.08 and proposed gain 1.35 tuned to B's audit ("the funnel should make the
corner block MEAN, not impossible");
· B's site_audit false-negative on headless site JSON (their setWorld ask to A);
· D's cold playthrough of site_02 — unblocked at last;
· A decides the carport collateral number and the Anchor.type enum widening D flagged;
· gate 3 (John plays the week) — FOUR sprints standing.

View File

@ -155,6 +155,24 @@
"status": "PASS",
"problems": []
},
{
"name": "carport_01_wrecked",
"dims": [
6.2819,
5.5704,
2.1983
],
"tris": 144,
"nodes": [
"beams",
"carport_01_wrecked",
"footings",
"posts",
"roof_down"
],
"status": "PASS",
"problems": []
},
{
"name": "shed_01",
"dims": [

View File

@ -781,6 +781,21 @@ def build_house_yardside(name):
return root
# What it costs to take the client's carport with you. Lane A owns this number —
# it's a design call, not an art one — but the trap needs A number or it isn't a
# trap, and shipping the asset without one is why nothing scored it. My proposal
# and the reasoning, to argue with rather than adopt:
# · the gnome is 25, a night's shop budget is 80, a good week banks ~475;
# · at 180 it's 2.25 nights' budget — it turns a good week into a broke one and
# is felt for the rest of the run, without instantly ending a strong one;
# · cheaper than ~120 and the trap is a shrug; dearer than ~250 and tying off
# the carport once is a silent game over, which teaches nothing because the
# player never gets to act on the lesson.
# The point is that it should be the worst thing on the site's bill and still be
# a week you can dig out of.
CARPORT_COLLATERAL = 180
def build_carport_01(name):
"""A single-car carport — the corner block's whole personality (SPRINT9 §E).
@ -858,6 +873,87 @@ def build_carport_01(name):
stamp(root, name, "structure")
root["site_hint"] = "corner_block"
root["is_anchor_trap"] = True
# Without this the trap is unscoreable: the anchors say collateral="carport"
# but nothing said what a carport COSTS, so Lane A's aftermath had no number
# to reach for. main.js already reads world.gnome.collateralValue — same
# shape, same place.
root["collateral_value"] = CARPORT_COLLATERAL
root["collateral_label"] = "the carport"
return root
def build_carport_01_wrecked(name):
"""The carport after you tied a sail to it (SPRINT10 §E).
Same origin and footprint as `carport_01`, so Lane A swaps mesh-for-mesh the
way the gnome and fence already do. This is the payoff for the trap: the
site's lesson only lands if taking the beam LOOKS like taking the carport,
not like a number going down on a card.
It fails the way a light structure actually fails not flattened. The
windward pair of posts stays in its pads and the leeward pair folds, so the
whole frame racks over like a parallelogram and the roof sheet peels off
downwind in one piece. That's the tell: a carport doesn't shatter, it leans
and then it's somewhere else.
"""
rng = rng_for(name)
root = add_empty(name)
steel = get_material("Mat_Steel", PAL["steel_gal"], 0.4, metallic=0.85)
dark = get_material("Mat_SteelDark", PAL["steel_dark"], 0.45, metallic=0.8)
sheet = get_material("Mat_Colorbond", PAL["colorbond"], 0.45, metallic=0.5)
conc = get_material("Mat_Concrete", PAL["concrete"], 0.95)
W, D = 3.0, 5.4
H_HI, H_LO = 2.45, 2.20
RACK = math.radians(34) # how far the frame leaned before it stopped
pads, posts = [], []
for sx in (-1, 1):
for sy in (-1, 1):
x, y = sx * (W / 2 - 0.09), sy * (D / 2 - 0.09)
h = H_HI if sy < 0 else H_LO
# The pads stay: they were never the weak part. That's the joke.
pads.append(add_box(f"{name}_pad_{sx}_{sy}", (0.26, 0.26, 0.09),
(x, y, 0.045), conc))
lean = RACK * rng.uniform(0.86, 1.0)
# Racked over toward +X, hinging at the pad — so the top travels and
# the base doesn't.
posts.append(add_box(f"{name}_post_{sx}_{sy}", (0.09, 0.09, h),
(x + math.sin(lean) * h / 2, y,
0.09 + math.cos(lean) * h / 2), steel,
rot=(0, lean, 0)))
join_group(pads, "footings", root)
join_group(posts, "posts", root)
# The beams stay LEVEL. A beam spans the short axis and the frame racks in
# that same axis, so both post tops travel together and the beam rides across
# — it does not tilt. Rotating it swung one end to 2.96 m, i.e. the wreck came
# out TALLER than the carport it used to be. Measured, not reasoned.
beams = []
for sy in (-1, 1):
h = H_HI if sy < 0 else H_LO
beams.append(add_box(f"{name}_beam_{sy}", (W, 0.05, 0.14),
(math.sin(RACK) * h, sy * (D / 2 - 0.09),
0.09 + math.cos(RACK) * h), dark))
join_group(beams, "beams", root)
# The sheet let go and went downwind — folded, not flat, and clear of the
# frame. Nobody's parking under this again.
# Flat-ish and ON the grass. The first pass tilted them enough to sink a
# corner to z=-0.41 — through the ground — which is both wrong and what
# inflated the wreck's measured height. Kept nearer the frame too: a 7 m
# footprint would overlap whatever Lane A has parked next door.
join_group([add_box(f"{name}_sheet_a", (W + 0.2, D * 0.55, 0.04),
(W * 0.95, -D * 0.18, 0.12), sheet,
rot=(0, math.radians(3), math.radians(-13))),
add_box(f"{name}_sheet_b", (W * 0.8, D * 0.38, 0.04),
(W * 0.88, D * 0.30, 0.28), sheet,
rot=(math.radians(9), math.radians(-4), math.radians(8)))],
"roof_down", root)
stamp(root, name, "structure")
root["broken_variant_of"] = "carport_01"
root["collateral_value"] = CARPORT_COLLATERAL
return root
@ -2054,6 +2150,11 @@ ASSETS = [
nodes=["footings", "posts", "beams", "roof",
"beam_anchor_01", "beam_anchor_02",
"post_anchor_01", "post_anchor_02"]),
# Wider than the intact carport (the sheet goes downwind) but SHORTER — a
# wreck that stands taller than the thing it was is a bug, not a wreck.
dict(name="carport_01_wrecked", fn=build_carport_01_wrecked,
dims=((4.5, 6.6), (5.2, 5.8), (1.9, 2.45)),
nodes=["footings", "posts", "beams", "roof_down"]),
dict(name="shed_01", fn=build_shed_01,
dims=((2.4, 2.7), (1.8, 2.1), (1.95, 2.25)),
nodes=["shell", "roof", "doors", "door_anchor"]),

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

After

Width:  |  Height:  |  Size: 3.2 MiB

View File

@ -0,0 +1,80 @@
{
"id": "site_02_corner_block",
"name": "The Corner Block",
"blurb": "Two streets, two fences, and a carport that looks like four free anchors. It isn't.",
"yard": { "width": 24, "depth": 16 },
"sun": { "elevationDeg": 55, "azimuthDeg": -125 },
"gardenBed": { "x": 0, "z": 1, "w": 5, "d": 3.5 },
"_design": [
"DESIGN.md wants the corner block anchor-poor — 'nowhere to tie off'. An empty yard is just a",
"smaller yard, so the trap is the interesting build: give it something that LOOKS like four free",
"anchors and is the worst steel in the game. That's E's carport (beam 0.22, post 0.30 rating_hint,",
"both BELOW the house fascia's 0.35, collateral 'carport'). Tie 25 m2 to it in a southerly and you",
"don't break a shackle — you take the carport, and it lands on whatever's under it.",
"",
"The site IS winnable off the honest anchors — one gum tree east and three ground posts — so the",
"carport is temptation, not necessity. Measured at authoring time over honest anchors only:",
" tr1+q2+q3 with the carport's cb corner is the covering play, but the honest 3 posts alone",
" make an in-band quad and q1+q2+q3+tr1 covers 58% at 32.6 m2. Full load audit: Lane B's",
" tools/site_audit. If tree+posts can't make a winnable $80 line, the fix is to move the tree",
" (or add a post), NEVER to soften the carport — E's e.test.js pins those ratings with the reason."
],
"structures": [
{
"id": "carport", "model": "carport_01_v1",
"x": -7, "z": -3, "rotYDeg": 0,
"solid": true,
"anchors": [
{ "id": "cb1", "node": "beam_anchor_01", "type": "post", "work": "bracket" },
{ "id": "cb2", "node": "beam_anchor_02", "type": "post", "work": "bracket" },
{ "id": "cp1", "node": "post_anchor_01", "type": "post", "work": "cloth" },
{ "id": "cp2", "node": "post_anchor_02", "type": "post", "work": "cloth" }
]
}
],
"trees": [
{
"id": "tr1", "model": "tree_gum_02_v1",
"x": 7, "z": -1, "phase": 1.7, "trunkH": 3.8, "anchorY": 3.1,
"anchors": [
{ "id": "tr1", "node": "branch_anchor_01", "type": "tree", "work": "cloth" },
{ "id": "tr1b", "node": "branch_anchor_02", "type": "tree", "work": "cloth" }
]
}
],
"posts": [
{ "id": "q1", "x": -3.5, "z": -2.5, "h": 4.0, "type": "post", "work": "cloth" },
{ "id": "q2", "x": 3.5, "z": -2.0, "h": 4.0, "type": "post", "work": "cloth" },
{ "id": "q3", "x": 2.0, "z": 4.0, "h": 4.0, "type": "post", "work": "cloth" },
{ "id": "q4", "x": -3.0, "z": 4.0, "h": 4.0, "type": "post", "work": "cloth" }
],
"shedTable": { "model": "shed_table_v1", "x": 8.5, "z": 4.5, "rotYDeg": -90, "pickupNode": "pickup_anchor" },
"gnome": { "model": "garden_gnome_01_v1", "x": 2.6, "z": 3.0, "rotYDeg": 0.6, "collateralValue": 25 },
"fence": {
"sides": ["south", "east"]
},
"wind": {
"_venturi": [
"Lane C: the corner block's whole weather personality is the venturi — a street on two sides,",
"so a gap that SCREAMS when the southerly swings to run along it and is calm otherwise. The gap",
"runs NE-SW between the house line and the carport, i.e. along the yard's west edge. Two",
"coordinates you asked for: throat centre (-6, 0), and it funnels along axis ~2.1 rad (the",
"southerly's direction after the change). Numbers are yours to tune; I've put a starting shape",
"below in your schema. gain/radius/sharp are your call."
],
"venturi": [
{ "x": -6, "z": 0, "axis": 2.1, "gain": 1.5, "radius": 5, "sharp": 3 }
]
}
}

View File

@ -355,7 +355,7 @@ export function createHud(d) {
* @param {{key:string, def:object}[]} storms
* @param {(key:string) => void} onPick
*/
showForecast({ key, def }, wk, onGo) {
showForecast({ key, def, site }, wk, onGo) {
// Integrator merge (Sprint 8): A's week card shape + C's forecastLines —
// MEASURED numbers, banded by lead (the old inline estimate read 30 m/s
// for a storm that really gusts 32.3). Tonight is lead 0 = exact; when
@ -375,10 +375,17 @@ showForecast({ key, def }, wk, onGo) {
now ? '◆' : done ? (held ? '●' : '○') : '·'}</span>`;
}).join('');
// A different yard announces itself — the corner block is somewhere you've
// never rigged, and that's most of what makes night three land.
const siteLine = site
? `<div class="stat" style="color:#7ee0ff">${site.name}${site.blurb ? `${site.blurb}` : ''}</div>`
: '';
card.innerHTML = `<div class="card">
<h1>NIGHT ${wk.night} OF ${wk.nights}</h1>
<div class="pips">${pips}</div>
<h2>${(f.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? ' · NIGHT' : ''}</h2>
${siteLine}
<div class="stat">${f.wind}</div>
<div class="stat">${f.rain}${hail ? ' · hail forecast' : ''}</div>
${f.confidence ? `<div class="stat" style="color:#8ba0ad">${f.confidence}</div>` : ''}

View File

@ -36,23 +36,36 @@ export const LADDER_URL = './models/ladder_01_v1.glb';
* at the bracket on the wall, while a blown post or tree corner is re-made on the cloth, which has
* fallen to somewhere you can stand. Height is a coincidence of that; the bracket is the reason.
*
* SPRINT9, site_02 (SPRINT9 §Lane D "the ladder assumes fascia height; the carport may not have
* one"): `type === 'house'` is site_01's spelling of that idea, not the idea itself. The corner
* block has a CARPORT a roofline you'd also work at the bracket and contracts.js types anchors
* as a closed enum `'house'|'tree'|'post'` that a carport doesn't fit. Whatever E types it, this
* must not fail OPEN: if `needsLadder` says false, `canReach` in interact.js returns true
* unconditionally and the player re-rigs a 2.6 m bracket standing on the grass the mechanic just
* quietly stops existing, with no error. That is the same disease as StumbleBack's dead threshold
* and the camera-less shadow grid: a rule keyed on the wrong thing, failing silently open.
* SPRINT10 §Gate 1 keyed on the MECHANISM, from site data. `type === 'house'` was site_01's
* spelling of the idea, not the idea itself, and contracts.js types anchors as a closed enum
* `'house'|'tree'|'post'` that site_02's carport does not fit. That mattered because it fails OPEN:
* when `needsLadder` says false, `canReach` in interact.js returns true UNCONDITIONALLY, so the
* player re-rigs a bracket well over their head while standing on the grass and the whole mechanic
* quietly stops existing no error, no red. Same disease as StumbleBack's dead threshold and the
* camera-less shadow grid: a rule keyed on the wrong thing, failing silently open.
*
* So it reads DATA first, exactly like `anchor.ratingHint` already does (world.js pulls that from
* the GLB's `userData.rating_hint`), and falls back to site_01's shape. Lane A/E: set
* `anchor.worksAtBracket = true` on the carport's anchors in the site JSON and the ladder follows
* the site with no code change. Nothing on site_01 carries the flag, so this is a no-op there.
* Measured against E's shipped `carport_01_v1.glb`, which is exactly the case the audit predicted
* and note it carries BOTH mechanisms, so a per-STRUCTURE rule would still be wrong:
* beam_anchor_01/02 y=2.36 anchor_type "carport" over the 2.20 m reach: BRACKET
* post_anchor_01/02 y=1.75 anchor_type "carport_post" under it, reachable: CLOTH
* Neither is typed 'house'. The beam is the double trap E's worst rating in the game (0.22) AND
* the one you need the ladder for.
*
* Reads DATA first, exactly like `anchor.ratingHint` already does from the GLB's `userData`:
* · `work: 'bracket' | 'cloth'` SPRINT10's spelling, and the canonical one. Positive about the
* mechanism rather than a negation, which is why it beats the boolean I proposed in Sprint 9.
* · `worksAtBracket: boolean` my Sprint-9 spelling, still honoured so nothing that already
* sets it breaks.
* · else `type === 'house'` site_01's shape. Nothing there carries either field, so this is
* a no-op on backyard_01 and E's anchor tripwires stay green.
* Lane A: emit `work` per anchor in the site JSON and the ladder follows the site with no code
* change. The carport mapping you want is beambracket, postcloth, per the heights above.
*/
export const needsLadder = (anchor) => {
if (!anchor) return false;
if (typeof anchor.worksAtBracket === 'boolean') return anchor.worksAtBracket; // site data wins
if (anchor.work === 'bracket') return true; // SPRINT10 spelling
if (anchor.work === 'cloth') return false;
if (typeof anchor.worksAtBracket === 'boolean') return anchor.worksAtBracket; // Sprint-9 spelling
return anchor.type === 'house'; // site_01's shape
};

View File

@ -25,17 +25,17 @@ import { createDebris } from './debris.js';
import { createSkyFx } from './skyfx.js';
import { createRiggingUI } from './rigging.js';
import { createHud } from './hud.js';
import { createWeek, NIGHTS } from './week.js';
import { createWeek, NIGHTS, nightAt } from './week.js';
/** The calm day the forecast and prep phases run under. */
const CALM_STORM = 'storm_01_gentle';
/**
* Every storm the session can run. This used to be a three-storm difficulty
* select; the week's ladder (week.js NIGHTS) now decides what's coming, and the
* forecast card's job changed from "pick your poison" to "here's tonight".
* The distinct storm keys the session preloads. A night is a {storm, site} pair
* now (SPRINT10), so this pulls the storm out of each and dedupes the same
* storm can appear on more than one night, and only wants loading once.
*/
const STORMS = NIGHTS;
const STORMS = [...new Set(NIGHTS.map((_, i) => nightAt(i).storm))];
/**
* How fast an unprotected garden dies, in HP per second at full rain.
@ -376,30 +376,89 @@ export async function boot(opts = {}) {
const wind = createWindRouter(Object.values(winds));
// --- world & camera -----------------------------------------------------
// SPRINT10: the yard is data. `site` decides which one, and the week hands it
// over per night — night three is the corner block.
const site = await loadSite(opts.site ?? week.site);
const world = createWorld(scene, { wind, site });
// Lane C: a venturi is a shelter's opposite — a gap SPEEDS wind along its axis.
// Absent/empty is a perfect no-op, which is what keeps backyard_01 identical.
wind.setVenturi?.(site.wind?.venturi ?? []);
// Lane E's GLBs land over the graybox. Awaited here because it resolves
// world.shedTable onto E's baked pickup_anchor, and wireYardActions (below,
// inside rigSail) reads that position when it registers the spare pickup.
await world.dress();
// SPRINT10: the yard is data, and the week hands over a different one per
// night — night three is the corner block. `world` is a `let` because the
// site changes under it; everything downstream reads `world.*` late (the loop,
// scoreRun, the garden), so re-pointing this binding re-points all of them
// without threading a mutable through fourteen closures.
let world;
let player;
let currentSite = null;
// rig and rigging are assigned further down but referenced by loadSiteInto's
// re-point step, which runs once at boot BEFORE they're built — declared here
// as `let` so that first call sees `undefined` (a clean no-op) rather than a
// temporal-dead-zone throw. Boot builds them against the same fresh world.
let rig;
let rigging;
const cameraRig = createCameraRig(canvas);
cameraRig.setSolids(world.solids);
cameraRig.setGround(world.heightAt);
// Lane C: trees don't shelter anything until they're told where they are.
wind.setSheltersFromTrees(world.anchors.filter((a) => a.type === 'tree'));
// --- 2. player ----------------------------------------------------------
const interact = new Interact();
const player = await createPlayer(scene, world, cameraRig, { wind, interact });
// Site blurbs for the forecast card, so a new yard announces itself. Filled
// as each site is loaded; the card degrades gracefully if a name is missing.
const siteMeta = {};
/**
* Build (or rebuild) the yard for a site. Idempotent per site name asked for
* the one already standing, it no-ops, so calling it every forecast is free on
* the four backyard nights and only pays on the one that moves.
*
* The player, camera, rig, hud and wind OBJECTS all survive; only the yard
* group and the anchor set are replaced. That's the whole reason this is a
* contained change and not a re-boot: those systems read the world through its
* interface every frame, so a new world instance is picked up, not re-wired.
*/
async function loadSiteInto(siteName) {
if (siteName === currentSite) return world;
const siteDef = await loadSite(siteName);
siteMeta[siteName] = { name: siteDef.name, blurb: siteDef.blurb };
if (world) { world.dispose(); player?.dispose?.(); }
world = createWorld(scene, { wind, site: siteDef });
await world.dress();
currentSite = siteName;
cameraRig.setSolids(world.solids);
cameraRig.setGround(world.heightAt);
// Lane C: local wind effects are per-yard. A venturi (site_02's screaming
// gap) and tree shelters both re-register here; empty/absent is a no-op,
// which is what keeps backyard_01 byte-identical.
wind.setVenturi?.(siteDef.wind?.venturi ?? []);
wind.setSheltersFromTrees(world.anchors.filter((a) => a.type === 'tree'));
// The player is rebuilt with the yard, not re-pointed: createPlayer captures
// world into its ground clamp, solid collider, ladder and broom, none of
// which have a setter (Lane D's modules). A site change only happens at a
// forecast, never mid-storm, so a clean rebuild is honest and cheap.
player = await createPlayer(scene, world, cameraRig, { wind, interact });
// Re-point everything that captured the old anchor set. Done HERE, right
// after the rebuild, rather than in the caller — a caller-side `if (switched)`
// was fragile (it broke the moment a debug path had already advanced
// currentSite). If the world was rebuilt, these are stale, full stop.
// · rig / session: created at boot, hold anchors by reference.
// · rigging UI markers: Lane B's file builds clickable markers from
// world.anchors at construction and has no setter — so the corner
// block's markers don't exist and its panel lists the backyard. That's
// the one piece I can't re-point without reaching into their module;
// requested `rigging.setWorld(world)` in THREADS. Session + rig follow,
// so it's riggable from code/audit; the CLICKABLE UI wants Lane B.
// (rig and rigging are declared below and don't exist on the first, boot-time
// call — the guards make that the no-op it should be; boot creates them
// against this same fresh world moments later.)
if (rig) rig.anchors = world.anchors;
if (rigging) {
rigging.session.anchors = world.anchors;
rigging.setWorld?.(world);
}
return world;
}
// Night one's site — the week object is built further down, but night one is
// always index 0, so its site is known without it. (opts.site lets a debug
// boot jump straight to a site.)
await loadSiteInto(opts.site ?? nightAt(0).site);
// --- 3. sail ------------------------------------------------------------
const rig = new SailRig({ anchors: world.anchors });
rig = new SailRig({ anchors: world.anchors });
let sailView = null;
/**
@ -513,7 +572,7 @@ export async function boot(opts = {}) {
// Lane B's picking adapter. `panel:false` — their built-in panel is a fine
// bench, but hud.js owns the screen now, so the two shouldn't both draw.
const rigging = await createRiggingUI({
rigging = await createRiggingUI({
scene,
camera: cameraRig.object,
domElement: canvas,
@ -624,7 +683,13 @@ export async function boot(opts = {}) {
* anything. Lane B's file, flagged in THREADS main.js therefore tracks
* `spentThisNight` itself off the bank rather than trusting `.spent`.
*/
function showTonight() {
async function showTonight() {
// SPRINT10: tonight's yard. loadSiteInto no-ops when the site is unchanged
// (the four backyard nights) and rebuilds + re-points everything holding the
// old anchor set when it isn't (night three). The re-point lives inside
// loadSiteInto, keyed on the rebuild itself, so it can't desync from it.
await loadSiteInto(week.site);
// Reaching into Lane B's session to re-bank the shop. `RiggingSession` takes
// a budget at construction but exposes no setter, and the UI owns the
// instance — so this is the one private touch in main.js, done loudly rather
@ -636,7 +701,7 @@ export async function boot(opts = {}) {
rigging.session.reset();
spentThisNight = 0;
hud.showForecast(
{ key: week.stormKey, def: defs[week.stormKey] },
{ key: week.stormKey, def: defs[week.stormKey], site: siteMeta[week.site] },
{ night: week.night, nights: week.nights, bank: week.bank, log: week.log },
() => { stormKey = week.stormKey; game.setPhase('prep'); },
);
@ -669,11 +734,18 @@ export async function boot(opts = {}) {
if (to === 'storm') { pondPeak = 0; pondDumped = 0; }
if (to === 'forecast') {
garden.reset();
resetRig();
player.sim.carrying = null;
hud.setDawn(false);
showTonight();
// showTonight rebuilds the yard when the site changes, so it MUST run
// before the resets that touch the world (garden.reset → world.setPlants)
// or the player (rebuilt inside it). Awaited via .then so the resets land
// on the new world/player, not the old one mid-swap. This is the only
// phase transition that can rebuild the scene, so it's the only one that
// has to sequence like this.
showTonight().then(() => {
garden.reset();
resetRig();
player.sim.carrying = null;
});
}
// The card belongs to the phase, not to the button that happened to open it.
// Tying its lifetime to the forecast button meant any other route into prep
@ -808,7 +880,13 @@ export async function boot(opts = {}) {
// Handy for poking at the world from the console, and for the selftest-free
// hand checks the sprint's acceptance actually turns on.
const api = {
renderer, scene, world, cameraRig, player, game, wind, rig, rigSail,
renderer, scene, cameraRig, game, wind, rig, rigSail,
// world and player are rebuilt on a site change (SPRINT10), so these are
// getters — a captured reference would be last night's yard.
get world() { return world; },
get player() { return player; },
get currentSite() { return currentSite; },
week, loadSiteInto,
get sailView() { return sailView; },
debris, interact, events, hud, rigging, garden, defs, winds,
get stormKey() { return stormKey; },

View File

@ -10,7 +10,7 @@ import { createCameraRig } from '../camera.js';
import { createGame, createWindRouter, verdictFor } from '../main.js';
import { orderRing } from '../sail.js';
import { loadStorm, createWind } from '../weather.js';
import { createWeek, NIGHTS, gradeFor, BROKE_BELOW } from '../week.js';
import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW } from '../week.js';
import { assert, assertEq, assertLess, fixedLoop } from '../testkit.js';
/** @param {import('../testkit.js').Suite} t */
@ -57,14 +57,20 @@ export default async function run(t) {
// --- the week (SPRINT8 gate 1) -------------------------------------------
t.test('the week is five escalating nights and the ladder never reorders', () => {
t.test('the week is five escalating nights, each a storm and a site', () => {
assertEq(NIGHTS.length, 5);
assertEq(NIGHTS[0], 'storm_01_gentle', 'night one must be the one that cannot hurt you');
assertEq(NIGHTS[4], 'storm_02b_icenight', 'night five is the ice night');
// SPRINT10: a night is {storm, site} now. nightAt() normalises either shape.
assertEq(nightAt(0).storm, 'storm_01_gentle', 'night one must be the one that cannot hurt you');
assertEq(nightAt(4).storm, 'storm_02b_icenight', 'night five is the ice night');
assertEq(nightAt(2).site, 'site_02_corner_block', 'night three moves to the corner block');
assertEq(nightAt(0).site, 'backyard_01', 'the rest are the backyard');
const w = createWeek();
assertEq(w.night, 1); assertEq(w.bank, 80);
assertEq(w.site, 'backyard_01', 'week.site tracks the ladder');
assert(!w.isFinalNight);
for (let i = 0; i < 4; i++) w.advance();
for (let i = 0; i < 2; i++) w.advance();
assertEq(w.site, 'site_02_corner_block', 'and reaches the corner block on night three');
for (let i = 0; i < 2; i++) w.advance();
assertEq(w.night, 5);
assert(w.isFinalNight, 'night five is the last');
w.advance();
@ -123,6 +129,40 @@ export default async function run(t) {
assertEq(w2.settle(ruin, def, 80).outcome, 'win', 'night five always ends the week, not the run');
});
// Gate 2 acceptance: both sites load from data, and the corner block is not
// the backyard with the furniture moved. Built up front (Suite.test() can't
// await — the guard I added last sprint enforces it, and just caught me).
let site2World = null;
try {
const s2 = validateSite(await loadSite('site_02_corner_block'));
site2World = createWorld(new THREE.Scene(), { wind: createStubWind({ calm: true }), site: s2 });
try { await site2World.dress(); } catch { /* graybox anchors are enough */ }
} catch (err) {
console.warn('[a.test] site_02 unavailable (no server?):', err.message);
}
t.test('site_02 loads from JSON and is a genuinely different yard', () => {
if (!site2World) return 'SKIPPED — no server for site_02';
const ids = new Set(site2World.anchors.map((a) => a.id));
assert(ids.has('q1') && ids.has('tr1'), 'has its own honest anchors');
assert(!ids.has('h1') && !ids.has('p4'), 'and NOT the backyard\'s');
assertLess(site2World.gardenBed.w, world.gardenBed.w, 'the corner block is a smaller yard');
});
t.test('the carport is a trap: worst steel in the game, and a bracket job', () => {
if (!site2World) return 'SKIPPED — no server for site_02';
const cb = site2World.anchors.find((a) => a.id === 'cb1');
assert(cb, 'carport beam anchor cb1 exists');
// The whole reason the site teaches: it must out-lie the house fascia (0.35),
// and E's e.test pins these below it. If someone "fixes" them up, the corner
// block stops being a corner block and becomes a small yard.
if (cb.ratingHint != null) assertLess(cb.ratingHint, 0.35, 'carport must out-lie the fascia');
// D's field, keyed on MECHANISM: a bracket up a bare beam needs the ladder.
// Keyed on type it would fail open (carport isn't house), and the ladder
// mechanic would silently vanish — which is the audit D ran.
assertEq(cb.work, 'bracket', 'the carport beam is a bracket job — needs the ladder');
});
// --- the wind router -----------------------------------------------------
// Loaded HERE, not inside t.test(). These two were written `async` and
// Suite.test() cannot await — so they were recorded as passes while asserting

View File

@ -448,22 +448,46 @@ export default async function run(t) {
'nor a tree limb — that is a strop you throw');
});
t.test('ladder: a site can DECLARE which anchors are worked at the bracket', () => {
// SPRINT9 §Lane D, ahead of site_02. The corner block has a CARPORT, and contracts.js types
// anchors as a closed enum ('house'|'tree'|'post') that a carport doesn't fit. The failure mode
// matters more than the feature: if needsLadder says false, interact's canReach returns true
// UNCONDITIONALLY, so the player re-rigs a 2.6 m bracket from the grass and the mechanic
// silently stops existing. Data first, site_01's type as the fallback.
assert(realNeedsLadder({ type: 'carport', pos: { y: 2.6 }, worksAtBracket: true }),
'a carport roofline is bracket work even though its type is not "house"');
assert(!realNeedsLadder({ type: 'house', pos: { y: 2.48 }, worksAtBracket: false }),
'and a site can say otherwise — a low pergola you can reach off the deck');
// and site_01 must be untouched: nothing there carries the flag
assert(realNeedsLadder({ type: 'house', pos: { y: 2.48 } }), 'no flag → site_01 behaviour');
assert(!realNeedsLadder({ type: 'post', pos: { y: 3.95 } }), 'no flag → post is still ground work');
t.test('ladder: keys on the MECHANISM a site declares, not on the anchor type', () => {
// SPRINT10 gate 1. The failure mode is the point: when needsLadder says false, interact's
// canReach returns TRUE UNCONDITIONALLY — so a mis-keyed anchor doesn't error, it silently
// deletes the ladder mechanic. Fail-open is why this is data and not a type string.
assert(realNeedsLadder({ type: 'carport', pos: { y: 2.36 }, work: 'bracket' }),
'declared bracket work needs the ladder, whatever the type says');
assert(!realNeedsLadder({ type: 'carport_post', pos: { y: 1.75 }, work: 'cloth' }),
'declared cloth work does not — you re-make it on the fallen sail');
assert(!realNeedsLadder({ type: 'house', pos: { y: 2.48 }, work: 'cloth' }),
'and a site outranks the type in both directions (a low pergola off a deck)');
// Sprint-9's spelling still honoured, so anything already setting it keeps working
assert(realNeedsLadder({ type: 'carport', pos: { y: 2.36 }, worksAtBracket: true }));
assert(!realNeedsLadder({ type: 'house', pos: { y: 2.48 }, worksAtBracket: false }));
// site_01 must be untouched — nothing there carries either field
assert(realNeedsLadder({ type: 'house', pos: { y: 2.48 } }), 'no field → site_01 behaviour');
assert(!realNeedsLadder({ type: 'post', pos: { y: 3.95 } }), 'no field → post is still ground work');
assert(!realNeedsLadder(null) && !realNeedsLadder(undefined), 'and it never throws on a gap');
});
t.test('ladder: E\'s REAL carport resolves both ways round (site_02\'s trap)', () => {
// Read off carport_01_v1.glb rather than invented: E ships BOTH mechanisms on one structure,
// which is why a per-structure rule would still be wrong.
// beam_anchor_01/02 y=2.36 anchor_type "carport" rating_hint 0.22
// post_anchor_01/02 y=1.75 anchor_type "carport_post" rating_hint 0.30
// The beam is the double trap: E's worst rating in the game AND over the 2.20 m reach.
const beam = { id: 'c1', type: 'carport', pos: { y: 2.36 }, work: 'bracket', ratingHint: 0.22 };
const post = { id: 'c3', type: 'carport_post', pos: { y: 1.75 }, work: 'cloth', ratingHint: 0.30 };
assert(realNeedsLadder(beam), 'the beam bracket at 2.36 m is over a 1.72 m person\'s 2.20 m reach');
assert(!realNeedsLadder(post), 'the post fitting at 1.75 m is not — you can just reach it');
assert(beam.ratingHint < post.ratingHint, 'and the one needing the ladder is also the weaker lie');
// The regression that matters: WITHOUT the work field, E's types are not 'house', so the old
// rule said "no ladder" for a 2.36 m bracket and canReach waved it through from the grass.
assert(!realNeedsLadder({ type: 'carport', pos: { y: 2.36 } }),
'un-declared, it falls back to the type and reads as ground work — which is exactly why '
+ 'Lane A must emit `work` per anchor in the site JSON, and why this test exists');
});
t.test('ladder: fascia re-rig is gated on being up it; post re-rig is not', () => {
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const L = fakeLadder({ player: p });

View File

@ -83,6 +83,8 @@ const ASSETS = [
{ name: 'carport_01', h: [2.4, 2.8],
nodes: ['footings', 'posts', 'beams', 'roof',
'beam_anchor_01', 'beam_anchor_02', 'post_anchor_01', 'post_anchor_02'] },
{ name: 'carport_01_wrecked', h: [1.9, 2.45],
nodes: ['footings', 'posts', 'beams', 'roof_down'] },
];
function sizeOf(gltf) {
@ -153,6 +155,36 @@ export default async function run(t) {
// house fascia, which DESIGN.md already calls a lie. If someone quietly
// "fixes" these ratings upward the site stops teaching anything and just
// becomes a smaller yard, so they're pinned here with the reason attached.
// The trap has to be scoreable or it isn't a trap, only a warning. The anchors
// said collateral="carport" from Sprint 9 but nothing said what a carport
// COSTS, so Lane A's aftermath had no number to reach for and the site's whole
// lesson was unpriced. main.js already reads world.gnome.collateralValue — this
// is the same shape in the same place.
t.test('the carport is priced, and it is the worst bill on the site', () => {
const c = loaded.get('carport_01')?.scene.getObjectByName('carport_01');
assert(c, 'carport_01 root missing');
const cost = c.userData?.collateral_value;
assert(typeof cost === 'number', `carport carries no collateral_value (${JSON.stringify(c.userData)})`);
const gnome = loaded.get('garden_gnome_01')?.scene
.getObjectByName('garden_gnome_01')?.userData?.collateral_value;
assert(cost > gnome, `carport (${cost}) must cost more than the gnome (${gnome})`);
// Wreckage carries the same price, so scoring can read either state.
const w = loaded.get('carport_01_wrecked')?.scene.getObjectByName('carport_01_wrecked');
assert(w?.userData?.collateral_value === cost,
'the wrecked carport must be priced the same as the one it used to be');
assert(w?.userData?.broken_variant_of === 'carport_01',
'wrecked carport must name its intact twin so A can pair the swap');
});
// A wreck that stands taller than the thing it was is a bug, not a wreck.
t.test('the wrecked carport is shorter than the carport', () => {
const a = new THREE.Box3().setFromObject(loaded.get('carport_01').scene);
const b = new THREE.Box3().setFromObject(loaded.get('carport_01_wrecked').scene);
const hi = a.max.y - a.min.y, lo = b.max.y - b.min.y;
assert(lo < hi, `wreck stands ${lo.toFixed(2)} m vs the intact ${hi.toFixed(2)} m`);
assert(Math.abs(b.min.y) < 0.05, `wreck sits at y=${b.min.y.toFixed(2)} — roof sheet through the ground?`);
});
t.test('carport anchors stay a trap — the corner block has no good tie-off', () => {
const g = loaded.get('carport_01');
assert(g, 'carport_01 did not load');

View File

@ -11,7 +11,7 @@
// call it with the fetched storm defs.
import {
createWindField, validateStorm, gustEnvelope, GUST, RAIN_TIME_COMPRESSION,
createWindField, validateStorm, validateSiteWind, gustEnvelope, GUST, RAIN_TIME_COMPRESSION,
stormStats, forecastFor, hailBlockFor,
} from '../weather.core.js';
@ -324,6 +324,24 @@ export function weatherCases(storms) {
assert(maxRatio > 1.3, `funnel didn't strengthen the downdraft (max ${maxRatio.toFixed(2)}×) — it should ride local speed`);
});
test('validateSiteWind: a good funnel passes, a bad one fails loud', () => {
const ok = validateSiteWind({ venturi: [{ x: -6, z: 4, axis: -1.08, gain: 1.4, radius: 5, sharp: 3 }] }, 's');
assert(ok.ok, `a valid venturi was rejected: ${ok.errors.join('; ')}`);
assert(validateSiteWind(null, 's').ok, 'a site with no wind block should validate');
assert(validateSiteWind({}, 's').ok, 'an empty wind block should validate');
assert(validateSiteWind({ venturi: [] }, 's').ok, 'an empty venturi list should validate');
for (const [label, bad] of [
['gain < 1 (a venturi speeds up, never slows)', { venturi: [{ x: 0, z: 0, gain: 0.7 }] }],
['gain absurd', { venturi: [{ x: 0, z: 0, gain: 9 }] }],
['no x,z', { venturi: [{ axis: 1, gain: 1.4 }] }],
['axis not finite', { venturi: [{ x: 0, z: 0, axis: 'south' }] }],
['radius <= 0', { venturi: [{ x: 0, z: 0, radius: 0 }] }],
['venturi not an array', { venturi: { x: 0 } }],
]) {
assert(!validateSiteWind(bad, 's').ok, `validateSiteWind accepted a bad funnel: ${label}`);
}
});
test('an empty venturi list is a perfect no-op (backyard_01 is untouched)', () => {
const def = storms.storm_02_wildnight;
const bare = createWindField(def);

View File

@ -807,3 +807,29 @@ export function validateStorm(def, name = 'storm') {
return { ok: errors.length === 0, errors };
}
// ---------- site wind validator (SPRINT10 site-as-data) ----------
// A site's `wind` block is hand-authored data like a storm, so it fails loud
// the same way. Lane A calls this at site load; setVenturi clamps defensively
// too, but a clamp hides a typo and this names it. Wind personality that lives
// in site JSON: venturi funnels (and later, per-site shelter overrides).
export function validateSiteWind(wind, name = 'site') {
const errors = [];
const bad = (m) => errors.push(`${name}.wind: ${m}`);
if (wind == null) return { ok: true, errors }; // a site with no wind block is fine
if (typeof wind !== 'object') { bad('must be an object'); return { ok: false, errors }; }
if (wind.venturi != null) {
if (!Array.isArray(wind.venturi)) bad('venturi must be an array of funnel zones');
else wind.venturi.forEach((v, i) => {
const at = `venturi[${i}]`;
if (!Number.isFinite(v.x) || !Number.isFinite(v.z)) bad(`${at} needs finite x,z (the throat centre)`);
if (v.axis != null && !Number.isFinite(v.axis)) bad(`${at}.axis must be radians (the direction the gap runs)`);
if (v.gain != null && !(v.gain >= 1)) bad(`${at}.gain must be >= 1 — a venturi speeds wind UP; use shelters to slow it (got ${v.gain})`);
if (v.gain != null && v.gain > 3) bad(`${at}.gain ${v.gain} is a wind tunnel, not a gap — cap ~2`);
if (v.radius != null && !(v.radius > 0)) bad(`${at}.radius must be > 0 metres`);
if (v.sharp != null && !(v.sharp >= 1)) bad(`${at}.sharp must be >= 1 (alignment falloff exponent)`);
});
}
return { ok: errors.length === 0, errors };
}

View File

@ -11,11 +11,14 @@
import * as THREE from '../vendor/three.module.js';
import {
createWindField, validateStorm, GUST, RAIN_TIME_COMPRESSION,
createWindField, validateStorm, validateSiteWind, GUST, RAIN_TIME_COMPRESSION,
hailBlockFor, stormStats, forecastFor,
} from './weather.core.js';
export { GUST, validateStorm, RAIN_TIME_COMPRESSION, hailBlockFor, stormStats, forecastFor };
export {
GUST, validateStorm, validateSiteWind, RAIN_TIME_COMPRESSION,
hailBlockFor, stormStats, forecastFor,
};
const kmh = (ms) => ms * 3.6;
const band = (b, fmt) => (b.hi - b.lo < 0.05 ? fmt(b.lo) : `${fmt(b.lo)}${fmt(b.hi)}`);

View File

@ -23,15 +23,28 @@ import { START_BUDGET, HARDWARE } from './contracts.js';
* 4 Wild Night gust 30.0, hail the one the whole repo tuned for
* 5 Ice Night gust 28.5, hail less wind, more ice
* Night 3 is the trap: it looks like night 2 on the forecast card and isn't.
*
* SPRINT10: a night is now a STORM and a SITE. Nights 1/2/4/5 are the backyard;
* night 3 moves to the corner block the same "looks like night 2 and isn't"
* beat, sharpened: it's a new yard you've never rigged, its carport is a trap,
* and its storm carries the southerly change that makes the venturi scream. The
* plain-string form is still accepted (defaults to backyard_01) so nothing that
* reads NIGHTS[i] as a storm key breaks.
*/
export const NIGHTS = [
'storm_01_gentle',
'storm_03_southerly',
'storm_03b_earlybuster',
'storm_02_wildnight',
'storm_02b_icenight',
{ storm: 'storm_01_gentle', site: 'backyard_01' },
{ storm: 'storm_03_southerly', site: 'backyard_01' },
{ storm: 'storm_03b_earlybuster', site: 'site_02_corner_block' },
{ storm: 'storm_02_wildnight', site: 'backyard_01' },
{ storm: 'storm_02b_icenight', site: 'backyard_01' },
];
/** A night entry, either shape → {storm, site}. */
export function nightAt(i) {
const n = NIGHTS[i];
return typeof n === 'string' ? { storm: n, site: 'backyard_01' } : n;
}
/**
* The economy, in one place because it is the thing most likely to need tuning
* once somebody actually plays five nights in a row.
@ -153,7 +166,9 @@ export function createWeek(opts = {}) {
/** 1-based, for humans. "Night 3 of 5". */
get night() { return index + 1; },
get nights() { return NIGHTS.length; },
get stormKey() { return NIGHTS[index]; },
get stormKey() { return nightAt(index).storm; },
/** SPRINT10: which yard tonight is on. main.js loads it. */
get site() { return nightAt(index).site; },
get bank() { return bank; },
get isFinalNight() { return index === NIGHTS.length - 1; },
get over() { return done; },

View File

@ -397,6 +397,30 @@ export function createWorld(scene, opts = {}) {
));
}
// --- structures (the carport, and whatever else a site tacks on) -------
// These carry ANCHORS, which is the whole point: site_02's carport looks like
// four free tie-offs and is the worst steel in the game. Graybox positions are
// approximate; dress() moves each anchor onto the node Lane E baked, with the
// rating_hint / collateral that make it a trap. The site's own asserts (E's
// e.test) pin those ratings below the fascia's — do not "fix" them upward.
const structures = new Map();
for (const st of site.structures ?? []) {
const marker = new THREE.Group();
marker.name = st.id;
marker.position.set(st.x, heightAt(st.x, st.z), st.z);
marker.rotation.y = rad(st.rotYDeg);
root.add(marker);
structures.set(st.id, { spec: st, marker });
for (const a of st.anchors ?? []) {
// Graybox guess: near the structure origin, above head height. dress()
// corrects it; until then the site is at least riggable in the selftest.
anchors.push(Object.assign(
makeStaticAnchor(a.id, a.type ?? 'post', new THREE.Vector3(st.x, heightAt(st.x, st.z) + 2.4, st.z)),
{ work: a.work },
));
}
}
// --- garden bed (the thing you are protecting) -------------------------
const bed = new THREE.Group();
bed.name = 'garden_bed';
@ -651,6 +675,26 @@ export function createWorld(scene, opts = {}) {
const anchor = table.getObjectByName('pickup_anchor');
if (anchor) shedTable.pos.setFromMatrixPosition(anchor.matrixWorld);
}
// --- structures ------------------------------------------------------
// The carport, and its trap. Load the GLB, drop it on its graybox marker,
// and move each anchor onto the node E baked — carrying the rating_hint and
// collateral that make it the worst tie-off in the game. adoptAnchor reads
// both off userData, so the "0.22 beam" trap arrives with the geometry.
for (const st of site.structures ?? []) {
const entry = structures.get(st.id);
const glb = await load(st.model);
if (!glb) continue;
retire(entry.marker);
glb.name = st.id;
glb.position.set(st.x, heightAt(st.x, st.z), st.z);
glb.rotation.y = rad(st.rotYDeg);
root.add(glb);
if (st.solid) glb.traverse((o) => { if (o.isMesh) solids.push(o); });
glb.updateWorldMatrix(true, true);
for (const a of st.anchors ?? []) adoptAnchor(glb, a.node, a.id);
}
return { shed, table };
}
@ -684,6 +728,21 @@ export function createWorld(scene, opts = {}) {
return anchors.find((a) => a.id === id) ?? null;
},
/**
* Take the whole yard out of the scene and free its GPU memory. SPRINT10:
* the week rebuilds the world when the site changes between nights, and
* without this each new yard leaks the last one's geometry and its lights
* pile up in the scene. Removes the root and both lights (skyfx borrows the
* lights and hands them back on its own dispose, so by here they're ours).
*/
dispose() {
root.traverse((o) => { o.geometry?.dispose?.(); o.material?.dispose?.(); });
root.parent?.remove(root);
sun.parent?.remove(sun);
sun.target.parent?.remove(sun.target);
hemi.parent?.remove(hemi);
},
update(dt, t) {
// Canopies lean with the wind they actually stand in — this is the tell
// the player reads a gust front from, a beat before it reaches the sail.

Binary file not shown.

Binary file not shown.