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
|
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
|
strikes/night for reckless serving). Design §6 calls it the bartender's
|
||||||
signature tension; it is currently a coin flip.
|
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 { defaultRoleFor, roleById, type RoleDef } from '../../data/roster';
|
||||||
import { buildRegularCast } from '../../patrons/regularCast';
|
import { buildRegularCast } from '../../patrons/regularCast';
|
||||||
import { recordDenialBy } from '../../patrons/memory';
|
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;
|
// 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
|
// 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>> };
|
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. */
|
/** Door/Floor ask to move the player; the night decides and flips the scenes. */
|
||||||
requestLocation(loc: 'door' | 'floor'): void;
|
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
|
* 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.
|
* 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,
|
memory: this.run.regulars,
|
||||||
},
|
},
|
||||||
requestLocation: (loc) => this.setLocation(loc),
|
requestLocation: (loc) => this.setLocation(loc),
|
||||||
|
inspectorWatching: () => isWatching(this.inspector, this.state.clockMin),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.events.once('shutdown', () => this.teardown());
|
this.events.once('shutdown', () => this.teardown());
|
||||||
|
|||||||
@ -37,7 +37,7 @@ import {
|
|||||||
judgeDrop, msToNearestBar, rollTip,
|
judgeDrop, msToNearestBar, rollTip,
|
||||||
} from './djShift';
|
} from './djShift';
|
||||||
import {
|
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,
|
RSA_STRIKE_REASON, TAB_DRINK_PRICE, TAB_OPEN_CHANCE,
|
||||||
canOpenTab, cardLineup, isRsaBreach, judgeCall, judgePour, pourAdjust,
|
canOpenTab, cardLineup, isRsaBreach, judgeCall, judgePour, pourAdjust,
|
||||||
type BarCall, type BarOutcome, type Tab,
|
type BarCall, type BarOutcome, type Tab,
|
||||||
@ -1335,17 +1335,19 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
rq: RngStream,
|
rq: RngStream,
|
||||||
pourVerdict: 'short' | 'clean' | 'overflow' | null,
|
pourVerdict: 'short' | 'clean' | 'overflow' | null,
|
||||||
): void {
|
): void {
|
||||||
const delta: { vibe?: number; aggro?: number } = {};
|
const delta: { vibe?: number; aggro?: number; hype?: number } = {};
|
||||||
if (out.vibe) delta.vibe = out.vibe;
|
if (out.vibe) delta.vibe = out.vibe;
|
||||||
if (out.aggro) delta.aggro = out.aggro;
|
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));
|
asker.patron.intoxication = Math.max(0, Math.min(1, asker.patron.intoxication + out.intoxDelta));
|
||||||
if (out.sendsHome) {
|
if (out.sendsHome) {
|
||||||
asker.handled = true;
|
asker.handled = true;
|
||||||
this.crowd.sendHome(asker);
|
this.crowd.sendHome(asker);
|
||||||
// If they have a tab, the card-return poll catches them on the way out.
|
// 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([{
|
this.nightCtx.scheduleDeferred([{
|
||||||
atClockMin: this.clock.clockMin + BREACH_SEEN_AFTER[0] + rq.int(0, BREACH_SEEN_AFTER[1]),
|
atClockMin: this.clock.clockMin + BREACH_SEEN_AFTER[0] + rq.int(0, BREACH_SEEN_AFTER[1]),
|
||||||
heatStrike: { reason: RSA_STRIKE_REASON, deferred: true },
|
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. */
|
/** 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';
|
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_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. */
|
/** Clock-minutes later the write-up lands: base + up-to spread. */
|
||||||
export const BREACH_SEEN_AFTER: readonly [number, number] = [6, 20];
|
export const BREACH_SEEN_AFTER: readonly [number, number] = [6, 20];
|
||||||
|
|
||||||
export interface BarOutcome {
|
export interface BarOutcome {
|
||||||
vibe?: number;
|
vibe?: number;
|
||||||
aggro?: 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). */
|
/** Applied to the patron's intoxication (scene clamps to 0..1). */
|
||||||
intoxDelta: number;
|
intoxDelta: number;
|
||||||
/** An RSA breach pour — may earn the deferred write-up. */
|
/** An RSA breach pour — may earn the deferred write-up. */
|
||||||
@ -92,6 +107,19 @@ export function judgePour(fill: number): PourVerdict {
|
|||||||
return 'short';
|
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.
|
* 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.
|
* 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;
|
return adjusted;
|
||||||
}
|
}
|
||||||
if (pour === 'overflow') {
|
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) ---------------------------------
|
// ---- tabs (the jar of other people's cards) ---------------------------------
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
CLEAN_POUR_HYPE,
|
||||||
|
breachSeenChance,
|
||||||
OVERFLOW_EXTRA_INTOX,
|
OVERFLOW_EXTRA_INTOX,
|
||||||
POUR_CLEAN_FROM,
|
POUR_CLEAN_FROM,
|
||||||
POUR_OVER_FROM,
|
POUR_OVER_FROM,
|
||||||
@ -106,8 +108,22 @@ describe('judgePour', () => {
|
|||||||
describe('pourAdjust', () => {
|
describe('pourAdjust', () => {
|
||||||
const base = judgeCall('serve', 'tipsy');
|
const base = judgeCall('serve', 'tipsy');
|
||||||
|
|
||||||
it('a clean pour changes nothing', () => {
|
it('a clean pour keeps the serve intact and sells the room a little', () => {
|
||||||
expect(pourAdjust(base, 'clean')).toEqual(base);
|
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', () => {
|
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', () => {
|
describe('tabs', () => {
|
||||||
it('sober people pay as they go', () => {
|
it('sober people pay as they go', () => {
|
||||||
expect(canOpenTab('sober')).toBe(false);
|
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 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 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 door = (p: typeof CAREFUL): NightMetrics[] => SEEDS.map((s) => simulateNight(s, p));
|
||||||
|
|
||||||
const sumBar = (runs: BarMetrics[]): string =>
|
const sumBar = (runs: BarMetrics[]): string =>
|
||||||
@ -92,7 +94,7 @@ describe('the bar economy (bot shifts, 5 seeds)', () => {
|
|||||||
const butter = bar(BUTTERFINGERS);
|
const butter = bar(BUTTERFINGERS);
|
||||||
|
|
||||||
it('prints the current shape (baseline visibility)', () => {
|
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);
|
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 { DeferredHit } from '../../src/rules/doorTypes';
|
||||||
import type { DrunkStage } from '../../src/data/types';
|
import type { DrunkStage } from '../../src/data/types';
|
||||||
import {
|
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,
|
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';
|
} from '../../src/scenes/floor/barShift';
|
||||||
|
|
||||||
export interface BarPolicy {
|
export interface BarPolicy {
|
||||||
@ -68,7 +69,12 @@ const ARRIVE_EVERY_MIN = 2;
|
|||||||
/** FloorDemoScene's MAX_CROWD — the sim's perf budget, mirrored. */
|
/** FloorDemoScene's MAX_CROWD — the sim's perf budget, mirrored. */
|
||||||
const MAX_CROWD = 44;
|
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();
|
_resetPatronSerial();
|
||||||
const bus = new EventBus();
|
const bus = new EventBus();
|
||||||
const rng = new SeededRNG(seed);
|
const rng = new SeededRNG(seed);
|
||||||
@ -162,16 +168,17 @@ export function simulateBarShift(seed: number, policy: BarPolicy): BarMetrics {
|
|||||||
metrics.cutoffs++;
|
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.vibe) delta.vibe = out.vibe;
|
||||||
if (out.aggro) delta.aggro = out.aggro;
|
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));
|
agent.patron.intoxication = Math.max(0, Math.min(1, agent.patron.intoxication + out.intoxDelta));
|
||||||
if (out.sendsHome) {
|
if (out.sendsHome) {
|
||||||
agent.handled = true;
|
agent.handled = true;
|
||||||
crowd.sendHome(agent);
|
crowd.sendHome(agent);
|
||||||
}
|
}
|
||||||
if (out.breach && orderRng.chance(BREACH_SEEN_CHANCE)) {
|
if (out.breach && orderRng.chance(breachSeenChance(opts.inspectorWatching === true))) {
|
||||||
deferred.push({
|
deferred.push({
|
||||||
atClockMin: state.clockMin + BREACH_SEEN_AFTER[0] + orderRng.int(0, BREACH_SEEN_AFTER[1]),
|
atClockMin: state.clockMin + BREACH_SEEN_AFTER[0] + orderRng.int(0, BREACH_SEEN_AFTER[1]),
|
||||||
heatStrike: { reason: RSA_STRIKE_REASON, deferred: true },
|
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));
|
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));
|
const post11 = metrics.vibeSamples.slice(Math.floor(120 / 5));
|
||||||
metrics.vibePinnedShare = post11.length ? post11.filter((v) => v >= 95).length / post11.length : 0;
|
metrics.vibePinnedShare = post11.length ? post11.filter((v) => v >= 95).length / post11.length : 0;
|
||||||
metrics.crowdAvg = crowdTicks ? crowdSum / crowdTicks : 0;
|
metrics.crowdAvg = crowdTicks ? crowdSum / crowdTicks : 0;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user