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>
158 lines
5.5 KiB
TypeScript
158 lines
5.5 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { dazzaDue, nextDazza, type DazzaWorld } from '../src/scenes/door/dazzaSchedule';
|
|
import { DAZZA_TEXTS, type DazzaLine } from '../src/data/strings/dazza';
|
|
|
|
// Default world satisfies none of the mood gates, so rule-bearing timing can be
|
|
// tested without a gate accidentally letting commentary through.
|
|
const world = (over: Partial<DazzaWorld> = {}): DazzaWorld => ({
|
|
vibe: 50,
|
|
aggro: 0,
|
|
queueLength: 0,
|
|
insideCount: 50,
|
|
fired: new Set<string>(),
|
|
...over,
|
|
});
|
|
|
|
const ids = (lines: readonly DazzaLine[]): string[] => lines.map((l) => l.id);
|
|
const dueIds = (clockMin: number, w: DazzaWorld): string[] => ids(dazzaDue(clockMin, w));
|
|
|
|
const lineById = (id: string): DazzaLine => {
|
|
const line = DAZZA_TEXTS.find((l) => l.id === id);
|
|
if (!line) throw new Error(`unknown dazza line: ${id}`);
|
|
return line;
|
|
};
|
|
|
|
const RULE_LINES = DAZZA_TEXTS.filter((l) => l.ruleId);
|
|
|
|
describe('dazzaDue', () => {
|
|
it('says nothing at the top of the night', () => {
|
|
expect(dazzaDue(0, world())).toEqual([]);
|
|
});
|
|
|
|
it('delivers every rule-bearing text on its minute, never a minute early', () => {
|
|
expect(RULE_LINES).toHaveLength(8);
|
|
for (const line of RULE_LINES) {
|
|
expect(dueIds(line.fromMin - 1, world())).not.toContain(line.id);
|
|
expect(dueIds(line.fromMin, world())).toContain(line.id);
|
|
}
|
|
});
|
|
|
|
it('puts the law ahead of the philosophy when several land at once', () => {
|
|
const due = dazzaDue(360, world({ vibe: 20, queueLength: 8, insideCount: 2 }));
|
|
const ruleIdx = due.flatMap((l, i) => (l.ruleId ? [i] : []));
|
|
const moodIdx = due.flatMap((l, i) => (l.ruleId ? [] : [i]));
|
|
expect(ruleIdx.length).toBeGreaterThan(0);
|
|
expect(moodIdx.length).toBeGreaterThan(0);
|
|
expect(Math.max(...ruleIdx)).toBeLessThan(Math.min(...moodIdx));
|
|
});
|
|
|
|
it('orders within each group by fromMin ascending', () => {
|
|
const due = dazzaDue(360, world({ vibe: 20, queueLength: 8, insideCount: 2 }));
|
|
expect(ids(due.filter((l) => l.ruleId))).toEqual([
|
|
'rule-basics',
|
|
'rule-logos',
|
|
'rule-sunnies',
|
|
'rule-sneakers',
|
|
'rule-bucketHats',
|
|
'rule-suits',
|
|
'rule-songRequest',
|
|
'rule-couples',
|
|
]);
|
|
expect(ids(due.filter((l) => !l.ruleId))).toEqual([
|
|
'vibe-empty',
|
|
'vibe-mid',
|
|
'vibe-queue',
|
|
'kayden',
|
|
'owner-scare',
|
|
'philosophy-1',
|
|
'philosophy-2',
|
|
]);
|
|
});
|
|
|
|
it('never repeats a line that has already been sent', () => {
|
|
const w = world({ vibe: 20, queueLength: 8, insideCount: 2 });
|
|
const first = dazzaDue(360, w);
|
|
expect(first.length).toBeGreaterThan(0);
|
|
expect(dazzaDue(360, { ...w, fired: new Set(ids(first)) })).toEqual([]);
|
|
|
|
const partial = world({ fired: new Set(['rule-basics']) });
|
|
expect(dueIds(90, partial)).not.toContain('rule-basics');
|
|
expect(dueIds(90, partial)).toContain('rule-logos');
|
|
});
|
|
});
|
|
|
|
describe('mood gates', () => {
|
|
const cases: { id: string; yes: Partial<DazzaWorld>; no: Partial<DazzaWorld> }[] = [
|
|
{ id: 'vibe-mid', yes: { vibe: 34 }, no: { vibe: 35 } },
|
|
{ id: 'vibe-good', yes: { vibe: 73 }, no: { vibe: 72 } },
|
|
{ id: 'vibe-queue', yes: { queueLength: 6 }, no: { queueLength: 5 } },
|
|
{ id: 'vibe-empty', yes: { insideCount: 3 }, no: { insideCount: 4 } },
|
|
{ id: 'kayden', yes: { queueLength: 3 }, no: { queueLength: 2 } },
|
|
];
|
|
|
|
for (const c of cases) {
|
|
it(`${c.id} fires only when the room justifies it`, () => {
|
|
const at = lineById(c.id).fromMin;
|
|
expect(dueIds(at, world(c.yes))).toContain(c.id);
|
|
expect(dueIds(at, world(c.no))).not.toContain(c.id);
|
|
// gate satisfied but too early is still silence
|
|
expect(dueIds(at - 1, world(c.yes))).not.toContain(c.id);
|
|
});
|
|
}
|
|
|
|
it('ungated lines fire on time whatever the room is doing', () => {
|
|
const rooms: Partial<DazzaWorld>[] = [
|
|
{ vibe: 0, aggro: 100, queueLength: 0, insideCount: 0 },
|
|
{ vibe: 100, aggro: 0, queueLength: 40, insideCount: 400 },
|
|
];
|
|
for (const id of ['owner-scare', 'philosophy-1', 'philosophy-2']) {
|
|
const at = lineById(id).fromMin;
|
|
for (const room of rooms) {
|
|
expect(dueIds(at, world(room))).toContain(id);
|
|
expect(dueIds(at - 1, world(room))).not.toContain(id);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('nextDazza', () => {
|
|
it('hands back the head of the queue, one text at a time', () => {
|
|
const w = world({ vibe: 20, queueLength: 8, insideCount: 2 });
|
|
const due = dazzaDue(360, w);
|
|
expect(nextDazza(360, w)).toBe(due[0]);
|
|
expect(nextDazza(360, w)?.id).toBe('rule-basics');
|
|
});
|
|
|
|
it('returns undefined when he has nothing to say', () => {
|
|
expect(nextDazza(0, world())).toBeUndefined();
|
|
expect(nextDazza(360, world({ fired: new Set(ids(DAZZA_TEXTS)) }))).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('a whole night, minute by minute', () => {
|
|
it('delivers every line exactly once, rules on the minute they become law', () => {
|
|
const fired = new Set<string>();
|
|
const deliveredAt = new Map<string, number>();
|
|
|
|
for (let m = 0; m <= 360; m++) {
|
|
// vibe swings so both vibe-mid and vibe-good get a chance across the night
|
|
const line = nextDazza(m, {
|
|
vibe: m % 2 === 0 ? 20 : 90,
|
|
aggro: 0,
|
|
queueLength: 8,
|
|
insideCount: 2,
|
|
fired,
|
|
});
|
|
if (!line) continue;
|
|
expect(deliveredAt.has(line.id)).toBe(false);
|
|
fired.add(line.id);
|
|
deliveredAt.set(line.id, m);
|
|
}
|
|
|
|
expect([...deliveredAt.keys()].sort()).toEqual(ids(DAZZA_TEXTS).sort());
|
|
for (const line of RULE_LINES) {
|
|
expect(deliveredAt.get(line.id)).toBe(line.fromMin);
|
|
}
|
|
});
|
|
});
|