241 lines
11 KiB
TypeScript
241 lines
11 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 { 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<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 =>
|
|
{
|
|
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);
|
|
});
|
|
});
|