Compare commits

...

39 Commits

Author SHA1 Message Date
type-two
7af3732f39 Fix build break in the shoe tests: freshNightState takes (venueId, nightIndex, licensed)
vitest does not typecheck, so these passed under `npm test` while `npm run build`
failed. The gate is lint AND build AND test for exactly this reason — run it in
that order after touching tests, not before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 03:18:49 +10:00
type-two
4118a60aea Review sweep: close the softlock class with data+tests, un-deadlock the door, and make the booth shoe real money
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 03:17:39 +10:00
type-two
0d3ce80c6b Six defects from the adversarial review: two softlocks, a dead door, and a bar that punished the right call
- DJ post stood the player inside the sealed djbooth (same class as the taps
  softlock): every direction blocked, and since the sweep needs you to walk to
  the entry, the night could never be paid. DECKS_SPOT moves to the booth's west
  counter, which is where the map comment always said the gear faces from.
- Role stations are now DATA (venueMap POSTS/PROBES) with tests/floor/stations.test.ts
  pinning the whole bug class: posts must be walkable AND escapable, probes must
  have somewhere walkable within interact range. Written as a MOVEMENT test, not
  a tile-name test — the question is whether the player can leave.
- DoorScene.busy is released by a delayedCall on the scene clock, and stopping a
  scene discards pending events, so a ruling in the last 2.6s of a night left it
  stuck true: a dead door — no rope, no verdicts, no way inside — for the rest
  of the run. Now reset per night, along with seenCards/kebab/franko/retiredRules.
- Bar WATER and CUT OFF never settled the patron, so watering a maggot from
  behind the taps left them ticking as unhandled and re-logging every grace
  period: the call that fixed the problem kept being charged for it.
- Every bar verb logged as 'barServe', putting drinks in the record that were
  never poured. One kind per verb; the report system takes unknown kinds by design.
- carpetMsFor() is consumed on read: a regular parked, recalled and knocked back
  came round again later and was paid the entrance dividend a second time.
- Sweep state joins resetNightState() so an interrupted floor score cannot carry
  its cash, or its previous-night pay callback, into the next night.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 03:13:09 +10:00
type-two
bcc06c104a Handover: record the taps softlock fix
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 02:32:44 +10:00
type-two
3b8682dd40 Fix the run-ending softlock: handing the taps back stranded you on a stool tile
toggleTaps() teleported the player to TAPS_FRONT on exit, and TAPS_FRONT was
tile (47,6) — inside venueMap's stool rect, which is not in WALKABLE. Verified
in the live build: after handing the taps back the player sat at (760,104) and
W/A/S/D each moved them exactly 0px, for the rest of the night. The bartender
roster role mounts the taps automatically, so one keypress ended the run.

Exit no longer teleports at all: you stay behind the bar and walk out around
its east end, which is both walkable (verified end to end) and how staff
actually leave a rail. TAPS_FRONT moves to row 7 so the approach probe sits on
the punter side rather than inside the furniture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 02:32:11 +10:00
type-two
6d2fc56bfb Handover: FABLE-SOLO-12 — three lifecycle fixes and a soak against the roster wave
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 02:28:48 +10:00
type-two
164b4480c5 Fix floor-start shifts booting inert, and the floor carrying last night into this one
applyLocation() only moved flags — it never announced the move. FloorDemoScene
latches `present` when it binds the night context, which (now the night preloads
every prop texture) happens BEFORE the initial applyLocation runs, so every
floor-start shift — bartender, glassie, DJ — came up visible but completely
inert, with the audio filter still in door mode. The night now emits
night:phaseChange and audio:location for the starting post.

Phaser reuses scene instances across restarts, so class-field initialisers run
once per GAME, not once per night: a DJ shift left djMode set and the next
night's floor booted with movement locked and the keyboard eaten, last night's
puddles kept slipping people through destroyed sprites, and the handled-patron
sets marked fresh arrivals as already dealt with. resetNightState() now wipes
all of it in create().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 02:24:22 +10:00
type-two
79cd80dc01 Fix the dev route killing the door: N mid-night left a live Night driving a dead Door
showScene() stopped 'Door' (it was in the stop list) and then declined to
restart the already-active 'Night', leaving an unplayable black screen with no
way back. Route only ROOT scenes, and restart the target when it is already
running so N means 'fresh night'. NightScene.teardown() now stops Door and
Floor, making real the ownership main.ts always claimed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 02:13:47 +10:00
type-two
d4f802407b The floor score: lights on at close — sweep the notes, the shrapnel, the untouched kebab; the baggie is a choice and the pay line knows
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 03:40:24 +10:00
type-two
ec9afaff0f The roster board: role × venue level select — six shifts, wages on the card, the night opens wherever the shift stands you
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 03:10:17 +10:00
type-two
e008fb442f Radio command: the Head of Security verb — Bump sorts the mess, Shan waters the worst, Kayden holds the rope (Elevate+, competence not guaranteed)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 02:49:14 +10:00
type-two
5334a30150 Prop batch 2: monitor, booth lamp, dishwasher, pool table, wet-floor cone, high tables, plants, and a real red-carpet plate for the door
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 02:39:11 +10:00
type-two
5ebdfc61eb The red carpet: 3-slot velvet holding pen — park them, they pay hype; patience by archetype, storms, and the entrance dividend on admit
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 02:32:15 +10:00
type-two
b7bb8f49ab The money layer: booth tips (cash buys the yes, not the landing), the pour meter, bar tabs with the card jar and the 3-card handback
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 02:17:45 +10:00
type-two
022ea32005 Handover: fix deploy URL typo
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 02:06:49 +10:00
type-two
c20a689086 ROOM boss week: venue 4 — seeded 15-min dress-code shuffle (the board is the law), lip-read rope dialogue, ROOM crowd/knobs; SCENARIOS shelf cleared
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 02:06:16 +10:00
type-two
b273c5c833 The bartender shift: hold the taps — per-stage order lines, SERVE/WATER/CUT OFF priced per §4.3, RSA breach rides a deferred write-up
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 01:56:49 +10:00
type-two
41e93d01ab Door tween leak: tutorial fade spawned an 800ms tween per frame — now pure tutorialAlpha() driven from update()
Ported from claude/epic-faraday-332679 (6c5889c) — merge was permission-blocked,
same diff applied directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 01:41:21 +10:00
type-two
fbcbb2e349 The DJ shift: hold the decks — bass-out builds, bar-line-judged drops, booth requests with odds
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:49:33 +10:00
type-two
1b0842024b The whole shelf: 9 scripted characters (cop/celebrity/sovereign/rider/promoter/neighbour/hens/twins/ex-doorman) + 5 floor systems (vomit/phone/water/first-aid/kebab run)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:27:43 +10:00
type-two
d8c3b0507d SCENARIOS shelf: mark lockout, passback, step-up lines, fence-jumpers shipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 18:46:12 +10:00
type-two
4471bf4376 Late game: 1:30 lockout law, ID passback (SEEN TONIGHT), regular step-up lines, yard fence-jumpers
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 18:45:50 +10:00
type-two
389376c2f1 The joint bust (ChoiceOverlay, corruption verb, report accounts) + the glassie run (kitchen, rack carry, wobble physics, glass smash)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:51:49 +10:00
type-two
7803873825 The club gets dressed: full MODELBEAST art drop (20 sprites + street backdrop), scan-beam light show, colour-cycling dance grid, pool corner + furniture, present-race fix
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:19:46 +10:00
type-two
f38ea0743c Cross-night regulars with disguises, venue crowd identities, retractions as texts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:43:40 +10:00
type-two
9827e4ebe6 Venue ladder: Royal→Voltage→Elevate with promotion flow, per-venue knobs, sim coverage; yard rain + booth DJ
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 15:53:52 +10:00
type-two
b1e2f038bc Spaces: DJ booth, smoking yard, sinks, glassie station; data-driven PROPS/LIGHTS with beat-pulsed zone lighting; art drop-in pipeline + generation queue
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:38:19 +10:00
type-two
2e0b1917b8 Asset survey: RIGROOM library, modular kit, repose pipeline, procity notes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:36:50 +10:00
type-two
22f5f11dde Fix card/judge disagreement under text lag; session handover
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 06:06:42 +10:00
type-two
7b44e01333 Solo build-out: floor SFX + drunk typos, 8-encounter table with cross-night gating, scheduler rework
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 05:51:16 +10:00
type-two
63a3b4d4fc Economy tuning pass: sim harness + 9 feel-bands, Dazza rule cap, natural churn, capacity strike fix, hype decay, vibe cooling
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 05:35:47 +10:00
type-two
a961e1853b Document live deploy at monsterrobot.games/not-tonight
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 23:35:48 +10:00
type-two
662060388a Phase 3 review: merge content/floor2/juice2, port stale-base floor work into night mode, aggro decay, Ambience+MixDesk wiring
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 22:17:57 +10:00
type-two
df70c99dd0 Merge branch 'lane/floor'
# Conflicts:
#	LANEHANDOVER.md
#	src/scenes/floor/FloorDemoScene.ts
2026-07-19 22:14:08 +10:00
type-two
e1972c1c07 Merge branch 'lane/juice2'
# Conflicts:
#	LANEHANDOVER.md
2026-07-19 22:03:48 +10:00
type-two
10462ebada LANE-JUICE2: session block — ear pass is rigged and waiting on ears
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:48:44 +10:00
type-two
1a66c602fd LANE-JUICE2: ear-pass mix desk, ambience beds, night arc in the engine
The Phase-1 debt was 'verified by ear: NOTHING'. I still can't hear, so this
lane builds the instrument that lets someone who can tell me what's wrong, and
makes their answer a diff instead of a conversation.

- TechnoEngine drives every voice from the live TrackMix and the arc, instead of
  module consts. Behaviour-identical at DEFAULT_MIX (verified constant by
  constant). Voices whose effective gain rounds to silence are skipped, not
  clamped — at 9PM the bass builds zero nodes rather than ~8/sec of silence.
  setMixValue quantises integer paths, so what formatMix dumps is what played.
- MixDeskScene: solo/mute per voice with a per-voice listening prompt, every
  mix param on log-mapped sliders, an arc scrubber (audition 1AM at 9PM), and
  DUMP MIX -> a pasteable TrackMix literal. Restores the arc pin and solo state
  it found on close, rather than forcing a default — the demo deliberately pins
  PEAK and the desk was silently un-tuning it.
- Ambience: rain outside, crowd murmur inside, kebab-shop fluoro hum. On the SFX
  bus, not the music lowpass — that filter models the PA being behind a wall,
  and rain is not behind anything. Headcount comes off door:verdict admits and
  floor:ejection, NOT door:clicker: the clicker is the player's claimed count and
  its divergence from reality is the mechanic, so the room would sound like the
  lie. Verified live: 120 + 2 admits - 1 ejection = 121, clicker ignored.
- JuiceDemoScene pins the arc at PEAK on entry. GameClock runs 360 game-min in
  13 real min, so an unpinned demo opens on kick+hat and anyone judging the bass
  would conclude it's broken.
- Phone thread scrolls with visible-window culling and a binary-searched range,
  a history cap, and a 'new below' cue when a text lands off-screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:47:53 +10:00
type-two
906a366ba8 LANE-FLOOR round 2: fight-brewing infraction + six defect fixes
Builds the last missing v0.2 floor mechanic (design §3.2). A fight is not a
modal: two patrons square up, a 7s fuse burns through five escalating shove
rungs, and you stop it by physically standing between them — it spends the
floor's only real resource, where you are. The tell (shoving silhouettes +
pulsing marker) deliberately reads OUTSIDE the torch cone, because spotting it
is the gameplay.

Adversarially reviewed the whole lane, then hand-adjudicated the findings. Six
real defects fixed, several in code from round 1:
- PatDownOverlay compared 'Escape' against the scene's uppercased 'ESCAPE', so
  ESC was dead there while the on-screen hint promised it worked.
- The spawn cap counted departed agents (CrowdSim keeps them so find() works),
  so every ejection permanently shrank the venue and the floor drained.
- FloorView's sprite cache is keyed by patron id and outlived resetRun, while
  _resetPatronSerial recycles ids from p0 — reseeding gave the new crowd the
  old run's dolls.
- escalation's per-incident `resolved` was consumed as a permanent per-patron
  `handled`, retiring a correctly-watered `loose` patron for the whole night
  while they kept drinking. Now settles per stage and re-arms when they worsen.
- Stall busts retired the captured occupant list, but the crowd keeps simulating
  behind the modal. Re-queries at resolution.
- Stall infractions put a stall id in the frozen `patronId` field.
Also wired teardown to Phaser's SHUTDOWN event; a `shutdown()` method is never
called, so leaving the floor leaked the view, sim, overlays and bus listeners.

Fight balance was measured, not guessed: at a 20s cooldown an idle night brewed
28 fights and 28 heat strikes against a run cap of 3. Now 150s (~5/night) with a
deferred heat strike, matching §3.2's "possible Heat". An idle night ends at
vibe 0 / 5 strikes; an attentive one at vibe 54 / 0 strikes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:57:28 +10:00
type-two
d112bda270 LANE-JUICE2: lift the track's constants into a live mix + a night arc
The Phase-1 engine baked every level, pitch and envelope into module consts, so
nothing could be tuned without an edit-reload cycle. Three things need them
live: the ear pass (a human turns a knob and hears it on the next beat), the
night arc, and the Phase-4 DJ role, whose control panel is specified as this
exact surface (BUILD_PLAN §4).

- mix.ts: TrackMix + DEFAULT_MIX (reproducing the Phase-1 values exactly, so
  lifting them changes no sound), MIX_RANGES bounds, and formatMix() — which is
  what turns a tuning session into a diff instead of numbers that die with the
  tab.
- arc.ts: the night's shape as per-voice gain scalars. Thin kick at 9PM, bass
  in once there's a crowd to carry it, everything at midnight, low end pulled
  first at Last Drinks. Pure keyframes, so it's testable and the DJ role can
  drive the same surface by hand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:30:42 +10:00
146 changed files with 12465 additions and 331 deletions

1
.gitignore vendored
View File

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

View File

@ -35,9 +35,13 @@ by parallel "lanes" coordinated through files, reviewed by Fable.
floor WASD + E (E at the entry exits back to the door). While you're inside,
Kayden works the door — badly, on purpose.
- Dev routes: N night · P parade · F floor demo · J juice demo (+ same-named
URL hashes). `localStorage.removeItem('not-tonight-save')` for a clean run.
URL hashes) · M toggles the MIX DESK overlay anywhere (ear-pass rig).
`localStorage.removeItem('not-tonight-save')` for a clean run.
- Audio unlocks on first click (TechnoEngine + location filter). Before that a
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)
- Gate: `npm run lint && npm run build && npm test` — all three must pass before
@ -61,3 +65,13 @@ by parallel "lanes" coordinated through files, reviewed by Fable.
The game is Aussie hospitality satire — affectionate, funny, with a moral undertow
(design doc §4.3). If content you're writing reads as mean-spirited rather than
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

1
art_incoming/README.txt Normal file
View File

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

BIN
art_incoming/arcade.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

BIN
art_incoming/barShelf.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

BIN
art_incoming/boothTable.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
art_incoming/buttBin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
art_incoming/cloak.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

BIN
art_incoming/crate.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
art_incoming/deck.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

BIN
art_incoming/discoball.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

BIN
art_incoming/glassRack.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

BIN
art_incoming/glassStack.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

BIN
art_incoming/mixer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
art_incoming/poster1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 823 KiB

BIN
art_incoming/poster2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

BIN
art_incoming/poster3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

BIN
art_incoming/sinkRow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

BIN
art_incoming/speaker.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 944 KiB

BIN
art_incoming/taps.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

BIN
art_incoming/till.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@ -26,16 +26,42 @@ nike_air_max, moschino, quiksilver, kmart, disruptor_ii…), dynamically loaded
sprites before committing — pixelated photo-sprites can look mushy; may need a
cleanup pass or MODELBEAST-assisted redraw per sprite.
### 2. Drunk/idle/dance animation references
`ultra:~/Documents/90sDJsim/web/world/models/` — 22 ped GLBs + **9 anim clip GLBs
incl. `walk_drunk`, idle, sit** (from the shared `character_kit` rig). Plus
`ultra:~/Documents/MOTIONLIB/` — 2,703 motion/anim FBX.
- Option A (cheap): use `walk_drunk` etc. as *visual reference* for hand-animating
the pixel sway/stumble frames.
- Option B (pipeline): pre-render `character_kit` characters + MOTIONLIB clips to
top-down sprite-sheet rotations for the Floor scene (Blender orthographic cam →
pixelate). Heavier, but gives unlimited consistent NPC animation. Decide at v0.4;
the doll contract's `floorTopDown` pose doesn't care which path wins.
### 2. RIGROOM — the character × animation factory (surveyed 2026-07-19)
**This supersedes the old "animation references" note. The fleet has a full
character/animation pipeline, live and organised:**
- **RIGROOM library** (`ultra:~/meshgod-backups/library`, 2.4 GB): **607
animation FBX** in themed packs + 59 character files. Web UI live at
`http://100.91.239.7:8010/rigroom` (characters × animations pairing, three.js
preview, instant Mixamo-ready FBX download). CLI:
`MESHGOD/scripts/rigroom.py classify|sort|scan`.
- Clips that read like THIS game's shot list: `Searching_Pockets` (pat-down),
`Standing_Arguing` + a full boxing/knockout pack (floor fights),
`Shaking_Head_No` (the deny), `Inspecting`/`Looking_Around` (ID check),
`Leaning_Idle02_Smoking` (smoking area), dozens of idles/sits, dance packs,
`Brutal To Happy Walking`, zombie stagger (maggot walk), 12 DJ motions.
Raw source: `m3ultra:~/Documents/Rokoko_Free_Mocap_FBX_263` (606 FBX).
- **Modular character kit** (`m3ultra:~/Documents/character_kit_modular`,
canonical `ultra:~/Documents/character_kit/modular/`): GODVERSE socket
standard — head/hands/feet/torso swap on one shared 65-bone `mixamorig:`
skeleton, 40 embedded actions, every library clip retargets for free.
- **repose** (`m3ultra:~/Documents/repose`): photo → T-pose views (SD1.5 +
ControlNet, all-local) → MeshGod multi-view → autorig. A face becomes a
rigged character without leaving the tailnet.
- **For NOT TONIGHT (v0.4 art pass), the pre-render path is now fully paved:**
modular kit character + RIGROOM clip → Blender orthographic top-down/front →
pixelate → sprite sheets behind the existing `renderDoll` poses. The outfit
variety could even come from kit part swaps. Same pipeline, one venue's
worth of NPC animation for free. Decide at v0.4; contracts don't move.
- Also present, unsurveyed depth: `MOCAPGOD` (empty queue — service scaffold),
`mixamo_upload` staging, `MOTIONLIB` (2,703 FBX, ultra) still the deep pool.
### 2b. procity (m3ultra) — the 90s-Aussie town sim
Walkable procedural shopping town (three.js, no build step), built by lanes
like ours. Pipeline: 186 GLB/FBX with fit/normalize/decimate tooling
(`procity_fit_*` props: arcade cabinet, clothes racks, shelving), 21 peds.
The **prop pipeline conventions** (batch manifests, decimation results JSON)
are the pattern to copy if NOT TONIGHT ever goes 3D-prop-assisted; the venue
furniture (bar, booths, stools) could come straight from its fit pipeline.
### 3. Venue / street reference art
- `ultra:~/Documents/90sDJsim/web/world/media/` named scene/venue art — 100 wired

91
docs/SCENARIOS.md Normal file
View File

@ -0,0 +1,91 @@
# NOT TONIGHT — Character, Scenario & Interaction Backlog
*The idea shelf. Everything here obeys §4.3: choices cost something real, the
game never grades them, and the humane option is never secretly optimal.
Status: ✅ implemented · 🔜 next nibbles · 💡 backlog.*
## Interactions & mini-systems
- ✅ **The joint bust** (smoking yard). E on a smoker who smells herbal → three
verbs, none of them clean: **BIN IT** (by the book — write-up in the truth
log, smoker's night dampened), **POCKET IT** (the game's first corruption
verb — nothing happens now; maybe a Dazza sting later; and it sits in the
incident log waiting for the report form, where your account of it can be a
lie the audit remembers), **DIDN'T SEE IT** (the yard relaxes, vibe drifts
up… and it's still on the record as ignored). 'joint' is real contraband, so
it also turns up in pat-downs at the rope.
- ✅ **The glassie run** (the glassie role's fantasy, previewed as a floor
task). A wire rack of glasses appears at the bar's east end; carry it the
length of the venue to the kitchen dishwasher. Crowd bumps and sharp turns
build WOBBLE; wobble breaks glasses one smash at a time. Deliver what
survives for vibe. Racks keep coming. The door is unattended while you do it
— Kayden is thrilled.
- ✅ **ID passback detection** (2026-07-20): the same card twice in one night — the name on
the guest-list clipboard you already checked; second appearance of an
identical card = passback. Needs a "cards seen tonight" memory at the desk.
- ✅ **Regular step-up lines** (2026-07-20) keyed off timesDeniedBefore ("back again. new
moustache. no hard feelings").
- ✅ **Smoking-door slip-in** (2026-07-20) (FLOOR2 §2's second noStamp source).
- ✅ **The 1:30 lockout** (2026-07-20) (deeply Queensland): after 1:30 AM, admissions are
ILLEGAL. The queue still forms; Dazza still wants bodies; every post-lockout
admit is a guaranteed strike risk vs a rioting line. A whole late-game phase
in one law.
- ✅ **Vomit protocol** (2026-07-20): someone redecorates the dance floor → deploy the wet
floor sign (we own the prop) → slips if you dawdle; the glassie mop loop.
- ✅ **Phone confiscation** (2026-07-20): someone filming on the dance floor — the
influencer's revenge arc.
- ✅ **Water station duty** (2026-07-20) (RSA): press a cup on a `loose` patron; done right
it de-escalates the ripen timer. The humane tool that costs time.
- ✅ **First aid** (2026-07-20): a fainter. Recovery position via a QTE that is deliberately
NOT funny; the room hushes; the night resumes changed.
- ✅ **Kebab run** (2026-07-20): Dazza demands a kebab mid-slam. The shop is right there.
The queue can see you. The whole game in one errand.
## Characters (door archetypes & scripted people)
- ✅ **The off-duty cop** (2026-07-20) — flashes a badge at the rope. Real cop (deny freely,
he respects it; admit and he quietly reports the venue's breaches — a
walking inspector) or a $30 costume badge (the tells: hat, confidence,
wrong lanyard). The ID game's scariest bluff.
- ✅ **The celebrity incognito** (2026-07-20) — cap + sunnies (two live rule breaches!).
Deny: nothing happens, ever, and Dazza never finds out who. Admit: vibe
rocket OR pap swarm outside (aggro storm). You cannot know which.
- ✅ **The twins** (2026-07-20) — two people, one face, different names. Not a fake:
genuinely twins. Everything about the photo check is suddenly on trial.
- ✅ **The sovereign citizen** (2026-07-20) — "I don't recognise your dress code as having
authority over my person." Aggro engine in a polo. His ID is a laminated
business card.
- ✅ **The delivery rider** (2026-07-20) — insists the DJ ordered food. The DJ did not
order food. OR DID HE. (The booth lamp flickers hungrily.)
- ✅ **The promoter** (2026-07-20) — arrives with a roll of wristbands and 'his' guest list
that doesn't match yours. Two clipboards enter, one leaves.
- ✅ **The noise-complaint neighbour** (2026-07-20) — pyjamas, at the rope, at 1 AM, not
trying to get in: trying to get it turned DOWN. The only patron who wants
less club. Handle him badly and the inspector hears about it.
- ✅ **The hen's party, lost** (2026-07-20) — seven matching sashes, wrong venue, right
energy. Admitting them is a vibe grenade with a random timer.
- ✅ **The ex-doorman** (2026-07-20) — watches you work a patron, then tells you what you
missed (a live tutorial character; his tips are right ~80% of the time,
which is the joke).
## Systems worth a whole phase
- ✅ **Venue 4 / ROOM boss week** (2026-07-21, first cut): fourth rung on the
ladder (promotes from Elevate). No Dazza rule chronology — the board IS the
law and re-randomises every 15 in-game minutes (3 rules per window, seeded,
with a toast on the turn); every rope line renders as bracketed lip-read
guesses (`[FIST? LIST?]`) because the techno ate the dialogue. 60 licensed,
1.2 arrival scale, inspector working, crowd of heads and VIP-claimers.
- ✅ **The DJ shift** (2026-07-20, first cut): E at the booth hands you the decks
while the resident steps out. B pulls the bass (the room leans in); SPACE
drops, judged against the LIVE bar line (clean/late/clang — early and late
are the same offence); requests arrive at the booth glass with per-request
odds ("got any dnb", nan's song, the DUN DUN one) and three answers that all
cost something. A build held too long runs out of stairs. Movement locks
while you hold them; the floor and the door keep happening without you.
- ✅ **Bartender shift** (2026-07-21, first cut): E at the till hands you the
taps (movement locks; the floor keeps happening). Orders arrive as choices —
the order line degrades with the patron's stage (the spelling IS the sobriety
test) — SERVE / WATER / CUT OFF all legal, all priced. Serving messy/maggot
is the RSA breach: the vibe pays up front, and 35% of the time somebody with
a lanyard saw it (ONE deferred write-up per night, deduped by reason).

115
docs/SPACES.md Normal file
View File

@ -0,0 +1,115 @@
# NOT TONIGHT — Spaces Plan
*How the game's two spaces grow from readable-but-abstract into places, using the
fleet's asset factories. Written 2026-07-20. Code contract: everything lands
behind data tables (`venueMap.PROPS` / `LIGHTS`) and a sprite drop-in convention —
generated art replaces placeholders with zero code changes.*
## 1. Design principles
1. **Darkness stays the mechanic.** Props read as silhouettes in ambient dark and
resolve under the torch. Nothing may make the room bright enough to scan from
the doorway.
2. **Navigate by light signature.** Each zone owns a colour of light, so the
player orients by glow before geometry: bar = warm amber · dance floor =
pulsing pink (on the actual beat) · DJ booth = deep blue · toilets =
flickering green fluoro · smoking yard = cool rain-blue spill · entry/exit =
venue pink. The torch is white; UV is purple. No other light sources.
3. **Props are data.** `PropDef { kind, tile pos, footprint, emissive? }` — the
renderer prefers a generated texture (`gen:prop:<kind>`) and falls back to a
procedural placeholder, so the game is complete TONIGHT and gets prettier as
art lands.
4. **Spaces anticipate the role ladder** (design §6): the bar interior is laid
out for a future bartender shift, the DJ booth for the DJ shift, the glassie
station and smoking yard for floor loops that already have lanes speccing them.
## 2. The venue floor (80×45 tiles, one room + pockets)
```
┌──────────────────────────────────────────────────────────────────┐
│ N ═ BAR ══════════ back-shelf glow ┌─ DUNNIES ────────┐│
│ taps · till · glasses │ s1 s2 s3 s4 ││
│ ○○○○○○ stools │ sinks + mirror ││
│ └───door───────────┘│
│ W │
│ ┈entry┈ ┌ DANCE FLOOR (pulsing grid) ┐ ╔═ DJ BOOTH ╗ booths│
│ corridor │ speaker · · · · · speaker │ ║ decks+mixer║ E│
│ posters │ · · · disco ball · · · │ ║ (sealed) ║ booths│
│ cloak │ speaker · · · · · speaker │ ╚════════════╝ │
│ glassie └───────────────────────────┘ booths│
│ station │
│ ▓ SMOKING YARD (open sky, rain) ▓ tables+candles S │
└────────▓ fence · butt bin ▓──────────────────────────────────────┘
```
Zone-by-zone (tile coords in `venueMap.ts`):
| Zone | What's there (props) | Light signature | Future role hook |
|---|---|---|---|
| **Bar** (north run) | back-bar shelf (emissive), 3 beer taps, till, glass stacks | steady amber pools along the shelf | Bartender shift: the serving lane between bar tiles and shelf is already unwalkable-to-patrons |
| **Dance floor** (centre) | 4 corner speaker stacks, disco ball (centre), floor grid cells | pink grid cells pulse ON `beat:tick`; ball throws a slow-rotating glint | DJ role reads crowd here |
| **DJ booth** (east of dance, sealed box facing the floor) | 2 decks + mixer (from the 90sDJsim gear GLBs!), booth monitor, lamp | deep blue wash + VU flicker | The DJ shift's playable pocket |
| **Booths** (south + east walls) | table per booth pair, candle | tiny warm candle pools | — |
| **Dunnies** (NE walled room) | sink row + mirror on west wall, hand dryer | green fluoro, FLICKERS (torch almost redundant inside — deliberate) | — |
| **Smoking yard** (SW walled pocket, open sky) | butt bin, milk crate seats, fence | cool blue outdoor spill + rain particles INSIDE the yard | FLOOR2's smoking loop (specced) |
| **Entry corridor** (west) | poster wall (3 art slots — MODELBEAST posters!), cloakroom window, stamp podium | venue-pink spill from the door | UV re-entry checks happen here |
| **Glassie station** (bar's west end alcove) | glass racks, tub trolley | dim amber | Glassie role's home base |
Sim impact: walkability only changes where NEW walls go (DJ booth box, yard
fence, sink row). All existing anchors keep working; reachability is asserted in
tests. The yard gets a `smoke` anchor upgrade so wanderers actually go out for one.
## 3. The door street (640×360 tableau)
Already reads well. Two upgrades, both drop-in images (no layout change):
1. **Backdrop plate** behind the existing rects: one wide pixel-art street wall
(brick, posters, grime) — swaps in as `gen:street:backdrop` when generated.
2. **Kebab shop interior glow card** in its window + rotating poster art in two
street frames (same poster slots as the entry corridor).
## 4. The sprite drop-in contract (how John's art lands)
- Drop processed PNGs in **`public/props/`** named `<kind>.png` and list them in
**`public/props/manifest.json`** (`{"props": ["deck", "speaker", ...]}`).
- The floor loads the manifest at boot; anything listed replaces its placeholder
(texture key `gen:prop:<kind>`). Missing = placeholder. No code changes.
- **`tools/props_import.py`** (PIL, runs anywhere with python3+PIL — ultra has it)
takes raw generated images → target pixel size + palette quantise + transparent
background → writes `public/props/` + updates the manifest:
`python3 tools/props_import.py incoming/deck_raw.png deck`
- Target sizes are in the script (`TARGETS`): hero props 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.)*

BIN
public/props/arcade.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 B

BIN
public/props/barShelf.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

BIN
public/props/boothLamp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 B

BIN
public/props/boothTable.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 B

BIN
public/props/buttBin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 B

BIN
public/props/carpet.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
public/props/cloak.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 691 B

BIN
public/props/crate.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 B

BIN
public/props/deck.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
public/props/discoball.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
public/props/dishwasher.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 B

BIN
public/props/glassRack.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
public/props/glassStack.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
public/props/highTable.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 B

View File

@ -0,0 +1,32 @@
{
"props": [
"arcade",
"barShelf",
"boothLamp",
"boothTable",
"buttBin",
"carpet",
"cloak",
"crate",
"deck",
"discoball",
"dishwasher",
"glassRack",
"glassStack",
"highTable",
"mixer",
"monitor",
"plant",
"poolTable",
"poster1",
"poster2",
"poster3",
"sinkRow",
"speaker",
"stampPodium",
"street_backdrop",
"taps",
"till",
"wetFloor"
]
}

BIN
public/props/mixer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 B

BIN
public/props/monitor.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 B

BIN
public/props/plant.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 B

BIN
public/props/poolTable.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
public/props/poster1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
public/props/poster2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 851 B

BIN
public/props/poster3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 892 B

BIN
public/props/sinkRow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
public/props/speaker.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 657 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

BIN
public/props/taps.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1001 B

BIN
public/props/till.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 B

BIN
public/props/wetFloor.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 B

445
src/audio/Ambience.ts Normal file
View File

@ -0,0 +1,445 @@
// The beds under the night: rain outside, bodies inside, and the kebab shop's
// fluoro tube humming to itself while both happen.
//
// ROUTING. Everything here lands on the engine's sfxBus, never the musicBus.
// The music lowpass models "the track heard through a wall" — that is a
// statement about the PA being on the other side of a door. Rain is not on the
// other side of anything; it is falling on your head, so filtering it with the
// music would put the weather indoors with the speakers. What makes rain
// door-only is its own gain envelope tracking `audio:location`, not a cutoff.
// The crowd bed is the same argument run backwards: it is the room you are
// standing in when you are inside, and a wall's worth of muffling belongs on
// the PA, not on the people. Location is a mix decision here, not a filter one.
import type { EventBus } from '../core/EventBus';
import { SeededRNG } from '../core/SeededRNG';
import type { Sfx } from './Sfx';
export interface AmbienceOptions {
rain?: boolean;
crowd?: boolean;
kebab?: boolean;
}
type Bed = 'rain' | 'crowd' | 'kebab';
type Location = 'door' | 'floor';
/** Seconds of looped noise behind the crowd bed. Long enough to hide the seam. */
const NOISE_SECONDS = 4;
/** Location crossfade. Sits between the 600/900ms filter sweeps either way, so
* stepping through the door reads as one movement rather than two events. */
const LOC_FADE_S = 0.7;
/** Headcount swell. Slow enough to be a swell, fast enough to feel caused. */
const COUNT_FADE_S = 0.4;
/** Enable/disable toggles are a tuning affordance, not a game beat — keep short. */
const TOGGLE_FADE_S = 0.25;
/** Peak crowd gain, at a room so full it cannot get louder. Well under the kick. */
const CROWD_PEAK = 0.09;
/**
* Saturation constant for the headcount curve. At k=14 the first dozen people
* buy most of the loudness and the hundredth buys almost none which is how a
* room actually behaves, and the opposite of what a linear map does.
*/
const CROWD_K = 14;
/** Crowd bed at the door: the room leaking past you, not silence. */
const CROWD_DOOR_DUCK = 0.15;
/** The fluoro tube. Barely there on purpose — it is set dressing, not a voice. */
const KEBAB_LEVEL = 0.014;
export class Ambience {
private readonly bus: EventBus;
private readonly ctx: AudioContext;
private readonly dest: AudioNode;
private readonly sfx: Sfx;
private readonly offs: Array<() => void> = [];
private readonly enabled: Record<Bed, boolean>;
private loc: Location = 'door';
private running = false;
private count = 0;
private crowd: CrowdBed | null = null;
private kebab: KebabBed | null = null;
/** Built on first start() and kept: restarting shouldn't re-allocate 4s of noise. */
private noise: AudioBuffer | null = null;
constructor(
bus: EventBus,
ctx: AudioContext,
destination: AudioNode,
sfx: Sfx,
opts: AmbienceOptions = {},
) {
this.bus = bus;
this.ctx = ctx;
this.dest = destination;
this.sfx = sfx;
this.enabled = {
rain: opts.rain ?? true,
crowd: opts.crowd ?? true,
// The kebab shop is the moral centre of the thing (design doc §5). It gets
// a sound whether or not anyone is listening for it.
kebab: opts.kebab ?? true,
};
this.offs.push(
this.bus.on('audio:location', ({ location }) => {
if (location === this.loc) return; // door -> door must not restack a fade
this.loc = location;
this.applyLocation();
}),
);
// Headcount is derived, not published: NightState.capacity.inside is in no
// event payload and we may not add one. So we count the truth log — an
// admission puts a body in the room, an ejection takes one out.
//
// Deliberately NOT door:clicker. The clicker is the player's *claimed*
// count, and the gap between it and reality is a game mechanic. Driving the
// crowd bed off it would make the room sound like the lie instead of the
// room, and would hand the player a way to hear their own miscount.
this.offs.push(
this.bus.on('door:verdict', ({ verdict }) => {
if (verdict === 'admit') this.setInsideCount(this.count + 1);
}),
);
this.offs.push(
this.bus.on('floor:ejection', () => this.setInsideCount(this.count - 1)),
);
}
get insideCount(): number {
return this.count;
}
/** Lets a demo drive the room without staging a hundred real admissions. */
setInsideCount(n: number): void {
const next = Math.max(0, Math.floor(n));
if (next === this.count) return;
this.count = next;
this.crowd?.setLevel(this.crowdTarget(), COUNT_FADE_S);
}
start(): void {
if (this.running) return;
this.running = true;
this.noise ??= noiseBuffer(this.ctx);
this.crowd = new CrowdBed(this.ctx, this.dest, this.noise);
this.kebab = new KebabBed(this.ctx, this.dest);
// Snap rather than fade on the first frame: there is no previous location to
// cross from, and a 700ms swell out of nothing sounds like a mistake.
this.crowd.setLevel(this.crowdTarget(), 0);
this.crowd.setLocation(this.crowdDuck(), 0);
this.kebab.setLevel(this.kebabTarget(), 0);
this.kebab.setLocation(this.kebabDuck(), 0);
this.applyRain();
}
stop(): void {
if (!this.running) return;
this.running = false;
this.crowd?.stop();
this.crowd = null;
this.kebab?.stop();
this.kebab = null;
this.sfx.stopRain();
}
setEnabled(bed: Bed, on: boolean): void {
if (this.enabled[bed] === on) return;
this.enabled[bed] = on;
if (!this.running) return;
switch (bed) {
case 'rain':
return this.applyRain();
case 'crowd':
return this.crowd?.setLevel(this.crowdTarget(), TOGGLE_FADE_S);
case 'kebab':
return this.kebab?.setLevel(this.kebabTarget(), TOGGLE_FADE_S);
}
}
destroy(): void {
this.stop();
for (const off of this.offs) off();
this.offs.length = 0;
}
// --- policy ---------------------------------------------------------------
private applyLocation(): void {
this.crowd?.setLocation(this.crowdDuck(), LOC_FADE_S);
this.kebab?.setLocation(this.kebabDuck(), LOC_FADE_S);
this.applyRain();
}
/**
* Rain is delegated to Sfx, which already owns a rain bed wired to this same
* bus; a second one would just be the first one twice as loud. Both calls are
* idempotent, so repeated door -> door is a no-op there as well as here.
*
* The `running` guard is what the crowd and kebab beds get for free from their
* null refs. Without it a location change after stop() the subscription
* outlives stop(), only destroy() drains it would start rain under a room
* with no crowd and no kebab in it, and nothing would take it back down.
*/
private applyRain(): void {
if (!this.running) return;
if (this.enabled.rain && this.loc === 'door') this.sfx.startRain();
else this.sfx.stopRain();
}
private crowdTarget(): number {
if (!this.enabled.crowd) return 0;
return CROWD_PEAK * (1 - Math.exp(-this.count / CROWD_K));
}
private crowdDuck(): number {
return this.loc === 'floor' ? 1 : CROWD_DOOR_DUCK;
}
private kebabTarget(): number {
return this.enabled.kebab ? KEBAB_LEVEL : 0;
}
private kebabDuck(): number {
return this.loc === 'door' ? 1 : 0;
}
}
/**
* Bodies and talking. A wide bandpass around 500Hz is the whole trick: above
* ~1kHz noise turns into hiss and below ~250Hz it turns into traffic, and the
* band between them is where a room full of people sits.
*/
class CrowdBed {
private readonly src: AudioBufferSourceNode;
private readonly lfos: OscillatorNode[] = [];
private readonly chain: AudioNode[] = [];
private readonly level: GainNode;
private readonly loc: GainNode;
private readonly ctx: AudioContext;
constructor(ctx: AudioContext, dest: AudioNode, buffer: AudioBuffer) {
this.ctx = ctx;
this.src = ctx.createBufferSource();
this.src.buffer = buffer;
this.src.loop = true;
// Slowed a little: it drags the noise's spectral centre down and costs nothing.
this.src.playbackRate.value = 0.85;
const band = ctx.createBiquadFilter();
band.type = 'bandpass';
band.frequency.value = 500;
band.Q.value = 0.75; // ~300-800Hz at -3dB
// Second pass shaves the fizz the bandpass's gentle skirt lets through.
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 1100;
// Two slow LFOs on a multiplying node, not on the level itself — swelling a
// gain of 0 would push it negative, and an empty room has to be silent.
const wobble = ctx.createGain();
wobble.gain.value = 1;
this.wobbler(wobble, 0.19, 0.12);
this.wobbler(wobble, 0.07, 0.07);
this.level = ctx.createGain();
this.level.gain.value = 0;
this.loc = ctx.createGain();
this.loc.gain.value = 0;
this.chain.push(band, lp, wobble, this.level, this.loc);
this.src.connect(band).connect(lp).connect(wobble).connect(this.level).connect(this.loc);
this.loc.connect(dest);
this.src.start();
}
setLevel(to: number, durS: number): void {
ramp(this.ctx, this.level.gain, to, durS);
}
setLocation(to: number, durS: number): void {
ramp(this.ctx, this.loc.gain, to, durS);
}
stop(): void {
stopBed(this.ctx, this.loc, this.src, this.lfos, this.chain);
}
private wobbler(target: GainNode, hz: number, depth: number): void {
const lfo = this.ctx.createOscillator();
lfo.type = 'sine';
lfo.frequency.value = hz;
const amount = this.ctx.createGain();
amount.gain.value = depth;
lfo.connect(amount).connect(target.gain);
lfo.start();
this.lfos.push(lfo);
this.chain.push(amount);
}
}
/**
* The kebab shop's fluoro tube, heard from the footpath. 100Hz because a tube
* buzzes at twice mains frequency; the 50Hz underneath is the ballast doing the
* work. Mono and completely dry it is six metres away through an open
* shopfront, and reverb would put it in a cathedral.
*/
class KebabBed {
private readonly oscs: OscillatorNode[] = [];
private readonly chain: AudioNode[] = [];
private readonly level: GainNode;
private readonly loc: GainNode;
private readonly ctx: AudioContext;
constructor(ctx: AudioContext, dest: AudioNode) {
this.ctx = ctx;
const sum = ctx.createGain();
sum.gain.value = 1;
// Fundamental, mains harmonic, and a thin 200Hz that keeps it from reading
// as a clean test tone.
this.partial(100, 1, sum);
this.partial(50, 0.45, sum);
this.partial(200, 0.18, sum);
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 400;
// A dying tube breathes. 0.6Hz at 8% is under conscious notice and is the
// difference between "hum" and "sine wave someone left running".
const flicker = ctx.createGain();
flicker.gain.value = 1;
const lfo = ctx.createOscillator();
lfo.type = 'sine';
lfo.frequency.value = 0.6;
const depth = ctx.createGain();
depth.gain.value = 0.08;
lfo.connect(depth).connect(flicker.gain);
lfo.start();
this.oscs.push(lfo);
this.level = ctx.createGain();
this.level.gain.value = 0;
this.loc = ctx.createGain();
this.loc.gain.value = 0;
this.chain.push(sum, lp, depth, flicker, this.level, this.loc);
sum.connect(lp).connect(flicker).connect(this.level).connect(this.loc);
this.loc.connect(dest);
}
setLevel(to: number, durS: number): void {
ramp(this.ctx, this.level.gain, to, durS);
}
setLocation(to: number, durS: number): void {
ramp(this.ctx, this.loc.gain, to, durS);
}
stop(): void {
stopBed(this.ctx, this.loc, null, this.oscs, this.chain);
}
private partial(hz: number, gain: number, sum: GainNode): void {
const osc = this.ctx.createOscillator();
osc.type = 'sine';
osc.frequency.value = hz;
const g = this.ctx.createGain();
g.gain.value = gain;
osc.connect(g).connect(sum);
osc.start();
this.oscs.push(osc);
this.chain.push(g);
}
}
// --- plumbing ---------------------------------------------------------------
/**
* Linear throughout, unlike the engine's sweeps: these targets legitimately
* reach 0 (empty room, bed disabled) and an exponential ramp to zero throws.
* Re-anchoring at the live value first keeps an interrupted fade continuous
* players do walk in and straight back out.
*
* Read `param.value` BEFORE cancelling, same as `TechnoEngine.ramp()`.
* `cancelScheduledValues(now)` removes every event at or after `now`, including
* the in-flight ramp's end event, and the spec does not say what the getter
* reads afterwards: Blink returns the last rendered value, but a UA computing it
* from the surviving timeline returns the ramp's origin. Reading first pins the
* mid-ramp value the player is actually hearing, so a crowd bed 350ms into a
* 0.15 -> 1 door->floor swell fades from ~0.57 rather than jumping down to 0.15.
*
* `cancelAndHoldAtTime` holds that value at `now` instead of discarding it,
* which is the same thing done properly but Firefox has never shipped it, so
* feature-detect and fall back.
*/
function ramp(ctx: AudioContext, param: AudioParam, to: number, durS: number): void {
const now = ctx.currentTime;
const from = param.value;
if (typeof param.cancelAndHoldAtTime === 'function') param.cancelAndHoldAtTime(now);
else param.cancelScheduledValues(now);
param.setValueAtTime(from, now);
if (durS <= 0) param.setValueAtTime(to, now);
else param.linearRampToValueAtTime(to, now + durS);
}
/**
* Fade out, then stop and disconnect everything after the fade lands. Cutting a
* running noise bed is audible as a click, and leaving the nodes attached means
* a six-hour night accumulates every bed it ever started.
*/
function stopBed(
ctx: AudioContext,
out: GainNode,
src: AudioBufferSourceNode | null,
oscs: readonly OscillatorNode[],
chain: readonly AudioNode[],
): void {
const fade = 0.25;
const at = ctx.currentTime + fade;
ramp(ctx, out.gain, 0, fade);
const tail = src ?? oscs[0];
if (tail) {
tail.onended = () => {
for (const n of chain) n.disconnect();
src?.disconnect();
for (const o of oscs) o.disconnect();
};
}
src?.stop(at);
for (const o of oscs) o.stop(at);
}
/** Deterministic white noise house rule forbids Math.random, and a fixed seed
* means the room sounds the same on every run. */
function noiseBuffer(ctx: AudioContext): AudioBuffer {
const len = Math.floor(ctx.sampleRate * NOISE_SECONDS);
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
const data = buf.getChannelData(0);
const rng = new SeededRNG(0x4b3b).stream('crowd-noise');
for (let i = 0; i < len; i++) data[i] = rng.next() * 2 - 1;
return buf;
}

View File

@ -16,7 +16,8 @@ export type SfxName =
| 'phoneBuzz'
| 'ropeUnhook'
| 'denyCrowdOoh'
| 'typeTick';
| 'typeTick'
| 'glassSmash';
/** Seconds of white noise generated once and shared by every noise voice. */
const NOISE_SECONDS = 2;
@ -84,6 +85,8 @@ export class Sfx {
return this.denyCrowdOoh();
case 'typeTick':
return this.typeTick();
case 'glassSmash':
return this.glassSmash();
}
}
@ -271,6 +274,15 @@ export class Sfx {
this.voice(src, [this.band(3400, 2.2), this.env(t, 0.0005, 0.002, 0.012, 0.045)], t, 0.016);
}
/** 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. */
startRain(): void {
if (this.rain) return;

View File

@ -1,11 +1,22 @@
import type { EventBus } from '../core/EventBus';
import { SeededRNG } from '../core/SeededRNG';
import { type ArcScales, arcAt } from './arc';
import {
type AudioLocation,
cutoffFor,
gainFor,
sweepMsFor,
} from './filterCurve';
import {
DEFAULT_MIX,
MIX_RANGES,
type TrackMix,
VOICE_NAMES,
type VoiceName,
clampTo,
cloneMix,
voiceGain,
} from './mix';
import {
type BeatGrid,
barOf,
@ -29,28 +40,33 @@ const TIMER_MS = 25;
*/
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 BASS_ROOT_HZ = 55; // A1
const BASS_STEPS = 32; // 2 bars of 16ths
const PAD_BARS = 8;
/** Seconds to the top of the bass filter sweep. Shape, not level — not tunable. */
const BASS_SWEEP_S = 0.03;
/**
* Semitones from A1, one entry per 16th. Nothing lands on a downbeat the kick
* owns that slot, and leaving it empty is what makes the bassline roll instead
* of thud.
* The bass tail settles a touch above the sweep floor: `filterLoHz` is where the
* note *starts*, and resting exactly there makes the release sound like a gate.
* Expressed as a ratio so retuning the floor carries the rest point with it.
*/
const BASS_TAIL_RATIO = 200 / 180;
/** Fraction of the pad cycle spent fading in; the rest fades out. */
const PAD_RISE = 0.4;
/** A slider drag on `master` should not zipper, but should not lag either. */
const KNOB_RAMP_S = 0.03;
/** Mix paths the engine rounds at use-time, so they must be stored rounded too. */
const INTEGER_PATHS = new Set(['pad.bars']);
/**
* Semitones from the bass root, one entry per 16th. Nothing lands on a downbeat
* the kick owns that slot, and leaving it empty is what makes the bassline
* roll instead of thud.
*/
const BASS_PATTERN: readonly (number | null)[] = [
null, null, 0, 0,
@ -70,6 +86,29 @@ const PAD_VOICES: ReadonlyArray<{ hz: number; detune: number; type: OscillatorTy
{ hz: 164.81, detune: 0, type: 'triangle' },
];
/** 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 {
bpm?: number;
lookaheadMs?: number;
@ -85,6 +124,10 @@ export interface TechnoEngineOptions {
* future. That timer is throttled on a blurred tab exactly as rAF is, so the
* 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.
*
* 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 {
readonly ctx: AudioContext;
@ -105,6 +148,12 @@ export class TechnoEngine {
*/
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 nextBeat = 0;
private loc: AudioLocation = 'door';
@ -122,13 +171,13 @@ export class TechnoEngine {
this.grid = { bpm: this.bpm, originMs: 0 };
this.master = this.ctx.createGain();
this.master.gain.value = MASTER_GAIN;
this.master.gain.value = this.mixState.master;
this.master.connect(this.ctx.destination);
this.filter = this.ctx.createBiquadFilter();
this.filter.type = 'lowpass';
this.filter.frequency.value = cutoffFor(this.loc);
this.filter.Q.value = DOOR_Q;
this.filter.Q.value = this.qFor(this.loc);
this.filter.connect(this.master);
this.musicBus = this.ctx.createGain();
@ -144,7 +193,14 @@ export class TechnoEngine {
this.unsubs.push(
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 {
@ -163,6 +219,76 @@ export class TechnoEngine {
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
* overlapping callers await the same resume rather than racing the origin.
@ -228,7 +354,7 @@ export class TechnoEngine {
const durS = immediate ? 0 : sweepMsFor(location) / 1000;
this.ramp(this.filter.frequency, cutoffFor(location), durS, true);
this.ramp(this.musicBus.gain, gainFor(location), durS, false);
this.ramp(this.filter.Q, location === 'floor' ? FLOOR_Q : DOOR_Q, durS, false);
this.ramp(this.filter.Q, this.qFor(location), durS, false);
}
/** Non-null pins the cutoff and suppresses location sweeps; null hands it back. */
@ -245,6 +371,7 @@ export class TechnoEngine {
this.stop();
for (const off of this.unsubs) off();
this.unsubs.length = 0;
if (activeEngine === this) activeEngine = null;
void this.ctx.close().catch(() => undefined);
}
@ -290,104 +417,148 @@ export class TechnoEngine {
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 {
const sixteenths = subdivisions(this.grid, beatIndex, 4);
const mix = this.mixState;
this.kick(timeMs / 1000);
// Silent voices are skipped, not scheduled quietly: an exponential ramp to
// zero throws, and at 9 PM the arc mutes the bass outright — no reason to
// build and tear down eight nodes a second for something nobody can hear.
const kickGain = this.gainOf('kick');
if (kickGain > SILENT) this.kick(timeMs / 1000, kickGain);
const hatGain = this.gainOf('hat');
const offbeat = sixteenths[2]; // the 8th between beats
if (offbeat !== undefined) this.hat(offbeat / 1000);
if (hatGain > SILENT && offbeat !== undefined) this.hat(offbeat / 1000, hatGain);
for (let s = 0; s < sixteenths.length; s++) {
const at = sixteenths[s];
const semi = BASS_PATTERN[patternStep(beatIndex, s, BASS_STEPS)];
if (at === undefined || semi === undefined || semi === null) continue;
this.bass(at / 1000, BASS_ROOT_HZ * Math.pow(2, semi / 12));
const bassGain = this.gainOf('bass');
if (bassGain > SILENT) {
for (let s = 0; s < sixteenths.length; s++) {
const at = sixteenths[s];
const semi = BASS_PATTERN[patternStep(beatIndex, s, BASS_STEPS)];
if (at === undefined || semi === undefined || semi === null) continue;
this.bass(at / 1000, mix.bass.rootHz * Math.pow(2, semi / 12), bassGain);
}
}
if (isDownbeat(beatIndex) && barOf(beatIndex) % PAD_BARS === 0) this.pad(timeMs / 1000);
const padGain = this.gainOf('pad');
const padBars = this.padBars();
if (padGain > SILENT && isDownbeat(beatIndex) && barOf(beatIndex) % padBars === 0) {
this.pad(timeMs / 1000, padGain, padBars);
}
}
/** The pad's cycle length doubles as its retrigger interval, so it must be a whole bar count. */
private padBars(): number {
return Math.max(1, Math.round(this.mixState.pad.bars));
}
// --- voices ---------------------------------------------------------------
private kick(at: number): void {
private kick(at: number, peak: number): void {
const k = this.mixState.kick;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
// 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.
osc.frequency.setValueAtTime(150, at);
osc.frequency.exponentialRampToValueAtTime(50, at + 0.06);
osc.frequency.setValueAtTime(k.startHz, at);
osc.frequency.exponentialRampToValueAtTime(k.endHz, at + k.dropS);
// A decay tuned shorter than the attack would schedule the ramps out of
// order; the desk allows that combination, WebAudio does not.
const decayS = Math.max(k.attackS + 0.005, k.decayS);
gain.gain.setValueAtTime(SILENT, at);
gain.gain.exponentialRampToValueAtTime(KICK_PEAK, at + 0.008);
gain.gain.exponentialRampToValueAtTime(SILENT, at + 0.34);
gain.gain.exponentialRampToValueAtTime(peak, at + k.attackS);
gain.gain.exponentialRampToValueAtTime(SILENT, at + decayS);
osc.connect(gain).connect(this.musicBus);
osc.start(at);
osc.stop(at + 0.36);
osc.stop(at + decayS + 0.02);
this.hold(osc, [gain]);
}
private hat(at: number): void {
private hat(at: number, peak: number): void {
const h = this.mixState.hat;
const src = this.ctx.createBufferSource();
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();
hp.type = 'highpass';
hp.frequency.value = 7000;
hp.frequency.value = h.hpHz;
const gain = this.ctx.createGain();
gain.gain.setValueAtTime(HAT_PEAK, at);
gain.gain.exponentialRampToValueAtTime(SILENT, at + 0.04);
gain.gain.setValueAtTime(peak, at);
gain.gain.exponentialRampToValueAtTime(SILENT, at + h.decayS);
src.connect(hp).connect(gain).connect(this.musicBus);
src.start(at, offset, 0.06);
// Read far enough to cover the envelope; a longer decay must not be cut off
// by the buffer running out under it.
const readS = Math.min(Math.max(0.06, h.decayS + 0.02), this.noiseBuffer.duration);
// Cheap variety: start the read head somewhere different each hit so the one
// shared buffer doesn't ring identically 400 times a minute. The span is the
// buffer minus the read length, so offset + readS lands inside the buffer for
// any tuning of hat.decayS; a read as long as the buffer leaves no span.
const span = this.noiseBuffer.duration - readS;
const offset = span > 0 ? (at * 7.3) % span : 0;
src.start(at, offset, readS);
this.hold(src, [hp, gain]);
}
private bass(at: number, hz: number): void {
private bass(at: number, hz: number, peak: number): void {
const b = this.mixState.bass;
const lp = this.ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.Q.value = 8;
lp.Q.value = b.q;
const decayS = Math.max(BASS_SWEEP_S + 0.02, b.decayS);
// Per-note filter sweep — the "wow" that stops 16ths sounding like a fax.
lp.frequency.setValueAtTime(180, at);
lp.frequency.exponentialRampToValueAtTime(900, at + 0.03);
lp.frequency.exponentialRampToValueAtTime(200, at + 0.11);
lp.frequency.setValueAtTime(b.filterLoHz, at);
lp.frequency.exponentialRampToValueAtTime(b.filterHiHz, at + BASS_SWEEP_S);
lp.frequency.exponentialRampToValueAtTime(b.filterLoHz * BASS_TAIL_RATIO, at + decayS - 0.01);
const gain = this.ctx.createGain();
gain.gain.setValueAtTime(SILENT, at);
gain.gain.exponentialRampToValueAtTime(BASS_PEAK, at + 0.006);
gain.gain.exponentialRampToValueAtTime(SILENT, at + 0.12);
gain.gain.exponentialRampToValueAtTime(peak, at + 0.006);
gain.gain.exponentialRampToValueAtTime(SILENT, at + decayS);
lp.connect(gain).connect(this.musicBus);
for (const detune of [-8, 8]) {
const spread = [-b.detuneCents, b.detuneCents];
spread.forEach((detune, i) => {
const osc = this.ctx.createOscillator();
osc.type = 'sawtooth';
osc.frequency.value = hz;
osc.detune.value = detune;
osc.connect(lp);
osc.start(at);
osc.stop(at + 0.13);
this.hold(osc, detune > 0 ? [lp, gain] : []);
}
osc.stop(at + decayS + 0.01);
// Hang the shared chain off the LAST saw by index, not by sign: at zero
// detune both are 0 and a sign test would strand lp/gain on the bus.
this.hold(osc, i === spread.length - 1 ? [lp, gain] : []);
});
}
private pad(at: number): void {
const cycle = (PAD_BARS * 4 * beatIntervalMs(this.bpm)) / 1000;
private pad(at: number, peak: number, bars: number): void {
const cycle = (bars * 4 * beatIntervalMs(this.bpm)) / 1000;
const lp = this.ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 600;
lp.frequency.value = this.mixState.pad.lpHz;
const gain = this.ctx.createGain();
gain.gain.setValueAtTime(0, at);
gain.gain.linearRampToValueAtTime(PAD_PEAK, at + cycle * 0.4);
gain.gain.linearRampToValueAtTime(peak, at + cycle * PAD_RISE);
gain.gain.linearRampToValueAtTime(0, at + cycle);
lp.connect(gain).connect(this.musicBus);
@ -406,6 +577,44 @@ export class TechnoEngine {
// --- 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.
* Allocating a buffer per hit would mean ~8 buffers a second for six hours.

98
src/audio/arc.ts Normal file
View File

@ -0,0 +1,98 @@
import { VOICE_NAMES, type VoiceName } from './mix';
// The night's shape, as gain scalars per voice.
//
// A club does not play the same track at 9 PM and at 1 AM, and a room that
// sounds identical for six hours is the main reason a long shift reads as flat.
// So the mix opens thin — kick and a bit of hat, the sound of a room hoping
// people turn up — the bassline arrives once there is a crowd to carry it, the
// pads land at peak, and Last Drinks strips it back so the lights coming up
// feel earned rather than abrupt.
//
// Pure keyframe interpolation: no audio types here, so it is testable in node
// and reusable by the Phase-4 DJ role, which drives this same surface by hand.
/** clockMin is minutes since 21:00; the night ends at 360 (3 AM). */
export const NIGHT_END_MIN = 360;
export type ArcScales = Record<VoiceName, number>;
export interface ArcKeyframe {
/** Minutes since 21:00. */
at: number;
label: string;
scales: ArcScales;
}
/**
* Keyframes, ascending by `at`. Deliberately sparse the interpolation between
* them is what makes the build feel gradual rather than stepped.
*/
export const ARC: readonly ArcKeyframe[] = [
// 9 PM. Doors open to an empty room. Kick and a whisper of hat.
{ at: 0, label: 'EMPTY', scales: { kick: 0.8, hat: 0.4, bass: 0, pad: 0 } },
// 10 PM. First real queue. The bassline turns up with them.
{ at: 60, label: 'FILLING', scales: { kick: 0.95, hat: 0.75, bass: 0.5, pad: 0.15 } },
// 11 PM. Room has weight.
{ at: 120, label: 'BUILDING', scales: { kick: 1, hat: 0.9, bass: 0.85, pad: 0.5 } },
// Midnight through 2 AM. Everything, all of it.
{ at: 180, label: 'PEAK', scales: { kick: 1, hat: 1, bass: 1, pad: 1 } },
{ at: 300, label: 'PEAK', scales: { kick: 1, hat: 1, bass: 1, pad: 1 } },
// 2:30. Last drinks. Pull the low end first — that is what empties a floor.
{ at: 330, label: 'LAST DRINKS', scales: { kick: 0.9, hat: 0.7, bass: 0.55, pad: 0.85 } },
// 3 AM. Pads over a dying kick while the lights come up.
{ at: NIGHT_END_MIN, label: 'LIGHTS UP', scales: { kick: 0.45, hat: 0.2, bass: 0.12, pad: 0.6 } },
];
const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
/**
* Voice scalars at a given clock minute. Clamps outside the night rather than
* extrapolating a paused or overrunning clock should hold the edge, not
* wander into negative gain.
*/
export function arcAt(clockMin: number): ArcScales {
const t = Number.isFinite(clockMin) ? clockMin : 0;
const first = ARC[0];
const last = ARC[ARC.length - 1];
/* istanbul ignore next -- ARC is a non-empty literal */
if (!first || !last) return { kick: 1, hat: 1, bass: 1, pad: 1 };
if (t <= first.at) return { ...first.scales };
if (t >= last.at) return { ...last.scales };
for (let i = 1; i < ARC.length; i++) {
const prev = ARC[i - 1];
const next = ARC[i];
if (!prev || !next || t > next.at) continue;
const span = next.at - prev.at;
const k = span <= 0 ? 1 : (t - prev.at) / span;
const out = {} as ArcScales;
for (const v of VOICE_NAMES) out[v] = lerp(prev.scales[v], next.scales[v], k);
return out;
}
/* istanbul ignore next -- the loop always returns for t inside the range */
return { ...last.scales };
}
/** Nearest keyframe label at or before this minute — for HUD readouts. */
export function arcLabel(clockMin: number): string {
const t = Number.isFinite(clockMin) ? clockMin : 0;
let label = ARC[0]?.label ?? '';
for (const k of ARC) {
if (t >= k.at) label = k.label;
}
return label;
}
/**
* One number for "how hard is the room going", 0..1 the mean of the voice
* scalars. Handy for anything that wants to follow the night's energy without
* caring which voice is doing it (lighting, crowd density, the DJ role's meter).
*/
export function arcIntensity(clockMin: number): number {
const s = arcAt(clockMin);
return VOICE_NAMES.reduce((sum, v) => sum + s[v], 0) / VOICE_NAMES.length;
}

159
src/audio/mix.ts Normal file
View File

@ -0,0 +1,159 @@
// The track's tunable surface, lifted out of TechnoEngine so it can be changed
// while the music is playing.
//
// Three things need this and none of them can use a `const` in a module body:
// the ear pass (a human turns a knob and hears it on the next beat), the night
// arc (arc.ts scales voice levels as the night builds), and the Phase-4 DJ role,
// whose control panel is specified as this exact surface (BUILD_PLAN §4).
//
// Defaults reproduce the Phase-1 engine's hardcoded values exactly, so lifting
// them changed no sound. Anything John retunes in the ear pass gets pasted back
// over DEFAULT_MIX via `formatMix()`.
export type VoiceName = 'kick' | 'hat' | 'bass' | 'pad';
export const VOICE_NAMES: readonly VoiceName[] = ['kick', 'hat', 'bass', 'pad'];
export interface KickParams {
/** Beater pitch at the transient. */
startHz: number;
/** Where the drop lands — the body of the cone. */
endHz: number;
/** Seconds for the pitch drop. Longer reads as a bigger speaker. */
dropS: number;
attackS: number;
decayS: number;
}
export interface HatParams {
/** Highpass corner. Lower lets more of the noise body through. */
hpHz: number;
decayS: number;
}
export interface BassParams {
rootHz: number;
/** Per-note filter sweep floor and peak — the "wow" on each 16th. */
filterLoHz: number;
filterHiHz: number;
q: number;
decayS: number;
/** Cents of detune between the two saws. The whole character lives here. */
detuneCents: number;
}
export interface PadParams {
lpHz: number;
/** Length of the fade-in/out cycle, in bars. */
bars: number;
}
export interface TrackMix {
/** Post-everything output level. */
master: number;
/** Per-voice peak gains, pre-filter. */
levels: Record<VoiceName, number>;
/** Ear-pass solo/mute. A muted voice is still scheduled, just silent. */
enabled: Record<VoiceName, boolean>;
kick: KickParams;
hat: HatParams;
bass: BassParams;
pad: PadParams;
/** Filter resonance at each location — the door's peak is the "through a wall" cue. */
doorQ: number;
floorQ: number;
}
export const DEFAULT_MIX: TrackMix = {
master: 0.8,
levels: { kick: 0.9, hat: 0.25, bass: 0.11, pad: 0.05 },
enabled: { kick: true, hat: true, bass: true, pad: true },
kick: { startHz: 150, endHz: 50, dropS: 0.06, attackS: 0.008, decayS: 0.34 },
hat: { hpHz: 7000, decayS: 0.04 },
bass: { rootHz: 55, filterLoHz: 180, filterHiHz: 900, q: 8, decayS: 0.12, detuneCents: 8 },
pad: { lpHz: 600, bars: 8 },
doorQ: 6,
floorQ: 0.7,
};
/** Bounds every knob, so a slider (or a typo in the console) cannot blow an ear. */
export const MIX_RANGES = {
master: [0, 1.2],
level: [0, 1.5],
'kick.startHz': [60, 400],
'kick.endHz': [25, 120],
'kick.dropS': [0.01, 0.3],
'kick.attackS': [0.001, 0.05],
'kick.decayS': [0.05, 1],
'hat.hpHz': [2000, 14000],
'hat.decayS': [0.005, 0.2],
'bass.rootHz': [30, 110],
'bass.filterLoHz': [60, 600],
'bass.filterHiHz': [300, 4000],
'bass.q': [0.5, 20],
'bass.decayS': [0.02, 0.5],
'bass.detuneCents': [0, 40],
'pad.lpHz': [200, 4000],
'pad.bars': [1, 16],
doorQ: [0.5, 20],
floorQ: [0.1, 4],
} as const satisfies Record<string, readonly [number, number]>;
export const clampTo = (v: number, range: readonly [number, number]): number =>
Number.isFinite(v) ? Math.max(range[0], Math.min(range[1], v)) : range[0];
/** Deep copy — callers mutate their own mix without touching DEFAULT_MIX. */
export function cloneMix(m: TrackMix): TrackMix {
return {
master: m.master,
levels: { ...m.levels },
enabled: { ...m.enabled },
kick: { ...m.kick },
hat: { ...m.hat },
bass: { ...m.bass },
pad: { ...m.pad },
doorQ: m.doorQ,
floorQ: m.floorQ,
};
}
/**
* Effective gain for a voice: its level, muted by `enabled`, scaled by whatever
* the night arc says this voice should be doing right now.
*
* Kept here rather than in the engine so the ear-pass rig can show the same
* number the scheduler will actually use.
*/
export function voiceGain(mix: TrackMix, voice: VoiceName, arcScale = 1): number {
if (!mix.enabled[voice]) return 0;
return Math.max(0, mix.levels[voice] * arcScale);
}
/** True when exactly one voice is unmuted — the ear pass's soloed state. */
export const soloedVoice = (mix: TrackMix): VoiceName | null => {
const on = VOICE_NAMES.filter((v) => mix.enabled[v]);
return on.length === 1 ? (on[0] ?? null) : null;
};
const n = (v: number): string => (Number.isInteger(v) ? String(v) : String(Number(v.toFixed(4))));
/**
* Emit a mix as a pasteable `TrackMix` literal.
*
* The ear pass is a human saying "more click on the kick" and me turning that
* into numbers; without a dump button those numbers die with the tab. This is
* how a tuning session becomes a diff.
*/
export function formatMix(m: TrackMix): string {
return `{
master: ${n(m.master)},
levels: { kick: ${n(m.levels.kick)}, hat: ${n(m.levels.hat)}, bass: ${n(m.levels.bass)}, pad: ${n(m.levels.pad)} },
enabled: { kick: true, hat: true, bass: true, pad: true },
kick: { startHz: ${n(m.kick.startHz)}, endHz: ${n(m.kick.endHz)}, dropS: ${n(m.kick.dropS)}, attackS: ${n(m.kick.attackS)}, decayS: ${n(m.kick.decayS)} },
hat: { hpHz: ${n(m.hat.hpHz)}, decayS: ${n(m.hat.decayS)} },
bass: { rootHz: ${n(m.bass.rootHz)}, filterLoHz: ${n(m.bass.filterLoHz)}, filterHiHz: ${n(m.bass.filterHiHz)}, q: ${n(m.bass.q)}, decayS: ${n(m.bass.decayS)}, detuneCents: ${n(m.bass.detuneCents)} },
pad: { lpHz: ${n(m.pad.lpHz)}, bars: ${n(m.pad.bars)} },
doorQ: ${n(m.doorQ)},
floorQ: ${n(m.floorQ)},
}`;
}

View File

@ -18,6 +18,19 @@ export function freshNightState(venueId: string, nightIndex: number, licensed: n
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
/**
* 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;
// nothing else may mutate NightState meters directly.
export class Meters {

View File

@ -16,6 +16,7 @@ export const CONTRABAND: readonly ContrabandDef[] = [
{ 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: '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: '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: [
{ type: 'none', weight: 6, colours: ['black'] },
{ type: 'blazer', weight: 2, colours: ['black', 'navy', 'grey'] },
{ type: 'blazer', weight: 1, colours: ['black', 'navy', 'grey'] },
{ type: 'bomber', weight: 2, colours: ['black', 'green', 'navy'], canLogo: true, canVintage: true },
{ type: 'hoodie', weight: 2, colours: ['black', 'grey', 'red'], canLogo: true },
{ type: 'leather', weight: 1, colours: ['black', 'brown'], canVintage: true },
@ -75,9 +75,9 @@ export const OUTFIT_VOCAB: Record<OutfitSlot, readonly OutfitTypeDef[]> = {
],
accessory: [
{ type: 'none', weight: 6, colours: ['black'] },
{ type: 'bucketHat', weight: 2, colours: ['cream', 'black', 'green'], canLogo: true, canVintage: true },
{ type: 'bucketHat', weight: 1, colours: ['cream', 'black', 'green'], canLogo: true, canVintage: true },
{ type: 'cap', weight: 2, colours: ['black', 'red', 'navy'], canLogo: true },
{ type: 'sunnies', weight: 2, colours: ['black'] }, // sunglasses. at night. a tell in itself
{ type: 'sunnies', weight: 1, colours: ['black'] }, // sunglasses. at night. a tell in itself
{ type: 'bumbag', weight: 1, colours: ['black', 'purple', 'yellow'], canLogo: true, canVintage: true },
{ type: 'chain', weight: 1, colours: ['yellow', 'grey'] },
],

89
src/data/roster.ts Normal file
View File

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

View File

@ -22,6 +22,10 @@ export const DAZZA_TEXTS: readonly DazzaLine[] = [
{ id: 'rule-suits', text: 'too corporate in here tonight. no blazers til the suits ive already got leave', ruleId: 'noBlazers', fromMin: 200 },
{ id: 'rule-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
{ 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 },

View File

@ -16,6 +16,16 @@ export const DOOR_UI = {
idTrayEmpty: 'no ID handed over',
dressCode: "DAZZA'S RULES",
dressCodeEmpty: 'no rules yet.\nuse ur judgement\n(dont)',
carpet: 'CARPET',
} as const;
/** The red carpet: a velvet holding pen for making the right people wait. */
export const CARPET_UI = {
sent: 'they take position on the carpet like it was always the plan.',
full: 'the carpet is at capacity. it is a small carpet.',
storm: 'someone on the carpet is done being furniture. they leave, loudly, mid-pose.',
entrance: 'an ENTRANCE. the room parts. worth every minute of the wait.',
recallBusy: 'finish with the one at the rope first.',
} as const;
/** Shown once, first thirty seconds, then never again. A stranger must be able to play. */
@ -74,6 +84,20 @@ export const CLAIM_LINES: readonly string[] = [
'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. */
export const DENY_REACTIONS: readonly string[] = [
'the queue goes quiet',
@ -140,6 +164,61 @@ export const ripenLine = (stage: string, roll: number): string => {
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 = {
title: 'SOBRIETY TEST',
pickPrompt: 'pick your test',

View File

@ -21,6 +21,9 @@ export interface EncounterScript {
admitDazza?: string;
denyNote?: 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> = {
@ -80,4 +83,194 @@ export const ENCOUNTER_SCRIPTS: Record<string, EncounterScript> = {
admitNote: 'In he goes, logo and all, with the sign already up.',
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,6 +80,43 @@ export const PATDOWN_FOUND: Record<string, string> = {
export const PATDOWN_CLEAR = 'Nothing on him. "Told ya," he says, delighted.';
export const PATDOWN_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. */
export const NO_STAMP_RADIO: readonly string[] = [
'"Yeah sorry, that one might\'ve been me. Sorry. Sorry."',
@ -89,3 +126,295 @@ export const NO_STAMP_RADIO: readonly string[] = [
export const HELP_TEXT =
'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,6 +22,8 @@ export interface IdFakeTells {
wrongHologram?: boolean;
jokeName?: boolean;
photoMismatch?: boolean;
/** The same card was already admitted tonight (passback — set by the door). */
passback?: boolean;
}
export interface IdCard {
@ -42,6 +44,11 @@ export interface PatronFlags {
disguised?: boolean;
/** Set on couples by door/QueueManager; predicate for noHandHolders (CCR-1). */
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 {
@ -120,6 +127,8 @@ export interface GameState {
heatStrikes: HeatStrike[];
regulars: Record<string, RegularMemory>;
pastReports: IncidentRecord[][];
/** Scripted-encounter ids the run has already met (repeat gating). */
seenEncounters?: string[];
}
// ---- event bus ----
@ -136,6 +145,8 @@ export interface EventMap {
'floor:infractionSpotted': { patronId: string; kind: 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'fight' };
'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 };
'audio:location': { location: 'door' | 'floor' };

102
src/data/venues.ts Normal file
View File

@ -0,0 +1,102 @@
// The venue ladder (design §6): each venue is a week of Thu→Fri→Sat, and a
// difficulty tier expressed as knobs on systems that already exist. Survive a
// week, get the next venue's door. Three strikes anywhere, the run dies.
export interface VenueDef {
id: string;
name: string;
/** Dazza's blurb on the promotion screen. */
pitch: string;
licensed: number;
/** Multiplies the arrival curve — how hard the queue slams. */
arrivalScale: number;
/**
* How many of Dazza's rule texts fire this week (chronological prefix).
* The live-rule cap (dressCode.ACTIVE_RULE_CAP) applies on top.
*/
ruleLimit: number;
/** Does the plainclothes inspector work this venue? */
inspector: boolean;
/**
* Spawn-weight OVERRIDES merged over data/archetypes.ts the venue's crowd
* identity. Omitted keys keep their default weight.
*/
archetypeWeights?: Partial<Record<import('./types').Archetype, number>>;
/** Head of Security tier (design §6): radio command of NPC staff. */
radioCommand?: boolean;
/** ROOM (design §6): the dress code re-randomises every 15 in-game minutes. */
shuffleDressCode?: boolean;
/** ROOM (design §6): rope dialogue renders as lip-read guesses. */
lipRead?: boolean;
}
export const VENUES: readonly VenueDef[] = [
{
id: 'theRoyal',
name: 'The Royal',
pitch: 'a dive with a dance floor. nobody checks anything. u will',
licensed: 80,
arrivalScale: 0.8,
ruleLimit: 4,
inspector: false,
// A local's pub that happens to have a dance floor: regulars and punters,
// barely an influencer in sight.
archetypeWeights: { regular: 14, influencer: 2, financeBro: 6, ownersMate: 2 },
},
{
id: 'voltage',
name: 'Voltage',
pitch: 'valley institution. full rulebook. the inspector drinks here. lemonade',
licensed: 110,
arrivalScale: 1.0,
ruleLimit: 8,
inspector: true,
// The balanced book — the tuning baseline. No overrides.
},
{
id: 'elevate',
name: 'Elevate',
pitch: 'rooftop. small room, big opinions. every second person is a VIP apparently',
licensed: 70,
arrivalScale: 1.1,
ruleLimit: 8,
inspector: true,
// Elevate is the Head of Security tier: the radio starts taking orders.
radioCommand: true,
// Rooftop VIP politics: every second person is on a list, knows the owner,
// or is filming. Regulars don't come here; they can't get in either.
archetypeWeights: { influencer: 18, ownersMate: 7, financeBro: 14, punter: 38, regular: 3 },
},
{
id: 'room',
name: 'ROOM',
pitch: 'no sign. no socials. code changes when the board says so. u wont hear a word anyone says. good luck',
licensed: 60,
arrivalScale: 1.2,
// Dazza doesn't announce rules here — the board is the law and it keeps
// rewriting itself (shuffleDressCode). His other texts still land.
ruleLimit: 0,
inspector: true,
radioCommand: true,
shuffleDressCode: true,
lipRead: true,
// Underground techno: heads and chancers. Everyone claims VIP; almost
// nobody is. Regulars can't find the door.
archetypeWeights: { punter: 34, influencer: 12, ownersMate: 9, quietMessy: 8, financeBro: 10, regular: 2, fresh18: 6 },
},
];
export const TOTAL_VENUES = VENUES.length;
export function venueByIndex(i: number): VenueDef {
return VENUES[Math.max(0, Math.min(VENUES.length - 1, i))]!;
}
/** Week `w` (venue index) shifts the base night dates forward 7 days per week. */
export function nightDateFor(baseIso: string, venueIndex: number): string {
const [y = '2026', m = '07', d = '16'] = baseIso.split('-');
const date = new Date(Number(y), Number(m) - 1, Number(d) + venueIndex * 7);
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
return `${date.getFullYear()}-${mm}-${dd}`;
}

View File

@ -6,7 +6,9 @@ import { DoorScene } from './scenes/door/DoorScene';
import { NightSummaryScene } from './scenes/door/NightSummaryScene';
import { IncidentReportScene } from './scenes/door/IncidentReportScene';
import { FloorDemoScene } from './scenes/floor/FloorDemoScene';
import { RosterScene } from './scenes/shared/RosterScene';
import { JuiceDemoScene } from './ui/JuiceDemoScene';
import { MixDeskScene } from './ui/MixDeskScene';
// 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
@ -24,7 +26,10 @@ const game = new Phaser.Game({
},
// FloorDemoScene registers twice: 'Floor' is the night-mode instance NightScene
// launches with a NightContext; 'FloorDemo' stays the standalone demo (key F).
// RosterScene is scene[0] and auto-starts: the week begins at the board
// (design §6 — role × venue = level select). Dev route N skips it.
scene: [
RosterScene,
NightScene,
DoorScene,
NightSummaryScene,
@ -34,6 +39,7 @@ const game = new Phaser.Game({
ParadeScene,
new FloorDemoScene('FloorDemo'),
JuiceDemoScene,
MixDeskScene,
],
});
@ -47,19 +53,28 @@ const game = new Phaser.Game({
const ROUTES = { night: 'Night', parade: 'Parade', floor: 'FloorDemo', juice: 'JuiceDemo' } as const;
type RouteKey = keyof typeof ROUTES;
// Only ROOT scenes are routed. Door, Floor and NightSummary belong to Night and
// go down with its teardown; stopping them from here used to leave a live Night
// driving a dead Door — a black screen with no way back, because the guard
// below then declined to restart the already-active Night.
const ROOT_SCENES = ['Roster', 'Night', 'Parade', 'FloorDemo', 'JuiceDemo'] as const;
const showScene = (target: (typeof ROUTES)[RouteKey]): void => {
for (const key of ['Night', 'Door', 'NightSummary', 'Parade', 'FloorDemo', 'JuiceDemo']) {
for (const key of ROOT_SCENES) {
if (key !== target && game.scene.isActive(key)) game.scene.stop(key);
}
if (!game.scene.isActive(target)) game.scene.start(target);
// Pressing a route key for what is already running means "start it over".
// It is the only way to get a fresh night out of N mid-shift.
if (game.scene.isActive(target)) game.scene.stop(target);
game.scene.start(target);
};
// NightScene is scene[0] and auto-starts. Hash routing must wait until it is
// RosterScene is scene[0] and auto-starts. Hash routing must wait until it is
// actually up before swapping, or both scenes end up running on top of each other.
const hash = window.location.hash.slice(1) as RouteKey;
if (hash in ROUTES && ROUTES[hash] !== 'Night') {
if (hash in ROUTES) {
const route = (): void => {
if (!game.scene.isActive('Night')) return;
if (!game.scene.isActive('Roster')) return;
game.events.off(Phaser.Core.Events.POST_STEP, route);
showScene(ROUTES[hash]);
};
@ -72,4 +87,13 @@ window.addEventListener('keydown', (e) => {
else if (k === 'p') showScene('Parade');
else if (k === 'f') showScene('FloorDemo');
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 {
// Drunk tells are baked into the texture, so intoxication stage is part of
// the cache key (coarse: swayPx bucket, not raw float).
return `doll:${p.dollSeed}:${pose}:${dollPlan(p, pose).swayPx}`;
return `doll:${p.dollSeed}:${pose}:${dollPlan(p, pose).swayPx}:${p.flags.disguised === true ? 'd' : ''}`;
}
export function renderDoll(scene: Phaser.Scene, p: Patron, pose: DollPose): string {

View File

@ -122,5 +122,16 @@ export function dollPlan(p: Patron, pose: DollPose): DollPlan {
rects.push({ x: cx + 1, y: 9, w: 1, h: 1, colour: 0xcc3333 });
}
// 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 };
}

View File

@ -36,7 +36,9 @@ function rollOutfit(rng: RngStream): OutfitLayer[] {
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 layer: OutfitLayer = { slot, type: def.type, colour: rng.pick(def.colours) };
if (def.canLogo && rng.chance(0.35)) layer.logo = true;
// 0.18 (was 0.35, tuning 2026-07-19): with 8 rules live by 1 AM, a third of
// the crowd wearing logos made most patrons deniable and emptied the room.
if (def.canLogo && rng.chance(0.18)) layer.logo = true;
if (def.canVintage && rng.chance(0.2)) layer.vintage = true;
layers.push(layer);
}
@ -80,13 +82,17 @@ function rollId(rng: RngStream, nightDate: Date, trueAge: number, dollSeed: numb
export interface GeneratorContext {
rng: SeededRNG;
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 {
const rng = ctx.rng.stream('patrons');
const def = archetypeOverride
? ARCHETYPE_BY_KEY.get(archetypeOverride)!
: rng.weighted(ARCHETYPES.map((a) => [a, a.weight] as const));
: rng.weighted(
ARCHETYPES.map((a) => [a, ctx.archetypeWeights?.[a.key] ?? a.weight] as const),
);
const dollSeed = rng.int(0, 2 ** 31 - 1);
const age = rng.int(def.ageRange[0], def.ageRange[1]);

View File

@ -3,6 +3,22 @@ import type { GameState, Patron, RegularMemory } from '../data/types';
// Regulars' grudge store. Behaviour wiring (disguises, dialogue callbacks) lands
// 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 {
const existing = state.regulars[patron.id];
const mem: RegularMemory = existing ?? {
@ -28,3 +44,8 @@ export function shouldReturnDisguised(state: GameState, patronId: string): boole
const mem = state.regulars[patronId];
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

@ -0,0 +1,53 @@
// The run's regulars: a small stable cast generated from the RUN seed, so the
// same faces turn up Thursday, Friday, and next week at the better venue.
// Design §4.1: deny one twice and the third appearance arrives in a bad fake
// moustache — same walk, same card, new "identity".
import { SeededRNG } from '../core/SeededRNG';
import { generatePatron } from './generator';
import type { Patron } from '../data/types';
export const REGULAR_CAST_SIZE = 6;
/**
* Deterministic per run seed and independent of every night stream (its own
* SeededRNG instance). The cast members are full Patron templates the queue
* adopts their IDENTITY (face, age, licence) onto freshly rolled nights
* (tonight's outfit, tonight's drinks) at spawn time.
*/
export function buildRegularCast(runSeed: number, nightDate: Date): Patron[] {
const rng = new SeededRNG(runSeed ^ 0x5eed);
const cast: Patron[] = [];
for (let i = 0; i < REGULAR_CAST_SIZE; i++) {
const p = generatePatron({ rng, nightDate }, 0, 'regular');
p.flags.regularId = `reg${i}`;
cast.push(p);
}
return cast;
}
/**
* Dress tonight's rolled regular in a cast member's identity. The roll keeps
* its outfit/intoxication (people change clothes and moods); the cast supplies
* who they ARE: face, age, licence, tolerance and the grudge bookkeeping.
*/
export function adoptCastIdentity(
rolled: Patron,
member: Patron,
timesDenied: number,
disguised: boolean,
): Patron {
return {
...rolled,
dollSeed: member.dollSeed,
age: member.age,
idCard: { ...member.idCard },
tolerance: member.tolerance,
flags: {
...rolled.flags,
regularId: member.flags.regularId!,
...(timesDenied > 0 ? { timesDeniedBefore: timesDenied } : {}),
...(disguised ? { disguised: true } : {}),
},
};
}

View File

@ -6,6 +6,7 @@
// text that announced it is unplayable, and two hand-kept copies always drift.
import './doorTypes';
import { SeededRNG } from '../core/SeededRNG';
import { DAZZA_TEXTS } from '../data/strings/dazza';
import type { DressCodeRule, OutfitLayer, OutfitSlot, Patron } from '../data/types';
@ -158,10 +159,59 @@ export const DRESS_CODE_RULES: readonly DoorDressCodeRule[] = SPECS.map(build).s
);
/** Rules in force at `clockMin`. Live the moment the text lands, hence inclusive. */
export function activeRules(clockMin: number): DoorDressCodeRule[] {
/**
* Dazza's attention span. Only his most recent rules are LIVE when a fifth
* lands, the oldest is quietly rescinded (the card greys it out and he sends a
* retraction text). Tuning pass 2026-07-19: with all eight rules cumulative,
* ~55% of the late crowd was deniable and a rule-following door emptied the
* room. Four keeps the card readable and the check fast, and Dazza forgetting
* his own rules is exactly in character.
*/
export const ACTIVE_RULE_CAP = 4;
// ---- ROOM shuffle (design §6) ----------------------------------------------
// The boss venue re-randomises the code every 15 in-game minutes: no Dazza
// chronology, the board IS the law. Module-level mode set by NightScene each
// night (null = normal announcements) — threading a venue through every
// violations() caller bought nothing but noise, and every consumer must agree
// on the live set anyway or the card lies.
export const SHUFFLE_EVERY_MIN = 15;
export const SHUFFLE_RULE_COUNT = 3;
let shuffleSeed: number | null = null;
export function setDressCodeShuffle(seed: number | null): void {
shuffleSeed = seed;
}
/** Which 15-minute window `clockMin` falls in — the board changes on the turn. */
export function shuffleWindow(clockMin: number): number {
return Math.floor(Math.max(0, clockMin) / SHUFFLE_EVERY_MIN);
}
function shuffledRules(clockMin: number, seed: number): DoorDressCodeRule[] {
// One deterministic deck per (seed, window): re-reading the board mid-window
// must not reroll it.
const rng = new SeededRNG(seed ^ Math.imul(shuffleWindow(clockMin) + 1, 0x9e3779b9)).stream('code');
const deck = [...DRESS_CODE_RULES];
for (let i = deck.length - 1; i > 0; i--) {
const j = rng.int(0, i);
[deck[i], deck[j]] = [deck[j]!, deck[i]!];
}
return deck.slice(0, SHUFFLE_RULE_COUNT);
}
/** Every rule Dazza has EVER texted tonight, live or rescinded. */
export function announcedRules(clockMin: number): DoorDressCodeRule[] {
if (shuffleSeed !== null) return shuffledRules(clockMin, shuffleSeed);
return DRESS_CODE_RULES.filter((r) => r.activeFrom <= clockMin);
}
export function activeRules(clockMin: number): DoorDressCodeRule[] {
return announcedRules(clockMin).slice(-ACTIVE_RULE_CAP);
}
export function violations(patron: Patron, clockMin: number): DoorDressCodeRule[] {
return activeRules(clockMin).filter((r) => r.violates(patron));
}

View File

@ -18,7 +18,11 @@ import type { Archetype, DrunkStage, HeatStrike, Patron } from '../data/types';
// optimal either. A generous option that quietly pays better is the same lie told
// the other way round. Every `onAdmit` below is priced to be a real decision.
export type EncounterId = 'grabHerMate' | 'quietBeer' | 'lastNight' | 'nightShift';
export type EncounterId =
| 'grabHerMate' | 'quietBeer' | 'lastNight' | 'nightShift'
| 'eighteenToday' | 'kaydensMate' | 'bigNightOut' | 'twoOfThem'
| 'offDutyCop' | 'celebrity' | 'sovereign' | 'deliveryRider'
| 'promoter' | 'neighbour' | 'hensParty' | 'twins' | 'exDoorman';
export interface EncounterBeat {
line: string;
@ -37,6 +41,19 @@ export interface EncounterOutcome {
dazzaText?: string;
/** shown on the verdict toast — factual, never a moral verdict */
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 {
@ -49,6 +66,13 @@ export interface ScriptedEncounter {
beats: EncounterBeat[];
onAdmit: 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. */
@ -153,7 +177,7 @@ const beatsOf = (id: EncounterId): EncounterBeat[] =>
// ---- the encounters ----------------------------------------------------------
export const ENCOUNTERS: readonly ScriptedEncounter[] = [
const ENCOUNTERS_BASE: readonly ScriptedEncounter[] = [
{
// Early: he is doing the rounds while he can still stand at a bar and talk.
id: 'lastNight',
@ -201,9 +225,11 @@ export const ENCOUNTERS: readonly ScriptedEncounter[] = [
onDeny: { note: script('quietBeer').denyNote },
},
{
// After the logo rule lands at 90. Before that he is just a bloke in a polo.
// After the logo rule lands at 90 — and BEFORE Dazza rescinds it (the rule
// cap retires noLogos when the 200-minute rule arrives). Outside that band
// he is just a bloke in a polo and the dilemma evaporates.
id: 'nightShift',
window: [150, 270],
window: [150, 195],
archetype: 'punter',
dress(patron, rng) {
const s = script('nightShift');
@ -260,29 +286,363 @@ export const ENCOUNTERS: readonly ScriptedEncounter[] = [
// Free. Costs nothing but the moment, and the moment is not a mechanic.
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)) {
if (!ENCOUNTERS.some((e) => e.id === id)) {
throw new Error(`encounters: dialogue script '${id}' belongs to no encounter`);
}
}
// scheduleEncounters must never move an encounter outside its own window, so the
// windows themselves have to admit a legal layout for the WHOLE set — any subset
// is strictly easier. Checked at load, where a bad window edit is a boot failure
// rather than a night that quietly drops its third encounter.
function assertWindowsSchedulable(): void {
let earliest = Number.NEGATIVE_INFINITY;
for (const e of [...ENCOUNTERS].sort((a, b) => a.window[0] - b.window[0])) {
if (Math.max(e.window[0], earliest) > e.window[1]) {
throw new Error(`encounters: window for '${e.id}' cannot clear ${ENCOUNTER_SPACING_MIN} min`);
// With eight overlapping windows a whole-set worst-case chain no longer fits in
// one night (that guarantee died when the table outgrew a single night, by
// design — a week should not see the same four people). The load-time check now
// guards each window's own sanity; the scheduler handles infeasible DRAWS by
// skipping the pick (a 2-encounter night beats a crashed one).
function assertWindowsSane(): void {
for (const e of ENCOUNTERS) {
if (e.window[0] >= e.window[1] || e.window[0] < 0 || e.window[1] > 350) {
throw new Error(`encounters: window for '${e.id}' is not a usable night interval`);
}
// Worst case: the previous encounter landed at the very end of its window.
earliest = e.window[1] + ENCOUNTER_SPACING_MIN;
}
}
assertWindowsSchedulable();
assertWindowsSane();
export function encounterById(id: EncounterId): ScriptedEncounter | undefined {
return ENCOUNTERS.find((e) => e.id === id);
@ -297,28 +657,61 @@ export function encounterById(id: EncounterId): ScriptedEncounter | undefined {
export function scheduleEncounters(
rng: RngStream,
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 }> {
const wanted = Math.min(Math.max(0, Math.floor(count)), ENCOUNTERS.length);
if (wanted === 0) return [];
const pool = [...ENCOUNTERS];
for (let i = pool.length - 1; i > 0; i--) {
const j = rng.int(0, i);
const a = pool[i]!;
pool[i] = pool[j]!;
pool[j] = a;
const shuffle = (arr: ScriptedEncounter[]): ScriptedEncounter[] => {
for (let i = arr.length - 1; i > 0; i--) {
const j = rng.int(0, i);
const a = arr[i]!;
arr[i] = arr[j]!;
arr[j] = a;
}
return arr;
};
// Fresh faces first; once the run has met everyone, repeats are honest
// (a regular being a regular), so the excluded set tops the pool back up.
const fresh = shuffle(ENCOUNTERS.filter((e) => !exclude.has(e.id)));
const met = shuffle(ENCOUNTERS.filter((e) => exclude.has(e.id)));
const pool = [...fresh, ...met];
// Three passes, so a random draw can never strand a later pick:
// 1. end-order greedy at the EARLIEST legal minute — anything that fails
// here genuinely does not fit tonight and is dropped (a 2-encounter
// night beats a crashed one);
// 2. a backward pass computing each survivor's latest SAFE minute (its own
// window end, capped by the next pick's latest minute less the spacing);
// 3. a forward pass drawing the actual minute inside [earliest, latestSafe]
// — random where there is room, exact where there is not.
const chosen = pool.slice(0, wanted).sort((a, b) => a.window[1] - b.window[1]);
const placed: ScriptedEncounter[] = [];
let probe = Number.NEGATIVE_INFINITY;
for (const e of chosen) {
const lo = Math.max(e.window[0], probe);
if (lo > e.window[1]) continue;
placed.push(e);
probe = lo + ENCOUNTER_SPACING_MIN;
}
const latest: number[] = new Array(placed.length);
for (let i = placed.length - 1; i >= 0; i--) {
const cap = i === placed.length - 1 ? Infinity : latest[i + 1]! - ENCOUNTER_SPACING_MIN;
latest[i] = Math.min(placed[i]!.window[1], cap);
}
// Placed in window order, each pushed clear of the one before. Because the
// windows are staggered wider than the spacing (asserted above), the lower
// bound can never overrun a window's end.
const chosen = pool.slice(0, wanted).sort((a, b) => a.window[0] - b.window[0]);
const out: Array<{ atMin: number; id: EncounterId }> = [];
let earliest = Number.NEGATIVE_INFINITY;
for (const e of chosen) {
const atMin = rng.int(Math.max(e.window[0], earliest), e.window[1]);
for (let i = 0; i < placed.length; i++) {
const e = placed[i]!;
const lo = Math.max(e.window[0], earliest);
const atMin = rng.int(lo, Math.max(lo, latest[i]!));
out.push({ atMin, id: e.id });
earliest = atMin + ENCOUNTER_SPACING_MIN;
}
return out;
return out.sort((a, b) => a.atMin - b.atMin);
}

68
src/rules/floorScore.ts Normal file
View File

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

14
src/rules/heat.ts Normal file
View File

@ -0,0 +1,14 @@
import type { HeatStrike, NightState } from '../data/types';
/** CONTRACTS.md §3: heatStrikes is "run-scoped, max 3". Design §2: 3 = licence gone. */
export const MAX_HEAT_STRIKES = 3;
/**
* Whether a strike is worth recording. Two guards, both contract-driven:
* past three the run is already over, and a breach that persists (over capacity
* for an hour) is ONE offence the inspector writes up, not one per patron.
*/
export function shouldRecordStrike(state: NightState, strike: HeatStrike): boolean {
if (state.heatStrikes.length >= MAX_HEAT_STRIKES) return false;
return !state.heatStrikes.some((s) => s.reason === strike.reason);
}

View File

@ -17,6 +17,7 @@ const TELL_ORDER = [
'jokeName',
'photoMismatch',
'expired',
'passback',
] as const;
export type IdTell = (typeof TELL_ORDER)[number];
@ -62,6 +63,9 @@ export function idVerdict(id: IdCard, nightDate: Date): IdVerdict {
// The generator ships fakes whose ONLY tell is the date, with no fake flags
// set at all. Deriving this here is what makes those catchable.
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]);

View File

@ -68,6 +68,9 @@ export const REPORT_TUNING = {
// band, never a fixed running order.
interest: {
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,
maggotUnhandled: 7,
kayden: 6, // his mistake, your signature
@ -137,6 +140,30 @@ const BANKS: Record<string, AccountBank> = {
{ id: 'maggot.soft2', text: 'Patron was seated and stable. No intervention was assessed as necessary at that point.', truthful: false },
],
},
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: {
prompt: 'A ruling was made at the rope while you were inside. Your account?',
options: [

View File

@ -15,14 +15,24 @@ import type { Patron, Verdict } from '../data/types';
// pays vibe, it just costs far more aggro than denying a real violator does.
// 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 = {
/** Deny someone who genuinely broke something. The job, done right. */
denyViolatorVibe: 6,
/** Deny someone who genuinely broke something. The job, done right and
* routine. The room expects it; the payoff is small (tuning pass 2026-07-19:
* was 6, which made denial the vibe engine and emptied the room). */
denyViolatorVibe: 2,
/** The queue still watched a stranger get humiliated. */
denyViolatorAggro: 1,
/** Deny someone with nothing wrong with them. Small reward... */
denyCleanVibe: 2,
denyCleanVibe: 1,
/** ...large heat. The whole line saw that there was no reason. */
denyCleanAggro: 7,
@ -37,12 +47,12 @@ export const JUDGE_TUNING = {
/** A regular you have knocked back before. He kept count. */
denyGrudgedRegularVibe: -5,
admitCleanVibe: 3,
admitCleanVibe: 2,
admitCleanAggro: -3,
/** Waving a violator through moves the queue too, just less — you hesitated. */
admitViolatorAggro: -2,
/** The trap: a dud ID admitted still reads to the queue as a fast door. */
admitBadIdVibe: 2,
admitBadIdVibe: 1,
/** "Dazza does a lap": the breach lands 2..5 minutes after you could argue. */
lapDelayMin: 2,
@ -104,10 +114,15 @@ export function judge(patron: Patron, verdict: Verdict, ctx: JudgeContext): Door
// verb (bribe, guest-list override) an else-branch would silently score it as
// an ADMIT — filing heat strikes and capacity breaches for a call the player
// never made. Falling through to deny is inert by comparison.
return verdict === 'admit' ? judgeAdmit(patron, broken, id, ctx) : judgeDeny(patron, broken, id);
return verdict === 'admit' ? judgeAdmit(patron, broken, id, ctx) : judgeDeny(patron, broken, id, ctx);
}
function judgeDeny(patron: Patron, broken: DoorDressCodeRule[], id: IdVerdict): DoorOutcome {
function judgeDeny(
patron: Patron,
broken: DoorDressCodeRule[],
id: IdVerdict,
ctx: JudgeContext,
): DoorOutcome {
const T = JUDGE_TUNING;
// Early return on purpose. Whatever else was wrong with him, Dazza only ever
@ -138,7 +153,9 @@ function judgeDeny(patron: Patron, broken: DoorDressCodeRule[], id: IdVerdict):
id.looksFake ||
patron.age < 18 ||
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 aggroDelta = justified ? T.denyViolatorAggro : T.denyCleanAggro;
@ -254,12 +271,28 @@ function judgeAdmit(
});
}
if (ctx.insideCount >= ctx.licensed) {
const reason = `capacity breach — ${patron.id} admitted at ${ctx.insideCount}/${ctx.licensed}`;
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, deferred: true },
reason,
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) {
// The STRIKE reason is deliberately stable: shouldRecordStrike dedupes by
// reason, and running the room over capacity is ONE offence the inspector
// writes up, not one per punter (a patron-id in this string meant every
// admit past 80 filed its own strike and three admits pulled the licence).
// The incident log keeps the per-patron detail.
deferred.push({
atClockMin: T.auditClockMin,
heatStrike: { reason: 'room over licensed capacity', deferred: true },
reason: `capacity breach — ${patron.id} admitted at ${ctx.insideCount}/${ctx.licensed}`,
patronId: patron.id,
});
}

52
src/rules/lipRead.ts Normal file
View File

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

46
src/rules/staff.ts Normal file
View File

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

View File

@ -1,20 +1,28 @@
import Phaser from 'phaser';
import { renderDoll } from '../../patrons/doll';
import { dollPlan } from '../../patrons/dollPlan';
import { violations, type DoorDressCodeRule } from '../../rules/dressCode';
import { activeRules, announcedRules, shuffleWindow, violations, type DoorDressCodeRule } from '../../rules/dressCode';
import { drunkStage } from '../../rules/drunk';
import { idVerdict, formatDate } from '../../rules/idCheck';
import { judge } from '../../rules/judge';
import { LOCKOUT_MIN, judge } from '../../rules/judge';
import type { DoorOutcome } from '../../rules/doorTypes';
import { CONTRABAND } from '../../data/contraband';
import type { Patron, Verdict } from '../../data/types';
import {
ADMIT_REACTIONS,
KEBAB,
DENY_REACTIONS,
DOOR_TUTORIAL,
DOOR_UI,
BOARD_SHUFFLE_TOASTS,
CARPET_UI,
FRANKO_TIPS,
PATDOWN_LINES,
RULE_RETRACTIONS,
} from '../../data/strings/door';
import { QueueManager } from './QueueManager';
import { CARPET_TUNING, QueueManager, entranceBonus } from './QueueManager';
import { KAYDEN_HOLD_COOLDOWN_MS, KAYDEN_HOLD_MS } from '../../rules/staff';
import { STAFF_RADIO } from '../../data/strings/floor';
import { PatronUpView } from './PatronUpView';
import { IdCardView } from './IdCardView';
import { PhoneWidget } from './PhoneWidget';
@ -23,6 +31,7 @@ import { ClipboardPanel, type ClipboardTab } from './ClipboardPanel';
import { encounterById } from '../../rules/encounters';
import { SobrietyModal } from './SobrietyModal';
import { stamp, type StampHandle } from '../../ui/Stamp';
import { tutorialAlpha } from './tutorialFade';
import { Button, DOOR_PALETTE, MeterBar, MONO, panel, text } from './ui';
import { shouldRecordStrike, type NightContext } from './NightScene';
@ -35,7 +44,6 @@ const KAYDEN_RULE_MS = 5000;
const UP_FOOT_Y = 244;
const DOOR_X = 566;
const QUEUE_FOOT_Y = 228;
const TUTORIAL_MS = 26_000;
/**
* TODO(contract): CCR-4 `dazza:text` carries `rule?: DressCodeRule`, so a rule
@ -67,6 +75,7 @@ export class DoorScene extends Phaser.Scene {
private sobriety!: SobrietyModal;
private queueSprites: QueueSprite[] = [];
private carpetSprites: QueueSprite[] = [];
private trayCard: Phaser.GameObjects.Container | null = null;
private buttons: Record<string, Button> = {};
private vibeBar!: MeterBar;
@ -83,12 +92,31 @@ export class DoorScene extends Phaser.Scene {
private busy = false;
private rulingToken = 0;
private elapsedMs = 0;
/** ROOM: which 15-min shuffle window the code board currently shows. */
private codeWindow = -1;
private testedThisPatron = false;
private pattedThisPatron = false;
private offs: Array<() => void> = [];
/** Is the player at the rope? When false, Kayden works the door. Badly. */
private present = true;
private kaydenAccMs = 0;
/** Radio command: Kayden holds the rope until this elapsedMs, ready again after the other. */
private kaydenHoldUntilMs = 0;
private kaydenHoldReadyMs = 0;
private readonly retiredRules = new Set<string>();
/** Every card ADMITTED tonight, name|dob -> clock label. The passback net. */
private readonly seenCards = new Map<string, string>();
/** 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;
constructor() {
@ -100,19 +128,85 @@ export class DoorScene extends Phaser.Scene {
}
create(): void {
this.queue = new QueueManager(this.night.bus, this.night.rng, this.night.nightDate);
this.queue = new QueueManager(
this.night.bus,
this.night.rng,
this.night.nightDate,
this.night.seenEncounters,
this.night.venue.arrivalScale,
this.night.venue.archetypeWeights,
this.night.regulars,
);
this.up = new PatronUpView(this, UP_X, UP_FOOT_Y);
this.idCard = new IdCardView(this, this.night.nightDate);
this.sobriety = new SobrietyModal(this);
this.present = true;
this.kaydenAccMs = 0;
// Per-night state. Phaser relaunches this ONE scene instance every night, so
// field initialisers run once per game — anything scoped to a single night
// has to be cleared here. `seenCards` leaking was the worst of them: it is
// the passback net, so on Friday every returning regular walked up holding a
// card the desk "already admitted tonight", got stamped SEEN TONIGHT, and
// judge() scored the correct call as admitting a fake.
this.seenCards.clear();
this.retiredRules.clear();
// `busy` is released by a delayedCall on the SCENE clock, and stopping a
// scene discards its pending events — so any ruling inside the last 2.6s of
// a night (or the one that ended it) left this stuck true forever. That is a
// dead door for the rest of the run: no rope, no verdicts, no way inside.
this.busy = false;
this.rulingToken++;
this.frankoTips = 0;
this.kebabPending = false;
this.kebabHandled = false;
this.onKebabRun = false;
this.kebabRunMsLeft = 0;
this.kebabUi = [];
this.kebabFill = null;
// Some nights, mid-slam, Dazza needs a kebab. The shop is right there.
const kebabRng = this.night.rng.stream('kebab');
this.kebabAskAtMin = kebabRng.chance(0.65) ? kebabRng.int(140, 260) : null;
this.buildStreet();
this.buildDesk();
this.buildHud();
// Radio command (design §6, Head of Security tier): from the door you
// order the FLOOR staff; Kayden's hold only matters from the floor, so
// he registers here and answers there.
if (this.night.venue.radioCommand) {
this.night.kaydenHold = () => {
if (this.elapsedMs < this.kaydenHoldReadyMs) return 'cooldown';
this.kaydenHoldUntilMs = this.elapsedMs + KAYDEN_HOLD_MS;
this.kaydenHoldReadyMs = this.elapsedMs + KAYDEN_HOLD_COOLDOWN_MS;
return 'sent';
};
this.input.keyboard?.on('keydown', (ev: KeyboardEvent) => {
if (!this.present || this.sobriety.isOpen) return;
if (ev.key === '1') this.radioFloorOrder('mess');
else if (ev.key === '2') this.radioFloorOrder('water');
});
this.add
.text(6, 246, STAFF_RADIO.hintDoor, { fontFamily: MONO, fontSize: '7px', color: '#6a6a82' })
.setDepth(30);
}
this.offs = [
this.night.bus.on('clock:tick', ({ clockMin }) => this.queue.onClockMinute(clockMin)),
this.night.bus.on('clock:tick', ({ clockMin }) => {
this.queue.onClockMinute(clockMin);
// ROOM: the board rewrites itself on every 15-min window turn.
if (this.night.venue.shuffleDressCode) {
const w = shuffleWindow(clockMin);
if (w !== this.codeWindow) {
this.codeWindow = w;
this.codeCard.setAll(this, activeRules(clockMin), this.elapsedMs);
this.showToast(BOARD_SHUFFLE_TOASTS[w % BOARD_SHUFFLE_TOASTS.length]!);
this.night.sfx?.play('phoneBuzz');
}
}
}),
this.night.bus.on('meters:changed', ({ vibe, aggro, hype }) => {
this.vibeBar.set(vibe / 100, vibe < 20);
this.aggroBar.set(aggro / 100, aggro > 80);
@ -124,7 +218,18 @@ export class DoorScene extends Phaser.Scene {
// `short`. Narrowing back to the lane's own DoorDressCodeRule is a real
// contract gap (CCR-4) — this predicate is the honest version of it
// rather than an `as never` punch-through.
if (isDoorRule(rule)) this.codeCard.add(this, rule, this.elapsedMs);
if (isDoorRule(rule)) {
this.codeCard.add(this, rule, this.elapsedMs);
this.retireStaleRules();
}
}),
this.night.bus.on('door:verdict', ({ patron, verdict }) => {
if (verdict !== 'admit') return;
// The desk remembers the card, whoever waved it through.
this.seenCards.set(
`${patron.idCard.name}|${patron.idCard.dob}`,
this.night.clock.label,
);
}),
this.night.bus.on('heat:strike', () => this.refreshCounters()),
this.night.bus.on('night:phaseChange', ({ location }) => {
@ -152,7 +257,13 @@ export class DoorScene extends Phaser.Scene {
// ---------- construction ----------
private buildStreet(): void {
this.add.rectangle(W / 2, 88, W, 176, 0x0a0a12); // wall of night
if (this.textures.exists('gen:prop:street_backdrop')) {
// The generated plate (SPACES §5 item 11): brick, posters, grime. Sits
// where the flat wall-of-night rect went; everything else layers on top.
this.add.image(0, 0, 'gen:prop:street_backdrop').setOrigin(0, 0).setDisplaySize(W, 176).setAlpha(0.9);
} else {
this.add.rectangle(W / 2, 88, W, 176, 0x0a0a12); // wall of night
}
this.add.rectangle(W / 2, 212, W, 76, 0x1a1a24); // footpath
this.add.rectangle(W / 2, 176, W, 2, 0x2a2a38);
@ -161,6 +272,9 @@ export class DoorScene extends Phaser.Scene {
this.add.rectangle(62, 88, 96, 18, DOOR_PALETTE.kebab, 0.9);
this.add.text(28, 82, 'KEBABS', { fontFamily: MONO, fontSize: '12px', color: '#3a2404' });
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);
// the venue
@ -185,6 +299,21 @@ export class DoorScene extends Phaser.Scene {
.text(DOOR_X, 184, 'step inside', { fontFamily: MONO, fontSize: '7px', color: '#6a4a5a' })
.setOrigin(0.5, 0);
// The red carpet: a strip of somewhere-better between rope and door,
// flanked by brass posts. Bodies parked here are rendered by
// syncCarpetSprites; the strip is set dressing.
if (this.textures.exists('gen:prop:carpet')) {
this.add.image(420, 236, 'gen:prop:carpet').setOrigin(0.5, 1).setDisplaySize(132, 34).setDepth(2);
} else {
this.add.rectangle(420, 232, 124, 12, 0x8a1428).setDepth(2);
this.add.rectangle(420, 227, 124, 2, 0xa8283c).setDepth(2);
for (const px of [356, 484]) {
this.add.rectangle(px, 216, 3, 22, 0xc8a24a).setDepth(2);
this.add.rectangle(px, 205, 7, 4, 0xe0c060).setDepth(2);
}
this.add.rectangle(420, 206, 126, 2, 0x7a2438).setDepth(2); // the velvet itself
}
this.buildRain();
this.buildRope();
}
@ -231,6 +360,13 @@ export class DoorScene extends Phaser.Scene {
// Two panels, one footprint. The tab header sits above them both.
this.codeCard = new DressCodeCard(this, 6, 268, 168, 86);
// ROOM: no Dazza chronology — the board opens already written, and the
// rope reads lips (PatronUpView renders every line as a guess).
if (this.night.venue.shuffleDressCode) {
this.codeWindow = shuffleWindow(this.night.state.clockMin);
this.codeCard.setAll(this, activeRules(this.night.state.clockMin), this.elapsedMs);
}
this.up.lipRead = this.night.venue.lipRead === true;
this.clipboard = new ClipboardPanel(this, this.queue.guestList, 6, 268, 168, 86);
this.buildDeskTabs();
@ -272,10 +408,15 @@ export class DoorScene extends Phaser.Scene {
// verdicts
this.buttons.rope = new Button(this, {
x: 442, y: 256, w: 192, h: 24, label: DOOR_UI.rope, size: 9,
x: 442, y: 256, w: 130, h: 24, label: DOOR_UI.rope, size: 9,
fill: 0x5a2030, hover: 0x8a2038,
onClick: () => this.callNext(),
});
this.buttons.carpet = new Button(this, {
x: 576, y: 256, w: 58, h: 24, label: DOOR_UI.carpet, size: 7,
fill: 0x6a1424, hover: 0x9a2038,
onClick: () => this.callCarpet(),
});
this.buttons.test = new Button(this, {
x: 442, y: 284, w: 94, h: 22, label: DOOR_UI.test, size: 7,
onClick: () => this.runSobriety(),
@ -374,10 +515,27 @@ export class DoorScene extends Phaser.Scene {
this.showToast(DOOR_UI.ropeEmpty);
return;
}
this.presentUp(p);
}
/** Everything that happens once SOMEBODY is at the rope the rope and the
* carpet recall share this path, so a recalled patron is a full patron. */
private presentUp(p: Patron): void {
this.testedThisPatron = false;
this.pattedThisPatron = false;
// Passback: this exact card already went in tonight. The tell is set HERE —
// 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.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.setVerdictButtons(true);
// Only a claim puts a name on the clipboard. Someone who says nothing is
@ -393,6 +551,89 @@ export class DoorScene extends Phaser.Scene {
});
}
/** Door-side radio: order the floor staff without leaving the rope. */
private radioFloorOrder(kind: 'mess' | 'water'): void {
const ack = this.night.floorOrder?.(kind, (msg) => {
if (this.present) this.showToast(msg);
});
if (ack === 'sent') this.showToast(kind === 'mess' ? STAFF_RADIO.bumpAck : STAFF_RADIO.shanAck);
else if (ack === 'cooldown') this.showToast(STAFF_RADIO.cooldown);
else if (ack === 'nothing') this.showToast(kind === 'mess' ? STAFF_RADIO.bumpNothing : STAFF_RADIO.shanNothing);
}
// ---- the red carpet ----------------------------------------------------
/** The patron at the rope takes position between the brass posts. */
private callCarpet(): void {
const p = this.queue.patronUp;
if (!p || this.busy) return;
if (this.queue.toCarpet() === null) {
this.showToast(CARPET_UI.full);
return;
}
this.idCard.close();
this.trayCard?.destroy();
this.trayCard = null;
this.up.clear();
this.setVerdictButtons(false);
this.night.sfx?.play('ropeUnhook');
this.showToast(CARPET_UI.sent);
this.logIncidentDoor('carpet', p.id, 'Sent to the carpet to be seen waiting.');
}
/** Click a carpet doll: wave them up to the rope for the actual ruling. */
private recallCarpet(patronId: string): void {
if (this.busy) return;
if (this.queue.patronUp) {
this.showToast(CARPET_UI.recallBusy);
return;
}
const p = this.queue.recallFromCarpet(patronId);
if (!p) return;
this.presentUp(p);
}
private logIncidentDoor(kind: string, patronId: string | undefined, detail: string): void {
this.night.bus.emit('incident:log', { clockMin: this.night.state.clockMin, kind, patronId, detail });
}
/** The carpet is a view of QueueManager, same discipline as the queue line. */
private syncCarpetSprites(): void {
const slots = this.queue.carpetSnapshot;
const same =
slots.length === this.carpetSprites.length &&
slots.every((s, i) => this.carpetSprites[i]?.patron.id === s.patron.id);
if (!same) {
for (const s of this.carpetSprites) s.image.destroy();
this.carpetSprites = slots.map((slot, i) => {
const x = 372 + i * 34;
const img = this.add
.image(x, QUEUE_FOOT_Y + 6, renderDoll(this, slot.patron, 'queue'))
.setOrigin(0.5, 1)
.setScale(1.5)
.setDepth(205)
.setInteractive({ useHandCursor: true });
img.on('pointerdown', () => this.recallCarpet(slot.patron.id));
img.setData('baseX', x);
return { patron: slot.patron, image: img, link: null };
});
}
// Impatience is visible: the sway grows as the patience runs out.
for (let i = 0; i < this.carpetSprites.length; i++) {
const s = this.carpetSprites[i]!;
const slot = slots[i];
if (!slot) continue;
const fuse = Math.min(1, slot.standMs / slot.patienceMs);
s.image.x = s.image.getData('baseX') + Math.sin(this.elapsedMs / (300 - 140 * fuse)) * (1 + fuse * 3);
}
for (const p of this.queue.takeStorms()) {
this.showToast(CARPET_UI.storm);
this.night.sfx?.play('doorBang');
this.logIncidentDoor('carpetStorm', p.id, 'Ran out of patience on the carpet and left, loudly.');
}
}
/** Nudge the player toward a tab that just became relevant, without switching it. */
private flashTab(tab: ClipboardTab): void {
const b = this.tabButtons[tab];
@ -482,6 +723,14 @@ export class DoorScene extends Phaser.Scene {
}
this.applyOutcome(p, verdict, outcome);
this.applyEncounter(p, verdict);
// The entrance dividend: an admit off the carpet pays for every minute
// they were made to stand there being looked at.
const stoodMs = this.queue.carpetMsFor(p.id);
if (verdict === 'admit' && stoodMs > 0) {
this.night.bus.emit('meters:delta', { vibe: CARPET_TUNING.entranceVibe, hype: entranceBonus(stoodMs) });
this.time.delayedCall(600, () => this.showToast(CARPET_UI.entrance));
this.logIncidentDoor('carpetEntrance', p.id, `Admitted after ${Math.round(stoodMs / 1000)}s on the carpet.`);
}
this.queue.resolveUp(verdict === 'deny');
// Highlight AFTER the call, never before. Lighting up the broken rules the
@ -546,7 +795,7 @@ export class DoorScene extends Phaser.Scene {
const id = this.queue.encounterFor(p);
const enc = id ? encounterById(id) : undefined;
if (!enc) return;
const out = verdict === 'admit' ? enc.onAdmit : enc.onDeny;
const out = enc.resolve ? enc.resolve(p, verdict) : verdict === 'admit' ? enc.onAdmit : enc.onDeny;
const delta: { vibe?: number; aggro?: number } = {};
if (out.vibeDelta) delta.vibe = out.vibeDelta;
@ -561,6 +810,25 @@ export class DoorScene extends Phaser.Scene {
if (out.dazzaText) this.night.bus.emit('dazza:text', { text: out.dazzaText });
if (out.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', {
clockMin: this.night.state.clockMin,
kind: 'encounter',
@ -569,6 +837,109 @@ export class DoorScene extends Phaser.Scene {
});
}
/**
* Franko's tip for the patron at the rope. Category is drawn from the REAL
* inspection surfaces; 1-in-5 he confidently names a problem the patron does
* not have. Fourteen years of doors.
*/
private frankoTip(p: Patron): string {
const rng = this.night.rng.stream('franko');
const v = idVerdict(p.idCard, this.night.nightDate);
const stage = drunkStage(p.intoxication);
const truths: Array<keyof typeof FRANKO_TIPS> = [];
if (v.looksFake) truths.push('fake');
if (v.underage) truths.push('underage');
if (stage === 'loose' || stage === 'messy' || stage === 'maggot') truths.push('drunk');
if (violations(p, this.night.state.clockMin).length > 0) truths.push('dress');
if (truths.length === 0) truths.push('clean');
let cat = truths[rng.int(0, truths.length - 1)]!;
if (rng.chance(0.2)) {
const lies = (Object.keys(FRANKO_TIPS) as Array<keyof typeof FRANKO_TIPS>).filter(
(k) => !truths.includes(k),
);
if (lies.length > 0) cat = lies[rng.int(0, lies.length - 1)]!;
}
const pool = FRANKO_TIPS[cat];
return pool[rng.int(0, pool.length - 1)]!;
}
/** The ask, the glow, and — ignored long enough — the sulk. */
private tickKebab(): void {
const min = this.night.state.clockMin;
if (this.kebabAskAtMin !== null && !this.kebabHandled && !this.kebabPending && min >= this.kebabAskAtMin) {
this.kebabPending = true;
this.night.bus.emit('dazza:text', { text: KEBAB.ask });
if (this.kebabGlow) {
this.kebabGlow.setFillStyle(0xe8c060, 0.22);
this.tweens.add({ targets: this.kebabGlow, alpha: { from: 1, to: 0.4 }, duration: 900, yoyo: true, repeat: -1 });
}
}
if (this.kebabPending && this.kebabAskAtMin !== null && min >= this.kebabAskAtMin + 25) {
// He waited 25 clock-minutes. He found the muesli bar.
this.kebabPending = false;
this.kebabHandled = true;
if (this.kebabGlow) {
this.kebabGlow.setFillStyle(0xe8c060, 0);
this.tweens.killTweensOf(this.kebabGlow);
this.kebabGlow.setAlpha(1);
}
this.night.bus.emit('meters:delta', { vibe: -2 });
this.night.bus.emit('dazza:text', { text: KEBAB.sulk });
}
}
/**
* Twelve real seconds in the shop, in full view of the queue, while Kayden
* finds the clipboard. The whole game in one errand (docs/SCENARIOS.md).
*/
private startKebabRun(): void {
if (!this.kebabPending || this.onKebabRun || this.busy) return;
this.kebabPending = false;
this.kebabHandled = true;
this.onKebabRun = true;
this.kebabRunMsLeft = 12_000;
if (this.kebabGlow) {
this.kebabGlow.setFillStyle(0xe8c060, 0);
this.tweens.killTweensOf(this.kebabGlow);
this.kebabGlow.setAlpha(1);
}
const shade = this.add.rectangle(320, 180, 640, 360, 0x08060a, 0.86).setDepth(950).setInteractive();
const label = this.add
.text(320, 150, KEBAB.running, {
fontFamily: MONO,
fontSize: '11px',
color: '#e8c060',
align: 'center',
lineSpacing: 4,
})
.setOrigin(0.5)
.setDepth(951);
const bar = this.add.rectangle(320 - 90, 232, 180, 6, 0x3a3424).setOrigin(0, 0.5).setDepth(951);
this.kebabFill = this.add.rectangle(320 - 90, 232, 1, 6, 0xe8c060).setOrigin(0, 0.5).setDepth(951);
this.kebabUi = [shade, label, bar, this.kebabFill];
}
/** Driven from update() — a real 12s of shop time, not a tween the pane can stall. */
private tickKebabRun(deltaMs: number): void {
if (!this.onKebabRun) return;
this.kebabRunMsLeft -= deltaMs;
this.kebabFill?.setDisplaySize(Math.max(1, 180 * (1 - this.kebabRunMsLeft / 12_000)), 6);
if (this.kebabRunMsLeft > 0) return;
for (const o of this.kebabUi) o.destroy();
this.kebabUi = [];
this.kebabFill = null;
this.onKebabRun = false;
this.night.bus.emit('meters:delta', { vibe: 2 });
this.night.bus.emit('dazza:text', { text: KEBAB.thanks });
this.night.bus.emit('incident:log', {
clockMin: this.night.state.clockMin,
kind: 'kebabRun',
detail: 'Left the rope mid-shift. Returned with a kebab. Garlic sauce correct.',
});
this.showToast('back on the rope. the kebab is gone within the minute.');
}
private judgeCtx(): Parameters<typeof judge>[2] {
return {
clockMin: this.night.state.clockMin,
@ -651,6 +1022,29 @@ export class DoorScene extends Phaser.Scene {
});
}
/**
* Rules cap at ACTIVE_RULE_CAP (Dazza's attention span). When a new one lands,
* grey out whatever fell off the card and let Dazza announce the retraction
* judge() and the card must agree about what is live, or the card lies.
*/
private retireStaleRules(): void {
const live = new Set(activeRules(this.night.state.clockMin).map((r) => r.id));
for (const r of announcedRules(this.night.state.clockMin)) {
if (live.has(r.id)) continue;
// Always re-sync the card: Dazza's texts can LAG the clock (the send
// cooldown queues them), so a rule can be rescinded before its card row
// has even landed. retire() is an idempotent no-op until the row exists,
// and every later text re-runs this sync — the card converges.
this.codeCard.retire(r.id);
if (this.retiredRules.has(r.id)) continue; // toast once per rule, though
this.retiredRules.add(r.id);
const line = RULE_RETRACTIONS[this.retiredRules.size % RULE_RETRACTIONS.length]!;
// A retraction is a TEXT — it lands on the phone with a buzz like every
// other pronouncement, and lives in the thread as evidence.
this.night.bus.emit('dazza:text', { text: line.replace('{rule}', r.short).replace('dazza: ', '') });
}
}
private refreshCounters(): void {
const cap = this.night.state.capacity;
this.clickerText.setText(String(cap.clickerShown));
@ -674,20 +1068,36 @@ export class DoorScene extends Phaser.Scene {
// confident, and wrong a lot — his admits go through the same judge(), so
// his mistakes cost the same vibe/heat yours do. (His stamp accuracy is
// NightScene's problem; his verdicts are made here on the real queue.)
if (!this.present) {
this.kaydenAccMs += deltaMs;
while (this.kaydenAccMs >= KAYDEN_RULE_MS) {
this.kaydenAccMs -= KAYDEN_RULE_MS;
this.kaydenRule();
if (!this.present || this.onKebabRun) {
// "Hold the rope" (radio command): Kayden does nothing, magnificently.
if (this.elapsedMs < this.kaydenHoldUntilMs) {
this.kaydenAccMs = 0;
} else {
this.kaydenAccMs += deltaMs;
while (this.kaydenAccMs >= KAYDEN_RULE_MS) {
this.kaydenAccMs -= KAYDEN_RULE_MS;
this.kaydenRule();
}
}
}
this.clockText.setText(this.night.clock.label);
if (this.tutorial.alpha > 0 && this.elapsedMs > TUTORIAL_MS) {
this.tweens.add({ targets: this.tutorial, alpha: 0, duration: 800 });
this.tickKebab();
this.tickKebabRun(deltaMs);
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.syncCarpetSprites();
this.buttons.carpet?.setEnabled(
!this.busy && this.queue.patronUp !== null && this.queue.carpetSnapshot.length < CARPET_TUNING.slots,
);
const rope = this.buttons.rope;
if (rope) {
@ -746,6 +1156,8 @@ export class DoorScene extends Phaser.Scene {
}
private teardown(): void {
for (const s of this.carpetSprites) s.image.destroy();
this.carpetSprites = [];
for (const off of this.offs) off();
this.offs = [];
this.phone?.destroy();

View File

@ -14,6 +14,8 @@ interface Row {
label: Phaser.GameObjects.Text;
badge: Phaser.GameObjects.Text;
addedAt: number;
/** Rescinded by Dazza (rules cap at ACTIVE_RULE_CAP) — greyed, never judged. */
retired?: boolean;
}
export class DressCodeCard {
@ -57,14 +59,36 @@ export class DressCodeCard {
this.rows.push({ rule, label, badge, addedAt: nowMs });
}
/** ROOM shuffle: the board rewrites itself wholesale — clear and restate. */
setAll(scene: Phaser.Scene, rules: readonly DoorDressCodeRule[], nowMs: number): void {
for (const r of this.rows) {
r.label.destroy();
r.badge.destroy();
}
this.rows.length = 0;
this.emptyText.setVisible(rules.length === 0);
for (const rule of rules) this.add(scene, rule, nowMs);
}
/** Highlight the rules the patron currently in front of you is breaking. */
highlight(violatedIds: readonly string[]): void {
for (const r of this.rows) {
if (r.retired) continue;
const hit = violatedIds.includes(r.rule.id);
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 {
for (const r of this.rows) {
if (r.badge.visible && nowMs - r.addedAt > NEW_BADGE_MS) r.badge.setVisible(false);

View File

@ -133,6 +133,16 @@ export class IdCardView {
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 });
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) {
// 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));

View File

@ -3,15 +3,20 @@ import { EventBus } from '../../core/EventBus';
import { GameClock, DEFAULT_CLOCK, type ClockConfig } from '../../core/GameClock';
import { SeededRNG } from '../../core/SeededRNG';
import { StubBeatClock } from '../../core/StubBeatClock';
import { Meters, freshNightState } from '../../core/meters';
import { Meters, freshNightState, vibeCoolingPerMin } from '../../core/meters';
import { loadGame, saveGame, clearSave, freshGameState } from '../../core/save';
import { _resetPatronSerial } from '../../patrons/generator';
import { ruleById } from '../../rules/dressCode';
import { ruleById, setDressCodeShuffle } from '../../rules/dressCode';
import { TechnoEngine } from '../../audio/TechnoEngine';
import { Ambience } from '../../audio/Ambience';
import { Sfx } from '../../audio/Sfx';
import type { DeferredHit } from '../../rules/doorTypes';
import type { GameState, HeatStrike, NightState, Patron, Verdict, VerdictOutcome } from '../../data/types';
import { nextDazza } from './dazzaSchedule';
import { TOTAL_VENUES, nightDateFor, venueByIndex, type VenueDef } from '../../data/venues';
import { defaultRoleFor, roleById, type RoleDef } from '../../data/roster';
import { buildRegularCast } from '../../patrons/regularCast';
import { recordDenialBy } from '../../patrons/memory';
import { observe, startWatch, type InspectorWatch, type VenueBreaches } from '../../rules/inspector';
// Phase-2: the night-flow machine. Door and Floor both run for the whole night;
@ -19,11 +24,12 @@ import { observe, startWatch, type InspectorWatch, type VenueBreaches } from '..
// meters, the audio engine (and the location filter that goes with it), Kayden's
// 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;
const VIBE_SAMPLE_EVERY_MIN = 5;
/** CONTRACTS.md §3: heatStrikes is "run-scoped, max 3". Design §2: 3 = licence gone. */
export const MAX_HEAT_STRIKES = 3;
import { MAX_HEAT_STRIKES, shouldRecordStrike } from '../../rules/heat';
export { MAX_HEAT_STRIKES, shouldRecordStrike };
/** Thu → Fri → Sat at The Royal. Index = nightIndex. */
export const NIGHT_DATES = ['2026-07-16', '2026-07-17', '2026-07-18'] as const;
@ -36,10 +42,6 @@ const RADIO_PULL_MINS = [120, 250] as const;
/** Dynamic maggot-radio rate limit (clock minutes). */
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';
@ -61,6 +63,17 @@ export interface NightLog {
heatStrikes: HeatStrike[];
endReason: NightEndReason;
seed: number;
/** The shift taken off the roster board (design §6) and what it pays. */
role: string;
wage: number;
/** What the lights-on sweep found in cash. */
floorCash: number;
/**
* Cash that went in your pocket during the shift the DJ booth's shoe. Kept
* apart from floorCash because the sweep only happens on a clean close, and a
* pulled licence does not confiscate what somebody already handed you.
*/
pocketCash: number;
}
/** Run-level result attached to the summary. */
@ -69,10 +82,15 @@ export interface RunInfo {
game: GameState;
nightIndex: 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 */
runOver: boolean;
/** survived all three nights */
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. */
@ -88,8 +106,33 @@ export interface NightContext {
beatIntervalMs: number;
scheduleDeferred(hits: readonly DeferredHit[]): 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. */
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 {
@ -99,11 +142,14 @@ export class NightScene extends Phaser.Scene {
private stubBeat: StubBeatClock | null = null;
private engine: TechnoEngine | null = null;
private sfx: Sfx | null = null;
private ambience: Ambience | null = null;
private meters!: Meters;
private state!: NightState;
private run!: GameState;
private seed = 4207;
private nightIndex = 0;
private venue!: VenueDef;
private role!: RoleDef;
private ended = false;
private deferred: DeferredHit[] = [];
private readonly firedDazza = new Set<string>();
@ -126,7 +172,7 @@ export class NightScene extends Phaser.Scene {
super('Night');
}
init(data: { seed?: number; nightIndex?: number; freshRun?: boolean }): void {
init(data: { seed?: number; nightIndex?: number; freshRun?: boolean; role?: string }): void {
if (data.freshRun) clearSave();
this.run = loadGame(typeof data.seed === 'number' ? data.seed : 4207);
if (typeof data.seed === 'number' && this.run.seed !== data.seed) {
@ -134,8 +180,11 @@ export class NightScene extends Phaser.Scene {
}
this.nightIndex = data.nightIndex ?? this.run.nightIndex;
if (this.nightIndex >= TOTAL_NIGHTS) this.nightIndex = 0;
// Each night is its own deterministic world derived from the run seed.
this.seed = this.run.seed + this.nightIndex * 101;
this.venue = venueByIndex(this.run.venueIndex);
this.role = roleById(data.role) ?? defaultRoleFor(this.venue);
// Each night is its own deterministic world derived from the run seed —
// and each venue-week its own block of them.
this.seed = this.run.seed + this.run.venueIndex * 1009 + this.nightIndex * 101;
}
create(): void {
@ -150,9 +199,16 @@ export class NightScene extends Phaser.Scene {
this.bus = new EventBus();
this.rng = new SeededRNG(this.seed);
const clockCfg: ClockConfig = { ...DEFAULT_CLOCK, nightDate: NIGHT_DATES[this.nightIndex]! };
const clockCfg: ClockConfig = {
...DEFAULT_CLOCK,
nightDate: nightDateFor(NIGHT_DATES[this.nightIndex]!, this.run.venueIndex),
};
this.clock = new GameClock(this.bus, clockCfg);
this.state = freshNightState('theRoyal', this.nightIndex, LICENSED_CAPACITY);
this.state = freshNightState(this.venue.id, this.nightIndex, this.venue.licensed);
// ROOM: the board rewrites itself every 15 min — judge(), the card and
// Kayden must all read the same shuffled set, so the mode lives at module
// level in rules/dressCode. Set (or cleared) fresh every night.
setDressCodeShuffle(this.venue.shuffleDressCode ? this.seed : null);
// Heat is run-scoped: strikes from earlier nights are already on the licence.
this.state.heatStrikes.push(...this.run.heatStrikes);
this.meters = new Meters(this.bus, this.state);
@ -161,6 +217,8 @@ export class NightScene extends Phaser.Scene {
// Until the first gesture, a stub keeps beat:tick honest (then hands over).
this.engine = new TechnoEngine(this.bus);
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.start();
this.engine.start();
@ -181,6 +239,10 @@ export class NightScene extends Phaser.Scene {
heatStrikes: [],
endReason: 'clock',
seed: this.seed,
role: this.role.name,
wage: this.role.wage,
floorCash: 0,
pocketCash: 0,
};
this.offs.push(
@ -191,16 +253,31 @@ export class NightScene extends Phaser.Scene {
this.bus.on('floor:ejection', () => {
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.stubBeat?.stop();
this.stubBeat = null;
this.ambience?.start();
}),
this.bus.on('radio:call', () => this.sfx?.play('radioChirp')),
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') {
this.maggotLive = true;
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.
if (inc.kind === 'ejection' || inc.kind === 'cutOff') this.maggotLive = false;
}),
@ -214,21 +291,65 @@ export class NightScene extends Phaser.Scene {
seed: this.seed,
nightDate: this.clock.nightDate,
sfx: this.sfx,
role: this.role,
beatIntervalMs: 60_000 / 128,
scheduleDeferred: (hits) => this.deferred.push(...hits),
bankCash: (amount, source) => {
if (amount <= 0) return;
this.log.pocketCash += amount;
this.bus.emit('incident:log', {
clockMin: this.state.clockMin,
kind: 'pocketCash',
detail: `$${amount}${source}.`,
});
},
reportQueue: (length) => {
this.queueLength = length;
},
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),
};
this.events.once('shutdown', () => this.teardown());
this.clock.start();
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.
this.time.delayedCall(0, () => this.applyLocation('door'));
// Generated art loads BEFORE the scenes that consume it (textures are
// game-global): the door's street backdrop and the floor's props both ride
// public/props + manifest. Missing manifest = placeholders, zero waiting.
this.load.json('propManifest', 'props/manifest.json');
this.load.once(Phaser.Loader.Events.COMPLETE, () => {
const manifest = this.cache.json.get('propManifest') as { props?: string[] } | undefined;
const missing = (manifest?.props ?? []).filter((k) => !this.textures.exists(`gen:prop:${k}`));
const launch = (): void => {
this.scene.launch('Door', { night: this.ctx });
this.scene.launch('Floor', { night: this.ctx });
// Door and Floor BOTH run all night; the flags below decide which one
// the player is inhabiting. Floor starts hidden.
// The roster decides where you stand when the doors open (design §6).
this.time.delayedCall(0, () => {
this.applyLocation(this.role.startLoc);
// ...and ANNOUNCE it. applyLocation only moves flags; the room latches
// onto the event. FloorDemoScene reads `present` when it binds this
// context, which — now that the night preloads every prop texture —
// happens before this runs, so a floor-start shift (bartender, glassie,
// DJ) came up visible but completely inert, and the audio filter stayed
// in door mode while you stood on the dancefloor.
this.bus.emit('night:phaseChange', { location: this.role.startLoc });
this.bus.emit('audio:location', { location: this.role.startLoc });
});
};
if (missing.length === 0) return launch();
for (const k of missing) this.load.image(`gen:prop:${k}`, `props/${k}.png`);
this.load.once(Phaser.Loader.Events.COMPLETE, launch);
this.load.start();
});
this.load.start();
}
// ---- location ----------------------------------------------------------
@ -263,11 +384,18 @@ export class NightScene extends Phaser.Scene {
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
// thing that marks them is that admitting one starts a clock (§4.2).
if (patron.archetype === 'inspector' && this.inspector === null) {
// ...and only at venues the inspector actually works (venue ladder).
if (patron.archetype === 'inspector' && this.inspector === null && this.venue.inspector) {
this.inspector = startWatch(patron.id, this.state.clockMin);
}
if (patron.age < 18) this.minorsInside++;
} else if (verdict === 'deny') this.log.denied++;
} else if (verdict === 'deny') {
this.log.denied++;
// The regulars keep count across nights AND venues (same town).
if (patron.flags.regularId) {
recordDenialBy(this.run, patron.flags.regularId, this.run.venueIndex * TOTAL_NIGHTS + this.nightIndex);
}
}
else if (verdict === 'sobrietyTest') this.log.tested++;
else if (verdict === 'patDown') this.log.pattedDown++;
}
@ -299,6 +427,10 @@ export class NightScene extends Phaser.Scene {
this.maybeDazza(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');
}
@ -383,6 +515,7 @@ export class NightScene extends Phaser.Scene {
queueLength: this.queueLength,
insideCount: this.state.capacity.inside,
fired: this.firedDazza,
ruleLimit: this.venue.ruleLimit,
});
if (!line) return;
this.firedDazza.add(line.id);
@ -415,12 +548,21 @@ export class NightScene extends Phaser.Scene {
const runOver = reason === 'licence';
const survived = reason === 'clock';
const weekDone = survived && this.nightIndex >= TOTAL_NIGHTS - 1;
const seasonDone = weekDone && this.run.venueIndex >= TOTAL_VENUES - 1;
this.run.heatStrikes = [...this.state.heatStrikes];
// pastReports is written by IncidentReportScene now, not here: Phase 4
// audits what the player CLAIMED happened, and a raw truth dump would have
// nothing to catch them out with.
if (survived) this.run.nightIndex = this.nightIndex + 1;
if (runOver || weekDone) {
if (weekDone && !seasonDone) {
// PROMOTED: next venue, fresh week, fresh licence. Heat is per-venue —
// the new place has not heard of you (yet). Regulars and met-encounters
// carry across: it is the same town.
this.run.venueIndex += 1;
this.run.nightIndex = 0;
this.run.heatStrikes = [];
}
if (runOver || seasonDone) {
clearSave();
} else {
saveGame(this.run);
@ -429,14 +571,30 @@ export class NightScene extends Phaser.Scene {
game: this.run,
nightIndex: this.nightIndex,
totalNights: TOTAL_NIGHTS,
venue: this.venue,
promotedTo: weekDone && !seasonDone ? venueByIndex(this.run.venueIndex) : null,
runOver,
weekDone,
seasonDone,
};
this.time.delayedCall(reason === 'clock' ? 600 : 1400, () => {
this.scene.stop('Door');
this.scene.stop('Floor');
this.scene.start('NightSummary', { log: this.log, state: this.state, run: runInfo });
const proceed = (): void => {
this.scene.stop('Door');
this.scene.stop('Floor');
this.scene.start('NightSummary', { log: this.log, state: this.state, run: runInfo });
};
// Made it to close: the lights come on and the floor pays out first.
// A pulled licence or a dead room skips the sweep — nobody's sweeping.
if (reason === 'clock' && this.ctx.beginSweep) {
this.applyLocation('floor');
this.ctx.beginSweep((score) => {
this.log.floorCash = score.cash;
proceed();
});
} else {
proceed();
}
});
}
@ -447,9 +605,17 @@ export class NightScene extends Phaser.Scene {
}
private teardown(): void {
// Door and Floor are this scene's children — they hold this night's bus,
// clock and context. A Night that goes away without taking them leaves two
// scenes driving a dead bus, and a restarted Night would launch a second
// pair on top of the first.
this.scene.stop('Door');
this.scene.stop('Floor');
for (const off of this.offs) off();
this.offs = [];
this.meters?.destroy();
this.ambience?.destroy();
this.ambience = null;
this.engine?.destroy();
this.engine = null;
this.sfx = null;

View File

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

View File

@ -2,8 +2,9 @@ import Phaser from 'phaser';
import { renderDoll } from '../../patrons/doll';
import { dollPlan } from '../../patrons/dollPlan';
import { drunkStage } from '../../rules/drunk';
import { lipReadLine } from '../../rules/lipRead';
import type { Patron } from '../../data/types';
import { CLAIM_LINES, DRUNK_GREETINGS, OWNER_HINTS, PATRON_GREETINGS } from '../../data/strings/door';
import { CLAIM_LINES, DRUNK_GREETINGS, OWNER_HINTS, PATRON_GREETINGS, REGULAR_DISGUISED_LINES, REGULAR_RETURN_LINES } from '../../data/strings/door';
import { encounterById, type EncounterId } from '../../rules/encounters';
import { MONO } from './ui';
import { ZONE_BANDS, ZONE_ORDER, describeZone, greetingIndex, type InspectZone } from './inspect';
@ -25,6 +26,8 @@ export class PatronUpView {
private swayPx = 0;
/** The bubble currently on screen. Beats REPLACE it; they do not stack. */
private bubble: Phaser.GameObjects.GameObject[] = [];
/** ROOM (design §6): every line renders as a lip-read guess. Set by DoorScene. */
lipRead = false;
constructor(
private readonly scene: Phaser.Scene,
@ -92,18 +95,26 @@ export class PatronUpView {
// 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
// generic "i'm on the list" shared with every influencer.
const pool = p.flags.knowsOwner
? OWNER_HINTS
: p.flags.claimsGuestList
? CLAIM_LINES
: drunk === 'messy' || drunk === 'maggot' || drunk === 'loose'
? DRUNK_GREETINGS
: PATRON_GREETINGS;
// The disguised regular's denial-of-everything outranks every other line —
// it IS the tell (same face, same walk, "never been here"). A merely
// knocked-back regular gets his olive branch in ahead of list claims.
const pool = p.flags.disguised === true
? REGULAR_DISGUISED_LINES
: p.flags.regularId !== undefined && (p.flags.timesDeniedBefore ?? 0) > 0
? REGULAR_RETURN_LINES
: p.flags.knowsOwner
? OWNER_HINTS
: p.flags.claimsGuestList
? CLAIM_LINES
: drunk === 'messy' || drunk === 'maggot' || drunk === 'loose'
? DRUNK_GREETINGS
: PATRON_GREETINGS;
const line = pool[greetingIndex(p, pool.length)] ?? PATRON_GREETINGS[0]!;
const bubbleY = -DOLL_H * SCALE - 16;
// Lip-read guesses drop the quote marks — it's your reading, not their words.
const label = scene.add
.text(0, bubbleY, `"${line}"`, {
.text(0, bubbleY, this.lipRead ? lipReadLine(line, p.id) : `"${line}"`, {
fontFamily: MONO,
fontSize: '8px',
color: '#d8d0c0',
@ -136,7 +147,7 @@ export class PatronUpView {
this.clearBubble();
const bubbleY = -DOLL_H * SCALE - 16;
const label = scene.add
.text(0, bubbleY, `"${line}"`, {
.text(0, bubbleY, this.lipRead ? lipReadLine(line, this.patron.id) : `"${line}"`, {
fontFamily: MONO,
fontSize: '8px',
color: '#e8dcc4',

View File

@ -4,10 +4,14 @@ import { generatePatron } from '../../patrons/generator';
import type { Patron } from '../../data/types';
import { nextArrivalGapMin } from './arrivalCurve';
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 {
ENCOUNTERS,
encounterById,
scheduleEncounters,
twinSibling,
type EncounterId,
} from '../../rules/encounters';
@ -40,8 +44,15 @@ export const QUEUE_TUNING = {
* empty, so an empty-only rule meant a perfectly-played night still rioted.
*/
comfortableQueue: 4,
/** aggro bled off per real second with nobody waiting at all */
calmPerSec: 0.9,
/** 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.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 */
phoneHype: 0.12,
phoneCooldownMs: 2500,
@ -58,6 +69,43 @@ export const QUEUE_TUNING = {
encountersPerNight: 3,
} as const;
// The red carpet (design §2 queue theatre, escalated): a velvet holding pen
// where you park somebody gorgeous and make them WAIT, visibly, at length.
// Every body on display sells the room; every body has a limit.
export const CARPET_TUNING = {
/** brass posts only stretch so far */
slots: 3,
/** hype per real second per body on display */
hypePerSecPerBody: 0.02,
/** the entrance: admitting off the carpet pays hype scaled by the stand */
entranceHypePerSec: 0.012,
entranceHypeCap: 0.6,
entranceVibe: 2,
/** patience in real seconds, before the archetype scale */
patienceSecBase: [30, 55] as const,
/**
* Who tolerates being furniture. Influencers LIVE for it; a regular gives
* it thirty seconds and tells the whole suburb.
*/
patienceScale: {
influencer: 1.8, financeBro: 1.3, ownersMate: 1.2, fresh18: 1.1,
almost18: 1.0, inspector: 1.0, punter: 0.9, quietMessy: 0.7, regular: 0.5,
} as Readonly<Record<import('../../data/types').Archetype, number>>,
/** storming off the carpet is a scene the whole queue watches */
stormAggro: 3,
} as const;
/** The entrance dividend for `standMs` on the carpet, capped. */
export function entranceBonus(standMs: number): number {
return Math.min(CARPET_TUNING.entranceHypeCap, (standMs / 1000) * CARPET_TUNING.entranceHypePerSec);
}
export interface CarpetSlot {
patron: Patron;
standMs: number;
patienceMs: number;
}
export interface QueueSnapshot {
waiting: Patron[];
visible: Patron[];
@ -80,6 +128,10 @@ export class QueueManager {
private readonly comebackRng: RngStream;
private readonly listRng: 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 */
readonly guestList: GuestList;
/** scripted arrivals still owed tonight, soonest first */
@ -88,14 +140,34 @@ export class QueueManager {
private readonly encounterOf = new Map<string, EncounterId>();
/** patrons queued to re-enter the queue: [clockMin, patron] */
private readonly comebacks: Array<{ atMin: number; patron: Patron }> = [];
/** Twins whose sibling has already been sent for — one brother each. */
private readonly twinsSpawned = new Set<string>();
/** everyone who reached the front tonight, for the summary screen */
readonly seen: Patron[] = [];
/** bodies currently on display between the brass posts */
private readonly carpet: CarpetSlot[] = [];
/** storm-offs since the scene last asked — drained by takeStorms() */
private readonly storms: Patron[] = [];
/** patronId -> how long they stood, recorded at recall (the entrance maths) */
private readonly carpetStand = new Map<string, number>();
private readonly carpetRng: RngStream;
constructor(
private readonly bus: EventBus,
private readonly rng: SeededRNG,
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.coupleRng = rng.stream('couples');
@ -106,11 +178,40 @@ export class QueueManager {
this.comebackRng = rng.stream('comebacks');
this.listRng = rng.stream('guestList');
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.listRng,
this.listRng.int(8, Math.min(14, ENCOUNTERS.length + 12)),
);
this.pendingEncounters = scheduleEncounters(this.encounterRng, QUEUE_TUNING.encountersPerNight);
this.pendingEncounters = scheduleEncounters(
this.encounterRng,
QUEUE_TUNING.encountersPerNight,
new Set(seenEncounters),
);
// Somebody is ALWAYS at the rope when the shift starts. A game that opens on
// an empty street reads as broken, however accurate a 9 PM lull would be.
this.enqueue(this.make());
@ -167,7 +268,7 @@ export class QueueManager {
while (clockMin >= this.nextArrivalMin) {
this.spawnArrival();
const gap = nextArrivalGapMin(this.nextArrivalMin, this.arrivalRng.next());
const gap = nextArrivalGapMin(this.nextArrivalMin, this.arrivalRng.next(), this.arrivalScale);
this.nextArrivalMin += Math.max(1, Math.round(gap));
}
}
@ -176,6 +277,7 @@ export class QueueManager {
update(deltaMs: number): void {
const sec = deltaMs / 1000;
this.phoneCooldownMs = Math.max(0, this.phoneCooldownMs - deltaMs);
this.tickCarpet(deltaMs);
const idle = this.up === null && this.waiting.length > 0;
if (idle) {
@ -203,6 +305,82 @@ export class QueueManager {
const relief = (QUEUE_TUNING.calmPerSec * slack) / QUEUE_TUNING.comfortableQueue;
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. */
@ -272,7 +450,31 @@ export class QueueManager {
}
private make(): Patron {
const p = generatePatron({ rng: this.rng, nightDate: this.nightDate }, this.clockMin);
let p = generatePatron(
{ rng: this.rng, nightDate: this.nightDate, ...(this.archetypeWeights ? { archetypeWeights: this.archetypeWeights } : {}) },
this.clockMin,
);
// A rolled 'regular' is one of THE regulars — the run's stable cast. Same
// face every night; the grudge (and the moustache) rides the run save.
if (p.archetype === 'regular' && this.regulars && this.regulars.cast.length > 0) {
const member = this.regulars.cast[this.castRng.int(0, this.regulars.cast.length - 1)]!;
const mem = this.regulars.memory[member.flags.regularId ?? ''];
p = adoptCastIdentity(p, member, mem?.timesDenied ?? 0, disguisedFromMemory(mem));
}
// The passback (docs/SCENARIOS.md): late in the night, some chancer is
// holding a card that walked in HOURS ago — right name, right DOB, and a
// photo of somebody else entirely. No flag is set here; the DESK notices,
// if the desk has been paying attention.
if (
this.clockMin >= 150 &&
this.admittedCards.length > 0 &&
(p.archetype === 'punter' || p.archetype === 'almost18' || p.archetype === 'financeBro') &&
this.passbackRng.chance(0.05)
) {
const src = this.admittedCards[this.passbackRng.int(0, this.admittedCards.length - 1)]!;
p.idCard = { name: src.name, dob: src.dob, expiry: src.expiry, photoSeed: src.photoSeed };
}
// Anyone who says they're on the list gets a name the clipboard has an
// opinion about. Done here rather than in the generator because the list is

View File

@ -13,13 +13,16 @@ export interface ArrivalCurvePoint {
* backs up during the slam, few enough that the queue is still clearable.
*/
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: 45, perMin: 0.18 }, // 9:45 PM
{ clockMin: 90, perMin: 0.34 }, // 10:30 PM — the pre-drinks let out
{ clockMin: 150, perMin: 0.42 }, // 11:30 PM — peak
{ clockMin: 210, perMin: 0.32 }, // 12:30 AM — slam ends
{ clockMin: 270, perMin: 0.16 }, // 1:30 AM
{ clockMin: 330, perMin: 0.06 }, // 2:30 AM — last drinks
{ clockMin: 45, perMin: 0.2 }, // 9:45 PM
{ clockMin: 90, perMin: 0.55 }, // 10:30 PM — the pre-drinks let out
{ clockMin: 150, perMin: 0.72 }, // 11:30 PM — peak
{ clockMin: 210, perMin: 0.45 }, // 12:30 AM — slam ends
{ clockMin: 270, perMin: 0.2 }, // 1:30 AM
{ clockMin: 330, perMin: 0.07 }, // 2:30 AM — last drinks
{ clockMin: 360, perMin: 0 }, // 3:00 AM
];
@ -60,8 +63,11 @@ export function arrivalsPerMin(clockMin: number): number {
* an RngStream; the exponential draw is what makes arrivals clump three at once
* then a lull instead of ticking over like a metronome.
*/
export function nextArrivalGapMin(clockMin: number, roll: number): number {
const rate = arrivalsPerMin(clockMin);
export function nextArrivalGapMin(clockMin: number, roll: number, scale = 1): number {
// `scale` is the venue's arrival multiplier (ladder, design §6): a dive runs
// at 0.8x this curve, the rooftop above 1x. Guarded so a junk scale reads as 1.
const s = Number.isFinite(scale) && scale > 0 ? scale : 1;
const rate = arrivalsPerMin(clockMin) * s;
if (!(rate > 0)) return NO_ARRIVALS_GAP_MIN;
const safeRoll = Number.isFinite(roll) ? Math.min(Math.max(roll, 0), 0.999999) : 0;

View File

@ -11,6 +11,11 @@ export interface DazzaWorld {
queueLength: number;
insideCount: number;
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. */
@ -23,9 +28,18 @@ const MOOD: Record<string, (w: DazzaWorld) => boolean> = {
};
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[] = [];
for (const line of DAZZA_TEXTS) {
if (world.fired.has(line.id)) continue;
if (line.ruleId !== undefined && !allowedRules.has(line.id)) continue;
if (clockMin < line.fromMin) continue;
const gate = MOOD[line.id];
if (gate && !gate(world)) continue;

View File

@ -0,0 +1,17 @@
/** How long the door tutorial text stays fully lit before it starts to fade. */
export const TUTORIAL_MS = 26_000;
/** Length of the fade once the hold expires. */
export const TUTORIAL_FADE_MS = 800;
/**
* Tutorial text alpha at `elapsedMs` into the night. Pure update()-driven math,
* deliberately NOT a tween: the old `if (alpha > 0) tweens.add(...)` guard
* spawned a fresh 800ms tween every frame (each new tween re-captured the
* current alpha and kept it above zero, so the guard never closed ~1,100 live
* tweens by night's end), and tweens stall under the preview pane's stepped
* clock anyway.
*/
export function tutorialAlpha(elapsedMs: number): number {
if (elapsedMs <= TUTORIAL_MS) return 1;
return Math.max(0, 1 - (elapsedMs - TUTORIAL_MS) / TUTORIAL_FADE_MS);
}

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,8 @@ import Phaser from 'phaser';
import { renderDoll } from '../../patrons/doll';
import { dollPlan } from '../../patrons/dollPlan';
import { conePolygon, type ConeSpec } from './cone';
import { TILE, tileAt, tileColour, type VenueMap } from './venueMap';
import { FIGHT_FUSE_MS } from './fight';
import { LIGHTS, PROPS, TILE, tileAt, tileColour, type PropKind, type VenueMap } from './venueMap';
import type { Agent } from './crowdSim';
// Pure drawing for the floor. Darkness is the mechanic: the venue is painted
@ -35,6 +36,22 @@ const NEON_TINTS = [0xff3d9a, 0x38e0ff, 0xb45cff] as const;
const GLOW_SIZE = 64;
// 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 {
sil: Phaser.GameObjects.Image;
detail: Phaser.GameObjects.Image;
@ -59,8 +76,27 @@ export class FloorView {
private readonly playerLight: Phaser.GameObjects.Image;
private readonly playerImg: 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>();
/** 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 uv = false;
@ -111,6 +147,12 @@ export class FloorView {
}
}
this.buildProps();
this.buildLights();
this.buildYardRain();
this.buildDj();
this.buildScanBeams();
this.maskG = scene.make.graphics({}, false);
this.mask = this.maskG.createGeometryMask();
this.detailC = scene.add.container(0, 0).setDepth(D_DETAIL);
@ -118,6 +160,191 @@ export class FloorView {
this.playerImg = scene.add.image(0, 0, PLAYER_TEX).setDepth(D_MARKER);
this.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 {
@ -150,9 +377,14 @@ export class FloorView {
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;
g.sil.setPosition(x, y);
g.detail.setPosition(x, y);
g.uvDot.setPosition(x + 4, y + 2);
// Empty unless this punter is squaring up, so the common path is a miss.
const sh = this.shove.get(a.patron.id);
const sx = sh ? x + sh.dx : x;
const sy = sh ? y + sh.dy : y;
g.sil.setPosition(sx, sy);
g.detail.setPosition(sx, sy);
g.uvDot.setPosition(sx + 4, sy + 2);
g.detail.setVisible(!this.uv);
// No stamp, no dot — the absence is the tell.
g.uvDot.setVisible(this.uv && a.patron.flags.uvStamped === true);
@ -200,7 +432,79 @@ export class FloorView {
this.marker.setAlpha(0.55 + 0.45 * Math.abs(Math.sin(this.scene.time.now / 260)));
}
/** 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 {
this.shove.clear();
this.fightHalo.destroy();
this.fightPip.destroy();
for (const g of this.agents.values()) this.destroyGfx(g);
this.agents.clear();
this.detailC.clearMask(true);
@ -210,6 +514,16 @@ export class FloorView {
this.dark.destroy();
this.tileImg.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.playerLight.destroy();
this.playerImg.destroy();
@ -239,6 +553,20 @@ export class FloorView {
};
}
/**
* Drop every cached sprite. The scene must call this on reseed: patron ids are
* recycled from p0 by _resetPatronSerial(), so without it the new run's crowd
* inherits the old run's dolls, sway and stamps.
*/
reset(): void {
for (const g of this.agents.values()) this.destroyGfx(g);
this.agents.clear();
this.shove.clear();
this.fightHalo.setVisible(false);
this.fightPip.setVisible(false);
this.marker.setVisible(false);
}
private drop(id: string): void {
const g = this.agents.get(id);
if (!g) return;
@ -299,3 +627,139 @@ export class FloorView {
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

@ -0,0 +1,152 @@
// The bartender shift (docs/SCENARIOS.md, design §6 role ladder). Same split
// as djShift.ts: pure pricing here, tested; the scene owns the overlay, the
// teleport and the taps.
//
// The shape of the fantasy: the other side of the counter. Every order is a
// sobriety read at arm's length — the stage is written on the doll and in the
// spelling — and SERVE / WATER / CUT OFF are all legal, all priced (§4.3).
// Serving someone visibly cooked is the one the licence remembers.
import type { DrunkStage } from '../../data/types';
import { WATER_SOBER_STEP } from './hazards';
export type BarCall = 'serve' | 'water' | 'cutoff';
/** What one served drink adds — a real pour, on top of the bar-anchor drift. */
export const SERVE_DRINK_STEP = 0.06;
/** Chance-per-second an order lands while you hold the taps (~one per 18s). */
export const ORDER_CHANCE_PER_S = 0.055;
/** One patron doesn't re-order inside this window. */
export const ORDER_COOLDOWN_MS = 60_000;
/**
* The RSA line (rules/drunk.ts stages): messy and maggot are the breach.
* 'loose' has visible tells but stays legal that band is where the player
* has to actually decide something, same as the floor cut-off.
*/
export function isRsaBreach(stage: DrunkStage): boolean {
return stage === 'messy' || stage === 'maggot';
}
/** Stable reason so shouldRecordStrike folds a whole bad shift into ONE write-up. */
export const RSA_STRIKE_REASON = 'served alcohol to a visibly intoxicated patron';
/** A breach pour only becomes a strike if somebody official SAW it. */
export const BREACH_SEEN_CHANCE = 0.35;
/** Clock-minutes later the write-up lands: base + up-to spread. */
export const BREACH_SEEN_AFTER: readonly [number, number] = [6, 20];
export interface BarOutcome {
vibe?: number;
aggro?: number;
/** Applied to the patron's intoxication (scene clamps to 0..1). */
intoxDelta: number;
/** An RSA breach pour — may earn the deferred write-up. */
breach: boolean;
/** Done for the night; the crowd walks them out itself. */
sendsHome: boolean;
}
/**
* Price a call against what the patron actually is (§4.3: priced, never
* graded). The breach pour still pays its vibe up front the bribe is
* immediate, the price arrives later.
*/
export function judgeCall(call: BarCall, stage: DrunkStage): BarOutcome {
if (call === 'serve') {
return {
vibe: 1,
intoxDelta: SERVE_DRINK_STEP,
breach: isRsaBreach(stage),
sendsHome: false,
};
}
if (call === 'water') {
const needed = stage === 'loose' || isRsaBreach(stage);
return needed
? { vibe: 1, intoxDelta: -WATER_SOBER_STEP, breach: false, sendsHome: false }
: { aggro: 1, intoxDelta: -WATER_SOBER_STEP, breach: false, sendsHome: false };
}
// cutoff: right on a breach-stage patron, a whole scene on anyone upright.
return isRsaBreach(stage)
? { vibe: 1, aggro: 1, intoxDelta: 0, breach: false, sendsHome: true }
: { vibe: -1, aggro: 2, intoxDelta: 0, breach: false, sendsHome: true };
}
// ---- the pour (SERVE is a hand, not a menu click) ---------------------------
// A sweeping marker, one key to stop it. The line lives near the top because a
// good pour is nearly a mistake.
export type PourVerdict = 'short' | 'clean' | 'overflow';
/** Marker sweep 0→1→0, this long each way. */
export const POUR_SWEEP_MS = 1300;
export const POUR_CLEAN_FROM = 0.68;
export const POUR_OVER_FROM = 0.93;
/** What a heavy hand adds on top of SERVE_DRINK_STEP. */
export const OVERFLOW_EXTRA_INTOX = 0.03;
export function judgePour(fill: number): PourVerdict {
if (fill >= POUR_OVER_FROM) return 'overflow';
if (fill >= POUR_CLEAN_FROM) return 'clean';
return 'short';
}
/**
* Fold the pour into the serve. Short: half a drink, no thanks, a look.
* Overflow: the room loves a heavy pour and the patron wears more of it.
* The RSA breach flag rides the SERVE decision, not the pour a stingy pour
* to a maggot is still a pour to a maggot.
*/
export function pourAdjust(out: BarOutcome, pour: PourVerdict): BarOutcome {
if (pour === 'short') {
const adjusted: BarOutcome = { ...out, aggro: (out.aggro ?? 0) + 1, intoxDelta: out.intoxDelta / 2 };
delete adjusted.vibe;
return adjusted;
}
if (pour === 'overflow') {
return { ...out, vibe: (out.vibe ?? 0) + 1, intoxDelta: out.intoxDelta + OVERFLOW_EXTRA_INTOX };
}
return out;
}
// ---- tabs (the jar of other people's cards) ---------------------------------
/** Chance a served tipsy+ patron opens a tab ("start a tab? legend"). */
export const TAB_OPEN_CHANCE = 0.3;
/** A drink, for the tab total the toast reads out. */
export const TAB_DRINK_PRICE = 14;
export interface Tab {
/** The name printed on the card in the jar. */
name: string;
drinks: number;
}
/** Stages loose+ can open a tab — sober people pay as they go. */
export function canOpenTab(stage: DrunkStage): boolean {
return stage !== 'sober';
}
/**
* The card-return line-up: the right name plus two strangers' cards from the
* jar/room. Order is seeded so the correct answer isn't always slot one.
*/
export function cardLineup(
right: string,
others: readonly string[],
rng: { int(min: number, max: number): number },
): string[] {
const decoys = [...new Set(others.filter((n) => n !== right))];
// 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,7 +9,8 @@ import { isWalkable, isWalkableWorld, tileToWorld, worldToTile } from './venueMa
import type { StallDef, VenueMap, WorldPoint } from './venueMap';
export type Activity =
| 'walking' | 'atBar' | 'dancing' | 'inBooth' | 'inStall' | 'smoking' | 'escorted' | 'leaving';
| 'walking' | 'atBar' | 'dancing' | 'inBooth' | 'inStall' | 'smoking' | 'escorted' | 'leaving'
| 'squaringUp';
export interface Agent {
patron: Patron;
@ -23,6 +24,12 @@ export interface Agent {
handled: boolean; // player has already dealt with this one
gone: boolean; // reached the exit / left the sim; scene should drop the sprite
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 {
@ -158,6 +165,11 @@ export class CrowdSim {
handled: false,
gone: false,
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, {
haunt: 'bar', path: [], bestDist: Infinity, stuckMs: 0, stumbleMs: 0, stumbleAngle: 0,
@ -178,7 +190,16 @@ export class CrowdSim {
for (const a of this.agents) {
if (a.gone) continue;
a.lastBumpMs += ms;
if (a.activity === 'escorted') continue;
if (a.arrivedAtMin === undefined) a.arrivedAtMin = clockMin;
// Both are driven from outside: the player's escort, and the fight director.
if (a.activity === 'escorted' || a.activity === 'squaringUp') continue;
if (
!a.headingHome &&
a.activity !== 'inStall' &&
clockMin >= a.arrivedAtMin + a.stayMinutes
) {
this.headHome(a);
}
if (a.activity === 'walking') this.walk(a, ms);
else this.dwell(a, ms);
}
@ -282,6 +303,34 @@ export class CrowdSim {
}
}
/** Pin an agent for a brewing fight. False if it isn't available (gone/escorted/in a stall). */
pinForFight(agent: Agent): boolean {
if (agent.gone) return false;
if (
agent.activity === 'escorted' || agent.activity === 'leaving' ||
agent.activity === 'inStall' || agent.activity === 'squaringUp'
) return false;
agent.activity = 'squaringUp';
agent.vx = 0;
agent.vy = 0;
// Whatever they were nursing is over; the fight owns the dwell now.
agent.dwellMs = 0;
return true;
}
/** Release back into the crowd; they walk it off. */
releaseFromFight(agent: Agent): void {
// Anything else got hold of them mid-fight (escorted, then gone) — leave it alone.
if (agent.activity !== 'squaringUp') return;
agent.activity = 'walking';
agent.dwellMs = 0;
agent.stallId = undefined;
const plan = this.plans.get(agent);
if (plan) plan.stumbleMs = 0;
this.chooseTarget(agent);
}
find(patronId: string): Agent | undefined {
return this.agents.find((a) => a.patron.id === patronId);
}
@ -398,9 +447,38 @@ export class CrowdSim {
else this.chooseTarget(a); // nowhere to go from here — find somewhere else to be
}
/** 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 {
a.vx = 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);
if (plan.haunt === 'toilet' && plan.stallId !== undefined) {

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