Compare commits
19 Commits
b285bbd1b0
...
f2fe5915c8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2fe5915c8 | ||
|
|
bb393566c5 | ||
|
|
df1fd4dd25 | ||
|
|
a7b859fbbf | ||
|
|
73e142947b | ||
|
|
a71d67e390 | ||
|
|
9578cde8a8 | ||
|
|
6f2990c76a | ||
|
|
def481d0c3 | ||
|
|
0f5d69b32d | ||
|
|
3c313f593f | ||
|
|
bb1106efec | ||
|
|
d222a1e0dd | ||
|
|
2996b092ae | ||
|
|
b98dcad13a | ||
|
|
d5569327c5 | ||
|
|
7fb30b33dc | ||
|
|
0cee198702 | ||
|
|
5cb838b314 |
@ -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
58
SPRINT11.md
Normal 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
|
||||
```
|
||||
242
THREADS.md
242
THREADS.md
@ -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,195 @@ 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
|
||||
|
||||
**C — your porosity → gardenHailExposure seam: already closed, no one-liner needed.** You asked
|
||||
(THREADS ~2764) whether to make `gardenHailExposure` read `sail.porosity`, or leave it to me. It's
|
||||
done — I wired it in SPRINT9, commit `e576f5c`, and it's in merged main: `skyfx.step` refreshes
|
||||
`sailPorosity` from `world.sail.porosity` (skyfx.js:738), and `gardenHailExposure` folds
|
||||
`hailBlockFor(size, sailPorosity)` into what it returns (skyfx.js:707). It's a no-op for membrane
|
||||
exactly as you specified. So skip the one-liner — the seam is wired, and `main.js:764` consumes it in
|
||||
the storm phase.
|
||||
|
||||
**But "wired" was untested end-to-end, and now it isn't.** The only test of the leak,
|
||||
`weather.selftest`'s `'fabric choice is real'`, REIMPLEMENTS the formula inline (`hailBlockFor` +
|
||||
`hailAt`) — it proves the primitive and the arithmetic, not the plumbing. A regression in the
|
||||
plumbing (the :738 refresh dropped, `wind.def.hail.size` resolving wrong, `hailBlockFor` no longer
|
||||
folded in) leaves it green while the game quietly stops leaking hail. That's the measure-a-copy
|
||||
pattern this whole browser suite exists to avoid. New assert in balance.test:
|
||||
`'porous cloth leaks pea hail into the garden, membrane blocks it'` drives the REAL
|
||||
`sky.gardenHailExposure`:
|
||||
|
||||
pea (storm_03, size 0.7): cloth 0.346 vs membrane 0.292 → porous leaks +18%
|
||||
ice (storm_02b, size 1.4): cloth 0.458 vs membrane 0.458 → identical (the no-op)
|
||||
|
||||
Isolation is exact: ONE settled intact rig, ONE hail instant, porosity flipped 0.30↔0 between reads
|
||||
(`sky.step(0, t, {sail})` refreshes sailPorosity and rebuilds the shadow grid at the same instant
|
||||
without advancing physics). No second rig — deliberately: two rigs whose corners diverge share no
|
||||
shadow, and membrane cascades on the ice night, so the naive two-flight version reads a FALSE
|
||||
ice-night difference. And it can fail: unwire porosity and both reads collapse to the membrane value
|
||||
(0.2917 == 0.2917) → red. Selftest **288 pass / 0 fail**.
|
||||
|
||||
**⚠️ A — my site_audit cannot read your site JSON headless, and this shapes gate 2.** I ran the tool
|
||||
on your committed `data/sites/backyard_01.json` (origin/lane/a). It reported *"no quad shades the bed
|
||||
— the site cannot be rigged."* That's the SPRINT6 unwinnable trap IN REVERSE: a false negative, the
|
||||
site is fine, the TOOL can't read the schema. Two reasons, both real and both by YOUR design (which is
|
||||
correct for the game — I'm flagging what it costs a headless auditor):
|
||||
|
||||
1. **Posts are pre-rake.** The JSON has `{id,x,z,h}`; `world.js:361` leans each post 8° off centre
|
||||
before it's an anchor. `p4`'s spec `(-3.2,-1.2)` dresses to `(-3.72,-1.40)` — the *exact* 0.56 m
|
||||
error my own snapshot shipped in SPRINT9. A tool reading `x/z` raw re-commits it.
|
||||
2. **House/tree anchors carry no coordinates** — only `node` (a GLB empty name). Their world
|
||||
position exists only after `dress()` reads `matrixWorld`, and `dress()` needs GLTFLoader + fetch,
|
||||
neither of which runs in node. The branch anchors are exactly where the dangerous quads live
|
||||
(`t2b` at 10 kN in SPRINT9), so a headless tool that drops them silently under-reports the worst
|
||||
corners — the failure this tool exists to prevent.
|
||||
|
||||
The single source of dressed positions is `createWorld(site).anchors`. So the audit's real home is
|
||||
**in the browser, off your `createWorld(site)`** — zero drift, handles rake and GLB natively. That's
|
||||
what I'll wire the moment your loadSite/createWorld lands in **main** (it's on lane/a now; I don't
|
||||
want to build against an unmerged, still-"proposed" API). Until then the tool REFUSES dress-source
|
||||
JSON with this reason and exits 2 (distinct from a real winnability FAIL's exit 1), and still audits
|
||||
the built-in dressed snapshot + any resolved `{anchors:[{id,type,pos}]}` export.
|
||||
|
||||
**Your call, A:** (a) I audit `site_02` in-browser via `createWorld(site).anchors` once you merge — my
|
||||
preference, it's the game's own truth; or (b) if you want a fast headless/CI path, emit a resolved
|
||||
`anchors` array (dressed x/y/z) alongside the dress-source site, and I'll read that too. Either works;
|
||||
(a) needs nothing from you but the merge.
|
||||
|
||||
**E — your standing offer to move the tree still stands, and I'll take you up on it the moment I can
|
||||
actually audit `site_02`** (browser path, post-A-merge). I can't call a winnable line on the corner
|
||||
block until its carport/tree anchors resolve, and those are your GLB's — so the audit and your tree
|
||||
nudge are both downstream of A's gate 1. Ready to run the instant it lands.
|
||||
|
||||
site_02 audit: **BLOCKED on A's gate 1** (no `data/sites/site_02_corner_block.json` on any branch yet).
|
||||
Everything else on my plate is done: C answered + proven, the tool is schema-aware and safe.
|
||||
|
||||
---
|
||||
|
||||
## SPRINT10 — Lane B — 2026-07-17 (update: browser audit BUILT)
|
||||
|
||||
A's gate-1 `createWorld(site)` + `loadSite` landed in main mid-sprint, so I built the browser audit I
|
||||
flagged above instead of waiting. **`tools/site_audit/audit.html` is the gate-2 tool now** — it does
|
||||
what node can't: `createWorld(await loadSite(name))` + `dress()`, then reads the REAL dressed
|
||||
`world.anchors` (GLB fascia + branch anchors, 8°-raked posts) and runs the sweep.
|
||||
|
||||
tools/site_audit/audit.html?site=backyard_01
|
||||
tools/site_audit/audit.html?site=site_02_corner_block&storm=storm_03_southerly
|
||||
|
||||
Validated against backyard_01: **dressed 12/12 anchors, PASS, best line p1,p2,p3,p4 $65 + $15 spare**
|
||||
— identical to the node snapshot AND to balance.test, off your real data with no snapshot anywhere.
|
||||
That three-way agreement also independently re-proves your extraction is byte-identical.
|
||||
|
||||
The winnability math is now one file, `sweep.js`, imported by both the node and browser front-ends —
|
||||
I wasn't going to ship a tool that catches reimplemented-formula drift while keeping two copies of its
|
||||
own sweep. audit.mjs output is byte-identical after the refactor.
|
||||
|
||||
**A — two knock-ons from your merge, both handled, one worth a glance:**
|
||||
1. `createWorld` now throws without a site. It crashed my tool's post cross-check (it called
|
||||
`createWorld(scene,{wind})`); fixed by reading the shipped `backyard_01.json` and passing it. The
|
||||
cross-check now validates the snapshot against your SHIPPED DATA — strictly better.
|
||||
2. Your `balance.test.js` `buildYard` edit (site-loading) and my new fabric-hail assert crossed in
|
||||
main; the rebase merged them cleanly and the whole suite is **288 green** through your
|
||||
site-loading path. Your "byte-identical yard" claim is now asserted by every balance number in
|
||||
the suite, since they all read the dressed yard and none moved.
|
||||
|
||||
**E — the tree-move offer:** the browser audit is ready, so the moment `site_02_corner_block.json`
|
||||
lands I run `audit.html?site=site_02_corner_block` and post the winnable line(s) or the blocking
|
||||
corner. If it's the latter, that's your cue. I can't audit the carport until it's on disk (its beam
|
||||
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;
|
||||
@ -2986,3 +3215,16 @@ Selftest: **277 passed, 0 failed, 0 skipped**, LANE BAL now visible.
|
||||
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.
|
||||
|
||||
@ -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": [
|
||||
|
||||
@ -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 |
132
tools/site_audit/audit.html
Normal file
132
tools/site_audit/audit.html
Normal file
@ -0,0 +1,132 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
site_audit, browser front-end. [Lane B, SPRINT10 — the gate-2 tool]
|
||||
|
||||
The node front-end (audit.mjs) is fast but blind: it cannot dress, so it only
|
||||
sees posts + a hand-kept snapshot, and it REFUSES dress-source site JSON rather
|
||||
than lie about a yard it can't resolve. This page is the honest audit. It builds
|
||||
the site exactly the way the game does — createWorld(await loadSite(name)) then
|
||||
dress() — and reads world.anchors, the single source of DRESSED positions:
|
||||
house fascia and tree branch anchors baked into E's GLBs, posts raked 8°, all of
|
||||
it. Then it runs the SAME sweep.js the node tool runs. This is the only way to
|
||||
audit a site with a carport (site_02) before it ships.
|
||||
|
||||
tools/site_audit/audit.html?site=backyard_01
|
||||
tools/site_audit/audit.html?site=site_02_corner_block&storm=storm_03_southerly
|
||||
|
||||
Served from the repo root (server.py), so the relative imports resolve.
|
||||
-->
|
||||
<meta charset="utf-8">
|
||||
<title>site_audit</title>
|
||||
<style>
|
||||
body { background:#111; color:#ddd; font:13px/1.5 ui-monospace,Menlo,monospace; margin:0; padding:20px; }
|
||||
h1 { font-size:15px; color:#fff; margin:0 0 2px; }
|
||||
.sub { color:#888; margin-bottom:14px; white-space:pre-wrap; }
|
||||
table { border-collapse:collapse; margin:8px 0; }
|
||||
td { padding:1px 10px 1px 0; white-space:nowrap; }
|
||||
.ok { color:#6c6; } .bad { color:#e66; } .warn { color:#dc6; }
|
||||
.verdict { font-size:14px; margin-top:14px; padding:10px 12px; border-radius:6px; white-space:pre-wrap; }
|
||||
.verdict.pass { background:#132; color:#8e8; } .verdict.fail { background:#311; color:#f99; }
|
||||
.corner { color:#9ab; } .none { color:#e66; font-weight:bold; }
|
||||
</style>
|
||||
<h1>site_audit</h1>
|
||||
<div class="sub" id="sub">loading…</div>
|
||||
<div id="out"></div>
|
||||
<div id="verdict"></div>
|
||||
|
||||
<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 { createWorld, loadSite } from '../../web/world/js/world.js';
|
||||
import { HARDWARE, START_BUDGET } from '../../web/world/js/contracts.js';
|
||||
import { AUDIT, auditSweep } from './sweep.js';
|
||||
|
||||
const q = new URLSearchParams(location.search);
|
||||
const siteName = q.get('site') || 'backyard_01';
|
||||
const stormName = q.get('storm') || 'storm_02_wildnight';
|
||||
const el = (id) => document.getElementById(id);
|
||||
|
||||
const loadJSON = async (path) => (await fetch(path)).json();
|
||||
|
||||
async function run() {
|
||||
// Build the site the way the game does — data in, dressed yard out.
|
||||
const site = await loadSite(siteName);
|
||||
const scene = new THREE.Scene();
|
||||
const calmStub = {
|
||||
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
|
||||
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
|
||||
gustTelegraph: () => null, setSheltersFromTrees() {}, eventsBetween: () => [],
|
||||
};
|
||||
const world = createWorld(scene, { wind: calmStub, site });
|
||||
let dressed = false;
|
||||
if (world.dress) { try { await world.dress(); dressed = true; } catch (e) { /* fall through, flagged below */ } }
|
||||
|
||||
// world.anchors are the REAL dressed positions — freeze sway like balance.test.
|
||||
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 stormDef = await loadJSON(`../../web/world/data/storms/${stormName}.json`);
|
||||
const calmDef = await loadJSON(`../../web/world/data/storms/${AUDIT.CALM_STORM}.json`);
|
||||
|
||||
el('sub').textContent =
|
||||
`${site.name || siteName} — ${anchors.length} dressed anchors ${dressed ? '(GLBs loaded ✓)' : '(⚠ dress() FAILED — graybox positions, not what ships)'}\n` +
|
||||
`storm: ${stormName} (${stormDef.duration}s, downdraft ${stormDef.gusts?.downdraftOfTotal ?? '—'})\n` +
|
||||
`shop: $${START_BUDGET} · ${HARDWARE.map((h) => `${h.name} $${h.cost}/${(h.rating / 1000).toFixed(1)}kN`).join(' · ')}`;
|
||||
|
||||
const { cands, rows, verdict, winners } = auditSweep({ anchors, bed: world.gardenBed, stormDef, calmDef });
|
||||
|
||||
// render rows
|
||||
const tbl = document.createElement('table');
|
||||
for (const r of rows) {
|
||||
const tr = tbl.insertRow();
|
||||
tr.insertCell().textContent = r.ids.join(',');
|
||||
tr.insertCell().textContent = `${r.area.toFixed(0)} m²`;
|
||||
tr.insertCell().textContent = `cover ${(r.cover * 100).toFixed(0)}%`;
|
||||
const mk = tr.insertCell();
|
||||
if (r.unholdable.length) { mk.textContent = '✗ unholdable'; mk.className = 'bad'; }
|
||||
else if (r.hw <= START_BUDGET) { mk.textContent = `✓ $${r.hw}`; mk.className = 'ok'; }
|
||||
else { mk.textContent = `✗ $${r.hw} > $${START_BUDGET}`; mk.className = 'warn'; }
|
||||
const cs = tr.insertCell(); cs.className = 'corner';
|
||||
cs.innerHTML = r.tiers.map((c) =>
|
||||
`${c.id} ${(c.peak / 1000).toFixed(1)}kN→${c.tier ? '$' + c.tier.cost : '<span class="none">NONE</span>'}`).join(' ');
|
||||
}
|
||||
el('out').appendChild(tbl);
|
||||
|
||||
// verdict
|
||||
const v = el('verdict');
|
||||
if (verdict.code === 'no-cover') {
|
||||
v.className = 'verdict fail';
|
||||
v.textContent = `✗ FAIL — no quad in the ${AUDIT.BAND.lo}-${AUDIT.BAND.hi} m² band shades the bed at all.\n` +
|
||||
`The site cannot be rigged. It needs an anchor near the bed before it ships.`;
|
||||
} else if (!verdict.ok) {
|
||||
const b = verdict.best;
|
||||
v.className = 'verdict fail';
|
||||
v.textContent = `✗ FAIL — no affordable holding line. Cheapest is ${b.ids.join(',')} at $${b.hw} on a $${START_BUDGET} budget` +
|
||||
(b.unholdable.length ? `, and ${b.unholdable.map((c) => c.id).join('/')} is over the shop's ${(HARDWARE.at(-1).rating / 1000).toFixed(1)} kN ceiling at any price.` : '.') +
|
||||
`\nThe SPRINT6 p1=7.4 kN failure. Move an anchor (E's standing offer), or the site ships unwinnable.`;
|
||||
} else {
|
||||
const w = verdict.best;
|
||||
v.className = 'verdict pass';
|
||||
v.textContent = `✓ PASS — ${winners.length} affordable line(s). Best: ${w.ids.join(',')} — $${w.hw} hardware` +
|
||||
`${w.total <= START_BUDGET ? ` (+$${AUDIT.SPARE_COST} spare = $${w.total}, inside budget)` : ` — no room for a $${AUDIT.SPARE_COST} spare`}` +
|
||||
`, ${w.area.toFixed(0)} m², ${(w.cover * 100).toFixed(0)}% of the bed.`;
|
||||
}
|
||||
// a machine-readable line, so this page can also be driven headless-in-browser
|
||||
window.__audit = { site: siteName, storm: stormName, dressed, anchors: anchors.length, cands: cands.length, verdict, winners: winners.map((w) => ({ ids: w.ids, hw: w.hw })) };
|
||||
document.title = `site_audit — ${verdict.ok ? 'PASS' : 'FAIL'}`;
|
||||
}
|
||||
|
||||
run().catch((e) => {
|
||||
el('verdict').className = 'verdict fail';
|
||||
el('verdict').textContent = `site_audit crashed: ${e.message}\n${e.stack || ''}`;
|
||||
window.__audit = { error: e.message };
|
||||
document.title = 'site_audit — ERROR';
|
||||
});
|
||||
</script>
|
||||
@ -24,17 +24,8 @@
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { SailRig, orderRing } from '../../web/world/js/sail.js';
|
||||
import { createWind } from '../../web/world/js/weather.js';
|
||||
import { HARDWARE, START_BUDGET, FIXED_DT } from '../../web/world/js/contracts.js';
|
||||
|
||||
const [CARABINER, SHACKLE, RATED] = HARDWARE;
|
||||
const SPARE_COST = 15;
|
||||
const BAND = { lo: 18, hi: 45 }; // A's a.test rigging band
|
||||
const MIN_COVER = 0.25; // A's "shades the bed" bar
|
||||
const SETTLE_S = 12; // D's settle — a player plays through prep
|
||||
const CALM_STORM = 'storm_01_gentle'; // main.js's CALM_STORM: what wind.use() hands the rig in prep
|
||||
const PRE_GUST_S = 3; // hold the settle inside gentle's pre-gust window (balance.test)
|
||||
import { HARDWARE, START_BUDGET } from '../../web/world/js/contracts.js';
|
||||
import { AUDIT, auditSweep } from './sweep.js';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const stormArg = (() => { const i = argv.indexOf('--storm'); return i >= 0 ? argv[i + 1] : 'storm_02_wildnight'; })();
|
||||
@ -91,6 +82,12 @@ const VERIFIABLE = new Set(['p1', 'p2', 'p3', 'p4']);
|
||||
/**
|
||||
* Re-derive the posts from live world.js and fail loudly if the dump drifted.
|
||||
* The one guard-rail a hand-typed yard can actually have.
|
||||
*
|
||||
* SPRINT10: world.js stopped being code — createWorld now REQUIRES a site and
|
||||
* throws without one. It reads the site from data/sites/backyard_01.json, which
|
||||
* is exactly the extraction A shipped, so this cross-check now compares the dump
|
||||
* against the DATA's posts (raked in createWorld, no dress needed). loadSite()
|
||||
* itself fetch()es and can't run in node, so read the JSON and hand it over raw.
|
||||
*/
|
||||
async function verifyPosts(site) {
|
||||
const THREE = await import('../../web/world/vendor/three.module.js');
|
||||
@ -100,7 +97,8 @@ async function verifyPosts(site) {
|
||||
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
|
||||
gustTelegraph: () => null, setSheltersFromTrees() {}, eventsBetween: () => [],
|
||||
};
|
||||
const world = createWorld(new THREE.Scene(), { wind: stub }); // graybox: fine, posts are graybox
|
||||
const siteData = JSON.parse(await readFile(new URL('../../web/world/data/sites/backyard_01.json', import.meta.url), 'utf8'));
|
||||
const world = createWorld(new THREE.Scene(), { wind: stub, site: siteData }); // graybox: fine, posts are raked in createWorld
|
||||
const live = new Map(world.anchors.map((a) => [a.id, a.pos]));
|
||||
const drift = [];
|
||||
for (const a of site.anchors) {
|
||||
@ -119,7 +117,46 @@ async function verifyPosts(site) {
|
||||
async function loadSite(path) {
|
||||
if (!path) return BACKYARD_01;
|
||||
const j = JSON.parse(await readFile(path, 'utf8'));
|
||||
// site-as-data shape (Lane A, gate 2). Tolerant: only anchors + bed are needed.
|
||||
|
||||
// A's committed site schema (SPRINT10, world.js loadSite/createWorld) is
|
||||
// DRESS-SOURCE, not resolved positions, and headless node cannot turn one into
|
||||
// the other. Two reasons, both load-bearing for an audit that must not lie:
|
||||
//
|
||||
// · POSTS are pre-rake. The JSON carries {id,x,z,h}; world.js:361 leans every
|
||||
// post 8° away from the yard centre before it becomes an anchor. p4's JSON
|
||||
// spec (-3.2,-1.2) dresses to (-3.72,-1.40) — 0.56 m, the exact error this
|
||||
// tool's own snapshot shipped with in SPRINT9. Reading x/z raw re-makes it.
|
||||
// · HOUSE and TREE anchors have NO coordinates at all — just `node`, a name
|
||||
// baked into E's GLB (fascia_anchor_01, branch_anchor_02…). Their world
|
||||
// position exists only after dress() reads the empty's matrixWorld, and
|
||||
// dress() needs GLTFLoader + fetch, neither of which runs in node.
|
||||
//
|
||||
// So a headless read of this file gets posts wrong and tree/house anchors not
|
||||
// at all — and the branch anchors are exactly where the dangerous quads live
|
||||
// (t2b pulled 10 kN in SPRINT9). Reporting on that silently is the SPRINT6
|
||||
// trap in reverse: a site called unriggable because the TOOL couldn't read it.
|
||||
//
|
||||
// The correct source of dressed positions is createWorld(site).anchors — which
|
||||
// is browser-only. Until that path lands (this is raised to A in THREADS), the
|
||||
// honest move is to refuse this schema loudly, not to guess at it.
|
||||
const dressSource = Array.isArray(j.posts) || j.house || Array.isArray(j.trees) || Array.isArray(j.structures);
|
||||
const resolved = Array.isArray(j.anchors) && j.anchors.length
|
||||
&& j.anchors.every((a) => Number.isFinite(a.pos?.x ?? a.x));
|
||||
if (dressSource && !resolved) {
|
||||
const name = (j.id || j.name || 'the_site').replace(/\.json$/, '');
|
||||
throw new Error(
|
||||
`"${j.name || j.id || path}" is a DRESS-SOURCE site (posts/house/trees), and its anchor\n` +
|
||||
` positions cannot be resolved headless: posts are pre-rake (world.js leans them 8°) and\n` +
|
||||
` house/tree anchors are GLB node refs that only exist after dress(), which node cannot run.\n` +
|
||||
` → Audit it in the browser, off the real dressed world.anchors:\n` +
|
||||
` tools/site_audit/audit.html?site=${name}\n` +
|
||||
` (serve the repo with server.py, open that URL — it dresses the yard the way the game\n` +
|
||||
` does and runs the same sweep). Or hand THIS tool a resolved\n` +
|
||||
` { anchors:[{id,type,pos:{x,y,z}}] } export. No argument audits the built-in snapshot.`);
|
||||
}
|
||||
|
||||
// Resolved-positions shape: a flat anchors[] each carrying real coordinates.
|
||||
// This is what a dressed export (or a future createWorld dump) would hand us.
|
||||
const anchors = (j.anchors || []).map((a) => ({
|
||||
id: a.id, type: a.type || 'post',
|
||||
pos: { x: a.pos?.x ?? a.x, y: a.pos?.y ?? a.y, z: a.pos?.z ?? a.z },
|
||||
@ -129,21 +166,11 @@ async function loadSite(path) {
|
||||
|
||||
const withSway = (list) => list.map((a) => ({ ...a, sway: () => a.pos }));
|
||||
|
||||
/** Ground-plane area, shoelace over the ring — the same formula a.test uses. */
|
||||
function areaOf(q) {
|
||||
const r = orderRing(q);
|
||||
let a = 0;
|
||||
for (let i = 0, j = r.length - 1; i < r.length; j = i++) a += (r[j].pos.x + r[i].pos.x) * (r[j].pos.z - r[i].pos.z);
|
||||
return Math.abs(a / 2);
|
||||
}
|
||||
|
||||
/** Cheapest hardware that holds a corner at `peak` kN, or null if the shop can't. */
|
||||
const tierFor = (peakN) => HARDWARE.find((h) => h.rating >= peakN) || null;
|
||||
|
||||
async function main() {
|
||||
const site = await loadSite(sitePath);
|
||||
const anchors = withSway(site.anchors);
|
||||
const def = JSON.parse(await readFile(new URL(`../../web/world/data/storms/${stormArg}.json`, import.meta.url), 'utf8'));
|
||||
const calmDef = JSON.parse(await readFile(new URL(`../../web/world/data/storms/${AUDIT.CALM_STORM}.json`, import.meta.url), 'utf8'));
|
||||
|
||||
console.log(`\nsite_audit — ${site.name}`);
|
||||
if (site.dumped) {
|
||||
@ -162,71 +189,15 @@ async function main() {
|
||||
console.log(`storm: ${stormArg} (${def.duration}s, downdraftOfTotal ${def.gusts?.downdraftOfTotal ?? '—'})`);
|
||||
console.log(`shop: $${START_BUDGET} · ${HARDWARE.map((h) => `${h.name} $${h.cost}/${(h.rating / 1000).toFixed(1)}kN`).join(' · ')}\n`);
|
||||
|
||||
// 1. every quad, in the rigging band, that shades the bed
|
||||
const cands = [];
|
||||
const A = anchors;
|
||||
for (let a = 0; a < A.length; a++) for (let b = a + 1; b < A.length; b++)
|
||||
for (let c = b + 1; c < A.length; c++) for (let d = c + 1; d < A.length; d++) {
|
||||
const q = [A[a], A[b], A[c], A[d]];
|
||||
const area = areaOf(q);
|
||||
if (area < BAND.lo || area > BAND.hi) continue;
|
||||
let rig;
|
||||
try {
|
||||
rig = new SailRig({ anchors, gridN: 10 }).attach(q.map((x) => x.id), Array(4).fill(HARDWARE[2]), 1.0);
|
||||
} catch { continue; } // degenerate ring
|
||||
const cover = rig.coverageOver(site.bed, { x: 0, y: 1, z: 0 });
|
||||
if (cover >= MIN_COVER) cands.push({ ids: q.map((x) => x.id), area, cover });
|
||||
}
|
||||
// The sweep itself is shared with the browser front-end — see sweep.js.
|
||||
const { cands, rows, winners, verdict } = auditSweep({ anchors, bed: site.bed, stormDef: def, calmDef });
|
||||
|
||||
if (!cands.length) {
|
||||
console.log(`✗ FAIL — no quad in the ${BAND.lo}-${BAND.hi} m² band shades the bed at all.`);
|
||||
if (verdict.code === 'no-cover') {
|
||||
console.log(`✗ FAIL — no quad in the ${AUDIT.BAND.lo}-${AUDIT.BAND.hi} m² band shades the bed at all.`);
|
||||
console.log(` The site cannot be rigged. It needs an anchor near the bed before it ships.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. peak corner loads, settled, on the real storm. This is the p1=7.4 kN check.
|
||||
//
|
||||
// This drives the wind the way main.js does, and every clause below is here
|
||||
// because the first draft skipped it and MIS-MEASURED:
|
||||
//
|
||||
// · createWind (not the raw field) + setSheltersFromTrees — trees knock a
|
||||
// hole downwind, and a quad hanging off tree anchors sits in it. main.js
|
||||
// does this at boot.
|
||||
// · the settle runs on the CALM day on a RUNNING clock, because that is what
|
||||
// wind.use() hands the rig during prep. The first draft settled on STORM
|
||||
// wind frozen at t=0 — the exact anti-pattern balance.test documents at
|
||||
// 1.94 kN vs 0.40 kN of standing load at storm entry.
|
||||
// · resetPeaks() at storm entry, because peakLoad is peak-since-ATTACH and
|
||||
// was quietly folding the attach transient + settle into the storm peak.
|
||||
//
|
||||
// A tool that vets sites is worthless if it doesn't fly the storm the game
|
||||
// flies. It reported p1 at 1.1 kN with all three of those wrong.
|
||||
const trees = site.anchors.filter((a) => a.type === 'tree');
|
||||
const wind = createWind(def);
|
||||
wind.setSheltersFromTrees(trees);
|
||||
const calmDef = JSON.parse(await readFile(new URL(`../../web/world/data/storms/${CALM_STORM}.json`, import.meta.url), 'utf8'));
|
||||
const calmWind = createWind(calmDef);
|
||||
calmWind.setSheltersFromTrees(trees);
|
||||
|
||||
const rows = [];
|
||||
for (const cnd of cands) {
|
||||
// shade cloth: the fabric a competent player takes into a windy night
|
||||
const rig = new SailRig({ anchors, gridN: 10, porosity: 0.30 })
|
||||
.attach(cnd.ids, Array(4).fill({ name: 'audit', cost: 0, rating: Infinity }), 1.0);
|
||||
for (let i = 0, n = Math.round(SETTLE_S / FIXED_DT); i < n; i++) {
|
||||
rig.step(FIXED_DT, calmWind, (i * FIXED_DT) % PRE_GUST_S);
|
||||
}
|
||||
rig.resetPeaks(); // ← the storm starts HERE
|
||||
for (let i = 0; i < def.duration * 60; i++) rig.step(FIXED_DT, wind, i * FIXED_DT);
|
||||
|
||||
const corners = rig.corners.map((c) => ({ id: c.anchorId, peak: c.peakLoad }));
|
||||
const tiers = corners.map((c) => ({ ...c, tier: tierFor(c.peak) }));
|
||||
const unholdable = tiers.filter((c) => !c.tier);
|
||||
const hw = tiers.reduce((s, c) => s + (c.tier ? c.tier.cost : 0), 0);
|
||||
rows.push({ ...cnd, tiers, unholdable, hw, total: hw + SPARE_COST, affordable: !unholdable.length && hw <= START_BUDGET });
|
||||
}
|
||||
rows.sort((a, b) => (a.affordable === b.affordable ? a.hw - b.hw : a.affordable ? -1 : 1));
|
||||
|
||||
console.log(`${cands.length} quad(s) in band shading the bed:\n`);
|
||||
for (const r of rows) {
|
||||
const cs = r.tiers.map((c) => `${c.id} ${(c.peak / 1000).toFixed(1)}kN→${c.tier ? '$' + c.tier.cost : 'NONE'}`).join(' ');
|
||||
@ -234,18 +205,17 @@ async function main() {
|
||||
console.log(` ${r.ids.join(',').padEnd(18)} ${r.area.toFixed(0).padStart(3)} m² cover ${(r.cover * 100).toFixed(0).padStart(3)}% ${mark.padEnd(14)} ${cs}`);
|
||||
}
|
||||
|
||||
const winners = rows.filter((r) => r.affordable);
|
||||
console.log('');
|
||||
if (!winners.length) {
|
||||
const best = rows[0];
|
||||
if (!verdict.ok) {
|
||||
const best = verdict.best;
|
||||
console.log(`✗ FAIL — no affordable holding line. Cheapest is ${best.ids.join(',')} at $${best.hw} on a $${START_BUDGET} budget` +
|
||||
(best.unholdable.length ? `, and ${best.unholdable.map((c) => c.id).join('/')} exceeds the shop's ${(HARDWARE.at(-1).rating / 1000).toFixed(1)} kN ceiling at any price.` : '.'));
|
||||
console.log(` This is the SPRINT6 p1=7.4 kN failure. Move an anchor, or the site ships unwinnable.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const w = winners[0];
|
||||
const w = verdict.best;
|
||||
console.log(`✓ PASS — ${winners.length} affordable line(s). Best: ${w.ids.join(',')} — $${w.hw} hardware` +
|
||||
`${w.total <= START_BUDGET ? ` (+$${SPARE_COST} spare = $${w.total}, still inside budget)` : ` — no room for a $${SPARE_COST} spare`}` +
|
||||
`${w.total <= START_BUDGET ? ` (+$${AUDIT.SPARE_COST} spare = $${w.total}, still inside budget)` : ` — no room for a $${AUDIT.SPARE_COST} spare`}` +
|
||||
`, ${w.area.toFixed(0)} m², ${(w.cover * 100).toFixed(0)}% of the bed.\n`);
|
||||
}
|
||||
|
||||
|
||||
106
tools/site_audit/sweep.js
Normal file
106
tools/site_audit/sweep.js
Normal file
@ -0,0 +1,106 @@
|
||||
/**
|
||||
* sweep.js — the winnability sweep, shared by BOTH front-ends. [Lane B, SPRINT10]
|
||||
*
|
||||
* There is exactly one copy of "is this site winnable" and this is it. audit.mjs
|
||||
* (node, fast, but blind to GLB-dressed anchors) and audit.html (browser, reads
|
||||
* the fully dressed createWorld(site).anchors) both import auditSweep and only
|
||||
* differ in how they GET the anchors and how they PRINT the result. A tool built
|
||||
* to catch reimplemented-formula drift must not carry two copies of its own math.
|
||||
*
|
||||
* Pure given its inputs: hand it resolved anchors (each {id, type, pos, sway}),
|
||||
* the bed rect, and the storm + calm-day defs. It flies the same settle+storm the
|
||||
* game flies and returns ranked rows + a verdict. No I/O, no process, no DOM.
|
||||
*/
|
||||
|
||||
import { SailRig, orderRing } from '../../web/world/js/sail.js';
|
||||
import { createWind } from '../../web/world/js/weather.js';
|
||||
import { HARDWARE, START_BUDGET, FIXED_DT } from '../../web/world/js/contracts.js';
|
||||
|
||||
/** Audit knobs, in one place so both front-ends and any future site agree. */
|
||||
export const AUDIT = {
|
||||
BAND: { lo: 18, hi: 45 }, // A's a.test rigging band, m²
|
||||
MIN_COVER: 0.25, // A's "shades the bed" bar
|
||||
SETTLE_S: 12, // D's settle — a player plays through prep
|
||||
PRE_GUST_S: 3, // hold the settle inside gentle's pre-gust window (balance.test)
|
||||
SPARE_COST: 15, // a $15 spare is the difference between a repair and a prayer
|
||||
CALM_STORM: 'storm_01_gentle',
|
||||
};
|
||||
|
||||
/** Ground-plane area, shoelace over the ring — the same formula a.test uses. */
|
||||
export function areaOf(q) {
|
||||
const r = orderRing(q);
|
||||
let a = 0;
|
||||
for (let i = 0, j = r.length - 1; i < r.length; j = i++) a += (r[j].pos.x + r[i].pos.x) * (r[j].pos.z - r[i].pos.z);
|
||||
return Math.abs(a / 2);
|
||||
}
|
||||
|
||||
/** Cheapest hardware that holds a corner at `peakN` newtons, or null if the shop can't. */
|
||||
export const tierFor = (peakN) => HARDWARE.find((h) => h.rating >= peakN) || null;
|
||||
|
||||
/**
|
||||
* Sweep every bed-covering quad and price the cheapest holding line.
|
||||
* @param {object} o
|
||||
* @param {Array} o.anchors resolved anchors: { id, type, pos:{x,y,z}, sway }
|
||||
* @param {object} o.bed garden bed rect { x, z, w, d }
|
||||
* @param {object} o.stormDef the storm JSON to fly
|
||||
* @param {object} o.calmDef the calm-day JSON to settle on (storm_01_gentle)
|
||||
* @returns {{ cands, rows, winners, verdict:{ ok:boolean, code:string, best } }}
|
||||
*/
|
||||
export function auditSweep({ anchors, bed, stormDef, calmDef }) {
|
||||
// 1. every quad, in the rigging band, that shades the bed
|
||||
const cands = [];
|
||||
for (let a = 0; a < anchors.length; a++) for (let b = a + 1; b < anchors.length; b++)
|
||||
for (let c = b + 1; c < anchors.length; c++) for (let d = c + 1; d < anchors.length; d++) {
|
||||
const q = [anchors[a], anchors[b], anchors[c], anchors[d]];
|
||||
const area = areaOf(q);
|
||||
if (area < AUDIT.BAND.lo || area > AUDIT.BAND.hi) continue;
|
||||
let rig;
|
||||
try {
|
||||
rig = new SailRig({ anchors, gridN: 10 }).attach(q.map((x) => x.id), Array(4).fill(HARDWARE[2]), 1.0);
|
||||
} catch { continue; } // degenerate ring
|
||||
const cover = rig.coverageOver(bed, { x: 0, y: 1, z: 0 });
|
||||
if (cover >= AUDIT.MIN_COVER) cands.push({ ids: q.map((x) => x.id), area, cover });
|
||||
}
|
||||
|
||||
if (!cands.length) return { cands, rows: [], winners: [], verdict: { ok: false, code: 'no-cover', best: null } };
|
||||
|
||||
// 2. peak corner loads, settled the way the game settles, on the real storm.
|
||||
// Every clause here is a bug the first draft shipped:
|
||||
// · createWind + setSheltersFromTrees — trees knock a hole downwind.
|
||||
// · settle on the CALM day on a RUNNING clock (main.js's prep), not storm
|
||||
// wind frozen at t=0 (that read 1.94 kN of standing load vs the true 0.40).
|
||||
// · resetPeaks() at entry — peakLoad is peak-since-ATTACH otherwise, folding
|
||||
// the settle transient into the storm peak.
|
||||
const trees = anchors.filter((a) => a.type === 'tree');
|
||||
const wind = createWind(stormDef);
|
||||
wind.setSheltersFromTrees(trees);
|
||||
const calmWind = createWind(calmDef);
|
||||
calmWind.setSheltersFromTrees(trees);
|
||||
|
||||
const rows = [];
|
||||
for (const cnd of cands) {
|
||||
// shade cloth (porosity 0.30): the fabric a competent player takes into a windy night
|
||||
const rig = new SailRig({ anchors, gridN: 10, porosity: 0.30 })
|
||||
.attach(cnd.ids, Array(4).fill({ name: 'audit', cost: 0, rating: Infinity }), 1.0);
|
||||
for (let i = 0, n = Math.round(AUDIT.SETTLE_S / FIXED_DT); i < n; i++) {
|
||||
rig.step(FIXED_DT, calmWind, (i * FIXED_DT) % AUDIT.PRE_GUST_S);
|
||||
}
|
||||
rig.resetPeaks(); // ← the storm starts HERE
|
||||
for (let i = 0; i < stormDef.duration * 60; i++) rig.step(FIXED_DT, wind, i * FIXED_DT);
|
||||
|
||||
const tiers = rig.corners.map((c) => ({ id: c.anchorId, peak: c.peakLoad, tier: tierFor(c.peakLoad) }));
|
||||
const unholdable = tiers.filter((c) => !c.tier);
|
||||
const hw = tiers.reduce((s, c) => s + (c.tier ? c.tier.cost : 0), 0);
|
||||
rows.push({ ...cnd, tiers, unholdable, hw, total: hw + AUDIT.SPARE_COST,
|
||||
affordable: !unholdable.length && hw <= START_BUDGET });
|
||||
}
|
||||
rows.sort((a, b) => (a.affordable === b.affordable ? a.hw - b.hw : a.affordable ? -1 : 1));
|
||||
|
||||
const winners = rows.filter((r) => r.affordable);
|
||||
return {
|
||||
cands, rows, winners,
|
||||
verdict: winners.length
|
||||
? { ok: true, code: 'pass', best: winners[0] }
|
||||
: { ok: false, code: 'unaffordable', best: rows[0] },
|
||||
};
|
||||
}
|
||||
86
web/world/data/sites/backyard_01.json
Normal file
86
web/world/data/sites/backyard_01.json
Normal file
@ -0,0 +1,86 @@
|
||||
{
|
||||
"id": "backyard_01",
|
||||
"name": "The Backyard",
|
||||
"blurb": "Your own place. You know where everything is, which is the only advantage you get.",
|
||||
|
||||
"yard": { "width": 30, "depth": 20 },
|
||||
|
||||
"sun": { "elevationDeg": 55, "azimuthDeg": -125 },
|
||||
|
||||
"gardenBed": { "x": 1, "z": 2, "w": 6, "d": 4 },
|
||||
|
||||
"house": {
|
||||
"model": "house_yardside_v1",
|
||||
"x": 0, "z": -10.5,
|
||||
"anchors": [
|
||||
{ "id": "h1", "node": "fascia_anchor_01", "type": "house", "work": "bracket" },
|
||||
{ "id": "h2", "node": "fascia_anchor_02", "type": "house", "work": "bracket" },
|
||||
{ "id": "h3", "node": "fascia_anchor_03", "type": "house", "work": "bracket" }
|
||||
]
|
||||
},
|
||||
|
||||
"trees": [
|
||||
{
|
||||
"id": "t1", "model": "tree_gum_01_v1",
|
||||
"x": -9, "z": 2, "phase": 0.7, "trunkH": 4.2, "anchorY": 3.4,
|
||||
"anchors": [
|
||||
{ "id": "t1", "node": "branch_anchor_01", "type": "tree", "work": "cloth" },
|
||||
{ "id": "t1b", "node": "branch_anchor_02", "type": "tree", "work": "cloth" },
|
||||
{ "id": "t1c", "node": "branch_anchor_03", "type": "tree", "work": "cloth" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "t2", "model": "tree_gum_02_v1",
|
||||
"x": 8, "z": -2, "phase": 2.9, "trunkH": 3.8, "anchorY": 3.1,
|
||||
"anchors": [
|
||||
{ "id": "t2", "node": "branch_anchor_01", "type": "tree", "work": "cloth" },
|
||||
{ "id": "t2b", "node": "branch_anchor_02", "type": "tree", "work": "cloth" }
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"posts": [
|
||||
{ "id": "p1", "x": -4.5, "z": 5.5, "h": 4.0, "type": "post", "work": "cloth" },
|
||||
{ "id": "p2", "x": 4.0, "z": 6.0, "h": 4.0, "type": "post", "work": "cloth" },
|
||||
{ "id": "p3", "x": 0, "z": 7.0, "h": 4.0, "type": "post", "work": "cloth" },
|
||||
{
|
||||
"id": "p4", "x": -3.2, "z": -1.2, "h": 4.0, "type": "post", "work": "cloth",
|
||||
"_why": [
|
||||
"DO NOT move this post toward the bed. Its position is swept, not chosen, and it is the",
|
||||
"single number holding DESIGN.md's central tension open.",
|
||||
"",
|
||||
"Before p4 the wild night had NO winnable line, for a geometric reason: every anchor near",
|
||||
"the bed (p1/p2/p3) is SOUTH of it and nothing stands north short of the house 10 m away,",
|
||||
"so covering rigs had to span the yard and died while rigs small enough to survive sat",
|
||||
"beside the bed rather than over it. p4 supplies the missing north-west corner.",
|
||||
"",
|
||||
"Swept against the smallest quad covering 90% of the bed:",
|
||||
" (-2.2, -1.2) -> 44.4 m2 DO NOT: full coverage lands INSIDE the survivable band,",
|
||||
" so covering the bed stops costing risk and the rigging",
|
||||
" puzzle loses its wrong answers.",
|
||||
" (-3.2, -1.2) -> 48.6 m2 <- here (with the 8 deg rake the top anchor lands at",
|
||||
" -3.72,-1.40 and full coverage costs 51.6 m2)",
|
||||
" (-3.2, -2.0) -> 51.7 m2",
|
||||
" (-4.2, -3.0) -> 60.0 m2",
|
||||
" beyond z=-4 -> 63.5 m2, i.e. too far out to matter at all",
|
||||
"",
|
||||
"Full coverage costs 63.5 m2 without it and 51.6 with: a real new option, still bigger",
|
||||
"than the 23-38 m2 rigs that survive unaided. a.test.js asserts the >45 m2 floor, so",
|
||||
"moving this inward goes red — that assert is the tension's only guard."
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"shed": { "model": "shed_01_v1", "x": 11.8, "z": 6.2, "rotYDeg": -90 },
|
||||
"shedTable": { "model": "shed_table_v1", "x": 9, "z": 6, "rotYDeg": -90, "pickupNode": "pickup_anchor" },
|
||||
|
||||
"gnome": { "model": "garden_gnome_01_v1", "x": 4.3, "z": 4.4, "rotYDeg": -126, "collateralValue": 25 },
|
||||
|
||||
"fence": {
|
||||
"sides": ["south", "west", "east"]
|
||||
},
|
||||
|
||||
"wind": {
|
||||
"venturi": []
|
||||
}
|
||||
}
|
||||
80
web/world/data/sites/site_02_corner_block.json
Normal file
80
web/world/data/sites/site_02_corner_block.json
Normal 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 }
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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>` : ''}
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { FIXED_DT, PHASES, STORM_LEN, HARDWARE, SPARE_COST, Emitter } from './contracts.js';
|
||||
import { createWorld } from './world.js';
|
||||
import { createWorld, loadSite } from './world.js';
|
||||
import { createCameraRig } from './camera.js';
|
||||
import { loadStorm, createWind } from './weather.js';
|
||||
import { SailRig, createSailView } from './sail.js';
|
||||
@ -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,24 +376,89 @@ export async function boot(opts = {}) {
|
||||
const wind = createWindRouter(Object.values(winds));
|
||||
|
||||
// --- world & camera -----------------------------------------------------
|
||||
const world = createWorld(scene, { wind });
|
||||
// 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;
|
||||
|
||||
/**
|
||||
@ -507,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,
|
||||
@ -618,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
|
||||
@ -630,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'); },
|
||||
);
|
||||
@ -663,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
|
||||
@ -802,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; },
|
||||
|
||||
@ -5,18 +5,21 @@
|
||||
|
||||
import * as THREE from '../../vendor/three.module.js';
|
||||
import { FIXED_DT, STORM_LEN, YARD, checkContract, createStubWind } from '../contracts.js';
|
||||
import { createWorld, heightAt } from '../world.js';
|
||||
import { createWorld, heightAt, loadSite, validateSite } from '../world.js';
|
||||
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 */
|
||||
export default async function run(t) {
|
||||
const scene = new THREE.Scene();
|
||||
const world = createWorld(scene, { wind: createStubWind({ calm: true }) });
|
||||
// SPRINT10: the yard is data now. Loading the REAL site rather than a fixture
|
||||
// is the point — these asserts are the proof that the extraction moved nothing.
|
||||
const site = await loadSite('backyard_01');
|
||||
const world = createWorld(scene, { wind: createStubWind({ calm: true }), site });
|
||||
|
||||
// Dress the yard before asserting anything about it: anchors are only FINAL
|
||||
// after dress(), which moves them onto the positions Lane E baked and adds the
|
||||
@ -54,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();
|
||||
@ -120,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
|
||||
|
||||
@ -64,7 +64,12 @@ async function buildYard() {
|
||||
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
|
||||
gustTelegraph: () => null, setSheltersFromTrees() {}, eventsBetween: () => [],
|
||||
};
|
||||
const world = createWorld(scene, { wind: calm });
|
||||
// SPRINT10 (Lane A, in Lane B's file — minimal, shout if you'd rather it read
|
||||
// differently): the yard is data now, so createWorld needs the site. Loading
|
||||
// the real backyard_01 keeps this suite's "read the yard, never copy it" rule
|
||||
// intact — it's the same yard, from its source instead of from code.
|
||||
const { loadSite } = await import('../world.js');
|
||||
const world = createWorld(scene, { wind: calm, site: await loadSite('backyard_01') });
|
||||
if (world.dress) { try { await world.dress(); } catch { /* graybox anchors are enough */ } }
|
||||
const anchors = world.anchors.map((a) => {
|
||||
const pos = { x: a.pos.x, y: a.pos.y, z: a.pos.z };
|
||||
@ -329,6 +334,63 @@ async function fly(yard, session, stormName, { repair = false, broom = false, se
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the REAL sky.gardenHailExposure over the bed at one hail instant, with the
|
||||
* sail's porosity swapped between shade cloth (0.30) and membrane (0) and NOTHING
|
||||
* else touched. Returns { cloth, membrane } exposure numbers.
|
||||
*
|
||||
* This exists to close a gap C's fabric-hail seam left open. porosity now feeds
|
||||
* the garden hail score in production — rig.porosity -> skyfx.step reads it into
|
||||
* sailPorosity (skyfx.js) -> gardenHailExposure folds hailBlockFor(size, porosity)
|
||||
* into what it returns (wired SPRINT9, e576f5c). But the only test of that leak,
|
||||
* weather.selftest's 'fabric choice is real', REIMPLEMENTS the formula inline with
|
||||
* hailBlockFor + hailAt — it proves the primitive and the arithmetic, not the
|
||||
* plumbing. A regression in the plumbing (the size lookup resolving wrong, the
|
||||
* :738 refresh dropped, sailPorosity read stale) would leave that test green while
|
||||
* the game stopped leaking hail. This drives the actual return, which is the whole
|
||||
* reason balance.test is a browser suite: measure the real chain, never a copy.
|
||||
*
|
||||
* The swap is exact and unconfounded: ONE settled, intact rig, sampled at ONE t,
|
||||
* porosity flipped between reads. `sky.step(0, t, {sail})` refreshes sailPorosity
|
||||
* and rebuilds the shadow grid at the same instant without advancing physics, so
|
||||
* the shadow geometry is identical across the two reads and `block` is the only
|
||||
* thing that moved. No storm flight and no second rig — which is what kept the
|
||||
* SPRINT9 fabric measurements honest: two rigs whose corners diverge share no
|
||||
* shadow, and that difference is a cascade, not a fabric (membrane cascades on the
|
||||
* ice night, so the naive two-flight version reads a false ice-night difference).
|
||||
*
|
||||
* `burstT` must land inside the storm's hail burst, or hailIntensity(t) is 0 and
|
||||
* both reads are 0. storm_03_southerly bursts at t=36 (pea, size 0.7);
|
||||
* storm_02b_icenight at t=50 (ice, size 1.4).
|
||||
*/
|
||||
async function gardenHailByPorosity(yard, stormName, burstT) {
|
||||
const def = await loadStorm(stormName);
|
||||
const wind = createWind(def);
|
||||
wind.setSheltersFromTrees(yard.anchors.filter((a) => a.type === 'tree'));
|
||||
const calmWind = createWind(await loadStorm(CALM_STORM));
|
||||
calmWind.setSheltersFromTrees(yard.anchors.filter((a) => a.type === 'tree'));
|
||||
|
||||
const s = shop(yard, COVER_QUAD, [CARABINER, RATED, SHACKLE, RATED], 0);
|
||||
if (!s) return null;
|
||||
s.setFabric('cloth');
|
||||
const rig = s.commit(new SailRig({ anchors: yard.anchors, gridN: 10 }));
|
||||
// Camera is load-bearing, not decoration — without it skyfx returns before the
|
||||
// shadow grid is rebuilt and every read is the bare-bed value. Same reason as fly().
|
||||
const camera = new THREE.PerspectiveCamera(60, 1.6, 0.1, 400);
|
||||
camera.position.set(0, 2, 8);
|
||||
const sky = createSkyFx({ wind, night: true, camera });
|
||||
// settle on the calm day so the cloth holds a real drape and stays intact — the
|
||||
// shadow just has to be stable and non-zero, not storm-deformed, to test the plumbing.
|
||||
for (let i = 0, n = Math.round(12 / FIXED_DT); i < n; i++) rig.step(FIXED_DT, calmWind, (i * FIXED_DT) % 3);
|
||||
|
||||
rig.porosity = 0.30; sky.step(0, burstT, { sail: rig });
|
||||
const cloth = sky.gardenHailExposure(yard.bed, burstT);
|
||||
rig.porosity = 0; sky.step(0, burstT, { sail: rig });
|
||||
const membrane = sky.gardenHailExposure(yard.bed, burstT);
|
||||
sky.dispose?.();
|
||||
return { cloth, membrane, lost: rig.corners.filter((c) => c.broken).length };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../testkit.js').Suite} t
|
||||
*
|
||||
@ -367,6 +429,13 @@ export default async function run(t) {
|
||||
if (membraneShop) membraneShop.setFabric('membrane');
|
||||
const membrane = membraneShop ? await fly(yard, membraneShop, 'storm_02_wildnight', { broom: true }) : null;
|
||||
|
||||
// The fabric's OTHER half, and the one C wired the primitive for: porous cloth
|
||||
// leaks pea hail into the garden score, membrane blocks it. Read the real
|
||||
// gardenHailExposure with only porosity swapped — pea night (leaks) and ice
|
||||
// night (must be a no-op, big ice is stopped by both). See the helper.
|
||||
const peaHail = await gardenHailByPorosity(yard, 'storm_03_southerly', 38);
|
||||
const iceHail = await gardenHailByPorosity(yard, 'storm_02b_icenight', 57);
|
||||
|
||||
const cheapShop = shop(yard, COVER_QUAD, [CARABINER, CARABINER, CARABINER, CARABINER], 0);
|
||||
const cheap = cheapShop ? await fly(yard, cheapShop, 'storm_02_wildnight') : null;
|
||||
|
||||
@ -484,6 +553,41 @@ export default async function run(t) {
|
||||
`(hp ${membrane.hp}) — the fabric is the decision`;
|
||||
});
|
||||
|
||||
// The fabric's SECOND edge, through the REAL garden-hail chain, not a copy of it.
|
||||
// `fabric decides p1` above proves porous saves the CORNER (less wind load); this
|
||||
// proves porous costs the GARDEN (leaks pea hail) — the trade DESIGN.md promises
|
||||
// and C's hailBlockFor supplies. It drives sky.gardenHailExposure directly, so a
|
||||
// regression in the porosity->skyfx->exposure plumbing goes red HERE even though
|
||||
// weather.selftest's inline-formula version would stay green. (Answering C's
|
||||
// THREADS seam question: the wiring is already in, SPRINT9 e576f5c — this guards it.)
|
||||
t.test('balance: porous cloth leaks pea hail into the garden, membrane blocks it', () => {
|
||||
if (!peaHail || !iceHail) throw new Error('could not build the fabric-hail probe on $80');
|
||||
if (!(peaHail.cloth > 0 && peaHail.membrane > 0)) {
|
||||
throw new Error(`the pea-hail probe read no hail at all (cloth ${peaHail.cloth}, membrane ` +
|
||||
`${peaHail.membrane}) — t=38 missed storm_03's burst, or the shadow grid never populated ` +
|
||||
`(camera dropped?). With nothing to block, this proves nothing.`);
|
||||
}
|
||||
// pea (size 0.7): porous leaks ~16% more through the real chain. Membrane blocks all.
|
||||
if (!(peaHail.cloth > peaHail.membrane * 1.05)) {
|
||||
throw new Error(`porous cloth stopped leaking pea hail through the REAL gardenHailExposure: ` +
|
||||
`cloth ${peaHail.cloth.toFixed(3)} vs membrane ${peaHail.membrane.toFixed(3)} (want cloth > ` +
|
||||
`membrane). The primitive test may still be green — this one drives sky.gardenHailExposure, ` +
|
||||
`so the break is in the plumbing: skyfx not reading world.sail.porosity (:738), the size ` +
|
||||
`lookup wind.def.hail.size resolving wrong, or hailBlockFor no longer folded into the return.`);
|
||||
}
|
||||
// ice (size 1.4): both stop it dead. hailBlockFor(1.4, 0.30) === hailBlockFor(1.4, 0) === 1.
|
||||
// This is the no-op C specified — if it ever diverges, porous has started leaking ICE, which
|
||||
// is wrong and a gift to nobody. Same intact rig both reads, so any gap is real, not a cascade.
|
||||
if (Math.abs(iceHail.cloth - iceHail.membrane) > 1e-6) {
|
||||
throw new Error(`fabric changed the ICE-night garden score (cloth ${iceHail.cloth.toFixed(4)} vs ` +
|
||||
`membrane ${iceHail.membrane.toFixed(4)}) — porous is meant to leak only the finest hail, and ` +
|
||||
`size 1.4 should read block=1 for both. hailBlockFor's aperture/smoothstep has drifted.`);
|
||||
}
|
||||
const leak = ((peaHail.cloth / peaHail.membrane) - 1) * 100;
|
||||
return `real gardenHailExposure: porous leaks +${leak.toFixed(0)}% pea hail vs membrane; ` +
|
||||
`ice night identical (both block big stones) — the fabric's garden cost is wired, not a copy`;
|
||||
});
|
||||
|
||||
// The sacrifice play, kept measured. DESIGN.md: "a sail that dies saving the
|
||||
// garden". Now that a clean win EXISTS, this stops being the wild night's only
|
||||
// outcome and becomes what it should always have been — a different, worse bet
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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)}`);
|
||||
|
||||
@ -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; },
|
||||
|
||||
@ -12,7 +12,10 @@
|
||||
*/
|
||||
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { YARD, createStubWind } from './contracts.js';
|
||||
import { createStubWind } from './contracts.js';
|
||||
|
||||
/** Degrees → radians. Site JSON is authored in degrees; nobody writes π/2 by hand. */
|
||||
const rad = (deg) => ((deg ?? 0) * Math.PI) / 180;
|
||||
|
||||
/**
|
||||
* Ground height in meters at a world XZ. Pure and cheap — this is called by the
|
||||
@ -33,32 +36,87 @@ export function heightAt(x, z) {
|
||||
);
|
||||
}
|
||||
|
||||
const GARDEN_BED = { x: 1, z: 2, w: 6, d: 4 };
|
||||
const SITE_DIR = new URL('../data/sites', import.meta.url).href;
|
||||
|
||||
// Shed on the east side, table out in front of it. Lane D tested reachability
|
||||
// against (9, 6), so the table stays there and the shed tucks in behind.
|
||||
const SHED = { x: 11.8, z: 6.2, rotY: -Math.PI / 2 };
|
||||
const SHED_TABLE = { x: 9, z: 6, rotY: -Math.PI / 2 };
|
||||
/**
|
||||
* Load a site. SPRINT10 gate 1 — the yard stopped being code today.
|
||||
*
|
||||
* Everything that used to be a constant in this file (bed rect, house line,
|
||||
* tree and post placements, shed, gnome, sun angle) is now `data/sites/*.json`,
|
||||
* and world.js is a builder. The proof of a faithful extraction is that nothing
|
||||
* moved: Lane E's anchor tripwires pin five branch positions to 6 dp and
|
||||
* a.test's quad-band asserts pin the whole rigging geometry, so a byte-identical
|
||||
* backyard_01 is checkable rather than claimable.
|
||||
*
|
||||
* Async and separate from createWorld() for the same reason dress() is: the
|
||||
* selftest builds yards with no server, and a fetch in the constructor is either
|
||||
* a break or a flake. Callers load the site, then build.
|
||||
*
|
||||
* @param {string} name e.g. 'backyard_01'
|
||||
*/
|
||||
export async function loadSite(name, dir = SITE_DIR) {
|
||||
const url = `${dir}/${name}.json`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`world: cannot load site ${url} (${res.status})`);
|
||||
return validateSite(await res.json(), name);
|
||||
}
|
||||
|
||||
// Lane E's house_yardside GLB is a 9.2 x 2.9 x 1.05 m façade whose fascia
|
||||
// anchors sit at local z = +0.55, so placing it here lands them on z = -9.95 —
|
||||
// the same line the graybox taught everyone to expect.
|
||||
const HOUSE = { x: 0, z: -10.5 };
|
||||
/**
|
||||
* Sites are content, so they fail loud rather than half-build. Every field this
|
||||
* checks is one that would otherwise produce a yard that looks fine and is
|
||||
* subtly wrong — an absent bed silently becomes NaN coverage, a missing anchor
|
||||
* list silently becomes an unriggable site.
|
||||
*/
|
||||
export function validateSite(site, name = site?.id ?? '?') {
|
||||
const bad = [];
|
||||
if (!site || typeof site !== 'object') bad.push('not an object');
|
||||
else {
|
||||
if (!site.yard?.width || !site.yard?.depth) bad.push('yard.width/depth');
|
||||
if (!site.gardenBed) bad.push('gardenBed');
|
||||
if (!Number.isFinite(site.sun?.elevationDeg)) bad.push('sun.elevationDeg');
|
||||
if (!Number.isFinite(site.sun?.azimuthDeg)) bad.push('sun.azimuthDeg');
|
||||
const ids = [
|
||||
...(site.house?.anchors ?? []).map((a) => a.id),
|
||||
...(site.trees ?? []).flatMap((t) => (t.anchors ?? []).map((a) => a.id)),
|
||||
...(site.posts ?? []).map((p) => p.id),
|
||||
...(site.structures ?? []).flatMap((s) => (s.anchors ?? []).map((a) => a.id)),
|
||||
];
|
||||
if (!ids.length) bad.push('no anchors at all — nothing to rig to');
|
||||
const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);
|
||||
if (dupes.length) bad.push(`duplicate anchor ids: ${[...new Set(dupes)].join(', ')}`);
|
||||
// Lane D's field. `work` is the MECHANISM, not the anchor's type: a post is
|
||||
// tensioned from a cleat at its base and a tree strop is thrown, but a
|
||||
// bracket bolted up a bare wall is the one you cannot fake — so that, and
|
||||
// only that, wants the ladder. Keyed on mechanism because `type` is a closed
|
||||
// enum ('house'|'tree'|'post') that site_02's carport doesn't fit, and
|
||||
// needsLadder keyed on type FAILS OPEN there: the ladder mechanic silently
|
||||
// vanishes and you re-rig a 2.6 m bracket standing on the grass (D's audit).
|
||||
const works = [
|
||||
...(site.house?.anchors ?? []),
|
||||
...(site.trees ?? []).flatMap((t) => t.anchors ?? []),
|
||||
...(site.posts ?? []),
|
||||
...(site.structures ?? []).flatMap((s) => s.anchors ?? []),
|
||||
];
|
||||
for (const a of works) {
|
||||
if (a.work !== 'cloth' && a.work !== 'bracket') {
|
||||
bad.push(`anchor ${a.id}: work must be "cloth" or "bracket", got ${JSON.stringify(a.work)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bad.length) throw new Error(`world: site '${name}' is invalid:\n ${bad.join('\n ')}`);
|
||||
return site;
|
||||
}
|
||||
|
||||
// The gnome stands just off the bed's south-east corner — inside the footprint
|
||||
// of most rigs, so a sail that lets go has something of the client's to land on.
|
||||
// DESIGN.md: your own failure is the worst debris of all.
|
||||
const GNOME = { x: 4.3, z: 4.4, rotY: -2.2, collateralValue: 25 };
|
||||
|
||||
// Sun: mid-afternoon, high and off the north-west shoulder. Elevation 55°.
|
||||
// Stored as the direction from the GROUND toward the SUN (see contracts.js).
|
||||
const SUN_ELEV = (55 * Math.PI) / 180;
|
||||
const SUN_AZIM = (-125 * Math.PI) / 180;
|
||||
const SUN_DIR = new THREE.Vector3(
|
||||
Math.cos(SUN_ELEV) * Math.sin(SUN_AZIM),
|
||||
Math.sin(SUN_ELEV),
|
||||
Math.cos(SUN_ELEV) * Math.cos(SUN_AZIM),
|
||||
).normalize();
|
||||
/** Direction from the GROUND toward the SUN (see contracts.js), off site data. */
|
||||
function sunDirOf(site) {
|
||||
const el = (site.sun.elevationDeg * Math.PI) / 180;
|
||||
const az = (site.sun.azimuthDeg * Math.PI) / 180;
|
||||
return new THREE.Vector3(
|
||||
Math.cos(el) * Math.sin(az),
|
||||
Math.sin(el),
|
||||
Math.cos(el) * Math.cos(az),
|
||||
).normalize();
|
||||
}
|
||||
|
||||
const COLORS = {
|
||||
sky: 0x9fc4dd,
|
||||
@ -89,6 +147,26 @@ const COLORS = {
|
||||
export function createWorld(scene, opts = {}) {
|
||||
const wind = opts.wind ?? createStubWind({ calm: true });
|
||||
|
||||
// SPRINT10 gate 1. Every one of these was a module constant in this file an
|
||||
// hour ago; they're site data now. They keep their old local names on purpose
|
||||
// — the builder below is ~500 lines of geometry that was already correct, and
|
||||
// rebinding the names is a change of SOURCE, not of behaviour. That's what
|
||||
// makes "backyard_01 is byte-identical" a structural claim rather than a hope.
|
||||
const site = validateSite(
|
||||
opts.site ?? (() => { throw new Error(
|
||||
'world: createWorld now needs a site — `createWorld(scene, { wind, site: await loadSite("backyard_01") })`. '
|
||||
+ 'The yard stopped being code in SPRINT10; data/sites/*.json is the source of truth.'); })(),
|
||||
);
|
||||
const YARD = site.yard;
|
||||
const GARDEN_BED = site.gardenBed;
|
||||
const SUN_DIR = sunDirOf(site);
|
||||
const HOUSE = site.house;
|
||||
const SHED = site.shed;
|
||||
const SHED_TABLE = site.shedTable ? { ...site.shedTable, rotY: rad(site.shedTable.rotYDeg) } : null;
|
||||
const GNOME = site.gnome ? { ...site.gnome, rotY: rad(site.gnome.rotYDeg) } : null;
|
||||
const treeSpecs = site.trees ?? [];
|
||||
const postSpecs = site.posts ?? [];
|
||||
|
||||
const root = new THREE.Group();
|
||||
root.name = 'yard';
|
||||
scene.add(root);
|
||||
@ -194,18 +272,22 @@ export function createWorld(scene, opts = {}) {
|
||||
solids.push(wall, roof);
|
||||
graybox.house = house;
|
||||
|
||||
for (const [i, x] of [-5, 0, 5].entries()) {
|
||||
anchors.push(makeStaticAnchor(`h${i + 1}`, 'house', new THREE.Vector3(x, 2.6, -9.9)));
|
||||
// Graybox stand-ins at the graybox's own spacing. dress() moves every one of
|
||||
// these onto the position Lane E baked, which is why the numbers here don't
|
||||
// matter and the IDS do — the site names them, and everything downstream
|
||||
// (Lane B's quads, D's prompts, the balance suite) refers to them by name.
|
||||
for (const [i, a] of (HOUSE?.anchors ?? []).entries()) {
|
||||
anchors.push(Object.assign(
|
||||
makeStaticAnchor(a.id, a.type ?? 'house', new THREE.Vector3(-5 + i * 5, 2.6, -9.9)),
|
||||
{ work: a.work },
|
||||
));
|
||||
}
|
||||
|
||||
// --- trees -------------------------------------------------------------
|
||||
// Two, on opposite shoulders, mirroring the prototype's tree placement.
|
||||
// Canopies are separate named nodes so they can be swayed independently —
|
||||
// Lane E's tree_gum_01.glb must keep `trunk` / `canopy_*` / `branch_anchor_*`.
|
||||
const treeSpecs = [
|
||||
{ id: 't1', x: -9, z: 2, phase: 0.7, trunkH: 4.2, anchorY: 3.4 },
|
||||
{ id: 't2', x: 8, z: -2, phase: 2.9, trunkH: 3.8, anchorY: 3.1 },
|
||||
];
|
||||
// (treeSpecs is site.trees — bound at the top of createWorld.)
|
||||
for (const spec of treeSpecs) {
|
||||
const y0 = heightAt(spec.x, spec.z);
|
||||
const tree = new THREE.Group();
|
||||
@ -243,13 +325,14 @@ export function createWorld(scene, opts = {}) {
|
||||
root.add(tree);
|
||||
graybox.trees.set(spec.id, tree);
|
||||
|
||||
// The anchor is at a branch fork, not the canopy centre.
|
||||
anchors.push(makeSwayAnchor(
|
||||
spec.id,
|
||||
new THREE.Vector3(spec.x, y0 + spec.anchorY, spec.z),
|
||||
spec.phase,
|
||||
wind,
|
||||
));
|
||||
// The anchor is at a branch fork, not the canopy centre. One per entry in
|
||||
// the site's list; dress() moves each onto its baked branch_anchor_* node.
|
||||
for (const a of spec.anchors ?? []) {
|
||||
anchors.push(Object.assign(
|
||||
makeSwayAnchor(a.id, new THREE.Vector3(spec.x, y0 + spec.anchorY, spec.z), spec.phase, wind),
|
||||
{ work: a.work },
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// --- posts -------------------------------------------------------------
|
||||
@ -271,32 +354,10 @@ export function createWorld(scene, opts = {}) {
|
||||
// be the expensive answer; the small quads buy survival and pay in patchy
|
||||
// shade. That IS the design (DESIGN.md, "big flat low vs small twisted steep").
|
||||
// SPRINT6 gate 1: p4 is the ONE close anchor the balance pass allows, and its
|
||||
// position is measured rather than guessed.
|
||||
//
|
||||
// The wild night had no winnable line because every anchor near the bed —
|
||||
// p1/p2/p3 — is SOUTH of it, and nothing stands north short of the house 10 m
|
||||
// away. So covering rigs had to span the yard and died, while rigs small
|
||||
// enough to survive sat beside the bed instead of over it.
|
||||
//
|
||||
// p4 supplies the missing north-west corner. Swept its position against the
|
||||
// smallest quad that covers 90% of the bed:
|
||||
// (-2.2, -1.2) → 44.4 m² ← breaks the tradeoff (see below)
|
||||
// (-3.2, -1.2) → 48.6 m² ← here
|
||||
// (-3.2, -2.0) → 51.7 m²
|
||||
// (-4.2, -3.0) → 60.0 m²
|
||||
// beyond ~z=-4 → 63.5 m², i.e. too far away to matter at all
|
||||
// Full coverage costs 63.5 m² without it. At (-3.2,-1.2) that drops to 48.6 —
|
||||
// a real new option, 23% cheaper — while still costing you a bigger sail than
|
||||
// the 23-38 m² rigs that survive on their own. Pulling it in to (-2.2,-1.2)
|
||||
// would put full coverage at 44.4 m², INSIDE the survivable band, which
|
||||
// collapses DESIGN.md's central tension: covering the bed has to cost risk.
|
||||
// a.test.js asserts that >45 m² floor, so moving this post inward goes red.
|
||||
const postSpecs = [
|
||||
{ id: 'p1', x: -4.5, z: 5.5, h: 4.0 },
|
||||
{ id: 'p2', x: 4.0, z: 6.0, h: 4.0 },
|
||||
{ id: 'p3', x: 0, z: 7.0, h: 4.0 },
|
||||
{ id: 'p4', x: -3.2, z: -1.2, h: 4.0 },
|
||||
];
|
||||
// position is measured rather than guessed — and the measurement moved with
|
||||
// the data. p4's swept position table now lives in backyard_01.json's `_why`,
|
||||
// beside the coordinate it justifies, because that is the number someone will
|
||||
// be tempted to nudge. (postSpecs is site.posts, bound at the top.)
|
||||
const RAKE = (8 * Math.PI) / 180;
|
||||
for (const spec of postSpecs) {
|
||||
const y0 = heightAt(spec.x, spec.z);
|
||||
@ -330,7 +391,34 @@ export function createWorld(scene, opts = {}) {
|
||||
footing.receiveShadow = true;
|
||||
root.add(footing);
|
||||
|
||||
anchors.push(makeStaticAnchor(spec.id, 'post', top));
|
||||
anchors.push(Object.assign(
|
||||
makeStaticAnchor(spec.id, spec.type ?? 'post', top),
|
||||
{ work: spec.work },
|
||||
));
|
||||
}
|
||||
|
||||
// --- 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) -------------------------
|
||||
@ -370,11 +458,16 @@ export function createWorld(scene, opts = {}) {
|
||||
fence.name = 'fence';
|
||||
const railMat = new THREE.MeshStandardMaterial({ color: 0x7a6a4f, roughness: 1 });
|
||||
const hx = YARD.width / 2, hz = YARD.depth / 2;
|
||||
const runs = [
|
||||
{ from: [-hx, hz], to: [hx, hz] }, // south
|
||||
{ from: [-hx, -hz], to: [-hx, hz] }, // west
|
||||
{ from: [hx, -hz], to: [hx, hz] }, // east
|
||||
];
|
||||
const SIDES = {
|
||||
south: { from: [-hx, hz], to: [hx, hz] },
|
||||
west: { from: [-hx, -hz], to: [-hx, hz] },
|
||||
east: { from: [hx, -hz], to: [hx, hz] },
|
||||
north: { from: [-hx, -hz], to: [hx, -hz] },
|
||||
};
|
||||
// backyard_01 fences south/west/east — the house IS its north boundary. The
|
||||
// corner block has a street on two sides and fences the other two, which is
|
||||
// the whole reason this is a site field rather than three hardcoded runs.
|
||||
const runs = (site.fence?.sides ?? ['south', 'west', 'east']).map((s) => SIDES[s]).filter(Boolean);
|
||||
for (const run of runs) {
|
||||
const [x0, z0] = run.from, [x1, z1] = run.to;
|
||||
const len = Math.hypot(x1 - x0, z1 - z0);
|
||||
@ -466,6 +559,7 @@ export function createWorld(scene, opts = {}) {
|
||||
};
|
||||
|
||||
const load = async (name) => {
|
||||
if (!name) return null; // the site simply doesn't have one
|
||||
try {
|
||||
const gltf = await loader.loadAsync(new URL(`../models/${name}.glb`, import.meta.url).href);
|
||||
gltf.scene.traverse((o) => {
|
||||
@ -482,11 +576,13 @@ export function createWorld(scene, opts = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
const [shed, table, houseGlb, tree1, tree2, bedGlb, gnome] = await Promise.all([
|
||||
load('shed_01_v1'), load('shed_table_v1'), load('house_yardside_v1'),
|
||||
load('tree_gum_01_v1'), load('tree_gum_02_v1'), load('garden_bed_v1'),
|
||||
load('garden_gnome_01_v1'),
|
||||
// Model names come off the site now. `load(null)` resolves null, so a site
|
||||
// that simply has no shed (site_02 has a carport) skips it without a branch.
|
||||
const [shed, table, houseGlb, bedGlb, gnome] = await Promise.all([
|
||||
load(SHED?.model), load(SHED_TABLE?.model), load(HOUSE?.model),
|
||||
load(site.gardenBedModel ?? 'garden_bed_v1'), load(GNOME?.model),
|
||||
]);
|
||||
const treeGlbs = await Promise.all(treeSpecs.map((s) => load(s.model)));
|
||||
|
||||
// --- garden bed ------------------------------------------------------
|
||||
if (bedGlb) {
|
||||
@ -524,9 +620,7 @@ export function createWorld(scene, opts = {}) {
|
||||
root.add(houseGlb);
|
||||
solids.push(houseGlb);
|
||||
houseGlb.updateWorldMatrix(true, true);
|
||||
for (const [i, id] of ['h1', 'h2', 'h3'].entries()) {
|
||||
adoptAnchor(houseGlb, `fascia_anchor_0${i + 1}`, id);
|
||||
}
|
||||
for (const a of HOUSE.anchors ?? []) adoptAnchor(houseGlb, a.node, a.id);
|
||||
}
|
||||
|
||||
// --- trees -----------------------------------------------------------
|
||||
@ -534,7 +628,8 @@ export function createWorld(scene, opts = {}) {
|
||||
// the fork, 0.76 out where the limb is thin) — the intel DESIGN.md wants
|
||||
// inspection to buy. branch_anchor_01 keeps the original t1/t2 id so
|
||||
// nothing that already references them breaks; the rest are added.
|
||||
for (const [glb, spec] of [[tree1, treeSpecs[0]], [tree2, treeSpecs[1]]]) {
|
||||
for (const [i, spec] of treeSpecs.entries()) {
|
||||
const glb = treeGlbs[i];
|
||||
if (!glb) continue;
|
||||
const old = graybox.trees.get(spec.id);
|
||||
retire(old);
|
||||
@ -552,20 +647,13 @@ export function createWorld(scene, opts = {}) {
|
||||
canopies.push({ group: canopy.parent, phase: spec.phase, base: canopy.parent.rotation.clone() });
|
||||
}
|
||||
|
||||
// Every branch anchor the site names now EXISTS before dress() runs (they
|
||||
// were created off spec.anchors above), so this only has to move them onto
|
||||
// the nodes Lane E baked. That deleted the old suffix-guessing loop, which
|
||||
// built t1/t1b/t1c out of an array index and could only ever describe the
|
||||
// two trees it was written for.
|
||||
glb.updateWorldMatrix(true, true);
|
||||
const suffix = ['', 'b', 'c'];
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const node = glb.getObjectByName(`branch_anchor_0${i}`);
|
||||
if (!node) continue;
|
||||
const id = spec.id + suffix[i - 1];
|
||||
if (i === 1) adoptAnchor(glb, `branch_anchor_01`, id);
|
||||
else {
|
||||
const p = new THREE.Vector3().setFromMatrixPosition(node.matrixWorld);
|
||||
const a = makeSwayAnchor(id, p, spec.phase, wind);
|
||||
a.ratingHint = node.userData?.rating_hint ?? 1;
|
||||
anchors.push(a);
|
||||
}
|
||||
}
|
||||
for (const a of spec.anchors ?? []) adoptAnchor(glb, a.node, a.id);
|
||||
}
|
||||
|
||||
if (shed) {
|
||||
@ -587,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 };
|
||||
}
|
||||
|
||||
@ -620,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.
BIN
web/world/models/carport_01_wrecked_v1.glb
Normal file
BIN
web/world/models/carport_01_wrecked_v1.glb
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user