Compare commits
47 Commits
lane/juice
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7af3732f39 | ||
|
|
4118a60aea | ||
|
|
0d3ce80c6b | ||
|
|
bcc06c104a | ||
|
|
3b8682dd40 | ||
|
|
6d2fc56bfb | ||
|
|
164b4480c5 | ||
|
|
79cd80dc01 | ||
|
|
d4f802407b | ||
|
|
ec9afaff0f | ||
|
|
e008fb442f | ||
|
|
5334a30150 | ||
|
|
5ebdfc61eb | ||
|
|
b7bb8f49ab | ||
|
|
022ea32005 | ||
|
|
c20a689086 | ||
|
|
b273c5c833 | ||
|
|
41e93d01ab | ||
|
|
fbcbb2e349 | ||
|
|
1b0842024b | ||
|
|
d8c3b0507d | ||
|
|
4471bf4376 | ||
|
|
389376c2f1 | ||
|
|
7803873825 | ||
|
|
f38ea0743c | ||
|
|
9827e4ebe6 | ||
|
|
b1e2f038bc | ||
|
|
2e0b1917b8 | ||
|
|
22f5f11dde | ||
|
|
7b44e01333 | ||
|
|
63a3b4d4fc | ||
|
|
a961e1853b | ||
|
|
662060388a | ||
|
|
df70c99dd0 | ||
|
|
e1972c1c07 | ||
|
|
10462ebada | ||
|
|
1a66c602fd | ||
|
|
37f9892309 | ||
|
|
906a366ba8 | ||
|
|
d112bda270 | ||
|
|
0f7b948439 | ||
|
|
595371f684 | ||
|
|
ca8fd75aac | ||
|
|
7aff70c1c0 | ||
|
|
e0b5831166 | ||
|
|
30162d331d | ||
|
|
3eee8432d4 |
@ -1,6 +1,15 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{ "name": "not-tonight", "runtimeExecutable": "npm", "runtimeArgs": ["run", "dev"], "port": 5199 }
|
||||
{
|
||||
"name": "not-tonight",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"dev"
|
||||
],
|
||||
"port": 5199,
|
||||
"autoPort": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
3
.gitignore
vendored
@ -1,2 +1,3 @@
|
||||
node_modules/
|
||||
node_modules
|
||||
dist/
|
||||
art_incoming/
|
||||
|
||||
26
CLAUDE.md
@ -29,7 +29,21 @@ by parallel "lanes" coordinated through files, reviewed by Fable.
|
||||
branch. An unpushed session didn't happen.
|
||||
- Remote is Gitea: `ssh://git@100.71.119.27:222/monster/not-tonight.git`.
|
||||
|
||||
## Dev notes (Phase 0 landed 2026-07-19)
|
||||
## Dev notes (Phase 2 landed 2026-07-19)
|
||||
- The game boots into the NIGHT (Thu→Fri→Sat run, saved). Controls: click the
|
||||
rope/verdicts at the door; click the venue doorway to step inside; on the
|
||||
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.
|
||||
- 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
|
||||
any push. Dev server: `npm run dev` (port 5199, config in `.claude/launch.json`).
|
||||
- Key APIs (read the source, it's short): `core/EventBus` (`on` returns an
|
||||
@ -51,3 +65,13 @@ by parallel "lanes" coordinated through files, reviewed by Fable.
|
||||
The game is Aussie hospitality satire — affectionate, funny, with a moral undertow
|
||||
(design doc §4.3). If content you're writing reads as mean-spirited rather than
|
||||
absurd, soften toward absurd.
|
||||
|
||||
## Deploy (live since 2026-07-19)
|
||||
- Live at **https://monsterrobot.games/not-tonight/** (botchat VPS, forum-nginx
|
||||
bind mount — host path `/home/humanjing/monsterrobot.games/games/not-tonight/`,
|
||||
`/not-tonight` is a symlink to it; landing tile in `../index.html`).
|
||||
- Ship: `npm run build && rsync -az --delete dist/ humanjing@100.71.119.27:/home/humanjing/monsterrobot.games/games/not-tonight/`
|
||||
then cache-busted curl to verify. Hashed asset names make CF cache a non-issue;
|
||||
index.html is text/html (uncached by CF default).
|
||||
- Cover art: `covers/nottonight.jpg` + `_riso.jpg` (MODELBEAST flux_local seed
|
||||
4208 + PIL stamp text/riso pass — recipe in LANEHANDOVER Phase-3 deploy note).
|
||||
|
||||
1634
LANEHANDOVER.md
1
art_incoming/README.txt
Normal file
@ -0,0 +1 @@
|
||||
raw drops go here; process with tools/props_import.py
|
||||
BIN
art_incoming/arcade.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
art_incoming/barShelf.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
art_incoming/boothTable.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
art_incoming/buttBin.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
art_incoming/cloak.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
art_incoming/crate.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
art_incoming/deck.png
Normal file
|
After Width: | Height: | Size: 178 KiB |
BIN
art_incoming/discoball.png
Normal file
|
After Width: | Height: | Size: 192 KiB |
BIN
art_incoming/glassRack.png
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
art_incoming/glassStack.png
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
art_incoming/mixer.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
art_incoming/poster1.png
Normal file
|
After Width: | Height: | Size: 823 KiB |
BIN
art_incoming/poster2.png
Normal file
|
After Width: | Height: | Size: 337 KiB |
BIN
art_incoming/poster3.png
Normal file
|
After Width: | Height: | Size: 562 KiB |
BIN
art_incoming/sinkRow.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
art_incoming/speaker.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
art_incoming/stampPodium.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
art_incoming/street_backdrop.png
Normal file
|
After Width: | Height: | Size: 944 KiB |
BIN
art_incoming/taps.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
art_incoming/till.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
@ -26,16 +26,42 @@ nike_air_max, moschino, quiksilver, kmart, disruptor_ii…), dynamically loaded
|
||||
sprites before committing — pixelated photo-sprites can look mushy; may need a
|
||||
cleanup pass or MODELBEAST-assisted redraw per sprite.
|
||||
|
||||
### 2. Drunk/idle/dance animation references
|
||||
`ultra:~/Documents/90sDJsim/web/world/models/` — 22 ped GLBs + **9 anim clip GLBs
|
||||
incl. `walk_drunk`, idle, sit** (from the shared `character_kit` rig). Plus
|
||||
`ultra:~/Documents/MOTIONLIB/` — 2,703 motion/anim FBX.
|
||||
- Option A (cheap): use `walk_drunk` etc. as *visual reference* for hand-animating
|
||||
the pixel sway/stumble frames.
|
||||
- Option B (pipeline): pre-render `character_kit` characters + MOTIONLIB clips to
|
||||
top-down sprite-sheet rotations for the Floor scene (Blender orthographic cam →
|
||||
pixelate). Heavier, but gives unlimited consistent NPC animation. Decide at v0.4;
|
||||
the doll contract's `floorTopDown` pose doesn't care which path wins.
|
||||
### 2. RIGROOM — the character × animation factory (surveyed 2026-07-19)
|
||||
**This supersedes the old "animation references" note. The fleet has a full
|
||||
character/animation pipeline, live and organised:**
|
||||
- **RIGROOM library** (`ultra:~/meshgod-backups/library`, 2.4 GB): **607
|
||||
animation FBX** in themed packs + 59 character files. Web UI live at
|
||||
`http://100.91.239.7:8010/rigroom` (characters × animations pairing, three.js
|
||||
preview, instant Mixamo-ready FBX download). CLI:
|
||||
`MESHGOD/scripts/rigroom.py classify|sort|scan`.
|
||||
- Clips that read like THIS game's shot list: `Searching_Pockets` (pat-down),
|
||||
`Standing_Arguing` + a full boxing/knockout pack (floor fights),
|
||||
`Shaking_Head_No` (the deny), `Inspecting`/`Looking_Around` (ID check),
|
||||
`Leaning_Idle02_Smoking` (smoking area), dozens of idles/sits, dance packs,
|
||||
`Brutal To Happy Walking`, zombie stagger (maggot walk), 12 DJ motions.
|
||||
Raw source: `m3ultra:~/Documents/Rokoko_Free_Mocap_FBX_263` (606 FBX).
|
||||
- **Modular character kit** (`m3ultra:~/Documents/character_kit_modular`,
|
||||
canonical `ultra:~/Documents/character_kit/modular/`): GODVERSE socket
|
||||
standard — head/hands/feet/torso swap on one shared 65-bone `mixamorig:`
|
||||
skeleton, 40 embedded actions, every library clip retargets for free.
|
||||
- **repose** (`m3ultra:~/Documents/repose`): photo → T-pose views (SD1.5 +
|
||||
ControlNet, all-local) → MeshGod multi-view → autorig. A face becomes a
|
||||
rigged character without leaving the tailnet.
|
||||
- **For NOT TONIGHT (v0.4 art pass), the pre-render path is now fully paved:**
|
||||
modular kit character + RIGROOM clip → Blender orthographic top-down/front →
|
||||
pixelate → sprite sheets behind the existing `renderDoll` poses. The outfit
|
||||
variety could even come from kit part swaps. Same pipeline, one venue's
|
||||
worth of NPC animation for free. Decide at v0.4; contracts don't move.
|
||||
- Also present, unsurveyed depth: `MOCAPGOD` (empty queue — service scaffold),
|
||||
`mixamo_upload` staging, `MOTIONLIB` (2,703 FBX, ultra) still the deep pool.
|
||||
|
||||
### 2b. procity (m3ultra) — the 90s-Aussie town sim
|
||||
Walkable procedural shopping town (three.js, no build step), built by lanes
|
||||
like ours. Pipeline: 186 GLB/FBX with fit/normalize/decimate tooling
|
||||
(`procity_fit_*` props: arcade cabinet, clothes racks, shelving), 21 peds.
|
||||
The **prop pipeline conventions** (batch manifests, decimation results JSON)
|
||||
are the pattern to copy if NOT TONIGHT ever goes 3D-prop-assisted; the venue
|
||||
furniture (bar, booths, stools) could come straight from its fit pipeline.
|
||||
|
||||
### 3. Venue / street reference art
|
||||
- `ultra:~/Documents/90sDJsim/web/world/media/` named scene/venue art — 100 wired
|
||||
|
||||
@ -68,15 +68,20 @@ scene code. Beat sync example: audio engine emits `beat:tick` on the bus; the to
|
||||
rhythm game consumes `beat:tick`; until integration, LANE-FLOOR uses the
|
||||
`StubBeatClock` from core (same event, fixed 128 BPM).
|
||||
|
||||
### Phase 2 — Integration (ONE lane, sequential)
|
||||
Night flow state machine (Door ⇄ Floor interleave, radio pulls, Last Drinks,
|
||||
Summary), real audio engine replaces stubs, save/progression, Kayden-at-door
|
||||
simulation while player is on Floor. Playtest pass: one full night start→summary.
|
||||
### Phase 2 — Integration — ✅ DONE 2026-07-19 (executed by Fable)
|
||||
Night flow machine: Door and Floor run concurrently all night, player toggles
|
||||
via the doorway / floor exit; Kayden rules the real queue while you're inside
|
||||
(80% admit, 1-in-7 unstamped); TechnoEngine + location filter + SFX live in the
|
||||
night; ui/Stamp + MeterHud swapped in; Thu→Fri→Sat run with saved run-scoped
|
||||
heat; 3rd strike = immediate LICENCE PULLED, run over. See the Phase-2 SESSION
|
||||
block in LANEHANDOVER.md.
|
||||
|
||||
### Phase 3 — Content & polish (parallelizable again)
|
||||
Archetypes, scripted moral encounters, inspector, incident report memory, regulars,
|
||||
venues 2–4, real pixel art swapped into the paper-doll layer slots, MODELBEAST-
|
||||
assisted art generation if useful.
|
||||
### Phase 3 — Content & polish (parallel lanes OPEN)
|
||||
`lanes/LANE_CONTENT.md` (guest list, moral encounters, inspector, incident
|
||||
report), `lanes/LANE_FLOOR2.md` (fights, smoking area, aggro decay),
|
||||
`lanes/LANE_JUICE2.md` (ear pass, ambience, night arc). Later in this phase:
|
||||
venues 2–4, real pixel art into the paper-doll slots (ASSETS.md), regulars'
|
||||
cross-night memory, the economy tuning pass (John + reviewer, NOT a lane).
|
||||
|
||||
### Phase 4 — The role ladder (design §6; future, do not start)
|
||||
Glassie, bartender, DJ roles + roster board. Not scoped yet — but it imposes ONE
|
||||
|
||||
@ -120,9 +120,18 @@ export interface EventMap {
|
||||
'meters:delta': { vibe?: number; aggro?: number; hype?: number };
|
||||
'meters:changed': { vibe: number; aggro: number; hype: number };
|
||||
'heat:strike': HeatStrike;
|
||||
// folded in at the Phase-1 review (2026-07-19):
|
||||
'incident:log': IncidentRecord; // single writer: core/meters.ts appends to NightState.incidents
|
||||
'door:phoneTheatre': { durationMs: number };
|
||||
'audio:unlocked': { atMs: number };
|
||||
'beat:bar': { barIndex: number; beatIndex: number; audioTimeMs: number };
|
||||
}
|
||||
```
|
||||
|
||||
Phase-1 review also added: `PatronFlags.handHolding?` (CCR-1),
|
||||
`VerdictOutcome.hypeDelta?` (CCR-2), `DressCodeRule.short?` (CCR-4).
|
||||
`src/data/types.ts` remains the source of truth where this doc lags.
|
||||
|
||||
**Bus discipline:** scenes emit `meters:delta`; only `core/meters.ts` mutates state
|
||||
and emits `meters:changed`. HUD listens to `meters:changed` only. This keeps three
|
||||
lanes from fighting over the same numbers.
|
||||
|
||||
91
docs/SCENARIOS.md
Normal file
@ -0,0 +1,91 @@
|
||||
# NOT TONIGHT — Character, Scenario & Interaction Backlog
|
||||
|
||||
*The idea shelf. Everything here obeys §4.3: choices cost something real, the
|
||||
game never grades them, and the humane option is never secretly optimal.
|
||||
Status: ✅ implemented · 🔜 next nibbles · 💡 backlog.*
|
||||
|
||||
## Interactions & mini-systems
|
||||
|
||||
- ✅ **The joint bust** (smoking yard). E on a smoker who smells herbal → three
|
||||
verbs, none of them clean: **BIN IT** (by the book — write-up in the truth
|
||||
log, smoker's night dampened), **POCKET IT** (the game's first corruption
|
||||
verb — nothing happens now; maybe a Dazza sting later; and it sits in the
|
||||
incident log waiting for the report form, where your account of it can be a
|
||||
lie the audit remembers), **DIDN'T SEE IT** (the yard relaxes, vibe drifts
|
||||
up… and it's still on the record as ignored). 'joint' is real contraband, so
|
||||
it also turns up in pat-downs at the rope.
|
||||
- ✅ **The glassie run** (the glassie role's fantasy, previewed as a floor
|
||||
task). A wire rack of glasses appears at the bar's east end; carry it the
|
||||
length of the venue to the kitchen dishwasher. Crowd bumps and sharp turns
|
||||
build WOBBLE; wobble breaks glasses one smash at a time. Deliver what
|
||||
survives for vibe. Racks keep coming. The door is unattended while you do it
|
||||
— Kayden is thrilled.
|
||||
- ✅ **ID passback detection** (2026-07-20): the same card twice in one night — the name on
|
||||
the guest-list clipboard you already checked; second appearance of an
|
||||
identical card = passback. Needs a "cards seen tonight" memory at the desk.
|
||||
- ✅ **Regular step-up lines** (2026-07-20) keyed off timesDeniedBefore ("back again. new
|
||||
moustache. no hard feelings").
|
||||
- ✅ **Smoking-door slip-in** (2026-07-20) (FLOOR2 §2's second noStamp source).
|
||||
- ✅ **The 1:30 lockout** (2026-07-20) (deeply Queensland): after 1:30 AM, admissions are
|
||||
ILLEGAL. The queue still forms; Dazza still wants bodies; every post-lockout
|
||||
admit is a guaranteed strike risk vs a rioting line. A whole late-game phase
|
||||
in one law.
|
||||
- ✅ **Vomit protocol** (2026-07-20): someone redecorates the dance floor → deploy the wet
|
||||
floor sign (we own the prop) → slips if you dawdle; the glassie mop loop.
|
||||
- ✅ **Phone confiscation** (2026-07-20): someone filming on the dance floor — the
|
||||
influencer's revenge arc.
|
||||
- ✅ **Water station duty** (2026-07-20) (RSA): press a cup on a `loose` patron; done right
|
||||
it de-escalates the ripen timer. The humane tool that costs time.
|
||||
- ✅ **First aid** (2026-07-20): a fainter. Recovery position via a QTE that is deliberately
|
||||
NOT funny; the room hushes; the night resumes changed.
|
||||
- ✅ **Kebab run** (2026-07-20): Dazza demands a kebab mid-slam. The shop is right there.
|
||||
The queue can see you. The whole game in one errand.
|
||||
|
||||
## Characters (door archetypes & scripted people)
|
||||
|
||||
- ✅ **The off-duty cop** (2026-07-20) — flashes a badge at the rope. Real cop (deny freely,
|
||||
he respects it; admit and he quietly reports the venue's breaches — a
|
||||
walking inspector) or a $30 costume badge (the tells: hat, confidence,
|
||||
wrong lanyard). The ID game's scariest bluff.
|
||||
- ✅ **The celebrity incognito** (2026-07-20) — cap + sunnies (two live rule breaches!).
|
||||
Deny: nothing happens, ever, and Dazza never finds out who. Admit: vibe
|
||||
rocket OR pap swarm outside (aggro storm). You cannot know which.
|
||||
- ✅ **The twins** (2026-07-20) — two people, one face, different names. Not a fake:
|
||||
genuinely twins. Everything about the photo check is suddenly on trial.
|
||||
- ✅ **The sovereign citizen** (2026-07-20) — "I don't recognise your dress code as having
|
||||
authority over my person." Aggro engine in a polo. His ID is a laminated
|
||||
business card.
|
||||
- ✅ **The delivery rider** (2026-07-20) — insists the DJ ordered food. The DJ did not
|
||||
order food. OR DID HE. (The booth lamp flickers hungrily.)
|
||||
- ✅ **The promoter** (2026-07-20) — arrives with a roll of wristbands and 'his' guest list
|
||||
that doesn't match yours. Two clipboards enter, one leaves.
|
||||
- ✅ **The noise-complaint neighbour** (2026-07-20) — pyjamas, at the rope, at 1 AM, not
|
||||
trying to get in: trying to get it turned DOWN. The only patron who wants
|
||||
less club. Handle him badly and the inspector hears about it.
|
||||
- ✅ **The hen's party, lost** (2026-07-20) — seven matching sashes, wrong venue, right
|
||||
energy. Admitting them is a vibe grenade with a random timer.
|
||||
- ✅ **The ex-doorman** (2026-07-20) — watches you work a patron, then tells you what you
|
||||
missed (a live tutorial character; his tips are right ~80% of the time,
|
||||
which is the joke).
|
||||
|
||||
## Systems worth a whole phase
|
||||
|
||||
- ✅ **Venue 4 / ROOM boss week** (2026-07-21, first cut): fourth rung on the
|
||||
ladder (promotes from Elevate). No Dazza rule chronology — the board IS the
|
||||
law and re-randomises every 15 in-game minutes (3 rules per window, seeded,
|
||||
with a toast on the turn); every rope line renders as bracketed lip-read
|
||||
guesses (`[FIST? LIST?]`) because the techno ate the dialogue. 60 licensed,
|
||||
1.2 arrival scale, inspector working, crowd of heads and VIP-claimers.
|
||||
- ✅ **The DJ shift** (2026-07-20, first cut): E at the booth hands you the decks
|
||||
while the resident steps out. B pulls the bass (the room leans in); SPACE
|
||||
drops, judged against the LIVE bar line (clean/late/clang — early and late
|
||||
are the same offence); requests arrive at the booth glass with per-request
|
||||
odds ("got any dnb", nan's song, the DUN DUN one) and three answers that all
|
||||
cost something. A build held too long runs out of stairs. Movement locks
|
||||
while you hold them; the floor and the door keep happening without you.
|
||||
- ✅ **Bartender shift** (2026-07-21, first cut): E at the till hands you the
|
||||
taps (movement locks; the floor keeps happening). Orders arrive as choices —
|
||||
the order line degrades with the patron's stage (the spelling IS the sobriety
|
||||
test) — SERVE / WATER / CUT OFF all legal, all priced. Serving messy/maggot
|
||||
is the RSA breach: the vibe pays up front, and 35% of the time somebody with
|
||||
a lanyard saw it (ONE deferred write-up per night, deduped by reason).
|
||||
115
docs/SPACES.md
Normal file
@ -0,0 +1,115 @@
|
||||
# NOT TONIGHT — Spaces Plan
|
||||
|
||||
*How the game's two spaces grow from readable-but-abstract into places, using the
|
||||
fleet's asset factories. Written 2026-07-20. Code contract: everything lands
|
||||
behind data tables (`venueMap.PROPS` / `LIGHTS`) and a sprite drop-in convention —
|
||||
generated art replaces placeholders with zero code changes.*
|
||||
|
||||
## 1. Design principles
|
||||
|
||||
1. **Darkness stays the mechanic.** Props read as silhouettes in ambient dark and
|
||||
resolve under the torch. Nothing may make the room bright enough to scan from
|
||||
the doorway.
|
||||
2. **Navigate by light signature.** Each zone owns a colour of light, so the
|
||||
player orients by glow before geometry: bar = warm amber · dance floor =
|
||||
pulsing pink (on the actual beat) · DJ booth = deep blue · toilets =
|
||||
flickering green fluoro · smoking yard = cool rain-blue spill · entry/exit =
|
||||
venue pink. The torch is white; UV is purple. No other light sources.
|
||||
3. **Props are data.** `PropDef { kind, tile pos, footprint, emissive? }` — the
|
||||
renderer prefers a generated texture (`gen:prop:<kind>`) and falls back to a
|
||||
procedural placeholder, so the game is complete TONIGHT and gets prettier as
|
||||
art lands.
|
||||
4. **Spaces anticipate the role ladder** (design §6): the bar interior is laid
|
||||
out for a future bartender shift, the DJ booth for the DJ shift, the glassie
|
||||
station and smoking yard for floor loops that already have lanes speccing them.
|
||||
|
||||
## 2. The venue floor (80×45 tiles, one room + pockets)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ N ═ BAR ══════════ back-shelf glow ┌─ DUNNIES ────────┐│
|
||||
│ taps · till · glasses │ s1 s2 s3 s4 ││
|
||||
│ ○○○○○○ stools │ sinks + mirror ││
|
||||
│ └───door───────────┘│
|
||||
│ W │
|
||||
│ ┈entry┈ ┌ DANCE FLOOR (pulsing grid) ┐ ╔═ DJ BOOTH ╗ booths│
|
||||
│ corridor │ speaker · · · · · speaker │ ║ decks+mixer║ E│
|
||||
│ posters │ · · · disco ball · · · │ ║ (sealed) ║ booths│
|
||||
│ cloak │ speaker · · · · · speaker │ ╚════════════╝ │
|
||||
│ glassie └───────────────────────────┘ booths│
|
||||
│ station │
|
||||
│ ▓ SMOKING YARD (open sky, rain) ▓ tables+candles S │
|
||||
└────────▓ fence · butt bin ▓──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Zone-by-zone (tile coords in `venueMap.ts`):
|
||||
|
||||
| Zone | What's there (props) | Light signature | Future role hook |
|
||||
|---|---|---|---|
|
||||
| **Bar** (north run) | back-bar shelf (emissive), 3 beer taps, till, glass stacks | steady amber pools along the shelf | Bartender shift: the serving lane between bar tiles and shelf is already unwalkable-to-patrons |
|
||||
| **Dance floor** (centre) | 4 corner speaker stacks, disco ball (centre), floor grid cells | pink grid cells pulse ON `beat:tick`; ball throws a slow-rotating glint | DJ role reads crowd here |
|
||||
| **DJ booth** (east of dance, sealed box facing the floor) | 2 decks + mixer (from the 90sDJsim gear GLBs!), booth monitor, lamp | deep blue wash + VU flicker | The DJ shift's playable pocket |
|
||||
| **Booths** (south + east walls) | table per booth pair, candle | tiny warm candle pools | — |
|
||||
| **Dunnies** (NE walled room) | sink row + mirror on west wall, hand dryer | green fluoro, FLICKERS (torch almost redundant inside — deliberate) | — |
|
||||
| **Smoking yard** (SW walled pocket, open sky) | butt bin, milk crate seats, fence | cool blue outdoor spill + rain particles INSIDE the yard | FLOOR2's smoking loop (specced) |
|
||||
| **Entry corridor** (west) | poster wall (3 art slots — MODELBEAST posters!), cloakroom window, stamp podium | venue-pink spill from the door | UV re-entry checks happen here |
|
||||
| **Glassie station** (bar's west end alcove) | glass racks, tub trolley | dim amber | Glassie role's home base |
|
||||
|
||||
Sim impact: walkability only changes where NEW walls go (DJ booth box, yard
|
||||
fence, sink row). All existing anchors keep working; reachability is asserted in
|
||||
tests. The yard gets a `smoke` anchor upgrade so wanderers actually go out for one.
|
||||
|
||||
## 3. The door street (640×360 tableau)
|
||||
|
||||
Already reads well. Two upgrades, both drop-in images (no layout change):
|
||||
1. **Backdrop plate** behind the existing rects: one wide pixel-art street wall
|
||||
(brick, posters, grime) — swaps in as `gen:street:backdrop` when generated.
|
||||
2. **Kebab shop interior glow card** in its window + rotating poster art in two
|
||||
street frames (same poster slots as the entry corridor).
|
||||
|
||||
## 4. The sprite drop-in contract (how John's art lands)
|
||||
|
||||
- Drop processed PNGs in **`public/props/`** named `<kind>.png` and list them in
|
||||
**`public/props/manifest.json`** (`{"props": ["deck", "speaker", ...]}`).
|
||||
- The floor loads the manifest at boot; anything listed replaces its placeholder
|
||||
(texture key `gen:prop:<kind>`). Missing = placeholder. No code changes.
|
||||
- **`tools/props_import.py`** (PIL, runs anywhere with python3+PIL — ultra has it)
|
||||
takes raw generated images → target pixel size + palette quantise + transparent
|
||||
background → writes `public/props/` + updates the manifest:
|
||||
`python3 tools/props_import.py incoming/deck_raw.png deck`
|
||||
- Target sizes are in the script (`TARGETS`): hero props 48–64 px, furniture
|
||||
24–32 px, small items 16 px. It refuses unknown kinds so typos can't ship.
|
||||
|
||||
## 5. GENERATION QUEUE — John's list (in priority order)
|
||||
|
||||
Pipelines: **[MB]** = MODELBEAST `flux_local` (params given) → drop raw output in
|
||||
`art_incoming/`, run the import script (or hand me the raws — either works).
|
||||
**[GLB]** = Blender ortho prerender of an existing fleet mesh (camera: orthographic,
|
||||
top-down 90° for floor props / front 0° for the street, transparent film, ~512px,
|
||||
neutral even lighting) → same import path.
|
||||
|
||||
| # | Item (kind) | Pipeline & source | Notes |
|
||||
|---|---|---|---|
|
||||
| 1 | `deck` | [GLB] `ultra:~/Documents/90sDJsim/web/world/models/gear/deck_t3_technics_sl_1200mk2.glb`, top-down | The hero. One render serves both booth decks |
|
||||
| 2 | `mixer` | [GLB] same dir, `mixer.glb` (or the urei once landed), top-down | Sits between the decks |
|
||||
| 3 | `speaker` | [GLB] 90sDJsim `models/props/` has boombox/speaker meshes; else [MB] prompt: "top-down view of a large black PA speaker stack, pixel art game sprite, dark background, viewed directly from above" 512×512 seed 4210 | 4 placements |
|
||||
| 4 | `discoball` | [MB] "mirrored disco ball seen directly from above, glinting, pixel art sprite, black background" 512×512 seed 4211 | Centre of dance floor |
|
||||
| 5 | `barShelf` | [MB] "top-down bar back-shelf with glowing bottles rows, amber light, pixel art sprite, dark" 1024×256 seed 4212 | Emissive — the amber source |
|
||||
| 6 | `taps` | [MB] "row of three beer taps on a bar counter viewed from above, pixel art, dark bg" 256×256 seed 4213 | |
|
||||
| 7 | `boothTable` | [MB] "small round pub table with two drinks seen from directly above, pixel art, dark bg" 256×256 seed 4214 | 6 placements |
|
||||
| 8 | `sink` | [MB] "public bathroom sink row with mirror viewed from above, green fluorescent light, pixel art, dark" 512×256 seed 4215 | |
|
||||
| 9 | `buttBin` + `crate` | [MB] one sheet: "outdoor smoking area props: cigarette butt bin and milk crate, top-down pixel art, dark bg" 512×256 seed 4216 | Yard dressing |
|
||||
| 10 | `poster1..3` | [MB] three runs: "grimy nightclub gig poster, 90s Australian pub rock / techno rave / missing cat, pixel art, portrait" 256×384 seeds 4217-19 | FRONT view (posters hang on walls) — used at door + corridor |
|
||||
| 11 | `street:backdrop` | [MB] "wide pixel art night street wall: dark brick, torn posters, graffiti, neon reflections, rain, front-on orthographic, game background" 1600×440 seed 4220 | The door scene plate. Biggest single visual win |
|
||||
| 12 | `cloak` / `glassRack` | [MB] "cloakroom window with hanging jackets / metal glass rack with stacked pint glasses, top-down pixel art, dark" 256×256 seeds 4221-22 | Corridor + glassie station |
|
||||
| 13 | `arcade` | [GLB] `m3ultra:~/Documents/procity/pipeline/_normalized/procity_fit_arcade_cabinet_01.glb`, top-down | Corner easter egg near entry |
|
||||
|
||||
**Ambition tier (later, not blocking):** pre-rendered patron sprite sheets from
|
||||
the modular character kit + RIGROOM clips (`Searching_Pockets`, boxing pack,
|
||||
`Leaning_Idle02_Smoking`, zombie stagger for maggots) — replaces the procedural
|
||||
dolls entirely per docs/ASSETS.md §2. Wants its own session to set the
|
||||
palette/density once.
|
||||
|
||||
*(flux params for all [MB] jobs: `model flux2-klein-4b, steps 4`; put "no text,
|
||||
no lettering" in every prompt EXCEPT the posters, where mangled text reads as
|
||||
authentic gig-poster grot.)*
|
||||
74
lanes/LANE_CONTENT.md
Normal file
@ -0,0 +1,74 @@
|
||||
# LANE-CONTENT — Door depth & the moral layer (Phase 3, parallel)
|
||||
|
||||
**Executor:** Opus 4.8. **Branch:** `lane/content` (from main after Phase 2).
|
||||
**Owns:** `src/scenes/door/`, `src/rules/`, `src/data/strings/` (door-related
|
||||
files), tests. **Prereq reading:** CLAUDE.md → `docs/GAME_DESIGN.md` §3.1/§4 →
|
||||
the Phase-2 REVIEW block in `LANEHANDOVER.md`.
|
||||
|
||||
## Phase-2 reality (read before coding)
|
||||
- The night is now a real flow: `NightScene` owns clock/meters/audio/save and a
|
||||
`NightContext` (see its interface — `sfx`, `requestLocation`, `beatIntervalMs`
|
||||
are new since Phase 1). Door and Floor run concurrently ALL night; the player
|
||||
toggles via the doorway ("step inside") and the floor's entry exit.
|
||||
- While the player is on the floor, **Kayden rules the real queue** in
|
||||
`DoorScene.kaydenRule()` (admit 80%, same `judge()`, no UV stamp 1-in-7 —
|
||||
stamp accuracy is decided in `NightScene.onVerdict`).
|
||||
- Heat is run-scoped and saved; 3rd strike ends the night AND the run
|
||||
immediately (`endReason: 'licence'`). Nights chain Thu→Fri→Sat via
|
||||
`NightSummaryScene.nextAction()`; `save.ts` carries `GameState`.
|
||||
- `ui/Stamp` + sfx are live in the deny path; `data/strings/door.ts` has
|
||||
`FAIL_TEXT.licence`.
|
||||
|
||||
## Deliverables (in priority order)
|
||||
|
||||
### 1. The Guest List clipboard (design §3.1 — specced, never built)
|
||||
A desk clipboard panel: tonight's list (generated, 8–14 names, some misspelled
|
||||
versions of real patron names). Patrons with `claimsGuestList` get a "'m on the
|
||||
list" line when they step up; player cross-checks. `knowsOwner` patrons who are
|
||||
DENIED trigger the existing furious-Dazza path (judge already scores this —
|
||||
surface it: their step-up line should drop hints ("Terry said swing by"), and
|
||||
`data/strings/dazza.ts` has the `owner-scare` text to foreshadow).
|
||||
Misspelling rule: a name that ALMOST matches is usually legit (bad handwriting);
|
||||
an exact-match name on a confident patron is sometimes a lift. Tells, not
|
||||
certainty. Pure logic in `rules/guestList.ts` + tests.
|
||||
|
||||
### 2. Scripted moral encounters (design §4.3 — the heart)
|
||||
2–3 scripted patrons injected per night by the generator override hook
|
||||
(`generatePatron(ctx, clockMin, archetypeOverride)` + a new scripted-encounter
|
||||
scheduler in `rules/` or `data/`): the girl who needs back in to grab her mate
|
||||
(no stamp, over capacity), the freshly-dumped regular who just wants one quiet
|
||||
beer (already `loose`). Multi-beat dialogue via the existing toast/dialogue
|
||||
patterns. Doing the human thing costs Vibe or risks Heat; the game never says
|
||||
which choice was right. NO achievement chimes, no morality meter — the design
|
||||
doc is explicit that this stays unscored.
|
||||
|
||||
### 3. The Inspector (archetype exists, does nothing yet)
|
||||
Activate `inspector`: reads as a boring `punter` at the rope (their doll is
|
||||
deliberately unremarkable). If admitted, they observe for ~30 clock-min:
|
||||
any live breach in that window (capacity over licensed, a `maggot` on the floor,
|
||||
underage inside) converts its deferred strike to IMMEDIATE. Then they leave and
|
||||
Dazza texts "that was the INSPECTOR you muppet". If denied — nothing happens,
|
||||
ever (they were just a quiet person; paranoia is the tax). Pure logic
|
||||
`rules/inspector.ts` + tests; wiring via bus events only.
|
||||
|
||||
### 4. The Incident Report (design §3.3 — lying groundwork)
|
||||
On the Night Summary: a short form (3–4 incidents sampled from
|
||||
`NightState.incidents`) where the player picks a written account per incident —
|
||||
truthful or flattering. Store both truth and account in
|
||||
`GameState.pastReports`. Cross-night audit (contradictions → strikes) is
|
||||
Phase 4; THIS lane only builds the form + storage + one taste: a lie about a
|
||||
`kayden` incident can be contradicted by Kayden's own log line next morning
|
||||
(Dazza text). Keep the form fast — 20 seconds, not homework.
|
||||
|
||||
### 5. Sobriety line-walk (stretch)
|
||||
The third challenge: cursor-follow line at the desk. Only if 1–4 land clean.
|
||||
|
||||
## Constraints
|
||||
- All scoring through `judge()`/`meters:delta`/`heat:strike` — no new direct
|
||||
meter paths. New events need a CONTRACT CHANGE REQUEST (declaration-merge
|
||||
bridges are BANNED now — request and wait for the fold).
|
||||
- Kayden must interact sanely with everything you add (e.g. he auto-admits
|
||||
guest-list liars — that's fine and funny; make sure it can't crash).
|
||||
- Every pure module tested; play a full 3-night run before your handover and
|
||||
report what the run FELT like — the economy findings from Phase 1 are still
|
||||
open and your content changes the mix.
|
||||
62
lanes/LANE_FLOOR2.md
Normal file
@ -0,0 +1,62 @@
|
||||
# LANE-FLOOR2 — Floor completion & aggro physiology (Phase 3, parallel)
|
||||
|
||||
**Executor:** Opus 4.8. **Branch:** `lane/floor2` (from main after Phase 2).
|
||||
**Owns:** `src/scenes/floor/`, `src/data/strings/floor.ts`, tests. **Prereq
|
||||
reading:** CLAUDE.md → `docs/GAME_DESIGN.md` §3.2 → Phase-2 REVIEW block in
|
||||
`LANEHANDOVER.md`.
|
||||
|
||||
## Phase-2 reality (read before coding)
|
||||
- `FloorDemoScene` now runs in TWO modes: standalone `'FloorDemo'` (key F,
|
||||
unchanged) and night mode `'Floor'` (launched by NightScene with a
|
||||
`NightContext`; see `bindNight()`). Night mode: shared bus/clock/state, fed by
|
||||
real door admissions (`door:verdict` → delayed `crowd.spawn`), `MeterHud` for
|
||||
HUD, exit via E at the entry anchor, sim keeps running while the player is at
|
||||
the door (`present === false` path in `update()`).
|
||||
- Incidents flow through `incident:log` (single writer in core/meters).
|
||||
- `NightContext.sfx` exists (SFX handle, always crisp past the location filter).
|
||||
- Kayden's unstamped admits (1 in 7) are your `noStamp` supply in night mode.
|
||||
|
||||
## Deliverables (in priority order)
|
||||
|
||||
### 1. Fight brewing (design §3.2 — the last missing v0.2 infraction)
|
||||
Two agents in escalating shove animation (crowd sim picks pairs with rising
|
||||
aggro contribution when they collide repeatedly). A swing timer (~8s from first
|
||||
shove): player must physically walk BETWEEN them before it ends. Interposed =
|
||||
defused (+vibe, both marked handled); missed = fight (big −vibe, +aggro, both
|
||||
auto-ejected, `incident:log` kind `fight`, and if the inspector is inside —
|
||||
LANE-CONTENT's problem — it's a heat risk). Pure pair-selection + timer logic
|
||||
in `floor/fights.ts`, tested. While the player is at the door, fights still
|
||||
happen (that's radio-pull pressure — emit a `radio:call` urgency 3 via the
|
||||
night's existing pattern: log the incident, NightScene already radios on
|
||||
`maggotUnhandled`; add `fight` to that trigger via CONTRACT CHANGE REQUEST or
|
||||
ask the reviewer to widen the trigger — one-line, will be approved).
|
||||
|
||||
### 2. Smoking area & pass-outs (closes the UV loop from the door side)
|
||||
The smoking-area door becomes live: patrons drift out for a smoke (activity
|
||||
'smoking' exists) and BACK IN through the entry. Re-entering smokers keep their
|
||||
stamp — but a small % of NEW patrons try to slip in through the smoking door
|
||||
(no stamp, never saw the front door). That's the noStamp mechanic getting a
|
||||
source that isn't only Kayden. Numbers small (2–3 a night), tells readable
|
||||
(they enter from the wrong door if you're watching).
|
||||
|
||||
### 3. Aggro physiology (FLOOR-2 CCR, deferred at Phase-1 review — now approved)
|
||||
Implement slow aggro decay on a calm floor: when no unhandled infraction exists
|
||||
and no fight is brewing, drift aggro down ~0.3/clock-min (numbers yours to
|
||||
tune, state the final value in the handover). Implementation note: this is a
|
||||
sim-level `meters:delta` emit from the floor's update, gated on `present`
|
||||
being EITHER value (a calm floor calms the night whether you're watching it or
|
||||
not). Add tests for the gating logic.
|
||||
|
||||
### 4. Night-mode polish
|
||||
- Stall bangs and pat-down bin drops should hit `NightContext.sfx`
|
||||
(`doorBang`, plus pick something for the bin thunk) — overlays take an
|
||||
optional sfx handle via their constructors.
|
||||
- Escorted patrons should exit via the ENTRY in night mode (they're being
|
||||
walked out the front, past the queue — future door-side schadenfreude hook).
|
||||
- Perf check at 44 agents + fights + smokers in night mode; report frame times.
|
||||
|
||||
## Constraints
|
||||
- Demo mode must stay working and identical-feeling (it's the tuning harness).
|
||||
- No imports from `scenes/door/`; the night talks to you via bus + context.
|
||||
- Pure logic tested; verify each mechanic by playing BOTH modes; handover per
|
||||
protocol.
|
||||
59
lanes/LANE_JUICE2.md
Normal file
@ -0,0 +1,59 @@
|
||||
# LANE-JUICE2 — The night has a sound (Phase 3, parallel)
|
||||
|
||||
**Executor:** Opus 4.8. **Branch:** `lane/juice2` (from main after Phase 2).
|
||||
**Owns:** `src/audio/`, `src/ui/`, tests. **Prereq reading:** CLAUDE.md →
|
||||
LANE_JUICE.md (your Phase-1 spec) → Phase-2 REVIEW block in `LANEHANDOVER.md`.
|
||||
|
||||
## Phase-2 reality (read before coding)
|
||||
- `TechnoEngine` + `Sfx` now live inside the actual night (`NightScene` creates
|
||||
them; unlock on first pointerdown; `StubBeatClock` hands over on
|
||||
`audio:unlocked`). The location filter sweeps on every doorway/exit toggle.
|
||||
- `audio:unlocked.atMs` now carries the ANCHORED beat-0 origin (your open
|
||||
question, ruled and implemented).
|
||||
- `MeterHud` has `initialHeat` (ruled: read run-scoped strikes at construction —
|
||||
wired in the floor's night mode).
|
||||
- `ui/Stamp` is the door's stamp, with `stampSlam`/`stampInk` wired. The decal
|
||||
is destroyed ~900ms post-slam by DoorScene.
|
||||
- Your `ui/juiceEvents.ts` was deleted at review — its events live in
|
||||
`data/types.ts` now. Extensions go via CONTRACT CHANGE REQUEST only.
|
||||
|
||||
## Deliverables (in priority order)
|
||||
|
||||
### 1. Ear pass with John (the debt from Phase 1: "verified by ear: NOTHING")
|
||||
Sit a human in front of the JuiceDemo AND a real night. Tune: does the kick
|
||||
read as a kick, is the bassline musical, `typeTick` level (it fires per
|
||||
character), stamp slam vs music balance, the door→floor sweep (does it feel
|
||||
like a door opening). Document every constant you change and why. This is THE
|
||||
deliverable — everything below is secondary.
|
||||
|
||||
### 2. Ambience layer
|
||||
- `rainLoop` at the door (start/stop on `audio:location` — rain is outside).
|
||||
- A crowd-murmur bed INSIDE (filtered noise, level follows
|
||||
`NightState.capacity.inside` — read via a bus listener on `meters:changed`?
|
||||
No — capacity isn't in that payload; request what you need or derive from
|
||||
`door:clicker`/`door:verdict` counting. State your approach in the handover).
|
||||
- The kebab-shop fluoro hum if you're feeling artistic (tiny, dry, mono).
|
||||
|
||||
### 3. The night has an arc
|
||||
Track intensity should rise across the night: thin kick+hat at 9 PM, bassline
|
||||
enters ~10, pads and full mix by midnight, strip back at Last Drinks. Drive it
|
||||
from `clock:tick` (you know the clock minute). Keep every parameter externally
|
||||
settable — this surface IS the future DJ role's control panel (BUILD_PLAN
|
||||
Phase 4 rule).
|
||||
|
||||
### 4. Widget debts
|
||||
- `Phone` thread: scroll instead of truncating the newest over-tall message.
|
||||
- `DialogueBox` drunk-typo mode: wire into the floor cut-off overlay (it
|
||||
currently renders plain text — coordinate via handover, LANE-FLOOR2 owns the
|
||||
call site; you own the widget).
|
||||
- Optional: `MeterHud` variant sized for the door's top bar, so DoorScene can
|
||||
drop its bespoke `MeterBar`s (currently duplicated look). Only if clean —
|
||||
the door HUD works and this is cosmetic unification, not a rescue.
|
||||
|
||||
## Constraints
|
||||
- Location filter architecture is non-negotiable: music through the lowpass,
|
||||
SFX and ambience around it (rain is OUTSIDE — it belongs on the sfx bus but
|
||||
only audible at the door; that's a routing decision, justify yours).
|
||||
- No imports from door/floor scenes. Bus + constructor config only.
|
||||
- Pure-testable math tested; everything else honestly listed as ear-checked or
|
||||
not, per your own Phase-1 standard.
|
||||
BIN
public/props/arcade.png
Normal file
|
After Width: | Height: | Size: 836 B |
BIN
public/props/barShelf.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
public/props/boothLamp.png
Normal file
|
After Width: | Height: | Size: 424 B |
BIN
public/props/boothTable.png
Normal file
|
After Width: | Height: | Size: 644 B |
BIN
public/props/buttBin.png
Normal file
|
After Width: | Height: | Size: 571 B |
BIN
public/props/carpet.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/props/cloak.png
Normal file
|
After Width: | Height: | Size: 691 B |
BIN
public/props/crate.png
Normal file
|
After Width: | Height: | Size: 549 B |
BIN
public/props/deck.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/props/discoball.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
public/props/dishwasher.png
Normal file
|
After Width: | Height: | Size: 552 B |
BIN
public/props/glassRack.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
public/props/glassStack.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/props/highTable.png
Normal file
|
After Width: | Height: | Size: 430 B |
32
public/props/manifest.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"props": [
|
||||
"arcade",
|
||||
"barShelf",
|
||||
"boothLamp",
|
||||
"boothTable",
|
||||
"buttBin",
|
||||
"carpet",
|
||||
"cloak",
|
||||
"crate",
|
||||
"deck",
|
||||
"discoball",
|
||||
"dishwasher",
|
||||
"glassRack",
|
||||
"glassStack",
|
||||
"highTable",
|
||||
"mixer",
|
||||
"monitor",
|
||||
"plant",
|
||||
"poolTable",
|
||||
"poster1",
|
||||
"poster2",
|
||||
"poster3",
|
||||
"sinkRow",
|
||||
"speaker",
|
||||
"stampPodium",
|
||||
"street_backdrop",
|
||||
"taps",
|
||||
"till",
|
||||
"wetFloor"
|
||||
]
|
||||
}
|
||||
BIN
public/props/mixer.png
Normal file
|
After Width: | Height: | Size: 918 B |
BIN
public/props/monitor.png
Normal file
|
After Width: | Height: | Size: 351 B |
BIN
public/props/plant.png
Normal file
|
After Width: | Height: | Size: 396 B |
BIN
public/props/poolTable.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/props/poster1.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
public/props/poster2.png
Normal file
|
After Width: | Height: | Size: 851 B |
BIN
public/props/poster3.png
Normal file
|
After Width: | Height: | Size: 892 B |
BIN
public/props/sinkRow.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
public/props/speaker.png
Normal file
|
After Width: | Height: | Size: 657 B |
BIN
public/props/stampPodium.png
Normal file
|
After Width: | Height: | Size: 502 B |
BIN
public/props/street_backdrop.png
Normal file
|
After Width: | Height: | Size: 206 KiB |
BIN
public/props/taps.png
Normal file
|
After Width: | Height: | Size: 1001 B |
BIN
public/props/till.png
Normal file
|
After Width: | Height: | Size: 646 B |
BIN
public/props/wetFloor.png
Normal file
|
After Width: | Height: | Size: 387 B |
445
src/audio/Ambience.ts
Normal file
@ -0,0 +1,445 @@
|
||||
// The beds under the night: rain outside, bodies inside, and the kebab shop's
|
||||
// fluoro tube humming to itself while both happen.
|
||||
//
|
||||
// ROUTING. Everything here lands on the engine's sfxBus, never the musicBus.
|
||||
// The music lowpass models "the track heard through a wall" — that is a
|
||||
// statement about the PA being on the other side of a door. Rain is not on the
|
||||
// other side of anything; it is falling on your head, so filtering it with the
|
||||
// music would put the weather indoors with the speakers. What makes rain
|
||||
// door-only is its own gain envelope tracking `audio:location`, not a cutoff.
|
||||
// The crowd bed is the same argument run backwards: it is the room you are
|
||||
// standing in when you are inside, and a wall's worth of muffling belongs on
|
||||
// the PA, not on the people. Location is a mix decision here, not a filter one.
|
||||
|
||||
import type { EventBus } from '../core/EventBus';
|
||||
import { SeededRNG } from '../core/SeededRNG';
|
||||
import type { Sfx } from './Sfx';
|
||||
|
||||
export interface AmbienceOptions {
|
||||
rain?: boolean;
|
||||
crowd?: boolean;
|
||||
kebab?: boolean;
|
||||
}
|
||||
|
||||
type Bed = 'rain' | 'crowd' | 'kebab';
|
||||
type Location = 'door' | 'floor';
|
||||
|
||||
/** Seconds of looped noise behind the crowd bed. Long enough to hide the seam. */
|
||||
const NOISE_SECONDS = 4;
|
||||
|
||||
/** Location crossfade. Sits between the 600/900ms filter sweeps either way, so
|
||||
* stepping through the door reads as one movement rather than two events. */
|
||||
const LOC_FADE_S = 0.7;
|
||||
|
||||
/** Headcount swell. Slow enough to be a swell, fast enough to feel caused. */
|
||||
const COUNT_FADE_S = 0.4;
|
||||
|
||||
/** Enable/disable toggles are a tuning affordance, not a game beat — keep short. */
|
||||
const TOGGLE_FADE_S = 0.25;
|
||||
|
||||
/** Peak crowd gain, at a room so full it cannot get louder. Well under the kick. */
|
||||
const CROWD_PEAK = 0.09;
|
||||
|
||||
/**
|
||||
* Saturation constant for the headcount curve. At k=14 the first dozen people
|
||||
* buy most of the loudness and the hundredth buys almost none — which is how a
|
||||
* room actually behaves, and the opposite of what a linear map does.
|
||||
*/
|
||||
const CROWD_K = 14;
|
||||
|
||||
/** Crowd bed at the door: the room leaking past you, not silence. */
|
||||
const CROWD_DOOR_DUCK = 0.15;
|
||||
|
||||
/** The fluoro tube. Barely there on purpose — it is set dressing, not a voice. */
|
||||
const KEBAB_LEVEL = 0.014;
|
||||
|
||||
export class Ambience {
|
||||
private readonly bus: EventBus;
|
||||
private readonly ctx: AudioContext;
|
||||
private readonly dest: AudioNode;
|
||||
private readonly sfx: Sfx;
|
||||
|
||||
private readonly offs: Array<() => void> = [];
|
||||
|
||||
private readonly enabled: Record<Bed, boolean>;
|
||||
|
||||
private loc: Location = 'door';
|
||||
private running = false;
|
||||
private count = 0;
|
||||
|
||||
private crowd: CrowdBed | null = null;
|
||||
private kebab: KebabBed | null = null;
|
||||
|
||||
/** Built on first start() and kept: restarting shouldn't re-allocate 4s of noise. */
|
||||
private noise: AudioBuffer | null = null;
|
||||
|
||||
constructor(
|
||||
bus: EventBus,
|
||||
ctx: AudioContext,
|
||||
destination: AudioNode,
|
||||
sfx: Sfx,
|
||||
opts: AmbienceOptions = {},
|
||||
) {
|
||||
this.bus = bus;
|
||||
this.ctx = ctx;
|
||||
this.dest = destination;
|
||||
this.sfx = sfx;
|
||||
this.enabled = {
|
||||
rain: opts.rain ?? true,
|
||||
crowd: opts.crowd ?? true,
|
||||
// The kebab shop is the moral centre of the thing (design doc §5). It gets
|
||||
// a sound whether or not anyone is listening for it.
|
||||
kebab: opts.kebab ?? true,
|
||||
};
|
||||
|
||||
this.offs.push(
|
||||
this.bus.on('audio:location', ({ location }) => {
|
||||
if (location === this.loc) return; // door -> door must not restack a fade
|
||||
this.loc = location;
|
||||
this.applyLocation();
|
||||
}),
|
||||
);
|
||||
|
||||
// Headcount is derived, not published: NightState.capacity.inside is in no
|
||||
// event payload and we may not add one. So we count the truth log — an
|
||||
// admission puts a body in the room, an ejection takes one out.
|
||||
//
|
||||
// Deliberately NOT door:clicker. The clicker is the player's *claimed*
|
||||
// count, and the gap between it and reality is a game mechanic. Driving the
|
||||
// crowd bed off it would make the room sound like the lie instead of the
|
||||
// room, and would hand the player a way to hear their own miscount.
|
||||
this.offs.push(
|
||||
this.bus.on('door:verdict', ({ verdict }) => {
|
||||
if (verdict === 'admit') this.setInsideCount(this.count + 1);
|
||||
}),
|
||||
);
|
||||
this.offs.push(
|
||||
this.bus.on('floor:ejection', () => this.setInsideCount(this.count - 1)),
|
||||
);
|
||||
}
|
||||
|
||||
get insideCount(): number {
|
||||
return this.count;
|
||||
}
|
||||
|
||||
/** Lets a demo drive the room without staging a hundred real admissions. */
|
||||
setInsideCount(n: number): void {
|
||||
const next = Math.max(0, Math.floor(n));
|
||||
if (next === this.count) return;
|
||||
this.count = next;
|
||||
this.crowd?.setLevel(this.crowdTarget(), COUNT_FADE_S);
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
|
||||
this.noise ??= noiseBuffer(this.ctx);
|
||||
this.crowd = new CrowdBed(this.ctx, this.dest, this.noise);
|
||||
this.kebab = new KebabBed(this.ctx, this.dest);
|
||||
|
||||
// Snap rather than fade on the first frame: there is no previous location to
|
||||
// cross from, and a 700ms swell out of nothing sounds like a mistake.
|
||||
this.crowd.setLevel(this.crowdTarget(), 0);
|
||||
this.crowd.setLocation(this.crowdDuck(), 0);
|
||||
this.kebab.setLevel(this.kebabTarget(), 0);
|
||||
this.kebab.setLocation(this.kebabDuck(), 0);
|
||||
this.applyRain();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (!this.running) return;
|
||||
this.running = false;
|
||||
|
||||
this.crowd?.stop();
|
||||
this.crowd = null;
|
||||
this.kebab?.stop();
|
||||
this.kebab = null;
|
||||
this.sfx.stopRain();
|
||||
}
|
||||
|
||||
setEnabled(bed: Bed, on: boolean): void {
|
||||
if (this.enabled[bed] === on) return;
|
||||
this.enabled[bed] = on;
|
||||
if (!this.running) return;
|
||||
|
||||
switch (bed) {
|
||||
case 'rain':
|
||||
return this.applyRain();
|
||||
case 'crowd':
|
||||
return this.crowd?.setLevel(this.crowdTarget(), TOGGLE_FADE_S);
|
||||
case 'kebab':
|
||||
return this.kebab?.setLevel(this.kebabTarget(), TOGGLE_FADE_S);
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.stop();
|
||||
for (const off of this.offs) off();
|
||||
this.offs.length = 0;
|
||||
}
|
||||
|
||||
// --- policy ---------------------------------------------------------------
|
||||
|
||||
private applyLocation(): void {
|
||||
this.crowd?.setLocation(this.crowdDuck(), LOC_FADE_S);
|
||||
this.kebab?.setLocation(this.kebabDuck(), LOC_FADE_S);
|
||||
this.applyRain();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rain is delegated to Sfx, which already owns a rain bed wired to this same
|
||||
* bus; a second one would just be the first one twice as loud. Both calls are
|
||||
* idempotent, so repeated door -> door is a no-op there as well as here.
|
||||
*
|
||||
* The `running` guard is what the crowd and kebab beds get for free from their
|
||||
* null refs. Without it a location change after stop() — the subscription
|
||||
* outlives stop(), only destroy() drains it — would start rain under a room
|
||||
* with no crowd and no kebab in it, and nothing would take it back down.
|
||||
*/
|
||||
private applyRain(): void {
|
||||
if (!this.running) return;
|
||||
if (this.enabled.rain && this.loc === 'door') this.sfx.startRain();
|
||||
else this.sfx.stopRain();
|
||||
}
|
||||
|
||||
private crowdTarget(): number {
|
||||
if (!this.enabled.crowd) return 0;
|
||||
return CROWD_PEAK * (1 - Math.exp(-this.count / CROWD_K));
|
||||
}
|
||||
|
||||
private crowdDuck(): number {
|
||||
return this.loc === 'floor' ? 1 : CROWD_DOOR_DUCK;
|
||||
}
|
||||
|
||||
private kebabTarget(): number {
|
||||
return this.enabled.kebab ? KEBAB_LEVEL : 0;
|
||||
}
|
||||
|
||||
private kebabDuck(): number {
|
||||
return this.loc === 'door' ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bodies and talking. A wide bandpass around 500Hz is the whole trick: above
|
||||
* ~1kHz noise turns into hiss and below ~250Hz it turns into traffic, and the
|
||||
* band between them is where a room full of people sits.
|
||||
*/
|
||||
class CrowdBed {
|
||||
private readonly src: AudioBufferSourceNode;
|
||||
private readonly lfos: OscillatorNode[] = [];
|
||||
private readonly chain: AudioNode[] = [];
|
||||
private readonly level: GainNode;
|
||||
private readonly loc: GainNode;
|
||||
private readonly ctx: AudioContext;
|
||||
|
||||
constructor(ctx: AudioContext, dest: AudioNode, buffer: AudioBuffer) {
|
||||
this.ctx = ctx;
|
||||
|
||||
this.src = ctx.createBufferSource();
|
||||
this.src.buffer = buffer;
|
||||
this.src.loop = true;
|
||||
// Slowed a little: it drags the noise's spectral centre down and costs nothing.
|
||||
this.src.playbackRate.value = 0.85;
|
||||
|
||||
const band = ctx.createBiquadFilter();
|
||||
band.type = 'bandpass';
|
||||
band.frequency.value = 500;
|
||||
band.Q.value = 0.75; // ~300-800Hz at -3dB
|
||||
|
||||
// Second pass shaves the fizz the bandpass's gentle skirt lets through.
|
||||
const lp = ctx.createBiquadFilter();
|
||||
lp.type = 'lowpass';
|
||||
lp.frequency.value = 1100;
|
||||
|
||||
// Two slow LFOs on a multiplying node, not on the level itself — swelling a
|
||||
// gain of 0 would push it negative, and an empty room has to be silent.
|
||||
const wobble = ctx.createGain();
|
||||
wobble.gain.value = 1;
|
||||
this.wobbler(wobble, 0.19, 0.12);
|
||||
this.wobbler(wobble, 0.07, 0.07);
|
||||
|
||||
this.level = ctx.createGain();
|
||||
this.level.gain.value = 0;
|
||||
|
||||
this.loc = ctx.createGain();
|
||||
this.loc.gain.value = 0;
|
||||
|
||||
this.chain.push(band, lp, wobble, this.level, this.loc);
|
||||
this.src.connect(band).connect(lp).connect(wobble).connect(this.level).connect(this.loc);
|
||||
this.loc.connect(dest);
|
||||
this.src.start();
|
||||
}
|
||||
|
||||
setLevel(to: number, durS: number): void {
|
||||
ramp(this.ctx, this.level.gain, to, durS);
|
||||
}
|
||||
|
||||
setLocation(to: number, durS: number): void {
|
||||
ramp(this.ctx, this.loc.gain, to, durS);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
stopBed(this.ctx, this.loc, this.src, this.lfos, this.chain);
|
||||
}
|
||||
|
||||
private wobbler(target: GainNode, hz: number, depth: number): void {
|
||||
const lfo = this.ctx.createOscillator();
|
||||
lfo.type = 'sine';
|
||||
lfo.frequency.value = hz;
|
||||
const amount = this.ctx.createGain();
|
||||
amount.gain.value = depth;
|
||||
lfo.connect(amount).connect(target.gain);
|
||||
lfo.start();
|
||||
this.lfos.push(lfo);
|
||||
this.chain.push(amount);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The kebab shop's fluoro tube, heard from the footpath. 100Hz because a tube
|
||||
* buzzes at twice mains frequency; the 50Hz underneath is the ballast doing the
|
||||
* work. Mono and completely dry — it is six metres away through an open
|
||||
* shopfront, and reverb would put it in a cathedral.
|
||||
*/
|
||||
class KebabBed {
|
||||
private readonly oscs: OscillatorNode[] = [];
|
||||
private readonly chain: AudioNode[] = [];
|
||||
private readonly level: GainNode;
|
||||
private readonly loc: GainNode;
|
||||
private readonly ctx: AudioContext;
|
||||
|
||||
constructor(ctx: AudioContext, dest: AudioNode) {
|
||||
this.ctx = ctx;
|
||||
|
||||
const sum = ctx.createGain();
|
||||
sum.gain.value = 1;
|
||||
|
||||
// Fundamental, mains harmonic, and a thin 200Hz that keeps it from reading
|
||||
// as a clean test tone.
|
||||
this.partial(100, 1, sum);
|
||||
this.partial(50, 0.45, sum);
|
||||
this.partial(200, 0.18, sum);
|
||||
|
||||
const lp = ctx.createBiquadFilter();
|
||||
lp.type = 'lowpass';
|
||||
lp.frequency.value = 400;
|
||||
|
||||
// A dying tube breathes. 0.6Hz at 8% is under conscious notice and is the
|
||||
// difference between "hum" and "sine wave someone left running".
|
||||
const flicker = ctx.createGain();
|
||||
flicker.gain.value = 1;
|
||||
const lfo = ctx.createOscillator();
|
||||
lfo.type = 'sine';
|
||||
lfo.frequency.value = 0.6;
|
||||
const depth = ctx.createGain();
|
||||
depth.gain.value = 0.08;
|
||||
lfo.connect(depth).connect(flicker.gain);
|
||||
lfo.start();
|
||||
this.oscs.push(lfo);
|
||||
|
||||
this.level = ctx.createGain();
|
||||
this.level.gain.value = 0;
|
||||
|
||||
this.loc = ctx.createGain();
|
||||
this.loc.gain.value = 0;
|
||||
|
||||
this.chain.push(sum, lp, depth, flicker, this.level, this.loc);
|
||||
sum.connect(lp).connect(flicker).connect(this.level).connect(this.loc);
|
||||
this.loc.connect(dest);
|
||||
}
|
||||
|
||||
setLevel(to: number, durS: number): void {
|
||||
ramp(this.ctx, this.level.gain, to, durS);
|
||||
}
|
||||
|
||||
setLocation(to: number, durS: number): void {
|
||||
ramp(this.ctx, this.loc.gain, to, durS);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
stopBed(this.ctx, this.loc, null, this.oscs, this.chain);
|
||||
}
|
||||
|
||||
private partial(hz: number, gain: number, sum: GainNode): void {
|
||||
const osc = this.ctx.createOscillator();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = hz;
|
||||
const g = this.ctx.createGain();
|
||||
g.gain.value = gain;
|
||||
osc.connect(g).connect(sum);
|
||||
osc.start();
|
||||
this.oscs.push(osc);
|
||||
this.chain.push(g);
|
||||
}
|
||||
}
|
||||
|
||||
// --- plumbing ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Linear throughout, unlike the engine's sweeps: these targets legitimately
|
||||
* reach 0 (empty room, bed disabled) and an exponential ramp to zero throws.
|
||||
* Re-anchoring at the live value first keeps an interrupted fade continuous —
|
||||
* players do walk in and straight back out.
|
||||
*
|
||||
* Read `param.value` BEFORE cancelling, same as `TechnoEngine.ramp()`.
|
||||
* `cancelScheduledValues(now)` removes every event at or after `now`, including
|
||||
* the in-flight ramp's end event, and the spec does not say what the getter
|
||||
* reads afterwards: Blink returns the last rendered value, but a UA computing it
|
||||
* from the surviving timeline returns the ramp's origin. Reading first pins the
|
||||
* mid-ramp value the player is actually hearing, so a crowd bed 350ms into a
|
||||
* 0.15 -> 1 door->floor swell fades from ~0.57 rather than jumping down to 0.15.
|
||||
*
|
||||
* `cancelAndHoldAtTime` holds that value at `now` instead of discarding it,
|
||||
* which is the same thing done properly — but Firefox has never shipped it, so
|
||||
* feature-detect and fall back.
|
||||
*/
|
||||
function ramp(ctx: AudioContext, param: AudioParam, to: number, durS: number): void {
|
||||
const now = ctx.currentTime;
|
||||
const from = param.value;
|
||||
if (typeof param.cancelAndHoldAtTime === 'function') param.cancelAndHoldAtTime(now);
|
||||
else param.cancelScheduledValues(now);
|
||||
param.setValueAtTime(from, now);
|
||||
if (durS <= 0) param.setValueAtTime(to, now);
|
||||
else param.linearRampToValueAtTime(to, now + durS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fade out, then stop and disconnect everything after the fade lands. Cutting a
|
||||
* running noise bed is audible as a click, and leaving the nodes attached means
|
||||
* a six-hour night accumulates every bed it ever started.
|
||||
*/
|
||||
function stopBed(
|
||||
ctx: AudioContext,
|
||||
out: GainNode,
|
||||
src: AudioBufferSourceNode | null,
|
||||
oscs: readonly OscillatorNode[],
|
||||
chain: readonly AudioNode[],
|
||||
): void {
|
||||
const fade = 0.25;
|
||||
const at = ctx.currentTime + fade;
|
||||
ramp(ctx, out.gain, 0, fade);
|
||||
|
||||
const tail = src ?? oscs[0];
|
||||
if (tail) {
|
||||
tail.onended = () => {
|
||||
for (const n of chain) n.disconnect();
|
||||
src?.disconnect();
|
||||
for (const o of oscs) o.disconnect();
|
||||
};
|
||||
}
|
||||
src?.stop(at);
|
||||
for (const o of oscs) o.stop(at);
|
||||
}
|
||||
|
||||
/** Deterministic white noise — house rule forbids Math.random, and a fixed seed
|
||||
* means the room sounds the same on every run. */
|
||||
function noiseBuffer(ctx: AudioContext): AudioBuffer {
|
||||
const len = Math.floor(ctx.sampleRate * NOISE_SECONDS);
|
||||
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
|
||||
const data = buf.getChannelData(0);
|
||||
const rng = new SeededRNG(0x4b3b).stream('crowd-noise');
|
||||
for (let i = 0; i < len; i++) data[i] = rng.next() * 2 - 1;
|
||||
return buf;
|
||||
}
|
||||
@ -16,7 +16,8 @@ export type SfxName =
|
||||
| 'phoneBuzz'
|
||||
| 'ropeUnhook'
|
||||
| 'denyCrowdOoh'
|
||||
| 'typeTick';
|
||||
| 'typeTick'
|
||||
| 'glassSmash';
|
||||
|
||||
/** Seconds of white noise generated once and shared by every noise voice. */
|
||||
const NOISE_SECONDS = 2;
|
||||
@ -84,6 +85,8 @@ export class Sfx {
|
||||
return this.denyCrowdOoh();
|
||||
case 'typeTick':
|
||||
return this.typeTick();
|
||||
case 'glassSmash':
|
||||
return this.glassSmash();
|
||||
}
|
||||
}
|
||||
|
||||
@ -271,6 +274,15 @@ export class Sfx {
|
||||
this.voice(src, [this.band(3400, 2.2), this.env(t, 0.0005, 0.002, 0.012, 0.045)], t, 0.016);
|
||||
}
|
||||
|
||||
/** A pint glass meeting a tiled floor: bright burst, short shard ring. */
|
||||
glassSmash(): void {
|
||||
const t = this.ctx.currentTime;
|
||||
const src = this.noiseSource();
|
||||
this.voice(src, [this.band(3100, 1.1), this.env(t, 0.001, 0.02, 0.12, 0.22)], t, 0.24);
|
||||
const ring = this.noiseSource();
|
||||
this.voice(ring, [this.band(5200, 6), this.env(t + 0.03, 0.001, 0.01, 0.08, 0.16)], t + 0.03, 0.18);
|
||||
}
|
||||
|
||||
/** Idempotent — the door scene may call this on every re-entry. */
|
||||
startRain(): void {
|
||||
if (this.rain) return;
|
||||
|
||||
@ -1,11 +1,22 @@
|
||||
import type { EventBus } from '../core/EventBus';
|
||||
import { SeededRNG } from '../core/SeededRNG';
|
||||
import { type ArcScales, arcAt } from './arc';
|
||||
import {
|
||||
type AudioLocation,
|
||||
cutoffFor,
|
||||
gainFor,
|
||||
sweepMsFor,
|
||||
} from './filterCurve';
|
||||
import {
|
||||
DEFAULT_MIX,
|
||||
MIX_RANGES,
|
||||
type TrackMix,
|
||||
VOICE_NAMES,
|
||||
type VoiceName,
|
||||
clampTo,
|
||||
cloneMix,
|
||||
voiceGain,
|
||||
} from './mix';
|
||||
import {
|
||||
type BeatGrid,
|
||||
barOf,
|
||||
@ -16,7 +27,6 @@ import {
|
||||
patternStep,
|
||||
subdivisions,
|
||||
} from './scheduling';
|
||||
import '../ui/juiceEvents';
|
||||
|
||||
const DEFAULT_BPM = 128;
|
||||
const DEFAULT_LOOKAHEAD_MS = 100;
|
||||
@ -30,28 +40,33 @@ const TIMER_MS = 25;
|
||||
*/
|
||||
const START_LEAD_MS = 60;
|
||||
|
||||
const MASTER_GAIN = 0.8;
|
||||
|
||||
// Resonance: a hair of peak at the cutoff is what reads as "bass through a wall"
|
||||
// rather than "someone turned the treble down". Flat once you're on the floor.
|
||||
const DOOR_Q = 6;
|
||||
const FLOOR_Q = 0.7;
|
||||
|
||||
const KICK_PEAK = 0.9;
|
||||
const HAT_PEAK = 0.25;
|
||||
const BASS_PEAK = 0.11;
|
||||
const PAD_PEAK = 0.05;
|
||||
|
||||
const SILENT = 0.0001; // exponential ramps cannot reach 0
|
||||
|
||||
const BASS_ROOT_HZ = 55; // A1
|
||||
const BASS_STEPS = 32; // 2 bars of 16ths
|
||||
const PAD_BARS = 8;
|
||||
|
||||
/** Seconds to the top of the bass filter sweep. Shape, not level — not tunable. */
|
||||
const BASS_SWEEP_S = 0.03;
|
||||
|
||||
/**
|
||||
* Semitones from A1, one entry per 16th. Nothing lands on a downbeat — the kick
|
||||
* owns that slot, and leaving it empty is what makes the bassline roll instead
|
||||
* of thud.
|
||||
* The bass tail settles a touch above the sweep floor: `filterLoHz` is where the
|
||||
* note *starts*, and resting exactly there makes the release sound like a gate.
|
||||
* Expressed as a ratio so retuning the floor carries the rest point with it.
|
||||
*/
|
||||
const BASS_TAIL_RATIO = 200 / 180;
|
||||
|
||||
/** Fraction of the pad cycle spent fading in; the rest fades out. */
|
||||
const PAD_RISE = 0.4;
|
||||
|
||||
/** A slider drag on `master` should not zipper, but should not lag either. */
|
||||
const KNOB_RAMP_S = 0.03;
|
||||
|
||||
/** Mix paths the engine rounds at use-time, so they must be stored rounded too. */
|
||||
const INTEGER_PATHS = new Set(['pad.bars']);
|
||||
|
||||
/**
|
||||
* Semitones from the bass root, one entry per 16th. Nothing lands on a downbeat
|
||||
* — the kick owns that slot, and leaving it empty is what makes the bassline
|
||||
* roll instead of thud.
|
||||
*/
|
||||
const BASS_PATTERN: readonly (number | null)[] = [
|
||||
null, null, 0, 0,
|
||||
@ -71,6 +86,29 @@ const PAD_VOICES: ReadonlyArray<{ hz: number; detune: number; type: OscillatorTy
|
||||
{ hz: 164.81, detune: 0, type: 'triangle' },
|
||||
];
|
||||
|
||||
/** Widened once so a dotted path can be looked up without a cast at each site. */
|
||||
const RANGES: Record<string, readonly [number, number] | undefined> = MIX_RANGES;
|
||||
|
||||
const GROUP_NAMES: readonly VoiceName[] = VOICE_NAMES;
|
||||
|
||||
interface MixLens {
|
||||
read: () => number;
|
||||
write: (v: number) => void;
|
||||
range: readonly [number, number];
|
||||
}
|
||||
|
||||
/**
|
||||
* The engine currently owning the AudioContext, or null.
|
||||
*
|
||||
* The tuning desk has to reach the engine during a real night, when the scene
|
||||
* that constructed it belongs to another lane and there is no route to it. A
|
||||
* module variable is the smallest thing that solves that — deliberately not a
|
||||
* window global, which would make it part of the page's public surface.
|
||||
*/
|
||||
let activeEngine: TechnoEngine | null = null;
|
||||
|
||||
export const getActiveEngine = (): TechnoEngine | null => activeEngine;
|
||||
|
||||
export interface TechnoEngineOptions {
|
||||
bpm?: number;
|
||||
lookaheadMs?: number;
|
||||
@ -86,6 +124,10 @@ export interface TechnoEngineOptions {
|
||||
* future. That timer is throttled on a blurred tab exactly as rAF is, so the
|
||||
* look-ahead alone does not save us; what does is `resync()`, which drops the
|
||||
* span that elapsed while we were asleep instead of dumping it into the graph.
|
||||
*
|
||||
* Every level and shape comes off a live `TrackMix` scaled by the night arc, so
|
||||
* the track can be retuned and auditioned mid-playback. Nothing is read at
|
||||
* construction that is not re-read on the next beat.
|
||||
*/
|
||||
export class TechnoEngine {
|
||||
readonly ctx: AudioContext;
|
||||
@ -106,6 +148,12 @@ export class TechnoEngine {
|
||||
*/
|
||||
private readonly voices = new Map<AudioScheduledSourceNode, AudioNode[]>();
|
||||
|
||||
private mixState: TrackMix = cloneMix(DEFAULT_MIX);
|
||||
private clockMinute = 0;
|
||||
private pinnedMinute: number | null = null;
|
||||
/** Cached so the scheduler is not re-interpolating the arc ~20 times a second. */
|
||||
private scales: ArcScales = arcAt(0);
|
||||
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private nextBeat = 0;
|
||||
private loc: AudioLocation = 'door';
|
||||
@ -123,13 +171,13 @@ export class TechnoEngine {
|
||||
this.grid = { bpm: this.bpm, originMs: 0 };
|
||||
|
||||
this.master = this.ctx.createGain();
|
||||
this.master.gain.value = MASTER_GAIN;
|
||||
this.master.gain.value = this.mixState.master;
|
||||
this.master.connect(this.ctx.destination);
|
||||
|
||||
this.filter = this.ctx.createBiquadFilter();
|
||||
this.filter.type = 'lowpass';
|
||||
this.filter.frequency.value = cutoffFor(this.loc);
|
||||
this.filter.Q.value = DOOR_Q;
|
||||
this.filter.Q.value = this.qFor(this.loc);
|
||||
this.filter.connect(this.master);
|
||||
|
||||
this.musicBus = this.ctx.createGain();
|
||||
@ -145,7 +193,14 @@ export class TechnoEngine {
|
||||
|
||||
this.unsubs.push(
|
||||
this.bus.on('audio:location', ({ location }) => this.setLocation(location)),
|
||||
this.bus.on('clock:tick', ({ clockMin }) => {
|
||||
this.clockMinute = clockMin;
|
||||
if (this.pinnedMinute === null) this.scales = arcAt(clockMin);
|
||||
}),
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias -- the registry is the point
|
||||
activeEngine = this;
|
||||
}
|
||||
|
||||
get running(): boolean {
|
||||
@ -164,6 +219,76 @@ export class TechnoEngine {
|
||||
return this.loc;
|
||||
}
|
||||
|
||||
// --- mix ------------------------------------------------------------------
|
||||
|
||||
get mix(): Readonly<TrackMix> {
|
||||
return this.mixState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set one knob by dotted path: `'master'`, `'levels.<voice>'`,
|
||||
* `'<voice>.<field>'`, `'doorQ'`, `'floorQ'`.
|
||||
*
|
||||
* An unknown path is inert rather than fatal — the tuning desk is generated
|
||||
* from a table, and a typo there should cost one dead slider, not the night.
|
||||
*/
|
||||
setMixValue(path: string, value: number): void {
|
||||
const lens = this.lens(path);
|
||||
if (!lens) return;
|
||||
// Quantise here rather than at the slider: what we store is what formatMix
|
||||
// dumps, so a fractional bar count would export a value nobody ever heard.
|
||||
// The engine owns the invariant because it is the thing that rounds at use.
|
||||
const v = clampTo(INTEGER_PATHS.has(path) ? Math.round(value) : value, lens.range);
|
||||
lens.write(v);
|
||||
|
||||
// Levels and shapes land on the next scheduled note (<500ms, inaudible as
|
||||
// lag). These two are continuous, so they have to move now.
|
||||
if (path === 'master') this.ramp(this.master.gain, v, KNOB_RAMP_S, false);
|
||||
if (path === this.loc + 'Q' && this.override === null) {
|
||||
this.ramp(this.filter.Q, v, KNOB_RAMP_S, false);
|
||||
}
|
||||
}
|
||||
|
||||
/** NaN for an unknown path — a wrong-but-plausible 0 would read as a real value. */
|
||||
getMixValue(path: string): number {
|
||||
return this.lens(path)?.read() ?? Number.NaN;
|
||||
}
|
||||
|
||||
setVoiceEnabled(voice: VoiceName, on: boolean): void {
|
||||
this.mixState.enabled[voice] = on;
|
||||
}
|
||||
|
||||
resetMix(): void {
|
||||
this.mixState = cloneMix(DEFAULT_MIX);
|
||||
this.ramp(this.master.gain, this.mixState.master, KNOB_RAMP_S, false);
|
||||
if (this.override === null) {
|
||||
this.ramp(this.filter.Q, this.qFor(this.loc), KNOB_RAMP_S, false);
|
||||
}
|
||||
}
|
||||
|
||||
// --- night arc ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pin the arc to a minute for the ear pass — this is how 1 AM gets auditioned
|
||||
* at 9 PM. null hands the arc back to `clock:tick`.
|
||||
*/
|
||||
setArcMinute(min: number | null): void {
|
||||
this.pinnedMinute = min;
|
||||
this.scales = arcAt(this.arcMinute);
|
||||
}
|
||||
|
||||
get arcMinute(): number {
|
||||
return this.pinnedMinute ?? this.clockMinute;
|
||||
}
|
||||
|
||||
get arcScales(): ArcScales {
|
||||
return { ...this.scales };
|
||||
}
|
||||
|
||||
get arcPinned(): boolean {
|
||||
return this.pinnedMinute !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume after a real user gesture. Idempotent, and safe to call concurrently
|
||||
* — overlapping callers await the same resume rather than racing the origin.
|
||||
@ -179,7 +304,9 @@ export class TechnoEngine {
|
||||
this.grid.originMs = atMs + START_LEAD_MS;
|
||||
this.nextBeat = 0;
|
||||
this.unlockedFlag = true;
|
||||
this.bus.emit('audio:unlocked', { atMs });
|
||||
// Review ruling: atMs carries the ANCHORED beat-0 origin, not the resume
|
||||
// instant — consumers may treat beat 0 as landing exactly at atMs.
|
||||
this.bus.emit('audio:unlocked', { atMs: this.grid.originMs });
|
||||
})();
|
||||
|
||||
try {
|
||||
@ -227,7 +354,7 @@ export class TechnoEngine {
|
||||
const durS = immediate ? 0 : sweepMsFor(location) / 1000;
|
||||
this.ramp(this.filter.frequency, cutoffFor(location), durS, true);
|
||||
this.ramp(this.musicBus.gain, gainFor(location), durS, false);
|
||||
this.ramp(this.filter.Q, location === 'floor' ? FLOOR_Q : DOOR_Q, durS, false);
|
||||
this.ramp(this.filter.Q, this.qFor(location), durS, false);
|
||||
}
|
||||
|
||||
/** Non-null pins the cutoff and suppresses location sweeps; null hands it back. */
|
||||
@ -244,6 +371,7 @@ export class TechnoEngine {
|
||||
this.stop();
|
||||
for (const off of this.unsubs) off();
|
||||
this.unsubs.length = 0;
|
||||
if (activeEngine === this) activeEngine = null;
|
||||
void this.ctx.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
@ -289,104 +417,148 @@ export class TechnoEngine {
|
||||
this.nextBeat = Math.ceil(firstFuture / 4) * 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective gain for a voice right now: level, mute and arc folded together.
|
||||
* Shared with the tuning desk via mix.ts so the number on screen is the number
|
||||
* the scheduler uses.
|
||||
*/
|
||||
private gainOf(voice: VoiceName): number {
|
||||
return voiceGain(this.mixState, voice, this.scales[voice]);
|
||||
}
|
||||
|
||||
private scheduleBeat(beatIndex: number, timeMs: number): void {
|
||||
const sixteenths = subdivisions(this.grid, beatIndex, 4);
|
||||
const mix = this.mixState;
|
||||
|
||||
this.kick(timeMs / 1000);
|
||||
// Silent voices are skipped, not scheduled quietly: an exponential ramp to
|
||||
// zero throws, and at 9 PM the arc mutes the bass outright — no reason to
|
||||
// build and tear down eight nodes a second for something nobody can hear.
|
||||
const kickGain = this.gainOf('kick');
|
||||
if (kickGain > SILENT) this.kick(timeMs / 1000, kickGain);
|
||||
|
||||
const hatGain = this.gainOf('hat');
|
||||
const offbeat = sixteenths[2]; // the 8th between beats
|
||||
if (offbeat !== undefined) this.hat(offbeat / 1000);
|
||||
if (hatGain > SILENT && offbeat !== undefined) this.hat(offbeat / 1000, hatGain);
|
||||
|
||||
for (let s = 0; s < sixteenths.length; s++) {
|
||||
const at = sixteenths[s];
|
||||
const semi = BASS_PATTERN[patternStep(beatIndex, s, BASS_STEPS)];
|
||||
if (at === undefined || semi === undefined || semi === null) continue;
|
||||
this.bass(at / 1000, BASS_ROOT_HZ * Math.pow(2, semi / 12));
|
||||
const bassGain = this.gainOf('bass');
|
||||
if (bassGain > SILENT) {
|
||||
for (let s = 0; s < sixteenths.length; s++) {
|
||||
const at = sixteenths[s];
|
||||
const semi = BASS_PATTERN[patternStep(beatIndex, s, BASS_STEPS)];
|
||||
if (at === undefined || semi === undefined || semi === null) continue;
|
||||
this.bass(at / 1000, mix.bass.rootHz * Math.pow(2, semi / 12), bassGain);
|
||||
}
|
||||
}
|
||||
|
||||
if (isDownbeat(beatIndex) && barOf(beatIndex) % PAD_BARS === 0) this.pad(timeMs / 1000);
|
||||
const padGain = this.gainOf('pad');
|
||||
const padBars = this.padBars();
|
||||
if (padGain > SILENT && isDownbeat(beatIndex) && barOf(beatIndex) % padBars === 0) {
|
||||
this.pad(timeMs / 1000, padGain, padBars);
|
||||
}
|
||||
}
|
||||
|
||||
/** The pad's cycle length doubles as its retrigger interval, so it must be a whole bar count. */
|
||||
private padBars(): number {
|
||||
return Math.max(1, Math.round(this.mixState.pad.bars));
|
||||
}
|
||||
|
||||
// --- voices ---------------------------------------------------------------
|
||||
|
||||
private kick(at: number): void {
|
||||
private kick(at: number, peak: number): void {
|
||||
const k = this.mixState.kick;
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
osc.type = 'sine';
|
||||
|
||||
// The pitch drop is the beater; the tail is the cone. This is the one voice
|
||||
// that has to survive a 250Hz lowpass, so it gets a long body.
|
||||
osc.frequency.setValueAtTime(150, at);
|
||||
osc.frequency.exponentialRampToValueAtTime(50, at + 0.06);
|
||||
osc.frequency.setValueAtTime(k.startHz, at);
|
||||
osc.frequency.exponentialRampToValueAtTime(k.endHz, at + k.dropS);
|
||||
|
||||
// A decay tuned shorter than the attack would schedule the ramps out of
|
||||
// order; the desk allows that combination, WebAudio does not.
|
||||
const decayS = Math.max(k.attackS + 0.005, k.decayS);
|
||||
gain.gain.setValueAtTime(SILENT, at);
|
||||
gain.gain.exponentialRampToValueAtTime(KICK_PEAK, at + 0.008);
|
||||
gain.gain.exponentialRampToValueAtTime(SILENT, at + 0.34);
|
||||
gain.gain.exponentialRampToValueAtTime(peak, at + k.attackS);
|
||||
gain.gain.exponentialRampToValueAtTime(SILENT, at + decayS);
|
||||
|
||||
osc.connect(gain).connect(this.musicBus);
|
||||
osc.start(at);
|
||||
osc.stop(at + 0.36);
|
||||
osc.stop(at + decayS + 0.02);
|
||||
this.hold(osc, [gain]);
|
||||
}
|
||||
|
||||
private hat(at: number): void {
|
||||
private hat(at: number, peak: number): void {
|
||||
const h = this.mixState.hat;
|
||||
const src = this.ctx.createBufferSource();
|
||||
src.buffer = this.noiseBuffer;
|
||||
// Cheap variety: start the read head somewhere different each hit so the
|
||||
// one shared buffer doesn't ring identically 400 times a minute.
|
||||
const offset = (at * 7.3) % (this.noiseBuffer.duration - 0.1);
|
||||
|
||||
const hp = this.ctx.createBiquadFilter();
|
||||
hp.type = 'highpass';
|
||||
hp.frequency.value = 7000;
|
||||
hp.frequency.value = h.hpHz;
|
||||
|
||||
const gain = this.ctx.createGain();
|
||||
gain.gain.setValueAtTime(HAT_PEAK, at);
|
||||
gain.gain.exponentialRampToValueAtTime(SILENT, at + 0.04);
|
||||
gain.gain.setValueAtTime(peak, at);
|
||||
gain.gain.exponentialRampToValueAtTime(SILENT, at + h.decayS);
|
||||
|
||||
src.connect(hp).connect(gain).connect(this.musicBus);
|
||||
src.start(at, offset, 0.06);
|
||||
// Read far enough to cover the envelope; a longer decay must not be cut off
|
||||
// by the buffer running out under it.
|
||||
const readS = Math.min(Math.max(0.06, h.decayS + 0.02), this.noiseBuffer.duration);
|
||||
// Cheap variety: start the read head somewhere different each hit so the one
|
||||
// shared buffer doesn't ring identically 400 times a minute. The span is the
|
||||
// buffer minus the read length, so offset + readS lands inside the buffer for
|
||||
// any tuning of hat.decayS; a read as long as the buffer leaves no span.
|
||||
const span = this.noiseBuffer.duration - readS;
|
||||
const offset = span > 0 ? (at * 7.3) % span : 0;
|
||||
src.start(at, offset, readS);
|
||||
this.hold(src, [hp, gain]);
|
||||
}
|
||||
|
||||
private bass(at: number, hz: number): void {
|
||||
private bass(at: number, hz: number, peak: number): void {
|
||||
const b = this.mixState.bass;
|
||||
const lp = this.ctx.createBiquadFilter();
|
||||
lp.type = 'lowpass';
|
||||
lp.Q.value = 8;
|
||||
lp.Q.value = b.q;
|
||||
|
||||
const decayS = Math.max(BASS_SWEEP_S + 0.02, b.decayS);
|
||||
// Per-note filter sweep — the "wow" that stops 16ths sounding like a fax.
|
||||
lp.frequency.setValueAtTime(180, at);
|
||||
lp.frequency.exponentialRampToValueAtTime(900, at + 0.03);
|
||||
lp.frequency.exponentialRampToValueAtTime(200, at + 0.11);
|
||||
lp.frequency.setValueAtTime(b.filterLoHz, at);
|
||||
lp.frequency.exponentialRampToValueAtTime(b.filterHiHz, at + BASS_SWEEP_S);
|
||||
lp.frequency.exponentialRampToValueAtTime(b.filterLoHz * BASS_TAIL_RATIO, at + decayS - 0.01);
|
||||
|
||||
const gain = this.ctx.createGain();
|
||||
gain.gain.setValueAtTime(SILENT, at);
|
||||
gain.gain.exponentialRampToValueAtTime(BASS_PEAK, at + 0.006);
|
||||
gain.gain.exponentialRampToValueAtTime(SILENT, at + 0.12);
|
||||
gain.gain.exponentialRampToValueAtTime(peak, at + 0.006);
|
||||
gain.gain.exponentialRampToValueAtTime(SILENT, at + decayS);
|
||||
|
||||
lp.connect(gain).connect(this.musicBus);
|
||||
|
||||
for (const detune of [-8, 8]) {
|
||||
const spread = [-b.detuneCents, b.detuneCents];
|
||||
spread.forEach((detune, i) => {
|
||||
const osc = this.ctx.createOscillator();
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.value = hz;
|
||||
osc.detune.value = detune;
|
||||
osc.connect(lp);
|
||||
osc.start(at);
|
||||
osc.stop(at + 0.13);
|
||||
this.hold(osc, detune > 0 ? [lp, gain] : []);
|
||||
}
|
||||
osc.stop(at + decayS + 0.01);
|
||||
// Hang the shared chain off the LAST saw by index, not by sign: at zero
|
||||
// detune both are 0 and a sign test would strand lp/gain on the bus.
|
||||
this.hold(osc, i === spread.length - 1 ? [lp, gain] : []);
|
||||
});
|
||||
}
|
||||
|
||||
private pad(at: number): void {
|
||||
const cycle = (PAD_BARS * 4 * beatIntervalMs(this.bpm)) / 1000;
|
||||
private pad(at: number, peak: number, bars: number): void {
|
||||
const cycle = (bars * 4 * beatIntervalMs(this.bpm)) / 1000;
|
||||
|
||||
const lp = this.ctx.createBiquadFilter();
|
||||
lp.type = 'lowpass';
|
||||
lp.frequency.value = 600;
|
||||
lp.frequency.value = this.mixState.pad.lpHz;
|
||||
|
||||
const gain = this.ctx.createGain();
|
||||
gain.gain.setValueAtTime(0, at);
|
||||
gain.gain.linearRampToValueAtTime(PAD_PEAK, at + cycle * 0.4);
|
||||
gain.gain.linearRampToValueAtTime(peak, at + cycle * PAD_RISE);
|
||||
gain.gain.linearRampToValueAtTime(0, at + cycle);
|
||||
|
||||
lp.connect(gain).connect(this.musicBus);
|
||||
@ -405,6 +577,44 @@ export class TechnoEngine {
|
||||
|
||||
// --- plumbing -------------------------------------------------------------
|
||||
|
||||
private qFor(location: AudioLocation): number {
|
||||
return location === 'floor' ? this.mixState.floorQ : this.mixState.doorQ;
|
||||
}
|
||||
|
||||
/** Resolve a dotted mix path to a get/set/clamp triple, or null if it is not a knob. */
|
||||
private lens(path: string): MixLens | null {
|
||||
const m = this.mixState;
|
||||
|
||||
if (path === 'master' || path === 'doorQ' || path === 'floorQ') {
|
||||
const range = RANGES[path];
|
||||
if (!range) return null;
|
||||
const key = path;
|
||||
return { read: () => m[key], write: (v) => { m[key] = v; }, range };
|
||||
}
|
||||
|
||||
const dot = path.indexOf('.');
|
||||
if (dot <= 0) return null;
|
||||
const head = path.slice(0, dot);
|
||||
const tail = path.slice(dot + 1);
|
||||
|
||||
if (head === 'levels') {
|
||||
// MIX_RANGES keys the shared bound as 'level' (singular) — one range for
|
||||
// all four faders — while the path is per-voice.
|
||||
if (!(VOICE_NAMES as readonly string[]).includes(tail)) return null;
|
||||
const v = tail as VoiceName;
|
||||
return { read: () => m.levels[v], write: (x) => { m.levels[v] = x; }, range: MIX_RANGES.level };
|
||||
}
|
||||
|
||||
if (!(GROUP_NAMES as readonly string[]).includes(head)) return null;
|
||||
// The four param groups have no index signature by design — the widening is
|
||||
// confined here, behind the hasOwn check below.
|
||||
const group = m[head as VoiceName] as unknown as Record<string, number>;
|
||||
if (!Object.hasOwn(group, tail)) return null;
|
||||
const range = RANGES[path];
|
||||
if (!range) return null;
|
||||
return { read: () => group[tail] ?? Number.NaN, write: (x) => { group[tail] = x; }, range };
|
||||
}
|
||||
|
||||
/**
|
||||
* One second of deterministic white noise, built once and re-read per hat.
|
||||
* Allocating a buffer per hit would mean ~8 buffers a second for six hours.
|
||||
|
||||
98
src/audio/arc.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { VOICE_NAMES, type VoiceName } from './mix';
|
||||
|
||||
// The night's shape, as gain scalars per voice.
|
||||
//
|
||||
// A club does not play the same track at 9 PM and at 1 AM, and a room that
|
||||
// sounds identical for six hours is the main reason a long shift reads as flat.
|
||||
// So the mix opens thin — kick and a bit of hat, the sound of a room hoping
|
||||
// people turn up — the bassline arrives once there is a crowd to carry it, the
|
||||
// pads land at peak, and Last Drinks strips it back so the lights coming up
|
||||
// feel earned rather than abrupt.
|
||||
//
|
||||
// Pure keyframe interpolation: no audio types here, so it is testable in node
|
||||
// and reusable by the Phase-4 DJ role, which drives this same surface by hand.
|
||||
|
||||
/** clockMin is minutes since 21:00; the night ends at 360 (3 AM). */
|
||||
export const NIGHT_END_MIN = 360;
|
||||
|
||||
export type ArcScales = Record<VoiceName, number>;
|
||||
|
||||
export interface ArcKeyframe {
|
||||
/** Minutes since 21:00. */
|
||||
at: number;
|
||||
label: string;
|
||||
scales: ArcScales;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyframes, ascending by `at`. Deliberately sparse — the interpolation between
|
||||
* them is what makes the build feel gradual rather than stepped.
|
||||
*/
|
||||
export const ARC: readonly ArcKeyframe[] = [
|
||||
// 9 PM. Doors open to an empty room. Kick and a whisper of hat.
|
||||
{ at: 0, label: 'EMPTY', scales: { kick: 0.8, hat: 0.4, bass: 0, pad: 0 } },
|
||||
// 10 PM. First real queue. The bassline turns up with them.
|
||||
{ at: 60, label: 'FILLING', scales: { kick: 0.95, hat: 0.75, bass: 0.5, pad: 0.15 } },
|
||||
// 11 PM. Room has weight.
|
||||
{ at: 120, label: 'BUILDING', scales: { kick: 1, hat: 0.9, bass: 0.85, pad: 0.5 } },
|
||||
// Midnight through 2 AM. Everything, all of it.
|
||||
{ at: 180, label: 'PEAK', scales: { kick: 1, hat: 1, bass: 1, pad: 1 } },
|
||||
{ at: 300, label: 'PEAK', scales: { kick: 1, hat: 1, bass: 1, pad: 1 } },
|
||||
// 2:30. Last drinks. Pull the low end first — that is what empties a floor.
|
||||
{ at: 330, label: 'LAST DRINKS', scales: { kick: 0.9, hat: 0.7, bass: 0.55, pad: 0.85 } },
|
||||
// 3 AM. Pads over a dying kick while the lights come up.
|
||||
{ at: NIGHT_END_MIN, label: 'LIGHTS UP', scales: { kick: 0.45, hat: 0.2, bass: 0.12, pad: 0.6 } },
|
||||
];
|
||||
|
||||
const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
|
||||
|
||||
/**
|
||||
* Voice scalars at a given clock minute. Clamps outside the night rather than
|
||||
* extrapolating — a paused or overrunning clock should hold the edge, not
|
||||
* wander into negative gain.
|
||||
*/
|
||||
export function arcAt(clockMin: number): ArcScales {
|
||||
const t = Number.isFinite(clockMin) ? clockMin : 0;
|
||||
|
||||
const first = ARC[0];
|
||||
const last = ARC[ARC.length - 1];
|
||||
/* istanbul ignore next -- ARC is a non-empty literal */
|
||||
if (!first || !last) return { kick: 1, hat: 1, bass: 1, pad: 1 };
|
||||
|
||||
if (t <= first.at) return { ...first.scales };
|
||||
if (t >= last.at) return { ...last.scales };
|
||||
|
||||
for (let i = 1; i < ARC.length; i++) {
|
||||
const prev = ARC[i - 1];
|
||||
const next = ARC[i];
|
||||
if (!prev || !next || t > next.at) continue;
|
||||
const span = next.at - prev.at;
|
||||
const k = span <= 0 ? 1 : (t - prev.at) / span;
|
||||
const out = {} as ArcScales;
|
||||
for (const v of VOICE_NAMES) out[v] = lerp(prev.scales[v], next.scales[v], k);
|
||||
return out;
|
||||
}
|
||||
|
||||
/* istanbul ignore next -- the loop always returns for t inside the range */
|
||||
return { ...last.scales };
|
||||
}
|
||||
|
||||
/** Nearest keyframe label at or before this minute — for HUD readouts. */
|
||||
export function arcLabel(clockMin: number): string {
|
||||
const t = Number.isFinite(clockMin) ? clockMin : 0;
|
||||
let label = ARC[0]?.label ?? '';
|
||||
for (const k of ARC) {
|
||||
if (t >= k.at) label = k.label;
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
/**
|
||||
* One number for "how hard is the room going", 0..1 — the mean of the voice
|
||||
* scalars. Handy for anything that wants to follow the night's energy without
|
||||
* caring which voice is doing it (lighting, crowd density, the DJ role's meter).
|
||||
*/
|
||||
export function arcIntensity(clockMin: number): number {
|
||||
const s = arcAt(clockMin);
|
||||
return VOICE_NAMES.reduce((sum, v) => sum + s[v], 0) / VOICE_NAMES.length;
|
||||
}
|
||||
159
src/audio/mix.ts
Normal file
@ -0,0 +1,159 @@
|
||||
// The track's tunable surface, lifted out of TechnoEngine so it can be changed
|
||||
// while the music is playing.
|
||||
//
|
||||
// Three things need this and none of them can use a `const` in a module body:
|
||||
// the ear pass (a human turns a knob and hears it on the next beat), the night
|
||||
// arc (arc.ts scales voice levels as the night builds), and the Phase-4 DJ role,
|
||||
// whose control panel is specified as this exact surface (BUILD_PLAN §4).
|
||||
//
|
||||
// Defaults reproduce the Phase-1 engine's hardcoded values exactly, so lifting
|
||||
// them changed no sound. Anything John retunes in the ear pass gets pasted back
|
||||
// over DEFAULT_MIX via `formatMix()`.
|
||||
|
||||
export type VoiceName = 'kick' | 'hat' | 'bass' | 'pad';
|
||||
|
||||
export const VOICE_NAMES: readonly VoiceName[] = ['kick', 'hat', 'bass', 'pad'];
|
||||
|
||||
export interface KickParams {
|
||||
/** Beater pitch at the transient. */
|
||||
startHz: number;
|
||||
/** Where the drop lands — the body of the cone. */
|
||||
endHz: number;
|
||||
/** Seconds for the pitch drop. Longer reads as a bigger speaker. */
|
||||
dropS: number;
|
||||
attackS: number;
|
||||
decayS: number;
|
||||
}
|
||||
|
||||
export interface HatParams {
|
||||
/** Highpass corner. Lower lets more of the noise body through. */
|
||||
hpHz: number;
|
||||
decayS: number;
|
||||
}
|
||||
|
||||
export interface BassParams {
|
||||
rootHz: number;
|
||||
/** Per-note filter sweep floor and peak — the "wow" on each 16th. */
|
||||
filterLoHz: number;
|
||||
filterHiHz: number;
|
||||
q: number;
|
||||
decayS: number;
|
||||
/** Cents of detune between the two saws. The whole character lives here. */
|
||||
detuneCents: number;
|
||||
}
|
||||
|
||||
export interface PadParams {
|
||||
lpHz: number;
|
||||
/** Length of the fade-in/out cycle, in bars. */
|
||||
bars: number;
|
||||
}
|
||||
|
||||
export interface TrackMix {
|
||||
/** Post-everything output level. */
|
||||
master: number;
|
||||
/** Per-voice peak gains, pre-filter. */
|
||||
levels: Record<VoiceName, number>;
|
||||
/** Ear-pass solo/mute. A muted voice is still scheduled, just silent. */
|
||||
enabled: Record<VoiceName, boolean>;
|
||||
kick: KickParams;
|
||||
hat: HatParams;
|
||||
bass: BassParams;
|
||||
pad: PadParams;
|
||||
/** Filter resonance at each location — the door's peak is the "through a wall" cue. */
|
||||
doorQ: number;
|
||||
floorQ: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_MIX: TrackMix = {
|
||||
master: 0.8,
|
||||
levels: { kick: 0.9, hat: 0.25, bass: 0.11, pad: 0.05 },
|
||||
enabled: { kick: true, hat: true, bass: true, pad: true },
|
||||
kick: { startHz: 150, endHz: 50, dropS: 0.06, attackS: 0.008, decayS: 0.34 },
|
||||
hat: { hpHz: 7000, decayS: 0.04 },
|
||||
bass: { rootHz: 55, filterLoHz: 180, filterHiHz: 900, q: 8, decayS: 0.12, detuneCents: 8 },
|
||||
pad: { lpHz: 600, bars: 8 },
|
||||
doorQ: 6,
|
||||
floorQ: 0.7,
|
||||
};
|
||||
|
||||
/** Bounds every knob, so a slider (or a typo in the console) cannot blow an ear. */
|
||||
export const MIX_RANGES = {
|
||||
master: [0, 1.2],
|
||||
level: [0, 1.5],
|
||||
'kick.startHz': [60, 400],
|
||||
'kick.endHz': [25, 120],
|
||||
'kick.dropS': [0.01, 0.3],
|
||||
'kick.attackS': [0.001, 0.05],
|
||||
'kick.decayS': [0.05, 1],
|
||||
'hat.hpHz': [2000, 14000],
|
||||
'hat.decayS': [0.005, 0.2],
|
||||
'bass.rootHz': [30, 110],
|
||||
'bass.filterLoHz': [60, 600],
|
||||
'bass.filterHiHz': [300, 4000],
|
||||
'bass.q': [0.5, 20],
|
||||
'bass.decayS': [0.02, 0.5],
|
||||
'bass.detuneCents': [0, 40],
|
||||
'pad.lpHz': [200, 4000],
|
||||
'pad.bars': [1, 16],
|
||||
doorQ: [0.5, 20],
|
||||
floorQ: [0.1, 4],
|
||||
} as const satisfies Record<string, readonly [number, number]>;
|
||||
|
||||
export const clampTo = (v: number, range: readonly [number, number]): number =>
|
||||
Number.isFinite(v) ? Math.max(range[0], Math.min(range[1], v)) : range[0];
|
||||
|
||||
/** Deep copy — callers mutate their own mix without touching DEFAULT_MIX. */
|
||||
export function cloneMix(m: TrackMix): TrackMix {
|
||||
return {
|
||||
master: m.master,
|
||||
levels: { ...m.levels },
|
||||
enabled: { ...m.enabled },
|
||||
kick: { ...m.kick },
|
||||
hat: { ...m.hat },
|
||||
bass: { ...m.bass },
|
||||
pad: { ...m.pad },
|
||||
doorQ: m.doorQ,
|
||||
floorQ: m.floorQ,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective gain for a voice: its level, muted by `enabled`, scaled by whatever
|
||||
* the night arc says this voice should be doing right now.
|
||||
*
|
||||
* Kept here rather than in the engine so the ear-pass rig can show the same
|
||||
* number the scheduler will actually use.
|
||||
*/
|
||||
export function voiceGain(mix: TrackMix, voice: VoiceName, arcScale = 1): number {
|
||||
if (!mix.enabled[voice]) return 0;
|
||||
return Math.max(0, mix.levels[voice] * arcScale);
|
||||
}
|
||||
|
||||
/** True when exactly one voice is unmuted — the ear pass's soloed state. */
|
||||
export const soloedVoice = (mix: TrackMix): VoiceName | null => {
|
||||
const on = VOICE_NAMES.filter((v) => mix.enabled[v]);
|
||||
return on.length === 1 ? (on[0] ?? null) : null;
|
||||
};
|
||||
|
||||
const n = (v: number): string => (Number.isInteger(v) ? String(v) : String(Number(v.toFixed(4))));
|
||||
|
||||
/**
|
||||
* Emit a mix as a pasteable `TrackMix` literal.
|
||||
*
|
||||
* The ear pass is a human saying "more click on the kick" and me turning that
|
||||
* into numbers; without a dump button those numbers die with the tab. This is
|
||||
* how a tuning session becomes a diff.
|
||||
*/
|
||||
export function formatMix(m: TrackMix): string {
|
||||
return `{
|
||||
master: ${n(m.master)},
|
||||
levels: { kick: ${n(m.levels.kick)}, hat: ${n(m.levels.hat)}, bass: ${n(m.levels.bass)}, pad: ${n(m.levels.pad)} },
|
||||
enabled: { kick: true, hat: true, bass: true, pad: true },
|
||||
kick: { startHz: ${n(m.kick.startHz)}, endHz: ${n(m.kick.endHz)}, dropS: ${n(m.kick.dropS)}, attackS: ${n(m.kick.attackS)}, decayS: ${n(m.kick.decayS)} },
|
||||
hat: { hpHz: ${n(m.hat.hpHz)}, decayS: ${n(m.hat.decayS)} },
|
||||
bass: { rootHz: ${n(m.bass.rootHz)}, filterLoHz: ${n(m.bass.filterLoHz)}, filterHiHz: ${n(m.bass.filterHiHz)}, q: ${n(m.bass.q)}, decayS: ${n(m.bass.decayS)}, detuneCents: ${n(m.bass.detuneCents)} },
|
||||
pad: { lpHz: ${n(m.pad.lpHz)}, bars: ${n(m.pad.bars)} },
|
||||
doorQ: ${n(m.doorQ)},
|
||||
floorQ: ${n(m.floorQ)},
|
||||
}`;
|
||||
}
|
||||
@ -18,6 +18,19 @@ export function freshNightState(venueId: string, nightIndex: number, licensed: n
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
@ -36,6 +49,9 @@ export class Meters {
|
||||
bus.on('night:phaseChange', ({ location }) => {
|
||||
this.state.location = location;
|
||||
}),
|
||||
bus.on('incident:log', (incident) => {
|
||||
this.state.incidents.push(incident);
|
||||
}),
|
||||
bus.on('door:clicker', ({ direction }) => {
|
||||
this.state.capacity.clickerShown = Math.max(
|
||||
0,
|
||||
|
||||
@ -16,6 +16,7 @@ export const CONTRABAND: readonly ContrabandDef[] = [
|
||||
{ id: 'someoneElsesId', name: "Someone else's ID", severity: 2, weight: 2, spriteHint: 'small card' },
|
||||
{ id: 'budgie', name: 'A live budgie', severity: 1, weight: 1, spriteHint: 'green blob (flaps)' },
|
||||
{ id: 'vapes', name: 'Fourteen vapes', severity: 1, weight: 3, spriteHint: 'neon sticks' },
|
||||
{ id: 'joint', name: 'One (1) joint', severity: 2, weight: 2, spriteHint: 'small white cone' },
|
||||
{ id: 'marker', name: 'Fat permanent marker', severity: 2, weight: 2, spriteHint: 'black stick' },
|
||||
{ id: 'snags', name: 'Raw sausages (why)', severity: 1, weight: 1, spriteHint: 'pink links' },
|
||||
];
|
||||
|
||||
@ -59,7 +59,7 @@ export const OUTFIT_VOCAB: Record<OutfitSlot, readonly OutfitTypeDef[]> = {
|
||||
],
|
||||
outer: [
|
||||
{ type: 'none', weight: 6, colours: ['black'] },
|
||||
{ type: 'blazer', weight: 2, colours: ['black', 'navy', 'grey'] },
|
||||
{ type: 'blazer', weight: 1, colours: ['black', 'navy', 'grey'] },
|
||||
{ type: 'bomber', weight: 2, colours: ['black', 'green', 'navy'], canLogo: true, canVintage: true },
|
||||
{ type: 'hoodie', weight: 2, colours: ['black', 'grey', 'red'], canLogo: true },
|
||||
{ type: 'leather', weight: 1, colours: ['black', 'brown'], canVintage: true },
|
||||
@ -75,9 +75,9 @@ export const OUTFIT_VOCAB: Record<OutfitSlot, readonly OutfitTypeDef[]> = {
|
||||
],
|
||||
accessory: [
|
||||
{ type: 'none', weight: 6, colours: ['black'] },
|
||||
{ type: 'bucketHat', weight: 2, colours: ['cream', 'black', 'green'], canLogo: true, canVintage: true },
|
||||
{ type: 'bucketHat', weight: 1, colours: ['cream', 'black', 'green'], canLogo: true, canVintage: true },
|
||||
{ type: 'cap', weight: 2, colours: ['black', 'red', 'navy'], canLogo: true },
|
||||
{ type: 'sunnies', weight: 2, colours: ['black'] }, // sunglasses. at night. a tell in itself
|
||||
{ type: 'sunnies', weight: 1, colours: ['black'] }, // sunglasses. at night. a tell in itself
|
||||
{ type: 'bumbag', weight: 1, colours: ['black', 'purple', 'yellow'], canLogo: true, canVintage: true },
|
||||
{ type: 'chain', weight: 1, colours: ['yellow', 'grey'] },
|
||||
],
|
||||
|
||||
89
src/data/roster.ts
Normal file
@ -0,0 +1,89 @@
|
||||
// The roster board (design §6): role × venue = level select. A role is a lens
|
||||
// + verb-set over the ONE simulation — picking a shift picks where you stand
|
||||
// when the doors open, not which game you're playing. Wages are what the
|
||||
// board says they are; the tips are your business.
|
||||
|
||||
import type { VenueDef } from './venues';
|
||||
|
||||
export type RoleId = 'glassie' | 'bartender' | 'doorgirl' | 'bouncer' | 'dj' | 'hos';
|
||||
|
||||
export interface RoleDef {
|
||||
id: RoleId;
|
||||
name: string;
|
||||
/** What the board admits the shift pays. */
|
||||
wage: number;
|
||||
/** The board's own words. */
|
||||
blurb: string;
|
||||
/** Where you stand when the doors open. */
|
||||
startLoc: 'door' | 'floor';
|
||||
/** Walk straight onto a post: the taps or the decks. */
|
||||
post?: 'taps' | 'decks';
|
||||
/** Venue ids where this shift is on the board (venue ladder, design §6). */
|
||||
venues: readonly string[];
|
||||
}
|
||||
|
||||
export const ROLES: readonly RoleDef[] = [
|
||||
{
|
||||
id: 'glassie',
|
||||
name: 'GLASSIE',
|
||||
wage: 120,
|
||||
blurb: 'zero authority. all glasses. you will SEE things',
|
||||
startLoc: 'floor',
|
||||
venues: ['theRoyal'],
|
||||
},
|
||||
{
|
||||
id: 'bartender',
|
||||
name: 'BARTENDER',
|
||||
wage: 160,
|
||||
blurb: 'the taps, the tabs, the RSA line. nobody cooked gets a pour',
|
||||
startLoc: 'floor',
|
||||
post: 'taps',
|
||||
venues: ['theRoyal', 'voltage'],
|
||||
},
|
||||
{
|
||||
id: 'doorgirl',
|
||||
name: 'DOOR',
|
||||
wage: 180,
|
||||
blurb: 'the rope, the IDs, the whole street watching you decide',
|
||||
startLoc: 'door',
|
||||
venues: ['theRoyal', 'voltage'],
|
||||
},
|
||||
{
|
||||
id: 'bouncer',
|
||||
name: 'BOUNCER',
|
||||
wage: 190,
|
||||
blurb: 'the floor, the stalls, the yard. walk it like you mean it',
|
||||
startLoc: 'floor',
|
||||
venues: ['voltage'],
|
||||
},
|
||||
{
|
||||
id: 'dj',
|
||||
name: 'DJ',
|
||||
wage: 220,
|
||||
blurb: 'hold the decks. the room is a meter and youre the needle',
|
||||
startLoc: 'floor',
|
||||
post: 'decks',
|
||||
venues: ['room'],
|
||||
},
|
||||
{
|
||||
id: 'hos',
|
||||
name: 'HEAD OF SECURITY',
|
||||
wage: 260,
|
||||
blurb: 'door, floor, radio. everything, at once, all night',
|
||||
startLoc: 'door',
|
||||
venues: ['elevate', 'room'],
|
||||
},
|
||||
];
|
||||
|
||||
export function rolesFor(venueId: string): RoleDef[] {
|
||||
return ROLES.filter((r) => r.venues.includes(venueId));
|
||||
}
|
||||
|
||||
export function roleById(id: string | undefined): RoleDef | undefined {
|
||||
return ROLES.find((r) => r.id === id);
|
||||
}
|
||||
|
||||
/** Booting a night without the board (dev routes): the sensible default. */
|
||||
export function defaultRoleFor(venue: VenueDef): RoleDef {
|
||||
return rolesFor(venue.id).find((r) => r.startLoc === 'door') ?? ROLES.find((r) => r.id === 'hos')!;
|
||||
}
|
||||
@ -16,11 +16,15 @@ export const DAZZA_TEXTS: readonly DazzaLine[] = [
|
||||
{ id: 'rule-basics', text: 'mate its 930 no thongs no singlets we are not a beer garden', ruleId: 'noThongsSinglets', fromMin: 30 },
|
||||
{ id: 'rule-logos', text: 'seeing too many logos in there. no visible logos from NOW', ruleId: 'noLogos', fromMin: 90 },
|
||||
{ id: 'rule-sneakers', text: 'white sneakers are OVER. unless vintage. u know the difference', ruleId: 'noWhiteSneakers', fromMin: 150 },
|
||||
{ id: 'rule-songRequest', text: 'if they look like theyd request a song they dont come in. u know the look', ruleId: 'noSongRequesters', fromMin: 240 },
|
||||
{ id: 'rule-songRequest', text: 'if they look like theyd request a song they dont come in. u know the look', ruleId: 'noSongRequesters', fromMin: 105 },
|
||||
{ id: 'rule-bucketHats', text: 'bucket hats r making the room feel like a festival toilet queue. gone', ruleId: 'noBucketHats', fromMin: 180 },
|
||||
{ id: 'rule-sunnies', text: 'anyone wearing sunnies inside at 11pm is a cop or a flog. neither gets in', ruleId: 'noSunnies', fromMin: 120 },
|
||||
{ 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: 280 },
|
||||
{ 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 },
|
||||
|
||||
276
src/data/strings/door.ts
Normal file
@ -0,0 +1,276 @@
|
||||
// Door-phase flavour text. Aussie register per docs/GAME_DESIGN.md §5 — affectionate,
|
||||
// absurd, never sneering at the punter. Added by LANE-DOOR; nothing here is imported
|
||||
// by another lane, so it can move to a shared strings module at integration.
|
||||
|
||||
export const DOOR_UI = {
|
||||
rope: 'UNHOOK ROPE',
|
||||
ropeWaiting: 'NEXT!',
|
||||
ropeEmpty: 'NOBODY THERE',
|
||||
admit: 'ADMIT',
|
||||
deny: 'NOT TONIGHT',
|
||||
test: 'SOBRIETY TEST',
|
||||
patDown: 'PAT-DOWN',
|
||||
clicker: 'CLICKER',
|
||||
phone: 'PHONE',
|
||||
idTray: 'ID TRAY',
|
||||
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. */
|
||||
export const DOOR_TUTORIAL = [
|
||||
'click the rope to call someone up',
|
||||
'hover their clothes to look closer',
|
||||
'click the ID in the tray to read it',
|
||||
'ADMIT or NOT TONIGHT',
|
||||
] as const;
|
||||
|
||||
export const PATRON_GREETINGS: readonly string[] = [
|
||||
'evening mate',
|
||||
'howsit goin',
|
||||
'big night?',
|
||||
'me and the girls just wanna dance',
|
||||
'is it busy in there',
|
||||
'my mates already inside',
|
||||
"we've been in the queue for ages",
|
||||
'do youse do stamps',
|
||||
'yeah nah yeah',
|
||||
"how ya goin' champ",
|
||||
'is there a cover charge or',
|
||||
'straight up i just need a sit down',
|
||||
];
|
||||
|
||||
export const DRUNK_GREETINGS: readonly string[] = [
|
||||
"i'm not even that drunk",
|
||||
"i've had like. two",
|
||||
'love this place. love it',
|
||||
'yourrrre a legend by the way',
|
||||
'wheres the kebab shop',
|
||||
'i know the DJ. sort of',
|
||||
"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',
|
||||
"we're on the list under Bec",
|
||||
'yeah nah my names definitely down',
|
||||
'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',
|
||||
'someone films it',
|
||||
'a bloke in the queue claps. once.',
|
||||
'muffled outrage',
|
||||
'nobody argues',
|
||||
'the queue shuffles forward',
|
||||
];
|
||||
|
||||
export const ADMIT_REACTIONS: readonly string[] = [
|
||||
'in they go',
|
||||
'the queue breathes out',
|
||||
'a small cheer',
|
||||
'door thuds shut',
|
||||
];
|
||||
|
||||
/**
|
||||
* "Dazza does a lap" — the deferred sting for waving a violator through. There
|
||||
* are several because one phrasing repeated six times in a night reads as a
|
||||
* broken game rather than a man losing his patience. `{rule}` is the card label.
|
||||
*/
|
||||
export const DAZZA_LAP_LINES: readonly string[] = [
|
||||
'did a lap mate and the sign says "{rule}" so explain that one to me',
|
||||
'im looking at someone by the bar rn. "{rule}". we discussed this',
|
||||
'mate. "{rule}". i typed it out and everything',
|
||||
'not being funny but whats the point of me sending u the rules. "{rule}"',
|
||||
'one on the dancefloor right now. "{rule}". i felt that in my chest',
|
||||
'ur letting them in with "{rule}" still on the card. bold',
|
||||
'the owner walked past that one. "{rule}". i aged a year',
|
||||
];
|
||||
|
||||
export const lapLine = (short: string, roll: number): string => {
|
||||
const i = Math.min(DAZZA_LAP_LINES.length - 1, Math.floor(roll * DAZZA_LAP_LINES.length));
|
||||
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',
|
||||
waiting: 'they think about it...',
|
||||
passBtn: 'PASSED',
|
||||
failBtn: 'FAILED',
|
||||
hint: 'you can rule however you like. thats the job',
|
||||
} as const;
|
||||
|
||||
export const PATDOWN_LINES = {
|
||||
clean: 'pockets are empty. just lint and a servo receipt',
|
||||
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',
|
||||
replay: 'click anywhere for another night',
|
||||
} as const;
|
||||
|
||||
export const FAIL_TEXT = {
|
||||
vibe: {
|
||||
title: 'THE VIBE DIED',
|
||||
dazza: 'the room is a MORGUE mate. dont come in tomorrow. or do. i dont know. no. dont',
|
||||
},
|
||||
aggro: {
|
||||
title: 'THE QUEUE WENT UP',
|
||||
dazza: 'someone glassed the door. THE DOOR. u had one job and the job was the door',
|
||||
},
|
||||
licence: {
|
||||
title: 'LICENCE PULLED',
|
||||
dazza: 'the inspector just walked out with the licence in a SLEEVE. do not text me. do not call me. terry is going to hear about this',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** Dazza's night-end verdicts, worst to best. Index chosen by final vibe. */
|
||||
export const DAZZA_SIGNOFF: readonly string[] = [
|
||||
'ok that was rough. we go again thursday. bring a jacket',
|
||||
'not ur best night but the room stayed open so. thats something',
|
||||
'solid. the owner didnt call me once. thats the highest praise i have',
|
||||
'mate. MATE. that room was electric. im telling terry u did that',
|
||||
];
|
||||
276
src/data/strings/encounters.ts
Normal file
@ -0,0 +1,276 @@
|
||||
// Scripted-encounter dialogue (design §4.3). These are the most rewritten strings
|
||||
// in the project and the ones a content pass is most likely to ruin.
|
||||
//
|
||||
// The register, in order of importance:
|
||||
// - Nobody begs. Every one of these people is competent, has a plan, and is
|
||||
// offering you information rather than asking for mercy.
|
||||
// - The game never says which call was right. `admitNote`/`denyNote` land on the
|
||||
// verdict toast, so they report what happened next and stop. No verdict on the
|
||||
// player, no reassurance either — reassurance is a verdict wearing a smile.
|
||||
// - Dazza may judge freely. He is a character inside the fiction and he is wrong
|
||||
// constantly; that is not the game speaking.
|
||||
//
|
||||
// `afterMs` is measured from the moment the patron reaches the rope, not from the
|
||||
// previous beat, so a skipped line cannot compress the ones after it.
|
||||
|
||||
export interface EncounterScript {
|
||||
/** Licence names to draw from — the same person, spelled a few different ways. */
|
||||
names: readonly string[];
|
||||
beats: readonly { readonly line: string; readonly afterMs: number }[];
|
||||
admitNote?: string;
|
||||
admitDazza?: string;
|
||||
denyNote?: string;
|
||||
denyDazza?: string;
|
||||
/** Coin-flip encounters (real/fake cop, rocket/paps): notes per variant key.
|
||||
* The variant is decided at dress() time and carried on patron.flags. */
|
||||
variantNotes?: Record<string, { admitNote?: string; admitDazza?: string; denyNote?: string; denyDazza?: string }>;
|
||||
}
|
||||
|
||||
export const ENCOUNTER_SCRIPTS: Record<string, EncounterScript> = {
|
||||
// She is not asking to party. She is asking for ninety seconds and a jacket,
|
||||
// and she already knows the stamp rule better than you do.
|
||||
grabHerMate: {
|
||||
names: ['Bec Doyle', 'Steph Vella', 'Aleisha Marsh'],
|
||||
beats: [
|
||||
{ line: 'i was inside ten minutes ago. no stamp, i know.', afterMs: 0 },
|
||||
{ line: "my mate's on a date in there thats gone bad. shes done the toilet text twice.", afterMs: 2200 },
|
||||
{ line: "ninety seconds. i get her and her jacket and we're both gone.", afterMs: 4600 },
|
||||
],
|
||||
// Reports the stamp, not the room: nothing here can see the clicker count,
|
||||
// and a note that asserts capacity is simply false half the time.
|
||||
admitNote: 'Back in without a stamp. Nothing on the door says she was ever inside.',
|
||||
admitDazza: 'the count and the clicker dont agree mate. i count from the office. i COUNT',
|
||||
denyNote: 'She waits under the awning across the road, then walks toward the taxi rank.',
|
||||
},
|
||||
|
||||
// The dumped bloke. The tell is the over-explaining, not the swaying. He says
|
||||
// the title of the game and does not notice he said it.
|
||||
quietBeer: {
|
||||
names: ['Trent Petersen', 'Dylan Marsh', 'Cam Duffy'],
|
||||
beats: [
|
||||
{ line: 'evening. just the one, then im off.', afterMs: 0 },
|
||||
{ line: 'ive had two at the RSL. before you ask.', afterMs: 2100 },
|
||||
{ line: 'no, no one with me. not tonight.', afterMs: 4400 },
|
||||
{ line: 'one beer. thats the whole plan.', afterMs: 6600 },
|
||||
],
|
||||
admitNote: 'Loose at the rope. Where it goes from here is between him and the bar.',
|
||||
admitDazza: 'theres a bloke at the end of the bar not drinking his drink. keep half an eye',
|
||||
denyNote: 'He says no dramas. He is at the kebab shop ten minutes later.',
|
||||
},
|
||||
|
||||
// Seven years, and he has brought a hip flask to shout the bar staff with. He
|
||||
// tells you about it, which is the entire trap — you cannot un-know it.
|
||||
lastNight: {
|
||||
names: ['Warren Kowalski', 'Raymond Vella', 'Gary Duffy'],
|
||||
beats: [
|
||||
{ line: 'last one, mate. flights booked. perth on thursday.', afterMs: 0 },
|
||||
{ line: 'seven years ive been coming here. you knocked me back once, back in nineteen. fair enough too.', afterMs: 2400 },
|
||||
{ line: 'i brought this for sue behind the bar. she reckons she wants a proper send off.', afterMs: 4800 },
|
||||
],
|
||||
admitNote: 'The flask went in with him.',
|
||||
denyNote: 'He waves it off. In the car park he opens the flask himself.',
|
||||
},
|
||||
|
||||
// Off a twelve-hour shift in a branded work polo, which the dress code has had
|
||||
// an opinion about since 10:30. He knows. He says so before you can.
|
||||
nightShift: {
|
||||
names: ['Jared Karim', 'Tui Taufa', 'Sam Nguyen'],
|
||||
beats: [
|
||||
{ line: 'sorry. straight off shift. i know how it looks.', afterMs: 0 },
|
||||
{ line: 'twelve hours. the polos got the servo on it, cant do much about that.', afterMs: 2300 },
|
||||
{ line: 'one beer, near the door, i wont go near the dancefloor. i dont have it in me.', afterMs: 4700 },
|
||||
],
|
||||
admitNote: 'In he goes, logo and all, with the sign already up.',
|
||||
denyNote: 'He nods, says fair enough, and walks back toward the servo.',
|
||||
},
|
||||
// Eighteen at midnight, at the rope by quarter past. Real card, real birthday,
|
||||
// and a plan he has clearly rehearsed on the walk over.
|
||||
eighteenToday: {
|
||||
names: ['Jai Petersen', 'Cooper Nguyen', 'Lachy Marsh'],
|
||||
beats: [
|
||||
{ line: 'evening. so. it went midnight about forty minutes ago.', afterMs: 0 },
|
||||
{ line: 'which makes this my first legal one. checked the law twice.', afterMs: 2400 },
|
||||
{ line: 'my mates did the countdown at the kebab shop. they went home. im here.', afterMs: 5000 },
|
||||
],
|
||||
admitNote: 'First stamp on a first night. He shows the bar staff his licence unprompted.',
|
||||
denyNote: 'Legal for forty minutes and headed home. The story will improve with age.',
|
||||
},
|
||||
|
||||
// Kayden has been making promises. Kayden does not have list privileges.
|
||||
// Kayden does not, strictly speaking, have a last name anyone can recall.
|
||||
kaydensMate: {
|
||||
names: ['Reece Kowalski', 'Jordan Vella', 'Tyson Doyle'],
|
||||
beats: [
|
||||
{ line: 'should be right, kayden said hed leave my name at the door.', afterMs: 0 },
|
||||
{ line: 'kayden. tall bloke. trainee vest. very confident handshake.', afterMs: 2300 },
|
||||
{ line: 'he said quote unquote "sorted mate". thats basically a guest list.', afterMs: 4800 },
|
||||
],
|
||||
admitNote: 'In on the authority of a man whose supervisor calls him "the situation".',
|
||||
admitDazza: 'a bloke just told the bar KAYDEN comped him. kayden cant comp a glass of water',
|
||||
denyNote: 'He takes it well. Says he will "raise it with kayden". Nobody will.',
|
||||
},
|
||||
|
||||
// The buck's party straggler. Missed the church bit, missed dinner, caught the
|
||||
// food poisoning. The buck is inside; the night is nearly over; here he is.
|
||||
bigNightOut: {
|
||||
names: ['Macca OBrien', 'Davo Romano', 'Nick Taufa'],
|
||||
beats: [
|
||||
{ line: 'the bucks is in there. i can hear the chanting. thats my chanting.', afterMs: 0 },
|
||||
{ line: 'i missed the church bit. food poisoning at the dinner. long story. bad oysters.', afterMs: 2500 },
|
||||
{ line: 'ive had a big recovery and two lemonades and i am READY to be present.', afterMs: 5200 },
|
||||
],
|
||||
admitNote: 'The chanting gets louder, gains a name in it, then settles.',
|
||||
denyNote: 'He listens to one more chant through the wall, films a voice memo of it, and goes.',
|
||||
},
|
||||
|
||||
// Engaged tonight. Still holding hands. The card says what the card says.
|
||||
twoOfThem: {
|
||||
names: ['Bree Karim', 'Holly Petersen', 'Sana Singh'],
|
||||
beats: [
|
||||
{ line: 'before you say anything about the hand thing. we got engaged tonight.', afterMs: 0 },
|
||||
{ line: 'TONIGHT tonight. at the lookout. theres a photo of a bird ruining it.', afterMs: 2400 },
|
||||
{ line: 'we are aware of the rule. we have discussed it. the answer is no.', afterMs: 5000 },
|
||||
],
|
||||
admitNote: 'They cross the floor like a three-legged race nobody can catch.',
|
||||
denyNote: 'They leave still attached. The rule holds. The rule is the only thing that does.',
|
||||
},
|
||||
|
||||
// The badge is real or it cost $30, and the licence is the only thing at the
|
||||
// rope that knows which. He is polite either way. That is the problem.
|
||||
offDutyCop: {
|
||||
names: ['Marcus Bell', 'Marc Bell', 'M. Bell'],
|
||||
beats: [
|
||||
{ line: "evening. job's done for the night. just after a quiet one.", afterMs: 0 },
|
||||
{ line: 'you can look at the badge if you want. most dont.', afterMs: 2400 },
|
||||
{ line: 'take your time with the licence. i respect the process.', afterMs: 5000 },
|
||||
],
|
||||
variantNotes: {
|
||||
real: {
|
||||
admitNote: 'He orders soda water and stands where he can see both exits.',
|
||||
denyNote: 'He nods once. "Good call. Always." He means it.',
|
||||
},
|
||||
fake: {
|
||||
admitNote: 'The badge says JUNIOR DETECTIVE on the back. Nobody looks at the back.',
|
||||
denyNote: 'He argues, then folds. The badge goes back in the bumbag.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Cap low, sunnies on, at night. Two live rule breaches and a face you
|
||||
// almost place. Denying is free forever. Admitting is a coin you cannot see.
|
||||
celebrity: {
|
||||
names: ['Ky Trainor', 'Kai Trainor', 'K. Trainor'],
|
||||
beats: [
|
||||
{ line: 'hey. so. yeah.', afterMs: 0 },
|
||||
{ line: "it'd be really good if this could be quick.", afterMs: 2200 },
|
||||
{ line: 'the sunnies stay on. sorry. they sort of have to.', afterMs: 4600 },
|
||||
],
|
||||
variantNotes: {
|
||||
rocket: { admitNote: 'Halfway to the bar, someone does a double-take.' },
|
||||
paps: {
|
||||
admitNote: 'A ute pulls up across the road. Then another one.',
|
||||
admitDazza: 'why are there seventeen blokes with cameras outside. WHY',
|
||||
},
|
||||
},
|
||||
denyNote: 'The cap goes lower and they walk. You will never know who that was.',
|
||||
},
|
||||
|
||||
// An aggro engine in a polo. His licence is a laminated business card and he
|
||||
// is PROUD of it. Nothing about him is a question the desk can answer.
|
||||
sovereign: {
|
||||
names: ['Gregory-John: Harmon', 'G-J: Harmon', 'Gregory-John of Harmon'],
|
||||
beats: [
|
||||
{ line: 'before we begin: i do not consent to this dress code.', afterMs: 0 },
|
||||
{ line: 'your rope is an admiralty flag. i am a living man, travelling.', afterMs: 2600 },
|
||||
{ line: 'i will be entering under common law. watch.', afterMs: 5200 },
|
||||
],
|
||||
admitNote: 'He steps over the rope rather than through the gap. On principle.',
|
||||
denyNote: 'He accepts no jurisdiction, but does, eventually, accept the footpath.',
|
||||
},
|
||||
|
||||
// The thermal bag says a name. The name is "DJ". The booth lamp flickers.
|
||||
deliveryRider: {
|
||||
names: ['Sanjay Pillai', 'San Pillai', 'S. Pillai'],
|
||||
beats: [
|
||||
{ line: 'delivery for "DJ". thats it. just says DJ.', afterMs: 0 },
|
||||
{ line: 'its getting cold mate. the bag only does so much.', afterMs: 2400 },
|
||||
{ line: 'ive got four more jobs on. yes or no.', afterMs: 4800 },
|
||||
],
|
||||
variantNotes: {
|
||||
ordered: {
|
||||
admitNote: 'The booth lamp stops flickering. So. He HAD ordered.',
|
||||
denyNote: 'The rider shrugs and eats it on the kerb. It looked incredible.',
|
||||
},
|
||||
not: {
|
||||
admitNote: 'He wanders the floor with the bag, calling a name nobody has.',
|
||||
denyNote: 'He checks the address, swears with real feeling, and rides off.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Two clipboards enter. One leaves.
|
||||
promoter: {
|
||||
names: ['Tyson Vale', 'Ty Vale', 'T. Vale'],
|
||||
beats: [
|
||||
{ line: 'evening chief. tyson. i run thursdays at elevate.', afterMs: 0 },
|
||||
{ line: 'youve got a list. ive got a list. mine is realer.', afterMs: 2600 },
|
||||
{ line: 'roll of wristbands here says we can help each other.', afterMs: 5200 },
|
||||
],
|
||||
admitNote: 'He shakes your hand like something just went well for him.',
|
||||
admitDazza: 'who is TYSON and why do nine people think his wristband beats my list',
|
||||
denyNote: 'He films a story outside: "this venue hates culture." 41 views.',
|
||||
},
|
||||
|
||||
// Pyjamas, at the rope, at 1 AM. The only person all night who wants LESS
|
||||
// club. She is not wrong about the bass.
|
||||
neighbour: {
|
||||
names: ['Carol Whitford', 'Caz Whitford', 'C. Whitford'],
|
||||
beats: [
|
||||
{ line: 'im not coming in. lord, no.', afterMs: 0 },
|
||||
{ line: 'number 12, across the laneway. its the bass. my GLASSES are walking.', afterMs: 2600 },
|
||||
{ line: 'either it comes down or i ring the council again. your choice, love.', afterMs: 5400 },
|
||||
],
|
||||
admitNote: 'She stands in the booth doorway pointing at the subwoofer until it comes down 2dB. Peace, of a kind.',
|
||||
denyNote: 'She photographs the queue on her way home. For the file.',
|
||||
},
|
||||
|
||||
// Wrong venue, right energy, seven matching sashes. A vibe grenade with a
|
||||
// random timer, and the pin is in your hand.
|
||||
hensParty: {
|
||||
names: ['Melissa Tran', 'Mel Tran', 'M. Tran'],
|
||||
beats: [
|
||||
{ line: 'ok so, small thing: this is not the venue on the invitation.', afterMs: 0 },
|
||||
{ line: 'HOWEVER. the venue on the invitation has a queue.', afterMs: 2400 },
|
||||
{ line: 'there are seven of us and we have practised a dance.', afterMs: 4800 },
|
||||
],
|
||||
admitNote: 'Seven sashes file past. The bar staff make eye contact with god.',
|
||||
denyNote: 'They find the right venue. You hear the scream of joy from two streets over.',
|
||||
},
|
||||
|
||||
// Genuinely twins. Everything the photo check believes is suddenly on trial,
|
||||
// and both of them have done this bit at every door in the Valley.
|
||||
twins: {
|
||||
names: ['Jesse Kearney', 'Jordan Kearney'],
|
||||
beats: [
|
||||
{ line: 'evening. before you ask: yes. there is two of me.', afterMs: 0 },
|
||||
{ line: 'my brothers parking the car. we get this a LOT.', afterMs: 2600 },
|
||||
{ line: 'same face, different bloke. this licence is mine. his is his.', afterMs: 5200 },
|
||||
],
|
||||
admitNote: 'One of two, in. That face will be back in five minutes under a different name.',
|
||||
denyNote: 'He rings his brother from the kerb: "told you. same face, no entry."',
|
||||
},
|
||||
|
||||
// Fourteen years on this exact door. His tips are right about eighty percent
|
||||
// of the time, which is the joke, and also fourteen years of doors.
|
||||
exDoorman: {
|
||||
names: ['Frank Oxley', 'Franko Oxley', 'F. Oxley'],
|
||||
beats: [
|
||||
{ line: 'fourteen years i worked this exact door. before the rebrand.', afterMs: 0 },
|
||||
{ line: 'dont mind me. just gonna watch a few. old times.', afterMs: 2600 },
|
||||
{ line: 'ill be by the planter. tip or two, free of charge.', afterMs: 5200 },
|
||||
],
|
||||
admitNote: 'He does one lap inside and comes straight back out to the planter. The door IS the venue, for him.',
|
||||
denyNote: 'Completely unbothered. He sets up by the planter with a dart. "Better view out here anyway."',
|
||||
},
|
||||
};
|
||||
|
||||
420
src/data/strings/floor.ts
Normal file
@ -0,0 +1,420 @@
|
||||
// Floor dialogue & flavour. Typed const maps, never inline in scene code
|
||||
// (CONTRACTS.md §7). Tone: Aussie hospitality satire — affectionate, absurd,
|
||||
// with a moral undertow. Nobody here is a villain; they're just having a night.
|
||||
//
|
||||
// NOTE(convention): CONTRACTS.md §7 puts dialogue in `src/data/strings/`, but
|
||||
// `data/` is LANE-0 territory and lanes commit only inside their owned dirs.
|
||||
// Parked here; see LANEHANDOVER.md — happy to move it on the reviewer's word.
|
||||
|
||||
import type { DrunkStage } from '../../data/types';
|
||||
|
||||
/** The three rungs of the cut-off ladder, softest first. */
|
||||
export type EscalationLine = 'water' | 'done' | 'escort';
|
||||
|
||||
export const ESCALATION_LINES: Record<EscalationLine, string> = {
|
||||
water: 'Have some water, mate. On the house.',
|
||||
done: "Righto — you're done for the night.",
|
||||
escort: "Come on. Walk with me. Out you go.",
|
||||
};
|
||||
|
||||
/** What you get back when you pitch it right for their state. */
|
||||
export const CUTOFF_REPLY_CLEAN: Record<DrunkStage, string> = {
|
||||
sober: '"...yeah, nah, cheers?" He drinks the water. Confused, but hydrated.',
|
||||
tipsy: '"Aw, legend." She takes the water and means it.',
|
||||
loose: '"Yeah fair, fair. Good on ya." He sips it like medicine.',
|
||||
messy: '"Nah I get it. I GET it." He shakes your hand for slightly too long.',
|
||||
maggot: 'No words. Just a slow, dignified nod, and the walk of the defeated.',
|
||||
};
|
||||
|
||||
/** Too much boot for their state — the whole room watches you overdo it. */
|
||||
export const CUTOFF_REPLY_SCENE: Record<DrunkStage, string> = {
|
||||
sober: '"I have had ONE lemon squash." The booth behind you starts filming.',
|
||||
tipsy: '"Mate. MATE. I\'m on my second." Her friends boo you in unison.',
|
||||
loose: '"Are you serious? I\'m FINE." He is fine. That\'s the problem.',
|
||||
messy: '"Don\'t TOUCH me—" Arms up. Drink goes sideways. Room turns.',
|
||||
maggot: 'He goes limp on purpose. All ninety kilos of him. Crowd cheers.',
|
||||
};
|
||||
|
||||
/** Too gentle for their state — they take the water and keep going. */
|
||||
export const CUTOFF_REPLY_SOFT: Record<DrunkStage, string> = {
|
||||
sober: '"Ta." Nothing happened. Nothing was going to happen.',
|
||||
tipsy: '"Sweet as." She wanders off, entirely unbothered.',
|
||||
loose: '"Yeah yeah, water, love it." Straight back to the bar.',
|
||||
messy: '"You\'re a good bloke." He pours the water into a pot plant.',
|
||||
maggot: 'He accepts the water, salutes you, and staggers into the dance floor.',
|
||||
};
|
||||
|
||||
/** Muffled through a stall door. They are not coming out politely. */
|
||||
export const STALL_MUFFLED: readonly string[] = [
|
||||
'"Piss off mate, I\'m BUSY."',
|
||||
'"Occupied! ...by one person!"',
|
||||
'"We are having a private conversation."',
|
||||
'"Nobody in here but me and my reflection."',
|
||||
'"Two seconds! ...Two more seconds!"',
|
||||
];
|
||||
|
||||
export const STALL_BUSTED: readonly string[] = [
|
||||
'The door swings. Two people leave in two directions at four times the speed of sound.',
|
||||
'Busted. One goes left, one goes through the smoking door, neither makes eye contact.',
|
||||
'They scatter like startled ibises. Someone drops a vape. Nobody comes back for it.',
|
||||
];
|
||||
|
||||
export const STALL_OFF_BEAT = 'Your knuckles land in the gap between kicks. Even the door is embarrassed.';
|
||||
|
||||
/** Pat-down patter. Confiscation is a bit, not a punishment. */
|
||||
export const PATDOWN_INTRO = 'Arms out, mate. You know the drill.';
|
||||
|
||||
export const PATDOWN_FOUND: Record<string, string> = {
|
||||
baggie: 'Well. That\'s not a mint.',
|
||||
flask: 'A hip flask. In this economy.',
|
||||
goonSack: 'Sir, this is a licensed venue and that is a pillow of wine.',
|
||||
tinnies: 'Two warm tinnies. Warm. That\'s the real crime.',
|
||||
kebab: 'An entire kebab. Still wrapped. Honestly? Respect.',
|
||||
someoneElsesId: 'This is Kylie. You are not Kylie.',
|
||||
budgie: 'That is a live budgie. It is looking at me. Into the bin, mate.',
|
||||
vapes: 'Fourteen vapes. Fourteen. Are you a shop?',
|
||||
marker: 'Fat marker. We just repainted that cubicle.',
|
||||
snags: 'Raw sausages. In your jacket. Why. WHY.',
|
||||
};
|
||||
|
||||
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."',
|
||||
'"He said he was getting his jacket. Forty minutes ago."',
|
||||
'"I stamped SOMEONE. Pretty sure I stamped someone."',
|
||||
];
|
||||
|
||||
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,6 +22,8 @@ export interface IdFakeTells {
|
||||
wrongHologram?: boolean;
|
||||
jokeName?: boolean;
|
||||
photoMismatch?: boolean;
|
||||
/** The same card was already admitted tonight (passback — set by the door). */
|
||||
passback?: boolean;
|
||||
}
|
||||
|
||||
export interface IdCard {
|
||||
@ -40,6 +42,13 @@ export interface PatronFlags {
|
||||
uvStamped?: boolean;
|
||||
timesDeniedBefore?: number;
|
||||
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 {
|
||||
@ -59,6 +68,8 @@ export interface Patron {
|
||||
export interface DressCodeRule {
|
||||
id: string;
|
||||
text: string;
|
||||
/** Card label for the on-screen dress-code board (CCR-4). */
|
||||
short?: string;
|
||||
activeFrom: number; // clock minutes since 21:00
|
||||
violates: (p: Patron) => boolean;
|
||||
}
|
||||
@ -73,6 +84,8 @@ export interface HeatStrike {
|
||||
export interface VerdictOutcome {
|
||||
vibeDelta: number;
|
||||
aggroDelta: number;
|
||||
/** Queue-theatre payoff channel — the 'wait' verdict's reward (CCR-2). */
|
||||
hypeDelta?: number;
|
||||
heatStrike?: HeatStrike;
|
||||
dazzaText?: string;
|
||||
}
|
||||
@ -114,6 +127,8 @@ export interface GameState {
|
||||
heatStrikes: HeatStrike[];
|
||||
regulars: Record<string, RegularMemory>;
|
||||
pastReports: IncidentRecord[][];
|
||||
/** Scripted-encounter ids the run has already met (repeat gating). */
|
||||
seenEncounters?: string[];
|
||||
}
|
||||
|
||||
// ---- event bus ----
|
||||
@ -130,6 +145,8 @@ export interface EventMap {
|
||||
|
||||
'floor:infractionSpotted': { patronId: string; kind: 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'fight' };
|
||||
'floor:ejection': { patronId: string; style: 'clean' | 'messy' };
|
||||
/** A patron went home on their own — capacity frees up (natural churn). */
|
||||
'floor:leave': { patronId: string };
|
||||
|
||||
'beat:tick': { beatIndex: number; audioTimeMs: number };
|
||||
'audio:location': { location: 'door' | 'floor' };
|
||||
@ -137,4 +154,14 @@ export interface EventMap {
|
||||
'meters:delta': { vibe?: number; aggro?: number; hype?: number };
|
||||
'meters:changed': { vibe: number; aggro: number; hype: number };
|
||||
'heat:strike': HeatStrike;
|
||||
|
||||
// ---- folded in at Phase-1 review (see LANEHANDOVER.md REVIEW 2026-07-19) ----
|
||||
/** Single-writer incident trail: core/meters.ts appends to NightState.incidents. */
|
||||
'incident:log': IncidentRecord;
|
||||
/** Player fiddled with their phone while a patron waits — pure theatre. */
|
||||
'door:phoneTheatre': { durationMs: number };
|
||||
/** AudioContext resumed after the first real user gesture. */
|
||||
'audio:unlocked': { atMs: number };
|
||||
/** Bar line boundary (every 4th beat) — for anything that wants downbeats. */
|
||||
'beat:bar': { barIndex: number; beatIndex: number; audioTimeMs: number };
|
||||
}
|
||||
|
||||
102
src/data/venues.ts
Normal file
@ -0,0 +1,102 @@
|
||||
// The venue ladder (design §6): each venue is a week of Thu→Fri→Sat, and a
|
||||
// difficulty tier expressed as knobs on systems that already exist. Survive a
|
||||
// week, get the next venue's door. Three strikes anywhere, the run dies.
|
||||
|
||||
export interface VenueDef {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Dazza's blurb on the promotion screen. */
|
||||
pitch: string;
|
||||
licensed: number;
|
||||
/** Multiplies the arrival curve — how hard the queue slams. */
|
||||
arrivalScale: number;
|
||||
/**
|
||||
* How many of Dazza's rule texts fire this week (chronological prefix).
|
||||
* The live-rule cap (dressCode.ACTIVE_RULE_CAP) applies on top.
|
||||
*/
|
||||
ruleLimit: number;
|
||||
/** Does the plainclothes inspector work this venue? */
|
||||
inspector: boolean;
|
||||
/**
|
||||
* Spawn-weight OVERRIDES merged over data/archetypes.ts — the venue's crowd
|
||||
* identity. Omitted keys keep their default weight.
|
||||
*/
|
||||
archetypeWeights?: Partial<Record<import('./types').Archetype, number>>;
|
||||
/** Head of Security tier (design §6): radio command of NPC staff. */
|
||||
radioCommand?: boolean;
|
||||
/** ROOM (design §6): the dress code re-randomises every 15 in-game minutes. */
|
||||
shuffleDressCode?: boolean;
|
||||
/** ROOM (design §6): rope dialogue renders as lip-read guesses. */
|
||||
lipRead?: boolean;
|
||||
}
|
||||
|
||||
export const VENUES: readonly VenueDef[] = [
|
||||
{
|
||||
id: 'theRoyal',
|
||||
name: 'The Royal',
|
||||
pitch: 'a dive with a dance floor. nobody checks anything. u will',
|
||||
licensed: 80,
|
||||
arrivalScale: 0.8,
|
||||
ruleLimit: 4,
|
||||
inspector: false,
|
||||
// A local's pub that happens to have a dance floor: regulars and punters,
|
||||
// barely an influencer in sight.
|
||||
archetypeWeights: { regular: 14, influencer: 2, financeBro: 6, ownersMate: 2 },
|
||||
},
|
||||
{
|
||||
id: 'voltage',
|
||||
name: 'Voltage',
|
||||
pitch: 'valley institution. full rulebook. the inspector drinks here. lemonade',
|
||||
licensed: 110,
|
||||
arrivalScale: 1.0,
|
||||
ruleLimit: 8,
|
||||
inspector: true,
|
||||
// The balanced book — the tuning baseline. No overrides.
|
||||
},
|
||||
{
|
||||
id: 'elevate',
|
||||
name: 'Elevate',
|
||||
pitch: 'rooftop. small room, big opinions. every second person is a VIP apparently',
|
||||
licensed: 70,
|
||||
arrivalScale: 1.1,
|
||||
ruleLimit: 8,
|
||||
inspector: true,
|
||||
// Elevate is the Head of Security tier: the radio starts taking orders.
|
||||
radioCommand: true,
|
||||
// Rooftop VIP politics: every second person is on a list, knows the owner,
|
||||
// or is filming. Regulars don't come here; they can't get in either.
|
||||
archetypeWeights: { influencer: 18, ownersMate: 7, financeBro: 14, punter: 38, regular: 3 },
|
||||
},
|
||||
{
|
||||
id: 'room',
|
||||
name: 'ROOM',
|
||||
pitch: 'no sign. no socials. code changes when the board says so. u wont hear a word anyone says. good luck',
|
||||
licensed: 60,
|
||||
arrivalScale: 1.2,
|
||||
// Dazza doesn't announce rules here — the board is the law and it keeps
|
||||
// rewriting itself (shuffleDressCode). His other texts still land.
|
||||
ruleLimit: 0,
|
||||
inspector: true,
|
||||
radioCommand: true,
|
||||
shuffleDressCode: true,
|
||||
lipRead: true,
|
||||
// Underground techno: heads and chancers. Everyone claims VIP; almost
|
||||
// nobody is. Regulars can't find the door.
|
||||
archetypeWeights: { punter: 34, influencer: 12, ownersMate: 9, quietMessy: 8, financeBro: 10, regular: 2, fresh18: 6 },
|
||||
},
|
||||
];
|
||||
|
||||
export const TOTAL_VENUES = VENUES.length;
|
||||
|
||||
export function venueByIndex(i: number): VenueDef {
|
||||
return VENUES[Math.max(0, Math.min(VENUES.length - 1, i))]!;
|
||||
}
|
||||
|
||||
/** Week `w` (venue index) shifts the base night dates forward 7 days per week. */
|
||||
export function nightDateFor(baseIso: string, venueIndex: number): string {
|
||||
const [y = '2026', m = '07', d = '16'] = baseIso.split('-');
|
||||
const date = new Date(Number(y), Number(m) - 1, Number(d) + venueIndex * 7);
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(date.getDate()).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${mm}-${dd}`;
|
||||
}
|
||||
83
src/main.ts
@ -1,8 +1,18 @@
|
||||
import Phaser from 'phaser';
|
||||
import { BootScene } from './scenes/shared/BootScene';
|
||||
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
|
||||
// stay reachable via dev routes below.
|
||||
const game = new Phaser.Game({
|
||||
type: Phaser.AUTO,
|
||||
width: 640,
|
||||
@ -14,37 +24,76 @@ const game = new Phaser.Game({
|
||||
autoCenter: Phaser.Scale.CENTER_BOTH,
|
||||
zoom: Phaser.Scale.MAX_ZOOM,
|
||||
},
|
||||
scene: [BootScene, ParadeScene, JuiceDemoScene],
|
||||
// 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,
|
||||
],
|
||||
});
|
||||
|
||||
// debug handle (harmless in prod; used by dev tooling)
|
||||
(window as unknown as { game: Phaser.Game }).game = game;
|
||||
|
||||
// TODO(integration): LANE-JUICE demo route — J for the juice demo, P back to the
|
||||
// parade, or load with #juice. There is no menu yet; Phase 2's shell replaces this
|
||||
// whole block. Touching main.ts is outside LANE-JUICE's owned dirs (BUILD_PLAN §2)
|
||||
// and is flagged for sign-off in LANEHANDOVER.md — without it the demo scene is
|
||||
// unreachable dead code.
|
||||
const showScene = (key: 'Parade' | 'JuiceDemo'): void => {
|
||||
const other = key === 'Parade' ? 'JuiceDemo' : 'Parade';
|
||||
game.scene.stop(other);
|
||||
if (!game.scene.isActive(key)) game.scene.start(key);
|
||||
// TODO(integration): dev routing until Phase 2's shell exists.
|
||||
// Keys: N night (the game) · P parade · F floor demo · J juice demo.
|
||||
// URL hashes #night/#parade/#floor/#juice pick the boot scene.
|
||||
// NightScene owns DoorScene/NightSummaryScene as children; stopping Night stops them.
|
||||
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) {
|
||||
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);
|
||||
};
|
||||
|
||||
// BootScene starts Parade itself, and does it after READY fires — so routing on
|
||||
// READY stops a scene that is not up yet and we end up with both running on top
|
||||
// of each other. Wait for Parade to actually exist, then swap.
|
||||
if (window.location.hash === '#juice') {
|
||||
// RosterScene is scene[0] and auto-starts. Hash routing must wait until it is
|
||||
// actually up before swapping, or both scenes end up running on top of each other.
|
||||
const hash = window.location.hash.slice(1) as RouteKey;
|
||||
if (hash in ROUTES) {
|
||||
const route = (): void => {
|
||||
if (!game.scene.isActive('Parade')) return;
|
||||
if (!game.scene.isActive('Roster')) return;
|
||||
game.events.off(Phaser.Core.Events.POST_STEP, route);
|
||||
showScene('JuiceDemo');
|
||||
showScene(ROUTES[hash]);
|
||||
};
|
||||
game.events.on(Phaser.Core.Events.POST_STEP, route);
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', (e) => {
|
||||
const k = e.key.toLowerCase();
|
||||
if (k === 'j') showScene('JuiceDemo');
|
||||
if (k === 'n') showScene('Night');
|
||||
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}`;
|
||||
return `doll:${p.dollSeed}:${pose}:${dollPlan(p, pose).swayPx}:${p.flags.disguised === true ? 'd' : ''}`;
|
||||
}
|
||||
|
||||
export function renderDoll(scene: Phaser.Scene, p: Patron, pose: DollPose): string {
|
||||
|
||||
@ -122,5 +122,16 @@ export function dollPlan(p: Patron, pose: DollPose): DollPlan {
|
||||
rects.push({ x: cx + 1, y: 9, w: 1, h: 1, colour: 0xcc3333 });
|
||||
}
|
||||
|
||||
// The disguise (design §4.1): a grudged regular's third appearance wears a
|
||||
// fake moustache and a big pair of NOT-sunnies. Deliberately absent from the
|
||||
// idPhoto pose — his card still shows the clean-shaven bloke you knocked
|
||||
// back on Thursday. Same walk. Same name. "Different" man.
|
||||
if (p.flags.disguised === true) {
|
||||
rects.push({ x: cx - 2, y: 11, w: 5, h: 1, colour: 0x2a1c10 }); // moustache
|
||||
rects.push({ x: cx - 3, y: 8, w: 2, h: 2, colour: 0x101018 }); // glasses L
|
||||
rects.push({ x: cx + 1, y: 8, w: 2, h: 2, colour: 0x101018 }); // glasses R
|
||||
rects.push({ x: cx - 1, y: 8, w: 2, h: 1, colour: 0x101018 }); // bridge
|
||||
}
|
||||
|
||||
return { width, height, rects, swayPx };
|
||||
}
|
||||
|
||||
@ -36,7 +36,9 @@ function rollOutfit(rng: RngStream): OutfitLayer[] {
|
||||
for (const slot of Object.keys(OUTFIT_VOCAB) as OutfitSlot[]) {
|
||||
const def = rng.weighted(OUTFIT_VOCAB[slot].map((d) => [d, d.weight] as const));
|
||||
const layer: OutfitLayer = { slot, type: def.type, colour: rng.pick(def.colours) };
|
||||
if (def.canLogo && rng.chance(0.35)) layer.logo = true;
|
||||
// 0.18 (was 0.35, tuning 2026-07-19): with 8 rules live by 1 AM, a third of
|
||||
// the crowd wearing logos made most patrons deniable and emptied the room.
|
||||
if (def.canLogo && rng.chance(0.18)) layer.logo = true;
|
||||
if (def.canVintage && rng.chance(0.2)) layer.vintage = true;
|
||||
layers.push(layer);
|
||||
}
|
||||
@ -80,13 +82,17 @@ function rollId(rng: RngStream, nightDate: Date, trueAge: number, dollSeed: numb
|
||||
export interface GeneratorContext {
|
||||
rng: SeededRNG;
|
||||
nightDate: Date;
|
||||
/** Venue crowd identity: spawn-weight overrides merged over ARCHETYPES. */
|
||||
archetypeWeights?: Partial<Record<Archetype, number>>;
|
||||
}
|
||||
|
||||
export function generatePatron(ctx: GeneratorContext, clockMin: number, archetypeOverride?: Archetype): Patron {
|
||||
const rng = ctx.rng.stream('patrons');
|
||||
const def = archetypeOverride
|
||||
? ARCHETYPE_BY_KEY.get(archetypeOverride)!
|
||||
: rng.weighted(ARCHETYPES.map((a) => [a, a.weight] as const));
|
||||
: rng.weighted(
|
||||
ARCHETYPES.map((a) => [a, ctx.archetypeWeights?.[a.key] ?? a.weight] as const),
|
||||
);
|
||||
|
||||
const dollSeed = rng.int(0, 2 ** 31 - 1);
|
||||
const age = rng.int(def.ageRange[0], def.ageRange[1]);
|
||||
|
||||
@ -3,6 +3,22 @@ import type { GameState, Patron, RegularMemory } from '../data/types';
|
||||
// Regulars' grudge store. Behaviour wiring (disguises, dialogue callbacks) lands
|
||||
// in Phase 2/3 — this is just the persistent bookkeeping, run-scoped in GameState.
|
||||
|
||||
/** Record a denial against a stable key (cast regularId — or any patron id). */
|
||||
export function recordDenialBy(state: GameState, key: string, nightIndex: number): RegularMemory {
|
||||
const existing = state.regulars[key];
|
||||
const mem: RegularMemory = existing ?? {
|
||||
patronId: key,
|
||||
timesDenied: 0,
|
||||
lastSeenNight: nightIndex,
|
||||
grudge: 0,
|
||||
};
|
||||
mem.timesDenied += 1;
|
||||
mem.lastSeenNight = nightIndex;
|
||||
mem.grudge = Math.min(1, mem.grudge + 0.34); // three denials = maximum grudge
|
||||
state.regulars[key] = mem;
|
||||
return mem;
|
||||
}
|
||||
|
||||
export function recordDenial(state: GameState, patron: Patron, nightIndex: number): RegularMemory {
|
||||
const existing = state.regulars[patron.id];
|
||||
const mem: RegularMemory = existing ?? {
|
||||
@ -28,3 +44,8 @@ export function shouldReturnDisguised(state: GameState, patronId: string): boole
|
||||
const mem = state.regulars[patronId];
|
||||
return !!mem && mem.timesDenied >= 2;
|
||||
}
|
||||
|
||||
/** Same rule, straight off a memory record (spawn-time check in the queue). */
|
||||
export function disguisedFromMemory(mem: RegularMemory | undefined): boolean {
|
||||
return !!mem && mem.timesDenied >= 2;
|
||||
}
|
||||
|
||||
53
src/patrons/regularCast.ts
Normal file
@ -0,0 +1,53 @@
|
||||
// The run's regulars: a small stable cast generated from the RUN seed, so the
|
||||
// same faces turn up Thursday, Friday, and next week at the better venue.
|
||||
// Design §4.1: deny one twice and the third appearance arrives in a bad fake
|
||||
// moustache — same walk, same card, new "identity".
|
||||
|
||||
import { SeededRNG } from '../core/SeededRNG';
|
||||
import { generatePatron } from './generator';
|
||||
import type { Patron } from '../data/types';
|
||||
|
||||
export const REGULAR_CAST_SIZE = 6;
|
||||
|
||||
/**
|
||||
* Deterministic per run seed and independent of every night stream (its own
|
||||
* SeededRNG instance). The cast members are full Patron templates — the queue
|
||||
* adopts their IDENTITY (face, age, licence) onto freshly rolled nights
|
||||
* (tonight's outfit, tonight's drinks) at spawn time.
|
||||
*/
|
||||
export function buildRegularCast(runSeed: number, nightDate: Date): Patron[] {
|
||||
const rng = new SeededRNG(runSeed ^ 0x5eed);
|
||||
const cast: Patron[] = [];
|
||||
for (let i = 0; i < REGULAR_CAST_SIZE; i++) {
|
||||
const p = generatePatron({ rng, nightDate }, 0, 'regular');
|
||||
p.flags.regularId = `reg${i}`;
|
||||
cast.push(p);
|
||||
}
|
||||
return cast;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dress tonight's rolled regular in a cast member's identity. The roll keeps
|
||||
* its outfit/intoxication (people change clothes and moods); the cast supplies
|
||||
* who they ARE: face, age, licence, tolerance — and the grudge bookkeeping.
|
||||
*/
|
||||
export function adoptCastIdentity(
|
||||
rolled: Patron,
|
||||
member: Patron,
|
||||
timesDenied: number,
|
||||
disguised: boolean,
|
||||
): Patron {
|
||||
return {
|
||||
...rolled,
|
||||
dollSeed: member.dollSeed,
|
||||
age: member.age,
|
||||
idCard: { ...member.idCard },
|
||||
tolerance: member.tolerance,
|
||||
flags: {
|
||||
...rolled.flags,
|
||||
regularId: member.flags.regularId!,
|
||||
...(timesDenied > 0 ? { timesDeniedBefore: timesDenied } : {}),
|
||||
...(disguised ? { disguised: true } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
47
src/rules/doorTypes.ts
Normal file
@ -0,0 +1,47 @@
|
||||
// LANE-DOOR type extensions. CCR-1 (PatronFlags.handHolding) and CCR-2
|
||||
// (VerdictOutcome.hypeDelta) were folded into data/types.ts at the Phase-1
|
||||
// review — this file now only holds door-domain types.
|
||||
|
||||
import type { HeatStrike, Patron, VerdictOutcome } from '../data/types';
|
||||
|
||||
/**
|
||||
* A consequence that lands LATER: Dazza does a lap and spots the bloke in
|
||||
* thongs you waved through, the inspector audits at 3 AM. Deferred hits are the
|
||||
* reason admitting is scary — the cost never arrives while you can still argue.
|
||||
*/
|
||||
export interface DeferredHit {
|
||||
/** clock-minute at which this fires (absolute, not relative). */
|
||||
atClockMin: number;
|
||||
vibeDelta?: number;
|
||||
aggroDelta?: number;
|
||||
dazzaText?: string;
|
||||
heatStrike?: HeatStrike;
|
||||
/** why, for the incident log + night summary */
|
||||
reason: string;
|
||||
patronId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structurally a VerdictOutcome (so it drops straight into the `door:verdict`
|
||||
* event payload), plus the deferred tail that the night shell schedules.
|
||||
*/
|
||||
export interface DoorOutcome extends VerdictOutcome {
|
||||
deferred?: DeferredHit[];
|
||||
/** short line shown on the verdict toast — player-facing feedback. */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** Everything judge() needs to score a call. Pure data — no Phaser, no scene. */
|
||||
export interface JudgeContext {
|
||||
clockMin: number;
|
||||
nightDate: Date;
|
||||
/** 0..1 roll used for the "Dazza does a lap" delay; caller passes rng.next(). */
|
||||
lapRoll: number;
|
||||
insideCount: number;
|
||||
licensed: number;
|
||||
}
|
||||
|
||||
/** Result of a sobriety test the player has already ruled on. */
|
||||
export type SobrietyRuling = 'pass' | 'fail';
|
||||
|
||||
export type { Patron };
|
||||
221
src/rules/dressCode.ts
Normal file
@ -0,0 +1,221 @@
|
||||
// The dress code Dazza texts through the night. Rules evaluate outfit DATA; the
|
||||
// player only ever sees pixels (design §4.1) — the gap is the game.
|
||||
//
|
||||
// `text` and `activeFrom` are NOT written here. They are read out of
|
||||
// data/strings/dazza.ts by ruleId, because a rule whose card disagrees with the
|
||||
// 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';
|
||||
|
||||
/** Contract rule + a card label short enough for the on-screen dress-code board. */
|
||||
export interface DoorDressCodeRule extends DressCodeRule {
|
||||
short: string;
|
||||
}
|
||||
|
||||
// The vocab spells "not wearing one" as type 'none' (data/outfits.ts), so every
|
||||
// lookup has to treat that as absent or `outer.type === 'blazer'` style checks
|
||||
// start reasoning about a garment nobody is wearing.
|
||||
const worn = (p: Patron, slot: OutfitSlot): OutfitLayer | undefined => {
|
||||
const layer = p.outfit.find((l) => l.slot === slot);
|
||||
return layer && layer.type !== 'none' ? layer : undefined;
|
||||
};
|
||||
|
||||
const isType = (p: Patron, slot: OutfitSlot, type: string): boolean => worn(p, slot)?.type === type;
|
||||
|
||||
// Slots whose logo dollPlan.ts actually paints (src/patrons/dollPlan.ts:104 draws
|
||||
// the white logo rect for the TOP slot and nothing else). The generator hands out
|
||||
// logos on five slots, so enforcing all of them denies roughly a quarter of the
|
||||
// queue over pixels that were never on screen — and Dazza's own text says
|
||||
// "no VISIBLE logos". Widen this set the moment the renderer draws the rest; see
|
||||
// CCR-3 in LANEHANDOVER.md.
|
||||
const LOGO_VISIBLE_SLOTS: readonly OutfitSlot[] = ['top'];
|
||||
|
||||
const hasVisibleLogo = (p: Patron): boolean =>
|
||||
p.outfit.some(
|
||||
(l) => l.type !== 'none' && l.logo === true && LOGO_VISIBLE_SLOTS.includes(l.slot),
|
||||
);
|
||||
|
||||
// Signals of the bloke who will be at the DJ booth by 1:15 asking for one song,
|
||||
// just one. No single item convicts — a jersey is a jersey — but two of these at
|
||||
// once is a man with a request already typed into his notes app.
|
||||
//
|
||||
// `renders` marks the tells dollPlan.ts actually DRAWS today. A jersey torso and
|
||||
// trackie legs are plain coloured rects, indistinguishable from a tee and jeans,
|
||||
// so convicting on those two alone denies a man over pixels that were never on
|
||||
// screen — the one thing design §4.1 forbids. Conviction therefore needs two
|
||||
// tells of which at least one is visible. When the art pass gives jersey and
|
||||
// trackies their own geometry, flip `renders` and this guard dissolves.
|
||||
interface Tell {
|
||||
test: (p: Patron) => boolean;
|
||||
renders: boolean;
|
||||
}
|
||||
|
||||
const SONG_REQUEST_TELLS: readonly Tell[] = [
|
||||
{ test: (p) => isType(p, 'top', 'jersey'), renders: false },
|
||||
{ test: (p) => isType(p, 'hair', 'mullet'), renders: true },
|
||||
{ test: (p) => isType(p, 'legs', 'trackies'), renders: false },
|
||||
{
|
||||
test: (p) => {
|
||||
const acc = worn(p, 'accessory')?.type;
|
||||
return acc === 'cap' || acc === 'chain' || acc === 'bumbag';
|
||||
},
|
||||
renders: true,
|
||||
},
|
||||
];
|
||||
|
||||
function looksLikeASongRequest(p: Patron): boolean {
|
||||
const hits = SONG_REQUEST_TELLS.filter((t) => t.test(p));
|
||||
return hits.length >= 2 && hits.some((t) => t.renders);
|
||||
}
|
||||
|
||||
interface RuleSpec {
|
||||
id: string;
|
||||
short: string;
|
||||
violates: (p: Patron) => boolean;
|
||||
}
|
||||
|
||||
// Declared in DAZZA_TEXTS order so the two files read side by side. That order is
|
||||
// NOT chronological (Dazza is not an organised man), so the sort below is
|
||||
// load-bearing — announcement order comes from activeFrom, never from this array.
|
||||
const SPECS: readonly RuleSpec[] = [
|
||||
{
|
||||
id: 'noThongsSinglets',
|
||||
short: 'NO THONGS / NO SINGLETS',
|
||||
violates: (p) => isType(p, 'shoes', 'thong') || isType(p, 'top', 'singlet'),
|
||||
},
|
||||
{
|
||||
id: 'noLogos',
|
||||
short: 'NO VISIBLE LOGOS',
|
||||
violates: hasVisibleLogo,
|
||||
},
|
||||
{
|
||||
id: 'noWhiteSneakers',
|
||||
short: 'NO WHITE SNEAKERS, VINTAGE OK',
|
||||
violates: (p) => {
|
||||
const shoes = worn(p, 'shoes');
|
||||
return shoes?.type === 'sneaker' && shoes.colour === 'white' && shoes.vintage !== true;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'noSongRequesters',
|
||||
short: 'NO SONG REQUESTERS',
|
||||
violates: looksLikeASongRequest,
|
||||
},
|
||||
{
|
||||
id: 'noBucketHats',
|
||||
short: 'NO BUCKET HATS',
|
||||
violates: (p) => isType(p, 'accessory', 'bucketHat'),
|
||||
},
|
||||
{
|
||||
id: 'noSunnies',
|
||||
short: 'NO SUNNIES INSIDE',
|
||||
violates: (p) => isType(p, 'accessory', 'sunnies'),
|
||||
},
|
||||
{
|
||||
id: 'noBlazers',
|
||||
short: 'NO BLAZERS',
|
||||
violates: (p) => isType(p, 'outer', 'blazer'),
|
||||
},
|
||||
{
|
||||
id: 'noHandHolders',
|
||||
short: 'NO HAND HOLDING',
|
||||
// QueueManager pairs arrivals; the linked-hands pixel is what the player sees.
|
||||
violates: (p) => p.flags.handHolding === true,
|
||||
},
|
||||
];
|
||||
|
||||
const DAZZA_BY_RULE = new Map(
|
||||
DAZZA_TEXTS.filter((l): l is typeof l & { ruleId: string } => l.ruleId !== undefined).map(
|
||||
(l) => [l.ruleId, l] as const,
|
||||
),
|
||||
);
|
||||
|
||||
function build(spec: RuleSpec): DoorDressCodeRule {
|
||||
const line = DAZZA_BY_RULE.get(spec.id);
|
||||
// Shipping a rule with an empty card is worse than not booting: the player
|
||||
// would be denying people over a rule nobody announced.
|
||||
if (!line) throw new Error(`dressCode: no Dazza text announces rule '${spec.id}'`);
|
||||
return {
|
||||
id: spec.id,
|
||||
text: line.text,
|
||||
activeFrom: line.fromMin,
|
||||
short: spec.short,
|
||||
violates: spec.violates,
|
||||
};
|
||||
}
|
||||
|
||||
for (const ruleId of DAZZA_BY_RULE.keys()) {
|
||||
if (!SPECS.some((s) => s.id === ruleId)) {
|
||||
throw new Error(`dressCode: Dazza announces rule '${ruleId}' but nothing enforces it`);
|
||||
}
|
||||
}
|
||||
|
||||
// Sorted once at load so activeRules/violations preserve announcement order for free.
|
||||
export const DRESS_CODE_RULES: readonly DoorDressCodeRule[] = SPECS.map(build).sort(
|
||||
(a, b) => a.activeFrom - b.activeFrom,
|
||||
);
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
export function violations(patron: Patron, clockMin: number): DoorDressCodeRule[] {
|
||||
return activeRules(clockMin).filter((r) => r.violates(patron));
|
||||
}
|
||||
|
||||
export function ruleById(id: string): DoorDressCodeRule | undefined {
|
||||
return DRESS_CODE_RULES.find((r) => r.id === id);
|
||||
}
|
||||
717
src/rules/encounters.ts
Normal file
@ -0,0 +1,717 @@
|
||||
import type { RngStream } from '../core/SeededRNG';
|
||||
import { drunkStage } from './drunk';
|
||||
import { CONTRABAND } from '../data/contraband';
|
||||
import { OUTFIT_VOCAB } from '../data/outfits';
|
||||
import { ENCOUNTER_SCRIPTS, type EncounterScript } from '../data/strings/encounters';
|
||||
import type { Archetype, DrunkStage, HeatStrike, Patron } from '../data/types';
|
||||
|
||||
// Scripted encounters — design §4.3, the reason this is a game about people and
|
||||
// not a spot-the-difference puzzle. Two or three of these are injected into the
|
||||
// generated crowd per night.
|
||||
//
|
||||
// The rule the rest of this file exists to serve: doing the human thing costs
|
||||
// Vibe or risks Heat, and NOTHING anywhere tells the player which call was right.
|
||||
// There is deliberately no score, no flag, no tally of humane verdicts — if a
|
||||
// later pass adds one, it has removed the mechanic rather than completed it.
|
||||
//
|
||||
// The corollary is just as load-bearing: the humane call must not be secretly
|
||||
// optimal either. A generous option that quietly pays better is the same lie told
|
||||
// the other way round. Every `onAdmit` below is priced to be a real decision.
|
||||
|
||||
export type EncounterId =
|
||||
| 'grabHerMate' | 'quietBeer' | 'lastNight' | 'nightShift'
|
||||
| 'eighteenToday' | 'kaydensMate' | 'bigNightOut' | 'twoOfThem'
|
||||
| 'offDutyCop' | 'celebrity' | 'sovereign' | 'deliveryRider'
|
||||
| 'promoter' | 'neighbour' | 'hensParty' | 'twins' | 'exDoorman';
|
||||
|
||||
export interface EncounterBeat {
|
||||
line: string;
|
||||
/**
|
||||
* Delay from the moment the patron REACHES THE ROPE — absolute, not a gap
|
||||
* since the previous beat. Read as a gap, a skipped or slow line compresses
|
||||
* everything after it and the exchange stops breathing.
|
||||
*/
|
||||
afterMs: number;
|
||||
}
|
||||
|
||||
export interface EncounterOutcome {
|
||||
vibeDelta?: number;
|
||||
aggroDelta?: number;
|
||||
heatStrike?: HeatStrike;
|
||||
dazzaText?: string;
|
||||
/** shown on the verdict toast — factual, never a moral verdict */
|
||||
note?: string;
|
||||
/**
|
||||
* Consequences that land LATER, at a random minute inside their window —
|
||||
* the hen's party detonating, the pap swarm assembling. Applied through
|
||||
* NightScene's deferred machinery so they survive a location switch and show
|
||||
* up in the night summary with their reason.
|
||||
*/
|
||||
deferredHits?: readonly {
|
||||
/** [lo, hi] clock-minutes AFTER the verdict */
|
||||
afterMin: readonly [number, number];
|
||||
vibeDelta?: number;
|
||||
aggroDelta?: number;
|
||||
reason: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface ScriptedEncounter {
|
||||
id: EncounterId;
|
||||
/** clock-minute window this can be scheduled into */
|
||||
window: [number, number];
|
||||
archetype: Archetype;
|
||||
/** mutates a freshly generated patron into this specific person */
|
||||
dress(patron: Patron, rng: RngStream): void;
|
||||
beats: EncounterBeat[];
|
||||
onAdmit: EncounterOutcome;
|
||||
onDeny: EncounterOutcome;
|
||||
/**
|
||||
* Coin-flip encounters: called INSTEAD of the static tables when present.
|
||||
* The coin was tossed at dress() time and rides in flags.encounterVariant, so
|
||||
* resolution is a lookup, never a fresh roll — the person at the rope was
|
||||
* always one thing, the player just couldn't see which.
|
||||
*/
|
||||
resolve?(patron: Patron, verdict: 'admit' | 'deny'): EncounterOutcome;
|
||||
}
|
||||
|
||||
/** Minutes of clear air an encounter needs either side of it to land. */
|
||||
export const ENCOUNTER_SPACING_MIN = 40;
|
||||
|
||||
// ---- intoxication bands ------------------------------------------------------
|
||||
|
||||
// The dumped bloke has to read as 'loose' at the rope — visible tells, not a
|
||||
// stumbling mess. Writing 0.55 here would silently become the wrong person the
|
||||
// day someone retunes drunk.ts, so the band is derived FROM drunkStage instead.
|
||||
// Scan step is fine enough to find each threshold to the nearest thousandth.
|
||||
const STAGE_SCAN_STEP = 0.0005;
|
||||
const BANDS = new Map<DrunkStage, readonly [number, number]>();
|
||||
|
||||
function stageBand(stage: DrunkStage): readonly [number, number] {
|
||||
const cached = BANDS.get(stage);
|
||||
if (cached) return cached;
|
||||
let lo = Number.POSITIVE_INFINITY;
|
||||
let hi = Number.NEGATIVE_INFINITY;
|
||||
for (let i = 0; i * STAGE_SCAN_STEP <= 1; i++) {
|
||||
const v = i * STAGE_SCAN_STEP;
|
||||
if (drunkStage(v) !== stage) continue;
|
||||
lo = Math.min(lo, v);
|
||||
hi = Math.max(hi, v);
|
||||
}
|
||||
if (!Number.isFinite(lo)) throw new Error(`encounters: no intoxication maps to '${stage}'`);
|
||||
const band = [lo, hi] as const;
|
||||
BANDS.set(stage, band);
|
||||
return band;
|
||||
}
|
||||
|
||||
/**
|
||||
* A value comfortably inside `stage`'s band. Inset from both edges because the
|
||||
* floor sim drifts intoxication upward: a patron parked on a threshold restages
|
||||
* on the first tick, and the script's whole point is the stage they arrive in.
|
||||
*/
|
||||
function intoxicationIn(stage: DrunkStage, rng: RngStream): number {
|
||||
const [lo, hi] = stageBand(stage);
|
||||
const inset = (hi - lo) * 0.15;
|
||||
return lo + inset + rng.next() * (hi - lo - 2 * inset);
|
||||
}
|
||||
|
||||
// ---- shared dressing ---------------------------------------------------------
|
||||
|
||||
const FLASK = CONTRABAND.find((c) => c.id === 'flask');
|
||||
if (!FLASK) throw new Error("encounters: contraband has no 'flask' — lastNight has nothing to carry");
|
||||
|
||||
const POLO = OUTFIT_VOCAB.top.find((d) => d.type === 'polo' && d.canLogo === true);
|
||||
if (!POLO) {
|
||||
throw new Error("encounters: outfit vocab has no logo-capable 'polo' — nightShift is the work shirt");
|
||||
}
|
||||
|
||||
/**
|
||||
* Give a scripted person a boring, valid licence.
|
||||
*
|
||||
* `punter` carries a 2% fake-ID roll, and a scripted moral call that turns into
|
||||
* an ID puzzle is neither. dress() has no nightDate to recompute an expiry from,
|
||||
* so the existing one is pushed five years past whatever the generator produced —
|
||||
* which clears the 'expired' tell whichever side of tonight it started on.
|
||||
*/
|
||||
function settleId(patron: Patron): void {
|
||||
delete patron.idCard.fake;
|
||||
patron.idCard.photoSeed = patron.dollSeed;
|
||||
patron.idCard.expiry = plusFiveYears(patron.idCard.expiry);
|
||||
}
|
||||
|
||||
const plusFiveYears = (iso: string): string => {
|
||||
const [y = '2026', m = '01', d = '01'] = iso.slice(0, 10).split('-');
|
||||
// Clamped to the 28th so a 29 February expiry cannot be pushed onto a date that
|
||||
// does not exist, which parses as NaN and quietly reads as "never expires".
|
||||
const day = Math.min(Number(d), 28);
|
||||
return `${Number(y) + 5}-${m}-${String(day).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
/** Everything every scripted patron needs. dollSeed is untouched on purpose: the
|
||||
* paper doll should still be a random stranger, or the player learns the faces. */
|
||||
function baseDress(patron: Patron, rng: RngStream, script: EncounterScript): void {
|
||||
settleId(patron);
|
||||
patron.idCard.name = rng.pick(script.names);
|
||||
// Same reasoning as clearContraband. The guest-list clipboard fires a
|
||||
// "'m on the list" step-up line off claimsGuestList, which would talk over
|
||||
// the scripted beats — and "i was inside ten minutes ago" plus a guest-list
|
||||
// claim is two different people standing in one pair of shoes.
|
||||
delete patron.flags.claimsGuestList;
|
||||
delete patron.flags.onGuestList;
|
||||
}
|
||||
|
||||
/** Scripted dilemmas are about the one thing they are about. A stray baggie from
|
||||
* the archetype roll turns the girl at the rope into a pat-down instead. */
|
||||
function clearContraband(patron: Patron): void {
|
||||
delete patron.flags.contraband;
|
||||
}
|
||||
|
||||
const script = (id: EncounterId): EncounterScript => {
|
||||
const s = ENCOUNTER_SCRIPTS[id];
|
||||
if (!s) throw new Error(`encounters: no dialogue script for '${id}'`);
|
||||
return s;
|
||||
};
|
||||
|
||||
const beatsOf = (id: EncounterId): EncounterBeat[] =>
|
||||
script(id).beats.map((b) => ({ line: b.line, afterMs: b.afterMs }));
|
||||
|
||||
// ---- the encounters ----------------------------------------------------------
|
||||
|
||||
const ENCOUNTERS_BASE: readonly ScriptedEncounter[] = [
|
||||
{
|
||||
// Early: he is doing the rounds while he can still stand at a bar and talk.
|
||||
id: 'lastNight',
|
||||
window: [30, 150],
|
||||
archetype: 'regular',
|
||||
dress(patron, rng) {
|
||||
const s = script('lastNight');
|
||||
baseDress(patron, rng, s);
|
||||
// He TELLS you about the flask. That is the trap — a hip flask you were
|
||||
// told about is a licence problem you chose, not one you missed.
|
||||
patron.flags.contraband = [FLASK.id];
|
||||
patron.intoxication = intoxicationIn('tipsy', rng);
|
||||
// Deliberately no timesDeniedBefore: judge() prices a grudged regular at
|
||||
// -5 Vibe on deny, which would tilt this straight back toward admitting.
|
||||
// The knock-back in his second beat happened years before this run.
|
||||
},
|
||||
beats: beatsOf('lastNight'),
|
||||
onAdmit: {
|
||||
vibeDelta: 2,
|
||||
heatStrike: { reason: 'BYO spirits admitted — hip flask carried in on the door', deferred: true },
|
||||
note: script('lastNight').admitNote,
|
||||
},
|
||||
onDeny: { note: script('lastNight').denyNote },
|
||||
},
|
||||
{
|
||||
id: 'quietBeer',
|
||||
window: [90, 210],
|
||||
archetype: 'quietMessy',
|
||||
dress(patron, rng) {
|
||||
const s = script('quietBeer');
|
||||
baseDress(patron, rng, s);
|
||||
clearContraband(patron);
|
||||
// 'loose' exactly: the tells are visible, so the player cannot claim later
|
||||
// that they could not tell. quietMessy's low tolerance does the rest.
|
||||
patron.intoxication = intoxicationIn('loose', rng);
|
||||
},
|
||||
beats: beatsOf('quietBeer'),
|
||||
onAdmit: {
|
||||
// The real cost is not this number. It is a loose quietMessy on the floor
|
||||
// with a reason to keep going, which the floor sim charges for later.
|
||||
vibeDelta: -1,
|
||||
dazzaText: script('quietBeer').admitDazza,
|
||||
note: script('quietBeer').admitNote,
|
||||
},
|
||||
onDeny: { note: script('quietBeer').denyNote },
|
||||
},
|
||||
{
|
||||
// After the logo rule lands at 90 — and BEFORE Dazza rescinds it (the rule
|
||||
// cap retires noLogos when the 200-minute rule arrives). Outside that band
|
||||
// he is just a bloke in a polo and the dilemma evaporates.
|
||||
id: 'nightShift',
|
||||
window: [150, 195],
|
||||
archetype: 'punter',
|
||||
dress(patron, rng) {
|
||||
const s = script('nightShift');
|
||||
baseDress(patron, rng, s);
|
||||
clearContraband(patron);
|
||||
patron.intoxication = intoxicationIn('sober', rng);
|
||||
// Mutated in place rather than pushed, so the outfit keeps exactly one
|
||||
// layer per slot. The generator always fills top; the guard is for a
|
||||
// hand-built patron in a test.
|
||||
const top = patron.outfit.find((l) => l.slot === 'top');
|
||||
if (top) {
|
||||
top.type = POLO.type;
|
||||
top.colour = rng.pick(POLO.colours);
|
||||
top.logo = true;
|
||||
}
|
||||
},
|
||||
beats: beatsOf('nightShift'),
|
||||
onAdmit: {
|
||||
// No numbers here on purpose. He is a live noLogos breach, so admitting
|
||||
// him is already priced by judge() — Dazza does a lap and finds him. A
|
||||
// bespoke penalty on top would charge the player twice for one call.
|
||||
note: script('nightShift').admitNote,
|
||||
},
|
||||
onDeny: { note: script('nightShift').denyNote },
|
||||
},
|
||||
{
|
||||
// Late, when the room is at the number and the stamp actually matters.
|
||||
id: 'grabHerMate',
|
||||
window: [210, 330],
|
||||
archetype: 'punter',
|
||||
dress(patron, rng) {
|
||||
const s = script('grabHerMate');
|
||||
baseDress(patron, rng, s);
|
||||
clearContraband(patron);
|
||||
// The whole dilemma in one flag. She stepped out without one and says so.
|
||||
patron.flags.uvStamped = false;
|
||||
patron.intoxication = intoxicationIn('tipsy', rng);
|
||||
},
|
||||
beats: beatsOf('grabHerMate'),
|
||||
onAdmit: {
|
||||
vibeDelta: -2,
|
||||
heatStrike: {
|
||||
// Worded off what THIS module knows — she has no stamp and the encounter
|
||||
// set that flag itself. It deliberately does NOT mention capacity: the
|
||||
// encounter cannot see insideCount, so a capacity-worded reason is a lie
|
||||
// whenever the room is half empty, and a duplicate of judge()'s own
|
||||
// capacity strike whenever it isn't. One admit, one strike, true reason.
|
||||
reason: 'unstamped re-entry admitted — nothing on the door to say she was inside',
|
||||
deferred: true,
|
||||
},
|
||||
dazzaText: script('grabHerMate').admitDazza,
|
||||
note: script('grabHerMate').admitNote,
|
||||
},
|
||||
// Free. Costs nothing but the moment, and the moment is not a mechanic.
|
||||
onDeny: { note: script('grabHerMate').denyNote },
|
||||
},
|
||||
{
|
||||
// Early: it went midnight forty minutes ago and he has rehearsed this.
|
||||
id: 'eighteenToday',
|
||||
window: [60, 190],
|
||||
archetype: 'fresh18',
|
||||
dress(patron, rng) {
|
||||
const s = script('eighteenToday');
|
||||
baseDress(patron, rng, s);
|
||||
clearContraband(patron);
|
||||
patron.intoxication = intoxicationIn('sober', rng);
|
||||
},
|
||||
beats: beatsOf('eighteenToday'),
|
||||
// No numbers either way. He is a clean legal admit and judge() already pays
|
||||
// the ordinary rate; denying him is the conscience test judgeDeny narrates.
|
||||
onAdmit: { note: script('eighteenToday').admitNote },
|
||||
onDeny: { note: script('eighteenToday').denyNote },
|
||||
},
|
||||
{
|
||||
// Kayden has been making promises again.
|
||||
id: 'kaydensMate',
|
||||
window: [120, 250],
|
||||
archetype: 'punter',
|
||||
dress(patron, rng) {
|
||||
const s = script('kaydensMate');
|
||||
baseDress(patron, rng, s);
|
||||
clearContraband(patron);
|
||||
patron.intoxication = intoxicationIn('tipsy', rng);
|
||||
},
|
||||
beats: beatsOf('kaydensMate'),
|
||||
onAdmit: {
|
||||
// Small and real: you honoured an authority that does not exist.
|
||||
vibeDelta: -1,
|
||||
dazzaText: script('kaydensMate').admitDazza,
|
||||
note: script('kaydensMate').admitNote,
|
||||
},
|
||||
onDeny: { note: script('kaydensMate').denyNote },
|
||||
},
|
||||
{
|
||||
// The buck's straggler. Sober-adjacent, desperate to be present.
|
||||
id: 'bigNightOut',
|
||||
window: [180, 300],
|
||||
archetype: 'punter',
|
||||
dress(patron, rng) {
|
||||
const s = script('bigNightOut');
|
||||
baseDress(patron, rng, s);
|
||||
clearContraband(patron);
|
||||
// 'loose' on two lemonades and a recovery: the tells are real even if his
|
||||
// story is true. RSA does not care about the wedding.
|
||||
patron.intoxication = intoxicationIn('loose', rng);
|
||||
},
|
||||
beats: beatsOf('bigNightOut'),
|
||||
onAdmit: {
|
||||
// The floor prices a loose admission already (judge ripen); this is the
|
||||
// chant getting his name in it.
|
||||
vibeDelta: 1,
|
||||
note: script('bigNightOut').admitNote,
|
||||
},
|
||||
onDeny: { note: script('bigNightOut').denyNote },
|
||||
},
|
||||
{
|
||||
// Engaged tonight, still holding hands, fully briefed on the rule.
|
||||
id: 'twoOfThem',
|
||||
window: [240, 345],
|
||||
archetype: 'punter',
|
||||
dress(patron, rng) {
|
||||
const s = script('twoOfThem');
|
||||
baseDress(patron, rng, s);
|
||||
clearContraband(patron);
|
||||
patron.intoxication = intoxicationIn('tipsy', rng);
|
||||
// A live noHandHolders breach the whole window (the rule survives the cap
|
||||
// from 200 onward). judge() prices the admit; the beats price the deny.
|
||||
patron.flags.handHolding = true;
|
||||
},
|
||||
beats: beatsOf('twoOfThem'),
|
||||
onAdmit: { note: script('twoOfThem').admitNote },
|
||||
onDeny: { note: script('twoOfThem').denyNote },
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
// ---- variant plumbing --------------------------------------------------------
|
||||
|
||||
/** The static outcome for a coin-flip encounter's variant, notes swapped in. */
|
||||
function variantOutcome(
|
||||
id: EncounterId,
|
||||
variant: string,
|
||||
verdict: 'admit' | 'deny',
|
||||
base: EncounterOutcome,
|
||||
): EncounterOutcome {
|
||||
const notes = script(id).variantNotes?.[variant];
|
||||
if (!notes) return base;
|
||||
const note = verdict === 'admit' ? notes.admitNote : notes.denyNote;
|
||||
const dazzaText = verdict === 'admit' ? notes.admitDazza : notes.denyDazza;
|
||||
return {
|
||||
...base,
|
||||
...(note !== undefined ? { note } : {}),
|
||||
...(dazzaText !== undefined ? { dazzaText } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Twin B, built from twin A after A's verdict. NOT a scripted encounter and not
|
||||
* a fake: his own real name, his own genuine licence, and — because they are
|
||||
* twins — his brother's face. The photo check is the thing on trial.
|
||||
*/
|
||||
export function twinSibling(a: Patron): Patron {
|
||||
const names = script('twins').names;
|
||||
const other = names.find((n) => n !== a.idCard.name) ?? names[0]!;
|
||||
return {
|
||||
...a,
|
||||
id: `${a.id}b`,
|
||||
idCard: { ...a.idCard, name: other, photoSeed: a.dollSeed },
|
||||
flags: {},
|
||||
outfit: a.outfit.map((l) => ({ ...l })),
|
||||
};
|
||||
}
|
||||
|
||||
const NEW_ENCOUNTERS: readonly ScriptedEncounter[] = [
|
||||
{
|
||||
// The ID game's scariest bluff. The badge is theatre either way — the
|
||||
// LICENCE knows which man this is (the costume shop does licences too).
|
||||
id: 'offDutyCop',
|
||||
window: [140, 280],
|
||||
archetype: 'punter',
|
||||
dress(patron, rng) {
|
||||
const s = script('offDutyCop');
|
||||
baseDress(patron, rng, s);
|
||||
clearContraband(patron);
|
||||
patron.intoxication = intoxicationIn('sober', rng);
|
||||
patron.age = 38;
|
||||
patron.flags.encounterVariant = rng.chance(0.5) ? 'real' : 'fake';
|
||||
if (patron.flags.encounterVariant === 'fake') {
|
||||
patron.idCard.fake = { wrongHologram: true };
|
||||
}
|
||||
},
|
||||
beats: beatsOf('offDutyCop'),
|
||||
// Static tables are the 'real' variant; resolve() swaps per-coin.
|
||||
onAdmit: {
|
||||
heatStrike: { reason: "off-duty officer noted the venue's breaches from the floor", deferred: true },
|
||||
note: script('offDutyCop').variantNotes!.real!.admitNote!,
|
||||
},
|
||||
onDeny: { aggroDelta: -2, note: script('offDutyCop').variantNotes!.real!.denyNote! },
|
||||
resolve(patron, verdict) {
|
||||
const v = patron.flags.encounterVariant ?? 'real';
|
||||
if (v === 'real') {
|
||||
return variantOutcome('offDutyCop', 'real', verdict, verdict === 'admit' ? this.onAdmit : this.onDeny);
|
||||
}
|
||||
// The $30 badge: a dickhead, not a danger. His fake licence already
|
||||
// justified the deny; admitting him is only ever embarrassing.
|
||||
return variantOutcome('offDutyCop', 'fake', verdict, verdict === 'admit' ? { vibeDelta: 1 } : {});
|
||||
},
|
||||
},
|
||||
{
|
||||
// Cap low, sunnies on. Deny: nothing, ever. Admit: a coin you cannot see.
|
||||
id: 'celebrity',
|
||||
window: [180, 320],
|
||||
archetype: 'punter',
|
||||
dress(patron, rng) {
|
||||
const s = script('celebrity');
|
||||
baseDress(patron, rng, s);
|
||||
clearContraband(patron);
|
||||
patron.intoxication = intoxicationIn('tipsy', rng);
|
||||
const acc = patron.outfit.find((l) => l.slot === 'accessory');
|
||||
if (acc) {
|
||||
acc.type = 'sunnies';
|
||||
acc.colour = 'black';
|
||||
delete acc.logo;
|
||||
}
|
||||
patron.flags.encounterVariant = rng.chance(0.5) ? 'rocket' : 'paps';
|
||||
},
|
||||
beats: beatsOf('celebrity'),
|
||||
onAdmit: {
|
||||
deferredHits: [{ afterMin: [8, 25], vibeDelta: 10, reason: 'the whole floor realises at once who that is' }],
|
||||
note: script('celebrity').variantNotes!.rocket!.admitNote!,
|
||||
},
|
||||
onDeny: { note: script('celebrity').denyNote! },
|
||||
resolve(patron, verdict) {
|
||||
if (verdict === 'deny') return this.onDeny;
|
||||
const v = patron.flags.encounterVariant ?? 'rocket';
|
||||
if (v === 'rocket') return this.onAdmit;
|
||||
return variantOutcome('celebrity', 'paps', 'admit', {
|
||||
deferredHits: [{ afterMin: [5, 15], aggroDelta: 12, reason: 'a pap swarm sets up across the road' }],
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
// The aggro engine in a polo. His licence is a laminated business card —
|
||||
// fake tells are load-bearing: the desk can SEE this is not a document.
|
||||
id: 'sovereign',
|
||||
window: [100, 260],
|
||||
archetype: 'punter',
|
||||
dress(patron, rng) {
|
||||
const s = script('sovereign');
|
||||
baseDress(patron, rng, s);
|
||||
clearContraband(patron);
|
||||
patron.intoxication = intoxicationIn('sober', rng);
|
||||
patron.age = 46;
|
||||
patron.idCard.fake = { jokeName: true, peelingLaminate: true };
|
||||
},
|
||||
beats: beatsOf('sovereign'),
|
||||
onAdmit: {
|
||||
vibeDelta: -2,
|
||||
deferredHits: [{ afterMin: [10, 30], aggroDelta: 5, reason: 'he is explaining maritime law to the dance floor' }],
|
||||
note: script('sovereign').admitNote!,
|
||||
},
|
||||
onDeny: { aggroDelta: 5, note: script('sovereign').denyNote! },
|
||||
},
|
||||
{
|
||||
// "delivery for DJ". The DJ did not order food. OR DID HE.
|
||||
id: 'deliveryRider',
|
||||
window: [60, 330],
|
||||
archetype: 'punter',
|
||||
dress(patron, rng) {
|
||||
const s = script('deliveryRider');
|
||||
baseDress(patron, rng, s);
|
||||
clearContraband(patron);
|
||||
patron.intoxication = intoxicationIn('sober', rng);
|
||||
patron.flags.encounterVariant = rng.chance(0.5) ? 'ordered' : 'not';
|
||||
},
|
||||
beats: beatsOf('deliveryRider'),
|
||||
onAdmit: {
|
||||
deferredHits: [{ afterMin: [3, 8], vibeDelta: 4, reason: 'the booth eats; the next hour of the mix has a spine' }],
|
||||
note: script('deliveryRider').variantNotes!.ordered!.admitNote!,
|
||||
},
|
||||
onDeny: { note: script('deliveryRider').variantNotes!.not!.denyNote! },
|
||||
resolve(patron, verdict) {
|
||||
const v = patron.flags.encounterVariant ?? 'not';
|
||||
if (v === 'ordered') {
|
||||
if (verdict === 'admit') return this.onAdmit;
|
||||
return variantOutcome('deliveryRider', 'ordered', 'deny', {
|
||||
deferredHits: [{ afterMin: [5, 12], vibeDelta: -3, reason: 'a hungry DJ plays like one' }],
|
||||
});
|
||||
}
|
||||
return variantOutcome('deliveryRider', 'not', verdict, verdict === 'admit' ? { vibeDelta: -1 } : {});
|
||||
},
|
||||
},
|
||||
{
|
||||
// Two clipboards enter, one leaves.
|
||||
id: 'promoter',
|
||||
window: [80, 200],
|
||||
archetype: 'financeBro',
|
||||
dress(patron, rng) {
|
||||
const s = script('promoter');
|
||||
baseDress(patron, rng, s);
|
||||
clearContraband(patron);
|
||||
patron.intoxication = intoxicationIn('sober', rng);
|
||||
},
|
||||
beats: beatsOf('promoter'),
|
||||
onAdmit: {
|
||||
vibeDelta: 2,
|
||||
deferredHits: [{ afterMin: [15, 40], aggroDelta: 6, reason: "wristbanded strangers are citing 'tyson' at the rope" }],
|
||||
dazzaText: script('promoter').admitDazza,
|
||||
note: script('promoter').admitNote!,
|
||||
},
|
||||
onDeny: { aggroDelta: 2, note: script('promoter').denyNote! },
|
||||
},
|
||||
{
|
||||
// The only patron all night who wants LESS club. Send her away and the
|
||||
// council takes her call; bring her in and the room feels it. §4.3 exactly.
|
||||
id: 'neighbour',
|
||||
window: [250, 340],
|
||||
archetype: 'punter',
|
||||
dress(patron, rng) {
|
||||
const s = script('neighbour');
|
||||
baseDress(patron, rng, s);
|
||||
clearContraband(patron);
|
||||
patron.intoxication = intoxicationIn('sober', rng);
|
||||
patron.age = 57;
|
||||
patron.idCard.dob = '1969-03-14';
|
||||
},
|
||||
beats: beatsOf('neighbour'),
|
||||
onAdmit: { vibeDelta: -3, note: script('neighbour').admitNote! },
|
||||
onDeny: {
|
||||
heatStrike: { reason: 'noise complaint escalated to council — the inspector took the call', deferred: true },
|
||||
note: script('neighbour').denyNote!,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Seven matching sashes, wrong venue, right energy.
|
||||
id: 'hensParty',
|
||||
window: [120, 280],
|
||||
archetype: 'punter',
|
||||
dress(patron, rng) {
|
||||
const s = script('hensParty');
|
||||
baseDress(patron, rng, s);
|
||||
clearContraband(patron);
|
||||
patron.intoxication = intoxicationIn('tipsy', rng);
|
||||
},
|
||||
beats: beatsOf('hensParty'),
|
||||
onAdmit: {
|
||||
deferredHits: [
|
||||
{ afterMin: [10, 45], vibeDelta: 8, reason: 'the practised dance detonates on the floor' },
|
||||
{ afterMin: [46, 80], aggroDelta: 5, reason: 'seven sashes at one bar is a supply problem' },
|
||||
],
|
||||
note: script('hensParty').admitNote!,
|
||||
},
|
||||
onDeny: { note: script('hensParty').denyNote! },
|
||||
},
|
||||
{
|
||||
// Genuinely twins. Twin B is injected by the queue after A's verdict —
|
||||
// a plain, un-scripted patron with his brother's face and his own name.
|
||||
id: 'twins',
|
||||
window: [90, 250],
|
||||
archetype: 'punter',
|
||||
dress(patron, rng) {
|
||||
const s = script('twins');
|
||||
baseDress(patron, rng, s);
|
||||
clearContraband(patron);
|
||||
patron.intoxication = intoxicationIn('tipsy', rng);
|
||||
},
|
||||
beats: beatsOf('twins'),
|
||||
onAdmit: { note: script('twins').admitNote! },
|
||||
onDeny: { note: script('twins').denyNote! },
|
||||
},
|
||||
{
|
||||
// The live tutorial character. Whatever you rule, he ends up by the
|
||||
// planter, and the next few patrons come with a tip that is right ~80% of
|
||||
// the time. Fourteen years of doors.
|
||||
id: 'exDoorman',
|
||||
window: [60, 220],
|
||||
archetype: 'regular',
|
||||
dress(patron, rng) {
|
||||
const s = script('exDoorman');
|
||||
baseDress(patron, rng, s);
|
||||
clearContraband(patron);
|
||||
patron.intoxication = intoxicationIn('sober', rng);
|
||||
patron.age = 49;
|
||||
},
|
||||
beats: beatsOf('exDoorman'),
|
||||
onAdmit: { note: script('exDoorman').admitNote! },
|
||||
onDeny: { note: script('exDoorman').denyNote! },
|
||||
},
|
||||
];
|
||||
|
||||
/** The full table: the original eight plus the second wave. Order is cosmetic —
|
||||
* the scheduler shuffles — but stable ids matter (cross-night seenEncounters). */
|
||||
export const ENCOUNTERS: readonly ScriptedEncounter[] = [...ENCOUNTERS_BASE, ...NEW_ENCOUNTERS];
|
||||
|
||||
for (const id of Object.keys(ENCOUNTER_SCRIPTS)) {
|
||||
if (!ENCOUNTERS.some((e) => e.id === id)) {
|
||||
throw new Error(`encounters: dialogue script '${id}' belongs to no encounter`);
|
||||
}
|
||||
}
|
||||
|
||||
// With eight overlapping windows a whole-set worst-case chain no longer fits in
|
||||
// one night (that guarantee died when the table outgrew a single night, by
|
||||
// design — a week should not see the same four people). The load-time check now
|
||||
// guards each window's own sanity; the scheduler handles infeasible DRAWS by
|
||||
// skipping the pick (a 2-encounter night beats a crashed one).
|
||||
function assertWindowsSane(): void {
|
||||
for (const e of ENCOUNTERS) {
|
||||
if (e.window[0] >= e.window[1] || e.window[0] < 0 || e.window[1] > 350) {
|
||||
throw new Error(`encounters: window for '${e.id}' is not a usable night interval`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assertWindowsSane();
|
||||
|
||||
export function encounterById(id: EncounterId): ScriptedEncounter | undefined {
|
||||
return ENCOUNTERS.find((e) => e.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick `count` distinct encounters and place each at a random minute inside its
|
||||
* own window, at least ENCOUNTER_SPACING_MIN apart and sorted by time. Asking for
|
||||
* more than exist returns all of them rather than throwing — a night that wants
|
||||
* five encounters should be short, not broken.
|
||||
*/
|
||||
export function scheduleEncounters(
|
||||
rng: RngStream,
|
||||
count: number,
|
||||
/** Encounter ids already met this run — a week should meet new people first. */
|
||||
exclude: ReadonlySet<string> = new Set(),
|
||||
): Array<{ atMin: number; id: EncounterId }> {
|
||||
const wanted = Math.min(Math.max(0, Math.floor(count)), ENCOUNTERS.length);
|
||||
if (wanted === 0) return [];
|
||||
|
||||
const shuffle = (arr: ScriptedEncounter[]): ScriptedEncounter[] => {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = rng.int(0, i);
|
||||
const a = arr[i]!;
|
||||
arr[i] = arr[j]!;
|
||||
arr[j] = a;
|
||||
}
|
||||
return arr;
|
||||
};
|
||||
|
||||
// Fresh faces first; once the run has met everyone, repeats are honest
|
||||
// (a regular being a regular), so the excluded set tops the pool back up.
|
||||
const fresh = shuffle(ENCOUNTERS.filter((e) => !exclude.has(e.id)));
|
||||
const met = shuffle(ENCOUNTERS.filter((e) => exclude.has(e.id)));
|
||||
const pool = [...fresh, ...met];
|
||||
|
||||
// Three passes, so a random draw can never strand a later pick:
|
||||
// 1. end-order greedy at the EARLIEST legal minute — anything that fails
|
||||
// here genuinely does not fit tonight and is dropped (a 2-encounter
|
||||
// night beats a crashed one);
|
||||
// 2. a backward pass computing each survivor's latest SAFE minute (its own
|
||||
// window end, capped by the next pick's latest minute less the spacing);
|
||||
// 3. a forward pass drawing the actual minute inside [earliest, latestSafe]
|
||||
// — random where there is room, exact where there is not.
|
||||
const chosen = pool.slice(0, wanted).sort((a, b) => a.window[1] - b.window[1]);
|
||||
|
||||
const placed: ScriptedEncounter[] = [];
|
||||
let probe = Number.NEGATIVE_INFINITY;
|
||||
for (const e of chosen) {
|
||||
const lo = Math.max(e.window[0], probe);
|
||||
if (lo > e.window[1]) continue;
|
||||
placed.push(e);
|
||||
probe = lo + ENCOUNTER_SPACING_MIN;
|
||||
}
|
||||
|
||||
const latest: number[] = new Array(placed.length);
|
||||
for (let i = placed.length - 1; i >= 0; i--) {
|
||||
const cap = i === placed.length - 1 ? Infinity : latest[i + 1]! - ENCOUNTER_SPACING_MIN;
|
||||
latest[i] = Math.min(placed[i]!.window[1], cap);
|
||||
}
|
||||
|
||||
const out: Array<{ atMin: number; id: EncounterId }> = [];
|
||||
let earliest = Number.NEGATIVE_INFINITY;
|
||||
for (let i = 0; i < placed.length; i++) {
|
||||
const e = placed[i]!;
|
||||
const lo = Math.max(e.window[0], earliest);
|
||||
const atMin = rng.int(lo, Math.max(lo, latest[i]!));
|
||||
out.push({ atMin, id: e.id });
|
||||
earliest = atMin + ENCOUNTER_SPACING_MIN;
|
||||
}
|
||||
return out.sort((a, b) => a.atMin - b.atMin);
|
||||
}
|
||||
68
src/rules/floorScore.ts
Normal file
@ -0,0 +1,68 @@
|
||||
// The floor score (John's ask, 2026-07-21): the venue closes, the lights come
|
||||
// ON, and the floor tells you what kind of night it was. Money, contraband,
|
||||
// and objects with no explanation. Pure rolls here; the scene owns the sweep.
|
||||
|
||||
import type { RngStream } from '../core/SeededRNG';
|
||||
|
||||
export type DropKind = 'note' | 'coins' | 'baggie' | 'phone' | 'curio';
|
||||
|
||||
export interface Drop {
|
||||
kind: DropKind;
|
||||
/** Dollars. Curios and phones are worth nothing and everything. */
|
||||
value: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** How strewn the floor is: a base mess plus one drop per this many admits. */
|
||||
export const DROPS_BASE = 6;
|
||||
export const DROPS_PER_ADMITS = 6;
|
||||
export const DROPS_MAX = 14;
|
||||
|
||||
export const NOTE_VALUES: readonly number[] = [5, 5, 10, 10, 20, 50];
|
||||
/** One baggie, sometimes. The moral beat of the sweep. */
|
||||
export const BAGGIE_CHANCE = 0.35;
|
||||
/** Someone's whole life, at 3%, face down near the booths. */
|
||||
export const PHONE_CHANCE = 0.4;
|
||||
|
||||
export const CURIOS: readonly string[] = [
|
||||
'a single white sneaker. vintage, technically',
|
||||
'one hoop earring, lying there victorious',
|
||||
'an untouched kebab. UNTOUCHED.',
|
||||
'a novelty lighter shaped like a slightly smaller lighter',
|
||||
'seventeen bobby pins in one tight formation',
|
||||
"someone's nan's reading glasses",
|
||||
'a laminated business card. sovereign.',
|
||||
'a wristband from a venue that shut in 2019',
|
||||
];
|
||||
|
||||
export const BAGGIE_LABEL = 'a zip-lock bag of definitely-not-yours';
|
||||
export const PHONE_LABEL = "someone's phone. 3% battery. 41 missed calls from MUM DO NOT ANSWER";
|
||||
|
||||
/**
|
||||
* What the lights find. Deterministic per stream: mostly cash in anxious
|
||||
* denominations, a couple of objects with no explanation, at most one baggie
|
||||
* and one phone.
|
||||
*/
|
||||
export function rollDrops(rng: RngStream, admitted: number): Drop[] {
|
||||
const count = Math.min(DROPS_MAX, DROPS_BASE + Math.floor(admitted / DROPS_PER_ADMITS));
|
||||
const drops: Drop[] = [];
|
||||
|
||||
if (rng.chance(BAGGIE_CHANCE)) drops.push({ kind: 'baggie', value: 0, label: BAGGIE_LABEL });
|
||||
if (rng.chance(PHONE_CHANCE)) drops.push({ kind: 'phone', value: 0, label: PHONE_LABEL });
|
||||
|
||||
const curios = rng.int(2, 3);
|
||||
for (let i = 0; i < curios && drops.length < count; i++) {
|
||||
drops.push({ kind: 'curio', value: 0, label: CURIOS[rng.int(0, CURIOS.length - 1)] ?? CURIOS[0]! });
|
||||
}
|
||||
|
||||
while (drops.length < count) {
|
||||
if (rng.chance(0.3)) {
|
||||
const v = rng.int(2, 9);
|
||||
drops.push({ kind: 'coins', value: v, label: `$${v} in shrapnel from under the booth seats` });
|
||||
} else {
|
||||
const v = NOTE_VALUES[rng.int(0, NOTE_VALUES.length - 1)] ?? 5;
|
||||
drops.push({ kind: 'note', value: v, label: `a folded $${v} — the floor pays better than the till` });
|
||||
}
|
||||
}
|
||||
return drops;
|
||||
}
|
||||
334
src/rules/guestList.ts
Normal file
@ -0,0 +1,334 @@
|
||||
// The guest-list clipboard (design §3.1). A handwritten list, and a queue of
|
||||
// people claiming to be on it.
|
||||
//
|
||||
// The mechanic is an inversion: a name that ALMOST matches is usually legit —
|
||||
// the doorman's handwriting is rubbish and half of them gave a nickname — while
|
||||
// a name that matches the clipboard EXACTLY, said with confidence, is as often
|
||||
// as not lifted off someone who walked in an hour ago.
|
||||
//
|
||||
// Nothing here is ever certain, on purpose. Every match class can be produced by
|
||||
// an honest patron AND by a liar; the numbers below only move the player from a
|
||||
// coin flip to better than chance. Do not "fix" that by making a class a
|
||||
// guaranteed tell — a guaranteed tell turns the clipboard into a lookup table
|
||||
// and there is no decision left to make.
|
||||
|
||||
import type { RngStream } from '../core/SeededRNG';
|
||||
|
||||
export interface GuestListEntry {
|
||||
name: string;
|
||||
plusOnes: number;
|
||||
}
|
||||
|
||||
export interface GuestList {
|
||||
entries: GuestListEntry[];
|
||||
}
|
||||
|
||||
export type NameMatch = 'exact' | 'near' | 'none';
|
||||
|
||||
export interface NameLookup {
|
||||
match: NameMatch;
|
||||
entry?: GuestListEntry;
|
||||
/** Edits to the CLOSEST entry, whatever the class. Infinity on an empty list. */
|
||||
distance: number;
|
||||
}
|
||||
|
||||
export const GUEST_LIST_TUNING = {
|
||||
minEntries: 8,
|
||||
maxEntries: 14,
|
||||
|
||||
// Two, not three. Every near variant this module produces is at most two edits
|
||||
// from its entry (a transposition is the expensive one — plain Levenshtein
|
||||
// charges 2 for it), so 2 is the tightest threshold that still catches every
|
||||
// plausible misreading. Three would only admit corruptions nothing generates,
|
||||
// while making the separation constraint below harder to satisfy.
|
||||
nearThreshold: 2,
|
||||
|
||||
// How far apart two entries on one clipboard must be. It is 2 * nearThreshold,
|
||||
// and the factor of two is load-bearing rather than cautious: a near variant
|
||||
// sits up to nearThreshold from its source, so by the triangle inequality only
|
||||
// a gap of MORE than twice the threshold guarantees that variant is still
|
||||
// outside the threshold of every other entry. At a gap of merely
|
||||
// nearThreshold + 1 a two-edit variant can land one edit from a neighbour, and
|
||||
// lookupName then hands the door UI the wrong row — the player highlights a
|
||||
// stranger and the +1 count they read belongs to someone else.
|
||||
entrySeparation: 4,
|
||||
|
||||
/** Mostly parties of one. The +1 that arrives as +6 is the door's problem. */
|
||||
plusOneWeights: [
|
||||
[0, 62],
|
||||
[1, 24],
|
||||
[2, 9],
|
||||
[3, 5],
|
||||
] as ReadonlyArray<readonly [number, number]>,
|
||||
|
||||
// Claim mix for a patron who really is on the list. They mostly give a name
|
||||
// the clipboard half-agrees with; sometimes it is written down perfectly; and
|
||||
// sometimes the doorman butchered it past recognition and the clipboard is no
|
||||
// help to an honest person at all.
|
||||
genuineExact: 0.3,
|
||||
genuineNear: 0.58,
|
||||
genuineGarbled: 0.12,
|
||||
|
||||
// Claim mix for a liar. Mostly a name that simply is not there. Sometimes they
|
||||
// overheard a real one and got it slightly wrong; sometimes they got it dead
|
||||
// right, which is the entire mechanic.
|
||||
liarExact: 0.06,
|
||||
liarNear: 0.06,
|
||||
liarInvented: 0.88,
|
||||
|
||||
// Share of guest-list CLAIMANTS who are telling the truth, derived from the
|
||||
// spawn weights and chances in data/archetypes.ts (influencers and regulars
|
||||
// are the only archetypes that are ever really on the list; punters, finance
|
||||
// bros and the owner's mate only ever claim). Liars outnumber listers about
|
||||
// five to one, which is why the mixes above are lopsided — they exist to drag
|
||||
// the posteriors back to something a player can read.
|
||||
//
|
||||
// Posteriors at this prior, asserted in tests/guestList.test.ts:
|
||||
// P(genuine | exact) ≈ 0.50 — a coin flip, and it must stay one
|
||||
// P(genuine | near) ≈ 0.66 — leans legit
|
||||
// P(genuine | none) ≈ 0.03 — leans liar, but is not proof
|
||||
// Re-derive all four numbers if archetypes.ts changes its guest-list chances.
|
||||
claimantGenuineRate: 0.168,
|
||||
|
||||
/** Random draws before falling back to a deterministic sweep of the pools. */
|
||||
drawAttempts: 24,
|
||||
/** Corruption rolls before falling back to a guaranteed one-edit variant. */
|
||||
variantAttempts: 12,
|
||||
/** Edits applied to garble a name past recognition. */
|
||||
garbleEdits: 4,
|
||||
/** Chance a near variant is a transposition rather than one or two cheap edits. */
|
||||
transposeChance: 0.3,
|
||||
/** Chance a cheap-edit variant gets a second edit on top. */
|
||||
secondEditChance: 0.35,
|
||||
};
|
||||
|
||||
// Deliberately duplicated from src/patrons/generator.ts, which keeps its pools
|
||||
// module-private and is frozen to this lane. The failure mode of duplication is
|
||||
// the two lists drifting apart, and a guest list drawn from names the crowd
|
||||
// never uses reads as obviously-not-the-crowd — a content bug, not a crash. If
|
||||
// generator.ts ever exports these, delete these copies and import them.
|
||||
const FIRST = ['Shazza', 'Dazza', 'Bazza', 'Kylie', 'Jai', 'Tahlia', 'Lachlan', 'Brooke', 'Cooper', 'Mia', 'Ngaio', 'Con', 'Duc', 'Sofia', 'Marco', 'Aroha', 'Blake', 'Chantelle', 'Rhys', 'Imogen'] as const;
|
||||
const LAST = ['Nguyen', 'Smith', 'Papadopoulos', 'Chen', 'OBrien', 'Kowalski', 'Singh', 'Taufa', 'Romano', 'Petersen', 'Doyle', 'Vella', 'Karim', 'Marsh', 'Duffy'] as const;
|
||||
|
||||
const POOL_SIZE = FIRST.length * LAST.length;
|
||||
|
||||
// Corruptions only ever land on lowercase letters, which in these pools means
|
||||
// every capital survives. A variant is meant to read as a name someone spelled
|
||||
// wrong; touching the initials gives you "oRmano" and " arim", which read as a
|
||||
// broken string instead — the player stops thinking about handwriting and starts
|
||||
// thinking about the program.
|
||||
const EDITABLE = /[a-z]/;
|
||||
|
||||
/** a→e→i→o→u→a. Uppercase is absent for the same reason. */
|
||||
const VOWEL_RING: Record<string, string> = { a: 'e', e: 'i', i: 'o', o: 'u', u: 'a' };
|
||||
|
||||
const normalise = (s: string): string => s.replace(/\s+/g, ' ').trim().toLowerCase();
|
||||
|
||||
export function editDistance(a: string, b: string): number {
|
||||
const s = normalise(a);
|
||||
const t = normalise(b);
|
||||
if (s === t) return 0;
|
||||
if (s.length === 0) return t.length;
|
||||
if (t.length === 0) return s.length;
|
||||
|
||||
// Two rows rather than the full matrix: this runs a few hundred times per
|
||||
// claimant, because every variant is verified against every entry.
|
||||
let prev = Array.from({ length: t.length + 1 }, (_, i) => i);
|
||||
let curr = new Array<number>(t.length + 1).fill(0);
|
||||
|
||||
for (let i = 1; i <= s.length; i++) {
|
||||
curr[0] = i;
|
||||
const sc = s[i - 1];
|
||||
for (let j = 1; j <= t.length; j++) {
|
||||
const cost = sc === t[j - 1] ? 0 : 1;
|
||||
curr[j] = Math.min(prev[j]! + 1, curr[j - 1]! + 1, prev[j - 1]! + cost);
|
||||
}
|
||||
[prev, curr] = [curr, prev];
|
||||
}
|
||||
return prev[t.length]!;
|
||||
}
|
||||
|
||||
export function lookupName(claimed: string, list: GuestList): NameLookup {
|
||||
let closest: GuestListEntry | undefined;
|
||||
let distance = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const entry of list.entries) {
|
||||
const d = editDistance(claimed, entry.name);
|
||||
if (d < distance) {
|
||||
distance = d;
|
||||
closest = entry;
|
||||
if (d === 0) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (closest === undefined) return { match: 'none', distance };
|
||||
|
||||
const match: NameMatch =
|
||||
distance === 0 ? 'exact' : distance <= GUEST_LIST_TUNING.nearThreshold ? 'near' : 'none';
|
||||
|
||||
// A 'none' carries no entry even though one was found. `distance` still
|
||||
// reports how close it got, but handing back the row would invite the door UI
|
||||
// to highlight a name the patron has nothing to do with — and any caller doing
|
||||
// `if (result.entry)` would be admitting strangers.
|
||||
return match === 'none' ? { match, distance } : { match, entry: closest, distance };
|
||||
}
|
||||
|
||||
const poolName = (rng: RngStream): string => `${rng.pick(FIRST)} ${rng.pick(LAST)}`;
|
||||
|
||||
/**
|
||||
* True iff `name` is more than `minDistance` edits from every entry.
|
||||
* At `nearThreshold` that means "reads as a miss"; at `entrySeparation` it means
|
||||
* "safe to add to the clipboard". Infinity on an empty list, so both hold.
|
||||
*/
|
||||
const isSeparated = (name: string, list: GuestList, minDistance: number): boolean =>
|
||||
lookupName(name, list).distance > minDistance;
|
||||
|
||||
// A pool name the clipboard has no opinion about. Random draws first so the mix
|
||||
// stays natural, then an exhaustive sweep so this always terminates: the pools
|
||||
// hold 300 combinations and a list holds at most 14, so the sweep only comes up
|
||||
// empty for a list far longer than this game builds.
|
||||
function drawSeparatedName(
|
||||
rng: RngStream,
|
||||
list: GuestList,
|
||||
minDistance: number,
|
||||
): string | undefined {
|
||||
for (let i = 0; i < GUEST_LIST_TUNING.drawAttempts; i++) {
|
||||
const name = poolName(rng);
|
||||
if (isSeparated(name, list, minDistance)) return name;
|
||||
}
|
||||
const start = rng.int(0, POOL_SIZE - 1);
|
||||
for (let k = 0; k < POOL_SIZE; k++) {
|
||||
const idx = (start + k) % POOL_SIZE;
|
||||
const name = `${FIRST[idx % FIRST.length]!} ${LAST[Math.floor(idx / FIRST.length)]!}`;
|
||||
if (isSeparated(name, list, minDistance)) return name;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function generateGuestList(rng: RngStream, size: number): GuestList {
|
||||
const T = GUEST_LIST_TUNING;
|
||||
const asked = Number.isFinite(size) ? Math.round(size) : T.minEntries;
|
||||
const wanted = Math.max(T.minEntries, Math.min(T.maxEntries, asked));
|
||||
const entries: GuestListEntry[] = [];
|
||||
|
||||
for (let i = 0; i < wanted; i++) {
|
||||
// Entries are kept entrySeparation apart so a near variant can only ever
|
||||
// point at one of them. Two Dazzas a couple of edits apart would make the
|
||||
// clipboard ambiguous in a way the player cannot reason about — and the
|
||||
// pools genuinely contain such pairs (Bazza/Dazza), which is why this is
|
||||
// enforced per list rather than assumed of the pools.
|
||||
const name = drawSeparatedName(rng, { entries }, T.entrySeparation);
|
||||
// A short list is a wobble; a hang is a crash. See drawSeparatedName.
|
||||
if (name === undefined) break;
|
||||
entries.push({ name, plusOnes: rng.weighted(T.plusOneWeights) });
|
||||
}
|
||||
|
||||
return { entries };
|
||||
}
|
||||
|
||||
function editableIndices(s: string): number[] {
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < s.length; i++) if (EDITABLE.test(s[i]!)) out.push(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* One edit: a dropped letter, a doubled letter, or a vowel gone sideways.
|
||||
* The three kinds are not equally likely — a consonant has no vowel-ring entry,
|
||||
* so kind 2 falls back to doubling and consonants double twice as often as they
|
||||
* drop. That is a deliberate lean: a doubled consonant ("Kowalskki") reads as
|
||||
* handwriting, a dropped one ("Kowaski") reads more like a different name.
|
||||
*/
|
||||
function oneEdit(rng: RngStream, s: string): string {
|
||||
const at = editableIndices(s);
|
||||
// Two letters minimum, so a name can never be edited down to nothing.
|
||||
if (at.length < 2) return s;
|
||||
const i = rng.pick(at);
|
||||
const ch = s[i]!;
|
||||
const kind = rng.int(0, 2);
|
||||
if (kind === 0) return s.slice(0, i) + s.slice(i + 1);
|
||||
if (kind === 1) return s.slice(0, i) + ch + s.slice(i);
|
||||
const swapped = VOWEL_RING[ch];
|
||||
return swapped === undefined
|
||||
? s.slice(0, i) + ch + s.slice(i)
|
||||
: s.slice(0, i) + swapped + s.slice(i + 1);
|
||||
}
|
||||
|
||||
/** Two adjacent letters the wrong way round — two edits under plain Levenshtein. */
|
||||
function transpose(rng: RngStream, s: string): string {
|
||||
const at = editableIndices(s).filter((i) => EDITABLE.test(s[i + 1] ?? '') && s[i] !== s[i + 1]);
|
||||
if (at.length === 0) return s;
|
||||
const i = rng.pick(at);
|
||||
return s.slice(0, i) + s[i + 1]! + s[i]! + s.slice(i + 2);
|
||||
}
|
||||
|
||||
function corrupt(rng: RngStream, name: string): string {
|
||||
// A transposition already spends the whole two-edit budget, so it never gets
|
||||
// stacked with anything. Two edits on top of it would be the garbled branch.
|
||||
if (rng.chance(GUEST_LIST_TUNING.transposeChance)) return transpose(rng, name);
|
||||
const once = oneEdit(rng, name);
|
||||
return rng.chance(GUEST_LIST_TUNING.secondEditChance) ? oneEdit(rng, once) : once;
|
||||
}
|
||||
|
||||
/** Doubling a letter is exactly one insertion, always. The safety net. */
|
||||
const doubledLetter = (name: string): string =>
|
||||
name.length > 1 ? `${name.slice(0, 2)}${name.slice(1)}` : `${name}${name}`;
|
||||
|
||||
function nearVariant(rng: RngStream, name: string, list: GuestList): string {
|
||||
for (let i = 0; i < GUEST_LIST_TUNING.variantAttempts; i++) {
|
||||
const candidate = corrupt(rng, name);
|
||||
// Two cheap edits can cancel each other out (a drop and a double of the
|
||||
// same letter), and a transposition on a short name can land somewhere
|
||||
// unexpected. Unverified, roughly 1% of corruptions come back distance 0 —
|
||||
// small, but it lands entirely in 'exact', the rarest and most load-bearing
|
||||
// class. The class is what the caller was promised, so it is verified.
|
||||
if (lookupName(candidate, list).match === 'near') return candidate;
|
||||
}
|
||||
return doubledLetter(name);
|
||||
}
|
||||
|
||||
// The other half of "bad handwriting": the name went down so badly that the
|
||||
// clipboard is no use to someone who is genuinely on it. This is the honest
|
||||
// person the system fails, and the game never tells the player it happened
|
||||
// (§4.3) — from the door it is indistinguishable from a chancer.
|
||||
function garbled(rng: RngStream, name: string, list: GuestList): string {
|
||||
const T = GUEST_LIST_TUNING;
|
||||
for (let i = 0; i < T.variantAttempts; i++) {
|
||||
let candidate = name;
|
||||
for (let e = 0; e < T.garbleEdits; e++) candidate = oneEdit(rng, candidate);
|
||||
// Only "reads as a miss" is required here, not clipboard-entry separation:
|
||||
// this name is never going on the list, it just has to classify as 'none'.
|
||||
// Verified rather than assumed — four edits still land inside the threshold
|
||||
// about a quarter of the time, and an unverified garble would quietly leak
|
||||
// into 'near' and drag the posteriors with it.
|
||||
if (isSeparated(candidate, list, T.nearThreshold)) return candidate;
|
||||
}
|
||||
// Failing that, they gave the name of the mate who actually made the booking.
|
||||
return drawSeparatedName(rng, list, T.nearThreshold) ?? doubledLetter(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* The name a claiming patron gives. `genuine` is the truth — `flags.onGuestList`
|
||||
* at the call site — and is never visible to the player, only its consequences.
|
||||
*/
|
||||
export function assignListedName(rng: RngStream, list: GuestList, genuine: boolean): string {
|
||||
const T = GUEST_LIST_TUNING;
|
||||
// An empty clipboard: nothing to be on and nothing to lift, so everyone reads
|
||||
// as a chancer. Guarded because rng.pick throws on an empty array.
|
||||
if (list.entries.length === 0) {
|
||||
return drawSeparatedName(rng, list, T.nearThreshold) ?? poolName(rng);
|
||||
}
|
||||
|
||||
const entry = rng.pick(list.entries);
|
||||
const roll = rng.next();
|
||||
|
||||
if (genuine) {
|
||||
if (roll < T.genuineExact) return entry.name;
|
||||
if (roll < T.genuineExact + T.genuineNear) return nearVariant(rng, entry.name, list);
|
||||
return garbled(rng, entry.name, list);
|
||||
}
|
||||
|
||||
if (roll < T.liarExact) return entry.name;
|
||||
if (roll < T.liarExact + T.liarNear) return nearVariant(rng, entry.name, list);
|
||||
return drawSeparatedName(rng, list, T.nearThreshold) ?? garbled(rng, entry.name, list);
|
||||
}
|
||||
14
src/rules/heat.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import type { HeatStrike, NightState } from '../data/types';
|
||||
|
||||
/** CONTRACTS.md §3: heatStrikes is "run-scoped, max 3". Design §2: 3 = licence gone. */
|
||||
export const MAX_HEAT_STRIKES = 3;
|
||||
|
||||
/**
|
||||
* Whether a strike is worth recording. Two guards, both contract-driven:
|
||||
* past three the run is already over, and a breach that persists (over capacity
|
||||
* for an hour) is ONE offence the inspector writes up, not one per patron.
|
||||
*/
|
||||
export function shouldRecordStrike(state: NightState, strike: HeatStrike): boolean {
|
||||
if (state.heatStrikes.length >= MAX_HEAT_STRIKES) return false;
|
||||
return !state.heatStrikes.some((s) => s.reason === strike.reason);
|
||||
}
|
||||
108
src/rules/idCheck.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { ageOn } from '../core/GameClock';
|
||||
import type { IdCard } from '../data/types';
|
||||
|
||||
// The ID minigame's date math. Everything the player can catch on a licence
|
||||
// resolves here so the card art, the verdict and the audit can never disagree.
|
||||
|
||||
// Reading order on the card, not severity — the player scans top-left to
|
||||
// bottom-right, and a stable order keeps tests and toast text deterministic.
|
||||
//
|
||||
// This tuple is the SOURCE of IdTell, not a parallel list of it. Typed the other
|
||||
// way round (`const TELL_ORDER: readonly IdTell[]`) a sixth tell added to the
|
||||
// union would still compile against a stale tuple, and `tells` would silently
|
||||
// drop it — an uncatchable fake, the exact bug this module exists to prevent.
|
||||
const TELL_ORDER = [
|
||||
'peelingLaminate',
|
||||
'wrongHologram',
|
||||
'jokeName',
|
||||
'photoMismatch',
|
||||
'expired',
|
||||
'passback',
|
||||
] as const;
|
||||
|
||||
export type IdTell = (typeof TELL_ORDER)[number];
|
||||
|
||||
export interface IdVerdict {
|
||||
claimedAge: number;
|
||||
underage: boolean;
|
||||
expired: boolean;
|
||||
/** Valid, but only just — flavour text, never a violation. */
|
||||
expiresTonight: boolean;
|
||||
turnsEighteenTonight: boolean;
|
||||
tells: IdTell[];
|
||||
looksFake: boolean;
|
||||
}
|
||||
|
||||
/** ISO date → local midnight ms. Local, not UTC: the licence is a local document. */
|
||||
const midnightMs = (iso: string): number => new Date(`${iso.slice(0, 10)}T00:00:00`).getTime();
|
||||
|
||||
/** Drop any time-of-day the caller's nightDate carries, so comparisons are day-vs-day. */
|
||||
const dayStart = (d: Date): Date => new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
|
||||
const dayBefore = (d: Date): Date => new Date(d.getFullYear(), d.getMonth(), d.getDate() - 1);
|
||||
|
||||
export function idVerdict(id: IdCard, nightDate: Date): IdVerdict {
|
||||
const night = dayStart(nightDate);
|
||||
const nightMs = night.getTime();
|
||||
const expiryMs = midnightMs(id.expiry);
|
||||
|
||||
const claimedAge = ageOn(id.dob, night);
|
||||
// A licence is good THROUGH its expiry date, so equality is not expired.
|
||||
const expired = expiryMs < nightMs;
|
||||
|
||||
// "Was 17 yesterday, is 18 today" rather than matching month/day, so a 29 Feb
|
||||
// DOB still trips this on the day the age math actually hands them their 18th.
|
||||
const turnsEighteenTonight = claimedAge === 18 && ageOn(id.dob, dayBefore(night)) === 17;
|
||||
|
||||
const flags = id.fake;
|
||||
const present: Record<IdTell, boolean> = {
|
||||
peelingLaminate: flags?.peelingLaminate === true,
|
||||
wrongHologram: flags?.wrongHologram === true,
|
||||
jokeName: flags?.jokeName === true,
|
||||
photoMismatch: flags?.photoMismatch === true,
|
||||
// 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]);
|
||||
|
||||
return {
|
||||
claimedAge,
|
||||
underage: claimedAge < 18,
|
||||
expired,
|
||||
expiresTonight: expiryMs === nightMs,
|
||||
turnsEighteenTonight,
|
||||
tells,
|
||||
looksFake: tells.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** DD/MM/YYYY — Australian order, hand-rolled because Intl output varies by host. */
|
||||
/**
|
||||
* Australian DD/MM/YYYY. Exported because the DESK needs to show tonight's date
|
||||
* in the same format the card shows the DOB — without it the player is asked to
|
||||
* subtract a birthdate from a date the game never told them, which made
|
||||
* `underage` (the tell that costs the licence) pure guesswork.
|
||||
*/
|
||||
export const formatDate = (d: Date): string =>
|
||||
`${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
|
||||
|
||||
const ddmmyyyy = (iso: string): string => {
|
||||
const [y = '', m = '', d = ''] = iso.slice(0, 10).split('-');
|
||||
return `${d.padStart(2, '0')}/${m.padStart(2, '0')}/${y.padStart(4, '0')}`;
|
||||
};
|
||||
|
||||
export function idDisplayLines(
|
||||
id: IdCard,
|
||||
nightDate: Date,
|
||||
): { name: string; dob: string; expiry: string; age: string } {
|
||||
return {
|
||||
name: id.name,
|
||||
dob: ddmmyyyy(id.dob),
|
||||
expiry: ddmmyyyy(id.expiry),
|
||||
age: String(ageOn(id.dob, dayStart(nightDate))),
|
||||
};
|
||||
}
|
||||
356
src/rules/incidentReport.ts
Normal file
@ -0,0 +1,356 @@
|
||||
import type { IncidentRecord } from '../data/types';
|
||||
import type { RngStream } from '../core/SeededRNG';
|
||||
|
||||
// The Incident Report (design §3.3). At last drinks the venue asks the player to
|
||||
// write up the night, and the player may lie. This module builds that form and
|
||||
// encodes what was filed. The cross-night audit that CATCHES the lies is Phase 4;
|
||||
// everything here exists to leave that audit something it can read.
|
||||
//
|
||||
// Nothing in this file scores anything. There is no honesty meter, no chime for
|
||||
// telling the truth, and no line of prose that tells the player they did well or
|
||||
// badly (§4.3). The lie is cheap tonight and that is the whole joke.
|
||||
//
|
||||
// ---- STORAGE ENCODING — read before touching fileReport ----
|
||||
// IncidentRecord is a frozen contract type {clockMin, kind, patronId?, detail},
|
||||
// so a filed row carries its machine payload inside `detail`:
|
||||
//
|
||||
// detail = `${account}` + REPORT_MARKER + JSON.stringify(FiledPayload)
|
||||
//
|
||||
// The account the player chose comes FIRST and verbatim, so a debug dump of
|
||||
// pastReports still reads like a report. Everything Phase 4 needs — which option
|
||||
// was picked, whether it was true, and the truth-log line the account replaced —
|
||||
// is JSON after the marker. The marker is newline-prefixed and no authored
|
||||
// account contains a newline, so it cannot collide with prose.
|
||||
//
|
||||
// Parse with parseFiledAccount(). Do not hand-roll a regex over this: it returns
|
||||
// undefined for a raw truth-log row, which matters because GameState.pastReports
|
||||
// already holds unfiled NightState.incidents rows from Phase 2.
|
||||
|
||||
export interface ReportOption {
|
||||
id: string;
|
||||
/** the written account, first person, bouncer-report register */
|
||||
text: string;
|
||||
truthful: boolean;
|
||||
}
|
||||
|
||||
export interface ReportQuestion {
|
||||
incident: IncidentRecord;
|
||||
prompt: string;
|
||||
options: ReportOption[];
|
||||
}
|
||||
|
||||
export interface FiledAnswer {
|
||||
incident: IncidentRecord;
|
||||
chosen: ReportOption;
|
||||
}
|
||||
|
||||
/** What parseFiledAccount recovers from a filed row. */
|
||||
export interface FiledAccount {
|
||||
/** the prose the player filed */
|
||||
account: string;
|
||||
optionId: string;
|
||||
/** false = the player filed a flattering version of this incident */
|
||||
truthful: boolean;
|
||||
/** the incident's own `detail` from the truth log, before the account replaced it */
|
||||
truthLog: string;
|
||||
}
|
||||
|
||||
export const REPORT_TUNING = {
|
||||
/** Three questions is twenty seconds. A fourth turns the bit into homework. */
|
||||
maxQuestions: 3,
|
||||
|
||||
// How worth-asking-about each incident kind is. Kinds are open strings written
|
||||
// by three different scenes (DoorScene verdicts + 'kayden', NightScene
|
||||
// 'deferred', FloorDemoScene's five), so this is a lookup, not a union.
|
||||
//
|
||||
// Zero means "never ask": a clean admit is not a story, and a night is mostly
|
||||
// clean admits. Ties are broken by an rng jitter below 1, so a weight is a
|
||||
// band, never a fixed running order.
|
||||
interest: {
|
||||
deferred: 10, // a breach the audit already found — the awkward one
|
||||
jointPocketed: 9, // the one question you really don't want asked
|
||||
jointBinned: 3,
|
||||
jointIgnored: 5,
|
||||
ejection: 8,
|
||||
maggotUnhandled: 7,
|
||||
kayden: 6, // his mistake, your signature
|
||||
cutOff: 5,
|
||||
stallBusted: 5,
|
||||
patDown: 4, // both door and floor log this kind; one bank covers both
|
||||
deny: 3,
|
||||
sobrietyTest: 2,
|
||||
admit: 0,
|
||||
wait: 0,
|
||||
} as Record<string, number>,
|
||||
|
||||
/**
|
||||
* Weight for a kind nobody listed. Deliberately askable: a lane that adds an
|
||||
* incident kind should find it turning up in the form, not silently missing.
|
||||
*/
|
||||
defaultInterest: 2,
|
||||
};
|
||||
|
||||
/** Separates the filed account from its machine payload inside `detail`. */
|
||||
export const REPORT_MARKER = '\n[report:v1]';
|
||||
|
||||
interface FiledPayload {
|
||||
optionId: string;
|
||||
truthful: boolean;
|
||||
truthLog: string;
|
||||
}
|
||||
|
||||
interface AccountBank {
|
||||
prompt: string;
|
||||
options: readonly ReportOption[];
|
||||
}
|
||||
|
||||
// The register: clipped, passive where a real report hedges, faintly defensive.
|
||||
// The joke is SPECIFICITY vs MUSH — the truthful option names what actually
|
||||
// happened and who did it; the flattering ones are plausible deniability, the
|
||||
// account a tired adult would actually write and could actually defend, never a
|
||||
// cartoon denial that nothing occurred (a lie you can't say out loud isn't a
|
||||
// choice, it's a button).
|
||||
//
|
||||
// §4.3: no account may pronounce a VERDICT on the player's night. "It was
|
||||
// wrong", "they had a point", "I had decided before they finished" all tell the
|
||||
// player which choice was right, which is the one thing this game does not do.
|
||||
// A truthful account states the fact and stops. The reader supplies the wince.
|
||||
const BANKS: Record<string, AccountBank> = {
|
||||
deferred: {
|
||||
prompt: 'Flagged on the nightly audit. Your account?',
|
||||
options: [
|
||||
{ id: 'deferred.true', text: 'I let them in. That was my call at the desk and my name is on the sheet.', truthful: true },
|
||||
{ id: 'deferred.soft1', text: 'Patron presented as compliant at the point of entry. Lighting at the rope is what it is.', truthful: false },
|
||||
{ id: 'deferred.soft2', text: 'Entry was authorised on the information available at the desk at that time.', truthful: false },
|
||||
],
|
||||
},
|
||||
ejection: {
|
||||
prompt: 'A patron was removed from the premises. Your account?',
|
||||
options: [
|
||||
{ id: 'ejection.true', text: 'I walked them out. They did not want to go and I did not give them the option.', truthful: true },
|
||||
{ id: 'ejection.soft1', text: 'Patron elected to leave and was accompanied to the exit as a courtesy.', truthful: false },
|
||||
{ id: 'ejection.soft2', text: 'An escort was offered and accepted. No more than a guiding hand was required.', truthful: false },
|
||||
],
|
||||
},
|
||||
maggotUnhandled: {
|
||||
prompt: 'A patron was left a while in a poor state. Your account?',
|
||||
options: [
|
||||
{ id: 'maggot.true', text: 'I saw them and I was on the door. Nobody got to them for a good while.', truthful: true },
|
||||
{ id: 'maggot.soft1', text: 'Patron was monitored at intervals from the floor. Water was made available.', truthful: false },
|
||||
{ id: 'maggot.soft2', text: 'Patron was seated and stable. No intervention was assessed as necessary at that point.', truthful: false },
|
||||
],
|
||||
},
|
||||
jointPocketed: {
|
||||
prompt: 'Contraband was located in the smoking area. Where is it now?',
|
||||
options: [
|
||||
{ id: 'jointP.true', text: 'On me. I took possession and I still have possession. That is the account.', truthful: true },
|
||||
{ id: 'jointP.soft1', text: 'Item was destroyed on the spot in view of the patron.', truthful: false },
|
||||
{ id: 'jointP.soft2', text: 'Item was placed in the amnesty bin per procedure. The bin was emptied by close.', truthful: false },
|
||||
],
|
||||
},
|
||||
jointBinned: {
|
||||
prompt: 'Contraband was located in the smoking area. Your account?',
|
||||
options: [
|
||||
{ id: 'jointB.true', text: 'Confiscated and binned in front of them. The yard went quiet for a bit.', truthful: true },
|
||||
{ id: 'jointB.soft1', text: 'Patron surrendered the item voluntarily and thanked security for the chat.', truthful: false },
|
||||
{ id: 'jointB.soft2', text: 'Item was assessed as herbal cigarettes. Binned as general litter.', truthful: false },
|
||||
],
|
||||
},
|
||||
jointIgnored: {
|
||||
prompt: 'A smell was reported near the smoking area. Your account?',
|
||||
options: [
|
||||
{ id: 'jointI.true', text: 'I smelled it, I saw it, and I decided the fence line was more interesting.', truthful: true },
|
||||
{ id: 'jointI.soft1', text: 'Area was patrolled at the time stated. Nothing of note was observed.', truthful: false },
|
||||
{ id: 'jointI.soft2', text: 'Smell was traced to the kebab shop exhaust across the road.', truthful: false },
|
||||
],
|
||||
},
|
||||
kayden: {
|
||||
prompt: 'A ruling was made at the rope while you were inside. Your account?',
|
||||
options: [
|
||||
{ id: 'kayden.true', text: 'I was on the floor. Kayden made that call and I heard about it after.', truthful: true },
|
||||
{ id: 'kayden.soft1', text: 'Door was covered under my direction while I attended a matter inside.', truthful: false },
|
||||
{ id: 'kayden.soft2', text: 'Ruling was consistent with the brief given to the second on the door.', truthful: false },
|
||||
],
|
||||
},
|
||||
cutOff: {
|
||||
prompt: 'A patron was refused further service. Your account?',
|
||||
options: [
|
||||
{ id: 'cutOff.true', text: 'I cut them off. They were filthy about it and they said so at length.', truthful: true },
|
||||
{ id: 'cutOff.soft1', text: 'Service was concluded in line with RSA. Patron was cooperative throughout.', truthful: false },
|
||||
{ id: 'cutOff.soft2', text: 'A water was provided and the matter was resolved at the bar without escalation.', truthful: false },
|
||||
],
|
||||
},
|
||||
stallBusted: {
|
||||
prompt: 'Two patrons were found in one cubicle. Your account?',
|
||||
options: [
|
||||
{ id: 'stall.true', text: 'I banged on the door until it opened. Both were spoken to and both were embarrassed.', truthful: true },
|
||||
{ id: 'stall.soft1', text: 'Cubicle was checked as part of a routine sweep. Occupants dispersed on request.', truthful: false },
|
||||
{ id: 'stall.soft2', text: 'One patron was assisting another who was unwell. Nothing further was observed.', truthful: false },
|
||||
],
|
||||
},
|
||||
patDown: {
|
||||
prompt: 'A patron was searched. Your account?',
|
||||
options: [
|
||||
{ id: 'patDown.true', text: 'I searched them on a hunch and the whole queue watched me do it.', truthful: true },
|
||||
{ id: 'patDown.soft1', text: 'Search was conducted with consent as part of a routine entry check.', truthful: false },
|
||||
{ id: 'patDown.soft2', text: 'Pockets and bag were checked in line with venue policy. Patron was thanked.', truthful: false },
|
||||
],
|
||||
},
|
||||
deny: {
|
||||
prompt: 'Entry was refused. Your account?',
|
||||
options: [
|
||||
{ id: 'deny.true', text: 'I knocked them back for the reason I had at the time. They copped it at the rope.', truthful: true },
|
||||
{ id: 'deny.soft1', text: 'Entry was declined under the dress code as advised by management on the night.', truthful: false },
|
||||
{ id: 'deny.soft2', text: 'Patron did not meet conditions of entry. Advised of same, politely, at the rope.', truthful: false },
|
||||
],
|
||||
},
|
||||
sobrietyTest: {
|
||||
prompt: 'A patron was assessed at the desk. Your account?',
|
||||
options: [
|
||||
{ id: 'sobriety.true', text: 'I tested them at the desk myself. It took a minute and I called it there.', truthful: true },
|
||||
{ id: 'sobriety.soft1', text: 'A brief assessment was carried out at the desk and the result recorded.', truthful: false },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// For kinds this file has never heard of. Vague on purpose — the player is
|
||||
// signing something they did not read, which is the correct amount of realism.
|
||||
const DEFAULT_BANK: AccountBank = {
|
||||
prompt: 'An entry was made in the log. Your account?',
|
||||
options: [
|
||||
{ id: 'other.true', text: 'It went in the log at the time and the log has it about right.', truthful: true },
|
||||
{ id: 'other.soft1', text: 'Logged for completeness. Nothing arising required action on the night.', truthful: false },
|
||||
],
|
||||
};
|
||||
|
||||
// Load-time, like dressCode's card check: a question with two truthful options
|
||||
// or a duplicate id is a form the Phase 4 audit cannot read, and finding that out
|
||||
// at 3 AM in a summary screen is finding it out too late.
|
||||
for (const [key, bank] of Object.entries({ ...BANKS, _default: DEFAULT_BANK })) {
|
||||
if (bank.options.length < 2) {
|
||||
throw new Error(`incidentReport: bank '${key}' offers no choice`);
|
||||
}
|
||||
if (bank.options.filter((o) => o.truthful).length !== 1) {
|
||||
throw new Error(`incidentReport: bank '${key}' must have exactly one truthful account`);
|
||||
}
|
||||
if (new Set(bank.options.map((o) => o.id)).size !== bank.options.length) {
|
||||
throw new Error(`incidentReport: bank '${key}' has duplicate option ids`);
|
||||
}
|
||||
}
|
||||
|
||||
// Own-property lookups only. `kind` is an open string, so a kind that happens to
|
||||
// name an Object.prototype member ('toString', 'constructor', 'valueOf') would
|
||||
// otherwise resolve to an inherited function: `interestOf` would return a
|
||||
// Function, `fn > 0` is false, and the incident would vanish from the form — the
|
||||
// exact silent-drop this module's defaultInterest exists to prevent.
|
||||
const own = <T>(table: Record<string, T>, key: string): T | undefined =>
|
||||
Object.prototype.hasOwnProperty.call(table, key) ? table[key] : undefined;
|
||||
|
||||
const interestOf = (incident: IncidentRecord): number =>
|
||||
own(REPORT_TUNING.interest, incident.kind) ?? REPORT_TUNING.defaultInterest;
|
||||
|
||||
/** Fisher-Yates, so the truthful account is not always the top button. */
|
||||
function shuffled(options: readonly ReportOption[], rng: RngStream): ReportOption[] {
|
||||
const out = [...options];
|
||||
for (let i = out.length - 1; i > 0; i--) {
|
||||
const j = rng.int(0, i);
|
||||
const a = out[i]!;
|
||||
const b = out[j]!;
|
||||
out[i] = b;
|
||||
out[j] = a;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildReport(
|
||||
incidents: readonly IncidentRecord[],
|
||||
rng: RngStream,
|
||||
): ReportQuestion[] {
|
||||
const scored = incidents
|
||||
// Already-filed rows are skipped rather than trusted: pastReports holds both
|
||||
// shapes, and asking the player to file an account of an account is a loop.
|
||||
.filter((i) => interestOf(i) > 0 && parseFiledAccount(i) === undefined)
|
||||
.map((incident) => ({ incident, score: interestOf(incident) + rng.next() }));
|
||||
|
||||
// Jitter is < 1 and weights are integers, so shuffling never promotes a dull
|
||||
// incident over an interesting one — it only reorders within a band.
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
|
||||
// Never padded: an uneventful night gets a short form, which is its own joke.
|
||||
const asked = scored.slice(0, REPORT_TUNING.maxQuestions);
|
||||
|
||||
// Filed in the order it happened, because that is how a report reads and how
|
||||
// Phase 4 will want to diff two nights against each other.
|
||||
asked.sort((a, b) => a.incident.clockMin - b.incident.clockMin);
|
||||
|
||||
return asked.map(({ incident }) => {
|
||||
const bank = own(BANKS, incident.kind) ?? DEFAULT_BANK;
|
||||
return { incident, prompt: bank.prompt, options: shuffled(bank.options, rng) };
|
||||
});
|
||||
}
|
||||
|
||||
/** The rows to push onto GameState.pastReports. See the encoding note up top. */
|
||||
export function fileReport(answers: readonly FiledAnswer[]): IncidentRecord[] {
|
||||
return answers.map(({ incident, chosen }) => {
|
||||
const payload: FiledPayload = {
|
||||
optionId: chosen.id,
|
||||
truthful: chosen.truthful,
|
||||
truthLog: incident.detail,
|
||||
};
|
||||
const filed: IncidentRecord = {
|
||||
clockMin: incident.clockMin,
|
||||
kind: incident.kind,
|
||||
detail: `${chosen.text}${REPORT_MARKER}${JSON.stringify(payload)}`,
|
||||
};
|
||||
// Spread rather than assigning undefined: patronId is optional and a present
|
||||
// key holding undefined survives JSON round-trips as a different shape.
|
||||
return incident.patronId === undefined ? filed : { ...filed, patronId: incident.patronId };
|
||||
});
|
||||
}
|
||||
|
||||
/** undefined for anything that is not a filed report row — including truth-log rows. */
|
||||
export function parseFiledAccount(record: IncidentRecord): FiledAccount | undefined {
|
||||
const at = record.detail.indexOf(REPORT_MARKER);
|
||||
if (at < 0) return undefined;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(record.detail.slice(at + REPORT_MARKER.length));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null) return undefined;
|
||||
|
||||
const { optionId, truthful, truthLog } = parsed as Partial<FiledPayload>;
|
||||
if (typeof optionId !== 'string' || typeof truthful !== 'boolean' || typeof truthLog !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
return { account: record.detail.slice(0, at), optionId, truthful, truthLog };
|
||||
}
|
||||
|
||||
export function lieCount(answers: readonly FiledAnswer[]): number {
|
||||
return answers.filter((a) => !a.chosen.truthful).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Design §3.3's "one taste" of the long-memory conscience: Kayden keeps his own
|
||||
* log, and it does not flatter anyone. If a PREVIOUS night's filed report
|
||||
* embellished a Kayden incident, this is what Dazza reads out the next morning.
|
||||
*
|
||||
* Phase 4 generalises this to a full cross-night audit with strikes attached;
|
||||
* for now it is one line, arriving one night late, which is the shape of the
|
||||
* mechanic in miniature.
|
||||
*/
|
||||
export function findKaydenContradiction(
|
||||
pastReports: readonly (readonly IncidentRecord[])[],
|
||||
): string | undefined {
|
||||
for (let n = pastReports.length - 1; n >= 0; n--) {
|
||||
for (const record of pastReports[n] ?? []) {
|
||||
if (record.kind !== 'kayden') continue;
|
||||
const filed = parseFiledAccount(record);
|
||||
if (!filed || filed.truthful) continue;
|
||||
return `kayden wrote his version of ${record.patronId ?? 'that one'} up too. it does not match urs. he is very proud of the log`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
156
src/rules/inspector.ts
Normal file
@ -0,0 +1,156 @@
|
||||
// The Liquor Licensing Inspector (design §4.2). Plainclothes: the generator
|
||||
// gives them nothing to look at, judge() scores them exactly like a punter, and
|
||||
// this module never sees the door at all. It only answers one question, once a
|
||||
// minute, while they are inside: is the room breaching right now?
|
||||
//
|
||||
// Everything here is keyed off clock minutes the caller supplies. No rng, no
|
||||
// clock, no bus — the night shell owns all three.
|
||||
|
||||
import type { HeatStrike } from '../data/types';
|
||||
|
||||
export const INSPECTOR_TUNING = {
|
||||
/** Clock-minutes they spend inside after being admitted. */
|
||||
watchMin: 30,
|
||||
/**
|
||||
* The whole mechanic in one field. Breaches normally file a DEFERRED strike
|
||||
* that surfaces at the 3 AM audit, which is late enough to argue with. With
|
||||
* the inspector in the room the same breach lands now.
|
||||
*/
|
||||
strikesDeferred: false,
|
||||
};
|
||||
|
||||
export interface InspectorWatch {
|
||||
patronId: string;
|
||||
admittedAtMin: number;
|
||||
leavesAtMin: number;
|
||||
}
|
||||
|
||||
/** Live venue conditions, sampled by the caller each clock minute. */
|
||||
export interface VenueBreaches {
|
||||
overCapacity: boolean;
|
||||
maggotOnFloor: boolean;
|
||||
underageInside: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dazza's reveal, after they have already left. Deliberately the SAME line
|
||||
* whether the inspector saw a spotless room or filed three strikes — the player
|
||||
* finds out how it went from the heat count, not from Dazza, and never finds out
|
||||
* at all if they denied him.
|
||||
*/
|
||||
export const INSPECTOR_LINES: readonly string[] = [
|
||||
'that boring bloke you let in was the INSPECTOR you muppet',
|
||||
'mate. MATE. the quiet one was licensing. hes walked back out already. im gonna be sick',
|
||||
'so that was the inspector. terry is going to ring me about tonight and im going to tell him it went great',
|
||||
];
|
||||
|
||||
/**
|
||||
* Reason strings are the dedupe key, so they are fixed sentences: no patron ids,
|
||||
* no clock times, no counts. Two different breaches must never collapse into
|
||||
* each other, and the same breach must read identically on every minute it is
|
||||
* live.
|
||||
*
|
||||
* WIRING, AND THIS IS NOT OPTIONAL: stable reasons only collapse a 30-minute
|
||||
* breach to one strike if the emitting site guards with
|
||||
* `shouldRecordStrike(state, strike)` (src/scenes/door/NightScene.ts) before
|
||||
* `bus.emit('heat:strike', ...)`, exactly as the door and deferred-audit paths
|
||||
* already do. Nothing on the bus dedupes — core/meters.ts records every strike
|
||||
* it hears and NightScene pulls the licence on the third. Emit observe()'s
|
||||
* strikes raw and a single sustained breach ends the run in three clock minutes.
|
||||
*/
|
||||
const BREACH_REASONS = {
|
||||
overCapacity: 'inspector saw the room over licensed capacity',
|
||||
maggotOnFloor: 'inspector saw a maggot still on the floor',
|
||||
underageInside: 'inspector saw a minor inside the venue',
|
||||
} as const;
|
||||
|
||||
// Declared order, so a room breaching three ways files its strikes the same way
|
||||
// every time and the night summary reads consistently.
|
||||
const BREACH_ORDER = ['overCapacity', 'maggotOnFloor', 'underageInside'] as const;
|
||||
|
||||
// observe() carries no rng and has to stay pure, so the reveal picks its line off
|
||||
// the one number the watch already holds. Admission minutes vary across a run, so
|
||||
// the player does not hear the same line every night.
|
||||
const rollFor = (watch: InspectorWatch): number =>
|
||||
(Math.abs(Math.trunc(watch.admittedAtMin)) % INSPECTOR_LINES.length) / INSPECTOR_LINES.length;
|
||||
|
||||
// NaN-safe like judge's lapRoll: a junk roll must fall back to a real line
|
||||
// rather than returning undefined and printing an empty text bubble.
|
||||
const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0);
|
||||
|
||||
/** Picks a reveal line from a 0..1 roll. Exported so a caller can use its own rng. */
|
||||
export function revealLine(roll: number): string {
|
||||
const i = Math.min(INSPECTOR_LINES.length - 1, Math.floor(clamp01(roll) * INSPECTOR_LINES.length));
|
||||
return INSPECTOR_LINES[i] ?? INSPECTOR_LINES[0]!;
|
||||
}
|
||||
|
||||
export function startWatch(patronId: string, clockMin: number): InspectorWatch {
|
||||
return {
|
||||
patronId,
|
||||
admittedAtMin: clockMin,
|
||||
leavesAtMin: clockMin + INSPECTOR_TUNING.watchMin,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Half-open [admittedAtMin, leavesAtMin). The minute they leave is theirs, not
|
||||
* yours — a breach that starts as they walk out the door is not something they
|
||||
* saw, and an inclusive bound here would steal a minute the player is entitled to.
|
||||
*/
|
||||
export function isWatching(watch: InspectorWatch | null, clockMin: number): boolean {
|
||||
if (watch === null) return false;
|
||||
return clockMin >= watch.admittedAtMin && clockMin < watch.leavesAtMin;
|
||||
}
|
||||
|
||||
export function hasLeft(watch: InspectorWatch | null, clockMin: number): boolean {
|
||||
return watch !== null && clockMin >= watch.leavesAtMin;
|
||||
}
|
||||
|
||||
export function breachStrikes(breaches: VenueBreaches): HeatStrike[] {
|
||||
return BREACH_ORDER.filter((k) => breaches[k]).map((k) => ({
|
||||
reason: BREACH_REASONS[k],
|
||||
deferred: INSPECTOR_TUNING.strikesDeferred,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Called once per clock minute for as long as a watch exists. Pure: the "has the
|
||||
* reveal already fired" bit is not stored anywhere, it is derived from the
|
||||
* ending minute being a single minute, which is why the caller must tick this at
|
||||
* one call per whole clock minute (GameClock emits exactly that).
|
||||
*
|
||||
* Returns one strike per live breach PER MINUTE — up to 3 * watchMin objects
|
||||
* across a window. That is deliberate (this module has no memory), and it is why
|
||||
* every strike must go out through `shouldRecordStrike`; see BREACH_REASONS.
|
||||
*
|
||||
* Known gap, caller's call: an inspector admitted after clock 330 has
|
||||
* leavesAtMin > 360, GameClock clamps at 360 and stops ticking, so the reveal
|
||||
* never fires and the player never learns who that was. Not clamped here because
|
||||
* clamping would make the window shorter than watchMin. To guarantee the reveal,
|
||||
* call observe(watch, watch.leavesAtMin, breaches) once at last drinks.
|
||||
*/
|
||||
export function observe(
|
||||
watch: InspectorWatch | null,
|
||||
clockMin: number,
|
||||
breaches: VenueBreaches,
|
||||
): { strikes: HeatStrike[]; dazzaText?: string; watchEnded: boolean } {
|
||||
if (isWatching(watch, clockMin)) {
|
||||
return { strikes: breachStrikes(breaches), watchEnded: false };
|
||||
}
|
||||
|
||||
// The one minute the reveal exists on. Before it: not inside yet. After it:
|
||||
// inert forever, because the caller keeps calling with the same stale watch.
|
||||
if (watch !== null && clockMin === watch.leavesAtMin) {
|
||||
return { strikes: [], dazzaText: revealLine(rollFor(watch)), watchEnded: true };
|
||||
}
|
||||
|
||||
return { strikes: [], watchEnded: false };
|
||||
}
|
||||
|
||||
// There is no denial branch, and its absence is the design (§4.3, LANE_CONTENT
|
||||
// deliverable 3). Deny the inspector and NOTHING happens — no strike, no relief,
|
||||
// no Dazza text, no note in the summary. They were a quiet person who wanted a
|
||||
// drink, and the player never learns what they dodged. The paranoia is the tax.
|
||||
// This will read as a missing branch to whoever maintains this next. It isn't.
|
||||
// Do not add a reward for the correct call; the game does not know which call
|
||||
// was correct, and that is the entire point of the mechanic.
|
||||
301
src/rules/judge.ts
Normal file
@ -0,0 +1,301 @@
|
||||
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 type { Patron, Verdict } from '../data/types';
|
||||
|
||||
// The scoring function — the one place this game's morality is a number. Every
|
||||
// branch below is a line of the design doc's tension table (§2 meters, §4.3 the
|
||||
// moral undertow), and every magnitude lives in JUDGE_TUNING so feel can be
|
||||
// tuned without archaeology.
|
||||
//
|
||||
// The load-bearing asymmetry, which is NOT a bug: denying a clean patron still
|
||||
// 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,
|
||||
/** The queue still watched a stranger get humiliated. */
|
||||
denyViolatorAggro: 1,
|
||||
|
||||
/** Deny someone with nothing wrong with them. Small reward... */
|
||||
denyCleanVibe: 1,
|
||||
/** ...large heat. The whole line saw that there was no reason. */
|
||||
denyCleanAggro: 7,
|
||||
|
||||
/** Deny a bloke who really does drink with the owner. The worst call available. */
|
||||
denyOwnersMateVibe: -14,
|
||||
denyOwnersMateAggro: 4,
|
||||
|
||||
/** Influencer denials, stacked on top of whichever deny applied. */
|
||||
denyInfluencerVibe: 3,
|
||||
denyInfluencerAggro: 4,
|
||||
|
||||
/** A regular you have knocked back before. He kept count. */
|
||||
denyGrudgedRegularVibe: -5,
|
||||
|
||||
admitCleanVibe: 2,
|
||||
admitCleanAggro: -3,
|
||||
/** Waving a violator through moves the queue too, just less — you hesitated. */
|
||||
admitViolatorAggro: -2,
|
||||
/** The trap: a dud ID admitted still reads to the queue as a fast door. */
|
||||
admitBadIdVibe: 1,
|
||||
|
||||
/** "Dazza does a lap": the breach lands 2..5 minutes after you could argue. */
|
||||
lapDelayMin: 2,
|
||||
lapDelaySpread: 3,
|
||||
lapVibePerRule: -4,
|
||||
lapVibeCap: -12,
|
||||
|
||||
/** 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,
|
||||
waitHype: 0.2,
|
||||
};
|
||||
|
||||
// NaN-safe on purpose: the caller is meant to pass rng.next(), but a missing or
|
||||
// junk lapRoll must not poison atClockMin into NaN and silently unschedule the
|
||||
// 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
|
||||
// through here as a normal verdict.
|
||||
if (verdict === 'sobrietyTest' || verdict === 'patDown') {
|
||||
return { vibeDelta: 0, aggroDelta: JUDGE_TUNING.stallAggro };
|
||||
}
|
||||
if (verdict === 'wait') {
|
||||
return { vibeDelta: 0, aggroDelta: JUDGE_TUNING.waitAggro, hypeDelta: JUDGE_TUNING.waitHype };
|
||||
}
|
||||
|
||||
const broken = violations(patron, ctx.clockMin);
|
||||
const id = idVerdict(patron.idCard, ctx.nightDate);
|
||||
|
||||
// Dispatch on 'admit' explicitly rather than letting it be the else-branch of
|
||||
// 'deny'. Verdict is a closed union today, but if the contract ever grows a
|
||||
// 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);
|
||||
}
|
||||
|
||||
function judgeDeny(
|
||||
patron: Patron,
|
||||
broken: DoorDressCodeRule[],
|
||||
id: IdVerdict,
|
||||
ctx: JudgeContext,
|
||||
): DoorOutcome {
|
||||
const T = JUDGE_TUNING;
|
||||
|
||||
// Early return on purpose. Whatever else was wrong with him, Dazza only ever
|
||||
// hears that you knocked back the owner's mate, and he hears it tonight —
|
||||
// this is the one consequence that does not wait for a lap.
|
||||
if (patron.flags.knowsOwner === true) {
|
||||
return {
|
||||
vibeDelta: T.denyOwnersMateVibe,
|
||||
aggroDelta: T.denyOwnersMateAggro,
|
||||
dazzaText: 'WHO did u just knock back that bloke plays golf with the owner im getting calls RIGHT NOW mate',
|
||||
};
|
||||
}
|
||||
|
||||
// "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;
|
||||
|
||||
let vibeDelta = justified ? T.denyViolatorVibe : T.denyCleanVibe;
|
||||
let aggroDelta = justified ? T.denyViolatorAggro : T.denyCleanAggro;
|
||||
let note: string | undefined;
|
||||
|
||||
if (patron.archetype === 'influencer') {
|
||||
// The room approves loudly; the phone comes out and the queue watches it.
|
||||
vibeDelta += T.denyInfluencerVibe;
|
||||
aggroDelta += T.denyInfluencerAggro;
|
||||
}
|
||||
|
||||
if (patron.archetype === 'regular' && (patron.flags.timesDeniedBefore ?? 0) > 0) {
|
||||
vibeDelta += T.denyGrudgedRegularVibe;
|
||||
// Patrons carry no gender, so the note cannot say "he".
|
||||
note = 'They kept count. This is not the first time you have knocked them back.';
|
||||
}
|
||||
|
||||
// Deliberately not a penalty. Turning away a genuinely 18-year-old with a
|
||||
// genuinely real ID is the game's conscience test, and the game never tells
|
||||
// you which choice was right (§4.3). Do not make this cost anything.
|
||||
// The age guard matches the spec's "genuinely 18" literally: the note claims
|
||||
// this is their first legal night, so it must not fire on an older patron who
|
||||
// happened to roll the archetype.
|
||||
if (!justified && patron.archetype === 'fresh18' && patron.age === 18) {
|
||||
note = 'Real ID, first legal night out, and you said no. Good story for them one day.';
|
||||
}
|
||||
|
||||
// The inspector scores exactly like a punter, on purpose. Give them bespoke
|
||||
// numbers and a player can identify them by watching the meters twitch, which
|
||||
// deletes the entire mechanic. This is not an oversight — do not "fix" it.
|
||||
|
||||
return note === undefined ? { vibeDelta, aggroDelta } : { vibeDelta, aggroDelta, note };
|
||||
}
|
||||
|
||||
function judgeAdmit(
|
||||
patron: Patron,
|
||||
broken: DoorDressCodeRule[],
|
||||
id: IdVerdict,
|
||||
ctx: JudgeContext,
|
||||
): DoorOutcome {
|
||||
const T = JUDGE_TUNING;
|
||||
const cardIsBad = id.underage || id.looksFake;
|
||||
const deferred: DeferredHit[] = [];
|
||||
|
||||
let vibeDelta = 0;
|
||||
const aggroDelta = broken.length > 0 ? T.admitViolatorAggro : T.admitCleanAggro;
|
||||
|
||||
if (broken.length === 0 && !cardIsBad) vibeDelta += T.admitCleanVibe;
|
||||
// The queue only sees a fast door, so waving a dud card through feels good in
|
||||
// the moment. That is the entire trap; the bill arrives at the audit.
|
||||
if (cardIsBad) vibeDelta += T.admitBadIdVibe;
|
||||
|
||||
if (broken.length > 0) {
|
||||
const first = broken[0]!;
|
||||
const shorts = broken.map((r) => r.short);
|
||||
// Rule `short`s are the words printed on the dress-code card, and every one
|
||||
// of them is phrased as a prohibition ("NO VISIBLE LOGOS"). So the line has
|
||||
// to QUOTE the sign, never claim to see it: "i can see NO VISIBLE LOGOS"
|
||||
// reads as the exact opposite of the accusation Dazza is making.
|
||||
const lapAt = ctx.clockMin + T.lapDelayMin + Math.round(clamp01(ctx.lapRoll) * T.lapDelaySpread);
|
||||
deferred.push({
|
||||
// Clamped to the audit, which is also last drinks. A lap rolled at 2:58 AM
|
||||
// would otherwise be scheduled past the end of the night and never fire,
|
||||
// making every late admit consequence-free — the exploit being that the
|
||||
// last five minutes of every night are a free-for-all.
|
||||
atClockMin: Math.min(T.auditClockMin, lapAt),
|
||||
vibeDelta: Math.max(T.lapVibeCap, T.lapVibePerRule * broken.length),
|
||||
dazzaText: lapLine(first.short, clamp01(ctx.lapRoll)),
|
||||
reason: `admitted ${patron.id} in breach: ${shorts.join(', ')}`,
|
||||
patronId: patron.id,
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
// actually costs you the venue. A flawless fake protects nobody.
|
||||
if (cardIsBad || patron.age < 18) {
|
||||
const found: string[] = [];
|
||||
if (id.underage) found.push(`card read ${id.claimedAge}`);
|
||||
if (id.looksFake) {
|
||||
// idCheck folds 'expired' into `tells`, so `looksFake` is true for a
|
||||
// perfectly genuine licence that simply lapsed. Calling a 40-year-old's
|
||||
// out-of-date licence "fake" in the incident log is just wrong, so the
|
||||
// two findings are reported separately.
|
||||
const forged = id.tells.filter((t) => t !== 'expired');
|
||||
if (forged.length > 0) found.push(`card was dodgy (${forged.join(', ')})`);
|
||||
if (id.expired) found.push('card had expired');
|
||||
}
|
||||
if (patron.age < 18) found.push(`patron was ${patron.age}`);
|
||||
const reason = `ID/age breach admitted — ${patron.id}: ${found.join('; ')}`;
|
||||
deferred.push({
|
||||
atClockMin: T.auditClockMin,
|
||||
heatStrike: { reason, deferred: true },
|
||||
reason,
|
||||
patronId: patron.id,
|
||||
});
|
||||
}
|
||||
|
||||
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.
|
||||
deferred.push({
|
||||
atClockMin: T.auditClockMin,
|
||||
heatStrike: { reason: 'room over licensed capacity', deferred: true },
|
||||
reason: `capacity breach — ${patron.id} admitted at ${ctx.insideCount}/${ctx.licensed}`,
|
||||
patronId: patron.id,
|
||||
});
|
||||
}
|
||||
|
||||
return deferred.length > 0 ? { vibeDelta, aggroDelta, deferred } : { vibeDelta, aggroDelta };
|
||||
}
|
||||
52
src/rules/lipRead.ts
Normal file
@ -0,0 +1,52 @@
|
||||
// ROOM (design §6): the techno is so loud every line at the rope is a lip-read
|
||||
// guess. Pure text transform, deterministic per (salt, word) — the same patron
|
||||
// mouths the same guess twice, because re-asking doesn't reroll reality.
|
||||
//
|
||||
// Tone guard: the comedy is the player's OWN uncertain reading, never the
|
||||
// speaker's — everyone in ROOM is perfectly articulate, you just can't hear.
|
||||
|
||||
/** What the player's brain offers when a word is half-caught. Club-adjacent
|
||||
* nonsense on purpose — a guess should be worse than the truth. */
|
||||
const MISHEARINGS: readonly string[] = [
|
||||
'FIST', 'GOAT', 'CHIPS', 'NAN', 'TAX', 'DECAF', 'PILATES', 'HORSE',
|
||||
'COUNCIL', 'SOUP', 'LADDER', 'BINS', 'SHREK', 'MULCH',
|
||||
];
|
||||
|
||||
const LOST = '[???]';
|
||||
|
||||
/** FNV-1a — cheap, stable, and never Math.random (CONTRACTS.md). */
|
||||
function hash(s: string): number {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* `"mate im on the list"` → `"[MATE] [IM?] [ON THE?] [FIST? LIST?]"`-shaped
|
||||
* guesses. Every token is bracketed (the brackets ARE the deafness); short
|
||||
* words survive better than long ones; some words arrive as two competing
|
||||
* guesses and some don't arrive at all.
|
||||
*/
|
||||
export function lipReadLine(line: string, salt: string): string {
|
||||
const words = line.split(/\s+/).filter(Boolean);
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
const clean = (words[i] ?? '').replace(/[^a-z0-9']/gi, '').toUpperCase();
|
||||
if (clean.length === 0) continue;
|
||||
const h = hash(`${salt}:${i}:${clean}`);
|
||||
const roll = h % 100;
|
||||
if (roll < 12) {
|
||||
out.push(LOST);
|
||||
} else if (roll < 28 && clean.length >= 4) {
|
||||
out.push(`[${MISHEARINGS[h % MISHEARINGS.length]}? ${clean}?]`);
|
||||
} else if (roll < 60) {
|
||||
out.push(`[${clean}?]`);
|
||||
} else {
|
||||
out.push(`[${clean}]`);
|
||||
}
|
||||
}
|
||||
return out.length > 0 ? out.join(' ') : LOST;
|
||||
}
|
||||
191
src/rules/sobriety.ts
Normal file
@ -0,0 +1,191 @@
|
||||
// The sobriety-test minigame: content + performance model.
|
||||
//
|
||||
// The player picks a challenge, the patron performs, and then the player rules
|
||||
// PASS or FAIL regardless of what they just heard (design §3.1). So this module
|
||||
// never decides anything — it only produces a performance ambiguous enough that
|
||||
// ruling on it feels like a choice. `objectivelySober` exists so the night can
|
||||
// score the player's honesty later, not so it can score the patron.
|
||||
|
||||
import type { DrunkStage, Patron } from '../data/types';
|
||||
import type { RngStream } from '../core/SeededRNG';
|
||||
import { drunkStage } from './drunk';
|
||||
|
||||
export type ChallengeKind = 'alphabet' | 'riddle';
|
||||
|
||||
export interface Challenge {
|
||||
kind: ChallengeKind;
|
||||
prompt: string;
|
||||
ideal: string;
|
||||
}
|
||||
|
||||
export interface Performance {
|
||||
challenge: Challenge;
|
||||
answer: string;
|
||||
quality: number;
|
||||
objectivelySober: boolean;
|
||||
}
|
||||
|
||||
export const PASS_THRESHOLD = 0.6;
|
||||
|
||||
const ALPHABET_BACKWARDS = 'ZYXWVUTSRQPONMLKJIHGFEDCBA';
|
||||
|
||||
const ALPHABET_PROMPT = 'alphabet. backwards. any time you like.';
|
||||
|
||||
/** Asked BY the player, AT 1 AM, with a torch. Unanswerable is the point. */
|
||||
export const RIDDLES: readonly { prompt: string; ideal: string }[] = [
|
||||
{ prompt: 'kebab shop shuts at four, we shut at three. where are ya at half three?', ideal: 'in the kebab queue' },
|
||||
{ prompt: 'name three things that are not a schooner.', ideal: 'a pot, a midi, a jug' },
|
||||
{ prompt: 'whats heavier, a kilo of ice or a kilo of the ice machine?', ideal: 'same weight, different problem' },
|
||||
{ prompt: 'ya mate says hes five minutes away. how long is that in real minutes?', ideal: 'forty, maybe fifty' },
|
||||
{ prompt: 'spell sober backwards and tell me what it means.', ideal: 'rebos, and it means nothing' },
|
||||
{ prompt: 'whats the difference between a line and a queue?', ideal: 'nothing, ones just angrier' },
|
||||
{ prompt: 'the DJ plays one more song and then one more song. how many songs is that?', ideal: 'none, hes packing up' },
|
||||
{ prompt: 'if the smoking area is outside, and youre outside, are ya in the club?', ideal: 'no, but i was, and i will be' },
|
||||
];
|
||||
|
||||
// How much a night of practice buys you at the same blood alcohol. Seasoned
|
||||
// drinkers hold the shape of a sentence together long past the point of sense.
|
||||
//
|
||||
// The ceiling is load-bearing, not taste: `objectivelySober` is the oracle the
|
||||
// night uses to score the player's honesty, so a 'maggot' must NEVER read as
|
||||
// sober no matter how seasoned. Worst case is the bottom of the maggot band with
|
||||
// maximum tolerance, riding the top of the wobble:
|
||||
// qMax = 1 - 0.85 * (1 - TOLERANCE_RELIEF) + WOBBLE.maggot < PASS_THRESHOLD
|
||||
// which needs TOLERANCE_RELIEF < 0.365. At the old 0.4 an intoxication-0.85,
|
||||
// tolerance-1.0 patron passed ~11% of the time. `maggot never reads sober` in
|
||||
// the test file pins this against future retunes.
|
||||
const TOLERANCE_RELIEF = 0.33;
|
||||
|
||||
// Roll spread per stage. 'loose' and 'messy' are deliberately the widest: that
|
||||
// band is where the player has to actually decide something. A maggot is narrow
|
||||
// because there is nothing ambiguous about a maggot.
|
||||
const WOBBLE: Record<DrunkStage, number> = {
|
||||
sober: 0.06,
|
||||
tipsy: 0.16,
|
||||
loose: 0.24,
|
||||
messy: 0.28,
|
||||
maggot: 0.14,
|
||||
};
|
||||
|
||||
// Above this the recital is clean — the ideal, no theatre.
|
||||
const FLAWLESS = 0.99;
|
||||
|
||||
const clamp01 = (v: number): number => Math.max(0, Math.min(1, v));
|
||||
|
||||
const ALPHABET_GIVE_UPS = [
|
||||
' ... nah thats the good half',
|
||||
' ... hang on',
|
||||
' ... does it count if i sing it',
|
||||
' ... i know the rest i just dont like it',
|
||||
] as const;
|
||||
|
||||
const NON_ANSWERS = [
|
||||
'yeah nah',
|
||||
'is this a test or are we just talkin',
|
||||
'my mate outside knows this one',
|
||||
'ask me the alphabet one instead',
|
||||
'thats a trick question and i respect that',
|
||||
'whats the prize',
|
||||
] as const;
|
||||
|
||||
// Roughly QWERTY-adjacent. Not exact — a drunk thumb isn't exact either.
|
||||
const KEY_NEIGHBOURS: Record<string, string> = {
|
||||
a: 'sqw', b: 'vgn', c: 'xdv', d: 'sfe', e: 'wrd', f: 'dgr', g: 'fht',
|
||||
h: 'gjy', i: 'uok', j: 'hkn', k: 'jlm', l: 'ko', m: 'nk', n: 'bm',
|
||||
o: 'ipl', p: 'ol', q: 'wa', r: 'etf', s: 'adw', t: 'ryg', u: 'yih',
|
||||
v: 'cbf', w: 'qes', x: 'zsc', y: 'tuh', z: 'xa',
|
||||
};
|
||||
|
||||
const VOWELS = 'aeiou';
|
||||
|
||||
/**
|
||||
* Dropped letters, swapped pairs, a stall, and eventually giving up partway.
|
||||
* Reach shrinks faster than accuracy does — people lose the thread before they
|
||||
* lose the letters.
|
||||
*/
|
||||
function reciteAlphabet(ideal: string, quality: number, rng: RngStream): string {
|
||||
if (quality >= FLAWLESS) return ideal;
|
||||
|
||||
const reach = Math.max(3, Math.round(ideal.length * Math.min(1, 0.3 + quality * 0.85)));
|
||||
const slip = (1 - quality) * 0.45;
|
||||
const out: string[] = [];
|
||||
|
||||
for (let i = 0; i < reach; i++) {
|
||||
const ch = ideal[i] ?? '';
|
||||
if (!rng.chance(slip)) {
|
||||
out.push(ch);
|
||||
continue;
|
||||
}
|
||||
const kind = rng.int(0, 2);
|
||||
if (kind === 0) continue; // dropped
|
||||
if (kind === 1) {
|
||||
out.push(ideal[i + 1] ?? '', ch); // swapped with the next one
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
out.push('...', ch); // stall
|
||||
}
|
||||
|
||||
const said = out.join('');
|
||||
const tail = reach < ideal.length ? rng.pick(ALPHABET_GIVE_UPS) : '';
|
||||
const answer = `${said}${tail}`;
|
||||
return answer.trim().length > 0 ? answer : rng.pick(ALPHABET_GIVE_UPS).trim();
|
||||
}
|
||||
|
||||
function drunkTypos(ideal: string, quality: number, rng: RngStream): string {
|
||||
const slip = (1 - quality) * 0.4;
|
||||
let out = '';
|
||||
|
||||
for (const ch of ideal) {
|
||||
const lower = ch.toLowerCase();
|
||||
const isLetter = lower >= 'a' && lower <= 'z';
|
||||
if (!isLetter || !rng.chance(slip)) {
|
||||
out += ch;
|
||||
continue;
|
||||
}
|
||||
const kind = rng.int(0, 2);
|
||||
if (kind === 0) {
|
||||
const nb = KEY_NEIGHBOURS[lower] ?? '';
|
||||
out += nb.length > 0 ? (nb[rng.int(0, nb.length - 1)] ?? ch) : ch;
|
||||
} else if (kind === 1) {
|
||||
out += ch + ch;
|
||||
} else if (!VOWELS.includes(lower)) {
|
||||
out += ch; // only vowels get swallowed
|
||||
}
|
||||
}
|
||||
|
||||
return out.trim().length > 0 ? out : ideal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Riddles fail sideways rather than sadly — past a point the patron stops
|
||||
* answering and starts negotiating. Tone guard: absurd, never pitiable.
|
||||
*/
|
||||
function answerRiddle(ideal: string, quality: number, rng: RngStream): string {
|
||||
if (quality >= FLAWLESS) return ideal;
|
||||
const bail = clamp01((0.4 - quality) / 0.4);
|
||||
if (rng.chance(bail)) return rng.pick(NON_ANSWERS);
|
||||
return drunkTypos(ideal, quality, rng);
|
||||
}
|
||||
|
||||
/** One of each kind, so the player is choosing a flavour of humiliation. */
|
||||
export function challengesFor(rng: RngStream): Challenge[] {
|
||||
const riddle = rng.pick(RIDDLES);
|
||||
return [
|
||||
{ kind: 'alphabet', prompt: ALPHABET_PROMPT, ideal: ALPHABET_BACKWARDS },
|
||||
{ kind: 'riddle', prompt: riddle.prompt, ideal: riddle.ideal },
|
||||
];
|
||||
}
|
||||
|
||||
export function perform(patron: Patron, challenge: Challenge, rng: RngStream): Performance {
|
||||
const effective = patron.intoxication * (1 - TOLERANCE_RELIEF * patron.tolerance);
|
||||
const wobble = WOBBLE[drunkStage(patron.intoxication)];
|
||||
const quality = clamp01(1 - effective + (rng.next() * 2 - 1) * wobble);
|
||||
|
||||
const answer =
|
||||
challenge.kind === 'alphabet'
|
||||
? reciteAlphabet(challenge.ideal, quality, rng)
|
||||
: answerRiddle(challenge.ideal, quality, rng);
|
||||
|
||||
return { challenge, answer, quality, objectivelySober: quality >= PASS_THRESHOLD };
|
||||
}
|
||||
46
src/rules/staff.ts
Normal file
@ -0,0 +1,46 @@
|
||||
// Radio command (design §6, the Head of Security finale): NPC staff work the
|
||||
// roles you aren't, and the radio is how you reach them. You used to speak
|
||||
// into the radio; now you're the voice on the other end. Competence varies —
|
||||
// that IS the staff. Pure numbers here; the scenes own the walkie theatre.
|
||||
|
||||
/** Orders the floor staff take. 'mess' is Bump the glassie, 'water' is Shan. */
|
||||
export type FloorOrderKind = 'mess' | 'water';
|
||||
|
||||
export type OrderAck = 'sent' | 'cooldown' | 'nothing';
|
||||
|
||||
export const RADIO_COOLDOWN_MS: Readonly<Record<FloorOrderKind, number>> = {
|
||||
mess: 45_000,
|
||||
water: 60_000,
|
||||
};
|
||||
|
||||
/** Staff cross a real floor — an order lands, then happens. */
|
||||
export const ORDER_LAG_MS: readonly [number, number] = [3000, 7000];
|
||||
|
||||
/** Bump mops AND signs (in that order, worryingly) this often; else he signs
|
||||
* it and finds a phone in the puddle. */
|
||||
export const BUMP_MOP_CHANCE = 0.65;
|
||||
/** Shan gets the water in without a negotiation this often; else she leads
|
||||
* with the phrase "you've had enough". */
|
||||
export const SHAN_CLEAN_CHANCE = 0.8;
|
||||
/** A firm two cups. */
|
||||
export const SHAN_WATER_STEP = 0.14;
|
||||
export const SHAN_BOTCH_AGGRO = 2;
|
||||
export const ORDER_DONE_VIBE = 1;
|
||||
/** Shan only bothers with loose and worse. */
|
||||
export const SHAN_MIN_INTOX = 0.45;
|
||||
|
||||
/** Kayden holds the rope this long, then resumes ruling. Badly. */
|
||||
export const KAYDEN_HOLD_MS = 60_000;
|
||||
export const KAYDEN_HOLD_COOLDOWN_MS = 90_000;
|
||||
|
||||
export function rollBumpMops(rng: { chance(p: number): boolean }): boolean {
|
||||
return rng.chance(BUMP_MOP_CHANCE);
|
||||
}
|
||||
|
||||
export function rollShanClean(rng: { chance(p: number): boolean }): boolean {
|
||||
return rng.chance(SHAN_CLEAN_CHANCE);
|
||||
}
|
||||
|
||||
export function orderLag(rng: { int(min: number, max: number): number }): number {
|
||||
return rng.int(ORDER_LAG_MS[0], ORDER_LAG_MS[1]);
|
||||
}
|
||||
90
src/scenes/door/ClipboardPanel.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import Phaser from 'phaser';
|
||||
import { lookupName, type GuestList, type NameLookup } from '../../rules/guestList';
|
||||
import { DOOR_PALETTE, MONO, panel, text } from './ui';
|
||||
|
||||
// The guest-list clipboard, sharing the desk's left panel with Dazza's rules via
|
||||
// a two-tab header. There is no spare desk real estate at 640x360, and a tab is
|
||||
// honest about the tradeoff: you cannot read the dress code and the list at the
|
||||
// same time, so checking a name costs you a beat of attention. That is the point.
|
||||
|
||||
export type ClipboardTab = 'rules' | 'list';
|
||||
|
||||
/** Two columns of names; 14 entries is the generator's ceiling. */
|
||||
const ROWS_PER_COL = 7;
|
||||
const ROW_H = 10;
|
||||
|
||||
export class ClipboardPanel {
|
||||
private readonly root: Phaser.GameObjects.Container;
|
||||
private readonly rows: Phaser.GameObjects.Text[] = [];
|
||||
private readonly hint: Phaser.GameObjects.Text;
|
||||
private highlighted = -1;
|
||||
|
||||
constructor(
|
||||
scene: Phaser.Scene,
|
||||
private readonly list: GuestList,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
) {
|
||||
this.root = scene.add.container(0, 0).setDepth(50);
|
||||
this.root.add(panel(scene, x, y, w, h, 0x1d1a14, 0x4a4436));
|
||||
this.list.entries.forEach((entry, i) => {
|
||||
const col = Math.floor(i / ROWS_PER_COL);
|
||||
const row = i % ROWS_PER_COL;
|
||||
const label = entry.plusOnes > 0 ? `${entry.name} +${entry.plusOnes}` : entry.name;
|
||||
const t = scene.add.text(x + 5 + col * (w / 2 - 4), y + 5 + row * ROW_H, label, {
|
||||
fontFamily: MONO,
|
||||
fontSize: '6px',
|
||||
color: '#c8bda0',
|
||||
});
|
||||
this.rows.push(t);
|
||||
this.root.add(t);
|
||||
});
|
||||
|
||||
this.hint = text(scene, x + 5, y + h - 11, '', 6, DOOR_PALETTE.inkDim);
|
||||
this.root.add(this.hint);
|
||||
this.root.setVisible(false);
|
||||
}
|
||||
|
||||
setVisible(on: boolean): void {
|
||||
this.root.setVisible(on);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show what the clipboard says about the name in front of you. It reports the
|
||||
* MATCH, never the truth — an exact hit is the suspicious one (rules/guestList
|
||||
* has the arithmetic), and nothing here tells the player which way to call it.
|
||||
*/
|
||||
check(claimed: string | null): NameLookup | null {
|
||||
this.clearHighlight();
|
||||
if (!claimed) {
|
||||
this.hint.setText('');
|
||||
return null;
|
||||
}
|
||||
const found = lookupName(claimed, this.list);
|
||||
const idx = found.entry ? this.list.entries.indexOf(found.entry) : -1;
|
||||
if (idx >= 0 && found.match !== 'none') {
|
||||
this.rows[idx]?.setColor('#ffe08a');
|
||||
this.highlighted = idx;
|
||||
}
|
||||
this.hint.setText(
|
||||
found.match === 'exact'
|
||||
? `"${claimed}" — written exactly like that`
|
||||
: found.match === 'near'
|
||||
? `"${claimed}" — close to one on the list`
|
||||
: `"${claimed}" — nothing like it here`,
|
||||
);
|
||||
this.hint.setColor(found.match === 'none' ? '#c08080' : '#c8bda0');
|
||||
return found;
|
||||
}
|
||||
|
||||
private clearHighlight(): void {
|
||||
if (this.highlighted >= 0) this.rows[this.highlighted]?.setColor('#c8bda0');
|
||||
this.highlighted = -1;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.root.destroy();
|
||||
}
|
||||
}
|
||||
1169
src/scenes/door/DoorScene.ts
Normal file
106
src/scenes/door/DressCodeCard.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import Phaser from 'phaser';
|
||||
import type { DoorDressCodeRule } from '../../rules/dressCode';
|
||||
import { DOOR_UI } from '../../data/strings/door';
|
||||
import { DOOR_PALETTE, MONO, panel, text } from './ui';
|
||||
|
||||
// The laminated card on the desk. Rules land the moment Dazza's text arrives
|
||||
// (design §3.1), so the NEW badge is the only warning the player gets that the
|
||||
// punter they are already looking at just became a violation.
|
||||
|
||||
const NEW_BADGE_MS = 12_000;
|
||||
|
||||
interface Row {
|
||||
rule: DoorDressCodeRule;
|
||||
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 {
|
||||
private readonly root: Phaser.GameObjects.Container;
|
||||
private readonly emptyText: Phaser.GameObjects.Text;
|
||||
private readonly rows: Row[] = [];
|
||||
private readonly x: number;
|
||||
private readonly y: number;
|
||||
|
||||
constructor(scene: Phaser.Scene, x: number, y: number, w: number, h: number) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
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(this.emptyText);
|
||||
}
|
||||
|
||||
add(scene: Phaser.Scene, rule: DoorDressCodeRule, nowMs: number): void {
|
||||
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 label = scene.add.text(this.x + 5, y, `· ${rule.short}`, {
|
||||
fontFamily: MONO,
|
||||
fontSize: '7px',
|
||||
color: '#e0e8c8',
|
||||
wordWrap: { width: 150 },
|
||||
});
|
||||
const badge = scene.add.text(this.x + 5, y, 'NEW', {
|
||||
fontFamily: MONO,
|
||||
fontSize: '7px',
|
||||
color: '#ffd050',
|
||||
});
|
||||
badge.x = this.x + 5 + label.width + 4;
|
||||
this.root.add([label, badge]);
|
||||
|
||||
// A flash on arrival — the card is peripheral vision, it has to shout once.
|
||||
scene.tweens.add({ targets: badge, alpha: { from: 1, to: 0.25 }, duration: 400, yoyo: true, repeat: 8 });
|
||||
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();
|
||||
}
|
||||
}
|
||||
200
src/scenes/door/IdCardView.ts
Normal file
@ -0,0 +1,200 @@
|
||||
import Phaser from 'phaser';
|
||||
import { renderDoll } from '../../patrons/doll';
|
||||
import { idVerdict, idDisplayLines, formatDate } from '../../rules/idCheck';
|
||||
import type { Patron } from '../../data/types';
|
||||
import { DOOR_PALETTE, MONO, text } from './ui';
|
||||
|
||||
// The ID card, at two sizes: a thumbnail sitting in the tray and a half-screen
|
||||
// zoom you actually read. Every fake tell is drawn, never labelled — the player
|
||||
// judges pixels, rules/idCheck judges the data (design §4.1).
|
||||
|
||||
const CARD_W = 268;
|
||||
const CARD_H = 156;
|
||||
|
||||
/** The genuine hologram colour. The desk carries a reference swatch of this so a
|
||||
* wrong hologram is a fair catch rather than a memory test. */
|
||||
export const GENUINE_HOLOGRAM = 0x46c8c0;
|
||||
const FAKE_HOLOGRAM = 0xc046b0;
|
||||
|
||||
export class IdCardView {
|
||||
private root: Phaser.GameObjects.Container | null = null;
|
||||
private patron: Patron | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly scene: Phaser.Scene,
|
||||
private readonly nightDate: Date,
|
||||
) {}
|
||||
|
||||
get isOpen(): boolean {
|
||||
return this.root !== null;
|
||||
}
|
||||
|
||||
/** Small always-visible representation for the tray. Caller owns placement. */
|
||||
thumbnail(p: Patron, x: number, y: number): Phaser.GameObjects.Container {
|
||||
const c = this.scene.add.container(x, y);
|
||||
const bg = this.scene.add.rectangle(0, 0, 78, 46, 0xdad4c4).setStrokeStyle(1, 0x8b8474);
|
||||
const photo = this.scene.add.image(-24, 0, renderDoll(this.scene, p, 'idPhoto')).setScale(1.4);
|
||||
const holo = this.scene.add
|
||||
.rectangle(22, -14, 26, 8, p.idCard.fake?.wrongHologram ? FAKE_HOLOGRAM : GENUINE_HOLOGRAM, 0.75)
|
||||
.setAngle(-8);
|
||||
const name = this.scene.add
|
||||
.text(-6, -4, p.idCard.name.slice(0, 13), { fontFamily: MONO, fontSize: '7px', color: '#2a2620' })
|
||||
.setOrigin(0, 0.5);
|
||||
c.add([bg, photo, holo, name]);
|
||||
if (p.idCard.fake?.peelingLaminate) {
|
||||
c.add(this.scene.add.triangle(36, 20, 0, 0, 9, 0, 9, -9, 0xf4f0e6, 0.85));
|
||||
}
|
||||
c.setSize(78, 46);
|
||||
return c;
|
||||
}
|
||||
|
||||
open(p: Patron): void {
|
||||
if (this.root) this.close();
|
||||
this.patron = p;
|
||||
const { scene } = this;
|
||||
// Parked right of centre on purpose: the photo-mismatch tell is only
|
||||
// playable if you can see the card and the actual face at the same time, so
|
||||
// the card must never sit on top of the patron at the rope (x ~252..348).
|
||||
const cx = 462;
|
||||
const cy = 134;
|
||||
|
||||
const root = scene.add.container(cx, cy).setDepth(700);
|
||||
this.root = root;
|
||||
|
||||
// Dim the street enough to focus the card, light enough to still read a face.
|
||||
const shade = scene.add
|
||||
.rectangle(-cx, -cy, 1280, 720, 0x05050a, 0.4)
|
||||
.setOrigin(0, 0)
|
||||
.setInteractive()
|
||||
.on('pointerdown', () => this.close());
|
||||
root.add(shade);
|
||||
|
||||
const card = scene.add.rectangle(0, 0, CARD_W, CARD_H, 0xdad4c4).setStrokeStyle(2, 0x8b8474);
|
||||
root.add(card);
|
||||
|
||||
// state banner
|
||||
root.add(scene.add.rectangle(0, -CARD_H / 2 + 13, CARD_W - 8, 20, 0x2a4a8a));
|
||||
root.add(
|
||||
scene.add
|
||||
.text(0, -CARD_H / 2 + 13, 'QUEENSLAND · ADULT PROOF OF AGE', {
|
||||
fontFamily: MONO,
|
||||
fontSize: '9px',
|
||||
color: '#dfe6f4',
|
||||
})
|
||||
.setOrigin(0.5),
|
||||
);
|
||||
|
||||
// photo, rendered from photoSeed — a mismatch is a visibly different person
|
||||
const photoKey = renderDoll(scene, p, 'idPhoto');
|
||||
root.add(scene.add.rectangle(-84, 8, 84, 84, 0xb8b2a2));
|
||||
root.add(scene.add.image(-84, 8, photoKey).setScale(3.2));
|
||||
|
||||
const lines = idDisplayLines(p.idCard, this.nightDate);
|
||||
const field = (label: string, value: string, y: number, big = false): void => {
|
||||
root.add(
|
||||
scene.add
|
||||
.text(-26, y, label, { fontFamily: MONO, fontSize: '7px', color: '#6b6558' })
|
||||
.setOrigin(0, 0.5),
|
||||
);
|
||||
root.add(
|
||||
scene.add
|
||||
.text(-26, y + 11, value, {
|
||||
fontFamily: MONO,
|
||||
fontSize: big ? '13px' : '11px',
|
||||
color: '#1e1a14',
|
||||
})
|
||||
.setOrigin(0, 0.5),
|
||||
);
|
||||
};
|
||||
field('NAME', lines.name, -36, true);
|
||||
field('DATE OF BIRTH', lines.dob, 2, true);
|
||||
field('EXPIRES', lines.expiry, 40);
|
||||
|
||||
// The whole ID minigame is one subtraction, and the player cannot do it
|
||||
// without the other operand. Printed on the card itself rather than only on
|
||||
// the desk so both numbers are in the same glance.
|
||||
root.add(
|
||||
scene.add
|
||||
.text(92, 4, `TONIGHT\n${formatDate(this.nightDate)}`, {
|
||||
fontFamily: MONO,
|
||||
fontSize: '8px',
|
||||
color: '#8a3a3a',
|
||||
align: 'center',
|
||||
lineSpacing: 2,
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setAngle(-4),
|
||||
);
|
||||
|
||||
// hologram over the corner — wrong colour is the tell, the desk has a swatch
|
||||
const holoColour = p.idCard.fake?.wrongHologram ? FAKE_HOLOGRAM : GENUINE_HOLOGRAM;
|
||||
const holo = scene.add.rectangle(92, 44, 58, 22, holoColour, 0.55).setAngle(-10);
|
||||
root.add(holo);
|
||||
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));
|
||||
root.add(scene.add.rectangle(CARD_W / 2 - 40, CARD_H / 2 - 8, 5, 3, 0xf6f2e8, 0.8));
|
||||
root.add(scene.add.rectangle(CARD_W / 2 - 52, CARD_H / 2 - 14, 3, 3, 0xf6f2e8, 0.7));
|
||||
}
|
||||
|
||||
const v = idVerdict(p.idCard, this.nightDate);
|
||||
if (v.expired) {
|
||||
root.add(
|
||||
scene.add
|
||||
.text(-40, 62, 'EXPIRED', { fontFamily: MONO, fontSize: '10px', color: '#a02020' })
|
||||
.setOrigin(0, 0.5)
|
||||
.setAngle(-6),
|
||||
);
|
||||
}
|
||||
|
||||
root.add(
|
||||
text(scene, -CARD_W / 2 + 6, CARD_H / 2 - 12, 'click anywhere to put it down', 7, '#6b6558'),
|
||||
);
|
||||
|
||||
root.setScale(0.2);
|
||||
root.setAlpha(0);
|
||||
scene.tweens.add({ targets: root, scale: 1, alpha: 1, duration: 150, ease: 'Back.easeOut' });
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.root?.destroy();
|
||||
this.root = null;
|
||||
this.patron = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference swatch for the desk: what a real hologram looks like. It sits on
|
||||
* the SAME cream backing the card uses — a translucent teal reads completely
|
||||
* differently over dark laminate than over card stock, and a reference the
|
||||
* player can't trust is worse than no reference.
|
||||
*/
|
||||
static holoSwatch(scene: Phaser.Scene, x: number, y: number): Phaser.GameObjects.Container {
|
||||
const c = scene.add.container(x, y);
|
||||
c.add(scene.add.rectangle(0, 0, 38, 15, 0xdad4c4));
|
||||
c.add(scene.add.rectangle(0, 0, 30, 11, GENUINE_HOLOGRAM, 0.55));
|
||||
c.add(scene.add.rectangle(0, 0, 30, 3, 0xffffff, 0.3));
|
||||
c.add(
|
||||
scene.add
|
||||
.text(0, 10, 'GENUINE', { fontFamily: MONO, fontSize: '6px', color: DOOR_PALETTE.inkDim })
|
||||
.setOrigin(0.5, 0),
|
||||
);
|
||||
return c;
|
||||
}
|
||||
|
||||
get currentPatron(): Patron | null {
|
||||
return this.patron;
|
||||
}
|
||||
}
|
||||