Compare commits
21 Commits
claude/epi
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dd61fef36 | ||
|
|
754f2b4767 | ||
|
|
7af3732f39 | ||
|
|
4118a60aea | ||
|
|
0d3ce80c6b | ||
|
|
bcc06c104a | ||
|
|
3b8682dd40 | ||
|
|
6d2fc56bfb | ||
|
|
164b4480c5 | ||
|
|
79cd80dc01 | ||
|
|
d4f802407b | ||
|
|
ec9afaff0f | ||
|
|
e008fb442f | ||
|
|
5334a30150 | ||
|
|
5ebdfc61eb | ||
|
|
b7bb8f49ab | ||
|
|
022ea32005 | ||
|
|
c20a689086 | ||
|
|
b273c5c833 | ||
|
|
41e93d01ab | ||
|
|
fbcbb2e349 |
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist/
|
dist/
|
||||||
|
art_incoming/
|
||||||
|
|||||||
516
LANEHANDOVER.md
@ -1338,3 +1338,519 @@ kebab run full cycle with incident + toast ✓.
|
|||||||
- Aggro sim band floor eased 15 → 12: doubling the encounter pool reshuffled
|
- Aggro sim band floor eased 15 → 12: doubling the encounter pool reshuffled
|
||||||
seeded nights; the band's claim ("denials sting at all") is intact.
|
seeded nights; the band's claim ("denials sting at all") is intact.
|
||||||
- SCENARIOS.md: everything except the three phase-scale systems is now ✅.
|
- 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-19 · 2026-07-21
|
||||||
|
|
||||||
|
**Branch:** main (solo, Fable, ultra) · **Gate:** lint ✓ build ✓ test ✓ (749 tests, 49 files)
|
||||||
|
**NOT deployed** — John is playtesting the live build; deploy is his call when done.
|
||||||
|
|
||||||
|
### Floor-economy sim: the bot nights the floor never had
|
||||||
|
economy.test.ts only ever measured doorgirl nights. Eleven floor systems
|
||||||
|
shipped 2026-07-21 against zero bot coverage. Now covered:
|
||||||
|
|
||||||
|
- **`tests/sim/economy.ts` extended**: `BotPolicy.carpet`/`carpetStandMs`, real
|
||||||
|
`toCarpet`/`recallFromCarpet` wiring (QueueManager owns the carpet, so the
|
||||||
|
sim drives the SHIPPING code), entrance dividend mirrored from DoorScene.rule,
|
||||||
|
new metrics (`carpetParked/Storms/Entrances`, `hypePinnedShare`). Policies
|
||||||
|
`SHOWPONY` (parks the tolerant, recalls inside the fuse) and `GREEDY` (parks
|
||||||
|
anyone, holds to the limit).
|
||||||
|
- **`tests/sim/floorShift.ts` (new)**: bar-economy sim on the REAL CrowdSim
|
||||||
|
(node-safe, already test-driven) + real `judgeCall`/`judgePour`/`pourAdjust`.
|
||||||
|
Mirrors tickBar/applyBarCall. Policies `RSA_STRAIGHT`, `PUBLICAN` (serves
|
||||||
|
anyone), `BUTTERFINGERS` (legal calls, hopeless hands).
|
||||||
|
- **`tests/sim/floorEconomy.test.ts` (new)**: 14 feel-bands.
|
||||||
|
|
||||||
|
### THE BUG IT FOUND (fixed)
|
||||||
|
**The red carpet was an uncapped hype ratchet.** Three tolerant patrons parked
|
||||||
|
all night rode hype to the x3 ceiling; because `Meters` scales vibe GAINS by
|
||||||
|
hype, post-11PM vibe-pin went **0.27 → 0.71** against the suite's documented
|
||||||
|
`< 0.5` band — the exact "vibe pinned by 10 PM, hype a pure ratchet" failure
|
||||||
|
the Phase-3 tuning pass fixed for the door.
|
||||||
|
Fix: `CARPET_TUNING.hypePerStandCap = 0.15` with per-slot accounting in
|
||||||
|
`tickCarpet` — the same guard queue theatre already had (`hypePerWaitCap`).
|
||||||
|
The trickle is now flavour; the ENTRANCE dividend is the real payoff.
|
||||||
|
Result: hype 3.00 → 1.74, hypePin 0.11 → 0.00, vibePin 0.71 → 0.56.
|
||||||
|
|
||||||
|
I also trialled `entranceVibe: 2 → 1` (0.56 → 0.50) and **reverted it** — that
|
||||||
|
is a feel decision, not a regression, and 0.50 sits exactly on a band edge.
|
||||||
|
|
||||||
|
### What the sim says is HEALTHY (don't "fix" these)
|
||||||
|
- The carpet's fuse is real: GREEDY (holds 45s) storms **34 of 49** parks,
|
||||||
|
aggro 14 → 31, admits 78 → 60. Greed is properly punished.
|
||||||
|
- Reading the archetype IS the skill: SHOWPONY takes zero storms, GREEDY many.
|
||||||
|
- Pour skill is felt: BUTTERFINGERS' short pours push aggroMax 15 → 32.
|
||||||
|
- RSA-straight play never earns heat (the lawful shift is a tension, not a trap).
|
||||||
|
|
||||||
|
### Findings for John (design calls, NOT actioned)
|
||||||
|
1. **The bar generates zero hype** (1.0 flat all night) and moves vibe by ~2.
|
||||||
|
So a bartender's pay is ~wage while a doorgirl working the carpet banks a
|
||||||
|
hype bonus worth up to +$180. The roster's wage column says bartender $160
|
||||||
|
vs door $180, but the real gap is much wider. Either the bar wants a hype
|
||||||
|
channel (a well-poured busy bar SHOULD sell the room) or the wage table
|
||||||
|
should acknowledge it.
|
||||||
|
2. **The RSA dilemma only presents ~3 times a night** (3.2 breach-stage orders
|
||||||
|
per PUBLICAN night) and dedupe caps it at ONE strike, so reckless serving
|
||||||
|
costs 0.8 strikes/night. Design §6 calls RSA the bartender's *signature*
|
||||||
|
tension; right now it is a coin flip. Options: raise `BREACH_SEEN_CHANCE`,
|
||||||
|
or let the inspector's presence in the room modify it.
|
||||||
|
|||||||
@ -70,10 +70,22 @@ Status: ✅ implemented · 🔜 next nibbles · 💡 backlog.*
|
|||||||
|
|
||||||
## Systems worth a whole phase
|
## Systems worth a whole phase
|
||||||
|
|
||||||
- 💡 **Venue 4 / ROOM boss week** (design §6): dress code re-randomises every
|
- ✅ **Venue 4 / ROOM boss week** (2026-07-21, first cut): fourth rung on the
|
||||||
15 in-game minutes; dialogue rendered as lip-read guesses under the loudest
|
ladder (promotes from Elevate). No Dazza rule chronology — the board IS the
|
||||||
mix arc.
|
law and re-randomises every 15 in-game minutes (3 rules per window, seeded,
|
||||||
- 💡 **The DJ shift** (role ladder): the booth is built, the engine's mix is
|
with a toast on the turn); every rope line renders as bracketed lip-read
|
||||||
already externally scriptable — requests at the booth, energy management.
|
guesses (`[FIST? LIST?]`) because the techno ate the dialogue. 60 licensed,
|
||||||
- 💡 **Bartender shift**: the bar geometry and RSA scoring already exist;
|
1.2 arrival scale, inspector working, crowd of heads and VIP-claimers.
|
||||||
pour-and-cut-off from the other side of the counter.
|
- ✅ **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).
|
||||||
|
|||||||
BIN
public/props/boothLamp.png
Normal file
|
After Width: | Height: | Size: 424 B |
BIN
public/props/carpet.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/props/dishwasher.png
Normal file
|
After Width: | Height: | Size: 552 B |
BIN
public/props/highTable.png
Normal file
|
After Width: | Height: | Size: 430 B |
@ -2,15 +2,22 @@
|
|||||||
"props": [
|
"props": [
|
||||||
"arcade",
|
"arcade",
|
||||||
"barShelf",
|
"barShelf",
|
||||||
|
"boothLamp",
|
||||||
"boothTable",
|
"boothTable",
|
||||||
"buttBin",
|
"buttBin",
|
||||||
|
"carpet",
|
||||||
"cloak",
|
"cloak",
|
||||||
"crate",
|
"crate",
|
||||||
"deck",
|
"deck",
|
||||||
"discoball",
|
"discoball",
|
||||||
|
"dishwasher",
|
||||||
"glassRack",
|
"glassRack",
|
||||||
"glassStack",
|
"glassStack",
|
||||||
|
"highTable",
|
||||||
"mixer",
|
"mixer",
|
||||||
|
"monitor",
|
||||||
|
"plant",
|
||||||
|
"poolTable",
|
||||||
"poster1",
|
"poster1",
|
||||||
"poster2",
|
"poster2",
|
||||||
"poster3",
|
"poster3",
|
||||||
@ -19,6 +26,7 @@
|
|||||||
"stampPodium",
|
"stampPodium",
|
||||||
"street_backdrop",
|
"street_backdrop",
|
||||||
"taps",
|
"taps",
|
||||||
"till"
|
"till",
|
||||||
|
"wetFloor"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/props/monitor.png
Normal file
|
After Width: | Height: | Size: 351 B |
BIN
public/props/plant.png
Normal file
|
After Width: | Height: | Size: 396 B |
BIN
public/props/poolTable.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/props/wetFloor.png
Normal file
|
After Width: | Height: | Size: 387 B |
89
src/data/roster.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
// 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,6 +16,16 @@ export const DOOR_UI = {
|
|||||||
idTrayEmpty: 'no ID handed over',
|
idTrayEmpty: 'no ID handed over',
|
||||||
dressCode: "DAZZA'S RULES",
|
dressCode: "DAZZA'S RULES",
|
||||||
dressCodeEmpty: 'no rules yet.\nuse ur judgement\n(dont)',
|
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;
|
} as const;
|
||||||
|
|
||||||
/** Shown once, first thirty seconds, then never again. A stranger must be able to play. */
|
/** Shown once, first thirty seconds, then never again. A stranger must be able to play. */
|
||||||
@ -158,6 +168,14 @@ export const ripenLine = (stage: string, roll: number): string => {
|
|||||||
* Dazza rescinding a rule when a newer one pushes it off the card
|
* 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).
|
* (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[] = [
|
export const RULE_RETRACTIONS: readonly string[] = [
|
||||||
'dazza: forget the "{rule}" thing. new priorities',
|
'dazza: forget the "{rule}" thing. new priorities',
|
||||||
'dazza: "{rule}" is over. keep up',
|
'dazza: "{rule}" is over. keep up',
|
||||||
|
|||||||
@ -128,6 +128,37 @@ export const HELP_TEXT =
|
|||||||
'WASD move · TAB uv · E interact · SPACE bang · ESC cancel · R reseed';
|
'WASD move · TAB uv · E interact · SPACE bang · ESC cancel · R reseed';
|
||||||
|
|
||||||
|
|
||||||
|
/** The 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. */
|
/** The smoking-yard joint bust (docs/SCENARIOS.md). Three verbs, none clean. */
|
||||||
export const JOINT_UI = {
|
export const JOINT_UI = {
|
||||||
title: 'SOMETHING SMELLS HERBAL',
|
title: 'SOMETHING SMELLS HERBAL',
|
||||||
@ -196,3 +227,194 @@ export const AID_UI = {
|
|||||||
ok: 'They come around on their side. Walked out the back to a taxi. The room starts again, quieter.',
|
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.',
|
ambulance: 'The ambulance takes them out the back. The room starts again, but not really.',
|
||||||
} as const;
|
} 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,6 +22,12 @@ export interface VenueDef {
|
|||||||
* identity. Omitted keys keep their default weight.
|
* identity. Omitted keys keep their default weight.
|
||||||
*/
|
*/
|
||||||
archetypeWeights?: Partial<Record<import('./types').Archetype, number>>;
|
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[] = [
|
export const VENUES: readonly VenueDef[] = [
|
||||||
@ -55,10 +61,29 @@ export const VENUES: readonly VenueDef[] = [
|
|||||||
arrivalScale: 1.1,
|
arrivalScale: 1.1,
|
||||||
ruleLimit: 8,
|
ruleLimit: 8,
|
||||||
inspector: true,
|
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,
|
// 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.
|
// 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 },
|
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;
|
export const TOTAL_VENUES = VENUES.length;
|
||||||
|
|||||||
23
src/main.ts
@ -6,6 +6,7 @@ import { DoorScene } from './scenes/door/DoorScene';
|
|||||||
import { NightSummaryScene } from './scenes/door/NightSummaryScene';
|
import { NightSummaryScene } from './scenes/door/NightSummaryScene';
|
||||||
import { IncidentReportScene } from './scenes/door/IncidentReportScene';
|
import { IncidentReportScene } from './scenes/door/IncidentReportScene';
|
||||||
import { FloorDemoScene } from './scenes/floor/FloorDemoScene';
|
import { FloorDemoScene } from './scenes/floor/FloorDemoScene';
|
||||||
|
import { RosterScene } from './scenes/shared/RosterScene';
|
||||||
import { JuiceDemoScene } from './ui/JuiceDemoScene';
|
import { JuiceDemoScene } from './ui/JuiceDemoScene';
|
||||||
import { MixDeskScene } from './ui/MixDeskScene';
|
import { MixDeskScene } from './ui/MixDeskScene';
|
||||||
|
|
||||||
@ -25,7 +26,10 @@ const game = new Phaser.Game({
|
|||||||
},
|
},
|
||||||
// FloorDemoScene registers twice: 'Floor' is the night-mode instance NightScene
|
// FloorDemoScene registers twice: 'Floor' is the night-mode instance NightScene
|
||||||
// launches with a NightContext; 'FloorDemo' stays the standalone demo (key F).
|
// 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: [
|
scene: [
|
||||||
|
RosterScene,
|
||||||
NightScene,
|
NightScene,
|
||||||
DoorScene,
|
DoorScene,
|
||||||
NightSummaryScene,
|
NightSummaryScene,
|
||||||
@ -49,19 +53,28 @@ const game = new Phaser.Game({
|
|||||||
const ROUTES = { night: 'Night', parade: 'Parade', floor: 'FloorDemo', juice: 'JuiceDemo' } as const;
|
const ROUTES = { night: 'Night', parade: 'Parade', floor: 'FloorDemo', juice: 'JuiceDemo' } as const;
|
||||||
type RouteKey = keyof typeof ROUTES;
|
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 => {
|
const showScene = (target: (typeof ROUTES)[RouteKey]): void => {
|
||||||
for (const key of ['Night', 'Door', 'NightSummary', 'Parade', 'FloorDemo', 'JuiceDemo']) {
|
for (const key of ROOT_SCENES) {
|
||||||
if (key !== target && game.scene.isActive(key)) game.scene.stop(key);
|
if (key !== target && game.scene.isActive(key)) game.scene.stop(key);
|
||||||
}
|
}
|
||||||
if (!game.scene.isActive(target)) game.scene.start(target);
|
// 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);
|
||||||
};
|
};
|
||||||
|
|
||||||
// NightScene is scene[0] and auto-starts. Hash routing must wait until it is
|
// RosterScene 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.
|
// actually up before swapping, or both scenes end up running on top of each other.
|
||||||
const hash = window.location.hash.slice(1) as RouteKey;
|
const hash = window.location.hash.slice(1) as RouteKey;
|
||||||
if (hash in ROUTES && ROUTES[hash] !== 'Night') {
|
if (hash in ROUTES) {
|
||||||
const route = (): void => {
|
const route = (): void => {
|
||||||
if (!game.scene.isActive('Night')) return;
|
if (!game.scene.isActive('Roster')) return;
|
||||||
game.events.off(Phaser.Core.Events.POST_STEP, route);
|
game.events.off(Phaser.Core.Events.POST_STEP, route);
|
||||||
showScene(ROUTES[hash]);
|
showScene(ROUTES[hash]);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
// text that announced it is unplayable, and two hand-kept copies always drift.
|
// text that announced it is unplayable, and two hand-kept copies always drift.
|
||||||
|
|
||||||
import './doorTypes';
|
import './doorTypes';
|
||||||
|
import { SeededRNG } from '../core/SeededRNG';
|
||||||
import { DAZZA_TEXTS } from '../data/strings/dazza';
|
import { DAZZA_TEXTS } from '../data/strings/dazza';
|
||||||
import type { DressCodeRule, OutfitLayer, OutfitSlot, Patron } from '../data/types';
|
import type { DressCodeRule, OutfitLayer, OutfitSlot, Patron } from '../data/types';
|
||||||
|
|
||||||
@ -168,8 +169,42 @@ export const DRESS_CODE_RULES: readonly DoorDressCodeRule[] = SPECS.map(build).s
|
|||||||
*/
|
*/
|
||||||
export const ACTIVE_RULE_CAP = 4;
|
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. */
|
/** Every rule Dazza has EVER texted tonight, live or rescinded. */
|
||||||
export function announcedRules(clockMin: number): DoorDressCodeRule[] {
|
export function announcedRules(clockMin: number): DoorDressCodeRule[] {
|
||||||
|
if (shuffleSeed !== null) return shuffledRules(clockMin, shuffleSeed);
|
||||||
return DRESS_CODE_RULES.filter((r) => r.activeFrom <= clockMin);
|
return DRESS_CODE_RULES.filter((r) => r.activeFrom <= clockMin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
68
src/rules/floorScore.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
// 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;
|
||||||
|
}
|
||||||
52
src/rules/lipRead.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
// 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;
|
||||||
|
}
|
||||||
46
src/rules/staff.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
// 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 Phaser from 'phaser';
|
||||||
import { renderDoll } from '../../patrons/doll';
|
import { renderDoll } from '../../patrons/doll';
|
||||||
import { dollPlan } from '../../patrons/dollPlan';
|
import { dollPlan } from '../../patrons/dollPlan';
|
||||||
import { activeRules, announcedRules, violations, type DoorDressCodeRule } from '../../rules/dressCode';
|
import { activeRules, announcedRules, shuffleWindow, violations, type DoorDressCodeRule } from '../../rules/dressCode';
|
||||||
import { drunkStage } from '../../rules/drunk';
|
import { drunkStage } from '../../rules/drunk';
|
||||||
import { idVerdict, formatDate } from '../../rules/idCheck';
|
import { idVerdict, formatDate } from '../../rules/idCheck';
|
||||||
import { LOCKOUT_MIN, judge } from '../../rules/judge';
|
import { LOCKOUT_MIN, judge } from '../../rules/judge';
|
||||||
@ -14,11 +14,15 @@ import {
|
|||||||
DENY_REACTIONS,
|
DENY_REACTIONS,
|
||||||
DOOR_TUTORIAL,
|
DOOR_TUTORIAL,
|
||||||
DOOR_UI,
|
DOOR_UI,
|
||||||
|
BOARD_SHUFFLE_TOASTS,
|
||||||
|
CARPET_UI,
|
||||||
FRANKO_TIPS,
|
FRANKO_TIPS,
|
||||||
PATDOWN_LINES,
|
PATDOWN_LINES,
|
||||||
RULE_RETRACTIONS,
|
RULE_RETRACTIONS,
|
||||||
} from '../../data/strings/door';
|
} from '../../data/strings/door';
|
||||||
import { QueueManager } from './QueueManager';
|
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 { PatronUpView } from './PatronUpView';
|
import { PatronUpView } from './PatronUpView';
|
||||||
import { IdCardView } from './IdCardView';
|
import { IdCardView } from './IdCardView';
|
||||||
import { PhoneWidget } from './PhoneWidget';
|
import { PhoneWidget } from './PhoneWidget';
|
||||||
@ -27,6 +31,7 @@ import { ClipboardPanel, type ClipboardTab } from './ClipboardPanel';
|
|||||||
import { encounterById } from '../../rules/encounters';
|
import { encounterById } from '../../rules/encounters';
|
||||||
import { SobrietyModal } from './SobrietyModal';
|
import { SobrietyModal } from './SobrietyModal';
|
||||||
import { stamp, type StampHandle } from '../../ui/Stamp';
|
import { stamp, type StampHandle } from '../../ui/Stamp';
|
||||||
|
import { tutorialAlpha } from './tutorialFade';
|
||||||
import { Button, DOOR_PALETTE, MeterBar, MONO, panel, text } from './ui';
|
import { Button, DOOR_PALETTE, MeterBar, MONO, panel, text } from './ui';
|
||||||
import { shouldRecordStrike, type NightContext } from './NightScene';
|
import { shouldRecordStrike, type NightContext } from './NightScene';
|
||||||
|
|
||||||
@ -39,7 +44,6 @@ const KAYDEN_RULE_MS = 5000;
|
|||||||
const UP_FOOT_Y = 244;
|
const UP_FOOT_Y = 244;
|
||||||
const DOOR_X = 566;
|
const DOOR_X = 566;
|
||||||
const QUEUE_FOOT_Y = 228;
|
const QUEUE_FOOT_Y = 228;
|
||||||
const TUTORIAL_MS = 26_000;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TODO(contract): CCR-4 — `dazza:text` carries `rule?: DressCodeRule`, so a rule
|
* TODO(contract): CCR-4 — `dazza:text` carries `rule?: DressCodeRule`, so a rule
|
||||||
@ -71,6 +75,7 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
private sobriety!: SobrietyModal;
|
private sobriety!: SobrietyModal;
|
||||||
|
|
||||||
private queueSprites: QueueSprite[] = [];
|
private queueSprites: QueueSprite[] = [];
|
||||||
|
private carpetSprites: QueueSprite[] = [];
|
||||||
private trayCard: Phaser.GameObjects.Container | null = null;
|
private trayCard: Phaser.GameObjects.Container | null = null;
|
||||||
private buttons: Record<string, Button> = {};
|
private buttons: Record<string, Button> = {};
|
||||||
private vibeBar!: MeterBar;
|
private vibeBar!: MeterBar;
|
||||||
@ -87,12 +92,17 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
private busy = false;
|
private busy = false;
|
||||||
private rulingToken = 0;
|
private rulingToken = 0;
|
||||||
private elapsedMs = 0;
|
private elapsedMs = 0;
|
||||||
|
/** ROOM: which 15-min shuffle window the code board currently shows. */
|
||||||
|
private codeWindow = -1;
|
||||||
private testedThisPatron = false;
|
private testedThisPatron = false;
|
||||||
private pattedThisPatron = false;
|
private pattedThisPatron = false;
|
||||||
private offs: Array<() => void> = [];
|
private offs: Array<() => void> = [];
|
||||||
/** Is the player at the rope? When false, Kayden works the door. Badly. */
|
/** Is the player at the rope? When false, Kayden works the door. Badly. */
|
||||||
private present = true;
|
private present = true;
|
||||||
private kaydenAccMs = 0;
|
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>();
|
private readonly retiredRules = new Set<string>();
|
||||||
/** Every card ADMITTED tonight, name|dob -> clock label. The passback net. */
|
/** Every card ADMITTED tonight, name|dob -> clock label. The passback net. */
|
||||||
private readonly seenCards = new Map<string, string>();
|
private readonly seenCards = new Map<string, string>();
|
||||||
@ -133,6 +143,28 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
this.present = true;
|
this.present = true;
|
||||||
this.kaydenAccMs = 0;
|
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.
|
// Some nights, mid-slam, Dazza needs a kebab. The shop is right there.
|
||||||
const kebabRng = this.night.rng.stream('kebab');
|
const kebabRng = this.night.rng.stream('kebab');
|
||||||
this.kebabAskAtMin = kebabRng.chance(0.65) ? kebabRng.int(140, 260) : null;
|
this.kebabAskAtMin = kebabRng.chance(0.65) ? kebabRng.int(140, 260) : null;
|
||||||
@ -141,8 +173,40 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
this.buildDesk();
|
this.buildDesk();
|
||||||
this.buildHud();
|
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.offs = [
|
||||||
this.night.bus.on('clock:tick', ({ clockMin }) => this.queue.onClockMinute(clockMin)),
|
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('meters:changed', ({ vibe, aggro, hype }) => {
|
this.night.bus.on('meters:changed', ({ vibe, aggro, hype }) => {
|
||||||
this.vibeBar.set(vibe / 100, vibe < 20);
|
this.vibeBar.set(vibe / 100, vibe < 20);
|
||||||
this.aggroBar.set(aggro / 100, aggro > 80);
|
this.aggroBar.set(aggro / 100, aggro > 80);
|
||||||
@ -235,6 +299,21 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
.text(DOOR_X, 184, 'step inside', { fontFamily: MONO, fontSize: '7px', color: '#6a4a5a' })
|
.text(DOOR_X, 184, 'step inside', { fontFamily: MONO, fontSize: '7px', color: '#6a4a5a' })
|
||||||
.setOrigin(0.5, 0);
|
.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.buildRain();
|
||||||
this.buildRope();
|
this.buildRope();
|
||||||
}
|
}
|
||||||
@ -281,6 +360,13 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// Two panels, one footprint. The tab header sits above them both.
|
// Two panels, one footprint. The tab header sits above them both.
|
||||||
this.codeCard = new DressCodeCard(this, 6, 268, 168, 86);
|
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.clipboard = new ClipboardPanel(this, this.queue.guestList, 6, 268, 168, 86);
|
||||||
this.buildDeskTabs();
|
this.buildDeskTabs();
|
||||||
|
|
||||||
@ -322,10 +408,15 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// verdicts
|
// verdicts
|
||||||
this.buttons.rope = new Button(this, {
|
this.buttons.rope = new Button(this, {
|
||||||
x: 442, y: 256, w: 192, h: 24, label: DOOR_UI.rope, size: 9,
|
x: 442, y: 256, w: 130, h: 24, label: DOOR_UI.rope, size: 9,
|
||||||
fill: 0x5a2030, hover: 0x8a2038,
|
fill: 0x5a2030, hover: 0x8a2038,
|
||||||
onClick: () => this.callNext(),
|
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, {
|
this.buttons.test = new Button(this, {
|
||||||
x: 442, y: 284, w: 94, h: 22, label: DOOR_UI.test, size: 7,
|
x: 442, y: 284, w: 94, h: 22, label: DOOR_UI.test, size: 7,
|
||||||
onClick: () => this.runSobriety(),
|
onClick: () => this.runSobriety(),
|
||||||
@ -424,6 +515,12 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
this.showToast(DOOR_UI.ropeEmpty);
|
this.showToast(DOOR_UI.ropeEmpty);
|
||||||
return;
|
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.testedThisPatron = false;
|
||||||
this.pattedThisPatron = false;
|
this.pattedThisPatron = false;
|
||||||
// Passback: this exact card already went in tonight. The tell is set HERE —
|
// Passback: this exact card already went in tonight. The tell is set HERE —
|
||||||
@ -454,6 +551,89 @@ 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. */
|
/** Nudge the player toward a tab that just became relevant, without switching it. */
|
||||||
private flashTab(tab: ClipboardTab): void {
|
private flashTab(tab: ClipboardTab): void {
|
||||||
const b = this.tabButtons[tab];
|
const b = this.tabButtons[tab];
|
||||||
@ -543,6 +723,14 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
this.applyOutcome(p, verdict, outcome);
|
this.applyOutcome(p, verdict, outcome);
|
||||||
this.applyEncounter(p, verdict);
|
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');
|
this.queue.resolveUp(verdict === 'deny');
|
||||||
|
|
||||||
// Highlight AFTER the call, never before. Lighting up the broken rules the
|
// Highlight AFTER the call, never before. Lighting up the broken rules the
|
||||||
@ -881,10 +1069,15 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
// his mistakes cost the same vibe/heat yours do. (His stamp accuracy is
|
// 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.)
|
// NightScene's problem; his verdicts are made here on the real queue.)
|
||||||
if (!this.present || this.onKebabRun) {
|
if (!this.present || this.onKebabRun) {
|
||||||
this.kaydenAccMs += deltaMs;
|
// "Hold the rope" (radio command): Kayden does nothing, magnificently.
|
||||||
while (this.kaydenAccMs >= KAYDEN_RULE_MS) {
|
if (this.elapsedMs < this.kaydenHoldUntilMs) {
|
||||||
this.kaydenAccMs -= KAYDEN_RULE_MS;
|
this.kaydenAccMs = 0;
|
||||||
this.kaydenRule();
|
} else {
|
||||||
|
this.kaydenAccMs += deltaMs;
|
||||||
|
while (this.kaydenAccMs >= KAYDEN_RULE_MS) {
|
||||||
|
this.kaydenAccMs -= KAYDEN_RULE_MS;
|
||||||
|
this.kaydenRule();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -894,11 +1087,17 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
const locked = this.night.state.clockMin >= LOCKOUT_MIN;
|
const locked = this.night.state.clockMin >= LOCKOUT_MIN;
|
||||||
this.clockText.setText(locked ? `${this.night.clock.label} · LOCKOUT` : this.night.clock.label);
|
this.clockText.setText(locked ? `${this.night.clock.label} · LOCKOUT` : this.night.clock.label);
|
||||||
this.clockText.setColor(locked ? '#ff8080' : '#9fe8a0');
|
this.clockText.setColor(locked ? '#ff8080' : '#9fe8a0');
|
||||||
if (this.tutorial.alpha > 0 && this.elapsedMs > TUTORIAL_MS) {
|
if (this.tutorial.visible) {
|
||||||
this.tweens.add({ targets: this.tutorial, alpha: 0, duration: 800 });
|
const a = tutorialAlpha(this.elapsedMs);
|
||||||
|
this.tutorial.setAlpha(a);
|
||||||
|
if (a <= 0) this.tutorial.setVisible(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.syncQueueSprites();
|
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;
|
const rope = this.buttons.rope;
|
||||||
if (rope) {
|
if (rope) {
|
||||||
@ -957,6 +1156,8 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private teardown(): void {
|
private teardown(): void {
|
||||||
|
for (const s of this.carpetSprites) s.image.destroy();
|
||||||
|
this.carpetSprites = [];
|
||||||
for (const off of this.offs) off();
|
for (const off of this.offs) off();
|
||||||
this.offs = [];
|
this.offs = [];
|
||||||
this.phone?.destroy();
|
this.phone?.destroy();
|
||||||
|
|||||||
@ -59,6 +59,17 @@ export class DressCodeCard {
|
|||||||
this.rows.push({ rule, label, badge, addedAt: nowMs });
|
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 the rules the patron currently in front of you is breaking. */
|
||||||
highlight(violatedIds: readonly string[]): void {
|
highlight(violatedIds: readonly string[]): void {
|
||||||
for (const r of this.rows) {
|
for (const r of this.rows) {
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { StubBeatClock } from '../../core/StubBeatClock';
|
|||||||
import { Meters, freshNightState, vibeCoolingPerMin } from '../../core/meters';
|
import { Meters, freshNightState, vibeCoolingPerMin } from '../../core/meters';
|
||||||
import { loadGame, saveGame, clearSave, freshGameState } from '../../core/save';
|
import { loadGame, saveGame, clearSave, freshGameState } from '../../core/save';
|
||||||
import { _resetPatronSerial } from '../../patrons/generator';
|
import { _resetPatronSerial } from '../../patrons/generator';
|
||||||
import { ruleById } from '../../rules/dressCode';
|
import { ruleById, setDressCodeShuffle } from '../../rules/dressCode';
|
||||||
import { TechnoEngine } from '../../audio/TechnoEngine';
|
import { TechnoEngine } from '../../audio/TechnoEngine';
|
||||||
import { Ambience } from '../../audio/Ambience';
|
import { Ambience } from '../../audio/Ambience';
|
||||||
import { Sfx } from '../../audio/Sfx';
|
import { Sfx } from '../../audio/Sfx';
|
||||||
@ -14,6 +14,7 @@ import type { DeferredHit } from '../../rules/doorTypes';
|
|||||||
import type { GameState, HeatStrike, NightState, Patron, Verdict, VerdictOutcome } from '../../data/types';
|
import type { GameState, HeatStrike, NightState, Patron, Verdict, VerdictOutcome } from '../../data/types';
|
||||||
import { nextDazza } from './dazzaSchedule';
|
import { nextDazza } from './dazzaSchedule';
|
||||||
import { TOTAL_VENUES, nightDateFor, venueByIndex, type VenueDef } from '../../data/venues';
|
import { TOTAL_VENUES, nightDateFor, venueByIndex, type VenueDef } from '../../data/venues';
|
||||||
|
import { defaultRoleFor, roleById, type RoleDef } from '../../data/roster';
|
||||||
import { buildRegularCast } from '../../patrons/regularCast';
|
import { buildRegularCast } from '../../patrons/regularCast';
|
||||||
import { recordDenialBy } from '../../patrons/memory';
|
import { recordDenialBy } from '../../patrons/memory';
|
||||||
import { observe, startWatch, type InspectorWatch, type VenueBreaches } from '../../rules/inspector';
|
import { observe, startWatch, type InspectorWatch, type VenueBreaches } from '../../rules/inspector';
|
||||||
@ -62,6 +63,17 @@ export interface NightLog {
|
|||||||
heatStrikes: HeatStrike[];
|
heatStrikes: HeatStrike[];
|
||||||
endReason: NightEndReason;
|
endReason: NightEndReason;
|
||||||
seed: number;
|
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. */
|
/** Run-level result attached to the summary. */
|
||||||
@ -98,10 +110,29 @@ export interface NightContext {
|
|||||||
seenEncounters: readonly string[];
|
seenEncounters: readonly string[];
|
||||||
/** This week's venue — capacity/pressure knobs (venue ladder, design §6). */
|
/** This week's venue — capacity/pressure knobs (venue ladder, design §6). */
|
||||||
venue: VenueDef;
|
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). */
|
/** The run's stable regulars and their grudges (design §4.1). */
|
||||||
regulars: { cast: readonly Patron[]; memory: Readonly<Record<string, import('../../data/types').RegularMemory>> };
|
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. */
|
/** Door/Floor ask to move the player; the night decides and flips the scenes. */
|
||||||
requestLocation(loc: 'door' | 'floor'): void;
|
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 {
|
export class NightScene extends Phaser.Scene {
|
||||||
@ -118,6 +149,7 @@ export class NightScene extends Phaser.Scene {
|
|||||||
private seed = 4207;
|
private seed = 4207;
|
||||||
private nightIndex = 0;
|
private nightIndex = 0;
|
||||||
private venue!: VenueDef;
|
private venue!: VenueDef;
|
||||||
|
private role!: RoleDef;
|
||||||
private ended = false;
|
private ended = false;
|
||||||
private deferred: DeferredHit[] = [];
|
private deferred: DeferredHit[] = [];
|
||||||
private readonly firedDazza = new Set<string>();
|
private readonly firedDazza = new Set<string>();
|
||||||
@ -140,7 +172,7 @@ export class NightScene extends Phaser.Scene {
|
|||||||
super('Night');
|
super('Night');
|
||||||
}
|
}
|
||||||
|
|
||||||
init(data: { seed?: number; nightIndex?: number; freshRun?: boolean }): void {
|
init(data: { seed?: number; nightIndex?: number; freshRun?: boolean; role?: string }): void {
|
||||||
if (data.freshRun) clearSave();
|
if (data.freshRun) clearSave();
|
||||||
this.run = loadGame(typeof data.seed === 'number' ? data.seed : 4207);
|
this.run = loadGame(typeof data.seed === 'number' ? data.seed : 4207);
|
||||||
if (typeof data.seed === 'number' && this.run.seed !== data.seed) {
|
if (typeof data.seed === 'number' && this.run.seed !== data.seed) {
|
||||||
@ -149,6 +181,7 @@ export class NightScene extends Phaser.Scene {
|
|||||||
this.nightIndex = data.nightIndex ?? this.run.nightIndex;
|
this.nightIndex = data.nightIndex ?? this.run.nightIndex;
|
||||||
if (this.nightIndex >= TOTAL_NIGHTS) this.nightIndex = 0;
|
if (this.nightIndex >= TOTAL_NIGHTS) this.nightIndex = 0;
|
||||||
this.venue = venueByIndex(this.run.venueIndex);
|
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 —
|
// Each night is its own deterministic world derived from the run seed —
|
||||||
// and each venue-week its own block of them.
|
// and each venue-week its own block of them.
|
||||||
this.seed = this.run.seed + this.run.venueIndex * 1009 + this.nightIndex * 101;
|
this.seed = this.run.seed + this.run.venueIndex * 1009 + this.nightIndex * 101;
|
||||||
@ -172,6 +205,10 @@ export class NightScene extends Phaser.Scene {
|
|||||||
};
|
};
|
||||||
this.clock = new GameClock(this.bus, clockCfg);
|
this.clock = new GameClock(this.bus, clockCfg);
|
||||||
this.state = freshNightState(this.venue.id, this.nightIndex, this.venue.licensed);
|
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.
|
// Heat is run-scoped: strikes from earlier nights are already on the licence.
|
||||||
this.state.heatStrikes.push(...this.run.heatStrikes);
|
this.state.heatStrikes.push(...this.run.heatStrikes);
|
||||||
this.meters = new Meters(this.bus, this.state);
|
this.meters = new Meters(this.bus, this.state);
|
||||||
@ -202,6 +239,10 @@ export class NightScene extends Phaser.Scene {
|
|||||||
heatStrikes: [],
|
heatStrikes: [],
|
||||||
endReason: 'clock',
|
endReason: 'clock',
|
||||||
seed: this.seed,
|
seed: this.seed,
|
||||||
|
role: this.role.name,
|
||||||
|
wage: this.role.wage,
|
||||||
|
floorCash: 0,
|
||||||
|
pocketCash: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.offs.push(
|
this.offs.push(
|
||||||
@ -250,8 +291,18 @@ export class NightScene extends Phaser.Scene {
|
|||||||
seed: this.seed,
|
seed: this.seed,
|
||||||
nightDate: this.clock.nightDate,
|
nightDate: this.clock.nightDate,
|
||||||
sfx: this.sfx,
|
sfx: this.sfx,
|
||||||
|
role: this.role,
|
||||||
beatIntervalMs: 60_000 / 128,
|
beatIntervalMs: 60_000 / 128,
|
||||||
scheduleDeferred: (hits) => this.deferred.push(...hits),
|
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) => {
|
reportQueue: (length) => {
|
||||||
this.queueLength = length;
|
this.queueLength = length;
|
||||||
},
|
},
|
||||||
@ -280,7 +331,18 @@ export class NightScene extends Phaser.Scene {
|
|||||||
this.scene.launch('Floor', { night: this.ctx });
|
this.scene.launch('Floor', { night: this.ctx });
|
||||||
// Door and Floor BOTH run all night; the flags below decide which one
|
// Door and Floor BOTH run all night; the flags below decide which one
|
||||||
// the player is inhabiting. Floor starts hidden.
|
// the player is inhabiting. Floor starts hidden.
|
||||||
this.time.delayedCall(0, () => this.applyLocation('door'));
|
// 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 });
|
||||||
|
});
|
||||||
};
|
};
|
||||||
if (missing.length === 0) return launch();
|
if (missing.length === 0) return launch();
|
||||||
for (const k of missing) this.load.image(`gen:prop:${k}`, `props/${k}.png`);
|
for (const k of missing) this.load.image(`gen:prop:${k}`, `props/${k}.png`);
|
||||||
@ -517,9 +579,22 @@ export class NightScene extends Phaser.Scene {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.time.delayedCall(reason === 'clock' ? 600 : 1400, () => {
|
this.time.delayedCall(reason === 'clock' ? 600 : 1400, () => {
|
||||||
this.scene.stop('Door');
|
const proceed = (): void => {
|
||||||
this.scene.stop('Floor');
|
this.scene.stop('Door');
|
||||||
this.scene.start('NightSummary', { log: this.log, state: this.state, run: runInfo });
|
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();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -530,6 +605,12 @@ export class NightScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private teardown(): void {
|
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();
|
for (const off of this.offs) off();
|
||||||
this.offs = [];
|
this.offs = [];
|
||||||
this.meters?.destroy();
|
this.meters?.destroy();
|
||||||
|
|||||||
@ -15,11 +15,13 @@ const NIGHT_NAMES = ['thursday', 'friday', 'saturday'] as const;
|
|||||||
export function nightCash(log: NightLog, state: NightState): number {
|
export function nightCash(log: NightLog, state: NightState): number {
|
||||||
// Wage plus a hype bonus, minus a strike deduction. Vibe is not paid directly —
|
// 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.
|
// Dazza pays for the room feeling busy, not for you being right.
|
||||||
const wage = 180;
|
const wage = log.wage;
|
||||||
const hypeBonus = Math.round((log.hypePeak - 1) * 90);
|
const hypeBonus = Math.round((log.hypePeak - 1) * 90);
|
||||||
const doorBonus = log.admitted * 2;
|
const doorBonus = log.admitted * 2;
|
||||||
const strikes = state.heatStrikes.length * 60;
|
const strikes = state.heatStrikes.length * 60;
|
||||||
return Math.max(0, wage + hypeBonus + doorBonus - strikes);
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function signoffFor(vibe: number, strikes: number): string {
|
export function signoffFor(vibe: number, strikes: number): string {
|
||||||
@ -64,7 +66,7 @@ export class NightSummaryScene extends Phaser.Scene {
|
|||||||
46,
|
46,
|
||||||
failed
|
failed
|
||||||
? `${this.run?.venue.name ?? this.state.venueId} · ${nightName} · night over early`
|
? `${this.run?.venue.name ?? this.state.venueId} · ${nightName} · night over early`
|
||||||
: `${SUMMARY_UI.subtitle}${nightName ? ` · ${nightName}` : ''}`,
|
: `${SUMMARY_UI.subtitle}${nightName ? ` · ${nightName}` : ''} · shift: ${this.log.role.toLowerCase()} $${this.log.wage}`,
|
||||||
{
|
{
|
||||||
fontFamily: MONO,
|
fontFamily: MONO,
|
||||||
fontSize: '8px',
|
fontSize: '8px',
|
||||||
@ -106,19 +108,19 @@ export class NightSummaryScene extends Phaser.Scene {
|
|||||||
if (!r) {
|
if (!r) {
|
||||||
return {
|
return {
|
||||||
prompt: SUMMARY_UI.replay,
|
prompt: SUMMARY_UI.replay,
|
||||||
next: () => this.scene.start('Night', { seed: this.log.seed + 1 }),
|
next: () => this.scene.start('Roster', { seed: this.log.seed + 1 }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (r.runOver) {
|
if (r.runOver) {
|
||||||
return {
|
return {
|
||||||
prompt: 'licence gone · click to start a fresh week somewhere that has not heard of you',
|
prompt: 'licence gone · click to start a fresh week somewhere that has not heard of you',
|
||||||
next: () => this.scene.start('Night', { freshRun: true, seed: this.log.seed + 13 }),
|
next: () => this.scene.start('Roster', { freshRun: true, seed: this.log.seed + 13 }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (r.seasonDone) {
|
if (r.seasonDone) {
|
||||||
return {
|
return {
|
||||||
prompt: 'THE SEASON IS DONE. every door in the valley, held. click for a fresh run',
|
prompt: 'THE SEASON IS DONE. every door in the valley, held. click for a fresh run',
|
||||||
next: () => this.scene.start('Night', { freshRun: true, seed: this.log.seed + 7 }),
|
next: () => this.scene.start('Roster', { freshRun: true, seed: this.log.seed + 7 }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (r.weekDone && r.promotedTo) {
|
if (r.weekDone && r.promotedTo) {
|
||||||
@ -126,7 +128,7 @@ export class NightSummaryScene extends Phaser.Scene {
|
|||||||
return {
|
return {
|
||||||
// Dazza's pitch sells the next rung — the ladder is the reward.
|
// Dazza's pitch sells the next rung — the ladder is the reward.
|
||||||
prompt: `WEEK SURVIVED — PROMOTED: ${v.name.toUpperCase()}. "${v.pitch}"`,
|
prompt: `WEEK SURVIVED — PROMOTED: ${v.name.toUpperCase()}. "${v.pitch}"`,
|
||||||
next: () => this.scene.start('Night', {}),
|
next: () => this.scene.start('Roster', {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const failedNight = this.log.endReason !== 'clock';
|
const failedNight = this.log.endReason !== 'clock';
|
||||||
@ -135,7 +137,7 @@ export class NightSummaryScene extends Phaser.Scene {
|
|||||||
: (NIGHT_NAMES[r.nightIndex + 1] ?? 'the next night');
|
: (NIGHT_NAMES[r.nightIndex + 1] ?? 'the next night');
|
||||||
return {
|
return {
|
||||||
prompt: failedNight ? `click to run ${nextName} back` : `click for ${nextName} · ${NIGHT_DATES[r.nightIndex + 1] ?? ''}`,
|
prompt: failedNight ? `click to run ${nextName} back` : `click for ${nextName} · ${NIGHT_DATES[r.nightIndex + 1] ?? ''}`,
|
||||||
next: () => this.scene.start('Night', {}),
|
next: () => this.scene.start('Roster', {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -187,6 +189,10 @@ export class NightSummaryScene extends Phaser.Scene {
|
|||||||
`${this.state.heatStrikes.length}/3`,
|
`${this.state.heatStrikes.length}/3`,
|
||||||
this.state.heatStrikes.length > 0 ? '#ff8080' : undefined,
|
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) => {
|
rows.forEach(([label, value, colour], i) => {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import Phaser from 'phaser';
|
|||||||
import { renderDoll } from '../../patrons/doll';
|
import { renderDoll } from '../../patrons/doll';
|
||||||
import { dollPlan } from '../../patrons/dollPlan';
|
import { dollPlan } from '../../patrons/dollPlan';
|
||||||
import { drunkStage } from '../../rules/drunk';
|
import { drunkStage } from '../../rules/drunk';
|
||||||
|
import { lipReadLine } from '../../rules/lipRead';
|
||||||
import type { Patron } from '../../data/types';
|
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 { 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';
|
import { encounterById, type EncounterId } from '../../rules/encounters';
|
||||||
@ -25,6 +26,8 @@ export class PatronUpView {
|
|||||||
private swayPx = 0;
|
private swayPx = 0;
|
||||||
/** The bubble currently on screen. Beats REPLACE it; they do not stack. */
|
/** The bubble currently on screen. Beats REPLACE it; they do not stack. */
|
||||||
private bubble: Phaser.GameObjects.GameObject[] = [];
|
private bubble: Phaser.GameObjects.GameObject[] = [];
|
||||||
|
/** ROOM (design §6): every line renders as a lip-read guess. Set by DoorScene. */
|
||||||
|
lipRead = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly scene: Phaser.Scene,
|
private readonly scene: Phaser.Scene,
|
||||||
@ -109,8 +112,9 @@ export class PatronUpView {
|
|||||||
const line = pool[greetingIndex(p, pool.length)] ?? PATRON_GREETINGS[0]!;
|
const line = pool[greetingIndex(p, pool.length)] ?? PATRON_GREETINGS[0]!;
|
||||||
|
|
||||||
const bubbleY = -DOLL_H * SCALE - 16;
|
const bubbleY = -DOLL_H * SCALE - 16;
|
||||||
|
// Lip-read guesses drop the quote marks — it's your reading, not their words.
|
||||||
const label = scene.add
|
const label = scene.add
|
||||||
.text(0, bubbleY, `"${line}"`, {
|
.text(0, bubbleY, this.lipRead ? lipReadLine(line, p.id) : `"${line}"`, {
|
||||||
fontFamily: MONO,
|
fontFamily: MONO,
|
||||||
fontSize: '8px',
|
fontSize: '8px',
|
||||||
color: '#d8d0c0',
|
color: '#d8d0c0',
|
||||||
@ -143,7 +147,7 @@ export class PatronUpView {
|
|||||||
this.clearBubble();
|
this.clearBubble();
|
||||||
const bubbleY = -DOLL_H * SCALE - 16;
|
const bubbleY = -DOLL_H * SCALE - 16;
|
||||||
const label = scene.add
|
const label = scene.add
|
||||||
.text(0, bubbleY, `"${line}"`, {
|
.text(0, bubbleY, this.lipRead ? lipReadLine(line, this.patron.id) : `"${line}"`, {
|
||||||
fontFamily: MONO,
|
fontFamily: MONO,
|
||||||
fontSize: '8px',
|
fontSize: '8px',
|
||||||
color: '#e8dcc4',
|
color: '#e8dcc4',
|
||||||
|
|||||||
@ -69,6 +69,55 @@ export const QUEUE_TUNING = {
|
|||||||
encountersPerNight: 3,
|
encountersPerNight: 3,
|
||||||
} as const;
|
} 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,
|
||||||
|
/**
|
||||||
|
* Hype the trickle can earn PER STAND — the same guard queue theatre has
|
||||||
|
* (QUEUE_TUNING.hypePerWaitCap), for the same reason. Uncapped, a bot that
|
||||||
|
* kept three tolerant patrons on the velvet all night rode hype to the x3
|
||||||
|
* ceiling, and since Meters scales vibe GAINS by hype, vibe then pinned:
|
||||||
|
* post-11PM pinned share went 0.27 -> 0.71 against a documented band of
|
||||||
|
* <0.5 (tests/sim/floorEconomy). The stand's real payoff is the entrance
|
||||||
|
* dividend below; this trickle is the flavour, so it is the part that caps.
|
||||||
|
*/
|
||||||
|
hypePerStandCap: 0.15,
|
||||||
|
/** 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;
|
||||||
|
/** Trickle hype already paid for this stand (capped — see the tuning note). */
|
||||||
|
hypeEarned: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface QueueSnapshot {
|
export interface QueueSnapshot {
|
||||||
waiting: Patron[];
|
waiting: Patron[];
|
||||||
visible: Patron[];
|
visible: Patron[];
|
||||||
@ -103,9 +152,18 @@ export class QueueManager {
|
|||||||
private readonly encounterOf = new Map<string, EncounterId>();
|
private readonly encounterOf = new Map<string, EncounterId>();
|
||||||
/** patrons queued to re-enter the queue: [clockMin, patron] */
|
/** patrons queued to re-enter the queue: [clockMin, patron] */
|
||||||
private readonly comebacks: Array<{ atMin: number; patron: 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 */
|
/** everyone who reached the front tonight, for the summary screen */
|
||||||
readonly seen: Patron[] = [];
|
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(
|
constructor(
|
||||||
private readonly bus: EventBus,
|
private readonly bus: EventBus,
|
||||||
@ -134,11 +192,20 @@ export class QueueManager {
|
|||||||
this.encounterRng = rng.stream('encounters');
|
this.encounterRng = rng.stream('encounters');
|
||||||
this.castRng = rng.stream('castPick');
|
this.castRng = rng.stream('castPick');
|
||||||
this.passbackRng = rng.stream('passback');
|
this.passbackRng = rng.stream('passback');
|
||||||
|
this.carpetRng = rng.stream('carpet');
|
||||||
bus.on('door:verdict', ({ patron, verdict }) => {
|
bus.on('door:verdict', ({ patron, verdict }) => {
|
||||||
// Twin B arrives a few minutes after A's verdict, WHATEVER it was — he
|
// 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
|
// 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.
|
// real name, his brother's face. The photo check is the thing on trial.
|
||||||
if (this.encounterOf.get(patron.id) === 'twins') {
|
// ...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);
|
||||||
this.comebacks.push({
|
this.comebacks.push({
|
||||||
atMin: this.clockMin + this.comebackRng.int(2, 5),
|
atMin: this.clockMin + this.comebackRng.int(2, 5),
|
||||||
patron: twinSibling(patron),
|
patron: twinSibling(patron),
|
||||||
@ -222,6 +289,7 @@ export class QueueManager {
|
|||||||
update(deltaMs: number): void {
|
update(deltaMs: number): void {
|
||||||
const sec = deltaMs / 1000;
|
const sec = deltaMs / 1000;
|
||||||
this.phoneCooldownMs = Math.max(0, this.phoneCooldownMs - deltaMs);
|
this.phoneCooldownMs = Math.max(0, this.phoneCooldownMs - deltaMs);
|
||||||
|
this.tickCarpet(deltaMs);
|
||||||
|
|
||||||
const idle = this.up === null && this.waiting.length > 0;
|
const idle = this.up === null && this.waiting.length > 0;
|
||||||
if (idle) {
|
if (idle) {
|
||||||
@ -256,6 +324,87 @@ 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;
|
||||||
|
// Per-slot accounting: each stand pays the trickle only up to its cap, so
|
||||||
|
// three tolerant patrons parked all night can no longer print hype.
|
||||||
|
let hype = 0;
|
||||||
|
for (const slot of this.carpet) {
|
||||||
|
const room = Math.max(0, CARPET_TUNING.hypePerStandCap - slot.hypeEarned);
|
||||||
|
const earned = Math.min(room, CARPET_TUNING.hypePerSecPerBody * sec);
|
||||||
|
slot.hypeEarned += earned;
|
||||||
|
hype += earned;
|
||||||
|
}
|
||||||
|
if (hype > 0) this.bus.emit('meters:delta', { hype });
|
||||||
|
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,
|
||||||
|
hypeEarned: 0,
|
||||||
|
});
|
||||||
|
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. */
|
/** The Rope. Nothing steps up until the player says so. */
|
||||||
callNext(): Patron | null {
|
callNext(): Patron | null {
|
||||||
if (this.up !== null) return null;
|
if (this.up !== null) return null;
|
||||||
|
|||||||
17
src/scenes/door/tutorialFade.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
/** How long the door tutorial text stays fully lit before it starts to fade. */
|
||||||
|
export const TUTORIAL_MS = 26_000;
|
||||||
|
/** Length of the fade once the hold expires. */
|
||||||
|
export const TUTORIAL_FADE_MS = 800;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tutorial text alpha at `elapsedMs` into the night. Pure update()-driven math,
|
||||||
|
* deliberately NOT a tween: the old `if (alpha > 0) tweens.add(...)` guard
|
||||||
|
* spawned a fresh 800ms tween every frame (each new tween re-captured the
|
||||||
|
* current alpha and kept it above zero, so the guard never closed — ~1,100 live
|
||||||
|
* tweens by night's end), and tweens stall under the preview pane's stepped
|
||||||
|
* clock anyway.
|
||||||
|
*/
|
||||||
|
export function tutorialAlpha(elapsedMs: number): number {
|
||||||
|
if (elapsedMs <= TUTORIAL_MS) return 1;
|
||||||
|
return Math.max(0, 1 - (elapsedMs - TUTORIAL_MS) / TUTORIAL_FADE_MS);
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
import { EventBus } from '../../core/EventBus';
|
import { EventBus } from '../../core/EventBus';
|
||||||
import { GameClock } from '../../core/GameClock';
|
import { GameClock } from '../../core/GameClock';
|
||||||
import { SeededRNG } from '../../core/SeededRNG';
|
import { SeededRNG, type RngStream } from '../../core/SeededRNG';
|
||||||
import { StubBeatClock } from '../../core/StubBeatClock';
|
import { StubBeatClock } from '../../core/StubBeatClock';
|
||||||
import { Meters, freshNightState } from '../../core/meters';
|
import { Meters, freshNightState } from '../../core/meters';
|
||||||
import { drunkStage } from '../../rules/drunk';
|
import { drunkStage } from '../../rules/drunk';
|
||||||
@ -28,10 +28,33 @@ import {
|
|||||||
SLIP_CHANCE_PER_S, SLIP_COOLDOWN_MS, SLIP_RADIUS_PX,
|
SLIP_CHANCE_PER_S, SLIP_COOLDOWN_MS, SLIP_RADIUS_PX,
|
||||||
VOMIT_CHANCE_PER_S, watered,
|
VOMIT_CHANCE_PER_S, watered,
|
||||||
} from './hazards';
|
} from './hazards';
|
||||||
import { RACK_RESPAWN_MIN, freshRack, stepRack, turnSharpness, type RackState } from './glassie';
|
|
||||||
import { tileToWorld } from './venueMap';
|
|
||||||
import {
|
import {
|
||||||
AID_UI, HELP_TEXT, JOINT_RESULTS, JOINT_STING, JOINT_UI, NO_STAMP_RADIO,
|
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 {
|
||||||
|
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,
|
||||||
PHONE_POSTED, PHONE_UI, VOMIT_UI, WATER_UI,
|
PHONE_POSTED, PHONE_UI, VOMIT_UI, WATER_UI,
|
||||||
} from '../../data/strings/floor';
|
} from '../../data/strings/floor';
|
||||||
import { MeterHud } from '../../ui/MeterHud';
|
import { MeterHud } from '../../ui/MeterHud';
|
||||||
@ -49,6 +72,23 @@ const RACK_SPOT = tileToWorld(51, 7);
|
|||||||
const DISH_SPOT = tileToWorld(17, 3);
|
const DISH_SPOT = tileToWorld(17, 3);
|
||||||
/** The sinks (venueMap rect 66..71,15): where a cup of water comes from. */
|
/** The sinks (venueMap rect 66..71,15): where a cup of water comes from. */
|
||||||
const WATER_SPOT = tileToWorld(68, 15);
|
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. */
|
/** Crowd contact distance while carrying, and its debounce. */
|
||||||
const CARRY_BUMP_PX = 14;
|
const CARRY_BUMP_PX = 14;
|
||||||
const CARRY_BUMP_COOLDOWN_MS = 350;
|
const CARRY_BUMP_COOLDOWN_MS = 350;
|
||||||
@ -60,7 +100,7 @@ const STAGE_RANK: Readonly<Record<DrunkStage, number>> = {
|
|||||||
/** What pressing E on the current target would do. */
|
/** What pressing E on the current target would do. */
|
||||||
type InteractKind =
|
type InteractKind =
|
||||||
| 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'exit' | 'joint' | 'rack' | 'dishwasher'
|
| 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'exit' | 'joint' | 'rack' | 'dishwasher'
|
||||||
| 'puddle' | 'water' | 'filming' | 'fainter';
|
| 'puddle' | 'water' | 'filming' | 'fainter' | 'decks' | 'taps';
|
||||||
|
|
||||||
interface InteractTarget {
|
interface InteractTarget {
|
||||||
kind: InteractKind;
|
kind: InteractKind;
|
||||||
@ -116,6 +156,30 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
private fainter: { agentId: string; x: number; y: number; msLeft: number } | null = null;
|
private fainter: { agentId: string; x: number; y: number; msLeft: number } | null = null;
|
||||||
private fainterAtMin: number | null = null;
|
private fainterAtMin: number | null = null;
|
||||||
private aid!: AidOverlay;
|
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). */
|
/** Clock minutes at which somebody comes OVER the yard fence (night mode). */
|
||||||
private jumperTimes: number[] = [];
|
private jumperTimes: number[] = [];
|
||||||
private jumperRng: SeededRNG | null = null;
|
private jumperRng: SeededRNG | null = null;
|
||||||
@ -150,6 +214,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
|
|
||||||
create(data?: { night?: NightContext }): void {
|
create(data?: { night?: NightContext }): void {
|
||||||
this.nightCtx = data?.night ?? null;
|
this.nightCtx = data?.night ?? null;
|
||||||
|
this.resetNightState();
|
||||||
// Wired explicitly: Phaser does not invoke a `shutdown` METHOD on a Scene
|
// Wired explicitly: Phaser does not invoke a `shutdown` METHOD on a Scene
|
||||||
// subclass; without this, listeners and sprites leak on every scene stop.
|
// subclass; without this, listeners and sprites leak on every scene stop.
|
||||||
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.teardown());
|
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.teardown());
|
||||||
@ -164,6 +229,48 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
this.load.start();
|
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 {
|
private loadGenProps(): void {
|
||||||
const manifest = this.cache.json.get('propManifest') as { props?: string[] } | undefined;
|
const manifest = this.cache.json.get('propManifest') as { props?: string[] } | undefined;
|
||||||
const kinds = manifest?.props ?? [];
|
const kinds = manifest?.props ?? [];
|
||||||
@ -183,6 +290,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
this.patDown = new PatDownOverlay(this, this.nightCtx?.sfx ?? null);
|
this.patDown = new PatDownOverlay(this, this.nightCtx?.sfx ?? null);
|
||||||
this.choice = new ChoiceOverlay(this);
|
this.choice = new ChoiceOverlay(this);
|
||||||
this.aid = new AidOverlay(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.
|
// Some nights have a fainter. Most don't. You find out at the time.
|
||||||
const faintRng = this.nightCtx
|
const faintRng = this.nightCtx
|
||||||
? new SeededRNG(this.nightCtx.seed ^ 0xa1d5).stream('fainter')
|
? new SeededRNG(this.nightCtx.seed ^ 0xa1d5).stream('fainter')
|
||||||
@ -202,7 +310,9 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
this.promptText = this.fixedText(4, VIEW_H - 24, '#9fe8a0');
|
this.promptText = this.fixedText(4, VIEW_H - 24, '#9fe8a0');
|
||||||
this.statusText = this.fixedText(4, VIEW_H - 12, '#556');
|
this.statusText = this.fixedText(4, VIEW_H - 12, '#556');
|
||||||
this.radioText = this.fixedText(4, 26, '#d8a020');
|
this.radioText = this.fixedText(4, 26, '#d8a020');
|
||||||
this.fixedText(4, 14, '#445').setText(HELP_TEXT);
|
this.fixedText(4, 14, '#445').setText(
|
||||||
|
this.nightCtx?.venue.radioCommand ? `${HELP_TEXT} · ${STAFF_RADIO.hint}` : HELP_TEXT,
|
||||||
|
);
|
||||||
|
|
||||||
this.keys = this.input.keyboard!.addKeys(
|
this.keys = this.input.keyboard!.addKeys(
|
||||||
'W,A,S,D,UP,DOWN,LEFT,RIGHT,E,TAB,SPACE,ESC,R',
|
'W,A,S,D,UP,DOWN,LEFT,RIGHT,E,TAB,SPACE,ESC,R',
|
||||||
@ -219,6 +329,19 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
/** Night mode: adopt the night's world instead of building our own. */
|
/** Night mode: adopt the night's world instead of building our own. */
|
||||||
private bindNight(ctx: NightContext): void {
|
private bindNight(ctx: NightContext): void {
|
||||||
this.seed = ctx.seed;
|
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.bus = ctx.bus;
|
||||||
this.rng = ctx.rng;
|
this.rng = ctx.rng;
|
||||||
this.clock = ctx.clock;
|
this.clock = ctx.clock;
|
||||||
@ -226,6 +349,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
this.beat = null; // beat:tick arrives on the shared bus (stub, then TechnoEngine)
|
this.beat = null; // beat:tick arrives on the shared bus (stub, then TechnoEngine)
|
||||||
this.meters = null; // NightScene owns the single writer
|
this.meters = null; // NightScene owns the single writer
|
||||||
this.hud = new MeterHud(this, this.bus, { initialHeat: this.night.heatStrikes.length });
|
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.stall = new StallOverlay(this, this.bus, ctx.beatIntervalMs, ctx.sfx);
|
||||||
|
|
||||||
this.crowd = new CrowdSim({ map: VENUE, bus: this.bus, rng: this.rng.stream('crowd') });
|
this.crowd = new CrowdSim({ map: VENUE, bus: this.bus, rng: this.rng.stream('crowd') });
|
||||||
@ -250,6 +374,12 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
this.spotted.clear();
|
this.spotted.clear();
|
||||||
this.cutOffDealt.clear();
|
this.cutOffDealt.clear();
|
||||||
this.jointDealt.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.rack = null;
|
||||||
this.rackReadyAtMin = 6;
|
this.rackReadyAtMin = 6;
|
||||||
|
|
||||||
@ -272,6 +402,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
if (this.present) this.target = null;
|
if (this.present) this.target = null;
|
||||||
}),
|
}),
|
||||||
this.bus.on('beat:tick', ({ beatIndex }) => {
|
this.bus.on('beat:tick', ({ beatIndex }) => {
|
||||||
|
this.lastBeat = { index: beatIndex, atMs: this.elapsedMs };
|
||||||
this.crowd.onBeat(beatIndex);
|
this.crowd.onBeat(beatIndex);
|
||||||
this.view.onBeat(beatIndex);
|
this.view.onBeat(beatIndex);
|
||||||
}),
|
}),
|
||||||
@ -303,12 +434,14 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
this.night.location = 'floor';
|
this.night.location = 'floor';
|
||||||
this.meters = new Meters(this.bus, this.night);
|
this.meters = new Meters(this.bus, this.night);
|
||||||
this.hud = new HudStub(this, this.bus);
|
this.hud = new HudStub(this, this.bus);
|
||||||
|
this.beatMs = this.beat.beatIntervalMs;
|
||||||
this.stall = new StallOverlay(this, this.bus, 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.crowd = new CrowdSim({ map: VENUE, bus: this.bus, rng: this.rng.stream('crowd') });
|
||||||
this.fights?.destroy();
|
this.fights?.destroy();
|
||||||
this.fights = new FightDirector({ bus: this.bus, rng: this.rng.stream('fights'), crowd: this.crowd });
|
this.fights = new FightDirector({ bus: this.bus, rng: this.rng.stream('fights'), crowd: this.crowd });
|
||||||
this.bus.on('beat:tick', ({ beatIndex }) => {
|
this.bus.on('beat:tick', ({ beatIndex }) => {
|
||||||
|
this.lastBeat = { index: beatIndex, atMs: this.elapsedMs };
|
||||||
this.crowd.onBeat(beatIndex);
|
this.crowd.onBeat(beatIndex);
|
||||||
this.view.onBeat(beatIndex);
|
this.view.onBeat(beatIndex);
|
||||||
});
|
});
|
||||||
@ -326,6 +459,12 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
this.spotted.clear();
|
this.spotted.clear();
|
||||||
this.cutOffDealt.clear();
|
this.cutOffDealt.clear();
|
||||||
this.jointDealt.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.rack = null;
|
||||||
this.rackReadyAtMin = 6;
|
this.rackReadyAtMin = 6;
|
||||||
|
|
||||||
@ -339,22 +478,52 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
const key = ev.key === ' ' ? 'SPACE' : ev.key.toUpperCase();
|
const key = ev.key === ' ' ? 'SPACE' : ev.key.toUpperCase();
|
||||||
|
|
||||||
if (this.aid.isOpen) return this.aid.handleKey(key);
|
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.cutOff.isOpen) return this.cutOff.handleKey(key);
|
||||||
if (this.stall.isOpen) return this.stall.handleKey(key);
|
if (this.stall.isOpen) return this.stall.handleKey(key);
|
||||||
if (this.patDown.isOpen) return this.patDown.handleKey(key);
|
if (this.patDown.isOpen) return this.patDown.handleKey(key);
|
||||||
if (this.choice.isOpen) return this.choice.handleKey(key);
|
if (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') {
|
if (key === 'TAB') {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
this.uvMode = !this.uvMode;
|
this.uvMode = !this.uvMode;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (key === 'R' && !this.nightCtx) return this.resetRun(this.seed + 1);
|
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();
|
if (key === 'E') return this.interact();
|
||||||
}
|
}
|
||||||
|
|
||||||
private get overlayOpen(): boolean {
|
private get overlayOpen(): boolean {
|
||||||
return this.cutOff.isOpen || this.stall.isOpen || this.patDown.isOpen || this.choice.isOpen || this.aid.isOpen;
|
return this.cutOff.isOpen || this.stall.isOpen || this.patDown.isOpen || this.choice.isOpen || this.aid.isOpen || this.pour.isOpen;
|
||||||
}
|
}
|
||||||
|
|
||||||
private readInput(): PlayerInput {
|
private readInput(): PlayerInput {
|
||||||
@ -375,6 +544,10 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
if (!this.view) return; // world still waiting on the prop manifest
|
if (!this.view) return; // world still waiting on the prop manifest
|
||||||
const dt = Math.min(deltaMs, 50);
|
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
|
// These run REGARDLESS of overlays. The music doesn't stop because you
|
||||||
// opened a cubicle door — and the stall minigame judges against beat:tick,
|
// opened a cubicle door — and the stall minigame judges against beat:tick,
|
||||||
// so freezing the beat clock behind a modal makes it literally unplayable.
|
// so freezing the beat clock behind a modal makes it literally unplayable.
|
||||||
@ -437,6 +610,12 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
this.cutOff.update(dt);
|
this.cutOff.update(dt);
|
||||||
this.stall.update(dt);
|
this.stall.update(dt);
|
||||||
this.patDown.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
|
// World keeps breathing behind the panel, but you can't move while you're
|
||||||
// dealing with someone — that's the point of the mechanic.
|
// dealing with someone — that's the point of the mechanic.
|
||||||
this.crowd.update(dt, this.clock.clockMin);
|
this.crowd.update(dt, this.clock.clockMin);
|
||||||
@ -447,7 +626,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// Escorting costs you the walk: no interacting, slower going.
|
// Escorting costs you the walk: no interacting, slower going.
|
||||||
const input = this.readInput();
|
const input = this.readInput();
|
||||||
this.player = stepPlayer(this.player, input, dt, VENUE);
|
this.player = this.djMode || this.barMode ? this.player : stepPlayer(this.player, input, dt, VENUE);
|
||||||
|
|
||||||
if (!this.nightCtx) {
|
if (!this.nightCtx) {
|
||||||
// Demo mode stands in for the door with a synthetic spawner.
|
// Demo mode stands in for the door with a synthetic spawner.
|
||||||
@ -470,7 +649,8 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
|
|
||||||
this.trackMaggots();
|
this.trackMaggots();
|
||||||
this.tickHazards(dt);
|
this.tickHazards(dt);
|
||||||
this.aid.update(dt);
|
this.tickDecks(dt);
|
||||||
|
this.tickBar(dt);
|
||||||
|
|
||||||
// Mopping: feet planted until it's done. The commitment IS the mechanic.
|
// Mopping: feet planted until it's done. The commitment IS the mechanic.
|
||||||
if (this.mop) {
|
if (this.mop) {
|
||||||
@ -630,6 +810,22 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
if (d <= INTERACT_RANGE) return { kind: 'water', prompt: WATER_UI.pickup };
|
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.
|
// Stall doors first — you interact with the door, not a person.
|
||||||
for (const s of VENUE.stalls) {
|
for (const s of VENUE.stalls) {
|
||||||
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, s.door.x, s.door.y);
|
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, s.door.x, s.door.y);
|
||||||
@ -704,6 +900,8 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
if (t.kind === 'exit') return this.nightCtx?.requestLocation('door');
|
if (t.kind === 'exit') return this.nightCtx?.requestLocation('door');
|
||||||
if (t.kind === 'rack') return this.takeRack();
|
if (t.kind === 'rack') return this.takeRack();
|
||||||
if (t.kind === 'dishwasher') return this.deliverRack();
|
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 === 'fainter') return this.openAid();
|
||||||
if (t.kind === 'puddle') return this.interactPuddle();
|
if (t.kind === 'puddle') return this.interactPuddle();
|
||||||
if (t.kind === 'water') {
|
if (t.kind === 'water') {
|
||||||
@ -991,6 +1189,605 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// ---- chrome ------------------------------------------------------------
|
// ---- 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
|
* Everything on the floor that happens TO the room rather than through the
|
||||||
* player: vomit + slips, the filming influencer's timer, the fainter. Runs
|
* player: vomit + slips, the filming influencer's timer, the fainter. Runs
|
||||||
@ -1140,7 +1937,13 @@ export class FloorDemoScene extends Phaser.Scene {
|
|||||||
this.fainter = null;
|
this.fainter = null;
|
||||||
this.view.setDowned(null);
|
this.view.setDowned(null);
|
||||||
const a = this.crowd.agents.find((x) => x.patron.id === f.agentId);
|
const a = this.crowd.agents.find((x) => x.patron.id === f.agentId);
|
||||||
if (a) a.gone = true;
|
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 (success) {
|
if (success) {
|
||||||
this.bus.emit('meters:delta', { vibe: -3, aggro: -3 });
|
this.bus.emit('meters:delta', { vibe: -3, aggro: -3 });
|
||||||
this.logIncident('firstAid', f.agentId, 'Recovery position. Walked out the back to a taxi.');
|
this.logIncident('firstAid', f.agentId, 'Recovery position. Walked out the back to a taxi.');
|
||||||
|
|||||||
152
src/scenes/floor/barShift.ts
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
// 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,6 +447,12 @@ export class CrowdSim {
|
|||||||
else this.chooseTarget(a); // nowhere to go from here — find somewhere else to be
|
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. */
|
/** Done for the night: point them at the way out. */
|
||||||
private headHome(a: Agent): void {
|
private headHome(a: Agent): void {
|
||||||
const plan = this.plans.get(a);
|
const plan = this.plans.get(a);
|
||||||
|
|||||||
95
src/scenes/floor/djShift.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
// 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;
|
||||||
|
}
|
||||||
101
src/scenes/floor/overlays/PourOverlay.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
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,6 +300,35 @@ export const VENUE: VenueMap = Object.freeze({
|
|||||||
stalls: Object.freeze(buildStalls()),
|
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 {
|
export function tileAt(map: VenueMap, tx: number, ty: number): TileKind {
|
||||||
if (tx < 0 || tx >= map.width || ty < 0 || ty >= map.height) return 'void';
|
if (tx < 0 || tx >= map.width || ty < 0 || ty >= map.height) return 'void';
|
||||||
return map.tiles[ty * map.width + tx] ?? 'void';
|
return map.tiles[ty * map.width + tx] ?? 'void';
|
||||||
|
|||||||
99
src/scenes/shared/RosterScene.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
tests/dressCodeShuffle.test.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
168
tests/floor/barShift.test.ts
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
94
tests/floor/djShift.test.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
90
tests/floor/stations.test.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
47
tests/floorScore.test.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
38
tests/lipRead.test.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
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,6 +22,10 @@ const log = (over: Partial<NightLog> = {}): NightLog => ({
|
|||||||
heatStrikes: [],
|
heatStrikes: [],
|
||||||
endReason: 'clock',
|
endReason: 'clock',
|
||||||
seed: 1,
|
seed: 1,
|
||||||
|
role: 'DOOR',
|
||||||
|
wage: 180,
|
||||||
|
floorCash: 0,
|
||||||
|
pocketCash: 0,
|
||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -125,3 +129,26 @@ describe('shouldRecordStrike', () => {
|
|||||||
expect(shouldRecordStrike(s, { reason: 'over capacity', deferred: true })).toBe(false);
|
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 { EventBus } from '../src/core/EventBus';
|
||||||
import { SeededRNG } from '../src/core/SeededRNG';
|
import { SeededRNG } from '../src/core/SeededRNG';
|
||||||
import { _resetPatronSerial } from '../src/patrons/generator';
|
import { _resetPatronSerial } from '../src/patrons/generator';
|
||||||
import { QueueManager, QUEUE_TUNING } from '../src/scenes/door/QueueManager';
|
import { CARPET_TUNING, QueueManager, QUEUE_TUNING, entranceBonus } from '../src/scenes/door/QueueManager';
|
||||||
import type { Patron } from '../src/data/types';
|
import type { Patron } from '../src/data/types';
|
||||||
|
|
||||||
const NIGHT = new Date('2026-07-18T00:00:00');
|
const NIGHT = new Date('2026-07-18T00:00:00');
|
||||||
@ -289,3 +289,99 @@ describe('QueueManager — determinism', () => {
|
|||||||
expect(nightFingerprint(1)).not.toEqual(nightFingerprint(2));
|
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('the trickle caps per stand — you cannot park one body and print hype', () => {
|
||||||
|
// Uncapped, a bot parking three tolerant patrons all night rode hype to the
|
||||||
|
// x3 ceiling and pinned vibe with it (tests/sim/floorEconomy found this).
|
||||||
|
const { q, total } = carpetSetup();
|
||||||
|
q.callNext();
|
||||||
|
q.toCarpet();
|
||||||
|
const before = total('hype');
|
||||||
|
// Far longer than the cap can pay for, but inside the shortest patience.
|
||||||
|
for (let i = 0; i < 25; i++) q.update(1000);
|
||||||
|
const carpetHype = total('hype') - before;
|
||||||
|
// Queue theatre keeps paying its own capped share, so this is an upper bound.
|
||||||
|
expect(carpetHype).toBeLessThan(CARPET_TUNING.hypePerStandCap + QUEUE_TUNING.hypePerWaitCap + 0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
46
tests/roster.test.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -8,7 +8,7 @@ import { GameClock, DEFAULT_CLOCK } from '../../src/core/GameClock';
|
|||||||
import { Meters, freshNightState, vibeCoolingPerMin } from '../../src/core/meters';
|
import { Meters, freshNightState, vibeCoolingPerMin } from '../../src/core/meters';
|
||||||
import { SeededRNG } from '../../src/core/SeededRNG';
|
import { SeededRNG } from '../../src/core/SeededRNG';
|
||||||
import { _resetPatronSerial } from '../../src/patrons/generator';
|
import { _resetPatronSerial } from '../../src/patrons/generator';
|
||||||
import { QueueManager } from '../../src/scenes/door/QueueManager';
|
import { CARPET_TUNING, QueueManager, entranceBonus } from '../../src/scenes/door/QueueManager';
|
||||||
import { shouldRecordStrike } from '../../src/rules/heat';
|
import { shouldRecordStrike } from '../../src/rules/heat';
|
||||||
import { LOCKOUT_MIN, judge } from '../../src/rules/judge';
|
import { LOCKOUT_MIN, judge } from '../../src/rules/judge';
|
||||||
import { violations } from '../../src/rules/dressCode';
|
import { violations } from '../../src/rules/dressCode';
|
||||||
@ -23,6 +23,13 @@ export interface BotPolicy {
|
|||||||
/** Real ms spent inspecting before the verdict lands. Models human reading. */
|
/** Real ms spent inspecting before the verdict lands. Models human reading. */
|
||||||
ruleDelayMs: (p: Patron, clockMin: number, nightDate: Date) => number;
|
ruleDelayMs: (p: Patron, clockMin: number, nightDate: Date) => number;
|
||||||
decide: (p: Patron, clockMin: number, nightDate: Date, insideCount: number, licensed: number) => 'admit' | 'deny';
|
decide: (p: Patron, clockMin: number, nightDate: Date, insideCount: number, licensed: number) => 'admit' | 'deny';
|
||||||
|
/**
|
||||||
|
* The red carpet (design §2 theatre): park this one between the posts instead
|
||||||
|
* of ruling on them? Omitted = never uses it, which is every pre-carpet band.
|
||||||
|
*/
|
||||||
|
carpet?: (p: Patron, clockMin: number) => boolean;
|
||||||
|
/** Real ms the bot leaves them standing there before waving them up. */
|
||||||
|
carpetStandMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NightMetrics {
|
export interface NightMetrics {
|
||||||
@ -49,6 +56,12 @@ export interface NightMetrics {
|
|||||||
slamQueueMax: number;
|
slamQueueMax: number;
|
||||||
slamQueueAvg: number;
|
slamQueueAvg: number;
|
||||||
inside: number;
|
inside: number;
|
||||||
|
/** Red carpet: bodies parked, walk-offs, and admits that paid the entrance. */
|
||||||
|
carpetParked: number;
|
||||||
|
carpetStorms: number;
|
||||||
|
carpetEntrances: number;
|
||||||
|
/** Share of post-11PM samples with hype pinned at the x3 ceiling. */
|
||||||
|
hypePinnedShare: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TICK_MS = 100;
|
const TICK_MS = 100;
|
||||||
@ -100,7 +113,12 @@ export function simulateNight(
|
|||||||
slamQueueMax: 0,
|
slamQueueMax: 0,
|
||||||
slamQueueAvg: 0,
|
slamQueueAvg: 0,
|
||||||
inside: 0,
|
inside: 0,
|
||||||
|
carpetParked: 0,
|
||||||
|
carpetStorms: 0,
|
||||||
|
carpetEntrances: 0,
|
||||||
|
hypePinnedShare: 0,
|
||||||
};
|
};
|
||||||
|
const hypeSamples: number[] = [];
|
||||||
|
|
||||||
let ended = false;
|
let ended = false;
|
||||||
bus.on('meters:changed', ({ vibe, aggro, hype }) => {
|
bus.on('meters:changed', ({ vibe, aggro, hype }) => {
|
||||||
@ -156,6 +174,7 @@ export function simulateNight(
|
|||||||
lastSample = clockMin;
|
lastSample = clockMin;
|
||||||
metrics.vibeSamples.push(state.vibe);
|
metrics.vibeSamples.push(state.vibe);
|
||||||
metrics.aggroSamples.push(state.aggro);
|
metrics.aggroSamples.push(state.aggro);
|
||||||
|
hypeSamples.push(state.hype);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -185,6 +204,12 @@ export function simulateNight(
|
|||||||
if (outcome.aggroDelta) delta.aggro = outcome.aggroDelta;
|
if (outcome.aggroDelta) delta.aggro = outcome.aggroDelta;
|
||||||
if (outcome.hypeDelta) delta.hype = outcome.hypeDelta;
|
if (outcome.hypeDelta) delta.hype = outcome.hypeDelta;
|
||||||
if (Object.keys(delta).length > 0) bus.emit('meters:delta', delta);
|
if (Object.keys(delta).length > 0) bus.emit('meters:delta', delta);
|
||||||
|
// Mirrors DoorScene.rule: an admit off the carpet pays the entrance.
|
||||||
|
const stoodMs = queue.carpetMsFor(p.id);
|
||||||
|
if (verdict === 'admit' && stoodMs > 0) {
|
||||||
|
metrics.carpetEntrances++;
|
||||||
|
bus.emit('meters:delta', { vibe: CARPET_TUNING.entranceVibe, hype: entranceBonus(stoodMs) });
|
||||||
|
}
|
||||||
if (outcome.heatStrike && !outcome.heatStrike.deferred && shouldRecordStrike(state, outcome.heatStrike)) {
|
if (outcome.heatStrike && !outcome.heatStrike.deferred && shouldRecordStrike(state, outcome.heatStrike)) {
|
||||||
bus.emit('heat:strike', outcome.heatStrike);
|
bus.emit('heat:strike', outcome.heatStrike);
|
||||||
}
|
}
|
||||||
@ -195,6 +220,7 @@ export function simulateNight(
|
|||||||
clock.start();
|
clock.start();
|
||||||
let slamTicks = 0;
|
let slamTicks = 0;
|
||||||
let slamQueueSum = 0;
|
let slamQueueSum = 0;
|
||||||
|
const parked = new Set<string>();
|
||||||
|
|
||||||
const totalMs = DEFAULT_CLOCK.nightRealMinutes * 60_000;
|
const totalMs = DEFAULT_CLOCK.nightRealMinutes * 60_000;
|
||||||
for (let t = 0; t < totalMs && !ended; t += TICK_MS) {
|
for (let t = 0; t < totalMs && !ended; t += TICK_MS) {
|
||||||
@ -208,15 +234,35 @@ export function simulateNight(
|
|||||||
metrics.slamQueueMax = Math.max(metrics.slamQueueMax, queue.queueLength);
|
metrics.slamQueueMax = Math.max(metrics.slamQueueMax, queue.queueLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The carpet ticks itself inside queue.update(); the bot only decides who
|
||||||
|
// goes on it and when they come off. Walk-offs are counted, never re-parked.
|
||||||
|
metrics.carpetStorms += queue.takeStorms().length;
|
||||||
|
|
||||||
const up = queue.patronUp;
|
const up = queue.patronUp;
|
||||||
if (up) {
|
if (up) {
|
||||||
if (ruleTimerMs < 0) ruleTimerMs = policy.ruleDelayMs(up, state.clockMin, clock.nightDate);
|
if (ruleTimerMs < 0) ruleTimerMs = policy.ruleDelayMs(up, state.clockMin, clock.nightDate);
|
||||||
ruleTimerMs -= TICK_MS;
|
ruleTimerMs -= TICK_MS;
|
||||||
if (ruleTimerMs <= 0) {
|
if (ruleTimerMs <= 0) {
|
||||||
applyVerdict(up, policy.decide(up, state.clockMin, clock.nightDate, state.capacity.inside, state.capacity.licensed));
|
// Park them instead of ruling — but only once each, or a showpony bot
|
||||||
|
// would loop one influencer round the posts all night.
|
||||||
|
if (
|
||||||
|
policy.carpet?.(up, state.clockMin) === true &&
|
||||||
|
!parked.has(up.id) &&
|
||||||
|
queue.toCarpet() !== null
|
||||||
|
) {
|
||||||
|
parked.add(up.id);
|
||||||
|
metrics.carpetParked++;
|
||||||
|
} else {
|
||||||
|
applyVerdict(up, policy.decide(up, state.clockMin, clock.nightDate, state.capacity.inside, state.capacity.licensed));
|
||||||
|
}
|
||||||
ruleTimerMs = -1;
|
ruleTimerMs = -1;
|
||||||
waitTimerMs = 0;
|
waitTimerMs = 0;
|
||||||
}
|
}
|
||||||
|
} else if (queue.carpetSnapshot.some((s) => s.standMs >= (policy.carpetStandMs ?? 0))) {
|
||||||
|
// Rope free and somebody has stood long enough: wave them up. The stand
|
||||||
|
// they have already banked is what the entrance pays for.
|
||||||
|
const ready = queue.carpetSnapshot.find((s) => s.standMs >= (policy.carpetStandMs ?? 0));
|
||||||
|
if (ready) queue.recallFromCarpet(ready.patron.id);
|
||||||
} else if (queue.queueLength > 0) {
|
} else if (queue.queueLength > 0) {
|
||||||
waitTimerMs += TICK_MS;
|
waitTimerMs += TICK_MS;
|
||||||
if (waitTimerMs >= policy.callDelayMs(queue.queueLength)) {
|
if (waitTimerMs >= policy.callDelayMs(queue.queueLength)) {
|
||||||
@ -235,6 +281,10 @@ export function simulateNight(
|
|||||||
metrics.vibePinnedShare = post11.length
|
metrics.vibePinnedShare = post11.length
|
||||||
? post11.filter((v) => v >= 95).length / post11.length
|
? post11.filter((v) => v >= 95).length / post11.length
|
||||||
: 0;
|
: 0;
|
||||||
|
const hypePost11 = hypeSamples.slice(Math.floor(120 / 5));
|
||||||
|
metrics.hypePinnedShare = hypePost11.length
|
||||||
|
? hypePost11.filter((h) => h >= 2.95).length / hypePost11.length
|
||||||
|
: 0;
|
||||||
metrics.slamQueueAvg = slamTicks ? slamQueueSum / slamTicks : 0;
|
metrics.slamQueueAvg = slamTicks ? slamQueueSum / slamTicks : 0;
|
||||||
metrics.inside = state.capacity.inside;
|
metrics.inside = state.capacity.inside;
|
||||||
metrics.endClockMin = ended ? metrics.endClockMin : 360;
|
metrics.endClockMin = ended ? metrics.endClockMin : 360;
|
||||||
@ -282,3 +332,34 @@ export const DAWDLER: BotPolicy = {
|
|||||||
ruleDelayMs: CAREFUL.ruleDelayMs,
|
ruleDelayMs: CAREFUL.ruleDelayMs,
|
||||||
decide: CAREFUL.decide,
|
decide: CAREFUL.decide,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Careful verdicts, but works the velvet: parks everyone with the patience for
|
||||||
|
* it and leaves them there just inside their fuse. This is the carpet played as
|
||||||
|
* hard as it can honestly be played — the band it has to satisfy is that
|
||||||
|
* theatre pays WELL without turning hype back into the ratchet it used to be.
|
||||||
|
*/
|
||||||
|
export const SHOWPONY: BotPolicy = {
|
||||||
|
name: 'showpony',
|
||||||
|
callDelayMs: () => 700,
|
||||||
|
ruleDelayMs: CAREFUL.ruleDelayMs,
|
||||||
|
decide: CAREFUL.decide,
|
||||||
|
// The ones who read being furniture as being SEEN. A regular parked here
|
||||||
|
// storms off, so the showpony leaves the regulars alone.
|
||||||
|
carpet: (p) => p.archetype === 'influencer' || p.archetype === 'financeBro' || p.archetype === 'ownersMate',
|
||||||
|
carpetStandMs: 25_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The showpony with no brakes: parks ANYONE and holds them near the fuse. The
|
||||||
|
* band this satisfies is that the carpet is a gamble at all — hold long enough
|
||||||
|
* and the walk-offs have to actually arrive, or the verb is free money.
|
||||||
|
*/
|
||||||
|
export const GREEDY: BotPolicy = {
|
||||||
|
name: 'greedy',
|
||||||
|
callDelayMs: () => 700,
|
||||||
|
ruleDelayMs: CAREFUL.ruleDelayMs,
|
||||||
|
decide: CAREFUL.decide,
|
||||||
|
carpet: () => true,
|
||||||
|
carpetStandMs: 45_000,
|
||||||
|
};
|
||||||
|
|||||||
134
tests/sim/floorEconomy.test.ts
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { BUTTERFINGERS, PUBLICAN, RSA_STRAIGHT, simulateBarShift, type BarMetrics } from './floorShift';
|
||||||
|
import { CAREFUL, GREEDY, SHOWPONY, simulateNight, type NightMetrics } from './economy';
|
||||||
|
|
||||||
|
// The floor economy, as numbers — the twin of economy.test.ts, which only ever
|
||||||
|
// measured doorgirl nights. Eleven floor systems shipped in a day against zero
|
||||||
|
// bot coverage; this is the contract they answer to now.
|
||||||
|
//
|
||||||
|
// What the first run of this file found, for the record:
|
||||||
|
// - the red carpet was an uncapped hype ratchet. Three tolerant patrons parked
|
||||||
|
// all night rode hype to the x3 ceiling, and since Meters scales vibe GAINS
|
||||||
|
// by hype, post-11PM vibe-pin went 0.27 -> 0.71 against a documented <0.5.
|
||||||
|
// Fixed by CARPET_TUNING.hypePerStandCap (the guard queue theatre already
|
||||||
|
// had). The rest of the carpet's shape turned out sound — see 'the fuse'.
|
||||||
|
|
||||||
|
const SEEDS = [4207, 4208, 4209, 7, 99];
|
||||||
|
const avg = (xs: number[]): number => xs.reduce((a, b) => a + b, 0) / xs.length;
|
||||||
|
const sum = (xs: number[]): number => xs.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
const bar = (p: typeof RSA_STRAIGHT): BarMetrics[] => SEEDS.map((s) => simulateBarShift(s, p));
|
||||||
|
const door = (p: typeof CAREFUL): NightMetrics[] => SEEDS.map((s) => simulateNight(s, p));
|
||||||
|
|
||||||
|
const sumBar = (runs: BarMetrics[]): string =>
|
||||||
|
`${runs[0]!.policy}: end=${runs.map((r) => `${r.endReason}@${r.endClockMin}`).join(',')} ` +
|
||||||
|
`orders=${avg(runs.map((r) => r.orders)).toFixed(0)} serve=${avg(runs.map((r) => r.serves)).toFixed(0)} ` +
|
||||||
|
`water=${avg(runs.map((r) => r.waters)).toFixed(0)} cut=${avg(runs.map((r) => r.cutoffs)).toFixed(0)} ` +
|
||||||
|
`breach=${avg(runs.map((r) => r.breaches)).toFixed(1)} strikes=${avg(runs.map((r) => r.heatStrikes)).toFixed(1)} ` +
|
||||||
|
`short=${avg(runs.map((r) => r.shortPours)).toFixed(0)} over=${avg(runs.map((r) => r.overPours)).toFixed(0)} ` +
|
||||||
|
`tabs=${avg(runs.map((r) => r.tabsOpened)).toFixed(1)} vibeMin=${avg(runs.map((r) => r.vibeMin)).toFixed(0)} ` +
|
||||||
|
`vibePin=${avg(runs.map((r) => r.vibePinnedShare)).toFixed(2)} aggroMax=${avg(runs.map((r) => r.aggroMax)).toFixed(0)} ` +
|
||||||
|
`hype=${avg(runs.map((r) => r.hypePeak)).toFixed(1)} crowd=${avg(runs.map((r) => r.crowdAvg)).toFixed(0)}`;
|
||||||
|
|
||||||
|
const sumDoor = (runs: NightMetrics[]): string =>
|
||||||
|
`${runs[0]!.policy}: end=${runs.map((r) => `${r.endReason}@${r.endClockMin}`).join(',')} ` +
|
||||||
|
`admit=${avg(runs.map((r) => r.admitted)).toFixed(0)} parked=${avg(runs.map((r) => r.carpetParked)).toFixed(1)} ` +
|
||||||
|
`storms=${avg(runs.map((r) => r.carpetStorms)).toFixed(1)} entrances=${avg(runs.map((r) => r.carpetEntrances)).toFixed(1)} ` +
|
||||||
|
`hype=${avg(runs.map((r) => r.hypePeak)).toFixed(2)} hypePin=${avg(runs.map((r) => r.hypePinnedShare)).toFixed(2)} ` +
|
||||||
|
`vibePin=${avg(runs.map((r) => r.vibePinnedShare)).toFixed(2)} aggroMax=${avg(runs.map((r) => r.aggroMax)).toFixed(0)}`;
|
||||||
|
|
||||||
|
describe('the red carpet (bot nights, 5 seeds)', () => {
|
||||||
|
const careful = door(CAREFUL);
|
||||||
|
const showpony = door(SHOWPONY);
|
||||||
|
const greedy = door(GREEDY);
|
||||||
|
|
||||||
|
it('prints the current shape (baseline visibility)', () => {
|
||||||
|
console.log('\n' + [careful, showpony, greedy].map(sumDoor).join('\n'));
|
||||||
|
expect(showpony[0]!.carpetParked).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('theatre pays — working the velvet beats not working it', () => {
|
||||||
|
expect(avg(showpony.map((r) => r.hypePeak))).toBeGreaterThan(avg(careful.map((r) => r.hypePeak)) + 0.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('but hype NEVER pins at the ceiling — the ratchet stays dead', () => {
|
||||||
|
// THE regression guard. This is the band the carpet broke on arrival: hype
|
||||||
|
// is a pressure, not a currency you accumulate until the night stops caring.
|
||||||
|
for (const runs of [careful, showpony, greedy]) {
|
||||||
|
expect(avg(runs.map((r) => r.hypePinnedShare)), `${runs[0]!.policy} pinned hype`).toBeLessThan(0.02);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('vibe survives the carpet — a hyped room is not a dead meter', () => {
|
||||||
|
// Skilled carpet play legitimately buys a better room than careful play,
|
||||||
|
// so this band sits above the door's 0.5 — but the meter must still move.
|
||||||
|
expect(avg(showpony.map((r) => r.vibePinnedShare))).toBeLessThan(0.65);
|
||||||
|
expect(Math.min(...showpony.map((r) => r.vibeMin))).toBeLessThan(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the fuse is real — hold them to the limit and they walk', () => {
|
||||||
|
// Parking is only a gamble if the walk-off actually arrives. GREEDY holds
|
||||||
|
// every archetype near its patience; most of them leave.
|
||||||
|
expect(sum(greedy.map((r) => r.carpetStorms))).toBeGreaterThan(0);
|
||||||
|
expect(avg(greedy.map((r) => r.carpetStorms))).toBeGreaterThan(avg(greedy.map((r) => r.carpetEntrances)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('and greed is paid for in aggro and in throughput', () => {
|
||||||
|
expect(avg(greedy.map((r) => r.aggroMax))).toBeGreaterThan(avg(careful.map((r) => r.aggroMax)) * 1.5);
|
||||||
|
// The rope is busy being theatre: fewer people actually get through it.
|
||||||
|
expect(avg(greedy.map((r) => r.admitted))).toBeLessThan(avg(showpony.map((r) => r.admitted)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reading the archetype is the skill — patience is not uniform', () => {
|
||||||
|
// SHOWPONY parks only the tolerant and recalls inside their fuse: no storms.
|
||||||
|
// GREEDY parks anyone and holds: many. Same verb, different reading.
|
||||||
|
expect(sum(showpony.map((r) => r.carpetStorms))).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the bar economy (bot shifts, 5 seeds)', () => {
|
||||||
|
const straight = bar(RSA_STRAIGHT);
|
||||||
|
const publican = bar(PUBLICAN);
|
||||||
|
const butter = bar(BUTTERFINGERS);
|
||||||
|
|
||||||
|
it('prints the current shape (baseline visibility)', () => {
|
||||||
|
console.log('\n' + [straight, publican, butter].map(sumBar).join('\n'));
|
||||||
|
expect(straight[0]!.orders).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the legal shift survives the night, every seed', () => {
|
||||||
|
for (const r of straight) expect(r.endReason).toBe('clock');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('and is never punished for it — playing RSA straight earns no heat', () => {
|
||||||
|
// If the lawful bartender collected strikes, the rule would be a trap
|
||||||
|
// rather than a tension. Cutting people off has to be SAFE, just costly.
|
||||||
|
expect(sum(straight.map((r) => r.heatStrikes))).toBe(0);
|
||||||
|
expect(sum(straight.map((r) => r.breaches))).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serving the cooked is a real risk — the RSA line has teeth', () => {
|
||||||
|
expect(sum(publican.map((r) => r.breaches))).toBeGreaterThan(0);
|
||||||
|
expect(sum(publican.map((r) => r.heatStrikes))).toBeGreaterThan(0);
|
||||||
|
expect(avg(publican.map((r) => r.heatStrikes))).toBeGreaterThan(avg(straight.map((r) => r.heatStrikes)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the pour is a skill that costs you — a bad hand is felt in the room', () => {
|
||||||
|
// Same legal calls, worse hands: short pours land and the room resents it.
|
||||||
|
expect(avg(butter.map((r) => r.shortPours))).toBeGreaterThan(avg(straight.map((r) => r.shortPours)) * 2);
|
||||||
|
expect(avg(butter.map((r) => r.aggroMax))).toBeGreaterThan(avg(straight.map((r) => r.aggroMax)) * 1.4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the bar is not a vibe printer', () => {
|
||||||
|
// A night of pouring must not pin the room the way the old door did.
|
||||||
|
for (const runs of [straight, publican, butter]) {
|
||||||
|
expect(avg(runs.map((r) => r.vibePinnedShare)), `${runs[0]!.policy} pinned vibe`).toBeLessThan(0.3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the room actually fills — the shift has customers to get wrong', () => {
|
||||||
|
// Guards the harness itself: a bar sim serving an empty room proves nothing.
|
||||||
|
expect(avg(straight.map((r) => r.crowdAvg))).toBeGreaterThan(10);
|
||||||
|
expect(avg(straight.map((r) => r.orders))).toBeGreaterThan(15);
|
||||||
|
});
|
||||||
|
});
|
||||||
266
tests/sim/floorShift.ts
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
// Bot-driven BAR-economy simulation — the floor-side twin of economy.ts.
|
||||||
|
// Node-only: EventBus + GameClock + Meters + the real CrowdSim + the real
|
||||||
|
// barShift pure functions. Mirrors FloorDemoScene.tickBar / applyBarCall
|
||||||
|
// closely enough that a knob change here means the same thing in the game.
|
||||||
|
//
|
||||||
|
// Why this exists: eleven floor systems shipped in one day and the only bot
|
||||||
|
// nights we had were doorgirl nights. An untested economy is a guess.
|
||||||
|
//
|
||||||
|
// WHAT THIS DOES NOT MODEL, so nobody reads a green suite as wider cover than
|
||||||
|
// it is: the floor's escalation layer (maggot ripening -> radio pressure ->
|
||||||
|
// deferred heat), fights, hazards, the glassie run and the DJ booth. The
|
||||||
|
// 2026-07-22 review found a real bug in exactly that seam — watering someone
|
||||||
|
// from behind the bar left them ticking as an unhandled maggot — and this
|
||||||
|
// harness would not have caught it. Extend here when that layer needs bands.
|
||||||
|
|
||||||
|
import { EventBus } from '../../src/core/EventBus';
|
||||||
|
import { GameClock, DEFAULT_CLOCK } from '../../src/core/GameClock';
|
||||||
|
import { Meters, freshNightState, vibeCoolingPerMin } from '../../src/core/meters';
|
||||||
|
import { SeededRNG, type RngStream } from '../../src/core/SeededRNG';
|
||||||
|
import { generatePatron, _resetPatronSerial } from '../../src/patrons/generator';
|
||||||
|
import { CrowdSim, type Agent } from '../../src/scenes/floor/crowdSim';
|
||||||
|
import { VENUE } from '../../src/scenes/floor/venueMap';
|
||||||
|
import { drunkStage } from '../../src/rules/drunk';
|
||||||
|
import { shouldRecordStrike } from '../../src/rules/heat';
|
||||||
|
import type { DeferredHit } from '../../src/rules/doorTypes';
|
||||||
|
import type { DrunkStage } from '../../src/data/types';
|
||||||
|
import {
|
||||||
|
BREACH_SEEN_AFTER, BREACH_SEEN_CHANCE, ORDER_CHANCE_PER_S, ORDER_COOLDOWN_MS,
|
||||||
|
POUR_CLEAN_FROM, POUR_OVER_FROM, RSA_STRIKE_REASON, TAB_OPEN_CHANCE,
|
||||||
|
canOpenTab, isRsaBreach, judgeCall, judgePour, pourAdjust, type BarCall,
|
||||||
|
} from '../../src/scenes/floor/barShift';
|
||||||
|
|
||||||
|
export interface BarPolicy {
|
||||||
|
name: string;
|
||||||
|
/** What you do with an order, knowing what you can see of them. */
|
||||||
|
call: (stage: DrunkStage) => BarCall;
|
||||||
|
/** Where the marker gets stopped. Models a hand, not a menu click. */
|
||||||
|
pourFill: (rng: RngStream) => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BarMetrics {
|
||||||
|
policy: string;
|
||||||
|
seed: number;
|
||||||
|
endReason: 'clock' | 'vibe' | 'aggro';
|
||||||
|
endClockMin: number;
|
||||||
|
orders: number;
|
||||||
|
serves: number;
|
||||||
|
waters: number;
|
||||||
|
cutoffs: number;
|
||||||
|
/** Pours that went into someone already visibly cooked (the RSA line). */
|
||||||
|
breaches: number;
|
||||||
|
shortPours: number;
|
||||||
|
overPours: number;
|
||||||
|
tabsOpened: number;
|
||||||
|
heatStrikes: number;
|
||||||
|
vibeSamples: number[];
|
||||||
|
vibeMin: number;
|
||||||
|
vibePinnedShare: number;
|
||||||
|
aggroMax: number;
|
||||||
|
hypePeak: number;
|
||||||
|
/** Bodies on the floor, averaged across the night — the room the bar serves. */
|
||||||
|
crowdAvg: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TICK_MS = 100;
|
||||||
|
/** One arrival every this many clock-minutes (Kayden is working the door). */
|
||||||
|
const ARRIVE_EVERY_MIN = 2;
|
||||||
|
/** FloorDemoScene's MAX_CROWD — the sim's perf budget, mirrored. */
|
||||||
|
const MAX_CROWD = 44;
|
||||||
|
|
||||||
|
export function simulateBarShift(seed: number, policy: BarPolicy): BarMetrics {
|
||||||
|
_resetPatronSerial();
|
||||||
|
const bus = new EventBus();
|
||||||
|
const rng = new SeededRNG(seed);
|
||||||
|
const clock = new GameClock(bus, DEFAULT_CLOCK);
|
||||||
|
const state = freshNightState('sim', 0, 80);
|
||||||
|
const meters = new Meters(bus, state);
|
||||||
|
const crowd = new CrowdSim({ map: VENUE, bus, rng: rng.stream('crowd') });
|
||||||
|
const orderRng = rng.stream('barOrders');
|
||||||
|
const pourRng = rng.stream('pour');
|
||||||
|
// generatePatron wants the RNG itself (it takes its own 'patrons' stream), so
|
||||||
|
// arrivals get a derived instance rather than one of this seed's streams.
|
||||||
|
const spawnRng = new SeededRNG(seed ^ 0x5ba7);
|
||||||
|
const deferred: DeferredHit[] = [];
|
||||||
|
const orderedAt = new Map<string, number>();
|
||||||
|
const tabs = new Set<string>();
|
||||||
|
|
||||||
|
const metrics: BarMetrics = {
|
||||||
|
policy: policy.name,
|
||||||
|
seed,
|
||||||
|
endReason: 'clock',
|
||||||
|
endClockMin: 360,
|
||||||
|
orders: 0,
|
||||||
|
serves: 0,
|
||||||
|
waters: 0,
|
||||||
|
cutoffs: 0,
|
||||||
|
breaches: 0,
|
||||||
|
shortPours: 0,
|
||||||
|
overPours: 0,
|
||||||
|
tabsOpened: 0,
|
||||||
|
heatStrikes: 0,
|
||||||
|
vibeSamples: [state.vibe],
|
||||||
|
vibeMin: state.vibe,
|
||||||
|
vibePinnedShare: 0,
|
||||||
|
aggroMax: 0,
|
||||||
|
hypePeak: 1,
|
||||||
|
crowdAvg: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let ended = false;
|
||||||
|
bus.on('meters:changed', ({ vibe, aggro, hype }) => {
|
||||||
|
metrics.vibeMin = Math.min(metrics.vibeMin, vibe);
|
||||||
|
metrics.aggroMax = Math.max(metrics.aggroMax, aggro);
|
||||||
|
metrics.hypePeak = Math.max(metrics.hypePeak, hype);
|
||||||
|
if (ended) return;
|
||||||
|
if (vibe <= 0) {
|
||||||
|
ended = true;
|
||||||
|
metrics.endReason = 'vibe';
|
||||||
|
metrics.endClockMin = state.clockMin;
|
||||||
|
} else if (aggro >= 100) {
|
||||||
|
ended = true;
|
||||||
|
metrics.endReason = 'aggro';
|
||||||
|
metrics.endClockMin = state.clockMin;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
bus.on('heat:strike', () => metrics.heatStrikes++);
|
||||||
|
|
||||||
|
let lastSample = -5;
|
||||||
|
let nextArriveMin = 0;
|
||||||
|
bus.on('clock:tick', ({ clockMin }) => {
|
||||||
|
// Deferred RSA write-ups land on the minute, as NightScene fires them.
|
||||||
|
for (let i = deferred.length - 1; i >= 0; i--) {
|
||||||
|
const hit = deferred[i]!;
|
||||||
|
if (clockMin < hit.atClockMin) continue;
|
||||||
|
deferred.splice(i, 1);
|
||||||
|
if (hit.heatStrike && shouldRecordStrike(state, hit.heatStrike)) {
|
||||||
|
bus.emit('heat:strike', hit.heatStrike);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cooling = vibeCoolingPerMin(state.vibe);
|
||||||
|
if (cooling !== 0 && !ended) bus.emit('meters:delta', { vibe: cooling });
|
||||||
|
if (clockMin - lastSample >= 5) {
|
||||||
|
lastSample = clockMin;
|
||||||
|
metrics.vibeSamples.push(state.vibe);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Mirrors FloorDemoScene.applyBarCall. */
|
||||||
|
const applyCall = (agent: Agent, stage: DrunkStage, call: BarCall): void => {
|
||||||
|
let out = judgeCall(call, stage);
|
||||||
|
if (call === 'serve') {
|
||||||
|
const fill = policy.pourFill(pourRng);
|
||||||
|
const verdict = judgePour(fill);
|
||||||
|
if (verdict === 'short') metrics.shortPours++;
|
||||||
|
if (verdict === 'overflow') metrics.overPours++;
|
||||||
|
out = pourAdjust(out, verdict);
|
||||||
|
metrics.serves++;
|
||||||
|
if (out.breach) metrics.breaches++;
|
||||||
|
} else if (call === 'water') {
|
||||||
|
metrics.waters++;
|
||||||
|
} else {
|
||||||
|
metrics.cutoffs++;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) bus.emit('meters:delta', delta);
|
||||||
|
agent.patron.intoxication = Math.max(0, Math.min(1, agent.patron.intoxication + out.intoxDelta));
|
||||||
|
if (out.sendsHome) {
|
||||||
|
agent.handled = true;
|
||||||
|
crowd.sendHome(agent);
|
||||||
|
}
|
||||||
|
if (out.breach && orderRng.chance(BREACH_SEEN_CHANCE)) {
|
||||||
|
deferred.push({
|
||||||
|
atClockMin: state.clockMin + BREACH_SEEN_AFTER[0] + orderRng.int(0, BREACH_SEEN_AFTER[1]),
|
||||||
|
heatStrike: { reason: RSA_STRIKE_REASON, deferred: true },
|
||||||
|
reason: RSA_STRIKE_REASON,
|
||||||
|
patronId: agent.patron.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (call === 'serve' && !tabs.has(agent.patron.id) && canOpenTab(stage) && orderRng.chance(TAB_OPEN_CHANCE)) {
|
||||||
|
tabs.add(agent.patron.id);
|
||||||
|
metrics.tabsOpened++;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
clock.start();
|
||||||
|
const totalMs = DEFAULT_CLOCK.nightRealMinutes * 60_000;
|
||||||
|
let elapsedMs = 0;
|
||||||
|
let crowdTicks = 0;
|
||||||
|
let crowdSum = 0;
|
||||||
|
|
||||||
|
for (let t = 0; t < totalMs && !ended; t += TICK_MS) {
|
||||||
|
clock.update(TICK_MS);
|
||||||
|
elapsedMs += TICK_MS;
|
||||||
|
|
||||||
|
// The room fills: Kayden's door, mirrored as a steady drip.
|
||||||
|
while (state.clockMin >= nextArriveMin) {
|
||||||
|
nextArriveMin += ARRIVE_EVERY_MIN;
|
||||||
|
const live = crowd.agents.filter((a) => !a.gone).length;
|
||||||
|
if (live < MAX_CROWD) {
|
||||||
|
crowd.spawn(generatePatron({ rng: spawnRng, nightDate: clock.nightDate }, state.clockMin));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crowd.update(TICK_MS, state.clockMin);
|
||||||
|
crowdTicks++;
|
||||||
|
crowdSum += crowd.agents.filter((a) => !a.gone).length;
|
||||||
|
|
||||||
|
// Behind the taps all night (mirrors tickBar's roll).
|
||||||
|
if (!orderRng.chance((TICK_MS / 1000) * ORDER_CHANCE_PER_S)) continue;
|
||||||
|
const asker = crowd.agents.find(
|
||||||
|
(a) =>
|
||||||
|
!a.gone &&
|
||||||
|
!a.handled &&
|
||||||
|
a.activity === 'atBar' &&
|
||||||
|
elapsedMs - (orderedAt.get(a.patron.id) ?? -Infinity) > ORDER_COOLDOWN_MS,
|
||||||
|
);
|
||||||
|
if (!asker) continue;
|
||||||
|
orderedAt.set(asker.patron.id, elapsedMs);
|
||||||
|
metrics.orders++;
|
||||||
|
const stage = drunkStage(asker.patron.intoxication);
|
||||||
|
applyCall(asker, stage, policy.call(stage));
|
||||||
|
}
|
||||||
|
|
||||||
|
const post11 = metrics.vibeSamples.slice(Math.floor(120 / 5));
|
||||||
|
metrics.vibePinnedShare = post11.length ? post11.filter((v) => v >= 95).length / post11.length : 0;
|
||||||
|
metrics.crowdAvg = crowdTicks ? crowdSum / crowdTicks : 0;
|
||||||
|
metrics.endClockMin = ended ? metrics.endClockMin : 360;
|
||||||
|
meters.destroy();
|
||||||
|
return metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- policies ----------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A hand that knows the glass. Lands the clean band most of the time. */
|
||||||
|
const skilledPour = (rng: RngStream): number => {
|
||||||
|
const roll = rng.next();
|
||||||
|
if (roll < 0.7) return POUR_CLEAN_FROM + rng.next() * (POUR_OVER_FROM - POUR_CLEAN_FROM);
|
||||||
|
if (roll < 0.85) return rng.next() * POUR_CLEAN_FROM;
|
||||||
|
return POUR_OVER_FROM + rng.next() * (1 - POUR_OVER_FROM);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Stops the marker wherever. What the first night actually looks like. */
|
||||||
|
const masherPour = (rng: RngStream): number => rng.next();
|
||||||
|
|
||||||
|
/** The legal shift: cut the cooked off, water the wobbly, serve the rest. */
|
||||||
|
export const RSA_STRAIGHT: BarPolicy = {
|
||||||
|
name: 'rsa-straight',
|
||||||
|
call: (stage) => (isRsaBreach(stage) ? 'cutoff' : stage === 'loose' ? 'water' : 'serve'),
|
||||||
|
pourFill: skilledPour,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Pours for anyone with a voice. The licence is a problem for later. */
|
||||||
|
export const PUBLICAN: BarPolicy = {
|
||||||
|
name: 'publican',
|
||||||
|
call: () => 'serve',
|
||||||
|
pourFill: skilledPour,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Legal calls, hopeless hands — the first-night bartender. */
|
||||||
|
export const BUTTERFINGERS: BarPolicy = {
|
||||||
|
name: 'butterfingers',
|
||||||
|
call: RSA_STRAIGHT.call,
|
||||||
|
pourFill: masherPour,
|
||||||
|
};
|
||||||
55
tests/staff.test.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
40
tests/tutorialFade.test.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { TUTORIAL_FADE_MS, TUTORIAL_MS, tutorialAlpha } from '../src/scenes/door/tutorialFade';
|
||||||
|
|
||||||
|
// Regression guard for the door-tutorial fade. The original implementation was
|
||||||
|
// `if (alpha > 0) tweens.add({ alpha: 0, duration: 800 })` inside update():
|
||||||
|
// each frame's new tween re-captured the still-positive alpha, so the guard
|
||||||
|
// never closed and DoorScene accumulated one live 800ms tween per frame
|
||||||
|
// (~1,100 by the end of a night). The fade is now this pure function; these
|
||||||
|
// tests pin the property that killed the tween version — it must actually
|
||||||
|
// reach 0 and stay there.
|
||||||
|
|
||||||
|
describe('tutorialAlpha', () => {
|
||||||
|
it('holds at full alpha through the tutorial window', () => {
|
||||||
|
expect(tutorialAlpha(0)).toBe(1);
|
||||||
|
expect(tutorialAlpha(TUTORIAL_MS)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fades linearly once the hold expires', () => {
|
||||||
|
expect(tutorialAlpha(TUTORIAL_MS + TUTORIAL_FADE_MS / 2)).toBeCloseTo(0.5);
|
||||||
|
expect(tutorialAlpha(TUTORIAL_MS + TUTORIAL_FADE_MS / 4)).toBeCloseTo(0.75);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reaches exactly 0 at fade end and never goes negative after', () => {
|
||||||
|
expect(tutorialAlpha(TUTORIAL_MS + TUTORIAL_FADE_MS)).toBe(0);
|
||||||
|
expect(tutorialAlpha(TUTORIAL_MS + TUTORIAL_FADE_MS + 1)).toBe(0);
|
||||||
|
// A whole night later it is still pinned at 0 — the condition the old
|
||||||
|
// tween-spawning guard could never satisfy.
|
||||||
|
expect(tutorialAlpha(TUTORIAL_MS + 4 * 60 * 60 * 1000)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is monotonically non-increasing frame over frame', () => {
|
||||||
|
let prev = Infinity;
|
||||||
|
for (let ms = 0; ms < TUTORIAL_MS + 2 * TUTORIAL_FADE_MS; ms += 16.67) {
|
||||||
|
const a = tutorialAlpha(ms);
|
||||||
|
expect(a).toBeLessThanOrEqual(prev);
|
||||||
|
expect(a).toBeGreaterThanOrEqual(0);
|
||||||
|
prev = a;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -29,6 +29,8 @@ TARGETS = {
|
|||||||
"dishwasher": (32, 16),
|
"dishwasher": (32, 16),
|
||||||
# the door-scene backdrop plate is special-cased: full size, no quantise
|
# the door-scene backdrop plate is special-cased: full size, no quantise
|
||||||
"street:backdrop": (640, 176),
|
"street:backdrop": (640, 176),
|
||||||
|
# door-scene red carpet plate (front-on, not a floor prop)
|
||||||
|
"carpet": (128, 32),
|
||||||
}
|
}
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
|||||||