From 4118a60aeac2616dc4c9bfd9afee5505ded7bce2 Mon Sep 17 00:00:00 2001 From: type-two Date: Wed, 22 Jul 2026 03:17:39 +1000 Subject: [PATCH] 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 --- LANEHANDOVER.md | 67 ++++++++++++++++++++++++++++ src/scenes/door/NightScene.ts | 22 +++++++++ src/scenes/door/NightSummaryScene.ts | 7 ++- src/scenes/floor/FloorDemoScene.ts | 5 +++ tests/nightSummary.test.ts | 24 ++++++++++ 5 files changed, 124 insertions(+), 1 deletion(-) diff --git a/LANEHANDOVER.md b/LANEHANDOVER.md index d72dd0c..8ababcf 100644 --- a/LANEHANDOVER.md +++ b/LANEHANDOVER.md @@ -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. diff --git a/src/scenes/door/NightScene.ts b/src/scenes/door/NightScene.ts index 8ee2706..751afbf 100644 --- a/src/scenes/door/NightScene.ts +++ b/src/scenes/door/NightScene.ts @@ -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; }, diff --git a/src/scenes/door/NightSummaryScene.ts b/src/scenes/door/NightSummaryScene.ts index 3e93897..b968d09 100644 --- a/src/scenes/door/NightSummaryScene.ts +++ b/src/scenes/door/NightSummaryScene.ts @@ -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) => { diff --git a/src/scenes/floor/FloorDemoScene.ts b/src/scenes/floor/FloorDemoScene.ts index 2fd9643..a1ffae5 100644 --- a/src/scenes/floor/FloorDemoScene.ts +++ b/src/scenes/floor/FloorDemoScene.ts @@ -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([{ diff --git a/tests/nightSummary.test.ts b/tests/nightSummary.test.ts index a197645..0bcd531 100644 --- a/tests/nightSummary.test.ts +++ b/tests/nightSummary.test.ts @@ -25,6 +25,7 @@ const log = (over: Partial = {}): 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); + }); +});