not-tonight/tests/guestList.test.ts
type-two 37f9892309 LANE-CONTENT: the guest list, the moral encounters, the inspector, the paperwork
Phase 3 deliverables 1-4. The door now has something to read rather than a
checklist to tick.

The guest list clipboard shares the desk's left panel with Dazza's rules via a
tab, because there is no spare space at 640x360 and the tradeoff is honest:
checking a name costs you a beat of queue time. The mechanic is measured rather
than asserted — at the generator's real claim rates an exact match is a coin
flip, a near match leans legit, and no match leans liar. Owner's-mate patrons
now drop Terry's name, so the trap has a tell that isn't just confidence.

Scripted encounters (four, three a night) arrive ahead of the arrival curve and
speak in beats. Their consequences are real and the game never says which call
was right — no meter, no chime, no narrator.

The inspector reads as a boring punter; admitted, he converts any live breach
from a 3 AM audit finding into an immediate strike. Denied, nothing happens
ever. Verified in play: a night ended LICENCE PULLED with two of three strikes
his.

The incident report sits between the summary and the next shift. Filed accounts
go to pastReports so Phase 4 audits what the player claimed; NightScene no
longer dumps the raw truth there, which had nothing to catch anyone out with.

Also closes my own Phase-1 finding: judge() now reads intoxication and
contraband. Refusing someone who cannot stand up used to score identically to
refusing a clean punter, so the game's most legible signal was worth nothing.
That gap was also what made quietBeer's humane option secretly optimal on both
meters — the one thing design 4.3 forbids.

518 tests (was 413). Played a full 3-night run: all three nights reached 3 AM,
heat carried 1 -> 2 -> 2. The Phase-1 economy findings are all still true and
this lane did not change them — vibe pinned at 97-98 every night and aggro sat
at 0. Details in LANEHANDOVER.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:32:37 +10:00

345 lines
14 KiB
TypeScript

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<NameMatch, number> = { 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<NameMatch, { genuine: number; liar: number }> = {
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);
});
});