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,
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.
[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 = {
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 ~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
*/
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,

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
* 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;

View File

@ -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