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 { announcedRules, ACTIVE_RULE_CAP, 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 = { 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>>>; 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 => { const rule = DRESS_CODE_RULES.find((r) => r.id === id)!; return ids(violations(patron(outfit, flags), rule.activeFrom)).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, capped at Dazza\'s attention span', () => { const late = activeRules(LATE); expect(late).toHaveLength(ACTIVE_RULE_CAP); expect(late.map((r) => r.activeFrom)).toEqual([...late.map((r) => r.activeFrom)].sort((a, b) => a - b)); expect(announcedRules(LATE)).toHaveLength(DRESS_CODE_RULES.length); expect(activeRules(0)).toHaveLength(0); expect(ids(activeRules(100))).toEqual(['noThongsSinglets', 'noLogos']); }); it('pins the escalation: all eight announce in order, only the last four are LIVE', () => { // SPECS is declared in DAZZA_TEXTS order, which is not chronological — this // fails if the activeFrom sort is ever dropped. expect(ids(announcedRules(LATE))).toEqual([ 'noThongsSinglets', 'noLogos', 'noSongRequesters', 'noSunnies', 'noHandHolders', 'noWhiteSneakers', 'noBucketHats', 'noBlazers', ]); // Dazza's attention span: by 3 AM the early classics are rescinded. expect(ids(activeRules(LATE))).toEqual([ 'noHandHolders', 'noWhiteSneakers', 'noBucketHats', 'noBlazers', ]); }); }); 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']); // By 3 AM the thongs rule has been rescinded off the card — only the // blazer still convicts. Dazza moved on; the door moves with him. expect(ids(violations(p, LATE))).toEqual(['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); }); });