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:
parent
0d3ce80c6b
commit
4118a60aea
@ -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.
|
||||
|
||||
@ -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;
|
||||
},
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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([{
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user