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 { CARPET_TUNING, QueueManager, QUEUE_TUNING, entranceBonus } 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); // Two flows while somebody is up: aggro relief AND the hype-decay tick // (theatre fades while you work — tuning 2026-07-19). expect(deltas.filter((d) => d.aggro !== undefined)).toHaveLength(1); expect(total('aggro')).toBeLessThan(0); expect(total('hype')).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); // No RELIEF at a full queue. Hype decay still ticks (someone is up). expect(deltas.filter((d) => d.aggro !== undefined)).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 => { const counts = new Map(); 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)); }); }); describe('the red carpet', () => { const carpetSetup = () => { const s = setup(9); fillTo(s.q, 6); return s; }; it('toCarpet parks the up patron and frees the rope', () => { const { q } = carpetSetup(); const p = q.callNext()!; expect(q.toCarpet()?.id).toBe(p.id); expect(q.patronUp).toBeNull(); expect(q.carpetSnapshot.length).toBe(1); expect(q.callNext()).not.toBeNull(); // the rope keeps working }); it('the brass posts only stretch to CARPET_TUNING.slots', () => { const { q } = carpetSetup(); for (let i = 0; i < CARPET_TUNING.slots; i++) { expect(q.callNext()).not.toBeNull(); expect(q.toCarpet()).not.toBeNull(); } expect(q.callNext()).not.toBeNull(); expect(q.toCarpet()).toBeNull(); // full — the fourth stays at the rope expect(q.patronUp).not.toBeNull(); }); it('bodies on display pay hype per second, on top of the queue theatre', () => { const { q, total } = carpetSetup(); q.callNext(); q.toCarpet(); // Rope free + line waiting: ordinary queue theatre still flows. The // carpet's dividend is ADDITIVE — that is the whole point of the posts. const before = total('hype'); q.update(1000); expect(total('hype') - before).toBeCloseTo( CARPET_TUNING.hypePerSecPerBody + QUEUE_TUNING.hypePerSec, 3, ); }); it('patience runs out: a storm-off, aggro, and the slot comes back', () => { const { q, total } = carpetSetup(); const p = q.callNext()!; q.toCarpet(); const before = total('aggro'); // Worst-case patience is base-max × the biggest archetype scale. q.update(CARPET_TUNING.patienceSecBase[1] * 1.8 * 1000 + 1000); expect(q.carpetSnapshot.length).toBe(0); expect(q.takeStorms().map((x) => x.id)).toEqual([p.id]); expect(q.takeStorms()).toEqual([]); // drained means drained expect(total('aggro') - before).toBeGreaterThanOrEqual(CARPET_TUNING.stormAggro); }); it('recall puts them back up and remembers the stand for the entrance', () => { const { q } = carpetSetup(); const p = q.callNext()!; q.toCarpet(); q.update(3000); expect(q.recallFromCarpet('nobody')).toBeNull(); const back = q.recallFromCarpet(p.id); expect(back?.id).toBe(p.id); expect(q.patronUp?.id).toBe(p.id); expect(q.carpetMsFor(p.id)).toBe(3000); }); it('recall refuses while somebody else is at the rope', () => { const { q } = carpetSetup(); const p = q.callNext()!; q.toCarpet(); q.callNext(); expect(q.recallFromCarpet(p.id)).toBeNull(); expect(q.carpetSnapshot.length).toBe(1); }); it('entranceBonus scales with the stand and caps', () => { expect(entranceBonus(0)).toBe(0); expect(entranceBonus(10_000)).toBeCloseTo(10 * CARPET_TUNING.entranceHypePerSec); expect(entranceBonus(10_000_000)).toBe(CARPET_TUNING.entranceHypeCap); }); });