The bar sells the room, and the inspector makes one bad pour matter

- pourAdjust pays hype: clean 0.03, heavy 0.05, short nothing. The crowd-pleaser
  is also what walks the room toward the RSA line — same lever, both ways.
  Role pay gap closes from ~$180 to $45.
- breachSeenChance(inspectorWatching): 0.35 alone, 0.9 with a lanyard in the room
  (design §6). Corrects SOLO-19: detection was never the weak part — dedupe caps
  strikes at one a night and 0.35 already saturates across several pours.
- Sim harness was missing the 3 AM audit and under-reporting late-night strikes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-26 19:53:25 +10:00
parent fbc959fde8
commit 700bd48bcb
7 changed files with 159 additions and 17 deletions

View File

@ -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.

View File

@ -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<Record<string, import('../../data/types').RegularMemory>> };
/** 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());

View File

@ -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 },

View File

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

View File

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

View File

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

View File

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