Compare commits
1 Commits
main
...
claude/epi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c5889c957 |
1
.gitignore
vendored
@ -1,3 +1,2 @@
|
||||
node_modules
|
||||
dist/
|
||||
art_incoming/
|
||||
|
||||
485
LANEHANDOVER.md
@ -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.4–0.75, `REQUEST_HIT`; hit vibe +3/hype +0.2, flop vibe −3), NAH (aggro
|
||||
+1), AFTER THIS ONE (40% deferred aggro +2 in 8–20 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), 6–26 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`, Fisher–Yates 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 25–70s with cash and
|
||||
the same request ("the cash is folded into a little paper crane this time").
|
||||
Queue is `djReturns`; a return with no live asker is silently lost.
|
||||
- Step-off toast + incident: "$N in the shoe."
|
||||
|
||||
**2. The pour** (`PourOverlay` + `judgePour`/`pourAdjust` pure in barShift):
|
||||
- SERVE now opens a sweep-stop meter (0→1→1300ms, SPACE stops, ESC bails =
|
||||
no serve). Bands painted on the glass: short <0.68 / clean / overflow ≥0.93.
|
||||
- short: vibe cancelled, aggro +1, HALF the drink. overflow: vibe +1 extra,
|
||||
+0.03 extra intox. Breach flag rides the SERVE decision, never the pour.
|
||||
- Serve application refactored into `applyBarCall()` (water/cutoff unchanged).
|
||||
|
||||
**3. Tabs — the jar of other people's cards** (`Tab`/`canOpenTab`/`cardLineup`):
|
||||
- A completed serve to tipsy+ has 30% chance of opening a tab (card in the jar,
|
||||
toast names the card); further serves increment `drinks`.
|
||||
- When a tab-holder is cut off or heads home while you hold the taps, the
|
||||
card-return poll (head of tickBar) opens a ChoiceOverlay: their doll, a
|
||||
drunk's description of their own card, and a seeded 3-name lineup
|
||||
(`cardLineup` — right card + two strangers from the room, never slot-stable).
|
||||
- Right: vibe +1, tab total read out ($14/drink). Wrong: aggro +2, someone
|
||||
leaves with Chantelle's debit card. ESC: aggro +1, the jar keeps it.
|
||||
- Incidents: `tabClosed` / `tabMisreturned` / `tabUnclaimed` / `djTip`.
|
||||
|
||||
### Verified in the pane (frame-stepped, direct handleKey — see SOLO-12 note)
|
||||
Tipped return overlay + TAKE → $20 in shoe + djTip incident ✓ · free-NAH → ATM
|
||||
return queued ✓ · step-off shoe toast ✓ · serve→pour opens, sweep advances ✓ ·
|
||||
overflow +0.09 / short +0.03 with pour-noted incidents ✓ · tab opened on serve,
|
||||
holder heading home triggered 3-card lineup, right card closed $14 ✓ · wrong
|
||||
card aggro +2 + tabMisreturned ✓ · THE POUR screenshot.
|
||||
|
||||
### Notes
|
||||
- ChoiceOverlay is doing tab lineups with patron NAMES as choice ids — fine
|
||||
while names are unique per lineup (cardLineup dedupes); revisit if two
|
||||
patrons ever share a full name.
|
||||
- No cash meter exists on purpose: tips/tab totals are incidents + toasts
|
||||
(the report is the moral ledger, per the joint bust).
|
||||
|
||||
## 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 30–55s × 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 4230–4237,
|
||||
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, 3–7s 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 1–4 ✓ 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, 2–3 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.
|
||||
|
||||
@ -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.
|
||||
|
||||
|
Before Width: | Height: | Size: 424 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 552 B |
|
Before Width: | Height: | Size: 430 B |
@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 351 B |
|
Before Width: | Height: | Size: 396 B |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 387 B |
@ -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')!;
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
23
src/main.ts
@ -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]);
|
||||
};
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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]);
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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.');
|
||||
|
||||
@ -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))];
|
||||
// Fisher–Yates on the decoy pool, take two.
|
||||
for (let i = decoys.length - 1; i > 0; i--) {
|
||||
const j = rng.int(0, i);
|
||||
[decoys[i], decoys[j]] = [decoys[j]!, decoys[i]!];
|
||||
}
|
||||
const lineup = [right, ...decoys.slice(0, 2)];
|
||||
for (let i = lineup.length - 1; i > 0; i--) {
|
||||
const j = rng.int(0, i);
|
||||
[lineup[i], lineup[j]] = [lineup[j]!, lineup[i]!];
|
||||
}
|
||||
return lineup;
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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 0→1→0 and
|
||||
* SPACE stops it — the fill IS your pour. The clean band sits near the top
|
||||
* because a good pour is nearly a mistake. Same shell discipline as the other
|
||||
* overlays: the scene routes keys in and calls update(dt).
|
||||
*/
|
||||
export class PourOverlay {
|
||||
private root: Phaser.GameObjects.Container | null = null;
|
||||
private fillRect: Phaser.GameObjects.Rectangle | null = null;
|
||||
private t = 0;
|
||||
private rising = true;
|
||||
private done: ((fill: number | null) => void) | null = null;
|
||||
|
||||
constructor(private readonly scene: Phaser.Scene) {}
|
||||
|
||||
get isOpen(): boolean {
|
||||
return this.root !== null;
|
||||
}
|
||||
|
||||
open(done: (fill: number | null) => void): void {
|
||||
if (this.root) return;
|
||||
this.done = done;
|
||||
this.t = 0;
|
||||
this.rising = true;
|
||||
|
||||
const root = this.scene.add.container(0, 0).setDepth(900).setScrollFactor(0);
|
||||
this.root = root;
|
||||
root.add(this.scene.add.rectangle(W / 2, H / 2, W, H, 0x03030a, 0.72));
|
||||
root.add(this.scene.add.rectangle(W / 2, H / 2, 280, 200, 0x10121c).setStrokeStyle(1, 0x3a4054));
|
||||
root.add(
|
||||
this.scene.add
|
||||
.text(W / 2, H / 2 - 82, POUR_UI.title, { fontFamily: 'monospace', fontSize: '13px', color: '#e8e4da' })
|
||||
.setOrigin(0.5),
|
||||
);
|
||||
|
||||
// The glass, and the bands painted on its side like an honest jug.
|
||||
const gx = W / 2;
|
||||
const gBottom = H / 2 + 58;
|
||||
root.add(this.scene.add.rectangle(gx, gBottom - GLASS_H / 2, GLASS_W + 8, GLASS_H + 6, 0x232838));
|
||||
const band = (from: number, to: number, colour: number): void => {
|
||||
const y0 = gBottom - GLASS_H * from;
|
||||
const y1 = gBottom - GLASS_H * to;
|
||||
root.add(
|
||||
this.scene.add
|
||||
.rectangle(gx + GLASS_W / 2 + 10, (y0 + y1) / 2, 4, y0 - y1, colour)
|
||||
.setOrigin(0.5),
|
||||
);
|
||||
};
|
||||
band(0, POUR_CLEAN_FROM, 0x5a4a48); // short: the region of regret
|
||||
band(POUR_CLEAN_FROM, POUR_OVER_FROM, 0x74a06a); // clean
|
||||
band(POUR_OVER_FROM, 1, 0xd8a020); // overflow: beloved, expensive
|
||||
this.fillRect = this.scene.add
|
||||
.rectangle(gx, gBottom, GLASS_W, 1, 0xc8a24a)
|
||||
.setOrigin(0.5, 1);
|
||||
root.add(this.fillRect);
|
||||
|
||||
root.add(
|
||||
this.scene.add
|
||||
.text(W / 2, H / 2 + 78, POUR_UI.help, { fontFamily: 'monospace', fontSize: '9px', color: '#8890a8' })
|
||||
.setOrigin(0.5),
|
||||
);
|
||||
}
|
||||
|
||||
handleKey(key: string): void {
|
||||
if (!this.root) return;
|
||||
if (key === 'ESCAPE' || key === 'ESC') return this.finish(null);
|
||||
if (key === 'SPACE') return this.finish(this.t);
|
||||
}
|
||||
|
||||
update(dtMs: number): void {
|
||||
if (!this.root) return;
|
||||
const step = dtMs / POUR_SWEEP_MS;
|
||||
this.t += this.rising ? step : -step;
|
||||
if (this.t >= 1) {
|
||||
this.t = 1;
|
||||
this.rising = false;
|
||||
} else if (this.t <= 0) {
|
||||
this.t = 0;
|
||||
this.rising = true;
|
||||
}
|
||||
this.fillRect?.setDisplaySize(GLASS_W, Math.max(1, GLASS_H * this.t));
|
||||
}
|
||||
|
||||
private finish(fill: number | null): void {
|
||||
const cb = this.done;
|
||||
this.root?.destroy();
|
||||
this.root = null;
|
||||
this.fillRect = null;
|
||||
this.done = null;
|
||||
cb?.(fill);
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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:
|
||||
|
||||