import { describe, expect, it } from 'vitest'; import { SeededRNG } from '../src/core/SeededRNG'; import { generatePatron, _resetPatronSerial, type GeneratorContext } from '../src/patrons/generator'; import { OUTFIT_VOCAB } from '../src/data/outfits'; import { drunkStage } from '../src/rules/drunk'; import type { DrunkStage, OutfitLayer, OutfitSlot, Patron } from '../src/data/types'; import { ZONE_BANDS, ZONE_ORDER, describeEyes, describeSlot, describeStance, describeZone, greetingIndex, hasWordFor, type InspectZone, } from '../src/scenes/door/inspect'; const BASE_OUTFIT: readonly OutfitLayer[] = [ { slot: 'shoes', type: 'boot', colour: 'brown' }, { slot: 'legs', type: 'jeans', colour: 'denim' }, { slot: 'top', type: 'tee', colour: 'red' }, { slot: 'outer', type: 'hoodie', colour: 'green' }, { slot: 'hair', type: 'mullet', colour: 'brown' }, { slot: 'accessory', type: 'cap', colour: 'navy' }, ]; type SlotOverrides = Partial>>; const patron = (overrides: SlotOverrides = {}, intoxication = 0): Patron => ({ id: 'p-test', dollSeed: 12345, archetype: 'punter', age: 25, idCard: { name: 'Shazza Vella', dob: '2001-03-02', expiry: '2030-01-01', photoSeed: 12345 }, outfit: BASE_OUTFIT.map((l) => ({ ...l, ...overrides[l.slot] })), intoxication, tolerance: 0.5, flags: {}, }); const slots = Object.keys(OUTFIT_VOCAB) as OutfitSlot[]; const NIGHT = new Date('2026-07-18T00:00:00'); describe('describeSlot vocabulary coverage', () => { it('has plain-English words for every type in OUTFIT_VOCAB', () => { // Exact, not heuristic: a garment added to the vocabulary without a word // here would otherwise reach the player as a raw id, and the tooltips are // the only channel through which four of the dress-code rules are readable. const missing: string[] = []; for (const slot of Object.keys(OUTFIT_VOCAB) as OutfitSlot[]) { for (const def of OUTFIT_VOCAB[slot]) { if (!hasWordFor(slot, def.type)) missing.push(`${slot}.${def.type}`); } } expect(missing).toEqual([]); }); it('never leaks a camelCase type id into player-facing text', () => { for (const slot of Object.keys(OUTFIT_VOCAB) as OutfitSlot[]) { for (const def of OUTFIT_VOCAB[slot]) { const line = describeSlot(patron({ [slot]: { type: def.type, colour: 'black' } }), slot); expect(line).not.toMatch(/[a-z][A-Z]/); expect(line.length).toBeGreaterThan(0); } } }); it('prefixes a colour without producing "navy a blazer"', () => { // The noun tables must hold BARE nouns; an article baked into one shows up // here immediately. for (const [slot, type] of [['outer', 'blazer'], ['top', 'singlet'], ['accessory', 'bucketHat'], ['legs', 'skirt']] as const) { const line = describeSlot(patron({ [slot]: { type, colour: 'navy' } }), slot); expect(line).not.toMatch(/\b(a|an) /); expect(line.startsWith('navy ')).toBe(true); } }); it('never leaks a camelCase type id into player-facing text', () => { for (const slot of slots) { for (const def of OUTFIT_VOCAB[slot]) { if (def.type === def.type.toLowerCase()) continue; const colour = def.colours[0] ?? 'black'; const line = describeSlot(patron({ [slot]: { type: def.type, colour } }), slot); expect(line).not.toContain(def.type); } } }); }); describe('describeSlot', () => { it('names the colour of clothing but not of hair', () => { expect(describeSlot(patron({ top: { type: 'polo', colour: 'purple' } }), 'top')).toContain('purple'); expect(describeSlot(patron({ shoes: { type: 'heel', colour: 'pink' } }), 'shoes')).toContain('pink'); const hair = describeSlot(patron({ hair: { type: 'mullet', colour: 'brown' } }), 'hair'); expect(hair).not.toContain('brown'); expect(hair).toBe('a mullet'); }); it('uses absence phrasing when a slot is empty', () => { expect(describeSlot(patron({ outer: { type: 'none' } }), 'outer')).toBe('no jacket'); expect(describeSlot(patron({ accessory: { type: 'none' } }), 'accessory')).toBe('no accessories'); }); it('calls out vintage as old, and pristine white sneakers as new', () => { const old = describeSlot(patron({ top: { type: 'tee', colour: 'black', vintage: true } }), 'top'); expect(old).toMatch(/old/); const fresh = describeSlot(patron({ shoes: { type: 'sneaker', colour: 'white' } }), 'shoes'); expect(fresh).toMatch(/new/); // Only white sneakers read as new; the same shoe in another colour must not. const black = describeSlot(patron({ shoes: { type: 'sneaker', colour: 'black' } }), 'shoes'); expect(black).not.toMatch(/new/); }); it('mentions a logo when the layer has one', () => { const line = describeSlot(patron({ top: { type: 'jersey', colour: 'red', logo: true } }), 'top'); expect(line).toContain('logo'); expect(describeSlot(patron({ top: { type: 'jersey', colour: 'red' } }), 'top')).not.toContain('logo'); }); }); describe('drunk tells', () => { const byStage: Record = { sober: 0.05, tipsy: 0.3, loose: 0.5, messy: 0.75, maggot: 0.95, }; const stages = Object.keys(byStage) as DrunkStage[]; it('the chosen intoxication values really do span all five stages', () => { for (const stage of stages) expect(drunkStage(byStage[stage])).toBe(stage); }); it('describeEyes and describeStance give a distinct non-empty line per stage', () => { for (const fn of [describeEyes, describeStance]) { const lines = stages.map((s) => fn(patron({}, byStage[s]))); for (const line of lines) expect(line.length).toBeGreaterThan(0); expect(new Set(lines).size).toBe(5); } }); it('eyes and stance are different observations, not the same line twice', () => { for (const stage of stages) { const p = patron({}, byStage[stage]); expect(describeEyes(p)).not.toBe(describeStance(p)); } }); }); describe('describeZone', () => { it('surfaces every outfit slot across the five zones', () => { const p = patron({ shoes: { type: 'thong', colour: 'orange' }, legs: { type: 'cargo', colour: 'denim' }, top: { type: 'crop', colour: 'purple' }, outer: { type: 'puffer', colour: 'cream' }, accessory: { type: 'bumbag', colour: 'yellow' }, hair: { type: 'shaved', colour: 'pink' }, }); const all = ZONE_ORDER.flatMap((z) => describeZone(p, z)).join(' | '); for (const colour of ['orange', 'denim', 'purple', 'cream', 'yellow']) { expect(all).toContain(colour); } // Hair deliberately carries no colour, so it is checked by its garment word. expect(all).toContain('shaved head'); }); it('every zone returns at least one line', () => { const p = patron(); for (const zone of ZONE_ORDER) { const lines = describeZone(p, zone); expect(lines.length).toBeGreaterThan(0); for (const line of lines) expect(line.trim().length).toBeGreaterThan(0); } }); }); describe('ZONE_BANDS', () => { it('tiles the 32x48 queue doll with no gaps or overlaps', () => { const first = ZONE_ORDER[0]; const last = ZONE_ORDER[ZONE_ORDER.length - 1]; expect(first).toBeDefined(); expect(last).toBeDefined(); expect(ZONE_BANDS[first!].y0).toBe(0); expect(ZONE_BANDS[last!].y1).toBe(48); for (let i = 0; i < ZONE_ORDER.length - 1; i++) { const here = ZONE_ORDER[i]; const next = ZONE_ORDER[i + 1]; expect(here).toBeDefined(); expect(next).toBeDefined(); expect(ZONE_BANDS[here!].y1).toBe(ZONE_BANDS[next!].y0); expect(ZONE_BANDS[here!].y1).toBeGreaterThan(ZONE_BANDS[here!].y0); } }); it('has a band for every zone in ZONE_ORDER and no strays', () => { expect(Object.keys(ZONE_BANDS).sort()).toEqual([...ZONE_ORDER].sort()); }); }); describe('greetingIndex', () => { it('is deterministic and inside the pool', () => { const p = patron(); for (const size of [1, 2, 3, 7, 13]) { const i = greetingIndex(p, size); expect(i).toBe(greetingIndex(p, size)); expect(i).toBeGreaterThanOrEqual(0); expect(i).toBeLessThan(size); expect(Number.isInteger(i)).toBe(true); } }); it('returns 0 rather than NaN for an empty pool', () => { expect(greetingIndex(patron(), 0)).toBe(0); }); it('spreads different patrons across the pool', () => { _resetPatronSerial(); const ctx: GeneratorContext = { rng: new SeededRNG(5), nightDate: NIGHT }; const seen = new Set(); for (let i = 0; i < 50; i++) seen.add(greetingIndex(generatePatron(ctx, 0), 5)); expect(seen.size).toBeGreaterThan(1); }); }); describe('total over generated patrons', () => { it('describes 200 random patrons without an empty or undefined line', () => { _resetPatronSerial(); const ctx: GeneratorContext = { rng: new SeededRNG(3), nightDate: NIGHT }; const zones: readonly InspectZone[] = ZONE_ORDER; for (let i = 0; i < 200; i++) { const p = generatePatron(ctx, i); for (const zone of zones) { for (const line of describeZone(p, zone)) { expect(line.trim().length).toBeGreaterThan(0); expect(line).not.toContain('undefined'); } } } }); });