not-tonight/tests/incidentReport.test.ts
type-two 37f9892309 LANE-CONTENT: the guest list, the moral encounters, the inspector, the paperwork
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>
2026-07-19 21:32:37 +10:00

324 lines
13 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();
});
});