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>
231 lines
8.7 KiB
TypeScript
231 lines
8.7 KiB
TypeScript
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,
|
|
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<Record<OutfitSlot, Partial<OutfitLayer>>>;
|
|
|
|
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');
|
|
|
|
/** The text before ' — ' is the garment name; anything after it is a note. */
|
|
const nameOf = (line: string): string => line.split(' — ')[0] ?? '';
|
|
|
|
describe('describeSlot vocabulary coverage', () => {
|
|
it('has plain-English words for every type in OUTFIT_VOCAB', () => {
|
|
for (const slot of slots) {
|
|
for (const def of OUTFIT_VOCAB[slot]) {
|
|
const colour = def.colours[0] ?? 'black';
|
|
const line = describeSlot(patron({ [slot]: { type: def.type, colour } }), slot);
|
|
expect(line.length).toBeGreaterThan(0);
|
|
if (def.type === 'none') continue;
|
|
// A type with no word falls through to the raw id, i.e. exactly the
|
|
// colour+type (or bare type for hair). Ids that are already plural
|
|
// English ('jeans', 'sunnies') read fine that way; everything else has
|
|
// to be translated, so a new garment without a word fails here.
|
|
if (def.type.endsWith('s')) continue;
|
|
const rawFallback = slot === 'hair' ? def.type : `${colour} ${def.type}`;
|
|
expect(nameOf(line)).not.toBe(rawFallback);
|
|
}
|
|
}
|
|
});
|
|
|
|
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<DrunkStage, number> = {
|
|
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<number>();
|
|
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');
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|