Merge remote-tracking branch 'origin/lane/d'
# Conflicts: # THREADS.md
This commit is contained in:
commit
26529eeb58
45
THREADS.md
45
THREADS.md
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user