diff --git a/LANEHANDOVER.md b/LANEHANDOVER.md index 0e8c89f..d3788ea 100644 --- a/LANEHANDOVER.md +++ b/LANEHANDOVER.md @@ -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. diff --git a/src/rules/incidentReport.ts b/src/rules/incidentReport.ts index 975db00..dd350c3 100644 --- a/src/rules/incidentReport.ts +++ b/src/rules/incidentReport.ts @@ -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, @@ -164,6 +205,99 @@ const BANKS: Record = { { 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 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: [ diff --git a/src/scenes/floor/FloorDemoScene.ts b/src/scenes/floor/FloorDemoScene.ts index a1ffae5..adb80d5 100644 --- a/src/scenes/floor/FloorDemoScene.ts +++ b/src/scenes/floor/FloorDemoScene.ts @@ -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}`); } diff --git a/tests/incidentReport.test.ts b/tests/incidentReport.test.ts index a5658bb..8851450 100644 --- a/tests/incidentReport.test.ts +++ b/tests/incidentReport.test.ts @@ -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(); + } + }); +});