import { beforeEach, describe, expect, it } from 'vitest'; import { EventBus } from '../../src/core/EventBus'; import { SeededRNG } from '../../src/core/SeededRNG'; import type { Patron } from '../../src/data/types'; import { _resetPatronSerial, generatePatron } from '../../src/patrons/generator'; import { CrowdSim } from '../../src/scenes/floor/crowdSim'; import type { Agent } from '../../src/scenes/floor/crowdSim'; import { VENUE, isWalkableWorld } from '../../src/scenes/floor/venueMap'; const NIGHT = new Date('2026-07-19T21:00:00'); function makeSim(seed = 1234): CrowdSim { return new CrowdSim({ map: VENUE, bus: new EventBus(), rng: new SeededRNG(seed).stream('crowd') }); } /** Same seed in, same crowd out — the sims under test must not share patrons. */ function makeCrowd(count: number, seed = 99): Patron[] { _resetPatronSerial(); const rng = new SeededRNG(seed); const out: Patron[] = []; for (let i = 0; i < count; i++) out.push(generatePatron({ rng, nightDate: NIGHT }, 60)); return out; } const isEntry = (a: Agent): boolean => VENUE.anchors.entry.some((e) => e.x === a.x && e.y === a.y); /** Park an agent in a fixed activity so the test can drive one thing at a time. */ function pin(a: Agent, activity: Agent['activity'], x?: number, y?: number): Agent { a.activity = activity; a.dwellMs = 10 * 60 * 1000; if (x !== undefined) a.x = x; if (y !== undefined) a.y = y; return a; } beforeEach(() => { _resetPatronSerial(); }); describe('spawn', () => { it('puts the agent on an entry anchor, unhandled and present', () => { const sim = makeSim(); for (const p of makeCrowd(6)) { const a = sim.spawn(p); expect(isEntry(a)).toBe(true); expect(a.handled).toBe(false); expect(a.gone).toBe(false); expect(a.activity).toBe('walking'); } expect(sim.agents).toHaveLength(6); }); it('finds agents by patron id', () => { const sim = makeSim(); const crowd = makeCrowd(3); for (const p of crowd) sim.spawn(p); expect(sim.find(crowd[1]!.id)?.patron).toBe(crowd[1]); expect(sim.find('nobody')).toBeUndefined(); }); }); describe('movement', () => { it('keeps every agent on walkable ground for a whole night', () => { const sim = makeSim(); for (const p of makeCrowd(14)) sim.spawn(p); for (let step = 0; step < 6000; step++) { sim.update(16, step / 60); for (const a of sim.agents) { expect( isWalkableWorld(VENUE, a.x, a.y), `${a.patron.id} walked into a wall at ${a.x.toFixed(1)},${a.y.toFixed(1)}`, ).toBe(true); } } }); it('actually gets people to their anchors — nobody walks all night', () => { const sim = makeSim(); for (const p of makeCrowd(10)) sim.spawn(p); const settled = new Set(); for (let step = 0; step < 4000; step++) { sim.update(16, step / 60); for (const a of sim.agents) if (a.activity !== 'walking') settled.add(a.patron.id); } expect(settled.size).toBe(10); }); }); describe('intoxication drift', () => { it('rises with the clock, and faster at the bar', () => { const sim = makeSim(); const [barP, danceP] = makeCrowd(2); const bar = pin(sim.spawn(barP!), 'atBar'); const dance = pin(sim.spawn(danceP!), 'dancing'); for (const a of [bar, dance]) { a.patron.tolerance = 0.5; a.patron.intoxication = 0.3; } sim.update(16, 0); sim.update(16, 90); expect(bar.patron.intoxication).toBeGreaterThan(0.3); expect(dance.patron.intoxication).toBeGreaterThan(0.3); expect(bar.patron.intoxication).toBeGreaterThan(dance.patron.intoxication); }); it('is framerate independent — only clock minutes count', () => { const coarse = makeSim(); const fine = makeSim(); const [a1] = makeCrowd(1); const [a2] = makeCrowd(1); const slow = pin(coarse.spawn(a1!), 'atBar'); const fast = pin(fine.spawn(a2!), 'atBar'); for (const a of [slow, fast]) { a.patron.tolerance = 0.4; a.patron.intoxication = 0.2; } coarse.update(16, 0); coarse.update(16, 60); fine.update(16, 0); for (let i = 1; i <= 60; i++) fine.update(16, i); expect(fast.patron.intoxication).toBeCloseTo(slow.patron.intoxication, 10); }); it('never tips past legless', () => { const sim = makeSim(); const a = pin(sim.spawn(makeCrowd(1)[0]!), 'atBar'); a.patron.tolerance = 0; a.patron.intoxication = 0.9; sim.update(16, 0); sim.update(16, 10000); expect(a.patron.intoxication).toBe(1); }); }); describe('beat bob', () => { it('advances dancers only', () => { const sim = makeSim(); const [p1, p2] = makeCrowd(2); const dancer = pin(sim.spawn(p1!), 'dancing'); const drinker = pin(sim.spawn(p2!), 'atBar'); const before = { dancer: dancer.bobPhase, drinker: drinker.bobPhase }; sim.onBeat(1); expect(dancer.bobPhase).not.toBe(before.dancer); expect(drinker.bobPhase).toBe(before.drinker); }); it('keeps the phase inside 0..1 across a long set', () => { const sim = makeSim(); const dancer = pin(sim.spawn(makeCrowd(1)[0]!), 'dancing'); for (let beat = 0; beat < 500; beat++) { sim.onBeat(beat); expect(dancer.bobPhase).toBeGreaterThanOrEqual(0); expect(dancer.bobPhase).toBeLessThan(1); } }); }); describe('stalls', () => { it('reports who is in which cubicle', () => { const sim = makeSim(); const [p1, p2, p3] = makeCrowd(3); const a = pin(sim.spawn(p1!), 'inStall'); const b = pin(sim.spawn(p2!), 'inStall'); const c = pin(sim.spawn(p3!), 'inStall'); a.stallId = 's1'; b.stallId = 's1'; c.stallId = 's3'; const occ = sim.stallOccupancy(); expect(occ.get('s1')).toHaveLength(2); expect(occ.get('s2')).toHaveLength(0); expect(occ.get('s3')).toEqual([c]); }); it('lets two punters end up in one cubicle on its own', () => { const sim = makeSim(); for (const p of makeCrowd(14)) sim.spawn(p); let doubled = false; let maxOccupancy = 0; for (let step = 0; step < 9000; step++) { // Slow clock: at step/60 the crowd aged past its stay window (natural // churn, 2026-07-19) and went home before ever doubling up in a cubicle. sim.update(16, step / 600); for (const [, list] of sim.stallOccupancy()) { maxOccupancy = Math.max(maxOccupancy, list.length); if (list.length >= 2) doubled = true; } } expect(doubled).toBe(true); expect(maxOccupancy).toBeLessThanOrEqual(2); // the cap holds }); }); describe('bumping', () => { function bumpRig(): { sim: CrowdSim; hits: number[]; drunk: Agent } { const bus = new EventBus(); const sim = new CrowdSim({ map: VENUE, bus, rng: new SeededRNG(5).stream('crowd') }); const hits: number[] = []; bus.on('meters:delta', (d) => hits.push(d.aggro ?? 0)); const [p1, p2] = makeCrowd(2); const drunk = pin(sim.spawn(p1!), 'dancing'); const mate = pin(sim.spawn(p2!), 'dancing'); drunk.patron.intoxication = 0.95; drunk.patron.tolerance = 1; // no drift, so the stage under test stays put mate.patron.intoxication = 0.05; mate.patron.tolerance = 1; mate.x = drunk.x; mate.y = drunk.y; return { sim, hits, drunk }; } it('fires once, then not again until the cooldown lapses', () => { const { sim, hits } = bumpRig(); for (let i = 0; i < 100; i++) sim.update(16, 0); // 1.6s of grinding together expect(hits).toHaveLength(1); expect(hits[0]).toBeGreaterThan(0); // Second leak needs BOTH windows open: the 12s per-agent cooldown and the // 8s sim-wide gate. 800 more frames puts total elapsed at ~14.4s. for (let i = 0; i < 800; i++) sim.update(16, 0); expect(hits).toHaveLength(2); }); // Regression: the per-agent cooldown bounds one PAIR, but the leak rate used // to scale with crowd size — a full late-night floor pinned vibe to 0 and // aggro to 100 on its own, before the player had touched anything. The // sim-wide gate is what keeps ambient chaos a background cost. it('bounds the total leak no matter how big the rowdy crowd is', () => { const bus = new EventBus(); const sim = new CrowdSim({ map: VENUE, bus, rng: new SeededRNG(9).stream('crowd') }); let vibe = 0; bus.on('meters:delta', (d) => { vibe += d.vibe ?? 0; }); // 30 maggots stacked on one spot — the worst case the venue can produce. const agents = makeCrowd(30).map((p) => pin(sim.spawn(p), 'dancing')); const first = agents[0]!; for (const a of agents) { a.patron.intoxication = 0.95; a.patron.tolerance = 1; a.x = first.x; a.y = first.y; } for (let i = 0; i < 3750; i++) sim.update(16, 0); // 60s of maximum chaos // One leak per 8s, so a minute of it costs ~8 bumps, not hundreds. expect(vibe).toBeGreaterThan(-5); expect(vibe).toBeLessThan(0); // but it does still cost you something }); it('stays quiet when the sober one is the only neighbour', () => { const { sim, hits, drunk } = bumpRig(); drunk.patron.intoxication = 0.1; // nobody here is messy for (let i = 0; i < 400; i++) sim.update(16, 0); expect(hits).toHaveLength(0); }); it('carries the contracted vibe/aggro payload', () => { const bus = new EventBus(); const sim = new CrowdSim({ map: VENUE, bus, rng: new SeededRNG(5).stream('crowd') }); const seen: Array<{ vibe?: number; aggro?: number }> = []; bus.on('meters:delta', (d) => seen.push(d)); const [p1, p2] = makeCrowd(2); const drunk = pin(sim.spawn(p1!), 'dancing'); const mate = pin(sim.spawn(p2!), 'dancing'); drunk.patron.intoxication = 0.95; drunk.patron.tolerance = 1; mate.patron.tolerance = 1; mate.x = drunk.x; mate.y = drunk.y; sim.update(16, 0); expect(seen).toEqual([{ vibe: -0.4, aggro: 0.6 }]); }); }); describe('determinism', () => { it('same seed and same calls give identical positions', () => { const snap = (sim: CrowdSim): string => sim.agents .map((a) => `${a.patron.id}:${a.x.toFixed(6)},${a.y.toFixed(6)}:${a.activity}:${a.patron.intoxication.toFixed(6)}`) .join('|'); const runOne = (): string => { const sim = makeSim(4242); for (const p of makeCrowd(9)) sim.spawn(p); for (let step = 0; step < 3000; step++) sim.update(16, step / 60); return snap(sim); }; expect(runOne()).toBe(runOne()); }); }); describe('escort', () => { it('walks them to the door when you follow, and only then', () => { const sim = makeSim(); const a = sim.spawn(makeCrowd(1)[0]!); // Start them deep in the dunnies — the nastiest route out of the venue. a.x = VENUE.stalls[0]!.door.x; a.y = VENUE.stalls[0]!.door.y; expect(sim.beginEscort(a)).toBe(true); expect(a.activity).toBe('escorted'); expect(a.handled).toBe(true); expect(sim.beginEscort(a)).toBe(false); // Stand off and they refuse to budge. const parked = { x: a.x, y: a.y }; for (let i = 0; i < 100; i++) sim.updateEscorts(parked.x + 400, parked.y + 200, 16); expect(a.x).toBe(parked.x); expect(a.y).toBe(parked.y); expect(a.gone).toBe(false); // Stay on their heels and they go. for (let i = 0; i < 12000 && !a.gone; i++) { sim.updateEscorts(a.x, a.y, 16); expect(isWalkableWorld(VENUE, a.x, a.y)).toBe(true); } expect(a.gone).toBe(true); expect(a.activity).toBe('leaving'); expect(a.x).toBe(VENUE.anchors.exit[0]!.x); expect(a.y).toBe(VENUE.anchors.exit[0]!.y); }); it('refuses to escort someone already gone', () => { const sim = makeSim(); const a = sim.spawn(makeCrowd(1)[0]!); sim.beginEscort(a); for (let i = 0; i < 12000 && !a.gone; i++) sim.updateEscorts(a.x, a.y, 16); expect(a.gone).toBe(true); expect(sim.beginEscort(a)).toBe(false); }); it('pulls them out of the cubicle they were hiding in', () => { const sim = makeSim(); const a = pin(sim.spawn(makeCrowd(1)[0]!), 'inStall'); a.stallId = 's2'; a.x = VENUE.stalls[1]!.inside.x; a.y = VENUE.stalls[1]!.inside.y; expect(sim.beginEscort(a)).toBe(true); expect(a.stallId).toBeUndefined(); expect(a.x).toBe(VENUE.stalls[1]!.door.x); expect(sim.stallOccupancy().get('s2')).toHaveLength(0); }); }); describe('destroy', () => { it('drops the crowd', () => { const sim = makeSim(); for (const p of makeCrowd(4)) sim.spawn(p); sim.destroy(); expect(sim.agents).toHaveLength(0); expect(sim.find('p0')).toBeUndefined(); }); });