292 lines
9.6 KiB
TypeScript
292 lines
9.6 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);
|
|
// 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<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));
|
|
});
|
|
});
|