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> = { 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(); 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(); 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(); /** The jar of other people's cards: patronId -> open tab. */ private readonly tabs = new Map(); 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(); /** 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; 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(); private spotted = new Set(); private liveFight: { id: string; cause: string } | null = null; /** Worst drunk stage we've already had words about, per patron. */ private cutOffDealt = new Map(); 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; // 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(); } }