Compare commits

..

1 Commits

Author SHA1 Message Date
type-two
6c5889c957 Door tween leak: tutorial fade spawned an 800ms tween per frame — now pure tutorialAlpha() driven from update()
The alpha>0 guard never closed (each new tween re-captured the still-
positive alpha), so DoorScene accumulated ~1 live tween per frame all
night. Fade is now tween-free elapsedMs math with tests pinning that it
reaches exactly 0 and stays there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:43:08 +10:00
45 changed files with 73 additions and 3534 deletions

1
.gitignore vendored
View File

@ -1,3 +1,2 @@
node_modules
dist/
art_incoming/

View File

@ -1340,463 +1340,28 @@ kebab run full cycle with incident + toast ✓.
- SCENARIOS.md: everything except the three phase-scale systems is now ✅.
---
## SESSION — FABLE-SOLO-11 · 2026-07-20
**Branch:** main (solo session, Fable) · **Gate:** lint ✓ build ✓ test ✓ (674 tests, 41 files)
**Deployed:** https://monsterrobot.games/not-tonight/ (rsync, curl 200)
### The DJ shift (design §6 role ladder — first playable cut)
`scenes/floor/djShift.ts` (pure, 9 tests) + FloorDemoScene wiring + strings.
- **Enter/exit**: E at the booth (`DECKS_SPOT` = tile 55,22). Movement locks;
B/SPACE go live; every other key is eaten ("the decks eat every other key").
E steps off; an armed build fizzles on handback.
- **BUILD (B)**: `getActiveEngine()?.setVoiceEnabled('bass', false)` — the
actual bass leaves the actual mix — hype +0.1. Held past 4 bars it fizzles
(vibe 2, bass returns unannounced).
- **DROP (SPACE)**: judged against the LIVE bar line. Scene tracks
`lastBeat {index, atMs}` off `beat:tick` (both modes) + `beatMs` from the
night's beatIntervalMs; `msToNearestBar(beatInBar, msSinceBeat, beatMs)`
`judgeDrop`: ≤140ms clean (vibe +4, hype +0.4, crowd-ooh sfx), ≤320ms late
(+1), else clang (vibe 2, aggro +1). Early and late are the same offence.
- **Requests**: while on the decks, stream `'djRequests'` (~1 per 80s) opens
ChoiceOverlay with a real dancer's doll: PLAY IT (per-request hit odds
0.40.75, `REQUEST_HIT`; hit vibe +3/hype +0.2, flop vibe 3), NAH (aggro
+1), AFTER THIS ONE (40% deferred aggro +2 in 820 min — "they remembered").
Six requests: dnb, nan, birthday, shazam, slower, the DUN DUN one. A request
needs a live body on the floor — empty room sends nobody to the glass.
- Incident kinds `djShift`/`djDrop`/`djRequest` feed the report system.
### Verified in the pane (frame-stepped)
Enter prompt + movement lock + help line ✓ · build arms, bass voice off ✓ ·
drop AT bar boundary → clean, vibe +5.5 hype-scaled, incident ✓ · mid-bar drop
→ clang ✓ · 4-bar fizzle ✓ · rigged-stream request → overlay with asker doll,
PLAY IT → "It landed." incident ✓ · organic request appeared during screenshot
✓ · step-off clears an armed build ✓.
### Notes
- Tween-leak hunt (DoorScene, ~1.1k live 800ms tweens) is running in a separate
session — this session deliberately avoided DoorScene.
- Shelf remaining: ROOM boss week, bartender shift.
## SESSION — FABLE-SOLO-12 · 2026-07-21
**Branch:** main (solo session, Fable, on ultra — took over from JING5) ·
**Gate:** lint ✓ build ✓ test ✓ (701 tests, 45 files)
**Deployed:** https://monsterrobot.games/not-tonight/ (rsync, curl 200)
### 1. Tween-leak fix landed on main
`claude/epic-faraday-332679` (pure `tutorialAlpha()` + tests) was finished but
never merged. Ported onto main as 41e93d0 (merge itself was permission-blocked;
same diff, file checkout + commit).
### 2. The bartender shift (docs/SCENARIOS.md, design §6 role ladder)
`scenes/floor/barShift.ts` (pure, tested) + FloorDemoScene wiring + strings.
- **Enter/exit**: E at the till front (tile 47,6) teleports you behind the taps
(47,3), movement locks, E steps off. Blocked with a rack or cup in hand.
- **Orders**: stream `'barOrders'` (~1 per 18s) picks an `atBar` agent (60s
per-patron cooldown); ChoiceOverlay shows the asker's doll + a per-stage
order line (`BAR_ORDER_LINES` — the spelling degrades with the stage; the
order IS the sobriety read).
- **`judgeCall(call, stage)`** prices everything (§4.3): SERVE = vibe +1 and a
real +0.06 pour, breach at messy/maggot (the vibe still pays — the bribe is
immediate, the price arrives later); WATER = watered() 0.07, vibe +1 on
loose+, aggro +1 snub on sober/tipsy; CUT OFF = sendsHome either way, right
call vibe +1 aggro +1, early call vibe 1 aggro +2.
- **RSA write-up**: breach pours have a 35% seen-chance → ONE deferred heat
strike (stable reason `'served alcohol to a visibly intoxicated patron'`,
shouldRecordStrike dedupes a whole bad shift), 626 clock-min later, with a
Dazza heads-up text.
- `CrowdSim.sendHome()` is new public API (cut-off patrons walk themselves out).
- Incidents: `barShift` / `barServe`.
### 3. ROOM boss week (design §6, venue 4 — first cut)
- **`data/venues.ts`**: ROOM entry (60 licensed, 1.2 arrivals, inspector,
`ruleLimit: 0`, crowd weights) + two new VenueDef flags:
`shuffleDressCode`, `lipRead`. Promotion after Elevate is automatic
(venueByIndex/TOTAL_VENUES were already generic).
- **Dress-code shuffle** (`rules/dressCode.ts`): module-level mode set by
NightScene each night (`setDressCodeShuffle(seed|null)`) — judge(), Kayden,
the card and the sim all read the same set with zero signature churn.
`SHUFFLE_EVERY_MIN = 15`, `SHUFFLE_RULE_COUNT = 3`, FisherYates over the
full rulebook per (seed, window), stable within a window. DoorScene rebuilds
the card on the window turn (`DressCodeCard.setAll`) + toast + buzz; the
board opens already written (no announcements, `ruleLimit 0` keeps Dazza's
rule texts out while his other pins still fire).
- **Lip-read dialogue** (`rules/lipRead.ts`, pure): every rope line renders as
bracketed uppercase guesses — FNV-1a per (patron, word index), so re-asking
never rerolls; 12% lost `[???]`, competing two-guess mishearings from a
club-nonsense pool (`[FIST? LIST?]`), quotes dropped (it's your reading, not
their words). Wired via `PatronUpView.lipRead` (both speak() and scripted
sayLater() beats).
### Verified in the pane (frame-stepped)
Bartender: taps target/enter/lock/exit ✓ · per-stage order line + doll in
overlay ✓ · serve breach vibe+1 intox+0.06 + incident ✓ · water snub aggro+1 ✓
· cutoff → headingHome ✓ · deferred RSA strike scheduled with dazza text ✓ ·
screenshot. ROOM (save warped to venueIndex 3): boots venue 'room', board
pre-written (3 rules), window turn at clock 15 rewrote card + toast ✓ ·
lip-read bubble `[EVENING] [???]` ✓ · screenshot.
NOTE for pane verification: dispatched KeyboardEvents pile up in Phaser's queue
while rAF is frozen and REPLAY EN MASSE on the next dispatch — drive overlay
verbs with direct `handleKey()` calls, never synthetic keydowns.
### Notes
- Dress-code shuffle is module-level state (set per night). Any future scene
that judges dress outside a night must call `setDressCodeShuffle(null)` or
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 2570s 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).
## SESSION — FABLE-SOLO-14 · 2026-07-21
**Branch:** main (solo, Fable, ultra) · **Gate:** lint ✓ build ✓ test ✓ (718 tests, 45 files)
**Deployed:** https://monsterrobot.games/not-tonight/ (rsync, curl 200)
### The red carpet (John's ask: "make ppl wait ages")
QueueManager owns it (pure, 7 new tests); DoorScene renders it.
- **CARPET button** beside UNHOOK ROPE (rope shrank 192→130px). Sends the
patron at the rope to a 3-slot red strip between rope and door (brass posts
+ velvet drawn in buildStreet; dolls clickable, impatience-sway ramps).
- **Economy** (`CARPET_TUNING`): 0.02 hype/s per body on display, ADDITIVE to
ordinary queue theatre (freeing the rope re-arms the per-wait hype cap — the
carpet is a hype engine, that's the point). Patience 3055s × archetype
scale (influencer 1.8 … regular 0.5); past it they STORM (aggro +3, toast,
`carpetStorm` incident, drained via `takeStorms()` — no new bus event names,
EventMap is frozen contract).
- **Recall**: click the doll (rope must be free) → `recallFromCarpet` puts
them up through the shared `presentUp()` path (extracted from callNext — a
recalled patron gets passback checks, Franko tips, tray, the lot).
- **The entrance**: ADMIT off the carpet pays vibe +2 + hype 0.012/s stood
(cap 0.6), `carpetEntrance` incident. Deny pays nothing extra — you made
them wait for THAT.
- Verified in pane: park → rope keeps working → recall(4s) → admit paid 0.048
hype + incident ✓ · storm at 25s with toast/incident ✓ · screenshots.
### Notes
- Kayden never uses the carpet (he doesn't know what it's for). Bodies keep
ticking/storming while the player is inside — QueueManager.update runs on.
- Art audit for the next MODELBEAST batch (placeholder rects live for):
`monitor`, `boothLamp`, `dishwasher`, `poolTable`, `wetFloor`, `highTable`,
plus new candidates: red-carpet strip + brass bollards (street), the pour
glass, the card jar. Recipe in the Phase-3 deploy note.
## SESSION — FABLE-SOLO-15 · 2026-07-21
**Branch:** main (solo, Fable, ultra) · **Gate:** lint ✓ build ✓ test ✓ (718 tests)
**Deployed:** https://monsterrobot.games/not-tonight/ (rsync, curl 200)
### MODELBEAST prop batch #2 (the SOLO-14 art audit, cleared)
8 flux_local jobs on the farm (flux2-klein-4b steps 4, seeds 42304237,
SPACES.md prompt register), imported via tools/props_import.py → 28 generated
props now in the manifest: `monitor`, `boothLamp`, `dishwasher`, `poolTable`,
`wetFloor`, `highTable`, `plant`, and **`carpet`** — a front-on red-carpet
plate (128×32, new TARGETS entry) that DoorScene now prefers over the
rect-drawn strip (`gen:prop:carpet` texture check, rects stay as fallback).
Batch script kept at scratchpad `mb_props_batch.py` pattern; raws in
art_incoming/ (now gitignored — repo carries only the game-ready sprites).
Verified in pane: all 8 `gen:prop:*` textures load, carpet plate renders at
the door, pool corner dressed on the floor. Screenshots taken.
### Shelf
Remaining §6: Head of Security finale arc, roster board. Ambition-tier patron
sprite sheets (ASSETS.md §2) still wants its own session.
## SESSION — FABLE-SOLO-16 · 2026-07-21
**Branch:** main (solo, Fable, ultra) · **Gate:** lint ✓ build ✓ test ✓ (723 tests, 47 files)
**Deployed:** https://monsterrobot.games/not-tonight/ (rsync, curl 200)
### Radio command — the Head of Security finale verb (design §6, first cut)
"You used to speak into the radio; now you're the voice on the other end."
Elevate + ROOM only (`VenueDef.radioCommand`).
- **`rules/staff.ts`** (pure, 5 tests): cooldowns, 37s order lag (staff cross
a real floor), competence odds — Bump mops-and-signs 65% (else signs it and
finds a phone in the puddle), Shan waters clean 80% (else leads with
"YOU'VE HAD ENOUGH", aggro +2). Kayden holds the rope 60s, 90s cooldown.
- **Cross-scene wiring**: mutable registration fields on NightContext
(`floorOrder`, `kaydenHold`) — NOT bus events; EventMap is frozen contract.
Floor registers its executor at bindNight; Door registers Kayden's hold.
Completion lines travel back via a `notify` callback, so the toast lands in
whichever scene gave the order.
- **Verbs**: door keys 1/2 (Bump: sort the oldest puddle · Shan: water the
worst upright drunk ≥ loose); floor keys 1/2/3 (same + Kayden rope-hold —
he does nothing, magnificently; his ruling loop pauses). Hint lines on both
scenes; all outcomes are `radioOrder` incidents.
- Player racing the order is handled: if the puddle/drunk is gone when the
lag lands, the order dissolves silently.
### Verified in pane (Elevate save, frame-stepped, direct calls)
Acks/cooldown toasts ✓ · Bump mopped after lag (puddle gone, incident, door
toast) ✓ · Shan watered 0.9→0.76 ✓ · Kayden 0 rulings during a 10s hold ✓ ·
hint renders at the door ✓ · screenshot.
### Shelf
§6 remaining: roster board (role × venue shift picker — every role verb now
exists), patron sprite sheets (own session).
## SESSION — FABLE-SOLO-17 · 2026-07-21
**Branch:** main (solo, Fable, ultra) · **Gate:** lint ✓ build ✓ test ✓ (729 tests, 49 files)
**Deployed:** https://monsterrobot.games/not-tonight/ (rsync, curl 200)
### The roster board (design §6 — role × venue = level select, first cut)
- **`data/roster.ts`** (pure, 6 tests): six RoleDefs — glassie $120 / bartender
$160 / doorgirl $180 / bouncer $190 / dj $220 / hos $260 — each pinned to
venues per the ladder (Royal: glassie/bar/door · Voltage: door/bouncer ·
Elevate: hos · ROOM: dj/hos). A role = startLoc (door|floor) + optional
post (taps|decks) + wage + blurb. `defaultRoleFor(venue)` covers dev routes.
- **`RosterScene`** ('Roster', now scene[0]/boot): corkboard, pinned tilted
cards, wage + blurb, click → `Night {role}`. Shows venue/night/seed/week
from the save (display only — NightScene stays the authority).
- **NightScene**: init takes `role`; `ctx.role`; opening `applyLocation`
honours `role.startLoc`; log carries role name + wage.
- **FloorDemoScene**: `ctx.role.post` walks you straight onto the taps/decks
600ms after the world is up (the apron/decks handover toast opens the night).
- **NightSummary**: subtitle shows `shift: bartender $160`; `nightCash()` now
pays the ROSTER wage (was hardcoded 180) + the existing hype/admit/strike
maths. All five next-night seams route through the Roster, so every retry,
promotion and fresh run passes the board.
- **main.ts**: Roster boots; hash-route guard waits on Roster; `#night`/N
still skips the board (default role = door/hos by venue).
### Verified in pane
Fresh boot → Royal board (3 cards, wages, blurbs, week 1/4) ✓ · click
BARTENDER → night opens on the floor, barMode auto-entered at the taps
(player 760,56), log role/wage BARTENDER/$160 ✓ · screenshots. NOTE: the
Door/Floor launch waits on the prop-image loader — in the frozen pane you
must yield between exec calls before the child scenes exist.
### Design-§6 status: COMPLETE (first cuts)
Role ladder ✓ roster board ✓ venues 14 ✓ radio command ✓ DJ/bartender/
glassie verbs ✓. Remaining ambition: per-role restricted verb-sets (glassie
zero-authority), story beats on roles, patron sprite sheets (own session).
## SESSION — FABLE-SOLO-18 · 2026-07-21
**Branch:** main (solo, Fable, ultra) · **Gate:** lint ✓ build ✓ test ✓ (734 tests, 51 files)
**Deployed:** https://monsterrobot.games/not-tonight/ (rsync, curl 200)
### The floor score (John's ask: lights on at close, find what dropped)
- **`rules/floorScore.ts`** (pure, 5 tests): `rollDrops(stream, admitted)`
6 + admits/6 drops (cap 14): notes in anxious denominations ($5$50), booth
shrapnel, 23 worthless curios with load-bearing labels ("an untouched
kebab. UNTOUCHED."), ≤1 baggie (35%), ≤1 phone (40%).
- **Sweep mode** (FloorDemoScene): NightScene's `endNight('clock')` now routes
through `ctx.beginSweep` before the summary — lights-on wash over the whole
map, crowd cleared, drops scattered on walkable tiles (stream `'sweep'`,
pulsing glints — green for the baggie). WASD + E collects; prompt names the
nearest find; the baggie freezes your feet for a B-bin / SPACE-pocket call
(`baggieBinned`/`baggiePocketed` incidents — report bait, the night's over
so the ledger is the only place it can bite); the phone goes to lost
property. E at the entry walks out; licence-pull and riot ends skip the
sweep entirely (nobody's sweeping).
- **Pay**: `NightLog.floorCash` → summary row "floor score $N" (gold) and
`nightCash()` adds it AFTER the zero-floor clamp — a catastrophic night
still pays out the floor. Sweep incidents land in the report form.
### Verified in pane
endNight('clock') → sweep: 6 drops (baggie/2 curios/2 notes/coins) ✓ ·
collected $19, baggie pocketed with incident ✓ · walk-out at the entry anchor
→ NightSummary with floor score $19 + PAY includes it ✓ · screenshots.
### Note
`entry` anchor is (72,360) — tile (4,22)-ish, NOT the exit tiles at x=1; the
sweep walk-out and the night-mode exit both key off `VENUE.anchors.entry[0]`.
---
## SESSION — FABLE-SOLO-12 · 2026-07-22
**Branch:** main (solo, Fable) · **Gate:** lint ✓ build ✓ test ✓ (735 tests, 49 files)
**Deployed:** https://monsterrobot.games/not-tonight/ (curl 200)
This session ran ALONGSIDE the parallel session that landed the roster wave
(41e93d0..d4f8024). No feature work from me — I soak-tested, found three real
defects, fixed them, and re-verified the merged tree.
### Fixed 1 — the dev route killed the door (main.ts, NightScene)
`showScene()` stopped `'Door'` (it was in the stop list) and then declined to
restart the already-active `'Night'`. Pressing **N** mid-night therefore left a
live Night driving a SHUTDOWN Door: an unplayable black screen with no way back.
`'Floor'` was not in the list at all, so it survived — the asymmetry is what made
it look like a launch race. Now only ROOT scenes route (`Roster/Night/Parade/
FloorDemo/JuiceDemo`), and re-pressing a route key restarts the target, so N
means "fresh night". `NightScene.teardown()` now stops Door and Floor, making
real the ownership main.ts always claimed in a comment.
### Fixed 2 — every floor-start shift booted INERT (NightScene)
`applyLocation()` moves flags but emits nothing; the initial call is
`applyLocation(role.startLoc)`. FloorDemoScene latches `present` when it binds
the night context — and now that the night preloads all 28 prop textures, the
floor's own loader finds nothing missing and binds SYNCHRONOUSLY, i.e. BEFORE
that delayedCall(0) runs. So `present` latched `false` for bartender, glassie
and DJ: floor visible, camera live, and every interaction dead. The audio filter
also stayed in door mode while you stood on the dancefloor. The night now emits
`night:phaseChange` + `audio:location` for the starting post. Verified all four
roles: bartender→taps, glassie→floor, dj→decks, door→door.
### Fixed 3 — the floor carried last night into this one (FloorDemoScene)
Phaser reuses scene INSTANCES across restarts, so class-field initialisers run
once per GAME. A DJ shift left `djMode` set and the next night booted with
movement locked and the keyboard eaten; last night's `puddles` kept slipping
people through sprites Phaser had destroyed; `spotted`/`cutOffDealt`/`jointDealt`
marked fresh arrivals as already handled (patron ids restart with the run).
`resetNightState()` in `create()` now wipes all of it. NOTE: the roster wave's
own bar state (tabs/tips/orders/staffReadyAt) was already reset correctly in
bindNight — only the older floor state leaked.
### Soak (frame-stepped, ~1.5h game time, zero errors)
Door object count flat (~70), floor objects track crowd, vibe oscillates 50→67→54
(live pressure, not pinned). Night ended legitimately on AGGRO 97.8 after I
abandoned the rope all night and Kayden waved through 13 people including a
17-year-old — the fail narration is correct.
### Verified of the roster wave
Roster board renders and routes; BARTENDER shift starts on the floor at the taps;
order → SERVE → pour meter → vibe +4.2 → `barServe` incident. Old saves still
load (GameState shape unchanged, `loadGame` validates `v===1` and falls back).
### Harness note for future sessions
`window.game.loop.step()` does NOT advance real asset loading. A night that
preloads 28 textures needs real wall-clock time before Door/Floor launch — step,
then `await sleep(~900ms)`, then step again. Two "critical bugs" this session
were my own harness outrunning the loader; check `scene._queue` and
`load.totalToLoad` before believing a scene is stuck.
### Fixed 4 (P0, found after the block above) — the taps softlock
`toggleTaps()`'s exit branch teleported the player to `TAPS_FRONT`, tile (47,6),
which sits inside venueMap's `stool` rect — and `stool` is not in `WALKABLE`.
Reproduced in the live build: after handing the taps back the player sat at
(760,104) and W/A/S/D each moved them **0px** for the rest of the night. The
`bartender` roster role mounts the taps automatically, so a single keypress
ended the run with 13 real minutes left to watch.
Exit no longer teleports: you stay at `TAPS_SPOT` behind the bar and walk out
around its east end (verified walkable end-to-end: staff row → east → south →
back west in front of the bar). `TAPS_FRONT` moves to row 7 so the approach
probe is on the punter side instead of inside the furniture. Re-entry prompt
re-verified from the new spot.
Credit where due: this one was surfaced by a design-panel agent that probed
`stepPlayer` against the real `VENUE` in all four directions rather than reading
the code — worth copying as a technique for any "is this position legal" claim.
---
## SESSION — FABLE-SOLO-13 · 2026-07-22 (review sweep)
**Gate:** lint ✓ build ✓ test ✓ (741 tests, 49 files) · **Deployed + pushed.**
Three adversarial review workflows (6 finder dimensions each, every finding put
to three independent skeptics; 46 findings confirmed of ~55 raised). What I
actioned, beyond the four fixes already logged in FABLE-SOLO-12:
### The softlock class — now closed by construction
The DJ post had the SAME defect as the taps: `DECKS_SPOT` (55,22) sits inside
the sealed `djbooth`, which is not in `WALKABLE`. Taking the DJ shift and
stepping off the decks froze the player permanently — and because the closing
sweep requires walking to the entry anchor, **the night could never be paid**.
Moved to the booth's west counter (51,22), which is where the map comment always
said the gear faces from.
Then made the class untestable-by-accident: role stations are now DATA in
venueMap — `POSTS` (tiles a role stands you ON: must be walkable AND escapable)
vs `PROBES` (anchors you walk UP TO: may sit in furniture, must have somewhere
walkable within `INTERACT_RANGE`). `tests/floor/stations.test.ts` pins both, and
deliberately asserts on MOVEMENT (run the real `stepPlayer` against the real
`VENUE` for a second, in each direction) rather than tile names — the question is
never "what tile is this", it is "can the player leave". Writing it immediately
found a third instance (the water station probe sits on a `sink`), which the
POSTS/PROBES split correctly classifies as fine.
### The dead door
`DoorScene.busy` is released by a `delayedCall` on the SCENE clock, and stopping
a scene discards its pending events. Any ruling inside the last 2.6s of a night —
including the one that ends it — left `busy` stuck true: no rope, no verdicts, no
doorway, no carpet, for the rest of the run. Now reset per night alongside
`seenCards` (the passback net, which had been flagging Friday's returning
regulars as passbacks), `retiredRules`, `frankoTips` and the kebab flags.
### The bar punished the right call
Bar WATER and CUT OFF never settled the patron the way the water station does, so
watering a maggot from behind the taps left them ticking as unhandled and
re-logging every grace period. Also, every bar verb logged as `barServe` — a
cut-off went into the record as a drink that was never poured, which matters in a
game whose report system is about what you SAY happened.
### §4.3 inversion: the booth shoe was fake money
`djTips` accumulated into a toast and an incident and nothing else — it never
reached the pay line. Taking cash therefore had zero upside and kept its 25%
"word got around" vibe sting: the corrupt option was strictly dominated, which
breaks §4.3 exactly as badly as making it optimal. The shoe is real now:
`NightContext.bankCash(amount, source)``log.pocketCash`, its own summary row
("in your pocket"), added AFTER the strike deduction so a wiped wage cannot
reach into your pocket for money a punter physically handed you. Pinned by two
tests in nightSummary.test.ts.
Also: `carpetMsFor()` is consumed on read (a regular parked, recalled and knocked
back was paid the entrance dividend a second time on a later ordinary visit), and
sweep state joined `resetNightState()`.
### Still open from the reviews (triaged, NOT actioned)
- **Red carpet is an uncapped hype faucet** (HIGH, balance): parking bodies pays
the maximum bonus for ~33s of doing nothing. Wants a design call, not a patch —
it is a tuning question about what queue theatre should be worth.
- `enterSweep` leaves bar overlays open (the floor score swallows keys).
- Parking a scripted patron on the carpet leaks their dialogue beats onto the
next patron at the rope.
- The sweep is untimed while the door keeps running, so dawdling changes pay.
- `applyBarCall` does not re-check the asker is still in the building.
### SESSION — FABLE-SOLO-11 (tween-leak chip) — 2026-07-20
**Branch/commits:** claude/epic-faraday-332679 @ 1b08420..(this commit)
**Done:** Killed the DoorScene tween leak flagged in FABLE-SOLO-10. Root cause:
the tutorial fade at the bottom of `update()` was `if (tutorial.alpha > 0)
tweens.add({ alpha: 0, duration: 800 })` — every new tween re-captured the
still-positive alpha and wrote over the older tweens' output, so the guard
never closed and the scene added one live 800ms tween PER FRAME after the 26s
mark (reproduced in the pane: 841 live tweens after 2,400 stepped frames, all
duration 800, oldest only 703ms advanced — tweens also stall under the pane's
stepped clock, so none ever completed there). Fix: no tween at all — fade is
now pure `tutorialAlpha(elapsedMs)` in `src/scenes/door/tutorialFade.ts`,
driven from update() (same lesson as the kebab-run timer), text setVisible(false)
once it hits 0. Re-ran the same 2,400-frame reproduction post-fix: 0 live
tweens, tutorial alpha 0 / hidden, door renders normally.
**How to see it:** `npm run dev`#N → in console
`const b=performance.now(); for(let i=1;i<=2400;i++) game.loop.step(b+i*16.67)`
`game.scene.getScene('Door').tweens.getTweens().length` → 0 (was ~841).
**Tests:** 669 passing / 0 failing (4 new in tests/tutorialFade.test.ts — pins
that the fade reaches exactly 0, stays 0 a whole night later, and is monotonic).
**Broke / known-wonky:** nothing. No scene-shutdown teardown added — the leak
was update()-loop spawning, not shutdown; Phaser already kills scene tweens on
shutdown.
**Contract change requests:** none
**Questions for reviewer:** none
**Next session should:** merge this branch; nothing else pending from the chip.

View File

@ -70,22 +70,10 @@ Status: ✅ implemented · 🔜 next nibbles · 💡 backlog.*
## Systems worth a whole phase
- ✅ **Venue 4 / ROOM boss week** (2026-07-21, first cut): fourth rung on the
ladder (promotes from Elevate). No Dazza rule chronology — the board IS the
law and re-randomises every 15 in-game minutes (3 rules per window, seeded,
with a toast on the turn); every rope line renders as bracketed lip-read
guesses (`[FIST? LIST?]`) because the techno ate the dialogue. 60 licensed,
1.2 arrival scale, inspector working, crowd of heads and VIP-claimers.
- ✅ **The DJ shift** (2026-07-20, first cut): E at the booth hands you the decks
while the resident steps out. B pulls the bass (the room leans in); SPACE
drops, judged against the LIVE bar line (clean/late/clang — early and late
are the same offence); requests arrive at the booth glass with per-request
odds ("got any dnb", nan's song, the DUN DUN one) and three answers that all
cost something. A build held too long runs out of stairs. Movement locks
while you hold them; the floor and the door keep happening without you.
- ✅ **Bartender shift** (2026-07-21, first cut): E at the till hands you the
taps (movement locks; the floor keeps happening). Orders arrive as choices —
the order line degrades with the patron's stage (the spelling IS the sobriety
test) — SERVE / WATER / CUT OFF all legal, all priced. Serving messy/maggot
is the RSA breach: the vibe pays up front, and 35% of the time somebody with
a lanyard saw it (ONE deferred write-up per night, deduped by reason).
- 💡 **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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 424 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 552 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 B

View File

@ -2,22 +2,15 @@
"props": [
"arcade",
"barShelf",
"boothLamp",
"boothTable",
"buttBin",
"carpet",
"cloak",
"crate",
"deck",
"discoball",
"dishwasher",
"glassRack",
"glassStack",
"highTable",
"mixer",
"monitor",
"plant",
"poolTable",
"poster1",
"poster2",
"poster3",
@ -26,7 +19,6 @@
"stampPodium",
"street_backdrop",
"taps",
"till",
"wetFloor"
"till"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 351 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 396 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 387 B

View File

@ -1,89 +0,0 @@
// The roster board (design §6): role × venue = level select. A role is a lens
// + verb-set over the ONE simulation — picking a shift picks where you stand
// when the doors open, not which game you're playing. Wages are what the
// board says they are; the tips are your business.
import type { VenueDef } from './venues';
export type RoleId = 'glassie' | 'bartender' | 'doorgirl' | 'bouncer' | 'dj' | 'hos';
export interface RoleDef {
id: RoleId;
name: string;
/** What the board admits the shift pays. */
wage: number;
/** The board's own words. */
blurb: string;
/** Where you stand when the doors open. */
startLoc: 'door' | 'floor';
/** Walk straight onto a post: the taps or the decks. */
post?: 'taps' | 'decks';
/** Venue ids where this shift is on the board (venue ladder, design §6). */
venues: readonly string[];
}
export const ROLES: readonly RoleDef[] = [
{
id: 'glassie',
name: 'GLASSIE',
wage: 120,
blurb: 'zero authority. all glasses. you will SEE things',
startLoc: 'floor',
venues: ['theRoyal'],
},
{
id: 'bartender',
name: 'BARTENDER',
wage: 160,
blurb: 'the taps, the tabs, the RSA line. nobody cooked gets a pour',
startLoc: 'floor',
post: 'taps',
venues: ['theRoyal', 'voltage'],
},
{
id: 'doorgirl',
name: 'DOOR',
wage: 180,
blurb: 'the rope, the IDs, the whole street watching you decide',
startLoc: 'door',
venues: ['theRoyal', 'voltage'],
},
{
id: 'bouncer',
name: 'BOUNCER',
wage: 190,
blurb: 'the floor, the stalls, the yard. walk it like you mean it',
startLoc: 'floor',
venues: ['voltage'],
},
{
id: 'dj',
name: 'DJ',
wage: 220,
blurb: 'hold the decks. the room is a meter and youre the needle',
startLoc: 'floor',
post: 'decks',
venues: ['room'],
},
{
id: 'hos',
name: 'HEAD OF SECURITY',
wage: 260,
blurb: 'door, floor, radio. everything, at once, all night',
startLoc: 'door',
venues: ['elevate', 'room'],
},
];
export function rolesFor(venueId: string): RoleDef[] {
return ROLES.filter((r) => r.venues.includes(venueId));
}
export function roleById(id: string | undefined): RoleDef | undefined {
return ROLES.find((r) => r.id === id);
}
/** Booting a night without the board (dev routes): the sensible default. */
export function defaultRoleFor(venue: VenueDef): RoleDef {
return rolesFor(venue.id).find((r) => r.startLoc === 'door') ?? ROLES.find((r) => r.id === 'hos')!;
}

View File

@ -16,16 +16,6 @@ export const DOOR_UI = {
idTrayEmpty: 'no ID handed over',
dressCode: "DAZZA'S RULES",
dressCodeEmpty: 'no rules yet.\nuse ur judgement\n(dont)',
carpet: 'CARPET',
} as const;
/** The red carpet: a velvet holding pen for making the right people wait. */
export const CARPET_UI = {
sent: 'they take position on the carpet like it was always the plan.',
full: 'the carpet is at capacity. it is a small carpet.',
storm: 'someone on the carpet is done being furniture. they leave, loudly, mid-pose.',
entrance: 'an ENTRANCE. the room parts. worth every minute of the wait.',
recallBusy: 'finish with the one at the rope first.',
} as const;
/** Shown once, first thirty seconds, then never again. A stranger must be able to play. */
@ -168,14 +158,6 @@ export const ripenLine = (stage: string, roll: number): string => {
* Dazza rescinding a rule when a newer one pushes it off the card
* (rules cap at ACTIVE_RULE_CAP his attention span, not a settings screen).
*/
/** ROOM (design §6): the board rewrote itself again. Indexed by window. */
export const BOARD_SHUFFLE_TOASTS: readonly string[] = [
'the board flickers. new code. no explanation.',
'the board changed while you were reading it.',
'new code on the board. the board does not negotiate.',
'the board again. someone back there is just typing.',
];
export const RULE_RETRACTIONS: readonly string[] = [
'dazza: forget the "{rule}" thing. new priorities',
'dazza: "{rule}" is over. keep up',

View File

@ -128,37 +128,6 @@ export const HELP_TEXT =
'WASD move · TAB uv · E interact · SPACE bang · ESC cancel · R reseed';
/** The floor score: lights on at close, the floor pays out. */
export const SWEEP_UI = {
open: 'LIGHTS ON. the floor will now tell you what kind of night it was.',
pickup: 'E — pick it up',
leave: 'E — call it a night',
lastOne: "that's the lot. the door is that way.",
baggiePrompt: 'zip-lock bag: B bin it · SPACE pocket it',
baggieBinned: 'into the amnesty bin. the bin does not thank you. bins never do.',
baggiePocketed: 'it goes in your pocket. for the report. obviously. for the report.',
phoneFound: 'straight into lost property. MUM DO NOT ANSWER can rest.',
/** {cash} replaced at walk-out. */
done: 'floor swept. ${cash} better off. the room, finally, is just a room.',
} as const;
/** Radio command (design §6): the staff on the other end of the walkie. */
export const STAFF_RADIO = {
hint: 'RADIO — 1 bump (mess) · 2 shan (bar) · 3 kayden (rope)',
hintDoor: 'RADIO — 1 bump (mess) · 2 shan (bar)',
bumpAck: '*click* yeah boss. seen it. on it. *click*',
bumpDone: 'bump mopped it and signed it. in that order, worryingly.',
bumpDistracted: 'bump signed it, then found a phone in the puddle. investigation ongoing.',
bumpNothing: '*click* nothing to sort boss. floor is immaculate. u ok? *click*',
shanAck: '*click* shan says say less *click*',
shanDone: 'shan got a water into them without a single negotiation. artist.',
shanBotch: 'shan led with "YOUVE HAD ENOUGH." they had not had enough. now everyone is upset.',
shanNothing: '*click* everyones upright boss. genuinely no idea how *click*',
cooldown: 'the radio crackles back: mid-task. give it a minute.',
kaydenAck: '*click* kayden copies. rope shut. kayden salutes. nobody can see him saluting. *click*',
kaydenCooldown: 'kayden is still holding the rope from last time. white-knuckled. honoured.',
} as const;
/** The smoking-yard joint bust (docs/SCENARIOS.md). Three verbs, none clean. */
export const JOINT_UI = {
title: 'SOMETHING SMELLS HERBAL',
@ -227,194 +196,3 @@ export const AID_UI = {
ok: 'They come around on their side. Walked out the back to a taxi. The room starts again, quieter.',
ambulance: 'The ambulance takes them out the back. The room starts again, but not really.',
} as const;
/** The DJ shift (docs/SCENARIOS.md, design §6). Holding the decks, briefly. */
export const DJ_UI = {
enter: 'E — hold the decks for a minute',
exit: 'E — step off the decks',
handover: 'the resident points at the crossfader, then at you, then leaves at pace toward the toilets.',
handback: 'the resident returns smelling of the yard. "good hands." highest honour.',
help: 'B build \u00b7 SPACE drop \u00b7 E step off',
buildStart: 'the bass ducks out. the room leans in.',
fizzle: 'the riser runs out of stairs. the bass slinks back in unannounced.',
} as const;
export const DJ_DROP_RESULTS = {
clean: 'ON the bar. the floor detonates. somebody loses a shoe and does not care.',
late: 'near enough. the floor forgives. the floor wanted to forgive.',
clang: 'clanged it. a bartender winces audibly over the PA somehow.',
} as const;
/** Booth requests: id must match REQUEST_HIT in scenes/floor/djShift.ts. */
export interface DjRequestScript {
id: string;
line: string;
hit: string;
flop: string;
nah: string;
}
export const DJ_REQUESTS: readonly DjRequestScript[] = [
{
id: 'dnb',
line: 'got any dnb. any at all. i can wait',
hit: 'the dnb lands. three blokes materialise from nowhere doing the washing machine.',
flop: 'the dnb clears the middle of the floor like a fire drill.',
nah: 'he nods slowly and stays exactly where he is. he can wait.',
},
{
id: 'nan',
line: 'can u play something my nan would know. shes here. shes ninety',
hit: 'nan goes OFF. the entire floor orbits her. this is her venue now.',
flop: 'nan is polite about it. the floor is not.',
nah: 'nan waves at the booth anyway. no hard feelings from nan.',
},
{
id: 'birthday',
line: 'its my birthday. this is a fact u can verify',
hit: 'the birthday track hits and the whole room sings at a stranger. he weeps.',
flop: 'the birthday track dies. he stands in the middle of it, aging.',
nah: 'he shows you his licence to prove the birthday. you have seen worse proof tonight.',
},
{
id: 'shazam',
line: '*holds phone against the booth glass* play THIS',
hit: 'phone song works. she points at you like you invented it.',
flop: 'the phone song is 34 seconds of someone talking over a boat engine.',
nah: 'she lowers the phone one centimetre at a time, maintaining eye contact.',
},
{
id: 'slower',
line: 'somethin slower?? my feet r cooked',
hit: 'the slow one lands and the couples appear. where were the couples STANDING.',
flop: 'the energy drops through the floor and into the carpark below.',
nah: 'she sits on the subwoofer to rest instead. security problem, later, probably.',
},
{
id: 'again',
line: 'play the one from before again. the DUN DUN one',
hit: 'the DUN DUN one, again. somehow BIGGER. lightning does strike twice.',
flop: 'the same song twice reads less like a victory lap and more like a cry for help.',
nah: 'he hums it to himself on the way back to the floor. dun dun. dun dun.',
},
];
/** 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;
/** 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',
playDetail: 'give the floor to them. see what it does with it',
nah: 'NAH',
nahDetail: 'the set has a plan. the plan is not this',
later: 'AFTER THIS ONE',
laterDetail: 'the diplomatic no. sometimes they remember',
laterQuiet: 'they nod and drift off satisfied. for now.',
laterGrudge: 'they came back. they remembered. they brought a mood.',
} as const;

View File

@ -22,12 +22,6 @@ export interface VenueDef {
* identity. Omitted keys keep their default weight.
*/
archetypeWeights?: Partial<Record<import('./types').Archetype, number>>;
/** Head of Security tier (design §6): radio command of NPC staff. */
radioCommand?: boolean;
/** ROOM (design §6): the dress code re-randomises every 15 in-game minutes. */
shuffleDressCode?: boolean;
/** ROOM (design §6): rope dialogue renders as lip-read guesses. */
lipRead?: boolean;
}
export const VENUES: readonly VenueDef[] = [
@ -61,29 +55,10 @@ export const VENUES: readonly VenueDef[] = [
arrivalScale: 1.1,
ruleLimit: 8,
inspector: true,
// Elevate is the Head of Security tier: the radio starts taking orders.
radioCommand: true,
// Rooftop VIP politics: every second person is on a list, knows the owner,
// or is filming. Regulars don't come here; they can't get in either.
archetypeWeights: { influencer: 18, ownersMate: 7, financeBro: 14, punter: 38, regular: 3 },
},
{
id: 'room',
name: 'ROOM',
pitch: 'no sign. no socials. code changes when the board says so. u wont hear a word anyone says. good luck',
licensed: 60,
arrivalScale: 1.2,
// Dazza doesn't announce rules here — the board is the law and it keeps
// rewriting itself (shuffleDressCode). His other texts still land.
ruleLimit: 0,
inspector: true,
radioCommand: true,
shuffleDressCode: true,
lipRead: true,
// Underground techno: heads and chancers. Everyone claims VIP; almost
// nobody is. Regulars can't find the door.
archetypeWeights: { punter: 34, influencer: 12, ownersMate: 9, quietMessy: 8, financeBro: 10, regular: 2, fresh18: 6 },
},
];
export const TOTAL_VENUES = VENUES.length;

View File

@ -6,7 +6,6 @@ import { DoorScene } from './scenes/door/DoorScene';
import { NightSummaryScene } from './scenes/door/NightSummaryScene';
import { IncidentReportScene } from './scenes/door/IncidentReportScene';
import { FloorDemoScene } from './scenes/floor/FloorDemoScene';
import { RosterScene } from './scenes/shared/RosterScene';
import { JuiceDemoScene } from './ui/JuiceDemoScene';
import { MixDeskScene } from './ui/MixDeskScene';
@ -26,10 +25,7 @@ const game = new Phaser.Game({
},
// FloorDemoScene registers twice: 'Floor' is the night-mode instance NightScene
// launches with a NightContext; 'FloorDemo' stays the standalone demo (key F).
// RosterScene is scene[0] and auto-starts: the week begins at the board
// (design §6 — role × venue = level select). Dev route N skips it.
scene: [
RosterScene,
NightScene,
DoorScene,
NightSummaryScene,
@ -53,28 +49,19 @@ const game = new Phaser.Game({
const ROUTES = { night: 'Night', parade: 'Parade', floor: 'FloorDemo', juice: 'JuiceDemo' } as const;
type RouteKey = keyof typeof ROUTES;
// Only ROOT scenes are routed. Door, Floor and NightSummary belong to Night and
// go down with its teardown; stopping them from here used to leave a live Night
// driving a dead Door — a black screen with no way back, because the guard
// below then declined to restart the already-active Night.
const ROOT_SCENES = ['Roster', 'Night', 'Parade', 'FloorDemo', 'JuiceDemo'] as const;
const showScene = (target: (typeof ROUTES)[RouteKey]): void => {
for (const key of ROOT_SCENES) {
for (const key of ['Night', 'Door', 'NightSummary', 'Parade', 'FloorDemo', 'JuiceDemo']) {
if (key !== target && game.scene.isActive(key)) game.scene.stop(key);
}
// Pressing a route key for what is already running means "start it over".
// It is the only way to get a fresh night out of N mid-shift.
if (game.scene.isActive(target)) game.scene.stop(target);
game.scene.start(target);
if (!game.scene.isActive(target)) game.scene.start(target);
};
// RosterScene is scene[0] and auto-starts. Hash routing must wait until it is
// NightScene is scene[0] and auto-starts. Hash routing must wait until it is
// actually up before swapping, or both scenes end up running on top of each other.
const hash = window.location.hash.slice(1) as RouteKey;
if (hash in ROUTES) {
if (hash in ROUTES && ROUTES[hash] !== 'Night') {
const route = (): void => {
if (!game.scene.isActive('Roster')) return;
if (!game.scene.isActive('Night')) return;
game.events.off(Phaser.Core.Events.POST_STEP, route);
showScene(ROUTES[hash]);
};

View File

@ -6,7 +6,6 @@
// text that announced it is unplayable, and two hand-kept copies always drift.
import './doorTypes';
import { SeededRNG } from '../core/SeededRNG';
import { DAZZA_TEXTS } from '../data/strings/dazza';
import type { DressCodeRule, OutfitLayer, OutfitSlot, Patron } from '../data/types';
@ -169,42 +168,8 @@ export const DRESS_CODE_RULES: readonly DoorDressCodeRule[] = SPECS.map(build).s
*/
export const ACTIVE_RULE_CAP = 4;
// ---- ROOM shuffle (design §6) ----------------------------------------------
// The boss venue re-randomises the code every 15 in-game minutes: no Dazza
// chronology, the board IS the law. Module-level mode set by NightScene each
// night (null = normal announcements) — threading a venue through every
// violations() caller bought nothing but noise, and every consumer must agree
// on the live set anyway or the card lies.
export const SHUFFLE_EVERY_MIN = 15;
export const SHUFFLE_RULE_COUNT = 3;
let shuffleSeed: number | null = null;
export function setDressCodeShuffle(seed: number | null): void {
shuffleSeed = seed;
}
/** Which 15-minute window `clockMin` falls in — the board changes on the turn. */
export function shuffleWindow(clockMin: number): number {
return Math.floor(Math.max(0, clockMin) / SHUFFLE_EVERY_MIN);
}
function shuffledRules(clockMin: number, seed: number): DoorDressCodeRule[] {
// One deterministic deck per (seed, window): re-reading the board mid-window
// must not reroll it.
const rng = new SeededRNG(seed ^ Math.imul(shuffleWindow(clockMin) + 1, 0x9e3779b9)).stream('code');
const deck = [...DRESS_CODE_RULES];
for (let i = deck.length - 1; i > 0; i--) {
const j = rng.int(0, i);
[deck[i], deck[j]] = [deck[j]!, deck[i]!];
}
return deck.slice(0, SHUFFLE_RULE_COUNT);
}
/** Every rule Dazza has EVER texted tonight, live or rescinded. */
export function announcedRules(clockMin: number): DoorDressCodeRule[] {
if (shuffleSeed !== null) return shuffledRules(clockMin, shuffleSeed);
return DRESS_CODE_RULES.filter((r) => r.activeFrom <= clockMin);
}

View File

@ -1,68 +0,0 @@
// The floor score (John's ask, 2026-07-21): the venue closes, the lights come
// ON, and the floor tells you what kind of night it was. Money, contraband,
// and objects with no explanation. Pure rolls here; the scene owns the sweep.
import type { RngStream } from '../core/SeededRNG';
export type DropKind = 'note' | 'coins' | 'baggie' | 'phone' | 'curio';
export interface Drop {
kind: DropKind;
/** Dollars. Curios and phones are worth nothing and everything. */
value: number;
label: string;
}
/** How strewn the floor is: a base mess plus one drop per this many admits. */
export const DROPS_BASE = 6;
export const DROPS_PER_ADMITS = 6;
export const DROPS_MAX = 14;
export const NOTE_VALUES: readonly number[] = [5, 5, 10, 10, 20, 50];
/** One baggie, sometimes. The moral beat of the sweep. */
export const BAGGIE_CHANCE = 0.35;
/** Someone's whole life, at 3%, face down near the booths. */
export const PHONE_CHANCE = 0.4;
export const CURIOS: readonly string[] = [
'a single white sneaker. vintage, technically',
'one hoop earring, lying there victorious',
'an untouched kebab. UNTOUCHED.',
'a novelty lighter shaped like a slightly smaller lighter',
'seventeen bobby pins in one tight formation',
"someone's nan's reading glasses",
'a laminated business card. sovereign.',
'a wristband from a venue that shut in 2019',
];
export const BAGGIE_LABEL = 'a zip-lock bag of definitely-not-yours';
export const PHONE_LABEL = "someone's phone. 3% battery. 41 missed calls from MUM DO NOT ANSWER";
/**
* What the lights find. Deterministic per stream: mostly cash in anxious
* denominations, a couple of objects with no explanation, at most one baggie
* and one phone.
*/
export function rollDrops(rng: RngStream, admitted: number): Drop[] {
const count = Math.min(DROPS_MAX, DROPS_BASE + Math.floor(admitted / DROPS_PER_ADMITS));
const drops: Drop[] = [];
if (rng.chance(BAGGIE_CHANCE)) drops.push({ kind: 'baggie', value: 0, label: BAGGIE_LABEL });
if (rng.chance(PHONE_CHANCE)) drops.push({ kind: 'phone', value: 0, label: PHONE_LABEL });
const curios = rng.int(2, 3);
for (let i = 0; i < curios && drops.length < count; i++) {
drops.push({ kind: 'curio', value: 0, label: CURIOS[rng.int(0, CURIOS.length - 1)] ?? CURIOS[0]! });
}
while (drops.length < count) {
if (rng.chance(0.3)) {
const v = rng.int(2, 9);
drops.push({ kind: 'coins', value: v, label: `$${v} in shrapnel from under the booth seats` });
} else {
const v = NOTE_VALUES[rng.int(0, NOTE_VALUES.length - 1)] ?? 5;
drops.push({ kind: 'note', value: v, label: `a folded $${v} — the floor pays better than the till` });
}
}
return drops;
}

View File

@ -1,52 +0,0 @@
// ROOM (design §6): the techno is so loud every line at the rope is a lip-read
// guess. Pure text transform, deterministic per (salt, word) — the same patron
// mouths the same guess twice, because re-asking doesn't reroll reality.
//
// Tone guard: the comedy is the player's OWN uncertain reading, never the
// speaker's — everyone in ROOM is perfectly articulate, you just can't hear.
/** What the player's brain offers when a word is half-caught. Club-adjacent
* nonsense on purpose a guess should be worse than the truth. */
const MISHEARINGS: readonly string[] = [
'FIST', 'GOAT', 'CHIPS', 'NAN', 'TAX', 'DECAF', 'PILATES', 'HORSE',
'COUNCIL', 'SOUP', 'LADDER', 'BINS', 'SHREK', 'MULCH',
];
const LOST = '[???]';
/** FNV-1a — cheap, stable, and never Math.random (CONTRACTS.md). */
function hash(s: string): number {
let h = 2166136261;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
/**
* `"mate im on the list"` `"[MATE] [IM?] [ON THE?] [FIST? LIST?]"`-shaped
* guesses. Every token is bracketed (the brackets ARE the deafness); short
* words survive better than long ones; some words arrive as two competing
* guesses and some don't arrive at all.
*/
export function lipReadLine(line: string, salt: string): string {
const words = line.split(/\s+/).filter(Boolean);
const out: string[] = [];
for (let i = 0; i < words.length; i++) {
const clean = (words[i] ?? '').replace(/[^a-z0-9']/gi, '').toUpperCase();
if (clean.length === 0) continue;
const h = hash(`${salt}:${i}:${clean}`);
const roll = h % 100;
if (roll < 12) {
out.push(LOST);
} else if (roll < 28 && clean.length >= 4) {
out.push(`[${MISHEARINGS[h % MISHEARINGS.length]}? ${clean}?]`);
} else if (roll < 60) {
out.push(`[${clean}?]`);
} else {
out.push(`[${clean}]`);
}
}
return out.length > 0 ? out.join(' ') : LOST;
}

View File

@ -1,46 +0,0 @@
// Radio command (design §6, the Head of Security finale): NPC staff work the
// roles you aren't, and the radio is how you reach them. You used to speak
// into the radio; now you're the voice on the other end. Competence varies —
// that IS the staff. Pure numbers here; the scenes own the walkie theatre.
/** Orders the floor staff take. 'mess' is Bump the glassie, 'water' is Shan. */
export type FloorOrderKind = 'mess' | 'water';
export type OrderAck = 'sent' | 'cooldown' | 'nothing';
export const RADIO_COOLDOWN_MS: Readonly<Record<FloorOrderKind, number>> = {
mess: 45_000,
water: 60_000,
};
/** Staff cross a real floor — an order lands, then happens. */
export const ORDER_LAG_MS: readonly [number, number] = [3000, 7000];
/** Bump mops AND signs (in that order, worryingly) this often; else he signs
* it and finds a phone in the puddle. */
export const BUMP_MOP_CHANCE = 0.65;
/** Shan gets the water in without a negotiation this often; else she leads
* with the phrase "you've had enough". */
export const SHAN_CLEAN_CHANCE = 0.8;
/** A firm two cups. */
export const SHAN_WATER_STEP = 0.14;
export const SHAN_BOTCH_AGGRO = 2;
export const ORDER_DONE_VIBE = 1;
/** Shan only bothers with loose and worse. */
export const SHAN_MIN_INTOX = 0.45;
/** Kayden holds the rope this long, then resumes ruling. Badly. */
export const KAYDEN_HOLD_MS = 60_000;
export const KAYDEN_HOLD_COOLDOWN_MS = 90_000;
export function rollBumpMops(rng: { chance(p: number): boolean }): boolean {
return rng.chance(BUMP_MOP_CHANCE);
}
export function rollShanClean(rng: { chance(p: number): boolean }): boolean {
return rng.chance(SHAN_CLEAN_CHANCE);
}
export function orderLag(rng: { int(min: number, max: number): number }): number {
return rng.int(ORDER_LAG_MS[0], ORDER_LAG_MS[1]);
}

View File

@ -1,7 +1,7 @@
import Phaser from 'phaser';
import { renderDoll } from '../../patrons/doll';
import { dollPlan } from '../../patrons/dollPlan';
import { activeRules, announcedRules, shuffleWindow, violations, type DoorDressCodeRule } from '../../rules/dressCode';
import { activeRules, announcedRules, violations, type DoorDressCodeRule } from '../../rules/dressCode';
import { drunkStage } from '../../rules/drunk';
import { idVerdict, formatDate } from '../../rules/idCheck';
import { LOCKOUT_MIN, judge } from '../../rules/judge';
@ -14,15 +14,11 @@ import {
DENY_REACTIONS,
DOOR_TUTORIAL,
DOOR_UI,
BOARD_SHUFFLE_TOASTS,
CARPET_UI,
FRANKO_TIPS,
PATDOWN_LINES,
RULE_RETRACTIONS,
} from '../../data/strings/door';
import { CARPET_TUNING, QueueManager, entranceBonus } from './QueueManager';
import { KAYDEN_HOLD_COOLDOWN_MS, KAYDEN_HOLD_MS } from '../../rules/staff';
import { STAFF_RADIO } from '../../data/strings/floor';
import { QueueManager } from './QueueManager';
import { PatronUpView } from './PatronUpView';
import { IdCardView } from './IdCardView';
import { PhoneWidget } from './PhoneWidget';
@ -75,7 +71,6 @@ export class DoorScene extends Phaser.Scene {
private sobriety!: SobrietyModal;
private queueSprites: QueueSprite[] = [];
private carpetSprites: QueueSprite[] = [];
private trayCard: Phaser.GameObjects.Container | null = null;
private buttons: Record<string, Button> = {};
private vibeBar!: MeterBar;
@ -92,17 +87,12 @@ export class DoorScene extends Phaser.Scene {
private busy = false;
private rulingToken = 0;
private elapsedMs = 0;
/** ROOM: which 15-min shuffle window the code board currently shows. */
private codeWindow = -1;
private testedThisPatron = false;
private pattedThisPatron = false;
private offs: Array<() => void> = [];
/** Is the player at the rope? When false, Kayden works the door. Badly. */
private present = true;
private kaydenAccMs = 0;
/** Radio command: Kayden holds the rope until this elapsedMs, ready again after the other. */
private kaydenHoldUntilMs = 0;
private kaydenHoldReadyMs = 0;
private readonly retiredRules = new Set<string>();
/** Every card ADMITTED tonight, name|dob -> clock label. The passback net. */
private readonly seenCards = new Map<string, string>();
@ -143,28 +133,6 @@ export class DoorScene extends Phaser.Scene {
this.present = true;
this.kaydenAccMs = 0;
// Per-night state. Phaser relaunches this ONE scene instance every night, so
// field initialisers run once per game — anything scoped to a single night
// has to be cleared here. `seenCards` leaking was the worst of them: it is
// the passback net, so on Friday every returning regular walked up holding a
// card the desk "already admitted tonight", got stamped SEEN TONIGHT, and
// judge() scored the correct call as admitting a fake.
this.seenCards.clear();
this.retiredRules.clear();
// `busy` is released by a delayedCall on the SCENE clock, and stopping a
// scene discards its pending events — so any ruling inside the last 2.6s of
// a night (or the one that ended it) left this stuck true forever. That is a
// dead door for the rest of the run: no rope, no verdicts, no way inside.
this.busy = false;
this.rulingToken++;
this.frankoTips = 0;
this.kebabPending = false;
this.kebabHandled = false;
this.onKebabRun = false;
this.kebabRunMsLeft = 0;
this.kebabUi = [];
this.kebabFill = null;
// Some nights, mid-slam, Dazza needs a kebab. The shop is right there.
const kebabRng = this.night.rng.stream('kebab');
this.kebabAskAtMin = kebabRng.chance(0.65) ? kebabRng.int(140, 260) : null;
@ -173,40 +141,8 @@ export class DoorScene extends Phaser.Scene {
this.buildDesk();
this.buildHud();
// Radio command (design §6, Head of Security tier): from the door you
// order the FLOOR staff; Kayden's hold only matters from the floor, so
// he registers here and answers there.
if (this.night.venue.radioCommand) {
this.night.kaydenHold = () => {
if (this.elapsedMs < this.kaydenHoldReadyMs) return 'cooldown';
this.kaydenHoldUntilMs = this.elapsedMs + KAYDEN_HOLD_MS;
this.kaydenHoldReadyMs = this.elapsedMs + KAYDEN_HOLD_COOLDOWN_MS;
return 'sent';
};
this.input.keyboard?.on('keydown', (ev: KeyboardEvent) => {
if (!this.present || this.sobriety.isOpen) return;
if (ev.key === '1') this.radioFloorOrder('mess');
else if (ev.key === '2') this.radioFloorOrder('water');
});
this.add
.text(6, 246, STAFF_RADIO.hintDoor, { fontFamily: MONO, fontSize: '7px', color: '#6a6a82' })
.setDepth(30);
}
this.offs = [
this.night.bus.on('clock:tick', ({ clockMin }) => {
this.queue.onClockMinute(clockMin);
// ROOM: the board rewrites itself on every 15-min window turn.
if (this.night.venue.shuffleDressCode) {
const w = shuffleWindow(clockMin);
if (w !== this.codeWindow) {
this.codeWindow = w;
this.codeCard.setAll(this, activeRules(clockMin), this.elapsedMs);
this.showToast(BOARD_SHUFFLE_TOASTS[w % BOARD_SHUFFLE_TOASTS.length]!);
this.night.sfx?.play('phoneBuzz');
}
}
}),
this.night.bus.on('clock:tick', ({ clockMin }) => this.queue.onClockMinute(clockMin)),
this.night.bus.on('meters:changed', ({ vibe, aggro, hype }) => {
this.vibeBar.set(vibe / 100, vibe < 20);
this.aggroBar.set(aggro / 100, aggro > 80);
@ -299,21 +235,6 @@ export class DoorScene extends Phaser.Scene {
.text(DOOR_X, 184, 'step inside', { fontFamily: MONO, fontSize: '7px', color: '#6a4a5a' })
.setOrigin(0.5, 0);
// The red carpet: a strip of somewhere-better between rope and door,
// flanked by brass posts. Bodies parked here are rendered by
// syncCarpetSprites; the strip is set dressing.
if (this.textures.exists('gen:prop:carpet')) {
this.add.image(420, 236, 'gen:prop:carpet').setOrigin(0.5, 1).setDisplaySize(132, 34).setDepth(2);
} else {
this.add.rectangle(420, 232, 124, 12, 0x8a1428).setDepth(2);
this.add.rectangle(420, 227, 124, 2, 0xa8283c).setDepth(2);
for (const px of [356, 484]) {
this.add.rectangle(px, 216, 3, 22, 0xc8a24a).setDepth(2);
this.add.rectangle(px, 205, 7, 4, 0xe0c060).setDepth(2);
}
this.add.rectangle(420, 206, 126, 2, 0x7a2438).setDepth(2); // the velvet itself
}
this.buildRain();
this.buildRope();
}
@ -360,13 +281,6 @@ export class DoorScene extends Phaser.Scene {
// Two panels, one footprint. The tab header sits above them both.
this.codeCard = new DressCodeCard(this, 6, 268, 168, 86);
// ROOM: no Dazza chronology — the board opens already written, and the
// rope reads lips (PatronUpView renders every line as a guess).
if (this.night.venue.shuffleDressCode) {
this.codeWindow = shuffleWindow(this.night.state.clockMin);
this.codeCard.setAll(this, activeRules(this.night.state.clockMin), this.elapsedMs);
}
this.up.lipRead = this.night.venue.lipRead === true;
this.clipboard = new ClipboardPanel(this, this.queue.guestList, 6, 268, 168, 86);
this.buildDeskTabs();
@ -408,15 +322,10 @@ export class DoorScene extends Phaser.Scene {
// verdicts
this.buttons.rope = new Button(this, {
x: 442, y: 256, w: 130, h: 24, label: DOOR_UI.rope, size: 9,
x: 442, y: 256, w: 192, h: 24, label: DOOR_UI.rope, size: 9,
fill: 0x5a2030, hover: 0x8a2038,
onClick: () => this.callNext(),
});
this.buttons.carpet = new Button(this, {
x: 576, y: 256, w: 58, h: 24, label: DOOR_UI.carpet, size: 7,
fill: 0x6a1424, hover: 0x9a2038,
onClick: () => this.callCarpet(),
});
this.buttons.test = new Button(this, {
x: 442, y: 284, w: 94, h: 22, label: DOOR_UI.test, size: 7,
onClick: () => this.runSobriety(),
@ -515,12 +424,6 @@ export class DoorScene extends Phaser.Scene {
this.showToast(DOOR_UI.ropeEmpty);
return;
}
this.presentUp(p);
}
/** Everything that happens once SOMEBODY is at the rope the rope and the
* carpet recall share this path, so a recalled patron is a full patron. */
private presentUp(p: Patron): void {
this.testedThisPatron = false;
this.pattedThisPatron = false;
// Passback: this exact card already went in tonight. The tell is set HERE —
@ -551,89 +454,6 @@ export class DoorScene extends Phaser.Scene {
});
}
/** Door-side radio: order the floor staff without leaving the rope. */
private radioFloorOrder(kind: 'mess' | 'water'): void {
const ack = this.night.floorOrder?.(kind, (msg) => {
if (this.present) this.showToast(msg);
});
if (ack === 'sent') this.showToast(kind === 'mess' ? STAFF_RADIO.bumpAck : STAFF_RADIO.shanAck);
else if (ack === 'cooldown') this.showToast(STAFF_RADIO.cooldown);
else if (ack === 'nothing') this.showToast(kind === 'mess' ? STAFF_RADIO.bumpNothing : STAFF_RADIO.shanNothing);
}
// ---- the red carpet ----------------------------------------------------
/** The patron at the rope takes position between the brass posts. */
private callCarpet(): void {
const p = this.queue.patronUp;
if (!p || this.busy) return;
if (this.queue.toCarpet() === null) {
this.showToast(CARPET_UI.full);
return;
}
this.idCard.close();
this.trayCard?.destroy();
this.trayCard = null;
this.up.clear();
this.setVerdictButtons(false);
this.night.sfx?.play('ropeUnhook');
this.showToast(CARPET_UI.sent);
this.logIncidentDoor('carpet', p.id, 'Sent to the carpet to be seen waiting.');
}
/** Click a carpet doll: wave them up to the rope for the actual ruling. */
private recallCarpet(patronId: string): void {
if (this.busy) return;
if (this.queue.patronUp) {
this.showToast(CARPET_UI.recallBusy);
return;
}
const p = this.queue.recallFromCarpet(patronId);
if (!p) return;
this.presentUp(p);
}
private logIncidentDoor(kind: string, patronId: string | undefined, detail: string): void {
this.night.bus.emit('incident:log', { clockMin: this.night.state.clockMin, kind, patronId, detail });
}
/** The carpet is a view of QueueManager, same discipline as the queue line. */
private syncCarpetSprites(): void {
const slots = this.queue.carpetSnapshot;
const same =
slots.length === this.carpetSprites.length &&
slots.every((s, i) => this.carpetSprites[i]?.patron.id === s.patron.id);
if (!same) {
for (const s of this.carpetSprites) s.image.destroy();
this.carpetSprites = slots.map((slot, i) => {
const x = 372 + i * 34;
const img = this.add
.image(x, QUEUE_FOOT_Y + 6, renderDoll(this, slot.patron, 'queue'))
.setOrigin(0.5, 1)
.setScale(1.5)
.setDepth(205)
.setInteractive({ useHandCursor: true });
img.on('pointerdown', () => this.recallCarpet(slot.patron.id));
img.setData('baseX', x);
return { patron: slot.patron, image: img, link: null };
});
}
// Impatience is visible: the sway grows as the patience runs out.
for (let i = 0; i < this.carpetSprites.length; i++) {
const s = this.carpetSprites[i]!;
const slot = slots[i];
if (!slot) continue;
const fuse = Math.min(1, slot.standMs / slot.patienceMs);
s.image.x = s.image.getData('baseX') + Math.sin(this.elapsedMs / (300 - 140 * fuse)) * (1 + fuse * 3);
}
for (const p of this.queue.takeStorms()) {
this.showToast(CARPET_UI.storm);
this.night.sfx?.play('doorBang');
this.logIncidentDoor('carpetStorm', p.id, 'Ran out of patience on the carpet and left, loudly.');
}
}
/** Nudge the player toward a tab that just became relevant, without switching it. */
private flashTab(tab: ClipboardTab): void {
const b = this.tabButtons[tab];
@ -723,14 +543,6 @@ export class DoorScene extends Phaser.Scene {
}
this.applyOutcome(p, verdict, outcome);
this.applyEncounter(p, verdict);
// The entrance dividend: an admit off the carpet pays for every minute
// they were made to stand there being looked at.
const stoodMs = this.queue.carpetMsFor(p.id);
if (verdict === 'admit' && stoodMs > 0) {
this.night.bus.emit('meters:delta', { vibe: CARPET_TUNING.entranceVibe, hype: entranceBonus(stoodMs) });
this.time.delayedCall(600, () => this.showToast(CARPET_UI.entrance));
this.logIncidentDoor('carpetEntrance', p.id, `Admitted after ${Math.round(stoodMs / 1000)}s on the carpet.`);
}
this.queue.resolveUp(verdict === 'deny');
// Highlight AFTER the call, never before. Lighting up the broken rules the
@ -1069,15 +881,10 @@ export class DoorScene extends Phaser.Scene {
// his mistakes cost the same vibe/heat yours do. (His stamp accuracy is
// NightScene's problem; his verdicts are made here on the real queue.)
if (!this.present || this.onKebabRun) {
// "Hold the rope" (radio command): Kayden does nothing, magnificently.
if (this.elapsedMs < this.kaydenHoldUntilMs) {
this.kaydenAccMs = 0;
} else {
this.kaydenAccMs += deltaMs;
while (this.kaydenAccMs >= KAYDEN_RULE_MS) {
this.kaydenAccMs -= KAYDEN_RULE_MS;
this.kaydenRule();
}
this.kaydenAccMs += deltaMs;
while (this.kaydenAccMs >= KAYDEN_RULE_MS) {
this.kaydenAccMs -= KAYDEN_RULE_MS;
this.kaydenRule();
}
}
@ -1094,10 +901,6 @@ export class DoorScene extends Phaser.Scene {
}
this.syncQueueSprites();
this.syncCarpetSprites();
this.buttons.carpet?.setEnabled(
!this.busy && this.queue.patronUp !== null && this.queue.carpetSnapshot.length < CARPET_TUNING.slots,
);
const rope = this.buttons.rope;
if (rope) {
@ -1156,8 +959,6 @@ export class DoorScene extends Phaser.Scene {
}
private teardown(): void {
for (const s of this.carpetSprites) s.image.destroy();
this.carpetSprites = [];
for (const off of this.offs) off();
this.offs = [];
this.phone?.destroy();

View File

@ -59,17 +59,6 @@ export class DressCodeCard {
this.rows.push({ rule, label, badge, addedAt: nowMs });
}
/** ROOM shuffle: the board rewrites itself wholesale — clear and restate. */
setAll(scene: Phaser.Scene, rules: readonly DoorDressCodeRule[], nowMs: number): void {
for (const r of this.rows) {
r.label.destroy();
r.badge.destroy();
}
this.rows.length = 0;
this.emptyText.setVisible(rules.length === 0);
for (const rule of rules) this.add(scene, rule, nowMs);
}
/** Highlight the rules the patron currently in front of you is breaking. */
highlight(violatedIds: readonly string[]): void {
for (const r of this.rows) {

View File

@ -6,7 +6,7 @@ import { StubBeatClock } from '../../core/StubBeatClock';
import { Meters, freshNightState, vibeCoolingPerMin } from '../../core/meters';
import { loadGame, saveGame, clearSave, freshGameState } from '../../core/save';
import { _resetPatronSerial } from '../../patrons/generator';
import { ruleById, setDressCodeShuffle } from '../../rules/dressCode';
import { ruleById } from '../../rules/dressCode';
import { TechnoEngine } from '../../audio/TechnoEngine';
import { Ambience } from '../../audio/Ambience';
import { Sfx } from '../../audio/Sfx';
@ -14,7 +14,6 @@ import type { DeferredHit } from '../../rules/doorTypes';
import type { GameState, HeatStrike, NightState, Patron, Verdict, VerdictOutcome } from '../../data/types';
import { nextDazza } from './dazzaSchedule';
import { TOTAL_VENUES, nightDateFor, venueByIndex, type VenueDef } from '../../data/venues';
import { defaultRoleFor, roleById, type RoleDef } from '../../data/roster';
import { buildRegularCast } from '../../patrons/regularCast';
import { recordDenialBy } from '../../patrons/memory';
import { observe, startWatch, type InspectorWatch, type VenueBreaches } from '../../rules/inspector';
@ -63,17 +62,6 @@ export interface NightLog {
heatStrikes: HeatStrike[];
endReason: NightEndReason;
seed: number;
/** The shift taken off the roster board (design §6) and what it pays. */
role: string;
wage: number;
/** What the lights-on sweep found in cash. */
floorCash: number;
/**
* Cash that went in your pocket during the shift the DJ booth's shoe. Kept
* apart from floorCash because the sweep only happens on a clean close, and a
* pulled licence does not confiscate what somebody already handed you.
*/
pocketCash: number;
}
/** Run-level result attached to the summary. */
@ -110,29 +98,10 @@ export interface NightContext {
seenEncounters: readonly string[];
/** This week's venue — capacity/pressure knobs (venue ladder, design §6). */
venue: VenueDef;
/** Tonight's shift off the roster board — a lens, not a different game. */
role: RoleDef;
/** The run's stable regulars and their grudges (design §4.1). */
regulars: { cast: readonly Patron[]; memory: Readonly<Record<string, import('../../data/types').RegularMemory>> };
/** Door/Floor ask to move the player; the night decides and flips the scenes. */
requestLocation(loc: 'door' | 'floor'): void;
/**
* Radio command (design §6, Head of Security tier). The FLOOR registers its
* staff-order executor here at bind time; the DOOR registers Kayden's hold.
* Registered as mutable fields (not bus events EventMap is a frozen
* contract) so either scene can call the other without knowing it.
*/
floorOrder?: (kind: import('../../rules/staff').FloorOrderKind, notify: (msg: string) => void) => import('../../rules/staff').OrderAck;
kaydenHold?: () => 'sent' | 'cooldown';
/** The floor score: lights on at close, sweep what the night dropped.
* Registered by the floor; the night calls it before the summary. */
beginSweep?: (done: (score: { cash: number }) => void) => void;
/**
* Cash taken during the shift, banked as it is taken. Pushed rather than read
* at close so it survives a night that ends early you keep what you were
* handed, whatever happens to the licence.
*/
bankCash: (amount: number, source: string) => void;
}
export class NightScene extends Phaser.Scene {
@ -149,7 +118,6 @@ export class NightScene extends Phaser.Scene {
private seed = 4207;
private nightIndex = 0;
private venue!: VenueDef;
private role!: RoleDef;
private ended = false;
private deferred: DeferredHit[] = [];
private readonly firedDazza = new Set<string>();
@ -172,7 +140,7 @@ export class NightScene extends Phaser.Scene {
super('Night');
}
init(data: { seed?: number; nightIndex?: number; freshRun?: boolean; role?: string }): void {
init(data: { seed?: number; nightIndex?: number; freshRun?: boolean }): void {
if (data.freshRun) clearSave();
this.run = loadGame(typeof data.seed === 'number' ? data.seed : 4207);
if (typeof data.seed === 'number' && this.run.seed !== data.seed) {
@ -181,7 +149,6 @@ export class NightScene extends Phaser.Scene {
this.nightIndex = data.nightIndex ?? this.run.nightIndex;
if (this.nightIndex >= TOTAL_NIGHTS) this.nightIndex = 0;
this.venue = venueByIndex(this.run.venueIndex);
this.role = roleById(data.role) ?? defaultRoleFor(this.venue);
// Each night is its own deterministic world derived from the run seed —
// and each venue-week its own block of them.
this.seed = this.run.seed + this.run.venueIndex * 1009 + this.nightIndex * 101;
@ -205,10 +172,6 @@ export class NightScene extends Phaser.Scene {
};
this.clock = new GameClock(this.bus, clockCfg);
this.state = freshNightState(this.venue.id, this.nightIndex, this.venue.licensed);
// ROOM: the board rewrites itself every 15 min — judge(), the card and
// Kayden must all read the same shuffled set, so the mode lives at module
// level in rules/dressCode. Set (or cleared) fresh every night.
setDressCodeShuffle(this.venue.shuffleDressCode ? this.seed : null);
// Heat is run-scoped: strikes from earlier nights are already on the licence.
this.state.heatStrikes.push(...this.run.heatStrikes);
this.meters = new Meters(this.bus, this.state);
@ -239,10 +202,6 @@ export class NightScene extends Phaser.Scene {
heatStrikes: [],
endReason: 'clock',
seed: this.seed,
role: this.role.name,
wage: this.role.wage,
floorCash: 0,
pocketCash: 0,
};
this.offs.push(
@ -291,18 +250,8 @@ export class NightScene extends Phaser.Scene {
seed: this.seed,
nightDate: this.clock.nightDate,
sfx: this.sfx,
role: this.role,
beatIntervalMs: 60_000 / 128,
scheduleDeferred: (hits) => this.deferred.push(...hits),
bankCash: (amount, source) => {
if (amount <= 0) return;
this.log.pocketCash += amount;
this.bus.emit('incident:log', {
clockMin: this.state.clockMin,
kind: 'pocketCash',
detail: `$${amount}${source}.`,
});
},
reportQueue: (length) => {
this.queueLength = length;
},
@ -331,18 +280,7 @@ export class NightScene extends Phaser.Scene {
this.scene.launch('Floor', { night: this.ctx });
// Door and Floor BOTH run all night; the flags below decide which one
// the player is inhabiting. Floor starts hidden.
// The roster decides where you stand when the doors open (design §6).
this.time.delayedCall(0, () => {
this.applyLocation(this.role.startLoc);
// ...and ANNOUNCE it. applyLocation only moves flags; the room latches
// onto the event. FloorDemoScene reads `present` when it binds this
// context, which — now that the night preloads every prop texture —
// happens before this runs, so a floor-start shift (bartender, glassie,
// DJ) came up visible but completely inert, and the audio filter stayed
// in door mode while you stood on the dancefloor.
this.bus.emit('night:phaseChange', { location: this.role.startLoc });
this.bus.emit('audio:location', { location: this.role.startLoc });
});
this.time.delayedCall(0, () => this.applyLocation('door'));
};
if (missing.length === 0) return launch();
for (const k of missing) this.load.image(`gen:prop:${k}`, `props/${k}.png`);
@ -579,22 +517,9 @@ export class NightScene extends Phaser.Scene {
};
this.time.delayedCall(reason === 'clock' ? 600 : 1400, () => {
const proceed = (): void => {
this.scene.stop('Door');
this.scene.stop('Floor');
this.scene.start('NightSummary', { log: this.log, state: this.state, run: runInfo });
};
// Made it to close: the lights come on and the floor pays out first.
// A pulled licence or a dead room skips the sweep — nobody's sweeping.
if (reason === 'clock' && this.ctx.beginSweep) {
this.applyLocation('floor');
this.ctx.beginSweep((score) => {
this.log.floorCash = score.cash;
proceed();
});
} else {
proceed();
}
this.scene.stop('Door');
this.scene.stop('Floor');
this.scene.start('NightSummary', { log: this.log, state: this.state, run: runInfo });
});
}
@ -605,12 +530,6 @@ export class NightScene extends Phaser.Scene {
}
private teardown(): void {
// Door and Floor are this scene's children — they hold this night's bus,
// clock and context. A Night that goes away without taking them leaves two
// scenes driving a dead bus, and a restarted Night would launch a second
// pair on top of the first.
this.scene.stop('Door');
this.scene.stop('Floor');
for (const off of this.offs) off();
this.offs = [];
this.meters?.destroy();

View File

@ -15,13 +15,11 @@ const NIGHT_NAMES = ['thursday', 'friday', 'saturday'] as const;
export function nightCash(log: NightLog, state: NightState): number {
// Wage plus a hype bonus, minus a strike deduction. Vibe is not paid directly —
// Dazza pays for the room feeling busy, not for you being right.
const wage = log.wage;
const wage = 180;
const hypeBonus = Math.round((log.hypePeak - 1) * 90);
const doorBonus = log.admitted * 2;
const strikes = state.heatStrikes.length * 60;
// Cash is added AFTER the floor: a strike deduction can wipe your wage, but it
// cannot reach into your pocket for money a punter already handed you.
return Math.max(0, wage + hypeBonus + doorBonus - strikes) + log.floorCash + log.pocketCash;
return Math.max(0, wage + hypeBonus + doorBonus - strikes);
}
export function signoffFor(vibe: number, strikes: number): string {
@ -66,7 +64,7 @@ export class NightSummaryScene extends Phaser.Scene {
46,
failed
? `${this.run?.venue.name ?? this.state.venueId} · ${nightName} · night over early`
: `${SUMMARY_UI.subtitle}${nightName ? ` · ${nightName}` : ''} · shift: ${this.log.role.toLowerCase()} $${this.log.wage}`,
: `${SUMMARY_UI.subtitle}${nightName ? ` · ${nightName}` : ''}`,
{
fontFamily: MONO,
fontSize: '8px',
@ -108,19 +106,19 @@ export class NightSummaryScene extends Phaser.Scene {
if (!r) {
return {
prompt: SUMMARY_UI.replay,
next: () => this.scene.start('Roster', { seed: this.log.seed + 1 }),
next: () => this.scene.start('Night', { seed: this.log.seed + 1 }),
};
}
if (r.runOver) {
return {
prompt: 'licence gone · click to start a fresh week somewhere that has not heard of you',
next: () => this.scene.start('Roster', { freshRun: true, seed: this.log.seed + 13 }),
next: () => this.scene.start('Night', { freshRun: true, seed: this.log.seed + 13 }),
};
}
if (r.seasonDone) {
return {
prompt: 'THE SEASON IS DONE. every door in the valley, held. click for a fresh run',
next: () => this.scene.start('Roster', { freshRun: true, seed: this.log.seed + 7 }),
next: () => this.scene.start('Night', { freshRun: true, seed: this.log.seed + 7 }),
};
}
if (r.weekDone && r.promotedTo) {
@ -128,7 +126,7 @@ export class NightSummaryScene extends Phaser.Scene {
return {
// Dazza's pitch sells the next rung — the ladder is the reward.
prompt: `WEEK SURVIVED — PROMOTED: ${v.name.toUpperCase()}. "${v.pitch}"`,
next: () => this.scene.start('Roster', {}),
next: () => this.scene.start('Night', {}),
};
}
const failedNight = this.log.endReason !== 'clock';
@ -137,7 +135,7 @@ export class NightSummaryScene extends Phaser.Scene {
: (NIGHT_NAMES[r.nightIndex + 1] ?? 'the next night');
return {
prompt: failedNight ? `click to run ${nextName} back` : `click for ${nextName} · ${NIGHT_DATES[r.nightIndex + 1] ?? ''}`,
next: () => this.scene.start('Roster', {}),
next: () => this.scene.start('Night', {}),
};
}
@ -189,10 +187,6 @@ export class NightSummaryScene extends Phaser.Scene {
`${this.state.heatStrikes.length}/3`,
this.state.heatStrikes.length > 0 ? '#ff8080' : undefined,
],
['floor score', `$${this.log.floorCash}`, this.log.floorCash > 0 ? '#f0d060' : undefined],
// Its own line, named plainly. The game does not editorialise about the
// shoe — it just declines to hide it (design §4.3).
['in your pocket', `$${this.log.pocketCash}`, this.log.pocketCash > 0 ? '#f0d060' : undefined],
];
rows.forEach(([label, value, colour], i) => {

View File

@ -2,7 +2,6 @@ import Phaser from 'phaser';
import { renderDoll } from '../../patrons/doll';
import { dollPlan } from '../../patrons/dollPlan';
import { drunkStage } from '../../rules/drunk';
import { lipReadLine } from '../../rules/lipRead';
import type { Patron } from '../../data/types';
import { CLAIM_LINES, DRUNK_GREETINGS, OWNER_HINTS, PATRON_GREETINGS, REGULAR_DISGUISED_LINES, REGULAR_RETURN_LINES } from '../../data/strings/door';
import { encounterById, type EncounterId } from '../../rules/encounters';
@ -26,8 +25,6 @@ export class PatronUpView {
private swayPx = 0;
/** The bubble currently on screen. Beats REPLACE it; they do not stack. */
private bubble: Phaser.GameObjects.GameObject[] = [];
/** ROOM (design §6): every line renders as a lip-read guess. Set by DoorScene. */
lipRead = false;
constructor(
private readonly scene: Phaser.Scene,
@ -112,9 +109,8 @@ export class PatronUpView {
const line = pool[greetingIndex(p, pool.length)] ?? PATRON_GREETINGS[0]!;
const bubbleY = -DOLL_H * SCALE - 16;
// Lip-read guesses drop the quote marks — it's your reading, not their words.
const label = scene.add
.text(0, bubbleY, this.lipRead ? lipReadLine(line, p.id) : `"${line}"`, {
.text(0, bubbleY, `"${line}"`, {
fontFamily: MONO,
fontSize: '8px',
color: '#d8d0c0',
@ -147,7 +143,7 @@ export class PatronUpView {
this.clearBubble();
const bubbleY = -DOLL_H * SCALE - 16;
const label = scene.add
.text(0, bubbleY, this.lipRead ? lipReadLine(line, this.patron.id) : `"${line}"`, {
.text(0, bubbleY, `"${line}"`, {
fontFamily: MONO,
fontSize: '8px',
color: '#e8dcc4',

View File

@ -69,43 +69,6 @@ export const QUEUE_TUNING = {
encountersPerNight: 3,
} as const;
// The red carpet (design §2 queue theatre, escalated): a velvet holding pen
// where you park somebody gorgeous and make them WAIT, visibly, at length.
// Every body on display sells the room; every body has a limit.
export const CARPET_TUNING = {
/** brass posts only stretch so far */
slots: 3,
/** hype per real second per body on display */
hypePerSecPerBody: 0.02,
/** the entrance: admitting off the carpet pays hype scaled by the stand */
entranceHypePerSec: 0.012,
entranceHypeCap: 0.6,
entranceVibe: 2,
/** patience in real seconds, before the archetype scale */
patienceSecBase: [30, 55] as const,
/**
* Who tolerates being furniture. Influencers LIVE for it; a regular gives
* it thirty seconds and tells the whole suburb.
*/
patienceScale: {
influencer: 1.8, financeBro: 1.3, ownersMate: 1.2, fresh18: 1.1,
almost18: 1.0, inspector: 1.0, punter: 0.9, quietMessy: 0.7, regular: 0.5,
} as Readonly<Record<import('../../data/types').Archetype, number>>,
/** storming off the carpet is a scene the whole queue watches */
stormAggro: 3,
} as const;
/** The entrance dividend for `standMs` on the carpet, capped. */
export function entranceBonus(standMs: number): number {
return Math.min(CARPET_TUNING.entranceHypeCap, (standMs / 1000) * CARPET_TUNING.entranceHypePerSec);
}
export interface CarpetSlot {
patron: Patron;
standMs: number;
patienceMs: number;
}
export interface QueueSnapshot {
waiting: Patron[];
visible: Patron[];
@ -140,18 +103,9 @@ export class QueueManager {
private readonly encounterOf = new Map<string, EncounterId>();
/** patrons queued to re-enter the queue: [clockMin, patron] */
private readonly comebacks: Array<{ atMin: number; patron: Patron }> = [];
/** Twins whose sibling has already been sent for — one brother each. */
private readonly twinsSpawned = new Set<string>();
/** everyone who reached the front tonight, for the summary screen */
readonly seen: Patron[] = [];
/** bodies currently on display between the brass posts */
private readonly carpet: CarpetSlot[] = [];
/** storm-offs since the scene last asked — drained by takeStorms() */
private readonly storms: Patron[] = [];
/** patronId -> how long they stood, recorded at recall (the entrance maths) */
private readonly carpetStand = new Map<string, number>();
private readonly carpetRng: RngStream;
constructor(
private readonly bus: EventBus,
@ -180,20 +134,11 @@ export class QueueManager {
this.encounterRng = rng.stream('encounters');
this.castRng = rng.stream('castPick');
this.passbackRng = rng.stream('passback');
this.carpetRng = rng.stream('carpet');
bus.on('door:verdict', ({ patron, verdict }) => {
// Twin B arrives a few minutes after A's verdict, WHATEVER it was — he
// was parking the car either way. A plain patron, no script: his own
// real name, his brother's face. The photo check is the thing on trial.
// ...but only on a FINAL ruling, and only once. `door:verdict` also fires
// for 'sobrietyTest' and 'patDown', so a doorperson who tested the twin
// AND patted him down summoned three of his brother.
if (
(verdict === 'admit' || verdict === 'deny') &&
this.encounterOf.get(patron.id) === 'twins' &&
!this.twinsSpawned.has(patron.id)
) {
this.twinsSpawned.add(patron.id);
if (this.encounterOf.get(patron.id) === 'twins') {
this.comebacks.push({
atMin: this.clockMin + this.comebackRng.int(2, 5),
patron: twinSibling(patron),
@ -277,7 +222,6 @@ export class QueueManager {
update(deltaMs: number): void {
const sec = deltaMs / 1000;
this.phoneCooldownMs = Math.max(0, this.phoneCooldownMs - deltaMs);
this.tickCarpet(deltaMs);
const idle = this.up === null && this.waiting.length > 0;
if (idle) {
@ -312,77 +256,6 @@ export class QueueManager {
}
}
/** The carpet ticks whether or not the rope is busy — display doesn't pause. */
private tickCarpet(deltaMs: number): void {
if (this.carpet.length === 0) return;
const sec = deltaMs / 1000;
this.bus.emit('meters:delta', { hype: CARPET_TUNING.hypePerSecPerBody * this.carpet.length * sec });
for (let i = this.carpet.length - 1; i >= 0; i--) {
const slot = this.carpet[i]!;
slot.standMs += deltaMs;
if (slot.standMs < slot.patienceMs) continue;
this.carpet.splice(i, 1);
this.storms.push(slot.patron);
this.bus.emit('meters:delta', { aggro: CARPET_TUNING.stormAggro });
}
}
get carpetSnapshot(): readonly CarpetSlot[] {
return this.carpet;
}
/** Storm-offs since last asked. The scene narrates; the meters already paid. */
takeStorms(): Patron[] {
return this.storms.splice(0, this.storms.length);
}
/**
* The patron at the rope takes position between the brass posts. Frees the
* rope the theatre is that you keep working while they stand there.
*/
toCarpet(): Patron | null {
const p = this.up;
if (!p || this.carpet.length >= CARPET_TUNING.slots) return null;
this.up = null;
this.waitMs = 0;
this.hypeEarnedThisWait = 0;
const [lo, hi] = CARPET_TUNING.patienceSecBase;
const scale = CARPET_TUNING.patienceScale[p.archetype] ?? 1;
this.carpet.push({
patron: p,
standMs: 0,
patienceMs: this.carpetRng.int(lo, hi) * scale * 1000,
});
return p;
}
/** Wave them off the carpet and up to the rope. Only while the rope is free. */
recallFromCarpet(patronId: string): Patron | null {
if (this.up !== null) return null;
const i = this.carpet.findIndex((s) => s.patron.id === patronId);
if (i < 0) return null;
const slot = this.carpet.splice(i, 1)[0]!;
this.carpetStand.set(slot.patron.id, slot.standMs);
this.up = slot.patron;
this.waitMs = 0;
this.hypeEarnedThisWait = 0;
this.bus.emit('door:patronUp', { patron: slot.patron });
return slot.patron;
}
/**
* How long this patron stood on the carpet before their recall, in ms
* CONSUMED on read. The dividend is paid for one stand, once: a regular who
* was parked, recalled and knocked back comes round again later through the
* ordinary queue, and without this the door paid them a second entrance bonus
* for a stand that had already been cashed.
*/
carpetMsFor(patronId: string): number {
const ms = this.carpetStand.get(patronId) ?? 0;
this.carpetStand.delete(patronId);
return ms;
}
/** The Rope. Nothing steps up until the player says so. */
callNext(): Patron | null {
if (this.up !== null) return null;

View File

@ -1,7 +1,7 @@
import Phaser from 'phaser';
import { EventBus } from '../../core/EventBus';
import { GameClock } from '../../core/GameClock';
import { SeededRNG, type RngStream } from '../../core/SeededRNG';
import { SeededRNG } from '../../core/SeededRNG';
import { StubBeatClock } from '../../core/StubBeatClock';
import { Meters, freshNightState } from '../../core/meters';
import { drunkStage } from '../../rules/drunk';
@ -28,33 +28,10 @@ import {
SLIP_CHANCE_PER_S, SLIP_COOLDOWN_MS, SLIP_RADIUS_PX,
VOMIT_CHANCE_PER_S, watered,
} from './hazards';
import {
BUILD_FIZZLE_BARS, BUILD_HYPE, DROP_PAYOUT, FIZZLE_VIBE,
REQUEST_HIT, REQUEST_LATER_GRUDGE, REQUEST_NAH_AGGRO,
REQUEST_PLAY_FLOP, REQUEST_PLAY_HIT,
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, 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 {
ORDER_DONE_VIBE, RADIO_COOLDOWN_MS, SHAN_BOTCH_AGGRO, SHAN_MIN_INTOX, SHAN_WATER_STEP,
orderLag, rollBumpMops, rollShanClean, type FloorOrderKind, type OrderAck,
} from '../../rules/staff';
import { rollDrops, type Drop } from '../../rules/floorScore';
import { getActiveEngine } from '../../audio/TechnoEngine';
import { RACK_RESPAWN_MIN, freshRack, stepRack, turnSharpness, type RackState } from './glassie';
import { MAP_H, MAP_W, isWalkable, tileToWorld } from './venueMap';
import { tileToWorld } from './venueMap';
import {
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, STAFF_RADIO, SWEEP_UI,
AID_UI, HELP_TEXT, JOINT_RESULTS, JOINT_STING, JOINT_UI, NO_STAMP_RADIO,
PHONE_POSTED, PHONE_UI, VOMIT_UI, WATER_UI,
} from '../../data/strings/floor';
import { MeterHud } from '../../ui/MeterHud';
@ -72,23 +49,6 @@ const RACK_SPOT = tileToWorld(51, 7);
const DISH_SPOT = tileToWorld(17, 3);
/** The sinks (venueMap rect 66..71,15): where a cup of water comes from. */
const WATER_SPOT = tileToWorld(68, 15);
/** The booth (venueMap rect 52..58,19..25): where you hold the decks. */
/**
* Where you stand to work the decks: the booth's WEST COUNTER, not its inside.
* venueMap seals the booth interior (`djbooth`/`djfloor` are not in WALKABLE)
* it was built as a stage back when the DJ was set dressing. Standing the player
* on (55,22) put them inside that seal: every direction blocked, for the rest of
* the night, and the `dj` roster role posts you there automatically. The counter
* is where the map comment always said the gear faces from.
*/
const DECKS_SPOT = tileToWorld(51, 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. Row 7, not row
* 6: row 6 is the `stool` rect, and `stool` is not in venueMap's WALKABLE set.
*/
const TAPS_FRONT = tileToWorld(47, 7);
/** Crowd contact distance while carrying, and its debounce. */
const CARRY_BUMP_PX = 14;
const CARRY_BUMP_COOLDOWN_MS = 350;
@ -100,7 +60,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' | 'taps';
| 'puddle' | 'water' | 'filming' | 'fainter';
interface InteractTarget {
kind: InteractKind;
@ -156,30 +116,6 @@ export class FloorDemoScene extends Phaser.Scene {
private fainter: { agentId: string; x: number; y: number; msLeft: number } | null = null;
private fainterAtMin: number | null = null;
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>();
/** The jar of other people's cards: patronId -> open tab. */
private readonly tabs = new Map<string, Tab>();
private pour!: PourOverlay;
/** Cash taken at the booth tonight. The shoe keeps the count. */
private djTips = 0;
/** NAH'd requesters on their ATM pilgrimage, due back with cash. */
private djReturns: Array<{ scriptId: string; tip: number; dueMs: number }> = [];
/** Radio command: elapsedMs at which each floor order is usable again. */
private readonly staffReadyAt = new Map<FloorOrderKind, number>();
/** The floor score: lights on, crowd gone, the sweep. */
private sweeping = false;
private sweepCash = 0;
private sweepDone: ((score: { cash: number }) => void) | null = null;
private sweepDrops: Array<{ drop: Drop; x: number; y: number; gfx: Phaser.GameObjects.Container }> = [];
private baggiePending: Drop | null = null;
private djBuild: { armedAtMs: number } | null = null;
private lastBeat = { index: 0, atMs: 0 };
private beatMs = 500;
/** Clock minutes at which somebody comes OVER the yard fence (night mode). */
private jumperTimes: number[] = [];
private jumperRng: SeededRNG | null = null;
@ -214,7 +150,6 @@ export class FloorDemoScene extends Phaser.Scene {
create(data?: { night?: NightContext }): void {
this.nightCtx = data?.night ?? null;
this.resetNightState();
// Wired explicitly: Phaser does not invoke a `shutdown` METHOD on a Scene
// subclass; without this, listeners and sprites leak on every scene stop.
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.teardown());
@ -229,48 +164,6 @@ export class FloorDemoScene extends Phaser.Scene {
this.load.start();
}
/**
* Wipe everything that belongs to ONE night.
*
* Phaser reuses the scene instance across restarts, so class-field
* initialisers run exactly once in the lifetime of the game every field
* below would otherwise carry last night into this one. That is not
* theoretical: a DJ shift left `djMode` set, so the next night's floor booted
* with movement locked and the keyboard eaten; last night's puddles kept
* slipping people through sprites Phaser had already destroyed; and the
* handled-patron sets marked fresh arrivals as already dealt with, because
* patron ids restart with the run.
*/
private resetNightState(): void {
this.djMode = false;
this.djBuild = null;
this.barMode = false;
this.carryingCup = false;
this.mop = null;
// The graphics these referenced went down with the previous scene shutdown;
// dropping the references is the whole job.
this.puddles = [];
this.vomitCount = 0;
this.filming = null;
this.fainter = null;
this.rack = null;
this.escorting = null;
this.target = null;
// The floor score belongs to the night that ran it. A sweep interrupted by
// the scene stopping would otherwise carry its cash — and its pay-out
// callback, which closes over the PREVIOUS night — into the next one.
this.sweeping = false;
this.sweepCash = 0;
this.sweepDone = null;
this.liveFight = null;
this.uvMode = false;
this.jointDealt.clear();
this.maggotSeen.clear();
this.spotted.clear();
this.cutOffDealt.clear();
this.slipCooldown.clear();
}
private loadGenProps(): void {
const manifest = this.cache.json.get('propManifest') as { props?: string[] } | undefined;
const kinds = manifest?.props ?? [];
@ -290,7 +183,6 @@ 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')
@ -310,9 +202,7 @@ export class FloorDemoScene extends Phaser.Scene {
this.promptText = this.fixedText(4, VIEW_H - 24, '#9fe8a0');
this.statusText = this.fixedText(4, VIEW_H - 12, '#556');
this.radioText = this.fixedText(4, 26, '#d8a020');
this.fixedText(4, 14, '#445').setText(
this.nightCtx?.venue.radioCommand ? `${HELP_TEXT} · ${STAFF_RADIO.hint}` : HELP_TEXT,
);
this.fixedText(4, 14, '#445').setText(HELP_TEXT);
this.keys = this.input.keyboard!.addKeys(
'W,A,S,D,UP,DOWN,LEFT,RIGHT,E,TAB,SPACE,ESC,R',
@ -329,19 +219,6 @@ export class FloorDemoScene extends Phaser.Scene {
/** Night mode: adopt the night's world instead of building our own. */
private bindNight(ctx: NightContext): void {
this.seed = ctx.seed;
// Radio command (design §6): the door orders floor staff through here.
if (ctx.venue.radioCommand) {
ctx.floorOrder = (kind, notify) => this.execStaffOrder(kind, notify);
}
// The floor score: the night calls this at close, before the summary.
ctx.beginSweep = (done) => this.enterSweep(done);
// The roster put you on a post: walk straight onto it once the world is up.
if (ctx.role.post) {
this.time.delayedCall(600, () => {
if (ctx.role.post === 'taps' && !this.barMode) this.toggleTaps();
else if (ctx.role.post === 'decks' && !this.djMode) this.toggleDecks();
});
}
this.bus = ctx.bus;
this.rng = ctx.rng;
this.clock = ctx.clock;
@ -349,7 +226,6 @@ export class FloorDemoScene extends Phaser.Scene {
this.beat = null; // beat:tick arrives on the shared bus (stub, then TechnoEngine)
this.meters = null; // NightScene owns the single writer
this.hud = new MeterHud(this, this.bus, { initialHeat: this.night.heatStrikes.length });
this.beatMs = ctx.beatIntervalMs;
this.stall = new StallOverlay(this, this.bus, ctx.beatIntervalMs, ctx.sfx);
this.crowd = new CrowdSim({ map: VENUE, bus: this.bus, rng: this.rng.stream('crowd') });
@ -374,12 +250,6 @@ export class FloorDemoScene extends Phaser.Scene {
this.spotted.clear();
this.cutOffDealt.clear();
this.jointDealt.clear();
this.barMode = false;
this.barOrderAt.clear();
this.staffReadyAt.clear();
this.tabs.clear();
this.djTips = 0;
this.djReturns = [];
this.rack = null;
this.rackReadyAtMin = 6;
@ -402,7 +272,6 @@ export class FloorDemoScene extends Phaser.Scene {
if (this.present) this.target = null;
}),
this.bus.on('beat:tick', ({ beatIndex }) => {
this.lastBeat = { index: beatIndex, atMs: this.elapsedMs };
this.crowd.onBeat(beatIndex);
this.view.onBeat(beatIndex);
}),
@ -434,14 +303,12 @@ export class FloorDemoScene extends Phaser.Scene {
this.night.location = 'floor';
this.meters = new Meters(this.bus, this.night);
this.hud = new HudStub(this, this.bus);
this.beatMs = this.beat.beatIntervalMs;
this.stall = new StallOverlay(this, this.bus, this.beat.beatIntervalMs);
this.crowd = new CrowdSim({ map: VENUE, bus: this.bus, rng: this.rng.stream('crowd') });
this.fights?.destroy();
this.fights = new FightDirector({ bus: this.bus, rng: this.rng.stream('fights'), crowd: this.crowd });
this.bus.on('beat:tick', ({ beatIndex }) => {
this.lastBeat = { index: beatIndex, atMs: this.elapsedMs };
this.crowd.onBeat(beatIndex);
this.view.onBeat(beatIndex);
});
@ -459,12 +326,6 @@ export class FloorDemoScene extends Phaser.Scene {
this.spotted.clear();
this.cutOffDealt.clear();
this.jointDealt.clear();
this.barMode = false;
this.barOrderAt.clear();
this.staffReadyAt.clear();
this.tabs.clear();
this.djTips = 0;
this.djReturns = [];
this.rack = null;
this.rackReadyAtMin = 6;
@ -478,52 +339,22 @@ 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);
if (this.choice.isOpen) return this.choice.handleKey(key);
if (this.sweeping) {
if (this.baggiePending) {
if (key === 'B') return this.resolveBaggie(false);
if (key === 'SPACE') return this.resolveBaggie(true);
return; // the bag deserves your full attention
}
if (key === 'E') return this.sweepInteract();
return;
}
if (this.djMode) {
if (key === 'B') return this.djStartBuild();
if (key === 'SPACE') return this.djDrop();
if (key === 'E') return this.interact();
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;
return;
}
if (key === 'R' && !this.nightCtx) return this.resetRun(this.seed + 1);
// Radio command (design §6): hands-free only — the decks and the taps
// already ate their keys above.
if (this.nightCtx?.venue.radioCommand) {
if (key === '1') return this.radioFloor('mess');
if (key === '2') return this.radioFloor('water');
if (key === '3') return this.radioKayden();
}
if (key === 'E') return this.interact();
}
private get overlayOpen(): boolean {
return this.cutOff.isOpen || this.stall.isOpen || this.patDown.isOpen || this.choice.isOpen || this.aid.isOpen || this.pour.isOpen;
return this.cutOff.isOpen || this.stall.isOpen || this.patDown.isOpen || this.choice.isOpen || this.aid.isOpen;
}
private readInput(): PlayerInput {
@ -544,10 +375,6 @@ export class FloorDemoScene extends Phaser.Scene {
if (!this.view) return; // world still waiting on the prop manifest
const dt = Math.min(deltaMs, 50);
// Lights on: the sweep replaces the whole night loop. The night is over —
// no clock, no crowd, no meters. Just you, the floor, and what it dropped.
if (this.sweeping) return this.tickSweep(dt);
// These run REGARDLESS of overlays. The music doesn't stop because you
// opened a cubicle door — and the stall minigame judges against beat:tick,
// so freezing the beat clock behind a modal makes it literally unplayable.
@ -610,12 +437,6 @@ export class FloorDemoScene extends Phaser.Scene {
this.cutOff.update(dt);
this.stall.update(dt);
this.patDown.update(dt);
this.pour.update(dt);
// The aid QTE ticks HERE, with the other modals. It used to be ticked
// after this branch returns — and `overlayOpen` counts the aid overlay,
// so its timer only ran on frames where it was closed. The bar froze at
// full, no step could time out, and every fainter was saved by default.
this.aid.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);
@ -626,7 +447,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.barMode ? this.player : stepPlayer(this.player, input, dt, VENUE);
this.player = stepPlayer(this.player, input, dt, VENUE);
if (!this.nightCtx) {
// Demo mode stands in for the door with a synthetic spawner.
@ -649,8 +470,7 @@ export class FloorDemoScene extends Phaser.Scene {
this.trackMaggots();
this.tickHazards(dt);
this.tickDecks(dt);
this.tickBar(dt);
this.aid.update(dt);
// Mopping: feet planted until it's done. The commitment IS the mechanic.
if (this.mop) {
@ -810,22 +630,6 @@ export class FloorDemoScene extends Phaser.Scene {
if (d <= INTERACT_RANGE) return { kind: 'water', prompt: WATER_UI.pickup };
}
// 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) {
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, DECKS_SPOT.x, DECKS_SPOT.y);
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);
@ -900,8 +704,6 @@ export class FloorDemoScene extends Phaser.Scene {
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 === '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') {
@ -1189,605 +991,6 @@ export class FloorDemoScene extends Phaser.Scene {
// ---- chrome ------------------------------------------------------------
// ---- the DJ shift (docs/SCENARIOS.md, design §6) -----------------------
/** E at the booth: take the decks, or hand them back. */
private toggleDecks(): void {
if (!this.djMode) {
this.djMode = true;
this.player.x = DECKS_SPOT.x;
this.player.y = DECKS_SPOT.y;
this.toast(DJ_UI.handover);
this.promptText.setText(DJ_UI.help);
this.logIncident('djShift', undefined, 'Held the decks while the resident stepped out.');
return;
}
// Handing back mid-build: the bass comes home unsupervised.
if (this.djBuild) this.djResolveBuild(null);
this.djMode = false;
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 {
if (this.djBuild || this.overlayOpen) return;
this.djBuild = { armedAtMs: this.elapsedMs };
getActiveEngine()?.setVoiceEnabled('bass', false);
this.bus.emit('meters:delta', { hype: BUILD_HYPE });
this.toast(DJ_UI.buildStart);
}
private djDrop(): void {
if (!this.djBuild || this.overlayOpen) return;
const sinceBeat = this.elapsedMs - this.lastBeat.atMs;
const verdict = judgeDrop(msToNearestBar(this.lastBeat.index % 4, sinceBeat, this.beatMs));
this.djResolveBuild(verdict);
}
/** null = fizzle/abandon; otherwise a judged drop. Either way the bass returns. */
private djResolveBuild(verdict: 'clean' | 'late' | 'clang' | null): void {
this.djBuild = null;
getActiveEngine()?.setVoiceEnabled('bass', true);
if (verdict === null) {
this.bus.emit('meters:delta', { vibe: FIZZLE_VIBE });
this.toast(DJ_UI.fizzle);
return;
}
const pay = DROP_PAYOUT[verdict];
const delta: { vibe?: number; aggro?: number; hype?: number } = {};
if (pay.vibe) delta.vibe = pay.vibe;
if (pay.aggro) delta.aggro = pay.aggro;
if (pay.hype) delta.hype = pay.hype;
this.bus.emit('meters:delta', delta);
this.logIncident('djDrop', undefined, `Drop: ${verdict}.`);
this.toast(DJ_DROP_RESULTS[verdict]);
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;
// Deliberately NO teleport. The punter side of the till is a `stool` tile,
// which is not walkable — dropping the player there stranded them for the
// rest of the night with every direction blocked, and the bartender roster
// role mounts the taps automatically, so one keypress ended the run. Staff
// leave the rail the way staff do: around the east end of the bar.
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');
// 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(
(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);
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;
}
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);
// Water and a cut-off are you DEALING with someone, so the floor has to stop
// treating them as outstanding — exactly what the water station does. Without
// this a maggot you watered from behind the bar kept ticking as unhandled and
// re-logging every grace period, punishing the call that fixed the problem.
if (call === 'water' || call === 'cutoff') {
this.maggotSeen.delete(asker.patron.id);
this.cutOffDealt.set(asker.patron.id, drunkStage(asker.patron.intoxication));
}
const pourNote = pourVerdict && pourVerdict !== 'clean' ? ` Pour: ${pourVerdict}.` : '';
// One kind per verb. Logging a cut-off as a serve put a drink in the record
// that was never poured — and the incident report is a system about what you
// say happened, so the truth side of it has to be true.
const kind = call === 'serve' ? 'barServe' : call === 'water' ? 'barWater' : 'barCutOff';
this.logIncident(kind, 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}).`);
}
},
);
}
// ---- the floor score (lights on at close) ------------------------------
/** The night is over; the lights come on; the floor pays out. */
private enterSweep(done: (score: { cash: number }) => void): void {
this.sweeping = true;
this.sweepDone = done;
this.sweepCash = 0;
this.baggiePending = null;
this.present = true;
this.barMode = false;
this.djMode = false;
this.escorting = null;
this.rack = null;
this.mop = null;
this.uvMode = false;
// Everyone out. The crowd sim keeps the bodies; the lights don't.
for (const a of this.crowd.agents) a.gone = true;
this.view.syncAgents(this.crowd.agents);
this.view.setFight(null, 0, 0);
this.view.setDowned(null);
// The lights: a wash over the whole room. Ugly on purpose — closing
// lights are never flattering.
this.add
.rectangle((MAP_W * 16) / 2, (MAP_H * 16) / 2, MAP_W * 16, MAP_H * 16, 0xfff2d8, 0.16)
.setDepth(38);
// What the night dropped, scattered anywhere feet could reach.
const rs = this.rng.stream('sweep');
const drops = rollDrops(rs, this.crowd.agents.length);
this.sweepDrops = drops.map((drop) => {
let tx = 40;
let ty = 22;
for (let tries = 0; tries < 60; tries++) {
tx = rs.int(2, MAP_W - 3);
ty = rs.int(2, MAP_H - 3);
if (isWalkable(VENUE, tx, ty)) break;
}
const { x, y } = tileToWorld(tx, ty);
const gfx = this.add.container(x, y).setDepth(39);
gfx.add(this.add.circle(0, 0, 4, 0x000000, 0.25).setScale(1, 0.5));
gfx.add(this.add.circle(0, -2, 3, drop.kind === 'baggie' ? 0xa0e0a0 : 0xffe080));
this.tweens.add({ targets: gfx, scale: { from: 1, to: 1.25 }, duration: 600, yoyo: true, repeat: -1 });
return { drop, x, y, gfx };
});
this.toast(SWEEP_UI.open);
this.promptText.setText(SWEEP_UI.open);
this.logIncident('lightsOn', undefined, `Close. ${drops.length} things on the floor.`);
}
private tickSweep(dt: number): void {
if (!this.baggiePending) {
const input = this.readInput();
this.player = stepPlayer(this.player, input, dt, VENUE);
}
this.cameras.main.centerOn(this.player.x, this.player.y);
this.view.updateLights(dt);
this.view.updatePlayer(this.player.x, this.player.y, this.player.facing);
// Prompt: the bag first, then the nearest find, then the way out.
if (this.baggiePending) {
this.promptText.setText(SWEEP_UI.baggiePrompt);
return;
}
const near = this.nearestDrop();
if (near) {
this.promptText.setText(`${SWEEP_UI.pickup}${near.drop.label}`);
return;
}
const entry = VENUE.anchors.entry[0];
const atExit =
entry && Phaser.Math.Distance.Between(this.player.x, this.player.y, entry.x, entry.y) <= INTERACT_RANGE;
this.promptText.setText(atExit ? SWEEP_UI.leave : this.sweepDrops.length === 0 ? SWEEP_UI.lastOne : '');
}
private nearestDrop(): (typeof this.sweepDrops)[number] | null {
let best: (typeof this.sweepDrops)[number] | null = null;
let bestD = INTERACT_RANGE;
for (const d of this.sweepDrops) {
const dist = Phaser.Math.Distance.Between(this.player.x, this.player.y, d.x, d.y);
if (dist < bestD) {
bestD = dist;
best = d;
}
}
return best;
}
private sweepInteract(): void {
const near = this.nearestDrop();
if (near) return this.collectDrop(near);
const entry = VENUE.anchors.entry[0];
if (entry && Phaser.Math.Distance.Between(this.player.x, this.player.y, entry.x, entry.y) <= INTERACT_RANGE) {
this.finishSweep();
}
}
private collectDrop(d: (typeof this.sweepDrops)[number]): void {
d.gfx.destroy();
this.sweepDrops = this.sweepDrops.filter((x) => x !== d);
const drop = d.drop;
if (drop.kind === 'baggie') {
this.baggiePending = drop;
this.toast(drop.label);
return;
}
if (drop.kind === 'phone') {
this.logIncident('lostProperty', undefined, 'A phone off the floor, into lost property.');
this.toast(SWEEP_UI.phoneFound);
} else {
this.sweepCash += drop.value;
this.toast(drop.label);
this.nightCtx?.sfx?.play('clickerClunk');
}
if (this.sweepDrops.length === 0) this.time.delayedCall(1200, () => this.toast(SWEEP_UI.lastOne));
}
private resolveBaggie(pocket: boolean): void {
this.baggiePending = null;
if (pocket) {
this.logIncident('baggiePocketed', undefined, 'Contraband off the floor. Retained by security.');
this.toast(SWEEP_UI.baggiePocketed);
} else {
this.logIncident('baggieBinned', undefined, 'Contraband off the floor, into the amnesty bin.');
this.toast(SWEEP_UI.baggieBinned);
}
}
private finishSweep(): void {
if (!this.sweeping) return;
this.sweeping = false;
if (this.sweepCash > 0) {
this.logIncident('floorScore', undefined, `Swept the floor: $${this.sweepCash} found.`);
}
this.toast(SWEEP_UI.done.replace('{cash}', String(this.sweepCash)));
const cb = this.sweepDone;
this.sweepDone = null;
this.time.delayedCall(900, () => cb?.({ cash: this.sweepCash }));
}
// ---- radio command (design §6, Head of Security) -----------------------
/** Floor-side radio keys: ack + result toasts land here. */
private radioFloor(kind: FloorOrderKind): void {
const ack = this.execStaffOrder(kind, (msg) => {
if (this.present) this.toast(msg);
});
if (ack === 'sent') this.toast(kind === 'mess' ? STAFF_RADIO.bumpAck : STAFF_RADIO.shanAck);
else if (ack === 'cooldown') this.toast(STAFF_RADIO.cooldown);
else this.toast(kind === 'mess' ? STAFF_RADIO.bumpNothing : STAFF_RADIO.shanNothing);
}
private radioKayden(): void {
const ack = this.nightCtx?.kaydenHold?.();
if (ack === 'sent') this.toast(STAFF_RADIO.kaydenAck);
else if (ack === 'cooldown') this.toast(STAFF_RADIO.kaydenCooldown);
}
/**
* An order goes out over the radio; a staffer crosses a real floor to do it.
* `notify` carries the completion line back to whichever scene asked.
*/
private execStaffOrder(kind: FloorOrderKind, notify: (msg: string) => void): OrderAck {
if (this.elapsedMs < (this.staffReadyAt.get(kind) ?? 0)) return 'cooldown';
const rs = this.rng.stream('staff');
if (kind === 'mess') {
const pud = this.puddles[0];
if (!pud) return 'nothing';
this.staffReadyAt.set(kind, this.elapsedMs + RADIO_COOLDOWN_MS.mess);
const targetId = pud.id;
this.time.delayedCall(orderLag(rs), () => {
const p = this.puddles.find((q) => q.id === targetId);
if (!p) return; // the player got there first; bump keeps the credit anyway
if (rollBumpMops(rs)) {
p.gfx.destroy();
this.puddles = this.puddles.filter((q) => q.id !== targetId);
this.bus.emit('meters:delta', { vibe: ORDER_DONE_VIBE });
this.logIncident('radioOrder', undefined, 'Bump: puddle mopped and signed, in that order.');
notify(STAFF_RADIO.bumpDone);
} else if (!p.signed) {
p.signed = true;
if (this.textures.exists('gen:prop:wetFloor')) {
p.gfx.add(this.add.image(0, -8, 'gen:prop:wetFloor').setDisplaySize(14, 18));
} else {
p.gfx.add(this.add.triangle(0, -8, 0, 12, 6, 0, 12, 12, 0xd8c23a, 0.9));
}
this.logIncident('radioOrder', undefined, 'Bump: signed the puddle, then got distracted.');
notify(STAFF_RADIO.bumpDistracted);
} else {
notify(STAFF_RADIO.bumpDistracted);
}
});
return 'sent';
}
// Shan: the worst upright drunk gets a firm two cups.
let worst: Agent | null = null;
for (const a of this.crowd.agents) {
if (a.gone || a.handled || a.activity === 'escorted' || a.activity === 'inStall') continue;
if (a.patron.intoxication < SHAN_MIN_INTOX) continue;
if (!worst || a.patron.intoxication > worst.patron.intoxication) worst = a;
}
if (!worst) return 'nothing';
this.staffReadyAt.set(kind, this.elapsedMs + RADIO_COOLDOWN_MS.water);
const target = worst;
this.time.delayedCall(orderLag(rs), () => {
if (target.gone || target.handled) return;
if (rollShanClean(rs)) {
target.patron.intoxication = Math.max(0, target.patron.intoxication - SHAN_WATER_STEP);
this.maggotSeen.delete(target.patron.id);
this.cutOffDealt.set(target.patron.id, drunkStage(target.patron.intoxication));
this.bus.emit('meters:delta', { vibe: ORDER_DONE_VIBE });
this.logIncident('radioOrder', target.patron.id, 'Shan: water in, no negotiation.');
notify(STAFF_RADIO.shanDone);
} else {
this.bus.emit('meters:delta', { aggro: SHAN_BOTCH_AGGRO });
this.logIncident('radioOrder', target.patron.id, 'Shan led with "you\'ve had enough". Scenes.');
notify(STAFF_RADIO.shanBotch);
}
});
return 'sent';
}
/** Build fizzle + booth requests. Only while the player is on the decks. */
private tickDecks(dt: number): void {
if (!this.djMode) return;
if (this.djBuild && this.elapsedMs - this.djBuild.armedAtMs > BUILD_FIZZLE_BARS * 4 * this.beatMs) {
this.djResolveBuild(null);
}
if (this.overlayOpen) return;
const rq = this.rng.stream('djRequests');
// 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;
// The shoe is REAL money. It used to be a number that only ever reached
// a toast, which made taking cash pure downside (you still carried the
// sting roll) — an inversion of §4.3 just as dishonest as making it
// secretly optimal. Now it pays, and the temptation is a real one.
this.nightCtx?.bankCash(amount, 'booth tip');
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: s.line,
patron: asker.patron,
choices: [
{ id: 'play', label: DJ_REQUEST_UI.play, detail: DJ_REQUEST_UI.playDetail },
{ id: 'nah', label: DJ_REQUEST_UI.nah, detail: DJ_REQUEST_UI.nahDetail },
{ id: 'later', label: DJ_REQUEST_UI.later, detail: DJ_REQUEST_UI.laterDetail },
],
},
(id) => {
if (id === null) return; // you stared at the crossfader until they left
if (id === 'play') {
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 ${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 ${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([{
atClockMin: this.clock.clockMin + 8 + rq.int(0, 12),
aggroDelta: 2,
reason: 'a deferred booth request came back with interest',
patronId: asker.patron.id,
}]);
}
this.toast(DJ_REQUEST_UI.laterQuiet);
}
},
);
}
/**
* Everything on the floor that happens TO the room rather than through the
* player: vomit + slips, the filming influencer's timer, the fainter. Runs
@ -1937,13 +1140,7 @@ export class FloorDemoScene extends Phaser.Scene {
this.fainter = null;
this.view.setDowned(null);
const a = this.crowd.agents.find((x) => x.patron.id === f.agentId);
if (a && !a.gone) {
a.gone = true;
// They left in a taxi or an ambulance, but they LEFT — say so, or the
// clicker keeps counting a body that is not in the room and the night
// drifts toward an over-capacity strike nobody can explain.
this.bus.emit('floor:leave', { patronId: a.patron.id });
}
if (a) a.gone = true;
if (success) {
this.bus.emit('meters:delta', { vibe: -3, aggro: -3 });
this.logIncident('firstAid', f.agentId, 'Recovery position. Walked out the back to a taxi.');

View File

@ -1,152 +0,0 @@
// 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 };
}
// ---- 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))];
// FisherYates 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;
}

View File

@ -447,12 +447,6 @@ 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

@ -1,95 +0,0 @@
// The DJ shift (design §6 role ladder, previewed as a floor interaction the way
// the glassie run was). Pure numbers + judgement here, tested; the scene owns
// sprites, keys and the TechnoEngine hookup.
//
// The shape of the fantasy: the resident needs a minute, you hold the decks.
// BUILD pulls the bass out and the room leans in; DROP is judged against the
// bar line the engine is actually playing. Requests arrive mid-set and every
// answer costs something (§4.3 — being the fun DJ and being the good DJ are
// different jobs).
export type DropVerdict = 'clean' | 'late' | 'clang';
/** Inside this of a bar boundary is a clean drop. */
export const DROP_CLEAN_MS = 140;
/** Beyond clean but inside this is a stumble the room forgives. */
export const DROP_LATE_MS = 320;
/**
* Judge a drop from its distance to the NEAREST bar line (early or late are the
* same offence the room only hears wrong).
*/
export function judgeDrop(msToNearestBar: number): DropVerdict {
const d = Math.abs(msToNearestBar);
if (d <= DROP_CLEAN_MS) return 'clean';
if (d <= DROP_LATE_MS) return 'late';
return 'clang';
}
/**
* Distance to the nearest bar boundary, from the current beat index within the
* bar and ms elapsed since that beat. Bars are 4 beats.
*/
export function msToNearestBar(beatInBar: number, msSinceBeat: number, beatMs: number): number {
const intoBar = beatInBar * beatMs + msSinceBeat;
const barMs = 4 * beatMs;
const behind = intoBar; // how late past the last bar line
const ahead = barMs - intoBar; // how early before the next
return behind <= ahead ? behind : -ahead;
}
/** A build that never drops runs out of stairs after this many bars. */
export const BUILD_FIZZLE_BARS = 4;
// ---- outcomes (§4.3: priced, never graded) ----------------------------------
export const DROP_PAYOUT: Record<DropVerdict, { vibe: number; hype: number; aggro: number }> = {
clean: { vibe: 4, hype: 0.4, aggro: 0 },
late: { vibe: 1, hype: 0.1, aggro: 0 },
clang: { vibe: -2, hype: 0, aggro: 1 },
};
export const BUILD_HYPE = 0.1;
export const FIZZLE_VIBE = -2;
/** Per-request odds the floor is WITH it. The rest of the request lives in
* data/strings/floor.ts (DJ_REQUESTS) keyed by the same id. */
export const REQUEST_HIT: Record<string, number> = {
dnb: 0.75,
nan: 0.5,
birthday: 0.65,
shazam: 0.55,
slower: 0.4,
again: 0.7,
};
export const REQUEST_PLAY_HIT = { vibe: 3, hype: 0.2 };
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;
}

View File

@ -1,101 +0,0 @@
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 010 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);
}
}

View File

@ -300,35 +300,6 @@ export const VENUE: VenueMap = Object.freeze({
stalls: Object.freeze(buildStalls()),
});
/**
* The tiles a role PLANTS the player on. The floor scene teleports the player
* here, so each one must be walkable and must have a way out a post on a
* sealed tile is not a bug you catch in review, it is a night the player spends
* frozen in place. Two shipped that way: the taps handed you back onto a
* `stool`, and the decks stood you inside the sealed `djbooth`.
*/
export const POSTS: Readonly<Record<string, TilePoint>> = Object.freeze({
/** Behind the till: where you hold the taps. */
taps: { tx: 47, ty: 3 },
/** The DJ booth's WEST COUNTER — the gear faces across it, per the map above. */
decks: { tx: 51, ty: 22 },
});
/**
* Interaction anchors the player walks UP TO rather than stands on. These are
* allowed to sit inside furniture (a sink, a counter, a dishwasher) what they
* must have is somewhere walkable within reach to stand while using them.
*/
export const PROBES: Readonly<Record<string, TilePoint>> = Object.freeze({
/** The punter side of the till, where E offers you the apron. */
tapsFront: { tx: 47, ty: 7 },
/** The glassie run: full racks appear here and go to the dishwasher. */
rack: { tx: 51, ty: 7 },
dishwasher: { tx: 17, ty: 3 },
/** The water station (RSA duty) — the sink row itself. */
water: { tx: 68, ty: 15 },
});
export function tileAt(map: VenueMap, tx: number, ty: number): TileKind {
if (tx < 0 || tx >= map.width || ty < 0 || ty >= map.height) return 'void';
return map.tiles[ty * map.width + tx] ?? 'void';

View File

@ -1,99 +0,0 @@
import Phaser from 'phaser';
import { freshGameState, loadGame } from '../../core/save';
import { TOTAL_NIGHTS } from '../door/NightScene';
import { venueByIndex } from '../../data/venues';
import { rolesFor } from '../../data/roster';
// The roster board (design §6): role × venue = level select. A corkboard in
// the back office; tonight's shifts are pinned to it; you take one. The night
// itself is the same simulation whichever card you pull — the card is where
// you STAND when the doors open, and what the board admits it pays.
const W = 640;
const H = 360;
const MONO = 'monospace';
const NIGHT_NAMES = ['thursday', 'friday', 'saturday'] as const;
const CARD_W = 168;
const CARD_H = 150;
interface PassThrough {
seed?: number;
nightIndex?: number;
freshRun?: boolean;
}
export class RosterScene extends Phaser.Scene {
private carried: PassThrough = {};
constructor() {
super('Roster');
}
init(data: PassThrough): void {
this.carried = data ?? {};
}
create(): void {
// Display only — NightScene remains the authority on run state (it will
// clearSave on freshRun itself; we just show the board that run implies).
const seed = this.carried.seed ?? 4207;
const run = this.carried.freshRun ? freshGameState(seed) : loadGame(seed);
let nightIndex = this.carried.nightIndex ?? run.nightIndex;
if (nightIndex >= TOTAL_NIGHTS) nightIndex = 0;
const venue = venueByIndex(run.venueIndex);
const roles = rolesFor(venue.id);
// the back office: unpainted, honest
this.add.rectangle(W / 2, H / 2, W, H, 0x14100c);
this.add.rectangle(W / 2, H / 2 - 8, 560, 280, 0x3a2a18).setStrokeStyle(3, 0x241a10);
this.add
.text(W / 2, 42, `${venue.name}${NIGHT_NAMES[nightIndex] ?? ''}`, {
fontFamily: MONO, fontSize: '16px', color: '#f0e4d0',
})
.setOrigin(0.5);
this.add
.text(W / 2, 62, 'THE ROSTER · take a shift', {
fontFamily: MONO, fontSize: '8px', color: '#a89878',
})
.setOrigin(0.5);
const totalW = roles.length * CARD_W + (roles.length - 1) * 16;
const x0 = (W - totalW) / 2 + CARD_W / 2;
roles.forEach((role, i) => {
const x = x0 + i * (CARD_W + 16);
const y = H / 2 + 22;
const tilt = ((i * 7) % 5) - 2; // pinned by someone in a hurry
const card = this.add.container(x, y).setAngle(tilt);
const bg = this.add.rectangle(0, 0, CARD_W, CARD_H, 0xe8dcc0).setStrokeStyle(1, 0x9a8a68);
card.add(bg);
card.add(this.add.circle(0, -CARD_H / 2 + 7, 3, 0xa03028)); // the pin
card.add(
this.add.text(0, -CARD_H / 2 + 24, role.name, {
fontFamily: MONO, fontSize: '11px', color: '#241a10', align: 'center', wordWrap: { width: CARD_W - 16 },
}).setOrigin(0.5, 0),
);
card.add(
this.add.text(0, -CARD_H / 2 + 58, `$${role.wage} the night`, {
fontFamily: MONO, fontSize: '9px', color: '#5a4a30',
}).setOrigin(0.5, 0),
);
card.add(
this.add.text(0, -CARD_H / 2 + 80, role.blurb, {
fontFamily: MONO, fontSize: '7px', color: '#40342a', align: 'center', wordWrap: { width: CARD_W - 20 },
}).setOrigin(0.5, 0),
);
bg.setInteractive({ useHandCursor: true });
bg.on('pointerover', () => card.setScale(1.04));
bg.on('pointerout', () => card.setScale(1));
bg.on('pointerdown', () => this.scene.start('Night', { ...this.carried, role: role.id }));
});
this.add
.text(W / 2, H - 20, `seed ${run.seed} · week ${run.venueIndex + 1}/4`, {
fontFamily: MONO, fontSize: '7px', color: '#6a5a44',
})
.setOrigin(0.5);
}
}

View File

@ -1,75 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
ACTIVE_RULE_CAP,
DRESS_CODE_RULES,
SHUFFLE_EVERY_MIN,
SHUFFLE_RULE_COUNT,
activeRules,
announcedRules,
setDressCodeShuffle,
shuffleWindow,
violations,
} from '../src/rules/dressCode';
import { generatePatron, _resetPatronSerial } from '../src/patrons/generator';
import { SeededRNG } from '../src/core/SeededRNG';
// Module-level mode: ALWAYS clear after each test or the whole suite goes to ROOM.
afterEach(() => setDressCodeShuffle(null));
const ids = (clockMin: number): string[] => activeRules(clockMin).map((r) => r.id);
describe('ROOM dress-code shuffle', () => {
it('windows turn every SHUFFLE_EVERY_MIN', () => {
expect(shuffleWindow(0)).toBe(0);
expect(shuffleWindow(SHUFFLE_EVERY_MIN - 1)).toBe(0);
expect(shuffleWindow(SHUFFLE_EVERY_MIN)).toBe(1);
expect(shuffleWindow(SHUFFLE_EVERY_MIN * 4 + 3)).toBe(4);
});
it('serves SHUFFLE_RULE_COUNT real rules, stable within a window', () => {
setDressCodeShuffle(4207);
const a = ids(0);
expect(a).toHaveLength(SHUFFLE_RULE_COUNT);
expect(SHUFFLE_RULE_COUNT).toBeLessThanOrEqual(ACTIVE_RULE_CAP);
for (const id of a) expect(DRESS_CODE_RULES.some((r) => r.id === id)).toBe(true);
// re-reading the board mid-window must not reroll it
expect(ids(SHUFFLE_EVERY_MIN - 1)).toEqual(a);
});
it('rewrites across windows and is deterministic per seed', () => {
setDressCodeShuffle(4207);
const windows = Array.from({ length: 8 }, (_, w) => ids(w * SHUFFLE_EVERY_MIN));
// Some consecutive pair must differ — a board that never changes isn't ROOM.
expect(windows.some((w, i) => i > 0 && w.join() !== windows[i - 1]!.join())).toBe(true);
// Same seed replays the same night.
setDressCodeShuffle(4207);
expect(ids(SHUFFLE_EVERY_MIN * 3)).toEqual(windows[3]);
// A different seed is a different board.
setDressCodeShuffle(999);
const other = Array.from({ length: 8 }, (_, w) => ids(w * SHUFFLE_EVERY_MIN));
expect(other.join('|')).not.toBe(windows.join('|'));
});
it('announcedRules mirrors the live set — nothing greyed, nothing historical', () => {
setDressCodeShuffle(4207);
expect(announcedRules(200).map((r) => r.id)).toEqual(ids(200));
});
it('clearing the mode restores the Dazza chronology', () => {
setDressCodeShuffle(4207);
setDressCodeShuffle(null);
expect(activeRules(0)).toEqual(DRESS_CODE_RULES.filter((r) => r.activeFrom <= 0).slice(-ACTIVE_RULE_CAP));
});
it('violations() judges against the shuffled set', () => {
_resetPatronSerial();
const rng = new SeededRNG(7);
const ctx = { rng, nightDate: new Date('2026-07-16T00:00:00') };
setDressCodeShuffle(4207);
for (let i = 0; i < 40; i++) {
const p = generatePatron(ctx, 30);
const live = new Set(ids(30));
for (const v of violations(p, 30)) expect(live.has(v.id)).toBe(true);
}
});
});

View File

@ -1,168 +0,0 @@
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';
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('judgePour', () => {
it('short below the line, clean in the band, overflow past it', () => {
expect(judgePour(0)).toBe('short');
expect(judgePour(POUR_CLEAN_FROM - 0.01)).toBe('short');
expect(judgePour(POUR_CLEAN_FROM)).toBe('clean');
expect(judgePour(POUR_OVER_FROM - 0.01)).toBe('clean');
expect(judgePour(POUR_OVER_FROM)).toBe('overflow');
expect(judgePour(1)).toBe('overflow');
});
});
describe('pourAdjust', () => {
const base = judgeCall('serve', 'tipsy');
it('a clean pour changes nothing', () => {
expect(pourAdjust(base, 'clean')).toEqual(base);
});
it('a short pour: no thanks, a look, half a drink', () => {
const out = pourAdjust(base, 'short');
expect(out.vibe).toBeUndefined();
expect(out.aggro).toBe(1);
expect(out.intoxDelta).toBe(SERVE_DRINK_STEP / 2);
});
it('overflow: the room approves, the patron wears more of it', () => {
const out = pourAdjust(base, 'overflow');
expect(out.vibe).toBe(2);
expect(out.intoxDelta).toBeCloseTo(SERVE_DRINK_STEP + OVERFLOW_EXTRA_INTOX);
});
it('the breach flag rides the SERVE, not the pour', () => {
const breach = judgeCall('serve', 'maggot');
expect(pourAdjust(breach, 'short').breach).toBe(true);
expect(pourAdjust(breach, 'overflow').breach).toBe(true);
});
});
describe('tabs', () => {
it('sober people pay as they go', () => {
expect(canOpenTab('sober')).toBe(false);
for (const s of ['tipsy', 'loose', 'messy', 'maggot'] as const) expect(canOpenTab(s)).toBe(true);
});
it('cardLineup always contains the right card, plus up to two strangers', () => {
const rng = new SeededRNG(11).stream('t');
for (let i = 0; i < 20; i++) {
const lineup = cardLineup('Jai Doyle', ['Mia Chen', 'Lou Reed', 'Jai Doyle', 'Sam Fox'], rng);
expect(lineup).toContain('Jai Doyle');
expect(lineup.length).toBe(3);
expect(new Set(lineup).size).toBe(3);
}
});
it('the right card is not always slot one', () => {
const rng = new SeededRNG(11).stream('t');
const slots = new Set<number>();
for (let i = 0; i < 30; i++) {
slots.add(cardLineup('A', ['B', 'C', 'D', 'E'], rng).indexOf('A'));
}
expect(slots.size).toBeGreaterThan(1);
});
it('survives an empty jar — a lineup of one is still a lineup', () => {
const rng = new SeededRNG(3).stream('t');
expect(cardLineup('A', [], rng)).toEqual(['A']);
});
});
describe('BAR_ORDER_LINES', () => {
it('every stage has lines — the order IS the sobriety test', () => {
for (const stage of STAGES) expect(BAR_ORDER_LINES[stage].length).toBeGreaterThan(0);
});
});

View File

@ -1,94 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
BUILD_FIZZLE_BARS,
DROP_CLEAN_MS,
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', () => {
it('rewards the bar line, forgives near misses, punishes the clang', () => {
expect(judgeDrop(0)).toBe('clean');
expect(judgeDrop(DROP_CLEAN_MS)).toBe('clean');
expect(judgeDrop(-DROP_CLEAN_MS)).toBe('clean');
expect(judgeDrop(DROP_CLEAN_MS + 1)).toBe('late');
expect(judgeDrop(-DROP_LATE_MS)).toBe('late');
expect(judgeDrop(DROP_LATE_MS + 1)).toBe('clang');
});
it('early and late are the same offence — the room only hears wrong', () => {
for (const d of [50, 200, 500]) expect(judgeDrop(d)).toBe(judgeDrop(-d));
});
});
describe('msToNearestBar', () => {
const BEAT = 500; // 120bpm
it('is zero exactly on the bar', () => {
expect(msToNearestBar(0, 0, BEAT)).toBe(0);
});
it('measures behind the last bar early in the bar', () => {
expect(msToNearestBar(0, 120, BEAT)).toBe(120);
expect(msToNearestBar(1, 0, BEAT)).toBe(500);
});
it('measures ahead (negative) late in the bar', () => {
expect(msToNearestBar(3, 400, BEAT)).toBe(-(4 * BEAT - (3 * BEAT + 400)));
expect(msToNearestBar(3, 499, BEAT)).toBe(-1);
});
it('a mid-bar drop is maximally wrong from either side', () => {
expect(Math.abs(msToNearestBar(2, 0, BEAT))).toBe(2 * BEAT);
});
});
describe('the shift is priced, not graded', () => {
it('a clean drop pays, a clang costs — and neither is free', () => {
expect(DROP_PAYOUT.clean.vibe).toBeGreaterThan(0);
expect(DROP_PAYOUT.clang.vibe).toBeLessThan(0);
expect(DROP_PAYOUT.clang.aggro).toBeGreaterThan(0);
expect(DROP_PAYOUT.late.vibe).toBeLessThan(DROP_PAYOUT.clean.vibe);
});
it('a build cannot be held forever', () => {
expect(BUILD_FIZZLE_BARS).toBeLessThanOrEqual(8);
});
});
describe('booth requests', () => {
it('every scripted request has odds, and every odds entry has a script', () => {
const scriptIds = DJ_REQUESTS.map((r) => r.id);
expect(new Set(scriptIds).size).toBe(scriptIds.length);
for (const id of scriptIds) {
expect(REQUEST_HIT[id], `no odds for '${id}'`).toBeGreaterThan(0);
expect(REQUEST_HIT[id]).toBeLessThan(1); // nothing is a sure thing at the booth
}
for (const id of Object.keys(REQUEST_HIT)) {
expect(scriptIds).toContain(id);
}
});
});
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);
});
});

View File

@ -1,90 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
POSTS,
PROBES,
VENUE,
isWalkable,
tileAt,
tileToWorld,
type TilePoint,
} from '../../src/scenes/floor/venueMap';
import { freshPlayer, stepPlayer, type PlayerInput } from '../../src/scenes/floor/player';
// Two run-ending softlocks shipped from one mistake: a role stood the player on
// a tile the player cannot stand on. Handing the taps back dropped you on a
// `stool`; the DJ post stood you inside the sealed `djbooth`. Every direction
// blocked, for the rest of the night — and both roster roles mount their post
// automatically, so it was a single keypress from an unplayable shift.
//
// These tests are the guard rail, and they are deliberately about MOVEMENT
// rather than tile names: the question is never "what kind of tile is this", it
// is "can the player actually leave".
const DIRECTIONS: ReadonlyArray<[string, PlayerInput]> = [
['up', { up: true, down: false, left: false, right: false }],
['down', { up: false, down: true, left: false, right: false }],
['left', { up: false, down: false, left: true, right: false }],
['right', { up: false, down: false, left: false, right: true }],
];
/** Distance the player covers from `at` in one second of 60fps frames. */
function travel(at: TilePoint, input: PlayerInput): number {
const world = tileToWorld(at.tx, at.ty);
const start = { ...freshPlayer(world.x, world.y) };
let p = start;
for (let i = 0; i < 60; i++) p = stepPlayer(p, input, 16.67, VENUE);
return Math.hypot(p.x - world.x, p.y - world.y);
}
/** Interaction range in FloorDemoScene. A probe needs somewhere to stand within it. */
const INTERACT_RANGE = 46;
describe('posts — tiles a role stands the player ON', () => {
it('are walkable', () => {
for (const [name, at] of Object.entries(POSTS)) {
expect(
isWalkable(VENUE, at.tx, at.ty),
`post '${name}' is a '${tileAt(VENUE, at.tx, at.ty)}' tile, which is not walkable`,
).toBe(true);
}
});
it('can be walked off — the softlock test', () => {
for (const [name, at] of Object.entries(POSTS)) {
const best = Math.max(...DIRECTIONS.map(([, input]) => travel(at, input)));
expect(best, `post '${name}' traps the player: no direction moves them`).toBeGreaterThan(8);
}
});
});
describe('probes — anchors the player walks UP TO', () => {
it('have somewhere walkable to stand within interact range', () => {
const reach = Math.ceil(INTERACT_RANGE / 16);
for (const [name, at] of Object.entries(PROBES)) {
const world = tileToWorld(at.tx, at.ty);
let found = false;
for (let dx = -reach; dx <= reach && !found; dx++) {
for (let dy = -reach; dy <= reach && !found; dy++) {
const tx = at.tx + dx;
const ty = at.ty + dy;
if (!isWalkable(VENUE, tx, ty)) continue;
const w = tileToWorld(tx, ty);
if (Math.hypot(w.x - world.x, w.y - world.y) <= INTERACT_RANGE) found = true;
}
}
expect(found, `probe '${name}' has no walkable tile within ${INTERACT_RANGE}px — unreachable`).toBe(true);
}
});
});
describe('the tiles those softlocks landed on', () => {
it('a bar stool is still not walkable — the taps must never hand you back onto one', () => {
expect(tileAt(VENUE, 47, 6)).toBe('stool');
expect(isWalkable(VENUE, 47, 6)).toBe(false);
});
it('the DJ booth interior is still sealed — the decks post belongs at its counter', () => {
expect(isWalkable(VENUE, 55, 22)).toBe(false);
expect(isWalkable(VENUE, POSTS.decks!.tx, POSTS.decks!.ty)).toBe(true);
});
});

View File

@ -1,47 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
DROPS_BASE,
DROPS_MAX,
NOTE_VALUES,
rollDrops,
} from '../src/rules/floorScore';
import { SeededRNG } from '../src/core/SeededRNG';
describe('the floor score', () => {
it('a busier night is a messier floor, up to a limit', () => {
const rng = new SeededRNG(1);
expect(rollDrops(rng.stream('a'), 0).length).toBe(DROPS_BASE);
expect(rollDrops(rng.stream('b'), 24).length).toBe(DROPS_BASE + 4);
expect(rollDrops(rng.stream('c'), 500).length).toBe(DROPS_MAX);
});
it('is deterministic per stream — the same night drops the same floor', () => {
const a = rollDrops(new SeededRNG(7).stream('sweep'), 40);
const b = rollDrops(new SeededRNG(7).stream('sweep'), 40);
expect(a).toEqual(b);
});
it('cash is real denominations; curios, phones and baggies are worth $0', () => {
for (let seed = 1; seed <= 20; seed++) {
for (const d of rollDrops(new SeededRNG(seed).stream('s'), 60)) {
if (d.kind === 'note') expect(NOTE_VALUES).toContain(d.value);
else if (d.kind === 'coins') expect(d.value).toBeGreaterThan(0);
else expect(d.value).toBe(0);
}
}
});
it('at most one baggie and one phone per night', () => {
for (let seed = 1; seed <= 30; seed++) {
const drops = rollDrops(new SeededRNG(seed).stream('s'), 60);
expect(drops.filter((d) => d.kind === 'baggie').length).toBeLessThanOrEqual(1);
expect(drops.filter((d) => d.kind === 'phone').length).toBeLessThanOrEqual(1);
}
});
it('every drop has words — the label IS the reward for curios', () => {
for (const d of rollDrops(new SeededRNG(11).stream('s'), 30)) {
expect(d.label.length).toBeGreaterThan(5);
}
});
});

View File

@ -1,38 +0,0 @@
import { describe, expect, it } from 'vitest';
import { lipReadLine } from '../src/rules/lipRead';
describe('lipReadLine', () => {
it('is deterministic — re-asking does not reroll reality', () => {
const line = 'mate im on the list i promise you im on the list';
expect(lipReadLine(line, 'p1')).toBe(lipReadLine(line, 'p1'));
});
it('different patrons hear the room differently', () => {
const line = 'im on the list i swear on my nan';
const guesses = new Set(['p1', 'p2', 'p3', 'p4', 'p5'].map((s) => lipReadLine(line, s)));
expect(guesses.size).toBeGreaterThan(1);
});
it('every token is a bracketed uppercase guess', () => {
const out = lipReadLine('mate the owner literally invited me', 'p9');
for (const tok of out.split(' ')) {
expect(tok.startsWith('[')).toBe(true);
expect(tok.endsWith(']')).toBe(true);
expect(tok).toBe(tok.toUpperCase());
}
});
it('shows real uncertainty across a long line — not a clean transcript', () => {
const out = lipReadLine(
'listen i know the promoter and the promoter knows the owner and honestly the whole situation is very much under control',
'p2',
);
expect(out).toContain('?');
});
it('never emits an empty guess', () => {
expect(lipReadLine('', 'p1')).toBe('[???]');
expect(lipReadLine('...', 'p1')).toBe('[???]');
expect(lipReadLine('*points*', 'p1').length).toBeGreaterThan(0);
});
});

View File

@ -22,10 +22,6 @@ const log = (over: Partial<NightLog> = {}): NightLog => ({
heatStrikes: [],
endReason: 'clock',
seed: 1,
role: 'DOOR',
wage: 180,
floorCash: 0,
pocketCash: 0,
...over,
});
@ -129,26 +125,3 @@ describe('shouldRecordStrike', () => {
expect(shouldRecordStrike(s, { reason: 'over capacity', deferred: true })).toBe(false);
});
});
describe('the booth shoe', () => {
it('is real money — cash taken at the decks reaches the pay line', () => {
const without = nightCash(log({ pocketCash: 0 }), freshNightState('theRoyal', 0, 80));
const withTips = nightCash(log({ pocketCash: 50 }), freshNightState('theRoyal', 0, 80));
expect(withTips - without).toBe(50);
});
it('cannot be eaten by strikes — a deduction takes the wage, not your pocket', () => {
// Three strikes wipes the wage to the Math.max(0, ...) floor. Whatever a
// punter physically handed you is still in your pocket at the end of it.
const state = freshNightState('theRoyal', 0, 80);
state.heatStrikes = [
{ reason: 'a', deferred: true },
{ reason: 'b', deferred: true },
{ reason: 'c', deferred: true },
];
const broke = nightCash(log({ wage: 120, pocketCash: 0 }), state);
const tipped = nightCash(log({ wage: 120, pocketCash: 40 }), state);
expect(broke).toBe(0);
expect(tipped).toBe(40);
});
});

View File

@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest';
import { EventBus } from '../src/core/EventBus';
import { SeededRNG } from '../src/core/SeededRNG';
import { _resetPatronSerial } from '../src/patrons/generator';
import { CARPET_TUNING, QueueManager, QUEUE_TUNING, entranceBonus } from '../src/scenes/door/QueueManager';
import { QueueManager, QUEUE_TUNING } from '../src/scenes/door/QueueManager';
import type { Patron } from '../src/data/types';
const NIGHT = new Date('2026-07-18T00:00:00');
@ -289,85 +289,3 @@ describe('QueueManager — determinism', () => {
expect(nightFingerprint(1)).not.toEqual(nightFingerprint(2));
});
});
describe('the red carpet', () => {
const carpetSetup = () => {
const s = setup(9);
fillTo(s.q, 6);
return s;
};
it('toCarpet parks the up patron and frees the rope', () => {
const { q } = carpetSetup();
const p = q.callNext()!;
expect(q.toCarpet()?.id).toBe(p.id);
expect(q.patronUp).toBeNull();
expect(q.carpetSnapshot.length).toBe(1);
expect(q.callNext()).not.toBeNull(); // the rope keeps working
});
it('the brass posts only stretch to CARPET_TUNING.slots', () => {
const { q } = carpetSetup();
for (let i = 0; i < CARPET_TUNING.slots; i++) {
expect(q.callNext()).not.toBeNull();
expect(q.toCarpet()).not.toBeNull();
}
expect(q.callNext()).not.toBeNull();
expect(q.toCarpet()).toBeNull(); // full — the fourth stays at the rope
expect(q.patronUp).not.toBeNull();
});
it('bodies on display pay hype per second, on top of the queue theatre', () => {
const { q, total } = carpetSetup();
q.callNext();
q.toCarpet();
// Rope free + line waiting: ordinary queue theatre still flows. The
// carpet's dividend is ADDITIVE — that is the whole point of the posts.
const before = total('hype');
q.update(1000);
expect(total('hype') - before).toBeCloseTo(
CARPET_TUNING.hypePerSecPerBody + QUEUE_TUNING.hypePerSec,
3,
);
});
it('patience runs out: a storm-off, aggro, and the slot comes back', () => {
const { q, total } = carpetSetup();
const p = q.callNext()!;
q.toCarpet();
const before = total('aggro');
// Worst-case patience is base-max × the biggest archetype scale.
q.update(CARPET_TUNING.patienceSecBase[1] * 1.8 * 1000 + 1000);
expect(q.carpetSnapshot.length).toBe(0);
expect(q.takeStorms().map((x) => x.id)).toEqual([p.id]);
expect(q.takeStorms()).toEqual([]); // drained means drained
expect(total('aggro') - before).toBeGreaterThanOrEqual(CARPET_TUNING.stormAggro);
});
it('recall puts them back up and remembers the stand for the entrance', () => {
const { q } = carpetSetup();
const p = q.callNext()!;
q.toCarpet();
q.update(3000);
expect(q.recallFromCarpet('nobody')).toBeNull();
const back = q.recallFromCarpet(p.id);
expect(back?.id).toBe(p.id);
expect(q.patronUp?.id).toBe(p.id);
expect(q.carpetMsFor(p.id)).toBe(3000);
});
it('recall refuses while somebody else is at the rope', () => {
const { q } = carpetSetup();
const p = q.callNext()!;
q.toCarpet();
q.callNext();
expect(q.recallFromCarpet(p.id)).toBeNull();
expect(q.carpetSnapshot.length).toBe(1);
});
it('entranceBonus scales with the stand and caps', () => {
expect(entranceBonus(0)).toBe(0);
expect(entranceBonus(10_000)).toBeCloseTo(10 * CARPET_TUNING.entranceHypePerSec);
expect(entranceBonus(10_000_000)).toBe(CARPET_TUNING.entranceHypeCap);
});
});

View File

@ -1,46 +0,0 @@
import { describe, expect, it } from 'vitest';
import { ROLES, defaultRoleFor, roleById, rolesFor } from '../src/data/roster';
import { VENUES } from '../src/data/venues';
describe('the roster board', () => {
it('every venue has at least one shift pinned to it', () => {
for (const v of VENUES) {
expect(rolesFor(v.id).length, `${v.id} board is empty`).toBeGreaterThan(0);
}
});
it('every role is pinned to a real venue', () => {
const ids = new Set(VENUES.map((v) => v.id));
for (const r of ROLES) {
expect(r.venues.length).toBeGreaterThan(0);
for (const vid of r.venues) expect(ids.has(vid), `${r.id} references '${vid}'`).toBe(true);
}
});
it('the ladder pays upward — the boss venues pay the most', () => {
for (const r of ROLES) expect(r.wage).toBeGreaterThan(0);
const hos = roleById('hos')!;
for (const r of ROLES) expect(hos.wage).toBeGreaterThanOrEqual(r.wage);
});
it('posts belong to floor starts — you cannot hold the taps from the door', () => {
for (const r of ROLES) {
if (r.post) expect(r.startLoc).toBe('floor');
}
});
it('defaultRoleFor always resolves (dev routes skip the board)', () => {
for (const v of VENUES) {
const d = defaultRoleFor(v);
expect(d).toBeDefined();
// the sensible default mans the door, or is the HoS at boss venues
expect(d.startLoc).toBe('door');
}
});
it('roleById round-trips and rejects nonsense', () => {
for (const r of ROLES) expect(roleById(r.id)?.id).toBe(r.id);
expect(roleById('mascot')).toBeUndefined();
expect(roleById(undefined)).toBeUndefined();
});
});

View File

@ -1,55 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
BUMP_MOP_CHANCE,
KAYDEN_HOLD_COOLDOWN_MS,
KAYDEN_HOLD_MS,
ORDER_LAG_MS,
RADIO_COOLDOWN_MS,
SHAN_CLEAN_CHANCE,
SHAN_MIN_INTOX,
orderLag,
rollBumpMops,
rollShanClean,
} from '../src/rules/staff';
import { drunkStage } from '../src/rules/drunk';
import { SeededRNG } from '../src/core/SeededRNG';
import { VENUES } from '../src/data/venues';
describe('radio command', () => {
it('is a Head of Security-tier verb: Elevate and ROOM only', () => {
expect(VENUES.map((v) => v.radioCommand === true)).toEqual([false, false, true, true]);
});
it('staff competence is real but imperfect — that IS the staff', () => {
const rng = new SeededRNG(3).stream('s');
let bump = 0;
let shan = 0;
for (let i = 0; i < 2000; i++) {
if (rollBumpMops(rng)) bump++;
if (rollShanClean(rng)) shan++;
}
expect(bump / 2000).toBeGreaterThan(BUMP_MOP_CHANCE - 0.05);
expect(bump / 2000).toBeLessThan(BUMP_MOP_CHANCE + 0.05);
expect(shan / 2000).toBeGreaterThan(SHAN_CLEAN_CHANCE - 0.05);
expect(shan / 2000).toBeLessThan(SHAN_CLEAN_CHANCE + 0.05);
});
it('orders take a real walk and cool down longer than they take', () => {
const rng = new SeededRNG(4).stream('s');
for (let i = 0; i < 100; i++) {
const lag = orderLag(rng);
expect(lag).toBeGreaterThanOrEqual(ORDER_LAG_MS[0]);
expect(lag).toBeLessThanOrEqual(ORDER_LAG_MS[1]);
}
expect(RADIO_COOLDOWN_MS.mess).toBeGreaterThan(ORDER_LAG_MS[1]);
expect(RADIO_COOLDOWN_MS.water).toBeGreaterThan(ORDER_LAG_MS[1]);
});
it("Shan's floor starts at loose — sober people are not a radio matter", () => {
expect(drunkStage(SHAN_MIN_INTOX)).toBe('loose');
});
it('the rope hold ends before it can be re-ordered', () => {
expect(KAYDEN_HOLD_COOLDOWN_MS).toBeGreaterThan(KAYDEN_HOLD_MS);
});
});

View File

@ -29,8 +29,6 @@ TARGETS = {
"dishwasher": (32, 16),
# the door-scene backdrop plate is special-cased: full size, no quantise
"street:backdrop": (640, 176),
# door-scene red carpet plate (front-on, not a floor prop)
"carpet": (128, 32),
}
def main() -> None: