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:
parent
442efa4c3c
commit
91fe06d023
@ -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.
|
||||
|
||||
@ -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<string, AccountBank> = {
|
||||
{ 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<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 = 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.
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user