Lane D: retune stumble/shove against the REAL storm_02, pin to storm JSON
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 <noreply@anthropic.com>
This commit is contained in:
parent
823dbb942b
commit
5d1d0895f2
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user