Compare commits
No commits in common. "main" and "lane/juice2" have entirely different histories.
main
...
lane/juice
1
.gitignore
vendored
@ -1,3 +1,2 @@
|
||||
node_modules
|
||||
dist/
|
||||
art_incoming/
|
||||
|
||||
16
CLAUDE.md
@ -35,13 +35,9 @@ by parallel "lanes" coordinated through files, reviewed by Fable.
|
||||
floor WASD + E (E at the entry exits back to the door). While you're inside,
|
||||
Kayden works the door — badly, on purpose.
|
||||
- Dev routes: N night · P parade · F floor demo · J juice demo (+ same-named
|
||||
URL hashes) · M toggles the MIX DESK overlay anywhere (ear-pass rig).
|
||||
`localStorage.removeItem('not-tonight-save')` for a clean run.
|
||||
URL hashes). `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
|
||||
@ -65,13 +61,3 @@ by parallel "lanes" coordinated through files, reviewed by Fable.
|
||||
The game is Aussie hospitality satire — affectionate, funny, with a moral undertow
|
||||
(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).
|
||||
|
||||
1136
LANEHANDOVER.md
@ -1 +0,0 @@
|
||||
raw drops go here; process with tools/props_import.py
|
||||
|
Before Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 823 KiB |
|
Before Width: | Height: | Size: 337 KiB |
|
Before Width: | Height: | Size: 562 KiB |
|
Before Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 944 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 46 KiB |
@ -26,42 +26,16 @@ nike_air_max, moschino, quiksilver, kmart, disruptor_ii…), dynamically loaded
|
||||
sprites before committing — pixelated photo-sprites can look mushy; may need a
|
||||
cleanup pass or MODELBEAST-assisted redraw per sprite.
|
||||
|
||||
### 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.
|
||||
### 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.
|
||||
|
||||
### 3. Venue / street reference art
|
||||
- `ultra:~/Documents/90sDJsim/web/world/media/` named scene/venue art — 100 wired
|
||||
|
||||
@ -1,91 +0,0 @@
|
||||
# NOT TONIGHT — Character, Scenario & Interaction Backlog
|
||||
|
||||
*The idea shelf. Everything here obeys §4.3: choices cost something real, the
|
||||
game never grades them, and the humane option is never secretly optimal.
|
||||
Status: ✅ implemented · 🔜 next nibbles · 💡 backlog.*
|
||||
|
||||
## Interactions & mini-systems
|
||||
|
||||
- ✅ **The joint bust** (smoking yard). E on a smoker who smells herbal → three
|
||||
verbs, none of them clean: **BIN IT** (by the book — write-up in the truth
|
||||
log, smoker's night dampened), **POCKET IT** (the game's first corruption
|
||||
verb — nothing happens now; maybe a Dazza sting later; and it sits in the
|
||||
incident log waiting for the report form, where your account of it can be a
|
||||
lie the audit remembers), **DIDN'T SEE IT** (the yard relaxes, vibe drifts
|
||||
up… and it's still on the record as ignored). 'joint' is real contraband, so
|
||||
it also turns up in pat-downs at the rope.
|
||||
- ✅ **The glassie run** (the glassie role's fantasy, previewed as a floor
|
||||
task). A wire rack of glasses appears at the bar's east end; carry it the
|
||||
length of the venue to the kitchen dishwasher. Crowd bumps and sharp turns
|
||||
build WOBBLE; wobble breaks glasses one smash at a time. Deliver what
|
||||
survives for vibe. Racks keep coming. The door is unattended while you do it
|
||||
— Kayden is thrilled.
|
||||
- ✅ **ID passback detection** (2026-07-20): the same card twice in one night — the name on
|
||||
the guest-list clipboard you already checked; second appearance of an
|
||||
identical card = passback. Needs a "cards seen tonight" memory at the desk.
|
||||
- ✅ **Regular step-up lines** (2026-07-20) keyed off timesDeniedBefore ("back again. new
|
||||
moustache. no hard feelings").
|
||||
- ✅ **Smoking-door slip-in** (2026-07-20) (FLOOR2 §2's second noStamp source).
|
||||
- ✅ **The 1:30 lockout** (2026-07-20) (deeply Queensland): after 1:30 AM, admissions are
|
||||
ILLEGAL. The queue still forms; Dazza still wants bodies; every post-lockout
|
||||
admit is a guaranteed strike risk vs a rioting line. A whole late-game phase
|
||||
in one law.
|
||||
- ✅ **Vomit protocol** (2026-07-20): someone redecorates the dance floor → deploy the wet
|
||||
floor sign (we own the prop) → slips if you dawdle; the glassie mop loop.
|
||||
- ✅ **Phone confiscation** (2026-07-20): someone filming on the dance floor — the
|
||||
influencer's revenge arc.
|
||||
- ✅ **Water station duty** (2026-07-20) (RSA): press a cup on a `loose` patron; done right
|
||||
it de-escalates the ripen timer. The humane tool that costs time.
|
||||
- ✅ **First aid** (2026-07-20): a fainter. Recovery position via a QTE that is deliberately
|
||||
NOT funny; the room hushes; the night resumes changed.
|
||||
- ✅ **Kebab run** (2026-07-20): Dazza demands a kebab mid-slam. The shop is right there.
|
||||
The queue can see you. The whole game in one errand.
|
||||
|
||||
## Characters (door archetypes & scripted people)
|
||||
|
||||
- ✅ **The off-duty cop** (2026-07-20) — flashes a badge at the rope. Real cop (deny freely,
|
||||
he respects it; admit and he quietly reports the venue's breaches — a
|
||||
walking inspector) or a $30 costume badge (the tells: hat, confidence,
|
||||
wrong lanyard). The ID game's scariest bluff.
|
||||
- ✅ **The celebrity incognito** (2026-07-20) — cap + sunnies (two live rule breaches!).
|
||||
Deny: nothing happens, ever, and Dazza never finds out who. Admit: vibe
|
||||
rocket OR pap swarm outside (aggro storm). You cannot know which.
|
||||
- ✅ **The twins** (2026-07-20) — two people, one face, different names. Not a fake:
|
||||
genuinely twins. Everything about the photo check is suddenly on trial.
|
||||
- ✅ **The sovereign citizen** (2026-07-20) — "I don't recognise your dress code as having
|
||||
authority over my person." Aggro engine in a polo. His ID is a laminated
|
||||
business card.
|
||||
- ✅ **The delivery rider** (2026-07-20) — insists the DJ ordered food. The DJ did not
|
||||
order food. OR DID HE. (The booth lamp flickers hungrily.)
|
||||
- ✅ **The promoter** (2026-07-20) — arrives with a roll of wristbands and 'his' guest list
|
||||
that doesn't match yours. Two clipboards enter, one leaves.
|
||||
- ✅ **The noise-complaint neighbour** (2026-07-20) — pyjamas, at the rope, at 1 AM, not
|
||||
trying to get in: trying to get it turned DOWN. The only patron who wants
|
||||
less club. Handle him badly and the inspector hears about it.
|
||||
- ✅ **The hen's party, lost** (2026-07-20) — seven matching sashes, wrong venue, right
|
||||
energy. Admitting them is a vibe grenade with a random timer.
|
||||
- ✅ **The ex-doorman** (2026-07-20) — watches you work a patron, then tells you what you
|
||||
missed (a live tutorial character; his tips are right ~80% of the time,
|
||||
which is the joke).
|
||||
|
||||
## Systems worth a whole phase
|
||||
|
||||
- ✅ **Venue 4 / ROOM boss week** (2026-07-21, first cut): fourth rung on the
|
||||
ladder (promotes from Elevate). No Dazza rule chronology — the board IS the
|
||||
law and re-randomises every 15 in-game minutes (3 rules per window, seeded,
|
||||
with a toast on the turn); every rope line renders as bracketed lip-read
|
||||
guesses (`[FIST? LIST?]`) because the techno ate the dialogue. 60 licensed,
|
||||
1.2 arrival scale, inspector working, crowd of heads and VIP-claimers.
|
||||
- ✅ **The DJ shift** (2026-07-20, first cut): E at the booth hands you the decks
|
||||
while the resident steps out. B pulls the bass (the room leans in); SPACE
|
||||
drops, judged against the LIVE bar line (clean/late/clang — early and late
|
||||
are the same offence); requests arrive at the booth glass with per-request
|
||||
odds ("got any dnb", nan's song, the DUN DUN one) and three answers that all
|
||||
cost something. A build held too long runs out of stairs. Movement locks
|
||||
while you hold them; the floor and the door keep happening without you.
|
||||
- ✅ **Bartender shift** (2026-07-21, first cut): E at the till hands you the
|
||||
taps (movement locks; the floor keeps happening). Orders arrive as choices —
|
||||
the order line degrades with the patron's stage (the spelling IS the sobriety
|
||||
test) — SERVE / WATER / CUT OFF all legal, all priced. Serving messy/maggot
|
||||
is the RSA breach: the vibe pays up front, and 35% of the time somebody with
|
||||
a lanyard saw it (ONE deferred write-up per night, deduped by reason).
|
||||
115
docs/SPACES.md
@ -1,115 +0,0 @@
|
||||
# NOT TONIGHT — Spaces Plan
|
||||
|
||||
*How the game's two spaces grow from readable-but-abstract into places, using the
|
||||
fleet's asset factories. Written 2026-07-20. Code contract: everything lands
|
||||
behind data tables (`venueMap.PROPS` / `LIGHTS`) and a sprite drop-in convention —
|
||||
generated art replaces placeholders with zero code changes.*
|
||||
|
||||
## 1. Design principles
|
||||
|
||||
1. **Darkness stays the mechanic.** Props read as silhouettes in ambient dark and
|
||||
resolve under the torch. Nothing may make the room bright enough to scan from
|
||||
the doorway.
|
||||
2. **Navigate by light signature.** Each zone owns a colour of light, so the
|
||||
player orients by glow before geometry: bar = warm amber · dance floor =
|
||||
pulsing pink (on the actual beat) · DJ booth = deep blue · toilets =
|
||||
flickering green fluoro · smoking yard = cool rain-blue spill · entry/exit =
|
||||
venue pink. The torch is white; UV is purple. No other light sources.
|
||||
3. **Props are data.** `PropDef { kind, tile pos, footprint, emissive? }` — the
|
||||
renderer prefers a generated texture (`gen:prop:<kind>`) and falls back to a
|
||||
procedural placeholder, so the game is complete TONIGHT and gets prettier as
|
||||
art lands.
|
||||
4. **Spaces anticipate the role ladder** (design §6): the bar interior is laid
|
||||
out for a future bartender shift, the DJ booth for the DJ shift, the glassie
|
||||
station and smoking yard for floor loops that already have lanes speccing them.
|
||||
|
||||
## 2. The venue floor (80×45 tiles, one room + pockets)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ N ═ BAR ══════════ back-shelf glow ┌─ DUNNIES ────────┐│
|
||||
│ taps · till · glasses │ s1 s2 s3 s4 ││
|
||||
│ ○○○○○○ stools │ sinks + mirror ││
|
||||
│ └───door───────────┘│
|
||||
│ W │
|
||||
│ ┈entry┈ ┌ DANCE FLOOR (pulsing grid) ┐ ╔═ DJ BOOTH ╗ booths│
|
||||
│ corridor │ speaker · · · · · speaker │ ║ decks+mixer║ E│
|
||||
│ posters │ · · · disco ball · · · │ ║ (sealed) ║ booths│
|
||||
│ cloak │ speaker · · · · · speaker │ ╚════════════╝ │
|
||||
│ glassie └───────────────────────────┘ booths│
|
||||
│ station │
|
||||
│ ▓ SMOKING YARD (open sky, rain) ▓ tables+candles S │
|
||||
└────────▓ fence · butt bin ▓──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Zone-by-zone (tile coords in `venueMap.ts`):
|
||||
|
||||
| Zone | What's there (props) | Light signature | Future role hook |
|
||||
|---|---|---|---|
|
||||
| **Bar** (north run) | back-bar shelf (emissive), 3 beer taps, till, glass stacks | steady amber pools along the shelf | Bartender shift: the serving lane between bar tiles and shelf is already unwalkable-to-patrons |
|
||||
| **Dance floor** (centre) | 4 corner speaker stacks, disco ball (centre), floor grid cells | pink grid cells pulse ON `beat:tick`; ball throws a slow-rotating glint | DJ role reads crowd here |
|
||||
| **DJ booth** (east of dance, sealed box facing the floor) | 2 decks + mixer (from the 90sDJsim gear GLBs!), booth monitor, lamp | deep blue wash + VU flicker | The DJ shift's playable pocket |
|
||||
| **Booths** (south + east walls) | table per booth pair, candle | tiny warm candle pools | — |
|
||||
| **Dunnies** (NE walled room) | sink row + mirror on west wall, hand dryer | green fluoro, FLICKERS (torch almost redundant inside — deliberate) | — |
|
||||
| **Smoking yard** (SW walled pocket, open sky) | butt bin, milk crate seats, fence | cool blue outdoor spill + rain particles INSIDE the yard | FLOOR2's smoking loop (specced) |
|
||||
| **Entry corridor** (west) | poster wall (3 art slots — MODELBEAST posters!), cloakroom window, stamp podium | venue-pink spill from the door | UV re-entry checks happen here |
|
||||
| **Glassie station** (bar's west end alcove) | glass racks, tub trolley | dim amber | Glassie role's home base |
|
||||
|
||||
Sim impact: walkability only changes where NEW walls go (DJ booth box, yard
|
||||
fence, sink row). All existing anchors keep working; reachability is asserted in
|
||||
tests. The yard gets a `smoke` anchor upgrade so wanderers actually go out for one.
|
||||
|
||||
## 3. The door street (640×360 tableau)
|
||||
|
||||
Already reads well. Two upgrades, both drop-in images (no layout change):
|
||||
1. **Backdrop plate** behind the existing rects: one wide pixel-art street wall
|
||||
(brick, posters, grime) — swaps in as `gen:street:backdrop` when generated.
|
||||
2. **Kebab shop interior glow card** in its window + rotating poster art in two
|
||||
street frames (same poster slots as the entry corridor).
|
||||
|
||||
## 4. The sprite drop-in contract (how John's art lands)
|
||||
|
||||
- Drop processed PNGs in **`public/props/`** named `<kind>.png` and list them in
|
||||
**`public/props/manifest.json`** (`{"props": ["deck", "speaker", ...]}`).
|
||||
- The floor loads the manifest at boot; anything listed replaces its placeholder
|
||||
(texture key `gen:prop:<kind>`). Missing = placeholder. No code changes.
|
||||
- **`tools/props_import.py`** (PIL, runs anywhere with python3+PIL — ultra has it)
|
||||
takes raw generated images → target pixel size + palette quantise + transparent
|
||||
background → writes `public/props/` + updates the manifest:
|
||||
`python3 tools/props_import.py incoming/deck_raw.png deck`
|
||||
- Target sizes are in the script (`TARGETS`): hero props 48–64 px, furniture
|
||||
24–32 px, small items 16 px. It refuses unknown kinds so typos can't ship.
|
||||
|
||||
## 5. GENERATION QUEUE — John's list (in priority order)
|
||||
|
||||
Pipelines: **[MB]** = MODELBEAST `flux_local` (params given) → drop raw output in
|
||||
`art_incoming/`, run the import script (or hand me the raws — either works).
|
||||
**[GLB]** = Blender ortho prerender of an existing fleet mesh (camera: orthographic,
|
||||
top-down 90° for floor props / front 0° for the street, transparent film, ~512px,
|
||||
neutral even lighting) → same import path.
|
||||
|
||||
| # | Item (kind) | Pipeline & source | Notes |
|
||||
|---|---|---|---|
|
||||
| 1 | `deck` | [GLB] `ultra:~/Documents/90sDJsim/web/world/models/gear/deck_t3_technics_sl_1200mk2.glb`, top-down | The hero. One render serves both booth decks |
|
||||
| 2 | `mixer` | [GLB] same dir, `mixer.glb` (or the urei once landed), top-down | Sits between the decks |
|
||||
| 3 | `speaker` | [GLB] 90sDJsim `models/props/` has boombox/speaker meshes; else [MB] prompt: "top-down view of a large black PA speaker stack, pixel art game sprite, dark background, viewed directly from above" 512×512 seed 4210 | 4 placements |
|
||||
| 4 | `discoball` | [MB] "mirrored disco ball seen directly from above, glinting, pixel art sprite, black background" 512×512 seed 4211 | Centre of dance floor |
|
||||
| 5 | `barShelf` | [MB] "top-down bar back-shelf with glowing bottles rows, amber light, pixel art sprite, dark" 1024×256 seed 4212 | Emissive — the amber source |
|
||||
| 6 | `taps` | [MB] "row of three beer taps on a bar counter viewed from above, pixel art, dark bg" 256×256 seed 4213 | |
|
||||
| 7 | `boothTable` | [MB] "small round pub table with two drinks seen from directly above, pixel art, dark bg" 256×256 seed 4214 | 6 placements |
|
||||
| 8 | `sink` | [MB] "public bathroom sink row with mirror viewed from above, green fluorescent light, pixel art, dark" 512×256 seed 4215 | |
|
||||
| 9 | `buttBin` + `crate` | [MB] one sheet: "outdoor smoking area props: cigarette butt bin and milk crate, top-down pixel art, dark bg" 512×256 seed 4216 | Yard dressing |
|
||||
| 10 | `poster1..3` | [MB] three runs: "grimy nightclub gig poster, 90s Australian pub rock / techno rave / missing cat, pixel art, portrait" 256×384 seeds 4217-19 | FRONT view (posters hang on walls) — used at door + corridor |
|
||||
| 11 | `street:backdrop` | [MB] "wide pixel art night street wall: dark brick, torn posters, graffiti, neon reflections, rain, front-on orthographic, game background" 1600×440 seed 4220 | The door scene plate. Biggest single visual win |
|
||||
| 12 | `cloak` / `glassRack` | [MB] "cloakroom window with hanging jackets / metal glass rack with stacked pint glasses, top-down pixel art, dark" 256×256 seeds 4221-22 | Corridor + glassie station |
|
||||
| 13 | `arcade` | [GLB] `m3ultra:~/Documents/procity/pipeline/_normalized/procity_fit_arcade_cabinet_01.glb`, top-down | Corner easter egg near entry |
|
||||
|
||||
**Ambition tier (later, not blocking):** pre-rendered patron sprite sheets from
|
||||
the modular character kit + RIGROOM clips (`Searching_Pockets`, boxing pack,
|
||||
`Leaning_Idle02_Smoking`, zombie stagger for maggots) — replaces the procedural
|
||||
dolls entirely per docs/ASSETS.md §2. Wants its own session to set the
|
||||
palette/density once.
|
||||
|
||||
*(flux params for all [MB] jobs: `model flux2-klein-4b, steps 4`; put "no text,
|
||||
no lettering" in every prompt EXCEPT the posters, where mangled text reads as
|
||||
authentic gig-poster grot.)*
|
||||
|
Before Width: | Height: | Size: 836 B |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 424 B |
|
Before Width: | Height: | Size: 644 B |
|
Before Width: | Height: | Size: 571 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 691 B |
|
Before Width: | Height: | Size: 549 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 552 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 430 B |
@ -1,32 +0,0 @@
|
||||
{
|
||||
"props": [
|
||||
"arcade",
|
||||
"barShelf",
|
||||
"boothLamp",
|
||||
"boothTable",
|
||||
"buttBin",
|
||||
"carpet",
|
||||
"cloak",
|
||||
"crate",
|
||||
"deck",
|
||||
"discoball",
|
||||
"dishwasher",
|
||||
"glassRack",
|
||||
"glassStack",
|
||||
"highTable",
|
||||
"mixer",
|
||||
"monitor",
|
||||
"plant",
|
||||
"poolTable",
|
||||
"poster1",
|
||||
"poster2",
|
||||
"poster3",
|
||||
"sinkRow",
|
||||
"speaker",
|
||||
"stampPodium",
|
||||
"street_backdrop",
|
||||
"taps",
|
||||
"till",
|
||||
"wetFloor"
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 918 B |
|
Before Width: | Height: | Size: 351 B |
|
Before Width: | Height: | Size: 396 B |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 851 B |
|
Before Width: | Height: | Size: 892 B |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 657 B |
|
Before Width: | Height: | Size: 502 B |
|
Before Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 1001 B |
|
Before Width: | Height: | Size: 646 B |
|
Before Width: | Height: | Size: 387 B |
@ -16,8 +16,7 @@ export type SfxName =
|
||||
| 'phoneBuzz'
|
||||
| 'ropeUnhook'
|
||||
| 'denyCrowdOoh'
|
||||
| 'typeTick'
|
||||
| 'glassSmash';
|
||||
| 'typeTick';
|
||||
|
||||
/** Seconds of white noise generated once and shared by every noise voice. */
|
||||
const NOISE_SECONDS = 2;
|
||||
@ -85,8 +84,6 @@ export class Sfx {
|
||||
return this.denyCrowdOoh();
|
||||
case 'typeTick':
|
||||
return this.typeTick();
|
||||
case 'glassSmash':
|
||||
return this.glassSmash();
|
||||
}
|
||||
}
|
||||
|
||||
@ -274,15 +271,6 @@ export class Sfx {
|
||||
this.voice(src, [this.band(3400, 2.2), this.env(t, 0.0005, 0.002, 0.012, 0.045)], t, 0.016);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
||||
@ -18,19 +18,6 @@ export function freshNightState(venueId: string, nightIndex: number, licensed: n
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
|
||||
@ -16,7 +16,6 @@ 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' },
|
||||
];
|
||||
|
||||
@ -59,7 +59,7 @@ export const OUTFIT_VOCAB: Record<OutfitSlot, readonly OutfitTypeDef[]> = {
|
||||
],
|
||||
outer: [
|
||||
{ type: 'none', weight: 6, colours: ['black'] },
|
||||
{ type: 'blazer', weight: 1, colours: ['black', 'navy', 'grey'] },
|
||||
{ type: 'blazer', weight: 2, colours: ['black', 'navy', 'grey'] },
|
||||
{ type: 'bomber', weight: 2, colours: ['black', 'green', 'navy'], canLogo: true, canVintage: true },
|
||||
{ type: '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: 1, colours: ['cream', 'black', 'green'], canLogo: true, canVintage: true },
|
||||
{ type: 'bucketHat', weight: 2, colours: ['cream', 'black', 'green'], canLogo: true, canVintage: true },
|
||||
{ type: 'cap', weight: 2, colours: ['black', 'red', 'navy'], canLogo: true },
|
||||
{ type: 'sunnies', weight: 1, colours: ['black'] }, // sunglasses. at night. a tell in itself
|
||||
{ type: 'sunnies', weight: 2, colours: ['black'] }, // sunglasses. at night. a tell in itself
|
||||
{ type: 'bumbag', weight: 1, colours: ['black', 'purple', 'yellow'], canLogo: true, canVintage: true },
|
||||
{ type: 'chain', weight: 1, colours: ['yellow', 'grey'] },
|
||||
],
|
||||
|
||||
@ -1,89 +0,0 @@
|
||||
// The roster board (design §6): role × venue = level select. A role is a lens
|
||||
// + verb-set over the ONE simulation — picking a shift picks where you stand
|
||||
// when the doors open, not which game you're playing. Wages are what the
|
||||
// board says they are; the tips are your business.
|
||||
|
||||
import type { VenueDef } from './venues';
|
||||
|
||||
export type RoleId = 'glassie' | 'bartender' | 'doorgirl' | 'bouncer' | 'dj' | 'hos';
|
||||
|
||||
export interface RoleDef {
|
||||
id: RoleId;
|
||||
name: string;
|
||||
/** What the board admits the shift pays. */
|
||||
wage: number;
|
||||
/** The board's own words. */
|
||||
blurb: string;
|
||||
/** Where you stand when the doors open. */
|
||||
startLoc: 'door' | 'floor';
|
||||
/** Walk straight onto a post: the taps or the decks. */
|
||||
post?: 'taps' | 'decks';
|
||||
/** Venue ids where this shift is on the board (venue ladder, design §6). */
|
||||
venues: readonly string[];
|
||||
}
|
||||
|
||||
export const ROLES: readonly RoleDef[] = [
|
||||
{
|
||||
id: 'glassie',
|
||||
name: 'GLASSIE',
|
||||
wage: 120,
|
||||
blurb: 'zero authority. all glasses. you will SEE things',
|
||||
startLoc: 'floor',
|
||||
venues: ['theRoyal'],
|
||||
},
|
||||
{
|
||||
id: 'bartender',
|
||||
name: 'BARTENDER',
|
||||
wage: 160,
|
||||
blurb: 'the taps, the tabs, the RSA line. nobody cooked gets a pour',
|
||||
startLoc: 'floor',
|
||||
post: 'taps',
|
||||
venues: ['theRoyal', 'voltage'],
|
||||
},
|
||||
{
|
||||
id: 'doorgirl',
|
||||
name: 'DOOR',
|
||||
wage: 180,
|
||||
blurb: 'the rope, the IDs, the whole street watching you decide',
|
||||
startLoc: 'door',
|
||||
venues: ['theRoyal', 'voltage'],
|
||||
},
|
||||
{
|
||||
id: 'bouncer',
|
||||
name: 'BOUNCER',
|
||||
wage: 190,
|
||||
blurb: 'the floor, the stalls, the yard. walk it like you mean it',
|
||||
startLoc: 'floor',
|
||||
venues: ['voltage'],
|
||||
},
|
||||
{
|
||||
id: 'dj',
|
||||
name: 'DJ',
|
||||
wage: 220,
|
||||
blurb: 'hold the decks. the room is a meter and youre the needle',
|
||||
startLoc: 'floor',
|
||||
post: 'decks',
|
||||
venues: ['room'],
|
||||
},
|
||||
{
|
||||
id: 'hos',
|
||||
name: 'HEAD OF SECURITY',
|
||||
wage: 260,
|
||||
blurb: 'door, floor, radio. everything, at once, all night',
|
||||
startLoc: 'door',
|
||||
venues: ['elevate', 'room'],
|
||||
},
|
||||
];
|
||||
|
||||
export function rolesFor(venueId: string): RoleDef[] {
|
||||
return ROLES.filter((r) => r.venues.includes(venueId));
|
||||
}
|
||||
|
||||
export function roleById(id: string | undefined): RoleDef | undefined {
|
||||
return ROLES.find((r) => r.id === id);
|
||||
}
|
||||
|
||||
/** Booting a night without the board (dev routes): the sensible default. */
|
||||
export function defaultRoleFor(venue: VenueDef): RoleDef {
|
||||
return rolesFor(venue.id).find((r) => r.startLoc === 'door') ?? ROLES.find((r) => r.id === 'hos')!;
|
||||
}
|
||||
@ -22,10 +22,6 @@ export const DAZZA_TEXTS: readonly DazzaLine[] = [
|
||||
{ id: 'rule-suits', text: 'too corporate in here tonight. no blazers til the suits ive already got leave', ruleId: 'noBlazers', fromMin: 200 },
|
||||
{ id: 'rule-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 },
|
||||
|
||||
@ -16,16 +16,6 @@ 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. */
|
||||
@ -61,21 +51,6 @@ 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',
|
||||
@ -84,20 +59,6 @@ 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',
|
||||
@ -135,90 +96,6 @@ 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',
|
||||
@ -233,19 +110,6 @@ 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',
|
||||
|
||||
@ -1,276 +0,0 @@
|
||||
// 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."',
|
||||
},
|
||||
};
|
||||
|
||||
@ -80,43 +80,6 @@ export const PATDOWN_FOUND: Record<string, string> = {
|
||||
export const PATDOWN_CLEAR = 'Nothing on him. "Told ya," he says, delighted.';
|
||||
export const PATDOWN_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."',
|
||||
@ -126,295 +89,3 @@ 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;
|
||||
|
||||
@ -22,8 +22,6 @@ 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 {
|
||||
@ -44,11 +42,6 @@ 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 {
|
||||
@ -127,8 +120,6 @@ 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 ----
|
||||
@ -145,8 +136,6 @@ 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' };
|
||||
|
||||
@ -1,102 +0,0 @@
|
||||
// The venue ladder (design §6): each venue is a week of Thu→Fri→Sat, and a
|
||||
// difficulty tier expressed as knobs on systems that already exist. Survive a
|
||||
// week, get the next venue's door. Three strikes anywhere, the run dies.
|
||||
|
||||
export interface VenueDef {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Dazza's blurb on the promotion screen. */
|
||||
pitch: string;
|
||||
licensed: number;
|
||||
/** Multiplies the arrival curve — how hard the queue slams. */
|
||||
arrivalScale: number;
|
||||
/**
|
||||
* How many of Dazza's rule texts fire this week (chronological prefix).
|
||||
* The live-rule cap (dressCode.ACTIVE_RULE_CAP) applies on top.
|
||||
*/
|
||||
ruleLimit: number;
|
||||
/** Does the plainclothes inspector work this venue? */
|
||||
inspector: boolean;
|
||||
/**
|
||||
* Spawn-weight OVERRIDES merged over data/archetypes.ts — the venue's crowd
|
||||
* identity. Omitted keys keep their default weight.
|
||||
*/
|
||||
archetypeWeights?: Partial<Record<import('./types').Archetype, number>>;
|
||||
/** Head of Security tier (design §6): radio command of NPC staff. */
|
||||
radioCommand?: boolean;
|
||||
/** ROOM (design §6): the dress code re-randomises every 15 in-game minutes. */
|
||||
shuffleDressCode?: boolean;
|
||||
/** ROOM (design §6): rope dialogue renders as lip-read guesses. */
|
||||
lipRead?: boolean;
|
||||
}
|
||||
|
||||
export const VENUES: readonly VenueDef[] = [
|
||||
{
|
||||
id: 'theRoyal',
|
||||
name: 'The Royal',
|
||||
pitch: 'a dive with a dance floor. nobody checks anything. u will',
|
||||
licensed: 80,
|
||||
arrivalScale: 0.8,
|
||||
ruleLimit: 4,
|
||||
inspector: false,
|
||||
// A local's pub that happens to have a dance floor: regulars and punters,
|
||||
// barely an influencer in sight.
|
||||
archetypeWeights: { regular: 14, influencer: 2, financeBro: 6, ownersMate: 2 },
|
||||
},
|
||||
{
|
||||
id: 'voltage',
|
||||
name: 'Voltage',
|
||||
pitch: 'valley institution. full rulebook. the inspector drinks here. lemonade',
|
||||
licensed: 110,
|
||||
arrivalScale: 1.0,
|
||||
ruleLimit: 8,
|
||||
inspector: true,
|
||||
// The balanced book — the tuning baseline. No overrides.
|
||||
},
|
||||
{
|
||||
id: 'elevate',
|
||||
name: 'Elevate',
|
||||
pitch: 'rooftop. small room, big opinions. every second person is a VIP apparently',
|
||||
licensed: 70,
|
||||
arrivalScale: 1.1,
|
||||
ruleLimit: 8,
|
||||
inspector: true,
|
||||
// Elevate is the Head of Security tier: the radio starts taking orders.
|
||||
radioCommand: true,
|
||||
// Rooftop VIP politics: every second person is on a list, knows the owner,
|
||||
// or is filming. Regulars don't come here; they can't get in either.
|
||||
archetypeWeights: { influencer: 18, ownersMate: 7, financeBro: 14, punter: 38, regular: 3 },
|
||||
},
|
||||
{
|
||||
id: 'room',
|
||||
name: 'ROOM',
|
||||
pitch: 'no sign. no socials. code changes when the board says so. u wont hear a word anyone says. good luck',
|
||||
licensed: 60,
|
||||
arrivalScale: 1.2,
|
||||
// Dazza doesn't announce rules here — the board is the law and it keeps
|
||||
// rewriting itself (shuffleDressCode). His other texts still land.
|
||||
ruleLimit: 0,
|
||||
inspector: true,
|
||||
radioCommand: true,
|
||||
shuffleDressCode: true,
|
||||
lipRead: true,
|
||||
// Underground techno: heads and chancers. Everyone claims VIP; almost
|
||||
// nobody is. Regulars can't find the door.
|
||||
archetypeWeights: { punter: 34, influencer: 12, ownersMate: 9, quietMessy: 8, financeBro: 10, regular: 2, fresh18: 6 },
|
||||
},
|
||||
];
|
||||
|
||||
export const TOTAL_VENUES = VENUES.length;
|
||||
|
||||
export function venueByIndex(i: number): VenueDef {
|
||||
return VENUES[Math.max(0, Math.min(VENUES.length - 1, i))]!;
|
||||
}
|
||||
|
||||
/** Week `w` (venue index) shifts the base night dates forward 7 days per week. */
|
||||
export function nightDateFor(baseIso: string, venueIndex: number): string {
|
||||
const [y = '2026', m = '07', d = '16'] = baseIso.split('-');
|
||||
const date = new Date(Number(y), Number(m) - 1, Number(d) + venueIndex * 7);
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(date.getDate()).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${mm}-${dd}`;
|
||||
}
|
||||
36
src/main.ts
@ -4,11 +4,8 @@ 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
|
||||
@ -26,20 +23,15 @@ 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,
|
||||
],
|
||||
});
|
||||
|
||||
@ -53,28 +45,19 @@ 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 ROOT_SCENES) {
|
||||
for (const key of ['Night', 'Door', 'NightSummary', 'Parade', 'FloorDemo', 'JuiceDemo']) {
|
||||
if (key !== target && game.scene.isActive(key)) game.scene.stop(key);
|
||||
}
|
||||
// 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);
|
||||
if (!game.scene.isActive(target)) game.scene.start(target);
|
||||
};
|
||||
|
||||
// RosterScene is scene[0] and auto-starts. Hash routing must wait until it is
|
||||
// NightScene is scene[0] and auto-starts. Hash routing must wait until it is
|
||||
// actually up before swapping, or both scenes end up running on top of each other.
|
||||
const hash = window.location.hash.slice(1) as RouteKey;
|
||||
if (hash in ROUTES) {
|
||||
if (hash in ROUTES && ROUTES[hash] !== 'Night') {
|
||||
const route = (): void => {
|
||||
if (!game.scene.isActive('Roster')) return;
|
||||
if (!game.scene.isActive('Night')) return;
|
||||
game.events.off(Phaser.Core.Events.POST_STEP, route);
|
||||
showScene(ROUTES[hash]);
|
||||
};
|
||||
@ -87,13 +70,4 @@ 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');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -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}:${p.flags.disguised === true ? 'd' : ''}`;
|
||||
return `doll:${p.dollSeed}:${pose}:${dollPlan(p, pose).swayPx}`;
|
||||
}
|
||||
|
||||
export function renderDoll(scene: Phaser.Scene, p: Patron, pose: DollPose): string {
|
||||
|
||||
@ -122,16 +122,5 @@ export function dollPlan(p: Patron, pose: DollPose): DollPlan {
|
||||
rects.push({ x: cx + 1, y: 9, w: 1, h: 1, colour: 0xcc3333 });
|
||||
}
|
||||
|
||||
// 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 };
|
||||
}
|
||||
|
||||
@ -36,9 +36,7 @@ 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) };
|
||||
// 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.canLogo && rng.chance(0.35)) layer.logo = true;
|
||||
if (def.canVintage && rng.chance(0.2)) layer.vintage = true;
|
||||
layers.push(layer);
|
||||
}
|
||||
@ -82,17 +80,13 @@ 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, ctx.archetypeWeights?.[a.key] ?? a.weight] as const),
|
||||
);
|
||||
: rng.weighted(ARCHETYPES.map((a) => [a, a.weight] as const));
|
||||
|
||||
const dollSeed = rng.int(0, 2 ** 31 - 1);
|
||||
const age = rng.int(def.ageRange[0], def.ageRange[1]);
|
||||
|
||||
@ -3,22 +3,6 @@ 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 ?? {
|
||||
@ -44,8 +28,3 @@ 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;
|
||||
}
|
||||
|
||||
@ -1,53 +0,0 @@
|
||||
// The run's regulars: a small stable cast generated from the RUN seed, so the
|
||||
// same faces turn up Thursday, Friday, and next week at the better venue.
|
||||
// Design §4.1: deny one twice and the third appearance arrives in a bad fake
|
||||
// moustache — same walk, same card, new "identity".
|
||||
|
||||
import { SeededRNG } from '../core/SeededRNG';
|
||||
import { generatePatron } from './generator';
|
||||
import type { Patron } from '../data/types';
|
||||
|
||||
export const REGULAR_CAST_SIZE = 6;
|
||||
|
||||
/**
|
||||
* Deterministic per run seed and independent of every night stream (its own
|
||||
* SeededRNG instance). The cast members are full Patron templates — the queue
|
||||
* adopts their IDENTITY (face, age, licence) onto freshly rolled nights
|
||||
* (tonight's outfit, tonight's drinks) at spawn time.
|
||||
*/
|
||||
export function buildRegularCast(runSeed: number, nightDate: Date): Patron[] {
|
||||
const rng = new SeededRNG(runSeed ^ 0x5eed);
|
||||
const cast: Patron[] = [];
|
||||
for (let i = 0; i < REGULAR_CAST_SIZE; i++) {
|
||||
const p = generatePatron({ rng, nightDate }, 0, 'regular');
|
||||
p.flags.regularId = `reg${i}`;
|
||||
cast.push(p);
|
||||
}
|
||||
return cast;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dress tonight's rolled regular in a cast member's identity. The roll keeps
|
||||
* its outfit/intoxication (people change clothes and moods); the cast supplies
|
||||
* who they ARE: face, age, licence, tolerance — and the grudge bookkeeping.
|
||||
*/
|
||||
export function adoptCastIdentity(
|
||||
rolled: Patron,
|
||||
member: Patron,
|
||||
timesDenied: number,
|
||||
disguised: boolean,
|
||||
): Patron {
|
||||
return {
|
||||
...rolled,
|
||||
dollSeed: member.dollSeed,
|
||||
age: member.age,
|
||||
idCard: { ...member.idCard },
|
||||
tolerance: member.tolerance,
|
||||
flags: {
|
||||
...rolled.flags,
|
||||
regularId: member.flags.regularId!,
|
||||
...(timesDenied > 0 ? { timesDeniedBefore: timesDenied } : {}),
|
||||
...(disguised ? { disguised: true } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -6,7 +6,6 @@
|
||||
// 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';
|
||||
|
||||
@ -159,57 +158,8 @@ export const DRESS_CODE_RULES: readonly DoorDressCodeRule[] = SPECS.map(build).s
|
||||
);
|
||||
|
||||
/** Rules in force at `clockMin`. Live the moment the text lands, hence inclusive. */
|
||||
/**
|
||||
* 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);
|
||||
return DRESS_CODE_RULES.filter((r) => r.activeFrom <= clockMin);
|
||||
}
|
||||
|
||||
export function violations(patron: Patron, clockMin: number): DoorDressCodeRule[] {
|
||||
|
||||
@ -1,717 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@ -1,68 +0,0 @@
|
||||
// The floor score (John's ask, 2026-07-21): the venue closes, the lights come
|
||||
// ON, and the floor tells you what kind of night it was. Money, contraband,
|
||||
// and objects with no explanation. Pure rolls here; the scene owns the sweep.
|
||||
|
||||
import type { RngStream } from '../core/SeededRNG';
|
||||
|
||||
export type DropKind = 'note' | 'coins' | 'baggie' | 'phone' | 'curio';
|
||||
|
||||
export interface Drop {
|
||||
kind: DropKind;
|
||||
/** Dollars. Curios and phones are worth nothing and everything. */
|
||||
value: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** How strewn the floor is: a base mess plus one drop per this many admits. */
|
||||
export const DROPS_BASE = 6;
|
||||
export const DROPS_PER_ADMITS = 6;
|
||||
export const DROPS_MAX = 14;
|
||||
|
||||
export const NOTE_VALUES: readonly number[] = [5, 5, 10, 10, 20, 50];
|
||||
/** One baggie, sometimes. The moral beat of the sweep. */
|
||||
export const BAGGIE_CHANCE = 0.35;
|
||||
/** Someone's whole life, at 3%, face down near the booths. */
|
||||
export const PHONE_CHANCE = 0.4;
|
||||
|
||||
export const CURIOS: readonly string[] = [
|
||||
'a single white sneaker. vintage, technically',
|
||||
'one hoop earring, lying there victorious',
|
||||
'an untouched kebab. UNTOUCHED.',
|
||||
'a novelty lighter shaped like a slightly smaller lighter',
|
||||
'seventeen bobby pins in one tight formation',
|
||||
"someone's nan's reading glasses",
|
||||
'a laminated business card. sovereign.',
|
||||
'a wristband from a venue that shut in 2019',
|
||||
];
|
||||
|
||||
export const BAGGIE_LABEL = 'a zip-lock bag of definitely-not-yours';
|
||||
export const PHONE_LABEL = "someone's phone. 3% battery. 41 missed calls from MUM DO NOT ANSWER";
|
||||
|
||||
/**
|
||||
* What the lights find. Deterministic per stream: mostly cash in anxious
|
||||
* denominations, a couple of objects with no explanation, at most one baggie
|
||||
* and one phone.
|
||||
*/
|
||||
export function rollDrops(rng: RngStream, admitted: number): Drop[] {
|
||||
const count = Math.min(DROPS_MAX, DROPS_BASE + Math.floor(admitted / DROPS_PER_ADMITS));
|
||||
const drops: Drop[] = [];
|
||||
|
||||
if (rng.chance(BAGGIE_CHANCE)) drops.push({ kind: 'baggie', value: 0, label: BAGGIE_LABEL });
|
||||
if (rng.chance(PHONE_CHANCE)) drops.push({ kind: 'phone', value: 0, label: PHONE_LABEL });
|
||||
|
||||
const curios = rng.int(2, 3);
|
||||
for (let i = 0; i < curios && drops.length < count; i++) {
|
||||
drops.push({ kind: 'curio', value: 0, label: CURIOS[rng.int(0, CURIOS.length - 1)] ?? CURIOS[0]! });
|
||||
}
|
||||
|
||||
while (drops.length < count) {
|
||||
if (rng.chance(0.3)) {
|
||||
const v = rng.int(2, 9);
|
||||
drops.push({ kind: 'coins', value: v, label: `$${v} in shrapnel from under the booth seats` });
|
||||
} else {
|
||||
const v = NOTE_VALUES[rng.int(0, NOTE_VALUES.length - 1)] ?? 5;
|
||||
drops.push({ kind: 'note', value: v, label: `a folded $${v} — the floor pays better than the till` });
|
||||
}
|
||||
}
|
||||
return drops;
|
||||
}
|
||||
@ -1,334 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
import type { HeatStrike, NightState } from '../data/types';
|
||||
|
||||
/** CONTRACTS.md §3: heatStrikes is "run-scoped, max 3". Design §2: 3 = licence gone. */
|
||||
export const MAX_HEAT_STRIKES = 3;
|
||||
|
||||
/**
|
||||
* Whether a strike is worth recording. Two guards, both contract-driven:
|
||||
* past three the run is already over, and a breach that persists (over capacity
|
||||
* for an hour) is ONE offence the inspector writes up, not one per patron.
|
||||
*/
|
||||
export function shouldRecordStrike(state: NightState, strike: HeatStrike): boolean {
|
||||
if (state.heatStrikes.length >= MAX_HEAT_STRIKES) return false;
|
||||
return !state.heatStrikes.some((s) => s.reason === strike.reason);
|
||||
}
|
||||
@ -17,7 +17,6 @@ const TELL_ORDER = [
|
||||
'jokeName',
|
||||
'photoMismatch',
|
||||
'expired',
|
||||
'passback',
|
||||
] as const;
|
||||
|
||||
export type IdTell = (typeof TELL_ORDER)[number];
|
||||
@ -63,9 +62,6 @@ export function idVerdict(id: IdCard, nightDate: Date): IdVerdict {
|
||||
// The generator ships fakes whose ONLY tell is the date, with no fake flags
|
||||
// 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]);
|
||||
|
||||
|
||||
@ -1,356 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -1,156 +0,0 @@
|
||||
// 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.
|
||||
@ -1,9 +1,7 @@
|
||||
import { violations, type DoorDressCodeRule } from './dressCode';
|
||||
import { idVerdict, type IdVerdict } from './idCheck';
|
||||
import type { DeferredHit, DoorOutcome, JudgeContext } from './doorTypes';
|
||||
import { lapLine, ripenLine } from '../data/strings/door';
|
||||
import { CONTRABAND } from '../data/contraband';
|
||||
import { drunkStage } from './drunk';
|
||||
import { lapLine } from '../data/strings/door';
|
||||
import type { Patron, Verdict } from '../data/types';
|
||||
|
||||
// The scoring function — the one place this game's morality is a number. Every
|
||||
@ -15,24 +13,14 @@ 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 — 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,
|
||||
/** Deny someone who genuinely broke something. The job, done right. */
|
||||
denyViolatorVibe: 6,
|
||||
/** The queue still watched a stranger get humiliated. */
|
||||
denyViolatorAggro: 1,
|
||||
|
||||
/** Deny someone with nothing wrong with them. Small reward... */
|
||||
denyCleanVibe: 1,
|
||||
denyCleanVibe: 2,
|
||||
/** ...large heat. The whole line saw that there was no reason. */
|
||||
denyCleanAggro: 7,
|
||||
|
||||
@ -47,12 +35,12 @@ export const JUDGE_TUNING = {
|
||||
/** A regular you have knocked back before. He kept count. */
|
||||
denyGrudgedRegularVibe: -5,
|
||||
|
||||
admitCleanVibe: 2,
|
||||
admitCleanVibe: 3,
|
||||
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: 1,
|
||||
admitBadIdVibe: 2,
|
||||
|
||||
/** "Dazza does a lap": the breach lands 2..5 minutes after you could argue. */
|
||||
lapDelayMin: 2,
|
||||
@ -63,10 +51,6 @@ 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,
|
||||
@ -78,23 +62,6 @@ 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
|
||||
@ -114,15 +81,10 @@ 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, ctx);
|
||||
return verdict === 'admit' ? judgeAdmit(patron, broken, id, ctx) : judgeDeny(patron, broken, id);
|
||||
}
|
||||
|
||||
function judgeDeny(
|
||||
patron: Patron,
|
||||
broken: DoorDressCodeRule[],
|
||||
id: IdVerdict,
|
||||
ctx: JudgeContext,
|
||||
): DoorOutcome {
|
||||
function judgeDeny(patron: Patron, broken: DoorDressCodeRule[], id: IdVerdict): DoorOutcome {
|
||||
const T = JUDGE_TUNING;
|
||||
|
||||
// Early return on purpose. Whatever else was wrong with him, Dazza only ever
|
||||
@ -137,25 +99,8 @@ function judgeDeny(
|
||||
}
|
||||
|
||||
// "Genuinely violates something": the dress code, a card that reads under 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;
|
||||
// or fake, or an actual minor regardless of what the card claimed.
|
||||
const justified = broken.length > 0 || id.underage || id.looksFake || patron.age < 18;
|
||||
|
||||
let vibeDelta = justified ? T.denyViolatorVibe : T.denyCleanVibe;
|
||||
let aggroDelta = justified ? T.denyViolatorAggro : T.denyCleanAggro;
|
||||
@ -229,22 +174,6 @@ 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
|
||||
@ -271,28 +200,12 @@ function judgeAdmit(
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.clockMin >= LOCKOUT_MIN) {
|
||||
// One stable reason: the inspector writes up "traded after lockout" once,
|
||||
// however many you let through. (Letting many through still bleeds vibe
|
||||
// via laps/ripens like any other admit — the strike is the licence part.)
|
||||
deferred.push({
|
||||
atClockMin: T.auditClockMin,
|
||||
heatStrike: { reason: 'admission after the 1:30 lockout', deferred: true },
|
||||
reason: `lockout breach — ${patron.id} admitted at clock ${ctx.clockMin}`,
|
||||
patronId: patron.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.insideCount >= ctx.licensed) {
|
||||
// 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.
|
||||
const reason = `capacity breach — ${patron.id} admitted at ${ctx.insideCount}/${ctx.licensed}`;
|
||||
deferred.push({
|
||||
atClockMin: T.auditClockMin,
|
||||
heatStrike: { reason: 'room over licensed capacity', deferred: true },
|
||||
reason: `capacity breach — ${patron.id} admitted at ${ctx.insideCount}/${ctx.licensed}`,
|
||||
heatStrike: { reason, deferred: true },
|
||||
reason,
|
||||
patronId: patron.id,
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,52 +0,0 @@
|
||||
// ROOM (design §6): the techno is so loud every line at the rope is a lip-read
|
||||
// guess. Pure text transform, deterministic per (salt, word) — the same patron
|
||||
// mouths the same guess twice, because re-asking doesn't reroll reality.
|
||||
//
|
||||
// Tone guard: the comedy is the player's OWN uncertain reading, never the
|
||||
// speaker's — everyone in ROOM is perfectly articulate, you just can't hear.
|
||||
|
||||
/** What the player's brain offers when a word is half-caught. Club-adjacent
|
||||
* nonsense on purpose — a guess should be worse than the truth. */
|
||||
const MISHEARINGS: readonly string[] = [
|
||||
'FIST', 'GOAT', 'CHIPS', 'NAN', 'TAX', 'DECAF', 'PILATES', 'HORSE',
|
||||
'COUNCIL', 'SOUP', 'LADDER', 'BINS', 'SHREK', 'MULCH',
|
||||
];
|
||||
|
||||
const LOST = '[???]';
|
||||
|
||||
/** FNV-1a — cheap, stable, and never Math.random (CONTRACTS.md). */
|
||||
function hash(s: string): number {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* `"mate im on the list"` → `"[MATE] [IM?] [ON THE?] [FIST? LIST?]"`-shaped
|
||||
* guesses. Every token is bracketed (the brackets ARE the deafness); short
|
||||
* words survive better than long ones; some words arrive as two competing
|
||||
* guesses and some don't arrive at all.
|
||||
*/
|
||||
export function lipReadLine(line: string, salt: string): string {
|
||||
const words = line.split(/\s+/).filter(Boolean);
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
const clean = (words[i] ?? '').replace(/[^a-z0-9']/gi, '').toUpperCase();
|
||||
if (clean.length === 0) continue;
|
||||
const h = hash(`${salt}:${i}:${clean}`);
|
||||
const roll = h % 100;
|
||||
if (roll < 12) {
|
||||
out.push(LOST);
|
||||
} else if (roll < 28 && clean.length >= 4) {
|
||||
out.push(`[${MISHEARINGS[h % MISHEARINGS.length]}? ${clean}?]`);
|
||||
} else if (roll < 60) {
|
||||
out.push(`[${clean}?]`);
|
||||
} else {
|
||||
out.push(`[${clean}]`);
|
||||
}
|
||||
}
|
||||
return out.length > 0 ? out.join(' ') : LOST;
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
// Radio command (design §6, the Head of Security finale): NPC staff work the
|
||||
// roles you aren't, and the radio is how you reach them. You used to speak
|
||||
// into the radio; now you're the voice on the other end. Competence varies —
|
||||
// that IS the staff. Pure numbers here; the scenes own the walkie theatre.
|
||||
|
||||
/** Orders the floor staff take. 'mess' is Bump the glassie, 'water' is Shan. */
|
||||
export type FloorOrderKind = 'mess' | 'water';
|
||||
|
||||
export type OrderAck = 'sent' | 'cooldown' | 'nothing';
|
||||
|
||||
export const RADIO_COOLDOWN_MS: Readonly<Record<FloorOrderKind, number>> = {
|
||||
mess: 45_000,
|
||||
water: 60_000,
|
||||
};
|
||||
|
||||
/** Staff cross a real floor — an order lands, then happens. */
|
||||
export const ORDER_LAG_MS: readonly [number, number] = [3000, 7000];
|
||||
|
||||
/** Bump mops AND signs (in that order, worryingly) this often; else he signs
|
||||
* it and finds a phone in the puddle. */
|
||||
export const BUMP_MOP_CHANCE = 0.65;
|
||||
/** Shan gets the water in without a negotiation this often; else she leads
|
||||
* with the phrase "you've had enough". */
|
||||
export const SHAN_CLEAN_CHANCE = 0.8;
|
||||
/** A firm two cups. */
|
||||
export const SHAN_WATER_STEP = 0.14;
|
||||
export const SHAN_BOTCH_AGGRO = 2;
|
||||
export const ORDER_DONE_VIBE = 1;
|
||||
/** Shan only bothers with loose and worse. */
|
||||
export const SHAN_MIN_INTOX = 0.45;
|
||||
|
||||
/** Kayden holds the rope this long, then resumes ruling. Badly. */
|
||||
export const KAYDEN_HOLD_MS = 60_000;
|
||||
export const KAYDEN_HOLD_COOLDOWN_MS = 90_000;
|
||||
|
||||
export function rollBumpMops(rng: { chance(p: number): boolean }): boolean {
|
||||
return rng.chance(BUMP_MOP_CHANCE);
|
||||
}
|
||||
|
||||
export function rollShanClean(rng: { chance(p: number): boolean }): boolean {
|
||||
return rng.chance(SHAN_CLEAN_CHANCE);
|
||||
}
|
||||
|
||||
export function orderLag(rng: { int(min: number, max: number): number }): number {
|
||||
return rng.int(ORDER_LAG_MS[0], ORDER_LAG_MS[1]);
|
||||
}
|
||||
@ -1,90 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -1,37 +1,26 @@
|
||||
import Phaser from 'phaser';
|
||||
import { renderDoll } from '../../patrons/doll';
|
||||
import { dollPlan } from '../../patrons/dollPlan';
|
||||
import { activeRules, announcedRules, shuffleWindow, violations, type DoorDressCodeRule } from '../../rules/dressCode';
|
||||
import { drunkStage } from '../../rules/drunk';
|
||||
import { violations, type DoorDressCodeRule } from '../../rules/dressCode';
|
||||
import { idVerdict, formatDate } from '../../rules/idCheck';
|
||||
import { LOCKOUT_MIN, judge } from '../../rules/judge';
|
||||
import { judge } from '../../rules/judge';
|
||||
import type { DoorOutcome } from '../../rules/doorTypes';
|
||||
import { 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 { 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 { QueueManager } from './QueueManager';
|
||||
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';
|
||||
|
||||
@ -44,6 +33,7 @@ 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
|
||||
@ -69,13 +59,9 @@ 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;
|
||||
@ -92,31 +78,12 @@ 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() {
|
||||
@ -128,85 +95,19 @@ export class DoorScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
create(): void {
|
||||
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.queue = new QueueManager(this.night.bus, this.night.rng, this.night.nightDate);
|
||||
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);
|
||||
// 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('clock:tick', ({ clockMin }) => this.queue.onClockMinute(clockMin)),
|
||||
this.night.bus.on('meters:changed', ({ vibe, aggro, hype }) => {
|
||||
this.vibeBar.set(vibe / 100, vibe < 20);
|
||||
this.aggroBar.set(aggro / 100, aggro > 80);
|
||||
@ -218,18 +119,7 @@ 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);
|
||||
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,
|
||||
);
|
||||
if (isDoorRule(rule)) this.codeCard.add(this, rule, this.elapsedMs);
|
||||
}),
|
||||
this.night.bus.on('heat:strike', () => this.refreshCounters()),
|
||||
this.night.bus.on('night:phaseChange', ({ location }) => {
|
||||
@ -257,13 +147,7 @@ export class DoorScene extends Phaser.Scene {
|
||||
// ---------- construction ----------
|
||||
|
||||
private buildStreet(): void {
|
||||
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, 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);
|
||||
|
||||
@ -272,9 +156,6 @@ 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
|
||||
@ -299,21 +180,6 @@ 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();
|
||||
}
|
||||
@ -358,17 +224,7 @@ 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);
|
||||
|
||||
// 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();
|
||||
this.codeCard = new DressCodeCard(this, 6, 256, 168, 98);
|
||||
|
||||
// ID tray
|
||||
panel(this, 180, 256, 96, 98).setDepth(30);
|
||||
@ -408,15 +264,10 @@ export class DoorScene extends Phaser.Scene {
|
||||
|
||||
// verdicts
|
||||
this.buttons.rope = new Button(this, {
|
||||
x: 442, y: 256, w: 130, h: 24, label: DOOR_UI.rope, size: 9,
|
||||
x: 442, y: 256, w: 192, h: 24, label: DOOR_UI.rope, size: 9,
|
||||
fill: 0x5a2030, hover: 0x8a2038,
|
||||
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(),
|
||||
@ -439,36 +290,6 @@ 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);
|
||||
@ -515,33 +336,12 @@ export class DoorScene extends Phaser.Scene {
|
||||
this.showToast(DOOR_UI.ropeEmpty);
|
||||
return;
|
||||
}
|
||||
this.presentUp(p);
|
||||
}
|
||||
|
||||
/** Everything that happens once SOMEBODY is at the rope — the rope and the
|
||||
* carpet recall share this path, so a recalled patron is a full patron. */
|
||||
private presentUp(p: Patron): void {
|
||||
this.testedThisPatron = false;
|
||||
this.pattedThisPatron = false;
|
||||
// Passback: this exact card already went in tonight. The tell is set HERE —
|
||||
// it is the DESK's memory, not the generator's — so Kayden (who never
|
||||
// checks) waves the same card through all night.
|
||||
if (this.seenCards.has(`${p.idCard.name}|${p.idCard.dob}`)) {
|
||||
p.idCard.fake = { ...(p.idCard.fake ?? {}), passback: true };
|
||||
}
|
||||
this.night.sfx?.play('ropeUnhook');
|
||||
this.up.show(p, this.queue.encounterFor(p));
|
||||
if (this.frankoTips > 0 && this.queue.encounterFor(p) === undefined) {
|
||||
this.frankoTips--;
|
||||
const tip = this.frankoTip(p);
|
||||
this.time.delayedCall(1700, () => this.showToast(tip));
|
||||
}
|
||||
this.up.show(p);
|
||||
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,
|
||||
@ -551,96 +351,6 @@ export class DoorScene extends Phaser.Scene {
|
||||
});
|
||||
}
|
||||
|
||||
/** Door-side radio: order the floor staff without leaving the rope. */
|
||||
private radioFloorOrder(kind: 'mess' | 'water'): void {
|
||||
const ack = this.night.floorOrder?.(kind, (msg) => {
|
||||
if (this.present) this.showToast(msg);
|
||||
});
|
||||
if (ack === 'sent') this.showToast(kind === 'mess' ? STAFF_RADIO.bumpAck : STAFF_RADIO.shanAck);
|
||||
else if (ack === 'cooldown') this.showToast(STAFF_RADIO.cooldown);
|
||||
else if (ack === 'nothing') this.showToast(kind === 'mess' ? STAFF_RADIO.bumpNothing : STAFF_RADIO.shanNothing);
|
||||
}
|
||||
|
||||
// ---- the red carpet ----------------------------------------------------
|
||||
|
||||
/** The patron at the rope takes position between the brass posts. */
|
||||
private callCarpet(): void {
|
||||
const p = this.queue.patronUp;
|
||||
if (!p || this.busy) return;
|
||||
if (this.queue.toCarpet() === null) {
|
||||
this.showToast(CARPET_UI.full);
|
||||
return;
|
||||
}
|
||||
this.idCard.close();
|
||||
this.trayCard?.destroy();
|
||||
this.trayCard = null;
|
||||
this.up.clear();
|
||||
this.setVerdictButtons(false);
|
||||
this.night.sfx?.play('ropeUnhook');
|
||||
this.showToast(CARPET_UI.sent);
|
||||
this.logIncidentDoor('carpet', p.id, 'Sent to the carpet to be seen waiting.');
|
||||
}
|
||||
|
||||
/** Click a carpet doll: wave them up to the rope for the actual ruling. */
|
||||
private recallCarpet(patronId: string): void {
|
||||
if (this.busy) return;
|
||||
if (this.queue.patronUp) {
|
||||
this.showToast(CARPET_UI.recallBusy);
|
||||
return;
|
||||
}
|
||||
const p = this.queue.recallFromCarpet(patronId);
|
||||
if (!p) return;
|
||||
this.presentUp(p);
|
||||
}
|
||||
|
||||
private logIncidentDoor(kind: string, patronId: string | undefined, detail: string): void {
|
||||
this.night.bus.emit('incident:log', { clockMin: this.night.state.clockMin, kind, patronId, detail });
|
||||
}
|
||||
|
||||
/** The carpet is a view of QueueManager, same discipline as the queue line. */
|
||||
private syncCarpetSprites(): void {
|
||||
const slots = this.queue.carpetSnapshot;
|
||||
const same =
|
||||
slots.length === this.carpetSprites.length &&
|
||||
slots.every((s, i) => this.carpetSprites[i]?.patron.id === s.patron.id);
|
||||
if (!same) {
|
||||
for (const s of this.carpetSprites) s.image.destroy();
|
||||
this.carpetSprites = slots.map((slot, i) => {
|
||||
const x = 372 + i * 34;
|
||||
const img = this.add
|
||||
.image(x, QUEUE_FOOT_Y + 6, renderDoll(this, slot.patron, 'queue'))
|
||||
.setOrigin(0.5, 1)
|
||||
.setScale(1.5)
|
||||
.setDepth(205)
|
||||
.setInteractive({ useHandCursor: true });
|
||||
img.on('pointerdown', () => this.recallCarpet(slot.patron.id));
|
||||
img.setData('baseX', x);
|
||||
return { patron: slot.patron, image: img, link: null };
|
||||
});
|
||||
}
|
||||
// Impatience is visible: the sway grows as the patience runs out.
|
||||
for (let i = 0; i < this.carpetSprites.length; i++) {
|
||||
const s = this.carpetSprites[i]!;
|
||||
const slot = slots[i];
|
||||
if (!slot) continue;
|
||||
const fuse = Math.min(1, slot.standMs / slot.patienceMs);
|
||||
s.image.x = s.image.getData('baseX') + Math.sin(this.elapsedMs / (300 - 140 * fuse)) * (1 + fuse * 3);
|
||||
}
|
||||
|
||||
for (const p of this.queue.takeStorms()) {
|
||||
this.showToast(CARPET_UI.storm);
|
||||
this.night.sfx?.play('doorBang');
|
||||
this.logIncidentDoor('carpetStorm', p.id, 'Ran out of patience on the carpet and left, loudly.');
|
||||
}
|
||||
}
|
||||
|
||||
/** Nudge the player toward a tab that just became relevant, without switching it. */
|
||||
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);
|
||||
@ -722,15 +432,6 @@ 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
|
||||
@ -785,161 +486,6 @@ 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,
|
||||
@ -1012,7 +558,6 @@ 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,
|
||||
@ -1022,29 +567,6 @@ export class DoorScene extends Phaser.Scene {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rules cap at ACTIVE_RULE_CAP (Dazza's attention span). When a new one lands,
|
||||
* grey out whatever fell off the card and let Dazza announce the retraction —
|
||||
* judge() and the card must agree about what is live, or the card lies.
|
||||
*/
|
||||
private retireStaleRules(): void {
|
||||
const live = new Set(activeRules(this.night.state.clockMin).map((r) => r.id));
|
||||
for (const r of announcedRules(this.night.state.clockMin)) {
|
||||
if (live.has(r.id)) continue;
|
||||
// Always re-sync the card: Dazza's texts can LAG the clock (the send
|
||||
// cooldown queues them), so a rule can be rescinded before its card row
|
||||
// has even landed. retire() is an idempotent no-op until the row exists,
|
||||
// and every later text re-runs this sync — the card converges.
|
||||
this.codeCard.retire(r.id);
|
||||
if (this.retiredRules.has(r.id)) continue; // toast once per rule, though
|
||||
this.retiredRules.add(r.id);
|
||||
const line = RULE_RETRACTIONS[this.retiredRules.size % RULE_RETRACTIONS.length]!;
|
||||
// A retraction is a TEXT — it lands on the phone with a buzz like every
|
||||
// other pronouncement, and lives in the thread as evidence.
|
||||
this.night.bus.emit('dazza:text', { text: line.replace('{rule}', r.short).replace('dazza: ', '') });
|
||||
}
|
||||
}
|
||||
|
||||
private refreshCounters(): void {
|
||||
const cap = this.night.state.capacity;
|
||||
this.clickerText.setText(String(cap.clickerShown));
|
||||
@ -1068,36 +590,20 @@ 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.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();
|
||||
}
|
||||
if (!this.present) {
|
||||
this.kaydenAccMs += deltaMs;
|
||||
while (this.kaydenAccMs >= KAYDEN_RULE_MS) {
|
||||
this.kaydenAccMs -= KAYDEN_RULE_MS;
|
||||
this.kaydenRule();
|
||||
}
|
||||
}
|
||||
|
||||
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.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.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) {
|
||||
@ -1156,8 +662,6 @@ 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();
|
||||
|
||||
@ -14,8 +14,6 @@ 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 {
|
||||
@ -31,7 +29,10 @@ export class DressCodeCard {
|
||||
this.root = scene.add.container(0, 0).setDepth(50);
|
||||
|
||||
this.root.add(panel(scene, x, y, w, h, 0x1a2018, 0x3c4a38));
|
||||
this.emptyText = text(scene, x + 5, y + 6, DOOR_UI.dressCodeEmpty, 7, DOOR_PALETTE.inkDim);
|
||||
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.root.add(this.emptyText);
|
||||
}
|
||||
|
||||
@ -39,7 +40,7 @@ export class DressCodeCard {
|
||||
if (this.rows.some((r) => r.rule.id === rule.id)) return;
|
||||
this.emptyText.setVisible(false);
|
||||
|
||||
const y = this.y + 6 + this.rows.length * 11;
|
||||
const y = this.y + 20 + this.rows.length * 11;
|
||||
const label = scene.add.text(this.x + 5, y, `· ${rule.short}`, {
|
||||
fontFamily: MONO,
|
||||
fontSize: '7px',
|
||||
@ -59,47 +60,20 @@ 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();
|
||||
}
|
||||
|
||||
@ -133,16 +133,6 @@ 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));
|
||||
|
||||
@ -1,215 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -3,33 +3,26 @@ 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, vibeCoolingPerMin } from '../../core/meters';
|
||||
import { Meters, freshNightState } from '../../core/meters';
|
||||
import { loadGame, saveGame, clearSave, freshGameState } from '../../core/save';
|
||||
import { _resetPatronSerial } from '../../patrons/generator';
|
||||
import { ruleById, setDressCodeShuffle } from '../../rules/dressCode';
|
||||
import { ruleById } 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;
|
||||
|
||||
import { MAX_HEAT_STRIKES, shouldRecordStrike } from '../../rules/heat';
|
||||
export { MAX_HEAT_STRIKES, shouldRecordStrike };
|
||||
/** CONTRACTS.md §3: heatStrikes is "run-scoped, max 3". Design §2: 3 = licence gone. */
|
||||
export const MAX_HEAT_STRIKES = 3;
|
||||
|
||||
/** Thu → Fri → Sat at The Royal. Index = nightIndex. */
|
||||
export const NIGHT_DATES = ['2026-07-16', '2026-07-17', '2026-07-18'] as const;
|
||||
@ -42,6 +35,10 @@ 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';
|
||||
|
||||
@ -63,34 +60,16 @@ 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. */
|
||||
@ -106,33 +85,8 @@ 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 {
|
||||
@ -142,25 +96,16 @@ 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;
|
||||
@ -172,7 +117,7 @@ export class NightScene extends Phaser.Scene {
|
||||
super('Night');
|
||||
}
|
||||
|
||||
init(data: { seed?: number; nightIndex?: number; freshRun?: boolean; role?: string }): void {
|
||||
init(data: { seed?: number; nightIndex?: number; freshRun?: boolean }): void {
|
||||
if (data.freshRun) clearSave();
|
||||
this.run = loadGame(typeof data.seed === 'number' ? data.seed : 4207);
|
||||
if (typeof data.seed === 'number' && this.run.seed !== data.seed) {
|
||||
@ -180,11 +125,8 @@ export class NightScene extends Phaser.Scene {
|
||||
}
|
||||
this.nightIndex = data.nightIndex ?? this.run.nightIndex;
|
||||
if (this.nightIndex >= TOTAL_NIGHTS) this.nightIndex = 0;
|
||||
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;
|
||||
// Each night is its own deterministic world derived from the run seed.
|
||||
this.seed = this.run.seed + this.nightIndex * 101;
|
||||
}
|
||||
|
||||
create(): void {
|
||||
@ -199,16 +141,9 @@ export class NightScene extends Phaser.Scene {
|
||||
|
||||
this.bus = new EventBus();
|
||||
this.rng = new SeededRNG(this.seed);
|
||||
const clockCfg: ClockConfig = {
|
||||
...DEFAULT_CLOCK,
|
||||
nightDate: nightDateFor(NIGHT_DATES[this.nightIndex]!, this.run.venueIndex),
|
||||
};
|
||||
const clockCfg: ClockConfig = { ...DEFAULT_CLOCK, nightDate: NIGHT_DATES[this.nightIndex]! };
|
||||
this.clock = new GameClock(this.bus, clockCfg);
|
||||
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);
|
||||
this.state = freshNightState('theRoyal', this.nightIndex, LICENSED_CAPACITY);
|
||||
// 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);
|
||||
@ -217,8 +152,6 @@ 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();
|
||||
@ -239,10 +172,6 @@ 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(
|
||||
@ -253,33 +182,13 @@ export class NightScene extends Phaser.Scene {
|
||||
this.bus.on('floor:ejection', () => {
|
||||
this.state.capacity.inside = Math.max(0, this.state.capacity.inside - 1);
|
||||
}),
|
||||
this.bus.on('floor:leave', () => {
|
||||
this.state.capacity.inside = Math.max(0, this.state.capacity.inside - 1);
|
||||
}),
|
||||
this.bus.on('audio:unlocked', () => {
|
||||
this.stubBeat?.stop();
|
||||
this.stubBeat = null;
|
||||
this.ambience?.start();
|
||||
}),
|
||||
this.bus.on('radio:call', () => this.sfx?.play('radioChirp')),
|
||||
this.bus.on('incident:log', (inc) => {
|
||||
if (inc.kind === 'encounter' && inc.detail.includes(':')) {
|
||||
// The door logs encounters as "<id>: let in/turned away" — the id is
|
||||
// what gates repeats across the week.
|
||||
const encId = inc.detail.split(':')[0]!;
|
||||
const seen = (this.run.seenEncounters ??= []);
|
||||
if (!seen.includes(encId)) seen.push(encId);
|
||||
}
|
||||
if (inc.kind === 'maggotUnhandled') {
|
||||
this.maggotLive = true;
|
||||
if (this.state.location === 'door') this.maggotRadio();
|
||||
}
|
||||
// A swing on the floor while you're at the rope: the radio tells you.
|
||||
if (inc.kind === 'fight' && this.state.location === 'door' && inc.detail.startsWith('swung')) {
|
||||
this.bus.emit('radio:call', { urgency: 3, line: 'there was a SWING inside. get in here. NOW' });
|
||||
}
|
||||
// The floor cleared them out; the room is legal again.
|
||||
if (inc.kind === 'ejection' || inc.kind === 'cutOff') this.maggotLive = false;
|
||||
if (inc.kind === 'maggotUnhandled' && this.state.location === 'door') this.maggotRadio();
|
||||
}),
|
||||
);
|
||||
|
||||
@ -291,65 +200,21 @@ 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();
|
||||
|
||||
// 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();
|
||||
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'));
|
||||
}
|
||||
|
||||
// ---- location ----------------------------------------------------------
|
||||
@ -382,20 +247,7 @@ 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);
|
||||
// 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 === 'deny') this.log.denied++;
|
||||
else if (verdict === 'sobrietyTest') this.log.tested++;
|
||||
else if (verdict === 'patDown') this.log.pattedDown++;
|
||||
}
|
||||
@ -423,41 +275,12 @@ 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) {
|
||||
@ -515,7 +338,6 @@ 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);
|
||||
@ -548,53 +370,20 @@ export class NightScene extends Phaser.Scene {
|
||||
const runOver = reason === 'licence';
|
||||
const survived = reason === 'clock';
|
||||
const weekDone = survived && this.nightIndex >= TOTAL_NIGHTS - 1;
|
||||
const seasonDone = weekDone && this.run.venueIndex >= TOTAL_VENUES - 1;
|
||||
this.run.heatStrikes = [...this.state.heatStrikes];
|
||||
// pastReports is written by IncidentReportScene now, not here: Phase 4
|
||||
// audits what the player CLAIMED happened, and a raw truth dump would have
|
||||
// nothing to catch them out with.
|
||||
this.run.pastReports.push([...this.state.incidents]);
|
||||
if (survived) this.run.nightIndex = this.nightIndex + 1;
|
||||
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) {
|
||||
if (runOver || weekDone) {
|
||||
clearSave();
|
||||
} else {
|
||||
saveGame(this.run);
|
||||
}
|
||||
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,
|
||||
};
|
||||
const runInfo: RunInfo = { nightIndex: this.nightIndex, totalNights: TOTAL_NIGHTS, runOver, weekDone };
|
||||
|
||||
this.time.delayedCall(reason === 'clock' ? 600 : 1400, () => {
|
||||
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();
|
||||
}
|
||||
this.scene.stop('Door');
|
||||
this.scene.stop('Floor');
|
||||
this.scene.start('NightSummary', { log: this.log, state: this.state, run: runInfo });
|
||||
});
|
||||
}
|
||||
|
||||
@ -605,17 +394,9 @@ 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;
|
||||
|
||||
@ -15,13 +15,11 @@ 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 = log.wage;
|
||||
const wage = 180;
|
||||
const hypeBonus = Math.round((log.hypePeak - 1) * 90);
|
||||
const doorBonus = log.admitted * 2;
|
||||
const strikes = state.heatStrikes.length * 60;
|
||||
// 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;
|
||||
return Math.max(0, wage + hypeBonus + doorBonus - strikes);
|
||||
}
|
||||
|
||||
export function signoffFor(vibe: number, strikes: number): string {
|
||||
@ -65,8 +63,8 @@ export class NightSummaryScene extends Phaser.Scene {
|
||||
W / 2,
|
||||
46,
|
||||
failed
|
||||
? `${this.run?.venue.name ?? this.state.venueId} · ${nightName} · night over early`
|
||||
: `${SUMMARY_UI.subtitle}${nightName ? ` · ${nightName}` : ''} · shift: ${this.log.role.toLowerCase()} $${this.log.wage}`,
|
||||
? `${this.state.venueId} · ${nightName} · night over early`
|
||||
: `${SUMMARY_UI.subtitle}${nightName ? ` · ${nightName}` : ''}`,
|
||||
{
|
||||
fontFamily: MONO,
|
||||
fontSize: '8px',
|
||||
@ -89,17 +87,8 @@ export class NightSummaryScene extends Phaser.Scene {
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
// 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);
|
||||
this.input.once('pointerdown', next);
|
||||
this.input.keyboard?.once('keydown-SPACE', next);
|
||||
}
|
||||
|
||||
/** What clicking through leads to: the next shift, or a fresh run. */
|
||||
@ -108,27 +97,19 @@ export class NightSummaryScene extends Phaser.Scene {
|
||||
if (!r) {
|
||||
return {
|
||||
prompt: SUMMARY_UI.replay,
|
||||
next: () => this.scene.start('Roster', { seed: this.log.seed + 1 }),
|
||||
next: () => this.scene.start('Night', { seed: this.log.seed + 1 }),
|
||||
};
|
||||
}
|
||||
if (r.runOver) {
|
||||
return {
|
||||
prompt: 'licence gone · click to start a fresh week somewhere that has not heard of you',
|
||||
next: () => this.scene.start('Roster', { freshRun: true, seed: this.log.seed + 13 }),
|
||||
next: () => this.scene.start('Night', { freshRun: true, seed: this.log.seed + 13 }),
|
||||
};
|
||||
}
|
||||
if (r.seasonDone) {
|
||||
if (r.weekDone) {
|
||||
return {
|
||||
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', {}),
|
||||
prompt: 'WEEK SURVIVED. click for a new week (new seed, same rain)',
|
||||
next: () => this.scene.start('Night', { freshRun: true, seed: this.log.seed + 7 }),
|
||||
};
|
||||
}
|
||||
const failedNight = this.log.endReason !== 'clock';
|
||||
@ -137,7 +118,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('Roster', {}),
|
||||
next: () => this.scene.start('Night', {}),
|
||||
};
|
||||
}
|
||||
|
||||
@ -189,10 +170,6 @@ 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) => {
|
||||
|
||||
@ -2,10 +2,8 @@ import Phaser from 'phaser';
|
||||
import { renderDoll } from '../../patrons/doll';
|
||||
import { dollPlan } from '../../patrons/dollPlan';
|
||||
import { drunkStage } from '../../rules/drunk';
|
||||
import { lipReadLine } from '../../rules/lipRead';
|
||||
import type { Patron } from '../../data/types';
|
||||
import { CLAIM_LINES, DRUNK_GREETINGS, OWNER_HINTS, PATRON_GREETINGS, REGULAR_DISGUISED_LINES, REGULAR_RETURN_LINES } from '../../data/strings/door';
|
||||
import { encounterById, type EncounterId } from '../../rules/encounters';
|
||||
import { CLAIM_LINES, DRUNK_GREETINGS, PATRON_GREETINGS } from '../../data/strings/door';
|
||||
import { MONO } from './ui';
|
||||
import { ZONE_BANDS, ZONE_ORDER, describeZone, greetingIndex, type InspectZone } from './inspect';
|
||||
|
||||
@ -24,10 +22,6 @@ 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,
|
||||
@ -40,7 +34,7 @@ export class PatronUpView {
|
||||
return this.patron;
|
||||
}
|
||||
|
||||
show(p: Patron, encounter?: EncounterId): void {
|
||||
show(p: Patron): void {
|
||||
this.clear();
|
||||
this.patron = p;
|
||||
const { scene } = this;
|
||||
@ -70,7 +64,7 @@ export class PatronUpView {
|
||||
root.add(hit);
|
||||
}
|
||||
|
||||
this.speak(p, encounter);
|
||||
this.speak(p);
|
||||
|
||||
// step-up: they walk in from the queue side and settle
|
||||
root.x = this.x - 90;
|
||||
@ -78,43 +72,21 @@ export class PatronUpView {
|
||||
scene.tweens.add({ targets: root, x: this.x, alpha: 1, duration: 380, ease: 'Quad.easeOut' });
|
||||
}
|
||||
|
||||
private speak(p: Patron, encounter?: EncounterId): void {
|
||||
private speak(p: Patron): 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);
|
||||
// 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 pool =
|
||||
p.flags.claimsGuestList || p.flags.knowsOwner
|
||||
? 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, this.lipRead ? lipReadLine(line, p.id) : `"${line}"`, {
|
||||
.text(0, bubbleY, `"${line}"`, {
|
||||
fontFamily: MONO,
|
||||
fontSize: '8px',
|
||||
color: '#d8d0c0',
|
||||
@ -126,44 +98,10 @@ 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;
|
||||
@ -253,7 +191,6 @@ export class PatronUpView {
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.clearBubble();
|
||||
this.hideTooltip();
|
||||
this.root?.destroy();
|
||||
this.root = null;
|
||||
|
||||
@ -3,22 +3,12 @@ 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?"
|
||||
@ -44,15 +34,8 @@ 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
|
||||
* (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,
|
||||
/** aggro bled off per real second with nobody waiting at all */
|
||||
calmPerSec: 0.9,
|
||||
/** looking at your phone while someone waits: pure theatre, pure profit */
|
||||
phoneHype: 0.12,
|
||||
phoneCooldownMs: 2500,
|
||||
@ -65,47 +48,8 @@ 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[];
|
||||
@ -126,48 +70,16 @@ 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');
|
||||
@ -176,42 +88,6 @@ 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());
|
||||
@ -250,14 +126,6 @@ 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) {
|
||||
@ -268,7 +136,7 @@ export class QueueManager {
|
||||
|
||||
while (clockMin >= this.nextArrivalMin) {
|
||||
this.spawnArrival();
|
||||
const gap = nextArrivalGapMin(this.nextArrivalMin, this.arrivalRng.next(), this.arrivalScale);
|
||||
const gap = nextArrivalGapMin(this.nextArrivalMin, this.arrivalRng.next());
|
||||
this.nextArrivalMin += Math.max(1, Math.round(gap));
|
||||
}
|
||||
}
|
||||
@ -277,7 +145,6 @@ 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) {
|
||||
@ -305,82 +172,6 @@ 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. */
|
||||
@ -444,63 +235,8 @@ 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 {
|
||||
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;
|
||||
return generatePatron({ rng: this.rng, nightDate: this.nightDate }, this.clockMin);
|
||||
}
|
||||
|
||||
private enqueue(p: Patron): Patron | null {
|
||||
|
||||
@ -13,16 +13,13 @@ 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.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: 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: 360, perMin: 0 }, // 3:00 AM
|
||||
];
|
||||
|
||||
@ -63,11 +60,8 @@ export function arrivalsPerMin(clockMin: number): number {
|
||||
* an RngStream; the exponential draw is what makes arrivals clump — three at once
|
||||
* then a lull — instead of ticking over like a metronome.
|
||||
*/
|
||||
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;
|
||||
export function nextArrivalGapMin(clockMin: number, roll: number): number {
|
||||
const rate = arrivalsPerMin(clockMin);
|
||||
if (!(rate > 0)) return NO_ARRIVALS_GAP_MIN;
|
||||
|
||||
const safeRoll = Number.isFinite(roll) ? Math.min(Math.max(roll, 0), 0.999999) : 0;
|
||||
|
||||
@ -11,11 +11,6 @@ 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. */
|
||||
@ -28,18 +23,9 @@ 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;
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
/** How long the door tutorial text stays fully lit before it starts to fade. */
|
||||
export const TUTORIAL_MS = 26_000;
|
||||
/** Length of the fade once the hold expires. */
|
||||
export const TUTORIAL_FADE_MS = 800;
|
||||
|
||||
/**
|
||||
* Tutorial text alpha at `elapsedMs` into the night. Pure update()-driven math,
|
||||
* deliberately NOT a tween: the old `if (alpha > 0) tweens.add(...)` guard
|
||||
* spawned a fresh 800ms tween every frame (each new tween re-captured the
|
||||
* current alpha and kept it above zero, so the guard never closed — ~1,100 live
|
||||
* tweens by night's end), and tweens stall under the preview pane's stepped
|
||||
* clock anyway.
|
||||
*/
|
||||
export function tutorialAlpha(elapsedMs: number): number {
|
||||
if (elapsedMs <= TUTORIAL_MS) return 1;
|
||||
return Math.max(0, 1 - (elapsedMs - TUTORIAL_MS) / TUTORIAL_FADE_MS);
|
||||
}
|
||||
@ -2,8 +2,7 @@ import Phaser from 'phaser';
|
||||
import { renderDoll } from '../../patrons/doll';
|
||||
import { dollPlan } from '../../patrons/dollPlan';
|
||||
import { conePolygon, type ConeSpec } from './cone';
|
||||
import { FIGHT_FUSE_MS } from './fight';
|
||||
import { LIGHTS, PROPS, TILE, tileAt, tileColour, type PropKind, type VenueMap } from './venueMap';
|
||||
import { TILE, tileAt, tileColour, type VenueMap } from './venueMap';
|
||||
import type { Agent } from './crowdSim';
|
||||
|
||||
// Pure drawing for the floor. Darkness is the mechanic: the venue is painted
|
||||
@ -36,22 +35,6 @@ const NEON_TINTS = [0xff3d9a, 0x38e0ff, 0xb45cff] as const;
|
||||
|
||||
const GLOW_SIZE = 64;
|
||||
|
||||
// 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;
|
||||
@ -76,27 +59,8 @@ 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;
|
||||
|
||||
@ -147,12 +111,6 @@ 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);
|
||||
@ -160,191 +118,6 @@ 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 {
|
||||
@ -377,14 +150,9 @@ 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;
|
||||
|
||||
// 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.sil.setPosition(x, y);
|
||||
g.detail.setPosition(x, y);
|
||||
g.uvDot.setPosition(x + 4, y + 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);
|
||||
@ -432,79 +200,7 @@ 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);
|
||||
@ -514,16 +210,6 @@ 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();
|
||||
@ -553,20 +239,6 @@ export class FloorView {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every cached sprite. The scene must call this on reseed: patron ids are
|
||||
* recycled from p0 by _resetPatronSerial(), so without it the new run's crowd
|
||||
* inherits the old run's dolls, sway and stamps.
|
||||
*/
|
||||
reset(): void {
|
||||
for (const g of this.agents.values()) this.destroyGfx(g);
|
||||
this.agents.clear();
|
||||
this.shove.clear();
|
||||
this.fightHalo.setVisible(false);
|
||||
this.fightPip.setVisible(false);
|
||||
this.marker.setVisible(false);
|
||||
}
|
||||
|
||||
private drop(id: string): void {
|
||||
const g = this.agents.get(id);
|
||||
if (!g) return;
|
||||
@ -627,139 +299,3 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
@ -1,152 +0,0 @@
|
||||
// The bartender shift (docs/SCENARIOS.md, design §6 role ladder). Same split
|
||||
// as djShift.ts: pure pricing here, tested; the scene owns the overlay, the
|
||||
// teleport and the taps.
|
||||
//
|
||||
// The shape of the fantasy: the other side of the counter. Every order is a
|
||||
// sobriety read at arm's length — the stage is written on the doll and in the
|
||||
// spelling — and SERVE / WATER / CUT OFF are all legal, all priced (§4.3).
|
||||
// Serving someone visibly cooked is the one the licence remembers.
|
||||
|
||||
import type { DrunkStage } from '../../data/types';
|
||||
import { WATER_SOBER_STEP } from './hazards';
|
||||
|
||||
export type BarCall = 'serve' | 'water' | 'cutoff';
|
||||
|
||||
/** What one served drink adds — a real pour, on top of the bar-anchor drift. */
|
||||
export const SERVE_DRINK_STEP = 0.06;
|
||||
|
||||
/** Chance-per-second an order lands while you hold the taps (~one per 18s). */
|
||||
export const ORDER_CHANCE_PER_S = 0.055;
|
||||
/** One patron doesn't re-order inside this window. */
|
||||
export const ORDER_COOLDOWN_MS = 60_000;
|
||||
|
||||
/**
|
||||
* The RSA line (rules/drunk.ts stages): messy and maggot are the breach.
|
||||
* 'loose' has visible tells but stays legal — that band is where the player
|
||||
* has to actually decide something, same as the floor cut-off.
|
||||
*/
|
||||
export function isRsaBreach(stage: DrunkStage): boolean {
|
||||
return stage === 'messy' || stage === 'maggot';
|
||||
}
|
||||
|
||||
/** Stable reason so shouldRecordStrike folds a whole bad shift into ONE write-up. */
|
||||
export const RSA_STRIKE_REASON = 'served alcohol to a visibly intoxicated patron';
|
||||
/** A breach pour only becomes a strike if somebody official SAW it. */
|
||||
export const BREACH_SEEN_CHANCE = 0.35;
|
||||
/** Clock-minutes later the write-up lands: base + up-to spread. */
|
||||
export const BREACH_SEEN_AFTER: readonly [number, number] = [6, 20];
|
||||
|
||||
export interface BarOutcome {
|
||||
vibe?: number;
|
||||
aggro?: number;
|
||||
/** Applied to the patron's intoxication (scene clamps to 0..1). */
|
||||
intoxDelta: number;
|
||||
/** An RSA breach pour — may earn the deferred write-up. */
|
||||
breach: boolean;
|
||||
/** Done for the night; the crowd walks them out itself. */
|
||||
sendsHome: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Price a call against what the patron actually is (§4.3: priced, never
|
||||
* graded). The breach pour still pays its vibe up front — the bribe is
|
||||
* immediate, the price arrives later.
|
||||
*/
|
||||
export function judgeCall(call: BarCall, stage: DrunkStage): BarOutcome {
|
||||
if (call === 'serve') {
|
||||
return {
|
||||
vibe: 1,
|
||||
intoxDelta: SERVE_DRINK_STEP,
|
||||
breach: isRsaBreach(stage),
|
||||
sendsHome: false,
|
||||
};
|
||||
}
|
||||
if (call === 'water') {
|
||||
const needed = stage === 'loose' || isRsaBreach(stage);
|
||||
return needed
|
||||
? { vibe: 1, intoxDelta: -WATER_SOBER_STEP, breach: false, sendsHome: false }
|
||||
: { aggro: 1, intoxDelta: -WATER_SOBER_STEP, breach: false, sendsHome: false };
|
||||
}
|
||||
// cutoff: right on a breach-stage patron, a whole scene on anyone upright.
|
||||
return isRsaBreach(stage)
|
||||
? { vibe: 1, aggro: 1, intoxDelta: 0, breach: false, sendsHome: true }
|
||||
: { vibe: -1, aggro: 2, intoxDelta: 0, breach: false, sendsHome: true };
|
||||
}
|
||||
|
||||
// ---- the pour (SERVE is a hand, not a menu click) ---------------------------
|
||||
// A sweeping marker, one key to stop it. The line lives near the top because a
|
||||
// good pour is nearly a mistake.
|
||||
|
||||
export type PourVerdict = 'short' | 'clean' | 'overflow';
|
||||
|
||||
/** Marker sweep 0→1→0, this long each way. */
|
||||
export const POUR_SWEEP_MS = 1300;
|
||||
export const POUR_CLEAN_FROM = 0.68;
|
||||
export const POUR_OVER_FROM = 0.93;
|
||||
/** What a heavy hand adds on top of SERVE_DRINK_STEP. */
|
||||
export const OVERFLOW_EXTRA_INTOX = 0.03;
|
||||
|
||||
export function judgePour(fill: number): PourVerdict {
|
||||
if (fill >= POUR_OVER_FROM) return 'overflow';
|
||||
if (fill >= POUR_CLEAN_FROM) return 'clean';
|
||||
return 'short';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the pour into the serve. Short: half a drink, no thanks, a look.
|
||||
* Overflow: the room loves a heavy pour and the patron wears more of it.
|
||||
* The RSA breach flag rides the SERVE decision, not the pour — a stingy pour
|
||||
* to a maggot is still a pour to a maggot.
|
||||
*/
|
||||
export function pourAdjust(out: BarOutcome, pour: PourVerdict): BarOutcome {
|
||||
if (pour === 'short') {
|
||||
const adjusted: BarOutcome = { ...out, aggro: (out.aggro ?? 0) + 1, intoxDelta: out.intoxDelta / 2 };
|
||||
delete adjusted.vibe;
|
||||
return adjusted;
|
||||
}
|
||||
if (pour === 'overflow') {
|
||||
return { ...out, vibe: (out.vibe ?? 0) + 1, intoxDelta: out.intoxDelta + OVERFLOW_EXTRA_INTOX };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- tabs (the jar of other people's cards) ---------------------------------
|
||||
|
||||
/** Chance a served tipsy+ patron opens a tab ("start a tab? legend"). */
|
||||
export const TAB_OPEN_CHANCE = 0.3;
|
||||
/** A drink, for the tab total the toast reads out. */
|
||||
export const TAB_DRINK_PRICE = 14;
|
||||
|
||||
export interface Tab {
|
||||
/** The name printed on the card in the jar. */
|
||||
name: string;
|
||||
drinks: number;
|
||||
}
|
||||
|
||||
/** Stages loose+ can open a tab — sober people pay as they go. */
|
||||
export function canOpenTab(stage: DrunkStage): boolean {
|
||||
return stage !== 'sober';
|
||||
}
|
||||
|
||||
/**
|
||||
* The card-return line-up: the right name plus two strangers' cards from the
|
||||
* jar/room. Order is seeded so the correct answer isn't always slot one.
|
||||
*/
|
||||
export function cardLineup(
|
||||
right: string,
|
||||
others: readonly string[],
|
||||
rng: { int(min: number, max: number): number },
|
||||
): string[] {
|
||||
const decoys = [...new Set(others.filter((n) => n !== right))];
|
||||
// Fisher–Yates on the decoy pool, take two.
|
||||
for (let i = decoys.length - 1; i > 0; i--) {
|
||||
const j = rng.int(0, i);
|
||||
[decoys[i], decoys[j]] = [decoys[j]!, decoys[i]!];
|
||||
}
|
||||
const lineup = [right, ...decoys.slice(0, 2)];
|
||||
for (let i = lineup.length - 1; i > 0; i--) {
|
||||
const j = rng.int(0, i);
|
||||
[lineup[i], lineup[j]] = [lineup[j]!, lineup[i]!];
|
||||
}
|
||||
return lineup;
|
||||
}
|
||||
@ -9,8 +9,7 @@ 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'
|
||||
| 'squaringUp';
|
||||
| 'walking' | 'atBar' | 'dancing' | 'inBooth' | 'inStall' | 'smoking' | 'escorted' | 'leaving';
|
||||
|
||||
export interface Agent {
|
||||
patron: Patron;
|
||||
@ -24,12 +23,6 @@ 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 {
|
||||
@ -165,11 +158,6 @@ 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,
|
||||
@ -190,16 +178,7 @@ export class CrowdSim {
|
||||
for (const a of this.agents) {
|
||||
if (a.gone) continue;
|
||||
a.lastBumpMs += ms;
|
||||
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 === 'escorted') continue;
|
||||
if (a.activity === 'walking') this.walk(a, ms);
|
||||
else this.dwell(a, ms);
|
||||
}
|
||||
@ -303,34 +282,6 @@ export class CrowdSim {
|
||||
}
|
||||
}
|
||||
|
||||
/** Pin an agent for a brewing fight. False if it isn't available (gone/escorted/in a stall). */
|
||||
pinForFight(agent: Agent): boolean {
|
||||
if (agent.gone) return false;
|
||||
if (
|
||||
agent.activity === 'escorted' || agent.activity === 'leaving' ||
|
||||
agent.activity === 'inStall' || agent.activity === 'squaringUp'
|
||||
) return false;
|
||||
|
||||
agent.activity = 'squaringUp';
|
||||
agent.vx = 0;
|
||||
agent.vy = 0;
|
||||
// Whatever they were nursing is over; the fight owns the dwell now.
|
||||
agent.dwellMs = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Release back into the crowd; they walk it off. */
|
||||
releaseFromFight(agent: Agent): void {
|
||||
// Anything else got hold of them mid-fight (escorted, then gone) — leave it alone.
|
||||
if (agent.activity !== 'squaringUp') return;
|
||||
agent.activity = 'walking';
|
||||
agent.dwellMs = 0;
|
||||
agent.stallId = undefined;
|
||||
const plan = this.plans.get(agent);
|
||||
if (plan) plan.stumbleMs = 0;
|
||||
this.chooseTarget(agent);
|
||||
}
|
||||
|
||||
find(patronId: string): Agent | undefined {
|
||||
return this.agents.find((a) => a.patron.id === patronId);
|
||||
}
|
||||
@ -447,38 +398,9 @@ export class CrowdSim {
|
||||
else this.chooseTarget(a); // nowhere to go from here — find somewhere else to be
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
|
||||