135 lines
7.3 KiB
TypeScript
135 lines
7.3 KiB
TypeScript
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);
|
|
});
|
|
});
|