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>
345 lines
14 KiB
TypeScript
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);
|
|
});
|
|
});
|