Merge remote-tracking branch 'origin/lane/d'

# Conflicts:
#	THREADS.md
This commit is contained in:
m3ultra 2026-07-17 09:35:12 +10:00
commit 26529eeb58
4 changed files with 239 additions and 84 deletions

View File

@ -2062,3 +2062,48 @@ Format: `[lane letter] YYYY-MM-DD — note`
(rain weight 0.25 vs hail 5.0). So the fabric choice is nearly free until the drain weights change, (rain weight 0.25 vs hail 5.0). So the fabric choice is nearly free until the drain weights change,
and I don't want to ship a choice with an obvious answer. **Your call on the hail rule + whether and I don't want to ship a choice with an obvious answer. **Your call on the hail rule + whether
rain's weight is going up in the balance pass; I'll price it once you've said.** Not coding it blind. rain's weight is going up in the balance pass; I'll price it once you've said.** Not coding it blind.
[D] 2026-07-18 — 🪣 **DOUSE CALIBRATED to Lane B's real ponds (§Lane D) — measured on the live sim, not
modelled.** B, thank you for the numbers and for freezing the API; I rebuilt against the frozen
shape, and **your shape is better than the one I asked for** — one `pondCentroid()` beats the
`ponds[]` array I proposed (your belly genuinely pools into a single heaviest place), and per-frame
`drainPondAt(node, dt, radius)` is right for a hold. It also gives a rule I like: an **interrupted
poke sheds only what it got through**, so half a poke is half the water. (I added an `onHold` hook
to interact.js for actions that do work DURING a hold rather than at the end.)
Measured on your sim — flat rig, storm_02, pond mass at poke → kg shed by one 1.5 s poke → outcome:
` 8 s · 85 kg → 76 shed → wet` (caught it before A's 200 kg ticker even fires)
`12 s · 152 kg → 104 shed → STAGGER` (you answered the warning)
`20 s · 281 kg → 182 shed → KNOCKED` (you ignored it)
Bands: `stagger ≥ 100`, `knocked ≥ 180`. **Note I did NOT take your suggested 150-kg stagger line
or my own first guess of 350 for the knockdown** — both were derived from your ~290-shed "loaded
belly", and this rig's biggest real poke sheds **182**, so a 350 knockdown would have been
unreachable dead code. That is the StumbleBack mistake precisely (tuned against a mock, shipped
inert), so d.test.js now guards all three bands against the measured shed range and **will fail
loudly when gate 1's balance pass moves pond masses.** Expect that failure; it's the guard working.
If your ~310 kg belly is the more typical case post-balance, say so and I'll re-measure — my
numbers come from one flat oversized rig, which is exactly the rig gate 1 is deleting.
[D] 2026-07-18 — 🐛 **My bug, found only by poking a REAL pond: the broom refused the sagging belly.**
Worth logging because the failure mode is instructive. I'd stubbed ponds politely at head height,
so every test passed. Against B's live ponding a real pond's centroid **rides from y=1.2 down
through y=-1.0 as it fills** — the belly sags toward you, which IS the mechanic — and my reach gate
rejected anything with `up < 0`. Result: **"nothing pooling here" with 281 kg hanging over the
garden.** Fixed to a physical band (`sagFloor -1.5 … reach +3.2`), with the floor set between two
measured cases rather than guessed: a LIVE 281 kg belly at y=-0.8 with the rig still up (pokeable)
and post-tear wreckage at y=-4.5 (nothing coherent to poke). Regression test pins both.
[D] 2026-07-18 — ❓ **LANE B — a question, not a bug report: should the cloth sag THROUGH the lawn?**
Fell out of the above. Measured on a flat oversized rig in storm_02, the pond centroid reaches
**y = -0.8 while the rig is still standing**, and y = -4.5 after the belly-tear — i.e. the sail is
up to 4.5 m under the grass. Nothing of mine breaks (my sagFloor handles it), and it's plainly the
decision-2 oversized-rig regime that gate 1 is removing, so I'm **not asking for a fix** — but if
cloth↔ground collision is simply absent, the balance pass is a good moment to know that, because
"the belly rests ON the lawn" is a much better read than "the belly is inside it", and a sail
touching down is a real drainage/ponding event in DESIGN.md terms. Your call entirely.
[D] 2026-07-18 — ⏳ **§Lane D feel pass on the BALANCED storms is pending gate 1 — flagging so nobody
waits on me.** Gate 1 (A+B+C) hasn't landed; storms today are still the pre-balance ones the
integrator measured as unwinnable (bare bed 36%, covering rigs die, twisted rigs don't cover). A
feel pass against those would only re-report what's already quantified in THREADS. **The moment
gate 1 lands I'll play all the balanced storms end to end and file notes before it ships**, which
is the order SPRINT6 asks for. My douse re-measure is queued behind the same gate — the guard
assert is the tripwire. Everything else in my lane is landed: 61 Lane D asserts, 0 fail.

View File

@ -27,14 +27,42 @@ export const BROOM_URL = './models/broom_01_v1.glb';
export const BROOM_TUNE = { export const BROOM_TUNE = {
pokeSecs: 1.5, // matches B's "drain over ~1.5 s" 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 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 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: // CALIBRATED against what a poke actually SHEDS — not against the pond's total mass, which is the
// a full bucket is ~10 kg, so a splash is nothing, half a bathtub staggers you, and a bathtub // mistake my Sprint-5 guesses (15/60/120) and my first pass at these (60/150/350) both made.
// puts you down. Flagged in THREADS for the tuning pass. // Two measurements, one from Lane B and one taken here against their live sim:
splashKg: 15, // below this it's just cold and funny // · B: a 1.5 s poke on a loaded ~310 kg belly sheds ~290 kg.
staggerKg: 60, // above this you lose your footing // · here: a real mid-storm 169 kg pond sheds ~106 kg per poke.
// above staggerKg*2 → flat on your back // (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 ~50420 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 → ~100150 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 * @param {object} getRig () => sailRig a getter, because rigSail() REPLACES the rig object
*/ */
export function createBroom(scene, world, interact, player, getRig) { 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. // 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 }; 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 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(); 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. * The pond IF this player can actually reach it: near enough in plan, and between their feet and
* Picks the HEAVIEST reachable one rather than the nearest if you're standing under two, the * the top of the broom.
* one about to break the rig is the one you meant. *
* 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() { function targetPond() {
let best = null; const c = pond();
for (const p of ponds()) { if (!c) return null;
if (!p || !p.pos || !(p.mass > 0)) continue; const d = Math.hypot(c.x - player.pos.x, c.z - player.pos.z);
const d = Math.hypot(p.pos.x - player.pos.x, p.pos.z - player.pos.z); const up = c.y - (player.pos.y + player.climbY);
const up = p.pos.y - (player.pos.y + player.climbY); if (d > state.tune.standRadius) return null;
if (d > state.tune.standRadius || up > state.tune.reachUp || up < 0) continue; if (up > state.tune.reachUp || up < state.tune.sagFloor) return null;
if (!best || p.mass > best.mass) best = p; return c;
}
return best;
} }
/** Where to stand: the pond's shadow on the grass. */ /** Where to stand: the pond's shadow on the grass. */
const pokeSpot = () => { const pokeSpot = () => {
const p = ponds().reduce((a, b) => (!a || (b && b.mass > a.mass) ? b : a), null); const c = pond();
if (!p || !p.pos || !(p.mass > 0)) return null; return c ? { x: c.x, y: 0, z: c.z } : null;
return { x: p.pos.x, y: 0, z: p.pos.z };
}; };
const wired = []; const wired = [];
@ -138,35 +177,46 @@ export function createBroom(scene, world, interact, player, getRig) {
clip: 'Crank', // E's anim_hint — no new Mixamo needed clip: 'Crank', // E's anim_hint — no new Mixamo needed
label: (p) => { label: (p) => {
if (p.carrying !== 'broom') return 'you need the broom'; if (p.carrying !== 'broom') return 'you need the broom';
const pond = targetPond(); const c = targetPond();
if (!pond) return 'nothing pooling here'; if (!c) return 'nothing pooling here';
return `push the water off (${Math.round(pond.mass)} kg)`; 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) // physical gates only — never player.state (see interact.register's note; it cancels its own hold)
canUse: (p) => { canUse: (p) => {
const rig = getRig && getRig(); const rig = getRig && getRig();
return p.carrying === 'broom' && !!(rig && rig.drainPondAt) && !!targetPond(); 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 rig = getRig && getRig();
const pond = targetPond(); const c = targetPond();
if (!rig || !pond) return; if (!rig || !c) return;
// B returns the kg actually dumped; fall back to diffing pondMass() if they'd rather not const shed = rig.drainPondAt(c.node, dt, state.tune.drainRadius);
let kg = rig.drainPondAt(pond.node); if (typeof shed === 'number' && shed > 0) state.pokeKg += shed;
if (typeof kg !== 'number') kg = pond.mass; },
onWater(p, kg, pond, t); 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 * 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. * 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 }); p.events.push({ type: 'doused', kg, t });
const T = state.tune; const T = state.tune;
if (kg >= T.staggerKg * 2) { if (kg >= T.knockKg) {
// downward and behind: a bathtub arriving on your head does not blow you downwind // 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)); p.knockdown(t, -Math.sin(p.facing), -Math.cos(p.facing));
} else if (kg >= T.staggerKg) { } else if (kg >= T.staggerKg) {
p.staggerHit(t); p.staggerHit(t);
@ -177,8 +227,9 @@ export function createBroom(scene, world, interact, player, getRig) {
return { return {
get carried() { return state.carried; }, get carried() { return state.carried; },
get home() { return home; }, get home() { return home; },
get pokeKg() { return state.pokeKg; },
tune: state.tune, tune: state.tune,
ponds, pond,
targetPond, targetPond,
pokeSpot, pokeSpot,
onWater, onWater,

View File

@ -36,6 +36,10 @@ export class Interact {
* can't start a hold, because step() checks `!player.busy` first. This bit twice: the ladder's * can't start a hold, because step() checks `!player.busy` first. This bit twice: the ladder's
* climb and the fascia reach gate. * climb and the fascia reach gate.
* @param {function} [spec.onDone] (player, t) -> void * @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', ). * @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. * Must name a clip in player_anims.glb; omitted means the busy state's default Idle.
* @returns {function} unregister * @returns {function} unregister
@ -43,7 +47,8 @@ export class Interact {
register(spec) { register(spec) {
if (!spec || !spec.id) throw new Error('interact.register: id required'); if (!spec || !spec.id) throw new Error('interact.register: id required');
const target = { 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); this.targets.set(target.id, target);
return () => this.unregister(target.id); return () => this.unregister(target.id);
@ -142,6 +147,9 @@ export class Interact {
if (this.active) { if (this.active) {
this.progress += dt / Math.max(1e-6, this.active.holdSecs); 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) { if (this.progress >= 1) {
const done = this.active; const done = this.active;
this.active = null; this.active = null;

View File

@ -566,19 +566,24 @@ export default async function run(t) {
}); });
// ---------------------------------------------------------------- the broom (SPRINT5 §Lane D-1) // ---------------------------------------------------------------- the broom (SPRINT5 §Lane D-1)
// Lane B's ponding isn't landed yet, so this stub is the seam I posted in THREADS: // Modelled on Lane B's FROZEN contract (contracts.js SailRig), which is not the shape I originally
// ponds -> [{node, mass, pos}], drainPondAt(node) -> kg dumped. // asked for and is better: one pondCentroid() rather than a ponds[] array (B's belly pools into a
const stubRig = (ponds = []) => ({ // single heaviest place), and drainPondAt(node, dt, radius) draining PER FRAME and returning the kg
ponds, // shed by that call. The total is only knowable by accumulating over the hold.
pondMass: () => ponds.reduce((s, p) => s + p.mass, 0), // B measured: a 1.5 s poke sheds ~290 of ~310 kg, i.e. ~93% — this stub matches that rate.
drainPondAt(node) { const stubRig = (mass, at = { x: 0, y: 2.6, z: 0 }) => {
const p = ponds.find((x) => x.node === node); const s = { mass, node: 44 };
if (!p) return 0; return {
const kg = p.mass; pondMass: () => s.mass,
p.mass = 0; // B's sail springs back on its own once the weight is gone pondCentroid: () => (s.mass > 0 ? { ...at, mass: s.mass, node: s.node } : null),
return kg; 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', () => { t.test('broom: is a third carry type and queues behind the same hands', () => {
const p = new PlayerSim(); 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', () => { 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 p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const it = new Interact(); const it = new Interact();
const rig = stubRig([]); // nothing pooling const rig = stubRig(0); // nothing pooling
const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig); const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig);
p.carrying = 'broom'; p.carrying = 'broom';
assertEq(b.targetPond(), null, 'no pond, nothing to poke'); assertEq(b.targetPond(), null, 'no pond, nothing to poke');
@ -600,73 +605,119 @@ export default async function run(t) {
b.dispose(); 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 p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const it = new Interact(); 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); const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig);
p.carrying = 'broom'; p.carrying = 'broom';
const pond = b.targetPond(); assert(!!b.targetPond(), 'standing under the belly, there is a pond to poke');
assert(!!pond, 'standing under the belly, there is a pond to poke');
assertEq(it.nearest(p).id, 'broom_poke', 'and the broom is offered'); 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)); fixedLoop(2, DT, (dt, tt) => it.step(dt, tt, p, true));
assertEq(rig.pondMass(), 0, 'the pond is gone'); assertLess(rig.pondMass(), 40, 'nearly all of it came down (B measures ~93% over a 1.5 s poke)');
assert(p.events.some((e) => e.type === 'doused' && e.kg === 30), 'and it went over the player'); 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(); b.dispose();
}); });
t.test('broom: the size of the pond decides the size of the joke', () => { 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 p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const it = new Interact(); const it = new Interact();
const rig = stubRig([{ node: 1, mass: kg, pos: { x: 0, y: 2.6, z: 0 } }]); const rig = stubRig(kg); // ONE rig — `() => stubRig(kg)` would hand the broom a fresh
const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig); const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig); // full pond every frame
p.carrying = 'broom'; p.carrying = 'broom';
fixedLoop(2, DT, (dt, tt) => it.step(dt, tt, p, true)); fixedLoop(2, DT, (dt, tt) => it.step(dt, tt, p, true));
b.dispose(); b.dispose();
return p.state; return p.state;
}; };
assertEq(mk(5), 'idle', 'a splash just makes you wet'); assertEq(outcome(90), 'idle', 'caught it early: you just get wet');
assertEq(mk(BROOM_TUNE.staggerKg + 5), 'stagger', 'half a bathtub breaks your stride'); assertEq(outcome(160), 'stagger', 'a pond you were warned about breaks your stride');
assertEq(mk(BROOM_TUNE.staggerKg * 2 + 5), 'knocked', 'a bathtub puts you on your back'); assertEq(outcome(300), 'knocked', 'a belly you ignored puts you on your back');
});
t.test('broom: picks the heaviest pond overhead, not the nearest', () => { // Guard, and it is the StumbleBack lesson restated: a threshold is meaningless except against
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } }); // the data it fires on, and gate 1's balance pass moves pond masses. These three numbers are
const it = new Interact(); // MEASURED on B's live sim (flat rig, storm_02, kg shed by one 1.5 s poke) — if a rebalance
const rig = stubRig([ // pushes a band outside the achievable range, this fails loudly instead of the band quietly
{ node: 1, mass: 8, pos: { x: 0.2, y: 2.6, z: 0 } }, // nearer // ceasing to exist. Re-measure after gate 1 and update these, don't just widen the asserts.
{ node: 2, mass: 90, pos: { x: 1.4, y: 2.6, z: 0 } }, // heavier — the one breaking the rig const SHED_EARLY = 76, SHED_WARNED = 104, SHED_IGNORED = 190;
]); assert(BROOM_TUNE.staggerKg > SHED_EARLY,
const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig); `an early poke (~${SHED_EARLY} kg) must stay a splash, or every poke floors you`);
p.carrying = 'broom'; assert(BROOM_TUNE.staggerKg <= SHED_WARNED,
assertEq(b.targetPond().node, 2, 'if you are under two, you meant the one about to kill you'); `answering the ticker (~${SHED_WARNED} kg) must reach the stagger band — that is the beat`);
b.dispose(); 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', () => { 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 p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const it = new Interact(); 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); const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig);
p.carrying = 'broom'; p.carrying = 'broom';
assertEq(b.targetPond(), null, 'a broom is 1.4 m long, not 9'); assertEq(b.targetPond(), null, 'a broom is 1.4 m long, not 9');
b.dispose(); 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 p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
const it = new Interact(); 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); const b = createBroom(null, { heightAt: () => 0 }, it, p, () => noPonding);
p.carrying = 'broom'; 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)); fixedLoop(2, DT, (dt, tt) => it.step(dt, tt, p, true));
assert(!p.events.some((e) => e.type === 'doused'), 'nothing fires, nothing throws'); 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 // ---------------------------------------------------------------- solids collision