import { describe, expect, it } from 'vitest'; import { SeededRNG, type RngStream } from '../src/core/SeededRNG'; import { challengesFor, perform, PASS_THRESHOLD, RIDDLES, type Challenge, type ChallengeKind, } from '../src/rules/sobriety'; import type { Patron } from '../src/data/types'; const stream = (seed: number): RngStream => new SeededRNG(seed).stream('sobriety'); const patron = (intoxication: number, tolerance = 0.5): Patron => ({ id: 'p1', dollSeed: 1, archetype: 'punter', age: 24, idCard: { name: 'Shazza Vella', dob: '2002-03-04', expiry: '2029-03-04', photoSeed: 1 }, outfit: [], intoxication, tolerance, flags: {}, }); const challenge = (kind: ChallengeKind, seed = 1): Challenge => { const found = challengesFor(stream(seed)).find((c) => c.kind === kind); if (!found) throw new Error(`no ${kind} challenge`); return found; }; const ALPHABET = challenge('alphabet'); const RIDDLE = challenge('riddle'); /** Mean quality over n independent performances of the given patron shape. */ const meanQuality = (make: (rng: RngStream) => Patron, seed: number, n: number): number => { const rng = stream(seed); let total = 0; for (let i = 0; i < n; i++) total += perform(make(rng), ALPHABET, rng).quality; return total / n; }; const passRate = (make: (rng: RngStream) => Patron, seed: number, n: number): number => { const rng = stream(seed); let passes = 0; for (let i = 0; i < n; i++) if (perform(make(rng), RIDDLE, rng).objectivelySober) passes++; return passes / n; }; const inBand = (lo: number, hi: number) => (rng: RngStream) => patron(lo + rng.next() * (hi - lo)); /** n answers for one patron shape, so the degradation machinery can be inspected. */ const answersFor = ( intoxication: number, tolerance: number, c: Challenge, seed: number, n: number, ): string[] => { const rng = stream(seed); const out: string[] = []; for (let i = 0; i < n; i++) out.push(perform(patron(intoxication, tolerance), c, rng).answer); return out; }; /** Answers that recur verbatim are canned lines; typo'd ones essentially never repeat. */ const cannedShare = (answers: string[], key: (a: string) => string, minRepeats = 5): number => { const counts = new Map(); for (const a of answers) counts.set(key(a), (counts.get(key(a)) ?? 0) + 1); let canned = 0; for (const c of counts.values()) if (c >= minRepeats) canned += c; return canned / answers.length; }; describe('perform', () => { it('is deterministic: same seed + same patron → identical Performance', () => { const a = perform(patron(0.55), RIDDLE, stream(99)); const b = perform(patron(0.55), RIDDLE, stream(99)); expect(a).toEqual(b); }); it('average quality falls monotonically across drunk stages', () => { const sober = meanQuality(inBand(0, 0.2), 3, 200); const tipsy = meanQuality(inBand(0.2, 0.45), 3, 200); const messy = meanQuality(inBand(0.65, 0.85), 3, 200); const maggot = meanQuality(inBand(0.85, 1), 3, 200); expect(sober).toBeGreaterThan(tipsy); expect(tipsy).toBeGreaterThan(messy); expect(messy).toBeGreaterThan(maggot); }); it('a sober patron passes objectively in >95% of rolls', () => { expect(passRate(() => patron(0, 0.5), 5, 200)).toBeGreaterThan(0.95); }); it('a maggot passes objectively in <5% of rolls', () => { expect(passRate(() => patron(0.95, 0.5), 7, 200)).toBeLessThan(0.05); }); it('the loose/messy band stays genuinely ambiguous', () => { const rate = passRate(inBand(0.45, 0.85), 11, 300); expect(rate).toBeGreaterThan(0.15); expect(rate).toBeLessThan(0.85); }); it('quality stays in 0..1 and the answer is never empty', () => { const rng = stream(13); for (let i = 0; i < 500; i++) { const p = patron(rng.next(), rng.next()); const perf = perform(p, i % 2 === 0 ? ALPHABET : RIDDLE, rng); expect(perf.quality).toBeGreaterThanOrEqual(0); expect(perf.quality).toBeLessThanOrEqual(1); expect(perf.answer.length).toBeGreaterThan(0); expect(perf.objectivelySober).toBe(perf.quality >= PASS_THRESHOLD); } }); it('a flawless roll reproduces the ideal answer exactly', () => { const rng = stream(17); let checked = 0; for (let i = 0; i < 400; i++) { const c = i % 2 === 0 ? ALPHABET : RIDDLE; const perf = perform(patron(0, 1), c, rng); if (perf.quality < 0.99) continue; expect(perf.answer).toBe(c.ideal); checked++; } expect(checked).toBeGreaterThan(0); }); it('a messy patron almost never gets the recital out clean', () => { const rng = stream(19); let clean = 0; for (let i = 0; i < 200; i++) { if (perform(patron(0.75, 0.4), ALPHABET, rng).answer === ALPHABET.ideal) clean++; } expect(clean / 200).toBeLessThan(0.05); }); it('tolerance helps at equal intoxication', () => { const hard = meanQuality(() => patron(0.7, 0.9), 23, 200); const soft = meanQuality(() => patron(0.7, 0.1), 23, 200); expect(hard).toBeGreaterThan(soft); }); it('PASS_THRESHOLD is 0.6 and the comparison is inclusive', () => { expect(PASS_THRESHOLD).toBe(0.6); // A stub stream that always sits dead centre => zero jitter, so quality is // exactly 1 - intoxication. 0.4 lands us precisely on the threshold. const flat: RngStream = { next: () => 0.5, int: (min) => min, pick: (arr: readonly T[]): T => arr[0] as T, chance: () => false, weighted: (e: ReadonlyArray): T => e[0]![0], }; const perf = perform(patron(0.4, 0), RIDDLE, flat); expect(perf.quality).toBe(PASS_THRESHOLD); expect(perf.objectivelySober).toBe(true); }); it('a maggot never reads as objectively sober, at any tolerance', () => { // The honesty oracle must not be rescuable by seasoning. Sweep the whole // maggot band against the whole tolerance range, including the corners. const rng = stream(29); let worst = 0; let worstAt = ''; let sober = 0; for (let i = 0; i <= 30; i++) { const intox = 0.85 + (i / 30) * 0.15; for (let t = 0; t <= 20; t++) { const tol = t / 20; for (let k = 0; k < 40; k++) { const perf = perform(patron(intox, tol), RIDDLE, rng); if (perf.objectivelySober) sober++; if (perf.quality > worst) { worst = perf.quality; worstAt = `intoxication ${intox.toFixed(3)}, tolerance ${tol.toFixed(2)}`; } } } } expect({ sober, worstAt }).toEqual({ sober: 0, worstAt }); expect(worst).toBeLessThan(PASS_THRESHOLD); }); it('the alphabet recital loses its reach as quality falls, then gives up', () => { const letters = (a: string): number => (a.match(/[A-Z]/g) ?? []).length; const meanLetters = (intox: number, seed: number): number => { const as = answersFor(intox, 0.5, ALPHABET, seed, 300); return as.reduce((s, a) => s + letters(a), 0) / as.length; }; const nearSober = meanLetters(0.1, 41); const loose = meanLetters(0.55, 43); const maggot = meanLetters(0.95, 47); expect(nearSober).toBeGreaterThan(24); expect(loose).toBeLessThan(nearSober - 3); expect(maggot).toBeLessThan(loose - 3); expect(maggot).toBeLessThan(14); // gave up well before Z..A ran out // The bail is a canned line, so the tails repeat verbatim; a merely // mangled recital would produce ~300 distinct endings. const tails = answersFor(0.95, 0.5, ALPHABET, 53, 300); expect(cannedShare(tails, (a) => a.slice(-14))).toBeGreaterThan(0.5); }); it('riddle answers go from typo to outright non-answer as quality falls', () => { const ideal = RIDDLE.ideal; // Mid-band: recognisably an attempt at the answer, but mangled. const mid = answersFor(0.55, 0.5, RIDDLE, 59, 400); const mangled = mid.filter((a) => a !== ideal && a.length >= ideal.length - 2); expect(mangled.length / mid.length).toBeGreaterThan(0.25); // Bottom: getting it out verbatim is a rare fluke (~0.3%, and that fluke is // deliberate — the minigame is not a reliable oracle), and a real share bail // to canned non-answers. const bottom = answersFor(0.99, 0.5, RIDDLE, 61, 2000); expect(bottom.filter((a) => a === ideal).length / bottom.length).toBeLessThan(0.02); expect(cannedShare(bottom, (a) => a, 20)).toBeGreaterThan(0.2); // ...drawn from a pool, not one stock line. const counts = new Map(); for (const a of bottom) counts.set(a, (counts.get(a) ?? 0) + 1); const pool = [...counts.values()].filter((c) => c >= 20).length; expect(pool).toBeGreaterThanOrEqual(3); }); }); describe('challenges', () => { it('challengesFor returns exactly one alphabet and one riddle from RIDDLES', () => { for (let seed = 0; seed < 25; seed++) { const cs = challengesFor(stream(seed)); expect(cs.map((c) => c.kind).sort()).toEqual(['alphabet', 'riddle']); const riddle = cs.find((c) => c.kind === 'riddle'); expect(RIDDLES.some((r) => r.prompt === riddle?.prompt && r.ideal === riddle?.ideal)).toBe(true); } }); it('the alphabet ideal is exactly the reversed alphabet', () => { const reversed = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').reverse().join(''); expect(ALPHABET.ideal).toBe(reversed); }); it('RIDDLES has 8 usable, uniquely-prompted entries', () => { expect(RIDDLES).toHaveLength(8); for (const r of RIDDLES) { expect(r.prompt.trim().length).toBeGreaterThan(0); expect(r.ideal.trim().length).toBeGreaterThan(0); } expect(new Set(RIDDLES.map((r) => r.prompt)).size).toBe(RIDDLES.length); }); });