not-tonight/tests/floor/patDown.test.ts
type-two 595371f684 Phase 1 review: merge door/floor/juice, fold CCRs into contracts, integration fixes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 19:26:35 +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/data/strings/floor';
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);
});
});