import { beforeEach, describe, expect, it } from 'vitest'; import { EventBus } from '../../src/core/EventBus'; import { SeededRNG } from '../../src/core/SeededRNG'; import type { EventMap, 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 { FIGHT_COOLDOWN_MS, FIGHT_FUSE_MS } from '../../src/scenes/floor/fight'; import { FightDirector } from '../../src/scenes/floor/fightDirector'; import { FIGHT_CAUSE } from '../../src/data/strings/floor'; import { VENUE } from '../../src/scenes/floor/venueMap'; const NIGHT = new Date('2026-07-19T21:00:00'); // Nowhere near the pair, so nothing accidentally counts as intervening. const AWAY_X = 20; const AWAY_Y = 20; 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; } interface Rig { bus: EventBus; crowd: CrowdSim; dir: FightDirector; agents: Agent[]; meters: Array; heat: Array; spotted: Array; } /** * `count` agents parked shoulder-to-shoulder and all well past FIGHT_MIN_INTOX, * so every pair is eligible and the director has a real choice to make. Nothing * calls crowd.update() unless a test wants it — that keeps the bus to fight events. */ function rig(count = 2, seed = 7): Rig { const bus = new EventBus(); const rng = new SeededRNG(seed); const crowd = new CrowdSim({ map: VENUE, bus, rng: rng.stream('crowd') }); const agents = makeCrowd(count).map((p, i) => { const a = crowd.spawn(p); a.patron.intoxication = 0.8; a.activity = 'dancing'; a.dwellMs = 10 * 60 * 1000; // Open floor, 20px apart: inside FIGHT_MIN_DIST_PX (so neighbours are // eligible), outside the ambient bump radius, and clear of every wall so a // released agent can actually walk off. a.x = 240 + i * 20; a.y = 100; return a; }); const meters: Array = []; const heat: Array = []; const spotted: Array = []; bus.on('meters:delta', (p) => meters.push(p)); bus.on('heat:strike', (p) => heat.push(p)); bus.on('floor:infractionSpotted', (p) => spotted.push(p)); return { bus, crowd, agents, meters, heat, spotted, dir: new FightDirector({ bus, rng: rng.stream('fights'), crowd }), }; } function runAway(dir: FightDirector, ms: number, step = 100): void { for (let t = 0; t < ms; t += step) dir.update(AWAY_X, AWAY_Y, step); } /** The point the player has to stand on to get between them. */ function midpoint(p: [Agent, Agent]): [number, number] { return [(p[0].x + p[1].x) / 2, (p[0].y + p[1].y) / 2]; } beforeEach(() => { _resetPatronSerial(); }); describe('starting', () => { it('kicks off a fight in a drunk crowd and flags it as an infraction', () => { const r = rig(6); runAway(r.dir, 500); const pair = r.dir.pair(); expect(pair).not.toBeNull(); expect(r.dir.active?.phase).toBe('shoving'); expect(FIGHT_CAUSE).toContain(r.dir.cause); expect(r.spotted).toEqual([{ patronId: pair?.[0].patron.id, kind: 'fight' }]); }); it('never runs two fights at once', () => { const r = rig(6); // Derived from the constants rather than hardcoded: a wall of frames that // covered several fights at a 20s cooldown covers less than one at 150s, // and the >1 assertion below is what proves the window actually exercised // the between-fights transition rather than passing vacuously. const dt = 50; const frames = Math.ceil((3 * (FIGHT_FUSE_MS + FIGHT_COOLDOWN_MS)) / dt); for (let i = 0; i < frames; i++) { r.dir.update(AWAY_X, AWAY_Y, dt); const squaring = r.agents.filter((a) => a.activity === 'squaringUp'); expect(squaring.length).toBeLessThanOrEqual(2); expect(squaring.length).toBe(r.dir.active ? 2 : 0); } expect(r.spotted.length).toBeGreaterThan(1); }); it('leaves a sober crowd alone', () => { const r = rig(6); for (const a of r.agents) a.patron.intoxication = 0.2; runAway(r.dir, 60_000); expect(r.dir.active).toBeNull(); expect(r.spotted).toHaveLength(0); }); it('pins both agents so the sim stops moving them', () => { const r = rig(3); // Walking, not parked — without the pin the sim would carry them off, which // is what makes the unchanged coordinates below mean anything. for (const a of r.agents) a.activity = 'walking'; r.dir.update(AWAY_X, AWAY_Y, 16); const pair = r.dir.pair(); expect(pair).not.toBeNull(); if (!pair) return; const loose = r.agents.find((a) => !pair.includes(a)); const before = [...pair, loose].map((a) => ({ x: a?.x, y: a?.y })); for (let i = 0; i < 20; i++) { r.crowd.update(16, 60 + i / 60); r.dir.update(AWAY_X, AWAY_Y, 16); } for (const [i, a] of pair.entries()) { expect(a.activity).toBe('squaringUp'); expect(a.x).toBe(before[i]?.x); expect(a.y).toBe(before[i]?.y); } // Control: the one nobody pinned kept walking. expect(loose?.x).not.toBe(before[2]?.x); }); }); describe('resolving', () => { it('breaks up when you stand between them', () => { const r = rig(2); r.dir.update(AWAY_X, AWAY_Y, 16); const pair = r.dir.pair(); expect(pair).not.toBeNull(); if (!pair) return; const [px, py] = midpoint(pair); r.dir.update(px, py, 16); expect(r.dir.active).toBeNull(); expect(r.meters).toEqual([{ vibe: 6, aggro: 1 }]); expect(r.heat).toHaveLength(0); for (const a of pair) { expect(a.handled).toBe(true); expect(a.activity).toBe('walking'); } }); it('lets the swing land if you walk away, and takes a heat strike for it', () => { const r = rig(2); runAway(r.dir, FIGHT_FUSE_MS + 500); expect(r.dir.active).toBeNull(); expect(r.meters).toEqual([{ vibe: -12, aggro: 20 }]); // Deferred: a missed brawl surfaces at inspection rather than striking on the spot. expect(r.heat).toEqual([{ reason: 'Fight on the floor', deferred: true }]); }); it('does not count a swung fight as dealt with', () => { const r = rig(2); r.dir.update(AWAY_X, AWAY_Y, 16); const pair = r.dir.pair(); runAway(r.dir, FIGHT_FUSE_MS + 500); expect(r.dir.takeResolution()?.phase).toBe('swung'); for (const a of pair ?? []) { expect(a.handled).toBe(false); expect(a.activity).toBe('walking'); } }); it('hands the resolution over exactly once', () => { const r = rig(2); r.dir.update(AWAY_X, AWAY_Y, 16); const pair = r.dir.pair(); expect(r.dir.takeResolution()).toBeNull(); // nothing to say mid-fight if (!pair) return; const [px, py] = midpoint(pair); r.dir.update(px, py, 16); const res = r.dir.takeResolution(); expect(res?.phase).toBe('broken'); expect(res?.outcome.heat).toBe(false); expect(res?.line).toBeTruthy(); expect(r.dir.takeResolution()).toBeNull(); }); }); describe('cooldown', () => { it('will not start another fight straight after one resolves', () => { const r = rig(6); runAway(r.dir, FIGHT_FUSE_MS + 200); expect(r.spotted).toHaveLength(1); expect(r.dir.active).toBeNull(); runAway(r.dir, FIGHT_COOLDOWN_MS - 1000); expect(r.dir.active).toBeNull(); expect(r.spotted).toHaveLength(1); runAway(r.dir, 2000); expect(r.spotted).toHaveLength(2); }); }); describe('bail-outs', () => { it('aborts without scoring if one of them leaves mid-fight', () => { const r = rig(2); r.dir.update(AWAY_X, AWAY_Y, 16); const pair = r.dir.pair(); expect(pair).not.toBeNull(); if (!pair) return; const [a, b] = pair; a.gone = true; r.dir.update(AWAY_X, AWAY_Y, 16); expect(r.dir.active).toBeNull(); expect(r.dir.takeResolution()).toBeNull(); expect(r.meters).toHaveLength(0); expect(r.heat).toHaveLength(0); expect(b.activity).toBe('walking'); expect(b.handled).toBe(false); // Nobody drags a departed patron back onto the dance floor. expect(a.activity).toBe('squaringUp'); // And the cooldown started, so the survivor isn't instantly in another one. runAway(r.dir, FIGHT_COOLDOWN_MS - 2000); expect(r.dir.active).toBeNull(); }); it('leaves nobody frozen when destroyed', () => { const r = rig(6); r.dir.update(AWAY_X, AWAY_Y, 16); expect(r.agents.filter((a) => a.activity === 'squaringUp')).toHaveLength(2); r.dir.destroy(); expect(r.dir.active).toBeNull(); expect(r.dir.pair()).toBeNull(); expect(r.agents.filter((a) => a.activity === 'squaringUp')).toHaveLength(0); }); }); describe('determinism', () => { it('picks the same pair and the same cause from the same seed', () => { const runOnce = (): { ids: string[]; cause: string } => { const r = rig(6, 4242); r.dir.update(AWAY_X, AWAY_Y, 16); const pair = r.dir.pair(); return { ids: (pair ?? []).map((a) => a.patron.id), cause: r.dir.cause }; }; const first = runOnce(); expect(first.ids).toHaveLength(2); expect(runOnce()).toEqual(first); }); it('diverges on a different seed', () => { const causeFor = (seed: number): string => { const r = rig(6, seed); runAway(r.dir, 200_000); // several fights, so the streams have room to drift return r.spotted.map((s) => s.patronId).join(','); }; expect(causeFor(1)).not.toBe(causeFor(2)); }); });