not-tonight/tests/floor/fightDirector.test.ts
type-two df70c99dd0 Merge branch 'lane/floor'
# Conflicts:
#	LANEHANDOVER.md
#	src/scenes/floor/FloorDemoScene.ts
2026-07-19 22:14:08 +10:00

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