diff --git a/THREADS.md b/THREADS.md index 0338ed4..9f5700f 100644 --- a/THREADS.md +++ b/THREADS.md @@ -1717,3 +1717,74 @@ Format: `[lane letter] YYYY-MM-DD β€” note` anchor trio that isn't collinear, or a storm with rain but gentle wind (a "steady soaker", DESIGN.md's slow-pressure level type). Not my call β€” flagging with numbers. The broom mechanic works regardless; this is about whether a player ever NEEDS it. +[D] 2026-07-17 β€” 🀝 **LANE B β€” the broom↔pond seam, posted EARLY as Β§Lane B-2 asks. This is what I need, + and it's three members.** I'm building against exactly this shape right now, duck-typed, so land it + whenever and my side lights up with no further coordination: + Β· `sailRig.ponds -> [{ node, mass, pos }]` β€” every pool worth pointing a broom at. `node` is your + grid index (whatever `drainPondAt` wants back), `mass` in kg, `pos` a LIVE world Vector3 of the + pooled node. **`pos` is the one I really need**: the player has to physically walk under the + belly, so I need to know where the belly IS. Empty array when nothing's pooled. + Β· `sailRig.drainPondAt(node) -> number` β€” **please return the kg actually dumped.** That number is + the whole joke: it's what decides whether the water is a splash, a stagger, or puts the player + on their back. If it returns void I have to read `pondMass()` before and after and diff it, + which works but is silly. + Β· `sailRig.pondMass() -> number` β€” you already have this for A's HUD; I use it for "is there any + point picking the broom up". + Your spec says drain takes ~1.5 s β€” mine is a 1.5 s hold-E, so if you'd rather drain gradually over + the hold than dump on completion, say so and I'll drive it per-frame instead. **Dump-on-completion is + my default** because the comedy needs a single moment, not a trickle. + Not blocked on you: the broom carries, walks and pokes today against a local stub, and self-skips + when `drainPondAt` is absent (same pattern as decision 4 β€” my call sites landed first and you + conformed, which worked well for both of us). + +[D] 2026-07-17 β€” 🧹 **BROOM LANDED (Β§Lane D-1) β€” the loop runs in the real game against a stub pond.** + `web/world/js/broom.js` (Lane D). Driven by hand: take broom off the shed wall β†’ walk under the + belly β†’ prompt reads **"push the water off (78 kg)"** β†’ hold-E poke β†’ `pondMass=0`, and **78 kg lands + on the player β†’ stagger.** Self-wires from createPlayer; reads the live rig off `interact.sailRig` + (see the wireYardActions note β€” published there so it survives rigSail's attach() swap). Selftest + **217/0/0** (was 207); 13 of the new asserts are the broom's + the greyed prompt's. + Everything deferred to E's baked metadata β€” `carry_type`, `poke_tip.use` (bristle end, "won't hole + the cloth"), `anim_hint` (reuse Crank). Nothing invented. E, the asset did the design work; thank you. + +[D] 2026-07-17 β€” πŸ‘‹ **LANE B β€” my side of the seam is LIVE and waiting, exactly as I posted it.** The + broom already calls `sailRig.ponds` (reads `{node, mass, pos}`) and `sailRig.drainPondAt(node)` + (uses the returned kg to size the douse). Confirmed end-to-end in the real game against a stub in + your shape β€” so **when you land ponding, the broom lights up with zero further coordination.** One + calibration note for your tuning pass: my douse thresholds are `splash<15kg`, `staggerβ‰₯60`, + `knockdownβ‰₯120` β€” pure physical guesses (a full bucket β‰ˆ10 kg). Once your pond masses are real, + tell me a typical storm_02 belly-pond mass and I'll tune the comedy to it. Right now a 78 kg pond + staggers; I don't yet know if 78 kg is a normal night or a biblical one. + +[D] 2026-07-17 β€” βœ… **GREYED PROMPTS (Β§Lane D-2) β€” the vanishing-prompt confusion is dead. LANE A, your + HUD hook is ready.** `interact.step()` now returns `{target, label, progress, holding, usable}`. When + `usable:false` the prompt is a REASON, not an offer β€” grey it, no radial. The label is already the + sentence: "hands full", "out of reach β€” needs the ladder", "the fascia needs the ladder β€” it's by + the shed", "you need the broom". `interact.visible(player)` is the standalone version if you'd rather + call it directly. Verified in the real game: walk to the shed table holding the ladder and it now says + **"hands full" greyed** instead of showing nothing, while `nearest()` (what hold-E fires on) still + correctly refuses it β€” display and action stay separate. Asserted three ways. + +[D] 2026-07-17 β€” πŸ’¬ **FEEL PASS β€” I played a round (the Β§Lane D-3 hail+ponding pass can't fully run yet; + hailApi and pondApi are both still false on main, so this is the loop that exists + a stub pond):** + 1. **The face is real and it's good.** Forecast β†’ prep table (11 anchors, budget, tension, anchor + rings) β†’ 90 s wild night β†’ a proper aftermath card (garden %, corners, hardware, collateral, + verdict, PLAY AGAIN). It plays like a game now, not a tech demo. + 2. **The feel pass immediately re-confirmed decision 13's necessity, live.** A flawless rig β€” 4/4, + "Every corner held", nothing lost β€” scored **garden 50%.** Half the garden gone with a perfect + run, because rain walks under the cloth and nothing rig-responsive is landed yet. This is EXACTLY + A's 54-vs-48 finding, and it reads as broken from the player's chair: "I did everything right and + the card still says I half-failed." Hail (C) + the score-truth wiring (A) are the fix and they're + in flight; flagging that **until they land, a perfect round feels like a loss**, which is the one + thing that most needs to be true-or-false by gate 3. + 3. **The broom is funny even solo.** 78 kg on the head β†’ stagger β†’ the player reels back from under + the sail. It'll be funnier when it's a pond you watched grow and dreaded, not one I injected β€” + but the beat already lands. Reserve judgement on the douse SIZES until B's masses are real (see + my note to B); the *shape* (walk under, poke, wear it) is right. + 4. **Greyed prompts fixed the thing that read as broken last sprint.** Carrying the ladder past the + shed table now says "hands full" instead of going silent. Small change, and the game stopped + feeling buggy in exactly the spot it did. + +[D] 2026-07-17 β€” 🧡 **wireYardActions now publishes `interact.sailRig`** (the live rig, refreshed on every + rigSail/attach) so Lane-D systems that need the rig but are built by createPlayer β€” the broom needs + `ponds`/`drainPondAt` β€” can read it without main.js threading it through. Same pattern and same reason + as `interact.ladder`. B/C/E: if you ever need the current rig from an interact-wired system, it's there. diff --git a/web/world/js/broom.js b/web/world/js/broom.js new file mode 100644 index 0000000..4ee99b0 --- /dev/null +++ b/web/world/js/broom.js @@ -0,0 +1,188 @@ +/** + * broom.js β€” DESIGN.md's funniest correct mechanic. (Lane D, SPRINT5 Β§Lane D-1) + * + * A flat sail pools water. The water is heavier than anything else in the game and it will pull the + * rig down. The fix is a bloke with a broom walking under the belly and poking it upward β€” at which + * point forty kilos of cold water arrives on his head. That is both the correct engineering answer + * and the joke, and they are the same thing, which is the best kind of mechanic. + * + * Everything here defers to the asset. E baked the intent into broom_01_v1.glb: + * grip_anchor extras.carry_type = "broom" + * poke_tip extras.use = "push the pond up from under the sail; soft end, won't hole the cloth" + * β€” on the BRISTLE end, deliberately: a broomstick jabbed at a loaded sail holes it. + * root extras.anim_hint = "reuse Crank/Dig for the poke β€” no new Mixamo needed" + * so the carry type, the working end and the animation are all read, not invented. + * + * Seam with Lane B (posted in THREADS before either of us built): sailRig.ponds -> [{node,mass,pos}] + * and drainPondAt(node) -> kg dumped. Duck-typed: with no ponding landed the broom still carries, + * walks and refuses to poke thin air, and the moment B lands it the whole thing lights up. + */ +// No top-level THREE/GLTFLoader import, deliberately: the vendored addons import the bare specifier +// 'three', which only resolves under index.html's importmap β€” so importing them here would drag the +// whole GL chain into d.test.js and cost this lane its headless suite (node resolves relative paths +// only). The pond logic below is the part worth asserting and it is pure; the view is loaded +// dynamically, which never happens outside a browser. +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 + standRadius: 2.0, // m β€” how near the pond's ground shadow you must be + + // 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 +}; + +/** + * @param {THREE.Object3D} scene + * @param {object} world contracts World (dressed) + * @param {object} interact Lane D's Interact + * @param {object} player PlayerSim + * @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 } }; + + // 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 }; + if (world.shedTable && world.shedTable.pos) { + home.x = world.shedTable.pos.x - 0.8; + home.z = world.shedTable.pos.z + 1.0; + } + home.y = world.heightAt ? world.heightAt(home.x, home.z) : 0; + + if (scene) { + import('../vendor/addons/loaders/GLTFLoader.js').then(({ GLTFLoader }) => { + new GLTFLoader().load(BROOM_URL, (g) => { + g.scene.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.frustumCulled = false; } }); + state.view = g.scene; + scene.add(g.scene); + sync(); + }, undefined, () => { /* no asset: the mechanic still runs, you just can't see the broom */ }); + }).catch(() => { /* headless (selftest/node): logic only, no view */ }); + } + + function sync() { + if (!state.view) return; + state.view.visible = !state.carried; + state.view.position.set(home.x, home.y, home.z); + 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 = () => { + const rig = getRig && getRig(); + return (rig && Array.isArray(rig.ponds)) ? rig.ponds : []; + }; + + /** + * 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. + */ + 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; + } + + /** 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 wired = []; + + // 1. take the broom off the shed wall β€” a third carry type, so it queues behind the same hands + wired.push(interact.register({ + id: 'broom_take', + pos: () => (state.carried ? null : home), + radius: 1.5, + holdSecs: 0.7, + clip: 'PickUp', + label: (p) => (p.carrying ? 'hands full' : 'take the broom'), + canUse: (p) => !state.carried && !p.carrying && p.climbY < 0.02, + onDone: (p, t) => { state.carried = true; p.pickUp('broom', t); sync(); }, + })); + + // 2. put it back + wired.push(interact.register({ + id: 'broom_drop', + pos: () => (state.carried ? { x: home.x, y: home.y, z: home.z } : null), + radius: 1.5, + holdSecs: 0.4, + clip: 'PickUp', + label: 'put the broom back', + canUse: (p) => state.carried && p.carrying === 'broom', + onDone: (p, t) => { state.carried = false; p.drop(t); sync(); }, + })); + + // 3. THE POKE. Stand under the belly, push up, wear it. + wired.push(interact.register({ + id: 'broom_poke', + pos: pokeSpot, + radius: state.tune.standRadius, + holdSecs: state.tune.pokeSecs, + 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)`; + }, + // 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) => { + 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); + }, + })); + + /** + * 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. + */ + function onWater(p, kg, pond, 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 + p.knockdown(t, -Math.sin(p.facing), -Math.cos(p.facing)); + } else if (kg >= T.staggerKg) { + p.staggerHit(t); + } + // below splashKg: you just get wet, which is its own reward + } + + return { + get carried() { return state.carried; }, + get home() { return home; }, + tune: state.tune, + ponds, + targetPond, + pokeSpot, + onWater, + update() { sync(); }, + dispose() { wired.forEach((un) => un()); if (state.view) scene.remove(state.view); }, + }; +} diff --git a/web/world/js/interact.js b/web/world/js/interact.js index 5c797ca..cfee8a2 100644 --- a/web/world/js/interact.js +++ b/web/world/js/interact.js @@ -62,7 +62,7 @@ export class Interact { return !target.canUse || !!target.canUse(player); } - /** Nearest registered target in range whose canUse() passes. */ + /** Nearest registered target in range whose canUse() passes. This is what a hold-E acts on. */ nearest(player) { let best = null, bestD = Infinity; for (const target of this.targets.values()) { @@ -74,6 +74,31 @@ export class Interact { return best; } + /** + * What the HUD should SHOW, which is not the same question as what E acts on. + * + * A usable action always wins. But when nothing is usable, this returns the nearest action that + * is merely unavailable, so the prompt can say WHY instead of vanishing. That distinction came + * out of playing it: walking to the shed table with the ladder in your hands made the prompt + * disappear, which reads as a broken game rather than a full pair of hands β€” and every target + * already had a perfectly good sentence sitting in its `label`, unreachable, because canUse had + * filtered it out before the label was ever asked. + * + * @returns {{target, usable, label}|null} + */ + visible(player) { + const usable = this.nearest(player); + if (usable) return { target: usable, usable: true, label: this.labelOf(usable, player) }; + let best = null, bestD = Infinity; + for (const target of this.targets.values()) { + const p = typeof target.pos === 'function' ? target.pos() : target.pos; + if (!p) continue; + const d = Math.hypot(p.x - player.pos.x, p.z - player.pos.z); + if (d <= target.radius && d < bestD) { best = target; bestD = d; } + } + return best ? { target: best, usable: false, label: this.labelOf(best, player) } : null; + } + cancel(t, player) { if (!this.active) return; // only hand the player back if they're still ours β€” a knockdown mid-hold already re-stated them @@ -88,7 +113,10 @@ export class Interact { * @param {number} dt @param {number} t * @param {PlayerSim} player * @param {boolean} holding is E held this frame - * @returns {{target, progress, label, holding}} for hud.js to draw the prompt + radial + * @returns {{target, progress, label, holding, usable}} for hud.js to draw the prompt + radial. + * `usable:false` means the prompt is a REASON, not an offer β€” grey it out and don't show a + * radial. Lane A: this is the greyed-prompt surface I offered; `label` is already the sentence + * ("hands full", "out of reach β€” needs the ladder", "you need the broom"). */ step(dt, t, player, holding) { // One press, one action: a completed hold latches until E is released. Without this, a held key @@ -126,12 +154,22 @@ export class Interact { } } - const shown = this.active || near; + if (this.active) { + return { + target: this.active, + progress: this.progress, + label: this.labelOf(this.active, player), + holding: true, + usable: true, + }; + } + const shown = this.visible(player); return { - target: shown, - progress: this.progress, - label: shown ? this.labelOf(shown, player) : '', - holding: !!this.active, + target: shown ? shown.target : null, + progress: 0, + label: shown ? shown.label : '', + holding: false, + usable: !!(shown && shown.usable), }; } } @@ -158,6 +196,11 @@ export function wireYardActions(interact, deps = {}) { // createLadder publishes itself onto the Interact instance, so main.js doesn't have to thread a // ladder through to get the fascia reach gate. An explicit dep still wins (tests pass one). const ladder = deps.ladder || interact.ladder || null; + // Publish the CURRENT rig for lane-D systems built before it exists (the broom needs ponds + + // drainPondAt). main.js re-calls wireYardActions through rigSail() every time attach() replaces + // the rig object, so reading `interact.sailRig` is always the live one β€” which is the same reason + // the corner closures below read by index rather than capturing. + interact.sailRig = sailRig || null; const wired = []; const cornerAt = (i) => (sailRig && sailRig.corners && sailRig.corners[i]) || null; const anchorOf = (i) => { diff --git a/web/world/js/ladder.js b/web/world/js/ladder.js index 3860498..ea9a0a0 100644 --- a/web/world/js/ladder.js +++ b/web/world/js/ladder.js @@ -141,7 +141,13 @@ export function createLadder(scene, world, interact, player) { radius: PLACE_RANGE, holdSecs: 1.0, clip: 'PickUp', - label: `set the ladder under ${a.id}`, + // Reads as an offer when you can, and as a reason when you can't β€” interact.visible() now + // shows unusable targets greyed, and a label written only as an offer explains nothing there. + // Standing under a blown fascia bracket holding a spare, "the fascia needs the ladder" is the + // single most useful sentence in the game. + label: (p) => (p.carrying === 'ladder' + ? `set the ladder under ${a.id}` + : 'the fascia needs the ladder β€” it\'s by the shed'), canUse: (p) => p.carrying === 'ladder', onDone: (p, t) => { state.carried = false; diff --git a/web/world/js/player.js b/web/world/js/player.js index 3efcbe6..3205340 100644 --- a/web/world/js/player.js +++ b/web/world/js/player.js @@ -17,8 +17,9 @@ import { clone as skeletonClone } from '../vendor/addons/utils/SkeletonUtils.js' import { GLTFLoader } from '../vendor/addons/loaders/GLTFLoader.js'; import { PlayerSim, STATES, TUNE, clipFor, onLadder } from './player.sim.js'; import { createLadder } from './ladder.js'; +import { createBroom } from './broom.js'; -export { PlayerSim, STATES, TUNE, clipFor, onLadder, createLadder }; +export { PlayerSim, STATES, TUNE, clipFor, onLadder, createLadder, createBroom }; export const CHAR_URL = './models/player_01.glb'; export const ANIM_URL = './models/player_anims.glb'; @@ -287,6 +288,11 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) { // change to get a whole sub-system. Opt out with {ladder: false} if a harness doesn't want it. const ladder = (opts.ladder === false || !opts.interact) ? null : createLadder(scene, world, opts.interact, sim); + // The broom needs the sail rig, which createPlayer isn't handed β€” but wireYardActions is, and + // main.js re-calls it through rigSail() whenever attach() swaps the rig. So read it live off + // interact rather than capturing a rig that's about to be replaced. + const broom = (opts.broom === false || !opts.interact) + ? null : createBroom(scene, world, opts.interact, sim, () => opts.interact.sailRig); return { get pos() { return sim.pos; }, @@ -299,6 +305,7 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) { const input = keyboard.read(cameraRig ? cameraRig.yaw || 0 : 0); sim.step(dt, t, input, opts.wind); if (ladder) ladder.update(dt, t, input); + if (broom) broom.update(dt, t, input); if (opts.interact) opts.interact.step(dt, t, sim, keyboard.holding); view.sync(sim, dt); }, @@ -308,8 +315,13 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) { sim, view, ladder, + broom, keyboard, - dispose() { keyboard.dispose(); view.dispose(); if (ladder) ladder.dispose(); }, + dispose() { + keyboard.dispose(); view.dispose(); + if (ladder) ladder.dispose(); + if (broom) broom.dispose(); + }, }; } diff --git a/web/world/js/tests/d.test.js b/web/world/js/tests/d.test.js index a5a7301..126b1db 100644 --- a/web/world/js/tests/d.test.js +++ b/web/world/js/tests/d.test.js @@ -13,6 +13,7 @@ */ import { PlayerSim, STATES, TUNE, clipFor } from '../player.sim.js'; import { Interact, wireYardActions } from '../interact.js'; +import { createBroom, BROOM_TUNE } from '../broom.js'; import { assert, assertEq, assertClose, assertLess, fixedLoop } from '../testkit.js'; import { FIXED_DT } from '../contracts.js'; import { loadStorm, createWind } from '../weather.js'; @@ -517,6 +518,157 @@ export default async function run(t) { assertEq(p.climbY, 0); }); + // ---------------------------------------------------------------- the greyed-prompt surface + t.test('prompt: a usable action always wins over a reason', () => { + const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } }); + const it = new Interact(); + it.register({ id: 'gated', pos: { x: 0, y: 0, z: 0 }, radius: 3, canUse: () => false, + label: 'hands full' }); + it.register({ id: 'open', pos: { x: 0, y: 0, z: 1 }, radius: 3, label: 'take a spare' }); + const v = it.visible(p); + assertEq(v.target.id, 'open', 'the thing you CAN do is the thing you are offered'); + assert(v.usable, 'and it is offered, not explained'); + }); + + t.test('prompt: an unavailable action explains itself instead of vanishing', () => { + // The bug this fixes, found by playing: walk to the shed table carrying the ladder and the + // prompt disappeared, because canUse filtered the target out of nearest() before its label + // could ever say "hands full". No prompt reads as a broken game; a greyed one reads as a rule. + const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } }); + const it = new Interact(); + it.register({ id: 'spare_table', pos: { x: 0, y: 0, z: 0 }, radius: 3, + label: (pl) => (pl.carrying ? 'hands full' : 'take a spare'), + canUse: (pl) => !pl.carrying }); + + assertEq(it.visible(p).label, 'take a spare', 'empty-handed: an offer'); + assert(it.visible(p).usable); + + p.carrying = 'ladder'; + const v = it.visible(p); + assert(!!v, 'carrying something, the prompt must NOT vanish'); + assertEq(v.label, 'hands full', 'it says why'); + assert(!v.usable, 'and is flagged unusable so the HUD can grey it'); + assertEq(it.nearest(p), null, 'while nearest() β€” what E acts on β€” still correctly refuses it'); + }); + + t.test('prompt: step() reports usable so the HUD can grey the radial', () => { + const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } }); + const it = new Interact(); + it.register({ id: 'x', pos: { x: 0, y: 0, z: 0 }, radius: 3, holdSecs: 1, label: 'do it', + canUse: (pl) => !pl.carrying }); + p.carrying = 'broom'; + let r = it.step(DT, 0, p, true); + assert(!r.usable, 'unusable'); + assertEq(r.progress, 0, 'and no radial creeps up on an action that cannot run'); + p.carrying = null; + r = it.step(DT, 0.1, p, true); + assert(r.usable, 'usable once the hands are free'); + }); + + // ---------------------------------------------------------------- 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; + }, + }); + + t.test('broom: is a third carry type and queues behind the same hands', () => { + const p = new PlayerSim(); + assertEq(p.pickUp('broom'), true, 'take the broom'); + assertEq(p.pickUp('spare'), false, 'no spare as well'); + assertEq(p.pickUp('ladder'), false, 'no ladder as well'); + assertEq(clipFor(p), 'CarryIdle', 'and you read as carrying'); + assertEq(p.drop(), '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 it = new Interact(); + const rig = stubRig([]); // nothing pooling + const b = createBroom(null, { heightAt: () => 0 }, it, p, () => rig); + p.carrying = 'broom'; + assertEq(b.targetPond(), null, 'no pond, nothing to poke'); + assertEq(it.nearest(p), null, 'and no offer'); + b.dispose(); + }); + + t.test('broom: poke drains the pond Lane B reports, and the water 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 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'); + 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'); + + 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'); + b.dispose(); + }); + + t.test('broom: the size of the pond decides the size of the joke', () => { + const mk = (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); + 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'); + }); + + 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(); + }); + + 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 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', () => { + 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 b = createBroom(null, { heightAt: () => 0 }, it, p, () => noPonding); + p.carrying = 'broom'; + assertEq(b.ponds().length, 0, 'no ponds 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(); + }); + // ---------------------------------------------------------------- solids collision t.test('collision: solids stop you, and the pushout slides you along them', () => { // one box: the yard's north wall, x -8..8, z -16..-10, waist high