not-tonight/tests/sim/economy.test.ts
2026-07-20 05:35:47 +10:00

78 lines
3.7 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { CAREFUL, DAWDLER, SLOPPY, simulateNight, type NightMetrics } from './economy';
// 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', () => {
const a = avg(careful.map((r) => r.admitted));
const d = avg(careful.map((r) => r.denied));
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', () => {
const peaks = careful.map((r) => r.aggroMax);
expect(avg(peaks)).toBeGreaterThan(15);
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);
});
});