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>
This commit is contained in:
type-two 2026-08-06 21:19:56 +10:00
parent 442efa4c3c
commit 91fe06d023
3 changed files with 192 additions and 1 deletions

View File

@ -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 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 session also edited · all 14 feature markers from this session's work confirmed
present · deployed bundle hash matches the local build. 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.

View File

@ -96,6 +96,7 @@ export const REPORT_TUNING = {
jointBinned: 3, jointBinned: 3,
carpetStorm: 3, carpetStorm: 3,
radioOrder: 3, // you did not do it; you said who would 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, deny: 3,
vomit: 2, vomit: 2,
slip: 2, slip: 2,
@ -231,6 +232,74 @@ const BANKS: Record<string, AccountBank> = {
{ id: 'barSB.soft2', text: 'Beverage was supplied to a member of the same group and not to the patron named.', 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: { barCutOff: {
prompt: 'A patron was refused service at the bar. Your account?', prompt: 'A patron was refused service at the bar. Your account?',
options: [ options: [
@ -410,8 +479,22 @@ export function buildReport(
// incident over an interesting one — it only reorders within a band. // incident over an interesting one — it only reorders within a band.
scored.sort((a, b) => b.score - a.score); 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. // 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 // 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. // Phase 4 will want to diff two nights against each other.

View File

@ -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);
});
});