not-tonight/src/scenes/floor/FloorDemoScene.ts
type-two 0d3ce80c6b 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>
2026-07-22 03:13:09 +10:00

2012 lines
81 KiB
TypeScript

import Phaser from 'phaser';
import { EventBus } from '../../core/EventBus';
import { GameClock } from '../../core/GameClock';
import { SeededRNG, type RngStream } from '../../core/SeededRNG';
import { StubBeatClock } from '../../core/StubBeatClock';
import { Meters, freshNightState } from '../../core/meters';
import { drunkStage } from '../../rules/drunk';
import { generatePatron, _resetPatronSerial } from '../../patrons/generator';
import type { DrunkStage, NightState } from '../../data/types';
import { HudStub } from '../shared/HudStub';
import { VENUE } from './venueMap';
import { NORMAL_CONE, UV_CONE, inCone, type ConeSpec } from './cone';
import { CrowdSim, type Agent } from './crowdSim';
import { FightDirector } from './fightDirector';
import { freshPlayer, stepPlayer, type PlayerInput, type PlayerState } from './player';
import { FloorView } from './FloorView';
import { maggotOverdue } from './escalation';
import { layoutPockets, type Pocket } from './patDown';
import { CutOffOverlay } from './overlays/CutOffOverlay';
import { StallOverlay } from './overlays/StallOverlay';
import { PatDownOverlay } from './overlays/PatDownOverlay';
import { ChoiceOverlay } from './overlays/ChoiceOverlay';
import { AidOverlay } from './overlays/AidOverlay';
import {
FAINT_NIGHT_CHANCE, FAINT_UNATTENDED_MS, FAINT_WINDOW,
MAX_PUDDLES, MAX_VOMITS_PER_NIGHT, MOP_MS,
SLIP_CHANCE_PER_S, SLIP_COOLDOWN_MS, SLIP_RADIUS_PX,
VOMIT_CHANCE_PER_S, watered,
} from './hazards';
import {
BUILD_FIZZLE_BARS, BUILD_HYPE, DROP_PAYOUT, FIZZLE_VIBE,
REQUEST_HIT, REQUEST_LATER_GRUDGE, REQUEST_NAH_AGGRO,
REQUEST_PLAY_FLOP, REQUEST_PLAY_HIT,
TIP_AMOUNTS, TIP_REFUSED_AGGRO, TIP_RETURN_AFTER_MS, TIP_RETURN_CHANCE,
TIP_STING_CHANCE, TIP_STING_VIBE,
judgeDrop, msToNearestBar, rollTip,
} from './djShift';
import {
BREACH_SEEN_AFTER, BREACH_SEEN_CHANCE, ORDER_CHANCE_PER_S, ORDER_COOLDOWN_MS,
RSA_STRIKE_REASON, TAB_DRINK_PRICE, TAB_OPEN_CHANCE,
canOpenTab, cardLineup, isRsaBreach, judgeCall, judgePour, pourAdjust,
type BarCall, type BarOutcome, type Tab,
} from './barShift';
import { PourOverlay } from './overlays/PourOverlay';
import {
ORDER_DONE_VIBE, RADIO_COOLDOWN_MS, SHAN_BOTCH_AGGRO, SHAN_MIN_INTOX, SHAN_WATER_STEP,
orderLag, rollBumpMops, rollShanClean, type FloorOrderKind, type OrderAck,
} from '../../rules/staff';
import { rollDrops, type Drop } from '../../rules/floorScore';
import { getActiveEngine } from '../../audio/TechnoEngine';
import { RACK_RESPAWN_MIN, freshRack, stepRack, turnSharpness, type RackState } from './glassie';
import { MAP_H, MAP_W, isWalkable, tileToWorld } from './venueMap';
import {
AID_UI, BAR_ORDER_LINES, BAR_RESULTS, BAR_TAB_UI, BAR_UI,
DJ_DROP_RESULTS, DJ_REQUESTS, DJ_REQUEST_UI, DJ_TIP_UI, DJ_UI, POUR_UI,
HELP_TEXT, JOINT_RESULTS, JOINT_STING, JOINT_UI, NO_STAMP_RADIO, STAFF_RADIO, SWEEP_UI,
PHONE_POSTED, PHONE_UI, VOMIT_UI, WATER_UI,
} from '../../data/strings/floor';
import { MeterHud } from '../../ui/MeterHud';
import type { NightContext } from '../door/NightScene';
import type { Patron } from '../../data/types';
const VIEW_H = 360;
const INTERACT_RANGE = 46;
const MAX_CROWD = 44;
const SPAWN_EVERY_MS = 420;
/** Admitted at the rope → appears on the floor after the walk in. */
const WALK_IN_MS: [number, number] = [6000, 14000];
/** Where a full rack of empties appears (bar's east end) and where it goes. */
const RACK_SPOT = tileToWorld(51, 7);
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. */
/**
* 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);
/**
* The customer side of the till — where E offers you the apron. Row 7, not row
* 6: row 6 is the `stool` rect, and `stool` is not in venueMap's WALKABLE set.
*/
const TAPS_FRONT = tileToWorld(47, 7);
/** Crowd contact distance while carrying, and its debounce. */
const CARRY_BUMP_PX = 14;
const CARRY_BUMP_COOLDOWN_MS = 350;
const STAGE_RANK: Readonly<Record<DrunkStage, number>> = {
sober: 0, tipsy: 1, loose: 2, messy: 3, maggot: 4,
};
/** What pressing E on the current target would do. */
type InteractKind =
| 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'exit' | 'joint' | 'rack' | 'dishwasher'
| 'puddle' | 'water' | 'filming' | 'fainter' | 'decks' | 'taps';
interface InteractTarget {
kind: InteractKind;
agent?: Agent;
stallId?: string;
prompt: string;
}
/**
* The venue floor. Two modes, one scene class:
* - 'FloorDemo' (standalone): self-populates from the patron generator, owns its
* own bus/clock/meters. The Phase-1 demo, unchanged.
* - 'Floor' (night mode): launched by NightScene with a NightContext. Shares the
* night's bus/clock/state, is populated by ACTUAL door admissions, runs its
* sim all night (even while the player works the rope), and hands the player
* back to the door via the exit.
*/
export class FloorDemoScene extends Phaser.Scene {
private bus!: EventBus;
private rng!: SeededRNG;
private clock!: GameClock;
private beat: StubBeatClock | null = null;
private meters: Meters | null = null;
private night!: NightState;
private hud!: HudStub | MeterHud;
private nightCtx: NightContext | null = null;
/** In night mode: is the player physically on the floor right now? */
private present = true;
private elapsedMs = 0;
private pendingSpawns: Array<{ patron: Patron; atMs: number }> = [];
private ctxOffs: Array<() => void> = [];
private view!: FloorView;
private crowd!: CrowdSim;
private fights!: FightDirector;
private player!: PlayerState;
private cutOff!: CutOffOverlay;
private stall!: StallOverlay;
private patDown!: PatDownOverlay;
private choice!: ChoiceOverlay;
private readonly jointDealt = new Set<string>();
private rack: RackState | null = null;
/** One cup of water, carried from the sinks to somebody who needs it (RSA). */
private carryingCup = false;
/** Active vomit puddles. Unsigned ones slip people; signed ones just shame them. */
private puddles: Array<{ id: string; x: number; y: number; signed: boolean; gfx: Phaser.GameObjects.Container }> = [];
private vomitCount = 0;
private slipCooldown = new Map<string, number>();
private mop: { puddleId: string; msLeft: number } | null = null;
/** The influencer mid-story. `chosen` = the player has already made their call. */
private filming: { agentId: string; msLeft: number; chosen: boolean; glow: Phaser.GameObjects.Image } | null = null;
/** The fainter, pinned where they went down until resolved one way or another. */
private fainter: { agentId: string; x: number; y: number; msLeft: number } | null = null;
private fainterAtMin: number | null = null;
private aid!: AidOverlay;
/** On the decks: movement locked, B/SPACE live, requests arrive. */
private djMode = false;
/** On the taps: movement locked, orders arrive as choices. */
private barMode = false;
/** patronId -> elapsedMs of their last order, so nobody re-orders instantly. */
private barOrderAt = new Map<string, number>();
/** The jar of other people's cards: patronId -> open tab. */
private readonly tabs = new Map<string, Tab>();
private pour!: PourOverlay;
/** Cash taken at the booth tonight. The shoe keeps the count. */
private djTips = 0;
/** NAH'd requesters on their ATM pilgrimage, due back with cash. */
private djReturns: Array<{ scriptId: string; tip: number; dueMs: number }> = [];
/** Radio command: elapsedMs at which each floor order is usable again. */
private readonly staffReadyAt = new Map<FloorOrderKind, number>();
/** The floor score: lights on, crowd gone, the sweep. */
private sweeping = false;
private sweepCash = 0;
private sweepDone: ((score: { cash: number }) => void) | null = null;
private sweepDrops: Array<{ drop: Drop; x: number; y: number; gfx: Phaser.GameObjects.Container }> = [];
private baggiePending: Drop | null = null;
private djBuild: { armedAtMs: number } | null = null;
private lastBeat = { index: 0, atMs: 0 };
private beatMs = 500;
/** Clock minutes at which somebody comes OVER the yard fence (night mode). */
private jumperTimes: number[] = [];
private jumperRng: SeededRNG | null = null;
private rackReadyAtMin = 6;
private rackMarker: Phaser.GameObjects.Image | null = null;
private carryBumpMs = 0;
private prevVx = 0;
private prevVy = 0;
private keys!: Record<string, Phaser.Input.Keyboard.Key>;
private uvMode = false;
private seed = 4207;
private spawnAccMs = 0;
private escorting: Agent | null = null;
private target: InteractTarget | null = null;
/** patronId -> clock minute we first saw them as a maggot (deferred heat risk). */
private maggotSeen = new Map<string, number>();
private spotted = new Set<string>();
private liveFight: { id: string; cause: string } | null = null;
/** Worst drunk stage we've already had words about, per patron. */
private cutOffDealt = new Map<string, DrunkStage>();
private promptText!: Phaser.GameObjects.Text;
private statusText!: Phaser.GameObjects.Text;
private radioText!: Phaser.GameObjects.Text;
private radioMs = 0;
private calmAccMs = 0;
constructor(key: string = 'FloorDemo') {
super(key);
}
create(data?: { night?: NightContext }): void {
this.nightCtx = data?.night ?? null;
this.resetNightState();
// Wired explicitly: Phaser does not invoke a `shutdown` METHOD on a Scene
// subclass; without this, listeners and sprites leak on every scene stop.
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.teardown());
this.cameras.main.setBounds(0, 0, VENUE.width * 16, VENUE.height * 16);
this.cameras.main.setBackgroundColor('#04040a');
// Generated prop art loads FIRST (public/props + manifest — docs/SPACES.md
// §4) so FloorView can prefer 'gen:prop:*' textures at build time. A missing
// or empty manifest just builds with placeholders; nothing waits on art.
this.load.json('propManifest', 'props/manifest.json');
this.load.once(Phaser.Loader.Events.COMPLETE, () => this.loadGenProps());
this.load.start();
}
/**
* Wipe everything that belongs to ONE night.
*
* Phaser reuses the scene instance across restarts, so class-field
* initialisers run exactly once in the lifetime of the game — every field
* below would otherwise carry last night into this one. That is not
* theoretical: a DJ shift left `djMode` set, so the next night's floor booted
* with movement locked and the keyboard eaten; last night's puddles kept
* slipping people through sprites Phaser had already destroyed; and the
* handled-patron sets marked fresh arrivals as already dealt with, because
* patron ids restart with the run.
*/
private resetNightState(): void {
this.djMode = false;
this.djBuild = null;
this.barMode = false;
this.carryingCup = false;
this.mop = null;
// The graphics these referenced went down with the previous scene shutdown;
// dropping the references is the whole job.
this.puddles = [];
this.vomitCount = 0;
this.filming = null;
this.fainter = null;
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();
this.maggotSeen.clear();
this.spotted.clear();
this.cutOffDealt.clear();
this.slipCooldown.clear();
}
private loadGenProps(): void {
const manifest = this.cache.json.get('propManifest') as { props?: string[] } | undefined;
const kinds = manifest?.props ?? [];
const missing = kinds.filter((k) => !this.textures.exists(`gen:prop:${k}`));
if (missing.length === 0) {
this.buildWorld();
return;
}
for (const k of missing) this.load.image(`gen:prop:${k}`, `props/${k}.png`);
this.load.once(Phaser.Loader.Events.COMPLETE, () => this.buildWorld());
this.load.start();
}
private buildWorld(): void {
this.view = new FloorView(this, VENUE);
this.cutOff = new CutOffOverlay(this);
this.patDown = new PatDownOverlay(this, this.nightCtx?.sfx ?? null);
this.choice = new ChoiceOverlay(this);
this.aid = new AidOverlay(this);
this.pour = new PourOverlay(this);
// Some nights have a fainter. Most don't. You find out at the time.
const faintRng = this.nightCtx
? new SeededRNG(this.nightCtx.seed ^ 0xa1d5).stream('fainter')
: new SeededRNG(this.seed ^ 0xa1d5).stream('fainter');
this.fainterAtMin = faintRng.chance(FAINT_NIGHT_CHANCE)
? faintRng.int(FAINT_WINDOW[0], FAINT_WINDOW[1])
: null;
this.rackMarker = this.add
.image(RACK_SPOT.x, RACK_SPOT.y, 'floor:glow')
.setDepth(17)
.setBlendMode(Phaser.BlendModes.ADD)
.setTint(0xd8c23a)
.setAlpha(0.5)
.setScale(0.8)
.setVisible(false);
this.promptText = this.fixedText(4, VIEW_H - 24, '#9fe8a0');
this.statusText = this.fixedText(4, VIEW_H - 12, '#556');
this.radioText = this.fixedText(4, 26, '#d8a020');
this.fixedText(4, 14, '#445').setText(
this.nightCtx?.venue.radioCommand ? `${HELP_TEXT} · ${STAFF_RADIO.hint}` : HELP_TEXT,
);
this.keys = this.input.keyboard!.addKeys(
'W,A,S,D,UP,DOWN,LEFT,RIGHT,E,TAB,SPACE,ESC,R',
) as Record<string, Phaser.Input.Keyboard.Key>;
// Overlays own no keyboard listeners of their own — the scene routes keys so
// exactly one thing consumes input at a time.
this.input.keyboard!.on('keydown', (ev: KeyboardEvent) => this.onKey(ev));
if (this.nightCtx) this.bindNight(this.nightCtx);
else this.resetRun(this.seed);
}
/** Night mode: adopt the night's world instead of building our own. */
private bindNight(ctx: NightContext): void {
this.seed = ctx.seed;
// Radio command (design §6): the door orders floor staff through here.
if (ctx.venue.radioCommand) {
ctx.floorOrder = (kind, notify) => this.execStaffOrder(kind, notify);
}
// The floor score: the night calls this at close, before the summary.
ctx.beginSweep = (done) => this.enterSweep(done);
// The roster put you on a post: walk straight onto it once the world is up.
if (ctx.role.post) {
this.time.delayedCall(600, () => {
if (ctx.role.post === 'taps' && !this.barMode) this.toggleTaps();
else if (ctx.role.post === 'decks' && !this.djMode) this.toggleDecks();
});
}
this.bus = ctx.bus;
this.rng = ctx.rng;
this.clock = ctx.clock;
this.night = ctx.state;
this.beat = null; // beat:tick arrives on the shared bus (stub, then TechnoEngine)
this.meters = null; // NightScene owns the single writer
this.hud = new MeterHud(this, this.bus, { initialHeat: this.night.heatStrikes.length });
this.beatMs = ctx.beatIntervalMs;
this.stall = new StallOverlay(this, this.bus, ctx.beatIntervalMs, ctx.sfx);
this.crowd = new CrowdSim({ map: VENUE, bus: this.bus, rng: this.rng.stream('crowd') });
this.fights?.destroy();
this.fights = new FightDirector({ bus: this.bus, rng: this.rng.stream('fights'), crowd: this.crowd });
const entry = VENUE.anchors.entry[0] ?? { x: 64, y: 64 };
this.player = freshPlayer(entry.x, entry.y);
this.view.reset();
this.escorting = null;
this.target = null;
this.liveFight = null;
this.uvMode = false;
// From STATE, not an assumption: if the player stepped inside during the
// prop-manifest load window, the phaseChange event fired before our
// listener existed and 'false' would stick forever.
this.present = ctx.state.location === 'floor';
this.elapsedMs = 0;
this.pendingSpawns = [];
this.maggotSeen.clear();
this.spotted.clear();
this.cutOffDealt.clear();
this.jointDealt.clear();
this.barMode = false;
this.barOrderAt.clear();
this.staffReadyAt.clear();
this.tabs.clear();
this.djTips = 0;
this.djReturns = [];
this.rack = null;
this.rackReadyAtMin = 6;
// Fence-jumpers (SCENARIOS.md / FLOOR2 §2): two or three a night come over
// the smoking-yard fence — no stamp, no door:verdict, no idea. Their RNG is
// its own instance so the door's streams never feel them.
this.jumperRng = new SeededRNG(ctx.seed ^ 0xfe9ce);
const jr = this.jumperRng.stream('jumpers');
this.jumperTimes = Array.from({ length: jr.int(2, 3) }, () => jr.int(120, 300)).sort((a, b) => a - b);
this.ctxOffs = [
// The floor is fed by the door: every admission walks in a few seconds later.
this.bus.on('door:verdict', ({ patron, verdict }) => {
if (verdict !== 'admit') return;
const delay = this.rng.stream('floorArrive').int(WALK_IN_MS[0], WALK_IN_MS[1]);
this.pendingSpawns.push({ patron, atMs: this.elapsedMs + delay });
}),
this.bus.on('night:phaseChange', ({ location }) => {
this.present = location === 'floor';
if (this.present) this.target = null;
}),
this.bus.on('beat:tick', ({ beatIndex }) => {
this.lastBeat = { index: beatIndex, atMs: this.elapsedMs };
this.crowd.onBeat(beatIndex);
this.view.onBeat(beatIndex);
}),
];
}
private fixedText(x: number, y: number, colour: string): Phaser.GameObjects.Text {
return this.add
.text(x, y, '', { fontFamily: 'monospace', fontSize: '8px', color: colour })
.setScrollFactor(0)
.setDepth(4000);
}
private resetRun(seed: number): void {
this.seed = seed;
_resetPatronSerial();
this.crowd?.destroy();
this.stall?.destroy();
this.bus?.removeAll();
this.meters?.destroy();
this.hud?.destroy();
this.bus = new EventBus();
this.rng = new SeededRNG(seed);
this.clock = new GameClock(this.bus);
this.beat = new StubBeatClock(this.bus);
this.night = freshNightState('voltage', 0, 200);
this.night.location = 'floor';
this.meters = new Meters(this.bus, this.night);
this.hud = new HudStub(this, this.bus);
this.beatMs = this.beat.beatIntervalMs;
this.stall = new StallOverlay(this, this.bus, this.beat.beatIntervalMs);
this.crowd = new CrowdSim({ map: VENUE, bus: this.bus, rng: this.rng.stream('crowd') });
this.fights?.destroy();
this.fights = new FightDirector({ bus: this.bus, rng: this.rng.stream('fights'), crowd: this.crowd });
this.bus.on('beat:tick', ({ beatIndex }) => {
this.lastBeat = { index: beatIndex, atMs: this.elapsedMs };
this.crowd.onBeat(beatIndex);
this.view.onBeat(beatIndex);
});
const entry = VENUE.anchors.entry[0] ?? { x: 64, y: 64 };
this.player = freshPlayer(entry.x, entry.y);
this.view.reset();
this.escorting = null;
this.target = null;
this.liveFight = null;
this.uvMode = false;
this.spawnAccMs = 0;
this.maggotSeen.clear();
this.spotted.clear();
this.cutOffDealt.clear();
this.jointDealt.clear();
this.barMode = false;
this.barOrderAt.clear();
this.staffReadyAt.clear();
this.tabs.clear();
this.djTips = 0;
this.djReturns = [];
this.rack = null;
this.rackReadyAtMin = 6;
this.clock.start();
this.beat.start();
}
// ---- input -------------------------------------------------------------
private onKey(ev: KeyboardEvent): void {
const key = ev.key === ' ' ? 'SPACE' : ev.key.toUpperCase();
if (this.aid.isOpen) return this.aid.handleKey(key);
if (this.pour.isOpen) return this.pour.handleKey(key);
if (this.cutOff.isOpen) return this.cutOff.handleKey(key);
if (this.stall.isOpen) return this.stall.handleKey(key);
if (this.patDown.isOpen) return this.patDown.handleKey(key);
if (this.choice.isOpen) return this.choice.handleKey(key);
if (this.sweeping) {
if (this.baggiePending) {
if (key === 'B') return this.resolveBaggie(false);
if (key === 'SPACE') return this.resolveBaggie(true);
return; // the bag deserves your full attention
}
if (key === 'E') return this.sweepInteract();
return;
}
if (this.djMode) {
if (key === 'B') return this.djStartBuild();
if (key === 'SPACE') return this.djDrop();
if (key === 'E') return this.interact();
return; // the decks eat every other key; the floor can wait
}
if (this.barMode) {
if (key === 'E') return this.interact();
return; // the taps hold both hands; orders arrive on their own
}
if (key === 'TAB') {
ev.preventDefault();
this.uvMode = !this.uvMode;
return;
}
if (key === 'R' && !this.nightCtx) return this.resetRun(this.seed + 1);
// Radio command (design §6): hands-free only — the decks and the taps
// already ate their keys above.
if (this.nightCtx?.venue.radioCommand) {
if (key === '1') return this.radioFloor('mess');
if (key === '2') return this.radioFloor('water');
if (key === '3') return this.radioKayden();
}
if (key === 'E') return this.interact();
}
private get overlayOpen(): boolean {
return this.cutOff.isOpen || this.stall.isOpen || this.patDown.isOpen || this.choice.isOpen || this.aid.isOpen || this.pour.isOpen;
}
private readInput(): PlayerInput {
const k = this.keys;
const down = (a?: Phaser.Input.Keyboard.Key, b?: Phaser.Input.Keyboard.Key) =>
Boolean(a?.isDown || b?.isDown);
return {
up: down(k['W'], k['UP']),
down: down(k['S'], k['DOWN']),
left: down(k['A'], k['LEFT']),
right: down(k['D'], k['RIGHT']),
};
}
// ---- the loop ----------------------------------------------------------
override update(_time: number, deltaMs: number): void {
if (!this.view) return; // world still waiting on the prop manifest
const dt = Math.min(deltaMs, 50);
// Lights on: the sweep replaces the whole night loop. The night is over —
// no clock, no crowd, no meters. Just you, the floor, and what it dropped.
if (this.sweeping) return this.tickSweep(dt);
// These run REGARDLESS of overlays. The music doesn't stop because you
// opened a cubicle door — and the stall minigame judges against beat:tick,
// so freezing the beat clock behind a modal makes it literally unplayable.
// The night clock keeps running too: time is the one thing you never get back.
// (In night mode NightScene drives both; this scene just consumes the bus.)
if (!this.nightCtx) {
this.clock.update(dt);
this.beat?.update(dt);
}
this.elapsedMs += dt;
if (this.nightCtx) {
for (let i = this.pendingSpawns.length - 1; i >= 0; i--) {
const s = this.pendingSpawns[i]!;
if (this.elapsedMs < s.atMs) continue;
this.pendingSpawns.splice(i, 1);
// Licensed capacity is 80 but 44 agents is the sim's perf budget; past
// that, arrivals dissolve into the (conceptual) back room. log()-style
// honesty: this is a cap, not a simulation. Counts LIVE bodies (ejections
// must not permanently shrink the venue).
if (this.liveCrowd < MAX_CROWD) this.crowd.spawn(s.patron);
}
while (this.jumperTimes.length > 0 && this.clock.clockMin >= this.jumperTimes[0]!) {
this.jumperTimes.shift();
if (this.jumperRng && this.liveCrowd < MAX_CROWD) {
const p = generatePatron({ rng: this.jumperRng, nightDate: this.clock.nightDate }, this.clock.clockMin);
p.flags.uvStamped = false;
const a = this.crowd.spawn(p);
const yard = tileToWorld(18, 41);
a.x = yard.x;
a.y = yard.y;
}
}
// Player at the door: the floor keeps living (drinks keep being drunk,
// stalls keep filling, maggots keep ripening → radio pressure), but
// nothing needs the camera, the cone, or input.
if (!this.present) {
this.crowd.update(dt, this.clock.clockMin);
// Fights don't wait for you. An off-map player position means the
// interpose check can never accidentally succeed — unattended fights
// swing, and the incident radios the door.
this.fights.update(-9999, -9999, dt);
this.tickFight();
this.trackMaggots();
this.tickHazards(dt);
this.tickCalm(dt);
return;
}
}
// Fights burn their fuse through overlays too. You can't be in a cubicle and
// between two blokes at once — choosing which one to abandon IS the floor.
// ESC is always the way out of a modal, so it's a decision, not a trap.
this.fights.update(this.player.x, this.player.y, dt);
this.tickFight();
if (this.overlayOpen) {
this.cutOff.update(dt);
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);
this.view.updateLights(dt);
this.view.syncAgents(this.crowd.agents);
return;
}
// Escorting costs you the walk: no interacting, slower going.
const input = this.readInput();
this.player = this.djMode || this.barMode ? this.player : stepPlayer(this.player, input, dt, VENUE);
if (!this.nightCtx) {
// Demo mode stands in for the door with a synthetic spawner.
// CrowdSim deliberately keeps departed agents around so find() still works,
// so the cap has to count LIVE bodies — otherwise every ejection permanently
// shrinks the venue and the floor drains to empty over the night.
this.spawnAccMs += dt;
while (this.spawnAccMs > SPAWN_EVERY_MS && this.liveCrowd < MAX_CROWD) {
this.spawnAccMs -= SPAWN_EVERY_MS;
this.spawnOne();
}
}
if (this.hud instanceof MeterHud) this.hud.update(dt);
this.crowd.update(dt, this.clock.clockMin);
if (this.escorting) this.tickEscort(dt);
this.tickCarry(dt);
this.rackMarker?.setVisible(this.rack === null && this.clock.clockMin >= this.rackReadyAtMin);
this.trackMaggots();
this.tickHazards(dt);
this.tickDecks(dt);
this.tickBar(dt);
// Mopping: feet planted until it's done. The commitment IS the mechanic.
if (this.mop) {
this.mop.msLeft -= dt;
if (this.mop.msLeft <= 0) {
const done = this.mop.puddleId;
const pud = this.puddles.find((q) => q.id === done);
if (pud) {
pud.gfx.destroy();
this.puddles = this.puddles.filter((q) => q.id !== done);
this.bus.emit('meters:delta', { vibe: 1 });
this.logIncident('mopped', undefined, 'Wet floor mopped and signed off.');
this.toast(VOMIT_UI.mopped);
}
this.mop = null;
}
}
this.tickCalm(dt);
this.target = this.escorting ? null : this.findTarget();
this.cameras.main.centerOn(this.player.x, this.player.y);
this.view.updateLights(dt);
this.view.updatePlayer(this.player.x, this.player.y, this.player.facing);
this.view.updateCone(this.coneSpec(), this.uvMode);
this.view.syncAgents(this.crowd.agents);
this.view.setInteractTarget(this.target?.agent ?? null);
this.drawHud(dt);
}
/**
* Aggro physiology (Phase-1 floor request, approved): a calm floor slowly
* cools the night — whether or not the player is watching it. "Calm" means no
* fight brewing and no unhandled maggot; the drift is deliberately slower
* than any single mistake so it reads as breathing, not forgiveness.
* ~0.3 aggro per clock-minute (1 clock-min ≈ 2.17s real at DEFAULT_CLOCK).
*/
private tickCalm(dt: number): void {
if (this.fights.active) {
this.calmAccMs = 0;
return;
}
for (const a of this.crowd.agents) {
if (!a.gone && !a.handled && drunkStage(a.patron.intoxication) === 'maggot') {
this.calmAccMs = 0;
return;
}
}
this.calmAccMs += dt;
if (this.calmAccMs >= 1000) {
this.calmAccMs -= 1000;
this.bus.emit('meters:delta', { aggro: -0.14 });
}
}
/** Have we already dealt with this patron at their current level of drunk? */
private settledAt(agent: Agent, stage: DrunkStage): boolean {
const dealt = this.cutOffDealt.get(agent.patron.id);
return dealt !== undefined && STAGE_RANK[stage] <= STAGE_RANK[dealt];
}
private get liveCrowd(): number {
let n = 0;
for (const a of this.crowd.agents) if (!a.gone) n++;
return n;
}
private coneSpec(): ConeSpec {
const c = this.uvMode ? UV_CONE : NORMAL_CONE;
return { x: this.player.x, y: this.player.y, facing: this.player.facing, ...c };
}
private spawnOne(): void {
const patron = generatePatron(
{ rng: this.rng, nightDate: this.clock.nightDate },
this.clock.clockMin,
);
// Standing in for the door phase: most people got stamped on the way in.
// The ones who didn't snuck past Kayden — that's the UV infraction.
patron.flags.uvStamped = this.rng.stream('floorStamp').chance(0.86);
this.crowd.spawn(patron);
}
// ---- infraction detection ---------------------------------------------
private trackMaggots(): void {
const now = this.clock.clockMin;
for (const a of this.crowd.agents) {
if (a.gone || a.handled) continue;
if (drunkStage(a.patron.intoxication) !== 'maggot') continue;
if (this.settledAt(a, 'maggot')) continue;
const seen = this.maggotSeen.get(a.patron.id);
if (seen === undefined) {
this.maggotSeen.set(a.patron.id, now);
this.spot(a.patron.id, 'drunk');
} else if (maggotOverdue(seen, now)) {
this.maggotSeen.set(a.patron.id, now); // re-arm so it logs once per grace period
this.logIncident('maggotUnhandled', a.patron.id, 'Left on the floor past the grace period.');
}
}
}
private spot(patronId: string, kind: 'drunk' | 'stall' | 'contraband' | 'noStamp'): void {
const key = `${patronId}:${kind}`;
if (this.spotted.has(key)) return;
this.spotted.add(key);
this.bus.emit('floor:infractionSpotted', { patronId, kind });
}
private findTarget(): InteractTarget | null {
const spec = this.coneSpec();
// Hands full: the only thing in the world is the dishwasher. Everything
// else (including the door home) waits until the rack is down.
if (this.rack) {
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, DISH_SPOT.x, DISH_SPOT.y);
if (d <= INTERACT_RANGE) return { kind: 'dishwasher', prompt: 'E — rack into the dishwasher' };
return null;
}
// A full rack waiting at the bar's east end.
if (this.clock.clockMin >= this.rackReadyAtMin) {
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, RACK_SPOT.x, RACK_SPOT.y);
if (d <= INTERACT_RANGE) {
return { kind: 'rack', prompt: 'E — take the rack through to the kitchen' };
}
}
// Night mode: the way back out to the rope is itself an interaction.
if (this.nightCtx) {
const entry = VENUE.anchors.entry[0];
if (entry) {
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, entry.x, entry.y);
if (d <= INTERACT_RANGE) return { kind: 'exit', prompt: 'E — back out to the door' };
}
}
// The fainter outranks everything except a rack already in your hands.
if (this.fainter) {
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, this.fainter.x, this.fainter.y);
if (d <= INTERACT_RANGE) return { kind: 'fainter', prompt: AID_UI.prompt };
}
// Puddles: sign it, then mop it.
for (const pud of this.puddles) {
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, pud.x, pud.y);
if (d <= INTERACT_RANGE) {
return { kind: 'puddle', prompt: pud.signed ? VOMIT_UI.mop : VOMIT_UI.sign };
}
}
// The water station (RSA duty): a cup from the sinks, carried to someone
// who needs it. One cup at a time; the walk is the cost.
if (!this.carryingCup) {
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, WATER_SPOT.x, WATER_SPOT.y);
if (d <= INTERACT_RANGE) return { kind: 'water', prompt: WATER_UI.pickup };
}
// On the decks (or the taps) the only interaction is stepping off them.
if (this.djMode) return { kind: 'decks', prompt: DJ_UI.exit };
if (this.barMode) return { kind: 'taps', prompt: BAR_UI.exit };
// The booth: hold the decks. Blocked while your hands are otherwise full.
if (!this.carryingCup) {
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, DECKS_SPOT.x, DECKS_SPOT.y);
if (d <= INTERACT_RANGE) return { kind: 'decks', prompt: DJ_UI.enter };
}
// The till: hold the taps. Same both-hands rule as the decks.
if (!this.carryingCup && !this.rack) {
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, TAPS_FRONT.x, TAPS_FRONT.y);
if (d <= INTERACT_RANGE) return { kind: 'taps', prompt: BAR_UI.enter };
}
// Stall doors first — you interact with the door, not a person.
for (const s of VENUE.stalls) {
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, s.door.x, s.door.y);
if (d > INTERACT_RANGE) continue;
const occupants = this.crowd.stallOccupancy().get(s.id) ?? [];
if (occupants.length < 2) continue;
// Report a real patron, not the cubicle: `patronId` is a frozen contract
// field and a stall id resolves to nobody. Keying on the occupant also
// means a fresh pair in the same cubicle reports again later in the night.
const first = occupants[0];
if (first) this.spot(first.patron.id, 'stall');
return { kind: 'stall', stallId: s.id, prompt: 'E — two pairs of feet under that door' };
}
let best: Agent | null = null;
let bestDist = INTERACT_RANGE;
for (const a of this.crowd.agents) {
if (a.gone || a.handled || a.activity === 'inStall' || a.activity === 'escorted') continue;
if (!inCone(spec, a.x, a.y)) continue;
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, a.x, a.y);
if (d < bestDist) {
bestDist = d;
best = a;
}
}
if (!best) return null;
// UV mode is a different lens on the same crowd: it only surfaces stamps.
if (this.uvMode) {
if (best.patron.flags.uvStamped) return null;
this.spot(best.patron.id, 'noStamp');
return { kind: 'noStamp', agent: best, prompt: 'E — no stamp. Walk them back to the door' };
}
// The yard bust: a smoker whose rollie is not a rollie (docs/SCENARIOS.md).
if (
best.activity === 'smoking' &&
best.patron.flags.contraband?.includes('joint') &&
!this.jointDealt.has(best.patron.id)
) {
this.spot(best.patron.id, 'contraband');
return { kind: 'joint', agent: best, prompt: 'E — something smells herbal' };
}
// The influencer mid-story: only until the player has made their call.
if (this.filming && !this.filming.chosen && best.patron.id === this.filming.agentId) {
return { kind: 'filming', agent: best, prompt: "E — they're filming the floor" };
}
const stage = drunkStage(best.patron.intoxication);
if (stage === 'messy' || stage === 'maggot' || stage === 'loose') {
// Dealing with someone settles that stage, not the whole night. The Quiet
// Messy One you sensibly watered at 'loose' has to come back around when
// they hit 'maggot' — otherwise a correct early call blinds you to them.
if (!this.settledAt(best, stage)) {
return { kind: 'drunk', agent: best, prompt: this.carryingCup ? WATER_UI.serve : 'E — have a word' };
}
}
if (best.patron.flags.contraband?.length) {
this.spot(best.patron.id, 'contraband');
return { kind: 'contraband', agent: best, prompt: 'E — pat them down' };
}
return null;
}
// ---- interactions ------------------------------------------------------
private interact(): void {
const t = this.target;
if (!t || this.escorting) return;
if (t.kind === 'exit') return this.nightCtx?.requestLocation('door');
if (t.kind === 'rack') return this.takeRack();
if (t.kind === 'dishwasher') return this.deliverRack();
if (t.kind === 'decks') return this.toggleDecks();
if (t.kind === 'taps') return this.toggleTaps();
if (t.kind === 'fainter') return this.openAid();
if (t.kind === 'puddle') return this.interactPuddle();
if (t.kind === 'water') {
this.carryingCup = true;
this.toast(WATER_UI.picked);
return;
}
if (t.kind === 'stall' && t.stallId) return this.openStall(t.stallId);
if (!t.agent) return;
if (t.kind === 'joint') return this.openJointBust(t.agent);
if (t.kind === 'filming') return this.openFilming(t.agent);
if (t.kind === 'noStamp') return this.startEscort(t.agent, 'clean', 'noStamp');
if (t.kind === 'drunk') {
if (this.carryingCup) return this.serveWater(t.agent);
return this.openCutOff(t.agent);
}
if (t.kind === 'contraband') return this.openPatDown(t.agent);
}
/** E near a puddle: routed to the nearest one in range. */
private interactPuddle(): void {
let best: (typeof this.puddles)[number] | null = null;
let bestD = INTERACT_RANGE;
for (const pud of this.puddles) {
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, pud.x, pud.y);
if (d < bestD) {
bestD = d;
best = pud;
}
}
if (best) this.handlePuddle(best);
}
/** Water station duty: the cup lands, the ripen clock forgives a little. */
private serveWater(agent: Agent): void {
this.carryingCup = false;
agent.patron.intoxication = watered(agent.patron.intoxication);
this.maggotSeen.delete(agent.patron.id);
this.cutOffDealt.set(agent.patron.id, drunkStage(agent.patron.intoxication));
this.logIncident('waterServed', agent.patron.id, 'Water station: one cup, taken.');
this.toast(WATER_UI.served);
}
private openAid(): void {
if (!this.fainter || this.aid.isOpen) return;
this.aid.open((success) => this.resolveAid(success));
}
/** The influencer mid-story: three verbs, none of them content-neutral. */
private openFilming(agent: Agent): void {
const f = this.filming;
if (!f || f.agentId !== agent.patron.id) return;
this.choice.open(
{
title: PHONE_UI.title,
line: PHONE_UI.line,
patron: agent.patron,
choices: [
{ id: 'confiscate', label: PHONE_UI.confiscate, detail: PHONE_UI.confiscateDetail },
{ id: 'ask', label: PHONE_UI.ask, detail: PHONE_UI.askDetail },
{ id: 'leave', label: PHONE_UI.leave, detail: PHONE_UI.leaveDetail },
],
},
(id) => {
if (id === null) return; // walked away — the camera keeps rolling
if (id === 'confiscate') {
f.glow.destroy();
this.filming = null;
this.bus.emit('meters:delta', { aggro: 3 });
this.logIncident('phoneConfiscated', agent.patron.id, 'Phone held at the bar til close.');
this.toast(PHONE_UI.confiscated);
} else if (id === 'ask') {
if (this.rng.stream('filming').chance(0.5)) {
f.glow.destroy();
this.filming = null;
this.bus.emit('meters:delta', { vibe: 1 });
this.logIncident('phoneAsked', agent.patron.id, 'Asked nicely. They stopped.');
this.toast(PHONE_UI.askedOk);
} else {
this.toast(PHONE_UI.askedNo);
}
} else {
// Riding it out is a choice with a timer on it; the coin lands when
// the story posts (tickHazards).
f.chosen = true;
this.logIncident('phoneAllowed', agent.patron.id, 'Filming allowed to continue.');
}
},
);
}
// ---- the joint bust (docs/SCENARIOS.md) --------------------------------
private openJointBust(agent: Agent): void {
this.choice.open(
{
title: JOINT_UI.title,
line: JOINT_UI.smokerLine,
patron: agent.patron,
choices: [
{ id: 'bin', label: JOINT_UI.bin, detail: JOINT_UI.binDetail },
{ id: 'pocket', label: JOINT_UI.pocket, detail: JOINT_UI.pocketDetail },
{ id: 'slide', label: JOINT_UI.slide, detail: JOINT_UI.slideDetail },
],
},
(id) => {
if (id === null) return; // walked away; the smell keeps
this.jointDealt.add(agent.patron.id);
const left = (agent.patron.flags.contraband ?? []).filter((c) => c !== 'joint');
if (left.length > 0) agent.patron.flags.contraband = left;
else delete agent.patron.flags.contraband;
if (id === 'bin') {
// By the book. The yard notices the book.
this.bus.emit('meters:delta', { aggro: 1 });
this.logIncident('jointBinned', agent.patron.id, 'Confiscated in the yard, into the amnesty bin.');
this.toast(JOINT_RESULTS.binned);
} else if (id === 'pocket') {
// Nothing happens now. That is the whole point of now.
this.logIncident('jointPocketed', agent.patron.id, 'Confiscated in the yard. Retained by security.');
this.toast(JOINT_RESULTS.pocketed);
if (this.nightCtx && this.rng.stream('joint').chance(0.5)) {
this.nightCtx.scheduleDeferred([{
atClockMin: this.clock.clockMin + 20 + this.rng.stream('joint').int(0, 40),
vibeDelta: -2,
dazzaText: JOINT_STING,
reason: 'word got around about what security is holding',
patronId: agent.patron.id,
}]);
}
} else {
// The fence line really is excellent tonight.
agent.patron.intoxication = Math.min(1, agent.patron.intoxication + 0.08);
this.bus.emit('meters:delta', { vibe: 1 });
this.logIncident('jointIgnored', agent.patron.id, 'A smell near the yard. No action taken.');
this.toast(JOINT_RESULTS.slid);
}
},
);
}
// ---- the glassie run (docs/SCENARIOS.md) -------------------------------
private takeRack(): void {
this.rack = freshRack();
this.rackMarker?.setVisible(false);
this.prevVx = this.player.vx;
this.prevVy = this.player.vy;
}
private deliverRack(): void {
const r = this.rack;
if (!r) return;
this.rack = null;
this.rackReadyAtMin = this.clock.clockMin + RACK_RESPAWN_MIN;
const vibe = Math.min(3, r.glasses * 0.5);
if (vibe > 0) this.bus.emit('meters:delta', { vibe });
this.logIncident('glassieRun', undefined, `${r.glasses}/6 glasses made it to the dishwasher.`);
this.toast(r.glasses === 6 ? 'Full rack, not a chip. The dishwasher accepts your offering.' : `${r.glasses} of 6 survived the crowd.`);
}
/** Carry physics: bumps and hard steering shake the rack; wobble breaks glass. */
private tickCarry(dt: number): void {
const r = this.rack;
if (!r) return;
this.carryBumpMs += dt;
let bumped = false;
if (this.carryBumpMs >= CARRY_BUMP_COOLDOWN_MS) {
for (const a of this.crowd.agents) {
if (a.gone) continue;
if (Phaser.Math.Distance.Between(this.player.x, this.player.y, a.x, a.y) < CARRY_BUMP_PX) {
bumped = true;
this.carryBumpMs = 0;
break;
}
}
}
const sharp = turnSharpness(this.prevVx, this.prevVy, this.player.vx, this.player.vy, 116);
this.prevVx = this.player.vx;
this.prevVy = this.player.vy;
const out = stepRack(r, dt, bumped, sharp);
this.rack = out.rack;
if (out.broke > 0) {
this.nightCtx?.sfx?.play('glassSmash');
this.bus.emit('meters:delta', { vibe: -0.5 });
if (out.rack.glasses <= 0) {
this.rack = null;
this.rackReadyAtMin = this.clock.clockMin + RACK_RESPAWN_MIN;
this.bus.emit('meters:delta', { vibe: -1.5 });
this.logIncident('rackDropped', undefined, 'The whole rack. The whole floor heard it.');
this.toast('The last glass goes. The crowd applauds. Someone yells TAXI.');
}
}
}
private openStall(stallId: string): void {
const occupants = this.crowd.stallOccupancy().get(stallId) ?? [];
this.stall.open(
{ stallId, patrons: occupants.map((a) => a.patron) },
(r) => {
this.bus.emit('meters:delta', { vibe: r.vibeDelta, aggro: r.aggroDelta });
if (r.solved) {
// Re-query: the crowd keeps simulating behind the modal, so whoever was
// in there when you started banging may have wandered off. Scattering
// the captured list would retire innocent patrons standing elsewhere.
const inThere = this.crowd.stallOccupancy().get(stallId) ?? [];
// Zeroing the dwell makes the sim's own stall-exit path fire next
// frame: both get put back at the door and independently re-target,
// which reads exactly like scattering in opposite directions.
for (const a of inThere) {
a.handled = true;
a.dwellMs = 0;
}
this.logIncident('stallBusted', inThere[0]?.patron.id, 'Two out of one cubicle.');
}
},
);
}
private openCutOff(agent: Agent): void {
const stage = drunkStage(agent.patron.intoxication);
this.cutOff.open({ patron: agent.patron, stage }, (r) => {
if (!r) return;
this.bus.emit('meters:delta', { vibe: r.vibeDelta, aggro: r.aggroDelta });
// Only an ejection retires someone for good. A resolved-but-staying patron
// is settled at THIS stage and becomes your problem again if they get worse.
if (r.resolved && !r.eject) this.cutOffDealt.set(agent.patron.id, stage);
if (r.eject) {
this.startEscort(agent, r.outcome === 'clean' ? 'clean' : 'messy', 'drunk');
} else {
this.logIncident('cutOff', agent.patron.id, `${stage}${r.outcome}`);
}
});
}
private openPatDown(agent: Agent): void {
const pockets: Pocket[] = layoutPockets(agent.patron, this.rng.stream('patdown'));
this.patDown.open({ patron: agent.patron, pockets }, (r) => {
this.bus.emit('meters:delta', { vibe: r.vibeDelta, aggro: r.aggroDelta });
agent.handled = true;
this.logIncident('patDown', agent.patron.id, r.summary);
if (r.eject) this.startEscort(agent, 'clean', 'contraband');
});
}
private startEscort(agent: Agent, style: 'clean' | 'messy', kind: InteractKind): void {
if (!this.crowd.beginEscort(agent)) return;
agent.handled = true;
this.escorting = agent;
if (kind === 'noStamp') {
this.radio(NO_STAMP_RADIO[this.rng.stream('radio').int(0, NO_STAMP_RADIO.length - 1)] ?? '');
}
this.bus.emit('floor:ejection', { patronId: agent.patron.id, style });
this.logIncident('ejection', agent.patron.id, `${kind} / ${style}`);
}
/** Draw the brewing-fight tell and narrate the resolution when one lands. */
private tickFight(): void {
const f = this.fights.active;
const pair = this.fights.pair();
this.view.setFight(pair, f?.shoves ?? 0, f?.msLeft ?? 0);
// The director releases both agents the instant it resolves, so pair() and
// cause are already gone by the time we're told about it — remember them
// while the fight is still live.
if (pair) this.liveFight = { id: pair[0].patron.id, cause: this.fights.cause };
const res = this.fights.takeResolution();
if (!res) return;
const was = this.liveFight;
this.liveFight = null;
this.toast(res.line);
this.logIncident('fight', was?.id, `${res.phase === 'broken' ? 'broken up' : 'swung'}${was?.cause ?? ''}`);
}
private tickEscort(dt: number): void {
const a = this.escorting;
if (!a) return;
this.crowd.updateEscorts(this.player.x, this.player.y, dt);
if (a.gone) {
this.escorting = null;
this.bus.emit('meters:delta', { vibe: 1 });
}
}
// ---- chrome ------------------------------------------------------------
// ---- the DJ shift (docs/SCENARIOS.md, design §6) -----------------------
/** E at the booth: take the decks, or hand them back. */
private toggleDecks(): void {
if (!this.djMode) {
this.djMode = true;
this.player.x = DECKS_SPOT.x;
this.player.y = DECKS_SPOT.y;
this.toast(DJ_UI.handover);
this.promptText.setText(DJ_UI.help);
this.logIncident('djShift', undefined, 'Held the decks while the resident stepped out.');
return;
}
// Handing back mid-build: the bass comes home unsupervised.
if (this.djBuild) this.djResolveBuild(null);
this.djMode = false;
if (this.djTips > 0) {
this.toast(DJ_TIP_UI.shoe.replace('{total}', String(this.djTips)));
this.logIncident('djShift', undefined, `Handed the decks back with $${this.djTips} in the shoe.`);
} else {
this.toast(DJ_UI.handback);
}
}
private djStartBuild(): void {
if (this.djBuild || this.overlayOpen) return;
this.djBuild = { armedAtMs: this.elapsedMs };
getActiveEngine()?.setVoiceEnabled('bass', false);
this.bus.emit('meters:delta', { hype: BUILD_HYPE });
this.toast(DJ_UI.buildStart);
}
private djDrop(): void {
if (!this.djBuild || this.overlayOpen) return;
const sinceBeat = this.elapsedMs - this.lastBeat.atMs;
const verdict = judgeDrop(msToNearestBar(this.lastBeat.index % 4, sinceBeat, this.beatMs));
this.djResolveBuild(verdict);
}
/** null = fizzle/abandon; otherwise a judged drop. Either way the bass returns. */
private djResolveBuild(verdict: 'clean' | 'late' | 'clang' | null): void {
this.djBuild = null;
getActiveEngine()?.setVoiceEnabled('bass', true);
if (verdict === null) {
this.bus.emit('meters:delta', { vibe: FIZZLE_VIBE });
this.toast(DJ_UI.fizzle);
return;
}
const pay = DROP_PAYOUT[verdict];
const delta: { vibe?: number; aggro?: number; hype?: number } = {};
if (pay.vibe) delta.vibe = pay.vibe;
if (pay.aggro) delta.aggro = pay.aggro;
if (pay.hype) delta.hype = pay.hype;
this.bus.emit('meters:delta', delta);
this.logIncident('djDrop', undefined, `Drop: ${verdict}.`);
this.toast(DJ_DROP_RESULTS[verdict]);
if (verdict === 'clean') this.nightCtx?.sfx?.play('denyCrowdOoh');
}
// ---- the bartender shift (docs/SCENARIOS.md, design §6) ----------------
/** E at the till: take the taps, or hand them back. */
private toggleTaps(): void {
if (!this.barMode) {
this.barMode = true;
this.player.x = TAPS_SPOT.x;
this.player.y = TAPS_SPOT.y;
this.toast(BAR_UI.handover);
this.promptText.setText(BAR_UI.help);
this.logIncident('barShift', undefined, 'Held the taps while the bartender stepped out.');
return;
}
this.barMode = false;
// Deliberately NO teleport. The punter side of the till is a `stool` tile,
// which is not walkable — dropping the player there stranded them for the
// rest of the night with every direction blocked, and the bartender roster
// role mounts the taps automatically, so one keypress ended the run. Staff
// leave the rail the way staff do: around the east end of the bar.
this.toast(BAR_UI.handback);
}
/** Orders at the taps. Only while the player is behind the bar. */
private tickBar(dt: number): void {
if (!this.barMode || this.overlayOpen) return;
const rq = this.rng.stream('barOrders');
// A tab-holder on their way out wants the card back before anything else.
for (const [pid, tab] of this.tabs) {
const holder = this.crowd.agents.find((a) => a.patron.id === pid);
if (holder && (holder.headingHome === true || holder.gone)) {
this.openCardReturn(holder, tab);
return;
}
}
if (!rq.chance((dt / 1000) * ORDER_CHANCE_PER_S)) return;
// An order needs a body AT the bar — an empty counter pours nothing.
const asker = this.crowd.agents.find(
(a) =>
!a.gone &&
!a.handled &&
a.activity === 'atBar' &&
this.elapsedMs - (this.barOrderAt.get(a.patron.id) ?? -Infinity) > ORDER_COOLDOWN_MS,
);
if (!asker) return;
this.barOrderAt.set(asker.patron.id, this.elapsedMs);
const stage = drunkStage(asker.patron.intoxication);
const lines = BAR_ORDER_LINES[stage];
this.choice.open(
{
title: BAR_UI.title,
line: lines[rq.int(0, lines.length - 1)] ?? lines[0]!,
patron: asker.patron,
choices: [
{ id: 'serve', label: BAR_UI.serve, detail: BAR_UI.serveDetail },
{ id: 'water', label: BAR_UI.water, detail: BAR_UI.waterDetail },
{ id: 'cutoff', label: BAR_UI.cutoff, detail: BAR_UI.cutoffDetail },
],
},
(id) => {
if (id === null) return; // they gave up on the queue. the queue noticed.
const call = id as BarCall;
const out = judgeCall(call, stage);
if (call === 'serve') {
// A serve is a hand, not a menu click: the pour decides the rest.
this.pour.open((fill) => {
if (fill === null) return this.toast(POUR_UI.bailed);
const verdict = judgePour(fill);
this.applyBarCall(asker, stage, call, pourAdjust(out, verdict), rq, verdict);
});
return;
}
this.applyBarCall(asker, stage, call, out, rq, null);
},
);
}
/** Land a priced bar call on the patron, the meters and the record. */
private applyBarCall(
asker: Agent,
stage: DrunkStage,
call: BarCall,
out: BarOutcome,
rq: RngStream,
pourVerdict: 'short' | 'clean' | 'overflow' | null,
): void {
const delta: { vibe?: number; aggro?: number } = {};
if (out.vibe) delta.vibe = out.vibe;
if (out.aggro) delta.aggro = out.aggro;
if (out.vibe || out.aggro) this.bus.emit('meters:delta', delta);
asker.patron.intoxication = Math.max(0, Math.min(1, asker.patron.intoxication + out.intoxDelta));
if (out.sendsHome) {
asker.handled = true;
this.crowd.sendHome(asker);
// If they have a tab, the card-return poll catches them on the way out.
}
if (out.breach && this.nightCtx && rq.chance(BREACH_SEEN_CHANCE)) {
this.nightCtx.scheduleDeferred([{
atClockMin: this.clock.clockMin + BREACH_SEEN_AFTER[0] + rq.int(0, BREACH_SEEN_AFTER[1]),
heatStrike: { reason: RSA_STRIKE_REASON, deferred: true },
dazzaText: BAR_RESULTS.writeUpDazza,
reason: RSA_STRIKE_REASON,
patronId: asker.patron.id,
}]);
}
// The tab: cards land in the jar off a completed serve, loose lips willing.
let tabToast: string | null = null;
if (call === 'serve') {
const existing = this.tabs.get(asker.patron.id);
if (existing) {
existing.drinks++;
} else if (canOpenTab(stage) && rq.chance(TAB_OPEN_CHANCE)) {
this.tabs.set(asker.patron.id, { name: asker.patron.idCard.name, drinks: 1 });
tabToast = BAR_TAB_UI.opened.replace('{name}', asker.patron.idCard.name);
}
}
const toastLine =
call === 'serve'
? pourVerdict === 'short' || pourVerdict === 'overflow'
? POUR_UI[pourVerdict]
: out.breach ? BAR_RESULTS.servedBreach : BAR_RESULTS.served
: call === 'water'
? 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}.` : '';
// 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. */
private openCardReturn(holder: Agent, tab: Tab): void {
const rq = this.rng.stream('tabs');
const others = this.crowd.agents
.filter((a) => a.patron.id !== holder.patron.id)
.map((a) => a.patron.idCard.name);
const lineup = cardLineup(tab.name, others, rq);
this.choice.open(
{
title: BAR_TAB_UI.title,
line: BAR_TAB_UI.lines[rq.int(0, BAR_TAB_UI.lines.length - 1)] ?? BAR_TAB_UI.lines[0]!,
patron: holder.patron,
choices: lineup.map((n) => ({ id: n, label: n.toUpperCase(), detail: 'the jar offers it up' })),
},
(id) => {
this.tabs.delete(holder.patron.id);
if (id === null) {
this.bus.emit('meters:delta', { aggro: 1 });
this.toast(BAR_TAB_UI.unclaimed);
this.logIncident('tabUnclaimed', holder.patron.id, `Left without the card. ${tab.drinks} drinks on it.`);
return;
}
if (id === tab.name) {
this.bus.emit('meters:delta', { vibe: 1 });
this.toast(
BAR_TAB_UI.closed
.replace('{drinks}', String(tab.drinks))
.replace('{total}', String(tab.drinks * TAB_DRINK_PRICE)),
);
this.logIncident('tabClosed', holder.patron.id, `Tab closed clean: ${tab.drinks} drinks, $${tab.drinks * TAB_DRINK_PRICE}.`);
} else {
this.bus.emit('meters:delta', { aggro: 2 });
this.toast(BAR_TAB_UI.wrong);
this.logIncident('tabMisreturned', holder.patron.id, `Handed back the wrong card (${id}).`);
}
},
);
}
// ---- the floor score (lights on at close) ------------------------------
/** The night is over; the lights come on; the floor pays out. */
private enterSweep(done: (score: { cash: number }) => void): void {
this.sweeping = true;
this.sweepDone = done;
this.sweepCash = 0;
this.baggiePending = null;
this.present = true;
this.barMode = false;
this.djMode = false;
this.escorting = null;
this.rack = null;
this.mop = null;
this.uvMode = false;
// Everyone out. The crowd sim keeps the bodies; the lights don't.
for (const a of this.crowd.agents) a.gone = true;
this.view.syncAgents(this.crowd.agents);
this.view.setFight(null, 0, 0);
this.view.setDowned(null);
// The lights: a wash over the whole room. Ugly on purpose — closing
// lights are never flattering.
this.add
.rectangle((MAP_W * 16) / 2, (MAP_H * 16) / 2, MAP_W * 16, MAP_H * 16, 0xfff2d8, 0.16)
.setDepth(38);
// What the night dropped, scattered anywhere feet could reach.
const rs = this.rng.stream('sweep');
const drops = rollDrops(rs, this.crowd.agents.length);
this.sweepDrops = drops.map((drop) => {
let tx = 40;
let ty = 22;
for (let tries = 0; tries < 60; tries++) {
tx = rs.int(2, MAP_W - 3);
ty = rs.int(2, MAP_H - 3);
if (isWalkable(VENUE, tx, ty)) break;
}
const { x, y } = tileToWorld(tx, ty);
const gfx = this.add.container(x, y).setDepth(39);
gfx.add(this.add.circle(0, 0, 4, 0x000000, 0.25).setScale(1, 0.5));
gfx.add(this.add.circle(0, -2, 3, drop.kind === 'baggie' ? 0xa0e0a0 : 0xffe080));
this.tweens.add({ targets: gfx, scale: { from: 1, to: 1.25 }, duration: 600, yoyo: true, repeat: -1 });
return { drop, x, y, gfx };
});
this.toast(SWEEP_UI.open);
this.promptText.setText(SWEEP_UI.open);
this.logIncident('lightsOn', undefined, `Close. ${drops.length} things on the floor.`);
}
private tickSweep(dt: number): void {
if (!this.baggiePending) {
const input = this.readInput();
this.player = stepPlayer(this.player, input, dt, VENUE);
}
this.cameras.main.centerOn(this.player.x, this.player.y);
this.view.updateLights(dt);
this.view.updatePlayer(this.player.x, this.player.y, this.player.facing);
// Prompt: the bag first, then the nearest find, then the way out.
if (this.baggiePending) {
this.promptText.setText(SWEEP_UI.baggiePrompt);
return;
}
const near = this.nearestDrop();
if (near) {
this.promptText.setText(`${SWEEP_UI.pickup}${near.drop.label}`);
return;
}
const entry = VENUE.anchors.entry[0];
const atExit =
entry && Phaser.Math.Distance.Between(this.player.x, this.player.y, entry.x, entry.y) <= INTERACT_RANGE;
this.promptText.setText(atExit ? SWEEP_UI.leave : this.sweepDrops.length === 0 ? SWEEP_UI.lastOne : '');
}
private nearestDrop(): (typeof this.sweepDrops)[number] | null {
let best: (typeof this.sweepDrops)[number] | null = null;
let bestD = INTERACT_RANGE;
for (const d of this.sweepDrops) {
const dist = Phaser.Math.Distance.Between(this.player.x, this.player.y, d.x, d.y);
if (dist < bestD) {
bestD = dist;
best = d;
}
}
return best;
}
private sweepInteract(): void {
const near = this.nearestDrop();
if (near) return this.collectDrop(near);
const entry = VENUE.anchors.entry[0];
if (entry && Phaser.Math.Distance.Between(this.player.x, this.player.y, entry.x, entry.y) <= INTERACT_RANGE) {
this.finishSweep();
}
}
private collectDrop(d: (typeof this.sweepDrops)[number]): void {
d.gfx.destroy();
this.sweepDrops = this.sweepDrops.filter((x) => x !== d);
const drop = d.drop;
if (drop.kind === 'baggie') {
this.baggiePending = drop;
this.toast(drop.label);
return;
}
if (drop.kind === 'phone') {
this.logIncident('lostProperty', undefined, 'A phone off the floor, into lost property.');
this.toast(SWEEP_UI.phoneFound);
} else {
this.sweepCash += drop.value;
this.toast(drop.label);
this.nightCtx?.sfx?.play('clickerClunk');
}
if (this.sweepDrops.length === 0) this.time.delayedCall(1200, () => this.toast(SWEEP_UI.lastOne));
}
private resolveBaggie(pocket: boolean): void {
this.baggiePending = null;
if (pocket) {
this.logIncident('baggiePocketed', undefined, 'Contraband off the floor. Retained by security.');
this.toast(SWEEP_UI.baggiePocketed);
} else {
this.logIncident('baggieBinned', undefined, 'Contraband off the floor, into the amnesty bin.');
this.toast(SWEEP_UI.baggieBinned);
}
}
private finishSweep(): void {
if (!this.sweeping) return;
this.sweeping = false;
if (this.sweepCash > 0) {
this.logIncident('floorScore', undefined, `Swept the floor: $${this.sweepCash} found.`);
}
this.toast(SWEEP_UI.done.replace('{cash}', String(this.sweepCash)));
const cb = this.sweepDone;
this.sweepDone = null;
this.time.delayedCall(900, () => cb?.({ cash: this.sweepCash }));
}
// ---- radio command (design §6, Head of Security) -----------------------
/** Floor-side radio keys: ack + result toasts land here. */
private radioFloor(kind: FloorOrderKind): void {
const ack = this.execStaffOrder(kind, (msg) => {
if (this.present) this.toast(msg);
});
if (ack === 'sent') this.toast(kind === 'mess' ? STAFF_RADIO.bumpAck : STAFF_RADIO.shanAck);
else if (ack === 'cooldown') this.toast(STAFF_RADIO.cooldown);
else this.toast(kind === 'mess' ? STAFF_RADIO.bumpNothing : STAFF_RADIO.shanNothing);
}
private radioKayden(): void {
const ack = this.nightCtx?.kaydenHold?.();
if (ack === 'sent') this.toast(STAFF_RADIO.kaydenAck);
else if (ack === 'cooldown') this.toast(STAFF_RADIO.kaydenCooldown);
}
/**
* An order goes out over the radio; a staffer crosses a real floor to do it.
* `notify` carries the completion line back to whichever scene asked.
*/
private execStaffOrder(kind: FloorOrderKind, notify: (msg: string) => void): OrderAck {
if (this.elapsedMs < (this.staffReadyAt.get(kind) ?? 0)) return 'cooldown';
const rs = this.rng.stream('staff');
if (kind === 'mess') {
const pud = this.puddles[0];
if (!pud) return 'nothing';
this.staffReadyAt.set(kind, this.elapsedMs + RADIO_COOLDOWN_MS.mess);
const targetId = pud.id;
this.time.delayedCall(orderLag(rs), () => {
const p = this.puddles.find((q) => q.id === targetId);
if (!p) return; // the player got there first; bump keeps the credit anyway
if (rollBumpMops(rs)) {
p.gfx.destroy();
this.puddles = this.puddles.filter((q) => q.id !== targetId);
this.bus.emit('meters:delta', { vibe: ORDER_DONE_VIBE });
this.logIncident('radioOrder', undefined, 'Bump: puddle mopped and signed, in that order.');
notify(STAFF_RADIO.bumpDone);
} else if (!p.signed) {
p.signed = true;
if (this.textures.exists('gen:prop:wetFloor')) {
p.gfx.add(this.add.image(0, -8, 'gen:prop:wetFloor').setDisplaySize(14, 18));
} else {
p.gfx.add(this.add.triangle(0, -8, 0, 12, 6, 0, 12, 12, 0xd8c23a, 0.9));
}
this.logIncident('radioOrder', undefined, 'Bump: signed the puddle, then got distracted.');
notify(STAFF_RADIO.bumpDistracted);
} else {
notify(STAFF_RADIO.bumpDistracted);
}
});
return 'sent';
}
// Shan: the worst upright drunk gets a firm two cups.
let worst: Agent | null = null;
for (const a of this.crowd.agents) {
if (a.gone || a.handled || a.activity === 'escorted' || a.activity === 'inStall') continue;
if (a.patron.intoxication < SHAN_MIN_INTOX) continue;
if (!worst || a.patron.intoxication > worst.patron.intoxication) worst = a;
}
if (!worst) return 'nothing';
this.staffReadyAt.set(kind, this.elapsedMs + RADIO_COOLDOWN_MS.water);
const target = worst;
this.time.delayedCall(orderLag(rs), () => {
if (target.gone || target.handled) return;
if (rollShanClean(rs)) {
target.patron.intoxication = Math.max(0, target.patron.intoxication - SHAN_WATER_STEP);
this.maggotSeen.delete(target.patron.id);
this.cutOffDealt.set(target.patron.id, drunkStage(target.patron.intoxication));
this.bus.emit('meters:delta', { vibe: ORDER_DONE_VIBE });
this.logIncident('radioOrder', target.patron.id, 'Shan: water in, no negotiation.');
notify(STAFF_RADIO.shanDone);
} else {
this.bus.emit('meters:delta', { aggro: SHAN_BOTCH_AGGRO });
this.logIncident('radioOrder', target.patron.id, 'Shan led with "you\'ve had enough". Scenes.');
notify(STAFF_RADIO.shanBotch);
}
});
return 'sent';
}
/** Build fizzle + booth requests. Only while the player is on the decks. */
private tickDecks(dt: number): void {
if (!this.djMode) return;
if (this.djBuild && this.elapsedMs - this.djBuild.armedAtMs > BUILD_FIZZLE_BARS * 4 * this.beatMs) {
this.djResolveBuild(null);
}
if (this.overlayOpen) return;
const rq = this.rng.stream('djRequests');
// ATM returns jump the queue: they NAH'd a free ask and went to get cash.
let script: (typeof DJ_REQUESTS)[number] | null = null;
let tip: number | null = null;
let returned = false;
const dueIdx = this.djReturns.findIndex((r) => this.elapsedMs >= r.dueMs);
if (dueIdx >= 0) {
const due = this.djReturns.splice(dueIdx, 1)[0]!;
script = DJ_REQUESTS.find((s) => s.id === due.scriptId) ?? null;
tip = due.tip;
returned = true;
}
if (!script) {
if (!rq.chance((dt / 1000) * 0.012)) return;
script = DJ_REQUESTS[rq.int(0, DJ_REQUESTS.length - 1)]!;
tip = rollTip(rq);
returned = false;
}
// The overlay shows the asker's doll, so a request needs an actual body —
// an empty floor sends nobody to the booth glass.
const asker =
this.crowd.agents.find((a) => !a.gone && a.activity === 'dancing') ??
this.crowd.agents.find((a) => !a.gone);
if (!asker) return;
const s = script;
if (tip !== null) {
// Cash on the glass. Cash never helps the song land — it buys the yes.
const amount = tip;
const playIt = (took: boolean): void => {
const hit = rq.chance(REQUEST_HIT[s.id] ?? 0.5);
this.bus.emit('meters:delta', hit ? { ...REQUEST_PLAY_HIT } : { ...REQUEST_PLAY_FLOP });
this.toast(hit ? s.hit : s.flop);
if (!took) {
this.logIncident('djRequest', asker.patron.id, `Waved off $${amount}, played the ${s.id} request anyway. ${hit ? 'It landed.' : 'It did not.'}`);
return;
}
this.djTips += amount;
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([{
atClockMin: this.clock.clockMin + 10 + rq.int(0, 25),
vibeDelta: TIP_STING_VIBE,
dazzaText: DJ_TIP_UI.sting,
reason: 'word got around that the booth takes cash',
patronId: asker.patron.id,
}]);
}
};
this.choice.open(
{
title: DJ_TIP_UI.titleTipped.replace('{tip}', String(amount)),
line: returned ? `${DJ_TIP_UI.returnLinePrefix}${s.line}${DJ_TIP_UI.returnLineSuffix}` : s.line,
patron: asker.patron,
choices: [
{ id: 'take', label: DJ_TIP_UI.take, detail: DJ_TIP_UI.takeDetail },
{ id: 'free', label: DJ_TIP_UI.free, detail: DJ_TIP_UI.freeDetail },
{ id: 'nah', label: DJ_TIP_UI.nah, detail: DJ_TIP_UI.nahDetail },
],
},
(id) => {
if (id === null) return; // the note and the request both time out
if (id === 'take') return playIt(true);
if (id === 'free') return playIt(false);
this.bus.emit('meters:delta', { aggro: TIP_REFUSED_AGGRO });
this.logIncident('djRequest', asker.patron.id, `Refused the ${s.id} request AND the $${amount}.`);
this.toast(DJ_TIP_UI.refused);
},
);
return;
}
this.choice.open(
{
title: DJ_REQUEST_UI.title,
line: s.line,
patron: asker.patron,
choices: [
{ id: 'play', label: DJ_REQUEST_UI.play, detail: DJ_REQUEST_UI.playDetail },
{ id: 'nah', label: DJ_REQUEST_UI.nah, detail: DJ_REQUEST_UI.nahDetail },
{ id: 'later', label: DJ_REQUEST_UI.later, detail: DJ_REQUEST_UI.laterDetail },
],
},
(id) => {
if (id === null) return; // you stared at the crossfader until they left
if (id === 'play') {
const hit = rq.chance(REQUEST_HIT[s.id] ?? 0.5);
this.bus.emit('meters:delta', hit ? { ...REQUEST_PLAY_HIT } : { ...REQUEST_PLAY_FLOP });
this.logIncident('djRequest', asker.patron.id, `Played the ${s.id} request. ${hit ? 'It landed.' : 'It did not.'}`);
this.toast(hit ? s.hit : s.flop);
} else if (id === 'nah') {
this.bus.emit('meters:delta', { aggro: REQUEST_NAH_AGGRO });
this.logIncident('djRequest', asker.patron.id, `Declined the ${s.id} request.`);
this.toast(s.nah);
// The booth taught them the price. Some go find an ATM.
if (rq.chance(TIP_RETURN_CHANCE)) {
this.djReturns.push({
scriptId: s.id,
tip: TIP_AMOUNTS[rq.int(0, TIP_AMOUNTS.length - 1)] ?? 20,
dueMs: this.elapsedMs + TIP_RETURN_AFTER_MS[0] + rq.int(0, TIP_RETURN_AFTER_MS[1] - TIP_RETURN_AFTER_MS[0]),
});
}
} else {
if (this.nightCtx && rq.chance(REQUEST_LATER_GRUDGE)) {
this.nightCtx.scheduleDeferred([{
atClockMin: this.clock.clockMin + 8 + rq.int(0, 12),
aggroDelta: 2,
reason: 'a deferred booth request came back with interest',
patronId: asker.patron.id,
}]);
}
this.toast(DJ_REQUEST_UI.laterQuiet);
}
},
);
}
/**
* Everything on the floor that happens TO the room rather than through the
* player: vomit + slips, the filming influencer's timer, the fainter. Runs
* whether the player is present or not — the floor does not wait.
*/
private tickHazards(dt: number): void {
const hz = this.rng.stream('hazards');
// Vomit: a messy/maggot patron redecorates. Rare per-second, common per-night.
if (this.puddles.length < MAX_PUDDLES && this.vomitCount < MAX_VOMITS_PER_NIGHT) {
for (const a of this.crowd.agents) {
if (a.gone || a.activity === 'inStall' || a.activity === 'escorted') continue;
const stage = drunkStage(a.patron.intoxication);
if (stage !== 'messy' && stage !== 'maggot') continue;
if (!hz.chance((dt / 1000) * VOMIT_CHANCE_PER_S)) continue;
this.spawnPuddle(a.x, a.y);
break;
}
}
// Slips: unsigned puddles claim ankles.
for (const p of this.puddles) {
if (p.signed) continue;
for (const a of this.crowd.agents) {
if (a.gone || a.activity === 'inStall') continue;
if (Phaser.Math.Distance.Between(a.x, a.y, p.x, p.y) > SLIP_RADIUS_PX) continue;
const cool = this.slipCooldown.get(a.patron.id) ?? -Infinity;
if (this.elapsedMs - cool < SLIP_COOLDOWN_MS) continue;
if (!hz.chance((dt / 1000) * SLIP_CHANCE_PER_S)) continue;
this.slipCooldown.set(a.patron.id, this.elapsedMs);
this.bus.emit('meters:delta', { vibe: -1, aggro: 1 });
this.logIncident('slip', a.patron.id, 'Went over near the puddle. Unsigned at the time.');
if (this.present) this.toast(VOMIT_UI.slip);
this.nightCtx?.sfx?.play('doorBang');
}
// Carrying the rack past an unsigned puddle is exactly as bad an idea
// as it sounds.
if (this.rack && Phaser.Math.Distance.Between(this.player.x, this.player.y, p.x, p.y) <= SLIP_RADIUS_PX) {
this.rack.wobble = Math.min(1.2, this.rack.wobble + (dt / 1000) * 0.9);
}
}
// The influencer's story.
if (!this.filming) {
for (const a of this.crowd.agents) {
if (a.gone || a.patron.archetype !== 'influencer' || a.activity !== 'dancing') continue;
if (!hz.chance((dt / 1000) * 0.003)) continue;
const glow = this.add
.image(a.x, a.y, 'floor:glow')
.setDepth(18)
.setBlendMode(Phaser.BlendModes.ADD)
.setTint(0xe8f0ff)
.setAlpha(0.55)
.setScale(0.6);
this.filming = { agentId: a.patron.id, msLeft: 75_000, chosen: false, glow };
if (this.present) this.toast('someone is filming the floor');
break;
}
} else {
const f = this.filming;
const a = this.crowd.agents.find((x) => x.patron.id === f.agentId && !x.gone);
if (!a) {
f.glow.destroy();
this.filming = null;
} else {
f.glow.setPosition(a.x, a.y - 6);
f.msLeft -= dt;
if (f.msLeft <= 0) {
// The story goes up whether you made a call or not. Not making a
// call WAS a call.
const good = hz.chance(0.5);
this.bus.emit('meters:delta', good ? { vibe: 3 } : { vibe: -3, aggro: 2 });
this.logIncident(
'phonePosted',
f.agentId,
good ? 'Story posted. The room photographs well.' : 'Story posted. It caught the wrong five seconds.',
);
this.nightCtx?.bus.emit('dazza:text', { text: good ? PHONE_POSTED.good : PHONE_POSTED.bad });
f.glow.destroy();
this.filming = null;
}
}
}
// The fainter.
if (this.fainterAtMin !== null && !this.fainter && this.clock.clockMin >= this.fainterAtMin) {
const pick = this.crowd.agents.filter(
(a) => !a.gone && a.activity !== 'inStall' && a.activity !== 'escorted' && a.activity !== 'squaringUp',
);
if (pick.length > 0) {
const a = pick[hz.int(0, pick.length - 1)]!;
this.fainter = { agentId: a.patron.id, x: a.x, y: a.y, msLeft: FAINT_UNATTENDED_MS };
this.fainterAtMin = null;
this.view.setDowned(a.patron.id);
this.radio(AID_UI.radio);
this.logIncident('fainter', a.patron.id, 'Person down on the floor.');
}
}
if (this.fainter && !this.aid.isOpen) {
const f = this.fainter;
const a = this.crowd.agents.find((x) => x.patron.id === f.agentId && !x.gone);
if (a) {
// Pinned where they fell: the crowd sim does not get them back until
// this is over.
a.x = f.x;
a.y = f.y;
a.vx = 0;
a.vy = 0;
a.dwellMs = 5000;
a.target = { x: f.x, y: f.y };
}
f.msLeft -= dt;
if (f.msLeft <= 0) this.resolveAid(false, true);
}
}
private spawnPuddle(x: number, y: number): void {
this.vomitCount++;
const id = `pud${this.vomitCount}`;
const gfx = this.add.container(x, y).setDepth(6);
gfx.add(this.add.ellipse(0, 0, 26, 16, 0x5a6a2a, 0.65));
this.puddles.push({ id, x, y, signed: false, gfx });
this.radio(VOMIT_UI.radio);
this.logIncident('vomit', undefined, 'The dance floor has been redecorated.');
if (this.present) this.toast(VOMIT_UI.splash);
}
private handlePuddle(p: { id: string; signed: boolean; gfx: Phaser.GameObjects.Container }): void {
if (!p.signed) {
p.signed = true;
if (this.textures.exists('gen:prop:wetFloor')) {
p.gfx.add(this.add.image(0, -8, 'gen:prop:wetFloor').setDisplaySize(14, 18));
} else {
p.gfx.add(this.add.triangle(0, -8, 0, 12, 6, 0, 12, 12, 0xd8c23a, 0.9));
}
this.logIncident('wetFloorSign', undefined, 'Sign deployed over the hazard.');
this.toast(VOMIT_UI.signed);
return;
}
this.mop = { puddleId: p.id, msLeft: MOP_MS };
}
/** The fainter is resolved: recovery position done right, or the ambulance. */
private resolveAid(success: boolean, unattended = false): void {
const f = this.fainter;
if (!f) return;
this.fainter = null;
this.view.setDowned(null);
const a = this.crowd.agents.find((x) => x.patron.id === f.agentId);
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.');
this.toast(AID_UI.ok);
} else {
this.bus.emit('meters:delta', { vibe: -8, aggro: 4 });
this.logIncident(
'ambulance',
f.agentId,
unattended ? 'Ambulance called. Nobody had got to them.' : 'Ambulance called.',
);
this.toast(AID_UI.ambulance);
}
}
private radio(line: string): void {
this.toast(line ? `KAYDEN: ${line}` : '');
}
/** Transient line in the corner — radio chatter, fight outcomes, anything shouted. */
private toast(line: string): void {
this.radioText.setText(line);
this.radioMs = 5000;
}
private logIncident(kind: string, patronId: string | undefined, detail: string): void {
this.bus.emit('incident:log', { clockMin: this.clock.clockMin, kind, patronId, detail });
}
private drawHud(dt: number): void {
if (this.radioMs > 0) {
this.radioMs -= dt;
if (this.radioMs <= 0) this.radioText.setText('');
}
// A brewing fight outranks everything — it's the one thing on a clock.
const carryLine = this.rack
? `RACK ${'▮'.repeat(this.rack.glasses)}${'▯'.repeat(6 - this.rack.glasses)} wobble ${'!'.repeat(Math.min(4, Math.ceil(this.rack.wobble * 4)))}`
: null;
this.promptText.setText(
this.fights.active
? 'GET BETWEEN THEM'
: this.escorting
? 'escorting — walk them to the door (no interacting)'
: carryLine && !this.target
? carryLine
: this.target?.prompt ?? '',
);
const maggots = this.crowd.agents.filter(
(a) => !a.gone && !a.handled && drunkStage(a.patron.intoxication) === 'maggot',
).length;
this.statusText.setText(
`${this.clock.label} seed ${this.seed} crowd ${this.liveCrowd} maggots ${maggots} ${this.uvMode ? 'UV' : 'torch'} incidents ${this.night.incidents.length}`,
);
}
private teardown(): void {
for (const off of this.ctxOffs) off();
this.ctxOffs = [];
this.fights?.destroy();
this.crowd?.destroy();
this.view?.destroy();
this.cutOff?.destroy();
this.stall?.destroy();
this.patDown?.destroy();
this.choice?.destroy();
// Night mode: the bus/meters belong to NightScene — touch nothing shared.
this.meters?.destroy();
this.hud?.destroy();
}
}