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

164 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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:3012: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);
}
});
});