diff --git a/LANEHANDOVER.md b/LANEHANDOVER.md index 6975542..5cbd1db 100644 --- a/LANEHANDOVER.md +++ b/LANEHANDOVER.md @@ -1623,3 +1623,35 @@ must yield between exec calls before the child scenes exist. Role ladder ✓ roster board ✓ venues 1–4 ✓ radio command ✓ DJ/bartender/ glassie verbs ✓. Remaining ambition: per-role restricted verb-sets (glassie zero-authority), story beats on roles, patron sprite sheets (own session). + +## SESSION — FABLE-SOLO-18 · 2026-07-21 + +**Branch:** main (solo, Fable, ultra) · **Gate:** lint ✓ build ✓ test ✓ (734 tests, 51 files) +**Deployed:** https://monsterrobot.games/not-tonight/ (rsync, curl 200) + +### The floor score (John's ask: lights on at close, find what dropped) +- **`rules/floorScore.ts`** (pure, 5 tests): `rollDrops(stream, admitted)` — + 6 + admits/6 drops (cap 14): notes in anxious denominations ($5–$50), booth + shrapnel, 2–3 worthless curios with load-bearing labels ("an untouched + kebab. UNTOUCHED."), ≤1 baggie (35%), ≤1 phone (40%). +- **Sweep mode** (FloorDemoScene): NightScene's `endNight('clock')` now routes + through `ctx.beginSweep` before the summary — lights-on wash over the whole + map, crowd cleared, drops scattered on walkable tiles (stream `'sweep'`, + pulsing glints — green for the baggie). WASD + E collects; prompt names the + nearest find; the baggie freezes your feet for a B-bin / SPACE-pocket call + (`baggieBinned`/`baggiePocketed` incidents — report bait, the night's over + so the ledger is the only place it can bite); the phone goes to lost + property. E at the entry walks out; licence-pull and riot ends skip the + sweep entirely (nobody's sweeping). +- **Pay**: `NightLog.floorCash` → summary row "floor score $N" (gold) and + `nightCash()` adds it AFTER the zero-floor clamp — a catastrophic night + still pays out the floor. Sweep incidents land in the report form. + +### Verified in pane +endNight('clock') → sweep: 6 drops (baggie/2 curios/2 notes/coins) ✓ · +collected $19, baggie pocketed with incident ✓ · walk-out at the entry anchor +→ NightSummary with floor score $19 + PAY includes it ✓ · screenshots. + +### Note +`entry` anchor is (72,360) — tile (4,22)-ish, NOT the exit tiles at x=1; the +sweep walk-out and the night-mode exit both key off `VENUE.anchors.entry[0]`. diff --git a/src/data/strings/floor.ts b/src/data/strings/floor.ts index 045b9b7..309a9fc 100644 --- a/src/data/strings/floor.ts +++ b/src/data/strings/floor.ts @@ -128,6 +128,20 @@ export const HELP_TEXT = 'WASD move · TAB uv · E interact · SPACE bang · ESC cancel · R reseed'; +/** The floor score: lights on at close, the floor pays out. */ +export const SWEEP_UI = { + open: 'LIGHTS ON. the floor will now tell you what kind of night it was.', + pickup: 'E — pick it up', + leave: 'E — call it a night', + lastOne: "that's the lot. the door is that way.", + baggiePrompt: 'zip-lock bag: B bin it · SPACE pocket it', + baggieBinned: 'into the amnesty bin. the bin does not thank you. bins never do.', + baggiePocketed: 'it goes in your pocket. for the report. obviously. for the report.', + phoneFound: 'straight into lost property. MUM DO NOT ANSWER can rest.', + /** {cash} replaced at walk-out. */ + done: 'floor swept. ${cash} better off. the room, finally, is just a room.', +} as const; + /** Radio command (design §6): the staff on the other end of the walkie. */ export const STAFF_RADIO = { hint: 'RADIO — 1 bump (mess) · 2 shan (bar) · 3 kayden (rope)', diff --git a/src/rules/floorScore.ts b/src/rules/floorScore.ts new file mode 100644 index 0000000..9468456 --- /dev/null +++ b/src/rules/floorScore.ts @@ -0,0 +1,68 @@ +// The floor score (John's ask, 2026-07-21): the venue closes, the lights come +// ON, and the floor tells you what kind of night it was. Money, contraband, +// and objects with no explanation. Pure rolls here; the scene owns the sweep. + +import type { RngStream } from '../core/SeededRNG'; + +export type DropKind = 'note' | 'coins' | 'baggie' | 'phone' | 'curio'; + +export interface Drop { + kind: DropKind; + /** Dollars. Curios and phones are worth nothing and everything. */ + value: number; + label: string; +} + +/** How strewn the floor is: a base mess plus one drop per this many admits. */ +export const DROPS_BASE = 6; +export const DROPS_PER_ADMITS = 6; +export const DROPS_MAX = 14; + +export const NOTE_VALUES: readonly number[] = [5, 5, 10, 10, 20, 50]; +/** One baggie, sometimes. The moral beat of the sweep. */ +export const BAGGIE_CHANCE = 0.35; +/** Someone's whole life, at 3%, face down near the booths. */ +export const PHONE_CHANCE = 0.4; + +export const CURIOS: readonly string[] = [ + 'a single white sneaker. vintage, technically', + 'one hoop earring, lying there victorious', + 'an untouched kebab. UNTOUCHED.', + 'a novelty lighter shaped like a slightly smaller lighter', + 'seventeen bobby pins in one tight formation', + "someone's nan's reading glasses", + 'a laminated business card. sovereign.', + 'a wristband from a venue that shut in 2019', +]; + +export const BAGGIE_LABEL = 'a zip-lock bag of definitely-not-yours'; +export const PHONE_LABEL = "someone's phone. 3% battery. 41 missed calls from MUM DO NOT ANSWER"; + +/** + * What the lights find. Deterministic per stream: mostly cash in anxious + * denominations, a couple of objects with no explanation, at most one baggie + * and one phone. + */ +export function rollDrops(rng: RngStream, admitted: number): Drop[] { + const count = Math.min(DROPS_MAX, DROPS_BASE + Math.floor(admitted / DROPS_PER_ADMITS)); + const drops: Drop[] = []; + + if (rng.chance(BAGGIE_CHANCE)) drops.push({ kind: 'baggie', value: 0, label: BAGGIE_LABEL }); + if (rng.chance(PHONE_CHANCE)) drops.push({ kind: 'phone', value: 0, label: PHONE_LABEL }); + + const curios = rng.int(2, 3); + for (let i = 0; i < curios && drops.length < count; i++) { + drops.push({ kind: 'curio', value: 0, label: CURIOS[rng.int(0, CURIOS.length - 1)] ?? CURIOS[0]! }); + } + + while (drops.length < count) { + if (rng.chance(0.3)) { + const v = rng.int(2, 9); + drops.push({ kind: 'coins', value: v, label: `$${v} in shrapnel from under the booth seats` }); + } else { + const v = NOTE_VALUES[rng.int(0, NOTE_VALUES.length - 1)] ?? 5; + drops.push({ kind: 'note', value: v, label: `a folded $${v} — the floor pays better than the till` }); + } + } + return drops; +} diff --git a/src/scenes/door/NightScene.ts b/src/scenes/door/NightScene.ts index e561605..aa4fdea 100644 --- a/src/scenes/door/NightScene.ts +++ b/src/scenes/door/NightScene.ts @@ -66,6 +66,8 @@ export interface NightLog { /** The shift taken off the roster board (design §6) and what it pays. */ role: string; wage: number; + /** What the lights-on sweep found in cash. */ + floorCash: number; } /** Run-level result attached to the summary. */ @@ -116,6 +118,9 @@ export interface NightContext { */ floorOrder?: (kind: import('../../rules/staff').FloorOrderKind, notify: (msg: string) => void) => import('../../rules/staff').OrderAck; kaydenHold?: () => 'sent' | 'cooldown'; + /** The floor score: lights on at close, sweep what the night dropped. + * Registered by the floor; the night calls it before the summary. */ + beginSweep?: (done: (score: { cash: number }) => void) => void; } export class NightScene extends Phaser.Scene { @@ -224,6 +229,7 @@ export class NightScene extends Phaser.Scene { seed: this.seed, role: this.role.name, wage: this.role.wage, + floorCash: 0, }; this.offs.push( @@ -541,9 +547,22 @@ export class NightScene extends Phaser.Scene { }; this.time.delayedCall(reason === 'clock' ? 600 : 1400, () => { - this.scene.stop('Door'); - this.scene.stop('Floor'); - this.scene.start('NightSummary', { log: this.log, state: this.state, run: runInfo }); + const proceed = (): void => { + this.scene.stop('Door'); + this.scene.stop('Floor'); + this.scene.start('NightSummary', { log: this.log, state: this.state, run: runInfo }); + }; + // Made it to close: the lights come on and the floor pays out first. + // A pulled licence or a dead room skips the sweep — nobody's sweeping. + if (reason === 'clock' && this.ctx.beginSweep) { + this.applyLocation('floor'); + this.ctx.beginSweep((score) => { + this.log.floorCash = score.cash; + proceed(); + }); + } else { + proceed(); + } }); } diff --git a/src/scenes/door/NightSummaryScene.ts b/src/scenes/door/NightSummaryScene.ts index e7eb7b9..3e93897 100644 --- a/src/scenes/door/NightSummaryScene.ts +++ b/src/scenes/door/NightSummaryScene.ts @@ -19,7 +19,7 @@ export function nightCash(log: NightLog, state: NightState): number { const hypeBonus = Math.round((log.hypePeak - 1) * 90); const doorBonus = log.admitted * 2; const strikes = state.heatStrikes.length * 60; - return Math.max(0, wage + hypeBonus + doorBonus - strikes); + return Math.max(0, wage + hypeBonus + doorBonus - strikes) + log.floorCash; } export function signoffFor(vibe: number, strikes: number): string { @@ -187,6 +187,7 @@ export class NightSummaryScene extends Phaser.Scene { `${this.state.heatStrikes.length}/3`, this.state.heatStrikes.length > 0 ? '#ff8080' : undefined, ], + ['floor score', `$${this.log.floorCash}`, this.log.floorCash > 0 ? '#f0d060' : undefined], ]; rows.forEach(([label, value, colour], i) => { diff --git a/src/scenes/floor/FloorDemoScene.ts b/src/scenes/floor/FloorDemoScene.ts index 4cb09d7..63d3a28 100644 --- a/src/scenes/floor/FloorDemoScene.ts +++ b/src/scenes/floor/FloorDemoScene.ts @@ -47,13 +47,14 @@ import { ORDER_DONE_VIBE, RADIO_COOLDOWN_MS, SHAN_BOTCH_AGGRO, SHAN_MIN_INTOX, SHAN_WATER_STEP, orderLag, rollBumpMops, rollShanClean, type FloorOrderKind, type OrderAck, } from '../../rules/staff'; +import { rollDrops, type Drop } from '../../rules/floorScore'; import { getActiveEngine } from '../../audio/TechnoEngine'; import { RACK_RESPAWN_MIN, freshRack, stepRack, turnSharpness, type RackState } from './glassie'; -import { tileToWorld } from './venueMap'; +import { MAP_H, MAP_W, isWalkable, tileToWorld } from './venueMap'; import { AID_UI, BAR_ORDER_LINES, BAR_RESULTS, BAR_TAB_UI, BAR_UI, DJ_DROP_RESULTS, DJ_REQUESTS, DJ_REQUEST_UI, DJ_TIP_UI, DJ_UI, POUR_UI, - HELP_TEXT, JOINT_RESULTS, JOINT_STING, JOINT_UI, NO_STAMP_RADIO, STAFF_RADIO, + HELP_TEXT, JOINT_RESULTS, JOINT_STING, JOINT_UI, NO_STAMP_RADIO, STAFF_RADIO, SWEEP_UI, PHONE_POSTED, PHONE_UI, VOMIT_UI, WATER_UI, } from '../../data/strings/floor'; import { MeterHud } from '../../ui/MeterHud'; @@ -159,6 +160,12 @@ export class FloorDemoScene extends Phaser.Scene { private djReturns: Array<{ scriptId: string; tip: number; dueMs: number }> = []; /** Radio command: elapsedMs at which each floor order is usable again. */ private readonly staffReadyAt = new Map(); + /** The floor score: lights on, crowd gone, the sweep. */ + private sweeping = false; + private sweepCash = 0; + private sweepDone: ((score: { cash: number }) => void) | null = null; + private sweepDrops: Array<{ drop: Drop; x: number; y: number; gfx: Phaser.GameObjects.Container }> = []; + private baggiePending: Drop | null = null; private djBuild: { armedAtMs: number } | null = null; private lastBeat = { index: 0, atMs: 0 }; private beatMs = 500; @@ -272,6 +279,8 @@ export class FloorDemoScene extends Phaser.Scene { if (ctx.venue.radioCommand) { ctx.floorOrder = (kind, notify) => this.execStaffOrder(kind, notify); } + // The floor score: the night calls this at close, before the summary. + ctx.beginSweep = (done) => this.enterSweep(done); // The roster put you on a post: walk straight onto it once the world is up. if (ctx.role.post) { this.time.delayedCall(600, () => { @@ -421,6 +430,16 @@ export class FloorDemoScene extends Phaser.Scene { if (this.patDown.isOpen) return this.patDown.handleKey(key); if (this.choice.isOpen) return this.choice.handleKey(key); + if (this.sweeping) { + if (this.baggiePending) { + if (key === 'B') return this.resolveBaggie(false); + if (key === 'SPACE') return this.resolveBaggie(true); + return; // the bag deserves your full attention + } + if (key === 'E') return this.sweepInteract(); + return; + } + if (this.djMode) { if (key === 'B') return this.djStartBuild(); if (key === 'SPACE') return this.djDrop(); @@ -471,6 +490,10 @@ export class FloorDemoScene extends Phaser.Scene { if (!this.view) return; // world still waiting on the prop manifest const dt = Math.min(deltaMs, 50); + // Lights on: the sweep replaces the whole night loop. The night is over — + // no clock, no crowd, no meters. Just you, the floor, and what it dropped. + if (this.sweeping) return this.tickSweep(dt); + // These run REGARDLESS of overlays. The music doesn't stop because you // opened a cubicle door — and the stall minigame judges against beat:tick, // so freezing the beat clock behind a modal makes it literally unplayable. @@ -1335,6 +1358,147 @@ export class FloorDemoScene extends Phaser.Scene { ); } + // ---- the floor score (lights on at close) ------------------------------ + + /** The night is over; the lights come on; the floor pays out. */ + private enterSweep(done: (score: { cash: number }) => void): void { + this.sweeping = true; + this.sweepDone = done; + this.sweepCash = 0; + this.baggiePending = null; + this.present = true; + this.barMode = false; + this.djMode = false; + this.escorting = null; + this.rack = null; + this.mop = null; + this.uvMode = false; + // Everyone out. The crowd sim keeps the bodies; the lights don't. + for (const a of this.crowd.agents) a.gone = true; + this.view.syncAgents(this.crowd.agents); + this.view.setFight(null, 0, 0); + this.view.setDowned(null); + + // The lights: a wash over the whole room. Ugly on purpose — closing + // lights are never flattering. + this.add + .rectangle((MAP_W * 16) / 2, (MAP_H * 16) / 2, MAP_W * 16, MAP_H * 16, 0xfff2d8, 0.16) + .setDepth(38); + + // What the night dropped, scattered anywhere feet could reach. + const rs = this.rng.stream('sweep'); + const drops = rollDrops(rs, this.crowd.agents.length); + this.sweepDrops = drops.map((drop) => { + let tx = 40; + let ty = 22; + for (let tries = 0; tries < 60; tries++) { + tx = rs.int(2, MAP_W - 3); + ty = rs.int(2, MAP_H - 3); + if (isWalkable(VENUE, tx, ty)) break; + } + const { x, y } = tileToWorld(tx, ty); + const gfx = this.add.container(x, y).setDepth(39); + gfx.add(this.add.circle(0, 0, 4, 0x000000, 0.25).setScale(1, 0.5)); + gfx.add(this.add.circle(0, -2, 3, drop.kind === 'baggie' ? 0xa0e0a0 : 0xffe080)); + this.tweens.add({ targets: gfx, scale: { from: 1, to: 1.25 }, duration: 600, yoyo: true, repeat: -1 }); + return { drop, x, y, gfx }; + }); + + this.toast(SWEEP_UI.open); + this.promptText.setText(SWEEP_UI.open); + this.logIncident('lightsOn', undefined, `Close. ${drops.length} things on the floor.`); + } + + private tickSweep(dt: number): void { + if (!this.baggiePending) { + const input = this.readInput(); + this.player = stepPlayer(this.player, input, dt, VENUE); + } + this.cameras.main.centerOn(this.player.x, this.player.y); + this.view.updateLights(dt); + this.view.updatePlayer(this.player.x, this.player.y, this.player.facing); + + // Prompt: the bag first, then the nearest find, then the way out. + if (this.baggiePending) { + this.promptText.setText(SWEEP_UI.baggiePrompt); + return; + } + const near = this.nearestDrop(); + if (near) { + this.promptText.setText(`${SWEEP_UI.pickup} — ${near.drop.label}`); + return; + } + const entry = VENUE.anchors.entry[0]; + const atExit = + entry && Phaser.Math.Distance.Between(this.player.x, this.player.y, entry.x, entry.y) <= INTERACT_RANGE; + this.promptText.setText(atExit ? SWEEP_UI.leave : this.sweepDrops.length === 0 ? SWEEP_UI.lastOne : ''); + } + + private nearestDrop(): (typeof this.sweepDrops)[number] | null { + let best: (typeof this.sweepDrops)[number] | null = null; + let bestD = INTERACT_RANGE; + for (const d of this.sweepDrops) { + const dist = Phaser.Math.Distance.Between(this.player.x, this.player.y, d.x, d.y); + if (dist < bestD) { + bestD = dist; + best = d; + } + } + return best; + } + + private sweepInteract(): void { + const near = this.nearestDrop(); + if (near) return this.collectDrop(near); + const entry = VENUE.anchors.entry[0]; + if (entry && Phaser.Math.Distance.Between(this.player.x, this.player.y, entry.x, entry.y) <= INTERACT_RANGE) { + this.finishSweep(); + } + } + + private collectDrop(d: (typeof this.sweepDrops)[number]): void { + d.gfx.destroy(); + this.sweepDrops = this.sweepDrops.filter((x) => x !== d); + const drop = d.drop; + if (drop.kind === 'baggie') { + this.baggiePending = drop; + this.toast(drop.label); + return; + } + if (drop.kind === 'phone') { + this.logIncident('lostProperty', undefined, 'A phone off the floor, into lost property.'); + this.toast(SWEEP_UI.phoneFound); + } else { + this.sweepCash += drop.value; + this.toast(drop.label); + this.nightCtx?.sfx?.play('clickerClunk'); + } + if (this.sweepDrops.length === 0) this.time.delayedCall(1200, () => this.toast(SWEEP_UI.lastOne)); + } + + private resolveBaggie(pocket: boolean): void { + this.baggiePending = null; + if (pocket) { + this.logIncident('baggiePocketed', undefined, 'Contraband off the floor. Retained by security.'); + this.toast(SWEEP_UI.baggiePocketed); + } else { + this.logIncident('baggieBinned', undefined, 'Contraband off the floor, into the amnesty bin.'); + this.toast(SWEEP_UI.baggieBinned); + } + } + + private finishSweep(): void { + if (!this.sweeping) return; + this.sweeping = false; + if (this.sweepCash > 0) { + this.logIncident('floorScore', undefined, `Swept the floor: $${this.sweepCash} found.`); + } + this.toast(SWEEP_UI.done.replace('{cash}', String(this.sweepCash))); + const cb = this.sweepDone; + this.sweepDone = null; + this.time.delayedCall(900, () => cb?.({ cash: this.sweepCash })); + } + // ---- radio command (design §6, Head of Security) ----------------------- /** Floor-side radio keys: ack + result toasts land here. */ diff --git a/tests/floorScore.test.ts b/tests/floorScore.test.ts new file mode 100644 index 0000000..f2e0347 --- /dev/null +++ b/tests/floorScore.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { + DROPS_BASE, + DROPS_MAX, + NOTE_VALUES, + rollDrops, +} from '../src/rules/floorScore'; +import { SeededRNG } from '../src/core/SeededRNG'; + +describe('the floor score', () => { + it('a busier night is a messier floor, up to a limit', () => { + const rng = new SeededRNG(1); + expect(rollDrops(rng.stream('a'), 0).length).toBe(DROPS_BASE); + expect(rollDrops(rng.stream('b'), 24).length).toBe(DROPS_BASE + 4); + expect(rollDrops(rng.stream('c'), 500).length).toBe(DROPS_MAX); + }); + + it('is deterministic per stream — the same night drops the same floor', () => { + const a = rollDrops(new SeededRNG(7).stream('sweep'), 40); + const b = rollDrops(new SeededRNG(7).stream('sweep'), 40); + expect(a).toEqual(b); + }); + + it('cash is real denominations; curios, phones and baggies are worth $0', () => { + for (let seed = 1; seed <= 20; seed++) { + for (const d of rollDrops(new SeededRNG(seed).stream('s'), 60)) { + if (d.kind === 'note') expect(NOTE_VALUES).toContain(d.value); + else if (d.kind === 'coins') expect(d.value).toBeGreaterThan(0); + else expect(d.value).toBe(0); + } + } + }); + + it('at most one baggie and one phone per night', () => { + for (let seed = 1; seed <= 30; seed++) { + const drops = rollDrops(new SeededRNG(seed).stream('s'), 60); + expect(drops.filter((d) => d.kind === 'baggie').length).toBeLessThanOrEqual(1); + expect(drops.filter((d) => d.kind === 'phone').length).toBeLessThanOrEqual(1); + } + }); + + it('every drop has words — the label IS the reward for curios', () => { + for (const d of rollDrops(new SeededRNG(11).stream('s'), 30)) { + expect(d.label.length).toBeGreaterThan(5); + } + }); +}); diff --git a/tests/nightSummary.test.ts b/tests/nightSummary.test.ts index 9cb0c22..a197645 100644 --- a/tests/nightSummary.test.ts +++ b/tests/nightSummary.test.ts @@ -24,6 +24,7 @@ const log = (over: Partial = {}): NightLog => ({ seed: 1, role: 'DOOR', wage: 180, + floorCash: 0, ...over, });