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