The gust-only downdraft could not satisfy B's 60% no-free-lunch bar and the §7
twisted-survives gate together: the downdraft peaked at the gust peak, where
the horizontal peaked too, so a flat sail never reached 60% of a pitched one's
load without a spike so violent it also broke the twisted rig. The integrator
measured the pincer (0.58 -> 48% and still breaks twisted).
Fix: the downdraft is now a fraction of the LOCAL total wind speed, not of gust
power (weather.core verticalAt = -frac * localHoriz). It presses a flat roof
steadily across the whole storm — peak total 32.6 m/s dwarfs peak gust power
12.6 — so the ratio clears 60% at a gentle fraction, with no gust-peak spike.
It rides the local speed, so a tree's wind shadow shelters from falling air too.
speedAt() stays horizontal (a wind meter doesn't read falling air).
Field renamed downdraft -> downdraftOfTotal; the validator rejects the old name
rather than silently re-meaning it. The vertical now carries NO rng draws at
all, so the determinism guarantee (tuning can't re-time gusts) is structural,
not just separate-stream.
Measured both gates myself with B's SailRig (8-direction flat-vs-pitched sweep +
§7 legs). Target for storm_02 is 0.45: 69% of-max / 60% worst-heading on the
bar, twisted rated rig survives with ~21% margin. HELD at 0.12 this commit —
the current yard's only twisted quad ('h1,t2,p1,t1', ~190 m2) starts losing a
corner near 0.15 in the exact solver, so 0.45 would red B's §7. 0.12 ~= the old
gust-only 0.3 in peak downdraft (-4.2 vs -4.5 m/s). Bump to 0.45 is a one-number
joint step once A lands decision-2 anchors (18-45 m2 quads) and B re-points §7.
storm_03_southerly: campaign ramp between gentle and wildnight — peak gust 21
m/s, sustained 13, one moderate southerly change. Auto-swept by the suite.
Selftest 170/0/0 (all three §7 legs green). Verified live: downdraft/horizontal
ratio is exactly 0.12 in-game, rain occlusion still covers the bed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Posts pulled in to (-4.5,5.5)/(4.0,6.0) with p3 at (0,7), and dress() now swaps
Lane E's house_yardside and both gum trees over the graybox, adopting their baked
anchors rather than my constants (decision 6). E's fascia sits at x=-3..3, not my
guessed -5..5 — narrowing the house span by 4 m is a real part of why small quads
exist at all.
Every anchor now carries E's rating_hint: fascia 0.35 with collateral "gutter"
(they encoded DESIGN.md's "the fascia board is a lie" straight into the asset),
tree branches descending 1.0/0.88/0.76 from fork to thin limb. branch_anchor_01
keeps the t1/t2 ids so nothing referencing them breaks; the rest are added.
The yard went from 7 anchors offering nothing under 110 m² to 11 offering 34
quads in the 18-45 m² band, 8 of which shade a quarter of the bed or more.
Measured through the same storm_02: the big house-to-post span loses its
carabiner at t=3.7 s and cascades to 2/4, while a 37.7 m² tree-to-post rig at
0.85 tension survives all 90 s intact and shades 58% of the bed. That is
DESIGN.md's thesis finally standing up in the yard rather than in a doc.
Lane B's "cascade at t=0.4 s from pre-tension alone" is gone: calm peaks are now
634 N (big) and 200 N (small) against a 1200 N carabiner, and no rig breaks
before the storm starts.
Two asserts pin it, because both halves are easy to lose by accident: at least 3
riggable quads in 18-45 m² must shade the bed, AND full bed coverage must stay
above 45 m² — if a small quad ever covers the whole bed, the rigging puzzle has
no wrong answers left. Selftest 172/0/0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SPRINT3 decision 9 / §Lane A.1. Lane D's §7 loop — rig, carry a spare, repair
mid-storm — had nowhere to pick a spare up, so wireYardActions self-skipped the
pickup and nothing in the game could put one in your hands.
world.shedTable.pos is published synchronously from constants even though the
meshes arrive later: createWorld() has to stay sync (a.test.js and the selftest
build a yard with no server), and wireYardActions reads the position at wiring
time. dress() then refines it onto Lane E's baked pickup_anchor — which moved it
5 cm off my guess at where a table top is, so it was worth reading.
dress() is async and separate from createWorld for the same reason: a fetch in
the constructor would make the selftest slow and flaky, or break it. Each load is
guarded individually, so a missing GLB leaves its graybox standing instead of
taking the boot down.
Verified by hand rather than by registration check: walk up, hold E, spare in
hand; a second hold says "hands full"; leaning on the table with E held for 6 s
deals exactly one spare (D's latch works); nothing fires from across the yard.
Selftest 169/0/0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
rake_pivot shipped as a childless empty, so rotating it moved nothing — and
rotating the whole GLB instead, which is the only alternative, tips the concrete
footing out of the ground along with the post. DESIGN.md makes raking away from
the load a player decision, so the handle has to actually work.
It now holds post + pad_eye + top_anchor, with footing left on the root: rake it
8° and the post leans while the concrete stays planted. Asserted both directions
in e.test.js — the head must travel over 0.3 m and the footing under 0.01.
Same class as the canopy bug, found the same way: by driving the handle in a
test instead of eyeballing the model. Exported dims unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Textures (SPRINT2 §Lane E-2). sail_weave.png is a seamless 512² knitted
shade-cloth weave with the stripe banding real cloth has; the script proves the
wrap by evaluating a second tile and requiring an exact match, because Lane B is
being told to set RepeatWrapping and a bad wrap would seam across the whole
sail. Luminance rides in a narrow band so it multiplies the base colour instead
of replacing it — high contrast reads as burlap, not HDPE. Dropped the per-pixel
noise: invisible at ±0.012 and incompressible, it cost 305 KB of the 323.
sail_tears.png is a strip of 4 escalating rips. Each is a lens, not a slit —
fabric under tension parts widest in the middle and tapers to a point, and a
constant-width gap reads as a drawn line. Threads bridge the gap, scaled to the
local width; without them a dark lens is a hole rather than a tear.
Dressing (§Lane E-3): wheelie_bin_01 (240 L, 12 kg, `lid` on its own node so it
flaps before the bin goes over), washing_line_01 (a Hills Hoist — the `head`
freewheels, giving a second wind tell at head height), garden_gnome_01
(collateral bait: a smashed gnome reads where a damage number doesn't).
Bin and tramp land in models/debris/ for Lane C to glob. All 19 assets pass, 28
output files byte-identical across two runs, selftest 129/0/0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
world.js sways a tree by rotating a `canopy` group whose origin sits at the
trunk top, so the blobs swing about the trunk. The trees shipped canopy_01..03
as siblings of `trunk` with each origin at its own blob centre — rotating one
spins a sphere in place, which is visually nothing. Lane A could not have swayed
these trees, and the canopy lean is the gust telegraph the player reads a beat
before it hits the sail, so the tell would have gone missing rather than looked
wrong.
Adds the `canopy` empty at the trunk top with the blobs parented under it, so
A's existing code works unchanged, plus sway_amp / sway_phase / sway_pivot_y
for the per-tree tuning SPRINT2 §Lane E asks for (gum_01 is big and leans less
at 0.85; gum_02 is whippy at 1.20 and shows a gust first).
sway_phase draws from its own RNG stream: taking it from the shared one would
consume a value and shift every blob draw after it, resilhouetting a tree the
other lanes have already tuned against. Exported dims are identical to Sprint 1.
e.test.js asserts the pivot by rotating the handle and measuring that a blob
actually travels — red before this change, green after.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
SPRINT2 §Lane A steps 1-4. The placeholder capsule and stub wind are gone; every
lane's proven module now runs in one yard.
Wind goes through a router. Every consumer binds to `wind` exactly once at
construction — the yard closes over it for tree sway, createPlayer takes it in
opts, createDebris reads its event stream — so swapping storm_01 for storm_02 at
the phase change has to be a re-point, not a re-wire, or half the game would
still be sampling a calm day while the other half is in a gale. Shelters apply to
every storm, since the trees don't stop existing when the weather turns.
skyfx is rebuilt rather than re-pointed: it reads the storm's sky block at
construction. Verified its dispose() hands world.sun/world.hemi back exactly
(2.0/1.8 after a 40 s storm had them at 1.07/1.13) and that nothing compounds
over repeated phase cycles — that was Lane C's §Lane C.5 ask.
Two seams needed care. createSailView reads rig.pos/rig.tris, which don't exist
until attach() allocates them, and a re-rig can change the grid — so rigSail() is
the one door both boot and Lane B's picking adapter come through, rebuilding the
view and re-wiring interact (whose targets close over a corners array that
attach() replaces). And knockdown(t, dirX, dirZ) takes the sim clock first, not
the impact: passing debris impact there would jam ~40 into the state machine's
start time and the player would never get up.
Storm_02 verified end to end by hand: the carabiner blows at t=45.4 s, and p2's
shackle cascades at t=56 s — one second after the southerly change. Coverage over
the bed is 1.0 intact and 0 once two corners are gone, which is the whole game in
one number. 0.63 ms/frame (0.17 sim + 0.45 render) against a 16.67 ms budget.
Selftest 121/0/0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lane C's skyfx modulates sun and hemi as the storm builds and hands them back on
dispose() — it doesn't own them. It already had `sun`; `hemi` was private, so the
sky could darken but the sky-bounce fill couldn't follow it down.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`ready` only meant the graph got built — a suspended AudioContext is still
silent, so there was no way to tell whether the storm was actually audible.
The HUD now reports the real context state.
Verified through it: context runs on first gesture; 7.5 -> 17.8 m/s takes the
wind bed 0.16 -> 0.36 gain while the howl layer goes 0.016 -> 0.104 and the
cutoff opens 428 -> 767 Hz, so a gale reads as a gale and not just a louder
breeze. Rain tracks its curve 0.03 -> 0.32.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rebased onto M0 and reconciled against the real spine. checkContract
('sailRig') now conforms and js/tests/b.test.js runs 28 asserts green.
Contract fixes:
- anchor.sway(t) is the ABSOLUTE position, not an offset (thanks A —
I had it adding sway to pos, which would have flung every
tree-anchored corner to double its coordinates).
- events is an Emitter emitting {type, corner}, not a drained array.
- coverageOver() rects are centre+size, matching world.gardenBed. It
consumes world.sunDir directly: a hit along sunDir means shaded.
- START_BUDGET/SPARE_COST/HARDWARE/FIXED_DT now come from contracts.js
rather than being redeclared here.
Bug: a corner that blew was marked broken but never had its mass
returned, so invMass stayed 0 and the "blown" corner sat welded in
mid-air — no flogging, and the sail silently went dead. PLAN3D §5-B
wants flogging emergent from the freed node, so _checkFailure now frees
it. The cascade test missed this because it called _repin() by hand;
the new test drives a real overload failure instead and asserts the
corner tears 2 m off its anchor and keeps moving.
Tension dial remapped from the prototype's rest/tension to a real
pre-strain. rest/tension asks for 17% strain at dial 1.2 and 29% at 1.4
— stretching an 18 m sail by three metres — and put 68 kN on a corner of
the yard's biggest quad with no wind blowing. At 0.10 strain-per-dial it
swings a 5x5 rig's peak load 2.1x loose-to-tight and redlines a 192 m2
quad at 8.3 kN drum-tight, which is punishing and correct.
HARDWARE ratings retuned in contracts.js to real newtons per the
standing note there that Lane B owns these numbers. Costs and tier shape
untouched; $80 still buys rated hardware on at most 2 of 4 corners.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ports the prototype's economy verbatim: $80 budget, $5/$15/$30 hardware
tiers, $15 spare, tension 0.6-1.4. Adds unrig-with-refund, which the
prototype lacked — a misclick there was unrecoverable, and a full refund
costs the economy nothing.
RiggingSession holds all the rules and is three-free and DOM-free, so it
tests headless. The picking UI is left as an explicit seam: it needs Lane
A's camera and anchor markers to raycast against, which do not exist yet.
One assert encodes a design invariant rather than a code fact: $80 must
not buy rated shackles on all four corners. DESIGN.md's economic tension
is that you always field one dodgy corner and choose which one; if that
test ever passes, the budget has become decoration.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verlet cloth on a bilinear patch between 4 anchors, N=10 grid,
structural/shear/bend constraints, 5 relaxation iterations at a fixed
1/60 substep. Wind is applied per FACE so hypar twist genuinely sheds
load rather than being cosmetic.
Two deviations from PLAN3D worth flagging:
- Load is read from each constraint's XPBD Lagrange multiplier, not from
FABRIC_K * leftover-stretch. After a fixed iteration count the leftover
stretch is solver error, not fabric strain, so the naive reading came
out ~50x hot (60 kN peaks on a 5x5 m sail). The multiplier is the real
constraint impulse, which the statics assert confirms by balancing the
corner reactions against the applied wind to 8%.
- Wind uses a signed square (d*|d|) rather than clamp(d)^2, so the
leeward face is pushed too. A sail is double-sided.
The sim core deliberately does not import three.js: it runs headless
under node today, stays allocation-free in the hot loop, and replays
bit-for-bit. createSailView() pulls three in lazily for rendering.
Loads land in real newtons (~1-4 kN on a 5x5 m sail in a 34 m/s storm),
so hardware ratings are real working load limits.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
player.sim.js Deterministic core, zero imports: camera-relative movement, the
state-machine table, wind slow/gust-shove/knockdown. step(dt,t)
is the whole clock — no Date.now, no Math.random, no rAF — so a
storm fast-forwards identically in selftest and in the game.
player.js The view: rig load per the 90sDJsim DEVMANUAL rig rules
(SkeletonUtils.clone, height-normalise off the MEASURED head
bone, canonicalised bone namespace), clip retarget, and
createPlayer() satisfying the Player contract.
interact.js Hold-E with radial progress + wireYardActions (re-rig 2.5 s,
turnbuckle trim 1.2 s, carry-one-item), duck-typed so it no-ops
cleanly until Lane B lands sailRig.repair/trim.
d.test.js 20 asserts in Lane A's harness: 38 pass / 3 skip overall.
Ported from the 2D prototype's shape (game.js:250-252), retuned to m/s: the
slow curve, and shove gated to gusts only and scaling with speed² so gusts have
teeth. Knockdown needs 0.5 s of SUSTAINED overload — deliberately the same rule
as a sail corner letting go, so cloth and people speak one language.
Two decisions worth the review:
- The knockdown pitches the root in code rather than playing the Falling clip's
root. Shared clips must drop Hips.quaternion (a different-orientation source
lays the target flat), so a clip physically cannot lie the body down. Doing it
in code also lets the fall go DOWNWIND of the gust that caused it, which a
canned clip could never do, and keeps it deterministic.
- Gust magnitude is recovered from a slow EMA of local wind rather than widening
Lane C's contract: wind.sample() gives the total and gustTelegraph() only fires
BEFORE a gust, so nothing reports gust size during the hold. The EMA
self-calibrates to whatever storm JSON Lane C authors.
Verified in a real scene, not only in asserts: head bone 1.715 m at fig scale
0.983, all six clips bound, walk/run at the tuned speeds, the body lies down and
gets back up, hold-E fires exactly once per press.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the skip stub with the checks Lane A's header asked for — every GLB
loads, is metre-scale with height on +Y, and keeps the nodes other lanes query
by name — plus anchor world-position and the garden bed's three damage states.
This catches what the Blender side structurally cannot: Blender exports
Z-up->Y-up and imports Y-up->Z-up, so a broken export_yup round-trips green.
A native glTF reader is the only thing that can prove the file.
GLTFLoader is imported dynamically on purpose. Three.js addons import the bare
specifier `three`, no page in the repo has an importmap yet, and selftest.html
turns an un-importable lane module into a hard FAIL — so a static import would
redden Lane A's merge gate over a harness gap rather than a real defect. It
skips with the fix instead, and lights up by itself once the importmap lands.
Verified behind a temporary probe first: 36/36 pass. Need logged in THREADS.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three bugs the bench found once it was actually running a storm:
- Debris was glued to the floor. The scrape `v *= 0.86` ran every frame while
grounded, which is 0.86^60 per second — it pinned a 9 kg crate at 0.7 m/s in
a 19 m/s wind. Friction now applies on impact, with dt-scaled rolling
friction while resting. A crate crosses the whole yard at ~6 m/s.
- Debris slid instead of tumbling: wind is horizontal, so once down there was
no vertical force at all and it skated at constant height. Added tumbling
lift that flips sign as it rolls — bins hop now.
- The cloud dome had a dead straight seam across the sky. The fbm claimed to
tile and didn't; now each octave wraps at its own integer period.
Also: shelters and the bench now use Lane A's landed yard coords (t1 -9,2 /
t2 8,-2, gardenBed 1,2) instead of my guesses, so shelter tuning means
something.
Verified in-browser against a real storm: crate crosses the yard and the t=74
bin spawns from storm JSON; sail node shoved 0.32 m; a 14 kg bin at 20 m/s
knocks the player down and a tub drifting at 0.3 m/s correctly does not;
lightning peaks 0.88. Lane A's selftest: 37 pass / 3 skip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verifying the GLBs in three.js (not just Blender) showed four assets reporting
inflated bounds: tramp_01 came back 3.29 x 1.27 m against a true 2.96 x 0.78.
Box3.setFromObject expands each mesh's LOCAL box by the world matrix, so a node
carrying a rotation over-reports — the same trap Blender's obj.bound_box sets,
and what three uses for frustum culling. Joined nodes inherited parts[0]'s
rotation, which for an arc is half a segment step off-axis.
Applying rotation at join time makes every local box axis-aligned, so the
default Box3 path is now correct for consumers and culling is tight. World
geometry is unchanged; the exported dims are identical.
Adds tools/assetcheck/, the three.js harness that caught this. It's the only
check that can: a Blender round-trip exports Z-up->Y-up and imports Y-up->Z-up,
so a broken export_yup flips back and passes green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
16 generated assets plus the grass billboard atlas and the four debris models
copied verbatim from the 3D-STORE library (copies rule, §0). All meter-scale,
Y-up, and far under the 15k tri budget — garden_bed is heaviest at 2,580.
Regeneration is byte-deterministic: two consecutive runs produce identical
files, so re-running the factory causes no churn.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
debris.js: hand-rolled kinematic tumble, drag ∝ speed² so the gust that
spikes a corner is the one that launches the neighbour's bin. Ground bounce
via world.heightAt (contracts.js documents it as ours), sphere-vs-player
knockdown reported to Lane D, sphere-vs-sail-node impulse duck-typed so it
lights up when Lane B exposes nodes and stays silent until then.
skyfx.js: instanced rain that wraps around the camera rather than respawning,
storm sky + procedural cloud dome, lightning, and synthesized WebAudio layers
(wind bed, howl, rain, gust whoosh on the telegraph, rope creak off the worst
corner, flog when one blows). It modulates Lane A's lights and hands them back
on dispose() rather than owning them.
weather_demo.html: graybox bench to drive all three before M0 — mock sail,
storm scrub, 4x, throw-a-crate.
Aligned to contracts.js now that it has landed: relative three imports (there
is no importmap), contracts' rng() instead of a local copy, and storm paths
resolved off import.meta.url — server.py serves the repo root, so an absolute
/world/... would have 404'd at integration.
storm_02: fix the southerly change. contracts.js puts north at -Z and the wind
vector blows toward (cos d, sin d), so the old swing to +2.6 blew toward due
south — a northerly wearing a southerly's name. Now slews to -1.35: a SSW
buster off the open side of the yard, into the house.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements the contracts.js wind surface (PLAN3D §4): sample(pos,t) and
gustTelegraph(t), plus storm defs as data.
The prototype scheduled gusts by integrating (wind.gustT += dt). We can't:
sample(pos,t) is called by sail/player/debris/rain at arbitrary t, so gusts
are precomputed into a timeline from a seeded PRNG at load and read from t.
Envelope shape is a faithful port — telegraph 1.5s / ramp 0.8s / hold 1.7s /
fade 1.0s.
Maths lives in weather.core.js with zero imports (no THREE, no DOM, no
Date.now), so the determinism rule is structural and the suite runs in node
without waiting on Lane A's M0.
storm_01_gentle peaks at 11 m/s; storm_02_wildnight sustains 20 m/s and gusts
to 32 (BOM 'destructive'), with the southerly change landing just before the
worst of it — the corners that were slack all storm are the ones that cop it.
15 asserts green: telegraph lead >=1.2s, wind continuity in time and space,
storm JSON validator (incl. 14 deliberately-broken defs it must reject),
determinism, sample-order independence, tree wind shadow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
selftest.html only imports; each lane owns js/tests/<letter>.test.js. Five lanes
sharing one selftest.html would be the single guaranteed merge conflict in the
repo, so the stubs are pre-created with each lane's PLAN3D asserts written into
the header.
No rAF anywhere: it's throttled in a hidden tab, so a rAF-driven selftest would
pass only while you watch it. fixedLoop() drives time instead.
Lane A's suite covers the §5-A acceptance criteria, including "camera never
clips through house" — stated as the underlying invariant (no solid between the
player's head and the camera) so it survives Lane E swapping the house GLB.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The yard's meshes are placeholders; its layout is not. Anchor positions, ground
heights, the garden rect and the sun direction are what the other lanes build
against, so Lane E's GLBs drop into named slots without anyone re-deriving them.
Sun sits in the north (Australian yard), which puts the house's yard-facing wall
in permanent backlight. That's correct, but the fascia line carries three of the
seven anchors, so sky fill is turned up to keep it readable rather than a void.
Camera collision resolves toward the geometry, not away from it: clamping up to
a comfort distance after the raycast is what put the camera 17cm inside the
north wall. Ground is handled by heightAt() instead of a raycast — exact, can't
be tunnelled, and free.
main.js runs a fixed-dt accumulator so sim modules only ever see FIXED_DT. That
is what lets selftest fast-forward a 90s storm and get the numbers the player
got. Nothing auto-runs on import; index.html calls boot().
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Types, tuning constants ported from the prototype, a seeded PRNG and Emitter,
plus checkContract() — the tripwire that catches a lane drifting from its
interface at merge time instead of three lanes later.
createStubWind() ports the prototype's gust shape (telegraph 1.5s, ramp 0.8s,
hold 1.7s, fade 1.0s) so B and D can develop before Lane C lands weather.js.
Its schedule is precomputed at construction, which keeps sample() pure in t —
the selftest samples out of order and must get the same answer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
server.py serves the repo root rather than web/, so the 2D prototype stays
reachable next to the 3D port — you want to flip between the reference
implementation and the thing you're porting without stopping the server.
--selfcheck verifies the tree and prints URLs; no node, no network.
three r175 is copied in from 90sDJsim rather than referenced, so the game has
no runtime dependency on a sibling checkout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Character is a byte copy of 90sDJsim's man_casual_01 ped (already metre-scale,
head bone 1.75 m, bind pose proven in that game). Clips ride beside it in an
anim-only carrier built from the FBX libraries on the M1 Ultra.
The peds cannot round-trip through Blender: they encode metre scale as a node
scale of 0.01 on mixamorig*:Hips with child bones in centimetres, and Blender
bones carry no rest scale, so the glTF importer silently drops it — the rig is
exploded on import (LeftToe_End 96 m below the floor) before any merge happens.
So merging clips onto the ped (PLAN3D 5-D.1) is not viable; we ship the ped
untouched and retarget rotation-only clips onto it at runtime, the same shape
90sDJsim already uses for peds/idle.glb + peds/walk.glb.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>