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
|
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
|
`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.
|
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;
|
wage: number;
|
||||||
/** What the lights-on sweep found in cash. */
|
/** What the lights-on sweep found in cash. */
|
||||||
floorCash: number;
|
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. */
|
/** 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.
|
/** The floor score: lights on at close, sweep what the night dropped.
|
||||||
* Registered by the floor; the night calls it before the summary. */
|
* Registered by the floor; the night calls it before the summary. */
|
||||||
beginSweep?: (done: (score: { cash: number }) => void) => void;
|
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 {
|
export class NightScene extends Phaser.Scene {
|
||||||
@ -230,6 +242,7 @@ export class NightScene extends Phaser.Scene {
|
|||||||
role: this.role.name,
|
role: this.role.name,
|
||||||
wage: this.role.wage,
|
wage: this.role.wage,
|
||||||
floorCash: 0,
|
floorCash: 0,
|
||||||
|
pocketCash: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.offs.push(
|
this.offs.push(
|
||||||
@ -281,6 +294,15 @@ export class NightScene extends Phaser.Scene {
|
|||||||
role: this.role,
|
role: this.role,
|
||||||
beatIntervalMs: 60_000 / 128,
|
beatIntervalMs: 60_000 / 128,
|
||||||
scheduleDeferred: (hits) => this.deferred.push(...hits),
|
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) => {
|
reportQueue: (length) => {
|
||||||
this.queueLength = 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 hypeBonus = Math.round((log.hypePeak - 1) * 90);
|
||||||
const doorBonus = log.admitted * 2;
|
const doorBonus = log.admitted * 2;
|
||||||
const strikes = state.heatStrikes.length * 60;
|
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 {
|
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,
|
this.state.heatStrikes.length > 0 ? '#ff8080' : undefined,
|
||||||
],
|
],
|
||||||
['floor score', `$${this.log.floorCash}`, this.log.floorCash > 0 ? '#f0d060' : 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) => {
|
rows.forEach(([label, value, colour], i) => {
|
||||||
|
|||||||
@ -1704,6 +1704,11 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.djTips += amount;
|
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.'}`);
|
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)) {
|
if (this.nightCtx && rq.chance(TIP_STING_CHANCE)) {
|
||||||
this.nightCtx.scheduleDeferred([{
|
this.nightCtx.scheduleDeferred([{
|
||||||
|
|||||||
@ -25,6 +25,7 @@ const log = (over: Partial<NightLog> = {}): NightLog => ({
|
|||||||
role: 'DOOR',
|
role: 'DOOR',
|
||||||
wage: 180,
|
wage: 180,
|
||||||
floorCash: 0,
|
floorCash: 0,
|
||||||
|
pocketCash: 0,
|
||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -128,3 +129,26 @@ describe('shouldRecordStrike', () => {
|
|||||||
expect(shouldRecordStrike(s, { reason: 'over capacity', deferred: true })).toBe(false);
|
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