Compare commits
39 Commits
lane/conte
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7af3732f39 | ||
|
|
4118a60aea | ||
|
|
0d3ce80c6b | ||
|
|
bcc06c104a | ||
|
|
3b8682dd40 | ||
|
|
6d2fc56bfb | ||
|
|
164b4480c5 | ||
|
|
79cd80dc01 | ||
|
|
d4f802407b | ||
|
|
ec9afaff0f | ||
|
|
e008fb442f | ||
|
|
5334a30150 | ||
|
|
5ebdfc61eb | ||
|
|
b7bb8f49ab | ||
|
|
022ea32005 | ||
|
|
c20a689086 | ||
|
|
b273c5c833 | ||
|
|
41e93d01ab | ||
|
|
fbcbb2e349 | ||
|
|
1b0842024b | ||
|
|
d8c3b0507d | ||
|
|
4471bf4376 | ||
|
|
389376c2f1 | ||
|
|
7803873825 | ||
|
|
f38ea0743c | ||
|
|
9827e4ebe6 | ||
|
|
b1e2f038bc | ||
|
|
2e0b1917b8 | ||
|
|
22f5f11dde | ||
|
|
7b44e01333 | ||
|
|
63a3b4d4fc | ||
|
|
a961e1853b | ||
|
|
662060388a | ||
|
|
df70c99dd0 | ||
|
|
e1972c1c07 | ||
|
|
10462ebada | ||
|
|
1a66c602fd | ||
|
|
906a366ba8 | ||
|
|
d112bda270 |
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist/
|
dist/
|
||||||
|
art_incoming/
|
||||||
|
|||||||
16
CLAUDE.md
@ -35,9 +35,13 @@ by parallel "lanes" coordinated through files, reviewed by Fable.
|
|||||||
floor WASD + E (E at the entry exits back to the door). While you're inside,
|
floor WASD + E (E at the entry exits back to the door). While you're inside,
|
||||||
Kayden works the door — badly, on purpose.
|
Kayden works the door — badly, on purpose.
|
||||||
- Dev routes: N night · P parade · F floor demo · J juice demo (+ same-named
|
- Dev routes: N night · P parade · F floor demo · J juice demo (+ same-named
|
||||||
URL hashes). `localStorage.removeItem('not-tonight-save')` for a clean run.
|
URL hashes) · M toggles the MIX DESK overlay anywhere (ear-pass rig).
|
||||||
|
`localStorage.removeItem('not-tonight-save')` for a clean run.
|
||||||
- Audio unlocks on first click (TechnoEngine + location filter). Before that a
|
- Audio unlocks on first click (TechnoEngine + location filter). Before that a
|
||||||
StubBeatClock keeps `beat:tick` honest.
|
StubBeatClock keeps `beat:tick` honest.
|
||||||
|
- Venue spaces/props/lights are DATA (`venueMap.PROPS`/`LIGHTS`, plan in
|
||||||
|
docs/SPACES.md). Generated art drops into `public/props/` via
|
||||||
|
`tools/props_import.py` + manifest — placeholders render until then.
|
||||||
|
|
||||||
## Dev notes (Phase 0, still true)
|
## Dev notes (Phase 0, still true)
|
||||||
- Gate: `npm run lint && npm run build && npm test` — all three must pass before
|
- Gate: `npm run lint && npm run build && npm test` — all three must pass before
|
||||||
@ -61,3 +65,13 @@ by parallel "lanes" coordinated through files, reviewed by Fable.
|
|||||||
The game is Aussie hospitality satire — affectionate, funny, with a moral undertow
|
The game is Aussie hospitality satire — affectionate, funny, with a moral undertow
|
||||||
(design doc §4.3). If content you're writing reads as mean-spirited rather than
|
(design doc §4.3). If content you're writing reads as mean-spirited rather than
|
||||||
absurd, soften toward absurd.
|
absurd, soften toward absurd.
|
||||||
|
|
||||||
|
## Deploy (live since 2026-07-19)
|
||||||
|
- Live at **https://monsterrobot.games/not-tonight/** (botchat VPS, forum-nginx
|
||||||
|
bind mount — host path `/home/humanjing/monsterrobot.games/games/not-tonight/`,
|
||||||
|
`/not-tonight` is a symlink to it; landing tile in `../index.html`).
|
||||||
|
- Ship: `npm run build && rsync -az --delete dist/ humanjing@100.71.119.27:/home/humanjing/monsterrobot.games/games/not-tonight/`
|
||||||
|
then cache-busted curl to verify. Hashed asset names make CF cache a non-issue;
|
||||||
|
index.html is text/html (uncached by CF default).
|
||||||
|
- Cover art: `covers/nottonight.jpg` + `_riso.jpg` (MODELBEAST flux_local seed
|
||||||
|
4208 + PIL stamp text/riso pass — recipe in LANEHANDOVER Phase-3 deploy note).
|
||||||
|
|||||||
1096
LANEHANDOVER.md
1
art_incoming/README.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
raw drops go here; process with tools/props_import.py
|
||||||
BIN
art_incoming/arcade.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
art_incoming/barShelf.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
art_incoming/boothTable.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
art_incoming/buttBin.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
art_incoming/cloak.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
art_incoming/crate.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
art_incoming/deck.png
Normal file
|
After Width: | Height: | Size: 178 KiB |
BIN
art_incoming/discoball.png
Normal file
|
After Width: | Height: | Size: 192 KiB |
BIN
art_incoming/glassRack.png
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
art_incoming/glassStack.png
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
art_incoming/mixer.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
art_incoming/poster1.png
Normal file
|
After Width: | Height: | Size: 823 KiB |
BIN
art_incoming/poster2.png
Normal file
|
After Width: | Height: | Size: 337 KiB |
BIN
art_incoming/poster3.png
Normal file
|
After Width: | Height: | Size: 562 KiB |
BIN
art_incoming/sinkRow.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
art_incoming/speaker.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
art_incoming/stampPodium.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
art_incoming/street_backdrop.png
Normal file
|
After Width: | Height: | Size: 944 KiB |
BIN
art_incoming/taps.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
art_incoming/till.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
@ -26,16 +26,42 @@ nike_air_max, moschino, quiksilver, kmart, disruptor_ii…), dynamically loaded
|
|||||||
sprites before committing — pixelated photo-sprites can look mushy; may need a
|
sprites before committing — pixelated photo-sprites can look mushy; may need a
|
||||||
cleanup pass or MODELBEAST-assisted redraw per sprite.
|
cleanup pass or MODELBEAST-assisted redraw per sprite.
|
||||||
|
|
||||||
### 2. Drunk/idle/dance animation references
|
### 2. RIGROOM — the character × animation factory (surveyed 2026-07-19)
|
||||||
`ultra:~/Documents/90sDJsim/web/world/models/` — 22 ped GLBs + **9 anim clip GLBs
|
**This supersedes the old "animation references" note. The fleet has a full
|
||||||
incl. `walk_drunk`, idle, sit** (from the shared `character_kit` rig). Plus
|
character/animation pipeline, live and organised:**
|
||||||
`ultra:~/Documents/MOTIONLIB/` — 2,703 motion/anim FBX.
|
- **RIGROOM library** (`ultra:~/meshgod-backups/library`, 2.4 GB): **607
|
||||||
- Option A (cheap): use `walk_drunk` etc. as *visual reference* for hand-animating
|
animation FBX** in themed packs + 59 character files. Web UI live at
|
||||||
the pixel sway/stumble frames.
|
`http://100.91.239.7:8010/rigroom` (characters × animations pairing, three.js
|
||||||
- Option B (pipeline): pre-render `character_kit` characters + MOTIONLIB clips to
|
preview, instant Mixamo-ready FBX download). CLI:
|
||||||
top-down sprite-sheet rotations for the Floor scene (Blender orthographic cam →
|
`MESHGOD/scripts/rigroom.py classify|sort|scan`.
|
||||||
pixelate). Heavier, but gives unlimited consistent NPC animation. Decide at v0.4;
|
- Clips that read like THIS game's shot list: `Searching_Pockets` (pat-down),
|
||||||
the doll contract's `floorTopDown` pose doesn't care which path wins.
|
`Standing_Arguing` + a full boxing/knockout pack (floor fights),
|
||||||
|
`Shaking_Head_No` (the deny), `Inspecting`/`Looking_Around` (ID check),
|
||||||
|
`Leaning_Idle02_Smoking` (smoking area), dozens of idles/sits, dance packs,
|
||||||
|
`Brutal To Happy Walking`, zombie stagger (maggot walk), 12 DJ motions.
|
||||||
|
Raw source: `m3ultra:~/Documents/Rokoko_Free_Mocap_FBX_263` (606 FBX).
|
||||||
|
- **Modular character kit** (`m3ultra:~/Documents/character_kit_modular`,
|
||||||
|
canonical `ultra:~/Documents/character_kit/modular/`): GODVERSE socket
|
||||||
|
standard — head/hands/feet/torso swap on one shared 65-bone `mixamorig:`
|
||||||
|
skeleton, 40 embedded actions, every library clip retargets for free.
|
||||||
|
- **repose** (`m3ultra:~/Documents/repose`): photo → T-pose views (SD1.5 +
|
||||||
|
ControlNet, all-local) → MeshGod multi-view → autorig. A face becomes a
|
||||||
|
rigged character without leaving the tailnet.
|
||||||
|
- **For NOT TONIGHT (v0.4 art pass), the pre-render path is now fully paved:**
|
||||||
|
modular kit character + RIGROOM clip → Blender orthographic top-down/front →
|
||||||
|
pixelate → sprite sheets behind the existing `renderDoll` poses. The outfit
|
||||||
|
variety could even come from kit part swaps. Same pipeline, one venue's
|
||||||
|
worth of NPC animation for free. Decide at v0.4; contracts don't move.
|
||||||
|
- Also present, unsurveyed depth: `MOCAPGOD` (empty queue — service scaffold),
|
||||||
|
`mixamo_upload` staging, `MOTIONLIB` (2,703 FBX, ultra) still the deep pool.
|
||||||
|
|
||||||
|
### 2b. procity (m3ultra) — the 90s-Aussie town sim
|
||||||
|
Walkable procedural shopping town (three.js, no build step), built by lanes
|
||||||
|
like ours. Pipeline: 186 GLB/FBX with fit/normalize/decimate tooling
|
||||||
|
(`procity_fit_*` props: arcade cabinet, clothes racks, shelving), 21 peds.
|
||||||
|
The **prop pipeline conventions** (batch manifests, decimation results JSON)
|
||||||
|
are the pattern to copy if NOT TONIGHT ever goes 3D-prop-assisted; the venue
|
||||||
|
furniture (bar, booths, stools) could come straight from its fit pipeline.
|
||||||
|
|
||||||
### 3. Venue / street reference art
|
### 3. Venue / street reference art
|
||||||
- `ultra:~/Documents/90sDJsim/web/world/media/` named scene/venue art — 100 wired
|
- `ultra:~/Documents/90sDJsim/web/world/media/` named scene/venue art — 100 wired
|
||||||
|
|||||||
91
docs/SCENARIOS.md
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
# NOT TONIGHT — Character, Scenario & Interaction Backlog
|
||||||
|
|
||||||
|
*The idea shelf. Everything here obeys §4.3: choices cost something real, the
|
||||||
|
game never grades them, and the humane option is never secretly optimal.
|
||||||
|
Status: ✅ implemented · 🔜 next nibbles · 💡 backlog.*
|
||||||
|
|
||||||
|
## Interactions & mini-systems
|
||||||
|
|
||||||
|
- ✅ **The joint bust** (smoking yard). E on a smoker who smells herbal → three
|
||||||
|
verbs, none of them clean: **BIN IT** (by the book — write-up in the truth
|
||||||
|
log, smoker's night dampened), **POCKET IT** (the game's first corruption
|
||||||
|
verb — nothing happens now; maybe a Dazza sting later; and it sits in the
|
||||||
|
incident log waiting for the report form, where your account of it can be a
|
||||||
|
lie the audit remembers), **DIDN'T SEE IT** (the yard relaxes, vibe drifts
|
||||||
|
up… and it's still on the record as ignored). 'joint' is real contraband, so
|
||||||
|
it also turns up in pat-downs at the rope.
|
||||||
|
- ✅ **The glassie run** (the glassie role's fantasy, previewed as a floor
|
||||||
|
task). A wire rack of glasses appears at the bar's east end; carry it the
|
||||||
|
length of the venue to the kitchen dishwasher. Crowd bumps and sharp turns
|
||||||
|
build WOBBLE; wobble breaks glasses one smash at a time. Deliver what
|
||||||
|
survives for vibe. Racks keep coming. The door is unattended while you do it
|
||||||
|
— Kayden is thrilled.
|
||||||
|
- ✅ **ID passback detection** (2026-07-20): the same card twice in one night — the name on
|
||||||
|
the guest-list clipboard you already checked; second appearance of an
|
||||||
|
identical card = passback. Needs a "cards seen tonight" memory at the desk.
|
||||||
|
- ✅ **Regular step-up lines** (2026-07-20) keyed off timesDeniedBefore ("back again. new
|
||||||
|
moustache. no hard feelings").
|
||||||
|
- ✅ **Smoking-door slip-in** (2026-07-20) (FLOOR2 §2's second noStamp source).
|
||||||
|
- ✅ **The 1:30 lockout** (2026-07-20) (deeply Queensland): after 1:30 AM, admissions are
|
||||||
|
ILLEGAL. The queue still forms; Dazza still wants bodies; every post-lockout
|
||||||
|
admit is a guaranteed strike risk vs a rioting line. A whole late-game phase
|
||||||
|
in one law.
|
||||||
|
- ✅ **Vomit protocol** (2026-07-20): someone redecorates the dance floor → deploy the wet
|
||||||
|
floor sign (we own the prop) → slips if you dawdle; the glassie mop loop.
|
||||||
|
- ✅ **Phone confiscation** (2026-07-20): someone filming on the dance floor — the
|
||||||
|
influencer's revenge arc.
|
||||||
|
- ✅ **Water station duty** (2026-07-20) (RSA): press a cup on a `loose` patron; done right
|
||||||
|
it de-escalates the ripen timer. The humane tool that costs time.
|
||||||
|
- ✅ **First aid** (2026-07-20): a fainter. Recovery position via a QTE that is deliberately
|
||||||
|
NOT funny; the room hushes; the night resumes changed.
|
||||||
|
- ✅ **Kebab run** (2026-07-20): Dazza demands a kebab mid-slam. The shop is right there.
|
||||||
|
The queue can see you. The whole game in one errand.
|
||||||
|
|
||||||
|
## Characters (door archetypes & scripted people)
|
||||||
|
|
||||||
|
- ✅ **The off-duty cop** (2026-07-20) — flashes a badge at the rope. Real cop (deny freely,
|
||||||
|
he respects it; admit and he quietly reports the venue's breaches — a
|
||||||
|
walking inspector) or a $30 costume badge (the tells: hat, confidence,
|
||||||
|
wrong lanyard). The ID game's scariest bluff.
|
||||||
|
- ✅ **The celebrity incognito** (2026-07-20) — cap + sunnies (two live rule breaches!).
|
||||||
|
Deny: nothing happens, ever, and Dazza never finds out who. Admit: vibe
|
||||||
|
rocket OR pap swarm outside (aggro storm). You cannot know which.
|
||||||
|
- ✅ **The twins** (2026-07-20) — two people, one face, different names. Not a fake:
|
||||||
|
genuinely twins. Everything about the photo check is suddenly on trial.
|
||||||
|
- ✅ **The sovereign citizen** (2026-07-20) — "I don't recognise your dress code as having
|
||||||
|
authority over my person." Aggro engine in a polo. His ID is a laminated
|
||||||
|
business card.
|
||||||
|
- ✅ **The delivery rider** (2026-07-20) — insists the DJ ordered food. The DJ did not
|
||||||
|
order food. OR DID HE. (The booth lamp flickers hungrily.)
|
||||||
|
- ✅ **The promoter** (2026-07-20) — arrives with a roll of wristbands and 'his' guest list
|
||||||
|
that doesn't match yours. Two clipboards enter, one leaves.
|
||||||
|
- ✅ **The noise-complaint neighbour** (2026-07-20) — pyjamas, at the rope, at 1 AM, not
|
||||||
|
trying to get in: trying to get it turned DOWN. The only patron who wants
|
||||||
|
less club. Handle him badly and the inspector hears about it.
|
||||||
|
- ✅ **The hen's party, lost** (2026-07-20) — seven matching sashes, wrong venue, right
|
||||||
|
energy. Admitting them is a vibe grenade with a random timer.
|
||||||
|
- ✅ **The ex-doorman** (2026-07-20) — watches you work a patron, then tells you what you
|
||||||
|
missed (a live tutorial character; his tips are right ~80% of the time,
|
||||||
|
which is the joke).
|
||||||
|
|
||||||
|
## Systems worth a whole phase
|
||||||
|
|
||||||
|
- ✅ **Venue 4 / ROOM boss week** (2026-07-21, first cut): fourth rung on the
|
||||||
|
ladder (promotes from Elevate). No Dazza rule chronology — the board IS the
|
||||||
|
law and re-randomises every 15 in-game minutes (3 rules per window, seeded,
|
||||||
|
with a toast on the turn); every rope line renders as bracketed lip-read
|
||||||
|
guesses (`[FIST? LIST?]`) because the techno ate the dialogue. 60 licensed,
|
||||||
|
1.2 arrival scale, inspector working, crowd of heads and VIP-claimers.
|
||||||
|
- ✅ **The DJ shift** (2026-07-20, first cut): E at the booth hands you the decks
|
||||||
|
while the resident steps out. B pulls the bass (the room leans in); SPACE
|
||||||
|
drops, judged against the LIVE bar line (clean/late/clang — early and late
|
||||||
|
are the same offence); requests arrive at the booth glass with per-request
|
||||||
|
odds ("got any dnb", nan's song, the DUN DUN one) and three answers that all
|
||||||
|
cost something. A build held too long runs out of stairs. Movement locks
|
||||||
|
while you hold them; the floor and the door keep happening without you.
|
||||||
|
- ✅ **Bartender shift** (2026-07-21, first cut): E at the till hands you the
|
||||||
|
taps (movement locks; the floor keeps happening). Orders arrive as choices —
|
||||||
|
the order line degrades with the patron's stage (the spelling IS the sobriety
|
||||||
|
test) — SERVE / WATER / CUT OFF all legal, all priced. Serving messy/maggot
|
||||||
|
is the RSA breach: the vibe pays up front, and 35% of the time somebody with
|
||||||
|
a lanyard saw it (ONE deferred write-up per night, deduped by reason).
|
||||||
115
docs/SPACES.md
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
# NOT TONIGHT — Spaces Plan
|
||||||
|
|
||||||
|
*How the game's two spaces grow from readable-but-abstract into places, using the
|
||||||
|
fleet's asset factories. Written 2026-07-20. Code contract: everything lands
|
||||||
|
behind data tables (`venueMap.PROPS` / `LIGHTS`) and a sprite drop-in convention —
|
||||||
|
generated art replaces placeholders with zero code changes.*
|
||||||
|
|
||||||
|
## 1. Design principles
|
||||||
|
|
||||||
|
1. **Darkness stays the mechanic.** Props read as silhouettes in ambient dark and
|
||||||
|
resolve under the torch. Nothing may make the room bright enough to scan from
|
||||||
|
the doorway.
|
||||||
|
2. **Navigate by light signature.** Each zone owns a colour of light, so the
|
||||||
|
player orients by glow before geometry: bar = warm amber · dance floor =
|
||||||
|
pulsing pink (on the actual beat) · DJ booth = deep blue · toilets =
|
||||||
|
flickering green fluoro · smoking yard = cool rain-blue spill · entry/exit =
|
||||||
|
venue pink. The torch is white; UV is purple. No other light sources.
|
||||||
|
3. **Props are data.** `PropDef { kind, tile pos, footprint, emissive? }` — the
|
||||||
|
renderer prefers a generated texture (`gen:prop:<kind>`) and falls back to a
|
||||||
|
procedural placeholder, so the game is complete TONIGHT and gets prettier as
|
||||||
|
art lands.
|
||||||
|
4. **Spaces anticipate the role ladder** (design §6): the bar interior is laid
|
||||||
|
out for a future bartender shift, the DJ booth for the DJ shift, the glassie
|
||||||
|
station and smoking yard for floor loops that already have lanes speccing them.
|
||||||
|
|
||||||
|
## 2. The venue floor (80×45 tiles, one room + pockets)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ N ═ BAR ══════════ back-shelf glow ┌─ DUNNIES ────────┐│
|
||||||
|
│ taps · till · glasses │ s1 s2 s3 s4 ││
|
||||||
|
│ ○○○○○○ stools │ sinks + mirror ││
|
||||||
|
│ └───door───────────┘│
|
||||||
|
│ W │
|
||||||
|
│ ┈entry┈ ┌ DANCE FLOOR (pulsing grid) ┐ ╔═ DJ BOOTH ╗ booths│
|
||||||
|
│ corridor │ speaker · · · · · speaker │ ║ decks+mixer║ E│
|
||||||
|
│ posters │ · · · disco ball · · · │ ║ (sealed) ║ booths│
|
||||||
|
│ cloak │ speaker · · · · · speaker │ ╚════════════╝ │
|
||||||
|
│ glassie └───────────────────────────┘ booths│
|
||||||
|
│ station │
|
||||||
|
│ ▓ SMOKING YARD (open sky, rain) ▓ tables+candles S │
|
||||||
|
└────────▓ fence · butt bin ▓──────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Zone-by-zone (tile coords in `venueMap.ts`):
|
||||||
|
|
||||||
|
| Zone | What's there (props) | Light signature | Future role hook |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Bar** (north run) | back-bar shelf (emissive), 3 beer taps, till, glass stacks | steady amber pools along the shelf | Bartender shift: the serving lane between bar tiles and shelf is already unwalkable-to-patrons |
|
||||||
|
| **Dance floor** (centre) | 4 corner speaker stacks, disco ball (centre), floor grid cells | pink grid cells pulse ON `beat:tick`; ball throws a slow-rotating glint | DJ role reads crowd here |
|
||||||
|
| **DJ booth** (east of dance, sealed box facing the floor) | 2 decks + mixer (from the 90sDJsim gear GLBs!), booth monitor, lamp | deep blue wash + VU flicker | The DJ shift's playable pocket |
|
||||||
|
| **Booths** (south + east walls) | table per booth pair, candle | tiny warm candle pools | — |
|
||||||
|
| **Dunnies** (NE walled room) | sink row + mirror on west wall, hand dryer | green fluoro, FLICKERS (torch almost redundant inside — deliberate) | — |
|
||||||
|
| **Smoking yard** (SW walled pocket, open sky) | butt bin, milk crate seats, fence | cool blue outdoor spill + rain particles INSIDE the yard | FLOOR2's smoking loop (specced) |
|
||||||
|
| **Entry corridor** (west) | poster wall (3 art slots — MODELBEAST posters!), cloakroom window, stamp podium | venue-pink spill from the door | UV re-entry checks happen here |
|
||||||
|
| **Glassie station** (bar's west end alcove) | glass racks, tub trolley | dim amber | Glassie role's home base |
|
||||||
|
|
||||||
|
Sim impact: walkability only changes where NEW walls go (DJ booth box, yard
|
||||||
|
fence, sink row). All existing anchors keep working; reachability is asserted in
|
||||||
|
tests. The yard gets a `smoke` anchor upgrade so wanderers actually go out for one.
|
||||||
|
|
||||||
|
## 3. The door street (640×360 tableau)
|
||||||
|
|
||||||
|
Already reads well. Two upgrades, both drop-in images (no layout change):
|
||||||
|
1. **Backdrop plate** behind the existing rects: one wide pixel-art street wall
|
||||||
|
(brick, posters, grime) — swaps in as `gen:street:backdrop` when generated.
|
||||||
|
2. **Kebab shop interior glow card** in its window + rotating poster art in two
|
||||||
|
street frames (same poster slots as the entry corridor).
|
||||||
|
|
||||||
|
## 4. The sprite drop-in contract (how John's art lands)
|
||||||
|
|
||||||
|
- Drop processed PNGs in **`public/props/`** named `<kind>.png` and list them in
|
||||||
|
**`public/props/manifest.json`** (`{"props": ["deck", "speaker", ...]}`).
|
||||||
|
- The floor loads the manifest at boot; anything listed replaces its placeholder
|
||||||
|
(texture key `gen:prop:<kind>`). Missing = placeholder. No code changes.
|
||||||
|
- **`tools/props_import.py`** (PIL, runs anywhere with python3+PIL — ultra has it)
|
||||||
|
takes raw generated images → target pixel size + palette quantise + transparent
|
||||||
|
background → writes `public/props/` + updates the manifest:
|
||||||
|
`python3 tools/props_import.py incoming/deck_raw.png deck`
|
||||||
|
- Target sizes are in the script (`TARGETS`): hero props 48–64 px, furniture
|
||||||
|
24–32 px, small items 16 px. It refuses unknown kinds so typos can't ship.
|
||||||
|
|
||||||
|
## 5. GENERATION QUEUE — John's list (in priority order)
|
||||||
|
|
||||||
|
Pipelines: **[MB]** = MODELBEAST `flux_local` (params given) → drop raw output in
|
||||||
|
`art_incoming/`, run the import script (or hand me the raws — either works).
|
||||||
|
**[GLB]** = Blender ortho prerender of an existing fleet mesh (camera: orthographic,
|
||||||
|
top-down 90° for floor props / front 0° for the street, transparent film, ~512px,
|
||||||
|
neutral even lighting) → same import path.
|
||||||
|
|
||||||
|
| # | Item (kind) | Pipeline & source | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `deck` | [GLB] `ultra:~/Documents/90sDJsim/web/world/models/gear/deck_t3_technics_sl_1200mk2.glb`, top-down | The hero. One render serves both booth decks |
|
||||||
|
| 2 | `mixer` | [GLB] same dir, `mixer.glb` (or the urei once landed), top-down | Sits between the decks |
|
||||||
|
| 3 | `speaker` | [GLB] 90sDJsim `models/props/` has boombox/speaker meshes; else [MB] prompt: "top-down view of a large black PA speaker stack, pixel art game sprite, dark background, viewed directly from above" 512×512 seed 4210 | 4 placements |
|
||||||
|
| 4 | `discoball` | [MB] "mirrored disco ball seen directly from above, glinting, pixel art sprite, black background" 512×512 seed 4211 | Centre of dance floor |
|
||||||
|
| 5 | `barShelf` | [MB] "top-down bar back-shelf with glowing bottles rows, amber light, pixel art sprite, dark" 1024×256 seed 4212 | Emissive — the amber source |
|
||||||
|
| 6 | `taps` | [MB] "row of three beer taps on a bar counter viewed from above, pixel art, dark bg" 256×256 seed 4213 | |
|
||||||
|
| 7 | `boothTable` | [MB] "small round pub table with two drinks seen from directly above, pixel art, dark bg" 256×256 seed 4214 | 6 placements |
|
||||||
|
| 8 | `sink` | [MB] "public bathroom sink row with mirror viewed from above, green fluorescent light, pixel art, dark" 512×256 seed 4215 | |
|
||||||
|
| 9 | `buttBin` + `crate` | [MB] one sheet: "outdoor smoking area props: cigarette butt bin and milk crate, top-down pixel art, dark bg" 512×256 seed 4216 | Yard dressing |
|
||||||
|
| 10 | `poster1..3` | [MB] three runs: "grimy nightclub gig poster, 90s Australian pub rock / techno rave / missing cat, pixel art, portrait" 256×384 seeds 4217-19 | FRONT view (posters hang on walls) — used at door + corridor |
|
||||||
|
| 11 | `street:backdrop` | [MB] "wide pixel art night street wall: dark brick, torn posters, graffiti, neon reflections, rain, front-on orthographic, game background" 1600×440 seed 4220 | The door scene plate. Biggest single visual win |
|
||||||
|
| 12 | `cloak` / `glassRack` | [MB] "cloakroom window with hanging jackets / metal glass rack with stacked pint glasses, top-down pixel art, dark" 256×256 seeds 4221-22 | Corridor + glassie station |
|
||||||
|
| 13 | `arcade` | [GLB] `m3ultra:~/Documents/procity/pipeline/_normalized/procity_fit_arcade_cabinet_01.glb`, top-down | Corner easter egg near entry |
|
||||||
|
|
||||||
|
**Ambition tier (later, not blocking):** pre-rendered patron sprite sheets from
|
||||||
|
the modular character kit + RIGROOM clips (`Searching_Pockets`, boxing pack,
|
||||||
|
`Leaning_Idle02_Smoking`, zombie stagger for maggots) — replaces the procedural
|
||||||
|
dolls entirely per docs/ASSETS.md §2. Wants its own session to set the
|
||||||
|
palette/density once.
|
||||||
|
|
||||||
|
*(flux params for all [MB] jobs: `model flux2-klein-4b, steps 4`; put "no text,
|
||||||
|
no lettering" in every prompt EXCEPT the posters, where mangled text reads as
|
||||||
|
authentic gig-poster grot.)*
|
||||||
BIN
public/props/arcade.png
Normal file
|
After Width: | Height: | Size: 836 B |
BIN
public/props/barShelf.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
public/props/boothLamp.png
Normal file
|
After Width: | Height: | Size: 424 B |
BIN
public/props/boothTable.png
Normal file
|
After Width: | Height: | Size: 644 B |
BIN
public/props/buttBin.png
Normal file
|
After Width: | Height: | Size: 571 B |
BIN
public/props/carpet.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/props/cloak.png
Normal file
|
After Width: | Height: | Size: 691 B |
BIN
public/props/crate.png
Normal file
|
After Width: | Height: | Size: 549 B |
BIN
public/props/deck.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/props/discoball.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
public/props/dishwasher.png
Normal file
|
After Width: | Height: | Size: 552 B |
BIN
public/props/glassRack.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
public/props/glassStack.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/props/highTable.png
Normal file
|
After Width: | Height: | Size: 430 B |
32
public/props/manifest.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"props": [
|
||||||
|
"arcade",
|
||||||
|
"barShelf",
|
||||||
|
"boothLamp",
|
||||||
|
"boothTable",
|
||||||
|
"buttBin",
|
||||||
|
"carpet",
|
||||||
|
"cloak",
|
||||||
|
"crate",
|
||||||
|
"deck",
|
||||||
|
"discoball",
|
||||||
|
"dishwasher",
|
||||||
|
"glassRack",
|
||||||
|
"glassStack",
|
||||||
|
"highTable",
|
||||||
|
"mixer",
|
||||||
|
"monitor",
|
||||||
|
"plant",
|
||||||
|
"poolTable",
|
||||||
|
"poster1",
|
||||||
|
"poster2",
|
||||||
|
"poster3",
|
||||||
|
"sinkRow",
|
||||||
|
"speaker",
|
||||||
|
"stampPodium",
|
||||||
|
"street_backdrop",
|
||||||
|
"taps",
|
||||||
|
"till",
|
||||||
|
"wetFloor"
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
public/props/mixer.png
Normal file
|
After Width: | Height: | Size: 918 B |
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/poster1.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
public/props/poster2.png
Normal file
|
After Width: | Height: | Size: 851 B |
BIN
public/props/poster3.png
Normal file
|
After Width: | Height: | Size: 892 B |
BIN
public/props/sinkRow.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
public/props/speaker.png
Normal file
|
After Width: | Height: | Size: 657 B |
BIN
public/props/stampPodium.png
Normal file
|
After Width: | Height: | Size: 502 B |
BIN
public/props/street_backdrop.png
Normal file
|
After Width: | Height: | Size: 206 KiB |
BIN
public/props/taps.png
Normal file
|
After Width: | Height: | Size: 1001 B |
BIN
public/props/till.png
Normal file
|
After Width: | Height: | Size: 646 B |
BIN
public/props/wetFloor.png
Normal file
|
After Width: | Height: | Size: 387 B |
445
src/audio/Ambience.ts
Normal file
@ -0,0 +1,445 @@
|
|||||||
|
// The beds under the night: rain outside, bodies inside, and the kebab shop's
|
||||||
|
// fluoro tube humming to itself while both happen.
|
||||||
|
//
|
||||||
|
// ROUTING. Everything here lands on the engine's sfxBus, never the musicBus.
|
||||||
|
// The music lowpass models "the track heard through a wall" — that is a
|
||||||
|
// statement about the PA being on the other side of a door. Rain is not on the
|
||||||
|
// other side of anything; it is falling on your head, so filtering it with the
|
||||||
|
// music would put the weather indoors with the speakers. What makes rain
|
||||||
|
// door-only is its own gain envelope tracking `audio:location`, not a cutoff.
|
||||||
|
// The crowd bed is the same argument run backwards: it is the room you are
|
||||||
|
// standing in when you are inside, and a wall's worth of muffling belongs on
|
||||||
|
// the PA, not on the people. Location is a mix decision here, not a filter one.
|
||||||
|
|
||||||
|
import type { EventBus } from '../core/EventBus';
|
||||||
|
import { SeededRNG } from '../core/SeededRNG';
|
||||||
|
import type { Sfx } from './Sfx';
|
||||||
|
|
||||||
|
export interface AmbienceOptions {
|
||||||
|
rain?: boolean;
|
||||||
|
crowd?: boolean;
|
||||||
|
kebab?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Bed = 'rain' | 'crowd' | 'kebab';
|
||||||
|
type Location = 'door' | 'floor';
|
||||||
|
|
||||||
|
/** Seconds of looped noise behind the crowd bed. Long enough to hide the seam. */
|
||||||
|
const NOISE_SECONDS = 4;
|
||||||
|
|
||||||
|
/** Location crossfade. Sits between the 600/900ms filter sweeps either way, so
|
||||||
|
* stepping through the door reads as one movement rather than two events. */
|
||||||
|
const LOC_FADE_S = 0.7;
|
||||||
|
|
||||||
|
/** Headcount swell. Slow enough to be a swell, fast enough to feel caused. */
|
||||||
|
const COUNT_FADE_S = 0.4;
|
||||||
|
|
||||||
|
/** Enable/disable toggles are a tuning affordance, not a game beat — keep short. */
|
||||||
|
const TOGGLE_FADE_S = 0.25;
|
||||||
|
|
||||||
|
/** Peak crowd gain, at a room so full it cannot get louder. Well under the kick. */
|
||||||
|
const CROWD_PEAK = 0.09;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saturation constant for the headcount curve. At k=14 the first dozen people
|
||||||
|
* buy most of the loudness and the hundredth buys almost none — which is how a
|
||||||
|
* room actually behaves, and the opposite of what a linear map does.
|
||||||
|
*/
|
||||||
|
const CROWD_K = 14;
|
||||||
|
|
||||||
|
/** Crowd bed at the door: the room leaking past you, not silence. */
|
||||||
|
const CROWD_DOOR_DUCK = 0.15;
|
||||||
|
|
||||||
|
/** The fluoro tube. Barely there on purpose — it is set dressing, not a voice. */
|
||||||
|
const KEBAB_LEVEL = 0.014;
|
||||||
|
|
||||||
|
export class Ambience {
|
||||||
|
private readonly bus: EventBus;
|
||||||
|
private readonly ctx: AudioContext;
|
||||||
|
private readonly dest: AudioNode;
|
||||||
|
private readonly sfx: Sfx;
|
||||||
|
|
||||||
|
private readonly offs: Array<() => void> = [];
|
||||||
|
|
||||||
|
private readonly enabled: Record<Bed, boolean>;
|
||||||
|
|
||||||
|
private loc: Location = 'door';
|
||||||
|
private running = false;
|
||||||
|
private count = 0;
|
||||||
|
|
||||||
|
private crowd: CrowdBed | null = null;
|
||||||
|
private kebab: KebabBed | null = null;
|
||||||
|
|
||||||
|
/** Built on first start() and kept: restarting shouldn't re-allocate 4s of noise. */
|
||||||
|
private noise: AudioBuffer | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
bus: EventBus,
|
||||||
|
ctx: AudioContext,
|
||||||
|
destination: AudioNode,
|
||||||
|
sfx: Sfx,
|
||||||
|
opts: AmbienceOptions = {},
|
||||||
|
) {
|
||||||
|
this.bus = bus;
|
||||||
|
this.ctx = ctx;
|
||||||
|
this.dest = destination;
|
||||||
|
this.sfx = sfx;
|
||||||
|
this.enabled = {
|
||||||
|
rain: opts.rain ?? true,
|
||||||
|
crowd: opts.crowd ?? true,
|
||||||
|
// The kebab shop is the moral centre of the thing (design doc §5). It gets
|
||||||
|
// a sound whether or not anyone is listening for it.
|
||||||
|
kebab: opts.kebab ?? true,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.offs.push(
|
||||||
|
this.bus.on('audio:location', ({ location }) => {
|
||||||
|
if (location === this.loc) return; // door -> door must not restack a fade
|
||||||
|
this.loc = location;
|
||||||
|
this.applyLocation();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Headcount is derived, not published: NightState.capacity.inside is in no
|
||||||
|
// event payload and we may not add one. So we count the truth log — an
|
||||||
|
// admission puts a body in the room, an ejection takes one out.
|
||||||
|
//
|
||||||
|
// Deliberately NOT door:clicker. The clicker is the player's *claimed*
|
||||||
|
// count, and the gap between it and reality is a game mechanic. Driving the
|
||||||
|
// crowd bed off it would make the room sound like the lie instead of the
|
||||||
|
// room, and would hand the player a way to hear their own miscount.
|
||||||
|
this.offs.push(
|
||||||
|
this.bus.on('door:verdict', ({ verdict }) => {
|
||||||
|
if (verdict === 'admit') this.setInsideCount(this.count + 1);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.offs.push(
|
||||||
|
this.bus.on('floor:ejection', () => this.setInsideCount(this.count - 1)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get insideCount(): number {
|
||||||
|
return this.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lets a demo drive the room without staging a hundred real admissions. */
|
||||||
|
setInsideCount(n: number): void {
|
||||||
|
const next = Math.max(0, Math.floor(n));
|
||||||
|
if (next === this.count) return;
|
||||||
|
this.count = next;
|
||||||
|
this.crowd?.setLevel(this.crowdTarget(), COUNT_FADE_S);
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.running) return;
|
||||||
|
this.running = true;
|
||||||
|
|
||||||
|
this.noise ??= noiseBuffer(this.ctx);
|
||||||
|
this.crowd = new CrowdBed(this.ctx, this.dest, this.noise);
|
||||||
|
this.kebab = new KebabBed(this.ctx, this.dest);
|
||||||
|
|
||||||
|
// Snap rather than fade on the first frame: there is no previous location to
|
||||||
|
// cross from, and a 700ms swell out of nothing sounds like a mistake.
|
||||||
|
this.crowd.setLevel(this.crowdTarget(), 0);
|
||||||
|
this.crowd.setLocation(this.crowdDuck(), 0);
|
||||||
|
this.kebab.setLevel(this.kebabTarget(), 0);
|
||||||
|
this.kebab.setLocation(this.kebabDuck(), 0);
|
||||||
|
this.applyRain();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (!this.running) return;
|
||||||
|
this.running = false;
|
||||||
|
|
||||||
|
this.crowd?.stop();
|
||||||
|
this.crowd = null;
|
||||||
|
this.kebab?.stop();
|
||||||
|
this.kebab = null;
|
||||||
|
this.sfx.stopRain();
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnabled(bed: Bed, on: boolean): void {
|
||||||
|
if (this.enabled[bed] === on) return;
|
||||||
|
this.enabled[bed] = on;
|
||||||
|
if (!this.running) return;
|
||||||
|
|
||||||
|
switch (bed) {
|
||||||
|
case 'rain':
|
||||||
|
return this.applyRain();
|
||||||
|
case 'crowd':
|
||||||
|
return this.crowd?.setLevel(this.crowdTarget(), TOGGLE_FADE_S);
|
||||||
|
case 'kebab':
|
||||||
|
return this.kebab?.setLevel(this.kebabTarget(), TOGGLE_FADE_S);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.stop();
|
||||||
|
for (const off of this.offs) off();
|
||||||
|
this.offs.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- policy ---------------------------------------------------------------
|
||||||
|
|
||||||
|
private applyLocation(): void {
|
||||||
|
this.crowd?.setLocation(this.crowdDuck(), LOC_FADE_S);
|
||||||
|
this.kebab?.setLocation(this.kebabDuck(), LOC_FADE_S);
|
||||||
|
this.applyRain();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rain is delegated to Sfx, which already owns a rain bed wired to this same
|
||||||
|
* bus; a second one would just be the first one twice as loud. Both calls are
|
||||||
|
* idempotent, so repeated door -> door is a no-op there as well as here.
|
||||||
|
*
|
||||||
|
* The `running` guard is what the crowd and kebab beds get for free from their
|
||||||
|
* null refs. Without it a location change after stop() — the subscription
|
||||||
|
* outlives stop(), only destroy() drains it — would start rain under a room
|
||||||
|
* with no crowd and no kebab in it, and nothing would take it back down.
|
||||||
|
*/
|
||||||
|
private applyRain(): void {
|
||||||
|
if (!this.running) return;
|
||||||
|
if (this.enabled.rain && this.loc === 'door') this.sfx.startRain();
|
||||||
|
else this.sfx.stopRain();
|
||||||
|
}
|
||||||
|
|
||||||
|
private crowdTarget(): number {
|
||||||
|
if (!this.enabled.crowd) return 0;
|
||||||
|
return CROWD_PEAK * (1 - Math.exp(-this.count / CROWD_K));
|
||||||
|
}
|
||||||
|
|
||||||
|
private crowdDuck(): number {
|
||||||
|
return this.loc === 'floor' ? 1 : CROWD_DOOR_DUCK;
|
||||||
|
}
|
||||||
|
|
||||||
|
private kebabTarget(): number {
|
||||||
|
return this.enabled.kebab ? KEBAB_LEVEL : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private kebabDuck(): number {
|
||||||
|
return this.loc === 'door' ? 1 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bodies and talking. A wide bandpass around 500Hz is the whole trick: above
|
||||||
|
* ~1kHz noise turns into hiss and below ~250Hz it turns into traffic, and the
|
||||||
|
* band between them is where a room full of people sits.
|
||||||
|
*/
|
||||||
|
class CrowdBed {
|
||||||
|
private readonly src: AudioBufferSourceNode;
|
||||||
|
private readonly lfos: OscillatorNode[] = [];
|
||||||
|
private readonly chain: AudioNode[] = [];
|
||||||
|
private readonly level: GainNode;
|
||||||
|
private readonly loc: GainNode;
|
||||||
|
private readonly ctx: AudioContext;
|
||||||
|
|
||||||
|
constructor(ctx: AudioContext, dest: AudioNode, buffer: AudioBuffer) {
|
||||||
|
this.ctx = ctx;
|
||||||
|
|
||||||
|
this.src = ctx.createBufferSource();
|
||||||
|
this.src.buffer = buffer;
|
||||||
|
this.src.loop = true;
|
||||||
|
// Slowed a little: it drags the noise's spectral centre down and costs nothing.
|
||||||
|
this.src.playbackRate.value = 0.85;
|
||||||
|
|
||||||
|
const band = ctx.createBiquadFilter();
|
||||||
|
band.type = 'bandpass';
|
||||||
|
band.frequency.value = 500;
|
||||||
|
band.Q.value = 0.75; // ~300-800Hz at -3dB
|
||||||
|
|
||||||
|
// Second pass shaves the fizz the bandpass's gentle skirt lets through.
|
||||||
|
const lp = ctx.createBiquadFilter();
|
||||||
|
lp.type = 'lowpass';
|
||||||
|
lp.frequency.value = 1100;
|
||||||
|
|
||||||
|
// Two slow LFOs on a multiplying node, not on the level itself — swelling a
|
||||||
|
// gain of 0 would push it negative, and an empty room has to be silent.
|
||||||
|
const wobble = ctx.createGain();
|
||||||
|
wobble.gain.value = 1;
|
||||||
|
this.wobbler(wobble, 0.19, 0.12);
|
||||||
|
this.wobbler(wobble, 0.07, 0.07);
|
||||||
|
|
||||||
|
this.level = ctx.createGain();
|
||||||
|
this.level.gain.value = 0;
|
||||||
|
|
||||||
|
this.loc = ctx.createGain();
|
||||||
|
this.loc.gain.value = 0;
|
||||||
|
|
||||||
|
this.chain.push(band, lp, wobble, this.level, this.loc);
|
||||||
|
this.src.connect(band).connect(lp).connect(wobble).connect(this.level).connect(this.loc);
|
||||||
|
this.loc.connect(dest);
|
||||||
|
this.src.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
setLevel(to: number, durS: number): void {
|
||||||
|
ramp(this.ctx, this.level.gain, to, durS);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocation(to: number, durS: number): void {
|
||||||
|
ramp(this.ctx, this.loc.gain, to, durS);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
stopBed(this.ctx, this.loc, this.src, this.lfos, this.chain);
|
||||||
|
}
|
||||||
|
|
||||||
|
private wobbler(target: GainNode, hz: number, depth: number): void {
|
||||||
|
const lfo = this.ctx.createOscillator();
|
||||||
|
lfo.type = 'sine';
|
||||||
|
lfo.frequency.value = hz;
|
||||||
|
const amount = this.ctx.createGain();
|
||||||
|
amount.gain.value = depth;
|
||||||
|
lfo.connect(amount).connect(target.gain);
|
||||||
|
lfo.start();
|
||||||
|
this.lfos.push(lfo);
|
||||||
|
this.chain.push(amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The kebab shop's fluoro tube, heard from the footpath. 100Hz because a tube
|
||||||
|
* buzzes at twice mains frequency; the 50Hz underneath is the ballast doing the
|
||||||
|
* work. Mono and completely dry — it is six metres away through an open
|
||||||
|
* shopfront, and reverb would put it in a cathedral.
|
||||||
|
*/
|
||||||
|
class KebabBed {
|
||||||
|
private readonly oscs: OscillatorNode[] = [];
|
||||||
|
private readonly chain: AudioNode[] = [];
|
||||||
|
private readonly level: GainNode;
|
||||||
|
private readonly loc: GainNode;
|
||||||
|
private readonly ctx: AudioContext;
|
||||||
|
|
||||||
|
constructor(ctx: AudioContext, dest: AudioNode) {
|
||||||
|
this.ctx = ctx;
|
||||||
|
|
||||||
|
const sum = ctx.createGain();
|
||||||
|
sum.gain.value = 1;
|
||||||
|
|
||||||
|
// Fundamental, mains harmonic, and a thin 200Hz that keeps it from reading
|
||||||
|
// as a clean test tone.
|
||||||
|
this.partial(100, 1, sum);
|
||||||
|
this.partial(50, 0.45, sum);
|
||||||
|
this.partial(200, 0.18, sum);
|
||||||
|
|
||||||
|
const lp = ctx.createBiquadFilter();
|
||||||
|
lp.type = 'lowpass';
|
||||||
|
lp.frequency.value = 400;
|
||||||
|
|
||||||
|
// A dying tube breathes. 0.6Hz at 8% is under conscious notice and is the
|
||||||
|
// difference between "hum" and "sine wave someone left running".
|
||||||
|
const flicker = ctx.createGain();
|
||||||
|
flicker.gain.value = 1;
|
||||||
|
const lfo = ctx.createOscillator();
|
||||||
|
lfo.type = 'sine';
|
||||||
|
lfo.frequency.value = 0.6;
|
||||||
|
const depth = ctx.createGain();
|
||||||
|
depth.gain.value = 0.08;
|
||||||
|
lfo.connect(depth).connect(flicker.gain);
|
||||||
|
lfo.start();
|
||||||
|
this.oscs.push(lfo);
|
||||||
|
|
||||||
|
this.level = ctx.createGain();
|
||||||
|
this.level.gain.value = 0;
|
||||||
|
|
||||||
|
this.loc = ctx.createGain();
|
||||||
|
this.loc.gain.value = 0;
|
||||||
|
|
||||||
|
this.chain.push(sum, lp, depth, flicker, this.level, this.loc);
|
||||||
|
sum.connect(lp).connect(flicker).connect(this.level).connect(this.loc);
|
||||||
|
this.loc.connect(dest);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLevel(to: number, durS: number): void {
|
||||||
|
ramp(this.ctx, this.level.gain, to, durS);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocation(to: number, durS: number): void {
|
||||||
|
ramp(this.ctx, this.loc.gain, to, durS);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
stopBed(this.ctx, this.loc, null, this.oscs, this.chain);
|
||||||
|
}
|
||||||
|
|
||||||
|
private partial(hz: number, gain: number, sum: GainNode): void {
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.value = hz;
|
||||||
|
const g = this.ctx.createGain();
|
||||||
|
g.gain.value = gain;
|
||||||
|
osc.connect(g).connect(sum);
|
||||||
|
osc.start();
|
||||||
|
this.oscs.push(osc);
|
||||||
|
this.chain.push(g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- plumbing ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linear throughout, unlike the engine's sweeps: these targets legitimately
|
||||||
|
* reach 0 (empty room, bed disabled) and an exponential ramp to zero throws.
|
||||||
|
* Re-anchoring at the live value first keeps an interrupted fade continuous —
|
||||||
|
* players do walk in and straight back out.
|
||||||
|
*
|
||||||
|
* Read `param.value` BEFORE cancelling, same as `TechnoEngine.ramp()`.
|
||||||
|
* `cancelScheduledValues(now)` removes every event at or after `now`, including
|
||||||
|
* the in-flight ramp's end event, and the spec does not say what the getter
|
||||||
|
* reads afterwards: Blink returns the last rendered value, but a UA computing it
|
||||||
|
* from the surviving timeline returns the ramp's origin. Reading first pins the
|
||||||
|
* mid-ramp value the player is actually hearing, so a crowd bed 350ms into a
|
||||||
|
* 0.15 -> 1 door->floor swell fades from ~0.57 rather than jumping down to 0.15.
|
||||||
|
*
|
||||||
|
* `cancelAndHoldAtTime` holds that value at `now` instead of discarding it,
|
||||||
|
* which is the same thing done properly — but Firefox has never shipped it, so
|
||||||
|
* feature-detect and fall back.
|
||||||
|
*/
|
||||||
|
function ramp(ctx: AudioContext, param: AudioParam, to: number, durS: number): void {
|
||||||
|
const now = ctx.currentTime;
|
||||||
|
const from = param.value;
|
||||||
|
if (typeof param.cancelAndHoldAtTime === 'function') param.cancelAndHoldAtTime(now);
|
||||||
|
else param.cancelScheduledValues(now);
|
||||||
|
param.setValueAtTime(from, now);
|
||||||
|
if (durS <= 0) param.setValueAtTime(to, now);
|
||||||
|
else param.linearRampToValueAtTime(to, now + durS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fade out, then stop and disconnect everything after the fade lands. Cutting a
|
||||||
|
* running noise bed is audible as a click, and leaving the nodes attached means
|
||||||
|
* a six-hour night accumulates every bed it ever started.
|
||||||
|
*/
|
||||||
|
function stopBed(
|
||||||
|
ctx: AudioContext,
|
||||||
|
out: GainNode,
|
||||||
|
src: AudioBufferSourceNode | null,
|
||||||
|
oscs: readonly OscillatorNode[],
|
||||||
|
chain: readonly AudioNode[],
|
||||||
|
): void {
|
||||||
|
const fade = 0.25;
|
||||||
|
const at = ctx.currentTime + fade;
|
||||||
|
ramp(ctx, out.gain, 0, fade);
|
||||||
|
|
||||||
|
const tail = src ?? oscs[0];
|
||||||
|
if (tail) {
|
||||||
|
tail.onended = () => {
|
||||||
|
for (const n of chain) n.disconnect();
|
||||||
|
src?.disconnect();
|
||||||
|
for (const o of oscs) o.disconnect();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
src?.stop(at);
|
||||||
|
for (const o of oscs) o.stop(at);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deterministic white noise — house rule forbids Math.random, and a fixed seed
|
||||||
|
* means the room sounds the same on every run. */
|
||||||
|
function noiseBuffer(ctx: AudioContext): AudioBuffer {
|
||||||
|
const len = Math.floor(ctx.sampleRate * NOISE_SECONDS);
|
||||||
|
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
|
||||||
|
const data = buf.getChannelData(0);
|
||||||
|
const rng = new SeededRNG(0x4b3b).stream('crowd-noise');
|
||||||
|
for (let i = 0; i < len; i++) data[i] = rng.next() * 2 - 1;
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
@ -16,7 +16,8 @@ export type SfxName =
|
|||||||
| 'phoneBuzz'
|
| 'phoneBuzz'
|
||||||
| 'ropeUnhook'
|
| 'ropeUnhook'
|
||||||
| 'denyCrowdOoh'
|
| 'denyCrowdOoh'
|
||||||
| 'typeTick';
|
| 'typeTick'
|
||||||
|
| 'glassSmash';
|
||||||
|
|
||||||
/** Seconds of white noise generated once and shared by every noise voice. */
|
/** Seconds of white noise generated once and shared by every noise voice. */
|
||||||
const NOISE_SECONDS = 2;
|
const NOISE_SECONDS = 2;
|
||||||
@ -84,6 +85,8 @@ export class Sfx {
|
|||||||
return this.denyCrowdOoh();
|
return this.denyCrowdOoh();
|
||||||
case 'typeTick':
|
case 'typeTick':
|
||||||
return this.typeTick();
|
return this.typeTick();
|
||||||
|
case 'glassSmash':
|
||||||
|
return this.glassSmash();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -271,6 +274,15 @@ export class Sfx {
|
|||||||
this.voice(src, [this.band(3400, 2.2), this.env(t, 0.0005, 0.002, 0.012, 0.045)], t, 0.016);
|
this.voice(src, [this.band(3400, 2.2), this.env(t, 0.0005, 0.002, 0.012, 0.045)], t, 0.016);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A pint glass meeting a tiled floor: bright burst, short shard ring. */
|
||||||
|
glassSmash(): void {
|
||||||
|
const t = this.ctx.currentTime;
|
||||||
|
const src = this.noiseSource();
|
||||||
|
this.voice(src, [this.band(3100, 1.1), this.env(t, 0.001, 0.02, 0.12, 0.22)], t, 0.24);
|
||||||
|
const ring = this.noiseSource();
|
||||||
|
this.voice(ring, [this.band(5200, 6), this.env(t + 0.03, 0.001, 0.01, 0.08, 0.16)], t + 0.03, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
/** Idempotent — the door scene may call this on every re-entry. */
|
/** Idempotent — the door scene may call this on every re-entry. */
|
||||||
startRain(): void {
|
startRain(): void {
|
||||||
if (this.rain) return;
|
if (this.rain) return;
|
||||||
|
|||||||
@ -1,11 +1,22 @@
|
|||||||
import type { EventBus } from '../core/EventBus';
|
import type { EventBus } from '../core/EventBus';
|
||||||
import { SeededRNG } from '../core/SeededRNG';
|
import { SeededRNG } from '../core/SeededRNG';
|
||||||
|
import { type ArcScales, arcAt } from './arc';
|
||||||
import {
|
import {
|
||||||
type AudioLocation,
|
type AudioLocation,
|
||||||
cutoffFor,
|
cutoffFor,
|
||||||
gainFor,
|
gainFor,
|
||||||
sweepMsFor,
|
sweepMsFor,
|
||||||
} from './filterCurve';
|
} from './filterCurve';
|
||||||
|
import {
|
||||||
|
DEFAULT_MIX,
|
||||||
|
MIX_RANGES,
|
||||||
|
type TrackMix,
|
||||||
|
VOICE_NAMES,
|
||||||
|
type VoiceName,
|
||||||
|
clampTo,
|
||||||
|
cloneMix,
|
||||||
|
voiceGain,
|
||||||
|
} from './mix';
|
||||||
import {
|
import {
|
||||||
type BeatGrid,
|
type BeatGrid,
|
||||||
barOf,
|
barOf,
|
||||||
@ -29,28 +40,33 @@ const TIMER_MS = 25;
|
|||||||
*/
|
*/
|
||||||
const START_LEAD_MS = 60;
|
const START_LEAD_MS = 60;
|
||||||
|
|
||||||
const MASTER_GAIN = 0.8;
|
|
||||||
|
|
||||||
// Resonance: a hair of peak at the cutoff is what reads as "bass through a wall"
|
|
||||||
// rather than "someone turned the treble down". Flat once you're on the floor.
|
|
||||||
const DOOR_Q = 6;
|
|
||||||
const FLOOR_Q = 0.7;
|
|
||||||
|
|
||||||
const KICK_PEAK = 0.9;
|
|
||||||
const HAT_PEAK = 0.25;
|
|
||||||
const BASS_PEAK = 0.11;
|
|
||||||
const PAD_PEAK = 0.05;
|
|
||||||
|
|
||||||
const SILENT = 0.0001; // exponential ramps cannot reach 0
|
const SILENT = 0.0001; // exponential ramps cannot reach 0
|
||||||
|
|
||||||
const BASS_ROOT_HZ = 55; // A1
|
|
||||||
const BASS_STEPS = 32; // 2 bars of 16ths
|
const BASS_STEPS = 32; // 2 bars of 16ths
|
||||||
const PAD_BARS = 8;
|
|
||||||
|
/** Seconds to the top of the bass filter sweep. Shape, not level — not tunable. */
|
||||||
|
const BASS_SWEEP_S = 0.03;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Semitones from A1, one entry per 16th. Nothing lands on a downbeat — the kick
|
* The bass tail settles a touch above the sweep floor: `filterLoHz` is where the
|
||||||
* owns that slot, and leaving it empty is what makes the bassline roll instead
|
* note *starts*, and resting exactly there makes the release sound like a gate.
|
||||||
* of thud.
|
* Expressed as a ratio so retuning the floor carries the rest point with it.
|
||||||
|
*/
|
||||||
|
const BASS_TAIL_RATIO = 200 / 180;
|
||||||
|
|
||||||
|
/** Fraction of the pad cycle spent fading in; the rest fades out. */
|
||||||
|
const PAD_RISE = 0.4;
|
||||||
|
|
||||||
|
/** A slider drag on `master` should not zipper, but should not lag either. */
|
||||||
|
const KNOB_RAMP_S = 0.03;
|
||||||
|
|
||||||
|
/** Mix paths the engine rounds at use-time, so they must be stored rounded too. */
|
||||||
|
const INTEGER_PATHS = new Set(['pad.bars']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Semitones from the bass root, one entry per 16th. Nothing lands on a downbeat
|
||||||
|
* — the kick owns that slot, and leaving it empty is what makes the bassline
|
||||||
|
* roll instead of thud.
|
||||||
*/
|
*/
|
||||||
const BASS_PATTERN: readonly (number | null)[] = [
|
const BASS_PATTERN: readonly (number | null)[] = [
|
||||||
null, null, 0, 0,
|
null, null, 0, 0,
|
||||||
@ -70,6 +86,29 @@ const PAD_VOICES: ReadonlyArray<{ hz: number; detune: number; type: OscillatorTy
|
|||||||
{ hz: 164.81, detune: 0, type: 'triangle' },
|
{ hz: 164.81, detune: 0, type: 'triangle' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Widened once so a dotted path can be looked up without a cast at each site. */
|
||||||
|
const RANGES: Record<string, readonly [number, number] | undefined> = MIX_RANGES;
|
||||||
|
|
||||||
|
const GROUP_NAMES: readonly VoiceName[] = VOICE_NAMES;
|
||||||
|
|
||||||
|
interface MixLens {
|
||||||
|
read: () => number;
|
||||||
|
write: (v: number) => void;
|
||||||
|
range: readonly [number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The engine currently owning the AudioContext, or null.
|
||||||
|
*
|
||||||
|
* The tuning desk has to reach the engine during a real night, when the scene
|
||||||
|
* that constructed it belongs to another lane and there is no route to it. A
|
||||||
|
* module variable is the smallest thing that solves that — deliberately not a
|
||||||
|
* window global, which would make it part of the page's public surface.
|
||||||
|
*/
|
||||||
|
let activeEngine: TechnoEngine | null = null;
|
||||||
|
|
||||||
|
export const getActiveEngine = (): TechnoEngine | null => activeEngine;
|
||||||
|
|
||||||
export interface TechnoEngineOptions {
|
export interface TechnoEngineOptions {
|
||||||
bpm?: number;
|
bpm?: number;
|
||||||
lookaheadMs?: number;
|
lookaheadMs?: number;
|
||||||
@ -85,6 +124,10 @@ export interface TechnoEngineOptions {
|
|||||||
* future. That timer is throttled on a blurred tab exactly as rAF is, so the
|
* future. That timer is throttled on a blurred tab exactly as rAF is, so the
|
||||||
* look-ahead alone does not save us; what does is `resync()`, which drops the
|
* look-ahead alone does not save us; what does is `resync()`, which drops the
|
||||||
* span that elapsed while we were asleep instead of dumping it into the graph.
|
* span that elapsed while we were asleep instead of dumping it into the graph.
|
||||||
|
*
|
||||||
|
* Every level and shape comes off a live `TrackMix` scaled by the night arc, so
|
||||||
|
* the track can be retuned and auditioned mid-playback. Nothing is read at
|
||||||
|
* construction that is not re-read on the next beat.
|
||||||
*/
|
*/
|
||||||
export class TechnoEngine {
|
export class TechnoEngine {
|
||||||
readonly ctx: AudioContext;
|
readonly ctx: AudioContext;
|
||||||
@ -105,6 +148,12 @@ export class TechnoEngine {
|
|||||||
*/
|
*/
|
||||||
private readonly voices = new Map<AudioScheduledSourceNode, AudioNode[]>();
|
private readonly voices = new Map<AudioScheduledSourceNode, AudioNode[]>();
|
||||||
|
|
||||||
|
private mixState: TrackMix = cloneMix(DEFAULT_MIX);
|
||||||
|
private clockMinute = 0;
|
||||||
|
private pinnedMinute: number | null = null;
|
||||||
|
/** Cached so the scheduler is not re-interpolating the arc ~20 times a second. */
|
||||||
|
private scales: ArcScales = arcAt(0);
|
||||||
|
|
||||||
private timer: ReturnType<typeof setInterval> | null = null;
|
private timer: ReturnType<typeof setInterval> | null = null;
|
||||||
private nextBeat = 0;
|
private nextBeat = 0;
|
||||||
private loc: AudioLocation = 'door';
|
private loc: AudioLocation = 'door';
|
||||||
@ -122,13 +171,13 @@ export class TechnoEngine {
|
|||||||
this.grid = { bpm: this.bpm, originMs: 0 };
|
this.grid = { bpm: this.bpm, originMs: 0 };
|
||||||
|
|
||||||
this.master = this.ctx.createGain();
|
this.master = this.ctx.createGain();
|
||||||
this.master.gain.value = MASTER_GAIN;
|
this.master.gain.value = this.mixState.master;
|
||||||
this.master.connect(this.ctx.destination);
|
this.master.connect(this.ctx.destination);
|
||||||
|
|
||||||
this.filter = this.ctx.createBiquadFilter();
|
this.filter = this.ctx.createBiquadFilter();
|
||||||
this.filter.type = 'lowpass';
|
this.filter.type = 'lowpass';
|
||||||
this.filter.frequency.value = cutoffFor(this.loc);
|
this.filter.frequency.value = cutoffFor(this.loc);
|
||||||
this.filter.Q.value = DOOR_Q;
|
this.filter.Q.value = this.qFor(this.loc);
|
||||||
this.filter.connect(this.master);
|
this.filter.connect(this.master);
|
||||||
|
|
||||||
this.musicBus = this.ctx.createGain();
|
this.musicBus = this.ctx.createGain();
|
||||||
@ -144,7 +193,14 @@ export class TechnoEngine {
|
|||||||
|
|
||||||
this.unsubs.push(
|
this.unsubs.push(
|
||||||
this.bus.on('audio:location', ({ location }) => this.setLocation(location)),
|
this.bus.on('audio:location', ({ location }) => this.setLocation(location)),
|
||||||
|
this.bus.on('clock:tick', ({ clockMin }) => {
|
||||||
|
this.clockMinute = clockMin;
|
||||||
|
if (this.pinnedMinute === null) this.scales = arcAt(clockMin);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-this-alias -- the registry is the point
|
||||||
|
activeEngine = this;
|
||||||
}
|
}
|
||||||
|
|
||||||
get running(): boolean {
|
get running(): boolean {
|
||||||
@ -163,6 +219,76 @@ export class TechnoEngine {
|
|||||||
return this.loc;
|
return this.loc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- mix ------------------------------------------------------------------
|
||||||
|
|
||||||
|
get mix(): Readonly<TrackMix> {
|
||||||
|
return this.mixState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set one knob by dotted path: `'master'`, `'levels.<voice>'`,
|
||||||
|
* `'<voice>.<field>'`, `'doorQ'`, `'floorQ'`.
|
||||||
|
*
|
||||||
|
* An unknown path is inert rather than fatal — the tuning desk is generated
|
||||||
|
* from a table, and a typo there should cost one dead slider, not the night.
|
||||||
|
*/
|
||||||
|
setMixValue(path: string, value: number): void {
|
||||||
|
const lens = this.lens(path);
|
||||||
|
if (!lens) return;
|
||||||
|
// Quantise here rather than at the slider: what we store is what formatMix
|
||||||
|
// dumps, so a fractional bar count would export a value nobody ever heard.
|
||||||
|
// The engine owns the invariant because it is the thing that rounds at use.
|
||||||
|
const v = clampTo(INTEGER_PATHS.has(path) ? Math.round(value) : value, lens.range);
|
||||||
|
lens.write(v);
|
||||||
|
|
||||||
|
// Levels and shapes land on the next scheduled note (<500ms, inaudible as
|
||||||
|
// lag). These two are continuous, so they have to move now.
|
||||||
|
if (path === 'master') this.ramp(this.master.gain, v, KNOB_RAMP_S, false);
|
||||||
|
if (path === this.loc + 'Q' && this.override === null) {
|
||||||
|
this.ramp(this.filter.Q, v, KNOB_RAMP_S, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** NaN for an unknown path — a wrong-but-plausible 0 would read as a real value. */
|
||||||
|
getMixValue(path: string): number {
|
||||||
|
return this.lens(path)?.read() ?? Number.NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVoiceEnabled(voice: VoiceName, on: boolean): void {
|
||||||
|
this.mixState.enabled[voice] = on;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetMix(): void {
|
||||||
|
this.mixState = cloneMix(DEFAULT_MIX);
|
||||||
|
this.ramp(this.master.gain, this.mixState.master, KNOB_RAMP_S, false);
|
||||||
|
if (this.override === null) {
|
||||||
|
this.ramp(this.filter.Q, this.qFor(this.loc), KNOB_RAMP_S, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- night arc ------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pin the arc to a minute for the ear pass — this is how 1 AM gets auditioned
|
||||||
|
* at 9 PM. null hands the arc back to `clock:tick`.
|
||||||
|
*/
|
||||||
|
setArcMinute(min: number | null): void {
|
||||||
|
this.pinnedMinute = min;
|
||||||
|
this.scales = arcAt(this.arcMinute);
|
||||||
|
}
|
||||||
|
|
||||||
|
get arcMinute(): number {
|
||||||
|
return this.pinnedMinute ?? this.clockMinute;
|
||||||
|
}
|
||||||
|
|
||||||
|
get arcScales(): ArcScales {
|
||||||
|
return { ...this.scales };
|
||||||
|
}
|
||||||
|
|
||||||
|
get arcPinned(): boolean {
|
||||||
|
return this.pinnedMinute !== null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resume after a real user gesture. Idempotent, and safe to call concurrently
|
* Resume after a real user gesture. Idempotent, and safe to call concurrently
|
||||||
* — overlapping callers await the same resume rather than racing the origin.
|
* — overlapping callers await the same resume rather than racing the origin.
|
||||||
@ -228,7 +354,7 @@ export class TechnoEngine {
|
|||||||
const durS = immediate ? 0 : sweepMsFor(location) / 1000;
|
const durS = immediate ? 0 : sweepMsFor(location) / 1000;
|
||||||
this.ramp(this.filter.frequency, cutoffFor(location), durS, true);
|
this.ramp(this.filter.frequency, cutoffFor(location), durS, true);
|
||||||
this.ramp(this.musicBus.gain, gainFor(location), durS, false);
|
this.ramp(this.musicBus.gain, gainFor(location), durS, false);
|
||||||
this.ramp(this.filter.Q, location === 'floor' ? FLOOR_Q : DOOR_Q, durS, false);
|
this.ramp(this.filter.Q, this.qFor(location), durS, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Non-null pins the cutoff and suppresses location sweeps; null hands it back. */
|
/** Non-null pins the cutoff and suppresses location sweeps; null hands it back. */
|
||||||
@ -245,6 +371,7 @@ export class TechnoEngine {
|
|||||||
this.stop();
|
this.stop();
|
||||||
for (const off of this.unsubs) off();
|
for (const off of this.unsubs) off();
|
||||||
this.unsubs.length = 0;
|
this.unsubs.length = 0;
|
||||||
|
if (activeEngine === this) activeEngine = null;
|
||||||
void this.ctx.close().catch(() => undefined);
|
void this.ctx.close().catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -290,104 +417,148 @@ export class TechnoEngine {
|
|||||||
this.nextBeat = Math.ceil(firstFuture / 4) * 4;
|
this.nextBeat = Math.ceil(firstFuture / 4) * 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effective gain for a voice right now: level, mute and arc folded together.
|
||||||
|
* Shared with the tuning desk via mix.ts so the number on screen is the number
|
||||||
|
* the scheduler uses.
|
||||||
|
*/
|
||||||
|
private gainOf(voice: VoiceName): number {
|
||||||
|
return voiceGain(this.mixState, voice, this.scales[voice]);
|
||||||
|
}
|
||||||
|
|
||||||
private scheduleBeat(beatIndex: number, timeMs: number): void {
|
private scheduleBeat(beatIndex: number, timeMs: number): void {
|
||||||
const sixteenths = subdivisions(this.grid, beatIndex, 4);
|
const sixteenths = subdivisions(this.grid, beatIndex, 4);
|
||||||
|
const mix = this.mixState;
|
||||||
|
|
||||||
this.kick(timeMs / 1000);
|
// Silent voices are skipped, not scheduled quietly: an exponential ramp to
|
||||||
|
// zero throws, and at 9 PM the arc mutes the bass outright — no reason to
|
||||||
|
// build and tear down eight nodes a second for something nobody can hear.
|
||||||
|
const kickGain = this.gainOf('kick');
|
||||||
|
if (kickGain > SILENT) this.kick(timeMs / 1000, kickGain);
|
||||||
|
|
||||||
|
const hatGain = this.gainOf('hat');
|
||||||
const offbeat = sixteenths[2]; // the 8th between beats
|
const offbeat = sixteenths[2]; // the 8th between beats
|
||||||
if (offbeat !== undefined) this.hat(offbeat / 1000);
|
if (hatGain > SILENT && offbeat !== undefined) this.hat(offbeat / 1000, hatGain);
|
||||||
|
|
||||||
for (let s = 0; s < sixteenths.length; s++) {
|
const bassGain = this.gainOf('bass');
|
||||||
const at = sixteenths[s];
|
if (bassGain > SILENT) {
|
||||||
const semi = BASS_PATTERN[patternStep(beatIndex, s, BASS_STEPS)];
|
for (let s = 0; s < sixteenths.length; s++) {
|
||||||
if (at === undefined || semi === undefined || semi === null) continue;
|
const at = sixteenths[s];
|
||||||
this.bass(at / 1000, BASS_ROOT_HZ * Math.pow(2, semi / 12));
|
const semi = BASS_PATTERN[patternStep(beatIndex, s, BASS_STEPS)];
|
||||||
|
if (at === undefined || semi === undefined || semi === null) continue;
|
||||||
|
this.bass(at / 1000, mix.bass.rootHz * Math.pow(2, semi / 12), bassGain);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDownbeat(beatIndex) && barOf(beatIndex) % PAD_BARS === 0) this.pad(timeMs / 1000);
|
const padGain = this.gainOf('pad');
|
||||||
|
const padBars = this.padBars();
|
||||||
|
if (padGain > SILENT && isDownbeat(beatIndex) && barOf(beatIndex) % padBars === 0) {
|
||||||
|
this.pad(timeMs / 1000, padGain, padBars);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pad's cycle length doubles as its retrigger interval, so it must be a whole bar count. */
|
||||||
|
private padBars(): number {
|
||||||
|
return Math.max(1, Math.round(this.mixState.pad.bars));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- voices ---------------------------------------------------------------
|
// --- voices ---------------------------------------------------------------
|
||||||
|
|
||||||
private kick(at: number): void {
|
private kick(at: number, peak: number): void {
|
||||||
|
const k = this.mixState.kick;
|
||||||
const osc = this.ctx.createOscillator();
|
const osc = this.ctx.createOscillator();
|
||||||
const gain = this.ctx.createGain();
|
const gain = this.ctx.createGain();
|
||||||
osc.type = 'sine';
|
osc.type = 'sine';
|
||||||
|
|
||||||
// The pitch drop is the beater; the tail is the cone. This is the one voice
|
// The pitch drop is the beater; the tail is the cone. This is the one voice
|
||||||
// that has to survive a 250Hz lowpass, so it gets a long body.
|
// that has to survive a 250Hz lowpass, so it gets a long body.
|
||||||
osc.frequency.setValueAtTime(150, at);
|
osc.frequency.setValueAtTime(k.startHz, at);
|
||||||
osc.frequency.exponentialRampToValueAtTime(50, at + 0.06);
|
osc.frequency.exponentialRampToValueAtTime(k.endHz, at + k.dropS);
|
||||||
|
|
||||||
|
// A decay tuned shorter than the attack would schedule the ramps out of
|
||||||
|
// order; the desk allows that combination, WebAudio does not.
|
||||||
|
const decayS = Math.max(k.attackS + 0.005, k.decayS);
|
||||||
gain.gain.setValueAtTime(SILENT, at);
|
gain.gain.setValueAtTime(SILENT, at);
|
||||||
gain.gain.exponentialRampToValueAtTime(KICK_PEAK, at + 0.008);
|
gain.gain.exponentialRampToValueAtTime(peak, at + k.attackS);
|
||||||
gain.gain.exponentialRampToValueAtTime(SILENT, at + 0.34);
|
gain.gain.exponentialRampToValueAtTime(SILENT, at + decayS);
|
||||||
|
|
||||||
osc.connect(gain).connect(this.musicBus);
|
osc.connect(gain).connect(this.musicBus);
|
||||||
osc.start(at);
|
osc.start(at);
|
||||||
osc.stop(at + 0.36);
|
osc.stop(at + decayS + 0.02);
|
||||||
this.hold(osc, [gain]);
|
this.hold(osc, [gain]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private hat(at: number): void {
|
private hat(at: number, peak: number): void {
|
||||||
|
const h = this.mixState.hat;
|
||||||
const src = this.ctx.createBufferSource();
|
const src = this.ctx.createBufferSource();
|
||||||
src.buffer = this.noiseBuffer;
|
src.buffer = this.noiseBuffer;
|
||||||
// Cheap variety: start the read head somewhere different each hit so the
|
|
||||||
// one shared buffer doesn't ring identically 400 times a minute.
|
|
||||||
const offset = (at * 7.3) % (this.noiseBuffer.duration - 0.1);
|
|
||||||
|
|
||||||
const hp = this.ctx.createBiquadFilter();
|
const hp = this.ctx.createBiquadFilter();
|
||||||
hp.type = 'highpass';
|
hp.type = 'highpass';
|
||||||
hp.frequency.value = 7000;
|
hp.frequency.value = h.hpHz;
|
||||||
|
|
||||||
const gain = this.ctx.createGain();
|
const gain = this.ctx.createGain();
|
||||||
gain.gain.setValueAtTime(HAT_PEAK, at);
|
gain.gain.setValueAtTime(peak, at);
|
||||||
gain.gain.exponentialRampToValueAtTime(SILENT, at + 0.04);
|
gain.gain.exponentialRampToValueAtTime(SILENT, at + h.decayS);
|
||||||
|
|
||||||
src.connect(hp).connect(gain).connect(this.musicBus);
|
src.connect(hp).connect(gain).connect(this.musicBus);
|
||||||
src.start(at, offset, 0.06);
|
// Read far enough to cover the envelope; a longer decay must not be cut off
|
||||||
|
// by the buffer running out under it.
|
||||||
|
const readS = Math.min(Math.max(0.06, h.decayS + 0.02), this.noiseBuffer.duration);
|
||||||
|
// Cheap variety: start the read head somewhere different each hit so the one
|
||||||
|
// shared buffer doesn't ring identically 400 times a minute. The span is the
|
||||||
|
// buffer minus the read length, so offset + readS lands inside the buffer for
|
||||||
|
// any tuning of hat.decayS; a read as long as the buffer leaves no span.
|
||||||
|
const span = this.noiseBuffer.duration - readS;
|
||||||
|
const offset = span > 0 ? (at * 7.3) % span : 0;
|
||||||
|
src.start(at, offset, readS);
|
||||||
this.hold(src, [hp, gain]);
|
this.hold(src, [hp, gain]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private bass(at: number, hz: number): void {
|
private bass(at: number, hz: number, peak: number): void {
|
||||||
|
const b = this.mixState.bass;
|
||||||
const lp = this.ctx.createBiquadFilter();
|
const lp = this.ctx.createBiquadFilter();
|
||||||
lp.type = 'lowpass';
|
lp.type = 'lowpass';
|
||||||
lp.Q.value = 8;
|
lp.Q.value = b.q;
|
||||||
|
|
||||||
|
const decayS = Math.max(BASS_SWEEP_S + 0.02, b.decayS);
|
||||||
// Per-note filter sweep — the "wow" that stops 16ths sounding like a fax.
|
// Per-note filter sweep — the "wow" that stops 16ths sounding like a fax.
|
||||||
lp.frequency.setValueAtTime(180, at);
|
lp.frequency.setValueAtTime(b.filterLoHz, at);
|
||||||
lp.frequency.exponentialRampToValueAtTime(900, at + 0.03);
|
lp.frequency.exponentialRampToValueAtTime(b.filterHiHz, at + BASS_SWEEP_S);
|
||||||
lp.frequency.exponentialRampToValueAtTime(200, at + 0.11);
|
lp.frequency.exponentialRampToValueAtTime(b.filterLoHz * BASS_TAIL_RATIO, at + decayS - 0.01);
|
||||||
|
|
||||||
const gain = this.ctx.createGain();
|
const gain = this.ctx.createGain();
|
||||||
gain.gain.setValueAtTime(SILENT, at);
|
gain.gain.setValueAtTime(SILENT, at);
|
||||||
gain.gain.exponentialRampToValueAtTime(BASS_PEAK, at + 0.006);
|
gain.gain.exponentialRampToValueAtTime(peak, at + 0.006);
|
||||||
gain.gain.exponentialRampToValueAtTime(SILENT, at + 0.12);
|
gain.gain.exponentialRampToValueAtTime(SILENT, at + decayS);
|
||||||
|
|
||||||
lp.connect(gain).connect(this.musicBus);
|
lp.connect(gain).connect(this.musicBus);
|
||||||
|
|
||||||
for (const detune of [-8, 8]) {
|
const spread = [-b.detuneCents, b.detuneCents];
|
||||||
|
spread.forEach((detune, i) => {
|
||||||
const osc = this.ctx.createOscillator();
|
const osc = this.ctx.createOscillator();
|
||||||
osc.type = 'sawtooth';
|
osc.type = 'sawtooth';
|
||||||
osc.frequency.value = hz;
|
osc.frequency.value = hz;
|
||||||
osc.detune.value = detune;
|
osc.detune.value = detune;
|
||||||
osc.connect(lp);
|
osc.connect(lp);
|
||||||
osc.start(at);
|
osc.start(at);
|
||||||
osc.stop(at + 0.13);
|
osc.stop(at + decayS + 0.01);
|
||||||
this.hold(osc, detune > 0 ? [lp, gain] : []);
|
// Hang the shared chain off the LAST saw by index, not by sign: at zero
|
||||||
}
|
// detune both are 0 and a sign test would strand lp/gain on the bus.
|
||||||
|
this.hold(osc, i === spread.length - 1 ? [lp, gain] : []);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private pad(at: number): void {
|
private pad(at: number, peak: number, bars: number): void {
|
||||||
const cycle = (PAD_BARS * 4 * beatIntervalMs(this.bpm)) / 1000;
|
const cycle = (bars * 4 * beatIntervalMs(this.bpm)) / 1000;
|
||||||
|
|
||||||
const lp = this.ctx.createBiquadFilter();
|
const lp = this.ctx.createBiquadFilter();
|
||||||
lp.type = 'lowpass';
|
lp.type = 'lowpass';
|
||||||
lp.frequency.value = 600;
|
lp.frequency.value = this.mixState.pad.lpHz;
|
||||||
|
|
||||||
const gain = this.ctx.createGain();
|
const gain = this.ctx.createGain();
|
||||||
gain.gain.setValueAtTime(0, at);
|
gain.gain.setValueAtTime(0, at);
|
||||||
gain.gain.linearRampToValueAtTime(PAD_PEAK, at + cycle * 0.4);
|
gain.gain.linearRampToValueAtTime(peak, at + cycle * PAD_RISE);
|
||||||
gain.gain.linearRampToValueAtTime(0, at + cycle);
|
gain.gain.linearRampToValueAtTime(0, at + cycle);
|
||||||
|
|
||||||
lp.connect(gain).connect(this.musicBus);
|
lp.connect(gain).connect(this.musicBus);
|
||||||
@ -406,6 +577,44 @@ export class TechnoEngine {
|
|||||||
|
|
||||||
// --- plumbing -------------------------------------------------------------
|
// --- plumbing -------------------------------------------------------------
|
||||||
|
|
||||||
|
private qFor(location: AudioLocation): number {
|
||||||
|
return location === 'floor' ? this.mixState.floorQ : this.mixState.doorQ;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a dotted mix path to a get/set/clamp triple, or null if it is not a knob. */
|
||||||
|
private lens(path: string): MixLens | null {
|
||||||
|
const m = this.mixState;
|
||||||
|
|
||||||
|
if (path === 'master' || path === 'doorQ' || path === 'floorQ') {
|
||||||
|
const range = RANGES[path];
|
||||||
|
if (!range) return null;
|
||||||
|
const key = path;
|
||||||
|
return { read: () => m[key], write: (v) => { m[key] = v; }, range };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dot = path.indexOf('.');
|
||||||
|
if (dot <= 0) return null;
|
||||||
|
const head = path.slice(0, dot);
|
||||||
|
const tail = path.slice(dot + 1);
|
||||||
|
|
||||||
|
if (head === 'levels') {
|
||||||
|
// MIX_RANGES keys the shared bound as 'level' (singular) — one range for
|
||||||
|
// all four faders — while the path is per-voice.
|
||||||
|
if (!(VOICE_NAMES as readonly string[]).includes(tail)) return null;
|
||||||
|
const v = tail as VoiceName;
|
||||||
|
return { read: () => m.levels[v], write: (x) => { m.levels[v] = x; }, range: MIX_RANGES.level };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(GROUP_NAMES as readonly string[]).includes(head)) return null;
|
||||||
|
// The four param groups have no index signature by design — the widening is
|
||||||
|
// confined here, behind the hasOwn check below.
|
||||||
|
const group = m[head as VoiceName] as unknown as Record<string, number>;
|
||||||
|
if (!Object.hasOwn(group, tail)) return null;
|
||||||
|
const range = RANGES[path];
|
||||||
|
if (!range) return null;
|
||||||
|
return { read: () => group[tail] ?? Number.NaN, write: (x) => { group[tail] = x; }, range };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One second of deterministic white noise, built once and re-read per hat.
|
* One second of deterministic white noise, built once and re-read per hat.
|
||||||
* Allocating a buffer per hit would mean ~8 buffers a second for six hours.
|
* Allocating a buffer per hit would mean ~8 buffers a second for six hours.
|
||||||
|
|||||||
98
src/audio/arc.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { VOICE_NAMES, type VoiceName } from './mix';
|
||||||
|
|
||||||
|
// The night's shape, as gain scalars per voice.
|
||||||
|
//
|
||||||
|
// A club does not play the same track at 9 PM and at 1 AM, and a room that
|
||||||
|
// sounds identical for six hours is the main reason a long shift reads as flat.
|
||||||
|
// So the mix opens thin — kick and a bit of hat, the sound of a room hoping
|
||||||
|
// people turn up — the bassline arrives once there is a crowd to carry it, the
|
||||||
|
// pads land at peak, and Last Drinks strips it back so the lights coming up
|
||||||
|
// feel earned rather than abrupt.
|
||||||
|
//
|
||||||
|
// Pure keyframe interpolation: no audio types here, so it is testable in node
|
||||||
|
// and reusable by the Phase-4 DJ role, which drives this same surface by hand.
|
||||||
|
|
||||||
|
/** clockMin is minutes since 21:00; the night ends at 360 (3 AM). */
|
||||||
|
export const NIGHT_END_MIN = 360;
|
||||||
|
|
||||||
|
export type ArcScales = Record<VoiceName, number>;
|
||||||
|
|
||||||
|
export interface ArcKeyframe {
|
||||||
|
/** Minutes since 21:00. */
|
||||||
|
at: number;
|
||||||
|
label: string;
|
||||||
|
scales: ArcScales;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyframes, ascending by `at`. Deliberately sparse — the interpolation between
|
||||||
|
* them is what makes the build feel gradual rather than stepped.
|
||||||
|
*/
|
||||||
|
export const ARC: readonly ArcKeyframe[] = [
|
||||||
|
// 9 PM. Doors open to an empty room. Kick and a whisper of hat.
|
||||||
|
{ at: 0, label: 'EMPTY', scales: { kick: 0.8, hat: 0.4, bass: 0, pad: 0 } },
|
||||||
|
// 10 PM. First real queue. The bassline turns up with them.
|
||||||
|
{ at: 60, label: 'FILLING', scales: { kick: 0.95, hat: 0.75, bass: 0.5, pad: 0.15 } },
|
||||||
|
// 11 PM. Room has weight.
|
||||||
|
{ at: 120, label: 'BUILDING', scales: { kick: 1, hat: 0.9, bass: 0.85, pad: 0.5 } },
|
||||||
|
// Midnight through 2 AM. Everything, all of it.
|
||||||
|
{ at: 180, label: 'PEAK', scales: { kick: 1, hat: 1, bass: 1, pad: 1 } },
|
||||||
|
{ at: 300, label: 'PEAK', scales: { kick: 1, hat: 1, bass: 1, pad: 1 } },
|
||||||
|
// 2:30. Last drinks. Pull the low end first — that is what empties a floor.
|
||||||
|
{ at: 330, label: 'LAST DRINKS', scales: { kick: 0.9, hat: 0.7, bass: 0.55, pad: 0.85 } },
|
||||||
|
// 3 AM. Pads over a dying kick while the lights come up.
|
||||||
|
{ at: NIGHT_END_MIN, label: 'LIGHTS UP', scales: { kick: 0.45, hat: 0.2, bass: 0.12, pad: 0.6 } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voice scalars at a given clock minute. Clamps outside the night rather than
|
||||||
|
* extrapolating — a paused or overrunning clock should hold the edge, not
|
||||||
|
* wander into negative gain.
|
||||||
|
*/
|
||||||
|
export function arcAt(clockMin: number): ArcScales {
|
||||||
|
const t = Number.isFinite(clockMin) ? clockMin : 0;
|
||||||
|
|
||||||
|
const first = ARC[0];
|
||||||
|
const last = ARC[ARC.length - 1];
|
||||||
|
/* istanbul ignore next -- ARC is a non-empty literal */
|
||||||
|
if (!first || !last) return { kick: 1, hat: 1, bass: 1, pad: 1 };
|
||||||
|
|
||||||
|
if (t <= first.at) return { ...first.scales };
|
||||||
|
if (t >= last.at) return { ...last.scales };
|
||||||
|
|
||||||
|
for (let i = 1; i < ARC.length; i++) {
|
||||||
|
const prev = ARC[i - 1];
|
||||||
|
const next = ARC[i];
|
||||||
|
if (!prev || !next || t > next.at) continue;
|
||||||
|
const span = next.at - prev.at;
|
||||||
|
const k = span <= 0 ? 1 : (t - prev.at) / span;
|
||||||
|
const out = {} as ArcScales;
|
||||||
|
for (const v of VOICE_NAMES) out[v] = lerp(prev.scales[v], next.scales[v], k);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* istanbul ignore next -- the loop always returns for t inside the range */
|
||||||
|
return { ...last.scales };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nearest keyframe label at or before this minute — for HUD readouts. */
|
||||||
|
export function arcLabel(clockMin: number): string {
|
||||||
|
const t = Number.isFinite(clockMin) ? clockMin : 0;
|
||||||
|
let label = ARC[0]?.label ?? '';
|
||||||
|
for (const k of ARC) {
|
||||||
|
if (t >= k.at) label = k.label;
|
||||||
|
}
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One number for "how hard is the room going", 0..1 — the mean of the voice
|
||||||
|
* scalars. Handy for anything that wants to follow the night's energy without
|
||||||
|
* caring which voice is doing it (lighting, crowd density, the DJ role's meter).
|
||||||
|
*/
|
||||||
|
export function arcIntensity(clockMin: number): number {
|
||||||
|
const s = arcAt(clockMin);
|
||||||
|
return VOICE_NAMES.reduce((sum, v) => sum + s[v], 0) / VOICE_NAMES.length;
|
||||||
|
}
|
||||||
159
src/audio/mix.ts
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
// The track's tunable surface, lifted out of TechnoEngine so it can be changed
|
||||||
|
// while the music is playing.
|
||||||
|
//
|
||||||
|
// Three things need this and none of them can use a `const` in a module body:
|
||||||
|
// the ear pass (a human turns a knob and hears it on the next beat), the night
|
||||||
|
// arc (arc.ts scales voice levels as the night builds), and the Phase-4 DJ role,
|
||||||
|
// whose control panel is specified as this exact surface (BUILD_PLAN §4).
|
||||||
|
//
|
||||||
|
// Defaults reproduce the Phase-1 engine's hardcoded values exactly, so lifting
|
||||||
|
// them changed no sound. Anything John retunes in the ear pass gets pasted back
|
||||||
|
// over DEFAULT_MIX via `formatMix()`.
|
||||||
|
|
||||||
|
export type VoiceName = 'kick' | 'hat' | 'bass' | 'pad';
|
||||||
|
|
||||||
|
export const VOICE_NAMES: readonly VoiceName[] = ['kick', 'hat', 'bass', 'pad'];
|
||||||
|
|
||||||
|
export interface KickParams {
|
||||||
|
/** Beater pitch at the transient. */
|
||||||
|
startHz: number;
|
||||||
|
/** Where the drop lands — the body of the cone. */
|
||||||
|
endHz: number;
|
||||||
|
/** Seconds for the pitch drop. Longer reads as a bigger speaker. */
|
||||||
|
dropS: number;
|
||||||
|
attackS: number;
|
||||||
|
decayS: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HatParams {
|
||||||
|
/** Highpass corner. Lower lets more of the noise body through. */
|
||||||
|
hpHz: number;
|
||||||
|
decayS: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BassParams {
|
||||||
|
rootHz: number;
|
||||||
|
/** Per-note filter sweep floor and peak — the "wow" on each 16th. */
|
||||||
|
filterLoHz: number;
|
||||||
|
filterHiHz: number;
|
||||||
|
q: number;
|
||||||
|
decayS: number;
|
||||||
|
/** Cents of detune between the two saws. The whole character lives here. */
|
||||||
|
detuneCents: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PadParams {
|
||||||
|
lpHz: number;
|
||||||
|
/** Length of the fade-in/out cycle, in bars. */
|
||||||
|
bars: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackMix {
|
||||||
|
/** Post-everything output level. */
|
||||||
|
master: number;
|
||||||
|
/** Per-voice peak gains, pre-filter. */
|
||||||
|
levels: Record<VoiceName, number>;
|
||||||
|
/** Ear-pass solo/mute. A muted voice is still scheduled, just silent. */
|
||||||
|
enabled: Record<VoiceName, boolean>;
|
||||||
|
kick: KickParams;
|
||||||
|
hat: HatParams;
|
||||||
|
bass: BassParams;
|
||||||
|
pad: PadParams;
|
||||||
|
/** Filter resonance at each location — the door's peak is the "through a wall" cue. */
|
||||||
|
doorQ: number;
|
||||||
|
floorQ: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_MIX: TrackMix = {
|
||||||
|
master: 0.8,
|
||||||
|
levels: { kick: 0.9, hat: 0.25, bass: 0.11, pad: 0.05 },
|
||||||
|
enabled: { kick: true, hat: true, bass: true, pad: true },
|
||||||
|
kick: { startHz: 150, endHz: 50, dropS: 0.06, attackS: 0.008, decayS: 0.34 },
|
||||||
|
hat: { hpHz: 7000, decayS: 0.04 },
|
||||||
|
bass: { rootHz: 55, filterLoHz: 180, filterHiHz: 900, q: 8, decayS: 0.12, detuneCents: 8 },
|
||||||
|
pad: { lpHz: 600, bars: 8 },
|
||||||
|
doorQ: 6,
|
||||||
|
floorQ: 0.7,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Bounds every knob, so a slider (or a typo in the console) cannot blow an ear. */
|
||||||
|
export const MIX_RANGES = {
|
||||||
|
master: [0, 1.2],
|
||||||
|
level: [0, 1.5],
|
||||||
|
'kick.startHz': [60, 400],
|
||||||
|
'kick.endHz': [25, 120],
|
||||||
|
'kick.dropS': [0.01, 0.3],
|
||||||
|
'kick.attackS': [0.001, 0.05],
|
||||||
|
'kick.decayS': [0.05, 1],
|
||||||
|
'hat.hpHz': [2000, 14000],
|
||||||
|
'hat.decayS': [0.005, 0.2],
|
||||||
|
'bass.rootHz': [30, 110],
|
||||||
|
'bass.filterLoHz': [60, 600],
|
||||||
|
'bass.filterHiHz': [300, 4000],
|
||||||
|
'bass.q': [0.5, 20],
|
||||||
|
'bass.decayS': [0.02, 0.5],
|
||||||
|
'bass.detuneCents': [0, 40],
|
||||||
|
'pad.lpHz': [200, 4000],
|
||||||
|
'pad.bars': [1, 16],
|
||||||
|
doorQ: [0.5, 20],
|
||||||
|
floorQ: [0.1, 4],
|
||||||
|
} as const satisfies Record<string, readonly [number, number]>;
|
||||||
|
|
||||||
|
export const clampTo = (v: number, range: readonly [number, number]): number =>
|
||||||
|
Number.isFinite(v) ? Math.max(range[0], Math.min(range[1], v)) : range[0];
|
||||||
|
|
||||||
|
/** Deep copy — callers mutate their own mix without touching DEFAULT_MIX. */
|
||||||
|
export function cloneMix(m: TrackMix): TrackMix {
|
||||||
|
return {
|
||||||
|
master: m.master,
|
||||||
|
levels: { ...m.levels },
|
||||||
|
enabled: { ...m.enabled },
|
||||||
|
kick: { ...m.kick },
|
||||||
|
hat: { ...m.hat },
|
||||||
|
bass: { ...m.bass },
|
||||||
|
pad: { ...m.pad },
|
||||||
|
doorQ: m.doorQ,
|
||||||
|
floorQ: m.floorQ,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effective gain for a voice: its level, muted by `enabled`, scaled by whatever
|
||||||
|
* the night arc says this voice should be doing right now.
|
||||||
|
*
|
||||||
|
* Kept here rather than in the engine so the ear-pass rig can show the same
|
||||||
|
* number the scheduler will actually use.
|
||||||
|
*/
|
||||||
|
export function voiceGain(mix: TrackMix, voice: VoiceName, arcScale = 1): number {
|
||||||
|
if (!mix.enabled[voice]) return 0;
|
||||||
|
return Math.max(0, mix.levels[voice] * arcScale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when exactly one voice is unmuted — the ear pass's soloed state. */
|
||||||
|
export const soloedVoice = (mix: TrackMix): VoiceName | null => {
|
||||||
|
const on = VOICE_NAMES.filter((v) => mix.enabled[v]);
|
||||||
|
return on.length === 1 ? (on[0] ?? null) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const n = (v: number): string => (Number.isInteger(v) ? String(v) : String(Number(v.toFixed(4))));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit a mix as a pasteable `TrackMix` literal.
|
||||||
|
*
|
||||||
|
* The ear pass is a human saying "more click on the kick" and me turning that
|
||||||
|
* into numbers; without a dump button those numbers die with the tab. This is
|
||||||
|
* how a tuning session becomes a diff.
|
||||||
|
*/
|
||||||
|
export function formatMix(m: TrackMix): string {
|
||||||
|
return `{
|
||||||
|
master: ${n(m.master)},
|
||||||
|
levels: { kick: ${n(m.levels.kick)}, hat: ${n(m.levels.hat)}, bass: ${n(m.levels.bass)}, pad: ${n(m.levels.pad)} },
|
||||||
|
enabled: { kick: true, hat: true, bass: true, pad: true },
|
||||||
|
kick: { startHz: ${n(m.kick.startHz)}, endHz: ${n(m.kick.endHz)}, dropS: ${n(m.kick.dropS)}, attackS: ${n(m.kick.attackS)}, decayS: ${n(m.kick.decayS)} },
|
||||||
|
hat: { hpHz: ${n(m.hat.hpHz)}, decayS: ${n(m.hat.decayS)} },
|
||||||
|
bass: { rootHz: ${n(m.bass.rootHz)}, filterLoHz: ${n(m.bass.filterLoHz)}, filterHiHz: ${n(m.bass.filterHiHz)}, q: ${n(m.bass.q)}, decayS: ${n(m.bass.decayS)}, detuneCents: ${n(m.bass.detuneCents)} },
|
||||||
|
pad: { lpHz: ${n(m.pad.lpHz)}, bars: ${n(m.pad.bars)} },
|
||||||
|
doorQ: ${n(m.doorQ)},
|
||||||
|
floorQ: ${n(m.floorQ)},
|
||||||
|
}`;
|
||||||
|
}
|
||||||
@ -18,6 +18,19 @@ export function freshNightState(venueId: string, nightIndex: number, licensed: n
|
|||||||
|
|
||||||
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
|
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vibe per clock-minute the room loses on its own (emit from the night's
|
||||||
|
* minute tick). Nobody stays impressed: above the baseline the room cools, so
|
||||||
|
* a lit room is a job you keep doing, not a ratchet you finish. Never positive
|
||||||
|
* — a dead floor does not heal itself (tuning pass 2026-07-19; before this,
|
||||||
|
* any net-positive night pinned vibe at 100 by 10 PM and the meter went dead).
|
||||||
|
*/
|
||||||
|
export function vibeCoolingPerMin(vibe: number): number {
|
||||||
|
if (vibe > 70) return -0.8;
|
||||||
|
if (vibe > 50) return -0.4;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Single writer for the night's numbers. Scenes emit meters:delta / heat:strike;
|
// Single writer for the night's numbers. Scenes emit meters:delta / heat:strike;
|
||||||
// nothing else may mutate NightState meters directly.
|
// nothing else may mutate NightState meters directly.
|
||||||
export class Meters {
|
export class Meters {
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export const CONTRABAND: readonly ContrabandDef[] = [
|
|||||||
{ id: 'someoneElsesId', name: "Someone else's ID", severity: 2, weight: 2, spriteHint: 'small card' },
|
{ id: 'someoneElsesId', name: "Someone else's ID", severity: 2, weight: 2, spriteHint: 'small card' },
|
||||||
{ id: 'budgie', name: 'A live budgie', severity: 1, weight: 1, spriteHint: 'green blob (flaps)' },
|
{ id: 'budgie', name: 'A live budgie', severity: 1, weight: 1, spriteHint: 'green blob (flaps)' },
|
||||||
{ id: 'vapes', name: 'Fourteen vapes', severity: 1, weight: 3, spriteHint: 'neon sticks' },
|
{ id: 'vapes', name: 'Fourteen vapes', severity: 1, weight: 3, spriteHint: 'neon sticks' },
|
||||||
|
{ id: 'joint', name: 'One (1) joint', severity: 2, weight: 2, spriteHint: 'small white cone' },
|
||||||
{ id: 'marker', name: 'Fat permanent marker', severity: 2, weight: 2, spriteHint: 'black stick' },
|
{ id: 'marker', name: 'Fat permanent marker', severity: 2, weight: 2, spriteHint: 'black stick' },
|
||||||
{ id: 'snags', name: 'Raw sausages (why)', severity: 1, weight: 1, spriteHint: 'pink links' },
|
{ id: 'snags', name: 'Raw sausages (why)', severity: 1, weight: 1, spriteHint: 'pink links' },
|
||||||
];
|
];
|
||||||
|
|||||||
@ -59,7 +59,7 @@ export const OUTFIT_VOCAB: Record<OutfitSlot, readonly OutfitTypeDef[]> = {
|
|||||||
],
|
],
|
||||||
outer: [
|
outer: [
|
||||||
{ type: 'none', weight: 6, colours: ['black'] },
|
{ type: 'none', weight: 6, colours: ['black'] },
|
||||||
{ type: 'blazer', weight: 2, colours: ['black', 'navy', 'grey'] },
|
{ type: 'blazer', weight: 1, colours: ['black', 'navy', 'grey'] },
|
||||||
{ type: 'bomber', weight: 2, colours: ['black', 'green', 'navy'], canLogo: true, canVintage: true },
|
{ type: 'bomber', weight: 2, colours: ['black', 'green', 'navy'], canLogo: true, canVintage: true },
|
||||||
{ type: 'hoodie', weight: 2, colours: ['black', 'grey', 'red'], canLogo: true },
|
{ type: 'hoodie', weight: 2, colours: ['black', 'grey', 'red'], canLogo: true },
|
||||||
{ type: 'leather', weight: 1, colours: ['black', 'brown'], canVintage: true },
|
{ type: 'leather', weight: 1, colours: ['black', 'brown'], canVintage: true },
|
||||||
@ -75,9 +75,9 @@ export const OUTFIT_VOCAB: Record<OutfitSlot, readonly OutfitTypeDef[]> = {
|
|||||||
],
|
],
|
||||||
accessory: [
|
accessory: [
|
||||||
{ type: 'none', weight: 6, colours: ['black'] },
|
{ type: 'none', weight: 6, colours: ['black'] },
|
||||||
{ type: 'bucketHat', weight: 2, colours: ['cream', 'black', 'green'], canLogo: true, canVintage: true },
|
{ type: 'bucketHat', weight: 1, colours: ['cream', 'black', 'green'], canLogo: true, canVintage: true },
|
||||||
{ type: 'cap', weight: 2, colours: ['black', 'red', 'navy'], canLogo: true },
|
{ type: 'cap', weight: 2, colours: ['black', 'red', 'navy'], canLogo: true },
|
||||||
{ type: 'sunnies', weight: 2, colours: ['black'] }, // sunglasses. at night. a tell in itself
|
{ type: 'sunnies', weight: 1, colours: ['black'] }, // sunglasses. at night. a tell in itself
|
||||||
{ type: 'bumbag', weight: 1, colours: ['black', 'purple', 'yellow'], canLogo: true, canVintage: true },
|
{ type: 'bumbag', weight: 1, colours: ['black', 'purple', 'yellow'], canLogo: true, canVintage: true },
|
||||||
{ type: 'chain', weight: 1, colours: ['yellow', 'grey'] },
|
{ type: 'chain', weight: 1, colours: ['yellow', 'grey'] },
|
||||||
],
|
],
|
||||||
|
|||||||
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')!;
|
||||||
|
}
|
||||||
@ -22,6 +22,10 @@ export const DAZZA_TEXTS: readonly DazzaLine[] = [
|
|||||||
{ id: 'rule-suits', text: 'too corporate in here tonight. no blazers til the suits ive already got leave', ruleId: 'noBlazers', fromMin: 200 },
|
{ id: 'rule-suits', text: 'too corporate in here tonight. no blazers til the suits ive already got leave', ruleId: 'noBlazers', fromMin: 200 },
|
||||||
{ id: 'rule-couples', text: 'no more couples holding hands in the queue its making the room feel married', ruleId: 'noHandHolders', fromMin: 135 },
|
{ id: 'rule-couples', text: 'no more couples holding hands in the queue its making the room feel married', ruleId: 'noHandHolders', fromMin: 135 },
|
||||||
|
|
||||||
|
// the 1:30 lockout (state law; rules/judge.ts LOCKOUT_MIN)
|
||||||
|
{ id: 'lockout-warn', text: 'lockout in 15. after 130 the law says this door is SHUT. the queue will not agree with the law', fromMin: 255 },
|
||||||
|
{ id: 'lockout-live', text: '130. lockout. nobody new gets in. i dont care who terry is. especially if its terry', fromMin: 270 },
|
||||||
|
|
||||||
// vibe commentary
|
// vibe commentary
|
||||||
{ id: 'vibe-mid', text: 'why is the floor MID rn. fix it', fromMin: 60 },
|
{ id: 'vibe-mid', text: 'why is the floor MID rn. fix it', fromMin: 60 },
|
||||||
{ id: 'vibe-good', text: 'room is going OFF right now whatever ur doing keep doing it', fromMin: 100 },
|
{ id: 'vibe-good', text: 'room is going OFF right now whatever ur doing keep doing it', fromMin: 100 },
|
||||||
|
|||||||
@ -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. */
|
||||||
@ -74,6 +84,20 @@ export const CLAIM_LINES: readonly string[] = [
|
|||||||
'i was here last weekend',
|
'i was here last weekend',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** A regular you have knocked back before, stepping up again. He kept count. */
|
||||||
|
export const REGULAR_RETURN_LINES: readonly string[] = [
|
||||||
|
'back again. we good? we\'re good',
|
||||||
|
'different jacket tonight. thought about what u said',
|
||||||
|
'no hard feelings about last time. some feelings. no hard ones',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The disguise era (two knockbacks): same face, same card, new "man". */
|
||||||
|
export const REGULAR_DISGUISED_LINES: readonly string[] = [
|
||||||
|
'first time at this establishment. love what youve done',
|
||||||
|
'never been here before in my life. u seem nice though',
|
||||||
|
'my brother speaks very highly of the door staff here. very highly',
|
||||||
|
];
|
||||||
|
|
||||||
/** What the room mutters when you send someone away. Never cruel about the patron. */
|
/** What the room mutters when you send someone away. Never cruel about the patron. */
|
||||||
export const DENY_REACTIONS: readonly string[] = [
|
export const DENY_REACTIONS: readonly string[] = [
|
||||||
'the queue goes quiet',
|
'the queue goes quiet',
|
||||||
@ -140,6 +164,61 @@ export const ripenLine = (stage: string, roll: number): string => {
|
|||||||
return pool[i] ?? pool[0]!;
|
return pool[i] ?? pool[0]!;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dazza rescinding a rule when a newer one pushes it off the card
|
||||||
|
* (rules cap at ACTIVE_RULE_CAP — his attention span, not a settings screen).
|
||||||
|
*/
|
||||||
|
/** ROOM (design §6): the board rewrote itself again. Indexed by window. */
|
||||||
|
export const BOARD_SHUFFLE_TOASTS: readonly string[] = [
|
||||||
|
'the board flickers. new code. no explanation.',
|
||||||
|
'the board changed while you were reading it.',
|
||||||
|
'new code on the board. the board does not negotiate.',
|
||||||
|
'the board again. someone back there is just typing.',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const RULE_RETRACTIONS: readonly string[] = [
|
||||||
|
'dazza: forget the "{rule}" thing. new priorities',
|
||||||
|
'dazza: "{rule}" is over. keep up',
|
||||||
|
'dazza: im rescinding "{rule}". it wasnt working. dont ask',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ex-doorman's free advice, shouted from the planter after his own verdict.
|
||||||
|
* Right about 80% of the time — the caller picks the category from the REAL
|
||||||
|
* inspection surfaces and rolls the lie chance; these are only the voice.
|
||||||
|
*/
|
||||||
|
export const FRANKO_TIPS: Record<'fake' | 'underage' | 'drunk' | 'dress' | 'clean', readonly string[]> = {
|
||||||
|
fake: [
|
||||||
|
'FRANKO (from the planter): "tilt that licence mate. tilt it."',
|
||||||
|
'FRANKO: "the laminate on that one. im just saying."',
|
||||||
|
],
|
||||||
|
underage: [
|
||||||
|
'FRANKO: "do the maths on that birthday. properly."',
|
||||||
|
'FRANKO: "that dob is doing a lot of work."',
|
||||||
|
],
|
||||||
|
drunk: [
|
||||||
|
'FRANKO: "watch the feet on this one. the feet dont lie."',
|
||||||
|
'FRANKO: "count the blinks. too slow. u know what that means."',
|
||||||
|
],
|
||||||
|
dress: [
|
||||||
|
'FRANKO: "check the board, then check the outfit. slowly."',
|
||||||
|
'FRANKO: "dazza wouldve had opinions about that outfit in my day."',
|
||||||
|
],
|
||||||
|
clean: [
|
||||||
|
'FRANKO: "this ones fine. boring, but fine."',
|
||||||
|
'FRANKO: "no notes. dont overthink it."',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The kebab run (docs/SCENARIOS.md): the whole game in one errand. */
|
||||||
|
export const KEBAB = {
|
||||||
|
ask: 'mate. kebab. garlic sauce, small chilli. the shop is RIGHT THERE. the door can hold itself for 2 mins',
|
||||||
|
glowPrompt: 'the kebab shop calls',
|
||||||
|
running: 'IN THE SHOP\n\nthe garlic sauce question takes longer than it should.\nthrough the window you can see the rope.\nkayden has found the clipboard.',
|
||||||
|
thanks: 'u beautiful thing. exact right amount of sauce. tell no one i said this: ur the best door ive had',
|
||||||
|
sulk: 'forget the kebab. ive eaten a muesli bar from the till. it was from 2019. this is what management tastes like',
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const SOBRIETY_UI = {
|
export const SOBRIETY_UI = {
|
||||||
title: 'SOBRIETY TEST',
|
title: 'SOBRIETY TEST',
|
||||||
pickPrompt: 'pick your test',
|
pickPrompt: 'pick your test',
|
||||||
|
|||||||
@ -21,6 +21,9 @@ export interface EncounterScript {
|
|||||||
admitDazza?: string;
|
admitDazza?: string;
|
||||||
denyNote?: string;
|
denyNote?: string;
|
||||||
denyDazza?: string;
|
denyDazza?: string;
|
||||||
|
/** Coin-flip encounters (real/fake cop, rocket/paps): notes per variant key.
|
||||||
|
* The variant is decided at dress() time and carried on patron.flags. */
|
||||||
|
variantNotes?: Record<string, { admitNote?: string; admitDazza?: string; denyNote?: string; denyDazza?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ENCOUNTER_SCRIPTS: Record<string, EncounterScript> = {
|
export const ENCOUNTER_SCRIPTS: Record<string, EncounterScript> = {
|
||||||
@ -80,4 +83,194 @@ export const ENCOUNTER_SCRIPTS: Record<string, EncounterScript> = {
|
|||||||
admitNote: 'In he goes, logo and all, with the sign already up.',
|
admitNote: 'In he goes, logo and all, with the sign already up.',
|
||||||
denyNote: 'He nods, says fair enough, and walks back toward the servo.',
|
denyNote: 'He nods, says fair enough, and walks back toward the servo.',
|
||||||
},
|
},
|
||||||
|
// Eighteen at midnight, at the rope by quarter past. Real card, real birthday,
|
||||||
|
// and a plan he has clearly rehearsed on the walk over.
|
||||||
|
eighteenToday: {
|
||||||
|
names: ['Jai Petersen', 'Cooper Nguyen', 'Lachy Marsh'],
|
||||||
|
beats: [
|
||||||
|
{ line: 'evening. so. it went midnight about forty minutes ago.', afterMs: 0 },
|
||||||
|
{ line: 'which makes this my first legal one. checked the law twice.', afterMs: 2400 },
|
||||||
|
{ line: 'my mates did the countdown at the kebab shop. they went home. im here.', afterMs: 5000 },
|
||||||
|
],
|
||||||
|
admitNote: 'First stamp on a first night. He shows the bar staff his licence unprompted.',
|
||||||
|
denyNote: 'Legal for forty minutes and headed home. The story will improve with age.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Kayden has been making promises. Kayden does not have list privileges.
|
||||||
|
// Kayden does not, strictly speaking, have a last name anyone can recall.
|
||||||
|
kaydensMate: {
|
||||||
|
names: ['Reece Kowalski', 'Jordan Vella', 'Tyson Doyle'],
|
||||||
|
beats: [
|
||||||
|
{ line: 'should be right, kayden said hed leave my name at the door.', afterMs: 0 },
|
||||||
|
{ line: 'kayden. tall bloke. trainee vest. very confident handshake.', afterMs: 2300 },
|
||||||
|
{ line: 'he said quote unquote "sorted mate". thats basically a guest list.', afterMs: 4800 },
|
||||||
|
],
|
||||||
|
admitNote: 'In on the authority of a man whose supervisor calls him "the situation".',
|
||||||
|
admitDazza: 'a bloke just told the bar KAYDEN comped him. kayden cant comp a glass of water',
|
||||||
|
denyNote: 'He takes it well. Says he will "raise it with kayden". Nobody will.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// The buck's party straggler. Missed the church bit, missed dinner, caught the
|
||||||
|
// food poisoning. The buck is inside; the night is nearly over; here he is.
|
||||||
|
bigNightOut: {
|
||||||
|
names: ['Macca OBrien', 'Davo Romano', 'Nick Taufa'],
|
||||||
|
beats: [
|
||||||
|
{ line: 'the bucks is in there. i can hear the chanting. thats my chanting.', afterMs: 0 },
|
||||||
|
{ line: 'i missed the church bit. food poisoning at the dinner. long story. bad oysters.', afterMs: 2500 },
|
||||||
|
{ line: 'ive had a big recovery and two lemonades and i am READY to be present.', afterMs: 5200 },
|
||||||
|
],
|
||||||
|
admitNote: 'The chanting gets louder, gains a name in it, then settles.',
|
||||||
|
denyNote: 'He listens to one more chant through the wall, films a voice memo of it, and goes.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Engaged tonight. Still holding hands. The card says what the card says.
|
||||||
|
twoOfThem: {
|
||||||
|
names: ['Bree Karim', 'Holly Petersen', 'Sana Singh'],
|
||||||
|
beats: [
|
||||||
|
{ line: 'before you say anything about the hand thing. we got engaged tonight.', afterMs: 0 },
|
||||||
|
{ line: 'TONIGHT tonight. at the lookout. theres a photo of a bird ruining it.', afterMs: 2400 },
|
||||||
|
{ line: 'we are aware of the rule. we have discussed it. the answer is no.', afterMs: 5000 },
|
||||||
|
],
|
||||||
|
admitNote: 'They cross the floor like a three-legged race nobody can catch.',
|
||||||
|
denyNote: 'They leave still attached. The rule holds. The rule is the only thing that does.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// The badge is real or it cost $30, and the licence is the only thing at the
|
||||||
|
// rope that knows which. He is polite either way. That is the problem.
|
||||||
|
offDutyCop: {
|
||||||
|
names: ['Marcus Bell', 'Marc Bell', 'M. Bell'],
|
||||||
|
beats: [
|
||||||
|
{ line: "evening. job's done for the night. just after a quiet one.", afterMs: 0 },
|
||||||
|
{ line: 'you can look at the badge if you want. most dont.', afterMs: 2400 },
|
||||||
|
{ line: 'take your time with the licence. i respect the process.', afterMs: 5000 },
|
||||||
|
],
|
||||||
|
variantNotes: {
|
||||||
|
real: {
|
||||||
|
admitNote: 'He orders soda water and stands where he can see both exits.',
|
||||||
|
denyNote: 'He nods once. "Good call. Always." He means it.',
|
||||||
|
},
|
||||||
|
fake: {
|
||||||
|
admitNote: 'The badge says JUNIOR DETECTIVE on the back. Nobody looks at the back.',
|
||||||
|
denyNote: 'He argues, then folds. The badge goes back in the bumbag.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cap low, sunnies on, at night. Two live rule breaches and a face you
|
||||||
|
// almost place. Denying is free forever. Admitting is a coin you cannot see.
|
||||||
|
celebrity: {
|
||||||
|
names: ['Ky Trainor', 'Kai Trainor', 'K. Trainor'],
|
||||||
|
beats: [
|
||||||
|
{ line: 'hey. so. yeah.', afterMs: 0 },
|
||||||
|
{ line: "it'd be really good if this could be quick.", afterMs: 2200 },
|
||||||
|
{ line: 'the sunnies stay on. sorry. they sort of have to.', afterMs: 4600 },
|
||||||
|
],
|
||||||
|
variantNotes: {
|
||||||
|
rocket: { admitNote: 'Halfway to the bar, someone does a double-take.' },
|
||||||
|
paps: {
|
||||||
|
admitNote: 'A ute pulls up across the road. Then another one.',
|
||||||
|
admitDazza: 'why are there seventeen blokes with cameras outside. WHY',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
denyNote: 'The cap goes lower and they walk. You will never know who that was.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// An aggro engine in a polo. His licence is a laminated business card and he
|
||||||
|
// is PROUD of it. Nothing about him is a question the desk can answer.
|
||||||
|
sovereign: {
|
||||||
|
names: ['Gregory-John: Harmon', 'G-J: Harmon', 'Gregory-John of Harmon'],
|
||||||
|
beats: [
|
||||||
|
{ line: 'before we begin: i do not consent to this dress code.', afterMs: 0 },
|
||||||
|
{ line: 'your rope is an admiralty flag. i am a living man, travelling.', afterMs: 2600 },
|
||||||
|
{ line: 'i will be entering under common law. watch.', afterMs: 5200 },
|
||||||
|
],
|
||||||
|
admitNote: 'He steps over the rope rather than through the gap. On principle.',
|
||||||
|
denyNote: 'He accepts no jurisdiction, but does, eventually, accept the footpath.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// The thermal bag says a name. The name is "DJ". The booth lamp flickers.
|
||||||
|
deliveryRider: {
|
||||||
|
names: ['Sanjay Pillai', 'San Pillai', 'S. Pillai'],
|
||||||
|
beats: [
|
||||||
|
{ line: 'delivery for "DJ". thats it. just says DJ.', afterMs: 0 },
|
||||||
|
{ line: 'its getting cold mate. the bag only does so much.', afterMs: 2400 },
|
||||||
|
{ line: 'ive got four more jobs on. yes or no.', afterMs: 4800 },
|
||||||
|
],
|
||||||
|
variantNotes: {
|
||||||
|
ordered: {
|
||||||
|
admitNote: 'The booth lamp stops flickering. So. He HAD ordered.',
|
||||||
|
denyNote: 'The rider shrugs and eats it on the kerb. It looked incredible.',
|
||||||
|
},
|
||||||
|
not: {
|
||||||
|
admitNote: 'He wanders the floor with the bag, calling a name nobody has.',
|
||||||
|
denyNote: 'He checks the address, swears with real feeling, and rides off.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Two clipboards enter. One leaves.
|
||||||
|
promoter: {
|
||||||
|
names: ['Tyson Vale', 'Ty Vale', 'T. Vale'],
|
||||||
|
beats: [
|
||||||
|
{ line: 'evening chief. tyson. i run thursdays at elevate.', afterMs: 0 },
|
||||||
|
{ line: 'youve got a list. ive got a list. mine is realer.', afterMs: 2600 },
|
||||||
|
{ line: 'roll of wristbands here says we can help each other.', afterMs: 5200 },
|
||||||
|
],
|
||||||
|
admitNote: 'He shakes your hand like something just went well for him.',
|
||||||
|
admitDazza: 'who is TYSON and why do nine people think his wristband beats my list',
|
||||||
|
denyNote: 'He films a story outside: "this venue hates culture." 41 views.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Pyjamas, at the rope, at 1 AM. The only person all night who wants LESS
|
||||||
|
// club. She is not wrong about the bass.
|
||||||
|
neighbour: {
|
||||||
|
names: ['Carol Whitford', 'Caz Whitford', 'C. Whitford'],
|
||||||
|
beats: [
|
||||||
|
{ line: 'im not coming in. lord, no.', afterMs: 0 },
|
||||||
|
{ line: 'number 12, across the laneway. its the bass. my GLASSES are walking.', afterMs: 2600 },
|
||||||
|
{ line: 'either it comes down or i ring the council again. your choice, love.', afterMs: 5400 },
|
||||||
|
],
|
||||||
|
admitNote: 'She stands in the booth doorway pointing at the subwoofer until it comes down 2dB. Peace, of a kind.',
|
||||||
|
denyNote: 'She photographs the queue on her way home. For the file.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Wrong venue, right energy, seven matching sashes. A vibe grenade with a
|
||||||
|
// random timer, and the pin is in your hand.
|
||||||
|
hensParty: {
|
||||||
|
names: ['Melissa Tran', 'Mel Tran', 'M. Tran'],
|
||||||
|
beats: [
|
||||||
|
{ line: 'ok so, small thing: this is not the venue on the invitation.', afterMs: 0 },
|
||||||
|
{ line: 'HOWEVER. the venue on the invitation has a queue.', afterMs: 2400 },
|
||||||
|
{ line: 'there are seven of us and we have practised a dance.', afterMs: 4800 },
|
||||||
|
],
|
||||||
|
admitNote: 'Seven sashes file past. The bar staff make eye contact with god.',
|
||||||
|
denyNote: 'They find the right venue. You hear the scream of joy from two streets over.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Genuinely twins. Everything the photo check believes is suddenly on trial,
|
||||||
|
// and both of them have done this bit at every door in the Valley.
|
||||||
|
twins: {
|
||||||
|
names: ['Jesse Kearney', 'Jordan Kearney'],
|
||||||
|
beats: [
|
||||||
|
{ line: 'evening. before you ask: yes. there is two of me.', afterMs: 0 },
|
||||||
|
{ line: 'my brothers parking the car. we get this a LOT.', afterMs: 2600 },
|
||||||
|
{ line: 'same face, different bloke. this licence is mine. his is his.', afterMs: 5200 },
|
||||||
|
],
|
||||||
|
admitNote: 'One of two, in. That face will be back in five minutes under a different name.',
|
||||||
|
denyNote: 'He rings his brother from the kerb: "told you. same face, no entry."',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Fourteen years on this exact door. His tips are right about eighty percent
|
||||||
|
// of the time, which is the joke, and also fourteen years of doors.
|
||||||
|
exDoorman: {
|
||||||
|
names: ['Frank Oxley', 'Franko Oxley', 'F. Oxley'],
|
||||||
|
beats: [
|
||||||
|
{ line: 'fourteen years i worked this exact door. before the rebrand.', afterMs: 0 },
|
||||||
|
{ line: 'dont mind me. just gonna watch a few. old times.', afterMs: 2600 },
|
||||||
|
{ line: 'ill be by the planter. tip or two, free of charge.', afterMs: 5200 },
|
||||||
|
],
|
||||||
|
admitNote: 'He does one lap inside and comes straight back out to the planter. The door IS the venue, for him.',
|
||||||
|
denyNote: 'Completely unbothered. He sets up by the planter with a dart. "Better view out here anyway."',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -80,6 +80,43 @@ export const PATDOWN_FOUND: Record<string, string> = {
|
|||||||
export const PATDOWN_CLEAR = 'Nothing on him. "Told ya," he says, delighted.';
|
export const PATDOWN_CLEAR = 'Nothing on him. "Told ya," he says, delighted.';
|
||||||
export const PATDOWN_MISSED = 'Time. Whatever\'s left in there walks out the door with him.';
|
export const PATDOWN_MISSED = 'Time. Whatever\'s left in there walks out the door with him.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two blokes squaring up. Pub fights start over nothing and everyone involved
|
||||||
|
* knows it — keep the stakes stupid so the tension stays funny, and so getting
|
||||||
|
* between them reads as defusing a farce, not breaking up a war.
|
||||||
|
*/
|
||||||
|
export const FIGHT_CAUSE: readonly string[] = [
|
||||||
|
'Someone stepped on someone\'s very white shoe.',
|
||||||
|
'A disagreement about whether this song is "the actual version".',
|
||||||
|
'One of them called the other one\'s cousin "alright, I guess".',
|
||||||
|
'A pool table was, allegedly, next-ed out of turn.',
|
||||||
|
'Someone said the kebab shop over the road has "gone downhill".',
|
||||||
|
'It is about a text message from 2019.',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Escalating shove chatter — you hear this before you see anything. */
|
||||||
|
export const FIGHT_SHOVES: readonly string[] = [
|
||||||
|
'"Say it again."',
|
||||||
|
'"Nah nah nah, say it AGAIN."',
|
||||||
|
'"I\'m not even angry, mate. I\'m not."',
|
||||||
|
'"Hold my drink. HOLD it."',
|
||||||
|
'"You reckon? You RECKON?"',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** You got between them in time. */
|
||||||
|
export const FIGHT_BROKEN: readonly string[] = [
|
||||||
|
'You step into the gap. Both of them discover they were leaving anyway.',
|
||||||
|
'A wall of hi-vis arrives. Suddenly everyone is best mates and very tired.',
|
||||||
|
'You say nothing. They remember they have work in the morning.',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** You didn't. */
|
||||||
|
export const FIGHT_SWUNG: readonly string[] = [
|
||||||
|
'The swing lands. So does the drink, the stool, and the mood of the entire room.',
|
||||||
|
'One punch, no contact, both on the floor anyway. The room turns on you, not them.',
|
||||||
|
'It is over in a second and everyone saw you standing too far away.',
|
||||||
|
];
|
||||||
|
|
||||||
/** Kayden, on the radio, when a stampless patron turns up inside. */
|
/** Kayden, on the radio, when a stampless patron turns up inside. */
|
||||||
export const NO_STAMP_RADIO: readonly string[] = [
|
export const NO_STAMP_RADIO: readonly string[] = [
|
||||||
'"Yeah sorry, that one might\'ve been me. Sorry. Sorry."',
|
'"Yeah sorry, that one might\'ve been me. Sorry. Sorry."',
|
||||||
@ -89,3 +126,295 @@ export const NO_STAMP_RADIO: readonly string[] = [
|
|||||||
|
|
||||||
export const HELP_TEXT =
|
export const HELP_TEXT =
|
||||||
'WASD move · TAB uv · E interact · SPACE bang · ESC cancel · R reseed';
|
'WASD move · TAB uv · E interact · SPACE bang · ESC cancel · R reseed';
|
||||||
|
|
||||||
|
|
||||||
|
/** The floor score: lights on at close, the floor pays out. */
|
||||||
|
export const SWEEP_UI = {
|
||||||
|
open: 'LIGHTS ON. the floor will now tell you what kind of night it was.',
|
||||||
|
pickup: 'E — pick it up',
|
||||||
|
leave: 'E — call it a night',
|
||||||
|
lastOne: "that's the lot. the door is that way.",
|
||||||
|
baggiePrompt: 'zip-lock bag: B bin it · SPACE pocket it',
|
||||||
|
baggieBinned: 'into the amnesty bin. the bin does not thank you. bins never do.',
|
||||||
|
baggiePocketed: 'it goes in your pocket. for the report. obviously. for the report.',
|
||||||
|
phoneFound: 'straight into lost property. MUM DO NOT ANSWER can rest.',
|
||||||
|
/** {cash} replaced at walk-out. */
|
||||||
|
done: 'floor swept. ${cash} better off. the room, finally, is just a room.',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Radio command (design §6): the staff on the other end of the walkie. */
|
||||||
|
export const STAFF_RADIO = {
|
||||||
|
hint: 'RADIO — 1 bump (mess) · 2 shan (bar) · 3 kayden (rope)',
|
||||||
|
hintDoor: 'RADIO — 1 bump (mess) · 2 shan (bar)',
|
||||||
|
bumpAck: '*click* yeah boss. seen it. on it. *click*',
|
||||||
|
bumpDone: 'bump mopped it and signed it. in that order, worryingly.',
|
||||||
|
bumpDistracted: 'bump signed it, then found a phone in the puddle. investigation ongoing.',
|
||||||
|
bumpNothing: '*click* nothing to sort boss. floor is immaculate. u ok? *click*',
|
||||||
|
shanAck: '*click* shan says say less *click*',
|
||||||
|
shanDone: 'shan got a water into them without a single negotiation. artist.',
|
||||||
|
shanBotch: 'shan led with "YOUVE HAD ENOUGH." they had not had enough. now everyone is upset.',
|
||||||
|
shanNothing: '*click* everyones upright boss. genuinely no idea how *click*',
|
||||||
|
cooldown: 'the radio crackles back: mid-task. give it a minute.',
|
||||||
|
kaydenAck: '*click* kayden copies. rope shut. kayden salutes. nobody can see him saluting. *click*',
|
||||||
|
kaydenCooldown: 'kayden is still holding the rope from last time. white-knuckled. honoured.',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** The smoking-yard joint bust (docs/SCENARIOS.md). Three verbs, none clean. */
|
||||||
|
export const JOINT_UI = {
|
||||||
|
title: 'SOMETHING SMELLS HERBAL',
|
||||||
|
smokerLine: 'its uh. a rollie. bush tobacco. very natural',
|
||||||
|
bin: 'BIN IT',
|
||||||
|
binDetail: 'by the book. into the amnesty bin, into the log',
|
||||||
|
pocket: 'POCKET IT',
|
||||||
|
pocketDetail: 'for later. for evidence. for whatever',
|
||||||
|
slide: "DIDN'T SEE IT",
|
||||||
|
slideDetail: 'you were never here. neither was the smell',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const JOINT_RESULTS = {
|
||||||
|
binned: 'It goes in the bin. The yard watches it go.',
|
||||||
|
pocketed: 'It goes in your pocket. Nobody says anything. Especially you.',
|
||||||
|
slid: 'You study the fence line. Excellent fence. The yard exhales.',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Dazza, later, if word gets around about what is in your pocket. */
|
||||||
|
export const JOINT_STING = 'a punter says SECURITY is holding. tell me thats a lie. tell me NOW';
|
||||||
|
|
||||||
|
/** The vomit protocol (docs/SCENARIOS.md). Sign first, mop second. */
|
||||||
|
export const VOMIT_UI = {
|
||||||
|
splash: 'Someone has redecorated the floor.',
|
||||||
|
radio: '"Cleanup near the dance floor. Not a drill. Bring the sign."',
|
||||||
|
sign: 'E — deploy the wet floor sign',
|
||||||
|
mop: 'E — mop it up',
|
||||||
|
signed: 'The yellow sign takes its post. The floor is now officially advisory.',
|
||||||
|
mopped: 'Gone. Like it never happened. The mop knows the truth.',
|
||||||
|
slip: 'Someone goes over near the puddle.',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Water station duty (RSA): the humane tool that costs time. */
|
||||||
|
export const WATER_UI = {
|
||||||
|
pickup: 'E — pour a water',
|
||||||
|
picked: 'One water, no ice, house cup. The most powerful item in the building.',
|
||||||
|
serve: 'E — get some water into them',
|
||||||
|
served: 'They take the cup. "Legend." They mean it.',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Phone confiscation: the influencer's revenge arc. */
|
||||||
|
export const PHONE_UI = {
|
||||||
|
title: 'FILMING THE FLOOR',
|
||||||
|
line: 'its CONTENT. people need to know this place goes off',
|
||||||
|
confiscate: 'PHONE. NOW.',
|
||||||
|
confiscateDetail: 'held at the bar til close. house policy, sort of',
|
||||||
|
ask: 'ASK NICELY',
|
||||||
|
askDetail: 'faces in that crowd. maybe they get it',
|
||||||
|
leave: 'LET IT RIDE',
|
||||||
|
leaveDetail: 'free promo. what could a story possibly cost',
|
||||||
|
confiscated: 'The phone goes behind the bar. The look you get goes somewhere deeper.',
|
||||||
|
askedOk: 'They actually stop. "Yeah nah, fair." The camera sleeps.',
|
||||||
|
askedNo: 'They pan, slowly, to you.',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const PHONE_POSTED = {
|
||||||
|
good: 'the story went up. the venue looks INCREDIBLE. whoever was on the door tonight, cheers',
|
||||||
|
bad: 'the story went up. it caught the wrong five seconds. comments are open',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** The fainter. Plain words only — this one is not a bit. */
|
||||||
|
export const AID_UI = {
|
||||||
|
prompt: "E — they've gone down",
|
||||||
|
title: 'THEY\u2019VE GONE DOWN',
|
||||||
|
radio: '"Person down on the floor. Give them space. GIVE THEM SPACE."',
|
||||||
|
ok: 'They come around on their side. Walked out the back to a taxi. The room starts again, quieter.',
|
||||||
|
ambulance: 'The ambulance takes them out the back. The room starts again, but not really.',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** The DJ shift (docs/SCENARIOS.md, design §6). Holding the decks, briefly. */
|
||||||
|
export const DJ_UI = {
|
||||||
|
enter: 'E — hold the decks for a minute',
|
||||||
|
exit: 'E — step off the decks',
|
||||||
|
handover: 'the resident points at the crossfader, then at you, then leaves at pace toward the toilets.',
|
||||||
|
handback: 'the resident returns smelling of the yard. "good hands." highest honour.',
|
||||||
|
help: 'B build \u00b7 SPACE drop \u00b7 E step off',
|
||||||
|
buildStart: 'the bass ducks out. the room leans in.',
|
||||||
|
fizzle: 'the riser runs out of stairs. the bass slinks back in unannounced.',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const DJ_DROP_RESULTS = {
|
||||||
|
clean: 'ON the bar. the floor detonates. somebody loses a shoe and does not care.',
|
||||||
|
late: 'near enough. the floor forgives. the floor wanted to forgive.',
|
||||||
|
clang: 'clanged it. a bartender winces audibly over the PA somehow.',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Booth requests: id must match REQUEST_HIT in scenes/floor/djShift.ts. */
|
||||||
|
export interface DjRequestScript {
|
||||||
|
id: string;
|
||||||
|
line: string;
|
||||||
|
hit: string;
|
||||||
|
flop: string;
|
||||||
|
nah: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DJ_REQUESTS: readonly DjRequestScript[] = [
|
||||||
|
{
|
||||||
|
id: 'dnb',
|
||||||
|
line: 'got any dnb. any at all. i can wait',
|
||||||
|
hit: 'the dnb lands. three blokes materialise from nowhere doing the washing machine.',
|
||||||
|
flop: 'the dnb clears the middle of the floor like a fire drill.',
|
||||||
|
nah: 'he nods slowly and stays exactly where he is. he can wait.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nan',
|
||||||
|
line: 'can u play something my nan would know. shes here. shes ninety',
|
||||||
|
hit: 'nan goes OFF. the entire floor orbits her. this is her venue now.',
|
||||||
|
flop: 'nan is polite about it. the floor is not.',
|
||||||
|
nah: 'nan waves at the booth anyway. no hard feelings from nan.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'birthday',
|
||||||
|
line: 'its my birthday. this is a fact u can verify',
|
||||||
|
hit: 'the birthday track hits and the whole room sings at a stranger. he weeps.',
|
||||||
|
flop: 'the birthday track dies. he stands in the middle of it, aging.',
|
||||||
|
nah: 'he shows you his licence to prove the birthday. you have seen worse proof tonight.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'shazam',
|
||||||
|
line: '*holds phone against the booth glass* play THIS',
|
||||||
|
hit: 'phone song works. she points at you like you invented it.',
|
||||||
|
flop: 'the phone song is 34 seconds of someone talking over a boat engine.',
|
||||||
|
nah: 'she lowers the phone one centimetre at a time, maintaining eye contact.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'slower',
|
||||||
|
line: 'somethin slower?? my feet r cooked',
|
||||||
|
hit: 'the slow one lands and the couples appear. where were the couples STANDING.',
|
||||||
|
flop: 'the energy drops through the floor and into the carpark below.',
|
||||||
|
nah: 'she sits on the subwoofer to rest instead. security problem, later, probably.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'again',
|
||||||
|
line: 'play the one from before again. the DUN DUN one',
|
||||||
|
hit: 'the DUN DUN one, again. somehow BIGGER. lightning does strike twice.',
|
||||||
|
flop: 'the same song twice reads less like a victory lap and more like a cry for help.',
|
||||||
|
nah: 'he hums it to himself on the way back to the floor. dun dun. dun dun.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The bartender shift (docs/SCENARIOS.md, design §6). The other side of the counter. */
|
||||||
|
export const BAR_UI = {
|
||||||
|
enter: 'E — jump on the taps for a bit',
|
||||||
|
exit: 'E — hand the taps back',
|
||||||
|
handover:
|
||||||
|
'the bartender unties the apron mid-stride. "orders come to you. nobody cooked gets a pour." gone.',
|
||||||
|
handback: 'the bartender reclaims the apron and inspects the till like a crime scene. nods once.',
|
||||||
|
help: 'orders come to the taps · E step off',
|
||||||
|
title: 'AT THE TAPS',
|
||||||
|
serve: 'SERVE',
|
||||||
|
serveDetail: 'pour it. its a bar. this is the job',
|
||||||
|
water: 'WATER',
|
||||||
|
waterDetail: 'the house cup. not what they ordered',
|
||||||
|
cutoff: 'CUT OFF',
|
||||||
|
cutoffDetail: 'not tonight. the phrase works in here too',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** The order IS the sobriety test — the spelling degrades with the patron. */
|
||||||
|
export const BAR_ORDER_LINES: Record<DrunkStage, readonly string[]> = {
|
||||||
|
sober: [
|
||||||
|
'two pots and a lemonade, cheers',
|
||||||
|
'schooner of the middle tap please',
|
||||||
|
'whats on special. actually doesnt matter, one of those',
|
||||||
|
'a pot, and one for me mate whos coming. he said five minutes',
|
||||||
|
],
|
||||||
|
tipsy: [
|
||||||
|
'same again legend',
|
||||||
|
'shots?? no. no youre right. two beers',
|
||||||
|
'one of the fancy ones with the... yknow. the fancy ones',
|
||||||
|
'a jug! for the table! the table voted!',
|
||||||
|
],
|
||||||
|
loose: [
|
||||||
|
'mate. mate. two... no. three beers',
|
||||||
|
'what did i have last time. that. two of that',
|
||||||
|
'somethin strong im celebratin a thing i currently forget',
|
||||||
|
'a beer for now and a beer for later. thats time management',
|
||||||
|
],
|
||||||
|
messy: [
|
||||||
|
'jus one more n then im done sweat',
|
||||||
|
'beeer. the yellow one. yeknow the one',
|
||||||
|
'whos shout is it. its ur shout. one shout pls',
|
||||||
|
'i love this bar. one bar please. i mean beer',
|
||||||
|
],
|
||||||
|
maggot: [
|
||||||
|
'*points at the taps generally*',
|
||||||
|
'u kno wha. surprise me champ',
|
||||||
|
'*rests forehead on the counter* ...the usual',
|
||||||
|
'im so. okay. one. listen. one',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BAR_RESULTS = {
|
||||||
|
served: 'poured. they tap the counter twice, which means thanks.',
|
||||||
|
servedBreach: 'poured. it took them three goes to find the glass. somebody definitely saw that.',
|
||||||
|
water: 'they take the water like its a trick. drink it anyway. improve measurably.',
|
||||||
|
waterSnub: '"this is water." it is. "i ordered a beer." they did.',
|
||||||
|
cutoff: 'cut off. they leave the bar the way a tide goes out: slowly, and taking things with it.',
|
||||||
|
cutoffEarly: 'cut off, two drinks deep, stone sober. they tell the whole queue about you on the way out.',
|
||||||
|
writeUpDazza:
|
||||||
|
'heads up — someone with a lanyard watched that last pour go into a bloke who could not locate his own mouth',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** The tip economy: cash on the booth glass. */
|
||||||
|
export const DJ_TIP_UI = {
|
||||||
|
/** {tip} replaced with the amount. */
|
||||||
|
titleTipped: 'BOOTH REQUEST · ${tip} ON THE GLASS',
|
||||||
|
returnLinePrefix: 'back already. ',
|
||||||
|
returnLineSuffix: ' *the cash is folded into a little paper crane this time*',
|
||||||
|
take: 'TAKE IT. PLAY IT.',
|
||||||
|
takeDetail: 'the song owes you nothing now. neither do they',
|
||||||
|
free: 'PLAY IT. KEEP YOUR CASH.',
|
||||||
|
freeDetail: 'the booth has standards. tonight, anyway',
|
||||||
|
nah: 'NAH',
|
||||||
|
nahDetail: 'not the song. not the money. nothing personal',
|
||||||
|
took: 'the note disappears into the shoe. the request queue watches it happen.',
|
||||||
|
freed: 'you wave the cash off and cue it anyway. confusing scenes. good scenes.',
|
||||||
|
refused: 'refusing the song is business. refusing the money is personal.',
|
||||||
|
/** {total} replaced at step-off. */
|
||||||
|
shoe: '${total} in the shoe. the shoe says nothing.',
|
||||||
|
sting: 'dazza: hearing the booth takes requests for cash now?? we are not that venue. we are exactly that venue but STILL',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** The pour meter. */
|
||||||
|
export const POUR_UI = {
|
||||||
|
title: 'THE POUR',
|
||||||
|
help: 'SPACE — stop the pour · ESC — bail',
|
||||||
|
short: 'thats mostly glass. they hold it up to the light, disgusted.',
|
||||||
|
clean: 'a proper pour. the glass sweats politely.',
|
||||||
|
overflow: 'a criminally heavy pour. someone applauds. the till winces.',
|
||||||
|
bailed: 'you put the glass down mid-pour. everyone pretends that was normal.',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Tabs: the jar of other people's cards. */
|
||||||
|
export const BAR_TAB_UI = {
|
||||||
|
/** {name} replaced with the card name. */
|
||||||
|
opened: 'a card lands in the jar. the name on it says {name}. start a tab? apparently youve already agreed.',
|
||||||
|
title: 'THE CARD BACK',
|
||||||
|
lines: [
|
||||||
|
'my card. the black one. or blue. its got a bank on it',
|
||||||
|
'im heading off. card. the one with my name on. classic card',
|
||||||
|
'card please champ. ill know it when i see it. i will not',
|
||||||
|
],
|
||||||
|
/** {drinks} and {total} replaced on a clean handback. */
|
||||||
|
closed: '{drinks} drinks on it. ${total}. they sign something that is legally a drawing.',
|
||||||
|
wrong: 'they leave with someone elses card. somewhere, a direct debit changes hands.',
|
||||||
|
unclaimed: 'they gave up waiting for the card and left. the jar keeps it. the jar keeps everything.',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const DJ_REQUEST_UI = {
|
||||||
|
title: 'BOOTH REQUEST',
|
||||||
|
play: 'PLAY IT',
|
||||||
|
playDetail: 'give the floor to them. see what it does with it',
|
||||||
|
nah: 'NAH',
|
||||||
|
nahDetail: 'the set has a plan. the plan is not this',
|
||||||
|
later: 'AFTER THIS ONE',
|
||||||
|
laterDetail: 'the diplomatic no. sometimes they remember',
|
||||||
|
laterQuiet: 'they nod and drift off satisfied. for now.',
|
||||||
|
laterGrudge: 'they came back. they remembered. they brought a mood.',
|
||||||
|
} as const;
|
||||||
|
|||||||
@ -22,6 +22,8 @@ export interface IdFakeTells {
|
|||||||
wrongHologram?: boolean;
|
wrongHologram?: boolean;
|
||||||
jokeName?: boolean;
|
jokeName?: boolean;
|
||||||
photoMismatch?: boolean;
|
photoMismatch?: boolean;
|
||||||
|
/** The same card was already admitted tonight (passback — set by the door). */
|
||||||
|
passback?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IdCard {
|
export interface IdCard {
|
||||||
@ -42,6 +44,11 @@ export interface PatronFlags {
|
|||||||
disguised?: boolean;
|
disguised?: boolean;
|
||||||
/** Set on couples by door/QueueManager; predicate for noHandHolders (CCR-1). */
|
/** Set on couples by door/QueueManager; predicate for noHandHolders (CCR-1). */
|
||||||
handHolding?: boolean;
|
handHolding?: boolean;
|
||||||
|
/** Stable cast identity for run-scoped regulars (cross-night memory, §4.1). */
|
||||||
|
regularId?: string;
|
||||||
|
/** Coin-flip scripted encounters: which person this actually is (set at
|
||||||
|
* dress() time — real/fake cop, rocket/paps celebrity, fed/hungry DJ). */
|
||||||
|
encounterVariant?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Patron {
|
export interface Patron {
|
||||||
@ -120,6 +127,8 @@ export interface GameState {
|
|||||||
heatStrikes: HeatStrike[];
|
heatStrikes: HeatStrike[];
|
||||||
regulars: Record<string, RegularMemory>;
|
regulars: Record<string, RegularMemory>;
|
||||||
pastReports: IncidentRecord[][];
|
pastReports: IncidentRecord[][];
|
||||||
|
/** Scripted-encounter ids the run has already met (repeat gating). */
|
||||||
|
seenEncounters?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- event bus ----
|
// ---- event bus ----
|
||||||
@ -136,6 +145,8 @@ export interface EventMap {
|
|||||||
|
|
||||||
'floor:infractionSpotted': { patronId: string; kind: 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'fight' };
|
'floor:infractionSpotted': { patronId: string; kind: 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'fight' };
|
||||||
'floor:ejection': { patronId: string; style: 'clean' | 'messy' };
|
'floor:ejection': { patronId: string; style: 'clean' | 'messy' };
|
||||||
|
/** A patron went home on their own — capacity frees up (natural churn). */
|
||||||
|
'floor:leave': { patronId: string };
|
||||||
|
|
||||||
'beat:tick': { beatIndex: number; audioTimeMs: number };
|
'beat:tick': { beatIndex: number; audioTimeMs: number };
|
||||||
'audio:location': { location: 'door' | 'floor' };
|
'audio:location': { location: 'door' | 'floor' };
|
||||||
|
|||||||
102
src/data/venues.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
// The venue ladder (design §6): each venue is a week of Thu→Fri→Sat, and a
|
||||||
|
// difficulty tier expressed as knobs on systems that already exist. Survive a
|
||||||
|
// week, get the next venue's door. Three strikes anywhere, the run dies.
|
||||||
|
|
||||||
|
export interface VenueDef {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** Dazza's blurb on the promotion screen. */
|
||||||
|
pitch: string;
|
||||||
|
licensed: number;
|
||||||
|
/** Multiplies the arrival curve — how hard the queue slams. */
|
||||||
|
arrivalScale: number;
|
||||||
|
/**
|
||||||
|
* How many of Dazza's rule texts fire this week (chronological prefix).
|
||||||
|
* The live-rule cap (dressCode.ACTIVE_RULE_CAP) applies on top.
|
||||||
|
*/
|
||||||
|
ruleLimit: number;
|
||||||
|
/** Does the plainclothes inspector work this venue? */
|
||||||
|
inspector: boolean;
|
||||||
|
/**
|
||||||
|
* Spawn-weight OVERRIDES merged over data/archetypes.ts — the venue's crowd
|
||||||
|
* identity. Omitted keys keep their default weight.
|
||||||
|
*/
|
||||||
|
archetypeWeights?: Partial<Record<import('./types').Archetype, number>>;
|
||||||
|
/** Head of Security tier (design §6): radio command of NPC staff. */
|
||||||
|
radioCommand?: boolean;
|
||||||
|
/** ROOM (design §6): the dress code re-randomises every 15 in-game minutes. */
|
||||||
|
shuffleDressCode?: boolean;
|
||||||
|
/** ROOM (design §6): rope dialogue renders as lip-read guesses. */
|
||||||
|
lipRead?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VENUES: readonly VenueDef[] = [
|
||||||
|
{
|
||||||
|
id: 'theRoyal',
|
||||||
|
name: 'The Royal',
|
||||||
|
pitch: 'a dive with a dance floor. nobody checks anything. u will',
|
||||||
|
licensed: 80,
|
||||||
|
arrivalScale: 0.8,
|
||||||
|
ruleLimit: 4,
|
||||||
|
inspector: false,
|
||||||
|
// A local's pub that happens to have a dance floor: regulars and punters,
|
||||||
|
// barely an influencer in sight.
|
||||||
|
archetypeWeights: { regular: 14, influencer: 2, financeBro: 6, ownersMate: 2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'voltage',
|
||||||
|
name: 'Voltage',
|
||||||
|
pitch: 'valley institution. full rulebook. the inspector drinks here. lemonade',
|
||||||
|
licensed: 110,
|
||||||
|
arrivalScale: 1.0,
|
||||||
|
ruleLimit: 8,
|
||||||
|
inspector: true,
|
||||||
|
// The balanced book — the tuning baseline. No overrides.
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'elevate',
|
||||||
|
name: 'Elevate',
|
||||||
|
pitch: 'rooftop. small room, big opinions. every second person is a VIP apparently',
|
||||||
|
licensed: 70,
|
||||||
|
arrivalScale: 1.1,
|
||||||
|
ruleLimit: 8,
|
||||||
|
inspector: true,
|
||||||
|
// Elevate is the Head of Security tier: the radio starts taking orders.
|
||||||
|
radioCommand: true,
|
||||||
|
// Rooftop VIP politics: every second person is on a list, knows the owner,
|
||||||
|
// or is filming. Regulars don't come here; they can't get in either.
|
||||||
|
archetypeWeights: { influencer: 18, ownersMate: 7, financeBro: 14, punter: 38, regular: 3 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'room',
|
||||||
|
name: 'ROOM',
|
||||||
|
pitch: 'no sign. no socials. code changes when the board says so. u wont hear a word anyone says. good luck',
|
||||||
|
licensed: 60,
|
||||||
|
arrivalScale: 1.2,
|
||||||
|
// Dazza doesn't announce rules here — the board is the law and it keeps
|
||||||
|
// rewriting itself (shuffleDressCode). His other texts still land.
|
||||||
|
ruleLimit: 0,
|
||||||
|
inspector: true,
|
||||||
|
radioCommand: true,
|
||||||
|
shuffleDressCode: true,
|
||||||
|
lipRead: true,
|
||||||
|
// Underground techno: heads and chancers. Everyone claims VIP; almost
|
||||||
|
// nobody is. Regulars can't find the door.
|
||||||
|
archetypeWeights: { punter: 34, influencer: 12, ownersMate: 9, quietMessy: 8, financeBro: 10, regular: 2, fresh18: 6 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TOTAL_VENUES = VENUES.length;
|
||||||
|
|
||||||
|
export function venueByIndex(i: number): VenueDef {
|
||||||
|
return VENUES[Math.max(0, Math.min(VENUES.length - 1, i))]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Week `w` (venue index) shifts the base night dates forward 7 days per week. */
|
||||||
|
export function nightDateFor(baseIso: string, venueIndex: number): string {
|
||||||
|
const [y = '2026', m = '07', d = '16'] = baseIso.split('-');
|
||||||
|
const date = new Date(Number(y), Number(m) - 1, Number(d) + venueIndex * 7);
|
||||||
|
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${date.getFullYear()}-${mm}-${dd}`;
|
||||||
|
}
|
||||||
34
src/main.ts
@ -6,7 +6,9 @@ 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';
|
||||||
|
|
||||||
// TODO(integration): Phase 2 owns scene routing (menu -> roster -> night). For
|
// TODO(integration): Phase 2 owns scene routing (menu -> roster -> night). For
|
||||||
// v0.1 the game IS the Door night, so it boots straight into it. The demo scenes
|
// v0.1 the game IS the Door night, so it boots straight into it. The demo scenes
|
||||||
@ -24,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,
|
||||||
@ -34,6 +39,7 @@ const game = new Phaser.Game({
|
|||||||
ParadeScene,
|
ParadeScene,
|
||||||
new FloorDemoScene('FloorDemo'),
|
new FloorDemoScene('FloorDemo'),
|
||||||
JuiceDemoScene,
|
JuiceDemoScene,
|
||||||
|
MixDeskScene,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -47,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]);
|
||||||
};
|
};
|
||||||
@ -72,4 +87,13 @@ window.addEventListener('keydown', (e) => {
|
|||||||
else if (k === 'p') showScene('Parade');
|
else if (k === 'p') showScene('Parade');
|
||||||
else if (k === 'f') showScene('FloorDemo');
|
else if (k === 'f') showScene('FloorDemo');
|
||||||
else if (k === 'j') showScene('JuiceDemo');
|
else if (k === 'j') showScene('JuiceDemo');
|
||||||
|
else if (k === 'm') {
|
||||||
|
// The mix desk OVERLAYS whatever is running (it attaches to the active
|
||||||
|
// engine), so the ear pass can happen during a real night. Toggle, not route.
|
||||||
|
if (game.scene.isActive('MixDesk')) game.scene.stop('MixDesk');
|
||||||
|
else {
|
||||||
|
game.scene.run('MixDesk');
|
||||||
|
game.scene.bringToTop('MixDesk');
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import type { Patron } from '../data/types';
|
|||||||
export function dollTextureKey(p: Patron, pose: DollPose): string {
|
export function dollTextureKey(p: Patron, pose: DollPose): string {
|
||||||
// Drunk tells are baked into the texture, so intoxication stage is part of
|
// Drunk tells are baked into the texture, so intoxication stage is part of
|
||||||
// the cache key (coarse: swayPx bucket, not raw float).
|
// the cache key (coarse: swayPx bucket, not raw float).
|
||||||
return `doll:${p.dollSeed}:${pose}:${dollPlan(p, pose).swayPx}`;
|
return `doll:${p.dollSeed}:${pose}:${dollPlan(p, pose).swayPx}:${p.flags.disguised === true ? 'd' : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderDoll(scene: Phaser.Scene, p: Patron, pose: DollPose): string {
|
export function renderDoll(scene: Phaser.Scene, p: Patron, pose: DollPose): string {
|
||||||
|
|||||||
@ -122,5 +122,16 @@ export function dollPlan(p: Patron, pose: DollPose): DollPlan {
|
|||||||
rects.push({ x: cx + 1, y: 9, w: 1, h: 1, colour: 0xcc3333 });
|
rects.push({ x: cx + 1, y: 9, w: 1, h: 1, colour: 0xcc3333 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The disguise (design §4.1): a grudged regular's third appearance wears a
|
||||||
|
// fake moustache and a big pair of NOT-sunnies. Deliberately absent from the
|
||||||
|
// idPhoto pose — his card still shows the clean-shaven bloke you knocked
|
||||||
|
// back on Thursday. Same walk. Same name. "Different" man.
|
||||||
|
if (p.flags.disguised === true) {
|
||||||
|
rects.push({ x: cx - 2, y: 11, w: 5, h: 1, colour: 0x2a1c10 }); // moustache
|
||||||
|
rects.push({ x: cx - 3, y: 8, w: 2, h: 2, colour: 0x101018 }); // glasses L
|
||||||
|
rects.push({ x: cx + 1, y: 8, w: 2, h: 2, colour: 0x101018 }); // glasses R
|
||||||
|
rects.push({ x: cx - 1, y: 8, w: 2, h: 1, colour: 0x101018 }); // bridge
|
||||||
|
}
|
||||||
|
|
||||||
return { width, height, rects, swayPx };
|
return { width, height, rects, swayPx };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,7 +36,9 @@ function rollOutfit(rng: RngStream): OutfitLayer[] {
|
|||||||
for (const slot of Object.keys(OUTFIT_VOCAB) as OutfitSlot[]) {
|
for (const slot of Object.keys(OUTFIT_VOCAB) as OutfitSlot[]) {
|
||||||
const def = rng.weighted(OUTFIT_VOCAB[slot].map((d) => [d, d.weight] as const));
|
const def = rng.weighted(OUTFIT_VOCAB[slot].map((d) => [d, d.weight] as const));
|
||||||
const layer: OutfitLayer = { slot, type: def.type, colour: rng.pick(def.colours) };
|
const layer: OutfitLayer = { slot, type: def.type, colour: rng.pick(def.colours) };
|
||||||
if (def.canLogo && rng.chance(0.35)) layer.logo = true;
|
// 0.18 (was 0.35, tuning 2026-07-19): with 8 rules live by 1 AM, a third of
|
||||||
|
// the crowd wearing logos made most patrons deniable and emptied the room.
|
||||||
|
if (def.canLogo && rng.chance(0.18)) layer.logo = true;
|
||||||
if (def.canVintage && rng.chance(0.2)) layer.vintage = true;
|
if (def.canVintage && rng.chance(0.2)) layer.vintage = true;
|
||||||
layers.push(layer);
|
layers.push(layer);
|
||||||
}
|
}
|
||||||
@ -80,13 +82,17 @@ function rollId(rng: RngStream, nightDate: Date, trueAge: number, dollSeed: numb
|
|||||||
export interface GeneratorContext {
|
export interface GeneratorContext {
|
||||||
rng: SeededRNG;
|
rng: SeededRNG;
|
||||||
nightDate: Date;
|
nightDate: Date;
|
||||||
|
/** Venue crowd identity: spawn-weight overrides merged over ARCHETYPES. */
|
||||||
|
archetypeWeights?: Partial<Record<Archetype, number>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generatePatron(ctx: GeneratorContext, clockMin: number, archetypeOverride?: Archetype): Patron {
|
export function generatePatron(ctx: GeneratorContext, clockMin: number, archetypeOverride?: Archetype): Patron {
|
||||||
const rng = ctx.rng.stream('patrons');
|
const rng = ctx.rng.stream('patrons');
|
||||||
const def = archetypeOverride
|
const def = archetypeOverride
|
||||||
? ARCHETYPE_BY_KEY.get(archetypeOverride)!
|
? ARCHETYPE_BY_KEY.get(archetypeOverride)!
|
||||||
: rng.weighted(ARCHETYPES.map((a) => [a, a.weight] as const));
|
: rng.weighted(
|
||||||
|
ARCHETYPES.map((a) => [a, ctx.archetypeWeights?.[a.key] ?? a.weight] as const),
|
||||||
|
);
|
||||||
|
|
||||||
const dollSeed = rng.int(0, 2 ** 31 - 1);
|
const dollSeed = rng.int(0, 2 ** 31 - 1);
|
||||||
const age = rng.int(def.ageRange[0], def.ageRange[1]);
|
const age = rng.int(def.ageRange[0], def.ageRange[1]);
|
||||||
|
|||||||
@ -3,6 +3,22 @@ import type { GameState, Patron, RegularMemory } from '../data/types';
|
|||||||
// Regulars' grudge store. Behaviour wiring (disguises, dialogue callbacks) lands
|
// Regulars' grudge store. Behaviour wiring (disguises, dialogue callbacks) lands
|
||||||
// in Phase 2/3 — this is just the persistent bookkeeping, run-scoped in GameState.
|
// in Phase 2/3 — this is just the persistent bookkeeping, run-scoped in GameState.
|
||||||
|
|
||||||
|
/** Record a denial against a stable key (cast regularId — or any patron id). */
|
||||||
|
export function recordDenialBy(state: GameState, key: string, nightIndex: number): RegularMemory {
|
||||||
|
const existing = state.regulars[key];
|
||||||
|
const mem: RegularMemory = existing ?? {
|
||||||
|
patronId: key,
|
||||||
|
timesDenied: 0,
|
||||||
|
lastSeenNight: nightIndex,
|
||||||
|
grudge: 0,
|
||||||
|
};
|
||||||
|
mem.timesDenied += 1;
|
||||||
|
mem.lastSeenNight = nightIndex;
|
||||||
|
mem.grudge = Math.min(1, mem.grudge + 0.34); // three denials = maximum grudge
|
||||||
|
state.regulars[key] = mem;
|
||||||
|
return mem;
|
||||||
|
}
|
||||||
|
|
||||||
export function recordDenial(state: GameState, patron: Patron, nightIndex: number): RegularMemory {
|
export function recordDenial(state: GameState, patron: Patron, nightIndex: number): RegularMemory {
|
||||||
const existing = state.regulars[patron.id];
|
const existing = state.regulars[patron.id];
|
||||||
const mem: RegularMemory = existing ?? {
|
const mem: RegularMemory = existing ?? {
|
||||||
@ -28,3 +44,8 @@ export function shouldReturnDisguised(state: GameState, patronId: string): boole
|
|||||||
const mem = state.regulars[patronId];
|
const mem = state.regulars[patronId];
|
||||||
return !!mem && mem.timesDenied >= 2;
|
return !!mem && mem.timesDenied >= 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Same rule, straight off a memory record (spawn-time check in the queue). */
|
||||||
|
export function disguisedFromMemory(mem: RegularMemory | undefined): boolean {
|
||||||
|
return !!mem && mem.timesDenied >= 2;
|
||||||
|
}
|
||||||
|
|||||||
53
src/patrons/regularCast.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
// The run's regulars: a small stable cast generated from the RUN seed, so the
|
||||||
|
// same faces turn up Thursday, Friday, and next week at the better venue.
|
||||||
|
// Design §4.1: deny one twice and the third appearance arrives in a bad fake
|
||||||
|
// moustache — same walk, same card, new "identity".
|
||||||
|
|
||||||
|
import { SeededRNG } from '../core/SeededRNG';
|
||||||
|
import { generatePatron } from './generator';
|
||||||
|
import type { Patron } from '../data/types';
|
||||||
|
|
||||||
|
export const REGULAR_CAST_SIZE = 6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic per run seed and independent of every night stream (its own
|
||||||
|
* SeededRNG instance). The cast members are full Patron templates — the queue
|
||||||
|
* adopts their IDENTITY (face, age, licence) onto freshly rolled nights
|
||||||
|
* (tonight's outfit, tonight's drinks) at spawn time.
|
||||||
|
*/
|
||||||
|
export function buildRegularCast(runSeed: number, nightDate: Date): Patron[] {
|
||||||
|
const rng = new SeededRNG(runSeed ^ 0x5eed);
|
||||||
|
const cast: Patron[] = [];
|
||||||
|
for (let i = 0; i < REGULAR_CAST_SIZE; i++) {
|
||||||
|
const p = generatePatron({ rng, nightDate }, 0, 'regular');
|
||||||
|
p.flags.regularId = `reg${i}`;
|
||||||
|
cast.push(p);
|
||||||
|
}
|
||||||
|
return cast;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dress tonight's rolled regular in a cast member's identity. The roll keeps
|
||||||
|
* its outfit/intoxication (people change clothes and moods); the cast supplies
|
||||||
|
* who they ARE: face, age, licence, tolerance — and the grudge bookkeeping.
|
||||||
|
*/
|
||||||
|
export function adoptCastIdentity(
|
||||||
|
rolled: Patron,
|
||||||
|
member: Patron,
|
||||||
|
timesDenied: number,
|
||||||
|
disguised: boolean,
|
||||||
|
): Patron {
|
||||||
|
return {
|
||||||
|
...rolled,
|
||||||
|
dollSeed: member.dollSeed,
|
||||||
|
age: member.age,
|
||||||
|
idCard: { ...member.idCard },
|
||||||
|
tolerance: member.tolerance,
|
||||||
|
flags: {
|
||||||
|
...rolled.flags,
|
||||||
|
regularId: member.flags.regularId!,
|
||||||
|
...(timesDenied > 0 ? { timesDeniedBefore: timesDenied } : {}),
|
||||||
|
...(disguised ? { disguised: true } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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';
|
||||||
|
|
||||||
@ -158,10 +159,59 @@ export const DRESS_CODE_RULES: readonly DoorDressCodeRule[] = SPECS.map(build).s
|
|||||||
);
|
);
|
||||||
|
|
||||||
/** Rules in force at `clockMin`. Live the moment the text lands, hence inclusive. */
|
/** Rules in force at `clockMin`. Live the moment the text lands, hence inclusive. */
|
||||||
export function activeRules(clockMin: number): DoorDressCodeRule[] {
|
/**
|
||||||
|
* Dazza's attention span. Only his most recent rules are LIVE — when a fifth
|
||||||
|
* lands, the oldest is quietly rescinded (the card greys it out and he sends a
|
||||||
|
* retraction text). Tuning pass 2026-07-19: with all eight rules cumulative,
|
||||||
|
* ~55% of the late crowd was deniable and a rule-following door emptied the
|
||||||
|
* room. Four keeps the card readable and the check fast, and Dazza forgetting
|
||||||
|
* his own rules is exactly in character.
|
||||||
|
*/
|
||||||
|
export const ACTIVE_RULE_CAP = 4;
|
||||||
|
|
||||||
|
// ---- ROOM shuffle (design §6) ----------------------------------------------
|
||||||
|
// The boss venue re-randomises the code every 15 in-game minutes: no Dazza
|
||||||
|
// chronology, the board IS the law. Module-level mode set by NightScene each
|
||||||
|
// night (null = normal announcements) — threading a venue through every
|
||||||
|
// violations() caller bought nothing but noise, and every consumer must agree
|
||||||
|
// on the live set anyway or the card lies.
|
||||||
|
|
||||||
|
export const SHUFFLE_EVERY_MIN = 15;
|
||||||
|
export const SHUFFLE_RULE_COUNT = 3;
|
||||||
|
|
||||||
|
let shuffleSeed: number | null = null;
|
||||||
|
|
||||||
|
export function setDressCodeShuffle(seed: number | null): void {
|
||||||
|
shuffleSeed = seed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which 15-minute window `clockMin` falls in — the board changes on the turn. */
|
||||||
|
export function shuffleWindow(clockMin: number): number {
|
||||||
|
return Math.floor(Math.max(0, clockMin) / SHUFFLE_EVERY_MIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffledRules(clockMin: number, seed: number): DoorDressCodeRule[] {
|
||||||
|
// One deterministic deck per (seed, window): re-reading the board mid-window
|
||||||
|
// must not reroll it.
|
||||||
|
const rng = new SeededRNG(seed ^ Math.imul(shuffleWindow(clockMin) + 1, 0x9e3779b9)).stream('code');
|
||||||
|
const deck = [...DRESS_CODE_RULES];
|
||||||
|
for (let i = deck.length - 1; i > 0; i--) {
|
||||||
|
const j = rng.int(0, i);
|
||||||
|
[deck[i], deck[j]] = [deck[j]!, deck[i]!];
|
||||||
|
}
|
||||||
|
return deck.slice(0, SHUFFLE_RULE_COUNT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every rule Dazza has EVER texted tonight, live or rescinded. */
|
||||||
|
export function announcedRules(clockMin: number): DoorDressCodeRule[] {
|
||||||
|
if (shuffleSeed !== null) return shuffledRules(clockMin, shuffleSeed);
|
||||||
return DRESS_CODE_RULES.filter((r) => r.activeFrom <= clockMin);
|
return DRESS_CODE_RULES.filter((r) => r.activeFrom <= clockMin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function activeRules(clockMin: number): DoorDressCodeRule[] {
|
||||||
|
return announcedRules(clockMin).slice(-ACTIVE_RULE_CAP);
|
||||||
|
}
|
||||||
|
|
||||||
export function violations(patron: Patron, clockMin: number): DoorDressCodeRule[] {
|
export function violations(patron: Patron, clockMin: number): DoorDressCodeRule[] {
|
||||||
return activeRules(clockMin).filter((r) => r.violates(patron));
|
return activeRules(clockMin).filter((r) => r.violates(patron));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,11 @@ import type { Archetype, DrunkStage, HeatStrike, Patron } from '../data/types';
|
|||||||
// optimal either. A generous option that quietly pays better is the same lie told
|
// optimal either. A generous option that quietly pays better is the same lie told
|
||||||
// the other way round. Every `onAdmit` below is priced to be a real decision.
|
// the other way round. Every `onAdmit` below is priced to be a real decision.
|
||||||
|
|
||||||
export type EncounterId = 'grabHerMate' | 'quietBeer' | 'lastNight' | 'nightShift';
|
export type EncounterId =
|
||||||
|
| 'grabHerMate' | 'quietBeer' | 'lastNight' | 'nightShift'
|
||||||
|
| 'eighteenToday' | 'kaydensMate' | 'bigNightOut' | 'twoOfThem'
|
||||||
|
| 'offDutyCop' | 'celebrity' | 'sovereign' | 'deliveryRider'
|
||||||
|
| 'promoter' | 'neighbour' | 'hensParty' | 'twins' | 'exDoorman';
|
||||||
|
|
||||||
export interface EncounterBeat {
|
export interface EncounterBeat {
|
||||||
line: string;
|
line: string;
|
||||||
@ -37,6 +41,19 @@ export interface EncounterOutcome {
|
|||||||
dazzaText?: string;
|
dazzaText?: string;
|
||||||
/** shown on the verdict toast — factual, never a moral verdict */
|
/** shown on the verdict toast — factual, never a moral verdict */
|
||||||
note?: string;
|
note?: string;
|
||||||
|
/**
|
||||||
|
* Consequences that land LATER, at a random minute inside their window —
|
||||||
|
* the hen's party detonating, the pap swarm assembling. Applied through
|
||||||
|
* NightScene's deferred machinery so they survive a location switch and show
|
||||||
|
* up in the night summary with their reason.
|
||||||
|
*/
|
||||||
|
deferredHits?: readonly {
|
||||||
|
/** [lo, hi] clock-minutes AFTER the verdict */
|
||||||
|
afterMin: readonly [number, number];
|
||||||
|
vibeDelta?: number;
|
||||||
|
aggroDelta?: number;
|
||||||
|
reason: string;
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScriptedEncounter {
|
export interface ScriptedEncounter {
|
||||||
@ -49,6 +66,13 @@ export interface ScriptedEncounter {
|
|||||||
beats: EncounterBeat[];
|
beats: EncounterBeat[];
|
||||||
onAdmit: EncounterOutcome;
|
onAdmit: EncounterOutcome;
|
||||||
onDeny: EncounterOutcome;
|
onDeny: EncounterOutcome;
|
||||||
|
/**
|
||||||
|
* Coin-flip encounters: called INSTEAD of the static tables when present.
|
||||||
|
* The coin was tossed at dress() time and rides in flags.encounterVariant, so
|
||||||
|
* resolution is a lookup, never a fresh roll — the person at the rope was
|
||||||
|
* always one thing, the player just couldn't see which.
|
||||||
|
*/
|
||||||
|
resolve?(patron: Patron, verdict: 'admit' | 'deny'): EncounterOutcome;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Minutes of clear air an encounter needs either side of it to land. */
|
/** Minutes of clear air an encounter needs either side of it to land. */
|
||||||
@ -153,7 +177,7 @@ const beatsOf = (id: EncounterId): EncounterBeat[] =>
|
|||||||
|
|
||||||
// ---- the encounters ----------------------------------------------------------
|
// ---- the encounters ----------------------------------------------------------
|
||||||
|
|
||||||
export const ENCOUNTERS: readonly ScriptedEncounter[] = [
|
const ENCOUNTERS_BASE: readonly ScriptedEncounter[] = [
|
||||||
{
|
{
|
||||||
// Early: he is doing the rounds while he can still stand at a bar and talk.
|
// Early: he is doing the rounds while he can still stand at a bar and talk.
|
||||||
id: 'lastNight',
|
id: 'lastNight',
|
||||||
@ -201,9 +225,11 @@ export const ENCOUNTERS: readonly ScriptedEncounter[] = [
|
|||||||
onDeny: { note: script('quietBeer').denyNote },
|
onDeny: { note: script('quietBeer').denyNote },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// After the logo rule lands at 90. Before that he is just a bloke in a polo.
|
// After the logo rule lands at 90 — and BEFORE Dazza rescinds it (the rule
|
||||||
|
// cap retires noLogos when the 200-minute rule arrives). Outside that band
|
||||||
|
// he is just a bloke in a polo and the dilemma evaporates.
|
||||||
id: 'nightShift',
|
id: 'nightShift',
|
||||||
window: [150, 270],
|
window: [150, 195],
|
||||||
archetype: 'punter',
|
archetype: 'punter',
|
||||||
dress(patron, rng) {
|
dress(patron, rng) {
|
||||||
const s = script('nightShift');
|
const s = script('nightShift');
|
||||||
@ -260,29 +286,363 @@ export const ENCOUNTERS: readonly ScriptedEncounter[] = [
|
|||||||
// Free. Costs nothing but the moment, and the moment is not a mechanic.
|
// Free. Costs nothing but the moment, and the moment is not a mechanic.
|
||||||
onDeny: { note: script('grabHerMate').denyNote },
|
onDeny: { note: script('grabHerMate').denyNote },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Early: it went midnight forty minutes ago and he has rehearsed this.
|
||||||
|
id: 'eighteenToday',
|
||||||
|
window: [60, 190],
|
||||||
|
archetype: 'fresh18',
|
||||||
|
dress(patron, rng) {
|
||||||
|
const s = script('eighteenToday');
|
||||||
|
baseDress(patron, rng, s);
|
||||||
|
clearContraband(patron);
|
||||||
|
patron.intoxication = intoxicationIn('sober', rng);
|
||||||
|
},
|
||||||
|
beats: beatsOf('eighteenToday'),
|
||||||
|
// No numbers either way. He is a clean legal admit and judge() already pays
|
||||||
|
// the ordinary rate; denying him is the conscience test judgeDeny narrates.
|
||||||
|
onAdmit: { note: script('eighteenToday').admitNote },
|
||||||
|
onDeny: { note: script('eighteenToday').denyNote },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Kayden has been making promises again.
|
||||||
|
id: 'kaydensMate',
|
||||||
|
window: [120, 250],
|
||||||
|
archetype: 'punter',
|
||||||
|
dress(patron, rng) {
|
||||||
|
const s = script('kaydensMate');
|
||||||
|
baseDress(patron, rng, s);
|
||||||
|
clearContraband(patron);
|
||||||
|
patron.intoxication = intoxicationIn('tipsy', rng);
|
||||||
|
},
|
||||||
|
beats: beatsOf('kaydensMate'),
|
||||||
|
onAdmit: {
|
||||||
|
// Small and real: you honoured an authority that does not exist.
|
||||||
|
vibeDelta: -1,
|
||||||
|
dazzaText: script('kaydensMate').admitDazza,
|
||||||
|
note: script('kaydensMate').admitNote,
|
||||||
|
},
|
||||||
|
onDeny: { note: script('kaydensMate').denyNote },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The buck's straggler. Sober-adjacent, desperate to be present.
|
||||||
|
id: 'bigNightOut',
|
||||||
|
window: [180, 300],
|
||||||
|
archetype: 'punter',
|
||||||
|
dress(patron, rng) {
|
||||||
|
const s = script('bigNightOut');
|
||||||
|
baseDress(patron, rng, s);
|
||||||
|
clearContraband(patron);
|
||||||
|
// 'loose' on two lemonades and a recovery: the tells are real even if his
|
||||||
|
// story is true. RSA does not care about the wedding.
|
||||||
|
patron.intoxication = intoxicationIn('loose', rng);
|
||||||
|
},
|
||||||
|
beats: beatsOf('bigNightOut'),
|
||||||
|
onAdmit: {
|
||||||
|
// The floor prices a loose admission already (judge ripen); this is the
|
||||||
|
// chant getting his name in it.
|
||||||
|
vibeDelta: 1,
|
||||||
|
note: script('bigNightOut').admitNote,
|
||||||
|
},
|
||||||
|
onDeny: { note: script('bigNightOut').denyNote },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Engaged tonight, still holding hands, fully briefed on the rule.
|
||||||
|
id: 'twoOfThem',
|
||||||
|
window: [240, 345],
|
||||||
|
archetype: 'punter',
|
||||||
|
dress(patron, rng) {
|
||||||
|
const s = script('twoOfThem');
|
||||||
|
baseDress(patron, rng, s);
|
||||||
|
clearContraband(patron);
|
||||||
|
patron.intoxication = intoxicationIn('tipsy', rng);
|
||||||
|
// A live noHandHolders breach the whole window (the rule survives the cap
|
||||||
|
// from 200 onward). judge() prices the admit; the beats price the deny.
|
||||||
|
patron.flags.handHolding = true;
|
||||||
|
},
|
||||||
|
beats: beatsOf('twoOfThem'),
|
||||||
|
onAdmit: { note: script('twoOfThem').admitNote },
|
||||||
|
onDeny: { note: script('twoOfThem').denyNote },
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
// ---- variant plumbing --------------------------------------------------------
|
||||||
|
|
||||||
|
/** The static outcome for a coin-flip encounter's variant, notes swapped in. */
|
||||||
|
function variantOutcome(
|
||||||
|
id: EncounterId,
|
||||||
|
variant: string,
|
||||||
|
verdict: 'admit' | 'deny',
|
||||||
|
base: EncounterOutcome,
|
||||||
|
): EncounterOutcome {
|
||||||
|
const notes = script(id).variantNotes?.[variant];
|
||||||
|
if (!notes) return base;
|
||||||
|
const note = verdict === 'admit' ? notes.admitNote : notes.denyNote;
|
||||||
|
const dazzaText = verdict === 'admit' ? notes.admitDazza : notes.denyDazza;
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
...(note !== undefined ? { note } : {}),
|
||||||
|
...(dazzaText !== undefined ? { dazzaText } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Twin B, built from twin A after A's verdict. NOT a scripted encounter and not
|
||||||
|
* a fake: his own real name, his own genuine licence, and — because they are
|
||||||
|
* twins — his brother's face. The photo check is the thing on trial.
|
||||||
|
*/
|
||||||
|
export function twinSibling(a: Patron): Patron {
|
||||||
|
const names = script('twins').names;
|
||||||
|
const other = names.find((n) => n !== a.idCard.name) ?? names[0]!;
|
||||||
|
return {
|
||||||
|
...a,
|
||||||
|
id: `${a.id}b`,
|
||||||
|
idCard: { ...a.idCard, name: other, photoSeed: a.dollSeed },
|
||||||
|
flags: {},
|
||||||
|
outfit: a.outfit.map((l) => ({ ...l })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const NEW_ENCOUNTERS: readonly ScriptedEncounter[] = [
|
||||||
|
{
|
||||||
|
// The ID game's scariest bluff. The badge is theatre either way — the
|
||||||
|
// LICENCE knows which man this is (the costume shop does licences too).
|
||||||
|
id: 'offDutyCop',
|
||||||
|
window: [140, 280],
|
||||||
|
archetype: 'punter',
|
||||||
|
dress(patron, rng) {
|
||||||
|
const s = script('offDutyCop');
|
||||||
|
baseDress(patron, rng, s);
|
||||||
|
clearContraband(patron);
|
||||||
|
patron.intoxication = intoxicationIn('sober', rng);
|
||||||
|
patron.age = 38;
|
||||||
|
patron.flags.encounterVariant = rng.chance(0.5) ? 'real' : 'fake';
|
||||||
|
if (patron.flags.encounterVariant === 'fake') {
|
||||||
|
patron.idCard.fake = { wrongHologram: true };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beats: beatsOf('offDutyCop'),
|
||||||
|
// Static tables are the 'real' variant; resolve() swaps per-coin.
|
||||||
|
onAdmit: {
|
||||||
|
heatStrike: { reason: "off-duty officer noted the venue's breaches from the floor", deferred: true },
|
||||||
|
note: script('offDutyCop').variantNotes!.real!.admitNote!,
|
||||||
|
},
|
||||||
|
onDeny: { aggroDelta: -2, note: script('offDutyCop').variantNotes!.real!.denyNote! },
|
||||||
|
resolve(patron, verdict) {
|
||||||
|
const v = patron.flags.encounterVariant ?? 'real';
|
||||||
|
if (v === 'real') {
|
||||||
|
return variantOutcome('offDutyCop', 'real', verdict, verdict === 'admit' ? this.onAdmit : this.onDeny);
|
||||||
|
}
|
||||||
|
// The $30 badge: a dickhead, not a danger. His fake licence already
|
||||||
|
// justified the deny; admitting him is only ever embarrassing.
|
||||||
|
return variantOutcome('offDutyCop', 'fake', verdict, verdict === 'admit' ? { vibeDelta: 1 } : {});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Cap low, sunnies on. Deny: nothing, ever. Admit: a coin you cannot see.
|
||||||
|
id: 'celebrity',
|
||||||
|
window: [180, 320],
|
||||||
|
archetype: 'punter',
|
||||||
|
dress(patron, rng) {
|
||||||
|
const s = script('celebrity');
|
||||||
|
baseDress(patron, rng, s);
|
||||||
|
clearContraband(patron);
|
||||||
|
patron.intoxication = intoxicationIn('tipsy', rng);
|
||||||
|
const acc = patron.outfit.find((l) => l.slot === 'accessory');
|
||||||
|
if (acc) {
|
||||||
|
acc.type = 'sunnies';
|
||||||
|
acc.colour = 'black';
|
||||||
|
delete acc.logo;
|
||||||
|
}
|
||||||
|
patron.flags.encounterVariant = rng.chance(0.5) ? 'rocket' : 'paps';
|
||||||
|
},
|
||||||
|
beats: beatsOf('celebrity'),
|
||||||
|
onAdmit: {
|
||||||
|
deferredHits: [{ afterMin: [8, 25], vibeDelta: 10, reason: 'the whole floor realises at once who that is' }],
|
||||||
|
note: script('celebrity').variantNotes!.rocket!.admitNote!,
|
||||||
|
},
|
||||||
|
onDeny: { note: script('celebrity').denyNote! },
|
||||||
|
resolve(patron, verdict) {
|
||||||
|
if (verdict === 'deny') return this.onDeny;
|
||||||
|
const v = patron.flags.encounterVariant ?? 'rocket';
|
||||||
|
if (v === 'rocket') return this.onAdmit;
|
||||||
|
return variantOutcome('celebrity', 'paps', 'admit', {
|
||||||
|
deferredHits: [{ afterMin: [5, 15], aggroDelta: 12, reason: 'a pap swarm sets up across the road' }],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The aggro engine in a polo. His licence is a laminated business card —
|
||||||
|
// fake tells are load-bearing: the desk can SEE this is not a document.
|
||||||
|
id: 'sovereign',
|
||||||
|
window: [100, 260],
|
||||||
|
archetype: 'punter',
|
||||||
|
dress(patron, rng) {
|
||||||
|
const s = script('sovereign');
|
||||||
|
baseDress(patron, rng, s);
|
||||||
|
clearContraband(patron);
|
||||||
|
patron.intoxication = intoxicationIn('sober', rng);
|
||||||
|
patron.age = 46;
|
||||||
|
patron.idCard.fake = { jokeName: true, peelingLaminate: true };
|
||||||
|
},
|
||||||
|
beats: beatsOf('sovereign'),
|
||||||
|
onAdmit: {
|
||||||
|
vibeDelta: -2,
|
||||||
|
deferredHits: [{ afterMin: [10, 30], aggroDelta: 5, reason: 'he is explaining maritime law to the dance floor' }],
|
||||||
|
note: script('sovereign').admitNote!,
|
||||||
|
},
|
||||||
|
onDeny: { aggroDelta: 5, note: script('sovereign').denyNote! },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// "delivery for DJ". The DJ did not order food. OR DID HE.
|
||||||
|
id: 'deliveryRider',
|
||||||
|
window: [60, 330],
|
||||||
|
archetype: 'punter',
|
||||||
|
dress(patron, rng) {
|
||||||
|
const s = script('deliveryRider');
|
||||||
|
baseDress(patron, rng, s);
|
||||||
|
clearContraband(patron);
|
||||||
|
patron.intoxication = intoxicationIn('sober', rng);
|
||||||
|
patron.flags.encounterVariant = rng.chance(0.5) ? 'ordered' : 'not';
|
||||||
|
},
|
||||||
|
beats: beatsOf('deliveryRider'),
|
||||||
|
onAdmit: {
|
||||||
|
deferredHits: [{ afterMin: [3, 8], vibeDelta: 4, reason: 'the booth eats; the next hour of the mix has a spine' }],
|
||||||
|
note: script('deliveryRider').variantNotes!.ordered!.admitNote!,
|
||||||
|
},
|
||||||
|
onDeny: { note: script('deliveryRider').variantNotes!.not!.denyNote! },
|
||||||
|
resolve(patron, verdict) {
|
||||||
|
const v = patron.flags.encounterVariant ?? 'not';
|
||||||
|
if (v === 'ordered') {
|
||||||
|
if (verdict === 'admit') return this.onAdmit;
|
||||||
|
return variantOutcome('deliveryRider', 'ordered', 'deny', {
|
||||||
|
deferredHits: [{ afterMin: [5, 12], vibeDelta: -3, reason: 'a hungry DJ plays like one' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return variantOutcome('deliveryRider', 'not', verdict, verdict === 'admit' ? { vibeDelta: -1 } : {});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Two clipboards enter, one leaves.
|
||||||
|
id: 'promoter',
|
||||||
|
window: [80, 200],
|
||||||
|
archetype: 'financeBro',
|
||||||
|
dress(patron, rng) {
|
||||||
|
const s = script('promoter');
|
||||||
|
baseDress(patron, rng, s);
|
||||||
|
clearContraband(patron);
|
||||||
|
patron.intoxication = intoxicationIn('sober', rng);
|
||||||
|
},
|
||||||
|
beats: beatsOf('promoter'),
|
||||||
|
onAdmit: {
|
||||||
|
vibeDelta: 2,
|
||||||
|
deferredHits: [{ afterMin: [15, 40], aggroDelta: 6, reason: "wristbanded strangers are citing 'tyson' at the rope" }],
|
||||||
|
dazzaText: script('promoter').admitDazza,
|
||||||
|
note: script('promoter').admitNote!,
|
||||||
|
},
|
||||||
|
onDeny: { aggroDelta: 2, note: script('promoter').denyNote! },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The only patron all night who wants LESS club. Send her away and the
|
||||||
|
// council takes her call; bring her in and the room feels it. §4.3 exactly.
|
||||||
|
id: 'neighbour',
|
||||||
|
window: [250, 340],
|
||||||
|
archetype: 'punter',
|
||||||
|
dress(patron, rng) {
|
||||||
|
const s = script('neighbour');
|
||||||
|
baseDress(patron, rng, s);
|
||||||
|
clearContraband(patron);
|
||||||
|
patron.intoxication = intoxicationIn('sober', rng);
|
||||||
|
patron.age = 57;
|
||||||
|
patron.idCard.dob = '1969-03-14';
|
||||||
|
},
|
||||||
|
beats: beatsOf('neighbour'),
|
||||||
|
onAdmit: { vibeDelta: -3, note: script('neighbour').admitNote! },
|
||||||
|
onDeny: {
|
||||||
|
heatStrike: { reason: 'noise complaint escalated to council — the inspector took the call', deferred: true },
|
||||||
|
note: script('neighbour').denyNote!,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Seven matching sashes, wrong venue, right energy.
|
||||||
|
id: 'hensParty',
|
||||||
|
window: [120, 280],
|
||||||
|
archetype: 'punter',
|
||||||
|
dress(patron, rng) {
|
||||||
|
const s = script('hensParty');
|
||||||
|
baseDress(patron, rng, s);
|
||||||
|
clearContraband(patron);
|
||||||
|
patron.intoxication = intoxicationIn('tipsy', rng);
|
||||||
|
},
|
||||||
|
beats: beatsOf('hensParty'),
|
||||||
|
onAdmit: {
|
||||||
|
deferredHits: [
|
||||||
|
{ afterMin: [10, 45], vibeDelta: 8, reason: 'the practised dance detonates on the floor' },
|
||||||
|
{ afterMin: [46, 80], aggroDelta: 5, reason: 'seven sashes at one bar is a supply problem' },
|
||||||
|
],
|
||||||
|
note: script('hensParty').admitNote!,
|
||||||
|
},
|
||||||
|
onDeny: { note: script('hensParty').denyNote! },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Genuinely twins. Twin B is injected by the queue after A's verdict —
|
||||||
|
// a plain, un-scripted patron with his brother's face and his own name.
|
||||||
|
id: 'twins',
|
||||||
|
window: [90, 250],
|
||||||
|
archetype: 'punter',
|
||||||
|
dress(patron, rng) {
|
||||||
|
const s = script('twins');
|
||||||
|
baseDress(patron, rng, s);
|
||||||
|
clearContraband(patron);
|
||||||
|
patron.intoxication = intoxicationIn('tipsy', rng);
|
||||||
|
},
|
||||||
|
beats: beatsOf('twins'),
|
||||||
|
onAdmit: { note: script('twins').admitNote! },
|
||||||
|
onDeny: { note: script('twins').denyNote! },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The live tutorial character. Whatever you rule, he ends up by the
|
||||||
|
// planter, and the next few patrons come with a tip that is right ~80% of
|
||||||
|
// the time. Fourteen years of doors.
|
||||||
|
id: 'exDoorman',
|
||||||
|
window: [60, 220],
|
||||||
|
archetype: 'regular',
|
||||||
|
dress(patron, rng) {
|
||||||
|
const s = script('exDoorman');
|
||||||
|
baseDress(patron, rng, s);
|
||||||
|
clearContraband(patron);
|
||||||
|
patron.intoxication = intoxicationIn('sober', rng);
|
||||||
|
patron.age = 49;
|
||||||
|
},
|
||||||
|
beats: beatsOf('exDoorman'),
|
||||||
|
onAdmit: { note: script('exDoorman').admitNote! },
|
||||||
|
onDeny: { note: script('exDoorman').denyNote! },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The full table: the original eight plus the second wave. Order is cosmetic —
|
||||||
|
* the scheduler shuffles — but stable ids matter (cross-night seenEncounters). */
|
||||||
|
export const ENCOUNTERS: readonly ScriptedEncounter[] = [...ENCOUNTERS_BASE, ...NEW_ENCOUNTERS];
|
||||||
|
|
||||||
for (const id of Object.keys(ENCOUNTER_SCRIPTS)) {
|
for (const id of Object.keys(ENCOUNTER_SCRIPTS)) {
|
||||||
if (!ENCOUNTERS.some((e) => e.id === id)) {
|
if (!ENCOUNTERS.some((e) => e.id === id)) {
|
||||||
throw new Error(`encounters: dialogue script '${id}' belongs to no encounter`);
|
throw new Error(`encounters: dialogue script '${id}' belongs to no encounter`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// scheduleEncounters must never move an encounter outside its own window, so the
|
// With eight overlapping windows a whole-set worst-case chain no longer fits in
|
||||||
// windows themselves have to admit a legal layout for the WHOLE set — any subset
|
// one night (that guarantee died when the table outgrew a single night, by
|
||||||
// is strictly easier. Checked at load, where a bad window edit is a boot failure
|
// design — a week should not see the same four people). The load-time check now
|
||||||
// rather than a night that quietly drops its third encounter.
|
// guards each window's own sanity; the scheduler handles infeasible DRAWS by
|
||||||
function assertWindowsSchedulable(): void {
|
// skipping the pick (a 2-encounter night beats a crashed one).
|
||||||
let earliest = Number.NEGATIVE_INFINITY;
|
function assertWindowsSane(): void {
|
||||||
for (const e of [...ENCOUNTERS].sort((a, b) => a.window[0] - b.window[0])) {
|
for (const e of ENCOUNTERS) {
|
||||||
if (Math.max(e.window[0], earliest) > e.window[1]) {
|
if (e.window[0] >= e.window[1] || e.window[0] < 0 || e.window[1] > 350) {
|
||||||
throw new Error(`encounters: window for '${e.id}' cannot clear ${ENCOUNTER_SPACING_MIN} min`);
|
throw new Error(`encounters: window for '${e.id}' is not a usable night interval`);
|
||||||
}
|
}
|
||||||
// Worst case: the previous encounter landed at the very end of its window.
|
|
||||||
earliest = e.window[1] + ENCOUNTER_SPACING_MIN;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assertWindowsSchedulable();
|
assertWindowsSane();
|
||||||
|
|
||||||
export function encounterById(id: EncounterId): ScriptedEncounter | undefined {
|
export function encounterById(id: EncounterId): ScriptedEncounter | undefined {
|
||||||
return ENCOUNTERS.find((e) => e.id === id);
|
return ENCOUNTERS.find((e) => e.id === id);
|
||||||
@ -297,28 +657,61 @@ export function encounterById(id: EncounterId): ScriptedEncounter | undefined {
|
|||||||
export function scheduleEncounters(
|
export function scheduleEncounters(
|
||||||
rng: RngStream,
|
rng: RngStream,
|
||||||
count: number,
|
count: number,
|
||||||
|
/** Encounter ids already met this run — a week should meet new people first. */
|
||||||
|
exclude: ReadonlySet<string> = new Set(),
|
||||||
): Array<{ atMin: number; id: EncounterId }> {
|
): Array<{ atMin: number; id: EncounterId }> {
|
||||||
const wanted = Math.min(Math.max(0, Math.floor(count)), ENCOUNTERS.length);
|
const wanted = Math.min(Math.max(0, Math.floor(count)), ENCOUNTERS.length);
|
||||||
if (wanted === 0) return [];
|
if (wanted === 0) return [];
|
||||||
|
|
||||||
const pool = [...ENCOUNTERS];
|
const shuffle = (arr: ScriptedEncounter[]): ScriptedEncounter[] => {
|
||||||
for (let i = pool.length - 1; i > 0; i--) {
|
for (let i = arr.length - 1; i > 0; i--) {
|
||||||
const j = rng.int(0, i);
|
const j = rng.int(0, i);
|
||||||
const a = pool[i]!;
|
const a = arr[i]!;
|
||||||
pool[i] = pool[j]!;
|
arr[i] = arr[j]!;
|
||||||
pool[j] = a;
|
arr[j] = a;
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fresh faces first; once the run has met everyone, repeats are honest
|
||||||
|
// (a regular being a regular), so the excluded set tops the pool back up.
|
||||||
|
const fresh = shuffle(ENCOUNTERS.filter((e) => !exclude.has(e.id)));
|
||||||
|
const met = shuffle(ENCOUNTERS.filter((e) => exclude.has(e.id)));
|
||||||
|
const pool = [...fresh, ...met];
|
||||||
|
|
||||||
|
// Three passes, so a random draw can never strand a later pick:
|
||||||
|
// 1. end-order greedy at the EARLIEST legal minute — anything that fails
|
||||||
|
// here genuinely does not fit tonight and is dropped (a 2-encounter
|
||||||
|
// night beats a crashed one);
|
||||||
|
// 2. a backward pass computing each survivor's latest SAFE minute (its own
|
||||||
|
// window end, capped by the next pick's latest minute less the spacing);
|
||||||
|
// 3. a forward pass drawing the actual minute inside [earliest, latestSafe]
|
||||||
|
// — random where there is room, exact where there is not.
|
||||||
|
const chosen = pool.slice(0, wanted).sort((a, b) => a.window[1] - b.window[1]);
|
||||||
|
|
||||||
|
const placed: ScriptedEncounter[] = [];
|
||||||
|
let probe = Number.NEGATIVE_INFINITY;
|
||||||
|
for (const e of chosen) {
|
||||||
|
const lo = Math.max(e.window[0], probe);
|
||||||
|
if (lo > e.window[1]) continue;
|
||||||
|
placed.push(e);
|
||||||
|
probe = lo + ENCOUNTER_SPACING_MIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest: number[] = new Array(placed.length);
|
||||||
|
for (let i = placed.length - 1; i >= 0; i--) {
|
||||||
|
const cap = i === placed.length - 1 ? Infinity : latest[i + 1]! - ENCOUNTER_SPACING_MIN;
|
||||||
|
latest[i] = Math.min(placed[i]!.window[1], cap);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Placed in window order, each pushed clear of the one before. Because the
|
|
||||||
// windows are staggered wider than the spacing (asserted above), the lower
|
|
||||||
// bound can never overrun a window's end.
|
|
||||||
const chosen = pool.slice(0, wanted).sort((a, b) => a.window[0] - b.window[0]);
|
|
||||||
const out: Array<{ atMin: number; id: EncounterId }> = [];
|
const out: Array<{ atMin: number; id: EncounterId }> = [];
|
||||||
let earliest = Number.NEGATIVE_INFINITY;
|
let earliest = Number.NEGATIVE_INFINITY;
|
||||||
for (const e of chosen) {
|
for (let i = 0; i < placed.length; i++) {
|
||||||
const atMin = rng.int(Math.max(e.window[0], earliest), e.window[1]);
|
const e = placed[i]!;
|
||||||
|
const lo = Math.max(e.window[0], earliest);
|
||||||
|
const atMin = rng.int(lo, Math.max(lo, latest[i]!));
|
||||||
out.push({ atMin, id: e.id });
|
out.push({ atMin, id: e.id });
|
||||||
earliest = atMin + ENCOUNTER_SPACING_MIN;
|
earliest = atMin + ENCOUNTER_SPACING_MIN;
|
||||||
}
|
}
|
||||||
return out;
|
return out.sort((a, b) => a.atMin - b.atMin);
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
14
src/rules/heat.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import type { HeatStrike, NightState } from '../data/types';
|
||||||
|
|
||||||
|
/** CONTRACTS.md §3: heatStrikes is "run-scoped, max 3". Design §2: 3 = licence gone. */
|
||||||
|
export const MAX_HEAT_STRIKES = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a strike is worth recording. Two guards, both contract-driven:
|
||||||
|
* past three the run is already over, and a breach that persists (over capacity
|
||||||
|
* for an hour) is ONE offence the inspector writes up, not one per patron.
|
||||||
|
*/
|
||||||
|
export function shouldRecordStrike(state: NightState, strike: HeatStrike): boolean {
|
||||||
|
if (state.heatStrikes.length >= MAX_HEAT_STRIKES) return false;
|
||||||
|
return !state.heatStrikes.some((s) => s.reason === strike.reason);
|
||||||
|
}
|
||||||
@ -17,6 +17,7 @@ const TELL_ORDER = [
|
|||||||
'jokeName',
|
'jokeName',
|
||||||
'photoMismatch',
|
'photoMismatch',
|
||||||
'expired',
|
'expired',
|
||||||
|
'passback',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type IdTell = (typeof TELL_ORDER)[number];
|
export type IdTell = (typeof TELL_ORDER)[number];
|
||||||
@ -62,6 +63,9 @@ export function idVerdict(id: IdCard, nightDate: Date): IdVerdict {
|
|||||||
// The generator ships fakes whose ONLY tell is the date, with no fake flags
|
// The generator ships fakes whose ONLY tell is the date, with no fake flags
|
||||||
// set at all. Deriving this here is what makes those catchable.
|
// set at all. Deriving this here is what makes those catchable.
|
||||||
expired,
|
expired,
|
||||||
|
// Set by the DOOR, not the generator: the desk remembers every card it has
|
||||||
|
// admitted tonight, and this card has been through once already.
|
||||||
|
passback: flags?.passback === true,
|
||||||
};
|
};
|
||||||
const tells = TELL_ORDER.filter((t) => present[t]);
|
const tells = TELL_ORDER.filter((t) => present[t]);
|
||||||
|
|
||||||
|
|||||||
@ -68,6 +68,9 @@ export const REPORT_TUNING = {
|
|||||||
// band, never a fixed running order.
|
// band, never a fixed running order.
|
||||||
interest: {
|
interest: {
|
||||||
deferred: 10, // a breach the audit already found — the awkward one
|
deferred: 10, // a breach the audit already found — the awkward one
|
||||||
|
jointPocketed: 9, // the one question you really don't want asked
|
||||||
|
jointBinned: 3,
|
||||||
|
jointIgnored: 5,
|
||||||
ejection: 8,
|
ejection: 8,
|
||||||
maggotUnhandled: 7,
|
maggotUnhandled: 7,
|
||||||
kayden: 6, // his mistake, your signature
|
kayden: 6, // his mistake, your signature
|
||||||
@ -137,6 +140,30 @@ const BANKS: Record<string, AccountBank> = {
|
|||||||
{ id: 'maggot.soft2', text: 'Patron was seated and stable. No intervention was assessed as necessary at that point.', truthful: false },
|
{ id: 'maggot.soft2', text: 'Patron was seated and stable. No intervention was assessed as necessary at that point.', truthful: false },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
jointPocketed: {
|
||||||
|
prompt: 'Contraband was located in the smoking area. Where is it now?',
|
||||||
|
options: [
|
||||||
|
{ id: 'jointP.true', text: 'On me. I took possession and I still have possession. That is the account.', truthful: true },
|
||||||
|
{ id: 'jointP.soft1', text: 'Item was destroyed on the spot in view of the patron.', truthful: false },
|
||||||
|
{ id: 'jointP.soft2', text: 'Item was placed in the amnesty bin per procedure. The bin was emptied by close.', truthful: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
jointBinned: {
|
||||||
|
prompt: 'Contraband was located in the smoking area. Your account?',
|
||||||
|
options: [
|
||||||
|
{ id: 'jointB.true', text: 'Confiscated and binned in front of them. The yard went quiet for a bit.', truthful: true },
|
||||||
|
{ id: 'jointB.soft1', text: 'Patron surrendered the item voluntarily and thanked security for the chat.', truthful: false },
|
||||||
|
{ id: 'jointB.soft2', text: 'Item was assessed as herbal cigarettes. Binned as general litter.', truthful: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
jointIgnored: {
|
||||||
|
prompt: 'A smell was reported near the smoking area. Your account?',
|
||||||
|
options: [
|
||||||
|
{ id: 'jointI.true', text: 'I smelled it, I saw it, and I decided the fence line was more interesting.', truthful: true },
|
||||||
|
{ id: 'jointI.soft1', text: 'Area was patrolled at the time stated. Nothing of note was observed.', truthful: false },
|
||||||
|
{ id: 'jointI.soft2', text: 'Smell was traced to the kebab shop exhaust across the road.', truthful: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
kayden: {
|
kayden: {
|
||||||
prompt: 'A ruling was made at the rope while you were inside. Your account?',
|
prompt: 'A ruling was made at the rope while you were inside. Your account?',
|
||||||
options: [
|
options: [
|
||||||
|
|||||||
@ -15,14 +15,24 @@ import type { Patron, Verdict } from '../data/types';
|
|||||||
// pays vibe, it just costs far more aggro than denying a real violator does.
|
// pays vibe, it just costs far more aggro than denying a real violator does.
|
||||||
// Arbitrary power is rewarded and expensive at the same time. That is the game.
|
// Arbitrary power is rewarded and expensive at the same time. That is the game.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 1:30 AM lockout (clock minute 270). Queensland's actual gift to game
|
||||||
|
* design: after this minute, NEW admissions are against the LAW — the licence
|
||||||
|
* is on the line, the queue does not care, and Dazza cares in both directions.
|
||||||
|
* Denials after lockout are always justified; admissions file a strike.
|
||||||
|
*/
|
||||||
|
export const LOCKOUT_MIN = 270;
|
||||||
|
|
||||||
export const JUDGE_TUNING = {
|
export const JUDGE_TUNING = {
|
||||||
/** Deny someone who genuinely broke something. The job, done right. */
|
/** Deny someone who genuinely broke something. The job, done right — and
|
||||||
denyViolatorVibe: 6,
|
* routine. The room expects it; the payoff is small (tuning pass 2026-07-19:
|
||||||
|
* was 6, which made denial the vibe engine and emptied the room). */
|
||||||
|
denyViolatorVibe: 2,
|
||||||
/** The queue still watched a stranger get humiliated. */
|
/** The queue still watched a stranger get humiliated. */
|
||||||
denyViolatorAggro: 1,
|
denyViolatorAggro: 1,
|
||||||
|
|
||||||
/** Deny someone with nothing wrong with them. Small reward... */
|
/** Deny someone with nothing wrong with them. Small reward... */
|
||||||
denyCleanVibe: 2,
|
denyCleanVibe: 1,
|
||||||
/** ...large heat. The whole line saw that there was no reason. */
|
/** ...large heat. The whole line saw that there was no reason. */
|
||||||
denyCleanAggro: 7,
|
denyCleanAggro: 7,
|
||||||
|
|
||||||
@ -37,12 +47,12 @@ export const JUDGE_TUNING = {
|
|||||||
/** A regular you have knocked back before. He kept count. */
|
/** A regular you have knocked back before. He kept count. */
|
||||||
denyGrudgedRegularVibe: -5,
|
denyGrudgedRegularVibe: -5,
|
||||||
|
|
||||||
admitCleanVibe: 3,
|
admitCleanVibe: 2,
|
||||||
admitCleanAggro: -3,
|
admitCleanAggro: -3,
|
||||||
/** Waving a violator through moves the queue too, just less — you hesitated. */
|
/** Waving a violator through moves the queue too, just less — you hesitated. */
|
||||||
admitViolatorAggro: -2,
|
admitViolatorAggro: -2,
|
||||||
/** The trap: a dud ID admitted still reads to the queue as a fast door. */
|
/** The trap: a dud ID admitted still reads to the queue as a fast door. */
|
||||||
admitBadIdVibe: 2,
|
admitBadIdVibe: 1,
|
||||||
|
|
||||||
/** "Dazza does a lap": the breach lands 2..5 minutes after you could argue. */
|
/** "Dazza does a lap": the breach lands 2..5 minutes after you could argue. */
|
||||||
lapDelayMin: 2,
|
lapDelayMin: 2,
|
||||||
@ -104,10 +114,15 @@ export function judge(patron: Patron, verdict: Verdict, ctx: JudgeContext): Door
|
|||||||
// verb (bribe, guest-list override) an else-branch would silently score it as
|
// verb (bribe, guest-list override) an else-branch would silently score it as
|
||||||
// an ADMIT — filing heat strikes and capacity breaches for a call the player
|
// an ADMIT — filing heat strikes and capacity breaches for a call the player
|
||||||
// never made. Falling through to deny is inert by comparison.
|
// never made. Falling through to deny is inert by comparison.
|
||||||
return verdict === 'admit' ? judgeAdmit(patron, broken, id, ctx) : judgeDeny(patron, broken, id);
|
return verdict === 'admit' ? judgeAdmit(patron, broken, id, ctx) : judgeDeny(patron, broken, id, ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
function judgeDeny(patron: Patron, broken: DoorDressCodeRule[], id: IdVerdict): DoorOutcome {
|
function judgeDeny(
|
||||||
|
patron: Patron,
|
||||||
|
broken: DoorDressCodeRule[],
|
||||||
|
id: IdVerdict,
|
||||||
|
ctx: JudgeContext,
|
||||||
|
): DoorOutcome {
|
||||||
const T = JUDGE_TUNING;
|
const T = JUDGE_TUNING;
|
||||||
|
|
||||||
// Early return on purpose. Whatever else was wrong with him, Dazza only ever
|
// Early return on purpose. Whatever else was wrong with him, Dazza only ever
|
||||||
@ -138,7 +153,9 @@ function judgeDeny(patron: Patron, broken: DoorDressCodeRule[], id: IdVerdict):
|
|||||||
id.looksFake ||
|
id.looksFake ||
|
||||||
patron.age < 18 ||
|
patron.age < 18 ||
|
||||||
tooDrunkToServe ||
|
tooDrunkToServe ||
|
||||||
seriousContraband;
|
seriousContraband ||
|
||||||
|
// After lockout, "no" is the law — it is never arbitrary again tonight.
|
||||||
|
ctx.clockMin >= LOCKOUT_MIN;
|
||||||
|
|
||||||
let vibeDelta = justified ? T.denyViolatorVibe : T.denyCleanVibe;
|
let vibeDelta = justified ? T.denyViolatorVibe : T.denyCleanVibe;
|
||||||
let aggroDelta = justified ? T.denyViolatorAggro : T.denyCleanAggro;
|
let aggroDelta = justified ? T.denyViolatorAggro : T.denyCleanAggro;
|
||||||
@ -254,12 +271,28 @@ function judgeAdmit(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctx.insideCount >= ctx.licensed) {
|
if (ctx.clockMin >= LOCKOUT_MIN) {
|
||||||
const reason = `capacity breach — ${patron.id} admitted at ${ctx.insideCount}/${ctx.licensed}`;
|
// One stable reason: the inspector writes up "traded after lockout" once,
|
||||||
|
// however many you let through. (Letting many through still bleeds vibe
|
||||||
|
// via laps/ripens like any other admit — the strike is the licence part.)
|
||||||
deferred.push({
|
deferred.push({
|
||||||
atClockMin: T.auditClockMin,
|
atClockMin: T.auditClockMin,
|
||||||
heatStrike: { reason, deferred: true },
|
heatStrike: { reason: 'admission after the 1:30 lockout', deferred: true },
|
||||||
reason,
|
reason: `lockout breach — ${patron.id} admitted at clock ${ctx.clockMin}`,
|
||||||
|
patronId: patron.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.insideCount >= ctx.licensed) {
|
||||||
|
// The STRIKE reason is deliberately stable: shouldRecordStrike dedupes by
|
||||||
|
// reason, and running the room over capacity is ONE offence the inspector
|
||||||
|
// writes up, not one per punter (a patron-id in this string meant every
|
||||||
|
// admit past 80 filed its own strike and three admits pulled the licence).
|
||||||
|
// The incident log keeps the per-patron detail.
|
||||||
|
deferred.push({
|
||||||
|
atClockMin: T.auditClockMin,
|
||||||
|
heatStrike: { reason: 'room over licensed capacity', deferred: true },
|
||||||
|
reason: `capacity breach — ${patron.id} admitted at ${ctx.insideCount}/${ctx.licensed}`,
|
||||||
patronId: patron.id,
|
patronId: patron.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
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,20 +1,28 @@
|
|||||||
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 { violations, type DoorDressCodeRule } from '../../rules/dressCode';
|
import { activeRules, announcedRules, shuffleWindow, violations, type DoorDressCodeRule } from '../../rules/dressCode';
|
||||||
|
import { drunkStage } from '../../rules/drunk';
|
||||||
import { idVerdict, formatDate } from '../../rules/idCheck';
|
import { idVerdict, formatDate } from '../../rules/idCheck';
|
||||||
import { judge } from '../../rules/judge';
|
import { LOCKOUT_MIN, judge } from '../../rules/judge';
|
||||||
import type { DoorOutcome } from '../../rules/doorTypes';
|
import type { DoorOutcome } from '../../rules/doorTypes';
|
||||||
import { CONTRABAND } from '../../data/contraband';
|
import { CONTRABAND } from '../../data/contraband';
|
||||||
import type { Patron, Verdict } from '../../data/types';
|
import type { Patron, Verdict } from '../../data/types';
|
||||||
import {
|
import {
|
||||||
ADMIT_REACTIONS,
|
ADMIT_REACTIONS,
|
||||||
|
KEBAB,
|
||||||
DENY_REACTIONS,
|
DENY_REACTIONS,
|
||||||
DOOR_TUTORIAL,
|
DOOR_TUTORIAL,
|
||||||
DOOR_UI,
|
DOOR_UI,
|
||||||
|
BOARD_SHUFFLE_TOASTS,
|
||||||
|
CARPET_UI,
|
||||||
|
FRANKO_TIPS,
|
||||||
PATDOWN_LINES,
|
PATDOWN_LINES,
|
||||||
|
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';
|
||||||
@ -23,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';
|
||||||
|
|
||||||
@ -35,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
|
||||||
@ -67,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;
|
||||||
@ -83,12 +92,31 @@ 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>();
|
||||||
|
/** Every card ADMITTED tonight, name|dob -> clock label. The passback net. */
|
||||||
|
private readonly seenCards = new Map<string, string>();
|
||||||
|
/** Patrons still owed a shouted tip from the planter (ex-doorman aftermath). */
|
||||||
|
private frankoTips = 0;
|
||||||
|
/** The kebab run: Dazza's mid-slam errand. 'pending' once asked, until done or sulked. */
|
||||||
|
private kebabAskAtMin: number | null = null;
|
||||||
|
private kebabPending = false;
|
||||||
|
private kebabHandled = false;
|
||||||
|
private onKebabRun = false;
|
||||||
|
private kebabRunMsLeft = 0;
|
||||||
|
private kebabUi: Phaser.GameObjects.GameObject[] = [];
|
||||||
|
private kebabFill: Phaser.GameObjects.Rectangle | null = null;
|
||||||
|
private kebabGlow: Phaser.GameObjects.Rectangle | null = null;
|
||||||
private venueDoorGlow: Phaser.GameObjects.Rectangle | null = null;
|
private venueDoorGlow: Phaser.GameObjects.Rectangle | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@ -100,19 +128,85 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
create(): void {
|
create(): void {
|
||||||
this.queue = new QueueManager(this.night.bus, this.night.rng, this.night.nightDate);
|
this.queue = new QueueManager(
|
||||||
|
this.night.bus,
|
||||||
|
this.night.rng,
|
||||||
|
this.night.nightDate,
|
||||||
|
this.night.seenEncounters,
|
||||||
|
this.night.venue.arrivalScale,
|
||||||
|
this.night.venue.archetypeWeights,
|
||||||
|
this.night.regulars,
|
||||||
|
);
|
||||||
this.up = new PatronUpView(this, UP_X, UP_FOOT_Y);
|
this.up = new PatronUpView(this, UP_X, UP_FOOT_Y);
|
||||||
this.idCard = new IdCardView(this, this.night.nightDate);
|
this.idCard = new IdCardView(this, this.night.nightDate);
|
||||||
this.sobriety = new SobrietyModal(this);
|
this.sobriety = new SobrietyModal(this);
|
||||||
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.
|
||||||
|
const kebabRng = this.night.rng.stream('kebab');
|
||||||
|
this.kebabAskAtMin = kebabRng.chance(0.65) ? kebabRng.int(140, 260) : null;
|
||||||
|
|
||||||
this.buildStreet();
|
this.buildStreet();
|
||||||
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);
|
||||||
@ -124,7 +218,18 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
// `short`. Narrowing back to the lane's own DoorDressCodeRule is a real
|
// `short`. Narrowing back to the lane's own DoorDressCodeRule is a real
|
||||||
// contract gap (CCR-4) — this predicate is the honest version of it
|
// contract gap (CCR-4) — this predicate is the honest version of it
|
||||||
// rather than an `as never` punch-through.
|
// rather than an `as never` punch-through.
|
||||||
if (isDoorRule(rule)) this.codeCard.add(this, rule, this.elapsedMs);
|
if (isDoorRule(rule)) {
|
||||||
|
this.codeCard.add(this, rule, this.elapsedMs);
|
||||||
|
this.retireStaleRules();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
this.night.bus.on('door:verdict', ({ patron, verdict }) => {
|
||||||
|
if (verdict !== 'admit') return;
|
||||||
|
// The desk remembers the card, whoever waved it through.
|
||||||
|
this.seenCards.set(
|
||||||
|
`${patron.idCard.name}|${patron.idCard.dob}`,
|
||||||
|
this.night.clock.label,
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
this.night.bus.on('heat:strike', () => this.refreshCounters()),
|
this.night.bus.on('heat:strike', () => this.refreshCounters()),
|
||||||
this.night.bus.on('night:phaseChange', ({ location }) => {
|
this.night.bus.on('night:phaseChange', ({ location }) => {
|
||||||
@ -152,7 +257,13 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
// ---------- construction ----------
|
// ---------- construction ----------
|
||||||
|
|
||||||
private buildStreet(): void {
|
private buildStreet(): void {
|
||||||
this.add.rectangle(W / 2, 88, W, 176, 0x0a0a12); // wall of night
|
if (this.textures.exists('gen:prop:street_backdrop')) {
|
||||||
|
// The generated plate (SPACES §5 item 11): brick, posters, grime. Sits
|
||||||
|
// where the flat wall-of-night rect went; everything else layers on top.
|
||||||
|
this.add.image(0, 0, 'gen:prop:street_backdrop').setOrigin(0, 0).setDisplaySize(W, 176).setAlpha(0.9);
|
||||||
|
} else {
|
||||||
|
this.add.rectangle(W / 2, 88, W, 176, 0x0a0a12); // wall of night
|
||||||
|
}
|
||||||
this.add.rectangle(W / 2, 212, W, 76, 0x1a1a24); // footpath
|
this.add.rectangle(W / 2, 212, W, 76, 0x1a1a24); // footpath
|
||||||
this.add.rectangle(W / 2, 176, W, 2, 0x2a2a38);
|
this.add.rectangle(W / 2, 176, W, 2, 0x2a2a38);
|
||||||
|
|
||||||
@ -161,6 +272,9 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
this.add.rectangle(62, 88, 96, 18, DOOR_PALETTE.kebab, 0.9);
|
this.add.rectangle(62, 88, 96, 18, DOOR_PALETTE.kebab, 0.9);
|
||||||
this.add.text(28, 82, 'KEBABS', { fontFamily: MONO, fontSize: '12px', color: '#3a2404' });
|
this.add.text(28, 82, 'KEBABS', { fontFamily: MONO, fontSize: '12px', color: '#3a2404' });
|
||||||
this.add.rectangle(62, 148, 92, 46, 0xe8c060, 0.12);
|
this.add.rectangle(62, 148, 92, 46, 0xe8c060, 0.12);
|
||||||
|
// The whole shopfront becomes a button when Dazza wants feeding.
|
||||||
|
this.kebabGlow = this.add.rectangle(62, 120, 112, 106, 0xe8c060, 0).setInteractive();
|
||||||
|
this.kebabGlow.on('pointerdown', () => this.startKebabRun());
|
||||||
this.add.rectangle(62, 214, 96, 8, DOOR_PALETTE.kebab, 0.13);
|
this.add.rectangle(62, 214, 96, 8, DOOR_PALETTE.kebab, 0.13);
|
||||||
|
|
||||||
// the venue
|
// the venue
|
||||||
@ -185,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();
|
||||||
}
|
}
|
||||||
@ -231,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();
|
||||||
|
|
||||||
@ -272,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(),
|
||||||
@ -374,10 +515,27 @@ 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 —
|
||||||
|
// it is the DESK's memory, not the generator's — so Kayden (who never
|
||||||
|
// checks) waves the same card through all night.
|
||||||
|
if (this.seenCards.has(`${p.idCard.name}|${p.idCard.dob}`)) {
|
||||||
|
p.idCard.fake = { ...(p.idCard.fake ?? {}), passback: true };
|
||||||
|
}
|
||||||
this.night.sfx?.play('ropeUnhook');
|
this.night.sfx?.play('ropeUnhook');
|
||||||
this.up.show(p, this.queue.encounterFor(p));
|
this.up.show(p, this.queue.encounterFor(p));
|
||||||
|
if (this.frankoTips > 0 && this.queue.encounterFor(p) === undefined) {
|
||||||
|
this.frankoTips--;
|
||||||
|
const tip = this.frankoTip(p);
|
||||||
|
this.time.delayedCall(1700, () => this.showToast(tip));
|
||||||
|
}
|
||||||
this.showTray(p);
|
this.showTray(p);
|
||||||
this.setVerdictButtons(true);
|
this.setVerdictButtons(true);
|
||||||
// Only a claim puts a name on the clipboard. Someone who says nothing is
|
// Only a claim puts a name on the clipboard. Someone who says nothing is
|
||||||
@ -393,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];
|
||||||
@ -482,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
|
||||||
@ -546,7 +795,7 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
const id = this.queue.encounterFor(p);
|
const id = this.queue.encounterFor(p);
|
||||||
const enc = id ? encounterById(id) : undefined;
|
const enc = id ? encounterById(id) : undefined;
|
||||||
if (!enc) return;
|
if (!enc) return;
|
||||||
const out = verdict === 'admit' ? enc.onAdmit : enc.onDeny;
|
const out = enc.resolve ? enc.resolve(p, verdict) : verdict === 'admit' ? enc.onAdmit : enc.onDeny;
|
||||||
|
|
||||||
const delta: { vibe?: number; aggro?: number } = {};
|
const delta: { vibe?: number; aggro?: number } = {};
|
||||||
if (out.vibeDelta) delta.vibe = out.vibeDelta;
|
if (out.vibeDelta) delta.vibe = out.vibeDelta;
|
||||||
@ -561,6 +810,25 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
if (out.dazzaText) this.night.bus.emit('dazza:text', { text: out.dazzaText });
|
if (out.dazzaText) this.night.bus.emit('dazza:text', { text: out.dazzaText });
|
||||||
if (out.note) this.time.delayedCall(900, () => this.showToast(out.note!));
|
if (out.note) this.time.delayedCall(900, () => this.showToast(out.note!));
|
||||||
|
|
||||||
|
// Later consequences (hen's party detonation, pap swarm) go through the
|
||||||
|
// night's deferred machinery so they survive a location switch and land in
|
||||||
|
// the summary with their reason attached.
|
||||||
|
if (out.deferredHits && out.deferredHits.length > 0) {
|
||||||
|
const dr = this.night.rng.stream('encounterAftermath');
|
||||||
|
this.night.scheduleDeferred(
|
||||||
|
out.deferredHits.map((h) => ({
|
||||||
|
atClockMin: this.night.state.clockMin + dr.int(h.afterMin[0], h.afterMin[1]),
|
||||||
|
...(h.vibeDelta !== undefined ? { vibeDelta: h.vibeDelta } : {}),
|
||||||
|
...(h.aggroDelta !== undefined ? { aggroDelta: h.aggroDelta } : {}),
|
||||||
|
reason: h.reason,
|
||||||
|
patronId: p.id,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whatever you ruled, Franko sets up by the planter and starts helping.
|
||||||
|
if (id === 'exDoorman') this.frankoTips = 4;
|
||||||
|
|
||||||
this.night.bus.emit('incident:log', {
|
this.night.bus.emit('incident:log', {
|
||||||
clockMin: this.night.state.clockMin,
|
clockMin: this.night.state.clockMin,
|
||||||
kind: 'encounter',
|
kind: 'encounter',
|
||||||
@ -569,6 +837,109 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Franko's tip for the patron at the rope. Category is drawn from the REAL
|
||||||
|
* inspection surfaces; 1-in-5 he confidently names a problem the patron does
|
||||||
|
* not have. Fourteen years of doors.
|
||||||
|
*/
|
||||||
|
private frankoTip(p: Patron): string {
|
||||||
|
const rng = this.night.rng.stream('franko');
|
||||||
|
const v = idVerdict(p.idCard, this.night.nightDate);
|
||||||
|
const stage = drunkStage(p.intoxication);
|
||||||
|
const truths: Array<keyof typeof FRANKO_TIPS> = [];
|
||||||
|
if (v.looksFake) truths.push('fake');
|
||||||
|
if (v.underage) truths.push('underage');
|
||||||
|
if (stage === 'loose' || stage === 'messy' || stage === 'maggot') truths.push('drunk');
|
||||||
|
if (violations(p, this.night.state.clockMin).length > 0) truths.push('dress');
|
||||||
|
if (truths.length === 0) truths.push('clean');
|
||||||
|
let cat = truths[rng.int(0, truths.length - 1)]!;
|
||||||
|
if (rng.chance(0.2)) {
|
||||||
|
const lies = (Object.keys(FRANKO_TIPS) as Array<keyof typeof FRANKO_TIPS>).filter(
|
||||||
|
(k) => !truths.includes(k),
|
||||||
|
);
|
||||||
|
if (lies.length > 0) cat = lies[rng.int(0, lies.length - 1)]!;
|
||||||
|
}
|
||||||
|
const pool = FRANKO_TIPS[cat];
|
||||||
|
return pool[rng.int(0, pool.length - 1)]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ask, the glow, and — ignored long enough — the sulk. */
|
||||||
|
private tickKebab(): void {
|
||||||
|
const min = this.night.state.clockMin;
|
||||||
|
if (this.kebabAskAtMin !== null && !this.kebabHandled && !this.kebabPending && min >= this.kebabAskAtMin) {
|
||||||
|
this.kebabPending = true;
|
||||||
|
this.night.bus.emit('dazza:text', { text: KEBAB.ask });
|
||||||
|
if (this.kebabGlow) {
|
||||||
|
this.kebabGlow.setFillStyle(0xe8c060, 0.22);
|
||||||
|
this.tweens.add({ targets: this.kebabGlow, alpha: { from: 1, to: 0.4 }, duration: 900, yoyo: true, repeat: -1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.kebabPending && this.kebabAskAtMin !== null && min >= this.kebabAskAtMin + 25) {
|
||||||
|
// He waited 25 clock-minutes. He found the muesli bar.
|
||||||
|
this.kebabPending = false;
|
||||||
|
this.kebabHandled = true;
|
||||||
|
if (this.kebabGlow) {
|
||||||
|
this.kebabGlow.setFillStyle(0xe8c060, 0);
|
||||||
|
this.tweens.killTweensOf(this.kebabGlow);
|
||||||
|
this.kebabGlow.setAlpha(1);
|
||||||
|
}
|
||||||
|
this.night.bus.emit('meters:delta', { vibe: -2 });
|
||||||
|
this.night.bus.emit('dazza:text', { text: KEBAB.sulk });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Twelve real seconds in the shop, in full view of the queue, while Kayden
|
||||||
|
* finds the clipboard. The whole game in one errand (docs/SCENARIOS.md).
|
||||||
|
*/
|
||||||
|
private startKebabRun(): void {
|
||||||
|
if (!this.kebabPending || this.onKebabRun || this.busy) return;
|
||||||
|
this.kebabPending = false;
|
||||||
|
this.kebabHandled = true;
|
||||||
|
this.onKebabRun = true;
|
||||||
|
this.kebabRunMsLeft = 12_000;
|
||||||
|
if (this.kebabGlow) {
|
||||||
|
this.kebabGlow.setFillStyle(0xe8c060, 0);
|
||||||
|
this.tweens.killTweensOf(this.kebabGlow);
|
||||||
|
this.kebabGlow.setAlpha(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shade = this.add.rectangle(320, 180, 640, 360, 0x08060a, 0.86).setDepth(950).setInteractive();
|
||||||
|
const label = this.add
|
||||||
|
.text(320, 150, KEBAB.running, {
|
||||||
|
fontFamily: MONO,
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#e8c060',
|
||||||
|
align: 'center',
|
||||||
|
lineSpacing: 4,
|
||||||
|
})
|
||||||
|
.setOrigin(0.5)
|
||||||
|
.setDepth(951);
|
||||||
|
const bar = this.add.rectangle(320 - 90, 232, 180, 6, 0x3a3424).setOrigin(0, 0.5).setDepth(951);
|
||||||
|
this.kebabFill = this.add.rectangle(320 - 90, 232, 1, 6, 0xe8c060).setOrigin(0, 0.5).setDepth(951);
|
||||||
|
this.kebabUi = [shade, label, bar, this.kebabFill];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Driven from update() — a real 12s of shop time, not a tween the pane can stall. */
|
||||||
|
private tickKebabRun(deltaMs: number): void {
|
||||||
|
if (!this.onKebabRun) return;
|
||||||
|
this.kebabRunMsLeft -= deltaMs;
|
||||||
|
this.kebabFill?.setDisplaySize(Math.max(1, 180 * (1 - this.kebabRunMsLeft / 12_000)), 6);
|
||||||
|
if (this.kebabRunMsLeft > 0) return;
|
||||||
|
for (const o of this.kebabUi) o.destroy();
|
||||||
|
this.kebabUi = [];
|
||||||
|
this.kebabFill = null;
|
||||||
|
this.onKebabRun = false;
|
||||||
|
this.night.bus.emit('meters:delta', { vibe: 2 });
|
||||||
|
this.night.bus.emit('dazza:text', { text: KEBAB.thanks });
|
||||||
|
this.night.bus.emit('incident:log', {
|
||||||
|
clockMin: this.night.state.clockMin,
|
||||||
|
kind: 'kebabRun',
|
||||||
|
detail: 'Left the rope mid-shift. Returned with a kebab. Garlic sauce correct.',
|
||||||
|
});
|
||||||
|
this.showToast('back on the rope. the kebab is gone within the minute.');
|
||||||
|
}
|
||||||
|
|
||||||
private judgeCtx(): Parameters<typeof judge>[2] {
|
private judgeCtx(): Parameters<typeof judge>[2] {
|
||||||
return {
|
return {
|
||||||
clockMin: this.night.state.clockMin,
|
clockMin: this.night.state.clockMin,
|
||||||
@ -651,6 +1022,29 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rules cap at ACTIVE_RULE_CAP (Dazza's attention span). When a new one lands,
|
||||||
|
* grey out whatever fell off the card and let Dazza announce the retraction —
|
||||||
|
* judge() and the card must agree about what is live, or the card lies.
|
||||||
|
*/
|
||||||
|
private retireStaleRules(): void {
|
||||||
|
const live = new Set(activeRules(this.night.state.clockMin).map((r) => r.id));
|
||||||
|
for (const r of announcedRules(this.night.state.clockMin)) {
|
||||||
|
if (live.has(r.id)) continue;
|
||||||
|
// Always re-sync the card: Dazza's texts can LAG the clock (the send
|
||||||
|
// cooldown queues them), so a rule can be rescinded before its card row
|
||||||
|
// has even landed. retire() is an idempotent no-op until the row exists,
|
||||||
|
// and every later text re-runs this sync — the card converges.
|
||||||
|
this.codeCard.retire(r.id);
|
||||||
|
if (this.retiredRules.has(r.id)) continue; // toast once per rule, though
|
||||||
|
this.retiredRules.add(r.id);
|
||||||
|
const line = RULE_RETRACTIONS[this.retiredRules.size % RULE_RETRACTIONS.length]!;
|
||||||
|
// A retraction is a TEXT — it lands on the phone with a buzz like every
|
||||||
|
// other pronouncement, and lives in the thread as evidence.
|
||||||
|
this.night.bus.emit('dazza:text', { text: line.replace('{rule}', r.short).replace('dazza: ', '') });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private refreshCounters(): void {
|
private refreshCounters(): void {
|
||||||
const cap = this.night.state.capacity;
|
const cap = this.night.state.capacity;
|
||||||
this.clickerText.setText(String(cap.clickerShown));
|
this.clickerText.setText(String(cap.clickerShown));
|
||||||
@ -674,20 +1068,36 @@ export class DoorScene extends Phaser.Scene {
|
|||||||
// confident, and wrong a lot — his admits go through the same judge(), so
|
// confident, and wrong a lot — his admits go through the same judge(), so
|
||||||
// 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) {
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.clockText.setText(this.night.clock.label);
|
this.tickKebab();
|
||||||
if (this.tutorial.alpha > 0 && this.elapsedMs > TUTORIAL_MS) {
|
this.tickKebabRun(deltaMs);
|
||||||
this.tweens.add({ targets: this.tutorial, alpha: 0, duration: 800 });
|
|
||||||
|
const locked = this.night.state.clockMin >= LOCKOUT_MIN;
|
||||||
|
this.clockText.setText(locked ? `${this.night.clock.label} · LOCKOUT` : this.night.clock.label);
|
||||||
|
this.clockText.setColor(locked ? '#ff8080' : '#9fe8a0');
|
||||||
|
if (this.tutorial.visible) {
|
||||||
|
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) {
|
||||||
@ -746,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();
|
||||||
|
|||||||
@ -14,6 +14,8 @@ interface Row {
|
|||||||
label: Phaser.GameObjects.Text;
|
label: Phaser.GameObjects.Text;
|
||||||
badge: Phaser.GameObjects.Text;
|
badge: Phaser.GameObjects.Text;
|
||||||
addedAt: number;
|
addedAt: number;
|
||||||
|
/** Rescinded by Dazza (rules cap at ACTIVE_RULE_CAP) — greyed, never judged. */
|
||||||
|
retired?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DressCodeCard {
|
export class DressCodeCard {
|
||||||
@ -57,14 +59,36 @@ 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) {
|
||||||
|
if (r.retired) continue;
|
||||||
const hit = violatedIds.includes(r.rule.id);
|
const hit = violatedIds.includes(r.rule.id);
|
||||||
r.label.setColor(hit ? '#ff9090' : '#e0e8c8');
|
r.label.setColor(hit ? '#ff9090' : '#e0e8c8');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Dazza moved on: grey the rule out. It stays on the card as a monument. */
|
||||||
|
retire(ruleId: string): void {
|
||||||
|
const row = this.rows.find((r) => r.rule.id === ruleId && !r.retired);
|
||||||
|
if (!row) return;
|
||||||
|
row.retired = true;
|
||||||
|
row.label.setColor('#4a5a48');
|
||||||
|
row.label.setText(`- ${row.rule.short}`);
|
||||||
|
row.badge.setVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
update(nowMs: number): void {
|
update(nowMs: number): void {
|
||||||
for (const r of this.rows) {
|
for (const r of this.rows) {
|
||||||
if (r.badge.visible && nowMs - r.addedAt > NEW_BADGE_MS) r.badge.setVisible(false);
|
if (r.badge.visible && nowMs - r.addedAt > NEW_BADGE_MS) r.badge.setVisible(false);
|
||||||
|
|||||||
@ -133,6 +133,16 @@ export class IdCardView {
|
|||||||
root.add(scene.add.rectangle(92, 44, 58, 3, 0xffffff, 0.3).setAngle(-10));
|
root.add(scene.add.rectangle(92, 44, 58, 3, 0xffffff, 0.3).setAngle(-10));
|
||||||
scene.tweens.add({ targets: holo, alpha: { from: 0.35, to: 0.7 }, duration: 900, yoyo: true, repeat: -1 });
|
scene.tweens.add({ targets: holo, alpha: { from: 0.35, to: 0.7 }, duration: 900, yoyo: true, repeat: -1 });
|
||||||
|
|
||||||
|
if (p.idCard.fake?.passback) {
|
||||||
|
// The desk's own memory, stamped in red: this exact card already went in.
|
||||||
|
root.add(
|
||||||
|
scene.add
|
||||||
|
.text(-20, 30, 'SEEN TONIGHT', { fontFamily: MONO, fontSize: '11px', color: '#d03030' })
|
||||||
|
.setOrigin(0.5)
|
||||||
|
.setAngle(-9)
|
||||||
|
.setAlpha(0.9),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (p.idCard.fake?.peelingLaminate) {
|
if (p.idCard.fake?.peelingLaminate) {
|
||||||
// lifted corner + a couple of detached flecks
|
// lifted corner + a couple of detached flecks
|
||||||
root.add(scene.add.triangle(CARD_W / 2 - 22, CARD_H / 2 - 22, 0, 0, 30, 0, 30, -30, 0xf6f2e8, 0.9));
|
root.add(scene.add.triangle(CARD_W / 2 - 22, CARD_H / 2 - 22, 0, 0, 30, 0, 30, -30, 0xf6f2e8, 0.9));
|
||||||
|
|||||||
@ -3,15 +3,20 @@ import { EventBus } from '../../core/EventBus';
|
|||||||
import { GameClock, DEFAULT_CLOCK, type ClockConfig } from '../../core/GameClock';
|
import { GameClock, DEFAULT_CLOCK, type ClockConfig } from '../../core/GameClock';
|
||||||
import { SeededRNG } from '../../core/SeededRNG';
|
import { SeededRNG } from '../../core/SeededRNG';
|
||||||
import { StubBeatClock } from '../../core/StubBeatClock';
|
import { StubBeatClock } from '../../core/StubBeatClock';
|
||||||
import { Meters, freshNightState } 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 { Sfx } from '../../audio/Sfx';
|
import { Sfx } from '../../audio/Sfx';
|
||||||
import type { DeferredHit } from '../../rules/doorTypes';
|
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 { defaultRoleFor, roleById, type RoleDef } from '../../data/roster';
|
||||||
|
import { buildRegularCast } from '../../patrons/regularCast';
|
||||||
|
import { recordDenialBy } from '../../patrons/memory';
|
||||||
import { observe, startWatch, type InspectorWatch, type VenueBreaches } from '../../rules/inspector';
|
import { observe, startWatch, type InspectorWatch, type VenueBreaches } from '../../rules/inspector';
|
||||||
|
|
||||||
// Phase-2: the night-flow machine. Door and Floor both run for the whole night;
|
// Phase-2: the night-flow machine. Door and Floor both run for the whole night;
|
||||||
@ -19,11 +24,12 @@ import { observe, startWatch, type InspectorWatch, type VenueBreaches } from '..
|
|||||||
// meters, the audio engine (and the location filter that goes with it), Kayden's
|
// meters, the audio engine (and the location filter that goes with it), Kayden's
|
||||||
// stamp accuracy, the radio, and the run-level save. It still renders nothing.
|
// stamp accuracy, the radio, and the run-level save. It still renders nothing.
|
||||||
|
|
||||||
|
/** The Royal's licence — kept for tests/back-compat; live nights use the venue def. */
|
||||||
export const LICENSED_CAPACITY = 80;
|
export const LICENSED_CAPACITY = 80;
|
||||||
const VIBE_SAMPLE_EVERY_MIN = 5;
|
const VIBE_SAMPLE_EVERY_MIN = 5;
|
||||||
|
|
||||||
/** CONTRACTS.md §3: heatStrikes is "run-scoped, max 3". Design §2: 3 = licence gone. */
|
import { MAX_HEAT_STRIKES, shouldRecordStrike } from '../../rules/heat';
|
||||||
export const MAX_HEAT_STRIKES = 3;
|
export { MAX_HEAT_STRIKES, shouldRecordStrike };
|
||||||
|
|
||||||
/** Thu → Fri → Sat at The Royal. Index = nightIndex. */
|
/** Thu → Fri → Sat at The Royal. Index = nightIndex. */
|
||||||
export const NIGHT_DATES = ['2026-07-16', '2026-07-17', '2026-07-18'] as const;
|
export const NIGHT_DATES = ['2026-07-16', '2026-07-17', '2026-07-18'] as const;
|
||||||
@ -36,10 +42,6 @@ const RADIO_PULL_MINS = [120, 250] as const;
|
|||||||
/** Dynamic maggot-radio rate limit (clock minutes). */
|
/** Dynamic maggot-radio rate limit (clock minutes). */
|
||||||
const RADIO_MAGGOT_COOLDOWN = 15;
|
const RADIO_MAGGOT_COOLDOWN = 15;
|
||||||
|
|
||||||
export function shouldRecordStrike(state: NightState, strike: HeatStrike): boolean {
|
|
||||||
if (state.heatStrikes.length >= MAX_HEAT_STRIKES) return false;
|
|
||||||
return !state.heatStrikes.some((s) => s.reason === strike.reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type NightEndReason = 'clock' | 'vibe' | 'aggro' | 'licence';
|
export type NightEndReason = 'clock' | 'vibe' | 'aggro' | 'licence';
|
||||||
|
|
||||||
@ -61,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. */
|
||||||
@ -69,10 +82,15 @@ export interface RunInfo {
|
|||||||
game: GameState;
|
game: GameState;
|
||||||
nightIndex: number;
|
nightIndex: number;
|
||||||
totalNights: number;
|
totalNights: number;
|
||||||
|
venue: VenueDef;
|
||||||
|
/** the week is done and a HARDER venue wants you (venue ladder, design §6) */
|
||||||
|
promotedTo: VenueDef | null;
|
||||||
/** licence pulled — the run is dead, only a fresh one remains */
|
/** licence pulled — the run is dead, only a fresh one remains */
|
||||||
runOver: boolean;
|
runOver: boolean;
|
||||||
/** survived all three nights */
|
/** survived all three nights */
|
||||||
weekDone: boolean;
|
weekDone: boolean;
|
||||||
|
/** survived every week at every venue — the season is complete */
|
||||||
|
seasonDone: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Everything the door and floor need from the night. Passed by reference. */
|
/** Everything the door and floor need from the night. Passed by reference. */
|
||||||
@ -88,8 +106,33 @@ export interface NightContext {
|
|||||||
beatIntervalMs: number;
|
beatIntervalMs: number;
|
||||||
scheduleDeferred(hits: readonly DeferredHit[]): void;
|
scheduleDeferred(hits: readonly DeferredHit[]): void;
|
||||||
reportQueue(length: number): void;
|
reportQueue(length: number): void;
|
||||||
|
/** Scripted encounters this RUN has already met (cross-night repeat gating). */
|
||||||
|
seenEncounters: readonly string[];
|
||||||
|
/** This week's venue — capacity/pressure knobs (venue ladder, design §6). */
|
||||||
|
venue: VenueDef;
|
||||||
|
/** Tonight's shift off the roster board — a lens, not a different game. */
|
||||||
|
role: RoleDef;
|
||||||
|
/** The run's stable regulars and their grudges (design §4.1). */
|
||||||
|
regulars: { cast: readonly Patron[]; memory: Readonly<Record<string, import('../../data/types').RegularMemory>> };
|
||||||
/** Door/Floor ask to move the player; the night decides and flips the scenes. */
|
/** 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 {
|
||||||
@ -99,11 +142,14 @@ export class NightScene extends Phaser.Scene {
|
|||||||
private stubBeat: StubBeatClock | null = null;
|
private stubBeat: StubBeatClock | null = null;
|
||||||
private engine: TechnoEngine | null = null;
|
private engine: TechnoEngine | null = null;
|
||||||
private sfx: Sfx | null = null;
|
private sfx: Sfx | null = null;
|
||||||
|
private ambience: Ambience | null = null;
|
||||||
private meters!: Meters;
|
private meters!: Meters;
|
||||||
private state!: NightState;
|
private state!: NightState;
|
||||||
private run!: GameState;
|
private run!: GameState;
|
||||||
private seed = 4207;
|
private seed = 4207;
|
||||||
private nightIndex = 0;
|
private nightIndex = 0;
|
||||||
|
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>();
|
||||||
@ -126,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) {
|
||||||
@ -134,8 +180,11 @@ 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;
|
||||||
// Each night is its own deterministic world derived from the run seed.
|
this.venue = venueByIndex(this.run.venueIndex);
|
||||||
this.seed = this.run.seed + this.nightIndex * 101;
|
this.role = roleById(data.role) ?? defaultRoleFor(this.venue);
|
||||||
|
// Each night is its own deterministic world derived from the run seed —
|
||||||
|
// and each venue-week its own block of them.
|
||||||
|
this.seed = this.run.seed + this.run.venueIndex * 1009 + this.nightIndex * 101;
|
||||||
}
|
}
|
||||||
|
|
||||||
create(): void {
|
create(): void {
|
||||||
@ -150,9 +199,16 @@ export class NightScene extends Phaser.Scene {
|
|||||||
|
|
||||||
this.bus = new EventBus();
|
this.bus = new EventBus();
|
||||||
this.rng = new SeededRNG(this.seed);
|
this.rng = new SeededRNG(this.seed);
|
||||||
const clockCfg: ClockConfig = { ...DEFAULT_CLOCK, nightDate: NIGHT_DATES[this.nightIndex]! };
|
const clockCfg: ClockConfig = {
|
||||||
|
...DEFAULT_CLOCK,
|
||||||
|
nightDate: nightDateFor(NIGHT_DATES[this.nightIndex]!, this.run.venueIndex),
|
||||||
|
};
|
||||||
this.clock = new GameClock(this.bus, clockCfg);
|
this.clock = new GameClock(this.bus, clockCfg);
|
||||||
this.state = freshNightState('theRoyal', this.nightIndex, LICENSED_CAPACITY);
|
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);
|
||||||
@ -161,6 +217,8 @@ export class NightScene extends Phaser.Scene {
|
|||||||
// Until the first gesture, a stub keeps beat:tick honest (then hands over).
|
// Until the first gesture, a stub keeps beat:tick honest (then hands over).
|
||||||
this.engine = new TechnoEngine(this.bus);
|
this.engine = new TechnoEngine(this.bus);
|
||||||
this.sfx = new Sfx(this.engine.ctx, this.engine.sfxBus);
|
this.sfx = new Sfx(this.engine.ctx, this.engine.sfxBus);
|
||||||
|
// Rain outside, murmur inside, fluoro hum — starts once audio unlocks.
|
||||||
|
this.ambience = new Ambience(this.bus, this.engine.ctx, this.engine.sfxBus, this.sfx);
|
||||||
this.stubBeat = new StubBeatClock(this.bus);
|
this.stubBeat = new StubBeatClock(this.bus);
|
||||||
this.stubBeat.start();
|
this.stubBeat.start();
|
||||||
this.engine.start();
|
this.engine.start();
|
||||||
@ -181,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(
|
||||||
@ -191,16 +253,31 @@ export class NightScene extends Phaser.Scene {
|
|||||||
this.bus.on('floor:ejection', () => {
|
this.bus.on('floor:ejection', () => {
|
||||||
this.state.capacity.inside = Math.max(0, this.state.capacity.inside - 1);
|
this.state.capacity.inside = Math.max(0, this.state.capacity.inside - 1);
|
||||||
}),
|
}),
|
||||||
|
this.bus.on('floor:leave', () => {
|
||||||
|
this.state.capacity.inside = Math.max(0, this.state.capacity.inside - 1);
|
||||||
|
}),
|
||||||
this.bus.on('audio:unlocked', () => {
|
this.bus.on('audio:unlocked', () => {
|
||||||
this.stubBeat?.stop();
|
this.stubBeat?.stop();
|
||||||
this.stubBeat = null;
|
this.stubBeat = null;
|
||||||
|
this.ambience?.start();
|
||||||
}),
|
}),
|
||||||
this.bus.on('radio:call', () => this.sfx?.play('radioChirp')),
|
this.bus.on('radio:call', () => this.sfx?.play('radioChirp')),
|
||||||
this.bus.on('incident:log', (inc) => {
|
this.bus.on('incident:log', (inc) => {
|
||||||
|
if (inc.kind === 'encounter' && inc.detail.includes(':')) {
|
||||||
|
// The door logs encounters as "<id>: let in/turned away" — the id is
|
||||||
|
// what gates repeats across the week.
|
||||||
|
const encId = inc.detail.split(':')[0]!;
|
||||||
|
const seen = (this.run.seenEncounters ??= []);
|
||||||
|
if (!seen.includes(encId)) seen.push(encId);
|
||||||
|
}
|
||||||
if (inc.kind === 'maggotUnhandled') {
|
if (inc.kind === 'maggotUnhandled') {
|
||||||
this.maggotLive = true;
|
this.maggotLive = true;
|
||||||
if (this.state.location === 'door') this.maggotRadio();
|
if (this.state.location === 'door') this.maggotRadio();
|
||||||
}
|
}
|
||||||
|
// A swing on the floor while you're at the rope: the radio tells you.
|
||||||
|
if (inc.kind === 'fight' && this.state.location === 'door' && inc.detail.startsWith('swung')) {
|
||||||
|
this.bus.emit('radio:call', { urgency: 3, line: 'there was a SWING inside. get in here. NOW' });
|
||||||
|
}
|
||||||
// The floor cleared them out; the room is legal again.
|
// The floor cleared them out; the room is legal again.
|
||||||
if (inc.kind === 'ejection' || inc.kind === 'cutOff') this.maggotLive = false;
|
if (inc.kind === 'ejection' || inc.kind === 'cutOff') this.maggotLive = false;
|
||||||
}),
|
}),
|
||||||
@ -214,21 +291,65 @@ 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;
|
||||||
},
|
},
|
||||||
|
seenEncounters: this.run.seenEncounters ?? [],
|
||||||
|
venue: this.venue,
|
||||||
|
regulars: {
|
||||||
|
// Cast is RUN-seeded: same six faces every night, every venue.
|
||||||
|
cast: buildRegularCast(this.run.seed, this.clock.nightDate),
|
||||||
|
memory: this.run.regulars,
|
||||||
|
},
|
||||||
requestLocation: (loc) => this.setLocation(loc),
|
requestLocation: (loc) => this.setLocation(loc),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.events.once('shutdown', () => this.teardown());
|
this.events.once('shutdown', () => this.teardown());
|
||||||
this.clock.start();
|
this.clock.start();
|
||||||
this.scene.launch('Door', { night: this.ctx });
|
|
||||||
this.scene.launch('Floor', { night: this.ctx });
|
// Generated art loads BEFORE the scenes that consume it (textures are
|
||||||
// Door and Floor BOTH run all night; the flags below decide which one the
|
// game-global): the door's street backdrop and the floor's props both ride
|
||||||
// player is inhabiting. Floor starts hidden.
|
// public/props + manifest. Missing manifest = placeholders, zero waiting.
|
||||||
this.time.delayedCall(0, () => this.applyLocation('door'));
|
this.load.json('propManifest', 'props/manifest.json');
|
||||||
|
this.load.once(Phaser.Loader.Events.COMPLETE, () => {
|
||||||
|
const manifest = this.cache.json.get('propManifest') as { props?: string[] } | undefined;
|
||||||
|
const missing = (manifest?.props ?? []).filter((k) => !this.textures.exists(`gen:prop:${k}`));
|
||||||
|
const launch = (): void => {
|
||||||
|
this.scene.launch('Door', { night: this.ctx });
|
||||||
|
this.scene.launch('Floor', { night: this.ctx });
|
||||||
|
// Door and Floor BOTH run all night; the flags below decide which one
|
||||||
|
// the player is inhabiting. Floor starts hidden.
|
||||||
|
// The roster decides where you stand when the doors open (design §6).
|
||||||
|
this.time.delayedCall(0, () => {
|
||||||
|
this.applyLocation(this.role.startLoc);
|
||||||
|
// ...and ANNOUNCE it. applyLocation only moves flags; the room latches
|
||||||
|
// onto the event. FloorDemoScene reads `present` when it binds this
|
||||||
|
// context, which — now that the night preloads every prop texture —
|
||||||
|
// happens before this runs, so a floor-start shift (bartender, glassie,
|
||||||
|
// DJ) came up visible but completely inert, and the audio filter stayed
|
||||||
|
// in door mode while you stood on the dancefloor.
|
||||||
|
this.bus.emit('night:phaseChange', { location: this.role.startLoc });
|
||||||
|
this.bus.emit('audio:location', { location: this.role.startLoc });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if (missing.length === 0) return launch();
|
||||||
|
for (const k of missing) this.load.image(`gen:prop:${k}`, `props/${k}.png`);
|
||||||
|
this.load.once(Phaser.Loader.Events.COMPLETE, launch);
|
||||||
|
this.load.start();
|
||||||
|
});
|
||||||
|
this.load.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- location ----------------------------------------------------------
|
// ---- location ----------------------------------------------------------
|
||||||
@ -263,11 +384,18 @@ export class NightScene extends Phaser.Scene {
|
|||||||
this.state.location === 'door' ? true : this.rng.stream('kaydenStamp').chance(KAYDEN_STAMP_CHANCE);
|
this.state.location === 'door' ? true : this.rng.stream('kaydenStamp').chance(KAYDEN_STAMP_CHANCE);
|
||||||
// The inspector reads as a boring punter and is scored as one — the ONLY
|
// The inspector reads as a boring punter and is scored as one — the ONLY
|
||||||
// thing that marks them is that admitting one starts a clock (§4.2).
|
// thing that marks them is that admitting one starts a clock (§4.2).
|
||||||
if (patron.archetype === 'inspector' && this.inspector === null) {
|
// ...and only at venues the inspector actually works (venue ladder).
|
||||||
|
if (patron.archetype === 'inspector' && this.inspector === null && this.venue.inspector) {
|
||||||
this.inspector = startWatch(patron.id, this.state.clockMin);
|
this.inspector = startWatch(patron.id, this.state.clockMin);
|
||||||
}
|
}
|
||||||
if (patron.age < 18) this.minorsInside++;
|
if (patron.age < 18) this.minorsInside++;
|
||||||
} else if (verdict === 'deny') this.log.denied++;
|
} else if (verdict === 'deny') {
|
||||||
|
this.log.denied++;
|
||||||
|
// The regulars keep count across nights AND venues (same town).
|
||||||
|
if (patron.flags.regularId) {
|
||||||
|
recordDenialBy(this.run, patron.flags.regularId, this.run.venueIndex * TOTAL_NIGHTS + this.nightIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
else if (verdict === 'sobrietyTest') this.log.tested++;
|
else if (verdict === 'sobrietyTest') this.log.tested++;
|
||||||
else if (verdict === 'patDown') this.log.pattedDown++;
|
else if (verdict === 'patDown') this.log.pattedDown++;
|
||||||
}
|
}
|
||||||
@ -299,6 +427,10 @@ export class NightScene extends Phaser.Scene {
|
|||||||
this.maybeDazza(clockMin);
|
this.maybeDazza(clockMin);
|
||||||
this.maybePull(clockMin);
|
this.maybePull(clockMin);
|
||||||
|
|
||||||
|
// The room cools on its own — keeping it lit is the job (core/meters.ts).
|
||||||
|
const cooling = vibeCoolingPerMin(this.state.vibe);
|
||||||
|
if (cooling !== 0) this.bus.emit('meters:delta', { vibe: cooling });
|
||||||
|
|
||||||
if (clockMin >= this.clock.config.nightClockMinutes) this.endNight('clock');
|
if (clockMin >= this.clock.config.nightClockMinutes) this.endNight('clock');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -383,6 +515,7 @@ export class NightScene extends Phaser.Scene {
|
|||||||
queueLength: this.queueLength,
|
queueLength: this.queueLength,
|
||||||
insideCount: this.state.capacity.inside,
|
insideCount: this.state.capacity.inside,
|
||||||
fired: this.firedDazza,
|
fired: this.firedDazza,
|
||||||
|
ruleLimit: this.venue.ruleLimit,
|
||||||
});
|
});
|
||||||
if (!line) return;
|
if (!line) return;
|
||||||
this.firedDazza.add(line.id);
|
this.firedDazza.add(line.id);
|
||||||
@ -415,12 +548,21 @@ export class NightScene extends Phaser.Scene {
|
|||||||
const runOver = reason === 'licence';
|
const runOver = reason === 'licence';
|
||||||
const survived = reason === 'clock';
|
const survived = reason === 'clock';
|
||||||
const weekDone = survived && this.nightIndex >= TOTAL_NIGHTS - 1;
|
const weekDone = survived && this.nightIndex >= TOTAL_NIGHTS - 1;
|
||||||
|
const seasonDone = weekDone && this.run.venueIndex >= TOTAL_VENUES - 1;
|
||||||
this.run.heatStrikes = [...this.state.heatStrikes];
|
this.run.heatStrikes = [...this.state.heatStrikes];
|
||||||
// pastReports is written by IncidentReportScene now, not here: Phase 4
|
// pastReports is written by IncidentReportScene now, not here: Phase 4
|
||||||
// audits what the player CLAIMED happened, and a raw truth dump would have
|
// audits what the player CLAIMED happened, and a raw truth dump would have
|
||||||
// nothing to catch them out with.
|
// nothing to catch them out with.
|
||||||
if (survived) this.run.nightIndex = this.nightIndex + 1;
|
if (survived) this.run.nightIndex = this.nightIndex + 1;
|
||||||
if (runOver || weekDone) {
|
if (weekDone && !seasonDone) {
|
||||||
|
// PROMOTED: next venue, fresh week, fresh licence. Heat is per-venue —
|
||||||
|
// the new place has not heard of you (yet). Regulars and met-encounters
|
||||||
|
// carry across: it is the same town.
|
||||||
|
this.run.venueIndex += 1;
|
||||||
|
this.run.nightIndex = 0;
|
||||||
|
this.run.heatStrikes = [];
|
||||||
|
}
|
||||||
|
if (runOver || seasonDone) {
|
||||||
clearSave();
|
clearSave();
|
||||||
} else {
|
} else {
|
||||||
saveGame(this.run);
|
saveGame(this.run);
|
||||||
@ -429,14 +571,30 @@ export class NightScene extends Phaser.Scene {
|
|||||||
game: this.run,
|
game: this.run,
|
||||||
nightIndex: this.nightIndex,
|
nightIndex: this.nightIndex,
|
||||||
totalNights: TOTAL_NIGHTS,
|
totalNights: TOTAL_NIGHTS,
|
||||||
|
venue: this.venue,
|
||||||
|
promotedTo: weekDone && !seasonDone ? venueByIndex(this.run.venueIndex) : null,
|
||||||
runOver,
|
runOver,
|
||||||
weekDone,
|
weekDone,
|
||||||
|
seasonDone,
|
||||||
};
|
};
|
||||||
|
|
||||||
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();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -447,9 +605,17 @@ 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();
|
||||||
|
this.ambience?.destroy();
|
||||||
|
this.ambience = null;
|
||||||
this.engine?.destroy();
|
this.engine?.destroy();
|
||||||
this.engine = null;
|
this.engine = null;
|
||||||
this.sfx = null;
|
this.sfx = null;
|
||||||
|
|||||||
@ -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 {
|
||||||
@ -63,8 +65,8 @@ export class NightSummaryScene extends Phaser.Scene {
|
|||||||
W / 2,
|
W / 2,
|
||||||
46,
|
46,
|
||||||
failed
|
failed
|
||||||
? `${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,27 @@ 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.weekDone) {
|
if (r.seasonDone) {
|
||||||
return {
|
return {
|
||||||
prompt: 'WEEK SURVIVED. click for a new week (new seed, same rain)',
|
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) {
|
||||||
|
const v = r.promotedTo;
|
||||||
|
return {
|
||||||
|
// Dazza's pitch sells the next rung — the ladder is the reward.
|
||||||
|
prompt: `WEEK SURVIVED — PROMOTED: ${v.name.toUpperCase()}. "${v.pitch}"`,
|
||||||
|
next: () => this.scene.start('Roster', {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const failedNight = this.log.endReason !== 'clock';
|
const failedNight = this.log.endReason !== 'clock';
|
||||||
@ -127,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', {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -179,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,8 +2,9 @@ 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 } 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';
|
||||||
import { MONO } from './ui';
|
import { MONO } from './ui';
|
||||||
import { ZONE_BANDS, ZONE_ORDER, describeZone, greetingIndex, type InspectZone } from './inspect';
|
import { ZONE_BANDS, ZONE_ORDER, describeZone, greetingIndex, type InspectZone } from './inspect';
|
||||||
@ -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,
|
||||||
@ -92,18 +95,26 @@ export class PatronUpView {
|
|||||||
// knowsOwner is checked FIRST and separately: the owner's mate is the trap
|
// knowsOwner is checked FIRST and separately: the owner's mate is the trap
|
||||||
// (design §3.1), so his line has to be the one that drops the name, not a
|
// (design §3.1), so his line has to be the one that drops the name, not a
|
||||||
// generic "i'm on the list" shared with every influencer.
|
// generic "i'm on the list" shared with every influencer.
|
||||||
const pool = p.flags.knowsOwner
|
// The disguised regular's denial-of-everything outranks every other line —
|
||||||
? OWNER_HINTS
|
// it IS the tell (same face, same walk, "never been here"). A merely
|
||||||
: p.flags.claimsGuestList
|
// knocked-back regular gets his olive branch in ahead of list claims.
|
||||||
? CLAIM_LINES
|
const pool = p.flags.disguised === true
|
||||||
: drunk === 'messy' || drunk === 'maggot' || drunk === 'loose'
|
? REGULAR_DISGUISED_LINES
|
||||||
? DRUNK_GREETINGS
|
: p.flags.regularId !== undefined && (p.flags.timesDeniedBefore ?? 0) > 0
|
||||||
: PATRON_GREETINGS;
|
? REGULAR_RETURN_LINES
|
||||||
|
: p.flags.knowsOwner
|
||||||
|
? OWNER_HINTS
|
||||||
|
: p.flags.claimsGuestList
|
||||||
|
? CLAIM_LINES
|
||||||
|
: drunk === 'messy' || drunk === 'maggot' || drunk === 'loose'
|
||||||
|
? DRUNK_GREETINGS
|
||||||
|
: PATRON_GREETINGS;
|
||||||
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',
|
||||||
@ -136,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',
|
||||||
|
|||||||
@ -4,10 +4,14 @@ import { generatePatron } from '../../patrons/generator';
|
|||||||
import type { Patron } from '../../data/types';
|
import type { Patron } from '../../data/types';
|
||||||
import { nextArrivalGapMin } from './arrivalCurve';
|
import { nextArrivalGapMin } from './arrivalCurve';
|
||||||
import { assignListedName, generateGuestList, type GuestList } from '../../rules/guestList';
|
import { assignListedName, generateGuestList, type GuestList } from '../../rules/guestList';
|
||||||
|
import { adoptCastIdentity } from '../../patrons/regularCast';
|
||||||
|
import { disguisedFromMemory } from '../../patrons/memory';
|
||||||
|
import type { RegularMemory } from '../../data/types';
|
||||||
import {
|
import {
|
||||||
ENCOUNTERS,
|
ENCOUNTERS,
|
||||||
encounterById,
|
encounterById,
|
||||||
scheduleEncounters,
|
scheduleEncounters,
|
||||||
|
twinSibling,
|
||||||
type EncounterId,
|
type EncounterId,
|
||||||
} from '../../rules/encounters';
|
} from '../../rules/encounters';
|
||||||
|
|
||||||
@ -40,8 +44,15 @@ export const QUEUE_TUNING = {
|
|||||||
* empty, so an empty-only rule meant a perfectly-played night still rioted.
|
* empty, so an empty-only rule meant a perfectly-played night still rioted.
|
||||||
*/
|
*/
|
||||||
comfortableQueue: 4,
|
comfortableQueue: 4,
|
||||||
/** aggro bled off per real second with nobody waiting at all */
|
/** aggro bled off per real second with nobody waiting at all
|
||||||
calmPerSec: 0.9,
|
* (tuning pass 2026-07-19: was 0.9 — relief refunded ~10:1 and aggro never moved) */
|
||||||
|
calmPerSec: 0.35,
|
||||||
|
/**
|
||||||
|
* Hype decays toward x1 while a patron is actually being served. Theatre
|
||||||
|
* fades the moment you get back to work — without this, hype was a pure
|
||||||
|
* ratchet and every night ended at x3 regardless of play.
|
||||||
|
*/
|
||||||
|
hypeDecayPerSec: 0.045,
|
||||||
/** looking at your phone while someone waits: pure theatre, pure profit */
|
/** looking at your phone while someone waits: pure theatre, pure profit */
|
||||||
phoneHype: 0.12,
|
phoneHype: 0.12,
|
||||||
phoneCooldownMs: 2500,
|
phoneCooldownMs: 2500,
|
||||||
@ -58,6 +69,43 @@ 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,
|
||||||
|
/** the entrance: admitting off the carpet pays hype scaled by the stand */
|
||||||
|
entranceHypePerSec: 0.012,
|
||||||
|
entranceHypeCap: 0.6,
|
||||||
|
entranceVibe: 2,
|
||||||
|
/** patience in real seconds, before the archetype scale */
|
||||||
|
patienceSecBase: [30, 55] as const,
|
||||||
|
/**
|
||||||
|
* Who tolerates being furniture. Influencers LIVE for it; a regular gives
|
||||||
|
* it thirty seconds and tells the whole suburb.
|
||||||
|
*/
|
||||||
|
patienceScale: {
|
||||||
|
influencer: 1.8, financeBro: 1.3, ownersMate: 1.2, fresh18: 1.1,
|
||||||
|
almost18: 1.0, inspector: 1.0, punter: 0.9, quietMessy: 0.7, regular: 0.5,
|
||||||
|
} as Readonly<Record<import('../../data/types').Archetype, number>>,
|
||||||
|
/** storming off the carpet is a scene the whole queue watches */
|
||||||
|
stormAggro: 3,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** The entrance dividend for `standMs` on the carpet, capped. */
|
||||||
|
export function entranceBonus(standMs: number): number {
|
||||||
|
return Math.min(CARPET_TUNING.entranceHypeCap, (standMs / 1000) * CARPET_TUNING.entranceHypePerSec);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CarpetSlot {
|
||||||
|
patron: Patron;
|
||||||
|
standMs: number;
|
||||||
|
patienceMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface QueueSnapshot {
|
export interface QueueSnapshot {
|
||||||
waiting: Patron[];
|
waiting: Patron[];
|
||||||
visible: Patron[];
|
visible: Patron[];
|
||||||
@ -80,6 +128,10 @@ export class QueueManager {
|
|||||||
private readonly comebackRng: RngStream;
|
private readonly comebackRng: RngStream;
|
||||||
private readonly listRng: RngStream;
|
private readonly listRng: RngStream;
|
||||||
private readonly encounterRng: RngStream;
|
private readonly encounterRng: RngStream;
|
||||||
|
private readonly castRng: RngStream;
|
||||||
|
private readonly passbackRng: RngStream;
|
||||||
|
/** Cards of patrons admitted tonight — the pool a passback clones from. */
|
||||||
|
private readonly admittedCards: Array<{ name: string; dob: string; expiry: string; photoSeed: number }> = [];
|
||||||
/** tonight's clipboard — read by the door's guest-list panel */
|
/** tonight's clipboard — read by the door's guest-list panel */
|
||||||
readonly guestList: GuestList;
|
readonly guestList: GuestList;
|
||||||
/** scripted arrivals still owed tonight, soonest first */
|
/** scripted arrivals still owed tonight, soonest first */
|
||||||
@ -88,14 +140,34 @@ 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,
|
||||||
private readonly rng: SeededRNG,
|
private readonly rng: SeededRNG,
|
||||||
private readonly nightDate: Date,
|
private readonly nightDate: Date,
|
||||||
|
/** Encounters already met this run — the scheduler prefers new people. */
|
||||||
|
seenEncounters: readonly string[] = [],
|
||||||
|
/** Venue arrival multiplier (ladder, design §6). */
|
||||||
|
private readonly arrivalScale: number = 1,
|
||||||
|
/** Venue crowd identity — archetype weight overrides (ladder, design §6). */
|
||||||
|
private readonly archetypeWeights?: Partial<Record<import('../../data/types').Archetype, number>>,
|
||||||
|
/** The run's stable regulars + what they remember (design §4.1). */
|
||||||
|
private readonly regulars?: {
|
||||||
|
cast: readonly import('../../data/types').Patron[];
|
||||||
|
memory: Readonly<Record<string, RegularMemory>>;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
this.arrivalRng = rng.stream('arrivals');
|
this.arrivalRng = rng.stream('arrivals');
|
||||||
this.coupleRng = rng.stream('couples');
|
this.coupleRng = rng.stream('couples');
|
||||||
@ -106,11 +178,40 @@ export class QueueManager {
|
|||||||
this.comebackRng = rng.stream('comebacks');
|
this.comebackRng = rng.stream('comebacks');
|
||||||
this.listRng = rng.stream('guestList');
|
this.listRng = rng.stream('guestList');
|
||||||
this.encounterRng = rng.stream('encounters');
|
this.encounterRng = rng.stream('encounters');
|
||||||
|
this.castRng = rng.stream('castPick');
|
||||||
|
this.passbackRng = rng.stream('passback');
|
||||||
|
this.carpetRng = rng.stream('carpet');
|
||||||
|
bus.on('door:verdict', ({ patron, verdict }) => {
|
||||||
|
// Twin B arrives a few minutes after A's verdict, WHATEVER it was — he
|
||||||
|
// was parking the car either way. A plain patron, no script: his own
|
||||||
|
// real name, his brother's face. The photo check is the thing on trial.
|
||||||
|
// ...but only on a FINAL ruling, and only once. `door:verdict` also fires
|
||||||
|
// for 'sobrietyTest' and 'patDown', so a doorperson who tested the twin
|
||||||
|
// AND patted him down summoned three of his brother.
|
||||||
|
if (
|
||||||
|
(verdict === 'admit' || verdict === 'deny') &&
|
||||||
|
this.encounterOf.get(patron.id) === 'twins' &&
|
||||||
|
!this.twinsSpawned.has(patron.id)
|
||||||
|
) {
|
||||||
|
this.twinsSpawned.add(patron.id);
|
||||||
|
this.comebacks.push({
|
||||||
|
atMin: this.clockMin + this.comebackRng.int(2, 5),
|
||||||
|
patron: twinSibling(patron),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (verdict !== 'admit') return;
|
||||||
|
const c = patron.idCard;
|
||||||
|
this.admittedCards.push({ name: c.name, dob: c.dob, expiry: c.expiry, photoSeed: c.photoSeed });
|
||||||
|
});
|
||||||
this.guestList = generateGuestList(
|
this.guestList = generateGuestList(
|
||||||
this.listRng,
|
this.listRng,
|
||||||
this.listRng.int(8, Math.min(14, ENCOUNTERS.length + 12)),
|
this.listRng.int(8, Math.min(14, ENCOUNTERS.length + 12)),
|
||||||
);
|
);
|
||||||
this.pendingEncounters = scheduleEncounters(this.encounterRng, QUEUE_TUNING.encountersPerNight);
|
this.pendingEncounters = scheduleEncounters(
|
||||||
|
this.encounterRng,
|
||||||
|
QUEUE_TUNING.encountersPerNight,
|
||||||
|
new Set(seenEncounters),
|
||||||
|
);
|
||||||
// Somebody is ALWAYS at the rope when the shift starts. A game that opens on
|
// Somebody is ALWAYS at the rope when the shift starts. A game that opens on
|
||||||
// an empty street reads as broken, however accurate a 9 PM lull would be.
|
// an empty street reads as broken, however accurate a 9 PM lull would be.
|
||||||
this.enqueue(this.make());
|
this.enqueue(this.make());
|
||||||
@ -167,7 +268,7 @@ export class QueueManager {
|
|||||||
|
|
||||||
while (clockMin >= this.nextArrivalMin) {
|
while (clockMin >= this.nextArrivalMin) {
|
||||||
this.spawnArrival();
|
this.spawnArrival();
|
||||||
const gap = nextArrivalGapMin(this.nextArrivalMin, this.arrivalRng.next());
|
const gap = nextArrivalGapMin(this.nextArrivalMin, this.arrivalRng.next(), this.arrivalScale);
|
||||||
this.nextArrivalMin += Math.max(1, Math.round(gap));
|
this.nextArrivalMin += Math.max(1, Math.round(gap));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -176,6 +277,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) {
|
||||||
@ -203,6 +305,82 @@ export class QueueManager {
|
|||||||
const relief = (QUEUE_TUNING.calmPerSec * slack) / QUEUE_TUNING.comfortableQueue;
|
const relief = (QUEUE_TUNING.calmPerSec * slack) / QUEUE_TUNING.comfortableQueue;
|
||||||
this.bus.emit('meters:delta', { aggro: -relief * sec });
|
this.bus.emit('meters:delta', { aggro: -relief * sec });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Somebody is at the rope being dealt with: the theatre wears off.
|
||||||
|
if (this.up !== null) {
|
||||||
|
this.bus.emit('meters:delta', { hype: -QUEUE_TUNING.hypeDecayPerSec * sec });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The carpet ticks whether or not the rope is busy — display doesn't pause. */
|
||||||
|
private tickCarpet(deltaMs: number): void {
|
||||||
|
if (this.carpet.length === 0) return;
|
||||||
|
const sec = deltaMs / 1000;
|
||||||
|
this.bus.emit('meters:delta', { hype: CARPET_TUNING.hypePerSecPerBody * this.carpet.length * sec });
|
||||||
|
for (let i = this.carpet.length - 1; i >= 0; i--) {
|
||||||
|
const slot = this.carpet[i]!;
|
||||||
|
slot.standMs += deltaMs;
|
||||||
|
if (slot.standMs < slot.patienceMs) continue;
|
||||||
|
this.carpet.splice(i, 1);
|
||||||
|
this.storms.push(slot.patron);
|
||||||
|
this.bus.emit('meters:delta', { aggro: CARPET_TUNING.stormAggro });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get carpetSnapshot(): readonly CarpetSlot[] {
|
||||||
|
return this.carpet;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Storm-offs since last asked. The scene narrates; the meters already paid. */
|
||||||
|
takeStorms(): Patron[] {
|
||||||
|
return this.storms.splice(0, this.storms.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The patron at the rope takes position between the brass posts. Frees the
|
||||||
|
* rope — the theatre is that you keep working while they stand there.
|
||||||
|
*/
|
||||||
|
toCarpet(): Patron | null {
|
||||||
|
const p = this.up;
|
||||||
|
if (!p || this.carpet.length >= CARPET_TUNING.slots) return null;
|
||||||
|
this.up = null;
|
||||||
|
this.waitMs = 0;
|
||||||
|
this.hypeEarnedThisWait = 0;
|
||||||
|
const [lo, hi] = CARPET_TUNING.patienceSecBase;
|
||||||
|
const scale = CARPET_TUNING.patienceScale[p.archetype] ?? 1;
|
||||||
|
this.carpet.push({
|
||||||
|
patron: p,
|
||||||
|
standMs: 0,
|
||||||
|
patienceMs: this.carpetRng.int(lo, hi) * scale * 1000,
|
||||||
|
});
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wave them off the carpet and up to the rope. Only while the rope is free. */
|
||||||
|
recallFromCarpet(patronId: string): Patron | null {
|
||||||
|
if (this.up !== null) return null;
|
||||||
|
const i = this.carpet.findIndex((s) => s.patron.id === patronId);
|
||||||
|
if (i < 0) return null;
|
||||||
|
const slot = this.carpet.splice(i, 1)[0]!;
|
||||||
|
this.carpetStand.set(slot.patron.id, slot.standMs);
|
||||||
|
this.up = slot.patron;
|
||||||
|
this.waitMs = 0;
|
||||||
|
this.hypeEarnedThisWait = 0;
|
||||||
|
this.bus.emit('door:patronUp', { patron: slot.patron });
|
||||||
|
return slot.patron;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long this patron stood on the carpet before their recall, in ms —
|
||||||
|
* CONSUMED on read. The dividend is paid for one stand, once: a regular who
|
||||||
|
* was parked, recalled and knocked back comes round again later through the
|
||||||
|
* ordinary queue, and without this the door paid them a second entrance bonus
|
||||||
|
* for a stand that had already been cashed.
|
||||||
|
*/
|
||||||
|
carpetMsFor(patronId: string): number {
|
||||||
|
const ms = this.carpetStand.get(patronId) ?? 0;
|
||||||
|
this.carpetStand.delete(patronId);
|
||||||
|
return ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The Rope. Nothing steps up until the player says so. */
|
/** The Rope. Nothing steps up until the player says so. */
|
||||||
@ -272,7 +450,31 @@ export class QueueManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private make(): Patron {
|
private make(): Patron {
|
||||||
const p = generatePatron({ rng: this.rng, nightDate: this.nightDate }, this.clockMin);
|
let p = generatePatron(
|
||||||
|
{ rng: this.rng, nightDate: this.nightDate, ...(this.archetypeWeights ? { archetypeWeights: this.archetypeWeights } : {}) },
|
||||||
|
this.clockMin,
|
||||||
|
);
|
||||||
|
// A rolled 'regular' is one of THE regulars — the run's stable cast. Same
|
||||||
|
// face every night; the grudge (and the moustache) rides the run save.
|
||||||
|
if (p.archetype === 'regular' && this.regulars && this.regulars.cast.length > 0) {
|
||||||
|
const member = this.regulars.cast[this.castRng.int(0, this.regulars.cast.length - 1)]!;
|
||||||
|
const mem = this.regulars.memory[member.flags.regularId ?? ''];
|
||||||
|
p = adoptCastIdentity(p, member, mem?.timesDenied ?? 0, disguisedFromMemory(mem));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The passback (docs/SCENARIOS.md): late in the night, some chancer is
|
||||||
|
// holding a card that walked in HOURS ago — right name, right DOB, and a
|
||||||
|
// photo of somebody else entirely. No flag is set here; the DESK notices,
|
||||||
|
// if the desk has been paying attention.
|
||||||
|
if (
|
||||||
|
this.clockMin >= 150 &&
|
||||||
|
this.admittedCards.length > 0 &&
|
||||||
|
(p.archetype === 'punter' || p.archetype === 'almost18' || p.archetype === 'financeBro') &&
|
||||||
|
this.passbackRng.chance(0.05)
|
||||||
|
) {
|
||||||
|
const src = this.admittedCards[this.passbackRng.int(0, this.admittedCards.length - 1)]!;
|
||||||
|
p.idCard = { name: src.name, dob: src.dob, expiry: src.expiry, photoSeed: src.photoSeed };
|
||||||
|
}
|
||||||
|
|
||||||
// Anyone who says they're on the list gets a name the clipboard has an
|
// Anyone who says they're on the list gets a name the clipboard has an
|
||||||
// opinion about. Done here rather than in the generator because the list is
|
// opinion about. Done here rather than in the generator because the list is
|
||||||
|
|||||||
@ -13,13 +13,16 @@ export interface ArrivalCurvePoint {
|
|||||||
* backs up during the slam, few enough that the queue is still clearable.
|
* backs up during the slam, few enough that the queue is still clearable.
|
||||||
*/
|
*/
|
||||||
export const ARRIVAL_CURVE: readonly ArrivalCurvePoint[] = [
|
export const ARRIVAL_CURVE: readonly ArrivalCurvePoint[] = [
|
||||||
|
// Tuning pass 2026-07-19: the old peak (0.42/min) sat below even a careful
|
||||||
|
// player's throughput, so the 10:30-12:30 "slam" was fiction — measured queue
|
||||||
|
// avg 0.1-0.2. A night now delivers ~120 punters and the slam outruns you.
|
||||||
{ clockMin: 0, perMin: 0.12 }, // 9:00 PM — staff, and one bloke who thinks it's a pub
|
{ clockMin: 0, perMin: 0.12 }, // 9:00 PM — staff, and one bloke who thinks it's a pub
|
||||||
{ clockMin: 45, perMin: 0.18 }, // 9:45 PM
|
{ clockMin: 45, perMin: 0.2 }, // 9:45 PM
|
||||||
{ clockMin: 90, perMin: 0.34 }, // 10:30 PM — the pre-drinks let out
|
{ clockMin: 90, perMin: 0.55 }, // 10:30 PM — the pre-drinks let out
|
||||||
{ clockMin: 150, perMin: 0.42 }, // 11:30 PM — peak
|
{ clockMin: 150, perMin: 0.72 }, // 11:30 PM — peak
|
||||||
{ clockMin: 210, perMin: 0.32 }, // 12:30 AM — slam ends
|
{ clockMin: 210, perMin: 0.45 }, // 12:30 AM — slam ends
|
||||||
{ clockMin: 270, perMin: 0.16 }, // 1:30 AM
|
{ clockMin: 270, perMin: 0.2 }, // 1:30 AM
|
||||||
{ clockMin: 330, perMin: 0.06 }, // 2:30 AM — last drinks
|
{ clockMin: 330, perMin: 0.07 }, // 2:30 AM — last drinks
|
||||||
{ clockMin: 360, perMin: 0 }, // 3:00 AM
|
{ clockMin: 360, perMin: 0 }, // 3:00 AM
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -60,8 +63,11 @@ export function arrivalsPerMin(clockMin: number): number {
|
|||||||
* an RngStream; the exponential draw is what makes arrivals clump — three at once
|
* an RngStream; the exponential draw is what makes arrivals clump — three at once
|
||||||
* then a lull — instead of ticking over like a metronome.
|
* then a lull — instead of ticking over like a metronome.
|
||||||
*/
|
*/
|
||||||
export function nextArrivalGapMin(clockMin: number, roll: number): number {
|
export function nextArrivalGapMin(clockMin: number, roll: number, scale = 1): number {
|
||||||
const rate = arrivalsPerMin(clockMin);
|
// `scale` is the venue's arrival multiplier (ladder, design §6): a dive runs
|
||||||
|
// at 0.8x this curve, the rooftop above 1x. Guarded so a junk scale reads as 1.
|
||||||
|
const s = Number.isFinite(scale) && scale > 0 ? scale : 1;
|
||||||
|
const rate = arrivalsPerMin(clockMin) * s;
|
||||||
if (!(rate > 0)) return NO_ARRIVALS_GAP_MIN;
|
if (!(rate > 0)) return NO_ARRIVALS_GAP_MIN;
|
||||||
|
|
||||||
const safeRoll = Number.isFinite(roll) ? Math.min(Math.max(roll, 0), 0.999999) : 0;
|
const safeRoll = Number.isFinite(roll) ? Math.min(Math.max(roll, 0), 0.999999) : 0;
|
||||||
|
|||||||
@ -11,6 +11,11 @@ export interface DazzaWorld {
|
|||||||
queueLength: number;
|
queueLength: number;
|
||||||
insideCount: number;
|
insideCount: number;
|
||||||
fired: ReadonlySet<string>;
|
fired: ReadonlySet<string>;
|
||||||
|
/**
|
||||||
|
* How many rule-bearing texts this venue's week uses (chronological prefix
|
||||||
|
* of the rule book — venue ladder, design §6). Omitted = all of them.
|
||||||
|
*/
|
||||||
|
ruleLimit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Conditions for the non-rule texts, keyed by DazzaLine id. */
|
/** Conditions for the non-rule texts, keyed by DazzaLine id. */
|
||||||
@ -23,9 +28,18 @@ const MOOD: Record<string, (w: DazzaWorld) => boolean> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function dazzaDue(clockMin: number, world: DazzaWorld): DazzaLine[] {
|
export function dazzaDue(clockMin: number, world: DazzaWorld): DazzaLine[] {
|
||||||
|
// The venue's rule book: the first N rule texts by fromMin. A dive doesn't
|
||||||
|
// run the full eight; the ladder's later venues do.
|
||||||
|
const limit = world.ruleLimit ?? Number.POSITIVE_INFINITY;
|
||||||
|
const ruleOrder = DAZZA_TEXTS.filter((l) => l.ruleId !== undefined)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.fromMin - b.fromMin);
|
||||||
|
const allowedRules = new Set(ruleOrder.slice(0, limit).map((l) => l.id));
|
||||||
|
|
||||||
const due: DazzaLine[] = [];
|
const due: DazzaLine[] = [];
|
||||||
for (const line of DAZZA_TEXTS) {
|
for (const line of DAZZA_TEXTS) {
|
||||||
if (world.fired.has(line.id)) continue;
|
if (world.fired.has(line.id)) continue;
|
||||||
|
if (line.ruleId !== undefined && !allowedRules.has(line.id)) continue;
|
||||||
if (clockMin < line.fromMin) continue;
|
if (clockMin < line.fromMin) continue;
|
||||||
const gate = MOOD[line.id];
|
const gate = MOOD[line.id];
|
||||||
if (gate && !gate(world)) continue;
|
if (gate && !gate(world)) continue;
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
@ -2,7 +2,8 @@ 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 { conePolygon, type ConeSpec } from './cone';
|
import { conePolygon, type ConeSpec } from './cone';
|
||||||
import { TILE, tileAt, tileColour, type VenueMap } from './venueMap';
|
import { FIGHT_FUSE_MS } from './fight';
|
||||||
|
import { LIGHTS, PROPS, TILE, tileAt, tileColour, type PropKind, type VenueMap } from './venueMap';
|
||||||
import type { Agent } from './crowdSim';
|
import type { Agent } from './crowdSim';
|
||||||
|
|
||||||
// Pure drawing for the floor. Darkness is the mechanic: the venue is painted
|
// Pure drawing for the floor. Darkness is the mechanic: the venue is painted
|
||||||
@ -35,6 +36,22 @@ const NEON_TINTS = [0xff3d9a, 0x38e0ff, 0xb45cff] as const;
|
|||||||
|
|
||||||
const GLOW_SIZE = 64;
|
const GLOW_SIZE = 64;
|
||||||
|
|
||||||
|
// A fight is spotted by motion, not by torchlight (design §3.2) — the tell has to
|
||||||
|
// survive the darkness sheet, so the marker rides at D_MARKER and the shove is
|
||||||
|
// carried by the silhouettes, which stay legible through the 50% black.
|
||||||
|
const FIGHT_PINK = 0xd03470;
|
||||||
|
const FIGHT_AMBER = 0xd8a020;
|
||||||
|
const SHOVE_STEPS = 5;
|
||||||
|
// Wobble: bigger and faster every rung, so a fight you clocked late still looks
|
||||||
|
// worse than one you clocked early.
|
||||||
|
const SHOVE_AMP_PX = 1.2;
|
||||||
|
const SHOVE_AMP_STEP_PX = 1.1;
|
||||||
|
const SHOVE_MS = 260;
|
||||||
|
const SHOVE_MS_STEP = 22;
|
||||||
|
// Pulse period at a full fuse, and how much of it the last second buys back.
|
||||||
|
const PULSE_SLOW_MS = 520;
|
||||||
|
const PULSE_SPAN_MS = 390;
|
||||||
|
|
||||||
interface AgentGfx {
|
interface AgentGfx {
|
||||||
sil: Phaser.GameObjects.Image;
|
sil: Phaser.GameObjects.Image;
|
||||||
detail: Phaser.GameObjects.Image;
|
detail: Phaser.GameObjects.Image;
|
||||||
@ -59,8 +76,27 @@ export class FloorView {
|
|||||||
private readonly playerLight: Phaser.GameObjects.Image;
|
private readonly playerLight: Phaser.GameObjects.Image;
|
||||||
private readonly playerImg: Phaser.GameObjects.Image;
|
private readonly playerImg: Phaser.GameObjects.Image;
|
||||||
private readonly marker: Phaser.GameObjects.Image;
|
private readonly marker: Phaser.GameObjects.Image;
|
||||||
|
private readonly fightHalo: Phaser.GameObjects.Image;
|
||||||
|
private readonly fightPip: Phaser.GameObjects.Image;
|
||||||
|
|
||||||
private readonly agents = new Map<string, AgentGfx>();
|
private readonly agents = new Map<string, AgentGfx>();
|
||||||
|
/** Patron currently horizontal (the fainter) — rotated + drained of colour. */
|
||||||
|
private downedId: string | null = null;
|
||||||
|
private readonly propImgs: Phaser.GameObjects.Image[] = [];
|
||||||
|
/** glow images that pulse on beat:tick (dance grid + emissive beat props) */
|
||||||
|
private readonly beatLights: Phaser.GameObjects.Image[] = [];
|
||||||
|
/** glow images that flicker (dunny fluoro, booth lamp) with their base alpha */
|
||||||
|
private readonly flickerLights: Array<{ img: Phaser.GameObjects.Image; base: number; phase: number }> = [];
|
||||||
|
private beatKick = 0;
|
||||||
|
private yardRain: Phaser.GameObjects.Particles.ParticleEmitter | null = null;
|
||||||
|
private readonly beams: Phaser.GameObjects.Image[] = [];
|
||||||
|
private beamAngle = 0;
|
||||||
|
private dj: Phaser.GameObjects.Image | null = null;
|
||||||
|
private djBob = 0;
|
||||||
|
// Render-only shove displacement by patron id. The sim owns agent.x/agent.y;
|
||||||
|
// syncAgents adds this on top so a shoving pair jitters without the crowd
|
||||||
|
// simulation ever hearing about it.
|
||||||
|
private readonly shove = new Map<string, { dx: number; dy: number }>();
|
||||||
private frame = 0;
|
private frame = 0;
|
||||||
private uv = false;
|
private uv = false;
|
||||||
|
|
||||||
@ -111,6 +147,12 @@ export class FloorView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.buildProps();
|
||||||
|
this.buildLights();
|
||||||
|
this.buildYardRain();
|
||||||
|
this.buildDj();
|
||||||
|
this.buildScanBeams();
|
||||||
|
|
||||||
this.maskG = scene.make.graphics({}, false);
|
this.maskG = scene.make.graphics({}, false);
|
||||||
this.mask = this.maskG.createGeometryMask();
|
this.mask = this.maskG.createGeometryMask();
|
||||||
this.detailC = scene.add.container(0, 0).setDepth(D_DETAIL);
|
this.detailC = scene.add.container(0, 0).setDepth(D_DETAIL);
|
||||||
@ -118,6 +160,191 @@ export class FloorView {
|
|||||||
|
|
||||||
this.playerImg = scene.add.image(0, 0, PLAYER_TEX).setDepth(D_MARKER);
|
this.playerImg = scene.add.image(0, 0, PLAYER_TEX).setDepth(D_MARKER);
|
||||||
this.marker = scene.add.image(0, 0, MARKER_TEX).setDepth(D_MARKER).setVisible(false);
|
this.marker = scene.add.image(0, 0, MARKER_TEX).setDepth(D_MARKER).setVisible(false);
|
||||||
|
|
||||||
|
// Built here and only ever shown/hidden — a fight starts on a frame where
|
||||||
|
// there is already plenty going on.
|
||||||
|
this.fightHalo = scene.add
|
||||||
|
.image(0, 0, GLOW_TEX)
|
||||||
|
.setDepth(D_MARKER)
|
||||||
|
.setBlendMode(Phaser.BlendModes.ADD)
|
||||||
|
.setTint(FIGHT_PINK)
|
||||||
|
.setVisible(false);
|
||||||
|
this.fightPip = scene.add
|
||||||
|
.image(0, 0, MARKER_TEX)
|
||||||
|
.setDepth(D_MARKER)
|
||||||
|
.setTint(FIGHT_AMBER)
|
||||||
|
.setVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props prefer a GENERATED texture ('gen:prop:<kind>', loaded from
|
||||||
|
* public/props via the manifest — docs/SPACES.md §4) and fall back to a baked
|
||||||
|
* placeholder. They sit under the darkness sheet like the tiles do, so they
|
||||||
|
* read as silhouettes until the torch finds them.
|
||||||
|
*/
|
||||||
|
private buildProps(): void {
|
||||||
|
for (const p of PROPS) {
|
||||||
|
const gen = `gen:prop:${p.kind}`;
|
||||||
|
const key = this.scene.textures.exists(gen) ? gen : bakePropPlaceholder(this.scene, p.kind);
|
||||||
|
const img = this.scene.add
|
||||||
|
.image(p.tx * TILE, p.ty * TILE, key)
|
||||||
|
.setOrigin(0, 0)
|
||||||
|
.setDepth(D_TILES + 1)
|
||||||
|
.setDisplaySize(p.tw * TILE, p.th * TILE);
|
||||||
|
this.propImgs.push(img);
|
||||||
|
if (p.emissive) this.addGlow(p, p.emissive.colour, p.emissive.radius, p.emissive.pulse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zone light signatures — navigate by glow colour (docs/SPACES.md §1). */
|
||||||
|
private buildLights(): void {
|
||||||
|
for (const l of LIGHTS) this.addGlow(l, l.colour, l.radius, l.pulse);
|
||||||
|
}
|
||||||
|
|
||||||
|
private addGlow(
|
||||||
|
at: { tx: number; ty: number; tw?: number; th?: number },
|
||||||
|
colour: number,
|
||||||
|
radius: number,
|
||||||
|
pulse?: 'beat' | 'flicker',
|
||||||
|
): void {
|
||||||
|
const cx = (at.tx + (at.tw ?? 1) / 2) * TILE;
|
||||||
|
const cy = (at.ty + (at.th ?? 1) / 2) * TILE;
|
||||||
|
const alpha = pulse === 'beat' ? 0.22 : 0.4;
|
||||||
|
const img = this.scene.add
|
||||||
|
.image(cx, cy, GLOW_TEX)
|
||||||
|
.setDepth(D_NEON)
|
||||||
|
.setBlendMode(Phaser.BlendModes.ADD)
|
||||||
|
.setTint(colour)
|
||||||
|
.setAlpha(alpha)
|
||||||
|
.setScale(radius / 32);
|
||||||
|
this.neon.push(img); // shares neon lifecycle (destroy)
|
||||||
|
if (pulse === 'beat') this.beatLights.push(img);
|
||||||
|
else if (pulse === 'flicker') {
|
||||||
|
this.flickerLights.push({ img, base: alpha, phase: (cx * 13 + cy * 7) % 100 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The light rig: two scan beams pivoting on the disco ball, sweeping the
|
||||||
|
* dance floor in opposite directions. Baked wedge texture, additive blend —
|
||||||
|
* all show, no cost.
|
||||||
|
*/
|
||||||
|
private buildScanBeams(): void {
|
||||||
|
const key = 'floor:beam';
|
||||||
|
if (!this.scene.textures.exists(key)) {
|
||||||
|
const canvas = this.scene.textures.createCanvas(key, 180, 36);
|
||||||
|
if (canvas) {
|
||||||
|
const c = canvas.getContext();
|
||||||
|
const grad = c.createLinearGradient(0, 0, 180, 0);
|
||||||
|
grad.addColorStop(0, 'rgba(255,255,255,0.55)');
|
||||||
|
grad.addColorStop(1, 'rgba(255,255,255,0)');
|
||||||
|
c.fillStyle = grad;
|
||||||
|
c.beginPath();
|
||||||
|
c.moveTo(0, 16); c.lineTo(180, 0); c.lineTo(180, 36); c.lineTo(0, 20);
|
||||||
|
c.closePath(); c.fill();
|
||||||
|
canvas.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pivot on the disco ball (PROPS: 'ball' at 37,22 with a 2x2 footprint).
|
||||||
|
const cx = 38 * TILE;
|
||||||
|
const cy = 23 * TILE;
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
this.beams.push(
|
||||||
|
this.scene.add
|
||||||
|
.image(cx, cy, key)
|
||||||
|
.setOrigin(0, 0.5)
|
||||||
|
.setDepth(D_NEON)
|
||||||
|
.setBlendMode(Phaser.BlendModes.ADD)
|
||||||
|
.setAlpha(0.16)
|
||||||
|
.setRotation(i * Math.PI),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rain falls INSIDE the smoking yard — it is the one open-sky pocket. */
|
||||||
|
private buildYardRain(): void {
|
||||||
|
const yard = { x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity };
|
||||||
|
for (let ty = 0; ty < this.map.height; ty++) {
|
||||||
|
for (let tx = 0; tx < this.map.width; tx++) {
|
||||||
|
if (tileAt(this.map, tx, ty) !== 'yard') continue;
|
||||||
|
yard.x0 = Math.min(yard.x0, tx * TILE);
|
||||||
|
yard.y0 = Math.min(yard.y0, ty * TILE);
|
||||||
|
yard.x1 = Math.max(yard.x1, (tx + 1) * TILE);
|
||||||
|
yard.y1 = Math.max(yard.y1, (ty + 1) * TILE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(yard.x0)) return; // no yard on this map
|
||||||
|
const g = this.scene.add.graphics();
|
||||||
|
g.fillStyle(0x8899bb, 1);
|
||||||
|
g.fillRect(0, 0, 1, 3);
|
||||||
|
g.generateTexture('floor:rain', 1, 3);
|
||||||
|
g.destroy();
|
||||||
|
this.yardRain = this.scene.add.particles(0, 0, 'floor:rain', {
|
||||||
|
x: { min: yard.x0, max: yard.x1 },
|
||||||
|
y: yard.y0 - 4,
|
||||||
|
lifespan: 700,
|
||||||
|
speedY: { min: 90, max: 130 },
|
||||||
|
speedX: { min: -8, max: -2 },
|
||||||
|
quantity: 1,
|
||||||
|
alpha: { start: 0.5, end: 0.1 },
|
||||||
|
});
|
||||||
|
this.yardRain.setDepth(D_NEON);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The DJ. Sealed in the booth, bobbing on the downbeat, paid in exposure. */
|
||||||
|
private buildDj(): void {
|
||||||
|
const key = 'floor:dj';
|
||||||
|
if (!this.scene.textures.exists(key)) {
|
||||||
|
const canvas = this.scene.textures.createCanvas(key, 12, 14);
|
||||||
|
if (canvas) {
|
||||||
|
const c = canvas.getContext();
|
||||||
|
c.fillStyle = '#1c1c26'; c.fillRect(2, 6, 8, 8); // hoodie shoulders
|
||||||
|
c.fillStyle = '#d9a066'; c.fillRect(4, 2, 4, 5); // head
|
||||||
|
c.fillStyle = '#15151c'; c.fillRect(2, 2, 8, 2); // headphones band
|
||||||
|
c.fillStyle = '#3060c0'; c.fillRect(2, 3, 2, 3); c.fillRect(8, 3, 2, 3); // cans
|
||||||
|
canvas.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Behind the decks (PROPS puts the gear on the booth's west edge at tx 53;
|
||||||
|
// the DJ stands one tile east of it, centred on the mixer row).
|
||||||
|
this.dj = this.scene.add.image(55.5 * TILE, 22.5 * TILE, key).setDepth(D_TILES + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Call from the scene's beat:tick handler — the dance floor breathes in time. */
|
||||||
|
onBeat(beatIndex: number): void {
|
||||||
|
this.beatKick = beatIndex % 4 === 0 ? 1 : 0.6;
|
||||||
|
this.djBob = 1;
|
||||||
|
// The dance grid cycles colour by bar — each cell offset so the floor
|
||||||
|
// ripples instead of blinking as one.
|
||||||
|
const bar = Math.floor(beatIndex / 4);
|
||||||
|
const PALETTE = [0xd03470, 0x3060c0, 0xd8a020];
|
||||||
|
this.beatLights.forEach((img, i) => {
|
||||||
|
img.setTint(PALETTE[(bar + i) % PALETTE.length]!);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-frame light life: beat decay + fluoro flicker. */
|
||||||
|
updateLights(dtMs: number): void {
|
||||||
|
if (this.beatKick > 0) {
|
||||||
|
this.beatKick = Math.max(0, this.beatKick - dtMs / 260);
|
||||||
|
for (const img of this.beatLights) img.setAlpha(0.22 + 0.3 * this.beatKick);
|
||||||
|
}
|
||||||
|
this.beamAngle += dtMs * 0.00035 * (1 + this.beatKick * 0.8);
|
||||||
|
this.beams.forEach((b, i) => {
|
||||||
|
b.setRotation(this.beamAngle * (i === 0 ? 1 : -1) + i * Math.PI);
|
||||||
|
b.setAlpha(0.1 + 0.14 * this.beatKick);
|
||||||
|
});
|
||||||
|
if (this.dj && this.djBob > 0) {
|
||||||
|
this.djBob = Math.max(0, this.djBob - dtMs / 200);
|
||||||
|
this.dj.setY(22.5 * TILE - 2 * this.djBob);
|
||||||
|
}
|
||||||
|
const t = this.scene.time.now;
|
||||||
|
for (const f of this.flickerLights) {
|
||||||
|
// Two incommensurate sines + a hard dropout band reads as dying fluoro.
|
||||||
|
const w = Math.sin(t * 0.013 + f.phase) * Math.sin(t * 0.0037 + f.phase * 2);
|
||||||
|
const drop = w > 0.93 ? 0 : 1;
|
||||||
|
f.img.setAlpha(f.base * (0.75 + 0.25 * w) * drop);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
syncAgents(agents: readonly Agent[]): void {
|
syncAgents(agents: readonly Agent[]): void {
|
||||||
@ -150,9 +377,14 @@ export class FloorView {
|
|||||||
const y = a.y - Math.abs(Math.sin(a.bobPhase * Math.PI * 2)) * bob;
|
const y = a.y - Math.abs(Math.sin(a.bobPhase * Math.PI * 2)) * bob;
|
||||||
const x = g.swayPx > 0 ? a.x + Math.sin(t / 260 + g.phase) * g.swayPx : a.x;
|
const x = g.swayPx > 0 ? a.x + Math.sin(t / 260 + g.phase) * g.swayPx : a.x;
|
||||||
|
|
||||||
g.sil.setPosition(x, y);
|
// Empty unless this punter is squaring up, so the common path is a miss.
|
||||||
g.detail.setPosition(x, y);
|
const sh = this.shove.get(a.patron.id);
|
||||||
g.uvDot.setPosition(x + 4, y + 2);
|
const sx = sh ? x + sh.dx : x;
|
||||||
|
const sy = sh ? y + sh.dy : y;
|
||||||
|
|
||||||
|
g.sil.setPosition(sx, sy);
|
||||||
|
g.detail.setPosition(sx, sy);
|
||||||
|
g.uvDot.setPosition(sx + 4, sy + 2);
|
||||||
g.detail.setVisible(!this.uv);
|
g.detail.setVisible(!this.uv);
|
||||||
// No stamp, no dot — the absence is the tell.
|
// No stamp, no dot — the absence is the tell.
|
||||||
g.uvDot.setVisible(this.uv && a.patron.flags.uvStamped === true);
|
g.uvDot.setVisible(this.uv && a.patron.flags.uvStamped === true);
|
||||||
@ -200,7 +432,79 @@ export class FloorView {
|
|||||||
this.marker.setAlpha(0.55 + 0.45 * Math.abs(Math.sin(this.scene.time.now / 260)));
|
this.marker.setAlpha(0.55 + 0.45 * Math.abs(Math.sin(this.scene.time.now / 260)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Draw the brewing-fight tell between two agents. Pass null when no fight is live. */
|
||||||
|
/** Lay a patron down (the fainter) or stand the previous one back up (null). */
|
||||||
|
setDowned(patronId: string | null): void {
|
||||||
|
if (this.downedId && this.downedId !== patronId) {
|
||||||
|
const prev = this.agents.get(this.downedId);
|
||||||
|
if (prev) {
|
||||||
|
prev.sil.setRotation(0);
|
||||||
|
prev.detail.setRotation(0);
|
||||||
|
prev.detail.clearTint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.downedId = patronId;
|
||||||
|
if (patronId) {
|
||||||
|
const g = this.agents.get(patronId);
|
||||||
|
if (g) {
|
||||||
|
g.sil.setRotation(Math.PI / 2);
|
||||||
|
g.detail.setRotation(Math.PI / 2);
|
||||||
|
g.detail.setTint(0xb8c4d8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFight(pair: readonly [Agent, Agent] | null, shoves: number, msLeft: number): void {
|
||||||
|
this.shove.clear();
|
||||||
|
if (!pair) {
|
||||||
|
this.fightHalo.setVisible(false);
|
||||||
|
this.fightPip.setVisible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [a, b] = pair;
|
||||||
|
const t = this.scene.time.now;
|
||||||
|
const rung = Math.max(0, Math.min(SHOVE_STEPS, shoves));
|
||||||
|
|
||||||
|
// Unit vector a -> b. Two punters standing in exactly the same spot still
|
||||||
|
// need somewhere to shove, so fall back to sideways.
|
||||||
|
const dx = b.x - a.x;
|
||||||
|
const dy = b.y - a.y;
|
||||||
|
const dist = Math.hypot(dx, dy);
|
||||||
|
const ux = dist > 0 ? dx / dist : 1;
|
||||||
|
const uy = dist > 0 ? dy / dist : 0;
|
||||||
|
|
||||||
|
// Positive swings them into each other, negative rocks them back off it.
|
||||||
|
const amp = SHOVE_AMP_PX + rung * SHOVE_AMP_STEP_PX;
|
||||||
|
const period = SHOVE_MS - rung * SHOVE_MS_STEP;
|
||||||
|
const off = Math.sin((t / period) * Math.PI * 2) * amp;
|
||||||
|
this.shove.set(a.patron.id, { dx: ux * off, dy: uy * off });
|
||||||
|
this.shove.set(b.patron.id, { dx: -ux * off, dy: -uy * off });
|
||||||
|
|
||||||
|
const mx = (a.x + b.x) / 2;
|
||||||
|
const my = (a.y + b.y) / 2;
|
||||||
|
|
||||||
|
// Urgency drives the pulse, not the rung: the fuse is what you're racing.
|
||||||
|
const u = Math.max(0, Math.min(1, 1 - msLeft / FIGHT_FUSE_MS));
|
||||||
|
const pulseMs = PULSE_SLOW_MS - PULSE_SPAN_MS * u;
|
||||||
|
const p = 0.5 + 0.5 * Math.sin((t / pulseMs) * Math.PI * 2);
|
||||||
|
|
||||||
|
this.fightHalo
|
||||||
|
.setVisible(true)
|
||||||
|
.setPosition(mx, my)
|
||||||
|
.setAlpha(0.22 + 0.5 * p)
|
||||||
|
.setScale(1.5 + 0.55 * p + 0.3 * u);
|
||||||
|
this.fightPip
|
||||||
|
.setVisible(true)
|
||||||
|
.setPosition(mx, my - 15 - 3 * p)
|
||||||
|
.setAlpha(0.55 + 0.45 * p)
|
||||||
|
.setScale(1 + 0.35 * u);
|
||||||
|
}
|
||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
|
this.shove.clear();
|
||||||
|
this.fightHalo.destroy();
|
||||||
|
this.fightPip.destroy();
|
||||||
for (const g of this.agents.values()) this.destroyGfx(g);
|
for (const g of this.agents.values()) this.destroyGfx(g);
|
||||||
this.agents.clear();
|
this.agents.clear();
|
||||||
this.detailC.clearMask(true);
|
this.detailC.clearMask(true);
|
||||||
@ -210,6 +514,16 @@ export class FloorView {
|
|||||||
this.dark.destroy();
|
this.dark.destroy();
|
||||||
this.tileImg.destroy();
|
this.tileImg.destroy();
|
||||||
for (const n of this.neon) n.destroy();
|
for (const n of this.neon) n.destroy();
|
||||||
|
this.yardRain?.destroy();
|
||||||
|
this.yardRain = null;
|
||||||
|
for (const b of this.beams) b.destroy();
|
||||||
|
this.beams.length = 0;
|
||||||
|
this.dj?.destroy();
|
||||||
|
this.dj = null;
|
||||||
|
for (const pr of this.propImgs) pr.destroy();
|
||||||
|
this.propImgs.length = 0;
|
||||||
|
this.beatLights.length = 0;
|
||||||
|
this.flickerLights.length = 0;
|
||||||
this.neon.length = 0;
|
this.neon.length = 0;
|
||||||
this.playerLight.destroy();
|
this.playerLight.destroy();
|
||||||
this.playerImg.destroy();
|
this.playerImg.destroy();
|
||||||
@ -239,6 +553,20 @@ export class FloorView {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop every cached sprite. The scene must call this on reseed: patron ids are
|
||||||
|
* recycled from p0 by _resetPatronSerial(), so without it the new run's crowd
|
||||||
|
* inherits the old run's dolls, sway and stamps.
|
||||||
|
*/
|
||||||
|
reset(): void {
|
||||||
|
for (const g of this.agents.values()) this.destroyGfx(g);
|
||||||
|
this.agents.clear();
|
||||||
|
this.shove.clear();
|
||||||
|
this.fightHalo.setVisible(false);
|
||||||
|
this.fightPip.setVisible(false);
|
||||||
|
this.marker.setVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
private drop(id: string): void {
|
private drop(id: string): void {
|
||||||
const g = this.agents.get(id);
|
const g = this.agents.get(id);
|
||||||
if (!g) return;
|
if (!g) return;
|
||||||
@ -299,3 +627,139 @@ export class FloorView {
|
|||||||
g.destroy();
|
g.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- prop placeholders --------------------------------------------------------
|
||||||
|
// Chunky pixel stand-ins, one per PropKind, drawn at the prop's natural footprint.
|
||||||
|
// Generated art (public/props/<kind>.png via tools/props_import.py) replaces
|
||||||
|
// these by texture key without touching any of this.
|
||||||
|
|
||||||
|
const PROP_SIZES: Record<PropKind, [number, number]> = {
|
||||||
|
deck: [32, 32], mixer: [32, 16], monitor: [16, 16], boothLamp: [16, 16],
|
||||||
|
speaker: [16, 32], discoball: [32, 32],
|
||||||
|
barShelf: [496, 16], taps: [48, 16], till: [32, 16], glassStack: [32, 16],
|
||||||
|
glassRack: [32, 16], boothTable: [32, 16], sinkRow: [96, 16],
|
||||||
|
buttBin: [16, 16], crate: [16, 16],
|
||||||
|
poster1: [32, 16], poster2: [32, 16], poster3: [32, 16],
|
||||||
|
cloak: [16, 48], stampPodium: [16, 16], arcade: [32, 32],
|
||||||
|
highTable: [16, 16], plant: [16, 16], wetFloor: [16, 16], poolTable: [48, 32],
|
||||||
|
dishwasher: [32, 16],
|
||||||
|
};
|
||||||
|
|
||||||
|
function bakePropPlaceholder(scene: Phaser.Scene, kind: PropKind): string {
|
||||||
|
const key = `prop:${kind}`;
|
||||||
|
if (scene.textures.exists(key)) return key;
|
||||||
|
const [w, h] = PROP_SIZES[kind];
|
||||||
|
const canvas = scene.textures.createCanvas(key, w, h);
|
||||||
|
if (!canvas) return key;
|
||||||
|
const c = canvas.getContext();
|
||||||
|
const f = (x: number, y: number, fw: number, fh: number, col: string): void => {
|
||||||
|
c.fillStyle = col;
|
||||||
|
c.fillRect(x, y, fw, fh);
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (kind) {
|
||||||
|
case 'deck':
|
||||||
|
f(1, 1, 30, 30, '#3a3a46'); f(4, 4, 24, 24, '#18181f');
|
||||||
|
c.beginPath(); c.arc(16, 16, 10, 0, Math.PI * 2); c.fillStyle = '#242430'; c.fill();
|
||||||
|
c.beginPath(); c.arc(16, 16, 3, 0, Math.PI * 2); c.fillStyle = '#c0c0c8'; c.fill();
|
||||||
|
f(25, 6, 3, 14, '#8a8a94');
|
||||||
|
break;
|
||||||
|
case 'mixer':
|
||||||
|
f(1, 1, 30, 14, '#2c2c36');
|
||||||
|
for (const x of [7, 15, 23]) { f(x, 3, 2, 10, '#101018'); f(x - 1, 7, 4, 2, '#d03470'); }
|
||||||
|
break;
|
||||||
|
case 'monitor':
|
||||||
|
f(2, 3, 12, 10, '#1a1a24'); f(4, 5, 8, 6, '#3a86c8');
|
||||||
|
break;
|
||||||
|
case 'boothLamp':
|
||||||
|
f(6, 8, 4, 7, '#2a2a34'); f(4, 3, 8, 6, '#3060c0');
|
||||||
|
break;
|
||||||
|
case 'speaker':
|
||||||
|
f(1, 1, 14, 30, '#15151c');
|
||||||
|
c.beginPath(); c.arc(8, 10, 5, 0, Math.PI * 2); c.fillStyle = '#2e2e3a'; c.fill();
|
||||||
|
c.beginPath(); c.arc(8, 23, 5, 0, Math.PI * 2); c.fillStyle = '#2e2e3a'; c.fill();
|
||||||
|
break;
|
||||||
|
case 'discoball':
|
||||||
|
c.beginPath(); c.arc(16, 16, 12, 0, Math.PI * 2); c.fillStyle = '#8a8a98'; c.fill();
|
||||||
|
for (let y = 6; y < 28; y += 5) for (let x = 6 + (y % 2) * 2; x < 28; x += 5) f(x, y, 2, 2, '#e8e8f2');
|
||||||
|
break;
|
||||||
|
case 'barShelf':
|
||||||
|
f(0, 0, 496, 16, '#2a2018');
|
||||||
|
for (let x = 6; x < 490; x += 10) {
|
||||||
|
const cols = ['#d8a020', '#7a4fc0', '#2e7d46', '#c03434'];
|
||||||
|
f(x, 3, 4, 10, cols[(x / 10) % 4 | 0] ?? '#d8a020');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'taps':
|
||||||
|
f(0, 10, 48, 6, '#4a3a28');
|
||||||
|
for (const x of [8, 22, 36]) { f(x, 2, 3, 9, '#8a8a94'); f(x - 2, 1, 7, 3, '#d8c23a'); }
|
||||||
|
break;
|
||||||
|
case 'till':
|
||||||
|
f(2, 4, 28, 11, '#3a3a46'); f(5, 6, 10, 5, '#9fe8a0'); f(18, 7, 9, 6, '#22222c');
|
||||||
|
break;
|
||||||
|
case 'glassStack':
|
||||||
|
for (const x of [3, 11, 19, 25]) { f(x, 4, 5, 9, '#aeb8c8'); f(x + 1, 2, 3, 3, '#dce4ee'); }
|
||||||
|
break;
|
||||||
|
case 'glassRack':
|
||||||
|
f(0, 0, 32, 16, '#33333e');
|
||||||
|
for (let x = 3; x < 30; x += 6) f(x, 3, 4, 10, '#9fb2c8');
|
||||||
|
break;
|
||||||
|
case 'boothTable':
|
||||||
|
c.beginPath(); c.arc(16, 8, 7, 0, Math.PI * 2); c.fillStyle = '#4a3226'; c.fill();
|
||||||
|
f(11, 5, 3, 4, '#d8a050'); f(19, 6, 3, 4, '#c03434');
|
||||||
|
break;
|
||||||
|
case 'sinkRow':
|
||||||
|
f(0, 0, 96, 4, '#5c6c72');
|
||||||
|
for (const x of [8, 40, 72]) { f(x, 6, 16, 8, '#8a9aa2'); f(x + 5, 8, 6, 4, '#22303a'); }
|
||||||
|
break;
|
||||||
|
case 'buttBin':
|
||||||
|
f(5, 3, 6, 12, '#4c4c56'); f(4, 2, 8, 3, '#6a6a76'); f(7, 0, 2, 3, '#d8a050');
|
||||||
|
break;
|
||||||
|
case 'crate':
|
||||||
|
f(2, 4, 12, 10, '#2c4a6a'); f(3, 5, 10, 2, '#16283c'); f(3, 9, 10, 2, '#16283c');
|
||||||
|
break;
|
||||||
|
case 'poster1':
|
||||||
|
f(1, 1, 30, 14, '#c8b890'); f(4, 3, 24, 4, '#c03434'); f(4, 9, 18, 3, '#22222c');
|
||||||
|
break;
|
||||||
|
case 'poster2':
|
||||||
|
f(1, 1, 30, 14, '#20303c'); f(4, 4, 24, 8, '#d03470'); f(8, 6, 14, 3, '#e8e8f2');
|
||||||
|
break;
|
||||||
|
case 'poster3':
|
||||||
|
f(1, 1, 30, 14, '#d8d0b8'); f(5, 3, 8, 8, '#6d4726'); f(16, 4, 12, 2, '#22222c'); f(16, 8, 10, 2, '#22222c');
|
||||||
|
break;
|
||||||
|
case 'cloak':
|
||||||
|
f(0, 0, 16, 48, '#2a2230');
|
||||||
|
for (const y of [6, 16, 26, 36]) { f(3, y, 10, 6, '#5a4a6a'); f(7, y - 2, 2, 3, '#8a8a94'); }
|
||||||
|
break;
|
||||||
|
case 'stampPodium':
|
||||||
|
f(3, 2, 10, 12, '#3c3040'); f(5, 4, 6, 4, '#7a4fc0');
|
||||||
|
break;
|
||||||
|
case 'arcade':
|
||||||
|
f(3, 1, 26, 30, '#1c2a3c'); f(7, 5, 18, 12, '#40c0d0'); f(9, 20, 6, 4, '#c03434'); f(19, 20, 6, 4, '#d8c23a');
|
||||||
|
break;
|
||||||
|
case 'highTable':
|
||||||
|
c.beginPath(); c.arc(8, 8, 6, 0, Math.PI * 2); c.fillStyle = '#3c2e22'; c.fill();
|
||||||
|
f(5, 5, 3, 3, '#d8a050'); f(9, 7, 3, 3, '#9fb2c8');
|
||||||
|
break;
|
||||||
|
case 'plant':
|
||||||
|
f(6, 10, 4, 5, '#6e4a2e');
|
||||||
|
f(4, 3, 3, 6, '#2e7d46'); f(9, 2, 3, 7, '#2e7d46'); f(6, 1, 3, 8, '#3a9456');
|
||||||
|
break;
|
||||||
|
case 'wetFloor':
|
||||||
|
f(5, 2, 6, 12, '#d8c23a'); f(6, 4, 4, 4, '#22222c');
|
||||||
|
break;
|
||||||
|
case 'dishwasher':
|
||||||
|
f(1, 1, 30, 14, '#3a4450'); f(4, 4, 10, 8, '#22303a'); f(17, 4, 11, 3, '#50c060'); f(17, 9, 11, 3, '#8a9aa2');
|
||||||
|
break;
|
||||||
|
case 'poolTable':
|
||||||
|
f(2, 2, 44, 28, '#4a3226'); f(5, 5, 38, 22, '#1e6e3c');
|
||||||
|
for (const [px, py] of [[6, 6], [42, 6], [6, 25], [42, 25], [24, 5], [24, 26]] as const) {
|
||||||
|
c.beginPath(); c.arc(px, py, 2, 0, Math.PI * 2); c.fillStyle = '#0c0c12'; c.fill();
|
||||||
|
}
|
||||||
|
f(20, 14, 3, 3, '#f2f2f2'); f(28, 12, 3, 3, '#c03434'); f(31, 18, 3, 3, '#d8c23a');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
canvas.refresh();
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@ -9,7 +9,8 @@ import { isWalkable, isWalkableWorld, tileToWorld, worldToTile } from './venueMa
|
|||||||
import type { StallDef, VenueMap, WorldPoint } from './venueMap';
|
import type { StallDef, VenueMap, WorldPoint } from './venueMap';
|
||||||
|
|
||||||
export type Activity =
|
export type Activity =
|
||||||
| 'walking' | 'atBar' | 'dancing' | 'inBooth' | 'inStall' | 'smoking' | 'escorted' | 'leaving';
|
| 'walking' | 'atBar' | 'dancing' | 'inBooth' | 'inStall' | 'smoking' | 'escorted' | 'leaving'
|
||||||
|
| 'squaringUp';
|
||||||
|
|
||||||
export interface Agent {
|
export interface Agent {
|
||||||
patron: Patron;
|
patron: Patron;
|
||||||
@ -23,6 +24,12 @@ export interface Agent {
|
|||||||
handled: boolean; // player has already dealt with this one
|
handled: boolean; // player has already dealt with this one
|
||||||
gone: boolean; // reached the exit / left the sim; scene should drop the sprite
|
gone: boolean; // reached the exit / left the sim; scene should drop the sprite
|
||||||
lastBumpMs: number; // cooldown so bumping can't spam meters
|
lastBumpMs: number; // cooldown so bumping can't spam meters
|
||||||
|
/** In-game minutes this patron intends to stay before going home. */
|
||||||
|
stayMinutes: number;
|
||||||
|
/** Clock minute they hit the floor (set on first update tick). */
|
||||||
|
arrivedAtMin?: number;
|
||||||
|
/** Done for the night: walking to the entry, then gone (emits floor:leave). */
|
||||||
|
headingHome?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CrowdSimOpts {
|
export interface CrowdSimOpts {
|
||||||
@ -158,6 +165,11 @@ export class CrowdSim {
|
|||||||
handled: false,
|
handled: false,
|
||||||
gone: false,
|
gone: false,
|
||||||
lastBumpMs: BUMP_COOLDOWN_MS,
|
lastBumpMs: BUMP_COOLDOWN_MS,
|
||||||
|
// Nobody stays all night. 50-140 in-game minutes, then they head home —
|
||||||
|
// without this, capacity was a one-way ratchet and the door had to lock
|
||||||
|
// shut from 1 AM (tuning pass 2026-07-19). Derived from dollSeed, not the
|
||||||
|
// stream: drawing here would shift every later roll in the sim.
|
||||||
|
stayMinutes: 50 + (Math.abs(patron.dollSeed) % 91),
|
||||||
};
|
};
|
||||||
this.plans.set(agent, {
|
this.plans.set(agent, {
|
||||||
haunt: 'bar', path: [], bestDist: Infinity, stuckMs: 0, stumbleMs: 0, stumbleAngle: 0,
|
haunt: 'bar', path: [], bestDist: Infinity, stuckMs: 0, stumbleMs: 0, stumbleAngle: 0,
|
||||||
@ -178,7 +190,16 @@ export class CrowdSim {
|
|||||||
for (const a of this.agents) {
|
for (const a of this.agents) {
|
||||||
if (a.gone) continue;
|
if (a.gone) continue;
|
||||||
a.lastBumpMs += ms;
|
a.lastBumpMs += ms;
|
||||||
if (a.activity === 'escorted') continue;
|
if (a.arrivedAtMin === undefined) a.arrivedAtMin = clockMin;
|
||||||
|
// Both are driven from outside: the player's escort, and the fight director.
|
||||||
|
if (a.activity === 'escorted' || a.activity === 'squaringUp') continue;
|
||||||
|
if (
|
||||||
|
!a.headingHome &&
|
||||||
|
a.activity !== 'inStall' &&
|
||||||
|
clockMin >= a.arrivedAtMin + a.stayMinutes
|
||||||
|
) {
|
||||||
|
this.headHome(a);
|
||||||
|
}
|
||||||
if (a.activity === 'walking') this.walk(a, ms);
|
if (a.activity === 'walking') this.walk(a, ms);
|
||||||
else this.dwell(a, ms);
|
else this.dwell(a, ms);
|
||||||
}
|
}
|
||||||
@ -282,6 +303,34 @@ export class CrowdSim {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pin an agent for a brewing fight. False if it isn't available (gone/escorted/in a stall). */
|
||||||
|
pinForFight(agent: Agent): boolean {
|
||||||
|
if (agent.gone) return false;
|
||||||
|
if (
|
||||||
|
agent.activity === 'escorted' || agent.activity === 'leaving' ||
|
||||||
|
agent.activity === 'inStall' || agent.activity === 'squaringUp'
|
||||||
|
) return false;
|
||||||
|
|
||||||
|
agent.activity = 'squaringUp';
|
||||||
|
agent.vx = 0;
|
||||||
|
agent.vy = 0;
|
||||||
|
// Whatever they were nursing is over; the fight owns the dwell now.
|
||||||
|
agent.dwellMs = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Release back into the crowd; they walk it off. */
|
||||||
|
releaseFromFight(agent: Agent): void {
|
||||||
|
// Anything else got hold of them mid-fight (escorted, then gone) — leave it alone.
|
||||||
|
if (agent.activity !== 'squaringUp') return;
|
||||||
|
agent.activity = 'walking';
|
||||||
|
agent.dwellMs = 0;
|
||||||
|
agent.stallId = undefined;
|
||||||
|
const plan = this.plans.get(agent);
|
||||||
|
if (plan) plan.stumbleMs = 0;
|
||||||
|
this.chooseTarget(agent);
|
||||||
|
}
|
||||||
|
|
||||||
find(patronId: string): Agent | undefined {
|
find(patronId: string): Agent | undefined {
|
||||||
return this.agents.find((a) => a.patron.id === patronId);
|
return this.agents.find((a) => a.patron.id === patronId);
|
||||||
}
|
}
|
||||||
@ -398,9 +447,38 @@ 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. */
|
||||||
|
private headHome(a: Agent): void {
|
||||||
|
const plan = this.plans.get(a);
|
||||||
|
if (!plan) return;
|
||||||
|
a.headingHome = true;
|
||||||
|
a.activity = 'walking';
|
||||||
|
a.dwellMs = 0;
|
||||||
|
plan.path = [];
|
||||||
|
plan.bestDist = Infinity;
|
||||||
|
plan.stuckMs = 0;
|
||||||
|
plan.stallId = undefined;
|
||||||
|
const entries = this.map.anchors.entry;
|
||||||
|
if (entries.length > 0) a.target = this.rng.pick(entries);
|
||||||
|
}
|
||||||
|
|
||||||
private arrive(a: Agent, plan: Plan): void {
|
private arrive(a: Agent, plan: Plan): void {
|
||||||
a.vx = 0;
|
a.vx = 0;
|
||||||
a.vy = 0;
|
a.vy = 0;
|
||||||
|
|
||||||
|
if (a.headingHome) {
|
||||||
|
a.activity = 'leaving';
|
||||||
|
a.gone = true;
|
||||||
|
this.bus.emit('floor:leave', { patronId: a.patron.id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
a.dwellMs = this.rng.int(DWELL_MIN_MS, DWELL_MAX_MS);
|
a.dwellMs = this.rng.int(DWELL_MIN_MS, DWELL_MAX_MS);
|
||||||
|
|
||||||
if (plan.haunt === 'toilet' && plan.stallId !== undefined) {
|
if (plan.haunt === 'toilet' && plan.stallId !== undefined) {
|
||||||
|
|||||||