Compare commits
8 Commits
0f7b948439
...
662060388a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
662060388a | ||
|
|
df70c99dd0 | ||
|
|
e1972c1c07 | ||
|
|
10462ebada | ||
|
|
1a66c602fd | ||
|
|
37f9892309 | ||
|
|
906a366ba8 | ||
|
|
d112bda270 |
@ -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.
|
||||
|
||||
|
||||
335
LANEHANDOVER.md
335
LANEHANDOVER.md
@ -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 18–54 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 1–3.
|
||||
**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 97–98 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. 1–4 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 8–10 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 8–10.
|
||||
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
445
src/audio/Ambience.ts
Normal file
@ -0,0 +1,445 @@
|
||||
// The beds under the night: rain outside, bodies inside, and the kebab shop's
|
||||
// fluoro tube humming to itself while both happen.
|
||||
//
|
||||
// ROUTING. Everything here lands on the engine's sfxBus, never the musicBus.
|
||||
// The music lowpass models "the track heard through a wall" — that is a
|
||||
// statement about the PA being on the other side of a door. Rain is not on the
|
||||
// other side of anything; it is falling on your head, so filtering it with the
|
||||
// music would put the weather indoors with the speakers. What makes rain
|
||||
// door-only is its own gain envelope tracking `audio:location`, not a cutoff.
|
||||
// The crowd bed is the same argument run backwards: it is the room you are
|
||||
// standing in when you are inside, and a wall's worth of muffling belongs on
|
||||
// the PA, not on the people. Location is a mix decision here, not a filter one.
|
||||
|
||||
import type { EventBus } from '../core/EventBus';
|
||||
import { SeededRNG } from '../core/SeededRNG';
|
||||
import type { Sfx } from './Sfx';
|
||||
|
||||
export interface AmbienceOptions {
|
||||
rain?: boolean;
|
||||
crowd?: boolean;
|
||||
kebab?: boolean;
|
||||
}
|
||||
|
||||
type Bed = 'rain' | 'crowd' | 'kebab';
|
||||
type Location = 'door' | 'floor';
|
||||
|
||||
/** Seconds of looped noise behind the crowd bed. Long enough to hide the seam. */
|
||||
const NOISE_SECONDS = 4;
|
||||
|
||||
/** Location crossfade. Sits between the 600/900ms filter sweeps either way, so
|
||||
* stepping through the door reads as one movement rather than two events. */
|
||||
const LOC_FADE_S = 0.7;
|
||||
|
||||
/** Headcount swell. Slow enough to be a swell, fast enough to feel caused. */
|
||||
const COUNT_FADE_S = 0.4;
|
||||
|
||||
/** Enable/disable toggles are a tuning affordance, not a game beat — keep short. */
|
||||
const TOGGLE_FADE_S = 0.25;
|
||||
|
||||
/** Peak crowd gain, at a room so full it cannot get louder. Well under the kick. */
|
||||
const CROWD_PEAK = 0.09;
|
||||
|
||||
/**
|
||||
* Saturation constant for the headcount curve. At k=14 the first dozen people
|
||||
* buy most of the loudness and the hundredth buys almost none — which is how a
|
||||
* room actually behaves, and the opposite of what a linear map does.
|
||||
*/
|
||||
const CROWD_K = 14;
|
||||
|
||||
/** Crowd bed at the door: the room leaking past you, not silence. */
|
||||
const CROWD_DOOR_DUCK = 0.15;
|
||||
|
||||
/** The fluoro tube. Barely there on purpose — it is set dressing, not a voice. */
|
||||
const KEBAB_LEVEL = 0.014;
|
||||
|
||||
export class Ambience {
|
||||
private readonly bus: EventBus;
|
||||
private readonly ctx: AudioContext;
|
||||
private readonly dest: AudioNode;
|
||||
private readonly sfx: Sfx;
|
||||
|
||||
private readonly offs: Array<() => void> = [];
|
||||
|
||||
private readonly enabled: Record<Bed, boolean>;
|
||||
|
||||
private loc: Location = 'door';
|
||||
private running = false;
|
||||
private count = 0;
|
||||
|
||||
private crowd: CrowdBed | null = null;
|
||||
private kebab: KebabBed | null = null;
|
||||
|
||||
/** Built on first start() and kept: restarting shouldn't re-allocate 4s of noise. */
|
||||
private noise: AudioBuffer | null = null;
|
||||
|
||||
constructor(
|
||||
bus: EventBus,
|
||||
ctx: AudioContext,
|
||||
destination: AudioNode,
|
||||
sfx: Sfx,
|
||||
opts: AmbienceOptions = {},
|
||||
) {
|
||||
this.bus = bus;
|
||||
this.ctx = ctx;
|
||||
this.dest = destination;
|
||||
this.sfx = sfx;
|
||||
this.enabled = {
|
||||
rain: opts.rain ?? true,
|
||||
crowd: opts.crowd ?? true,
|
||||
// The kebab shop is the moral centre of the thing (design doc §5). It gets
|
||||
// a sound whether or not anyone is listening for it.
|
||||
kebab: opts.kebab ?? true,
|
||||
};
|
||||
|
||||
this.offs.push(
|
||||
this.bus.on('audio:location', ({ location }) => {
|
||||
if (location === this.loc) return; // door -> door must not restack a fade
|
||||
this.loc = location;
|
||||
this.applyLocation();
|
||||
}),
|
||||
);
|
||||
|
||||
// Headcount is derived, not published: NightState.capacity.inside is in no
|
||||
// event payload and we may not add one. So we count the truth log — an
|
||||
// admission puts a body in the room, an ejection takes one out.
|
||||
//
|
||||
// Deliberately NOT door:clicker. The clicker is the player's *claimed*
|
||||
// count, and the gap between it and reality is a game mechanic. Driving the
|
||||
// crowd bed off it would make the room sound like the lie instead of the
|
||||
// room, and would hand the player a way to hear their own miscount.
|
||||
this.offs.push(
|
||||
this.bus.on('door:verdict', ({ verdict }) => {
|
||||
if (verdict === 'admit') this.setInsideCount(this.count + 1);
|
||||
}),
|
||||
);
|
||||
this.offs.push(
|
||||
this.bus.on('floor:ejection', () => this.setInsideCount(this.count - 1)),
|
||||
);
|
||||
}
|
||||
|
||||
get insideCount(): number {
|
||||
return this.count;
|
||||
}
|
||||
|
||||
/** Lets a demo drive the room without staging a hundred real admissions. */
|
||||
setInsideCount(n: number): void {
|
||||
const next = Math.max(0, Math.floor(n));
|
||||
if (next === this.count) return;
|
||||
this.count = next;
|
||||
this.crowd?.setLevel(this.crowdTarget(), COUNT_FADE_S);
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
|
||||
this.noise ??= noiseBuffer(this.ctx);
|
||||
this.crowd = new CrowdBed(this.ctx, this.dest, this.noise);
|
||||
this.kebab = new KebabBed(this.ctx, this.dest);
|
||||
|
||||
// Snap rather than fade on the first frame: there is no previous location to
|
||||
// cross from, and a 700ms swell out of nothing sounds like a mistake.
|
||||
this.crowd.setLevel(this.crowdTarget(), 0);
|
||||
this.crowd.setLocation(this.crowdDuck(), 0);
|
||||
this.kebab.setLevel(this.kebabTarget(), 0);
|
||||
this.kebab.setLocation(this.kebabDuck(), 0);
|
||||
this.applyRain();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (!this.running) return;
|
||||
this.running = false;
|
||||
|
||||
this.crowd?.stop();
|
||||
this.crowd = null;
|
||||
this.kebab?.stop();
|
||||
this.kebab = null;
|
||||
this.sfx.stopRain();
|
||||
}
|
||||
|
||||
setEnabled(bed: Bed, on: boolean): void {
|
||||
if (this.enabled[bed] === on) return;
|
||||
this.enabled[bed] = on;
|
||||
if (!this.running) return;
|
||||
|
||||
switch (bed) {
|
||||
case 'rain':
|
||||
return this.applyRain();
|
||||
case 'crowd':
|
||||
return this.crowd?.setLevel(this.crowdTarget(), TOGGLE_FADE_S);
|
||||
case 'kebab':
|
||||
return this.kebab?.setLevel(this.kebabTarget(), TOGGLE_FADE_S);
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.stop();
|
||||
for (const off of this.offs) off();
|
||||
this.offs.length = 0;
|
||||
}
|
||||
|
||||
// --- policy ---------------------------------------------------------------
|
||||
|
||||
private applyLocation(): void {
|
||||
this.crowd?.setLocation(this.crowdDuck(), LOC_FADE_S);
|
||||
this.kebab?.setLocation(this.kebabDuck(), LOC_FADE_S);
|
||||
this.applyRain();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rain is delegated to Sfx, which already owns a rain bed wired to this same
|
||||
* bus; a second one would just be the first one twice as loud. Both calls are
|
||||
* idempotent, so repeated door -> door is a no-op there as well as here.
|
||||
*
|
||||
* The `running` guard is what the crowd and kebab beds get for free from their
|
||||
* null refs. Without it a location change after stop() — the subscription
|
||||
* outlives stop(), only destroy() drains it — would start rain under a room
|
||||
* with no crowd and no kebab in it, and nothing would take it back down.
|
||||
*/
|
||||
private applyRain(): void {
|
||||
if (!this.running) return;
|
||||
if (this.enabled.rain && this.loc === 'door') this.sfx.startRain();
|
||||
else this.sfx.stopRain();
|
||||
}
|
||||
|
||||
private crowdTarget(): number {
|
||||
if (!this.enabled.crowd) return 0;
|
||||
return CROWD_PEAK * (1 - Math.exp(-this.count / CROWD_K));
|
||||
}
|
||||
|
||||
private crowdDuck(): number {
|
||||
return this.loc === 'floor' ? 1 : CROWD_DOOR_DUCK;
|
||||
}
|
||||
|
||||
private kebabTarget(): number {
|
||||
return this.enabled.kebab ? KEBAB_LEVEL : 0;
|
||||
}
|
||||
|
||||
private kebabDuck(): number {
|
||||
return this.loc === 'door' ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bodies and talking. A wide bandpass around 500Hz is the whole trick: above
|
||||
* ~1kHz noise turns into hiss and below ~250Hz it turns into traffic, and the
|
||||
* band between them is where a room full of people sits.
|
||||
*/
|
||||
class CrowdBed {
|
||||
private readonly src: AudioBufferSourceNode;
|
||||
private readonly lfos: OscillatorNode[] = [];
|
||||
private readonly chain: AudioNode[] = [];
|
||||
private readonly level: GainNode;
|
||||
private readonly loc: GainNode;
|
||||
private readonly ctx: AudioContext;
|
||||
|
||||
constructor(ctx: AudioContext, dest: AudioNode, buffer: AudioBuffer) {
|
||||
this.ctx = ctx;
|
||||
|
||||
this.src = ctx.createBufferSource();
|
||||
this.src.buffer = buffer;
|
||||
this.src.loop = true;
|
||||
// Slowed a little: it drags the noise's spectral centre down and costs nothing.
|
||||
this.src.playbackRate.value = 0.85;
|
||||
|
||||
const band = ctx.createBiquadFilter();
|
||||
band.type = 'bandpass';
|
||||
band.frequency.value = 500;
|
||||
band.Q.value = 0.75; // ~300-800Hz at -3dB
|
||||
|
||||
// Second pass shaves the fizz the bandpass's gentle skirt lets through.
|
||||
const lp = ctx.createBiquadFilter();
|
||||
lp.type = 'lowpass';
|
||||
lp.frequency.value = 1100;
|
||||
|
||||
// Two slow LFOs on a multiplying node, not on the level itself — swelling a
|
||||
// gain of 0 would push it negative, and an empty room has to be silent.
|
||||
const wobble = ctx.createGain();
|
||||
wobble.gain.value = 1;
|
||||
this.wobbler(wobble, 0.19, 0.12);
|
||||
this.wobbler(wobble, 0.07, 0.07);
|
||||
|
||||
this.level = ctx.createGain();
|
||||
this.level.gain.value = 0;
|
||||
|
||||
this.loc = ctx.createGain();
|
||||
this.loc.gain.value = 0;
|
||||
|
||||
this.chain.push(band, lp, wobble, this.level, this.loc);
|
||||
this.src.connect(band).connect(lp).connect(wobble).connect(this.level).connect(this.loc);
|
||||
this.loc.connect(dest);
|
||||
this.src.start();
|
||||
}
|
||||
|
||||
setLevel(to: number, durS: number): void {
|
||||
ramp(this.ctx, this.level.gain, to, durS);
|
||||
}
|
||||
|
||||
setLocation(to: number, durS: number): void {
|
||||
ramp(this.ctx, this.loc.gain, to, durS);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
stopBed(this.ctx, this.loc, this.src, this.lfos, this.chain);
|
||||
}
|
||||
|
||||
private wobbler(target: GainNode, hz: number, depth: number): void {
|
||||
const lfo = this.ctx.createOscillator();
|
||||
lfo.type = 'sine';
|
||||
lfo.frequency.value = hz;
|
||||
const amount = this.ctx.createGain();
|
||||
amount.gain.value = depth;
|
||||
lfo.connect(amount).connect(target.gain);
|
||||
lfo.start();
|
||||
this.lfos.push(lfo);
|
||||
this.chain.push(amount);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The kebab shop's fluoro tube, heard from the footpath. 100Hz because a tube
|
||||
* buzzes at twice mains frequency; the 50Hz underneath is the ballast doing the
|
||||
* work. Mono and completely dry — it is six metres away through an open
|
||||
* shopfront, and reverb would put it in a cathedral.
|
||||
*/
|
||||
class KebabBed {
|
||||
private readonly oscs: OscillatorNode[] = [];
|
||||
private readonly chain: AudioNode[] = [];
|
||||
private readonly level: GainNode;
|
||||
private readonly loc: GainNode;
|
||||
private readonly ctx: AudioContext;
|
||||
|
||||
constructor(ctx: AudioContext, dest: AudioNode) {
|
||||
this.ctx = ctx;
|
||||
|
||||
const sum = ctx.createGain();
|
||||
sum.gain.value = 1;
|
||||
|
||||
// Fundamental, mains harmonic, and a thin 200Hz that keeps it from reading
|
||||
// as a clean test tone.
|
||||
this.partial(100, 1, sum);
|
||||
this.partial(50, 0.45, sum);
|
||||
this.partial(200, 0.18, sum);
|
||||
|
||||
const lp = ctx.createBiquadFilter();
|
||||
lp.type = 'lowpass';
|
||||
lp.frequency.value = 400;
|
||||
|
||||
// A dying tube breathes. 0.6Hz at 8% is under conscious notice and is the
|
||||
// difference between "hum" and "sine wave someone left running".
|
||||
const flicker = ctx.createGain();
|
||||
flicker.gain.value = 1;
|
||||
const lfo = ctx.createOscillator();
|
||||
lfo.type = 'sine';
|
||||
lfo.frequency.value = 0.6;
|
||||
const depth = ctx.createGain();
|
||||
depth.gain.value = 0.08;
|
||||
lfo.connect(depth).connect(flicker.gain);
|
||||
lfo.start();
|
||||
this.oscs.push(lfo);
|
||||
|
||||
this.level = ctx.createGain();
|
||||
this.level.gain.value = 0;
|
||||
|
||||
this.loc = ctx.createGain();
|
||||
this.loc.gain.value = 0;
|
||||
|
||||
this.chain.push(sum, lp, depth, flicker, this.level, this.loc);
|
||||
sum.connect(lp).connect(flicker).connect(this.level).connect(this.loc);
|
||||
this.loc.connect(dest);
|
||||
}
|
||||
|
||||
setLevel(to: number, durS: number): void {
|
||||
ramp(this.ctx, this.level.gain, to, durS);
|
||||
}
|
||||
|
||||
setLocation(to: number, durS: number): void {
|
||||
ramp(this.ctx, this.loc.gain, to, durS);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
stopBed(this.ctx, this.loc, null, this.oscs, this.chain);
|
||||
}
|
||||
|
||||
private partial(hz: number, gain: number, sum: GainNode): void {
|
||||
const osc = this.ctx.createOscillator();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = hz;
|
||||
const g = this.ctx.createGain();
|
||||
g.gain.value = gain;
|
||||
osc.connect(g).connect(sum);
|
||||
osc.start();
|
||||
this.oscs.push(osc);
|
||||
this.chain.push(g);
|
||||
}
|
||||
}
|
||||
|
||||
// --- plumbing ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Linear throughout, unlike the engine's sweeps: these targets legitimately
|
||||
* reach 0 (empty room, bed disabled) and an exponential ramp to zero throws.
|
||||
* Re-anchoring at the live value first keeps an interrupted fade continuous —
|
||||
* players do walk in and straight back out.
|
||||
*
|
||||
* Read `param.value` BEFORE cancelling, same as `TechnoEngine.ramp()`.
|
||||
* `cancelScheduledValues(now)` removes every event at or after `now`, including
|
||||
* the in-flight ramp's end event, and the spec does not say what the getter
|
||||
* reads afterwards: Blink returns the last rendered value, but a UA computing it
|
||||
* from the surviving timeline returns the ramp's origin. Reading first pins the
|
||||
* mid-ramp value the player is actually hearing, so a crowd bed 350ms into a
|
||||
* 0.15 -> 1 door->floor swell fades from ~0.57 rather than jumping down to 0.15.
|
||||
*
|
||||
* `cancelAndHoldAtTime` holds that value at `now` instead of discarding it,
|
||||
* which is the same thing done properly — but Firefox has never shipped it, so
|
||||
* feature-detect and fall back.
|
||||
*/
|
||||
function ramp(ctx: AudioContext, param: AudioParam, to: number, durS: number): void {
|
||||
const now = ctx.currentTime;
|
||||
const from = param.value;
|
||||
if (typeof param.cancelAndHoldAtTime === 'function') param.cancelAndHoldAtTime(now);
|
||||
else param.cancelScheduledValues(now);
|
||||
param.setValueAtTime(from, now);
|
||||
if (durS <= 0) param.setValueAtTime(to, now);
|
||||
else param.linearRampToValueAtTime(to, now + durS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fade out, then stop and disconnect everything after the fade lands. Cutting a
|
||||
* running noise bed is audible as a click, and leaving the nodes attached means
|
||||
* a six-hour night accumulates every bed it ever started.
|
||||
*/
|
||||
function stopBed(
|
||||
ctx: AudioContext,
|
||||
out: GainNode,
|
||||
src: AudioBufferSourceNode | null,
|
||||
oscs: readonly OscillatorNode[],
|
||||
chain: readonly AudioNode[],
|
||||
): void {
|
||||
const fade = 0.25;
|
||||
const at = ctx.currentTime + fade;
|
||||
ramp(ctx, out.gain, 0, fade);
|
||||
|
||||
const tail = src ?? oscs[0];
|
||||
if (tail) {
|
||||
tail.onended = () => {
|
||||
for (const n of chain) n.disconnect();
|
||||
src?.disconnect();
|
||||
for (const o of oscs) o.disconnect();
|
||||
};
|
||||
}
|
||||
src?.stop(at);
|
||||
for (const o of oscs) o.stop(at);
|
||||
}
|
||||
|
||||
/** Deterministic white noise — house rule forbids Math.random, and a fixed seed
|
||||
* means the room sounds the same on every run. */
|
||||
function noiseBuffer(ctx: AudioContext): AudioBuffer {
|
||||
const len = Math.floor(ctx.sampleRate * NOISE_SECONDS);
|
||||
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
|
||||
const data = buf.getChannelData(0);
|
||||
const rng = new SeededRNG(0x4b3b).stream('crowd-noise');
|
||||
for (let i = 0; i < len; i++) data[i] = rng.next() * 2 - 1;
|
||||
return buf;
|
||||
}
|
||||
@ -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
98
src/audio/arc.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { VOICE_NAMES, type VoiceName } from './mix';
|
||||
|
||||
// The night's shape, as gain scalars per voice.
|
||||
//
|
||||
// A club does not play the same track at 9 PM and at 1 AM, and a room that
|
||||
// sounds identical for six hours is the main reason a long shift reads as flat.
|
||||
// So the mix opens thin — kick and a bit of hat, the sound of a room hoping
|
||||
// people turn up — the bassline arrives once there is a crowd to carry it, the
|
||||
// pads land at peak, and Last Drinks strips it back so the lights coming up
|
||||
// feel earned rather than abrupt.
|
||||
//
|
||||
// Pure keyframe interpolation: no audio types here, so it is testable in node
|
||||
// and reusable by the Phase-4 DJ role, which drives this same surface by hand.
|
||||
|
||||
/** clockMin is minutes since 21:00; the night ends at 360 (3 AM). */
|
||||
export const NIGHT_END_MIN = 360;
|
||||
|
||||
export type ArcScales = Record<VoiceName, number>;
|
||||
|
||||
export interface ArcKeyframe {
|
||||
/** Minutes since 21:00. */
|
||||
at: number;
|
||||
label: string;
|
||||
scales: ArcScales;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyframes, ascending by `at`. Deliberately sparse — the interpolation between
|
||||
* them is what makes the build feel gradual rather than stepped.
|
||||
*/
|
||||
export const ARC: readonly ArcKeyframe[] = [
|
||||
// 9 PM. Doors open to an empty room. Kick and a whisper of hat.
|
||||
{ at: 0, label: 'EMPTY', scales: { kick: 0.8, hat: 0.4, bass: 0, pad: 0 } },
|
||||
// 10 PM. First real queue. The bassline turns up with them.
|
||||
{ at: 60, label: 'FILLING', scales: { kick: 0.95, hat: 0.75, bass: 0.5, pad: 0.15 } },
|
||||
// 11 PM. Room has weight.
|
||||
{ at: 120, label: 'BUILDING', scales: { kick: 1, hat: 0.9, bass: 0.85, pad: 0.5 } },
|
||||
// Midnight through 2 AM. Everything, all of it.
|
||||
{ at: 180, label: 'PEAK', scales: { kick: 1, hat: 1, bass: 1, pad: 1 } },
|
||||
{ at: 300, label: 'PEAK', scales: { kick: 1, hat: 1, bass: 1, pad: 1 } },
|
||||
// 2:30. Last drinks. Pull the low end first — that is what empties a floor.
|
||||
{ at: 330, label: 'LAST DRINKS', scales: { kick: 0.9, hat: 0.7, bass: 0.55, pad: 0.85 } },
|
||||
// 3 AM. Pads over a dying kick while the lights come up.
|
||||
{ at: NIGHT_END_MIN, label: 'LIGHTS UP', scales: { kick: 0.45, hat: 0.2, bass: 0.12, pad: 0.6 } },
|
||||
];
|
||||
|
||||
const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
|
||||
|
||||
/**
|
||||
* Voice scalars at a given clock minute. Clamps outside the night rather than
|
||||
* extrapolating — a paused or overrunning clock should hold the edge, not
|
||||
* wander into negative gain.
|
||||
*/
|
||||
export function arcAt(clockMin: number): ArcScales {
|
||||
const t = Number.isFinite(clockMin) ? clockMin : 0;
|
||||
|
||||
const first = ARC[0];
|
||||
const last = ARC[ARC.length - 1];
|
||||
/* istanbul ignore next -- ARC is a non-empty literal */
|
||||
if (!first || !last) return { kick: 1, hat: 1, bass: 1, pad: 1 };
|
||||
|
||||
if (t <= first.at) return { ...first.scales };
|
||||
if (t >= last.at) return { ...last.scales };
|
||||
|
||||
for (let i = 1; i < ARC.length; i++) {
|
||||
const prev = ARC[i - 1];
|
||||
const next = ARC[i];
|
||||
if (!prev || !next || t > next.at) continue;
|
||||
const span = next.at - prev.at;
|
||||
const k = span <= 0 ? 1 : (t - prev.at) / span;
|
||||
const out = {} as ArcScales;
|
||||
for (const v of VOICE_NAMES) out[v] = lerp(prev.scales[v], next.scales[v], k);
|
||||
return out;
|
||||
}
|
||||
|
||||
/* istanbul ignore next -- the loop always returns for t inside the range */
|
||||
return { ...last.scales };
|
||||
}
|
||||
|
||||
/** Nearest keyframe label at or before this minute — for HUD readouts. */
|
||||
export function arcLabel(clockMin: number): string {
|
||||
const t = Number.isFinite(clockMin) ? clockMin : 0;
|
||||
let label = ARC[0]?.label ?? '';
|
||||
for (const k of ARC) {
|
||||
if (t >= k.at) label = k.label;
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
/**
|
||||
* One number for "how hard is the room going", 0..1 — the mean of the voice
|
||||
* scalars. Handy for anything that wants to follow the night's energy without
|
||||
* caring which voice is doing it (lighting, crowd density, the DJ role's meter).
|
||||
*/
|
||||
export function arcIntensity(clockMin: number): number {
|
||||
const s = arcAt(clockMin);
|
||||
return VOICE_NAMES.reduce((sum, v) => sum + s[v], 0) / VOICE_NAMES.length;
|
||||
}
|
||||
159
src/audio/mix.ts
Normal file
159
src/audio/mix.ts
Normal file
@ -0,0 +1,159 @@
|
||||
// The track's tunable surface, lifted out of TechnoEngine so it can be changed
|
||||
// while the music is playing.
|
||||
//
|
||||
// Three things need this and none of them can use a `const` in a module body:
|
||||
// the ear pass (a human turns a knob and hears it on the next beat), the night
|
||||
// arc (arc.ts scales voice levels as the night builds), and the Phase-4 DJ role,
|
||||
// whose control panel is specified as this exact surface (BUILD_PLAN §4).
|
||||
//
|
||||
// Defaults reproduce the Phase-1 engine's hardcoded values exactly, so lifting
|
||||
// them changed no sound. Anything John retunes in the ear pass gets pasted back
|
||||
// over DEFAULT_MIX via `formatMix()`.
|
||||
|
||||
export type VoiceName = 'kick' | 'hat' | 'bass' | 'pad';
|
||||
|
||||
export const VOICE_NAMES: readonly VoiceName[] = ['kick', 'hat', 'bass', 'pad'];
|
||||
|
||||
export interface KickParams {
|
||||
/** Beater pitch at the transient. */
|
||||
startHz: number;
|
||||
/** Where the drop lands — the body of the cone. */
|
||||
endHz: number;
|
||||
/** Seconds for the pitch drop. Longer reads as a bigger speaker. */
|
||||
dropS: number;
|
||||
attackS: number;
|
||||
decayS: number;
|
||||
}
|
||||
|
||||
export interface HatParams {
|
||||
/** Highpass corner. Lower lets more of the noise body through. */
|
||||
hpHz: number;
|
||||
decayS: number;
|
||||
}
|
||||
|
||||
export interface BassParams {
|
||||
rootHz: number;
|
||||
/** Per-note filter sweep floor and peak — the "wow" on each 16th. */
|
||||
filterLoHz: number;
|
||||
filterHiHz: number;
|
||||
q: number;
|
||||
decayS: number;
|
||||
/** Cents of detune between the two saws. The whole character lives here. */
|
||||
detuneCents: number;
|
||||
}
|
||||
|
||||
export interface PadParams {
|
||||
lpHz: number;
|
||||
/** Length of the fade-in/out cycle, in bars. */
|
||||
bars: number;
|
||||
}
|
||||
|
||||
export interface TrackMix {
|
||||
/** Post-everything output level. */
|
||||
master: number;
|
||||
/** Per-voice peak gains, pre-filter. */
|
||||
levels: Record<VoiceName, number>;
|
||||
/** Ear-pass solo/mute. A muted voice is still scheduled, just silent. */
|
||||
enabled: Record<VoiceName, boolean>;
|
||||
kick: KickParams;
|
||||
hat: HatParams;
|
||||
bass: BassParams;
|
||||
pad: PadParams;
|
||||
/** Filter resonance at each location — the door's peak is the "through a wall" cue. */
|
||||
doorQ: number;
|
||||
floorQ: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_MIX: TrackMix = {
|
||||
master: 0.8,
|
||||
levels: { kick: 0.9, hat: 0.25, bass: 0.11, pad: 0.05 },
|
||||
enabled: { kick: true, hat: true, bass: true, pad: true },
|
||||
kick: { startHz: 150, endHz: 50, dropS: 0.06, attackS: 0.008, decayS: 0.34 },
|
||||
hat: { hpHz: 7000, decayS: 0.04 },
|
||||
bass: { rootHz: 55, filterLoHz: 180, filterHiHz: 900, q: 8, decayS: 0.12, detuneCents: 8 },
|
||||
pad: { lpHz: 600, bars: 8 },
|
||||
doorQ: 6,
|
||||
floorQ: 0.7,
|
||||
};
|
||||
|
||||
/** Bounds every knob, so a slider (or a typo in the console) cannot blow an ear. */
|
||||
export const MIX_RANGES = {
|
||||
master: [0, 1.2],
|
||||
level: [0, 1.5],
|
||||
'kick.startHz': [60, 400],
|
||||
'kick.endHz': [25, 120],
|
||||
'kick.dropS': [0.01, 0.3],
|
||||
'kick.attackS': [0.001, 0.05],
|
||||
'kick.decayS': [0.05, 1],
|
||||
'hat.hpHz': [2000, 14000],
|
||||
'hat.decayS': [0.005, 0.2],
|
||||
'bass.rootHz': [30, 110],
|
||||
'bass.filterLoHz': [60, 600],
|
||||
'bass.filterHiHz': [300, 4000],
|
||||
'bass.q': [0.5, 20],
|
||||
'bass.decayS': [0.02, 0.5],
|
||||
'bass.detuneCents': [0, 40],
|
||||
'pad.lpHz': [200, 4000],
|
||||
'pad.bars': [1, 16],
|
||||
doorQ: [0.5, 20],
|
||||
floorQ: [0.1, 4],
|
||||
} as const satisfies Record<string, readonly [number, number]>;
|
||||
|
||||
export const clampTo = (v: number, range: readonly [number, number]): number =>
|
||||
Number.isFinite(v) ? Math.max(range[0], Math.min(range[1], v)) : range[0];
|
||||
|
||||
/** Deep copy — callers mutate their own mix without touching DEFAULT_MIX. */
|
||||
export function cloneMix(m: TrackMix): TrackMix {
|
||||
return {
|
||||
master: m.master,
|
||||
levels: { ...m.levels },
|
||||
enabled: { ...m.enabled },
|
||||
kick: { ...m.kick },
|
||||
hat: { ...m.hat },
|
||||
bass: { ...m.bass },
|
||||
pad: { ...m.pad },
|
||||
doorQ: m.doorQ,
|
||||
floorQ: m.floorQ,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective gain for a voice: its level, muted by `enabled`, scaled by whatever
|
||||
* the night arc says this voice should be doing right now.
|
||||
*
|
||||
* Kept here rather than in the engine so the ear-pass rig can show the same
|
||||
* number the scheduler will actually use.
|
||||
*/
|
||||
export function voiceGain(mix: TrackMix, voice: VoiceName, arcScale = 1): number {
|
||||
if (!mix.enabled[voice]) return 0;
|
||||
return Math.max(0, mix.levels[voice] * arcScale);
|
||||
}
|
||||
|
||||
/** True when exactly one voice is unmuted — the ear pass's soloed state. */
|
||||
export const soloedVoice = (mix: TrackMix): VoiceName | null => {
|
||||
const on = VOICE_NAMES.filter((v) => mix.enabled[v]);
|
||||
return on.length === 1 ? (on[0] ?? null) : null;
|
||||
};
|
||||
|
||||
const n = (v: number): string => (Number.isInteger(v) ? String(v) : String(Number(v.toFixed(4))));
|
||||
|
||||
/**
|
||||
* Emit a mix as a pasteable `TrackMix` literal.
|
||||
*
|
||||
* The ear pass is a human saying "more click on the kick" and me turning that
|
||||
* into numbers; without a dump button those numbers die with the tab. This is
|
||||
* how a tuning session becomes a diff.
|
||||
*/
|
||||
export function formatMix(m: TrackMix): string {
|
||||
return `{
|
||||
master: ${n(m.master)},
|
||||
levels: { kick: ${n(m.levels.kick)}, hat: ${n(m.levels.hat)}, bass: ${n(m.levels.bass)}, pad: ${n(m.levels.pad)} },
|
||||
enabled: { kick: true, hat: true, bass: true, pad: true },
|
||||
kick: { startHz: ${n(m.kick.startHz)}, endHz: ${n(m.kick.endHz)}, dropS: ${n(m.kick.dropS)}, attackS: ${n(m.kick.attackS)}, decayS: ${n(m.kick.decayS)} },
|
||||
hat: { hpHz: ${n(m.hat.hpHz)}, decayS: ${n(m.hat.decayS)} },
|
||||
bass: { rootHz: ${n(m.bass.rootHz)}, filterLoHz: ${n(m.bass.filterLoHz)}, filterHiHz: ${n(m.bass.filterHiHz)}, q: ${n(m.bass.q)}, decayS: ${n(m.bass.decayS)}, detuneCents: ${n(m.bass.detuneCents)} },
|
||||
pad: { lpHz: ${n(m.pad.lpHz)}, bars: ${n(m.pad.bars)} },
|
||||
doorQ: ${n(m.doorQ)},
|
||||
floorQ: ${n(m.floorQ)},
|
||||
}`;
|
||||
}
|
||||
@ -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',
|
||||
|
||||
83
src/data/strings/encounters.ts
Normal file
83
src/data/strings/encounters.ts
Normal 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.',
|
||||
},
|
||||
};
|
||||
@ -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."',
|
||||
|
||||
13
src/main.ts
13
src/main.ts
@ -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
324
src/rules/encounters.ts
Normal 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
334
src/rules/guestList.ts
Normal file
@ -0,0 +1,334 @@
|
||||
// The guest-list clipboard (design §3.1). A handwritten list, and a queue of
|
||||
// people claiming to be on it.
|
||||
//
|
||||
// The mechanic is an inversion: a name that ALMOST matches is usually legit —
|
||||
// the doorman's handwriting is rubbish and half of them gave a nickname — while
|
||||
// a name that matches the clipboard EXACTLY, said with confidence, is as often
|
||||
// as not lifted off someone who walked in an hour ago.
|
||||
//
|
||||
// Nothing here is ever certain, on purpose. Every match class can be produced by
|
||||
// an honest patron AND by a liar; the numbers below only move the player from a
|
||||
// coin flip to better than chance. Do not "fix" that by making a class a
|
||||
// guaranteed tell — a guaranteed tell turns the clipboard into a lookup table
|
||||
// and there is no decision left to make.
|
||||
|
||||
import type { RngStream } from '../core/SeededRNG';
|
||||
|
||||
export interface GuestListEntry {
|
||||
name: string;
|
||||
plusOnes: number;
|
||||
}
|
||||
|
||||
export interface GuestList {
|
||||
entries: GuestListEntry[];
|
||||
}
|
||||
|
||||
export type NameMatch = 'exact' | 'near' | 'none';
|
||||
|
||||
export interface NameLookup {
|
||||
match: NameMatch;
|
||||
entry?: GuestListEntry;
|
||||
/** Edits to the CLOSEST entry, whatever the class. Infinity on an empty list. */
|
||||
distance: number;
|
||||
}
|
||||
|
||||
export const GUEST_LIST_TUNING = {
|
||||
minEntries: 8,
|
||||
maxEntries: 14,
|
||||
|
||||
// Two, not three. Every near variant this module produces is at most two edits
|
||||
// from its entry (a transposition is the expensive one — plain Levenshtein
|
||||
// charges 2 for it), so 2 is the tightest threshold that still catches every
|
||||
// plausible misreading. Three would only admit corruptions nothing generates,
|
||||
// while making the separation constraint below harder to satisfy.
|
||||
nearThreshold: 2,
|
||||
|
||||
// How far apart two entries on one clipboard must be. It is 2 * nearThreshold,
|
||||
// and the factor of two is load-bearing rather than cautious: a near variant
|
||||
// sits up to nearThreshold from its source, so by the triangle inequality only
|
||||
// a gap of MORE than twice the threshold guarantees that variant is still
|
||||
// outside the threshold of every other entry. At a gap of merely
|
||||
// nearThreshold + 1 a two-edit variant can land one edit from a neighbour, and
|
||||
// lookupName then hands the door UI the wrong row — the player highlights a
|
||||
// stranger and the +1 count they read belongs to someone else.
|
||||
entrySeparation: 4,
|
||||
|
||||
/** Mostly parties of one. The +1 that arrives as +6 is the door's problem. */
|
||||
plusOneWeights: [
|
||||
[0, 62],
|
||||
[1, 24],
|
||||
[2, 9],
|
||||
[3, 5],
|
||||
] as ReadonlyArray<readonly [number, number]>,
|
||||
|
||||
// Claim mix for a patron who really is on the list. They mostly give a name
|
||||
// the clipboard half-agrees with; sometimes it is written down perfectly; and
|
||||
// sometimes the doorman butchered it past recognition and the clipboard is no
|
||||
// help to an honest person at all.
|
||||
genuineExact: 0.3,
|
||||
genuineNear: 0.58,
|
||||
genuineGarbled: 0.12,
|
||||
|
||||
// Claim mix for a liar. Mostly a name that simply is not there. Sometimes they
|
||||
// overheard a real one and got it slightly wrong; sometimes they got it dead
|
||||
// right, which is the entire mechanic.
|
||||
liarExact: 0.06,
|
||||
liarNear: 0.06,
|
||||
liarInvented: 0.88,
|
||||
|
||||
// Share of guest-list CLAIMANTS who are telling the truth, derived from the
|
||||
// spawn weights and chances in data/archetypes.ts (influencers and regulars
|
||||
// are the only archetypes that are ever really on the list; punters, finance
|
||||
// bros and the owner's mate only ever claim). Liars outnumber listers about
|
||||
// five to one, which is why the mixes above are lopsided — they exist to drag
|
||||
// the posteriors back to something a player can read.
|
||||
//
|
||||
// Posteriors at this prior, asserted in tests/guestList.test.ts:
|
||||
// P(genuine | exact) ≈ 0.50 — a coin flip, and it must stay one
|
||||
// P(genuine | near) ≈ 0.66 — leans legit
|
||||
// P(genuine | none) ≈ 0.03 — leans liar, but is not proof
|
||||
// Re-derive all four numbers if archetypes.ts changes its guest-list chances.
|
||||
claimantGenuineRate: 0.168,
|
||||
|
||||
/** Random draws before falling back to a deterministic sweep of the pools. */
|
||||
drawAttempts: 24,
|
||||
/** Corruption rolls before falling back to a guaranteed one-edit variant. */
|
||||
variantAttempts: 12,
|
||||
/** Edits applied to garble a name past recognition. */
|
||||
garbleEdits: 4,
|
||||
/** Chance a near variant is a transposition rather than one or two cheap edits. */
|
||||
transposeChance: 0.3,
|
||||
/** Chance a cheap-edit variant gets a second edit on top. */
|
||||
secondEditChance: 0.35,
|
||||
};
|
||||
|
||||
// Deliberately duplicated from src/patrons/generator.ts, which keeps its pools
|
||||
// module-private and is frozen to this lane. The failure mode of duplication is
|
||||
// the two lists drifting apart, and a guest list drawn from names the crowd
|
||||
// never uses reads as obviously-not-the-crowd — a content bug, not a crash. If
|
||||
// generator.ts ever exports these, delete these copies and import them.
|
||||
const FIRST = ['Shazza', 'Dazza', 'Bazza', 'Kylie', 'Jai', 'Tahlia', 'Lachlan', 'Brooke', 'Cooper', 'Mia', 'Ngaio', 'Con', 'Duc', 'Sofia', 'Marco', 'Aroha', 'Blake', 'Chantelle', 'Rhys', 'Imogen'] as const;
|
||||
const LAST = ['Nguyen', 'Smith', 'Papadopoulos', 'Chen', 'OBrien', 'Kowalski', 'Singh', 'Taufa', 'Romano', 'Petersen', 'Doyle', 'Vella', 'Karim', 'Marsh', 'Duffy'] as const;
|
||||
|
||||
const POOL_SIZE = FIRST.length * LAST.length;
|
||||
|
||||
// Corruptions only ever land on lowercase letters, which in these pools means
|
||||
// every capital survives. A variant is meant to read as a name someone spelled
|
||||
// wrong; touching the initials gives you "oRmano" and " arim", which read as a
|
||||
// broken string instead — the player stops thinking about handwriting and starts
|
||||
// thinking about the program.
|
||||
const EDITABLE = /[a-z]/;
|
||||
|
||||
/** a→e→i→o→u→a. Uppercase is absent for the same reason. */
|
||||
const VOWEL_RING: Record<string, string> = { a: 'e', e: 'i', i: 'o', o: 'u', u: 'a' };
|
||||
|
||||
const normalise = (s: string): string => s.replace(/\s+/g, ' ').trim().toLowerCase();
|
||||
|
||||
export function editDistance(a: string, b: string): number {
|
||||
const s = normalise(a);
|
||||
const t = normalise(b);
|
||||
if (s === t) return 0;
|
||||
if (s.length === 0) return t.length;
|
||||
if (t.length === 0) return s.length;
|
||||
|
||||
// Two rows rather than the full matrix: this runs a few hundred times per
|
||||
// claimant, because every variant is verified against every entry.
|
||||
let prev = Array.from({ length: t.length + 1 }, (_, i) => i);
|
||||
let curr = new Array<number>(t.length + 1).fill(0);
|
||||
|
||||
for (let i = 1; i <= s.length; i++) {
|
||||
curr[0] = i;
|
||||
const sc = s[i - 1];
|
||||
for (let j = 1; j <= t.length; j++) {
|
||||
const cost = sc === t[j - 1] ? 0 : 1;
|
||||
curr[j] = Math.min(prev[j]! + 1, curr[j - 1]! + 1, prev[j - 1]! + cost);
|
||||
}
|
||||
[prev, curr] = [curr, prev];
|
||||
}
|
||||
return prev[t.length]!;
|
||||
}
|
||||
|
||||
export function lookupName(claimed: string, list: GuestList): NameLookup {
|
||||
let closest: GuestListEntry | undefined;
|
||||
let distance = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const entry of list.entries) {
|
||||
const d = editDistance(claimed, entry.name);
|
||||
if (d < distance) {
|
||||
distance = d;
|
||||
closest = entry;
|
||||
if (d === 0) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (closest === undefined) return { match: 'none', distance };
|
||||
|
||||
const match: NameMatch =
|
||||
distance === 0 ? 'exact' : distance <= GUEST_LIST_TUNING.nearThreshold ? 'near' : 'none';
|
||||
|
||||
// A 'none' carries no entry even though one was found. `distance` still
|
||||
// reports how close it got, but handing back the row would invite the door UI
|
||||
// to highlight a name the patron has nothing to do with — and any caller doing
|
||||
// `if (result.entry)` would be admitting strangers.
|
||||
return match === 'none' ? { match, distance } : { match, entry: closest, distance };
|
||||
}
|
||||
|
||||
const poolName = (rng: RngStream): string => `${rng.pick(FIRST)} ${rng.pick(LAST)}`;
|
||||
|
||||
/**
|
||||
* True iff `name` is more than `minDistance` edits from every entry.
|
||||
* At `nearThreshold` that means "reads as a miss"; at `entrySeparation` it means
|
||||
* "safe to add to the clipboard". Infinity on an empty list, so both hold.
|
||||
*/
|
||||
const isSeparated = (name: string, list: GuestList, minDistance: number): boolean =>
|
||||
lookupName(name, list).distance > minDistance;
|
||||
|
||||
// A pool name the clipboard has no opinion about. Random draws first so the mix
|
||||
// stays natural, then an exhaustive sweep so this always terminates: the pools
|
||||
// hold 300 combinations and a list holds at most 14, so the sweep only comes up
|
||||
// empty for a list far longer than this game builds.
|
||||
function drawSeparatedName(
|
||||
rng: RngStream,
|
||||
list: GuestList,
|
||||
minDistance: number,
|
||||
): string | undefined {
|
||||
for (let i = 0; i < GUEST_LIST_TUNING.drawAttempts; i++) {
|
||||
const name = poolName(rng);
|
||||
if (isSeparated(name, list, minDistance)) return name;
|
||||
}
|
||||
const start = rng.int(0, POOL_SIZE - 1);
|
||||
for (let k = 0; k < POOL_SIZE; k++) {
|
||||
const idx = (start + k) % POOL_SIZE;
|
||||
const name = `${FIRST[idx % FIRST.length]!} ${LAST[Math.floor(idx / FIRST.length)]!}`;
|
||||
if (isSeparated(name, list, minDistance)) return name;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function generateGuestList(rng: RngStream, size: number): GuestList {
|
||||
const T = GUEST_LIST_TUNING;
|
||||
const asked = Number.isFinite(size) ? Math.round(size) : T.minEntries;
|
||||
const wanted = Math.max(T.minEntries, Math.min(T.maxEntries, asked));
|
||||
const entries: GuestListEntry[] = [];
|
||||
|
||||
for (let i = 0; i < wanted; i++) {
|
||||
// Entries are kept entrySeparation apart so a near variant can only ever
|
||||
// point at one of them. Two Dazzas a couple of edits apart would make the
|
||||
// clipboard ambiguous in a way the player cannot reason about — and the
|
||||
// pools genuinely contain such pairs (Bazza/Dazza), which is why this is
|
||||
// enforced per list rather than assumed of the pools.
|
||||
const name = drawSeparatedName(rng, { entries }, T.entrySeparation);
|
||||
// A short list is a wobble; a hang is a crash. See drawSeparatedName.
|
||||
if (name === undefined) break;
|
||||
entries.push({ name, plusOnes: rng.weighted(T.plusOneWeights) });
|
||||
}
|
||||
|
||||
return { entries };
|
||||
}
|
||||
|
||||
function editableIndices(s: string): number[] {
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < s.length; i++) if (EDITABLE.test(s[i]!)) out.push(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* One edit: a dropped letter, a doubled letter, or a vowel gone sideways.
|
||||
* The three kinds are not equally likely — a consonant has no vowel-ring entry,
|
||||
* so kind 2 falls back to doubling and consonants double twice as often as they
|
||||
* drop. That is a deliberate lean: a doubled consonant ("Kowalskki") reads as
|
||||
* handwriting, a dropped one ("Kowaski") reads more like a different name.
|
||||
*/
|
||||
function oneEdit(rng: RngStream, s: string): string {
|
||||
const at = editableIndices(s);
|
||||
// Two letters minimum, so a name can never be edited down to nothing.
|
||||
if (at.length < 2) return s;
|
||||
const i = rng.pick(at);
|
||||
const ch = s[i]!;
|
||||
const kind = rng.int(0, 2);
|
||||
if (kind === 0) return s.slice(0, i) + s.slice(i + 1);
|
||||
if (kind === 1) return s.slice(0, i) + ch + s.slice(i);
|
||||
const swapped = VOWEL_RING[ch];
|
||||
return swapped === undefined
|
||||
? s.slice(0, i) + ch + s.slice(i)
|
||||
: s.slice(0, i) + swapped + s.slice(i + 1);
|
||||
}
|
||||
|
||||
/** Two adjacent letters the wrong way round — two edits under plain Levenshtein. */
|
||||
function transpose(rng: RngStream, s: string): string {
|
||||
const at = editableIndices(s).filter((i) => EDITABLE.test(s[i + 1] ?? '') && s[i] !== s[i + 1]);
|
||||
if (at.length === 0) return s;
|
||||
const i = rng.pick(at);
|
||||
return s.slice(0, i) + s[i + 1]! + s[i]! + s.slice(i + 2);
|
||||
}
|
||||
|
||||
function corrupt(rng: RngStream, name: string): string {
|
||||
// A transposition already spends the whole two-edit budget, so it never gets
|
||||
// stacked with anything. Two edits on top of it would be the garbled branch.
|
||||
if (rng.chance(GUEST_LIST_TUNING.transposeChance)) return transpose(rng, name);
|
||||
const once = oneEdit(rng, name);
|
||||
return rng.chance(GUEST_LIST_TUNING.secondEditChance) ? oneEdit(rng, once) : once;
|
||||
}
|
||||
|
||||
/** Doubling a letter is exactly one insertion, always. The safety net. */
|
||||
const doubledLetter = (name: string): string =>
|
||||
name.length > 1 ? `${name.slice(0, 2)}${name.slice(1)}` : `${name}${name}`;
|
||||
|
||||
function nearVariant(rng: RngStream, name: string, list: GuestList): string {
|
||||
for (let i = 0; i < GUEST_LIST_TUNING.variantAttempts; i++) {
|
||||
const candidate = corrupt(rng, name);
|
||||
// Two cheap edits can cancel each other out (a drop and a double of the
|
||||
// same letter), and a transposition on a short name can land somewhere
|
||||
// unexpected. Unverified, roughly 1% of corruptions come back distance 0 —
|
||||
// small, but it lands entirely in 'exact', the rarest and most load-bearing
|
||||
// class. The class is what the caller was promised, so it is verified.
|
||||
if (lookupName(candidate, list).match === 'near') return candidate;
|
||||
}
|
||||
return doubledLetter(name);
|
||||
}
|
||||
|
||||
// The other half of "bad handwriting": the name went down so badly that the
|
||||
// clipboard is no use to someone who is genuinely on it. This is the honest
|
||||
// person the system fails, and the game never tells the player it happened
|
||||
// (§4.3) — from the door it is indistinguishable from a chancer.
|
||||
function garbled(rng: RngStream, name: string, list: GuestList): string {
|
||||
const T = GUEST_LIST_TUNING;
|
||||
for (let i = 0; i < T.variantAttempts; i++) {
|
||||
let candidate = name;
|
||||
for (let e = 0; e < T.garbleEdits; e++) candidate = oneEdit(rng, candidate);
|
||||
// Only "reads as a miss" is required here, not clipboard-entry separation:
|
||||
// this name is never going on the list, it just has to classify as 'none'.
|
||||
// Verified rather than assumed — four edits still land inside the threshold
|
||||
// about a quarter of the time, and an unverified garble would quietly leak
|
||||
// into 'near' and drag the posteriors with it.
|
||||
if (isSeparated(candidate, list, T.nearThreshold)) return candidate;
|
||||
}
|
||||
// Failing that, they gave the name of the mate who actually made the booking.
|
||||
return drawSeparatedName(rng, list, T.nearThreshold) ?? doubledLetter(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* The name a claiming patron gives. `genuine` is the truth — `flags.onGuestList`
|
||||
* at the call site — and is never visible to the player, only its consequences.
|
||||
*/
|
||||
export function assignListedName(rng: RngStream, list: GuestList, genuine: boolean): string {
|
||||
const T = GUEST_LIST_TUNING;
|
||||
// An empty clipboard: nothing to be on and nothing to lift, so everyone reads
|
||||
// as a chancer. Guarded because rng.pick throws on an empty array.
|
||||
if (list.entries.length === 0) {
|
||||
return drawSeparatedName(rng, list, T.nearThreshold) ?? poolName(rng);
|
||||
}
|
||||
|
||||
const entry = rng.pick(list.entries);
|
||||
const roll = rng.next();
|
||||
|
||||
if (genuine) {
|
||||
if (roll < T.genuineExact) return entry.name;
|
||||
if (roll < T.genuineExact + T.genuineNear) return nearVariant(rng, entry.name, list);
|
||||
return garbled(rng, entry.name, list);
|
||||
}
|
||||
|
||||
if (roll < T.liarExact) return entry.name;
|
||||
if (roll < T.liarExact + T.liarNear) return nearVariant(rng, entry.name, list);
|
||||
return drawSeparatedName(rng, list, T.nearThreshold) ?? garbled(rng, entry.name, list);
|
||||
}
|
||||
329
src/rules/incidentReport.ts
Normal file
329
src/rules/incidentReport.ts
Normal 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
156
src/rules/inspector.ts
Normal file
@ -0,0 +1,156 @@
|
||||
// The Liquor Licensing Inspector (design §4.2). Plainclothes: the generator
|
||||
// gives them nothing to look at, judge() scores them exactly like a punter, and
|
||||
// this module never sees the door at all. It only answers one question, once a
|
||||
// minute, while they are inside: is the room breaching right now?
|
||||
//
|
||||
// Everything here is keyed off clock minutes the caller supplies. No rng, no
|
||||
// clock, no bus — the night shell owns all three.
|
||||
|
||||
import type { HeatStrike } from '../data/types';
|
||||
|
||||
export const INSPECTOR_TUNING = {
|
||||
/** Clock-minutes they spend inside after being admitted. */
|
||||
watchMin: 30,
|
||||
/**
|
||||
* The whole mechanic in one field. Breaches normally file a DEFERRED strike
|
||||
* that surfaces at the 3 AM audit, which is late enough to argue with. With
|
||||
* the inspector in the room the same breach lands now.
|
||||
*/
|
||||
strikesDeferred: false,
|
||||
};
|
||||
|
||||
export interface InspectorWatch {
|
||||
patronId: string;
|
||||
admittedAtMin: number;
|
||||
leavesAtMin: number;
|
||||
}
|
||||
|
||||
/** Live venue conditions, sampled by the caller each clock minute. */
|
||||
export interface VenueBreaches {
|
||||
overCapacity: boolean;
|
||||
maggotOnFloor: boolean;
|
||||
underageInside: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dazza's reveal, after they have already left. Deliberately the SAME line
|
||||
* whether the inspector saw a spotless room or filed three strikes — the player
|
||||
* finds out how it went from the heat count, not from Dazza, and never finds out
|
||||
* at all if they denied him.
|
||||
*/
|
||||
export const INSPECTOR_LINES: readonly string[] = [
|
||||
'that boring bloke you let in was the INSPECTOR you muppet',
|
||||
'mate. MATE. the quiet one was licensing. hes walked back out already. im gonna be sick',
|
||||
'so that was the inspector. terry is going to ring me about tonight and im going to tell him it went great',
|
||||
];
|
||||
|
||||
/**
|
||||
* Reason strings are the dedupe key, so they are fixed sentences: no patron ids,
|
||||
* no clock times, no counts. Two different breaches must never collapse into
|
||||
* each other, and the same breach must read identically on every minute it is
|
||||
* live.
|
||||
*
|
||||
* WIRING, AND THIS IS NOT OPTIONAL: stable reasons only collapse a 30-minute
|
||||
* breach to one strike if the emitting site guards with
|
||||
* `shouldRecordStrike(state, strike)` (src/scenes/door/NightScene.ts) before
|
||||
* `bus.emit('heat:strike', ...)`, exactly as the door and deferred-audit paths
|
||||
* already do. Nothing on the bus dedupes — core/meters.ts records every strike
|
||||
* it hears and NightScene pulls the licence on the third. Emit observe()'s
|
||||
* strikes raw and a single sustained breach ends the run in three clock minutes.
|
||||
*/
|
||||
const BREACH_REASONS = {
|
||||
overCapacity: 'inspector saw the room over licensed capacity',
|
||||
maggotOnFloor: 'inspector saw a maggot still on the floor',
|
||||
underageInside: 'inspector saw a minor inside the venue',
|
||||
} as const;
|
||||
|
||||
// Declared order, so a room breaching three ways files its strikes the same way
|
||||
// every time and the night summary reads consistently.
|
||||
const BREACH_ORDER = ['overCapacity', 'maggotOnFloor', 'underageInside'] as const;
|
||||
|
||||
// observe() carries no rng and has to stay pure, so the reveal picks its line off
|
||||
// the one number the watch already holds. Admission minutes vary across a run, so
|
||||
// the player does not hear the same line every night.
|
||||
const rollFor = (watch: InspectorWatch): number =>
|
||||
(Math.abs(Math.trunc(watch.admittedAtMin)) % INSPECTOR_LINES.length) / INSPECTOR_LINES.length;
|
||||
|
||||
// NaN-safe like judge's lapRoll: a junk roll must fall back to a real line
|
||||
// rather than returning undefined and printing an empty text bubble.
|
||||
const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0);
|
||||
|
||||
/** Picks a reveal line from a 0..1 roll. Exported so a caller can use its own rng. */
|
||||
export function revealLine(roll: number): string {
|
||||
const i = Math.min(INSPECTOR_LINES.length - 1, Math.floor(clamp01(roll) * INSPECTOR_LINES.length));
|
||||
return INSPECTOR_LINES[i] ?? INSPECTOR_LINES[0]!;
|
||||
}
|
||||
|
||||
export function startWatch(patronId: string, clockMin: number): InspectorWatch {
|
||||
return {
|
||||
patronId,
|
||||
admittedAtMin: clockMin,
|
||||
leavesAtMin: clockMin + INSPECTOR_TUNING.watchMin,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Half-open [admittedAtMin, leavesAtMin). The minute they leave is theirs, not
|
||||
* yours — a breach that starts as they walk out the door is not something they
|
||||
* saw, and an inclusive bound here would steal a minute the player is entitled to.
|
||||
*/
|
||||
export function isWatching(watch: InspectorWatch | null, clockMin: number): boolean {
|
||||
if (watch === null) return false;
|
||||
return clockMin >= watch.admittedAtMin && clockMin < watch.leavesAtMin;
|
||||
}
|
||||
|
||||
export function hasLeft(watch: InspectorWatch | null, clockMin: number): boolean {
|
||||
return watch !== null && clockMin >= watch.leavesAtMin;
|
||||
}
|
||||
|
||||
export function breachStrikes(breaches: VenueBreaches): HeatStrike[] {
|
||||
return BREACH_ORDER.filter((k) => breaches[k]).map((k) => ({
|
||||
reason: BREACH_REASONS[k],
|
||||
deferred: INSPECTOR_TUNING.strikesDeferred,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Called once per clock minute for as long as a watch exists. Pure: the "has the
|
||||
* reveal already fired" bit is not stored anywhere, it is derived from the
|
||||
* ending minute being a single minute, which is why the caller must tick this at
|
||||
* one call per whole clock minute (GameClock emits exactly that).
|
||||
*
|
||||
* Returns one strike per live breach PER MINUTE — up to 3 * watchMin objects
|
||||
* across a window. That is deliberate (this module has no memory), and it is why
|
||||
* every strike must go out through `shouldRecordStrike`; see BREACH_REASONS.
|
||||
*
|
||||
* Known gap, caller's call: an inspector admitted after clock 330 has
|
||||
* leavesAtMin > 360, GameClock clamps at 360 and stops ticking, so the reveal
|
||||
* never fires and the player never learns who that was. Not clamped here because
|
||||
* clamping would make the window shorter than watchMin. To guarantee the reveal,
|
||||
* call observe(watch, watch.leavesAtMin, breaches) once at last drinks.
|
||||
*/
|
||||
export function observe(
|
||||
watch: InspectorWatch | null,
|
||||
clockMin: number,
|
||||
breaches: VenueBreaches,
|
||||
): { strikes: HeatStrike[]; dazzaText?: string; watchEnded: boolean } {
|
||||
if (isWatching(watch, clockMin)) {
|
||||
return { strikes: breachStrikes(breaches), watchEnded: false };
|
||||
}
|
||||
|
||||
// The one minute the reveal exists on. Before it: not inside yet. After it:
|
||||
// inert forever, because the caller keeps calling with the same stale watch.
|
||||
if (watch !== null && clockMin === watch.leavesAtMin) {
|
||||
return { strikes: [], dazzaText: revealLine(rollFor(watch)), watchEnded: true };
|
||||
}
|
||||
|
||||
return { strikes: [], watchEnded: false };
|
||||
}
|
||||
|
||||
// There is no denial branch, and its absence is the design (§4.3, LANE_CONTENT
|
||||
// deliverable 3). Deny the inspector and NOTHING happens — no strike, no relief,
|
||||
// no Dazza text, no note in the summary. They were a quiet person who wanted a
|
||||
// drink, and the player never learns what they dodged. The paranoia is the tax.
|
||||
// This will read as a missing branch to whoever maintains this next. It isn't.
|
||||
// Do not add a reward for the correct call; the game does not know which call
|
||||
// was correct, and that is the entire point of the mechanic.
|
||||
@ -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
|
||||
|
||||
90
src/scenes/door/ClipboardPanel.ts
Normal file
90
src/scenes/door/ClipboardPanel.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import Phaser from 'phaser';
|
||||
import { lookupName, type GuestList, type NameLookup } from '../../rules/guestList';
|
||||
import { DOOR_PALETTE, MONO, panel, text } from './ui';
|
||||
|
||||
// The guest-list clipboard, sharing the desk's left panel with Dazza's rules via
|
||||
// a two-tab header. There is no spare desk real estate at 640x360, and a tab is
|
||||
// honest about the tradeoff: you cannot read the dress code and the list at the
|
||||
// same time, so checking a name costs you a beat of attention. That is the point.
|
||||
|
||||
export type ClipboardTab = 'rules' | 'list';
|
||||
|
||||
/** Two columns of names; 14 entries is the generator's ceiling. */
|
||||
const ROWS_PER_COL = 7;
|
||||
const ROW_H = 10;
|
||||
|
||||
export class ClipboardPanel {
|
||||
private readonly root: Phaser.GameObjects.Container;
|
||||
private readonly rows: Phaser.GameObjects.Text[] = [];
|
||||
private readonly hint: Phaser.GameObjects.Text;
|
||||
private highlighted = -1;
|
||||
|
||||
constructor(
|
||||
scene: Phaser.Scene,
|
||||
private readonly list: GuestList,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
) {
|
||||
this.root = scene.add.container(0, 0).setDepth(50);
|
||||
this.root.add(panel(scene, x, y, w, h, 0x1d1a14, 0x4a4436));
|
||||
this.list.entries.forEach((entry, i) => {
|
||||
const col = Math.floor(i / ROWS_PER_COL);
|
||||
const row = i % ROWS_PER_COL;
|
||||
const label = entry.plusOnes > 0 ? `${entry.name} +${entry.plusOnes}` : entry.name;
|
||||
const t = scene.add.text(x + 5 + col * (w / 2 - 4), y + 5 + row * ROW_H, label, {
|
||||
fontFamily: MONO,
|
||||
fontSize: '6px',
|
||||
color: '#c8bda0',
|
||||
});
|
||||
this.rows.push(t);
|
||||
this.root.add(t);
|
||||
});
|
||||
|
||||
this.hint = text(scene, x + 5, y + h - 11, '', 6, DOOR_PALETTE.inkDim);
|
||||
this.root.add(this.hint);
|
||||
this.root.setVisible(false);
|
||||
}
|
||||
|
||||
setVisible(on: boolean): void {
|
||||
this.root.setVisible(on);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show what the clipboard says about the name in front of you. It reports the
|
||||
* MATCH, never the truth — an exact hit is the suspicious one (rules/guestList
|
||||
* has the arithmetic), and nothing here tells the player which way to call it.
|
||||
*/
|
||||
check(claimed: string | null): NameLookup | null {
|
||||
this.clearHighlight();
|
||||
if (!claimed) {
|
||||
this.hint.setText('');
|
||||
return null;
|
||||
}
|
||||
const found = lookupName(claimed, this.list);
|
||||
const idx = found.entry ? this.list.entries.indexOf(found.entry) : -1;
|
||||
if (idx >= 0 && found.match !== 'none') {
|
||||
this.rows[idx]?.setColor('#ffe08a');
|
||||
this.highlighted = idx;
|
||||
}
|
||||
this.hint.setText(
|
||||
found.match === 'exact'
|
||||
? `"${claimed}" — written exactly like that`
|
||||
: found.match === 'near'
|
||||
? `"${claimed}" — close to one on the list`
|
||||
: `"${claimed}" — nothing like it here`,
|
||||
);
|
||||
this.hint.setColor(found.match === 'none' ? '#c08080' : '#c8bda0');
|
||||
return found;
|
||||
}
|
||||
|
||||
private clearHighlight(): void {
|
||||
if (this.highlighted >= 0) this.rows[this.highlighted]?.setColor('#c8bda0');
|
||||
this.highlighted = -1;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.root.destroy();
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
215
src/scenes/door/IncidentReportScene.ts
Normal file
215
src/scenes/door/IncidentReportScene.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
124
src/scenes/floor/fight.ts
Normal 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
|
||||
);
|
||||
}
|
||||
200
src/scenes/floor/fightDirector.ts
Normal file
200
src/scenes/floor/fightDirector.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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
724
src/ui/MixDeskScene.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
402
src/ui/Phone.ts
402
src/ui/Phone.ts
@ -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
277
tests/arc.test.ts
Normal 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
225
tests/encounters.test.ts
Normal 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
306
tests/floor/fight.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
290
tests/floor/fightDirector.test.ts
Normal file
290
tests/floor/fightDirector.test.ts
Normal 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
344
tests/guestList.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
323
tests/incidentReport.test.ts
Normal file
323
tests/incidentReport.test.ts
Normal 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 Kayden’s', () => {
|
||||
const other: IncidentRecord = { clockMin: 60, kind: 'deferred', patronId: 'p3', detail: 'x' };
|
||||
expect(findKaydenContradiction([fileOne(other, false)])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the most recent night’s 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
218
tests/inspector.test.ts
Normal 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));
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -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
271
tests/mix.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user