not-tonight/tests/dressCode.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

228 lines
10 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import '../src/rules/doorTypes';
import { SeededRNG } from '../src/core/SeededRNG';
import { DAZZA_TEXTS } from '../src/data/strings/dazza';
import { generatePatron, _resetPatronSerial } from '../src/patrons/generator';
import {
DRESS_CODE_RULES,
activeRules,
ruleById,
violations,
} from '../src/rules/dressCode';
import type { OutfitLayer, OutfitSlot, Patron, PatronFlags } from '../src/data/types';
// Explicit Z: a bare ISO datetime is parsed in local time, so this would other-
// wise be one more machine-dependent input in a suite that must be reproducible.
const NIGHT = new Date('2026-07-18T00:00:00Z');
const LATE = 360; // 3 AM — every rule live
// A deliberately unremarkable outfit: passes all eight rules, so any test only
// has to state the one thing it is testing.
const CLEAN: Record<OutfitSlot, OutfitLayer> = {
shoes: { slot: 'shoes', type: 'boot', colour: 'black' },
legs: { slot: 'legs', type: 'jeans', colour: 'denim' },
top: { slot: 'top', type: 'shirt', colour: 'white' },
outer: { slot: 'outer', type: 'none', colour: 'black' },
hair: { slot: 'hair', type: 'short', colour: 'brown' },
accessory: { slot: 'accessory', type: 'none', colour: 'black' },
};
type OutfitOverrides = Partial<Record<OutfitSlot, Partial<Omit<OutfitLayer, 'slot'>>>>;
const patron = (outfit: OutfitOverrides = {}, flags: PatronFlags = {}): Patron => ({
id: 'test',
dollSeed: 1,
archetype: 'punter',
age: 25,
idCard: { name: 'Test Patron', dob: '2001-01-01', expiry: '2030-01-01', photoSeed: 1 },
outfit: (Object.keys(CLEAN) as OutfitSlot[]).map((slot) => ({ ...CLEAN[slot], ...outfit[slot] })),
intoxication: 0.1,
tolerance: 0.5,
flags,
});
const ids = (rules: { id: string }[]): string[] => rules.map((r) => r.id);
const trips = (id: string, outfit: OutfitOverrides, flags?: PatronFlags): boolean =>
ids(violations(patron(outfit, flags), LATE)).includes(id);
describe('DRESS_CODE_RULES vs Dazza texts', () => {
it('every rule takes its text and activeFrom verbatim from the Dazza line', () => {
for (const rule of DRESS_CODE_RULES) {
const line = DAZZA_TEXTS.find((l) => l.ruleId === rule.id);
expect(line, `no Dazza line for ${rule.id}`).toBeDefined();
expect(rule.text).toBe(line!.text);
expect(rule.activeFrom).toBe(line!.fromMin);
}
});
it('every rule-bearing Dazza line has exactly one rule enforcing it', () => {
const announced = DAZZA_TEXTS.filter((l) => l.ruleId !== undefined).map((l) => l.ruleId!);
expect([...announced].sort()).toEqual([...ids([...DRESS_CODE_RULES])].sort());
expect(new Set(ids([...DRESS_CODE_RULES])).size).toBe(DRESS_CODE_RULES.length);
});
it('carries a short card label for every rule', () => {
for (const rule of DRESS_CODE_RULES) expect(rule.short.length).toBeGreaterThan(0);
});
it('ruleById finds rules by id and returns undefined otherwise', () => {
expect(ruleById('noBlazers')?.short).toBe('NO BLAZERS');
expect(ruleById('noCrocs')).toBeUndefined();
});
});
describe('activeRules', () => {
it('activates each rule exactly at activeFrom, not a minute before', () => {
for (const rule of DRESS_CODE_RULES) {
expect(ids(activeRules(rule.activeFrom - 1))).not.toContain(rule.id);
expect(ids(activeRules(rule.activeFrom))).toContain(rule.id);
}
});
it('returns rules in announcement order and accumulates over the night', () => {
const late = activeRules(LATE);
expect(late).toHaveLength(DRESS_CODE_RULES.length);
expect(late.map((r) => r.activeFrom)).toEqual([...late.map((r) => r.activeFrom)].sort((a, b) => a - b));
expect(activeRules(0)).toHaveLength(0);
expect(ids(activeRules(100))).toEqual(['noThongsSinglets', 'noLogos']);
});
it('pins the full escalation order from design §3.1', () => {
// SPECS is declared in DAZZA_TEXTS order, which is not chronological — this
// fails if the activeFrom sort is ever dropped.
expect(ids(activeRules(LATE))).toEqual([
'noThongsSinglets',
'noLogos',
'noSunnies',
'noWhiteSneakers',
'noBucketHats',
'noBlazers',
'noSongRequesters',
'noHandHolders',
]);
});
});
describe('predicates', () => {
it('noThongsSinglets catches thongs and singlets, not boots and shirts', () => {
expect(trips('noThongsSinglets', { shoes: { type: 'thong', colour: 'blue' } })).toBe(true);
expect(trips('noThongsSinglets', { top: { type: 'singlet', colour: 'white' } })).toBe(true);
expect(trips('noThongsSinglets', {})).toBe(false);
});
// Dazza's text says "no VISIBLE logos", and dollPlan.ts paints a logo rect for
// the top slot only. Convicting on the other four would deny a quarter of the
// queue over pixels that were never drawn (design §4.1). When the art pass adds
// per-slot logos, widen LOGO_VISIBLE_SLOTS and flip these expectations.
it('noLogos trips on a logo the renderer actually draws, and only that', () => {
expect(trips('noLogos', { top: { type: 'tee', colour: 'black', logo: true } })).toBe(true);
for (const slot of ['shoes', 'legs', 'outer', 'accessory'] as const) {
const type = slot === 'outer' ? 'bomber' : slot === 'accessory' ? 'cap' : CLEAN[slot].type;
expect(trips('noLogos', { [slot]: { type, logo: true } }), slot).toBe(false);
}
expect(trips('noLogos', { top: { type: 'tee', colour: 'black', logo: false } })).toBe(false);
expect(trips('noLogos', {})).toBe(false);
});
it('noSunnies catches sunnies at 3 AM but ignores a bare face', () => {
expect(trips('noSunnies', { accessory: { type: 'sunnies', colour: 'black' } })).toBe(true);
expect(trips('noSunnies', { accessory: { type: 'chain', colour: 'yellow' } })).toBe(false);
});
it('noWhiteSneakers spares vintage whites and non-white sneakers', () => {
expect(trips('noWhiteSneakers', { shoes: { type: 'sneaker', colour: 'white' } })).toBe(true);
expect(trips('noWhiteSneakers', { shoes: { type: 'sneaker', colour: 'white', vintage: true } })).toBe(false);
expect(trips('noWhiteSneakers', { shoes: { type: 'sneaker', colour: 'black' } })).toBe(false);
expect(trips('noWhiteSneakers', { shoes: { type: 'heel', colour: 'white' } })).toBe(false);
});
it('noBucketHats and noBlazers key off their own slots only', () => {
expect(trips('noBucketHats', { accessory: { type: 'bucketHat', colour: 'cream' } })).toBe(true);
expect(trips('noBucketHats', { accessory: { type: 'cap', colour: 'black' } })).toBe(false);
expect(trips('noBlazers', { outer: { type: 'blazer', colour: 'navy' } })).toBe(true);
expect(trips('noBlazers', { outer: { type: 'bomber', colour: 'green' } })).toBe(false);
});
it('noSongRequesters needs two tells — one is just a bloke', () => {
expect(trips('noSongRequesters', { top: { type: 'jersey', colour: 'red' } })).toBe(false);
expect(trips('noSongRequesters', { hair: { type: 'mullet', colour: 'yellow' } })).toBe(false);
expect(trips('noSongRequesters', {
top: { type: 'jersey', colour: 'red' },
hair: { type: 'mullet', colour: 'yellow' },
})).toBe(true);
expect(trips('noSongRequesters', {
legs: { type: 'trackies', colour: 'grey' },
accessory: { type: 'bumbag', colour: 'black' },
})).toBe(true);
expect(trips('noSongRequesters', {
hair: { type: 'mullet', colour: 'brown' },
accessory: { type: 'cap', colour: 'navy' },
})).toBe(true);
});
it('noSongRequesters acquits when no tell is actually on screen', () => {
// dollPlan draws the torso and legs as plain coloured rects: a jersey looks
// like a tee and trackies look like jeans. Two invisible tells must not deny
// anyone, or the player is stamping people over data they never saw (§4.1).
expect(trips('noSongRequesters', {
top: { type: 'jersey', colour: 'blue' },
legs: { type: 'trackies', colour: 'grey' },
})).toBe(false);
// ...but add one tell the renderer DOES draw and the case is visible again.
expect(trips('noSongRequesters', {
top: { type: 'jersey', colour: 'blue' },
legs: { type: 'trackies', colour: 'grey' },
hair: { type: 'mullet', colour: 'brown' },
})).toBe(true);
});
it('noHandHolders reads the QueueManager flag, and only that', () => {
expect(trips('noHandHolders', {}, { handHolding: true })).toBe(true);
expect(trips('noHandHolders', {}, {})).toBe(false);
});
it('predicates are total: an empty outfit throws nothing and violates nothing visual', () => {
const naked: Patron = { ...patron(), outfit: [] };
expect(() => violations(naked, LATE)).not.toThrow();
expect(violations(naked, LATE)).toEqual([]);
});
it("'none' in a slot counts as absent, logo flag and all", () => {
expect(violations(patron({ outer: { type: 'none' }, accessory: { type: 'none' } }), LATE)).toEqual([]);
// An empty slot is not a garment, so a stray logo flag on one denies nobody.
expect(trips('noLogos', { outer: { type: 'none', logo: true } })).toBe(false);
expect(trips('noLogos', { accessory: { type: 'none', logo: true } })).toBe(false);
// ...and logo:false on a real garment is clean, not merely "logo is present".
expect(trips('noLogos', { top: { type: 'tee', colour: 'red', logo: false } })).toBe(false);
});
});
describe('violations', () => {
it('returns nothing for a clean patron with every rule live', () => {
expect(violations(patron(), LATE)).toEqual([]);
});
it('reports only the rules already announced, in announcement order', () => {
const p = patron({
shoes: { type: 'thong', colour: 'blue' },
outer: { type: 'blazer', colour: 'navy' },
});
expect(ids(violations(p, 100))).toEqual(['noThongsSinglets']);
expect(ids(violations(p, LATE))).toEqual(['noThongsSinglets', 'noBlazers']);
});
it('survives 300 generated patrons and denies a playable fraction of them', () => {
_resetPatronSerial();
const ctx = { rng: new SeededRNG(5), nightDate: NIGHT };
let denied = 0;
for (let i = 0; i < 300; i++) {
const p = generatePatron(ctx, LATE);
expect(() => violations(p, LATE)).not.toThrow();
if (violations(p, LATE).length > 0) denied++;
}
// Toothless or unplayable are both failures — the door has to be a judgement call.
expect(denied / 300).toBeGreaterThan(0.1);
expect(denied / 300).toBeLessThan(0.95);
});
});