513 lines
20 KiB
TypeScript
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();
|
|
}
|
|
}
|