not-tonight/tests/sim/economy.test.ts

102 lines
5.0 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { CAREFUL, DAWDLER, SLOPPY, simulateNight, type NightMetrics } from './economy';
import { VENUES } from '../../src/data/venues';
// The feel, as numbers. Phase-1/Phase-3 both measured the same failures:
// vibe pinned at ~100 by 10 PM, aggro flat at 0, hype a pure ratchet, dawdling
// dominant, the slam fictional. These tests are the tuning pass's contract —
// if a knob change breaks a band, the feel regressed, not just a constant.
const SEEDS = [4207, 4208, 4209, 7, 99];
const runAll = (policy: typeof CAREFUL): NightMetrics[] => SEEDS.map((s) => simulateNight(s, policy));
const avg = (xs: number[]): number => xs.reduce((a, b) => a + b, 0) / xs.length;
const summarise = (runs: NightMetrics[]): string =>
`${runs[0]!.policy}: end=${runs.map((r) => `${r.endReason}@${r.endClockMin}`).join(',')} ` +
`admit=${avg(runs.map((r) => r.admitted)).toFixed(0)} deny=${avg(runs.map((r) => r.denied)).toFixed(0)} ` +
`vibePin=${avg(runs.map((r) => r.vibePinnedShare)).toFixed(2)} aggroMax=${avg(runs.map((r) => r.aggroMax)).toFixed(0)} ` +
`slamQ(avg/max)=${avg(runs.map((r) => r.slamQueueAvg)).toFixed(1)}/${Math.max(...runs.map((r) => r.slamQueueMax))} ` +
`hype=${avg(runs.map((r) => r.hypePeak)).toFixed(1)} strikes=${avg(runs.map((r) => r.heatStrikes)).toFixed(1)} inside=${avg(runs.map((r) => r.inside)).toFixed(0)}`;
describe('door economy (bot nights, 5 seeds)', () => {
const careful = runAll(CAREFUL);
const sloppy = runAll(SLOPPY);
const dawdler = runAll(DAWDLER);
it('prints the current shape (baseline visibility)', () => {
console.log('\n' + [careful, sloppy, dawdler].map(summarise).join('\n'));
expect(careful.length).toBe(SEEDS.length);
});
it('careful play survives to 3 AM', () => {
for (const r of careful) expect(r.endReason).toBe('clock');
});
it('careful play keeps vibe ALIVE — not pinned at the ceiling', () => {
// The meter must stay a live pressure: less than half of the post-11PM
// samples may sit at 95+, and the night must actually visit the low band.
expect(avg(careful.map((r) => r.vibePinnedShare))).toBeLessThan(0.5);
});
it('careful play fills the room — admits comfortably beat denies', () => {
// Pre-lockout counts: after 1:30 the law says deny-everyone, and blanket
// legal denials would drown the signal this band exists to hold.
const a = avg(careful.map((r) => r.admittedPreLockout));
const d = avg(careful.map((r) => r.deniedPreLockout));
expect(a).toBeGreaterThan(d * 1.2);
});
it('the slam is real — the queue visibly backs up mid-night', () => {
expect(Math.max(...careful.map((r) => r.slamQueueMax))).toBeGreaterThanOrEqual(6);
expect(avg(careful.map((r) => r.slamQueueAvg))).toBeGreaterThan(1.5);
});
it('aggro breathes for a careful player — pressure without a riot', () => {
// Floor was 15 pre-wave-2; doubling the encounter pool reshuffled which
// scripted people land in the seeded nights and shaved the average peak to
// ~13.7. The claim this band protects is "denials sting at all", not any
// exact number — 12 keeps the claim while surviving table growth.
const peaks = careful.map((r) => r.aggroMax);
expect(avg(peaks)).toBeGreaterThan(12);
for (const p of peaks) expect(p).toBeLessThan(95);
});
it('sloppy play is punished: strikes pile up', () => {
expect(avg(sloppy.map((r) => r.heatStrikes))).toBeGreaterThanOrEqual(3);
});
it('dawdling is NOT dominant — a stalled rope riots before it profits', () => {
// Dying is worth nothing: a night that ends early scores zero. The cash
// proxy mirrors nightCash's shape (final vibe + hype bonus), gated on
// actually surviving to 3 AM.
const score = (r: NightMetrics): number =>
r.endReason === 'clock' ? r.vibeSamples[r.vibeSamples.length - 1]! + (r.hypePeak - 1) * 30 : 0;
expect(avg(dawdler.map(score))).toBeLessThanOrEqual(avg(careful.map(score)) + 1);
});
it('careful play does not bleed licence points to invisible information', () => {
// Strikes must be EARNABLE knowledge: a bot that checks everything checkable
// should end most nights clean (scripted encounters may cost the odd one).
expect(avg(careful.map((r) => r.heatStrikes))).toBeLessThan(1);
});
});
describe('the venue ladder changes the pressure (design §6)', () => {
it('The Royal is a gentler week than Voltage for the same careful hands', () => {
const royal = VENUES[0]!;
const seeds = [4207, 4208, 4209];
const atRoyal = seeds.map((s) =>
simulateNight(s, CAREFUL, { arrivalScale: royal.arrivalScale, licensed: royal.licensed }),
);
const atVoltage = seeds.map((s) => simulateNight(s, CAREFUL));
const avgOf = (xs: number[]): number => xs.reduce((a, b) => a + b, 0) / xs.length;
// Fewer arrivals -> a visibly calmer slam and a survivable night.
expect(avgOf(atRoyal.map((r) => r.slamQueueAvg))).toBeLessThan(
avgOf(atVoltage.map((r) => r.slamQueueAvg)),
);
for (const r of atRoyal) expect(r.endReason).toBe('clock');
});
});