not-tonight/src/rules/incidentReport.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

330 lines
15 KiB
TypeScript

import type { IncidentRecord } from '../data/types';
import type { RngStream } from '../core/SeededRNG';
// The Incident Report (design §3.3). At last drinks the venue asks the player to
// write up the night, and the player may lie. This module builds that form and
// encodes what was filed. The cross-night audit that CATCHES the lies is Phase 4;
// everything here exists to leave that audit something it can read.
//
// Nothing in this file scores anything. There is no honesty meter, no chime for
// telling the truth, and no line of prose that tells the player they did well or
// badly (§4.3). The lie is cheap tonight and that is the whole joke.
//
// ---- STORAGE ENCODING — read before touching fileReport ----
// IncidentRecord is a frozen contract type {clockMin, kind, patronId?, detail},
// so a filed row carries its machine payload inside `detail`:
//
// detail = `${account}` + REPORT_MARKER + JSON.stringify(FiledPayload)
//
// The account the player chose comes FIRST and verbatim, so a debug dump of
// pastReports still reads like a report. Everything Phase 4 needs — which option
// was picked, whether it was true, and the truth-log line the account replaced —
// is JSON after the marker. The marker is newline-prefixed and no authored
// account contains a newline, so it cannot collide with prose.
//
// Parse with parseFiledAccount(). Do not hand-roll a regex over this: it returns
// undefined for a raw truth-log row, which matters because GameState.pastReports
// already holds unfiled NightState.incidents rows from Phase 2.
export interface ReportOption {
id: string;
/** the written account, first person, bouncer-report register */
text: string;
truthful: boolean;
}
export interface ReportQuestion {
incident: IncidentRecord;
prompt: string;
options: ReportOption[];
}
export interface FiledAnswer {
incident: IncidentRecord;
chosen: ReportOption;
}
/** What parseFiledAccount recovers from a filed row. */
export interface FiledAccount {
/** the prose the player filed */
account: string;
optionId: string;
/** false = the player filed a flattering version of this incident */
truthful: boolean;
/** the incident's own `detail` from the truth log, before the account replaced it */
truthLog: string;
}
export const REPORT_TUNING = {
/** Three questions is twenty seconds. A fourth turns the bit into homework. */
maxQuestions: 3,
// How worth-asking-about each incident kind is. Kinds are open strings written
// by three different scenes (DoorScene verdicts + 'kayden', NightScene
// 'deferred', FloorDemoScene's five), so this is a lookup, not a union.
//
// Zero means "never ask": a clean admit is not a story, and a night is mostly
// clean admits. Ties are broken by an rng jitter below 1, so a weight is a
// band, never a fixed running order.
interest: {
deferred: 10, // a breach the audit already found — the awkward one
ejection: 8,
maggotUnhandled: 7,
kayden: 6, // his mistake, your signature
cutOff: 5,
stallBusted: 5,
patDown: 4, // both door and floor log this kind; one bank covers both
deny: 3,
sobrietyTest: 2,
admit: 0,
wait: 0,
} as Record<string, number>,
/**
* Weight for a kind nobody listed. Deliberately askable: a lane that adds an
* incident kind should find it turning up in the form, not silently missing.
*/
defaultInterest: 2,
};
/** Separates the filed account from its machine payload inside `detail`. */
export const REPORT_MARKER = '\n[report:v1]';
interface FiledPayload {
optionId: string;
truthful: boolean;
truthLog: string;
}
interface AccountBank {
prompt: string;
options: readonly ReportOption[];
}
// The register: clipped, passive where a real report hedges, faintly defensive.
// The joke is SPECIFICITY vs MUSH — the truthful option names what actually
// happened and who did it; the flattering ones are plausible deniability, the
// account a tired adult would actually write and could actually defend, never a
// cartoon denial that nothing occurred (a lie you can't say out loud isn't a
// choice, it's a button).
//
// §4.3: no account may pronounce a VERDICT on the player's night. "It was
// wrong", "they had a point", "I had decided before they finished" all tell the
// player which choice was right, which is the one thing this game does not do.
// A truthful account states the fact and stops. The reader supplies the wince.
const BANKS: Record<string, AccountBank> = {
deferred: {
prompt: 'Flagged on the nightly audit. Your account?',
options: [
{ id: 'deferred.true', text: 'I let them in. That was my call at the desk and my name is on the sheet.', truthful: true },
{ id: 'deferred.soft1', text: 'Patron presented as compliant at the point of entry. Lighting at the rope is what it is.', truthful: false },
{ id: 'deferred.soft2', text: 'Entry was authorised on the information available at the desk at that time.', truthful: false },
],
},
ejection: {
prompt: 'A patron was removed from the premises. Your account?',
options: [
{ id: 'ejection.true', text: 'I walked them out. They did not want to go and I did not give them the option.', truthful: true },
{ id: 'ejection.soft1', text: 'Patron elected to leave and was accompanied to the exit as a courtesy.', truthful: false },
{ id: 'ejection.soft2', text: 'An escort was offered and accepted. No more than a guiding hand was required.', truthful: false },
],
},
maggotUnhandled: {
prompt: 'A patron was left a while in a poor state. Your account?',
options: [
{ id: 'maggot.true', text: 'I saw them and I was on the door. Nobody got to them for a good while.', truthful: true },
{ id: 'maggot.soft1', text: 'Patron was monitored at intervals from the floor. Water was made available.', truthful: false },
{ id: 'maggot.soft2', text: 'Patron was seated and stable. No intervention was assessed as necessary at that point.', truthful: false },
],
},
kayden: {
prompt: 'A ruling was made at the rope while you were inside. Your account?',
options: [
{ id: 'kayden.true', text: 'I was on the floor. Kayden made that call and I heard about it after.', truthful: true },
{ id: 'kayden.soft1', text: 'Door was covered under my direction while I attended a matter inside.', truthful: false },
{ id: 'kayden.soft2', text: 'Ruling was consistent with the brief given to the second on the door.', truthful: false },
],
},
cutOff: {
prompt: 'A patron was refused further service. Your account?',
options: [
{ id: 'cutOff.true', text: 'I cut them off. They were filthy about it and they said so at length.', truthful: true },
{ id: 'cutOff.soft1', text: 'Service was concluded in line with RSA. Patron was cooperative throughout.', truthful: false },
{ id: 'cutOff.soft2', text: 'A water was provided and the matter was resolved at the bar without escalation.', truthful: false },
],
},
stallBusted: {
prompt: 'Two patrons were found in one cubicle. Your account?',
options: [
{ id: 'stall.true', text: 'I banged on the door until it opened. Both were spoken to and both were embarrassed.', truthful: true },
{ id: 'stall.soft1', text: 'Cubicle was checked as part of a routine sweep. Occupants dispersed on request.', truthful: false },
{ id: 'stall.soft2', text: 'One patron was assisting another who was unwell. Nothing further was observed.', truthful: false },
],
},
patDown: {
prompt: 'A patron was searched. Your account?',
options: [
{ id: 'patDown.true', text: 'I searched them on a hunch and the whole queue watched me do it.', truthful: true },
{ id: 'patDown.soft1', text: 'Search was conducted with consent as part of a routine entry check.', truthful: false },
{ id: 'patDown.soft2', text: 'Pockets and bag were checked in line with venue policy. Patron was thanked.', truthful: false },
],
},
deny: {
prompt: 'Entry was refused. Your account?',
options: [
{ id: 'deny.true', text: 'I knocked them back for the reason I had at the time. They copped it at the rope.', truthful: true },
{ id: 'deny.soft1', text: 'Entry was declined under the dress code as advised by management on the night.', truthful: false },
{ id: 'deny.soft2', text: 'Patron did not meet conditions of entry. Advised of same, politely, at the rope.', truthful: false },
],
},
sobrietyTest: {
prompt: 'A patron was assessed at the desk. Your account?',
options: [
{ id: 'sobriety.true', text: 'I tested them at the desk myself. It took a minute and I called it there.', truthful: true },
{ id: 'sobriety.soft1', text: 'A brief assessment was carried out at the desk and the result recorded.', truthful: false },
],
},
};
// For kinds this file has never heard of. Vague on purpose — the player is
// signing something they did not read, which is the correct amount of realism.
const DEFAULT_BANK: AccountBank = {
prompt: 'An entry was made in the log. Your account?',
options: [
{ id: 'other.true', text: 'It went in the log at the time and the log has it about right.', truthful: true },
{ id: 'other.soft1', text: 'Logged for completeness. Nothing arising required action on the night.', truthful: false },
],
};
// Load-time, like dressCode's card check: a question with two truthful options
// or a duplicate id is a form the Phase 4 audit cannot read, and finding that out
// at 3 AM in a summary screen is finding it out too late.
for (const [key, bank] of Object.entries({ ...BANKS, _default: DEFAULT_BANK })) {
if (bank.options.length < 2) {
throw new Error(`incidentReport: bank '${key}' offers no choice`);
}
if (bank.options.filter((o) => o.truthful).length !== 1) {
throw new Error(`incidentReport: bank '${key}' must have exactly one truthful account`);
}
if (new Set(bank.options.map((o) => o.id)).size !== bank.options.length) {
throw new Error(`incidentReport: bank '${key}' has duplicate option ids`);
}
}
// Own-property lookups only. `kind` is an open string, so a kind that happens to
// name an Object.prototype member ('toString', 'constructor', 'valueOf') would
// otherwise resolve to an inherited function: `interestOf` would return a
// Function, `fn > 0` is false, and the incident would vanish from the form — the
// exact silent-drop this module's defaultInterest exists to prevent.
const own = <T>(table: Record<string, T>, key: string): T | undefined =>
Object.prototype.hasOwnProperty.call(table, key) ? table[key] : undefined;
const interestOf = (incident: IncidentRecord): number =>
own(REPORT_TUNING.interest, incident.kind) ?? REPORT_TUNING.defaultInterest;
/** Fisher-Yates, so the truthful account is not always the top button. */
function shuffled(options: readonly ReportOption[], rng: RngStream): ReportOption[] {
const out = [...options];
for (let i = out.length - 1; i > 0; i--) {
const j = rng.int(0, i);
const a = out[i]!;
const b = out[j]!;
out[i] = b;
out[j] = a;
}
return out;
}
export function buildReport(
incidents: readonly IncidentRecord[],
rng: RngStream,
): ReportQuestion[] {
const scored = incidents
// Already-filed rows are skipped rather than trusted: pastReports holds both
// shapes, and asking the player to file an account of an account is a loop.
.filter((i) => interestOf(i) > 0 && parseFiledAccount(i) === undefined)
.map((incident) => ({ incident, score: interestOf(incident) + rng.next() }));
// Jitter is < 1 and weights are integers, so shuffling never promotes a dull
// incident over an interesting one — it only reorders within a band.
scored.sort((a, b) => b.score - a.score);
// Never padded: an uneventful night gets a short form, which is its own joke.
const asked = scored.slice(0, REPORT_TUNING.maxQuestions);
// Filed in the order it happened, because that is how a report reads and how
// Phase 4 will want to diff two nights against each other.
asked.sort((a, b) => a.incident.clockMin - b.incident.clockMin);
return asked.map(({ incident }) => {
const bank = own(BANKS, incident.kind) ?? DEFAULT_BANK;
return { incident, prompt: bank.prompt, options: shuffled(bank.options, rng) };
});
}
/** The rows to push onto GameState.pastReports. See the encoding note up top. */
export function fileReport(answers: readonly FiledAnswer[]): IncidentRecord[] {
return answers.map(({ incident, chosen }) => {
const payload: FiledPayload = {
optionId: chosen.id,
truthful: chosen.truthful,
truthLog: incident.detail,
};
const filed: IncidentRecord = {
clockMin: incident.clockMin,
kind: incident.kind,
detail: `${chosen.text}${REPORT_MARKER}${JSON.stringify(payload)}`,
};
// Spread rather than assigning undefined: patronId is optional and a present
// key holding undefined survives JSON round-trips as a different shape.
return incident.patronId === undefined ? filed : { ...filed, patronId: incident.patronId };
});
}
/** undefined for anything that is not a filed report row — including truth-log rows. */
export function parseFiledAccount(record: IncidentRecord): FiledAccount | undefined {
const at = record.detail.indexOf(REPORT_MARKER);
if (at < 0) return undefined;
let parsed: unknown;
try {
parsed = JSON.parse(record.detail.slice(at + REPORT_MARKER.length));
} catch {
return undefined;
}
if (typeof parsed !== 'object' || parsed === null) return undefined;
const { optionId, truthful, truthLog } = parsed as Partial<FiledPayload>;
if (typeof optionId !== 'string' || typeof truthful !== 'boolean' || typeof truthLog !== 'string') {
return undefined;
}
return { account: record.detail.slice(0, at), optionId, truthful, truthLog };
}
export function lieCount(answers: readonly FiledAnswer[]): number {
return answers.filter((a) => !a.chosen.truthful).length;
}
/**
* Design §3.3's "one taste" of the long-memory conscience: Kayden keeps his own
* log, and it does not flatter anyone. If a PREVIOUS night's filed report
* embellished a Kayden incident, this is what Dazza reads out the next morning.
*
* Phase 4 generalises this to a full cross-night audit with strikes attached;
* for now it is one line, arriving one night late, which is the shape of the
* mechanic in miniature.
*/
export function findKaydenContradiction(
pastReports: readonly (readonly IncidentRecord[])[],
): string | undefined {
for (let n = pastReports.length - 1; n >= 0; n--) {
for (const record of pastReports[n] ?? []) {
if (record.kind !== 'kayden') continue;
const filed = parseFiledAccount(record);
if (!filed || filed.truthful) continue;
return `kayden wrote his version of ${record.patronId ?? 'that one'} up too. it does not match urs. he is very proud of the log`;
}
}
return undefined;
}