Review sweep: close the softlock class with data+tests, un-deadlock the door, and make the booth shoe real money

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-22 03:17:39 +10:00
parent 0d3ce80c6b
commit 4118a60aea
5 changed files with 124 additions and 1 deletions

View File

@ -1733,3 +1733,70 @@ re-verified from the new spot.
Credit where due: this one was surfaced by a design-panel agent that probed
`stepPlayer` against the real `VENUE` in all four directions rather than reading
the code — worth copying as a technique for any "is this position legal" claim.
---
## SESSION — FABLE-SOLO-13 · 2026-07-22 (review sweep)
**Gate:** lint ✓ build ✓ test ✓ (741 tests, 49 files) · **Deployed + pushed.**
Three adversarial review workflows (6 finder dimensions each, every finding put
to three independent skeptics; 46 findings confirmed of ~55 raised). What I
actioned, beyond the four fixes already logged in FABLE-SOLO-12:
### The softlock class — now closed by construction
The DJ post had the SAME defect as the taps: `DECKS_SPOT` (55,22) sits inside
the sealed `djbooth`, which is not in `WALKABLE`. Taking the DJ shift and
stepping off the decks froze the player permanently — and because the closing
sweep requires walking to the entry anchor, **the night could never be paid**.
Moved to the booth's west counter (51,22), which is where the map comment always
said the gear faces from.
Then made the class untestable-by-accident: role stations are now DATA in
venueMap — `POSTS` (tiles a role stands you ON: must be walkable AND escapable)
vs `PROBES` (anchors you walk UP TO: may sit in furniture, must have somewhere
walkable within `INTERACT_RANGE`). `tests/floor/stations.test.ts` pins both, and
deliberately asserts on MOVEMENT (run the real `stepPlayer` against the real
`VENUE` for a second, in each direction) rather than tile names — the question is
never "what tile is this", it is "can the player leave". Writing it immediately
found a third instance (the water station probe sits on a `sink`), which the
POSTS/PROBES split correctly classifies as fine.
### The dead door
`DoorScene.busy` is released by a `delayedCall` on the SCENE clock, and stopping
a scene discards its pending events. Any ruling inside the last 2.6s of a night —
including the one that ends it — left `busy` stuck true: no rope, no verdicts, no
doorway, no carpet, for the rest of the run. Now reset per night alongside
`seenCards` (the passback net, which had been flagging Friday's returning
regulars as passbacks), `retiredRules`, `frankoTips` and the kebab flags.
### The bar punished the right call
Bar WATER and CUT OFF never settled the patron the way the water station does, so
watering a maggot from behind the taps left them ticking as unhandled and
re-logging every grace period. Also, every bar verb logged as `barServe` — a
cut-off went into the record as a drink that was never poured, which matters in a
game whose report system is about what you SAY happened.
### §4.3 inversion: the booth shoe was fake money
`djTips` accumulated into a toast and an incident and nothing else — it never
reached the pay line. Taking cash therefore had zero upside and kept its 25%
"word got around" vibe sting: the corrupt option was strictly dominated, which
breaks §4.3 exactly as badly as making it optimal. The shoe is real now:
`NightContext.bankCash(amount, source)``log.pocketCash`, its own summary row
("in your pocket"), added AFTER the strike deduction so a wiped wage cannot
reach into your pocket for money a punter physically handed you. Pinned by two
tests in nightSummary.test.ts.
Also: `carpetMsFor()` is consumed on read (a regular parked, recalled and knocked
back was paid the entrance dividend a second time on a later ordinary visit), and
sweep state joined `resetNightState()`.
### Still open from the reviews (triaged, NOT actioned)
- **Red carpet is an uncapped hype faucet** (HIGH, balance): parking bodies pays
the maximum bonus for ~33s of doing nothing. Wants a design call, not a patch —
it is a tuning question about what queue theatre should be worth.
- `enterSweep` leaves bar overlays open (the floor score swallows keys).
- Parking a scripted patron on the carpet leaks their dialogue beats onto the
next patron at the rope.
- The sweep is untimed while the door keeps running, so dawdling changes pay.
- `applyBarCall` does not re-check the asker is still in the building.

View File

@ -68,6 +68,12 @@ export interface NightLog {
wage: number;
/** What the lights-on sweep found in cash. */
floorCash: number;
/**
* Cash that went in your pocket during the shift the DJ booth's shoe. Kept
* apart from floorCash because the sweep only happens on a clean close, and a
* pulled licence does not confiscate what somebody already handed you.
*/
pocketCash: number;
}
/** Run-level result attached to the summary. */
@ -121,6 +127,12 @@ export interface NightContext {
/** 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;
/**
* 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
* handed, whatever happens to the licence.
*/
bankCash: (amount: number, source: string) => void;
}
export class NightScene extends Phaser.Scene {
@ -230,6 +242,7 @@ export class NightScene extends Phaser.Scene {
role: this.role.name,
wage: this.role.wage,
floorCash: 0,
pocketCash: 0,
};
this.offs.push(
@ -281,6 +294,15 @@ export class NightScene extends Phaser.Scene {
role: this.role,
beatIntervalMs: 60_000 / 128,
scheduleDeferred: (hits) => this.deferred.push(...hits),
bankCash: (amount, source) => {
if (amount <= 0) return;
this.log.pocketCash += amount;
this.bus.emit('incident:log', {
clockMin: this.state.clockMin,
kind: 'pocketCash',
detail: `$${amount}${source}.`,
});
},
reportQueue: (length) => {
this.queueLength = length;
},

View File

@ -19,7 +19,9 @@ 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) + log.floorCash;
// Cash is added AFTER the floor: a strike deduction can wipe your wage, but it
// cannot reach into your pocket for money a punter already handed you.
return Math.max(0, wage + hypeBonus + doorBonus - strikes) + log.floorCash + log.pocketCash;
}
export function signoffFor(vibe: number, strikes: number): string {
@ -188,6 +190,9 @@ export class NightSummaryScene extends Phaser.Scene {
this.state.heatStrikes.length > 0 ? '#ff8080' : undefined,
],
['floor score', `$${this.log.floorCash}`, this.log.floorCash > 0 ? '#f0d060' : undefined],
// Its own line, named plainly. The game does not editorialise about the
// shoe — it just declines to hide it (design §4.3).
['in your pocket', `$${this.log.pocketCash}`, this.log.pocketCash > 0 ? '#f0d060' : undefined],
];
rows.forEach(([label, value, colour], i) => {

View File

@ -1704,6 +1704,11 @@ export class FloorDemoScene extends Phaser.Scene {
return;
}
this.djTips += amount;
// The shoe is REAL money. It used to be a number that only ever reached
// a toast, which made taking cash pure downside (you still carried the
// sting roll) — an inversion of §4.3 just as dishonest as making it
// secretly optimal. Now it pays, and the temptation is a real one.
this.nightCtx?.bankCash(amount, 'booth tip');
this.logIncident('djTip', asker.patron.id, `Took $${amount} to play the ${s.id} request. ${hit ? 'It landed.' : 'It did not.'}`);
if (this.nightCtx && rq.chance(TIP_STING_CHANCE)) {
this.nightCtx.scheduleDeferred([{

View File

@ -25,6 +25,7 @@ const log = (over: Partial<NightLog> = {}): NightLog => ({
role: 'DOOR',
wage: 180,
floorCash: 0,
pocketCash: 0,
...over,
});
@ -128,3 +129,26 @@ describe('shouldRecordStrike', () => {
expect(shouldRecordStrike(s, { reason: 'over capacity', deferred: true })).toBe(false);
});
});
describe('the booth shoe', () => {
it('is real money — cash taken at the decks reaches the pay line', () => {
const without = nightCash(log({ pocketCash: 0 }), freshNightState('theRoyal', 80));
const withTips = nightCash(log({ pocketCash: 50 }), freshNightState('theRoyal', 80));
expect(withTips - without).toBe(50);
});
it('cannot be eaten by strikes — a deduction takes the wage, not your pocket', () => {
// Three strikes wipes the wage to the Math.max(0, ...) floor. Whatever a
// punter physically handed you is still in your pocket at the end of it.
const state = freshNightState('theRoyal', 80);
state.heatStrikes = [
{ reason: 'a', deferred: true },
{ reason: 'b', deferred: true },
{ reason: 'c', deferred: true },
];
const broke = nightCash(log({ wage: 120, pocketCash: 0 }), state);
const tipped = nightCash(log({ wage: 120, pocketCash: 40 }), state);
expect(broke).toBe(0);
expect(tipped).toBe(40);
});
});