The joint bust (ChoiceOverlay, corruption verb, report accounts) + the glassie run (kitchen, rack carry, wobble physics, glass smash)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-20 17:51:49 +10:00
parent 7803873825
commit 389376c2f1
13 changed files with 632 additions and 7 deletions

View File

@ -1157,3 +1157,46 @@ smoking-door slip-in (FLOOR2 §2's second noStamp source) are the next nibbles.
**Next:** John plays it (economy feel + ear pass, M works live); re-roll any **Next:** John plays it (economy feel + ear pass, M works live); re-roll any
sprite that bugs him; regular step-up lines and the smoking-door slip-in stay sprite that bugs him; regular step-up lines and the smoking-door slip-in stay
the next code nibbles. the next code nibbles.
---
### SESSION — SCENARIOS: THE JOINT & THE RACK (Fable, solo) — 2026-07-21 00:50
**Branch/commits:** main; deployed live.
**Done:**
- **docs/SCENARIOS.md** — the idea shelf: characters (off-duty cop, celebrity
incognito, twins, sovereign citizen, noise-complaint neighbour, lost hen's
party, ex-doorman tutorial character), interactions (ID passback, 1:30
LOCKOUT LAW as a late-game phase, vomit protocol, water station, kebab run),
all held to §4.3 discipline. Two items implemented this session:
- **THE JOINT BUST** (smoking yard): 'joint' is real contraband (severity 2 —
it also turns up in rope pat-downs now). E on a committed smoker who smells
herbal opens the new reusable **ChoiceOverlay** (three verbs + doll +
keyboard/pointer, built for every future floor scenario):
- BIN IT: by the book; the yard grumbles (+1 aggro); truth-logged.
- POCKET IT — **the game's first corruption verb**: nothing happens now;
50% a Dazza sting lands later ("a punter says SECURITY is holding");
and the incident sits in the log with report-form accounts where the
TRUTHFUL option is a confession (interest weight 9 — it WILL come up).
- DIDN'T SEE IT: the yard exhales (+1 vibe, smoker +0.08 intox), and
"no action taken" is still on the record.
- **THE GLASSIE RUN** (the role ladder's glassie fantasy as a floor task):
kitchen room carved behind the bar's west end (dishwasher prop with green
glow, racks, door), a full rack spawns at the bar's EAST end (~22 min
respawn, amber marker). Carry it the length of the venue: crowd bumps and
hard steering build WOBBLE (pure physics in floor/glassie.ts, 5 tests);
wobble breaks glasses one smash at a time (new Sfx 'glassSmash' voice);
deliver survivors for vibe. Drop the lot: "The crowd applauds. Someone
yells TAXI." While carrying, the ONLY interaction in the world is the
dishwasher — the rack owns your hands, the door owns Kayden.
**Verified in-browser**: full rack run (took rack → stressed → 5/6 delivered,
incident logged), joint bust modal screenshot, POCKET IT → truth-log incident +
sting queued. **Tests: 651. Gate clean. Deployed, live 200.**
**Broke / known-wonky:**
- The joint gates on activity === 'smoking' (a committed yard dwell) — right
for real play; test harnesses must pin dwellMs.
- Rack carry doesn't slow the player (wobble IS the cost); revisit if the run
feels free once John plays it.
- ChoiceOverlay is scroll-fixed full-screen; on-floor camera position is
irrelevant to it (deliberate).
**Next:** SCENARIOS.md 🔜 items (ID passback, regular step-up lines,
smoking-door slip-in); the lockout law wants its own design pass; John's two
human passes still open.

79
docs/SCENARIOS.md Normal file
View File

@ -0,0 +1,79 @@
# NOT TONIGHT — Character, Scenario & Interaction Backlog
*The idea shelf. Everything here obeys §4.3: choices cost something real, the
game never grades them, and the humane option is never secretly optimal.
Status: ✅ implemented · 🔜 next nibbles · 💡 backlog.*
## Interactions & mini-systems
- ✅ **The joint bust** (smoking yard). E on a smoker who smells herbal → three
verbs, none of them clean: **BIN IT** (by the book — write-up in the truth
log, smoker's night dampened), **POCKET IT** (the game's first corruption
verb — nothing happens now; maybe a Dazza sting later; and it sits in the
incident log waiting for the report form, where your account of it can be a
lie the audit remembers), **DIDN'T SEE IT** (the yard relaxes, vibe drifts
up… and it's still on the record as ignored). 'joint' is real contraband, so
it also turns up in pat-downs at the rope.
- ✅ **The glassie run** (the glassie role's fantasy, previewed as a floor
task). A wire rack of glasses appears at the bar's east end; carry it the
length of the venue to the kitchen dishwasher. Crowd bumps and sharp turns
build WOBBLE; wobble breaks glasses one smash at a time. Deliver what
survives for vibe. Racks keep coming. The door is unattended while you do it
— Kayden is thrilled.
- 🔜 **ID passback detection**: the same card twice in one night — the name on
the guest-list clipboard you already checked; second appearance of an
identical card = passback. Needs a "cards seen tonight" memory at the desk.
- 🔜 **Regular step-up lines** keyed off timesDeniedBefore ("back again. new
moustache. no hard feelings").
- 🔜 **Smoking-door slip-in** (FLOOR2 §2's second noStamp source).
- 💡 **The 1:30 lockout** (deeply Queensland): after 1:30 AM, admissions are
ILLEGAL. The queue still forms; Dazza still wants bodies; every post-lockout
admit is a guaranteed strike risk vs a rioting line. A whole late-game phase
in one law.
- 💡 **Vomit protocol**: someone redecorates the dance floor → deploy the wet
floor sign (we own the prop) → slips if you dawdle; the glassie mop loop.
- 💡 **Phone confiscation**: someone filming on the dance floor — the
influencer's revenge arc.
- 💡 **Water station duty** (RSA): press a cup on a `loose` patron; done right
it de-escalates the ripen timer. The humane tool that costs time.
- 💡 **First aid**: a fainter. Recovery position via a QTE that is deliberately
NOT funny; the room hushes; the night resumes changed.
- 💡 **Kebab run**: Dazza demands a kebab mid-slam. The shop is right there.
The queue can see you. The whole game in one errand.
## Characters (door archetypes & scripted people)
- 💡 **The off-duty cop** — flashes a badge at the rope. Real cop (deny freely,
he respects it; admit and he quietly reports the venue's breaches — a
walking inspector) or a $30 costume badge (the tells: hat, confidence,
wrong lanyard). The ID game's scariest bluff.
- 💡 **The celebrity incognito** — cap + sunnies (two live rule breaches!).
Deny: nothing happens, ever, and Dazza never finds out who. Admit: vibe
rocket OR pap swarm outside (aggro storm). You cannot know which.
- 💡 **The twins** — two people, one face, different names. Not a fake:
genuinely twins. Everything about the photo check is suddenly on trial.
- 💡 **The sovereign citizen** — "I don't recognise your dress code as having
authority over my person." Aggro engine in a polo. His ID is a laminated
business card.
- 💡 **The delivery rider** — insists the DJ ordered food. The DJ did not
order food. OR DID HE. (The booth lamp flickers hungrily.)
- 💡 **The promoter** — arrives with a roll of wristbands and 'his' guest list
that doesn't match yours. Two clipboards enter, one leaves.
- 💡 **The noise-complaint neighbour** — pyjamas, at the rope, at 1 AM, not
trying to get in: trying to get it turned DOWN. The only patron who wants
less club. Handle him badly and the inspector hears about it.
- 💡 **The hen's party, lost** — seven matching sashes, wrong venue, right
energy. Admitting them is a vibe grenade with a random timer.
- 💡 **The ex-doorman** — watches you work a patron, then tells you what you
missed (a live tutorial character; his tips are right ~80% of the time,
which is the joke).
## Systems worth a whole phase
- 💡 **Venue 4 / ROOM boss week** (design §6): dress code re-randomises every
15 in-game minutes; dialogue rendered as lip-read guesses under the loudest
mix arc.
- 💡 **The DJ shift** (role ladder): the booth is built, the engine's mix is
already externally scriptable — requests at the booth, energy management.
- 💡 **Bartender shift**: the bar geometry and RSA scoring already exist;
pour-and-cut-off from the other side of the counter.

View File

@ -16,7 +16,8 @@ export type SfxName =
| 'phoneBuzz' | 'phoneBuzz'
| 'ropeUnhook' | 'ropeUnhook'
| 'denyCrowdOoh' | 'denyCrowdOoh'
| 'typeTick'; | 'typeTick'
| 'glassSmash';
/** Seconds of white noise generated once and shared by every noise voice. */ /** Seconds of white noise generated once and shared by every noise voice. */
const NOISE_SECONDS = 2; const NOISE_SECONDS = 2;
@ -84,6 +85,8 @@ export class Sfx {
return this.denyCrowdOoh(); return this.denyCrowdOoh();
case 'typeTick': case 'typeTick':
return this.typeTick(); return this.typeTick();
case 'glassSmash':
return this.glassSmash();
} }
} }
@ -271,6 +274,15 @@ export class Sfx {
this.voice(src, [this.band(3400, 2.2), this.env(t, 0.0005, 0.002, 0.012, 0.045)], t, 0.016); this.voice(src, [this.band(3400, 2.2), this.env(t, 0.0005, 0.002, 0.012, 0.045)], t, 0.016);
} }
/** A pint glass meeting a tiled floor: bright burst, short shard ring. */
glassSmash(): void {
const t = this.ctx.currentTime;
const src = this.noiseSource();
this.voice(src, [this.band(3100, 1.1), this.env(t, 0.001, 0.02, 0.12, 0.22)], t, 0.24);
const ring = this.noiseSource();
this.voice(ring, [this.band(5200, 6), this.env(t + 0.03, 0.001, 0.01, 0.08, 0.16)], t + 0.03, 0.18);
}
/** Idempotent — the door scene may call this on every re-entry. */ /** Idempotent — the door scene may call this on every re-entry. */
startRain(): void { startRain(): void {
if (this.rain) return; if (this.rain) return;

View File

@ -16,6 +16,7 @@ export const CONTRABAND: readonly ContrabandDef[] = [
{ id: 'someoneElsesId', name: "Someone else's ID", severity: 2, weight: 2, spriteHint: 'small card' }, { id: 'someoneElsesId', name: "Someone else's ID", severity: 2, weight: 2, spriteHint: 'small card' },
{ id: 'budgie', name: 'A live budgie', severity: 1, weight: 1, spriteHint: 'green blob (flaps)' }, { id: 'budgie', name: 'A live budgie', severity: 1, weight: 1, spriteHint: 'green blob (flaps)' },
{ id: 'vapes', name: 'Fourteen vapes', severity: 1, weight: 3, spriteHint: 'neon sticks' }, { id: 'vapes', name: 'Fourteen vapes', severity: 1, weight: 3, spriteHint: 'neon sticks' },
{ id: 'joint', name: 'One (1) joint', severity: 2, weight: 2, spriteHint: 'small white cone' },
{ id: 'marker', name: 'Fat permanent marker', severity: 2, weight: 2, spriteHint: 'black stick' }, { id: 'marker', name: 'Fat permanent marker', severity: 2, weight: 2, spriteHint: 'black stick' },
{ id: 'snags', name: 'Raw sausages (why)', severity: 1, weight: 1, spriteHint: 'pink links' }, { id: 'snags', name: 'Raw sausages (why)', severity: 1, weight: 1, spriteHint: 'pink links' },
]; ];

View File

@ -126,3 +126,25 @@ export const NO_STAMP_RADIO: readonly string[] = [
export const HELP_TEXT = export const HELP_TEXT =
'WASD move · TAB uv · E interact · SPACE bang · ESC cancel · R reseed'; 'WASD move · TAB uv · E interact · SPACE bang · ESC cancel · R reseed';
/** The smoking-yard joint bust (docs/SCENARIOS.md). Three verbs, none clean. */
export const JOINT_UI = {
title: 'SOMETHING SMELLS HERBAL',
smokerLine: 'its uh. a rollie. bush tobacco. very natural',
bin: 'BIN IT',
binDetail: 'by the book. into the amnesty bin, into the log',
pocket: 'POCKET IT',
pocketDetail: 'for later. for evidence. for whatever',
slide: "DIDN'T SEE IT",
slideDetail: 'you were never here. neither was the smell',
} as const;
export const JOINT_RESULTS = {
binned: 'It goes in the bin. The yard watches it go.',
pocketed: 'It goes in your pocket. Nobody says anything. Especially you.',
slid: 'You study the fence line. Excellent fence. The yard exhales.',
} as const;
/** Dazza, later, if word gets around about what is in your pocket. */
export const JOINT_STING = 'a punter says SECURITY is holding. tell me thats a lie. tell me NOW';

View File

@ -68,6 +68,9 @@ export const REPORT_TUNING = {
// band, never a fixed running order. // band, never a fixed running order.
interest: { interest: {
deferred: 10, // a breach the audit already found — the awkward one deferred: 10, // a breach the audit already found — the awkward one
jointPocketed: 9, // the one question you really don't want asked
jointBinned: 3,
jointIgnored: 5,
ejection: 8, ejection: 8,
maggotUnhandled: 7, maggotUnhandled: 7,
kayden: 6, // his mistake, your signature kayden: 6, // his mistake, your signature
@ -137,6 +140,30 @@ const BANKS: Record<string, AccountBank> = {
{ id: 'maggot.soft2', text: 'Patron was seated and stable. No intervention was assessed as necessary at that point.', truthful: false }, { id: 'maggot.soft2', text: 'Patron was seated and stable. No intervention was assessed as necessary at that point.', truthful: false },
], ],
}, },
jointPocketed: {
prompt: 'Contraband was located in the smoking area. Where is it now?',
options: [
{ id: 'jointP.true', text: 'On me. I took possession and I still have possession. That is the account.', truthful: true },
{ id: 'jointP.soft1', text: 'Item was destroyed on the spot in view of the patron.', truthful: false },
{ id: 'jointP.soft2', text: 'Item was placed in the amnesty bin per procedure. The bin was emptied by close.', truthful: false },
],
},
jointBinned: {
prompt: 'Contraband was located in the smoking area. Your account?',
options: [
{ id: 'jointB.true', text: 'Confiscated and binned in front of them. The yard went quiet for a bit.', truthful: true },
{ id: 'jointB.soft1', text: 'Patron surrendered the item voluntarily and thanked security for the chat.', truthful: false },
{ id: 'jointB.soft2', text: 'Item was assessed as herbal cigarettes. Binned as general litter.', truthful: false },
],
},
jointIgnored: {
prompt: 'A smell was reported near the smoking area. Your account?',
options: [
{ id: 'jointI.true', text: 'I smelled it, I saw it, and I decided the fence line was more interesting.', truthful: true },
{ id: 'jointI.soft1', text: 'Area was patrolled at the time stated. Nothing of note was observed.', truthful: false },
{ id: 'jointI.soft2', text: 'Smell was traced to the kebab shop exhaust across the road.', truthful: false },
],
},
kayden: { kayden: {
prompt: 'A ruling was made at the rope while you were inside. Your account?', prompt: 'A ruling was made at the rope while you were inside. Your account?',
options: [ options: [

View File

@ -20,7 +20,10 @@ import { layoutPockets, type Pocket } from './patDown';
import { CutOffOverlay } from './overlays/CutOffOverlay'; import { CutOffOverlay } from './overlays/CutOffOverlay';
import { StallOverlay } from './overlays/StallOverlay'; import { StallOverlay } from './overlays/StallOverlay';
import { PatDownOverlay } from './overlays/PatDownOverlay'; import { PatDownOverlay } from './overlays/PatDownOverlay';
import { HELP_TEXT, NO_STAMP_RADIO } from '../../data/strings/floor'; import { ChoiceOverlay } from './overlays/ChoiceOverlay';
import { RACK_RESPAWN_MIN, freshRack, stepRack, turnSharpness, type RackState } from './glassie';
import { tileToWorld } from './venueMap';
import { HELP_TEXT, JOINT_RESULTS, JOINT_STING, JOINT_UI, NO_STAMP_RADIO } from '../../data/strings/floor';
import { MeterHud } from '../../ui/MeterHud'; import { MeterHud } from '../../ui/MeterHud';
import type { NightContext } from '../door/NightScene'; import type { NightContext } from '../door/NightScene';
import type { Patron } from '../../data/types'; import type { Patron } from '../../data/types';
@ -31,13 +34,19 @@ const MAX_CROWD = 44;
const SPAWN_EVERY_MS = 420; const SPAWN_EVERY_MS = 420;
/** Admitted at the rope → appears on the floor after the walk in. */ /** Admitted at the rope → appears on the floor after the walk in. */
const WALK_IN_MS: [number, number] = [6000, 14000]; const WALK_IN_MS: [number, number] = [6000, 14000];
/** Where a full rack of empties appears (bar's east end) and where it goes. */
const RACK_SPOT = tileToWorld(51, 7);
const DISH_SPOT = tileToWorld(17, 3);
/** Crowd contact distance while carrying, and its debounce. */
const CARRY_BUMP_PX = 14;
const CARRY_BUMP_COOLDOWN_MS = 350;
const STAGE_RANK: Readonly<Record<DrunkStage, number>> = { const STAGE_RANK: Readonly<Record<DrunkStage, number>> = {
sober: 0, tipsy: 1, loose: 2, messy: 3, maggot: 4, sober: 0, tipsy: 1, loose: 2, messy: 3, maggot: 4,
}; };
/** What pressing E on the current target would do. */ /** What pressing E on the current target would do. */
type InteractKind = 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'exit'; type InteractKind = 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'exit' | 'joint' | 'rack' | 'dishwasher';
interface InteractTarget { interface InteractTarget {
kind: InteractKind; kind: InteractKind;
@ -77,6 +86,14 @@ export class FloorDemoScene extends Phaser.Scene {
private cutOff!: CutOffOverlay; private cutOff!: CutOffOverlay;
private stall!: StallOverlay; private stall!: StallOverlay;
private patDown!: PatDownOverlay; private patDown!: PatDownOverlay;
private choice!: ChoiceOverlay;
private readonly jointDealt = new Set<string>();
private rack: RackState | null = null;
private rackReadyAtMin = 6;
private rackMarker: Phaser.GameObjects.Image | null = null;
private carryBumpMs = 0;
private prevVx = 0;
private prevVy = 0;
private keys!: Record<string, Phaser.Input.Keyboard.Key>; private keys!: Record<string, Phaser.Input.Keyboard.Key>;
private uvMode = false; private uvMode = false;
@ -134,6 +151,15 @@ export class FloorDemoScene extends Phaser.Scene {
this.view = new FloorView(this, VENUE); this.view = new FloorView(this, VENUE);
this.cutOff = new CutOffOverlay(this); this.cutOff = new CutOffOverlay(this);
this.patDown = new PatDownOverlay(this, this.nightCtx?.sfx ?? null); this.patDown = new PatDownOverlay(this, this.nightCtx?.sfx ?? null);
this.choice = new ChoiceOverlay(this);
this.rackMarker = this.add
.image(RACK_SPOT.x, RACK_SPOT.y, 'floor:glow')
.setDepth(17)
.setBlendMode(Phaser.BlendModes.ADD)
.setTint(0xd8c23a)
.setAlpha(0.5)
.setScale(0.8)
.setVisible(false);
this.promptText = this.fixedText(4, VIEW_H - 24, '#9fe8a0'); this.promptText = this.fixedText(4, VIEW_H - 24, '#9fe8a0');
this.statusText = this.fixedText(4, VIEW_H - 12, '#556'); this.statusText = this.fixedText(4, VIEW_H - 12, '#556');
@ -185,6 +211,9 @@ export class FloorDemoScene extends Phaser.Scene {
this.maggotSeen.clear(); this.maggotSeen.clear();
this.spotted.clear(); this.spotted.clear();
this.cutOffDealt.clear(); this.cutOffDealt.clear();
this.jointDealt.clear();
this.rack = null;
this.rackReadyAtMin = 6;
this.ctxOffs = [ this.ctxOffs = [
// The floor is fed by the door: every admission walks in a few seconds later. // The floor is fed by the door: every admission walks in a few seconds later.
@ -251,6 +280,9 @@ export class FloorDemoScene extends Phaser.Scene {
this.maggotSeen.clear(); this.maggotSeen.clear();
this.spotted.clear(); this.spotted.clear();
this.cutOffDealt.clear(); this.cutOffDealt.clear();
this.jointDealt.clear();
this.rack = null;
this.rackReadyAtMin = 6;
this.clock.start(); this.clock.start();
this.beat.start(); this.beat.start();
@ -264,6 +296,7 @@ export class FloorDemoScene extends Phaser.Scene {
if (this.cutOff.isOpen) return this.cutOff.handleKey(key); if (this.cutOff.isOpen) return this.cutOff.handleKey(key);
if (this.stall.isOpen) return this.stall.handleKey(key); if (this.stall.isOpen) return this.stall.handleKey(key);
if (this.patDown.isOpen) return this.patDown.handleKey(key); if (this.patDown.isOpen) return this.patDown.handleKey(key);
if (this.choice.isOpen) return this.choice.handleKey(key);
if (key === 'TAB') { if (key === 'TAB') {
ev.preventDefault(); ev.preventDefault();
@ -275,7 +308,7 @@ export class FloorDemoScene extends Phaser.Scene {
} }
private get overlayOpen(): boolean { private get overlayOpen(): boolean {
return this.cutOff.isOpen || this.stall.isOpen || this.patDown.isOpen; return this.cutOff.isOpen || this.stall.isOpen || this.patDown.isOpen || this.choice.isOpen;
} }
private readInput(): PlayerInput { private readInput(): PlayerInput {
@ -373,6 +406,8 @@ export class FloorDemoScene extends Phaser.Scene {
this.crowd.update(dt, this.clock.clockMin); this.crowd.update(dt, this.clock.clockMin);
if (this.escorting) this.tickEscort(dt); if (this.escorting) this.tickEscort(dt);
this.tickCarry(dt);
this.rackMarker?.setVisible(this.rack === null && this.clock.clockMin >= this.rackReadyAtMin);
this.trackMaggots(); this.trackMaggots();
this.tickCalm(dt); this.tickCalm(dt);
@ -470,6 +505,22 @@ export class FloorDemoScene extends Phaser.Scene {
private findTarget(): InteractTarget | null { private findTarget(): InteractTarget | null {
const spec = this.coneSpec(); const spec = this.coneSpec();
// Hands full: the only thing in the world is the dishwasher. Everything
// else (including the door home) waits until the rack is down.
if (this.rack) {
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, DISH_SPOT.x, DISH_SPOT.y);
if (d <= INTERACT_RANGE) return { kind: 'dishwasher', prompt: 'E — rack into the dishwasher' };
return null;
}
// A full rack waiting at the bar's east end.
if (this.clock.clockMin >= this.rackReadyAtMin) {
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, RACK_SPOT.x, RACK_SPOT.y);
if (d <= INTERACT_RANGE) {
return { kind: 'rack', prompt: 'E — take the rack through to the kitchen' };
}
}
// Night mode: the way back out to the rope is itself an interaction. // Night mode: the way back out to the rope is itself an interaction.
if (this.nightCtx) { if (this.nightCtx) {
const entry = VENUE.anchors.entry[0]; const entry = VENUE.anchors.entry[0];
@ -513,6 +564,16 @@ export class FloorDemoScene extends Phaser.Scene {
return { kind: 'noStamp', agent: best, prompt: 'E — no stamp. Walk them back to the door' }; return { kind: 'noStamp', agent: best, prompt: 'E — no stamp. Walk them back to the door' };
} }
// The yard bust: a smoker whose rollie is not a rollie (docs/SCENARIOS.md).
if (
best.activity === 'smoking' &&
best.patron.flags.contraband?.includes('joint') &&
!this.jointDealt.has(best.patron.id)
) {
this.spot(best.patron.id, 'contraband');
return { kind: 'joint', agent: best, prompt: 'E — something smells herbal' };
}
const stage = drunkStage(best.patron.intoxication); const stage = drunkStage(best.patron.intoxication);
if (stage === 'messy' || stage === 'maggot' || stage === 'loose') { if (stage === 'messy' || stage === 'maggot' || stage === 'loose') {
// Dealing with someone settles that stage, not the whole night. The Quiet // Dealing with someone settles that stage, not the whole night. The Quiet
@ -536,13 +597,120 @@ export class FloorDemoScene extends Phaser.Scene {
if (!t || this.escorting) return; if (!t || this.escorting) return;
if (t.kind === 'exit') return this.nightCtx?.requestLocation('door'); if (t.kind === 'exit') return this.nightCtx?.requestLocation('door');
if (t.kind === 'rack') return this.takeRack();
if (t.kind === 'dishwasher') return this.deliverRack();
if (t.kind === 'stall' && t.stallId) return this.openStall(t.stallId); if (t.kind === 'stall' && t.stallId) return this.openStall(t.stallId);
if (!t.agent) return; if (!t.agent) return;
if (t.kind === 'joint') return this.openJointBust(t.agent);
if (t.kind === 'noStamp') return this.startEscort(t.agent, 'clean', 'noStamp'); if (t.kind === 'noStamp') return this.startEscort(t.agent, 'clean', 'noStamp');
if (t.kind === 'drunk') return this.openCutOff(t.agent); if (t.kind === 'drunk') return this.openCutOff(t.agent);
if (t.kind === 'contraband') return this.openPatDown(t.agent); if (t.kind === 'contraband') return this.openPatDown(t.agent);
} }
// ---- the joint bust (docs/SCENARIOS.md) --------------------------------
private openJointBust(agent: Agent): void {
this.choice.open(
{
title: JOINT_UI.title,
line: JOINT_UI.smokerLine,
patron: agent.patron,
choices: [
{ id: 'bin', label: JOINT_UI.bin, detail: JOINT_UI.binDetail },
{ id: 'pocket', label: JOINT_UI.pocket, detail: JOINT_UI.pocketDetail },
{ id: 'slide', label: JOINT_UI.slide, detail: JOINT_UI.slideDetail },
],
},
(id) => {
if (id === null) return; // walked away; the smell keeps
this.jointDealt.add(agent.patron.id);
const left = (agent.patron.flags.contraband ?? []).filter((c) => c !== 'joint');
if (left.length > 0) agent.patron.flags.contraband = left;
else delete agent.patron.flags.contraband;
if (id === 'bin') {
// By the book. The yard notices the book.
this.bus.emit('meters:delta', { aggro: 1 });
this.logIncident('jointBinned', agent.patron.id, 'Confiscated in the yard, into the amnesty bin.');
this.toast(JOINT_RESULTS.binned);
} else if (id === 'pocket') {
// Nothing happens now. That is the whole point of now.
this.logIncident('jointPocketed', agent.patron.id, 'Confiscated in the yard. Retained by security.');
this.toast(JOINT_RESULTS.pocketed);
if (this.nightCtx && this.rng.stream('joint').chance(0.5)) {
this.nightCtx.scheduleDeferred([{
atClockMin: this.clock.clockMin + 20 + this.rng.stream('joint').int(0, 40),
vibeDelta: -2,
dazzaText: JOINT_STING,
reason: 'word got around about what security is holding',
patronId: agent.patron.id,
}]);
}
} else {
// The fence line really is excellent tonight.
agent.patron.intoxication = Math.min(1, agent.patron.intoxication + 0.08);
this.bus.emit('meters:delta', { vibe: 1 });
this.logIncident('jointIgnored', agent.patron.id, 'A smell near the yard. No action taken.');
this.toast(JOINT_RESULTS.slid);
}
},
);
}
// ---- the glassie run (docs/SCENARIOS.md) -------------------------------
private takeRack(): void {
this.rack = freshRack();
this.rackMarker?.setVisible(false);
this.prevVx = this.player.vx;
this.prevVy = this.player.vy;
}
private deliverRack(): void {
const r = this.rack;
if (!r) return;
this.rack = null;
this.rackReadyAtMin = this.clock.clockMin + RACK_RESPAWN_MIN;
const vibe = Math.min(3, r.glasses * 0.5);
if (vibe > 0) this.bus.emit('meters:delta', { vibe });
this.logIncident('glassieRun', undefined, `${r.glasses}/6 glasses made it to the dishwasher.`);
this.toast(r.glasses === 6 ? 'Full rack, not a chip. The dishwasher accepts your offering.' : `${r.glasses} of 6 survived the crowd.`);
}
/** Carry physics: bumps and hard steering shake the rack; wobble breaks glass. */
private tickCarry(dt: number): void {
const r = this.rack;
if (!r) return;
this.carryBumpMs += dt;
let bumped = false;
if (this.carryBumpMs >= CARRY_BUMP_COOLDOWN_MS) {
for (const a of this.crowd.agents) {
if (a.gone) continue;
if (Phaser.Math.Distance.Between(this.player.x, this.player.y, a.x, a.y) < CARRY_BUMP_PX) {
bumped = true;
this.carryBumpMs = 0;
break;
}
}
}
const sharp = turnSharpness(this.prevVx, this.prevVy, this.player.vx, this.player.vy, 116);
this.prevVx = this.player.vx;
this.prevVy = this.player.vy;
const out = stepRack(r, dt, bumped, sharp);
this.rack = out.rack;
if (out.broke > 0) {
this.nightCtx?.sfx?.play('glassSmash');
this.bus.emit('meters:delta', { vibe: -0.5 });
if (out.rack.glasses <= 0) {
this.rack = null;
this.rackReadyAtMin = this.clock.clockMin + RACK_RESPAWN_MIN;
this.bus.emit('meters:delta', { vibe: -1.5 });
this.logIncident('rackDropped', undefined, 'The whole rack. The whole floor heard it.');
this.toast('The last glass goes. The crowd applauds. Someone yells TAXI.');
}
}
}
private openStall(stallId: string): void { private openStall(stallId: string): void {
const occupants = this.crowd.stallOccupancy().get(stallId) ?? []; const occupants = this.crowd.stallOccupancy().get(stallId) ?? [];
this.stall.open( this.stall.open(
@ -655,12 +823,17 @@ export class FloorDemoScene extends Phaser.Scene {
if (this.radioMs <= 0) this.radioText.setText(''); if (this.radioMs <= 0) this.radioText.setText('');
} }
// A brewing fight outranks everything — it's the one thing on a clock. // A brewing fight outranks everything — it's the one thing on a clock.
const carryLine = this.rack
? `RACK ${'▮'.repeat(this.rack.glasses)}${'▯'.repeat(6 - this.rack.glasses)} wobble ${'!'.repeat(Math.min(4, Math.ceil(this.rack.wobble * 4)))}`
: null;
this.promptText.setText( this.promptText.setText(
this.fights.active this.fights.active
? 'GET BETWEEN THEM' ? 'GET BETWEEN THEM'
: this.escorting : this.escorting
? 'escorting — walk them to the door (no interacting)' ? 'escorting — walk them to the door (no interacting)'
: (this.target?.prompt ?? ''), : carryLine && !this.target
? carryLine
: this.target?.prompt ?? '',
); );
const maggots = this.crowd.agents.filter( const maggots = this.crowd.agents.filter(
(a) => !a.gone && !a.handled && drunkStage(a.patron.intoxication) === 'maggot', (a) => !a.gone && !a.handled && drunkStage(a.patron.intoxication) === 'maggot',
@ -679,6 +852,7 @@ export class FloorDemoScene extends Phaser.Scene {
this.cutOff?.destroy(); this.cutOff?.destroy();
this.stall?.destroy(); this.stall?.destroy();
this.patDown?.destroy(); this.patDown?.destroy();
this.choice?.destroy();
// Night mode: the bus/meters belong to NightScene — touch nothing shared. // Night mode: the bus/meters belong to NightScene — touch nothing shared.
this.meters?.destroy(); this.meters?.destroy();
this.hud?.destroy(); this.hud?.destroy();

View File

@ -619,6 +619,7 @@ const PROP_SIZES: Record<PropKind, [number, number]> = {
poster1: [32, 16], poster2: [32, 16], poster3: [32, 16], poster1: [32, 16], poster2: [32, 16], poster3: [32, 16],
cloak: [16, 48], stampPodium: [16, 16], arcade: [32, 32], cloak: [16, 48], stampPodium: [16, 16], arcade: [32, 32],
highTable: [16, 16], plant: [16, 16], wetFloor: [16, 16], poolTable: [48, 32], highTable: [16, 16], plant: [16, 16], wetFloor: [16, 16], poolTable: [48, 32],
dishwasher: [32, 16],
}; };
function bakePropPlaceholder(scene: Phaser.Scene, kind: PropKind): string { function bakePropPlaceholder(scene: Phaser.Scene, kind: PropKind): string {
@ -724,6 +725,9 @@ function bakePropPlaceholder(scene: Phaser.Scene, kind: PropKind): string {
case 'wetFloor': case 'wetFloor':
f(5, 2, 6, 12, '#d8c23a'); f(6, 4, 4, 4, '#22222c'); f(5, 2, 6, 12, '#d8c23a'); f(6, 4, 4, 4, '#22222c');
break; break;
case 'dishwasher':
f(1, 1, 30, 14, '#3a4450'); f(4, 4, 10, 8, '#22303a'); f(17, 4, 11, 3, '#50c060'); f(17, 9, 11, 3, '#8a9aa2');
break;
case 'poolTable': case 'poolTable':
f(2, 2, 44, 28, '#4a3226'); f(5, 5, 38, 22, '#1e6e3c'); f(2, 2, 44, 28, '#4a3226'); f(5, 5, 38, 22, '#1e6e3c');
for (const [px, py] of [[6, 6], [42, 6], [6, 25], [42, 25], [24, 5], [24, 26]] as const) { for (const [px, py] of [[6, 6], [42, 6], [6, 25], [42, 25], [24, 5], [24, 26]] as const) {

View File

@ -0,0 +1,63 @@
// The glassie run, pure half (docs/SCENARIOS.md): carry a wire rack of glasses
// through a nightclub crowd without redecorating the floor. Wobble physics and
// breakage rules live here so the feel is testable; the scene only reports
// bumps and steering.
export const RACK_GLASSES = 6;
/** Wobble at or past this breaks a glass (and the wobble sheds with it). */
export const BREAK_AT = 1;
/** A break dumps you back here — shaken, not reset. */
const AFTER_BREAK = 0.45;
/** Passive settle per second when you walk smoothly. */
const SETTLE_PER_S = 0.35;
/** One crowd bump is most of the way to trouble on a settled rack. */
const BUMP_WOBBLE = 0.55;
/** Full-lock steering reversal adds this much per second. */
const TURN_WOBBLE_PER_S = 1.4;
/** In-game minutes between racks — the bar never stops producing empties. */
export const RACK_RESPAWN_MIN = 22;
export interface RackState {
glasses: number;
wobble: number;
}
export const freshRack = (): RackState => ({ glasses: RACK_GLASSES, wobble: 0 });
/**
* Advance the rack one frame. `turnSharpness` is 0..1 (how hard the carry
* velocity swung this frame); `bumped` is a de-bounced crowd contact.
* Returns the new state plus how many glasses broke THIS frame (0 or 1 a
* frame only ever breaks one; the wobble shed makes chains possible but earned).
*/
export function stepRack(
rack: RackState,
dtMs: number,
bumped: boolean,
turnSharpness: number,
): { rack: RackState; broke: number } {
const dt = Math.max(0, dtMs) / 1000;
let wobble = rack.wobble - SETTLE_PER_S * dt;
wobble += Math.max(0, Math.min(1, turnSharpness)) * TURN_WOBBLE_PER_S * dt;
if (bumped) wobble += BUMP_WOBBLE;
wobble = Math.max(0, wobble);
if (wobble >= BREAK_AT && rack.glasses > 0) {
return { rack: { glasses: rack.glasses - 1, wobble: AFTER_BREAK }, broke: 1 };
}
return { rack: { glasses: rack.glasses, wobble }, broke: 0 };
}
/** 0..1 steering harshness from consecutive frame velocities. */
export function turnSharpness(
prevVx: number,
prevVy: number,
vx: number,
vy: number,
maxSpeed: number,
): number {
if (maxSpeed <= 0) return 0;
const dx = vx - prevVx;
const dy = vy - prevVy;
return Math.min(1, Math.hypot(dx, dy) / (maxSpeed * 2));
}

View File

@ -0,0 +1,140 @@
import Phaser from 'phaser';
import { renderDoll } from '../../../patrons/doll';
import type { Patron } from '../../../data/types';
// A reusable three-verb modal for floor scenarios (docs/SCENARIOS.md): the
// joint bust today, whatever the yard produces next. Same shell discipline as
// the other overlays: the scene routes keys, the overlay owns no listeners of
// its own beyond pointer hits on its rows.
const W = 640;
const H = 360;
const PANEL_X = 96;
const PANEL_Y = 52;
const PANEL_W = 448;
const PANEL_H = 236;
const ROW_X = PANEL_X + 118;
const ROW_W = PANEL_W - 136;
const ROW_TOP = PANEL_Y + 86;
const ROW_GAP = 34;
const AMBER = 0xd8a020;
const mono = (size: number, colour: string): Phaser.Types.GameObjects.Text.TextStyle => ({
fontFamily: 'monospace',
fontSize: `${size}px`,
color: colour,
});
export interface ChoiceDef {
id: string;
label: string;
detail: string;
}
export interface ChoiceArgs {
title: string;
/** What the person at the centre of it says. */
line: string;
patron: Patron;
choices: readonly ChoiceDef[]; // up to 3
}
export class ChoiceOverlay {
private readonly scene: Phaser.Scene;
private readonly root: Phaser.GameObjects.Container;
private readonly titleText: Phaser.GameObjects.Text;
private readonly lineText: Phaser.GameObjects.Text;
private readonly doll: Phaser.GameObjects.Image;
private readonly rows: Array<{
box: Phaser.GameObjects.Rectangle;
label: Phaser.GameObjects.Text;
detail: Phaser.GameObjects.Text;
}> = [];
private choices: readonly ChoiceDef[] = [];
private done: ((id: string | null) => void) | null = null;
private open_ = false;
constructor(scene: Phaser.Scene) {
this.scene = scene;
this.root = scene.add.container(0, 0).setDepth(5000).setScrollFactor(0).setVisible(false);
this.root.add(scene.add.rectangle(W / 2, H / 2, W, H, 0x000000, 0.82));
this.root.add(
scene.add.rectangle(W / 2, PANEL_Y + PANEL_H / 2, PANEL_W, PANEL_H, 0x101018).setStrokeStyle(1, 0x6080a0, 0.7),
);
this.titleText = scene.add.text(W / 2, PANEL_Y + 16, '', mono(11, '#9fc0e8')).setOrigin(0.5, 0);
this.lineText = scene.add
.text(PANEL_X + 118, PANEL_Y + 44, '', mono(8, '#cfc6d8'))
.setOrigin(0, 0)
.setWordWrapWidth(ROW_W);
this.doll = scene.add.image(PANEL_X + 58, PANEL_Y + 120, '__DEFAULT').setScale(2);
this.root.add([this.titleText, this.lineText, this.doll]);
for (let i = 0; i < 3; i++) {
const y = ROW_TOP + i * ROW_GAP;
const box = scene.add
.rectangle(ROW_X + ROW_W / 2, y, ROW_W, 28, 0x14141f)
.setStrokeStyle(1, 0x2a2a3a)
.setInteractive({ useHandCursor: true });
const label = scene.add.text(ROW_X + 10, y - 6, '', mono(9, '#e8e0d0')).setOrigin(0, 0.5);
const detail = scene.add.text(ROW_X + 10, y + 7, '', mono(7, '#6a7a8a')).setOrigin(0, 0.5);
box.on('pointerover', () => box.setStrokeStyle(1, AMBER));
box.on('pointerout', () => box.setStrokeStyle(1, 0x2a2a3a));
box.on('pointerup', () => this.pick(i));
this.root.add([box, label, detail]);
this.rows.push({ box, label, detail });
}
this.root.add(
scene.add
.text(W / 2, PANEL_Y + PANEL_H - 16, '1/2/3 choose · ESC walk away', mono(7, '#5a5a70'))
.setOrigin(0.5, 0),
);
}
get isOpen(): boolean {
return this.open_;
}
open(args: ChoiceArgs, done: (id: string | null) => void): void {
this.done = done;
this.choices = args.choices.slice(0, 3);
this.open_ = true;
this.titleText.setText(args.title);
this.lineText.setText(`"${args.line}"`);
this.doll.setTexture(renderDoll(this.scene, args.patron, 'queue'));
this.rows.forEach((r, i) => {
const c = this.choices[i];
r.box.setVisible(!!c);
r.label.setVisible(!!c).setText(c ? `${i + 1} ${c.label}` : '');
r.detail.setVisible(!!c).setText(c ? c.detail : '');
});
this.root.setVisible(true);
}
handleKey(key: string): void {
if (!this.open_) return;
if (key === 'ESCAPE' || key === 'ESC') return this.close(null);
const idx = Number(key) - 1;
if (idx >= 0 && idx < this.choices.length) this.pick(idx);
}
update(_dtMs: number): void {}
private pick(i: number): void {
const c = this.choices[i];
if (c) this.close(c.id);
}
private close(id: string | null): void {
this.open_ = false;
this.root.setVisible(false);
const done = this.done;
this.done = null;
done?.(id);
}
destroy(): void {
this.root.destroy();
}
}

View File

@ -114,6 +114,11 @@ function buildTiles(): TileKind[] {
// Dunnies get plumbing: sink row along the south interior wall. // Dunnies get plumbing: sink row along the south interior wall.
rect(66, 15, 71, 15, 'sink'); rect(66, 15, 71, 15, 'sink');
// The kitchen: back-of-house behind the bar's west end. Glassie territory —
// the dishwasher lives here and the racks come home to it.
box(14, 1, 21, 8, 'wall');
set(18, 8, 'floor');
// Dunnies: walled room in the north-east, one doorway on its south face. // Dunnies: walled room in the north-east, one doorway on its south face.
box(60, 3, 76, 16, 'wall'); box(60, 3, 76, 16, 'wall');
rect(62, 16, 63, 16, 'floor'); rect(62, 16, 63, 16, 'floor');
@ -178,7 +183,7 @@ export type PropKind =
| 'speaker' | 'discoball' | 'speaker' | 'discoball'
| 'barShelf' | 'taps' | 'till' | 'glassStack' | 'glassRack' | 'barShelf' | 'taps' | 'till' | 'glassStack' | 'glassRack'
| 'boothTable' | 'highTable' | 'sinkRow' | 'boothTable' | 'highTable' | 'sinkRow'
| 'buttBin' | 'crate' | 'plant' | 'wetFloor' | 'poolTable' | 'buttBin' | 'crate' | 'plant' | 'wetFloor' | 'poolTable' | 'dishwasher'
| 'poster1' | 'poster2' | 'poster3' | 'cloak' | 'stampPodium' | 'arcade'; | 'poster1' | 'poster2' | 'poster3' | 'cloak' | 'stampPodium' | 'arcade';
export interface PropEmissive { export interface PropEmissive {
@ -229,9 +234,10 @@ export const PROPS: readonly PropDef[] = Object.freeze([
P('glassStack', 'glassStack', 24, 4, 2, 1), P('glassStack', 'glassStack', 24, 4, 2, 1),
P('taps', 'taps', 32, 4, 3, 1), P('taps', 'taps', 32, 4, 3, 1),
P('till', 'till', 46, 4, 2, 1), P('till', 'till', 46, 4, 2, 1),
// glassie station (future role home — design §6) // the kitchen (glassie territory — design §6)
P('rackA', 'glassRack', 16, 4, 2, 1), P('rackA', 'glassRack', 16, 4, 2, 1),
P('rackB', 'glassRack', 16, 6, 2, 1), P('rackB', 'glassRack', 16, 6, 2, 1),
P('dish', 'dishwasher', 15, 2, 2, 1, { colour: 0x50c060, radius: 20 }),
// booths // booths
P('tblS1', 'boothTable', 25, 40, 2, 1, { colour: 0xd8a050, radius: 12 }), P('tblS1', 'boothTable', 25, 40, 2, 1, { colour: 0xd8a050, radius: 12 }),
P('tblS2', 'boothTable', 32, 40, 2, 1, { colour: 0xd8a050, radius: 12 }), P('tblS2', 'boothTable', 32, 40, 2, 1, { colour: 0xd8a050, radius: 12 }),

View File

@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { BREAK_AT, RACK_GLASSES, freshRack, stepRack, turnSharpness } from '../../src/scenes/floor/glassie';
describe('the glassie run (pure physics)', () => {
it('a smooth walk settles the rack and breaks nothing', () => {
let r = { glasses: RACK_GLASSES, wobble: 0.6 };
let broke = 0;
for (let i = 0; i < 120; i++) {
const out = stepRack(r, 16, false, 0);
r = out.rack;
broke += out.broke;
}
expect(broke).toBe(0);
expect(r.wobble).toBe(0);
expect(r.glasses).toBe(RACK_GLASSES);
});
it('two quick crowd bumps break a glass; the wobble sheds with it', () => {
let r = freshRack();
r = stepRack(r, 16, true, 0).rack;
expect(r.glasses).toBe(RACK_GLASSES); // one bump is a scare, not a smash
const out = stepRack(r, 16, true, 0);
expect(out.broke).toBe(1);
expect(out.rack.glasses).toBe(RACK_GLASSES - 1);
expect(out.rack.wobble).toBeLessThan(BREAK_AT);
});
it('sawing the stick hard eventually costs glass even with no crowd', () => {
let r = freshRack();
let broke = 0;
for (let i = 0; i < 300; i++) {
const out = stepRack(r, 16, false, 1);
r = out.rack;
broke += out.broke;
}
expect(broke).toBeGreaterThan(0);
});
it('a rack cannot go below zero glasses', () => {
const r = { glasses: 1, wobble: 0.99 };
const out = stepRack(r, 16, true, 1);
expect(out.rack.glasses).toBe(0);
const again = stepRack(out.rack, 16, true, 1);
expect(again.rack.glasses).toBe(0);
expect(again.broke).toBe(0);
});
it('turnSharpness is 0 for steady cruise, 1 for a full reversal', () => {
expect(turnSharpness(100, 0, 100, 0, 116)).toBe(0);
expect(turnSharpness(116, 0, -116, 0, 116)).toBe(1);
expect(turnSharpness(0, 0, 60, 0, 0)).toBe(0); // junk maxSpeed is inert
});
});

View File

@ -26,6 +26,7 @@ TARGETS = {
"poster1": (32, 16), "poster2": (32, 16), "poster3": (32, 16), "poster1": (32, 16), "poster2": (32, 16), "poster3": (32, 16),
"cloak": (16, 48), "stampPodium": (16, 16), "arcade": (32, 32), "cloak": (16, 48), "stampPodium": (16, 16), "arcade": (32, 32),
"highTable": (16, 16), "plant": (16, 16), "wetFloor": (16, 16), "poolTable": (48, 32), "highTable": (16, 16), "plant": (16, 16), "wetFloor": (16, 16), "poolTable": (48, 32),
"dishwasher": (32, 16),
# the door-scene backdrop plate is special-cased: full size, no quantise # the door-scene backdrop plate is special-cased: full size, no quantise
"street:backdrop": (640, 176), "street:backdrop": (640, 176),
} }