not-tonight/tests/floor/crowdSim.test.ts
type-two 3eee8432d4 LANE-FLOOR: the Floor — patrol, flashlight/UV, cut-off, stalls, pat-down
FloorDemoScene (F from Parade): an 80x45-tile venue you patrol in the dark with
a flashlight cone that reveals detail, plus all three v0.2 infractions and the
UV stamp check. Self-populates from the patron generator; consumes Patron[] only,
so Phase 2 can hand it the real admitted list.

Pure, tested logic (venueMap, cone, player, crowdSim, bangJudge, escalation,
patDown); Phaser layer stays thin (FloorView + three overlays). 120 new tests,
169 total.

Two bugs found by running it rather than by the suite, both fixed:
- bump penalties had a per-agent cooldown but no sim-wide bound, so the leak rate
  scaled with crowd size and an unattended venue pinned vibe to 0 / aggro to 100
  by ~1AM. Sim-wide gate added, with a regression test.
- beat.update() sat below the overlay early-return, freezing the beat clock behind
  modals and making the stall rhythm game unwinnable. Clock and beat now always run.

Also retuned dwell times and the tile/darkness palette for readability, and
dropped Light2D in favour of the cone mask it was fighting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:49:13 +10:00

363 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++) {
sim.update(16, step / 60);
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();
});
});