Compare commits

...

37 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
37f9892309 LANE-CONTENT: the guest list, the moral encounters, the inspector, the paperwork
Phase 3 deliverables 1-4. The door now has something to read rather than a
checklist to tick.

The guest list clipboard shares the desk's left panel with Dazza's rules via a
tab, because there is no spare space at 640x360 and the tradeoff is honest:
checking a name costs you a beat of queue time. The mechanic is measured rather
than asserted — at the generator's real claim rates an exact match is a coin
flip, a near match leans legit, and no match leans liar. Owner's-mate patrons
now drop Terry's name, so the trap has a tell that isn't just confidence.

Scripted encounters (four, three a night) arrive ahead of the arrival curve and
speak in beats. Their consequences are real and the game never says which call
was right — no meter, no chime, no narrator.

The inspector reads as a boring punter; admitted, he converts any live breach
from a 3 AM audit finding into an immediate strike. Denied, nothing happens
ever. Verified in play: a night ended LICENCE PULLED with two of three strikes
his.

The incident report sits between the summary and the next shift. Filed accounts
go to pastReports so Phase 4 audits what the player claimed; NightScene no
longer dumps the raw truth there, which had nothing to catch anyone out with.

Also closes my own Phase-1 finding: judge() now reads intoxication and
contraband. Refusing someone who cannot stand up used to score identically to
refusing a clean punter, so the game's most legible signal was worth nothing.
That gap was also what made quietBeer's humane option secretly optimal on both
meters — the one thing design 4.3 forbids.

518 tests (was 413). Played a full 3-night run: all three nights reached 3 AM,
heat carried 1 -> 2 -> 2. The Phase-1 economy findings are all still true and
this lane did not change them — vibe pinned at 97-98 every night and aggro sat
at 0. Details in LANEHANDOVER.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:32:37 +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
144 changed files with 12764 additions and 188 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

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

@ -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. */
@ -51,6 +61,21 @@ export const DRUNK_GREETINGS: readonly string[] = [
"i'm fine i'm fine i'm fine",
];
/**
* The owner's mate (design §3.1 "the trap"). Usually a lie, occasionally
* true, and denying a true one is the worst call at the rope. The tell has to
* be a NAME rather than confidence, because confidence is free and 'terry'
* is the name Dazza begs you about in his `owner-scare` text.
*/
export const OWNER_HINTS: readonly string[] = [
'terry said swing by tonight',
"i drink with the owner. terry. we're mates",
"ring terry if you like. he'll vouch",
'ive known terry since the old venue',
"terry's expecting me, don't make it weird",
'me and terry go back. ask him',
];
export const CLAIM_LINES: readonly string[] = [
"i'm on the list",
'i know the owner',
@ -59,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',
@ -96,6 +135,90 @@ export const lapLine = (short: string, roll: number): string => {
return (DAZZA_LAP_LINES[i] ?? DAZZA_LAP_LINES[0]!).replace('{rule}', short);
};
/**
* Dazza noticing, later, that someone you waved through has ripened. Keyed by
* how far gone they already were at the rope he is describing the room, never
* grading the player.
*/
export const DAZZA_RIPEN_LINES: Record<string, readonly string[]> = {
loose: [
'someone in here has found their second wind and it is not a good one',
'theres a bloke doing the shuffle by the speaker. u know the shuffle',
'one of urs has started telling people about his ute',
],
messy: [
'mate whoever u let in with the eyes is now sitting on the floor. SITTING',
'we have a leaner. by the bar. leaning on people not walls',
'someone just tried to order from the DJ',
],
maggot: [
'the bloke u waved through is in the garden bed. the GARDEN BED',
'im watching security carry someone out and i am asking myself questions',
'whoever that was they are now horizontal and thats on the door',
],
};
export const ripenLine = (stage: string, roll: number): string => {
const pool = DAZZA_RIPEN_LINES[stage] ?? DAZZA_RIPEN_LINES.loose!;
const i = Math.min(pool.length - 1, Math.floor(roll * pool.length));
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',
@ -110,6 +233,19 @@ export const PATDOWN_LINES = {
found: (item: string): string => `AMNESTY BIN: ${item}`,
} as const;
export const REPORT_UI = {
title: 'INCIDENT REPORT',
subtitle: 'management requires an account of the night. be accurate. or dont',
nothingToReport: 'nothing happened worth writing down.\n\nthat is, itself, a kind of night.',
filed: 'REPORT FILED',
done: 'click to knock off',
/** Same weight either way — the game does not grade the paperwork. */
filedFlavour: (embellished: boolean): string =>
embellished
? 'dazza signs it without reading it. the filing cabinet closes.'
: 'dazza signs it without reading it. the filing cabinet closes.',
} as const;
export const SUMMARY_UI = {
title: 'LAST DRINKS',
subtitle: '3:00 AM · the lights come up',

View File

@ -0,0 +1,276 @@
// Scripted-encounter dialogue (design §4.3). These are the most rewritten strings
// in the project and the ones a content pass is most likely to ruin.
//
// The register, in order of importance:
// - Nobody begs. Every one of these people is competent, has a plan, and is
// offering you information rather than asking for mercy.
// - The game never says which call was right. `admitNote`/`denyNote` land on the
// verdict toast, so they report what happened next and stop. No verdict on the
// player, no reassurance either — reassurance is a verdict wearing a smile.
// - Dazza may judge freely. He is a character inside the fiction and he is wrong
// constantly; that is not the game speaking.
//
// `afterMs` is measured from the moment the patron reaches the rope, not from the
// previous beat, so a skipped line cannot compress the ones after it.
export interface EncounterScript {
/** Licence names to draw from — the same person, spelled a few different ways. */
names: readonly string[];
beats: readonly { readonly line: string; readonly afterMs: number }[];
admitNote?: string;
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> = {
// She is not asking to party. She is asking for ninety seconds and a jacket,
// and she already knows the stamp rule better than you do.
grabHerMate: {
names: ['Bec Doyle', 'Steph Vella', 'Aleisha Marsh'],
beats: [
{ line: 'i was inside ten minutes ago. no stamp, i know.', afterMs: 0 },
{ line: "my mate's on a date in there thats gone bad. shes done the toilet text twice.", afterMs: 2200 },
{ line: "ninety seconds. i get her and her jacket and we're both gone.", afterMs: 4600 },
],
// Reports the stamp, not the room: nothing here can see the clicker count,
// and a note that asserts capacity is simply false half the time.
admitNote: 'Back in without a stamp. Nothing on the door says she was ever inside.',
admitDazza: 'the count and the clicker dont agree mate. i count from the office. i COUNT',
denyNote: 'She waits under the awning across the road, then walks toward the taxi rank.',
},
// The dumped bloke. The tell is the over-explaining, not the swaying. He says
// the title of the game and does not notice he said it.
quietBeer: {
names: ['Trent Petersen', 'Dylan Marsh', 'Cam Duffy'],
beats: [
{ line: 'evening. just the one, then im off.', afterMs: 0 },
{ line: 'ive had two at the RSL. before you ask.', afterMs: 2100 },
{ line: 'no, no one with me. not tonight.', afterMs: 4400 },
{ line: 'one beer. thats the whole plan.', afterMs: 6600 },
],
admitNote: 'Loose at the rope. Where it goes from here is between him and the bar.',
admitDazza: 'theres a bloke at the end of the bar not drinking his drink. keep half an eye',
denyNote: 'He says no dramas. He is at the kebab shop ten minutes later.',
},
// Seven years, and he has brought a hip flask to shout the bar staff with. He
// tells you about it, which is the entire trap — you cannot un-know it.
lastNight: {
names: ['Warren Kowalski', 'Raymond Vella', 'Gary Duffy'],
beats: [
{ line: 'last one, mate. flights booked. perth on thursday.', afterMs: 0 },
{ line: 'seven years ive been coming here. you knocked me back once, back in nineteen. fair enough too.', afterMs: 2400 },
{ line: 'i brought this for sue behind the bar. she reckons she wants a proper send off.', afterMs: 4800 },
],
admitNote: 'The flask went in with him.',
denyNote: 'He waves it off. In the car park he opens the flask himself.',
},
// Off a twelve-hour shift in a branded work polo, which the dress code has had
// an opinion about since 10:30. He knows. He says so before you can.
nightShift: {
names: ['Jared Karim', 'Tui Taufa', 'Sam Nguyen'],
beats: [
{ line: 'sorry. straight off shift. i know how it looks.', afterMs: 0 },
{ line: 'twelve hours. the polos got the servo on it, cant do much about that.', afterMs: 2300 },
{ line: 'one beer, near the door, i wont go near the dancefloor. i dont have it in me.', afterMs: 4700 },
],
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

@ -4,8 +4,11 @@ import { ParadeScene } from './scenes/shared/ParadeScene';
import { NightScene } from './scenes/door/NightScene';
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
@ -23,15 +26,20 @@ 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,
IncidentReportScene,
new FloorDemoScene('Floor'),
BootScene,
ParadeScene,
new FloorDemoScene('FloorDemo'),
JuiceDemoScene,
MixDeskScene,
],
});
@ -45,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]);
};
@ -70,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));
}

717
src/rules/encounters.ts Normal file
View File

@ -0,0 +1,717 @@
import type { RngStream } from '../core/SeededRNG';
import { drunkStage } from './drunk';
import { CONTRABAND } from '../data/contraband';
import { OUTFIT_VOCAB } from '../data/outfits';
import { ENCOUNTER_SCRIPTS, type EncounterScript } from '../data/strings/encounters';
import type { Archetype, DrunkStage, HeatStrike, Patron } from '../data/types';
// Scripted encounters — design §4.3, the reason this is a game about people and
// not a spot-the-difference puzzle. Two or three of these are injected into the
// generated crowd per night.
//
// The rule the rest of this file exists to serve: doing the human thing costs
// Vibe or risks Heat, and NOTHING anywhere tells the player which call was right.
// There is deliberately no score, no flag, no tally of humane verdicts — if a
// later pass adds one, it has removed the mechanic rather than completed it.
//
// The corollary is just as load-bearing: the humane call must not be secretly
// 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'
| 'eighteenToday' | 'kaydensMate' | 'bigNightOut' | 'twoOfThem'
| 'offDutyCop' | 'celebrity' | 'sovereign' | 'deliveryRider'
| 'promoter' | 'neighbour' | 'hensParty' | 'twins' | 'exDoorman';
export interface EncounterBeat {
line: string;
/**
* Delay from the moment the patron REACHES THE ROPE absolute, not a gap
* since the previous beat. Read as a gap, a skipped or slow line compresses
* everything after it and the exchange stops breathing.
*/
afterMs: number;
}
export interface EncounterOutcome {
vibeDelta?: number;
aggroDelta?: number;
heatStrike?: HeatStrike;
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 {
id: EncounterId;
/** clock-minute window this can be scheduled into */
window: [number, number];
archetype: Archetype;
/** mutates a freshly generated patron into this specific person */
dress(patron: Patron, rng: RngStream): void;
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. */
export const ENCOUNTER_SPACING_MIN = 40;
// ---- intoxication bands ------------------------------------------------------
// The dumped bloke has to read as 'loose' at the rope — visible tells, not a
// stumbling mess. Writing 0.55 here would silently become the wrong person the
// day someone retunes drunk.ts, so the band is derived FROM drunkStage instead.
// Scan step is fine enough to find each threshold to the nearest thousandth.
const STAGE_SCAN_STEP = 0.0005;
const BANDS = new Map<DrunkStage, readonly [number, number]>();
function stageBand(stage: DrunkStage): readonly [number, number] {
const cached = BANDS.get(stage);
if (cached) return cached;
let lo = Number.POSITIVE_INFINITY;
let hi = Number.NEGATIVE_INFINITY;
for (let i = 0; i * STAGE_SCAN_STEP <= 1; i++) {
const v = i * STAGE_SCAN_STEP;
if (drunkStage(v) !== stage) continue;
lo = Math.min(lo, v);
hi = Math.max(hi, v);
}
if (!Number.isFinite(lo)) throw new Error(`encounters: no intoxication maps to '${stage}'`);
const band = [lo, hi] as const;
BANDS.set(stage, band);
return band;
}
/**
* A value comfortably inside `stage`'s band. Inset from both edges because the
* floor sim drifts intoxication upward: a patron parked on a threshold restages
* on the first tick, and the script's whole point is the stage they arrive in.
*/
function intoxicationIn(stage: DrunkStage, rng: RngStream): number {
const [lo, hi] = stageBand(stage);
const inset = (hi - lo) * 0.15;
return lo + inset + rng.next() * (hi - lo - 2 * inset);
}
// ---- shared dressing ---------------------------------------------------------
const FLASK = CONTRABAND.find((c) => c.id === 'flask');
if (!FLASK) throw new Error("encounters: contraband has no 'flask' — lastNight has nothing to carry");
const POLO = OUTFIT_VOCAB.top.find((d) => d.type === 'polo' && d.canLogo === true);
if (!POLO) {
throw new Error("encounters: outfit vocab has no logo-capable 'polo' — nightShift is the work shirt");
}
/**
* Give a scripted person a boring, valid licence.
*
* `punter` carries a 2% fake-ID roll, and a scripted moral call that turns into
* an ID puzzle is neither. dress() has no nightDate to recompute an expiry from,
* so the existing one is pushed five years past whatever the generator produced
* which clears the 'expired' tell whichever side of tonight it started on.
*/
function settleId(patron: Patron): void {
delete patron.idCard.fake;
patron.idCard.photoSeed = patron.dollSeed;
patron.idCard.expiry = plusFiveYears(patron.idCard.expiry);
}
const plusFiveYears = (iso: string): string => {
const [y = '2026', m = '01', d = '01'] = iso.slice(0, 10).split('-');
// Clamped to the 28th so a 29 February expiry cannot be pushed onto a date that
// does not exist, which parses as NaN and quietly reads as "never expires".
const day = Math.min(Number(d), 28);
return `${Number(y) + 5}-${m}-${String(day).padStart(2, '0')}`;
};
/** Everything every scripted patron needs. dollSeed is untouched on purpose: the
* paper doll should still be a random stranger, or the player learns the faces. */
function baseDress(patron: Patron, rng: RngStream, script: EncounterScript): void {
settleId(patron);
patron.idCard.name = rng.pick(script.names);
// Same reasoning as clearContraband. The guest-list clipboard fires a
// "'m on the list" step-up line off claimsGuestList, which would talk over
// the scripted beats — and "i was inside ten minutes ago" plus a guest-list
// claim is two different people standing in one pair of shoes.
delete patron.flags.claimsGuestList;
delete patron.flags.onGuestList;
}
/** Scripted dilemmas are about the one thing they are about. A stray baggie from
* the archetype roll turns the girl at the rope into a pat-down instead. */
function clearContraband(patron: Patron): void {
delete patron.flags.contraband;
}
const script = (id: EncounterId): EncounterScript => {
const s = ENCOUNTER_SCRIPTS[id];
if (!s) throw new Error(`encounters: no dialogue script for '${id}'`);
return s;
};
const beatsOf = (id: EncounterId): EncounterBeat[] =>
script(id).beats.map((b) => ({ line: b.line, afterMs: b.afterMs }));
// ---- the encounters ----------------------------------------------------------
const ENCOUNTERS_BASE: readonly ScriptedEncounter[] = [
{
// Early: he is doing the rounds while he can still stand at a bar and talk.
id: 'lastNight',
window: [30, 150],
archetype: 'regular',
dress(patron, rng) {
const s = script('lastNight');
baseDress(patron, rng, s);
// He TELLS you about the flask. That is the trap — a hip flask you were
// told about is a licence problem you chose, not one you missed.
patron.flags.contraband = [FLASK.id];
patron.intoxication = intoxicationIn('tipsy', rng);
// Deliberately no timesDeniedBefore: judge() prices a grudged regular at
// -5 Vibe on deny, which would tilt this straight back toward admitting.
// The knock-back in his second beat happened years before this run.
},
beats: beatsOf('lastNight'),
onAdmit: {
vibeDelta: 2,
heatStrike: { reason: 'BYO spirits admitted — hip flask carried in on the door', deferred: true },
note: script('lastNight').admitNote,
},
onDeny: { note: script('lastNight').denyNote },
},
{
id: 'quietBeer',
window: [90, 210],
archetype: 'quietMessy',
dress(patron, rng) {
const s = script('quietBeer');
baseDress(patron, rng, s);
clearContraband(patron);
// 'loose' exactly: the tells are visible, so the player cannot claim later
// that they could not tell. quietMessy's low tolerance does the rest.
patron.intoxication = intoxicationIn('loose', rng);
},
beats: beatsOf('quietBeer'),
onAdmit: {
// The real cost is not this number. It is a loose quietMessy on the floor
// with a reason to keep going, which the floor sim charges for later.
vibeDelta: -1,
dazzaText: script('quietBeer').admitDazza,
note: script('quietBeer').admitNote,
},
onDeny: { note: script('quietBeer').denyNote },
},
{
// 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, 195],
archetype: 'punter',
dress(patron, rng) {
const s = script('nightShift');
baseDress(patron, rng, s);
clearContraband(patron);
patron.intoxication = intoxicationIn('sober', rng);
// Mutated in place rather than pushed, so the outfit keeps exactly one
// layer per slot. The generator always fills top; the guard is for a
// hand-built patron in a test.
const top = patron.outfit.find((l) => l.slot === 'top');
if (top) {
top.type = POLO.type;
top.colour = rng.pick(POLO.colours);
top.logo = true;
}
},
beats: beatsOf('nightShift'),
onAdmit: {
// No numbers here on purpose. He is a live noLogos breach, so admitting
// him is already priced by judge() — Dazza does a lap and finds him. A
// bespoke penalty on top would charge the player twice for one call.
note: script('nightShift').admitNote,
},
onDeny: { note: script('nightShift').denyNote },
},
{
// Late, when the room is at the number and the stamp actually matters.
id: 'grabHerMate',
window: [210, 330],
archetype: 'punter',
dress(patron, rng) {
const s = script('grabHerMate');
baseDress(patron, rng, s);
clearContraband(patron);
// The whole dilemma in one flag. She stepped out without one and says so.
patron.flags.uvStamped = false;
patron.intoxication = intoxicationIn('tipsy', rng);
},
beats: beatsOf('grabHerMate'),
onAdmit: {
vibeDelta: -2,
heatStrike: {
// Worded off what THIS module knows — she has no stamp and the encounter
// set that flag itself. It deliberately does NOT mention capacity: the
// encounter cannot see insideCount, so a capacity-worded reason is a lie
// whenever the room is half empty, and a duplicate of judge()'s own
// capacity strike whenever it isn't. One admit, one strike, true reason.
reason: 'unstamped re-entry admitted — nothing on the door to say she was inside',
deferred: true,
},
dazzaText: script('grabHerMate').admitDazza,
note: script('grabHerMate').admitNote,
},
// 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`);
}
}
// 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`);
}
}
}
assertWindowsSane();
export function encounterById(id: EncounterId): ScriptedEncounter | undefined {
return ENCOUNTERS.find((e) => e.id === id);
}
/**
* Pick `count` distinct encounters and place each at a random minute inside its
* own window, at least ENCOUNTER_SPACING_MIN apart and sorted by time. Asking for
* more than exist returns all of them rather than throwing a night that wants
* five encounters should be short, not broken.
*/
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 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);
}
const out: Array<{ atMin: number; id: EncounterId }> = [];
let earliest = Number.NEGATIVE_INFINITY;
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.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;
}

334
src/rules/guestList.ts Normal file
View File

@ -0,0 +1,334 @@
// The guest-list clipboard (design §3.1). A handwritten list, and a queue of
// people claiming to be on it.
//
// The mechanic is an inversion: a name that ALMOST matches is usually legit —
// the doorman's handwriting is rubbish and half of them gave a nickname — while
// a name that matches the clipboard EXACTLY, said with confidence, is as often
// as not lifted off someone who walked in an hour ago.
//
// Nothing here is ever certain, on purpose. Every match class can be produced by
// an honest patron AND by a liar; the numbers below only move the player from a
// coin flip to better than chance. Do not "fix" that by making a class a
// guaranteed tell — a guaranteed tell turns the clipboard into a lookup table
// and there is no decision left to make.
import type { RngStream } from '../core/SeededRNG';
export interface GuestListEntry {
name: string;
plusOnes: number;
}
export interface GuestList {
entries: GuestListEntry[];
}
export type NameMatch = 'exact' | 'near' | 'none';
export interface NameLookup {
match: NameMatch;
entry?: GuestListEntry;
/** Edits to the CLOSEST entry, whatever the class. Infinity on an empty list. */
distance: number;
}
export const GUEST_LIST_TUNING = {
minEntries: 8,
maxEntries: 14,
// Two, not three. Every near variant this module produces is at most two edits
// from its entry (a transposition is the expensive one — plain Levenshtein
// charges 2 for it), so 2 is the tightest threshold that still catches every
// plausible misreading. Three would only admit corruptions nothing generates,
// while making the separation constraint below harder to satisfy.
nearThreshold: 2,
// How far apart two entries on one clipboard must be. It is 2 * nearThreshold,
// and the factor of two is load-bearing rather than cautious: a near variant
// sits up to nearThreshold from its source, so by the triangle inequality only
// a gap of MORE than twice the threshold guarantees that variant is still
// outside the threshold of every other entry. At a gap of merely
// nearThreshold + 1 a two-edit variant can land one edit from a neighbour, and
// lookupName then hands the door UI the wrong row — the player highlights a
// stranger and the +1 count they read belongs to someone else.
entrySeparation: 4,
/** Mostly parties of one. The +1 that arrives as +6 is the door's problem. */
plusOneWeights: [
[0, 62],
[1, 24],
[2, 9],
[3, 5],
] as ReadonlyArray<readonly [number, number]>,
// Claim mix for a patron who really is on the list. They mostly give a name
// the clipboard half-agrees with; sometimes it is written down perfectly; and
// sometimes the doorman butchered it past recognition and the clipboard is no
// help to an honest person at all.
genuineExact: 0.3,
genuineNear: 0.58,
genuineGarbled: 0.12,
// Claim mix for a liar. Mostly a name that simply is not there. Sometimes they
// overheard a real one and got it slightly wrong; sometimes they got it dead
// right, which is the entire mechanic.
liarExact: 0.06,
liarNear: 0.06,
liarInvented: 0.88,
// Share of guest-list CLAIMANTS who are telling the truth, derived from the
// spawn weights and chances in data/archetypes.ts (influencers and regulars
// are the only archetypes that are ever really on the list; punters, finance
// bros and the owner's mate only ever claim). Liars outnumber listers about
// five to one, which is why the mixes above are lopsided — they exist to drag
// the posteriors back to something a player can read.
//
// Posteriors at this prior, asserted in tests/guestList.test.ts:
// P(genuine | exact) ≈ 0.50 — a coin flip, and it must stay one
// P(genuine | near) ≈ 0.66 — leans legit
// P(genuine | none) ≈ 0.03 — leans liar, but is not proof
// Re-derive all four numbers if archetypes.ts changes its guest-list chances.
claimantGenuineRate: 0.168,
/** Random draws before falling back to a deterministic sweep of the pools. */
drawAttempts: 24,
/** Corruption rolls before falling back to a guaranteed one-edit variant. */
variantAttempts: 12,
/** Edits applied to garble a name past recognition. */
garbleEdits: 4,
/** Chance a near variant is a transposition rather than one or two cheap edits. */
transposeChance: 0.3,
/** Chance a cheap-edit variant gets a second edit on top. */
secondEditChance: 0.35,
};
// Deliberately duplicated from src/patrons/generator.ts, which keeps its pools
// module-private and is frozen to this lane. The failure mode of duplication is
// the two lists drifting apart, and a guest list drawn from names the crowd
// never uses reads as obviously-not-the-crowd — a content bug, not a crash. If
// generator.ts ever exports these, delete these copies and import them.
const FIRST = ['Shazza', 'Dazza', 'Bazza', 'Kylie', 'Jai', 'Tahlia', 'Lachlan', 'Brooke', 'Cooper', 'Mia', 'Ngaio', 'Con', 'Duc', 'Sofia', 'Marco', 'Aroha', 'Blake', 'Chantelle', 'Rhys', 'Imogen'] as const;
const LAST = ['Nguyen', 'Smith', 'Papadopoulos', 'Chen', 'OBrien', 'Kowalski', 'Singh', 'Taufa', 'Romano', 'Petersen', 'Doyle', 'Vella', 'Karim', 'Marsh', 'Duffy'] as const;
const POOL_SIZE = FIRST.length * LAST.length;
// Corruptions only ever land on lowercase letters, which in these pools means
// every capital survives. A variant is meant to read as a name someone spelled
// wrong; touching the initials gives you "oRmano" and " arim", which read as a
// broken string instead — the player stops thinking about handwriting and starts
// thinking about the program.
const EDITABLE = /[a-z]/;
/** a→e→i→o→u→a. Uppercase is absent for the same reason. */
const VOWEL_RING: Record<string, string> = { a: 'e', e: 'i', i: 'o', o: 'u', u: 'a' };
const normalise = (s: string): string => s.replace(/\s+/g, ' ').trim().toLowerCase();
export function editDistance(a: string, b: string): number {
const s = normalise(a);
const t = normalise(b);
if (s === t) return 0;
if (s.length === 0) return t.length;
if (t.length === 0) return s.length;
// Two rows rather than the full matrix: this runs a few hundred times per
// claimant, because every variant is verified against every entry.
let prev = Array.from({ length: t.length + 1 }, (_, i) => i);
let curr = new Array<number>(t.length + 1).fill(0);
for (let i = 1; i <= s.length; i++) {
curr[0] = i;
const sc = s[i - 1];
for (let j = 1; j <= t.length; j++) {
const cost = sc === t[j - 1] ? 0 : 1;
curr[j] = Math.min(prev[j]! + 1, curr[j - 1]! + 1, prev[j - 1]! + cost);
}
[prev, curr] = [curr, prev];
}
return prev[t.length]!;
}
export function lookupName(claimed: string, list: GuestList): NameLookup {
let closest: GuestListEntry | undefined;
let distance = Number.POSITIVE_INFINITY;
for (const entry of list.entries) {
const d = editDistance(claimed, entry.name);
if (d < distance) {
distance = d;
closest = entry;
if (d === 0) break;
}
}
if (closest === undefined) return { match: 'none', distance };
const match: NameMatch =
distance === 0 ? 'exact' : distance <= GUEST_LIST_TUNING.nearThreshold ? 'near' : 'none';
// A 'none' carries no entry even though one was found. `distance` still
// reports how close it got, but handing back the row would invite the door UI
// to highlight a name the patron has nothing to do with — and any caller doing
// `if (result.entry)` would be admitting strangers.
return match === 'none' ? { match, distance } : { match, entry: closest, distance };
}
const poolName = (rng: RngStream): string => `${rng.pick(FIRST)} ${rng.pick(LAST)}`;
/**
* True iff `name` is more than `minDistance` edits from every entry.
* At `nearThreshold` that means "reads as a miss"; at `entrySeparation` it means
* "safe to add to the clipboard". Infinity on an empty list, so both hold.
*/
const isSeparated = (name: string, list: GuestList, minDistance: number): boolean =>
lookupName(name, list).distance > minDistance;
// A pool name the clipboard has no opinion about. Random draws first so the mix
// stays natural, then an exhaustive sweep so this always terminates: the pools
// hold 300 combinations and a list holds at most 14, so the sweep only comes up
// empty for a list far longer than this game builds.
function drawSeparatedName(
rng: RngStream,
list: GuestList,
minDistance: number,
): string | undefined {
for (let i = 0; i < GUEST_LIST_TUNING.drawAttempts; i++) {
const name = poolName(rng);
if (isSeparated(name, list, minDistance)) return name;
}
const start = rng.int(0, POOL_SIZE - 1);
for (let k = 0; k < POOL_SIZE; k++) {
const idx = (start + k) % POOL_SIZE;
const name = `${FIRST[idx % FIRST.length]!} ${LAST[Math.floor(idx / FIRST.length)]!}`;
if (isSeparated(name, list, minDistance)) return name;
}
return undefined;
}
export function generateGuestList(rng: RngStream, size: number): GuestList {
const T = GUEST_LIST_TUNING;
const asked = Number.isFinite(size) ? Math.round(size) : T.minEntries;
const wanted = Math.max(T.minEntries, Math.min(T.maxEntries, asked));
const entries: GuestListEntry[] = [];
for (let i = 0; i < wanted; i++) {
// Entries are kept entrySeparation apart so a near variant can only ever
// point at one of them. Two Dazzas a couple of edits apart would make the
// clipboard ambiguous in a way the player cannot reason about — and the
// pools genuinely contain such pairs (Bazza/Dazza), which is why this is
// enforced per list rather than assumed of the pools.
const name = drawSeparatedName(rng, { entries }, T.entrySeparation);
// A short list is a wobble; a hang is a crash. See drawSeparatedName.
if (name === undefined) break;
entries.push({ name, plusOnes: rng.weighted(T.plusOneWeights) });
}
return { entries };
}
function editableIndices(s: string): number[] {
const out: number[] = [];
for (let i = 0; i < s.length; i++) if (EDITABLE.test(s[i]!)) out.push(i);
return out;
}
/**
* One edit: a dropped letter, a doubled letter, or a vowel gone sideways.
* The three kinds are not equally likely a consonant has no vowel-ring entry,
* so kind 2 falls back to doubling and consonants double twice as often as they
* drop. That is a deliberate lean: a doubled consonant ("Kowalskki") reads as
* handwriting, a dropped one ("Kowaski") reads more like a different name.
*/
function oneEdit(rng: RngStream, s: string): string {
const at = editableIndices(s);
// Two letters minimum, so a name can never be edited down to nothing.
if (at.length < 2) return s;
const i = rng.pick(at);
const ch = s[i]!;
const kind = rng.int(0, 2);
if (kind === 0) return s.slice(0, i) + s.slice(i + 1);
if (kind === 1) return s.slice(0, i) + ch + s.slice(i);
const swapped = VOWEL_RING[ch];
return swapped === undefined
? s.slice(0, i) + ch + s.slice(i)
: s.slice(0, i) + swapped + s.slice(i + 1);
}
/** Two adjacent letters the wrong way round — two edits under plain Levenshtein. */
function transpose(rng: RngStream, s: string): string {
const at = editableIndices(s).filter((i) => EDITABLE.test(s[i + 1] ?? '') && s[i] !== s[i + 1]);
if (at.length === 0) return s;
const i = rng.pick(at);
return s.slice(0, i) + s[i + 1]! + s[i]! + s.slice(i + 2);
}
function corrupt(rng: RngStream, name: string): string {
// A transposition already spends the whole two-edit budget, so it never gets
// stacked with anything. Two edits on top of it would be the garbled branch.
if (rng.chance(GUEST_LIST_TUNING.transposeChance)) return transpose(rng, name);
const once = oneEdit(rng, name);
return rng.chance(GUEST_LIST_TUNING.secondEditChance) ? oneEdit(rng, once) : once;
}
/** Doubling a letter is exactly one insertion, always. The safety net. */
const doubledLetter = (name: string): string =>
name.length > 1 ? `${name.slice(0, 2)}${name.slice(1)}` : `${name}${name}`;
function nearVariant(rng: RngStream, name: string, list: GuestList): string {
for (let i = 0; i < GUEST_LIST_TUNING.variantAttempts; i++) {
const candidate = corrupt(rng, name);
// Two cheap edits can cancel each other out (a drop and a double of the
// same letter), and a transposition on a short name can land somewhere
// unexpected. Unverified, roughly 1% of corruptions come back distance 0 —
// small, but it lands entirely in 'exact', the rarest and most load-bearing
// class. The class is what the caller was promised, so it is verified.
if (lookupName(candidate, list).match === 'near') return candidate;
}
return doubledLetter(name);
}
// The other half of "bad handwriting": the name went down so badly that the
// clipboard is no use to someone who is genuinely on it. This is the honest
// person the system fails, and the game never tells the player it happened
// (§4.3) — from the door it is indistinguishable from a chancer.
function garbled(rng: RngStream, name: string, list: GuestList): string {
const T = GUEST_LIST_TUNING;
for (let i = 0; i < T.variantAttempts; i++) {
let candidate = name;
for (let e = 0; e < T.garbleEdits; e++) candidate = oneEdit(rng, candidate);
// Only "reads as a miss" is required here, not clipboard-entry separation:
// this name is never going on the list, it just has to classify as 'none'.
// Verified rather than assumed — four edits still land inside the threshold
// about a quarter of the time, and an unverified garble would quietly leak
// into 'near' and drag the posteriors with it.
if (isSeparated(candidate, list, T.nearThreshold)) return candidate;
}
// Failing that, they gave the name of the mate who actually made the booking.
return drawSeparatedName(rng, list, T.nearThreshold) ?? doubledLetter(name);
}
/**
* The name a claiming patron gives. `genuine` is the truth `flags.onGuestList`
* at the call site and is never visible to the player, only its consequences.
*/
export function assignListedName(rng: RngStream, list: GuestList, genuine: boolean): string {
const T = GUEST_LIST_TUNING;
// An empty clipboard: nothing to be on and nothing to lift, so everyone reads
// as a chancer. Guarded because rng.pick throws on an empty array.
if (list.entries.length === 0) {
return drawSeparatedName(rng, list, T.nearThreshold) ?? poolName(rng);
}
const entry = rng.pick(list.entries);
const roll = rng.next();
if (genuine) {
if (roll < T.genuineExact) return entry.name;
if (roll < T.genuineExact + T.genuineNear) return nearVariant(rng, entry.name, list);
return garbled(rng, entry.name, list);
}
if (roll < T.liarExact) return entry.name;
if (roll < T.liarExact + T.liarNear) return nearVariant(rng, entry.name, list);
return drawSeparatedName(rng, list, T.nearThreshold) ?? garbled(rng, entry.name, list);
}

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]);

356
src/rules/incidentReport.ts Normal file
View File

@ -0,0 +1,356 @@
import type { IncidentRecord } from '../data/types';
import type { RngStream } from '../core/SeededRNG';
// The Incident Report (design §3.3). At last drinks the venue asks the player to
// write up the night, and the player may lie. This module builds that form and
// encodes what was filed. The cross-night audit that CATCHES the lies is Phase 4;
// everything here exists to leave that audit something it can read.
//
// Nothing in this file scores anything. There is no honesty meter, no chime for
// telling the truth, and no line of prose that tells the player they did well or
// badly (§4.3). The lie is cheap tonight and that is the whole joke.
//
// ---- STORAGE ENCODING — read before touching fileReport ----
// IncidentRecord is a frozen contract type {clockMin, kind, patronId?, detail},
// so a filed row carries its machine payload inside `detail`:
//
// detail = `${account}` + REPORT_MARKER + JSON.stringify(FiledPayload)
//
// The account the player chose comes FIRST and verbatim, so a debug dump of
// pastReports still reads like a report. Everything Phase 4 needs — which option
// was picked, whether it was true, and the truth-log line the account replaced —
// is JSON after the marker. The marker is newline-prefixed and no authored
// account contains a newline, so it cannot collide with prose.
//
// Parse with parseFiledAccount(). Do not hand-roll a regex over this: it returns
// undefined for a raw truth-log row, which matters because GameState.pastReports
// already holds unfiled NightState.incidents rows from Phase 2.
export interface ReportOption {
id: string;
/** the written account, first person, bouncer-report register */
text: string;
truthful: boolean;
}
export interface ReportQuestion {
incident: IncidentRecord;
prompt: string;
options: ReportOption[];
}
export interface FiledAnswer {
incident: IncidentRecord;
chosen: ReportOption;
}
/** What parseFiledAccount recovers from a filed row. */
export interface FiledAccount {
/** the prose the player filed */
account: string;
optionId: string;
/** false = the player filed a flattering version of this incident */
truthful: boolean;
/** the incident's own `detail` from the truth log, before the account replaced it */
truthLog: string;
}
export const REPORT_TUNING = {
/** Three questions is twenty seconds. A fourth turns the bit into homework. */
maxQuestions: 3,
// How worth-asking-about each incident kind is. Kinds are open strings written
// by three different scenes (DoorScene verdicts + 'kayden', NightScene
// 'deferred', FloorDemoScene's five), so this is a lookup, not a union.
//
// Zero means "never ask": a clean admit is not a story, and a night is mostly
// clean admits. Ties are broken by an rng jitter below 1, so a weight is a
// 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
cutOff: 5,
stallBusted: 5,
patDown: 4, // both door and floor log this kind; one bank covers both
deny: 3,
sobrietyTest: 2,
admit: 0,
wait: 0,
} as Record<string, number>,
/**
* Weight for a kind nobody listed. Deliberately askable: a lane that adds an
* incident kind should find it turning up in the form, not silently missing.
*/
defaultInterest: 2,
};
/** Separates the filed account from its machine payload inside `detail`. */
export const REPORT_MARKER = '\n[report:v1]';
interface FiledPayload {
optionId: string;
truthful: boolean;
truthLog: string;
}
interface AccountBank {
prompt: string;
options: readonly ReportOption[];
}
// The register: clipped, passive where a real report hedges, faintly defensive.
// The joke is SPECIFICITY vs MUSH — the truthful option names what actually
// happened and who did it; the flattering ones are plausible deniability, the
// account a tired adult would actually write and could actually defend, never a
// cartoon denial that nothing occurred (a lie you can't say out loud isn't a
// choice, it's a button).
//
// §4.3: no account may pronounce a VERDICT on the player's night. "It was
// wrong", "they had a point", "I had decided before they finished" all tell the
// player which choice was right, which is the one thing this game does not do.
// A truthful account states the fact and stops. The reader supplies the wince.
const BANKS: Record<string, AccountBank> = {
deferred: {
prompt: 'Flagged on the nightly audit. Your account?',
options: [
{ id: 'deferred.true', text: 'I let them in. That was my call at the desk and my name is on the sheet.', truthful: true },
{ id: 'deferred.soft1', text: 'Patron presented as compliant at the point of entry. Lighting at the rope is what it is.', truthful: false },
{ id: 'deferred.soft2', text: 'Entry was authorised on the information available at the desk at that time.', truthful: false },
],
},
ejection: {
prompt: 'A patron was removed from the premises. Your account?',
options: [
{ id: 'ejection.true', text: 'I walked them out. They did not want to go and I did not give them the option.', truthful: true },
{ id: 'ejection.soft1', text: 'Patron elected to leave and was accompanied to the exit as a courtesy.', truthful: false },
{ id: 'ejection.soft2', text: 'An escort was offered and accepted. No more than a guiding hand was required.', truthful: false },
],
},
maggotUnhandled: {
prompt: 'A patron was left a while in a poor state. Your account?',
options: [
{ id: 'maggot.true', text: 'I saw them and I was on the door. Nobody got to them for a good while.', truthful: true },
{ id: 'maggot.soft1', text: 'Patron was monitored at intervals from the floor. Water was made available.', truthful: false },
{ id: 'maggot.soft2', text: 'Patron was seated and stable. No intervention was assessed as necessary at that point.', truthful: false },
],
},
jointPocketed: {
prompt: 'Contraband was located in the smoking area. Where is it now?',
options: [
{ id: 'jointP.true', text: 'On me. I took possession and I still have possession. That is the account.', truthful: true },
{ id: 'jointP.soft1', text: 'Item was destroyed on the spot in view of the patron.', truthful: false },
{ id: 'jointP.soft2', text: 'Item was placed in the amnesty bin per procedure. The bin was emptied by close.', truthful: false },
],
},
jointBinned: {
prompt: 'Contraband was located in the smoking area. Your account?',
options: [
{ id: 'jointB.true', text: 'Confiscated and binned in front of them. The yard went quiet for a bit.', truthful: true },
{ id: 'jointB.soft1', text: 'Patron surrendered the item voluntarily and thanked security for the chat.', truthful: false },
{ id: 'jointB.soft2', text: 'Item was assessed as herbal cigarettes. Binned as general litter.', truthful: false },
],
},
jointIgnored: {
prompt: 'A smell was reported near the smoking area. Your account?',
options: [
{ id: 'jointI.true', text: 'I smelled it, I saw it, and I decided the fence line was more interesting.', truthful: true },
{ id: 'jointI.soft1', text: 'Area was patrolled at the time stated. Nothing of note was observed.', truthful: false },
{ id: 'jointI.soft2', text: 'Smell was traced to the kebab shop exhaust across the road.', truthful: false },
],
},
kayden: {
prompt: 'A ruling was made at the rope while you were inside. Your account?',
options: [
{ id: 'kayden.true', text: 'I was on the floor. Kayden made that call and I heard about it after.', truthful: true },
{ id: 'kayden.soft1', text: 'Door was covered under my direction while I attended a matter inside.', truthful: false },
{ id: 'kayden.soft2', text: 'Ruling was consistent with the brief given to the second on the door.', truthful: false },
],
},
cutOff: {
prompt: 'A patron was refused further service. Your account?',
options: [
{ id: 'cutOff.true', text: 'I cut them off. They were filthy about it and they said so at length.', truthful: true },
{ id: 'cutOff.soft1', text: 'Service was concluded in line with RSA. Patron was cooperative throughout.', truthful: false },
{ id: 'cutOff.soft2', text: 'A water was provided and the matter was resolved at the bar without escalation.', truthful: false },
],
},
stallBusted: {
prompt: 'Two patrons were found in one cubicle. Your account?',
options: [
{ id: 'stall.true', text: 'I banged on the door until it opened. Both were spoken to and both were embarrassed.', truthful: true },
{ id: 'stall.soft1', text: 'Cubicle was checked as part of a routine sweep. Occupants dispersed on request.', truthful: false },
{ id: 'stall.soft2', text: 'One patron was assisting another who was unwell. Nothing further was observed.', truthful: false },
],
},
patDown: {
prompt: 'A patron was searched. Your account?',
options: [
{ id: 'patDown.true', text: 'I searched them on a hunch and the whole queue watched me do it.', truthful: true },
{ id: 'patDown.soft1', text: 'Search was conducted with consent as part of a routine entry check.', truthful: false },
{ id: 'patDown.soft2', text: 'Pockets and bag were checked in line with venue policy. Patron was thanked.', truthful: false },
],
},
deny: {
prompt: 'Entry was refused. Your account?',
options: [
{ id: 'deny.true', text: 'I knocked them back for the reason I had at the time. They copped it at the rope.', truthful: true },
{ id: 'deny.soft1', text: 'Entry was declined under the dress code as advised by management on the night.', truthful: false },
{ id: 'deny.soft2', text: 'Patron did not meet conditions of entry. Advised of same, politely, at the rope.', truthful: false },
],
},
sobrietyTest: {
prompt: 'A patron was assessed at the desk. Your account?',
options: [
{ id: 'sobriety.true', text: 'I tested them at the desk myself. It took a minute and I called it there.', truthful: true },
{ id: 'sobriety.soft1', text: 'A brief assessment was carried out at the desk and the result recorded.', truthful: false },
],
},
};
// For kinds this file has never heard of. Vague on purpose — the player is
// signing something they did not read, which is the correct amount of realism.
const DEFAULT_BANK: AccountBank = {
prompt: 'An entry was made in the log. Your account?',
options: [
{ id: 'other.true', text: 'It went in the log at the time and the log has it about right.', truthful: true },
{ id: 'other.soft1', text: 'Logged for completeness. Nothing arising required action on the night.', truthful: false },
],
};
// Load-time, like dressCode's card check: a question with two truthful options
// or a duplicate id is a form the Phase 4 audit cannot read, and finding that out
// at 3 AM in a summary screen is finding it out too late.
for (const [key, bank] of Object.entries({ ...BANKS, _default: DEFAULT_BANK })) {
if (bank.options.length < 2) {
throw new Error(`incidentReport: bank '${key}' offers no choice`);
}
if (bank.options.filter((o) => o.truthful).length !== 1) {
throw new Error(`incidentReport: bank '${key}' must have exactly one truthful account`);
}
if (new Set(bank.options.map((o) => o.id)).size !== bank.options.length) {
throw new Error(`incidentReport: bank '${key}' has duplicate option ids`);
}
}
// Own-property lookups only. `kind` is an open string, so a kind that happens to
// name an Object.prototype member ('toString', 'constructor', 'valueOf') would
// otherwise resolve to an inherited function: `interestOf` would return a
// Function, `fn > 0` is false, and the incident would vanish from the form — the
// exact silent-drop this module's defaultInterest exists to prevent.
const own = <T>(table: Record<string, T>, key: string): T | undefined =>
Object.prototype.hasOwnProperty.call(table, key) ? table[key] : undefined;
const interestOf = (incident: IncidentRecord): number =>
own(REPORT_TUNING.interest, incident.kind) ?? REPORT_TUNING.defaultInterest;
/** Fisher-Yates, so the truthful account is not always the top button. */
function shuffled(options: readonly ReportOption[], rng: RngStream): ReportOption[] {
const out = [...options];
for (let i = out.length - 1; i > 0; i--) {
const j = rng.int(0, i);
const a = out[i]!;
const b = out[j]!;
out[i] = b;
out[j] = a;
}
return out;
}
export function buildReport(
incidents: readonly IncidentRecord[],
rng: RngStream,
): ReportQuestion[] {
const scored = incidents
// Already-filed rows are skipped rather than trusted: pastReports holds both
// shapes, and asking the player to file an account of an account is a loop.
.filter((i) => interestOf(i) > 0 && parseFiledAccount(i) === undefined)
.map((incident) => ({ incident, score: interestOf(incident) + rng.next() }));
// Jitter is < 1 and weights are integers, so shuffling never promotes a dull
// incident over an interesting one — it only reorders within a band.
scored.sort((a, b) => b.score - a.score);
// Never padded: an uneventful night gets a short form, which is its own joke.
const asked = scored.slice(0, REPORT_TUNING.maxQuestions);
// Filed in the order it happened, because that is how a report reads and how
// Phase 4 will want to diff two nights against each other.
asked.sort((a, b) => a.incident.clockMin - b.incident.clockMin);
return asked.map(({ incident }) => {
const bank = own(BANKS, incident.kind) ?? DEFAULT_BANK;
return { incident, prompt: bank.prompt, options: shuffled(bank.options, rng) };
});
}
/** The rows to push onto GameState.pastReports. See the encoding note up top. */
export function fileReport(answers: readonly FiledAnswer[]): IncidentRecord[] {
return answers.map(({ incident, chosen }) => {
const payload: FiledPayload = {
optionId: chosen.id,
truthful: chosen.truthful,
truthLog: incident.detail,
};
const filed: IncidentRecord = {
clockMin: incident.clockMin,
kind: incident.kind,
detail: `${chosen.text}${REPORT_MARKER}${JSON.stringify(payload)}`,
};
// Spread rather than assigning undefined: patronId is optional and a present
// key holding undefined survives JSON round-trips as a different shape.
return incident.patronId === undefined ? filed : { ...filed, patronId: incident.patronId };
});
}
/** undefined for anything that is not a filed report row — including truth-log rows. */
export function parseFiledAccount(record: IncidentRecord): FiledAccount | undefined {
const at = record.detail.indexOf(REPORT_MARKER);
if (at < 0) return undefined;
let parsed: unknown;
try {
parsed = JSON.parse(record.detail.slice(at + REPORT_MARKER.length));
} catch {
return undefined;
}
if (typeof parsed !== 'object' || parsed === null) return undefined;
const { optionId, truthful, truthLog } = parsed as Partial<FiledPayload>;
if (typeof optionId !== 'string' || typeof truthful !== 'boolean' || typeof truthLog !== 'string') {
return undefined;
}
return { account: record.detail.slice(0, at), optionId, truthful, truthLog };
}
export function lieCount(answers: readonly FiledAnswer[]): number {
return answers.filter((a) => !a.chosen.truthful).length;
}
/**
* Design §3.3's "one taste" of the long-memory conscience: Kayden keeps his own
* log, and it does not flatter anyone. If a PREVIOUS night's filed report
* embellished a Kayden incident, this is what Dazza reads out the next morning.
*
* Phase 4 generalises this to a full cross-night audit with strikes attached;
* for now it is one line, arriving one night late, which is the shape of the
* mechanic in miniature.
*/
export function findKaydenContradiction(
pastReports: readonly (readonly IncidentRecord[])[],
): string | undefined {
for (let n = pastReports.length - 1; n >= 0; n--) {
for (const record of pastReports[n] ?? []) {
if (record.kind !== 'kayden') continue;
const filed = parseFiledAccount(record);
if (!filed || filed.truthful) continue;
return `kayden wrote his version of ${record.patronId ?? 'that one'} up too. it does not match urs. he is very proud of the log`;
}
}
return undefined;
}

156
src/rules/inspector.ts Normal file
View File

@ -0,0 +1,156 @@
// The Liquor Licensing Inspector (design §4.2). Plainclothes: the generator
// gives them nothing to look at, judge() scores them exactly like a punter, and
// this module never sees the door at all. It only answers one question, once a
// minute, while they are inside: is the room breaching right now?
//
// Everything here is keyed off clock minutes the caller supplies. No rng, no
// clock, no bus — the night shell owns all three.
import type { HeatStrike } from '../data/types';
export const INSPECTOR_TUNING = {
/** Clock-minutes they spend inside after being admitted. */
watchMin: 30,
/**
* The whole mechanic in one field. Breaches normally file a DEFERRED strike
* that surfaces at the 3 AM audit, which is late enough to argue with. With
* the inspector in the room the same breach lands now.
*/
strikesDeferred: false,
};
export interface InspectorWatch {
patronId: string;
admittedAtMin: number;
leavesAtMin: number;
}
/** Live venue conditions, sampled by the caller each clock minute. */
export interface VenueBreaches {
overCapacity: boolean;
maggotOnFloor: boolean;
underageInside: boolean;
}
/**
* Dazza's reveal, after they have already left. Deliberately the SAME line
* whether the inspector saw a spotless room or filed three strikes the player
* finds out how it went from the heat count, not from Dazza, and never finds out
* at all if they denied him.
*/
export const INSPECTOR_LINES: readonly string[] = [
'that boring bloke you let in was the INSPECTOR you muppet',
'mate. MATE. the quiet one was licensing. hes walked back out already. im gonna be sick',
'so that was the inspector. terry is going to ring me about tonight and im going to tell him it went great',
];
/**
* Reason strings are the dedupe key, so they are fixed sentences: no patron ids,
* no clock times, no counts. Two different breaches must never collapse into
* each other, and the same breach must read identically on every minute it is
* live.
*
* WIRING, AND THIS IS NOT OPTIONAL: stable reasons only collapse a 30-minute
* breach to one strike if the emitting site guards with
* `shouldRecordStrike(state, strike)` (src/scenes/door/NightScene.ts) before
* `bus.emit('heat:strike', ...)`, exactly as the door and deferred-audit paths
* already do. Nothing on the bus dedupes core/meters.ts records every strike
* it hears and NightScene pulls the licence on the third. Emit observe()'s
* strikes raw and a single sustained breach ends the run in three clock minutes.
*/
const BREACH_REASONS = {
overCapacity: 'inspector saw the room over licensed capacity',
maggotOnFloor: 'inspector saw a maggot still on the floor',
underageInside: 'inspector saw a minor inside the venue',
} as const;
// Declared order, so a room breaching three ways files its strikes the same way
// every time and the night summary reads consistently.
const BREACH_ORDER = ['overCapacity', 'maggotOnFloor', 'underageInside'] as const;
// observe() carries no rng and has to stay pure, so the reveal picks its line off
// the one number the watch already holds. Admission minutes vary across a run, so
// the player does not hear the same line every night.
const rollFor = (watch: InspectorWatch): number =>
(Math.abs(Math.trunc(watch.admittedAtMin)) % INSPECTOR_LINES.length) / INSPECTOR_LINES.length;
// NaN-safe like judge's lapRoll: a junk roll must fall back to a real line
// rather than returning undefined and printing an empty text bubble.
const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0);
/** Picks a reveal line from a 0..1 roll. Exported so a caller can use its own rng. */
export function revealLine(roll: number): string {
const i = Math.min(INSPECTOR_LINES.length - 1, Math.floor(clamp01(roll) * INSPECTOR_LINES.length));
return INSPECTOR_LINES[i] ?? INSPECTOR_LINES[0]!;
}
export function startWatch(patronId: string, clockMin: number): InspectorWatch {
return {
patronId,
admittedAtMin: clockMin,
leavesAtMin: clockMin + INSPECTOR_TUNING.watchMin,
};
}
/**
* Half-open [admittedAtMin, leavesAtMin). The minute they leave is theirs, not
* yours a breach that starts as they walk out the door is not something they
* saw, and an inclusive bound here would steal a minute the player is entitled to.
*/
export function isWatching(watch: InspectorWatch | null, clockMin: number): boolean {
if (watch === null) return false;
return clockMin >= watch.admittedAtMin && clockMin < watch.leavesAtMin;
}
export function hasLeft(watch: InspectorWatch | null, clockMin: number): boolean {
return watch !== null && clockMin >= watch.leavesAtMin;
}
export function breachStrikes(breaches: VenueBreaches): HeatStrike[] {
return BREACH_ORDER.filter((k) => breaches[k]).map((k) => ({
reason: BREACH_REASONS[k],
deferred: INSPECTOR_TUNING.strikesDeferred,
}));
}
/**
* Called once per clock minute for as long as a watch exists. Pure: the "has the
* reveal already fired" bit is not stored anywhere, it is derived from the
* ending minute being a single minute, which is why the caller must tick this at
* one call per whole clock minute (GameClock emits exactly that).
*
* Returns one strike per live breach PER MINUTE up to 3 * watchMin objects
* across a window. That is deliberate (this module has no memory), and it is why
* every strike must go out through `shouldRecordStrike`; see BREACH_REASONS.
*
* Known gap, caller's call: an inspector admitted after clock 330 has
* leavesAtMin > 360, GameClock clamps at 360 and stops ticking, so the reveal
* never fires and the player never learns who that was. Not clamped here because
* clamping would make the window shorter than watchMin. To guarantee the reveal,
* call observe(watch, watch.leavesAtMin, breaches) once at last drinks.
*/
export function observe(
watch: InspectorWatch | null,
clockMin: number,
breaches: VenueBreaches,
): { strikes: HeatStrike[]; dazzaText?: string; watchEnded: boolean } {
if (isWatching(watch, clockMin)) {
return { strikes: breachStrikes(breaches), watchEnded: false };
}
// The one minute the reveal exists on. Before it: not inside yet. After it:
// inert forever, because the caller keeps calling with the same stale watch.
if (watch !== null && clockMin === watch.leavesAtMin) {
return { strikes: [], dazzaText: revealLine(rollFor(watch)), watchEnded: true };
}
return { strikes: [], watchEnded: false };
}
// There is no denial branch, and its absence is the design (§4.3, LANE_CONTENT
// deliverable 3). Deny the inspector and NOTHING happens — no strike, no relief,
// no Dazza text, no note in the summary. They were a quiet person who wanted a
// drink, and the player never learns what they dodged. The paranoia is the tax.
// This will read as a missing branch to whoever maintains this next. It isn't.
// Do not add a reward for the correct call; the game does not know which call
// was correct, and that is the entire point of the mechanic.

View File

@ -1,7 +1,9 @@
import { violations, type DoorDressCodeRule } from './dressCode';
import { idVerdict, type IdVerdict } from './idCheck';
import type { DeferredHit, DoorOutcome, JudgeContext } from './doorTypes';
import { lapLine } from '../data/strings/door';
import { lapLine, ripenLine } from '../data/strings/door';
import { CONTRABAND } from '../data/contraband';
import { drunkStage } from './drunk';
import type { Patron, Verdict } from '../data/types';
// The scoring function — the one place this game's morality is a number. Every
@ -13,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,
@ -35,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,
@ -51,6 +63,10 @@ export const JUDGE_TUNING = {
/** v0.1 has no roaming inspector, so every heat strike surfaces at 3 AM. */
auditClockMin: 360,
/** How long a too-drunk admission takes to become somebody's problem. */
ripenDelayMin: 18,
ripenVibePerStage: -3,
/** Stalls buy you time and cost you queue patience, nothing else. */
stallAggro: 2,
waitAggro: 3,
@ -62,6 +78,23 @@ export const JUDGE_TUNING = {
// lap. An unrolled lap is the earliest lap, not no lap at all.
const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0);
/**
* Drunk enough that refusing entry is defensible rather than arbitrary. 'loose'
* is deliberately NOT included a bit merry is a judgement call, and the game
* does not get to tell you it was the right one.
*/
function refusableDrunk(p: Patron): boolean {
const stage = drunkStage(p.intoxication);
return stage === 'messy' || stage === 'maggot';
}
/** Severity 3 is the ejection-worthy tier in data/contraband.ts. */
function hasSeriousContraband(p: Patron): boolean {
return (p.flags.contraband ?? []).some(
(id) => CONTRABAND.find((c) => c.id === id)?.severity === 3,
);
}
export function judge(patron: Patron, verdict: Verdict, ctx: JudgeContext): DoorOutcome {
// Stalls are theatre, not rulings. The queue notices the delay; nothing else
// moves until the player actually says admit or deny, which comes back
@ -81,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
@ -99,8 +137,25 @@ function judgeDeny(patron: Patron, broken: DoorDressCodeRule[], id: IdVerdict):
}
// "Genuinely violates something": the dress code, a card that reads under 18
// or fake, or an actual minor regardless of what the card claimed.
const justified = broken.length > 0 || id.underage || id.looksFake || patron.age < 18;
// or fake, an actual minor regardless of what the card claimed — or a state
// you are not allowed to serve.
//
// Intoxication belongs here. Until it did, denying someone who could not
// stand up was scored identically to denying a clean punter (+7 aggro), so
// the most legible signal in the game — sway, red eyes, the whole sobriety
// minigame — was worth nothing, and waving maggots through was the optimal
// line. RSA says you legally cannot serve them; the scoring now agrees.
const tooDrunkToServe = refusableDrunk(patron);
const seriousContraband = hasSeriousContraband(patron);
const justified =
broken.length > 0 ||
id.underage ||
id.looksFake ||
patron.age < 18 ||
tooDrunkToServe ||
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;
@ -174,6 +229,22 @@ function judgeAdmit(
});
}
// The quiet messy one (design §4.2): fine at the rope, a problem by midnight.
// Without this, admitting a visibly drunk patron was free at the desk and its
// only cost lived in the floor sim — deferred, invisible, and unsized against
// the aggro you save. Now the trade is real on both sides of the rope.
const stage = drunkStage(patron.intoxication);
if (stage === 'loose' || stage === 'messy' || stage === 'maggot') {
const severity = stage === 'loose' ? 1 : stage === 'messy' ? 2 : 3;
deferred.push({
atClockMin: Math.min(T.auditClockMin, ctx.clockMin + T.ripenDelayMin * severity),
vibeDelta: T.ripenVibePerStage * severity,
dazzaText: ripenLine(stage, clamp01(ctx.lapRoll)),
reason: `admitted ${patron.id} already ${stage}`,
patronId: patron.id,
});
}
// One strike, however many ways it was wrong — a licence only gets pulled
// once, and the audit files card problems and a real minor as one finding.
// Note the split: the CARD is what the player could see, the age is what
@ -200,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

@ -0,0 +1,90 @@
import Phaser from 'phaser';
import { lookupName, type GuestList, type NameLookup } from '../../rules/guestList';
import { DOOR_PALETTE, MONO, panel, text } from './ui';
// The guest-list clipboard, sharing the desk's left panel with Dazza's rules via
// a two-tab header. There is no spare desk real estate at 640x360, and a tab is
// honest about the tradeoff: you cannot read the dress code and the list at the
// same time, so checking a name costs you a beat of attention. That is the point.
export type ClipboardTab = 'rules' | 'list';
/** Two columns of names; 14 entries is the generator's ceiling. */
const ROWS_PER_COL = 7;
const ROW_H = 10;
export class ClipboardPanel {
private readonly root: Phaser.GameObjects.Container;
private readonly rows: Phaser.GameObjects.Text[] = [];
private readonly hint: Phaser.GameObjects.Text;
private highlighted = -1;
constructor(
scene: Phaser.Scene,
private readonly list: GuestList,
x: number,
y: number,
w: number,
h: number,
) {
this.root = scene.add.container(0, 0).setDepth(50);
this.root.add(panel(scene, x, y, w, h, 0x1d1a14, 0x4a4436));
this.list.entries.forEach((entry, i) => {
const col = Math.floor(i / ROWS_PER_COL);
const row = i % ROWS_PER_COL;
const label = entry.plusOnes > 0 ? `${entry.name} +${entry.plusOnes}` : entry.name;
const t = scene.add.text(x + 5 + col * (w / 2 - 4), y + 5 + row * ROW_H, label, {
fontFamily: MONO,
fontSize: '6px',
color: '#c8bda0',
});
this.rows.push(t);
this.root.add(t);
});
this.hint = text(scene, x + 5, y + h - 11, '', 6, DOOR_PALETTE.inkDim);
this.root.add(this.hint);
this.root.setVisible(false);
}
setVisible(on: boolean): void {
this.root.setVisible(on);
}
/**
* Show what the clipboard says about the name in front of you. It reports the
* MATCH, never the truth an exact hit is the suspicious one (rules/guestList
* has the arithmetic), and nothing here tells the player which way to call it.
*/
check(claimed: string | null): NameLookup | null {
this.clearHighlight();
if (!claimed) {
this.hint.setText('');
return null;
}
const found = lookupName(claimed, this.list);
const idx = found.entry ? this.list.entries.indexOf(found.entry) : -1;
if (idx >= 0 && found.match !== 'none') {
this.rows[idx]?.setColor('#ffe08a');
this.highlighted = idx;
}
this.hint.setText(
found.match === 'exact'
? `"${claimed}" — written exactly like that`
: found.match === 'near'
? `"${claimed}" — close to one on the list`
: `"${claimed}" — nothing like it here`,
);
this.hint.setColor(found.match === 'none' ? '#c08080' : '#c8bda0');
return found;
}
private clearHighlight(): void {
if (this.highlighted >= 0) this.rows[this.highlighted]?.setColor('#c8bda0');
this.highlighted = -1;
}
destroy(): void {
this.root.destroy();
}
}

View File

@ -1,26 +1,37 @@
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';
import { DressCodeCard } from './DressCodeCard';
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';
@ -33,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
@ -59,9 +69,13 @@ export class DoorScene extends Phaser.Scene {
private idCard!: IdCardView;
private phone!: PhoneWidget;
private codeCard!: DressCodeCard;
private clipboard!: ClipboardPanel;
private deskTab: ClipboardTab = 'rules';
private tabButtons: Partial<Record<ClipboardTab, Button>> = {};
private sobriety!: SobrietyModal;
private queueSprites: QueueSprite[] = [];
private carpetSprites: QueueSprite[] = [];
private trayCard: Phaser.GameObjects.Container | null = null;
private buttons: Record<string, Button> = {};
private vibeBar!: MeterBar;
@ -78,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() {
@ -95,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);
@ -119,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 }) => {
@ -147,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);
@ -156,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
@ -180,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();
}
@ -224,7 +358,17 @@ export class DoorScene extends Phaser.Scene {
this.add.rectangle(W / 2, (DESK_Y + H) / 2, W, H - DESK_Y, DOOR_PALETTE.desk).setDepth(20);
this.add.rectangle(W / 2, DESK_Y + 1, W, 2, DOOR_PALETTE.deskEdge).setDepth(21);
this.codeCard = new DressCodeCard(this, 6, 256, 168, 98);
// 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();
// ID tray
panel(this, 180, 256, 96, 98).setDepth(30);
@ -264,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(),
@ -290,6 +439,36 @@ export class DoorScene extends Phaser.Scene {
this.setVerdictButtons(false);
}
/** RULES | LIST switch above the shared left panel. */
private buildDeskTabs(): void {
const mk = (tab: ClipboardTab, label: string, x: number, w: number): Button =>
new Button(this, {
x, y: 254, w, h: 13, label, size: 6,
fill: DOOR_PALETTE.deskEdge,
hover: DOOR_PALETTE.panelEdge,
onClick: () => this.showTab(tab),
}).setDepth(52);
this.tabButtons.rules = mk('rules', "DAZZA'S RULES", 6, 84);
this.tabButtons.list = mk('list', 'GUEST LIST', 90, 84);
this.showTab('rules');
}
private showTab(tab: ClipboardTab): void {
this.deskTab = tab;
this.codeCard.setVisible(tab === 'rules');
this.clipboard.setVisible(tab === 'list');
// NOT setEnabled: disabling the current tab greys it out, which reads as
// "this one is off" — exactly backwards. Both stay live; the selected one is
// simply the bright one, sitting flush with the panel it opens.
for (const key of ['rules', 'list'] as const) {
const b = this.tabButtons[key];
if (!b) continue;
const active = key === tab;
b.rect.setFillStyle(active ? DOOR_PALETTE.panelEdge : 0x1b1720);
b.label.setColor(active ? DOOR_PALETTE.inkHot : DOOR_PALETTE.inkDim);
}
}
private buildHud(): void {
this.add.rectangle(W / 2, 9, W, 18, 0x05050a, 0.75).setDepth(500);
this.vibeBar = new MeterBar(this, 6, 10, 88, 'VIBE', DOOR_PALETTE.green);
@ -336,12 +515,33 @@ 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.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
// not on the list and is not pretending to be.
this.clipboard.check(p.flags.claimsGuestList ? p.idCard.name : null);
if (p.flags.claimsGuestList) this.flashTab('list');
this.tweens.add({
targets: this.ropeProp.getByName('rope') as Phaser.GameObjects.Rectangle,
@ -351,6 +551,96 @@ 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];
if (!b || this.deskTab === tab) return;
this.tweens.add({ targets: b.label, alpha: { from: 1, to: 0.25 }, duration: 320, yoyo: true, repeat: 3 });
}
private showTray(p: Patron): void {
this.trayCard?.destroy();
const c = this.idCard.thumbnail(p, 228, 300);
@ -432,6 +722,15 @@ export class DoorScene extends Phaser.Scene {
this.night.state.capacity.inside++;
}
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
@ -486,6 +785,161 @@ export class DoorScene extends Phaser.Scene {
});
}
/**
* A scripted encounter's own consequence, on top of the ordinary judge()
* score. Deliberately silent about whether the call was decent: §4.3 says the
* cost is real and the verdict is absent, so there is no chime here and no
* tally anywhere.
*/
private applyEncounter(p: Patron, verdict: 'admit' | 'deny'): void {
const id = this.queue.encounterFor(p);
const enc = id ? encounterById(id) : undefined;
if (!enc) return;
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;
if (out.aggroDelta) delta.aggro = out.aggroDelta;
if (Object.keys(delta).length > 0) this.night.bus.emit('meters:delta', delta);
if (out.heatStrike && shouldRecordStrike(this.night.state, out.heatStrike)) {
// Fresh object per firing: the encounter tables are module singletons, so
// pushing the literal would alias one strike across the whole run.
this.night.bus.emit('heat:strike', { ...out.heatStrike });
}
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',
patronId: p.id,
detail: `${id}: ${verdict === 'admit' ? 'let in' : 'turned away'}`,
});
}
/**
* 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,
@ -558,6 +1012,7 @@ export class DoorScene extends Phaser.Scene {
// applyOutcome emits door:verdict (and the verdict incident) — same path as
// a player ruling, so the floor is fed exactly once per admission.
this.applyOutcome(p, verdict, outcome);
this.applyEncounter(p, verdict);
this.queue.resolveUp(verdict === 'deny');
this.night.bus.emit('incident:log', {
clockMin: this.night.state.clockMin,
@ -567,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));
@ -590,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) {
@ -662,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 {
@ -29,10 +31,7 @@ export class DressCodeCard {
this.root = scene.add.container(0, 0).setDepth(50);
this.root.add(panel(scene, x, y, w, h, 0x1a2018, 0x3c4a38));
this.root.add(text(scene, x + 5, y + 4, DOOR_UI.dressCode, 8, '#c8d8b0'));
this.root.add(scene.add.rectangle(x + w / 2, y + 15, w - 8, 1, 0x3c4a38));
this.emptyText = text(scene, x + 5, y + 21, DOOR_UI.dressCodeEmpty, 7, DOOR_PALETTE.inkDim);
this.emptyText = text(scene, x + 5, y + 6, DOOR_UI.dressCodeEmpty, 7, DOOR_PALETTE.inkDim);
this.root.add(this.emptyText);
}
@ -40,7 +39,7 @@ export class DressCodeCard {
if (this.rows.some((r) => r.rule.id === rule.id)) return;
this.emptyText.setVisible(false);
const y = this.y + 20 + this.rows.length * 11;
const y = this.y + 6 + this.rows.length * 11;
const label = scene.add.text(this.x + 5, y, `· ${rule.short}`, {
fontFamily: MONO,
fontSize: '7px',
@ -60,20 +59,47 @@ 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);
}
}
/** The clipboard tab shares this panel's footprint — see ClipboardPanel. */
setVisible(on: boolean): void {
this.root.setVisible(on);
}
destroy(): void {
this.root.destroy();
}

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

@ -0,0 +1,215 @@
import Phaser from 'phaser';
import type { GameState, IncidentRecord, NightState } from '../../data/types';
import {
buildReport,
fileReport,
findKaydenContradiction,
lieCount,
type FiledAnswer,
type ReportQuestion,
} from '../../rules/incidentReport';
import { SeededRNG } from '../../core/SeededRNG';
import { saveGame } from '../../core/save';
import { REPORT_UI } from '../../data/strings/door';
import { Button, DOOR_PALETTE, MONO, panel, text } from './ui';
// The form (design §3.3). You write up what happened, and you may write it up
// generously. Lying is free tonight; the inspector cross-references across
// nights and Phase 4 is where it bites.
//
// Deliberately unscored: nothing here counts your lies back at you, no meter
// moves, and Dazza's line at the end is the same length whatever you picked.
// One night's contradiction is the only feedback, and it arrives a night late.
const W = 640;
const H = 360;
/**
* Incident details are written for the log, not for the player, so they carry
* raw patron ids ("admitted p43 in breach"). The id is load-bearing for Phase
* 4's cross-night audit and stays in the STORED record it just has no place
* on a form a person is reading.
*/
function humanise(detail: string): string {
return detail.replace(/\bp\d+\b/g, 'a patron');
}
/** Wall-clock label for an incident. The night starts at 9 PM (GameClock). */
function clockLabel(clockMin: number): string {
const total = 21 * 60 + clockMin;
const h24 = Math.floor(total / 60) % 24;
const m = total % 60;
const h12 = h24 % 12 === 0 ? 12 : h24 % 12;
return `${h12}:${String(m).padStart(2, '0')} ${h24 < 12 ? 'AM' : 'PM'}`;
}
export interface ReportSceneData {
state: NightState;
run: GameState | null;
seed: number;
/** Where to go once the paperwork is done. */
next: () => void;
}
export class IncidentReportScene extends Phaser.Scene {
// NOT `data` — Phaser.Scene already owns that name (its DataManager).
private input$!: ReportSceneData;
private questions: ReportQuestion[] = [];
private answers: FiledAnswer[] = [];
private index = 0;
private buttons: Button[] = [];
private promptText!: Phaser.GameObjects.Text;
private counter!: Phaser.GameObjects.Text;
private contradiction: string | undefined;
constructor() {
super('IncidentReport');
}
init(data: ReportSceneData): void {
this.input$ = data;
this.questions = [];
this.answers = [];
this.index = 0;
this.buttons = [];
}
create(): void {
// Its own stream: buildReport's draw count varies with how many incidents
// the night produced, which is player-driven. Sharing a stream would let
// door decisions perturb unrelated generation.
const rng = new SeededRNG(this.input$.seed).stream('report');
this.questions = buildReport(this.input$.state.incidents, rng);
// Read BEFORE this night's rows are filed, so tonight cannot contradict itself.
this.contradiction = this.input$.run
? findKaydenContradiction(this.input$.run.pastReports)
: undefined;
this.add.rectangle(W / 2, H / 2, W, H, 0x0d0c10);
this.add
.text(W / 2, 24, REPORT_UI.title, { fontFamily: MONO, fontSize: '16px', color: '#e8dcc4' })
.setOrigin(0.5);
this.add
.text(W / 2, 44, REPORT_UI.subtitle, { fontFamily: MONO, fontSize: '8px', color: DOOR_PALETTE.inkDim })
.setOrigin(0.5);
panel(this, 40, 62, W - 80, 232, 0x15141a, 0x39364a);
this.counter = text(this, 48, 68, '', 7, DOOR_PALETTE.inkDim);
this.promptText = this.add.text(48, 84, '', {
fontFamily: MONO,
fontSize: '9px',
color: '#d8d0c0',
wordWrap: { width: W - 100 },
});
if (this.questions.length === 0) {
// A night with nothing worth writing up is its own small joke.
this.promptText.setText(REPORT_UI.nothingToReport);
this.add
.text(W / 2, H - 20, REPORT_UI.done, { fontFamily: MONO, fontSize: '8px', color: DOOR_PALETTE.inkDim })
.setOrigin(0.5);
this.input.once('pointerdown', () => this.finish());
return;
}
this.showQuestion();
}
private showQuestion(): void {
for (const b of this.buttons) b.destroy();
this.buttons = [];
const q = this.questions[this.index];
if (!q) {
this.finish();
return;
}
this.counter.setText(`incident ${this.index + 1} of ${this.questions.length}`);
// The bank prompt states the CATEGORY ("a patron was removed"); without the
// time and the specific detail beside it the player is being asked to
// account for something the form never names, which is unanswerable rather
// than morally interesting.
this.promptText.setText(`${clockLabel(q.incident.clockMin)}${humanise(q.incident.detail)}\n${q.prompt}`);
// Options are laid out in the order buildReport shuffled them into — the
// truthful one is not always first, and nothing marks which is which.
let y = 84 + this.promptText.height + 12;
q.options.forEach((opt) => {
const label = this.add.text(0, 0, opt.text, {
fontFamily: MONO,
fontSize: '8px',
color: '#cfc6b4',
wordWrap: { width: W - 132 },
});
const h = Math.max(30, label.height + 14);
label.destroy();
const b = new Button(this, {
x: 52,
y,
w: W - 104,
h,
label: opt.text,
size: 8,
fill: 0x231f2c,
hover: 0x36304a,
colour: '#cfc6b4',
onClick: () => this.choose(q, opt),
});
b.label.setWordWrapWidth(W - 132);
b.label.setAlign('left');
b.setDepth(40);
this.buttons.push(b);
y += h + 8;
});
}
private choose(q: ReportQuestion, opt: ReportQuestion['options'][number]): void {
this.answers.push({ incident: q.incident, chosen: opt });
this.index++;
this.showQuestion();
}
private finish(): void {
const filed: IncidentRecord[] = fileReport(this.answers);
// pastReports is the cross-night memory. Storing the ACCOUNT (not the truth)
// is what lets a later night catch you out — rules/incidentReport documents
// the encoding so Phase 4 does not have to reverse-engineer it.
if (this.input$.run) {
this.input$.run.pastReports.push(filed);
// The night's save was written before the paperwork; re-save so the
// accounts survive into the night that audits them.
saveGame(this.input$.run);
}
const lies = lieCount(this.answers);
for (const b of this.buttons) b.destroy();
this.buttons = [];
this.promptText.setText('');
this.counter.setText('');
// Dazza's sign-off is the same shape whether you told the truth or not.
// He is not the conscience; there isn't one.
this.add
.text(W / 2, 150, REPORT_UI.filed, { fontFamily: MONO, fontSize: '11px', color: '#9fe8a0' })
.setOrigin(0.5);
const line = this.contradiction ?? REPORT_UI.filedFlavour(lies > 0);
this.add
.text(W / 2, 178, line, {
fontFamily: MONO,
fontSize: '9px',
color: this.contradiction ? '#f0d060' : '#bfe8c8',
align: 'center',
wordWrap: { width: W - 140 },
})
.setOrigin(0.5);
this.add
.text(W / 2, H - 20, REPORT_UI.done, { fontFamily: MONO, fontSize: '8px', color: DOOR_PALETTE.inkDim })
.setOrigin(0.5);
const go = (): void => this.input$.next();
this.input.once('pointerdown', go);
this.input.keyboard?.once('keydown-SPACE', go);
}
}

View File

@ -3,26 +3,33 @@ 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;
// this scene owns WHERE THE PLAYER IS (visibility + input), the clock, the
// 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;
@ -35,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';
@ -60,16 +63,34 @@ 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. */
export interface RunInfo {
/** The run save itself, so the report form can file into it and persist. */
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. */
@ -85,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 {
@ -96,16 +142,25 @@ 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>();
private lastDazzaMin = -99;
private lastVibeSampleMin = -99;
/** Live inspector watch, or null. At most one per night. */
private inspector: InspectorWatch | null = null;
/** Minors the player (or Kayden) has actually let past the rope. */
private minorsInside = 0;
/** True while the floor has an unhandled maggot — the inspector's worst find. */
private maggotLive = false;
private lastMaggotRadioMin = -99;
private readonly firedPulls = new Set<number>();
private queueLength = 0;
@ -117,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) {
@ -125,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 {
@ -141,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);
@ -152,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();
@ -172,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(
@ -182,13 +253,33 @@ 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 === 'maggotUnhandled' && this.state.location === 'door') this.maggotRadio();
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;
}),
);
@ -200,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 ----------------------------------------------------------
@ -247,7 +382,20 @@ export class NightScene extends Phaser.Scene {
// forgets one hand in seven — those are the floor's noStamp infractions.
patron.flags.uvStamped =
this.state.location === 'door' ? true : this.rng.stream('kaydenStamp').chance(KAYDEN_STAMP_CHANCE);
} else if (verdict === 'deny') this.log.denied++;
// 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).
// ...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++;
// 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++;
}
@ -275,12 +423,41 @@ export class NightScene extends Phaser.Scene {
}
this.fireDeferred(clockMin);
this.observeInspector(clockMin);
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');
}
/**
* The inspector, if one is inside. While they watch, a live breach files its
* strike NOW instead of at the 3 AM audit the whole point being that you
* cannot know which half-hour of the night that was.
*
* Every strike still goes through shouldRecordStrike: observe() returns one
* per breach per minute, so emitting them raw would pull the licence within
* three minutes of any sustained breach.
*/
private observeInspector(clockMin: number): void {
if (!this.inspector) return;
const breaches: VenueBreaches = {
overCapacity: this.state.capacity.inside > this.state.capacity.licensed,
maggotOnFloor: this.maggotLive,
underageInside: this.minorsInside > 0,
};
const seen = observe(this.inspector, clockMin, breaches);
for (const strike of seen.strikes) {
if (shouldRecordStrike(this.state, strike)) this.bus.emit('heat:strike', strike);
}
if (seen.dazzaText) this.bus.emit('dazza:text', { text: seen.dazzaText });
if (seen.watchEnded) this.inspector = null;
}
/** Scripted floor pulls: the radio wants you inside around these times. */
private maybePull(clockMin: number): void {
for (const at of RADIO_PULL_MINS) {
@ -338,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);
@ -370,20 +548,53 @@ 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];
this.run.pastReports.push([...this.state.incidents]);
// 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);
}
const runInfo: RunInfo = { nightIndex: this.nightIndex, totalNights: TOTAL_NIGHTS, runOver, weekDone };
const runInfo: RunInfo = {
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();
}
});
}
@ -394,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',
@ -87,8 +89,17 @@ export class NightSummaryScene extends Phaser.Scene {
})
.setOrigin(0.5);
this.input.once('pointerdown', next);
this.input.keyboard?.once('keydown-SPACE', next);
// The paperwork sits between the night and the next shift (design §3.3).
const toReport = (): void => {
this.scene.start('IncidentReport', {
state: this.state,
run: this.run?.game ?? null,
seed: this.log.seed,
next,
});
};
this.input.once('pointerdown', toReport);
this.input.keyboard?.once('keydown-SPACE', toReport);
}
/** What clicking through leads to: the next shift, or a fresh run. */
@ -97,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';
@ -118,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', {}),
};
}
@ -170,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,10 @@ 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, 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';
@ -22,6 +24,10 @@ export class PatronUpView {
private patron: Patron | null = null;
private swayT = 0;
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,
@ -34,7 +40,7 @@ export class PatronUpView {
return this.patron;
}
show(p: Patron): void {
show(p: Patron, encounter?: EncounterId): void {
this.clear();
this.patron = p;
const { scene } = this;
@ -64,7 +70,7 @@ export class PatronUpView {
root.add(hit);
}
this.speak(p);
this.speak(p, encounter);
// step-up: they walk in from the queue side and settle
root.x = this.x - 90;
@ -72,21 +78,43 @@ export class PatronUpView {
scene.tweens.add({ targets: root, x: this.x, alpha: 1, duration: 380, ease: 'Quad.easeOut' });
}
private speak(p: Patron): void {
private speak(p: Patron, encounter?: EncounterId): void {
if (!this.root) return;
// A scripted patron says their own words, in beats, and says them over the
// top of nothing else — the whole point is that they are a person rather
// than a draw from a pool.
const script = encounter ? encounterById(encounter) : undefined;
if (script) {
for (const beat of script.beats) this.sayLater(beat.line, beat.afterMs);
return;
}
const { scene } = this;
const drunk = drunkStage(p.intoxication);
const pool =
p.flags.claimsGuestList || p.flags.knowsOwner
? CLAIM_LINES
: drunk === 'messy' || drunk === 'maggot' || drunk === 'loose'
? DRUNK_GREETINGS
: PATRON_GREETINGS;
// 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.
// 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',
@ -98,10 +126,44 @@ export class PatronUpView {
.rectangle(0, bubbleY - label.height / 2 + 1, label.width + 10, label.height + 6, 0x14121a, 0.85)
.setStrokeStyle(1, 0x3a3448);
this.root.add([bg, label]);
this.bubble = [bg, label];
scene.tweens.add({ targets: [bg, label], alpha: 0, delay: 3600, duration: 700 });
}
private clearBubble(): void {
for (const o of this.bubble) o.destroy();
this.bubble = [];
}
/** One speech bubble, `afterMs` from the moment they stepped up. */
private sayLater(line: string, afterMs: number): void {
this.scene.time.delayedCall(afterMs, () => {
// The patron may have been ruled on and walked off before this beat was
// due — a stale bubble floating over an empty rope is the bug here.
if (!this.root || this.patron === null) return;
const { scene } = this;
// An exchange is a sequence, not a pile: beat two replaces beat one.
this.clearBubble();
const bubbleY = -DOLL_H * SCALE - 16;
const label = scene.add
.text(0, bubbleY, this.lipRead ? lipReadLine(line, this.patron.id) : `"${line}"`, {
fontFamily: MONO,
fontSize: '8px',
color: '#e8dcc4',
align: 'center',
wordWrap: { width: 190 },
})
.setOrigin(0.5, 1);
const bg = scene.add
.rectangle(0, bubbleY - label.height / 2 + 1, label.width + 10, label.height + 6, 0x14121a, 0.9)
.setStrokeStyle(1, 0x584a68);
this.root.add([bg, label]);
this.bubble = [bg, label];
scene.tweens.add({ targets: [bg, label], alpha: 0, delay: 3200, duration: 700 });
});
}
private showTooltip(zone: InspectZone, localY: number): void {
this.hideTooltip();
if (!this.root || !this.patron) return;
@ -191,6 +253,7 @@ export class PatronUpView {
}
clear(): void {
this.clearBubble();
this.hideTooltip();
this.root?.destroy();
this.root = null;

View File

@ -3,12 +3,22 @@ import type { RngStream, SeededRNG } from '../../core/SeededRNG';
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';
/** Second patron of the night lands here regardless of the curve's opinion. */
const OPENING_ARRIVAL_MIN = 3;
/** 2:45 AM. Last drinks is 3:00; the line has to stop growing before then. */
const DOORS_SHUT_MIN = 345;
import '../../rules/doorTypes'; // side-effect: PatronFlags.handHolding augmentation
// Pure queue logic — no Phaser. DoorScene renders whatever this says is true.
// Owning the queue outside the scene keeps the "how much does neglect cost?"
@ -34,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,
@ -48,8 +65,47 @@ export const QUEUE_TUNING = {
regularMaxComebacks: 2,
/** chance a new arrival brings a hand-holding partner (feeds noHandHolders) */
coupleChance: 0.12,
/** scripted moral encounters injected per night (design §4.3) */
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[];
@ -70,16 +126,48 @@ export class QueueManager {
private readonly arrivalRng: RngStream;
private readonly coupleRng: RngStream;
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 */
private pendingEncounters: Array<{ atMin: number; id: EncounterId }> = [];
/** patronId -> the script they are playing, for the door to look up */
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');
@ -88,6 +176,42 @@ export class QueueManager {
// arrival time for the rest of the night, which is exactly the cross-system
// perturbation CONTRACTS.md §6 gives named streams to prevent.
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,
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());
@ -126,6 +250,14 @@ export class QueueManager {
// happens to be due. Shut means shut.
if (clockMin >= DOORS_SHUT_MIN) return;
// Scripted arrivals jump the curve — they are people who turned up at a
// particular moment, not draws from a distribution.
while (this.pendingEncounters.length > 0 && clockMin >= this.pendingEncounters[0]!.atMin) {
const due = this.pendingEncounters.shift()!;
const scripted = this.makeScripted(due.id);
if (scripted) this.enqueue(scripted);
}
for (let i = this.comebacks.length - 1; i >= 0; i--) {
const c = this.comebacks[i]!;
if (clockMin >= c.atMin) {
@ -136,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));
}
}
@ -145,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) {
@ -172,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. */
@ -235,8 +444,63 @@ export class QueueManager {
}
}
/** Which scripted encounter this patron is, if any. */
encounterFor(patron: Patron): EncounterId | undefined {
return this.encounterOf.get(patron.id);
}
private make(): Patron {
return 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
// a door-phase prop and patrons/ is frozen — see rules/guestList.ts for why
// an exact match is the suspicious one.
if (p.flags.claimsGuestList) {
p.idCard.name = assignListedName(this.listRng, this.guestList, p.flags.onGuestList === true);
}
return p;
}
/**
* A scripted patron, if one is due. Generated through the SAME generator so
* their doll, ID and outfit are ordinary `dress()` only changes what the
* script needs, which is why they don't announce themselves before speaking.
*/
private makeScripted(id: EncounterId): Patron | null {
const enc = encounterById(id);
if (!enc) return null;
const p = generatePatron({ rng: this.rng, nightDate: this.nightDate }, this.clockMin, enc.archetype);
enc.dress(p, this.encounterRng);
if (p.flags.claimsGuestList) {
p.idCard.name = assignListedName(this.listRng, this.guestList, p.flags.onGuestList === true);
}
this.encounterOf.set(p.id, id);
return p;
}
private enqueue(p: Patron): Patron | null {

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