Compare commits

..

No commits in common. "main" and "lane/content" have entirely different histories.

146 changed files with 330 additions and 12464 deletions

1
.gitignore vendored
View File

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

View File

@ -35,13 +35,9 @@ 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) · M toggles the MIX DESK overlay anywhere (ear-pass rig). URL hashes). `localStorage.removeItem('not-tonight-save')` for a clean run.
`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
@ -65,13 +61,3 @@ 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).

File diff suppressed because it is too large Load Diff

View File

@ -1 +0,0 @@
raw drops go here; process with tools/props_import.py

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 823 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 944 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

View File

@ -26,42 +26,16 @@ 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. RIGROOM — the character × animation factory (surveyed 2026-07-19) ### 2. Drunk/idle/dance animation references
**This supersedes the old "animation references" note. The fleet has a full `ultra:~/Documents/90sDJsim/web/world/models/` — 22 ped GLBs + **9 anim clip GLBs
character/animation pipeline, live and organised:** incl. `walk_drunk`, idle, sit** (from the shared `character_kit` rig). Plus
- **RIGROOM library** (`ultra:~/meshgod-backups/library`, 2.4 GB): **607 `ultra:~/Documents/MOTIONLIB/` — 2,703 motion/anim FBX.
animation FBX** in themed packs + 59 character files. Web UI live at - Option A (cheap): use `walk_drunk` etc. as *visual reference* for hand-animating
`http://100.91.239.7:8010/rigroom` (characters × animations pairing, three.js the pixel sway/stumble frames.
preview, instant Mixamo-ready FBX download). CLI: - Option B (pipeline): pre-render `character_kit` characters + MOTIONLIB clips to
`MESHGOD/scripts/rigroom.py classify|sort|scan`. top-down sprite-sheet rotations for the Floor scene (Blender orthographic cam →
- Clips that read like THIS game's shot list: `Searching_Pockets` (pat-down), pixelate). Heavier, but gives unlimited consistent NPC animation. Decide at v0.4;
`Standing_Arguing` + a full boxing/knockout pack (floor fights), the doll contract's `floorTopDown` pose doesn't care which path wins.
`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

View File

@ -1,91 +0,0 @@
# 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).

View File

@ -1,115 +0,0 @@
# 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 4864 px, furniture
2432 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.)*

Binary file not shown.

Before

Width:  |  Height:  |  Size: 836 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 424 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 644 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 571 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 691 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 549 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 552 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 B

View File

@ -1,32 +0,0 @@
{
"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"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 918 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 351 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 396 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 851 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 892 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 657 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 502 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1001 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 646 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 387 B

View File

@ -1,445 +0,0 @@
// 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;
}

View File

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

View File

@ -1,22 +1,11 @@
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,
@ -40,33 +29,28 @@ 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;
/** /**
* The bass tail settles a touch above the sweep floor: `filterLoHz` is where the * Semitones from A1, one entry per 16th. Nothing lands on a downbeat the kick
* note *starts*, and resting exactly there makes the release sound like a gate. * owns that slot, and leaving it empty is what makes the bassline roll instead
* Expressed as a ratio so retuning the floor carries the rest point with it. * of thud.
*/
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,
@ -86,29 +70,6 @@ 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;
@ -124,10 +85,6 @@ 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;
@ -148,12 +105,6 @@ 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';
@ -171,13 +122,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 = this.mixState.master; this.master.gain.value = MASTER_GAIN;
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 = this.qFor(this.loc); this.filter.Q.value = DOOR_Q;
this.filter.connect(this.master); this.filter.connect(this.master);
this.musicBus = this.ctx.createGain(); this.musicBus = this.ctx.createGain();
@ -193,14 +144,7 @@ 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 {
@ -219,76 +163,6 @@ 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.
@ -354,7 +228,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, this.qFor(location), durS, false); this.ramp(this.filter.Q, location === 'floor' ? FLOOR_Q : DOOR_Q, 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. */
@ -371,7 +245,6 @@ 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);
} }
@ -417,148 +290,104 @@ 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;
// Silent voices are skipped, not scheduled quietly: an exponential ramp to this.kick(timeMs / 1000);
// 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 (hatGain > SILENT && offbeat !== undefined) this.hat(offbeat / 1000, hatGain); if (offbeat !== undefined) this.hat(offbeat / 1000);
const bassGain = this.gainOf('bass'); for (let s = 0; s < sixteenths.length; s++) {
if (bassGain > SILENT) { const at = sixteenths[s];
for (let s = 0; s < sixteenths.length; s++) { const semi = BASS_PATTERN[patternStep(beatIndex, s, BASS_STEPS)];
const at = sixteenths[s]; if (at === undefined || semi === undefined || semi === null) continue;
const semi = BASS_PATTERN[patternStep(beatIndex, s, BASS_STEPS)]; this.bass(at / 1000, BASS_ROOT_HZ * Math.pow(2, semi / 12));
if (at === undefined || semi === undefined || semi === null) continue;
this.bass(at / 1000, mix.bass.rootHz * Math.pow(2, semi / 12), bassGain);
}
} }
const padGain = this.gainOf('pad'); if (isDownbeat(beatIndex) && barOf(beatIndex) % PAD_BARS === 0) this.pad(timeMs / 1000);
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, peak: number): void { private kick(at: 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(k.startHz, at); osc.frequency.setValueAtTime(150, at);
osc.frequency.exponentialRampToValueAtTime(k.endHz, at + k.dropS); osc.frequency.exponentialRampToValueAtTime(50, at + 0.06);
// 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(peak, at + k.attackS); gain.gain.exponentialRampToValueAtTime(KICK_PEAK, at + 0.008);
gain.gain.exponentialRampToValueAtTime(SILENT, at + decayS); gain.gain.exponentialRampToValueAtTime(SILENT, at + 0.34);
osc.connect(gain).connect(this.musicBus); osc.connect(gain).connect(this.musicBus);
osc.start(at); osc.start(at);
osc.stop(at + decayS + 0.02); osc.stop(at + 0.36);
this.hold(osc, [gain]); this.hold(osc, [gain]);
} }
private hat(at: number, peak: number): void { private hat(at: 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 = h.hpHz; hp.frequency.value = 7000;
const gain = this.ctx.createGain(); const gain = this.ctx.createGain();
gain.gain.setValueAtTime(peak, at); gain.gain.setValueAtTime(HAT_PEAK, at);
gain.gain.exponentialRampToValueAtTime(SILENT, at + h.decayS); gain.gain.exponentialRampToValueAtTime(SILENT, at + 0.04);
src.connect(hp).connect(gain).connect(this.musicBus); src.connect(hp).connect(gain).connect(this.musicBus);
// Read far enough to cover the envelope; a longer decay must not be cut off src.start(at, offset, 0.06);
// 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, peak: number): void { private bass(at: number, hz: 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 = b.q; lp.Q.value = 8;
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(b.filterLoHz, at); lp.frequency.setValueAtTime(180, at);
lp.frequency.exponentialRampToValueAtTime(b.filterHiHz, at + BASS_SWEEP_S); lp.frequency.exponentialRampToValueAtTime(900, at + 0.03);
lp.frequency.exponentialRampToValueAtTime(b.filterLoHz * BASS_TAIL_RATIO, at + decayS - 0.01); lp.frequency.exponentialRampToValueAtTime(200, at + 0.11);
const gain = this.ctx.createGain(); const gain = this.ctx.createGain();
gain.gain.setValueAtTime(SILENT, at); gain.gain.setValueAtTime(SILENT, at);
gain.gain.exponentialRampToValueAtTime(peak, at + 0.006); gain.gain.exponentialRampToValueAtTime(BASS_PEAK, at + 0.006);
gain.gain.exponentialRampToValueAtTime(SILENT, at + decayS); gain.gain.exponentialRampToValueAtTime(SILENT, at + 0.12);
lp.connect(gain).connect(this.musicBus); lp.connect(gain).connect(this.musicBus);
const spread = [-b.detuneCents, b.detuneCents]; for (const detune of [-8, 8]) {
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 + decayS + 0.01); osc.stop(at + 0.13);
// Hang the shared chain off the LAST saw by index, not by sign: at zero this.hold(osc, detune > 0 ? [lp, gain] : []);
// 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, peak: number, bars: number): void { private pad(at: number): void {
const cycle = (bars * 4 * beatIntervalMs(this.bpm)) / 1000; const cycle = (PAD_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 = this.mixState.pad.lpHz; lp.frequency.value = 600;
const gain = this.ctx.createGain(); const gain = this.ctx.createGain();
gain.gain.setValueAtTime(0, at); gain.gain.setValueAtTime(0, at);
gain.gain.linearRampToValueAtTime(peak, at + cycle * PAD_RISE); gain.gain.linearRampToValueAtTime(PAD_PEAK, at + cycle * 0.4);
gain.gain.linearRampToValueAtTime(0, at + cycle); gain.gain.linearRampToValueAtTime(0, at + cycle);
lp.connect(gain).connect(this.musicBus); lp.connect(gain).connect(this.musicBus);
@ -577,44 +406,6 @@ 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.

View File

@ -1,98 +0,0 @@
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;
}

View File

@ -1,159 +0,0 @@
// 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)},
}`;
}

View File

@ -18,19 +18,6 @@ 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 {

View File

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

View File

@ -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: 1, colours: ['black', 'navy', 'grey'] }, { type: 'blazer', weight: 2, 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: 1, colours: ['cream', 'black', 'green'], canLogo: true, canVintage: true }, { type: 'bucketHat', weight: 2, 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: 1, colours: ['black'] }, // sunglasses. at night. a tell in itself { type: 'sunnies', weight: 2, 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'] },
], ],

View File

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

View File

@ -22,10 +22,6 @@ 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 },

View File

@ -16,16 +16,6 @@ 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. */
@ -84,20 +74,6 @@ 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',
@ -164,61 +140,6 @@ 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',

View File

@ -21,9 +21,6 @@ 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> = {
@ -83,194 +80,4 @@ 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."',
},
}; };

View File

@ -80,43 +80,6 @@ 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."',
@ -126,295 +89,3 @@ 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;

View File

@ -22,8 +22,6 @@ 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 {
@ -44,11 +42,6 @@ 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 {
@ -127,8 +120,6 @@ 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 ----
@ -145,8 +136,6 @@ 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' };

View File

@ -1,102 +0,0 @@
// 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}`;
}

View File

@ -6,9 +6,7 @@ import { DoorScene } from './scenes/door/DoorScene';
import { NightSummaryScene } from './scenes/door/NightSummaryScene'; import { NightSummaryScene } from './scenes/door/NightSummaryScene';
import { IncidentReportScene } from './scenes/door/IncidentReportScene'; import { IncidentReportScene } from './scenes/door/IncidentReportScene';
import { FloorDemoScene } from './scenes/floor/FloorDemoScene'; import { FloorDemoScene } from './scenes/floor/FloorDemoScene';
import { RosterScene } from './scenes/shared/RosterScene';
import { JuiceDemoScene } from './ui/JuiceDemoScene'; import { JuiceDemoScene } from './ui/JuiceDemoScene';
import { MixDeskScene } from './ui/MixDeskScene';
// 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
@ -26,10 +24,7 @@ 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,
@ -39,7 +34,6 @@ const game = new Phaser.Game({
ParadeScene, ParadeScene,
new FloorDemoScene('FloorDemo'), new FloorDemoScene('FloorDemo'),
JuiceDemoScene, JuiceDemoScene,
MixDeskScene,
], ],
}); });
@ -53,28 +47,19 @@ 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 ROOT_SCENES) { for (const key of ['Night', 'Door', 'NightSummary', 'Parade', 'FloorDemo', 'JuiceDemo']) {
if (key !== target && game.scene.isActive(key)) game.scene.stop(key); if (key !== target && game.scene.isActive(key)) game.scene.stop(key);
} }
// Pressing a route key for what is already running means "start it over". if (!game.scene.isActive(target)) game.scene.start(target);
// 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);
}; };
// RosterScene is scene[0] and auto-starts. Hash routing must wait until it is // NightScene is scene[0] and auto-starts. Hash routing must wait until it is
// actually up before swapping, or both scenes end up running on top of each other. // 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) { if (hash in ROUTES && ROUTES[hash] !== 'Night') {
const route = (): void => { const route = (): void => {
if (!game.scene.isActive('Roster')) return; if (!game.scene.isActive('Night')) 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]);
}; };
@ -87,13 +72,4 @@ 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');
}
}
}); });

View File

@ -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}:${p.flags.disguised === true ? 'd' : ''}`; return `doll:${p.dollSeed}:${pose}:${dollPlan(p, pose).swayPx}`;
} }
export function renderDoll(scene: Phaser.Scene, p: Patron, pose: DollPose): string { export function renderDoll(scene: Phaser.Scene, p: Patron, pose: DollPose): string {

View File

@ -122,16 +122,5 @@ 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 };
} }

View File

@ -36,9 +36,7 @@ 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) };
// 0.18 (was 0.35, tuning 2026-07-19): with 8 rules live by 1 AM, a third of if (def.canLogo && rng.chance(0.35)) layer.logo = true;
// 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);
} }
@ -82,17 +80,13 @@ 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( : rng.weighted(ARCHETYPES.map((a) => [a, a.weight] as const));
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]);

View File

@ -3,22 +3,6 @@ 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 ?? {
@ -44,8 +28,3 @@ 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;
}

View File

@ -1,53 +0,0 @@
// 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 } : {}),
},
};
}

View File

@ -6,7 +6,6 @@
// 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';
@ -159,57 +158,8 @@ 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. */
/**
* 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);
}
export function activeRules(clockMin: number): DoorDressCodeRule[] { export function activeRules(clockMin: number): DoorDressCodeRule[] {
return announcedRules(clockMin).slice(-ACTIVE_RULE_CAP); return DRESS_CODE_RULES.filter((r) => r.activeFrom <= clockMin);
} }
export function violations(patron: Patron, clockMin: number): DoorDressCodeRule[] { export function violations(patron: Patron, clockMin: number): DoorDressCodeRule[] {

View File

@ -18,11 +18,7 @@ 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 = export type EncounterId = 'grabHerMate' | 'quietBeer' | 'lastNight' | 'nightShift';
| '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;
@ -41,19 +37,6 @@ 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 {
@ -66,13 +49,6 @@ 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. */
@ -177,7 +153,7 @@ const beatsOf = (id: EncounterId): EncounterBeat[] =>
// ---- the encounters ---------------------------------------------------------- // ---- the encounters ----------------------------------------------------------
const ENCOUNTERS_BASE: readonly ScriptedEncounter[] = [ export const ENCOUNTERS: 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',
@ -225,11 +201,9 @@ const ENCOUNTERS_BASE: readonly ScriptedEncounter[] = [
onDeny: { note: script('quietBeer').denyNote }, onDeny: { note: script('quietBeer').denyNote },
}, },
{ {
// After the logo rule lands at 90 — and BEFORE Dazza rescinds it (the rule // After the logo rule lands at 90. Before that he is just a bloke in a polo.
// 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, 195], window: [150, 270],
archetype: 'punter', archetype: 'punter',
dress(patron, rng) { dress(patron, rng) {
const s = script('nightShift'); const s = script('nightShift');
@ -286,363 +260,29 @@ const ENCOUNTERS_BASE: 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`);
} }
} }
// With eight overlapping windows a whole-set worst-case chain no longer fits in // scheduleEncounters must never move an encounter outside its own window, so the
// one night (that guarantee died when the table outgrew a single night, by // windows themselves have to admit a legal layout for the WHOLE set — any subset
// design — a week should not see the same four people). The load-time check now // is strictly easier. Checked at load, where a bad window edit is a boot failure
// guards each window's own sanity; the scheduler handles infeasible DRAWS by // rather than a night that quietly drops its third encounter.
// skipping the pick (a 2-encounter night beats a crashed one). function assertWindowsSchedulable(): void {
function assertWindowsSane(): void { let earliest = Number.NEGATIVE_INFINITY;
for (const e of ENCOUNTERS) { for (const e of [...ENCOUNTERS].sort((a, b) => a.window[0] - b.window[0])) {
if (e.window[0] >= e.window[1] || e.window[0] < 0 || e.window[1] > 350) { if (Math.max(e.window[0], earliest) > e.window[1]) {
throw new Error(`encounters: window for '${e.id}' is not a usable night interval`); throw new Error(`encounters: window for '${e.id}' cannot clear ${ENCOUNTER_SPACING_MIN} min`);
} }
// Worst case: the previous encounter landed at the very end of its window.
earliest = e.window[1] + ENCOUNTER_SPACING_MIN;
} }
} }
assertWindowsSane(); assertWindowsSchedulable();
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);
@ -657,61 +297,28 @@ 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 shuffle = (arr: ScriptedEncounter[]): ScriptedEncounter[] => { const pool = [...ENCOUNTERS];
for (let i = arr.length - 1; i > 0; i--) { for (let i = pool.length - 1; i > 0; i--) {
const j = rng.int(0, i); const j = rng.int(0, i);
const a = arr[i]!; const a = pool[i]!;
arr[i] = arr[j]!; pool[i] = pool[j]!;
arr[j] = a; pool[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 (let i = 0; i < placed.length; i++) { for (const e of chosen) {
const e = placed[i]!; const atMin = rng.int(Math.max(e.window[0], earliest), e.window[1]);
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.sort((a, b) => a.atMin - b.atMin); return out;
} }

View File

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

View File

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

View File

@ -17,7 +17,6 @@ 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];
@ -63,9 +62,6 @@ 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]);

View File

@ -68,9 +68,6 @@ 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
@ -140,30 +137,6 @@ const BANKS: Record<string, AccountBank> = {
{ id: 'maggot.soft2', text: 'Patron was seated and stable. No intervention was assessed as necessary at that point.', truthful: false }, { id: 'maggot.soft2', text: 'Patron was seated and stable. No intervention was assessed as necessary at that point.', truthful: false },
], ],
}, },
jointPocketed: {
prompt: 'Contraband was located in the smoking area. Where is it now?',
options: [
{ id: 'jointP.true', text: 'On me. I took possession and I still have possession. That is the account.', truthful: true },
{ id: 'jointP.soft1', text: 'Item was destroyed on the spot in view of the patron.', truthful: false },
{ id: 'jointP.soft2', text: 'Item was placed in the amnesty bin per procedure. The bin was emptied by close.', truthful: false },
],
},
jointBinned: {
prompt: 'Contraband was located in the smoking area. Your account?',
options: [
{ id: 'jointB.true', text: 'Confiscated and binned in front of them. The yard went quiet for a bit.', truthful: true },
{ id: 'jointB.soft1', text: 'Patron surrendered the item voluntarily and thanked security for the chat.', truthful: false },
{ id: 'jointB.soft2', text: 'Item was assessed as herbal cigarettes. Binned as general litter.', truthful: false },
],
},
jointIgnored: {
prompt: 'A smell was reported near the smoking area. Your account?',
options: [
{ id: 'jointI.true', text: 'I smelled it, I saw it, and I decided the fence line was more interesting.', truthful: true },
{ id: 'jointI.soft1', text: 'Area was patrolled at the time stated. Nothing of note was observed.', truthful: false },
{ id: 'jointI.soft2', text: 'Smell was traced to the kebab shop exhaust across the road.', truthful: false },
],
},
kayden: { kayden: {
prompt: 'A ruling was made at the rope while you were inside. Your account?', prompt: 'A ruling was made at the rope while you were inside. Your account?',
options: [ options: [

View File

@ -15,24 +15,14 @@ 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 and /** Deny someone who genuinely broke something. The job, done right. */
* routine. The room expects it; the payoff is small (tuning pass 2026-07-19: denyViolatorVibe: 6,
* 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: 1, denyCleanVibe: 2,
/** ...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,
@ -47,12 +37,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: 2, admitCleanVibe: 3,
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: 1, admitBadIdVibe: 2,
/** "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,
@ -114,15 +104,10 @@ 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, ctx); return verdict === 'admit' ? judgeAdmit(patron, broken, id, ctx) : judgeDeny(patron, broken, id);
} }
function judgeDeny( function judgeDeny(patron: Patron, broken: DoorDressCodeRule[], id: IdVerdict): DoorOutcome {
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
@ -153,9 +138,7 @@ function judgeDeny(
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;
@ -271,28 +254,12 @@ function judgeAdmit(
}); });
} }
if (ctx.clockMin >= LOCKOUT_MIN) {
// 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({
atClockMin: T.auditClockMin,
heatStrike: { reason: 'admission after the 1:30 lockout', deferred: true },
reason: `lockout breach — ${patron.id} admitted at clock ${ctx.clockMin}`,
patronId: patron.id,
});
}
if (ctx.insideCount >= ctx.licensed) { if (ctx.insideCount >= ctx.licensed) {
// The STRIKE reason is deliberately stable: shouldRecordStrike dedupes by const reason = `capacity breach — ${patron.id} admitted at ${ctx.insideCount}/${ctx.licensed}`;
// 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({ deferred.push({
atClockMin: T.auditClockMin, atClockMin: T.auditClockMin,
heatStrike: { reason: 'room over licensed capacity', deferred: true }, heatStrike: { reason, deferred: true },
reason: `capacity breach — ${patron.id} admitted at ${ctx.insideCount}/${ctx.licensed}`, reason,
patronId: patron.id, patronId: patron.id,
}); });
} }

View File

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

View File

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

View File

@ -1,28 +1,20 @@
import Phaser from 'phaser'; import Phaser from 'phaser';
import { renderDoll } from '../../patrons/doll'; import { renderDoll } from '../../patrons/doll';
import { dollPlan } from '../../patrons/dollPlan'; import { dollPlan } from '../../patrons/dollPlan';
import { activeRules, announcedRules, shuffleWindow, violations, type DoorDressCodeRule } from '../../rules/dressCode'; import { violations, type DoorDressCodeRule } from '../../rules/dressCode';
import { drunkStage } from '../../rules/drunk';
import { idVerdict, formatDate } from '../../rules/idCheck'; import { idVerdict, formatDate } from '../../rules/idCheck';
import { LOCKOUT_MIN, judge } from '../../rules/judge'; import { 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 { CARPET_TUNING, QueueManager, entranceBonus } from './QueueManager'; import { QueueManager } 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';
@ -31,7 +23,6 @@ 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';
@ -44,6 +35,7 @@ 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
@ -75,7 +67,6 @@ 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;
@ -92,31 +83,12 @@ 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() {
@ -128,85 +100,19 @@ export class DoorScene extends Phaser.Scene {
} }
create(): void { create(): void {
this.queue = new QueueManager( this.queue = new QueueManager(this.night.bus, this.night.rng, this.night.nightDate);
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.night.bus.on('clock:tick', ({ clockMin }) => this.queue.onClockMinute(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);
@ -218,18 +124,7 @@ 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)) { if (isDoorRule(rule)) this.codeCard.add(this, rule, this.elapsedMs);
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 }) => {
@ -257,13 +152,7 @@ export class DoorScene extends Phaser.Scene {
// ---------- construction ---------- // ---------- construction ----------
private buildStreet(): void { private buildStreet(): void {
if (this.textures.exists('gen:prop:street_backdrop')) { this.add.rectangle(W / 2, 88, W, 176, 0x0a0a12); // wall of night
// 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);
@ -272,9 +161,6 @@ 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
@ -299,21 +185,6 @@ 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();
} }
@ -360,13 +231,6 @@ 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();
@ -408,15 +272,10 @@ 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: 130, h: 24, label: DOOR_UI.rope, size: 9, x: 442, y: 256, w: 192, h: 24, label: DOOR_UI.rope, size: 9,
fill: 0x5a2030, hover: 0x8a2038, 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(),
@ -515,27 +374,10 @@ 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
@ -551,89 +393,6 @@ export class DoorScene extends Phaser.Scene {
}); });
} }
/** Door-side radio: order the floor staff without leaving the rope. */
private radioFloorOrder(kind: 'mess' | 'water'): void {
const ack = this.night.floorOrder?.(kind, (msg) => {
if (this.present) this.showToast(msg);
});
if (ack === 'sent') this.showToast(kind === 'mess' ? STAFF_RADIO.bumpAck : STAFF_RADIO.shanAck);
else if (ack === 'cooldown') this.showToast(STAFF_RADIO.cooldown);
else if (ack === 'nothing') this.showToast(kind === 'mess' ? STAFF_RADIO.bumpNothing : STAFF_RADIO.shanNothing);
}
// ---- the red carpet ----------------------------------------------------
/** The patron at the rope takes position between the brass posts. */
private callCarpet(): void {
const p = this.queue.patronUp;
if (!p || this.busy) return;
if (this.queue.toCarpet() === null) {
this.showToast(CARPET_UI.full);
return;
}
this.idCard.close();
this.trayCard?.destroy();
this.trayCard = null;
this.up.clear();
this.setVerdictButtons(false);
this.night.sfx?.play('ropeUnhook');
this.showToast(CARPET_UI.sent);
this.logIncidentDoor('carpet', p.id, 'Sent to the carpet to be seen waiting.');
}
/** Click a carpet doll: wave them up to the rope for the actual ruling. */
private recallCarpet(patronId: string): void {
if (this.busy) return;
if (this.queue.patronUp) {
this.showToast(CARPET_UI.recallBusy);
return;
}
const p = this.queue.recallFromCarpet(patronId);
if (!p) return;
this.presentUp(p);
}
private logIncidentDoor(kind: string, patronId: string | undefined, detail: string): void {
this.night.bus.emit('incident:log', { clockMin: this.night.state.clockMin, kind, patronId, detail });
}
/** The carpet is a view of QueueManager, same discipline as the queue line. */
private syncCarpetSprites(): void {
const slots = this.queue.carpetSnapshot;
const same =
slots.length === this.carpetSprites.length &&
slots.every((s, i) => this.carpetSprites[i]?.patron.id === s.patron.id);
if (!same) {
for (const s of this.carpetSprites) s.image.destroy();
this.carpetSprites = slots.map((slot, i) => {
const x = 372 + i * 34;
const img = this.add
.image(x, QUEUE_FOOT_Y + 6, renderDoll(this, slot.patron, 'queue'))
.setOrigin(0.5, 1)
.setScale(1.5)
.setDepth(205)
.setInteractive({ useHandCursor: true });
img.on('pointerdown', () => this.recallCarpet(slot.patron.id));
img.setData('baseX', x);
return { patron: slot.patron, image: img, link: null };
});
}
// Impatience is visible: the sway grows as the patience runs out.
for (let i = 0; i < this.carpetSprites.length; i++) {
const s = this.carpetSprites[i]!;
const slot = slots[i];
if (!slot) continue;
const fuse = Math.min(1, slot.standMs / slot.patienceMs);
s.image.x = s.image.getData('baseX') + Math.sin(this.elapsedMs / (300 - 140 * fuse)) * (1 + fuse * 3);
}
for (const p of this.queue.takeStorms()) {
this.showToast(CARPET_UI.storm);
this.night.sfx?.play('doorBang');
this.logIncidentDoor('carpetStorm', p.id, 'Ran out of patience on the carpet and left, loudly.');
}
}
/** Nudge the player toward a tab that just became relevant, without switching it. */ /** 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];
@ -723,14 +482,6 @@ 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
@ -795,7 +546,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 = enc.resolve ? enc.resolve(p, verdict) : verdict === 'admit' ? enc.onAdmit : enc.onDeny; const out = 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;
@ -810,25 +561,6 @@ 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',
@ -837,109 +569,6 @@ 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,
@ -1022,29 +651,6 @@ 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));
@ -1068,36 +674,20 @@ 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 || this.onKebabRun) { if (!this.present) {
// "Hold the rope" (radio command): Kayden does nothing, magnificently. this.kaydenAccMs += deltaMs;
if (this.elapsedMs < this.kaydenHoldUntilMs) { while (this.kaydenAccMs >= KAYDEN_RULE_MS) {
this.kaydenAccMs = 0; this.kaydenAccMs -= KAYDEN_RULE_MS;
} else { this.kaydenRule();
this.kaydenAccMs += deltaMs;
while (this.kaydenAccMs >= KAYDEN_RULE_MS) {
this.kaydenAccMs -= KAYDEN_RULE_MS;
this.kaydenRule();
}
} }
} }
this.tickKebab(); this.clockText.setText(this.night.clock.label);
this.tickKebabRun(deltaMs); if (this.tutorial.alpha > 0 && this.elapsedMs > TUTORIAL_MS) {
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) {
@ -1156,8 +746,6 @@ 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();

View File

@ -14,8 +14,6 @@ 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 {
@ -59,36 +57,14 @@ 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);

View File

@ -133,16 +133,6 @@ 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));

View File

@ -3,20 +3,15 @@ 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, vibeCoolingPerMin } from '../../core/meters'; import { Meters, freshNightState } 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, setDressCodeShuffle } from '../../rules/dressCode'; import { ruleById } 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;
@ -24,12 +19,11 @@ 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;
import { MAX_HEAT_STRIKES, shouldRecordStrike } from '../../rules/heat'; /** CONTRACTS.md §3: heatStrikes is "run-scoped, max 3". Design §2: 3 = licence gone. */
export { MAX_HEAT_STRIKES, shouldRecordStrike }; export const MAX_HEAT_STRIKES = 3;
/** 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;
@ -42,6 +36,10 @@ 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';
@ -63,17 +61,6 @@ 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. */
@ -82,15 +69,10 @@ 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. */
@ -106,33 +88,8 @@ 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 {
@ -142,14 +99,11 @@ 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>();
@ -172,7 +126,7 @@ export class NightScene extends Phaser.Scene {
super('Night'); super('Night');
} }
init(data: { seed?: number; nightIndex?: number; freshRun?: boolean; role?: string }): void { init(data: { seed?: number; nightIndex?: number; freshRun?: boolean }): void {
if (data.freshRun) clearSave(); 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) {
@ -180,11 +134,8 @@ export class NightScene extends Phaser.Scene {
} }
this.nightIndex = data.nightIndex ?? this.run.nightIndex; this.nightIndex = data.nightIndex ?? this.run.nightIndex;
if (this.nightIndex >= TOTAL_NIGHTS) this.nightIndex = 0; if (this.nightIndex >= TOTAL_NIGHTS) this.nightIndex = 0;
this.venue = venueByIndex(this.run.venueIndex); // Each night is its own deterministic world derived from the run seed.
this.role = roleById(data.role) ?? defaultRoleFor(this.venue); this.seed = this.run.seed + this.nightIndex * 101;
// 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 {
@ -199,16 +150,9 @@ 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 = { const clockCfg: ClockConfig = { ...DEFAULT_CLOCK, nightDate: NIGHT_DATES[this.nightIndex]! };
...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(this.venue.id, this.nightIndex, this.venue.licensed); this.state = freshNightState('theRoyal', this.nightIndex, LICENSED_CAPACITY);
// 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);
@ -217,8 +161,6 @@ 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();
@ -239,10 +181,6 @@ 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(
@ -253,31 +191,16 @@ 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;
}), }),
@ -291,65 +214,21 @@ 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 });
// Generated art loads BEFORE the scenes that consume it (textures are this.scene.launch('Floor', { night: this.ctx });
// game-global): the door's street backdrop and the floor's props both ride // Door and Floor BOTH run all night; the flags below decide which one the
// public/props + manifest. Missing manifest = placeholders, zero waiting. // player is inhabiting. Floor starts hidden.
this.load.json('propManifest', 'props/manifest.json'); this.time.delayedCall(0, () => this.applyLocation('door'));
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 ----------------------------------------------------------
@ -384,18 +263,11 @@ 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).
// ...and only at venues the inspector actually works (venue ladder). if (patron.archetype === 'inspector' && this.inspector === null) {
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') { } else if (verdict === 'deny') this.log.denied++;
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++;
} }
@ -427,10 +299,6 @@ 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');
} }
@ -515,7 +383,6 @@ 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);
@ -548,21 +415,12 @@ 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 (weekDone && !seasonDone) { if (runOver || weekDone) {
// 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);
@ -571,30 +429,14 @@ 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, () => {
const proceed = (): void => { this.scene.stop('Door');
this.scene.stop('Door'); this.scene.stop('Floor');
this.scene.stop('Floor'); this.scene.start('NightSummary', { log: this.log, state: this.state, run: runInfo });
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();
}
}); });
} }
@ -605,17 +447,9 @@ 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;

View File

@ -15,13 +15,11 @@ 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 = log.wage; const wage = 180;
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;
// Cash is added AFTER the floor: a strike deduction can wipe your wage, but it return Math.max(0, wage + hypeBonus + doorBonus - strikes);
// 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 {
@ -65,8 +63,8 @@ export class NightSummaryScene extends Phaser.Scene {
W / 2, W / 2,
46, 46,
failed failed
? `${this.run?.venue.name ?? this.state.venueId} · ${nightName} · night over early` ? `${this.state.venueId} · ${nightName} · night over early`
: `${SUMMARY_UI.subtitle}${nightName ? ` · ${nightName}` : ''} · shift: ${this.log.role.toLowerCase()} $${this.log.wage}`, : `${SUMMARY_UI.subtitle}${nightName ? ` · ${nightName}` : ''}`,
{ {
fontFamily: MONO, fontFamily: MONO,
fontSize: '8px', fontSize: '8px',
@ -108,27 +106,19 @@ export class NightSummaryScene extends Phaser.Scene {
if (!r) { if (!r) {
return { return {
prompt: SUMMARY_UI.replay, prompt: SUMMARY_UI.replay,
next: () => this.scene.start('Roster', { seed: this.log.seed + 1 }), next: () => this.scene.start('Night', { 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('Roster', { freshRun: true, seed: this.log.seed + 13 }), next: () => this.scene.start('Night', { freshRun: true, seed: this.log.seed + 13 }),
}; };
} }
if (r.seasonDone) { if (r.weekDone) {
return { return {
prompt: 'THE SEASON IS DONE. every door in the valley, held. click for a fresh run', prompt: 'WEEK SURVIVED. click for a new week (new seed, same rain)',
next: () => this.scene.start('Roster', { freshRun: true, seed: this.log.seed + 7 }), next: () => this.scene.start('Night', { freshRun: true, seed: this.log.seed + 7 }),
};
}
if (r.weekDone && r.promotedTo) {
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';
@ -137,7 +127,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('Roster', {}), next: () => this.scene.start('Night', {}),
}; };
} }
@ -189,10 +179,6 @@ 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) => {

View File

@ -2,9 +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 { drunkStage } from '../../rules/drunk'; import { drunkStage } from '../../rules/drunk';
import { lipReadLine } from '../../rules/lipRead';
import type { Patron } from '../../data/types'; import type { Patron } from '../../data/types';
import { CLAIM_LINES, DRUNK_GREETINGS, OWNER_HINTS, PATRON_GREETINGS, REGULAR_DISGUISED_LINES, REGULAR_RETURN_LINES } from '../../data/strings/door'; import { CLAIM_LINES, DRUNK_GREETINGS, OWNER_HINTS, PATRON_GREETINGS } 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';
@ -26,8 +25,6 @@ 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,
@ -95,26 +92,18 @@ 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.
// The disguised regular's denial-of-everything outranks every other line — const pool = p.flags.knowsOwner
// it IS the tell (same face, same walk, "never been here"). A merely ? OWNER_HINTS
// knocked-back regular gets his olive branch in ahead of list claims. : p.flags.claimsGuestList
const pool = p.flags.disguised === true ? CLAIM_LINES
? REGULAR_DISGUISED_LINES : drunk === 'messy' || drunk === 'maggot' || drunk === 'loose'
: p.flags.regularId !== undefined && (p.flags.timesDeniedBefore ?? 0) > 0 ? DRUNK_GREETINGS
? REGULAR_RETURN_LINES : PATRON_GREETINGS;
: 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, this.lipRead ? lipReadLine(line, p.id) : `"${line}"`, { .text(0, bubbleY, `"${line}"`, {
fontFamily: MONO, fontFamily: MONO,
fontSize: '8px', fontSize: '8px',
color: '#d8d0c0', color: '#d8d0c0',
@ -147,7 +136,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, this.lipRead ? lipReadLine(line, this.patron.id) : `"${line}"`, { .text(0, bubbleY, `"${line}"`, {
fontFamily: MONO, fontFamily: MONO,
fontSize: '8px', fontSize: '8px',
color: '#e8dcc4', color: '#e8dcc4',

View File

@ -4,14 +4,10 @@ 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';
@ -44,15 +40,8 @@ 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 */
* (tuning pass 2026-07-19: was 0.9 relief refunded ~10:1 and aggro never moved) */ calmPerSec: 0.9,
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,
@ -69,43 +58,6 @@ 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[];
@ -128,10 +80,6 @@ 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 */
@ -140,34 +88,14 @@ 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');
@ -178,40 +106,11 @@ 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.pendingEncounters = scheduleEncounters(this.encounterRng, QUEUE_TUNING.encountersPerNight);
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());
@ -268,7 +167,7 @@ export class QueueManager {
while (clockMin >= this.nextArrivalMin) { while (clockMin >= this.nextArrivalMin) {
this.spawnArrival(); this.spawnArrival();
const gap = nextArrivalGapMin(this.nextArrivalMin, this.arrivalRng.next(), this.arrivalScale); const gap = nextArrivalGapMin(this.nextArrivalMin, this.arrivalRng.next());
this.nextArrivalMin += Math.max(1, Math.round(gap)); this.nextArrivalMin += Math.max(1, Math.round(gap));
} }
} }
@ -277,7 +176,6 @@ 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) {
@ -305,82 +203,6 @@ 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. */
@ -450,31 +272,7 @@ export class QueueManager {
} }
private make(): Patron { private make(): Patron {
let p = generatePatron( const p = generatePatron({ rng: this.rng, nightDate: this.nightDate }, this.clockMin);
{ 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

View File

@ -13,16 +13,13 @@ 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.2 }, // 9:45 PM { clockMin: 45, perMin: 0.18 }, // 9:45 PM
{ clockMin: 90, perMin: 0.55 }, // 10:30 PM — the pre-drinks let out { clockMin: 90, perMin: 0.34 }, // 10:30 PM — the pre-drinks let out
{ clockMin: 150, perMin: 0.72 }, // 11:30 PM — peak { clockMin: 150, perMin: 0.42 }, // 11:30 PM — peak
{ clockMin: 210, perMin: 0.45 }, // 12:30 AM — slam ends { clockMin: 210, perMin: 0.32 }, // 12:30 AM — slam ends
{ clockMin: 270, perMin: 0.2 }, // 1:30 AM { clockMin: 270, perMin: 0.16 }, // 1:30 AM
{ clockMin: 330, perMin: 0.07 }, // 2:30 AM — last drinks { clockMin: 330, perMin: 0.06 }, // 2:30 AM — last drinks
{ clockMin: 360, perMin: 0 }, // 3:00 AM { clockMin: 360, perMin: 0 }, // 3:00 AM
]; ];
@ -63,11 +60,8 @@ 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, scale = 1): number { export function nextArrivalGapMin(clockMin: number, roll: number): number {
// `scale` is the venue's arrival multiplier (ladder, design §6): a dive runs const rate = arrivalsPerMin(clockMin);
// 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;

View File

@ -11,11 +11,6 @@ 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. */
@ -28,18 +23,9 @@ 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;

View File

@ -1,17 +0,0 @@
/** 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);
}

File diff suppressed because it is too large Load Diff

View File

@ -2,8 +2,7 @@ import Phaser from 'phaser';
import { renderDoll } from '../../patrons/doll'; import { renderDoll } from '../../patrons/doll';
import { dollPlan } from '../../patrons/dollPlan'; import { dollPlan } from '../../patrons/dollPlan';
import { conePolygon, type ConeSpec } from './cone'; import { conePolygon, type ConeSpec } from './cone';
import { FIGHT_FUSE_MS } from './fight'; import { TILE, tileAt, tileColour, type VenueMap } from './venueMap';
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
@ -36,22 +35,6 @@ 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;
@ -76,27 +59,8 @@ 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;
@ -147,12 +111,6 @@ 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);
@ -160,191 +118,6 @@ 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 {
@ -377,14 +150,9 @@ 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;
// Empty unless this punter is squaring up, so the common path is a miss. g.sil.setPosition(x, y);
const sh = this.shove.get(a.patron.id); g.detail.setPosition(x, y);
const sx = sh ? x + sh.dx : x; g.uvDot.setPosition(x + 4, y + 2);
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);
@ -432,79 +200,7 @@ 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);
@ -514,16 +210,6 @@ 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();
@ -553,20 +239,6 @@ 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;
@ -627,139 +299,3 @@ 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;
}

View File

@ -1,152 +0,0 @@
// The bartender shift (docs/SCENARIOS.md, design §6 role ladder). Same split
// as djShift.ts: pure pricing here, tested; the scene owns the overlay, the
// teleport and the taps.
//
// The shape of the fantasy: the other side of the counter. Every order is a
// sobriety read at arm's length — the stage is written on the doll and in the
// spelling — and SERVE / WATER / CUT OFF are all legal, all priced (§4.3).
// Serving someone visibly cooked is the one the licence remembers.
import type { DrunkStage } from '../../data/types';
import { WATER_SOBER_STEP } from './hazards';
export type BarCall = 'serve' | 'water' | 'cutoff';
/** What one served drink adds — a real pour, on top of the bar-anchor drift. */
export const SERVE_DRINK_STEP = 0.06;
/** Chance-per-second an order lands while you hold the taps (~one per 18s). */
export const ORDER_CHANCE_PER_S = 0.055;
/** One patron doesn't re-order inside this window. */
export const ORDER_COOLDOWN_MS = 60_000;
/**
* The RSA line (rules/drunk.ts stages): messy and maggot are the breach.
* 'loose' has visible tells but stays legal that band is where the player
* has to actually decide something, same as the floor cut-off.
*/
export function isRsaBreach(stage: DrunkStage): boolean {
return stage === 'messy' || stage === 'maggot';
}
/** Stable reason so shouldRecordStrike folds a whole bad shift into ONE write-up. */
export const RSA_STRIKE_REASON = 'served alcohol to a visibly intoxicated patron';
/** A breach pour only becomes a strike if somebody official SAW it. */
export const BREACH_SEEN_CHANCE = 0.35;
/** Clock-minutes later the write-up lands: base + up-to spread. */
export const BREACH_SEEN_AFTER: readonly [number, number] = [6, 20];
export interface BarOutcome {
vibe?: number;
aggro?: number;
/** Applied to the patron's intoxication (scene clamps to 0..1). */
intoxDelta: number;
/** An RSA breach pour — may earn the deferred write-up. */
breach: boolean;
/** Done for the night; the crowd walks them out itself. */
sendsHome: boolean;
}
/**
* Price a call against what the patron actually is (§4.3: priced, never
* graded). The breach pour still pays its vibe up front the bribe is
* immediate, the price arrives later.
*/
export function judgeCall(call: BarCall, stage: DrunkStage): BarOutcome {
if (call === 'serve') {
return {
vibe: 1,
intoxDelta: SERVE_DRINK_STEP,
breach: isRsaBreach(stage),
sendsHome: false,
};
}
if (call === 'water') {
const needed = stage === 'loose' || isRsaBreach(stage);
return needed
? { vibe: 1, intoxDelta: -WATER_SOBER_STEP, breach: false, sendsHome: false }
: { aggro: 1, intoxDelta: -WATER_SOBER_STEP, breach: false, sendsHome: false };
}
// cutoff: right on a breach-stage patron, a whole scene on anyone upright.
return isRsaBreach(stage)
? { vibe: 1, aggro: 1, intoxDelta: 0, breach: false, sendsHome: true }
: { vibe: -1, aggro: 2, intoxDelta: 0, breach: false, sendsHome: true };
}
// ---- the pour (SERVE is a hand, not a menu click) ---------------------------
// A sweeping marker, one key to stop it. The line lives near the top because a
// good pour is nearly a mistake.
export type PourVerdict = 'short' | 'clean' | 'overflow';
/** Marker sweep 0→1→0, this long each way. */
export const POUR_SWEEP_MS = 1300;
export const POUR_CLEAN_FROM = 0.68;
export const POUR_OVER_FROM = 0.93;
/** What a heavy hand adds on top of SERVE_DRINK_STEP. */
export const OVERFLOW_EXTRA_INTOX = 0.03;
export function judgePour(fill: number): PourVerdict {
if (fill >= POUR_OVER_FROM) return 'overflow';
if (fill >= POUR_CLEAN_FROM) return 'clean';
return 'short';
}
/**
* Fold the pour into the serve. Short: half a drink, no thanks, a look.
* Overflow: the room loves a heavy pour and the patron wears more of it.
* The RSA breach flag rides the SERVE decision, not the pour a stingy pour
* to a maggot is still a pour to a maggot.
*/
export function pourAdjust(out: BarOutcome, pour: PourVerdict): BarOutcome {
if (pour === 'short') {
const adjusted: BarOutcome = { ...out, aggro: (out.aggro ?? 0) + 1, intoxDelta: out.intoxDelta / 2 };
delete adjusted.vibe;
return adjusted;
}
if (pour === 'overflow') {
return { ...out, vibe: (out.vibe ?? 0) + 1, intoxDelta: out.intoxDelta + OVERFLOW_EXTRA_INTOX };
}
return out;
}
// ---- tabs (the jar of other people's cards) ---------------------------------
/** Chance a served tipsy+ patron opens a tab ("start a tab? legend"). */
export const TAB_OPEN_CHANCE = 0.3;
/** A drink, for the tab total the toast reads out. */
export const TAB_DRINK_PRICE = 14;
export interface Tab {
/** The name printed on the card in the jar. */
name: string;
drinks: number;
}
/** Stages loose+ can open a tab — sober people pay as they go. */
export function canOpenTab(stage: DrunkStage): boolean {
return stage !== 'sober';
}
/**
* The card-return line-up: the right name plus two strangers' cards from the
* jar/room. Order is seeded so the correct answer isn't always slot one.
*/
export function cardLineup(
right: string,
others: readonly string[],
rng: { int(min: number, max: number): number },
): string[] {
const decoys = [...new Set(others.filter((n) => n !== right))];
// FisherYates on the decoy pool, take two.
for (let i = decoys.length - 1; i > 0; i--) {
const j = rng.int(0, i);
[decoys[i], decoys[j]] = [decoys[j]!, decoys[i]!];
}
const lineup = [right, ...decoys.slice(0, 2)];
for (let i = lineup.length - 1; i > 0; i--) {
const j = rng.int(0, i);
[lineup[i], lineup[j]] = [lineup[j]!, lineup[i]!];
}
return lineup;
}

View File

@ -9,8 +9,7 @@ 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;
@ -24,12 +23,6 @@ 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 {
@ -165,11 +158,6 @@ 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,
@ -190,16 +178,7 @@ 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.arrivedAtMin === undefined) a.arrivedAtMin = clockMin; if (a.activity === 'escorted') continue;
// 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);
} }
@ -303,34 +282,6 @@ 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);
} }
@ -447,38 +398,9 @@ 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) {

Some files were not shown because too many files have changed in this diff Show More