From b7bb8f49abfa975d2955b0bc716c866b896baf6f Mon Sep 17 00:00:00 2001 From: type-two Date: Tue, 21 Jul 2026 02:17:45 +1000 Subject: [PATCH] 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 --- LANEHANDOVER.md | 50 +++++ src/data/strings/floor.ts | 46 ++++ src/scenes/floor/FloorDemoScene.ts | 263 +++++++++++++++++++---- src/scenes/floor/barShift.ts | 78 +++++++ src/scenes/floor/djShift.ts | 25 +++ src/scenes/floor/overlays/PourOverlay.ts | 101 +++++++++ tests/floor/barShift.test.ts | 77 +++++++ tests/floor/djShift.test.ts | 20 ++ 8 files changed, 621 insertions(+), 39 deletions(-) create mode 100644 src/scenes/floor/overlays/PourOverlay.ts diff --git a/LANEHANDOVER.md b/LANEHANDOVER.md index 47e240e..f9d0f74 100644 --- a/LANEHANDOVER.md +++ b/LANEHANDOVER.md @@ -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). diff --git a/src/data/strings/floor.ts b/src/data/strings/floor.ts index da92a7f..e24b4b1 100644 --- a/src/data/strings/floor.ts +++ b/src/data/strings/floor.ts @@ -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', diff --git a/src/scenes/floor/FloorDemoScene.ts b/src/scenes/floor/FloorDemoScene.ts index 193e93f..420e2d7 100644 --- a/src/scenes/floor/FloorDemoScene.ts +++ b/src/scenes/floor/FloorDemoScene.ts @@ -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(); + /** The jar of other people's cards: patronId -> open tab. */ + private readonly tabs = new Map(); + 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([{ diff --git a/src/scenes/floor/barShift.ts b/src/scenes/floor/barShift.ts index 3153b4f..16563b1 100644 --- a/src/scenes/floor/barShift.ts +++ b/src/scenes/floor/barShift.ts @@ -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; +} diff --git a/src/scenes/floor/djShift.ts b/src/scenes/floor/djShift.ts index 401f4b2..486d269 100644 --- a/src/scenes/floor/djShift.ts +++ b/src/scenes/floor/djShift.ts @@ -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; +} diff --git a/src/scenes/floor/overlays/PourOverlay.ts b/src/scenes/floor/overlays/PourOverlay.ts new file mode 100644 index 0000000..2d22997 --- /dev/null +++ b/src/scenes/floor/overlays/PourOverlay.ts @@ -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); + } +} diff --git a/tests/floor/barShift.test.ts b/tests/floor/barShift.test.ts index d62f3bf..760f5b1 100644 --- a/tests/floor/barShift.test.ts +++ b/tests/floor/barShift.test.ts @@ -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(); + 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); diff --git a/tests/floor/djShift.test.ts b/tests/floor/djShift.test.ts index 2377216..127fd38 100644 --- a/tests/floor/djShift.test.ts +++ b/tests/floor/djShift.test.ts @@ -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); + }); +});