Six defects from the adversarial review: two softlocks, a dead door, and a bar that punished the right call

- DJ post stood the player inside the sealed djbooth (same class as the taps
  softlock): every direction blocked, and since the sweep needs you to walk to
  the entry, the night could never be paid. DECKS_SPOT moves to the booth's west
  counter, which is where the map comment always said the gear faces from.
- Role stations are now DATA (venueMap POSTS/PROBES) with tests/floor/stations.test.ts
  pinning the whole bug class: posts must be walkable AND escapable, probes must
  have somewhere walkable within interact range. Written as a MOVEMENT test, not
  a tile-name test — the question is whether the player can leave.
- DoorScene.busy is released by a delayedCall on the scene clock, and stopping a
  scene discards pending events, so a ruling in the last 2.6s of a night left it
  stuck true: a dead door — no rope, no verdicts, no way inside — for the rest
  of the run. Now reset per night, along with seenCards/kebab/franko/retiredRules.
- Bar WATER and CUT OFF never settled the patron, so watering a maggot from
  behind the taps left them ticking as unhandled and re-logging every grace
  period: the call that fixed the problem kept being charged for it.
- Every bar verb logged as 'barServe', putting drinks in the record that were
  never poured. One kind per verb; the report system takes unknown kinds by design.
- carpetMsFor() is consumed on read: a regular parked, recalled and knocked back
  came round again later and was paid the entrance dividend a second time.
- Sweep state joins resetNightState() so an interrupted floor score cannot carry
  its cash, or its previous-night pay callback, into the next night.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-22 03:13:09 +10:00
parent bcc06c104a
commit 0d3ce80c6b
5 changed files with 204 additions and 7 deletions

View File

@ -143,6 +143,28 @@ export class DoorScene extends Phaser.Scene {
this.present = true;
this.kaydenAccMs = 0;
// Per-night state. Phaser relaunches this ONE scene instance every night, so
// field initialisers run once per game — anything scoped to a single night
// has to be cleared here. `seenCards` leaking was the worst of them: it is
// the passback net, so on Friday every returning regular walked up holding a
// card the desk "already admitted tonight", got stamped SEEN TONIGHT, and
// judge() scored the correct call as admitting a fake.
this.seenCards.clear();
this.retiredRules.clear();
// `busy` is released by a delayedCall on the SCENE clock, and stopping a
// scene discards its pending events — so any ruling inside the last 2.6s of
// a night (or the one that ended it) left this stuck true forever. That is a
// dead door for the rest of the run: no rope, no verdicts, no way inside.
this.busy = false;
this.rulingToken++;
this.frankoTips = 0;
this.kebabPending = false;
this.kebabHandled = false;
this.onKebabRun = false;
this.kebabRunMsLeft = 0;
this.kebabUi = [];
this.kebabFill = null;
// Some nights, mid-slam, Dazza needs a kebab. The shop is right there.
const kebabRng = this.night.rng.stream('kebab');
this.kebabAskAtMin = kebabRng.chance(0.65) ? kebabRng.int(140, 260) : null;

View File

@ -140,6 +140,8 @@ export class QueueManager {
private readonly encounterOf = new Map<string, EncounterId>();
/** patrons queued to re-enter the queue: [clockMin, patron] */
private readonly comebacks: Array<{ atMin: number; patron: Patron }> = [];
/** Twins whose sibling has already been sent for — one brother each. */
private readonly twinsSpawned = new Set<string>();
/** everyone who reached the front tonight, for the summary screen */
readonly seen: Patron[] = [];
@ -183,7 +185,15 @@ export class QueueManager {
// Twin B arrives a few minutes after A's verdict, WHATEVER it was — he
// was parking the car either way. A plain patron, no script: his own
// real name, his brother's face. The photo check is the thing on trial.
if (this.encounterOf.get(patron.id) === 'twins') {
// ...but only on a FINAL ruling, and only once. `door:verdict` also fires
// for 'sobrietyTest' and 'patDown', so a doorperson who tested the twin
// AND patted him down summoned three of his brother.
if (
(verdict === 'admit' || verdict === 'deny') &&
this.encounterOf.get(patron.id) === 'twins' &&
!this.twinsSpawned.has(patron.id)
) {
this.twinsSpawned.add(patron.id);
this.comebacks.push({
atMin: this.clockMin + this.comebackRng.int(2, 5),
patron: twinSibling(patron),
@ -360,9 +370,17 @@ export class QueueManager {
return slot.patron;
}
/** How long this patron stood on the carpet before their recall, in ms. */
/**
* How long this patron stood on the carpet before their recall, in ms
* CONSUMED on read. The dividend is paid for one stand, once: a regular who
* was parked, recalled and knocked back comes round again later through the
* ordinary queue, and without this the door paid them a second entrance bonus
* for a stand that had already been cashed.
*/
carpetMsFor(patronId: string): number {
return this.carpetStand.get(patronId) ?? 0;
const ms = this.carpetStand.get(patronId) ?? 0;
this.carpetStand.delete(patronId);
return ms;
}
/** The Rope. Nothing steps up until the player says so. */

View File

@ -73,7 +73,15 @@ const DISH_SPOT = tileToWorld(17, 3);
/** The sinks (venueMap rect 66..71,15): where a cup of water comes from. */
const WATER_SPOT = tileToWorld(68, 15);
/** The booth (venueMap rect 52..58,19..25): where you hold the decks. */
const DECKS_SPOT = tileToWorld(55, 22);
/**
* Where you stand to work the decks: the booth's WEST COUNTER, not its inside.
* venueMap seals the booth interior (`djbooth`/`djfloor` are not in WALKABLE)
* it was built as a stage back when the DJ was set dressing. Standing the player
* on (55,22) put them inside that seal: every direction blocked, for the rest of
* the night, and the `dj` roster role posts you there automatically. The counter
* is where the map comment always said the gear faces from.
*/
const DECKS_SPOT = tileToWorld(51, 22);
/** Behind the till (venueMap prop 46..47,4): where you hold the taps. */
const TAPS_SPOT = tileToWorld(47, 3);
/**
@ -248,6 +256,12 @@ export class FloorDemoScene extends Phaser.Scene {
this.rack = null;
this.escorting = null;
this.target = null;
// The floor score belongs to the night that ran it. A sweep interrupted by
// the scene stopping would otherwise carry its cash — and its pay-out
// callback, which closes over the PREVIOUS night — into the next one.
this.sweeping = false;
this.sweepCash = 0;
this.sweepDone = null;
this.liveFight = null;
this.uvMode = false;
this.jointDealt.clear();
@ -597,6 +611,11 @@ export class FloorDemoScene extends Phaser.Scene {
this.stall.update(dt);
this.patDown.update(dt);
this.pour.update(dt);
// The aid QTE ticks HERE, with the other modals. It used to be ticked
// after this branch returns — and `overlayOpen` counts the aid overlay,
// so its timer only ran on frames where it was closed. The bar froze at
// full, no step could time out, and every fainter was saved by default.
this.aid.update(dt);
// World keeps breathing behind the panel, but you can't move while you're
// dealing with someone — that's the point of the mechanic.
this.crowd.update(dt, this.clock.clockMin);
@ -630,7 +649,6 @@ export class FloorDemoScene extends Phaser.Scene {
this.trackMaggots();
this.tickHazards(dt);
this.aid.update(dt);
this.tickDecks(dt);
this.tickBar(dt);
@ -1358,8 +1376,22 @@ export class FloorDemoScene extends Phaser.Scene {
? out.aggro ? BAR_RESULTS.waterSnub : BAR_RESULTS.water
: isRsaBreach(stage) ? BAR_RESULTS.cutoff : BAR_RESULTS.cutoffEarly;
this.toast(tabToast ?? toastLine);
// Water and a cut-off are you DEALING with someone, so the floor has to stop
// treating them as outstanding — exactly what the water station does. Without
// this a maggot you watered from behind the bar kept ticking as unhandled and
// re-logging every grace period, punishing the call that fixed the problem.
if (call === 'water' || call === 'cutoff') {
this.maggotSeen.delete(asker.patron.id);
this.cutOffDealt.set(asker.patron.id, drunkStage(asker.patron.intoxication));
}
const pourNote = pourVerdict && pourVerdict !== 'clean' ? ` Pour: ${pourVerdict}.` : '';
this.logIncident('barServe', asker.patron.id, `${call} at ${stage}.${out.breach ? ' RSA breach.' : ''}${pourNote}`);
// One kind per verb. Logging a cut-off as a serve put a drink in the record
// that was never poured — and the incident report is a system about what you
// say happened, so the truth side of it has to be true.
const kind = call === 'serve' ? 'barServe' : call === 'water' ? 'barWater' : 'barCutOff';
this.logIncident(kind, asker.patron.id, `${call} at ${stage}.${out.breach ? ' RSA breach.' : ''}${pourNote}`);
}
/** They're leaving; the jar holds their card; the jar holds three cards. */
@ -1900,7 +1932,13 @@ export class FloorDemoScene extends Phaser.Scene {
this.fainter = null;
this.view.setDowned(null);
const a = this.crowd.agents.find((x) => x.patron.id === f.agentId);
if (a) a.gone = true;
if (a && !a.gone) {
a.gone = true;
// They left in a taxi or an ambulance, but they LEFT — say so, or the
// clicker keeps counting a body that is not in the room and the night
// drifts toward an over-capacity strike nobody can explain.
this.bus.emit('floor:leave', { patronId: a.patron.id });
}
if (success) {
this.bus.emit('meters:delta', { vibe: -3, aggro: -3 });
this.logIncident('firstAid', f.agentId, 'Recovery position. Walked out the back to a taxi.');

View File

@ -300,6 +300,35 @@ export const VENUE: VenueMap = Object.freeze({
stalls: Object.freeze(buildStalls()),
});
/**
* The tiles a role PLANTS the player on. The floor scene teleports the player
* here, so each one must be walkable and must have a way out a post on a
* sealed tile is not a bug you catch in review, it is a night the player spends
* frozen in place. Two shipped that way: the taps handed you back onto a
* `stool`, and the decks stood you inside the sealed `djbooth`.
*/
export const POSTS: Readonly<Record<string, TilePoint>> = Object.freeze({
/** Behind the till: where you hold the taps. */
taps: { tx: 47, ty: 3 },
/** The DJ booth's WEST COUNTER — the gear faces across it, per the map above. */
decks: { tx: 51, ty: 22 },
});
/**
* Interaction anchors the player walks UP TO rather than stands on. These are
* allowed to sit inside furniture (a sink, a counter, a dishwasher) what they
* must have is somewhere walkable within reach to stand while using them.
*/
export const PROBES: Readonly<Record<string, TilePoint>> = Object.freeze({
/** The punter side of the till, where E offers you the apron. */
tapsFront: { tx: 47, ty: 7 },
/** The glassie run: full racks appear here and go to the dishwasher. */
rack: { tx: 51, ty: 7 },
dishwasher: { tx: 17, ty: 3 },
/** The water station (RSA duty) — the sink row itself. */
water: { tx: 68, ty: 15 },
});
export function tileAt(map: VenueMap, tx: number, ty: number): TileKind {
if (tx < 0 || tx >= map.width || ty < 0 || ty >= map.height) return 'void';
return map.tiles[ty * map.width + tx] ?? 'void';

View File

@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import {
POSTS,
PROBES,
VENUE,
isWalkable,
tileAt,
tileToWorld,
type TilePoint,
} from '../../src/scenes/floor/venueMap';
import { freshPlayer, stepPlayer, type PlayerInput } from '../../src/scenes/floor/player';
// Two run-ending softlocks shipped from one mistake: a role stood the player on
// a tile the player cannot stand on. Handing the taps back dropped you on a
// `stool`; the DJ post stood you inside the sealed `djbooth`. Every direction
// blocked, for the rest of the night — and both roster roles mount their post
// automatically, so it was a single keypress from an unplayable shift.
//
// These tests are the guard rail, and they are deliberately about MOVEMENT
// rather than tile names: the question is never "what kind of tile is this", it
// is "can the player actually leave".
const DIRECTIONS: ReadonlyArray<[string, PlayerInput]> = [
['up', { up: true, down: false, left: false, right: false }],
['down', { up: false, down: true, left: false, right: false }],
['left', { up: false, down: false, left: true, right: false }],
['right', { up: false, down: false, left: false, right: true }],
];
/** Distance the player covers from `at` in one second of 60fps frames. */
function travel(at: TilePoint, input: PlayerInput): number {
const world = tileToWorld(at.tx, at.ty);
const start = { ...freshPlayer(world.x, world.y) };
let p = start;
for (let i = 0; i < 60; i++) p = stepPlayer(p, input, 16.67, VENUE);
return Math.hypot(p.x - world.x, p.y - world.y);
}
/** Interaction range in FloorDemoScene. A probe needs somewhere to stand within it. */
const INTERACT_RANGE = 46;
describe('posts — tiles a role stands the player ON', () => {
it('are walkable', () => {
for (const [name, at] of Object.entries(POSTS)) {
expect(
isWalkable(VENUE, at.tx, at.ty),
`post '${name}' is a '${tileAt(VENUE, at.tx, at.ty)}' tile, which is not walkable`,
).toBe(true);
}
});
it('can be walked off — the softlock test', () => {
for (const [name, at] of Object.entries(POSTS)) {
const best = Math.max(...DIRECTIONS.map(([, input]) => travel(at, input)));
expect(best, `post '${name}' traps the player: no direction moves them`).toBeGreaterThan(8);
}
});
});
describe('probes — anchors the player walks UP TO', () => {
it('have somewhere walkable to stand within interact range', () => {
const reach = Math.ceil(INTERACT_RANGE / 16);
for (const [name, at] of Object.entries(PROBES)) {
const world = tileToWorld(at.tx, at.ty);
let found = false;
for (let dx = -reach; dx <= reach && !found; dx++) {
for (let dy = -reach; dy <= reach && !found; dy++) {
const tx = at.tx + dx;
const ty = at.ty + dy;
if (!isWalkable(VENUE, tx, ty)) continue;
const w = tileToWorld(tx, ty);
if (Math.hypot(w.x - world.x, w.y - world.y) <= INTERACT_RANGE) found = true;
}
}
expect(found, `probe '${name}' has no walkable tile within ${INTERACT_RANGE}px — unreachable`).toBe(true);
}
});
});
describe('the tiles those softlocks landed on', () => {
it('a bar stool is still not walkable — the taps must never hand you back onto one', () => {
expect(tileAt(VENUE, 47, 6)).toBe('stool');
expect(isWalkable(VENUE, 47, 6)).toBe(false);
});
it('the DJ booth interior is still sealed — the decks post belongs at its counter', () => {
expect(isWalkable(VENUE, 55, 22)).toBe(false);
expect(isWalkable(VENUE, POSTS.decks!.tx, POSTS.decks!.ty)).toBe(true);
});
});