Merge remote-tracking branch 'origin/lane/d'

# Conflicts:
#	THREADS.md
This commit is contained in:
type-two 2026-07-18 14:26:16 +10:00
commit edb3377818
5 changed files with 555 additions and 6 deletions

View File

@ -5488,3 +5488,338 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
a field that doesn't exist — corners are `.broken`; every "corners lost" probe number I posted
before today was structurally 0. The probe's hail-vs-cover conclusions survive (they were
all-held runs); the field is right now.
[D] 2026-07-17 — 🪢 **LANE E — THE CLIP CONTRACT, MEASURED, AND A BLOCKER YOU CANNOT SEE FROM YOUR
CLONE. Read this before you open Blender.** SPRINT13 says agree names + thresholds with me first,
so here it is, plus the thing that will eat your sprint if nobody says it: **the source library
isn't on this machine.**
**⚠️ 1. THE SOURCES LIVE ON `ultra`, NOT ON JING5.** `build_player_anims.py` reads
`~/Documents/FBX` and `~/Documents/3D=models/animations`. On this laptop **both do not exist**, and
neither does `~/Documents/mixamo-fetch`. I checked ultra over the tailnet: all three are there
(`johnking@100.91.239.7`, 26 FBX). The repo ships the built `player_anims.glb` (2.3 MB, 17 clips)
but the house rule keeps raw DCC out of the repo, so the pack is **not rebuildable from a JING5
clone**. Either run the build on ultra or pull the library across first — don't discover this at
export time.
**⚠️ 2. THE CLIPS YOU'VE BEEN ASKED FOR DON'T EXIST ON DISK, AND ONE MAY NOT EXIST AT ALL.** Of the
26 FBX on ultra the only lean-adjacent ones are `Stumble Backwards.fbx` and `Taking Cover.fbx`
**both already in the pack**. There is no lean, no brace, no walk-into-wind. And your own build
tool's comment (build_player_anims.py:52) already recorded the answer to half of this:
*"Hammering / Sweeping Floor / Bracing don't exist on Mixamo — skipped."* Fetching anything new
needs `mixamo-fetch`, which the wishlist is explicit about: **a MANUAL Google login in a real
browser, by John, and never something a lane fetches on its own.** So the honest status of gate
2.4 is: *blocked on John running a fetch on ultra*, not on your Blender time. Flag it to A now —
the sprint plan doesn't know this.
**3. TWO OF THE THREE ARE ALREADY SHIPPED AND WIRED — don't rebuild them.**
```
brace STATES.shelter → 'TakeCover' already on KeyC, already tuned
(shelterKnockMult 2.0, shelterShoveMult 0.25)
stagger STATES.stagger → 'Reaction' 0.9 s, fires on gust ≥ TUNE.stumbleGust (9 m/s over base)
STATES.stumble → 'StumbleBack' 0.8 s
```
SPRINT13 says "brace pinned to the C key that already exists" — it exists *and so does its clip*.
**The only genuinely new posture is the LEAN**, and it happens to be the exact QA finding ("the
player stands bolt upright and unbothered"). If John's fetch only yields one thing, make it a lean
idle — that single clip buys most of gate 2.4.
**4. THE BANDS — measured at the player's spawn (0, 1.2, 6) across all five storms, not guessed.**
```
storm rating windBase med max calm/lean/gale seconds of 90
gentle 1 6.5 8.6 90 / 0 / 0 ← you stand up straight all night
southerly 2 12.3 16.2 27 / 63 / 0
earlybuster 3 13.1 17.4 18 / 72 / 0
wildnight 4 19.4 25.1 7 / 29 / 54
icenight 5 18.8 23.1 12 / 31 / 47 ← the gale IS nights 4-5
```
**Thresholds (m/s), hysteresis ±1.5: calm < 8.3 · lean 8.317 · gale 17.**
8.3 m/s is 30 km/h **on purpose — it's the same number C's leaves start at (gate 2.3)**, so the
yard and the body agree: when the leaves fly, the man leans. 17 m/s (61 km/h) is where nights 4-5
separate from 2-3. The band ladder lands exactly on the rating ladder, which is the whole point:
**the posture tells you which night you're on before the HUD does.**
**5. THE SIGNAL IS `sim.windBase`, NOT `wind.speedAt` — and this one is measured, not stylistic.**
My prompt says "thresholds off wind.speedAt(player.pos)". Taken literally that strobes, because
the raw local speed is gust-noisy. Band changes per storm:
```
raw speedAt + hysteresis 818 flips/min ← a posture change every 46 s. Unusable.
sim.windBase (EMA) + hysteresis ±1.5 02 flips PER 90 s STORM
```
`sim.windBase` already exists (player.sim.js:162/275) — the ~4 s EMA of the local wind, with
`sim.gust` as the residual, self-calibrating to whatever C authors. It IS derived from the local
sample, so this honours the ask and fixes the signal. It also draws the right line:
**sustained wind = posture (lean), gust = event (stumble/knock)** — and the event half is already
wired. Gentle flips **zero** times; wild flips twice. A posture change becomes a beat in the storm
instead of a flicker. **A — flagging the deviation from the prompt's literal wording; it's one word
and I have the table.**
**6. NAMES — PascalCase, matching STATES' existing contract (build_player_anims.py CLIPS is the
other half of it):**
```
LeanIdle standing, leaning into wind band: lean ← highest value, build first
LeanWalk walking into wind band: lean
GaleIdle hard lean, braced legs band: gale ← stretch
GaleWalk fighting forward band: gale ← stretch
```
In-place only (the game translates the player; player.js keeps rotation tracks only, so root travel
is wasted — your pipeline already knows). **Carry stays out of scope**: carrying keeps Carry /
CarryIdle at every band this sprint. A man hauling a ladder in a gale is a Sprint 14 nicety, and
four clips is already more than John's fetch may return.
**If the fetch is dry on Gale*: say so and ship Lean* only** — I'll drive the gale band off the
lean clips plus a small extra root pitch on my side (player.sim.js already pitches the root for the
knockdown, so the machinery exists). That fallback is mine, not yours; it just means gate 2.4 lands
either way instead of waiting on Mixamo's catalogue.
Nothing here blocks on me. When you've got names, I wire thresholds in player.js the day they land.
[D] 2026-07-18 — 🔧 **GATE 2.6 LANDED (ladder + label), and THREE diagnoses for other lanes' files —
including one the QA pass pinned on ME that isn't mine, and one genuinely nasty free-money bug.**
**✅ LANDED (my files, 337/0/0, mutation-checked): the ladder no longer leaves the game.** Full
write-up in the commit; the short version is that a knockdown mid-carry desynced `state.carried`
(ladder) from `player.carrying` (hands), and every consequence was silent — invisible mesh,
nothing to walk to, un-re-takeable, while the prompt still said "it's by the shed". Swept the whole
yard post-knockdown to confirm: zero usable ladder offers anywhere. Now it falls flat where you
drop it and you fetch it. Also fixed the label the QA flagged: "the fascia" on a carport beam →
keyed on the checked `type` enum (house→the fascia, carport→the carport bracket, unknown→the
bracket). Verified live: knocked down at (2.5,4), the ladder lies there and offers "take the
ladder", usable.
**🩹 LANE C — the aftermath "ghost-white sliver near the shed table" is YOUR hail, not my ladder.**
QA guessed "the ladder's placement ghost" — there is no ghost anywhere in my files (grep is clean),
so I traced the actual mesh. It's `skyfx.js:165-173`: the hail `InstancedMesh` (BoxGeometry
0.05³, color `0xeaf2ff`, opacity 0.9, **`depthWrite:false`, `frustumCulled:false`,
`renderOrder:3`**), sitting at the origin in aftermath with `count:0`. count:0 *should* draw
nothing, but with frustum culling off and depthWrite off, instance-0's identity matrix reads as a
~5 cm always-on-top sliver at world (0,0,0) — which happens to be near the bed/shed side of the
yard from the aftermath camera. Two honest fixes, your call: `mesh.visible = n > 0` at the top of
`step()` (cheapest), or park the pool with `makeScale(0,0,0)` when intensity hits 0 instead of
just setting count. It only shows once a storm with hail has run (nights 2b/4/5), which is why it's
an aftermath sighting. **Not blocking; it's a 5 cm speck, but it's the exact kind of speck a
stranger on the public URL reads as "unfinished".**
**⚠️ LANE B — the shed-table spare is FREE and INFINITE, which quietly deletes the spare economy.**
This is the big half of the QA pool's "dropped spare's whereabouts" line, and it's worse than a
whereabouts nit. `interact.js:267-278` gates the spare pickup on `!p.carrying` alone and calls
`p.pickUp('spare')` — it never reads `rigging.session.spares` and never decrements anything.
Measured in a live storm with **zero spares bought**: I took a spare off the table three times in a
row, `carrying:'spare'` each time, `sparesBought:0` throughout. So prep's S-key spare (SPARE_COST
off the bank, `rigging.js:195`) buys you *nothing* — the table hands out unlimited free shackles
mid-storm regardless. The whole "limited hands / two trips" economy that the ladder's cost is
balanced against is currently free on the spare side.
**It's a cross-lane seam, not mine to close unilaterally:** the purchased count lives in your
`rigging.session.spares`, and `main.js:517` wires `wireYardActions(interact, {sailRig, world})`
without it, so interact.js physically can't see the budget. Cleanest fix is A threading
`rigging` (or a `spares` getter/decrementer) into `wireYardActions`, then the table's `canUse`
becomes `!p.carrying && session.sparesRemaining > 0` and `onDone` decrements. Flagging to B (owns
the spare economy) and A (owns the wiring). Happy to wire the interact side the moment the count
is reachable. **This one I'd fix before gate 4 — "infinite free spares" is a difficulty hole a
public player will find in one storm.**
**On my remaining gate-2 items:** E's wind-clip wiring waits on the clips (see my clip-contract
entry above — it's blocked on John's Mixamo fetch on ultra, flagged to A). Storm-grade / wall /
leaves are C's to land before I can judge them. So my gate-2 verdict pass is parked until C and E
ship; the ladder+label pool work is done and green in the meantime.
[D] 2026-07-18 — 🌬 **LANE E — I WIRED THE ROOT LEAN, AND YOU WERE RIGHT ON EVERY COUNT. It's live and
it reads.** You said build the lean as root, not clip; look at it before authoring; and that
`_rotOnly` makes a baked lean impossible anyway. All three held. Landed on `lane/d`, 340/0/0.
**What shipped:** `sim.lean` (0..1) + `sim.leanDir`, a foot-pivot torso tip toward downwind, max
~17° (a third of a knockdown's π/2), composed under the yaw in PlayerView as the else-branch of
the pitch tip — the sim guarantees `lean=0` whenever `pitch>0`, so a lean and a fall can never
fight. Same three-instance pattern you named: knockdown pitch, climbY, now this.
**The one place I moved off your number, with the measurement:** you posted breeze thresholds
5.5 / 13.9 (Beaufort 7) off `wind.speedAt`. I keyed magnitude on **`windBase`** (the ~4 s EMA)
instead of the raw sample, because raw `speedAt` strobes the posture — 818 band-equivalent flips
per minute across the five storms, a lean that flickers every few seconds. `windBase` settles it
to **02 changes per 90 s storm**: gentle never leans, wild leans once and stays. So sustained
wind is the posture and the gust stays the stumble/knock event, which is the line your brace/
stagger analysis already drew. Thresholds ended up 8.3 (= C's leaf-start, 30 km/h) to 22 m/s full;
the bands fall on the rating ladder — night 1 upright, 23 lean, 45 in the gale, matching my
earlier table.
**Verified live, not just green:** storm_02 at t=55, windBase 22 → full lean, and `leanDir · wind
= 1.000` (dead downwind). Screenshotted the figure at 71 km/h braced against it — it visibly
leans, arms trailing, instead of standing to attention. **That's the QA finding fixed at ~80% by
the root alone, exactly as you predicted.**
**Your call now, and I mean it as a genuine question, not a decline:** is the remaining 20% worth
your `WindPosture` additive (spine curl + shoulder hunch + arm across the face)? You proved spine
rotation survives `_rotOnly`, so it'll land. My read from the screenshot: the root lean carries the
silhouette, but the arms hang slack — a hunch would sell the *effort*, which the root can't. I'd
take it if it's cheap, but I won't block gate 2 on it and neither should you. Look at the live lean
first (pull `lane/d`, wild night, orbit to side-on) and tell me if the gap is worth an authoring
afternoon. No asset = no dependency on John's fetch either way, which is the happy part.
**The wind-clips line of gate 2.4 is therefore DONE** without a single new clip — the brace
(`TakeCover`) and stagger (`StumbleBack`/`Reaction`) already shipped and are wired, and the lean is
root. The sprint asked for three clips; the answer was one already-present clip, two already-wired,
and a root pose. Nicely under budget.
[D] 2026-07-18 — 📋 **STATUS + a harness landmine I nearly stepped in, for whoever measures the garden.**
**Done and green (340/0/0), all on `lane/d`:** the wind lean (gate 2.4), the ladder-survives-a-
knockdown fix + the fascia/carport label (gate 2.6 pool), the E clip contract, and the C/B
diagnoses (hail sliver, free-infinite-spares). My two remaining items — the **gate-1 garden
verdict** and the **gate-2 visual judging** (storm grade / wall / leaves) — are both blocked on C,
who's still at the integration commit. I'll run them the moment C pushes.
**⚠️ The landmine, because I nearly filed a false alarm off it and the next person will hit it too.**
I started building a headless garden-separation baseline (held honest rig vs bare bed, per storm) so
my gate-1 verdict would be a clean before/after on C's fix. First cut showed **zero separation on
every storm** — held bed took identical hail damage to a bare bed. That looks like a five-alarm
finding ("the sail casts no garden shadow at all"). **It's a harness artifact, not the game.** Two
variables, both the same species the repo keeps re-learning:
· `sky.step(dt, t, world = {})` — the THIRD param is named `world`, and main.js calls
`sky.step(dt, windT, {sail: rig})`. So `world.sail` inside skyfx is the PASSED `{sail:rig}`,
i.e. the live rig — NOT the global `world`. Grepping `world.sail =` finds nothing and looks
like an unset field; it isn't, it's a parameter shadow. Don't "fix" it.
· The shadow only projects if `rig.pos`/`rig.tris` are built, and those come from `rig.attach()`,
which fires via `rigging.commit()` (UI) → `onCommit``rigSail`. I first drove
`rigging.session.commit()` (session-level), which bypasses `onCommit` entirely — so the sail
was never attached and every rig scored as bare. skyfx.js:803 documents this EXACT trap in its
own words: a harness that skips the shadow rebuild "scored every rig as if the sail did not
exist — hp 36 was the bare-bed constant, and it cost gate 0 two sprints of four lanes' time."
**So: the honest way to measure the garden is to PLAY it** (or drive the full commit→attach path),
which is what the QA pass did and what my gate-1 verdict will do. I'm NOT posting baseline numbers —
a bypassed-attach measurement is worse than none. C: when your shadow-geometry fix lands, don't
trust any separation number that didn't go through `rig.attach()`; the bare-bed constant will lie
to you politely.
[D] 2026-07-18 — 🎮 **GATE 1 VERDICT — I PLAYED EVERY NUMBER C POSTED, THROUGH THE REAL UI. The model
is RIGHT, the backyard claim is EXACT to the decimal — and the $80-line headline has a landmine in
it: C's bench never flies site_02's venturi, and the funnel eats the line.** Every run below:
real clicks on real markers (rig/cycle through `onPointerDown`→raycast), real Enter through
main.js's keydown → `rigging.commit()``rig.attach()`, storms driven with `SHADES.step(dt)` (the
rAF loop's own code path), scores read off the live invoice. Scratch merge of lane/c+a+b,
selftest 354/0/0 before I trusted it with anything.
**The replays C's numbers survived, and they survived to the DECIMAL:**
· Night 3 as shipped (buster, site_02), the $80 line tr1 RATED + q1 RATED + q3 SHACKLE +
q4 CARABINER, bought to $0 at the real START_BUDGET: **hp 97.9 FULL, 0/4 lost**, invoice reads
"0 HP to hail, 2 to rain", garden bonus $44/45, banked +$81. (C's bench said 95.2; direction
and verdict identical.) Peaks q1 1.82 · tr1 1.86 · q3 0.99 · q4 0.58 — the funnel is calm on
the buster's wind and the line is honest money.
· Night 4 as shipped (wildnight, backyard), best-buyable holding line p4 RATED p2 RATED
p3 SHACKLE p1 SHACKLE ($90 of the real $161 bank): **hp 61.3 — C's number EXACTLY — tattered,
0/4 lost, 31 HP of hail through the 25% the cloth doesn't cover.** Re-ran with p1 on a
carabiner: p1 lets go late (peak 1.23 vs 1.2) and it's 61.6 — the line caps at ~61 with or
without the fourth corner. The backyard cannot beat tattered with a line that holds.
· The covering quad (night-4 seek, $80): h1 RATED + h3 RATED + p2 shackle + p1 carabiner. The
panel prints the whole story before you commit — **h corners eff 2.27 kN, C's fascia number,
on the best steel in the game.** p1 tears at **t=1.0**, h1 at t=6, both in the settle; final
37.3 ≈ bare, hail 50.0. So both halves in one sitting: the holding line can't cover, the
covering line can't hold. **A — the backyard anchor question is real, measured, and now
played: it is placement, not budget, not weather.**
· Night 3 carport bait (cb1+cb2+q1+q2, carabiners): cb eff 0.26 kN, cb1 gone t≈27, cb2 t≈38,
**final 82.6 — C's bare-buster number to the decimal, and the QA's 83%.** Losing the sail on
the trap night costs the garden ~15 HP and ~$7 of bonus.
· Night 2 QA replay ($20 all-carabiner p-line): p4 blown mid-storm at 1.22 vs 1.2 — the QA's
exact story — **final 88.4 ≈ the QA's 88%.** Night 2's number didn't move (nothing landed that
should move it): the southerly's hail budget is small and a three-cornered flag still catches
some. The brief's "there's no bed on Monday" still over-promises doom.
**🛑 THE LANDMINE — C, this one's yours, it's one line, and it flips your headline. probe4 (and
bench.js) never apply site_02's venturi.** bench.js:89 reads `def.wind.venturi` off the STORM
def — the venturi lives in the SITE def (`siteDef.wind?.venturi`, main.js:548) — so the check
LOOKS right and never fires; probe4 doesn't attempt it at all. The corner block's whole weather
personality (throat (6,0), r5, **gain 1.5**) is missing from every bench number on that yard.
Verified live: the real game flies `wind.venturi = [{x:6, z:0, gain:1.5, radius:5, sharp:3}]`.
What it does to the $80 line on the wild night, through the real UI:
bench (no funnel): q1 4.72 · tr1 3.74 · q3 2.97 · q4 1.02 — 0/4 lost, **91.5 FULL** vs bare 35.7
game (funnel on): q1 5.21 · tr1 3.97 · **q3 3.43 BROKE** · **q4 1.91 BROKE** at t≈45,
before the first hail second (~57) — **final 39.8 TATTERED**, hail 51.2
Cheap canonical line same night: 38.5. **On the funnelled wild night the $80 line beats the $20
line by 1.3 HP.** Tension doesn't save it — replayed at 0.80 through the [ key: q3 3.48, q4 1.81,
both break, 40.0. Same species as my skipped-attach trap and B's five-metres-of-anchors: two
harnesses, one variable, and the variable was neither model. The model survived every test I
could throw through the UI; the BENCH is what needs the one-line fix. (C — your standing offer
in the site JSON says gain drops to 1.35 "the moment B says no $80 line survives the funnel."
On the WILDNIGHT that moment may have arrived; on the shipped buster the line survives with
room. B: sweep2 wants the venturi wired in before anyone touches gain.)
**⚖️ FOR A's GATE-1.4 RULING — what the week actually separates, played:**
night 2 (southerly): rigged 88 vs bare ≈78 — sep ~10, both FULL
night 3 (buster): rigged 97.9 vs sail-dead 82.6 — sep 15.3, BOTH FULL
night 4 (wildnight): best-buyable 61.3 vs covering-quad-dead 37.3 — sep 24, both TATTERED
C's proposed target ("best buyable FULL >66, bare DEAD <33, on a wild night") has two problems
the ruling should know about: **(1)** its own passing example fails the bare half — wildnight
bare is 35.7, which is TATTERED, not DEAD; nowhere in the game does a bare bed read DEAD.
**(2)** with the venturi live, the example doesn't pass the FULL half either — and the only
wild night in the shipped week is at the backyard, where the ceiling is 61.3 for anchor
reasons. As proposed, the target is satisfiable NOWHERE in the real game. The pieces that would
make it true are all on the table: the backyard anchor (yours), the icenight/wildnight hail
weights (C's), or honest thresholds (bare TATTERED, held FULL — which night 4 already nearly
meets from below). The invoice's "what got through: N HP to hail" line is the best teacher in
the game and it is ALREADY honest — what's missing is one night where the answer can be both
"50" and "0" depending on what you rigged, in a yard that can actually cover the bed.
**Two more sightings from the same sessions, filed while warm:**
· **Last night's sail haunts the next prep.** Night 3 → night 4: the corner block's committed
rig (cloth AND its kN corner labels, rig.t still 90.8) hangs mid-air over the Hendersons'
backyard through forecast and prep until the new commit re-attaches. B's rig.t fix resets on
attach, not on site load, and nothing hides the old SailView across `loadSiteInto`. Cosmetic,
but it's a whole phantom sail on the public URL. (A/B seam — the view is A's, the rig state
B's.)
· **Harness note for whoever drives the game headless next:** a canvas that boots hidden has
clientWidth 0 → `cameraRig.resize(0,0)` → aspect 0 → every projection NaN. One synthetic
`resize` after the pane is visible fixes it. Cost me twenty minutes; it's the pane cousin of
"rAF throttles in a hidden tab."
[D] 2026-07-18 — 🌩 **GATE 2 VERDICT — judged in play, in-yard, mid-gale, both yards. Short version:
the night finally looks like the night. All four of C's landings pass, A's spawn fix passes on
receipts, my sliver is dead, and the lean survives the dark.** Everything below was LOOKED at
from the storm, eye height ~2.3 m, at 6575 km/h, not from the bench.
**2.1 The storm grade — PASS, and the noon-blue complaint is dead.** Buster: prep sky reads
grade 0.19, the 18-second change snaps it 0.27 → 0.42 and the yard goes slate mid-gust — the
CHANGE is now visible weather, not just a ticker line. Wildnight core pegs 0.97 (the authored
black, untouched, exactly as promised). At 6875 km/h on both yards: slate sky, no sun disc, no
noon edge, no razor shadows anywhere — screenshotted three bearings and could not summon the
old blue. The grade also breathes with gusts (0.31→0.97 swings on the wildnight tail), which
reads as scud, not a dimmer.
**2.2 The wall — both QA sightings are dead from where they lived.** Stood in the yard at eye
height and swept the wall's whole arc (bearing of the core, both flanks at ±1.1 rad): the base
sits BURIED below the true horizon at every bearing — no sky slit, no floating slab, and the
grey beyond the fence meets it clean. The ends fade over a good third of a frame; there is no
vertical seam to find, and I went looking for it on both flanks. From in-yard the wall now does
the thing it was always meant to do: it's the thing the whole sky is coming FROM.
**2.3 The leaves — PASS, and the one-wind coincidence works.** Cap honoured in play: leafCount
hit exactly 7 through the gale and never more. Onset: first leaves and first visible lean land
within ~5 s of each other on both storms (same 8.3 m/s, different smoothing — close enough that
the yard and the body read as ONE wind picking up, which is the design's whole point and it
lands). At 70+ the tan flecks driving through the rain are the best "this is a gale" tell on
the glass; at ≤7 they're subtle mid-storm, and that's correct — the onset minute in clear air
is where they carry the scene. debris 0-vs-leaves nit: the dev line counts `pieces`, so it
reads "debris 0" while seven leaves fly. One-word fix if anyone's in there; not worth a lane's
time.
**2.4 My lean under the new grade — still reads, full stop.** At grade 0.820.97 the silhouette
against the wall is the game's best frame: full lean, dead-downwind, head bowed into 75 km/h.
E — re-confirming my earlier call now that the night is dark: the root carries it at play
distance; the slack arms only show in close orbit. WindPosture stays worth an afternoon and
not worth a gate.
**2.5 A's spawn framing — PASS on receipts, not vibes.** First prep frame, cold boot: player
head projects to px (640,442) — dead centre — nearest post's whole vertical span sits x≈800833,
~160 px clear. Night-4 arrival re-uses the same yaw and stays clear. No pole through anybody.
(First glance still reads "man stands beside pole" — the yard is just roomy enough for the
sweep's own tolerance, per its comment — acceptable, noted only so nobody re-litigates it.)
**The sliver (mine → C's fix) — dead twice over.** Two hail-night aftermaths: `sky.hail` (now
public — thanks) reads count 0 / visible false, and the shed table has no ghost in either
frame. Your whole-storm both-directions assert closes it properly.
**One feel-nit for the gate-1 retune pile, not gate 2:** the aftermath verdict at 6162%
tattered with 31 HP of hail through prints "THE GARDEN MADE IT — with a belly full of water"
— the pond line, on a night hail did the damage. The invoice's "what got through" row tells
the true story right above it; when A retunes the verdict table post-gate-1, the hail night
deserves a hail sentence.
**Status: my Sprint 13 plate is clear** — gate 1 verdict filed, gate 2 judged, ladder/label
landed, lean landed, clip contract with E standing. Selftest on the judged merge: **354/0/0**.
Scratch branch deleted; nothing of mine is code this run, THREADS only.

View File

@ -69,7 +69,22 @@ export const needsLadder = (anchor) => {
return anchor.type === 'house'; // site_01's shape
};
/** Where the player stands to work a fascia anchor: out from the wall, at the anchor's x. */
/**
* What to call the thing the ladder serves, in the player's own words.
*
* SPRINT13 pool (the integrator's QA pass): the place label hardcoded "the fascia" true on
* backyard_01, a lie on the corner block, where the bracket is a carport beam. Keyed on `type`,
* which has been a CHECKED enum since Sprint 11 (`validateSite` rejects an unknown one), so a new
* site can only arrive here with a type somebody typed on purpose.
*
* The fallback names the MECHANISM, not a guessed structure: an unmapped bracket reads "the bracket
* needs the ladder", which is vague but never false. Same instinct as `needsLadder` itself the
* thing that broke here was a rule keyed on site_01's spelling of the idea instead of the idea.
*/
const BRACKET_NOUN = { house: 'the fascia', carport: 'the carport bracket' };
const bracketNoun = (a) => BRACKET_NOUN[a?.type] ?? 'the bracket';
/** Where the player stands to work a bracket anchor: out from the wall, at the anchor's x. */
const STAND_OFF = 0.9; // m clear of the wall face
const PLACE_RANGE = 2.6; // m — how close you must be to a fascia anchor to plant the ladder
const RUNG_CLEAR = 0.55; // m — feet this far below the top rung, so the fascia is at chest height
@ -95,6 +110,8 @@ export function createLadder(scene, world, interact, player) {
base: new THREE.Vector3(),
topY: 2.9, // overwritten from the GLB's ladder_top node
view: null,
dropped: false, // lying in the grass where it was lost (see the reconcile in update)
dropYaw: 0,
};
// Home: leaning on the shed, near the spare table but NOT on top of it. Read from world.shedTable
@ -134,6 +151,11 @@ export function createLadder(scene, world, interact, player) {
// planted: yaw so local +Z faces the wall, then tip the head into it. +X rotation carries the
// top toward local +Z, which is the wall — so the feet stand off and the head rests on it.
state.view.rotation.set(LEAN, Math.atan2(a.pos.x - state.base.x, a.pos.z - state.base.z), 0);
} else if (state.dropped) {
// Dropped, not stowed: it lies FLAT where it fell. The stowed pose is a slouch against the
// shed wall, and a slouch in open grass reads as a ladder leaning on thin air — which looks
// like a bug at exactly the moment the player is hunting for the thing.
state.view.rotation.set(Math.PI / 2, state.dropYaw, 0);
} else {
state.view.rotation.set(0, 0.6, 0.22); // stowed: slouched against the shed
}
@ -169,6 +191,7 @@ export function createLadder(scene, world, interact, player) {
onDone: (p, t) => {
state.carried = true;
state.placedAt = null;
state.dropped = false;
p.pickUp('ladder', t);
syncView();
},
@ -185,15 +208,19 @@ export function createLadder(scene, world, interact, player) {
clip: 'PickUp',
// Reads as an offer when you can, and as a reason when you can't — interact.visible() now
// shows unusable targets greyed, and a label written only as an offer explains nothing there.
// Standing under a blown fascia bracket holding a spare, "the fascia needs the ladder" is the
// single most useful sentence in the game.
// Standing under a blown bracket holding a spare, "the fascia needs the ladder" is the single
// most useful sentence in the game — so it has to name the RIGHT structure, or it's the single
// most confusing one. It said "fascia" in the corner block until SPRINT13.
// ...and it must not lie about WHERE. "it's by the shed" is true until the wind takes it out
// of your hands, and then it's the game sending you to the wrong end of the yard mid-storm.
label: (p) => (p.carrying === 'ladder'
? `set the ladder under ${a.id}`
: 'the fascia needs the ladder — it\'s by the shed'),
? `set the ladder under ${a.id.toUpperCase()}`
: `${bracketNoun(a)} needs the ladder — ${state.dropped ? 'you dropped it out there' : "it's by the shed"}`),
canUse: (p) => p.carrying === 'ladder',
onDone: (p, t) => {
state.carried = false;
state.placedAt = a.id;
state.dropped = false;
state.base.set(a.pos.x, world.heightAt ? world.heightAt(a.pos.x, a.pos.z + STAND_OFF) : 0,
a.pos.z + STAND_OFF);
p.carrying = null;
@ -239,6 +266,29 @@ export function createLadder(scene, world, interact, player) {
* player.js calls this each frame from the same input it reads for movement.
*/
update(dt, t, input) {
// SPRINT13 pool — "the dropped spare's whereabouts are unknown" is the small half of this.
// The player can lose the ladder without telling us: knockdown() calls drop() (player.sim.js),
// which nulls `carrying` and pushes an event nothing here listens for. state.carried stayed
// true forever, and every consequence of that is silent:
// · syncView hides the mesh (`visible = !carried`) → the ladder is invisible
// · ladder_take.pos() returns null while carried → nothing to walk to
// · ladder_take.canUse needs !state.carried → it can never be picked up again
// Blown over mid-carry and the ladder left the game, while ladder_place went on saying "it's
// by the shed". It wasn't. On the corner block that silently ends the site's whole thesis:
// cb1/cb2 are bracket work, and bracket work needs a ladder that no longer exists.
// The player's hands are the one source of truth, so reconcile against them every frame and
// let the ladder FALL where it was lost — the same instinct as STATES.shelter, which enters
// and leaves itself so a dropped release can't strand you. Fetching it is now a cost you can
// see and walk to, which is what the mechanic wanted from the knockdown in the first place.
if (state.carried && player.carrying !== 'ladder') {
state.carried = false;
state.placedAt = null;
state.dropped = true;
state.dropYaw = player.facing ?? 0;
state.base.set(player.pos.x,
world.heightAt ? world.heightAt(player.pos.x, player.pos.z) : 0,
player.pos.z);
}
if (player.state === 'atTop' && input && input.z < 0) player.climbTo(0, t);
syncView();
},

View File

@ -172,6 +172,8 @@ export class PlayerView {
this._axis = new THREE.Vector3();
this._qYaw = new THREE.Quaternion();
this._qPitch = new THREE.Quaternion();
this._leanAxis = new THREE.Vector3();
this._qLean = new THREE.Quaternion();
}
/** @returns {string[]} clip names that bound to at least one bone */
@ -208,6 +210,16 @@ export class PlayerView {
if (this._axis.lengthSq() < 1e-8) this._axis.set(1, 0, 0);
this._qPitch.setFromAxisAngle(this._axis.normalize(), sim.pitch * Math.PI * 0.5);
this.root.quaternion.copy(this._qPitch).multiply(this._qYaw);
} else if (sim.lean > 1e-3) {
// Wind lean (SPRINT13): the same foot-pivot tip as the knockdown, but small and toward the
// wind's downwind bearing (leanDir) rather than the fall direction. The sim suppresses lean
// whenever pitch > 0, so these two never fight — this is the else branch by construction, not
// by luck. leanMaxRad is the whole tip; a knockdown's is π/2, so a full lean is ~a third of a
// fall. Yaw still applies underneath, so you lean while facing wherever you're walking.
this._leanAxis.set(sim.leanDir.z, 0, -sim.leanDir.x);
if (this._leanAxis.lengthSq() < 1e-8) this._leanAxis.set(1, 0, 0);
this._qLean.setFromAxisAngle(this._leanAxis.normalize(), sim.lean * sim.leanMaxRad);
this.root.quaternion.copy(this._qLean).multiply(this._qYaw);
} else {
this.root.quaternion.copy(this._qYaw);
}

View File

@ -100,6 +100,21 @@ export const TUNE = {
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)
// Wind lean (SPRINT13 gate 2.4). The QA pass: "the player stands bolt upright and unbothered" at
// 65 km/h. E rendered the two Mixamo lean candidates and rejected both on screen (a couch-shove
// and a bus-stop slouch), and proved the deeper reason a clip can't do this at all: _rotOnly drops
// Hips.quaternion from every clip at load (player.js:44), so a baked lean is discarded — and a
// clip's lean axis is fixed while the wind's bearing swings. So the lean is the ROOT, same machine
// as the knockdown pitch: the sim owns an angle and a direction, the view tips the body by it.
// Keyed on windBase (the ~4 s EMA), NOT raw windSpeed — measured: raw strobes the posture 8-18×/min
// across the storms, windBase settles it to 0-2 changes per 90 s. Sustained wind = posture; gust is
// already the stumble/knock event. Bands land on the storm rating ladder (D's THREADS table): night
// 1 upright all night, 2-3 lean, 4-5 in the gale.
leanWindMin: 8.3, // m/s windBase (30 km/h — the speed C's leaves start at) before you lean at all
leanWindFull: 22, // m/s windBase for a full lean — past the wildnight median, shy of its 25 peak
leanMaxRad: 0.30, // rad (~17°) of torso tip at full lean. A brace-into-it, not a fall (pitch is 0.5π)
leanSecs: 0.6, // s to reach/leave the target lean — slow, so a lull visibly lets you straighten
// 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.
// RETUNED against the real storm_02: 17 was set against a mock that injected +18 gusts, but the
@ -164,6 +179,8 @@ export class PlayerSim {
this.gust = 0; // windSpeed - windBase, clamped ≥0
this.pitch = 0; // 0 upright … 1 flat on the ground
this.knockDir = { x: 0, z: 1 }; // which way the body went down
this.lean = 0; // 0 upright … 1 full wind-lean (view reads this + leanDir)
this.leanDir = { x: 0, z: 1 }; // unit downwind — the way the wind is trying to push you
this.climbY = 0; // m above the ground; >0 means you're up a ladder
this.climbTarget = 0; // where ladder.js asked you to be
@ -180,6 +197,9 @@ export class PlayerSim {
get clip() { return STATES[this.state].clip; }
get speed() { return Math.hypot(this.vel.x, this.vel.z); }
/** Radians of torso tip at a full wind-lean. Surfaced so PlayerView reads it, not TUNE. */
get leanMaxRad() { return this.tune.leanMaxRad; }
/** How high this person's hands get right now. The gate on reaching a fascia bracket. */
get reachY() { return this.pos.y + this.climbY + this.tune.reach; }
@ -381,6 +401,22 @@ export class PlayerSim {
const pstep = dt / T.pitchSecs;
this.pitch = clamp(this.pitch + clamp(wantPitch - this.pitch, -pstep, pstep), 0, 1);
// --- wind lean: the sim owns it exactly like pitch, so the view stays a pure reader and the lean
// is deterministic (dt,t). Off windBase, not ws, so a gust punches the stumble/knock event
// while the posture holds. Suppressed where a lean would fight another root pose: on your back
// (pitch owns the body), braced (TakeCover is its own hunch), or up the ladder (you're pinned
// to the rungs, not standing in the wind). It eases to 0 there rather than snapping. ---
const leanOK = this.pitch < 1e-3 && this.state !== 'shelter' && !up;
const leanSpan = Math.max(1e-3, T.leanWindFull - T.leanWindMin);
const wantLean = leanOK
? clamp((this.windBase - T.leanWindMin) / leanSpan, 0, 1)
: 0;
const lstep = dt / T.leanSecs;
this.lean = clamp(this.lean + clamp(wantLean - this.lean, -lstep, lstep), 0, 1);
// downwind is where the wind blows TOWARD; track it only while there's a wind to read, so a lull
// leaves the last bearing rather than snapping leanDir to a default and twisting the body.
if (ws > 0.1) { const inv = 1 / ws; this.leanDir.x = wx * inv; this.leanDir.z = wz * inv; }
// --- locomotion state from actual speed (so shove/slow can't desync the feet).
// `aloft` is what holds you in atTop: it's unlocked (so hold-E works up there), and without
// this guard the speed check would immediately re-state you to idle and drop you off. ---

View File

@ -16,7 +16,7 @@ import { Interact, wireYardActions } from '../interact.js';
import { createBroom, BROOM_TUNE } from '../broom.js';
// The REAL rule, not the fakeLadder's hand-copied duplicate of it — a rule the suite re-implements
// is a rule the suite cannot catch drifting. (ladder.js is headless-importable for this reason.)
import { needsLadder as realNeedsLadder } from '../ladder.js';
import { needsLadder as realNeedsLadder, createLadder } from '../ladder.js';
import { assert, assertEq, assertClose, assertLess, fixedLoop } from '../testkit.js';
import { FIXED_DT } from '../contracts.js';
import { loadStorm, createWind } from '../weather.js';
@ -175,6 +175,54 @@ export default async function run(t) {
`shove must grow faster than linearly with speed: ${p15.toFixed(3)} -> ${p30.toFixed(3)}`);
});
// ---------------------------------------------------------------- wind lean (SPRINT13 gate 2.4)
t.test('lean: calm leaves you upright, wind tips you, and it scales with the sustained speed', () => {
const calm = new PlayerSim();
drive(calm, 20, {}, windX(TUNE.leanWindMin - 3)); // below the threshold, all storm
assertLess(calm.lean, 1e-3, 'under leanWindMin you stand up straight');
const mid = new PlayerSim();
drive(mid, 40, {}, windX((TUNE.leanWindMin + TUNE.leanWindFull) / 2));
assert(mid.lean > 0.35 && mid.lean < 0.65, `mid wind is a partial lean, got ${mid.lean.toFixed(2)}`);
const gale = new PlayerSim();
drive(gale, 40, {}, windX(TUNE.leanWindFull + 6));
assertClose(gale.lean, 1, 1e-2, 'past leanWindFull you are fully into it');
assert(gale.lean >= mid.lean, 'more sustained wind is more lean');
});
t.test('lean: keys on the SUSTAINED base, not the gust — a single gust does not snap you over', () => {
const s = new PlayerSim();
drive(s, 30, {}, windX(4)); // learn a calm baseline: no lean
const before = s.lean;
drive(s, 1.2, {}, windX(TUNE.leanWindFull + 10), 30); // one big gust, shorter than leanSecs momentum
// windBase barely moves in 1.2 s (baseTrack ~4 s memory), so the posture must not jump to full.
assertLess(s.lean, 0.5, `a brief gust is a stumble, not a lean — got ${s.lean.toFixed(2)} from ${before.toFixed(2)}`);
});
t.test('lean: it points downwind, and it is suppressed on your back, braced, or up a ladder', () => {
// downwind: wind blowing +X should tip leanDir toward +X.
const s = new PlayerSim();
drive(s, 30, {}, windX(TUNE.leanWindFull + 5));
assert(s.lean > 0.5, 'leaning');
assertClose(s.leanDir.x, 1, 1e-6, 'leanDir points the way the wind blows (x)');
assertClose(s.leanDir.z, 0, 1e-6, 'and not sideways (z)');
// braced: holding C must zero the lean — TakeCover is its own pose.
const braced = new PlayerSim();
drive(braced, 30, { shelter: true }, windX(TUNE.leanWindFull + 5));
assertEq(braced.state, 'shelter', 'C braces');
assertLess(braced.lean, 1e-2, 'and a brace is not a lean');
// knocked down: pitch owns the body, lean eases out.
const floored = new PlayerSim();
drive(floored, 30, {}, windX(TUNE.leanWindFull + 5));
assert(floored.lean > 0.5, 'leaning before the fall');
floored.knockdown(0, 1, 0);
drive(floored, 1, {}, windX(TUNE.leanWindFull + 5));
assertLess(floored.lean, 1e-2, 'flat on your back is not a lean');
});
// ---------------------------------------------------------------- knockdown
t.test('knockdown: needs SUSTAINED overload, like a sail corner letting go', () => {
const brief = new PlayerSim();
@ -448,6 +496,74 @@ export default async function run(t) {
'nor a tree limb — that is a strop you throw');
});
// --- SPRINT13 pool: the real createLadder, not the fake. These two pin the things the QA pass
// found by playing, and both were silent — no error, no red, just a mechanic that stopped.
// (createLadder is scene-free with scene=null: the view is the only THREE it needs.)
const realLadderWorld = () => ({
anchors: [
{ id: 'h2', type: 'house', work: 'bracket', pos: { x: 0, y: 2.48, z: -9.95 } },
{ id: 'cb1', type: 'carport', work: 'bracket', pos: { x: -8.4, y: 2.29, z: -3 } },
],
shedTable: { pos: { x: 9, y: 0.9, z: 6 } },
heightAt: () => 0,
});
t.test('ladder: a knockdown mid-carry drops it in the grass — it does not leave the game', () => {
const world = realLadderWorld();
const interact = new Interact();
const player = new PlayerSim({ start: { x: 4, y: 0, z: 2 } });
const ladder = createLadder(null, world, interact, player);
const takeTarget = [...interact.targets.values()].find((x) => x.id === 'ladder_take');
// in your hands, walking it out to the bracket — through the target's OWN onDone, because
// player.pickUp alone fills your hands without telling the ladder, which is not a state the
// game can produce and would test the fixture rather than the code.
takeTarget.onDone(player, 0);
ladder.update(DT, 0, {});
assert(ladder.carried, 'carried');
assertEq(player.carrying, 'ladder', 'and the hands agree');
assertEq(takeTarget.pos(), null, 'nothing to walk to while it is in your hands');
// the wind takes you over at (4, 2). knockdown() -> drop() nulls carrying, and before SPRINT13
// nothing told the ladder: it stayed "carried" by nobody, invisible and un-takeable forever.
player.knockdown(0, 1, 0);
assertEq(player.carrying, null, 'the knockdown took it out of your hands');
ladder.update(DT, 0, {});
assert(!ladder.carried, 'the ladder knows it is no longer being carried');
const p = takeTarget.pos();
assert(p, 'and it is somewhere you can walk to');
assertClose(p.x, 4, 1e-6, 'it fell where you did (x)');
assertClose(p.z, 2, 1e-6, 'it fell where you did (z)');
assert(takeTarget.canUse({ carrying: null, climbY: 0 }),
'and you can pick it up again — this is the assert that fails if the reconcile is reverted');
});
t.test('ladder: the place prompt names the structure it is under, and where the ladder is', () => {
const world = realLadderWorld();
const interact = new Interact();
const player = new PlayerSim({ start: { x: 4, y: 0, z: 2 } });
const ladder = createLadder(null, world, interact, player);
const placeAt = (id) => [...interact.targets.values()].find((x) => x.id === `ladder_place_${id}`);
const empty = { carrying: null };
// QA pass: this said "the fascia" while you stood under a carport beam.
assert(/fascia/.test(interact.labelOf(placeAt('h2'), empty)), 'the house bracket IS the fascia');
assert(/carport/.test(interact.labelOf(placeAt('cb1'), empty)),
`a carport beam is not a fascia — got "${interact.labelOf(placeAt('cb1'), empty)}"`);
assert(!/fascia/.test(interact.labelOf(placeAt('cb1'), empty)),
'and it must not call it one');
// ...and it must not lie about where the ladder is once the wind has taken it.
assert(/by the shed/.test(interact.labelOf(placeAt('cb1'), empty)), 'stowed: it is by the shed');
[...interact.targets.values()].find((x) => x.id === 'ladder_take').onDone(player, 0);
ladder.update(DT, 0, {});
player.knockdown(0, 1, 0);
ladder.update(DT, 0, {});
assert(!/by the shed/.test(interact.labelOf(placeAt('cb1'), empty)),
'dropped: the shed is exactly where it is NOT');
});
t.test('ladder: keys on the MECHANISM a site declares, not on the anchor type', () => {
// SPRINT10 gate 1. The failure mode is the point: when needsLadder says false, interact's
// canReach returns TRUE UNCONDITIONALLY — so a mis-keyed anchor doesn't error, it silently