// 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(); const tabs = new Set(); 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, };