diff --git a/LANEHANDOVER.md b/LANEHANDOVER.md index d3788ea..37b2f14 100644 --- a/LANEHANDOVER.md +++ b/LANEHANDOVER.md @@ -1902,3 +1902,50 @@ three loaded ones, options shuffled, all three screenshotted. 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. + +## SESSION — FABLE-SOLO-21 · 2026-07-22 + +**Branch:** main (solo, Fable, ultra — John gave design/style calls to me) +**Gate:** lint ✓ build ✓ test ✓ (764 tests, 50 files) · **Deployed** + verified + +### 1. The bar sells the room (closing the role pay gap) +SOLO-19 found a bartender's night was worth ~wage while a carpet doorgirl banked +up to +$180 of hype bonus. The door has always had queue theatre; the bar had no +hype channel at all. +- `BarOutcome.hype`; `pourAdjust` pays `CLEAN_POUR_HYPE 0.03`, `OVERFLOW_POUR_HYPE + 0.05`, short pours nothing ("nobody cheers a two-thirds beer"). +- **The heavy pour pays MORE on purpose.** It is the crowd-pleaser AND the thing + that walks the room toward the RSA line faster (+0.03 extra intox). Spectacle + and liability are the same lever — the bartender's version of §4.3's "being + fun and being right are different jobs". +- Measured: bar-shift hype peak 1.0 → 1.8 (rsa-straight), 2.1 (publican), 1.3 + (butterfingers — bad hands earn less). vibePin stays 0.00. +- **Pay gap $180 → $45** (bartender $356 vs carpet doorgirl $401), which is about + what the roster's own wage column intends ($160 vs $180). + +### 2. RSA teeth — and a CORRECTION to SOLO-19's finding +Design §6: "Heat if the inspector's watching". `breachSeenChance(watching)` — +0.35 alone, 0.9 with the inspector in the room. `NightContext.inspectorWatching()` +exposes the live watch (isWatching against the night's InspectorWatch). + +**But my SOLO-19 reading was wrong about the cause.** "Reckless serving costs +0.8 strikes/night" is true, and it is NOT weak detection: strikes dedupe by +reason (one write-up a night, deliberate contract) and across 3+ breach pours +even 0.35 saturates — P(miss 6) < 0.1. The variance is whether ANY maggot orders +at all (seed 4208: zero breach-stage orders all night). So raising the chance +changes only the case that reads as a decision — ONE bad pour, with or without a +lanyard in the room. That is exactly the case worth changing, so the wiring +stands, but do not expect it to move aggregate strike counts. Bands in +tests/floor/barShift.test.ts state this explicitly so nobody "fixes" it again. + +I did NOT touch the one-strike dedupe (frozen contract) or manufacture more +maggots at the bar (most people at a bar are not maggots; forcing it is fake). + +### 3. Harness bug fixed +`tests/sim/floorShift.ts` never fired leftover deferred hits at close, though +NightScene.endNight does — a breach poured after ~2:35 scheduled past 360 and +silently never happened. The sim was under-reporting strikes. 3 AM audit added. + +### Verified in pane +Bartender shift: clean pour hype 1.2917 → 1.3217 (+0.03), overflow → 1.3717 +(+0.05), `inspectorWatching()` live. Report form from SOLO-20 re-checked. diff --git a/src/scenes/door/NightScene.ts b/src/scenes/door/NightScene.ts index 751afbf..e34dbb8 100644 --- a/src/scenes/door/NightScene.ts +++ b/src/scenes/door/NightScene.ts @@ -17,7 +17,7 @@ import { TOTAL_VENUES, nightDateFor, venueByIndex, type VenueDef } from '../../d import { defaultRoleFor, roleById, type RoleDef } from '../../data/roster'; import { buildRegularCast } from '../../patrons/regularCast'; import { recordDenialBy } from '../../patrons/memory'; -import { observe, startWatch, type InspectorWatch, type VenueBreaches } from '../../rules/inspector'; +import { isWatching, observe, startWatch, type InspectorWatch, type VenueBreaches } from '../../rules/inspector'; // Phase-2: the night-flow machine. Door and Floor both run for the whole night; // this scene owns WHERE THE PLAYER IS (visibility + input), the clock, the @@ -116,6 +116,12 @@ export interface NightContext { regulars: { cast: readonly Patron[]; memory: Readonly> }; /** Door/Floor ask to move the player; the night decides and flips the scenes. */ requestLocation(loc: 'door' | 'floor'): void; + /** + * Is the plainclothes inspector in the room RIGHT NOW? The floor asks before + * pricing an RSA breach (design §6: "Heat if the inspector's watching"). A + * function rather than a flag because the watch opens and closes mid-night. + */ + inspectorWatching(): boolean; /** * Radio command (design §6, Head of Security tier). The FLOOR registers its * staff-order executor here at bind time; the DOOR registers Kayden's hold. @@ -314,6 +320,7 @@ export class NightScene extends Phaser.Scene { memory: this.run.regulars, }, requestLocation: (loc) => this.setLocation(loc), + inspectorWatching: () => isWatching(this.inspector, this.state.clockMin), }; this.events.once('shutdown', () => this.teardown()); diff --git a/src/scenes/floor/FloorDemoScene.ts b/src/scenes/floor/FloorDemoScene.ts index adb80d5..7646594 100644 --- a/src/scenes/floor/FloorDemoScene.ts +++ b/src/scenes/floor/FloorDemoScene.ts @@ -37,7 +37,7 @@ import { judgeDrop, msToNearestBar, rollTip, } from './djShift'; import { - BREACH_SEEN_AFTER, BREACH_SEEN_CHANCE, ORDER_CHANCE_PER_S, ORDER_COOLDOWN_MS, + BREACH_SEEN_AFTER, ORDER_CHANCE_PER_S, ORDER_COOLDOWN_MS, breachSeenChance, RSA_STRIKE_REASON, TAB_DRINK_PRICE, TAB_OPEN_CHANCE, canOpenTab, cardLineup, isRsaBreach, judgeCall, judgePour, pourAdjust, type BarCall, type BarOutcome, type Tab, @@ -1335,17 +1335,19 @@ export class FloorDemoScene extends Phaser.Scene { rq: RngStream, pourVerdict: 'short' | 'clean' | 'overflow' | null, ): void { - const delta: { vibe?: number; aggro?: number } = {}; + const delta: { vibe?: number; aggro?: number; hype?: number } = {}; if (out.vibe) delta.vibe = out.vibe; if (out.aggro) delta.aggro = out.aggro; - if (out.vibe || out.aggro) this.bus.emit('meters:delta', delta); + if (out.hype) delta.hype = out.hype; + if (out.vibe || out.aggro || out.hype) this.bus.emit('meters:delta', delta); asker.patron.intoxication = Math.max(0, Math.min(1, asker.patron.intoxication + out.intoxDelta)); if (out.sendsHome) { asker.handled = true; this.crowd.sendHome(asker); // If they have a tab, the card-return poll catches them on the way out. } - if (out.breach && this.nightCtx && rq.chance(BREACH_SEEN_CHANCE)) { + // Who is standing there decides whether the pour costs you (§6). + if (out.breach && this.nightCtx && rq.chance(breachSeenChance(this.nightCtx.inspectorWatching()))) { this.nightCtx.scheduleDeferred([{ atClockMin: this.clock.clockMin + BREACH_SEEN_AFTER[0] + rq.int(0, BREACH_SEEN_AFTER[1]), heatStrike: { reason: RSA_STRIKE_REASON, deferred: true }, diff --git a/src/scenes/floor/barShift.ts b/src/scenes/floor/barShift.ts index 16563b1..87b14f4 100644 --- a/src/scenes/floor/barShift.ts +++ b/src/scenes/floor/barShift.ts @@ -31,14 +31,29 @@ export function isRsaBreach(stage: DrunkStage): boolean { /** Stable reason so shouldRecordStrike folds a whole bad shift into ONE write-up. */ export const RSA_STRIKE_REASON = 'served alcohol to a visibly intoxicated patron'; -/** A breach pour only becomes a strike if somebody official SAW it. */ +/** + * A breach pour only becomes a strike if somebody official SAW it. Design §6 + * names the bartender's signature tension exactly: "you legally CANNOT serve a + * Maggot — the cut-off with them begging across the bar, Heat if the inspector's + * watching". So detection is situational, not a flat coin flip: a lanyard might + * clock it on any night, but with the inspector actually in the room it is very + * nearly certain. Bot nights had reckless serving costing 0.8 strikes a night + * (tests/sim/floorEconomy) — a gamble worth taking, which is the wrong lesson. + */ export const BREACH_SEEN_CHANCE = 0.35; +export const BREACH_SEEN_WATCHED = 0.9; + +export function breachSeenChance(inspectorWatching: boolean): number { + return inspectorWatching ? BREACH_SEEN_WATCHED : BREACH_SEEN_CHANCE; +} /** Clock-minutes later the write-up lands: base + up-to spread. */ export const BREACH_SEEN_AFTER: readonly [number, number] = [6, 20]; export interface BarOutcome { vibe?: number; aggro?: number; + /** A bar that is visibly working sells the room (see the pour notes). */ + hype?: number; /** Applied to the patron's intoxication (scene clamps to 0..1). */ intoxDelta: number; /** An RSA breach pour — may earn the deferred write-up. */ @@ -92,6 +107,19 @@ export function judgePour(fill: number): PourVerdict { return 'short'; } +// A bar that is visibly working is part of why the room feels good — the +// doorgirl's queue theatre has always paid hype and the bar paid none, which +// left a bartender's night worth about their wage while a carpet doorgirl +// banked up to +$180 (found by tests/sim/floorEconomy). +// +// The heavy pour pays MORE than the clean one on purpose. It is the crowd +// pleaser, and it is also the thing that walks the room toward the RSA line +// faster — the spectacle and the liability are the same lever, which is the +// bartender's version of the door's "being fun and being right are different +// jobs" (§4.3). A short pour sells nothing; nobody cheers a two-thirds beer. +export const CLEAN_POUR_HYPE = 0.03; +export const OVERFLOW_POUR_HYPE = 0.05; + /** * Fold the pour into the serve. Short: half a drink, no thanks, a look. * Overflow: the room loves a heavy pour and the patron wears more of it. @@ -105,9 +133,14 @@ export function pourAdjust(out: BarOutcome, pour: PourVerdict): BarOutcome { return adjusted; } if (pour === 'overflow') { - return { ...out, vibe: (out.vibe ?? 0) + 1, intoxDelta: out.intoxDelta + OVERFLOW_EXTRA_INTOX }; + return { + ...out, + vibe: (out.vibe ?? 0) + 1, + hype: (out.hype ?? 0) + OVERFLOW_POUR_HYPE, + intoxDelta: out.intoxDelta + OVERFLOW_EXTRA_INTOX, + }; } - return out; + return { ...out, hype: (out.hype ?? 0) + CLEAN_POUR_HYPE }; } // ---- tabs (the jar of other people's cards) --------------------------------- diff --git a/tests/floor/barShift.test.ts b/tests/floor/barShift.test.ts index 760f5b1..576ef32 100644 --- a/tests/floor/barShift.test.ts +++ b/tests/floor/barShift.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + CLEAN_POUR_HYPE, + breachSeenChance, OVERFLOW_EXTRA_INTOX, POUR_CLEAN_FROM, POUR_OVER_FROM, @@ -106,8 +108,22 @@ describe('judgePour', () => { describe('pourAdjust', () => { const base = judgeCall('serve', 'tipsy'); - it('a clean pour changes nothing', () => { - expect(pourAdjust(base, 'clean')).toEqual(base); + it('a clean pour keeps the serve intact and sells the room a little', () => { + const out = pourAdjust(base, 'clean'); + expect(out).toMatchObject({ vibe: base.vibe, intoxDelta: base.intoxDelta, breach: base.breach }); + expect(out.hype).toBe(CLEAN_POUR_HYPE); + }); + + it('the heavy pour sells it harder — and gets them there faster', () => { + // Deliberate: the crowd-pleaser is also the thing that walks the room + // toward the RSA line. Spectacle and liability are the same lever. + const over = pourAdjust(base, 'overflow'); + expect(over.hype!).toBeGreaterThan(CLEAN_POUR_HYPE); + expect(over.intoxDelta).toBeGreaterThan(pourAdjust(base, 'clean').intoxDelta); + }); + + it('a short pour sells nothing — nobody cheers a two-thirds beer', () => { + expect(pourAdjust(base, 'short').hype).toBeUndefined(); }); it('a short pour: no thanks, a look, half a drink', () => { @@ -130,6 +146,27 @@ describe('pourAdjust', () => { }); }); +describe('the RSA line has teeth when it counts', () => { + // Correcting a first reading of the sim: "reckless serving only costs 0.8 + // strikes a night" is true, but NOT because detection is weak. Strikes dedupe + // by reason (one write-up a night, deliberately), and across 3+ breach pours + // even the base 0.35 saturates. The variance is whether any maggot orders at + // all. So the chance only decides the case that actually reads as a decision: + // ONE bad pour, and whether anyone official was standing there. + it('one bad pour is a gamble alone and near-certain heat if the inspector is in', () => { + const alone = breachSeenChance(false); + const watched = breachSeenChance(true); + expect(alone).toBeLessThan(0.5); // usually you get away with it + expect(watched).toBeGreaterThan(0.85); // with a lanyard in the room, you do not + }); + + it('but a night of them is caught either way — six pours saturate both', () => { + const missSix = (p: number): number => (1 - p) ** 6; + expect(missSix(breachSeenChance(false))).toBeLessThan(0.1); + expect(missSix(breachSeenChance(true))).toBeLessThan(0.01); + }); +}); + describe('tabs', () => { it('sober people pay as they go', () => { expect(canOpenTab('sober')).toBe(false); diff --git a/tests/sim/floorEconomy.test.ts b/tests/sim/floorEconomy.test.ts index 7974228..aae543b 100644 --- a/tests/sim/floorEconomy.test.ts +++ b/tests/sim/floorEconomy.test.ts @@ -18,6 +18,8 @@ const avg = (xs: number[]): number => xs.reduce((a, b) => a + b, 0) / xs.length; const sum = (xs: number[]): number => xs.reduce((a, b) => a + b, 0); const bar = (p: typeof RSA_STRAIGHT): BarMetrics[] => SEEDS.map((s) => simulateBarShift(s, p)); +const barWatched = (p: typeof RSA_STRAIGHT): BarMetrics[] => + SEEDS.map((s) => simulateBarShift(s, p, { inspectorWatching: true })); const door = (p: typeof CAREFUL): NightMetrics[] => SEEDS.map((s) => simulateNight(s, p)); const sumBar = (runs: BarMetrics[]): string => @@ -92,7 +94,7 @@ describe('the bar economy (bot shifts, 5 seeds)', () => { const butter = bar(BUTTERFINGERS); it('prints the current shape (baseline visibility)', () => { - console.log('\n' + [straight, publican, butter].map(sumBar).join('\n')); + console.log('\n' + [straight, publican, butter, barWatched(PUBLICAN)].map(sumBar).join('\n')); expect(straight[0]!.orders).toBeGreaterThan(0); }); diff --git a/tests/sim/floorShift.ts b/tests/sim/floorShift.ts index 714efff..469bf2f 100644 --- a/tests/sim/floorShift.ts +++ b/tests/sim/floorShift.ts @@ -25,9 +25,10 @@ import { shouldRecordStrike } from '../../src/rules/heat'; import type { DeferredHit } from '../../src/rules/doorTypes'; import type { DrunkStage } from '../../src/data/types'; import { - BREACH_SEEN_AFTER, BREACH_SEEN_CHANCE, ORDER_CHANCE_PER_S, ORDER_COOLDOWN_MS, + BREACH_SEEN_AFTER, ORDER_CHANCE_PER_S, ORDER_COOLDOWN_MS, POUR_CLEAN_FROM, POUR_OVER_FROM, RSA_STRIKE_REASON, TAB_OPEN_CHANCE, - canOpenTab, isRsaBreach, judgeCall, judgePour, pourAdjust, type BarCall, + breachSeenChance, canOpenTab, isRsaBreach, judgeCall, judgePour, pourAdjust, + type BarCall, } from '../../src/scenes/floor/barShift'; export interface BarPolicy { @@ -68,7 +69,12 @@ const ARRIVE_EVERY_MIN = 2; /** FloorDemoScene's MAX_CROWD — the sim's perf budget, mirrored. */ const MAX_CROWD = 44; -export function simulateBarShift(seed: number, policy: BarPolicy): BarMetrics { +export interface BarShiftOpts { + /** Is the plainclothes inspector in the room? Mirrors NightContext. */ + inspectorWatching?: boolean; +} + +export function simulateBarShift(seed: number, policy: BarPolicy, opts: BarShiftOpts = {}): BarMetrics { _resetPatronSerial(); const bus = new EventBus(); const rng = new SeededRNG(seed); @@ -162,16 +168,17 @@ export function simulateBarShift(seed: number, policy: BarPolicy): BarMetrics { metrics.cutoffs++; } - const delta: { vibe?: number; aggro?: number } = {}; + const delta: { vibe?: number; aggro?: number; hype?: number } = {}; if (out.vibe) delta.vibe = out.vibe; if (out.aggro) delta.aggro = out.aggro; - if (out.vibe || out.aggro) bus.emit('meters:delta', delta); + if (out.hype) delta.hype = out.hype; + if (out.vibe || out.aggro || out.hype) bus.emit('meters:delta', delta); agent.patron.intoxication = Math.max(0, Math.min(1, agent.patron.intoxication + out.intoxDelta)); if (out.sendsHome) { agent.handled = true; crowd.sendHome(agent); } - if (out.breach && orderRng.chance(BREACH_SEEN_CHANCE)) { + if (out.breach && orderRng.chance(breachSeenChance(opts.inspectorWatching === true))) { deferred.push({ atClockMin: state.clockMin + BREACH_SEEN_AFTER[0] + orderRng.int(0, BREACH_SEEN_AFTER[1]), heatStrike: { reason: RSA_STRIKE_REASON, deferred: true }, @@ -223,6 +230,13 @@ export function simulateBarShift(seed: number, policy: BarPolicy): BarMetrics { applyCall(asker, stage, policy.call(stage)); } + // 3 AM audit: whatever deferred heat is left lands now, as NightScene.endNight + // does. Without this a breach poured after ~2:35 scheduled past the end of the + // night and quietly never happened — the harness was under-reporting strikes. + for (const hit of deferred) { + if (hit.heatStrike && shouldRecordStrike(state, hit.heatStrike)) bus.emit('heat:strike', hit.heatStrike); + } + const post11 = metrics.vibeSamples.slice(Math.floor(120 / 5)); metrics.vibePinnedShare = post11.length ? post11.filter((v) => v >= 95).length / post11.length : 0; metrics.crowdAvg = crowdTicks ? crowdSum / crowdTicks : 0;