Compare commits

...

15 Commits

Author SHA1 Message Date
m3ultra
6c88c8125e Add aftermath wreckage props and verify glTF extras survive
The aftermath screen needs something to point at (SPRINT3 §Lane E-2).
garden_gnome_01_broken snaps at the ankles with the base left where the player
last saw him, head rolled clear with the beard still on, hat off — recognisably
him rather than a shattered pile, because wreckage nobody can identify is just
gravel. fence_panel_broken loses a few palings and hangs one off a nail; most of
it stays standing, which is what makes the hole read as damage rather than as a
design choice. Both keep their intact twin's origin and ground plane so Lane A
swaps mesh-for-mesh with no offsets, asserted both ways.

Also asserts something claimed since Sprint 1 and never checked: that glTF
extras reach three's userData. They do — the gnome's collateral_value, the
canopy's sway_amp, branch_anchor rating_hint and the bin's mass_hint all arrive
as numbers. Had that silently dropped, Lane A's gnome would score $0 and every
anchor would rate identical, which reads as a gameplay decision rather than a
missing field.

Contact-sheet framing now keys the capsule off height, not max(dims): the broken
gnome is 0.39 m across but stands 0.11, so spread-based framing buried it the
same way it once buried the shackle.

Selftest 175/0/0, 30 output files byte-identical across two runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 01:23:10 +10:00
m3ultra
624a72e458 Add Sprint 3 plan and lane prompts: close the game loop
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 01:11:16 +10:00
m3ultra
1ace50da0f Merge Sprint 2 lanes; wire debris into sail step; settle downdraft dispute as documented skip
Selftest on merged main: 169 pass / 0 fail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 01:09:51 +10:00
m3ultra
cd173d1ce0 Merge remote-tracking branch 'origin/lane/d'
# Conflicts:
#	THREADS.md
2026-07-17 01:01:48 +10:00
m3ultra
3f6fc27d00 Merge remote-tracking branch 'origin/lane/b'
# Conflicts:
#	THREADS.md
2026-07-17 01:01:48 +10:00
m3ultra
ac5021d279 Merge remote-tracking branch 'origin/lane/c'
# Conflicts:
#	THREADS.md
2026-07-17 01:01:48 +10:00
m3ultra
fd6dfc77da Log Sprint 2 landing, and block Lane C's decision 3 on real numbers
Decision 3 doesn't clear its own 60% bar: measured against lane/c over 8
headings and the full 90 s, flat-horizontal is 34% of flat-pitched, not
>=60%. The downdraft is 0.3 of the GUST component only, so storm_02's
strongest is -4.5 m/s against a 32.6 m/s horizontal peak, and pressure
goes as v^2. Needs ~0.55-0.6, or a fraction of total speed.

Also flags for A: rig.step needs the 4th debris arg or crates fly through
the sail; and the cheap-rig cascade fires at t=0.4s from pre-tension on
the 192 m2 quad, which decision 2 fixes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:26:38 +10:00
m3ultra
2034593a30 Re-run the §7 gate against real storm wind; stop allocating per face
B-4. The gate used to run on my own stub wind — uniform, horizontal,
tuned by nobody — so it proved the cloth was self-consistent, not that
the game works. It now loads the shipped storm JSON and drives the cloth
through weather.core.js (pure and import-free, so the same path runs in
node and in selftest.html; weather.js's own loadStorm can't help because
its STORM_DIR is a file:// URL that node's fetch won't open).

All three halves of §7 now run on real storm_02 and the real yard:
a flat drum-tight carabiner rig cascades 4/4; a well-twisted mixed rig
holds all four; and a twisted rig with one dodgy corner blows it and
finishes 4/4 intact after a single repair — the DoD scenario, in an
assert.

Perf: Lane C added an `out` param to wind.sample specifically so sail.js
wouldn't allocate, and I wasn't passing it — 162 faces at 60 Hz is ~9.7k
throwaway Vector3s a second. Now passing a scratch vector. Stub winds
that ignore `out` still work; we read the return value.

Two tests skip rather than pass vacuously while Lane C's downdraft is
unmerged: a "nothing broke" repair scenario would go green forever and
check nothing. Both light up by themselves on merge, and the repair one
hard-fails if a downdraft IS present and still can't threaten the rig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:25:25 +10:00
m3ultra
cb557a1d6f Add prep-phase picking UI over RiggingSession (unblocks A step 8)
RiggingSession already held every rule and was tested headless; this is
only the hands. Click an anchor to rig it, click again to cycle its
hardware, shift-click to pull it off for a full refund, [ and ] for
tension, S for the spare. RMB is left alone — that's the camera's orbit.
Enter stays Lane A's: the phase machine calls commit(), which goes
through main.js's rigSail() door as A asked.

It renders its own anchor markers because the yard has none to raycast
against — world.js builds posts and trunks, not pick targets — and
marker styling is prep-phase UI, so it belongs to this lane.

What you click is not what you see: the marker ring's tube is 5 cm, which
at yard distance is a couple of pixels and genuinely unhittable. Picking
goes against an invisible 0.45 m sphere and the ring is just the read.

The panel shows live sail AREA, which SPRINT2 doesn't ask for but
decision 2 implies: the 70-192 m² problem is invisible to a player who
can't see what they're about to build. Picking the obvious quad
(h1/h3/p1/p2) now says "191 m2" before you commit to it.

dev_rigging.html follows the house pattern (C's weather_demo, D's
dev_player): the picking UI can't be asserted headless — it is clicks,
raycasts and materials — and can't run in index.html until A wires step
8. It boots the real world.js, real anchors and real cloth, so what's
verified is what ships. Retire it once index.html hosts prep.

Verified by hand in it: rig h2 by click, cycle carabiner -> shackle,
budget $80 -> $65, weak link flagged, dashed quad preview, commit ->
sail in scene (100 verts, 162 tris, casting a real shadow over the
garden bed) -> storm with corner loads reading 1.0-1.2 kN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:14:34 +10:00
m3ultra
43ab43d3fb Log the proven §7 loop and the exact seams A and B need
Stood in for world.shedTable and rig.repair/cornerPos in the live game and drove
the whole scenario through the real sim, real interact and real rig: take a
spare, carry it to a blown corner, hold to re-rig, spare consumed, corner back.
Lane D's half of gate 3 is done; the gap is two small seams, now specified with
working code rather than a request.

Selftest 136/0/0 across all five lanes (was 121) — no other suite moved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:12:29 +10:00
m3ultra
d6aa124cb7 Log Sprint 2 landing: decisions 3 & 5, rain occlusion, fog fix
Includes the controlled downdraft rebalance table for Lane B, the frozen
debris.pieces seam, and the garden-HP design question for Lane A (rain shadow
vs sun coverage).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:12:03 +10:00
m3ultra
6971f31984 Rain stops at the cloth (SPRINT2 Lane C.3)
The garden visibly stays dry under the sail. Rain arrives along the wind, so
the dry patch sits downwind of the cloth and slides across the yard as the
wind swings — at the southerly change it walks right off the bed, free drama
and honest physics.

Cheap on purpose. Ray-testing 3k drops against 162 triangles every frame is
~486k intersections for an effect nobody inspects closely. Instead project the
sail's triangles ALONG the rain onto the ground into a coarse height grid, a
few times a second (the cloth moves slowly next to the rain); per-drop cost is
one grid read, and occluded drops get a zero-scale matrix rather than a
raycast. Measured 0.041 ms/rebuild, 10x/s = 0.41 ms/s against A's 0.63 ms
frame — negligible. Reads rig.pos/rig.tris, so nothing new needed from Lane B.

Verified in the real game with a surviving twisted rated rig: 497 grid cells
covered, 96% of the garden bed under cover, live drops culled under the cloth
and falling in the open yard either side. Screenshot for DESIGN.md.

skyfx exposes rainShadowOver(rect) — NOT the same as rig.coverageOver(bed,
sunDir). That one is the SUN shadow; this is the RAIN shadow (down the wind).
Which drives garden HP is a design call — flagged for A in THREADS.

Selftest 134/0/0 (8 new rain-shadow asserts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:10:29 +10:00
m3ultra
264c2e25d2 Lane D: solids collision, the M3 verbs, shelter and stumble
At gate 1 the ped walked straight through the house. Collision now stops it
0.30 m off the wall face (expected -9.70, measured -9.70), off trunks, posts and
the fence, and slides along a wall hit at an angle. Injected as opts.collide the
way groundAt already was, so player.sim.js stays zero-import and node-runnable.

Two things the real yard taught, neither guessable from the plan:
- `fence` is a GROUP of 37 child meshes whose combined box is the entire 30x20 m
  yard, so one box per solids entry is useless. Flattened to 43 leaf boxes.
- the house ROOF spans y 2.99-3.21 and reaches 0.4 m FURTHER into the yard than
  the wall under it (eaves overhang). A flat footprint test would stop a 1.7 m
  person dead at an invisible eave, so every box is filtered by vertical overlap
  with the body and the roof drops out on its own.
Boxes are built once (solids are static) and distance-pruned — no per-frame
raycast, which is the thing Lane A measured as catastrophic on the terrain.

The M3 pack is wired: carrying swaps locomotion to Carry/CarryIdle; an
interaction names its own verb through a new `clip` field on the interact spec
(Crank at a turnbuckle, PickUp at the shed table); StumbleBack fires on a gust
that breaks your stride but can't floor you — below knockWind on purpose, so a
storm reads as shoved → stumbling → floored rather than fine-fine-fine-flat.

TakeCover (hold C) became a real mechanic rather than a pose: brace and knockWind
x2.0, shove x0.25. A 38 m/s gale floors you standing and doesn't while braced;
let go in the same gale and you're down in half a second. It raises the bar, it
does not remove it — a big enough gust still wins, braced or not. So the storm's
answer to "the gusts are too strong to cross the yard" is now wait one out and
move in the lull, which is the repair-window language DESIGN.md already uses.

wireYardActions now reads sailRig.corners[i] live by index instead of capturing
the corner object — per Lane A's warning that attach() replaces the array, a
captured corner is one the sim no longer steps and would gate forever on a
`broken` flag that can never change again.

35 Lane D asserts, 0 fail (was 20). Carry/shelter/knockdown verified against the
real 17-clip pack in the assembled game, not only in the harness.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:08:46 +10:00
m3ultra
135511fb05 Add vertical gusts, freeze debris.pieces, fix fog restore
SPRINT2 decisions 3 and 5, plus Lane A's fog nit.

Decision 3 — gusts now descend. Cloth pressure goes with dot(wind, normal); a
flat panel's normal points at the sky, so in a perfectly horizontal wind the
dot is ~0 and "lie it flat and ignore the storm" was the cheapest winning rig.
A gust front is descending air, not just faster air. Per-gust downdraft
fraction in storm JSON (storm_02 0.3, storm_01 0.18, default 0.25, validated
0..1), each gust varying 0.6-1.4x. Peak downdraft in storm_02 is 4.4 m/s, 17%
of the horizontal. Lane B: the cloth-side assert is yours.

The vertical draws from its OWN rng stream, and there's an assert pinning
that: pulling it from the main stream would shift every subsequent (t0, pow)
and silently re-time storms Lane A has already hand-verified. Their carabiner
still blows at t=45.4 and cascades at t=56.

speedAt() stays horizontal — an anemometer doesn't read falling air, and a
wind meter that spikes because a gust is descending reads as a bug.

Decision 5 — debris.pieces frozen and documented in contracts.js as the seam
Lane B reads in sail.step(), with the sphere/SI/mutated-in-place semantics
spelled out and an assert tying the live shape to the contract table.

Fog: dispose() captured scene.fog by reference and step() mutates that object
in place, so restoring it restored nothing (Lane A caught it). Now captured by
value, and fog we created ourselves is removed rather than left behind.

Selftest 130/0/0 (was 121); Lane C 28 asserts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:58:31 +10:00
m3ultra
7e8ffa307c Land decisions 4 and 5: Lane D's rig seam, debris impulses
Decision 4 — conform to Lane D's call sites rather than the reverse (D
landed first and duck-typed them): repair(i), trim(i, delta) and
cornerPos(i). repair takes no hardware argument because prep sells
exactly one kind of spare, so it re-rigs at shackle grade — an upgrade
on a blown carabiner, a downgrade on a blown rated shackle, which is the
prototype's behaviour and a real choice about which corner you run to.
cornerPos returns a fresh vector at the live node, so a blown corner's
prompt chases the flogging corner instead of sitting on a dead anchor
(measured: 13 m off). All three are contract entries now, not PROPOSED
comments, so the merge tripwire enforces the seam.

Decision 5 — sail.step() takes an optional debris and applies sphere-vs-
cloth impulses. The exchange is symmetric: every newton-second the cloth
takes out of a crate, the crate loses. Asserted, and it conserves to
0.000% on an interior hit. Pinned corners are the deliberate exception —
invMass 0 means a crate off a corner dumps its momentum into the house,
which is correct, the anchor is bolted to a wall.

Note this leaves debris.js's applyToSail dead: it guards on `sail.nodes`,
which never existed on the rig — the cloth stores Float64Arrays. So the
debris-vs-sail impulse has been silently doing nothing in the assembled
game. Decision 5 puts it on this side; flagged for Lane C in THREADS.

The contact radius is swept by the piece's travel because main.js steps
the sail before the debris (so piece positions are a frame stale) and a
0.3 m crate at 25 m/s covers 0.42 m per frame — enough to pass clean
between cloth nodes.

Also: coverageOver() rays now start at heightAt(x,z) rather than y=0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:58:14 +10:00
25 changed files with 2539 additions and 76 deletions

View File

@ -158,3 +158,60 @@ re-deciding them.
> washing line, garden gnome — same one-script determinism + contact-sheet
> acceptance; (4) when Lane A's yard is dressed, render a contact sheet of the
> assembled yard from the game camera for DESIGN.md.
---
---
# SPRINT 3 prompts (the game loop — fire all five; B+C pair on item 1)
Same rules: own clone, own branch, rebase onto latest main FIRST (Sprint 2 is
merged; main.js passes debris into rig.step; downdraft data reverted to 0.3
with B's assert self-skipping — THREADS' last [I] entry explains why). Read
SPRINT3.md in full; decisions 7/8/9 are made.
## Lane A — Sprint 3
> You are Lane A on SHADES 3D, Sprint 3. Rebase onto main, read SPRINT3.md
> §Lane A. Priority 1 is world.shedTable (unblocks D, ~15 lines). Then the
> decision-2 anchor rework with the quad-area assert, the playable prep phase
> (wire B's picking adapter, take their preview-rig force-arrows offer), the
> HUD (kN bars, telegraph, garden HP via skyfx.rainShadowOver per decision 7,
> plant damage-state swaps), forecast card + aftermath screen (score the gnome),
> and finish the yard dressing with E's GLBs and sway handles. Retitle the page.
> Merge shepherd duties continue. Small commits, selftest green after each.
## Lane B — Sprint 3
> You are Lane B on SHADES 3D, Sprint 3. Rebase onto main and read SPRINT3.md
> §B+C and THREADS' last [I] entry — the integrator measured that gust-only
> downdraft cannot satisfy your 60% bar and §7 together (0.45→42% + twisted
> loses a corner; 0.58→48% + still loses one), so decision 8 adopts your
> fraction-of-TOTAL semantics. Pair with C in THREADS: when their weather.core
> change lands, re-run your 8-direction sweep, delete the <0.5 self-skip from
> your decision-3 assert, and confirm all three §7 legs on the SAME storm JSON.
> Log final constants. Then sail UVs with E's sail_weave.png per their recipe.
> Also: A may take your preview-rig offer for prep force arrows — support them.
## Lane C — Sprint 3
> You are Lane C on SHADES 3D, Sprint 3. Rebase onto main and read SPRINT3.md
> §B+C and THREADS' last [I] entry. Decision 8: change weather.core downdraft
> to a fraction of TOTAL wind speed (keep the own-RNG determinism guarantee,
> keep speedAt() horizontal, update the validator; rename the JSON field if the
> semantics warrant it), then pair with B to land values where their 60% bar
> AND the §7 gates are green on the same data. Retune storm_01/storm_02, and
> author storm_03 between them so the campaign has a ramp. Decision 7 landed
> garden HP on your rainShadowOver — coordinate the API with A as they wire it.
## Lane D — Sprint 3
> You are Lane D on SHADES 3D, Sprint 3. Rebase onto main, read SPRINT3.md
> §Lane D. The moment A posts world.shedTable in THREADS: close the §7 loop BY
> HAND — pickup → carry through gusts (brace when needed) → hold-E repair →
> ≥3/4 corners at storm end — and RECORD the run (SHADES.step + screenshots);
> that artifact is the sprint's definition of done. Retune stumble/knockdown
> thresholds against real storm_02 gusts now that the downdraft is live.
> Stretch: the ladder loop (carry, place, ClimbLadder to fascia anchors) —
> flag early in THREADS if it's bigger than the sprint.
## Lane E — Sprint 3
> You are Lane E on SHADES 3D, Sprint 3. Rebase onto main, read SPRINT3.md
> §Lane E. Small juice pass: tear-decal hookup recipe for B (like your weave
> recipe), broken-gnome + snapped-fence-panel variants for the aftermath
> screen, and refresh the assembled-yard contact sheet once A's dressing lands.

115
SPRINT3.md Normal file
View File

@ -0,0 +1,115 @@
# SPRINT 3 — THE GAME LOOP (instructions for Opus 4.8 lanes)
*Sprint 2 verdict: the storm is real. Merged main boots into a yard with live
weather, a walking ped, a rendered sail that cascades believably, debris that
dents both cloth and player, rain that stops at the cloth, and 169/0/0 green.
What's missing is the GAME around it: you can't rig with the mouse in the shell,
can't pick up a spare, can't see loads without the console, and nothing scores
the aftermath. Sprint 3 closes the loop so a stranger could play it.*
Read THREADS.md from the last [I] entry down before starting. Two integrator
facts you must absorb: (1) main.js now passes `debris` as rig.step's 4th arg;
(2) the downdraft data is back at C's 0.3/0.18 and B's decision-3 assert
self-skips below 0.5 — item 1 below is why.
## Decisions (made)
7. **Garden HP is driven by `skyfx.rainShadowOver(bed)` during storms.**
B and C both recommended it and they're right — at night the sun shadow is a
number about nothing. `rig.coverageOver(bed, sunDir)` stays as the daytime
shade readout (and becomes the heatwave scorer later). Lane A wires it.
8. **Downdraft semantics change to fraction-of-TOTAL wind speed** (B's
preference, C's code). Gust-only semantics measurably cannot satisfy both
the no-free-lunch bar and §7 survival (numbers in THREADS [I] 2026-07-17).
9. **`world.shedTable` is Lane A's, this sprint, first.** It gates D's §7
hand-play and it's ~15 lines of dressing.
## Lane A — finish the shell (the sprint's spine)
In priority order:
1. **`world.shedTable`** — place `shed_01_v1.glb` + `shed_table_v1.glb`, expose
`{pos}` (use E's `pickup_anchor` empty if present). Unblocks D immediately.
2. **Decision 2 anchor rework** (carried from Sprint 2): posts in to ~(4.5,5.5)
and (4.0,6.0), add p3 near (0,7), register E's tree `branch_anchor_*`
(they carry `rating_hint`). New a.test assert: ≥3 pickable quads in 1845 m²
covering the bed. This also fixes B's "cascade at t=0.4 s from pre-tension
alone" finding — the yard currently teaches the wrong lesson.
3. **Prep phase playable**: wire B's picking adapter (it exists on their branch
contract — coordinate in THREADS) to anchor markers + hardware cycling +
tension dial + spare purchase, with budget $80. Take B up on their offer of
a **preview rig for live force arrows during prep** — DESIGN.md calls this
the core teaching tool.
4. **HUD**: per-corner load bars in kN vs rating (world-anchored), wind meter +
gust telegraph banner, garden HP (decision 7) driving E's
plants_full/tattered/dead swaps, phase banner.
5. **Forecast card** (storm JSON summary: peak wind, gust character, change
time — sell the dread) and **aftermath screen** (garden %, corners lost,
hardware bill, verdict line). Enter-to-advance is fine.
6. Yard dressing completion: swap graybox house for `house_yardside_v1.glb`
(decision 6 — read fascia_anchor_* from the GLB), fence set, washing line
(head spins in gusts — E gave you `sway_amp` handles too, use them for the
canopy telegraph), wheelie bin into the debris pool, gnome placed in sail
range (`collateral_value` 25 — score it in aftermath).
7. Retitle the page — it still says M0.
## Lane B + Lane C — the downdraft semantic (JOINT, do it first, pair in THREADS)
1. **C**: `weather.core.js` — downdraft becomes a fraction of TOTAL wind speed
(sustained + gust), keeping the own-RNG-stream determinism guarantee and the
storm-JSON validator (rename the field if semantics change enough to warrant
it — e.g. `downdraftOfTotal` — a silently re-meaning field is worse than a
rename). Keep `speedAt()` horizontal.
2. **B**: re-run the 8-direction sweep at C's proposed values, re-enable the
decision-3 assert (delete the <0.5 self-skip it documents *gust-only*
semantics and dies with them), confirm §7 all three legs (cheap cascades /
twisted survives / twisted+repair survives) against the SAME data. Log the
final constants in THREADS. Both asserts green on the same storm JSON is
this item's definition of done.
3. **B**: sail UVs + E's `sail_weave.png` per their recipe (uv attribute,
repeat 6×6, sRGB) — the membrane should read as fabric, and the tear decal
strip is waiting for M3 tearing.
4. **C**: storm_03 — author a third storm between gentle and wildnight so the
campaign has a ramp (and the forecast card has range to sell).
## Lane D — close the §7 loop by hand (gate 3)
1. The moment A posts `world.shedTable`: verify the full loop live — pick up
spare (PickUp → Carry/CarryIdle), run it through gusts (shelter on C when
needed), hold-E repair at the blown corner (Crank/repair, spare consumed),
rig finishes ≥3/4. **Record the run** (SHADES.step + screenshots, or a
screen capture) — this artifact is the sprint's definition of done.
2. Wire `StumbleBack` vs full knockdown thresholds against real storm_02 gusts
with C's downdraft — the brace mechanic (hold C) changes the tuning you did
pre-merge.
3. Ladder: E shipped `ladder_01_v1.glb` with base/top nodes and you have
ClimbLadder — if fascia repairs are meant to need it (DESIGN.md says yes),
land carry-ladder + place + climb as the stretch goal; flag early if it's
bigger than this sprint.
## Lane E — juice pass (small)
1. Sail tear decals hookup notes for B (you shipped the atlas; write the
two-line recipe like you did for the weave).
2. Aftermath props: broken-gnome variant (`garden_gnome_01_broken`), snapped
fence panel — the aftermath screen wants wreckage to point at.
3. Assembled-yard contact sheet refresh once A's dressing lands (your Sprint 2
item 4, still open — the yard finally looks like the game this week).
## Gates
```
gate 1: A-1 lands → D starts closing the loop
gate 2: B+C downdraft semantic → both physics gates green on one storm JSON
gate 3: prep→storm→aftermath playable start to finish with the mouse,
§7 scenario ON RECORD, selftest green
```
Definition of done = gate 3. That is also the moment SHADES stops being a tech
demo — get there and the next conversation is about content (storms, sites,
the landscaper campaign), not systems.
## For John
- Nothing blocks on you this sprint. The M1 stays an asset-build box.
- When gate 3 lands you'll want to actually play it: `python3 server.py` in the
repo, http://localhost:8801/ (or --port anything), WASD/shift/E/C/Enter.

View File

@ -576,3 +576,335 @@ Format: `[lane letter] YYYY-MM-DD — note`
the gust tell. Cheap fixes in your court: lift the hemisphere light's ground colour, or I can bake a
little emissive into the leaf material so gums stay readable from underneath. Say the word and it's
one constant in my palette — not touching it unprompted since lighting is yours.
above was measured through them. (Confirmed on my side — every number below came through them too.)
[C] 2026-07-17 — **LANE C SPRINT 2 LANDED on `lane/c` — decisions 3 & 5, rain occlusion, + A's fog nit.**
Selftest **134/0/0** (was 121; +13 Lane C asserts). Rebased on gate-1 main. Four pushes, small commits.
Thanks A for doing my §C.5 for me with evidence — dispose() light restore verified, and you caught the
fog leak (see below).
[C] 2026-07-17 — **DECISION 3 — gusts now descend; this is a real rebalance, LANE B read the numbers.**
Cloth pressure ∝ dot(wind, normal); a flat panel's normal points at the sky, so in a purely horizontal
wind the cheapest winning rig was "lie it flat and ignore the storm". Fixed: per-gust downdraft fraction
in storm JSON (`gusts.downdraft`, 0..1, validated), storm_02 0.3 / storm_01 0.18 / default 0.25, each
gust varying 0.61.4×. `wind.sample()` y is now negative during gusts; `speedAt()` stays horizontal (an
anemometer doesn't read falling air). Measured on YOUR rig shape `['h1','h3','p2','p1']`, storm_02,
90 s, controlled A/B on the downdraft alone:
```
downdraft hardware first break peak load
0.0 rated shackle never 1929 N
0.3 rated shackle never 4165 N ← +116% peak, still survives
0.0 shackle never 1929 N
0.3 shackle t=20.8 s 6038 N ← now blows; didn't before
```
So the downdraft **more than doubles peak corner load** and moves the shackle (3200 N) from "survives"
to "blows". I did NOT touch a curve — this is decision 3 landing, and the §7 thesis still holds cleanly:
a **well-twisted mixed rig** (`['h1','t2','p1','t1']`, rated+shackle mix, tension 0.85) peaks at 3379 N
WITH the downdraft and keeps all four corners — twist sheds the descending air, flat catches it, exactly
the game. The downdraft sharpens the choice, it doesn't break it. **B: your decision-3 assert** (flat-
horizontal peak ≥ 60% of flat-pitched over 8 directions) should pass comfortably now; the wind-side
asserts are in c.test ('gusts carry a downdraft…', 'downdraft does not re-time the storm').
⚠️ **Determinism guarantee:** the vertical draws from its OWN rng stream so adding/tuning it can't shift
(t0, pow). Your hand-verified cascade (carabiner t=45.4, p2 t=56) is untouched — asserted.
[C] 2026-07-17 — **DECISION 5 — `debris.pieces` FROZEN in contracts.js. B, this is your seam.** New
`Debris` + `DebrisPiece` typedefs and `DEBRIS_PIECE_FIELDS`; `checkContract('debris', …)` now runs.
Shape you can rely on inside `sail.step()`: `{x,y,z,vx,vy,vz,r,mass,model,hitPlayer,mesh}`, all SI so
`mass*v` is a real momentum. Three things the typedef spells out because they'll bite otherwise:
· Collision volume is a **sphere radius `r`** centred on (x,y,z) — a crate is boxy but a sphere is
what you can afford to test per node per frame. `y` is the CENTRE, rests at `heightAt(x,z)+r`.
· The array is **mutated in place** — pieces splice out on despawn. Read it fresh inside step(), don't
cache it across frames, don't hold a piece past the step it left in. `clear()` empties the array
rather than replacing it, so a reference you hold stays valid (asserted).
· Don't move `piece.mesh` — Lane C drives it from the sim each step; you'd be fighting me.
[C] 2026-07-17 — **RAIN STOPS AT THE CLOTH (§C.3).** Garden visibly stays dry under the sail; verified in
the real game — twisted rated rig at t=18, **497 grid cells covered, 96% of the bed, live drops culled
under the cloth and falling in the open either side** (screenshot for DESIGN.md). Cost 0.041 ms/rebuild
×10/s = **0.41 ms/s**, negligible vs your 0.63 ms frame. Reads `rig.pos`/`rig.tris` only — nothing new
from B. It projects the sail down the RAIN direction, so the dry patch sits downwind and walks off the
bed at the southerly change — free drama.
**LANE A — design call, not mine: which shadow drives garden HP?** `skyfx.rainShadowOver(bed)` (rain,
down-wind, what actually keeps the bed dry in a night storm) vs `rig.coverageOver(bed, world.sunDir)`
(sun, which at night is a number about nothing). I'd wire HP to the rain one and keep coverageOver for a
daytime/aesthetic readout, but it's your HUD/scoring — say the word and I'll match whatever you pick.
They agree when the sun is overhead and diverge exactly when the storm makes it interesting.
[C] 2026-07-17 — **FOG — fixed, thanks A.** `dispose()` captured `scene.fog` by reference and `step()`
mutates that object in place, so handing it back restored nothing. Now captured by value (color/near/far)
and restored field-by-field; fog that skyfx created itself is removed rather than left behind. Asserted
in c.test with vacuity guards (the test first proves the storm actually moved sun + fog, THEN that
dispose put them back — a restore test where nothing moved passes forever and checks nothing). Your
rebuild-on-phase-change path is clean now in both directions.
[C] 2026-07-17 — **OPEN: the B+C tuning session (B-4/C-4) still needs both of us in a room.** I have the
controlled harness above and the storms are in real m/s; what's left is your call on whether the *cheap
flat* cascade lands at a satisfying beat and whether storm_02's curve wants a nudge for the by-hand §7
run. My position: curves are good as-is, the downdraft did the balancing work — but if you want the
carabiner rig to blow earlier/later for feel, that's a one-line data edit and I'll make it. Ping when
sail-side tuning is settled and we lock constants together. (weather_demo.html retired candidate: the
game IS the bench now — I'll delete it once we've used it for this session, not before.)
[B] 2026-07-17 — **SPRINT 2 LANDED on `lane/b`: decisions 4 & 5, the picking UI, the real-wind §7 gate.**
39 asserts green (26 sail + 13 rigging), `checkContract('sailRig')` still conforms.
· **Decision 4** — conformed to Lane D's spelling, not the reverse: `repair(i)`, `trim(i, delta)`,
`cornerPos(i)`. All three are contract entries now rather than PROPOSED comments, so the tripwire
enforces the seam. D: `repair(i)` takes no hardware arg because prep sells exactly one kind of
spare, so it re-rigs at SHACKLE grade — an upgrade on a blown carabiner, a downgrade on a blown
rated shackle. `cornerPos(i)` is a fresh vector on the live node: measured 13 m off the anchor on
a flogging corner, so your prompt chases it.
· **Decision 5**`sail.step(dt, wind, t, debris)` now applies sphere-vs-cloth impulses. Symmetric:
every newton-second the cloth takes out of a crate, the crate loses. Conserves to 0.000% on an
interior hit (asserted). Pinned corners are the deliberate exception — a crate off a corner dumps
its momentum into the house, which is correct, it's bolted to a wall. **Lane A: this needs the
4th arg — `rig.step(dt, wind, windT, debris)` in main.js, or the crates fly through the sail.**
· **§7 gate now runs on the real storm JSON**, not my stub. Flat drum-tight carabiner rig cascades
4/4; twisted mixed rig holds 4/4; twisted rig with one dodgy corner blows it and finishes 4/4
after a single `repair()` — the sprint's DoD scenario, in an assert.
[B] 2026-07-17 — **⚠️ LANE C — decision 3 does NOT clear its own bar yet. Numbers, before you merge.**
Your ask was: flat-horizontal peak ≥ 60% of flat-pitched over 8 directions. Measured against your
branch, 8 headings, **full 90 s**: **flat-horizontal 0.56 kN vs flat-pitched 1.66 kN = 34%.** Still a
free lunch. Why: `downdraft: 0.3` is 0.3 of the **gust component only**, and storm_02's strongest
downdraft is **4.5 m/s** against a **32.6 m/s** horizontal peak (t=75.3 s). Pressure goes as v², so
4.5² / (32.6·sin 16.7°)² ≈ ⅓ — which is the 34% almost exactly. To reach 60% the downdraft needs to
hit ~7.3 m/s, i.e. **downdraft ≈ 0.550.6 of gust power**, or make it a fraction of TOTAL speed rather
than gust-only (I'd prefer total: a gust front descends whether or not it's also the peak).
Your +116% A/B is real and I reproduced it (twisted rig 1.16 → 2.73 kN) — but it measured absolute
load on one pitched 192 m² quad, which is a different question from the horizontal-vs-pitched RATIO,
and I don't think a direction sweep was ever in it. Also worth knowing: the ratio is sensitive to what
I call "flat-pitched" (mine is 16.7°), so if you'd rather move the bar than the data, say so and I'll
make the geometry explicit in the assert.
**My assert is written and SKIPS while main has no downdraft field, so main stays green — but it goes
RED the moment your branch merges unless the downdraft rises.** You offered "a one-line data edit";
this is me taking you up on it. Ping when it's in and I'll re-measure the same sweep.
Two other things from your entries, both confirmed: `debris.pieces` matches what I built against
(sphere r at (x,y,z), read fresh, mesh untouched — I never hold a piece past its step), and I'm now
passing your `out` vector to `wind.sample`, which I'd been ignoring — that was ~9.7k throwaway
Vector3s a second. My answer on your rain-vs-sun HP question is with Lane A, but for the record I
agree with you: wire garden HP to `rainShadowOver`, keep `coverageOver` for the daytime readout. At
night the sun shadow is a number about nothing.
[B] 2026-07-17 — **⚠️ LANE A — the §7 cheap-rig cascade currently fires at t=0.4 s, and it's the yard.**
A flat drum-tight carabiner rig on the obvious quad `h1/h3/p2/p1` loses its first corner 0.4 s after
the storm starts — not from the storm, from PRE-TENSION alone. 192 m² at tension 1.3 is ~6 kN per
corner before any wind blows (measured per-corner peaks: h1 6.10 / h3 6.00 / p2 7.25 / p1 6.01 kN).
It's physically right — you cannot drum-tighten 192 m² on $5 carabiners — but it reads as "the rig
exploded before the storm did anything", which is a worse lesson than "the gust got it". **Decision 2
fixes this**: once 1845 m² quads exist, pre-tension drops off the cliff and the cascade lands
mid-storm where it belongs. Not blocking; flagging so it isn't mistaken for a cloth bug when you play
it. Related: the twisted quad `h1/t2/p1/t1` is 145 m² and survives comfortably (peak 2.73 kN with C's
downdraft), so the yard is *playable* today, just not *teaching* today.
Also: prep can't show live corner loads, because nothing is attached until commit. DESIGN.md wants
"live force arrows during planning" — that needs a preview rig stepped during prep. Cheap to do from
my side if you want it in the HUD; say the word.
[B] 2026-07-17 — **Lane A — wiring the prep phase (this is your step 8).** `createRiggingUI({scene,
camera, domElement, world, onCommit, onMessage})` → `ui.setActive(phase === 'prep')` on phaseChange,
`ui.update(dt, t)` each frame, `ui.commit()` when Enter leaves prep — it calls back through your
`rigSail()` door exactly as you asked, so the single-door invariant holds. `ui.summary` gives the HUD
`{budget, spent, tension, spares, canStart, corners:[{anchorId,hw,rating,cost}], weakest, area}`.
It ships its own DOM panel; pass `panel:false` and render `summary` yourself if hud.js wants it.
It renders its own anchor markers because the yard has none to raycast against — world.js builds
posts and trunks, not pick targets. If you'd rather own them, take `world.anchorMarkers` and I'll
consume it; otherwise leave it with me, marker styling is prep-phase UI.
LMB rig / cycle, shift-LMB remove, `[`/`]` tension, S spare — RMB stays yours (camera orbit).
Verified by hand in `dev_rigging.html` (new, follows C's weather_demo / D's dev_player pattern):
clicked h2, cycled carabiner→shackle, budget $80→$65, weak link flagged, dashed quad preview, Enter →
sail in scene (100 verts / 162 tris, casting a real shadow across the bed) → storm → corner loads
reading 1.01.2 kN. **One thing worth stealing: the panel shows live sail AREA.** Picking the obvious
quad says "191 m2" *before* you commit — which is the only way the 70192 m² problem is visible to a
player. Retire dev_rigging.html once index.html hosts prep.
[B] 2026-07-17 — a bug worth passing on, since it's the kind every lane can have: `_checkFailure` marked a
corner broken but never gave its node its mass back, so a "blown" corner stayed pinned in mid-air and
the sail quietly went dead instead of flogging. **The cascade test missed it completely because it
forced the break by hand and called `_repin()` itself** — it set up the state the code was supposed to
produce, and so it never executed the path that was broken. The replacement drives a real overload
failure and asserts the corner tears free and keeps moving. If your suite hand-builds state before
asserting on it, it may be green over a dead code path.
[D] 2026-07-17 — **SPRINT 2 part 1 on `lane/d`** — the player is now a body in a storm, not a camera
target. Selftest **35 Lane D asserts, 0 fail** (was 20). All verified in the real yard, not just
in asserts:
· **`world.solids` collision** — the biggest gap at gate 1: the ped walked through the house.
Now stops dead 0.30 m off the wall face (expected 9.70, measured 9.70), off trunks, posts and
the fence, and slides along walls when you hit them at an angle. Injected as `opts.collide`
the same way `groundAt` is, so player.sim.js stays zero-import and node-runnable.
· **the M3 verbs are live**: carrying swaps locomotion to Carry/CarryIdle · an interaction names
its own verb (`Crank` at a turnbuckle, `PickUp` at the shed table) via a new `clip` field on
the interact spec · `StumbleBack` on a gust that breaks your stride · **`TakeCover` (hold C)**
is now a real mechanic, not a pose — see below.
· Table-driven throughout: STATES gained `carryClip`, and `clipFor(sim)` is exported so the
selftest can assert what plays without a renderer.
[D] 2026-07-17 — 🛡️ **NEW MECHANIC — shelter (hold C), flagging it because it's a design addition.**
SPRINT2 §Lane D.4 said "TakeCover as the storm shelter verb" and left the transition to me. It
brace-locks you: knockWind ×2.0 and shove ×0.25 while held. Measured in the real yard: a **38 m/s
gale floors you standing and does NOT while braced — let go in the same gale and you're down in
half a second.** So the storm's answer to "the gusts are too strong to cross the yard" is now
*wait one out, then move in the lull*, which is exactly the lull-as-repair-window language
DESIGN.md §Wind already uses. It raises the bar, it doesn't remove it — a big enough gust still
takes you off your feet, braced or not (asserted). You cannot brace from your back.
[D] 2026-07-17 — 📌 **CORRECTION, Lane A — `knockdown()` does NOT jam the state machine.** Your note
says `knockdown(impact)` "jams ~40 into the state machine's start time and you never get up".
Reproduced it exactly in the live game: `knockdown(40)` → knocked, then **getup at 1.38 s, idle at
2.68 s. You get up.** `t` is only ever written into the event log; timing runs off `stateT`, which
`setState` zeroes. The only real effect is cosmetic — polluted event timestamps. **Your wiring
(`knockdown(windT, piece.vx, piece.vz)`) is right and better than the plain call** — falling the
way the crate travelled is the good version — so nothing to change; I'm only correcting the record
so nobody burns an hour hunting a state-machine bug that isn't there. Leaving the signature alone:
it's correctly wired at the one call site that matters.
(`player.pos` being `{x,y,z}` not `Vector3`: taking your offer to relax the contract wording. Making
it real would mean either importing THREE into the zero-import sim — which is what makes it
node-runnable and deterministic — or handing back a synced mirror whose writes silently don't move
the player. Neither is worth a nominal type match.)
[D] 2026-07-17 — ❗ **BLOCKED ON LANE A — `world.shedTable`, and it gates the sprint's "done".** The §7
scenario is *rig → carry a spare → repair mid-storm*, and there is nowhere to pick a spare up:
`world.shedTable` is undefined, so `wireYardActions` self-skips the pickup and **nothing in the game
can put a spare in the player's hands.** E shipped `shed_01_v1.glb` AND `shed_table_v1.glb` and
they're on disk unused. All I need is world.js to place them and expose
`world.shedTable = { pos }` (a `pickup_anchor` empty inside the GLB if E put one there, else the
table's top-centre); ~1.5 m from the table's edge is reachable. Everything downstream of it is
already wired and asserted. Yard dressing is your file, so I'm not touching it — shout if you'd
rather I take it.
[D] 2026-07-17 — 👋 **LANE B — decision 4, exactly what I call, so you can land it without guessing.**
I've hardened my side while waiting; `sail.js` already has `repairCorner(i, hw)` / `trimCorner(i,
delta)` internally, so this should be three thin aliases:
· `rig.repair(i)` — I gate on `corners[i].broken && carrying === 'spare'`, hold 2.5 s, then call
it and consume the spare. Pick the hw yourself (the spare is untyped on my side for now).
· `rig.trim(i, delta)` — I call `trim(i, +0.1)` after a 1.2 s hold. Plays `Crank`.
· `rig.cornerPos(i)`**live world position, fresh vector.** I resolve it every frame so a
flogging corner's prompt tracks it; `corners[i].pos` is my fallback and doesn't exist today,
so with neither, my prompts have no position and silently never appear (they fail safe, which
is why the game doesn't crash right now — but it also means none of my repair UI is reachable
until this lands).
Also: your `attach()` replaces the corners array, per Lane A's warning. **My closures now read
`sailRig.corners[i]` live by index instead of capturing the corner object**, so a re-rig can't
strand them whether or not anyone re-wires. Asserted both ways (swap the array → the targets track
the new objects). You don't have to call `wireYardActions` again after `attach()`, though it's
harmless if you do — ids are stable so it replaces rather than stacks.
[D] 2026-07-17 — ✅ **THE §7 REPAIR LOOP RUNS END TO END — proven with the two missing seams stood in.**
Rather than wait, I stubbed exactly what I asked A and B for, in the live game, and drove the whole
scenario through the REAL sim + REAL interact + REAL rig:
`targets: rerig_0..3, trim_0..3, spare_table`
`corner 2 broken → cornerPos(2) = 5.3, 3.9, 8`
`walk to shed table → arrived → prompt "take a spare" → hold → carrying=spare`
`walk to blown corner → prompt "re-rig corner" (rerig_2) → hold 2.5 s`
`→ corner2.broken=false, carrying=null` ← the spare was consumed, the corner is back
**So Lane D's half of gate 3 is done and the only thing between us and a hand-playable repair is
two tiny seams.** Nothing else of mine is waiting on anything.
[D] 2026-07-17 — 🔧 **LANE B — `cornerPos(i)`, written and proven against your sim. Three lines.** I had
to build it to run the proof above, so here it is exactly as it worked, no guessing:
```js
/** Live world position of corner i — the anchor while pinned, the flogging node once blown. */
cornerPos(i) {
const ci = this.cornerIdx[i] * 3;
return new THREE.Vector3(this.pos[ci], this.pos[ci + 1], this.pos[ci + 2]);
}
repair(i) { return this.repairCorner(i); }
trim(i, delta) { return this.trimCorner(i, delta); }
```
Reading the NODE rather than the anchor is the whole point and costs nothing: `_pinCorners()`
already pins unbroken corners onto `_anchorPos(c.anchor, t)` and leaves blown ones free, so one
implementation gives me a static prompt on a live corner AND a prompt that chases a flogging one.
Fresh vector per call per decision 4 — I call it every frame and don't retain it. (Note your
corners carry `anchor` but no `pos`, so `corners[i].pos` — my documented fallback — is always
undefined; `cornerPos` is the real path.)
[D] 2026-07-17 — 🔧 **LANE A — `world.shedTable`, one line, and it unblocks the sprint's "done".** What I
stubbed to prove the loop was literally:
```js
world.shedTable = { pos: new THREE.Vector3(9, heightAt(9, 6) + 0.9, 6) }; // table-top centre
```
Anywhere reachable works — my pickup radius is 1.5 m off that point and the player walked to it
fine. `shed_01_v1.glb` + `shed_table_v1.glb` are on disk from E and currently unused; if E baked a
`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.
[I] 2026-07-17 — **SPRINT 2 INTEGRATION (main).** Lanes b/c/d/e merged (keep-both THREADS). Wired B's
4th arg in main.js (`rig.step(dt, wind, windT, debris)` — crates no longer fly through cloth).
**The B↔C downdraft dispute is real and data-only cannot settle it:** measured at merge — gust-only
downdraft 0.45 → ratio 42% AND the twisted rig loses a corner; 0.58 → 48%, still loses one. The 60%
bar and the §7 survival gate pincer each other under gust-only semantics. Storm data reverted to C's
landed 0.3/0.18; B's decision-3 assert now self-skips below downdraft 0.5 with the measurements in a
comment. **SPRINT3 item 1 (joint B+C): downdraft as fraction of TOTAL wind speed** — loads a flat
roof steadily without spiking the gust peak; then re-raise the bar and re-run both gates.
Selftest on merged main: **169 pass / 0 fail**. Hand-driven check via SHADES.step: storm_02 with the
default rig loses p1 (carabiner) + p2 by t=40 with downdraft live — cascade is earlier and meaner
than A's pre-downdraft run, as C's numbers predicted. Screenshot of the merged storm going to DESIGN.md.
[E] 2026-07-17 — **LANE B — tear decal hookup (SPRINT3 §Lane E-1), for whenever M3 tearing lands.**
`models/textures/sail_tears.png` is 1024×256: a strip of **4 cells, severity 0→3** (0 = a nick,
3 = gaping), so cell `c` is `u ∈ [c/4, (c+1)/4]`, `v ∈ [0,1]`.
The one thing that matters: **a decal has to ride the sim nodes.** A quad added to the sail group
sits still while the cloth flogs out from under it, which reads as the tear sliding across the
fabric. Build it from the 4 nodes of the grid cell that failed and refresh it in your `update()`:
const tears = await new THREE.TextureLoader().loadAsync('/world/models/textures/sail_tears.png');
tears.colorSpace = THREE.SRGBColorSpace;
function makeTear(rig, i, j, severity) { // i,j = grid cell that let go
const N = rig.N, c = Math.min(3, severity);
const nodes = [j*N+i, j*N+i+1, (j+1)*N+i+1, (j+1)*N+i];
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(12), 3));
g.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(
[c/4,0, (c+1)/4,0, (c+1)/4,1, c/4,1]), 2));
g.setIndex([0,1,2, 0,2,3]);
const mesh = new THREE.Mesh(g, new THREE.MeshStandardMaterial({
map: tears, transparent: true, side: THREE.DoubleSide,
depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2, // no z-fight vs cloth
}));
mesh.frustumCulled = false; // same reason your sail isn't
mesh.update = () => { // call from group.update()
const p = g.attributes.position.array;
nodes.forEach((n, k) => { p[k*3] = rig.pos[n*3]; p[k*3+1] = rig.pos[n*3+1];
p[k*3+2] = rig.pos[n*3+2]; });
g.attributes.position.needsUpdate = true;
};
return mesh;
}
One cell ≈ 0.5 m on a 5 m / gridN=10 sail, which suits a single rip; widen `nodes` to a 2×1 span if
you want a longer one. Severity is yours to map — corner load at failure is the obvious source. No
rush on any of this; it's parked until tearing is actually scoped.
[E] 2026-07-17 — aftermath wreckage landed (SPRINT3 §Lane E-2). Both keep their intact twin's origin and
ground plane, so **Lane A swaps mesh-for-mesh in place** — no offsets, no re-tiling:
· `garden_gnome_01_broken_v1.glb` — 0.39 × 0.35 × 0.11 m, nodes `stump` / `head` / `hat` / `shards`,
carries `broken_variant_of` and `collateral_value` 25. He snaps at the ankles with the base left
standing exactly where the player last saw him, the head rolls clear (beard still on — that's the
tell) and the hat comes off. Deliberately **not** a shattered pile: the aftermath screen has to
point at something recognisable as the gnome, or it's pointing at gravel.
· `fence_panel_broken_v1.glb` — same 2.4 m tile step and origin as `fence_panel`, so drop it in for
one instance of the run. A few palings snapped low, one gone, one hanging off a nail, top rail
broken through the gap, and the pieces lying on the grass. Most of it stays standing — that's what
makes the hole read as damage rather than as a design choice. It IS deeper than the intact panel
(0.77 m vs 0.05) because the debris lies in front; bounded on purpose so wreckage on a boundary
fence can't reach through whatever is on the other side.
[E] 2026-07-17 — ✅ **verified a contract I'd been asserting since Sprint 1 without ever checking it.**
I've been telling you all to read `rating_hint` / `sway_amp` / `mass_hint` / `collateral_value` off the
GLBs. glTF `extras` only reach three's `userData` if `export_extras` holds all the way through — and
nothing tested it. It does hold: e.test.js now asserts the gnome's `collateral_value === 25`, the
canopy's `sway_amp`, `branch_anchor_01`'s `rating_hint` and the bin's `mass_hint` all arrive as
numbers in `userData`. Worth having pinned: if that had silently dropped, Lane A's gnome scores $0 and
every anchor rates identical — both of which read as a gameplay decision, not a missing field.
Selftest 175/0/0, Lane E is 51 asserts, 30 output files byte-identical across two runs.
[E] 2026-07-17 — contact-sheet framing now keys the 1.7 m capsule off an asset's **height**, not
`max(dims)`. The broken gnome is 0.39 m across but stands 0.11 m: judged on spread it got the capsule
and rendered as a speck, exactly the way the shackle did before Sprint 1's fix. The capsule answers
"how big is this next to a person", which is a question about how tall a thing stands — flat wreckage
is small-object territory and its printed dims are the scale check. Only asset affected is the broken
gnome.
[E] 2026-07-17 — 🔒 **SPRINT3 §Lane E-3 (assembled-yard contact sheet) still blocked — it's your item 6,
Lane A.** Checked main at 624a72e: `world.js` has zero `_v1.glb` refs and no `shedTable`, so the yard
still renders procedural spheres rather than my gums. Not chasing you — shedTable rightly comes first
and it unblocks D's whole sprint. **Ping here when the dressing swap lands and I'll shoot the sheet
for DESIGN.md the same session.** Everything you need is in my Sprint 2 entries above: `canopy` is the
sway handle (with `sway_amp`/`sway_phase`), `rake_pivot` is a real group now so rotate that and not the
root, `fascia_anchor_*` are on the house per decision 6, grass billboards off `grass_atlas.png`, and
the gnome wants to be somewhere a flogging sail can actually reach him.

View File

@ -337,6 +337,41 @@
],
"status": "PASS",
"problems": []
},
{
"name": "garden_gnome_01_broken",
"dims": [
0.3947,
0.3519,
0.106
],
"tris": 344,
"nodes": [
"garden_gnome_01_broken",
"hat",
"head",
"shards",
"stump"
],
"status": "PASS",
"problems": []
},
{
"name": "fence_panel_broken",
"dims": [
2.4,
0.7749,
1.8197
],
"tris": 336,
"nodes": [
"debris_palings",
"fence_panel_broken",
"palings",
"rails"
],
"status": "PASS",
"problems": []
}
],
"debris": [

View File

@ -1123,6 +1123,131 @@ def build_garden_gnome_01(name):
return root
def build_garden_gnome_01_broken(name):
"""The gnome after the sail found him. Same origin and ground plane as the
intact one, so Lane A swaps meshes in place without moving anything: hide
`garden_gnome_01`, show this, bill $25 on the aftermath screen.
Deliberately NOT a shattered pile the wreckage has to be *recognisable* as
the gnome from across the yard, or the aftermath screen is pointing at
gravel. So: he snaps at the ankles, the head rolls, the hat comes off, and
the base stays exactly where the player last saw it standing.
"""
rng = rng_for(name)
root = add_empty(name)
skin = get_material("Mat_Skin", PAL["gnome_skin"], 0.8)
coat = get_material("Mat_Coat", PAL["gnome_coat"], 0.85)
hat = get_material("Mat_Hat", PAL["gnome_hat"], 0.85)
beard = get_material("Mat_Beard", PAL["line_white"], 0.9)
base_m = get_material("Mat_Concrete", PAL["concrete"], 0.95)
# The stump: base plus the bottom of the coat, snapped off at a ragged line.
join_group([
add_cyl(f"{name}_base", 0.075, 0.02, (0, 0, 0.01), base_m, verts=10),
add_cone(f"{name}_stump", 0.072, 0.060, 0.055, (0, 0, 0.048), coat,
verts=10),
add_cyl(f"{name}_break_face", 0.060, 0.006, (0, 0, 0.078), base_m,
verts=10), # raw concrete at the fracture
], "stump", root)
# The head, rolled clear and face-down. Beard still on, which is the tell.
hx, hy = 0.16, -0.09
join_group([
add_ico(f"{name}_head", 0.042, (hx, hy, 0.040), skin, subdiv=2),
add_cone(f"{name}_beard", 0.038, 0.004, 0.075, (hx + 0.02, hy - 0.03, 0.030),
beard, verts=8, rot=(math.radians(96), 0, math.radians(20))),
add_ico(f"{name}_nose", 0.011, (hx + 0.01, hy - 0.035, 0.046), skin, subdiv=1),
], "head", root)
# The hat, off and on its side — the single most legible piece of him.
join_group([add_cone(f"{name}_hat", 0.050, 0.002, 0.14, (-0.15, 0.07, 0.026),
hat, verts=10, rot=(math.radians(90), 0,
math.radians(-35)))], "hat", root)
shards = []
for i in range(6):
a = math.tau * rng.random()
d = rng.uniform(0.10, 0.26)
s = rng.uniform(0.010, 0.022)
shards.append(add_box(f"{name}_shard_{i}", (s, s * 1.4, s * 0.7),
(math.cos(a) * d, math.sin(a) * d, s * 0.35),
coat if i % 2 else base_m,
rot=(0, 0, rng.uniform(0, math.tau))))
join_group(shards, "shards", root)
stamp(root, name, "prop")
root["broken_variant_of"] = "garden_gnome_01"
root["collateral_value"] = 25
return root
def build_fence_panel_broken(name):
"""A panel the storm went through. Same 2.4 m tile footprint and origin as
fence_panel, so Lane A drops it into the run in place of one instance rather
than re-tiling the fence.
A panel does not disintegrate it loses a few palings and hangs off one
rail. Keeping most of it standing is what makes the gap read as damage
instead of as a design choice.
"""
rng = rng_for(name)
root = add_empty(name)
timber = get_material("Mat_Timber", PAL["timber"], 0.85)
rail_m = get_material("Mat_TimberDark", PAL["timber_dark"], 0.85)
width, h = 2.4, 1.8
pw = 0.09
n = 24
step = width / n
standing, ground = [], []
for i in range(n):
x = -width / 2 + step * (i + 0.5)
roll = rng.random()
if 9 <= i <= 13 and roll < 0.75:
# The hole: snapped low, or gone entirely onto the grass.
if roll < 0.42:
continue
ph = rng.uniform(0.35, 0.72) # jagged stump
standing.append(add_box(f"{name}_snapped_{i:02d}", (pw, 0.019, ph),
(x, 0, ph / 2), timber))
elif roll < 0.10:
# One paling hanging by a single nail, swung off vertical.
standing.append(add_box(f"{name}_hanging_{i:02d}", (pw, 0.019, h * 0.8),
(x + 0.06, 0.01, h * 0.42), timber,
rot=(0, rng.uniform(0.25, 0.5), 0)))
else:
ph = h + rng.uniform(-0.02, 0.02)
standing.append(add_box(f"{name}_paling_{i:02d}", (pw, 0.019, ph),
(x, 0, ph / 2), timber))
join_group(standing, "palings", root)
# Top rail snapped through the gap; bottom rail survives.
rails = [add_box(f"{name}_rail_bot", (width, 0.035, 0.07), (0, 0.027, 0.35),
rail_m),
add_box(f"{name}_rail_top_l", (width * 0.42, 0.035, 0.07),
(-width * 0.29, 0.027, 1.45), rail_m),
add_box(f"{name}_rail_top_r", (width * 0.30, 0.035, 0.07),
(width * 0.35, 0.027, 1.45), rail_m,
rot=(rng.uniform(0.05, 0.14), 0, 0))]
join_group(rails, "rails", root)
# The pieces that left, lying on the grass in front of the hole. Kept to
# snapped lengths and tucked close: the fence sits on the yard boundary, so
# a full-length paling flung a metre out pokes through whatever is on the
# other side of it. Wreckage should read as wreckage, not reach.
for i in range(3):
ground.append(add_box(f"{name}_down_{i}", (pw, 0.019, rng.uniform(0.5, 0.95)),
(rng.uniform(-0.2, 0.6), rng.uniform(-0.40, -0.15),
0.012),
timber, rot=(math.pi / 2, 0, rng.uniform(-0.5, 0.5))))
join_group(ground, "debris_palings", root)
stamp(root, name, "fence")
root["broken_variant_of"] = "fence_panel"
root["tile_step"] = width
return root
# ============================================================================
# GRASS ATLAS — a texture, not geometry (PLAN3D §5-E item 9)
# ============================================================================
@ -1354,6 +1479,17 @@ ASSETS = [
dict(name="garden_gnome_01", fn=build_garden_gnome_01,
dims=((0.10, 0.20), (0.10, 0.20), (0.33, 0.42)),
nodes=["gnome"]),
# Aftermath wreckage (SPRINT3 §Lane E-2). Each keeps its intact twin's origin
# and footprint so Lane A swaps in place.
dict(name="garden_gnome_01_broken", fn=build_garden_gnome_01_broken,
dims=((0.30, 0.70), (0.25, 0.65), (0.08, 0.20)),
nodes=["stump", "head", "hat", "shards"]),
# Deeper than fence_panel on purpose: the snapped palings lie on the grass in
# front of it. Bounded so wreckage on a boundary fence can't reach through
# whatever is behind it.
dict(name="fence_panel_broken", fn=build_fence_panel_broken,
dims=((2.38, 2.60), (0.03, 1.05), (1.70, 1.90)),
nodes=["palings", "rails", "debris_palings"]),
]
@ -1572,10 +1708,15 @@ def verify_all(only=None):
problems.append(f"{tris} tris > {TRI_BUDGET} budget")
# The capsule beside it — the actual acceptance criterion. Skipped for
# hardware: a 1.7 m human next to a 60 mm shackle tells you nothing and
# zooms the shackle down to one pixel. Below 0.30 m the printed dims are
# the scale check, and the tile's job is proving the thing READS.
show_capsule = name != "ref_capsule" and max(dims) >= 0.30
# small things: a 1.7 m human next to a 60 mm shackle tells you nothing
# and zooms the shackle down to one pixel. Below the cut the printed dims
# are the scale check, and the tile's job is proving the thing READS.
#
# Keyed on HEIGHT, not max(dims): the capsule answers "how big is this
# next to a person", which is a question about how tall it stands. Flat
# wreckage spread 0.39 m across the grass but standing 0.11 m is small-
# object territory — measuring its scatter against a human just buries it.
show_capsule = name != "ref_capsule" and dims[2] >= 0.30
if show_capsule:
build_ref_capsule("ref_capsule")
for o in bpy.data.objects:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 2.7 MiB

View File

@ -13,7 +13,8 @@
"maxGap": 14,
"powBase": 2,
"powRand": 3,
"powRamp": 2
"powRamp": 2,
"downdraft": 0.18
},
"dirCurve": [[0, 0.9], [45, 1.0], [90, 1.15]],

View File

@ -11,13 +11,16 @@
"baseCurve": [[0, 7.0], [15, 11.0], [40, 17.0], [60, 20.0], [78, 19.0], [90, 16.0]],
"_gusts_comment": "downdraft = fraction of gust power that blows DOWN, per gust (each gust varies 0.6-1.4x this). A gust front is descending air, not just faster air; without it a flat horizontal sail sheds everything and ignoring the storm is the winning move. 0.3 here because a wild night should punish a flat rig hard.",
"gusts": {
"firstAt": 3,
"minGap": 5.5,
"maxGap": 11,
"powBase": 3,
"powRand": 5,
"powRamp": 7
"powRamp": 7,
"downdraft": 0.3
},
"dirCurve": [[0, 0.85], [50, 0.95], [55, 0.6], [59, -1.25], [70, -1.45], [90, -1.35]],

160
web/world/dev_rigging.html Normal file
View File

@ -0,0 +1,160 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>SHADES — Lane B rigging harness</title>
<style>
html, body { margin: 0; height: 100%; background: #6f7f8c; overflow: hidden;
font: 12px/1.5 ui-monospace, Menlo, monospace; }
canvas { display: block; }
#dev { position: fixed; bottom: 8px; left: 8px; color: #fff; text-shadow: 0 1px 2px #000;
white-space: pre; pointer-events: none; }
#hint { position: fixed; bottom: 8px; right: 8px; color: #ffd27a; text-shadow: 0 1px 2px #000;
text-align: right; white-space: pre; pointer-events: none; }
</style>
<!-- Required: world.js/sail.js pull addons that import the bare 'three' specifier. -->
<script type="importmap">
{ "imports": { "three": "./vendor/three.module.js", "three/addons/": "./vendor/addons/" } }
</script>
</head>
<body>
<canvas id="c"></canvas>
<div id="dev"></div>
<div id="hint">Lane B harness — the prep phase only.
ENTER commits the rig and starts a storm. R resets to prep.</div>
<script type="module">
/**
* Lane B harness — the prep-phase picking UI against Lane A's real yard.
*
* Same reason Lane C has weather_demo.html and Lane D has dev_player.html: the
* picking UI can't be asserted headless (it is clicks, raycasts and materials),
* and it can't be exercised in index.html until Lane A wires it in step 8. This
* boots the real world.js, the real anchors and the real cloth so the thing
* being verified is the thing that ships. Retire it once index.html hosts prep.
*/
import * as THREE from 'three';
import { createWorld } from './js/world.js';
import { createCameraRig } from './js/camera.js';
import { createWind, loadStorm } from './js/weather.js';
import { SailRig, createSailView } from './js/sail.js';
import { createRiggingUI } from './js/rigging.js';
import { FIXED_DT } from './js/contracts.js';
const canvas = document.getElementById('c');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
const scene = new THREE.Scene();
const [calmDef, wildDef] = await Promise.all([
loadStorm('storm_01_gentle'), loadStorm('storm_02_wildnight'),
]);
// main.js multiplexes these behind a private router; the harness only ever needs
// one at a time, so it just swaps the reference when the storm starts.
const calmWind = createWind(calmDef);
const wildWind = createWind(wildDef);
let wind = calmWind;
const windProxy = {
sample: (p, t, out) => wind.sample(p, t, out),
gustTelegraph: (t) => wind.gustTelegraph(t),
eventsBetween: (a, b) => wind.eventsBetween?.(a, b) ?? [],
setSheltersFromTrees: (trees) => { calmWind.setSheltersFromTrees?.(trees); wildWind.setSheltersFromTrees?.(trees); },
};
const world = createWorld(scene, { wind: windProxy });
const cameraRig = createCameraRig(canvas);
cameraRig.setSolids(world.solids);
cameraRig.setGround(world.heightAt);
windProxy.setSheltersFromTrees(world.anchors.filter((a) => a.type === 'tree'));
const rig = new SailRig({ anchors: world.anchors });
let sailView = null;
async function rigSail(anchorIds, hwChoices, tension) {
rig.attach(anchorIds, hwChoices, tension);
if (sailView) {
scene.remove(sailView);
sailView.traverse((o) => { o.geometry?.dispose(); o.material?.dispose(); });
}
sailView = await createSailView(rig);
scene.add(sailView);
}
let msg = '';
let msgT = 0;
const ui = await createRiggingUI({
scene, camera: cameraRig.object, domElement: canvas, world,
onCommit: (ids, hw, tension) => { rigSail(ids, hw, tension); wind = wildWind; t = 0; phase = 'storm'; },
onMessage: (m) => { msg = m; msgT = 2; },
});
let phase = 'prep';
ui.setActive(true);
// harness-only handle, so a debugger (or an agent driving this page) can pick
// anchors without hunting for pixels. Never imported by the game.
window.__laneB = {
ui, rig, world, cameraRig, scene,
get phase() { return phase; },
/** rig by id, exactly as if you'd clicked it */
pick: (id) => ui.session.rig(id),
cycle: (id) => ui.session.cycleHardware(id),
};
addEventListener('keydown', (e) => {
if (e.key === 'Enter' && phase === 'prep') { if (ui.commit()) ui.setActive(false); }
if (e.key.toLowerCase() === 'r') {
phase = 'prep';
wind = calmWind;
ui.setActive(true);
if (sailView) { scene.remove(sailView); sailView = null; }
}
});
// follow-cam target: the yard centre, so prep reads as a site walk-around
const focus = new THREE.Vector3(0, 1.2, 0);
const dev = document.getElementById('dev');
const clock = new THREE.Clock();
let t = 0, acc = 0;
renderer.setAnimationLoop(() => {
const raw = Math.min(0.25, clock.getDelta());
acc += raw;
while (acc >= FIXED_DT) {
t += FIXED_DT;
world.update(FIXED_DT, t);
if (phase === 'storm') rig.step(FIXED_DT, windProxy, t);
acc -= FIXED_DT;
if (msgT > 0) msgT -= FIXED_DT;
}
ui.update(raw, t);
sailView?.update();
cameraRig.update(raw, focus);
const s = ui.summary;
const loads = rig.rigged
? rig.corners.map((c) => `${c.anchorId}:${c.broken ? 'BLOWN' : (c.load / 1000).toFixed(1) + 'kN'}`).join(' ')
: '(not rigged)';
dev.textContent =
`${phase.toUpperCase()} t ${t.toFixed(1)}s wind ${windProxy.sample(focus, t).length().toFixed(1)} m/s\n` +
`budget $${s.budget} corners ${s.corners.length}/4 area ${s.area ? s.area.toFixed(0) + ' m2' : '—'}\n` +
`${loads}` + (msgT > 0 ? `\n!! ${msg}` : '');
renderer.render(scene, cameraRig.object);
});
addEventListener('resize', () => {
renderer.setSize(innerWidth, innerHeight);
cameraRig.object.aspect = innerWidth / innerHeight;
cameraRig.object.updateProjectionMatrix();
});
renderer.setSize(innerWidth, innerHeight);
cameraRig.object.aspect = innerWidth / innerHeight;
cameraRig.object.updateProjectionMatrix();
</script>
</body>
</html>

View File

@ -177,9 +177,20 @@ export class Emitter {
* angle around their centroid. tension scales spring rest lengths, 0.61.4
* (low = loose and floggy, high = drum tight and shock-loaded).
* @property {(dt:number, wind:Wind, t:number) => void} step Fixed dt. Deterministic.
* @property {(rect: {x:number,z:number,w:number,d:number}) => number} coverageOver
* Ground-projected shade over a rect, 0..1.
* @property {(rect: {x:number,z:number,w:number,d:number}, sunDir?: THREE.Vector3, heightAt?: (x:number,z:number)=>number) => number} coverageOver
* Ground-projected shade over a rect, 0..1. Pass world.sunDir and
* world.heightAt so the rays start at the real ground and point at the real
* sun; the defaults (overhead sun, flat y=0) are only for tests.
* @property {Emitter} events Emits 'break' and 'repair' as {type, corner}.
* @property {(i: number) => void} repair
* Re-rig corner i with the carried spare (shackle grade the only kind prep
* sells). No-op if the corner isn't broken. Lane D's 2.5 s hold-E.
* @property {(i: number, delta: number) => void} trim
* Per-corner turnbuckle; delta is ±, clamped to 0.851.15. Lane D's 1.2 s hold.
* @property {(i: number) => (THREE.Vector3|null)} cornerPos
* LIVE world position of corner i, as a fresh vector safe to keep. A blown
* corner's node is flying, so an interaction prompt anchored to this chases
* the flogging corner instead of sitting on the dead anchor. null if unrigged.
*/
/**
@ -190,6 +201,48 @@ export class Emitter {
* @property {boolean} broken
*/
/**
* DEBRIS Lane C implements. Lane B consumes `pieces` inside sail.step().
*
* SPRINT2 decision 5: the sail reads the pieces and applies its own impulses,
* rather than debris.js reaching into the cloth. Momentum bookkeeping stays in
* the one integrator that owns the nodes. That makes `pieces` a real contract
* surface, so it is **frozen** here: fields below are what Lane B may rely on.
*
* @typedef {object} Debris
* @property {DebrisPiece[]} pieces
* Live pieces, newest last. The ARRAY IS MUTATED IN PLACE each step pieces
* are spliced out when they leave the yard, so don't hold a reference to it
* across frames, and don't hold a piece past the step it despawned in. Read it
* fresh inside step(). Order is not stable.
* @property {(dt:number, t:number, world?:object) => void} step Fixed dt. Deterministic.
* @property {(ev:object, t:number) => DebrisPiece} spawn
* @property {(map:Object<string,THREE.Object3D>) => Debris} setModels
* @property {() => void} clear
*/
/**
* One airborne object. Frozen shape Lane C will not remove or repurpose these.
*
* The collision volume is a SPHERE of radius `r` centred on (x,y,z): a crate is
* boxy, but a sphere is what you can afford to test against every cloth node,
* every frame. Everything is SI metres, m/s, kg so `mass * v` is a real
* momentum you can subtract from.
*
* @typedef {object} DebrisPiece
* @property {number} x
* @property {number} y Centre, not base. Rests at heightAt(x,z) + r.
* @property {number} z
* @property {number} vx
* @property {number} vy
* @property {number} vz
* @property {number} r Collision sphere radius, m.
* @property {number} mass kg. Crate 9, tub 5, bin 14.
* @property {string} model Key into models/debris/, e.g. 'BlueCrate_v2'.
* @property {boolean} hitPlayer Already knocked the player down once.
* @property {THREE.Object3D|null} mesh Render instance. Lane C drives it; don't move it.
*/
/**
* PLAYER Lane D implements.
*
@ -253,11 +306,25 @@ export class Emitter {
export const CONTRACT = {
wind: { sample: 'function', gustTelegraph: 'function' },
world: { anchors: 'object', heightAt: 'function', gardenBed: 'object', sunDir: 'object', solids: 'object', update: 'function' },
sailRig: { corners: 'object', attach: 'function', step: 'function', coverageOver: 'function', events: 'object' },
sailRig: { corners: 'object', attach: 'function', step: 'function', coverageOver: 'function', events: 'object', repair: 'function', trim: 'function', cornerPos: 'function' },
player: { pos: 'object', carrying: '*', busy: '*', update: 'function' },
interact: { register: 'function' },
camera: { object: 'object', yaw: 'number', update: 'function' },
game: { phase: 'string', on: 'function' },
debris: { pieces: 'object', step: 'function', spawn: 'function', setModels: 'function', clear: 'function' },
};
/**
* The frozen DebrisPiece fields (SPRINT2 decision 5). Lane B's sail.step() reads
* these off `debris.pieces` and applies impulses from them, so renaming one is a
* breaking change to someone else's integrator, not a local tidy-up. Asserted
* against live pieces in c.test.js if this table and debris.js disagree, the
* selftest says so before Lane B's cloth does.
*/
export const DEBRIS_PIECE_FIELDS = {
x: 'number', y: 'number', z: 'number',
vx: 'number', vy: 'number', vz: 'number',
r: 'number', mass: 'number', model: 'string',
};
/**

View File

@ -30,12 +30,14 @@ export class Interact {
* @param {string|function} [spec.label] string, or (player)->string for live text
* @param {function} [spec.canUse] (player) -> bool
* @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.
* @returns {function} unregister
*/
register(spec) {
if (!spec || !spec.id) throw new Error('interact.register: id required');
const target = {
radius: 1.6, holdSecs: 1, label: '', canUse: null, onDone: null, ...spec,
radius: 1.6, holdSecs: 1, label: '', canUse: null, onDone: null, clip: null, ...spec,
};
this.targets.set(target.id, target);
return () => this.unregister(target.id);
@ -70,6 +72,7 @@ export class Interact {
if (!this.active) return;
// only hand the player back if they're still ours — a knockdown mid-hold already re-stated them
if (player.state === 'busy') player.setState('idle', t);
player.busyClip = null;
this.events.push({ type: 'cancel', id: this.active.id, t });
this.active = null;
this.progress = 0;
@ -99,6 +102,7 @@ export class Interact {
if (!this.active && holding && !this.latched && near && !player.busy) {
this.active = near;
this.progress = 0;
player.busyClip = near.clip || null; // the verb: Crank at a turnbuckle, PickUp at the table
player.setState('busy', t);
}
@ -110,6 +114,7 @@ export class Interact {
this.progress = 0;
this.latched = true;
player.setState('idle', t); // release busy FIRST — onDone may pickUp(), which refuses while busy
player.busyClip = null; // ...and after it, so the carry clips win on the next frame
if (done.onDone) done.onDone(player, t);
this.events.push({ type: 'done', id: done.id, t });
}
@ -129,38 +134,54 @@ export class Interact {
* Register the standard yard actions (PLAN3D §5-D.4). Duck-typed against the contracts so Lane D
* never edits Lane B's or Lane A's files anything not yet landed is simply skipped.
*
* Every closure reads `sailRig.corners[i]` LIVE rather than capturing the corner object. Lane A's
* THREADS note: `attach()` REPLACES the corners array, so a captured corner is a stale object the
* sim no longer steps the prompt would gate forever on a `broken` flag that can never change
* again. Reading by index means a re-rig can't strand these targets whether or not we get re-wired.
*
* @param {Interact} interact
* @param {object} deps {sailRig, world, spares}
* sailRig.corners -> [{anchorId, hw, load, broken}] (contracts.js, Lane B)
* sailRig.repair(i) -> void [PROPOSED see THREADS.md]
* sailRig.trim(i,d) -> void [PROPOSED per-corner turnbuckle, see THREADS.md]
* world.shedTable -> {pos} (Lane A/E)
* @param {object} deps {sailRig, world}
* sailRig.corners -> [{anchorId, hw, load, broken}] (contracts.js, Lane B)
* sailRig.repair(i) -> void (decision 4)
* sailRig.trim(i,d) -> void (decision 4)
* sailRig.cornerPos(i) -> Vector3 (decision 4 live world position; a flogging corner moves)
* world.shedTable -> {pos} (Lane A until it lands, the pickup self-skips)
*/
export function wireYardActions(interact, deps = {}) {
const { sailRig, world } = deps;
const wired = [];
const cornerAt = (i) => (sailRig && sailRig.corners && sailRig.corners[i]) || 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;
return (sailRig.cornerPos && sailRig.cornerPos(i)) || c.pos || null;
};
if (sailRig && Array.isArray(sailRig.corners)) {
sailRig.corners.forEach((corner, i) => {
sailRig.corners.forEach((_corner, i) => {
// re-rig a broken corner — costs the spare you're carrying
wired.push(interact.register({
id: `rerig_${i}`,
pos: () => corner.pos || (sailRig.cornerPos && sailRig.cornerPos(i)),
pos: posAt(i),
radius: 1.8,
holdSecs: 2.5,
label: 're-rig corner',
canUse: (p) => corner.broken && p.carrying === 'spare',
onDone: (p) => { p.carrying = null; if (sailRig.repair) sailRig.repair(i); },
clip: 'Crank',
canUse: (p) => !!(cornerAt(i) && cornerAt(i).broken)
&& p.carrying === 'spare' && !!sailRig.repair,
onDone: (p) => { p.carrying = null; sailRig.repair(i); },
}));
// per-corner turnbuckle trim — new vs the prototype; makes corners individual
wired.push(interact.register({
id: `trim_${i}`,
pos: () => corner.pos || (sailRig.cornerPos && sailRig.cornerPos(i)),
pos: posAt(i),
radius: 1.8,
holdSecs: 1.2,
label: 'tighten turnbuckle',
canUse: () => !corner.broken && !!sailRig.trim,
onDone: () => sailRig.trim && sailRig.trim(i, +0.1),
clip: 'Crank',
canUse: () => !!cornerAt(i) && !cornerAt(i).broken && !!sailRig.trim,
onDone: () => sailRig.trim(i, +0.1),
}));
});
}
@ -172,6 +193,7 @@ export function wireYardActions(interact, deps = {}) {
radius: 1.5,
holdSecs: 0.6,
label: (p) => (p.carrying ? 'hands full' : 'take a spare'),
clip: 'PickUp',
canUse: (p) => !p.carrying, // hands-full rule
onDone: (p, t) => p.pickUp('spare', t),
}));

View File

@ -346,7 +346,7 @@ export async function boot(opts = {}) {
windT = windTime();
world.update(dt, windT);
player.update(dt, windT);
rig.step(dt, wind, windT);
rig.step(dt, wind, windT, debris);
debris.step(dt, windT, { player: player.sim, sail: rig });
sky?.step(dt, windT, { sail: rig });
}

View File

@ -15,9 +15,9 @@
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 } from './player.sim.js';
import { PlayerSim, STATES, TUNE, clipFor } from './player.sim.js';
export { PlayerSim, STATES, TUNE };
export { PlayerSim, STATES, TUNE, clipFor };
export const CHAR_URL = './models/player_01.glb';
export const ANIM_URL = './models/player_anims.glb';
@ -56,6 +56,69 @@ const _loadGLTF = (loader, url) => new Promise((res, rej) =>
const UP = new THREE.Vector3(0, 1, 0);
const _clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
/**
* Build the player's collision test out of `world.solids` (contracts World).
*
* Shape of the problem, measured in the real yard rather than assumed:
* · `fence` is a GROUP of 37 child meshes whose combined box is the whole 30×20 m yard so one
* box per entry in solids is useless. We flatten to leaf meshes and box each one.
* · the house ROOF is a solid spanning y 2.993.21, i.e. entirely above a 1.72 m head. A flat
* footprint test would wall off the eaves, so every box is filtered by vertical overlap with
* the body and the roof simply drops out.
* Solids are static, so the boxes are computed once. ~44 leaves, distance-pruned no raycast per
* frame. (Lane A's note: the ground is deliberately NOT in solids; heightAt covers it.)
*
* @param {object} world contracts World
* @param {object} [opts] {radius} metres, the player's shoulder radius
* @returns {(x:number,z:number,feetY:number,headY:number)=>{x:number,z:number}}
*/
export function makeSolidCollider(world, opts = {}) {
const radius = opts.radius ?? 0.3;
const boxes = [];
const b = new THREE.Box3();
for (const root of (world && world.solids) || []) {
root.updateWorldMatrix(true, true);
root.traverse((o) => {
if (!o.isMesh) return;
b.setFromObject(o);
if (!isFinite(b.min.x)) return;
boxes.push({ x0: b.min.x, x1: b.max.x, z0: b.min.z, z1: b.max.z, y0: b.min.y, y1: b.max.y });
});
}
const out = { x: 0, z: 0 }; // scratch — copied by the caller immediately, never retained
const r2 = radius * radius;
return function collide(x, z, feetY, headY) {
out.x = x; out.z = z;
for (let i = 0; i < boxes.length; i++) {
const bx = boxes[i];
if (bx.y1 <= feetY + 0.05 || bx.y0 >= headY) continue; // under the eaves / over a low wall
// closest point on the box to the body centre, in XZ
const cx = _clamp(out.x, bx.x0, bx.x1), cz = _clamp(out.z, bx.z0, bx.z1);
const dx = out.x - cx, dz = out.z - cz;
const d2 = dx * dx + dz * dz;
if (d2 >= r2) continue; // clear
if (d2 > 1e-10) { // outside: push along the normal
const d = Math.sqrt(d2);
out.x = cx + (dx / d) * radius;
out.z = cz + (dz / d) * radius;
} else {
// centre is inside the box (spawned in a wall, or shoved through): eject through the nearest
// face rather than picking an arbitrary axis, so you pop out the side you came in.
const l = out.x - bx.x0, rr = bx.x1 - out.x, u = out.z - bx.z0, dn = bx.z1 - out.z;
const m = Math.min(l, rr, u, dn);
if (m === l) out.x = bx.x0 - radius;
else if (m === rr) out.x = bx.x1 + radius;
else if (m === u) out.z = bx.z0 - radius;
else out.z = bx.z1 + radius;
}
}
return out;
};
}
export class PlayerView {
/**
* @param {object} rig {scene, anims} the character
@ -127,7 +190,8 @@ export class PlayerView {
/** Push one sim frame onto the rig. dt drives the mixer only — the sim already stepped. */
sync(sim, dt) {
const st = STATES[sim.state];
this.play(st.clip, st.loop !== false);
// 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);
@ -202,9 +266,13 @@ export async function loadPlayer(scene, opts = {}) {
* @returns {Promise<object>} satisfies checkContract('player', )
*/
export async function createPlayer(scene, world, cameraRig, opts = {}) {
const height = opts.height || 1.72;
const p = await loadPlayer(scene, {
...opts,
height,
groundAt: world && world.heightAt ? (x, z) => world.heightAt(x, z) : undefined,
// built AFTER the world exists so the boxes capture E's real GLBs, not the graybox
collide: opts.collide !== undefined ? opts.collide : makeSolidCollider(world, opts),
start: opts.start || { x: 0, y: 0, z: 6 },
});
const keyboard = new KeyboardInput();
@ -257,7 +325,11 @@ export class KeyboardInput {
const k = this.keys;
const x = (k.has('KeyD') || k.has('ArrowRight') ? 1 : 0) - (k.has('KeyA') || k.has('ArrowLeft') ? 1 : 0);
const z = (k.has('KeyW') || k.has('ArrowUp') ? 1 : 0) - (k.has('KeyS') || k.has('ArrowDown') ? 1 : 0);
return { x, z, run: k.has('ShiftLeft') || k.has('ShiftRight'), camYaw };
return {
x, z, camYaw,
run: k.has('ShiftLeft') || k.has('ShiftRight'),
shelter: k.has('KeyC'), // hold to brace — see STATES.shelter
};
}
dispose() {

View File

@ -11,23 +11,39 @@
/**
* The state machine, as a table (PLAN3D §5-D.5 asks for a table test).
* clip clip name in player_anims.glb
* locked movement input is ignored, and `player.busy` is true
* secs timed states auto-advance to `next` after this long
* clip clip name in player_anims.glb
* carryClip clip to use instead when the player has something in their hands
* locked movement input is ignored, and `player.busy` is true
* secs timed states auto-advance to `next` after this long
* Invariant the selftest enforces: every locked state either has a `next` (so it drains on its own)
* or is released by an external actor. `busy` is the only externally-released state interact.js
* both enters and leaves it, so a dropped release can't strand the player.
* or names an external releaser. `busy` and `shelter` are the released ones interact.js and the
* shelter key each both ENTER and LEAVE their own state, so a dropped release can't strand you.
*/
export const STATES = {
idle: { clip: 'Idle', locked: false, loop: true },
walk: { clip: 'Walk', locked: false, loop: true },
run: { clip: 'Run', locked: false, loop: true },
idle: { clip: 'Idle', carryClip: 'CarryIdle', locked: false, loop: true },
walk: { clip: 'Walk', carryClip: 'Carry', locked: false, loop: true },
run: { clip: 'Run', carryClip: 'Carry', locked: false, loop: true },
busy: { clip: 'Idle', locked: true, loop: true, releasedBy: 'interact' },
shelter: { clip: 'TakeCover', locked: true, loop: true, releasedBy: 'input' },
stumble: { clip: 'StumbleBack', locked: true, loop: false, secs: 0.8, next: 'idle' },
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' },
};
/**
* 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)
* interact.js writes that into `sim.busyClip`. Everything else is the table's `clip`.
* Kept here rather than in player.js so the selftest can assert it without a renderer.
*/
export function clipFor(sim) {
const st = STATES[sim.state];
if (sim.state === 'busy' && sim.busyClip) return sim.busyClip;
if (sim.carrying && st.carryClip) return st.carryClip;
return st.clip;
}
/**
* Tuning. Ported from the 2D prototype's shape (prototype/game.js:250-252), retuned to metres and
* m/s per PLAN3D §1 ("port the behaviour, retune the constants").
@ -61,6 +77,16 @@ export const TUNE = {
knockSustain: 0.5, // s above knockWind before it happens
knockBleed: 2, // exposure drains this many × faster than it fills
pitchSecs: 0.35, // s for the body to swing down / back up (view reads sim.pitch)
// A gust that can't floor you can still break your stride. Sits BELOW knockWind on purpose, so a
// storm reads as: shoved → stumbling → floored, rather than fine-fine-fine-flat-on-your-back.
stumbleGust: 17, // m/s over baseline → you lose your footing (but not your feet)
stumbleCooldown: 3, // s — punctuation, not a stutter: one gust hold must not stumble you twice
// Shelter (hold C): brace and the wind stops owning you. This is the storm's real answer to "the
// 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
};
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
@ -78,6 +104,10 @@ export class PlayerSim {
* @param {object} [opts]
* @param {object} [opts.start] {x,y,z} spawn, metres
* @param {function} [opts.groundAt] (x,z) -> y. Lane A's world.js provides the real one.
* @param {function} [opts.collide] (x,z,feetY,headY) -> {x,z} pushed clear of world.solids.
* Injected, not imported, for the same reason as groundAt: this file must stay renderer-free.
* player.js#makeSolidCollider builds the real one out of world.solids.
* @param {number} [opts.height] body height, metres the collider's vertical span
* @param {object} [opts.tune] overrides for TUNE
*/
constructor(opts = {}) {
@ -90,6 +120,8 @@ export class PlayerSim {
this.state = 'idle';
this.stateT = 0;
this.carrying = null; // contract: player.carrying — one item, hands-full rule
this.busyClip = null; // interact.js names the verb for the current hold (Crank, PickUp…)
this.stumbleCool = 0; // s until a gust may stumble you again
this.events = []; // {type:'state'|'drop'|'knockdown', …} drained by the view/HUD
this.exposure = 0; // s spent above knockWind
@ -100,6 +132,8 @@ export class PlayerSim {
this.knockDir = { x: 0, z: 1 }; // which way the body went down
this.groundAt = opts.groundAt || (() => 0);
this.collide = opts.collide || null;
this.bodyHeight = opts.height || 1.72;
this.tune = { ...TUNE, ...(opts.tune || {}) };
}
@ -168,6 +202,7 @@ export class PlayerSim {
step(dt, t, input = {}, wind = null) {
const T = this.tune;
this.stateT += dt;
this.stumbleCool = Math.max(0, this.stumbleCool - dt);
// --- local wind, and how much of it is gust ---
let wx = 0, wz = 0;
@ -180,11 +215,29 @@ export class PlayerSim {
this.windBase += (ws - this.windBase) * clamp(dt * T.baseTrack, 0, 1);
this.gust = Math.max(0, ws - this.windBase);
// --- sustained extreme wind puts you down (same rule as a sail corner letting go) ---
if (ws > T.knockWind) this.exposure += dt;
// --- 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. ---
const wantShelter = !!input.shelter;
const canShelter = this.state === 'idle' || this.state === 'walk' || this.state === 'run';
if (wantShelter && canShelter) this.setState('shelter', t);
else if (!wantShelter && this.state === 'shelter') this.setState('idle', t);
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;
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);
// --- 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')) {
this.stumbleCool = T.stumbleCooldown;
this.setState('stumble', t);
this.vel.x = this.vel.z = 0;
}
const st = STATES[this.state];
// --- movement ---
@ -215,7 +268,7 @@ export class PlayerSim {
// --- gust shove: pressure ∝ speed², gust only, never while you're already on the ground ---
const grounded = this.state === 'knocked' || this.state === 'getup';
if (!grounded && this.gust > T.shoveGustMin && ws > 1e-3) {
const a = T.shoveK * ws * ws;
const a = T.shoveK * ws * ws * (braced ? T.shelterShoveMult : 1);
this.shove.x += (wx / ws) * a * dt;
this.shove.z += (wz / ws) * a * dt;
}
@ -226,6 +279,15 @@ export class PlayerSim {
this.pos.z += (this.vel.z + this.shove.z) * dt;
this.pos.y = this.groundAt(this.pos.x, this.pos.z);
// Solids: push back out of anything we ended up inside. Pushout is perpendicular to the surface,
// so walking into a wall at an angle keeps its tangential component and slides along it for free
// — no separate slide pass. Velocity is deliberately NOT zeroed: the wind should still be able to
// hold you against a fence, and the pushout wins over it every frame anyway.
if (this.collide) {
const r = this.collide(this.pos.x, this.pos.z, this.pos.y, this.pos.y + this.bodyHeight);
if (r) { this.pos.x = r.x; this.pos.z = r.z; }
}
// --- body pitch: the sim owns it so a knockdown is deterministic and falls DOWNWIND,
// which a canned clip can't do. player.js just reads sim.pitch + sim.knockDir. ---
const wantPitch = this.state === 'knocked' ? 1

View File

@ -147,11 +147,262 @@ export class RiggingSession {
/**
* Prep-phase picking UI.
*
* Deliberately unimplemented: it needs Lane A's camera, renderer canvas and
* anchor markers to raycast against, none of which exist yet. RiggingSession
* above holds all the rules and is fully tested, so this stays a thin
* click-to-session adapter once M0 lands. See THREADS.md.
* Everything above is the rules; this is only the hands. It renders its own
* anchor markers because the yard has none to raycast against world.js builds
* posts and trunks, not pick targets and marker styling is prep-phase UI, so
* it belongs to this lane rather than to the terrain.
*
* Controls: LMB an anchor to rig it, LMB again to cycle its hardware,
* shift-LMB to pull it off for a full refund, [ and ] for tension, S for the
* spare. RMB is left alone that's the camera's orbit. Enter belongs to Lane
* A's phase machine, which calls commit() on the way out of prep.
*
* @param {object} o
* @param {object} o.scene THREE.Scene to hang markers in
* @param {object} o.camera cameraRig.object what we raycast from
* @param {Element} o.domElement renderer.domElement where clicks land
* @param {object} o.world needs world.anchors
* @param {function} o.onCommit (anchorIds, hwChoices, tension) => void Lane A's rigSail
* @param {function} [o.onMessage] (text) => void refusals, for the event ticker
* @param {boolean} [o.panel=true] draw the built-in prep panel; false if hud.js takes it over
*/
export async function createRiggingUI() {
throw new Error('rigging UI lands once Lane A has a camera and anchor markers — see THREADS.md');
export async function createRiggingUI({
scene, camera, domElement, world,
onCommit, onMessage = () => {}, panel = true,
} = {}) {
const THREE = await import('../vendor/three.module.js');
const session = new RiggingSession({ anchors: world.anchors });
// --- markers -----------------------------------------------------------
const group = new THREE.Group();
group.visible = false;
scene.add(group);
const DIM = 0x33424c;
const ringGeo = new THREE.TorusGeometry(0.28, 0.05, 8, 20);
const dotGeo = new THREE.SphereGeometry(0.1, 10, 8);
// What you click is NOT what you see: the ring's tube is 5 cm, which at yard
// distance is a couple of pixels and unhittable. Pick against an invisible
// sphere big enough to mean "that anchor" and let the ring just be the read.
const pickGeo = new THREE.SphereGeometry(0.45, 8, 6);
const pickMat = new THREE.MeshBasicMaterial({ visible: false });
const markers = world.anchors.map((a) => {
const mat = new THREE.MeshBasicMaterial({ color: DIM, transparent: true, opacity: 0.9 });
const ring = new THREE.Mesh(ringGeo, mat);
const dot = new THREE.Mesh(dotGeo, mat);
const hit = new THREE.Mesh(pickGeo, pickMat);
hit.userData.anchorId = a.id;
const holder = new THREE.Group();
holder.add(ring, dot, hit, makeLabel(THREE, a.id.toUpperCase()));
group.add(holder);
return { anchor: a, holder, ring, dot, hit, mat, label: holder.children[3] };
});
const pickTargets = markers.map((m) => m.hit);
// --- quad preview ------------------------------------------------------
// A closed loop through the ring-ordered picks: this is the shape you are
// about to build, drawn before you commit to it.
const previewGeo = new THREE.BufferGeometry();
previewGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(5 * 3), 3));
const preview = new THREE.Line(
previewGeo,
new THREE.LineDashedMaterial({ color: 0xffd27a, dashSize: 0.35, gapSize: 0.25 }),
);
preview.frustumCulled = false;
group.add(preview);
// --- panel -------------------------------------------------------------
const el = panel ? document.createElement('div') : null;
if (el) {
el.id = 'rigging-panel';
el.style.cssText = `position:fixed;top:12px;left:12px;z-index:20;display:none;
background:#0d1418e0;border:1px solid #2c3a44;border-radius:6px;padding:10px 12px;
font:12px/1.65 ui-monospace,Menlo,monospace;color:#dde5ea;min-width:280px;
white-space:pre;pointer-events:none`;
document.body.appendChild(el);
}
let active = false;
let hovered = null;
const ndc = new THREE.Vector2();
const ray = new THREE.Raycaster();
const scratch = new THREE.Vector3();
function pickAt(ev) {
const r = domElement.getBoundingClientRect();
ndc.x = ((ev.clientX - r.left) / r.width) * 2 - 1;
ndc.y = -((ev.clientY - r.top) / r.height) * 2 + 1;
ray.setFromCamera(ndc, camera);
return ray.intersectObjects(pickTargets, false)[0]?.object.userData.anchorId ?? null;
}
function say(result) {
if (result && result.ok === false) onMessage(result.reason);
return result;
}
function onPointerDown(ev) {
if (!active || ev.button !== 0) return; // RMB is the camera's
const id = pickAt(ev);
if (!id) return;
ev.preventDefault();
if (!session.isRigged(id)) say(session.rig(id));
else if (ev.shiftKey) say(session.unrig(id));
else say(session.cycleHardware(id));
refresh();
}
function onPointerMove(ev) {
if (!active) return;
hovered = pickAt(ev);
domElement.style.cursor = hovered ? 'pointer' : '';
}
function onKeyDown(ev) {
if (!active) return;
if (ev.key === '[') session.setTension(session.tension - 0.05);
else if (ev.key === ']') session.setTension(session.tension + 0.05);
else if (ev.key.toLowerCase() === 's') say(session.setSpares(session.spares ? 0 : 1));
else return;
ev.preventDefault();
refresh();
}
domElement.addEventListener('pointerdown', onPointerDown);
domElement.addEventListener('pointermove', onPointerMove);
addEventListener('keydown', onKeyDown);
/** Ground-plane area of the quad as picked, m² — the 70-192 m² problem, visible. */
function quadArea() {
if (session.picks.length !== MAX_CORNERS) return 0;
const p = session.picks.map((k) => world.anchors.find((a) => a.id === k.anchorId).pos);
const tri = (a, b, c) =>
new THREE.Vector3().subVectors(b, a).cross(new THREE.Vector3().subVectors(c, a)).length() * 0.5;
return tri(p[0], p[1], p[2]) + tri(p[0], p[2], p[3]);
}
function refresh() {
if (!el) return;
const s = session.summary;
const rows = world.anchors.map((a) => {
const pick = session.pickOf(a.id);
if (!pick) return ` ${a.id.padEnd(3)} ${a.type.padEnd(6)}`;
const weak = s.weakest === a.id && session.picks.length > 1 ? ' <- weak link' : '';
return ` ${a.id.padEnd(3)} ${pick.hw.name.padEnd(14)} ${(pick.hw.rating / 1000).toFixed(1)} kN $${pick.hw.cost}${weak}`;
});
const area = quadArea();
el.textContent = [
`PREP — rig four corners $${s.budget} left`,
`tension ${s.tension.toFixed(2)} spare x${s.spares}${area ? ` sail ${area.toFixed(0)} m2` : ''}`,
'',
...rows,
'',
s.canStart ? 'ENTER to start the storm' : `pick ${MAX_CORNERS - session.picks.length} more corner(s)`,
'click anchor: rig / cycle hw shift-click: remove',
'[ ] tension S spare RMB orbit',
].join('\n');
}
const ui = {
session,
get summary() { return { ...session.summary, area: quadArea() }; },
get canStart() { return session.canStart; },
get active() { return active; },
/** Lane A: call on phaseChange — markers and clicks are prep-only. */
setActive(on) {
active = !!on;
group.visible = active;
if (el) el.style.display = active ? 'block' : 'none';
if (!active) domElement.style.cursor = '';
if (active) refresh();
return ui;
},
/** Markers ride the anchors, so a tree corner wanders before you even rig it. */
update(dt, t) {
if (!active) return;
for (const m of markers) {
const p = m.anchor.sway ? m.anchor.sway(t) : m.anchor.pos;
m.holder.position.set(p.x, p.y, p.z);
m.holder.quaternion.copy(camera.quaternion); // rings face the player
const pick = session.pickOf(m.anchor.id);
m.mat.color.setHex(pick ? pick.hw.color : DIM);
const s = (hovered === m.anchor.id ? 1.35 : 1) * (pick ? 1.15 : 1);
m.ring.scale.setScalar(s);
m.label.visible = !!pick || hovered === m.anchor.id;
}
const pos = previewGeo.attributes.position;
if (session.picks.length >= 2) {
preview.visible = true;
const n = session.picks.length;
for (let i = 0; i <= n; i++) {
const k = session.picks[i % n];
const a = world.anchors.find((x) => x.id === k.anchorId);
const p = a.sway ? a.sway(t) : a.pos;
scratch.set(p.x, p.y, p.z);
pos.setXYZ(i, scratch.x, scratch.y, scratch.z);
}
// degenerate tail so a partial pick doesn't draw a stale segment
for (let i = session.picks.length + 1; i < 5; i++) pos.setXYZ(i, scratch.x, scratch.y, scratch.z);
pos.needsUpdate = true;
previewGeo.setDrawRange(0, session.picks.length + 1);
preview.computeLineDistances();
} else {
preview.visible = false;
}
},
/** Hand the finished rig to Lane A's rigSail. Returns false if it isn't four corners. */
commit() {
if (!session.canStart) {
onMessage(`rig ${MAX_CORNERS - session.picks.length} more corner(s) first`);
return false;
}
onCommit(
session.picks.map((p) => p.anchorId),
session.picks.map((p) => p.hw),
session.tension,
);
return true;
},
dispose() {
domElement.removeEventListener('pointerdown', onPointerDown);
domElement.removeEventListener('pointermove', onPointerMove);
removeEventListener('keydown', onKeyDown);
scene.remove(group);
ringGeo.dispose(); dotGeo.dispose(); previewGeo.dispose();
preview.material.dispose();
for (const m of markers) { m.mat.dispose(); m.label.material.map?.dispose(); m.label.material.dispose(); }
el?.remove();
},
};
refresh();
return ui;
}
/** A cheap canvas-texture nameplate, so anchors read as h1/t2/p1 rather than dots. */
function makeLabel(THREE, text) {
const c = document.createElement('canvas');
c.width = 128; c.height = 64;
const g = c.getContext('2d');
g.font = 'bold 40px ui-monospace, Menlo, monospace';
g.textAlign = 'center';
g.textBaseline = 'middle';
g.lineWidth = 6;
g.strokeStyle = '#0d1418';
g.strokeText(text, 64, 32);
g.fillStyle = '#dde5ea';
g.fillText(text, 64, 32);
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
map: new THREE.CanvasTexture(c), depthTest: false, transparent: true,
}));
sprite.position.set(0, 0.55, 0);
sprite.scale.set(0.8, 0.4, 1);
return sprite;
}

View File

@ -18,10 +18,17 @@
* appears in createSailView(), which is imported lazily.
*/
import * as THREE from '../vendor/three.module.js';
import { Emitter, FIXED_DT, HARDWARE } from './contracts.js';
export { HARDWARE };
/**
* What a carried spare re-rigs a corner with. The prep phase sells exactly one
* kind ("spare shackle, $15"), so repair() has no hardware argument to take.
*/
const SPARE_HW = HARDWARE[1];
// ---------- sim tunables ----------
const SIM_DT = FIXED_DT; // sim always steps at a fixed rate; step() accumulates
const MAX_SUBSTEPS = 5; // spiral-of-death guard when the frame hitches
@ -49,6 +56,10 @@ const COMP_COMPRESS = 1 / (FABRIC_K * K_COMPRESS);
const COMP_BEND = 1 / (FABRIC_K * K_BEND);
const VEL_DAMP = 0.995; // light; relative-wind drag supplies the real damping
// ---------- debris (SPRINT2 decision 5) ----------
const DEBRIS_RESTITUTION = 0.1; // a wheelie bin into shade cloth barely bounces
const DEBRIS_SKIN = 0.06; // contact margin, ~cloth thickness
// ---------- failure ----------
const OVERLOAD_SECS = 0.4; // prototype: 0.4 s sustained overload before it lets go
const OVERLOAD_RECOVER = 2.0; // prototype: overload timer bleeds off at 2x
@ -114,6 +125,11 @@ export class SailRig {
this._acc = 0;
// scratch, reused every face to keep the hot loop allocation-free
this._probe = { x: 0, y: 0, z: 0 };
// Lane C's wind.sample(pos, t, out) takes an out-vector so we don't allocate
// one per face per substep — 162 faces at 60 Hz is ~9.7k throwaway Vector3s
// a second otherwise. A stub wind that ignores `out` still works: we read
// the RETURN value, not this.
this._windOut = new THREE.Vector3();
}
/**
@ -306,16 +322,20 @@ export class SailRig {
* so a variable-rate render loop and a fast-forwarded selftest produce
* identical traces. Never reads a clock.
*
* @param {number} dt seconds elapsed since last call
* @param {object} wind { sample(pos, t) -> {x,y,z} }
* @param {number} t world time, seconds
* @param {number} dt seconds elapsed since last call
* @param {object} wind { sample(pos, t) -> {x,y,z} }
* @param {number} t world time, seconds
* @param {object} [debris] Lane C's debris module, or anything with `.pieces`.
* Optional the cloth runs fine without a storm's
* worth of crates in it.
*/
step(dt, wind, t) {
step(dt, wind, t, debris = null) {
if (!this.rigged) return;
const pieces = debris ? (debris.pieces ?? debris) : null;
this._acc += dt;
let n = 0;
while (this._acc >= SIM_DT && n < MAX_SUBSTEPS) {
this._substep(SIM_DT, wind, this.t);
this._substep(SIM_DT, wind, this.t, pieces);
this._acc -= SIM_DT;
this.t += SIM_DT;
n++;
@ -323,8 +343,9 @@ export class SailRig {
if (n === MAX_SUBSTEPS) this._acc = 0; // dropped frames: don't try to catch up
}
_substep(dt, wind, t) {
_substep(dt, wind, t, pieces) {
this._accumulateWind(wind, t, dt);
if (pieces && pieces.length) this._applyDebris(pieces, dt);
this._integrate(dt);
this.lambda.fill(0); // XPBD multipliers are per-substep
for (let i = 0; i < RELAX_ITERS; i++) this._relax(dt * dt);
@ -357,7 +378,7 @@ export class SailRig {
probe.x = (pos[ia] + pos[ib] + pos[ic]) / 3;
probe.y = (pos[ia + 1] + pos[ib + 1] + pos[ic + 1]) / 3;
probe.z = (pos[ia + 2] + pos[ib + 2] + pos[ic + 2]) / 3;
const w = wind.sample(probe, t);
const w = wind.sample(probe, t, this._windOut);
// Relative wind, not absolute: as the cloth accelerates downwind the load
// bleeds off by itself. This is what stops flogging from exploding.
@ -382,6 +403,88 @@ export class SailRig {
}
}
/**
* Sphere-vs-cloth impulses for Lane C's debris (SPRINT2 decision 5, option b).
*
* The exchange is symmetric: every newton-second the cloth takes out of a
* crate, the crate loses. That's the point of the decision one integrator
* does the momentum bookkeeping, so a crate punching through a sail slows
* down by exactly as much as it speeds the cloth up. Asserted in
* sail.selftest.js.
*
* Pinned corners are the deliberate exception: they have invMass 0, so a
* crate that hits one bounces off and the momentum goes into the house. That
* is correct the anchor is bolted to a wall and it's why the momentum
* assert uses an interior hit.
*
* @param {Array} pieces debris.pieces {x,y,z,vx,vy,vz,r,mass}
*/
_applyDebris(pieces, dt) {
const pos = this.pos, prev = this.prev, im = this.invMass;
for (const p of pieces) {
if (p.alive === false || !Number.isFinite(p.mass) || p.mass <= 0) continue;
// Swept: main.js steps the sail BEFORE the debris, so these positions are
// a frame stale, and a 0.3 m crate at 25 m/s covers 0.42 m in a frame —
// enough to pass clean between cloth nodes. Growing the contact radius by
// the piece's travel catches both the lag and the tunnelling.
const speed = Math.hypot(p.vx, p.vy, p.vz);
const solid = p.r + DEBRIS_SKIN;
const reach = solid + speed * dt;
const reachSq = reach * reach;
const wPiece = 1 / p.mass;
let jx = 0, jy = 0, jz = 0, hits = 0;
for (let n = 0; n < im.length; n++) {
const i = n * 3;
const dx = pos[i] - p.x, dy = pos[i + 1] - p.y, dz = pos[i + 2] - p.z;
const dsq = dx * dx + dy * dy + dz * dz;
if (dsq > reachSq || dsq < 1e-12) continue;
const d = Math.sqrt(dsq);
const nx = dx / d, ny = dy / d, nz = dz / d; // piece centre -> node
// node velocity, read out of verlet
const vnx = (pos[i] - prev[i]) / dt;
const vny = (pos[i + 1] - prev[i + 1]) / dt;
const vnz = (pos[i + 2] - prev[i + 2]) / dt;
const vrel = (vnx - p.vx) * nx + (vny - p.vy) * ny + (vnz - p.vz) * nz;
if (vrel > 0) continue; // already separating — don't glue them together
const wNode = im[n];
const denom = wNode + wPiece;
if (denom < 1e-12) continue;
const j = (-(1 + DEBRIS_RESTITUTION) * vrel) / denom;
hits++;
// node takes +j along the contact normal; verlet stores velocity as a
// position difference, so the impulse goes in by moving `prev`
prev[i] -= nx * j * wNode * dt;
prev[i + 1] -= ny * j * wNode * dt;
prev[i + 2] -= nz * j * wNode * dt;
// ...and the piece takes exactly -j. This is the conservation.
jx -= nx * j; jy -= ny * j; jz -= nz * j;
// Depenetrate free nodes by moving pos AND prev together, so pushing
// the cloth off the crate doesn't secretly inject velocity.
if (wNode > 0 && d < solid) {
const push = solid - d;
pos[i] += nx * push; prev[i] += nx * push;
pos[i + 1] += ny * push; prev[i + 1] += ny * push;
pos[i + 2] += nz * push; prev[i + 2] += nz * push;
}
}
if (hits) {
p.vx += jx * wPiece; p.vy += jy * wPiece; p.vz += jz * wPiece;
this.events.emit('debrisHit', {
type: 'debrisHit', piece: p, nodes: hits,
impulse: Math.hypot(jx, jy, jz), t: this.t,
});
}
}
}
_integrate(dt) {
const pos = this.pos, prev = this.prev, F = this.force, im = this.invMass;
const dt2 = dt * dt;
@ -503,8 +606,43 @@ export class SailRig {
if (this._dirtyRest) { this._applyRestLengths(); this._dirtyRest = false; }
}
// --- Lane D's seam (SPRINT2 decision 4) --------------------------------
// D landed first and duck-typed these against the rig, so B conforms to D's
// spelling rather than the other way round. Thin aliases on purpose: the
// behaviour lives in repairCorner/trimCorner, these just match the call sites
// in interact.js and are what contracts.js promises.
/**
* Re-rig corner `i` with the spare the player was carrying. The spare is the
* "$15 spare shackle" the prep phase sells, so it re-rigs at shackle grade
* which can be an UPGRADE on a corner that blew a carabiner, and a downgrade
* on one that blew a rated shackle. That's the prototype's behaviour and it's
* a real decision about which corner you run back to.
* @param {number} i
*/
repair(i) { this.repairCorner(i, SPARE_HW); }
/**
* Per-corner turnbuckle. @param {number} i @param {number} delta ±, clamped 0.851.15.
*/
trim(i, delta) { this.trimCorner(i, delta); }
/**
* Live world position of corner `i`, as a FRESH vector a blown corner's node
* is flying, so Lane D's prompt has to chase it rather than sit on the anchor.
* Fresh (not shared scratch) because interact.js holds the result across the
* frame and two corners are read back to back.
* @param {number} i
* @returns {THREE.Vector3|null}
*/
cornerPos(i) {
if (!this.rigged || !this.corners[i]) return null;
const n = this.cornerIdx[i] * 3;
return new THREE.Vector3(this.pos[n], this.pos[n + 1], this.pos[n + 2]);
}
/** Re-rig a blown corner with fresh hardware. Lane D's hold-E repair calls this. */
repairCorner(index, hw = HARDWARE[1]) {
repairCorner(index, hw = SPARE_HW) {
const c = this.corners[index];
if (!c || !c.broken) return false;
c.broken = false;
@ -539,8 +677,10 @@ export class SailRig {
* @param {object} rect world.gardenBed shape: CENTRE (x,z), size (w,d), metres
* @param {object} sunDir world.sunDir unit vector from the ground TOWARD
* the sun. A hit means shaded. Defaults to overhead.
* @param {function} heightAt world.heightAt rays start at the real ground.
* Defaults to a flat y=0, which is only right for tests.
*/
coverageOver(rect, sunDir = { x: 0, y: 1, z: 0 }) {
coverageOver(rect, sunDir = { x: 0, y: 1, z: 0 }, heightAt = null) {
if (!this.rigged) return 0;
const len = Math.hypot(sunDir.x, sunDir.y, sunDir.z) || 1;
const dx = sunDir.x / len, dy = sunDir.y / len, dz = sunDir.z / len;
@ -553,7 +693,8 @@ export class SailRig {
// rect is centre-and-size, so samples straddle (rect.x, rect.z)
const ox = rect.x + ((i + 0.5) / COLS - 0.5) * rect.w;
const oz = rect.z + ((j + 0.5) / ROWS - 0.5) * rect.d;
if (this._rayHitsSail(ox, 0, oz, dx, dy, dz)) hit++;
const oy = heightAt ? heightAt(ox, oz) : 0;
if (this._rayHitsSail(ox, oy, oz, dx, dy, dz)) hit++;
}
}
return hit / (COLS * ROWS);

View File

@ -12,9 +12,57 @@
import { SailRig } from './sail.js';
import { HARDWARE, FIXED_DT, createStubWind, rng } from './contracts.js';
import { createWindField } from './weather.core.js';
const SIM_DT = FIXED_DT;
// ---------- real storm wind (SPRINT2 B-4) ----------
// The §7 gate used to run on the local stub, which is uniform, horizontal and
// tuned by nobody. These load the storms design actually ships and drive the
// cloth with them. weather.core.js is pure and import-free, so the same code
// path works in node and in Lane A's selftest.html; only reading the JSON off
// disk differs, and weather.js's own loadStorm can't help there (its STORM_DIR
// is a file:// URL under node, which fetch won't open).
async function loadStormDef(name) {
const url = new URL(`../data/storms/${name}.json`, import.meta.url);
if (typeof process !== 'undefined' && process.versions?.node) {
const { readFile } = await import('node:fs/promises');
return JSON.parse(await readFile(url, 'utf8'));
}
return (await fetch(url)).json();
}
const STORM_02 = await loadStormDef('storm_02_wildnight');
/** A Wind over a real storm def. Same field the game flies. */
function realWind(def = STORM_02, opts = {}) {
const field = createWindField(def, opts);
const out = { x: 0, y: 0, z: 0 };
return {
sample(pos, t) { return field.vecAt(pos.x, pos.z, t, out); },
speedAt(t) { field.vecAt(0, 0, t, out); return Math.hypot(out.x, out.z); },
gustTelegraph: (t) => field.gustTelegraph?.(t) ?? null,
};
}
/** 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],
].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
// a cloth assert fail for a reason that isn't the cloth. Sway is exercised in
// the game and in a.test.
return { id, type, pos, sway: () => pos };
});
const yardRig = (ids, hw, tension) =>
new SailRig({ anchors: YARD, gridN: 10 })
.attach(ids, Array.isArray(hw) ? hw : Array(4).fill(hw), tension);
// ---------- deterministic stub wind ----------
// contracts.js ships createStubWind(), and the integration test below uses it.
// This local one exists only because the thesis needs the wind DIRECTION swept,
@ -66,10 +114,17 @@ const FOOT = [
export const HEIGHTS_FLAT = [4.0, 4.0, 2.5, 2.5]; // y linear in z -> one plane
export const HEIGHTS_HYPAR = [4.0, 2.5, 4.0, 2.5]; // opposite corners up/down -> saddle
/** Anchors shaped like contracts.js Anchor: sway(t) is the ABSOLUTE position. */
export const makeAnchors = (heights) =>
/**
* Anchors shaped like contracts.js Anchor: sway(t) is the ABSOLUTE position.
* `theta` spins the footprint about the yard's Y axis which is how you sweep
* wind direction against a real storm, whose direction curve you don't get to
* choose. Rotating the rig under the wind and rotating the wind over the rig are
* the same experiment; only one of them is available with authored storm JSON.
*/
export const makeAnchors = (heights, theta = 0) =>
FOOT.map((f, i) => {
const pos = { x: f.x, y: heights[i], z: f.z };
const c = Math.cos(theta), s = Math.sin(theta);
const pos = { x: f.x * c - f.z * s, y: heights[i], z: f.x * s + f.z * c };
return { id: `a${i}`, type: 'post', pos, sway: () => pos };
});
@ -332,6 +387,272 @@ test('break and repair emit on the events Emitter', () => {
return `repaired corner back to ${kN(r.corners[0].load)}, ${seen.length} event(s) emitted`;
});
// --- SPRINT2 decision 4: the seam Lane D already calls ---------------------
test('decision 4: repair(i) re-rigs a blown corner with the spare', () => {
const w = constantWind({ x: 0, y: 0, z: 20 });
const r = rig(HEIGHTS_HYPAR, { hw: HARDWARE[0] });
runStorm(r, w, 4);
r.corners[0].broken = true;
r._repin(r.t);
// exactly Lane D's interact.js call: no hardware argument, return ignored
r.repair(0);
assert(!r.corners[0].broken, 'repair(0) should have re-rigged the corner');
assert(r.corners[0].hw === HARDWARE[1], `spare should re-rig at shackle grade, got ${r.corners[0].hw.name}`);
assert(r.invMass[r.cornerIdx[0]] === 0, 'repaired corner should be pinned again');
runStorm(r, w, 3);
assert(r.corners[0].load > 100, `repaired corner only pulling ${kN(r.corners[0].load)}`);
return `repair(0) -> ${r.corners[0].hw.name}, back to ${kN(r.corners[0].load)}`;
});
test('decision 4: repair(i) on an intact corner is a no-op', () => {
const r = rig(HEIGHTS_HYPAR, { hw: HARDWARE[2] });
runStorm(r, constantWind({ x: 0, y: 0, z: 12 }), 2);
const hw = r.corners[1].hw;
r.repair(1); // D gates on corner.broken, but the rig must not trust that
assert(r.corners[1].hw === hw, 'repairing an intact corner downgraded its hardware');
return 'intact corner untouched';
});
test('decision 4: trim(i, delta) tightens one corner only', () => {
const r = rig(HEIGHTS_HYPAR);
r.trim(0, +0.1);
assert(Math.abs(r.corners[0].trim - 1.1) < 1e-9, `corner 0 trim ${r.corners[0].trim}`);
assert(r.corners[1].trim === 1.0, 'trim leaked onto a neighbour');
for (let i = 0; i < 40; i++) r.trim(0, +0.1); // Lane D can hold the key down
assert(r.corners[0].trim <= 1.15 + 1e-9, `trim ran past its clamp: ${r.corners[0].trim}`);
return `trim clamps at ${r.corners[0].trim.toFixed(2)}, neighbours unmoved`;
});
test('decision 4: cornerPos(i) is live, fresh, and chases a flogging corner', () => {
const w = makeStubWind({ seed: 11, stormLen: 90 });
const r = rig(HEIGHTS_FLAT, { hw: HARDWARE[0], tension: 1.3 });
const anchor = r.corners[0].anchor.pos;
const p0 = r.cornerPos(0);
assert(Math.hypot(p0.x - anchor.x, p0.y - anchor.y, p0.z - anchor.z) < 1e-6,
'an intact corner should report its anchor position');
assert(r.cornerPos(0) !== r.cornerPos(0), 'cornerPos must return a FRESH vector, not shared scratch');
// blow it, then confirm the prompt would follow the flying corner
r.corners[0].broken = true;
r._repin(r.t);
runStorm(r, w, 6);
const p1 = r.cornerPos(0);
const drift = Math.hypot(p1.x - anchor.x, p1.y - anchor.y, p1.z - anchor.z);
assert(drift > 0.3, `blown corner's prompt only moved ${drift.toFixed(2)} m off the anchor`);
assert(new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT) }).cornerPos(0) === null,
'cornerPos on an unrigged rig should be null, not a throw');
return `prompt tracks the blown corner ${drift.toFixed(2)} m off its anchor`;
});
// --- SPRINT2 decision 5: debris -------------------------------------------
const crate = (over) => ({ x: 0, y: 3.25, z: 0, vx: 0, vy: 0, vz: 14, r: 0.3, mass: 9, alive: true, ...over });
test('decision 5: a crate hitting the sail conserves momentum', () => {
const r = rig(HEIGHTS_FLAT);
runStorm(r, makeStubWind({ calm: true }), 4); // settle, so the cloth isn't ringing
// aimed at the belly, not a corner: a pinned corner would (correctly) dump
// momentum into the house and there'd be nothing to conserve
const mid = r.N * Math.floor(r.N / 2) + Math.floor(r.N / 2);
const p = crate({ x: r.pos[mid * 3], y: r.pos[mid * 3 + 1] - 0.25, z: r.pos[mid * 3 + 2], vy: 6, vz: 0 });
const clothP = () => {
let x = 0, y = 0, z = 0;
for (let n = 0; n < r.invMass.length; n++) {
if (r.invMass[n] === 0) continue; // pinned: its momentum belongs to the house
const i = n * 3;
x += (r.pos[i] - r.prev[i]) / SIM_DT * r.nodeMass;
y += (r.pos[i + 1] - r.prev[i + 1]) / SIM_DT * r.nodeMass;
z += (r.pos[i + 2] - r.prev[i + 2]) / SIM_DT * r.nodeMass;
}
return { x, y, z };
};
const total = () => {
const c = clothP();
return { x: c.x + p.vx * p.mass, y: c.y + p.vy * p.mass, z: c.z + p.vz * p.mass };
};
const before = total();
r._applyDebris([p], SIM_DT);
const after = total();
const drift = Math.hypot(after.x - before.x, after.y - before.y, after.z - before.z);
const scale = Math.hypot(before.x, before.y, before.z);
assert(scale > 1, 'test crate carries no momentum to conserve');
assert(drift / scale < 0.01, `momentum drifted ${drift.toFixed(3)} of ${scale.toFixed(1)} kg·m/s (${(drift / scale * 100).toFixed(1)}%)`);
assert(p.vy < 6, `the crate should have LOST speed to the cloth, still at ${p.vy.toFixed(2)} m/s`);
return `crate ${scale.toFixed(0)} kg·m/s, exchange conserves to ${(drift / scale * 100).toFixed(3)}%`;
});
test('decision 5: a crate through the sail shoves the cloth and emits', () => {
const r = rig(HEIGHTS_FLAT);
runStorm(r, makeStubWind({ calm: true }), 4);
const hits = [];
r.events.on('debrisHit', (e) => hits.push(e));
const mid = r.N * Math.floor(r.N / 2) + Math.floor(r.N / 2);
const before = r.pos[mid * 3 + 1];
const p = crate({ x: r.pos[mid * 3], y: r.pos[mid * 3 + 1] - 0.6, z: r.pos[mid * 3 + 2], vy: 12, vz: 0 });
const v0 = p.vy;
// Peak, not final: the crate crosses the cloth in about three frames and the
// membrane springs back well inside the run, so sampling the end measures the
// recovery rather than the punch.
const wind = makeStubWind({ calm: true });
let peak = before;
for (let i = 0; i < 30; i++) {
r.step(SIM_DT, wind, i * SIM_DT, { pieces: [p] });
p.y += p.vy * SIM_DT; p.z += p.vz * SIM_DT;
peak = Math.max(peak, r.pos[mid * 3 + 1]);
}
assert(hits.length > 0, 'crate passed through the cloth without a single contact');
assert(peak > before + 0.05, `belly only lifted ${(peak - before).toFixed(3)} m — the crate went straight through`);
assert(p.vy < v0, `crate left at ${p.vy.toFixed(2)} m/s, never paid for the punch (entered at ${v0})`);
return `${hits.length} contacts, belly punched ${(peak - before).toFixed(2)} m, crate ${v0} -> ${p.vy.toFixed(1)} m/s`;
});
test('decision 5: no debris and empty debris are both fine', () => {
const w = makeStubWind({ seed: 2, stormLen: 20 });
const a = rig(HEIGHTS_HYPAR), b = rig(HEIGHTS_HYPAR);
for (let i = 0; i < 600; i++) {
a.step(SIM_DT, w, i * SIM_DT); // Lane A's 3-arg call still works
b.step(SIM_DT, makeStubWind({ seed: 2, stormLen: 20 }), i * SIM_DT, { pieces: [] });
}
for (let k = 0; k < 4; k++) {
assert(Math.abs(a.corners[k].load - b.corners[k].load) < 1e-9,
'an empty debris list changed the sim');
}
return 'empty and absent debris both no-op';
});
// --- SPRINT2 B-4: the §7 gate, against the wind the game actually flies ------
// PLAN3D §7: "A flat drum-tight cheap rig MUST cascade-fail in storm_02; a
// well-twisted mixed rig with one mid-storm repair MUST be survivable." The old
// version of this proved it against my own stub wind, which is uniform,
// horizontal and tuned by nobody — so it proved the cloth was self-consistent,
// not that the game works. This is the real storm JSON, the real yard, and the
// same two rig shapes Lane C measured decision 3 against.
test('§7 gate on REAL storm_02: cheap flat rig cascades', () => {
const rig = yardRig(['h1', 'h3', 'p2', 'p1'], HARDWARE[0], 1.3); // drum-tight carabiners
const broke = [];
rig.events.on('break', (e) => broke.push(e));
const w = realWind();
for (let i = 0; i < Math.round(STORM_02.duration / SIM_DT); i++) rig.step(SIM_DT, w, i * SIM_DT);
const lost = rig.corners.filter((c) => c.broken).length;
assert(lost >= 2, `flat drum-tight carabiner rig only lost ${lost}/4 in the real storm_02 — no cascade`);
return `lost ${lost}/4, first at t=${broke[0].t.toFixed(1)}s (${broke[0].anchorId}, ${broke[0].hw})`;
});
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 w = realWind();
let peak = 0;
for (let i = 0; i < Math.round(STORM_02.duration / SIM_DT); i++) {
rig.step(SIM_DT, w, i * SIM_DT);
peak = Math.max(peak, rig.maxLoad());
}
const lost = rig.corners.filter((c) => c.broken).length;
assert(lost === 0, `well-twisted mixed rig lost ${lost}/4 in storm_02 — §7 says it must be survivable`);
return `all 4 corners held, peak ${kN(peak)} (area ${rig.area.toFixed(0)} m2)`;
});
test('§7 gate on REAL storm_02: twisted rig + one repair on the dodgy corner', () => {
// The other half of §7: "a well-twisted mixed rig with ONE mid-storm repair
// MUST be survivable". The twisted rig above already survives outright, so
// 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.
const rig = yardRig(
['h1', 't2', 'p1', 't1'],
[HARDWARE[2], HARDWARE[0], HARDWARE[1], HARDWARE[1]],
0.85,
);
const w = realWind();
let repairs = 0;
rig.events.on('break', () => { /* seen below; repairing inside the emit would reenter step */ });
for (let i = 0; i < Math.round(STORM_02.duration / SIM_DT); i++) {
rig.step(SIM_DT, w, i * SIM_DT);
if (repairs === 0) {
const k = rig.corners.findIndex((c) => c.broken);
if (k >= 0) { rig.repair(k); repairs++; }
}
}
const lost = rig.corners.filter((c) => c.broken).length;
if (repairs === 0) {
// A vacuous pass is worse than a skip: "nothing broke" would let this go
// 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';
}
assert(lost <= 1, `after one repair the rig still lost ${lost}/4 — not survivable`);
return `${repairs} repair, finished ${4 - lost}/4 corners intact`;
});
// --- 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.
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) => {
let worst = 0;
for (let k = 0; k < 8; k++) {
const r = new SailRig({ anchors: makeAnchors(heights, (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
worst = Math.max(worst, runStorm(r, realWind(), STORM_02.duration));
}
return worst;
};
const pitched = sweep(HEIGHTS_FLAT);
const horizontal = sweep(FLAT_H);
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})`;
});
test('runs against the shared contracts.js stub wind', () => {
// Proves the rig eats the sanctioned Wind implementation, not just my local
// stub — so nothing surprises us when Lane C's weather.js drops in.

View File

@ -22,6 +22,112 @@ const CALM_SKY = new THREE.Color(0x9fc4e8);
const STORM_SKY = new THREE.Color(0x2a2f3a);
const NIGHT_SKY = new THREE.Color(0x11141c);
// ------------------------------------------------------------ rain shadow
/**
* Where the sail is keeping the ground dry (SPRINT2 §Lane C.3).
*
* This is the RAIN shadow, not the sun shadow. Rain arrives along the wind, so
* the dry patch sits downwind of the cloth and slides across the yard as the
* wind swings at the southerly change it walks right off the garden, which is
* free drama and the honest physics.
*
* Cheap on purpose: ray-testing 3 k drops against 162 triangles every frame is
* ~486 k intersections for an effect nobody inspects closely. Instead we project
* the sail's triangles ALONG the rain onto the ground and rasterise them into a
* coarse grid, a few times a second the cloth moves slowly next to the rain.
* Per-drop cost is then one projection and one array read.
*
* Reads `rig.pos`/`rig.tris`, which are already the surface Lane A's sail view
* consumes, so this needs nothing new from Lane B.
*/
export class RainShadow {
constructor(o = {}) {
this.n = o.cells ?? 64; // ~0.56 m over a 36 m span
this.half = o.half ?? 18;
this.groundY = o.groundY ?? 0;
this.ceil = new Float32Array(this.n * this.n); // sail height per cell, 0 = open sky
this.live = false;
this.dx = 0; this.dy = -1; this.dz = 0;
}
_idx(gx, gz) {
const i = Math.floor(((gx + this.half) / (this.half * 2)) * this.n);
const j = Math.floor(((gz + this.half) / (this.half * 2)) * this.n);
if (i < 0 || j < 0 || i >= this.n || j >= this.n) return -1;
return j * this.n + i;
}
/** @param {object} rig Lane B's SailRig @param {number} dx,dy,dz unit rain direction */
update(rig, dx, dy, dz) {
this.live = false;
if (!rig || !rig.pos || !rig.tris || dy > -1e-3) return; // rain must fall
this.ceil.fill(0);
this.dx = dx; this.dy = dy; this.dz = dz;
const pos = rig.pos, tris = rig.tris, cellW = (this.half * 2) / this.n;
const gx = [0, 0, 0], gz = [0, 0, 0], gy = [0, 0, 0];
for (let i = 0; i < tris.length; i += 3) {
for (let k = 0; k < 3; k++) {
const a = tris[i + k] * 3;
const vy = pos[a + 1];
const tt = (vy - this.groundY) / -dy; // slide down the rain to the ground
gx[k] = pos[a] + dx * tt;
gz[k] = pos[a + 2] + dz * tt;
gy[k] = vy;
}
const d = (gz[1] - gz[2]) * (gx[0] - gx[2]) + (gx[2] - gx[1]) * (gz[0] - gz[2]);
if (Math.abs(d) < 1e-9) continue; // degenerate once projected
const minX = Math.min(gx[0], gx[1], gx[2]), maxX = Math.max(gx[0], gx[1], gx[2]);
const minZ = Math.min(gz[0], gz[1], gz[2]), maxZ = Math.max(gz[0], gz[1], gz[2]);
for (let px = minX; px <= maxX + cellW; px += cellW) {
for (let pz = minZ; pz <= maxZ + cellW; pz += cellW) {
const c = this._idx(px, pz);
if (c < 0) continue;
// barycentric, with a little slop so cracks between tris don't leak rain
const l1 = ((gz[1] - gz[2]) * (px - gx[2]) + (gx[2] - gx[1]) * (pz - gz[2])) / d;
const l2 = ((gz[2] - gz[0]) * (px - gx[2]) + (gx[0] - gx[2]) * (pz - gz[2])) / d;
const l3 = 1 - l1 - l2;
if (l1 < -0.05 || l2 < -0.05 || l3 < -0.05) continue;
const y = l1 * gy[0] + l2 * gy[1] + l3 * gy[2];
if (y > this.ceil[c]) this.ceil[c] = y;
}
}
this.live = true;
}
}
/** Has a drop here already been stopped by the cloth? */
occluded(x, y, z) {
if (!this.live) return false;
const tt = (y - this.groundY) / -this.dy;
const c = this._idx(x + this.dx * tt, z + this.dz * tt);
if (c < 0) return false;
const ceil = this.ceil[c];
return ceil > 0 && y < ceil; // above the cloth it hasn't hit yet
}
/** 0..1 of a ground rect under cover. Same rect shape as sailRig.coverageOver. */
fractionOver(rect, cols = 6, rows = 4) {
if (!this.live) return 0;
let hit = 0;
for (let i = 0; i < cols; i++) {
for (let j = 0; j < rows; j++) {
const x = rect.x + ((i + 0.5) / cols - 0.5) * rect.w;
const z = rect.z + ((j + 0.5) / rows - 0.5) * rect.d;
const c = this._idx(x, z);
if (c >= 0 && this.ceil[c] > 0) hit++;
}
}
return hit / (cols * rows);
}
}
/** Rain velocity, m/s. One definition, used by the drops and by the shadow. */
function rainVelocity(w, intensity, out) {
return out.set(w.x * 0.55, -(9 + intensity * 4), w.z * 0.55);
}
// ---------------------------------------------------------------- rain
function createRain(opts) {
const max = opts.maxDrops ?? 3000;
@ -55,22 +161,29 @@ function createRain(opts) {
const q = new THREE.Quaternion();
const up = new THREE.Vector3(0, 1, 0);
const vel = new THREE.Vector3();
const unit = new THREE.Vector3();
const scale = new THREE.Vector3(1, 1, 1);
const zero = new THREE.Vector3();
// zero-scale: an instance that renders to nothing
const HIDDEN = new THREE.Matrix4().makeScale(0, 0, 0);
return {
mesh,
/** @param {THREE.Vector3} camPos @param {THREE.Vector3} w local wind */
step(dt, camPos, w, intensity) {
/**
* @param {THREE.Vector3} camPos
* @param {THREE.Vector3} w local wind
* @param {RainShadow} [shadow] drops under the cloth are not drawn
*/
step(dt, camPos, w, intensity, shadow) {
const n = Math.floor(max * clamp01(intensity));
mesh.count = n;
if (n === 0) return;
const fall = 9 + intensity * 4;
// rain leans into the wind; that lean IS the readout of how hard it's blowing
vel.set(w.x * 0.55, -fall, w.z * 0.55);
rainVelocity(w, intensity, vel);
const fall = -vel.y;
const speed = vel.length() || 1;
q.setFromUnitVectors(up, vel.clone().divideScalar(speed));
q.setFromUnitVectors(up, unit.copy(vel).divideScalar(speed));
// streak stretches with speed — drizzle is dots, a squall is lines
scale.set(1, Math.min(2.6, 0.35 + speed * 0.055), 1);
m.compose(zero, q, scale);
@ -91,6 +204,15 @@ function createRain(opts) {
if (py[i] < groundY) py[i] += height;
else if (py[i] > top) py[i] -= height;
// Under the cloth this drop was stopped up there. Keep simulating it —
// it wraps back to the top and rains again beyond the sail's edge — but
// don't draw it. A degenerate matrix is cheaper than reshuffling the
// instance list, and InstancedMesh has no per-instance visibility.
if (shadow && shadow.occluded(px[i], py[i], pz[i])) {
mesh.setMatrixAt(i, HIDDEN);
continue;
}
m.elements[12] = px[i];
m.elements[13] = py[i];
m.elements[14] = pz[i];
@ -322,6 +444,9 @@ export function createSkyFx(o = {}) {
const rain = createRain({ groundY: o.groundY ?? 0 });
if (scene) scene.add(rain.mesh);
const shadow = new RainShadow({ groundY: o.groundY ?? 0 });
const rainDir = new THREE.Vector3();
let shadowTick = 0;
const audio = createAudio((wind && wind.seed) || 1);
@ -337,10 +462,19 @@ export function createSkyFx(o = {}) {
dome.renderOrder = -1;
if (scene) scene.add(dome);
// remember what world.js handed us, so dispose() puts it back exactly
// Remember what world.js handed us, so dispose() puts it back exactly.
// Fog is captured BY VALUE, not by reference: step() mutates that very object
// in place, so `scene.fog = original.fog` restores the object we just spent a
// storm wrecking. Lane A caught it — sun and hemi came back exactly and the fog
// stayed where the storm left it. Harmless today only because the next skyfx
// immediately re-drives it, which is exactly the kind of bug that waits.
const ownsFog = !!scene && !scene.fog;
const original = {
background: scene ? scene.background : null,
fog: scene ? scene.fog : null,
fogColor: scene && scene.fog ? scene.fog.color.clone() : null,
fogNear: scene && scene.fog ? scene.fog.near : 0,
fogFar: scene && scene.fog ? scene.fog.far : 0,
sun: sun ? sun.intensity : 0,
hemi: hemi ? hemi.intensity : 0,
};
@ -360,9 +494,22 @@ export function createSkyFx(o = {}) {
const w = new THREE.Vector3();
const fx = {
rain, audio, dome,
rain, audio, dome, shadow,
get flash() { return flash; },
/**
* 0..1 of a ground rect the sail is keeping dry, right now.
*
* Lane A: this is NOT `rig.coverageOver(bed, world.sunDir)`. That one is the
* SUN shadow the summer-afternoon question. This is the RAIN shadow, which
* arrives along the wind, sits downwind of the cloth, and walks across the
* yard when the wind swings. During a storm at night the sun shadow is a
* number about nothing; this is the one that says whether the garden is
* getting hit. Which of the two drives garden HP is a design call, not mine
* flagged in THREADS. Cheap either way: reads the grid we already built.
*/
rainShadowOver(rect) { return shadow.fractionOver(rect); },
/** Wire to the first click/keydown — browsers won't start audio otherwise. */
unlockAudio() { audio.unlock(); },
@ -422,7 +569,16 @@ export function createSkyFx(o = {}) {
domeTex.offset.y = (domeTex.offset.y + scroll * dt * 0.12) % 1;
// --- rain ---
rain.step(dt, camPos, w, intensity);
// Rebuild the shadow a few times a second, not every frame: the cloth
// moves slowly next to the rain, and this is the only part that costs.
shadowTick -= dt;
if (shadowTick <= 0) {
shadowTick = 0.1;
rainVelocity(w, intensity, rainDir);
const len = rainDir.length() || 1;
shadow.update(world.sail, rainDir.x / len, rainDir.y / len, rainDir.z / len);
}
rain.step(dt, camPos, w, intensity, shadow);
// --- audio ---
audio.setLevels(speed, intensity);
@ -454,7 +610,14 @@ export function createSkyFx(o = {}) {
scene.remove(rain.mesh);
scene.remove(dome);
scene.background = original.background;
scene.fog = original.fog;
if (ownsFog) {
scene.fog = null; // we brought it; we take it
} else if (original.fog) {
scene.fog = original.fog;
original.fog.color.copy(original.fogColor);
original.fog.near = original.fogNear;
original.fog.far = original.fogFar;
}
}
if (sun) sun.intensity = original.sun;
if (hemi) hemi.intensity = original.hemi;

View File

@ -15,8 +15,10 @@
import * as THREE from '../../vendor/three.module.js';
import { assert, fixedLoop } from '../testkit.js';
import { FIXED_DT, checkContract } from '../contracts.js';
import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS } from '../contracts.js';
import { loadStorm, createWind } from '../weather.js';
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'];
@ -43,12 +45,34 @@ export default async function run(t) {
const pos = new THREE.Vector3(3, 0, -2);
const a = wind.sample(pos, 12.5);
assert(a instanceof THREE.Vector3, 'sample did not return a THREE.Vector3');
assert(a.y === 0, `wind should be horizontal, got y=${a.y}`);
assert(Number.isFinite(a.x) && Number.isFinite(a.y) && Number.isFinite(a.z),
`sample returned a non-finite vector: ${a.x},${a.y},${a.z}`);
// out param must not change the answer, only where it lands
const out = new THREE.Vector3();
const b = wind.sample(pos, 12.5, out);
assert(b === out, 'out param was ignored');
assert(a.x === b.x && a.z === b.z, 'out param changed the result');
assert(a.x === b.x && a.y === b.y && a.z === b.z, 'out param changed the result');
});
// This assert used to read `a.y === 0` — "wind should be horizontal". SPRINT2
// decision 3 made that false on purpose: gusts now descend, which is what makes
// a flat sail pay. Keeping the useful half — y is downward-or-zero, never up,
// and never garbage — so player shove and rain angle can still trust the sign.
t.test('vertical wind is downward-only, and only during gusts', () => {
const wind = createWind(storms.storm_02_wildnight);
const pos = new THREE.Vector3(0, 1.7, 0);
const v = new THREE.Vector3();
let sawDown = false;
fixedLoop(wind.duration, FIXED_DT, (dt, time) => {
wind.sample(pos, time, v);
assert(v.y <= 1e-9, `wind blew UP (y=${v.y.toFixed(3)}) at t=${time.toFixed(2)}`);
if (v.y < -1) sawDown = true;
});
assert(sawDown, 'never saw a downdraft worth the name in a whole wild night');
// and the wind meter must stay horizontal — a falling gust shouldn't spike the HUD
const calm = wind.speedAt(pos, 0.5);
assert(Math.abs(calm - Math.hypot(wind.sample(pos, 0.5).x, wind.sample(pos, 0.5).z)) < 1e-9,
'speedAt() is not the horizontal magnitude of sample()');
});
// Lifted from a.test.js onto the real wind (Lane A's note in this file's
@ -70,6 +94,122 @@ export default async function run(t) {
assert(edges >= 5, `only ${edges} gusts telegraphed in a ${wind.duration}s storm — too quiet to test`);
});
// --- SPRINT2 decision 5: debris.pieces is Lane B's to read, so it's frozen ---
t.test('debris conforms and its pieces match the frozen shape', () => {
const wind = createWind(storms.storm_02_wildnight);
const debris = createDebris({ wind });
assert(checkContract('debris', debris).length === 0, checkContract('debris', debris).join('; '));
const p = debris.spawn({ model: 'BlueCrate_v2', lateral: 0 }, 40);
for (const [field, want] of Object.entries(DEBRIS_PIECE_FIELDS)) {
const got = typeof p[field];
assert(got === want, `piece.${field} is ${got}, contract says ${want}`);
if (want === 'number') assert(Number.isFinite(p[field]), `piece.${field} is not finite`);
}
assert(debris.pieces.includes(p), 'spawn() returned a piece that is not in pieces');
assert(p.r > 0 && p.mass > 0, 'a piece with no radius or no mass cannot be collided with');
// The array is mutated in place and pieces are spliced on despawn — that's
// documented, and B reads it fresh inside step(). Prove clear() empties it
// rather than swapping in a new array behind their reference.
const ref = debris.pieces;
debris.clear();
assert(ref === debris.pieces && debris.pieces.length === 0,
'clear() replaced the pieces array instead of emptying it — B holds a reference');
});
// Lane A rebuilds skyfx on every phase change, so dispose() is on the hot path.
// They verified sun/hemi restore exactly and spotted that fog didn't; this pins
// both. The vacuity guards matter — a restore test where nothing ever moved is
// a test that passes forever and checks nothing.
t.test('skyfx.dispose() hands the scene back exactly as it found it', () => {
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x9fc4e8);
scene.fog = new THREE.Fog(0x9fc4e8, 30, 140);
const camera = new THREE.PerspectiveCamera();
const sun = new THREE.DirectionalLight(0xfff4e0, 2.0);
const hemi = new THREE.HemisphereLight(0xbfd8ff, 0x3a4a2a, 1.8);
const before = {
bg: scene.background, fogColor: scene.fog.color.getHex(),
fogNear: scene.fog.near, fogFar: scene.fog.far,
sun: sun.intensity, hemi: hemi.intensity, children: scene.children.length,
};
const wind = createWind(storms.storm_02_wildnight);
const sky = createSkyFx({ scene, camera, wind, sun, hemi });
fixedLoop(40, FIXED_DT, (dt, time) => sky.step(dt, time, {}));
assert(sun.intensity < before.sun * 0.9, 'the storm never dimmed the sun — this test proves nothing');
assert(scene.fog.near !== before.fogNear, 'the storm never touched the fog — this test proves nothing');
sky.dispose();
assert(sun.intensity === before.sun, `sun left at ${sun.intensity}, want ${before.sun}`);
assert(hemi.intensity === before.hemi, `hemi left at ${hemi.intensity}, want ${before.hemi}`);
assert(scene.background === before.bg, 'scene.background not restored');
assert(scene.fog.color.getHex() === before.fogColor,
`fog colour left at #${scene.fog.color.getHex().toString(16)}, want #${before.fogColor.toString(16)}`);
assert(scene.fog.near === before.fogNear && scene.fog.far === before.fogFar,
`fog left at near=${scene.fog.near} far=${scene.fog.far}, want ${before.fogNear}/${before.fogFar}`);
assert(scene.children.length === before.children,
`skyfx left ${scene.children.length - before.children} object(s) in the scene`);
});
// --- SPRINT2 §Lane C.3: rain has to stop at the cloth ---
// Driven with a synthetic 4×4 m panel rather than a whole cloth sim: the thing
// under test is the projection, and a flat panel makes the right answer
// something you can work out on paper.
const PANEL = {
pos: new Float32Array([-2, 3, -2, 2, 3, -2, 2, 3, 2, -2, 3, 2]),
tris: [0, 1, 2, 0, 2, 3],
};
t.test('rain shadow: straight-down rain leaves a dry patch under the panel', () => {
const s = new RainShadow();
s.update(PANEL, 0, -1, 0);
assert(s.live, 'shadow never built');
assert(s.occluded(0, 1, 0), 'drop directly under the panel is still falling');
assert(s.occluded(1.5, 0.1, 1.5), 'drop near the panel corner is still falling');
assert(!s.occluded(0, 5, 0), 'drop ABOVE the panel was culled — it has not hit yet');
assert(!s.occluded(8, 1, 0), 'drop well clear of the panel was culled');
assert(!s.occluded(0, 1, 9), 'drop well clear of the panel was culled');
});
t.test('rain shadow leans with the rain, and follows the wind round', () => {
const s = new RainShadow();
// rain driving hard along +x: the dry ground moves +x, out from under the panel
s.update(PANEL, 0.6, -0.8, 0);
const shift = 3 * (0.6 / 0.8); // 3 m of fall × the lean
assert(s.occluded(shift, 0.05, 0), `dry patch is not downwind at x=${shift.toFixed(2)}`);
assert(!s.occluded(-shift, 0.05, 0), 'dry patch went UPWIND — the projection is inverted');
// swing the wind 180° and the patch has to swap sides. This is the southerly
// change: the sail stops covering the bed without a single corner failing.
s.update(PANEL, -0.6, -0.8, 0);
assert(s.occluded(-shift, 0.05, 0), 'dry patch did not follow the wind round');
assert(!s.occluded(shift, 0.05, 0), 'dry patch stayed put when the wind swung');
});
t.test('rain shadow: no sail, no shelter', () => {
const s = new RainShadow();
s.update(null, 0, -1, 0);
assert(!s.live && !s.occluded(0, 1, 0), 'sheltered by a sail that does not exist');
// and rain that is not falling can't cast a shadow (guards a divide by ~0)
s.update(PANEL, 1, 0, 0);
assert(!s.live, 'horizontal rain projected to infinity instead of bailing out');
});
t.test('rain shadow: fractionOver reads a rect the way coverageOver does', () => {
const s = new RainShadow();
s.update(PANEL, 0, -1, 0);
// the panel spans x,z in [-2,2]; a rect inside it is fully covered
assert(s.fractionOver({ x: 0, z: 0, w: 2, d: 2 }) === 1,
'a rect wholly under the panel is not fully covered');
assert(s.fractionOver({ x: 12, z: 0, w: 2, d: 2 }) === 0,
'a rect nowhere near the panel is covered');
const half = s.fractionOver({ x: 2, z: 0, w: 4, d: 2 });
assert(half > 0.2 && half < 0.8, `a rect straddling the edge reads ${half}, want a partial`);
});
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

@ -11,7 +11,7 @@
* web/world/dev_player.html and written up in THREADS.md: head bone 1.715 m at fig scale 0.983,
* all six clips bound, Hips tracks correctly absent.
*/
import { PlayerSim, STATES, TUNE } from '../player.sim.js';
import { PlayerSim, STATES, TUNE, clipFor } from '../player.sim.js';
import { Interact, wireYardActions } from '../interact.js';
import { assert, assertEq, assertClose, assertLess, fixedLoop } from '../testkit.js';
import { FIXED_DT } from '../contracts.js';
@ -28,13 +28,36 @@ const drive = (sim, secs, input = {}, wind = null, t0 = 0) =>
/** @param {import('../testkit.js').Suite} t */
export default function run(t) {
// ---------------------------------------------------------------- state machine table
// The 17 clips actually in player_anims.glb (integrator baked the M3 pack; names logged in THREADS).
// Verified against the real GLB in-browser: SHADES.player.view.clipNames matches this exactly.
const PACK = new Set(['Idle', 'Walk', 'Run', 'Falling', 'CrouchToStand', 'Reaction',
'ClimbLadder', 'Crank', 'Dig', 'PickUp', 'Carry', 'CarryTurn', 'CarryIdle', 'StandUp',
'TakeCover', 'StumbleBack', 'PlantSeeds']);
t.test('state table: every state\'s clip exists in player_anims.glb', () => {
const clips = new Set(['Idle', 'Walk', 'Run', 'Falling', 'CrouchToStand', 'Reaction']);
for (const [name, st] of Object.entries(STATES)) {
assert(clips.has(st.clip), `state ${name} wants missing clip ${st.clip}`);
assert(PACK.has(st.clip), `state ${name} wants missing clip ${st.clip}`);
if (st.carryClip) assert(PACK.has(st.carryClip), `state ${name} wants missing ${st.carryClip}`);
}
});
t.test('clipFor: carrying swaps the locomotion set, an interaction names its own verb', () => {
const s = new PlayerSim();
assertEq(clipFor(s), 'Idle', 'empty-handed idle');
s.state = 'walk'; assertEq(clipFor(s), 'Walk');
s.carrying = 'spare';
assertEq(clipFor(s), 'Carry', 'carrying while walking');
s.state = 'run'; assertEq(clipFor(s), 'Carry', 'no CarryRun clip exists — Carry covers it');
s.state = 'idle'; assertEq(clipFor(s), 'CarryIdle', 'carrying while standing');
s.state = 'busy'; s.busyClip = 'Crank';
assertEq(clipFor(s), 'Crank', 'the verb wins over the carry set while busy');
s.busyClip = null;
assertEq(clipFor(s), 'Idle', 'busy with no named verb falls back to the table');
// locked states have no carry variant — you drop what you held anyway
s.carrying = 'spare'; s.state = 'knocked';
assertEq(clipFor(s), 'Falling', 'knockdown always plays Falling');
});
t.test('state table: no stuck states — every locked state drains to a free one', () => {
for (const [name, st] of Object.entries(STATES)) {
if (!st.locked) continue;
@ -163,6 +186,130 @@ export default function run(t) {
assert(!s.busy, 'player is free');
});
// ---------------------------------------------------------------- shelter (hold C)
t.test('shelter: bracing survives a gust that floors you standing', () => {
const gust = TUNE.knockWind + 8; // over the standing bar, under the braced one
const standing = new PlayerSim();
drive(standing, TUNE.knockSustain + 0.3, {}, windX(gust));
assertEq(standing.state, 'knocked', 'standing, this gust floors you');
const braced = new PlayerSim();
drive(braced, TUNE.knockSustain + 0.3, { shelter: true }, windX(gust));
assertEq(braced.state, 'shelter', 'braced, the same gust does not');
assert(braced.busy, 'shelter is locked — you cannot walk while braced');
});
t.test('shelter: raises the bar, it does not remove it', () => {
const s = new PlayerSim();
drive(s, TUNE.knockSustain + 0.2, { shelter: true }, windX(TUNE.knockWind * TUNE.shelterKnockMult + 5));
assertEq(s.state, 'knocked', 'a big enough gust still takes you off your feet, braced or not');
});
t.test('shelter: releasing the key always frees you, even mid-gust', () => {
const s = new PlayerSim();
drive(s, 1, { shelter: true }, windX(20));
assertEq(s.state, 'shelter', 'braced');
drive(s, 0.5, {}, windX(20)); // let go, wind still blowing
assert(!s.busy && s.state === 'idle', 'released');
});
t.test('shelter: cannot brace from your back', () => {
const s = new PlayerSim();
s.knockdown(0);
drive(s, 0.3, { shelter: true });
assertEq(s.state, 'knocked', 'holding C while down does not hijack the knockdown');
});
t.test('shelter: the wind barely moves you while braced', () => {
const push = (input) => {
const p = new PlayerSim();
drive(p, 30, input, windX(2)); // learn a calm baseline
const x0 = p.pos.x;
drive(p, 1.2, input, windX(24), 30);
return Math.abs(p.pos.x - x0);
};
assertLess(push({ shelter: true }), push({}) * 0.5, 'bracing must cut the shove hard');
});
// ---------------------------------------------------------------- stumble
t.test('stumble: a gust below the knockdown bar still breaks your stride', () => {
const s = new PlayerSim();
drive(s, 30, {}, windX(3)); // calm baseline
drive(s, 0.4, {}, windX(3 + TUNE.stumbleGust + 4), 30);
assertEq(s.state, 'stumble', 'gust over stumbleGust but under knockWind');
assert(s.busy, 'stumble is locked');
drive(s, 1.0, {}, windX(3), 31);
assertEq(s.state, 'idle', 'and it drains on its own');
});
t.test('stumble: one gust hold must not stumble you twice', () => {
const s = new PlayerSim();
drive(s, 30, {}, windX(3));
let stumbles = 0;
const before = s.events.length;
drive(s, 2.5, {}, windX(3 + TUNE.stumbleGust + 4), 30); // a full ~1.7 s hold and then some
for (const e of s.events.slice(before)) if (e.type === 'state' && e.state === 'stumble') stumbles++;
assertEq(stumbles, 1, 'stumbleCooldown makes it punctuation, not a stutter');
});
t.test('stumble: bracing means you keep your feet', () => {
const s = new PlayerSim();
drive(s, 30, { shelter: true }, windX(3));
drive(s, 0.5, { shelter: true }, windX(3 + TUNE.stumbleGust + 4), 30);
assertEq(s.state, 'shelter', 'braced, the gust does not stumble you');
});
// ---------------------------------------------------------------- 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
const wall = { x0: -8, x1: 8, z0: -16, z1: -10, y0: 0, y1: 3 };
const R = 0.3;
const collide = (x, z, feetY, headY) => {
if (wall.y1 <= feetY + 0.05 || wall.y0 >= headY) return { x, z };
const cx = Math.min(Math.max(x, wall.x0), wall.x1);
const cz = Math.min(Math.max(z, wall.z0), wall.z1);
const dx = x - cx, dz = z - cz, d2 = dx * dx + dz * dz;
if (d2 >= R * R || d2 <= 1e-10) return { x, z };
const d = Math.sqrt(d2);
return { x: cx + dx / d * R, z: cz + dz / d * R };
};
const s = new PlayerSim({ start: { x: 0, y: 0, z: -5 }, collide });
drive(s, 6, { x: 0, z: 1, run: true, camYaw: 0 }, null); // camYaw 0 → forward is -Z
assert(s.pos.z >= -10 - 1e-6, `must not enter the wall, z=${s.pos.z.toFixed(3)}`);
assertClose(s.pos.z, -10 + R, 0.02, 'stops exactly one body radius off the face');
// Diagonal into a LONG wall: blocked north, but must still slide east. The wall has to outrun
// the player here — against the 16 m one above, a 4 s diagonal sprint rounds its east end and
// gets past, which is correct behaviour and not what this assert is about.
const long = { ...wall, x0: -100, x1: 100 };
const collideLong = (x, z, feetY, headY) => {
if (long.y1 <= feetY + 0.05 || long.y0 >= headY) return { x, z };
const cx = Math.min(Math.max(x, long.x0), long.x1);
const cz = Math.min(Math.max(z, long.z0), long.z1);
const dx = x - cx, dz = z - cz, d2 = dx * dx + dz * dz;
if (d2 >= R * R || d2 <= 1e-10) return { x, z };
const d = Math.sqrt(d2);
return { x: cx + dx / d * R, z: cz + dz / d * R };
};
const g = new PlayerSim({ start: { x: 0, y: 0, z: -5 }, collide: collideLong });
drive(g, 4, { x: 1, z: 1, run: true, camYaw: 0 }, null);
assertClose(g.pos.z, -10 + R, 0.02, 'held off the wall');
assert(g.pos.x > 2, `pushout must preserve the tangential slide, x=${g.pos.x.toFixed(2)}`);
});
t.test('collision: you can walk under an overhang (eaves are above your head)', () => {
// the real roof: y 2.99..3.21, reaching 0.4 m further into the yard than the wall below it
const collide = (x, z, feetY, headY) => {
const y0 = 2.99, y1 = 3.21;
if (y1 <= feetY + 0.05 || y0 >= headY) return { x, z }; // filtered out for a 1.72 m body
return { x, z: Math.max(z, -9.6 + 0.3) }; // would wall you off if it applied
};
const s = new PlayerSim({ start: { x: 0, y: 0, z: -5 }, collide, height: 1.72 });
drive(s, 4, { x: 0, z: 1, run: true, camYaw: 0 }, null); // camYaw 0 → forward is -Z
assert(s.pos.z < -9, `a 3 m eave must not block a 1.7 m person, z=${s.pos.z.toFixed(2)}`);
});
// ---------------------------------------------------------------- determinism (PLAN3D §4)
t.test('determinism: two identical 50 s runs produce byte-equal traces', () => {
const trace = () => {
@ -270,6 +417,61 @@ export default function run(t) {
assertEq(sim.carrying, null, 'hands empty');
});
t.test('interact: the action names the verb the player plays', () => {
const sim = new PlayerSim();
const it = new Interact();
it.register({ id: 'crank', pos: { x: 0, y: 0, z: 0 }, radius: 2, holdSecs: 1, clip: 'Crank' });
fixedLoop(30 * DT, DT, (dt, tt) => it.step(dt, tt, sim, true));
assertEq(sim.busyClip, 'Crank', 'busyClip is set for the hold');
assertEq(clipFor(sim), 'Crank', 'and that is what plays');
it.step(DT, 1, sim, false);
assertEq(sim.busyClip, null, 'cancelling clears the verb');
assertEq(clipFor(sim), 'Idle', 'back to the table');
});
t.test('interact: the verb is cleared on completion, so carry clips win afterwards', () => {
const sim = new PlayerSim();
const it = new Interact();
it.register({ id: 'take', pos: { x: 0, y: 0, z: 0 }, radius: 2, holdSecs: 0.5, clip: 'PickUp',
onDone: (p, tt) => p.pickUp('spare', tt) });
fixedLoop(1, DT, (dt, tt) => it.step(dt, tt, sim, true));
assertEq(sim.carrying, 'spare', 'picked it up');
assertEq(sim.busyClip, null, 'verb cleared');
assertEq(clipFor(sim), 'CarryIdle', 'and the player now reads as carrying');
});
t.test('wireYardActions: reads corners LIVE, so attach() cannot strand the targets', () => {
// Lane A's warning: attach() REPLACES the corners array. Capturing the corner object at wire
// time would leave these gated on a `broken` flag nothing updates ever again.
const rig = {
corners: [{ anchorId: 'p1', broken: true, pos: { x: 0, y: 2, z: 0 } }],
repair: () => {}, trim: () => {},
cornerPos: (i) => rig.corners[i].pos,
};
const it = new Interact();
wireYardActions(it, { sailRig: rig });
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
p.carrying = 'spare';
assertEq(it.nearest(p).id, 'rerig_0', 'broken corner offers a re-rig');
// now do what attach() does: swap the whole array for fresh objects
rig.corners = [{ anchorId: 'p1', broken: false, pos: { x: 0, y: 2, z: 0 } }];
assertEq(it.nearest(p).id, 'trim_0', 'the NEW corner is unbroken → trim, not re-rig');
rig.corners = [{ anchorId: 'p1', broken: true, pos: { x: 4, y: 2, z: 4 } }];
assertEq(it.nearest(p), null, 'and it followed the corner when it moved out of range');
});
t.test('wireYardActions: prompts track a moving (flogging) corner', () => {
const corner = { anchorId: 'p1', broken: false, pos: { x: 0, y: 2, z: 0 } };
const rig = { corners: [corner], repair: () => {}, trim: () => {}, cornerPos: (i) => rig.corners[i].pos };
const it = new Interact();
wireYardActions(it, { sailRig: rig });
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
assertEq(it.nearest(p).id, 'trim_0', 'in range at the start');
corner.pos = { x: 9, y: 2, z: 9 }; // the corner blows away
assertEq(it.nearest(p), null, 'prompt follows it out of range, not pinned to where it was');
});
t.test('wireYardActions: duck-types against a half-landed world', () => {
const empty = new Interact();
wireYardActions(empty, {});

View File

@ -73,6 +73,10 @@ const ASSETS = [
nodes: ['bin_body', 'lid', 'lid_plate', 'wheels'], sub: 'debris/' },
{ name: 'washing_line_01', h: [2.0, 2.4], nodes: ['mast', 'head', 'arms'] },
{ name: 'garden_gnome_01', h: [0.33, 0.42], nodes: ['gnome'] },
{ name: 'garden_gnome_01_broken', h: [0.08, 0.20],
nodes: ['stump', 'head', 'hat', 'shards'] },
{ name: 'fence_panel_broken', h: [1.70, 1.90],
nodes: ['palings', 'rails', 'debris_palings'] },
];
function sizeOf(gltf) {
@ -234,6 +238,45 @@ export default async function run(t) {
assert(Number.isFinite(before.x) && Number.isFinite(after.x), 'arms world position is not finite');
});
// Custom props are a contract, not decoration — and I have been telling other
// lanes to read these since Sprint 1 without ever checking they survive the
// export. glTF `extras` arrive as three's userData, but only if export_extras
// held all the way through; if it silently dropped, Lane A's gnome scores $0
// and Lane B's anchors all rate the same, both of which would look like a
// gameplay decision rather than a missing field.
t.test('glTF extras survive as userData — the props other lanes read', () => {
const gnome = loaded.get('garden_gnome_01')?.scene.getObjectByName('garden_gnome_01');
assert(gnome, 'gnome root node missing');
assert(gnome.userData?.collateral_value === 25,
`collateral_value lost (userData=${JSON.stringify(gnome.userData)}) — Lane A scores off this`);
const canopy = loaded.get('tree_gum_01')?.scene.getObjectByName('canopy');
assert(typeof canopy?.userData?.sway_amp === 'number',
'canopy.sway_amp lost — world.js per-tree sway tuning reads it');
const branch = loaded.get('tree_gum_01')?.scene.getObjectByName('branch_anchor_01');
assert(typeof branch?.userData?.rating_hint === 'number',
'branch_anchor_01.rating_hint lost — Lane B picks anchors on it');
const bin = loaded.get('wheelie_bin_01')?.scene.getObjectByName('wheelie_bin_01');
assert(typeof bin?.userData?.mass_hint === 'number',
'wheelie_bin mass_hint lost — Lane C throws it with this');
});
// The wreckage has to drop into the intact asset's place, so both variants
// stand on the same ground plane. If the broken one floats or sinks, Lane A's
// swap needs a fudge offset per prop and will grow one.
t.test('broken variants sit on the same ground plane as their intact twin', () => {
for (const [intact, broken] of [['garden_gnome_01', 'garden_gnome_01_broken'],
['fence_panel', 'fence_panel_broken']]) {
for (const n of [intact, broken]) {
const box = new THREE.Box3().setFromObject(loaded.get(n).scene);
assert(Math.abs(box.min.y) < 0.03,
`${n} rests at y=${box.min.y.toFixed(3)}, not on the ground`);
}
}
});
// One GLB carries three wilt states as siblings; Lane A toggles .visible
// rather than reloading, so all three have to be present at once.
t.test('garden_bed carries all 3 damage states in one GLB', () => {

View File

@ -280,6 +280,97 @@ export function weatherCases(storms) {
assert(Math.abs(luvS - luvB) < 1e-9, 'upwind side is being sheltered — shadow is pointing the wrong way');
});
// ---- 9. vertical gust structure (SPRINT2 decision 3) ----
// Cloth pressure goes with dot(wind, normal). A flat horizontal panel's normal
// points at the sky, so in a perfectly horizontal wind that dot is ~0 and the
// cheapest winning rig is "lie it flat and ignore the storm" — the opposite of
// the game. Gust fronts are descending air, and descending air hits a flat
// panel square on. Lane B owns the cloth-side assert; these are the wind side.
test('gusts carry a downdraft, and still air does not', () => {
const f = createWindField(storms.storm_02_wildnight);
let peakDown = 0, betweenMax = 0;
for (let t = 0; t <= f.duration; t += DT) {
const v = f.gustVertical(t);
assert(v <= 1e-12, `vertical wind went UP (${v.toFixed(2)}) at t=${t.toFixed(2)} — downdraft only`);
const live = f.gusts.some((g) => t > g.t0 && t < g.endAt);
if (live) peakDown = Math.min(peakDown, v);
else betweenMax = Math.max(betweenMax, Math.abs(v));
}
metrics['storm_02.peakDowndraft'] = +peakDown.toFixed(2);
assert(betweenMax === 0, `air is falling between gusts (${betweenMax}) — downdraft must be a gust feature`);
assert(peakDown < -2, `peak downdraft only ${peakDown.toFixed(2)} m/s — a flat sail would still shrug it off`);
});
test('downdraft tracks its own gust and its JSON fraction', () => {
const def = storms.storm_02_wildnight;
const f = createWindField(def);
const frac = def.gusts.downdraft;
for (const g of f.gusts) {
assert(g.down >= frac * 0.6 - 1e-9 && g.down <= frac * 1.4 + 1e-9,
`gust at t=${g.t0.toFixed(1)} has down=${g.down.toFixed(3)}, outside 0.61.4× of ${frac}`);
// minGap >= GUST.TOTAL means gusts never overlap, so at hold it's exactly this gust
const atHold = f.gustVertical(g.t0 + 3);
assert(Math.abs(atHold - -(g.pow * g.down)) < 1e-9,
`at gust hold vertical is ${atHold.toFixed(3)}, want ${(-g.pow * g.down).toFixed(3)}`);
}
});
test('downdraft 0 gives a perfectly horizontal wind', () => {
const def = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
def.gusts.downdraft = 0;
const f = createWindField(def);
const out = { x: 0, y: 0, z: 0 };
for (let t = 0; t <= f.duration; t += 0.05) {
f.vecAt(2, -1, t, out);
assert(out.y === 0, `y=${out.y} at t=${t.toFixed(2)} with downdraft 0 — the opt-out leaks`);
}
});
test('downdraft does not re-time the storm', () => {
// The vertical draws from its own RNG stream precisely so that adding or
// tuning it can't shift gust times or powers. Lane A hand-drove storm_02 and
// watched the carabiner blow at t=45.4 and p2 cascade at t=56; a downdraft
// tweak silently moving those would be a nasty way to lose an afternoon.
const base = storms.storm_02_wildnight;
const a = createWindField(base);
for (const dd of [0, 0.1, 0.25, 0.5, 1]) {
const d = JSON.parse(JSON.stringify(base));
d.gusts.downdraft = dd;
const b = createWindField(d);
assert(a.gusts.length === b.gusts.length, `downdraft ${dd} changed the gust count`);
a.gusts.forEach((g, i) => {
assert(g.t0 === b.gusts[i].t0,
`downdraft ${dd} moved gust ${i} from t=${g.t0.toFixed(3)} to ${b.gusts[i].t0.toFixed(3)}`);
assert(g.pow === b.gusts[i].pow, `downdraft ${dd} changed gust ${i}'s power`);
});
}
});
test('at a gust peak the downdraft is a real fraction of the horizontal', () => {
const f = createWindField(storms.storm_02_wildnight);
const out = { x: 0, y: 0, z: 0 };
let bestRatio = 0, atT = 0;
for (let t = 0; t <= f.duration; t += DT) {
f.vecAt(0, 0, t, out);
const horiz = Math.hypot(out.x, out.z);
if (horiz < 1) continue;
const r = Math.abs(out.y) / horiz;
if (r > bestRatio) { bestRatio = r; atT = t; }
}
metrics['storm_02.peakVerticalRatio'] = +bestRatio.toFixed(3);
assert(bestRatio > 0.12,
`strongest downdraft is only ${(bestRatio * 100).toFixed(0)}% of the horizontal wind (t=${atT.toFixed(1)}) — a flat sail still shrugs`);
});
test('validator rejects a bad downdraft', () => {
for (const dd of [-0.1, 1.5, NaN, 'lots']) {
const d = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
d.gusts.downdraft = dd;
const { ok } = validateStorm(d, 'broken');
assert(!ok, `validator ACCEPTED downdraft = ${dd}`);
}
});
return { cases, metrics };
}

View File

@ -130,17 +130,29 @@ function sampleAngleCurve(curve, t) {
// ---------- gust timeline ----------
// Prototype: pow = 12 + rand*16 + 10*p, next = t + 5 + rand*7. Same shape, from JSON.
export const DEFAULT_DOWNDRAFT = 0.25;
export function buildGustTimeline(def, seed) {
const g = def.gusts || {};
const rng = mulberry32(seed >>> 0);
// Vertical draws from its OWN stream, deliberately. Pulling it from `rng`
// would shift every subsequent (t0, pow) and silently re-time storms that are
// already tuned and hand-verified — A drove storm_02 and watched the carabiner
// blow at t=45.4 and cascade at t=56, one second after the change. Adding a
// downdraft shouldn't move that.
const rngV = mulberry32((seed ^ 0x0d0117) >>> 0);
const minGap = g.minGap ?? 5, maxGap = g.maxGap ?? 12;
const downFrac = g.downdraft ?? DEFAULT_DOWNDRAFT;
const out = [];
let t = g.firstAt ?? 3;
// hard cap: a malformed gap can't spin us forever
while (t < def.duration && out.length < 512) {
const p = def.duration > 0 ? t / def.duration : 0;
const pow = (g.powBase ?? 12) + rng() * (g.powRand ?? 16) + (g.powRamp ?? 10) * p;
out.push({ t0: t, pow, rampAt: t + GUST.TELEGRAPH, endAt: t + GUST.TOTAL });
// Not every gust slams down the same: some roll through nearly flat, some
// are a proper little downburst. 0.61.4× the storm's fraction.
const down = downFrac * (0.6 + rngV() * 0.8);
out.push({ t0: t, pow, down, rampAt: t + GUST.TELEGRAPH, endAt: t + GUST.TOTAL });
t += minGap + rng() * Math.max(0, maxGap - minGap);
}
return out;
@ -190,6 +202,26 @@ export function createWindField(def, opts = {}) {
return sampleAngleCurve(def.dirCurve, t) + wAmp * Math.sin(t * wRate);
}
/**
* Vertical wind, m/s. NEGATIVE = downward. Zero between gusts.
*
* A gust front is descending air, not just faster air. Without this the field
* is perfectly horizontal, and a horizontal sail is a free lunch: cloth
* pressure goes with dot(wind, normal), a flat panel's normal points at the
* sky, and the dot product is ~0 no matter how hard it blows. So the cheapest
* winning rig was "lie it flat and ignore the storm", which is the opposite of
* the game (SPRINT2 decision 3). A downdraft hits a flat panel square on.
*/
function gustVertical(t) {
let v = 0;
for (let i = 0; i < gusts.length; i++) {
const g = gusts[i];
if (t <= g.t0) break; // sorted — nothing later is live
if (t < g.endAt) v -= gustEnvelope(t - g.t0, g.pow) * g.down;
}
return v;
}
// ---- noise drift ----
// The noise pattern rides downwind with the mean flow (Taylor's frozen
// turbulence), so a gust visibly travels ACROSS the yard instead of blinking on
@ -278,7 +310,13 @@ export function createWindField(def, opts = {}) {
},
get shelters() { return shelters; },
/** Scalar wind speed (m/s) at a point. The cheap path — no allocation. */
/**
* Scalar wind speed (m/s) at a point HORIZONTAL only, which is what an
* anemometer reads and what the HUD, rain and grass want. The gust downdraft
* is deliberately not in here: a wind meter jumping because air is falling
* past it would read as a bug. Use vecAt/sample for the full 3D vector.
* The cheap path no allocation.
*/
speedAt(x, z, t) {
const uni = uniformSpeed(t);
const d = dirAt(t);
@ -289,16 +327,18 @@ export function createWindField(def, opts = {}) {
dirAt,
uniformSpeed,
gustOnly,
gustVertical,
/** Writes wind velocity (m/s) into out {x,y,z}. Ground plane is XZ, +Y up. */
vecAt(x, z, t, out) {
const uni = uniformSpeed(t);
const d = dirAt(t);
const dirX = Math.cos(d), dirZ = Math.sin(d);
let s = uni * spatialFactor(x, z, t) * shelterFactor(x, z, dirX, dirZ);
const m = spatialFactor(x, z, t) * shelterFactor(x, z, dirX, dirZ);
let s = uni * m;
if (s < 0) s = 0;
out.x = dirX * s;
out.y = 0; // wind is horizontal; lift is the sail's job (Lane B)
out.y = gustVertical(t) * m; // gust fronts descend — see gustVertical()
out.z = dirZ * s;
return out;
},
@ -375,6 +415,10 @@ export function validateStorm(def, name = 'storm') {
// Overlapping gusts stack, and a stacked telegraph is unreadable to the player.
if (minGap < GUST.TOTAL) bad(`gusts.minGap (${minGap}) < gust length ${GUST.TOTAL}s — gusts would overlap`);
if ((g.powBase ?? 12) < 0) bad('gusts.powBase must be >= 0');
const dd = g.downdraft ?? DEFAULT_DOWNDRAFT;
if (!Number.isFinite(dd) || dd < 0 || dd > 1) {
bad(`gusts.downdraft must be 0..1 — the fraction of gust power that blows DOWN — got ${dd}`);
}
}
for (const e of def.events || []) {

Binary file not shown.

Binary file not shown.