The money layer: booth tips (cash buys the yes, not the landing), the pour meter, bar tabs with the card jar and the 3-card handback

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-21 02:17:45 +10:00
parent 022ea32005
commit b7bb8f49ab
8 changed files with 621 additions and 39 deletions

View File

@ -1448,3 +1448,53 @@ verbs with direct `handleKey()` calls, never synthetic keydowns.
inherit ROOM's board. Tests always reset in afterEach.
- Shelf: SCENARIOS.md fully ✅. Remaining §6 role-ladder ideas (Head of
Security finale arc, roster board) have no shelf entries yet.
## SESSION — FABLE-SOLO-13 · 2026-07-21
**Branch:** main (solo session, Fable, ultra) · **Gate:** lint ✓ build ✓ test ✓ (711 tests, 45 files)
**Deployed:** https://monsterrobot.games/not-tonight/ (rsync, curl 200)
### John's asks: DJ tips, bar tabs, the pour
**1. The booth tip economy** (`djShift.ts` + tickDecks rework):
- 30% of fresh requests arrive with cash on the glass (`rollTip`, $10/$20/$20/$50).
- Tipped choices: TAKE IT AND PLAY IT (cash into `djTips`, `djTip` incident, 25%
deferred sting vibe 2 + Dazza text — the joint-bust corruption pattern),
PLAY IT KEEP YOUR CASH, NAH (aggro +2 — refusing money is personal).
- Cash NEVER changes the song's hit odds — it buys the yes, not the landing.
- NAH a FREE request → 45% they visit the ATM and return in 2570s with cash and
the same request ("the cash is folded into a little paper crane this time").
Queue is `djReturns`; a return with no live asker is silently lost.
- Step-off toast + incident: "$N in the shoe."
**2. The pour** (`PourOverlay` + `judgePour`/`pourAdjust` pure in barShift):
- SERVE now opens a sweep-stop meter (0→1→1300ms, SPACE stops, ESC bails =
no serve). Bands painted on the glass: short <0.68 / clean / overflow 0.93.
- short: vibe cancelled, aggro +1, HALF the drink. overflow: vibe +1 extra,
+0.03 extra intox. Breach flag rides the SERVE decision, never the pour.
- Serve application refactored into `applyBarCall()` (water/cutoff unchanged).
**3. Tabs — the jar of other people's cards** (`Tab`/`canOpenTab`/`cardLineup`):
- A completed serve to tipsy+ has 30% chance of opening a tab (card in the jar,
toast names the card); further serves increment `drinks`.
- When a tab-holder is cut off or heads home while you hold the taps, the
card-return poll (head of tickBar) opens a ChoiceOverlay: their doll, a
drunk's description of their own card, and a seeded 3-name lineup
(`cardLineup` — right card + two strangers from the room, never slot-stable).
- Right: vibe +1, tab total read out ($14/drink). Wrong: aggro +2, someone
leaves with Chantelle's debit card. ESC: aggro +1, the jar keeps it.
- Incidents: `tabClosed` / `tabMisreturned` / `tabUnclaimed` / `djTip`.
### Verified in the pane (frame-stepped, direct handleKey — see SOLO-12 note)
Tipped return overlay + TAKE → $20 in shoe + djTip incident ✓ · free-NAH → ATM
return queued ✓ · step-off shoe toast ✓ · serve→pour opens, sweep advances ✓ ·
overflow +0.09 / short +0.03 with pour-noted incidents ✓ · tab opened on serve,
holder heading home triggered 3-card lineup, right card closed $14 ✓ · wrong
card aggro +2 + tabMisreturned ✓ · THE POUR screenshot.
### Notes
- ChoiceOverlay is doing tab lineups with patron NAMES as choice ids — fine
while names are unique per lineup (cardLineup dedupes); revisit if two
patrons ever share a full name.
- No cash meter exists on purpose: tips/tab totals are incidents + toasts
(the report is the moral ledger, per the joint bust).

View File

@ -330,6 +330,52 @@ export const BAR_RESULTS = {
'heads up — someone with a lanyard watched that last pour go into a bloke who could not locate his own mouth',
} as const;
/** The tip economy: cash on the booth glass. */
export const DJ_TIP_UI = {
/** {tip} replaced with the amount. */
titleTipped: 'BOOTH REQUEST · ${tip} ON THE GLASS',
returnLinePrefix: 'back already. ',
returnLineSuffix: ' *the cash is folded into a little paper crane this time*',
take: 'TAKE IT. PLAY IT.',
takeDetail: 'the song owes you nothing now. neither do they',
free: 'PLAY IT. KEEP YOUR CASH.',
freeDetail: 'the booth has standards. tonight, anyway',
nah: 'NAH',
nahDetail: 'not the song. not the money. nothing personal',
took: 'the note disappears into the shoe. the request queue watches it happen.',
freed: 'you wave the cash off and cue it anyway. confusing scenes. good scenes.',
refused: 'refusing the song is business. refusing the money is personal.',
/** {total} replaced at step-off. */
shoe: '${total} in the shoe. the shoe says nothing.',
sting: 'dazza: hearing the booth takes requests for cash now?? we are not that venue. we are exactly that venue but STILL',
} as const;
/** The pour meter. */
export const POUR_UI = {
title: 'THE POUR',
help: 'SPACE — stop the pour · ESC — bail',
short: 'thats mostly glass. they hold it up to the light, disgusted.',
clean: 'a proper pour. the glass sweats politely.',
overflow: 'a criminally heavy pour. someone applauds. the till winces.',
bailed: 'you put the glass down mid-pour. everyone pretends that was normal.',
} as const;
/** Tabs: the jar of other people's cards. */
export const BAR_TAB_UI = {
/** {name} replaced with the card name. */
opened: 'a card lands in the jar. the name on it says {name}. start a tab? apparently youve already agreed.',
title: 'THE CARD BACK',
lines: [
'my card. the black one. or blue. its got a bank on it',
'im heading off. card. the one with my name on. classic card',
'card please champ. ill know it when i see it. i will not',
],
/** {drinks} and {total} replaced on a clean handback. */
closed: '{drinks} drinks on it. ${total}. they sign something that is legally a drawing.',
wrong: 'they leave with someone elses card. somewhere, a direct debit changes hands.',
unclaimed: 'they gave up waiting for the card and left. the jar keeps it. the jar keeps everything.',
} as const;
export const DJ_REQUEST_UI = {
title: 'BOOTH REQUEST',
play: 'PLAY IT',

View File

@ -1,7 +1,7 @@
import Phaser from 'phaser';
import { EventBus } from '../../core/EventBus';
import { GameClock } from '../../core/GameClock';
import { SeededRNG } from '../../core/SeededRNG';
import { SeededRNG, type RngStream } from '../../core/SeededRNG';
import { StubBeatClock } from '../../core/StubBeatClock';
import { Meters, freshNightState } from '../../core/meters';
import { drunkStage } from '../../rules/drunk';
@ -32,18 +32,23 @@ import {
BUILD_FIZZLE_BARS, BUILD_HYPE, DROP_PAYOUT, FIZZLE_VIBE,
REQUEST_HIT, REQUEST_LATER_GRUDGE, REQUEST_NAH_AGGRO,
REQUEST_PLAY_FLOP, REQUEST_PLAY_HIT,
judgeDrop, msToNearestBar,
TIP_AMOUNTS, TIP_REFUSED_AGGRO, TIP_RETURN_AFTER_MS, TIP_RETURN_CHANCE,
TIP_STING_CHANCE, TIP_STING_VIBE,
judgeDrop, msToNearestBar, rollTip,
} from './djShift';
import {
BREACH_SEEN_AFTER, BREACH_SEEN_CHANCE, ORDER_CHANCE_PER_S, ORDER_COOLDOWN_MS,
RSA_STRIKE_REASON, isRsaBreach, judgeCall, type BarCall,
RSA_STRIKE_REASON, TAB_DRINK_PRICE, TAB_OPEN_CHANCE,
canOpenTab, cardLineup, isRsaBreach, judgeCall, judgePour, pourAdjust,
type BarCall, type BarOutcome, type Tab,
} from './barShift';
import { PourOverlay } from './overlays/PourOverlay';
import { getActiveEngine } from '../../audio/TechnoEngine';
import { RACK_RESPAWN_MIN, freshRack, stepRack, turnSharpness, type RackState } from './glassie';
import { tileToWorld } from './venueMap';
import {
AID_UI, BAR_ORDER_LINES, BAR_RESULTS, BAR_UI,
DJ_DROP_RESULTS, DJ_REQUESTS, DJ_REQUEST_UI, DJ_UI,
AID_UI, BAR_ORDER_LINES, BAR_RESULTS, BAR_TAB_UI, BAR_UI,
DJ_DROP_RESULTS, DJ_REQUESTS, DJ_REQUEST_UI, DJ_TIP_UI, DJ_UI, POUR_UI,
HELP_TEXT, JOINT_RESULTS, JOINT_STING, JOINT_UI, NO_STAMP_RADIO,
PHONE_POSTED, PHONE_UI, VOMIT_UI, WATER_UI,
} from '../../data/strings/floor';
@ -141,6 +146,13 @@ export class FloorDemoScene extends Phaser.Scene {
private barMode = false;
/** patronId -> elapsedMs of their last order, so nobody re-orders instantly. */
private barOrderAt = new Map<string, number>();
/** The jar of other people's cards: patronId -> open tab. */
private readonly tabs = new Map<string, Tab>();
private pour!: PourOverlay;
/** Cash taken at the booth tonight. The shoe keeps the count. */
private djTips = 0;
/** NAH'd requesters on their ATM pilgrimage, due back with cash. */
private djReturns: Array<{ scriptId: string; tip: number; dueMs: number }> = [];
private djBuild: { armedAtMs: number } | null = null;
private lastBeat = { index: 0, atMs: 0 };
private beatMs = 500;
@ -211,6 +223,7 @@ export class FloorDemoScene extends Phaser.Scene {
this.patDown = new PatDownOverlay(this, this.nightCtx?.sfx ?? null);
this.choice = new ChoiceOverlay(this);
this.aid = new AidOverlay(this);
this.pour = new PourOverlay(this);
// Some nights have a fainter. Most don't. You find out at the time.
const faintRng = this.nightCtx
? new SeededRNG(this.nightCtx.seed ^ 0xa1d5).stream('fainter')
@ -281,6 +294,9 @@ export class FloorDemoScene extends Phaser.Scene {
this.jointDealt.clear();
this.barMode = false;
this.barOrderAt.clear();
this.tabs.clear();
this.djTips = 0;
this.djReturns = [];
this.rack = null;
this.rackReadyAtMin = 6;
@ -362,6 +378,9 @@ export class FloorDemoScene extends Phaser.Scene {
this.jointDealt.clear();
this.barMode = false;
this.barOrderAt.clear();
this.tabs.clear();
this.djTips = 0;
this.djReturns = [];
this.rack = null;
this.rackReadyAtMin = 6;
@ -375,6 +394,7 @@ export class FloorDemoScene extends Phaser.Scene {
const key = ev.key === ' ' ? 'SPACE' : ev.key.toUpperCase();
if (this.aid.isOpen) return this.aid.handleKey(key);
if (this.pour.isOpen) return this.pour.handleKey(key);
if (this.cutOff.isOpen) return this.cutOff.handleKey(key);
if (this.stall.isOpen) return this.stall.handleKey(key);
if (this.patDown.isOpen) return this.patDown.handleKey(key);
@ -402,7 +422,7 @@ export class FloorDemoScene extends Phaser.Scene {
}
private get overlayOpen(): boolean {
return this.cutOff.isOpen || this.stall.isOpen || this.patDown.isOpen || this.choice.isOpen || this.aid.isOpen;
return this.cutOff.isOpen || this.stall.isOpen || this.patDown.isOpen || this.choice.isOpen || this.aid.isOpen || this.pour.isOpen;
}
private readInput(): PlayerInput {
@ -485,6 +505,7 @@ export class FloorDemoScene extends Phaser.Scene {
this.cutOff.update(dt);
this.stall.update(dt);
this.patDown.update(dt);
this.pour.update(dt);
// World keeps breathing behind the panel, but you can't move while you're
// dealing with someone — that's the point of the mechanic.
this.crowd.update(dt, this.clock.clockMin);
@ -1075,8 +1096,13 @@ export class FloorDemoScene extends Phaser.Scene {
// Handing back mid-build: the bass comes home unsupervised.
if (this.djBuild) this.djResolveBuild(null);
this.djMode = false;
if (this.djTips > 0) {
this.toast(DJ_TIP_UI.shoe.replace('{total}', String(this.djTips)));
this.logIncident('djShift', undefined, `Handed the decks back with $${this.djTips} in the shoe.`);
} else {
this.toast(DJ_UI.handback);
}
}
private djStartBuild(): void {
if (this.djBuild || this.overlayOpen) return;
@ -1136,6 +1162,16 @@ export class FloorDemoScene extends Phaser.Scene {
private tickBar(dt: number): void {
if (!this.barMode || this.overlayOpen) return;
const rq = this.rng.stream('barOrders');
// A tab-holder on their way out wants the card back before anything else.
for (const [pid, tab] of this.tabs) {
const holder = this.crowd.agents.find((a) => a.patron.id === pid);
if (holder && (holder.headingHome === true || holder.gone)) {
this.openCardReturn(holder, tab);
return;
}
}
if (!rq.chance((dt / 1000) * ORDER_CHANCE_PER_S)) return;
// An order needs a body AT the bar — an empty counter pours nothing.
const asker = this.crowd.agents.find(
@ -1164,6 +1200,29 @@ export class FloorDemoScene extends Phaser.Scene {
if (id === null) return; // they gave up on the queue. the queue noticed.
const call = id as BarCall;
const out = judgeCall(call, stage);
if (call === 'serve') {
// A serve is a hand, not a menu click: the pour decides the rest.
this.pour.open((fill) => {
if (fill === null) return this.toast(POUR_UI.bailed);
const verdict = judgePour(fill);
this.applyBarCall(asker, stage, call, pourAdjust(out, verdict), rq, verdict);
});
return;
}
this.applyBarCall(asker, stage, call, out, rq, null);
},
);
}
/** Land a priced bar call on the patron, the meters and the record. */
private applyBarCall(
asker: Agent,
stage: DrunkStage,
call: BarCall,
out: BarOutcome,
rq: RngStream,
pourVerdict: 'short' | 'clean' | 'overflow' | null,
): void {
const delta: { vibe?: number; aggro?: number } = {};
if (out.vibe) delta.vibe = out.vibe;
if (out.aggro) delta.aggro = out.aggro;
@ -1172,6 +1231,7 @@ export class FloorDemoScene extends Phaser.Scene {
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)) {
this.nightCtx.scheduleDeferred([{
@ -1182,14 +1242,67 @@ export class FloorDemoScene extends Phaser.Scene {
patronId: asker.patron.id,
}]);
}
// The tab: cards land in the jar off a completed serve, loose lips willing.
let tabToast: string | null = null;
if (call === 'serve') {
const existing = this.tabs.get(asker.patron.id);
if (existing) {
existing.drinks++;
} else if (canOpenTab(stage) && rq.chance(TAB_OPEN_CHANCE)) {
this.tabs.set(asker.patron.id, { name: asker.patron.idCard.name, drinks: 1 });
tabToast = BAR_TAB_UI.opened.replace('{name}', asker.patron.idCard.name);
}
}
const toastLine =
call === 'serve'
? out.breach ? BAR_RESULTS.servedBreach : BAR_RESULTS.served
? pourVerdict === 'short' || pourVerdict === 'overflow'
? POUR_UI[pourVerdict]
: out.breach ? BAR_RESULTS.servedBreach : BAR_RESULTS.served
: call === 'water'
? out.aggro ? BAR_RESULTS.waterSnub : BAR_RESULTS.water
: isRsaBreach(stage) ? BAR_RESULTS.cutoff : BAR_RESULTS.cutoffEarly;
this.toast(toastLine);
this.logIncident('barServe', asker.patron.id, `${call} at ${stage}.${out.breach ? ' RSA breach.' : ''}`);
this.toast(tabToast ?? toastLine);
const pourNote = pourVerdict && pourVerdict !== 'clean' ? ` Pour: ${pourVerdict}.` : '';
this.logIncident('barServe', asker.patron.id, `${call} at ${stage}.${out.breach ? ' RSA breach.' : ''}${pourNote}`);
}
/** They're leaving; the jar holds their card; the jar holds three cards. */
private openCardReturn(holder: Agent, tab: Tab): void {
const rq = this.rng.stream('tabs');
const others = this.crowd.agents
.filter((a) => a.patron.id !== holder.patron.id)
.map((a) => a.patron.idCard.name);
const lineup = cardLineup(tab.name, others, rq);
this.choice.open(
{
title: BAR_TAB_UI.title,
line: BAR_TAB_UI.lines[rq.int(0, BAR_TAB_UI.lines.length - 1)] ?? BAR_TAB_UI.lines[0]!,
patron: holder.patron,
choices: lineup.map((n) => ({ id: n, label: n.toUpperCase(), detail: 'the jar offers it up' })),
},
(id) => {
this.tabs.delete(holder.patron.id);
if (id === null) {
this.bus.emit('meters:delta', { aggro: 1 });
this.toast(BAR_TAB_UI.unclaimed);
this.logIncident('tabUnclaimed', holder.patron.id, `Left without the card. ${tab.drinks} drinks on it.`);
return;
}
if (id === tab.name) {
this.bus.emit('meters:delta', { vibe: 1 });
this.toast(
BAR_TAB_UI.closed
.replace('{drinks}', String(tab.drinks))
.replace('{total}', String(tab.drinks * TAB_DRINK_PRICE)),
);
this.logIncident('tabClosed', holder.patron.id, `Tab closed clean: ${tab.drinks} drinks, $${tab.drinks * TAB_DRINK_PRICE}.`);
} else {
this.bus.emit('meters:delta', { aggro: 2 });
this.toast(BAR_TAB_UI.wrong);
this.logIncident('tabMisreturned', holder.patron.id, `Handed back the wrong card (${id}).`);
}
},
);
}
@ -1202,18 +1315,82 @@ export class FloorDemoScene extends Phaser.Scene {
}
if (this.overlayOpen) return;
const rq = this.rng.stream('djRequests');
// ATM returns jump the queue: they NAH'd a free ask and went to get cash.
let script: (typeof DJ_REQUESTS)[number] | null = null;
let tip: number | null = null;
let returned = false;
const dueIdx = this.djReturns.findIndex((r) => this.elapsedMs >= r.dueMs);
if (dueIdx >= 0) {
const due = this.djReturns.splice(dueIdx, 1)[0]!;
script = DJ_REQUESTS.find((s) => s.id === due.scriptId) ?? null;
tip = due.tip;
returned = true;
}
if (!script) {
if (!rq.chance((dt / 1000) * 0.012)) return;
const script = DJ_REQUESTS[rq.int(0, DJ_REQUESTS.length - 1)]!;
script = DJ_REQUESTS[rq.int(0, DJ_REQUESTS.length - 1)]!;
tip = rollTip(rq);
returned = false;
}
// The overlay shows the asker's doll, so a request needs an actual body —
// an empty floor sends nobody to the booth glass.
const asker =
this.crowd.agents.find((a) => !a.gone && a.activity === 'dancing') ??
this.crowd.agents.find((a) => !a.gone);
if (!asker) return;
const s = script;
if (tip !== null) {
// Cash on the glass. Cash never helps the song land — it buys the yes.
const amount = tip;
const playIt = (took: boolean): void => {
const hit = rq.chance(REQUEST_HIT[s.id] ?? 0.5);
this.bus.emit('meters:delta', hit ? { ...REQUEST_PLAY_HIT } : { ...REQUEST_PLAY_FLOP });
this.toast(hit ? s.hit : s.flop);
if (!took) {
this.logIncident('djRequest', asker.patron.id, `Waved off $${amount}, played the ${s.id} request anyway. ${hit ? 'It landed.' : 'It did not.'}`);
return;
}
this.djTips += amount;
this.logIncident('djTip', asker.patron.id, `Took $${amount} to play the ${s.id} request. ${hit ? 'It landed.' : 'It did not.'}`);
if (this.nightCtx && rq.chance(TIP_STING_CHANCE)) {
this.nightCtx.scheduleDeferred([{
atClockMin: this.clock.clockMin + 10 + rq.int(0, 25),
vibeDelta: TIP_STING_VIBE,
dazzaText: DJ_TIP_UI.sting,
reason: 'word got around that the booth takes cash',
patronId: asker.patron.id,
}]);
}
};
this.choice.open(
{
title: DJ_TIP_UI.titleTipped.replace('{tip}', String(amount)),
line: returned ? `${DJ_TIP_UI.returnLinePrefix}${s.line}${DJ_TIP_UI.returnLineSuffix}` : s.line,
patron: asker.patron,
choices: [
{ id: 'take', label: DJ_TIP_UI.take, detail: DJ_TIP_UI.takeDetail },
{ id: 'free', label: DJ_TIP_UI.free, detail: DJ_TIP_UI.freeDetail },
{ id: 'nah', label: DJ_TIP_UI.nah, detail: DJ_TIP_UI.nahDetail },
],
},
(id) => {
if (id === null) return; // the note and the request both time out
if (id === 'take') return playIt(true);
if (id === 'free') return playIt(false);
this.bus.emit('meters:delta', { aggro: TIP_REFUSED_AGGRO });
this.logIncident('djRequest', asker.patron.id, `Refused the ${s.id} request AND the $${amount}.`);
this.toast(DJ_TIP_UI.refused);
},
);
return;
}
this.choice.open(
{
title: DJ_REQUEST_UI.title,
line: script.line,
line: s.line,
patron: asker.patron,
choices: [
{ id: 'play', label: DJ_REQUEST_UI.play, detail: DJ_REQUEST_UI.playDetail },
@ -1224,14 +1401,22 @@ export class FloorDemoScene extends Phaser.Scene {
(id) => {
if (id === null) return; // you stared at the crossfader until they left
if (id === 'play') {
const hit = rq.chance(REQUEST_HIT[script.id] ?? 0.5);
const hit = rq.chance(REQUEST_HIT[s.id] ?? 0.5);
this.bus.emit('meters:delta', hit ? { ...REQUEST_PLAY_HIT } : { ...REQUEST_PLAY_FLOP });
this.logIncident('djRequest', asker.patron.id, `Played the ${script.id} request. ${hit ? 'It landed.' : 'It did not.'}`);
this.toast(hit ? script.hit : script.flop);
this.logIncident('djRequest', asker.patron.id, `Played the ${s.id} request. ${hit ? 'It landed.' : 'It did not.'}`);
this.toast(hit ? s.hit : s.flop);
} else if (id === 'nah') {
this.bus.emit('meters:delta', { aggro: REQUEST_NAH_AGGRO });
this.logIncident('djRequest', asker.patron.id, `Declined the ${script.id} request.`);
this.toast(script.nah);
this.logIncident('djRequest', asker.patron.id, `Declined the ${s.id} request.`);
this.toast(s.nah);
// The booth taught them the price. Some go find an ATM.
if (rq.chance(TIP_RETURN_CHANCE)) {
this.djReturns.push({
scriptId: s.id,
tip: TIP_AMOUNTS[rq.int(0, TIP_AMOUNTS.length - 1)] ?? 20,
dueMs: this.elapsedMs + TIP_RETURN_AFTER_MS[0] + rq.int(0, TIP_RETURN_AFTER_MS[1] - TIP_RETURN_AFTER_MS[0]),
});
}
} else {
if (this.nightCtx && rq.chance(REQUEST_LATER_GRUDGE)) {
this.nightCtx.scheduleDeferred([{

View File

@ -72,3 +72,81 @@ export function judgeCall(call: BarCall, stage: DrunkStage): BarOutcome {
? { vibe: 1, aggro: 1, intoxDelta: 0, breach: false, sendsHome: true }
: { vibe: -1, aggro: 2, intoxDelta: 0, breach: false, sendsHome: true };
}
// ---- the pour (SERVE is a hand, not a menu click) ---------------------------
// A sweeping marker, one key to stop it. The line lives near the top because a
// good pour is nearly a mistake.
export type PourVerdict = 'short' | 'clean' | 'overflow';
/** Marker sweep 0→1→0, this long each way. */
export const POUR_SWEEP_MS = 1300;
export const POUR_CLEAN_FROM = 0.68;
export const POUR_OVER_FROM = 0.93;
/** What a heavy hand adds on top of SERVE_DRINK_STEP. */
export const OVERFLOW_EXTRA_INTOX = 0.03;
export function judgePour(fill: number): PourVerdict {
if (fill >= POUR_OVER_FROM) return 'overflow';
if (fill >= POUR_CLEAN_FROM) return 'clean';
return 'short';
}
/**
* 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.
* The RSA breach flag rides the SERVE decision, not the pour a stingy pour
* to a maggot is still a pour to a maggot.
*/
export function pourAdjust(out: BarOutcome, pour: PourVerdict): BarOutcome {
if (pour === 'short') {
const adjusted: BarOutcome = { ...out, aggro: (out.aggro ?? 0) + 1, intoxDelta: out.intoxDelta / 2 };
delete adjusted.vibe;
return adjusted;
}
if (pour === 'overflow') {
return { ...out, vibe: (out.vibe ?? 0) + 1, intoxDelta: out.intoxDelta + OVERFLOW_EXTRA_INTOX };
}
return out;
}
// ---- tabs (the jar of other people's cards) ---------------------------------
/** Chance a served tipsy+ patron opens a tab ("start a tab? legend"). */
export const TAB_OPEN_CHANCE = 0.3;
/** A drink, for the tab total the toast reads out. */
export const TAB_DRINK_PRICE = 14;
export interface Tab {
/** The name printed on the card in the jar. */
name: string;
drinks: number;
}
/** Stages loose+ can open a tab — sober people pay as they go. */
export function canOpenTab(stage: DrunkStage): boolean {
return stage !== 'sober';
}
/**
* The card-return line-up: the right name plus two strangers' cards from the
* jar/room. Order is seeded so the correct answer isn't always slot one.
*/
export function cardLineup(
right: string,
others: readonly string[],
rng: { int(min: number, max: number): number },
): string[] {
const decoys = [...new Set(others.filter((n) => n !== right))];
// FisherYates on the decoy pool, take two.
for (let i = decoys.length - 1; i > 0; i--) {
const j = rng.int(0, i);
[decoys[i], decoys[j]] = [decoys[j]!, decoys[i]!];
}
const lineup = [right, ...decoys.slice(0, 2)];
for (let i = lineup.length - 1; i > 0; i--) {
const j = rng.int(0, i);
[lineup[i], lineup[j]] = [lineup[j]!, lineup[i]!];
}
return lineup;
}

View File

@ -68,3 +68,28 @@ export const REQUEST_PLAY_FLOP = { vibe: -3 };
export const REQUEST_NAH_AGGRO = 1;
/** "after this one": chance they come back later, having remembered. */
export const REQUEST_LATER_GRUDGE = 0.4;
// ---- the tip economy (§4.3: priced, never graded — ESPECIALLY this one) -----
// Some requests arrive waving cash. Cash never helps the song land (the floor
// can't hear your wallet); what it buys is a yes. NAH a free request and
// there's a decent chance they visit the ATM and come back — the booth taught
// them the price. Word can also get around that the booth is for sale.
/** Chance a FRESH request arrives with cash already on the glass. */
export const TIP_OFFER_CHANCE = 0.3;
/** What gets waved. Twenties are the local currency of sudden sincerity. */
export const TIP_AMOUNTS: readonly number[] = [10, 20, 20, 50];
/** After a free NAH: chance they come back with cash. */
export const TIP_RETURN_CHANCE = 0.45;
/** How long the ATM pilgrimage takes (real ms at the decks). */
export const TIP_RETURN_AFTER_MS: readonly [number, number] = [25_000, 70_000];
/** Chance a taken tip is SEEN — deferred, the room cools on a bought booth. */
export const TIP_STING_CHANCE = 0.25;
export const TIP_STING_VIBE = -2;
/** Refusing cash TO ITS FACE is a different insult to refusing a song. */
export const TIP_REFUSED_AGGRO = 2;
export function rollTip(rng: { chance(p: number): boolean; int(min: number, max: number): number }): number | null {
if (!rng.chance(TIP_OFFER_CHANCE)) return null;
return TIP_AMOUNTS[rng.int(0, TIP_AMOUNTS.length - 1)] ?? 20;
}

View File

@ -0,0 +1,101 @@
import Phaser from 'phaser';
import { POUR_SWEEP_MS, POUR_CLEAN_FROM, POUR_OVER_FROM } from '../barShift';
import { POUR_UI } from '../../../data/strings/floor';
const W = 640;
const H = 360;
const GLASS_H = 120;
const GLASS_W = 44;
/**
* The pour (docs/SCENARIOS.md, bartender shift): a marker sweeps 010 and
* SPACE stops it the fill IS your pour. The clean band sits near the top
* because a good pour is nearly a mistake. Same shell discipline as the other
* overlays: the scene routes keys in and calls update(dt).
*/
export class PourOverlay {
private root: Phaser.GameObjects.Container | null = null;
private fillRect: Phaser.GameObjects.Rectangle | null = null;
private t = 0;
private rising = true;
private done: ((fill: number | null) => void) | null = null;
constructor(private readonly scene: Phaser.Scene) {}
get isOpen(): boolean {
return this.root !== null;
}
open(done: (fill: number | null) => void): void {
if (this.root) return;
this.done = done;
this.t = 0;
this.rising = true;
const root = this.scene.add.container(0, 0).setDepth(900).setScrollFactor(0);
this.root = root;
root.add(this.scene.add.rectangle(W / 2, H / 2, W, H, 0x03030a, 0.72));
root.add(this.scene.add.rectangle(W / 2, H / 2, 280, 200, 0x10121c).setStrokeStyle(1, 0x3a4054));
root.add(
this.scene.add
.text(W / 2, H / 2 - 82, POUR_UI.title, { fontFamily: 'monospace', fontSize: '13px', color: '#e8e4da' })
.setOrigin(0.5),
);
// The glass, and the bands painted on its side like an honest jug.
const gx = W / 2;
const gBottom = H / 2 + 58;
root.add(this.scene.add.rectangle(gx, gBottom - GLASS_H / 2, GLASS_W + 8, GLASS_H + 6, 0x232838));
const band = (from: number, to: number, colour: number): void => {
const y0 = gBottom - GLASS_H * from;
const y1 = gBottom - GLASS_H * to;
root.add(
this.scene.add
.rectangle(gx + GLASS_W / 2 + 10, (y0 + y1) / 2, 4, y0 - y1, colour)
.setOrigin(0.5),
);
};
band(0, POUR_CLEAN_FROM, 0x5a4a48); // short: the region of regret
band(POUR_CLEAN_FROM, POUR_OVER_FROM, 0x74a06a); // clean
band(POUR_OVER_FROM, 1, 0xd8a020); // overflow: beloved, expensive
this.fillRect = this.scene.add
.rectangle(gx, gBottom, GLASS_W, 1, 0xc8a24a)
.setOrigin(0.5, 1);
root.add(this.fillRect);
root.add(
this.scene.add
.text(W / 2, H / 2 + 78, POUR_UI.help, { fontFamily: 'monospace', fontSize: '9px', color: '#8890a8' })
.setOrigin(0.5),
);
}
handleKey(key: string): void {
if (!this.root) return;
if (key === 'ESCAPE' || key === 'ESC') return this.finish(null);
if (key === 'SPACE') return this.finish(this.t);
}
update(dtMs: number): void {
if (!this.root) return;
const step = dtMs / POUR_SWEEP_MS;
this.t += this.rising ? step : -step;
if (this.t >= 1) {
this.t = 1;
this.rising = false;
} else if (this.t <= 0) {
this.t = 0;
this.rising = true;
}
this.fillRect?.setDisplaySize(GLASS_W, Math.max(1, GLASS_H * this.t));
}
private finish(fill: number | null): void {
const cb = this.done;
this.root?.destroy();
this.root = null;
this.fillRect = null;
this.done = null;
cb?.(fill);
}
}

View File

@ -1,9 +1,17 @@
import { describe, expect, it } from 'vitest';
import {
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';
@ -84,6 +92,75 @@ describe('judgeCall / cutoff', () => {
});
});
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 changes nothing', () => {
expect(pourAdjust(base, 'clean')).toEqual(base);
});
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('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);

View File

@ -5,9 +5,13 @@ import {
DROP_LATE_MS,
DROP_PAYOUT,
REQUEST_HIT,
TIP_AMOUNTS,
TIP_OFFER_CHANCE,
judgeDrop,
msToNearestBar,
rollTip,
} from '../../src/scenes/floor/djShift';
import { SeededRNG } from '../../src/core/SeededRNG';
import { DJ_REQUESTS } from '../../src/data/strings/floor';
describe('judgeDrop', () => {
@ -72,3 +76,19 @@ describe('booth requests', () => {
}
});
});
describe('the tip economy', () => {
it('rollTip: sometimes cash, always a real amount, never help for the song', () => {
const rng = new SeededRNG(7).stream('tips');
let offers = 0;
for (let i = 0; i < 2000; i++) {
const tip = rollTip(rng);
if (tip === null) continue;
offers++;
expect(TIP_AMOUNTS).toContain(tip);
}
// Roughly TIP_OFFER_CHANCE of requests wave cash.
expect(offers / 2000).toBeGreaterThan(TIP_OFFER_CHANCE - 0.06);
expect(offers / 2000).toBeLessThan(TIP_OFFER_CHANCE + 0.06);
});
});