The floor score: lights on at close — sweep the notes, the shrapnel, the untouched kebab; the baggie is a choice and the pay line knows

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-21 03:40:24 +10:00
parent ec9afaff0f
commit d4f802407b
8 changed files with 352 additions and 6 deletions

View File

@ -1623,3 +1623,35 @@ must yield between exec calls before the child scenes exist.
Role ladder ✓ roster board ✓ venues 14 ✓ 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, 23 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]`.

View File

@ -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)',

68
src/rules/floorScore.ts Normal file
View File

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

View File

@ -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, () => {
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();
}
});
}

View File

@ -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) => {

View File

@ -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<FloorOrderKind, number>();
/** 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. */

47
tests/floorScore.test.ts Normal file
View File

@ -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);
}
});
});

View File

@ -24,6 +24,7 @@ const log = (over: Partial<NightLog> = {}): NightLog => ({
seed: 1,
role: 'DOOR',
wage: 180,
floorCash: 0,
...over,
});