not-tonight/tests/sim/economy.ts
2026-07-20 18:45:50 +10:00

285 lines
10 KiB
TypeScript

// 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,
};