import { describe, expect, it } from 'vitest'; import { SeededRNG } from '../src/core/SeededRNG'; import { ARRIVAL_CURVE, arrivalsPerMin, expectedTotalArrivals, nextArrivalGapMin, } from '../src/scenes/door/arrivalCurve'; // Two DIFFERENT private values in arrivalCurve.ts, kept apart on purpose: // - the dead-rate sentinel, returned only when the rate is 0; // - the real gap cap, the most in-game minutes one live roll may ever burn. // Asserting only against the sentinel would let the gap cap drift to anything // under 999 unnoticed, which is exactly the bug the cap exists to prevent. const DEAD_SENTINEL = 999; /** A live gap must never eat more than a sixth of the 360-minute night. */ const MAX_LIVE_GAP = 60; const sweep = (): number[] => Array.from({ length: 361 }, (_, i) => arrivalsPerMin(i)); const peak = (): number => Math.max(...sweep()); describe('ARRIVAL_CURVE', () => { it('is sorted, spans the whole night, and never goes negative', () => { expect(ARRIVAL_CURVE[0]?.clockMin).toBe(0); expect(ARRIVAL_CURVE[ARRIVAL_CURVE.length - 1]?.clockMin).toBe(360); for (let i = 1; i < ARRIVAL_CURVE.length; i++) { expect(ARRIVAL_CURVE[i]!.clockMin).toBeGreaterThan(ARRIVAL_CURVE[i - 1]!.clockMin); } for (const p of ARRIVAL_CURVE) expect(p.perMin).toBeGreaterThanOrEqual(0); }); }); describe('arrivalsPerMin', () => { it('interpolates strictly between adjacent control points', () => { const at90 = arrivalsPerMin(90); const at150 = arrivalsPerMin(150); const mid = arrivalsPerMin(120); expect(at150).toBeGreaterThan(at90); expect(mid).toBeGreaterThan(at90); expect(mid).toBeLessThan(at150); }); it('clamps flat outside 0..360', () => { expect(arrivalsPerMin(-50)).toBe(ARRIVAL_CURVE[0]!.perMin); expect(arrivalsPerMin(0)).toBe(ARRIVAL_CURVE[0]!.perMin); const lastPerMin = ARRIVAL_CURVE[ARRIVAL_CURVE.length - 1]!.perMin; expect(arrivalsPerMin(400)).toBe(lastPerMin); expect(arrivalsPerMin(360)).toBe(lastPerMin); }); it('peaks inside the 10:30–12:30 slam window', () => { const values = sweep(); const peakIndex = values.indexOf(Math.max(...values)); expect(peakIndex).toBeGreaterThanOrEqual(90); expect(peakIndex).toBeLessThanOrEqual(210); expect(peakIndex).toBe(150); }); it('9 PM is quiet — under a third of peak', () => { expect(arrivalsPerMin(0)).toBeLessThan(peak() / 3); }); it('2:30 AM has tapered hard — under a quarter of peak', () => { expect(arrivalsPerMin(330)).toBeLessThan(peak() / 4); }); it('is never negative and never NaN, including for junk input', () => { for (let m = -60; m <= 420; m++) { const v = arrivalsPerMin(m); expect(Number.isNaN(v)).toBe(false); expect(v).toBeGreaterThanOrEqual(0); } for (const junk of [NaN, Infinity, -Infinity]) { const v = arrivalsPerMin(junk); expect(Number.isNaN(v)).toBe(false); expect(v).toBeGreaterThanOrEqual(0); } }); }); describe('expectedTotalArrivals', () => { it('lands in the 95-130 patron design band (tuning 2026-07-19: the slam must outrun the door)', () => { const total = expectedTotalArrivals(); expect(total).toBeGreaterThanOrEqual(95); expect(total).toBeLessThanOrEqual(130); }); }); describe('nextArrivalGapMin', () => { it('is finite, positive and capped across 2000 (clockMin, roll) pairs', () => { const rolls = [0, 0.5, 0.9999999]; for (const roll of rolls) { for (let m = 0; m <= 360; m++) { const gap = nextArrivalGapMin(m, roll); expect(Number.isFinite(gap)).toBe(true); expect(gap).toBeGreaterThan(0); expect(gap).toBeLessThanOrEqual(DEAD_SENTINEL); } } for (let i = 0; i < 920; i++) { const gap = nextArrivalGapMin((i * 7) % 361, i / 920); expect(Number.isFinite(gap)).toBe(true); expect(gap).toBeGreaterThan(0); expect(gap).toBeLessThanOrEqual(DEAD_SENTINEL); } }); it('no single unlucky roll can swallow the night', () => { // The spec's cap bullet. Sweep the LIVE window (rate > 0) at rolls pressed // right up against 1 — the worst draw the exponential can produce — and // demand the cap bites. Without this, the cap could be raised to any value // under the dead-rate sentinel and every other test would still pass. let worst = 0; for (const roll of [0.99, 0.999, 0.999999, 0.9999999, 1, 2]) { for (let m = 0; m <= 359; m++) { const gap = nextArrivalGapMin(m, roll); if (gap === DEAD_SENTINEL) continue; // dead rate, not a live draw expect(Number.isFinite(gap)).toBe(true); worst = Math.max(worst, gap); } } expect(worst).toBeGreaterThan(0); expect(worst).toBeLessThanOrEqual(MAX_LIVE_GAP); }); it('gaps are shorter at peak than at 9 PM for the same roll', () => { // Non-strict across the whole roll range: the sub-minute floor that stops // two patrons landing on one frame also floors BOTH ends for tiny rolls, // so strict inequality is genuinely false down there. Assert what is true. for (let i = 0; i <= 100; i++) { const roll = i / 101; expect(nextArrivalGapMin(150, roll)).toBeLessThanOrEqual(nextArrivalGapMin(0, roll)); } // Strict wherever the floor is not binding, which is the range that matters. for (const roll of [0.05, 0.1, 0.5, 0.9, 0.99]) { expect(nextArrivalGapMin(150, roll)).toBeLessThan(nextArrivalGapMin(0, roll)); } }); it('a dead rate returns a large finite gap rather than Infinity', () => { // 3 AM: the curve is zero, so the maths would divide by zero if unguarded. expect(arrivalsPerMin(360)).toBe(0); const gap = nextArrivalGapMin(360, 0.5); expect(Number.isFinite(gap)).toBe(true); expect(gap).toBe(DEAD_SENTINEL); }); it('a simulated night delivers a plausible crowd, on every seed', () => { // Twelve seeds, not one: a single seed proves the seed was lucky, not that // the curve is tuned. Observed across 200 seeds: min 49, mean 71, max 93. for (const seed of [1, 7, 42, 99, 404, 1312, 2600, 4207, 7777, 9001, 31337, 65535]) { const rng = new SeededRNG(seed).stream('arrivals'); let clock = 0; let arrivals = 0; while (clock < 360) { clock += nextArrivalGapMin(clock, rng.next()); if (clock < 360) arrivals++; } expect(arrivals, `seed ${seed}`).toBeGreaterThanOrEqual(75); expect(arrivals, `seed ${seed}`).toBeLessThanOrEqual(150); } }); });