/** * 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 // 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) // 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 }; /** * @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 }, 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 }; 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 } /** * 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(); if (!rig || typeof rig.pondCentroid !== 'function') return null; const c = rig.pondCentroid(); return c && c.mass > 0 ? c : null; }; /** * 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() { 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 c = pond(); return c ? { x: c.x, y: 0, z: c.z } : null; }; 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 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(); }, // 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 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, t) { p.events.push({ type: 'doused', kg, t }); const T = state.tune; 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); } // below splashKg: you just get wet, which is its own reward } return { get carried() { return state.carried; }, get home() { return home; }, get pokeKg() { return state.pokeKg; }, tune: state.tune, pond, targetPond, pokeSpot, onWater, update() { sync(); }, dispose() { wired.forEach((un) => un()); if (state.view) scene.remove(state.view); }, }; }