- 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>
2012 lines
81 KiB
TypeScript
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();
|
|
}
|
|
}
|