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(); 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(); 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 ": 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(); } }