From b273c5c833ec8ea83a19e0ae2e26bd473797a4d2 Mon Sep 17 00:00:00 2001 From: type-two Date: Tue, 21 Jul 2026 01:56:49 +1000 Subject: [PATCH] =?UTF-8?q?The=20bartender=20shift:=20hold=20the=20taps=20?= =?UTF-8?q?=E2=80=94=20per-stage=20order=20lines,=20SERVE/WATER/CUT=20OFF?= =?UTF-8?q?=20priced=20per=20=C2=A74.3,=20RSA=20breach=20rides=20a=20defer?= =?UTF-8?q?red=20write-up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- src/data/strings/floor.ts | 62 +++++++++++++++ src/scenes/floor/FloorDemoScene.ts | 120 ++++++++++++++++++++++++++++- src/scenes/floor/barShift.ts | 74 ++++++++++++++++++ src/scenes/floor/crowdSim.ts | 6 ++ tests/floor/barShift.test.ts | 91 ++++++++++++++++++++++ 5 files changed, 349 insertions(+), 4 deletions(-) create mode 100644 src/scenes/floor/barShift.ts create mode 100644 tests/floor/barShift.test.ts diff --git a/src/data/strings/floor.ts b/src/data/strings/floor.ts index 05e8777..da92a7f 100644 --- a/src/data/strings/floor.ts +++ b/src/data/strings/floor.ts @@ -268,6 +268,68 @@ export const DJ_REQUESTS: readonly DjRequestScript[] = [ }, ]; +/** The bartender shift (docs/SCENARIOS.md, design §6). The other side of the counter. */ +export const BAR_UI = { + enter: 'E — jump on the taps for a bit', + exit: 'E — hand the taps back', + handover: + 'the bartender unties the apron mid-stride. "orders come to you. nobody cooked gets a pour." gone.', + handback: 'the bartender reclaims the apron and inspects the till like a crime scene. nods once.', + help: 'orders come to the taps · E step off', + title: 'AT THE TAPS', + serve: 'SERVE', + serveDetail: 'pour it. its a bar. this is the job', + water: 'WATER', + waterDetail: 'the house cup. not what they ordered', + cutoff: 'CUT OFF', + cutoffDetail: 'not tonight. the phrase works in here too', +} as const; + +/** The order IS the sobriety test — the spelling degrades with the patron. */ +export const BAR_ORDER_LINES: Record = { + sober: [ + 'two pots and a lemonade, cheers', + 'schooner of the middle tap please', + 'whats on special. actually doesnt matter, one of those', + 'a pot, and one for me mate whos coming. he said five minutes', + ], + tipsy: [ + 'same again legend', + 'shots?? no. no youre right. two beers', + 'one of the fancy ones with the... yknow. the fancy ones', + 'a jug! for the table! the table voted!', + ], + loose: [ + 'mate. mate. two... no. three beers', + 'what did i have last time. that. two of that', + 'somethin strong im celebratin a thing i currently forget', + 'a beer for now and a beer for later. thats time management', + ], + messy: [ + 'jus one more n then im done sweat', + 'beeer. the yellow one. yeknow the one', + 'whos shout is it. its ur shout. one shout pls', + 'i love this bar. one bar please. i mean beer', + ], + maggot: [ + '*points at the taps generally*', + 'u kno wha. surprise me champ', + '*rests forehead on the counter* ...the usual', + 'im so. okay. one. listen. one', + ], +}; + +export const BAR_RESULTS = { + served: 'poured. they tap the counter twice, which means thanks.', + servedBreach: 'poured. it took them three goes to find the glass. somebody definitely saw that.', + water: 'they take the water like its a trick. drink it anyway. improve measurably.', + waterSnub: '"this is water." it is. "i ordered a beer." they did.', + cutoff: 'cut off. they leave the bar the way a tide goes out: slowly, and taking things with it.', + cutoffEarly: 'cut off, two drinks deep, stone sober. they tell the whole queue about you on the way out.', + writeUpDazza: + 'heads up — someone with a lanyard watched that last pour go into a bloke who could not locate his own mouth', +} 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 ec702cb..193e93f 100644 --- a/src/scenes/floor/FloorDemoScene.ts +++ b/src/scenes/floor/FloorDemoScene.ts @@ -34,11 +34,16 @@ import { REQUEST_PLAY_FLOP, REQUEST_PLAY_HIT, judgeDrop, msToNearestBar, } from './djShift'; +import { + BREACH_SEEN_AFTER, BREACH_SEEN_CHANCE, ORDER_CHANCE_PER_S, ORDER_COOLDOWN_MS, + RSA_STRIKE_REASON, isRsaBreach, judgeCall, type BarCall, +} from './barShift'; import { getActiveEngine } from '../../audio/TechnoEngine'; import { RACK_RESPAWN_MIN, freshRack, stepRack, turnSharpness, type RackState } from './glassie'; import { tileToWorld } from './venueMap'; import { - AID_UI, DJ_DROP_RESULTS, DJ_REQUESTS, DJ_REQUEST_UI, DJ_UI, + AID_UI, BAR_ORDER_LINES, BAR_RESULTS, BAR_UI, + DJ_DROP_RESULTS, DJ_REQUESTS, DJ_REQUEST_UI, DJ_UI, HELP_TEXT, JOINT_RESULTS, JOINT_STING, JOINT_UI, NO_STAMP_RADIO, PHONE_POSTED, PHONE_UI, VOMIT_UI, WATER_UI, } from '../../data/strings/floor'; @@ -59,6 +64,10 @@ const DISH_SPOT = tileToWorld(17, 3); const WATER_SPOT = tileToWorld(68, 15); /** The booth (venueMap rect 52..58,19..25): where you hold the decks. */ const DECKS_SPOT = tileToWorld(55, 22); +/** Behind the till (venueMap prop 46..47,4): where you hold the taps. */ +const TAPS_SPOT = tileToWorld(47, 3); +/** The customer side of the till — where E offers you the apron. */ +const TAPS_FRONT = tileToWorld(47, 6); /** Crowd contact distance while carrying, and its debounce. */ const CARRY_BUMP_PX = 14; const CARRY_BUMP_COOLDOWN_MS = 350; @@ -70,7 +79,7 @@ const STAGE_RANK: Readonly> = { /** What pressing E on the current target would do. */ type InteractKind = | 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'exit' | 'joint' | 'rack' | 'dishwasher' - | 'puddle' | 'water' | 'filming' | 'fainter' | 'decks'; + | 'puddle' | 'water' | 'filming' | 'fainter' | 'decks' | 'taps'; interface InteractTarget { kind: InteractKind; @@ -128,6 +137,10 @@ export class FloorDemoScene extends Phaser.Scene { private aid!: AidOverlay; /** On the decks: movement locked, B/SPACE live, requests arrive. */ private djMode = false; + /** On the taps: movement locked, orders arrive as choices. */ + private barMode = false; + /** patronId -> elapsedMs of their last order, so nobody re-orders instantly. */ + private barOrderAt = new Map(); private djBuild: { armedAtMs: number } | null = null; private lastBeat = { index: 0, atMs: 0 }; private beatMs = 500; @@ -266,6 +279,8 @@ export class FloorDemoScene extends Phaser.Scene { this.spotted.clear(); this.cutOffDealt.clear(); this.jointDealt.clear(); + this.barMode = false; + this.barOrderAt.clear(); this.rack = null; this.rackReadyAtMin = 6; @@ -345,6 +360,8 @@ export class FloorDemoScene extends Phaser.Scene { this.spotted.clear(); this.cutOffDealt.clear(); this.jointDealt.clear(); + this.barMode = false; + this.barOrderAt.clear(); this.rack = null; this.rackReadyAtMin = 6; @@ -370,6 +387,11 @@ export class FloorDemoScene extends Phaser.Scene { return; // the decks eat every other key; the floor can wait } + if (this.barMode) { + if (key === 'E') return this.interact(); + return; // the taps hold both hands; orders arrive on their own + } + if (key === 'TAB') { ev.preventDefault(); this.uvMode = !this.uvMode; @@ -473,7 +495,7 @@ export class FloorDemoScene extends Phaser.Scene { // Escorting costs you the walk: no interacting, slower going. const input = this.readInput(); - this.player = this.djMode ? this.player : stepPlayer(this.player, input, dt, VENUE); + this.player = this.djMode || this.barMode ? this.player : stepPlayer(this.player, input, dt, VENUE); if (!this.nightCtx) { // Demo mode stands in for the door with a synthetic spawner. @@ -498,6 +520,7 @@ export class FloorDemoScene extends Phaser.Scene { this.tickHazards(dt); this.aid.update(dt); this.tickDecks(dt); + this.tickBar(dt); // Mopping: feet planted until it's done. The commitment IS the mechanic. if (this.mop) { @@ -657,8 +680,9 @@ export class FloorDemoScene extends Phaser.Scene { if (d <= INTERACT_RANGE) return { kind: 'water', prompt: WATER_UI.pickup }; } - // On the decks the only interaction is stepping off them. + // On the decks (or the taps) the only interaction is stepping off them. if (this.djMode) return { kind: 'decks', prompt: DJ_UI.exit }; + if (this.barMode) return { kind: 'taps', prompt: BAR_UI.exit }; // The booth: hold the decks. Blocked while your hands are otherwise full. if (!this.carryingCup) { @@ -666,6 +690,12 @@ export class FloorDemoScene extends Phaser.Scene { if (d <= INTERACT_RANGE) return { kind: 'decks', prompt: DJ_UI.enter }; } + // The till: hold the taps. Same both-hands rule as the decks. + if (!this.carryingCup && !this.rack) { + const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, TAPS_FRONT.x, TAPS_FRONT.y); + if (d <= INTERACT_RANGE) return { kind: 'taps', prompt: BAR_UI.enter }; + } + // Stall doors first — you interact with the door, not a person. for (const s of VENUE.stalls) { const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, s.door.x, s.door.y); @@ -741,6 +771,7 @@ export class FloorDemoScene extends Phaser.Scene { if (t.kind === 'rack') return this.takeRack(); if (t.kind === 'dishwasher') return this.deliverRack(); if (t.kind === 'decks') return this.toggleDecks(); + if (t.kind === 'taps') return this.toggleTaps(); if (t.kind === 'fainter') return this.openAid(); if (t.kind === 'puddle') return this.interactPuddle(); if (t.kind === 'water') { @@ -1082,6 +1113,87 @@ export class FloorDemoScene extends Phaser.Scene { if (verdict === 'clean') this.nightCtx?.sfx?.play('denyCrowdOoh'); } + // ---- the bartender shift (docs/SCENARIOS.md, design §6) ---------------- + + /** E at the till: take the taps, or hand them back. */ + private toggleTaps(): void { + if (!this.barMode) { + this.barMode = true; + this.player.x = TAPS_SPOT.x; + this.player.y = TAPS_SPOT.y; + this.toast(BAR_UI.handover); + this.promptText.setText(BAR_UI.help); + this.logIncident('barShift', undefined, 'Held the taps while the bartender stepped out.'); + return; + } + this.barMode = false; + this.player.x = TAPS_FRONT.x; + this.player.y = TAPS_FRONT.y; + this.toast(BAR_UI.handback); + } + + /** Orders at the taps. Only while the player is behind the bar. */ + private tickBar(dt: number): void { + if (!this.barMode || this.overlayOpen) return; + const rq = this.rng.stream('barOrders'); + 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( + (a) => + !a.gone && + !a.handled && + a.activity === 'atBar' && + this.elapsedMs - (this.barOrderAt.get(a.patron.id) ?? -Infinity) > ORDER_COOLDOWN_MS, + ); + if (!asker) return; + this.barOrderAt.set(asker.patron.id, this.elapsedMs); + const stage = drunkStage(asker.patron.intoxication); + const lines = BAR_ORDER_LINES[stage]; + this.choice.open( + { + title: BAR_UI.title, + line: lines[rq.int(0, lines.length - 1)] ?? lines[0]!, + patron: asker.patron, + choices: [ + { id: 'serve', label: BAR_UI.serve, detail: BAR_UI.serveDetail }, + { id: 'water', label: BAR_UI.water, detail: BAR_UI.waterDetail }, + { id: 'cutoff', label: BAR_UI.cutoff, detail: BAR_UI.cutoffDetail }, + ], + }, + (id) => { + 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 (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, + }]); + } + 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.' : ''}`); + }, + ); + } + /** Build fizzle + booth requests. Only while the player is on the decks. */ private tickDecks(dt: number): void { if (!this.djMode) return; diff --git a/src/scenes/floor/barShift.ts b/src/scenes/floor/barShift.ts new file mode 100644 index 0000000..3153b4f --- /dev/null +++ b/src/scenes/floor/barShift.ts @@ -0,0 +1,74 @@ +// The bartender shift (docs/SCENARIOS.md, design §6 role ladder). Same split +// as djShift.ts: pure pricing here, tested; the scene owns the overlay, the +// teleport and the taps. +// +// The shape of the fantasy: the other side of the counter. Every order is a +// sobriety read at arm's length — the stage is written on the doll and in the +// spelling — and SERVE / WATER / CUT OFF are all legal, all priced (§4.3). +// Serving someone visibly cooked is the one the licence remembers. + +import type { DrunkStage } from '../../data/types'; +import { WATER_SOBER_STEP } from './hazards'; + +export type BarCall = 'serve' | 'water' | 'cutoff'; + +/** What one served drink adds — a real pour, on top of the bar-anchor drift. */ +export const SERVE_DRINK_STEP = 0.06; + +/** Chance-per-second an order lands while you hold the taps (~one per 18s). */ +export const ORDER_CHANCE_PER_S = 0.055; +/** One patron doesn't re-order inside this window. */ +export const ORDER_COOLDOWN_MS = 60_000; + +/** + * The RSA line (rules/drunk.ts stages): messy and maggot are the breach. + * 'loose' has visible tells but stays legal — that band is where the player + * has to actually decide something, same as the floor cut-off. + */ +export function isRsaBreach(stage: DrunkStage): boolean { + return stage === 'messy' || stage === 'maggot'; +} + +/** Stable reason so shouldRecordStrike folds a whole bad shift into ONE write-up. */ +export const RSA_STRIKE_REASON = 'served alcohol to a visibly intoxicated patron'; +/** A breach pour only becomes a strike if somebody official SAW it. */ +export const BREACH_SEEN_CHANCE = 0.35; +/** Clock-minutes later the write-up lands: base + up-to spread. */ +export const BREACH_SEEN_AFTER: readonly [number, number] = [6, 20]; + +export interface BarOutcome { + vibe?: number; + aggro?: number; + /** Applied to the patron's intoxication (scene clamps to 0..1). */ + intoxDelta: number; + /** An RSA breach pour — may earn the deferred write-up. */ + breach: boolean; + /** Done for the night; the crowd walks them out itself. */ + sendsHome: boolean; +} + +/** + * Price a call against what the patron actually is (§4.3: priced, never + * graded). The breach pour still pays its vibe up front — the bribe is + * immediate, the price arrives later. + */ +export function judgeCall(call: BarCall, stage: DrunkStage): BarOutcome { + if (call === 'serve') { + return { + vibe: 1, + intoxDelta: SERVE_DRINK_STEP, + breach: isRsaBreach(stage), + sendsHome: false, + }; + } + if (call === 'water') { + const needed = stage === 'loose' || isRsaBreach(stage); + return needed + ? { vibe: 1, intoxDelta: -WATER_SOBER_STEP, breach: false, sendsHome: false } + : { aggro: 1, intoxDelta: -WATER_SOBER_STEP, breach: false, sendsHome: false }; + } + // cutoff: right on a breach-stage patron, a whole scene on anyone upright. + return isRsaBreach(stage) + ? { vibe: 1, aggro: 1, intoxDelta: 0, breach: false, sendsHome: true } + : { vibe: -1, aggro: 2, intoxDelta: 0, breach: false, sendsHome: true }; +} diff --git a/src/scenes/floor/crowdSim.ts b/src/scenes/floor/crowdSim.ts index 7108fb1..a9c3cd1 100644 --- a/src/scenes/floor/crowdSim.ts +++ b/src/scenes/floor/crowdSim.ts @@ -447,6 +447,12 @@ export class CrowdSim { else this.chooseTarget(a); // nowhere to go from here — find somewhere else to be } + /** The bar cut them off: done for the night, they walk themselves out. */ + sendHome(a: Agent): void { + if (a.gone || a.activity === 'escorted') return; + this.headHome(a); + } + /** Done for the night: point them at the way out. */ private headHome(a: Agent): void { const plan = this.plans.get(a); diff --git a/tests/floor/barShift.test.ts b/tests/floor/barShift.test.ts new file mode 100644 index 0000000..d62f3bf --- /dev/null +++ b/tests/floor/barShift.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { + SERVE_DRINK_STEP, + isRsaBreach, + judgeCall, +} from '../../src/scenes/floor/barShift'; +import { WATER_SOBER_STEP } from '../../src/scenes/floor/hazards'; +import { BAR_ORDER_LINES } from '../../src/data/strings/floor'; +import type { DrunkStage } from '../../src/data/types'; + +const STAGES: readonly DrunkStage[] = ['sober', 'tipsy', 'loose', 'messy', 'maggot']; + +describe('isRsaBreach', () => { + it('draws the line at messy — loose is the decide band, same as the floor cut-off', () => { + expect(isRsaBreach('sober')).toBe(false); + expect(isRsaBreach('tipsy')).toBe(false); + expect(isRsaBreach('loose')).toBe(false); + expect(isRsaBreach('messy')).toBe(true); + expect(isRsaBreach('maggot')).toBe(true); + }); +}); + +describe('judgeCall / serve', () => { + it('a legal pour pays vibe and adds a real drink', () => { + const out = judgeCall('serve', 'tipsy'); + expect(out).toMatchObject({ vibe: 1, breach: false, sendsHome: false }); + expect(out.intoxDelta).toBe(SERVE_DRINK_STEP); + }); + + it('a breach pour STILL pays its vibe up front — the price arrives later', () => { + for (const stage of ['messy', 'maggot'] as const) { + const out = judgeCall('serve', stage); + expect(out.vibe).toBe(1); + expect(out.breach).toBe(true); + expect(out.intoxDelta).toBe(SERVE_DRINK_STEP); + } + }); + + it('never sends anyone home — a served patron stays a problem', () => { + for (const stage of STAGES) expect(judgeCall('serve', stage).sendsHome).toBe(false); + }); +}); + +describe('judgeCall / water', () => { + it('the good catch: loose and up take the cup and improve', () => { + for (const stage of ['loose', 'messy', 'maggot'] as const) { + const out = judgeCall('water', stage); + expect(out.vibe).toBe(1); + expect(out.aggro).toBeUndefined(); + expect(out.intoxDelta).toBe(-WATER_SOBER_STEP); + } + }); + + it('watering the sober is a snub — they ordered a beer', () => { + for (const stage of ['sober', 'tipsy'] as const) { + const out = judgeCall('water', stage); + expect(out.aggro).toBe(1); + expect(out.vibe).toBeUndefined(); + } + }); + + it('is never a breach — water cannot be an offence', () => { + for (const stage of STAGES) expect(judgeCall('water', stage).breach).toBe(false); + }); +}); + +describe('judgeCall / cutoff', () => { + it('always sends them home, whoever they were', () => { + for (const stage of STAGES) expect(judgeCall('cutoff', stage).sendsHome).toBe(true); + }); + + it('right on a breach-stage patron: a scene, but the room is relieved', () => { + const out = judgeCall('cutoff', 'maggot'); + expect(out).toMatchObject({ vibe: 1, aggro: 1 }); + }); + + it('early on someone upright: they tell the whole queue about you', () => { + const out = judgeCall('cutoff', 'tipsy'); + expect(out).toMatchObject({ vibe: -1, aggro: 2 }); + }); + + it('pours nothing either way', () => { + for (const stage of STAGES) expect(judgeCall('cutoff', stage).intoxDelta).toBe(0); + }); +}); + +describe('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); + }); +});