// Bot-driven door-economy simulation. Not a test by itself — the harness the // tuning tests drive. Node-only: EventBus + GameClock + Meters + QueueManager + // judge, no Phaser. Mirrors DoorScene.applyOutcome / NightScene.fireDeferred // closely enough that a knob change here means the same thing in the game. import { EventBus } from '../../src/core/EventBus'; import { GameClock, DEFAULT_CLOCK } from '../../src/core/GameClock'; import { Meters, freshNightState, vibeCoolingPerMin } from '../../src/core/meters'; import { SeededRNG } from '../../src/core/SeededRNG'; import { _resetPatronSerial } from '../../src/patrons/generator'; import { QueueManager } from '../../src/scenes/door/QueueManager'; import { shouldRecordStrike } from '../../src/rules/heat'; import { LOCKOUT_MIN, judge } from '../../src/rules/judge'; import { violations } from '../../src/rules/dressCode'; import { idVerdict } from '../../src/rules/idCheck'; import type { DeferredHit } from '../../src/rules/doorTypes'; import type { Patron } from '../../src/data/types'; export interface BotPolicy { name: string; /** Real ms the bot waits before pulling the rope (its "walking back" time). */ callDelayMs: (queueLen: number) => number; /** Real ms spent inspecting before the verdict lands. Models human reading. */ ruleDelayMs: (p: Patron, clockMin: number, nightDate: Date) => number; decide: (p: Patron, clockMin: number, nightDate: Date, insideCount: number, licensed: number) => 'admit' | 'deny'; } export interface NightMetrics { policy: string; seed: number; endReason: 'clock' | 'vibe' | 'aggro'; endClockMin: number; admitted: number; denied: number; /** Same counts, before the 1:30 lockout — the fill band measures PLAY, not the law. */ admittedPreLockout: number; deniedPreLockout: number; /** vibe sampled every 5 clock-min */ vibeSamples: number[]; vibeMin: number; vibeMax: number; /** share of post-11PM samples pinned at >= 95 (the "meter is dead" signal) */ vibePinnedShare: number; aggroMax: number; aggroSamples: number[]; hypePeak: number; heatStrikes: number; /** queue depth stats inside the slam window (clockMin 90..210) */ slamQueueMax: number; slamQueueAvg: number; inside: number; } const TICK_MS = 100; export interface SimVenue { arrivalScale: number; licensed: number; } /** Voltage-equivalent knobs — the tier the feel-bands are calibrated to. */ export const SIM_DEFAULT_VENUE: SimVenue = { arrivalScale: 1.0, licensed: 80 }; export function simulateNight( seed: number, policy: BotPolicy, venue: SimVenue = SIM_DEFAULT_VENUE, ): NightMetrics { _resetPatronSerial(); const bus = new EventBus(); const rng = new SeededRNG(seed); const clock = new GameClock(bus, DEFAULT_CLOCK); const state = freshNightState('sim', 0, venue.licensed); const meters = new Meters(bus, state); const queue = new QueueManager(bus, rng, clock.nightDate, [], venue.arrivalScale); const deferred: DeferredHit[] = []; const lapRng = rng.stream('dazzaLaps'); // Mirrors CrowdSim's natural churn: admitted patrons go home after 50-140 // in-game minutes, freeing capacity (floor:leave in the real game). const leaverRng = rng.stream('simLeavers'); const leaveAt: number[] = []; const metrics: NightMetrics = { policy: policy.name, seed, endReason: 'clock', endClockMin: 360, admitted: 0, denied: 0, admittedPreLockout: 0, deniedPreLockout: 0, vibeSamples: [state.vibe], vibeMin: state.vibe, vibeMax: state.vibe, vibePinnedShare: 0, aggroMax: 0, aggroSamples: [state.aggro], hypePeak: 1, heatStrikes: 0, slamQueueMax: 0, slamQueueAvg: 0, inside: 0, }; let ended = false; bus.on('meters:changed', ({ vibe, aggro, hype }) => { metrics.vibeMin = Math.min(metrics.vibeMin, vibe); metrics.vibeMax = Math.max(metrics.vibeMax, vibe); metrics.aggroMax = Math.max(metrics.aggroMax, aggro); metrics.hypePeak = Math.max(metrics.hypePeak, hype); if (ended) return; if (vibe <= 0) { ended = true; metrics.endReason = 'vibe'; metrics.endClockMin = state.clockMin; } else if (aggro >= 100) { ended = true; metrics.endReason = 'aggro'; metrics.endClockMin = state.clockMin; } }); let lastSample = -5; let lastMinute = -1; const fireDeferred = (clockMin: number): void => { const due: DeferredHit[] = []; for (let i = deferred.length - 1; i >= 0; i--) { if (clockMin >= deferred[i]!.atClockMin) due.push(...deferred.splice(i, 1)); } for (const hit of due) { if (ended) return; if (hit.vibeDelta !== undefined || hit.aggroDelta !== undefined) { bus.emit('meters:delta', { ...(hit.vibeDelta !== undefined ? { vibe: hit.vibeDelta } : {}), ...(hit.aggroDelta !== undefined ? { aggro: hit.aggroDelta } : {}), }); } if (hit.heatStrike && shouldRecordStrike(state, hit.heatStrike)) { bus.emit('heat:strike', hit.heatStrike); } } }; bus.on('heat:strike', () => metrics.heatStrikes++); bus.on('clock:tick', ({ clockMin }) => { queue.onClockMinute(clockMin); fireDeferred(clockMin); for (let i = leaveAt.length - 1; i >= 0; i--) { if (clockMin >= leaveAt[i]!) { leaveAt.splice(i, 1); state.capacity.inside = Math.max(0, state.capacity.inside - 1); } } const cooling = vibeCoolingPerMin(state.vibe); if (cooling !== 0 && !ended) bus.emit('meters:delta', { vibe: cooling }); if (clockMin - lastSample >= 5) { lastSample = clockMin; metrics.vibeSamples.push(state.vibe); metrics.aggroSamples.push(state.aggro); } }); // ---- the bot ---- let waitTimerMs = 0; let ruleTimerMs = -1; // -1: nobody up const applyVerdict = (p: Patron, verdict: 'admit' | 'deny'): void => { const outcome = judge(p, verdict, { clockMin: state.clockMin, nightDate: clock.nightDate, lapRoll: lapRng.next(), insideCount: state.capacity.inside, licensed: state.capacity.licensed, }); if (verdict === 'admit') { state.capacity.inside++; metrics.admitted++; if (state.clockMin < LOCKOUT_MIN) metrics.admittedPreLockout++; leaveAt.push(state.clockMin + leaverRng.int(50, 140)); } else { metrics.denied++; if (state.clockMin < LOCKOUT_MIN) metrics.deniedPreLockout++; } const delta: { vibe?: number; aggro?: number; hype?: number } = {}; if (outcome.vibeDelta) delta.vibe = outcome.vibeDelta; if (outcome.aggroDelta) delta.aggro = outcome.aggroDelta; if (outcome.hypeDelta) delta.hype = outcome.hypeDelta; if (Object.keys(delta).length > 0) bus.emit('meters:delta', delta); if (outcome.heatStrike && !outcome.heatStrike.deferred && shouldRecordStrike(state, outcome.heatStrike)) { bus.emit('heat:strike', outcome.heatStrike); } if (outcome.deferred?.length) deferred.push(...outcome.deferred); queue.resolveUp(verdict === 'deny'); }; clock.start(); let slamTicks = 0; let slamQueueSum = 0; const totalMs = DEFAULT_CLOCK.nightRealMinutes * 60_000; for (let t = 0; t < totalMs && !ended; t += TICK_MS) { clock.update(TICK_MS); queue.update(TICK_MS); if (state.clockMin !== lastMinute) lastMinute = state.clockMin; if (state.clockMin >= 90 && state.clockMin <= 210) { slamTicks++; slamQueueSum += queue.queueLength; metrics.slamQueueMax = Math.max(metrics.slamQueueMax, queue.queueLength); } const up = queue.patronUp; if (up) { if (ruleTimerMs < 0) ruleTimerMs = policy.ruleDelayMs(up, state.clockMin, clock.nightDate); ruleTimerMs -= TICK_MS; if (ruleTimerMs <= 0) { applyVerdict(up, policy.decide(up, state.clockMin, clock.nightDate, state.capacity.inside, state.capacity.licensed)); ruleTimerMs = -1; waitTimerMs = 0; } } else if (queue.queueLength > 0) { waitTimerMs += TICK_MS; if (waitTimerMs >= policy.callDelayMs(queue.queueLength)) { queue.callNext(); waitTimerMs = 0; } } } // 3 AM audit: whatever deferred heat is left lands now. for (const hit of deferred) { if (hit.heatStrike && shouldRecordStrike(state, hit.heatStrike)) bus.emit('heat:strike', hit.heatStrike); } const post11 = metrics.vibeSamples.slice(Math.floor(120 / 5)); metrics.vibePinnedShare = post11.length ? post11.filter((v) => v >= 95).length / post11.length : 0; metrics.slamQueueAvg = slamTicks ? slamQueueSum / slamTicks : 0; metrics.inside = state.capacity.inside; metrics.endClockMin = ended ? metrics.endClockMin : 360; meters.destroy(); return metrics; } // ---- policies ---------------------------------------------------------------- /** Plays the rules straight, with human-ish deliberation time. */ export const CAREFUL: BotPolicy = { name: 'careful', callDelayMs: () => 700, ruleDelayMs: (p, _clockMin, nightDate) => { const id = idVerdict(p.idCard, nightDate); // Reading a dodgy card or a near-boundary DOB costs real seconds. return 2300 + (id.looksFake || id.underage || p.age <= 19 ? 1400 : 0); }, decide: (p, clockMin, nightDate, insideCount, licensed) => { // Watches the clicker: one out, one in doesn't exist yet, so at capacity // the door simply shuts. This is what playing the licence looks like. if (insideCount >= licensed) return 'deny'; // 1:30 lockout — after LOCKOUT_MIN "no" is the law, and careful play obeys it. if (clockMin >= LOCKOUT_MIN) return 'deny'; const id = idVerdict(p.idCard, nightDate); if (id.underage || id.looksFake || p.age < 18) return 'deny'; if (violations(p, clockMin).length > 0) return 'deny'; if (p.intoxication > 0.62) return 'deny'; return 'admit'; }, }; /** Waves everyone in as fast as the buttons allow. */ export const SLOPPY: BotPolicy = { name: 'sloppy', callDelayMs: () => 300, ruleDelayMs: () => 700, decide: () => 'admit', }; /** Careful verdicts, but farms the wait: lets the queue stew before every call. */ export const DAWDLER: BotPolicy = { name: 'dawdler', callDelayMs: () => 6000, ruleDelayMs: CAREFUL.ruleDelayMs, decide: CAREFUL.decide, };