Compare commits

...

47 Commits

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:47:53 +10:00
type-two
37f9892309 LANE-CONTENT: the guest list, the moral encounters, the inspector, the paperwork
Phase 3 deliverables 1-4. The door now has something to read rather than a
checklist to tick.

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:30:42 +10:00
type-two
0f7b948439 Phase 2: night-flow integration — door/floor interleave, Kayden, TechnoEngine in the night, run progression; Phase 3 lane instructions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 20:20:18 +10:00
type-two
595371f684 Phase 1 review: merge door/floor/juice, fold CCRs into contracts, integration fixes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 19:26:35 +10:00
type-two
ca8fd75aac Merge branch 'lane/juice'
# Conflicts:
#	LANEHANDOVER.md
#	src/main.ts
2026-07-19 19:15:55 +10:00
type-two
7aff70c1c0 Merge branch 'lane/floor'
# Conflicts:
#	LANEHANDOVER.md
#	src/main.ts
2026-07-19 19:15:01 +10:00
type-two
e0b5831166 LANE-DOOR: land the adversarial review findings
The important one: the dress-code card was highlighting every rule a patron
broke the moment they stepped up, so optimal play was reading a red line off the
desk. That deleted the inspection loop the whole scene exists for. The highlight
now fires after the ruling, as feedback.

Second: tonight's date appeared nowhere in the game, so working out whether an ID
was underage — the tell that files a heat strike and costs the licence — was
guesswork. It is now on the card beside the DOB and under the HUD clock, which
finally makes the boundary cases idCheck.ts was written for reachable.

Also:
- determinism: the partner's generatePatron call sat behind an enqueue-success
  check, and enqueue fails at maxQueue, which is player-controlled — so from the
  first queue overflow the rest of the night's patrons diverged. Same class as
  the comeback-stream bug, fixed for couples and missed for patrons.
- the sobriety modal's backdrop wasn't interactive; you could rule straight
  through it
- tooltips read "navy a blazer" — articles baked into the noun tables, then
  colour-prefixed. They are the only detection channel for four rules under
  placeholder art, so they have to read cleanly.
- bumbag and chain are drawn on the body but were described only in the head zone
- NightScene.endNight pushed heat strikes past core/meters.ts
- dropped the diff's one `as never` for a real type guard (CCR-4)
- replaced a vocabulary-coverage test whose string-equality heuristic silently
  stopped working once a legitimate word matched its own type id

231 tests. Balance findings (vibe pins at 100, stalling is dominant, the slam
never materialises) are measured and written up in LANEHANDOVER.md but not
applied — that is a whole-economy retune and it wants a human playtest, not a
bot with perfect information.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 19:06:31 +10:00
type-two
30162d331d LANE-DOOR: the full v0.1 Door night — queue, ID minigame, dress code, stamps, summary
Playable start to finish: 9 PM to 3 AM, patrons arrive on a curve, you work the
rope, inspect them, and rule. Dazza texts escalate the dress code through the
night, deferred consequences land when you can no longer argue, and the night
ends in a summary or one of two fail states.

Rules engine (src/rules/, pure, no Phaser):
- dressCode: 8 rules whose text/activeFrom are read out of data/strings/dazza.ts
  by ruleId, with a load-time throw if a rule and its announcing text disagree
- idCheck: all ID date maths, built on core/GameClock's ageOn
- judge: the single scoring function, every magnitude named in JUDGE_TUNING
- sobriety: challenge content and the drunk-performance model

Scene layer (src/scenes/door/): NightScene shell, DoorScene, pure QueueManager +
arrivalCurve, patron-up inspection view, ID card, phone, dress-code card,
sobriety modal, stamp, summary.

Tests 49 -> 229.

Ten bugs found by playing it rather than by testing it, including one patron
being ruled eleven times (state was mutating inside a tween callback; it now
settles synchronously), a correctly-played night still rioting at 1:37 AM, and
28% of patrons being deniable for a logo the renderer never drew.

Contract change requests CCR-1..3 are in LANEHANDOVER.md. src/data/types.ts,
src/core/ and docs/ are byte-identical to main; the two additive extensions are
declare-module augmentations. src/main.ts is the one file touched outside the
lane (8 lines, to boot into the night) and is flagged for the reviewer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:49:27 +10:00
type-two
3eee8432d4 LANE-FLOOR: the Floor — patrol, flashlight/UV, cut-off, stalls, pat-down
FloorDemoScene (F from Parade): an 80x45-tile venue you patrol in the dark with
a flashlight cone that reveals detail, plus all three v0.2 infractions and the
UV stamp check. Self-populates from the patron generator; consumes Patron[] only,
so Phase 2 can hand it the real admitted list.

Pure, tested logic (venueMap, cone, player, crowdSim, bangJudge, escalation,
patDown); Phaser layer stays thin (FloorView + three overlays). 120 new tests,
169 total.

Two bugs found by running it rather than by the suite, both fixed:
- bump penalties had a per-agent cooldown but no sim-wide bound, so the leak rate
  scaled with crowd size and an unattended venue pinned vibe to 0 / aggro to 100
  by ~1AM. Sim-wide gate added, with a regression test.
- beat.update() sat below the overlay early-return, freezing the beat clock behind
  modals and making the stall rhythm game unwinnable. Clock and beat now always run.

Also retuned dwell times and the tile/darkness palette for readability, and
dropped Light2D in favour of the cone mask it was fighting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:49:13 +10:00
181 changed files with 26604 additions and 199 deletions

View File

@ -1,6 +1,15 @@
{ {
"version": "0.0.1", "version": "0.0.1",
"configurations": [ "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
View File

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

View File

@ -29,7 +29,21 @@ by parallel "lanes" coordinated through files, reviewed by Fable.
branch. An unpushed session didn't happen. branch. An unpushed session didn't happen.
- Remote is Gitea: `ssh://git@100.71.119.27:222/monster/not-tonight.git`. - 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 - 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`). 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 - 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 The game is Aussie hospitality satire — affectionate, funny, with a moral undertow
(design doc §4.3). If content you're writing reads as mean-spirited rather than (design doc §4.3). If content you're writing reads as mean-spirited rather than
absurd, soften toward absurd. absurd, soften toward absurd.
## Deploy (live since 2026-07-19)
- Live at **https://monsterrobot.games/not-tonight/** (botchat VPS, forum-nginx
bind mount — host path `/home/humanjing/monsterrobot.games/games/not-tonight/`,
`/not-tonight` is a symlink to it; landing tile in `../index.html`).
- Ship: `npm run build && rsync -az --delete dist/ humanjing@100.71.119.27:/home/humanjing/monsterrobot.games/games/not-tonight/`
then cache-busted curl to verify. Hashed asset names make CF cache a non-issue;
index.html is text/html (uncached by CF default).
- Cover art: `covers/nottonight.jpg` + `_riso.jpg` (MODELBEAST flux_local seed
4208 + PIL stamp text/riso pass — recipe in LANEHANDOVER Phase-3 deploy note).

File diff suppressed because it is too large Load Diff

1
art_incoming/README.txt Normal file
View File

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

BIN
art_incoming/arcade.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

BIN
art_incoming/barShelf.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

BIN
art_incoming/boothTable.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
art_incoming/buttBin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
art_incoming/cloak.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

BIN
art_incoming/crate.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
art_incoming/deck.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

BIN
art_incoming/discoball.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

BIN
art_incoming/glassRack.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

BIN
art_incoming/glassStack.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

BIN
art_incoming/mixer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
art_incoming/poster1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 823 KiB

BIN
art_incoming/poster2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

BIN
art_incoming/poster3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

BIN
art_incoming/sinkRow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

BIN
art_incoming/speaker.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 944 KiB

BIN
art_incoming/taps.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

BIN
art_incoming/till.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

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

View File

@ -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 rhythm game consumes `beat:tick`; until integration, LANE-FLOOR uses the
`StubBeatClock` from core (same event, fixed 128 BPM). `StubBeatClock` from core (same event, fixed 128 BPM).
### Phase 2 — Integration (ONE lane, sequential) ### Phase 2 — Integration — ✅ DONE 2026-07-19 (executed by Fable)
Night flow state machine (Door ⇄ Floor interleave, radio pulls, Last Drinks, Night flow machine: Door and Floor run concurrently all night, player toggles
Summary), real audio engine replaces stubs, save/progression, Kayden-at-door via the doorway / floor exit; Kayden rules the real queue while you're inside
simulation while player is on Floor. Playtest pass: one full night start→summary. (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) ### Phase 3 — Content & polish (parallel lanes OPEN)
Archetypes, scripted moral encounters, inspector, incident report memory, regulars, `lanes/LANE_CONTENT.md` (guest list, moral encounters, inspector, incident
venues 24, real pixel art swapped into the paper-doll layer slots, MODELBEAST- report), `lanes/LANE_FLOOR2.md` (fights, smoking area, aggro decay),
assisted art generation if useful. `lanes/LANE_JUICE2.md` (ear pass, ambience, night arc). Later in this phase:
venues 24, 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) ### Phase 4 — The role ladder (design §6; future, do not start)
Glassie, bartender, DJ roles + roster board. Not scoped yet — but it imposes ONE Glassie, bartender, DJ roles + roster board. Not scoped yet — but it imposes ONE

View File

@ -120,9 +120,18 @@ export interface EventMap {
'meters:delta': { vibe?: number; aggro?: number; hype?: number }; 'meters:delta': { vibe?: number; aggro?: number; hype?: number };
'meters:changed': { vibe: number; aggro: number; hype: number }; 'meters:changed': { vibe: number; aggro: number; hype: number };
'heat:strike': HeatStrike; '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 **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 and emits `meters:changed`. HUD listens to `meters:changed` only. This keeps three
lanes from fighting over the same numbers. lanes from fighting over the same numbers.

91
docs/SCENARIOS.md Normal file
View File

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

115
docs/SPACES.md Normal file
View File

@ -0,0 +1,115 @@
# NOT TONIGHT — Spaces Plan
*How the game's two spaces grow from readable-but-abstract into places, using the
fleet's asset factories. Written 2026-07-20. Code contract: everything lands
behind data tables (`venueMap.PROPS` / `LIGHTS`) and a sprite drop-in convention —
generated art replaces placeholders with zero code changes.*
## 1. Design principles
1. **Darkness stays the mechanic.** Props read as silhouettes in ambient dark and
resolve under the torch. Nothing may make the room bright enough to scan from
the doorway.
2. **Navigate by light signature.** Each zone owns a colour of light, so the
player orients by glow before geometry: bar = warm amber · dance floor =
pulsing pink (on the actual beat) · DJ booth = deep blue · toilets =
flickering green fluoro · smoking yard = cool rain-blue spill · entry/exit =
venue pink. The torch is white; UV is purple. No other light sources.
3. **Props are data.** `PropDef { kind, tile pos, footprint, emissive? }` — the
renderer prefers a generated texture (`gen:prop:<kind>`) and falls back to a
procedural placeholder, so the game is complete TONIGHT and gets prettier as
art lands.
4. **Spaces anticipate the role ladder** (design §6): the bar interior is laid
out for a future bartender shift, the DJ booth for the DJ shift, the glassie
station and smoking yard for floor loops that already have lanes speccing them.
## 2. The venue floor (80×45 tiles, one room + pockets)
```
┌──────────────────────────────────────────────────────────────────┐
│ N ═ BAR ══════════ back-shelf glow ┌─ DUNNIES ────────┐│
│ taps · till · glasses │ s1 s2 s3 s4 ││
│ ○○○○○○ stools │ sinks + mirror ││
│ └───door───────────┘│
│ W │
│ ┈entry┈ ┌ DANCE FLOOR (pulsing grid) ┐ ╔═ DJ BOOTH ╗ booths│
│ corridor │ speaker · · · · · speaker │ ║ decks+mixer║ E│
│ posters │ · · · disco ball · · · │ ║ (sealed) ║ booths│
│ cloak │ speaker · · · · · speaker │ ╚════════════╝ │
│ glassie └───────────────────────────┘ booths│
│ station │
│ ▓ SMOKING YARD (open sky, rain) ▓ tables+candles S │
└────────▓ fence · butt bin ▓──────────────────────────────────────┘
```
Zone-by-zone (tile coords in `venueMap.ts`):
| Zone | What's there (props) | Light signature | Future role hook |
|---|---|---|---|
| **Bar** (north run) | back-bar shelf (emissive), 3 beer taps, till, glass stacks | steady amber pools along the shelf | Bartender shift: the serving lane between bar tiles and shelf is already unwalkable-to-patrons |
| **Dance floor** (centre) | 4 corner speaker stacks, disco ball (centre), floor grid cells | pink grid cells pulse ON `beat:tick`; ball throws a slow-rotating glint | DJ role reads crowd here |
| **DJ booth** (east of dance, sealed box facing the floor) | 2 decks + mixer (from the 90sDJsim gear GLBs!), booth monitor, lamp | deep blue wash + VU flicker | The DJ shift's playable pocket |
| **Booths** (south + east walls) | table per booth pair, candle | tiny warm candle pools | — |
| **Dunnies** (NE walled room) | sink row + mirror on west wall, hand dryer | green fluoro, FLICKERS (torch almost redundant inside — deliberate) | — |
| **Smoking yard** (SW walled pocket, open sky) | butt bin, milk crate seats, fence | cool blue outdoor spill + rain particles INSIDE the yard | FLOOR2's smoking loop (specced) |
| **Entry corridor** (west) | poster wall (3 art slots — MODELBEAST posters!), cloakroom window, stamp podium | venue-pink spill from the door | UV re-entry checks happen here |
| **Glassie station** (bar's west end alcove) | glass racks, tub trolley | dim amber | Glassie role's home base |
Sim impact: walkability only changes where NEW walls go (DJ booth box, yard
fence, sink row). All existing anchors keep working; reachability is asserted in
tests. The yard gets a `smoke` anchor upgrade so wanderers actually go out for one.
## 3. The door street (640×360 tableau)
Already reads well. Two upgrades, both drop-in images (no layout change):
1. **Backdrop plate** behind the existing rects: one wide pixel-art street wall
(brick, posters, grime) — swaps in as `gen:street:backdrop` when generated.
2. **Kebab shop interior glow card** in its window + rotating poster art in two
street frames (same poster slots as the entry corridor).
## 4. The sprite drop-in contract (how John's art lands)
- Drop processed PNGs in **`public/props/`** named `<kind>.png` and list them in
**`public/props/manifest.json`** (`{"props": ["deck", "speaker", ...]}`).
- The floor loads the manifest at boot; anything listed replaces its placeholder
(texture key `gen:prop:<kind>`). Missing = placeholder. No code changes.
- **`tools/props_import.py`** (PIL, runs anywhere with python3+PIL — ultra has it)
takes raw generated images → target pixel size + palette quantise + transparent
background → writes `public/props/` + updates the manifest:
`python3 tools/props_import.py incoming/deck_raw.png deck`
- Target sizes are in the script (`TARGETS`): hero props 4864 px, furniture
2432 px, small items 16 px. It refuses unknown kinds so typos can't ship.
## 5. GENERATION QUEUE — John's list (in priority order)
Pipelines: **[MB]** = MODELBEAST `flux_local` (params given) → drop raw output in
`art_incoming/`, run the import script (or hand me the raws — either works).
**[GLB]** = Blender ortho prerender of an existing fleet mesh (camera: orthographic,
top-down 90° for floor props / front 0° for the street, transparent film, ~512px,
neutral even lighting) → same import path.
| # | Item (kind) | Pipeline & source | Notes |
|---|---|---|---|
| 1 | `deck` | [GLB] `ultra:~/Documents/90sDJsim/web/world/models/gear/deck_t3_technics_sl_1200mk2.glb`, top-down | The hero. One render serves both booth decks |
| 2 | `mixer` | [GLB] same dir, `mixer.glb` (or the urei once landed), top-down | Sits between the decks |
| 3 | `speaker` | [GLB] 90sDJsim `models/props/` has boombox/speaker meshes; else [MB] prompt: "top-down view of a large black PA speaker stack, pixel art game sprite, dark background, viewed directly from above" 512×512 seed 4210 | 4 placements |
| 4 | `discoball` | [MB] "mirrored disco ball seen directly from above, glinting, pixel art sprite, black background" 512×512 seed 4211 | Centre of dance floor |
| 5 | `barShelf` | [MB] "top-down bar back-shelf with glowing bottles rows, amber light, pixel art sprite, dark" 1024×256 seed 4212 | Emissive — the amber source |
| 6 | `taps` | [MB] "row of three beer taps on a bar counter viewed from above, pixel art, dark bg" 256×256 seed 4213 | |
| 7 | `boothTable` | [MB] "small round pub table with two drinks seen from directly above, pixel art, dark bg" 256×256 seed 4214 | 6 placements |
| 8 | `sink` | [MB] "public bathroom sink row with mirror viewed from above, green fluorescent light, pixel art, dark" 512×256 seed 4215 | |
| 9 | `buttBin` + `crate` | [MB] one sheet: "outdoor smoking area props: cigarette butt bin and milk crate, top-down pixel art, dark bg" 512×256 seed 4216 | Yard dressing |
| 10 | `poster1..3` | [MB] three runs: "grimy nightclub gig poster, 90s Australian pub rock / techno rave / missing cat, pixel art, portrait" 256×384 seeds 4217-19 | FRONT view (posters hang on walls) — used at door + corridor |
| 11 | `street:backdrop` | [MB] "wide pixel art night street wall: dark brick, torn posters, graffiti, neon reflections, rain, front-on orthographic, game background" 1600×440 seed 4220 | The door scene plate. Biggest single visual win |
| 12 | `cloak` / `glassRack` | [MB] "cloakroom window with hanging jackets / metal glass rack with stacked pint glasses, top-down pixel art, dark" 256×256 seeds 4221-22 | Corridor + glassie station |
| 13 | `arcade` | [GLB] `m3ultra:~/Documents/procity/pipeline/_normalized/procity_fit_arcade_cabinet_01.glb`, top-down | Corner easter egg near entry |
**Ambition tier (later, not blocking):** pre-rendered patron sprite sheets from
the modular character kit + RIGROOM clips (`Searching_Pockets`, boxing pack,
`Leaning_Idle02_Smoking`, zombie stagger for maggots) — replaces the procedural
dolls entirely per docs/ASSETS.md §2. Wants its own session to set the
palette/density once.
*(flux params for all [MB] jobs: `model flux2-klein-4b, steps 4`; put "no text,
no lettering" in every prompt EXCEPT the posters, where mangled text reads as
authentic gig-poster grot.)*

74
lanes/LANE_CONTENT.md Normal file
View 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, 814 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)
23 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 (34 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 14 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
View 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 (23 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 B

BIN
public/props/barShelf.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

BIN
public/props/boothLamp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 B

BIN
public/props/boothTable.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 B

BIN
public/props/buttBin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 B

BIN
public/props/carpet.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
public/props/cloak.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 691 B

BIN
public/props/crate.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 B

BIN
public/props/deck.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
public/props/discoball.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
public/props/dishwasher.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 B

BIN
public/props/glassRack.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
public/props/glassStack.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
public/props/highTable.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 B

View File

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

BIN
public/props/mixer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 B

BIN
public/props/monitor.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 B

BIN
public/props/plant.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 B

BIN
public/props/poolTable.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
public/props/poster1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
public/props/poster2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 851 B

BIN
public/props/poster3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 892 B

BIN
public/props/sinkRow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
public/props/speaker.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 657 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

BIN
public/props/taps.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1001 B

BIN
public/props/till.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 B

BIN
public/props/wetFloor.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 B

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

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

View File

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

View File

@ -1,11 +1,22 @@
import type { EventBus } from '../core/EventBus'; import type { EventBus } from '../core/EventBus';
import { SeededRNG } from '../core/SeededRNG'; import { SeededRNG } from '../core/SeededRNG';
import { type ArcScales, arcAt } from './arc';
import { import {
type AudioLocation, type AudioLocation,
cutoffFor, cutoffFor,
gainFor, gainFor,
sweepMsFor, sweepMsFor,
} from './filterCurve'; } from './filterCurve';
import {
DEFAULT_MIX,
MIX_RANGES,
type TrackMix,
VOICE_NAMES,
type VoiceName,
clampTo,
cloneMix,
voiceGain,
} from './mix';
import { import {
type BeatGrid, type BeatGrid,
barOf, barOf,
@ -16,7 +27,6 @@ import {
patternStep, patternStep,
subdivisions, subdivisions,
} from './scheduling'; } from './scheduling';
import '../ui/juiceEvents';
const DEFAULT_BPM = 128; const DEFAULT_BPM = 128;
const DEFAULT_LOOKAHEAD_MS = 100; const DEFAULT_LOOKAHEAD_MS = 100;
@ -30,28 +40,33 @@ const TIMER_MS = 25;
*/ */
const START_LEAD_MS = 60; const START_LEAD_MS = 60;
const MASTER_GAIN = 0.8;
// Resonance: a hair of peak at the cutoff is what reads as "bass through a wall"
// rather than "someone turned the treble down". Flat once you're on the floor.
const DOOR_Q = 6;
const FLOOR_Q = 0.7;
const KICK_PEAK = 0.9;
const HAT_PEAK = 0.25;
const BASS_PEAK = 0.11;
const PAD_PEAK = 0.05;
const SILENT = 0.0001; // exponential ramps cannot reach 0 const SILENT = 0.0001; // exponential ramps cannot reach 0
const BASS_ROOT_HZ = 55; // A1
const BASS_STEPS = 32; // 2 bars of 16ths const BASS_STEPS = 32; // 2 bars of 16ths
const PAD_BARS = 8;
/** Seconds to the top of the bass filter sweep. Shape, not level — not tunable. */
const BASS_SWEEP_S = 0.03;
/** /**
* Semitones from A1, one entry per 16th. Nothing lands on a downbeat the kick * The bass tail settles a touch above the sweep floor: `filterLoHz` is where the
* owns that slot, and leaving it empty is what makes the bassline roll instead * note *starts*, and resting exactly there makes the release sound like a gate.
* of thud. * 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)[] = [ const BASS_PATTERN: readonly (number | null)[] = [
null, null, 0, 0, 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' }, { hz: 164.81, detune: 0, type: 'triangle' },
]; ];
/** Widened once so a dotted path can be looked up without a cast at each site. */
const RANGES: Record<string, readonly [number, number] | undefined> = MIX_RANGES;
const GROUP_NAMES: readonly VoiceName[] = VOICE_NAMES;
interface MixLens {
read: () => number;
write: (v: number) => void;
range: readonly [number, number];
}
/**
* The engine currently owning the AudioContext, or null.
*
* The tuning desk has to reach the engine during a real night, when the scene
* that constructed it belongs to another lane and there is no route to it. A
* module variable is the smallest thing that solves that deliberately not a
* window global, which would make it part of the page's public surface.
*/
let activeEngine: TechnoEngine | null = null;
export const getActiveEngine = (): TechnoEngine | null => activeEngine;
export interface TechnoEngineOptions { export interface TechnoEngineOptions {
bpm?: number; bpm?: number;
lookaheadMs?: number; lookaheadMs?: number;
@ -86,6 +124,10 @@ export interface TechnoEngineOptions {
* future. That timer is throttled on a blurred tab exactly as rAF is, so the * future. That timer is throttled on a blurred tab exactly as rAF is, so the
* look-ahead alone does not save us; what does is `resync()`, which drops the * look-ahead alone does not save us; what does is `resync()`, which drops the
* span that elapsed while we were asleep instead of dumping it into the graph. * span that elapsed while we were asleep instead of dumping it into the graph.
*
* Every level and shape comes off a live `TrackMix` scaled by the night arc, so
* the track can be retuned and auditioned mid-playback. Nothing is read at
* construction that is not re-read on the next beat.
*/ */
export class TechnoEngine { export class TechnoEngine {
readonly ctx: AudioContext; readonly ctx: AudioContext;
@ -106,6 +148,12 @@ export class TechnoEngine {
*/ */
private readonly voices = new Map<AudioScheduledSourceNode, AudioNode[]>(); private readonly voices = new Map<AudioScheduledSourceNode, AudioNode[]>();
private mixState: TrackMix = cloneMix(DEFAULT_MIX);
private clockMinute = 0;
private pinnedMinute: number | null = null;
/** Cached so the scheduler is not re-interpolating the arc ~20 times a second. */
private scales: ArcScales = arcAt(0);
private timer: ReturnType<typeof setInterval> | null = null; private timer: ReturnType<typeof setInterval> | null = null;
private nextBeat = 0; private nextBeat = 0;
private loc: AudioLocation = 'door'; private loc: AudioLocation = 'door';
@ -123,13 +171,13 @@ export class TechnoEngine {
this.grid = { bpm: this.bpm, originMs: 0 }; this.grid = { bpm: this.bpm, originMs: 0 };
this.master = this.ctx.createGain(); this.master = this.ctx.createGain();
this.master.gain.value = MASTER_GAIN; this.master.gain.value = this.mixState.master;
this.master.connect(this.ctx.destination); this.master.connect(this.ctx.destination);
this.filter = this.ctx.createBiquadFilter(); this.filter = this.ctx.createBiquadFilter();
this.filter.type = 'lowpass'; this.filter.type = 'lowpass';
this.filter.frequency.value = cutoffFor(this.loc); this.filter.frequency.value = cutoffFor(this.loc);
this.filter.Q.value = DOOR_Q; this.filter.Q.value = this.qFor(this.loc);
this.filter.connect(this.master); this.filter.connect(this.master);
this.musicBus = this.ctx.createGain(); this.musicBus = this.ctx.createGain();
@ -145,7 +193,14 @@ export class TechnoEngine {
this.unsubs.push( this.unsubs.push(
this.bus.on('audio:location', ({ location }) => this.setLocation(location)), this.bus.on('audio:location', ({ location }) => this.setLocation(location)),
this.bus.on('clock:tick', ({ clockMin }) => {
this.clockMinute = clockMin;
if (this.pinnedMinute === null) this.scales = arcAt(clockMin);
}),
); );
// eslint-disable-next-line @typescript-eslint/no-this-alias -- the registry is the point
activeEngine = this;
} }
get running(): boolean { get running(): boolean {
@ -164,6 +219,76 @@ export class TechnoEngine {
return this.loc; return this.loc;
} }
// --- mix ------------------------------------------------------------------
get mix(): Readonly<TrackMix> {
return this.mixState;
}
/**
* Set one knob by dotted path: `'master'`, `'levels.<voice>'`,
* `'<voice>.<field>'`, `'doorQ'`, `'floorQ'`.
*
* An unknown path is inert rather than fatal the tuning desk is generated
* from a table, and a typo there should cost one dead slider, not the night.
*/
setMixValue(path: string, value: number): void {
const lens = this.lens(path);
if (!lens) return;
// Quantise here rather than at the slider: what we store is what formatMix
// dumps, so a fractional bar count would export a value nobody ever heard.
// The engine owns the invariant because it is the thing that rounds at use.
const v = clampTo(INTEGER_PATHS.has(path) ? Math.round(value) : value, lens.range);
lens.write(v);
// Levels and shapes land on the next scheduled note (<500ms, inaudible as
// lag). These two are continuous, so they have to move now.
if (path === 'master') this.ramp(this.master.gain, v, KNOB_RAMP_S, false);
if (path === this.loc + 'Q' && this.override === null) {
this.ramp(this.filter.Q, v, KNOB_RAMP_S, false);
}
}
/** NaN for an unknown path — a wrong-but-plausible 0 would read as a real value. */
getMixValue(path: string): number {
return this.lens(path)?.read() ?? Number.NaN;
}
setVoiceEnabled(voice: VoiceName, on: boolean): void {
this.mixState.enabled[voice] = on;
}
resetMix(): void {
this.mixState = cloneMix(DEFAULT_MIX);
this.ramp(this.master.gain, this.mixState.master, KNOB_RAMP_S, false);
if (this.override === null) {
this.ramp(this.filter.Q, this.qFor(this.loc), KNOB_RAMP_S, false);
}
}
// --- night arc ------------------------------------------------------------
/**
* Pin the arc to a minute for the ear pass this is how 1 AM gets auditioned
* at 9 PM. null hands the arc back to `clock:tick`.
*/
setArcMinute(min: number | null): void {
this.pinnedMinute = min;
this.scales = arcAt(this.arcMinute);
}
get arcMinute(): number {
return this.pinnedMinute ?? this.clockMinute;
}
get arcScales(): ArcScales {
return { ...this.scales };
}
get arcPinned(): boolean {
return this.pinnedMinute !== null;
}
/** /**
* Resume after a real user gesture. Idempotent, and safe to call concurrently * Resume after a real user gesture. Idempotent, and safe to call concurrently
* overlapping callers await the same resume rather than racing the origin. * overlapping callers await the same resume rather than racing the origin.
@ -179,7 +304,9 @@ export class TechnoEngine {
this.grid.originMs = atMs + START_LEAD_MS; this.grid.originMs = atMs + START_LEAD_MS;
this.nextBeat = 0; this.nextBeat = 0;
this.unlockedFlag = true; 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 { try {
@ -227,7 +354,7 @@ export class TechnoEngine {
const durS = immediate ? 0 : sweepMsFor(location) / 1000; const durS = immediate ? 0 : sweepMsFor(location) / 1000;
this.ramp(this.filter.frequency, cutoffFor(location), durS, true); this.ramp(this.filter.frequency, cutoffFor(location), durS, true);
this.ramp(this.musicBus.gain, gainFor(location), durS, false); this.ramp(this.musicBus.gain, gainFor(location), durS, false);
this.ramp(this.filter.Q, 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. */ /** Non-null pins the cutoff and suppresses location sweeps; null hands it back. */
@ -244,6 +371,7 @@ export class TechnoEngine {
this.stop(); this.stop();
for (const off of this.unsubs) off(); for (const off of this.unsubs) off();
this.unsubs.length = 0; this.unsubs.length = 0;
if (activeEngine === this) activeEngine = null;
void this.ctx.close().catch(() => undefined); void this.ctx.close().catch(() => undefined);
} }
@ -289,104 +417,148 @@ export class TechnoEngine {
this.nextBeat = Math.ceil(firstFuture / 4) * 4; this.nextBeat = Math.ceil(firstFuture / 4) * 4;
} }
/**
* Effective gain for a voice right now: level, mute and arc folded together.
* Shared with the tuning desk via mix.ts so the number on screen is the number
* the scheduler uses.
*/
private gainOf(voice: VoiceName): number {
return voiceGain(this.mixState, voice, this.scales[voice]);
}
private scheduleBeat(beatIndex: number, timeMs: number): void { private scheduleBeat(beatIndex: number, timeMs: number): void {
const sixteenths = subdivisions(this.grid, beatIndex, 4); const sixteenths = subdivisions(this.grid, beatIndex, 4);
const mix = this.mixState;
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 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 bassGain = this.gainOf('bass');
const at = sixteenths[s]; if (bassGain > SILENT) {
const semi = BASS_PATTERN[patternStep(beatIndex, s, BASS_STEPS)]; for (let s = 0; s < sixteenths.length; s++) {
if (at === undefined || semi === undefined || semi === null) continue; const at = sixteenths[s];
this.bass(at / 1000, BASS_ROOT_HZ * Math.pow(2, semi / 12)); 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 --------------------------------------------------------------- // --- voices ---------------------------------------------------------------
private kick(at: number): void { private kick(at: number, peak: number): void {
const k = this.mixState.kick;
const osc = this.ctx.createOscillator(); const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain(); const gain = this.ctx.createGain();
osc.type = 'sine'; osc.type = 'sine';
// The pitch drop is the beater; the tail is the cone. This is the one voice // The pitch drop is the beater; the tail is the cone. This is the one voice
// that has to survive a 250Hz lowpass, so it gets a long body. // that has to survive a 250Hz lowpass, so it gets a long body.
osc.frequency.setValueAtTime(150, at); osc.frequency.setValueAtTime(k.startHz, at);
osc.frequency.exponentialRampToValueAtTime(50, at + 0.06); 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.setValueAtTime(SILENT, at);
gain.gain.exponentialRampToValueAtTime(KICK_PEAK, at + 0.008); gain.gain.exponentialRampToValueAtTime(peak, at + k.attackS);
gain.gain.exponentialRampToValueAtTime(SILENT, at + 0.34); gain.gain.exponentialRampToValueAtTime(SILENT, at + decayS);
osc.connect(gain).connect(this.musicBus); osc.connect(gain).connect(this.musicBus);
osc.start(at); osc.start(at);
osc.stop(at + 0.36); osc.stop(at + decayS + 0.02);
this.hold(osc, [gain]); 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(); const src = this.ctx.createBufferSource();
src.buffer = this.noiseBuffer; src.buffer = this.noiseBuffer;
// Cheap variety: start the read head somewhere different each hit so the
// one shared buffer doesn't ring identically 400 times a minute.
const offset = (at * 7.3) % (this.noiseBuffer.duration - 0.1);
const hp = this.ctx.createBiquadFilter(); const hp = this.ctx.createBiquadFilter();
hp.type = 'highpass'; hp.type = 'highpass';
hp.frequency.value = 7000; hp.frequency.value = h.hpHz;
const gain = this.ctx.createGain(); const gain = this.ctx.createGain();
gain.gain.setValueAtTime(HAT_PEAK, at); gain.gain.setValueAtTime(peak, at);
gain.gain.exponentialRampToValueAtTime(SILENT, at + 0.04); gain.gain.exponentialRampToValueAtTime(SILENT, at + h.decayS);
src.connect(hp).connect(gain).connect(this.musicBus); 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]); 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(); const lp = this.ctx.createBiquadFilter();
lp.type = 'lowpass'; 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. // Per-note filter sweep — the "wow" that stops 16ths sounding like a fax.
lp.frequency.setValueAtTime(180, at); lp.frequency.setValueAtTime(b.filterLoHz, at);
lp.frequency.exponentialRampToValueAtTime(900, at + 0.03); lp.frequency.exponentialRampToValueAtTime(b.filterHiHz, at + BASS_SWEEP_S);
lp.frequency.exponentialRampToValueAtTime(200, at + 0.11); lp.frequency.exponentialRampToValueAtTime(b.filterLoHz * BASS_TAIL_RATIO, at + decayS - 0.01);
const gain = this.ctx.createGain(); const gain = this.ctx.createGain();
gain.gain.setValueAtTime(SILENT, at); gain.gain.setValueAtTime(SILENT, at);
gain.gain.exponentialRampToValueAtTime(BASS_PEAK, at + 0.006); gain.gain.exponentialRampToValueAtTime(peak, at + 0.006);
gain.gain.exponentialRampToValueAtTime(SILENT, at + 0.12); gain.gain.exponentialRampToValueAtTime(SILENT, at + decayS);
lp.connect(gain).connect(this.musicBus); 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(); const osc = this.ctx.createOscillator();
osc.type = 'sawtooth'; osc.type = 'sawtooth';
osc.frequency.value = hz; osc.frequency.value = hz;
osc.detune.value = detune; osc.detune.value = detune;
osc.connect(lp); osc.connect(lp);
osc.start(at); osc.start(at);
osc.stop(at + 0.13); osc.stop(at + decayS + 0.01);
this.hold(osc, detune > 0 ? [lp, gain] : []); // 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 { private pad(at: number, peak: number, bars: number): void {
const cycle = (PAD_BARS * 4 * beatIntervalMs(this.bpm)) / 1000; const cycle = (bars * 4 * beatIntervalMs(this.bpm)) / 1000;
const lp = this.ctx.createBiquadFilter(); const lp = this.ctx.createBiquadFilter();
lp.type = 'lowpass'; lp.type = 'lowpass';
lp.frequency.value = 600; lp.frequency.value = this.mixState.pad.lpHz;
const gain = this.ctx.createGain(); const gain = this.ctx.createGain();
gain.gain.setValueAtTime(0, at); 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); gain.gain.linearRampToValueAtTime(0, at + cycle);
lp.connect(gain).connect(this.musicBus); lp.connect(gain).connect(this.musicBus);
@ -405,6 +577,44 @@ export class TechnoEngine {
// --- plumbing ------------------------------------------------------------- // --- plumbing -------------------------------------------------------------
private qFor(location: AudioLocation): number {
return location === 'floor' ? this.mixState.floorQ : this.mixState.doorQ;
}
/** Resolve a dotted mix path to a get/set/clamp triple, or null if it is not a knob. */
private lens(path: string): MixLens | null {
const m = this.mixState;
if (path === 'master' || path === 'doorQ' || path === 'floorQ') {
const range = RANGES[path];
if (!range) return null;
const key = path;
return { read: () => m[key], write: (v) => { m[key] = v; }, range };
}
const dot = path.indexOf('.');
if (dot <= 0) return null;
const head = path.slice(0, dot);
const tail = path.slice(dot + 1);
if (head === 'levels') {
// MIX_RANGES keys the shared bound as 'level' (singular) — one range for
// all four faders — while the path is per-voice.
if (!(VOICE_NAMES as readonly string[]).includes(tail)) return null;
const v = tail as VoiceName;
return { read: () => m.levels[v], write: (x) => { m.levels[v] = x; }, range: MIX_RANGES.level };
}
if (!(GROUP_NAMES as readonly string[]).includes(head)) return null;
// The four param groups have no index signature by design — the widening is
// confined here, behind the hasOwn check below.
const group = m[head as VoiceName] as unknown as Record<string, number>;
if (!Object.hasOwn(group, tail)) return null;
const range = RANGES[path];
if (!range) return null;
return { read: () => group[tail] ?? Number.NaN, write: (x) => { group[tail] = x; }, range };
}
/** /**
* One second of deterministic white noise, built once and re-read per hat. * One second of deterministic white noise, built once and re-read per hat.
* Allocating a buffer per hit would mean ~8 buffers a second for six hours. * Allocating a buffer per hit would mean ~8 buffers a second for six hours.

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

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

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

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

View File

@ -18,6 +18,19 @@ export function freshNightState(venueId: string, nightIndex: number, licensed: n
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
/**
* Vibe per clock-minute the room loses on its own (emit from the night's
* minute tick). Nobody stays impressed: above the baseline the room cools, so
* a lit room is a job you keep doing, not a ratchet you finish. Never positive
* a dead floor does not heal itself (tuning pass 2026-07-19; before this,
* any net-positive night pinned vibe at 100 by 10 PM and the meter went dead).
*/
export function vibeCoolingPerMin(vibe: number): number {
if (vibe > 70) return -0.8;
if (vibe > 50) return -0.4;
return 0;
}
// Single writer for the night's numbers. Scenes emit meters:delta / heat:strike; // Single writer for the night's numbers. Scenes emit meters:delta / heat:strike;
// nothing else may mutate NightState meters directly. // nothing else may mutate NightState meters directly.
export class Meters { export class Meters {
@ -36,6 +49,9 @@ export class Meters {
bus.on('night:phaseChange', ({ location }) => { bus.on('night:phaseChange', ({ location }) => {
this.state.location = location; this.state.location = location;
}), }),
bus.on('incident:log', (incident) => {
this.state.incidents.push(incident);
}),
bus.on('door:clicker', ({ direction }) => { bus.on('door:clicker', ({ direction }) => {
this.state.capacity.clickerShown = Math.max( this.state.capacity.clickerShown = Math.max(
0, 0,

View File

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

View File

@ -59,7 +59,7 @@ export const OUTFIT_VOCAB: Record<OutfitSlot, readonly OutfitTypeDef[]> = {
], ],
outer: [ outer: [
{ type: 'none', weight: 6, colours: ['black'] }, { type: 'none', weight: 6, colours: ['black'] },
{ type: 'blazer', weight: 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: 'bomber', weight: 2, colours: ['black', 'green', 'navy'], canLogo: true, canVintage: true },
{ type: 'hoodie', weight: 2, colours: ['black', 'grey', 'red'], canLogo: true }, { type: 'hoodie', weight: 2, colours: ['black', 'grey', 'red'], canLogo: true },
{ type: 'leather', weight: 1, colours: ['black', 'brown'], canVintage: true }, { type: 'leather', weight: 1, colours: ['black', 'brown'], canVintage: true },
@ -75,9 +75,9 @@ export const OUTFIT_VOCAB: Record<OutfitSlot, readonly OutfitTypeDef[]> = {
], ],
accessory: [ accessory: [
{ type: 'none', weight: 6, colours: ['black'] }, { type: 'none', weight: 6, colours: ['black'] },
{ type: 'bucketHat', weight: 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: '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: 'bumbag', weight: 1, colours: ['black', 'purple', 'yellow'], canLogo: true, canVintage: true },
{ type: 'chain', weight: 1, colours: ['yellow', 'grey'] }, { type: 'chain', weight: 1, colours: ['yellow', 'grey'] },
], ],

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

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

View File

@ -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-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-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-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-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-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-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 // vibe commentary
{ id: 'vibe-mid', text: 'why is the floor MID rn. fix it', fromMin: 60 }, { id: 'vibe-mid', text: 'why is the floor MID rn. fix it', fromMin: 60 },

276
src/data/strings/door.ts Normal file
View 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',
];

View File

@ -0,0 +1,276 @@
// Scripted-encounter dialogue (design §4.3). These are the most rewritten strings
// in the project and the ones a content pass is most likely to ruin.
//
// The register, in order of importance:
// - Nobody begs. Every one of these people is competent, has a plan, and is
// offering you information rather than asking for mercy.
// - The game never says which call was right. `admitNote`/`denyNote` land on the
// verdict toast, so they report what happened next and stop. No verdict on the
// player, no reassurance either — reassurance is a verdict wearing a smile.
// - Dazza may judge freely. He is a character inside the fiction and he is wrong
// constantly; that is not the game speaking.
//
// `afterMs` is measured from the moment the patron reaches the rope, not from the
// previous beat, so a skipped line cannot compress the ones after it.
export interface EncounterScript {
/** Licence names to draw from — the same person, spelled a few different ways. */
names: readonly string[];
beats: readonly { readonly line: string; readonly afterMs: number }[];
admitNote?: string;
admitDazza?: string;
denyNote?: string;
denyDazza?: string;
/** Coin-flip encounters (real/fake cop, rocket/paps): notes per variant key.
* The variant is decided at dress() time and carried on patron.flags. */
variantNotes?: Record<string, { admitNote?: string; admitDazza?: string; denyNote?: string; denyDazza?: string }>;
}
export const ENCOUNTER_SCRIPTS: Record<string, EncounterScript> = {
// She is not asking to party. She is asking for ninety seconds and a jacket,
// and she already knows the stamp rule better than you do.
grabHerMate: {
names: ['Bec Doyle', 'Steph Vella', 'Aleisha Marsh'],
beats: [
{ line: 'i was inside ten minutes ago. no stamp, i know.', afterMs: 0 },
{ line: "my mate's on a date in there thats gone bad. shes done the toilet text twice.", afterMs: 2200 },
{ line: "ninety seconds. i get her and her jacket and we're both gone.", afterMs: 4600 },
],
// Reports the stamp, not the room: nothing here can see the clicker count,
// and a note that asserts capacity is simply false half the time.
admitNote: 'Back in without a stamp. Nothing on the door says she was ever inside.',
admitDazza: 'the count and the clicker dont agree mate. i count from the office. i COUNT',
denyNote: 'She waits under the awning across the road, then walks toward the taxi rank.',
},
// The dumped bloke. The tell is the over-explaining, not the swaying. He says
// the title of the game and does not notice he said it.
quietBeer: {
names: ['Trent Petersen', 'Dylan Marsh', 'Cam Duffy'],
beats: [
{ line: 'evening. just the one, then im off.', afterMs: 0 },
{ line: 'ive had two at the RSL. before you ask.', afterMs: 2100 },
{ line: 'no, no one with me. not tonight.', afterMs: 4400 },
{ line: 'one beer. thats the whole plan.', afterMs: 6600 },
],
admitNote: 'Loose at the rope. Where it goes from here is between him and the bar.',
admitDazza: 'theres a bloke at the end of the bar not drinking his drink. keep half an eye',
denyNote: 'He says no dramas. He is at the kebab shop ten minutes later.',
},
// Seven years, and he has brought a hip flask to shout the bar staff with. He
// tells you about it, which is the entire trap — you cannot un-know it.
lastNight: {
names: ['Warren Kowalski', 'Raymond Vella', 'Gary Duffy'],
beats: [
{ line: 'last one, mate. flights booked. perth on thursday.', afterMs: 0 },
{ line: 'seven years ive been coming here. you knocked me back once, back in nineteen. fair enough too.', afterMs: 2400 },
{ line: 'i brought this for sue behind the bar. she reckons she wants a proper send off.', afterMs: 4800 },
],
admitNote: 'The flask went in with him.',
denyNote: 'He waves it off. In the car park he opens the flask himself.',
},
// Off a twelve-hour shift in a branded work polo, which the dress code has had
// an opinion about since 10:30. He knows. He says so before you can.
nightShift: {
names: ['Jared Karim', 'Tui Taufa', 'Sam Nguyen'],
beats: [
{ line: 'sorry. straight off shift. i know how it looks.', afterMs: 0 },
{ line: 'twelve hours. the polos got the servo on it, cant do much about that.', afterMs: 2300 },
{ line: 'one beer, near the door, i wont go near the dancefloor. i dont have it in me.', afterMs: 4700 },
],
admitNote: 'In he goes, logo and all, with the sign already up.',
denyNote: 'He nods, says fair enough, and walks back toward the servo.',
},
// Eighteen at midnight, at the rope by quarter past. Real card, real birthday,
// and a plan he has clearly rehearsed on the walk over.
eighteenToday: {
names: ['Jai Petersen', 'Cooper Nguyen', 'Lachy Marsh'],
beats: [
{ line: 'evening. so. it went midnight about forty minutes ago.', afterMs: 0 },
{ line: 'which makes this my first legal one. checked the law twice.', afterMs: 2400 },
{ line: 'my mates did the countdown at the kebab shop. they went home. im here.', afterMs: 5000 },
],
admitNote: 'First stamp on a first night. He shows the bar staff his licence unprompted.',
denyNote: 'Legal for forty minutes and headed home. The story will improve with age.',
},
// Kayden has been making promises. Kayden does not have list privileges.
// Kayden does not, strictly speaking, have a last name anyone can recall.
kaydensMate: {
names: ['Reece Kowalski', 'Jordan Vella', 'Tyson Doyle'],
beats: [
{ line: 'should be right, kayden said hed leave my name at the door.', afterMs: 0 },
{ line: 'kayden. tall bloke. trainee vest. very confident handshake.', afterMs: 2300 },
{ line: 'he said quote unquote "sorted mate". thats basically a guest list.', afterMs: 4800 },
],
admitNote: 'In on the authority of a man whose supervisor calls him "the situation".',
admitDazza: 'a bloke just told the bar KAYDEN comped him. kayden cant comp a glass of water',
denyNote: 'He takes it well. Says he will "raise it with kayden". Nobody will.',
},
// The buck's party straggler. Missed the church bit, missed dinner, caught the
// food poisoning. The buck is inside; the night is nearly over; here he is.
bigNightOut: {
names: ['Macca OBrien', 'Davo Romano', 'Nick Taufa'],
beats: [
{ line: 'the bucks is in there. i can hear the chanting. thats my chanting.', afterMs: 0 },
{ line: 'i missed the church bit. food poisoning at the dinner. long story. bad oysters.', afterMs: 2500 },
{ line: 'ive had a big recovery and two lemonades and i am READY to be present.', afterMs: 5200 },
],
admitNote: 'The chanting gets louder, gains a name in it, then settles.',
denyNote: 'He listens to one more chant through the wall, films a voice memo of it, and goes.',
},
// Engaged tonight. Still holding hands. The card says what the card says.
twoOfThem: {
names: ['Bree Karim', 'Holly Petersen', 'Sana Singh'],
beats: [
{ line: 'before you say anything about the hand thing. we got engaged tonight.', afterMs: 0 },
{ line: 'TONIGHT tonight. at the lookout. theres a photo of a bird ruining it.', afterMs: 2400 },
{ line: 'we are aware of the rule. we have discussed it. the answer is no.', afterMs: 5000 },
],
admitNote: 'They cross the floor like a three-legged race nobody can catch.',
denyNote: 'They leave still attached. The rule holds. The rule is the only thing that does.',
},
// The badge is real or it cost $30, and the licence is the only thing at the
// rope that knows which. He is polite either way. That is the problem.
offDutyCop: {
names: ['Marcus Bell', 'Marc Bell', 'M. Bell'],
beats: [
{ line: "evening. job's done for the night. just after a quiet one.", afterMs: 0 },
{ line: 'you can look at the badge if you want. most dont.', afterMs: 2400 },
{ line: 'take your time with the licence. i respect the process.', afterMs: 5000 },
],
variantNotes: {
real: {
admitNote: 'He orders soda water and stands where he can see both exits.',
denyNote: 'He nods once. "Good call. Always." He means it.',
},
fake: {
admitNote: 'The badge says JUNIOR DETECTIVE on the back. Nobody looks at the back.',
denyNote: 'He argues, then folds. The badge goes back in the bumbag.',
},
},
},
// Cap low, sunnies on, at night. Two live rule breaches and a face you
// almost place. Denying is free forever. Admitting is a coin you cannot see.
celebrity: {
names: ['Ky Trainor', 'Kai Trainor', 'K. Trainor'],
beats: [
{ line: 'hey. so. yeah.', afterMs: 0 },
{ line: "it'd be really good if this could be quick.", afterMs: 2200 },
{ line: 'the sunnies stay on. sorry. they sort of have to.', afterMs: 4600 },
],
variantNotes: {
rocket: { admitNote: 'Halfway to the bar, someone does a double-take.' },
paps: {
admitNote: 'A ute pulls up across the road. Then another one.',
admitDazza: 'why are there seventeen blokes with cameras outside. WHY',
},
},
denyNote: 'The cap goes lower and they walk. You will never know who that was.',
},
// An aggro engine in a polo. His licence is a laminated business card and he
// is PROUD of it. Nothing about him is a question the desk can answer.
sovereign: {
names: ['Gregory-John: Harmon', 'G-J: Harmon', 'Gregory-John of Harmon'],
beats: [
{ line: 'before we begin: i do not consent to this dress code.', afterMs: 0 },
{ line: 'your rope is an admiralty flag. i am a living man, travelling.', afterMs: 2600 },
{ line: 'i will be entering under common law. watch.', afterMs: 5200 },
],
admitNote: 'He steps over the rope rather than through the gap. On principle.',
denyNote: 'He accepts no jurisdiction, but does, eventually, accept the footpath.',
},
// The thermal bag says a name. The name is "DJ". The booth lamp flickers.
deliveryRider: {
names: ['Sanjay Pillai', 'San Pillai', 'S. Pillai'],
beats: [
{ line: 'delivery for "DJ". thats it. just says DJ.', afterMs: 0 },
{ line: 'its getting cold mate. the bag only does so much.', afterMs: 2400 },
{ line: 'ive got four more jobs on. yes or no.', afterMs: 4800 },
],
variantNotes: {
ordered: {
admitNote: 'The booth lamp stops flickering. So. He HAD ordered.',
denyNote: 'The rider shrugs and eats it on the kerb. It looked incredible.',
},
not: {
admitNote: 'He wanders the floor with the bag, calling a name nobody has.',
denyNote: 'He checks the address, swears with real feeling, and rides off.',
},
},
},
// Two clipboards enter. One leaves.
promoter: {
names: ['Tyson Vale', 'Ty Vale', 'T. Vale'],
beats: [
{ line: 'evening chief. tyson. i run thursdays at elevate.', afterMs: 0 },
{ line: 'youve got a list. ive got a list. mine is realer.', afterMs: 2600 },
{ line: 'roll of wristbands here says we can help each other.', afterMs: 5200 },
],
admitNote: 'He shakes your hand like something just went well for him.',
admitDazza: 'who is TYSON and why do nine people think his wristband beats my list',
denyNote: 'He films a story outside: "this venue hates culture." 41 views.',
},
// Pyjamas, at the rope, at 1 AM. The only person all night who wants LESS
// club. She is not wrong about the bass.
neighbour: {
names: ['Carol Whitford', 'Caz Whitford', 'C. Whitford'],
beats: [
{ line: 'im not coming in. lord, no.', afterMs: 0 },
{ line: 'number 12, across the laneway. its the bass. my GLASSES are walking.', afterMs: 2600 },
{ line: 'either it comes down or i ring the council again. your choice, love.', afterMs: 5400 },
],
admitNote: 'She stands in the booth doorway pointing at the subwoofer until it comes down 2dB. Peace, of a kind.',
denyNote: 'She photographs the queue on her way home. For the file.',
},
// Wrong venue, right energy, seven matching sashes. A vibe grenade with a
// random timer, and the pin is in your hand.
hensParty: {
names: ['Melissa Tran', 'Mel Tran', 'M. Tran'],
beats: [
{ line: 'ok so, small thing: this is not the venue on the invitation.', afterMs: 0 },
{ line: 'HOWEVER. the venue on the invitation has a queue.', afterMs: 2400 },
{ line: 'there are seven of us and we have practised a dance.', afterMs: 4800 },
],
admitNote: 'Seven sashes file past. The bar staff make eye contact with god.',
denyNote: 'They find the right venue. You hear the scream of joy from two streets over.',
},
// Genuinely twins. Everything the photo check believes is suddenly on trial,
// and both of them have done this bit at every door in the Valley.
twins: {
names: ['Jesse Kearney', 'Jordan Kearney'],
beats: [
{ line: 'evening. before you ask: yes. there is two of me.', afterMs: 0 },
{ line: 'my brothers parking the car. we get this a LOT.', afterMs: 2600 },
{ line: 'same face, different bloke. this licence is mine. his is his.', afterMs: 5200 },
],
admitNote: 'One of two, in. That face will be back in five minutes under a different name.',
denyNote: 'He rings his brother from the kerb: "told you. same face, no entry."',
},
// Fourteen years on this exact door. His tips are right about eighty percent
// of the time, which is the joke, and also fourteen years of doors.
exDoorman: {
names: ['Frank Oxley', 'Franko Oxley', 'F. Oxley'],
beats: [
{ line: 'fourteen years i worked this exact door. before the rebrand.', afterMs: 0 },
{ line: 'dont mind me. just gonna watch a few. old times.', afterMs: 2600 },
{ line: 'ill be by the planter. tip or two, free of charge.', afterMs: 5200 },
],
admitNote: 'He does one lap inside and comes straight back out to the planter. The door IS the venue, for him.',
denyNote: 'Completely unbothered. He sets up by the planter with a dart. "Better view out here anyway."',
},
};

420
src/data/strings/floor.ts Normal file
View 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;

View File

@ -22,6 +22,8 @@ export interface IdFakeTells {
wrongHologram?: boolean; wrongHologram?: boolean;
jokeName?: boolean; jokeName?: boolean;
photoMismatch?: boolean; photoMismatch?: boolean;
/** The same card was already admitted tonight (passback — set by the door). */
passback?: boolean;
} }
export interface IdCard { export interface IdCard {
@ -40,6 +42,13 @@ export interface PatronFlags {
uvStamped?: boolean; uvStamped?: boolean;
timesDeniedBefore?: number; timesDeniedBefore?: number;
disguised?: boolean; 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 { export interface Patron {
@ -59,6 +68,8 @@ export interface Patron {
export interface DressCodeRule { export interface DressCodeRule {
id: string; id: string;
text: string; text: string;
/** Card label for the on-screen dress-code board (CCR-4). */
short?: string;
activeFrom: number; // clock minutes since 21:00 activeFrom: number; // clock minutes since 21:00
violates: (p: Patron) => boolean; violates: (p: Patron) => boolean;
} }
@ -73,6 +84,8 @@ export interface HeatStrike {
export interface VerdictOutcome { export interface VerdictOutcome {
vibeDelta: number; vibeDelta: number;
aggroDelta: number; aggroDelta: number;
/** Queue-theatre payoff channel — the 'wait' verdict's reward (CCR-2). */
hypeDelta?: number;
heatStrike?: HeatStrike; heatStrike?: HeatStrike;
dazzaText?: string; dazzaText?: string;
} }
@ -114,6 +127,8 @@ export interface GameState {
heatStrikes: HeatStrike[]; heatStrikes: HeatStrike[];
regulars: Record<string, RegularMemory>; regulars: Record<string, RegularMemory>;
pastReports: IncidentRecord[][]; pastReports: IncidentRecord[][];
/** Scripted-encounter ids the run has already met (repeat gating). */
seenEncounters?: string[];
} }
// ---- event bus ---- // ---- event bus ----
@ -130,6 +145,8 @@ export interface EventMap {
'floor:infractionSpotted': { patronId: string; kind: 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'fight' }; 'floor:infractionSpotted': { patronId: string; kind: 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'fight' };
'floor:ejection': { patronId: string; style: 'clean' | 'messy' }; 'floor:ejection': { patronId: string; style: 'clean' | 'messy' };
/** A patron went home on their own — capacity frees up (natural churn). */
'floor:leave': { patronId: string };
'beat:tick': { beatIndex: number; audioTimeMs: number }; 'beat:tick': { beatIndex: number; audioTimeMs: number };
'audio:location': { location: 'door' | 'floor' }; 'audio:location': { location: 'door' | 'floor' };
@ -137,4 +154,14 @@ export interface EventMap {
'meters:delta': { vibe?: number; aggro?: number; hype?: number }; 'meters:delta': { vibe?: number; aggro?: number; hype?: number };
'meters:changed': { vibe: number; aggro: number; hype: number }; 'meters:changed': { vibe: number; aggro: number; hype: number };
'heat:strike': HeatStrike; '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
View File

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

View File

@ -1,8 +1,18 @@
import Phaser from 'phaser'; import Phaser from 'phaser';
import { BootScene } from './scenes/shared/BootScene'; import { BootScene } from './scenes/shared/BootScene';
import { ParadeScene } from './scenes/shared/ParadeScene'; 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 { 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({ const game = new Phaser.Game({
type: Phaser.AUTO, type: Phaser.AUTO,
width: 640, width: 640,
@ -14,37 +24,76 @@ const game = new Phaser.Game({
autoCenter: Phaser.Scale.CENTER_BOTH, autoCenter: Phaser.Scale.CENTER_BOTH,
zoom: Phaser.Scale.MAX_ZOOM, 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) // debug handle (harmless in prod; used by dev tooling)
(window as unknown as { game: Phaser.Game }).game = game; (window as unknown as { game: Phaser.Game }).game = game;
// TODO(integration): LANE-JUICE demo route — J for the juice demo, P back to the // TODO(integration): dev routing until Phase 2's shell exists.
// parade, or load with #juice. There is no menu yet; Phase 2's shell replaces this // Keys: N night (the game) · P parade · F floor demo · J juice demo.
// whole block. Touching main.ts is outside LANE-JUICE's owned dirs (BUILD_PLAN §2) // URL hashes #night/#parade/#floor/#juice pick the boot scene.
// and is flagged for sign-off in LANEHANDOVER.md — without it the demo scene is // NightScene owns DoorScene/NightSummaryScene as children; stopping Night stops them.
// unreachable dead code. const ROUTES = { night: 'Night', parade: 'Parade', floor: 'FloorDemo', juice: 'JuiceDemo' } as const;
const showScene = (key: 'Parade' | 'JuiceDemo'): void => { type RouteKey = keyof typeof ROUTES;
const other = key === 'Parade' ? 'JuiceDemo' : 'Parade';
game.scene.stop(other); // Only ROOT scenes are routed. Door, Floor and NightSummary belong to Night and
if (!game.scene.isActive(key)) game.scene.start(key); // 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 // RosterScene is scene[0] and auto-starts. Hash routing must wait until it is
// READY stops a scene that is not up yet and we end up with both running on top // actually up before swapping, or both scenes end up running on top of each other.
// of each other. Wait for Parade to actually exist, then swap. const hash = window.location.hash.slice(1) as RouteKey;
if (window.location.hash === '#juice') { if (hash in ROUTES) {
const route = (): void => { const route = (): void => {
if (!game.scene.isActive('Parade')) return; if (!game.scene.isActive('Roster')) return;
game.events.off(Phaser.Core.Events.POST_STEP, route); game.events.off(Phaser.Core.Events.POST_STEP, route);
showScene('JuiceDemo'); showScene(ROUTES[hash]);
}; };
game.events.on(Phaser.Core.Events.POST_STEP, route); game.events.on(Phaser.Core.Events.POST_STEP, route);
} }
window.addEventListener('keydown', (e) => { window.addEventListener('keydown', (e) => {
const k = e.key.toLowerCase(); const k = e.key.toLowerCase();
if (k === 'j') showScene('JuiceDemo'); if (k === 'n') showScene('Night');
else if (k === 'p') showScene('Parade'); else if (k === 'p') showScene('Parade');
else if (k === 'f') showScene('FloorDemo');
else if (k === 'j') showScene('JuiceDemo');
else if (k === 'm') {
// The mix desk OVERLAYS whatever is running (it attaches to the active
// engine), so the ear pass can happen during a real night. Toggle, not route.
if (game.scene.isActive('MixDesk')) game.scene.stop('MixDesk');
else {
game.scene.run('MixDesk');
game.scene.bringToTop('MixDesk');
}
}
}); });

View File

@ -9,7 +9,7 @@ import type { Patron } from '../data/types';
export function dollTextureKey(p: Patron, pose: DollPose): string { export function dollTextureKey(p: Patron, pose: DollPose): string {
// Drunk tells are baked into the texture, so intoxication stage is part of // Drunk tells are baked into the texture, so intoxication stage is part of
// the cache key (coarse: swayPx bucket, not raw float). // the cache key (coarse: swayPx bucket, not raw float).
return `doll:${p.dollSeed}:${pose}:${dollPlan(p, pose).swayPx}`; 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 { export function renderDoll(scene: Phaser.Scene, p: Patron, pose: DollPose): string {

View File

@ -122,5 +122,16 @@ export function dollPlan(p: Patron, pose: DollPose): DollPlan {
rects.push({ x: cx + 1, y: 9, w: 1, h: 1, colour: 0xcc3333 }); rects.push({ x: cx + 1, y: 9, w: 1, h: 1, colour: 0xcc3333 });
} }
// The disguise (design §4.1): a grudged regular's third appearance wears a
// fake moustache and a big pair of NOT-sunnies. Deliberately absent from the
// idPhoto pose — his card still shows the clean-shaven bloke you knocked
// back on Thursday. Same walk. Same name. "Different" man.
if (p.flags.disguised === true) {
rects.push({ x: cx - 2, y: 11, w: 5, h: 1, colour: 0x2a1c10 }); // moustache
rects.push({ x: cx - 3, y: 8, w: 2, h: 2, colour: 0x101018 }); // glasses L
rects.push({ x: cx + 1, y: 8, w: 2, h: 2, colour: 0x101018 }); // glasses R
rects.push({ x: cx - 1, y: 8, w: 2, h: 1, colour: 0x101018 }); // bridge
}
return { width, height, rects, swayPx }; return { width, height, rects, swayPx };
} }

View File

@ -36,7 +36,9 @@ function rollOutfit(rng: RngStream): OutfitLayer[] {
for (const slot of Object.keys(OUTFIT_VOCAB) as OutfitSlot[]) { for (const slot of Object.keys(OUTFIT_VOCAB) as OutfitSlot[]) {
const def = rng.weighted(OUTFIT_VOCAB[slot].map((d) => [d, d.weight] as const)); const def = rng.weighted(OUTFIT_VOCAB[slot].map((d) => [d, d.weight] as const));
const layer: OutfitLayer = { slot, type: def.type, colour: rng.pick(def.colours) }; const layer: OutfitLayer = { slot, type: def.type, colour: rng.pick(def.colours) };
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; if (def.canVintage && rng.chance(0.2)) layer.vintage = true;
layers.push(layer); layers.push(layer);
} }
@ -80,13 +82,17 @@ function rollId(rng: RngStream, nightDate: Date, trueAge: number, dollSeed: numb
export interface GeneratorContext { export interface GeneratorContext {
rng: SeededRNG; rng: SeededRNG;
nightDate: Date; nightDate: Date;
/** Venue crowd identity: spawn-weight overrides merged over ARCHETYPES. */
archetypeWeights?: Partial<Record<Archetype, number>>;
} }
export function generatePatron(ctx: GeneratorContext, clockMin: number, archetypeOverride?: Archetype): Patron { export function generatePatron(ctx: GeneratorContext, clockMin: number, archetypeOverride?: Archetype): Patron {
const rng = ctx.rng.stream('patrons'); const rng = ctx.rng.stream('patrons');
const def = archetypeOverride const def = archetypeOverride
? ARCHETYPE_BY_KEY.get(archetypeOverride)! ? ARCHETYPE_BY_KEY.get(archetypeOverride)!
: rng.weighted(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 dollSeed = rng.int(0, 2 ** 31 - 1);
const age = rng.int(def.ageRange[0], def.ageRange[1]); const age = rng.int(def.ageRange[0], def.ageRange[1]);

View File

@ -3,6 +3,22 @@ import type { GameState, Patron, RegularMemory } from '../data/types';
// Regulars' grudge store. Behaviour wiring (disguises, dialogue callbacks) lands // Regulars' grudge store. Behaviour wiring (disguises, dialogue callbacks) lands
// in Phase 2/3 — this is just the persistent bookkeeping, run-scoped in GameState. // in Phase 2/3 — this is just the persistent bookkeeping, run-scoped in GameState.
/** Record a denial against a stable key (cast regularId — or any patron id). */
export function recordDenialBy(state: GameState, key: string, nightIndex: number): RegularMemory {
const existing = state.regulars[key];
const mem: RegularMemory = existing ?? {
patronId: key,
timesDenied: 0,
lastSeenNight: nightIndex,
grudge: 0,
};
mem.timesDenied += 1;
mem.lastSeenNight = nightIndex;
mem.grudge = Math.min(1, mem.grudge + 0.34); // three denials = maximum grudge
state.regulars[key] = mem;
return mem;
}
export function recordDenial(state: GameState, patron: Patron, nightIndex: number): RegularMemory { export function recordDenial(state: GameState, patron: Patron, nightIndex: number): RegularMemory {
const existing = state.regulars[patron.id]; const existing = state.regulars[patron.id];
const mem: RegularMemory = existing ?? { const mem: RegularMemory = existing ?? {
@ -28,3 +44,8 @@ export function shouldReturnDisguised(state: GameState, patronId: string): boole
const mem = state.regulars[patronId]; const mem = state.regulars[patronId];
return !!mem && mem.timesDenied >= 2; return !!mem && mem.timesDenied >= 2;
} }
/** Same rule, straight off a memory record (spawn-time check in the queue). */
export function disguisedFromMemory(mem: RegularMemory | undefined): boolean {
return !!mem && mem.timesDenied >= 2;
}

View File

@ -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
View 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
View 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
View File

@ -0,0 +1,717 @@
import type { RngStream } from '../core/SeededRNG';
import { drunkStage } from './drunk';
import { CONTRABAND } from '../data/contraband';
import { OUTFIT_VOCAB } from '../data/outfits';
import { ENCOUNTER_SCRIPTS, type EncounterScript } from '../data/strings/encounters';
import type { Archetype, DrunkStage, HeatStrike, Patron } from '../data/types';
// Scripted encounters — design §4.3, the reason this is a game about people and
// not a spot-the-difference puzzle. Two or three of these are injected into the
// generated crowd per night.
//
// The rule the rest of this file exists to serve: doing the human thing costs
// Vibe or risks Heat, and NOTHING anywhere tells the player which call was right.
// There is deliberately no score, no flag, no tally of humane verdicts — if a
// later pass adds one, it has removed the mechanic rather than completed it.
//
// The corollary is just as load-bearing: the humane call must not be secretly
// optimal either. A generous option that quietly pays better is the same lie told
// the other way round. Every `onAdmit` below is priced to be a real decision.
export type EncounterId =
| 'grabHerMate' | 'quietBeer' | 'lastNight' | 'nightShift'
| 'eighteenToday' | 'kaydensMate' | 'bigNightOut' | 'twoOfThem'
| 'offDutyCop' | 'celebrity' | 'sovereign' | 'deliveryRider'
| 'promoter' | 'neighbour' | 'hensParty' | 'twins' | 'exDoorman';
export interface EncounterBeat {
line: string;
/**
* Delay from the moment the patron REACHES THE ROPE absolute, not a gap
* since the previous beat. Read as a gap, a skipped or slow line compresses
* everything after it and the exchange stops breathing.
*/
afterMs: number;
}
export interface EncounterOutcome {
vibeDelta?: number;
aggroDelta?: number;
heatStrike?: HeatStrike;
dazzaText?: string;
/** shown on the verdict toast — factual, never a moral verdict */
note?: string;
/**
* Consequences that land LATER, at a random minute inside their window
* the hen's party detonating, the pap swarm assembling. Applied through
* NightScene's deferred machinery so they survive a location switch and show
* up in the night summary with their reason.
*/
deferredHits?: readonly {
/** [lo, hi] clock-minutes AFTER the verdict */
afterMin: readonly [number, number];
vibeDelta?: number;
aggroDelta?: number;
reason: string;
}[];
}
export interface ScriptedEncounter {
id: EncounterId;
/** clock-minute window this can be scheduled into */
window: [number, number];
archetype: Archetype;
/** mutates a freshly generated patron into this specific person */
dress(patron: Patron, rng: RngStream): void;
beats: EncounterBeat[];
onAdmit: EncounterOutcome;
onDeny: EncounterOutcome;
/**
* Coin-flip encounters: called INSTEAD of the static tables when present.
* The coin was tossed at dress() time and rides in flags.encounterVariant, so
* resolution is a lookup, never a fresh roll the person at the rope was
* always one thing, the player just couldn't see which.
*/
resolve?(patron: Patron, verdict: 'admit' | 'deny'): EncounterOutcome;
}
/** Minutes of clear air an encounter needs either side of it to land. */
export const ENCOUNTER_SPACING_MIN = 40;
// ---- intoxication bands ------------------------------------------------------
// The dumped bloke has to read as 'loose' at the rope — visible tells, not a
// stumbling mess. Writing 0.55 here would silently become the wrong person the
// day someone retunes drunk.ts, so the band is derived FROM drunkStage instead.
// Scan step is fine enough to find each threshold to the nearest thousandth.
const STAGE_SCAN_STEP = 0.0005;
const BANDS = new Map<DrunkStage, readonly [number, number]>();
function stageBand(stage: DrunkStage): readonly [number, number] {
const cached = BANDS.get(stage);
if (cached) return cached;
let lo = Number.POSITIVE_INFINITY;
let hi = Number.NEGATIVE_INFINITY;
for (let i = 0; i * STAGE_SCAN_STEP <= 1; i++) {
const v = i * STAGE_SCAN_STEP;
if (drunkStage(v) !== stage) continue;
lo = Math.min(lo, v);
hi = Math.max(hi, v);
}
if (!Number.isFinite(lo)) throw new Error(`encounters: no intoxication maps to '${stage}'`);
const band = [lo, hi] as const;
BANDS.set(stage, band);
return band;
}
/**
* A value comfortably inside `stage`'s band. Inset from both edges because the
* floor sim drifts intoxication upward: a patron parked on a threshold restages
* on the first tick, and the script's whole point is the stage they arrive in.
*/
function intoxicationIn(stage: DrunkStage, rng: RngStream): number {
const [lo, hi] = stageBand(stage);
const inset = (hi - lo) * 0.15;
return lo + inset + rng.next() * (hi - lo - 2 * inset);
}
// ---- shared dressing ---------------------------------------------------------
const FLASK = CONTRABAND.find((c) => c.id === 'flask');
if (!FLASK) throw new Error("encounters: contraband has no 'flask' — lastNight has nothing to carry");
const POLO = OUTFIT_VOCAB.top.find((d) => d.type === 'polo' && d.canLogo === true);
if (!POLO) {
throw new Error("encounters: outfit vocab has no logo-capable 'polo' — nightShift is the work shirt");
}
/**
* Give a scripted person a boring, valid licence.
*
* `punter` carries a 2% fake-ID roll, and a scripted moral call that turns into
* an ID puzzle is neither. dress() has no nightDate to recompute an expiry from,
* so the existing one is pushed five years past whatever the generator produced
* which clears the 'expired' tell whichever side of tonight it started on.
*/
function settleId(patron: Patron): void {
delete patron.idCard.fake;
patron.idCard.photoSeed = patron.dollSeed;
patron.idCard.expiry = plusFiveYears(patron.idCard.expiry);
}
const plusFiveYears = (iso: string): string => {
const [y = '2026', m = '01', d = '01'] = iso.slice(0, 10).split('-');
// Clamped to the 28th so a 29 February expiry cannot be pushed onto a date that
// does not exist, which parses as NaN and quietly reads as "never expires".
const day = Math.min(Number(d), 28);
return `${Number(y) + 5}-${m}-${String(day).padStart(2, '0')}`;
};
/** Everything every scripted patron needs. dollSeed is untouched on purpose: the
* paper doll should still be a random stranger, or the player learns the faces. */
function baseDress(patron: Patron, rng: RngStream, script: EncounterScript): void {
settleId(patron);
patron.idCard.name = rng.pick(script.names);
// Same reasoning as clearContraband. The guest-list clipboard fires a
// "'m on the list" step-up line off claimsGuestList, which would talk over
// the scripted beats — and "i was inside ten minutes ago" plus a guest-list
// claim is two different people standing in one pair of shoes.
delete patron.flags.claimsGuestList;
delete patron.flags.onGuestList;
}
/** Scripted dilemmas are about the one thing they are about. A stray baggie from
* the archetype roll turns the girl at the rope into a pat-down instead. */
function clearContraband(patron: Patron): void {
delete patron.flags.contraband;
}
const script = (id: EncounterId): EncounterScript => {
const s = ENCOUNTER_SCRIPTS[id];
if (!s) throw new Error(`encounters: no dialogue script for '${id}'`);
return s;
};
const beatsOf = (id: EncounterId): EncounterBeat[] =>
script(id).beats.map((b) => ({ line: b.line, afterMs: b.afterMs }));
// ---- the encounters ----------------------------------------------------------
const ENCOUNTERS_BASE: readonly ScriptedEncounter[] = [
{
// Early: he is doing the rounds while he can still stand at a bar and talk.
id: 'lastNight',
window: [30, 150],
archetype: 'regular',
dress(patron, rng) {
const s = script('lastNight');
baseDress(patron, rng, s);
// He TELLS you about the flask. That is the trap — a hip flask you were
// told about is a licence problem you chose, not one you missed.
patron.flags.contraband = [FLASK.id];
patron.intoxication = intoxicationIn('tipsy', rng);
// Deliberately no timesDeniedBefore: judge() prices a grudged regular at
// -5 Vibe on deny, which would tilt this straight back toward admitting.
// The knock-back in his second beat happened years before this run.
},
beats: beatsOf('lastNight'),
onAdmit: {
vibeDelta: 2,
heatStrike: { reason: 'BYO spirits admitted — hip flask carried in on the door', deferred: true },
note: script('lastNight').admitNote,
},
onDeny: { note: script('lastNight').denyNote },
},
{
id: 'quietBeer',
window: [90, 210],
archetype: 'quietMessy',
dress(patron, rng) {
const s = script('quietBeer');
baseDress(patron, rng, s);
clearContraband(patron);
// 'loose' exactly: the tells are visible, so the player cannot claim later
// that they could not tell. quietMessy's low tolerance does the rest.
patron.intoxication = intoxicationIn('loose', rng);
},
beats: beatsOf('quietBeer'),
onAdmit: {
// The real cost is not this number. It is a loose quietMessy on the floor
// with a reason to keep going, which the floor sim charges for later.
vibeDelta: -1,
dazzaText: script('quietBeer').admitDazza,
note: script('quietBeer').admitNote,
},
onDeny: { note: script('quietBeer').denyNote },
},
{
// After the logo rule lands at 90 — and BEFORE Dazza rescinds it (the rule
// cap retires noLogos when the 200-minute rule arrives). Outside that band
// he is just a bloke in a polo and the dilemma evaporates.
id: 'nightShift',
window: [150, 195],
archetype: 'punter',
dress(patron, rng) {
const s = script('nightShift');
baseDress(patron, rng, s);
clearContraband(patron);
patron.intoxication = intoxicationIn('sober', rng);
// Mutated in place rather than pushed, so the outfit keeps exactly one
// layer per slot. The generator always fills top; the guard is for a
// hand-built patron in a test.
const top = patron.outfit.find((l) => l.slot === 'top');
if (top) {
top.type = POLO.type;
top.colour = rng.pick(POLO.colours);
top.logo = true;
}
},
beats: beatsOf('nightShift'),
onAdmit: {
// No numbers here on purpose. He is a live noLogos breach, so admitting
// him is already priced by judge() — Dazza does a lap and finds him. A
// bespoke penalty on top would charge the player twice for one call.
note: script('nightShift').admitNote,
},
onDeny: { note: script('nightShift').denyNote },
},
{
// Late, when the room is at the number and the stamp actually matters.
id: 'grabHerMate',
window: [210, 330],
archetype: 'punter',
dress(patron, rng) {
const s = script('grabHerMate');
baseDress(patron, rng, s);
clearContraband(patron);
// The whole dilemma in one flag. She stepped out without one and says so.
patron.flags.uvStamped = false;
patron.intoxication = intoxicationIn('tipsy', rng);
},
beats: beatsOf('grabHerMate'),
onAdmit: {
vibeDelta: -2,
heatStrike: {
// Worded off what THIS module knows — she has no stamp and the encounter
// set that flag itself. It deliberately does NOT mention capacity: the
// encounter cannot see insideCount, so a capacity-worded reason is a lie
// whenever the room is half empty, and a duplicate of judge()'s own
// capacity strike whenever it isn't. One admit, one strike, true reason.
reason: 'unstamped re-entry admitted — nothing on the door to say she was inside',
deferred: true,
},
dazzaText: script('grabHerMate').admitDazza,
note: script('grabHerMate').admitNote,
},
// Free. Costs nothing but the moment, and the moment is not a mechanic.
onDeny: { note: script('grabHerMate').denyNote },
},
{
// Early: it went midnight forty minutes ago and he has rehearsed this.
id: 'eighteenToday',
window: [60, 190],
archetype: 'fresh18',
dress(patron, rng) {
const s = script('eighteenToday');
baseDress(patron, rng, s);
clearContraband(patron);
patron.intoxication = intoxicationIn('sober', rng);
},
beats: beatsOf('eighteenToday'),
// No numbers either way. He is a clean legal admit and judge() already pays
// the ordinary rate; denying him is the conscience test judgeDeny narrates.
onAdmit: { note: script('eighteenToday').admitNote },
onDeny: { note: script('eighteenToday').denyNote },
},
{
// Kayden has been making promises again.
id: 'kaydensMate',
window: [120, 250],
archetype: 'punter',
dress(patron, rng) {
const s = script('kaydensMate');
baseDress(patron, rng, s);
clearContraband(patron);
patron.intoxication = intoxicationIn('tipsy', rng);
},
beats: beatsOf('kaydensMate'),
onAdmit: {
// Small and real: you honoured an authority that does not exist.
vibeDelta: -1,
dazzaText: script('kaydensMate').admitDazza,
note: script('kaydensMate').admitNote,
},
onDeny: { note: script('kaydensMate').denyNote },
},
{
// The buck's straggler. Sober-adjacent, desperate to be present.
id: 'bigNightOut',
window: [180, 300],
archetype: 'punter',
dress(patron, rng) {
const s = script('bigNightOut');
baseDress(patron, rng, s);
clearContraband(patron);
// 'loose' on two lemonades and a recovery: the tells are real even if his
// story is true. RSA does not care about the wedding.
patron.intoxication = intoxicationIn('loose', rng);
},
beats: beatsOf('bigNightOut'),
onAdmit: {
// The floor prices a loose admission already (judge ripen); this is the
// chant getting his name in it.
vibeDelta: 1,
note: script('bigNightOut').admitNote,
},
onDeny: { note: script('bigNightOut').denyNote },
},
{
// Engaged tonight, still holding hands, fully briefed on the rule.
id: 'twoOfThem',
window: [240, 345],
archetype: 'punter',
dress(patron, rng) {
const s = script('twoOfThem');
baseDress(patron, rng, s);
clearContraband(patron);
patron.intoxication = intoxicationIn('tipsy', rng);
// A live noHandHolders breach the whole window (the rule survives the cap
// from 200 onward). judge() prices the admit; the beats price the deny.
patron.flags.handHolding = true;
},
beats: beatsOf('twoOfThem'),
onAdmit: { note: script('twoOfThem').admitNote },
onDeny: { note: script('twoOfThem').denyNote },
},
];
// ---- variant plumbing --------------------------------------------------------
/** The static outcome for a coin-flip encounter's variant, notes swapped in. */
function variantOutcome(
id: EncounterId,
variant: string,
verdict: 'admit' | 'deny',
base: EncounterOutcome,
): EncounterOutcome {
const notes = script(id).variantNotes?.[variant];
if (!notes) return base;
const note = verdict === 'admit' ? notes.admitNote : notes.denyNote;
const dazzaText = verdict === 'admit' ? notes.admitDazza : notes.denyDazza;
return {
...base,
...(note !== undefined ? { note } : {}),
...(dazzaText !== undefined ? { dazzaText } : {}),
};
}
/**
* Twin B, built from twin A after A's verdict. NOT a scripted encounter and not
* a fake: his own real name, his own genuine licence, and because they are
* twins his brother's face. The photo check is the thing on trial.
*/
export function twinSibling(a: Patron): Patron {
const names = script('twins').names;
const other = names.find((n) => n !== a.idCard.name) ?? names[0]!;
return {
...a,
id: `${a.id}b`,
idCard: { ...a.idCard, name: other, photoSeed: a.dollSeed },
flags: {},
outfit: a.outfit.map((l) => ({ ...l })),
};
}
const NEW_ENCOUNTERS: readonly ScriptedEncounter[] = [
{
// The ID game's scariest bluff. The badge is theatre either way — the
// LICENCE knows which man this is (the costume shop does licences too).
id: 'offDutyCop',
window: [140, 280],
archetype: 'punter',
dress(patron, rng) {
const s = script('offDutyCop');
baseDress(patron, rng, s);
clearContraband(patron);
patron.intoxication = intoxicationIn('sober', rng);
patron.age = 38;
patron.flags.encounterVariant = rng.chance(0.5) ? 'real' : 'fake';
if (patron.flags.encounterVariant === 'fake') {
patron.idCard.fake = { wrongHologram: true };
}
},
beats: beatsOf('offDutyCop'),
// Static tables are the 'real' variant; resolve() swaps per-coin.
onAdmit: {
heatStrike: { reason: "off-duty officer noted the venue's breaches from the floor", deferred: true },
note: script('offDutyCop').variantNotes!.real!.admitNote!,
},
onDeny: { aggroDelta: -2, note: script('offDutyCop').variantNotes!.real!.denyNote! },
resolve(patron, verdict) {
const v = patron.flags.encounterVariant ?? 'real';
if (v === 'real') {
return variantOutcome('offDutyCop', 'real', verdict, verdict === 'admit' ? this.onAdmit : this.onDeny);
}
// The $30 badge: a dickhead, not a danger. His fake licence already
// justified the deny; admitting him is only ever embarrassing.
return variantOutcome('offDutyCop', 'fake', verdict, verdict === 'admit' ? { vibeDelta: 1 } : {});
},
},
{
// Cap low, sunnies on. Deny: nothing, ever. Admit: a coin you cannot see.
id: 'celebrity',
window: [180, 320],
archetype: 'punter',
dress(patron, rng) {
const s = script('celebrity');
baseDress(patron, rng, s);
clearContraband(patron);
patron.intoxication = intoxicationIn('tipsy', rng);
const acc = patron.outfit.find((l) => l.slot === 'accessory');
if (acc) {
acc.type = 'sunnies';
acc.colour = 'black';
delete acc.logo;
}
patron.flags.encounterVariant = rng.chance(0.5) ? 'rocket' : 'paps';
},
beats: beatsOf('celebrity'),
onAdmit: {
deferredHits: [{ afterMin: [8, 25], vibeDelta: 10, reason: 'the whole floor realises at once who that is' }],
note: script('celebrity').variantNotes!.rocket!.admitNote!,
},
onDeny: { note: script('celebrity').denyNote! },
resolve(patron, verdict) {
if (verdict === 'deny') return this.onDeny;
const v = patron.flags.encounterVariant ?? 'rocket';
if (v === 'rocket') return this.onAdmit;
return variantOutcome('celebrity', 'paps', 'admit', {
deferredHits: [{ afterMin: [5, 15], aggroDelta: 12, reason: 'a pap swarm sets up across the road' }],
});
},
},
{
// The aggro engine in a polo. His licence is a laminated business card —
// fake tells are load-bearing: the desk can SEE this is not a document.
id: 'sovereign',
window: [100, 260],
archetype: 'punter',
dress(patron, rng) {
const s = script('sovereign');
baseDress(patron, rng, s);
clearContraband(patron);
patron.intoxication = intoxicationIn('sober', rng);
patron.age = 46;
patron.idCard.fake = { jokeName: true, peelingLaminate: true };
},
beats: beatsOf('sovereign'),
onAdmit: {
vibeDelta: -2,
deferredHits: [{ afterMin: [10, 30], aggroDelta: 5, reason: 'he is explaining maritime law to the dance floor' }],
note: script('sovereign').admitNote!,
},
onDeny: { aggroDelta: 5, note: script('sovereign').denyNote! },
},
{
// "delivery for DJ". The DJ did not order food. OR DID HE.
id: 'deliveryRider',
window: [60, 330],
archetype: 'punter',
dress(patron, rng) {
const s = script('deliveryRider');
baseDress(patron, rng, s);
clearContraband(patron);
patron.intoxication = intoxicationIn('sober', rng);
patron.flags.encounterVariant = rng.chance(0.5) ? 'ordered' : 'not';
},
beats: beatsOf('deliveryRider'),
onAdmit: {
deferredHits: [{ afterMin: [3, 8], vibeDelta: 4, reason: 'the booth eats; the next hour of the mix has a spine' }],
note: script('deliveryRider').variantNotes!.ordered!.admitNote!,
},
onDeny: { note: script('deliveryRider').variantNotes!.not!.denyNote! },
resolve(patron, verdict) {
const v = patron.flags.encounterVariant ?? 'not';
if (v === 'ordered') {
if (verdict === 'admit') return this.onAdmit;
return variantOutcome('deliveryRider', 'ordered', 'deny', {
deferredHits: [{ afterMin: [5, 12], vibeDelta: -3, reason: 'a hungry DJ plays like one' }],
});
}
return variantOutcome('deliveryRider', 'not', verdict, verdict === 'admit' ? { vibeDelta: -1 } : {});
},
},
{
// Two clipboards enter, one leaves.
id: 'promoter',
window: [80, 200],
archetype: 'financeBro',
dress(patron, rng) {
const s = script('promoter');
baseDress(patron, rng, s);
clearContraband(patron);
patron.intoxication = intoxicationIn('sober', rng);
},
beats: beatsOf('promoter'),
onAdmit: {
vibeDelta: 2,
deferredHits: [{ afterMin: [15, 40], aggroDelta: 6, reason: "wristbanded strangers are citing 'tyson' at the rope" }],
dazzaText: script('promoter').admitDazza,
note: script('promoter').admitNote!,
},
onDeny: { aggroDelta: 2, note: script('promoter').denyNote! },
},
{
// The only patron all night who wants LESS club. Send her away and the
// council takes her call; bring her in and the room feels it. §4.3 exactly.
id: 'neighbour',
window: [250, 340],
archetype: 'punter',
dress(patron, rng) {
const s = script('neighbour');
baseDress(patron, rng, s);
clearContraband(patron);
patron.intoxication = intoxicationIn('sober', rng);
patron.age = 57;
patron.idCard.dob = '1969-03-14';
},
beats: beatsOf('neighbour'),
onAdmit: { vibeDelta: -3, note: script('neighbour').admitNote! },
onDeny: {
heatStrike: { reason: 'noise complaint escalated to council — the inspector took the call', deferred: true },
note: script('neighbour').denyNote!,
},
},
{
// Seven matching sashes, wrong venue, right energy.
id: 'hensParty',
window: [120, 280],
archetype: 'punter',
dress(patron, rng) {
const s = script('hensParty');
baseDress(patron, rng, s);
clearContraband(patron);
patron.intoxication = intoxicationIn('tipsy', rng);
},
beats: beatsOf('hensParty'),
onAdmit: {
deferredHits: [
{ afterMin: [10, 45], vibeDelta: 8, reason: 'the practised dance detonates on the floor' },
{ afterMin: [46, 80], aggroDelta: 5, reason: 'seven sashes at one bar is a supply problem' },
],
note: script('hensParty').admitNote!,
},
onDeny: { note: script('hensParty').denyNote! },
},
{
// Genuinely twins. Twin B is injected by the queue after A's verdict —
// a plain, un-scripted patron with his brother's face and his own name.
id: 'twins',
window: [90, 250],
archetype: 'punter',
dress(patron, rng) {
const s = script('twins');
baseDress(patron, rng, s);
clearContraband(patron);
patron.intoxication = intoxicationIn('tipsy', rng);
},
beats: beatsOf('twins'),
onAdmit: { note: script('twins').admitNote! },
onDeny: { note: script('twins').denyNote! },
},
{
// The live tutorial character. Whatever you rule, he ends up by the
// planter, and the next few patrons come with a tip that is right ~80% of
// the time. Fourteen years of doors.
id: 'exDoorman',
window: [60, 220],
archetype: 'regular',
dress(patron, rng) {
const s = script('exDoorman');
baseDress(patron, rng, s);
clearContraband(patron);
patron.intoxication = intoxicationIn('sober', rng);
patron.age = 49;
},
beats: beatsOf('exDoorman'),
onAdmit: { note: script('exDoorman').admitNote! },
onDeny: { note: script('exDoorman').denyNote! },
},
];
/** The full table: the original eight plus the second wave. Order is cosmetic
* the scheduler shuffles but stable ids matter (cross-night seenEncounters). */
export const ENCOUNTERS: readonly ScriptedEncounter[] = [...ENCOUNTERS_BASE, ...NEW_ENCOUNTERS];
for (const id of Object.keys(ENCOUNTER_SCRIPTS)) {
if (!ENCOUNTERS.some((e) => e.id === id)) {
throw new Error(`encounters: dialogue script '${id}' belongs to no encounter`);
}
}
// With eight overlapping windows a whole-set worst-case chain no longer fits in
// one night (that guarantee died when the table outgrew a single night, by
// design — a week should not see the same four people). The load-time check now
// guards each window's own sanity; the scheduler handles infeasible DRAWS by
// skipping the pick (a 2-encounter night beats a crashed one).
function assertWindowsSane(): void {
for (const e of ENCOUNTERS) {
if (e.window[0] >= e.window[1] || e.window[0] < 0 || e.window[1] > 350) {
throw new Error(`encounters: window for '${e.id}' is not a usable night interval`);
}
}
}
assertWindowsSane();
export function encounterById(id: EncounterId): ScriptedEncounter | undefined {
return ENCOUNTERS.find((e) => e.id === id);
}
/**
* Pick `count` distinct encounters and place each at a random minute inside its
* own window, at least ENCOUNTER_SPACING_MIN apart and sorted by time. Asking for
* more than exist returns all of them rather than throwing a night that wants
* five encounters should be short, not broken.
*/
export function scheduleEncounters(
rng: RngStream,
count: number,
/** Encounter ids already met this run — a week should meet new people first. */
exclude: ReadonlySet<string> = new Set(),
): Array<{ atMin: number; id: EncounterId }> {
const wanted = Math.min(Math.max(0, Math.floor(count)), ENCOUNTERS.length);
if (wanted === 0) return [];
const shuffle = (arr: ScriptedEncounter[]): ScriptedEncounter[] => {
for (let i = arr.length - 1; i > 0; i--) {
const j = rng.int(0, i);
const a = arr[i]!;
arr[i] = arr[j]!;
arr[j] = a;
}
return arr;
};
// Fresh faces first; once the run has met everyone, repeats are honest
// (a regular being a regular), so the excluded set tops the pool back up.
const fresh = shuffle(ENCOUNTERS.filter((e) => !exclude.has(e.id)));
const met = shuffle(ENCOUNTERS.filter((e) => exclude.has(e.id)));
const pool = [...fresh, ...met];
// Three passes, so a random draw can never strand a later pick:
// 1. end-order greedy at the EARLIEST legal minute — anything that fails
// here genuinely does not fit tonight and is dropped (a 2-encounter
// night beats a crashed one);
// 2. a backward pass computing each survivor's latest SAFE minute (its own
// window end, capped by the next pick's latest minute less the spacing);
// 3. a forward pass drawing the actual minute inside [earliest, latestSafe]
// — random where there is room, exact where there is not.
const chosen = pool.slice(0, wanted).sort((a, b) => a.window[1] - b.window[1]);
const placed: ScriptedEncounter[] = [];
let probe = Number.NEGATIVE_INFINITY;
for (const e of chosen) {
const lo = Math.max(e.window[0], probe);
if (lo > e.window[1]) continue;
placed.push(e);
probe = lo + ENCOUNTER_SPACING_MIN;
}
const latest: number[] = new Array(placed.length);
for (let i = placed.length - 1; i >= 0; i--) {
const cap = i === placed.length - 1 ? Infinity : latest[i + 1]! - ENCOUNTER_SPACING_MIN;
latest[i] = Math.min(placed[i]!.window[1], cap);
}
const out: Array<{ atMin: number; id: EncounterId }> = [];
let earliest = Number.NEGATIVE_INFINITY;
for (let i = 0; i < placed.length; i++) {
const e = placed[i]!;
const lo = Math.max(e.window[0], earliest);
const atMin = rng.int(lo, Math.max(lo, latest[i]!));
out.push({ atMin, id: e.id });
earliest = atMin + ENCOUNTER_SPACING_MIN;
}
return out.sort((a, b) => a.atMin - b.atMin);
}

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

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

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

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

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

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

108
src/rules/idCheck.ts Normal file
View 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
View File

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

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

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

301
src/rules/judge.ts Normal file
View 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
View File

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

191
src/rules/sobriety.ts Normal file
View 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
View File

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

View File

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

1169
src/scenes/door/DoorScene.ts Normal file

File diff suppressed because it is too large Load Diff

View 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();
}
}

View 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;
}
}

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