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:
parent
022ea32005
commit
b7bb8f49ab
@ -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 25–70s 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).
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,7 +1096,12 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
// Handing back mid-build: the bass comes home unsupervised.
|
||||
if (this.djBuild) this.djResolveBuild(null);
|
||||
this.djMode = false;
|
||||
this.toast(DJ_UI.handback);
|
||||
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 {
|
||||
@ -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,32 +1200,109 @@ 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);
|
||||
const delta: { vibe?: number; aggro?: 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);
|
||||
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 (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;
|
||||
}
|
||||
if (out.breach && this.nightCtx && rq.chance(BREACH_SEEN_CHANCE)) {
|
||||
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 },
|
||||
dazzaText: BAR_RESULTS.writeUpDazza,
|
||||
reason: RSA_STRIKE_REASON,
|
||||
patronId: asker.patron.id,
|
||||
}]);
|
||||
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;
|
||||
if (out.vibe || out.aggro) 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)) {
|
||||
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 },
|
||||
dazzaText: BAR_RESULTS.writeUpDazza,
|
||||
reason: RSA_STRIKE_REASON,
|
||||
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'
|
||||
? 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(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}).`);
|
||||
}
|
||||
const toastLine =
|
||||
call === 'serve'
|
||||
? 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.' : ''}`);
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -1202,18 +1315,82 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
}
|
||||
if (this.overlayOpen) return;
|
||||
const rq = this.rng.stream('djRequests');
|
||||
if (!rq.chance((dt / 1000) * 0.012)) return;
|
||||
const script = DJ_REQUESTS[rq.int(0, DJ_REQUESTS.length - 1)]!;
|
||||
|
||||
// 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;
|
||||
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([{
|
||||
|
||||
@ -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))];
|
||||
// Fisher–Yates 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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
101
src/scenes/floor/overlays/PourOverlay.ts
Normal file
101
src/scenes/floor/overlays/PourOverlay.ts
Normal 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 0→1→0 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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user