274 lines
9.6 KiB
TypeScript
274 lines
9.6 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 { 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;
|
|
/** 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,
|
|
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++;
|
|
leaveAt.push(state.clockMin + leaverRng.int(50, 140));
|
|
} else metrics.denied++;
|
|
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';
|
|
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,
|
|
};
|