not-tonight/tests/incidentReport.test.ts
type-two fbc959fde8 The report catches up: frequency-aware interest weights and eleven banks for the floor's own paperwork
A bar shift logs ~26 serves and buildReport takes the top 3 with no dedupe, so
bartender nights filed three identical generic pour questions. Weights now price
frequency as well as interest, and the loaded kinds have real questions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 19:43:06 +10:00

380 lines
16 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();
}
});
});