not-tonight/tests/arrivalCurve.test.ts
type-two 30162d331d LANE-DOOR: the full v0.1 Door night — queue, ID minigame, dress code, stamps, summary
Playable start to finish: 9 PM to 3 AM, patrons arrive on a curve, you work the
rope, inspect them, and rule. Dazza texts escalate the dress code through the
night, deferred consequences land when you can no longer argue, and the night
ends in a summary or one of two fail states.

Rules engine (src/rules/, pure, no Phaser):
- dressCode: 8 rules whose text/activeFrom are read out of data/strings/dazza.ts
  by ruleId, with a load-time throw if a rule and its announcing text disagree
- idCheck: all ID date maths, built on core/GameClock's ageOn
- judge: the single scoring function, every magnitude named in JUDGE_TUNING
- sobriety: challenge content and the drunk-performance model

Scene layer (src/scenes/door/): NightScene shell, DoorScene, pure QueueManager +
arrivalCurve, patron-up inspection view, ID card, phone, dress-code card,
sobriety modal, stamp, summary.

Tests 49 -> 229.

Ten bugs found by playing it rather than by testing it, including one patron
being ruled eleven times (state was mutating inside a tween callback; it now
settles synchronously), a correctly-played night still rioting at 1:37 AM, and
28% of patrons being deniable for a logo the renderer never drew.

Contract change requests CCR-1..3 are in LANEHANDOVER.md. src/data/types.ts,
src/core/ and docs/ are byte-identical to main; the two additive extensions are
declare-module augmentations. src/main.ts is the one file touched outside the
lane (8 lines, to boot into the night) and is flagged for the reviewer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:49:27 +10:00

164 lines
6.3 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 6090 patron design band', () => {
const total = expectedTotalArrivals();
expect(total).toBeGreaterThanOrEqual(60);
expect(total).toBeLessThanOrEqual(90);
});
});
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(45);
expect(arrivals, `seed ${seed}`).toBeLessThanOrEqual(110);
}
});
});