not-tonight/tests/incidentReport.test.ts
type-two 91fe06d023 The paperwork asks by name, and stops repeating itself
Seven loaded incident kinds were falling through to 'an entry was made in the
log' — including radioedIn, which IS the glassie's whole shift. Each now gets a
bespoke prompt with one uncomfortable truth and two institutional softenings.

buildReport also prefers a distinct kind per question: a night with two radio
calls was spending two of three slots on an identical prompt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 21:19:56 +10:00

457 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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> = {}): 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<string, ReportOption>();
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<number>();
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 Kaydens', () => {
const other: IncidentRecord = { clockMin: 60, kind: 'deferred', patronId: 'p3', detail: 'x' };
expect(findKaydenContradiction([fileOne(other, false)])).toBeUndefined();
});
it('returns the most recent nights 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();
});
});
describe('the form survives a busy shift (frequency weighting)', () => {
// buildReport takes the top THREE of the night with no dedupe by kind, so a
// weight is a claim about how often the kind fires as much as how interesting
// it is. A bar shift logs ~26 serves; if those outscored the rare loaded
// stuff, every bartender night would file three identical pour questions.
const barNight = (): IncidentRecord[] => [
...Array.from({ length: 26 }, (_, i) =>
inc({ kind: 'barServe', clockMin: 30 + i * 10, detail: 'serve at tipsy.' }),
),
...Array.from({ length: 9 }, (_, i) => inc({ kind: 'barWater', clockMin: 40 + i * 20 })),
inc({ kind: 'baggiePocketed', clockMin: 350, detail: 'Contraband off the floor. Retained by security.' }),
];
it('asks about the baggie, not the twenty-sixth beer', () => {
// Every seed, not one lucky one: the jitter is < 1 and must never be able
// to promote a routine pour over the thing worth asking about.
for (let seed = 1; seed <= 30; seed++) {
const asked = buildReport(barNight(), stream(seed)).map((q) => q.incident.kind);
expect(asked, `seed ${seed}`).toContain('baggiePocketed');
}
});
it('and the loaded bar kinds outrank the routine ones', () => {
const w = REPORT_TUNING.interest;
expect(w.barServeBreach).toBeGreaterThan(w.barServe!);
expect(w.barCutOff).toBeGreaterThan(w.barServe!);
expect(w.tabMisreturned).toBeGreaterThan(w.tabClosed!);
expect(w.djTip).toBeGreaterThan(w.djDrop!);
});
it('a shift you merely turned up to is not an incident', () => {
const dull = [
inc({ kind: 'barShift', clockMin: 10 }),
inc({ kind: 'djShift', clockMin: 20 }),
inc({ kind: 'lightsOn', clockMin: 355 }),
inc({ kind: 'carpetEntrance', clockMin: 100 }),
];
expect(buildReport(dull, stream())).toHaveLength(0);
});
it('every kind the floor logs has a real question or a deliberate zero', () => {
// Guards the drift that started this: a new kind that nobody weighted lands
// on defaultInterest and quietly competes with the good ones.
const floorKinds = [
'barServe', 'barServeBreach', 'barWater', 'barCutOff', 'barShift',
'djShift', 'djDrop', 'djRequest', 'djTip',
'tabClosed', 'tabMisreturned', 'tabUnclaimed',
'carpetStorm', 'carpetEntrance', 'radioOrder',
'lightsOn', 'floorScore', 'baggiePocketed', 'baggieBinned', 'lostProperty',
];
for (const kind of floorKinds) {
expect(REPORT_TUNING.interest[kind], `'${kind}' is unweighted`).toBeDefined();
}
});
});
describe('the loaded questions are asked BY NAME', () => {
// The report is where §4.3's moral weight lands: a form that shrugs at a
// pocketed find with "an entry was made in the log" is not a test of
// anything. Every kind the game logs that is worth asking about must have
// its own words. This guard exists because seven of them did not.
const rng = () => new SeededRNG(4207).stream('report');
const rec = (kind: string): IncidentRecord => ({ clockMin: 120, kind, detail: 'x' });
const LOADED = [
'baggiePocketed', 'jointPocketed', 'djTip', 'tabMisreturned', 'barServeBreach',
'radioedIn', 'fainter', 'fight', 'rackDropped', 'floorScore', 'vomit', 'slip',
'ejection', 'maggotUnhandled', 'firstAid', 'barCutOff', 'stallBusted',
];
it('every loaded kind gets a bespoke prompt, not the generic one', () => {
const generic = buildReport([rec('somethingNobodyHasEverLogged')], rng())[0]!.prompt;
for (const kind of LOADED) {
const q = buildReport([rec(kind)], rng())[0];
expect(q, `${kind} produced no question at all`).toBeTruthy();
expect(q!.prompt, `${kind} falls through to the generic line`).not.toBe(generic);
}
});
it('and each of them still offers exactly one honest answer', () => {
// The audit can only catch a lie if precisely one option is the truth.
for (const kind of LOADED) {
const q = buildReport([rec(kind)], rng())[0]!;
expect(q.options.filter((o) => o.truthful), kind).toHaveLength(1);
expect(new Set(q.options.map((o) => o.id)).size).toBe(q.options.length);
}
});
it('the glassie can be asked about the one thing they are allowed to do', () => {
// Zero authority means the radio call IS the shift. If the form never asks
// about it, a glassie night files as a blank page.
const q = buildReport([rec('radioedIn')], rng())[0]!;
expect(q.prompt.toLowerCase()).toContain('radio');
expect(q.options.find((o) => o.truthful)!.text).toMatch(/not authorised|do not know/i);
});
});
describe('the form interrogates the NIGHT, not one moment of it', () => {
it('prefers a different kind per question over repeating one', () => {
// Three slots is the whole form. A night with two radio calls used to spend
// two of them on the same prompt, word for word — a stuck machine.
const night: IncidentRecord[] = [
{ clockMin: 90, kind: 'radioedIn', detail: 'a' },
{ clockMin: 150, kind: 'radioedIn', detail: 'b' },
{ clockMin: 200, kind: 'vomit', detail: 'c' },
{ clockMin: 250, kind: 'floorScore', detail: 'd' },
];
const kinds = buildReport(night, new SeededRNG(4207).stream('report')).map((q) => q.incident.kind);
expect(new Set(kinds).size).toBe(kinds.length);
});
it('but still fills the form when a night only had one sort of trouble', () => {
// Four ejections and nothing else is a real night; it should ask three times
// rather than shrink to one question out of tidiness.
const night: IncidentRecord[] = [90, 140, 190, 240].map((clockMin) => ({
clockMin, kind: 'ejection', detail: String(clockMin),
}));
expect(buildReport(night, new SeededRNG(7).stream('report'))).toHaveLength(
REPORT_TUNING.maxQuestions,
);
});
it('and a high-weight repeat never loses its slot to a dull one-off', () => {
const night: IncidentRecord[] = [
{ clockMin: 60, kind: 'baggiePocketed', detail: 'a' },
{ clockMin: 120, kind: 'baggiePocketed', detail: 'b' },
{ clockMin: 180, kind: 'tabClosed', detail: 'c' },
];
const kinds = buildReport(night, new SeededRNG(3).stream('report')).map((q) => q.incident.kind);
expect(kinds.filter((k) => k === 'baggiePocketed').length).toBeGreaterThanOrEqual(1);
});
});