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:
parent
91a27b56a4
commit
8bd219796a
@ -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()`.
|
||||
|
||||
@ -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)',
|
||||
|
||||
@ -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
95
src/rules/haul.ts
Normal 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.';
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
237
src/scenes/shared/HaulDeskScene.ts
Normal file
237
src/scenes/shared/HaulDeskScene.ts
Normal 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
76
tests/haul.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user