260 lines
9.2 KiB
TypeScript
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,
|
|
};
|