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>
574 lines
28 KiB
TypeScript
574 lines
28 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.
|
||
// 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 patron’s 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 patron’s 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 patron’s 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;
|
||
}
|