The bartender shift: hold the taps — per-stage order lines, SERVE/WATER/CUT OFF priced per §4.3, RSA breach rides a deferred write-up

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-21 01:56:49 +10:00
parent 41e93d01ab
commit b273c5c833
5 changed files with 349 additions and 4 deletions

View File

@ -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<DrunkStage, readonly string[]> = {
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',

View File

@ -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<Record<DrunkStage, number>> = {
/** 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<string, number>();
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;

View File

@ -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 };
}

View File

@ -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);

View File

@ -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);
});
});