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>
280 lines
9.9 KiB
TypeScript
280 lines
9.9 KiB
TypeScript
import { beforeEach, describe, expect, it } from 'vitest';
|
|
import { SeededRNG } from '../src/core/SeededRNG';
|
|
import { idDisplayLines, idVerdict, type IdTell } from '../src/rules/idCheck';
|
|
import { generatePatron, _resetPatronSerial, type GeneratorContext } from '../src/patrons/generator';
|
|
import type { IdCard, Patron } from '../src/data/types';
|
|
|
|
const NIGHT = new Date('2026-07-18T00:00:00');
|
|
|
|
const card = (over: Partial<IdCard> = {}): IdCard => ({
|
|
name: 'Shazza Nguyen',
|
|
dob: '2000-06-01',
|
|
expiry: '2030-06-01',
|
|
photoSeed: 1,
|
|
...over,
|
|
});
|
|
|
|
const on = (iso: string): Date => new Date(`${iso}T00:00:00`);
|
|
|
|
describe('idVerdict — birthday boundary', () => {
|
|
it('18th birthday is tonight: 18, not underage, flagged', () => {
|
|
const v = idVerdict(card({ dob: '2008-07-18' }), NIGHT);
|
|
expect(v.claimedAge).toBe(18);
|
|
expect(v.underage).toBe(false);
|
|
expect(v.turnsEighteenTonight).toBe(true);
|
|
});
|
|
|
|
it('18th birthday is tomorrow: still 17, underage', () => {
|
|
const v = idVerdict(card({ dob: '2008-07-19' }), NIGHT);
|
|
expect(v.claimedAge).toBe(17);
|
|
expect(v.underage).toBe(true);
|
|
expect(v.turnsEighteenTonight).toBe(false);
|
|
});
|
|
|
|
it('18th birthday was yesterday: 18, no longer the birthday', () => {
|
|
const v = idVerdict(card({ dob: '2008-07-17' }), NIGHT);
|
|
expect(v.claimedAge).toBe(18);
|
|
expect(v.turnsEighteenTonight).toBe(false);
|
|
});
|
|
|
|
it('turnsEighteenTonight only ever fires at exactly 18', () => {
|
|
expect(idVerdict(card({ dob: '2007-07-18' }), NIGHT).turnsEighteenTonight).toBe(false);
|
|
expect(idVerdict(card({ dob: '2009-07-18' }), NIGHT).turnsEighteenTonight).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('idVerdict — leap day DOB', () => {
|
|
it('29 Feb DOB is still 17 on 28 Feb of a non-leap year', () => {
|
|
const v = idVerdict(card({ dob: '2008-02-29' }), on('2026-02-28'));
|
|
expect(v.claimedAge).toBe(17);
|
|
expect(v.underage).toBe(true);
|
|
});
|
|
|
|
it('29 Feb DOB turns 18 on 1 March of a non-leap year', () => {
|
|
const v = idVerdict(card({ dob: '2008-02-29' }), on('2026-03-01'));
|
|
expect(v.claimedAge).toBe(18);
|
|
expect(v.underage).toBe(false);
|
|
// Deliberate: leap babies get the flag on the day the age math grants it,
|
|
// rather than never getting it in a non-leap year.
|
|
expect(v.turnsEighteenTonight).toBe(true);
|
|
});
|
|
|
|
// NB: a leap baby's 18th birthday can never fall on a real 29 Feb (leap years
|
|
// are 4 apart, 18 is not a multiple of 4), so the interesting leap-night case
|
|
// is a 28 Feb DOB the day AFTER its birthday.
|
|
it('28 Feb DOB on a 29 Feb night: birthday was yesterday', () => {
|
|
const v = idVerdict(card({ dob: '2010-02-28' }), on('2028-02-29'));
|
|
expect(v.claimedAge).toBe(18);
|
|
expect(v.turnsEighteenTonight).toBe(false);
|
|
});
|
|
|
|
it('29 Feb DOB evaluated on a real 29 Feb is exactly on its birthday', () => {
|
|
const v = idVerdict(card({ dob: '2008-02-29' }), on('2028-02-29'));
|
|
expect(v.claimedAge).toBe(20);
|
|
expect(v.underage).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('idVerdict — expiry boundary', () => {
|
|
it('expiry yesterday is expired', () => {
|
|
const v = idVerdict(card({ expiry: '2026-07-17' }), NIGHT);
|
|
expect(v.expired).toBe(true);
|
|
expect(v.expiresTonight).toBe(false);
|
|
expect(v.tells).toEqual(['expired']);
|
|
});
|
|
|
|
it('expiry tonight is valid — the trap', () => {
|
|
const v = idVerdict(card({ expiry: '2026-07-18' }), NIGHT);
|
|
expect(v.expired).toBe(false);
|
|
expect(v.expiresTonight).toBe(true);
|
|
expect(v.tells).toEqual([]);
|
|
expect(v.looksFake).toBe(false);
|
|
});
|
|
|
|
it('expiry tomorrow is valid and unremarkable', () => {
|
|
const v = idVerdict(card({ expiry: '2026-07-19' }), NIGHT);
|
|
expect(v.expired).toBe(false);
|
|
expect(v.expiresTonight).toBe(false);
|
|
});
|
|
|
|
it('a nightDate carrying a time of day still compares day-to-day', () => {
|
|
const v = idVerdict(card({ expiry: '2026-07-18' }), new Date('2026-07-18T02:30:00'));
|
|
expect(v.expired).toBe(false);
|
|
expect(v.expiresTonight).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('idVerdict — tells', () => {
|
|
it('a single visible tell comes back alone', () => {
|
|
const v = idVerdict(card({ fake: { peelingLaminate: true } }), NIGHT);
|
|
expect(v.tells).toEqual(['peelingLaminate']);
|
|
expect(v.looksFake).toBe(true);
|
|
});
|
|
|
|
it('a clean card with a future expiry has no tells', () => {
|
|
const v = idVerdict(card(), NIGHT);
|
|
expect(v.tells).toEqual([]);
|
|
expect(v.looksFake).toBe(false);
|
|
});
|
|
|
|
it('no fake object but a past expiry is still catchable', () => {
|
|
const v = idVerdict(card({ expiry: '2024-01-01' }), NIGHT);
|
|
expect(v.tells).toEqual(['expired']);
|
|
expect(v.looksFake).toBe(true);
|
|
});
|
|
|
|
it('all tells come back in fixed reading order', () => {
|
|
const v = idVerdict(
|
|
card({
|
|
expiry: '2020-05-05',
|
|
fake: { photoMismatch: true, jokeName: true, wrongHologram: true, peelingLaminate: true },
|
|
}),
|
|
NIGHT,
|
|
);
|
|
expect(v.tells).toEqual([
|
|
'peelingLaminate',
|
|
'wrongHologram',
|
|
'jokeName',
|
|
'photoMismatch',
|
|
'expired',
|
|
]);
|
|
});
|
|
|
|
it('subsets keep that order regardless of how the flags were written', () => {
|
|
const v = idVerdict(card({ fake: { photoMismatch: true, wrongHologram: true } }), NIGHT);
|
|
expect(v.tells).toEqual(['wrongHologram', 'photoMismatch']);
|
|
});
|
|
|
|
it('an empty fake object alone is not a tell', () => {
|
|
const v = idVerdict(card({ fake: {} }), NIGHT);
|
|
expect(v.tells).toEqual([]);
|
|
expect(v.looksFake).toBe(false);
|
|
});
|
|
|
|
// Guards against a later "if underage, skip the tell scan" shortcut: the two
|
|
// are independent readings of the same card and must both survive.
|
|
it('a card that both claims underage and has expired reports both', () => {
|
|
const v = idVerdict(card({ dob: '2009-07-19', expiry: '2025-01-01' }), NIGHT);
|
|
expect(v.claimedAge).toBe(16);
|
|
expect(v.underage).toBe(true);
|
|
expect(v.expired).toBe(true);
|
|
expect(v.tells).toEqual(['expired']);
|
|
expect(v.looksFake).toBe(true);
|
|
});
|
|
|
|
it('TELL_ORDER covers every IdTell — no tell can be silently unreportable', () => {
|
|
const everyTell: IdTell[] = [
|
|
'peelingLaminate',
|
|
'wrongHologram',
|
|
'jokeName',
|
|
'photoMismatch',
|
|
'expired',
|
|
];
|
|
const v = idVerdict(
|
|
card({
|
|
expiry: '2020-05-05',
|
|
fake: { peelingLaminate: true, wrongHologram: true, jokeName: true, photoMismatch: true },
|
|
}),
|
|
NIGHT,
|
|
);
|
|
expect([...v.tells].sort()).toEqual([...everyTell].sort());
|
|
});
|
|
});
|
|
|
|
describe('idVerdict — calendar edges', () => {
|
|
it('DOB 31 Dec against a 1 Jan night', () => {
|
|
expect(idVerdict(card({ dob: '2008-12-31' }), on('2027-01-01')).claimedAge).toBe(18);
|
|
expect(idVerdict(card({ dob: '2009-01-01' }), on('2027-01-01')).turnsEighteenTonight).toBe(true);
|
|
});
|
|
|
|
it('DOB 31 Jan checked on 28 Feb', () => {
|
|
const v = idVerdict(card({ dob: '2008-01-31' }), on('2026-02-28'));
|
|
expect(v.claimedAge).toBe(18);
|
|
expect(v.turnsEighteenTonight).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('idDisplayLines', () => {
|
|
it('formats DD/MM/YYYY with zero padding', () => {
|
|
const lines = idDisplayLines(card({ dob: '2008-01-05', expiry: '2029-11-09' }), NIGHT);
|
|
expect(lines.dob).toBe('05/01/2008');
|
|
expect(lines.expiry).toBe('09/11/2029');
|
|
expect(lines.name).toBe('Shazza Nguyen');
|
|
expect(lines.age).toBe('18');
|
|
});
|
|
|
|
it('age line agrees with the verdict at the underage boundary', () => {
|
|
const id = card({ dob: '2008-07-19' });
|
|
expect(idDisplayLines(id, NIGHT).age).toBe('17');
|
|
expect(String(idVerdict(id, NIGHT).claimedAge)).toBe('17');
|
|
});
|
|
});
|
|
|
|
describe('integration sweep over generated patrons', () => {
|
|
const SWEEP_NIGHT = new Date('2026-07-18T00:00:00');
|
|
let crowd: Patron[];
|
|
let kids: Patron[];
|
|
|
|
beforeEach(() => {
|
|
_resetPatronSerial();
|
|
const ctx: GeneratorContext = { rng: new SeededRNG(11), nightDate: SWEEP_NIGHT };
|
|
crowd = Array.from({ length: 800 }, (_, i) => generatePatron(ctx, i % 360));
|
|
const kidCtx: GeneratorContext = { rng: new SeededRNG(11), nightDate: SWEEP_NIGHT };
|
|
kids = Array.from({ length: 60 }, () => generatePatron(kidCtx, 0, 'almost18'));
|
|
});
|
|
|
|
it('every fake in the crowd is actually catchable', () => {
|
|
const suspect = crowd.filter(
|
|
(p) => p.idCard.fake || new Date(`${p.idCard.expiry}T00:00:00`) < SWEEP_NIGHT,
|
|
);
|
|
expect(suspect.length).toBeGreaterThan(0);
|
|
for (const p of suspect) {
|
|
expect(idVerdict(p.idCard, SWEEP_NIGHT).looksFake).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('clean cards in the crowd never read as fake', () => {
|
|
const clean = crowd.filter(
|
|
(p) => !p.idCard.fake && new Date(`${p.idCard.expiry}T00:00:00`) >= SWEEP_NIGHT,
|
|
);
|
|
expect(clean.length).toBeGreaterThan(0);
|
|
for (const p of clean) {
|
|
expect(idVerdict(p.idCard, SWEEP_NIGHT).looksFake).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("almost18 cards claim 18+ while the kid is 17 — tells are the only catch", () => {
|
|
expect(kids.length).toBe(60);
|
|
for (const kid of kids) {
|
|
expect(kid.age).toBe(17);
|
|
const v = idVerdict(kid.idCard, SWEEP_NIGHT);
|
|
expect(v.claimedAge).toBeGreaterThanOrEqual(18);
|
|
expect(v.underage).toBe(false);
|
|
expect(v.looksFake).toBe(true);
|
|
}
|
|
});
|
|
|
|
// The override run above is a forced sample; this checks the almost18s that
|
|
// actually turn up in a naturally-weighted crowd, which is what ships.
|
|
it('almost18s occurring naturally in the crowd are also lying and catchable', () => {
|
|
const inCrowd = crowd.filter((p) => p.archetype === 'almost18');
|
|
expect(inCrowd.length).toBeGreaterThan(0);
|
|
for (const kid of inCrowd) {
|
|
expect(kid.age).toBe(17);
|
|
const v = idVerdict(kid.idCard, SWEEP_NIGHT);
|
|
expect(v.claimedAge).toBeGreaterThanOrEqual(18);
|
|
expect(v.underage).toBe(false);
|
|
expect(v.looksFake).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('idVerdict never throws across the whole crowd', () => {
|
|
expect(() => {
|
|
for (const p of [...crowd, ...kids]) {
|
|
idVerdict(p.idCard, SWEEP_NIGHT);
|
|
idDisplayLines(p.idCard, SWEEP_NIGHT);
|
|
}
|
|
}).not.toThrow();
|
|
});
|
|
});
|