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:
parent
7af3732f39
commit
754f2b4767
@ -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.
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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()!;
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
134
tests/sim/floorEconomy.test.ts
Normal file
134
tests/sim/floorEconomy.test.ts
Normal 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
259
tests/sim/floorShift.ts
Normal 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,
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user