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