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:
parent
fbc959fde8
commit
700bd48bcb
@ -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.
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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) ---------------------------------
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user