From 8d0045bb29d754c329f05fbb8bf299172878b1aa Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 23:40:28 +1000 Subject: [PATCH 1/7] =?UTF-8?q?Lane=20D:=20clip=20contract=20for=20E=20?= =?UTF-8?q?=E2=80=94=20measured=20wind=20bands,=20windBase=20signal,=20and?= =?UTF-8?q?=20the=20fetch=20blocker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate 2.4 asks E to agree names+thresholds with D before exporting. Filed: - Blocker E can't see from a JING5 clone: build_player_anims.py's source libraries (~/Documents/FBX, 3D=models/animations, mixamo-fetch) do not exist on this laptop. All three are on ultra. The pack ships built but is not rebuildable here. - The asked-for clips aren't on disk anywhere (only Stumble Backwards and Taking Cover are lean-adjacent, both already shipped), and new Mixamo fetches need John's manual browser login. Brace and stagger are ALREADY shipped and wired (TakeCover on KeyC, Reaction/StumbleBack on gusts) โ€” the lean is the only genuinely new posture. - Bands measured at the player spawn across all five storms: calm <8.3 / lean 8.3-17 / gale >=17 m/s, hysteresis +/-1.5. 8.3 m/s is 30 km/h, the same number C's leaves start at. Band ladder lands on the storm rating ladder. - Signal is sim.windBase (existing ~4s EMA), not raw wind.speedAt: raw strobes at 8-18 band flips/min, windBase gives 0-2 per 90s storm. Sustained = posture, gust = event. Deviation from the prompt's literal wording flagged to A with the table. --- THREADS.md | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/THREADS.md b/THREADS.md index 91f5bbb..886df36 100644 --- a/THREADS.md +++ b/THREADS.md @@ -4561,3 +4561,87 @@ anchors are your GLB), but the tooling is now waiting, not TODO. โ€” a bad night reads as a story you did to yourself. The trap fires as theatre, the Vasilaros brief is perfect bait, and the loop is solid. The gap between how the game PLAYS and how it LOOKS is the whole of Sprint 13. + +[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.3โ€“17 ยท 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 8โ€“18 flips/min โ† a posture change every 4โ€“6 s. Unusable. + sim.windBase (EMA) + hysteresis ยฑ1.5 0โ€“2 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. From d7658fb7afecee04b138607b47aeb14e9e3d1277 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 01:00:01 +1000 Subject: [PATCH 2/7] Lane D: ladder survives a mid-carry knockdown, and the prompt names the right bracket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPRINT13 gate 2.6 pool, both found by the integrator's QA and both silent (no error, no red, just a mechanic that stopped): - The ladder left the game if the wind took you over while carrying it. knockdown() -> drop() nulls player.carrying but nothing told the ladder, so state.carried stayed true: the mesh hid (visible = !carried), pos() returned null so there was nothing to walk to, and canUse gated on !carried so it could never be picked up again. Swept the whole yard post- knockdown: no usable ladder offer anywhere, while ladder_place still said 'it's by the shed'. On the corner block that silently ends the site's thesis (cb1/cb2 are bracket work). Fix: reconcile against the player's hands every frame in update() and let the ladder fall flat where it was lost, findable and re-takeable. Verified in the running game. - The place label hardcoded 'the fascia' โ€” a lie on a carport beam. Now keyed on anchor.type (a checked enum since Sprint 11): house -> the fascia, carport -> the carport bracket, unknown -> the bracket (vague but never false). And it stops claiming 'by the shed' once the ladder is dropped in the yard. Two new asserts against the REAL createLadder, not the fake: the drop- reconcile and the label. Mutation-checked โ€” reverting the reconcile turns both red on the right assertions. Selftest 337/0/0 (335 + 2). --- web/world/js/ladder.js | 60 ++++++++++++++++++++++++++++--- web/world/js/tests/d.test.js | 70 +++++++++++++++++++++++++++++++++++- 2 files changed, 124 insertions(+), 6 deletions(-) diff --git a/web/world/js/ladder.js b/web/world/js/ladder.js index 1929d42..63cc45b 100644 --- a/web/world/js/ladder.js +++ b/web/world/js/ladder.js @@ -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(); }, diff --git a/web/world/js/tests/d.test.js b/web/world/js/tests/d.test.js index b38a1f2..084604d 100644 --- a/web/world/js/tests/d.test.js +++ b/web/world/js/tests/d.test.js @@ -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'; @@ -448,6 +448,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 From 7053623fde9fa7fa1ce36fe13be5a5c71188bc8d Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 01:01:51 +1000 Subject: [PATCH 3/7] Lane D: diagnoses for C (hail sliver is skyfx, not my ladder) and B (free infinite spares) - The aftermath 'ghost sliver' QA blamed on my ladder is C's hail InstancedMesh at the origin: count:0 but frustumCulled:false + depthWrite:false leaves instance-0 drawn as a 5cm always-on-top speck. Fix is mesh.visible = n>0 or scale-to-zero when intensity is 0. skyfx.js. - The shed-table spare is free and infinite: interact.js gates only on !carrying and never reads rigging.session.spares. Took a spare 3x with 0 bought. Deletes the spare economy the ladder cost is balanced against. Cross-lane: the count lives in B's session, main.js wires wireYardActions without it. Needs A to thread rigging in; B owns the economy. Flagged both. Should land before gate 4 (public difficulty hole). --- THREADS.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/THREADS.md b/THREADS.md index 886df36..3721515 100644 --- a/THREADS.md +++ b/THREADS.md @@ -4645,3 +4645,52 @@ anchors are your GLB), but the tooling is now waiting, not TODO. 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. From 7758b65331601b7403b88413058dde45c78b04e6 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 01:33:47 +1000 Subject: [PATCH 4/7] =?UTF-8?q?Lane=20D:=20the=20player=20leans=20into=20t?= =?UTF-8?q?he=20wind=20(gate=202.4=20=E2=80=94=20as=20a=20root=20pose,=20n?= =?UTF-8?q?ot=20a=20clip)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The QA pass's biggest player finding: 'stands bolt upright and unbothered' at 65 km/h. E rendered the two Mixamo lean candidates and rejected both on screen, and proved the deeper reason a clip can't do this: _rotOnly drops Hips.quaternion from every clip at load, 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, the same machine as the knockdown pitch and climbY lift. sim (player.sim.js): sim.lean 0..1 + sim.leanDir, eased over leanSecs. Magnitude off windBase (the ~4s EMA), NOT raw windSpeed โ€” measured: raw strobes the posture 8-18x/min across the storms, windBase settles it to 0-2 changes per 90s. Sustained wind = posture; the gust is already the stumble/knock event. Suppressed on your back (pitch owns the body), braced (TakeCover is its own hunch), and up a ladder. Bands land on the storm rating ladder: night 1 upright, 2-3 lean, 4-5 in the gale. view (player.js): a foot-pivot tip toward leanDir, max leanMaxRad (~17deg, a third of a knockdown's pi/2), composed under the yaw. It's the else branch of the pitch tip by construction โ€” the sim guarantees lean=0 whenever pitch>0, so they never fight. Three asserts: magnitude scales with sustained speed, a lone gust does NOT snap you over (windBase inertia), and it's downwind + suppressed when floored/braced/aloft. Mutation-checked: forcing lean to 0 reddens two. Verified live at 71 km/h โ€” leanDir dot wind = 1.0, the figure visibly braces. Selftest 340/0/0. This is the root lean E asked me to wire first; whether their WindPosture additive follows is a look-at-it-together call. --- web/world/js/player.js | 12 +++++++++ web/world/js/player.sim.js | 36 +++++++++++++++++++++++++++ web/world/js/tests/d.test.js | 48 ++++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+) diff --git a/web/world/js/player.js b/web/world/js/player.js index 3205340..10408de 100644 --- a/web/world/js/player.js +++ b/web/world/js/player.js @@ -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); } diff --git a/web/world/js/player.sim.js b/web/world/js/player.sim.js index 5b98558..e88fdf5 100644 --- a/web/world/js/player.sim.js +++ b/web/world/js/player.sim.js @@ -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. --- diff --git a/web/world/js/tests/d.test.js b/web/world/js/tests/d.test.js index 084604d..42ff55c 100644 --- a/web/world/js/tests/d.test.js +++ b/web/world/js/tests/d.test.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(); From f3136093fc6004cd156750bef842dd1e1da75d4a Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 01:35:02 +1000 Subject: [PATCH 5/7] =?UTF-8?q?Lane=20D:=20THREADS=20=E2=80=94=20root=20le?= =?UTF-8?q?an=20landed,=20closes=20E's=20gate-2.4=20handoff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wired the wind lean as a root pose per E's analysis. Keyed on windBase not raw speedAt (measured: raw strobes 8-18 flips/min, windBase 0-2 per storm). Verified live at 71 km/h, leanDir dot wind = 1.0. gate 2.4 wind-clips line is done with zero new clips: brace and stagger already shipped/wired, lean is root. Open question to E: is the WindPosture additive worth authoring. --- THREADS.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/THREADS.md b/THREADS.md index 3721515..b1ed898 100644 --- a/THREADS.md +++ b/THREADS.md @@ -4694,3 +4694,40 @@ anchors are your GLB), but the tooling is now waiting, not TODO. 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 โ€” 8โ€“18 band-equivalent flips + per minute across the five storms, a lean that flickers every few seconds. `windBase` settles it + to **0โ€“2 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, 2โ€“3 lean, 4โ€“5 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. From 94624cddcc8a5400a0e806ce742fab20ee7a8fae Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 01:52:10 +1000 Subject: [PATCH 6/7] Lane D: status + garden-harness landmine note (parameter shadow + skipped attach) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Done/green on lane/d: wind lean, ladder knockdown fix, label, E clip contract, C/B diagnoses. Gate-1 verdict and gate-2 visual judging blocked on C (still at integration commit). Documented a measurement trap before it becomes a false alarm: a headless garden-separation harness showed zero separation, which looks like 'the sail casts no shadow'. It's an artifact โ€” (1) sky.step's third param is named 'world' so world.sail is the passed {sail:rig}, not the global; (2) session.commit() bypasses onCommit->rigSail->rig.attach, so the sail is never built and every rig scores as bare (the hp-36 constant skyfx.js:803 warns cost gate 0 two sprints). Garden must be measured via real play or the full attach path. No baseline numbers posted โ€” a bypassed measurement is worse than none. --- THREADS.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/THREADS.md b/THREADS.md index b1ed898..bb5a864 100644 --- a/THREADS.md +++ b/THREADS.md @@ -4731,3 +4731,33 @@ anchors are your GLB), but the tooling is now waiting, not TODO. (`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. From 08f0e09ff0d9b622c6d7fa39adde1810995aa3a3 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 12:31:28 +1000 Subject: [PATCH 7/7] =?UTF-8?q?Lane=20D:=20gate-1=20and=20gate-2=20verdict?= =?UTF-8?q?s=20=E2=80=94=20played=20through=20the=20real=20UI;=20bench=20v?= =?UTF-8?q?enturi=20landmine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate 1: C's model confirmed to the decimal in play (backyard 61.3 exact, carport 82.6 exact, fascia 2.27 eff / t=1 settle tear, QA 88 reproduced). The $80-line wildnight headline does not survive the real game: probe4/bench read venturi off the storm def, the site def is where it lives โ€” funnel on, q3+q4 break before the hail and the line reads 39.8 vs cheap 38.5. Separation table for A's ruling; the proposed target is currently satisfiable nowhere (bare never reads DEAD). Gate 2: storm grade, wall (both QA sightings dead from in-yard), leaves (cap 7, one-wind onset with the lean), lean-under-grade, A's spawn fix (160 px clear on receipts), hail sliver dead twice over. Plus two sightings: last night's sail haunts the next prep across a site change; hidden-canvas boot NaNs the camera. Co-Authored-By: Claude Fable 5 --- THREADS.md | 136 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/THREADS.md b/THREADS.md index bb5a864..c6ba724 100644 --- a/THREADS.md +++ b/THREADS.md @@ -4761,3 +4761,139 @@ anchors are your GLB), but the tooling is now waiting, not TODO. 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 65โ€“75 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 68โ€“75 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.82โ€“0.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โ‰ˆ800โ€“833, + ~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 61โ€“62% + 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.