164 lines
6.4 KiB
TypeScript
164 lines
6.4 KiB
TypeScript
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);
|
||
}
|
||
});
|
||
});
|