not-tonight/src/scenes/door/NightScene.ts
2026-07-20 15:53:52 +10:00

513 lines
20 KiB
TypeScript

import Phaser from 'phaser';
import { EventBus } from '../../core/EventBus';
import { GameClock, DEFAULT_CLOCK, type ClockConfig } from '../../core/GameClock';
import { SeededRNG } from '../../core/SeededRNG';
import { StubBeatClock } from '../../core/StubBeatClock';
import { Meters, freshNightState, vibeCoolingPerMin } from '../../core/meters';
import { loadGame, saveGame, clearSave, freshGameState } from '../../core/save';
import { _resetPatronSerial } from '../../patrons/generator';
import { ruleById } from '../../rules/dressCode';
import { TechnoEngine } from '../../audio/TechnoEngine';
import { Ambience } from '../../audio/Ambience';
import { Sfx } from '../../audio/Sfx';
import type { DeferredHit } from '../../rules/doorTypes';
import type { GameState, HeatStrike, NightState, Patron, Verdict, VerdictOutcome } from '../../data/types';
import { nextDazza } from './dazzaSchedule';
import { TOTAL_VENUES, nightDateFor, venueByIndex, type VenueDef } from '../../data/venues';
import { observe, startWatch, type InspectorWatch, type VenueBreaches } from '../../rules/inspector';
// Phase-2: the night-flow machine. Door and Floor both run for the whole night;
// this scene owns WHERE THE PLAYER IS (visibility + input), the clock, the
// meters, the audio engine (and the location filter that goes with it), Kayden's
// stamp accuracy, the radio, and the run-level save. It still renders nothing.
/** The Royal's licence — kept for tests/back-compat; live nights use the venue def. */
export const LICENSED_CAPACITY = 80;
const VIBE_SAMPLE_EVERY_MIN = 5;
import { MAX_HEAT_STRIKES, shouldRecordStrike } from '../../rules/heat';
export { MAX_HEAT_STRIKES, shouldRecordStrike };
/** Thu → Fri → Sat at The Royal. Index = nightIndex. */
export const NIGHT_DATES = ['2026-07-16', '2026-07-17', '2026-07-18'] as const;
export const TOTAL_NIGHTS = NIGHT_DATES.length;
/** Kayden stamps most hands. Most. */
export const KAYDEN_STAMP_CHANCE = 0.86;
/** Scripted "get in here" pulls: the floor demands a lap around these minutes. */
const RADIO_PULL_MINS = [120, 250] as const;
/** Dynamic maggot-radio rate limit (clock minutes). */
const RADIO_MAGGOT_COOLDOWN = 15;
export type NightEndReason = 'clock' | 'vibe' | 'aggro' | 'licence';
export interface VerdictRecord {
patron: Patron;
verdict: Verdict;
outcome: VerdictOutcome;
clockMin: number;
}
export interface NightLog {
vibeHistory: number[];
verdicts: VerdictRecord[];
admitted: number;
denied: number;
tested: number;
pattedDown: number;
hypePeak: number;
heatStrikes: HeatStrike[];
endReason: NightEndReason;
seed: number;
}
/** Run-level result attached to the summary. */
export interface RunInfo {
/** The run save itself, so the report form can file into it and persist. */
game: GameState;
nightIndex: number;
totalNights: number;
venue: VenueDef;
/** the week is done and a HARDER venue wants you (venue ladder, design §6) */
promotedTo: VenueDef | null;
/** licence pulled — the run is dead, only a fresh one remains */
runOver: boolean;
/** survived all three nights */
weekDone: boolean;
/** survived every week at every venue — the season is complete */
seasonDone: boolean;
}
/** Everything the door and floor need from the night. Passed by reference. */
export interface NightContext {
bus: EventBus;
rng: SeededRNG;
clock: GameClock;
state: NightState;
seed: number;
nightDate: Date;
/** UI sfx — routed around the location filter, always crisp. */
sfx: Sfx | null;
beatIntervalMs: number;
scheduleDeferred(hits: readonly DeferredHit[]): void;
reportQueue(length: number): void;
/** Scripted encounters this RUN has already met (cross-night repeat gating). */
seenEncounters: readonly string[];
/** This week's venue — capacity/pressure knobs (venue ladder, design §6). */
venue: VenueDef;
/** Door/Floor ask to move the player; the night decides and flips the scenes. */
requestLocation(loc: 'door' | 'floor'): void;
}
export class NightScene extends Phaser.Scene {
private bus!: EventBus;
private rng!: SeededRNG;
private clock!: GameClock;
private stubBeat: StubBeatClock | null = null;
private engine: TechnoEngine | null = null;
private sfx: Sfx | null = null;
private ambience: Ambience | null = null;
private meters!: Meters;
private state!: NightState;
private run!: GameState;
private seed = 4207;
private nightIndex = 0;
private venue!: VenueDef;
private ended = false;
private deferred: DeferredHit[] = [];
private readonly firedDazza = new Set<string>();
private lastDazzaMin = -99;
private lastVibeSampleMin = -99;
/** Live inspector watch, or null. At most one per night. */
private inspector: InspectorWatch | null = null;
/** Minors the player (or Kayden) has actually let past the rope. */
private minorsInside = 0;
/** True while the floor has an unhandled maggot — the inspector's worst find. */
private maggotLive = false;
private lastMaggotRadioMin = -99;
private readonly firedPulls = new Set<number>();
private queueLength = 0;
private log!: NightLog;
private offs: Array<() => void> = [];
private ctx!: NightContext;
constructor() {
super('Night');
}
init(data: { seed?: number; nightIndex?: number; freshRun?: boolean }): void {
if (data.freshRun) clearSave();
this.run = loadGame(typeof data.seed === 'number' ? data.seed : 4207);
if (typeof data.seed === 'number' && this.run.seed !== data.seed) {
this.run = freshGameState(data.seed);
}
this.nightIndex = data.nightIndex ?? this.run.nightIndex;
if (this.nightIndex >= TOTAL_NIGHTS) this.nightIndex = 0;
this.venue = venueByIndex(this.run.venueIndex);
// Each night is its own deterministic world derived from the run seed —
// and each venue-week its own block of them.
this.seed = this.run.seed + this.run.venueIndex * 1009 + this.nightIndex * 101;
}
create(): void {
this.ended = false;
this.deferred = [];
this.firedDazza.clear();
this.firedPulls.clear();
this.lastDazzaMin = -99;
this.lastVibeSampleMin = -99;
this.lastMaggotRadioMin = -99;
_resetPatronSerial();
this.bus = new EventBus();
this.rng = new SeededRNG(this.seed);
const clockCfg: ClockConfig = {
...DEFAULT_CLOCK,
nightDate: nightDateFor(NIGHT_DATES[this.nightIndex]!, this.run.venueIndex),
};
this.clock = new GameClock(this.bus, clockCfg);
this.state = freshNightState(this.venue.id, this.nightIndex, this.venue.licensed);
// Heat is run-scoped: strikes from earlier nights are already on the licence.
this.state.heatStrikes.push(...this.run.heatStrikes);
this.meters = new Meters(this.bus, this.state);
// Audio: the engine is the beat authority once the browser lets it speak.
// Until the first gesture, a stub keeps beat:tick honest (then hands over).
this.engine = new TechnoEngine(this.bus);
this.sfx = new Sfx(this.engine.ctx, this.engine.sfxBus);
// Rain outside, murmur inside, fluoro hum — starts once audio unlocks.
this.ambience = new Ambience(this.bus, this.engine.ctx, this.engine.sfxBus, this.sfx);
this.stubBeat = new StubBeatClock(this.bus);
this.stubBeat.start();
this.engine.start();
const unlock = (): void => {
void this.engine?.unlock();
};
this.input.on('pointerdown', unlock);
this.offs.push(() => this.input.off('pointerdown', unlock));
this.log = {
vibeHistory: [this.state.vibe],
verdicts: [],
admitted: 0,
denied: 0,
tested: 0,
pattedDown: 0,
hypePeak: 1,
heatStrikes: [],
endReason: 'clock',
seed: this.seed,
};
this.offs.push(
this.bus.on('clock:tick', ({ clockMin }) => this.onMinute(clockMin)),
this.bus.on('meters:changed', ({ vibe, aggro, hype }) => this.onMeters(vibe, aggro, hype)),
this.bus.on('heat:strike', (s) => this.onStrike(s)),
this.bus.on('door:verdict', (v) => this.onVerdict(v)),
this.bus.on('floor:ejection', () => {
this.state.capacity.inside = Math.max(0, this.state.capacity.inside - 1);
}),
this.bus.on('floor:leave', () => {
this.state.capacity.inside = Math.max(0, this.state.capacity.inside - 1);
}),
this.bus.on('audio:unlocked', () => {
this.stubBeat?.stop();
this.stubBeat = null;
this.ambience?.start();
}),
this.bus.on('radio:call', () => this.sfx?.play('radioChirp')),
this.bus.on('incident:log', (inc) => {
if (inc.kind === 'encounter' && inc.detail.includes(':')) {
// The door logs encounters as "<id>: let in/turned away" — the id is
// what gates repeats across the week.
const encId = inc.detail.split(':')[0]!;
const seen = (this.run.seenEncounters ??= []);
if (!seen.includes(encId)) seen.push(encId);
}
if (inc.kind === 'maggotUnhandled') {
this.maggotLive = true;
if (this.state.location === 'door') this.maggotRadio();
}
// A swing on the floor while you're at the rope: the radio tells you.
if (inc.kind === 'fight' && this.state.location === 'door' && inc.detail.startsWith('swung')) {
this.bus.emit('radio:call', { urgency: 3, line: 'there was a SWING inside. get in here. NOW' });
}
// The floor cleared them out; the room is legal again.
if (inc.kind === 'ejection' || inc.kind === 'cutOff') this.maggotLive = false;
}),
);
this.ctx = {
bus: this.bus,
rng: this.rng,
clock: this.clock,
state: this.state,
seed: this.seed,
nightDate: this.clock.nightDate,
sfx: this.sfx,
beatIntervalMs: 60_000 / 128,
scheduleDeferred: (hits) => this.deferred.push(...hits),
reportQueue: (length) => {
this.queueLength = length;
},
seenEncounters: this.run.seenEncounters ?? [],
venue: this.venue,
requestLocation: (loc) => this.setLocation(loc),
};
this.events.once('shutdown', () => this.teardown());
this.clock.start();
this.scene.launch('Door', { night: this.ctx });
this.scene.launch('Floor', { night: this.ctx });
// Door and Floor BOTH run all night; the flags below decide which one the
// player is inhabiting. Floor starts hidden.
this.time.delayedCall(0, () => this.applyLocation('door'));
}
// ---- location ----------------------------------------------------------
private setLocation(loc: 'door' | 'floor'): void {
if (this.ended || this.state.location === loc) return;
this.applyLocation(loc);
this.bus.emit('night:phaseChange', { location: loc });
this.bus.emit('audio:location', { location: loc });
}
private applyLocation(loc: 'door' | 'floor'): void {
const doorHere = loc === 'door';
this.scene.setVisible(doorHere, 'Door');
this.scene.setVisible(!doorHere, 'Floor');
const door = this.scene.get('Door');
const floor = this.scene.get('Floor');
if (door) door.input.enabled = doorHere;
if (floor) floor.input.enabled = !doorHere;
this.state.location = loc; // meters also tracks via night:phaseChange; direct set covers the first frame
}
// ---- per-event ---------------------------------------------------------
private onVerdict({ patron, verdict, outcome }: { patron: Patron; verdict: Verdict; outcome: VerdictOutcome }): void {
this.log.verdicts.push({ patron, verdict, outcome, clockMin: this.state.clockMin });
if (verdict === 'admit') {
this.log.admitted++;
// The UV stamp happens at the door. The player never forgets; Kayden
// forgets one hand in seven — those are the floor's noStamp infractions.
patron.flags.uvStamped =
this.state.location === 'door' ? true : this.rng.stream('kaydenStamp').chance(KAYDEN_STAMP_CHANCE);
// The inspector reads as a boring punter and is scored as one — the ONLY
// thing that marks them is that admitting one starts a clock (§4.2).
// ...and only at venues the inspector actually works (venue ladder).
if (patron.archetype === 'inspector' && this.inspector === null && this.venue.inspector) {
this.inspector = startWatch(patron.id, this.state.clockMin);
}
if (patron.age < 18) this.minorsInside++;
} else if (verdict === 'deny') this.log.denied++;
else if (verdict === 'sobrietyTest') this.log.tested++;
else if (verdict === 'patDown') this.log.pattedDown++;
}
private onStrike(s: HeatStrike): void {
this.log.heatStrikes.push(s);
// Third strike doesn't wait for 3 AM: the inspector pulls the licence on the
// spot and the run is over (review ruling, design §2).
if (this.state.heatStrikes.length >= MAX_HEAT_STRIKES) this.endNight('licence');
}
private onMeters(vibe: number, aggro: number, hype: number): void {
if (hype > this.log.hypePeak) this.log.hypePeak = hype;
if (this.ended) return;
if (vibe <= 0) this.endNight('vibe');
else if (aggro >= 100) this.endNight('aggro');
}
private onMinute(clockMin: number): void {
if (this.ended) return;
if (clockMin - this.lastVibeSampleMin >= VIBE_SAMPLE_EVERY_MIN) {
this.lastVibeSampleMin = clockMin;
this.log.vibeHistory.push(this.state.vibe);
}
this.fireDeferred(clockMin);
this.observeInspector(clockMin);
this.maybeDazza(clockMin);
this.maybePull(clockMin);
// The room cools on its own — keeping it lit is the job (core/meters.ts).
const cooling = vibeCoolingPerMin(this.state.vibe);
if (cooling !== 0) this.bus.emit('meters:delta', { vibe: cooling });
if (clockMin >= this.clock.config.nightClockMinutes) this.endNight('clock');
}
/**
* The inspector, if one is inside. While they watch, a live breach files its
* strike NOW instead of at the 3 AM audit — the whole point being that you
* cannot know which half-hour of the night that was.
*
* Every strike still goes through shouldRecordStrike: observe() returns one
* per breach per minute, so emitting them raw would pull the licence within
* three minutes of any sustained breach.
*/
private observeInspector(clockMin: number): void {
if (!this.inspector) return;
const breaches: VenueBreaches = {
overCapacity: this.state.capacity.inside > this.state.capacity.licensed,
maggotOnFloor: this.maggotLive,
underageInside: this.minorsInside > 0,
};
const seen = observe(this.inspector, clockMin, breaches);
for (const strike of seen.strikes) {
if (shouldRecordStrike(this.state, strike)) this.bus.emit('heat:strike', strike);
}
if (seen.dazzaText) this.bus.emit('dazza:text', { text: seen.dazzaText });
if (seen.watchEnded) this.inspector = null;
}
/** Scripted floor pulls: the radio wants you inside around these times. */
private maybePull(clockMin: number): void {
for (const at of RADIO_PULL_MINS) {
if (clockMin < at || this.firedPulls.has(at)) continue;
this.firedPulls.add(at);
if (this.state.location === 'floor') continue; // already in there
this.bus.emit('radio:call', {
urgency: 2,
line: at === RADIO_PULL_MINS[0] ? 'mate do a lap, it is getting loose in here' : 'you are NEEDED inside. now.',
});
}
}
private maggotRadio(): void {
const clockMin = this.state.clockMin;
if (clockMin - this.lastMaggotRadioMin < RADIO_MAGGOT_COOLDOWN) return;
this.lastMaggotRadioMin = clockMin;
this.bus.emit('radio:call', { urgency: 3, line: 'someone is MAGGOT on the dance floor, get in here' });
}
private fireDeferred(clockMin: number): void {
// Collect first, THEN process: a hit's vibe delta can end the night, and
// endNight() reassigns this.deferred — mutating the array we'd be iterating.
const due: DeferredHit[] = [];
for (let i = this.deferred.length - 1; i >= 0; i--) {
if (clockMin >= this.deferred[i]!.atClockMin) due.push(...this.deferred.splice(i, 1));
}
for (const hit of due) {
if (this.ended) return;
if (hit.vibeDelta !== undefined || hit.aggroDelta !== undefined) {
this.bus.emit('meters:delta', {
...(hit.vibeDelta !== undefined ? { vibe: hit.vibeDelta } : {}),
...(hit.aggroDelta !== undefined ? { aggro: hit.aggroDelta } : {}),
});
}
if (hit.dazzaText) this.bus.emit('dazza:text', { text: hit.dazzaText });
if (hit.heatStrike && shouldRecordStrike(this.state, hit.heatStrike)) {
this.bus.emit('heat:strike', hit.heatStrike);
}
this.bus.emit('incident:log', {
clockMin,
kind: 'deferred',
patronId: hit.patronId,
detail: hit.reason,
});
}
}
private maybeDazza(clockMin: number): void {
if (clockMin - this.lastDazzaMin < 2) return;
const line = nextDazza(clockMin, {
vibe: this.state.vibe,
aggro: this.state.aggro,
queueLength: this.queueLength,
insideCount: this.state.capacity.inside,
fired: this.firedDazza,
ruleLimit: this.venue.ruleLimit,
});
if (!line) return;
this.firedDazza.add(line.id);
this.lastDazzaMin = clockMin;
const rule = line.ruleId ? ruleById(line.ruleId) : undefined;
this.bus.emit('dazza:text', { text: line.text, ...(rule ? { rule } : {}) });
}
/** The one exit. Everything routes through here so the log is always complete. */
private endNight(reason: NightEndReason): void {
if (this.ended) return;
this.ended = true;
this.log.endReason = reason;
this.clock.pause();
this.stubBeat?.stop();
// Unfired deferred hits are the 3 AM audit — they land whether you made it
// to closing or not. (Not after a pulled licence: the run is already dead.)
if (reason !== 'licence') {
for (const hit of this.deferred) {
if (hit.heatStrike && shouldRecordStrike(this.state, hit.heatStrike)) {
this.bus.emit('heat:strike', hit.heatStrike);
}
}
}
this.deferred = [];
this.log.vibeHistory.push(this.state.vibe);
// ---- run bookkeeping (save) ----
const runOver = reason === 'licence';
const survived = reason === 'clock';
const weekDone = survived && this.nightIndex >= TOTAL_NIGHTS - 1;
const seasonDone = weekDone && this.run.venueIndex >= TOTAL_VENUES - 1;
this.run.heatStrikes = [...this.state.heatStrikes];
// pastReports is written by IncidentReportScene now, not here: Phase 4
// audits what the player CLAIMED happened, and a raw truth dump would have
// nothing to catch them out with.
if (survived) this.run.nightIndex = this.nightIndex + 1;
if (weekDone && !seasonDone) {
// PROMOTED: next venue, fresh week, fresh licence. Heat is per-venue —
// the new place has not heard of you (yet). Regulars and met-encounters
// carry across: it is the same town.
this.run.venueIndex += 1;
this.run.nightIndex = 0;
this.run.heatStrikes = [];
}
if (runOver || seasonDone) {
clearSave();
} else {
saveGame(this.run);
}
const runInfo: RunInfo = {
game: this.run,
nightIndex: this.nightIndex,
totalNights: TOTAL_NIGHTS,
venue: this.venue,
promotedTo: weekDone && !seasonDone ? venueByIndex(this.run.venueIndex) : null,
runOver,
weekDone,
seasonDone,
};
this.time.delayedCall(reason === 'clock' ? 600 : 1400, () => {
this.scene.stop('Door');
this.scene.stop('Floor');
this.scene.start('NightSummary', { log: this.log, state: this.state, run: runInfo });
});
}
override update(_time: number, deltaMs: number): void {
if (this.ended) return;
this.clock.update(deltaMs);
this.stubBeat?.update(deltaMs);
}
private teardown(): void {
for (const off of this.offs) off();
this.offs = [];
this.meters?.destroy();
this.ambience?.destroy();
this.ambience = null;
this.engine?.destroy();
this.engine = null;
this.sfx = null;
this.bus?.removeAll();
}
}