365 lines
12 KiB
TypeScript
365 lines
12 KiB
TypeScript
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<string>();
|
|
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();
|
|
});
|
|
});
|