diff --git a/LANEHANDOVER.md b/LANEHANDOVER.md index 17cfeae..0341091 100644 --- a/LANEHANDOVER.md +++ b/LANEHANDOVER.md @@ -2465,3 +2465,34 @@ stashes · two dangling commits are pre-rebase copies of my own work, and every file in them verified byte-identical on HEAD or differing only where the other session also edited · all 14 feature markers from this session's work confirmed present · deployed bundle hash matches the local build. + +## SESSION — FABLE-SOLO-28 · 2026-07-22 + +**Branch:** main (solo, Fable) · **Gate:** lint ✓ build ✓ test ✓ (837 tests, 57 files) + +### The paperwork asks by name now +The report is where §4.3's moral weight actually lands, and seven loaded kinds +were being asked about with the generic "An entry was made in the log. Your +account?" — a form that shrugs at a pocketed find is not a test of anything. + +New banks: **radioedIn** (the glassie's entire shift in one question — the +truthful option is the only one that admits nobody came), **fainter**, **fight**, +**rackDropped**, **floorScore** (cash off the floor that did not reach the till), +**vomit**, **slip**. Each keeps the contract: exactly one truthful option, two +plausible institutional softenings. + +### And the form stopped repeating itself +A glassie night with two radio calls spent TWO of its three slots on the same +prompt, word for word — reads as a stuck machine, not an interrogation. +`buildReport` now takes the best of each KIND first and backfills from the +leftovers only if slots remain. A night with four ejections and nothing else +still gets a full three questions; a high-weight repeat never loses its slot to +a dull one-off. Both pinned by tests. + +### Note on what was already right +The other session had already added interest WEIGHTS for this session's kinds +(djTip 8, baggiePocketed 9, tabMisreturned 8, carpetStorm 3, radioOrder 3) and +had already split `barServeBreach` out of `barServe` so the RSA question can +actually fire. I only had to supply the missing words and the variety rule. +`tests/incidentReport.test.ts` now guards the whole class: every loaded kind +must have a bespoke prompt, not the generic line. diff --git a/src/rules/incidentReport.ts b/src/rules/incidentReport.ts index dd350c3..d121be3 100644 --- a/src/rules/incidentReport.ts +++ b/src/rules/incidentReport.ts @@ -96,6 +96,7 @@ export const REPORT_TUNING = { 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, @@ -231,6 +232,74 @@ const BANKS: Record = { { 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: [ @@ -410,8 +479,22 @@ export function buildReport( // 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(); + 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 = scored.slice(0, REPORT_TUNING.maxQuestions); + 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. diff --git a/tests/incidentReport.test.ts b/tests/incidentReport.test.ts index 8851450..225fdfb 100644 --- a/tests/incidentReport.test.ts +++ b/tests/incidentReport.test.ts @@ -377,3 +377,80 @@ describe('the form survives a busy shift (frequency weighting)', () => { } }); }); + +describe('the loaded questions are asked BY NAME', () => { + // The report is where §4.3's moral weight lands: a form that shrugs at a + // pocketed find with "an entry was made in the log" is not a test of + // anything. Every kind the game logs that is worth asking about must have + // its own words. This guard exists because seven of them did not. + const rng = () => new SeededRNG(4207).stream('report'); + const rec = (kind: string): IncidentRecord => ({ clockMin: 120, kind, detail: 'x' }); + + const LOADED = [ + 'baggiePocketed', 'jointPocketed', 'djTip', 'tabMisreturned', 'barServeBreach', + 'radioedIn', 'fainter', 'fight', 'rackDropped', 'floorScore', 'vomit', 'slip', + 'ejection', 'maggotUnhandled', 'firstAid', 'barCutOff', 'stallBusted', + ]; + + it('every loaded kind gets a bespoke prompt, not the generic one', () => { + const generic = buildReport([rec('somethingNobodyHasEverLogged')], rng())[0]!.prompt; + for (const kind of LOADED) { + const q = buildReport([rec(kind)], rng())[0]; + expect(q, `${kind} produced no question at all`).toBeTruthy(); + expect(q!.prompt, `${kind} falls through to the generic line`).not.toBe(generic); + } + }); + + it('and each of them still offers exactly one honest answer', () => { + // The audit can only catch a lie if precisely one option is the truth. + for (const kind of LOADED) { + const q = buildReport([rec(kind)], rng())[0]!; + expect(q.options.filter((o) => o.truthful), kind).toHaveLength(1); + expect(new Set(q.options.map((o) => o.id)).size).toBe(q.options.length); + } + }); + + it('the glassie can be asked about the one thing they are allowed to do', () => { + // Zero authority means the radio call IS the shift. If the form never asks + // about it, a glassie night files as a blank page. + const q = buildReport([rec('radioedIn')], rng())[0]!; + expect(q.prompt.toLowerCase()).toContain('radio'); + expect(q.options.find((o) => o.truthful)!.text).toMatch(/not authorised|do not know/i); + }); +}); + +describe('the form interrogates the NIGHT, not one moment of it', () => { + it('prefers a different kind per question over repeating one', () => { + // Three slots is the whole form. A night with two radio calls used to spend + // two of them on the same prompt, word for word — a stuck machine. + const night: IncidentRecord[] = [ + { clockMin: 90, kind: 'radioedIn', detail: 'a' }, + { clockMin: 150, kind: 'radioedIn', detail: 'b' }, + { clockMin: 200, kind: 'vomit', detail: 'c' }, + { clockMin: 250, kind: 'floorScore', detail: 'd' }, + ]; + const kinds = buildReport(night, new SeededRNG(4207).stream('report')).map((q) => q.incident.kind); + expect(new Set(kinds).size).toBe(kinds.length); + }); + + it('but still fills the form when a night only had one sort of trouble', () => { + // Four ejections and nothing else is a real night; it should ask three times + // rather than shrink to one question out of tidiness. + const night: IncidentRecord[] = [90, 140, 190, 240].map((clockMin) => ({ + clockMin, kind: 'ejection', detail: String(clockMin), + })); + expect(buildReport(night, new SeededRNG(7).stream('report'))).toHaveLength( + REPORT_TUNING.maxQuestions, + ); + }); + + it('and a high-weight repeat never loses its slot to a dull one-off', () => { + const night: IncidentRecord[] = [ + { clockMin: 60, kind: 'baggiePocketed', detail: 'a' }, + { clockMin: 120, kind: 'baggiePocketed', detail: 'b' }, + { clockMin: 180, kind: 'tabClosed', detail: 'c' }, + ]; + const kinds = buildReport(night, new SeededRNG(3).stream('report')).map((q) => q.incident.kind); + expect(kinds.filter((k) => k === 'baggiePocketed').length).toBeGreaterThanOrEqual(1); + }); +});