From 5d1d0895f24318ae5b0360d2af452a1a24726960 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 01:25:17 +1000 Subject: [PATCH 1/2] Lane D: retune stumble/shove against the REAL storm_02, pin to storm JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-merge thresholds were tuned against a mock that injected +18 gusts. In the actual storm_02, gusts-over-baseline peak at 12.1 m/s — so stumbleGust:17 was above every gust in the game and StumbleBack was unreachable dead code, and shoveGustMin:8 (a low bar in the prototype's 12-38 units) had become a high bar in ours and threw away most of the shove. Retuned against the real storm profile (12 gust events, peaks 12.1..6.1, 4 knockdowns, maxExposure 1.55): stumbleGust 17→9, shoveGustMin 8→4. A wild night now reads shoved (26s of 90) → stumbling (4) → floored (2); a calm day stays 0/0. d.test.js now loads the real storm JSON via weather.loadStorm and asserts every threshold is REACHABLE and correctly ordered — the guard the first tuning lacked. This matters because decision 8 (B+C) reweights the downdraft this sprint, which is exactly the kind of change that silently killed StumbleBack the first time. Also pinned the emergent 'unbrace into a gust → stumble' behaviour. Co-Authored-By: Claude Opus 4.8 --- web/world/js/player.sim.js | 13 ++++- web/world/js/tests/d.test.js | 92 ++++++++++++++++++++++++++++++++++-- 2 files changed, 99 insertions(+), 6 deletions(-) diff --git a/web/world/js/player.sim.js b/web/world/js/player.sim.js index 7f6acd5..d5ce189 100644 --- a/web/world/js/player.sim.js +++ b/web/world/js/player.sim.js @@ -62,7 +62,11 @@ export const TUNE = { // prototype: push = ws*ws*0.55 — "wind pressure goes with speed², gusts have teeth"; and shove // only applied while wind.gust > 8, never from the base wind. shoveK: 0.0035, // shove accel (m/s²) = shoveK · ws² → ~3.2 m/s² in a 30 m/s gust - shoveGustMin: 8, // m/s of gust (over baseline) before the wind can push you at all + // Gate RETUNED against the real storm_02 (see the header note): the prototype's literal 8 was a + // low bar in ITS units (gusts ran 12–38, so 8 was "almost always"); ported straight across it + // became a high bar in ours (gusts peak at 12.1) and threw away most of the shove. 4 restores the + // prototype's intent — you're being pushed for ~26 s of a 90 s storm, and a calm day is still calm. + shoveGustMin: 4, // m/s of gust (over baseline) before the wind can push you at all shoveDamp: 2.5, // 1/s foot-friction bleed → terminal drift ≈ shoveK·ws²/shoveDamp // Baseline tracker: contracts.js exposes wind.sample() (total) and wind.gustTelegraph() (before @@ -80,7 +84,12 @@ export const TUNE = { // A gust that can't floor you can still break your stride. Sits BELOW knockWind on purpose, so a // storm reads as: shoved → stumbling → floored, rather than fine-fine-fine-flat-on-your-back. - stumbleGust: 17, // m/s over baseline → you lose your footing (but not your feet) + // RETUNED against the real storm_02: 17 was set against a mock that injected +18 gusts, but the + // real storm's gusts-over-baseline peak at 12.1, so StumbleBack was unreachable — dead code in the + // shipped game. 9 catches the top third of the 12 gusts in a wild night: 4 stumbles to 2 + // knockdowns, which is the intended rhythm (stumble is the beat, knockdown the punchline). + // d.test.js pins this to the actual storm JSON so a reweighted downdraft can't quietly kill it again. + stumbleGust: 9, // m/s over baseline → you lose your footing (but not your feet) stumbleCooldown: 3, // s — punctuation, not a stutter: one gust hold must not stumble you twice // Shelter (hold C): brace and the wind stops owning you. This is the storm's real answer to "the diff --git a/web/world/js/tests/d.test.js b/web/world/js/tests/d.test.js index c4ccafb..2f63f44 100644 --- a/web/world/js/tests/d.test.js +++ b/web/world/js/tests/d.test.js @@ -15,6 +15,7 @@ import { PlayerSim, STATES, TUNE, clipFor } from '../player.sim.js'; import { Interact, wireYardActions } from '../interact.js'; import { assert, assertEq, assertClose, assertLess, fixedLoop } from '../testkit.js'; import { FIXED_DT } from '../contracts.js'; +import { loadStorm, createWind } from '../weather.js'; const DT = FIXED_DT; @@ -25,8 +26,31 @@ const windX = (speed) => ({ x: speed, y: 0, z: 0 }); const drive = (sim, secs, input = {}, wind = null, t0 = 0) => fixedLoop(secs, DT, (dt, t) => sim.step(dt, t0 + t, input, wind)); +/** Count what a real storm actually does to a person standing mid-yard. */ +function stormProfile(wind, tune, secs = 90) { + const p = new PlayerSim({ start: { x: 0, y: 0, z: 4 }, tune }); + let stumbles = 0, knocks = 0, maxGust = 0, maxWind = 0, shovedFor = 0; + fixedLoop(secs, DT, (dt, t) => { + p.step(dt, t, {}, wind); + maxGust = Math.max(maxGust, p.gust); + maxWind = Math.max(maxWind, p.windSpeed); + if (Math.hypot(p.shove.x, p.shove.z) > 0.15) shovedFor += dt; + for (const e of p.events) if (e.type === 'state') { + if (e.state === 'stumble') stumbles++; + if (e.state === 'knocked') knocks++; + } + p.events.length = 0; + }); + return { stumbles, knocks, maxGust, maxWind, shovedFor }; +} + /** @param {import('../testkit.js').Suite} t */ -export default function run(t) { +export default async function run(t) { + // The REAL storms, not a mock. Lane D's thresholds are meaningless except against the data they + // fire on, and this sprint's decision 8 has B+C changing the downdraft semantics underneath us — + // exactly the kind of edit that silently made StumbleBack unreachable the first time. + const wild = createWind(await loadStorm('storm_02_wildnight')); + const calm = createWind(await loadStorm('storm_01_gentle')); // ---------------------------------------------------------------- state machine table // The 17 clips actually in player_anims.glb (integrator baked the M3 pack; names logged in THREADS). // Verified against the real GLB in-browser: SHADES.player.view.clipNames matches this exactly. @@ -205,12 +229,27 @@ export default function run(t) { assertEq(s.state, 'knocked', 'a big enough gust still takes you off your feet, braced or not'); }); - t.test('shelter: releasing the key always frees you, even mid-gust', () => { + t.test('shelter: releasing the key always frees you — you are never stuck braced', () => { + // steady wind (already learned as baseline, so no apparent gust): straight back to idle const s = new PlayerSim(); + drive(s, 30, {}, windX(20)); drive(s, 1, { shelter: true }, windX(20)); assertEq(s.state, 'shelter', 'braced'); - drive(s, 0.5, {}, windX(20)); // let go, wind still blowing - assert(!s.busy && s.state === 'idle', 'released'); + drive(s, 0.5, {}, windX(20)); + assertEq(s.state, 'idle', 'let go in steady wind → idle'); + assert(!s.busy, 'and free to move'); + }); + + t.test('shelter: unbracing INTO the gust you were hiding from costs you your footing', () => { + // Emergent, and worth pinning: bracing is a commitment. Let go early and the gust takes you. + // This is what makes "wait for the lull" a decision rather than a formality. + const g = new PlayerSim(); + drive(g, 30, {}, windX(4)); // learn a calm baseline + drive(g, 1, { shelter: true }, windX(4 + TUNE.stumbleGust + 6), 30); + assertEq(g.state, 'shelter', 'braced through the gust'); + drive(g, 0.2, {}, windX(4 + TUNE.stumbleGust + 6), 31); + assert(g.state !== 'shelter', 'releasing always leaves shelter'); + assertEq(g.state, 'stumble', 'and straight into the gust → you stumble'); }); t.test('shelter: cannot brace from your back', () => { @@ -259,6 +298,51 @@ export default function run(t) { assertEq(s.state, 'shelter', 'braced, the gust does not stumble you'); }); + // ---------------------------------------------------------------- tuned against the REAL storms + // These are the guard rails the pre-merge tuning didn't have. Every threshold below is only + // meaningful relative to the storm JSON it fires on, so assert against the JSON. + t.test('storm_02: every player threshold is actually REACHABLE in the real storm', () => { + const p = stormProfile(wild, undefined); + assert(p.maxGust > TUNE.stumbleGust, + `StumbleBack is dead code: storm_02 gusts peak at ${p.maxGust.toFixed(1)} m/s over baseline, ` + + `but stumbleGust is ${TUNE.stumbleGust}. Lower it or ask Lane C for angrier gusts.`); + assert(p.maxGust > TUNE.shoveGustMin, `gust shove unreachable: peak ${p.maxGust.toFixed(1)}`); + assert(p.maxWind > TUNE.knockWind, + `knockdown unreachable: storm_02 peaks at ${p.maxWind.toFixed(1)} m/s, knockWind is ${TUNE.knockWind}`); + }); + + t.test('storm_02: reads as shoved → stumbling → floored, in that order of frequency', () => { + const p = stormProfile(wild, undefined); + assert(p.stumbles >= 2, `a wild night should break your stride more than twice, got ${p.stumbles}`); + assert(p.knocks >= 1, `and floor you at least once, got ${p.knocks}`); + assert(p.stumbles > p.knocks, + `stumble is the beat and knockdown the punchline — got ${p.stumbles} stumbles vs ${p.knocks} knocks`); + assert(p.shovedFor > 10, `you should feel pushed for a real slice of the storm, got ${p.shovedFor.toFixed(1)}s`); + assertLess(p.knocks, 8, `${p.knocks} knockdowns in 90 s is unplayable, not dramatic`); + }); + + t.test('storm_01: a calm day leaves you completely alone', () => { + const p = stormProfile(calm, undefined); + assertEq(p.stumbles, 0, 'no stumbles on a gentle day'); + assertEq(p.knocks, 0, 'no knockdowns on a gentle day'); + assertLess(p.shovedFor, 1, 'and essentially no shove'); + }); + + t.test('storm_02: bracing is what makes the worst of it survivable', () => { + const exposed = new PlayerSim({ start: { x: 0, y: 0, z: 4 } }); + const braced = new PlayerSim({ start: { x: 0, y: 0, z: 4 } }); + let exposedKnocks = 0, bracedKnocks = 0; + fixedLoop(90, DT, (dt, tt) => { + exposed.step(dt, tt, {}, wild); + braced.step(dt, tt, { shelter: true }, wild); + for (const e of exposed.events) if (e.state === 'knocked') exposedKnocks++; + for (const e of braced.events) if (e.state === 'knocked') bracedKnocks++; + exposed.events.length = 0; braced.events.length = 0; + }); + assert(exposedKnocks > 0, 'storm_02 floors you if you just stand in it'); + assertLess(bracedKnocks, exposedKnocks, 'and bracing through it is strictly better'); + }); + // ---------------------------------------------------------------- solids collision t.test('collision: solids stop you, and the pushout slides you along them', () => { // one box: the yard's north wall, x -8..8, z -16..-10, waist high From 135b3dcda5891a30cfc10ae2aee476588104045c Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 01:43:49 +1000 Subject: [PATCH 2/2] =?UTF-8?q?Log=20the=20on-record=20=C2=A77=20hand-run,?= =?UTF-8?q?=20the=20failure-envelope=20finding,=20and=20the=20ladder=20def?= =?UTF-8?q?erral?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The §7 loop is closed by hand on the merged game: pick up a spare at the shed, carry it across the storm, the carabiner weak-link blows on its own, chase the flogging corner (prompt tracks it live), hold-E re-rig → 4/4, ride out → survives with full coverage. Definition of done met with a real storm break, not induced. Also logged for B+C: the sail's failure envelope is a cliff — 4/4 up to tn 1.03 (~1.2kN peaks), instant ~10kN double-cascade at tn 1.04. Good rigs survive, flat rigs cascade; the thesis holds and the decision-8 downdraft semantic won't move the cliff (it's a cloth-stability ceiling). Ladder stretch goal deferred to Sprint 4 with reasoning — it's a second carry type + placement + code-driven climb height, bigger than what's left of this sprint. Co-Authored-By: Claude Opus 4.8 --- THREADS.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/THREADS.md b/THREADS.md index 2340de4..fd6abb0 100644 --- a/THREADS.md +++ b/THREADS.md @@ -866,3 +866,51 @@ Format: `[lane letter] YYYY-MM-DD — note` Selftest on merged main: **169 pass / 0 fail**. Hand-driven check via SHADES.step: storm_02 with the default rig loses p1 (carabiner) + p2 by t=40 with downdraft live — cascade is earlier and meaner than A's pre-downdraft run, as C's numbers predicted. Screenshot of the merged storm going to DESIGN.md. + +[D] 2026-07-17 — ✅ **GATE 3 §7 LOOP CLOSED BY HAND, ON RECORD — 4/4 survival, real storm-induced break.** + Drove the merged game through `SHADES.step` (no rAF), real key input, real hold-E. Full trace: + prep: rig 3×rated + 1×carabiner@p1, tn 1.0, settle → `oooo` + t=3.9 walk to shed table (9,6) → hold E → **carrying=spare** (PickUp→CarryIdle) + t≈3.4 storm: carry the spare across the yard (clip **Carry**) toward p1 + **p1's carabiner blows on its own** under storm load → `oooX`, sail flogs loose over the garden + prompt **`rerig_3` tracks the FLOGGING corner** as it swings (live cornerPos — the feature working) + hold E 2.5 s at the loose corner (clip **Crank**, state busy) → `oooo`, **spare consumed**, + **p1 re-rigged carabiner→shackle (an UPGRADE**, exactly B's documented behaviour) + ride out to t=90 → **ends 4/4, coverage 1.0, no further breaks** → aftermath, garden intact. + 4 screenshots (shed pickup · flogging sail + player w/ spare · re-tensioned sail · calm aftermath) + in this session's transcript. **This is the §7 thesis working: mixed rig with a weak link → the + weak link fails → one repair upgrades it → the now-uniform rig survives.** Definition of done met, + and the break was the storm's, not induced. + +[D] 2026-07-17 — 🔧 **RETUNED player thresholds against the REAL storm_02 (my pre-merge tune was against + a mock).** Committed ahead of the run. In the actual storm, gusts-over-baseline peak at **12.1 m/s**, + so my old `stumbleGust:17` was above every gust in the game — **StumbleBack was unreachable dead + code**. And `shoveGustMin:8` (a low bar in the prototype's 12–38 units) had become a high bar in + ours. Fixed: stumbleGust 17→9, shoveGustMin 8→4. A wild night now reads shoved (26 s of 90) → + stumbling (4) → floored (2); a calm day stays 0/0. **d.test.js now loads the real storm JSON + (`weather.loadStorm`) and asserts every threshold is REACHABLE and correctly ordered** — decision 8 + reweights the downdraft this sprint, and this guard fails loudly if that silently kills a mechanic + again. 40 Lane D asserts, 0 fail. + +[D] 2026-07-17 — 📊 **FOR B+C (decision 8) — measured the sail's failure envelope, and it's a CLIFF not a + slope.** Driving `rig.step` against the real wild wind, 3×rated+1×carabiner, settled, per-corner peak + loads: + tn ≤ 1.03 → **4/4 survives**, peaks ~875–1240 N (carabiner rides just under its 1200 N rating) + tn 1.04 → **instant double cascade**: h1 and p2 spike to **~10,300 N at t=1.1** and blow → 1/4 + A 0.01 tension step takes peak load from ~1.2 kN to ~10 kN — that's the cloth going **unstable**, not + gradual overload. Consequence for §7: in a HIGH-tension rig the cascade is faster than any hold-E + (neighbours gone <1 s after the first break), so a naturally-broken corner there is **not** + hand-repairable. BUT a survivable mixed rig (my run) IS — the weak link blows while the rated corners + keep margin and don't cascade, so the repair lands. **Net: good rigs survive, flat/drum-tight rigs + cascade — the thesis holds.** The decision-8 downdraft-semantic (fraction of total vs gust) is really + about loading a FLAT roof *steadily*; it won't fix the cliff, which is a cloth-stability ceiling near + tn 1.04. Worth a stability clamp on peak per-face force if you want a gentler failure curve. + +[D] 2026-07-17 — 🪜 **LADDER STRETCH GOAL — DEFERRED, flagging early as asked.** It's bigger than what's + left of this sprint: carry-ladder is a *second* carry type (can't also carry a spare — hands-full), + placement needs a valid-surface test + a snap to fascia anchors, and ClimbLadder needs vertical + root motion the rig's rotation-only clips don't provide (same class of problem as the knockdown — + I'd drive the climb height in code and play the clip on top). That's a whole interaction sub-system, + not a polish item, and the §7 loop (the actual gate) is closed without it. Recommend it as a + Sprint-4 item once A's anchor rework (decision 2) lands the fascia anchors it targets. The clip is + baked and waiting (`ClimbLadder` is in the pack), so it's not blocked on assets.