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

574 lines
28 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 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.
// A weight is a band, and it has to price FREQUENCY as well as interest. The
// form takes the top three of the night, so a kind that fires twenty-six
// times at weight 6 IS the form — three near-identical questions and every
// rare, loaded thing crowded out. Rule of thumb: once-a-night and awkward
// scores high, many-times-a-night scores 0-2 however interesting it feels.
interest: {
deferred: 10, // a breach the audit already found — the awkward one
jointPocketed: 9, // the one question you really don't want asked
baggiePocketed: 9, // ...and the one they ask when the lights are on
barServeBreach: 8, // a pour into someone who could not find the glass
djTip: 8, // money changed hands at the booth and the booth took it
tabMisreturned: 8, // somebody went home with another person's bank card
ejection: 8,
firstAid: 7,
fainter: 7,
maggotUnhandled: 7,
fight: 6,
kayden: 6, // his mistake, your signature
cutOff: 5,
barCutOff: 5, // the bar's own refusal — same call, other side of the counter
stallBusted: 5,
jointIgnored: 5,
rackDropped: 4,
tabUnclaimed: 4,
patDown: 4, // both door and floor log this kind; one bank covers both
phoneConfiscated: 4,
baggieBinned: 3,
jointBinned: 3,
carpetStorm: 3,
radioOrder: 3, // you did not do it; you said who would
radioedIn: 6, // the glassie's whole shift: you saw it and could only say so
deny: 3,
vomit: 2,
slip: 2,
floorScore: 2,
sobrietyTest: 2,
barWater: 2,
djRequest: 2,
// High-frequency or no-story. Zero means never asked; a night is mostly
// these, and a form full of them is a form nobody reads.
barServe: 1, // ~26 a night on a bar shift. Pouring a beer is not a story.
djDrop: 1,
tabClosed: 1,
lostProperty: 1,
glassieRun: 1,
waterServed: 1,
mopped: 0,
wetFloorSign: 0,
phoneAsked: 0,
phoneAllowed: 0,
barShift: 0, // holding the taps is a shift, not an incident
djShift: 0,
carpet: 0,
carpetEntrance: 0,
lightsOn: 0,
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 },
],
},
jointPocketed: {
prompt: 'Contraband was located in the smoking area. Where is it now?',
options: [
{ id: 'jointP.true', text: 'On me. I took possession and I still have possession. That is the account.', truthful: true },
{ id: 'jointP.soft1', text: 'Item was destroyed on the spot in view of the patron.', truthful: false },
{ id: 'jointP.soft2', text: 'Item was placed in the amnesty bin per procedure. The bin was emptied by close.', truthful: false },
],
},
jointBinned: {
prompt: 'Contraband was located in the smoking area. Your account?',
options: [
{ id: 'jointB.true', text: 'Confiscated and binned in front of them. The yard went quiet for a bit.', truthful: true },
{ id: 'jointB.soft1', text: 'Patron surrendered the item voluntarily and thanked security for the chat.', truthful: false },
{ id: 'jointB.soft2', text: 'Item was assessed as herbal cigarettes. Binned as general litter.', truthful: false },
],
},
jointIgnored: {
prompt: 'A smell was reported near the smoking area. Your account?',
options: [
{ id: 'jointI.true', text: 'I smelled it, I saw it, and I decided the fence line was more interesting.', truthful: true },
{ id: 'jointI.soft1', text: 'Area was patrolled at the time stated. Nothing of note was observed.', truthful: false },
{ id: 'jointI.soft2', text: 'Smell was traced to the kebab shop exhaust across the road.', truthful: false },
],
},
// ---- the floor's own paperwork (bar, booth, carpet, close) ----------------
baggiePocketed: {
prompt: 'Contraband was recovered from the floor after close. Where is it now?',
options: [
{ id: 'baggieP.true', text: 'In my pocket. I picked it up off the floor and I kept it. That is where it is.', truthful: true },
{ id: 'baggieP.soft1', text: 'Item was disposed of during the close-down sweep. No patron was present to caution.', truthful: false },
{ id: 'baggieP.soft2', text: 'Item was bagged and left for the licensee per the amnesty procedure.', truthful: false },
],
},
baggieBinned: {
prompt: 'Contraband was recovered from the floor after close. Your account?',
options: [
{ id: 'baggieB.true', text: 'Found it on the floor at lights-on and put it straight in the amnesty bin.', truthful: true },
{ id: 'baggieB.soft1', text: 'A small quantity of unidentified material was disposed of as general waste.', truthful: false },
{ id: 'baggieB.soft2', text: 'Nothing of note was recovered. The sweep was routine and unremarkable.', truthful: false },
],
},
barServeBreach: {
prompt: 'Service was provided to a patron in an advanced state. Your account?',
options: [
{ id: 'barSB.true', text: 'I poured it and I handed it over. It took them three goes to find the glass.', truthful: true },
{ id: 'barSB.soft1', text: 'Patron presented as coherent at the point of service. Assessment was made at the bar.', truthful: false },
{ id: 'barSB.soft2', text: 'Beverage was supplied to a member of the same group and not to the patron named.', truthful: false },
],
},
// Added 2026-07-22: these kinds were being asked about with the generic
// "an entry was made in the log" line. The report is where the game's moral
// weight actually lands (design §4.3), so the loaded ones need to be asked
// about BY NAME — a form that shrugs at a pocketed find is not a test.
/**
* The glassie's whole shift, in one question. You saw it, you had no standing
* to touch it, you said so on the radio — and then it was somebody else's
* call whether anything happened. The truthful option is the only one that
* admits nobody came.
*/
radioedIn: {
prompt: 'An issue was called in over the radio rather than actioned. Your account?',
options: [
{ id: 'radio.true', text: 'I called it in. I am not authorised to touch it. I do not know if anyone went.', truthful: true },
{ id: 'radio.soft1', text: 'Escalated to floor staff in line with procedure and handed over cleanly.', truthful: false },
{ id: 'radio.soft2', text: 'Reported and monitored until resolved. No further intervention was required.', truthful: false },
],
},
fainter: {
prompt: 'A patron became unresponsive on the floor. Your account?',
options: [
{ id: 'faint.true', text: 'Someone went down. I got to them and did what I could remember of a first-aid course.', truthful: true },
{ id: 'faint.soft1', text: 'Patron was assisted promptly and recovered without incident.', truthful: false },
{ id: 'faint.soft2', text: 'A brief episode was observed. Patron declined assistance and left with friends.', truthful: false },
],
},
fight: {
prompt: 'A physical altercation developed between patrons. Your account?',
options: [
{ id: 'fight.true', text: 'Two blokes squared up over nothing at all and I was not close enough, fast enough, or both.', truthful: true },
{ id: 'fight.soft1', text: 'A verbal disagreement was de-escalated by staff before physical contact occurred.', truthful: false },
{ id: 'fight.soft2', text: 'Patrons were separated immediately and both parties left the premises calmly.', truthful: false },
],
},
rackDropped: {
prompt: 'Glassware was broken in the service area. Your account?',
options: [
{ id: 'rack.true', text: 'I had the rack too high and I took the corner too fast. That one is mine.', truthful: true },
{ id: 'rack.soft1', text: 'A rack was destabilised by patron contact in a congested area.', truthful: false },
{ id: 'rack.soft2', text: 'Breakage occurred during routine collection. Area was made safe immediately.', truthful: false },
],
},
/** You swept cash off the floor at close and it did not go in the till. */
floorScore: {
prompt: 'Currency was recovered from the floor after close. Your account?',
options: [
{ id: 'score.true', text: 'I picked it up off the floor and it went in my pocket. It was on the floor.', truthful: true },
{ id: 'score.soft1', text: 'Recovered currency was logged and placed with lost property for the standard period.', truthful: false },
{ id: 'score.soft2', text: 'Loose change was collected during the close-down sweep and returned to the till.', truthful: false },
],
},
vomit: {
prompt: 'A biological spill occurred on the floor. Your account?',
options: [
{ id: 'vom.true', text: 'Someone redecorated the dance floor and I dealt with it in that order: sign, then mop.', truthful: true },
{ id: 'vom.soft1', text: 'A spill was identified, signed and cleaned within minutes of occurring.', truthful: false },
{ id: 'vom.soft2', text: 'Minor spillage attended to as part of routine floor maintenance.', truthful: false },
],
},
slip: {
prompt: 'A patron lost their footing on the floor. Your account?',
options: [
{ id: 'slip.true', text: 'They went over near a puddle that was not signed yet. It was not signed because of me.', truthful: true },
{ id: 'slip.soft1', text: 'Patron slipped in a signed and cordoned area despite clear warning signage.', truthful: false },
{ id: 'slip.soft2', text: 'A patron stumbled. No hazard was present and no injury was reported.', truthful: false },
],
},
barCutOff: {
prompt: 'A patron was refused service at the bar. Your account?',
options: [
{ id: 'barCO.true', text: 'I cut them off from behind the taps. They took it about as well as you would think.', truthful: true },
{ id: 'barCO.soft1', text: 'Service was concluded under RSA and a water was offered in its place.', truthful: false },
{ id: 'barCO.soft2', text: 'Patron was approaching the limit and elected to stop of their own accord.', truthful: false },
],
},
djTip: {
prompt: 'A gratuity was offered at the DJ booth. Your account?',
options: [
{ id: 'djTip.true', text: 'They put cash on the glass, I took it, and then I played what they asked for.', truthful: true },
{ id: 'djTip.soft1', text: 'A request was received at the booth and accommodated within the existing set.', truthful: false },
{ id: 'djTip.soft2', text: 'Any monies offered went to the staff tin behind the bar at the end of the shift.', truthful: false },
],
},
tabMisreturned: {
prompt: 'A card was returned to the wrong patron. Your account?',
options: [
{ id: 'tabM.true', text: 'I handed back the wrong card. They were out the door before anyone checked it.', truthful: true },
{ id: 'tabM.soft1', text: 'Card was released to a patron who identified it as theirs at the counter.', truthful: false },
{ id: 'tabM.soft2', text: 'All cards held behind the bar were reconciled against tabs before close.', truthful: false },
],
},
tabUnclaimed: {
prompt: 'A card was left behind the bar overnight. Your account?',
options: [
{ id: 'tabU.true', text: 'They gave up waiting and left without it. It is still in the jar.', truthful: true },
{ id: 'tabU.soft1', text: 'Patron was paged at the bar and did not present. Card retained per procedure.', truthful: false },
{ id: 'tabU.soft2', text: 'Card was retained at the patrons own request pending their return.', truthful: false },
],
},
carpetStorm: {
prompt: 'A patron left the holding area before being seen. Your account?',
options: [
{ id: 'carpetS.true', text: 'I left them standing on the carpet and they got sick of it and went home.', truthful: true },
{ id: 'carpetS.soft1', text: 'Patron declined to wait for assessment and departed the queue voluntarily.', truthful: false },
{ id: 'carpetS.soft2', text: 'Holding area was managed to capacity. Departure was the patrons own decision.', truthful: false },
],
},
radioOrder: {
prompt: 'An instruction was issued to floor staff over the radio. Your account?',
options: [
{ id: 'radio.true', text: 'I called it in and somebody else walked over and dealt with it.', truthful: true },
{ id: 'radio.soft1', text: 'The matter was delegated and supervised to completion from the door.', truthful: false },
{ id: 'radio.soft2', text: 'Response was coordinated across the team in line with the run sheet.', truthful: false },
],
},
// Plain words only. The fainter is not a bit (docs/GAME_DESIGN §4.3 tone guard),
// so even the flattering accounts stay flat rather than funny.
firstAid: {
prompt: 'First aid was administered on the floor. Your account?',
options: [
{ id: 'aid.true', text: 'They went down, I put them on their side, and they were walked out to a taxi.', truthful: true },
{ id: 'aid.soft1', text: 'Patron was assisted to the exit and monitored until transport arrived.', truthful: false },
{ id: 'aid.soft2', text: 'Patron was briefly unwell, recovered without intervention, and left unaided.', truthful: false },
],
},
phoneConfiscated: {
prompt: 'A patrons phone was held by staff. Your account?',
options: [
{ id: 'phoneC.true', text: 'I took their phone off them and it sat behind the bar until close.', truthful: true },
{ id: 'phoneC.soft1', text: 'Device was surrendered voluntarily and returned in full at the end of the night.', truthful: false },
{ id: 'phoneC.soft2', text: 'Patron was asked to stop filming and complied. No property was held.', 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);
// One question per KIND first. Three slots is the whole form, and a night
// with two radio calls in it was spending two of them on the same prompt,
// word for word — which reads as a stuck machine, not an interrogation. Take
// the best of each kind, then backfill from what is left if there is room.
const bestOfEachKind: typeof scored = [];
const leftovers: typeof scored = [];
const seenKinds = new Set<string>();
for (const row of scored) {
if (seenKinds.has(row.incident.kind)) leftovers.push(row);
else {
seenKinds.add(row.incident.kind);
bestOfEachKind.push(row);
}
}
// Never padded: an uneventful night gets a short form, which is its own joke.
const asked = [...bestOfEachKind, ...leftovers].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;
}