diff --git a/src/scenes/door/DoorScene.ts b/src/scenes/door/DoorScene.ts index 61fc346..a18625b 100644 --- a/src/scenes/door/DoorScene.ts +++ b/src/scenes/door/DoorScene.ts @@ -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; diff --git a/src/scenes/door/QueueManager.ts b/src/scenes/door/QueueManager.ts index 59481a9..2b5759e 100644 --- a/src/scenes/door/QueueManager.ts +++ b/src/scenes/door/QueueManager.ts @@ -140,6 +140,8 @@ export class QueueManager { private readonly encounterOf = new Map(); /** 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(); /** 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. */ diff --git a/src/scenes/floor/FloorDemoScene.ts b/src/scenes/floor/FloorDemoScene.ts index 9bca127..2fd9643 100644 --- a/src/scenes/floor/FloorDemoScene.ts +++ b/src/scenes/floor/FloorDemoScene.ts @@ -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.'); diff --git a/src/scenes/floor/venueMap.ts b/src/scenes/floor/venueMap.ts index 51ef7bf..e4299d4 100644 --- a/src/scenes/floor/venueMap.ts +++ b/src/scenes/floor/venueMap.ts @@ -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> = 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> = 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'; diff --git a/tests/floor/stations.test.ts b/tests/floor/stations.test.ts new file mode 100644 index 0000000..24948c2 --- /dev/null +++ b/tests/floor/stations.test.ts @@ -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); + }); +});