diff --git a/LANEHANDOVER.md b/LANEHANDOVER.md index 8ababcf..0e8c89f 100644 --- a/LANEHANDOVER.md +++ b/LANEHANDOVER.md @@ -1800,3 +1800,57 @@ sweep state joined `resetNightState()`. next patron at the rope. - The sweep is untimed while the door keeps running, so dawdling changes pay. - `applyBarCall` does not re-check the asker is still in the building. +## SESSION — FABLE-SOLO-19 · 2026-07-21 + +**Branch:** main (solo, Fable, ultra) · **Gate:** lint ✓ build ✓ test ✓ (749 tests, 49 files) +**NOT deployed** — John is playtesting the live build; deploy is his call when done. + +### Floor-economy sim: the bot nights the floor never had +economy.test.ts only ever measured doorgirl nights. Eleven floor systems +shipped 2026-07-21 against zero bot coverage. Now covered: + +- **`tests/sim/economy.ts` extended**: `BotPolicy.carpet`/`carpetStandMs`, real + `toCarpet`/`recallFromCarpet` wiring (QueueManager owns the carpet, so the + sim drives the SHIPPING code), entrance dividend mirrored from DoorScene.rule, + new metrics (`carpetParked/Storms/Entrances`, `hypePinnedShare`). Policies + `SHOWPONY` (parks the tolerant, recalls inside the fuse) and `GREEDY` (parks + anyone, holds to the limit). +- **`tests/sim/floorShift.ts` (new)**: bar-economy sim on the REAL CrowdSim + (node-safe, already test-driven) + real `judgeCall`/`judgePour`/`pourAdjust`. + Mirrors tickBar/applyBarCall. Policies `RSA_STRAIGHT`, `PUBLICAN` (serves + anyone), `BUTTERFINGERS` (legal calls, hopeless hands). +- **`tests/sim/floorEconomy.test.ts` (new)**: 14 feel-bands. + +### THE BUG IT FOUND (fixed) +**The red carpet was an uncapped hype ratchet.** Three tolerant patrons parked +all night rode hype to the x3 ceiling; because `Meters` scales vibe GAINS by +hype, post-11PM vibe-pin went **0.27 → 0.71** against the suite's documented +`< 0.5` band — the exact "vibe pinned by 10 PM, hype a pure ratchet" failure +the Phase-3 tuning pass fixed for the door. +Fix: `CARPET_TUNING.hypePerStandCap = 0.15` with per-slot accounting in +`tickCarpet` — the same guard queue theatre already had (`hypePerWaitCap`). +The trickle is now flavour; the ENTRANCE dividend is the real payoff. +Result: hype 3.00 → 1.74, hypePin 0.11 → 0.00, vibePin 0.71 → 0.56. + +I also trialled `entranceVibe: 2 → 1` (0.56 → 0.50) and **reverted it** — that +is a feel decision, not a regression, and 0.50 sits exactly on a band edge. + +### What the sim says is HEALTHY (don't "fix" these) +- The carpet's fuse is real: GREEDY (holds 45s) storms **34 of 49** parks, + aggro 14 → 31, admits 78 → 60. Greed is properly punished. +- Reading the archetype IS the skill: SHOWPONY takes zero storms, GREEDY many. +- Pour skill is felt: BUTTERFINGERS' short pours push aggroMax 15 → 32. +- RSA-straight play never earns heat (the lawful shift is a tension, not a trap). + +### Findings for John (design calls, NOT actioned) +1. **The bar generates zero hype** (1.0 flat all night) and moves vibe by ~2. + So a bartender's pay is ~wage while a doorgirl working the carpet banks a + hype bonus worth up to +$180. The roster's wage column says bartender $160 + vs door $180, but the real gap is much wider. Either the bar wants a hype + channel (a well-poured busy bar SHOULD sell the room) or the wage table + should acknowledge it. +2. **The RSA dilemma only presents ~3 times a night** (3.2 breach-stage orders + per PUBLICAN night) and dedupe caps it at ONE strike, so reckless serving + costs 0.8 strikes/night. Design §6 calls RSA the bartender's *signature* + tension; right now it is a coin flip. Options: raise `BREACH_SEEN_CHANCE`, + or let the inspector's presence in the room modify it. diff --git a/src/scenes/door/QueueManager.ts b/src/scenes/door/QueueManager.ts index 2b5759e..75435fd 100644 --- a/src/scenes/door/QueueManager.ts +++ b/src/scenes/door/QueueManager.ts @@ -77,6 +77,16 @@ export const CARPET_TUNING = { slots: 3, /** hype per real second per body on display */ hypePerSecPerBody: 0.02, + /** + * Hype the trickle can earn PER STAND — the same guard queue theatre has + * (QUEUE_TUNING.hypePerWaitCap), for the same reason. Uncapped, a bot that + * kept three tolerant patrons on the velvet all night rode hype to the x3 + * ceiling, and since Meters scales vibe GAINS by hype, vibe then pinned: + * post-11PM pinned share went 0.27 -> 0.71 against a documented band of + * <0.5 (tests/sim/floorEconomy). The stand's real payoff is the entrance + * dividend below; this trickle is the flavour, so it is the part that caps. + */ + hypePerStandCap: 0.15, /** the entrance: admitting off the carpet pays hype scaled by the stand */ entranceHypePerSec: 0.012, entranceHypeCap: 0.6, @@ -104,6 +114,8 @@ export interface CarpetSlot { patron: Patron; standMs: number; patienceMs: number; + /** Trickle hype already paid for this stand (capped — see the tuning note). */ + hypeEarned: number; } export interface QueueSnapshot { @@ -316,7 +328,16 @@ export class QueueManager { private tickCarpet(deltaMs: number): void { if (this.carpet.length === 0) return; const sec = deltaMs / 1000; - this.bus.emit('meters:delta', { hype: CARPET_TUNING.hypePerSecPerBody * this.carpet.length * sec }); + // Per-slot accounting: each stand pays the trickle only up to its cap, so + // three tolerant patrons parked all night can no longer print hype. + let hype = 0; + for (const slot of this.carpet) { + const room = Math.max(0, CARPET_TUNING.hypePerStandCap - slot.hypeEarned); + const earned = Math.min(room, CARPET_TUNING.hypePerSecPerBody * sec); + slot.hypeEarned += earned; + hype += earned; + } + if (hype > 0) this.bus.emit('meters:delta', { hype }); for (let i = this.carpet.length - 1; i >= 0; i--) { const slot = this.carpet[i]!; slot.standMs += deltaMs; @@ -352,6 +373,7 @@ export class QueueManager { patron: p, standMs: 0, patienceMs: this.carpetRng.int(lo, hi) * scale * 1000, + hypeEarned: 0, }); return p; } diff --git a/tests/queueManager.test.ts b/tests/queueManager.test.ts index d8a2027..302887f 100644 --- a/tests/queueManager.test.ts +++ b/tests/queueManager.test.ts @@ -331,6 +331,20 @@ describe('the red carpet', () => { ); }); + it('the trickle caps per stand — you cannot park one body and print hype', () => { + // Uncapped, a bot parking three tolerant patrons all night rode hype to the + // x3 ceiling and pinned vibe with it (tests/sim/floorEconomy found this). + const { q, total } = carpetSetup(); + q.callNext(); + q.toCarpet(); + const before = total('hype'); + // Far longer than the cap can pay for, but inside the shortest patience. + for (let i = 0; i < 25; i++) q.update(1000); + const carpetHype = total('hype') - before; + // Queue theatre keeps paying its own capped share, so this is an upper bound. + expect(carpetHype).toBeLessThan(CARPET_TUNING.hypePerStandCap + QUEUE_TUNING.hypePerWaitCap + 0.01); + }); + it('patience runs out: a storm-off, aggro, and the slot comes back', () => { const { q, total } = carpetSetup(); const p = q.callNext()!; diff --git a/tests/sim/economy.ts b/tests/sim/economy.ts index 7276fe6..fe608c6 100644 --- a/tests/sim/economy.ts +++ b/tests/sim/economy.ts @@ -8,7 +8,7 @@ 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 { 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'; @@ -23,6 +23,13 @@ export interface BotPolicy { /** 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 { @@ -49,6 +56,12 @@ export interface NightMetrics { 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; @@ -100,7 +113,12 @@ export function simulateNight( 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 }) => { @@ -156,6 +174,7 @@ export function simulateNight( lastSample = clockMin; metrics.vibeSamples.push(state.vibe); metrics.aggroSamples.push(state.aggro); + hypeSamples.push(state.hype); } }); @@ -185,6 +204,12 @@ export function simulateNight( 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); } @@ -195,6 +220,7 @@ export function simulateNight( clock.start(); let slamTicks = 0; let slamQueueSum = 0; + const parked = new Set(); const totalMs = DEFAULT_CLOCK.nightRealMinutes * 60_000; for (let t = 0; t < totalMs && !ended; t += TICK_MS) { @@ -208,15 +234,35 @@ export function simulateNight( 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) { - applyVerdict(up, policy.decide(up, state.clockMin, clock.nightDate, state.capacity.inside, state.capacity.licensed)); + // 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)) { @@ -235,6 +281,10 @@ export function simulateNight( 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; @@ -282,3 +332,34 @@ export const DAWDLER: BotPolicy = { 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, +}; diff --git a/tests/sim/floorEconomy.test.ts b/tests/sim/floorEconomy.test.ts new file mode 100644 index 0000000..7974228 --- /dev/null +++ b/tests/sim/floorEconomy.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; +import { BUTTERFINGERS, PUBLICAN, RSA_STRAIGHT, simulateBarShift, type BarMetrics } from './floorShift'; +import { CAREFUL, GREEDY, SHOWPONY, simulateNight, type NightMetrics } from './economy'; + +// The floor economy, as numbers — the twin of economy.test.ts, which only ever +// measured doorgirl nights. Eleven floor systems shipped in a day against zero +// bot coverage; this is the contract they answer to now. +// +// What the first run of this file found, for the record: +// - the red carpet was an uncapped hype ratchet. Three tolerant patrons parked +// all night rode hype to the x3 ceiling, and since Meters scales vibe GAINS +// by hype, post-11PM vibe-pin went 0.27 -> 0.71 against a documented <0.5. +// Fixed by CARPET_TUNING.hypePerStandCap (the guard queue theatre already +// had). The rest of the carpet's shape turned out sound — see 'the fuse'. + +const SEEDS = [4207, 4208, 4209, 7, 99]; +const avg = (xs: number[]): number => xs.reduce((a, b) => a + b, 0) / xs.length; +const sum = (xs: number[]): number => xs.reduce((a, b) => a + b, 0); + +const bar = (p: typeof RSA_STRAIGHT): BarMetrics[] => SEEDS.map((s) => simulateBarShift(s, p)); +const door = (p: typeof CAREFUL): NightMetrics[] => SEEDS.map((s) => simulateNight(s, p)); + +const sumBar = (runs: BarMetrics[]): string => + `${runs[0]!.policy}: end=${runs.map((r) => `${r.endReason}@${r.endClockMin}`).join(',')} ` + + `orders=${avg(runs.map((r) => r.orders)).toFixed(0)} serve=${avg(runs.map((r) => r.serves)).toFixed(0)} ` + + `water=${avg(runs.map((r) => r.waters)).toFixed(0)} cut=${avg(runs.map((r) => r.cutoffs)).toFixed(0)} ` + + `breach=${avg(runs.map((r) => r.breaches)).toFixed(1)} strikes=${avg(runs.map((r) => r.heatStrikes)).toFixed(1)} ` + + `short=${avg(runs.map((r) => r.shortPours)).toFixed(0)} over=${avg(runs.map((r) => r.overPours)).toFixed(0)} ` + + `tabs=${avg(runs.map((r) => r.tabsOpened)).toFixed(1)} vibeMin=${avg(runs.map((r) => r.vibeMin)).toFixed(0)} ` + + `vibePin=${avg(runs.map((r) => r.vibePinnedShare)).toFixed(2)} aggroMax=${avg(runs.map((r) => r.aggroMax)).toFixed(0)} ` + + `hype=${avg(runs.map((r) => r.hypePeak)).toFixed(1)} crowd=${avg(runs.map((r) => r.crowdAvg)).toFixed(0)}`; + +const sumDoor = (runs: NightMetrics[]): string => + `${runs[0]!.policy}: end=${runs.map((r) => `${r.endReason}@${r.endClockMin}`).join(',')} ` + + `admit=${avg(runs.map((r) => r.admitted)).toFixed(0)} parked=${avg(runs.map((r) => r.carpetParked)).toFixed(1)} ` + + `storms=${avg(runs.map((r) => r.carpetStorms)).toFixed(1)} entrances=${avg(runs.map((r) => r.carpetEntrances)).toFixed(1)} ` + + `hype=${avg(runs.map((r) => r.hypePeak)).toFixed(2)} hypePin=${avg(runs.map((r) => r.hypePinnedShare)).toFixed(2)} ` + + `vibePin=${avg(runs.map((r) => r.vibePinnedShare)).toFixed(2)} aggroMax=${avg(runs.map((r) => r.aggroMax)).toFixed(0)}`; + +describe('the red carpet (bot nights, 5 seeds)', () => { + const careful = door(CAREFUL); + const showpony = door(SHOWPONY); + const greedy = door(GREEDY); + + it('prints the current shape (baseline visibility)', () => { + console.log('\n' + [careful, showpony, greedy].map(sumDoor).join('\n')); + expect(showpony[0]!.carpetParked).toBeGreaterThan(0); + }); + + it('theatre pays — working the velvet beats not working it', () => { + expect(avg(showpony.map((r) => r.hypePeak))).toBeGreaterThan(avg(careful.map((r) => r.hypePeak)) + 0.3); + }); + + it('but hype NEVER pins at the ceiling — the ratchet stays dead', () => { + // THE regression guard. This is the band the carpet broke on arrival: hype + // is a pressure, not a currency you accumulate until the night stops caring. + for (const runs of [careful, showpony, greedy]) { + expect(avg(runs.map((r) => r.hypePinnedShare)), `${runs[0]!.policy} pinned hype`).toBeLessThan(0.02); + } + }); + + it('vibe survives the carpet — a hyped room is not a dead meter', () => { + // Skilled carpet play legitimately buys a better room than careful play, + // so this band sits above the door's 0.5 — but the meter must still move. + expect(avg(showpony.map((r) => r.vibePinnedShare))).toBeLessThan(0.65); + expect(Math.min(...showpony.map((r) => r.vibeMin))).toBeLessThan(60); + }); + + it('the fuse is real — hold them to the limit and they walk', () => { + // Parking is only a gamble if the walk-off actually arrives. GREEDY holds + // every archetype near its patience; most of them leave. + expect(sum(greedy.map((r) => r.carpetStorms))).toBeGreaterThan(0); + expect(avg(greedy.map((r) => r.carpetStorms))).toBeGreaterThan(avg(greedy.map((r) => r.carpetEntrances))); + }); + + it('and greed is paid for in aggro and in throughput', () => { + expect(avg(greedy.map((r) => r.aggroMax))).toBeGreaterThan(avg(careful.map((r) => r.aggroMax)) * 1.5); + // The rope is busy being theatre: fewer people actually get through it. + expect(avg(greedy.map((r) => r.admitted))).toBeLessThan(avg(showpony.map((r) => r.admitted))); + }); + + it('reading the archetype is the skill — patience is not uniform', () => { + // SHOWPONY parks only the tolerant and recalls inside their fuse: no storms. + // GREEDY parks anyone and holds: many. Same verb, different reading. + expect(sum(showpony.map((r) => r.carpetStorms))).toBe(0); + }); +}); + +describe('the bar economy (bot shifts, 5 seeds)', () => { + const straight = bar(RSA_STRAIGHT); + const publican = bar(PUBLICAN); + const butter = bar(BUTTERFINGERS); + + it('prints the current shape (baseline visibility)', () => { + console.log('\n' + [straight, publican, butter].map(sumBar).join('\n')); + expect(straight[0]!.orders).toBeGreaterThan(0); + }); + + it('the legal shift survives the night, every seed', () => { + for (const r of straight) expect(r.endReason).toBe('clock'); + }); + + it('and is never punished for it — playing RSA straight earns no heat', () => { + // If the lawful bartender collected strikes, the rule would be a trap + // rather than a tension. Cutting people off has to be SAFE, just costly. + expect(sum(straight.map((r) => r.heatStrikes))).toBe(0); + expect(sum(straight.map((r) => r.breaches))).toBe(0); + }); + + it('serving the cooked is a real risk — the RSA line has teeth', () => { + expect(sum(publican.map((r) => r.breaches))).toBeGreaterThan(0); + expect(sum(publican.map((r) => r.heatStrikes))).toBeGreaterThan(0); + expect(avg(publican.map((r) => r.heatStrikes))).toBeGreaterThan(avg(straight.map((r) => r.heatStrikes))); + }); + + it('the pour is a skill that costs you — a bad hand is felt in the room', () => { + // Same legal calls, worse hands: short pours land and the room resents it. + expect(avg(butter.map((r) => r.shortPours))).toBeGreaterThan(avg(straight.map((r) => r.shortPours)) * 2); + expect(avg(butter.map((r) => r.aggroMax))).toBeGreaterThan(avg(straight.map((r) => r.aggroMax)) * 1.4); + }); + + it('the bar is not a vibe printer', () => { + // A night of pouring must not pin the room the way the old door did. + for (const runs of [straight, publican, butter]) { + expect(avg(runs.map((r) => r.vibePinnedShare)), `${runs[0]!.policy} pinned vibe`).toBeLessThan(0.3); + } + }); + + it('the room actually fills — the shift has customers to get wrong', () => { + // Guards the harness itself: a bar sim serving an empty room proves nothing. + expect(avg(straight.map((r) => r.crowdAvg))).toBeGreaterThan(10); + expect(avg(straight.map((r) => r.orders))).toBeGreaterThan(15); + }); +}); diff --git a/tests/sim/floorShift.ts b/tests/sim/floorShift.ts new file mode 100644 index 0000000..e44c769 --- /dev/null +++ b/tests/sim/floorShift.ts @@ -0,0 +1,259 @@ +// 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, +};