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:
parent
bcc06c104a
commit
0d3ce80c6b
@ -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;
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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.');
|
||||
|
||||
@ -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';
|
||||
|
||||
90
tests/floor/stations.test.ts
Normal file
90
tests/floor/stations.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user