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>
288 lines
9.3 KiB
TypeScript
288 lines
9.3 KiB
TypeScript
import { beforeEach, describe, expect, it } from 'vitest';
|
|
import { EventBus } from '../src/core/EventBus';
|
|
import { SeededRNG } from '../src/core/SeededRNG';
|
|
import { _resetPatronSerial } from '../src/patrons/generator';
|
|
import { QueueManager, QUEUE_TUNING } from '../src/scenes/door/QueueManager';
|
|
import type { Patron } from '../src/data/types';
|
|
|
|
const NIGHT = new Date('2026-07-18T00:00:00');
|
|
|
|
type MeterDelta = { vibe?: number; aggro?: number; hype?: number };
|
|
|
|
const setup = (seed = 5) => {
|
|
const bus = new EventBus();
|
|
const q = new QueueManager(bus, new SeededRNG(seed), NIGHT);
|
|
const deltas: MeterDelta[] = [];
|
|
bus.on('meters:delta', (d) => deltas.push(d));
|
|
const total = (key: 'aggro' | 'hype'): number =>
|
|
deltas.reduce((sum, d) => sum + (d[key] ?? 0), 0);
|
|
return { bus, q, deltas, total };
|
|
};
|
|
|
|
/** Advance in-game minutes until the queue is long enough, without touching the rope. */
|
|
const fillTo = (q: QueueManager, want: number): void => {
|
|
for (let m = 1; m <= 360 && q.queueLength < want; m++) q.onClockMinute(m);
|
|
};
|
|
|
|
/** Play a whole night at one patron per minute, ruling the same way every time. */
|
|
const playNight = (q: QueueManager, denied: boolean): void => {
|
|
for (let m = 0; m <= 360; m++) {
|
|
q.onClockMinute(m);
|
|
if (q.callNext()) q.resolveUp(denied);
|
|
}
|
|
};
|
|
|
|
beforeEach(() => _resetPatronSerial());
|
|
|
|
describe('QueueManager — opening state', () => {
|
|
it('never opens on an empty street', () => {
|
|
for (const seed of [1, 2, 3, 99]) {
|
|
_resetPatronSerial();
|
|
const { q } = setup(seed);
|
|
expect(q.queueLength).toBeGreaterThanOrEqual(1);
|
|
expect(q.patronUp).toBeNull();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('QueueManager — the rope', () => {
|
|
it('callNext moves the front patron up, announces them once, and logs them as seen', () => {
|
|
const { bus, q } = setup();
|
|
const ups: Patron[] = [];
|
|
bus.on('door:patronUp', ({ patron }) => ups.push(patron));
|
|
|
|
const front = q.snapshot.waiting[0];
|
|
expect(front).toBeDefined();
|
|
|
|
const called = q.callNext();
|
|
expect(called).toBe(front);
|
|
expect(ups).toEqual([front]);
|
|
expect(q.patronUp).toBe(front);
|
|
expect(q.queueLength).toBe(0);
|
|
expect(q.seen).toEqual([front]);
|
|
});
|
|
|
|
it('callNext is a no-op while somebody is already at the rope', () => {
|
|
const { bus, q } = setup();
|
|
fillTo(q, 2);
|
|
const first = q.callNext();
|
|
const lenAfterFirst = q.queueLength;
|
|
|
|
let ups = 0;
|
|
bus.on('door:patronUp', () => ups++);
|
|
|
|
expect(q.callNext()).toBeNull();
|
|
expect(q.patronUp).toBe(first);
|
|
expect(q.queueLength).toBe(lenAfterFirst);
|
|
expect(q.seen).toHaveLength(1);
|
|
expect(ups).toBe(0);
|
|
});
|
|
|
|
it('callNext returns null with nobody left waiting', () => {
|
|
const { q } = setup();
|
|
q.callNext();
|
|
q.resolveUp(false);
|
|
expect(q.queueLength).toBe(0);
|
|
expect(q.callNext()).toBeNull();
|
|
expect(q.patronUp).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('QueueManager — impatience', () => {
|
|
it('leaving people standing there earns aggro, and the ramp makes it worse over time', () => {
|
|
const { q, deltas } = setup();
|
|
for (let i = 0; i < 6; i++) q.update(1000);
|
|
|
|
const aggro = deltas.map((d) => d.aggro ?? 0);
|
|
expect(aggro).toHaveLength(6);
|
|
const first = aggro[0];
|
|
const last = aggro[5];
|
|
expect(first).toBeDefined();
|
|
expect(last).toBeDefined();
|
|
expect(first!).toBeGreaterThan(0);
|
|
expect(last!).toBeGreaterThan(first!);
|
|
});
|
|
|
|
it('the ramp restarts once the wait is served', () => {
|
|
const { q, deltas } = setup();
|
|
fillTo(q, 2);
|
|
for (let i = 0; i < 10; i++) q.update(1000);
|
|
const rampedUp = deltas[deltas.length - 1]?.aggro;
|
|
expect(rampedUp).toBeDefined();
|
|
|
|
q.callNext();
|
|
q.resolveUp(false);
|
|
deltas.length = 0;
|
|
q.update(1000);
|
|
const fresh = deltas[0]?.aggro;
|
|
expect(fresh).toBeDefined();
|
|
expect(fresh!).toBeLessThan(rampedUp!);
|
|
});
|
|
|
|
it('hype from one wait is capped so you cannot farm a single punter', () => {
|
|
const { q, total } = setup();
|
|
for (let i = 0; i < 120; i++) q.update(1000); // two real minutes of theatre
|
|
expect(total('hype')).toBeLessThanOrEqual(QUEUE_TUNING.hypePerWaitCap + 1e-9);
|
|
expect(total('hype')).toBeGreaterThan(QUEUE_TUNING.hypePerWaitCap - 1e-6);
|
|
});
|
|
});
|
|
|
|
describe('QueueManager — the pressure valve', () => {
|
|
it('a short queue with somebody up bleeds aggro back off', () => {
|
|
const { q, deltas, total } = setup();
|
|
q.callNext();
|
|
expect(q.queueLength).toBeLessThan(QUEUE_TUNING.comfortableQueue);
|
|
|
|
deltas.length = 0;
|
|
q.update(1000);
|
|
expect(deltas).toHaveLength(1);
|
|
expect(total('aggro')).toBeLessThan(0);
|
|
});
|
|
|
|
it('relief stops entirely once the queue reaches comfortableQueue', () => {
|
|
const { q, deltas } = setup();
|
|
fillTo(q, QUEUE_TUNING.comfortableQueue + 1);
|
|
expect(q.callNext()).not.toBeNull();
|
|
expect(q.queueLength).toBeGreaterThanOrEqual(QUEUE_TUNING.comfortableQueue);
|
|
|
|
deltas.length = 0;
|
|
q.update(1000);
|
|
expect(deltas).toHaveLength(0);
|
|
});
|
|
|
|
it('relief scales with how short the queue is, not just with it being empty', () => {
|
|
const empty = setup();
|
|
empty.q.callNext();
|
|
empty.deltas.length = 0;
|
|
empty.q.update(1000);
|
|
|
|
_resetPatronSerial();
|
|
const short = setup();
|
|
fillTo(short.q, 2);
|
|
short.q.callNext();
|
|
short.deltas.length = 0;
|
|
short.q.update(1000);
|
|
|
|
expect(short.q.queueLength).toBeGreaterThan(empty.q.queueLength);
|
|
expect(short.total('aggro')).toBeLessThan(0);
|
|
expect(short.total('aggro')).toBeGreaterThan(empty.total('aggro'));
|
|
});
|
|
});
|
|
|
|
describe('QueueManager — the phone', () => {
|
|
it('pays out for an audience, then goes on cooldown', () => {
|
|
const { q, total } = setup();
|
|
const before = total('hype');
|
|
expect(q.lookAtPhone()).toBe(true);
|
|
expect(total('hype') - before).toBeCloseTo(QUEUE_TUNING.phoneHype, 6);
|
|
|
|
expect(q.lookAtPhone()).toBe(false);
|
|
expect(q.isPhoneReady).toBe(false);
|
|
|
|
q.update(QUEUE_TUNING.phoneCooldownMs);
|
|
expect(q.isPhoneReady).toBe(true);
|
|
expect(q.lookAtPhone()).toBe(true);
|
|
});
|
|
|
|
it('pays nothing with nobody watching', () => {
|
|
const { q, deltas } = setup();
|
|
q.callNext();
|
|
expect(q.queueLength).toBe(0);
|
|
|
|
deltas.length = 0;
|
|
expect(q.lookAtPhone()).toBe(false);
|
|
expect(deltas).toHaveLength(0);
|
|
expect(q.isPhoneReady).toBe(true); // a failed look must not burn the cooldown
|
|
});
|
|
});
|
|
|
|
describe('QueueManager — arrivals', () => {
|
|
it('delivers a full night of punters', () => {
|
|
const { q } = setup();
|
|
playNight(q, false);
|
|
expect(q.seen.length).toBeGreaterThan(40);
|
|
});
|
|
|
|
it('nobody new joins after the doors shut at 2:45', () => {
|
|
const { q } = setup();
|
|
for (let m = 0; m <= 344; m++) {
|
|
q.onClockMinute(m);
|
|
if (q.callNext()) q.resolveUp(false); // admitted: no comebacks to muddy the count
|
|
}
|
|
const atClose = q.queueLength;
|
|
for (let m = 345; m <= 360; m++) q.onClockMinute(m);
|
|
expect(q.queueLength).toBe(atClose);
|
|
});
|
|
|
|
it('the queue never grows past maxQueue — the rest wander off for a kebab', () => {
|
|
const { q } = setup();
|
|
for (let m = 0; m <= 360; m++) {
|
|
q.onClockMinute(m);
|
|
expect(q.queueLength).toBeLessThanOrEqual(QUEUE_TUNING.maxQueue);
|
|
}
|
|
expect(q.queueLength).toBe(QUEUE_TUNING.maxQueue);
|
|
});
|
|
});
|
|
|
|
describe('QueueManager — snapshot', () => {
|
|
it('caps the drawn dolls and accounts for the overflow exactly', () => {
|
|
const { q } = setup();
|
|
const small = q.snapshot;
|
|
expect(small.visible.length + small.offscreen).toBe(small.waiting.length);
|
|
expect(small.offscreen).toBe(0);
|
|
|
|
fillTo(q, QUEUE_TUNING.visibleSlots + 3);
|
|
const big = q.snapshot;
|
|
expect(big.waiting.length).toBeGreaterThan(QUEUE_TUNING.visibleSlots);
|
|
expect(big.visible).toHaveLength(QUEUE_TUNING.visibleSlots);
|
|
expect(big.visible.length + big.offscreen).toBe(big.waiting.length);
|
|
expect(big.up).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('QueueManager — regulars with a grudge', () => {
|
|
const idCounts = (seen: readonly Patron[]): Map<string, number> => {
|
|
const counts = new Map<string, number>();
|
|
for (const p of seen) counts.set(p.id, (counts.get(p.id) ?? 0) + 1);
|
|
return counts;
|
|
};
|
|
|
|
it('denied regulars come back, but no more than regularMaxComebacks times', () => {
|
|
const { q } = setup();
|
|
playNight(q, true);
|
|
|
|
const counts = idCounts(q.seen);
|
|
const most = Math.max(...counts.values());
|
|
expect(most).toBeGreaterThan(1); // somebody did come back — otherwise this test proves nothing
|
|
expect(most).toBeLessThanOrEqual(QUEUE_TUNING.regularMaxComebacks + 1);
|
|
});
|
|
|
|
it('admitted patrons never come back', () => {
|
|
const { q } = setup();
|
|
playNight(q, false);
|
|
|
|
expect(q.seen.some((p) => p.archetype === 'regular')).toBe(true);
|
|
const counts = idCounts(q.seen);
|
|
expect(Math.max(...counts.values())).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('QueueManager — determinism', () => {
|
|
const nightFingerprint = (seed: number): string[] => {
|
|
_resetPatronSerial();
|
|
const { q } = setup(seed);
|
|
playNight(q, true);
|
|
return q.seen.map((p) => `${p.id}:${p.dollSeed}:${p.archetype}`);
|
|
};
|
|
|
|
it('same seed, same play → identical patron sequence', () => {
|
|
const a = nightFingerprint(99);
|
|
expect(a.length).toBeGreaterThan(20);
|
|
expect(nightFingerprint(99)).toEqual(a);
|
|
});
|
|
|
|
it('different seeds → different nights', () => {
|
|
expect(nightFingerprint(1)).not.toEqual(nightFingerprint(2));
|
|
});
|
|
});
|