291 lines
9.6 KiB
TypeScript
291 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 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<EventMap['meters:delta']>;
|
|
heat: Array<EventMap['heat:strike']>;
|
|
spotted: Array<EventMap['floor:infractionSpotted']>;
|
|
}
|
|
|
|
/**
|
|
* `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<EventMap['meters:delta']> = [];
|
|
const heat: Array<EventMap['heat:strike']> = [];
|
|
const spotted: Array<EventMap['floor:infractionSpotted']> = [];
|
|
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));
|
|
});
|
|
});
|