not-tonight/tests/sim/economy.ts

366 lines
13 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 { CARPET_TUNING, QueueManager, entranceBonus } 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';
/**
* The red carpet (design §2 theatre): park this one between the posts instead
* of ruling on them? Omitted = never uses it, which is every pre-carpet band.
*/
carpet?: (p: Patron, clockMin: number) => boolean;
/** Real ms the bot leaves them standing there before waving them up. */
carpetStandMs?: number;
}
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;
/** Red carpet: bodies parked, walk-offs, and admits that paid the entrance. */
carpetParked: number;
carpetStorms: number;
carpetEntrances: number;
/** Share of post-11PM samples with hype pinned at the x3 ceiling. */
hypePinnedShare: 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,
carpetParked: 0,
carpetStorms: 0,
carpetEntrances: 0,
hypePinnedShare: 0,
};
const hypeSamples: number[] = [];
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);
hypeSamples.push(state.hype);
}
});
// ---- 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);
// Mirrors DoorScene.rule: an admit off the carpet pays the entrance.
const stoodMs = queue.carpetMsFor(p.id);
if (verdict === 'admit' && stoodMs > 0) {
metrics.carpetEntrances++;
bus.emit('meters:delta', { vibe: CARPET_TUNING.entranceVibe, hype: entranceBonus(stoodMs) });
}
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 parked = new Set<string>();
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);
}
// The carpet ticks itself inside queue.update(); the bot only decides who
// goes on it and when they come off. Walk-offs are counted, never re-parked.
metrics.carpetStorms += queue.takeStorms().length;
const up = queue.patronUp;
if (up) {
if (ruleTimerMs < 0) ruleTimerMs = policy.ruleDelayMs(up, state.clockMin, clock.nightDate);
ruleTimerMs -= TICK_MS;
if (ruleTimerMs <= 0) {
// Park them instead of ruling — but only once each, or a showpony bot
// would loop one influencer round the posts all night.
if (
policy.carpet?.(up, state.clockMin) === true &&
!parked.has(up.id) &&
queue.toCarpet() !== null
) {
parked.add(up.id);
metrics.carpetParked++;
} else {
applyVerdict(up, policy.decide(up, state.clockMin, clock.nightDate, state.capacity.inside, state.capacity.licensed));
}
ruleTimerMs = -1;
waitTimerMs = 0;
}
} else if (queue.carpetSnapshot.some((s) => s.standMs >= (policy.carpetStandMs ?? 0))) {
// Rope free and somebody has stood long enough: wave them up. The stand
// they have already banked is what the entrance pays for.
const ready = queue.carpetSnapshot.find((s) => s.standMs >= (policy.carpetStandMs ?? 0));
if (ready) queue.recallFromCarpet(ready.patron.id);
} 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;
const hypePost11 = hypeSamples.slice(Math.floor(120 / 5));
metrics.hypePinnedShare = hypePost11.length
? hypePost11.filter((h) => h >= 2.95).length / hypePost11.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,
};
/**
* Careful verdicts, but works the velvet: parks everyone with the patience for
* it and leaves them there just inside their fuse. This is the carpet played as
* hard as it can honestly be played — the band it has to satisfy is that
* theatre pays WELL without turning hype back into the ratchet it used to be.
*/
export const SHOWPONY: BotPolicy = {
name: 'showpony',
callDelayMs: () => 700,
ruleDelayMs: CAREFUL.ruleDelayMs,
decide: CAREFUL.decide,
// The ones who read being furniture as being SEEN. A regular parked here
// storms off, so the showpony leaves the regulars alone.
carpet: (p) => p.archetype === 'influencer' || p.archetype === 'financeBro' || p.archetype === 'ownersMate',
carpetStandMs: 25_000,
};
/**
* The showpony with no brakes: parks ANYONE and holds them near the fuse. The
* band this satisfies is that the carpet is a gamble at all — hold long enough
* and the walk-offs have to actually arrive, or the verb is free money.
*/
export const GREEDY: BotPolicy = {
name: 'greedy',
callDelayMs: () => 700,
ruleDelayMs: CAREFUL.ruleDelayMs,
decide: CAREFUL.decide,
carpet: () => true,
carpetStandMs: 45_000,
};