- 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>
206 lines
7.6 KiB
TypeScript
206 lines
7.6 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
CLEAN_POUR_HYPE,
|
|
breachSeenChance,
|
|
OVERFLOW_EXTRA_INTOX,
|
|
POUR_CLEAN_FROM,
|
|
POUR_OVER_FROM,
|
|
SERVE_DRINK_STEP,
|
|
canOpenTab,
|
|
cardLineup,
|
|
isRsaBreach,
|
|
judgeCall,
|
|
judgePour,
|
|
pourAdjust,
|
|
} from '../../src/scenes/floor/barShift';
|
|
import { SeededRNG } from '../../src/core/SeededRNG';
|
|
import { WATER_SOBER_STEP } from '../../src/scenes/floor/hazards';
|
|
import { BAR_ORDER_LINES } from '../../src/data/strings/floor';
|
|
import type { DrunkStage } from '../../src/data/types';
|
|
|
|
const STAGES: readonly DrunkStage[] = ['sober', 'tipsy', 'loose', 'messy', 'maggot'];
|
|
|
|
describe('isRsaBreach', () => {
|
|
it('draws the line at messy — loose is the decide band, same as the floor cut-off', () => {
|
|
expect(isRsaBreach('sober')).toBe(false);
|
|
expect(isRsaBreach('tipsy')).toBe(false);
|
|
expect(isRsaBreach('loose')).toBe(false);
|
|
expect(isRsaBreach('messy')).toBe(true);
|
|
expect(isRsaBreach('maggot')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('judgeCall / serve', () => {
|
|
it('a legal pour pays vibe and adds a real drink', () => {
|
|
const out = judgeCall('serve', 'tipsy');
|
|
expect(out).toMatchObject({ vibe: 1, breach: false, sendsHome: false });
|
|
expect(out.intoxDelta).toBe(SERVE_DRINK_STEP);
|
|
});
|
|
|
|
it('a breach pour STILL pays its vibe up front — the price arrives later', () => {
|
|
for (const stage of ['messy', 'maggot'] as const) {
|
|
const out = judgeCall('serve', stage);
|
|
expect(out.vibe).toBe(1);
|
|
expect(out.breach).toBe(true);
|
|
expect(out.intoxDelta).toBe(SERVE_DRINK_STEP);
|
|
}
|
|
});
|
|
|
|
it('never sends anyone home — a served patron stays a problem', () => {
|
|
for (const stage of STAGES) expect(judgeCall('serve', stage).sendsHome).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('judgeCall / water', () => {
|
|
it('the good catch: loose and up take the cup and improve', () => {
|
|
for (const stage of ['loose', 'messy', 'maggot'] as const) {
|
|
const out = judgeCall('water', stage);
|
|
expect(out.vibe).toBe(1);
|
|
expect(out.aggro).toBeUndefined();
|
|
expect(out.intoxDelta).toBe(-WATER_SOBER_STEP);
|
|
}
|
|
});
|
|
|
|
it('watering the sober is a snub — they ordered a beer', () => {
|
|
for (const stage of ['sober', 'tipsy'] as const) {
|
|
const out = judgeCall('water', stage);
|
|
expect(out.aggro).toBe(1);
|
|
expect(out.vibe).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it('is never a breach — water cannot be an offence', () => {
|
|
for (const stage of STAGES) expect(judgeCall('water', stage).breach).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('judgeCall / cutoff', () => {
|
|
it('always sends them home, whoever they were', () => {
|
|
for (const stage of STAGES) expect(judgeCall('cutoff', stage).sendsHome).toBe(true);
|
|
});
|
|
|
|
it('right on a breach-stage patron: a scene, but the room is relieved', () => {
|
|
const out = judgeCall('cutoff', 'maggot');
|
|
expect(out).toMatchObject({ vibe: 1, aggro: 1 });
|
|
});
|
|
|
|
it('early on someone upright: they tell the whole queue about you', () => {
|
|
const out = judgeCall('cutoff', 'tipsy');
|
|
expect(out).toMatchObject({ vibe: -1, aggro: 2 });
|
|
});
|
|
|
|
it('pours nothing either way', () => {
|
|
for (const stage of STAGES) expect(judgeCall('cutoff', stage).intoxDelta).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('judgePour', () => {
|
|
it('short below the line, clean in the band, overflow past it', () => {
|
|
expect(judgePour(0)).toBe('short');
|
|
expect(judgePour(POUR_CLEAN_FROM - 0.01)).toBe('short');
|
|
expect(judgePour(POUR_CLEAN_FROM)).toBe('clean');
|
|
expect(judgePour(POUR_OVER_FROM - 0.01)).toBe('clean');
|
|
expect(judgePour(POUR_OVER_FROM)).toBe('overflow');
|
|
expect(judgePour(1)).toBe('overflow');
|
|
});
|
|
});
|
|
|
|
describe('pourAdjust', () => {
|
|
const base = judgeCall('serve', 'tipsy');
|
|
|
|
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', () => {
|
|
const out = pourAdjust(base, 'short');
|
|
expect(out.vibe).toBeUndefined();
|
|
expect(out.aggro).toBe(1);
|
|
expect(out.intoxDelta).toBe(SERVE_DRINK_STEP / 2);
|
|
});
|
|
|
|
it('overflow: the room approves, the patron wears more of it', () => {
|
|
const out = pourAdjust(base, 'overflow');
|
|
expect(out.vibe).toBe(2);
|
|
expect(out.intoxDelta).toBeCloseTo(SERVE_DRINK_STEP + OVERFLOW_EXTRA_INTOX);
|
|
});
|
|
|
|
it('the breach flag rides the SERVE, not the pour', () => {
|
|
const breach = judgeCall('serve', 'maggot');
|
|
expect(pourAdjust(breach, 'short').breach).toBe(true);
|
|
expect(pourAdjust(breach, 'overflow').breach).toBe(true);
|
|
});
|
|
});
|
|
|
|
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);
|
|
for (const s of ['tipsy', 'loose', 'messy', 'maggot'] as const) expect(canOpenTab(s)).toBe(true);
|
|
});
|
|
|
|
it('cardLineup always contains the right card, plus up to two strangers', () => {
|
|
const rng = new SeededRNG(11).stream('t');
|
|
for (let i = 0; i < 20; i++) {
|
|
const lineup = cardLineup('Jai Doyle', ['Mia Chen', 'Lou Reed', 'Jai Doyle', 'Sam Fox'], rng);
|
|
expect(lineup).toContain('Jai Doyle');
|
|
expect(lineup.length).toBe(3);
|
|
expect(new Set(lineup).size).toBe(3);
|
|
}
|
|
});
|
|
|
|
it('the right card is not always slot one', () => {
|
|
const rng = new SeededRNG(11).stream('t');
|
|
const slots = new Set<number>();
|
|
for (let i = 0; i < 30; i++) {
|
|
slots.add(cardLineup('A', ['B', 'C', 'D', 'E'], rng).indexOf('A'));
|
|
}
|
|
expect(slots.size).toBeGreaterThan(1);
|
|
});
|
|
|
|
it('survives an empty jar — a lineup of one is still a lineup', () => {
|
|
const rng = new SeededRNG(3).stream('t');
|
|
expect(cardLineup('A', [], rng)).toEqual(['A']);
|
|
});
|
|
});
|
|
|
|
describe('BAR_ORDER_LINES', () => {
|
|
it('every stage has lines — the order IS the sobriety test', () => {
|
|
for (const stage of STAGES) expect(BAR_ORDER_LINES[stage].length).toBeGreaterThan(0);
|
|
});
|
|
});
|