diff --git a/web/world/js/broom.js b/web/world/js/broom.js index 4ee99b0..ab94af5 100644 --- a/web/world/js/broom.js +++ b/web/world/js/broom.js @@ -27,14 +27,42 @@ export const BROOM_URL = './models/broom_01_v1.glb'; export const BROOM_TUNE = { pokeSecs: 1.5, // matches B's "drain over ~1.5 s" reachUp: 3.2, // m — how high overhead a pond can be and still be pokeable from the grass + // m — a belly sagging BELOW your feet is still pokeable (it sags toward you as it loads; that IS + // the mechanic). Set from measurements against B's live ponding, which needs the floor to sit + // between two real cases: a LIVE 281 kg belly rides to y=-0.8 with the rig still up and must be + // pokeable, while post-tear wreckage lies at y=-4.5 and has nothing coherent to poke. -1.5 splits + // them with room either side. (-0.6 refused the 281 kg pond — measured, not theorised.) + sagFloor: -1.5, standRadius: 2.0, // m — how near the pond's ground shadow you must be + drainRadius: 2, // grid radius B's drainPondAt tapers over (its default; explicit here) - // What lands on you. Calibrate once B's masses are real — these are physical guesses, not measured: - // a full bucket is ~10 kg, so a splash is nothing, half a bathtub staggers you, and a bathtub - // puts you down. Flagged in THREADS for the tuning pass. - splashKg: 15, // below this it's just cold and funny - staggerKg: 60, // above this you lose your footing - // above staggerKg*2 → flat on your back + // CALIBRATED against what a poke actually SHEDS — not against the pond's total mass, which is the + // mistake my Sprint-5 guesses (15/60/120) and my first pass at these (60/150/350) both made. + // Two measurements, one from Lane B and one taken here against their live sim: + // · B: a 1.5 s poke on a loaded ~310 kg belly sheds ~290 kg. + // · here: a real mid-storm 169 kg pond sheds ~106 kg per poke. + // (The shed can exceed the pond's net change — rain keeps refilling while you poke. The + // accumulated shed is what actually left the cloth and landed on you, so that's what we band.) + // Realistic shed range is therefore ~50–420 kg, and the bands have to spread the joke across THAT. + // + // The gradient is the mechanic, not just the gag. Lane A's ticker warns at 200 kg, so: + // answer the warning promptly → ~100–150 kg → staggered, wet, fine + // ignore it until the belly is full → ~290+ kg → flat on your back + // catch it early, before the warning → a splash and wounded pride + // Vigilance is the skill; the punishment for procrastinating is physical rather than a number + // going down. NOTE: gate 1's balance pass moves pond masses — d.test.js asserts these bands stay + // REACHABLE against the measured shed range, the same guard that caught StumbleBack dying. + // Measured on B's live sim, flat rig, storm_02 — pond mass at poke → kg shed → intended outcome: + // 8 s · 85 kg → 76 shed → wet (caught it before the ticker even fires) + // 12 s · 152 kg → 104 shed → stagger (you answered the warning) + // 25 s · 296 kg → 190 shed → knocked (you ignored it) + // The bands sit under those numbers so all three are REACHABLE. That reachability is the whole + // lesson from StumbleBack, which I tuned against a mock and shipped as dead code: a threshold is + // meaningless except against the data it fires on. d.test.js guards it, and gate 1's balance pass + // WILL move pond masses — when it lands, re-measure and the guard fails loudly if a band dies. + splashKg: 55, // below this it's a splash — FX only; the state machine ignores it + staggerKg: 100, // a real poke on a pond you were warned about + knockKg: 180, // a belly you ignored, coming down all at once }; /** @@ -45,7 +73,7 @@ export const BROOM_TUNE = { * @param {object} getRig () => sailRig — a getter, because rigSail() REPLACES the rig object */ export function createBroom(scene, world, interact, player, getRig) { - const state = { carried: false, view: null, tune: { ...BROOM_TUNE } }; + const state = { carried: false, view: null, tune: { ...BROOM_TUNE }, pokeKg: 0 }; // Home: against the shed wall. E ships it standing on its head, which is how it lives there. const home = { x: 8.2, y: 0, z: 7.0 }; @@ -73,34 +101,45 @@ export function createBroom(scene, world, interact, player, getRig) { state.view.rotation.set(0, 0.9, 0.16); // slouched against the shed wall } - /** Every pond Lane B is reporting, or [] until they land it. */ - const ponds = () => { + /** + * Lane B's frozen contract (contracts.js SailRig): pondCentroid() -> {x,y,z,mass,node}|null. + * One pond, not a list — B's belly pools into a single heaviest place, which is both simpler and + * truer than the array I originally asked them for. Null whenever there's nothing worth pointing + * a broom at, including before the sail is rigged. + */ + const pond = () => { const rig = getRig && getRig(); - return (rig && Array.isArray(rig.ponds)) ? rig.ponds : []; + if (!rig || typeof rig.pondCentroid !== 'function') return null; + const c = rig.pondCentroid(); + return c && c.mass > 0 ? c : null; }; /** - * The pond this player could actually poke: near enough in plan, low enough overhead. - * Picks the HEAVIEST reachable one rather than the nearest — if you're standing under two, the - * one about to break the rig is the one you meant. + * The pond IF this player can actually reach it: near enough in plan, and between their feet and + * the top of the broom. + * + * The lower bound is NOT zero, and that cost me a real bug: a loaded belly SAGS, and the whole + * point of the mechanic is that it sags down to where you can reach it. Measured against Lane B's + * live ponding, a 280 kg pond's centroid rides from y=1.2 down through y=-0.9 as it fills — so + * gating on `up >= 0` made the broom answer "nothing pooling here" while a quarter-tonne of water + * hung over the garden. Sagging toward you is the mechanic working, not a reason to refuse. + * Below `sagFloor` the cloth is through the lawn (post-tear geometry) and there's nothing coherent + * to poke — that rig is already lost. */ function targetPond() { - let best = null; - for (const p of ponds()) { - if (!p || !p.pos || !(p.mass > 0)) continue; - const d = Math.hypot(p.pos.x - player.pos.x, p.pos.z - player.pos.z); - const up = p.pos.y - (player.pos.y + player.climbY); - if (d > state.tune.standRadius || up > state.tune.reachUp || up < 0) continue; - if (!best || p.mass > best.mass) best = p; - } - return best; + const c = pond(); + if (!c) return null; + const d = Math.hypot(c.x - player.pos.x, c.z - player.pos.z); + const up = c.y - (player.pos.y + player.climbY); + if (d > state.tune.standRadius) return null; + if (up > state.tune.reachUp || up < state.tune.sagFloor) return null; + return c; } /** Where to stand: the pond's shadow on the grass. */ const pokeSpot = () => { - const p = ponds().reduce((a, b) => (!a || (b && b.mass > a.mass) ? b : a), null); - if (!p || !p.pos || !(p.mass > 0)) return null; - return { x: p.pos.x, y: 0, z: p.pos.z }; + const c = pond(); + return c ? { x: c.x, y: 0, z: c.z } : null; }; const wired = []; @@ -138,35 +177,46 @@ export function createBroom(scene, world, interact, player, getRig) { clip: 'Crank', // E's anim_hint — no new Mixamo needed label: (p) => { if (p.carrying !== 'broom') return 'you need the broom'; - const pond = targetPond(); - if (!pond) return 'nothing pooling here'; - return `push the water off (${Math.round(pond.mass)} kg)`; + const c = targetPond(); + if (!c) return 'nothing pooling here'; + return `push the water off (${Math.round(c.mass)} kg)`; }, // physical gates only — never player.state (see interact.register's note; it cancels its own hold) canUse: (p) => { const rig = getRig && getRig(); return p.carrying === 'broom' && !!(rig && rig.drainPondAt) && !!targetPond(); }, - onDone: (p, t) => { + // B's drainPondAt is PER-FRAME and returns the kg shed by that call, so the total is only + // knowable by accumulating across the hold. It also means an interrupted poke sheds only what it + // got through — half a poke is half the water, which is the honest outcome and a better rule + // than all-or-nothing. + onHold: (p, dt) => { const rig = getRig && getRig(); - const pond = targetPond(); - if (!rig || !pond) return; - // B returns the kg actually dumped; fall back to diffing pondMass() if they'd rather not - let kg = rig.drainPondAt(pond.node); - if (typeof kg !== 'number') kg = pond.mass; - onWater(p, kg, pond, t); + const c = targetPond(); + if (!rig || !c) return; + const shed = rig.drainPondAt(c.node, dt, state.tune.drainRadius); + if (typeof shed === 'number' && shed > 0) state.pokeKg += shed; + }, + onDone: (p, t) => { + const kg = state.pokeKg; + state.pokeKg = 0; + if (kg > 0) onWater(p, kg, t); }, })); /** * The payoff. All of it lands on the player, because they are standing directly underneath it — * that is not a bug in the plan, it IS the plan. + * + * Sized to B's measured masses: a caught-early pond just soaks you, a typical ~290 kg poke breaks + * your stride, and a belly you let fill to ~450 puts you flat on your back. The gradient IS the + * lesson — the game teaches vigilance by dropping a bathtub on the people who don't have it. */ - function onWater(p, kg, pond, t) { + function onWater(p, kg, t) { p.events.push({ type: 'doused', kg, t }); const T = state.tune; - if (kg >= T.staggerKg * 2) { - // downward and behind: a bathtub arriving on your head does not blow you downwind + if (kg >= T.knockKg) { + // straight down and backwards: a bathtub arriving on your head does not blow you downwind p.knockdown(t, -Math.sin(p.facing), -Math.cos(p.facing)); } else if (kg >= T.staggerKg) { p.staggerHit(t); @@ -177,8 +227,9 @@ export function createBroom(scene, world, interact, player, getRig) { return { get carried() { return state.carried; }, get home() { return home; }, + get pokeKg() { return state.pokeKg; }, tune: state.tune, - ponds, + pond, targetPond, pokeSpot, onWater, diff --git a/web/world/js/interact.js b/web/world/js/interact.js index cfee8a2..a28feba 100644 --- a/web/world/js/interact.js +++ b/web/world/js/interact.js @@ -36,6 +36,10 @@ export class Interact { * can't start a hold, because step() checks `!player.busy` first. This bit twice: the ladder's * climb and the fascia reach gate. * @param {function} [spec.onDone] (player, t) -> void + * @param {function} [spec.onHold] (player, dt, t, progress) -> void, every frame the hold runs. + * For actions that do WORK over the hold rather than at the end of it — the broom's poke drains + * Lane B's pond per-frame (`drainPondAt(node, dt)` returns the kg shed that call), so the total + * is only knowable by accumulating it. onDone still fires at the end for the payoff. * @param {string} [spec.clip] verb played for the length of the hold ('Crank', 'PickUp', …). * Must name a clip in player_anims.glb; omitted means the busy state's default Idle. * @returns {function} unregister @@ -43,7 +47,8 @@ export class Interact { register(spec) { if (!spec || !spec.id) throw new Error('interact.register: id required'); const target = { - radius: 1.6, holdSecs: 1, label: '', canUse: null, onDone: null, clip: null, ...spec, + radius: 1.6, holdSecs: 1, label: '', canUse: null, onDone: null, onHold: null, clip: null, + ...spec, }; this.targets.set(target.id, target); return () => this.unregister(target.id); @@ -142,6 +147,9 @@ export class Interact { if (this.active) { this.progress += dt / Math.max(1e-6, this.active.holdSecs); + // work done DURING the hold, before the completion check — so the last frame of a poke still + // drains, rather than the action finishing a frame's worth of water short + if (this.active.onHold) this.active.onHold(player, dt, t, Math.min(1, this.progress)); if (this.progress >= 1) { const done = this.active; this.active = null; diff --git a/web/world/js/tests/d.test.js b/web/world/js/tests/d.test.js index 126b1db..bff8913 100644 --- a/web/world/js/tests/d.test.js +++ b/web/world/js/tests/d.test.js @@ -566,19 +566,24 @@ export default async function run(t) { }); // ---------------------------------------------------------------- the broom (SPRINT5 §Lane D-1) - // Lane B's ponding isn't landed yet, so this stub is the seam I posted in THREADS: - // ponds -> [{node, mass, pos}], drainPondAt(node) -> kg dumped. - const stubRig = (ponds = []) => ({ - ponds, - pondMass: () => ponds.reduce((s, p) => s + p.mass, 0), - drainPondAt(node) { - const p = ponds.find((x) => x.node === node); - if (!p) return 0; - const kg = p.mass; - p.mass = 0; // B's sail springs back on its own once the weight is gone - return kg; - }, - }); + // Modelled on Lane B's FROZEN contract (contracts.js SailRig), which is not the shape I originally + // asked for and is better: one pondCentroid() rather than a ponds[] array (B's belly pools into a + // single heaviest place), and drainPondAt(node, dt, radius) draining PER FRAME and returning the kg + // shed by that call. The total is only knowable by accumulating over the hold. + // B measured: a 1.5 s poke sheds ~290 of ~310 kg, i.e. ~93% — this stub matches that rate. + const stubRig = (mass, at = { x: 0, y: 2.6, z: 0 }) => { + const s = { mass, node: 44 }; + return { + pondMass: () => s.mass, + pondCentroid: () => (s.mass > 0 ? { ...at, mass: s.mass, node: s.node } : null), + drainPondAt(node, dt) { + if (node !== s.node || !(s.mass > 0)) return 0; + const shed = Math.min(s.mass, s.mass * (dt / 1.5) * 2.62); // ~93% over a 1.5 s hold + s.mass -= shed; + return shed; + }, + }; + }; t.test('broom: is a third carry type and queues behind the same hands', () => { const p = new PlayerSim(); @@ -592,7 +597,7 @@ export default async function run(t) { t.test('broom: refuses to poke thin air, and refuses without the broom', () => { const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } }); const it = new Interact(); - const rig = stubRig([]); // nothing pooling + const rig = stubRig(0); // nothing pooling const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig); p.carrying = 'broom'; assertEq(b.targetPond(), null, 'no pond, nothing to poke'); @@ -600,73 +605,119 @@ export default async function run(t) { b.dispose(); }); - t.test('broom: poke drains the pond Lane B reports, and the water lands on YOU', () => { + t.test('broom: the poke drains B\'s pond per-frame and the total lands on YOU', () => { const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } }); const it = new Interact(); - const rig = stubRig([{ node: 44, mass: 30, pos: { x: 0, y: 2.6, z: 0 } }]); + const rig = stubRig(300); const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig); p.carrying = 'broom'; - const pond = b.targetPond(); - assert(!!pond, 'standing under the belly, there is a pond to poke'); + assert(!!b.targetPond(), 'standing under the belly, there is a pond to poke'); assertEq(it.nearest(p).id, 'broom_poke', 'and the broom is offered'); - assert(/30 kg/.test(it.labelOf(it.nearest(p), p)), 'the prompt tells you how much is up there'); + assert(/300 kg/.test(it.labelOf(it.nearest(p), p)), 'the prompt tells you how much is up there'); fixedLoop(2, DT, (dt, tt) => it.step(dt, tt, p, true)); - assertEq(rig.pondMass(), 0, 'the pond is gone'); - assert(p.events.some((e) => e.type === 'doused' && e.kg === 30), 'and it went over the player'); + assertLess(rig.pondMass(), 40, 'nearly all of it came down (B measures ~93% over a 1.5 s poke)'); + const doused = p.events.find((e) => e.type === 'doused'); + assert(!!doused, 'and it went over the player'); + assert(doused.kg > 250, `the accumulated per-frame drain is what lands, got ${doused.kg.toFixed(0)} kg`); + b.dispose(); + }); + + t.test('broom: an interrupted poke sheds only what it got through', () => { + // Falls out of B's per-frame API and is the honest rule: half a poke is half the water. + const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } }); + const it = new Interact(); + const rig = stubRig(300); + const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig); + p.carrying = 'broom'; + fixedLoop(0.5, DT, (dt, tt) => it.step(dt, tt, p, true)); // let go a third of the way in + it.step(DT, 1, p, false); + assert(rig.pondMass() > 100, `most of the water is still up there, got ${rig.pondMass().toFixed(0)} kg`); + assert(!p.events.some((e) => e.type === 'doused'), 'and an abandoned poke does not douse you'); b.dispose(); }); t.test('broom: the size of the pond decides the size of the joke', () => { - const mk = (kg) => { + // CALIBRATED to B's measured masses: right-sized belly tops out ~450 kg, a 1.5 s poke sheds ~93%. + const outcome = (kg) => { const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } }); const it = new Interact(); - const rig = stubRig([{ node: 1, mass: kg, pos: { x: 0, y: 2.6, z: 0 } }]); - const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig); + const rig = stubRig(kg); // ONE rig — `() => stubRig(kg)` would hand the broom a fresh + const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig); // full pond every frame p.carrying = 'broom'; fixedLoop(2, DT, (dt, tt) => it.step(dt, tt, p, true)); b.dispose(); return p.state; }; - assertEq(mk(5), 'idle', 'a splash just makes you wet'); - assertEq(mk(BROOM_TUNE.staggerKg + 5), 'stagger', 'half a bathtub breaks your stride'); - assertEq(mk(BROOM_TUNE.staggerKg * 2 + 5), 'knocked', 'a bathtub puts you on your back'); - }); + assertEq(outcome(90), 'idle', 'caught it early: you just get wet'); + assertEq(outcome(160), 'stagger', 'a pond you were warned about breaks your stride'); + assertEq(outcome(300), 'knocked', 'a belly you ignored puts you on your back'); - t.test('broom: picks the heaviest pond overhead, not the nearest', () => { - const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } }); - const it = new Interact(); - const rig = stubRig([ - { node: 1, mass: 8, pos: { x: 0.2, y: 2.6, z: 0 } }, // nearer - { node: 2, mass: 90, pos: { x: 1.4, y: 2.6, z: 0 } }, // heavier — the one breaking the rig - ]); - const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig); - p.carrying = 'broom'; - assertEq(b.targetPond().node, 2, 'if you are under two, you meant the one about to kill you'); - b.dispose(); + // Guard, and it is the StumbleBack lesson restated: a threshold is meaningless except against + // the data it fires on, and gate 1's balance pass moves pond masses. These three numbers are + // MEASURED on B's live sim (flat rig, storm_02, kg shed by one 1.5 s poke) — if a rebalance + // pushes a band outside the achievable range, this fails loudly instead of the band quietly + // ceasing to exist. Re-measure after gate 1 and update these, don't just widen the asserts. + const SHED_EARLY = 76, SHED_WARNED = 104, SHED_IGNORED = 190; + assert(BROOM_TUNE.staggerKg > SHED_EARLY, + `an early poke (~${SHED_EARLY} kg) must stay a splash, or every poke floors you`); + assert(BROOM_TUNE.staggerKg <= SHED_WARNED, + `answering the ticker (~${SHED_WARNED} kg) must reach the stagger band — that is the beat`); + assert(BROOM_TUNE.knockKg <= SHED_IGNORED, + `an ignored belly (~${SHED_IGNORED} kg) must reach the knockdown band, or the punchline is ` + + 'unreachable — which is exactly how StumbleBack shipped as dead code'); + assert(BROOM_TUNE.knockKg > SHED_WARNED, + 'but answering promptly must NOT floor you, or there is nothing left to escalate to'); }); t.test('broom: a pond out of reach overhead cannot be poked from the grass', () => { const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } }); const it = new Interact(); - const rig = stubRig([{ node: 1, mass: 40, pos: { x: 0, y: 9, z: 0 } }]); // 9 m up + const rig = stubRig(300, { x: 0, y: 9, z: 0 }); // 9 m up const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig); p.carrying = 'broom'; assertEq(b.targetPond(), null, 'a broom is 1.4 m long, not 9'); b.dispose(); }); - t.test('broom: self-skips cleanly until Lane B lands drainPondAt', () => { + t.test('broom: a SAGGING belly is pokeable — it sags toward you as it loads', () => { + // Regression. Measured against Lane B's live ponding: a real 280 kg pond's centroid rides from + // y=1.2 down through y=-0.9 as the belly fills. Gating on `up >= 0` made the broom say "nothing + // pooling here" with a quarter-tonne hanging over the garden — a bug no stub would have shown, + // because I'd stubbed the pond politely at head height. + const mk = (y) => { + const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } }); + const it = new Interact(); + const rig = stubRig(280, { x: 0, y, z: 0 }); + const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig); + p.carrying = 'broom'; + const r = !!b.targetPond(); + b.dispose(); + return r; + }; + assert(mk(2.4), 'overhead: pokeable'); + assert(mk(1.0), 'a real 188 kg pond rides here: pokeable'); + assert(mk(0.1), 'sagging to your knees: still pokeable — this is the case that was broken'); + assert(mk(-0.8), 'a real LIVE 281 kg belly rides here, rig still up: must be pokeable'); + assert(!mk(-4.5), 'post-tear wreckage lies here: nothing coherent to poke'); + }); + + t.test('broom: survives a rig with no ponding at all (and a pre-rig null centroid)', () => { const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } }); const it = new Interact(); - const noPonding = { }; // a rig with no ponding at all + const noPonding = {}; // e.g. an old rig, or none const b = createBroom(null, { heightAt: () => 0 }, it, p, () => noPonding); p.carrying = 'broom'; - assertEq(b.ponds().length, 0, 'no ponds reported'); + assertEq(b.pond(), null, 'no centroid reported'); fixedLoop(2, DT, (dt, tt) => it.step(dt, tt, p, true)); assert(!p.events.some((e) => e.type === 'doused'), 'nothing fires, nothing throws'); - b.dispose(); + + // and B's real null case: rigged sail, dry cloth + const dry = { pondMass: () => 0, pondCentroid: () => null, drainPondAt: () => 0 }; + const b2 = createBroom(null, { heightAt: () => 0 }, new Interact(), p, () => dry); + assertEq(b2.targetPond(), null, 'a dry sail offers nothing to poke'); + b2.dispose(); b.dispose(); }); // ---------------------------------------------------------------- solids collision