not-tonight/tests/queueManager.test.ts

374 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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));
});
});
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);
});
});