The report catches up: frequency-aware interest weights and eleven banks for the floor's own paperwork

A bar shift logs ~26 serves and buildReport takes the top 3 with no dedupe, so
bartender nights filed three identical generic pour questions. Weights now price
frequency as well as interest, and the loaded kinds have real questions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-26 19:43:06 +10:00
parent 3dd61fef36
commit fbc959fde8
4 changed files with 249 additions and 3 deletions

View File

@ -1854,3 +1854,51 @@ is a feel decision, not a regression, and 0.50 sits exactly on a band edge.
costs 0.8 strikes/night. Design §6 calls RSA the bartender's *signature*
tension; right now it is a coin flip. Options: raise `BREACH_SEEN_CHANCE`,
or let the inspector's presence in the room modify it.
## SESSION — FABLE-SOLO-20 · 2026-07-22
**Branch:** main (solo, Fable, ultra) · **Gate:** lint ✓ build ✓ test 756+4 ✓
**Deployed:** https://monsterrobot.games/not-tonight/ (bundle hash verified against local build)
### The incident report catches up with the content
~15 incident kinds shipped 2026-07-21 all landed on DEFAULT_BANK ("An entry was
made in the log. Your account?"). The report is the game's moral spine (§4.3 —
the system about what you SAY happened), so the newest and most loaded content
was the vaguest thing in it.
**The real bug underneath**: `buildReport` takes the top 3 by `weight + jitter`
with NO dedupe by kind, and every unweighted kind gets `defaultInterest: 2`. A
bar shift logs ~26 `barServe` rows — so a bartender night's form was three
near-identical generic pour questions, crowding out the one baggie. Weights now
price FREQUENCY as well as interest: once-a-night-and-awkward scores high,
many-times-a-night scores 0-2 however interesting it feels. Full table written
for every kind the floor logs; `mopped`/`wetFloorSign`/`barShift`/`djShift`/
`lightsOn`/`carpetEntrance` are deliberate zeros (a shift you turned up to is
not an incident).
**New kind**: `barServeBreach` split out of `barServe` in FloorDemoScene —
pouring a beer and pouring for someone who cannot find their mouth are
different questions, and the report can only ask about what the kind can name.
**Eleven new banks**, in the established register (truthful names what happened
and stops; soft options are deniability a tired adult could defend; no account
pronounces a verdict): `baggiePocketed` ("Where is it now?" — the jointPocketed
tier), `baggieBinned`, `barServeBreach`, `barCutOff`, `djTip`, `tabMisreturned`,
`tabUnclaimed`, `carpetStorm`, `radioOrder`, `firstAid` (plain words only —
tone guard), `phoneConfiscated`.
### Tests (4 new, tests/incidentReport.test.ts)
- 26 serves + 1 baggie → the baggie is asked, **all 30 seeds** (the flooding guard)
- loaded bar kinds outrank routine ones; a turned-up-to shift asks nothing
- every floor kind is explicitly weighted (guards the drift that started this)
### Verified in pane
Rigged bar night (26 serves + breach + tip + baggie) → form asked exactly the
three loaded ones, options shuffled, all three screenshotted.
### Still open (from SOLO-19's findings, unactioned — John's design calls)
1. The bar generates zero hype; bartender pay ≈ wage vs a carpet doorgirl's
+$180. Either the bar wants a hype channel or the wage table should say so.
2. The RSA dilemma presents ~3x/night and dedupe caps it at one strike (0.8
strikes/night for reckless serving). Design §6 calls it the bartender's
signature tension; it is currently a coin flip.

View File

@ -66,19 +66,60 @@ export const REPORT_TUNING = {
// 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
jointBinned: 3,
jointIgnored: 5,
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
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>,
@ -164,6 +205,99 @@ const BANKS: Record<string, AccountBank> = {
{ 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 },
],
},
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: [

View File

@ -1390,7 +1390,15 @@ export class FloorDemoScene extends Phaser.Scene {
// One kind per verb. Logging a cut-off as a serve put a drink in the record
// that was never poured — and the incident report is a system about what you
// say happened, so the truth side of it has to be true.
const kind = call === 'serve' ? 'barServe' : call === 'water' ? 'barWater' : 'barCutOff';
// A breach pour is its OWN kind, not a detail string: pouring a beer and
// pouring for someone who cannot find their mouth are different questions,
// and the report can only ask about what the kind can name. It is also the
// rare one — a night has ~26 serves and maybe three breaches — so weighting
// them apart is what keeps the form off the boring ones (REPORT_TUNING).
const kind =
call === 'serve'
? out.breach ? 'barServeBreach' : 'barServe'
: call === 'water' ? 'barWater' : 'barCutOff';
this.logIncident(kind, asker.patron.id, `${call} at ${stage}.${out.breach ? ' RSA breach.' : ''}${pourNote}`);
}

View File

@ -321,3 +321,59 @@ describe('findKaydenContradiction', () => {
expect(findKaydenContradiction([[kaydenIncident('p1')]])).toBeUndefined();
});
});
describe('the form survives a busy shift (frequency weighting)', () => {
// buildReport takes the top THREE of the night with no dedupe by kind, so a
// weight is a claim about how often the kind fires as much as how interesting
// it is. A bar shift logs ~26 serves; if those outscored the rare loaded
// stuff, every bartender night would file three identical pour questions.
const barNight = (): IncidentRecord[] => [
...Array.from({ length: 26 }, (_, i) =>
inc({ kind: 'barServe', clockMin: 30 + i * 10, detail: 'serve at tipsy.' }),
),
...Array.from({ length: 9 }, (_, i) => inc({ kind: 'barWater', clockMin: 40 + i * 20 })),
inc({ kind: 'baggiePocketed', clockMin: 350, detail: 'Contraband off the floor. Retained by security.' }),
];
it('asks about the baggie, not the twenty-sixth beer', () => {
// Every seed, not one lucky one: the jitter is < 1 and must never be able
// to promote a routine pour over the thing worth asking about.
for (let seed = 1; seed <= 30; seed++) {
const asked = buildReport(barNight(), stream(seed)).map((q) => q.incident.kind);
expect(asked, `seed ${seed}`).toContain('baggiePocketed');
}
});
it('and the loaded bar kinds outrank the routine ones', () => {
const w = REPORT_TUNING.interest;
expect(w.barServeBreach).toBeGreaterThan(w.barServe!);
expect(w.barCutOff).toBeGreaterThan(w.barServe!);
expect(w.tabMisreturned).toBeGreaterThan(w.tabClosed!);
expect(w.djTip).toBeGreaterThan(w.djDrop!);
});
it('a shift you merely turned up to is not an incident', () => {
const dull = [
inc({ kind: 'barShift', clockMin: 10 }),
inc({ kind: 'djShift', clockMin: 20 }),
inc({ kind: 'lightsOn', clockMin: 355 }),
inc({ kind: 'carpetEntrance', clockMin: 100 }),
];
expect(buildReport(dull, stream())).toHaveLength(0);
});
it('every kind the floor logs has a real question or a deliberate zero', () => {
// Guards the drift that started this: a new kind that nobody weighted lands
// on defaultInterest and quietly competes with the good ones.
const floorKinds = [
'barServe', 'barServeBreach', 'barWater', 'barCutOff', 'barShift',
'djShift', 'djDrop', 'djRequest', 'djTip',
'tabClosed', 'tabMisreturned', 'tabUnclaimed',
'carpetStorm', 'carpetEntrance', 'radioOrder',
'lightsOn', 'floorScore', 'baggiePocketed', 'baggieBinned', 'lostProperty',
];
for (const kind of floorKinds) {
expect(REPORT_TUNING.interest[kind], `'${kind}' is unweighted`).toBeDefined();
}
});
});