not-tonight/tests/floor/patDown.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

164 lines
6.4 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { SeededRNG } from '../../src/core/SeededRNG';
import { PATDOWN_CLEAR, PATDOWN_MISSED } from '../../src/scenes/floor/strings';
import {
foundLine,
layoutPockets,
scorePatDown,
POCKET_COUNT,
type Pocket,
} from '../../src/scenes/floor/patDown';
import type { Patron } from '../../src/data/types';
const patronWith = (contraband: string[]): Patron => ({
id: 'p1',
dollSeed: 42,
archetype: 'punter',
age: 24,
idCard: { name: 'Shazza Nguyen', dob: '2002-03-04', expiry: '2029-03-04', photoSeed: 42 },
outfit: [],
intoxication: 0.3,
tolerance: 0.5,
flags: contraband.length > 0 ? { contraband } : {},
});
const stream = (seed: number) => new SeededRNG(seed).stream('patdown');
const binAll = (pockets: Pocket[]): Pocket[] => pockets.map((p) => ({ ...p, binned: p.itemId !== undefined }));
describe('layoutPockets', () => {
it('returns POCKET_COUNT pockets with unique ids at distinct positions', () => {
const pockets = layoutPockets(patronWith(['flask']), stream(1));
expect(pockets).toHaveLength(POCKET_COUNT);
expect(new Set(pockets.map((p) => p.id)).size).toBe(POCKET_COUNT);
expect(new Set(pockets.map((p) => `${p.x},${p.y}`)).size).toBe(POCKET_COUNT);
});
it('keeps pockets inside the 64x96 outline and clear of each other', () => {
const pockets = layoutPockets(patronWith([]), stream(2));
for (const p of pockets) {
expect(p.x).toBeGreaterThanOrEqual(0);
expect(p.y).toBeGreaterThanOrEqual(0);
expect(p.x).toBeLessThanOrEqual(64);
expect(p.y).toBeLessThanOrEqual(96);
}
for (let i = 0; i < pockets.length; i++) {
for (let j = i + 1; j < pockets.length; j++) {
const a = pockets[i]!;
const b = pockets[j]!;
const clear = Math.abs(a.x - b.x) >= 12 || Math.abs(a.y - b.y) >= 12;
expect(clear, `${a.label} overlaps ${b.label}`).toBe(true);
}
}
});
it('places every contraband id in a distinct pocket', () => {
const items = ['flask', 'kebab', 'budgie'];
const pockets = layoutPockets(patronWith(items), stream(3));
const filled = pockets.filter((p) => p.itemId !== undefined);
expect(filled).toHaveLength(items.length);
expect(new Set(filled.map((p) => p.id)).size).toBe(items.length);
expect(filled.map((p) => p.itemId).sort()).toEqual([...items].sort());
});
it('leaves an empty-handed patron with no items and nothing revealed or binned', () => {
const pockets = layoutPockets(patronWith([]), stream(4));
expect(pockets.every((p) => p.itemId === undefined)).toBe(true);
expect(pockets.every((p) => !p.revealed && !p.binned)).toBe(true);
});
it('places only what fits when there is more contraband than pockets', () => {
const items = ['flask', 'kebab', 'budgie', 'vapes', 'marker', 'snags', 'tinnies', 'goonSack'];
const pockets = layoutPockets(patronWith(items), stream(5));
expect(pockets.filter((p) => p.itemId !== undefined)).toHaveLength(POCKET_COUNT);
});
it('is deterministic for the same seed and divergent across seeds', () => {
const items = ['flask', 'kebab', 'budgie'];
const a = layoutPockets(patronWith(items), stream(7)).map((p) => p.itemId);
const b = layoutPockets(patronWith(items), stream(7)).map((p) => p.itemId);
expect(a).toEqual(b);
const seeds = [11, 12, 13, 14, 15, 16].map((s) =>
layoutPockets(patronWith(items), stream(s))
.map((p) => p.itemId ?? '-')
.join(),
);
expect(new Set(seeds).size).toBeGreaterThan(1);
});
});
describe('scorePatDown', () => {
it('rewards a full sweep: every item found, positive vibe, no aggro', () => {
const patron = patronWith(['flask', 'kebab']);
const result = scorePatDown(patron, binAll(layoutPockets(patron, stream(21))));
expect(result.foundIds.sort()).toEqual(['flask', 'kebab']);
expect(result.missedIds).toEqual([]);
expect(result.vibeDelta).toBe(4);
expect(result.aggroDelta).toBe(0);
expect(result.summary).not.toContain(PATDOWN_MISSED);
});
it('leaks vibe for items left in the pockets when time runs out', () => {
const patron = patronWith(['flask', 'kebab']);
const result = scorePatDown(patron, layoutPockets(patron, stream(22)));
expect(result.foundIds).toEqual([]);
expect(result.missedIds.sort()).toEqual(['flask', 'kebab']);
expect(result.vibeDelta).toBe(-3);
expect(result.summary).toBe(PATDOWN_MISSED);
expect(result.eject).toBe(false);
});
it('mixes found and missed, and still flags the miss', () => {
const patron = patronWith(['flask', 'kebab']);
const pockets = layoutPockets(patron, stream(23)).map((p) => ({
...p,
binned: p.itemId === 'flask',
}));
const result = scorePatDown(patron, pockets);
expect(result.foundIds).toEqual(['flask']);
expect(result.missedIds).toEqual(['kebab']);
expect(result.vibeDelta).toBe(0.5);
expect(result.summary).toContain(PATDOWN_MISSED);
});
it('ejects on a binned severity-3 item but not a severity-1 one', () => {
const baggie = patronWith(['baggie']);
expect(scorePatDown(baggie, binAll(layoutPockets(baggie, stream(31)))).eject).toBe(true);
const kebab = patronWith(['kebab']);
const kebabResult = scorePatDown(kebab, binAll(layoutPockets(kebab, stream(32))));
expect(kebabResult.eject).toBe(false);
expect(kebabResult.foundIds).toEqual(['kebab']);
});
it('does not eject for a severity-3 item that was never binned', () => {
const patron = patronWith(['baggie']);
const result = scorePatDown(patron, layoutPockets(patron, stream(33)));
expect(result.eject).toBe(false);
expect(result.missedIds).toEqual(['baggie']);
});
it('charges aggro and reads PATDOWN_CLEAR when they had nothing on them', () => {
const patron = patronWith([]);
const result = scorePatDown(patron, layoutPockets(patron, stream(41)));
expect(result.foundIds).toEqual([]);
expect(result.missedIds).toEqual([]);
expect(result.aggroDelta).toBe(1.5);
expect(result.vibeDelta).toBe(0);
expect(result.eject).toBe(false);
expect(result.summary).toBe(PATDOWN_CLEAR);
});
});
describe('foundLine', () => {
it('uses the scripted line for a known item', () => {
expect(foundLine('budgie')).toContain('budgie');
});
it('falls back gracefully for an unknown id', () => {
const line = foundLine('a-second-budgie');
expect(typeof line).toBe('string');
expect(line.length).toBeGreaterThan(0);
});
});