import { describe, expect, it } from 'vitest'; import { SeededRNG } from '../src/core/SeededRNG'; import { buildReport, fileReport, findKaydenContradiction, lieCount, parseFiledAccount, REPORT_MARKER, REPORT_TUNING, type FiledAnswer, type ReportOption, } from '../src/rules/incidentReport'; import type { IncidentRecord } from '../src/data/types'; const stream = (seed = 7) => new SeededRNG(seed).stream('report'); const inc = (over: Partial = {}): IncidentRecord => ({ clockMin: 120, kind: 'ejection', patronId: 'p7', detail: 'fight / messy', ...over, }); /** The shapes the three scenes actually write today (grep `incident:log`). */ const REAL_NIGHT: IncidentRecord[] = [ { clockMin: 12, kind: 'admit', patronId: 'p1', detail: 'punter · id 24' }, { clockMin: 40, kind: 'deny', patronId: 'p2', detail: 'influencer · id 22' }, { clockMin: 55, kind: 'wait', patronId: 'p3', detail: 'regular · id 31' }, { clockMin: 96, kind: 'kayden', patronId: 'p4', detail: 'Kayden waved through almost18' }, { clockMin: 140, kind: 'stallBusted', patronId: 'p5', detail: 'Two out of one cubicle.' }, { clockMin: 210, kind: 'ejection', patronId: 'p6', detail: 'fight / messy' }, { clockMin: 360, kind: 'deferred', patronId: 'p2', detail: 'capacity breach — p2 admitted at 80/80' }, ]; const truthfulOf = (o: readonly ReportOption[]): ReportOption => o.find((x) => x.truthful) ?? o[0]!; const allOptions = (): ReportOption[] => { const kinds = [...Object.keys(REPORT_TUNING.interest), 'somethingNobodyWroteYet']; const seen = new Map(); for (const kind of kinds) { for (const q of buildReport([inc({ kind })], stream())) { for (const o of q.options) seen.set(o.id, o); } } return [...seen.values()]; }; describe('buildReport', () => { it('is deterministic for the same seed', () => { expect(buildReport(REAL_NIGHT, stream(3))).toEqual(buildReport(REAL_NIGHT, stream(3))); }); it('asks at most maxQuestions', () => { const many = Array.from({ length: 30 }, (_, i) => inc({ clockMin: i * 10, patronId: `p${i}` }), ); expect(buildReport(many, stream()).length).toBe(REPORT_TUNING.maxQuestions); }); it('returns fewer questions rather than padding a quiet night', () => { const questions = buildReport([inc({ kind: 'cutOff' })], stream()); expect(questions.length).toBe(1); }); it('returns [] for an empty incident list without throwing', () => { expect(buildReport([], stream())).toEqual([]); }); it('never asks about routine admits or stalls-for-time', () => { const routine = [ inc({ clockMin: 5, kind: 'admit' }), inc({ clockMin: 6, kind: 'wait' }), inc({ clockMin: 7, kind: 'admit' }), ]; expect(buildReport(routine, stream())).toEqual([]); }); it('prefers the interesting incidents in a real night', () => { const kinds = buildReport(REAL_NIGHT, stream(11)).map((q) => q.incident.kind); expect(kinds).toContain('deferred'); expect(kinds).not.toContain('admit'); expect(kinds).not.toContain('wait'); }); it('asks in chronological order', () => { const mins = buildReport(REAL_NIGHT, stream(5)).map((q) => q.incident.clockMin); expect([...mins].sort((a, b) => a - b)).toEqual(mins); }); it('still asks about an incident kind it has never heard of', () => { const questions = buildReport([inc({ kind: 'budgieLoose' })], stream()); expect(questions.length).toBe(1); expect(questions[0]?.options.length).toBeGreaterThanOrEqual(2); }); it('skips rows that are already filed accounts', () => { const filed = fileReport([{ incident: inc(), chosen: { id: 'x', text: 'a', truthful: false } }]); expect(buildReport(filed, stream())).toEqual([]); }); it('offers exactly one truthful account and at least two options per question', () => { for (const kind of [...Object.keys(REPORT_TUNING.interest), 'unheardOf']) { for (const q of buildReport([inc({ kind })], stream())) { expect(q.options.length).toBeGreaterThanOrEqual(2); expect(q.options.length).toBeLessThanOrEqual(3); expect(q.options.filter((o) => o.truthful).length).toBe(1); expect(q.prompt.length).toBeGreaterThan(0); } } }); it('does not always put the truthful account in the same slot', () => { // Without the Fisher-Yates the truthful option sits at a fixed index forever // and the form becomes "click the top button". Deleting the shuffle must // fail a test, not pass silently. const positions = new Set(); for (let seed = 0; seed < 40; seed++) { const q = buildReport([inc({ kind: 'ejection' })], stream(seed))[0]!; positions.add(q.options.findIndex((o) => o.truthful)); } expect(positions.size).toBeGreaterThan(1); }); it('asks about a kind that collides with an Object.prototype member', () => { // `kind` is an open string; an inherited-property lookup would make these // vanish from the form instead of falling back to the default bank. for (const kind of ['toString', 'constructor', 'valueOf', 'hasOwnProperty']) { const questions = buildReport([inc({ kind })], stream()); expect(questions.length, kind).toBe(1); expect(questions[0]?.options.length, kind).toBeGreaterThanOrEqual(2); expect(questions[0]?.options.filter((o) => o.truthful).length, kind).toBe(1); } }); it('gives every option in a question a unique id', () => { for (const q of buildReport(REAL_NIGHT, stream(2))) { expect(new Set(q.options.map((o) => o.id)).size).toBe(q.options.length); } }); }); describe('fileReport', () => { it('round-trips a lie: the account and the truth flag both come back', () => { const question = buildReport([inc({ kind: 'ejection' })], stream())[0]!; const lie = question.options.find((o) => !o.truthful)!; const filed = fileReport([{ incident: question.incident, chosen: lie }])[0]!; const parsed = parseFiledAccount(filed); expect(parsed?.account).toBe(lie.text); expect(parsed?.truthful).toBe(false); expect(parsed?.optionId).toBe(lie.id); expect(parsed?.truthLog).toBe(question.incident.detail); }); it('round-trips the truthful account too', () => { const question = buildReport([inc({ kind: 'cutOff' })], stream())[0]!; const truth = truthfulOf(question.options); const filed = fileReport([{ incident: question.incident, chosen: truth }])[0]!; expect(parseFiledAccount(filed)).toEqual({ account: truth.text, optionId: truth.id, truthful: true, truthLog: question.incident.detail, }); }); it('preserves clockMin, kind and patronId', () => { const incident = inc({ clockMin: 233, kind: 'maggotUnhandled', patronId: 'p42' }); const filed = fileReport([ { incident, chosen: { id: 'maggot.soft1', text: 'Water was made available.', truthful: false } }, ])[0]!; expect(filed.clockMin).toBe(233); expect(filed.kind).toBe('maggotUnhandled'); expect(filed.patronId).toBe('p42'); }); it('leaves patronId off entirely when the incident had none', () => { const incident: IncidentRecord = { clockMin: 90, kind: 'deferred', detail: 'lap' }; const filed = fileReport([ { incident, chosen: { id: 'deferred.true', text: 'My call.', truthful: true } }, ])[0]!; expect('patronId' in filed).toBe(false); }); it('starts detail with the account so a debug dump still reads like a report', () => { const incident = inc(); const chosen: ReportOption = { id: 'ejection.true', text: 'I walked them out.', truthful: true }; const filed = fileReport([{ incident, chosen }])[0]!; expect(filed.detail.startsWith(chosen.text)).toBe(true); expect(filed.detail).toContain(REPORT_MARKER); }); it('survives a truth-log line containing marker-ish punctuation', () => { const incident = inc({ detail: 'weird [report:v1] {"truthful":true} · id 19' }); const filed = fileReport([ { incident, chosen: { id: 'ejection.soft1', text: 'Escorted as a courtesy.', truthful: false } }, ])[0]!; expect(parseFiledAccount(filed)?.truthLog).toBe(incident.detail); expect(parseFiledAccount(filed)?.truthful).toBe(false); }); it('files one row per answer', () => { const answers: FiledAnswer[] = REAL_NIGHT.slice(0, 3).map((incident) => ({ incident, chosen: { id: 'other.true', text: 'The log has it right.', truthful: true }, })); expect(fileReport(answers).length).toBe(3); }); }); describe('parseFiledAccount', () => { it('returns undefined for a raw truth-log row', () => { expect(parseFiledAccount(inc())).toBeUndefined(); }); it('returns undefined for a corrupt payload rather than guessing', () => { expect(parseFiledAccount(inc({ detail: `account${REPORT_MARKER}{not json` }))).toBeUndefined(); expect(parseFiledAccount(inc({ detail: `account${REPORT_MARKER}{"optionId":1}` }))).toBeUndefined(); }); }); describe('lieCount', () => { it('counts only the flattering answers', () => { const answers: FiledAnswer[] = [ { incident: inc(), chosen: { id: 'a', text: 'x', truthful: true } }, { incident: inc(), chosen: { id: 'b', text: 'x', truthful: false } }, { incident: inc(), chosen: { id: 'c', text: 'x', truthful: false } }, ]; expect(lieCount(answers)).toBe(2); expect(lieCount([])).toBe(0); }); }); describe('tone', () => { // §4.3: the satire is on the paperwork and the person holding the torch, never // on the person at the rope. const MEAN = /\b(flog|muppet|idiot|scumbag|junkie|pig|slut|pathetic|deserved|filth|animal|grub)\b/i; it('never blames the patron in a mean-spirited way', () => { for (const o of allOptions()) expect(o.text).not.toMatch(MEAN); }); it('keeps the lies deniable rather than cartoonish', () => { for (const o of allOptions()) { expect(o.text).not.toMatch(/nothing happened|never happened|no incident occurred/i); } }); it('never tells the player whether they chose well', () => { for (const o of allOptions()) { expect(o.text).not.toMatch(/\b(good call|well done|shameful|you should have)\b/i); } }); it('never lets an account pronounce a verdict on the player', () => { // The regex above is a strawman — no writer was ever going to type "well // done" into a bouncer report. The live risk is subtler: a truthful account // that adjudicates ("it was wrong", "they had a point", "I had decided // before they finished") makes truth-telling the self-flagellating branch // and lying the clean one, which is the game saying which choice was right. // §4.3 forbids that. A truthful account states the fact and stops. const VERDICT = /\b(it was wrong|i was wrong|they had a point|should not have|shouldn't have|my fault|i feel|i regret|if i am honest|if i'm honest|to be fair to them|in hindsight|looking at it now)\b/i; for (const o of allOptions()) expect(o.text, o.id).not.toMatch(VERDICT); }); it('writes every account in the first-person report register, no newlines', () => { for (const o of allOptions()) { expect(o.text).not.toContain('\n'); expect(o.text.endsWith('.')).toBe(true); } }); }); // ---- cross-night contradiction (design §3.3's "one taste") ------------------- // // Added after the play-through: a full 3-night run never exercised this, because // the contradiction needs a `kayden` incident, and Kayden only rules the rope // while the player is on the FLOOR. Easy to leave permanently unverified. describe('findKaydenContradiction', () => { const kaydenIncident = (patronId: string): IncidentRecord => ({ clockMin: 120, kind: 'kayden', patronId, detail: 'Kayden waved through punter', }); const fileOne = (incident: IncidentRecord, truthful: boolean): IncidentRecord[] => fileReport([ { incident, chosen: { id: truthful ? 't' : 'f', text: 'an account', truthful } }, ]); it('finds a lie told about a Kayden incident on an earlier night', () => { const past = [fileOne(kaydenIncident('p9'), false)]; const line = findKaydenContradiction(past); expect(line).toBeTruthy(); expect(line).toContain('kayden'); }); it('stays quiet when the Kayden incident was reported honestly', () => { expect(findKaydenContradiction([fileOne(kaydenIncident('p9'), true)])).toBeUndefined(); }); it('ignores lies about incidents that are not Kayden’s', () => { const other: IncidentRecord = { clockMin: 60, kind: 'deferred', patronId: 'p3', detail: 'x' }; expect(findKaydenContradiction([fileOne(other, false)])).toBeUndefined(); }); it('returns the most recent night’s lie when several nights lied', () => { const past = [fileOne(kaydenIncident('pOLD'), false), fileOne(kaydenIncident('pNEW'), false)]; expect(findKaydenContradiction(past)).toContain('pNEW'); }); it('is safe on empty history and on raw truth-log rows', () => { expect(findKaydenContradiction([])).toBeUndefined(); // A row NightScene wrote directly, with no filed-account encoding on it. expect(findKaydenContradiction([[kaydenIncident('p1')]])).toBeUndefined(); }); });