The back office: tip the night into a coin sorter and watch it cope

Between the swept floor and the paperwork. Cash counts, the phone slides into a
lost-property shoebox, curios JAM the machine, and the bag stops it dead with a
different line depending on whether you pocketed it hours ago. Ledger builds
down the left as a receipt for the night.

Sequence runs off update(), not tween callbacks — a dropped callback would have
stranded the player short of the paperwork. Desk also stops itself on handoff.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-08-05 16:08:25 +10:00
parent 91a27b56a4
commit 8bd219796a
8 changed files with 490 additions and 6 deletions

View File

@ -2394,3 +2394,41 @@ Worth remembering: **a health check that cannot distinguish "refused" from
Reset is **00:00 UTC = 10:00 AEST**. An evening session in Australia will always
find it spent; a morning one gets ~150 free images first. Today's poster re-rolls
were the first CF-generated sprites in the project.
## SESSION — FABLE-SOLO-26 · 2026-07-22
**Branch:** main (solo, Fable) · **Gate:** lint ✓ build ✓ test ✓ (793 tests, 54 files)
**Deployed** + verified.
### The back office — the haul finally means something
The sweep was collecting an untouched kebab, a single vintage sneaker and
seventeen bobby pins, then throwing the lot away as one integer. Now there is a
screen between the swept floor and the paperwork: you tip the night onto a desk
and feed it to a COIN-U-SORT 400, one thing at a time.
- **`rules/haul.ts`** (pure, 10 tests): `tallyHaul(haul, pocketedBaggie)` gives
every item a FATE — cash `counted`, phone `rejected` (into a lost-property
tray that is a shoebox), curios `jammed`, the bag `refused`. Quips are picked
by POSITION, not chance, so a seed's night always tells the same joke.
`sorterVerdict()` signs off based on how badly you treated a machine worth $80.
- **`scenes/shared/HaulDeskScene`**: items fall into the hopper, the tally pops,
the machine physically shakes when it jams, and a ledger builds down the left
(`+ $20 / · JAM / + $6 / · not a coin / · lost property / + $50`) — the night
as a receipt. Booth tips are added at the end as cash the sorter "never needs
to know about".
- The bag gets a different line depending on the call you made hours earlier:
pocketed, "the sorter has no slot for this. neither, legally, do you."; binned,
the desk notes your restraint in pencil.
- `Sweep` now returns `SweepResult { cash, haul, pocketedBaggie }` instead of a
bare number, so the desk can replay what you actually picked up.
### Driven from update(), NOT tween callbacks — deliberately
First cut chained the ceremony off `tween.onComplete` + `delayedCall` and it hung
at item 1 in the pane. That is the kebab-run lesson from SOLO-10 (tweens stall
under the stepped clock) but the REAL reason to change it is softlock class: a
dropped callback would strand the player on this screen with no route to the
paperwork. Tweens here decorate; `update()` owns the state machine.
### And a bug the verification caught
The desk handed off to NightSummary while **staying active underneath it**
nobody stopped it. Same class as the dev-route bug that left a live Night
driving a dead Door. `go()` now stops itself before calling `next()`.

View File

@ -164,6 +164,18 @@ export const NO_AUTHORITY = {
moot: 'it resolved itself, the way these things do when nobody is coming.',
} as const;
/** The back office at 3:40 AM, and a machine being asked to do too much. */
export const HAUL_UI = {
title: 'THE BACK OFFICE',
subtitle: '3:40 AM · tip it out · the sorter will handle it · the sorter will not handle it',
machineName: 'COIN-U-SORT 400',
tallyLabel: 'TONIGHT',
doneItem: 'pockets empty. desk covered. machine humbled.',
/** {n} = cash somebody actually handed you during the shift. */
plusPocket: 'plus ${n} from the shoe, which the sorter never needs to know about.',
prompt: 'click for the paperwork · ESC to skip the ceremony',
} 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)',

View File

@ -7,6 +7,7 @@ import { NightSummaryScene } from './scenes/door/NightSummaryScene';
import { IncidentReportScene } from './scenes/door/IncidentReportScene';
import { FloorDemoScene } from './scenes/floor/FloorDemoScene';
import { RosterScene } from './scenes/shared/RosterScene';
import { HaulDeskScene } from './scenes/shared/HaulDeskScene';
import { JuiceDemoScene } from './ui/JuiceDemoScene';
import { MixDeskScene } from './ui/MixDeskScene';
@ -33,6 +34,7 @@ const game = new Phaser.Game({
NightScene,
DoorScene,
NightSummaryScene,
HaulDeskScene,
IncidentReportScene,
new FloorDemoScene('Floor'),
BootScene,

95
src/rules/haul.ts Normal file
View File

@ -0,0 +1,95 @@
// The back office, 3:40 AM. You tip the night out of your pockets onto the
// desk and feed it to the coin sorter — a machine built for one job, being
// asked to do several.
//
// Pure sorting here; scenes/shared/HaulDeskScene owns the desk, the falling and
// the noise. The comedy is entirely in what the machine will and will not
// accept: cash sails through, everything else is a small institutional crisis.
import type { Drop, DropKind } from './floorScore';
/** What the sorter does with a thing. */
export type SorterFate = 'counted' | 'rejected' | 'jammed' | 'refused';
export interface HaulLine {
label: string;
value: number;
fate: SorterFate;
/** The machine's opinion, printed under the tally. */
quip: string;
}
export interface HaulTally {
lines: HaulLine[];
/** Cash the sorter actually counted. */
counted: number;
jams: number;
}
/** Sorter behaviour per kind. Money is the only thing it was designed for. */
const FATE: Record<DropKind, SorterFate> = {
note: 'counted',
coins: 'counted',
phone: 'rejected',
curio: 'jammed',
baggie: 'refused',
};
const JAM_QUIPS: readonly string[] = [
'the sorter thinks about it for a long moment.',
'the sorter makes a noise it has never made before.',
'something inside the sorter gives up.',
'the sorter rejects this on principle.',
'the sorter would like to speak to someone about this.',
'the machine stops. the machine considers its career.',
];
const COUNT_QUIPS: readonly string[] = [
'in it goes.',
'the sorter purrs.',
'clunk.',
'the satisfying one.',
];
export const REJECT_QUIP = 'slides out the side into the LOST PROPERTY tray. the tray is a shoebox.';
export const REFUSE_QUIP = 'the sorter has no slot for this. neither, legally, do you.';
export const REFUSE_BINNED_QUIP = 'binned hours ago. the desk notes your restraint, in pencil, somewhere.';
/**
* Feed the night's haul through the machine, in the order you picked it up.
* Deterministic: quips are chosen by position, not by chance, so the same
* night always tells the same joke the seed owns the humour too.
*/
export function tallyHaul(haul: readonly Drop[], pocketedBaggie: boolean): HaulTally {
const lines: HaulLine[] = [];
let counted = 0;
let jams = 0;
haul.forEach((drop, i) => {
const fate = FATE[drop.kind];
let quip: string;
if (fate === 'counted') {
counted += drop.value;
quip = COUNT_QUIPS[i % COUNT_QUIPS.length]!;
} else if (fate === 'rejected') {
quip = REJECT_QUIP;
} else if (fate === 'refused') {
// A binned bag never reaches the desk; a pocketed one very much does.
quip = pocketedBaggie ? REFUSE_QUIP : REFUSE_BINNED_QUIP;
} else {
jams++;
quip = JAM_QUIPS[jams % JAM_QUIPS.length]!;
}
lines.push({ label: drop.label, value: drop.value, fate, quip });
});
return { lines, counted, jams };
}
/** The sign-off, earned by how badly you jammed a machine worth $80. */
export function sorterVerdict(t: HaulTally): string {
if (t.lines.length === 0) return 'nothing in the pockets. a clean night, or a boring one.';
if (t.jams === 0) return 'the sorter had a lovely evening. so did you, financially.';
if (t.jams >= 4) return 'the sorter will need to be looked at. by someone. eventually.';
return 'the sorter survives to count another night.';
}

View File

@ -133,7 +133,7 @@ export interface NightContext {
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;
beginSweep?: (done: (score: import('../floor/sweep').SweepResult) => void) => void;
/**
* Cash taken during the shift, banked as it is taken. Pushed rather than read
* at close so it survives a night that ends early you keep what you were
@ -615,7 +615,16 @@ export class NightScene extends Phaser.Scene {
this.applyLocation('floor');
this.ctx.beginSweep((score) => {
this.log.floorCash = score.cash;
proceed();
// The back office: you tip the night out and feed it to the sorter
// before anyone lets you near the paperwork.
this.scene.stop('Door');
this.scene.stop('Floor');
this.scene.start('HaulDesk', {
haul: score.haul,
pocketedBaggie: score.pocketedBaggie,
pocketCash: this.log.pocketCash,
next: proceed,
});
});
} else {
proceed();

View File

@ -32,6 +32,13 @@ export interface SweepHost {
playCoin(): void;
}
/** What the night hands the office desk (scenes/shared/HaulDeskScene). */
export interface SweepResult {
cash: number;
haul: Drop[];
pocketedBaggie: boolean;
}
interface Lying {
drop: Drop;
x: number;
@ -42,7 +49,11 @@ interface Lying {
export class Sweep {
private active = false;
private cash = 0;
private done: ((score: { cash: number }) => void) | null = null;
private done: ((score: SweepResult) => void) | null = null;
/** Everything actually picked up, in the order it left the floor. */
private readonly haul: Drop[] = [];
/** Did the bag go in the pocket rather than the bin? The desk asks. */
private pocketedBaggie = false;
private lying: Lying[] = [];
/** The bag freezes your feet until you make the call. */
private baggiePending: Drop | null = null;
@ -62,10 +73,12 @@ export class Sweep {
}
/** The night is over; the lights come on; the floor pays out. */
begin(rng: RngStream, crowdSize: number, done: (score: { cash: number }) => void): void {
begin(rng: RngStream, crowdSize: number, done: (score: SweepResult) => void): void {
this.active = true;
this.done = done;
this.cash = 0;
this.haul.length = 0;
this.pocketedBaggie = false;
this.baggiePending = null;
// Everyone out. The crowd sim keeps the bodies; the lights do not.
@ -159,6 +172,7 @@ export class Sweep {
d.gfx.destroy();
this.lying = this.lying.filter((x) => x !== d);
const { drop } = d;
this.haul.push(drop);
if (drop.kind === 'baggie') {
this.baggiePending = drop;
this.host.toast(drop.label);
@ -177,6 +191,7 @@ export class Sweep {
private resolveBaggie(pocket: boolean): void {
this.baggiePending = null;
this.pocketedBaggie = pocket;
if (pocket) {
this.host.logIncident('baggiePocketed', undefined, 'Contraband off the floor. Retained by security.');
this.host.toast(SWEEP_UI.baggiePocketed);
@ -193,7 +208,7 @@ export class Sweep {
this.host.toast(SWEEP_UI.done.replace('{cash}', String(this.cash)));
const cb = this.done;
this.done = null;
const cash = this.cash;
this.scene.time.delayedCall(900, () => cb?.({ cash }));
const result: SweepResult = { cash: this.cash, haul: [...this.haul], pocketedBaggie: this.pocketedBaggie };
this.scene.time.delayedCall(900, () => cb?.(result));
}
}

View File

@ -0,0 +1,237 @@
import Phaser from 'phaser';
import { tallyHaul, sorterVerdict, type HaulLine } from '../../rules/haul';
import type { Drop } from '../../rules/floorScore';
import { HAUL_UI } from '../../data/strings/floor';
// The back office, 3:40 AM. Between the swept floor and the paperwork, you tip
// the night out onto the desk and feed it to the coin sorter one thing at a
// time. Cash goes through. Everything else is a small institutional crisis.
//
// Deliberately NOT interactive beyond "next": the joke is watching a machine
// fail at its job, and rushing it is the only verb anyone wants.
//
// The SEQUENCE is driven from update(), not from tween.onComplete chains. Two
// reasons, one of them learned the hard way here already (the kebab run,
// SOLO-10): tweens stall under the preview pane's stepped clock so a callback
// chain is unverifiable, and — the real one — a dropped callback would strand
// the player on this screen with no way to the paperwork. Tweens here decorate;
// they never advance the state machine.
const W = 640;
const H = 360;
const MONO = 'monospace';
/** Real ms an item takes to fall from the hand into the mouth of the sorter. */
const FALL_MS = 520;
/** Beat after it lands, before the next one goes in. */
const BEAT_MS = 620;
/** A jam costs the machine a longer, more embarrassing pause. */
const JAM_MS = 1150;
export interface HaulDeskData {
haul: Drop[];
pocketedBaggie: boolean;
/** Cash already in your pocket from the shift (booth tips). Not sorted. */
pocketCash: number;
next: () => void;
}
export class HaulDeskScene extends Phaser.Scene {
private args!: HaulDeskData;
private lines: HaulLine[] = [];
private idx = 0;
private counted = 0;
private tallyText!: Phaser.GameObjects.Text;
private quipText!: Phaser.GameObjects.Text;
private itemText!: Phaser.GameObjects.Text;
private sorter!: Phaser.GameObjects.Container;
private ledger!: Phaser.GameObjects.Text;
private finished = false;
/** Sequence state, advanced by update() alone. */
private phase: 'wait' | 'falling' | 'settling' = 'wait';
private phaseMs = 0;
private phaseFor = 700;
private falling: Phaser.GameObjects.Arc | null = null;
private tally!: ReturnType<typeof tallyHaul>;
constructor() {
super('HaulDesk');
}
init(args: HaulDeskData): void {
this.args = args;
}
create(): void {
const t = tallyHaul(this.args.haul, this.args.pocketedBaggie);
this.lines = t.lines;
this.add.rectangle(W / 2, H / 2, W, H, 0x14110c);
// the desk: a slab of laminate under a single tired bulb
this.add.ellipse(W / 2, 96, 460, 220, 0x2a2318, 0.55);
this.add.rectangle(W / 2, 250, W, 220, 0x3a2f1e);
this.add.rectangle(W / 2, 142, W, 3, 0x241c12);
this.add
.text(W / 2, 20, HAUL_UI.title, { fontFamily: MONO, fontSize: '15px', color: '#f0e4d0' })
.setOrigin(0.5);
this.add
.text(W / 2, 38, HAUL_UI.subtitle, { fontFamily: MONO, fontSize: '8px', color: '#8a7c60' })
.setOrigin(0.5);
this.buildSorter();
this.itemText = this.add
.text(W / 2, 60, '', { fontFamily: MONO, fontSize: '9px', color: '#e8dcc4', align: 'center', wordWrap: { width: 420 } })
.setOrigin(0.5, 0);
this.quipText = this.add
.text(W / 2, 300, '', { fontFamily: MONO, fontSize: '8px', color: '#a09070', align: 'center', wordWrap: { width: 460 } })
.setOrigin(0.5, 0);
this.tallyText = this.add
.text(W - 18, 250, '$0', { fontFamily: MONO, fontSize: '26px', color: '#f0d060' })
.setOrigin(1, 0.5);
this.add
.text(W - 18, 232, HAUL_UI.tallyLabel, { fontFamily: MONO, fontSize: '7px', color: '#8a7c60' })
.setOrigin(1, 0.5);
this.ledger = this.add
.text(18, 176, '', { fontFamily: MONO, fontSize: '7px', color: '#6a5c44', lineSpacing: 2 });
this.tally = t;
const skip = (): void => this.finish(t);
this.input.keyboard?.once('keydown-ESC', skip);
}
/** The only thing that moves the ceremony along. */
override update(_time: number, deltaMs: number): void {
if (this.finished) return;
this.phaseMs += deltaMs;
if (this.phase === 'falling' && this.falling) {
// Hand-interpolated so the fall is visible in a stepped clock too.
const k = Math.min(1, this.phaseMs / FALL_MS);
this.falling.y = 84 + (168 - 84) * k * k;
}
if (this.phaseMs < this.phaseFor) return;
this.phaseMs = 0;
if (this.phase === 'falling') {
this.falling?.destroy();
this.falling = null;
const line = this.lines[this.idx - 1];
if (line) this.land(line);
return;
}
// 'wait' or 'settling': the next thing goes in.
this.feedNext();
}
/** The machine: a hopper, a slot, and a tray that has seen things. */
private buildSorter(): void {
const x = W / 2;
const y = 210;
const c = this.add.container(x, y);
this.sorter = c;
c.add(this.add.rectangle(0, 0, 150, 92, 0x5a5348).setStrokeStyle(2, 0x3a352c));
c.add(this.add.rectangle(0, -38, 116, 20, 0x241f18)); // hopper mouth
c.add(this.add.rectangle(0, -46, 132, 8, 0x6a6355));
for (let i = -2; i <= 2; i++) {
c.add(this.add.rectangle(i * 26, 22, 18, 26, 0x2a251c)); // coin channels
}
c.add(this.add.rectangle(0, 50, 150, 10, 0x46403a)); // the tray
c.add(
this.add
.text(0, -8, HAUL_UI.machineName, { fontFamily: MONO, fontSize: '7px', color: '#9a9080' })
.setOrigin(0.5),
);
}
private feedNext(): void {
if (this.finished) return;
if (this.idx >= this.lines.length) return this.finish(this.tally);
const line = this.lines[this.idx]!;
this.idx++;
this.itemText.setText(line.label);
this.quipText.setText('');
// The thing falls from the hand into the hopper.
const colour =
line.fate === 'counted' ? 0xf0d060 : line.fate === 'refused' ? 0xa0e0a0 : 0xc8bca0;
this.falling = this.add.circle(W / 2, 84, 6, colour).setDepth(5);
this.phase = 'falling';
this.phaseFor = FALL_MS;
}
private land(line: HaulLine): void {
this.quipText.setText(line.quip);
const push = (s: string): void => {
const prev = this.ledger.text ? this.ledger.text.split('\n') : [];
prev.push(s);
this.ledger.setText(prev.slice(-9).join('\n'));
};
if (line.fate === 'counted') {
this.counted += line.value;
this.tallyText.setText(`$${this.counted}`);
this.tweens.add({ targets: this.tallyText, scale: { from: 1.3, to: 1 }, duration: 180 });
this.tweens.add({ targets: this.sorter, y: { from: 214, to: 210 }, duration: 90, yoyo: true });
push(`+ $${line.value}`);
this.phase = 'settling';
this.phaseFor = BEAT_MS;
return;
}
if (line.fate === 'rejected') {
push('· lost property');
this.phase = 'settling';
this.phaseFor = BEAT_MS;
return;
}
// A jam or a refusal: the machine shakes, thinks, and moves on with less
// dignity than it had. This is the whole reason the screen exists.
push(line.fate === 'refused' ? '· not a coin' : '· JAM');
this.tweens.add({
targets: this.sorter,
x: { from: W / 2 - 3, to: W / 2 + 3 },
duration: 55,
yoyo: true,
repeat: line.fate === 'refused' ? 2 : 6,
onComplete: () => this.sorter.setX(W / 2),
});
this.phase = 'settling';
this.phaseFor = JAM_MS;
}
private finish(t: ReturnType<typeof tallyHaul>): void {
if (this.finished) return;
this.finished = true;
this.tweens.killAll();
this.sorter.setPosition(W / 2, 210);
// Everything the sorter never saw: what somebody handed you during the shift.
const total = t.counted + this.args.pocketCash;
this.counted = t.counted;
this.tallyText.setText(`$${total}`);
this.itemText.setText(
this.args.pocketCash > 0
? HAUL_UI.plusPocket.replace('{n}', String(this.args.pocketCash))
: HAUL_UI.doneItem,
);
this.quipText.setText(sorterVerdict(t));
this.add
.text(W / 2, H - 14, HAUL_UI.prompt, { fontFamily: MONO, fontSize: '8px', color: '#8a7c60' })
.setOrigin(0.5);
// Stop OURSELVES on the way out. next() starts the summary but has no idea
// this scene exists, so without this the desk keeps running underneath the
// paperwork — same class as the dev-route bug that left a live Night
// driving a dead Door.
const go = (): void => {
this.scene.stop();
this.args.next();
};
this.input.once('pointerdown', go);
this.input.keyboard?.once('keydown-SPACE', go);
}
}

76
tests/haul.test.ts Normal file
View File

@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { REFUSE_QUIP, REFUSE_BINNED_QUIP, tallyHaul, sorterVerdict } from '../src/rules/haul';
import { rollDrops } from '../src/rules/floorScore';
import { SeededRNG } from '../src/core/SeededRNG';
import type { Drop } from '../src/rules/floorScore';
const d = (kind: Drop['kind'], value = 0, label = kind): Drop => ({ kind, value, label });
describe('the coin sorter', () => {
it('counts money and nothing else', () => {
const t = tallyHaul([d('note', 20), d('coins', 7), d('curio'), d('phone'), d('baggie')], true);
expect(t.counted).toBe(27);
});
it('jams on the objects, and that is the whole joke', () => {
const t = tallyHaul([d('curio'), d('curio'), d('note', 5)], false);
expect(t.jams).toBe(2);
expect(t.lines.filter((l) => l.fate === 'jammed')).toHaveLength(2);
expect(t.lines.find((l) => l.fate === 'counted')?.value).toBe(5);
});
it('the phone goes sideways into lost property, never into the total', () => {
const t = tallyHaul([d('phone')], false);
expect(t.lines[0]!.fate).toBe('rejected');
expect(t.counted).toBe(0);
});
it('the bag gets a different line depending on what you did with it', () => {
expect(tallyHaul([d('baggie')], true).lines[0]!.quip).toBe(REFUSE_QUIP);
expect(tallyHaul([d('baggie')], false).lines[0]!.quip).toBe(REFUSE_BINNED_QUIP);
});
it('keeps the order you picked things up in — the desk replays your night', () => {
const haul: Drop[] = [
{ kind: 'note', value: 10, label: 'a' },
{ kind: 'curio', value: 0, label: 'b' },
{ kind: 'coins', value: 3, label: 'c' },
];
expect(tallyHaul(haul, false).lines.map((l) => l.label)).toEqual(['a', 'b', 'c']);
});
it('is deterministic — the same night tells the same joke', () => {
const haul = rollDrops(new SeededRNG(4207).stream('sweep'), 40);
expect(tallyHaul(haul, true)).toEqual(tallyHaul(haul, true));
});
it('every line has words, whatever the machine made of it', () => {
const haul = rollDrops(new SeededRNG(9).stream('sweep'), 60);
for (const line of tallyHaul(haul, true).lines) {
expect(line.quip.length).toBeGreaterThan(5);
expect(line.label.length).toBeGreaterThan(0);
}
});
it('an empty haul is handled, and says so', () => {
const t = tallyHaul([], false);
expect(t).toMatchObject({ counted: 0, jams: 0 });
expect(t.lines).toEqual([]);
expect(sorterVerdict(t)).toContain('nothing in the pockets');
});
it('the sign-off escalates with how badly you treated the machine', () => {
const clean = sorterVerdict(tallyHaul([d('note', 5)], false));
const rough = sorterVerdict(tallyHaul(Array(5).fill(d('curio')), false));
expect(clean).not.toBe(rough);
expect(rough).toContain('looked at');
});
it('a real swept night always gives the desk something to do', () => {
for (const seed of [4207, 4208, 99]) {
const t = tallyHaul(rollDrops(new SeededRNG(seed).stream('sweep'), 40), true);
expect(t.lines.length).toBeGreaterThan(0);
expect(t.counted).toBeGreaterThan(0);
}
});
});