not-tonight/tests/sim/floorShift.ts

260 lines
9.2 KiB
TypeScript

// Bot-driven BAR-economy simulation — the floor-side twin of economy.ts.
// Node-only: EventBus + GameClock + Meters + the real CrowdSim + the real
// barShift pure functions. Mirrors FloorDemoScene.tickBar / applyBarCall
// closely enough that a knob change here means the same thing in the game.
//
// Why this exists: eleven floor systems shipped in one day and the only bot
// nights we had were doorgirl nights. An untested economy is a guess.
import { EventBus } from '../../src/core/EventBus';
import { GameClock, DEFAULT_CLOCK } from '../../src/core/GameClock';
import { Meters, freshNightState, vibeCoolingPerMin } from '../../src/core/meters';
import { SeededRNG, type RngStream } from '../../src/core/SeededRNG';
import { generatePatron, _resetPatronSerial } from '../../src/patrons/generator';
import { CrowdSim, type Agent } from '../../src/scenes/floor/crowdSim';
import { VENUE } from '../../src/scenes/floor/venueMap';
import { drunkStage } from '../../src/rules/drunk';
import { shouldRecordStrike } from '../../src/rules/heat';
import type { DeferredHit } from '../../src/rules/doorTypes';
import type { DrunkStage } from '../../src/data/types';
import {
BREACH_SEEN_AFTER, BREACH_SEEN_CHANCE, ORDER_CHANCE_PER_S, ORDER_COOLDOWN_MS,
POUR_CLEAN_FROM, POUR_OVER_FROM, RSA_STRIKE_REASON, TAB_OPEN_CHANCE,
canOpenTab, isRsaBreach, judgeCall, judgePour, pourAdjust, type BarCall,
} from '../../src/scenes/floor/barShift';
export interface BarPolicy {
name: string;
/** What you do with an order, knowing what you can see of them. */
call: (stage: DrunkStage) => BarCall;
/** Where the marker gets stopped. Models a hand, not a menu click. */
pourFill: (rng: RngStream) => number;
}
export interface BarMetrics {
policy: string;
seed: number;
endReason: 'clock' | 'vibe' | 'aggro';
endClockMin: number;
orders: number;
serves: number;
waters: number;
cutoffs: number;
/** Pours that went into someone already visibly cooked (the RSA line). */
breaches: number;
shortPours: number;
overPours: number;
tabsOpened: number;
heatStrikes: number;
vibeSamples: number[];
vibeMin: number;
vibePinnedShare: number;
aggroMax: number;
hypePeak: number;
/** Bodies on the floor, averaged across the night — the room the bar serves. */
crowdAvg: number;
}
const TICK_MS = 100;
/** One arrival every this many clock-minutes (Kayden is working the door). */
const ARRIVE_EVERY_MIN = 2;
/** FloorDemoScene's MAX_CROWD — the sim's perf budget, mirrored. */
const MAX_CROWD = 44;
export function simulateBarShift(seed: number, policy: BarPolicy): BarMetrics {
_resetPatronSerial();
const bus = new EventBus();
const rng = new SeededRNG(seed);
const clock = new GameClock(bus, DEFAULT_CLOCK);
const state = freshNightState('sim', 0, 80);
const meters = new Meters(bus, state);
const crowd = new CrowdSim({ map: VENUE, bus, rng: rng.stream('crowd') });
const orderRng = rng.stream('barOrders');
const pourRng = rng.stream('pour');
// generatePatron wants the RNG itself (it takes its own 'patrons' stream), so
// arrivals get a derived instance rather than one of this seed's streams.
const spawnRng = new SeededRNG(seed ^ 0x5ba7);
const deferred: DeferredHit[] = [];
const orderedAt = new Map<string, number>();
const tabs = new Set<string>();
const metrics: BarMetrics = {
policy: policy.name,
seed,
endReason: 'clock',
endClockMin: 360,
orders: 0,
serves: 0,
waters: 0,
cutoffs: 0,
breaches: 0,
shortPours: 0,
overPours: 0,
tabsOpened: 0,
heatStrikes: 0,
vibeSamples: [state.vibe],
vibeMin: state.vibe,
vibePinnedShare: 0,
aggroMax: 0,
hypePeak: 1,
crowdAvg: 0,
};
let ended = false;
bus.on('meters:changed', ({ vibe, aggro, hype }) => {
metrics.vibeMin = Math.min(metrics.vibeMin, 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;
}
});
bus.on('heat:strike', () => metrics.heatStrikes++);
let lastSample = -5;
let nextArriveMin = 0;
bus.on('clock:tick', ({ clockMin }) => {
// Deferred RSA write-ups land on the minute, as NightScene fires them.
for (let i = deferred.length - 1; i >= 0; i--) {
const hit = deferred[i]!;
if (clockMin < hit.atClockMin) continue;
deferred.splice(i, 1);
if (hit.heatStrike && shouldRecordStrike(state, hit.heatStrike)) {
bus.emit('heat:strike', hit.heatStrike);
}
}
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);
}
});
/** Mirrors FloorDemoScene.applyBarCall. */
const applyCall = (agent: Agent, stage: DrunkStage, call: BarCall): void => {
let out = judgeCall(call, stage);
if (call === 'serve') {
const fill = policy.pourFill(pourRng);
const verdict = judgePour(fill);
if (verdict === 'short') metrics.shortPours++;
if (verdict === 'overflow') metrics.overPours++;
out = pourAdjust(out, verdict);
metrics.serves++;
if (out.breach) metrics.breaches++;
} else if (call === 'water') {
metrics.waters++;
} else {
metrics.cutoffs++;
}
const delta: { vibe?: number; aggro?: number } = {};
if (out.vibe) delta.vibe = out.vibe;
if (out.aggro) delta.aggro = out.aggro;
if (out.vibe || out.aggro) bus.emit('meters:delta', delta);
agent.patron.intoxication = Math.max(0, Math.min(1, agent.patron.intoxication + out.intoxDelta));
if (out.sendsHome) {
agent.handled = true;
crowd.sendHome(agent);
}
if (out.breach && orderRng.chance(BREACH_SEEN_CHANCE)) {
deferred.push({
atClockMin: state.clockMin + BREACH_SEEN_AFTER[0] + orderRng.int(0, BREACH_SEEN_AFTER[1]),
heatStrike: { reason: RSA_STRIKE_REASON, deferred: true },
reason: RSA_STRIKE_REASON,
patronId: agent.patron.id,
});
}
if (call === 'serve' && !tabs.has(agent.patron.id) && canOpenTab(stage) && orderRng.chance(TAB_OPEN_CHANCE)) {
tabs.add(agent.patron.id);
metrics.tabsOpened++;
}
};
clock.start();
const totalMs = DEFAULT_CLOCK.nightRealMinutes * 60_000;
let elapsedMs = 0;
let crowdTicks = 0;
let crowdSum = 0;
for (let t = 0; t < totalMs && !ended; t += TICK_MS) {
clock.update(TICK_MS);
elapsedMs += TICK_MS;
// The room fills: Kayden's door, mirrored as a steady drip.
while (state.clockMin >= nextArriveMin) {
nextArriveMin += ARRIVE_EVERY_MIN;
const live = crowd.agents.filter((a) => !a.gone).length;
if (live < MAX_CROWD) {
crowd.spawn(generatePatron({ rng: spawnRng, nightDate: clock.nightDate }, state.clockMin));
}
}
crowd.update(TICK_MS, state.clockMin);
crowdTicks++;
crowdSum += crowd.agents.filter((a) => !a.gone).length;
// Behind the taps all night (mirrors tickBar's roll).
if (!orderRng.chance((TICK_MS / 1000) * ORDER_CHANCE_PER_S)) continue;
const asker = crowd.agents.find(
(a) =>
!a.gone &&
!a.handled &&
a.activity === 'atBar' &&
elapsedMs - (orderedAt.get(a.patron.id) ?? -Infinity) > ORDER_COOLDOWN_MS,
);
if (!asker) continue;
orderedAt.set(asker.patron.id, elapsedMs);
metrics.orders++;
const stage = drunkStage(asker.patron.intoxication);
applyCall(asker, stage, policy.call(stage));
}
const post11 = metrics.vibeSamples.slice(Math.floor(120 / 5));
metrics.vibePinnedShare = post11.length ? post11.filter((v) => v >= 95).length / post11.length : 0;
metrics.crowdAvg = crowdTicks ? crowdSum / crowdTicks : 0;
metrics.endClockMin = ended ? metrics.endClockMin : 360;
meters.destroy();
return metrics;
}
// ---- policies ----------------------------------------------------------------
/** A hand that knows the glass. Lands the clean band most of the time. */
const skilledPour = (rng: RngStream): number => {
const roll = rng.next();
if (roll < 0.7) return POUR_CLEAN_FROM + rng.next() * (POUR_OVER_FROM - POUR_CLEAN_FROM);
if (roll < 0.85) return rng.next() * POUR_CLEAN_FROM;
return POUR_OVER_FROM + rng.next() * (1 - POUR_OVER_FROM);
};
/** Stops the marker wherever. What the first night actually looks like. */
const masherPour = (rng: RngStream): number => rng.next();
/** The legal shift: cut the cooked off, water the wobbly, serve the rest. */
export const RSA_STRAIGHT: BarPolicy = {
name: 'rsa-straight',
call: (stage) => (isRsaBreach(stage) ? 'cutoff' : stage === 'loose' ? 'water' : 'serve'),
pourFill: skilledPour,
};
/** Pours for anyone with a voice. The licence is a problem for later. */
export const PUBLICAN: BarPolicy = {
name: 'publican',
call: () => 'serve',
pourFill: skilledPour,
};
/** Legal calls, hopeless hands — the first-night bartender. */
export const BUTTERFINGERS: BarPolicy = {
name: 'butterfingers',
call: RSA_STRAIGHT.call,
pourFill: masherPour,
};