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