Floor-economy sim: bot nights for the carpet and the bar — caught the carpet's uncapped hype ratchet (vibe-pin 0.27 to 0.71) and capped the trickle

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-25 21:02:27 +10:00
parent 7af3732f39
commit 754f2b4767
6 changed files with 567 additions and 3 deletions

View File

@ -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.

View File

@ -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;
}

View File

@ -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()!;

View File

@ -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<string>();
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,
};

View File

@ -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);
});
});

259
tests/sim/floorShift.ts Normal file
View File

@ -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<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,
};