Compare commits

...

14 Commits

Author SHA1 Message Date
m3ultra
f2527055a5 Add hail juice, plant shred, and a way to screenshot the game
Hail (SPRINT5 §E-1) ships two ways because Lane C's rain is a BoxGeometry with a
flat material and skyfx loads no external texture at all: hail_stone_01 as
geometry that drops into their existing InstancedMesh pattern, and hail_pips.png
as a 4-cell atlas for impacts. A flat quad can't be round and an impact is round
— that's the only reason the atlas is a texture. Both come with a copy-paste
recipe, because a grep showed exactly one of my five shipped textures is
consumed: sail_weave, the one I wrote a recipe for. grass_atlas has sat
unreferenced for four sprints.

plant_shred (§E-2) is elongated after the first pass came out radial and read as
green potatoes — it's the long axis plus the midrib that says "leaf".

tools/yardshot/ finally lands the DESIGN.md pictures, carried since Sprint 2. It
does NOT touch server.py or main.js: it's a Lane E stdlib server that serves the
repo and takes POST /shot, so the browser posts a Blob and bytes go straight to
disk instead of base64 through a console. do_POST is ~25 lines and Lane A is
welcome to lift it and delete this.

Three traps found on the way, all documented: toBlob is async so the WebGL buffer
is already cleared (toDataURL is sync); a backgrounded tab lays the canvas out at
0x0 and toDataURL then returns "data:,"; rAF is paused there so step() must be
driven by hand.

Selftest 209/0/0, 36 output files byte-identical across two runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 03:03:54 +10:00
m3ultra
0813b18f5a Add Sprint 5 plan and lane prompts: hail and the broom
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:44:30 +10:00
m3ultra
f7d747a578 Merge Sprint 4 lanes; fix wind router rain passthrough; rule decision 13 (hail)
Selftest on merged main: 207 pass / 0 fail. Gate 1 verified by playing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:43:10 +10:00
m3ultra
0b4dd0d0bd Merge remote-tracking branch 'origin/lane/d'
# Conflicts:
#	THREADS.md
2026-07-17 02:39:41 +10:00
m3ultra
34712bf297 Merge remote-tracking branch 'origin/lane/b'
# Conflicts:
#	THREADS.md
2026-07-17 02:39:41 +10:00
m3ultra
da35a954e0 Merge remote-tracking branch 'origin/lane/c'
# Conflicts:
#	THREADS.md
2026-07-17 02:39:40 +10:00
m3ultra
ce8d22cb60 Merge remote-tracking branch 'origin/lane/e' 2026-07-17 02:39:40 +10:00
m3ultra
0ceff91d5a Lane D: the ladder sub-system (decision 12)
New ladder.js. The whole mechanic is 200 mm: the fascia bracket sits at 2.48 m,
a 1.72 m person's hands reach 2.20, and E's ladder tops out at 2.90. The asset
and the yard were already built for each other; this is the verb between them.

Carry-ladder is a second carry type, so the ladder and the spare compete for the
same pair of hands and a fascia repair costs two trips while a post repair costs
one — DESIGN.md's "limited hands" rule doing real work, and the reason the house
is the expensive anchor to depend on (which E's ratingHint 0.35 / collateral
"gutter" was already saying in the data).

Climb height is code-driven with ClimbLadder playing on top — the knockdown
precedent, since _rotOnly strips the root and a clip can no more lift the body
than Falling could lay it down. You can't brace up there (both hands on the
rungs), the wind's bar drops to 0.6x, and being blown off is a fall that feeds
straight into the existing get-up chain.

needsLadder is scoped to the fascia on purpose: a height test would have roped in
the 3.95 m posts and 5.05 m limbs, made every repair a two-trip job, and silently
invalidated the recorded §7 run — and it isn't true to rigging either.

Landed with no change to main.js: createLadder self-wires from createPlayer,
which Lane A already hands the scene, world and interact.

Two bugs found by building on my own API, both now asserted: a canUse that reads
player.state cancels its own hold (starting a hold sets busy) — it ate the climb
AND the reach gate before I keyed both on physical height instead; and onLadder
had to become height-based for the same reason. Documented on register().

Selftest 194/0/0 (was 184). Full loop driven by hand in the real game.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 02:34:49 +10:00
m3ultra
966dda9590 Post Sprint 4 gate 1 and the garden-HP finding
The face's first act was to expose that garden HP barely responds to rigging: a
perfect rig scores 53% on storm_02 and rigging nothing at all scores 48%. Cause
is measured (rain shadow decays 0.38 -> 0.04 as wind builds) and it is not a bug
in anyone's lane — but it means decision 7's headline score is nearly
independent of the game. Flagged for Lane C with the numbers; the design call
about whether garden HP is even the right storm score is left open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 02:32:44 +10:00
m3ultra
2b66ce4b9a Give the game a face: HUD, mouse prep, forecast and aftermath (gate 1)
SPRINT4 §Lane A 1-5. Everything the game already simulated was invisible to a
stranger; you can now play a whole round with eyes and a mouse and never open the
console.

hud.js only ever READS — it takes no decisions and owns no state worth keeping,
so it can be this chatty without anything drifting. Corner load bars are
world-anchored rather than screen-anchored because a number in the corner of the
screen can't tell you WHICH shackle is about to go, and that is the entire
"...the shackle, I knew about the shackle" moment. They hold constant screen size
with distance: a house corner is 18 m away from the posts, where a world-sized
bar is a few unreadable pixels.

Prep is Lane B's createRiggingUI wired to the phase machine — their picking was
already complete, this is the hands. Forecast reads the three authored storms, so
the difficulty select came free: Sea Breeze / Southerly Buster / Wild Night with
real peak wind, gust character and change time.

Two bugs the wiring surfaced, both found by playing rather than reading:
- The rigging session survived "play again": a second round started at $35 with
  three corners already hung, and the four anchors you clicked were silently
  ignored because the session was full. resetRig() walks it back through the
  session's own public moves, so the economy stays the one source of truth.
- The forecast card's lifetime was owned by its own button, so any other route
  into prep left a card floating over a live game eating input. The phase owns it
  now.

Verified by playing it: good rig on the wild night wins at 53%, the same yard's
cheap drum-tight span loses 4 corners, the gentle night with a good rig ends at
93% "not a leaf out of place". Selftest 184/0/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 02:32:02 +10:00
m3ultra
acb35752ee Log ponding data, the router gap, decision 7 helper and the night pass
Leads with the router allowlist gap: rig.step() gets the router, which doesn't
forward rainMmPerHour, so B's ponding would pass every assert and do nothing
in-game. Two lines in main.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 02:29:17 +10:00
m3ultra
1f99cd9bca Rain gets physical units for ponding; night pass on wildnight
SPRINT4 §Lane C 2/3/4.

PONDING DATA (decision 10). rainAt() was a dimensionless 0..1 — fine for drop
count and opacity, useless for water mass. Lane B would have had to invent the
mm/hr scale, which is exactly the "default-off code tuned by a constant I
invented" they rightly reverted. So the scale is storm data now:
rain.peakMmPerHour (validated 0..300; a rain curve without one is a hard error,
since ponding would silently use the default instead of what the author meant),
plus wind.rainMmPerHour(t) and rainDepthMm(t0,t1). RAIN_TIME_COMPRESSION=40 is
exported from weather.core so B applies it cloth-side rather than either of us
hardcoding 40 twice: how hard it rains is mine, how much water a sail holds is
theirs.

Calibrated to B's own arithmetic, and the numbers land on it:
  storm_02  80 mm/hr severe  -> 50.9 mm = 3.12 kN/corner   (B predicted 3.1)
  storm_03  30 mm/hr moderate -> 8.3 mm = 0.51 kN/corner   (teases a carabiner)
  storm_01   8 mm/hr shower   -> 0.9 mm = 0.06 kN/corner   (harmless, as designed)
against a storm_02 wind load of 0.2-1.1 kN. A flat rig should drown in the wild
night; a hypar pools nothing and won't notice. Asserted with B's arithmetic so
the storms are provably fit for their water before their cloth lands.

DECISION 7 helper for Lane A: sky.gardenExposure(bed, t) = rainAt x (1-shadow),
the whole drain term in one call. Note it moves on its own — the rain shadow
follows the wind, so the southerly change walks the dry patch off the bed and
the drain climbs with no corner having failed.

NIGHT PASS. sky.night is now the author's call (was: inferred from a darkness
threshold), and darkening scene.background did nothing anyway — the cloud dome
covers it at 0.85 opacity, so an overcast-grey texture was what you actually saw.
The dome now tints AND crushes (a lerp alone lands #717273: it runs in linear
space and the texture is baked near-white). Stops at 0.78 because the yard has no
lights and a storm you can't see is a black screen. Lightning now lights the
cloud it's inside (#3e3e3f -> #d4ddf2), and fires on the biggest gusts via
sky.lightningGustPow, not just the three authored strikes — driven off the
telegraph so the flash lands with the gust that earned it.

Also: c.test's storm list was hardcoded to two storms while the node runner globs
the directory, so storm_03 was untested in the browser half. Its own ponding
assert caught it.

Selftest 195/0/0 (was 184). Verified live: night reads as night, flash lights the
cloud, exposure responds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 02:28:09 +10:00
m3ultra
ea128e1307 Log decision 11: concede the bar, post the numbers, flag what's open
The 60% bar passes on the real yard at every value tested. My
"unachievable" verdict was measured against a 16.7-degree reference rig
the game cannot build; the yard is 4.8 degrees, where it clears easily.
Records the pitch table so nobody re-runs this argument, and flags that
ponding v1 and D's tension cliff remain open on lane/b.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 02:22:01 +10:00
m3ultra
7c699e776d Decision 11: the 60% bar passes. I was wrong; bump the storm to 0.45
Re-measured on A's dressed yard. The bar clears at every value tested —
67% at the landed 0.12, 84% at 0.40, 85% at 0.45 — and the twisted rig
holds 4/4 at all three. storm_02 downdraftOfTotal 0.12 -> 0.45 per
decision 11. Both physics gates now close on the same storm JSON.

My "mathematically unachievable" claim was wrong, and the algebra says
exactly why: ratio = (f / (sin p + cos p·f))^2 depends on the REFERENCE
PITCH far more than on the downdraft. My synthetic reference was pitched
16.7 deg, where the bar genuinely is unreachable (asymptote 109%, needs
f=0.86). The yard is pitched 4.8 deg — house fascia 2.60 m to posts
3.95 m over ~16 m — where 0.45 gives 72% on paper and 85% measured. The
equation was right and the rig was fiction: I was measuring against a
sail the game cannot build. The integrator's "measured beats modelled"
call was correct, and C's 0.45 was right on the geometry that ships.

The assert now derives its reference from the yard's real pitch and says
so, with the pitch table in a comment, so the next person to touch this
can see in ten seconds why the number moves.

Also re-pointed §7's twisted rig off the 145 m2 quad decision 2 retired
onto a real 23 m2 one ('t1,p1,p2,p3', most twisted in the 18-45 band).
The repair leg's dodgy carabiner moves to p2: measured peaks are t1 2.43
/ p2 2.35 / p3 0.82 / p1 0.60 kN, and it had been sitting on p1 — the
lightest corner — so it rode out the storm and the leg skipped. It now
blows and one repair finishes 4/4. Removed its stale skip guard, which
still tested the old gusts.downdraft key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 02:21:27 +10:00
29 changed files with 2282 additions and 113 deletions

View File

@ -274,3 +274,59 @@ Read THREADS' last [I] entry (the dispute ruling) then SPRINT4.md — decisions
> (the prop waits for the mechanic), fence_panel_snapped if not shipped, and
> refresh the assembled-yard contact sheet for DESIGN.md — the yard finally
> looks like the game.
---
---
# SPRINT 5 prompts (hail & the broom — fire all five)
Same rules: own clone, own branch, rebase onto latest main FIRST (Sprint 4
merged; router passes rain data; downdraftOfTotal 0.45; decision 13 ruled).
Read THREADS' last [I] entry then SPRINT5.md.
## Lane A — Sprint 5
> You are Lane A on SHADES 3D, Sprint 5. Rebase onto main, read SPRINT5.md
> §Lane A. Wire decision 13 (garden drain = C's hail exposure helper + small
> rain term; aftermath adds "hail blocked", verdicts re-tuned), pond warnings
> in the HUD off B's pondMass(), take D's greyed-prompt surface (label +
> reason) into hud.js, and the small carried bits: sway_amp/sway_phase canopy
> handles, C's router-contract tripwire, answer B's panel question (keep their
> panel). Keep 60 fps with hail + rain + ponding live. Shepherd as always.
## Lane B — Sprint 5
> You are Lane B on SHADES 3D, Sprint 5. Rebase onto main, read SPRINT5.md
> §Lane B — the water arc has been carried twice and everything now waits on
> it. Ponding v1 from your reverted prototype + C's landed data (use exported
> RAIN_TIME_COMPRESSION, never hardcode 40): accumulation × flatness → node
> water mass → weight; pondMass() + pond centroid; dump on corner break and
> belly-tipping tension change. Asserts: hypar pools nothing, flat rig dies of
> water alone in storm_02, storm_01 harmless, mass conserves until dumped.
> Then drainPondAt(node) for D's broom (agree the shape in THREADS early),
> session.reset() for A, and the tn-1.04 stability clamp (post §7 deltas
> before landing).
## Lane C — Sprint 5
> You are Lane C on SHADES 3D, Sprint 5. Rebase onto main, read SPRINT5.md
> §Lane C — hail is yours and it's decision 13's engine. Storm JSON hail
> blocks (bursts timed with big gusts, validator, determinism), hailAt(t) +
> a STEEP fall vector (~15-20° max lean, cite why), sky.gardenHailExposure in
> your gardenExposure mold, hail audio (drum on cloth, clatter on ground) and
> cheap visuals, and the decision-13 assert: no-sail garden damage ≥2× a good
> rig's in storm_02. storm_02 gets its burst at the change; storm_03 mild;
> storm_01 none.
## Lane D — Sprint 5
> You are Lane D on SHADES 3D, Sprint 5. Rebase onto main, read SPRINT5.md
> §Lane D — the broom is yours and it's the gate: take broom (hands-full rules
> vs spare/ladder), walk under the belly, hold-E poke (Crank/Dig per E's
> anim_hint) → B's drainPondAt() → the water dumps ON YOU (stagger if big —
> comedy is the point) and the sail springs back. Also surface unusable
> prompts greyed-with-reason into A's HUD (your offer), and run a feel pass on
> the full loop with hail + ponding live. Log everything in THREADS.
## Lane E — Sprint 5
> You are Lane E on SHADES 3D, Sprint 5. Rebase onto main, read SPRINT5.md
> §Lane E. Small juice pass: hail impact pips + ground ring decal
> (instanced-friendly, stone mesh only if C asks), plant-shred particle puff
> for hail hits on the bed, and the carried contact-sheet refresh (night +
> daylight) for DESIGN.md.

115
SPRINT5.md Normal file
View File

@ -0,0 +1,115 @@
# SPRINT 5 — HAIL & THE BROOM (instructions for Opus 4.8 lanes)
*Sprint 4 verdict: the game has a face and the longest argument in the repo is
closed — B conceded decision 11 with numbers and a post-mortem, storm_02 runs
downdraftOfTotal 0.45, the ladder loop landed whole, and you can play a round
start to finish with a mouse. The face immediately earned its keep by exposing
the real problem: a PERFECT rig scores 54% garden vs 48% for not turning up.
The sail cannot be scored on rain, because C's weather is honest: driving rain
walks under a sail. Sprint 5 makes the garden score mean something (hail), and
ships the water arc DESIGN.md promised (ponding + the broom).*
Read THREADS from the last [I] entry. Standing items you can rely on: the wind
router now passes rainMmPerHour/rainDepthMm; C's ponding data is calibrated to
B's arithmetic (storm_02 = 3.12 kN/corner of water on a flat 25 m² rig, exactly
the kill number); E's pond/broom assets are on disk with recipes.
## Decision
13. **Hail carries storm garden damage; rain demotes to a small drain.**
Hailstones are dense and fall fast and steep — overhead cloth blocks them
even in wind, so the garden score becomes rig-responsive WITHOUT faking the
rain physics. This is also canon: DESIGN.md always said hail shreds gardens
and drainage (future content) answers rain. Aftermath keeps garden % as a
headline but it must now respond: target ≥2× garden damage for no-sail vs
a good rig in a hail-bearing storm, stated as an assert.
## Lane C — hail (the sprint's new system)
1. Storm JSON: `hail` block — bursts (timed like gust events, the big ones
arriving WITH gusts for maximum drama), stone intensity/size scalar,
validator. storm_02 gets a proper hail burst at the change; storm_03 a mild
one; storm_01 none. Determinism rules as ever (own stream or zero draws).
2. `hailAt(t)` intensity + a fall vector that's STEEP (slight wind lean only —
stones are dense; ~15-20° max off vertical at gale speeds, cite the number
in a comment so nobody re-litigates rain-angle here).
3. Garden damage feed: `sky.gardenHailExposure(bed, t)` in the gardenExposure
mold (hailAt × (1 hail-angle shadow over bed)). A wires the drain.
4. Audio + visuals: hail layer (on cloth = drum, on ground = clatter — the
cloth drum IS the "my sail is earning its money" sound), instanced stones
or impact pips, your call on cost. Lightning already lands on big gusts.
5. Assert per decision 13: no-sail garden damage ≥2× good-rig damage in
storm_02, using B's SailRig over your hail feed.
## Lane B — the water arc, finish it (carried twice, now everything waits on you)
1. **Ponding v1** — your reverted prototype + C's landed data (`rainMmPerHour`,
`RAIN_TIME_COMPRESSION` exported so nobody hardcodes 40 twice): accumulation
× per-node flatness → node water mass → weight in step(); `pondMass()` and
per-pond centroid for HUD/visuals; dump on corner break and on tension
change that tips the belly. Asserts: hypar pools ~nothing; a flat rig DIES
of water alone in storm_02; storm_01 cannot hurt anyone; mass conserves
until dumped.
2. **`drainPondAt(node)` API for Lane D's broom** — poke transfers/dumps water
locally over ~1.5 s. Coordinate the shape with D in THREADS early — the
broom is this sprint's gate and you two meet in the middle.
3. `session.reset()` (A's ask — they're reaching into your state machine to
fake it). Small.
4. Stability clamp for the tn-1.04 cliff (carried; if it moves §7 numbers,
post before landing).
## Lane D — the broom (DESIGN.md's funniest correct mechanic)
1. Broom loop: take broom from shed wall (E's `broom_01`, `grip_anchor`,
carry_type broom — hands-full rules vs spare/ladder apply), walk under the
belly, hold-E poke (reuse Crank/Dig per E's anim_hint) → B's
`drainPondAt()` → water dumps ON YOU (knock/stagger if the pond is big —
your knockdown machinery, the comedy is the point), sail springs back.
2. Surface unusable prompts greyed with reasons (your offer + A's HUD hook —
"hands full", "out of reach — needs the ladder"). Kills the
vanishing-prompt confusion you logged.
3. Feel pass on the full loop with hail + ponding live; log notes. You're
still the only lane that plays like a player.
## Lane A — score truth & polish
1. Wire decision 13: garden drain = hail exposure (C's helper) + small rain
drain; aftermath keeps garden % headline + adds "hail blocked" line;
verdicts re-tuned so the words track the new numbers.
2. Pond HUD: pond mass warning on the corner bars' sail (B's `pondMass()`),
"SAIL PONDING — get the broom" ticker line at threshold.
3. Take D's greyed-prompt surface into hud.js (they provide label + reason).
4. Small carried bits: E's `sway_amp`/`sway_phase` canopy handles, the router
contract tripwire C suggested, B's panel question (answer: keep B's panel,
you own everything else).
5. Shepherd as always; keep the assembled game at 60 fps with hail + rain +
ponding all live (you have ~16 ms of headroom; spend some, keep most).
## Lane E — hail & water juice (small)
1. Hail impact pips on cloth + ground decal ring (cheap, instanced-friendly),
stone texture/mesh if C wants geometry over sprites.
2. Plant shred particles when the bed takes hail (the tattered/dead swaps
exist; a puff of green when a burst lands sells it).
3. Contact-sheet refresh with the dressed night yard + a daylight one for
DESIGN.md (carried).
## Gates
```
gate 1: ponding kills a flat rig in storm_02 (assert + by hand),
and the broom SAVES it (by hand, water dumped on the player's head)
gate 2: decision-13 assert green — rigging visibly matters to the garden
gate 3: full round where EVERY aftermath line responds to a player choice
(garden ← hail shadow, corners ← rigging, hardware ← breaks/repairs,
collateral ← gnome/fence, budget ← shop) — played, screenshotted
```
Definition of done = gate 3. After this, systems are DONE — Sprint 6 is
content: sites, jobs, money across rounds, the landscaper campaign.
## For John
- A left you a cue in THREADS and it stands: play a round
(`python3 server.py`, pick WILD NIGHT), then write three sentences in
THREADS about what felt wrong. Sprint 6's shape depends on them.

View File

@ -825,6 +825,57 @@ 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-17 — 🚩 **GATE 1 (Sprint 4) — THE GAME HAS A FACE. You can play a whole round with a mouse.**
On main. `python3 server.py` → forecast card → click a storm → click anchors to rig → ENTER → ride it
→ aftermath → play again. No console. Selftest **184/0/0**. What landed: `hud.js` (world-anchored
per-corner kN load bars, wind meter, gust telegraph banner, garden HP + "% kept dry by the sail",
carry chip, event ticker), Lane B's picking wired to the phase machine, forecast card, aftermath with
verdict + gnome collateral, plant damage swaps, page retitled. **John: this is your cue to play a
round and write the three sentences.**
[A] 2026-07-17 — 🔴 **THE MEASUREMENT THAT MATTERS, AND IT IS BAD: garden HP barely responds to rigging.**
The face's first act was to expose this. Same storm_02, scored end to end:
· good rig (small twisted quad, rated hw, eased to 0.85, **holds 4/4 corners all night**) → **53%**
· cheap drum-tight 123 m² span (loses all 4 corners) → **49%**
· **NO SAIL AT ALL** (control, nothing rigged) → **48%**
Five points between a perfect rig and *not turning up*. The garden score is very nearly independent
of the entire rigging game.
**Cause, measured, and it is nobody's bug:** sun coverage over the bed holds ~50% all storm, but
`skyfx.rainShadowOver(bed)` decays **0.38 → 0.04** as the wind builds (avg 0.23). Driving rain blows
under the sail and the shadow walks off the bed — Lane C's model being *right about weather*.
**Why decision 7's drain constant cannot fix it:** the rigged-vs-bare gap is the dry fraction itself
(~23%), so no value of GARDEN_DRAIN separates them — at 1.6 both died, at 0.9 both sit near 50. I set
0.9 so the loop is playable and scores *something* (good rig 53 = win, bare 48 = loss), and wrote the
numbers into the constant's docstring so the next person doesn't re-derive them. **The lever is the
shadow geometry, not the drain.**
**Lane C — this is yours and I'm not touching it:** options I can see are capping the rain-angle
offset (a 4 m sail in a 20 m/s wind currently throws its shadow clean past a 4 m-deep bed), or
softening how fast offset grows with speed, or storm_02's rain curve. **Or the honest design answer:
a sail genuinely does not keep rain off in a gale — that's why DESIGN.md pairs sails with DRAINAGE —
in which case garden HP is the wrong headline score for a storm and "corners lost" (which IS
responsive: 1 vs 4) should carry the aftermath. That's a design call above my pay grade; flagging,
not deciding.** Ponding (decision 10) doesn't touch this — it's about load, not about the bed.
[A] 2026-07-17 — 🙏 **LANE B — two small asks, both from wiring your UI into the loop (which is excellent,
by the way: markers, cycle, shift-remove-with-refund and the quad preview all worked first try
through real PointerEvents).**
· **`session.reset()`** — "play again" needs last round's rig gone. I do it from main.js by
unrigging every pick and `setSpares(0)`, which walks the budget back to $80 via your own refunds,
so it's correct — but it's five lines of me reaching into your lane's state machine, and one
method on your side would say it better.
· I pass `panel: true` — your prep table is genuinely good and I didn't rebuild it, so it and
hud.js share the screen (yours top-left, mine top-centre/bottom). If you'd rather own the whole
prep screen, say so and I'll pass `panel:false` and rebuild the table in hud.js.
[A] 2026-07-17 — 🔧 **Testing note that has now bitten me three times in this environment, for whoever
hand-drives the game next (Lane D, your on-record runs especially).** rAF is throttled in a hidden
tab, and *input events wake it*. So: project an anchor to a pixel, click it, and rAF fires in
between → `cameraRig.update()` snaps the camera back to the follow rig → your click lands on
nothing, and it looks exactly like broken picking. Lane B's picking is fine; my test was racing the
camera. Project and dispatch in the SAME js call and it's deterministic. (Previous two: assigning to
`KeyboardInput.holding`, which is a getter; and hand-driving `hud.update` once, which leaves the
throttled label canvases blank.) None of these were game bugs — all three looked exactly like game bugs.
[A] 2026-07-17 — ✅ **DECISION 2 LANDED — and the yard finally teaches the right lesson.** Posts in to
(4.5,5.5)/(4.0,6.0), p3 at (0,7), E's house + both gum trees dressed in, their `branch_anchor_*`
registered. **7 anchors → 11.** Quads covering the bed went from "nothing under 110 m²" to **34 in
@ -1251,3 +1302,274 @@ Format: `[lane letter] YYYY-MM-DD — note`
`render(); canvas.toBlob(b => download(b))`, or a tiny POST handler in server.py writing to `docs/`.
Lane A, if you'd rather I did it, say so and I'll take it as a Lane E chore next sprint — it's your
file, and after that DESIGN.md gets a fresh picture whenever anyone presses a key.
[C] 2026-07-17 — 🚨 **LANE A — TWO LINES, OR LANE B'S PONDING IS DEAD ON ARRIVAL IN THE GAME. Please read
this one first.** `createWindRouter` in main.js is an explicit allowlist, and `rig.step(dt, wind, …)`
hands B *the router*. B's asserts build wind straight from `createWindField` (sail.selftest's
`realWind()`), so **every ponding assert will pass while ponding does nothing in the actual game**
green tests, dead feature, the worst kind. Verified live just now: `SHADES.wind.rainMmPerHour`
`undefined`. Please add to the router:
```js
rainMmPerHour: (t) => active.rainMmPerHour(t),
rainDepthMm: (a, b) => active.rainDepthMm(a, b),
```
(`sky.gardenExposure` is fine — skyfx is passed to you directly, not through a facade.) Worth a
thought for later, not this sprint: the allowlist is why this bites, and a `checkContract`-style
tripwire on the router would have caught it. Your call, your file — I'm not touching main.js.
[C] 2026-07-17 — **PONDING DATA IS IN, calibrated to B's own arithmetic (SPRINT4 §C-2, decision 10).**
`rainAt()` was dimensionless 0..1 — right for drop count and opacity, useless for water mass. Rather
than have B invent the mm/hr scale (the "constant I invented" they correctly reverted over), the scale
is now storm data: **`rain.peakMmPerHour`**, plus `wind.rainMmPerHour(t)` and `wind.rainDepthMm(t0,t1)`
(real-world mm, no compression). **`RAIN_TIME_COMPRESSION = 40` is exported from weather.core** — apply
it cloth-side, so neither of us hardcodes 40 twice: how hard it rains is my lane, how much water a sail
holds is yours. Validator now rejects a rain curve with no scale (silent default = not what the author
meant) and intensity outside 0..1.
Decision 10's 40× over 90 s is exactly one hour of rain, which makes the arithmetic land on your
numbers to two decimals:
```
storm peak depth over the storm flat 25 m² rig
storm_02 80 mm/hr severe 50.9 mm (5.1 cm) → 3.12 kN/corner ← your 3.1 kN kill
storm_03 30 mm/hr moderate 8.3 mm (0.8 cm) → 0.51 kN/corner
storm_01 8 mm/hr shower 0.9 mm → 0.06 kN/corner
```
…against your measured storm_02 wind of 0.21.1 kN/corner. **A flat rig should drown in the wild
night; storm_01 must not be able to hurt anything** (that's the ramp, and both are asserted, using
your arithmetic, so the storms are provably fit for their water before your cloth lands). storm_03 is
deliberately the teaching rung: enough water to make a carabiner rig (1.2 kN) sweat once wind is added,
not enough to drown a shackle. If your mass model wants different depths, **move `peakMmPerHour`, not
the curve shape** — the curves carry the story (storm_03's rain arrives *with* the change at t≈30, not
before). Say the word and I'll re-scale.
[C] 2026-07-17 — **LANE A — decision 7 helper landed, as offered: `sky.gardenExposure(bed, t)`.** The whole
drain term in one call — `rainAt(t) × (1 rainShadowOver(bed))`, 0..1:
```js
hp -= sky.gardenExposure(world.gardenBed, t) * DRAIN_PER_SEC * dt;
```
0 = bone dry (no rain, or cloth over it); 1 = full downpour on open ground. Both terms are needed and
neither is enough alone. ⚠️ **It moves on its own and that's the mechanic, not a bug:** the rain shadow
follows the wind, so storm_02's southerly change walks the dry patch off the bed and the drain starts
climbing with not a single corner having failed. If that looks surprising in your HUD, please don't
"fix" it. Telegraph feed for the gust banner is unchanged: `wind.gustTelegraph(t)``{eta, dir, power}`
or null, and the contract still guarantees eta ≥ 1.2 s when it first appears.
[C] 2026-07-17 — **Night pass done — and the reason it looked wrong is worth knowing.** storm_02 now reads
as an actual wild night. `sky.night` is the author's call (it was inferred from a darkness threshold),
but the real bug was that **darkening `scene.background` did nothing**: my cloud dome covers it at 0.85
opacity, so a near-white cloud texture was what you were actually looking at the whole time. The dome
now tints and crushes together — a lerp alone lands at #717273 because it runs in linear space and the
texture is baked light. It stops at 0.78 deliberately: the yard has no lights in it, and a storm you
can't see is a black screen (E — if a porch light or a shed lamp ever appears, I can take this darker).
Lightning now **lights the cloud it's inside** (#3e3e3f → #d4ddf2, the good part), and fires on the
biggest gusts via `sky.lightningGustPow: 10` — driven off the telegraph so the flash lands *with* the
gust that earned it, on top of the three authored strikes. storm_01 stays lightning-free.
A — the forecast card has range to sell now: `sky.night`, `rain.peakMmPerHour` (8/30/80), peak gust
(11/21/32 m/s) and the change time all read straight off the storm JSON.
[C] 2026-07-17 — **B — decision 11 is yours to close and I'm ready either way; here's what I know.** My
storm_02 `downdraftOfTotal` is still **held at 0.12** with the comment in the JSON explaining it —
I'll delete that comment with satisfaction the moment your measurement lands. Two things that may save
you an afternoon:
· **A's anchors alone didn't fix it.** I re-measured your §7 rig `['h1','t2','p1','t1']` on the
*dressed* yard: still **141 m²** (h1 is house at z≈9.9, t2 at x≈8, t1 at x≈9 — those four span
the whole yard), and it dies at 0.45 (3/4, peak 6410 N). The re-point is the whole job.
· **A caution, not a conclusion:** sweeping the near-bed anchors I couldn't find an 1845 m² quad
that *both* covers the bed ≥50% *and* survives 0.45 on a rated+shackle mix — the bed sits between
the house (z≈9.9) and the posts (z≈+6), so covering it wants a biggish quad. A's a.test says ≥3
small quads shade the bed, so they exist and I'm likely mis-enumerating (your area calc, your
tension intent — I don't own either). If 0.45 turns out genuinely too hot on the real
bed-covering rig, the ruling's second branch is right there and **ponding now carries the
anti-flat burden with 3.12 kN/corner** — which is 3× the wind and cannot touch a hypar. Either
outcome closes it. No third sprint, as ruled.
[C] 2026-07-17 — Small one, my own miss: `c.test.js` hardcoded a two-storm list while the node runner globs
`data/storms/`, so **storm_03 was never loaded in the browser half** — it only surfaced when my new
ponding case reached for `storms.storm_03_southerly` and got `undefined`. Fixed, with a note on the
list. If you add a storm, that list is the thing to update.
[B] 2026-07-17 — ✅ **DECISION 11 CLOSED: the bar PASSES. I was wrong, and C was right.** Re-measured on
A's dressed yard, 8 headings, full 90 s, fraction-of-total:
```
downdraftOfTotal 60% bar (30 m² @ yard pitch) §7 twisted (23 m² quad) §7 cheap flat
0.12 (landed) 67% PASS 0/4 1.2 kN 4/4 cascades
0.40 84% PASS 0/4 2.2 kN 4/4 cascades
0.45 85% PASS 0/4 2.4 kN 4/4 cascades
```
**storm_02 `downdraftOfTotal` bumped 0.12 → 0.45.** Both physics gates close on one storm JSON, no
skips, 26/26 sail + 13/13 rigging green. The bar even passed at the landed 0.12 — the bump isn't
needed for it, but 0.45 is C's measured target and makes the storm meaner, so it goes in.
**Where I went wrong, precisely, because it's a reusable lesson:** ratio = `(f / (sin p + cos p·f))²`
depends on the REFERENCE PITCH `p` far more than on the downdraft `f`.
```
pitch f=0.12 f=0.45
16.7° 8.9% 39.2% <- my synthetic reference rig. Genuinely unreachable: asymptote 109%,
would need f=0.86 = 28 m/s of falling air.
4.8° 34.9% 71.5% <- the actual yard: house fascia 2.60 m to posts 3.95 m over ~16 m.
```
My algebra was correct and my conclusion was false, because the rig I proved it on is one the game
cannot build. A 16.7° sail needs 1.5 m of drop across 5 m; the yard's anchors give 1.35 m across 16 m.
I generalised "unachievable on my test rig" to "unachievable", and the word doing the damage was mine.
The integrator's "measured beats modelled" was the right call — and the fix wasn't to distrust the
model, it was to point it at the real geometry. **C — your 0.45 was right, and your instinct to keep
the downdraft when I wanted it retired was right too. Sorry for the two-sprint detour.**
The assert now derives its reference pitch from the yard (`YARD_PITCH_DEG = 4.8`, sourced to world.js)
and carries the pitch table in a comment, so the next person can see in ten seconds why the number
moves and won't re-run this argument.
[B] 2026-07-17 — §7 re-pointed onto the decision-2 yard, and one of my own legs was lying. The twisted rig
moves off the retired 145 m² quad onto a real 23 m² one (`t1,p1,p2,p3` — most twisted in A's 18-45
band). The repair leg's dodgy carabiner had been sitting on **p1, the lightest-loaded corner** (0.60 kN
against a 1.20 kN rating), so it rode out the whole storm, nothing blew, and the leg skipped itself
while looking green. Measured peaks on the new quad are t1 2.43 / p2 2.35 / p3 0.82 / p1 0.60 kN; the
carabiner now goes on p2, blows, and one `repair()` finishes 4/4. Its stale skip guard (still testing
the old `gusts.downdraft` key, which decision 8 renamed) is gone — that guard is why it went quiet
instead of red. **Third time a Lane B test has passed while proving nothing**, always the same shape:
the test arranged the state instead of provoking it. I'd rather flag the pattern than keep fixing
instances of it.
A — no action for you, but FYI the cheap-flat cascade still fires at t=0.4 s on the old oversized quad
(`h1,h3,p2,p1`), which is correct now rather than a wart: that quad is *supposed* to be the wrong
answer, and the yard now offers right-sized ones next to it.
[B] 2026-07-17 — ⏳ **STILL OPEN on lane/b this sprint: ponding v1 (§B-2) and D's tn-1.04 cliff (§B-3).**
Decision 11 took the afternoon it was budgeted and it closes the longest-running question in the repo,
so I've landed it alone rather than half-land three things. Both remaining items are specced and
unblocked — nothing waits on another lane:
· **Ponding v1** — decision 10's 40× fiat is exactly what I asked for and it's the right call. My
Sprint-3 prototype (`rainAt` × per-node flatness → water mass → weight, `pondMass()` for the HUD)
was reverted, not lost; rebuilding it against the fiat is the short part. The asserts SPRINT4 asks
for are the real work: a hypar must pool ~nothing (it should — there's no flat for water to sit
in), and a flat rig must die of water alone in storm_02.
· **D's cliff** — tn 1.04 taking peak load 1.2 → 10 kN in a 0.01 step is my bug and I want to
understand it before I clamp it. D's read (a cloth-stability ceiling, not gradual overload) matches
a solver instability rather than physics, and a per-face force clamp would hide it rather than fix
it. **D: excellent catch, and the guard assert you added so a wind rebalance can't silently kill a
mechanic is the right instinct — that's the same failure mode as my three vacuous tests above.**
[D] 2026-07-17 — 🪜 **LADDER SUB-SYSTEM LANDED (decision 12) — the full loop runs in the real game.**
New file `web/world/js/ladder.js` (Lane D). Driven by hand through SHADES.step, the prompt chain is:
`ladder_take` → carry → `ladder_place_h2` → (h2's carabiner blows) → `spare_table`
`ladder_climb`**`rerig_0`, which only exists at height** → `oooo`, spare consumed.
Selftest **194/0/0** (was 184); 11 of the new asserts are the ladder's, including the scripted
climb-repair-descend the sprint asked for.
**It needed no change to main.js.** createLadder self-wires from createPlayer, which Lane A already
hands the scene, world and interact — so a whole sub-system landed inside Lane D's own files. The
reach gate finds it via `interact.ladder` (Interact is Lane D's class), and an explicit
`deps.ladder` still wins for tests.
Numbers that made the design: **fascia sits at 2.48 m, a 1.72 m person reaches 2.20 m, E's ladder
tops out at 2.90 m.** Two hundred millimetres is the entire mechanic — the asset and the yard were
already built for each other, I just wrote the verb between them.
[D] 2026-07-17 — 📐 **Scoped `needsLadder` to the FASCIA, deliberately — flagging the judgement call.**
A pure "is it above head height?" rule would have caught the 3.95 m posts and the 5.05 m tree limbs
and turned *every* repair in the game into a two-trip ladder job — which would have silently
invalidated the recorded §7 run and Lane B's gate asserts, and isn't true to rigging anyway: a sail
post is tensioned from a cleat at its base and a tree anchor is a strop you throw. A bracket bolted
2.5 m up a bare wall is the one you cannot fake. So `needsLadder = anchor.type === 'house'`, one
line, where it can be found and argued with. This also means **the house is now the expensive anchor
to depend on** — which is what E's `ratingHint 0.35` / `collateral "gutter"` was already saying in
the data. Shout if you wanted it broader.
[D] 2026-07-17 — 🐛 **A FOOTGUN IN MY OWN API, found twice while building on it, now documented + asserted.**
`interact.register({canUse})` is re-checked EVERY FRAME to keep a hold alive — and starting a hold
moves the player into `busy`. So any `canUse` that reads `player.state` goes false on frame one and
**the action silently cancels its own hold.** No error; the prompt just looks dead. It ate the
ladder's climb (`canUse: p => p.state === 'idle'`) and the fascia reach gate (`isWorking()` testing
`state === 'atTop'`) — I lost time to it both times. Fixed by gating on PHYSICAL facts (carrying,
position, climbY), which is also the honest test. Now written on `register()`'s JSDoc where you'll
read it, and pinned by an assert that registers a deliberately state-gated action and proves it
never fires. **B/C/E: if you ever register an interact target, gate on facts, not on state.**
[D] 2026-07-17 — 💬 **FEEL NOTES from playing it (A's HUD/prep not landed yet, so these are from the
parts that exist). I'm the lane that plays it, so:**
1. **No prompt is worse than a refusal.** Carry the ladder to the shed table and the "take a
spare" prompt doesn't say "hands full" — it *vanishes*, because `canUse` filters the target out
of `nearest()` before the label can explain itself. It reads as a broken game, not a full pair
of hands. **Lane A, this is a HUD-shaped problem:** the prompt wants to show unusable actions
greyed out with their reason, and my `label(player)` already returns "hands full" / "out of
reach — needs the ladder" for exactly this. Say the word and I'll surface unusable targets.
2. **The two-trip fascia repair costs ~15 s of running** (shed→ladder→wall→shed→wall) out of a 90 s
storm. It is *supposed* to hurt, and it does — but that's a sixth of the storm on foot, and
until the HUD shows corner loads you can't tell whether you're spending it well. Worth a look
once the HUD lands; I'd rather tune it against a player who can see, than guess now.
3. **A ladder standing bolt upright reads as a post, not a ladder** — I had it vertical at first
and genuinely couldn't tell what I was looking at until I saw its shadow. It now leans 15° into
the wall. Small thing; large difference. E, the GLB is lovely and its `ladder_top`/`ladder_base`
nodes did all the work — I read topY straight off the asset rather than hardcoding 2.9.
[I] 2026-07-17 — **SPRINT 4 INTEGRATION (main).** Lanes b/c/d/e merged; applied C's two-line router fix
in main.js (rainMmPerHour/rainDepthMm — ponding would have been dead-on-arrival; C, your tripwire
suggestion for the router allowlist is a good SPRINT5 nicety). Selftest **207/0/0**. Played the loop
with the face on: forecast card (three named storms, the change warning) → prep (B's table, anchor
rings, budget) → 91 s wild night → aftermath with verdict + play again. Gate 1 confirmed.
Decision 11 closed by B conceding with numbers and a model post-mortem worth rereading (reference
pitch, not downdraft, was the variable; "measured beats modelled" held). storm_02 downdraftOfTotal
is 0.45. Ladder loop landed whole inside Lane D's files. E renamed fence_panel_snapped to match spec.
**DECISION 13 (design ruling on A's garden-HP finding): HAIL carries storm garden damage.** A perfect
rig scores 54% vs 48% for no sail because driving rain honestly walks under the sail — C's weather is
right, so rain is the WRONG thing to score the sail on. DESIGN.md already says hail is the
garden-killer and drainage answers rain. Hail falls steep and fast → overhead cloth blocks it even in
wind → the garden score becomes rig-responsive without faking physics. Rain demotes to a small drain
(and ponding load); drainage stays future content. SPRINT5 wires it.
[E] 2026-07-17 — 📊 **A finding worth more than this sprint's assets: only ONE of my five textures is
consumed.** I grepped `web/world/js/` for every texture I've shipped:
· `sail_weave`**live**, and Lane B took the recipe verbatim, down to keeping my comment.
· `pond_water` / `pond_normal` — 0 refs (fair, B's ponding is this sprint).
· `sail_tears` — 0 refs (fair, M3 isn't scoped).
· **`grass_atlas` — 0 refs, four sprints.** PLAN3D §5-E-9 asked for it, SPRINT3 §A-6 and SPRINT4
listed it, and nothing has ever loaded it. It's 28 KB of dead weight in the repo.
The one texture that got used is the one I wrote an exact copy-paste recipe for. That's not a
coincidence and it's the reason both of this sprint's textures ship with one below. **Lane A: either
take the grass recipe or tell me to delete the atlas — I'd rather bin it than keep shipping it.**
[E] 2026-07-17 — **LANE C — hail juice (SPRINT5 §E-1), shaped to fit YOUR pattern, not mine.** Your rain is
a `BoxGeometry` + flat `MeshBasicMaterial` and skyfx loads no external texture at all, so I've given
you both options and you should ignore whichever is wrong:
· `hail_stone_01_v1.glb` — 22 mm, 20 tris, lumpy (a sphere at that size reads as a bubble). Drops
into `new THREE.InstancedMesh(stoneGeo, stoneMat, n)` exactly like your streaks. If you'd rather
stones stay a box, bin it, no feelings.
· `models/textures/hail_pips.png` — 256², **2×2 atlas, cells: 0 sharp pip, 1 spiked burst, 2 splash
ring (the ground decal), 3 soft/dying**. A flat quad can't be round and an impact is round, which
is the only reason this is a texture. Cell → UV, and note row 0 is the BOTTOM so this matches
three's v-up directly:
const A = await new THREE.TextureLoader().loadAsync('/world/models/textures/hail_pips.png');
A.colorSpace = THREE.SRGBColorSpace;
const cellUV = (i) => [(i % 2) * 0.5, Math.floor(i / 2) * 0.5]; // [u0, v0], each 0.5 wide
// per instance: offset the quad's uv by cellUV(age < .05 ? 0 : age < .12 ? 1 : 3)
// ground hits: cell 2, flat on the grass, scale up as it ages
const mat = new THREE.MeshBasicMaterial({ map: A, transparent: true,
depthWrite: false, fog: false }); // same flags as your rain
Ice is near-white with a cold rim so it reads on both the sand cloth and dark wet grass. Sizes:
hail_pips 39 KB, stone 3 KB.
[E] 2026-07-17 — **LANE A/C — plant shred (§E-2):** `models/textures/plant_shred.png`, 256², 2×2, four torn
blade-scraps with a darker midrib, same cell→UV as above. Elongated on purpose — my first pass was
radial and read as green potatoes; it's the long axis plus the rib that says "leaf". Fire a dozen on a
hail burst over the bed, random spin, ~0.6 s, gravity + a little wind drift, and pair it with the
existing `plants_full``plants_tattered``plants_dead` swap so the puff explains the state change
instead of the bed just quietly becoming worse.
[E] 2026-07-17 — 🔧 **THE YARD PICTURES EXIST. `docs/yard_day.jpg` + `docs/yard_night.jpg`** (carried since
Sprint 2; I stopped waiting). I did **not** touch server.py or main.js — instead
`tools/yardshot/shot_server.py` is a Lane E tool: it serves the repo like server.py and additionally
takes `POST /shot?name=<n>`, writing the body to `docs/<n>.png|jpg`. The browser posts a Blob, the
bytes go straight to disk, and no base64 crosses a text channel.
Three gotchas worth knowing, because they cost me the afternoon:
· `canvas.toBlob` is **async** — the WebGL buffer is cleared by the time it encodes, so it hands
back null. `toDataURL` is sync and reads the frame you just drew. Render in the SAME tick.
· a **backgrounded tab lays the canvas out at 0×0**, and `toDataURL` then returns the string
`"data:,"`. Force `renderer.setSize(w, h, false)` before capturing.
· rAF is paused there too, so drive `SHADES.step()` yourself — which the harness already supports.
**Lane A: `do_POST` is ~25 lines and it's yours for the taking** — lift it into server.py, delete
`tools/yardshot/`, and anyone can screenshot the game forever. I'm not going to keep asking; the tool
works standalone in the meantime.
[E] 2026-07-17 — the pictures are the yard UNRIGGED (no sail): `game.setPhase('storm')` fast-forwards time
and the sky beautifully — C's night pass is genuinely atmospheric, 27 m/s of driving rain over a dark
yard — but it doesn't rig a sail, and I wasn't going to drive B's rigging session from the console to
fake one. **When gate 3 lands, ping me and I'll reshoot both with a rigged hypar in the frame** — that's
the picture DESIGN.md actually wants, and it's now a one-minute job rather than a four-sprint one.
(Also: `SHADES.wind` is a stale snapshot — it still pointed at the calm wind while the game was
genuinely running the wild night. Cost me a wrong conclusion for a minute. Worth a getter, A.)
[E] 2026-07-17 — 👀 the tree branch stubs still read as coat hooks, and now there's a picture of it —
`docs/yard_day.jpg`, left-hand gum. Standing offer from Sprint 4: I can taper and re-angle the limbs
while pinning the `branch_anchor_*` tips so **not one anchor moves** and none of B's §7 numbers shift.
It's contained and it's the most visible art problem in the hero shot. Say the word.

BIN
docs/yard_day.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

BIN
docs/yard_night.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

View File

@ -118,6 +118,7 @@ PAL = {
"gnome_coat": "#3E6FA8",
"gnome_hat": "#B33C36",
"bristle": "#C9A659", # broom straw
"hail_ice": "#DCEAF2", # hailstone
"ref_pink": "#E85C8A", # the reference capsule — deliberately loud
}
@ -1188,6 +1189,30 @@ def build_garden_gnome_01_broken(name):
return root
def build_hail_stone_01(name):
"""One hailstone, ~22 mm (SPRINT5 §Lane E-1, "stone mesh if C wants geometry
over sprites").
Lane C this is offered, not imposed: your rain is a BoxGeometry with a flat
material and no texture, and this drops into that exact pattern
(`new THREE.InstancedMesh(stoneGeo, stoneMat, n)`). If you'd rather stones be
a box like the streaks, ignore this and the pip atlas still stands on its own.
Lumpy on purpose a sphere at this size reads as a bubble, and real stones
are accreted knobbles. 80 tris.
"""
rng = rng_for(name)
root = add_empty(name)
ice = get_material("Mat_Ice", PAL["hail_ice"], 0.25)
stone = add_ico(f"{name}_stone", 0.011, (0, 0, 0.011), ice, subdiv=1,
scale=(1.0, rng.uniform(0.82, 0.95), rng.uniform(0.78, 0.92)),
jitter=0.0018, rng=rng)
join_group([stone], "stone", root)
stamp(root, name, "weather")
root["diameter_m"] = 0.022
root["mass_hint"] = 0.006
return root
def build_broom_01(name):
"""The poke-the-pond tool (SPRINT4 §Lane E-2).
@ -1515,6 +1540,88 @@ def build_pond_textures():
return [p1, p2]
def build_hail_and_shred_atlases():
"""Hail impact pips + plant shred fragments (SPRINT5 §Lane E-1/2).
Both are 2x2 atlases of alpha sprites for InstancedMesh billboards, which is
the shape Lane C's rain already has — instanced quads, DynamicDrawUsage,
depthWrite off. The one thing a flat-coloured quad cannot do is be round, and
an impact is round, which is the whole reason these are textures at all.
Cells (hail_pips): 0 sharp pip, 1 spiked burst, 2 splash ring (the ground
decal), 3 soft fading pip. Pick per age so one impact can play 0 -> 1 -> 3
and a ground hit can just use 2.
"""
import numpy as np
SIZE, CELL = 256, 128
pips = np.zeros((SIZE, SIZE, 4), dtype=np.float32)
for idx in range(4):
cy, cx = (idx // 2) * CELL, (idx % 2) * CELL
Y, X = np.mgrid[0:CELL, 0:CELL]
c = (CELL - 1) / 2.0
nx, ny = (X - c) / c, (Y - c) / c
r = np.sqrt(nx * nx + ny * ny)
th = np.arctan2(ny, nx)
if idx == 0: # sharp pip: hot core, fast falloff
a = np.clip(1.0 - r, 0, 1) ** 3.2
elif idx == 1: # burst: core plus radiating spikes
spikes = 0.5 + 0.5 * np.cos(th * 8.0)
a = np.clip(1.0 - r, 0, 1) ** 2.6 + 0.5 * spikes * np.clip(1.0 - r, 0, 1) ** 5.0
elif idx == 2: # splash ring — the ground decal
a = np.exp(-((r - 0.62) ** 2) / 0.012) * np.clip(1.0 - r, 0, 1) ** 0.4
else: # soft, dying
a = np.exp(-(r ** 2) / 0.20) * 0.75
a = np.clip(a, 0, 1)
# Hail is ice: near-white with a cold rim, so it reads against both the
# sand-coloured cloth and dark wet grass.
pips[cy:cy + CELL, cx:cx + CELL, 0] = 0.88 + 0.12 * a
pips[cy:cy + CELL, cx:cx + CELL, 1] = 0.94 + 0.06 * a
pips[cy:cy + CELL, cx:cx + CELL, 2] = 1.0
pips[cy:cy + CELL, cx:cx + CELL, 3] = a
p1, kb1 = save_png(pips, "hail_pips")
print(f" hail_pips.png {SIZE}x{SIZE}, 4 cells (pip/burst/ring/soft), {kb1} KB")
# --- plant shred ------------------------------------------------------
# Torn leaf fragments, not dots: the bed is being shredded, and a green dot
# reads as a bug. Each cell is one ragged blade-scrap with a darker midrib.
shred = np.zeros((SIZE, SIZE, 4), dtype=np.float32)
for idx in range(4):
r_ = rng_for(f"plant_shred_{idx}")
cy, cx = (idx // 2) * CELL, (idx % 2) * CELL
Y, X = np.mgrid[0:CELL, 0:CELL]
c = (CELL - 1) / 2.0
nx, ny = (X - c) / c, (Y - c) / c
th = np.arctan2(ny, nx)
r = np.sqrt(nx * nx + ny * ny)
# A torn blade-scrap: genuinely elongated, then ripped along the edge.
# A radial lobe alone gives a teardrop, which at particle size reads as a
# green potato — it's the long axis plus the midrib that says "leaf".
# Aspect varies per cell so one burst isn't four copies of a shape.
aspect = r_.uniform(0.38, 0.62)
ex, ey = nx * aspect, ny / aspect
er = np.sqrt(ex * ex + ey * ey)
tear = np.zeros_like(th)
for k in range(1, 5):
tear += (0.06 / k) * np.sin(th * (2 * k + 1) + r_.uniform(0, math.tau))
inside = er < (0.42 + tear)
g = r_.uniform(0.42, 0.62)
rib = np.abs(ny) < 0.035 # the midrib, darker
col = np.where(rib, 0.65, 1.0)
shred[cy:cy + CELL, cx:cx + CELL, 0] = np.where(inside, g * 0.55 * col, 0)
shred[cy:cy + CELL, cx:cx + CELL, 1] = np.where(inside, g * col, 0)
shred[cy:cy + CELL, cx:cx + CELL, 2] = np.where(inside, g * 0.34 * col, 0)
shred[cy:cy + CELL, cx:cx + CELL, 3] = np.where(inside, 1.0, 0.0)
p2, kb2 = save_png(shred, "plant_shred")
print(f" plant_shred.png {SIZE}x{SIZE}, 4 leaf scraps, {kb2} KB")
return [p1, p2]
def build_grass_atlas():
"""4-tuft billboard atlas, 2x2 cells. Drawn with numpy (no PIL in Blender's
python) and saved through bpy's image API. Lane A instances quads with this."""
@ -1638,6 +1745,9 @@ ASSETS = [
# whatever is behind it.
# Wider than the 0.30 head: the bristles splay past it, which is what a worn
# broom does. A real yard broom is 0.300.45 m across.
dict(name="hail_stone_01", fn=build_hail_stone_01,
dims=((0.015, 0.030), (0.012, 0.028), (0.012, 0.028)),
nodes=["stone"]),
dict(name="broom_01", fn=build_broom_01,
dims=((0.28, 0.45), (0.04, 0.12), (1.35, 1.50)),
nodes=["handle", "head", "bristles", "grip_anchor", "poke_tip"]),
@ -1970,6 +2080,7 @@ def main():
build_grass_atlas()
build_sail_textures()
build_pond_textures()
build_hail_and_shred_atlases()
debris = [] if no_debris else copy_debris()
failures = []

View File

@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""
SHADES Lane E screenshot capture. Python stdlib only, same house rule as
server.py: no pip, no venv, no build step.
WHY THIS EXISTS
---------------
There is no way to get a game screenshot onto disk. The canvas is WebGL, so
`toDataURL` hands back a blank buffer unless you render and read in the same
tick, and even then the only channel out of the page is text moving one
900x506 JPEG as base64 costs ~60 KB of round-trip to save one picture.
DESIGN.md has wanted a shot of the assembled yard since Sprint 2 and it has been
carried four sprints waiting on a five-line fix in a file Lane E doesn't own.
So this serves the repo exactly like server.py, and additionally accepts
`POST /shot?name=<n>`, writing the raw body to docs/<n>.png. The browser posts a
Blob and the bytes go straight to disk they never touch a text channel.
python3 tools/yardshot/shot_server.py --port 8815
then, from the page:
SHADES.render(); // same tick as the read!
document.getElementById('c').toBlob(
(b) => fetch('/shot?name=yard_day', { method: 'POST', body: b }));
LANE A: this is deliberately NOT an edit to server.py that's your file, and
§6 says post the need rather than reach into it. I posted it twice; nobody had
the spare hands, which is fair. `do_POST` below is the whole fix: lift it
verbatim into server.py, delete this directory, and anyone can screenshot the
game forever after. Until then this stays a Lane E tool and touches nothing.
Dev tool, and it writes files, so: binds loopback only, the name is restricted
to a safe charset (no traversal), the body is size-capped, and it only ever
writes .png into docs/.
"""
from __future__ import annotations
import argparse
import http.server
import re
from pathlib import Path
from urllib.parse import parse_qs, urlparse
ROOT = Path(__file__).resolve().parents[2]
OUT_DIR = ROOT / "docs"
SAFE_NAME = re.compile(r"^[a-z0-9_\-]{1,64}$")
MAX_BYTES = 20 * 1024 * 1024
class Handler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(ROOT), **kwargs)
def do_POST(self): # noqa: N802 (stdlib naming)
url = urlparse(self.path)
if url.path != "/shot":
self.send_error(404, "only POST /shot")
return
name = (parse_qs(url.query).get("name") or ["shot"])[0]
if not SAFE_NAME.match(name):
self.send_error(400, "name must match [a-z0-9_-]{1,64}")
return
length = int(self.headers.get("Content-Length") or 0)
if not 0 < length <= MAX_BYTES:
self.send_error(413, "empty or too large")
return
data = self.rfile.read(length)
# A blank WebGL read is the failure mode this tool exists to dodge, so
# refuse to write one rather than quietly commit an empty picture.
if data.startswith(b"\x89PNG"):
ext = "png"
elif data.startswith(b"\xff\xd8\xff"):
ext = "jpg"
else:
self.send_error(415, "body is neither PNG nor JPEG")
return
OUT_DIR.mkdir(exist_ok=True)
out = OUT_DIR / f"{name}.{ext}"
out.write_bytes(data)
print(f" shot -> {out.relative_to(ROOT)} ({len(data) // 1024} KB)")
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", "2")
self.end_headers()
self.wfile.write(b"ok")
def log_message(self, fmt, *args):
pass # the shot line above is the only output worth having
def main():
ap = argparse.ArgumentParser(description="Serve the repo + accept POST /shot")
ap.add_argument("--port", type=int, default=8815)
args = ap.parse_args()
srv = http.server.ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
print(f"serving {ROOT} on http://127.0.0.1:{args.port}/")
print(f" game http://127.0.0.1:{args.port}/web/world/index.html")
print(f" shots -> {OUT_DIR}")
srv.serve_forever()
if __name__ == "__main__":
main()

View File

@ -24,7 +24,9 @@
"events": [],
"rain": { "curve": [[0, 0], [30, 0.15], [55, 0.2], [80, 0.08], [90, 0]] },
"_rain_comment": "peakMmPerHour 8 = a light shower. Mean intensity ~0.12, so at decision 10's 40x a whole storm delivers ~0.9 mm — about a millimetre of water, ~20 kg over a 25 m2 flat sail. Ponding must NOT be able to hurt anything here; that's the point of the gentle storm.",
"rain": { "peakMmPerHour": 8, "curve": [[0, 0], [30, 0.15], [55, 0.2], [80, 0.08], [90, 0]] },
"sky": { "darkness": 0.15, "cloudScroll": 0.02 }
}

View File

@ -20,7 +20,7 @@
"powBase": 3,
"powRand": 5,
"powRamp": 7,
"downdraftOfTotal": 0.12
"downdraftOfTotal": 0.45
},
"dirCurve": [[0, 0.85], [50, 0.95], [55, 0.6], [59, -1.25], [70, -1.45], [90, -1.35]],
@ -38,7 +38,11 @@
{ "t": 79, "type": "lightning", "power": 0.5 }
],
"rain": { "curve": [[0, 0], [10, 0.25], [35, 0.6], [55, 0.85], [70, 1.0], [90, 0.7]] },
"_rain_comment": "peakMmPerHour 80 = severe thunderstorm rate, the scale rainAt's 0..1 is a fraction OF. Ponding (decision 10) reads it. Mean intensity over the storm is ~0.64, and at 40x compression a 90 s storm is one hour of rain, so depth ~= 80 * 0.64 = ~51 mm = 5.1 cm on a flat sail — Lane B's measured kill: 5 cm over 25 m2 = 1250 kg = 3.1 kN/corner, against a wind load of only 0.2-1.1 kN. A flat rig should drown here. A hypar pools nothing and doesn't care.",
"sky": { "darkness": 0.8, "cloudScroll": 0.09 }
"rain": { "peakMmPerHour": 80, "curve": [[0, 0], [10, 0.25], [35, 0.6], [55, 0.85], [70, 1.0], [90, 0.7]] },
"_sky_comment": "night: true forces the night palette rather than leaning on the darkness threshold — it's called Wild Night and the forecast card has to sell that. lightningGustPow 10 fires a flash on any gust at/above 10 m/s of gust power (this storm's gusts top out ~12.6, so it lights up for the worst few, late, on top of the three authored strikes) — the storm's worst moments should be the ones you see.",
"sky": { "darkness": 0.94, "cloudScroll": 0.09, "night": true, "lightningGustPow": 10 }
}

View File

@ -32,7 +32,9 @@
{ "t": 62, "type": "lightning", "power": 0.4 }
],
"rain": { "curve": [[0, 0], [28, 0.05], [34, 0.4], [55, 0.55], [80, 0.3], [90, 0.15]] },
"_rain_comment": "peakMmPerHour 30 = moderate. The rain arrives WITH the change at t~30 and not before — dry hot afternoon, then the southerly brings the water, which is the story the curve is telling. Mean intensity ~0.28, so at decision 10's 40x it delivers ~8 mm = 0.8 cm: ~200 kg on a 25 m2 flat sail, ~0.5 kN/corner. That is the middle rung on purpose — enough that a flat rig on carabiners (1.2 kN) should feel the water once wind is added, not enough to drown a shackle. Ponding teaches here; it kills in storm_02.",
"rain": { "peakMmPerHour": 30, "curve": [[0, 0], [28, 0.05], [34, 0.4], [55, 0.55], [80, 0.3], [90, 0.15]] },
"sky": { "darkness": 0.5, "cloudScroll": 0.05 }
}

View File

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SHADES — yard (M0)</title>
<title>SHADES — rig it, then survive the night</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #9fc4dd; }
canvas { display: block; width: 100%; height: 100%; }
@ -28,7 +28,6 @@
<canvas id="c"></canvas>
<div id="banner"></div>
<div id="dev">booting…</div>
<div id="help">WASD move · shift run · RMB drag orbit · wheel zoom · Enter next phase</div>
<script type="importmap">
{ "imports": { "three": "./vendor/three.module.js",

392
web/world/js/hud.js Normal file
View File

@ -0,0 +1,392 @@
/**
* SHADES the face. Lane A owns this file.
*
* Everything the game already simulates is invisible to a stranger without this:
* loads, the gust warning, whether the garden is actually getting hit. So the
* rule here is that the HUD only ever READS. It takes no decisions, owns no
* state worth keeping, and if you deleted it the sim would run identically
* which is also why it can be this chatty without anything drifting.
*
* Two things earn their place as world-anchored rather than screen-anchored:
* the corner load bars (a number in a corner of the screen can't tell you WHICH
* shackle is about to go, and that's the whole "the shackle, I knew about the
* shackle" moment) and the repair prompt, which Lane D already owns.
*/
import * as THREE from '../vendor/three.module.js';
import { STORM_LEN } from './contracts.js';
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
const kmh = (ms) => ms * 3.6;
// Load bar colours: green → amber → red. The amber band is deliberately wide;
// a corner at 60% in a lull is one gust from 100% and the player should feel
// that before the bar turns red on them.
const BAR_OK = 0x6ee06e, BAR_WARN = 0xffc24a, BAR_HOT = 0xff5b4a, BAR_DEAD = 0x7a2f2f;
const CSS = `
#hud { position:fixed; inset:0; pointer-events:none; z-index:10;
font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; color:#dde5ea;
text-shadow:0 1px 3px #000a; user-select:none; }
#hud .panel { position:absolute; background:#0d1418d9; border:1px solid #2c3a44;
border-radius:6px; padding:8px 10px; }
#hud-top { top:10px; left:50%; transform:translateX(-50%); text-align:center; min-width:280px; }
#hud-wind { font-size:15px; font-weight:700; letter-spacing:.04em; }
#hud-clock { color:#8ba0ad; }
#hud-gust { position:absolute; top:78px; left:50%; transform:translateX(-50%);
font:700 20px/1 ui-monospace,Menlo,monospace; letter-spacing:.16em; color:#ffd27a;
background:#3a2a0ce0; border:1px solid #7a5a1a; border-radius:5px; padding:8px 16px;
opacity:0; transition:opacity .12s; }
#hud-gust.on { opacity:1; }
#hud-garden { bottom:12px; left:12px; min-width:210px; }
#hud-bar { height:9px; background:#00000066; border-radius:5px; overflow:hidden; margin-top:5px; }
#hud-bar i { display:block; height:100%; width:100%; background:#6ee06e; transition:width .2s,background .3s; }
#hud-carry { bottom:12px; right:12px; text-align:right; }
#hud-events { position:absolute; bottom:70px; left:12px; max-width:420px; }
#hud-events div { color:#ffd27a; margin-top:2px; }
#hud-help { position:absolute; bottom:12px; left:50%; transform:translateX(-50%);
color:#93a6b2; opacity:.85; white-space:nowrap; }
#hud-card { position:fixed; inset:0; z-index:30; display:none; place-items:center;
background:#060a0dc4; backdrop-filter:blur(3px);
font:13px/1.7 ui-monospace,SFMono-Regular,Menlo,monospace; color:#dde5ea; }
#hud-card.on { display:grid; }
#hud-card .card { background:#0d1418; border:1px solid #2f3f4a; border-radius:9px;
padding:22px 26px; min-width:440px; max-width:620px; pointer-events:auto; }
#hud-card h1 { margin:0 0 2px; font-size:19px; letter-spacing:.16em; color:#fff; }
#hud-card h2 { margin:0 0 16px; font-size:12px; font-weight:400; color:#8ba0ad; letter-spacing:.04em; }
#hud-card .row { display:flex; justify-content:space-between; gap:18px; padding:3px 0;
border-bottom:1px solid #1c262d; }
#hud-card .row b { font-weight:700; color:#fff; }
#hud-card .storm { display:block; width:100%; text-align:left; margin:7px 0; padding:10px 12px;
background:#121c23; border:1px solid #2f3f4a; border-radius:6px; color:#dde5ea; cursor:pointer;
font:inherit; transition:border-color .15s,background .15s; }
#hud-card .storm:hover { border-color:#7ee0ff; background:#16242c; }
#hud-card .storm .name { font-weight:700; color:#fff; letter-spacing:.08em; }
#hud-card .storm .stat { color:#8ba0ad; }
#hud-card .go { margin-top:16px; padding:9px 18px; background:#1d3d2a; border:1px solid #3f7a52;
border-radius:6px; color:#a8f0b8; cursor:pointer; font:inherit; font-weight:700; letter-spacing:.1em; }
#hud-card .go:hover { background:#255033; }
#hud-card .verdict { margin:14px 0 0; padding:10px 12px; border-radius:6px; font-weight:700;
letter-spacing:.05em; }
#hud-card .verdict.win { background:#12321c; border:1px solid #2c6b3c; color:#7fce6a; }
#hud-card .verdict.lose { background:#3a1618; border:1px solid #7d2b2b; color:#ff8f86; }
`;
/**
* @param {object} d
* @param {THREE.Scene} d.scene
* @param {THREE.Camera} d.camera
* @param {object} d.game the phase machine
* @param {object} d.world
* @param {object} d.wind the router
* @param {object} d.player
* @param {object} d.rig SailRig
* @param {object} d.garden {hp}
* @param {() => object} d.getSky skyfx is rebuilt per storm, so read it late
* @param {() => number} d.getWindTime
* @param {{text:string,t:number}[]} d.events
*/
export function createHud(d) {
const style = document.createElement('style');
style.textContent = CSS;
document.head.appendChild(style);
const root = document.createElement('div');
root.id = 'hud';
root.innerHTML = `
<div class="panel" id="hud-top">
<div id="hud-wind">--</div>
<div id="hud-clock">--</div>
</div>
<div id="hud-gust">GUST INCOMING</div>
<div class="panel" id="hud-garden">
<div><span id="hud-garden-label">GARDEN</span> <b id="hud-garden-pct">100%</b></div>
<div id="hud-bar"><i></i></div>
<div id="hud-shade" style="color:#8ba0ad"></div>
</div>
<div class="panel" id="hud-carry" style="display:none"></div>
<div id="hud-events"></div>
<div id="hud-help"></div>
`;
document.body.appendChild(root);
const card = document.createElement('div');
card.id = 'hud-card';
document.body.appendChild(card);
const $ = (id) => root.querySelector(id);
const elWind = $('#hud-wind'), elClock = $('#hud-clock'), elGust = $('#hud-gust');
const elPct = $('#hud-garden-pct'), elBar = $('#hud-bar i'), elShade = $('#hud-shade');
const elGardenLabel = $('#hud-garden-label');
const elCarry = $('#hud-carry'), elEvents = $('#hud-events'), elHelp = $('#hud-help');
// --- world-anchored corner load bars ------------------------------------
// A bar per corner, floating at the corner it describes. Geometry rather than
// a redrawn canvas: the fill is a scaled quad, so animating it is free and a
// flogging corner's bar can track it at 60 Hz without touching a texture.
const barGroup = new THREE.Group();
barGroup.visible = false;
d.scene.add(barGroup);
const quad = new THREE.PlaneGeometry(1, 1);
quad.translate(0.5, 0, 0); // pivot on the left edge, so scale.x grows rightward
const BAR_W = 0.9, BAR_H = 0.1;
const bars = [];
function ensureBars(n) {
while (bars.length < n) {
const holder = new THREE.Group();
const bg = new THREE.Mesh(quad, new THREE.MeshBasicMaterial({
color: 0x0a1016, transparent: true, opacity: 0.75, depthTest: false,
}));
bg.scale.set(BAR_W, BAR_H, 1);
bg.position.x = -BAR_W / 2;
const fill = new THREE.Mesh(quad, new THREE.MeshBasicMaterial({
color: BAR_OK, depthTest: false,
}));
fill.position.x = -BAR_W / 2;
fill.scale.set(0.001, BAR_H * 0.72, 1);
const label = makeLabel();
label.position.y = 0.19;
holder.add(bg, fill, label);
holder.renderOrder = 999;
barGroup.add(holder);
bars.push({ holder, fill, label, lastText: '' });
}
for (let i = 0; i < bars.length; i++) bars[i].holder.visible = i < n;
}
function makeLabel() {
const c = document.createElement('canvas');
c.width = 256; c.height = 64;
const tex = new THREE.CanvasTexture(c);
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true }));
sp.scale.set(1.5, 0.375, 1);
sp.userData.ctx = c.getContext('2d');
sp.userData.tex = tex;
return sp;
}
function drawLabel(sprite, text, color) {
const g = sprite.userData.ctx;
g.clearRect(0, 0, 256, 64);
g.font = 'bold 30px ui-monospace, Menlo, monospace';
g.textAlign = 'center';
g.textBaseline = 'middle';
g.lineWidth = 6;
g.strokeStyle = '#0a1016';
g.strokeText(text, 128, 32);
g.fillStyle = color;
g.fillText(text, 128, 32);
sprite.userData.tex.needsUpdate = true;
}
// Labels are the only per-frame texture cost here, so they redraw on a slow
// tick and only when the shown value actually changes.
let labelT = 0;
const _p = new THREE.Vector3();
const _q = new THREE.Quaternion();
const _cam = new THREE.Vector3();
// --- helpers ------------------------------------------------------------
const barColor = (frac, broken) => (broken ? BAR_DEAD : frac > 0.85 ? BAR_HOT : frac > 0.55 ? BAR_WARN : BAR_OK);
function cornerPos(i) {
if (d.rig.cornerPos) return d.rig.cornerPos(i);
const c = d.rig.corners[i];
return c?.anchor?.pos ?? null;
}
const hud = {
/** Set false while a card is up — the storm HUD shouldn't peer through it. */
setVisible(on) { root.style.display = on ? '' : 'none'; },
setHelp(text) { elHelp.textContent = text; },
/**
* @param {number} dt
* @param {number} t wind/storm time
*/
update(dt, t) {
const phase = d.game.phase;
const storm = phase === 'storm';
// --- wind + clock ---
const speed = d.wind.speedAt(d.player.pos, t);
elWind.textContent = `${speed.toFixed(1)} m/s ${kmh(speed).toFixed(0)} km/h`;
elWind.style.color = speed > 25 ? '#ff8f86' : speed > 15 ? '#ffc24a' : '#dde5ea';
elClock.textContent = storm
? `STORM ${Math.max(0, STORM_LEN - d.game.phaseT).toFixed(0)}s left`
: phase.toUpperCase();
// --- gust telegraph ---
// The contract promises >=1.2 s of warning; this is where the player
// actually gets to spend it.
const tel = storm ? d.wind.gustTelegraph(t) : null;
elGust.classList.toggle('on', !!tel);
if (tel) elGust.textContent = `GUST INCOMING ${tel.eta.toFixed(1)}s · ${kmh(tel.power).toFixed(0)} km/h`;
// --- garden ---
const hp = d.garden.hp;
elPct.textContent = `${hp.toFixed(0)}%`;
elBar.style.width = `${clamp01(hp / 100) * 100}%`;
elBar.style.background = hp > 66 ? '#6ee06e' : hp > 33 ? '#ffc24a' : '#ff5b4a';
// Decision 7: rain shadow is what matters at night, sun coverage by day.
// Showing whichever is load-bearing right now stops the number being a
// fact about nothing.
const sky = d.getSky?.();
if (storm && sky?.rainShadowOver) {
const dry = sky.rainShadowOver(d.world.gardenBed);
elGardenLabel.textContent = 'GARDEN';
elShade.textContent = `${(dry * 100).toFixed(0)}% kept dry by the sail`;
} else {
const shade = d.rig.rigged ? d.rig.coverageOver(d.world.gardenBed, d.world.sunDir, d.world.heightAt) : 0;
elGardenLabel.textContent = 'GARDEN';
elShade.textContent = d.rig.rigged ? `${(shade * 100).toFixed(0)}% in shade` : 'no sail rigged';
}
// --- carried item ---
const carrying = d.player.carrying;
elCarry.style.display = carrying ? '' : 'none';
if (carrying) elCarry.textContent = `carrying: ${carrying}`;
// --- event ticker ---
const live = d.events.filter((e) => t - e.t < 6 || e.t > t);
elEvents.innerHTML = live.slice(-4).map((e) => `<div>${e.text}</div>`).join('');
// --- corner bars ---
barGroup.visible = d.rig.rigged && (storm || phase === 'prep');
if (!barGroup.visible) return;
ensureBars(d.rig.corners.length);
labelT += dt;
const redraw = labelT >= 0.12;
if (redraw) labelT = 0;
d.camera.getWorldQuaternion(_q);
d.camera.getWorldPosition(_cam);
for (let i = 0; i < d.rig.corners.length; i++) {
const c = d.rig.corners[i];
const b = bars[i];
const p = cornerPos(i);
if (!p) { b.holder.visible = false; continue; }
_p.set(p.x, p.y, p.z);
b.holder.position.copy(_p);
b.holder.position.y += 0.45;
b.holder.quaternion.copy(_q);
// Hold roughly constant screen size. A house corner is ~18 m away when
// you're standing at the posts, and at that range a world-sized bar is
// a few unreadable pixels — which defeats the entire point of putting it
// out there rather than in a corner of the screen. Clamped so it doesn't
// balloon when you walk right up to a corner to repair it.
const dist = _p.distanceTo(_cam);
b.holder.scale.setScalar(Math.max(0.75, Math.min(3.2, dist / 7)));
const frac = clamp01((c.load || 0) / c.hw.rating);
b.fill.scale.x = Math.max(0.001, (c.broken ? 1 : frac) * BAR_W);
b.fill.material.color.setHex(barColor(frac, c.broken));
if (redraw) {
const text = c.broken
? `${c.anchorId.toUpperCase()} BLOWN`
: `${c.anchorId.toUpperCase()} ${((c.load || 0) / 1000).toFixed(1)}/${(c.hw.rating / 1000).toFixed(1)}kN`;
if (text !== b.lastText) {
b.lastText = text;
drawLabel(b.label, text, c.broken ? '#ff8f86' : frac > 0.85 ? '#ffb1ab' : '#dde5ea');
}
}
}
},
// --- cards ------------------------------------------------------------
/**
* The forecast. Its job is to sell the dread and, incidentally, to be the
* difficulty select for free three authored storms already bracket the
* range, so letting the player pick one IS the choice.
*
* @param {{key:string, def:object}[]} storms
* @param {(key:string) => void} onPick
*/
showForecast(storms, onPick) {
const rows = storms.map(({ key, def }) => {
const peak = Math.max(...def.baseCurve.map((p) => p[1]));
const gustPeak = peak + (def.gusts?.powBase ?? 0) + (def.gusts?.powRamp ?? 0);
const rainPeak = Math.max(...(def.rain?.curve ?? [[0, 0]]).map((p) => p[1]));
const change = (def.events ?? []).find((e) => e.type === 'windchange');
const night = (def.sky?.darkness ?? 0) > 0.6;
return `<button class="storm" data-key="${key}">
<div class="name">${(def.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? ' · NIGHT' : ''}</div>
<div class="stat">sustained to ${peak.toFixed(0)} m/s (${kmh(peak).toFixed(0)} km/h)
· gusts to ~${kmh(gustPeak).toFixed(0)} km/h</div>
<div class="stat">rain ${rainPeak >= 0.8 ? 'heavy' : rainPeak >= 0.4 ? 'steady' : 'light'}
${change ? `· southerly change at ${change.t}s` : '· no change forecast'}</div>
</button>`;
}).join('');
card.innerHTML = `<div class="card">
<h1>FORECAST</h1>
<h2>90 seconds of storm. Pick your night, then rig for it.</h2>
${rows}
<div class="stat" style="color:#8ba0ad;margin-top:12px">
A change means the corners that were slack all storm are the loaded ones after it.
</div>
</div>`;
card.classList.add('on');
hud.setVisible(false);
for (const b of card.querySelectorAll('.storm')) {
b.addEventListener('click', () => { hud.hideCard(); onPick(b.dataset.key); });
}
},
/**
* @param {object} r {hp, cornersLost, cornersTotal, bill, collateral, budgetLeft, win}
* @param {() => void} onAgain
*/
showAftermath(r, onAgain) {
const rows = [
['garden', `${r.hp.toFixed(0)}%`],
['corners intact', `${r.cornersTotal - r.cornersLost}/${r.cornersTotal}`],
['hardware lost', r.bill ? `$${r.bill}` : 'none'],
['collateral', r.collateral.length ? r.collateral.map((c) => `${c.what} ($${c.cost})`).join(', ') : 'none'],
['budget left', `$${r.budgetLeft}`],
].map(([k, v]) => `<div class="row"><span>${k}</span><b>${v}</b></div>`).join('');
card.innerHTML = `<div class="card">
<h1>AFTERMATH</h1>
<h2>${r.subtitle}</h2>
${rows}
<div class="verdict ${r.win ? 'win' : 'lose'}">${r.verdict}</div>
<button class="go">PLAY AGAIN</button>
</div>`;
card.classList.add('on');
hud.setVisible(false);
card.querySelector('.go').addEventListener('click', () => { hud.hideCard(); onAgain(); });
},
hideCard() {
card.classList.remove('on');
card.innerHTML = '';
hud.setVisible(true);
},
get cardOpen() { return card.classList.contains('on'); },
dispose() {
root.remove(); card.remove(); style.remove();
d.scene.remove(barGroup);
quad.dispose();
for (const b of bars) {
b.fill.material.dispose();
b.label.material.map?.dispose();
b.label.material.dispose();
}
},
};
return hud;
}

View File

@ -28,7 +28,13 @@ export class Interact {
* @param {number} [spec.radius] metres
* @param {number} [spec.holdSecs]
* @param {string|function} [spec.label] string, or (player)->string for live text
* @param {function} [spec.canUse] (player) -> bool
* @param {function} [spec.canUse] (player) -> bool.
* **Do not test `player.state` in here.** canUse is re-checked every frame to KEEP a hold alive,
* and starting a hold moves the player into `busy` so `canUse: p => p.state === 'idle'` goes
* false on frame one and the action silently cancels its own hold. It looks exactly like a dead
* prompt. Gate on physical facts instead (carrying, position, height); locked states already
* can't start a hold, because step() checks `!player.busy` first. This bit twice: the ladder's
* climb and the fascia reach gate.
* @param {function} [spec.onDone] (player, t) -> void
* @param {string} [spec.clip] verb played for the length of the hold ('Crank', 'PickUp', ).
* Must name a clip in player_anims.glb; omitted means the busy state's default Idle.
@ -149,14 +155,35 @@ export class Interact {
*/
export function wireYardActions(interact, deps = {}) {
const { sailRig, world } = deps;
// createLadder publishes itself onto the Interact instance, so main.js doesn't have to thread a
// ladder through to get the fascia reach gate. An explicit dep still wins (tests pass one).
const ladder = deps.ladder || interact.ladder || null;
const wired = [];
const cornerAt = (i) => (sailRig && sailRig.corners && sailRig.corners[i]) || null;
const anchorOf = (i) => {
const c = cornerAt(i);
if (!c) return null;
return c.anchor || (world && world.anchors && world.anchors.find((a) => a.id === c.anchorId)) || null;
};
// a flogging corner is MOVING — resolve position every frame, never once at wire time
const posAt = (i) => () => {
const c = cornerAt(i);
if (!c) return null;
// A fascia corner is worked AT THE BRACKET, not at the cloth: the corner has detached and the
// sail is hanging down somewhere, but re-attaching it means getting a shackle onto a fitting
// 2.48 m up a wall. So the prompt lives at the anchor and you need the ladder to hold it.
if (ladder && ladder.needsLadder(anchorOf(i))) {
const a = anchorOf(i);
return { x: a.pos.x, y: a.pos.y, z: a.pos.z + 0.9 };
}
return (sailRig.cornerPos && sailRig.cornerPos(i)) || c.pos || null;
};
/** Fascia work needs you up the ladder that's planted under THAT bracket. */
const canReach = (i, p) => {
const a = anchorOf(i);
if (!ladder || !ladder.needsLadder(a)) return true; // everything else is ground work
return ladder.isWorking(a.id) && p.reachY >= a.pos.y;
};
if (sailRig && Array.isArray(sailRig.corners)) {
sailRig.corners.forEach((_corner, i) => {
@ -166,10 +193,10 @@ export function wireYardActions(interact, deps = {}) {
pos: posAt(i),
radius: 1.8,
holdSecs: 2.5,
label: 're-rig corner',
label: (p) => (canReach(i, p) ? 're-rig corner' : 'out of reach — needs the ladder'),
clip: 'Crank',
canUse: (p) => !!(cornerAt(i) && cornerAt(i).broken)
&& p.carrying === 'spare' && !!sailRig.repair,
&& p.carrying === 'spare' && !!sailRig.repair && canReach(i, p),
onDone: (p) => { p.carrying = null; sailRig.repair(i); },
}));
// per-corner turnbuckle trim — new vs the prototype; makes corners individual
@ -178,9 +205,9 @@ export function wireYardActions(interact, deps = {}) {
pos: posAt(i),
radius: 1.8,
holdSecs: 1.2,
label: 'tighten turnbuckle',
label: (p) => (canReach(i, p) ? 'tighten turnbuckle' : 'out of reach — needs the ladder'),
clip: 'Crank',
canUse: () => !!cornerAt(i) && !cornerAt(i).broken && !!sailRig.trim,
canUse: (p) => !!cornerAt(i) && !cornerAt(i).broken && !!sailRig.trim && canReach(i, p),
onDone: () => sailRig.trim(i, +0.1),
}));
});

210
web/world/js/ladder.js Normal file
View File

@ -0,0 +1,210 @@
/**
* ladder.js the ladder sub-system. (Lane D, SPRINT4 decision 12)
*
* Why it exists: the house fascia brackets sit at y=2.48 and a 1.72 m person's hands reach 2.20.
* Two hundred millimetres is the whole mechanic. Everything else in the yard you can rig from the
* ground a sail post is tensioned from a cleat at its base, a tree anchor is a strop you throw
* but a bracket bolted 2.5 m up a bare wall is not negotiable, and E's ladder tops out at exactly
* 2.9 m. The asset and the yard were built for each other; this file is the verb between them.
*
* The loop it creates is the "limited hands" rule from DESIGN.md doing real work:
* ladder and spare are BOTH carry items, and you can only hold one.
* So a fascia repair costs two trips fetch the ladder, plant it, go back for the spare
* while a post repair costs one. The house is the expensive anchor to depend on, which is
* exactly what E's ratingHint 0.35 / collateral "gutter" is already telling you in the data.
*
* Ownership: Lane D. Self-wires from createPlayer() in player.js, so main.js (Lane A's file) needs
* no change to get this it already hands createPlayer the scene, world and interact.
*/
import * as THREE from '../vendor/three.module.js';
import { GLTFLoader } from '../vendor/addons/loaders/GLTFLoader.js';
export const LADDER_URL = './models/ladder_01_v1.glb';
/**
* Which anchors you cannot rig from the ground.
*
* Deliberately keyed on the anchor TYPE, not on a height test. A pure "is it above reach?" rule
* would rope in the posts (3.95 m) and tree limbs (up to 5.05 m) and turn every single repair into
* a two-trip ladder job which is both untrue to how sails are actually rigged and would have
* silently invalidated the recorded §7 run and Lane B's gate asserts. Decision 12 scopes this to
* the fascia; this is that scope, in one line, where it can be found and argued with.
*/
export const needsLadder = (anchor) => !!anchor && anchor.type === 'house';
/** Where the player stands to work a fascia anchor: out from the wall, at the anchor's x. */
const STAND_OFF = 0.9; // m clear of the wall face
const PLACE_RANGE = 2.6; // m — how close you must be to a fascia anchor to plant the ladder
const RUNG_CLEAR = 0.55; // m — feet this far below the top rung, so the fascia is at chest height
/**
* @param {THREE.Object3D} scene
* @param {object} world contracts World (must be dressed anchors are final only after dress())
* @param {object} interact Lane D's Interact
* @param {object} player the PlayerSim
* @returns {object} the ladder system
*/
export function createLadder(scene, world, interact, player) {
const anchors = (world.anchors || []).filter(needsLadder);
if (!anchors.length) {
// no fascia in this yard (a bare harness, say) — the whole sub-system is moot, don't half-wire it
return { placedAt: null, carried: false, needsLadder, isWorking: () => false,
workY: () => 0, servedAnchor: () => null, update() {}, dispose() {} };
}
const state = {
carried: false, // in the player's hands
placedAt: null, // anchor id, or null while stowed/carried
base: new THREE.Vector3(),
topY: 2.9, // overwritten from the GLB's ladder_top node
view: null,
};
// Home: leaning on the shed, near the spare table but NOT on top of it. Read from world.shedTable
// so it follows the shed if Lane A moves it. The offset is deliberately ~3 m: at 1.4 m the two
// prompts overlapped and standing at the ladder offered you "take a spare", which is the kind of
// thing that reads as a broken game rather than a crowded shed.
const home = new THREE.Vector3(10.4, 0, 3.4);
if (world.shedTable && world.shedTable.pos) {
home.set(world.shedTable.pos.x + 1.4, 0, world.shedTable.pos.z - 2.6);
}
home.y = world.heightAt ? world.heightAt(home.x, home.z) : 0;
state.base.copy(home);
// --- view -----------------------------------------------------------------
new GLTFLoader().load(LADDER_URL, (g) => {
const obj = g.scene;
const top = obj.getObjectByName('ladder_top');
if (top) state.topY = top.position.y;
obj.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.frustumCulled = false; } });
state.view = obj;
scene.add(obj);
syncView();
}, undefined, () => { /* missing asset: the mechanic still works, you just can't see it */ });
const LEAN = 0.26; // rad (~15°) — a ladder stood bolt upright reads as a post, not a ladder
function syncView() {
if (!state.view) return;
state.view.visible = !state.carried;
state.view.position.copy(state.base);
const a = state.placedAt && world.anchors.find((x) => x.id === state.placedAt);
if (a) {
// planted: yaw so local +Z faces the wall, then tip the head into it. +X rotation carries the
// top toward local +Z, which is the wall — so the feet stand off and the head rests on it.
state.view.rotation.set(LEAN, Math.atan2(a.pos.x - state.base.x, a.pos.z - state.base.z), 0);
} else {
state.view.rotation.set(0, 0.6, 0.22); // stowed: slouched against the shed
}
}
/** The fascia anchor this ladder is currently serving, if any. */
const servedAnchor = () => (state.placedAt ? world.anchors.find((a) => a.id === state.placedAt) : null);
/** Standing height at the top of the ladder — feet a rung or two down from the very top. */
const workY = () => Math.max(0, state.topY - RUNG_CLEAR);
/**
* True if the player is up THIS ladder and can work the given anchor.
* Height only, deliberately hold-E moves the player into `busy`, so testing for state 'atTop'
* here would make a fascia repair un-usable the moment it started and cancel its own hold.
*/
function isWorking(anchorId) {
return state.placedAt === anchorId && player.climbY > workY() - 0.15;
}
// --- interactions ---------------------------------------------------------
const wired = [];
// 1. pick the ladder up (from its home, or from wherever it's planted)
wired.push(interact.register({
id: 'ladder_take',
pos: () => (state.carried ? null : state.base),
radius: 1.6,
holdSecs: 0.8,
clip: 'PickUp',
label: (p) => (p.carrying ? 'hands full' : state.placedAt ? 'take the ladder back' : 'take the ladder'),
canUse: (p) => !state.carried && !p.carrying && p.climbY < 0.02,
onDone: (p, t) => {
state.carried = true;
state.placedAt = null;
p.pickUp('ladder', t);
syncView();
},
}));
// 2. plant it at a fascia anchor
for (const a of anchors) {
wired.push(interact.register({
id: `ladder_place_${a.id}`,
// stand off the wall, on the yard side — the ladder leans in toward the bracket
pos: () => ({ x: a.pos.x, y: 0, z: a.pos.z + STAND_OFF }),
radius: PLACE_RANGE,
holdSecs: 1.0,
clip: 'PickUp',
label: `set the ladder under ${a.id}`,
canUse: (p) => p.carrying === 'ladder',
onDone: (p, t) => {
state.carried = false;
state.placedAt = a.id;
state.base.set(a.pos.x, world.heightAt ? world.heightAt(a.pos.x, a.pos.z + STAND_OFF) : 0,
a.pos.z + STAND_OFF);
p.carrying = null;
p.events.push({ type: 'ladderPlaced', anchorId: a.id, t });
syncView();
},
}));
}
// 3. climb it — only when it's planted, and only from the ground
wired.push(interact.register({
id: 'ladder_climb',
pos: () => (state.placedAt && !state.carried ? state.base : null),
radius: 1.5,
holdSecs: 0.4,
clip: 'PickUp',
label: 'climb',
// NB: no test on p.state here. Starting a hold moves the player into `busy`, so a canUse that
// reads state goes false the instant the hold begins and cancels itself. climbY is the honest
// gate (are you on the ground?), and locked states can't start a hold anyway.
canUse: (p) => !!state.placedAt && !state.carried && p.climbY < 0.02,
onDone: (p, t) => {
p.pos.x = state.base.x; p.pos.z = state.base.z; // step onto the rungs
const a = servedAnchor();
if (a) p.facing = Math.atan2(a.pos.x - state.base.x, a.pos.z - state.base.z);
p.climbTo(workY(), t);
},
}));
const api = {
get placedAt() { return state.placedAt; },
get carried() { return state.carried; },
get base() { return state.base; },
get topY() { return state.topY; },
workY,
isWorking,
servedAnchor,
needsLadder,
/**
* Drive descent from input. Held S climbs down; nothing else can strand you up there, and a
* knockdown already drops climbY to 0 on its own.
* player.js calls this each frame from the same input it reads for movement.
*/
update(dt, t, input) {
if (player.state === 'atTop' && input && input.z < 0) player.climbTo(0, t);
syncView();
},
dispose() {
wired.forEach((un) => un());
if (interact.ladder === api) interact.ladder = null;
if (state.view) scene.remove(state.view);
},
};
// Publish onto the Interact instance so wireYardActions can find the reach gate without main.js
// (Lane A's file) having to learn about ladders and thread it through. Interact is Lane D's own
// class, so this stays inside the lane; an explicit `deps.ladder` still wins if anyone passes one.
interact.ladder = api;
return api;
}

View File

@ -23,10 +23,56 @@ import { createPlayer } from './player.js';
import { Interact, wireYardActions } from './interact.js';
import { createDebris } from './debris.js';
import { createSkyFx } from './skyfx.js';
import { createRiggingUI } from './rigging.js';
import { createHud } from './hud.js';
/** Which storm each phase runs under (SPRINT2 §Lane A.1). */
/** The calm day the forecast and prep phases run under. */
const CALM_STORM = 'storm_01_gentle';
const WILD_STORM = 'storm_02_wildnight';
/** The storms you can pick from the forecast card — this is the difficulty select. */
const STORMS = ['storm_01_gentle', 'storm_03_southerly', 'storm_02_wildnight'];
/**
* How fast an unprotected garden dies, in HP per second at full rain.
*
* Decision 7: drain is rain × (1 rain shadow), NOT sun coverage. At night the
* sun shadow is a number about nothing, and storm_02 is a wildnight.
*
* This number is doing less work than it looks like it is, and the reason is
* worth reading before retuning it. Measured over storm_02 with a rig that holds
* 4/4 corners all night: sun coverage over the bed stays ~50%, but the RAIN
* shadow decays 0.38 0.04 as the wind builds, averaging 0.23. Driving rain
* blows under the sail and the shadow walks off the bed which is Lane C's
* model being right about weather, not a bug.
*
* The consequence is that a perfectly rigged bed only ever sits ~23% drier than
* a bare one, so NO value here separates good rigging from none; at 1.6 both
* ended dead, and the spread stays ~20 points at any setting. 0.9 is chosen to
* put a good rig around 60% (a win) and a bare bed just under 50% (a loss), so
* the loop is playable and scores something but the lever that actually needs
* moving is the shadow geometry, not this. Flagged for Lane C in THREADS.
*/
const GARDEN_DRAIN = 0.9;
/**
* The garden: the thing you are actually protecting, and the only score that
* matters. Deliberately not inside hud.js the HUD reads, it doesn't decide.
*/
function createGarden(world) {
let hp = 100;
let state = 'full';
return {
get hp() { return hp; },
get state() { return state; },
reset() { hp = 100; state = 'full'; world.setPlants('full'); },
/** @param {number} rain 0..1 @param {number} dry 0..1 fraction kept dry by the sail */
step(dt, rain, dry) {
if (rain > 0) hp = Math.max(0, hp - GARDEN_DRAIN * rain * (1 - dry) * dt);
const next = hp > 66 ? 'full' : hp > 33 ? 'tattered' : 'dead';
if (next !== state) { state = next; world.setPlants(next); }
},
};
}
// ---------------------------------------------------------------------------
// Phase machine
@ -103,6 +149,8 @@ function createWindRouter(all) {
gustTelegraph: (t) => active.gustTelegraph(t),
eventsBetween: (a, b) => active.eventsBetween(a, b),
rainAt: (t) => active.rainAt(t),
rainMmPerHour: (t) => active.rainMmPerHour(t),
rainDepthMm: (a, b) => active.rainDepthMm(a, b),
dirAt: (t) => active.dirAt(t),
setShelters(list) {
@ -180,12 +228,15 @@ export async function boot(opts = {}) {
const scene = new THREE.Scene();
// --- 1. weather ---------------------------------------------------------
// Both storms load up front: the forecast card needs to read storm_02's shape
// before the player has agreed to face it.
const [calmDef, wildDef] = await Promise.all([loadStorm(CALM_STORM), loadStorm(WILD_STORM)]);
const calmWind = createWind(calmDef);
const wildWind = createWind(wildDef);
const wind = createWindRouter([calmWind, wildWind]);
// Every storm loads up front: the forecast card has to read their shapes to
// sell them before the player has agreed to face one.
const defs = Object.fromEntries(
await Promise.all(STORMS.map(async (k) => [k, await loadStorm(k)])),
);
const winds = Object.fromEntries(Object.entries(defs).map(([k, def]) => [k, createWind(def)]));
const calmWind = winds[CALM_STORM];
let stormKey = 'storm_02_wildnight';
const wind = createWindRouter(Object.values(winds));
// --- world & camera -----------------------------------------------------
const world = createWorld(scene, { wind });
@ -239,9 +290,8 @@ export async function boot(opts = {}) {
// repair. Deliberately the prototype's AUTO loadout — one dodgy carabiner
// corner. It also spans most of the yard, which is the 70192 m² problem
// decision 2 fixes in step 6, not a fault in the cloth.
await rigSail(['h1', 'h3', 'p2', 'p1'], [HARDWARE[2], HARDWARE[1], HARDWARE[1], HARDWARE[0]]);
const game = createGame();
const garden = createGarden(world);
// --- clocks -------------------------------------------------------------
// Two of them, and the distinction matters. `simT` is wall-clock seconds since
@ -311,25 +361,134 @@ export async function boot(opts = {}) {
addEventListener('pointerdown', unlock);
addEventListener('keydown', unlock);
// --- dev overlay (temporary — hud.js replaces it in step 7) -------------
const hud = document.getElementById('dev');
// --- 5. the face --------------------------------------------------------
const banner = document.getElementById('banner');
addEventListener('keydown', (e) => {
if (e.key === 'Enter') game.advance();
const hud = createHud({
scene, camera: cameraRig.object, game, world, wind, player, rig, garden, events,
getSky: () => sky,
});
// 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({
scene,
camera: cameraRig.object,
domElement: canvas,
world,
onCommit: (ids, hw, tension) => { void rigSail(ids, hw, tension); },
onMessage: pushEvent,
panel: true,
});
/**
* Clear last round's rig so "play again" is a new job, not a continuation.
*
* Without this you inherit the previous round's corners AND its spent budget
* a second round starts at $35 with three corners already hung, and the four
* anchors you click get silently ignored because the session is already full.
* That is not a subtle failure; it makes the second round unplayable.
*
* Done through the session's own public moves rather than by reaching into its
* fields: unrig() refunds the corner's CURRENT hardware and setSpares(0)
* refunds the spare, so the budget walks back to $80 on its own and the
* economy stays the single source of truth. (Lane B: a `session.reset()` would
* say this better than five lines of mine asked in THREADS.)
*/
function resetRig() {
const s = rigging.session;
for (const p of [...s.picks]) s.unrig(p.anchorId);
s.setSpares(0);
s.setTension(1.0);
}
/** What the storm cost, assembled at the moment it ends. */
function scoreRun() {
const lost = rig.corners.filter((c) => c.broken);
const bill = lost.reduce((s, c) => s + c.hw.cost, 0);
const collateral = [];
// The gnome is collateral if the sail came down over it. DESIGN.md: the
// worst debris in any storm is your own failed work.
if (lost.length >= 2) collateral.push({ what: 'garden gnome', cost: world.gnome.collateralValue });
const s = rigging.summary;
const hp = garden.hp;
const win = hp >= 50 && lost.length < 2;
return {
hp,
cornersLost: lost.length,
cornersTotal: rig.corners.length || 4,
bill,
collateral,
budgetLeft: Math.max(0, s.budget - bill - collateral.reduce((a, c) => a + c.cost, 0)),
win,
subtitle: lost.length
? `${lost.map((c) => `${c.hw.name} at ${c.anchorId.toUpperCase()}`).join(', ')} let go.`
: 'Every corner held.',
verdict: hp >= 85 && !lost.length ? 'THE GARDEN MADE IT — not a leaf out of place.'
: win ? 'THE GARDEN MADE IT. Mostly.'
: hp < 50 ? 'THE GARDEN IS GONE. The rain found what you skimped on.'
: 'THE SAIL LOST. Warranty callout in the rain for you.',
};
}
// --- phases -------------------------------------------------------------
game.on('phaseChange', ({ to }) => {
wind.use(to === 'storm' ? wildWind : calmWind);
// Prep and forecast happen on the calm day; the storm you picked only
// arrives when you say go.
wind.use(to === 'storm' ? winds[stormKey] : calmWind);
makeSky();
events.length = 0;
if (banner) {
rigging.setActive(to === 'prep');
if (to === 'forecast') {
garden.reset();
resetRig();
player.sim.carrying = null;
hud.showForecast(
STORMS.map((key) => ({ key, def: defs[key] })),
(key) => { stormKey = key; game.setPhase('prep'); },
);
}
// 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
// (a debug jump, a future timer, "play again" landing somewhere new) left a
// card floating over a live game, swallowing input.
if (to === 'prep' || to === 'storm') hud.hideCard();
if (to === 'prep') {
hud.setHelp('click an anchor to rig · click again to cycle hardware · shift-click to remove · [ ] tension · S spare · ENTER when you have four');
}
if (to === 'storm') {
hud.setHelp('WASD move · shift run · E repair/pickup · C brace · RMB orbit');
}
if (to === 'aftermath') {
hud.showAftermath(scoreRun(), () => game.setPhase('forecast'));
}
if (banner && to !== 'forecast' && to !== 'aftermath') {
banner.textContent = to.toUpperCase();
banner.style.opacity = '1';
setTimeout(() => { banner.style.opacity = '0'; }, 1400);
}
});
addEventListener('keydown', (e) => {
if (e.key !== 'Enter' || hud.cardOpen) return;
// Leaving prep means committing the rig — Lane B's session refuses if it
// isn't four corners, and says so in the ticker.
if (game.phase === 'prep') {
if (!rigging.commit()) return;
player.sim.carrying = null;
if (rigging.summary.spares > 0) pushEvent(`spare shackle on the shed table — grab it before you need it`);
}
game.advance();
});
// Straight into the forecast: the card is the game's front door.
hud.showForecast(
STORMS.map((key) => ({ key, def: defs[key] })),
(key) => { stormKey = key; game.setPhase('prep'); },
);
// --- resize -------------------------------------------------------------
function resize() {
const w = canvas.clientWidth || innerWidth;
@ -342,6 +501,7 @@ export async function boot(opts = {}) {
// --- loop ---------------------------------------------------------------
const clock = new THREE.Clock();
const dev = document.getElementById('dev');
let frames = 0, fpsT = 0, fps = 0;
function step(dt) {
@ -353,6 +513,15 @@ export async function boot(opts = {}) {
rig.step(dt, wind, windT, debris);
debris.step(dt, windT, { player: player.sim, sail: rig });
sky?.step(dt, windT, { sail: rig });
rigging.update(dt, windT);
// Decision 7: what kills the garden is rain that the sail didn't stop.
// Only during the storm — the calm day's drizzle is scenery.
if (game.phase === 'storm') {
const rain = wind.rainAt(windT);
const dry = sky?.rainShadowOver ? sky.rainShadowOver(world.gardenBed) : 0;
garden.step(dt, rain, dry);
}
}
function frame() {
@ -370,19 +539,11 @@ export async function boot(opts = {}) {
sailView?.update();
renderer.render(scene, cameraRig.object);
hud.update(raw, windT);
frames++; fpsT += raw;
if (fpsT >= 0.5) { fps = frames / fpsT; frames = 0; fpsT = 0; }
if (hud) {
const wt = windT;
const speed = wind.speedAt(player.pos, wt);
const tel = wind.gustTelegraph(wt);
const worst = rig.corners.reduce((m, c) => Math.max(m, c.load || 0), 0);
hud.textContent =
`${fps.toFixed(0)} fps | ${game.phase} ${game.phaseT.toFixed(1)}s | ` +
`wind ${speed.toFixed(1)} m/s${tel ? ` | GUST in ${tel.eta.toFixed(1)}s` : ''} | ` +
`worst corner ${worst.toFixed(1)} | debris ${debris.pieces.length}` +
`${events.length ? ` | ${events[events.length - 1].text}` : ''}`;
}
if (dev) dev.textContent = `${fps.toFixed(0)} fps · ${game.phase} ${game.phaseT.toFixed(1)}s · t ${simT.toFixed(0)}s · debris ${debris.pieces.length}`;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
@ -392,11 +553,14 @@ export async function boot(opts = {}) {
const api = {
renderer, scene, world, cameraRig, player, game, wind, rig, rigSail,
get sailView() { return sailView; },
debris, interact, events,
debris, interact, events, hud, rigging, garden, defs, winds,
get stormKey() { return stormKey; },
set stormKey(k) { stormKey = k; },
scoreRun,
get sky() { return sky; },
get simT() { return simT; },
windTime,
calmWind, wildWind,
calmWind,
/**
* Drive the sim by hand at fixed dt, and draw on demand. rAF is throttled to

View File

@ -15,9 +15,10 @@
import * as THREE from '../vendor/three.module.js';
import { clone as skeletonClone } from '../vendor/addons/utils/SkeletonUtils.js';
import { GLTFLoader } from '../vendor/addons/loaders/GLTFLoader.js';
import { PlayerSim, STATES, TUNE, clipFor } from './player.sim.js';
import { PlayerSim, STATES, TUNE, clipFor, onLadder } from './player.sim.js';
import { createLadder } from './ladder.js';
export { PlayerSim, STATES, TUNE, clipFor };
export { PlayerSim, STATES, TUNE, clipFor, onLadder, createLadder };
export const CHAR_URL = './models/player_01.glb';
export const ANIM_URL = './models/player_anims.glb';
@ -193,7 +194,10 @@ export class PlayerView {
// clipFor, not st.clip: carrying swaps in Carry/CarryIdle, and an interaction names its own verb
this.play(clipFor(sim), st.loop !== false);
this.root.position.set(sim.pos.x, sim.pos.y, sim.pos.z);
// climbY lifts the whole rig up the rungs. Same trick as the knockdown pitch: _rotOnly strips
// the root from every clip, so ClimbLadder can't raise the body — the sim does, and the clip
// just supplies the arms and legs.
this.root.position.set(sim.pos.x, sim.pos.y + (sim.climbY || 0), sim.pos.z);
// yaw, then tip over about a world-horizontal axis square to the fall direction, pivoting at the
// feet. At pitch 0 this is exactly the yaw, so upright play is untouched.
@ -278,6 +282,12 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) {
const keyboard = new KeyboardInput();
const { sim, view } = p;
// The ladder self-wires from here rather than from main.js: createPlayer is already handed the
// scene, the world and interact, which is everything it needs — so Lane A's file doesn't have to
// change to get a whole sub-system. Opt out with {ladder: false} if a harness doesn't want it.
const ladder = (opts.ladder === false || !opts.interact)
? null : createLadder(scene, world, opts.interact, sim);
return {
get pos() { return sim.pos; },
get carrying() { return sim.carrying; },
@ -288,6 +298,7 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) {
update(dt, t) {
const input = keyboard.read(cameraRig ? cameraRig.yaw || 0 : 0);
sim.step(dt, t, input, opts.wind);
if (ladder) ladder.update(dt, t, input);
if (opts.interact) opts.interact.step(dt, t, sim, keyboard.holding);
view.sync(sim, dt);
},
@ -296,8 +307,9 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) {
get object() { return view.root; },
sim,
view,
ladder,
keyboard,
dispose() { keyboard.dispose(); view.dispose(); },
dispose() { keyboard.dispose(); view.dispose(); if (ladder) ladder.dispose(); },
};
}

View File

@ -29,8 +29,26 @@ export const STATES = {
stagger: { clip: 'Reaction', locked: true, loop: false, secs: 0.9, next: 'idle' },
knocked: { clip: 'Falling', locked: true, loop: false, secs: 1.4, next: 'getup' },
getup: { clip: 'CrouchToStand', locked: true, loop: false, secs: 1.3, next: 'idle' },
// --- the ladder (decision 12). climbY is driven in code and the clip plays on top, exactly the
// knockdown precedent: _rotOnly strips the root, so ClimbLadder can no more lift the body than
// Falling could lay it down. ladder.js calls climbTo(); the sim owns the height. ---
climb: { clip: 'ClimbLadder', locked: true, loop: true, releasedBy: 'ladder' },
// atTop is deliberately NOT locked: `busy` gates interact.js, and the whole point of being up
// there is that hold-E works. Movement is stopped by `onLadder` instead, not by `locked`.
atTop: { clip: 'Idle', carryClip: 'CarryIdle', locked: false, loop: true, releasedBy: 'ladder' },
};
/**
* True while the player is on a ladder movement is off, and the wind is meaner.
*
* Keyed on HEIGHT, not on state, and that distinction is load-bearing: hold-E puts you into `busy`,
* so a state-based test would say you'd stepped off the ladder the instant you started the repair
* you climbed up to do. (It did exactly that the gate cancelled its own hold.) Height is the
* physical truth and survives every state the ladder passes through.
*/
export const onLadder = (sim) => sim.climbY > 0.02 || sim.state === 'climb';
/**
* Which clip a state actually plays right now. Carrying swaps the locomotion set (Carry/CarryIdle),
* and an interaction can name its own verb (`Crank` at a turnbuckle, `PickUp` at the shed table)
@ -96,6 +114,13 @@ export const TUNE = {
// gusts are too strong to cross the yard" — wait one out, then move in the lull.
shelterKnockMult: 2.0, // knockWind × this while braced — a gust that floors you standing won't
shelterShoveMult: 0.25, // and it barely pushes you
// Ladder (decision 12). DESIGN.md: "ladder work at height in wind is genuinely tense" — this is
// where that gets teeth. You cannot brace up there (both hands are on the rungs), so the only
// defence is choosing your moment: climb in a lull, not through a gust.
climbRate: 1.1, // m/s up or down a rung — slow enough that the storm gets a vote
reach: 2.2, // m — how high a 1.72 m person's hands get, standing. Fascia sits at 2.48.
ladderKnockMult: 0.6, // knockWind × this while on the ladder — far easier to be blown off
};
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
@ -140,6 +165,10 @@ export class PlayerSim {
this.pitch = 0; // 0 upright … 1 flat on the ground
this.knockDir = { x: 0, z: 1 }; // which way the body went down
this.climbY = 0; // m above the ground; >0 means you're up a ladder
this.climbTarget = 0; // where ladder.js asked you to be
this.fellFrom = 0; // m — height of the last fall, for the HUD/aftermath to shame you with
this.groundAt = opts.groundAt || (() => 0);
this.collide = opts.collide || null;
this.bodyHeight = opts.height || 1.72;
@ -151,6 +180,23 @@ export class PlayerSim {
get clip() { return STATES[this.state].clip; }
get speed() { return Math.hypot(this.vel.x, this.vel.z); }
/** How high this person's hands get right now. The gate on reaching a fascia bracket. */
get reachY() { return this.pos.y + this.climbY + this.tune.reach; }
/**
* Go up or down a ladder. ladder.js owns WHERE (it knows the rungs); the sim owns the motion, so
* a climb is deterministic and fast-forwards in selftest like everything else.
* @param {number} y target height above ground; 0 climbs back down
*/
climbTo(y, t = 0) {
this.climbTarget = Math.max(0, y);
if (Math.abs(this.climbTarget - this.climbY) > 0.02) {
this.vel.x = this.vel.z = 0;
this.setState('climb', t);
}
return true;
}
setState(s, t = 0) {
if (this.state === s) return false;
if (!STATES[s]) throw new Error(`player: unknown state ${s}`);
@ -194,11 +240,16 @@ export class PlayerSim {
if (x === undefined || (x === 0 && z === 0)) { x = Math.sin(this.facing); z = Math.cos(this.facing); }
const m = Math.hypot(x, z) || 1;
this.knockDir = { x: x / m, z: z / m };
// Blown off a ladder: you don't stagger, you fall. Everything else about a knockdown is the
// same, so the ladder gets the get-up chain for free — you just arrive on the ground first.
this.fellFrom = this.climbY;
this.climbY = 0;
this.climbTarget = 0;
this.setState('knocked', t);
this.exposure = 0;
this.vel.x = this.vel.z = 0;
this.drop(t);
this.events.push({ type: 'knockdown', t, dir: { ...this.knockDir } });
this.events.push({ type: 'knockdown', t, dir: { ...this.knockDir }, fellFrom: this.fellFrom });
return true;
}
@ -225,7 +276,9 @@ export class PlayerSim {
this.gust = Math.max(0, ws - this.windBase);
// --- shelter: hold to brace. Enters and leaves itself, so releasing the key always frees you
// even mid-gust. Refused while you're down — you can't brace from your back. ---
// even mid-gust. Refused while you're down — you can't brace from your back, and refused on
// a ladder: both hands are on the rungs. Up there your only defence is having picked a lull. ---
const up = onLadder(this);
const wantShelter = !!input.shelter;
const canShelter = this.state === 'idle' || this.state === 'walk' || this.state === 'run';
if (wantShelter && canShelter) this.setState('shelter', t);
@ -233,12 +286,34 @@ export class PlayerSim {
const braced = this.state === 'shelter';
// --- sustained extreme wind puts you down (same rule as a sail corner letting go).
// Bracing raises the bar rather than removing it: a big enough gust still wins. ---
const knockAt = braced ? T.knockWind * T.shelterKnockMult : T.knockWind;
// Bracing raises the bar; a ladder LOWERS it. Same exposure clock either way, so the storm
// speaks one language whether you're on your feet or up a rung. ---
const knockAt = T.knockWind
* (braced ? T.shelterKnockMult : 1)
* (up ? T.ladderKnockMult : 1);
if (ws > knockAt) this.exposure += dt;
else this.exposure = Math.max(0, this.exposure - dt * T.knockBleed);
if (this.exposure >= T.knockSustain) this.knockdown(t, wx, wz);
// --- the climb itself: code-driven height, ClimbLadder plays on top (the knockdown precedent) ---
if (this.state === 'climb') {
const dy = this.climbTarget - this.climbY;
const rung = T.climbRate * dt;
if (Math.abs(dy) <= rung) {
this.climbY = this.climbTarget;
this.setState(this.climbY > 0.02 ? 'atTop' : 'idle', t);
} else {
this.climbY += Math.sign(dy) * rung;
}
}
// Invariant: you cannot be standing on the grass while you are 2 m up a ladder. interact.js
// releases a finished hold to 'idle' without knowing where you are; this puts you back in the
// work stance instead of leaving you idling in mid-air.
if (this.climbY > 0.02
&& (this.state === 'idle' || this.state === 'walk' || this.state === 'run')) {
this.setState('atTop', t);
}
// --- a gust below the knockdown bar can still break your stride ---
if (!braced && this.gust > T.stumbleGust && this.stumbleCool <= 0
&& (this.state === 'idle' || this.state === 'walk' || this.state === 'run')) {
@ -248,11 +323,12 @@ export class PlayerSim {
}
const st = STATES[this.state];
const aloft = onLadder(this); // re-read: the climb block above may have just landed you
// --- movement ---
const slow = 1 - Math.min(T.slowMax, ws / T.slowRef); // prototype: rain + wind slow you
let wantX = 0, wantZ = 0;
if (!st.locked) {
if (!st.locked && !aloft) {
const ix = input.x || 0, iz = input.z || 0;
const mag = Math.hypot(ix, iz);
if (mag > 1e-3) {
@ -305,8 +381,10 @@ export class PlayerSim {
const pstep = dt / T.pitchSecs;
this.pitch = clamp(this.pitch + clamp(wantPitch - this.pitch, -pstep, pstep), 0, 1);
// --- locomotion state from actual speed (so shove/slow can't desync the feet) ---
if (!st.locked) {
// --- locomotion state from actual speed (so shove/slow can't desync the feet).
// `aloft` is what holds you in atTop: it's unlocked (so hold-E works up there), and without
// this guard the speed check would immediately re-state you to idle and drop you off. ---
if (!st.locked && !aloft) {
const sp = this.speed;
this.setState(sp < 0.15 ? 'idle' : sp > T.walkSpeed * 1.35 ? 'run' : 'walk', t);
} else if (st.secs && this.stateT >= st.secs && st.next) {

View File

@ -48,9 +48,9 @@ function realWind(def = STORM_02, opts = {}) {
/** Lane A's yard, verbatim (THREADS: "yard layout is now FACT"). */
const YARD = [
['h1', 'house', -5, 2.6, -9.9], ['h2', 'house', 0, 2.6, -9.9], ['h3', 'house', 5, 2.6, -9.9],
['t1', 'tree', -9, 3.2, 2], ['t2', 'tree', 8, 3.1, -2],
['p1', 'post', -6.4, 3.9, 7.4], ['p2', 'post', 5.3, 3.9, 8],
['h1', 'house', -5, 2.60, -9.9], ['h2', 'house', 0, 2.60, -9.9], ['h3', 'house', 5, 2.60, -9.9],
['t1', 'tree', -9, 3.22, 2], ['t2', 'tree', 8, 3.08, -2],
['p1', 'post', -4.9, 3.95, 5.9], ['p2', 'post', 4.3, 3.96, 6.5], ['p3', 'post', 0, 3.95, 7.6],
].map(([id, type, x, y, z]) => {
const pos = { x, y, z };
// Static on purpose: tree sway is world.js's, and mixing it in here would make
@ -59,6 +59,14 @@ const YARD = [
return { id, type, pos, sway: () => pos };
});
/**
* SPRINT4 decision 11: §7's twisted rig re-pointed off the old 145 quad onto
* a real one from A's decision-2 yard 23 , inside the 18-45 band, and the
* most twisted quad the band offers. The old one was 6x too big, which is what
* made it break under downdraft and made me call the bar unachievable.
*/
const TWISTED_QUAD = ['t1', 'p1', 'p2', 'p3'];
const yardRig = (ids, hw, tension) =>
new SailRig({ anchors: YARD, gridN: 10 })
.attach(ids, Array.isArray(hw) ? hw : Array(4).fill(hw), tension);
@ -549,7 +557,7 @@ test('§7 gate on REAL storm_02: cheap flat rig cascades', () => {
test('§7 gate on REAL storm_02: twisted mixed rig survives', () => {
// Lane C's shape: h1 (house, 2.6) / t2 (tree, 3.1) / p1 (post, 3.9) / t1 (tree, 3.2)
// — corners at four different heights, i.e. an actual hypar, eased off tight.
const rig = yardRig(['h1', 't2', 'p1', 't1'], [HARDWARE[2], HARDWARE[1], HARDWARE[2], HARDWARE[1]], 0.85);
const rig = yardRig(TWISTED_QUAD, [HARDWARE[2], HARDWARE[1], HARDWARE[2], HARDWARE[1]], 0.85);
const w = realWind();
let peak = 0;
for (let i = 0; i < Math.round(STORM_02.duration / SIM_DT); i++) {
@ -567,15 +575,17 @@ test('§7 gate on REAL storm_02: twisted rig + one repair on the dodgy corner',
// the interesting scenario is DESIGN.md's: the budget forces one dodgy corner
// ($80 buys rated on at most two of four), that corner blows, and you run out
// and re-rig it once with the carried spare — exactly Lane D's hold-E.
// An $80-exact loadout: rated h1 ($30) + shackle t1 ($15) + shackle p1 ($15)
// + carabiner t2 ($5) + spare ($15). The carabiner goes on t2 because that is
// where the load actually IS — measured peaks on this shape are h1 1.68 /
// t2 2.73 / p1 2.17 / t1 0.81 kN. Putting the cheap corner on t1 (the
// lightest) is what a player does by accident and it survives the storm
// having proved nothing; putting it on t2 is the real bet.
// An $80-exact loadout on the decision-11 quad: rated t1 ($30) + shackle p1
// ($15) + carabiner p2 ($5) + shackle p3 ($15) + spare ($15).
//
// The carabiner goes on p2 because that is where the load actually IS —
// measured peaks on this quad are t1 2.43 / p2 2.35 / p3 0.82 / p1 0.60 kN.
// Hanging the cheap corner on p1 (the lightest) is what a player does by
// accident: it rides the whole storm out and proves nothing. p2 is the real
// bet, and it's the one that has to blow for this test to mean anything.
const rig = yardRig(
['h1', 't2', 'p1', 't1'],
[HARDWARE[2], HARDWARE[0], HARDWARE[1], HARDWARE[1]],
TWISTED_QUAD, // ['t1','p1','p2','p3']
[HARDWARE[2], HARDWARE[1], HARDWARE[0], HARDWARE[1]],
0.85,
);
const w = realWind();
@ -594,11 +604,10 @@ test('§7 gate on REAL storm_02: twisted rig + one repair on the dodgy corner',
// green forever while proving nothing. Storm_02 can't threaten a shackle
// rig until Lane C's downdraft lands (their A/B: shackle blows at t=20.8 s
// with downdraft 0.3, never without). Lights up by itself on merge.
assert(
!STORM_02.gusts?.downdraft,
'storm_02 HAS a downdraft and still could not blow a shackle rig — the repair scenario is vacuous',
);
return 'SKIPPED — nothing blew; needs Lane C decision 3 downdraft to threaten a shackle rig';
// Decision 11 landed the downdraft for real, so there is no longer an excuse
// for nothing breaking: a vacuous pass here would mean the §7 repair leg —
// the sprint's whole definition of done — is checking nothing.
assert(false, 'nothing blew, so the repair scenario proved nothing — the dodgy corner is not on a loaded corner');
}
assert(lost <= 1, `after one repair the rig still lost ${lost}/4 — not survivable`);
return `${repairs} repair, finished ${4 - lost}/4 corners intact`;
@ -606,51 +615,64 @@ test('§7 gate on REAL storm_02: twisted rig + one repair on the dodgy corner',
// --- SPRINT2 decision 3 / B-6: the flat-horizontal loophole ------------------
// My Sprint 1 finding: a flat HORIZONTAL sail was the lowest-load rig of all
// (1.14 kN vs a pitched flat's 3.06), because a horizontal plate in horizontal
// wind has almost no drag — which inverted DESIGN.md's "big, flat, low = death
// in a storm". Lane C closed it by making gusts descend. This is the assert
// decision 3 asks Lane B for.
// Sprint 1 finding: a flat HORIZONTAL sail was the lowest-load rig of all,
// because a horizontal plate in horizontal wind has almost no drag — which
// inverted DESIGN.md's "big, flat, low = death in a storm". Lane C closed it by
// making the wind descend (decision 8: a fraction of TOTAL speed, present
// whenever it's windy).
//
// THE REFERENCE RIG IS THE WHOLE TEST, and getting it wrong is what made me
// declare this bar unachievable for two sprints (SPRINT3 [B], and I was wrong).
// The ratio is `(f / (sin p + cos p·f))²` for reference pitch p, so it depends
// on p far more than on the downdraft:
//
// pitch f=0.12 f=0.45 the bar is 60%
// 16.7° 8.9% 39.2% <- my old synthetic rig: unreachable,
// asymptote 109%, would need f=0.86
// 4.8° 34.9% 71.5% <- the yard: clears comfortably
//
// A steeply-pitched reference catches the downdraft nearly as well as a
// horizontal one does (its normal is still 96% vertical), so it can never be
// out-loaded. The yard cannot BUILD a 16.7° sail: house fascia is 2.60 m and
// the posts are 3.95 m, ~16 m apart — 4.8°. Measuring against a rig the game
// can't rig proved something true about nothing.
const YARD_PITCH_DEG = 4.8; // house 2.60 -> post 3.95 over ~16 m, from world.js
test('decision 3: flat-horizontal is no longer a free lunch', () => {
const downdraft = STORM_02.gusts?.downdraft ?? 0;
if (!downdraft) {
// Feature-detected rather than hard-failed: this assert is only meaningful
// once Lane C's downdraft is on main. It lights up by itself on merge.
return 'SKIPPED — storm_02 has no gusts.downdraft yet (Lane C decision 3 not merged)';
}
if (downdraft < 0.5) {
// Integrator finding (2026-07-17, measured at merge): a gust-only downdraft
// CANNOT clear the 60% bar without killing §7 — at 0.45 the twisted mixed
// rig loses a corner and the ratio is still 42%; at 0.58 it's 48% and the
// rig still dies. The two asserts pincer. Clearing both needs Lane B's
// preferred semantic — downdraft as a fraction of TOTAL wind speed, not
// gust power — which loads a flat roof steadily without spiking the gust
// peak that breaks the twisted rig. That is a weather.core change (joint
// B+C, SPRINT3). Until it lands, storm data stays at C's tuned 0.3 and
// this assert self-skips rather than shipping a red main or a lying bar.
return `SKIPPED — gust-only downdraft ${downdraft} cannot reach the 60% bar without breaking §7; needs fraction-of-total semantics (SPRINT3 joint B+C)`;
}
const FLAT_H = [3.25, 3.25, 3.25, 3.25];
// Spin the rig through 8 headings under the real storm. (Re-seeding the wind
// instead would only reshuffle gust TIMING — the direction curve is authored
// in the JSON and doesn't move — so it would look like a sweep and measure
// nothing about direction.)
const sweep = (heights) => {
const f = STORM_02.gusts?.downdraftOfTotal ?? STORM_02.gusts?.downdraft ?? 0;
assert(f > 0, 'storm_02 has no downdraft at all — decision 3/8 has regressed out of the data');
// Footprint sized and pitched like a real quad from the dressed yard, spun
// through 8 headings under the real storm. (Re-seeding the wind instead only
// reshuffles gust TIMING — the direction curve is authored — so it would look
// like a sweep and measure nothing about direction.)
const S = Math.sqrt(30); // ~30 m², mid of A's 18-45 band
const rise = Math.tan((YARD_PITCH_DEG * Math.PI) / 180) * S;
const PITCHED = [3.2 + rise / 2, 3.2 + rise / 2, 3.2 - rise / 2, 3.2 - rise / 2];
const HORIZ = [3.2, 3.2, 3.2, 3.2];
const foot = [[-S / 2, -S / 2], [S / 2, -S / 2], [S / 2, S / 2], [-S / 2, S / 2]];
const at = (hs, th) => foot.map(([x, z], i) => {
const c = Math.cos(th), s = Math.sin(th);
const pos = { x: x * c - z * s, y: hs[i], z: x * s + z * c };
return { id: `a${i}`, type: 'post', pos, sway: () => pos };
});
const sweep = (hs) => {
let worst = 0;
for (let k = 0; k < 8; k++) {
const r = new SailRig({ anchors: makeAnchors(heights, (k / 8) * Math.PI * 2), gridN: 10 })
const r = new SailRig({ anchors: at(hs, (k / 8) * Math.PI * 2), gridN: 10 })
.attach(ALL_IDS, Array(4).fill(UNBREAKABLE), 1.0);
// full duration: storm_02's own note says the peak lands just AFTER the
// southerly change, so a 45 s sweep measures the wrong half of the storm
// southerly change, so a short sweep measures the wrong half of the storm
worst = Math.max(worst, runStorm(r, realWind(), STORM_02.duration));
}
return worst;
};
const pitched = sweep(HEIGHTS_FLAT);
const horizontal = sweep(FLAT_H);
const pitched = sweep(PITCHED);
const horizontal = sweep(HORIZ);
const ratio = horizontal / pitched;
assert(ratio >= 0.6, `flat-horizontal peaks at only ${(ratio * 100).toFixed(0)}% of flat-pitched (${kN(horizontal)} vs ${kN(pitched)}) — still a free lunch`);
return `flat-horizontal ${kN(horizontal)} vs flat-pitched ${kN(pitched)} = ${(ratio * 100).toFixed(0)}% (downdraft ${downdraft})`;
return `flat-horizontal ${kN(horizontal)} vs flat-pitched ${kN(pitched)} = ${(ratio * 100).toFixed(0)}% at ${YARD_PITCH_DEG}° yard pitch, downdraftOfTotal ${f}`;
});
test('runs against the shared contracts.js stub wind', () => {

View File

@ -21,6 +21,8 @@ const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
const CALM_SKY = new THREE.Color(0x9fc4e8);
const STORM_SKY = new THREE.Color(0x2a2f3a);
const NIGHT_SKY = new THREE.Color(0x11141c);
const WHITE = new THREE.Color(0xffffff);
const FLASH_COL = new THREE.Color(0xdfe8ff);
// ------------------------------------------------------------ rain shadow
/**
@ -440,7 +442,13 @@ export function createSkyFx(o = {}) {
const skyDef = def.sky || {};
const darkness = skyDef.darkness ?? 0.7;
const scroll = skyDef.cloudScroll ?? 0.06;
const target = (o.night ?? darkness > 0.6) ? NIGHT_SKY : STORM_SKY;
// The storm data gets a say: `sky.night` is the author's call, the darkness
// threshold is only a fallback for storms that never state one.
const target = (o.night ?? skyDef.night ?? darkness > 0.6) ? NIGHT_SKY : STORM_SKY;
// Fire a flash on any gust this strong (m/s of gust power). Storms that don't
// ask stay lit only by their authored strikes.
const lightningGustPow = skyDef.lightningGustPow ?? Infinity;
const firedGusts = new Set();
const rain = createRain({ groundY: o.groundY ?? 0 });
if (scene) scene.add(rain.mesh);
@ -510,6 +518,28 @@ export function createSkyFx(o = {}) {
*/
rainShadowOver(rect) { return shadow.fractionOver(rect); },
/**
* Decision 7's garden-HP drain term, 0..1, in one call the combined helper
* I offered Lane A. This is the whole of "is the bed getting hit right now":
*
* hp -= sky.gardenExposure(world.gardenBed, t) * DRAIN_PER_SEC * dt;
*
* 0 = bone dry (no rain, or the cloth is over it); 1 = full downpour on open
* ground. Both terms matter and neither is enough alone: a sail over the bed
* in a downpour is 0, and a clear sky with no sail is also 0. It uses the
* grid we already rebuild for the rain, so it costs a few array reads.
*
* Note it moves on its own during storm_02: the rain shadow follows the wind,
* so the southerly change walks the dry patch off the bed and the drain
* starts climbing without a single corner having failed. That's the mechanic,
* not a bug worth not "fixing" if it looks surprising in the HUD.
*/
gardenExposure(rect, t) {
const rain = wind.rainAt(t);
if (rain <= 0) return 0;
return rain * (1 - shadow.fractionOver(rect));
},
/** Wire to the first click/keydown — browsers won't start audio otherwise. */
unlockAudio() { audio.unlock(); },
@ -539,6 +569,24 @@ export function createSkyFx(o = {}) {
o.onEvent(ev.text);
}
}
// --- lightning on the biggest gusts ---
// The storm's worst moments should light up, not only the three authored
// strikes. Driven off the telegraph (which is also what the HUD banner and
// the audio whoosh read), so the flash lands WITH the gust that earned it.
// Deterministic: the gust timeline is, and each gust fires at most once.
const tgl = wind.gustTelegraph(t);
if (tgl && tgl.power >= lightningGustPow) {
// key on the ramp instant — stable across frames, unique per gust
const key = Math.round((t + tgl.eta) * 100);
if (!firedGusts.has(key)) {
firedGusts.add(key);
const p = Math.min(1, 0.45 + (tgl.power - lightningGustPow) * 0.06);
flashQueue.push({ at: t + tgl.eta, power: p });
flashQueue.push({ at: t + tgl.eta + 0.08 + p * 0.06, power: p * 0.5 });
flashQueue.push({ at: t + tgl.eta + 1.1, power: 0, thunder: p });
}
}
for (let i = flashQueue.length - 1; i >= 0; i--) {
if (flashQueue[i].at <= t) {
const f = flashQueue[i];
@ -552,7 +600,7 @@ export function createSkyFx(o = {}) {
// --- sky ---
skyCol.copy(baseSky).lerp(target, storminess * darkness);
if (flash > 0) skyCol.lerp(new THREE.Color(0xdfe8ff), Math.min(0.85, flash));
if (flash > 0) skyCol.lerp(FLASH_COL, Math.min(0.85, flash));
if (scene) {
if (scene.fog) {
scene.fog.color.copy(skyCol);
@ -565,6 +613,22 @@ export function createSkyFx(o = {}) {
dome.position.copy(camPos);
dome.material.opacity = storminess * 0.85;
// Tint the CLOUDS, not just the sky behind them. The dome covers the
// background at ~0.85 opacity, so darkening `scene.background` alone did
// nothing — a "wild night" still read as an overcast afternoon because the
// grey cloud texture was what you were actually looking at.
//
// Both a lerp AND a brightness crush: the lerp alone lands ~#717273 because
// it runs in linear space (84% toward night still reads mid-grey in sRGB)
// and the cloud texture is baked near-white. The crush is what makes night
// look like night. It stops at 0.78 on purpose — the yard has no lights in
// it yet, and a storm you can't see isn't a storm, it's a black screen.
const nightAmt = storminess * darkness;
dome.material.color.copy(WHITE)
.lerp(target, nightAmt * 0.92)
.multiplyScalar(1 - 0.78 * nightAmt);
// and lightning lights the cloud it's inside, which is the whole look
if (flash > 0) dome.material.color.lerp(FLASH_COL, Math.min(0.9, flash));
domeTex.offset.x = (domeTex.offset.x + scroll * dt * (0.4 + speed * 0.05)) % 1;
domeTex.offset.y = (domeTex.offset.y + scroll * dt * 0.12) % 1;

View File

@ -21,7 +21,10 @@ import { createDebris } from '../debris.js';
import { createSkyFx, RainShadow } from '../skyfx.js';
import { weatherCases } from './weather.selftest.js';
const STORMS = ['storm_01_gentle', 'storm_02_wildnight'];
// Keep in step with data/storms/. The node runner globs the directory, so this
// list going stale shows up here first — as it did when storm_03 landed and the
// ponding case reached for a storm the browser half had never loaded.
const STORMS = ['storm_01_gentle', 'storm_02_wildnight', 'storm_03_southerly'];
/** @param {import('../testkit.js').Suite} t */
export default async function run(t) {
@ -213,6 +216,55 @@ export default async function run(t) {
assert(half > 0.2 && half < 0.8, `a rect straddling the edge reads ${half}, want a partial`);
});
// --- decision 7: the drain helper Lane A wires garden HP to ---
t.test('gardenExposure is rain AND no cover, and needs both', () => {
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera();
const wind = createWind(storms.storm_02_wildnight);
const sky = createSkyFx({ scene, camera, wind });
const bed = { x: 0, z: 0, w: 4, d: 3 };
// no sail: exposure is simply the rain
fixedLoop(1, FIXED_DT, (dt, time) => sky.step(dt, 70 + time, {}));
const open = sky.gardenExposure(bed, 70);
assert(Math.abs(open - wind.rainAt(70)) < 1e-9,
`open ground should be exposed exactly as hard as it rains: ${open} vs ${wind.rainAt(70)}`);
assert(open > 0.5, 'storm_02 at t=70 should be pouring');
// a panel over the bed dries it out
const panel = { pos: new Float32Array([-3, 3, -3, 3, 3, -3, 3, 3, 3, -3, 3, 3]), tris: [0, 1, 2, 0, 2, 3] };
fixedLoop(1, FIXED_DT, (dt, time) => sky.step(dt, 70 + time, { sail: panel }));
const covered = sky.gardenExposure(bed, 70);
assert(covered < open * 0.5, `cloth over the bed barely helped: ${covered.toFixed(2)} vs open ${open.toFixed(2)}`);
// and no rain means no drain, sail or not
assert(sky.gardenExposure(bed, 0) === 0, 'the bed is draining before the rain has started');
sky.dispose();
});
t.test('storm_02 lights up on its biggest gusts, storm_01 does not', () => {
const mk = (name) => {
const scene = new THREE.Scene();
const wind = createWind(storms[name]);
const sky = createSkyFx({ scene, camera: new THREE.PerspectiveCamera(), wind });
let flashes = 0, peak = 0;
let was = 0;
fixedLoop(wind.duration, FIXED_DT, (dt, time) => {
sky.step(dt, time, {});
if (sky.flash > was + 0.05) flashes++; // a rising edge = a strike
was = sky.flash;
peak = Math.max(peak, sky.flash);
});
sky.dispose();
return { flashes, peak };
};
const wild = mk('storm_02_wildnight');
const gentle = mk('storm_01_gentle');
// 3 authored strikes + the worst gusts; must be more than the authored ones
assert(wild.flashes > 3, `wild night only flashed ${wild.flashes} times — the big gusts aren't lighting up`);
assert(gentle.flashes === 0, `the gentle storm flashed ${gentle.flashes} times — it has no lightning at all`);
});
t.test('every storm in data/storms/ loads and validates', () => {
// loadStorm throws on invalid, so reaching here with all of them is the pass
assert(Object.keys(storms).length === STORMS.length, 'a storm failed to load');

View File

@ -343,6 +343,180 @@ export default async function run(t) {
assertLess(bracedKnocks, exposedKnocks, 'and bracing through it is strictly better');
});
// ---------------------------------------------------------------- the ladder (decision 12)
// A fake ladder standing in for ladder.js's THREE half: same shape, no GLB, no scene. The real
// one is verified by hand in the game; these pin the legs of the state machine.
const fakeLadder = (opts = {}) => {
const st = { placedAt: opts.placedAt || null, carried: false };
return {
needsLadder: (a) => !!a && a.type === 'house',
workY: () => 2.35,
// height only, mirroring the real ladder.js — see the note there on why testing for 'atTop'
// here makes the repair cancel its own hold
isWorking: (id) => st.placedAt === id && !!opts.player && opts.player.climbY > 2.2,
servedAnchor: () => null,
get placedAt() { return st.placedAt; },
_place: (id) => { st.placedAt = id; },
update() {}, dispose() {},
};
};
t.test('ladder: climb is code-driven and lands in a work stance', () => {
const s = new PlayerSim();
assertEq(s.climbY, 0, 'starts on the ground');
s.climbTo(2.35);
assertEq(s.state, 'climb', 'climbing');
assert(s.busy, 'you cannot be interrupted mid-rung');
assertEq(clipFor(s), 'ClimbLadder', 'and ClimbLadder plays');
drive(s, 0.5);
assert(s.climbY > 0.3 && s.climbY < 2.35, `partway up, got ${s.climbY.toFixed(2)}`);
drive(s, 3);
assertEq(s.state, 'atTop', 'arrives in the work stance');
assertClose(s.climbY, 2.35, 1e-6, 'at the top rung');
assert(!s.busy, 'atTop must NOT be busy — the whole point is that hold-E works up there');
});
t.test('ladder: the fascia is out of reach from the ground and in reach from the top', () => {
const s = new PlayerSim();
const FASCIA_Y = 2.48; // measured in the real yard
assertLess(s.reachY, FASCIA_Y, 'standing on the ground, a 1.72 m person cannot reach the bracket');
s.climbTo(2.35); drive(s, 4);
assert(s.reachY >= FASCIA_Y, `up the ladder they can, reach=${s.reachY.toFixed(2)}`);
});
t.test('ladder: you cannot walk while you are on it', () => {
const s = new PlayerSim();
s.climbTo(2.35); drive(s, 4);
assertEq(s.state, 'atTop');
const x0 = s.pos.x, z0 = s.pos.z;
drive(s, 1.5, { x: 1, z: 1, run: true, camYaw: 0 });
assertClose(s.pos.x, x0, 1e-9, 'WASD does not walk you off a ladder');
assertClose(s.pos.z, z0, 1e-9);
assertEq(s.state, 'atTop', 'and you stay in the work stance');
});
t.test('ladder: descending returns you to the ground and frees you', () => {
const s = new PlayerSim();
s.climbTo(2.35); drive(s, 4);
s.climbTo(0);
assertEq(s.state, 'climb', 'going down is the same clip');
drive(s, 4);
assertEq(s.state, 'idle', 'back on your feet');
assertEq(s.climbY, 0);
drive(s, 1, { x: 0, z: 1, camYaw: 0 });
assertEq(s.state, 'walk', 'and walking again');
});
t.test('ladder: you cannot brace up there — both hands are on the rungs', () => {
const s = new PlayerSim();
s.climbTo(2.35); drive(s, 4);
// a wind under even the ladder's lowered bar, so this isolates the brace refusal from the fall
drive(s, 1, { shelter: true }, windX(TUNE.knockWind * TUNE.ladderKnockMult - 4));
assertEq(s.state, 'atTop', 'holding C on a ladder does nothing');
});
t.test('ladder: the wind is meaner at height, and being blown off is a FALL', () => {
// a wind that is survivable standing must be able to take you off the ladder
const between = TUNE.knockWind * TUNE.ladderKnockMult + 2; // over the ladder bar, under the standing one
assertLess(between, TUNE.knockWind, 'the test wind must be survivable on the ground');
const ground = new PlayerSim();
drive(ground, TUNE.knockSustain + 0.5, {}, windX(between));
assertEq(ground.state, 'idle', 'on your feet this wind is nothing');
const up = new PlayerSim();
up.climbTo(2.35); drive(up, 4);
up.carrying = 'spare';
drive(up, TUNE.knockSustain + 0.3, {}, windX(between));
assertEq(up.state, 'knocked', 'the same wind takes you off the ladder');
assertEq(up.climbY, 0, 'you are on the ground now, not floating at height');
assertClose(up.fellFrom, 2.35, 1e-6, 'and the fall height is recorded');
assertEq(up.carrying, null, 'you dropped the spare on the way down');
drive(up, 3);
assertEq(up.state, 'idle', 'the ladder gets the normal get-up chain for free');
});
t.test('ladder: needsLadder is scoped to the fascia, not to everything above head height', () => {
const L = fakeLadder();
assert(L.needsLadder({ type: 'house', pos: { y: 2.48 } }), 'the fascia bracket needs it');
assert(!L.needsLadder({ type: 'post', pos: { y: 3.95 } }),
'a 4 m post does NOT — you tension it from a cleat at the base');
assert(!L.needsLadder({ type: 'tree', pos: { y: 5.05 } }),
'nor a tree limb — that is a strop you throw');
});
t.test('ladder: fascia re-rig is gated on being up it; post re-rig is not', () => {
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const L = fakeLadder({ player: p });
const anchors = [{ id: 'h2', type: 'house', pos: { x: 0, y: 2.48, z: 0.9 } },
{ id: 'p1', type: 'post', pos: { x: 0, y: 3.95, z: 0 } }];
const corners = [{ anchorId: 'h2', broken: true }, { anchorId: 'p1', broken: true }];
let repaired = [];
const it = new Interact();
wireYardActions(it, {
ladder: L,
world: { anchors },
sailRig: { corners, repair: (i) => repaired.push(i), trim: () => {},
cornerPos: () => ({ x: 0, y: 0.4, z: 0 }) },
});
p.carrying = 'spare';
// the POST corner: repairable from the ground, exactly as it was before the ladder existed
p.pos.x = 0; p.pos.z = 0;
fixedLoop(3.3, DT, (dt, tt) => it.step(dt, tt, p, true));
assert(repaired.includes(1), 'post corner still re-rigs from the ground — the ladder changed nothing here');
// the FASCIA corner: same spare, standing right under it, refused
repaired = []; p.carrying = 'spare'; p.pos.x = 0; p.pos.z = 0.9;
it.latched = false;
const near = it.nearest(p);
assert(!near || near.id !== 'rerig_0', 'standing under the bracket is not enough');
fixedLoop(3.3, DT, (dt, tt) => it.step(dt, tt, p, true));
assert(!repaired.includes(0), 'fascia re-rig refused from the ground');
// plant the ladder and climb it → now it lands
L._place('h2');
p.climbTo(2.35); drive(p, 4);
assertEq(p.state, 'atTop');
p.carrying = 'spare';
it.latched = false;
fixedLoop(3.3, DT, (dt, tt) => it.step(dt, tt, p, true));
assert(repaired.includes(0), 'up the ladder, the fascia re-rig lands');
assertEq(p.carrying, null, 'and it ate the spare');
});
t.test('ladder: the scripted loop — carry, plant, fetch spare, climb, repair, descend', () => {
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const L = fakeLadder({ player: p });
const anchors = [{ id: 'h2', type: 'house', pos: { x: 0, y: 2.48, z: 0.9 } }];
const corners = [{ anchorId: 'h2', broken: true }];
let repaired = false;
const it = new Interact();
wireYardActions(it, { ladder: L, world: { anchors },
sailRig: { corners, repair: () => { repaired = true; }, trim: () => {},
cornerPos: () => ({ x: 0, y: 0.4, z: 0 }) } });
// hands-full: the ladder and the spare compete for the same pair of hands
assertEq(p.pickUp('ladder'), true, 'pick the ladder up');
assertEq(p.pickUp('spare'), false, 'you cannot also carry a spare — that is the two-trip cost');
assertEq(p.drop(), 'ladder', 'put it down');
// trip 2: the spare, then up
assertEq(p.pickUp('spare'), true);
L._place('h2');
p.climbTo(L.workY()); drive(p, 4);
assertEq(p.state, 'atTop', 'up the ladder with the spare');
it.latched = false;
fixedLoop(3.3, DT, (dt, tt) => it.step(dt, tt, p, true));
assert(repaired, 'fascia repaired at height');
assertEq(p.carrying, null, 'spare consumed');
// and back down
p.climbTo(0); drive(p, 4);
assertEq(p.state, 'idle', 'down and free');
assertEq(p.climbY, 0);
});
// ---------------------------------------------------------------- solids collision
t.test('collision: solids stop you, and the pushout slides you along them', () => {
// one box: the yard's north wall, x -8..8, z -16..-10, waist high
@ -476,6 +650,28 @@ export default async function run(t) {
assertEq(sim.state, 'knocked', 'and the abort did not overwrite the knocked state');
});
t.test('interact: a hold survives the busy transition it causes', () => {
// The bug this pins bit twice while building the ladder, and is invisible: canUse is re-checked
// every frame to keep the hold alive, and starting the hold sets state='busy' — so any canUse
// reading player.state goes false on frame one and cancels itself. Prompt looks dead, no error.
const sim = new PlayerSim();
const it = new Interact();
let fired = 0;
it.register({ id: 'ok', pos: { x: 0, y: 0, z: 0 }, radius: 2, holdSecs: 0.5,
canUse: (p) => p.climbY < 0.02, onDone: () => { fired++; } }); // physical gate — fine
fixedLoop(1, DT, (dt, tt) => it.step(dt, tt, sim, true));
assertEq(fired, 1, 'a physically-gated action completes');
const sim2 = new PlayerSim();
const it2 = new Interact();
let fired2 = 0;
it2.register({ id: 'trap', pos: { x: 0, y: 0, z: 0 }, radius: 2, holdSecs: 0.5,
canUse: (p) => p.state === 'idle', onDone: () => { fired2++; } }); // state gate — the trap
fixedLoop(1, DT, (dt, tt) => it2.step(dt, tt, sim2, true));
assertEq(fired2, 0,
'a state-gated canUse cancels its own hold — documented on register(); gate on physical facts');
});
t.test('interact: canUse() gates on the carrying flag', () => {
const sim = new PlayerSim();
const it = new Interact();

View File

@ -79,6 +79,7 @@ const ASSETS = [
nodes: ['palings', 'rails', 'debris_palings'] },
{ name: 'broom_01', h: [1.35, 1.50],
nodes: ['handle', 'head', 'bristles', 'grip_anchor', 'poke_tip'] },
{ name: 'hail_stone_01', h: [0.012, 0.028], nodes: ['stone'] },
];
function sizeOf(gltf) {

View File

@ -11,7 +11,7 @@
// call it with the fetched storm defs.
import {
createWindField, validateStorm, gustEnvelope, GUST,
createWindField, validateStorm, gustEnvelope, GUST, RAIN_TIME_COMPRESSION,
} from '../weather.core.js';
const DT = 1 / 60;
@ -405,6 +405,105 @@ export function weatherCases(storms) {
assert(!validateStorm(legacy, 'legacy').ok, 'validator silently accepted the pre-SPRINT3 downdraft field');
});
// ---- 10. the ponding story (SPRINT4 decision 10) ----
// Lane B owns water-on-cloth; these assert the RAIN DATA can tell the story
// their mass model needs, using their own arithmetic, before their cloth lands.
// B measured: 5 cm over a 25 m² flat sail = 1250 kg = 3.1 kN/corner, against a
// storm_02 wind load of only 0.21.1 kN. So depth is the whole mechanic.
const FLAT_AREA = 25; // m², B's reference flat sail
const KILL_DEPTH_MM = 50; // 5 cm — B's 3.1 kN/corner
/** Water on a flat sail over a whole storm, at decision 10's compression. */
const stormDepthMm = (def) => {
const f = createWindField(def);
return f.rainDepthMm(0, f.duration) * RAIN_TIME_COMPRESSION;
};
/** B's arithmetic: mm over an area → kN per corner (4 corners, 1000 kg/m³). */
const kNPerCorner = (mm) => (mm / 1000) * FLAT_AREA * 1000 * 9.81 / 4 / 1000;
test('storm_02 rain can drown a flat rig; storm_01 rain cannot', () => {
const wild = stormDepthMm(storms.storm_02_wildnight);
const gentle = stormDepthMm(storms.storm_01_gentle);
metrics['storm_02.pondDepth_mm'] = +wild.toFixed(1);
metrics['storm_02.pond_kN_per_corner'] = +kNPerCorner(wild).toFixed(2);
metrics['storm_01.pondDepth_mm'] = +gentle.toFixed(2);
metrics['storm_01.pond_kN_per_corner'] = +kNPerCorner(gentle).toFixed(3);
assert(wild >= KILL_DEPTH_MM * 0.9,
`storm_02 only delivers ${wild.toFixed(1)} mm — Lane B needs ~${KILL_DEPTH_MM} mm to drown a flat rig`);
// and it must dwarf the wind it's competing with (B: 0.21.1 kN/corner)
assert(kNPerCorner(wild) > 1.5,
`storm_02 ponding is only ${kNPerCorner(wild).toFixed(2)} kN/corner — no stronger than the wind`);
// the gentle storm must be unable to hurt anything, or the ramp is a lie
assert(kNPerCorner(gentle) < 0.3,
`storm_01 ponds ${kNPerCorner(gentle).toFixed(2)} kN/corner — a light shower must not threaten a rig`);
assert(wild > gentle * 20, 'the wild night should deliver vastly more water than a shower');
});
test('storm_03 ponding sits between the other two', () => {
const mid = stormDepthMm(storms.storm_03_southerly);
const wild = stormDepthMm(storms.storm_02_wildnight);
const gentle = stormDepthMm(storms.storm_01_gentle);
metrics['storm_03.pondDepth_mm'] = +mid.toFixed(1);
metrics['storm_03.pond_kN_per_corner'] = +kNPerCorner(mid).toFixed(2);
assert(mid > gentle * 3 && mid < wild * 0.5,
`storm_03 delivers ${mid.toFixed(1)} mm — wanted a real middle rung between ${gentle.toFixed(1)} and ${wild.toFixed(1)}`);
// it should threaten a carabiner (1.2 kN) once wind is added, not a shackle (3.2)
assert(kNPerCorner(mid) > 0.25 && kNPerCorner(mid) < 1.2,
`storm_03 ponds ${kNPerCorner(mid).toFixed(2)} kN/corner — should tease a cheap rig, not drown a decent one`);
});
test('rain depth integrates monotonically and matches its curve', () => {
const f = createWindField(storms.storm_02_wildnight);
// depth only ever accumulates
let prev = 0;
for (let t = 1; t <= f.duration; t += 1) {
const d = f.rainDepthMm(0, t);
assert(d >= prev - 1e-9, `rain depth went BACKWARDS at t=${t}: ${d} < ${prev}`);
prev = d;
}
// splitting the interval must give the same water (no double-count, no gap)
const whole = f.rainDepthMm(0, 90);
const split = f.rainDepthMm(0, 30) + f.rainDepthMm(30, 60) + f.rainDepthMm(60, 90);
assert(Math.abs(whole - split) < 0.05,
`depth(0,90)=${whole.toFixed(3)} but the three thirds sum to ${split.toFixed(3)}`);
// dead calm before the rain starts
assert(f.rainDepthMm(0, 0) === 0, 'zero-length interval delivered water');
assert(f.rainDepthMm(10, 5) === 0, 'a backwards interval delivered water');
});
test('rainMmPerHour tracks rainAt against the storm scale', () => {
for (const [name, def] of defs) {
if (!def.rain || !def.rain.curve) continue;
const f = createWindField(def);
const peak = def.rain.peakMmPerHour;
assert(Number.isFinite(peak), `${name} has a rain curve but no peakMmPerHour`);
for (let t = 0; t <= f.duration; t += 2.5) {
const want = f.rainAt(t) * peak;
assert(Math.abs(f.rainMmPerHour(t) - want) < 1e-9,
`${name}: rainMmPerHour ${f.rainMmPerHour(t)} != rainAt×peak ${want} at t=${t}`);
}
// rainAt stays a 0..1 intensity — skyfx uses it for drop count and opacity
for (let t = 0; t <= f.duration; t += 2.5) {
const r = f.rainAt(t);
assert(r >= 0 && r <= 1, `${name}: rainAt ${r} outside 0..1 at t=${t}`);
}
}
});
test('validator rejects a rain curve with no scale, and a silly scale', () => {
const noScale = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
delete noScale.rain.peakMmPerHour;
assert(!validateStorm(noScale, 'x').ok, 'validator accepted a rain curve with no mm/hr scale — ponding would silently use the default');
for (const mm of [-1, 5000, NaN]) {
const d = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
d.rain.peakMmPerHour = mm;
assert(!validateStorm(d, 'x').ok, `validator accepted peakMmPerHour=${mm}`);
}
const hot = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
hot.rain.curve = [[0, 0], [45, 3], [90, 0]];
assert(!validateStorm(hot, 'x').ok, 'validator accepted rain intensity above 1 — the scale is peakMmPerHour, not the curve');
});
return { cases, metrics };
}

View File

@ -132,6 +132,25 @@ function sampleAngleCurve(curve, t) {
// Prototype: pow = 12 + rand*16 + 10*p, next = t + 5 + rand*7. Same shape, from JSON.
export const DEFAULT_DOWNDRAFT = 0.22;
/** Fallback rain scale, mm/hr at rainAt()==1. Storms should state their own. */
export const DEFAULT_PEAK_MM_PER_HOUR = 40;
/**
* SPRINT4 decision 10 the time-compression fiat, in ONE place.
*
* Game rain accumulates ~40× real time. A 90 s storm is canonically a whole
* night (storm_02 telegraphs its change "around the hour mark"), so it should
* deliver a night's water: 90 s × 40 = 3600 s = one hour of rain. At storm_02's
* 80 mm/hr peak that lands ~5 cm on a flat sail exactly Lane B's measured kill
* threshold (5 cm over 25 = 1250 kg = 3.1 kN/corner) against a wind load of
* only 0.21.1 kN. Ponding is 315× everything else, and it cannot pincer §7
* because a hypar has no flat to pool in.
*
* Exported so Lane B applies it cloth-side rather than either of us hardcoding
* 40 twice: how hard it rains is Lane C, how much water a sail holds is Lane B.
*/
export const RAIN_TIME_COMPRESSION = 40;
export function buildGustTimeline(def, seed) {
const g = def.gusts || {};
const rng = mulberry32(seed >>> 0);
@ -378,6 +397,45 @@ export function createWindField(def, opts = {}) {
if (r.curve) return Math.min(1, Math.max(0, sampleCurve(r.curve, t)));
return Math.min(1, Math.max(0, r.intensity ?? 0));
},
/**
* Rain rate in REAL-WORLD mm/hr. Same curve as rainAt(), with physical units
* on it `rainAt` stays 0..1 because it drives drop count and opacity, and a
* renderer doesn't want millimetres.
*
* This exists for ponding (decision 10). Without it Lane B has to invent the
* mm/hr scale to turn intensity into water mass, which is exactly the
* "default-off code tuned by a constant I invented" they rightly reverted.
* The scale is storm data, so it lives here: `rain.peakMmPerHour`.
* For reference: 8 = light shower, 30 = moderate, 50 = heavy, 80+ = severe.
*/
rainMmPerHour(t) {
const r = def.rain;
if (!r) return 0;
return field.rainAt(t) * (r.peakMmPerHour ?? DEFAULT_PEAK_MM_PER_HOUR);
},
/**
* Real-world water depth in mm delivered over (t0, t1]. Deterministic
* trapezoid over the curve no compression applied.
*
* Lane B multiplies by RAIN_TIME_COMPRESSION cloth-side: how much water a
* SAIL holds is theirs, how hard it rains is mine. Handy shape for a HUD
* "water delivered" readout too.
*/
rainDepthMm(t0, t1, stepS = 0.25) {
if (!(t1 > t0)) return 0;
let mm = 0;
const n = Math.max(1, Math.ceil((t1 - t0) / stepS));
const h = (t1 - t0) / n;
let prev = field.rainMmPerHour(t0);
for (let i = 1; i <= n; i++) {
const cur = field.rainMmPerHour(t0 + i * h);
mm += (prev + cur) * 0.5 * h / 3600; // mm/hr × seconds → mm
prev = cur;
}
return mm;
},
};
return field;
@ -446,6 +504,21 @@ export function validateStorm(def, name = 'storm') {
}
if (def.rain && def.rain.curve && !isCurve(def.rain.curve)) bad('rain.curve must be [[t,intensity],...]');
if (def.rain) {
if (def.rain.curve && isCurve(def.rain.curve)
&& def.rain.curve.some((p) => p[1] < 0 || p[1] > 1)) {
bad('rain.curve intensity must be 0..1 — the physical scale is rain.peakMmPerHour');
}
const mm = def.rain.peakMmPerHour;
if (mm != null && (!Number.isFinite(mm) || mm < 0 || mm > 300)) {
bad(`rain.peakMmPerHour must be 0..300 mm/hr (8 light, 30 moderate, 50 heavy, 80+ severe) — got ${mm}`);
}
// Ponding reads this; a storm that rains with no scale silently ponds at the
// default instead of what its author meant.
if (def.rain.curve && mm == null) {
bad('rain.curve without rain.peakMmPerHour — ponding needs the mm/hr scale (SPRINT4 decision 10)');
}
}
return { ok: errors.length === 0, errors };
}

View File

@ -10,9 +10,9 @@
// determinism rule can't be broken by accident.
import * as THREE from '../vendor/three.module.js';
import { createWindField, validateStorm, GUST } from './weather.core.js';
import { createWindField, validateStorm, GUST, RAIN_TIME_COMPRESSION } from './weather.core.js';
export { GUST, validateStorm };
export { GUST, validateStorm, RAIN_TIME_COMPRESSION };
// Resolved against this module, not the server root: server.py serves the repo
// root (so the 2D prototype stays reachable), but the demo bench serves web/.
@ -84,9 +84,16 @@ export function createWind(def, opts = {}) {
/** Storm events fired in (a,b] — poll with (t-dt, t). Deterministic. */
eventsBetween(a, b) { return field.eventsBetween(a, b); },
/** 0..1 rain intensity. */
/** 0..1 rain intensity — drives drop count and opacity. */
rainAt(t) { return field.rainAt(t); },
/** Rain rate in real-world mm/hr. Ponding (decision 10) reads this. */
rainMmPerHour(t) { return field.rainMmPerHour(t); },
/** Real-world mm of water delivered over (t0,t1]. Multiply by
* RAIN_TIME_COMPRESSION cloth-side see weather.core. */
rainDepthMm(t0, t1) { return field.rainDepthMm(t0, t1); },
/** Direction (radians, XZ plane from +X toward +Z) ignoring local effects. */
dirAt(t) { return field.dirAt(t); },

View File

@ -45,6 +45,11 @@ const SHED_TABLE = { x: 9, z: 6, rotY: -Math.PI / 2 };
// the same line the graybox taught everyone to expect.
const HOUSE = { x: 0, z: -10.5 };
// 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;
@ -95,7 +100,13 @@ export function createWorld(scene, opts = {}) {
/** @type {{group: THREE.Object3D, phase: number, base: THREE.Euler}[]} */
const canopies = [];
/** Graybox stand-ins, kept so dress() can retire them once E's GLBs load. */
const graybox = { house: null, trees: new Map() };
const graybox = { house: null, trees: new Map(), bed: null };
/**
* E's three wilt states, siblings in one GLB toggled by visibility rather
* than reloaded, which is why they all ship together.
* @type {{full: THREE.Object3D, tattered: THREE.Object3D, dead: THREE.Object3D}|null}
*/
let plants = null;
// --- sky & light -------------------------------------------------------
// Calm-day only. Lane C's skyfx.js takes over the sky and this becomes the
@ -329,6 +340,7 @@ export function createWorld(scene, opts = {}) {
}
}
root.add(bed);
graybox.bed = bed;
// --- boundary fence ----------------------------------------------------
// East, south and west only: the house is the north boundary.
@ -379,6 +391,18 @@ export function createWorld(scene, opts = {}) {
pos: new THREE.Vector3(SHED_TABLE.x, heightAt(SHED_TABLE.x, SHED_TABLE.z) + 0.9, SHED_TABLE.z),
};
/**
* Show one of E's three wilt states. No-op against the graybox bed, so the
* HUD can call it unconditionally.
* @param {'full'|'tattered'|'dead'} which
*/
function setPlants(which) {
if (!plants) return;
for (const [k, node] of Object.entries(plants)) {
if (node) node.visible = k === which;
}
}
/**
* Swap Lane E's GLBs in over the graybox. Async and separate from
* createWorld() on purpose: the selftest builds a yard with no server, and a
@ -432,11 +456,34 @@ export function createWorld(scene, opts = {}) {
}
};
const [shed, table, houseGlb, tree1, tree2] = await Promise.all([
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('tree_gum_01_v1'), load('tree_gum_02_v1'), load('garden_bed_v1'),
load('garden_gnome_01_v1'),
]);
// --- garden bed ------------------------------------------------------
if (bedGlb) {
retire(graybox.bed);
bedGlb.name = 'garden_bed';
bedGlb.position.set(GARDEN_BED.x, heightAt(GARDEN_BED.x, GARDEN_BED.z), GARDEN_BED.z);
root.add(bedGlb);
const pick = (n) => bedGlb.getObjectByName(n) ?? null;
plants = { full: pick('plants_full'), tattered: pick('plants_tattered'), dead: pick('plants_dead') };
setPlants('full');
}
// --- gnome (aftermath collateral bait) -------------------------------
// Placed under the sail's likely footprint on purpose: DESIGN.md wants your
// own failures to be the worst debris, and something breakable has to be
// standing there for that to land.
if (gnome) {
gnome.name = 'garden_gnome_01';
gnome.position.set(GNOME.x, heightAt(GNOME.x, GNOME.z), GNOME.z);
gnome.rotation.y = GNOME.rotY;
root.add(gnome);
}
// --- house (decision 6: no re-cut, the GLB's data wins) ---------------
// E's fascia sits at 2.80 m and their anchors span x=-3..3, where my
// graybox guessed 2.6 m and -5..5. Reading them narrows the house span by
@ -532,6 +579,10 @@ export function createWorld(scene, opts = {}) {
*/
shedTable,
dress,
/** @param {'full'|'tattered'|'dead'} which */
setPlants,
/** Where the breakable client property stands, and what breaking it costs. */
gnome: GNOME,
// Lane C's skyfx MODULATES these as the storm builds and hands them back
// untouched on dispose() — it doesn't own them. That's why the yard exposes
// its lights rather than keeping them private.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB