import { describe, expect, it } from 'vitest'; import { SeededRNG, type RngStream } from '../src/core/SeededRNG'; import { ARCHETYPES } from '../src/data/archetypes'; import { assignListedName, editDistance, generateGuestList, lookupName, GUEST_LIST_TUNING as T, type GuestList, type NameMatch, } from '../src/rules/guestList'; const stream = (seed: number) => new SeededRNG(seed).stream('guestList'); /** * A real stream with its FIRST `next()` forced to `roll`. assignListedName picks * an entry and then draws exactly one `next()` to choose its branch, so this * pins which branch runs while leaving every corruption draw genuinely random. * Without it the branches are only observable in aggregate, and aggregate * proportions are far too blunt to see a class leak. */ function forcedBranch(seed: number, roll: number): RngStream { const r = stream(seed); let spent = false; return { next: () => { if (spent) return r.next(); spent = true; return roll; }, int: (min, max) => r.int(min, max), pick: (arr) => r.pick(arr), chance: (p) => r.chance(p), weighted: (entries) => r.weighted(entries), }; } const listFor = (seed: number, size = 12): GuestList => generateGuestList(stream(seed), size); const classOf = (claimed: string, list: GuestList): NameMatch => lookupName(claimed, list).match; /** Class mix over `n` claims, all with the same truth value. */ function sample(seed: number, list: GuestList, genuine: boolean, n = 500) { const rng = stream(seed); const counts: Record = { exact: 0, near: 0, none: 0 }; const claims: string[] = []; for (let i = 0; i < n; i++) { const claimed = assignListedName(rng, list, genuine); claims.push(claimed); counts[classOf(claimed, list)]++; } return { counts, claims }; } describe('editDistance', () => { it('is zero for identical strings and for two empties', () => { expect(editDistance('', '')).toBe(0); expect(editDistance('Shazza Nguyen', 'Shazza Nguyen')).toBe(0); }); it('against an empty string costs one edit per character', () => { expect(editDistance('', 'Doyle')).toBe(5); expect(editDistance('Doyle', '')).toBe(5); }); it('charges one for a single insert, delete or substitute', () => { expect(editDistance('Kylie', 'Kyliee')).toBe(1); expect(editDistance('Kylie', 'Kyle')).toBe(1); expect(editDistance('Kylie', 'Kylia')).toBe(1); }); it('charges two for a transposition — this is Levenshtein, not Damerau', () => { // Load-bearing for nearThreshold: a swapped pair is the most expensive // variant the module produces, and it has to stay inside the threshold. expect(editDistance('Marco', 'Mraco')).toBe(2); expect(editDistance('Brooke Vella', 'Brooke Vlela')).toBe(2); }); it('ignores case and normalises whitespace', () => { expect(editDistance('BAZZA SMITH', 'bazza smith')).toBe(0); expect(editDistance(' Bazza Smith ', 'Bazza Smith')).toBe(0); expect(editDistance('Bazza\tSmith', 'bazza smith')).toBe(0); }); it('is symmetric and agrees with hand-counted multi-edit cases', () => { expect(editDistance('Chantelle', 'Chantel')).toBe(2); expect(editDistance('Chantel', 'Chantelle')).toBe(2); expect(editDistance('Ngaio Taufa', 'Naigo Toufa')).toBe(3); }); }); describe('generateGuestList', () => { it('is deterministic: same seed → identical list', () => { expect(listFor(4207)).toEqual(listFor(4207)); expect(listFor(4207)).not.toEqual(listFor(4208)); }); it('clamps the size to 8..14 however it is asked', () => { for (const asked of [-5, 0, 3, 8, 11, 14, 40, NaN]) { const n = generateGuestList(stream(77), asked).entries.length; expect(n).toBeGreaterThanOrEqual(T.minEntries); expect(n).toBeLessThanOrEqual(T.maxEntries); } }); it('never repeats a name and draws only from the crowd the generator uses', () => { for (let seed = 0; seed < 60; seed++) { const names = listFor(seed, 14).entries.map((e) => e.name); expect(new Set(names).size).toBe(names.length); for (const name of names) expect(name).toMatch(/^[A-Za-z]+ [A-Za-z]+$/); } }); it('keeps every pair of entries at least entrySeparation apart', () => { // The whole clipboard rests on this: if two entries were within the // threshold of one variant, that variant would point at both and the player // would be guessing rather than reading. The pools themselves cannot // promise it — Bazza and Dazza are one edit apart — so the list enforces it. // Asserted against the literal 4 as well as the constant: every other test // in this file reads T.nearThreshold on both sides of the comparison, so // without a literal somewhere a retune of the threshold is invisible. expect(T.nearThreshold).toBe(2); expect(T.entrySeparation).toBe(2 * T.nearThreshold); for (let seed = 0; seed < 120; seed++) { const { entries } = listFor(seed, 14); expect(entries.length).toBe(T.maxEntries); for (let i = 0; i < entries.length; i++) { for (let j = i + 1; j < entries.length; j++) { expect(editDistance(entries[i]!.name, entries[j]!.name)).toBeGreaterThan(4); } } } }); it('hands out 0..3 plus-ones, mostly none', () => { const all = Array.from({ length: 40 }, (_, s) => listFor(s, 14).entries).flat(); for (const e of all) { expect(Number.isInteger(e.plusOnes)).toBe(true); expect(e.plusOnes).toBeGreaterThanOrEqual(0); expect(e.plusOnes).toBeLessThanOrEqual(3); } const alone = all.filter((e) => e.plusOnes === 0).length / all.length; expect(alone).toBeGreaterThan(0.5); }); }); describe('lookupName', () => { it('classifies by distance and returns the closest entry', () => { const list = listFor(31); const target = list.entries[3]!; expect(lookupName(target.name, list)).toEqual({ match: 'exact', entry: target, distance: 0 }); const near = lookupName(`${target.name.slice(0, 1)}${target.name}`, list); expect(near.match).toBe('near'); expect(near.entry).toBe(target); expect(near.distance).toBe(1); }); it('a miss reports its distance but carries no entry', () => { // Anything handed back here is a name the door UI would highlight. const r = lookupName('Kayden Kayden', listFor(31)); expect(r.match).toBe('none'); expect(r.entry).toBeUndefined(); expect(r.distance).toBeGreaterThan(T.nearThreshold); }); it('an empty clipboard matches nothing and reports infinite distance', () => { const r = lookupName('Shazza Nguyen', { entries: [] }); expect(r.match).toBe('none'); expect(r.entry).toBeUndefined(); expect(r.distance).toBe(Number.POSITIVE_INFINITY); }); it('survives whatever a name field can contain', () => { const list = listFor(5); const odd = ['', ' ', '\n\t', '!!!', "O'Brien-Smith (the tall one)", '???? ????', '🍢 kebab', 'x'.repeat(5000), list.entries[0]!.name.toUpperCase(), ` ${list.entries[0]!.name} `]; for (const claimed of odd) { const r = lookupName(claimed, list); expect(['exact', 'near', 'none']).toContain(r.match); expect(r.distance).toBeGreaterThanOrEqual(0); } // Casing and stray whitespace must not cost a real guest their entry. expect(classOf(list.entries[0]!.name.toUpperCase(), list)).toBe('exact'); expect(classOf(` ${list.entries[0]!.name} `, list)).toBe('exact'); }); }); describe('assignListedName', () => { it('is deterministic and never throws on an empty clipboard', () => { const a = Array.from({ length: 20 }, () => 0); const runA = ((rng) => a.map(() => assignListedName(rng, { entries: [] }, true)))(stream(3)); const runB = ((rng) => a.map(() => assignListedName(rng, { entries: [] }, true)))(stream(3)); expect(runA).toEqual(runB); for (const name of runA) expect(name.length).toBeGreaterThan(0); }); it('a genuine lister mostly lands NEAR, sometimes exact, rarely unreadable', () => { const list = listFor(11); const { counts, claims } = sample(101, list, true); const n = claims.length; expect(counts.near / n).toBeCloseTo(T.genuineNear, 1); expect(counts.exact / n).toBeCloseTo(T.genuineExact, 1); expect(counts.none / n).toBeCloseTo(T.genuineGarbled, 1); }); it('a liar mostly lands NONE, but lifts a real entry often enough to matter', () => { const list = listFor(11); const { counts, claims } = sample(202, list, false); const n = claims.length; expect(counts.none / n).toBeCloseTo(T.liarInvented, 1); expect(counts.exact / n).toBeCloseTo(T.liarExact, 1); expect(counts.near / n).toBeCloseTo(T.liarNear, 1); expect(counts.exact).toBeGreaterThan(0); }); it('every claim is exactly on the list, inside the threshold, or clearly off it', () => { const list = listFor(11); const names = new Set(list.entries.map((e) => e.name)); for (const genuine of [true, false]) { for (const claimed of sample(303, list, genuine).claims) { const r = lookupName(claimed, list); // The only non-tautological halves: an 'exact' really is a verbatim row // (not a case/whitespace lookalike), and a 'near' really is not. if (r.match === 'exact') expect(names.has(claimed)).toBe(true); if (r.match === 'near') expect(names.has(claimed)).toBe(false); } } }); it('EVERY branch produces the class it intended, not just on average', () => { // The spec's hard requirement, and the one thing aggregate proportions // cannot see. A near variant that comes back distance 0 is an accidental // 'exact'; a garble that lands inside the threshold is an accidental // 'near'. Both are rare enough (~1% and ~23% of raw corruptions // respectively) to hide inside a toBeCloseTo on the branch mix, and both // move the posteriors. nearVariant() and garbled() each re-check their own // output before returning; if either check is removed, this fails and the // proportion tests above do not. const list = listFor(11); const bands: Array<[string, boolean, number, number, NameMatch]> = [ ['genuine → exact', true, 0, T.genuineExact, 'exact'], ['genuine → near', true, T.genuineExact, T.genuineExact + T.genuineNear, 'near'], ['genuine → garbled', true, T.genuineExact + T.genuineNear, 1, 'none'], ['liar → lifted exact', false, 0, T.liarExact, 'exact'], ['liar → near', false, T.liarExact, T.liarExact + T.liarNear, 'near'], ['liar → invented', false, T.liarExact + T.liarNear, 1, 'none'], ]; bands.forEach(([label, genuine, lo, hi, expected], band) => { for (let i = 0; i < 600; i++) { // Each band gets its own seed range: the forced roll does not perturb // the corruption draws, so sharing seeds across bands would re-test one // set of variants six times and shrink the real sample to a sixth. // 600 distinct corruptions puts a ~1% leak beyond plausible deniability. const seed = band * 1000 + i; // Walk the whole band, not just its midpoint: the boundaries are where // an off-by-one in the cumulative comparisons would show up. const roll = lo + ((hi - lo) * (i % 20)) / 20; const claimed = assignListedName(forcedBranch(seed, roll), list, genuine); expect({ label, roll, claimed, got: classOf(claimed, list) }).toEqual({ label, roll, claimed, got: expected, }); } }); }); it('a near claim is never within the threshold of two different entries', () => { // entrySeparation is 2 * nearThreshold precisely so this holds: a variant // sits <= nearThreshold from its source, so a gap of merely nearThreshold+1 // would let it land one edit from a neighbour and lookupName would hand the // door UI the wrong row, with someone else's plus-ones on it. for (let s = 0; s < 60; s++) { const rng = stream(s); const list = generateGuestList(rng, 14); for (let i = 0; i < 150; i++) { const claimed = assignListedName(rng, list, i % 2 === 0); if (classOf(claimed, list) !== 'near') continue; const within = list.entries.filter((e) => editDistance(claimed, e.name) <= T.nearThreshold); expect(within).toHaveLength(1); } } }); it('the claimant prior matches the archetype table it was derived from', () => { // claimantGenuineRate is copied maths, and copied maths rots. If someone // retunes onGuestList/claimsGuestList in data/archetypes.ts, the posteriors // below move and this fails first. let claims = 0; let listed = 0; for (const a of ARCHETYPES) { const onList = a.chances.onGuestList ?? 0; const claimP = onList + (1 - onList) * (a.chances.claimsGuestList ?? 0); claims += a.weight * claimP; listed += a.weight * onList; } expect(listed / claims).toBeCloseTo(T.claimantGenuineRate, 2); }); it('an exact match is a coin flip, near leans legit, none leans liar', () => { // THE test. Everything else in this file is scaffolding for it: the door is // only playable if the three match classes carry different, readable, and // never-certain odds. Nail these and the clipboard is a skill; let exact // drift to either extreme and it is either a lookup table or a dice roll. const rng = stream(9001); const list = generateGuestList(rng, 12); const seen: Record = { exact: { genuine: 0, liar: 0 }, near: { genuine: 0, liar: 0 }, none: { genuine: 0, liar: 0 }, }; for (let i = 0; i < 12000; i++) { const genuine = rng.chance(T.claimantGenuineRate); const bucket = seen[classOf(assignListedName(rng, list, genuine), list)]; if (genuine) bucket.genuine++; else bucket.liar++; } const posterior = (m: NameMatch): number => { const b = seen[m]; expect(b.genuine + b.liar).toBeGreaterThan(200); return b.genuine / (b.genuine + b.liar); }; const exact = posterior('exact'); const near = posterior('near'); const none = posterior('none'); expect(exact).toBeGreaterThan(0.42); expect(exact).toBeLessThan(0.58); expect(near).toBeGreaterThan(0.55); expect(none).toBeLessThan(0.1); // Ordered and, crucially, never certain in either direction: an honest // patron can read as a chancer and a chancer can read as honest. expect(none).toBeLessThan(exact); expect(exact).toBeLessThan(near); expect(near).toBeLessThan(1); expect(none).toBeGreaterThan(0); }); });