Compare commits

...

8 Commits

Author SHA1 Message Date
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
41 changed files with 7551 additions and 166 deletions

View File

@ -35,7 +35,8 @@ by parallel "lanes" coordinated through files, reviewed by Fable.
floor WASD + E (E at the entry exits back to the door). While you're inside,
Kayden works the door — badly, on purpose.
- Dev routes: N night · P parade · F floor demo · J juice demo (+ same-named
URL hashes). `localStorage.removeItem('not-tonight-save')` for a clean run.
URL hashes) · M toggles the MIX DESK overlay anywhere (ear-pass rig).
`localStorage.removeItem('not-tonight-save')` for a clean run.
- Audio unlocks on first click (TechnoEngine + location filter). Before that a
StubBeatClock keeps `beat:tick` honest.

View File

@ -597,3 +597,338 @@ heat).
(files in lanes/). All three parallel, worktrees, own branches, usual protocol.
If only one session: CONTENT first (it's the game's heart), FLOOR2, then JUICE2
— except run JUICE2 first if John is available for the ear pass.
---
### SESSION — LANE-CONTENT — 2026-07-19 21:35
**Branch/commits:** lane/content @ 0f7b948..(this commit), worktree `../not-tonight-content`
**Done — all four deliverables land, in priority order:**
1. **Guest list clipboard.** `rules/guestList.ts` (pure) + `door/ClipboardPanel.ts`. The
desk's left panel is now tabbed **DAZZA'S RULES | GUEST LIST** — there is no spare
real estate at 640×360, and the tab is honest about the cost: you cannot read the
dress code and the list at once, so checking a name spends a beat of queue time.
The mechanic works as designed and is *measured*, not asserted: at the generator's
actual claim rates an **exact** match is a ~50/50, **near** leans legit (~66%), and
**none** leans liar (~97%). That posterior test is the module's real deliverable.
`knowsOwner` patrons now get their own line pool (`OWNER_HINTS`) that drops Terry's
name — the trap needs a tell that isn't just confidence.
2. **Scripted moral encounters.** `rules/encounters.ts` + `data/strings/encounters.ts`.
Four, three per night, injected by `QueueManager` ahead of the arrival curve (they are
people who turned up at a moment, not draws from a distribution). Multi-beat speech at
the rope. Verified in play: *"seven years ive been coming here. you knocked me back
once, back in nineteen. fair enough too."*
3. **The Inspector.** `rules/inspector.ts` + `NightScene` wiring. Reads as a boring
punter; admitted, they watch for 30 clock-min and convert any live breach's DEFERRED
strike to an IMMEDIATE one. Denied: nothing, ever — paranoia is the tax, and that
absent branch carries a comment so nobody "fixes" it. Verified in play: a night ended
LICENCE PULLED at 12:47 AM with two of the three strikes reading *"inspector saw the
room over licensed capacity"* / *"inspector saw a minor inside the venue"*.
4. **The Incident Report.** `rules/incidentReport.ts` + `door/IncidentReportScene.ts`,
sitting between the summary and the next shift. 3 questions, one truthful account and
one or two plausible-deniability ones, shuffled. Filed accounts go to
`GameState.pastReports` and re-save, so Phase 4 audits what the player *claimed*.
`NightScene` no longer dumps raw incidents there — a truth log has nothing to catch
anyone out with.
**Also landed (my Phase-1 finding, now fixable because I own `rules/`):**
- **`judge()` finally reads intoxication and contraband.** Denying a `messy`/`maggot`
patron, or one carrying severity-3 contraband, is now *justified* rather than scored as
an arbitrary denial (+7 aggro). Admitting anyone already showing tells schedules a
ripening hit 1854 clock-min later with a Dazza line. `loose` is deliberately still a
judgement call. This closes the "the most legible signal in the game is worth zero
points" gap from the Phase-1 review, and it was the fix the encounters reviewer needed:
`quietBeer` had admit dominating deny on BOTH meters, which made the humane choice
secretly optimal — the one thing §4.3 forbids. Pinned by a test.
**How to see it:** `npm run dev` → play.
- Guest list: wait for someone who says *"i'm on the list"* — the GUEST LIST tab flashes,
click it, and the clipboard tells you whether the name matches. It never tells you
whether they are lying.
- Encounters: three a night, spaced 40+ min. They talk in beats and their name is not on
any list.
- Inspector: admit a `punter` who is secretly the inspector and then breach something
within 30 minutes. Or just play badly and find out.
- Report: play a night to 3 AM, click through the summary.
**Tests:** 518 passing / 0 failing (413 → 518, +105).
**Full 3-night run played** (careful player: refuse dodgy cards, minors, >0.62 intox, and
any dress breach). All three nights reached 3:00 AM; heat carried 1 → 2 → 2, so the week
was survived with one strike to spare. Encounters fired 3/3/3; guest-list claimants
13/19/13 a night; owner's mates 13.
**What it FELT like, honestly:** the door is much richer to *read* now — there is a reason
to look at a person rather than a checklist, and the clipboard genuinely creates doubt.
But **the Phase-1 economy findings are all still true and my content did not change them**:
vibe pinned at 9798 on every night, aggro sat at 0 all three nights, hype maxed at ×3 every
night. The careful player is never under meter pressure; the only real tension left is heat.
Also, because late-night dress rules make most of the crowd deniable, the careful bot ends
up denying MORE than it admits (70 vs 55, 70 vs 41, 50 vs 39) — the room the player is
protecting stays half empty. That is the tuning pass John is holding, and it is now the
single biggest thing between this and a good game.
**Broke / known-wonky:**
- **The cross-night Kayden contradiction never fired in three nights of play**, because it
needs a `kayden` incident and Kayden only works the rope while you are on the FLOOR — my
playthrough never went inside. The path is unit-tested (5 tests) but has never been seen
in a real game. Someone should play a night from the floor and lie about Kayden.
- Encounter outcome objects are module singletons; I clone `heatStrike` before emitting so
a run cannot alias one strike, but the rest of the outcome is shared. Fine while nothing
annotates them.
- `settleId()` in encounters leaves a fake ID's fabricated DOB, so ~1 in 50 scripted
patrons carries a card whose age disagrees with them. Cosmetic today (nothing reads the
mismatch); the proper fix needs `nightDate`, which `dress()` isn't given.
- I removed a stale `import '../../rules/doorTypes'` from `QueueManager` — a leftover
side-effect import for the augmentation you folded in at review.
- One test run failed once and never reproduced across 4 subsequent runs; it coincided with
the dev server rewriting a file mid-run. Recording it rather than pretending it didn't
happen.
- Sobriety line-walk (deliverable 5, stretch) NOT built. 14 took the session.
**Contract change requests:** none. Everything went through existing events
(`meters:delta`, `heat:strike`, `dazza:text`, `incident:log`) and no `declare module`.
**Questions for reviewer:**
1. `RunInfo` gained a `game: GameState` field so the report form can file into the save and
persist it. That hands a scene a mutable reference to the run — happy with that, or
would you rather the report emit and let `NightScene` own the write?
2. The report re-saves after the night's own `saveGame`. Two writes per night. Cheap, but
if you want one, the night's save needs to move after the paperwork.
3. Encounters are 3-per-night on a 4-entry table, so a week shows the same people every
night with only the order changing. Worth gating repeats across nights via
`GameState`, or is a regular being a regular fine?
**Next session should:**
- The tuning pass with John. My run data above is the current baseline; the Phase-1
measurements still hold.
- Play a floor-side night and exercise the Kayden contradiction.
- Sobriety line-walk if anyone wants it.
- More encounters — the table wants 810 so a week does not repeat.
### SESSION — LANE-JUICE2 — 2026-07-19 21:50
**Branch/commits:** lane/juice2 @ d112bda..1a66c60 (worktree `../not-tonight-juice2`)
**Done:**
- **`audio/mix.ts` + `audio/arc.ts`** (new, pure, tested). The track's constants
are now a live `TrackMix`, and the night has a shape. Same surface serves the
ear pass, the arc, and the Phase-4 DJ control panel (BUILD_PLAN §4 rule).
- **`TechnoEngine`** drives every voice from the mix × arc scalars. Verified
behaviour-identical at `DEFAULT_MIX`, constant by constant. Silent voices are
skipped rather than clamped (9 PM bass builds zero nodes). `setMixValue`
quantises integer paths so the dump can't export a value nobody heard.
- **`ui/MixDeskScene`** — the ear-pass rig. Solo/mute per voice with a
per-voice listening prompt, every param on log-mapped sliders (5 pages), an
arc scrubber, and DUMP MIX → pasteable `TrackMix` literal + clipboard.
Restores the arc pin and solo state it found, so it can't un-tune its host.
- **`audio/Ambience`** — rain (outside), crowd murmur (inside, level follows the
room), kebab-shop fluoro hum. See routing + headcount notes below.
- **`Phone`** thread scrolls: visible-window culling, binary-searched range,
history cap, sticky-bottom, and a "NEW BELOW" cue when a text lands off-screen.
- **`JuiceDemoScene`** hosts all of it; arc pinned at PEAK on entry.
**How to see it:** the demo lives in this worktree, so
`cd ../not-tonight-juice2 && npx vite --port 5311` → http://localhost:5311/#juice
(or J). Click once to unlock audio, then **M** for the mix desk.
**Tests:** 460 passing / 0 failing (was 413; +45 across mix/arc, +2 from fixes).
**Verified by ear:** STILL NOTHING — no audio output in this environment. Logic
verified in a real browser: arc pins/restores across desk open+close, integer
quantisation, crowd headcount arithmetic (120 + 2 admits 1 ejection = 121 with
the clicker event correctly ignored), zero console errors.
**THE ASK — the ear pass is now a ~10 minute job and I can't do it:**
Open the desk, SOLO each voice in turn, fix what's wrong, hit DUMP MIX, paste me
the literal. It goes straight over `DEFAULT_MIX` in `src/audio/mix.ts`.
`tests/mix.test.ts` pins the current values deliberately, so a retune will fail
that test — that's the tripwire working; update it in the same diff. Specifically
unjudged: does the kick read as a kick through the door filter, is the bassline
musical or just busy, is `typeTick` (fires per character) tolerable, does the
stamp sit right against the music, and does the door→floor sweep feel like a door.
**Broke / known-wonky:**
- `Ambience` rain uses `Sfx`'s own 1.2s/0.9s envelope, so it trails the 600/900ms
filter sweep by ~300ms. Fixing it properly means a `fadeS` param on
`Sfx.startRain/stopRain`. Reads fine; noting it rather than hiding it.
- `Ambience` assumes it is the sole owner of rain policy. If a door scene also
drives `sfx.startRain()` they will fight.
- `MixDesk` is reachable only from JuiceDemo (key M) — see request 1.
- No `TechnoEngine`/`Ambience` test files; both need a real `AudioContext` and
the suite is node. Their pure math is tested via mix/arc/scheduling/filterCurve.
**Contract change requests:** none. `door:phoneTheatre` was folded in at the
Phase-1 review; the crowd bed derives its headcount from existing events rather
than requesting `capacity.inside` in a payload (reasoning above).
**Requests for integration (main.ts is yours now, per the Phase-1 ruling):**
1. **A global route to the mix desk**, so the ear pass can also happen during a
REAL night rather than only in the demo. `MixDeskScene` already falls back to
`getActiveEngine()` when launched without data, so it attaches to whatever
engine is running — it just needs a key. Suggest **M**, alongside N/P/F/J.
2. Optional: `MeterHud` door-bar variant is still not done (LANE_JUICE2 §4.3,
explicitly "only if clean"). DoorScene's bespoke meters work; I left them.
**Questions for reviewer:**
1. The arc means the night no longer sounds the same at 9 PM and 1 AM — bass is
fully out at 9 PM by design. Worth confirming that's wanted before John tunes
against it, since it changes what "the mix" even means at a given hour.
2. `Ambience` currently has no owner in `NightScene` — I can't wire it there
(scenes aren't mine). Want it created alongside TechnoEngine at integration?
**Next session should:**
- Run the ear pass with John and land the retuned `DEFAULT_MIX`.
- Wire `Ambience` into `NightScene` (integration-owned).
- LANE-FLOOR2 owns the cut-off overlay call site; `DialogueBox` drunk-typo mode
is ready for it and needs no widget change — just pass `drunkenness` + `rng`.
---
### SESSION — LANE-FLOOR (round 2) — 2026-07-19 20:56
**Branch/commits:** lane/floor @ 3eee843..(this commit)
**Done:**
- **Fight-brewing infraction built** — the last missing v0.2 floor mechanic (design §3.2).
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. `fight.ts` (pure:
`isBetween` segment maths, `stepFight`, `scoreFight`, eligibility) +
`fightDirector.ts` (lifecycle, one fight at a time, cooldown, pin/release) +
a `setFight` tell in `FloorView` + scene wiring. `crowdSim` gained a
`'squaringUp'` activity that pins an agent, plus `pinForFight`/`releaseFromFight`.
- **Adversarial review of the whole lane** (5 dimensions x ~3k lines), then I
hand-adjudicated the findings and fixed six real defects — see below.
**How to see it:** `npm run dev`**F** → patrol. A fight announces itself with a
pulsing marker and shoving silhouettes **visible outside the torch cone** (verified
by screenshot with the beam pointed away) — spotting it is the gameplay. Prompt
reads GET BETWEEN THEM; stand in the gap before the fuse burns.
**Tests:** 225 passing / 0 failing (+56 this round: `fight.test.ts`, `fightDirector.test.ts`).
**Verified in-browser:** fight breaks up (+6 vibe, both released, no heat) and swings
(12 vibe, +20 aggro, exactly one heat strike, neither marked handled); shove rungs
escalate 0→4; the tell reads with the torch facing away; all four round-1 mechanics
still work after the fixes.
**Balance, measured rather than guessed** — same seed, whole night:
| | idle player | attentive player |
|---|---|---|
| vibe | 0 | 54 |
| aggro | 100 | 45 |
| heat strikes | 5 | 0 |
Doing nothing is ruinous; doing the job holds the room. That gradient is the point.
**Bugs found and fixed this round (all confirmed by reading the code, several in
code I wrote myself last session):**
1. `PatDownOverlay` checked `'Escape'` while the scene routes `'ESCAPE'` (it
uppercases) — ESC was dead in the pat-down while the on-screen hint promised it.
The other two overlays handled both spellings, which is why round 1 missed it.
2. The spawn cap and HUD counted `crowd.agents.length`, but CrowdSim deliberately
keeps departed agents so `find()` works — so **every ejection permanently shrank
the venue** and the floor drained over the night. Now counts live bodies.
(Verified: culled 15 → refilled to 44.)
3. `FloorView`'s sprite cache is keyed by `patron.id` and survives `resetRun`, but
`_resetPatronSerial()` recycles ids from `p0` — so **R** gave the new crowd the
previous run's dolls, sway and stamps. Added `FloorView.reset()`.
4. `escalation`'s per-incident `resolved` was consumed as a permanent per-patron
`handled`, so correctly watering a `loose` patron **retired them from all
tracking for the night while they kept drinking**. The Quiet Messy One could
never come back around. Now settled per-stage: they re-arm when they get worse.
5. Stall busts marked the **captured** occupant list handled, but the crowd keeps
simulating behind the modal — unrelated patrons who had wandered off got retired.
Now re-queries occupancy at resolution.
6. `floor:infractionSpotted` for a stall put a **stall id in the frozen `patronId`
field** (resolves to nobody), and the dedupe key meant one report per cubicle per
night. Now reports an occupant, so a fresh pair reports again.
7. Also wired `teardown()` to `Phaser.Scenes.Events.SHUTDOWN` — a `shutdown()`
*method* is not invoked by Phaser, so the previous version leaked FloorView,
CrowdSim, overlays and bus listeners every time you left the floor.
**Fight rebalance (found by running it, not by tests):** at a 20s cooldown an idle
night brewed **28 fights, all missed, 28 heat strikes** against a run cap of 3 —
fights swamped every other meter. Cooldown → 150s (≈5/night) and the heat strike is
now `deferred: true`, matching §3.2's "possible Heat" and the contract's
inspection/audit semantics. Two tests updated to match; the cooldown test now derives
its window from the constants so it can't silently pass vacuously again.
**Broke / known-wonky:**
- **My review harness under-reported and I overrode it.** 16 raw findings, and the
adversarial verify stage confirmed **zero** — I had required both skeptics to fail
to refute *and* told them to default to "refuted" when uncertain. That combination
produced a 100% false-negative rate: six of the sixteen were real, including two
found independently by three reviewers. I adjudicated them by hand instead.
Flagging because the *pattern* matters for anyone reusing that harness: a
refute-biased verifier plus unanimity is not a quality bar, it is an off switch.
- Fights burn their fuse through overlays. Deliberate — you can't be in a cubicle
and between two blokes at once, and ESC always exits — but it means a fight
starting during a 10s pat-down is a forced choice. Confirm you want that.
- An idle night still pins aggro at 100 by ~1AM. Aggro has no decay (see round 1
request #2); with that in place the idle curve would breathe rather than saturate.
**Contract change requests:** both from round 1 still open and unchanged —
(1) `incident:log` event + single writer in `core/meters.ts`; (2) aggro decay.
No new ones: the fight reuses the existing `'fight'` infraction kind and `heat:strike`.
**Questions for reviewer:**
1. `patrons/doll.ts` caches textures by key and never releases them, so each reseed
mints a fresh set with no upper bound (a long session leaks canvases). It is
LANE-0's file and frozen, so I left it — worth a Phase 2 ticket.
2. Fight numbers (broken +6 / swung 12 vibe, +20 aggro, deferred heat) and the
150s cooldown are my calls from the measured table above. Confirm before
LANE-DOOR mirrors any of it.
3. Round 1's questions on floor strings placement and the `.gitignore` symlink gap
are both still open.
**Next session should:**
- Phase 2 integration: real admitted-patron list from LANE-DOOR, LANE-JUICE's
`TechnoEngine` for `beat:tick`, real meter HUD.
- All five v0.2 floor infractions are now built — the floor is feature-complete
against design §3.2.
---
### REVIEW — Fable — 2026-07-19 (Phase 3 review & integration)
**Reviewed:** lane/content @ 37f9892 (518 tests), lane/floor @ 906a366 (225),
lane/juice2 @ 10462eb (460). All gates verified green per-branch. Merged
content → juice2 → floor; **621 tests green on main** post-merge + integration.
Verified in-browser: a Kayden-heavy night produced the full integrated strike
sheet (a fake-ID minor via Kayden, a hip flask, and an UNATTENDED FLOOR FIGHT —
cause: "someone said the kebab shop has gone downhill"), the incident-report
form runs between summary and next shift, and the mix desk overlays a live scene
via the new M route.
**Quality notes:**
- CONTENT: measured guest-list posteriors instead of asserting them, and the
judge() intoxication fix closed the worst scoring gap in the game — the exact
right person fixed it (rules owner). The §4.3 catch (humane choice must not be
secretly optimal) is the review of the phase.
- FLOOR2: six real defects found by re-reviewing YOUR OWN previous session is
the standard now set. The honest note that your verify harness had a 100%
false-negative rate (refute-biased + unanimity) is going in the reviewer
playbook: that pattern is an off switch, not a quality bar.
- JUICE2: the mix desk turns the ear-pass from a code task into a 10-minute
human task. Correct instinct throughout.
**Protocol breach (FLOOR2) — no harm, don't repeat:** the session branched from
its OLD lane tip (3eee843), not main — so it never saw Phase 2, its handover
re-requests CCRs that were folded a phase ago, and its round-2 code imported a
file main had moved. I ported everything at merge (fight files re-pointed at
data/strings/floor, fights wired into NIGHT mode incl. the unattended path,
live-body cap applied to the night spawn path). Next session: `git fetch &&
git switch -c lane/<name> origin/main`, and read the CURRENT handover first.
**Integration applied (beyond the merges):**
- Aggro physiology (approved round-1 request) implemented: `tickCalm` in the
floor — no brewing fight + no unhandled maggot → ~0.3 aggro/clock-min cools,
both modes, player present or not. Numbers are tuning-pass fodder.
- Fight swings radio the door (urgency 3) when the player is at the rope.
- `Ambience` created/destroyed by NightScene beside the engine; starts on
`audio:unlocked`.
- `MixDeskScene` registered globally; **M** toggles it as an overlay anywhere.
**Rulings:**
1. CONTENT Q1 (`RunInfo.game` mutable ref): ACCEPTED as-is. If a third writer
to the save ever appears, we revisit with an event; two is fine.
2. CONTENT Q2 (double save): accepted, cheap. Leave it.
3. CONTENT Q3 (encounter repeats): YES — gate repeats across nights via
GameState next content session, and grow the table to 810.
4. FLOOR2 (fight fuse burns through overlays): CONFIRMED as design. ESC exists;
choosing what to abandon is the floor.
5. FLOOR2 (doll texture cache growth): bounded per-night by the crowd cap;
acceptable until the v0.4 art pass, which owns texture lifecycle anyway.
6. JUICE2 Q1 (the arc changes what "the mix" means by hour): CONFIRMED wanted.
The desk pinning PEAK on entry is the right tuning default.
7. JUICE2 Q2 (Ambience owner): NightScene, done.
8. Fight heat strike as `deferred: true` + 150s cooldown: good rebalance,
approved retroactively.
**Open items carried:**
- The Kayden-contradiction path (lie about him in the report) has never fired in
real play — someone play a floor-heavy night and lie about it.
- Stall/pat-down/bin SFX still unwired (overlay constructors want the handle).
- Cut-off overlay ↔ DialogueBox drunk-typo integration (widget ready, call site
floor-owned).
**NO NEW LANES OPEN.** The bottleneck is no longer code: it is two sessions with
John — (1) the EAR PASS (press M in a real night, solo voices, DUMP MIX, paste
the literal back), and (2) the ECONOMY TUNING PASS (baselines: content's
3-night run data + door's Phase-1 measurements + floor's idle/attentive table).
After those land, the next lanes are content-expansion (encounters, repeat
gating), venue 2, and the v0.4 art pass per docs/ASSETS.md.

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

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

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

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

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

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

View File

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

View File

@ -0,0 +1,83 @@
// 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;
}
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.',
},
};

View File

@ -80,6 +80,43 @@ export const PATDOWN_FOUND: Record<string, string> = {
export const PATDOWN_CLEAR = 'Nothing on him. "Told ya," he says, delighted.';
export const PATDOWN_MISSED = 'Time. Whatever\'s left in there walks out the door with him.';
/**
* Two blokes squaring up. Pub fights start over nothing and everyone involved
* knows it keep the stakes stupid so the tension stays funny, and so getting
* between them reads as defusing a farce, not breaking up a war.
*/
export const FIGHT_CAUSE: readonly string[] = [
'Someone stepped on someone\'s very white shoe.',
'A disagreement about whether this song is "the actual version".',
'One of them called the other one\'s cousin "alright, I guess".',
'A pool table was, allegedly, next-ed out of turn.',
'Someone said the kebab shop over the road has "gone downhill".',
'It is about a text message from 2019.',
];
/** Escalating shove chatter — you hear this before you see anything. */
export const FIGHT_SHOVES: readonly string[] = [
'"Say it again."',
'"Nah nah nah, say it AGAIN."',
'"I\'m not even angry, mate. I\'m not."',
'"Hold my drink. HOLD it."',
'"You reckon? You RECKON?"',
];
/** You got between them in time. */
export const FIGHT_BROKEN: readonly string[] = [
'You step into the gap. Both of them discover they were leaving anyway.',
'A wall of hi-vis arrives. Suddenly everyone is best mates and very tired.',
'You say nothing. They remember they have work in the morning.',
];
/** You didn't. */
export const FIGHT_SWUNG: readonly string[] = [
'The swing lands. So does the drink, the stool, and the mood of the entire room.',
'One punch, no contact, both on the floor anyway. The room turns on you, not them.',
'It is over in a second and everyone saw you standing too far away.',
];
/** Kayden, on the radio, when a stampless patron turns up inside. */
export const NO_STAMP_RADIO: readonly string[] = [
'"Yeah sorry, that one might\'ve been me. Sorry. Sorry."',

View File

@ -4,8 +4,10 @@ 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 { 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
@ -27,11 +29,13 @@ const game = new Phaser.Game({
NightScene,
DoorScene,
NightSummaryScene,
IncidentReportScene,
new FloorDemoScene('Floor'),
BootScene,
ParadeScene,
new FloorDemoScene('FloorDemo'),
JuiceDemoScene,
MixDeskScene,
],
});
@ -70,4 +74,13 @@ window.addEventListener('keydown', (e) => {
else if (k === 'p') showScene('Parade');
else if (k === 'f') showScene('FloorDemo');
else if (k === 'j') showScene('JuiceDemo');
else if (k === 'm') {
// The mix desk OVERLAYS whatever is running (it attaches to the active
// engine), so the ear pass can happen during a real night. Toggle, not route.
if (game.scene.isActive('MixDesk')) game.scene.stop('MixDesk');
else {
game.scene.run('MixDesk');
game.scene.bringToTop('MixDesk');
}
}
});

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

@ -0,0 +1,324 @@
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';
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;
}
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;
}
/** 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 ----------------------------------------------------------
export const ENCOUNTERS: 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. Before that he is just a bloke in a polo.
id: 'nightShift',
window: [150, 270],
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 },
},
];
for (const id of Object.keys(ENCOUNTER_SCRIPTS)) {
if (!ENCOUNTERS.some((e) => e.id === id)) {
throw new Error(`encounters: dialogue script '${id}' belongs to no encounter`);
}
}
// scheduleEncounters must never move an encounter outside its own window, so the
// windows themselves have to admit a legal layout for the WHOLE set — any subset
// is strictly easier. Checked at load, where a bad window edit is a boot failure
// rather than a night that quietly drops its third encounter.
function assertWindowsSchedulable(): void {
let earliest = Number.NEGATIVE_INFINITY;
for (const e of [...ENCOUNTERS].sort((a, b) => a.window[0] - b.window[0])) {
if (Math.max(e.window[0], earliest) > e.window[1]) {
throw new Error(`encounters: window for '${e.id}' cannot clear ${ENCOUNTER_SPACING_MIN} min`);
}
// Worst case: the previous encounter landed at the very end of its window.
earliest = e.window[1] + ENCOUNTER_SPACING_MIN;
}
}
assertWindowsSchedulable();
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,
): Array<{ atMin: number; id: EncounterId }> {
const wanted = Math.min(Math.max(0, Math.floor(count)), ENCOUNTERS.length);
if (wanted === 0) return [];
const pool = [...ENCOUNTERS];
for (let i = pool.length - 1; i > 0; i--) {
const j = rng.int(0, i);
const a = pool[i]!;
pool[i] = pool[j]!;
pool[j] = a;
}
// Placed in window order, each pushed clear of the one before. Because the
// windows are staggered wider than the spacing (asserted above), the lower
// bound can never overrun a window's end.
const chosen = pool.slice(0, wanted).sort((a, b) => a.window[0] - b.window[0]);
const out: Array<{ atMin: number; id: EncounterId }> = [];
let earliest = Number.NEGATIVE_INFINITY;
for (const e of chosen) {
const atMin = rng.int(Math.max(e.window[0], earliest), e.window[1]);
out.push({ atMin, id: e.id });
earliest = atMin + ENCOUNTER_SPACING_MIN;
}
return out;
}

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

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

@ -0,0 +1,329 @@
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
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 },
],
},
kayden: {
prompt: 'A ruling was made at the rope while you were inside. Your account?',
options: [
{ id: 'kayden.true', text: 'I was on the floor. Kayden made that call and I heard about it after.', truthful: true },
{ id: 'kayden.soft1', text: 'Door was covered under my direction while I attended a matter inside.', truthful: false },
{ id: 'kayden.soft2', text: 'Ruling was consistent with the brief given to the second on the door.', truthful: false },
],
},
cutOff: {
prompt: 'A patron was refused further service. Your account?',
options: [
{ id: 'cutOff.true', text: 'I cut them off. They were filthy about it and they said so at length.', truthful: true },
{ id: 'cutOff.soft1', text: 'Service was concluded in line with RSA. Patron was cooperative throughout.', truthful: false },
{ id: 'cutOff.soft2', text: 'A water was provided and the matter was resolved at the bar without escalation.', truthful: false },
],
},
stallBusted: {
prompt: 'Two patrons were found in one cubicle. Your account?',
options: [
{ id: 'stall.true', text: 'I banged on the door until it opened. Both were spoken to and both were embarrassed.', truthful: true },
{ id: 'stall.soft1', text: 'Cubicle was checked as part of a routine sweep. Occupants dispersed on request.', truthful: false },
{ id: 'stall.soft2', text: 'One patron was assisting another who was unwell. Nothing further was observed.', truthful: false },
],
},
patDown: {
prompt: 'A patron was searched. Your account?',
options: [
{ id: 'patDown.true', text: 'I searched them on a hunch and the whole queue watched me do it.', truthful: true },
{ id: 'patDown.soft1', text: 'Search was conducted with consent as part of a routine entry check.', truthful: false },
{ id: 'patDown.soft2', text: 'Pockets and bag were checked in line with venue policy. Patron was thanked.', truthful: false },
],
},
deny: {
prompt: 'Entry was refused. Your account?',
options: [
{ id: 'deny.true', text: 'I knocked them back for the reason I had at the time. They copped it at the rope.', truthful: true },
{ id: 'deny.soft1', text: 'Entry was declined under the dress code as advised by management on the night.', truthful: false },
{ id: 'deny.soft2', text: 'Patron did not meet conditions of entry. Advised of same, politely, at the rope.', truthful: false },
],
},
sobrietyTest: {
prompt: 'A patron was assessed at the desk. Your account?',
options: [
{ id: 'sobriety.true', text: 'I tested them at the desk myself. It took a minute and I called it there.', truthful: true },
{ id: 'sobriety.soft1', text: 'A brief assessment was carried out at the desk and the result recorded.', truthful: false },
],
},
};
// For kinds this file has never heard of. Vague on purpose — the player is
// signing something they did not read, which is the correct amount of realism.
const DEFAULT_BANK: AccountBank = {
prompt: 'An entry was made in the log. Your account?',
options: [
{ id: 'other.true', text: 'It went in the log at the time and the log has it about right.', truthful: true },
{ id: 'other.soft1', text: 'Logged for completeness. Nothing arising required action on the night.', truthful: false },
],
};
// Load-time, like dressCode's card check: a question with two truthful options
// or a duplicate id is a form the Phase 4 audit cannot read, and finding that out
// at 3 AM in a summary screen is finding it out too late.
for (const [key, bank] of Object.entries({ ...BANKS, _default: DEFAULT_BANK })) {
if (bank.options.length < 2) {
throw new Error(`incidentReport: bank '${key}' offers no choice`);
}
if (bank.options.filter((o) => o.truthful).length !== 1) {
throw new Error(`incidentReport: bank '${key}' must have exactly one truthful account`);
}
if (new Set(bank.options.map((o) => o.id)).size !== bank.options.length) {
throw new Error(`incidentReport: bank '${key}' has duplicate option ids`);
}
}
// Own-property lookups only. `kind` is an open string, so a kind that happens to
// name an Object.prototype member ('toString', 'constructor', 'valueOf') would
// otherwise resolve to an inherited function: `interestOf` would return a
// Function, `fn > 0` is false, and the incident would vanish from the form — the
// exact silent-drop this module's defaultInterest exists to prevent.
const own = <T>(table: Record<string, T>, key: string): T | undefined =>
Object.prototype.hasOwnProperty.call(table, key) ? table[key] : undefined;
const interestOf = (incident: IncidentRecord): number =>
own(REPORT_TUNING.interest, incident.kind) ?? REPORT_TUNING.defaultInterest;
/** Fisher-Yates, so the truthful account is not always the top button. */
function shuffled(options: readonly ReportOption[], rng: RngStream): ReportOption[] {
const out = [...options];
for (let i = out.length - 1; i > 0; i--) {
const j = rng.int(0, i);
const a = out[i]!;
const b = out[j]!;
out[i] = b;
out[j] = a;
}
return out;
}
export function buildReport(
incidents: readonly IncidentRecord[],
rng: RngStream,
): ReportQuestion[] {
const scored = incidents
// Already-filed rows are skipped rather than trusted: pastReports holds both
// shapes, and asking the player to file an account of an account is a loop.
.filter((i) => interestOf(i) > 0 && parseFiledAccount(i) === undefined)
.map((incident) => ({ incident, score: interestOf(incident) + rng.next() }));
// Jitter is < 1 and weights are integers, so shuffling never promotes a dull
// incident over an interesting one — it only reorders within a band.
scored.sort((a, b) => b.score - a.score);
// Never padded: an uneventful night gets a short form, which is its own joke.
const asked = scored.slice(0, REPORT_TUNING.maxQuestions);
// Filed in the order it happened, because that is how a report reads and how
// Phase 4 will want to diff two nights against each other.
asked.sort((a, b) => a.incident.clockMin - b.incident.clockMin);
return asked.map(({ incident }) => {
const bank = own(BANKS, incident.kind) ?? DEFAULT_BANK;
return { incident, prompt: bank.prompt, options: shuffled(bank.options, rng) };
});
}
/** The rows to push onto GameState.pastReports. See the encoding note up top. */
export function fileReport(answers: readonly FiledAnswer[]): IncidentRecord[] {
return answers.map(({ incident, chosen }) => {
const payload: FiledPayload = {
optionId: chosen.id,
truthful: chosen.truthful,
truthLog: incident.detail,
};
const filed: IncidentRecord = {
clockMin: incident.clockMin,
kind: incident.kind,
detail: `${chosen.text}${REPORT_MARKER}${JSON.stringify(payload)}`,
};
// Spread rather than assigning undefined: patronId is optional and a present
// key holding undefined survives JSON round-trips as a different shape.
return incident.patronId === undefined ? filed : { ...filed, patronId: incident.patronId };
});
}
/** undefined for anything that is not a filed report row — including truth-log rows. */
export function parseFiledAccount(record: IncidentRecord): FiledAccount | undefined {
const at = record.detail.indexOf(REPORT_MARKER);
if (at < 0) return undefined;
let parsed: unknown;
try {
parsed = JSON.parse(record.detail.slice(at + REPORT_MARKER.length));
} catch {
return undefined;
}
if (typeof parsed !== 'object' || parsed === null) return undefined;
const { optionId, truthful, truthLog } = parsed as Partial<FiledPayload>;
if (typeof optionId !== 'string' || typeof truthful !== 'boolean' || typeof truthLog !== 'string') {
return undefined;
}
return { account: record.detail.slice(0, at), optionId, truthful, truthLog };
}
export function lieCount(answers: readonly FiledAnswer[]): number {
return answers.filter((a) => !a.chosen.truthful).length;
}
/**
* Design §3.3's "one taste" of the long-memory conscience: Kayden keeps his own
* log, and it does not flatter anyone. If a PREVIOUS night's filed report
* embellished a Kayden incident, this is what Dazza reads out the next morning.
*
* Phase 4 generalises this to a full cross-night audit with strikes attached;
* for now it is one line, arriving one night late, which is the shape of the
* mechanic in miniature.
*/
export function findKaydenContradiction(
pastReports: readonly (readonly IncidentRecord[])[],
): string | undefined {
for (let n = pastReports.length - 1; n >= 0; n--) {
for (const record of pastReports[n] ?? []) {
if (record.kind !== 'kayden') continue;
const filed = parseFiledAccount(record);
if (!filed || filed.truthful) continue;
return `kayden wrote his version of ${record.patronId ?? 'that one'} up too. it does not match urs. he is very proud of the log`;
}
}
return undefined;
}

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

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

View File

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

View File

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

View File

@ -19,6 +19,8 @@ import { PatronUpView } from './PatronUpView';
import { IdCardView } from './IdCardView';
import { PhoneWidget } from './PhoneWidget';
import { DressCodeCard } from './DressCodeCard';
import { ClipboardPanel, type ClipboardTab } from './ClipboardPanel';
import { encounterById } from '../../rules/encounters';
import { SobrietyModal } from './SobrietyModal';
import { stamp, type StampHandle } from '../../ui/Stamp';
import { Button, DOOR_PALETTE, MeterBar, MONO, panel, text } from './ui';
@ -59,6 +61,9 @@ export class DoorScene extends Phaser.Scene {
private idCard!: IdCardView;
private phone!: PhoneWidget;
private codeCard!: DressCodeCard;
private clipboard!: ClipboardPanel;
private deskTab: ClipboardTab = 'rules';
private tabButtons: Partial<Record<ClipboardTab, Button>> = {};
private sobriety!: SobrietyModal;
private queueSprites: QueueSprite[] = [];
@ -224,7 +229,10 @@ export class DoorScene extends Phaser.Scene {
this.add.rectangle(W / 2, (DESK_Y + H) / 2, W, H - DESK_Y, DOOR_PALETTE.desk).setDepth(20);
this.add.rectangle(W / 2, DESK_Y + 1, W, 2, DOOR_PALETTE.deskEdge).setDepth(21);
this.codeCard = new DressCodeCard(this, 6, 256, 168, 98);
// Two panels, one footprint. The tab header sits above them both.
this.codeCard = new DressCodeCard(this, 6, 268, 168, 86);
this.clipboard = new ClipboardPanel(this, this.queue.guestList, 6, 268, 168, 86);
this.buildDeskTabs();
// ID tray
panel(this, 180, 256, 96, 98).setDepth(30);
@ -290,6 +298,36 @@ export class DoorScene extends Phaser.Scene {
this.setVerdictButtons(false);
}
/** RULES | LIST switch above the shared left panel. */
private buildDeskTabs(): void {
const mk = (tab: ClipboardTab, label: string, x: number, w: number): Button =>
new Button(this, {
x, y: 254, w, h: 13, label, size: 6,
fill: DOOR_PALETTE.deskEdge,
hover: DOOR_PALETTE.panelEdge,
onClick: () => this.showTab(tab),
}).setDepth(52);
this.tabButtons.rules = mk('rules', "DAZZA'S RULES", 6, 84);
this.tabButtons.list = mk('list', 'GUEST LIST', 90, 84);
this.showTab('rules');
}
private showTab(tab: ClipboardTab): void {
this.deskTab = tab;
this.codeCard.setVisible(tab === 'rules');
this.clipboard.setVisible(tab === 'list');
// NOT setEnabled: disabling the current tab greys it out, which reads as
// "this one is off" — exactly backwards. Both stay live; the selected one is
// simply the bright one, sitting flush with the panel it opens.
for (const key of ['rules', 'list'] as const) {
const b = this.tabButtons[key];
if (!b) continue;
const active = key === tab;
b.rect.setFillStyle(active ? DOOR_PALETTE.panelEdge : 0x1b1720);
b.label.setColor(active ? DOOR_PALETTE.inkHot : DOOR_PALETTE.inkDim);
}
}
private buildHud(): void {
this.add.rectangle(W / 2, 9, W, 18, 0x05050a, 0.75).setDepth(500);
this.vibeBar = new MeterBar(this, 6, 10, 88, 'VIBE', DOOR_PALETTE.green);
@ -339,9 +377,13 @@ export class DoorScene extends Phaser.Scene {
this.testedThisPatron = false;
this.pattedThisPatron = false;
this.night.sfx?.play('ropeUnhook');
this.up.show(p);
this.up.show(p, this.queue.encounterFor(p));
this.showTray(p);
this.setVerdictButtons(true);
// Only a claim puts a name on the clipboard. Someone who says nothing is
// not on the list and is not pretending to be.
this.clipboard.check(p.flags.claimsGuestList ? p.idCard.name : null);
if (p.flags.claimsGuestList) this.flashTab('list');
this.tweens.add({
targets: this.ropeProp.getByName('rope') as Phaser.GameObjects.Rectangle,
@ -351,6 +393,13 @@ export class DoorScene extends Phaser.Scene {
});
}
/** Nudge the player toward a tab that just became relevant, without switching it. */
private flashTab(tab: ClipboardTab): void {
const b = this.tabButtons[tab];
if (!b || this.deskTab === tab) return;
this.tweens.add({ targets: b.label, alpha: { from: 1, to: 0.25 }, duration: 320, yoyo: true, repeat: 3 });
}
private showTray(p: Patron): void {
this.trayCard?.destroy();
const c = this.idCard.thumbnail(p, 228, 300);
@ -432,6 +481,7 @@ export class DoorScene extends Phaser.Scene {
this.night.state.capacity.inside++;
}
this.applyOutcome(p, verdict, outcome);
this.applyEncounter(p, verdict);
this.queue.resolveUp(verdict === 'deny');
// Highlight AFTER the call, never before. Lighting up the broken rules the
@ -486,6 +536,39 @@ export class DoorScene extends Phaser.Scene {
});
}
/**
* A scripted encounter's own consequence, on top of the ordinary judge()
* score. Deliberately silent about whether the call was decent: §4.3 says the
* cost is real and the verdict is absent, so there is no chime here and no
* tally anywhere.
*/
private applyEncounter(p: Patron, verdict: 'admit' | 'deny'): void {
const id = this.queue.encounterFor(p);
const enc = id ? encounterById(id) : undefined;
if (!enc) return;
const out = verdict === 'admit' ? enc.onAdmit : enc.onDeny;
const delta: { vibe?: number; aggro?: number } = {};
if (out.vibeDelta) delta.vibe = out.vibeDelta;
if (out.aggroDelta) delta.aggro = out.aggroDelta;
if (Object.keys(delta).length > 0) this.night.bus.emit('meters:delta', delta);
if (out.heatStrike && shouldRecordStrike(this.night.state, out.heatStrike)) {
// Fresh object per firing: the encounter tables are module singletons, so
// pushing the literal would alias one strike across the whole run.
this.night.bus.emit('heat:strike', { ...out.heatStrike });
}
if (out.dazzaText) this.night.bus.emit('dazza:text', { text: out.dazzaText });
if (out.note) this.time.delayedCall(900, () => this.showToast(out.note!));
this.night.bus.emit('incident:log', {
clockMin: this.night.state.clockMin,
kind: 'encounter',
patronId: p.id,
detail: `${id}: ${verdict === 'admit' ? 'let in' : 'turned away'}`,
});
}
private judgeCtx(): Parameters<typeof judge>[2] {
return {
clockMin: this.night.state.clockMin,
@ -558,6 +641,7 @@ export class DoorScene extends Phaser.Scene {
// applyOutcome emits door:verdict (and the verdict incident) — same path as
// a player ruling, so the floor is fed exactly once per admission.
this.applyOutcome(p, verdict, outcome);
this.applyEncounter(p, verdict);
this.queue.resolveUp(verdict === 'deny');
this.night.bus.emit('incident:log', {
clockMin: this.night.state.clockMin,

View File

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

View File

@ -8,10 +8,12 @@ import { loadGame, saveGame, clearSave, freshGameState } from '../../core/save';
import { _resetPatronSerial } from '../../patrons/generator';
import { ruleById } from '../../rules/dressCode';
import { TechnoEngine } from '../../audio/TechnoEngine';
import { Ambience } from '../../audio/Ambience';
import { Sfx } from '../../audio/Sfx';
import type { DeferredHit } from '../../rules/doorTypes';
import type { GameState, HeatStrike, NightState, Patron, Verdict, VerdictOutcome } from '../../data/types';
import { nextDazza } from './dazzaSchedule';
import { observe, startWatch, type InspectorWatch, type VenueBreaches } from '../../rules/inspector';
// Phase-2: the night-flow machine. Door and Floor both run for the whole night;
// this scene owns WHERE THE PLAYER IS (visibility + input), the clock, the
@ -64,6 +66,8 @@ export interface NightLog {
/** Run-level result attached to the summary. */
export interface RunInfo {
/** The run save itself, so the report form can file into it and persist. */
game: GameState;
nightIndex: number;
totalNights: number;
/** licence pulled — the run is dead, only a fresh one remains */
@ -96,6 +100,7 @@ export class NightScene extends Phaser.Scene {
private stubBeat: StubBeatClock | null = null;
private engine: TechnoEngine | null = null;
private sfx: Sfx | null = null;
private ambience: Ambience | null = null;
private meters!: Meters;
private state!: NightState;
private run!: GameState;
@ -106,6 +111,12 @@ export class NightScene extends Phaser.Scene {
private readonly firedDazza = new Set<string>();
private lastDazzaMin = -99;
private lastVibeSampleMin = -99;
/** Live inspector watch, or null. At most one per night. */
private inspector: InspectorWatch | null = null;
/** Minors the player (or Kayden) has actually let past the rope. */
private minorsInside = 0;
/** True while the floor has an unhandled maggot — the inspector's worst find. */
private maggotLive = false;
private lastMaggotRadioMin = -99;
private readonly firedPulls = new Set<number>();
private queueLength = 0;
@ -152,6 +163,8 @@ export class NightScene extends Phaser.Scene {
// Until the first gesture, a stub keeps beat:tick honest (then hands over).
this.engine = new TechnoEngine(this.bus);
this.sfx = new Sfx(this.engine.ctx, this.engine.sfxBus);
// Rain outside, murmur inside, fluoro hum — starts once audio unlocks.
this.ambience = new Ambience(this.bus, this.engine.ctx, this.engine.sfxBus, this.sfx);
this.stubBeat = new StubBeatClock(this.bus);
this.stubBeat.start();
this.engine.start();
@ -185,10 +198,20 @@ export class NightScene extends Phaser.Scene {
this.bus.on('audio:unlocked', () => {
this.stubBeat?.stop();
this.stubBeat = null;
this.ambience?.start();
}),
this.bus.on('radio:call', () => this.sfx?.play('radioChirp')),
this.bus.on('incident:log', (inc) => {
if (inc.kind === 'maggotUnhandled' && this.state.location === 'door') this.maggotRadio();
if (inc.kind === 'maggotUnhandled') {
this.maggotLive = true;
if (this.state.location === 'door') this.maggotRadio();
}
// A swing on the floor while you're at the rope: the radio tells you.
if (inc.kind === 'fight' && this.state.location === 'door' && inc.detail.startsWith('swung')) {
this.bus.emit('radio:call', { urgency: 3, line: 'there was a SWING inside. get in here. NOW' });
}
// The floor cleared them out; the room is legal again.
if (inc.kind === 'ejection' || inc.kind === 'cutOff') this.maggotLive = false;
}),
);
@ -247,6 +270,12 @@ export class NightScene extends Phaser.Scene {
// forgets one hand in seven — those are the floor's noStamp infractions.
patron.flags.uvStamped =
this.state.location === 'door' ? true : this.rng.stream('kaydenStamp').chance(KAYDEN_STAMP_CHANCE);
// The inspector reads as a boring punter and is scored as one — the ONLY
// thing that marks them is that admitting one starts a clock (§4.2).
if (patron.archetype === 'inspector' && this.inspector === null) {
this.inspector = startWatch(patron.id, this.state.clockMin);
}
if (patron.age < 18) this.minorsInside++;
} else if (verdict === 'deny') this.log.denied++;
else if (verdict === 'sobrietyTest') this.log.tested++;
else if (verdict === 'patDown') this.log.pattedDown++;
@ -275,12 +304,37 @@ export class NightScene extends Phaser.Scene {
}
this.fireDeferred(clockMin);
this.observeInspector(clockMin);
this.maybeDazza(clockMin);
this.maybePull(clockMin);
if (clockMin >= this.clock.config.nightClockMinutes) this.endNight('clock');
}
/**
* The inspector, if one is inside. While they watch, a live breach files its
* strike NOW instead of at the 3 AM audit the whole point being that you
* cannot know which half-hour of the night that was.
*
* Every strike still goes through shouldRecordStrike: observe() returns one
* per breach per minute, so emitting them raw would pull the licence within
* three minutes of any sustained breach.
*/
private observeInspector(clockMin: number): void {
if (!this.inspector) return;
const breaches: VenueBreaches = {
overCapacity: this.state.capacity.inside > this.state.capacity.licensed,
maggotOnFloor: this.maggotLive,
underageInside: this.minorsInside > 0,
};
const seen = observe(this.inspector, clockMin, breaches);
for (const strike of seen.strikes) {
if (shouldRecordStrike(this.state, strike)) this.bus.emit('heat:strike', strike);
}
if (seen.dazzaText) this.bus.emit('dazza:text', { text: seen.dazzaText });
if (seen.watchEnded) this.inspector = null;
}
/** Scripted floor pulls: the radio wants you inside around these times. */
private maybePull(clockMin: number): void {
for (const at of RADIO_PULL_MINS) {
@ -371,14 +425,22 @@ export class NightScene extends Phaser.Scene {
const survived = reason === 'clock';
const weekDone = survived && this.nightIndex >= TOTAL_NIGHTS - 1;
this.run.heatStrikes = [...this.state.heatStrikes];
this.run.pastReports.push([...this.state.incidents]);
// pastReports is written by IncidentReportScene now, not here: Phase 4
// audits what the player CLAIMED happened, and a raw truth dump would have
// nothing to catch them out with.
if (survived) this.run.nightIndex = this.nightIndex + 1;
if (runOver || weekDone) {
clearSave();
} else {
saveGame(this.run);
}
const runInfo: RunInfo = { nightIndex: this.nightIndex, totalNights: TOTAL_NIGHTS, runOver, weekDone };
const runInfo: RunInfo = {
game: this.run,
nightIndex: this.nightIndex,
totalNights: TOTAL_NIGHTS,
runOver,
weekDone,
};
this.time.delayedCall(reason === 'clock' ? 600 : 1400, () => {
this.scene.stop('Door');
@ -397,6 +459,8 @@ export class NightScene extends Phaser.Scene {
for (const off of this.offs) off();
this.offs = [];
this.meters?.destroy();
this.ambience?.destroy();
this.ambience = null;
this.engine?.destroy();
this.engine = null;
this.sfx = null;

View File

@ -87,8 +87,17 @@ export class NightSummaryScene extends Phaser.Scene {
})
.setOrigin(0.5);
this.input.once('pointerdown', next);
this.input.keyboard?.once('keydown-SPACE', next);
// The paperwork sits between the night and the next shift (design §3.3).
const toReport = (): void => {
this.scene.start('IncidentReport', {
state: this.state,
run: this.run?.game ?? null,
seed: this.log.seed,
next,
});
};
this.input.once('pointerdown', toReport);
this.input.keyboard?.once('keydown-SPACE', toReport);
}
/** What clicking through leads to: the next shift, or a fresh run. */

View File

@ -3,7 +3,8 @@ import { renderDoll } from '../../patrons/doll';
import { dollPlan } from '../../patrons/dollPlan';
import { drunkStage } from '../../rules/drunk';
import type { Patron } from '../../data/types';
import { CLAIM_LINES, DRUNK_GREETINGS, PATRON_GREETINGS } from '../../data/strings/door';
import { CLAIM_LINES, DRUNK_GREETINGS, OWNER_HINTS, PATRON_GREETINGS } from '../../data/strings/door';
import { encounterById, type EncounterId } from '../../rules/encounters';
import { MONO } from './ui';
import { ZONE_BANDS, ZONE_ORDER, describeZone, greetingIndex, type InspectZone } from './inspect';
@ -22,6 +23,8 @@ export class PatronUpView {
private patron: Patron | null = null;
private swayT = 0;
private swayPx = 0;
/** The bubble currently on screen. Beats REPLACE it; they do not stack. */
private bubble: Phaser.GameObjects.GameObject[] = [];
constructor(
private readonly scene: Phaser.Scene,
@ -34,7 +37,7 @@ export class PatronUpView {
return this.patron;
}
show(p: Patron): void {
show(p: Patron, encounter?: EncounterId): void {
this.clear();
this.patron = p;
const { scene } = this;
@ -64,7 +67,7 @@ export class PatronUpView {
root.add(hit);
}
this.speak(p);
this.speak(p, encounter);
// step-up: they walk in from the queue side and settle
root.x = this.x - 90;
@ -72,12 +75,26 @@ export class PatronUpView {
scene.tweens.add({ targets: root, x: this.x, alpha: 1, duration: 380, ease: 'Quad.easeOut' });
}
private speak(p: Patron): void {
private speak(p: Patron, encounter?: EncounterId): void {
if (!this.root) return;
// A scripted patron says their own words, in beats, and says them over the
// top of nothing else — the whole point is that they are a person rather
// than a draw from a pool.
const script = encounter ? encounterById(encounter) : undefined;
if (script) {
for (const beat of script.beats) this.sayLater(beat.line, beat.afterMs);
return;
}
const { scene } = this;
const drunk = drunkStage(p.intoxication);
const pool =
p.flags.claimsGuestList || p.flags.knowsOwner
// knowsOwner is checked FIRST and separately: the owner's mate is the trap
// (design §3.1), so his line has to be the one that drops the name, not a
// generic "i'm on the list" shared with every influencer.
const pool = p.flags.knowsOwner
? OWNER_HINTS
: p.flags.claimsGuestList
? CLAIM_LINES
: drunk === 'messy' || drunk === 'maggot' || drunk === 'loose'
? DRUNK_GREETINGS
@ -98,10 +115,44 @@ export class PatronUpView {
.rectangle(0, bubbleY - label.height / 2 + 1, label.width + 10, label.height + 6, 0x14121a, 0.85)
.setStrokeStyle(1, 0x3a3448);
this.root.add([bg, label]);
this.bubble = [bg, label];
scene.tweens.add({ targets: [bg, label], alpha: 0, delay: 3600, duration: 700 });
}
private clearBubble(): void {
for (const o of this.bubble) o.destroy();
this.bubble = [];
}
/** One speech bubble, `afterMs` from the moment they stepped up. */
private sayLater(line: string, afterMs: number): void {
this.scene.time.delayedCall(afterMs, () => {
// The patron may have been ruled on and walked off before this beat was
// due — a stale bubble floating over an empty rope is the bug here.
if (!this.root || this.patron === null) return;
const { scene } = this;
// An exchange is a sequence, not a pile: beat two replaces beat one.
this.clearBubble();
const bubbleY = -DOLL_H * SCALE - 16;
const label = scene.add
.text(0, bubbleY, `"${line}"`, {
fontFamily: MONO,
fontSize: '8px',
color: '#e8dcc4',
align: 'center',
wordWrap: { width: 190 },
})
.setOrigin(0.5, 1);
const bg = scene.add
.rectangle(0, bubbleY - label.height / 2 + 1, label.width + 10, label.height + 6, 0x14121a, 0.9)
.setStrokeStyle(1, 0x584a68);
this.root.add([bg, label]);
this.bubble = [bg, label];
scene.tweens.add({ targets: [bg, label], alpha: 0, delay: 3200, duration: 700 });
});
}
private showTooltip(zone: InspectZone, localY: number): void {
this.hideTooltip();
if (!this.root || !this.patron) return;
@ -191,6 +242,7 @@ export class PatronUpView {
}
clear(): void {
this.clearBubble();
this.hideTooltip();
this.root?.destroy();
this.root = null;

View File

@ -3,12 +3,18 @@ import type { RngStream, SeededRNG } from '../../core/SeededRNG';
import { generatePatron } from '../../patrons/generator';
import type { Patron } from '../../data/types';
import { nextArrivalGapMin } from './arrivalCurve';
import { assignListedName, generateGuestList, type GuestList } from '../../rules/guestList';
import {
ENCOUNTERS,
encounterById,
scheduleEncounters,
type EncounterId,
} from '../../rules/encounters';
/** Second patron of the night lands here regardless of the curve's opinion. */
const OPENING_ARRIVAL_MIN = 3;
/** 2:45 AM. Last drinks is 3:00; the line has to stop growing before then. */
const DOORS_SHUT_MIN = 345;
import '../../rules/doorTypes'; // side-effect: PatronFlags.handHolding augmentation
// Pure queue logic — no Phaser. DoorScene renders whatever this says is true.
// Owning the queue outside the scene keeps the "how much does neglect cost?"
@ -48,6 +54,8 @@ export const QUEUE_TUNING = {
regularMaxComebacks: 2,
/** chance a new arrival brings a hand-holding partner (feeds noHandHolders) */
coupleChance: 0.12,
/** scripted moral encounters injected per night (design §4.3) */
encountersPerNight: 3,
} as const;
export interface QueueSnapshot {
@ -70,6 +78,14 @@ export class QueueManager {
private readonly arrivalRng: RngStream;
private readonly coupleRng: RngStream;
private readonly comebackRng: RngStream;
private readonly listRng: RngStream;
private readonly encounterRng: RngStream;
/** tonight's clipboard — read by the door's guest-list panel */
readonly guestList: GuestList;
/** scripted arrivals still owed tonight, soonest first */
private pendingEncounters: Array<{ atMin: number; id: EncounterId }> = [];
/** patronId -> the script they are playing, for the door to look up */
private readonly encounterOf = new Map<string, EncounterId>();
/** patrons queued to re-enter the queue: [clockMin, patron] */
private readonly comebacks: Array<{ atMin: number; patron: Patron }> = [];
@ -88,6 +104,13 @@ export class QueueManager {
// arrival time for the rest of the night, which is exactly the cross-system
// perturbation CONTRACTS.md §6 gives named streams to prevent.
this.comebackRng = rng.stream('comebacks');
this.listRng = rng.stream('guestList');
this.encounterRng = rng.stream('encounters');
this.guestList = generateGuestList(
this.listRng,
this.listRng.int(8, Math.min(14, ENCOUNTERS.length + 12)),
);
this.pendingEncounters = scheduleEncounters(this.encounterRng, QUEUE_TUNING.encountersPerNight);
// Somebody is ALWAYS at the rope when the shift starts. A game that opens on
// an empty street reads as broken, however accurate a 9 PM lull would be.
this.enqueue(this.make());
@ -126,6 +149,14 @@ export class QueueManager {
// happens to be due. Shut means shut.
if (clockMin >= DOORS_SHUT_MIN) return;
// Scripted arrivals jump the curve — they are people who turned up at a
// particular moment, not draws from a distribution.
while (this.pendingEncounters.length > 0 && clockMin >= this.pendingEncounters[0]!.atMin) {
const due = this.pendingEncounters.shift()!;
const scripted = this.makeScripted(due.id);
if (scripted) this.enqueue(scripted);
}
for (let i = this.comebacks.length - 1; i >= 0; i--) {
const c = this.comebacks[i]!;
if (clockMin >= c.atMin) {
@ -235,8 +266,39 @@ export class QueueManager {
}
}
/** Which scripted encounter this patron is, if any. */
encounterFor(patron: Patron): EncounterId | undefined {
return this.encounterOf.get(patron.id);
}
private make(): Patron {
return generatePatron({ rng: this.rng, nightDate: this.nightDate }, this.clockMin);
const p = generatePatron({ rng: this.rng, nightDate: this.nightDate }, this.clockMin);
// Anyone who says they're on the list gets a name the clipboard has an
// opinion about. Done here rather than in the generator because the list is
// a door-phase prop and patrons/ is frozen — see rules/guestList.ts for why
// an exact match is the suspicious one.
if (p.flags.claimsGuestList) {
p.idCard.name = assignListedName(this.listRng, this.guestList, p.flags.onGuestList === true);
}
return p;
}
/**
* A scripted patron, if one is due. Generated through the SAME generator so
* their doll, ID and outfit are ordinary `dress()` only changes what the
* script needs, which is why they don't announce themselves before speaking.
*/
private makeScripted(id: EncounterId): Patron | null {
const enc = encounterById(id);
if (!enc) return null;
const p = generatePatron({ rng: this.rng, nightDate: this.nightDate }, this.clockMin, enc.archetype);
enc.dress(p, this.encounterRng);
if (p.flags.claimsGuestList) {
p.idCard.name = assignListedName(this.listRng, this.guestList, p.flags.onGuestList === true);
}
this.encounterOf.set(p.id, id);
return p;
}
private enqueue(p: Patron): Patron | null {

View File

@ -6,12 +6,13 @@ import { StubBeatClock } from '../../core/StubBeatClock';
import { Meters, freshNightState } from '../../core/meters';
import { drunkStage } from '../../rules/drunk';
import { generatePatron, _resetPatronSerial } from '../../patrons/generator';
import type { NightState } from '../../data/types';
import type { DrunkStage, NightState } from '../../data/types';
import { HudStub } from '../shared/HudStub';
import { VENUE } from './venueMap';
import { NORMAL_CONE, UV_CONE, inCone, type ConeSpec } from './cone';
import { CrowdSim, type Agent } from './crowdSim';
import { FightDirector } from './fightDirector';
import { freshPlayer, stepPlayer, type PlayerInput, type PlayerState } from './player';
import { FloorView } from './FloorView';
import { maggotOverdue } from './escalation';
@ -31,6 +32,10 @@ const SPAWN_EVERY_MS = 420;
/** Admitted at the rope → appears on the floor after the walk in. */
const WALK_IN_MS: [number, number] = [6000, 14000];
const STAGE_RANK: Readonly<Record<DrunkStage, number>> = {
sober: 0, tipsy: 1, loose: 2, messy: 3, maggot: 4,
};
/** What pressing E on the current target would do. */
type InteractKind = 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'exit';
@ -66,6 +71,7 @@ export class FloorDemoScene extends Phaser.Scene {
private ctxOffs: Array<() => void> = [];
private view!: FloorView;
private crowd!: CrowdSim;
private fights!: FightDirector;
private player!: PlayerState;
private cutOff!: CutOffOverlay;
@ -81,11 +87,15 @@ export class FloorDemoScene extends Phaser.Scene {
/** patronId -> clock minute we first saw them as a maggot (deferred heat risk). */
private maggotSeen = new Map<string, number>();
private spotted = new Set<string>();
private liveFight: { id: string; cause: string } | null = null;
/** Worst drunk stage we've already had words about, per patron. */
private cutOffDealt = new Map<string, DrunkStage>();
private promptText!: Phaser.GameObjects.Text;
private statusText!: Phaser.GameObjects.Text;
private radioText!: Phaser.GameObjects.Text;
private radioMs = 0;
private calmAccMs = 0;
constructor(key: string = 'FloorDemo') {
super(key);
@ -93,7 +103,9 @@ export class FloorDemoScene extends Phaser.Scene {
create(data?: { night?: NightContext }): void {
this.nightCtx = data?.night ?? null;
this.events.once('shutdown', () => this.shutdown());
// Wired explicitly: Phaser does not invoke a `shutdown` METHOD on a Scene
// subclass; without this, listeners and sprites leak on every scene stop.
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.teardown());
this.cameras.main.setBounds(0, 0, VENUE.width * 16, VENUE.height * 16);
this.cameras.main.setBackgroundColor('#04040a');
@ -131,18 +143,23 @@ export class FloorDemoScene extends Phaser.Scene {
this.stall = new StallOverlay(this, this.bus, ctx.beatIntervalMs);
this.crowd = new CrowdSim({ map: VENUE, bus: this.bus, rng: this.rng.stream('crowd') });
this.fights?.destroy();
this.fights = new FightDirector({ bus: this.bus, rng: this.rng.stream('fights'), crowd: this.crowd });
const entry = VENUE.anchors.entry[0] ?? { x: 64, y: 64 };
this.player = freshPlayer(entry.x, entry.y);
this.view.reset();
this.escorting = null;
this.target = null;
this.liveFight = null;
this.uvMode = false;
this.present = false; // the night starts at the rope
this.elapsedMs = 0;
this.pendingSpawns = [];
this.maggotSeen.clear();
this.spotted.clear();
this.cutOffDealt.clear();
this.ctxOffs = [
// The floor is fed by the door: every admission walks in a few seconds later.
@ -187,17 +204,22 @@ export class FloorDemoScene extends Phaser.Scene {
this.stall = new StallOverlay(this, this.bus, this.beat.beatIntervalMs);
this.crowd = new CrowdSim({ map: VENUE, bus: this.bus, rng: this.rng.stream('crowd') });
this.fights?.destroy();
this.fights = new FightDirector({ bus: this.bus, rng: this.rng.stream('fights'), crowd: this.crowd });
this.bus.on('beat:tick', ({ beatIndex }) => this.crowd.onBeat(beatIndex));
const entry = VENUE.anchors.entry[0] ?? { x: 64, y: 64 };
this.player = freshPlayer(entry.x, entry.y);
this.view.reset();
this.escorting = null;
this.target = null;
this.liveFight = null;
this.uvMode = false;
this.spawnAccMs = 0;
this.maggotSeen.clear();
this.spotted.clear();
this.cutOffDealt.clear();
this.clock.start();
this.beat.start();
@ -260,8 +282,9 @@ export class FloorDemoScene extends Phaser.Scene {
this.pendingSpawns.splice(i, 1);
// Licensed capacity is 80 but 44 agents is the sim's perf budget; past
// that, arrivals dissolve into the (conceptual) back room. log()-style
// honesty: this is a cap, not a simulation.
if (this.crowd.agents.length < MAX_CROWD) this.crowd.spawn(s.patron);
// honesty: this is a cap, not a simulation. Counts LIVE bodies (ejections
// must not permanently shrink the venue).
if (this.liveCrowd < MAX_CROWD) this.crowd.spawn(s.patron);
}
// Player at the door: the floor keeps living (drinks keep being drunk,
@ -269,11 +292,23 @@ export class FloorDemoScene extends Phaser.Scene {
// nothing needs the camera, the cone, or input.
if (!this.present) {
this.crowd.update(dt, this.clock.clockMin);
// Fights don't wait for you. An off-map player position means the
// interpose check can never accidentally succeed — unattended fights
// swing, and the incident radios the door.
this.fights.update(-9999, -9999, dt);
this.tickFight();
this.trackMaggots();
this.tickCalm(dt);
return;
}
}
// Fights burn their fuse through overlays too. You can't be in a cubicle and
// between two blokes at once — choosing which one to abandon IS the floor.
// ESC is always the way out of a modal, so it's a decision, not a trap.
this.fights.update(this.player.x, this.player.y, dt);
this.tickFight();
if (this.overlayOpen) {
this.cutOff.update(dt);
this.stall.update(dt);
@ -291,8 +326,11 @@ export class FloorDemoScene extends Phaser.Scene {
if (!this.nightCtx) {
// Demo mode stands in for the door with a synthetic spawner.
// CrowdSim deliberately keeps departed agents around so find() still works,
// so the cap has to count LIVE bodies — otherwise every ejection permanently
// shrinks the venue and the floor drains to empty over the night.
this.spawnAccMs += dt;
while (this.spawnAccMs > SPAWN_EVERY_MS && this.crowd.agents.length < MAX_CROWD) {
while (this.spawnAccMs > SPAWN_EVERY_MS && this.liveCrowd < MAX_CROWD) {
this.spawnAccMs -= SPAWN_EVERY_MS;
this.spawnOne();
}
@ -304,6 +342,7 @@ export class FloorDemoScene extends Phaser.Scene {
if (this.escorting) this.tickEscort(dt);
this.trackMaggots();
this.tickCalm(dt);
this.target = this.escorting ? null : this.findTarget();
this.cameras.main.centerOn(this.player.x, this.player.y);
@ -315,6 +354,43 @@ export class FloorDemoScene extends Phaser.Scene {
this.drawHud(dt);
}
/**
* Aggro physiology (Phase-1 floor request, approved): a calm floor slowly
* cools the night whether or not the player is watching it. "Calm" means no
* fight brewing and no unhandled maggot; the drift is deliberately slower
* than any single mistake so it reads as breathing, not forgiveness.
* ~0.3 aggro per clock-minute (1 clock-min 2.17s real at DEFAULT_CLOCK).
*/
private tickCalm(dt: number): void {
if (this.fights.active) {
this.calmAccMs = 0;
return;
}
for (const a of this.crowd.agents) {
if (!a.gone && !a.handled && drunkStage(a.patron.intoxication) === 'maggot') {
this.calmAccMs = 0;
return;
}
}
this.calmAccMs += dt;
if (this.calmAccMs >= 1000) {
this.calmAccMs -= 1000;
this.bus.emit('meters:delta', { aggro: -0.14 });
}
}
/** Have we already dealt with this patron at their current level of drunk? */
private settledAt(agent: Agent, stage: DrunkStage): boolean {
const dealt = this.cutOffDealt.get(agent.patron.id);
return dealt !== undefined && STAGE_RANK[stage] <= STAGE_RANK[dealt];
}
private get liveCrowd(): number {
let n = 0;
for (const a of this.crowd.agents) if (!a.gone) n++;
return n;
}
private coneSpec(): ConeSpec {
const c = this.uvMode ? UV_CONE : NORMAL_CONE;
return { x: this.player.x, y: this.player.y, facing: this.player.facing, ...c };
@ -338,6 +414,7 @@ export class FloorDemoScene extends Phaser.Scene {
for (const a of this.crowd.agents) {
if (a.gone || a.handled) continue;
if (drunkStage(a.patron.intoxication) !== 'maggot') continue;
if (this.settledAt(a, 'maggot')) continue;
const seen = this.maggotSeen.get(a.patron.id);
if (seen === undefined) {
this.maggotSeen.set(a.patron.id, now);
@ -374,7 +451,11 @@ export class FloorDemoScene extends Phaser.Scene {
if (d > INTERACT_RANGE) continue;
const occupants = this.crowd.stallOccupancy().get(s.id) ?? [];
if (occupants.length < 2) continue;
this.spot(s.id, 'stall');
// Report a real patron, not the cubicle: `patronId` is a frozen contract
// field and a stall id resolves to nobody. Keying on the occupant also
// means a fresh pair in the same cubicle reports again later in the night.
const first = occupants[0];
if (first) this.spot(first.patron.id, 'stall');
return { kind: 'stall', stallId: s.id, prompt: 'E — two pairs of feet under that door' };
}
@ -400,7 +481,12 @@ export class FloorDemoScene extends Phaser.Scene {
const stage = drunkStage(best.patron.intoxication);
if (stage === 'messy' || stage === 'maggot' || stage === 'loose') {
return { kind: 'drunk', agent: best, prompt: 'E — have a word' };
// Dealing with someone settles that stage, not the whole night. The Quiet
// Messy One you sensibly watered at 'loose' has to come back around when
// they hit 'maggot' — otherwise a correct early call blinds you to them.
if (!this.settledAt(best, stage)) {
return { kind: 'drunk', agent: best, prompt: 'E — have a word' };
}
}
if (best.patron.flags.contraband?.length) {
this.spot(best.patron.id, 'contraband');
@ -430,14 +516,18 @@ export class FloorDemoScene extends Phaser.Scene {
(r) => {
this.bus.emit('meters:delta', { vibe: r.vibeDelta, aggro: r.aggroDelta });
if (r.solved) {
// Re-query: the crowd keeps simulating behind the modal, so whoever was
// in there when you started banging may have wandered off. Scattering
// the captured list would retire innocent patrons standing elsewhere.
const inThere = this.crowd.stallOccupancy().get(stallId) ?? [];
// Zeroing the dwell makes the sim's own stall-exit path fire next
// frame: both get put back at the door and independently re-target,
// which reads exactly like scattering in opposite directions.
for (const a of occupants) {
for (const a of inThere) {
a.handled = true;
a.dwellMs = 0;
}
this.logIncident('stallBusted', occupants[0]?.patron.id, 'Two out of one cubicle.');
this.logIncident('stallBusted', inThere[0]?.patron.id, 'Two out of one cubicle.');
}
},
);
@ -448,7 +538,9 @@ export class FloorDemoScene extends Phaser.Scene {
this.cutOff.open({ patron: agent.patron, stage }, (r) => {
if (!r) return;
this.bus.emit('meters:delta', { vibe: r.vibeDelta, aggro: r.aggroDelta });
if (r.resolved) agent.handled = true;
// Only an ejection retires someone for good. A resolved-but-staying patron
// is settled at THIS stage and becomes your problem again if they get worse.
if (r.resolved && !r.eject) this.cutOffDealt.set(agent.patron.id, stage);
if (r.eject) {
this.startEscort(agent, r.outcome === 'clean' ? 'clean' : 'messy', 'drunk');
} else {
@ -478,6 +570,25 @@ export class FloorDemoScene extends Phaser.Scene {
this.logIncident('ejection', agent.patron.id, `${kind} / ${style}`);
}
/** Draw the brewing-fight tell and narrate the resolution when one lands. */
private tickFight(): void {
const f = this.fights.active;
const pair = this.fights.pair();
this.view.setFight(pair, f?.shoves ?? 0, f?.msLeft ?? 0);
// The director releases both agents the instant it resolves, so pair() and
// cause are already gone by the time we're told about it — remember them
// while the fight is still live.
if (pair) this.liveFight = { id: pair[0].patron.id, cause: this.fights.cause };
const res = this.fights.takeResolution();
if (!res) return;
const was = this.liveFight;
this.liveFight = null;
this.toast(res.line);
this.logIncident('fight', was?.id, `${res.phase === 'broken' ? 'broken up' : 'swung'}${was?.cause ?? ''}`);
}
private tickEscort(dt: number): void {
const a = this.escorting;
if (!a) return;
@ -491,7 +602,12 @@ export class FloorDemoScene extends Phaser.Scene {
// ---- chrome ------------------------------------------------------------
private radio(line: string): void {
this.radioText.setText(line ? `KAYDEN: ${line}` : '');
this.toast(line ? `KAYDEN: ${line}` : '');
}
/** Transient line in the corner — radio chatter, fight outcomes, anything shouted. */
private toast(line: string): void {
this.radioText.setText(line);
this.radioMs = 5000;
}
@ -504,20 +620,26 @@ export class FloorDemoScene extends Phaser.Scene {
this.radioMs -= dt;
if (this.radioMs <= 0) this.radioText.setText('');
}
// A brewing fight outranks everything — it's the one thing on a clock.
this.promptText.setText(
this.escorting ? 'escorting — walk them to the door (no interacting)' : (this.target?.prompt ?? ''),
this.fights.active
? 'GET BETWEEN THEM'
: this.escorting
? 'escorting — walk them to the door (no interacting)'
: (this.target?.prompt ?? ''),
);
const maggots = this.crowd.agents.filter(
(a) => !a.gone && !a.handled && drunkStage(a.patron.intoxication) === 'maggot',
).length;
this.statusText.setText(
`${this.clock.label} seed ${this.seed} crowd ${this.crowd.agents.length} maggots ${maggots} ${this.uvMode ? 'UV' : 'torch'} incidents ${this.night.incidents.length}`,
`${this.clock.label} seed ${this.seed} crowd ${this.liveCrowd} maggots ${maggots} ${this.uvMode ? 'UV' : 'torch'} incidents ${this.night.incidents.length}`,
);
}
shutdown(): void {
private teardown(): void {
for (const off of this.ctxOffs) off();
this.ctxOffs = [];
this.fights?.destroy();
this.crowd?.destroy();
this.view?.destroy();
this.cutOff?.destroy();

View File

@ -2,6 +2,7 @@ import Phaser from 'phaser';
import { renderDoll } from '../../patrons/doll';
import { dollPlan } from '../../patrons/dollPlan';
import { conePolygon, type ConeSpec } from './cone';
import { FIGHT_FUSE_MS } from './fight';
import { TILE, tileAt, tileColour, type VenueMap } from './venueMap';
import type { Agent } from './crowdSim';
@ -35,6 +36,22 @@ const NEON_TINTS = [0xff3d9a, 0x38e0ff, 0xb45cff] as const;
const GLOW_SIZE = 64;
// A fight is spotted by motion, not by torchlight (design §3.2) — the tell has to
// survive the darkness sheet, so the marker rides at D_MARKER and the shove is
// carried by the silhouettes, which stay legible through the 50% black.
const FIGHT_PINK = 0xd03470;
const FIGHT_AMBER = 0xd8a020;
const SHOVE_STEPS = 5;
// Wobble: bigger and faster every rung, so a fight you clocked late still looks
// worse than one you clocked early.
const SHOVE_AMP_PX = 1.2;
const SHOVE_AMP_STEP_PX = 1.1;
const SHOVE_MS = 260;
const SHOVE_MS_STEP = 22;
// Pulse period at a full fuse, and how much of it the last second buys back.
const PULSE_SLOW_MS = 520;
const PULSE_SPAN_MS = 390;
interface AgentGfx {
sil: Phaser.GameObjects.Image;
detail: Phaser.GameObjects.Image;
@ -59,8 +76,14 @@ export class FloorView {
private readonly playerLight: Phaser.GameObjects.Image;
private readonly playerImg: Phaser.GameObjects.Image;
private readonly marker: Phaser.GameObjects.Image;
private readonly fightHalo: Phaser.GameObjects.Image;
private readonly fightPip: Phaser.GameObjects.Image;
private readonly agents = new Map<string, AgentGfx>();
// Render-only shove displacement by patron id. The sim owns agent.x/agent.y;
// syncAgents adds this on top so a shoving pair jitters without the crowd
// simulation ever hearing about it.
private readonly shove = new Map<string, { dx: number; dy: number }>();
private frame = 0;
private uv = false;
@ -118,6 +141,20 @@ export class FloorView {
this.playerImg = scene.add.image(0, 0, PLAYER_TEX).setDepth(D_MARKER);
this.marker = scene.add.image(0, 0, MARKER_TEX).setDepth(D_MARKER).setVisible(false);
// Built here and only ever shown/hidden — a fight starts on a frame where
// there is already plenty going on.
this.fightHalo = scene.add
.image(0, 0, GLOW_TEX)
.setDepth(D_MARKER)
.setBlendMode(Phaser.BlendModes.ADD)
.setTint(FIGHT_PINK)
.setVisible(false);
this.fightPip = scene.add
.image(0, 0, MARKER_TEX)
.setDepth(D_MARKER)
.setTint(FIGHT_AMBER)
.setVisible(false);
}
syncAgents(agents: readonly Agent[]): void {
@ -150,9 +187,14 @@ export class FloorView {
const y = a.y - Math.abs(Math.sin(a.bobPhase * Math.PI * 2)) * bob;
const x = g.swayPx > 0 ? a.x + Math.sin(t / 260 + g.phase) * g.swayPx : a.x;
g.sil.setPosition(x, y);
g.detail.setPosition(x, y);
g.uvDot.setPosition(x + 4, y + 2);
// Empty unless this punter is squaring up, so the common path is a miss.
const sh = this.shove.get(a.patron.id);
const sx = sh ? x + sh.dx : x;
const sy = sh ? y + sh.dy : y;
g.sil.setPosition(sx, sy);
g.detail.setPosition(sx, sy);
g.uvDot.setPosition(sx + 4, sy + 2);
g.detail.setVisible(!this.uv);
// No stamp, no dot — the absence is the tell.
g.uvDot.setVisible(this.uv && a.patron.flags.uvStamped === true);
@ -200,7 +242,58 @@ export class FloorView {
this.marker.setAlpha(0.55 + 0.45 * Math.abs(Math.sin(this.scene.time.now / 260)));
}
/** Draw the brewing-fight tell between two agents. Pass null when no fight is live. */
setFight(pair: readonly [Agent, Agent] | null, shoves: number, msLeft: number): void {
this.shove.clear();
if (!pair) {
this.fightHalo.setVisible(false);
this.fightPip.setVisible(false);
return;
}
const [a, b] = pair;
const t = this.scene.time.now;
const rung = Math.max(0, Math.min(SHOVE_STEPS, shoves));
// Unit vector a -> b. Two punters standing in exactly the same spot still
// need somewhere to shove, so fall back to sideways.
const dx = b.x - a.x;
const dy = b.y - a.y;
const dist = Math.hypot(dx, dy);
const ux = dist > 0 ? dx / dist : 1;
const uy = dist > 0 ? dy / dist : 0;
// Positive swings them into each other, negative rocks them back off it.
const amp = SHOVE_AMP_PX + rung * SHOVE_AMP_STEP_PX;
const period = SHOVE_MS - rung * SHOVE_MS_STEP;
const off = Math.sin((t / period) * Math.PI * 2) * amp;
this.shove.set(a.patron.id, { dx: ux * off, dy: uy * off });
this.shove.set(b.patron.id, { dx: -ux * off, dy: -uy * off });
const mx = (a.x + b.x) / 2;
const my = (a.y + b.y) / 2;
// Urgency drives the pulse, not the rung: the fuse is what you're racing.
const u = Math.max(0, Math.min(1, 1 - msLeft / FIGHT_FUSE_MS));
const pulseMs = PULSE_SLOW_MS - PULSE_SPAN_MS * u;
const p = 0.5 + 0.5 * Math.sin((t / pulseMs) * Math.PI * 2);
this.fightHalo
.setVisible(true)
.setPosition(mx, my)
.setAlpha(0.22 + 0.5 * p)
.setScale(1.5 + 0.55 * p + 0.3 * u);
this.fightPip
.setVisible(true)
.setPosition(mx, my - 15 - 3 * p)
.setAlpha(0.55 + 0.45 * p)
.setScale(1 + 0.35 * u);
}
destroy(): void {
this.shove.clear();
this.fightHalo.destroy();
this.fightPip.destroy();
for (const g of this.agents.values()) this.destroyGfx(g);
this.agents.clear();
this.detailC.clearMask(true);
@ -239,6 +332,20 @@ export class FloorView {
};
}
/**
* Drop every cached sprite. The scene must call this on reseed: patron ids are
* recycled from p0 by _resetPatronSerial(), so without it the new run's crowd
* inherits the old run's dolls, sway and stamps.
*/
reset(): void {
for (const g of this.agents.values()) this.destroyGfx(g);
this.agents.clear();
this.shove.clear();
this.fightHalo.setVisible(false);
this.fightPip.setVisible(false);
this.marker.setVisible(false);
}
private drop(id: string): void {
const g = this.agents.get(id);
if (!g) return;

View File

@ -9,7 +9,8 @@ import { isWalkable, isWalkableWorld, tileToWorld, worldToTile } from './venueMa
import type { StallDef, VenueMap, WorldPoint } from './venueMap';
export type Activity =
| 'walking' | 'atBar' | 'dancing' | 'inBooth' | 'inStall' | 'smoking' | 'escorted' | 'leaving';
| 'walking' | 'atBar' | 'dancing' | 'inBooth' | 'inStall' | 'smoking' | 'escorted' | 'leaving'
| 'squaringUp';
export interface Agent {
patron: Patron;
@ -178,7 +179,8 @@ export class CrowdSim {
for (const a of this.agents) {
if (a.gone) continue;
a.lastBumpMs += ms;
if (a.activity === 'escorted') continue;
// Both are driven from outside: the player's escort, and the fight director.
if (a.activity === 'escorted' || a.activity === 'squaringUp') continue;
if (a.activity === 'walking') this.walk(a, ms);
else this.dwell(a, ms);
}
@ -282,6 +284,34 @@ export class CrowdSim {
}
}
/** Pin an agent for a brewing fight. False if it isn't available (gone/escorted/in a stall). */
pinForFight(agent: Agent): boolean {
if (agent.gone) return false;
if (
agent.activity === 'escorted' || agent.activity === 'leaving' ||
agent.activity === 'inStall' || agent.activity === 'squaringUp'
) return false;
agent.activity = 'squaringUp';
agent.vx = 0;
agent.vy = 0;
// Whatever they were nursing is over; the fight owns the dwell now.
agent.dwellMs = 0;
return true;
}
/** Release back into the crowd; they walk it off. */
releaseFromFight(agent: Agent): void {
// Anything else got hold of them mid-fight (escorted, then gone) — leave it alone.
if (agent.activity !== 'squaringUp') return;
agent.activity = 'walking';
agent.dwellMs = 0;
agent.stallId = undefined;
const plan = this.plans.get(agent);
if (plan) plan.stumbleMs = 0;
this.chooseTarget(agent);
}
find(patronId: string): Agent | undefined {
return this.agents.find((a) => a.patron.id === patronId);
}

124
src/scenes/floor/fight.ts Normal file
View File

@ -0,0 +1,124 @@
// Fight brewing (design §3.2). Unlike the stall and pat-down, this one is not a
// modal minigame — it spends the floor's only real resource, where you are
// standing. Pure: no Phaser, no bus. The scene owns spawning and cooldowns.
import { FIGHT_SHOVES } from '../../data/strings/floor';
export const FIGHT_FUSE_MS = 7000;
export const BREAK_RADIUS_PX = 26;
// A fight is an EVENT, not a heartbeat. At 20s an idle night produced 28 brawls,
// which swamped every other meter on the floor and generated 28 heat strikes
// against a run cap of 3. At 150s a busy night brews four or five.
export const FIGHT_COOLDOWN_MS = 150_000;
export const FIGHT_MIN_DIST_PX = 34;
// Both must be at least this drunk to square up. 0.45 is exactly where
// drunkStage() flips tipsy -> loose, so this is a loose-and-above problem: a
// merely tipsy pair never kicks off.
export const FIGHT_MIN_INTOX = 0.45;
/** Escalation rungs the fuse burns through; also the cap on `shoves`. */
const SHOVE_STEPS = 5;
export type FightPhase = 'shoving' | 'broken' | 'swung';
export interface FightState {
aId: string;
bId: string;
msLeft: number;
phase: FightPhase;
/** Cosmetic 0..SHOVE_STEPS tell; monotonic, derived from the burnt fuse. */
shoves: number;
}
export interface FightOutcome {
vibeDelta: number;
aggroDelta: number;
heat: boolean;
}
// You still got shoved about breaking it up, hence the aggro on a win.
const BROKEN_VIBE = 6;
const BROKEN_AGGRO = 1;
// A landed swing is the worst thing that can happen on the floor (design §3.2).
const SWUNG_VIBE = -12;
const SWUNG_AGGRO = 20;
export function freshFight(aId: string, bId: string): FightState {
return { aId, bId, msLeft: FIGHT_FUSE_MS, phase: 'shoving', shoves: 0 };
}
/** Rungs burnt so far. Clamped both ends so a mangled msLeft can't escape 0..5. */
function shovesFor(msLeft: number): number {
const burnt = FIGHT_FUSE_MS - msLeft;
const step = Math.floor(burnt / (FIGHT_FUSE_MS / SHOVE_STEPS));
return Math.max(0, Math.min(SHOVE_STEPS, step));
}
/** Shove chatter for the current rung. Clamped — FIGHT_SHOVES has no 6th line. */
export function shoveLine(shoves: number): string {
const i = Math.max(0, Math.min(FIGHT_SHOVES.length - 1, Math.floor(shoves)));
return FIGHT_SHOVES[i] ?? '';
}
/**
* Is the player physically between A and B? True when the point is within
* `radius` of the A-B SEGMENT (not the infinite line) so standing past either
* shoulder does not count. Handle the degenerate A==B case without dividing by zero.
*/
export function isBetween(
px: number,
py: number,
ax: number,
ay: number,
bx: number,
by: number,
radius: number = BREAK_RADIUS_PX,
): boolean {
const abx = bx - ax;
const aby = by - ay;
const apx = px - ax;
const apy = py - ay;
const lenSq = abx * abx + aby * aby;
// Two patrons standing in the same spot: the segment is a point.
const t = lenSq > 0 ? Math.max(0, Math.min(1, (apx * abx + apy * aby) / lenSq)) : 0;
const dx = apx - abx * t;
const dy = apy - aby * t;
return dx * dx + dy * dy <= radius * radius;
}
/** Pure: returns the next state, never mutates the input. */
export function stepFight(s: FightState, deltaMs: number, intervening: boolean): FightState {
// Once it's broken or swung it's history — later frames can't relitigate it.
if (s.phase !== 'shoving') return s;
if (intervening) {
return { ...s, phase: 'broken' };
}
const msLeft = Math.max(0, s.msLeft - deltaMs);
return {
...s,
msLeft,
phase: msLeft <= 0 ? 'swung' : 'shoving',
// Never goes backwards: msLeft only ever decreases while shoving.
shoves: Math.max(s.shoves, shovesFor(msLeft)),
};
}
export function scoreFight(s: FightState): FightOutcome {
if (s.phase === 'broken') {
return { vibeDelta: BROKEN_VIBE, aggroDelta: BROKEN_AGGRO, heat: false };
}
if (s.phase === 'swung') {
return { vibeDelta: SWUNG_VIBE, aggroDelta: SWUNG_AGGRO, heat: true };
}
return { vibeDelta: 0, aggroDelta: 0, heat: false };
}
/** Both drunk enough and close enough to kick off. */
export function fightEligible(aIntox: number, bIntox: number, distPx: number): boolean {
return (
aIntox >= FIGHT_MIN_INTOX && bIntox >= FIGHT_MIN_INTOX && distPx <= FIGHT_MIN_DIST_PX
);
}

View File

@ -0,0 +1,200 @@
// Owns the fight lifecycle end to end: when one brews, who's in it, whether you
// got between them in time, and what it cost. The scene only draws and narrates.
// fight.ts stays pure; this is the bit that touches the crowd and the bus.
import type { EventBus } from '../../core/EventBus';
import type { RngStream } from '../../core/SeededRNG';
import type { Activity, Agent, CrowdSim } from './crowdSim';
import {
FIGHT_COOLDOWN_MS,
fightEligible,
freshFight,
isBetween,
scoreFight,
stepFight,
} from './fight';
import type { FightOutcome, FightPhase, FightState } from './fight';
import { FIGHT_BROKEN, FIGHT_CAUSE, FIGHT_SWUNG } from '../../data/strings/floor';
// Not spoken dialogue — this lands in the heat log, so it reads as a licensing
// clerk's shorthand rather than as anything Dazza would say out loud.
const HEAT_REASON = 'Fight on the floor';
// You can only square up from somewhere you can actually swing. Anyone mid-stall,
// mid-escort or already pinned is somebody else's problem.
const AVAILABLE: ReadonlySet<Activity> = new Set<Activity>([
'walking', 'atBar', 'dancing', 'inBooth', 'smoking',
]);
export interface FightDirectorOpts {
bus: EventBus;
rng: RngStream;
crowd: CrowdSim;
}
interface Live {
state: FightState;
a: Agent;
b: Agent;
cause: string;
}
export interface FightResolution {
outcome: FightOutcome;
phase: FightPhase;
line: string;
}
export class FightDirector {
private readonly bus: EventBus;
private readonly rng: RngStream;
private readonly crowd: CrowdSim;
private live: Live | null = null;
private resolution: FightResolution | null = null;
// Starts full so the first fight of the night doesn't need a warm-up.
private cooldownMs = FIGHT_COOLDOWN_MS;
constructor(opts: FightDirectorOpts) {
this.bus = opts.bus;
this.rng = opts.rng;
this.crowd = opts.crowd;
}
/** The live fight, or null. At most ONE at a time — two is unreadable in the dark. */
get active(): FightState | null {
return this.live?.state ?? null;
}
/** Flavour for the current fight, from strings.ts FIGHT_CAUSE. */
get cause(): string {
return this.live?.cause ?? '';
}
/** The two agents squaring up, or null. */
pair(): [Agent, Agent] | null {
return this.live ? [this.live.a, this.live.b] : null;
}
/** Step the fight and/or consider starting one. */
update(playerX: number, playerY: number, deltaMs: number): void {
const ms = Math.max(0, deltaMs);
const live = this.live;
if (live) {
this.step(live, playerX, playerY, ms);
return;
}
// Only counted while the floor is calm — a long fight doesn't earn you a rest.
this.cooldownMs += ms;
if (this.cooldownMs >= FIGHT_COOLDOWN_MS) this.tryStart();
}
/** Latest resolution for the scene to narrate, consumed once (returns then clears). */
takeResolution(): FightResolution | null {
const r = this.resolution;
this.resolution = null;
return r;
}
destroy(): void {
if (this.live) {
this.release(this.live.a);
this.release(this.live.b);
}
this.live = null;
this.resolution = null;
}
// ---- internals ----
private step(live: Live, playerX: number, playerY: number, ms: number): void {
// Chucked out (or otherwise removed) mid-argument: there's no fight to score,
// and nobody dealt with anything.
if (live.a.gone || live.b.gone) {
this.abort(live);
return;
}
const intervening = isBetween(playerX, playerY, live.a.x, live.a.y, live.b.x, live.b.y);
const next = stepFight(live.state, ms, intervening);
live.state = next;
if (next.phase === 'shoving') return;
this.resolve(live, next);
}
private resolve(live: Live, state: FightState): void {
const outcome = scoreFight(state);
const broken = state.phase === 'broken';
this.bus.emit('meters:delta', { vibe: outcome.vibeDelta, aggro: outcome.aggroDelta });
// Deferred, not on the spot: design §3.2 hedges a missed fight as "possible
// Heat", and the contract's deferred flag means it surfaces at inspection or
// audit instead. A brawl you didn't stop is exactly the sort of thing that
// comes back at you later — that's the game's long-memory conscience mechanic.
if (outcome.heat) this.bus.emit('heat:strike', { reason: HEAT_REASON, deferred: true });
const line = this.rng.pick(broken ? FIGHT_BROKEN : FIGHT_SWUNG);
this.release(live.a);
this.release(live.b);
// A fight you missed is not a fight you dealt with — a swing leaves them both
// still on your list.
if (broken) {
live.a.handled = true;
live.b.handled = true;
}
this.resolution = { outcome, phase: state.phase, line };
this.end();
}
private abort(live: Live): void {
this.release(live.a);
this.release(live.b);
this.end();
}
private end(): void {
this.live = null;
this.cooldownMs = 0;
}
/** Never drag someone back onto the floor after they've left it. */
private release(agent: Agent): void {
if (agent.gone) return;
this.crowd.releaseFromFight(agent);
}
private tryStart(): void {
const pairs = this.candidates();
if (pairs.length === 0) return;
const [a, b] = this.rng.pick(pairs);
if (!this.crowd.pinForFight(a)) return;
if (!this.crowd.pinForFight(b)) {
this.crowd.releaseFromFight(a);
return;
}
this.live = { state: freshFight(a.patron.id, b.patron.id), a, b, cause: this.rng.pick(FIGHT_CAUSE) };
this.bus.emit('floor:infractionSpotted', { patronId: a.patron.id, kind: 'fight' });
}
/** Every pair drunk enough and close enough right now. Small crowd, so O(n²) is fine. */
private candidates(): Array<[Agent, Agent]> {
const pool = this.crowd.agents.filter(
(a) => !a.gone && !a.handled && AVAILABLE.has(a.activity),
);
const out: Array<[Agent, Agent]> = [];
for (let i = 0; i < pool.length; i++) {
const a = pool[i];
if (!a) continue;
for (let j = i + 1; j < pool.length; j++) {
const b = pool[j];
if (!b) continue;
const dist = Math.hypot(a.x - b.x, a.y - b.y);
if (fightEligible(a.patron.intoxication, b.patron.intoxication, dist)) out.push([a, b]);
}
}
return out;
}
}

View File

@ -227,7 +227,10 @@ export class PatDownOverlay {
handleKey(key: string): void {
if (!this.open_) return;
if (key === 'ESC' || key === 'Escape') this.resolve();
// The scene uppercases before routing, so 'Escape' never matched and ESC was
// dead here while the on-screen hint promised it worked.
const k = key.toUpperCase();
if (k === 'ESC' || k === 'ESCAPE') this.resolve();
}
destroy(): void {

View File

@ -3,6 +3,9 @@ import { EventBus } from '../core/EventBus';
import { GameClock } from '../core/GameClock';
import { SeededRNG } from '../core/SeededRNG';
import { Meters, freshNightState } from '../core/meters';
import { Ambience } from '../audio/Ambience';
import { arcLabel } from '../audio/arc';
import { VOICE_NAMES } from '../audio/mix';
import { Sfx, type SfxName } from '../audio/Sfx';
import { TechnoEngine } from '../audio/TechnoEngine';
import { DAZZA_TEXTS } from '../data/strings/dazza';
@ -10,6 +13,7 @@ import type { DressCodeRule } from '../data/types';
import { Clicker } from './Clicker';
import { DialogueBox } from './DialogueBox';
import { MeterHud } from './MeterHud';
import { MixDeskScene, type MixDeskData } from './MixDeskScene';
import { Phone } from './Phone';
import { stamp, type StampHandle } from './Stamp';
import { UI, chunkyButton, font } from './style';
@ -56,6 +60,37 @@ const SFX_BUTTONS: ReadonlyArray<readonly [SfxName, string]> = [
['typeTick', 'TYPE TICK'],
];
/** Structural copy of Ambience's private `Bed` union — it doesn't export one. */
type BedName = 'rain' | 'crowd' | 'kebab';
/** Beds default to a quiet street: room and kebab shop on, weather off, so RAIN
* still reads as a thing you switch on the way Phase 1 had it. */
const BED_DEFAULTS: Readonly<Record<BedName, boolean>> = { rain: false, crowd: true, kebab: true };
const BEDS: readonly BedName[] = ['rain', 'crowd', 'kebab'];
/** Inside-count nudges. ±10 exists because the crowd bed's curve saturates and
* you cannot hear that by clicking +1 eleven times. */
const COUNT_STEPS: readonly number[] = [-10, -1, 1, 10];
const MIX_BTN_X = 176;
const MIX_BTN_Y = 84;
const ROOM_X = 100;
const NIGHT_X = 396;
/**
* Midnight the PEAK keyframe, where every voice scales to 1. The scene pins here
* on open because it is a tuning rig, not a night: unpinned it starts at 21:00
* EMPTY, where bass and pad scale to 0, and GameClock's 360 game-minutes over 13
* real minutes means nobody hears a bassline for the first ~2 minutes. That reads
* as a broken engine. The button beside the readout hands the arc back.
*/
const PIN_MINUTE = 180;
const PIN_LABEL = 'PINNED @ PEAK · TAP=FOLLOW';
const FOLLOW_LABEL = 'FOLLOWING CLOCK · TAP=PIN';
interface PendingBeat {
beatIndex: number;
audioTimeMs: number;
@ -64,6 +99,21 @@ interface PendingBeat {
const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, v));
const pad2 = (n: number): string => String(n).padStart(2, '0');
/** arcMinute is minutes since 21:00, and may be pinned away from the game clock. */
const wallClock = (min: number): string => {
const t = (21 * 60 + Math.round(min)) % (24 * 60);
return `${pad2(Math.floor(t / 60))}:${pad2(t % 60)}`;
};
/** Ten cells of ASCII. Monospace is already the house font; a real bar would be
* four game objects per voice refreshed every frame for the same information. */
const meterBar = (v: number): string => {
const n = clamp(Math.round(v * 10), 0, 10);
return '#'.repeat(n) + '-'.repeat(10 - n);
};
const hzFromT = (t: number): number => MIN_HZ * Math.pow(HZ_RATIO, clamp(t, 0, 1));
const tFromHz = (hz: number): number =>
clamp(Math.log(clamp(hz, MIN_HZ, MAX_HZ) / MIN_HZ) / Math.log(HZ_RATIO), 0, 1);
@ -86,6 +136,7 @@ export class JuiceDemoScene extends Phaser.Scene {
private engine!: TechnoEngine;
private sfx!: Sfx;
private ambience!: Ambience;
private hud!: MeterHud;
private phone!: Phone;
@ -95,15 +146,19 @@ export class JuiceDemoScene extends Phaser.Scene {
private statusText!: Phaser.GameObjects.Text;
private hzText!: Phaser.GameObjects.Text;
private arcText!: Phaser.GameObjects.Text;
private insideText!: Phaser.GameObjects.Text;
private beatPad!: Phaser.GameObjects.Rectangle;
private beatRing!: Phaser.GameObjects.Rectangle;
private handle!: Phaser.GameObjects.Rectangle;
private locButton!: Phaser.GameObjects.Container;
private rainButton!: Phaser.GameObjects.Container;
private arcPinButton!: Phaser.GameObjects.Container;
private patronRoot!: Phaser.GameObjects.Container;
private readonly stampMarks: StampHandle[] = [];
private readonly pendingBeats: PendingBeat[] = [];
private readonly bedButtons: Array<{ bed: BedName; btn: Phaser.GameObjects.Container }> = [];
private readonly bedsOn: Record<BedName, boolean> = { ...BED_DEFAULTS };
private beatFlash = 0;
private beatFlashBar = false;
private lastBeat = -1;
@ -111,9 +166,21 @@ export class JuiceDemoScene extends Phaser.Scene {
private overrideHz: number | null = null;
private dragging = false;
private raining = false;
private dazzaIndex = 0;
private audioStarting = false;
/** Last pin state pushed to the button label; null forces a sync on the first frame. */
private arcPinShown: boolean | null = null;
/**
* The desk duck-types anything with startRain/stopRain and drives it from its
* header. Handing it this adapter rather than the Ambience keeps a single owner
* of rain policy two objects both calling Sfx.startRain is how you get a bed
* that will not switch off and keeps the demo's own RAIN button in step.
*/
private readonly deskRain = {
startRain: (): void => this.setBed('rain', true),
stopRain: (): void => this.setBed('rain', false),
};
constructor() {
super('JuiceDemo');
@ -135,14 +202,16 @@ export class JuiceDemoScene extends Phaser.Scene {
this.overrideHz = null;
this.dragging = false;
this.raining = false;
this.dazzaIndex = 0;
this.audioStarting = false;
this.arcPinShown = null;
this.dialogue = null;
this.dialogueKill = false;
// Shutdown already destroyed the objects these handles point at.
this.stampMarks.length = 0;
this.bedButtons.length = 0;
Object.assign(this.bedsOn, BED_DEFAULTS);
}
create(): void {
@ -156,7 +225,20 @@ export class JuiceDemoScene extends Phaser.Scene {
// The graph is built in the constructor, so Sfx can be wired now and simply
// stays silent until the context resumes. No post-unlock re-wiring needed.
this.engine = new TechnoEngine(this.bus);
// Every run, not just the first: a restart builds a fresh engine whose arc
// defaults to following a clock that has just been reset to 21:00.
this.engine.setArcMinute(PIN_MINUTE);
this.sfx = new Sfx(this.engine.ctx, this.engine.sfxBus);
// Same lifetime as Sfx, and on the same bus: the beds are dry ambience, not
// music, so the door's lowpass has no business touching them.
this.ambience = new Ambience(this.bus, this.engine.ctx, this.engine.sfxBus, this.sfx, {
...this.bedsOn,
});
// Registering the desk here is the only route to it: main.ts is
// integration-owned, so nothing else can put it in the scene manager.
// Re-adding a live key throws, and Phaser keeps scenes across a restart.
if (!this.scene.get('MixDesk')) this.scene.add('MixDesk', MixDeskScene, false);
this.buildBackdrop();
this.buildBeatIndicator();
@ -164,6 +246,9 @@ export class JuiceDemoScene extends Phaser.Scene {
this.buildStatus();
this.buildSfxColumn();
this.buildActionColumn();
this.buildMixDeskButton();
this.buildRoomPanel();
this.buildNightPanel();
this.buildAudioStrip();
this.hud = new MeterHud(this, this.bus, { x: 6, y: 6 });
@ -186,6 +271,7 @@ export class JuiceDemoScene extends Phaser.Scene {
this.buildUnlockOverlay();
this.input.keyboard?.on('keydown-SPACE', () => this.dialogue?.skip());
this.input.keyboard?.on('keydown-M', () => this.openMixDesk());
this.events.once('shutdown', () => this.teardown());
}
@ -204,7 +290,7 @@ export class JuiceDemoScene extends Phaser.Scene {
.text(
TRACK_X0,
336,
'sounds down the left · verdicts down the right · drag MUFFLE to open the door on the bass',
'sounds left · verdicts right · M for the mix desk · drag MUFFLE to open the door',
font(7, UI.paper),
)
.setAlpha(0.45);
@ -253,13 +339,89 @@ export class JuiceDemoScene extends Phaser.Scene {
onClick: () => this.sfx.play(name),
});
});
this.rainButton = chunkyButton(this, 50, 80 + SFX_BUTTONS.length * 16, 'RAIN: OFF', {
w: 76,
h: 13,
fontSize: 7,
fill: UI.grime,
onClick: () => this.toggleRain(),
}
/**
* The lane's headline: everything else on this screen is a widget, this is the
* thing you sit at for ten minutes and leave with a diff. Sized to say so.
*/
private buildMixDeskButton(): void {
chunkyButton(this, MIX_BTN_X, MIX_BTN_Y, 'MIX DESK [M]', {
w: 152,
h: 26,
fontSize: 12,
fill: UI.neonPink,
onClick: () => this.openMixDesk(),
});
this.add
.text(MIX_BTN_X, MIX_BTN_Y + 20, 'the ear pass lives here', font(7, UI.paper))
.setOrigin(0.5, 0)
.setAlpha(0.45);
}
/** Ambience beds plus a way to fill the room without admitting ninety people. */
private buildRoomPanel(): void {
this.add.text(ROOM_X, 116, 'THE ROOM', font(7, UI.kebabAmber)).setAlpha(0.8);
BEDS.forEach((bed, i) => {
const btn = chunkyButton(this, ROOM_X + 38, 130 + i * 16, this.bedLabel(bed), {
w: 84,
h: 13,
fontSize: 7,
fill: UI.grime,
onClick: () => this.setBed(bed, !this.bedsOn[bed]),
});
this.bedButtons.push({ bed, btn });
});
// Rain and kebab are door-only and the crowd all but vanishes out there, so
// an ON bed can still be silent. Say so before someone files it as a bug.
this.add
.text(ROOM_X, 172, 'rain + kebab outside · crowd inside', font(7, UI.paper))
.setAlpha(0.35);
this.insideText = this.add.text(ROOM_X, 186, '', font(8, UI.neonGreen));
COUNT_STEPS.forEach((step, i) => {
chunkyButton(this, ROOM_X + 17 + i * 36, 206, step > 0 ? `+${step}` : String(step), {
w: 34,
h: 13,
fontSize: 7,
onClick: () => this.ambience.setInsideCount(this.ambience.insideCount + step),
});
});
this.add
.text(ROOM_X, 218, 'the crowd bed swells as it fills', font(7, UI.paper))
.setAlpha(0.35);
}
/** What the night is doing to the mix, which is otherwise entirely invisible. */
private buildNightPanel(): void {
this.add.text(NIGHT_X, 116, 'THE NIGHT', font(7, UI.kebabAmber)).setAlpha(0.8);
this.arcText = this.add.text(NIGHT_X, 130, '', font(7, UI.neonGreen)).setLineSpacing(2);
// Width 128 keeps this clear of THE JOB's column, which starts at x 528.
this.arcPinButton = chunkyButton(this, NIGHT_X + 64, 198, PIN_LABEL, {
w: 128,
h: 14,
fontSize: 7,
fill: UI.kebabAmber,
textColour: UI.ink,
onClick: () => this.toggleArcPin(),
});
this.add
.text(NIGHT_X, 208, 'pinned at PEAK: everything on', font(7, UI.paper))
.setAlpha(0.35);
this.add
.text(NIGHT_X, 218, 'scrub the hour in the mix desk', font(7, UI.paper))
.setAlpha(0.35);
}
/** Reads the engine rather than a local flag: the mix desk pins and unpins too. */
private toggleArcPin(): void {
this.engine.setArcMinute(this.engine.arcPinned ? null : PIN_MINUTE);
}
private buildActionColumn(): void {
@ -291,7 +453,9 @@ export class JuiceDemoScene extends Phaser.Scene {
}
private buildAudioStrip(): void {
this.hzText = this.add.text(TRACK_X0, 296, '', font(8, UI.neonGreen));
// Right-aligned to the far end of the track: at TRACK_X0 it printed straight
// through the MUFFLE label, which is the same overlap defect Phase 1 caught.
this.hzText = this.add.text(TRACK_X1, 296, '', font(8, UI.neonGreen)).setOrigin(1, 0);
const track = this.add
.rectangle((TRACK_X0 + TRACK_X1) / 2, TRACK_Y, TRACK_X1 - TRACK_X0, 6, UI.panel)
@ -363,6 +527,9 @@ export class JuiceDemoScene extends Phaser.Scene {
.then(() => {
if (stale()) return;
engine.start();
// Beds need a running context to build their sources on; before this
// they would be four seconds of noise nobody ever hears.
this.ambience.start();
root.destroy(true);
})
.catch(() => {
@ -442,11 +609,30 @@ export class JuiceDemoScene extends Phaser.Scene {
this.bus.emit('dazza:text', { text: line.text, rule });
}
private toggleRain(): void {
this.raining = !this.raining;
if (this.raining) this.sfx.startRain();
else this.sfx.stopRain();
labelOf(this.rainButton)?.setText(this.raining ? 'RAIN: ON' : 'RAIN: OFF');
private bedLabel(bed: BedName): string {
return `${bed.toUpperCase().padEnd(5)} ${this.bedsOn[bed] ? 'ON' : 'OFF'}`;
}
/** chunkyButton restores its own fill on release, so latched state has to live
* in the label. Same trick the Phase-1 rain button used. */
private setBed(bed: BedName, on: boolean): void {
this.bedsOn[bed] = on;
this.ambience.setEnabled(bed, on);
const entry = this.bedButtons.find((b) => b.bed === bed);
if (entry) labelOf(entry.btn)?.setText(this.bedLabel(bed));
}
/**
* Launched, not started: the desk is an overlay and the music has to keep
* running underneath it while you turn things. The engine goes in the payload
* rather than leaning on getActiveEngine() so a restart cannot hand the desk
* the previous run's closed context.
*/
private openMixDesk(): void {
if (this.scene.isActive('MixDesk')) return;
const data: MixDeskData = { engine: this.engine, ambience: this.deskRain };
this.scene.launch('MixDesk', data);
this.scene.bringToTop('MixDesk');
}
private setCutoffFromX(x: number): void {
@ -481,6 +667,7 @@ export class JuiceDemoScene extends Phaser.Scene {
this.renderBeat(delta);
this.syncSlider();
this.renderStatus();
this.renderNight();
this.hud.update(delta);
this.phone.update(delta);
@ -545,7 +732,32 @@ export class JuiceDemoScene extends Phaser.Scene {
);
}
/**
* The arc is a silent multiplier on every voice, so without a readout "the bass
* is missing" at 9 PM looks like a broken engine rather than an empty room.
*/
private renderNight(): void {
const min = this.engine.arcMinute;
const pinned = this.engine.arcPinned;
// The desk drops the pin on its way out, so the label is derived, not latched.
if (pinned !== this.arcPinShown) {
this.arcPinShown = pinned;
labelOf(this.arcPinButton)?.setText(pinned ? PIN_LABEL : FOLLOW_LABEL);
}
const scales = this.engine.arcScales;
const lines = [
`${wallClock(min)} · ${arcLabel(min)}${pinned ? ' · PINNED' : ''}`,
...VOICE_NAMES.map((v) => `${v.padEnd(4)} |${meterBar(scales[v])}| ${scales[v].toFixed(2)}`),
];
this.arcText.setText(lines.join('\n'));
this.insideText.setText(`INSIDE: ${this.ambience.insideCount}`);
}
private teardown(): void {
// Before the engine goes: the desk holds a reference to it and writes knobs
// every frame, and a closed AudioContext throws when you touch its params.
if (this.scene.isActive('MixDesk')) this.scene.stop('MixDesk');
this.input.keyboard?.off('keydown-M');
this.input.keyboard?.off('keydown-SPACE');
for (const m of this.stampMarks) m.decal.destroy();
this.stampMarks.length = 0;
@ -554,7 +766,9 @@ export class JuiceDemoScene extends Phaser.Scene {
this.phone.destroy();
this.clicker.destroy();
this.hud.destroy();
// Sfx first: engine.destroy() closes the context its nodes hang off.
// Ambience before Sfx (it calls stopRain on the way out), Sfx before the
// engine, which closes the context all of their nodes hang off.
this.ambience.destroy();
this.sfx.destroy();
this.engine.destroy();
this.meters.destroy();

724
src/ui/MixDeskScene.ts Normal file
View File

@ -0,0 +1,724 @@
import Phaser from 'phaser';
import { NIGHT_END_MIN, arcLabel } from '../audio/arc';
import { getActiveEngine, type TechnoEngine } from '../audio/TechnoEngine';
import {
MIX_RANGES,
VOICE_NAMES,
clampTo,
formatMix,
soloedVoice,
voiceGain,
type VoiceName,
} from '../audio/mix';
import { UI, chunkyButton, font, panel } from './style';
// The ear pass, as a scene.
//
// Every number in the track was chosen by an agent that cannot hear. This desk
// exists so a human can sit down for ten minutes, solo one voice, drag the knob
// that is wrong, and leave with a diff. That last part is the whole point: a
// tuning session that does not end in a pasteable TrackMix literal was a
// conversation, not a change. Hence DUMP MIX, and hence the readouts showing
// *effective* gain rather than the raw level — the arc is a multiplier, and a
// kick that reads as too quiet at 9 PM may be perfectly set for midnight.
const CANVAS_W = 640;
const CANVAS_H = 360;
/** Hint grey. UI.dim is tuned for the game's panels and vanishes at 6px on this backdrop. */
const HINT = 0x8a8aa0;
const PARAM_ROWS_MAX = 6;
export interface MixDeskData {
engine?: TechnoEngine;
/** Duck-typed: Ambience is a sibling lane's file and the desk must not hard-depend on it. */
ambience?: unknown;
}
interface RainSource {
startRain(): void;
stopRain(): void;
}
const isRainSource = (o: unknown): o is RainSource => {
if (typeof o !== 'object' || o === null) return false;
const r = o as Partial<RainSource>;
return typeof r.startRain === 'function' && typeof r.stopRain === 'function';
};
interface ParamSpec {
/** Dotted path understood by TechnoEngine.setMixValue. */
readonly path: string;
readonly label: string;
readonly hint: string;
readonly range: keyof typeof MIX_RANGES;
/** Engine quantises this one, so the desk must too — see SliderSpec.integer. */
readonly integer?: true;
}
interface ParamGroup {
readonly name: string;
readonly rows: readonly ParamSpec[];
}
/**
* The knob table. Rows are data so the layout code is written once twenty
* hand-rolled rows is twenty places for a path typo to hide.
*/
const GROUPS: readonly ParamGroup[] = [
{
name: 'LEVELS',
rows: [
{ path: 'master', label: 'master', hint: 'post-filter output. mind your ears.', range: 'master' },
{ path: 'levels.kick', label: 'kick level', hint: 'raw gain, before the arc scales it.', range: 'level' },
{ path: 'levels.hat', label: 'hat level', hint: 'the tick. easy to over-serve.', range: 'level' },
{ path: 'levels.bass', label: 'bass level', hint: 'the door filter eats most of this.', range: 'level' },
{ path: 'levels.pad', label: 'pad level', hint: 'glue. hear it as a part, too loud.', range: 'level' },
],
},
{
name: 'KICK',
rows: [
{ path: 'kick.startHz', label: 'kick startHz', hint: 'beater pitch at the transient. click.', range: 'kick.startHz' },
{ path: 'kick.endHz', label: 'kick endHz', hint: 'where the drop lands. the body.', range: 'kick.endHz' },
{ path: 'kick.dropS', label: 'kick dropS', hint: 'length of the fall. longer = bigger box.', range: 'kick.dropS' },
{ path: 'kick.attackS', label: 'kick attackS', hint: 'slower attack rounds off the front.', range: 'kick.attackS' },
{ path: 'kick.decayS', label: 'kick decayS', hint: 'tail. too long and it smears the bass.', range: 'kick.decayS' },
],
},
{
name: 'HAT + PAD',
rows: [
{ path: 'hat.hpHz', label: 'hat hpHz', hint: 'highpass corner. lower = more body.', range: 'hat.hpHz' },
{ path: 'hat.decayS', label: 'hat decayS', hint: 'tick versus sizzle lives right here.', range: 'hat.decayS' },
{ path: 'pad.lpHz', label: 'pad lpHz', hint: 'brightness of the wash behind it all.', range: 'pad.lpHz' },
{ path: 'pad.bars', label: 'pad bars', hint: 'bars per fade. longer = unnoticed.', range: 'pad.bars', integer: true },
],
},
{
name: 'BASS',
rows: [
{ path: 'bass.rootHz', label: 'bass rootHz', hint: 'A1 by default. move it, move the lot.', range: 'bass.rootHz' },
{ path: 'bass.filterLoHz', label: 'bass filterLo', hint: 'floor of the per-note filter sweep.', range: 'bass.filterLoHz' },
{ path: 'bass.filterHiHz', label: 'bass filterHi', hint: 'peak of the sweep. this is the wow.', range: 'bass.filterHiHz' },
{ path: 'bass.q', label: 'bass q', hint: 'resonance on the sweep. past 12 it whistles.', range: 'bass.q' },
{ path: 'bass.decayS', label: 'bass decayS', hint: 'note length. short rolls, long drones.', range: 'bass.decayS' },
{ path: 'bass.detuneCents', label: 'bass detune', hint: 'cents between the saws. the character.', range: 'bass.detuneCents' },
],
},
{
name: 'ROOM',
rows: [
{ path: 'doorQ', label: 'door Q', hint: 'resonant peak outside — the wall cue.', range: 'doorQ' },
{ path: 'floorQ', label: 'floor Q', hint: 'inside. flat is right; you are in the room.', range: 'floorQ' },
],
},
];
/**
* What to actually listen for. The difference between "sounds fine" and a useful
* answer is usually just knowing which question was being asked.
*/
const LISTEN_FOR: Record<VoiceName, string> = {
kick: 'KICK — does it read as a kick through a wall, or just a thud? If it thuds, more startHz and a shorter dropS.',
hat: 'HAT — should tick, not hiss. Sizzling means hpHz is too low or decayS too long.',
bass: 'BASS — should roll under the kick, not fight it. If the two blur together, cut kick decayS first.',
pad: 'PAD — glue, not a part. If you can name the chord, the level is too high.',
};
const NO_SOLO = 'Nothing soloed — SOLO a voice to judge it on its own, then unsolo to hear it in the room.';
/** Readable at a glance across four orders of magnitude (0.008 s up to 14000 Hz). */
const fmt = (v: number): string => {
const a = Math.abs(v);
if (a >= 100) return v.toFixed(0);
if (a >= 10) return v.toFixed(1);
if (a >= 1) return v.toFixed(2);
return v.toFixed(3);
};
/**
* Text.setColor is unguarded: it goes through TextStyle.setFill to updateText(),
* which resizes and clears the backing canvas, re-measures, re-fills and re-uploads
* the GL texture no no-op check, unlike setText. refreshAll() runs every frame, so
* an unconditional setColor is hundreds of texture uploads a second on the same main
* thread as the audio scheduler, and the rig degrades the sound it exists to judge.
* Keyed weakly: entries die with the Text, so a scene restart starts cold.
*/
const lastColour = new WeakMap<Phaser.GameObjects.Text, string>();
const setColour = (t: Phaser.GameObjects.Text | null | undefined, css: string): void => {
if (!t || lastColour.get(t) === css) return;
lastColour.set(t, css);
t.setColor(css);
};
const pad2 = (n: number): string => String(n).padStart(2, '0');
/** clockMin is minutes since 21:00. */
const wallClock = (clockMin: number): string => {
const t = (21 * 60 + Math.round(clockMin)) % (24 * 60);
return `${pad2(Math.floor(t / 60))}:${pad2(t % 60)}`;
};
interface SliderSpec {
x: number;
y: number;
w: number;
range: readonly [number, number];
/** Frequencies get a log taper: linear Hz spends most of its travel where nothing changes. */
log?: boolean;
/**
* Snap to whole numbers on write. Without it a slider can hold a value the engine
* never uses (pad.bars rounds internally) and DUMP MIX exports that phantom a
* mix literal that does not describe what was heard is worse than no dump at all.
*/
integer?: boolean;
read: () => number;
write: (v: number) => void;
format?: (v: number) => string;
}
interface Refreshable {
refresh: () => void;
}
interface ToggleSpec {
x: number;
y: number;
w: number;
h?: number;
label: string;
on: () => boolean;
click: () => void;
onFill?: number;
}
/**
* Tuning desk for the techno engine, run as an overlay above whatever scene owns
* the audio. It never pauses that scene the music has to keep playing while
* you turn things.
*/
export class MixDeskScene extends Phaser.Scene {
private engine: TechnoEngine | null = null;
/** Arc pin as we found it, restored on close. null = was following the clock. */
private entryArc: number | null = null;
private entryEnabled: Record<VoiceName, boolean> | null = null;
private rain: RainSource | null = null;
private raining = false;
/** Everything alive for the whole scene; page-scoped objects live in `paramOwned`. */
private owned: Phaser.GameObjects.GameObject[] = [];
private paramOwned: Phaser.GameObjects.GameObject[] = [];
private refreshers: Refreshable[] = [];
private paramRefreshers: Refreshable[] = [];
private page = 0;
private groupTitle: Phaser.GameObjects.Text | null = null;
private pageLabel: Phaser.GameObjects.Text | null = null;
private listenText: Phaser.GameObjects.Text | null = null;
private statusText: Phaser.GameObjects.Text | null = null;
private arcText: Phaser.GameObjects.Text | null = null;
private scaleText: Phaser.GameObjects.Text | null = null;
private toastText: Phaser.GameObjects.Text | null = null;
private toastTween: Phaser.Tweens.Tween | null = null;
private readonly onEsc = (): void => {
this.scene.stop();
};
constructor() {
super('MixDesk');
}
init(data: MixDeskData): void {
// A desk that only works in the demo is useless on a real night, where the
// scene holding the engine is not one we are allowed to import.
this.engine = data.engine ?? getActiveEngine();
this.rain = isRainSource(data.ambience) ? data.ambience : null;
}
create(): void {
// Phaser reuses the scene instance across restarts and only re-runs create();
// field initialisers fired once, ever.
this.owned = [];
this.paramOwned = [];
this.refreshers = [];
this.paramRefreshers = [];
this.page = 0;
this.raining = false;
this.toastTween = null;
// Snapshot the monitoring state we are about to borrow. Closing the desk
// restores this rather than forcing a default: JuiceDemoScene deliberately
// pins the arc at PEAK so every voice is audible the moment you open it, and
// a desk that resets that on the way out silently un-tunes the very scene it
// was opened from.
const eng = this.engine;
this.entryArc = eng && eng.arcPinned ? eng.arcMinute : null;
this.entryEnabled = eng
? Object.fromEntries(VOICE_NAMES.map((v) => [v, eng.mix.enabled[v]])) as Record<VoiceName, boolean>
: null;
// Interactive so clicks land on the desk instead of leaking through to the
// scene underneath, which is still running and still listening.
this.own(
this.add.rectangle(CANVAS_W / 2, CANVAS_H / 2, CANVAS_W, CANVAS_H, UI.ink, 0.93).setInteractive(),
);
this.own(this.add.text(10, 4, 'MIX DESK', font(10, UI.neonPink)));
this.own(chunkyButton(this, 600, 11, 'CLOSE ESC', { w: 54, h: 13, onClick: () => this.scene.stop() }));
this.input.keyboard?.on('keydown-ESC', this.onEsc);
this.events.once('shutdown', () => this.teardown());
const engine = this.engine;
if (!engine) {
this.own(
this.add
.text(
CANVAS_W / 2,
CANVAS_H / 2,
'NO AUDIO ENGINE\n\nLaunch this scene with { engine } in its scene data,\nor start the audio first so getActiveEngine() has something to hand back.',
{ ...font(9, UI.paper), align: 'center' },
)
.setOrigin(0.5),
);
return;
}
if (this.rain) {
const rain = this.toggle({
x: 540,
y: 11,
w: 44,
h: 13,
label: 'RAIN',
on: () => this.raining,
click: () => this.setRain(!this.raining),
});
this.own(rain.node);
this.refreshers.push(rain);
}
this.statusText = this.own(this.add.text(90, 6, '', font(7, HINT)));
this.buildVoiceStrip(engine);
this.buildParamRegion(engine);
this.buildArcRegion(engine);
this.buildExportBar(engine);
this.refreshAll();
}
// ---------------------------------------------------------------- regions
private buildVoiceStrip(engine: TechnoEngine): void {
this.own(panel(this, CANVAS_W / 2, 49, 632, 58));
VOICE_NAMES.forEach((v, i) => {
const y = 32 + i * 13;
this.own(this.add.text(10, y, v.toUpperCase(), font(8, UI.paper)).setOrigin(0, 0.5));
const mute = this.toggle({
x: 46,
y,
w: 32,
h: 11,
label: 'MUTE',
on: () => !engine.mix.enabled[v],
click: () => engine.setVoiceEnabled(v, !engine.mix.enabled[v]),
onFill: UI.danger,
});
const solo = this.toggle({
x: 82,
y,
w: 32,
h: 11,
label: 'SOLO',
on: () => soloedVoice(engine.mix) === v,
click: () => this.solo(engine, v),
onFill: UI.kebabAmber,
});
this.own(mute.node);
this.own(solo.node);
const level = this.slider(
{
x: 108,
y,
w: 110,
range: MIX_RANGES.level,
read: () => engine.getMixValue(`levels.${v}`),
write: (val) => engine.setMixValue(`levels.${v}`, val),
},
this.owned,
);
// Effective gain, not the slider value: the arc is a multiplier, and hiding
// it is how you end up "fixing" a level that the arc had already dropped.
const eff = this.own(this.add.text(272, y, '', font(8, UI.neonGreen)).setOrigin(0, 0.5));
this.refreshers.push({
refresh: () => {
mute.refresh();
solo.refresh();
level.refresh();
const g = voiceGain(engine.mix, v, engine.arcScales[v]);
eff.setText(`= ${fmt(g)}`);
setColour(eff, g > 0.0005 ? '#9fe8a0' : '#c03434');
},
});
});
this.listenText = this.own(
this.add.text(336, 49, '', { ...font(7, UI.kebabAmber), wordWrap: { width: 292 } }).setOrigin(0, 0.5),
);
}
/** Solo toggles: on, this voice alone; off, everyone back. */
private solo(engine: TechnoEngine, v: VoiceName): void {
const already = soloedVoice(engine.mix) === v;
for (const other of VOICE_NAMES) engine.setVoiceEnabled(other, already || other === v);
}
private buildParamRegion(engine: TechnoEngine): void {
this.own(panel(this, CANVAS_W / 2, 163, 632, 162));
this.groupTitle = this.own(this.add.text(14, 92, '', font(9, UI.neonPink)).setOrigin(0, 0.5));
this.pageLabel = this.own(this.add.text(160, 92, '', font(7, HINT)).setOrigin(0, 0.5));
this.own(
chunkyButton(this, 600, 92, 'PAGE >', {
w: 54,
h: 13,
onClick: () => {
this.page = (this.page + 1) % GROUPS.length;
this.buildParamPage(engine);
},
}),
);
this.buildParamPage(engine);
}
/** Rebuilt wholesale per page — cheaper to reason about than repointing closures. */
private buildParamPage(engine: TechnoEngine): void {
for (const o of this.paramOwned) o.destroy();
this.paramOwned = [];
this.paramRefreshers = [];
const group = GROUPS[this.page] ?? GROUPS[0];
if (!group) return;
this.groupTitle?.setText(group.name);
this.pageLabel?.setText(`page ${this.page + 1} of ${GROUPS.length}`);
group.rows.slice(0, PARAM_ROWS_MAX).forEach((spec, i) => {
const y = 106 + i * 22;
this.paramOwned.push(this.add.text(14, y - 5, spec.label, font(8, UI.paper)).setOrigin(0, 0.5));
this.paramOwned.push(this.add.text(14, y + 5, spec.hint, font(6, HINT)).setOrigin(0, 0.5));
const handle = this.slider(
{
x: 200,
y,
w: 320,
range: MIX_RANGES[spec.range],
log: spec.path.endsWith('Hz'),
integer: spec.integer === true,
read: () => engine.getMixValue(spec.path),
write: (v) => engine.setMixValue(spec.path, v),
},
this.paramOwned,
);
this.paramRefreshers.push(handle);
});
}
private buildArcRegion(engine: TechnoEngine): void {
this.own(panel(this, CANVAS_W / 2, 275, 632, 54));
this.own(this.add.text(14, 262, 'ARC', font(8, UI.paper)).setOrigin(0, 0.5));
// Pinning is the only way the night's shape gets checked at all — otherwise
// auditioning 1 AM means waiting four hours for it.
const arc = this.slider(
{
x: 48,
y: 262,
w: 260,
range: [0, NIGHT_END_MIN],
integer: true,
read: () => engine.arcMinute,
write: (v) => engine.setArcMinute(v),
format: wallClock,
},
this.owned,
);
this.refreshers.push(arc);
this.arcText = this.own(this.add.text(370, 262, '', font(8, UI.neonGreen)).setOrigin(0, 0.5));
const follow = this.toggle({
x: 580,
y: 262,
w: 100,
h: 14,
label: 'FOLLOW CLOCK',
on: () => !engine.arcPinned,
click: () => engine.setArcMinute(null),
onFill: UI.ok,
});
this.own(follow.node);
this.refreshers.push(follow);
this.scaleText = this.own(this.add.text(14, 284, '', font(8, UI.paper)).setOrigin(0, 0.5));
this.own(
this.add
.text(370, 284, 'closing the desk puts the arc back how you found it', font(6, HINT))
.setOrigin(0, 0.5),
);
}
private buildExportBar(engine: TechnoEngine): void {
this.own(panel(this, CANVAS_W / 2, 329, 632, 46));
this.own(
chunkyButton(this, 60, 320, 'DUMP MIX', {
w: 100,
h: 16,
fill: UI.neonPink,
fontSize: 9,
onClick: () => this.dump(engine),
}),
);
this.own(
chunkyButton(this, 155, 320, 'RESET', {
w: 70,
h: 16,
fontSize: 9,
onClick: () => {
engine.resetMix();
this.refreshAll();
this.toast('back to DEFAULT_MIX');
},
}),
);
this.toastText = this.own(this.add.text(200, 320, '', font(8, UI.neonGreen)).setOrigin(0, 0.5).setAlpha(0));
this.own(
this.add.text(10, 340, 'DUMP writes a TrackMix literal — paste it over DEFAULT_MIX in src/audio/mix.ts', font(6, HINT)),
);
}
// ---------------------------------------------------------------- widgets
/**
* The one slider. Track, fill, handle, readout; drag the handle or click the
* track. Reads its value from the engine every refresh rather than caching, so
* a knob moved elsewhere (the arc, a reset) shows up here without plumbing.
*/
private slider(spec: SliderSpec, sink: Phaser.GameObjects.GameObject[]): Refreshable {
const lo = spec.range[0];
const hi = spec.range[1];
const logOk = spec.log === true && lo > 0 && hi > lo;
const int = spec.integer === true;
const format = spec.format ?? (int ? (v: number): string => v.toFixed(0) : fmt);
const quantise = (v: number): number => (int ? Math.round(v) : v);
const toT = (v: number): number => {
const c = clampTo(v, spec.range);
if (logOk) return Math.log(c / lo) / Math.log(hi / lo);
return hi === lo ? 0 : (c - lo) / (hi - lo);
};
const toV = (t: number): number => {
const k = Math.max(0, Math.min(1, t));
return logOk ? lo * Math.pow(hi / lo, k) : lo + (hi - lo) * k;
};
const groove = this.add
.rectangle(spec.x, spec.y, spec.w, 5, UI.grime)
.setOrigin(0, 0.5)
.setStrokeStyle(1, UI.ink);
const fill = this.add.rectangle(spec.x, spec.y, 1, 3, UI.neonPink).setOrigin(0, 0.5);
const knob = this.add
.rectangle(spec.x, spec.y, 5, 13, UI.paper)
.setOrigin(0.5)
.setStrokeStyle(1, UI.ink);
const readout = this.add.text(spec.x + spec.w + 8, spec.y, '', font(8, UI.neonGreen)).setOrigin(0, 0.5);
sink.push(groove, fill, knob, readout);
let lastW = -1;
const refresh = (): void => {
const v = spec.read();
const t = toT(v);
knob.x = spec.x + t * spec.w;
// setSize rebuilds the shape's path data; refresh() is per-frame, so diff it.
const w = Math.max(1, t * spec.w);
if (w !== lastW) {
lastW = w;
fill.setSize(w, 3);
}
readout.setText(format(v));
};
const setFromX = (px: number): void => {
// Quantise after clamping — every integer param's range has whole bounds.
spec.write(quantise(clampTo(toV((px - spec.x) / spec.w), spec.range)));
refresh();
};
groove.setInteractive({ useHandCursor: true });
groove.on('pointerdown', (p: Phaser.Input.Pointer) => setFromX(p.x));
knob.setInteractive({ draggable: true, useHandCursor: true });
knob.on('drag', (_p: Phaser.Input.Pointer, dragX: number) => setFromX(dragX));
return { refresh };
}
/**
* A button that shows a state. chunkyButton restores its own fill on release,
* so a latching control has to draw itself.
*/
private toggle(spec: ToggleSpec): Refreshable & { node: Phaser.GameObjects.Container } {
const h = spec.h ?? 13;
const onFill = spec.onFill ?? UI.neonGreen;
const node = this.add.container(spec.x, spec.y);
const face = this.add.rectangle(0, 0, spec.w, h, UI.panelLip).setStrokeStyle(1, UI.ink);
const text = this.add.text(0, 0, spec.label, font(7, UI.paper)).setOrigin(0.5);
node.add([face, text]);
// refreshAll() runs every frame; both setters below are unguarded in Phaser.
let lastOn: boolean | null = null;
const refresh = (): void => {
const on = spec.on();
if (on === lastOn) return;
lastOn = on;
face.setFillStyle(on ? onFill : UI.panelLip);
setColour(text, on ? '#0b0b12' : '#dad4c4');
};
// Same anti-phantom-click arming as chunkyButton: Phaser fires pointerup on
// any release over an object, including one whose press landed elsewhere.
let armed = false;
face.setInteractive({ useHandCursor: true });
face.on('pointerdown', () => {
armed = true;
});
face.on('pointerout', () => {
armed = false;
});
face.on('pointerup', () => {
if (!armed) return;
armed = false;
spec.click();
this.refreshAll();
});
refresh();
return { refresh, node };
}
// ---------------------------------------------------------------- actions
private setRain(on: boolean): void {
if (!this.rain) return;
this.raining = on;
if (on) this.rain.startRain();
else this.rain.stopRain();
}
private dump(engine: TechnoEngine): void {
const text = formatMix(engine.mix);
// The console copy is the one that always survives; the clipboard is a
// convenience that browsers refuse without focus or permission.
console.log(`[MixDesk] TrackMix @ ${wallClock(engine.arcMinute)}\n${text}`);
const clip = typeof navigator === 'undefined' ? undefined : navigator.clipboard;
if (!clip) {
this.toast('dumped to console (no clipboard API)');
return;
}
clip.writeText(text).then(
() => this.toast('copied to clipboard + console'),
() => this.toast('clipboard refused — it is in the console'),
);
}
private toast(msg: string): void {
const t = this.toastText;
if (!t) return;
this.toastTween?.remove();
t.setText(msg).setAlpha(1);
this.toastTween = this.tweens.add({ targets: t, alpha: 0, delay: 2200, duration: 500 });
}
// ---------------------------------------------------------------- refresh
private refreshAll(): void {
const engine = this.engine;
if (!engine) return;
for (const r of this.refreshers) r.refresh();
for (const r of this.paramRefreshers) r.refresh();
const solo = soloedVoice(engine.mix);
this.listenText?.setText(solo ? LISTEN_FOR[solo] : NO_SOLO);
setColour(this.listenText, solo ? '#d8a020' : '#8a8aa0');
const min = engine.arcMinute;
this.arcText?.setText(`${arcLabel(min)}${engine.arcPinned ? ' [PINNED]' : ''}`);
setColour(this.arcText, engine.arcPinned ? '#d8a020' : '#9fe8a0');
const s = engine.arcScales;
this.scaleText?.setText(
`arc scales ${VOICE_NAMES.map((v) => `${v} ${s[v].toFixed(2)}`).join(' ')}`,
);
const running = engine.ctx.state === 'running';
this.statusText?.setText(
running ? `audio running · ${engine.bpm} bpm` : `AUDIO ${engine.ctx.state.toUpperCase()} — start the audio before trusting your ears`,
);
setColour(this.statusText, running ? '#8a8aa0' : '#c03434');
}
override update(): void {
// Cheap enough at a dozen sliders, and it keeps the desk honest about state
// it does not own: the clock moves the arc, and the arc moves every readout.
this.refreshAll();
}
// ---------------------------------------------------------------- teardown
private own<T extends Phaser.GameObjects.GameObject>(o: T): T {
this.owned.push(o);
return o;
}
private teardown(): void {
this.input.keyboard?.off('keydown-ESC', this.onEsc);
this.toastTween?.remove();
this.toastTween = null;
this.tweens.killAll();
// Solo and arc-pin are monitoring state, not mix state — formatMix even hard-codes
// `enabled` back to all-true. Leaving either latched after the desk closes would
// silently break a real night with no UI left to undo it, so hand back exactly
// what we borrowed. On a real night nothing is pinned or muted, so this restores
// to all-true / follow-clock anyway.
const engine = this.engine;
if (engine) {
for (const v of VOICE_NAMES) engine.setVoiceEnabled(v, this.entryEnabled?.[v] ?? true);
engine.setArcMinute(this.entryArc);
}
if (this.raining) this.setRain(false);
for (const o of this.paramOwned) o.destroy();
for (const o of this.owned) o.destroy();
this.paramOwned = [];
this.owned = [];
this.refreshers = [];
this.paramRefreshers = [];
this.groupTitle = null;
this.pageLabel = null;
this.listenText = null;
this.statusText = null;
this.arcText = null;
this.scaleText = null;
this.toastText = null;
this.engine = null;
this.rain = null;
}
}

View File

@ -17,10 +17,38 @@ const PANEL_W = 150;
const PANEL_H = 180;
const PAD = 6;
const BUBBLE_MAX_W = 104;
const BUBBLE_PAD = 4;
const BUBBLE_GAP = 4;
/** Vertical room the clock stamp adds under a bubble. */
const STAMP_H = 8;
const HEADER_H = 13;
const DOTS_H = 12;
/**
* Bubbles are built only for the visible window plus this many either side.
* Scrolling is then independent of thread length; opening the panel still costs
* one arithmetic pass over the (bounded) history to lay out offsets. Overscan
* hides the pop at the edges.
*/
const OVERSCAN = 2;
/** Even culled, an unbounded array is a leak. Oldest lines fall off the top. */
const MAX_MESSAGES = 240;
/** Panel interior the thread scrolls inside, in threadRoot-local coordinates. */
const INTERIOR_TOP = -PANEL_H / 2 + HEADER_H + 3;
const INTERIOR_BOTTOM = PANEL_H / 2 - PAD;
const CUE_H = 5;
const NEW_BELOW_H = 10;
const WHEEL_SCALE = 0.4;
const WHEEL_CLAMP = 100;
const DRAG_SLOP = 3;
/**
* How close to the bottom still counts as "reading the latest". Must exceed
* DOTS_H, or reserving room for the typing dots would itself unstick the view.
*/
const STICK_SLOP = DOTS_H + 4;
const TOAST_W = 112;
const TOAST_H = 26;
const TOAST_MS = 3500;
@ -38,6 +66,10 @@ interface ThreadMessage {
carriedRule: boolean;
/** clock-min the text landed, if a clock:tick has been seen yet */
clockMin?: number;
/** Bubble box, measured once on arrival by the off-screen ruler. Layout needs
* every message's size but must not build a GameObject to learn it. */
bw?: number;
bh?: number;
}
export interface PhoneSfx {
@ -77,6 +109,30 @@ export class Phone {
private readonly threadBody: Phaser.GameObjects.Container;
private readonly dots: Phaser.GameObjects.Text;
private readonly maskShape: Phaser.GameObjects.Graphics;
private readonly bodyMask: Phaser.Display.Masks.GeometryMask;
private readonly scrollCue: Phaser.GameObjects.Container;
private readonly newBelow: Phaser.GameObjects.Container;
/** Off-display-list Text used purely to size bubbles. One texture, forever. */
private readonly ruler: Phaser.GameObjects.Text;
/** Distance from the top of the content to the top of the interior. */
private scrollY = 0;
private contentH = 0;
private maskH = 0;
private dragging = false;
private dragLastY = 0;
private dragMoved = false;
/** Content-space Y of each message top, parallel to `messages`. */
private readonly offsets: number[] = [];
/** Message index range currently realised as GameObjects; -1 = nothing built. */
private builtFrom = -1;
private builtTo = -1;
/** Whether the last layout kept the view pinned to the newest message. */
private lastStick = true;
private unreadBelow = false;
private toast: Phaser.GameObjects.Container | undefined;
private toastMs = 0;
private toastTween: Phaser.Tweens.Tween | undefined;
@ -118,7 +174,21 @@ export class Phone {
this.glowTween = scene.tweens.add({ targets: glow, alpha: 0.2, duration: 1800, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
body.setInteractive({ useHandCursor: true });
body.on('pointerup', () => this.toggle());
// Arm on our own down (and only if the pointer never turned into a scroll
// drag) so a release that started on the thread can't phantom-toggle.
let armed = false;
body.on('pointerdown', () => {
armed = true;
this.dragMoved = false;
});
body.on('pointerout', () => {
armed = false;
});
body.on('pointerup', () => {
const fire = armed && !this.dragMoved;
armed = false;
if (fire) this.toggle();
});
const anchor = this.threadAnchor();
this.threadRoot = scene.add.container(anchor.x, anchor.y).setDepth(this.depth + 2).setVisible(false);
@ -131,7 +201,86 @@ export class Phone {
.text(-PANEL_W / 2 + PAD + 4, PANEL_H / 2 - PAD - 9, '', font(8, UI.paper))
.setAlpha(0.6)
.setVisible(false);
this.threadRoot.add([chrome, title, rule, this.threadBody, this.dots]);
// The mask graphic lives off the display list and tracks threadRoot in world
// space — a geometry mask is not transformed by its parent container.
this.maskShape = scene.make.graphics({}, false);
this.bodyMask = this.maskShape.createGeometryMask();
this.threadBody.setMask(this.bodyMask);
this.ruler = scene.make.text(
{ style: { ...font(7, UI.ink), wordWrap: { width: BUBBLE_MAX_W - BUBBLE_PAD * 2, useAdvancedWrap: true } } },
false,
);
// Sits outside the mask so it draws over the clipped edge: without it there
// is nothing to tell the player history exists at all.
this.scrollCue = scene.add.container(0, 0).setVisible(false);
this.scrollCue.add([
scene.add.rectangle(0, INTERIOR_TOP + CUE_H / 2, PANEL_W - 2, CUE_H, UI.panel, 0.72),
scene.add.triangle(PANEL_W / 2 - PAD - 2, INTERIOR_TOP + 3, 0, 4, 3, 0, 6, 4, UI.paper).setAlpha(0.5),
]);
// fillAlpha 0 (not alpha 0 — that clears the render flag and kills hit testing).
const dragPad = scene.add
.rectangle(0, (INTERIOR_TOP + INTERIOR_BOTTOM) / 2, PANEL_W - 2, INTERIOR_BOTTOM - INTERIOR_TOP, 0xffffff, 0)
.setInteractive();
dragPad.on('pointerdown', (p: Phaser.Input.Pointer) => {
if (!this.open_) return;
this.dragging = true;
this.dragLastY = p.worldY;
this.dragMoved = false;
});
// Below the fold needs its own signal: the scroll cue only ever says "history
// above", and a rule-bearing text landing off-screen is a sacking offence.
// Added after dragPad so it wins the topOnly hit test and the bar stays
// clickable instead of starting a scroll drag.
this.newBelow = scene.add.container(0, 0).setVisible(false);
const nbBg = scene.add.rectangle(0, 0, PANEL_W - 2, NEW_BELOW_H, UI.neonPink, 0.92).setStrokeStyle(1, UI.ink, 0.5);
const nbLabel = scene.add.text(0, 0, 'NEW BELOW v', font(6, UI.paper)).setOrigin(0.5);
this.newBelow.add([nbBg, nbLabel]);
nbBg.setInteractive({ useHandCursor: true });
let nbArmed = false;
nbBg.on('pointerdown', () => {
nbArmed = true;
});
nbBg.on('pointerout', () => {
nbArmed = false;
});
nbBg.on('pointerup', () => {
const fire = nbArmed;
nbArmed = false;
if (fire) this.scrollToBottom();
});
this.threadRoot.add([chrome, title, rule, this.threadBody, this.scrollCue, this.dots, dragPad, this.newBelow]);
const onWheel = (p: Phaser.Input.Pointer, _over: unknown[], _dx: number, dy: number): void => {
if (!this.open_ || !this.pointerOverPanel(p)) return;
this.scrollBy(Phaser.Math.Clamp(dy, -WHEEL_CLAMP, WHEEL_CLAMP) * WHEEL_SCALE);
};
// Tracked on the plugin rather than the pad so a drag survives the pointer
// leaving the panel mid-flick.
const onMove = (p: Phaser.Input.Pointer): void => {
if (!this.dragging) return;
const dy = p.worldY - this.dragLastY;
this.dragLastY = p.worldY;
if (Math.abs(dy) >= DRAG_SLOP) this.dragMoved = true;
this.scrollBy(-dy);
};
const endDrag = (): void => {
this.dragging = false;
};
scene.input.on('wheel', onWheel);
scene.input.on('pointermove', onMove);
scene.input.on('pointerup', endDrag);
scene.input.on('pointerupoutside', endDrag);
this.offs.push(() => {
scene.input.off('wheel', onWheel);
scene.input.off('pointermove', onMove);
scene.input.off('pointerup', endDrag);
scene.input.off('pointerupoutside', endDrag);
});
this.offs.push(bus.on('dazza:text', ({ text, rule: dressRule }) => this.receive(text, dressRule !== undefined)));
this.offs.push(bus.on('clock:tick', ({ clockMin }) => { this.clockMin = clockMin; }));
@ -145,7 +294,7 @@ export class Phone {
if (this.open_) return;
this.open_ = true;
this.openMs = 0;
this.unreadDot.setVisible(false);
this.clearUnreadBelow();
this.screen.setFillStyle(UI.panel);
this.dismissToast();
@ -159,7 +308,7 @@ export class Phone {
duration: 160,
ease: 'Back.easeOut',
});
this.layoutThread();
this.layoutThread(true);
}
close(): void {
@ -191,6 +340,8 @@ export class Phone {
update(deltaMs: number): void {
if (this.open_) this.openMs += deltaMs;
// The panel slides on open/close, so the mask has to follow it every frame.
if (this.threadRoot.visible) this.syncMask();
if (this.pending) {
this.pendingMs += deltaMs;
@ -198,10 +349,11 @@ export class Phone {
const step = Math.floor(this.dotsMs / DOTS_STEP_MS) % 3;
this.dots.setText('.'.repeat(step + 1));
if (this.pendingMs >= TYPING_MS) {
this.messages.push(this.pending);
this.pushMessage(this.pending);
this.pending = undefined;
this.dots.setVisible(false);
this.layoutThread();
this.flagIfLandedOffscreen();
}
}
@ -220,6 +372,11 @@ export class Phone {
// anything still running writing to a dead GameObject every frame.
this.glowTween.remove();
this.dismissToast();
// The mask pair is off the display list; destroying threadRoot won't take it.
this.threadBody.clearMask();
this.bodyMask.destroy();
this.maskShape.destroy();
this.ruler.destroy();
this.threadRoot.destroy(true);
this.root.destroy(true);
}
@ -232,12 +389,13 @@ export class Phone {
if (this.open_) {
// Land any earlier pending line first so nothing gets swallowed by a
// second buzz arriving mid-typing.
if (this.pending) this.messages.push(this.pending);
if (this.pending) this.pushMessage(this.pending);
this.pending = msg;
this.pendingMs = 0;
this.dotsMs = 0;
this.dots.setText('.').setVisible(true);
this.layoutThread();
this.flagIfLandedOffscreen();
return;
}
@ -245,11 +403,57 @@ export class Phone {
}
private deliverClosed(msg: ThreadMessage): void {
this.messages.push(msg);
this.pushMessage(msg);
if (msg.carriedRule) this.unreadDot.setVisible(true);
this.showToast(msg.text);
}
/**
* Append, trimming the oldest lines past the cap. Trimming shifts every
* remaining message up by the shed height, so scrollY moves with it or the
* player's place in the thread jumps.
*/
private pushMessage(msg: ThreadMessage): void {
this.measure(msg);
this.messages.push(msg);
const over = this.messages.length - MAX_MESSAGES;
if (over <= 0) return;
let shed = 0;
for (let i = 0; i < over; i++) {
const dropped = this.messages[i];
if (dropped) shed += this.heightOf(dropped) + BUBBLE_GAP;
}
this.messages.splice(0, over);
this.scrollY = Math.max(0, this.scrollY - shed);
// Indices shifted under the built range; force the next pass to rebuild.
this.builtFrom = -1;
this.builtTo = -1;
}
/**
* A message that arrived while the player was reading history is silent and
* invisible the sticky-bottom rule is right, but it needs an alarm.
*/
private flagIfLandedOffscreen(): void {
if (this.lastStick) return;
this.unreadBelow = true;
this.unreadDot.setVisible(true);
this.newBelow.setVisible(true);
}
private clearUnreadBelow(): void {
this.unreadBelow = false;
this.unreadDot.setVisible(false);
this.newBelow.setVisible(false);
}
private scrollToBottom(): void {
this.scrollY = this.maxScroll;
this.clearUnreadBelow();
this.applyScroll();
}
/**
* Closing mid-typing has to land the held message now. Left in `pending` it
* would arrive behind anything received meanwhile out of order, and with no
@ -320,76 +524,178 @@ export class Phone {
this.toastMs = 0;
}
private layoutThread(): void {
this.threadBody.removeAll(true);
if (!this.open_) return;
/** Visible thread height; the typing dots eat into it while Dazza is typing. */
private get interiorH(): number {
return INTERIOR_BOTTOM - INTERIOR_TOP - (this.pending ? DOTS_H : 0);
}
const top = -PANEL_H / 2 + HEADER_H + 3;
const floor = PANEL_H / 2 - PAD - (this.pending ? DOTS_H : 0);
let cursor = floor;
private get maxScroll(): number {
return Math.max(0, this.contentH - this.interiorH);
}
// Newest at the bottom: build upward and stop as soon as one won't fit. The
// newest is clipped to the interior instead of dropped — a text long enough
// to overflow the panel on its own would otherwise blank the whole thread.
let placed = 0;
for (let i = this.messages.length - 1; i >= 0; i--) {
private pointerOverPanel(p: Phaser.Input.Pointer): boolean {
return (
Math.abs(p.worldX - this.threadRoot.x) <= PANEL_W / 2 &&
Math.abs(p.worldY - this.threadRoot.y) <= PANEL_H / 2
);
}
private scrollBy(dy: number): void {
const next = Phaser.Math.Clamp(this.scrollY + dy, 0, this.maxScroll);
if (next === this.scrollY) return;
this.scrollY = next;
this.applyScroll();
}
private applyScroll(): void {
this.rebuildVisible(false);
// Short threads hang off the bottom of the panel, the way a chat app does.
const slack = Math.max(0, this.interiorH - this.contentH);
this.threadBody.setY(INTERIOR_TOP + slack - this.scrollY);
this.scrollCue.setVisible(this.scrollY > 0.5);
// Reaching the bottom is the read receipt.
if (this.maxScroll - this.scrollY <= STICK_SLOP) this.clearUnreadBelow();
this.newBelow.setY(INTERIOR_TOP + this.interiorH - NEW_BELOW_H / 2).setVisible(this.unreadBelow);
this.redrawMask();
this.syncMask();
}
private redrawMask(): void {
const h = this.interiorH;
if (h === this.maskH) return;
this.maskH = h;
this.maskShape.clear().fillStyle(0xffffff).fillRect(-PANEL_W / 2 + 1, INTERIOR_TOP, PANEL_W - 2, h);
}
private syncMask(): void {
this.maskShape.setPosition(this.threadRoot.x, this.threadRoot.y);
}
/** Size a message without building it. Measured once, then cached forever. */
private measure(msg: ThreadMessage): void {
if (msg.bw !== undefined && msg.bh !== undefined) return;
this.ruler.setText(msg.text);
msg.bw = Math.min(BUBBLE_MAX_W, Math.ceil(this.ruler.width) + BUBBLE_PAD * 2);
msg.bh = Math.ceil(this.ruler.height) + BUBBLE_PAD * 2;
}
private heightOf(msg: ThreadMessage): number {
this.measure(msg);
return (msg.bh ?? 0) + (msg.clockMin !== undefined ? STAMP_H : 0);
}
/** Recompute content-space offsets. Arithmetic only — no GameObjects. */
private remeasure(): void {
this.offsets.length = this.messages.length;
let cursor = 0;
for (let i = 0; i < this.messages.length; i++) {
const msg = this.messages[i];
if (!msg) continue;
const bubble = this.buildBubble(msg, placed === 0 ? floor - top : Number.POSITIVE_INFINITY);
if (placed > 0 && cursor - bubble.height < top) {
bubble.container.destroy(true);
break;
}
placed++;
bubble.container.setY(cursor - bubble.height);
this.threadBody.add(bubble.container);
cursor -= bubble.height + BUBBLE_GAP;
this.offsets[i] = cursor;
cursor += this.heightOf(msg) + BUBBLE_GAP;
}
this.contentH = Math.max(0, cursor - BUBBLE_GAP);
}
/** Inclusive message range intersecting the interior, plus overscan. */
private visibleRange(): { from: number; to: number } {
const n = this.messages.length;
if (n === 0) return { from: 0, to: -1 };
const top = this.scrollY;
const bottom = top + this.interiorH;
// Offsets ascend and heights are positive, so "ends above the viewport" is
// monotonic — binary search it. The linear scan this replaces ran from index 0
// on every pointermove of a drag, which was the last place thread length still
// leaked into frame cost.
let lo = 0;
let hi = n - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
const msg = this.messages[mid];
if (msg && (this.offsets[mid] ?? 0) + this.heightOf(msg) <= top) lo = mid + 1;
else hi = mid;
}
const from = lo;
let to = from;
while (to < n - 1 && (this.offsets[to + 1] ?? 0) < bottom) to++;
return { from: Math.max(0, from - OVERSCAN), to: Math.min(n - 1, to + OVERSCAN) };
}
/**
* Realise only the bubbles the player can see. Scrolling a pixel does not
* rebuild; crossing into a new message does.
*/
private rebuildVisible(force: boolean): void {
const { from, to } = this.visibleRange();
if (!force && from === this.builtFrom && to === this.builtTo) return;
this.threadBody.removeAll(true);
this.builtFrom = from;
this.builtTo = to;
for (let i = from; i <= to; i++) {
const msg = this.messages[i];
if (!msg) continue;
this.threadBody.add(this.buildBubble(msg).setY(this.offsets[i] ?? 0));
}
}
/**
* Re-lay the thread top-down, oldest first. A new message only yanks the view
* to the bottom if the player was already reading there scrolling back for
* the dress-code rule and being thrown forward mid-sentence is unforgivable.
*/
private layoutThread(toBottom = false): void {
const stick = toBottom || this.maxScroll - this.scrollY <= STICK_SLOP;
this.lastStick = stick;
if (!this.open_) {
this.threadBody.removeAll(true);
this.builtFrom = -1;
this.builtTo = -1;
this.contentH = 0;
return;
}
this.remeasure();
this.scrollY = stick ? this.maxScroll : Phaser.Math.Clamp(this.scrollY, 0, this.maxScroll);
this.rebuildVisible(true);
this.applyScroll();
}
/** Container origin is the bubble's top-left; caller only sets Y. */
private buildBubble(
msg: ThreadMessage,
maxHeight = Number.POSITIVE_INFINITY,
): { container: Phaser.GameObjects.Container; height: number } {
private buildBubble(msg: ThreadMessage): Phaser.GameObjects.Container {
const incoming = msg.from === 'dazza';
const pad = 4;
this.measure(msg);
const w = msg.bw ?? 0;
const h = msg.bh ?? 0;
const c = this.scene.add.container(0, 0);
const text = this.scene.add.text(pad, pad, msg.text, {
const text = this.scene.add.text(BUBBLE_PAD, BUBBLE_PAD, msg.text, {
...font(7, incoming ? UI.ink : UI.paper),
wordWrap: { width: BUBBLE_MAX_W - pad * 2, useAdvancedWrap: true },
wordWrap: { width: BUBBLE_MAX_W - BUBBLE_PAD * 2, useAdvancedWrap: true },
});
const w = Math.min(BUBBLE_MAX_W, Math.ceil(text.width) + pad * 2);
const stampH = msg.clockMin !== undefined ? 8 : 0;
let h = Math.ceil(text.height) + pad * 2;
if (h + stampH > maxHeight) {
// Fixed height crops the text canvas: the overflow is cut, not reflowed.
const fit = Math.max(pad * 2 + 1, Math.floor(maxHeight) - stampH);
text.setFixedSize(0, fit - pad * 2);
h = fit;
}
const bg = this.scene.add
.rectangle(0, 0, w, h, incoming ? UI.paper : UI.neonPink)
.setOrigin(0, 0)
.setStrokeStyle(1, UI.ink, 0.4);
c.add([bg, text]);
let height = h;
if (msg.carriedRule) {
// Rule-bearing texts stay flagged forever — this is the one you get sacked
// for missing.
const badge = this.scene.add.text(w + 3, 0, 'NEW', font(6, UI.neonPink));
// for missing. Mirrored on right-aligned bubbles, which sit flush against
// the panel edge and would otherwise push the badge outside it.
const badge = this.scene.add.text(incoming ? w + 3 : -3, 0, 'NEW', font(6, UI.neonPink));
if (!incoming) badge.setOrigin(1, 0);
c.add(badge);
}
if (msg.clockMin !== undefined) {
const stamp = this.scene.add.text(0, h + 1, clockLabel(msg.clockMin), font(6, UI.paper));
stamp.setAlpha(0.4);
c.add(stamp);
height += 8;
}
c.setX(incoming ? -PANEL_W / 2 + PAD : PANEL_W / 2 - PAD - w);
return { container: c, height };
return c;
}
}

277
tests/arc.test.ts Normal file
View File

@ -0,0 +1,277 @@
import { describe, expect, it } from 'vitest';
import { ARC, arcAt, arcIntensity, arcLabel, NIGHT_END_MIN, type ArcScales } from '../src/audio/arc';
import { VOICE_NAMES } from '../src/audio/mix';
const first = ARC[0]!;
const last = ARC[ARC.length - 1]!;
const mean = (a: number, b: number): number => (a + b) / 2;
const PLATEAU_MIN = 180;
const KICK_FULL_MIN = 120;
// The plateau is read straight off the keyframe, never sampled out of arcAt —
// a bound derived from the function under test cannot catch that function
// drifting. Kick is full by 11 PM by design; these three climb to midnight.
const plateau: ArcScales = ARC.find((k) => k.at === PLATEAU_MIN)!.scales;
const LATE_BUILDERS = ['hat', 'bass', 'pad'] as const;
describe('ARC keyframes', () => {
// Every loop below iterates one of these two; a zero-length either would turn
// the whole suite green by iterating nothing.
it('has the keyframes and voices the rest of this suite loops over', () => {
expect(ARC.length).toBe(7);
expect(VOICE_NAMES.length).toBe(4);
expect([...VOICE_NAMES].sort()).toEqual(['bass', 'hat', 'kick', 'pad']);
});
it('runs strictly ascending from minute 0 to the end of the night', () => {
expect(first.at).toBe(0);
expect(last.at).toBe(NIGHT_END_MIN);
expect(NIGHT_END_MIN).toBe(360);
for (let i = 1; i < ARC.length; i++) {
expect(ARC[i]!.at).toBeGreaterThan(ARC[i - 1]!.at);
}
});
it('keeps every voice scale inside 0..1 and carries a label', () => {
for (const k of ARC) {
expect(k.label.length).toBeGreaterThan(0);
for (const v of VOICE_NAMES) {
expect(k.scales[v]).toBeGreaterThanOrEqual(0);
expect(k.scales[v]).toBeLessThanOrEqual(1);
}
}
});
});
describe('arcAt', () => {
it('returns the exact keyframe scales at each keyframe minute', () => {
for (const k of ARC) {
expect(arcAt(k.at)).toEqual(k.scales);
}
});
// Every branch has to copy. Handing back a live keyframe means one caller
// doing `arcAt(-1).bass = 0` silently rewrites ARC for the whole session, and
// every subsequent 9 PM opens wrong.
it('returns a fresh object on every branch, including both clamps', () => {
// Clamp low.
const low = arcAt(-1);
expect(low).not.toBe(first.scales);
low.bass = 0.123;
expect(first.scales.bass).toBe(0);
expect(arcAt(-1).bass).toBe(0);
// Clamp high.
const high = arcAt(NIGHT_END_MIN + 1);
expect(high).not.toBe(last.scales);
high.pad = 0.123;
expect(last.scales.pad).toBe(0.6);
expect(arcAt(NIGHT_END_MIN + 1).pad).toBe(0.6);
// The edge minutes themselves take the clamp branches, not interpolation.
const atFirst = arcAt(first.at);
expect(atFirst).not.toBe(first.scales);
atFirst.kick = 0.111;
expect(arcAt(first.at).kick).toBe(0.8);
const atLast = arcAt(last.at);
expect(atLast).not.toBe(last.scales);
atLast.kick = 0.111;
expect(arcAt(last.at).kick).toBe(0.45);
// A non-finite minute funnels into the clamp-low branch.
const stalled = arcAt(NaN);
expect(stalled).not.toBe(first.scales);
stalled.bass = 0.9;
expect(arcAt(NaN).bass).toBe(0);
// Interpolation branch.
const k = ARC[3]!;
const s = arcAt(k.at);
expect(s).not.toBe(k.scales);
s.kick = 0;
expect(k.scales.kick).toBe(1);
});
it('clamps outside the night instead of extrapolating', () => {
expect(arcAt(-1)).toEqual(first.scales);
expect(arcAt(-10_000)).toEqual(first.scales);
expect(arcAt(NIGHT_END_MIN + 1)).toEqual(last.scales);
expect(arcAt(10_000)).toEqual(last.scales);
});
it('never produces a negative or >1 gain at any minute, including past the end', () => {
for (let t = -120; t <= NIGHT_END_MIN + 120; t++) {
const s = arcAt(t);
for (const v of VOICE_NAMES) {
expect(s[v]).toBeGreaterThanOrEqual(0);
expect(s[v]).toBeLessThanOrEqual(1);
}
}
});
it('interpolates linearly between keyframes', () => {
// 30 is the midpoint of the 0 -> 60 span.
const a = ARC[0]!.scales;
const b = ARC[1]!.scales;
const mid = arcAt(30);
for (const v of VOICE_NAMES) expect(mid[v]).toBeCloseTo(mean(a[v], b[v]), 10);
// 90 is the midpoint of 60 -> 120.
const c = ARC[2]!.scales;
const mid2 = arcAt(90);
for (const v of VOICE_NAMES) expect(mid2[v]).toBeCloseTo(mean(b[v], c[v]), 10);
// A quarter of the way across the 330 -> 360 close-down.
const d = ARC[5]!.scales;
const e = ARC[6]!.scales;
const q = arcAt(337.5);
for (const v of VOICE_NAMES) expect(q[v]).toBeCloseTo(d[v] + (e[v] - d[v]) * 0.25, 10);
});
it('survives a non-finite clock minute without emitting NaN', () => {
for (const bad of [NaN, Infinity, -Infinity]) {
const s: ArcScales = arcAt(bad);
for (const v of VOICE_NAMES) expect(Number.isFinite(s[v])).toBe(true);
}
// A stalled clock reads as the start of the night, not as silence.
expect(arcAt(NaN)).toEqual(first.scales);
});
});
describe('the shape of the night', () => {
it('opens with no bassline — the room is empty', () => {
expect(arcAt(0).bass).toBe(0);
expect(arcAt(0).pad).toBe(0);
expect(arcAt(0).kick).toBeGreaterThan(0);
});
it('brings the bass up once there is a crowd to carry it', () => {
expect(arcAt(60).bass).toBeGreaterThan(arcAt(0).bass);
expect(arcAt(120).bass).toBeGreaterThan(arcAt(60).bass);
});
it('builds every voice through the first half of the night', () => {
for (const v of VOICE_NAMES) {
let prev = -Infinity;
for (let t = 0; t <= 180; t += 5) {
const s = arcAt(t)[v];
expect(s).toBeGreaterThanOrEqual(prev);
prev = s;
}
expect(arcAt(180)[v]).toBeGreaterThan(arcAt(0)[v]);
}
});
// The build has to be a real build: strictly quieter than the plateau on the
// way up, so a keyframe that arrives at full volume early — flattening the
// climb the whole arc exists for — is a failure, not a pass.
it('stays strictly under the midnight plateau on the way up', () => {
for (const k of ARC) {
if (k.at >= KICK_FULL_MIN) continue;
for (const v of VOICE_NAMES) expect(k.scales[v]).toBeLessThan(plateau[v]);
}
const eleven = ARC.find((k) => k.at === KICK_FULL_MIN)!;
expect(eleven.scales.kick).toBe(plateau.kick);
for (const v of LATE_BUILDERS) expect(eleven.scales[v]).toBeLessThan(plateau[v]);
for (let t = 0; t < KICK_FULL_MIN; t++) {
for (const v of VOICE_NAMES) expect(arcAt(t)[v]).toBeLessThan(plateau[v]);
}
for (let t = KICK_FULL_MIN; t < PLATEAU_MIN; t++) {
for (const v of LATE_BUILDERS) expect(arcAt(t)[v]).toBeLessThan(plateau[v]);
}
});
it('holds every voice at its loudest across the midnight..2 AM window', () => {
for (const v of VOICE_NAMES) {
for (let t = PLATEAU_MIN; t <= 300; t += 10) expect(arcAt(t)[v]).toBeCloseTo(plateau[v], 10);
for (let t = 301; t <= NIGHT_END_MIN; t++) expect(arcAt(t)[v]).toBeLessThan(plateau[v]);
}
});
it('strips every voice back by lights up', () => {
for (const v of VOICE_NAMES) {
expect(arcAt(NIGHT_END_MIN)[v]).toBeLessThan(plateau[v]);
expect(arcAt(NIGHT_END_MIN)[v]).toBeLessThan(arcAt(330)[v]);
}
// Low end goes first — that is what actually empties a floor.
expect(arcAt(330).bass).toBeLessThan(arcAt(330).pad);
});
});
describe('arcLabel', () => {
it('names each keyframe at its minute and holds it until the next', () => {
for (let i = 0; i < ARC.length; i++) {
const k = ARC[i]!;
expect(arcLabel(k.at)).toBe(k.label);
const next = ARC[i + 1];
if (next && next.at > k.at + 1) expect(arcLabel(k.at + 1)).toBe(k.label);
}
});
it('reads the expected labels across the night', () => {
expect(arcLabel(0)).toBe('EMPTY');
expect(arcLabel(59)).toBe('EMPTY');
expect(arcLabel(60)).toBe('FILLING');
expect(arcLabel(119)).toBe('FILLING');
expect(arcLabel(120)).toBe('BUILDING');
expect(arcLabel(180)).toBe('PEAK');
expect(arcLabel(299)).toBe('PEAK');
expect(arcLabel(300)).toBe('PEAK');
expect(arcLabel(330)).toBe('LAST DRINKS');
expect(arcLabel(359)).toBe('LAST DRINKS');
expect(arcLabel(NIGHT_END_MIN)).toBe('LIGHTS UP');
});
it('holds the edges before doors and after close', () => {
expect(arcLabel(-30)).toBe(first.label);
expect(arcLabel(NIGHT_END_MIN + 30)).toBe(last.label);
expect(arcLabel(NaN)).toBe(first.label);
});
});
describe('arcIntensity', () => {
it('stays within 0..1 across and beyond the night', () => {
for (let t = -60; t <= NIGHT_END_MIN + 60; t++) {
const i = arcIntensity(t);
expect(i).toBeGreaterThanOrEqual(0);
expect(i).toBeLessThanOrEqual(1);
}
});
it('is the mean of the four voice scalars', () => {
for (const t of [0, 45, 120, 240, 345, 360]) {
const s = arcAt(t);
expect(arcIntensity(t)).toBeCloseTo((s.kick + s.hat + s.bass + s.pad) / 4, 10);
}
});
it('rises from doors to peak', () => {
let prev = -Infinity;
for (let t = 0; t <= 180; t += 10) {
const i = arcIntensity(t);
expect(i).toBeGreaterThan(prev);
prev = i;
}
expect(arcIntensity(180)).toBe(1);
expect(arcIntensity(0)).toBeLessThan(0.5);
});
it('holds flat across the peak plateau', () => {
for (let t = 180; t <= 300; t += 20) expect(arcIntensity(t)).toBeCloseTo(1, 10);
});
it('falls from the plateau to lights up', () => {
let prev = Infinity;
for (let t = 300; t <= NIGHT_END_MIN; t += 10) {
const i = arcIntensity(t);
expect(i).toBeLessThan(prev);
prev = i;
}
expect(arcIntensity(NIGHT_END_MIN)).toBeLessThan(arcIntensity(300));
});
});

225
tests/encounters.test.ts Normal file
View File

@ -0,0 +1,225 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { SeededRNG, type RngStream } from '../src/core/SeededRNG';
import { drunkStage } from '../src/rules/drunk';
import {
ENCOUNTERS,
ENCOUNTER_SPACING_MIN,
encounterById,
scheduleEncounters,
type EncounterId,
type ScriptedEncounter,
} from '../src/rules/encounters';
import { generatePatron, _resetPatronSerial, type GeneratorContext } from '../src/patrons/generator';
import type { Patron } from '../src/data/types';
const NIGHT = new Date('2026-07-18T00:00:00');
const SLOTS = ['accessory', 'hair', 'legs', 'outer', 'shoes', 'top'];
const stream = (seed: number, name = 'encounters'): RngStream => new SeededRNG(seed).stream(name);
/** `n` patrons of `e`'s archetype, each put through `e.dress`. */
function dressed(e: ScriptedEncounter, seed: number, n: number): Patron[] {
_resetPatronSerial();
const ctx: GeneratorContext = { rng: new SeededRNG(seed), nightDate: NIGHT };
const rng = ctx.rng.stream('encounters');
return Array.from({ length: n }, (_, i) => {
const p = generatePatron(ctx, i, e.archetype);
e.dress(p, rng);
return p;
});
}
beforeEach(() => _resetPatronSerial());
describe('ENCOUNTERS', () => {
it('ids are unique', () => {
const ids = ENCOUNTERS.map((e) => e.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('every encounter has beats, a legal window and both outcomes', () => {
for (const e of ENCOUNTERS) {
expect(e.beats.length).toBeGreaterThan(0);
expect(e.window[0]).toBeGreaterThanOrEqual(0);
expect(e.window[1]).toBeLessThanOrEqual(360);
expect(e.window[0]).toBeLessThan(e.window[1]);
expect(e.onAdmit).toBeDefined();
expect(e.onDeny).toBeDefined();
}
});
it('beats are non-empty lines staggered forward in time', () => {
for (const e of ENCOUNTERS) {
let last = -1;
for (const b of e.beats) {
expect(b.line.trim().length).toBeGreaterThan(0);
expect(b.afterMs).toBeGreaterThan(last);
last = b.afterMs;
}
}
});
it('encounterById round-trips, and is undefined for an id that is not one', () => {
for (const e of ENCOUNTERS) expect(encounterById(e.id)).toBe(e);
expect(encounterById('kayden' as EncounterId)).toBeUndefined();
});
});
describe('dress', () => {
it('lands the dumped bloke in loose, every time', () => {
const quietBeer = encounterById('quietBeer')!;
for (const p of dressed(quietBeer, 3, 200)) {
expect(drunkStage(p.intoxication)).toBe('loose');
}
});
it('leaves the paper doll a stranger — dollSeed is never touched', () => {
for (const e of ENCOUNTERS) {
_resetPatronSerial();
const ctx: GeneratorContext = { rng: new SeededRNG(5), nightDate: NIGHT };
const rng = ctx.rng.stream('encounters');
for (let i = 0; i < 200; i++) {
const p = generatePatron(ctx, i, e.archetype);
const before = p.dollSeed;
e.dress(p, rng);
expect(p.dollSeed).toBe(before);
}
}
});
it('keeps the patron structurally valid: six slots, numbers in range', () => {
for (const e of ENCOUNTERS) {
for (const p of dressed(e, 9, 200)) {
expect(p.outfit.map((l) => l.slot).sort()).toEqual(SLOTS);
expect(p.intoxication).toBeGreaterThanOrEqual(0);
expect(p.intoxication).toBeLessThanOrEqual(1);
expect(p.tolerance).toBeGreaterThanOrEqual(0);
expect(p.tolerance).toBeLessThanOrEqual(1);
// No stray archetype rolls competing with the script: the guest-list
// clipboard fires its own step-up line off claimsGuestList and would
// talk straight over the beats.
expect(p.flags.claimsGuestList).toBeUndefined();
expect(p.flags.onGuestList).toBeUndefined();
}
}
});
it('names them, and never leaves a fake licence on a scripted person', () => {
for (const e of ENCOUNTERS) {
for (const p of dressed(e, 11, 200)) {
expect(p.idCard.name.trim().length).toBeGreaterThan(0);
expect(p.idCard.fake).toBeUndefined();
expect(p.idCard.photoSeed).toBe(p.dollSeed);
// Pushed years out rather than recomputed — dress() has no nightDate.
expect(new Date(`${p.idCard.expiry}T00:00:00`).getTime()).toBeGreaterThan(NIGHT.getTime());
}
}
});
it('is deterministic for a given rng', () => {
for (const e of ENCOUNTERS) {
expect(dressed(e, 13, 20)).toEqual(dressed(e, 13, 20));
}
});
it('gives each scripted person the thing their scene is about', () => {
for (const p of dressed(encounterById('grabHerMate')!, 17, 50)) {
expect(p.flags.uvStamped).toBe(false);
expect(p.flags.contraband).toBeUndefined();
}
for (const p of dressed(encounterById('lastNight')!, 19, 50)) {
expect(p.flags.contraband).toEqual(['flask']);
}
for (const p of dressed(encounterById('nightShift')!, 23, 50)) {
const top = p.outfit.find((l) => l.slot === 'top');
expect(top?.type).toBe('polo');
expect(top?.logo).toBe(true);
}
});
});
describe('scheduleEncounters', () => {
const schedule = (seed: number, count: number) => scheduleEncounters(stream(seed), count);
it('is deterministic for the same seed and count', () => {
expect(schedule(31, 3)).toEqual(schedule(31, 3));
});
it('different seeds give different nights', () => {
const runs = [schedule(1, 2), schedule(2, 2), schedule(3, 2), schedule(4, 2)];
expect(new Set(runs.map((r) => JSON.stringify(r))).size).toBeGreaterThan(1);
});
// Pinned to the literal, not to the constant. Asserting spacing >=
// ENCOUNTER_SPACING_MIN is self-referential — retune the constant to 5 and
// that assertion still passes while the spec's 40 is silently gone.
it('the spacing rule is 40 clock-minutes', () => {
expect(ENCOUNTER_SPACING_MIN).toBe(40);
});
it('returns sorted, distinct encounters spaced at least 40 minutes apart', () => {
for (let seed = 0; seed < 60; seed++) {
for (const count of [2, 3, 4]) {
const got = schedule(seed, count);
expect(got).toHaveLength(count);
expect(new Set(got.map((g) => g.id)).size).toBe(count);
for (let i = 1; i < got.length; i++) {
expect(got[i]!.atMin).toBeGreaterThan(got[i - 1]!.atMin); // sorted
expect(got[i]!.atMin - got[i - 1]!.atMin).toBeGreaterThanOrEqual(40);
}
}
}
});
// Every count, not just the full set: at count < 4 the scheduler places a
// RANDOM SUBSET, and the load-time schedulability proof only ever reasons
// about the whole run. A subset that pushed one encounter past its own
// window end would make rng.int(min > max) and land outside it.
it('never schedules an encounter outside its own window, at any count', () => {
for (let seed = 0; seed < 200; seed++) {
for (let count = 1; count <= ENCOUNTERS.length; count++) {
for (const { atMin, id } of schedule(seed, count)) {
const e = encounterById(id)!;
expect(atMin).toBeGreaterThanOrEqual(e.window[0]);
expect(atMin).toBeLessThanOrEqual(e.window[1]);
}
}
}
});
it('handles 0, 1, and more than there are', () => {
expect(schedule(41, 0)).toEqual([]);
expect(schedule(41, -2)).toEqual([]);
expect(schedule(41, 1)).toHaveLength(1);
expect(schedule(41, 99)).toHaveLength(ENCOUNTERS.length);
});
});
// TRIPWIRE for later content passes, not a style check. Design §4.3: the game
// never tells the player which call was right, so nothing player-facing here may
// grade them. If a rewrite trips this, the line is the problem, not the list.
// Dazza is exempt — he is a character in the fiction and he is wrong constantly.
describe('tone', () => {
const BANNED = [
'should have',
'good of you',
'cruel',
'kind of you',
'right thing',
'wrong thing',
'monster',
'hero',
];
it('no beat or note passes judgement on the player', () => {
const shown: string[] = [];
for (const e of ENCOUNTERS) {
for (const b of e.beats) shown.push(b.line);
for (const o of [e.onAdmit, e.onDeny]) if (o.note !== undefined) shown.push(o.note);
}
expect(shown.length).toBeGreaterThan(0);
for (const text of shown) {
for (const word of BANNED) expect(text.toLowerCase()).not.toContain(word);
}
});
});

306
tests/floor/fight.test.ts Normal file
View File

@ -0,0 +1,306 @@
import { describe, expect, it } from 'vitest';
import {
BREAK_RADIUS_PX,
FIGHT_FUSE_MS,
FIGHT_MIN_DIST_PX,
FIGHT_MIN_INTOX,
fightEligible,
freshFight,
isBetween,
scoreFight,
shoveLine,
stepFight,
type FightState,
} from '../../src/scenes/floor/fight';
import { FIGHT_SHOVES } from '../../src/data/strings/floor';
import { drunkStage } from '../../src/rules/drunk';
/** A 200px-apart pair on a horizontal line — long enough to have real shoulders. */
const A = { x: 100, y: 100 };
const B = { x: 300, y: 100 };
const MID = { x: 200, y: 100 };
describe('freshFight', () => {
it('starts shoving with a full fuse and no escalation', () => {
const f = freshFight('a', 'b');
expect(f.phase).toBe('shoving');
expect(f.msLeft).toBe(FIGHT_FUSE_MS);
expect(f.shoves).toBe(0);
expect(f.aId).toBe('a');
expect(f.bId).toBe('b');
});
});
describe('isBetween', () => {
it('is true dead centre', () => {
expect(isBetween(MID.x, MID.y, A.x, A.y, B.x, B.y)).toBe(true);
});
it('is true anywhere along the segment', () => {
for (let x = A.x; x <= B.x; x += 10) {
expect(isBetween(x, A.y, A.x, A.y, B.x, B.y)).toBe(true);
}
});
it('is FALSE past A on the extended line — the segment, not the infinite line', () => {
const past = A.x - BREAK_RADIUS_PX - 1;
expect(isBetween(past, A.y, A.x, A.y, B.x, B.y)).toBe(false);
});
it('is FALSE past B on the extended line', () => {
const past = B.x + BREAK_RADIUS_PX + 1;
expect(isBetween(past, B.y, A.x, A.y, B.x, B.y)).toBe(false);
});
it('is false a long way past either shoulder', () => {
expect(isBetween(-5000, A.y, A.x, A.y, B.x, B.y)).toBe(false);
expect(isBetween(5000, A.y, A.x, A.y, B.x, B.y)).toBe(false);
});
it('is false perpendicular beyond the radius', () => {
expect(isBetween(MID.x, MID.y + BREAK_RADIUS_PX + 1, A.x, A.y, B.x, B.y)).toBe(false);
});
it('honours the radius at its exact boundary', () => {
expect(isBetween(MID.x, MID.y + BREAK_RADIUS_PX, A.x, A.y, B.x, B.y)).toBe(true);
expect(isBetween(MID.x, MID.y + BREAK_RADIUS_PX + 0.001, A.x, A.y, B.x, B.y)).toBe(false);
});
it('honours an explicit radius override at its boundary', () => {
expect(isBetween(MID.x, MID.y + 10, A.x, A.y, B.x, B.y, 10)).toBe(true);
expect(isBetween(MID.x, MID.y + 10.001, A.x, A.y, B.x, B.y, 10)).toBe(false);
});
it('measures the shoulder radially, not just along the axis', () => {
// Diagonally off A's corner, just outside the radius.
const d = BREAK_RADIUS_PX;
expect(isBetween(A.x - d, A.y - d, A.x, A.y, B.x, B.y)).toBe(false);
expect(isBetween(A.x - 1, A.y - 1, A.x, A.y, B.x, B.y)).toBe(true);
});
it('does not throw or NaN when A and B are the same point', () => {
expect(isBetween(A.x, A.y, A.x, A.y, A.x, A.y)).toBe(true);
expect(isBetween(A.x + BREAK_RADIUS_PX, A.y, A.x, A.y, A.x, A.y)).toBe(true);
expect(isBetween(A.x + BREAK_RADIUS_PX + 1, A.y, A.x, A.y, A.x, A.y)).toBe(false);
});
it('works on a vertical pair too', () => {
expect(isBetween(100, 200, 100, 100, 100, 300)).toBe(true);
expect(isBetween(100, 300 + BREAK_RADIUS_PX + 1, 100, 100, 100, 300)).toBe(false);
});
});
describe('stepFight purity', () => {
it('never mutates the input state', () => {
const f = freshFight('a', 'b');
const snapshot = { ...f };
stepFight(f, 3000, false);
stepFight(f, 3000, true);
expect(f).toEqual(snapshot);
});
it('returns a new object while shoving', () => {
const f = freshFight('a', 'b');
expect(stepFight(f, 100, false)).not.toBe(f);
});
});
describe('stepFight fuse', () => {
it('burns msLeft down by the delta', () => {
const f = stepFight(freshFight('a', 'b'), 1000, false);
expect(f.msLeft).toBe(FIGHT_FUSE_MS - 1000);
expect(f.phase).toBe('shoving');
});
it('is still shoving one millisecond before the fuse ends', () => {
const f = stepFight(freshFight('a', 'b'), FIGHT_FUSE_MS - 1, false);
expect(f.phase).toBe('shoving');
expect(f.msLeft).toBe(1);
});
it('swings at exactly zero', () => {
const f = stepFight(freshFight('a', 'b'), FIGHT_FUSE_MS, false);
expect(f.phase).toBe('swung');
expect(f.msLeft).toBe(0);
});
it('lands on swung — not past it — for one huge delta', () => {
const f = stepFight(freshFight('a', 'b'), 10_000_000, false);
expect(f.phase).toBe('swung');
expect(f.msLeft).toBe(0);
expect(f.shoves).toBe(5);
});
it('reaches the same place in many small steps as in one big one', () => {
let f = freshFight('a', 'b');
for (let i = 0; i < 100; i++) f = stepFight(f, 100, false);
expect(f.phase).toBe('swung');
expect(f.msLeft).toBe(0);
});
});
describe('stepFight intervention', () => {
it('breaks immediately when you get between them', () => {
const f = stepFight(freshFight('a', 'b'), 16, true);
expect(f.phase).toBe('broken');
});
it('freezes msLeft where it was when broken', () => {
const live = stepFight(freshFight('a', 'b'), 2000, false);
const broken = stepFight(live, 500, true);
expect(broken.msLeft).toBe(live.msLeft);
});
it('beats the fuse when both land on the same step', () => {
const f = stepFight(freshFight('a', 'b'), FIGHT_FUSE_MS * 2, true);
expect(f.phase).toBe('broken');
});
});
describe('stepFight terminal phases absorb', () => {
it('a swung fight can never become broken', () => {
let f = stepFight(freshFight('a', 'b'), FIGHT_FUSE_MS, false);
expect(f.phase).toBe('swung');
for (let i = 0; i < 10; i++) f = stepFight(f, 1000, true);
expect(f.phase).toBe('swung');
expect(f.msLeft).toBe(0);
});
it('a broken fight can never become swung', () => {
let f = stepFight(freshFight('a', 'b'), 100, true);
expect(f.phase).toBe('broken');
const frozen = f.msLeft;
for (let i = 0; i < 10; i++) f = stepFight(f, 100_000, false);
expect(f.phase).toBe('broken');
expect(f.msLeft).toBe(frozen);
});
it('returns the identical object once terminal', () => {
const swung = stepFight(freshFight('a', 'b'), FIGHT_FUSE_MS, false);
expect(stepFight(swung, 50, false)).toBe(swung);
const broken = stepFight(freshFight('a', 'b'), 50, true);
expect(stepFight(broken, 50, true)).toBe(broken);
});
});
describe('stepFight shoves counter', () => {
it('is monotonic and capped at 5 across the whole fuse', () => {
let f = freshFight('a', 'b');
let prev = f.shoves;
for (let i = 0; i < 200; i++) {
f = stepFight(f, 50, false);
expect(f.shoves).toBeGreaterThanOrEqual(prev);
expect(f.shoves).toBeLessThanOrEqual(5);
expect(f.shoves).toBeGreaterThanOrEqual(0);
prev = f.shoves;
}
expect(f.phase).toBe('swung');
expect(f.shoves).toBe(5);
});
it('ticks one rung per fifth of the fuse', () => {
const fifth = FIGHT_FUSE_MS / 5;
for (let rung = 0; rung <= 4; rung++) {
const f = stepFight(freshFight('a', 'b'), fifth * rung, false);
expect(f.shoves).toBe(rung);
}
});
it('does not tick before the first fifth has burned', () => {
expect(stepFight(freshFight('a', 'b'), FIGHT_FUSE_MS / 5 - 1, false).shoves).toBe(0);
});
it('holds its rung when the fight is broken', () => {
const live = stepFight(freshFight('a', 'b'), FIGHT_FUSE_MS * 0.6, false);
expect(live.shoves).toBe(3);
expect(stepFight(live, 5000, true).shoves).toBe(3);
});
});
describe('shoveLine', () => {
it('gives a real line for every reachable rung, including the capped one', () => {
for (let s = 0; s <= 5; s++) {
expect(shoveLine(s).length).toBeGreaterThan(0);
expect(FIGHT_SHOVES).toContain(shoveLine(s));
}
});
it('clamps rubbish input rather than returning undefined', () => {
expect(shoveLine(-3)).toBe(FIGHT_SHOVES[0]);
expect(shoveLine(999)).toBe(FIGHT_SHOVES[FIGHT_SHOVES.length - 1]);
});
});
describe('scoreFight', () => {
const at = (phase: FightState['phase']): FightState => ({
aId: 'a', bId: 'b', msLeft: 1000, phase, shoves: 2,
});
it('rewards a broken fight with vibe and no heat', () => {
const o = scoreFight(at('broken'));
expect(o.vibeDelta).toBeGreaterThan(0);
expect(o.heat).toBe(false);
expect(o.aggroDelta).toBeGreaterThan(0);
});
it('punishes a landed swing hard, with heat', () => {
const o = scoreFight(at('swung'));
expect(o.vibeDelta).toBeLessThan(0);
expect(o.heat).toBe(true);
expect(o.aggroDelta).toBeGreaterThan(0);
});
it('costs more vibe to miss than it pays to break up', () => {
expect(Math.abs(scoreFight(at('swung')).vibeDelta))
.toBeGreaterThan(scoreFight(at('broken')).vibeDelta);
expect(scoreFight(at('swung')).aggroDelta)
.toBeGreaterThan(scoreFight(at('broken')).aggroDelta);
});
it('scores a live fight at zero', () => {
const o = scoreFight(at('shoving'));
expect(o).toEqual({ vibeDelta: 0, aggroDelta: 0, heat: false });
});
it('only ever raises heat on a swing', () => {
expect(scoreFight(at('shoving')).heat).toBe(false);
expect(scoreFight(at('broken')).heat).toBe(false);
expect(scoreFight(at('swung')).heat).toBe(true);
});
});
describe('fightEligible', () => {
it('is false for a sober pair standing on each other', () => {
expect(fightEligible(0, 0, 1)).toBe(false);
});
it('is false when only one of them is drunk', () => {
expect(fightEligible(0.9, 0.1, 10)).toBe(false);
expect(fightEligible(0.1, 0.9, 10)).toBe(false);
});
it('is false for a drunk pair on opposite sides of the room', () => {
expect(fightEligible(0.9, 0.9, FIGHT_MIN_DIST_PX + 1)).toBe(false);
expect(fightEligible(0.9, 0.9, 400)).toBe(false);
});
it('is true for a close, drunk pair', () => {
expect(fightEligible(0.7, 0.6, 20)).toBe(true);
});
it('is true at both boundaries exactly', () => {
expect(fightEligible(FIGHT_MIN_INTOX, FIGHT_MIN_INTOX, FIGHT_MIN_DIST_PX)).toBe(true);
});
it('is false a hair under the intoxication boundary', () => {
expect(fightEligible(FIGHT_MIN_INTOX - 0.001, FIGHT_MIN_INTOX, FIGHT_MIN_DIST_PX)).toBe(false);
expect(fightEligible(FIGHT_MIN_INTOX, FIGHT_MIN_INTOX - 0.001, FIGHT_MIN_DIST_PX)).toBe(false);
});
it('gates on the tipsy/loose boundary, so a loose pair qualifies', () => {
expect(drunkStage(FIGHT_MIN_INTOX)).toBe('loose');
expect(drunkStage(FIGHT_MIN_INTOX - 0.001)).toBe('tipsy');
expect(fightEligible(0.5, 0.5, 30)).toBe(true);
expect(fightEligible(0.44, 0.44, 30)).toBe(false);
});
});

View File

@ -0,0 +1,290 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { EventBus } from '../../src/core/EventBus';
import { SeededRNG } from '../../src/core/SeededRNG';
import type { EventMap, Patron } from '../../src/data/types';
import { _resetPatronSerial, generatePatron } from '../../src/patrons/generator';
import { CrowdSim } from '../../src/scenes/floor/crowdSim';
import type { Agent } from '../../src/scenes/floor/crowdSim';
import { FIGHT_COOLDOWN_MS, FIGHT_FUSE_MS } from '../../src/scenes/floor/fight';
import { FightDirector } from '../../src/scenes/floor/fightDirector';
import { FIGHT_CAUSE } from '../../src/data/strings/floor';
import { VENUE } from '../../src/scenes/floor/venueMap';
const NIGHT = new Date('2026-07-19T21:00:00');
// Nowhere near the pair, so nothing accidentally counts as intervening.
const AWAY_X = 20;
const AWAY_Y = 20;
function makeCrowd(count: number, seed = 99): Patron[] {
_resetPatronSerial();
const rng = new SeededRNG(seed);
const out: Patron[] = [];
for (let i = 0; i < count; i++) out.push(generatePatron({ rng, nightDate: NIGHT }, 60));
return out;
}
interface Rig {
bus: EventBus;
crowd: CrowdSim;
dir: FightDirector;
agents: Agent[];
meters: Array<EventMap['meters:delta']>;
heat: Array<EventMap['heat:strike']>;
spotted: Array<EventMap['floor:infractionSpotted']>;
}
/**
* `count` agents parked shoulder-to-shoulder and all well past FIGHT_MIN_INTOX,
* so every pair is eligible and the director has a real choice to make. Nothing
* calls crowd.update() unless a test wants it that keeps the bus to fight events.
*/
function rig(count = 2, seed = 7): Rig {
const bus = new EventBus();
const rng = new SeededRNG(seed);
const crowd = new CrowdSim({ map: VENUE, bus, rng: rng.stream('crowd') });
const agents = makeCrowd(count).map((p, i) => {
const a = crowd.spawn(p);
a.patron.intoxication = 0.8;
a.activity = 'dancing';
a.dwellMs = 10 * 60 * 1000;
// Open floor, 20px apart: inside FIGHT_MIN_DIST_PX (so neighbours are
// eligible), outside the ambient bump radius, and clear of every wall so a
// released agent can actually walk off.
a.x = 240 + i * 20;
a.y = 100;
return a;
});
const meters: Array<EventMap['meters:delta']> = [];
const heat: Array<EventMap['heat:strike']> = [];
const spotted: Array<EventMap['floor:infractionSpotted']> = [];
bus.on('meters:delta', (p) => meters.push(p));
bus.on('heat:strike', (p) => heat.push(p));
bus.on('floor:infractionSpotted', (p) => spotted.push(p));
return {
bus, crowd, agents, meters, heat, spotted,
dir: new FightDirector({ bus, rng: rng.stream('fights'), crowd }),
};
}
function runAway(dir: FightDirector, ms: number, step = 100): void {
for (let t = 0; t < ms; t += step) dir.update(AWAY_X, AWAY_Y, step);
}
/** The point the player has to stand on to get between them. */
function midpoint(p: [Agent, Agent]): [number, number] {
return [(p[0].x + p[1].x) / 2, (p[0].y + p[1].y) / 2];
}
beforeEach(() => {
_resetPatronSerial();
});
describe('starting', () => {
it('kicks off a fight in a drunk crowd and flags it as an infraction', () => {
const r = rig(6);
runAway(r.dir, 500);
const pair = r.dir.pair();
expect(pair).not.toBeNull();
expect(r.dir.active?.phase).toBe('shoving');
expect(FIGHT_CAUSE).toContain(r.dir.cause);
expect(r.spotted).toEqual([{ patronId: pair?.[0].patron.id, kind: 'fight' }]);
});
it('never runs two fights at once', () => {
const r = rig(6);
// Derived from the constants rather than hardcoded: a wall of frames that
// covered several fights at a 20s cooldown covers less than one at 150s,
// and the >1 assertion below is what proves the window actually exercised
// the between-fights transition rather than passing vacuously.
const dt = 50;
const frames = Math.ceil((3 * (FIGHT_FUSE_MS + FIGHT_COOLDOWN_MS)) / dt);
for (let i = 0; i < frames; i++) {
r.dir.update(AWAY_X, AWAY_Y, dt);
const squaring = r.agents.filter((a) => a.activity === 'squaringUp');
expect(squaring.length).toBeLessThanOrEqual(2);
expect(squaring.length).toBe(r.dir.active ? 2 : 0);
}
expect(r.spotted.length).toBeGreaterThan(1);
});
it('leaves a sober crowd alone', () => {
const r = rig(6);
for (const a of r.agents) a.patron.intoxication = 0.2;
runAway(r.dir, 60_000);
expect(r.dir.active).toBeNull();
expect(r.spotted).toHaveLength(0);
});
it('pins both agents so the sim stops moving them', () => {
const r = rig(3);
// Walking, not parked — without the pin the sim would carry them off, which
// is what makes the unchanged coordinates below mean anything.
for (const a of r.agents) a.activity = 'walking';
r.dir.update(AWAY_X, AWAY_Y, 16);
const pair = r.dir.pair();
expect(pair).not.toBeNull();
if (!pair) return;
const loose = r.agents.find((a) => !pair.includes(a));
const before = [...pair, loose].map((a) => ({ x: a?.x, y: a?.y }));
for (let i = 0; i < 20; i++) {
r.crowd.update(16, 60 + i / 60);
r.dir.update(AWAY_X, AWAY_Y, 16);
}
for (const [i, a] of pair.entries()) {
expect(a.activity).toBe('squaringUp');
expect(a.x).toBe(before[i]?.x);
expect(a.y).toBe(before[i]?.y);
}
// Control: the one nobody pinned kept walking.
expect(loose?.x).not.toBe(before[2]?.x);
});
});
describe('resolving', () => {
it('breaks up when you stand between them', () => {
const r = rig(2);
r.dir.update(AWAY_X, AWAY_Y, 16);
const pair = r.dir.pair();
expect(pair).not.toBeNull();
if (!pair) return;
const [px, py] = midpoint(pair);
r.dir.update(px, py, 16);
expect(r.dir.active).toBeNull();
expect(r.meters).toEqual([{ vibe: 6, aggro: 1 }]);
expect(r.heat).toHaveLength(0);
for (const a of pair) {
expect(a.handled).toBe(true);
expect(a.activity).toBe('walking');
}
});
it('lets the swing land if you walk away, and takes a heat strike for it', () => {
const r = rig(2);
runAway(r.dir, FIGHT_FUSE_MS + 500);
expect(r.dir.active).toBeNull();
expect(r.meters).toEqual([{ vibe: -12, aggro: 20 }]);
// Deferred: a missed brawl surfaces at inspection rather than striking on the spot.
expect(r.heat).toEqual([{ reason: 'Fight on the floor', deferred: true }]);
});
it('does not count a swung fight as dealt with', () => {
const r = rig(2);
r.dir.update(AWAY_X, AWAY_Y, 16);
const pair = r.dir.pair();
runAway(r.dir, FIGHT_FUSE_MS + 500);
expect(r.dir.takeResolution()?.phase).toBe('swung');
for (const a of pair ?? []) {
expect(a.handled).toBe(false);
expect(a.activity).toBe('walking');
}
});
it('hands the resolution over exactly once', () => {
const r = rig(2);
r.dir.update(AWAY_X, AWAY_Y, 16);
const pair = r.dir.pair();
expect(r.dir.takeResolution()).toBeNull(); // nothing to say mid-fight
if (!pair) return;
const [px, py] = midpoint(pair);
r.dir.update(px, py, 16);
const res = r.dir.takeResolution();
expect(res?.phase).toBe('broken');
expect(res?.outcome.heat).toBe(false);
expect(res?.line).toBeTruthy();
expect(r.dir.takeResolution()).toBeNull();
});
});
describe('cooldown', () => {
it('will not start another fight straight after one resolves', () => {
const r = rig(6);
runAway(r.dir, FIGHT_FUSE_MS + 200);
expect(r.spotted).toHaveLength(1);
expect(r.dir.active).toBeNull();
runAway(r.dir, FIGHT_COOLDOWN_MS - 1000);
expect(r.dir.active).toBeNull();
expect(r.spotted).toHaveLength(1);
runAway(r.dir, 2000);
expect(r.spotted).toHaveLength(2);
});
});
describe('bail-outs', () => {
it('aborts without scoring if one of them leaves mid-fight', () => {
const r = rig(2);
r.dir.update(AWAY_X, AWAY_Y, 16);
const pair = r.dir.pair();
expect(pair).not.toBeNull();
if (!pair) return;
const [a, b] = pair;
a.gone = true;
r.dir.update(AWAY_X, AWAY_Y, 16);
expect(r.dir.active).toBeNull();
expect(r.dir.takeResolution()).toBeNull();
expect(r.meters).toHaveLength(0);
expect(r.heat).toHaveLength(0);
expect(b.activity).toBe('walking');
expect(b.handled).toBe(false);
// Nobody drags a departed patron back onto the dance floor.
expect(a.activity).toBe('squaringUp');
// And the cooldown started, so the survivor isn't instantly in another one.
runAway(r.dir, FIGHT_COOLDOWN_MS - 2000);
expect(r.dir.active).toBeNull();
});
it('leaves nobody frozen when destroyed', () => {
const r = rig(6);
r.dir.update(AWAY_X, AWAY_Y, 16);
expect(r.agents.filter((a) => a.activity === 'squaringUp')).toHaveLength(2);
r.dir.destroy();
expect(r.dir.active).toBeNull();
expect(r.dir.pair()).toBeNull();
expect(r.agents.filter((a) => a.activity === 'squaringUp')).toHaveLength(0);
});
});
describe('determinism', () => {
it('picks the same pair and the same cause from the same seed', () => {
const runOnce = (): { ids: string[]; cause: string } => {
const r = rig(6, 4242);
r.dir.update(AWAY_X, AWAY_Y, 16);
const pair = r.dir.pair();
return { ids: (pair ?? []).map((a) => a.patron.id), cause: r.dir.cause };
};
const first = runOnce();
expect(first.ids).toHaveLength(2);
expect(runOnce()).toEqual(first);
});
it('diverges on a different seed', () => {
const causeFor = (seed: number): string => {
const r = rig(6, seed);
runAway(r.dir, 200_000); // several fights, so the streams have room to drift
return r.spotted.map((s) => s.patronId).join(',');
};
expect(causeFor(1)).not.toBe(causeFor(2));
});
});

344
tests/guestList.test.ts Normal file
View File

@ -0,0 +1,344 @@
import { describe, expect, it } from 'vitest';
import { SeededRNG, type RngStream } from '../src/core/SeededRNG';
import { ARCHETYPES } from '../src/data/archetypes';
import {
assignListedName,
editDistance,
generateGuestList,
lookupName,
GUEST_LIST_TUNING as T,
type GuestList,
type NameMatch,
} from '../src/rules/guestList';
const stream = (seed: number) => new SeededRNG(seed).stream('guestList');
/**
* A real stream with its FIRST `next()` forced to `roll`. assignListedName picks
* an entry and then draws exactly one `next()` to choose its branch, so this
* pins which branch runs while leaving every corruption draw genuinely random.
* Without it the branches are only observable in aggregate, and aggregate
* proportions are far too blunt to see a class leak.
*/
function forcedBranch(seed: number, roll: number): RngStream {
const r = stream(seed);
let spent = false;
return {
next: () => {
if (spent) return r.next();
spent = true;
return roll;
},
int: (min, max) => r.int(min, max),
pick: (arr) => r.pick(arr),
chance: (p) => r.chance(p),
weighted: (entries) => r.weighted(entries),
};
}
const listFor = (seed: number, size = 12): GuestList => generateGuestList(stream(seed), size);
const classOf = (claimed: string, list: GuestList): NameMatch => lookupName(claimed, list).match;
/** Class mix over `n` claims, all with the same truth value. */
function sample(seed: number, list: GuestList, genuine: boolean, n = 500) {
const rng = stream(seed);
const counts: Record<NameMatch, number> = { exact: 0, near: 0, none: 0 };
const claims: string[] = [];
for (let i = 0; i < n; i++) {
const claimed = assignListedName(rng, list, genuine);
claims.push(claimed);
counts[classOf(claimed, list)]++;
}
return { counts, claims };
}
describe('editDistance', () => {
it('is zero for identical strings and for two empties', () => {
expect(editDistance('', '')).toBe(0);
expect(editDistance('Shazza Nguyen', 'Shazza Nguyen')).toBe(0);
});
it('against an empty string costs one edit per character', () => {
expect(editDistance('', 'Doyle')).toBe(5);
expect(editDistance('Doyle', '')).toBe(5);
});
it('charges one for a single insert, delete or substitute', () => {
expect(editDistance('Kylie', 'Kyliee')).toBe(1);
expect(editDistance('Kylie', 'Kyle')).toBe(1);
expect(editDistance('Kylie', 'Kylia')).toBe(1);
});
it('charges two for a transposition — this is Levenshtein, not Damerau', () => {
// Load-bearing for nearThreshold: a swapped pair is the most expensive
// variant the module produces, and it has to stay inside the threshold.
expect(editDistance('Marco', 'Mraco')).toBe(2);
expect(editDistance('Brooke Vella', 'Brooke Vlela')).toBe(2);
});
it('ignores case and normalises whitespace', () => {
expect(editDistance('BAZZA SMITH', 'bazza smith')).toBe(0);
expect(editDistance(' Bazza Smith ', 'Bazza Smith')).toBe(0);
expect(editDistance('Bazza\tSmith', 'bazza smith')).toBe(0);
});
it('is symmetric and agrees with hand-counted multi-edit cases', () => {
expect(editDistance('Chantelle', 'Chantel')).toBe(2);
expect(editDistance('Chantel', 'Chantelle')).toBe(2);
expect(editDistance('Ngaio Taufa', 'Naigo Toufa')).toBe(3);
});
});
describe('generateGuestList', () => {
it('is deterministic: same seed → identical list', () => {
expect(listFor(4207)).toEqual(listFor(4207));
expect(listFor(4207)).not.toEqual(listFor(4208));
});
it('clamps the size to 8..14 however it is asked', () => {
for (const asked of [-5, 0, 3, 8, 11, 14, 40, NaN]) {
const n = generateGuestList(stream(77), asked).entries.length;
expect(n).toBeGreaterThanOrEqual(T.minEntries);
expect(n).toBeLessThanOrEqual(T.maxEntries);
}
});
it('never repeats a name and draws only from the crowd the generator uses', () => {
for (let seed = 0; seed < 60; seed++) {
const names = listFor(seed, 14).entries.map((e) => e.name);
expect(new Set(names).size).toBe(names.length);
for (const name of names) expect(name).toMatch(/^[A-Za-z]+ [A-Za-z]+$/);
}
});
it('keeps every pair of entries at least entrySeparation apart', () => {
// The whole clipboard rests on this: if two entries were within the
// threshold of one variant, that variant would point at both and the player
// would be guessing rather than reading. The pools themselves cannot
// promise it — Bazza and Dazza are one edit apart — so the list enforces it.
// Asserted against the literal 4 as well as the constant: every other test
// in this file reads T.nearThreshold on both sides of the comparison, so
// without a literal somewhere a retune of the threshold is invisible.
expect(T.nearThreshold).toBe(2);
expect(T.entrySeparation).toBe(2 * T.nearThreshold);
for (let seed = 0; seed < 120; seed++) {
const { entries } = listFor(seed, 14);
expect(entries.length).toBe(T.maxEntries);
for (let i = 0; i < entries.length; i++) {
for (let j = i + 1; j < entries.length; j++) {
expect(editDistance(entries[i]!.name, entries[j]!.name)).toBeGreaterThan(4);
}
}
}
});
it('hands out 0..3 plus-ones, mostly none', () => {
const all = Array.from({ length: 40 }, (_, s) => listFor(s, 14).entries).flat();
for (const e of all) {
expect(Number.isInteger(e.plusOnes)).toBe(true);
expect(e.plusOnes).toBeGreaterThanOrEqual(0);
expect(e.plusOnes).toBeLessThanOrEqual(3);
}
const alone = all.filter((e) => e.plusOnes === 0).length / all.length;
expect(alone).toBeGreaterThan(0.5);
});
});
describe('lookupName', () => {
it('classifies by distance and returns the closest entry', () => {
const list = listFor(31);
const target = list.entries[3]!;
expect(lookupName(target.name, list)).toEqual({ match: 'exact', entry: target, distance: 0 });
const near = lookupName(`${target.name.slice(0, 1)}${target.name}`, list);
expect(near.match).toBe('near');
expect(near.entry).toBe(target);
expect(near.distance).toBe(1);
});
it('a miss reports its distance but carries no entry', () => {
// Anything handed back here is a name the door UI would highlight.
const r = lookupName('Kayden Kayden', listFor(31));
expect(r.match).toBe('none');
expect(r.entry).toBeUndefined();
expect(r.distance).toBeGreaterThan(T.nearThreshold);
});
it('an empty clipboard matches nothing and reports infinite distance', () => {
const r = lookupName('Shazza Nguyen', { entries: [] });
expect(r.match).toBe('none');
expect(r.entry).toBeUndefined();
expect(r.distance).toBe(Number.POSITIVE_INFINITY);
});
it('survives whatever a name field can contain', () => {
const list = listFor(5);
const odd = ['', ' ', '\n\t', '!!!', "O'Brien-Smith (the tall one)", '???? ????', '🍢 kebab',
'x'.repeat(5000), list.entries[0]!.name.toUpperCase(), ` ${list.entries[0]!.name} `];
for (const claimed of odd) {
const r = lookupName(claimed, list);
expect(['exact', 'near', 'none']).toContain(r.match);
expect(r.distance).toBeGreaterThanOrEqual(0);
}
// Casing and stray whitespace must not cost a real guest their entry.
expect(classOf(list.entries[0]!.name.toUpperCase(), list)).toBe('exact');
expect(classOf(` ${list.entries[0]!.name} `, list)).toBe('exact');
});
});
describe('assignListedName', () => {
it('is deterministic and never throws on an empty clipboard', () => {
const a = Array.from({ length: 20 }, () => 0);
const runA = ((rng) => a.map(() => assignListedName(rng, { entries: [] }, true)))(stream(3));
const runB = ((rng) => a.map(() => assignListedName(rng, { entries: [] }, true)))(stream(3));
expect(runA).toEqual(runB);
for (const name of runA) expect(name.length).toBeGreaterThan(0);
});
it('a genuine lister mostly lands NEAR, sometimes exact, rarely unreadable', () => {
const list = listFor(11);
const { counts, claims } = sample(101, list, true);
const n = claims.length;
expect(counts.near / n).toBeCloseTo(T.genuineNear, 1);
expect(counts.exact / n).toBeCloseTo(T.genuineExact, 1);
expect(counts.none / n).toBeCloseTo(T.genuineGarbled, 1);
});
it('a liar mostly lands NONE, but lifts a real entry often enough to matter', () => {
const list = listFor(11);
const { counts, claims } = sample(202, list, false);
const n = claims.length;
expect(counts.none / n).toBeCloseTo(T.liarInvented, 1);
expect(counts.exact / n).toBeCloseTo(T.liarExact, 1);
expect(counts.near / n).toBeCloseTo(T.liarNear, 1);
expect(counts.exact).toBeGreaterThan(0);
});
it('every claim is exactly on the list, inside the threshold, or clearly off it', () => {
const list = listFor(11);
const names = new Set(list.entries.map((e) => e.name));
for (const genuine of [true, false]) {
for (const claimed of sample(303, list, genuine).claims) {
const r = lookupName(claimed, list);
// The only non-tautological halves: an 'exact' really is a verbatim row
// (not a case/whitespace lookalike), and a 'near' really is not.
if (r.match === 'exact') expect(names.has(claimed)).toBe(true);
if (r.match === 'near') expect(names.has(claimed)).toBe(false);
}
}
});
it('EVERY branch produces the class it intended, not just on average', () => {
// The spec's hard requirement, and the one thing aggregate proportions
// cannot see. A near variant that comes back distance 0 is an accidental
// 'exact'; a garble that lands inside the threshold is an accidental
// 'near'. Both are rare enough (~1% and ~23% of raw corruptions
// respectively) to hide inside a toBeCloseTo on the branch mix, and both
// move the posteriors. nearVariant() and garbled() each re-check their own
// output before returning; if either check is removed, this fails and the
// proportion tests above do not.
const list = listFor(11);
const bands: Array<[string, boolean, number, number, NameMatch]> = [
['genuine → exact', true, 0, T.genuineExact, 'exact'],
['genuine → near', true, T.genuineExact, T.genuineExact + T.genuineNear, 'near'],
['genuine → garbled', true, T.genuineExact + T.genuineNear, 1, 'none'],
['liar → lifted exact', false, 0, T.liarExact, 'exact'],
['liar → near', false, T.liarExact, T.liarExact + T.liarNear, 'near'],
['liar → invented', false, T.liarExact + T.liarNear, 1, 'none'],
];
bands.forEach(([label, genuine, lo, hi, expected], band) => {
for (let i = 0; i < 600; i++) {
// Each band gets its own seed range: the forced roll does not perturb
// the corruption draws, so sharing seeds across bands would re-test one
// set of variants six times and shrink the real sample to a sixth.
// 600 distinct corruptions puts a ~1% leak beyond plausible deniability.
const seed = band * 1000 + i;
// Walk the whole band, not just its midpoint: the boundaries are where
// an off-by-one in the cumulative comparisons would show up.
const roll = lo + ((hi - lo) * (i % 20)) / 20;
const claimed = assignListedName(forcedBranch(seed, roll), list, genuine);
expect({ label, roll, claimed, got: classOf(claimed, list) }).toEqual({
label,
roll,
claimed,
got: expected,
});
}
});
});
it('a near claim is never within the threshold of two different entries', () => {
// entrySeparation is 2 * nearThreshold precisely so this holds: a variant
// sits <= nearThreshold from its source, so a gap of merely nearThreshold+1
// would let it land one edit from a neighbour and lookupName would hand the
// door UI the wrong row, with someone else's plus-ones on it.
for (let s = 0; s < 60; s++) {
const rng = stream(s);
const list = generateGuestList(rng, 14);
for (let i = 0; i < 150; i++) {
const claimed = assignListedName(rng, list, i % 2 === 0);
if (classOf(claimed, list) !== 'near') continue;
const within = list.entries.filter((e) => editDistance(claimed, e.name) <= T.nearThreshold);
expect(within).toHaveLength(1);
}
}
});
it('the claimant prior matches the archetype table it was derived from', () => {
// claimantGenuineRate is copied maths, and copied maths rots. If someone
// retunes onGuestList/claimsGuestList in data/archetypes.ts, the posteriors
// below move and this fails first.
let claims = 0;
let listed = 0;
for (const a of ARCHETYPES) {
const onList = a.chances.onGuestList ?? 0;
const claimP = onList + (1 - onList) * (a.chances.claimsGuestList ?? 0);
claims += a.weight * claimP;
listed += a.weight * onList;
}
expect(listed / claims).toBeCloseTo(T.claimantGenuineRate, 2);
});
it('an exact match is a coin flip, near leans legit, none leans liar', () => {
// THE test. Everything else in this file is scaffolding for it: the door is
// only playable if the three match classes carry different, readable, and
// never-certain odds. Nail these and the clipboard is a skill; let exact
// drift to either extreme and it is either a lookup table or a dice roll.
const rng = stream(9001);
const list = generateGuestList(rng, 12);
const seen: Record<NameMatch, { genuine: number; liar: number }> = {
exact: { genuine: 0, liar: 0 },
near: { genuine: 0, liar: 0 },
none: { genuine: 0, liar: 0 },
};
for (let i = 0; i < 12000; i++) {
const genuine = rng.chance(T.claimantGenuineRate);
const bucket = seen[classOf(assignListedName(rng, list, genuine), list)];
if (genuine) bucket.genuine++;
else bucket.liar++;
}
const posterior = (m: NameMatch): number => {
const b = seen[m];
expect(b.genuine + b.liar).toBeGreaterThan(200);
return b.genuine / (b.genuine + b.liar);
};
const exact = posterior('exact');
const near = posterior('near');
const none = posterior('none');
expect(exact).toBeGreaterThan(0.42);
expect(exact).toBeLessThan(0.58);
expect(near).toBeGreaterThan(0.55);
expect(none).toBeLessThan(0.1);
// Ordered and, crucially, never certain in either direction: an honest
// patron can read as a chancer and a chancer can read as honest.
expect(none).toBeLessThan(exact);
expect(exact).toBeLessThan(near);
expect(near).toBeLessThan(1);
expect(none).toBeGreaterThan(0);
});
});

View File

@ -0,0 +1,323 @@
import { describe, expect, it } from 'vitest';
import { SeededRNG } from '../src/core/SeededRNG';
import {
buildReport,
fileReport,
findKaydenContradiction,
lieCount,
parseFiledAccount,
REPORT_MARKER,
REPORT_TUNING,
type FiledAnswer,
type ReportOption,
} from '../src/rules/incidentReport';
import type { IncidentRecord } from '../src/data/types';
const stream = (seed = 7) => new SeededRNG(seed).stream('report');
const inc = (over: Partial<IncidentRecord> = {}): IncidentRecord => ({
clockMin: 120,
kind: 'ejection',
patronId: 'p7',
detail: 'fight / messy',
...over,
});
/** The shapes the three scenes actually write today (grep `incident:log`). */
const REAL_NIGHT: IncidentRecord[] = [
{ clockMin: 12, kind: 'admit', patronId: 'p1', detail: 'punter · id 24' },
{ clockMin: 40, kind: 'deny', patronId: 'p2', detail: 'influencer · id 22' },
{ clockMin: 55, kind: 'wait', patronId: 'p3', detail: 'regular · id 31' },
{ clockMin: 96, kind: 'kayden', patronId: 'p4', detail: 'Kayden waved through almost18' },
{ clockMin: 140, kind: 'stallBusted', patronId: 'p5', detail: 'Two out of one cubicle.' },
{ clockMin: 210, kind: 'ejection', patronId: 'p6', detail: 'fight / messy' },
{ clockMin: 360, kind: 'deferred', patronId: 'p2', detail: 'capacity breach — p2 admitted at 80/80' },
];
const truthfulOf = (o: readonly ReportOption[]): ReportOption =>
o.find((x) => x.truthful) ?? o[0]!;
const allOptions = (): ReportOption[] => {
const kinds = [...Object.keys(REPORT_TUNING.interest), 'somethingNobodyWroteYet'];
const seen = new Map<string, ReportOption>();
for (const kind of kinds) {
for (const q of buildReport([inc({ kind })], stream())) {
for (const o of q.options) seen.set(o.id, o);
}
}
return [...seen.values()];
};
describe('buildReport', () => {
it('is deterministic for the same seed', () => {
expect(buildReport(REAL_NIGHT, stream(3))).toEqual(buildReport(REAL_NIGHT, stream(3)));
});
it('asks at most maxQuestions', () => {
const many = Array.from({ length: 30 }, (_, i) =>
inc({ clockMin: i * 10, patronId: `p${i}` }),
);
expect(buildReport(many, stream()).length).toBe(REPORT_TUNING.maxQuestions);
});
it('returns fewer questions rather than padding a quiet night', () => {
const questions = buildReport([inc({ kind: 'cutOff' })], stream());
expect(questions.length).toBe(1);
});
it('returns [] for an empty incident list without throwing', () => {
expect(buildReport([], stream())).toEqual([]);
});
it('never asks about routine admits or stalls-for-time', () => {
const routine = [
inc({ clockMin: 5, kind: 'admit' }),
inc({ clockMin: 6, kind: 'wait' }),
inc({ clockMin: 7, kind: 'admit' }),
];
expect(buildReport(routine, stream())).toEqual([]);
});
it('prefers the interesting incidents in a real night', () => {
const kinds = buildReport(REAL_NIGHT, stream(11)).map((q) => q.incident.kind);
expect(kinds).toContain('deferred');
expect(kinds).not.toContain('admit');
expect(kinds).not.toContain('wait');
});
it('asks in chronological order', () => {
const mins = buildReport(REAL_NIGHT, stream(5)).map((q) => q.incident.clockMin);
expect([...mins].sort((a, b) => a - b)).toEqual(mins);
});
it('still asks about an incident kind it has never heard of', () => {
const questions = buildReport([inc({ kind: 'budgieLoose' })], stream());
expect(questions.length).toBe(1);
expect(questions[0]?.options.length).toBeGreaterThanOrEqual(2);
});
it('skips rows that are already filed accounts', () => {
const filed = fileReport([{ incident: inc(), chosen: { id: 'x', text: 'a', truthful: false } }]);
expect(buildReport(filed, stream())).toEqual([]);
});
it('offers exactly one truthful account and at least two options per question', () => {
for (const kind of [...Object.keys(REPORT_TUNING.interest), 'unheardOf']) {
for (const q of buildReport([inc({ kind })], stream())) {
expect(q.options.length).toBeGreaterThanOrEqual(2);
expect(q.options.length).toBeLessThanOrEqual(3);
expect(q.options.filter((o) => o.truthful).length).toBe(1);
expect(q.prompt.length).toBeGreaterThan(0);
}
}
});
it('does not always put the truthful account in the same slot', () => {
// Without the Fisher-Yates the truthful option sits at a fixed index forever
// and the form becomes "click the top button". Deleting the shuffle must
// fail a test, not pass silently.
const positions = new Set<number>();
for (let seed = 0; seed < 40; seed++) {
const q = buildReport([inc({ kind: 'ejection' })], stream(seed))[0]!;
positions.add(q.options.findIndex((o) => o.truthful));
}
expect(positions.size).toBeGreaterThan(1);
});
it('asks about a kind that collides with an Object.prototype member', () => {
// `kind` is an open string; an inherited-property lookup would make these
// vanish from the form instead of falling back to the default bank.
for (const kind of ['toString', 'constructor', 'valueOf', 'hasOwnProperty']) {
const questions = buildReport([inc({ kind })], stream());
expect(questions.length, kind).toBe(1);
expect(questions[0]?.options.length, kind).toBeGreaterThanOrEqual(2);
expect(questions[0]?.options.filter((o) => o.truthful).length, kind).toBe(1);
}
});
it('gives every option in a question a unique id', () => {
for (const q of buildReport(REAL_NIGHT, stream(2))) {
expect(new Set(q.options.map((o) => o.id)).size).toBe(q.options.length);
}
});
});
describe('fileReport', () => {
it('round-trips a lie: the account and the truth flag both come back', () => {
const question = buildReport([inc({ kind: 'ejection' })], stream())[0]!;
const lie = question.options.find((o) => !o.truthful)!;
const filed = fileReport([{ incident: question.incident, chosen: lie }])[0]!;
const parsed = parseFiledAccount(filed);
expect(parsed?.account).toBe(lie.text);
expect(parsed?.truthful).toBe(false);
expect(parsed?.optionId).toBe(lie.id);
expect(parsed?.truthLog).toBe(question.incident.detail);
});
it('round-trips the truthful account too', () => {
const question = buildReport([inc({ kind: 'cutOff' })], stream())[0]!;
const truth = truthfulOf(question.options);
const filed = fileReport([{ incident: question.incident, chosen: truth }])[0]!;
expect(parseFiledAccount(filed)).toEqual({
account: truth.text,
optionId: truth.id,
truthful: true,
truthLog: question.incident.detail,
});
});
it('preserves clockMin, kind and patronId', () => {
const incident = inc({ clockMin: 233, kind: 'maggotUnhandled', patronId: 'p42' });
const filed = fileReport([
{ incident, chosen: { id: 'maggot.soft1', text: 'Water was made available.', truthful: false } },
])[0]!;
expect(filed.clockMin).toBe(233);
expect(filed.kind).toBe('maggotUnhandled');
expect(filed.patronId).toBe('p42');
});
it('leaves patronId off entirely when the incident had none', () => {
const incident: IncidentRecord = { clockMin: 90, kind: 'deferred', detail: 'lap' };
const filed = fileReport([
{ incident, chosen: { id: 'deferred.true', text: 'My call.', truthful: true } },
])[0]!;
expect('patronId' in filed).toBe(false);
});
it('starts detail with the account so a debug dump still reads like a report', () => {
const incident = inc();
const chosen: ReportOption = { id: 'ejection.true', text: 'I walked them out.', truthful: true };
const filed = fileReport([{ incident, chosen }])[0]!;
expect(filed.detail.startsWith(chosen.text)).toBe(true);
expect(filed.detail).toContain(REPORT_MARKER);
});
it('survives a truth-log line containing marker-ish punctuation', () => {
const incident = inc({ detail: 'weird [report:v1] {"truthful":true} · id 19' });
const filed = fileReport([
{ incident, chosen: { id: 'ejection.soft1', text: 'Escorted as a courtesy.', truthful: false } },
])[0]!;
expect(parseFiledAccount(filed)?.truthLog).toBe(incident.detail);
expect(parseFiledAccount(filed)?.truthful).toBe(false);
});
it('files one row per answer', () => {
const answers: FiledAnswer[] = REAL_NIGHT.slice(0, 3).map((incident) => ({
incident,
chosen: { id: 'other.true', text: 'The log has it right.', truthful: true },
}));
expect(fileReport(answers).length).toBe(3);
});
});
describe('parseFiledAccount', () => {
it('returns undefined for a raw truth-log row', () => {
expect(parseFiledAccount(inc())).toBeUndefined();
});
it('returns undefined for a corrupt payload rather than guessing', () => {
expect(parseFiledAccount(inc({ detail: `account${REPORT_MARKER}{not json` }))).toBeUndefined();
expect(parseFiledAccount(inc({ detail: `account${REPORT_MARKER}{"optionId":1}` }))).toBeUndefined();
});
});
describe('lieCount', () => {
it('counts only the flattering answers', () => {
const answers: FiledAnswer[] = [
{ incident: inc(), chosen: { id: 'a', text: 'x', truthful: true } },
{ incident: inc(), chosen: { id: 'b', text: 'x', truthful: false } },
{ incident: inc(), chosen: { id: 'c', text: 'x', truthful: false } },
];
expect(lieCount(answers)).toBe(2);
expect(lieCount([])).toBe(0);
});
});
describe('tone', () => {
// §4.3: the satire is on the paperwork and the person holding the torch, never
// on the person at the rope.
const MEAN = /\b(flog|muppet|idiot|scumbag|junkie|pig|slut|pathetic|deserved|filth|animal|grub)\b/i;
it('never blames the patron in a mean-spirited way', () => {
for (const o of allOptions()) expect(o.text).not.toMatch(MEAN);
});
it('keeps the lies deniable rather than cartoonish', () => {
for (const o of allOptions()) {
expect(o.text).not.toMatch(/nothing happened|never happened|no incident occurred/i);
}
});
it('never tells the player whether they chose well', () => {
for (const o of allOptions()) {
expect(o.text).not.toMatch(/\b(good call|well done|shameful|you should have)\b/i);
}
});
it('never lets an account pronounce a verdict on the player', () => {
// The regex above is a strawman — no writer was ever going to type "well
// done" into a bouncer report. The live risk is subtler: a truthful account
// that adjudicates ("it was wrong", "they had a point", "I had decided
// before they finished") makes truth-telling the self-flagellating branch
// and lying the clean one, which is the game saying which choice was right.
// §4.3 forbids that. A truthful account states the fact and stops.
const VERDICT =
/\b(it was wrong|i was wrong|they had a point|should not have|shouldn't have|my fault|i feel|i regret|if i am honest|if i'm honest|to be fair to them|in hindsight|looking at it now)\b/i;
for (const o of allOptions()) expect(o.text, o.id).not.toMatch(VERDICT);
});
it('writes every account in the first-person report register, no newlines', () => {
for (const o of allOptions()) {
expect(o.text).not.toContain('\n');
expect(o.text.endsWith('.')).toBe(true);
}
});
});
// ---- cross-night contradiction (design §3.3's "one taste") -------------------
//
// Added after the play-through: a full 3-night run never exercised this, because
// the contradiction needs a `kayden` incident, and Kayden only rules the rope
// while the player is on the FLOOR. Easy to leave permanently unverified.
describe('findKaydenContradiction', () => {
const kaydenIncident = (patronId: string): IncidentRecord => ({
clockMin: 120,
kind: 'kayden',
patronId,
detail: 'Kayden waved through punter',
});
const fileOne = (incident: IncidentRecord, truthful: boolean): IncidentRecord[] =>
fileReport([
{ incident, chosen: { id: truthful ? 't' : 'f', text: 'an account', truthful } },
]);
it('finds a lie told about a Kayden incident on an earlier night', () => {
const past = [fileOne(kaydenIncident('p9'), false)];
const line = findKaydenContradiction(past);
expect(line).toBeTruthy();
expect(line).toContain('kayden');
});
it('stays quiet when the Kayden incident was reported honestly', () => {
expect(findKaydenContradiction([fileOne(kaydenIncident('p9'), true)])).toBeUndefined();
});
it('ignores lies about incidents that are not Kaydens', () => {
const other: IncidentRecord = { clockMin: 60, kind: 'deferred', patronId: 'p3', detail: 'x' };
expect(findKaydenContradiction([fileOne(other, false)])).toBeUndefined();
});
it('returns the most recent nights lie when several nights lied', () => {
const past = [fileOne(kaydenIncident('pOLD'), false), fileOne(kaydenIncident('pNEW'), false)];
expect(findKaydenContradiction(past)).toContain('pNEW');
});
it('is safe on empty history and on raw truth-log rows', () => {
expect(findKaydenContradiction([])).toBeUndefined();
// A row NightScene wrote directly, with no filed-account encoding on it.
expect(findKaydenContradiction([[kaydenIncident('p1')]])).toBeUndefined();
});
});

218
tests/inspector.test.ts Normal file
View File

@ -0,0 +1,218 @@
import { describe, expect, it, vi } from 'vitest';
// shouldRecordStrike is pure but lives in a module that subclasses Phaser.Scene,
// and Phaser touches `window` at import time under the node environment. Same
// stub the night-summary tests use.
vi.mock('phaser', () => ({ default: { Scene: class {} } }));
import { freshNightState } from '../src/core/meters';
import type { NightState } from '../src/data/types';
import { MAX_HEAT_STRIKES, shouldRecordStrike } from '../src/scenes/door/NightScene';
import {
INSPECTOR_LINES,
INSPECTOR_TUNING,
breachStrikes,
hasLeft,
isWatching,
observe,
revealLine,
startWatch,
type VenueBreaches,
} from '../src/rules/inspector';
const NONE: VenueBreaches = { overCapacity: false, maggotOnFloor: false, underageInside: false };
const ALL: VenueBreaches = { overCapacity: true, maggotOnFloor: true, underageInside: true };
const breach = (over: Partial<VenueBreaches>): VenueBreaches => ({ ...NONE, ...over });
const ADMITTED = 100;
const watch = startWatch('p42', ADMITTED);
const LEAVES = watch.leavesAtMin;
describe('the watch window', () => {
it('runs exactly INSPECTOR_TUNING.watchMin from the minute they were admitted', () => {
expect(watch.patronId).toBe('p42');
expect(watch.admittedAtMin).toBe(ADMITTED);
expect(LEAVES - watch.admittedAtMin).toBe(INSPECTOR_TUNING.watchMin);
});
// Both bounds pinned explicitly: an off-by-one here either hands the player a
// free minute or steals one, and the strike it decides costs the run.
it('is watching ON the admitting minute and NOT on the leaving minute', () => {
expect(isWatching(watch, ADMITTED)).toBe(true);
expect(isWatching(watch, LEAVES - 1)).toBe(true);
expect(isWatching(watch, LEAVES)).toBe(false);
});
it('is not watching before they were admitted, or long after they left', () => {
expect(isWatching(watch, ADMITTED - 1)).toBe(false);
expect(isWatching(watch, LEAVES + 60)).toBe(false);
expect(isWatching(null, ADMITTED)).toBe(false);
});
it('hasLeft flips on the leaving minute and stays true', () => {
expect(hasLeft(watch, LEAVES - 1)).toBe(false);
expect(hasLeft(watch, LEAVES)).toBe(true);
expect(hasLeft(watch, LEAVES + 1)).toBe(true);
expect(hasLeft(null, LEAVES)).toBe(false);
});
});
describe('breach strikes', () => {
it('files nothing when the room is clean', () => {
expect(breachStrikes(NONE)).toEqual([]);
});
it('files one strike per live breach', () => {
expect(breachStrikes(breach({ overCapacity: true }))).toHaveLength(1);
expect(breachStrikes(breach({ maggotOnFloor: true, underageInside: true }))).toHaveLength(2);
expect(breachStrikes(ALL)).toHaveLength(3);
});
it('marks every strike immediate, never deferred to the 3 AM audit', () => {
for (const s of breachStrikes(ALL)) expect(s.deferred).toBe(false);
});
// NightScene dedupes strikes by reason, so these two properties are what stop
// a 30-minute capacity breach filing 30 strikes, or two breaches collapsing
// into one.
it('gives distinct breaches distinct reasons', () => {
const reasons = breachStrikes(ALL).map((s) => s.reason);
expect(new Set(reasons).size).toBe(reasons.length);
for (const r of reasons) expect(r.length).toBeGreaterThan(0);
});
// Counts and distinctness alone let the whole mapping be scrambled silently:
// the room is merely over capacity and the summary accuses you of admitting a
// minor. Each breach is pinned to its own reason by content, not position.
it('names the breach that actually happened', () => {
const only = (over: Partial<VenueBreaches>): string => {
const strikes = breachStrikes(breach(over));
expect(strikes).toHaveLength(1);
return strikes[0]!.reason;
};
expect(only({ overCapacity: true })).toMatch(/capacity/i);
expect(only({ maggotOnFloor: true })).toMatch(/maggot/i);
expect(only({ underageInside: true })).toMatch(/minor|underage/i);
// ...and the multi-breach path keeps each reason attached to its own breach.
expect(breachStrikes(breach({ overCapacity: true, underageInside: true }))).toEqual([
{ reason: only({ overCapacity: true }), deferred: false },
{ reason: only({ underageInside: true }), deferred: false },
]);
});
it('gives the same breach the same reason on consecutive minutes', () => {
const live = breach({ overCapacity: true });
const a = observe(watch, ADMITTED + 5, live).strikes.map((s) => s.reason);
const b = observe(watch, ADMITTED + 6, live).strikes.map((s) => s.reason);
expect(a).toEqual(b);
expect(a).toHaveLength(1);
});
});
describe('observe', () => {
it('is silent while watching a clean room', () => {
expect(observe(watch, ADMITTED, NONE)).toEqual({ strikes: [], watchEnded: false });
});
it('strikes for every breach live while they are inside', () => {
const res = observe(watch, ADMITTED + 10, ALL);
expect(res.strikes).toHaveLength(3);
expect(res.watchEnded).toBe(false);
expect(res.dazzaText).toBeUndefined();
});
it('reveals exactly once, on the leaving minute, and never strikes again', () => {
let reveals = 0;
let strikes = 0;
// Ticked the way NightScene ticks it: one call per whole clock minute,
// breaching the entire time, well past the point the inspector left.
for (let min = ADMITTED - 5; min <= LEAVES + 30; min++) {
const res = observe(watch, min, ALL);
if (res.watchEnded) reveals++;
if (min > LEAVES) {
expect(res.strikes).toEqual([]);
expect(res.dazzaText).toBeUndefined();
}
strikes += res.strikes.length;
}
expect(reveals).toBe(1);
expect(strikes).toBe(3 * INSPECTOR_TUNING.watchMin);
});
it('carries the reveal text on the ending minute only', () => {
const ending = observe(watch, LEAVES, ALL);
expect(ending.watchEnded).toBe(true);
expect(ending.strikes).toEqual([]);
expect(ending.dazzaText ?? '').not.toBe('');
});
it('is inert with no watch at all — a denied inspector never comes back', () => {
for (const min of [0, ADMITTED, LEAVES, 360]) {
expect(observe(null, min, ALL)).toEqual({ strikes: [], watchEnded: false });
}
});
it('is deterministic — same watch, same minute, same breaches, same result', () => {
const again = startWatch('p42', ADMITTED);
for (let min = ADMITTED; min <= LEAVES + 2; min++) {
expect(observe(again, min, ALL)).toEqual(observe(watch, min, ALL));
}
});
});
// observe() returns one strike per breach PER MINUTE — 30 objects for a single
// breach held across the window. Nothing on the bus collapses those: meters.ts
// pushes every heat:strike it hears, and NightScene ends the run on the third.
// The collapse is `shouldRecordStrike`, a guard the emitting site must call. If
// an integrator emits observe()'s strikes raw, a sustained breach pulls the
// licence in three clock minutes. These tests pin the contract end to end.
describe('wiring through shouldRecordStrike (the flood guard)', () => {
/** What NightScene does per emit: guard, then record. Returns strikes landed. */
const runWatch = (breaches: VenueBreaches): NightState => {
const state = freshNightState('royal', 0, 120);
for (let min = ADMITTED; min <= LEAVES + 5; min++) {
for (const s of observe(watch, min, breaches).strikes) {
if (shouldRecordStrike(state, s)) state.heatStrikes.push(s);
}
}
return state;
};
it('collapses a breach held for the whole window to ONE strike', () => {
const raw = observe(watch, ADMITTED, breach({ overCapacity: true })).strikes.length;
expect(raw).toBe(1); // per minute...
expect(runWatch(breach({ overCapacity: true })).heatStrikes).toHaveLength(1); // ...once overall
});
it('files one strike per distinct breach, not one per breach-minute', () => {
expect(runWatch(ALL).heatStrikes).toHaveLength(3);
});
it('never exceeds MAX_HEAT_STRIKES, so a watched breach cannot flood the run', () => {
expect(runWatch(ALL).heatStrikes.length).toBeLessThanOrEqual(MAX_HEAT_STRIKES);
});
it('emits far more raw strikes than land — the guard is load-bearing, not optional', () => {
let raw = 0;
for (let min = ADMITTED; min <= LEAVES + 5; min++) raw += observe(watch, min, ALL).strikes.length;
expect(raw).toBe(3 * INSPECTOR_TUNING.watchMin);
expect(raw).toBeGreaterThan(MAX_HEAT_STRIKES);
});
});
describe('the reveal lines', () => {
it('returns a real line for rolls 0, 0.5 and 1', () => {
for (const roll of [0, 0.5, 1]) {
expect(INSPECTOR_LINES).toContain(revealLine(roll));
expect(revealLine(roll).length).toBeGreaterThan(0);
}
});
it('falls back rather than returning nothing for a junk roll', () => {
for (const roll of [-1, 2, Number.NaN]) {
expect(INSPECTOR_LINES).toContain(revealLine(roll));
}
});
});

View File

@ -446,3 +446,92 @@ describe('judge — stalls and structure', () => {
expect(a).toEqual(b);
});
});
// ---- intoxication & contraband (LANE-CONTENT) --------------------------------
//
// Before this block existed, judge() read neither. Denying someone who could not
// stand up scored exactly like denying a clean punter (+7 aggro), so the game's
// most legible signal — sway, red eyes, an entire sobriety minigame — was worth
// nothing, and the optimal line was to wave the drunk through. These tests pin
// the fix in both directions: refusing a maggot is defensible, and admitting one
// costs you later.
const drunk = (intoxication: number): Patron => makePatron({ intoxication });
describe('judge — intoxication', () => {
it('denying a messy or maggot patron is scored as justified, not arbitrary', () => {
for (const x of [0.7, 0.9]) {
const o = judge(drunk(x), 'deny', ctx());
expect(o.vibeDelta).toBe(T.denyViolatorVibe);
expect(o.aggroDelta).toBe(T.denyViolatorAggro);
}
});
it('denying a merely LOOSE patron stays a judgement call, not a free win', () => {
// 'loose' is deliberately outside the refusable band: a bit merry is the
// player's call and the game does not get to tell them it was right.
const o = judge(drunk(0.5), 'deny', ctx());
expect(o.vibeDelta).toBe(T.denyCleanVibe);
expect(o.aggroDelta).toBe(T.denyCleanAggro);
});
it('a sober patron is unaffected by the drunk branch', () => {
const o = judge(drunk(0.05), 'deny', ctx());
expect(o.vibeDelta).toBe(T.denyCleanVibe);
});
it('admitting anyone already showing tells schedules a ripening hit', () => {
for (const [x, mult] of [[0.5, 1], [0.7, 2], [0.9, 3]] as const) {
const o = judge(drunk(x), 'admit', ctx({ clockMin: 100 }));
const ripen = o.deferred?.find((d) => d.reason.includes('already'));
expect(ripen, `intoxication ${x}`).toBeDefined();
expect(ripen!.vibeDelta).toBe(T.ripenVibePerStage * mult);
expect(ripen!.atClockMin).toBe(100 + T.ripenDelayMin * mult);
expect(ripen!.dazzaText).toBeTruthy();
expect(ripen!.patronId).toBe('p7');
}
});
it('admitting a sober patron schedules no ripening hit', () => {
const o = judge(drunk(0.05), 'admit', ctx());
expect(o.deferred?.some((d) => d.reason.includes('already'))).toBeFalsy();
});
it('the ripening hit never lands after the audit', () => {
const o = judge(drunk(0.9), 'admit', ctx({ clockMin: 355 }));
const ripen = o.deferred?.find((d) => d.reason.includes('already'));
expect(ripen!.atClockMin).toBeLessThanOrEqual(T.auditClockMin);
});
it('admitting a visibly drunk patron is NOT strictly better than denying them', () => {
// The encounters review found `quietBeer` had admit dominating deny on BOTH
// meters with no heat exposure, which made the humane call secretly optimal —
// the exact thing design §4.3 forbids. Deferred cost is what buys it back.
const p = drunk(0.5);
const admit = judge(p, 'admit', ctx());
const deny = judge(p, 'deny', ctx());
const admitDeferredVibe = (admit.deferred ?? []).reduce((s, d) => s + (d.vibeDelta ?? 0), 0);
const admitNet = admit.vibeDelta + admitDeferredVibe;
expect(admitNet).toBeLessThan(deny.vibeDelta);
});
});
describe('judge — contraband', () => {
it('denying someone carrying a severity-3 item is justified', () => {
const o = judge(makePatron({ flags: { contraband: ['baggie'] } }), 'deny', ctx());
expect(o.vibeDelta).toBe(T.denyViolatorVibe);
expect(o.aggroDelta).toBe(T.denyViolatorAggro);
});
it('a kebab or a warm tinnie is not grounds for anything', () => {
for (const item of ['kebab', 'tinnies', 'flask']) {
const o = judge(makePatron({ flags: { contraband: [item] } }), 'deny', ctx());
expect(o.vibeDelta, item).toBe(T.denyCleanVibe);
}
});
it('an unknown contraband id never throws or counts', () => {
const o = judge(makePatron({ flags: { contraband: ['budgieOfTheseus'] } }), 'deny', ctx());
expect(o.vibeDelta).toBe(T.denyCleanVibe);
});
});

271
tests/mix.test.ts Normal file
View File

@ -0,0 +1,271 @@
import { describe, expect, it } from 'vitest';
import {
clampTo,
cloneMix,
DEFAULT_MIX,
formatMix,
MIX_RANGES,
soloedVoice,
voiceGain,
VOICE_NAMES,
type TrackMix,
type VoiceName,
} from '../src/audio/mix';
const mix = (over: Partial<TrackMix> = {}): TrackMix => ({ ...cloneMix(DEFAULT_MIX), ...over });
const enabled = (...on: VoiceName[]): Record<VoiceName, boolean> => ({
kick: on.includes('kick'),
hat: on.includes('hat'),
bass: on.includes('bass'),
pad: on.includes('pad'),
});
describe('DEFAULT_MIX', () => {
// Tripwire. A retune is a deliberate, reviewed diff — if this goes red because
// someone pasted an ear-pass dump, update the literals in the same commit.
it('holds the Phase-1 hardcoded values exactly', () => {
expect(DEFAULT_MIX).toEqual({
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,
});
});
it('ships every voice unmuted and every level inside its range', () => {
for (const v of VOICE_NAMES) {
expect(DEFAULT_MIX.enabled[v]).toBe(true);
expect(DEFAULT_MIX.levels[v]).toBe(clampTo(DEFAULT_MIX.levels[v], MIX_RANGES.level));
}
expect(DEFAULT_MIX.master).toBe(clampTo(DEFAULT_MIX.master, MIX_RANGES.master));
});
});
describe('cloneMix', () => {
it('deep-copies every nested branch', () => {
const src = cloneMix(DEFAULT_MIX);
const copy = cloneMix(src);
expect(copy).toEqual(src);
expect(copy.levels).not.toBe(src.levels);
expect(copy.enabled).not.toBe(src.enabled);
expect(copy.kick).not.toBe(src.kick);
expect(copy.hat).not.toBe(src.hat);
expect(copy.bass).not.toBe(src.bass);
expect(copy.pad).not.toBe(src.pad);
});
it('leaves the source untouched when every branch of the clone is mutated', () => {
const src = cloneMix(DEFAULT_MIX);
const copy = cloneMix(src);
copy.master = 0.1;
copy.levels.kick = 0.01;
copy.levels.hat = 0.02;
copy.levels.bass = 0.03;
copy.levels.pad = 0.04;
copy.enabled.kick = false;
copy.enabled.hat = false;
copy.enabled.bass = false;
copy.enabled.pad = false;
copy.kick.startHz = 999;
copy.hat.hpHz = 999;
copy.bass.rootHz = 999;
copy.pad.lpHz = 999;
copy.doorQ = 99;
copy.floorQ = 99;
expect(src.master).toBe(0.8);
expect(src.levels).toEqual({ kick: 0.9, hat: 0.25, bass: 0.11, pad: 0.05 });
expect(src.enabled).toEqual({ kick: true, hat: true, bass: true, pad: true });
expect(src.kick.startHz).toBe(150);
expect(src.hat.hpHz).toBe(7000);
expect(src.bass.rootHz).toBe(55);
expect(src.pad.lpHz).toBe(600);
expect(src.doorQ).toBe(6);
expect(src.floorQ).toBe(0.7);
});
it('does not let a clone of DEFAULT_MIX mutate the module-level default', () => {
const c = cloneMix(DEFAULT_MIX);
c.levels.kick = 0;
c.enabled.pad = false;
expect(DEFAULT_MIX.levels.kick).toBe(0.9);
expect(DEFAULT_MIX.enabled.pad).toBe(true);
});
});
describe('clampTo', () => {
const range: readonly [number, number] = [0.5, 20];
it('passes values inside the range through unchanged', () => {
expect(clampTo(0.5, range)).toBe(0.5);
expect(clampTo(6, range)).toBe(6);
expect(clampTo(20, range)).toBe(20);
});
it('clamps both ends', () => {
expect(clampTo(-100, range)).toBe(0.5);
expect(clampTo(1e9, range)).toBe(20);
expect(clampTo(20.0001, range)).toBe(20);
expect(clampTo(0.4999, range)).toBe(0.5);
});
// A NaN gain reaching WebAudio silently kills a voice for the rest of the
// night, so a bad input must land on the minimum, never propagate. Infinity
// takes the same branch: non-finite reads as a broken value, not a loud one.
it('maps every non-finite input to the range minimum, not NaN and not the max', () => {
for (const bad of [NaN, Infinity, -Infinity]) {
expect(clampTo(bad, range)).toBe(0.5);
expect(clampTo(bad, MIX_RANGES.master)).toBe(0);
expect(Number.isNaN(clampTo(bad, range))).toBe(false);
}
});
it('keeps every declared range within its own bounds', () => {
for (const [lo, hi] of Object.values(MIX_RANGES)) {
expect(lo).toBeLessThan(hi);
expect(clampTo(lo - 1, [lo, hi])).toBe(lo);
expect(clampTo(hi + 1, [lo, hi])).toBe(hi);
}
});
});
describe('voiceGain', () => {
it('multiplies level by the arc scale', () => {
const m = mix();
expect(voiceGain(m, 'kick', 1)).toBeCloseTo(0.9, 10);
expect(voiceGain(m, 'kick', 0.5)).toBeCloseTo(0.45, 10);
expect(voiceGain(m, 'bass', 0.5)).toBeCloseTo(0.055, 10);
});
it('defaults the arc scale to 1', () => {
expect(voiceGain(mix(), 'hat')).toBe(mix().levels.hat);
});
it('returns 0 for a disabled voice regardless of level or arc scale', () => {
const m = mix({ enabled: enabled('hat'), levels: { kick: 1.5, hat: 0.25, bass: 1, pad: 1 } });
expect(voiceGain(m, 'kick', 1)).toBe(0);
expect(voiceGain(m, 'kick', 10)).toBe(0);
expect(voiceGain(m, 'bass', 1)).toBe(0);
expect(voiceGain(m, 'pad', 0.5)).toBe(0);
expect(voiceGain(m, 'hat', 1)).toBe(0.25);
});
it('yields exactly 0 at a 0 arc scale', () => {
for (const v of VOICE_NAMES) expect(voiceGain(mix(), v, 0)).toBe(0);
});
it('never returns a negative gain', () => {
const m = mix();
for (const v of VOICE_NAMES) {
expect(voiceGain(m, v, -1)).toBe(0);
expect(voiceGain(m, v, -1e6)).toBe(0);
expect(voiceGain(m, v, 2)).toBeGreaterThanOrEqual(0);
}
expect(voiceGain(mix({ levels: { kick: -1, hat: 0, bass: 0, pad: 0 } }), 'kick')).toBe(0);
});
});
describe('soloedVoice', () => {
it('is null when every voice is on', () => {
expect(soloedVoice(mix())).toBeNull();
});
it('is null when no voice is on', () => {
expect(soloedVoice(mix({ enabled: enabled() }))).toBeNull();
});
it('is null with two voices on', () => {
expect(soloedVoice(mix({ enabled: enabled('kick', 'bass') }))).toBeNull();
expect(soloedVoice(mix({ enabled: enabled('hat', 'pad') }))).toBeNull();
});
it('is null with three voices on', () => {
expect(soloedVoice(mix({ enabled: enabled('kick', 'hat', 'bass') }))).toBeNull();
});
it('names the voice when exactly one is on', () => {
for (const v of VOICE_NAMES) {
expect(soloedVoice(mix({ enabled: enabled(v) }))).toBe(v);
}
});
});
describe('formatMix', () => {
// The dump is how a tuning session becomes a diff; parse it back and compare
// field by field so a silently dropped knob goes red.
const parseDump = (s: string): unknown =>
JSON.parse(
s
.replace(/([{,]\s*)([A-Za-z][A-Za-z0-9]*)\s*:/g, '$1"$2":')
.replace(/,(\s*})/g, '$1'),
);
const tuned: TrackMix = {
master: 0.95,
levels: { kick: 1.05, hat: 0.3125, bass: 0.123456789, pad: 0.07 },
enabled: enabled('bass'),
kick: { startHz: 165, endHz: 44, dropS: 0.075, attackS: 0.003, decayS: 0.41 },
hat: { hpHz: 8200, decayS: 0.055 },
bass: { rootHz: 41, filterLoHz: 220, filterHiHz: 1400, q: 11.5, decayS: 0.19, detuneCents: 14 },
pad: { lpHz: 780, bars: 4 },
doorQ: 7.25,
floorQ: 0.85,
};
it('round-trips every numeric field, rounded to 4 decimal places', () => {
expect(parseDump(formatMix(tuned))).toEqual({
master: 0.95,
levels: { kick: 1.05, hat: 0.3125, bass: 0.1235, pad: 0.07 },
enabled: { kick: true, hat: true, bass: true, pad: true },
kick: { startHz: 165, endHz: 44, dropS: 0.075, attackS: 0.003, decayS: 0.41 },
hat: { hpHz: 8200, decayS: 0.055 },
bass: {
rootHz: 41,
filterLoHz: 220,
filterHiHz: 1400,
q: 11.5,
decayS: 0.19,
detuneCents: 14,
},
pad: { lpHz: 780, bars: 4 },
doorQ: 7.25,
floorQ: 0.85,
});
});
it('round-trips DEFAULT_MIX back to itself', () => {
expect(parseDump(formatMix(DEFAULT_MIX))).toEqual(DEFAULT_MIX);
});
it('emits every key of every branch', () => {
const out = parseDump(formatMix(tuned)) as Record<string, Record<string, unknown>>;
expect(Object.keys(out).sort()).toEqual(Object.keys(tuned).sort());
for (const branch of ['levels', 'enabled', 'kick', 'hat', 'bass', 'pad'] as const) {
expect(Object.keys(out[branch] ?? {}).sort()).toEqual(Object.keys(tuned[branch]).sort());
}
});
// Mutes are an ear-pass tool, not a shipped state: the dump deliberately
// un-mutes everything so a solo pass cannot ship as silence.
it('always emits enabled: all true, even from a soloed mix', () => {
expect(soloedVoice(tuned)).toBe('bass');
const out = parseDump(formatMix(tuned)) as { enabled: Record<string, boolean> };
expect(out.enabled).toEqual({ kick: true, hat: true, bass: true, pad: true });
});
it('emits integers without a decimal point', () => {
const dump = formatMix(DEFAULT_MIX);
expect(dump).toContain('startHz: 150');
expect(dump).toContain('hpHz: 7000');
expect(dump).toContain('doorQ: 6');
expect(dump).not.toMatch(/\d\.0000/);
});
});