Compare commits

...

9 Commits

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 19:06:31 +10:00
type-two
f5e1151c6e LANE-JUICE: session block + contract change request for door:phoneTheatre
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:53:23 +10:00
type-two
2eb3c94ccd LANE-JUICE: audio engine, SFX set, five UI widgets, JuiceDemoScene
Audio (src/audio/), raw WebAudio, no samples or libraries:
- TechnoEngine: 128 BPM four-on-the-floor kick, off-beat hats, 2-bar 16th
  bassline, 8-bar pad. Look-ahead scheduler (25ms timer, 100ms horizon) is the
  beat authority — emits beat:tick with the SCHEDULED audio time, so consumers
  can compare honestly. Replaces core/StubBeatClock at integration; same event.
  Stale beats are resynced to the next downbeat rather than caught up: a
  throttled/hidden tab otherwise dumps a 64-beat backlog onto one sample.
  Music runs through the location lowpass; SFX bypass it and stay crisp.
- Sfx: 10 procedural one-shots + rain bed, one shared noise buffer, voices
  self-disconnect on ended so a six-hour night doesn't accumulate nodes.

UI (src/ui/), reusable Phaser containers, no door/floor imports:
- Stamp (the signature interaction), Phone (Dazza thread + phoneTheatre),
  DialogueBox (typed-out, drunk-typo render), Clicker (digit roll),
  MeterHud (neon vibe / crowd-temp aggro / hype badge / 3 heat slots).
- JuiceDemoScene exercises every widget and SFX, with a log-mapped cutoff
  slider and a DOOR/FLOOR toggle that routes through the bus.

Verified in-browser: the door->floor sweep ramps 250Hz->18kHz over 600ms,
monotonic and exponential (halfway lands ~2.1kHz, not the ~9kHz a linear ramp
would give) — that late bloom is what sells the door opening. Under a real
hidden tab, 52 consecutive beats scheduled with zero in the past, 75-98ms lead,
max one beat per wake.

main.ts touched to route J / #juice to the demo — outside lane ownership,
flagged for sign-off in LANEHANDOVER.md.

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

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

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

Tests 49 -> 229.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:49:13 +10:00
type-two
c262ac8261 LANE-JUICE: pure foundation — beat scheduling, filter curve, UI style kit, drunk typo renderer
Testable-in-node math extracted ahead of the WebAudio/Phaser layers:
- audio/scheduling.ts: look-ahead beat grid (dueBeats never repeats or skips a
  beat however ragged the timer wakes; burst-capped for suspended tabs)
- audio/filterCurve.ts: the location filter's exponential cutoff ramp — the
  door-opening sweep, in frequency-space so it blooms late like a real door
- ui/style.ts: chunky pixel widget kit sharing the doll PALETTE
- ui/typo.ts: deterministic drunk-typo/typing/wobble renderer (seeded, no
  Math.random)
- ui/juiceEvents.ts: EventMap extensions via declaration merging, so
  door:phoneTheatre types correctly without editing frozen data/types.ts.
  TODO(contract) — CONTRACT CHANGE REQUEST filed in LANEHANDOVER.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:04:33 +10:00
74 changed files with 14514 additions and 8 deletions

View File

@ -1,6 +1,15 @@
{
"version": "0.0.1",
"configurations": [
{ "name": "not-tonight", "runtimeExecutable": "npm", "runtimeArgs": ["run", "dev"], "port": 5199 }
{
"name": "not-tonight",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"dev"
],
"port": 5199,
"autoPort": true
}
]
}
}

2
.gitignore vendored
View File

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

View File

@ -95,3 +95,447 @@ walking under an ID card claiming 18+ — that's the whole game in one image.
DOOR first, JUICE second, FLOOR third.
3. Every session ends with the gate + a SESSION block here + a push. Fable
reviews on request ("review the lanes").
---
### SESSION — LANE-DOOR — 2026-07-19 18:50
**Branch/commits:** lane/door @ 9805ff8..(this commit) — branched from main after Phase 0,
worked in worktree `../not-tonight-door`.
**Done** (all verified by playing it, not just by tests):
- **Full Door-only night is playable start→summary.** `npm run dev` boots straight into
9:00 PM, patrons arrive on a curve, you work the rope, rule on them, Dazza texts land,
meters move, 3:00 AM → summary. Both fail states reachable.
- **Rules engine** (`src/rules/`, pure, no Phaser):
- `dressCode.ts` — 8 rules. `text`/`activeFrom` are READ OUT of `data/strings/dazza.ts`
by ruleId rather than retyped, and the module throws at load if a rule and its
announcing text ever disagree, so the card can't drift from the message.
- `idCheck.ts` — all the ID date maths (`idVerdict`, `idDisplayLines`). Builds on
`ageOn`, does not reimplement it. Catches the 5th tell (expired-but-otherwise-real).
- `judge.ts` — the single scoring function + `JUDGE_TUNING` (every magic number named).
- `sobriety.ts` — challenge content + the drunk-performance model.
- `doorTypes.ts` — this lane's contract EXTENSIONS (see change requests below).
- **Scene layer** (`src/scenes/door/`): `NightScene` (thin night shell — clock, meters,
deferred consequences, fail states, marked `TODO(integration)`), `DoorScene` (tableau +
desk + all input), `QueueManager` + `arrivalCurve` (pure, no Phaser), `PatronUpView`
(×3 doll, 5 hover zones), `IdCardView` (tray thumbnail + half-screen zoom),
`PhoneWidget` (buzz, preview, scrollback thread), `DressCodeCard` (NEW badges),
`SobrietyModal`, `StampFx` (marked `TODO(juice)`), `NightSummaryScene`, `inspect.ts`,
`dazzaSchedule.ts`, `ui.ts`.
- **Tests: 49 → 229** (+180). Every judge branch, every ID boundary (18th birthday
tonight, leap-day DOB, expiry today vs tomorrow), every dress-code predicate, the queue
economy, the Dazza schedule, and the inspection vocabulary.
**How to see it:** `npm run dev` → http://localhost:5199.
Click the rope (or NEXT!) → patron steps up ×3 → **hover their shoes/torso/face** for
tooltips → click the ID in the tray to zoom it → ADMIT or NOT TONIGHT. Stamp someone to
see the signature interaction. Wait ~30 in-game min for Dazza's first rule text.
To fail on purpose: deny everyone (aggro riots by ~10:30 PM), or admit everyone (three
heat strikes, licence gone at 3 AM).
In the Claude preview pane, drive frames per CLAUDE.md "Dev notes" — `document.hidden`
freezes the Phaser loop at frame 0 there. **Drive a whole run inside ONE `javascript_exec`
call**: splitting the frame stepping across calls hands Phaser a multi-second delta and
its tweens/timers land in a state that looks like a game bug but isn't. Cost me an hour.
**Tests:** 229 passing / 0 failing.
**Bugs found by playing it that the test gate could never have caught** (all fixed):
1. Game opened on ~45 real seconds of empty street — the 9 PM arrival rate was honest and
unplayable. Raised the curve, capped the max gap, and the QueueManager now seeds one
patron at construction so somebody is always at the rope on the first frame.
2. `noLogos` fired on 47.6% of patrons, but `dollPlan.ts` only draws a logo for the TOP
slot — ~28% of all patrons were deniable for a logo that was never on screen. Narrowed
to visibly-rendered slots (see CCR-3). Dazza's own text says "no *visible* logos".
3. **One patron could be ruled up to 11 times.** Game state was being mutated inside the
stamp's tween callback; if the tween didn't land, the patron stayed at a rope the
player could keep scoring. All state now settles synchronously in `rule()` and the
tweens only move pixels. This class of bug is why `busy` also has a token-guarded
watchdog.
4. A correctly-played night still rioted at 1:37 AM. Aggro relief only fired on a
completely empty street, which never happens during the slam. Relief now scales with
queue slack — the pressure is "keep it moving", not "never deny".
5. Admit-everything logged **40** heat strikes (one per patron over capacity).
CONTRACTS.md §3 says heatStrikes is "run-scoped, max 3"; now enforced, and a persistent
breach is one write-up rather than one per patron.
6. One stubborn regular reappeared 6 times and ate a third of the night's denials.
Comebacks capped at 2.
7. Dazza sent the identical lap text 5 times in a row. Seven phrasings now, picked by the
same roll that sets the delay.
8. Determinism leak: denying a regular drew from the `arrivals` stream, so one player
decision reshuffled every remaining arrival time. Moved to its own `comebacks` stream
(CONTRACTS.md §6 exists precisely to stop this).
9. ID card modal covered the patron, making photo-vs-face impossible. Moved right.
The genuine-hologram reference swatch now sits on the same cream backing as the card —
a translucent teal reads as a different colour over dark laminate than over card stock,
so the reference was lying.
10. Phone preview text spilled off the screen onto the desk; strike reasons on the summary
ran off the right edge of the canvas. Both clamped.
**Broke / known-wonky:**
- `src/main.ts` is the one file I touched outside my lane — 8 lines, to register the three
door scenes and boot into `Night` instead of `Parade`. Unavoidable for the "npm run dev
→ playable night" deliverable. Boot/Parade are still registered; `game.scene.start('Parade')`
from the console still works. Flagging it explicitly for the merge.
- `src/data/strings/door.ts` is a NEW file in `data/strings/` — CONTRACTS.md §7 says Aussie
strings live there, and it adds no types, but `data/` is nominally frozen-ish. Move it if
you'd rather.
- With placeholder art, `noThongsSinglets` / `noWhiteSneakers` / `noBlazers` are not
distinguishable by silhouette (a thong and a boot are both a 3×4 rect). They ARE
readable via the hover tooltips, which is why I built the inspection surface before the
stamp. It costs the player time, which costs aggro — that tradeoff is fine, arguably
good. It stops being a compromise at the v0.4 art pass (CCR-3).
- Vibe pins at 100 for a competent player by ~midnight, so late-night vibe stops being a
live pressure. Aggro and heat still are. Worth a tuning pass in Phase 2; I did not want
to over-tune against a bot that plays with perfect information and zero deliberation
time. A human is much slower and will not see these numbers.
- `NightScene` renders nothing and `DoorScene` draws the HUD. Deliberate, but it means the
HUD moves to wherever Phase 2 puts the shell.
- No audio (LANE-JUICE). `StubBeatClock` runs and is consumed for nothing yet — no rhythm
flourish landed; the stamp is tween-timed, not beat-timed.
**Contract change requests:**
1. **CCR-1 — `PatronFlags.handHolding?: boolean`.** Needed by `noHandHolders`, which is one
of the 8 already-written Dazza rule texts and otherwise has no data to predicate on.
Implemented as a `declare module` interface augmentation in `src/rules/doorTypes.ts`, so
`src/data/types.ts` is byte-identical to main. QueueManager sets it on couples and
DoorScene draws a linked-hands pixel between them so the rule is visible, not psychic.
Request: fold the field into `types.ts` properly at integration and delete the augmentation.
2. **CCR-2 — `VerdictOutcome.hypeDelta?: number`.** Design §3.1 gives rope theatre a hype
reward but `VerdictOutcome` has no hype channel, so `judge()` cannot express the 'wait'
verdict's payoff. Same augmentation pattern, in `src/rules/judge.ts`. Same request.
3. **CCR-3 — renderer request, for LANE-0/art rather than contracts.** `dollPlan.ts` draws
a logo rect for the TOP slot only, and gives thongs/boots, blazer/bomber, and
sneaker/heel identical geometry. Four dress-code rules are therefore silhouette-invisible
and I narrowed `noLogos` to compensate (`LOGO_VISIBLE_SLOTS` in `dressCode.ts`, one
constant, widen it when the art lands). Requesting per-slot logo marks and distinct
shoe/outer silhouettes in the v0.4 art pass.
**Questions for reviewer:**
1. Is the `declare module` augmentation pattern acceptable as the standing mechanism for
additive contract extensions, or do you want every extension to go through you and land
in `types.ts`? It kept the frozen files clean and I'd use it again, but it is a
precedent and two other lanes will copy it.
2. `src/main.ts` and `src/data/strings/door.ts` — ruling on ownership, please. If either is
off-limits I'll move the strings into `scenes/door/` and hand you a patch for main.ts.
3. Should heat strikes end the night immediately at 3, or accumulate to the summary as
they do now? Design §2 says "3 strikes = licence pulled, run over" — I read that as
run-scoped, so the night still finishes and the summary declares it. Say the word and
it becomes a third fail state.
4. Vibe pinning at 100 (above) — worth retuning now, or leave it until there is a real
playtester rather than a bot?
**Next session should:**
- Take the review findings from the four-lens pass (lane discipline, scene lifecycle,
playability, balance) — I ran it but landed only what I could verify in-session.
- Swap `StampFx` for `ui/Stamp` + SFX once LANE-JUICE lands; hook a beat flourish to
`beat:tick` (currently consumed by nothing).
- Guest-list clipboard and the UV pass-out stamp are specced in design §3.1 and NOT built —
they were the right things to cut to get a full playable night.
- Sobriety line-walk (the third challenge) is still a stretch item; alphabet + riddle ship.
- Phase 2 integration should lift clock/meters/fail-state ownership out of `NightScene`
into the real night-flow machine and replace the temporary summary.
---
### SESSION — LANE-DOOR (review-fix pass) — 2026-07-19 19:05
**Branch/commits:** lane/door @ 30162d3..(this commit)
Ran a four-lens adversarial review over the lane (discipline / scene lifecycle /
playability / balance) and landed the findings I could verify in-session.
**Fixed:**
1. **The game was answering its own question.** `DoorScene` lit up every rule a patron
broke on the dress-code card the moment they stepped up. Optimal play was "read the red
line", which deletes the inspection loop the entire scene exists for — all 8 dress-code
rules were free. The highlight now fires AFTER the ruling, for ~2s, as feedback.
This is the single most important fix in this pass.
2. **Tonight's date appeared nowhere in the game.** The card showed `04/12/2000` and the
HUD showed `11:42 PM`; the player was asked to subtract a birthdate from a date they
were never given. `underage` is the tell that files a heat strike and costs the
licence, and it was pure guesswork. Now stamped on the card beside the DOB and under
the HUD clock. Every boundary case `idCheck.ts` was carefully written for
(`turnsEighteenTonight`, leap-day DOB) is finally reachable by a player.
3. **Determinism, again, worse than the one I already fixed.** In `spawnArrival` the
partner's `generatePatron` call sat behind `if (enqueued)`, and enqueue fails at
`maxQueue` — which is player-controlled. From the first overflow onward every
remaining patron of the night differed. All draws are now unconditional. Same class as
the comeback-stream bug; I fixed it for `couples` and left it in `patrons`.
4. **You could rule straight through the open sobriety modal.** Its backdrop was not
interactive, so ADMIT/NOT TONIGHT stayed live underneath. Now eats clicks.
5. **Tooltips read as broken text**`navy a blazer`, `white a singlet`, `cream a bucket
hat`. Articles were baked into the noun tables and then colour-prefixed. Since the
tooltips are the ONLY detection channel for four rules under placeholder art, the one
system the player has to trust looked broken. Nouns are bare now.
6. `bumbag` and `chain` are drawn on the torso/waist but were described only in the head
zone — hovering the visible item told you about a jacket. Both body zones now name them.
7. `NightScene.endNight` pushed to `state.heatStrikes` directly, bypassing `core/meters.ts`
fifty lines after correctly emitting `heat:strike`. Now emits. CONTRACTS.md §4.
8. Dropped the one `as never` in the diff (`DoorScene`'s `dazza:text` handler) for an
honest type guard — see CCR-4.
9. Test quality: `tests/inspect.test.ts` proved its vocabulary coverage by string-comparing
output against the raw type id, which silently stopped working once a legitimate word
('skirt', 'cap') matched its own id. Replaced with an exact `hasWordFor` check.
**Tests:** 231 passing / 0 failing (+2). Re-verified in-browser after the changes:
competent play still runs to 3:00 AM; deny-everything still riots (aggro 100 @ 10:42 PM);
one patron can still only be ruled once, plus up to 2 regular comebacks.
**New contract change request:**
4. **CCR-4 — `dazza:text` payload should carry the lane's rule type.** `EventMap` types it
`rule?: DressCodeRule`, so a rule this lane emits comes back without the `short` card
label the lane added to it. Currently narrowed with a runtime type guard in
`DoorScene`. Either widen the payload or accept the guard.
**Reviewer findings I did NOT land, with reasons:**
- **The economy is slack and dawdling is dominant.** Measured over 5 seeds: a competent
night pours 7491115 raw vibe into 50 points of headroom (≈94% discarded by the clamp),
vibe pins at 100 by ~10:00 PM and stays there 8186% of the night, and aggro peaks at
5/100 because relief refunds ~10:1. `hype` is a pure ratchet — nothing in `src/` ever
emits a negative hype delta — so stalling is paid twice (vibe multiplier + cash bonus)
and a player who plays *slower* earns 24% more at identical accuracy. Recommended:
`denyViolatorVibe` 6→1, `admitCleanVibe` 3→1, `QUEUE_TUNING.calmPerSec` 0.9→0.25.
I did not apply these: it is a whole-economy retune, the measurements come from a bot
with perfect information and zero deliberation time, and I would rather you tune it
against a human than have me chase a number I can't feel. Flagging it as the top item
for the next pass.
- **The queue never actually backs up.** Player throughput ≈0.83 arrivals/in-game-min
against a curve that peaks at 0.42 — the "10:3012:30 slam" is currently fiction
(measured avg queue 0.10.2, max 35 against 8 visible slots). Fixing it means roughly
doubling the curve to ~170 arrivals a night, which is a big feel change; wants a human
playtest first.
- **`noSongRequesters` (activeFrom 240) and `noHandHolders` (280) are nearly decorative** —
they arrive after 84% and 93% of the night's patrons have been served, and fire ~1.3 and
~0 times respectively. Their `fromMin` values come from `data/strings/dazza.ts`, which
is not mine to edit. Suggest 240→~105 and 280→~135.
- **`noHandHolders` is still unfair even when it fires.** The linked-hands pixel only draws
between adjacent QUEUE sprites; once a patron steps up to the rope there is no partner
drawn and no tooltip mentions it, so at the moment of judgement the tell is invisible.
CCR-1's justification ("rendered as a linked-hands pixel ... so the rule stays one the
player can SEE") overclaims. Either draw the partner at the rope, add it to
`describeZone`, or cut the rule.
- **Intoxication and contraband are worth zero points.** `judge()` reads neither. Denying
a maggot is scored as denying a clean punter (+7 aggro), and a pat-down that finds a
baggie changes nothing. The drunk channel is the most legible signal the game has — sway
tween, bloodshot pixels, a whole sobriety minigame — and it does not score. This is the
biggest *design* gap in the lane and I'd want your ruling on the intended numbers before
inventing them.
- **The clicker is inert.** `clickerShown` is read only for a text colour and one summary
row; `judge` uses the true `insideCount` for capacity. The brief calls the divergence
"the game", so it needs a consequence — presumably the inspector reads the clicker, not
reality, which is Phase-2 inspector work.
- Minor, unlanded: the ID modal's shade dims the desk hologram swatch but not the card's
(the teal/magenta hue gap carries it today, but a subtler fake colour would break);
`peelingLaminate` is cream-on-cream and near-invisible; `photoMismatch` degrades to a 1/6
coin flip for hatted patrons; three player-facing strings are still inline in `judge.ts`
against CONTRACTS.md §7, one of which ("Good story for them one day") reads as narrator
rather than the game's voice.
### SESSION — LANE-FLOOR — 2026-07-19 18:50
**Branch/commits:** lane/floor @ 9805ff8..(this commit) — branched from main, worktree `../not-tonight-floor`
**Done:**
- `FloorDemoScene` (menu key **F** from Parade): 80x45-tile venue (1280x720, 2x screen,
camera-follow), 44-patron crowd sim, flashlight cone, UV mode, all three v0.2
infractions + the stamp check, playable end-to-end.
- Pure logic, all tested: `venueMap` (programmatic layout + anchors + stalls),
`cone` (vision maths), `player` (heavy accel + wall slide), `crowdSim`
(archetype-weighted anchor wandering, clock-driven intoxication drift, beat-driven
dance bob, stall double-occupancy, escort-out), `bangJudge` (nearest-beat-boundary
timing), `escalation` (5x3 cut-off matrix), `patDown` (pocket layout + scoring).
- Phaser layer: `FloorView` (baked tile texture, silhouette/detail split with a
cone GeometryMask, neon glow pools, UV stamp dots) + three overlays
(`CutOffOverlay`, `StallOverlay`, `PatDownOverlay`).
- All floor dialogue in `src/scenes/floor/strings.ts` (see deviation #1).
**How to see it:** `npm run dev` → press **F** at the Parade → WASD patrol, **TAB** UV,
**E** interact, **SPACE** bang, **ESC** cancel, **R** reseed. Walk into the dark until
the torch finds someone. In Claude's preview pane drive frames per CLAUDE.md.
**Tests:** 169 passing / 0 failing (49 pre-existing + 120 new floor tests).
**Verified in-browser, not just green:** cut-off (correct rung ejects, one rung too
soft on a maggot lets them stagger free), escort walks to the exit and logs an
ejection, stall busts on 3 on-beat bangs and *never* busts on 8 off-beat ones,
pat-down bins an item (+2 vibe, right flavour line) and leaks it on timeout (-1.5),
UV shows the stamp glow and marches an unstamped patron out with a Kayden radio line.
**Broke / known-wonky:**
- **Two bugs the test suite could not catch, both found by running it and both fixed:**
1. *Ambient meter collapse.* The bump penalty had a per-agent cooldown but no
sim-wide bound, so leak rate scaled with crowd size — an unattended venue
pinned vibe to 0 and aggro to 100 by ~1AM before the player touched anything.
Added a sim-wide gate (`BUMP_GATE_MS`); an unattended night now drifts
50→27 vibe / 0→34 aggro. Regression test added.
2. *Frozen beat behind modals.* `beat.update()` sat below the overlay early-return
in the scene loop, so opening the stall minigame stopped the beat clock and the
rhythm game could never receive a tick. Clock + beat now run regardless of overlays.
- Dwell times raised (2.5-9s → 7-22s): at the old values ~68% of the crowd was
permanently in transit and the bar/dance floor read as empty.
- Tile palette + darkness alpha retuned; at the original values the venue
multiplied down to near-black and you could not tell a dance floor from a wall.
- Light2D dropped in `FloorView` (documented in-file) — the pipeline swap fights the
cone GeometryMask, and the mask is the actual gameplay. The look is carried by a
darkness sheet + additive cone + neon glow pools instead.
- `GeometryMask` on a container is a WebGL-first path; fine under `Phaser.AUTO`,
would misbehave if anything ever forced the Canvas renderer.
- The 16x16 top-down doll reads as a coloured ring from above (dark hair over
outer-layer shoulders). Correct per Phase 0's `dollPlan`, just noting it.
- Stall interiors are deliberately sealed (`stall` walkable, `stallDoor` not), so
patrons are moved in/out via `StallDef.inside`/`.door` rather than pathed.
**Contract change requests:**
1. **`incident:log` event.** `core` exposes no incident API and `EventMap` is frozen,
so `FloorDemoScene` appends to `NightState.incidents` directly (marked
`// TODO(contract):`). Three lanes writing that array directly is exactly the
shared-mutable-state fight the bus discipline exists to prevent, and Phase 3's
inspector/audit needs a single writer. Requesting
`'incident:log': IncidentRecord` plus a handler in `core/meters.ts`.
2. **Aggro has no decay.** `Meters` only ratchets aggro upward, so it is a one-way
trip to 100 over a long night. Ambient pressure is now bounded (above), but a slow
downward drift on a quiet floor would make the meter a *state* rather than a
countdown. Flagging as a design/contract question, not patching it unilaterally.
**Questions for reviewer:**
1. **Floor strings placement.** CONTRACTS.md §7 says Aussie dialogue lives in
`src/data/strings/`, but `data/` is LANE-0's and lanes commit only in their own
dirs. Parked in `src/scenes/floor/strings.ts`. Move it to
`src/data/strings/floor.ts` on your word — it is a pure file move.
2. **`.gitignore` gap (affects every lane using a worktree).** It lists
`node_modules/` with a trailing slash, which does not match a *symlinked*
`node_modules` — worktrees that symlink it show `?? node_modules` as untracked.
I staged paths explicitly, but a lane running `git add -A` would commit a symlink
pointing at an absolute path on John's machine. One-char fix (drop the slash);
left alone since `.gitignore` is not in my lane.
3. Escalation numbers (clean +4 vibe, scene -3/+5 per rung of overreach) are
deliberately small so aggression never feels rewarded. Confirm the balance intent
before LANE-DOOR mirrors it at the rope.
**Next session should:**
- Phase 2 integration: replace the self-populating spawner with the real admitted-patron
list from LANE-DOOR (the scene already only ever consumes `Patron[]`).
- Swap `StubBeatClock` for LANE-JUICE's `TechnoEngine``StallOverlay` takes
`beatIntervalMs` in its constructor and reads `beat:tick` off the bus, so it should
be a one-line change.
- Fight-brewing infraction (design §3.2) is specced but NOT built — the only v0.2
floor mechanic still missing.
- Replace `HudStub` with LANE-JUICE's real meter HUD.
### SESSION — LANE-JUICE — 2026-07-19 18:55
**Branch/commits:** lane/juice @ c262ac8..2eb3c94
**Done:**
- `audio/TechnoEngine.ts` — synthesized 128 BPM track (kick / off-beat hats /
2-bar 16th bassline / 8-bar pad), look-ahead scheduler (25ms timer, 100ms
horizon) as the beat authority. Emits `beat:tick {beatIndex, audioTimeMs}`
with the **scheduled** time, plus `beat:bar` on downbeats. Drop-in replacement
for `core/StubBeatClock`. Music → lowpass → master; SFX bypass the filter.
- `audio/Sfx.ts` — 10 procedural one-shots + `startRain`/`stopRain`. One shared
noise buffer; voices disconnect on `ended`.
- `audio/scheduling.ts` + `audio/filterCurve.ts` — pure, node-testable beat grid
and cutoff-ramp math (the WebAudio classes are thin wrappers over these).
- `ui/``Stamp`, `Phone`, `DialogueBox`, `Clicker`, `MeterHud`, plus
`style.ts` (chunky widget kit) and `typo.ts` (deterministic drunk typos).
- `ui/JuiceDemoScene.ts` — exercises every widget and SFX; log-mapped cutoff
slider, DOOR/FLOOR toggle routed through the bus, beat pad that flashes when
`ctx.currentTime` reaches the scheduled time (not on event receipt).
**How to see it:** `npm run dev` → http://localhost:5199/#juice (or press **J**;
**P** returns to the parade). Click once to unlock audio, then: drag MUFFLE to
sweep the filter by hand, hit STEP INSIDE for the door→floor sweep, mash the
SOUNDS column, stamp someone with NOT TONIGHT.
**Tests:** 111 passing / 0 failing (was 49 at Phase 0; +62 this lane).
**Verified by ear:** NOTHING. No audio output available in this environment.
Everything below was logic-checked in a real browser via the exposed engine:
- door→floor sweep ramps 250Hz→18kHz over 600ms, monotonic, exponential
(t=0.5 lands ~2.1kHz vs the ~9.1kHz a linear ramp gives).
- under a genuinely hidden/throttled tab: 52 consecutive beats, **zero**
scheduled in the past, 7598ms lead, max 1 beat scheduled per timer wake.
**John/Fable still need to ear-check:** does the kick read as a kick, is the
bassline musical, is `typeTick` quiet enough at the real mix level (it fires per
character), and does the sweep *feel* like a door rather than a filter.
**Broke / known-wonky:**
- `src/main.ts` is edited (2 lines + a ~12-line demo route). **Outside LANE-JUICE
ownership** — see request 2 below. Without it the demo scene is unreachable
and Vite tree-shakes it out entirely.
- `TechnoEngine` has no test file — it needs a real `AudioContext` and the suite
runs in node. Its scheduling/filter math is tested via the pure modules, but
the `resync` arithmetic is only reachable through the class. Extracting
`resyncBeat(grid, nextBeat, nowMs)` into `scheduling.ts` would close that gap.
- `Phone` thread truncates an over-tall newest message rather than scrolling.
- `Stamp` decals are caller-owned now; the demo caps them at 5. The door scene
must destroy them (or pass `parent`) or they accumulate over a night.
**Contract change requests:**
1. **Add `door:phoneTheatre: { durationMs: number }` to `EventMap`.** Emitted by
`ui/Phone` when the player closes the phone, carrying how long they stared at
it while a queue waited — the door scene rewards it with hype. It is a door
event, not an audio one, so it is outside my namespace. **I did not edit
`data/types.ts`.** `ui/juiceEvents.ts` declaration-merges it into `EventMap`
instead: fully typed, zero edits to the frozen file, and folding it into the
real contract later is a copy-paste. Same file also adds `audio:unlocked` and
`beat:bar`, which ARE mine per LANE_JUICE.md but are declared together so the
whole juice surface reads in one place.
2. **Sign-off requested on the `src/main.ts` edit** (scene registration + J/#juice
route, marked `// TODO(integration)`). Happy to revert it if you would rather
wire the demo from the Phase 2 shell yourself.
**Questions for reviewer:**
1. `TechnoEngine.unlock()` anchors beat 0 at `currentTime + 60ms` so beat 0 is as
honest as every other beat. If a consumer assumes beat 0 lands exactly at
`audio:unlocked.atMs`, that is a 60ms discrepancy — is that acceptable, or
should `atMs` carry the anchored origin instead?
2. `MeterHud` counts heat strikes from `heat:strike` events it receives. On a
mid-run scene swap it would restart from 0. Should it take an initial count in
its options, or read `NightState.heatStrikes.length` at construction?
**Next session should:**
- LANE-DOOR: swap the local stamp for `ui/Stamp` (`// TODO(juice)`), and take
`MeterHud` in place of `HudStub`.
- Someone with speakers should ear-check the mix and tell me what to retune.
- Consider extracting `resyncBeat` into `scheduling.ts` so the throttled-tab
resync gets direct test coverage.
---
### REVIEW — Fable — 2026-07-19 (Phase 1 review & integration)
**Reviewed:** lane/door @ e0b5831 (231 tests), lane/floor @ 3eee843 (169),
lane/juice @ f5e1151 (111). All three gates verified green per-branch before merge.
**Merged to main:** yes — door, then floor, then juice; 413 tests green post-merge.
Verified in-browser after integration: Door night boots and plays (called a patron
up, stamped her, meters moved per the judge table), FloorDemo cone/crowd runs (F),
JuiceDemo runs with AUDIO LIVE @ 128 BPM after unlock (J).
**Quality note to all three lanes:** outstanding sessions. The play-found bug
lists (door's 10 + review pass, floor's meter-collapse and frozen-beat catches)
and juice's honest "verified by ear: NOTHING" are exactly what this file is for.
**Contract decisions (all folded into `src/data/types.ts` + CONTRACTS.md §4 by me):**
1. CCR-1 `PatronFlags.handHolding` — ACCEPTED, folded in; doorTypes.ts augmentation removed.
2. CCR-2 `hypeDelta` — ACCEPTED onto `VerdictOutcome` (not just DoorOutcome); judge.ts augmentation removed.
3. CCR-3 (per-slot logos, distinct shoe/outer silhouettes) — ACCEPTED for the
v0.4 art pass; logged. `LOGO_VISIBLE_SLOTS` stays the guard until then.
4. CCR-4 — resolved by adding optional `short?: string` to `DressCodeRule`.
Door's runtime guard may stay or simplify; either is fine.
5. JUICE-1 `door:phoneTheatre` + `audio:unlocked` + `beat:bar` — ACCEPTED, folded in;
`ui/juiceEvents.ts` deleted (its three importers updated).
6. FLOOR-1 `incident:log` — ACCEPTED; `core/meters.ts` is now the single incident
writer and all three direct `incidents.push` sites (floor demo, DoorScene,
NightScene deferred) now emit the event instead.
7. FLOOR-2 aggro decay — deferred to the Phase-2 tuning pass, alongside door's
economy findings (vibe pinning, hype ratchet, queue slack). One tuning pass,
one owner, against a human player.
**Rulings on questions:**
- `declare module` augmentation: it was the right unblocking move, and both lanes
executed it cleanly — but it does NOT become standing practice. Contract
extensions land in `types.ts` at each review, augmentations are deleted on
folding. One source of truth wins over lane autonomy here.
- `src/main.ts`: now integration-owned (reviewer/Phase-2). Lanes request routing
changes via handover. Current dev routes: N night · P parade · F floor · J juice,
same hashes.
- `data/strings/<lane>.ts`: ACCEPTED as the standing convention — new string
files there are lane-owned, no sign-off needed (floor's moved:
`src/data/strings/floor.ts`). `types.ts` stays frozen.
- Heat 3-strikes: keep summary-declared for v0.1; Phase 2's shell makes it an
immediate "licence pulled" cut — logged for integration.
- Vibe-pins-at-100 / economy retune: HELD for a human playtest (John). Door's
measured numbers are the starting point for that session, not auto-applied.
- Juice beat-0 anchor: `audio:unlocked.atMs` should carry the anchored beat-0
origin — implement next juice session.
- MeterHud heat count: read `NightState.heatStrikes.length` at construction
(single source of truth) — implement next juice session.
- Floor escalation balance ("aggression never feels rewarded"): CONFIRMED as
design intent; door should mirror the shape at the rope.
- `.gitignore` slash: fixed.
**Also applied at integration:** dazza fromMin retunes per door's measurements
(noSongRequesters 240→105, noHandHolders 280→135; two pinned-order tests updated),
floor strings moved to `data/strings/floor.ts`, vite port now respects `$PORT`
(preview autoPort), 413 tests + lint + build green on main.
**Next:** Phase 2 integration is OPEN — one lane, `lanes/` file to follow. Big
rocks: night-flow state machine (door⇄floor interleave + radio pulls), TechnoEngine
replacing StubBeatClock in the night, ui/Stamp + MeterHud replacing door's local
versions, save/progression wiring, then the tuning pass with a human.

View File

@ -120,9 +120,18 @@ export interface EventMap {
'meters:delta': { vibe?: number; aggro?: number; hype?: number };
'meters:changed': { vibe: number; aggro: number; hype: number };
'heat:strike': HeatStrike;
// folded in at the Phase-1 review (2026-07-19):
'incident:log': IncidentRecord; // single writer: core/meters.ts appends to NightState.incidents
'door:phoneTheatre': { durationMs: number };
'audio:unlocked': { atMs: number };
'beat:bar': { barIndex: number; beatIndex: number; audioTimeMs: number };
}
```
Phase-1 review also added: `PatronFlags.handHolding?` (CCR-1),
`VerdictOutcome.hypeDelta?` (CCR-2), `DressCodeRule.short?` (CCR-4).
`src/data/types.ts` remains the source of truth where this doc lags.
**Bus discipline:** scenes emit `meters:delta`; only `core/meters.ts` mutates state
and emits `meters:changed`. HUD listens to `meters:changed` only. This keeps three
lanes from fighting over the same numbers.

455
src/audio/Sfx.ts Normal file
View File

@ -0,0 +1,455 @@
// Procedural one-shots. Every sound is synthesized from noise + oscillators —
// no samples ship with the game, so the whole SFX set costs zero bytes of
// download and every parameter is tweakable from here.
//
// These connect to TechnoEngine's sfxBus, which bypasses the location lowpass.
// That is deliberate: the music muffles when you are at the door, but the stamp
// under your own hand does not. The contrast is the joke.
export type SfxName =
| 'stampSlam'
| 'stampInk'
| 'clickerClunk'
| 'doorBang'
| 'radioStatic'
| 'radioChirp'
| 'phoneBuzz'
| 'ropeUnhook'
| 'denyCrowdOoh'
| 'typeTick';
/** Seconds of white noise generated once and shared by every noise voice. */
const NOISE_SECONDS = 2;
/**
* Read heads into the shared noise buffer. Successive hits start at different
* offsets so a run of stamps does not sound like the same recording twice
* a deterministic stand-in for the Math.random the house rules forbid.
*/
const NOISE_OFFSETS = [0, 0.31, 0.77, 0.13, 0.94, 0.52, 1.19, 0.68] as const;
/** Chain jingle: link strike times (s) and gains. Uneven on purpose. */
const ROPE_PINGS = [
{ at: 0, gain: 0.3, hz: 5200 },
{ at: 0.026, gain: 0.19, hz: 6400 },
{ at: 0.058, gain: 0.27, hz: 4600 },
{ at: 0.081, gain: 0.14, hz: 7100 },
{ at: 0.117, gain: 0.21, hz: 5800 },
{ at: 0.149, gain: 0.11, hz: 6900 },
] as const;
/** Gain steps across the static burst — flat noise reads as tape hiss, not radio. */
const STATIC_CRACKLE = [0.34, 0.12, 0.41, 0.2, 0.5, 0.16, 0.29, 0.38, 0.09, 0.22] as const;
/** WebAudio refuses an exponential ramp to zero; this is close enough to silence. */
const SILENT = 0.0001;
export class Sfx {
private readonly ctx: AudioContext;
private readonly dest: AudioNode;
private readonly noiseBuffer: AudioBuffer;
/** Sources still playing — destroy() has to be able to cut them mid-flight. */
private readonly live = new Set<AudioScheduledSourceNode>();
private offsetCursor = 0;
private rain: { src: AudioBufferSourceNode; lfo: OscillatorNode; gain: GainNode } | null = null;
constructor(ctx: AudioContext, destination: AudioNode) {
this.ctx = ctx;
this.dest = destination;
this.noiseBuffer = makeNoise(ctx);
}
play(name: SfxName): void {
switch (name) {
case 'stampSlam':
return this.stampSlam();
case 'stampInk':
return this.stampInk();
case 'clickerClunk':
return this.clickerClunk();
case 'doorBang':
return this.doorBang();
case 'radioStatic':
return this.radioStatic();
case 'radioChirp':
return this.radioChirp();
case 'phoneBuzz':
return this.phoneBuzz();
case 'ropeUnhook':
return this.ropeUnhook();
case 'denyCrowdOoh':
return this.denyCrowdOoh();
case 'typeTick':
return this.typeTick();
}
}
/**
* The signature sound. Two layers that must land on the same sample: a body
* thump you feel and a paper crack you hear. Drop the crack and it turns into
* a kick drum; drop the thump and the stamp weighs nothing.
*/
stampSlam(): void {
const t = this.ctx.currentTime;
const thump = this.ctx.createOscillator();
thump.type = 'sine';
thump.frequency.setValueAtTime(170, t);
thump.frequency.exponentialRampToValueAtTime(52, t + 0.09);
this.voice(thump, [this.env(t, 0.001, 0.01, 0.26, 0.95)], t, 0.3);
// Bandpassed rather than open noise: a wide burst sounds like a cymbal, a
// narrow one at 2.6k sounds like a page taking a hit.
const snap = this.noiseSource();
this.voice(
snap,
[this.band(2600, 1.1), this.env(t, 0.0006, 0.004, 0.07, 0.55)],
t,
0.09,
);
// A hair of top end, delayed a touch, is the wooden handle bottoming out.
const knock = this.noiseSource();
this.voice(
knock,
[this.band(620, 2.4), this.env(t + 0.008, 0.001, 0.004, 0.05, 0.3)],
t + 0.008,
0.06,
);
}
/** Damp, not percussive — the soft attack is what stops it reading as a click. */
stampInk(): void {
const t = this.ctx.currentTime;
const lp = this.ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.Q.value = 6;
lp.frequency.setValueAtTime(2000, t);
lp.frequency.exponentialRampToValueAtTime(210, t + 0.13);
const src = this.noiseSource();
src.playbackRate.value = 0.7;
this.voice(src, [lp, this.env(t, 0.018, 0.02, 0.16, 0.32)], t, 0.22);
}
/** Two clicks; the ear hears the pair as one mechanism, not two events. */
clickerClunk(): void {
const t = this.ctx.currentTime;
this.click(t, 2100, 0.5);
this.click(t + 0.035, 1720, 0.28);
}
/**
* The toilet rhythm game cues off this through a closed door and a techno
* mix, so it is the loudest thing in the set by a clear margin.
*/
doorBang(): void {
const t = this.ctx.currentTime;
const thud = this.ctx.createOscillator();
thud.type = 'sine';
thud.frequency.setValueAtTime(120, t);
thud.frequency.exponentialRampToValueAtTime(36, t + 0.14);
this.voice(thud, [this.env(t, 0.001, 0.02, 0.4, 1.1)], t, 0.46);
const slap = this.noiseSource();
this.voice(slap, [this.band(880, 0.8), this.env(t, 0.001, 0.01, 0.13, 0.7)], t, 0.16);
// High-Q noise band == a struck panel resonating. Cheaper than modelling it.
const ring = this.noiseSource();
this.voice(ring, [this.band(370, 18), this.env(t, 0.002, 0.03, 0.42, 0.5)], t, 0.5);
}
radioStatic(): void {
const t = this.ctx.currentTime;
const dur = 0.35;
const g = this.ctx.createGain();
g.gain.setValueAtTime(SILENT, t);
// Stepped, not ramped: hard jumps between levels are what crackle sounds like.
for (let i = 0; i < STATIC_CRACKLE.length; i++) {
const level = STATIC_CRACKLE[i] ?? 0.2;
g.gain.setValueAtTime(level * 0.6, t + (i / STATIC_CRACKLE.length) * dur);
}
g.gain.setValueAtTime(0.18, t + dur * 0.94);
g.gain.exponentialRampToValueAtTime(SILENT, t + dur);
const src = this.noiseSource();
this.voice(src, [this.band(1800, 0.9), g], t, dur);
}
/** The squelch tail you get when someone lets go of the PTT. */
radioChirp(): void {
const t = this.ctx.currentTime;
const tone = this.ctx.createOscillator();
tone.type = 'sawtooth';
tone.frequency.setValueAtTime(2400, t);
tone.frequency.exponentialRampToValueAtTime(680, t + 0.075);
this.voice(tone, [this.band(1600, 1.6), this.env(t, 0.002, 0.02, 0.07, 0.22)], t, 0.11);
const tick = this.noiseSource();
this.voice(
tick,
[this.band(3200, 1.2), this.env(t + 0.078, 0.001, 0.004, 0.03, 0.2)],
t + 0.078,
0.04,
);
}
/**
* Phone face-down on the bar. The 28Hz amplitude modulation is the whole
* effect an unmodulated 55Hz tone is a bass note, the same tone chopped at
* 28Hz is a plastic case rattling on laminate.
*/
phoneBuzz(): void {
const t = this.ctx.currentTime;
this.buzzBurst(t, 0.15);
this.buzzBurst(t + 0.24, 0.15);
}
ropeUnhook(): void {
const t = this.ctx.currentTime;
for (const ping of ROPE_PINGS) {
const at = t + ping.at;
const hp = this.ctx.createBiquadFilter();
hp.type = 'highpass';
hp.frequency.value = ping.hz;
const src = this.noiseSource();
this.voice(src, [hp, this.env(at, 0.001, 0.003, 0.05, ping.gain)], at, 0.06);
}
}
/**
* Six people at the rope who just watched someone get knocked back. Two
* bandpasses sweeping upward together fake the vowel moving "oo" -> "ooh";
* the slow swell keeps it a murmur instead of a boo.
*/
denyCrowdOoh(): void {
const t = this.ctx.currentTime;
const dur = 0.62;
const swell = this.ctx.createGain();
swell.gain.setValueAtTime(SILENT, t);
swell.gain.linearRampToValueAtTime(0.3, t + 0.24);
swell.gain.setValueAtTime(0.3, t + 0.34);
swell.gain.exponentialRampToValueAtTime(SILENT, t + dur);
const formant = (base: number, top: number, level: number): AudioNode[] => {
const f = this.ctx.createBiquadFilter();
f.type = 'bandpass';
f.Q.value = 5;
f.frequency.setValueAtTime(base, t);
f.frequency.linearRampToValueAtTime(top, t + 0.5);
const trim = this.ctx.createGain();
trim.gain.value = level;
return [f, trim];
};
const low = this.noiseSource();
low.playbackRate.value = 0.5;
// The lower formant owns the shared swell node, so it is disconnected with
// the rest of that chain on `ended` — both branches stop at the same time.
this.voice(low, [...formant(360, 560, 1), swell], t, dur);
const high = this.noiseSource();
high.playbackRate.value = 0.5;
this.voiceInto(high, formant(860, 1180, 0.45), swell, t, dur);
}
/**
* Fires once per typed character, so restraint is the entire spec. Anything
* louder or longer than this becomes unbearable by the third line of dialogue.
*/
typeTick(): void {
const t = this.ctx.currentTime;
const src = this.noiseSource();
this.voice(src, [this.band(3400, 2.2), this.env(t, 0.0005, 0.002, 0.012, 0.045)], t, 0.016);
}
/** Idempotent — the door scene may call this on every re-entry. */
startRain(): void {
if (this.rain) return;
const t = this.ctx.currentTime;
const src = this.ctx.createBufferSource();
src.buffer = this.noiseBuffer;
src.loop = true;
const lp = this.ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 3200;
const gain = this.ctx.createGain();
gain.gain.setValueAtTime(SILENT, t);
gain.gain.linearRampToValueAtTime(0.1, t + 1.2);
// A slow tremolo stops the bed reading as a static hiss the ear tunes out.
const lfo = this.ctx.createOscillator();
lfo.type = 'sine';
lfo.frequency.value = 0.13;
const lfoDepth = this.ctx.createGain();
lfoDepth.gain.value = 0.028;
lfo.connect(lfoDepth).connect(gain.gain);
src.connect(lp).connect(gain).connect(this.dest);
src.start(t);
lfo.start(t);
this.rain = { src, lfo, gain };
}
/** Fades rather than cuts — a hard stop on a noise bed is audible as a click. */
stopRain(): void {
const rain = this.rain;
if (!rain) return;
this.rain = null;
const t = this.ctx.currentTime;
const fade = 0.9;
rain.gain.gain.cancelScheduledValues(t);
rain.gain.gain.setValueAtTime(Math.max(rain.gain.gain.value, SILENT), t);
rain.gain.gain.exponentialRampToValueAtTime(SILENT, t + fade);
rain.src.onended = () => {
rain.src.disconnect();
rain.gain.disconnect();
};
rain.src.stop(t + fade);
rain.lfo.stop(t + fade);
rain.lfo.onended = () => rain.lfo.disconnect();
}
destroy(): void {
this.stopRain();
for (const src of this.live) {
try {
src.stop();
} catch {
// Already stopped or never started — nothing to unwind.
}
src.disconnect();
}
this.live.clear();
}
// --- voices -------------------------------------------------------------
/** One filtered click. Shared by the clicker's two halves. */
private click(at: number, hz: number, level: number): void {
const src = this.noiseSource();
this.voice(src, [this.band(hz, 12), this.env(at, 0.0008, 0.002, 0.028, level)], at, 0.04);
}
private buzzBurst(at: number, dur: number): void {
const carrier = this.ctx.createOscillator();
carrier.type = 'triangle';
carrier.frequency.value = 55;
// Modulator swings the ring gain around 0.5, so the carrier is chopped to
// near-silence and back 28 times a second.
const ring = this.ctx.createGain();
ring.gain.value = 0.5;
const mod = this.ctx.createOscillator();
mod.type = 'square';
mod.frequency.value = 28;
const modDepth = this.ctx.createGain();
modDepth.gain.value = 0.5;
mod.connect(modDepth).connect(ring.gain);
mod.start(at);
mod.stop(at + dur);
mod.onended = () => {
mod.disconnect();
modDepth.disconnect();
};
this.voice(carrier, [ring, this.env(at, 0.006, dur - 0.03, 0.02, 0.4)], at, dur);
}
// --- plumbing -----------------------------------------------------------
private noiseSource(): AudioBufferSourceNode {
const src = this.ctx.createBufferSource();
src.buffer = this.noiseBuffer;
return src;
}
private band(hz: number, q: number): BiquadFilterNode {
const f = this.ctx.createBiquadFilter();
f.type = 'bandpass';
f.frequency.value = hz;
f.Q.value = q;
return f;
}
/** Attack/hold/decay gain envelope, already scheduled against audio time. */
private env(at: number, attack: number, hold: number, decay: number, peak: number): GainNode {
const g = this.ctx.createGain();
g.gain.setValueAtTime(SILENT, at);
g.gain.linearRampToValueAtTime(peak, at + attack);
g.gain.setValueAtTime(peak, at + attack + hold);
g.gain.exponentialRampToValueAtTime(SILENT, at + attack + hold + decay);
return g;
}
private voice(
src: AudioScheduledSourceNode,
chain: readonly AudioNode[],
at: number,
dur: number,
): void {
this.voiceInto(src, chain, this.dest, at, dur);
}
/**
* Wires src -> chain -> out, plays it, and disconnects the whole chain on
* `ended`. Without this a long night leaks a few hundred dead nodes.
*/
private voiceInto(
src: AudioScheduledSourceNode,
chain: readonly AudioNode[],
out: AudioNode,
at: number,
dur: number,
): void {
let node: AudioNode = src;
for (const n of chain) node = node.connect(n);
node.connect(out);
this.live.add(src);
src.onended = () => {
this.live.delete(src);
src.disconnect();
for (const n of chain) n.disconnect();
};
if (src instanceof AudioBufferSourceNode) src.start(at, this.nextOffset(), dur);
else src.start(at);
src.stop(at + dur);
}
private nextOffset(): number {
const offset = NOISE_OFFSETS[this.offsetCursor % NOISE_OFFSETS.length] ?? 0;
this.offsetCursor++;
return offset;
}
}
/**
* White noise from a fixed-seed LCG. Deterministic by house rule, and one
* buffer for the whole night regenerating per hit would allocate megabytes.
*/
function makeNoise(ctx: AudioContext): AudioBuffer {
const length = Math.floor(ctx.sampleRate * NOISE_SECONDS);
const buffer = ctx.createBuffer(1, length, ctx.sampleRate);
const data = buffer.getChannelData(0);
let seed = 0x2f6e2b1;
for (let i = 0; i < length; i++) {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
data[i] = (seed / 0xffffffff) * 2 - 1;
}
return buffer;
}

459
src/audio/TechnoEngine.ts Normal file
View File

@ -0,0 +1,459 @@
import type { EventBus } from '../core/EventBus';
import { SeededRNG } from '../core/SeededRNG';
import {
type AudioLocation,
cutoffFor,
gainFor,
sweepMsFor,
} from './filterCurve';
import {
type BeatGrid,
barOf,
beatIntervalMs,
beatTimeMs,
dueBeats,
isDownbeat,
patternStep,
subdivisions,
} from './scheduling';
const DEFAULT_BPM = 128;
const DEFAULT_LOOKAHEAD_MS = 100;
const TIMER_MS = 25;
/**
* Beat 0 sits this far past the resume instant. Scheduling into the past is not
* an error the node just fires immediately but then the beat *sounds* later
* than the `audioTimeMs` we promised for it. The toilet rhythm game scores
* against that number, so the first beat has to be as honest as the rest.
*/
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;
/**
* 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.
*/
const BASS_PATTERN: readonly (number | null)[] = [
null, null, 0, 0,
null, 0, null, 0,
null, null, 3, 3,
null, 0, null, 12,
null, null, 0, 0,
null, 0, null, 0,
null, null, 5, 3,
null, 0, 2, null,
];
/** A minor, voiced wide and low. Detuning the pair is the whole character. */
const PAD_VOICES: ReadonlyArray<{ hz: number; detune: number; type: OscillatorType }> = [
{ hz: 110, detune: -7, type: 'sawtooth' },
{ hz: 110, detune: 7, type: 'sawtooth' },
{ hz: 164.81, detune: 0, type: 'triangle' },
];
export interface TechnoEngineOptions {
bpm?: number;
lookaheadMs?: number;
}
/**
* The looping techno track and the beat authority in one. Replaces
* core/StubBeatClock same `beat:tick` contract, but the beats now come off the
* AudioContext clock instead of a frame counter, so they are actually true.
*
* Everything is synthesized; there are no samples to load and nothing to
* preload. All scheduling happens on a 25ms timer looking `lookaheadMs` into the
* future. That timer is throttled on a blurred tab exactly as rAF is, so the
* look-ahead alone does not save us; what does is `resync()`, which drops the
* span that elapsed while we were asleep instead of dumping it into the graph.
*/
export class TechnoEngine {
readonly ctx: AudioContext;
readonly master: GainNode;
readonly musicBus: GainNode;
readonly sfxBus: GainNode;
readonly bpm: number;
private readonly filter: BiquadFilterNode;
private readonly noiseBuffer: AudioBuffer;
private readonly lookaheadMs: number;
private readonly grid: BeatGrid;
private readonly unsubs: Array<() => void> = [];
/**
* Long-lived handles so stop() can silence what is already on the schedule.
* Keyed by source, valued by the rest of that voice's chain, because stop()
* pre-empts `onended` and would otherwise strand those nodes on the bus.
*/
private readonly voices = new Map<AudioScheduledSourceNode, AudioNode[]>();
private timer: ReturnType<typeof setInterval> | null = null;
private nextBeat = 0;
private loc: AudioLocation = 'door';
private override: number | null = null;
private unlockedFlag = false;
private unlocking: Promise<void> | null = null;
constructor(
private readonly bus: EventBus,
opts: TechnoEngineOptions = {},
) {
this.bpm = opts.bpm ?? DEFAULT_BPM;
this.lookaheadMs = opts.lookaheadMs ?? DEFAULT_LOOKAHEAD_MS;
this.ctx = new AudioContext();
this.grid = { bpm: this.bpm, originMs: 0 };
this.master = this.ctx.createGain();
this.master.gain.value = MASTER_GAIN;
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.connect(this.master);
this.musicBus = this.ctx.createGain();
this.musicBus.gain.value = gainFor(this.loc);
this.musicBus.connect(this.filter);
// Deliberately NOT through the filter: a stamp must sound like a stamp even
// when the music is buried behind a door.
this.sfxBus = this.ctx.createGain();
this.sfxBus.connect(this.master);
this.noiseBuffer = this.buildNoise();
this.unsubs.push(
this.bus.on('audio:location', ({ location }) => this.setLocation(location)),
);
}
get running(): boolean {
return this.timer !== null;
}
get unlocked(): boolean {
return this.unlockedFlag;
}
get cutoffHz(): number {
return this.filter.frequency.value;
}
get location(): AudioLocation {
return this.loc;
}
/**
* Resume after a real user gesture. Idempotent, and safe to call concurrently
* overlapping callers await the same resume rather than racing the origin.
*/
async unlock(): Promise<void> {
if (this.unlockedFlag) return;
if (this.unlocking) return this.unlocking;
this.unlocking = (async () => {
if (this.ctx.state === 'suspended') await this.ctx.resume();
const atMs = this.ctx.currentTime * 1000;
// Re-origin the grid: beat 0 is now, not whenever this object was built.
this.grid.originMs = atMs + START_LEAD_MS;
this.nextBeat = 0;
this.unlockedFlag = true;
this.bus.emit('audio:unlocked', { atMs });
})();
try {
await this.unlocking;
} finally {
this.unlocking = null;
}
}
/**
* Start the scheduler. Starting before `unlock()` is legal but silent: the
* timer spins and emits nothing until the gesture lands. Callers therefore
* never have to sequence start/unlock.
*
* Restarting after a stop needs no re-anchoring here: nothing is scheduled
* outside `tick()`, and its first wake resyncs past the gap.
*/
start(): void {
if (this.timer !== null) return;
this.timer = setInterval(() => this.tick(), TIMER_MS);
}
stop(): void {
if (this.timer !== null) {
clearInterval(this.timer);
this.timer = null;
}
for (const [src, chain] of [...this.voices]) {
src.onended = null;
try {
src.stop();
} catch {
/* already finished — nothing to silence */
}
this.release(src, chain);
}
this.voices.clear();
}
/** Ramp to the location's cutoff/gain. A pinned override wins over both. */
setLocation(location: AudioLocation, immediate = false): void {
this.loc = location;
if (this.override !== null) return;
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);
}
/** Non-null pins the cutoff and suppresses location sweeps; null hands it back. */
setCutoffOverride(hz: number | null): void {
this.override = hz;
if (hz === null) {
this.setLocation(this.loc);
return;
}
this.ramp(this.filter.frequency, this.clampHz(hz), 0, true);
}
destroy(): void {
this.stop();
for (const off of this.unsubs) off();
this.unsubs.length = 0;
void this.ctx.close().catch(() => undefined);
}
// --- scheduling -----------------------------------------------------------
private tick(): void {
if (!this.unlockedFlag) return;
const nowMs = this.ctx.currentTime * 1000;
this.resync(nowMs);
const due = dueBeats(this.grid, this.nextBeat, nowMs, this.lookaheadMs);
if (due.length === 0) return;
for (const { beatIndex, timeMs } of due) {
this.scheduleBeat(beatIndex, timeMs);
// The SCHEDULED time, not the current one. Consumers time their input
// against this, so a convenient lie here is a broken rhythm game.
this.bus.emit('beat:tick', { beatIndex, audioTimeMs: timeMs });
if (isDownbeat(beatIndex)) {
this.bus.emit('beat:bar', { barIndex: barOf(beatIndex), beatIndex, audioTimeMs: timeMs });
}
}
const last = due[due.length - 1];
if (last) this.nextBeat = last.beatIndex + 1;
}
/**
* Skip a stale span rather than catch up on it. A throttled timer (hidden tab)
* or a long stop/start gap leaves the cursor minutes behind the audio clock;
* playing those beats would start every voice on the same sample a clipped
* blast, not music and `beat:tick` would hand the rhythm game timestamps it
* could only score as misses. The grace is one beat: ordinary wakeup jitter
* must not trigger a jump.
*
* Landing on a downbeat rather than the nearest beat keeps bar phase, so the
* resync reads as a cut instead of the track lurching sideways.
*/
private resync(nowMs: number): void {
const interval = beatIntervalMs(this.bpm);
if (beatTimeMs(this.grid, this.nextBeat) >= nowMs - interval) return;
const firstFuture = Math.ceil((nowMs - this.grid.originMs) / interval);
this.nextBeat = Math.ceil(firstFuture / 4) * 4;
}
private scheduleBeat(beatIndex: number, timeMs: number): void {
const sixteenths = subdivisions(this.grid, beatIndex, 4);
this.kick(timeMs / 1000);
const offbeat = sixteenths[2]; // the 8th between beats
if (offbeat !== undefined) this.hat(offbeat / 1000);
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));
}
if (isDownbeat(beatIndex) && barOf(beatIndex) % PAD_BARS === 0) this.pad(timeMs / 1000);
}
// --- voices ---------------------------------------------------------------
private kick(at: number): void {
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);
gain.gain.setValueAtTime(SILENT, at);
gain.gain.exponentialRampToValueAtTime(KICK_PEAK, at + 0.008);
gain.gain.exponentialRampToValueAtTime(SILENT, at + 0.34);
osc.connect(gain).connect(this.musicBus);
osc.start(at);
osc.stop(at + 0.36);
this.hold(osc, [gain]);
}
private hat(at: number): void {
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;
const gain = this.ctx.createGain();
gain.gain.setValueAtTime(HAT_PEAK, at);
gain.gain.exponentialRampToValueAtTime(SILENT, at + 0.04);
src.connect(hp).connect(gain).connect(this.musicBus);
src.start(at, offset, 0.06);
this.hold(src, [hp, gain]);
}
private bass(at: number, hz: number): void {
const lp = this.ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.Q.value = 8;
// 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);
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);
lp.connect(gain).connect(this.musicBus);
for (const detune of [-8, 8]) {
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] : []);
}
}
private pad(at: number): void {
const cycle = (PAD_BARS * 4 * beatIntervalMs(this.bpm)) / 1000;
const lp = this.ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 600;
const gain = this.ctx.createGain();
gain.gain.setValueAtTime(0, at);
gain.gain.linearRampToValueAtTime(PAD_PEAK, at + cycle * 0.4);
gain.gain.linearRampToValueAtTime(0, at + cycle);
lp.connect(gain).connect(this.musicBus);
PAD_VOICES.forEach((v, i) => {
const osc = this.ctx.createOscillator();
osc.type = v.type;
osc.frequency.value = v.hz;
osc.detune.value = v.detune;
osc.connect(lp);
osc.start(at);
osc.stop(at + cycle);
this.hold(osc, i === PAD_VOICES.length - 1 ? [lp, gain] : []);
});
}
// --- plumbing -------------------------------------------------------------
/**
* 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.
*/
private buildNoise(): AudioBuffer {
const len = Math.floor(this.ctx.sampleRate);
const buf = this.ctx.createBuffer(1, len, this.ctx.sampleRate);
const data = buf.getChannelData(0);
const rng = new SeededRNG(0x1a7).stream('hat-noise');
for (let i = 0; i < len; i++) data[i] = rng.next() * 2 - 1;
return buf;
}
/** Keep the node reachable for stop(); let it free itself if it runs out. */
private hold(src: AudioScheduledSourceNode, chain: AudioNode[]): void {
this.voices.set(src, chain);
src.onended = () => this.release(src, chain);
}
private release(src: AudioScheduledSourceNode, chain: AudioNode[]): void {
this.voices.delete(src);
src.disconnect();
for (const n of chain) n.disconnect();
}
/**
* Re-anchor at the live value before ramping so a sweep interrupted mid-flight
* (door -> floor -> door in two seconds, which players absolutely do) continues
* from where it actually is instead of snapping.
*
* Exponential ramps trace exactly `cutoffAt()`'s curve v0*(v1/v0)^t so we
* get cutoffCurve()'s shape without setValueCurveAtTime's rule that a new curve
* may not overlap a running one. That overlap throws, and re-entrant location
* changes are the normal case here, not the edge case.
*/
private ramp(param: AudioParam, to: number, durS: number, exponential: boolean): void {
const now = this.ctx.currentTime;
const from = param.value;
param.cancelScheduledValues(now);
param.setValueAtTime(from, now);
if (durS <= 0) {
param.setValueAtTime(to, now);
} else if (exponential && from > 0 && to > 0) {
param.exponentialRampToValueAtTime(to, now + durS);
} else {
param.linearRampToValueAtTime(to, now + durS);
}
}
private clampHz(hz: number): number {
return Math.max(20, Math.min(hz, this.ctx.sampleRate / 2));
}
}

66
src/audio/filterCurve.ts Normal file
View File

@ -0,0 +1,66 @@
// Pure math for the location filter — the game's signature audio trick.
//
// Music runs through one master lowpass. At the door you hear the kick through a
// brick wall; step inside and the filter opens. The sweep must feel like a door
// swinging, not a fader move, so it ramps exponentially in frequency (which is
// how ears hear pitch) rather than linearly in Hz.
export type AudioLocation = 'door' | 'floor';
/** Muffled-through-the-wall. Low enough that hats vanish and only kick survives. */
export const DOOR_CUTOFF_HZ = 250;
/** Effectively open — above this the filter is inaudible. */
export const FLOOR_CUTOFF_HZ = 18_000;
/** Music dips slightly outside; inside it is full. */
export const DOOR_GAIN = 0.62;
export const FLOOR_GAIN = 1;
/** Door opening is a fast reveal; door closing behind you is slower and sadder. */
export const OPEN_SWEEP_MS = 600;
export const CLOSE_SWEEP_MS = 900;
export const cutoffFor = (location: AudioLocation): number =>
location === 'floor' ? FLOOR_CUTOFF_HZ : DOOR_CUTOFF_HZ;
export const gainFor = (location: AudioLocation): number =>
location === 'floor' ? FLOOR_GAIN : DOOR_GAIN;
/** Opening (door -> floor) is the fast one. */
export const sweepMsFor = (to: AudioLocation): number =>
to === 'floor' ? OPEN_SWEEP_MS : CLOSE_SWEEP_MS;
/**
* Cutoff at normalised progress t (0..1) of a sweep, interpolated exponentially.
* t is clamped, so callers can hand it raw elapsed/duration.
*
* Exponential in frequency == linear in perceived pitch: at t=0.5 of a
* 250Hz->18kHz sweep you are at ~2.1kHz, not the ~9kHz a linear ramp would give.
* That late-blooming brightness is what sells "the door just opened".
*/
export function cutoffAt(t: number, fromHz: number, toHz: number): number {
const clamped = Math.max(0, Math.min(1, t));
return fromHz * Math.pow(toHz / fromHz, clamped);
}
/** Master gain at progress t — linear is fine, the ear tracks the filter here. */
export function gainAt(t: number, from: number, to: number): number {
const clamped = Math.max(0, Math.min(1, t));
return from + (to - from) * clamped;
}
/**
* Sampled ramp points for `setValueCurveAtTime`. WebAudio's
* exponentialRampToValueAtTime would do the job, but sampling lets us assert the
* curve's shape in tests and keeps door/floor behaviour identical everywhere.
*/
export function cutoffCurve(fromHz: number, toHz: number, points = 32): Float32Array {
// A degenerate count must still yield a usable curve: a NaN reaching
// setValueCurveAtTime throws, and a NaN assigned to AudioParam.value kills the
// filter silently, which would read as "the music stopped" with nothing logged.
const n = Number.isFinite(points) ? Math.max(1, Math.floor(points)) : 1;
const span = Math.max(1, n - 1);
const out = new Float32Array(n);
for (let i = 0; i < n; i++) out[i] = cutoffAt(i / span, fromHz, toHz);
return out;
}

69
src/audio/scheduling.ts Normal file
View File

@ -0,0 +1,69 @@
// Pure look-ahead scheduling math for TechnoEngine.
//
// The engine runs a 25ms timer; on each wake it asks this module which beats fall
// inside the next `lookaheadMs` of audio time and schedules oscillators for them.
// Keeping the arithmetic here means the beat grid is unit-testable without an
// AudioContext (tests run in node — there is no WebAudio there).
export interface BeatGrid {
bpm: number;
/** AudioContext time (ms) at which beat 0 sounds. */
originMs: number;
}
export interface ScheduledBeat {
beatIndex: number;
/** Absolute AudioContext time (ms) the beat sounds at. */
timeMs: number;
}
export const beatIntervalMs = (bpm: number): number => 60_000 / bpm;
/** Absolute audio time of a given beat index. */
export const beatTimeMs = (grid: BeatGrid, beatIndex: number): number =>
grid.originMs + beatIndex * beatIntervalMs(grid.bpm);
/**
* Every beat with index >= fromBeat whose time falls at or before
* `nowMs + lookaheadMs`. Returned in ascending order; empty when nothing is due.
*
* The engine advances its own cursor past the last returned index, so a beat is
* never scheduled twice however ragged the timer wakeups are.
*/
export function dueBeats(
grid: BeatGrid,
fromBeat: number,
nowMs: number,
lookaheadMs: number,
): ScheduledBeat[] {
const horizon = nowMs + lookaheadMs;
const out: ScheduledBeat[] = [];
// Guard against a pathological horizon (tab suspended for minutes) producing a
// scheduling storm — we cap the burst and let the cursor catch up next wake.
const MAX_BURST = 64;
for (let i = fromBeat; out.length < MAX_BURST; i++) {
const timeMs = beatTimeMs(grid, i);
if (timeMs > horizon) break;
out.push({ beatIndex: i, timeMs });
}
return out;
}
/** 16th-note subdivision times within a beat, for hats and the bassline. */
export function subdivisions(grid: BeatGrid, beatIndex: number, n: number): number[] {
const step = beatIntervalMs(grid.bpm) / n;
const base = beatTimeMs(grid, beatIndex);
return Array.from({ length: n }, (_, i) => base + i * step);
}
/** Bar/beat position of a beat index in 4/4. */
export const barOf = (beatIndex: number): number => Math.floor(beatIndex / 4);
export const beatInBar = (beatIndex: number): number => ((beatIndex % 4) + 4) % 4;
export const isDownbeat = (beatIndex: number): boolean => beatInBar(beatIndex) === 0;
/**
* Where a 16th-note step sits in the 2-bar (32-step) bassline pattern.
* Exposed so tests can assert the pattern wraps rather than drifting.
*/
export const patternStep = (beatIndex: number, sixteenth: number, steps = 32): number =>
(((beatIndex * 4 + sixteenth) % steps) + steps) % steps;

View File

@ -36,6 +36,9 @@ export class Meters {
bus.on('night:phaseChange', ({ location }) => {
this.state.location = location;
}),
bus.on('incident:log', (incident) => {
this.state.incidents.push(incident);
}),
bus.on('door:clicker', ({ direction }) => {
this.state.capacity.clickerShown = Math.max(
0,

View File

@ -16,11 +16,11 @@ export const DAZZA_TEXTS: readonly DazzaLine[] = [
{ id: 'rule-basics', text: 'mate its 930 no thongs no singlets we are not a beer garden', ruleId: 'noThongsSinglets', fromMin: 30 },
{ id: 'rule-logos', text: 'seeing too many logos in there. no visible logos from NOW', ruleId: 'noLogos', fromMin: 90 },
{ id: 'rule-sneakers', text: 'white sneakers are OVER. unless vintage. u know the difference', ruleId: 'noWhiteSneakers', fromMin: 150 },
{ id: 'rule-songRequest', text: 'if they look like theyd request a song they dont come in. u know the look', ruleId: 'noSongRequesters', fromMin: 240 },
{ id: 'rule-songRequest', text: 'if they look like theyd request a song they dont come in. u know the look', ruleId: 'noSongRequesters', fromMin: 105 },
{ id: 'rule-bucketHats', text: 'bucket hats r making the room feel like a festival toilet queue. gone', ruleId: 'noBucketHats', fromMin: 180 },
{ id: 'rule-sunnies', text: 'anyone wearing sunnies inside at 11pm is a cop or a flog. neither gets in', ruleId: 'noSunnies', fromMin: 120 },
{ id: 'rule-suits', text: 'too corporate in here tonight. no blazers til the suits ive already got leave', ruleId: 'noBlazers', fromMin: 200 },
{ id: 'rule-couples', text: 'no more couples holding hands in the queue its making the room feel married', ruleId: 'noHandHolders', fromMin: 280 },
{ id: 'rule-couples', text: 'no more couples holding hands in the queue its making the room feel married', ruleId: 'noHandHolders', fromMin: 135 },
// vibe commentary
{ id: 'vibe-mid', text: 'why is the floor MID rn. fix it', fromMin: 60 },

136
src/data/strings/door.ts Normal file
View File

@ -0,0 +1,136 @@
// Door-phase flavour text. Aussie register per docs/GAME_DESIGN.md §5 — affectionate,
// absurd, never sneering at the punter. Added by LANE-DOOR; nothing here is imported
// by another lane, so it can move to a shared strings module at integration.
export const DOOR_UI = {
rope: 'UNHOOK ROPE',
ropeWaiting: 'NEXT!',
ropeEmpty: 'NOBODY THERE',
admit: 'ADMIT',
deny: 'NOT TONIGHT',
test: 'SOBRIETY TEST',
patDown: 'PAT-DOWN',
clicker: 'CLICKER',
phone: 'PHONE',
idTray: 'ID TRAY',
idTrayEmpty: 'no ID handed over',
dressCode: "DAZZA'S RULES",
dressCodeEmpty: 'no rules yet.\nuse ur judgement\n(dont)',
} as const;
/** Shown once, first thirty seconds, then never again. A stranger must be able to play. */
export const DOOR_TUTORIAL = [
'click the rope to call someone up',
'hover their clothes to look closer',
'click the ID in the tray to read it',
'ADMIT or NOT TONIGHT',
] as const;
export const PATRON_GREETINGS: readonly string[] = [
'evening mate',
'howsit goin',
'big night?',
'me and the girls just wanna dance',
'is it busy in there',
'my mates already inside',
"we've been in the queue for ages",
'do youse do stamps',
'yeah nah yeah',
"how ya goin' champ",
'is there a cover charge or',
'straight up i just need a sit down',
];
export const DRUNK_GREETINGS: readonly string[] = [
"i'm not even that drunk",
"i've had like. two",
'love this place. love it',
'yourrrre a legend by the way',
'wheres the kebab shop',
'i know the DJ. sort of',
"i'm fine i'm fine i'm fine",
];
export const CLAIM_LINES: readonly string[] = [
"i'm on the list",
'i know the owner',
"we're on the list under Bec",
'yeah nah my names definitely down',
'i was here last weekend',
];
/** What the room mutters when you send someone away. Never cruel about the patron. */
export const DENY_REACTIONS: readonly string[] = [
'the queue goes quiet',
'someone films it',
'a bloke in the queue claps. once.',
'muffled outrage',
'nobody argues',
'the queue shuffles forward',
];
export const ADMIT_REACTIONS: readonly string[] = [
'in they go',
'the queue breathes out',
'a small cheer',
'door thuds shut',
];
/**
* "Dazza does a lap" the deferred sting for waving a violator through. There
* are several because one phrasing repeated six times in a night reads as a
* broken game rather than a man losing his patience. `{rule}` is the card label.
*/
export const DAZZA_LAP_LINES: readonly string[] = [
'did a lap mate and the sign says "{rule}" so explain that one to me',
'im looking at someone by the bar rn. "{rule}". we discussed this',
'mate. "{rule}". i typed it out and everything',
'not being funny but whats the point of me sending u the rules. "{rule}"',
'one on the dancefloor right now. "{rule}". i felt that in my chest',
'ur letting them in with "{rule}" still on the card. bold',
'the owner walked past that one. "{rule}". i aged a year',
];
export const lapLine = (short: string, roll: number): string => {
const i = Math.min(DAZZA_LAP_LINES.length - 1, Math.floor(roll * DAZZA_LAP_LINES.length));
return (DAZZA_LAP_LINES[i] ?? DAZZA_LAP_LINES[0]!).replace('{rule}', short);
};
export const SOBRIETY_UI = {
title: 'SOBRIETY TEST',
pickPrompt: 'pick your test',
waiting: 'they think about it...',
passBtn: 'PASSED',
failBtn: 'FAILED',
hint: 'you can rule however you like. thats the job',
} as const;
export const PATDOWN_LINES = {
clean: 'pockets are empty. just lint and a servo receipt',
found: (item: string): string => `AMNESTY BIN: ${item}`,
} as const;
export const SUMMARY_UI = {
title: 'LAST DRINKS',
subtitle: '3:00 AM · the lights come up',
replay: 'click anywhere for another night',
} as const;
export const FAIL_TEXT = {
vibe: {
title: 'THE VIBE DIED',
dazza: 'the room is a MORGUE mate. dont come in tomorrow. or do. i dont know. no. dont',
},
aggro: {
title: 'THE QUEUE WENT UP',
dazza: 'someone glassed the door. THE DOOR. u had one job and the job was the door',
},
} as const;
/** Dazza's night-end verdicts, worst to best. Index chosen by final vibe. */
export const DAZZA_SIGNOFF: readonly string[] = [
'ok that was rough. we go again thursday. bring a jacket',
'not ur best night but the room stayed open so. thats something',
'solid. the owner didnt call me once. thats the highest praise i have',
'mate. MATE. that room was electric. im telling terry u did that',
];

91
src/data/strings/floor.ts Normal file
View File

@ -0,0 +1,91 @@
// Floor dialogue & flavour. Typed const maps, never inline in scene code
// (CONTRACTS.md §7). Tone: Aussie hospitality satire — affectionate, absurd,
// with a moral undertow. Nobody here is a villain; they're just having a night.
//
// NOTE(convention): CONTRACTS.md §7 puts dialogue in `src/data/strings/`, but
// `data/` is LANE-0 territory and lanes commit only inside their owned dirs.
// Parked here; see LANEHANDOVER.md — happy to move it on the reviewer's word.
import type { DrunkStage } from '../../data/types';
/** The three rungs of the cut-off ladder, softest first. */
export type EscalationLine = 'water' | 'done' | 'escort';
export const ESCALATION_LINES: Record<EscalationLine, string> = {
water: 'Have some water, mate. On the house.',
done: "Righto — you're done for the night.",
escort: "Come on. Walk with me. Out you go.",
};
/** What you get back when you pitch it right for their state. */
export const CUTOFF_REPLY_CLEAN: Record<DrunkStage, string> = {
sober: '"...yeah, nah, cheers?" He drinks the water. Confused, but hydrated.',
tipsy: '"Aw, legend." She takes the water and means it.',
loose: '"Yeah fair, fair. Good on ya." He sips it like medicine.',
messy: '"Nah I get it. I GET it." He shakes your hand for slightly too long.',
maggot: 'No words. Just a slow, dignified nod, and the walk of the defeated.',
};
/** Too much boot for their state — the whole room watches you overdo it. */
export const CUTOFF_REPLY_SCENE: Record<DrunkStage, string> = {
sober: '"I have had ONE lemon squash." The booth behind you starts filming.',
tipsy: '"Mate. MATE. I\'m on my second." Her friends boo you in unison.',
loose: '"Are you serious? I\'m FINE." He is fine. That\'s the problem.',
messy: '"Don\'t TOUCH me—" Arms up. Drink goes sideways. Room turns.',
maggot: 'He goes limp on purpose. All ninety kilos of him. Crowd cheers.',
};
/** Too gentle for their state — they take the water and keep going. */
export const CUTOFF_REPLY_SOFT: Record<DrunkStage, string> = {
sober: '"Ta." Nothing happened. Nothing was going to happen.',
tipsy: '"Sweet as." She wanders off, entirely unbothered.',
loose: '"Yeah yeah, water, love it." Straight back to the bar.',
messy: '"You\'re a good bloke." He pours the water into a pot plant.',
maggot: 'He accepts the water, salutes you, and staggers into the dance floor.',
};
/** Muffled through a stall door. They are not coming out politely. */
export const STALL_MUFFLED: readonly string[] = [
'"Piss off mate, I\'m BUSY."',
'"Occupied! ...by one person!"',
'"We are having a private conversation."',
'"Nobody in here but me and my reflection."',
'"Two seconds! ...Two more seconds!"',
];
export const STALL_BUSTED: readonly string[] = [
'The door swings. Two people leave in two directions at four times the speed of sound.',
'Busted. One goes left, one goes through the smoking door, neither makes eye contact.',
'They scatter like startled ibises. Someone drops a vape. Nobody comes back for it.',
];
export const STALL_OFF_BEAT = 'Your knuckles land in the gap between kicks. Even the door is embarrassed.';
/** Pat-down patter. Confiscation is a bit, not a punishment. */
export const PATDOWN_INTRO = 'Arms out, mate. You know the drill.';
export const PATDOWN_FOUND: Record<string, string> = {
baggie: 'Well. That\'s not a mint.',
flask: 'A hip flask. In this economy.',
goonSack: 'Sir, this is a licensed venue and that is a pillow of wine.',
tinnies: 'Two warm tinnies. Warm. That\'s the real crime.',
kebab: 'An entire kebab. Still wrapped. Honestly? Respect.',
someoneElsesId: 'This is Kylie. You are not Kylie.',
budgie: 'That is a live budgie. It is looking at me. Into the bin, mate.',
vapes: 'Fourteen vapes. Fourteen. Are you a shop?',
marker: 'Fat marker. We just repainted that cubicle.',
snags: 'Raw sausages. In your jacket. Why. WHY.',
};
export const PATDOWN_CLEAR = 'Nothing on him. "Told ya," he says, delighted.';
export const PATDOWN_MISSED = 'Time. Whatever\'s left in there walks out the door with him.';
/** Kayden, on the radio, when a stampless patron turns up inside. */
export const NO_STAMP_RADIO: readonly string[] = [
'"Yeah sorry, that one might\'ve been me. Sorry. Sorry."',
'"He said he was getting his jacket. Forty minutes ago."',
'"I stamped SOMEONE. Pretty sure I stamped someone."',
];
export const HELP_TEXT =
'WASD move · TAB uv · E interact · SPACE bang · ESC cancel · R reseed';

View File

@ -40,6 +40,8 @@ export interface PatronFlags {
uvStamped?: boolean;
timesDeniedBefore?: number;
disguised?: boolean;
/** Set on couples by door/QueueManager; predicate for noHandHolders (CCR-1). */
handHolding?: boolean;
}
export interface Patron {
@ -59,6 +61,8 @@ export interface Patron {
export interface DressCodeRule {
id: string;
text: string;
/** Card label for the on-screen dress-code board (CCR-4). */
short?: string;
activeFrom: number; // clock minutes since 21:00
violates: (p: Patron) => boolean;
}
@ -73,6 +77,8 @@ export interface HeatStrike {
export interface VerdictOutcome {
vibeDelta: number;
aggroDelta: number;
/** Queue-theatre payoff channel — the 'wait' verdict's reward (CCR-2). */
hypeDelta?: number;
heatStrike?: HeatStrike;
dazzaText?: string;
}
@ -137,4 +143,14 @@ export interface EventMap {
'meters:delta': { vibe?: number; aggro?: number; hype?: number };
'meters:changed': { vibe: number; aggro: number; hype: number };
'heat:strike': HeatStrike;
// ---- folded in at Phase-1 review (see LANEHANDOVER.md REVIEW 2026-07-19) ----
/** Single-writer incident trail: core/meters.ts appends to NightState.incidents. */
'incident:log': IncidentRecord;
/** Player fiddled with their phone while a patron waits — pure theatre. */
'door:phoneTheatre': { durationMs: number };
/** AudioContext resumed after the first real user gesture. */
'audio:unlocked': { atMs: number };
/** Bar line boundary (every 4th beat) — for anything that wants downbeats. */
'beat:bar': { barIndex: number; beatIndex: number; audioTimeMs: number };
}

View File

@ -1,7 +1,15 @@
import Phaser from 'phaser';
import { BootScene } from './scenes/shared/BootScene';
import { ParadeScene } from './scenes/shared/ParadeScene';
import { NightScene } from './scenes/door/NightScene';
import { DoorScene } from './scenes/door/DoorScene';
import { NightSummaryScene } from './scenes/door/NightSummaryScene';
import { FloorDemoScene } from './scenes/floor/FloorDemoScene';
import { JuiceDemoScene } from './ui/JuiceDemoScene';
// TODO(integration): Phase 2 owns scene routing (menu -> roster -> night). For
// v0.1 the game IS the Door night, so it boots straight into it. The demo scenes
// stay reachable via dev routes below.
const game = new Phaser.Game({
type: Phaser.AUTO,
width: 640,
@ -13,8 +21,42 @@ const game = new Phaser.Game({
autoCenter: Phaser.Scale.CENTER_BOTH,
zoom: Phaser.Scale.MAX_ZOOM,
},
scene: [BootScene, ParadeScene],
scene: [NightScene, DoorScene, NightSummaryScene, BootScene, ParadeScene, FloorDemoScene, JuiceDemoScene],
});
// debug handle (harmless in prod; used by dev tooling)
(window as unknown as { game: Phaser.Game }).game = game;
// TODO(integration): dev routing until Phase 2's shell exists.
// Keys: N night (the game) · P parade · F floor demo · J juice demo.
// URL hashes #night/#parade/#floor/#juice pick the boot scene.
// NightScene owns DoorScene/NightSummaryScene as children; stopping Night stops them.
const ROUTES = { night: 'Night', parade: 'Parade', floor: 'FloorDemo', juice: 'JuiceDemo' } as const;
type RouteKey = keyof typeof ROUTES;
const showScene = (target: (typeof ROUTES)[RouteKey]): void => {
for (const key of ['Night', 'Door', 'NightSummary', 'Parade', 'FloorDemo', 'JuiceDemo']) {
if (key !== target && game.scene.isActive(key)) game.scene.stop(key);
}
if (!game.scene.isActive(target)) game.scene.start(target);
};
// NightScene is scene[0] and auto-starts. Hash routing must wait until it is
// actually up before swapping, or both scenes end up running on top of each other.
const hash = window.location.hash.slice(1) as RouteKey;
if (hash in ROUTES && ROUTES[hash] !== 'Night') {
const route = (): void => {
if (!game.scene.isActive('Night')) return;
game.events.off(Phaser.Core.Events.POST_STEP, route);
showScene(ROUTES[hash]);
};
game.events.on(Phaser.Core.Events.POST_STEP, route);
}
window.addEventListener('keydown', (e) => {
const k = e.key.toLowerCase();
if (k === 'n') showScene('Night');
else if (k === 'p') showScene('Parade');
else if (k === 'f') showScene('FloorDemo');
else if (k === 'j') showScene('JuiceDemo');
});

47
src/rules/doorTypes.ts Normal file
View File

@ -0,0 +1,47 @@
// LANE-DOOR type extensions. CCR-1 (PatronFlags.handHolding) and CCR-2
// (VerdictOutcome.hypeDelta) were folded into data/types.ts at the Phase-1
// review — this file now only holds door-domain types.
import type { HeatStrike, Patron, VerdictOutcome } from '../data/types';
/**
* A consequence that lands LATER: Dazza does a lap and spots the bloke in
* thongs you waved through, the inspector audits at 3 AM. Deferred hits are the
* reason admitting is scary the cost never arrives while you can still argue.
*/
export interface DeferredHit {
/** clock-minute at which this fires (absolute, not relative). */
atClockMin: number;
vibeDelta?: number;
aggroDelta?: number;
dazzaText?: string;
heatStrike?: HeatStrike;
/** why, for the incident log + night summary */
reason: string;
patronId: string;
}
/**
* Structurally a VerdictOutcome (so it drops straight into the `door:verdict`
* event payload), plus the deferred tail that the night shell schedules.
*/
export interface DoorOutcome extends VerdictOutcome {
deferred?: DeferredHit[];
/** short line shown on the verdict toast — player-facing feedback. */
note?: string;
}
/** Everything judge() needs to score a call. Pure data — no Phaser, no scene. */
export interface JudgeContext {
clockMin: number;
nightDate: Date;
/** 0..1 roll used for the "Dazza does a lap" delay; caller passes rng.next(). */
lapRoll: number;
insideCount: number;
licensed: number;
}
/** Result of a sobriety test the player has already ruled on. */
export type SobrietyRuling = 'pass' | 'fail';
export type { Patron };

171
src/rules/dressCode.ts Normal file
View File

@ -0,0 +1,171 @@
// The dress code Dazza texts through the night. Rules evaluate outfit DATA; the
// player only ever sees pixels (design §4.1) — the gap is the game.
//
// `text` and `activeFrom` are NOT written here. They are read out of
// data/strings/dazza.ts by ruleId, because a rule whose card disagrees with the
// text that announced it is unplayable, and two hand-kept copies always drift.
import './doorTypes';
import { DAZZA_TEXTS } from '../data/strings/dazza';
import type { DressCodeRule, OutfitLayer, OutfitSlot, Patron } from '../data/types';
/** Contract rule + a card label short enough for the on-screen dress-code board. */
export interface DoorDressCodeRule extends DressCodeRule {
short: string;
}
// The vocab spells "not wearing one" as type 'none' (data/outfits.ts), so every
// lookup has to treat that as absent or `outer.type === 'blazer'` style checks
// start reasoning about a garment nobody is wearing.
const worn = (p: Patron, slot: OutfitSlot): OutfitLayer | undefined => {
const layer = p.outfit.find((l) => l.slot === slot);
return layer && layer.type !== 'none' ? layer : undefined;
};
const isType = (p: Patron, slot: OutfitSlot, type: string): boolean => worn(p, slot)?.type === type;
// Slots whose logo dollPlan.ts actually paints (src/patrons/dollPlan.ts:104 draws
// the white logo rect for the TOP slot and nothing else). The generator hands out
// logos on five slots, so enforcing all of them denies roughly a quarter of the
// queue over pixels that were never on screen — and Dazza's own text says
// "no VISIBLE logos". Widen this set the moment the renderer draws the rest; see
// CCR-3 in LANEHANDOVER.md.
const LOGO_VISIBLE_SLOTS: readonly OutfitSlot[] = ['top'];
const hasVisibleLogo = (p: Patron): boolean =>
p.outfit.some(
(l) => l.type !== 'none' && l.logo === true && LOGO_VISIBLE_SLOTS.includes(l.slot),
);
// Signals of the bloke who will be at the DJ booth by 1:15 asking for one song,
// just one. No single item convicts — a jersey is a jersey — but two of these at
// once is a man with a request already typed into his notes app.
//
// `renders` marks the tells dollPlan.ts actually DRAWS today. A jersey torso and
// trackie legs are plain coloured rects, indistinguishable from a tee and jeans,
// so convicting on those two alone denies a man over pixels that were never on
// screen — the one thing design §4.1 forbids. Conviction therefore needs two
// tells of which at least one is visible. When the art pass gives jersey and
// trackies their own geometry, flip `renders` and this guard dissolves.
interface Tell {
test: (p: Patron) => boolean;
renders: boolean;
}
const SONG_REQUEST_TELLS: readonly Tell[] = [
{ test: (p) => isType(p, 'top', 'jersey'), renders: false },
{ test: (p) => isType(p, 'hair', 'mullet'), renders: true },
{ test: (p) => isType(p, 'legs', 'trackies'), renders: false },
{
test: (p) => {
const acc = worn(p, 'accessory')?.type;
return acc === 'cap' || acc === 'chain' || acc === 'bumbag';
},
renders: true,
},
];
function looksLikeASongRequest(p: Patron): boolean {
const hits = SONG_REQUEST_TELLS.filter((t) => t.test(p));
return hits.length >= 2 && hits.some((t) => t.renders);
}
interface RuleSpec {
id: string;
short: string;
violates: (p: Patron) => boolean;
}
// Declared in DAZZA_TEXTS order so the two files read side by side. That order is
// NOT chronological (Dazza is not an organised man), so the sort below is
// load-bearing — announcement order comes from activeFrom, never from this array.
const SPECS: readonly RuleSpec[] = [
{
id: 'noThongsSinglets',
short: 'NO THONGS / NO SINGLETS',
violates: (p) => isType(p, 'shoes', 'thong') || isType(p, 'top', 'singlet'),
},
{
id: 'noLogos',
short: 'NO VISIBLE LOGOS',
violates: hasVisibleLogo,
},
{
id: 'noWhiteSneakers',
short: 'NO WHITE SNEAKERS, VINTAGE OK',
violates: (p) => {
const shoes = worn(p, 'shoes');
return shoes?.type === 'sneaker' && shoes.colour === 'white' && shoes.vintage !== true;
},
},
{
id: 'noSongRequesters',
short: 'NO SONG REQUESTERS',
violates: looksLikeASongRequest,
},
{
id: 'noBucketHats',
short: 'NO BUCKET HATS',
violates: (p) => isType(p, 'accessory', 'bucketHat'),
},
{
id: 'noSunnies',
short: 'NO SUNNIES INSIDE',
violates: (p) => isType(p, 'accessory', 'sunnies'),
},
{
id: 'noBlazers',
short: 'NO BLAZERS',
violates: (p) => isType(p, 'outer', 'blazer'),
},
{
id: 'noHandHolders',
short: 'NO HAND HOLDING',
// QueueManager pairs arrivals; the linked-hands pixel is what the player sees.
violates: (p) => p.flags.handHolding === true,
},
];
const DAZZA_BY_RULE = new Map(
DAZZA_TEXTS.filter((l): l is typeof l & { ruleId: string } => l.ruleId !== undefined).map(
(l) => [l.ruleId, l] as const,
),
);
function build(spec: RuleSpec): DoorDressCodeRule {
const line = DAZZA_BY_RULE.get(spec.id);
// Shipping a rule with an empty card is worse than not booting: the player
// would be denying people over a rule nobody announced.
if (!line) throw new Error(`dressCode: no Dazza text announces rule '${spec.id}'`);
return {
id: spec.id,
text: line.text,
activeFrom: line.fromMin,
short: spec.short,
violates: spec.violates,
};
}
for (const ruleId of DAZZA_BY_RULE.keys()) {
if (!SPECS.some((s) => s.id === ruleId)) {
throw new Error(`dressCode: Dazza announces rule '${ruleId}' but nothing enforces it`);
}
}
// Sorted once at load so activeRules/violations preserve announcement order for free.
export const DRESS_CODE_RULES: readonly DoorDressCodeRule[] = SPECS.map(build).sort(
(a, b) => a.activeFrom - b.activeFrom,
);
/** Rules in force at `clockMin`. Live the moment the text lands, hence inclusive. */
export function activeRules(clockMin: number): DoorDressCodeRule[] {
return DRESS_CODE_RULES.filter((r) => r.activeFrom <= clockMin);
}
export function violations(patron: Patron, clockMin: number): DoorDressCodeRule[] {
return activeRules(clockMin).filter((r) => r.violates(patron));
}
export function ruleById(id: string): DoorDressCodeRule | undefined {
return DRESS_CODE_RULES.find((r) => r.id === id);
}

104
src/rules/idCheck.ts Normal file
View File

@ -0,0 +1,104 @@
import { ageOn } from '../core/GameClock';
import type { IdCard } from '../data/types';
// The ID minigame's date math. Everything the player can catch on a licence
// resolves here so the card art, the verdict and the audit can never disagree.
// Reading order on the card, not severity — the player scans top-left to
// bottom-right, and a stable order keeps tests and toast text deterministic.
//
// This tuple is the SOURCE of IdTell, not a parallel list of it. Typed the other
// way round (`const TELL_ORDER: readonly IdTell[]`) a sixth tell added to the
// union would still compile against a stale tuple, and `tells` would silently
// drop it — an uncatchable fake, the exact bug this module exists to prevent.
const TELL_ORDER = [
'peelingLaminate',
'wrongHologram',
'jokeName',
'photoMismatch',
'expired',
] as const;
export type IdTell = (typeof TELL_ORDER)[number];
export interface IdVerdict {
claimedAge: number;
underage: boolean;
expired: boolean;
/** Valid, but only just — flavour text, never a violation. */
expiresTonight: boolean;
turnsEighteenTonight: boolean;
tells: IdTell[];
looksFake: boolean;
}
/** ISO date → local midnight ms. Local, not UTC: the licence is a local document. */
const midnightMs = (iso: string): number => new Date(`${iso.slice(0, 10)}T00:00:00`).getTime();
/** Drop any time-of-day the caller's nightDate carries, so comparisons are day-vs-day. */
const dayStart = (d: Date): Date => new Date(d.getFullYear(), d.getMonth(), d.getDate());
const dayBefore = (d: Date): Date => new Date(d.getFullYear(), d.getMonth(), d.getDate() - 1);
export function idVerdict(id: IdCard, nightDate: Date): IdVerdict {
const night = dayStart(nightDate);
const nightMs = night.getTime();
const expiryMs = midnightMs(id.expiry);
const claimedAge = ageOn(id.dob, night);
// A licence is good THROUGH its expiry date, so equality is not expired.
const expired = expiryMs < nightMs;
// "Was 17 yesterday, is 18 today" rather than matching month/day, so a 29 Feb
// DOB still trips this on the day the age math actually hands them their 18th.
const turnsEighteenTonight = claimedAge === 18 && ageOn(id.dob, dayBefore(night)) === 17;
const flags = id.fake;
const present: Record<IdTell, boolean> = {
peelingLaminate: flags?.peelingLaminate === true,
wrongHologram: flags?.wrongHologram === true,
jokeName: flags?.jokeName === true,
photoMismatch: flags?.photoMismatch === true,
// The generator ships fakes whose ONLY tell is the date, with no fake flags
// set at all. Deriving this here is what makes those catchable.
expired,
};
const tells = TELL_ORDER.filter((t) => present[t]);
return {
claimedAge,
underage: claimedAge < 18,
expired,
expiresTonight: expiryMs === nightMs,
turnsEighteenTonight,
tells,
looksFake: tells.length > 0,
};
}
/** DD/MM/YYYY — Australian order, hand-rolled because Intl output varies by host. */
/**
* Australian DD/MM/YYYY. Exported because the DESK needs to show tonight's date
* in the same format the card shows the DOB without it the player is asked to
* subtract a birthdate from a date the game never told them, which made
* `underage` (the tell that costs the licence) pure guesswork.
*/
export const formatDate = (d: Date): string =>
`${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
const ddmmyyyy = (iso: string): string => {
const [y = '', m = '', d = ''] = iso.slice(0, 10).split('-');
return `${d.padStart(2, '0')}/${m.padStart(2, '0')}/${y.padStart(4, '0')}`;
};
export function idDisplayLines(
id: IdCard,
nightDate: Date,
): { name: string; dob: string; expiry: string; age: string } {
return {
name: id.name,
dob: ddmmyyyy(id.dob),
expiry: ddmmyyyy(id.expiry),
age: String(ageOn(id.dob, dayStart(nightDate))),
};
}

214
src/rules/judge.ts Normal file
View File

@ -0,0 +1,214 @@
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 type { Patron, Verdict } from '../data/types';
// The scoring function — the one place this game's morality is a number. Every
// branch below is a line of the design doc's tension table (§2 meters, §4.3 the
// moral undertow), and every magnitude lives in JUDGE_TUNING so feel can be
// tuned without archaeology.
//
// The load-bearing asymmetry, which is NOT a bug: denying a clean patron still
// pays vibe, it just costs far more aggro than denying a real violator does.
// Arbitrary power is rewarded and expensive at the same time. That is the game.
export const JUDGE_TUNING = {
/** Deny someone who genuinely broke something. The job, done right. */
denyViolatorVibe: 6,
/** The queue still watched a stranger get humiliated. */
denyViolatorAggro: 1,
/** Deny someone with nothing wrong with them. Small reward... */
denyCleanVibe: 2,
/** ...large heat. The whole line saw that there was no reason. */
denyCleanAggro: 7,
/** Deny a bloke who really does drink with the owner. The worst call available. */
denyOwnersMateVibe: -14,
denyOwnersMateAggro: 4,
/** Influencer denials, stacked on top of whichever deny applied. */
denyInfluencerVibe: 3,
denyInfluencerAggro: 4,
/** A regular you have knocked back before. He kept count. */
denyGrudgedRegularVibe: -5,
admitCleanVibe: 3,
admitCleanAggro: -3,
/** Waving a violator through moves the queue too, just less — you hesitated. */
admitViolatorAggro: -2,
/** The trap: a dud ID admitted still reads to the queue as a fast door. */
admitBadIdVibe: 2,
/** "Dazza does a lap": the breach lands 2..5 minutes after you could argue. */
lapDelayMin: 2,
lapDelaySpread: 3,
lapVibePerRule: -4,
lapVibeCap: -12,
/** v0.1 has no roaming inspector, so every heat strike surfaces at 3 AM. */
auditClockMin: 360,
/** Stalls buy you time and cost you queue patience, nothing else. */
stallAggro: 2,
waitAggro: 3,
waitHype: 0.2,
};
// NaN-safe on purpose: the caller is meant to pass rng.next(), but a missing or
// junk lapRoll must not poison atClockMin into NaN and silently unschedule the
// lap. An unrolled lap is the earliest lap, not no lap at all.
const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0);
export function judge(patron: Patron, verdict: Verdict, ctx: JudgeContext): DoorOutcome {
// Stalls are theatre, not rulings. The queue notices the delay; nothing else
// moves until the player actually says admit or deny, which comes back
// through here as a normal verdict.
if (verdict === 'sobrietyTest' || verdict === 'patDown') {
return { vibeDelta: 0, aggroDelta: JUDGE_TUNING.stallAggro };
}
if (verdict === 'wait') {
return { vibeDelta: 0, aggroDelta: JUDGE_TUNING.waitAggro, hypeDelta: JUDGE_TUNING.waitHype };
}
const broken = violations(patron, ctx.clockMin);
const id = idVerdict(patron.idCard, ctx.nightDate);
// Dispatch on 'admit' explicitly rather than letting it be the else-branch of
// 'deny'. Verdict is a closed union today, but if the contract ever grows a
// verb (bribe, guest-list override) an else-branch would silently score it as
// an ADMIT — filing heat strikes and capacity breaches for a call the player
// never made. Falling through to deny is inert by comparison.
return verdict === 'admit' ? judgeAdmit(patron, broken, id, ctx) : judgeDeny(patron, broken, id);
}
function judgeDeny(patron: Patron, broken: DoorDressCodeRule[], id: IdVerdict): DoorOutcome {
const T = JUDGE_TUNING;
// Early return on purpose. Whatever else was wrong with him, Dazza only ever
// hears that you knocked back the owner's mate, and he hears it tonight —
// this is the one consequence that does not wait for a lap.
if (patron.flags.knowsOwner === true) {
return {
vibeDelta: T.denyOwnersMateVibe,
aggroDelta: T.denyOwnersMateAggro,
dazzaText: 'WHO did u just knock back that bloke plays golf with the owner im getting calls RIGHT NOW mate',
};
}
// "Genuinely violates something": the dress code, a card that reads under 18
// or fake, or an actual minor regardless of what the card claimed.
const justified = broken.length > 0 || id.underage || id.looksFake || patron.age < 18;
let vibeDelta = justified ? T.denyViolatorVibe : T.denyCleanVibe;
let aggroDelta = justified ? T.denyViolatorAggro : T.denyCleanAggro;
let note: string | undefined;
if (patron.archetype === 'influencer') {
// The room approves loudly; the phone comes out and the queue watches it.
vibeDelta += T.denyInfluencerVibe;
aggroDelta += T.denyInfluencerAggro;
}
if (patron.archetype === 'regular' && (patron.flags.timesDeniedBefore ?? 0) > 0) {
vibeDelta += T.denyGrudgedRegularVibe;
// Patrons carry no gender, so the note cannot say "he".
note = 'They kept count. This is not the first time you have knocked them back.';
}
// Deliberately not a penalty. Turning away a genuinely 18-year-old with a
// genuinely real ID is the game's conscience test, and the game never tells
// you which choice was right (§4.3). Do not make this cost anything.
// The age guard matches the spec's "genuinely 18" literally: the note claims
// this is their first legal night, so it must not fire on an older patron who
// happened to roll the archetype.
if (!justified && patron.archetype === 'fresh18' && patron.age === 18) {
note = 'Real ID, first legal night out, and you said no. Good story for them one day.';
}
// The inspector scores exactly like a punter, on purpose. Give them bespoke
// numbers and a player can identify them by watching the meters twitch, which
// deletes the entire mechanic. This is not an oversight — do not "fix" it.
return note === undefined ? { vibeDelta, aggroDelta } : { vibeDelta, aggroDelta, note };
}
function judgeAdmit(
patron: Patron,
broken: DoorDressCodeRule[],
id: IdVerdict,
ctx: JudgeContext,
): DoorOutcome {
const T = JUDGE_TUNING;
const cardIsBad = id.underage || id.looksFake;
const deferred: DeferredHit[] = [];
let vibeDelta = 0;
const aggroDelta = broken.length > 0 ? T.admitViolatorAggro : T.admitCleanAggro;
if (broken.length === 0 && !cardIsBad) vibeDelta += T.admitCleanVibe;
// The queue only sees a fast door, so waving a dud card through feels good in
// the moment. That is the entire trap; the bill arrives at the audit.
if (cardIsBad) vibeDelta += T.admitBadIdVibe;
if (broken.length > 0) {
const first = broken[0]!;
const shorts = broken.map((r) => r.short);
// Rule `short`s are the words printed on the dress-code card, and every one
// of them is phrased as a prohibition ("NO VISIBLE LOGOS"). So the line has
// to QUOTE the sign, never claim to see it: "i can see NO VISIBLE LOGOS"
// reads as the exact opposite of the accusation Dazza is making.
const lapAt = ctx.clockMin + T.lapDelayMin + Math.round(clamp01(ctx.lapRoll) * T.lapDelaySpread);
deferred.push({
// Clamped to the audit, which is also last drinks. A lap rolled at 2:58 AM
// would otherwise be scheduled past the end of the night and never fire,
// making every late admit consequence-free — the exploit being that the
// last five minutes of every night are a free-for-all.
atClockMin: Math.min(T.auditClockMin, lapAt),
vibeDelta: Math.max(T.lapVibeCap, T.lapVibePerRule * broken.length),
dazzaText: lapLine(first.short, clamp01(ctx.lapRoll)),
reason: `admitted ${patron.id} in breach: ${shorts.join(', ')}`,
patronId: patron.id,
});
}
// One strike, however many ways it was wrong — a licence only gets pulled
// once, and the audit files card problems and a real minor as one finding.
// Note the split: the CARD is what the player could see, the age is what
// actually costs you the venue. A flawless fake protects nobody.
if (cardIsBad || patron.age < 18) {
const found: string[] = [];
if (id.underage) found.push(`card read ${id.claimedAge}`);
if (id.looksFake) {
// idCheck folds 'expired' into `tells`, so `looksFake` is true for a
// perfectly genuine licence that simply lapsed. Calling a 40-year-old's
// out-of-date licence "fake" in the incident log is just wrong, so the
// two findings are reported separately.
const forged = id.tells.filter((t) => t !== 'expired');
if (forged.length > 0) found.push(`card was dodgy (${forged.join(', ')})`);
if (id.expired) found.push('card had expired');
}
if (patron.age < 18) found.push(`patron was ${patron.age}`);
const reason = `ID/age breach admitted — ${patron.id}: ${found.join('; ')}`;
deferred.push({
atClockMin: T.auditClockMin,
heatStrike: { reason, deferred: true },
reason,
patronId: patron.id,
});
}
if (ctx.insideCount >= ctx.licensed) {
const reason = `capacity breach — ${patron.id} admitted at ${ctx.insideCount}/${ctx.licensed}`;
deferred.push({
atClockMin: T.auditClockMin,
heatStrike: { reason, deferred: true },
reason,
patronId: patron.id,
});
}
return deferred.length > 0 ? { vibeDelta, aggroDelta, deferred } : { vibeDelta, aggroDelta };
}

191
src/rules/sobriety.ts Normal file
View File

@ -0,0 +1,191 @@
// The sobriety-test minigame: content + performance model.
//
// The player picks a challenge, the patron performs, and then the player rules
// PASS or FAIL regardless of what they just heard (design §3.1). So this module
// never decides anything — it only produces a performance ambiguous enough that
// ruling on it feels like a choice. `objectivelySober` exists so the night can
// score the player's honesty later, not so it can score the patron.
import type { DrunkStage, Patron } from '../data/types';
import type { RngStream } from '../core/SeededRNG';
import { drunkStage } from './drunk';
export type ChallengeKind = 'alphabet' | 'riddle';
export interface Challenge {
kind: ChallengeKind;
prompt: string;
ideal: string;
}
export interface Performance {
challenge: Challenge;
answer: string;
quality: number;
objectivelySober: boolean;
}
export const PASS_THRESHOLD = 0.6;
const ALPHABET_BACKWARDS = 'ZYXWVUTSRQPONMLKJIHGFEDCBA';
const ALPHABET_PROMPT = 'alphabet. backwards. any time you like.';
/** Asked BY the player, AT 1 AM, with a torch. Unanswerable is the point. */
export const RIDDLES: readonly { prompt: string; ideal: string }[] = [
{ prompt: 'kebab shop shuts at four, we shut at three. where are ya at half three?', ideal: 'in the kebab queue' },
{ prompt: 'name three things that are not a schooner.', ideal: 'a pot, a midi, a jug' },
{ prompt: 'whats heavier, a kilo of ice or a kilo of the ice machine?', ideal: 'same weight, different problem' },
{ prompt: 'ya mate says hes five minutes away. how long is that in real minutes?', ideal: 'forty, maybe fifty' },
{ prompt: 'spell sober backwards and tell me what it means.', ideal: 'rebos, and it means nothing' },
{ prompt: 'whats the difference between a line and a queue?', ideal: 'nothing, ones just angrier' },
{ prompt: 'the DJ plays one more song and then one more song. how many songs is that?', ideal: 'none, hes packing up' },
{ prompt: 'if the smoking area is outside, and youre outside, are ya in the club?', ideal: 'no, but i was, and i will be' },
];
// How much a night of practice buys you at the same blood alcohol. Seasoned
// drinkers hold the shape of a sentence together long past the point of sense.
//
// The ceiling is load-bearing, not taste: `objectivelySober` is the oracle the
// night uses to score the player's honesty, so a 'maggot' must NEVER read as
// sober no matter how seasoned. Worst case is the bottom of the maggot band with
// maximum tolerance, riding the top of the wobble:
// qMax = 1 - 0.85 * (1 - TOLERANCE_RELIEF) + WOBBLE.maggot < PASS_THRESHOLD
// which needs TOLERANCE_RELIEF < 0.365. At the old 0.4 an intoxication-0.85,
// tolerance-1.0 patron passed ~11% of the time. `maggot never reads sober` in
// the test file pins this against future retunes.
const TOLERANCE_RELIEF = 0.33;
// Roll spread per stage. 'loose' and 'messy' are deliberately the widest: that
// band is where the player has to actually decide something. A maggot is narrow
// because there is nothing ambiguous about a maggot.
const WOBBLE: Record<DrunkStage, number> = {
sober: 0.06,
tipsy: 0.16,
loose: 0.24,
messy: 0.28,
maggot: 0.14,
};
// Above this the recital is clean — the ideal, no theatre.
const FLAWLESS = 0.99;
const clamp01 = (v: number): number => Math.max(0, Math.min(1, v));
const ALPHABET_GIVE_UPS = [
' ... nah thats the good half',
' ... hang on',
' ... does it count if i sing it',
' ... i know the rest i just dont like it',
] as const;
const NON_ANSWERS = [
'yeah nah',
'is this a test or are we just talkin',
'my mate outside knows this one',
'ask me the alphabet one instead',
'thats a trick question and i respect that',
'whats the prize',
] as const;
// Roughly QWERTY-adjacent. Not exact — a drunk thumb isn't exact either.
const KEY_NEIGHBOURS: Record<string, string> = {
a: 'sqw', b: 'vgn', c: 'xdv', d: 'sfe', e: 'wrd', f: 'dgr', g: 'fht',
h: 'gjy', i: 'uok', j: 'hkn', k: 'jlm', l: 'ko', m: 'nk', n: 'bm',
o: 'ipl', p: 'ol', q: 'wa', r: 'etf', s: 'adw', t: 'ryg', u: 'yih',
v: 'cbf', w: 'qes', x: 'zsc', y: 'tuh', z: 'xa',
};
const VOWELS = 'aeiou';
/**
* Dropped letters, swapped pairs, a stall, and eventually giving up partway.
* Reach shrinks faster than accuracy does people lose the thread before they
* lose the letters.
*/
function reciteAlphabet(ideal: string, quality: number, rng: RngStream): string {
if (quality >= FLAWLESS) return ideal;
const reach = Math.max(3, Math.round(ideal.length * Math.min(1, 0.3 + quality * 0.85)));
const slip = (1 - quality) * 0.45;
const out: string[] = [];
for (let i = 0; i < reach; i++) {
const ch = ideal[i] ?? '';
if (!rng.chance(slip)) {
out.push(ch);
continue;
}
const kind = rng.int(0, 2);
if (kind === 0) continue; // dropped
if (kind === 1) {
out.push(ideal[i + 1] ?? '', ch); // swapped with the next one
i++;
continue;
}
out.push('...', ch); // stall
}
const said = out.join('');
const tail = reach < ideal.length ? rng.pick(ALPHABET_GIVE_UPS) : '';
const answer = `${said}${tail}`;
return answer.trim().length > 0 ? answer : rng.pick(ALPHABET_GIVE_UPS).trim();
}
function drunkTypos(ideal: string, quality: number, rng: RngStream): string {
const slip = (1 - quality) * 0.4;
let out = '';
for (const ch of ideal) {
const lower = ch.toLowerCase();
const isLetter = lower >= 'a' && lower <= 'z';
if (!isLetter || !rng.chance(slip)) {
out += ch;
continue;
}
const kind = rng.int(0, 2);
if (kind === 0) {
const nb = KEY_NEIGHBOURS[lower] ?? '';
out += nb.length > 0 ? (nb[rng.int(0, nb.length - 1)] ?? ch) : ch;
} else if (kind === 1) {
out += ch + ch;
} else if (!VOWELS.includes(lower)) {
out += ch; // only vowels get swallowed
}
}
return out.trim().length > 0 ? out : ideal;
}
/**
* Riddles fail sideways rather than sadly past a point the patron stops
* answering and starts negotiating. Tone guard: absurd, never pitiable.
*/
function answerRiddle(ideal: string, quality: number, rng: RngStream): string {
if (quality >= FLAWLESS) return ideal;
const bail = clamp01((0.4 - quality) / 0.4);
if (rng.chance(bail)) return rng.pick(NON_ANSWERS);
return drunkTypos(ideal, quality, rng);
}
/** One of each kind, so the player is choosing a flavour of humiliation. */
export function challengesFor(rng: RngStream): Challenge[] {
const riddle = rng.pick(RIDDLES);
return [
{ kind: 'alphabet', prompt: ALPHABET_PROMPT, ideal: ALPHABET_BACKWARDS },
{ kind: 'riddle', prompt: riddle.prompt, ideal: riddle.ideal },
];
}
export function perform(patron: Patron, challenge: Challenge, rng: RngStream): Performance {
const effective = patron.intoxication * (1 - TOLERANCE_RELIEF * patron.tolerance);
const wobble = WOBBLE[drunkStage(patron.intoxication)];
const quality = clamp01(1 - effective + (rng.next() * 2 - 1) * wobble);
const answer =
challenge.kind === 'alphabet'
? reciteAlphabet(challenge.ideal, quality, rng)
: answerRiddle(challenge.ideal, quality, rng);
return { challenge, answer, quality, objectivelySober: quality >= PASS_THRESHOLD };
}

View File

@ -0,0 +1,586 @@
import Phaser from 'phaser';
import { renderDoll } from '../../patrons/doll';
import { dollPlan } from '../../patrons/dollPlan';
import { violations, type DoorDressCodeRule } from '../../rules/dressCode';
import { idVerdict, formatDate } from '../../rules/idCheck';
import { judge } from '../../rules/judge';
import type { DoorOutcome } from '../../rules/doorTypes';
import { CONTRABAND } from '../../data/contraband';
import type { Patron, Verdict } from '../../data/types';
import {
ADMIT_REACTIONS,
DENY_REACTIONS,
DOOR_TUTORIAL,
DOOR_UI,
PATDOWN_LINES,
} from '../../data/strings/door';
import { QueueManager } from './QueueManager';
import { PatronUpView } from './PatronUpView';
import { IdCardView } from './IdCardView';
import { PhoneWidget } from './PhoneWidget';
import { DressCodeCard } from './DressCodeCard';
import { SobrietyModal } from './SobrietyModal';
import { StampFx } from './StampFx';
import { Button, DOOR_PALETTE, MeterBar, MONO, panel, text } from './ui';
import { shouldRecordStrike, type NightContext } from './NightScene';
const W = 640;
const H = 360;
const DESK_Y = 250;
const UP_X = 300;
const UP_FOOT_Y = 244;
const DOOR_X = 566;
const QUEUE_FOOT_Y = 228;
const TUTORIAL_MS = 26_000;
/**
* TODO(contract): CCR-4 `dazza:text` carries `rule?: DressCodeRule`, so a rule
* emitted by this lane comes back without the `short` card label the lane added.
* Until the payload widens, narrow it here honestly instead of casting.
*/
const isDoorRule = (r: unknown): r is DoorDressCodeRule =>
typeof r === 'object' && r !== null && typeof (r as DoorDressCodeRule).short === 'string';
interface QueueSprite {
patron: Patron;
image: Phaser.GameObjects.Image;
link: Phaser.GameObjects.Rectangle | null;
}
// The Door. Thin by design: every number it acts on comes from src/rules/, every
// arrival comes from QueueManager, and the night's lifetime belongs to
// NightScene. What lives here is layout, input and feel.
export class DoorScene extends Phaser.Scene {
private night!: NightContext;
private queue!: QueueManager;
private up!: PatronUpView;
private idCard!: IdCardView;
private phone!: PhoneWidget;
private codeCard!: DressCodeCard;
private sobriety!: SobrietyModal;
private stamp!: StampFx;
private queueSprites: QueueSprite[] = [];
private trayCard: Phaser.GameObjects.Container | null = null;
private buttons: Record<string, Button> = {};
private vibeBar!: MeterBar;
private aggroBar!: MeterBar;
private hypeText!: Phaser.GameObjects.Text;
private clockText!: Phaser.GameObjects.Text;
private countText!: Phaser.GameObjects.Text;
private heatText!: Phaser.GameObjects.Text;
private clickerText!: Phaser.GameObjects.Text;
private toast!: Phaser.GameObjects.Text;
private tutorial!: Phaser.GameObjects.Text;
private ropeProp!: Phaser.GameObjects.Container;
private busy = false;
private rulingToken = 0;
private elapsedMs = 0;
private testedThisPatron = false;
private pattedThisPatron = false;
private offs: Array<() => void> = [];
constructor() {
super('Door');
}
init(data: { night: NightContext }): void {
this.night = data.night;
}
create(): void {
this.queue = new QueueManager(this.night.bus, this.night.rng, this.night.nightDate);
this.up = new PatronUpView(this, UP_X, UP_FOOT_Y);
this.idCard = new IdCardView(this, this.night.nightDate);
this.sobriety = new SobrietyModal(this);
this.stamp = new StampFx(this);
this.buildStreet();
this.buildDesk();
this.buildHud();
this.offs = [
this.night.bus.on('clock:tick', ({ clockMin }) => this.queue.onClockMinute(clockMin)),
this.night.bus.on('meters:changed', ({ vibe, aggro, hype }) => {
this.vibeBar.set(vibe / 100, vibe < 20);
this.aggroBar.set(aggro / 100, aggro > 80);
this.hypeText.setText(`HYPE x${hype.toFixed(1)}`);
}),
this.night.bus.on('dazza:text', ({ rule }) => {
// The bus payload is typed with the frozen DressCodeRule, which has no
// `short`. Narrowing back to the lane's own DoorDressCodeRule is a real
// contract gap (CCR-4) — this predicate is the honest version of it
// rather than an `as never` punch-through.
if (isDoorRule(rule)) this.codeCard.add(this, rule, this.elapsedMs);
}),
this.night.bus.on('heat:strike', () => this.refreshCounters()),
];
this.events.once('shutdown', () => this.teardown());
this.refreshCounters();
this.vibeBar.set(this.night.state.vibe / 100);
this.aggroBar.set(this.night.state.aggro / 100);
}
// ---------- construction ----------
private buildStreet(): void {
this.add.rectangle(W / 2, 88, W, 176, 0x0a0a12); // wall of night
this.add.rectangle(W / 2, 212, W, 76, 0x1a1a24); // footpath
this.add.rectangle(W / 2, 176, W, 2, 0x2a2a38);
// kebab shop, stage left — visible all night, the moral centre (design §5)
this.add.rectangle(62, 120, 112, 106, 0x2a1c08);
this.add.rectangle(62, 88, 96, 18, DOOR_PALETTE.kebab, 0.9);
this.add.text(28, 82, 'KEBABS', { fontFamily: MONO, fontSize: '12px', color: '#3a2404' });
this.add.rectangle(62, 148, 92, 46, 0xe8c060, 0.12);
this.add.rectangle(62, 214, 96, 8, DOOR_PALETTE.kebab, 0.13);
// the venue
this.add.rectangle(DOOR_X, 108, 116, 140, 0x181828);
this.add.rectangle(DOOR_X, 58, 92, 16, DOOR_PALETTE.neon, 0.92);
this.add.text(DOOR_X - 40, 51, 'NOT TONIGHT', { fontFamily: MONO, fontSize: '10px', color: '#2a0818' });
this.add.rectangle(DOOR_X, 140, 34, 74, 0x05050a);
this.add.rectangle(DOOR_X, 214, 66, 8, DOOR_PALETTE.neon, 0.16);
this.buildRain();
this.buildRope();
}
private buildRain(): void {
const g = this.add.graphics();
g.fillStyle(0x8899bb, 1);
g.fillRect(0, 0, 1, 4);
g.generateTexture('door:rain', 1, 4);
g.destroy();
this.add
.particles(0, 0, 'door:rain', {
x: { min: -20, max: W },
y: -6,
lifespan: 800,
speedY: { min: 230, max: 310 },
speedX: { min: -34, max: -12 },
quantity: 2,
alpha: { start: 0.45, end: 0.12 },
})
.setDepth(400);
}
private buildRope(): void {
const c = this.add.container(0, 0).setDepth(280);
c.add(this.add.rectangle(238, 216, 4, 56, 0x8a7a4a)); // post
c.add(this.add.rectangle(238, 190, 8, 8, 0xc0a860));
const rope = this.add.rectangle(206, 198, 62, 3, 0x8a2038).setName('rope');
c.add(rope);
this.ropeProp = c;
const hit = this.add
.rectangle(222, 200, 78, 40, 0xffffff, 0)
.setInteractive({ useHandCursor: true })
.setDepth(281);
hit.on('pointerdown', () => this.callNext());
hit.on('pointerover', () => rope.setFillStyle(0xc03050));
hit.on('pointerout', () => rope.setFillStyle(0x8a2038));
}
private buildDesk(): void {
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);
// ID tray
panel(this, 180, 256, 96, 98).setDepth(30);
text(this, 185, 260, DOOR_UI.idTray, 7, DOOR_PALETTE.inkDim).setDepth(31);
IdCardView.holoSwatch(this, 228, 336).setDepth(31);
// clicker
panel(this, 282, 256, 74, 98).setDepth(30);
text(this, 287, 260, DOOR_UI.clicker, 7, DOOR_PALETTE.inkDim).setDepth(31);
this.clickerText = this.add
.text(319, 286, '0', { fontFamily: MONO, fontSize: '20px', color: '#f0e4d0' })
.setOrigin(0.5)
.setDepth(31);
this.buttons.clickIn = new Button(this, {
x: 286, y: 304, w: 66, h: 22, label: 'CLICK IN', size: 7,
fill: DOOR_PALETTE.amber, hover: DOOR_PALETTE.amberHot,
onClick: () => this.click('in'),
});
this.buttons.clickIn.setDepth(32);
this.buttons.clickOut = new Button(this, {
x: 286, y: 328, w: 66, h: 20, label: 'out', size: 7,
onClick: () => this.click('out'),
});
this.buttons.clickOut.setDepth(32);
// phone
panel(this, 362, 256, 74, 98).setDepth(30);
text(this, 367, 260, DOOR_UI.phone, 7, DOOR_PALETTE.inkDim).setDepth(31);
this.phone = new PhoneWidget(
this,
this.night.bus,
399,
302,
() => this.queue.lookAtPhone(),
() => this.night.clock.label,
);
// verdicts
this.buttons.rope = new Button(this, {
x: 442, y: 256, w: 192, h: 24, label: DOOR_UI.rope, size: 9,
fill: 0x5a2030, hover: 0x8a2038,
onClick: () => this.callNext(),
});
this.buttons.test = new Button(this, {
x: 442, y: 284, w: 94, h: 22, label: DOOR_UI.test, size: 7,
onClick: () => this.runSobriety(),
});
this.buttons.pat = new Button(this, {
x: 540, y: 284, w: 94, h: 22, label: DOOR_UI.patDown, size: 7,
onClick: () => this.runPatDown(),
});
this.buttons.admit = new Button(this, {
x: 442, y: 310, w: 94, h: 44, label: DOOR_UI.admit, size: 11,
fill: DOOR_PALETTE.green, hover: DOOR_PALETTE.greenHot,
onClick: () => this.rule('admit'),
});
this.buttons.deny = new Button(this, {
x: 540, y: 310, w: 94, h: 44, label: DOOR_UI.deny, size: 11,
fill: DOOR_PALETTE.red, hover: DOOR_PALETTE.redHot,
onClick: () => this.rule('deny'),
});
for (const b of Object.values(this.buttons)) b.setDepth(32);
this.setVerdictButtons(false);
}
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);
this.aggroBar = new MeterBar(this, 104, 10, 88, 'AGGRO', DOOR_PALETTE.red);
this.hypeText = text(this, 202, 6, 'HYPE x1.0', 7, '#d0b060').setDepth(501);
this.countText = text(this, 268, 6, '', 7, DOOR_PALETTE.inkDim).setDepth(501);
this.heatText = text(this, 380, 6, '', 7, '#ff8080').setDepth(501);
this.clockText = this.add
.text(W - 6, 5, '9:00 PM', { fontFamily: MONO, fontSize: '10px', color: '#9fe8a0' })
.setOrigin(1, 0)
.setDepth(501);
this.add
.text(W - 6, 16, formatDate(this.night.nightDate), {
fontFamily: MONO,
fontSize: '7px',
color: DOOR_PALETTE.inkDim,
})
.setOrigin(1, 0)
.setDepth(501);
this.toast = this.add
.text(UP_X, 178, '', { fontFamily: MONO, fontSize: '8px', color: '#f0e4d0', align: 'center' })
.setOrigin(0.5)
.setDepth(600)
.setAlpha(0);
this.tutorial = this.add
.text(240, 24, DOOR_TUTORIAL.join('\n'), {
fontFamily: MONO,
fontSize: '8px',
color: '#6a7a8a',
align: 'left',
lineSpacing: 3,
})
.setDepth(501);
}
// ---------- interaction ----------
private callNext(): void {
if (this.busy || this.queue.patronUp) return;
const p = this.queue.callNext();
if (!p) {
this.showToast(DOOR_UI.ropeEmpty);
return;
}
this.testedThisPatron = false;
this.pattedThisPatron = false;
this.up.show(p);
this.showTray(p);
this.setVerdictButtons(true);
this.tweens.add({
targets: this.ropeProp.getByName('rope') as Phaser.GameObjects.Rectangle,
scaleX: { from: 1, to: 0.2 },
duration: 160,
yoyo: true,
});
}
private showTray(p: Patron): void {
this.trayCard?.destroy();
const c = this.idCard.thumbnail(p, 228, 300);
c.setDepth(31);
c.setInteractive(new Phaser.Geom.Rectangle(-39, -23, 78, 46), Phaser.Geom.Rectangle.Contains);
c.on('pointerdown', () => this.idCard.open(p));
this.tweens.add({ targets: c, angle: { from: -6, to: 0 }, y: { from: 314, to: 300 }, duration: 220 });
this.trayCard = c;
}
private click(direction: 'in' | 'out'): void {
this.night.bus.emit('door:clicker', { direction });
this.refreshCounters();
this.tweens.add({ targets: this.clickerText, scale: { from: 1.35, to: 1 }, duration: 120 });
}
private runSobriety(): void {
const p = this.queue.patronUp;
if (!p || this.busy || this.sobriety.isOpen) return;
if (this.testedThisPatron) {
this.showToast('you already tested them');
return;
}
this.testedThisPatron = true;
this.applyOutcome(p, 'sobrietyTest', judge(p, 'sobrietyTest', this.judgeCtx()));
this.sobriety.open(p, this.night.rng.stream('sobriety'), ({ ruling, performance }) => {
// Failing someone who objectively passed is allowed and profitable — and
// the queue can tell (design §3.1: "You may fail them even if they pass").
if (ruling === 'fail' && performance.objectivelySober) {
this.night.bus.emit('meters:delta', { vibe: 2, aggro: 5 });
this.showToast('they were fine.\nthe queue noticed');
} else if (ruling === 'pass' && !performance.objectivelySober) {
this.showToast('generous.');
}
});
}
private runPatDown(): void {
const p = this.queue.patronUp;
if (!p || this.busy) return;
if (this.pattedThisPatron) {
this.showToast('their pockets are already out');
return;
}
this.pattedThisPatron = true;
this.applyOutcome(p, 'patDown', judge(p, 'patDown', this.judgeCtx()));
const ids = p.flags.contraband ?? [];
if (ids.length === 0) {
this.showToast(PATDOWN_LINES.clean);
return;
}
const names = ids.map((id) => CONTRABAND.find((c) => c.id === id)?.name ?? id);
this.night.bus.emit('meters:delta', { vibe: 1 });
this.showToast(PATDOWN_LINES.found(names.join(' + ')));
}
private rule(verdict: Extract<Verdict, 'admit' | 'deny'>): void {
const p = this.queue.patronUp;
if (!p || this.busy) return;
this.busy = true;
this.setVerdictButtons(false);
this.idCard.close();
this.trayCard?.destroy();
this.trayCard = null;
// STATE SETTLES NOW, ANIMATION IS JUST ANIMATION.
//
// This used to run inside the stamp's onImpact callback, which meant a tween
// that failed to fire left the patron still standing at a rope the player
// could rule on again — one patron was scored eleven times in a test night.
// Anything that touches meters, capacity or the queue happens synchronously
// here; the tween callbacks below may only move pixels.
const outcome = judge(p, verdict, this.judgeCtx());
if (verdict === 'admit') {
// The clicker does NOT move unless the player moves it. The gap between
// clickerShown and the truth is the mechanic (CONTRACTS.md §3).
this.night.state.capacity.inside++;
}
this.applyOutcome(p, verdict, outcome);
this.queue.resolveUp(verdict === 'deny');
// Highlight AFTER the call, never before. Lighting up the broken rules the
// moment a patron steps up hands the player the answer and reduces all
// eight dress-code rules to reading a red line off the desk — it deletes
// the inspection loop the whole scene exists for. As post-verdict feedback
// it teaches instead: here is what you just missed.
const broken = violations(p, this.night.state.clockMin).map((r) => r.id);
this.codeCard.highlight(broken);
this.time.delayedCall(2200, () => this.codeCard.highlight([]));
// `busy` gates input for the length of the animation. It is still released
// from a callback, so it still gets a watchdog — but now the worst case is a
// couple of dead seconds, not a corrupted night. The token stops ruling N's
// watchdog from unlocking the buttons partway through ruling N+1.
const token = ++this.rulingToken;
const release = (): void => {
if (token === this.rulingToken) this.busy = false;
};
this.time.delayedCall(2600, release);
if (verdict === 'deny') {
this.stamp.play({
x: UP_X,
y: UP_FOOT_Y - 70,
label: 'NOT TONIGHT',
onImpact: () => {
this.up.walkOffDenied();
this.showToast(this.pick(DENY_REACTIONS, p));
},
onDone: release,
});
return;
}
this.showToast(this.pick(ADMIT_REACTIONS, p));
this.up.walkInAdmitted(DOOR_X, () => {
release();
this.refreshCounters();
});
}
private judgeCtx(): Parameters<typeof judge>[2] {
return {
clockMin: this.night.state.clockMin,
nightDate: this.night.nightDate,
lapRoll: this.night.rng.stream('dazzaLaps').next(),
insideCount: this.night.state.capacity.inside,
licensed: this.night.state.capacity.licensed,
};
}
/** The one path from a DoorOutcome to the world. Nothing else touches meters. */
private applyOutcome(patron: Patron, verdict: Verdict, outcome: DoorOutcome): void {
const delta: { vibe?: number; aggro?: number; hype?: number } = {};
if (outcome.vibeDelta) delta.vibe = outcome.vibeDelta;
if (outcome.aggroDelta) delta.aggro = outcome.aggroDelta;
if (outcome.hypeDelta) delta.hype = outcome.hypeDelta;
if (Object.keys(delta).length > 0) this.night.bus.emit('meters:delta', delta);
if (
outcome.heatStrike &&
!outcome.heatStrike.deferred &&
shouldRecordStrike(this.night.state, outcome.heatStrike)
) {
this.night.bus.emit('heat:strike', outcome.heatStrike);
}
if (outcome.dazzaText) this.night.bus.emit('dazza:text', { text: outcome.dazzaText });
if (outcome.deferred?.length) this.night.scheduleDeferred(outcome.deferred);
if (outcome.note) this.showToast(outcome.note);
this.night.bus.emit('incident:log', {
clockMin: this.night.state.clockMin,
kind: verdict,
patronId: patron.id,
detail: `${patron.archetype} · id ${idVerdict(patron.idCard, this.night.nightDate).claimedAge}`,
});
this.night.bus.emit('door:verdict', { patron, verdict, outcome });
this.refreshCounters();
}
private pick(pool: readonly string[], p: Patron): string {
return pool[p.dollSeed % pool.length] ?? pool[0]!;
}
private showToast(msg: string): void {
this.toast.setText(msg).setAlpha(1).setY(178);
this.tweens.killTweensOf(this.toast);
this.tweens.add({ targets: this.toast, alpha: 0, y: 168, delay: 1900, duration: 700 });
}
private setVerdictButtons(on: boolean): void {
this.buttons.admit?.setEnabled(on);
this.buttons.deny?.setEnabled(on);
this.buttons.test?.setEnabled(on);
this.buttons.pat?.setEnabled(on);
}
private refreshCounters(): void {
const cap = this.night.state.capacity;
this.clickerText.setText(String(cap.clickerShown));
this.clickerText.setColor(cap.clickerShown > cap.licensed ? '#ff8080' : '#f0e4d0');
this.countText.setText(`INSIDE ${cap.inside}/${cap.licensed}`);
this.countText.setColor(cap.inside > cap.licensed ? '#ff8080' : DOOR_PALETTE.inkDim);
const strikes = this.night.state.heatStrikes.length;
this.heatText.setText(strikes > 0 ? `HEAT ${'!'.repeat(Math.min(3, strikes))}` : '');
}
// ---------- frame ----------
override update(_time: number, deltaMs: number): void {
this.elapsedMs += deltaMs;
this.queue.update(deltaMs);
this.up.update(deltaMs);
this.codeCard.update(this.elapsedMs);
this.night.reportQueue(this.queue.queueLength);
this.clockText.setText(this.night.clock.label);
if (this.tutorial.alpha > 0 && this.elapsedMs > TUTORIAL_MS) {
this.tweens.add({ targets: this.tutorial, alpha: 0, duration: 800 });
}
this.syncQueueSprites();
const rope = this.buttons.rope;
if (rope) {
const waiting = this.queue.queueLength;
const canCall = !this.queue.patronUp && waiting > 0;
rope.setLabel(canCall ? `${DOOR_UI.ropeWaiting} (${waiting})` : DOOR_UI.rope);
rope.setEnabled(canCall);
}
}
/** The visible queue is a view of QueueManager, rebuilt only when it changes. */
private syncQueueSprites(): void {
const visible = this.queue.snapshot.visible;
const same =
visible.length === this.queueSprites.length &&
visible.every((p, i) => this.queueSprites[i]?.patron.id === p.id);
if (same) {
// still animate the drunk sway of whoever is waiting
for (const s of this.queueSprites) {
const sway = dollPlan(s.patron, 'queue').swayPx;
if (sway > 0) s.image.x = s.image.getData('baseX') + Math.sin(this.elapsedMs / 320) * sway;
}
return;
}
for (const s of this.queueSprites) {
s.image.destroy();
s.link?.destroy();
}
this.queueSprites = visible.map((p, i) => {
const x = 196 - i * 24;
const y = QUEUE_FOOT_Y - (i % 2) * 3;
const img = this.add
.image(x, y, renderDoll(this, p, 'queue'))
.setOrigin(0.5, 1)
.setScale(1.5)
.setDepth(200 - i)
.setTint(0xc8c8d8);
img.setData('baseX', x);
// hand-holding couples are drawn linked, so noHandHolders is a rule you
// can actually see coming (rules judge data, players judge pixels)
let link: Phaser.GameObjects.Rectangle | null = null;
const prev = visible[i - 1];
if (p.flags.handHolding && prev?.flags.handHolding) {
link = this.add.rectangle(x + 12, y - 26, 14, 2, 0xe0a0b0).setDepth(210);
}
return { patron: p, image: img, link };
});
const offscreen = this.queue.snapshot.offscreen;
this.countText.setText(
`INSIDE ${this.night.state.capacity.inside}/${this.night.state.capacity.licensed}` +
(offscreen > 0 ? ` +${offscreen} round the corner` : ''),
);
}
private teardown(): void {
for (const off of this.offs) off();
this.offs = [];
this.phone?.destroy();
this.codeCard?.destroy();
this.sobriety?.close();
this.idCard?.close();
this.up?.clear();
}
}

View File

@ -0,0 +1,80 @@
import Phaser from 'phaser';
import type { DoorDressCodeRule } from '../../rules/dressCode';
import { DOOR_UI } from '../../data/strings/door';
import { DOOR_PALETTE, MONO, panel, text } from './ui';
// The laminated card on the desk. Rules land the moment Dazza's text arrives
// (design §3.1), so the NEW badge is the only warning the player gets that the
// punter they are already looking at just became a violation.
const NEW_BADGE_MS = 12_000;
interface Row {
rule: DoorDressCodeRule;
label: Phaser.GameObjects.Text;
badge: Phaser.GameObjects.Text;
addedAt: number;
}
export class DressCodeCard {
private readonly root: Phaser.GameObjects.Container;
private readonly emptyText: Phaser.GameObjects.Text;
private readonly rows: Row[] = [];
private readonly x: number;
private readonly y: number;
constructor(scene: Phaser.Scene, x: number, y: number, w: number, h: number) {
this.x = x;
this.y = y;
this.root = scene.add.container(0, 0).setDepth(50);
this.root.add(panel(scene, x, y, w, h, 0x1a2018, 0x3c4a38));
this.root.add(text(scene, x + 5, y + 4, DOOR_UI.dressCode, 8, '#c8d8b0'));
this.root.add(scene.add.rectangle(x + w / 2, y + 15, w - 8, 1, 0x3c4a38));
this.emptyText = text(scene, x + 5, y + 21, DOOR_UI.dressCodeEmpty, 7, DOOR_PALETTE.inkDim);
this.root.add(this.emptyText);
}
add(scene: Phaser.Scene, rule: DoorDressCodeRule, nowMs: number): void {
if (this.rows.some((r) => r.rule.id === rule.id)) return;
this.emptyText.setVisible(false);
const y = this.y + 20 + this.rows.length * 11;
const label = scene.add.text(this.x + 5, y, `· ${rule.short}`, {
fontFamily: MONO,
fontSize: '7px',
color: '#e0e8c8',
wordWrap: { width: 150 },
});
const badge = scene.add.text(this.x + 5, y, 'NEW', {
fontFamily: MONO,
fontSize: '7px',
color: '#ffd050',
});
badge.x = this.x + 5 + label.width + 4;
this.root.add([label, badge]);
// A flash on arrival — the card is peripheral vision, it has to shout once.
scene.tweens.add({ targets: badge, alpha: { from: 1, to: 0.25 }, duration: 400, yoyo: true, repeat: 8 });
this.rows.push({ rule, label, badge, addedAt: nowMs });
}
/** Highlight the rules the patron currently in front of you is breaking. */
highlight(violatedIds: readonly string[]): void {
for (const r of this.rows) {
const hit = violatedIds.includes(r.rule.id);
r.label.setColor(hit ? '#ff9090' : '#e0e8c8');
}
}
update(nowMs: number): void {
for (const r of this.rows) {
if (r.badge.visible && nowMs - r.addedAt > NEW_BADGE_MS) r.badge.setVisible(false);
}
}
destroy(): void {
this.root.destroy();
}
}

View File

@ -0,0 +1,190 @@
import Phaser from 'phaser';
import { renderDoll } from '../../patrons/doll';
import { idVerdict, idDisplayLines, formatDate } from '../../rules/idCheck';
import type { Patron } from '../../data/types';
import { DOOR_PALETTE, MONO, text } from './ui';
// The ID card, at two sizes: a thumbnail sitting in the tray and a half-screen
// zoom you actually read. Every fake tell is drawn, never labelled — the player
// judges pixels, rules/idCheck judges the data (design §4.1).
const CARD_W = 268;
const CARD_H = 156;
/** The genuine hologram colour. The desk carries a reference swatch of this so a
* wrong hologram is a fair catch rather than a memory test. */
export const GENUINE_HOLOGRAM = 0x46c8c0;
const FAKE_HOLOGRAM = 0xc046b0;
export class IdCardView {
private root: Phaser.GameObjects.Container | null = null;
private patron: Patron | null = null;
constructor(
private readonly scene: Phaser.Scene,
private readonly nightDate: Date,
) {}
get isOpen(): boolean {
return this.root !== null;
}
/** Small always-visible representation for the tray. Caller owns placement. */
thumbnail(p: Patron, x: number, y: number): Phaser.GameObjects.Container {
const c = this.scene.add.container(x, y);
const bg = this.scene.add.rectangle(0, 0, 78, 46, 0xdad4c4).setStrokeStyle(1, 0x8b8474);
const photo = this.scene.add.image(-24, 0, renderDoll(this.scene, p, 'idPhoto')).setScale(1.4);
const holo = this.scene.add
.rectangle(22, -14, 26, 8, p.idCard.fake?.wrongHologram ? FAKE_HOLOGRAM : GENUINE_HOLOGRAM, 0.75)
.setAngle(-8);
const name = this.scene.add
.text(-6, -4, p.idCard.name.slice(0, 13), { fontFamily: MONO, fontSize: '7px', color: '#2a2620' })
.setOrigin(0, 0.5);
c.add([bg, photo, holo, name]);
if (p.idCard.fake?.peelingLaminate) {
c.add(this.scene.add.triangle(36, 20, 0, 0, 9, 0, 9, -9, 0xf4f0e6, 0.85));
}
c.setSize(78, 46);
return c;
}
open(p: Patron): void {
if (this.root) this.close();
this.patron = p;
const { scene } = this;
// Parked right of centre on purpose: the photo-mismatch tell is only
// playable if you can see the card and the actual face at the same time, so
// the card must never sit on top of the patron at the rope (x ~252..348).
const cx = 462;
const cy = 134;
const root = scene.add.container(cx, cy).setDepth(700);
this.root = root;
// Dim the street enough to focus the card, light enough to still read a face.
const shade = scene.add
.rectangle(-cx, -cy, 1280, 720, 0x05050a, 0.4)
.setOrigin(0, 0)
.setInteractive()
.on('pointerdown', () => this.close());
root.add(shade);
const card = scene.add.rectangle(0, 0, CARD_W, CARD_H, 0xdad4c4).setStrokeStyle(2, 0x8b8474);
root.add(card);
// state banner
root.add(scene.add.rectangle(0, -CARD_H / 2 + 13, CARD_W - 8, 20, 0x2a4a8a));
root.add(
scene.add
.text(0, -CARD_H / 2 + 13, 'QUEENSLAND · ADULT PROOF OF AGE', {
fontFamily: MONO,
fontSize: '9px',
color: '#dfe6f4',
})
.setOrigin(0.5),
);
// photo, rendered from photoSeed — a mismatch is a visibly different person
const photoKey = renderDoll(scene, p, 'idPhoto');
root.add(scene.add.rectangle(-84, 8, 84, 84, 0xb8b2a2));
root.add(scene.add.image(-84, 8, photoKey).setScale(3.2));
const lines = idDisplayLines(p.idCard, this.nightDate);
const field = (label: string, value: string, y: number, big = false): void => {
root.add(
scene.add
.text(-26, y, label, { fontFamily: MONO, fontSize: '7px', color: '#6b6558' })
.setOrigin(0, 0.5),
);
root.add(
scene.add
.text(-26, y + 11, value, {
fontFamily: MONO,
fontSize: big ? '13px' : '11px',
color: '#1e1a14',
})
.setOrigin(0, 0.5),
);
};
field('NAME', lines.name, -36, true);
field('DATE OF BIRTH', lines.dob, 2, true);
field('EXPIRES', lines.expiry, 40);
// The whole ID minigame is one subtraction, and the player cannot do it
// without the other operand. Printed on the card itself rather than only on
// the desk so both numbers are in the same glance.
root.add(
scene.add
.text(92, 4, `TONIGHT\n${formatDate(this.nightDate)}`, {
fontFamily: MONO,
fontSize: '8px',
color: '#8a3a3a',
align: 'center',
lineSpacing: 2,
})
.setOrigin(0.5)
.setAngle(-4),
);
// hologram over the corner — wrong colour is the tell, the desk has a swatch
const holoColour = p.idCard.fake?.wrongHologram ? FAKE_HOLOGRAM : GENUINE_HOLOGRAM;
const holo = scene.add.rectangle(92, 44, 58, 22, holoColour, 0.55).setAngle(-10);
root.add(holo);
root.add(scene.add.rectangle(92, 44, 58, 3, 0xffffff, 0.3).setAngle(-10));
scene.tweens.add({ targets: holo, alpha: { from: 0.35, to: 0.7 }, duration: 900, yoyo: true, repeat: -1 });
if (p.idCard.fake?.peelingLaminate) {
// lifted corner + a couple of detached flecks
root.add(scene.add.triangle(CARD_W / 2 - 22, CARD_H / 2 - 22, 0, 0, 30, 0, 30, -30, 0xf6f2e8, 0.9));
root.add(scene.add.rectangle(CARD_W / 2 - 40, CARD_H / 2 - 8, 5, 3, 0xf6f2e8, 0.8));
root.add(scene.add.rectangle(CARD_W / 2 - 52, CARD_H / 2 - 14, 3, 3, 0xf6f2e8, 0.7));
}
const v = idVerdict(p.idCard, this.nightDate);
if (v.expired) {
root.add(
scene.add
.text(-40, 62, 'EXPIRED', { fontFamily: MONO, fontSize: '10px', color: '#a02020' })
.setOrigin(0, 0.5)
.setAngle(-6),
);
}
root.add(
text(scene, -CARD_W / 2 + 6, CARD_H / 2 - 12, 'click anywhere to put it down', 7, '#6b6558'),
);
root.setScale(0.2);
root.setAlpha(0);
scene.tweens.add({ targets: root, scale: 1, alpha: 1, duration: 150, ease: 'Back.easeOut' });
}
close(): void {
this.root?.destroy();
this.root = null;
this.patron = null;
}
/**
* Reference swatch for the desk: what a real hologram looks like. It sits on
* the SAME cream backing the card uses a translucent teal reads completely
* differently over dark laminate than over card stock, and a reference the
* player can't trust is worse than no reference.
*/
static holoSwatch(scene: Phaser.Scene, x: number, y: number): Phaser.GameObjects.Container {
const c = scene.add.container(x, y);
c.add(scene.add.rectangle(0, 0, 38, 15, 0xdad4c4));
c.add(scene.add.rectangle(0, 0, 30, 11, GENUINE_HOLOGRAM, 0.55));
c.add(scene.add.rectangle(0, 0, 30, 3, 0xffffff, 0.3));
c.add(
scene.add
.text(0, 10, 'GENUINE', { fontFamily: MONO, fontSize: '6px', color: DOOR_PALETTE.inkDim })
.setOrigin(0.5, 0),
);
return c;
}
get currentPatron(): Patron | null {
return this.patron;
}
}

View File

@ -0,0 +1,263 @@
import Phaser from 'phaser';
import { EventBus } from '../../core/EventBus';
import { GameClock, DEFAULT_CLOCK } from '../../core/GameClock';
import { SeededRNG } from '../../core/SeededRNG';
import { StubBeatClock } from '../../core/StubBeatClock';
import { Meters, freshNightState } from '../../core/meters';
import { _resetPatronSerial } from '../../patrons/generator';
import { ruleById } from '../../rules/dressCode';
import type { DeferredHit } from '../../rules/doorTypes';
import type { HeatStrike, NightState, Patron, Verdict, VerdictOutcome } from '../../data/types';
import { nextDazza } from './dazzaSchedule';
// TODO(integration): Phase 2 replaces this shell with the real night-flow state
// machine (Door <-> Floor interleave, radio pulls, Last Drinks). Everything here
// is deliberately thin: it owns the night's lifetime, nothing about the door.
//
// It renders NOTHING. DoorScene is launched on top and draws everything, which
// keeps "when does the night end" and "what does the street look like" in
// separate files.
export const LICENSED_CAPACITY = 80;
const VIBE_SAMPLE_EVERY_MIN = 5;
/** CONTRACTS.md §3: heatStrikes is "run-scoped, max 3". Design §2: 3 = licence gone. */
export const MAX_HEAT_STRIKES = 3;
/**
* Whether a strike is worth recording. Two guards, both contract-driven:
* past three the run is already over, and a breach that persists (over capacity
* for an hour) is ONE offence the inspector writes up, not one per patron
* admitting everybody used to log forty of them.
*/
export function shouldRecordStrike(state: NightState, strike: HeatStrike): boolean {
if (state.heatStrikes.length >= MAX_HEAT_STRIKES) return false;
return !state.heatStrikes.some((s) => s.reason === strike.reason);
}
export type NightEndReason = 'clock' | 'vibe' | 'aggro';
export interface VerdictRecord {
patron: Patron;
verdict: Verdict;
outcome: VerdictOutcome;
clockMin: number;
}
export interface NightLog {
vibeHistory: number[];
verdicts: VerdictRecord[];
admitted: number;
denied: number;
tested: number;
pattedDown: number;
hypePeak: number;
heatStrikes: HeatStrike[];
endReason: NightEndReason;
seed: number;
}
/** Everything DoorScene needs from the night. Passed by reference, not copied. */
export interface NightContext {
bus: EventBus;
rng: SeededRNG;
clock: GameClock;
state: NightState;
seed: number;
nightDate: Date;
scheduleDeferred(hits: readonly DeferredHit[]): void;
/** DoorScene owns the queue; Dazza's queue-gated whinges need to see it. */
reportQueue(length: number): void;
}
export class NightScene extends Phaser.Scene {
private bus!: EventBus;
private rng!: SeededRNG;
private clock!: GameClock;
private beat!: StubBeatClock;
private meters!: Meters;
private state!: NightState;
private seed = 4207;
private ended = false;
private deferred: DeferredHit[] = [];
private readonly firedDazza = new Set<string>();
private lastDazzaMin = -99;
private lastVibeSampleMin = -99;
private queueLength = 0;
private log!: NightLog;
private offs: Array<() => void> = [];
constructor() {
super('Night');
}
init(data: { seed?: number }): void {
if (typeof data.seed === 'number') this.seed = data.seed;
}
create(): void {
this.ended = false;
this.deferred = [];
this.firedDazza.clear();
this.lastDazzaMin = -99;
this.lastVibeSampleMin = -99;
_resetPatronSerial();
this.bus = new EventBus();
this.rng = new SeededRNG(this.seed);
this.clock = new GameClock(this.bus, DEFAULT_CLOCK);
this.beat = new StubBeatClock(this.bus);
this.state = freshNightState('voltage', 0, LICENSED_CAPACITY);
this.meters = new Meters(this.bus, this.state);
this.log = {
vibeHistory: [this.state.vibe],
verdicts: [],
admitted: 0,
denied: 0,
tested: 0,
pattedDown: 0,
hypePeak: 1,
heatStrikes: [],
endReason: 'clock',
seed: this.seed,
};
this.offs = [
this.bus.on('clock:tick', ({ clockMin }) => this.onMinute(clockMin)),
this.bus.on('meters:changed', ({ vibe, aggro, hype }) => this.onMeters(vibe, aggro, hype)),
this.bus.on('heat:strike', (s) => this.log.heatStrikes.push(s)),
this.bus.on('door:verdict', (v) => this.onVerdict(v)),
];
const ctx: NightContext = {
bus: this.bus,
rng: this.rng,
clock: this.clock,
state: this.state,
seed: this.seed,
nightDate: this.clock.nightDate,
scheduleDeferred: (hits) => this.deferred.push(...hits),
reportQueue: (length) => {
this.queueLength = length;
},
};
this.events.once('shutdown', () => this.teardown());
this.clock.start();
this.beat.start();
this.scene.launch('Door', { night: ctx });
}
private onVerdict({ patron, verdict, outcome }: { patron: Patron; verdict: Verdict; outcome: VerdictOutcome }): void {
this.log.verdicts.push({ patron, verdict, outcome, clockMin: this.state.clockMin });
if (verdict === 'admit') this.log.admitted++;
else if (verdict === 'deny') this.log.denied++;
else if (verdict === 'sobrietyTest') this.log.tested++;
else if (verdict === 'patDown') this.log.pattedDown++;
}
private onMeters(vibe: number, aggro: number, hype: number): void {
if (hype > this.log.hypePeak) this.log.hypePeak = hype;
if (this.ended) return;
// Fail states are checked here rather than per-frame so they can only fire
// as a consequence of something that actually happened.
if (vibe <= 0) this.endNight('vibe');
else if (aggro >= 100) this.endNight('aggro');
}
private onMinute(clockMin: number): void {
if (this.ended) return;
if (clockMin - this.lastVibeSampleMin >= VIBE_SAMPLE_EVERY_MIN) {
this.lastVibeSampleMin = clockMin;
this.log.vibeHistory.push(this.state.vibe);
}
this.fireDeferred(clockMin);
this.maybeDazza(clockMin);
if (clockMin >= this.clock.config.nightClockMinutes) this.endNight('clock');
}
/** Consequences you earned earlier arriving now, when you can no longer argue. */
private fireDeferred(clockMin: number): void {
for (let i = this.deferred.length - 1; i >= 0; i--) {
const hit = this.deferred[i]!;
if (clockMin < hit.atClockMin) continue;
this.deferred.splice(i, 1);
if (hit.vibeDelta !== undefined || hit.aggroDelta !== undefined) {
this.bus.emit('meters:delta', {
...(hit.vibeDelta !== undefined ? { vibe: hit.vibeDelta } : {}),
...(hit.aggroDelta !== undefined ? { aggro: hit.aggroDelta } : {}),
});
}
if (hit.dazzaText) this.bus.emit('dazza:text', { text: hit.dazzaText });
if (hit.heatStrike && shouldRecordStrike(this.state, hit.heatStrike)) {
this.bus.emit('heat:strike', hit.heatStrike);
}
this.bus.emit('incident:log', {
clockMin,
kind: 'deferred',
patronId: hit.patronId,
detail: hit.reason,
});
}
}
private maybeDazza(clockMin: number): void {
if (clockMin - this.lastDazzaMin < 2) return;
const line = nextDazza(clockMin, {
vibe: this.state.vibe,
aggro: this.state.aggro,
queueLength: this.queueLength,
insideCount: this.state.capacity.inside,
fired: this.firedDazza,
});
if (!line) return;
this.firedDazza.add(line.id);
this.lastDazzaMin = clockMin;
const rule = line.ruleId ? ruleById(line.ruleId) : undefined;
this.bus.emit('dazza:text', { text: line.text, ...(rule ? { rule } : {}) });
}
/** The one exit. Everything routes through here so the log is always complete. */
private endNight(reason: NightEndReason): void {
if (this.ended) return;
this.ended = true;
this.log.endReason = reason;
this.clock.pause();
this.beat.stop();
// Unfired deferred hits are the 3 AM audit — they land whether you made it
// to closing or not.
for (const hit of this.deferred) {
// Emit rather than push: core/meters.ts is the only thing allowed to
// mutate NightState (CONTRACTS.md §4), and the log listener picks it up.
if (hit.heatStrike && shouldRecordStrike(this.state, hit.heatStrike)) {
this.bus.emit('heat:strike', hit.heatStrike);
}
}
this.deferred = [];
this.log.vibeHistory.push(this.state.vibe);
this.time.delayedCall(reason === 'clock' ? 600 : 1400, () => {
this.scene.stop('Door');
this.scene.start('NightSummary', { log: this.log, state: this.state });
});
}
override update(_time: number, deltaMs: number): void {
if (this.ended) return;
this.clock.update(deltaMs);
this.beat.update(deltaMs);
}
private teardown(): void {
for (const off of this.offs) off();
this.offs = [];
this.meters?.destroy();
this.bus?.removeAll();
}
}

View File

@ -0,0 +1,210 @@
import Phaser from 'phaser';
import type { NightState } from '../../data/types';
import { DAZZA_SIGNOFF, FAIL_TEXT, SUMMARY_UI } from '../../data/strings/door';
import { MAX_HEAT_STRIKES, type NightLog } from './NightScene';
import { DOOR_PALETTE, MONO, panel, text } from './ui';
// TODO(integration): Phase 2 owns the real summary (incident report form,
// cross-night audit, progression). This is the honest v0.1 version: what
// happened, what it cost, and Dazza's opinion.
const W = 640;
const H = 360;
export function nightCash(log: NightLog, state: NightState): number {
// Wage plus a hype bonus, minus a strike deduction. Vibe is not paid directly —
// Dazza pays for the room feeling busy, not for you being right.
const wage = 180;
const hypeBonus = Math.round((log.hypePeak - 1) * 90);
const doorBonus = log.admitted * 2;
const strikes = state.heatStrikes.length * 60;
return Math.max(0, wage + hypeBonus + doorBonus - strikes);
}
export function signoffFor(vibe: number, strikes: number): string {
if (strikes >= 2) return DAZZA_SIGNOFF[0]!;
const idx = vibe < 25 ? 0 : vibe < 50 ? 1 : vibe < 75 ? 2 : 3;
return DAZZA_SIGNOFF[idx]!;
}
export class NightSummaryScene extends Phaser.Scene {
private log!: NightLog;
private state!: NightState;
constructor() {
super('NightSummary');
}
init(data: { log: NightLog; state: NightState }): void {
this.log = data.log;
this.state = data.state;
}
create(): void {
const failed = this.log.endReason !== 'clock';
this.add.rectangle(W / 2, H / 2, W, H, failed ? 0x160a0e : 0x0a0a12);
const title = failed
? this.log.endReason === 'vibe'
? FAIL_TEXT.vibe.title
: FAIL_TEXT.aggro.title
: SUMMARY_UI.title;
this.add
.text(W / 2, 26, title, {
fontFamily: MONO,
fontSize: '18px',
color: failed ? '#ff8080' : '#f0e4d0',
})
.setOrigin(0.5);
this.add
.text(W / 2, 46, failed ? `${this.state.venueId} · night over early` : SUMMARY_UI.subtitle, {
fontFamily: MONO,
fontSize: '8px',
color: DOOR_PALETTE.inkDim,
})
.setOrigin(0.5);
this.drawVibeGraph(30, 64, 300, 88);
this.drawIncidents(30, 158, 300, 82);
this.drawStats(354, 64);
this.drawDazza(30, 250);
this.add
.text(W / 2, H - 14, `${SUMMARY_UI.replay} · seed ${this.log.seed}`, {
fontFamily: MONO,
fontSize: '8px',
color: DOOR_PALETTE.inkDim,
})
.setOrigin(0.5);
this.input.once('pointerdown', () => this.scene.start('Night', { seed: this.log.seed + 1 }));
this.input.keyboard?.once('keydown-SPACE', () => this.scene.start('Night', { seed: this.log.seed + 1 }));
}
private drawVibeGraph(x: number, y: number, w: number, h: number): void {
panel(this, x, y, w, h, 0x11111a, 0x2a2a38);
text(this, x + 5, y + 4, 'VIBE ACROSS THE NIGHT', 7, DOOR_PALETTE.inkDim);
// 50 is the line you started on — crossing below it is the story of the night
const mid = y + h - 14 - (h - 34) * 0.5;
this.add.rectangle(x + w / 2, mid, w - 10, 1, 0x33333f);
const pts = this.log.vibeHistory;
if (pts.length < 2) return;
const g = this.add.graphics();
g.lineStyle(2, DOOR_PALETTE.greenHot, 1);
g.beginPath();
pts.forEach((v, i) => {
const px = x + 5 + ((w - 10) * i) / (pts.length - 1);
const py = y + h - 14 - ((h - 34) * Math.max(0, Math.min(100, v))) / 100;
if (i === 0) g.moveTo(px, py);
else g.lineTo(px, py);
});
g.strokePath();
text(this, x + 5, y + h - 12, '9PM', 7, DOOR_PALETTE.inkDim);
text(this, x + w - 30, y + h - 12, '3AM', 7, DOOR_PALETTE.inkDim);
}
private drawStats(x: number, y: number): void {
const w = 256;
panel(this, x, y, w, 168, 0x11111a, 0x2a2a38);
const cash = nightCash(this.log, this.state);
const rows: Array<[string, string, string?]> = [
['admitted', String(this.log.admitted)],
['knocked back', String(this.log.denied)],
['sobriety tests', String(this.log.tested)],
['pat-downs', String(this.log.pattedDown)],
['peak hype', `x${this.log.hypePeak.toFixed(1)}`],
['final vibe', String(Math.round(this.state.vibe))],
['final aggro', String(Math.round(this.state.aggro))],
[
'clicker vs reality',
`${this.state.capacity.clickerShown} vs ${this.state.capacity.inside}`,
this.state.capacity.clickerShown === this.state.capacity.inside ? undefined : '#ffc060',
],
[
'heat strikes',
`${this.state.heatStrikes.length}/3`,
this.state.heatStrikes.length > 0 ? '#ff8080' : undefined,
],
];
rows.forEach(([label, value, colour], i) => {
const ry = y + 10 + i * 13;
text(this, x + 8, ry, label, 8, DOOR_PALETTE.inkDim);
this.add
.text(x + w - 8, ry, value, {
fontFamily: MONO,
fontSize: '8px',
color: colour ?? DOOR_PALETTE.inkHot,
})
.setOrigin(1, 0);
});
this.add.rectangle(x + w / 2, y + 138, w - 16, 1, 0x2a2a38);
text(this, x + 8, y + 145, 'PAY', 9, '#d0b060');
this.add
.text(x + w - 8, y + 143, `$${cash}`, { fontFamily: MONO, fontSize: '14px', color: '#f0d060' })
.setOrigin(1, 0);
}
/**
* The strikes spelled out. A count alone tells you the run is over; the
* reasons tell you which call did it, which is the only way the next night is
* played differently. Lives in its own panel because the reasons are long
* sentences and used to run straight off the right edge of the stats table.
*/
private drawIncidents(x: number, y: number, w: number, h: number): void {
panel(this, x, y, w, h, 0x11111a, 0x2a2a38);
const strikes = this.state.heatStrikes;
text(this, x + 5, y + 4, strikes.length > 0 ? 'THE INSPECTOR WROTE UP' : 'NOTHING ON THE RECORD', 7,
strikes.length > 0 ? '#ff8080' : DOOR_PALETTE.inkDim);
if (strikes.length === 0) {
this.add.text(x + 5, y + 18, 'a clean sheet. no strikes, no questions.\nthe licence survives to friday.', {
fontFamily: MONO, fontSize: '7px', color: DOOR_PALETTE.inkDim, wordWrap: { width: w - 12 }, lineSpacing: 2,
});
return;
}
let ry = y + 16;
for (const s of strikes.slice(0, MAX_HEAT_STRIKES)) {
const line = this.add.text(x + 5, ry, `· ${s.reason}`, {
fontFamily: MONO, fontSize: '7px', color: '#ff9090', wordWrap: { width: w - 12 },
});
ry += line.height + 3;
}
}
private drawDazza(x: number, y: number): void {
const w = W - 60;
panel(this, x, y, w, 74, 0x0d1116, 0x2c4a38);
text(this, x + 8, y + 6, 'DAZZA', 8, '#7de08a');
const failed = this.log.endReason !== 'clock';
const body = failed
? this.log.endReason === 'vibe'
? FAIL_TEXT.vibe.dazza
: FAIL_TEXT.aggro.dazza
: signoffFor(this.state.vibe, this.state.heatStrikes.length);
this.add.text(x + 8, y + 20, body, {
fontFamily: MONO,
fontSize: '9px',
color: failed ? '#ffb0b0' : '#bfe8c8',
wordWrap: { width: w - 16 },
});
if (this.state.heatStrikes.length >= 3) {
this.add.text(x + 8, y + 56, 'THREE STRIKES. THE LICENCE IS GONE. RUN OVER.', {
fontFamily: MONO,
fontSize: '9px',
color: '#ff6060',
});
}
}
}

View File

@ -0,0 +1,201 @@
import Phaser from 'phaser';
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 { MONO } from './ui';
import { ZONE_BANDS, ZONE_ORDER, describeZone, greetingIndex, type InspectZone } from './inspect';
// The patron at the rope, three times life size so the outfit actually reads.
// Hover zones map 1:1 onto the doll's y bands — you look at the shoes by
// looking at the shoes.
const SCALE = 3;
const DOLL_W = 32;
const DOLL_H = 48;
export class PatronUpView {
private root: Phaser.GameObjects.Container | null = null;
private doll: Phaser.GameObjects.Image | null = null;
private tooltip: Phaser.GameObjects.Container | null = null;
private patron: Patron | null = null;
private swayT = 0;
private swayPx = 0;
constructor(
private readonly scene: Phaser.Scene,
private readonly x: number,
/** y of the patron's FEET */
private readonly footY: number,
) {}
get current(): Patron | null {
return this.patron;
}
show(p: Patron): void {
this.clear();
this.patron = p;
const { scene } = this;
const root = scene.add.container(this.x, this.footY).setDepth(300);
this.root = root;
// a puddle-ish shadow so they are standing ON something
root.add(scene.add.ellipse(0, 2, DOLL_W * SCALE * 0.6, 10, 0x000000, 0.45));
const key = renderDoll(scene, p, 'queue');
const doll = scene.add.image(0, 0, key).setOrigin(0.5, 1).setScale(SCALE);
this.doll = doll;
this.swayPx = dollPlan(p, 'queue').swayPx;
root.add(doll);
// hover zones, in doll-local coordinates scaled up
for (const zone of ZONE_ORDER) {
const band = ZONE_BANDS[zone];
const h = (band.y1 - band.y0) * SCALE;
const cy = -DOLL_H * SCALE + (band.y0 + (band.y1 - band.y0) / 2) * SCALE;
const hit = scene.add
.rectangle(0, cy, DOLL_W * SCALE * 0.8, h, 0xffffff, 0)
.setInteractive({ useHandCursor: true });
hit.on('pointerover', () => this.showTooltip(zone, cy));
hit.on('pointerout', () => this.hideTooltip());
root.add(hit);
}
this.speak(p);
// step-up: they walk in from the queue side and settle
root.x = this.x - 90;
root.setAlpha(0);
scene.tweens.add({ targets: root, x: this.x, alpha: 1, duration: 380, ease: 'Quad.easeOut' });
}
private speak(p: Patron): void {
if (!this.root) return;
const { scene } = this;
const drunk = drunkStage(p.intoxication);
const pool =
p.flags.claimsGuestList || p.flags.knowsOwner
? CLAIM_LINES
: drunk === 'messy' || drunk === 'maggot' || drunk === 'loose'
? DRUNK_GREETINGS
: PATRON_GREETINGS;
const line = pool[greetingIndex(p, pool.length)] ?? PATRON_GREETINGS[0]!;
const bubbleY = -DOLL_H * SCALE - 16;
const label = scene.add
.text(0, bubbleY, `"${line}"`, {
fontFamily: MONO,
fontSize: '8px',
color: '#d8d0c0',
align: 'center',
wordWrap: { width: 150 },
})
.setOrigin(0.5, 1);
const bg = scene.add
.rectangle(0, bubbleY - label.height / 2 + 1, label.width + 10, label.height + 6, 0x14121a, 0.85)
.setStrokeStyle(1, 0x3a3448);
this.root.add([bg, label]);
scene.tweens.add({ targets: [bg, label], alpha: 0, delay: 3600, duration: 700 });
}
private showTooltip(zone: InspectZone, localY: number): void {
this.hideTooltip();
if (!this.root || !this.patron) return;
const { scene } = this;
const lines = describeZone(this.patron, zone);
const c = scene.add.container(56, localY).setDepth(310);
const label = scene.add
.text(6, 0, lines.join('\n'), {
fontFamily: MONO,
fontSize: '7px',
color: '#f0e4d0',
wordWrap: { width: 120 },
})
.setOrigin(0, 0.5);
const bg = scene.add
.rectangle(0, 0, label.width + 14, label.height + 8, 0x0d0b12, 0.94)
.setOrigin(0, 0.5)
.setStrokeStyle(1, 0x5a4a68);
c.add([bg, label]);
this.root.add(c);
this.tooltip = c;
}
private hideTooltip(): void {
this.tooltip?.destroy();
this.tooltip = null;
}
/** Called every frame — the sway is the drunk tell you watch for. */
update(deltaMs: number): void {
if (!this.doll || this.swayPx <= 0) return;
this.swayT += deltaMs / 1000;
const speed = 2.2 - this.swayPx * 0.35;
this.doll.x = Math.sin(this.swayT * speed) * this.swayPx * SCALE * 0.5;
this.doll.setAngle(Math.sin(this.swayT * speed * 0.7) * this.swayPx * 0.9);
}
/** They walk off left after a denial: the defeated slouch. */
walkOffDenied(onDone?: () => void): void {
const root = this.root;
this.patron = null;
this.hideTooltip();
if (!root) {
onDone?.();
return;
}
this.root = null;
this.doll?.setAngle(9); // the slouch
this.scene.tweens.add({
targets: root,
x: root.x - 170,
y: root.y + 6,
alpha: 0,
duration: 900,
ease: 'Quad.easeIn',
onComplete: () => {
root.destroy();
onDone?.();
},
});
}
/** They walk right, into the venue. */
walkInAdmitted(doorX: number, onDone?: () => void): void {
const root = this.root;
this.patron = null;
this.hideTooltip();
if (!root) {
onDone?.();
return;
}
this.root = null;
this.scene.tweens.add({
targets: root,
x: doorX,
y: root.y - 26,
scale: 0.55,
alpha: 0,
duration: 820,
ease: 'Quad.easeIn',
onComplete: () => {
root.destroy();
onDone?.();
},
});
}
clear(): void {
this.hideTooltip();
this.root?.destroy();
this.root = null;
this.doll = null;
this.patron = null;
this.swayT = 0;
}
}

View File

@ -0,0 +1,182 @@
import Phaser from 'phaser';
import type { EventBus } from '../../core/EventBus';
import { DOOR_PALETTE, MONO, text } from './ui';
// Dazza is never seen (design §4.4) — he is entirely this widget. Buzz, preview,
// and a thread you can scroll back through when you have forgotten which
// increasingly unhinged rule is currently law.
/** Lines of preview that fit inside the 56x32 phone screen at 6px. */
const PREVIEW_MAX_LINES = 3;
export interface PhoneMessage {
clockLabel: string;
body: string;
isRule: boolean;
}
export class PhoneWidget {
private readonly root: Phaser.GameObjects.Container;
private readonly screen: Phaser.GameObjects.Rectangle;
private readonly preview: Phaser.GameObjects.Text;
private readonly badge: Phaser.GameObjects.Container;
private readonly badgeCount: Phaser.GameObjects.Text;
private thread: Phaser.GameObjects.Container | null = null;
private readonly messages: PhoneMessage[] = [];
private unread = 0;
private readonly offs: Array<() => void> = [];
constructor(
private readonly scene: Phaser.Scene,
bus: EventBus,
x: number,
y: number,
private readonly onOpened: () => void,
private readonly clockLabel: () => string = () => '',
) {
this.root = scene.add.container(x, y).setDepth(60);
const body = scene.add.rectangle(0, 0, 64, 46, 0x101014).setStrokeStyle(1, 0x3a3a44);
this.screen = scene.add.rectangle(0, -3, 56, 32, 0x0d1a14);
this.preview = scene.add
.text(-26, -17, '', {
fontFamily: MONO,
fontSize: '6px',
color: '#7de08a',
wordWrap: { width: 52 },
// A phone screen is 56x32. Without a hard cap a long rule text wraps to
// six lines and spills green out onto the desk timber.
maxLines: PREVIEW_MAX_LINES,
})
.setOrigin(0, 0);
const home = scene.add.rectangle(0, 17, 14, 3, 0x3a3a44);
const caption = text(scene, -26, -25, 'DAZZA', 6, '#4a8a58');
this.badge = scene.add.container(26, -20);
this.badge.add(scene.add.circle(0, 0, 6, DOOR_PALETTE.red));
this.badgeCount = scene.add
.text(0, 0, '1', { fontFamily: MONO, fontSize: '7px', color: '#ffffff' })
.setOrigin(0.5);
this.badge.add(this.badgeCount);
this.badge.setVisible(false);
this.root.add([body, this.screen, caption, this.preview, home, this.badge]);
body.setInteractive({ useHandCursor: true }).on('pointerdown', () => this.toggleThread());
this.screen.setInteractive({ useHandCursor: true }).on('pointerdown', () => this.toggleThread());
this.offs.push(
bus.on('dazza:text', ({ text: line, rule }) => this.receive(line, !!rule)),
);
}
private receive(body: string, isRule: boolean): void {
this.messages.push({ clockLabel: this.clockLabel(), body, isRule });
this.unread++;
this.badge.setVisible(true);
this.badgeCount.setText(String(Math.min(9, this.unread)));
this.preview.setText(body.length > 44 ? `${body.slice(0, 42)}` : body);
this.buzz();
}
private buzz(): void {
const { scene } = this;
scene.tweens.add({
targets: this.root,
x: this.root.x + 2,
duration: 45,
yoyo: true,
repeat: 5,
ease: 'Sine.easeInOut',
});
scene.tweens.add({
targets: this.screen,
fillAlpha: { from: 1, to: 0.55 },
duration: 110,
yoyo: true,
repeat: 3,
});
}
private toggleThread(): void {
if (this.thread) {
this.closeThread();
return;
}
this.onOpened();
this.unread = 0;
this.badge.setVisible(false);
const { scene } = this;
const c = scene.add.container(320, 180).setDepth(720);
this.thread = c;
c.add(
scene.add
.rectangle(0, 0, 1280, 720, 0x05050a, 0.6)
.setInteractive()
.on('pointerdown', () => this.closeThread()),
);
c.add(scene.add.rectangle(0, 0, 300, 300, 0x0d1116).setStrokeStyle(1, 0x2c4a38));
c.add(
scene.add
.text(0, -134, 'DAZZA (MANAGEMENT)', { fontFamily: MONO, fontSize: '9px', color: '#7de08a' })
.setOrigin(0.5),
);
// Newest at the bottom, like a real thread; older ones scroll off the top.
const shown = this.messages.slice(-8);
let y = -112;
if (shown.length === 0) {
c.add(
scene.add
.text(0, 0, 'no messages yet\nenjoy it while it lasts', {
fontFamily: MONO,
fontSize: '8px',
color: '#4a6a58',
align: 'center',
})
.setOrigin(0.5),
);
}
for (const m of shown) {
const bubble = scene.add
.text(-134, y, m.body, {
fontFamily: MONO,
fontSize: '8px',
color: m.isRule ? '#f0d060' : '#bfe8c8',
wordWrap: { width: 262 },
})
.setOrigin(0, 0);
c.add(bubble);
if (m.clockLabel) {
c.add(
scene.add
.text(134, y, m.clockLabel, { fontFamily: MONO, fontSize: '6px', color: '#3d5a48' })
.setOrigin(1, 0),
);
}
y += bubble.height + 8;
}
c.add(
scene.add
.text(0, 136, 'click anywhere to pocket it', { fontFamily: MONO, fontSize: '7px', color: '#3d5a48' })
.setOrigin(0.5),
);
}
private closeThread(): void {
this.thread?.destroy();
this.thread = null;
}
get isThreadOpen(): boolean {
return this.thread !== null;
}
destroy(): void {
for (const off of this.offs) off();
this.closeThread();
this.root.destroy();
}
}

View File

@ -0,0 +1,247 @@
import type { EventBus } from '../../core/EventBus';
import type { RngStream, SeededRNG } from '../../core/SeededRNG';
import { generatePatron } from '../../patrons/generator';
import type { Patron } from '../../data/types';
import { nextArrivalGapMin } from './arrivalCurve';
/** 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?"
// tuning in one testable place instead of smeared across render code.
export const QUEUE_TUNING = {
/** dolls drawn on the footpath; everyone past this is an offscreen count */
visibleSlots: 8,
/** hard cap on people waiting — past this they wander off to the kebab shop */
maxQueue: 22,
/** aggro per real second, per waiting patron, before the impatience ramp */
aggroPerSecPerWaiter: 0.06,
/** the ramp: waiting gets worse the longer it goes on (design §2: Aggro punishes slowness) */
impatienceRampSec: 15,
/** hype per real second of making people wait — the theatre bonus */
hypePerSec: 0.035,
/** hype earned per single patron-wait, so you cannot farm one punter forever */
hypePerWaitCap: 0.5,
/**
* A queue this short reads as "under control" and bleeds aggro. Above it, only
* the idle penalty applies. Relief has to scale with queue LENGTH rather than
* only firing on an empty street: during the 11 PM slam the queue is never
* empty, so an empty-only rule meant a perfectly-played night still rioted.
*/
comfortableQueue: 4,
/** aggro bled off per real second with nobody waiting at all */
calmPerSec: 0.9,
/** looking at your phone while someone waits: pure theatre, pure profit */
phoneHype: 0.12,
phoneCooldownMs: 2500,
/** a denied regular comes back to try again this many in-game minutes later */
regularRetryMin: [25, 60] as const,
/**
* ...but only this many times. Uncapped, one stubborn regular reappeared six
* times in a single night and ate a third of the night's denials on his own.
*/
regularMaxComebacks: 2,
/** chance a new arrival brings a hand-holding partner (feeds noHandHolders) */
coupleChance: 0.12,
} as const;
export interface QueueSnapshot {
waiting: Patron[];
visible: Patron[];
offscreen: number;
up: Patron | null;
/** real ms the front of the queue has been left standing with nobody up */
waitMs: number;
}
export class QueueManager {
private waiting: Patron[] = [];
private up: Patron | null = null;
private waitMs = 0;
private hypeEarnedThisWait = 0;
private phoneCooldownMs = 0;
private nextArrivalMin = 0;
private clockMin = 0;
private readonly arrivalRng: RngStream;
private readonly coupleRng: RngStream;
private readonly comebackRng: RngStream;
/** patrons queued to re-enter the queue: [clockMin, patron] */
private readonly comebacks: Array<{ atMin: number; patron: Patron }> = [];
/** everyone who reached the front tonight, for the summary screen */
readonly seen: Patron[] = [];
constructor(
private readonly bus: EventBus,
private readonly rng: SeededRNG,
private readonly nightDate: Date,
) {
this.arrivalRng = rng.stream('arrivals');
this.coupleRng = rng.stream('couples');
// Its own stream on purpose. Drawing the comeback delay from 'arrivals'
// meant one player decision — denying a regular — shifted every remaining
// 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');
// 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());
this.nextArrivalMin = OPENING_ARRIVAL_MIN;
}
get snapshot(): QueueSnapshot {
const visible = this.waiting.slice(0, QUEUE_TUNING.visibleSlots);
return {
waiting: [...this.waiting],
visible,
offscreen: Math.max(0, this.waiting.length - visible.length),
up: this.up,
waitMs: this.waitMs,
};
}
get queueLength(): number {
return this.waiting.length;
}
get patronUp(): Patron | null {
return this.up;
}
get isPhoneReady(): boolean {
return this.phoneCooldownMs <= 0;
}
/** Called on every clock:tick — arrivals and comebacks run on in-game minutes. */
onClockMinute(clockMin: number): void {
this.clockMin = clockMin;
// Doors shut at 2:45. Whoever is already in the line still gets served, but
// nobody joins it after this — including a denied regular whose comeback
// happens to be due. Shut means shut.
if (clockMin >= DOORS_SHUT_MIN) return;
for (let i = this.comebacks.length - 1; i >= 0; i--) {
const c = this.comebacks[i]!;
if (clockMin >= c.atMin) {
this.comebacks.splice(i, 1);
this.enqueue(c.patron);
}
}
while (clockMin >= this.nextArrivalMin) {
this.spawnArrival();
const gap = nextArrivalGapMin(this.nextArrivalMin, this.arrivalRng.next());
this.nextArrivalMin += Math.max(1, Math.round(gap));
}
}
/** Called every frame with real elapsed ms. Owns the impatience economy. */
update(deltaMs: number): void {
const sec = deltaMs / 1000;
this.phoneCooldownMs = Math.max(0, this.phoneCooldownMs - deltaMs);
const idle = this.up === null && this.waiting.length > 0;
if (idle) {
this.waitMs += deltaMs;
const ramp = 1 + this.waitMs / 1000 / QUEUE_TUNING.impatienceRampSec;
const aggro = QUEUE_TUNING.aggroPerSecPerWaiter * this.waiting.length * ramp * sec;
// Hype is capped per wait; aggro is not. Theatre has diminishing returns,
// resentment does not.
const room = Math.max(0, QUEUE_TUNING.hypePerWaitCap - this.hypeEarnedThisWait);
const hype = Math.min(room, QUEUE_TUNING.hypePerSec * sec);
this.hypeEarnedThisWait += hype;
this.bus.emit('meters:delta', { aggro, ...(hype > 0 ? { hype } : {}) });
return;
}
if (this.waiting.length === 0) this.waitMs = 0;
// Somebody is being served, or nobody is waiting: the line relaxes in
// proportion to how short it is. This is the pressure valve that makes the
// night survivable — keep it moving and the aggro you earn bleeds back off.
const slack = QUEUE_TUNING.comfortableQueue - this.waiting.length;
if (slack > 0) {
const relief = (QUEUE_TUNING.calmPerSec * slack) / QUEUE_TUNING.comfortableQueue;
this.bus.emit('meters:delta', { aggro: -relief * sec });
}
}
/** The Rope. Nothing steps up until the player says so. */
callNext(): Patron | null {
if (this.up !== null) return null;
const next = this.waiting.shift();
if (!next) return null;
this.up = next;
this.waitMs = 0;
this.hypeEarnedThisWait = 0;
this.seen.push(next);
this.bus.emit('door:patronUp', { patron: next });
return next;
}
/** Player checked their phone while someone waits — reward the theatre. */
lookAtPhone(): boolean {
if (!this.isPhoneReady || this.waiting.length === 0) return false;
this.phoneCooldownMs = QUEUE_TUNING.phoneCooldownMs;
this.bus.emit('meters:delta', { hype: QUEUE_TUNING.phoneHype });
return true;
}
/**
* The patron at the rope has been dealt with. Denied regulars nurse the grudge
* and try again later in the night (design §4.1 regulars have memory).
*/
resolveUp(denied: boolean): void {
const p = this.up;
this.up = null;
this.waitMs = 0;
this.hypeEarnedThisWait = 0;
if (!p || !denied) return;
const priorDenials = p.flags.timesDeniedBefore ?? 0;
if (p.archetype === 'regular' && this.clockMin < 300 && priorDenials < QUEUE_TUNING.regularMaxComebacks) {
const [lo, hi] = QUEUE_TUNING.regularRetryMin;
const back: Patron = {
...p,
flags: { ...p.flags, timesDeniedBefore: (p.flags.timesDeniedBefore ?? 0) + 1 },
};
this.comebacks.push({ atMin: this.clockMin + this.comebackRng.int(lo, hi), patron: back });
}
}
private spawnArrival(): void {
// EVERY draw here happens unconditionally, before anything looks at whether
// the queue had room. Queue length is player-controlled, so a draw taken
// behind an `if (enqueued)` would make the rest of the night's patrons
// depend on how the player was playing — CONTRACTS.md §6 says same seed,
// same night, full stop.
const first = this.make();
const isCouple = this.coupleRng.chance(QUEUE_TUNING.coupleChance);
const partner = isCouple ? this.make() : null;
const p = this.enqueue(first);
if (p && partner) {
p.flags.handHolding = true;
partner.flags.handHolding = true;
this.enqueue(partner);
}
}
private make(): Patron {
return generatePatron({ rng: this.rng, nightDate: this.nightDate }, this.clockMin);
}
private enqueue(p: Patron): Patron | null {
if (this.waiting.length >= QUEUE_TUNING.maxQueue) return null; // they've gone for a kebab
this.waiting.push(p);
return p;
}
}

View File

@ -0,0 +1,165 @@
import Phaser from 'phaser';
import type { RngStream } from '../../core/SeededRNG';
import { challengesFor, perform, type Challenge, type Performance } from '../../rules/sobriety';
import type { Patron } from '../../data/types';
import { SOBRIETY_UI } from '../../data/strings/door';
import { Button, DOOR_PALETTE, MONO } from './ui';
// The power trip, in modal form. They perform, you rule. The ruling is not
// required to agree with the performance — that gap is the whole point, so the
// PASS/FAIL buttons are deliberately identical in weight.
export type SobrietyRuling = 'pass' | 'fail';
export interface SobrietyResult {
ruling: SobrietyRuling;
performance: Performance;
}
const TYPE_MS_PER_CHAR = 42;
export class SobrietyModal {
private root: Phaser.GameObjects.Container | null = null;
private readonly buttons: Button[] = [];
private typeEvent: Phaser.Time.TimerEvent | null = null;
constructor(private readonly scene: Phaser.Scene) {}
get isOpen(): boolean {
return this.root !== null;
}
open(patron: Patron, rng: RngStream, onResult: (r: SobrietyResult) => void): void {
this.close();
const { scene } = this;
const root = scene.add.container(320, 180).setDepth(760);
this.root = root;
// Interactive so it EATS clicks: without this the ADMIT / NOT TONIGHT
// buttons underneath stay live and you can rule on someone mid-test.
root.add(scene.add.rectangle(0, 0, 1280, 720, 0x05050a, 0.7).setInteractive());
root.add(scene.add.rectangle(0, 0, 340, 210, 0x14121a).setStrokeStyle(1, 0x5a4a68));
root.add(
scene.add
.text(0, -92, SOBRIETY_UI.title, { fontFamily: MONO, fontSize: '11px', color: '#f0e4d0' })
.setOrigin(0.5),
);
const prompt = scene.add
.text(0, -68, SOBRIETY_UI.pickPrompt, { fontFamily: MONO, fontSize: '8px', color: '#9a8fa8' })
.setOrigin(0.5);
root.add(prompt);
const options = challengesFor(rng);
options.forEach((c, i) => {
const b = new Button(scene, {
x: 320 - 150 + i * 152,
y: 180 - 44,
w: 146,
h: 40,
label: c.kind === 'alphabet' ? 'ALPHABET\nBACKWARDS' : 'ANSWER ME\nTHIS',
size: 8,
fill: DOOR_PALETTE.deskEdge,
onClick: () => this.runChallenge(patron, c, rng, prompt, onResult),
});
b.setDepth(770);
b.label.setAlign('center');
this.buttons.push(b);
});
root.add(
scene.add
.text(0, 88, SOBRIETY_UI.hint, { fontFamily: MONO, fontSize: '7px', color: '#5a5068' })
.setOrigin(0.5),
);
}
private runChallenge(
patron: Patron,
challenge: Challenge,
rng: RngStream,
prompt: Phaser.GameObjects.Text,
onResult: (r: SobrietyResult) => void,
): void {
const { scene } = this;
const root = this.root;
if (!root) return;
this.clearButtons();
prompt.setText(`"${challenge.prompt}"`).setColor('#d8d0c0').setWordWrapWidth(300);
const performance = perform(patron, challenge, rng);
const answer = scene.add
.text(0, -14, '', {
fontFamily: MONO,
fontSize: '10px',
color: '#9fe8a0',
align: 'center',
wordWrap: { width: 300 },
})
.setOrigin(0.5, 0);
root.add(answer);
// Typed out at drunk speed. Watching them struggle IS the information.
let i = 0;
this.typeEvent = scene.time.addEvent({
delay: TYPE_MS_PER_CHAR,
repeat: performance.answer.length - 1,
callback: () => {
i++;
answer.setText(performance.answer.slice(0, i));
if (i >= performance.answer.length) this.showRuling(performance, onResult);
},
});
if (performance.answer.length === 0) this.showRuling(performance, onResult);
}
private showRuling(performance: Performance, onResult: (r: SobrietyResult) => void): void {
const { scene } = this;
if (!this.root) return;
const finish = (ruling: SobrietyRuling): void => {
this.close();
onResult({ ruling, performance });
};
// Same size, same colour weight. The game does not tell you which is correct.
const pass = new Button(scene, {
x: 320 - 150,
y: 180 + 40,
w: 146,
h: 30,
label: SOBRIETY_UI.passBtn,
fill: DOOR_PALETTE.green,
hover: DOOR_PALETTE.greenHot,
onClick: () => finish('pass'),
});
const fail = new Button(scene, {
x: 320 + 4,
y: 180 + 40,
w: 146,
h: 30,
label: SOBRIETY_UI.failBtn,
fill: DOOR_PALETTE.red,
hover: DOOR_PALETTE.redHot,
onClick: () => finish('fail'),
});
pass.setDepth(770);
fail.setDepth(770);
this.buttons.push(pass, fail);
}
private clearButtons(): void {
for (const b of this.buttons) b.destroy();
this.buttons.length = 0;
}
close(): void {
this.typeEvent?.remove();
this.typeEvent = null;
this.clearButtons();
this.root?.destroy();
this.root = null;
}
}

147
src/scenes/door/StampFx.ts Normal file
View File

@ -0,0 +1,147 @@
import Phaser from 'phaser';
import { DOOR_PALETTE, MONO } from './ui';
// The signature interaction. Everything here exists to make one click feel like
// dropping a brick: overshoot, slam, shake, dust, ink that stays a beat too long.
// TODO(juice): swap for ui/Stamp + the stamp SFX when LANE-JUICE lands. Keep the
// timings — they were tuned by feel, not by theory.
const STAMP_KEY = 'door:stampInk';
const DUST_KEY = 'door:stampDust';
const TIMING = {
/** wind-up: stamp hangs above the patron, tilted */
rise: 90,
/** the drop itself. Short. A stamp does not float. */
slam: 110,
/** how long the ink sits before it starts fading */
hold: 520,
fade: 340,
} as const;
function ensureTextures(scene: Phaser.Scene): void {
if (!scene.textures.exists(DUST_KEY)) {
const g = scene.add.graphics();
g.fillStyle(0xb0a898, 1);
g.fillRect(0, 0, 2, 2);
g.generateTexture(DUST_KEY, 2, 2);
g.destroy();
}
if (scene.textures.exists(STAMP_KEY)) return;
// Ink block with a ragged edge — a clean rectangle reads as UI, a chewed one
// reads as rubber on a wet stamp pad.
const w = 118;
const h = 34;
const g = scene.add.graphics();
g.fillStyle(DOOR_PALETTE.red, 1);
g.fillRect(0, 3, w, h - 6);
g.fillRect(3, 0, w - 6, h);
g.fillStyle(0x000000, 1);
for (let i = 0; i < 26; i++) {
const x = (i * 37) % (w - 4);
const y = i % 2 === 0 ? 2 : h - 5;
g.fillRect(x, y, 3, 3);
}
g.generateTexture(STAMP_KEY, w, h);
g.destroy();
}
export interface StampOpts {
x: number;
y: number;
label: string;
onImpact?: () => void;
onDone?: () => void;
}
export class StampFx {
constructor(private readonly scene: Phaser.Scene) {
ensureTextures(scene);
}
play(opts: StampOpts): void {
const { scene } = this;
ensureTextures(scene);
// Above the street and the patron, BELOW every modal (ID card 700, phone
// thread 720, sobriety 760) — a stamp landing on top of an open ID card
// reads as a rendering bug.
const root = scene.add.container(opts.x, opts.y).setDepth(640);
const ink = scene.add.image(0, 0, STAMP_KEY).setOrigin(0.5);
const label = scene.add
.text(0, 1, opts.label, { fontFamily: MONO, fontSize: '13px', color: '#ffe8e8' })
.setOrigin(0.5);
root.add([ink, label]);
root.setAngle(-11);
root.setScale(3.4);
root.setAlpha(0);
// rise: fade in high and slightly rotated, so the drop has somewhere to come from
scene.tweens.add({
targets: root,
alpha: 1,
scale: 2.6,
duration: TIMING.rise,
ease: 'Quad.easeOut',
onComplete: () => this.slam(root, opts),
});
}
private slam(root: Phaser.GameObjects.Container, opts: StampOpts): void {
const { scene } = this;
scene.tweens.add({
targets: root,
scale: 1,
angle: -6,
duration: TIMING.slam,
ease: 'Cubic.easeIn',
onComplete: () => {
opts.onImpact?.();
scene.cameras.main.shake(140, 0.012);
this.dustRing(opts.x, opts.y);
// A 2px rebound after contact. Without it the stamp reads as a decal.
scene.tweens.add({
targets: root,
scaleX: 1.06,
scaleY: 0.94,
duration: 60,
yoyo: true,
ease: 'Quad.easeOut',
});
scene.time.delayedCall(TIMING.hold, () => {
scene.tweens.add({
targets: root,
alpha: 0,
y: root.y - 6,
duration: TIMING.fade,
ease: 'Quad.easeIn',
onComplete: () => {
root.destroy();
opts.onDone?.();
},
});
});
},
});
}
private dustRing(x: number, y: number): void {
const emitter = this.scene.add.particles(x, y, DUST_KEY, {
speed: { min: 40, max: 130 },
angle: { min: 190, max: 350 },
gravityY: 190,
lifespan: { min: 260, max: 520 },
quantity: 16,
scale: { start: 1.4, end: 0 },
alpha: { start: 0.65, end: 0 },
emitting: false,
});
emitter.setDepth(639);
emitter.explode(16);
this.scene.time.delayedCall(700, () => emitter.destroy());
}
}

View File

@ -0,0 +1,81 @@
// The night's arrival shape: dead at 9, slammed from 10:30, tapering after 1:30,
// nobody left to turn away by 3. Pure data + maths — QueueManager consumes this
// and the Phaser scene only draws the result, so nothing here touches the engine.
export interface ArrivalCurvePoint {
clockMin: number;
perMin: number;
}
/**
* Control points, linearly interpolated between. Tuned so a full night delivers
* ~70 patrons (assert in tests via expectedTotalArrivals): enough that the rope
* backs up during the slam, few enough that the queue is still clearable.
*/
export const ARRIVAL_CURVE: readonly ArrivalCurvePoint[] = [
{ clockMin: 0, perMin: 0.12 }, // 9:00 PM — staff, and one bloke who thinks it's a pub
{ clockMin: 45, perMin: 0.18 }, // 9:45 PM
{ clockMin: 90, perMin: 0.34 }, // 10:30 PM — the pre-drinks let out
{ clockMin: 150, perMin: 0.42 }, // 11:30 PM — peak
{ clockMin: 210, perMin: 0.32 }, // 12:30 AM — slam ends
{ clockMin: 270, perMin: 0.16 }, // 1:30 AM
{ clockMin: 330, perMin: 0.06 }, // 2:30 AM — last drinks
{ clockMin: 360, perMin: 0 }, // 3:00 AM
];
/**
* Longest gap one roll may produce. Held down deliberately: the 9 PM lull is
* meant to read as quiet, not as a broken game, and an uncapped exponential draw
* at the opening rate can hand the player a full minute of empty street.
*/
const MAX_GAP_MIN = 22;
/** Floor so two patrons never materialise on the same frame. */
const MIN_GAP_MIN = 0.25;
/** Nobody is coming. Finite on purpose: callers divide by and loop on this. */
const NO_ARRIVALS_GAP_MIN = 999;
/** Arrivals per in-game minute at `clockMin`, clamped flat outside the curve. */
export function arrivalsPerMin(clockMin: number): number {
const first = ARRIVAL_CURVE[0];
const last = ARRIVAL_CURVE[ARRIVAL_CURVE.length - 1];
if (!first || !last) return 0;
if (clockMin <= first.clockMin) return first.perMin;
if (clockMin >= last.clockMin) return last.perMin;
for (let i = 1; i < ARRIVAL_CURVE.length; i++) {
const a = ARRIVAL_CURVE[i - 1]!;
const b = ARRIVAL_CURVE[i]!;
if (clockMin <= b.clockMin) {
const span = b.clockMin - a.clockMin;
const t = span === 0 ? 0 : (clockMin - a.clockMin) / span;
return a.perMin + (b.perMin - a.perMin) * t;
}
}
// Unreachable given the clamps above, but NaN in must not become NaN out.
return last.perMin;
}
/**
* In-game minutes until the next patron walks up. `roll` is a 0..1 uniform from
* an RngStream; the exponential draw is what makes arrivals clump three at once
* then a lull instead of ticking over like a metronome.
*/
export function nextArrivalGapMin(clockMin: number, roll: number): number {
const rate = arrivalsPerMin(clockMin);
if (!(rate > 0)) return NO_ARRIVALS_GAP_MIN;
const safeRoll = Number.isFinite(roll) ? Math.min(Math.max(roll, 0), 0.999999) : 0;
const gap = -Math.log(1 - safeRoll) / rate;
return Math.min(MAX_GAP_MIN, Math.max(MIN_GAP_MIN, gap));
}
/** Integral of the curve over the whole night. Tuning knob, not gameplay. */
export function expectedTotalArrivals(): number {
let total = 0;
for (let i = 1; i < ARRIVAL_CURVE.length; i++) {
const a = ARRIVAL_CURVE[i - 1]!;
const b = ARRIVAL_CURVE[i]!;
total += ((a.perMin + b.perMin) / 2) * (b.clockMin - a.clockMin);
}
return total;
}

View File

@ -0,0 +1,42 @@
import { DAZZA_TEXTS, type DazzaLine } from '../../data/strings/dazza';
// Pure: decides which of Dazza's texts are due. Rule-bearing texts fire on the
// clock (they are law the moment they land, design §3.1); everything else fires
// when the night gives him something to complain about, so his commentary reads
// as a reaction rather than a timer.
export interface DazzaWorld {
vibe: number;
aggro: number;
queueLength: number;
insideCount: number;
fired: ReadonlySet<string>;
}
/** Conditions for the non-rule texts, keyed by DazzaLine id. */
const MOOD: Record<string, (w: DazzaWorld) => boolean> = {
'vibe-mid': (w) => w.vibe < 35,
'vibe-good': (w) => w.vibe > 72,
'vibe-queue': (w) => w.queueLength >= 6,
'vibe-empty': (w) => w.insideCount <= 3,
kayden: (w) => w.queueLength >= 3,
};
export function dazzaDue(clockMin: number, world: DazzaWorld): DazzaLine[] {
const due: DazzaLine[] = [];
for (const line of DAZZA_TEXTS) {
if (world.fired.has(line.id)) continue;
if (clockMin < line.fromMin) continue;
const gate = MOOD[line.id];
if (gate && !gate(world)) continue;
due.push(line);
}
// Rules first when several come due at once — the player needs the law before
// the philosophy.
return due.sort((a, b) => Number(!!b.ruleId) - Number(!!a.ruleId) || a.fromMin - b.fromMin);
}
/** At most one text per minute, so a quiet stretch does not become a wall of Dazza. */
export function nextDazza(clockMin: number, world: DazzaWorld): DazzaLine | undefined {
return dazzaDue(clockMin, world)[0];
}

179
src/scenes/door/inspect.ts Normal file
View File

@ -0,0 +1,179 @@
import type { OutfitSlot, Patron } from '../../data/types';
import { drunkStage } from '../../rules/drunk';
// Pure: what you SEE when you look at a patron. Deliberately never says
// "violates the dress code" — the card says what is banned, this says what is
// there, and closing that gap is the player's job (design §4.1).
const SHOE_WORDS: Record<string, string> = {
sneaker: 'sneakers',
boot: 'boots',
dress: 'dress shoes',
heel: 'heels',
thong: 'thongs',
loafer: 'loafers',
platform: 'platforms',
};
const LEG_WORDS: Record<string, string> = {
jeans: 'jeans',
chinos: 'chinos',
skirt: 'skirt',
trackies: 'trackies',
shorts: 'shorts',
slacks: 'slacks',
cargo: 'cargo pants',
};
const TOP_WORDS: Record<string, string> = {
tee: 'tee',
shirt: 'shirt',
singlet: 'singlet',
polo: 'polo',
crop: 'crop top',
dressShirt: 'dress shirt',
jersey: 'footy jersey',
};
const OUTER_WORDS: Record<string, string> = {
blazer: 'blazer',
bomber: 'bomber jacket',
hoodie: 'hoodie',
leather: 'leather jacket',
puffer: 'puffer',
};
const ACC_WORDS: Record<string, string> = {
bucketHat: 'bucket hat',
cap: 'cap',
sunnies: 'sunnies',
bumbag: 'bumbag',
chain: 'chain',
};
const HAIR_WORDS: Record<string, string> = {
short: 'short hair',
long: 'long hair',
bun: 'hair up',
mullet: 'a mullet',
shaved: 'a shaved head',
dyed: 'dyed hair',
};
const WORDS: Partial<Record<OutfitSlot, Record<string, string>>> = {
shoes: SHOE_WORDS,
legs: LEG_WORDS,
top: TOP_WORDS,
outer: OUTER_WORDS,
accessory: ACC_WORDS,
hair: HAIR_WORDS,
};
/**
* Does this slot/type pair have a hand-written word? Exported so a test can
* assert coverage EXACTLY, rather than inferring it by string-comparing the
* output against the raw type id that heuristic silently stopped working the
* moment a legitimate word ('skirt', 'cap') matched its own id.
*/
export function hasWordFor(slot: OutfitSlot, type: string): boolean {
return type === 'none' || WORDS[slot]?.[type] !== undefined;
}
/**
* One line about one slot. Colour comes first because colour is what the dress
* code keeps changing its mind about so the noun tables above hold BARE nouns
* with no article, or you get "navy a blazer". Hair is the exception: it is
* never colour-prefixed, so HAIR_WORDS keeps its articles.
*/
export function describeSlot(p: Patron, slot: OutfitSlot): string {
const layer = p.outfit.find((l) => l.slot === slot);
if (!layer || layer.type === 'none') {
return slot === 'outer' ? 'no jacket' : slot === 'accessory' ? 'no accessories' : 'nothing notable';
}
const noun = WORDS[slot]?.[layer.type] ?? layer.type;
const base = slot === 'hair' ? noun : `${layer.colour} ${noun}`;
const notes: string[] = [];
if (layer.vintage) notes.push('genuinely old');
else if (layer.type === 'sneaker' && layer.colour === 'white') notes.push('look brand new');
if (layer.logo) notes.push('big logo on it');
return notes.length > 0 ? `${base}${notes.join(', ')}` : base;
}
/** The eyes. The single most reliable intoxication tell at the rope. */
export function describeEyes(p: Patron): string {
switch (drunkStage(p.intoxication)) {
case 'sober':
return 'clear eyes, bit bored';
case 'tipsy':
return 'bright eyes. cheerful';
case 'loose':
return 'pink around the edges';
case 'messy':
return 'bloodshot. slow blink';
case 'maggot':
return 'not tracking. at all';
}
}
/** How they are standing. Matches the sway the renderer bakes in. */
export function describeStance(p: Patron): string {
switch (drunkStage(p.intoxication)) {
case 'sober':
return 'standing straight';
case 'tipsy':
return 'shifting foot to foot';
case 'loose':
return 'swaying, just a bit';
case 'messy':
return 'leaning on nothing';
case 'maggot':
return 'being held up by a mate';
}
}
/** What they say when they step up, chosen deterministically from dollSeed. */
export function greetingIndex(p: Patron, poolSize: number): number {
return poolSize > 0 ? p.dollSeed % poolSize : 0;
}
/** Accessories the renderer draws on the body rather than the head. */
function waistOrCollar(p: Patron): string[] {
const acc = p.outfit.find((l) => l.slot === 'accessory');
if (!acc || (acc.type !== 'bumbag' && acc.type !== 'chain')) return [];
return [describeSlot(p, 'accessory')];
}
export type InspectZone = 'head' | 'face' | 'torso' | 'legs' | 'shoes';
export const ZONE_ORDER: readonly InspectZone[] = ['head', 'face', 'torso', 'legs', 'shoes'];
/**
* Doll-local y bands (32x48 queue pose) each zone covers. Kept beside the
* descriptions so a change to dollPlan.ts has one obvious place to land.
*/
export const ZONE_BANDS: Record<InspectZone, { y0: number; y1: number }> = {
head: { y0: 0, y1: 8 },
face: { y0: 8, y1: 16 },
torso: { y0: 16, y1: 30 },
legs: { y0: 30, y1: 44 },
shoes: { y0: 44, y1: 48 },
};
/** Everything the tooltip for a zone should say, top line first. */
export function describeZone(p: Patron, zone: InspectZone): string[] {
switch (zone) {
case 'head':
return [describeSlot(p, 'hair'), describeSlot(p, 'accessory')];
case 'face':
return [describeEyes(p), describeStance(p)];
case 'torso':
// A chain hangs at the collar and a bumbag at the waist — dollPlan draws
// both on the torso, so the torso zone has to name them or the player
// hovers the thing they can see and is told about a jacket instead.
return [describeSlot(p, 'top'), describeSlot(p, 'outer'), ...waistOrCollar(p)];
case 'legs':
return [describeSlot(p, 'legs'), ...waistOrCollar(p)];
case 'shoes':
return [describeSlot(p, 'shoes')];
}
}

157
src/scenes/door/ui.ts Normal file
View File

@ -0,0 +1,157 @@
import Phaser from 'phaser';
// Local widget kit for the Door. Deliberately tiny and ugly-on-purpose: the desk
// should feel like laminated MDF, not a UI framework.
// TODO(juice): LANE-JUICE owns src/ui/ — swap Button/Panel for its widgets at
// integration and delete this file.
export const DOOR_PALETTE = {
desk: 0x241c18,
deskEdge: 0x3a2c24,
panel: 0x1b1520,
panelEdge: 0x4a3a52,
ink: '#cbbfae',
inkDim: '#7a7068',
inkHot: '#f0e4d0',
green: 0x2e7d46,
greenHot: 0x46a862,
red: 0xa02020,
redHot: 0xd03030,
amber: 0x8a6a20,
amberHot: 0xc09030,
neon: 0xd03470,
kebab: 0xd8a020,
} as const;
export const MONO = 'monospace';
export function text(
scene: Phaser.Scene,
x: number,
y: number,
content: string,
size = 8,
colour: string = DOOR_PALETTE.ink,
): Phaser.GameObjects.Text {
return scene.add.text(x, y, content, { fontFamily: MONO, fontSize: `${size}px`, color: colour });
}
export function panel(
scene: Phaser.Scene,
x: number,
y: number,
w: number,
h: number,
fill: number = DOOR_PALETTE.panel,
edge: number = DOOR_PALETTE.panelEdge,
): Phaser.GameObjects.Rectangle {
return scene.add.rectangle(x + w / 2, y + h / 2, w, h, fill).setStrokeStyle(1, edge);
}
export interface ButtonOpts {
x: number;
y: number;
w: number;
h: number;
label: string;
fill?: number;
hover?: number;
size?: number;
colour?: string;
onClick: () => void;
}
export class Button {
readonly rect: Phaser.GameObjects.Rectangle;
readonly label: Phaser.GameObjects.Text;
private enabled = true;
private readonly fill: number;
private readonly hover: number;
constructor(scene: Phaser.Scene, private readonly opts: ButtonOpts) {
this.fill = opts.fill ?? DOOR_PALETTE.deskEdge;
this.hover = opts.hover ?? DOOR_PALETTE.panelEdge;
this.rect = scene.add
.rectangle(opts.x + opts.w / 2, opts.y + opts.h / 2, opts.w, opts.h, this.fill)
.setStrokeStyle(1, 0x0a0a0a)
.setInteractive({ useHandCursor: true });
this.label = scene.add
.text(opts.x + opts.w / 2, opts.y + opts.h / 2 + 1, opts.label, {
fontFamily: MONO,
fontSize: `${opts.size ?? 8}px`,
color: opts.colour ?? DOOR_PALETTE.inkHot,
})
.setOrigin(0.5);
this.rect.on('pointerover', () => {
if (this.enabled) this.rect.setFillStyle(this.hover);
});
this.rect.on('pointerout', () => {
if (this.enabled) this.rect.setFillStyle(this.fill);
});
this.rect.on('pointerdown', () => {
if (!this.enabled) return;
// 1px press travel — cheap, but the desk feels physical with it.
this.label.y += 1;
scene.time.delayedCall(70, () => {
this.label.y -= 1;
});
this.opts.onClick();
});
}
setEnabled(on: boolean): this {
this.enabled = on;
this.rect.setFillStyle(on ? this.fill : 0x161216);
this.label.setColor(on ? (this.opts.colour ?? DOOR_PALETTE.inkHot) : DOOR_PALETTE.inkDim);
if (on) this.rect.setInteractive({ useHandCursor: true });
else this.rect.disableInteractive();
return this;
}
setLabel(s: string): this {
this.label.setText(s);
return this;
}
setDepth(d: number): this {
this.rect.setDepth(d);
this.label.setDepth(d + 1);
return this;
}
destroy(): void {
this.rect.destroy();
this.label.destroy();
}
}
/** Horizontal meter bar with a label. Used for vibe/aggro up top. */
export class MeterBar {
private readonly fillRect: Phaser.GameObjects.Rectangle;
private readonly caption: Phaser.GameObjects.Text;
constructor(
scene: Phaser.Scene,
private readonly x: number,
y: number,
private readonly w: number,
private readonly label: string,
private readonly colour: number,
) {
scene.add.rectangle(x + w / 2, y + 3, w, 6, 0x000000).setStrokeStyle(1, 0x333333);
this.fillRect = scene.add.rectangle(x, y + 3, w, 5, colour).setOrigin(0, 0.5);
this.caption = text(scene, x, y - 8, label, 7, DOOR_PALETTE.inkDim);
}
set(value01: number, danger = false): void {
const v = Math.max(0, Math.min(1, value01));
this.fillRect.width = Math.max(1, this.w * v);
this.fillRect.x = this.x;
this.fillRect.setFillStyle(danger ? DOOR_PALETTE.redHot : this.colour);
this.caption.setText(`${this.label} ${Math.round(v * 100)}`);
this.caption.setColor(danger ? '#ff8080' : DOOR_PALETTE.inkDim);
}
}

View File

@ -0,0 +1,429 @@
import Phaser from 'phaser';
import { EventBus } from '../../core/EventBus';
import { GameClock } from '../../core/GameClock';
import { SeededRNG } from '../../core/SeededRNG';
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 { 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 { freshPlayer, stepPlayer, type PlayerInput, type PlayerState } from './player';
import { FloorView } from './FloorView';
import { maggotOverdue } from './escalation';
import { layoutPockets, type Pocket } from './patDown';
import { CutOffOverlay } from './overlays/CutOffOverlay';
import { StallOverlay } from './overlays/StallOverlay';
import { PatDownOverlay } from './overlays/PatDownOverlay';
import { HELP_TEXT, NO_STAMP_RADIO } from '../../data/strings/floor';
const VIEW_H = 360;
const INTERACT_RANGE = 46;
const MAX_CROWD = 44;
const SPAWN_EVERY_MS = 420;
/** What pressing E on the current target would do. */
type InteractKind = 'drunk' | 'stall' | 'contraband' | 'noStamp';
interface InteractTarget {
kind: InteractKind;
agent?: Agent;
stallId?: string;
prompt: string;
}
/**
* LANE-FLOOR demo: a populated venue you patrol in the dark, with all three v0.2
* infractions plus the UV stamp check live. Self-populates from the patron
* generator; Phase 2 integration will hand it the real admitted-patron list
* instead, which is why everything downstream only ever sees Patron[].
*/
export class FloorDemoScene extends Phaser.Scene {
private bus!: EventBus;
private rng!: SeededRNG;
private clock!: GameClock;
private beat!: StubBeatClock;
private meters!: Meters;
private night!: NightState;
private hud!: HudStub;
private view!: FloorView;
private crowd!: CrowdSim;
private player!: PlayerState;
private cutOff!: CutOffOverlay;
private stall!: StallOverlay;
private patDown!: PatDownOverlay;
private keys!: Record<string, Phaser.Input.Keyboard.Key>;
private uvMode = false;
private seed = 4207;
private spawnAccMs = 0;
private escorting: Agent | null = null;
private target: InteractTarget | null = null;
/** 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 promptText!: Phaser.GameObjects.Text;
private statusText!: Phaser.GameObjects.Text;
private radioText!: Phaser.GameObjects.Text;
private radioMs = 0;
constructor() {
super('FloorDemo');
}
create(): void {
this.cameras.main.setBounds(0, 0, VENUE.width * 16, VENUE.height * 16);
this.cameras.main.setBackgroundColor('#04040a');
this.view = new FloorView(this, VENUE);
this.cutOff = new CutOffOverlay(this);
this.patDown = new PatDownOverlay(this);
this.promptText = this.fixedText(4, VIEW_H - 24, '#9fe8a0');
this.statusText = this.fixedText(4, VIEW_H - 12, '#556');
this.radioText = this.fixedText(4, 26, '#d8a020');
this.fixedText(4, 14, '#445').setText(HELP_TEXT);
this.keys = this.input.keyboard!.addKeys(
'W,A,S,D,UP,DOWN,LEFT,RIGHT,E,TAB,SPACE,ESC,R',
) as Record<string, Phaser.Input.Keyboard.Key>;
// Overlays own no keyboard listeners of their own — the scene routes keys so
// exactly one thing consumes input at a time.
this.input.keyboard!.on('keydown', (ev: KeyboardEvent) => this.onKey(ev));
this.resetRun(this.seed);
}
private fixedText(x: number, y: number, colour: string): Phaser.GameObjects.Text {
return this.add
.text(x, y, '', { fontFamily: 'monospace', fontSize: '8px', color: colour })
.setScrollFactor(0)
.setDepth(4000);
}
private resetRun(seed: number): void {
this.seed = seed;
_resetPatronSerial();
this.crowd?.destroy();
this.stall?.destroy();
this.bus?.removeAll();
this.meters?.destroy();
this.hud?.destroy();
this.bus = new EventBus();
this.rng = new SeededRNG(seed);
this.clock = new GameClock(this.bus);
this.beat = new StubBeatClock(this.bus);
this.night = freshNightState('voltage', 0, 200);
this.night.location = 'floor';
this.meters = new Meters(this.bus, this.night);
this.hud = new HudStub(this, this.bus);
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.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.escorting = null;
this.target = null;
this.uvMode = false;
this.spawnAccMs = 0;
this.maggotSeen.clear();
this.spotted.clear();
this.clock.start();
this.beat.start();
}
// ---- input -------------------------------------------------------------
private onKey(ev: KeyboardEvent): void {
const key = ev.key === ' ' ? 'SPACE' : ev.key.toUpperCase();
if (this.cutOff.isOpen) return this.cutOff.handleKey(key);
if (this.stall.isOpen) return this.stall.handleKey(key);
if (this.patDown.isOpen) return this.patDown.handleKey(key);
if (key === 'TAB') {
ev.preventDefault();
this.uvMode = !this.uvMode;
return;
}
if (key === 'R') return this.resetRun(this.seed + 1);
if (key === 'E') return this.interact();
}
private get overlayOpen(): boolean {
return this.cutOff.isOpen || this.stall.isOpen || this.patDown.isOpen;
}
private readInput(): PlayerInput {
const k = this.keys;
const down = (a?: Phaser.Input.Keyboard.Key, b?: Phaser.Input.Keyboard.Key) =>
Boolean(a?.isDown || b?.isDown);
return {
up: down(k['W'], k['UP']),
down: down(k['S'], k['DOWN']),
left: down(k['A'], k['LEFT']),
right: down(k['D'], k['RIGHT']),
};
}
// ---- the loop ----------------------------------------------------------
override update(_time: number, deltaMs: number): void {
const dt = Math.min(deltaMs, 50);
// These run REGARDLESS of overlays. The music doesn't stop because you
// opened a cubicle door — and the stall minigame judges against beat:tick,
// so freezing the beat clock behind a modal makes it literally unplayable.
// The night clock keeps running too: time is the one thing you never get back.
this.clock.update(dt);
this.beat.update(dt);
if (this.overlayOpen) {
this.cutOff.update(dt);
this.stall.update(dt);
this.patDown.update(dt);
// World keeps breathing behind the panel, but you can't move while you're
// dealing with someone — that's the point of the mechanic.
this.crowd.update(dt, this.clock.clockMin);
this.view.syncAgents(this.crowd.agents);
return;
}
// Escorting costs you the walk: no interacting, slower going.
const input = this.readInput();
this.player = stepPlayer(this.player, input, dt, VENUE);
this.spawnAccMs += dt;
while (this.spawnAccMs > SPAWN_EVERY_MS && this.crowd.agents.length < MAX_CROWD) {
this.spawnAccMs -= SPAWN_EVERY_MS;
this.spawnOne();
}
this.crowd.update(dt, this.clock.clockMin);
if (this.escorting) this.tickEscort(dt);
this.trackMaggots();
this.target = this.escorting ? null : this.findTarget();
this.cameras.main.centerOn(this.player.x, this.player.y);
this.view.updatePlayer(this.player.x, this.player.y, this.player.facing);
this.view.updateCone(this.coneSpec(), this.uvMode);
this.view.syncAgents(this.crowd.agents);
this.view.setInteractTarget(this.target?.agent ?? null);
this.drawHud(dt);
}
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 };
}
private spawnOne(): void {
const patron = generatePatron(
{ rng: this.rng, nightDate: this.clock.nightDate },
this.clock.clockMin,
);
// Standing in for the door phase: most people got stamped on the way in.
// The ones who didn't snuck past Kayden — that's the UV infraction.
patron.flags.uvStamped = this.rng.stream('floorStamp').chance(0.86);
this.crowd.spawn(patron);
}
// ---- infraction detection ---------------------------------------------
private trackMaggots(): void {
const now = this.clock.clockMin;
for (const a of this.crowd.agents) {
if (a.gone || a.handled) continue;
if (drunkStage(a.patron.intoxication) !== 'maggot') continue;
const seen = this.maggotSeen.get(a.patron.id);
if (seen === undefined) {
this.maggotSeen.set(a.patron.id, now);
this.spot(a.patron.id, 'drunk');
} else if (maggotOverdue(seen, now)) {
this.maggotSeen.set(a.patron.id, now); // re-arm so it logs once per grace period
this.logIncident('maggotUnhandled', a.patron.id, 'Left on the floor past the grace period.');
}
}
}
private spot(patronId: string, kind: 'drunk' | 'stall' | 'contraband' | 'noStamp'): void {
const key = `${patronId}:${kind}`;
if (this.spotted.has(key)) return;
this.spotted.add(key);
this.bus.emit('floor:infractionSpotted', { patronId, kind });
}
private findTarget(): InteractTarget | null {
const spec = this.coneSpec();
// Stall doors first — you interact with the door, not a person.
for (const s of VENUE.stalls) {
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, s.door.x, s.door.y);
if (d > INTERACT_RANGE) continue;
const occupants = this.crowd.stallOccupancy().get(s.id) ?? [];
if (occupants.length < 2) continue;
this.spot(s.id, 'stall');
return { kind: 'stall', stallId: s.id, prompt: 'E — two pairs of feet under that door' };
}
let best: Agent | null = null;
let bestDist = INTERACT_RANGE;
for (const a of this.crowd.agents) {
if (a.gone || a.handled || a.activity === 'inStall' || a.activity === 'escorted') continue;
if (!inCone(spec, a.x, a.y)) continue;
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, a.x, a.y);
if (d < bestDist) {
bestDist = d;
best = a;
}
}
if (!best) return null;
// UV mode is a different lens on the same crowd: it only surfaces stamps.
if (this.uvMode) {
if (best.patron.flags.uvStamped) return null;
this.spot(best.patron.id, 'noStamp');
return { kind: 'noStamp', agent: best, prompt: 'E — no stamp. Walk them back to the door' };
}
const stage = drunkStage(best.patron.intoxication);
if (stage === 'messy' || stage === 'maggot' || stage === 'loose') {
return { kind: 'drunk', agent: best, prompt: 'E — have a word' };
}
if (best.patron.flags.contraband?.length) {
this.spot(best.patron.id, 'contraband');
return { kind: 'contraband', agent: best, prompt: 'E — pat them down' };
}
return null;
}
// ---- interactions ------------------------------------------------------
private interact(): void {
const t = this.target;
if (!t || this.escorting) return;
if (t.kind === 'stall' && t.stallId) return this.openStall(t.stallId);
if (!t.agent) return;
if (t.kind === 'noStamp') return this.startEscort(t.agent, 'clean', 'noStamp');
if (t.kind === 'drunk') return this.openCutOff(t.agent);
if (t.kind === 'contraband') return this.openPatDown(t.agent);
}
private openStall(stallId: string): void {
const occupants = this.crowd.stallOccupancy().get(stallId) ?? [];
this.stall.open(
{ stallId, patrons: occupants.map((a) => a.patron) },
(r) => {
this.bus.emit('meters:delta', { vibe: r.vibeDelta, aggro: r.aggroDelta });
if (r.solved) {
// 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) {
a.handled = true;
a.dwellMs = 0;
}
this.logIncident('stallBusted', occupants[0]?.patron.id, 'Two out of one cubicle.');
}
},
);
}
private openCutOff(agent: Agent): void {
const stage = drunkStage(agent.patron.intoxication);
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;
if (r.eject) {
this.startEscort(agent, r.outcome === 'clean' ? 'clean' : 'messy', 'drunk');
} else {
this.logIncident('cutOff', agent.patron.id, `${stage}${r.outcome}`);
}
});
}
private openPatDown(agent: Agent): void {
const pockets: Pocket[] = layoutPockets(agent.patron, this.rng.stream('patdown'));
this.patDown.open({ patron: agent.patron, pockets }, (r) => {
this.bus.emit('meters:delta', { vibe: r.vibeDelta, aggro: r.aggroDelta });
agent.handled = true;
this.logIncident('patDown', agent.patron.id, r.summary);
if (r.eject) this.startEscort(agent, 'clean', 'contraband');
});
}
private startEscort(agent: Agent, style: 'clean' | 'messy', kind: InteractKind): void {
if (!this.crowd.beginEscort(agent)) return;
agent.handled = true;
this.escorting = agent;
if (kind === 'noStamp') {
this.radio(NO_STAMP_RADIO[this.rng.stream('radio').int(0, NO_STAMP_RADIO.length - 1)] ?? '');
}
this.bus.emit('floor:ejection', { patronId: agent.patron.id, style });
this.logIncident('ejection', agent.patron.id, `${kind} / ${style}`);
}
private tickEscort(dt: number): void {
const a = this.escorting;
if (!a) return;
this.crowd.updateEscorts(this.player.x, this.player.y, dt);
if (a.gone) {
this.escorting = null;
this.bus.emit('meters:delta', { vibe: 1 });
}
}
// ---- chrome ------------------------------------------------------------
private radio(line: string): void {
this.radioText.setText(line ? `KAYDEN: ${line}` : '');
this.radioMs = 5000;
}
private logIncident(kind: string, patronId: string | undefined, detail: string): void {
this.bus.emit('incident:log', { clockMin: this.clock.clockMin, kind, patronId, detail });
}
private drawHud(dt: number): void {
if (this.radioMs > 0) {
this.radioMs -= dt;
if (this.radioMs <= 0) this.radioText.setText('');
}
this.promptText.setText(
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}`,
);
}
shutdown(): void {
this.crowd?.destroy();
this.view?.destroy();
this.cutOff?.destroy();
this.stall?.destroy();
this.patDown?.destroy();
this.meters?.destroy();
this.hud?.destroy();
}
}

View File

@ -0,0 +1,301 @@
import Phaser from 'phaser';
import { renderDoll } from '../../patrons/doll';
import { dollPlan } from '../../patrons/dollPlan';
import { conePolygon, type ConeSpec } from './cone';
import { TILE, tileAt, tileColour, type VenueMap } from './venueMap';
import type { Agent } from './crowdSim';
// Pure drawing for the floor. Darkness is the mechanic: the venue is painted
// once into a flat texture, buried under a black sheet, and the torch cone is
// the only thing that puts colour back on a punter's face.
// Phaser Light2D is deliberately NOT used — the pipeline swap fights the
// geometry mask that drives the reveal, and the mask is the gameplay.
const TILES_TEX = 'floor:tiles';
const GLOW_TEX = 'floor:glow';
const PLAYER_TEX = 'floor:player';
const MARKER_TEX = 'floor:marker';
const D_TILES = 0;
const D_SIL = 10;
const D_DARK = 15;
const D_CONE = 16;
const D_NEON = 17;
const D_DETAIL = 20;
const D_MARKER = 40;
// A body in the dark should read as a presence, not vanish — the cone supplies
// identity (who, what they're wearing, how drunk), never the fact of them.
const SIL_TINT = 0x3d3d55;
const CONE_WARM = 0xffe0a8;
const CONE_UV = 0x7b2fd0;
const UV_STAMP = 0x50ff90;
const NEON_TINTS = [0xff3d9a, 0x38e0ff, 0xb45cff] as const;
const GLOW_SIZE = 64;
interface AgentGfx {
sil: Phaser.GameObjects.Image;
detail: Phaser.GameObjects.Image;
uvDot: Phaser.GameObjects.Image;
swayPx: number;
intox: number;
phase: number; // sway offset from dollSeed, so two neighbours don't wobble in lockstep
stamp: number;
}
export class FloorView {
private readonly scene: Phaser.Scene;
private readonly map: VenueMap;
private readonly tileImg: Phaser.GameObjects.Image;
private readonly neon: Phaser.GameObjects.Image[] = [];
private readonly dark: Phaser.GameObjects.Rectangle;
private readonly coneG: Phaser.GameObjects.Graphics;
private readonly maskG: Phaser.GameObjects.Graphics;
private readonly mask: Phaser.Display.Masks.GeometryMask;
private readonly detailC: Phaser.GameObjects.Container;
private readonly playerLight: Phaser.GameObjects.Image;
private readonly playerImg: Phaser.GameObjects.Image;
private readonly marker: Phaser.GameObjects.Image;
private readonly agents = new Map<string, AgentGfx>();
private frame = 0;
private uv = false;
constructor(scene: Phaser.Scene, map: VenueMap) {
this.scene = scene;
this.map = map;
const worldW = map.width * TILE;
const worldH = map.height * TILE;
this.bakeGlow();
this.bakePlayer();
this.bakeMarker();
this.bakeTiles(worldW, worldH);
this.tileImg = scene.add.image(0, 0, TILES_TEX).setOrigin(0, 0).setDepth(D_TILES);
this.dark = scene.add
.rectangle(0, 0, worldW, worldH, 0x000000, 0.5)
.setOrigin(0, 0)
.setDepth(D_DARK);
this.coneG = scene.add.graphics().setDepth(D_CONE);
this.coneG.setBlendMode(Phaser.BlendModes.ADD);
this.playerLight = scene.add
.image(0, 0, GLOW_TEX)
.setDepth(D_CONE)
.setBlendMode(Phaser.BlendModes.ADD)
.setTint(0xffd9a0)
.setAlpha(0.35)
.setScale(1.1);
// Neon lives above the black sheet so the room has colour pools you can
// navigate by with the torch off.
for (let ty = 0; ty < map.height; ty++) {
for (let tx = 0; tx < map.width; tx++) {
if (tileAt(map, tx, ty) !== 'neon') continue;
const tint = NEON_TINTS[(tx * 7 + ty * 3) % NEON_TINTS.length] ?? NEON_TINTS[0];
this.neon.push(
scene.add
.image(tx * TILE + TILE / 2, ty * TILE + TILE / 2, GLOW_TEX)
.setDepth(D_NEON)
.setBlendMode(Phaser.BlendModes.ADD)
.setTint(tint)
.setAlpha(0.72)
.setScale(3),
);
}
}
this.maskG = scene.make.graphics({}, false);
this.mask = this.maskG.createGeometryMask();
this.detailC = scene.add.container(0, 0).setDepth(D_DETAIL);
this.detailC.setMask(this.mask);
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);
}
syncAgents(agents: readonly Agent[]): void {
this.frame++;
const t = this.scene.time.now;
for (const a of agents) {
if (a.gone) {
this.drop(a.patron.id);
continue;
}
let g = this.agents.get(a.patron.id);
if (!g) {
g = this.create(a);
this.agents.set(a.patron.id, g);
}
g.stamp = this.frame;
// Drunk tells are baked into the doll texture, so a punter who has kept
// drinking needs a fresh one — but only when they actually have.
if (Math.abs(g.intox - a.patron.intoxication) > 1e-3) {
g.intox = a.patron.intoxication;
const key = renderDoll(this.scene, a.patron, 'floorTopDown');
g.swayPx = dollPlan(a.patron, 'floorTopDown').swayPx;
g.sil.setTexture(key);
g.detail.setTexture(key);
}
const bob = a.activity === 'dancing' ? 2.5 : 0.8;
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);
g.detail.setVisible(!this.uv);
// No stamp, no dot — the absence is the tell.
g.uvDot.setVisible(this.uv && a.patron.flags.uvStamped === true);
}
for (const [id, g] of this.agents) {
if (g.stamp !== this.frame) {
this.destroyGfx(g);
this.agents.delete(id);
}
}
}
updateCone(spec: ConeSpec, uv: boolean): void {
this.uv = uv;
const pts = conePolygon(spec);
this.maskG.clear();
this.maskG.fillStyle(0xffffff, 1);
this.maskG.fillPoints(pts, true);
// Wide wash plus a hotter core, so the beam reads as a beam and not a wedge
// of flat colour.
const tint = uv ? CONE_UV : CONE_WARM;
this.coneG.clear();
this.coneG.fillStyle(tint, uv ? 0.16 : 0.2);
this.coneG.fillPoints(pts, true);
this.coneG.fillStyle(tint, uv ? 0.12 : 0.18);
this.coneG.fillPoints(conePolygon({ ...spec, range: spec.range * 0.55 }, 16), true);
this.playerLight.setTint(uv ? CONE_UV : 0xffd9a0);
}
updatePlayer(x: number, y: number, facing: number): void {
this.playerImg.setPosition(x, y).setRotation(facing);
this.playerLight.setPosition(x, y);
}
setInteractTarget(agent: Agent | null): void {
if (!agent) {
this.marker.setVisible(false);
return;
}
this.marker.setVisible(true).setPosition(agent.x, agent.y - 12);
this.marker.setAlpha(0.55 + 0.45 * Math.abs(Math.sin(this.scene.time.now / 260)));
}
destroy(): void {
for (const g of this.agents.values()) this.destroyGfx(g);
this.agents.clear();
this.detailC.clearMask(true);
this.detailC.destroy();
this.maskG.destroy();
this.coneG.destroy();
this.dark.destroy();
this.tileImg.destroy();
for (const n of this.neon) n.destroy();
this.neon.length = 0;
this.playerLight.destroy();
this.playerImg.destroy();
this.marker.destroy();
}
private create(a: Agent): AgentGfx {
const key = renderDoll(this.scene, a.patron, 'floorTopDown');
const sil = this.scene.add.image(a.x, a.y, key).setDepth(D_SIL).setTint(SIL_TINT);
const detail = this.scene.add.image(a.x, a.y, key);
const uvDot = this.scene.add
.image(a.x, a.y, GLOW_TEX)
.setBlendMode(Phaser.BlendModes.ADD)
.setTint(UV_STAMP)
.setScale(0.16)
.setVisible(false);
this.detailC.add(detail);
this.detailC.add(uvDot);
return {
sil,
detail,
uvDot,
swayPx: dollPlan(a.patron, 'floorTopDown').swayPx,
intox: a.patron.intoxication,
phase: (a.patron.dollSeed % 628) / 100,
stamp: this.frame,
};
}
private drop(id: string): void {
const g = this.agents.get(id);
if (!g) return;
this.destroyGfx(g);
this.agents.delete(id);
}
private destroyGfx(g: AgentGfx): void {
g.sil.destroy();
g.detail.destroy();
g.uvDot.destroy();
}
private bakeTiles(worldW: number, worldH: number): void {
if (this.scene.textures.exists(TILES_TEX)) return;
const g = this.scene.make.graphics({}, false);
for (let ty = 0; ty < this.map.height; ty++) {
for (let tx = 0; tx < this.map.width; tx++) {
g.fillStyle(tileColour(tileAt(this.map, tx, ty)), 1);
g.fillRect(tx * TILE, ty * TILE, TILE, TILE);
}
}
g.generateTexture(TILES_TEX, worldW, worldH);
g.destroy();
}
private bakeGlow(): void {
if (this.scene.textures.exists(GLOW_TEX)) return;
const g = this.scene.make.graphics({}, false);
const c = GLOW_SIZE / 2;
// Stacked translucent discs — cheap radial falloff without a canvas gradient.
for (let i = 16; i >= 1; i--) {
g.fillStyle(0xffffff, 0.055);
g.fillCircle(c, c, (i / 16) * c);
}
g.generateTexture(GLOW_TEX, GLOW_SIZE, GLOW_SIZE);
g.destroy();
}
private bakePlayer(): void {
if (this.scene.textures.exists(PLAYER_TEX)) return;
const g = this.scene.make.graphics({}, false);
g.fillStyle(0x0a0a10, 1);
g.fillCircle(7, 7, 5);
g.fillStyle(0xdfe6ff, 1);
g.fillCircle(7, 7, 4);
g.fillRect(7, 6, 7, 3); // torch nose, points +x so rotation == facing
g.generateTexture(PLAYER_TEX, 14, 14);
g.destroy();
}
private bakeMarker(): void {
if (this.scene.textures.exists(MARKER_TEX)) return;
const g = this.scene.make.graphics({}, false);
g.fillStyle(0xffd23f, 1);
g.fillTriangle(0, 0, 8, 0, 4, 6);
g.generateTexture(MARKER_TEX, 8, 6);
g.destroy();
}
}

View File

@ -0,0 +1,76 @@
export interface BeatTick {
beatIndex: number;
audioTimeMs: number;
}
export type BangGrade = 'perfect' | 'ok' | 'miss';
export const PERFECT_MS = 60;
export const OK_MS = 130;
export const BANGS_TO_BUST = 3;
const AGGRO_PER_MISS = 0.8;
const AGGRO_CAP = 6;
const VIBE_BUSTED = 9;
const VIBE_FIZZLED = -1.5;
/**
* How far (ms, absolute) a bang landed from the NEAREST beat boundary implied by
* lastTick. Boundaries are lastTick.audioTimeMs + k*beatIntervalMs for integer k
* (k may be negative a bang can land just BEFORE the tick it is judged against).
*/
export function beatError(bangTimeMs: number, lastTick: BeatTick, beatIntervalMs: number): number {
const delta = bangTimeMs - lastTick.audioTimeMs;
// Degenerate clock: every point is its own boundary, so the tick is all we have.
if (!(beatIntervalMs > 0)) return Math.abs(delta);
const nearest = Math.round(delta / beatIntervalMs) * beatIntervalMs;
return Math.abs(delta - nearest);
}
export function judgeBang(bangTimeMs: number, lastTick: BeatTick, beatIntervalMs: number): BangGrade {
const err = beatError(bangTimeMs, lastTick, beatIntervalMs);
if (err <= PERFECT_MS) return 'perfect';
if (err <= OK_MS) return 'ok';
return 'miss';
}
export interface StallState {
combo: number;
bangs: number;
busted: boolean;
aggro: number;
lastGrade: BangGrade | null;
}
export function freshStall(): StallState {
return { combo: 0, bangs: 0, busted: false, aggro: 0, lastGrade: null };
}
/** Pure: returns the NEXT state, never mutates the input. */
export function applyBang(s: StallState, grade: BangGrade): StallState {
const hit = grade !== 'miss';
const combo = hit ? s.combo + 1 : 0;
return {
combo,
bangs: s.bangs + 1,
// Once the door gives, it stays given.
busted: s.busted || combo >= BANGS_TO_BUST,
aggro: hit ? s.aggro : s.aggro + AGGRO_PER_MISS,
lastGrade: grade,
};
}
export interface StallOutcome {
vibeDelta: number;
aggroDelta: number;
solved: boolean;
}
/** Final scoring when the stall minigame ends (busted, or the player walked away). */
export function scoreStall(s: StallState): StallOutcome {
return {
vibeDelta: s.busted ? VIBE_BUSTED : VIBE_FIZZLED,
aggroDelta: Math.min(s.aggro, AGGRO_CAP),
solved: s.busted,
};
}

65
src/scenes/floor/cone.ts Normal file
View File

@ -0,0 +1,65 @@
// Flashlight maths. Pure — the scene turns these numbers into a mask, the AI
// uses them to know whether it's being watched.
import type { WorldPoint } from './venueMap';
export interface ConeSpec {
x: number; y: number; // world px, cone apex (the player)
facing: number; // radians
halfAngle: number; // radians
range: number; // world px
}
export const NORMAL_CONE = { halfAngle: 0.55, range: 190 } as const; // ~63 deg wide
export const UV_CONE = { halfAngle: 0.42, range: 140 } as const; // tighter, shorter
const TAU = Math.PI * 2;
/** Shortest signed angular difference a-b, wrapped to (-PI, PI]. */
export function angleDelta(a: number, b: number): number {
let d = (a - b) % TAU;
if (d <= -Math.PI) d += TAU;
else if (d > Math.PI) d -= TAU;
return d;
}
export function inCone(spec: ConeSpec, x: number, y: number): boolean {
const dx = x - spec.x;
const dy = y - spec.y;
const dist = Math.hypot(dx, dy);
if (dist > spec.range) return false;
if (dist === 0) return true; // standing on the torch
return Math.abs(angleDelta(Math.atan2(dy, dx), spec.facing)) <= spec.halfAngle;
}
/**
* 0 = invisible, 1 = fully lit. Falls off toward the cone edge AND with distance.
* Must return 0 outside the cone and >0 strictly inside it.
*/
export function coneVisibility(spec: ConeSpec, x: number, y: number): number {
const dx = x - spec.x;
const dy = y - spec.y;
const dist = Math.hypot(dx, dy);
if (dist > spec.range) return 0;
if (dist === 0) return 1;
const off = Math.abs(angleDelta(Math.atan2(dy, dx), spec.facing));
if (off > spec.halfAngle) return 0;
const t = spec.halfAngle === 0 ? 0 : off / spec.halfAngle;
const d = spec.range === 0 ? 0 : dist / spec.range;
return (1 - t * t) * (1 - d * d);
}
/** Polygon points (world px) approximating the cone, for a Phaser mask/graphics fill. */
export function conePolygon(spec: ConeSpec, segments = 24): WorldPoint[] {
const steps = Math.max(1, Math.floor(segments));
const apex: WorldPoint = { x: spec.x, y: spec.y };
const pts: WorldPoint[] = [apex];
const start = spec.facing - spec.halfAngle;
const span = spec.halfAngle * 2;
for (let i = 0; i <= steps; i++) {
const a = start + (span * i) / steps;
pts.push({ x: spec.x + Math.cos(a) * spec.range, y: spec.y + Math.sin(a) * spec.range });
}
pts.push({ ...apex }); // explicit close so callers can stroke it without fixing up
return pts;
}

View File

@ -0,0 +1,574 @@
// The living crowd. Renderer-agnostic on purpose: this owns where everyone is
// and what they're doing, the Phaser layer just draws whatever it finds here.
import type { EventBus } from '../../core/EventBus';
import type { RngStream } from '../../core/SeededRNG';
import type { Archetype, DrunkStage, Patron } from '../../data/types';
import { drunkStage } from '../../rules/drunk';
import { isWalkable, isWalkableWorld, tileToWorld, worldToTile } from './venueMap';
import type { StallDef, VenueMap, WorldPoint } from './venueMap';
export type Activity =
| 'walking' | 'atBar' | 'dancing' | 'inBooth' | 'inStall' | 'smoking' | 'escorted' | 'leaving';
export interface Agent {
patron: Patron;
x: number; y: number;
vx: number; vy: number;
activity: Activity;
target: WorldPoint;
dwellMs: number; // ms left at the current anchor
bobPhase: number; // 0..1, advanced on beat:tick, drives the dance bob
stallId?: string; // set while activity === 'inStall'
handled: boolean; // player has already dealt with this one
gone: boolean; // reached the exit / left the sim; scene should drop the sprite
lastBumpMs: number; // cooldown so bumping can't spam meters
}
export interface CrowdSimOpts {
map: VenueMap;
bus: EventBus;
rng: RngStream;
}
type Haunt = 'bar' | 'dance' | 'booth' | 'toilet' | 'smoke';
const HAUNT_KINDS: readonly Haunt[] = ['bar', 'dance', 'booth', 'toilet', 'smoke'];
// Where each sort of punter gravitates. Weights, not probabilities — every
// archetype needs an entry or the crowd stops moving.
const HAUNTS: Readonly<Record<Archetype, Readonly<Record<Haunt, number>>>> = {
fresh18: { bar: 3, dance: 6, booth: 2, toilet: 2, smoke: 1 },
almost18: { bar: 2, dance: 5, booth: 3, toilet: 2, smoke: 2 },
financeBro: { bar: 8, dance: 2, booth: 3, toilet: 2, smoke: 1 },
influencer: { bar: 2, dance: 8, booth: 2, toilet: 2, smoke: 1 },
regular: { bar: 6, dance: 2, booth: 3, toilet: 1, smoke: 4 },
ownersMate: { bar: 7, dance: 2, booth: 5, toilet: 1, smoke: 2 },
quietMessy: { bar: 3, dance: 1, booth: 7, toilet: 3, smoke: 2 },
inspector: { bar: 2, dance: 1, booth: 4, toilet: 2, smoke: 1 },
punter: { bar: 4, dance: 4, booth: 3, toilet: 2, smoke: 2 },
};
const ACTIVITY_FOR: Readonly<Record<Haunt, Activity>> = {
bar: 'atBar',
dance: 'dancing',
booth: 'inBooth',
toilet: 'inStall',
smoke: 'smoking',
};
const SPEED_MIN = 28;
const SPEED_MAX = 44;
// Transits across a 1280x720 venue take ~30s at these speeds, so short dwells
// left ~68% of the crowd permanently walking and the bar/dance floor reading as
// empty. The player has to be able to FIND infractions where they'd expect them.
const DWELL_MIN_MS = 7000;
const DWELL_MAX_MS = 22_000;
const ARRIVE_PX = 2.5;
const WAYPOINT_PX = 9; // half a tile — loose enough that a drunk sway can't orbit one
const MAX_STEP_MS = 50;
const STUCK_MS = 700;
// Intoxication per in-game minute for a patron with zero tolerance. At the bar
// you're actively buying; everywhere else you're nursing what you've got.
const BAR_DRIFT_PER_MIN = 0.011;
const AMBIENT_DRIFT_PER_MIN = 0.0012;
const SWAY_BY_STAGE: Readonly<Record<DrunkStage, number>> = {
sober: 0, tipsy: 4, loose: 10, messy: 18, maggot: 26,
};
const SWAY_RATE = 0.006; // radians per ms — roughly a one-second wobble
const STUMBLE_CHANCE_PER_SEC = 0.5;
const STUMBLE_MIN_MS = 180;
const STUMBLE_MAX_MS = 520;
const STUMBLE_ARC = 0.9; // worst heading error, radians
const BUMP_DIST_PX = 10;
const BUMP_COOLDOWN_MS = 12_000;
// Per-agent cooldown alone doesn't bound the LEAK — the emission rate scales
// with how many rowdy patrons are on the floor, so a full late-night venue
// pinned vibe to 0 and aggro to 100 by ~1AM with the player doing nothing.
// This gate makes ambient chaos cost a roughly fixed amount per night
// regardless of crowd size; the player's own mistakes stay legible against it.
const BUMP_GATE_MS = 8000;
const BUMP_VIBE = -0.4;
const BUMP_AGGRO = 0.6;
// Chance a dunny-bound patron shoulders into a cubicle that's already busy.
const STALL_SHARE_CHANCE = 0.3;
const STALL_CAP = 2;
const ESCORT_SPEED = 22; // the walk of a bloke who knows it's over
const ESCORT_AHEAD_PX = 18; // they only shuffle on while you're this close behind
const BOB_STEP = 0.25; // four beats to a full bob, i.e. one bar
const NEIGHBOURS = [[1, 0], [-1, 0], [0, 1], [0, -1]] as const;
interface Plan {
haunt: Haunt;
stallId?: string; // reserved while walking, so the cap survives the trip over
path: WorldPoint[]; // detour waypoints; empty means steer straight at the target
bestDist: number; // closest approach to the current steer point so far
stuckMs: number;
stumbleMs: number;
stumbleAngle: number;
}
function rowdy(a: Agent): boolean {
const s = drunkStage(a.patron.intoxication);
return s === 'messy' || s === 'maggot';
}
/** Deterministic per-patron pace — same doll, same dawdle, every run. */
function paceOf(patron: Patron): number {
const span = SPEED_MAX - SPEED_MIN + 1;
return SPEED_MIN + (Math.abs(patron.dollSeed) % span);
}
export class CrowdSim {
readonly agents: Agent[] = [];
private readonly map: VenueMap;
private readonly bus: EventBus;
private readonly rng: RngStream;
private readonly plans = new WeakMap<Agent, Plan>();
private elapsedMs = 0;
private prevClockMin = Number.NaN;
private bumpGateMs = BUMP_GATE_MS;
constructor(opts: CrowdSimOpts) {
this.map = opts.map;
this.bus = opts.bus;
this.rng = opts.rng;
}
spawn(patron: Patron): Agent {
const entries = this.map.anchors.entry;
const at = entries.length > 0 ? this.rng.pick(entries) : { x: 0, y: 0 };
const agent: Agent = {
patron,
x: at.x,
y: at.y,
vx: 0,
vy: 0,
activity: 'walking',
target: { x: at.x, y: at.y },
dwellMs: 0,
bobPhase: (Math.abs(patron.dollSeed) % 100) / 100,
handled: false,
gone: false,
lastBumpMs: BUMP_COOLDOWN_MS,
};
this.plans.set(agent, {
haunt: 'bar', path: [], bestDist: Infinity, stuckMs: 0, stumbleMs: 0, stumbleAngle: 0,
});
this.agents.push(agent);
this.chooseTarget(agent);
return agent;
}
/** deltaMs of real time; clockMin is the in-game minute (drives intoxication drift). */
update(deltaMs: number, clockMin: number): void {
const ms = Math.max(0, Math.min(deltaMs, MAX_STEP_MS));
this.elapsedMs += ms;
this.bumpGateMs += ms;
this.drift(clockMin);
for (const a of this.agents) {
if (a.gone) continue;
a.lastBumpMs += ms;
if (a.activity === 'escorted') continue;
if (a.activity === 'walking') this.walk(a, ms);
else this.dwell(a, ms);
}
this.bumps();
}
/** Call from a bus.on('beat:tick') handler. Advances dance bob only. */
onBeat(beatIndex: number): void {
// The downbeat lands harder than the rest of the bar.
const step = beatIndex % 4 === 0 ? BOB_STEP * 1.5 : BOB_STEP;
for (const a of this.agents) {
if (a.gone || a.activity !== 'dancing') continue;
a.bobPhase = (a.bobPhase + step) % 1;
}
}
/** Agents currently inside each stall, keyed by stall id. */
stallOccupancy(): Map<string, Agent[]> {
const out = new Map<string, Agent[]>();
for (const s of this.map.stalls) out.set(s.id, []);
for (const a of this.agents) {
if (a.gone || a.activity !== 'inStall' || a.stallId === undefined) continue;
const list = out.get(a.stallId);
if (list) list.push(a);
else out.set(a.stallId, [a]);
}
return out;
}
/** Attach to the player for a walk-to-the-exit escort. Returns false if already escorted. */
beginEscort(agent: Agent): boolean {
if (agent.gone || agent.activity === 'escorted' || agent.activity === 'leaving') return false;
const exit = this.nearestExit(agent.x, agent.y);
if (!exit) return false;
if (agent.activity === 'inStall') {
const stall = this.stallById(agent.stallId);
if (stall) {
agent.x = stall.door.x;
agent.y = stall.door.y;
}
}
agent.stallId = undefined;
agent.activity = 'escorted';
agent.handled = true;
agent.dwellMs = 0;
agent.target = exit;
const plan = this.plans.get(agent);
if (plan) {
plan.stallId = undefined;
plan.stumbleMs = 0;
plan.bestDist = Infinity;
plan.stuckMs = 0;
// Always route the perp walk — the door is a long way from the dunnies and
// a straight line would wedge them in a corner with you shouting at them.
plan.path = this.findPath(agent.x, agent.y, exit) ?? [];
}
return true;
}
/** Called each frame with the player position so escorted agents walk ahead of you. */
updateEscorts(playerX: number, playerY: number, deltaMs: number): void {
const dt = Math.max(0, Math.min(deltaMs, MAX_STEP_MS)) / 1000;
const step = ESCORT_SPEED * dt;
for (const a of this.agents) {
if (a.gone || a.activity !== 'escorted') continue;
const plan = this.plans.get(a);
if (!plan) continue;
const point = this.steerPoint(a, plan);
const dx = point.x - a.x;
const dy = point.y - a.y;
const dist = Math.hypot(dx, dy);
if (plan.path.length === 0 && dist <= Math.max(ARRIVE_PX, step)) {
a.x = point.x;
a.y = point.y;
a.vx = 0;
a.vy = 0;
a.activity = 'leaving';
a.gone = true;
continue;
}
// They shuffle on only while you're breathing down their neck; stop pushing
// and they stop walking.
if (Math.hypot(playerX - a.x, playerY - a.y) > ESCORT_AHEAD_PX) {
a.vx = 0;
a.vy = 0;
continue;
}
const ux = dx / (dist || 1);
const uy = dy / (dist || 1);
a.vx = ux * ESCORT_SPEED;
a.vy = uy * ESCORT_SPEED;
this.tryMove(a, ux * step, uy * step);
}
}
find(patronId: string): Agent | undefined {
return this.agents.find((a) => a.patron.id === patronId);
}
destroy(): void {
// Nothing to unsubscribe — beat ticks are pushed in by the owning scene.
this.agents.length = 0;
}
// ---- internals ----
private drift(clockMin: number): void {
const prev = this.prevClockMin;
this.prevClockMin = clockMin;
if (Number.isNaN(prev)) return;
const mins = clockMin - prev;
if (mins <= 0) return;
for (const a of this.agents) {
if (a.gone) continue;
const base = a.activity === 'atBar' ? BAR_DRIFT_PER_MIN : AMBIENT_DRIFT_PER_MIN;
a.patron.intoxication = Math.min(1, a.patron.intoxication + base * (1 - a.patron.tolerance) * mins);
}
}
private dwell(a: Agent, ms: number): void {
a.vx = 0;
a.vy = 0;
a.dwellMs -= ms;
if (a.dwellMs > 0) return;
if (a.activity === 'inStall') {
const stall = this.stallById(a.stallId);
if (stall) {
a.x = stall.door.x;
a.y = stall.door.y;
}
a.stallId = undefined;
}
a.dwellMs = 0;
a.activity = 'walking';
this.chooseTarget(a);
}
private walk(a: Agent, ms: number): void {
const plan = this.plans.get(a);
if (!plan) return;
const dt = ms / 1000;
const speed = paceOf(a.patron);
const step = speed * dt;
const point = this.steerPoint(a, plan);
const dx = point.x - a.x;
const dy = point.y - a.y;
const dist = Math.hypot(dx, dy);
if (plan.path.length === 0 && dist <= Math.max(ARRIVE_PX, step)) {
this.arrive(a, plan);
return;
}
// Walked into geometry the straight line can't solve (the dunny room is one
// long concave corner). Fall back to a real route rather than pushing at a wall.
if (dist < plan.bestDist - 0.5) {
plan.bestDist = dist;
plan.stuckMs = 0;
} else {
plan.stuckMs += ms;
if (plan.stuckMs >= STUCK_MS) this.unstick(a, plan);
}
let heading = Math.atan2(dy, dx);
const stage = drunkStage(a.patron.intoxication);
if (plan.stumbleMs > 0) {
plan.stumbleMs -= ms;
heading += plan.stumbleAngle;
} else if ((stage === 'messy' || stage === 'maggot') && this.rng.chance(STUMBLE_CHANCE_PER_SEC * dt)) {
plan.stumbleMs = this.rng.int(STUMBLE_MIN_MS, STUMBLE_MAX_MS);
plan.stumbleAngle = (this.rng.next() * 2 - 1) * STUMBLE_ARC;
heading += plan.stumbleAngle;
}
const ux = Math.cos(heading);
const uy = Math.sin(heading);
// Sway rides perpendicular to travel — the wider the wobble, the drunker the walk.
const sway = Math.sin(this.elapsedMs * SWAY_RATE + a.patron.dollSeed) * SWAY_BY_STAGE[stage];
a.vx = ux * speed - uy * sway;
a.vy = uy * speed + ux * sway;
this.tryMove(a, a.vx * dt, a.vy * dt);
}
/** Current thing to walk at: the next detour waypoint, else the target itself. */
private steerPoint(a: Agent, plan: Plan): WorldPoint {
while (plan.path.length > 0) {
const wp = plan.path[0];
if (!wp) {
plan.path.shift();
continue;
}
if (Math.hypot(wp.x - a.x, wp.y - a.y) > WAYPOINT_PX) return wp;
plan.path.shift();
plan.bestDist = Infinity;
plan.stuckMs = 0;
}
return a.target;
}
private unstick(a: Agent, plan: Plan): void {
plan.stuckMs = 0;
plan.bestDist = Infinity;
const path = this.findPath(a.x, a.y, a.target);
if (path && path.length > 0) plan.path = path;
else this.chooseTarget(a); // nowhere to go from here — find somewhere else to be
}
private arrive(a: Agent, plan: Plan): void {
a.vx = 0;
a.vy = 0;
a.dwellMs = this.rng.int(DWELL_MIN_MS, DWELL_MAX_MS);
if (plan.haunt === 'toilet' && plan.stallId !== undefined) {
const stall = this.stallById(plan.stallId);
if (stall) {
a.activity = 'inStall';
a.stallId = stall.id;
a.x = stall.inside.x;
a.y = stall.inside.y;
return;
}
}
a.x = a.target.x;
a.y = a.target.y;
a.activity = ACTIVITY_FOR[plan.haunt];
}
private chooseTarget(a: Agent): void {
const plan = this.plans.get(a);
if (!plan) return;
plan.stallId = undefined;
plan.path = [];
plan.bestDist = Infinity;
plan.stuckMs = 0;
const weights = HAUNTS[a.patron.archetype];
let haunt = this.rng.weighted(HAUNT_KINDS.map((k) => [k, weights[k]] as const));
if (haunt === 'toilet') {
const stall = this.claimStall();
if (stall) {
plan.haunt = 'toilet';
plan.stallId = stall.id;
a.target = stall.door;
return;
}
haunt = 'bar'; // every dunny's full — go queue for another drink instead
}
const spots = this.map.anchors[haunt];
plan.haunt = haunt;
if (spots.length > 0) a.target = this.rng.pick(spots);
}
/** Reserve a cubicle, occasionally squeezing into one that's already busy. */
private claimStall(): StallDef | undefined {
const counts = new Map<string, number>();
for (const s of this.map.stalls) counts.set(s.id, 0);
const bump = (id: string | undefined): void => {
if (id === undefined) return;
counts.set(id, (counts.get(id) ?? 0) + 1);
};
for (const other of this.agents) {
if (other.gone) continue;
bump(other.stallId);
const p = this.plans.get(other);
if (other.activity === 'walking' && p) bump(p.stallId);
}
const free = this.map.stalls.filter((s) => (counts.get(s.id) ?? 0) === 0);
const busy = this.map.stalls.filter((s) => {
const n = counts.get(s.id) ?? 0;
return n > 0 && n < STALL_CAP;
});
if (busy.length > 0 && (free.length === 0 || this.rng.chance(STALL_SHARE_CHANCE))) {
return this.rng.pick(busy);
}
if (free.length > 0) return this.rng.pick(free);
return undefined;
}
private bumps(): void {
for (let i = 0; i < this.agents.length; i++) {
const a = this.agents[i];
if (!a || a.gone) continue;
for (let j = i + 1; j < this.agents.length; j++) {
const b = this.agents[j];
if (!b || b.gone) continue;
if (Math.hypot(a.x - b.x, a.y - b.y) >= BUMP_DIST_PX) continue;
for (const who of [a, b]) {
if (!rowdy(who) || who.lastBumpMs < BUMP_COOLDOWN_MS) continue;
who.lastBumpMs = 0;
// Still track the individual cooldown above (so one pair grinding
// together can't chain), but only the gate opens the till.
if (this.bumpGateMs < BUMP_GATE_MS) continue;
this.bumpGateMs = 0;
this.bus.emit('meters:delta', { vibe: BUMP_VIBE, aggro: BUMP_AGGRO });
}
}
}
}
private tryMove(a: Agent, dx: number, dy: number): void {
if (this.open(a.x + dx, a.y + dy)) {
a.x += dx;
a.y += dy;
return;
}
if (this.open(a.x + dx, a.y)) a.x += dx; // slide along the wall
else if (this.open(a.x, a.y + dy)) a.y += dy;
}
private open(x: number, y: number): boolean {
return isWalkableWorld(this.map, x, y);
}
/** 4-connected BFS over walkable tiles. Waypoints are tile centres. */
private findPath(fromX: number, fromY: number, to: WorldPoint): WorldPoint[] | undefined {
const w = this.map.width;
const h = this.map.height;
const start = worldToTile(fromX, fromY);
const goal = worldToTile(to.x, to.y);
const startK = start.ty * w + start.tx;
const goalK = goal.ty * w + goal.tx;
if (startK === goalK) return [];
if (!isWalkable(this.map, goal.tx, goal.ty)) return undefined;
const prev = new Int32Array(w * h).fill(-1);
const seen = new Uint8Array(w * h);
const queue = [startK];
seen[startK] = 1;
for (let head = 0; head < queue.length; head++) {
const cur = queue[head] ?? -1;
if (cur === goalK) break;
const cx = cur % w;
const cy = (cur - cx) / w;
for (const [dx, dy] of NEIGHBOURS) {
const nx = cx + dx;
const ny = cy + dy;
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
const k = ny * w + nx;
if (seen[k] === 1 || !isWalkable(this.map, nx, ny)) continue;
seen[k] = 1;
prev[k] = cur;
queue.push(k);
}
}
if (seen[goalK] !== 1) return undefined;
const out: WorldPoint[] = [];
let cur = goalK;
while (cur !== startK) {
const cx = cur % w;
out.push(tileToWorld(cx, (cur - cx) / w));
const p = prev[cur] ?? -1;
if (p < 0) break;
cur = p;
}
out.reverse();
return out;
}
private stallById(id: string | undefined): StallDef | undefined {
if (id === undefined) return undefined;
return this.map.stalls.find((s) => s.id === id);
}
private nearestExit(x: number, y: number): WorldPoint | undefined {
let best: WorldPoint | undefined;
let bestD = Infinity;
for (const e of this.map.anchors.exit) {
const d = Math.hypot(e.x - x, e.y - y);
if (d < bestD) {
bestD = d;
best = e;
}
}
return best;
}
}

View File

@ -0,0 +1,98 @@
// The Cut-Off judgement matrix. Pure — no Phaser, no bus. The scene decides
// what to do with the result; this only decides what the room thinks of you.
import type { DrunkStage } from '../../data/types';
import {
CUTOFF_REPLY_CLEAN,
CUTOFF_REPLY_SCENE,
CUTOFF_REPLY_SOFT,
ESCALATION_LINES,
type EscalationLine,
} from '../../data/strings/floor';
export type EscalationOutcome = 'clean' | 'scene' | 'staggerFree';
export interface EscalationResult {
outcome: EscalationOutcome;
vibeDelta: number;
aggroDelta: number;
/** What you said (from ESCALATION_LINES). */
line: string;
/** What they said back (from CUTOFF_REPLY_*). */
reply: string;
/** Eject: attach them to the escort-out walk. */
eject: boolean;
/** Resolved: stop treating them as an open infraction. */
resolved: boolean;
}
/** Softest first — comparing rungs is the whole judgement. */
const RUNG: Readonly<Record<EscalationLine, number>> = {
water: 0,
done: 1,
escort: 2,
};
/** The correct rung for each drunk stage. */
export const CORRECT_LINE: Readonly<Record<DrunkStage, EscalationLine>> = {
sober: 'water',
tipsy: 'water',
loose: 'water',
messy: 'done',
maggot: 'escort',
};
// Deliberately small. Being right about a drunk shouldn't feel like a jackpot,
// and overreach should sting more than restraint pays (design doc §4.3).
const CLEAN_VIBE = 4;
const SCENE_VIBE_PER_RUNG = -3;
const SCENE_AGGRO_PER_RUNG = 5;
const SOFT_VIBE = -1;
export function judgeEscalation(stage: DrunkStage, line: EscalationLine): EscalationResult {
const said = ESCALATION_LINES[line];
const overreach = RUNG[line] - RUNG[CORRECT_LINE[stage]];
if (overreach === 0) {
return {
outcome: 'clean',
vibeDelta: CLEAN_VIBE,
aggroDelta: 0,
line: said,
reply: CUTOFF_REPLY_CLEAN[stage],
// Water is a kindness, not an exit — only the top two stages walk.
eject: stage === 'messy' || stage === 'maggot',
resolved: true,
};
}
if (overreach > 0) {
return {
outcome: 'scene',
vibeDelta: SCENE_VIBE_PER_RUNG * overreach,
aggroDelta: SCENE_AGGRO_PER_RUNG * overreach,
line: said,
reply: CUTOFF_REPLY_SCENE[stage],
// They're not going anywhere with you now, but the moment is spent.
eject: false,
resolved: true,
};
}
return {
outcome: 'staggerFree',
vibeDelta: SOFT_VIBE,
aggroDelta: 0,
line: said,
reply: CUTOFF_REPLY_SOFT[stage],
eject: false,
resolved: false,
};
}
/** A maggot left alone too long becomes a deferred heat risk. */
export const MAGGOT_GRACE_MIN = 25;
export function maggotOverdue(spottedAtClockMin: number, nowClockMin: number): boolean {
return nowClockMin - spottedAtClockMin > MAGGOT_GRACE_MIN;
}

View File

@ -0,0 +1,216 @@
import Phaser from 'phaser';
import { renderDoll } from '../../../patrons/doll';
import type { DrunkStage, Patron } from '../../../data/types';
import { judgeEscalation, type EscalationResult } from '../escalation';
import { ESCALATION_LINES, type EscalationLine } from '../../../data/strings/floor';
const W = 640;
const H = 360;
const PANEL_X = 60;
const PANEL_Y = 56;
const PANEL_W = 520;
const PANEL_H = 248;
const ROW_X = 214;
const ROW_W = 348;
const ROW_H = 34;
const ROW_TOP = 126;
const ROW_GAP = 42;
const PINK = 0xd03470;
const AMBER = 0xd8a020;
const PURPLE = 0x7b2fd0;
const OUTCOME_COLOUR: Readonly<Record<EscalationResult['outcome'], string>> = {
clean: '#9fe8a0',
scene: '#d03470',
staggerFree: '#d8a020',
};
/** Softest first — index maps straight to the 1/2/3 keys. */
const ORDER: readonly EscalationLine[] = ['water', 'done', 'escort'];
// What the player can actually SEE across a dark room. The raw intoxication
// float never reaches the screen — you read the person, not a stat.
const LOOK: Readonly<Record<DrunkStage, string>> = {
sober: 'steady as a rock',
tipsy: 'talking with her hands',
loose: 'leaning on things that move',
messy: 'wearing some of the drink',
maggot: 'gone. absolutely gone.',
};
const HOLD_MS = 1700;
interface Row {
box: Phaser.GameObjects.Rectangle;
text: Phaser.GameObjects.Text;
}
const mono = (size: number, colour: string): Phaser.Types.GameObjects.Text.TextStyle => ({
fontFamily: 'monospace',
fontSize: `${size}px`,
color: colour,
});
/** Modal cut-off dialogue tree. A thin shell over judgeEscalation(). */
export class CutOffOverlay {
private readonly root: Phaser.GameObjects.Container;
private readonly doll: Phaser.GameObjects.Image;
private readonly whoText: Phaser.GameObjects.Text;
private readonly lookText: Phaser.GameObjects.Text;
private readonly hintText: Phaser.GameObjects.Text;
private readonly rows: Row[] = [];
private readonly saidText: Phaser.GameObjects.Text;
private readonly replyText: Phaser.GameObjects.Text;
private open_ = false;
private stage: DrunkStage = 'sober';
private holdMs = 0;
private pending: EscalationResult | null = null;
private done: ((r: EscalationResult | null) => void) | null = null;
constructor(private readonly scene: Phaser.Scene) {
this.root = scene.add.container(0, 0).setDepth(5000).setScrollFactor(0).setVisible(false);
this.root.add(scene.add.rectangle(W / 2, H / 2, W, H, 0x05050a, 0.82));
this.root.add(
scene.add
.rectangle(PANEL_X + PANEL_W / 2, PANEL_Y + PANEL_H / 2, PANEL_W, PANEL_H, 0x0b0b12, 0.98)
.setStrokeStyle(1, PURPLE),
);
this.root.add(scene.add.rectangle(PANEL_X + PANEL_W / 2, PANEL_Y + 1, PANEL_W, 2, PINK, 0.8));
this.root.add(scene.add.text(PANEL_X + 12, PANEL_Y + 10, 'CUT-OFF', mono(9, '#d03470')));
// '__DEFAULT' is Phaser's always-present blank texture — a placeholder so
// every object exists before the first open().
this.doll = scene.add.image(134, 252, '__DEFAULT').setOrigin(0.5, 1).setScale(2);
this.root.add(this.doll);
this.whoText = scene.add.text(134, 258, '', mono(8, '#9fe8a0')).setOrigin(0.5, 0);
this.lookText = scene.add
.text(134, 270, '', mono(7, '#8a7a96'))
.setOrigin(0.5, 0)
.setAlign('center')
.setWordWrapWidth(120);
this.root.add(this.whoText);
this.root.add(this.lookText);
ORDER.forEach((line, i) => {
const y = ROW_TOP + i * ROW_GAP;
const box = scene.add
.rectangle(ROW_X + ROW_W / 2, y, ROW_W, ROW_H, 0x14141f)
.setStrokeStyle(1, 0x2a2a3a)
.setInteractive({ useHandCursor: true });
const text = scene.add
.text(ROW_X + 10, y, `${i + 1} ${ESCALATION_LINES[line]}`, mono(8, '#cfc6d8'))
.setOrigin(0, 0.5);
box.on('pointerover', () => box.setStrokeStyle(1, AMBER));
box.on('pointerout', () => box.setStrokeStyle(1, 0x2a2a3a));
box.on('pointerup', () => this.pick(line));
this.root.add(box);
this.root.add(text);
this.rows.push({ box, text });
});
this.saidText = scene.add
.text(ROW_X, 136, '', mono(9, '#cfc6d8'))
.setOrigin(0, 0)
.setWordWrapWidth(ROW_W);
this.replyText = scene.add
.text(ROW_X, 176, '', mono(8, '#9fe8a0'))
.setOrigin(0, 0)
.setLineSpacing(3)
.setWordWrapWidth(ROW_W);
this.root.add(this.saidText);
this.root.add(this.replyText);
this.hintText = scene.add
.text(W / 2, PANEL_Y + PANEL_H - 16, '1/2/3 choose · ESC back off', mono(7, '#5a5a70'))
.setOrigin(0.5, 0);
this.root.add(this.hintText);
}
get isOpen(): boolean {
return this.open_;
}
open(args: { patron: Patron; stage: DrunkStage }, done: (r: EscalationResult | null) => void): void {
this.done = done;
this.stage = args.stage;
this.pending = null;
this.holdMs = 0;
this.open_ = true;
this.doll.setTexture(renderDoll(this.scene, args.patron, 'queue'));
this.whoText.setText(args.patron.archetype);
this.lookText.setText(LOOK[args.stage]);
this.showChoices(true);
this.root.setVisible(true);
}
update(deltaMs: number): void {
if (!this.open_ || this.holdMs <= 0) return;
this.holdMs -= deltaMs;
if (this.holdMs > 0) return;
const result = this.pending;
this.hide();
this.resolve(result);
}
handleKey(key: string): void {
if (!this.open_ || this.pending) return;
const k = key.toUpperCase();
if (k === 'ESC' || k === 'ESCAPE') {
this.hide();
this.resolve(null);
return;
}
const line = ORDER[Number(k) - 1];
if (line) this.pick(line);
}
destroy(): void {
this.open_ = false;
this.done = null;
this.root.destroy();
}
private pick(line: EscalationLine): void {
if (!this.open_ || this.pending) return;
const result = judgeEscalation(this.stage, line);
this.pending = result;
this.holdMs = HOLD_MS;
this.showChoices(false);
this.saidText.setText(`"${result.line}"`);
this.replyText.setText(result.reply).setColor(OUTCOME_COLOUR[result.outcome]);
}
private showChoices(on: boolean): void {
for (const r of this.rows) {
r.box.setVisible(on).setStrokeStyle(1, 0x2a2a3a);
r.text.setVisible(on);
}
this.hintText.setVisible(on);
this.saidText.setVisible(!on);
this.replyText.setVisible(!on);
}
private hide(): void {
this.open_ = false;
this.pending = null;
this.holdMs = 0;
this.root.setVisible(false);
}
/** done() fires exactly once per open(), always after hide(). */
private resolve(result: EscalationResult | null): void {
const cb = this.done;
this.done = null;
cb?.(result);
}
}

View File

@ -0,0 +1,469 @@
import Phaser from 'phaser';
import { CONTRABAND } from '../../../data/contraband';
import type { Patron } from '../../../data/types';
import { PATDOWN_INTRO } from '../../../data/strings/floor';
import { PATDOWN_MS, POCKET_COUNT, foundLine, scorePatDown } from '../patDown';
import type { PatDownResult, Pocket } from '../patDown';
const VIEW_W = 640;
const VIEW_H = 360;
const DEPTH = 5000;
/** patDown.ts lays pockets out on a 64x96 outline; blow it up so 12px zones are clickable. */
const SCALE = 2.5;
const OUTLINE_X = 46;
const OUTLINE_Y = 62;
const ZONE = 24;
const BIN_X = 430;
const BIN_Y = 190;
const BIN_W = 160;
const BIN_H = 170;
const PINK = 0xd03470;
const GREEN = 0x9fe8a0;
const AMBER = 0xd8a020;
const PURPLE = 0x7b2fd0;
const MONO = 'monospace';
interface TokenArt {
readonly w: number;
readonly h: number;
readonly colour: number;
}
/** Placeholder art, one entry per CONTRABAND spriteHint. Real sprites land in the v0.4 pass. */
const TOKEN_ART: Readonly<Record<string, TokenArt>> = {
baggie: { w: 14, h: 10, colour: 0xf2f2f0 },
flask: { w: 12, h: 22, colour: 0xb8c0c8 },
goonSack: { w: 24, h: 16, colour: 0xc8ccd4 },
tinnies: { w: 18, h: 20, colour: AMBER },
kebab: { w: 12, h: 26, colour: 0xd8cea4 },
someoneElsesId: { w: 22, h: 14, colour: 0xdad4c4 },
budgie: { w: 18, h: 14, colour: 0x6fd06f },
vapes: { w: 22, h: 12, colour: PURPLE },
marker: { w: 8, h: 24, colour: 0x24242c },
snags: { w: 24, h: 12, colour: 0xe08c94 },
};
const DEFAULT_ART: TokenArt = { w: 16, h: 14, colour: 0x9aa0b0 };
const NAME_BY_ID = new Map(CONTRABAND.map((c) => [c.id, c.name] as const));
interface ZoneView {
readonly g: Phaser.GameObjects.Graphics;
readonly label: Phaser.GameObjects.Text;
readonly hit: Phaser.GameObjects.Zone;
}
interface TokenView {
readonly root: Phaser.GameObjects.Container;
readonly body: Phaser.GameObjects.Rectangle;
readonly label: Phaser.GameObjects.Text;
pocketIndex: number;
itemId: string;
homeX: number;
homeY: number;
grabOffX: number;
grabOffY: number;
/** Owns a pocket's item this round. One token per pocket, so slots are never recycled mid-round. */
live: boolean;
/** In or past the bin tween: no longer draggable, no longer flapping. */
gone: boolean;
}
// Dashes along each edge, corners left open — reads as a rounded dashed hint box
// without paying for an actual rounded path per zone.
function dashRect(g: Phaser.GameObjects.Graphics, x: number, y: number, w: number, h: number): void {
const step = 6;
const dash = 3;
const r = 3;
for (let px = x + r; px < x + w - r; px += step) {
const len = Math.min(dash, x + w - r - px);
g.lineBetween(px, y, px + len, y);
g.lineBetween(px, y + h, px + len, y + h);
}
for (let py = y + r; py < y + h - r; py += step) {
const len = Math.min(dash, y + h - r - py);
g.lineBetween(x, py, x, py + len);
g.lineBetween(x + w, py, x + w, py + len);
}
}
/**
* The timed pat-down. Thin shell: all scoring lives in patDown.ts this only
* flips `revealed` / `binned` on the caller's pockets, which is the agreed channel.
*/
export class PatDownOverlay {
private readonly scene: Phaser.Scene;
private readonly root: Phaser.GameObjects.Container;
private readonly timerBar: Phaser.GameObjects.Rectangle;
private readonly timerText: Phaser.GameObjects.Text;
private readonly lineText: Phaser.GameObjects.Text;
private readonly logText: Phaser.GameObjects.Text;
private readonly binGeom = new Phaser.Geom.Rectangle(BIN_X - BIN_W / 2, BIN_Y - BIN_H / 2, BIN_W, BIN_H);
private readonly zones: ZoneView[] = [];
private readonly tokens: TokenView[] = [];
private pockets: Pocket[] = [];
private patron: Patron | null = null;
private doneFn: ((r: PatDownResult) => void) | null = null;
private remainingMs = 0;
private flapMs = 0;
private open_ = false;
private found: string[] = [];
constructor(scene: Phaser.Scene) {
this.scene = scene;
this.root = scene.add.container(0, 0);
this.root.add(scene.add.rectangle(VIEW_W / 2, VIEW_H / 2, VIEW_W, VIEW_H, 0x05050a, 0.82));
this.root.add(
scene.add.rectangle(VIEW_W / 2, VIEW_H / 2, 600, 328, 0x0d0d16, 0.96).setStrokeStyle(1, PINK, 0.6),
);
this.root.add(scene.add.rectangle(32, 30, 576, 8, 0x1c1c2a).setOrigin(0, 0));
this.timerBar = scene.add.rectangle(32, 30, 576, 8, GREEN).setOrigin(0, 0);
this.root.add(this.timerBar);
this.root.add(scene.add.text(32, 44, PATDOWN_INTRO, { fontFamily: MONO, fontSize: '9px', color: '#d03470' }));
this.timerText = scene.add
.text(608, 44, '', { fontFamily: MONO, fontSize: '9px', color: '#9fe8a0' })
.setOrigin(1, 0);
this.root.add(this.timerText);
this.buildOutline();
this.buildBin();
for (let i = 0; i < POCKET_COUNT; i++) this.zones.push(this.buildZone(i));
for (let i = 0; i < POCKET_COUNT; i++) this.tokens.push(this.buildToken());
this.logText = scene.add.text(524, 104, '', {
fontFamily: MONO,
fontSize: '7px',
color: '#d8a020',
wordWrap: { width: 88 },
lineSpacing: 2,
});
this.root.add(this.logText);
this.lineText = scene.add.text(46, 316, '', {
fontFamily: MONO,
fontSize: '8px',
color: '#e8e4d8',
wordWrap: { width: 390 },
});
this.root.add(this.lineText);
this.root.add(
scene.add
.text(608, 318, 'CLICK pockets · DRAG to bin · ESC done', {
fontFamily: MONO,
fontSize: '7px',
color: '#556',
})
.setOrigin(1, 0),
);
this.root.setDepth(DEPTH).setScrollFactor(0, 0, true).setVisible(false);
scene.input.on('dragstart', this.onDragStart);
scene.input.on('drag', this.onDrag);
scene.input.on('dragend', this.onDragEnd);
}
get isOpen(): boolean {
return this.open_;
}
open(args: { patron: Patron; pockets: Pocket[] }, done: (r: PatDownResult) => void): void {
if (this.open_) return;
this.patron = args.patron;
this.pockets = args.pockets;
this.doneFn = done;
this.remainingMs = PATDOWN_MS;
this.flapMs = 0;
this.found = [];
this.open_ = true;
this.lineText.setText('');
this.logText.setText('');
for (let i = 0; i < this.zones.length; i++) {
const z = this.zones[i];
const p = this.pockets[i];
if (z === undefined) continue;
const on = p !== undefined;
z.g.setVisible(on);
z.label.setVisible(on);
z.hit.setVisible(on);
if (z.hit.input) z.hit.input.enabled = on;
if (on) this.paintZone(i);
}
for (const t of this.tokens) this.hideToken(t);
this.root.setVisible(true);
this.tick();
}
update(deltaMs: number): void {
if (!this.open_) return;
this.flapMs += deltaMs;
this.remainingMs -= deltaMs;
for (const t of this.tokens) {
// The budgie is alive and it would like everyone to know.
if (t.live && !t.gone && t.itemId === 'budgie') t.root.setRotation(Math.sin(this.flapMs / 70) * 0.3);
}
if (this.remainingMs <= 0) {
this.remainingMs = 0;
this.tick();
this.resolve();
return;
}
this.tick();
}
handleKey(key: string): void {
if (!this.open_) return;
if (key === 'ESC' || key === 'Escape') this.resolve();
}
destroy(): void {
this.scene.input.off('dragstart', this.onDragStart);
this.scene.input.off('drag', this.onDrag);
this.scene.input.off('dragend', this.onDragEnd);
this.root.destroy(true);
this.doneFn = null;
this.open_ = false;
}
// ---- build (constructor only) ----
private buildOutline(): void {
const g = this.scene.add.graphics();
const px = (v: number): number => OUTLINE_X + v * SCALE;
const py = (v: number): number => OUTLINE_Y + v * SCALE;
const box = (x: number, y: number, w: number, h: number): void => {
g.fillRect(px(x), py(y), w * SCALE, h * SCALE);
g.strokeRect(px(x), py(y), w * SCALE, h * SCALE);
};
g.fillStyle(0x14141f, 1);
g.lineStyle(1, 0x3c3c5a, 1);
g.fillCircle(px(32), py(10), 8 * SCALE);
g.strokeCircle(px(32), py(10), 8 * SCALE);
box(30, 17, 4, 4); // neck
box(20, 21, 24, 29); // torso
box(2, 24, 18, 5); // arms out, mate
box(44, 24, 18, 5);
box(20, 50, 24, 10); // hips
box(21, 60, 9, 34); // legs
box(34, 60, 9, 34);
this.root.add(g);
}
private buildBin(): void {
const s = this.scene;
this.root.add(
s.add.rectangle(BIN_X, BIN_Y, BIN_W, BIN_H, 0x120c1c, 0.9).setStrokeStyle(1, GREEN, 0.75),
);
this.root.add(s.add.rectangle(BIN_X, BIN_Y - BIN_H / 2, BIN_W + 12, 12, GREEN, 0.22));
this.root.add(s.add.rectangle(BIN_X, BIN_Y - BIN_H / 2 + 2, 90, 4, 0x05050a)); // slot
this.root.add(
s.add
.text(BIN_X, BIN_Y - BIN_H / 2 + 18, 'AMNESTY BIN', {
fontFamily: MONO,
fontSize: '9px',
color: '#9fe8a0',
})
.setOrigin(0.5, 0),
);
this.root.add(
s.add
.text(BIN_X, BIN_Y + BIN_H / 2 - 16, 'no questions asked', {
fontFamily: MONO,
fontSize: '7px',
color: '#4a6a50',
})
.setOrigin(0.5, 0),
);
}
private buildZone(index: number): ZoneView {
const s = this.scene;
const g = s.add.graphics();
const label = s.add.text(0, 0, '', { fontFamily: MONO, fontSize: '6px', color: '#6f7aa8' }).setOrigin(0.5, 0);
const hit = s.add.zone(0, 0, ZONE + 8, ZONE + 8).setInteractive({ useHandCursor: true });
hit.on('pointerdown', () => this.revealPocket(index));
this.root.add([g, label, hit]);
return { g, label, hit };
}
private buildToken(): TokenView {
const s = this.scene;
const root = s.add.container(0, 0);
const body = s.add.rectangle(0, 0, 16, 14, 0xffffff).setStrokeStyle(1, 0x05050a, 0.8);
const label = s.add
.text(0, 12, '', { fontFamily: MONO, fontSize: '6px', color: '#e8e4d8', align: 'center' })
.setOrigin(0.5, 0);
root.add([body, label]);
root.setSize(40, 40).setInteractive(new Phaser.Geom.Rectangle(-20, -20, 40, 40), Phaser.Geom.Rectangle.Contains);
s.input.setDraggable(root);
this.root.add(root);
return {
root,
body,
label,
pocketIndex: -1,
itemId: '',
homeX: 0,
homeY: 0,
grabOffX: 0,
grabOffY: 0,
live: false,
gone: false,
};
}
// ---- runtime ----
private tick(): void {
const frac = Phaser.Math.Clamp(this.remainingMs / PATDOWN_MS, 0, 1);
this.timerBar.setSize(Math.max(576 * frac, 0.001), 8);
this.timerBar.setFillStyle(frac > 0.5 ? GREEN : frac > 0.2 ? AMBER : PINK, 1);
this.timerText.setText(`${(this.remainingMs / 1000).toFixed(1)}s`);
this.timerText.setColor(frac > 0.5 ? '#9fe8a0' : frac > 0.2 ? '#d8a020' : '#d03470');
}
private paintZone(index: number): void {
const z = this.zones[index];
const p = this.pockets[index];
if (z === undefined || p === undefined) return;
const cx = OUTLINE_X + (p.x + 6) * SCALE;
const cy = OUTLINE_Y + (p.y + 6) * SCALE;
const half = ZONE / 2;
z.hit.setPosition(cx, cy);
z.label.setPosition(cx, cy + half + 2).setText(p.label);
z.g.clear();
if (!p.revealed) {
z.g.lineStyle(1, 0x7b8cd0, 0.9);
dashRect(z.g, cx - half, cy - half, ZONE, ZONE);
z.label.setColor('#6f7aa8');
return;
}
const hot = p.itemId !== undefined && !p.binned;
const c = hot ? AMBER : GREEN;
z.g.fillStyle(c, 0.12);
z.g.fillRect(cx - half, cy - half, ZONE, ZONE);
z.g.lineStyle(1, c, 0.9);
z.g.strokeRect(cx - half, cy - half, ZONE, ZONE);
z.label.setColor(hot ? '#d8a020' : '#9fe8a0');
}
private revealPocket(index: number): void {
if (!this.open_) return;
const p = this.pockets[index];
if (p === undefined || p.revealed) return;
p.revealed = true;
this.paintZone(index);
if (p.itemId !== undefined) this.spawnToken(index, p.itemId);
}
private spawnToken(index: number, itemId: string): void {
const p = this.pockets[index];
const t = this.tokens.find((x) => !x.live);
if (p === undefined || t === undefined) return;
const art = TOKEN_ART[itemId] ?? DEFAULT_ART;
t.itemId = itemId;
t.pocketIndex = index;
t.live = true;
t.gone = false;
t.homeX = OUTLINE_X + (p.x + 6) * SCALE;
t.homeY = OUTLINE_Y + (p.y + 6) * SCALE;
t.body.setSize(art.w, art.h).setFillStyle(art.colour, 1);
t.label.setText(NAME_BY_ID.get(itemId) ?? itemId).setPosition(0, art.h / 2 + 2);
t.root.setPosition(t.homeX, t.homeY).setScale(1).setAlpha(1).setRotation(0).setVisible(true);
if (t.root.input) t.root.input.enabled = true;
this.root.bringToTop(t.root);
}
private hideToken(t: TokenView): void {
this.scene.tweens.killTweensOf(t.root);
t.live = false;
t.gone = false;
t.itemId = '';
t.pocketIndex = -1;
t.root.setVisible(false).setRotation(0).setScale(1).setAlpha(1);
if (t.root.input) t.root.input.enabled = false;
}
private tokenFor(obj: Phaser.GameObjects.GameObject): TokenView | undefined {
return this.tokens.find((t) => t.live && !t.gone && t.root === obj);
}
private readonly onDragStart = (pointer: Phaser.Input.Pointer, obj: Phaser.GameObjects.GameObject): void => {
if (!this.open_) return;
const t = this.tokenFor(obj);
if (t === undefined) return;
// Overlay is scrollFactor 0, so track screen-space pointer, not world drag coords.
t.grabOffX = t.root.x - pointer.x;
t.grabOffY = t.root.y - pointer.y;
this.root.bringToTop(t.root);
};
private readonly onDrag = (pointer: Phaser.Input.Pointer, obj: Phaser.GameObjects.GameObject): void => {
if (!this.open_) return;
const t = this.tokenFor(obj);
if (t === undefined) return;
t.root.setPosition(pointer.x + t.grabOffX, pointer.y + t.grabOffY);
};
private readonly onDragEnd = (_pointer: Phaser.Input.Pointer, obj: Phaser.GameObjects.GameObject): void => {
if (!this.open_) return;
const t = this.tokenFor(obj);
if (t === undefined) return;
if (this.binGeom.contains(t.root.x, t.root.y)) this.binToken(t);
else this.scene.tweens.add({ targets: t.root, x: t.homeX, y: t.homeY, duration: 160, ease: 'Back.easeOut' });
};
private binToken(t: TokenView): void {
const p = this.pockets[t.pocketIndex];
if (p === undefined) return;
p.binned = true;
const itemId = t.itemId;
if (t.root.input) t.root.input.enabled = false;
t.gone = true; // stops the flap and the drag, but keeps the slot so nothing reuses it mid-tween
this.scene.tweens.add({
targets: t.root,
x: BIN_X,
y: BIN_Y,
scale: 0.2,
alpha: 0,
rotation: 0,
duration: 240,
ease: 'Quad.easeIn',
onComplete: () => t.root.setVisible(false),
});
this.lineText.setText(foundLine(itemId));
this.found.push(NAME_BY_ID.get(itemId) ?? itemId);
this.logText.setText(this.found.join('\n'));
this.paintZone(t.pocketIndex);
}
private resolve(): void {
const done = this.doneFn;
const patron = this.patron;
if (!this.open_ || done === null || patron === null) return;
this.open_ = false;
this.doneFn = null;
this.root.setVisible(false);
done(scorePatDown(patron, this.pockets));
}
}

View File

@ -0,0 +1,334 @@
import Phaser from 'phaser';
import type { EventBus } from '../../../core/EventBus';
import { PALETTE } from '../../../data/outfits';
import type { Patron } from '../../../data/types';
import {
BANGS_TO_BUST,
OK_MS,
PERFECT_MS,
applyBang,
freshStall,
judgeBang,
scoreStall,
type StallOutcome,
type StallState,
} from '../bangJudge';
import { STALL_BUSTED, STALL_MUFFLED, STALL_OFF_BEAT } from '../../../data/strings/floor';
const W = 640;
const H = 360;
const PINK = 0xd03470;
const GREEN = 0x9fe8a0;
const AMBER = 0xd8a020;
const PURPLE = 0x7b2fd0;
const INK = 0x0a0a12;
const DOOR_W = 250;
const DOOR_H = 160;
const DOOR_CX = 320;
const DOOR_CY = 145;
const DOOR_HINGE_X = DOOR_CX - DOOR_W / 2;
const DOOR_BOTTOM = DOOR_CY + DOOR_H / 2;
const BAR_W = 380;
const BAR_H = 14;
const BAR_CX = 320;
const BAR_CY = 292;
const BUST_HOLD_MS = 1600;
/** How far the door swings before it's "open" — a sliver of edge stays visible. */
const DOOR_SWUNG_SCALE = 0.06;
interface TickMark {
beatIndex: number;
/** The tick's arrival stamped on OUR clock, so bangs and beats compare directly. */
atElapsedMs: number;
}
function shoeColour(p: Patron): number {
const layer = p.outfit.find((l) => l.slot === 'shoes');
if (!layer || layer.type === 'none') return 0x14141c;
return PALETTE[layer.colour] ?? 0x14141c;
}
export class StallOverlay {
private readonly root: Phaser.GameObjects.Container;
private readonly doorNode: Phaser.GameObjects.Container;
private readonly barFlash: Phaser.GameObjects.Rectangle;
private readonly marker: Phaser.GameObjects.Rectangle;
private readonly titleText: Phaser.GameObjects.Text;
private readonly gradeText: Phaser.GameObjects.Text;
private readonly flavourText: Phaser.GameObjects.Text;
private readonly noteText: Phaser.GameObjects.Text;
private readonly pips: Phaser.GameObjects.Rectangle[] = [];
private readonly feet: Phaser.GameObjects.Rectangle[] = [];
private readonly feetBaseX: number[] = [];
private readonly offBeat: () => void;
private open_ = false;
private resolved = true;
private done: ((r: StallOutcome) => void) | null = null;
private state: StallState = freshStall();
private elapsedMs = 0;
private lastTick: TickMark | null = null;
private bustHoldMs = 0;
private shakeMs = 0;
private shakeMag = 0;
private flashMs = 0;
constructor(
scene: Phaser.Scene,
bus: EventBus,
private readonly beatIntervalMs: number,
) {
const t = (x: number, y: number, size: number, colour: string): Phaser.GameObjects.Text =>
scene.add.text(x, y, '', { fontFamily: 'monospace', fontSize: `${size}px`, color: colour });
this.root = scene.add.container(0, 0);
this.root.add(scene.add.rectangle(W / 2, H / 2, W, H, 0x000000, 0.82));
this.root.add(scene.add.rectangle(W / 2, 180, 470, 310, INK).setStrokeStyle(1, PINK, 0.7));
this.titleText = t(105, 40, 9, '#d03470');
this.root.add(this.titleText);
// Stall interior: revealed as the door swings away.
this.root.add(scene.add.rectangle(DOOR_CX, DOOR_CY, DOOR_W, DOOR_H, 0x05050a));
this.root.add(scene.add.rectangle(DOOR_CX, DOOR_CY - 30, 40, 60, 0x11111a)); // cistern, in shadow
// Door hangs off its hinge so the bust can swing it, not slide it.
this.doorNode = scene.add.container(DOOR_HINGE_X, DOOR_CY);
const face = scene.add.rectangle(DOOR_W / 2, 0, DOOR_W, DOOR_H, 0x2b2018).setStrokeStyle(1, 0x120c08);
this.doorNode.add(face);
this.doorNode.add(scene.add.rectangle(DOOR_W / 2, -34, DOOR_W - 40, 54, 0x342519));
this.doorNode.add(scene.add.rectangle(DOOR_W / 2, 36, DOOR_W - 40, 54, 0x342519));
this.doorNode.add(scene.add.rectangle(DOOR_W - 26, 0, 14, 5, 0xc0c0c8)); // latch
this.doorNode.add(scene.add.rectangle(DOOR_W - 26, -12, 22, 9, 0x8a1020)); // ENGAGED tab
this.doorNode.add(
scene.add
.text(DOOR_W - 26, -12, 'ENGAGED', { fontFamily: 'monospace', fontSize: '5px', color: '#f0c0c8' })
.setOrigin(0.5),
);
this.doorNode.add(
scene.add
.text(DOOR_W / 2, -46, 'DAZZA WOZ HERE', { fontFamily: 'monospace', fontSize: '7px', color: '#5c4630' })
.setOrigin(0.5)
.setAngle(-6),
);
this.root.add(this.doorNode);
// The infraction itself: two pairs of feet in a one-person cubicle.
this.root.add(scene.add.rectangle(DOOR_CX, DOOR_BOTTOM + 7, DOOR_W, 14, 0x1c1c24));
for (let i = 0; i < 4; i++) {
const pairX = i < 2 ? DOOR_CX - 64 : DOOR_CX + 52;
const x = pairX + (i % 2) * 13;
this.feetBaseX.push(x);
const foot = scene.add.rectangle(x, DOOR_BOTTOM + 6, 9, 6, 0x14141c);
this.feet.push(foot);
this.root.add(foot);
}
this.flavourText = t(120, 244, 8, '#c8c8d4').setWordWrapWidth(400);
this.root.add(this.flavourText);
this.noteText = t(120, 268, 7, '#6a6a80').setWordWrapWidth(400);
this.root.add(this.noteText);
// Beat bar: the full width is one beat, dead centre is the kick.
const zone = (ms: number, colour: number, alpha: number): Phaser.GameObjects.Rectangle => {
const frac = beatIntervalMs > 0 ? Math.min(1, (2 * ms) / beatIntervalMs) : 1;
return scene.add.rectangle(BAR_CX, BAR_CY, BAR_W * frac, BAR_H, colour, alpha);
};
this.root.add(scene.add.rectangle(BAR_CX, BAR_CY, BAR_W, BAR_H, 0x14141c).setStrokeStyle(1, 0x30304a));
this.root.add(zone(OK_MS, AMBER, 0.22));
this.root.add(zone(PERFECT_MS, GREEN, 0.32));
this.barFlash = scene.add.rectangle(BAR_CX, BAR_CY, BAR_W, BAR_H, GREEN, 0);
this.root.add(this.barFlash);
this.marker = scene.add.rectangle(BAR_CX, BAR_CY, 3, BAR_H + 8, 0xffffff);
this.root.add(this.marker);
this.gradeText = t(BAR_CX + BAR_W / 2 + 6, BAR_CY - 5, 8, '#9fe8a0');
this.root.add(this.gradeText);
for (let i = 0; i < BANGS_TO_BUST; i++) {
const pip = scene.add.rectangle(BAR_CX - (BANGS_TO_BUST - 1) * 8 + i * 16, 314, 11, 7, PURPLE, 0.25);
this.pips.push(pip);
this.root.add(pip);
}
this.root.add(
scene.add
.text(BAR_CX, 328, 'SPACE bang on the door · ESC walk away', {
fontFamily: 'monospace',
fontSize: '7px',
color: '#556',
})
.setOrigin(0.5, 0),
);
this.root.setScrollFactor(0, 0, true);
this.root.setDepth(5200);
this.root.setVisible(false);
this.offBeat = bus.on('beat:tick', (p) => {
if (!this.open_) return;
this.lastTick = { beatIndex: p.beatIndex, atElapsedMs: this.elapsedMs };
});
}
get isOpen(): boolean {
return this.open_;
}
open(args: { stallId: string; patrons: Patron[] }, done: (r: StallOutcome) => void): void {
this.done = done;
this.resolved = false;
this.open_ = true;
this.state = freshStall();
this.elapsedMs = 0;
this.lastTick = null;
this.bustHoldMs = 0;
this.shakeMs = 0;
this.shakeMag = 0;
this.flashMs = 0;
this.titleText.setText(`CUBICLE ${args.stallId.toUpperCase()} — occupied. Twice.`);
this.flavourText.setText('Two pairs of shoes. One cubicle. Knock on the beat, mate.');
this.noteText.setText('');
this.gradeText.setText('');
this.feet.forEach((foot, i) => {
const patron = args.patrons[i < 2 ? 0 : 1];
foot.setVisible(patron !== undefined);
foot.setAlpha(1);
foot.x = this.feetBaseX[i] ?? foot.x;
if (patron) foot.setFillStyle(shoeColour(patron));
});
this.doorNode.setScale(1, 1);
this.doorNode.x = DOOR_HINGE_X;
this.doorNode.y = DOOR_CY;
this.barFlash.setAlpha(0);
this.paintPips();
this.root.setVisible(true);
}
handleKey(key: string): void {
if (!this.open_) return;
const k = key.toUpperCase();
if (k === 'ESC' || k === 'ESCAPE') return this.resolve();
// Door's already open; the last beat is just theatre.
if (this.bustHoldMs > 0) return;
if (k === 'SPACE' || k === ' ') this.bang();
}
update(deltaMs: number): void {
if (!this.open_) return;
this.elapsedMs += deltaMs;
if (this.shakeMs > 0) this.shakeMs = Math.max(0, this.shakeMs - deltaMs);
if (this.flashMs > 0) {
this.flashMs = Math.max(0, this.flashMs - deltaMs);
this.barFlash.setAlpha((this.flashMs / 180) * 0.5);
}
if (this.bustHoldMs > 0) {
this.bustHoldMs = Math.max(0, this.bustHoldMs - deltaMs);
const progress = 1 - this.bustHoldMs / BUST_HOLD_MS;
const swing = Math.min(1, progress * 2.2); // door goes fast, the shame lingers
this.doorNode.setScale(1 - swing * (1 - DOOR_SWUNG_SCALE), 1);
this.feet.forEach((foot, i) => {
const base = this.feetBaseX[i] ?? foot.x;
foot.x = base + (i < 2 ? -1 : 1) * swing * 70;
foot.setAlpha(1 - swing);
});
if (this.bustHoldMs === 0) this.resolve();
return;
}
this.doorNode.x = DOOR_HINGE_X + this.shakeWobble();
this.doorNode.y = DOOR_CY + this.shakeWobble() * 0.4;
this.marker.x = BAR_CX - BAR_W / 2 + this.beatPhase() * BAR_W;
this.marker.setAlpha(this.lastTick ? 1 : 0.25);
}
destroy(): void {
this.offBeat();
this.done = null;
this.open_ = false;
this.root.destroy(true);
}
private bang(): void {
const tick = this.lastTick;
// No beat authority yet — swinging at silence shouldn't cost you a combo.
if (!tick) return;
const grade = judgeBang(
this.elapsedMs,
{ beatIndex: tick.beatIndex, audioTimeMs: tick.atElapsedMs },
this.beatIntervalMs,
);
const brokeCombo = grade === 'miss' && this.state.combo > 0;
this.state = applyBang(this.state, grade);
this.shakeMs = grade === 'miss' ? 90 : 220;
this.shakeMag = grade === 'miss' ? 1.5 : 4.5;
this.flashMs = 180;
this.barFlash.setFillStyle(grade === 'perfect' ? GREEN : grade === 'ok' ? AMBER : PINK);
this.gradeText.setText(grade.toUpperCase());
this.gradeText.setColor(grade === 'perfect' ? '#9fe8a0' : grade === 'ok' ? '#d8a020' : '#d03470');
this.paintPips();
if (this.state.busted) {
this.flavourText.setText(this.line(STALL_BUSTED));
this.noteText.setText('');
this.bustHoldMs = BUST_HOLD_MS;
this.gradeText.setText('BUSTED');
return;
}
if (grade === 'miss') {
this.flavourText.setText(this.line(STALL_MUFFLED));
this.noteText.setText(brokeCombo ? STALL_OFF_BEAT : '');
} else {
this.flavourText.setText('The door rattles. Something in there stops talking.');
this.noteText.setText('');
}
}
/** Deterministic line pick — the beat index is the only entropy we're allowed. */
private line(lines: readonly string[]): string {
const i = ((this.lastTick?.beatIndex ?? 0) + this.state.bangs) % lines.length;
return lines[i] ?? lines[0] ?? '';
}
/** 0..1 across the bar, offset so a kick lands the marker dead centre. */
private beatPhase(): number {
if (!this.lastTick || !(this.beatIntervalMs > 0)) return 0.5;
const since = (this.elapsedMs - this.lastTick.atElapsedMs) / this.beatIntervalMs;
return ((since % 1) + 1.5) % 1;
}
private shakeWobble(): number {
if (this.shakeMs <= 0) return 0;
return Math.sin(this.shakeMs * 0.9) * this.shakeMag * (this.shakeMs / 220);
}
private paintPips(): void {
this.pips.forEach((pip, i) => {
const lit = i < this.state.combo;
pip.setFillStyle(lit ? GREEN : PURPLE, lit ? 0.95 : 0.25);
});
}
private resolve(): void {
if (this.resolved) return;
this.resolved = true;
this.open_ = false;
this.root.setVisible(false);
const done = this.done;
this.done = null;
done?.(scoreStall(this.state));
}
}

120
src/scenes/floor/patDown.ts Normal file
View File

@ -0,0 +1,120 @@
// The pat-down minigame's pure half: where the pockets are, what's in them,
// and what it costs you. Phaser-free so the scoring stays testable.
import type { RngStream } from '../../core/SeededRNG';
import { CONTRABAND } from '../../data/contraband';
import type { Patron } from '../../data/types';
import { PATDOWN_CLEAR, PATDOWN_FOUND, PATDOWN_MISSED } from '../../data/strings/floor';
export const PATDOWN_MS = 10_000;
export const POCKET_COUNT = 6;
export interface Pocket {
id: string;
label: string;
/** Position on a 64x96 patron-outline overlay coordinate space, top-left origin. */
x: number;
y: number;
itemId?: string;
revealed: boolean;
binned: boolean;
}
/**
* Fixed anchors down the outline chest, jacket, hips, back, sock. Spaced so no
* two hit areas touch at a ~12px footprint; the search is about time pressure,
* not pixel-hunting.
*/
const POCKET_SPOTS: ReadonlyArray<{ readonly label: string; readonly x: number; readonly y: number }> = [
{ label: 'inside pocket', x: 26, y: 22 },
{ label: 'left jacket', x: 10, y: 30 },
{ label: 'right jacket', x: 40, y: 30 },
{ label: 'front hip', x: 12, y: 52 },
{ label: 'back pocket', x: 42, y: 54 },
{ label: 'sock', x: 20, y: 80 },
];
const SEVERITY_BY_ID = new Map(CONTRABAND.map((c) => [c.id, c.severity] as const));
const NAME_BY_ID = new Map(CONTRABAND.map((c) => [c.id, c.name] as const));
/** Deterministic: same patron + same rng stream state => same layout. */
export function layoutPockets(patron: Patron, rng: RngStream): Pocket[] {
const pockets: Pocket[] = POCKET_SPOTS.map((spot, i) => ({
id: `pk${i}`,
label: spot.label,
x: spot.x,
y: spot.y,
revealed: false,
binned: false,
}));
// Fisher-Yates over slot indices, then fill from the front: distinct pockets
// for free, and anything past POCKET_COUNT simply doesn't fit on them.
const order = pockets.map((_, i) => i);
for (let i = order.length - 1; i > 0; i--) {
const j = rng.int(0, i);
const a = order[i]!;
order[i] = order[j]!;
order[j] = a;
}
const items = patron.flags.contraband ?? [];
const placeable = Math.min(items.length, POCKET_COUNT);
for (let i = 0; i < placeable; i++) {
pockets[order[i]!]!.itemId = items[i]!;
}
return pockets;
}
export interface PatDownResult {
foundIds: string[];
missedIds: string[];
vibeDelta: number;
aggroDelta: number;
/** Severity-3 contraband found => they are out. */
eject: boolean;
/** Player-facing summary line, built from strings.ts. */
summary: string;
}
// Scores the pockets as laid out, not the patron's flags — what you didn't open,
// you didn't find, even if the generator put it there.
export function scorePatDown(_patron: Patron, pockets: readonly Pocket[]): PatDownResult {
const foundIds: string[] = [];
const missedIds: string[] = [];
for (const p of pockets) {
if (p.itemId === undefined) continue;
(p.binned ? foundIds : missedIds).push(p.itemId);
}
const carried = foundIds.length + missedIds.length;
// An unnecessary pat-down is a small indignity — that's the moral cost.
const aggroDelta = carried === 0 ? 1.5 : 0;
const eject = foundIds.some((id) => SEVERITY_BY_ID.get(id) === 3);
let summary: string;
if (carried === 0) {
summary = PATDOWN_CLEAR;
} else {
const parts = foundIds.map(foundLine);
if (missedIds.length > 0) parts.push(PATDOWN_MISSED);
summary = parts.join(' ');
}
return {
foundIds,
missedIds,
vibeDelta: foundIds.length * 2 - missedIds.length * 1.5,
aggroDelta,
eject,
summary,
};
}
/** Flavour line for a found item; falls back gracefully on an unknown id. */
export function foundLine(itemId: string): string {
const line = PATDOWN_FOUND[itemId];
if (line !== undefined) return line;
const name = NAME_BY_ID.get(itemId);
return name !== undefined ? `${name}. Into the bin, mate.` : 'Into the bin, mate.';
}

View File

@ -0,0 +1,76 @@
// Player movement. Pure maths — the scene owns the sprite, this owns the weight.
// You are a big unit: slow to start, slower to stop, and you slide off walls
// rather than sticking to them.
import { isWalkableWorld } from './venueMap';
import type { VenueMap } from './venueMap';
export interface PlayerInput { up: boolean; down: boolean; left: boolean; right: boolean }
export interface PlayerState {
x: number; y: number; // world px
vx: number; vy: number; // px/sec
facing: number; // radians, persists when stationary
}
export const PLAYER_ACCEL = 620; // px/s^2
export const PLAYER_MAX_SPEED = 116;
export const PLAYER_FRICTION = 7; // per second, exponential damping
export const PLAYER_RADIUS = 5;
// A stalled tab hands back one enormous delta; cap it so nobody blinks through a
// wall. At the cap a full-speed step is ~6px, well under a tile.
const MAX_STEP_MS = 50;
// Below this the heading is noise, so the torch keeps pointing where it was.
const FACING_EPS = 4;
export function freshPlayer(x: number, y: number): PlayerState {
return { x, y, vx: 0, vy: 0, facing: 0 };
}
/** The 4 cardinal edge points of the body circle must all be on walkable floor. */
function canStand(map: VenueMap, x: number, y: number): boolean {
return isWalkableWorld(map, x + PLAYER_RADIUS, y)
&& isWalkableWorld(map, x - PLAYER_RADIUS, y)
&& isWalkableWorld(map, x, y + PLAYER_RADIUS)
&& isWalkableWorld(map, x, y - PLAYER_RADIUS);
}
/** Mutates nothing; returns the next state. Slides along walls (axis-separated collision). */
export function stepPlayer(s: PlayerState, input: PlayerInput, deltaMs: number, map: VenueMap): PlayerState {
const dt = Math.max(0, Math.min(deltaMs, MAX_STEP_MS)) / 1000;
let ix = (input.right ? 1 : 0) - (input.left ? 1 : 0);
let iy = (input.down ? 1 : 0) - (input.up ? 1 : 0);
const mag = Math.hypot(ix, iy);
if (mag > 0) { ix /= mag; iy /= mag; }
let vx = s.vx + ix * PLAYER_ACCEL * dt;
let vy = s.vy + iy * PLAYER_ACCEL * dt;
// Friction only bites on an axis you aren't driving, so held input still tops
// out at PLAYER_MAX_SPEED instead of settling at accel/friction.
const damp = Math.exp(-PLAYER_FRICTION * dt);
if (ix === 0) vx *= damp;
if (iy === 0) vy *= damp;
const speed = Math.hypot(vx, vy);
if (speed > PLAYER_MAX_SPEED) {
const k = PLAYER_MAX_SPEED / speed;
vx *= k;
vy *= k;
}
let x = s.x;
let y = s.y;
const nx = x + vx * dt;
if (canStand(map, nx, y)) x = nx; else vx = 0;
const ny = y + vy * dt;
if (canStand(map, x, ny)) y = ny; else vy = 0;
const moved = Math.hypot(vx, vy);
const facing = moved > FACING_EPS ? Math.atan2(vy, vx) : s.facing;
return { x, y, vx, vy, facing };
}

View File

@ -0,0 +1,175 @@
// Hand-authored top-down venue. Pure data + maths, no Phaser — the floor scene
// paints it, the AI walks it, the tests prove it hangs together.
export const TILE = 16;
export const MAP_W = 80; // tiles
export const MAP_H = 45; // tiles
export type TileKind =
| 'void' | 'floor' | 'wall' | 'bar' | 'stool' | 'dance'
| 'booth' | 'stall' | 'stallDoor' | 'exit' | 'smokeDoor' | 'neon';
export type AnchorKind = 'bar' | 'dance' | 'booth' | 'toilet' | 'smoke' | 'entry' | 'exit';
export interface TilePoint { tx: number; ty: number }
export interface WorldPoint { x: number; y: number }
export interface StallDef {
id: string; // 's1'..'s4'
door: WorldPoint; // world px, the tile just OUTSIDE the door (where you stand)
inside: WorldPoint; // world px, centre of the cubicle
}
export interface VenueMap {
width: number;
height: number;
tiles: readonly TileKind[]; // row-major, length MAP_W*MAP_H
anchors: Readonly<Record<AnchorKind, readonly WorldPoint[]>>; // world px
stalls: readonly StallDef[];
}
// 'stall' is walkable, but each cubicle interior is sealed behind a non-walkable
// 'stallDoor' — no path leads in. Movers must not pick stall tiles as pathing
// targets; send a patron inside via StallDef.inside instead. Everything else
// walkable is mutually reachable (see tests/floor/venueMap.test.ts).
const WALKABLE: ReadonlySet<TileKind> = new Set<TileKind>([
'floor', 'dance', 'exit', 'smokeDoor', 'stall',
]);
// Tuned UNDER the renderer's darkness sheet, not in isolation — at the original
// values the venue multiplied down to near-black and you couldn't tell the dance
// floor from a wall. Dark enough that you need the torch, light enough to navigate.
const COLOURS: Record<TileKind, number> = {
void: 0x05050a,
floor: 0x221e2b,
wall: 0x3a3448,
bar: 0x5c3f28,
stool: 0x6d4726,
dance: 0x33284a,
booth: 0x452c3c,
stall: 0x2e3838,
stallDoor: 0x4c6058,
exit: 0x2c5636,
smokeDoor: 0x3d4d5c,
neon: 0xd8409a,
};
// Cubicle west edges. Each cubicle is a 4x4 walled box; the last one shares the
// dunny room's east wall, which is why they stop at 73. CUBICLE_Y sits flush
// under the room's north wall — a gap there would strand a dead strip of floor.
const CUBICLE_X = [61, 65, 69, 73] as const;
const CUBICLE_Y = 4;
function buildTiles(): TileKind[] {
const tiles = new Array<TileKind>(MAP_W * MAP_H).fill('floor');
const set = (tx: number, ty: number, kind: TileKind): void => {
if (tx < 0 || tx >= MAP_W || ty < 0 || ty >= MAP_H) return;
tiles[ty * MAP_W + tx] = kind;
};
const rect = (x0: number, y0: number, x1: number, y1: number, kind: TileKind): void => {
for (let ty = y0; ty <= y1; ty++) for (let tx = x0; tx <= x1; tx++) set(tx, ty, kind);
};
const box = (x0: number, y0: number, x1: number, y1: number, kind: TileKind): void => {
rect(x0, y0, x1, y0, kind);
rect(x0, y1, x1, y1, kind);
rect(x0, y0, x0, y1, kind);
rect(x1, y0, x1, y1, kind);
};
box(0, 0, MAP_W - 1, MAP_H - 1, 'wall');
// Dead space west of the main room, carved back out for the entry corridor.
rect(1, 1, 13, MAP_H - 2, 'wall');
rect(1, 21, 14, 23, 'floor');
rect(1, 21, 1, 23, 'exit');
// Bar run along the north wall, stools hugging its south face.
rect(22, 4, 52, 5, 'bar');
rect(22, 6, 52, 6, 'stool');
rect(26, 16, 50, 30, 'dance');
// Booths: south wall then east wall.
for (const tx of [22, 30, 38, 46]) rect(tx, 41, tx + 1, 42, 'booth');
for (const ty of [22, 27, 32]) rect(76, ty, 77, ty + 1, 'booth');
// Dunnies: walled room in the north-east, one doorway on its south face.
box(60, 3, 76, 16, 'wall');
rect(62, 16, 63, 16, 'floor');
for (const x of CUBICLE_X) {
box(x, CUBICLE_Y, x + 3, CUBICLE_Y + 3, 'wall');
rect(x + 1, CUBICLE_Y + 1, x + 2, CUBICLE_Y + 2, 'stall');
set(x + 1, CUBICLE_Y + 3, 'stallDoor');
}
set(18, MAP_H - 1, 'smokeDoor');
// Accent strips the renderer hangs point lights off.
for (const tx of [20, 30, 40, 50, 60]) set(tx, 0, 'neon');
for (const ty of [10, 20, 30]) set(MAP_W - 1, ty, 'neon');
return tiles;
}
export function worldToTile(x: number, y: number): TilePoint {
return { tx: Math.floor(x / TILE), ty: Math.floor(y / TILE) };
}
/** Centre of the tile, not its corner — patrons stand on centres. */
export function tileToWorld(tx: number, ty: number): WorldPoint {
return { x: tx * TILE + TILE / 2, y: ty * TILE + TILE / 2 };
}
function buildStalls(): StallDef[] {
return CUBICLE_X.map((x, i) => ({
id: `s${i + 1}`,
door: tileToWorld(x + 1, CUBICLE_Y + 4),
// Centre of the 2x2 interior, which lands on a tile corner rather than a centre.
inside: { x: (x + 2) * TILE, y: (CUBICLE_Y + 2) * TILE },
}));
}
function buildAnchors(): Record<AnchorKind, readonly WorldPoint[]> {
const dance: WorldPoint[] = [];
for (const ty of [20, 26]) for (const tx of [30, 36, 42, 48]) dance.push(tileToWorld(tx, ty));
return {
bar: [24, 29, 34, 39, 44, 49].map((tx) => tileToWorld(tx, 7)),
dance,
booth: [
...[22, 30, 38, 46].map((tx) => tileToWorld(tx, 40)),
tileToWorld(75, 22),
tileToWorld(75, 27),
],
toilet: CUBICLE_X.map((x) => tileToWorld(x + 1, CUBICLE_Y + 4)),
smoke: [tileToWorld(18, MAP_H - 1)],
entry: [tileToWorld(4, 22), tileToWorld(9, 22)],
exit: [tileToWorld(1, 22)],
};
}
export const VENUE: VenueMap = Object.freeze({
width: MAP_W,
height: MAP_H,
tiles: Object.freeze(buildTiles()),
anchors: Object.freeze(buildAnchors()),
stalls: Object.freeze(buildStalls()),
});
export function tileAt(map: VenueMap, tx: number, ty: number): TileKind {
if (tx < 0 || tx >= map.width || ty < 0 || ty >= map.height) return 'void';
return map.tiles[ty * map.width + tx] ?? 'void';
}
export function isWalkable(map: VenueMap, tx: number, ty: number): boolean {
return WALKABLE.has(tileAt(map, tx, ty));
}
export function isWalkableWorld(map: VenueMap, x: number, y: number): boolean {
const { tx, ty } = worldToTile(x, y);
return isWalkable(map, tx, ty);
}
/** Colour for painting a tile in the dark venue. */
export function tileColour(kind: TileKind): number {
return COLOURS[kind];
}

View File

@ -45,6 +45,7 @@ export class ParadeScene extends Phaser.Scene {
this.resetRun(this.seed);
this.input.keyboard?.on('keydown-SPACE', () => this.resetRun(this.seed + 1));
this.input.keyboard?.on('keydown-F', () => this.scene.start('FloorDemo')); // LANE-FLOOR demo
this.input.keyboard?.on('keydown-D', () => {
this.showIds = !this.showIds;
for (const w of this.walkers) {
@ -89,7 +90,7 @@ export class ParadeScene extends Phaser.Scene {
this.seedText = this.add.text(4, H - 12, '', { fontFamily: 'monospace', fontSize: '8px', color: '#667' });
this.clockText = this.add.text(W - 60, 4, '', { fontFamily: 'monospace', fontSize: '8px', color: '#9fe8a0' });
this.add.text(4, 14, 'SPACE reseed · D toggle IDs', { fontFamily: 'monospace', fontSize: '8px', color: '#556' });
this.add.text(4, 14, 'SPACE reseed · D toggle IDs · F floor demo', { fontFamily: 'monospace', fontSize: '8px', color: '#556' });
}
private resetRun(seed: number): void {

178
src/ui/Clicker.ts Normal file
View File

@ -0,0 +1,178 @@
import Phaser from 'phaser';
import type { EventBus } from '../core/EventBus';
import { UI, chunkyButton, font, panel } from './style';
export interface ClickerSfx {
clickerClunk(): void;
}
export interface ClickerOptions {
x: number;
y: number;
sfx?: ClickerSfx;
depth?: number;
}
const BODY_W = 52;
const BODY_H = 34;
// Recessed digit window, in body-local coords.
const WIN_X = 0;
const WIN_Y = -9;
const WIN_W = 34;
const WIN_H = 15;
const DIGIT_SPACING = 10;
const ROLL_MS = 120;
// Slide distance: a glyph must clear the window before the mask stops hiding it.
const ROLL_TRAVEL = WIN_H;
const MAX_COUNT = 999;
interface DigitSlot {
readonly holder: Phaser.GameObjects.Container;
current: Phaser.GameObjects.Text;
incoming: Phaser.GameObjects.Text | null;
t: number;
}
/**
* The handheld tally clicker. It owns the SHOWN number only how many people
* are actually inside is somebody else's problem, which is the entire joke. IN
* is fat and obvious, OUT is small and easy to forget.
*/
export class Clicker {
private readonly scene: Phaser.Scene;
private readonly bus: EventBus;
private readonly sfx: ClickerSfx | undefined;
private readonly root: Phaser.GameObjects.Container;
private readonly maskGfx: Phaser.GameObjects.Graphics;
private readonly slots: DigitSlot[] = [];
private shown = 0;
constructor(scene: Phaser.Scene, bus: EventBus, opts: ClickerOptions) {
this.scene = scene;
this.bus = bus;
this.sfx = opts.sfx;
this.root = scene.add.container(opts.x, opts.y).setDepth(opts.depth ?? 900);
const body = panel(scene, 0, 0, BODY_W, BODY_H, UI.grime);
const well = scene.add.rectangle(WIN_X, WIN_Y, WIN_W, WIN_H, UI.ink).setStrokeStyle(1, UI.panelLip);
this.root.add([body, well]);
// Geometry mask lives in world space, so the widget is not meant to move
// after construction — nothing in the door scene moves it.
this.maskGfx = scene.make.graphics({}, false);
this.maskGfx.fillStyle(0xffffff);
this.maskGfx.fillRect(
opts.x + WIN_X - WIN_W / 2,
opts.y + WIN_Y - WIN_H / 2,
WIN_W,
WIN_H,
);
const mask = this.maskGfx.createGeometryMask();
for (let i = 0; i < 3; i++) {
const holder = scene.add.container(WIN_X + (i - 1) * DIGIT_SPACING, WIN_Y);
holder.setMask(mask);
const current = this.makeGlyph('0');
holder.add(current);
this.root.add(holder);
this.slots.push({ holder, current, incoming: null, t: 0 });
}
this.root.add(
chunkyButton(scene, -12, 10, 'IN', {
w: 26,
h: 13,
fill: UI.ok,
fontSize: 9,
onClick: () => this.bump(1),
}),
);
this.root.add(
chunkyButton(scene, 14, 11, 'out', {
w: 18,
h: 9,
fill: UI.panelLip,
textColour: UI.dim,
fontSize: 6,
onClick: () => this.bump(-1),
}),
);
}
get count(): number {
return this.shown;
}
update(deltaMs: number): void {
for (const slot of this.slots) {
const incoming = slot.incoming;
if (!incoming) continue;
slot.t += deltaMs;
const p = Math.min(1, slot.t / ROLL_MS);
const e = Phaser.Math.Easing.Quadratic.Out(p);
slot.current.y = -ROLL_TRAVEL * e;
incoming.y = ROLL_TRAVEL * (1 - e);
if (p >= 1) this.settle(slot);
}
}
destroy(): void {
for (const slot of this.slots) slot.holder.clearMask();
this.maskGfx.destroy();
this.root.destroy();
}
private makeGlyph(glyph: string): Phaser.GameObjects.Text {
return this.scene.add.text(0, 0, glyph, font(12, UI.neonGreen)).setOrigin(0.5);
}
/**
* The clunk is mechanical it fires even at the stops. The event does not:
* core/meters tracks clickerShown from the same event, so a press that moves
* nothing must not move it either.
*/
private bump(delta: number): void {
this.sfx?.clickerClunk();
const next = Phaser.Math.Clamp(this.shown + delta, 0, MAX_COUNT);
if (next === this.shown) return;
this.shown = next;
this.bus.emit('door:clicker', { direction: delta > 0 ? 'in' : 'out' });
this.render(next);
}
private render(value: number): void {
const digits = value.toString().padStart(3, '0');
for (let i = 0; i < this.slots.length; i++) {
const slot = this.slots[i];
const glyph = digits[i];
if (!slot || glyph === undefined) continue;
const settled = slot.incoming ?? slot.current;
if (settled.text === glyph) continue;
this.roll(slot, glyph);
}
}
private roll(slot: DigitSlot, glyph: string): void {
// A press mid-roll snaps the in-flight glyph home rather than stacking.
if (slot.incoming) this.settle(slot);
const incoming = this.makeGlyph(glyph);
incoming.y = ROLL_TRAVEL;
slot.holder.add(incoming);
slot.incoming = incoming;
slot.t = 0;
}
private settle(slot: DigitSlot): void {
const incoming = slot.incoming;
if (!incoming) return;
slot.current.destroy();
incoming.y = 0;
slot.current = incoming;
slot.incoming = null;
slot.t = 0;
}
}

288
src/ui/DialogueBox.ts Normal file
View File

@ -0,0 +1,288 @@
import Phaser from 'phaser';
import { UI, chunkyButton, font, panel } from './style';
import { drunkify, typedLength, wobbleAt, type RngLike } from './typo';
// Bottom-third dialogue for the door's sobriety test and the floor's cut-off
// chats. Deliberately ignorant of who is talking — the caller supplies a speaker
// tag, a line, and up to four things you can say back.
const PANEL_X = 320;
const PANEL_Y = 300;
const PANEL_W = 600;
const PANEL_H = 96;
const PAD = 10;
const BODY_SIZE = 10;
const CHOICE_H = 16;
const CHOICE_GAP = 8;
const CHOICE_MAX_W = 140;
export interface DialogueChoice {
id: string;
label: string;
}
export interface DialogueOptions {
speaker: string;
text: string;
choices?: readonly DialogueChoice[];
/** 0..1 — drives drunkify() plus per-character wobble. */
drunkenness?: number;
/** Required when drunkenness > 0; a deterministic stand-in is used if absent. */
rng?: RngLike;
charMs?: number;
depth?: number;
sfx?: { typeTick(): void };
onChoice?: (id: string) => void;
onComplete?: () => void;
}
interface Glyph {
ch: string;
/** Index into the mangled source string — what the reveal count is measured in. */
index: number;
x: number;
y: number;
}
/** LCG stand-in so a caller who forgot the rng gets slurring, not a crashed night. */
function fallbackRng(): RngLike {
let s = 0x2f6e2b1;
return {
next: (): number => {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 0x100000000;
},
};
}
/**
* Greedy word wrap that keeps every glyph's source index, which Phaser's own
* wordWrap throws away. The drunk path needs the mapping to reveal characters in
* source order while drawing them in wrapped positions.
*/
function layoutGlyphs(text: string, maxCols: number, charW: number, lineH: number, x0: number, y0: number): Glyph[] {
const out: Glyph[] = [];
let line = 0;
let col = 0;
let i = 0;
const push = (ch: string, index: number): void => {
out.push({ ch, index, x: x0 + col * charW, y: y0 + line * lineH });
col++;
};
while (i < text.length) {
const ch = text.charAt(i);
if (ch === '\n') {
line++;
col = 0;
i++;
continue;
}
if (ch === ' ') {
if (col > 0) push(ch, i);
i++;
continue;
}
let end = i;
while (end < text.length && text.charAt(end) !== ' ' && text.charAt(end) !== '\n') end++;
if (col > 0 && col + (end - i) > maxCols) {
// The space that pushed us over hangs off the end of the line — drop it.
if (out.at(-1)?.ch === ' ') out.pop();
line++;
col = 0;
}
for (let k = i; k < end; k++) {
if (col >= maxCols) {
line++;
col = 0;
}
push(text.charAt(k), k);
}
i = end;
}
return out;
}
export class DialogueBox {
private readonly root: Phaser.GameObjects.Container;
private readonly body: Phaser.GameObjects.Text | null = null;
private readonly glyphs: Glyph[] = [];
private readonly glyphTexts: Phaser.GameObjects.Text[] = [];
private readonly buttons: Phaser.GameObjects.Container[] = [];
private readonly full: string;
private readonly charMs: number;
private readonly drunkenness: number;
private readonly sfx: { typeTick(): void } | undefined;
private readonly onChoice: ((id: string) => void) | undefined;
private readonly onComplete: (() => void) | undefined;
private elapsed = 0;
private phase = 0;
private visible = 0;
private finished = false;
private completed = false;
private locked = false;
private destroyed = false;
constructor(scene: Phaser.Scene, opts: DialogueOptions) {
this.charMs = opts.charMs ?? 28;
this.drunkenness = Math.max(0, Math.min(1, opts.drunkenness ?? 0));
this.sfx = opts.sfx;
this.onChoice = opts.onChoice;
this.onComplete = opts.onComplete;
// Mangle once at construction; per-frame drunkify would shimmer the line.
this.full =
this.drunkenness > 0
? drunkify(opts.text, { drunkenness: this.drunkenness, rng: opts.rng ?? fallbackRng() })
: opts.text;
this.root = panel(scene, PANEL_X, PANEL_Y, PANEL_W, PANEL_H);
this.root.setDepth(opts.depth ?? 900);
const tag = scene.add.text(0, 0, opts.speaker, font(9, UI.ink)).setOrigin(0, 0.5);
const tagBg = scene.add
.rectangle(-PANEL_W / 2 + PAD - 4, -PANEL_H / 2, tag.width + 10, 13, UI.kebabAmber)
.setOrigin(0, 0.5);
tag.setPosition(-PANEL_W / 2 + PAD + 1, -PANEL_H / 2);
this.root.add([tagBg, tag]);
const bodyX = -PANEL_W / 2 + PAD;
const bodyY = -PANEL_H / 2 + 14;
const wrapPx = PANEL_W - PAD * 2;
if (this.drunkenness > 0) {
const probe = scene.add.text(0, 0, 'M', font(BODY_SIZE, UI.paper));
const charW = probe.width > 0 ? probe.width : BODY_SIZE * 0.6;
const lineH = Math.max(probe.height, BODY_SIZE + 2);
probe.destroy();
const cols = Math.max(1, Math.floor(wrapPx / charW));
this.glyphs = layoutGlyphs(this.full, cols, charW, lineH, bodyX, bodyY);
for (const g of this.glyphs) {
const t = scene.add.text(g.x, g.y, g.ch, font(BODY_SIZE, UI.paper)).setOrigin(0, 0).setVisible(false);
this.glyphTexts.push(t);
this.root.add(t);
}
} else {
this.body = scene.add
.text(bodyX, bodyY, '', { ...font(BODY_SIZE, UI.paper), wordWrap: { width: wrapPx } })
.setOrigin(0, 0);
this.root.add(this.body);
}
this.buildChoices(scene, opts.choices ?? []);
}
private buildChoices(scene: Phaser.Scene, choices: readonly DialogueChoice[]): void {
if (choices.length === 0) return;
const n = Math.min(choices.length, 4);
const avail = PANEL_W - PAD * 2;
const w = Math.min(CHOICE_MAX_W, (avail - CHOICE_GAP * (n - 1)) / n);
const rowW = w * n + CHOICE_GAP * (n - 1);
const y = PANEL_H / 2 - CHOICE_H / 2 - 6;
for (let i = 0; i < n; i++) {
const choice = choices[i];
if (choice === undefined) continue;
const x = -rowW / 2 + w / 2 + i * (w + CHOICE_GAP);
const btn = chunkyButton(scene, x, y, choice.label, {
w,
h: CHOICE_H,
fill: UI.grime,
textColour: UI.neonGreen,
fontSize: 8,
onClick: () => this.pick(choice.id),
});
btn.setVisible(false);
this.buttons.push(btn);
this.root.add(btn);
}
}
private pick(id: string): void {
if (this.destroyed || this.locked) return;
this.locked = true;
for (const btn of this.buttons) {
btn.setAlpha(0.55);
for (const child of btn.list) child.disableInteractive();
}
this.onChoice?.(id);
}
update(deltaMs: number): void {
if (this.destroyed) return;
// Both accumulators below are permanent state, so one non-finite delta would
// stall the reveal for the rest of the night with the buttons never shown.
// typo.ts clamps its own inputs too, but that only stops the NaN spreading —
// dropping the bad frame here is what keeps `elapsed` usable.
if (!Number.isFinite(deltaMs)) return;
if (this.drunkenness > 0) this.phase += deltaMs * 0.006;
if (!this.finished) {
this.elapsed += deltaMs;
this.reveal(typedLength(this.full, this.elapsed, this.charMs, this.drunkenness));
}
if (this.drunkenness > 0) {
this.glyphs.forEach((g, k) => {
const t = this.glyphTexts[k];
if (t?.visible !== true) return;
t.y = g.y + wobbleAt(g.index, this.drunkenness, this.phase);
});
}
}
skip(): void {
if (this.destroyed) return;
this.elapsed = this.full.length * this.charMs * 3;
this.reveal(this.full.length);
}
private reveal(next: number): void {
if (next > this.visible) {
// One tick per burst, and never for whitespace — otherwise it machine-guns.
if (/\S/.test(this.full.slice(this.visible, next))) this.sfx?.typeTick();
this.visible = next;
this.paint();
}
if (this.visible >= this.full.length && !this.finished) {
this.finished = true;
for (const btn of this.buttons) btn.setVisible(true);
if (!this.completed) {
this.completed = true;
this.onComplete?.();
}
}
}
private paint(): void {
if (this.body !== null) {
this.body.setText(this.full.slice(0, this.visible));
return;
}
this.glyphs.forEach((g, k) => {
this.glyphTexts[k]?.setVisible(g.index < this.visible);
});
}
get done(): boolean {
return this.finished;
}
// Guards every public entry point: Phaser's Text.preDestroy hands the canvas
// back to CanvasPool without nulling it, so a stray update()/skip() would
// setText() onto a canvas the pool may have already re-issued to another widget.
destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
this.root.destroy();
this.glyphs.length = 0;
this.glyphTexts.length = 0;
this.buttons.length = 0;
}
}

563
src/ui/JuiceDemoScene.ts Normal file
View File

@ -0,0 +1,563 @@
import Phaser from 'phaser';
import { EventBus } from '../core/EventBus';
import { GameClock } from '../core/GameClock';
import { SeededRNG } from '../core/SeededRNG';
import { Meters, freshNightState } from '../core/meters';
import { Sfx, type SfxName } from '../audio/Sfx';
import { TechnoEngine } from '../audio/TechnoEngine';
import { DAZZA_TEXTS } from '../data/strings/dazza';
import type { DressCodeRule } from '../data/types';
import { Clicker } from './Clicker';
import { DialogueBox } from './DialogueBox';
import { MeterHud } from './MeterHud';
import { Phone } from './Phone';
import { stamp, type StampHandle } from './Stamp';
import { UI, chunkyButton, font } from './style';
const W = 640;
const H = 360;
const SEED = 4207;
// Log slider range. 200Hz is "bass through a fire door"; 20kHz is "standing in
// front of the rig". A linear map spends 90% of the track above 2kHz, where
// nothing audible happens.
const MIN_HZ = 200;
const MAX_HZ = 20000;
const HZ_RATIO = MAX_HZ / MIN_HZ;
const TRACK_X0 = 96;
const TRACK_X1 = 340;
const TRACK_Y = 314;
const BEAT_X = 320;
// Sized so the 1.42x downbeat flash still clears the help line at y 54 — the pad
// grows from its centre, so the resting size has to leave room for the punch.
const BEAT_Y = 26;
const BEAT_SIZE = 28;
/** Flash decay. Shorter than a beat at 128BPM (469ms) so beats stay distinct. */
const FLASH_MS = 170;
const PATRON_X = 300;
const PATRON_Y = 200;
/** Reviewers mash the verdict buttons; keep a readable stack of overprints, not 400. */
const MAX_MARKS = 5;
const SFX_BUTTONS: ReadonlyArray<readonly [SfxName, string]> = [
['stampSlam', 'STAMP SLAM'],
['stampInk', 'STAMP INK'],
['clickerClunk', 'CLICKER'],
['doorBang', 'DOOR BANG'],
['radioStatic', 'RADIO HISS'],
['radioChirp', 'RADIO CHIRP'],
['phoneBuzz', 'PHONE BUZZ'],
['ropeUnhook', 'ROPE OFF'],
['denyCrowdOoh', 'CROWD OOOH'],
['typeTick', 'TYPE TICK'],
];
interface PendingBeat {
beatIndex: number;
audioTimeMs: number;
bar: boolean;
}
const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, v));
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);
/** chunkyButton hands back a Container; this is the only way to relabel one. */
function labelOf(btn: Phaser.GameObjects.Container): Phaser.GameObjects.Text | undefined {
return btn.list.find((o): o is Phaser.GameObjects.Text => o instanceof Phaser.GameObjects.Text);
}
/**
* LANE-JUICE proof scene: every widget and every SFX on one screen, plus a beat
* indicator that is honest about latency. Not shipped in the game this is the
* thing a reviewer opens to check the juice lane actually works.
*/
export class JuiceDemoScene extends Phaser.Scene {
private bus!: EventBus;
private clock!: GameClock;
private rng!: SeededRNG;
private meters!: Meters;
private engine!: TechnoEngine;
private sfx!: Sfx;
private hud!: MeterHud;
private phone!: Phone;
private clicker!: Clicker;
private dialogue: DialogueBox | null = null;
private dialogueKill = false;
private statusText!: Phaser.GameObjects.Text;
private hzText!: 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 patronRoot!: Phaser.GameObjects.Container;
private readonly stampMarks: StampHandle[] = [];
private readonly pendingBeats: PendingBeat[] = [];
private beatFlash = 0;
private beatFlashBar = false;
private lastBeat = -1;
private lastBar = -1;
private overrideHz: number | null = null;
private dragging = false;
private raining = false;
private dazzaIndex = 0;
private audioStarting = false;
constructor() {
super('JuiceDemo');
}
/**
* Phaser reuses the scene instance across restarts and only re-runs create(), so
* field initialisers fire once ever. Every per-run field has to be cleared here or
* it leaks into the next run: stale `pendingBeats` carry `audioTimeMs` from the old
* AudioContext, whose clock restarts near zero, and head-of-line-block drainBeats
* for as long as the previous run lasted.
*/
private resetRunState(): void {
this.pendingBeats.length = 0;
this.beatFlash = 0;
this.beatFlashBar = false;
this.lastBeat = -1;
this.lastBar = -1;
this.overrideHz = null;
this.dragging = false;
this.raining = false;
this.dazzaIndex = 0;
this.audioStarting = false;
this.dialogue = null;
this.dialogueKill = false;
// Shutdown already destroyed the objects these handles point at.
this.stampMarks.length = 0;
}
create(): void {
this.resetRunState();
this.bus = new EventBus();
this.rng = new SeededRNG(SEED);
this.clock = new GameClock(this.bus);
this.meters = new Meters(this.bus, freshNightState('theRoyal', 0, 80));
// 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);
this.sfx = new Sfx(this.engine.ctx, this.engine.sfxBus);
this.buildBackdrop();
this.buildBeatIndicator();
this.buildPatron();
this.buildStatus();
this.buildSfxColumn();
this.buildActionColumn();
this.buildAudioStrip();
this.hud = new MeterHud(this, this.bus, { x: 6, y: 6 });
this.clicker = new Clicker(this, this.bus, { x: 40, y: 320, sfx: this.sfx });
this.phone = new Phone(this, this.bus, { x: 600, y: 320, sfx: this.sfx });
this.bus.on('beat:tick', ({ beatIndex, audioTimeMs }) => {
this.pendingBeats.push({ beatIndex, audioTimeMs, bar: false });
});
// Fires straight after beat:tick for the same index, so the entry is there.
this.bus.on('beat:bar', ({ beatIndex }) => {
const b = this.pendingBeats.find((p) => p.beatIndex === beatIndex);
if (b) b.bar = true;
});
this.clock.start();
// Kick the HUD once so it shows the opening numbers instead of empty bars.
this.bus.emit('meters:delta', { vibe: 0 });
this.buildUnlockOverlay();
this.input.keyboard?.on('keydown-SPACE', () => this.dialogue?.skip());
this.events.once('shutdown', () => this.teardown());
}
// --- world ----------------------------------------------------------------
private buildBackdrop(): void {
this.add.rectangle(W / 2, H / 2, W, H, UI.ink);
this.add.rectangle(W / 2, 62, W, 1, UI.panelLip, 0.35);
this.add.rectangle(W / 2, 286, W, 1, UI.panelLip, 0.35);
this.add
.text(W / 2, 54, 'the door · press the buttons · nothing here is load-bearing', font(7, UI.paper))
.setOrigin(0.5, 0)
.setAlpha(0.4);
this.add
.text(
TRACK_X0,
336,
'sounds down the left · verdicts down the right · drag MUFFLE to open the door on the bass',
font(7, UI.paper),
)
.setAlpha(0.45);
}
private buildBeatIndicator(): void {
this.beatRing = this.add
.rectangle(BEAT_X, BEAT_Y, BEAT_SIZE + 10, BEAT_SIZE + 10, UI.neonPink, 0)
.setStrokeStyle(1, UI.neonPink, 0.25);
this.beatPad = this.add
.rectangle(BEAT_X, BEAT_Y, BEAT_SIZE, BEAT_SIZE, UI.neonGreen, 0.12)
.setStrokeStyle(1, UI.panelLip);
// Beside the pad, not under it — the backdrop's help line runs the full width
// of this row and a label below the ring lands right on top of it.
this.add.text(BEAT_X - 26, BEAT_Y, 'BEAT', font(7, UI.paper)).setOrigin(1, 0.5).setAlpha(0.5);
}
private buildPatron(): void {
// Stand-in only. The real doll needs a Patron record and lives in patrons/.
const root = this.add.container(PATRON_X, PATRON_Y);
this.patronRoot = root;
root.add(this.add.rectangle(0, 0, 40, 90, UI.grime).setStrokeStyle(1, UI.panelLip));
root.add(this.add.rectangle(0, -58, 26, 26, 0x8a6a52).setStrokeStyle(1, UI.ink));
root.add(this.add.rectangle(0, -14, 40, 3, UI.neonPink, 0.35));
root.add(
this.add.text(0, 56, 'SOME BLOKE', font(7, UI.paper)).setOrigin(0.5, 0).setAlpha(0.5),
);
root.add(
this.add.text(0, 66, 'stamp lands here', font(7, UI.paper)).setOrigin(0.5, 0).setAlpha(0.28),
);
}
private buildStatus(): void {
this.statusText = this.add.text(W - 6, 6, '', font(8, UI.neonGreen)).setOrigin(1, 0);
}
// --- controls -------------------------------------------------------------
private buildSfxColumn(): void {
this.add.text(12, 66, 'SOUNDS', font(7, UI.kebabAmber)).setAlpha(0.8);
SFX_BUTTONS.forEach(([name, label], i) => {
chunkyButton(this, 50, 80 + i * 16, label, {
w: 76,
h: 13,
fontSize: 7,
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(),
});
}
private buildActionColumn(): void {
this.add.text(528, 66, 'THE JOB', font(7, UI.kebabAmber)).setAlpha(0.8);
const rows: ReadonlyArray<readonly [string, () => void, number]> = [
['NOT TONIGHT', () => this.doStamp('DENIED', UI.danger), UI.danger],
['IN YOU GO', () => this.doStamp('ADMITTED', UI.ok), UI.ok],
['HAVE A WORD', () => this.openDialogue(false), UI.panelLip],
['ABSOLUTELY GONE', () => this.openDialogue(true), UI.panelLip],
['TEXT FROM DAZZA', () => this.pushDazza(), UI.kebabAmber],
['VIBE UP', () => this.bus.emit('meters:delta', { vibe: 10 }), UI.panelLip],
['VIBE DOWN', () => this.bus.emit('meters:delta', { vibe: -15 }), UI.panelLip],
['AGGRO UP', () => this.bus.emit('meters:delta', { aggro: 15 }), UI.panelLip],
['HYPE UP', () => this.bus.emit('meters:delta', { hype: 0.3 }), UI.panelLip],
['HEAT STRIKE', () => this.bus.emit('heat:strike', { reason: 'let a bloke in wearing crocs' }), UI.danger],
];
rows.forEach(([label, onClick, fill], i) => {
chunkyButton(this, 580, 80 + i * 16, label, {
w: 104,
h: 13,
fontSize: 7,
fill,
textColour: fill === UI.kebabAmber ? UI.ink : UI.paper,
onClick,
});
});
}
private buildAudioStrip(): void {
this.hzText = this.add.text(TRACK_X0, 296, '', font(8, UI.neonGreen));
const track = this.add
.rectangle((TRACK_X0 + TRACK_X1) / 2, TRACK_Y, TRACK_X1 - TRACK_X0, 6, UI.panel)
.setStrokeStyle(1, UI.panelLip);
track.setInteractive({ useHandCursor: true });
track.on('pointerdown', (p: Phaser.Input.Pointer) => this.setCutoffFromX(p.x));
this.handle = this.add
.rectangle(TRACK_X1, TRACK_Y, 8, 16, UI.neonPink)
.setStrokeStyle(1, UI.ink);
this.handle.setInteractive({ useHandCursor: true, draggable: true });
this.handle.on('dragstart', () => {
this.dragging = true;
});
this.handle.on('drag', (_p: Phaser.Input.Pointer, dragX: number) => this.setCutoffFromX(dragX));
this.handle.on('dragend', () => {
this.dragging = false;
});
this.add.text(TRACK_X0, 300, 'MUFFLE', font(7, UI.paper)).setOrigin(0, 1).setAlpha(0.45);
chunkyButton(this, 382, TRACK_Y, 'LET IT GO', {
w: 68,
h: 14,
fontSize: 7,
onClick: () => this.releaseCutoff(),
});
this.locButton = chunkyButton(this, 474, TRACK_Y, 'STEP INSIDE', {
w: 100,
h: 14,
fontSize: 7,
fill: UI.neonPink,
onClick: () => this.toggleLocation(),
});
}
private buildUnlockOverlay(): void {
const root = this.add.container(0, 0).setDepth(2000);
const veil = this.add.rectangle(W / 2, H / 2, W, H, UI.ink, 0.92);
const head = this.add
.text(W / 2, H / 2 - 14, 'CLICK TO START AUDIO', font(16, UI.neonGreen))
.setOrigin(0.5);
const hint = this.add
.text(
W / 2,
H / 2 + 12,
'the browser wants a hand on the door before it lets the bass out',
font(8, UI.paper),
)
.setOrigin(0.5)
.setAlpha(0.55);
root.add([veil, head, hint]);
veil.setInteractive();
veil.on('pointerdown', () => {
if (this.audioStarting) return;
this.audioStarting = true;
head.setText('OPENING UP...');
// A restart mid-unlock destroys this overlay and swaps the engine, but the
// promise still settles. Identity alone is not enough: teardown() destroys
// the engine without replacing it, so `this.engine` still points at the
// closed one. Check the context state too, or we start a closed context and
// poke text objects the next run has already replaced.
const engine = this.engine;
const stale = (): boolean => this.engine !== engine || engine.ctx.state === 'closed';
void engine
.unlock()
.then(() => {
if (stale()) return;
engine.start();
root.destroy(true);
})
.catch(() => {
if (stale()) return;
this.audioStarting = false;
head.setText('AUDIO KNOCKED US BACK');
hint.setText('give it another click');
});
});
}
// --- actions --------------------------------------------------------------
/**
* Marks are parented to the patron so they ride along and die with him, which is
* how the real door will use them. The scene still owns the handles: nothing else
* reaps a decal, so the oldest goes when the stack gets past reading.
*/
private doStamp(text: string, colour: number): void {
while (this.stampMarks.length >= MAX_MARKS) this.stampMarks.shift()?.decal.destroy();
this.stampMarks.push(
stamp(this, {
x: PATRON_X,
y: PATRON_Y - 20,
text,
colour,
depth: 100,
sfx: this.sfx,
parent: this.patronRoot,
// Fresh seed per impression, so a stack of DENIEDs reads as a knackered
// stamp rather than one mark printed five times.
seed: this.rng.stream('juice-demo-stamp').int(0, 0xffff),
onSlam: () => {
if (text === 'DENIED') this.sfx.denyCrowdOoh();
},
}),
);
}
private openDialogue(drunk: boolean): void {
this.dialogue?.destroy();
this.dialogueKill = false;
this.dialogue = new DialogueBox(this, {
speaker: drunk ? 'ABSOLUTELY GONE' : 'BLOKE IN A GOOD SHIRT',
text: drunk
? 'nah mate listen right, im not even that bad, i had like two, ask her, ask her, im basically the designated driver of this whole situation'
: 'evening. me and the boys are on the list, should be under Cam, might be under Cameron, might be under his missus actually.',
drunkenness: drunk ? 0.95 : 0,
rng: this.rng.stream('juice-demo-drunk'),
depth: 950,
sfx: this.sfx,
choices: [
{ id: 'in', label: 'IN YOU GO' },
{ id: 'test', label: 'WALK THE LINE' },
{ id: 'out', label: 'NOT TONIGHT' },
],
onChoice: () => {
this.sfx.clickerClunk();
this.dialogueKill = true;
},
});
}
private pushDazza(): void {
const line = DAZZA_TEXTS[this.dazzaIndex % DAZZA_TEXTS.length];
this.dazzaIndex++;
if (!line) return;
// Synthetic rule: the Phone only cares that the field is present (that is
// what lights the NEW badge), never that the predicate is real.
const rule: DressCodeRule | undefined =
line.ruleId === undefined
? undefined
: { id: line.ruleId, text: line.text, activeFrom: line.fromMin, violates: () => false };
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 setCutoffFromX(x: number): void {
const hz = hzFromT((clamp(x, TRACK_X0, TRACK_X1) - TRACK_X0) / (TRACK_X1 - TRACK_X0));
this.overrideHz = hz;
this.engine.setCutoffOverride(hz);
}
private releaseCutoff(): void {
this.overrideHz = null;
this.engine.setCutoffOverride(null);
}
/**
* Goes through the bus rather than calling engine.setLocation directly the
* point of the button is to prove the `audio:location` path works end to end.
* The override has to go first or the sweep is pinned and you hear nothing.
*/
private toggleLocation(): void {
this.releaseCutoff();
const next = this.engine.location === 'door' ? 'floor' : 'door';
this.bus.emit('audio:location', { location: next });
this.bus.emit('night:phaseChange', { location: next });
labelOf(this.locButton)?.setText(next === 'floor' ? 'BACK ON THE DOOR' : 'STEP INSIDE');
}
// --- loop -----------------------------------------------------------------
override update(_time: number, delta: number): void {
this.clock.update(delta);
this.drainBeats();
this.renderBeat(delta);
this.syncSlider();
this.renderStatus();
this.hud.update(delta);
this.phone.update(delta);
this.clicker.update(delta);
this.dialogue?.update(delta);
if (this.dialogueKill) {
this.dialogueKill = false;
this.dialogue?.destroy();
this.dialogue = null;
}
}
/**
* `audioTimeMs` is the SCHEDULED time and arrives up to a lookahead early.
* Flashing on receipt would put the light ~100ms ahead of the sound, which
* reads as the whole demo being broken. Hold the beat until the audio clock
* catches up to it.
*/
private drainBeats(): void {
const nowMs = this.engine.ctx.currentTime * 1000;
while (this.pendingBeats.length > 0) {
const b = this.pendingBeats[0];
if (b === undefined || b.audioTimeMs > nowMs) break;
this.pendingBeats.shift();
this.lastBeat = b.beatIndex;
if (b.bar) this.lastBar = Math.floor(b.beatIndex / 4);
this.beatFlash = 1;
this.beatFlashBar = b.bar;
}
}
private renderBeat(delta: number): void {
this.beatFlash = Math.max(0, this.beatFlash - delta / FLASH_MS);
const f = this.beatFlash;
const colour = this.beatFlashBar ? UI.neonPink : UI.neonGreen;
this.beatPad.setFillStyle(colour, 0.1 + f * 0.9);
this.beatPad.setScale(1 + f * (this.beatFlashBar ? 0.42 : 0.16));
this.beatRing.setStrokeStyle(1, colour, this.beatFlashBar ? 0.2 + f * 0.6 : 0.18);
this.beatRing.setScale(this.beatFlashBar ? 1 + f * 0.25 : 1);
}
/** In AUTO the handle rides the engine's live sweep; while dragging it leads. */
private syncSlider(): void {
if (this.dragging) return;
const t = tFromHz(this.overrideHz ?? this.engine.cutoffHz);
this.handle.x = TRACK_X0 + t * (TRACK_X1 - TRACK_X0);
}
private renderStatus(): void {
const hz = Math.round(this.engine.cutoffHz);
this.hzText.setText(`${hz} Hz ${this.overrideHz === null ? '(AUTO)' : '(HELD)'}`);
const audio = this.engine.unlocked ? (this.engine.running ? 'LIVE' : 'IDLE') : 'LOCKED';
this.statusText.setText(
[
this.clock.label,
`AUDIO ${audio} · ${this.engine.bpm} BPM`,
`BEAT ${this.lastBeat} · BAR ${this.lastBar}`,
`${this.engine.location.toUpperCase()} · ${hz} Hz`,
].join('\n'),
);
}
private teardown(): void {
this.input.keyboard?.off('keydown-SPACE');
for (const m of this.stampMarks) m.decal.destroy();
this.stampMarks.length = 0;
this.dialogue?.destroy();
this.dialogue = null;
this.phone.destroy();
this.clicker.destroy();
this.hud.destroy();
// Sfx first: engine.destroy() closes the context its nodes hang off.
this.sfx.destroy();
this.engine.destroy();
this.meters.destroy();
this.bus.removeAll();
}
}

250
src/ui/MeterHud.ts Normal file
View File

@ -0,0 +1,250 @@
import Phaser from 'phaser';
import { UI, font } from './style';
import type { EventBus } from '../core/EventBus';
export interface MeterHudOptions {
x?: number;
y?: number;
depth?: number;
}
const BAR_X = 30;
const BAR_W = 84;
const BAR_H = 7;
const ROW_H = 11;
const SEGMENTS = 10;
const SEG_GAP = 1;
const SEG_W = (BAR_W - SEG_GAP * (SEGMENTS - 1)) / SEGMENTS;
const SLOT = 12;
const SLOT_GAP = 3;
const HEAT_SLOTS = 3;
// Below this the neon sign starts dying; above it the bar is rock steady.
const VIBE_FLICKER_BELOW = 25;
// Above this the crowd strip starts breathing.
const AGGRO_PULSE_ABOVE = 70;
const AGGRO_COOL = 0x2f6f8f;
const clamp01 = (v: number): number => (v < 0 ? 0 : v > 1 ? 1 : v);
function mix(a: number, b: number, t: number): number {
const c = Phaser.Display.Color.Interpolate.ColorWithColor(
Phaser.Display.Color.ValueToColor(a),
Phaser.Display.Color.ValueToColor(b),
100,
Math.round(clamp01(t) * 100),
);
return Phaser.Display.Color.GetColor(c.r, c.g, c.b);
}
// Frame-rate independent ease: same settle time at 30fps as at 144.
const approach = (cur: number, target: number, deltaMs: number, tauMs: number): number =>
target + (cur - target) * Math.exp(-deltaMs / tauMs);
interface HeatSlot {
mark: Phaser.GameObjects.Container;
scale: number;
}
/**
* Vibe / Aggro / Hype / Heat readout. Read-only: it listens to `meters:changed`
* and `heat:strike` and never emits or mutates anything. Each meter gets its own
* visual language so a glance tells you which number moved.
*/
export class MeterHud {
private readonly root: Phaser.GameObjects.Container;
private readonly offs: Array<() => void> = [];
private vibe = 50;
private vibeShown = 50;
private aggro = 0;
private aggroShown = 0;
private hype = 1;
private hypeScale = 1;
private strikes = 0;
// Accumulated phases — deterministic stand-in for noise (no Math.random).
private flickerPhase = 0;
private pulsePhase = 0;
private readonly vibeGlow: Phaser.GameObjects.Rectangle;
private readonly vibeFill: Phaser.GameObjects.Rectangle;
private readonly aggroStrip: Phaser.GameObjects.Container;
private readonly segs: Phaser.GameObjects.Rectangle[] = [];
private readonly hypeBadge: Phaser.GameObjects.Container;
private readonly hypeText: Phaser.GameObjects.Text;
private readonly slots: HeatSlot[] = [];
constructor(scene: Phaser.Scene, bus: EventBus, opts: MeterHudOptions = {}) {
const rowY = (i: number): number => i * ROW_H;
this.root = scene.add
.container(opts.x ?? 6, opts.y ?? 6)
.setDepth(opts.depth ?? 1000)
.setScrollFactor(0);
this.root.add(
scene.add
.rectangle(-4, -4, BAR_X + BAR_W + 8, ROW_H * 4 + 6, UI.ink, 0.55)
.setOrigin(0, 0)
.setStrokeStyle(1, UI.panelLip, 0.4),
);
const label = (text: string, row: number): Phaser.GameObjects.Text =>
scene.add.text(0, rowY(row), text, font(7, UI.paper)).setAlpha(0.55);
this.root.add([label('VIBE', 0), label('AGGRO', 1), label('HYPE', 2), label('HEAT', 3)]);
// --- Vibe: neon sign. Glow is just a fatter, fainter copy of the tube.
const vibeMidY = rowY(0) + BAR_H / 2;
this.root.add(
scene.add.rectangle(BAR_X, vibeMidY, BAR_W, BAR_H, UI.ink, 0.7).setOrigin(0, 0.5),
);
this.vibeGlow = scene.add
.rectangle(BAR_X, vibeMidY, BAR_W, BAR_H + 5, UI.neonGreen, 0.18)
.setOrigin(0, 0.5);
this.vibeFill = scene.add
.rectangle(BAR_X, vibeMidY, BAR_W, BAR_H - 2, UI.neonGreen)
.setOrigin(0, 0.5);
this.root.add([this.vibeGlow, this.vibeFill]);
// --- Aggro: segmented crowd-temperature strip, cool on the left.
this.aggroStrip = scene.add.container(BAR_X, rowY(1) + BAR_H / 2);
for (let i = 0; i < SEGMENTS; i++) {
const seg = scene.add.rectangle(
i * (SEG_W + SEG_GAP) + SEG_W / 2,
0,
SEG_W,
BAR_H,
mix(AGGRO_COOL, UI.danger, i / (SEGMENTS - 1)),
);
this.segs.push(seg);
this.aggroStrip.add(seg);
}
this.root.add(this.aggroStrip);
// --- Hype: a bonus badge, not a bar. It only ever multiplies good news.
this.hypeBadge = scene.add.container(BAR_X + 14, rowY(2) + BAR_H / 2);
this.hypeText = scene.add.text(0, 0, 'x1.0', font(8, UI.kebabAmber)).setOrigin(0.5);
this.hypeBadge.add([
scene.add
.rectangle(0, 0, 28, 10, UI.kebabAmber, 0.16)
.setStrokeStyle(1, UI.kebabAmber, 0.7),
this.hypeText,
]);
this.root.add(this.hypeBadge);
// --- Heat: three licence-stamp slots. The third is the one that shuts you.
for (let i = 0; i < HEAT_SLOTS; i++) {
const final = i === HEAT_SLOTS - 1;
const cx = BAR_X + i * (SLOT + SLOT_GAP) + SLOT / 2;
const cy = rowY(3) + BAR_H / 2;
this.root.add(
scene.add
.rectangle(cx, cy, SLOT, SLOT, UI.ink, 0.35)
.setStrokeStyle(1, UI.paper, final ? 0.55 : 0.3),
);
const mark = scene.add.container(cx, cy).setVisible(false);
const crossColour = final ? UI.paper : UI.danger;
mark.add(scene.add.rectangle(0, 0, SLOT + 2, SLOT + 2, UI.danger, final ? 0.95 : 0.3));
for (const angle of [45, -45]) {
mark.add(
scene.add
.rectangle(0, 0, SLOT + 4, final ? 3 : 2, crossColour)
.setAngle(angle)
.setAlpha(final ? 1 : 0.9),
);
}
if (final) {
mark.add(
scene.add.rectangle(0, 0, SLOT + 3, SLOT + 3, 0x000000, 0).setStrokeStyle(2, UI.danger),
);
mark.setAngle(-8);
}
this.root.add(mark);
this.slots.push({ mark, scale: 1 });
}
this.offs.push(
bus.on('meters:changed', ({ vibe, aggro, hype }) => {
this.vibe = vibe;
this.aggro = aggro;
if (hype > this.hype) this.hypeScale = 1.7;
this.hype = hype;
this.hypeText.setText(`x${hype.toFixed(1)}`);
}),
bus.on('heat:strike', () => {
const slot = this.slots[this.strikes];
this.strikes++;
if (!slot) return;
slot.scale = 2.4;
slot.mark.setVisible(true);
}),
);
this.redraw();
}
update(deltaMs: number): void {
this.vibeShown = approach(this.vibeShown, this.vibe, deltaMs, 90);
this.aggroShown = approach(this.aggroShown, this.aggro, deltaMs, 110);
this.hypeScale = approach(this.hypeScale, 1, deltaMs, 80);
const dying = clamp01((VIBE_FLICKER_BELOW - this.vibeShown) / VIBE_FLICKER_BELOW);
this.flickerPhase += deltaMs * 0.006 * (1 + dying * 3);
const hot = clamp01((this.aggroShown - AGGRO_PULSE_ABOVE) / (100 - AGGRO_PULSE_ABOVE));
this.pulsePhase += deltaMs * 0.004 * (1 + hot * 2.5);
for (const slot of this.slots) {
slot.scale = approach(slot.scale, 1, deltaMs, 70);
slot.mark.setScale(slot.scale);
}
this.redraw();
}
private redraw(): void {
const vibeT = clamp01(this.vibeShown / 100);
const colour = mix(UI.neonPink, UI.neonGreen, vibeT);
const lit = Math.max(0.001, BAR_W * vibeT);
this.vibeFill.setSize(lit, BAR_H - 2).setFillStyle(colour);
this.vibeGlow.setSize(lit + 4, BAR_H + 5).setFillStyle(colour);
const dying = clamp01((VIBE_FLICKER_BELOW - this.vibeShown) / VIBE_FLICKER_BELOW);
const a = this.neonAlpha(dying);
this.vibeFill.setAlpha(a);
this.vibeGlow.setAlpha(0.18 * a);
const litSegs = (this.aggroShown / 100) * SEGMENTS;
const hot = clamp01((this.aggroShown - AGGRO_PULSE_ABOVE) / (100 - AGGRO_PULSE_ABOVE));
const breath = 0.5 + 0.5 * Math.sin(this.pulsePhase);
const pulse = 1 - 0.4 * hot * breath;
for (let i = 0; i < this.segs.length; i++) {
const seg = this.segs[i];
if (!seg) continue;
const on = clamp01(litSegs - i);
seg.setAlpha(on > 0 ? (0.35 + 0.65 * on) * pulse : 0.14);
}
this.aggroStrip.setScale(1, 1 + 0.28 * hot * breath);
this.hypeBadge.setScale(this.hypeScale);
}
/**
* Three sines at incommensurate rates stand in for noise; the threshold rises
* with `dying`, so blackouts get more frequent as the sign gives up.
*/
private neonAlpha(dying: number): number {
if (dying <= 0) return 1;
const p = this.flickerPhase;
const n = 0.5 * Math.sin(p) + 0.3 * Math.sin(p * 2.37 + 1.1) + 0.2 * Math.sin(p * 5.13 + 2.7);
if (n < -0.8 + dying * 1.1) return 0.12;
return 1 - 0.15 * dying * (0.5 + 0.5 * Math.sin(p * 3.1));
}
destroy(): void {
for (const off of this.offs) off();
this.offs.length = 0;
this.root.destroy();
}
}

395
src/ui/Phone.ts Normal file
View File

@ -0,0 +1,395 @@
import Phaser from 'phaser';
import type { EventBus } from '../core/EventBus';
import { UI, font, panel } from './style';
// The player's only line to management. Dazza is a man who is frightened of the
// owner, so every buzz is a small emergency. The phone is deliberately fiddly:
// looking at it costs you attention at the door, which is exactly the point —
// see `door:phoneTheatre`.
const W = 640;
const H = 360;
const PHONE_W = 22;
const PHONE_H = 38;
const PANEL_W = 150;
const PANEL_H = 180;
const PAD = 6;
const BUBBLE_MAX_W = 104;
const BUBBLE_GAP = 4;
const HEADER_H = 13;
const DOTS_H = 12;
const TOAST_W = 112;
const TOAST_H = 26;
const TOAST_MS = 3500;
const TOAST_CHARS = 40;
const BUZZ_MS = 400;
const BUZZ_AMP = 2.4;
const BUZZ_CYCLES = 9;
const TYPING_MS = 700;
const DOTS_STEP_MS = 220;
interface ThreadMessage {
from: 'dazza' | 'you';
text: string;
carriedRule: boolean;
/** clock-min the text landed, if a clock:tick has been seen yet */
clockMin?: number;
}
export interface PhoneSfx {
phoneBuzz(): void;
}
export interface PhoneOptions {
x: number;
y: number;
sfx?: PhoneSfx;
depth?: number;
}
/** Minutes since 21:00 -> venue-clock label. */
function clockLabel(clockMin: number): string {
const total = (21 * 60 + Math.max(0, Math.floor(clockMin))) % (24 * 60);
const h = Math.floor(total / 60);
const m = total % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
export class Phone {
private readonly scene: Phaser.Scene;
private readonly bus: EventBus;
private readonly sfx: PhoneSfx | undefined;
private readonly depth: number;
private readonly homeX: number;
private readonly homeY: number;
/** -1 when the phone sits on the right of the canvas: overlays open inward. */
private readonly side: number;
private readonly root: Phaser.GameObjects.Container;
private readonly screen: Phaser.GameObjects.Rectangle;
private readonly unreadDot: Phaser.GameObjects.Arc;
private readonly threadRoot: Phaser.GameObjects.Container;
private readonly threadBody: Phaser.GameObjects.Container;
private readonly dots: Phaser.GameObjects.Text;
private toast: Phaser.GameObjects.Container | undefined;
private toastMs = 0;
private toastTween: Phaser.Tweens.Tween | undefined;
private readonly offs: Array<() => void> = [];
private readonly messages: ThreadMessage[] = [];
private pending: ThreadMessage | undefined;
private pendingMs = 0;
private dotsMs = 0;
private open_ = false;
private openMs = 0;
private clockMin: number | undefined;
private readonly buzzPhase = { v: 1 };
private buzzTween: Phaser.Tweens.Tween | undefined;
private slideTween: Phaser.Tweens.Tween | undefined;
private readonly glowTween: Phaser.Tweens.Tween;
constructor(scene: Phaser.Scene, bus: EventBus, opts: PhoneOptions) {
this.scene = scene;
this.bus = bus;
this.sfx = opts.sfx;
this.depth = opts.depth ?? 900;
this.homeX = opts.x;
this.homeY = opts.y;
this.side = opts.x > W / 2 ? -1 : 1;
this.root = scene.add.container(this.homeX, this.homeY).setDepth(this.depth);
const body = scene.add.rectangle(0, 0, PHONE_W, PHONE_H, UI.grime).setStrokeStyle(1, UI.ink);
this.screen = scene.add.rectangle(0, -1, PHONE_W - 6, PHONE_H - 12, UI.ink);
const glow = scene.add.rectangle(0, -1, PHONE_W - 8, PHONE_H - 14, UI.neonGreen, 0.09);
const speaker = scene.add.rectangle(0, -PHONE_H / 2 + 4, 7, 1, UI.panelLip);
const homeKey = scene.add.circle(0, PHONE_H / 2 - 4, 2, UI.panelLip);
this.unreadDot = scene.add.circle(PHONE_W / 2 - 3, -PHONE_H / 2 + 3, 2.5, UI.neonPink).setVisible(false);
this.root.add([body, this.screen, glow, speaker, homeKey, this.unreadDot]);
// Standby breathing — a dark screen that is completely still reads as a prop.
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());
const anchor = this.threadAnchor();
this.threadRoot = scene.add.container(anchor.x, anchor.y).setDepth(this.depth + 2).setVisible(false);
const chrome = panel(scene, 0, 0, PANEL_W, PANEL_H);
const title = scene.add.text(-PANEL_W / 2 + PAD, -PANEL_H / 2 + 4, 'DAZZA (MANAGEMENT)', font(7, UI.paper));
title.setAlpha(0.55);
const rule = scene.add.rectangle(0, -PANEL_H / 2 + HEADER_H, PANEL_W - PAD * 2, 1, UI.panelLip, 0.7);
this.threadBody = scene.add.container(0, 0);
this.dots = scene.add
.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]);
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; }));
}
get isOpen(): boolean {
return this.open_;
}
open(): void {
if (this.open_) return;
this.open_ = true;
this.openMs = 0;
this.unreadDot.setVisible(false);
this.screen.setFillStyle(UI.panel);
this.dismissToast();
const anchor = this.threadAnchor();
this.threadRoot.setVisible(true).setPosition(anchor.x, anchor.y + 14).setAlpha(0);
this.slideTween?.remove();
this.slideTween = this.scene.tweens.add({
targets: this.threadRoot,
y: anchor.y,
alpha: 1,
duration: 160,
ease: 'Back.easeOut',
});
this.layoutThread();
}
close(): void {
if (!this.open_) return;
this.open_ = false;
this.screen.setFillStyle(UI.ink);
this.flushPending();
const anchor = this.threadAnchor();
this.slideTween?.remove();
this.slideTween = this.scene.tweens.add({
targets: this.threadRoot,
y: anchor.y + 14,
alpha: 0,
duration: 130,
ease: 'Quad.easeIn',
onComplete: () => this.threadRoot.setVisible(false),
});
// Standing at the door reading your phone while a queue waits is the purest
// expression of power a bouncer has. The door scene pays hype for it.
this.bus.emit('door:phoneTheatre', { durationMs: Math.round(this.openMs) });
}
toggle(): void {
if (this.open_) this.close();
else this.open();
}
update(deltaMs: number): void {
if (this.open_) this.openMs += deltaMs;
if (this.pending) {
this.pendingMs += deltaMs;
this.dotsMs += deltaMs;
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.pending = undefined;
this.dots.setVisible(false);
this.layoutThread();
}
}
if (this.toast) {
this.toastMs += deltaMs;
if (this.toastMs >= TOAST_MS) this.dismissToast();
}
}
destroy(): void {
for (const off of this.offs) off();
this.offs.length = 0;
this.buzzTween?.remove();
this.slideTween?.remove();
// Tweens outlive their targets: destroy() outside a scene shutdown leaves
// anything still running writing to a dead GameObject every frame.
this.glowTween.remove();
this.dismissToast();
this.threadRoot.destroy(true);
this.root.destroy(true);
}
private receive(text: string, carriedRule: boolean): void {
const msg: ThreadMessage = { from: 'dazza', text, carriedRule, ...(this.clockMin !== undefined ? { clockMin: this.clockMin } : {}) };
this.buzz();
this.sfx?.phoneBuzz();
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);
this.pending = msg;
this.pendingMs = 0;
this.dotsMs = 0;
this.dots.setText('.').setVisible(true);
this.layoutThread();
return;
}
this.deliverClosed(msg);
}
private deliverClosed(msg: ThreadMessage): void {
this.messages.push(msg);
if (msg.carriedRule) this.unreadDot.setVisible(true);
this.showToast(msg.text);
}
/**
* 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
* toast or unread dot to announce it to a player who has looked away.
*/
private flushPending(): void {
const msg = this.pending;
if (!msg) return;
this.pending = undefined;
this.pendingMs = 0;
this.dots.setVisible(false);
this.deliverClosed(msg);
}
/** Vibration driven off a tweened phase so it is frame-rate stable and seedless. */
private buzz(): void {
this.buzzTween?.remove();
this.buzzPhase.v = 0;
this.buzzTween = this.scene.tweens.add({
targets: this.buzzPhase,
v: 1,
duration: BUZZ_MS,
onUpdate: () => {
const t = this.buzzPhase.v;
const amp = BUZZ_AMP * (1 - t);
this.root.setPosition(
this.homeX + Math.sin(t * Math.PI * 2 * BUZZ_CYCLES) * amp,
this.homeY + Math.cos(t * Math.PI * 2 * (BUZZ_CYCLES + 4)) * amp * 0.55,
);
},
onComplete: () => this.root.setPosition(this.homeX, this.homeY),
});
}
private threadAnchor(): { x: number; y: number } {
const cx = Phaser.Math.Clamp(this.homeX, PANEL_W / 2 + 4, W - PANEL_W / 2 - 4);
const cy = Phaser.Math.Clamp(this.homeY - PHONE_H / 2 - 6 - PANEL_H / 2, PANEL_H / 2 + 4, H - PANEL_H / 2 - 4);
return { x: cx, y: cy };
}
private showToast(text: string): void {
this.dismissToast();
const preview = text.length > TOAST_CHARS ? `${text.slice(0, TOAST_CHARS).trimEnd()}` : text;
const targetX = Phaser.Math.Clamp(
this.homeX + this.side * (PHONE_W / 2 + 5 + TOAST_W / 2),
TOAST_W / 2 + 4,
W - TOAST_W / 2 - 4,
);
const y = Phaser.Math.Clamp(this.homeY - 8, TOAST_H / 2 + 4, H - TOAST_H / 2 - 4);
const c = panel(this.scene, targetX - this.side * 10, y, TOAST_W, TOAST_H).setDepth(this.depth + 1).setAlpha(0);
const from = this.scene.add.text(-TOAST_W / 2 + 5, -TOAST_H / 2 + 3, 'dazza', font(6, UI.neonPink));
const body = this.scene.add.text(-TOAST_W / 2 + 5, -TOAST_H / 2 + 11, preview, {
...font(7, UI.paper),
wordWrap: { width: TOAST_W - 10, useAdvancedWrap: true },
});
c.add([from, body]);
this.toast = c;
this.toastMs = 0;
this.toastTween = this.scene.tweens.add({ targets: c, x: targetX, alpha: 1, duration: 180, ease: 'Quad.easeOut' });
}
private dismissToast(): void {
this.toastTween?.remove();
this.toastTween = undefined;
this.toast?.destroy(true);
this.toast = undefined;
this.toastMs = 0;
}
private layoutThread(): void {
this.threadBody.removeAll(true);
if (!this.open_) return;
const top = -PANEL_H / 2 + HEADER_H + 3;
const floor = PANEL_H / 2 - PAD - (this.pending ? DOTS_H : 0);
let cursor = floor;
// 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--) {
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;
}
}
/** 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 } {
const incoming = msg.from === 'dazza';
const pad = 4;
const c = this.scene.add.container(0, 0);
const text = this.scene.add.text(pad, pad, msg.text, {
...font(7, incoming ? UI.ink : UI.paper),
wordWrap: { width: BUBBLE_MAX_W - 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));
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 };
}
}

268
src/ui/Stamp.ts Normal file
View File

@ -0,0 +1,268 @@
import Phaser from 'phaser';
import { UI, font, shake } from './style';
// The stamp is the whole game in one gesture, so the timing is doing the acting:
// a wind-up above the target, 160ms of accelerating travel, a hard hit, then a
// 90ms dead hang before the lift. The hang is what makes it feel like a decision
// rather than an animation — do not shorten it.
const HEAD_W = 54;
const HEAD_H = 22;
const TRAVEL_MS = 160;
const HANG_MS = 90;
const RELEASE_MS = 180;
const RING_MS = 200;
const DUST_MS = 350;
const DUST_COUNT = 9;
const SPLATS = 7;
/** Head sits above the decal; ring and dust sit between them. */
const RING_DEPTH_OFFSET = 5;
const HEAD_DEPTH_OFFSET = 10;
export interface StampSfx {
stampSlam(): void;
stampInk(): void;
}
export interface StampHandle {
/** The ink mark left behind. The caller owns it; destroy it when the patron leaves. */
decal: Phaser.GameObjects.Container;
}
export interface StampOptions {
/** Always scene/world coordinates — converted into `parent` space when one is given. */
x: number;
y: number;
text?: string;
colour?: number;
depth?: number;
sfx?: StampSfx;
/** Parent for the decal, so the mark travels with the patron. Defaults to the scene root. */
parent?: Phaser.GameObjects.Container;
/** Per-impression variation. Same seed => same mark. Defaults to a hash of `text`. */
seed?: number;
onSlam?: () => void;
onDone?: () => void;
}
/**
* FNV-1a over the label so DENIED and ADMITTED get different splatter, stably; the
* caller's seed is folded in after so two DENIEDs are not the same printed mess.
*/
function hashText(text: string, seed?: number): number {
let h = 2166136261;
for (const ch of text) {
h ^= ch.charCodeAt(0);
h = Math.imul(h, 16777619);
}
if (seed !== undefined) {
let s = seed >>> 0;
for (let i = 0; i < 4; i++) {
h ^= s & 0xff;
h = Math.imul(h, 16777619);
s >>>= 8;
}
}
return (h >>> 0) % 4096;
}
/** Hash noise in place of Math.random — same label always prints the same mess. */
function sprinkle(seed: number, i: number): number {
const s = Math.sin(seed * 12.9898 + i * 78.233) * 43758.5453;
return s - Math.floor(s);
}
/** The rubber head: dark body, inked face, knurled grip on top. */
function buildHead(
scene: Phaser.Scene,
colour: number,
label: string,
): Phaser.GameObjects.Container {
const head = scene.add.container(0, 0);
const body = scene.add.graphics();
body.fillStyle(UI.ink, 1);
body.fillRoundedRect(-HEAD_W / 2, -HEAD_H / 2, HEAD_W, HEAD_H, 3);
body.fillStyle(UI.panelLip, 1);
body.fillRect(-10, -HEAD_H / 2 - 8, 20, 8);
body.fillStyle(UI.panel, 1);
body.fillRect(-14, -HEAD_H / 2 - 11, 28, 4);
const face = scene.add.graphics();
face.fillStyle(colour, 1);
face.fillRoundedRect(-HEAD_W / 2 + 3, -HEAD_H / 2 + 4, HEAD_W - 6, HEAD_H - 7, 2);
const text = scene.add.text(0, 1, label, font(8, UI.paper)).setOrigin(0.5).setLetterSpacing(1);
head.add([body, face, text]);
return head;
}
/** The mark that outlives the gesture: border, label, and uneven ink. */
function fillDecal(
scene: Phaser.Scene,
decal: Phaser.GameObjects.Container,
colour: number,
label: string,
seed: number,
): void {
// Never quite square to the card — a machine would print it straight.
decal.setRotation((sprinkle(seed, 0) - 0.5) * 0.16);
const border = scene.add.graphics();
border.lineStyle(2, colour, 0.85);
border.strokeRoundedRect(-HEAD_W / 2 + 2, -HEAD_H / 2 + 3, HEAD_W - 4, HEAD_H - 6, 2);
decal.add(border);
// Smudge under the crisp pass: one bad impression, printed twice.
decal.add(
scene.add.text(1, 2, label, font(9, colour)).setOrigin(0.5).setLetterSpacing(1).setAlpha(0.35),
);
decal.add(scene.add.text(0, 1, label, font(9, colour)).setOrigin(0.5).setLetterSpacing(1).setAlpha(0.92));
// Over-inked pooling near the edges plus a few thrown specks.
for (let i = 0; i < SPLATS; i++) {
const a = (i / SPLATS) * Math.PI * 2 + sprinkle(seed, 100 + i);
const r = 12 + sprinkle(seed, 200 + i) * 16;
const size = 1 + Math.round(sprinkle(seed, 300 + i) * 2);
decal.add(
scene.add
.circle(Math.cos(a) * r, Math.sin(a) * r * 0.5, size, colour, 1)
.setAlpha(0.28 + sprinkle(seed, 400 + i) * 0.35),
);
}
}
/** Shockwave: a flattened ring, because the ink lands on a surface, not in air. */
function spawnRing(scene: Phaser.Scene, opts: StampOptions, colour: number, depth: number): void {
const ring = scene.add
.circle(opts.x, opts.y, 10, colour, 0)
.setStrokeStyle(2, colour, 0.9)
.setDepth(depth)
.setScale(0.4, 0.25);
scene.tweens.add({
targets: ring,
scaleX: 2.4,
scaleY: 1.3,
alpha: 0,
duration: RING_MS,
ease: 'Cubic.easeOut',
onComplete: () => ring.destroy(),
});
}
/** Fixed angular steps — a random spray reads as noise, an even ring reads as force. */
function spawnDust(scene: Phaser.Scene, opts: StampOptions, colour: number, seed: number, depth: number): void {
for (let i = 0; i < DUST_COUNT; i++) {
const a = (i / DUST_COUNT) * Math.PI * 2;
const dist = 13 + sprinkle(seed, 500 + i) * 11;
const size = 1 + Math.round(sprinkle(seed, 600 + i));
// Alternating grit and ink so the burst reads as both dust and spatter.
const tint = i % 2 === 0 ? UI.paper : colour;
const p = scene.add
.rectangle(opts.x, opts.y, size, size, tint, 0.75)
.setDepth(depth);
scene.tweens.add({
targets: p,
x: opts.x + Math.cos(a) * dist,
y: opts.y + Math.sin(a) * dist * 0.45 + 4,
alpha: 0,
duration: DUST_MS,
ease: 'Quad.easeOut',
onComplete: () => p.destroy(),
});
}
}
/**
* Slam a stamp onto world (x, y). The head, ring and dust clean themselves up; the
* returned decal is the caller's to destroy nothing else will. It is empty until
* the head lands, so the returned container is safe to reparent or destroy early.
* Colour and label are pure inputs the ADMITTED variant is the same code path.
*/
export function stamp(scene: Phaser.Scene, opts: StampOptions): StampHandle {
const label = opts.text ?? 'DENIED';
const colour = opts.colour ?? UI.danger;
const depth = opts.depth ?? 0;
const seed = hashText(label, opts.seed);
const decal = scene.add.container(0, 0).setDepth(depth);
if (opts.parent) opts.parent.add(decal);
let decalAlive = true;
decal.once(Phaser.GameObjects.Events.DESTROY, () => {
decalAlive = false;
});
const head = buildHead(scene, colour, label);
head
.setDepth(depth + HEAD_DEPTH_OFFSET)
.setPosition(opts.x + 14, opts.y - 70)
.setScale(1.6)
.setRotation(-0.35)
.setAlpha(0.35);
const impact = (): void => {
shake(scene, 4, 120);
opts.sfx?.stampSlam();
opts.sfx?.stampInk();
opts.onSlam?.();
if (decalAlive) {
// Placed at impact, not at call time, so a moving parent stamps where the ink hit.
const local = opts.parent
? opts.parent.getWorldTransformMatrix().applyInverse(opts.x, opts.y)
: { x: opts.x, y: opts.y };
decal.setPosition(local.x, local.y);
fillDecal(scene, decal, colour, label, seed);
}
spawnRing(scene, opts, colour, depth + RING_DEPTH_OFFSET);
spawnDust(scene, opts, colour, seed, depth + RING_DEPTH_OFFSET);
// `delay` is the hang — nothing moves, and that stillness is the punctuation.
scene.tweens.add({
targets: head,
y: opts.y - 26,
alpha: 0,
duration: RELEASE_MS,
delay: HANG_MS,
ease: 'Quad.easeOut',
onComplete: () => {
head.destroy();
opts.onDone?.();
},
});
};
// Split easings per axis: the horizontal settles early while the vertical is
// still accelerating, which bends the path into an arc without a path object.
scene.tweens.add({
targets: head,
x: opts.x,
duration: TRAVEL_MS,
ease: 'Quad.easeOut',
});
scene.tweens.add({
targets: head,
alpha: 1,
duration: TRAVEL_MS * 0.6,
ease: 'Linear',
});
scene.tweens.add({
targets: head,
y: opts.y,
rotation: 0,
scaleX: 1,
scaleY: 1,
duration: TRAVEL_MS,
ease: 'Back.easeIn',
onComplete: impact,
});
return { decal };
}

114
src/ui/style.ts Normal file
View File

@ -0,0 +1,114 @@
import Phaser from 'phaser';
import { PALETTE } from '../data/outfits';
// Shared look for every juice widget. Chunky, high contrast, slightly grubby —
// this club has not been deep-cleaned since the Olympics. Colours lean on the
// doll PALETTE so UI and patrons live in the same world.
export const UI = {
ink: 0x0b0b12,
panel: 0x1a1a24,
panelLip: 0x2c2c3a,
grime: 0x24242e,
paper: PALETTE.paper ?? 0xdad4c4,
neonPink: 0xd03470,
neonGreen: 0x9fe8a0,
kebabAmber: 0xd8a020,
danger: PALETTE.red ?? 0xc03434,
ok: PALETTE.green ?? 0x2e7d46,
dim: 0x556,
} as const;
export const hex = (c: number): string => `#${c.toString(16).padStart(6, '0')}`;
export const font = (size: number, colour: number): Phaser.Types.GameObjects.Text.TextStyle => ({
fontFamily: 'monospace',
fontSize: `${size}px`,
color: hex(colour),
});
/**
* A grubby recessed panel: dark fill, lit top lip, dark bottom shadow. Returns a
* container positioned at (x, y) with the panel centred on its origin, so
* callers can add children in local coordinates.
*/
export function panel(
scene: Phaser.Scene,
x: number,
y: number,
w: number,
h: number,
fill: number = UI.panel,
): Phaser.GameObjects.Container {
const c = scene.add.container(x, y);
c.add(scene.add.rectangle(0, 0, w, h, fill).setStrokeStyle(1, UI.ink));
c.add(scene.add.rectangle(0, -h / 2 + 1, w - 2, 1, UI.panelLip, 0.5));
c.add(scene.add.rectangle(0, h / 2 - 1, w - 2, 1, UI.ink, 0.6));
return c;
}
export interface ChunkyButtonOptions {
w?: number;
h?: number;
fill?: number;
textColour?: number;
fontSize?: number;
onClick: () => void;
}
/**
* Chunky pixel button with a real press: it drops 1px and darkens on pointerdown
* and springs back on release. The 1px travel is most of why it feels physical.
*/
export function chunkyButton(
scene: Phaser.Scene,
x: number,
y: number,
label: string,
opts: ChunkyButtonOptions,
): Phaser.GameObjects.Container {
const w = opts.w ?? 56;
const h = opts.h ?? 16;
const fill = opts.fill ?? UI.panelLip;
const c = scene.add.container(x, y);
const face = scene.add.rectangle(0, 0, w, h, fill).setStrokeStyle(1, UI.ink);
const shadow = scene.add.rectangle(0, h / 2, w, 2, UI.ink, 0.8);
const text = scene.add
.text(0, 0, label, font(opts.fontSize ?? 8, opts.textColour ?? UI.paper))
.setOrigin(0.5);
c.add([shadow, face, text]);
face.setInteractive({ useHandCursor: true });
const press = (down: boolean): void => {
face.y = down ? 1 : 0;
text.y = down ? 1 : 0;
shadow.setAlpha(down ? 0.2 : 0.8);
face.setFillStyle(down ? Phaser.Display.Color.ValueToColor(fill).darken(20).color : fill);
};
// Phaser fires 'pointerup' on any release over the object, even one whose
// 'pointerdown' landed elsewhere. Arm on our own down and disarm the moment the
// pointer leaves, so a stray release can't phantom-click (clicker tally is load-bearing).
let armed = false;
face.on('pointerdown', () => {
armed = true;
press(true);
});
// Leaving the face is the only way to release elsewhere, so this covers the
// press-drag-away-release case too.
face.on('pointerout', () => {
armed = false;
press(false);
});
face.on('pointerup', () => {
const fire = armed;
armed = false;
press(false);
if (fire) opts.onClick();
});
return c;
}
/** Short, sharp camera shake. Used by the stamp and anything else with weight. */
export function shake(scene: Phaser.Scene, px = 4, ms = 120): void {
scene.cameras.main.shake(ms, px / 1000, true);
}

110
src/ui/typo.ts Normal file
View File

@ -0,0 +1,110 @@
// Drunk-typo text renderer for DialogueBox.
//
// Pure and deterministic: same (text, drunkenness, seed) always yields the same
// mangled string, so a patron's slurred answer is reproducible from the run seed
// like everything else. No Math.random — callers pass a SeededRNG stream.
export interface RngLike {
/** 0..1, like SeededRNG streams. */
next(): number;
}
/** Adjacent-key swaps — a drunk thumb misses sideways, not randomly. */
const NEIGHBOURS: Record<string, string> = {
a: 's', b: 'v', c: 'x', d: 'f', e: 'w', f: 'g', g: 'h', h: 'j', i: 'o',
j: 'k', k: 'l', l: 'k', m: 'n', n: 'm', o: 'p', p: 'o', q: 'w', r: 't',
s: 'a', t: 'r', u: 'i', v: 'b', w: 'e', x: 'z', y: 'u', z: 'x',
};
/** Vowels get stretched when someone is really going for it. */
const VOWELS = 'aeiou';
/**
* Clamp to 0..1, treating a non-finite input as 0.
*
* A NaN reaching `text.y` makes Phaser silently render nothing, so it must never
* leave this module. Callers guard their own frame deltas this is the backstop,
* not the fix.
*/
function clamp01(n: number): number {
return Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : 0;
}
export interface TypoOptions {
/** 0..1 — 0 leaves text untouched, 1 is barely language. */
drunkenness: number;
rng: RngLike;
}
/**
* Mangle a line in proportion to how gone the speaker is.
*
* Four escalating effects, each gated on its own probability so a tipsy speaker
* gets the occasional doubled letter and a maggot one gets all of it at once:
* - adjacent-key substitution
* - doubled letters
* - dropped letters
* - stretched vowels ("yeahhh")
*/
export function drunkify(text: string, { drunkenness, rng }: TypoOptions): string {
const d = clamp01(drunkenness);
if (d <= 0) return text;
// Below ~0.25 people type fine. Above that it ramps quickly.
const intensity = Math.max(0, (d - 0.25) / 0.75);
if (intensity <= 0) return text;
const pSub = intensity * 0.14;
const pDouble = intensity * 0.09;
const pDrop = intensity * 0.07;
const pStretch = intensity * 0.12;
let out = '';
for (const ch of text) {
const lower = ch.toLowerCase();
const isLetter = lower >= 'a' && lower <= 'z';
if (!isLetter) {
out += ch;
continue;
}
if (rng.next() < pDrop) continue;
let emitted = ch;
if (rng.next() < pSub) {
const swap = NEIGHBOURS[lower];
if (swap !== undefined) emitted = ch === lower ? swap : swap.toUpperCase();
}
out += emitted;
if (VOWELS.includes(lower) && rng.next() < pStretch) {
const extra = 1 + Math.floor(rng.next() * 3);
out += emitted.repeat(extra);
} else if (rng.next() < pDouble) {
out += emitted;
}
}
return out;
}
/**
* How many characters of `text` are visible after `elapsedMs` of typing.
* Drunk speakers type slower and in lurches the stagger is deterministic in
* the character index so it does not shimmer between frames.
*/
export function typedLength(text: string, elapsedMs: number, charMs: number, drunkenness = 0): number {
if (charMs <= 0) return text.length;
const slow = 1 + clamp01(drunkenness) * 1.2;
const raw = Math.floor(elapsedMs / (charMs * slow));
if (Number.isNaN(raw)) return 0;
return Math.max(0, Math.min(text.length, raw));
}
/** Per-character vertical wobble (px) for drunk render mode. Deterministic. */
export function wobbleAt(index: number, drunkenness: number, phase: number): number {
const amp = clamp01(drunkenness) * 1.6;
if (amp <= 0) return 0;
const y = Math.sin(phase + index * 0.9) * amp;
return Number.isNaN(y) ? 0 : y;
}

163
tests/arrivalCurve.test.ts Normal file
View File

@ -0,0 +1,163 @@
import { describe, expect, it } from 'vitest';
import { SeededRNG } from '../src/core/SeededRNG';
import {
ARRIVAL_CURVE,
arrivalsPerMin,
expectedTotalArrivals,
nextArrivalGapMin,
} from '../src/scenes/door/arrivalCurve';
// Two DIFFERENT private values in arrivalCurve.ts, kept apart on purpose:
// - the dead-rate sentinel, returned only when the rate is 0;
// - the real gap cap, the most in-game minutes one live roll may ever burn.
// Asserting only against the sentinel would let the gap cap drift to anything
// under 999 unnoticed, which is exactly the bug the cap exists to prevent.
const DEAD_SENTINEL = 999;
/** A live gap must never eat more than a sixth of the 360-minute night. */
const MAX_LIVE_GAP = 60;
const sweep = (): number[] => Array.from({ length: 361 }, (_, i) => arrivalsPerMin(i));
const peak = (): number => Math.max(...sweep());
describe('ARRIVAL_CURVE', () => {
it('is sorted, spans the whole night, and never goes negative', () => {
expect(ARRIVAL_CURVE[0]?.clockMin).toBe(0);
expect(ARRIVAL_CURVE[ARRIVAL_CURVE.length - 1]?.clockMin).toBe(360);
for (let i = 1; i < ARRIVAL_CURVE.length; i++) {
expect(ARRIVAL_CURVE[i]!.clockMin).toBeGreaterThan(ARRIVAL_CURVE[i - 1]!.clockMin);
}
for (const p of ARRIVAL_CURVE) expect(p.perMin).toBeGreaterThanOrEqual(0);
});
});
describe('arrivalsPerMin', () => {
it('interpolates strictly between adjacent control points', () => {
const at90 = arrivalsPerMin(90);
const at150 = arrivalsPerMin(150);
const mid = arrivalsPerMin(120);
expect(at150).toBeGreaterThan(at90);
expect(mid).toBeGreaterThan(at90);
expect(mid).toBeLessThan(at150);
});
it('clamps flat outside 0..360', () => {
expect(arrivalsPerMin(-50)).toBe(ARRIVAL_CURVE[0]!.perMin);
expect(arrivalsPerMin(0)).toBe(ARRIVAL_CURVE[0]!.perMin);
const lastPerMin = ARRIVAL_CURVE[ARRIVAL_CURVE.length - 1]!.perMin;
expect(arrivalsPerMin(400)).toBe(lastPerMin);
expect(arrivalsPerMin(360)).toBe(lastPerMin);
});
it('peaks inside the 10:3012:30 slam window', () => {
const values = sweep();
const peakIndex = values.indexOf(Math.max(...values));
expect(peakIndex).toBeGreaterThanOrEqual(90);
expect(peakIndex).toBeLessThanOrEqual(210);
expect(peakIndex).toBe(150);
});
it('9 PM is quiet — under a third of peak', () => {
expect(arrivalsPerMin(0)).toBeLessThan(peak() / 3);
});
it('2:30 AM has tapered hard — under a quarter of peak', () => {
expect(arrivalsPerMin(330)).toBeLessThan(peak() / 4);
});
it('is never negative and never NaN, including for junk input', () => {
for (let m = -60; m <= 420; m++) {
const v = arrivalsPerMin(m);
expect(Number.isNaN(v)).toBe(false);
expect(v).toBeGreaterThanOrEqual(0);
}
for (const junk of [NaN, Infinity, -Infinity]) {
const v = arrivalsPerMin(junk);
expect(Number.isNaN(v)).toBe(false);
expect(v).toBeGreaterThanOrEqual(0);
}
});
});
describe('expectedTotalArrivals', () => {
it('lands in the 6090 patron design band', () => {
const total = expectedTotalArrivals();
expect(total).toBeGreaterThanOrEqual(60);
expect(total).toBeLessThanOrEqual(90);
});
});
describe('nextArrivalGapMin', () => {
it('is finite, positive and capped across 2000 (clockMin, roll) pairs', () => {
const rolls = [0, 0.5, 0.9999999];
for (const roll of rolls) {
for (let m = 0; m <= 360; m++) {
const gap = nextArrivalGapMin(m, roll);
expect(Number.isFinite(gap)).toBe(true);
expect(gap).toBeGreaterThan(0);
expect(gap).toBeLessThanOrEqual(DEAD_SENTINEL);
}
}
for (let i = 0; i < 920; i++) {
const gap = nextArrivalGapMin((i * 7) % 361, i / 920);
expect(Number.isFinite(gap)).toBe(true);
expect(gap).toBeGreaterThan(0);
expect(gap).toBeLessThanOrEqual(DEAD_SENTINEL);
}
});
it('no single unlucky roll can swallow the night', () => {
// The spec's cap bullet. Sweep the LIVE window (rate > 0) at rolls pressed
// right up against 1 — the worst draw the exponential can produce — and
// demand the cap bites. Without this, the cap could be raised to any value
// under the dead-rate sentinel and every other test would still pass.
let worst = 0;
for (const roll of [0.99, 0.999, 0.999999, 0.9999999, 1, 2]) {
for (let m = 0; m <= 359; m++) {
const gap = nextArrivalGapMin(m, roll);
if (gap === DEAD_SENTINEL) continue; // dead rate, not a live draw
expect(Number.isFinite(gap)).toBe(true);
worst = Math.max(worst, gap);
}
}
expect(worst).toBeGreaterThan(0);
expect(worst).toBeLessThanOrEqual(MAX_LIVE_GAP);
});
it('gaps are shorter at peak than at 9 PM for the same roll', () => {
// Non-strict across the whole roll range: the sub-minute floor that stops
// two patrons landing on one frame also floors BOTH ends for tiny rolls,
// so strict inequality is genuinely false down there. Assert what is true.
for (let i = 0; i <= 100; i++) {
const roll = i / 101;
expect(nextArrivalGapMin(150, roll)).toBeLessThanOrEqual(nextArrivalGapMin(0, roll));
}
// Strict wherever the floor is not binding, which is the range that matters.
for (const roll of [0.05, 0.1, 0.5, 0.9, 0.99]) {
expect(nextArrivalGapMin(150, roll)).toBeLessThan(nextArrivalGapMin(0, roll));
}
});
it('a dead rate returns a large finite gap rather than Infinity', () => {
// 3 AM: the curve is zero, so the maths would divide by zero if unguarded.
expect(arrivalsPerMin(360)).toBe(0);
const gap = nextArrivalGapMin(360, 0.5);
expect(Number.isFinite(gap)).toBe(true);
expect(gap).toBe(DEAD_SENTINEL);
});
it('a simulated night delivers a plausible crowd, on every seed', () => {
// Twelve seeds, not one: a single seed proves the seed was lucky, not that
// the curve is tuned. Observed across 200 seeds: min 49, mean 71, max 93.
for (const seed of [1, 7, 42, 99, 404, 1312, 2600, 4207, 7777, 9001, 31337, 65535]) {
const rng = new SeededRNG(seed).stream('arrivals');
let clock = 0;
let arrivals = 0;
while (clock < 360) {
clock += nextArrivalGapMin(clock, rng.next());
if (clock < 360) arrivals++;
}
expect(arrivals, `seed ${seed}`).toBeGreaterThanOrEqual(45);
expect(arrivals, `seed ${seed}`).toBeLessThanOrEqual(110);
}
});
});

157
tests/dazzaSchedule.test.ts Normal file
View File

@ -0,0 +1,157 @@
import { describe, expect, it } from 'vitest';
import { dazzaDue, nextDazza, type DazzaWorld } from '../src/scenes/door/dazzaSchedule';
import { DAZZA_TEXTS, type DazzaLine } from '../src/data/strings/dazza';
// Default world satisfies none of the mood gates, so rule-bearing timing can be
// tested without a gate accidentally letting commentary through.
const world = (over: Partial<DazzaWorld> = {}): DazzaWorld => ({
vibe: 50,
aggro: 0,
queueLength: 0,
insideCount: 50,
fired: new Set<string>(),
...over,
});
const ids = (lines: readonly DazzaLine[]): string[] => lines.map((l) => l.id);
const dueIds = (clockMin: number, w: DazzaWorld): string[] => ids(dazzaDue(clockMin, w));
const lineById = (id: string): DazzaLine => {
const line = DAZZA_TEXTS.find((l) => l.id === id);
if (!line) throw new Error(`unknown dazza line: ${id}`);
return line;
};
const RULE_LINES = DAZZA_TEXTS.filter((l) => l.ruleId);
describe('dazzaDue', () => {
it('says nothing at the top of the night', () => {
expect(dazzaDue(0, world())).toEqual([]);
});
it('delivers every rule-bearing text on its minute, never a minute early', () => {
expect(RULE_LINES).toHaveLength(8);
for (const line of RULE_LINES) {
expect(dueIds(line.fromMin - 1, world())).not.toContain(line.id);
expect(dueIds(line.fromMin, world())).toContain(line.id);
}
});
it('puts the law ahead of the philosophy when several land at once', () => {
const due = dazzaDue(360, world({ vibe: 20, queueLength: 8, insideCount: 2 }));
const ruleIdx = due.flatMap((l, i) => (l.ruleId ? [i] : []));
const moodIdx = due.flatMap((l, i) => (l.ruleId ? [] : [i]));
expect(ruleIdx.length).toBeGreaterThan(0);
expect(moodIdx.length).toBeGreaterThan(0);
expect(Math.max(...ruleIdx)).toBeLessThan(Math.min(...moodIdx));
});
it('orders within each group by fromMin ascending', () => {
const due = dazzaDue(360, world({ vibe: 20, queueLength: 8, insideCount: 2 }));
expect(ids(due.filter((l) => l.ruleId))).toEqual([
'rule-basics',
'rule-logos',
'rule-songRequest',
'rule-sunnies',
'rule-couples',
'rule-sneakers',
'rule-bucketHats',
'rule-suits',
]);
expect(ids(due.filter((l) => !l.ruleId))).toEqual([
'vibe-empty',
'vibe-mid',
'vibe-queue',
'kayden',
'owner-scare',
'philosophy-1',
'philosophy-2',
]);
});
it('never repeats a line that has already been sent', () => {
const w = world({ vibe: 20, queueLength: 8, insideCount: 2 });
const first = dazzaDue(360, w);
expect(first.length).toBeGreaterThan(0);
expect(dazzaDue(360, { ...w, fired: new Set(ids(first)) })).toEqual([]);
const partial = world({ fired: new Set(['rule-basics']) });
expect(dueIds(90, partial)).not.toContain('rule-basics');
expect(dueIds(90, partial)).toContain('rule-logos');
});
});
describe('mood gates', () => {
const cases: { id: string; yes: Partial<DazzaWorld>; no: Partial<DazzaWorld> }[] = [
{ id: 'vibe-mid', yes: { vibe: 34 }, no: { vibe: 35 } },
{ id: 'vibe-good', yes: { vibe: 73 }, no: { vibe: 72 } },
{ id: 'vibe-queue', yes: { queueLength: 6 }, no: { queueLength: 5 } },
{ id: 'vibe-empty', yes: { insideCount: 3 }, no: { insideCount: 4 } },
{ id: 'kayden', yes: { queueLength: 3 }, no: { queueLength: 2 } },
];
for (const c of cases) {
it(`${c.id} fires only when the room justifies it`, () => {
const at = lineById(c.id).fromMin;
expect(dueIds(at, world(c.yes))).toContain(c.id);
expect(dueIds(at, world(c.no))).not.toContain(c.id);
// gate satisfied but too early is still silence
expect(dueIds(at - 1, world(c.yes))).not.toContain(c.id);
});
}
it('ungated lines fire on time whatever the room is doing', () => {
const rooms: Partial<DazzaWorld>[] = [
{ vibe: 0, aggro: 100, queueLength: 0, insideCount: 0 },
{ vibe: 100, aggro: 0, queueLength: 40, insideCount: 400 },
];
for (const id of ['owner-scare', 'philosophy-1', 'philosophy-2']) {
const at = lineById(id).fromMin;
for (const room of rooms) {
expect(dueIds(at, world(room))).toContain(id);
expect(dueIds(at - 1, world(room))).not.toContain(id);
}
}
});
});
describe('nextDazza', () => {
it('hands back the head of the queue, one text at a time', () => {
const w = world({ vibe: 20, queueLength: 8, insideCount: 2 });
const due = dazzaDue(360, w);
expect(nextDazza(360, w)).toBe(due[0]);
expect(nextDazza(360, w)?.id).toBe('rule-basics');
});
it('returns undefined when he has nothing to say', () => {
expect(nextDazza(0, world())).toBeUndefined();
expect(nextDazza(360, world({ fired: new Set(ids(DAZZA_TEXTS)) }))).toBeUndefined();
});
});
describe('a whole night, minute by minute', () => {
it('delivers every line exactly once, rules on the minute they become law', () => {
const fired = new Set<string>();
const deliveredAt = new Map<string, number>();
for (let m = 0; m <= 360; m++) {
// vibe swings so both vibe-mid and vibe-good get a chance across the night
const line = nextDazza(m, {
vibe: m % 2 === 0 ? 20 : 90,
aggro: 0,
queueLength: 8,
insideCount: 2,
fired,
});
if (!line) continue;
expect(deliveredAt.has(line.id)).toBe(false);
fired.add(line.id);
deliveredAt.set(line.id, m);
}
expect([...deliveredAt.keys()].sort()).toEqual(ids(DAZZA_TEXTS).sort());
for (const line of RULE_LINES) {
expect(deliveredAt.get(line.id)).toBe(line.fromMin);
}
});
});

227
tests/dressCode.test.ts Normal file
View File

@ -0,0 +1,227 @@
import { describe, expect, it } from 'vitest';
import '../src/rules/doorTypes';
import { SeededRNG } from '../src/core/SeededRNG';
import { DAZZA_TEXTS } from '../src/data/strings/dazza';
import { generatePatron, _resetPatronSerial } from '../src/patrons/generator';
import {
DRESS_CODE_RULES,
activeRules,
ruleById,
violations,
} from '../src/rules/dressCode';
import type { OutfitLayer, OutfitSlot, Patron, PatronFlags } from '../src/data/types';
// Explicit Z: a bare ISO datetime is parsed in local time, so this would other-
// wise be one more machine-dependent input in a suite that must be reproducible.
const NIGHT = new Date('2026-07-18T00:00:00Z');
const LATE = 360; // 3 AM — every rule live
// A deliberately unremarkable outfit: passes all eight rules, so any test only
// has to state the one thing it is testing.
const CLEAN: Record<OutfitSlot, OutfitLayer> = {
shoes: { slot: 'shoes', type: 'boot', colour: 'black' },
legs: { slot: 'legs', type: 'jeans', colour: 'denim' },
top: { slot: 'top', type: 'shirt', colour: 'white' },
outer: { slot: 'outer', type: 'none', colour: 'black' },
hair: { slot: 'hair', type: 'short', colour: 'brown' },
accessory: { slot: 'accessory', type: 'none', colour: 'black' },
};
type OutfitOverrides = Partial<Record<OutfitSlot, Partial<Omit<OutfitLayer, 'slot'>>>>;
const patron = (outfit: OutfitOverrides = {}, flags: PatronFlags = {}): Patron => ({
id: 'test',
dollSeed: 1,
archetype: 'punter',
age: 25,
idCard: { name: 'Test Patron', dob: '2001-01-01', expiry: '2030-01-01', photoSeed: 1 },
outfit: (Object.keys(CLEAN) as OutfitSlot[]).map((slot) => ({ ...CLEAN[slot], ...outfit[slot] })),
intoxication: 0.1,
tolerance: 0.5,
flags,
});
const ids = (rules: { id: string }[]): string[] => rules.map((r) => r.id);
const trips = (id: string, outfit: OutfitOverrides, flags?: PatronFlags): boolean =>
ids(violations(patron(outfit, flags), LATE)).includes(id);
describe('DRESS_CODE_RULES vs Dazza texts', () => {
it('every rule takes its text and activeFrom verbatim from the Dazza line', () => {
for (const rule of DRESS_CODE_RULES) {
const line = DAZZA_TEXTS.find((l) => l.ruleId === rule.id);
expect(line, `no Dazza line for ${rule.id}`).toBeDefined();
expect(rule.text).toBe(line!.text);
expect(rule.activeFrom).toBe(line!.fromMin);
}
});
it('every rule-bearing Dazza line has exactly one rule enforcing it', () => {
const announced = DAZZA_TEXTS.filter((l) => l.ruleId !== undefined).map((l) => l.ruleId!);
expect([...announced].sort()).toEqual([...ids([...DRESS_CODE_RULES])].sort());
expect(new Set(ids([...DRESS_CODE_RULES])).size).toBe(DRESS_CODE_RULES.length);
});
it('carries a short card label for every rule', () => {
for (const rule of DRESS_CODE_RULES) expect(rule.short.length).toBeGreaterThan(0);
});
it('ruleById finds rules by id and returns undefined otherwise', () => {
expect(ruleById('noBlazers')?.short).toBe('NO BLAZERS');
expect(ruleById('noCrocs')).toBeUndefined();
});
});
describe('activeRules', () => {
it('activates each rule exactly at activeFrom, not a minute before', () => {
for (const rule of DRESS_CODE_RULES) {
expect(ids(activeRules(rule.activeFrom - 1))).not.toContain(rule.id);
expect(ids(activeRules(rule.activeFrom))).toContain(rule.id);
}
});
it('returns rules in announcement order and accumulates over the night', () => {
const late = activeRules(LATE);
expect(late).toHaveLength(DRESS_CODE_RULES.length);
expect(late.map((r) => r.activeFrom)).toEqual([...late.map((r) => r.activeFrom)].sort((a, b) => a - b));
expect(activeRules(0)).toHaveLength(0);
expect(ids(activeRules(100))).toEqual(['noThongsSinglets', 'noLogos']);
});
it('pins the full escalation order from design §3.1', () => {
// SPECS is declared in DAZZA_TEXTS order, which is not chronological — this
// fails if the activeFrom sort is ever dropped.
expect(ids(activeRules(LATE))).toEqual([
'noThongsSinglets',
'noLogos',
'noSongRequesters',
'noSunnies',
'noHandHolders',
'noWhiteSneakers',
'noBucketHats',
'noBlazers',
]);
});
});
describe('predicates', () => {
it('noThongsSinglets catches thongs and singlets, not boots and shirts', () => {
expect(trips('noThongsSinglets', { shoes: { type: 'thong', colour: 'blue' } })).toBe(true);
expect(trips('noThongsSinglets', { top: { type: 'singlet', colour: 'white' } })).toBe(true);
expect(trips('noThongsSinglets', {})).toBe(false);
});
// Dazza's text says "no VISIBLE logos", and dollPlan.ts paints a logo rect for
// the top slot only. Convicting on the other four would deny a quarter of the
// queue over pixels that were never drawn (design §4.1). When the art pass adds
// per-slot logos, widen LOGO_VISIBLE_SLOTS and flip these expectations.
it('noLogos trips on a logo the renderer actually draws, and only that', () => {
expect(trips('noLogos', { top: { type: 'tee', colour: 'black', logo: true } })).toBe(true);
for (const slot of ['shoes', 'legs', 'outer', 'accessory'] as const) {
const type = slot === 'outer' ? 'bomber' : slot === 'accessory' ? 'cap' : CLEAN[slot].type;
expect(trips('noLogos', { [slot]: { type, logo: true } }), slot).toBe(false);
}
expect(trips('noLogos', { top: { type: 'tee', colour: 'black', logo: false } })).toBe(false);
expect(trips('noLogos', {})).toBe(false);
});
it('noSunnies catches sunnies at 3 AM but ignores a bare face', () => {
expect(trips('noSunnies', { accessory: { type: 'sunnies', colour: 'black' } })).toBe(true);
expect(trips('noSunnies', { accessory: { type: 'chain', colour: 'yellow' } })).toBe(false);
});
it('noWhiteSneakers spares vintage whites and non-white sneakers', () => {
expect(trips('noWhiteSneakers', { shoes: { type: 'sneaker', colour: 'white' } })).toBe(true);
expect(trips('noWhiteSneakers', { shoes: { type: 'sneaker', colour: 'white', vintage: true } })).toBe(false);
expect(trips('noWhiteSneakers', { shoes: { type: 'sneaker', colour: 'black' } })).toBe(false);
expect(trips('noWhiteSneakers', { shoes: { type: 'heel', colour: 'white' } })).toBe(false);
});
it('noBucketHats and noBlazers key off their own slots only', () => {
expect(trips('noBucketHats', { accessory: { type: 'bucketHat', colour: 'cream' } })).toBe(true);
expect(trips('noBucketHats', { accessory: { type: 'cap', colour: 'black' } })).toBe(false);
expect(trips('noBlazers', { outer: { type: 'blazer', colour: 'navy' } })).toBe(true);
expect(trips('noBlazers', { outer: { type: 'bomber', colour: 'green' } })).toBe(false);
});
it('noSongRequesters needs two tells — one is just a bloke', () => {
expect(trips('noSongRequesters', { top: { type: 'jersey', colour: 'red' } })).toBe(false);
expect(trips('noSongRequesters', { hair: { type: 'mullet', colour: 'yellow' } })).toBe(false);
expect(trips('noSongRequesters', {
top: { type: 'jersey', colour: 'red' },
hair: { type: 'mullet', colour: 'yellow' },
})).toBe(true);
expect(trips('noSongRequesters', {
legs: { type: 'trackies', colour: 'grey' },
accessory: { type: 'bumbag', colour: 'black' },
})).toBe(true);
expect(trips('noSongRequesters', {
hair: { type: 'mullet', colour: 'brown' },
accessory: { type: 'cap', colour: 'navy' },
})).toBe(true);
});
it('noSongRequesters acquits when no tell is actually on screen', () => {
// dollPlan draws the torso and legs as plain coloured rects: a jersey looks
// like a tee and trackies look like jeans. Two invisible tells must not deny
// anyone, or the player is stamping people over data they never saw (§4.1).
expect(trips('noSongRequesters', {
top: { type: 'jersey', colour: 'blue' },
legs: { type: 'trackies', colour: 'grey' },
})).toBe(false);
// ...but add one tell the renderer DOES draw and the case is visible again.
expect(trips('noSongRequesters', {
top: { type: 'jersey', colour: 'blue' },
legs: { type: 'trackies', colour: 'grey' },
hair: { type: 'mullet', colour: 'brown' },
})).toBe(true);
});
it('noHandHolders reads the QueueManager flag, and only that', () => {
expect(trips('noHandHolders', {}, { handHolding: true })).toBe(true);
expect(trips('noHandHolders', {}, {})).toBe(false);
});
it('predicates are total: an empty outfit throws nothing and violates nothing visual', () => {
const naked: Patron = { ...patron(), outfit: [] };
expect(() => violations(naked, LATE)).not.toThrow();
expect(violations(naked, LATE)).toEqual([]);
});
it("'none' in a slot counts as absent, logo flag and all", () => {
expect(violations(patron({ outer: { type: 'none' }, accessory: { type: 'none' } }), LATE)).toEqual([]);
// An empty slot is not a garment, so a stray logo flag on one denies nobody.
expect(trips('noLogos', { outer: { type: 'none', logo: true } })).toBe(false);
expect(trips('noLogos', { accessory: { type: 'none', logo: true } })).toBe(false);
// ...and logo:false on a real garment is clean, not merely "logo is present".
expect(trips('noLogos', { top: { type: 'tee', colour: 'red', logo: false } })).toBe(false);
});
});
describe('violations', () => {
it('returns nothing for a clean patron with every rule live', () => {
expect(violations(patron(), LATE)).toEqual([]);
});
it('reports only the rules already announced, in announcement order', () => {
const p = patron({
shoes: { type: 'thong', colour: 'blue' },
outer: { type: 'blazer', colour: 'navy' },
});
expect(ids(violations(p, 100))).toEqual(['noThongsSinglets']);
expect(ids(violations(p, LATE))).toEqual(['noThongsSinglets', 'noBlazers']);
});
it('survives 300 generated patrons and denies a playable fraction of them', () => {
_resetPatronSerial();
const ctx = { rng: new SeededRNG(5), nightDate: NIGHT };
let denied = 0;
for (let i = 0; i < 300; i++) {
const p = generatePatron(ctx, LATE);
expect(() => violations(p, LATE)).not.toThrow();
if (violations(p, LATE).length > 0) denied++;
}
// Toothless or unplayable are both failures — the door has to be a judgement call.
expect(denied / 300).toBeGreaterThan(0.1);
expect(denied / 300).toBeLessThan(0.95);
});
});

200
tests/filterCurve.test.ts Normal file
View File

@ -0,0 +1,200 @@
import { describe, expect, it } from 'vitest';
import {
CLOSE_SWEEP_MS,
DOOR_CUTOFF_HZ,
DOOR_GAIN,
FLOOR_CUTOFF_HZ,
FLOOR_GAIN,
OPEN_SWEEP_MS,
cutoffAt,
cutoffCurve,
cutoffFor,
gainAt,
gainFor,
sweepMsFor,
} from '../src/audio/filterCurve';
describe('location constants', () => {
it('maps each location to its cutoff and gain', () => {
expect(cutoffFor('door')).toBe(DOOR_CUTOFF_HZ);
expect(cutoffFor('floor')).toBe(FLOOR_CUTOFF_HZ);
expect(gainFor('door')).toBe(DOOR_GAIN);
expect(gainFor('floor')).toBe(FLOOR_GAIN);
});
it('keeps the door muffled and quieter than the floor', () => {
expect(cutoffFor('door')).toBeLessThan(cutoffFor('floor'));
expect(gainFor('door')).toBeLessThan(gainFor('floor'));
});
it('opens faster than it closes', () => {
expect(sweepMsFor('floor')).toBe(OPEN_SWEEP_MS);
expect(sweepMsFor('door')).toBe(CLOSE_SWEEP_MS);
expect(sweepMsFor('floor')).toBeLessThan(sweepMsFor('door'));
});
});
describe('cutoffAt', () => {
it('hits the endpoints exactly', () => {
expect(cutoffAt(0, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ)).toBeCloseTo(DOOR_CUTOFF_HZ, 6);
expect(cutoffAt(1, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ)).toBeCloseTo(FLOOR_CUTOFF_HZ, 6);
expect(cutoffAt(0, FLOOR_CUTOFF_HZ, DOOR_CUTOFF_HZ)).toBeCloseTo(FLOOR_CUTOFF_HZ, 6);
expect(cutoffAt(1, FLOOR_CUTOFF_HZ, DOOR_CUTOFF_HZ)).toBeCloseTo(DOOR_CUTOFF_HZ, 6);
});
it('clamps t outside 0..1 so callers can pass raw elapsed/duration', () => {
expect(cutoffAt(-3, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ)).toBeCloseTo(DOOR_CUTOFF_HZ, 6);
expect(cutoffAt(-0.001, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ)).toBeCloseTo(DOOR_CUTOFF_HZ, 6);
expect(cutoffAt(1.5, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ)).toBeCloseTo(FLOOR_CUTOFF_HZ, 6);
expect(cutoffAt(9000, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ)).toBeCloseTo(FLOOR_CUTOFF_HZ, 6);
});
it('sits at the geometric mean halfway, not the arithmetic one', () => {
// The whole point: a linear ramp would already be at ~9kHz by the midpoint and
// the door would sound like someone nudged a fader.
const geometric = Math.sqrt(DOOR_CUTOFF_HZ * FLOOR_CUTOFF_HZ); // ~2121Hz
const arithmetic = (DOOR_CUTOFF_HZ + FLOOR_CUTOFF_HZ) / 2; // ~9125Hz
const mid = cutoffAt(0.5, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ);
expect(mid).toBeCloseTo(geometric, 3);
expect(mid).toBeCloseTo(2121.32, 1);
expect(mid).toBeLessThan(arithmetic / 4);
});
it('keeps the brightness late — three quarters through is still under half the range', () => {
const oneQuarter = cutoffAt(0.25, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ);
const threeQuarters = cutoffAt(0.75, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ);
// Anchored to measured values, not to the formula — otherwise any curve of the
// same algebraic shape passes and a regression in the clamp goes unnoticed.
expect(oneQuarter).toBeCloseTo(728.24, 1);
expect(threeQuarters).toBeCloseTo(6179.3, 1);
expect(threeQuarters).toBeLessThan(FLOOR_CUTOFF_HZ / 2);
});
it('covers equal ratios over equal steps of t', () => {
// The defining property of the sweep: every quarter multiplies the cutoff by the
// same factor, so the reveal accelerates in Hz while sounding even to the ear.
const at = (t: number) => cutoffAt(t, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ);
const step = Math.pow(FLOOR_CUTOFF_HZ / DOOR_CUTOFF_HZ, 0.25);
for (const t of [0, 0.25, 0.5, 0.75]) {
expect(at(t + 0.25) / at(t)).toBeCloseTo(step, 6);
}
// Equivalently: each sample is the geometric mean of its neighbours.
expect(at(0.75)).toBeCloseTo(Math.sqrt(at(0.5) * FLOOR_CUTOFF_HZ), 3);
});
it('rises monotonically while opening', () => {
let prev = -Infinity;
for (let i = 0; i <= 200; i++) {
const hz = cutoffAt(i / 200, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ);
expect(hz).toBeGreaterThan(prev);
prev = hz;
}
});
it('falls monotonically while closing', () => {
let prev = Infinity;
for (let i = 0; i <= 200; i++) {
const hz = cutoffAt(i / 200, FLOOR_CUTOFF_HZ, DOOR_CUTOFF_HZ);
expect(hz).toBeLessThan(prev);
prev = hz;
}
});
it('stays inside the sweep range at every t', () => {
for (let i = 0; i <= 100; i++) {
const hz = cutoffAt(i / 100, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ);
expect(hz).toBeGreaterThanOrEqual(DOOR_CUTOFF_HZ);
expect(hz).toBeLessThanOrEqual(FLOOR_CUTOFF_HZ);
}
});
it('is symmetric: closing retraces the opening sweep backwards', () => {
for (let i = 0; i <= 20; i++) {
const t = i / 20;
expect(cutoffAt(t, FLOOR_CUTOFF_HZ, DOOR_CUTOFF_HZ)).toBeCloseTo(
cutoffAt(1 - t, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ),
6,
);
}
});
});
describe('gainAt', () => {
it('interpolates linearly between the endpoints', () => {
expect(gainAt(0, DOOR_GAIN, FLOOR_GAIN)).toBeCloseTo(DOOR_GAIN, 6);
expect(gainAt(1, DOOR_GAIN, FLOOR_GAIN)).toBeCloseTo(FLOOR_GAIN, 6);
expect(gainAt(0.5, DOOR_GAIN, FLOOR_GAIN)).toBeCloseTo((DOOR_GAIN + FLOOR_GAIN) / 2, 6);
expect(gainAt(0.25, 0, 1)).toBeCloseTo(0.25, 6);
expect(gainAt(0.25, 1, 0)).toBeCloseTo(0.75, 6);
});
it('clamps t outside 0..1', () => {
expect(gainAt(-2, DOOR_GAIN, FLOOR_GAIN)).toBeCloseTo(DOOR_GAIN, 6);
expect(gainAt(4, DOOR_GAIN, FLOOR_GAIN)).toBeCloseTo(FLOOR_GAIN, 6);
});
});
describe('cutoffCurve', () => {
it('defaults to 32 sampled points spanning the full sweep', () => {
const curve = cutoffCurve(DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ);
expect(curve).toBeInstanceOf(Float32Array);
expect(curve.length).toBe(32);
expect(curve[0]!).toBeCloseTo(DOOR_CUTOFF_HZ, 3);
expect(curve[31]!).toBeCloseTo(FLOOR_CUTOFF_HZ, 0);
});
it('honours a custom point count', () => {
for (const points of [2, 8, 64, 129]) {
const curve = cutoffCurve(DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ, points);
expect(curve.length).toBe(points);
expect(curve[0]!).toBeCloseTo(DOOR_CUTOFF_HZ, 3);
expect(curve[points - 1]!).toBeCloseTo(FLOOR_CUTOFF_HZ, 0);
}
});
it('degrades to a single valid point instead of NaN, empty, or a throw', () => {
// A NaN here would reach setValueCurveAtTime (throws) or AudioParam.value
// (silently mutes the filter), so degenerate counts must stay finite.
for (const points of [1, 0, -1, -100]) {
const curve = cutoffCurve(DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ, points);
expect(curve.length).toBe(1);
expect(Number.isFinite(curve[0]!)).toBe(true);
expect(curve[0]!).toBeCloseTo(DOOR_CUTOFF_HZ, 3);
}
});
it('never emits a non-finite sample for any point count', () => {
for (const points of [-5, 0, 1, 2, 3, 32, 128]) {
const curve = cutoffCurve(FLOOR_CUTOFF_HZ, DOOR_CUTOFF_HZ, points);
expect(curve.length).toBeGreaterThan(0);
for (const hz of curve) expect(Number.isFinite(hz)).toBe(true);
}
});
it('rises monotonically opening and falls monotonically closing', () => {
const up = cutoffCurve(DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ, 64);
for (let i = 1; i < up.length; i++) expect(up[i]!).toBeGreaterThan(up[i - 1]!);
const down = cutoffCurve(FLOOR_CUTOFF_HZ, DOOR_CUTOFF_HZ, 64);
for (let i = 1; i < down.length; i++) expect(down[i]!).toBeLessThan(down[i - 1]!);
});
it('samples the same curve cutoffAt describes', () => {
const points = 16;
const curve = cutoffCurve(DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ, points);
for (let i = 0; i < points; i++) {
const expected = cutoffAt(i / (points - 1), DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ);
// Float32 storage costs precision, so compare relatively.
expect(curve[i]! / expected).toBeCloseTo(1, 5);
}
});
it('keeps its midpoint well below the linear halfway mark', () => {
const curve = cutoffCurve(DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ, 33);
expect(curve[16]!).toBeCloseTo(Math.sqrt(DOOR_CUTOFF_HZ * FLOOR_CUTOFF_HZ), 1);
expect(curve[16]!).toBeLessThan((DOOR_CUTOFF_HZ + FLOOR_CUTOFF_HZ) / 4);
});
});

View File

@ -0,0 +1,149 @@
import { describe, expect, it } from 'vitest';
import {
applyBang,
BANGS_TO_BUST,
beatError,
freshStall,
judgeBang,
OK_MS,
PERFECT_MS,
scoreStall,
type StallState,
} from '../../src/scenes/floor/bangJudge';
const INTERVAL = 60_000 / 128;
const TICK = { beatIndex: 4, audioTimeMs: 10_000 };
describe('beatError', () => {
it('is zero exactly on the tick', () => {
expect(beatError(TICK.audioTimeMs, TICK, INTERVAL)).toBe(0);
});
it('measures to the nearest boundary, not to the tick itself', () => {
expect(beatError(TICK.audioTimeMs + INTERVAL, TICK, INTERVAL)).toBeCloseTo(0, 6);
expect(beatError(TICK.audioTimeMs + 3 * INTERVAL + 25, TICK, INTERVAL)).toBeCloseTo(25, 6);
});
it('judges a bang landing before the tick against the previous boundary', () => {
expect(beatError(TICK.audioTimeMs - INTERVAL + 30, TICK, INTERVAL)).toBeCloseTo(30, 6);
expect(beatError(TICK.audioTimeMs - 20, TICK, INTERVAL)).toBeCloseTo(20, 6);
});
it('peaks halfway between beats', () => {
expect(beatError(TICK.audioTimeMs + INTERVAL / 2, TICK, INTERVAL)).toBeCloseTo(INTERVAL / 2, 6);
});
});
describe('judgeBang', () => {
it('grades an exact hit as perfect', () => {
expect(judgeBang(TICK.audioTimeMs, TICK, INTERVAL)).toBe('perfect');
});
it('still grades perfect a full beat interval later', () => {
expect(judgeBang(TICK.audioTimeMs + INTERVAL, TICK, INTERVAL)).toBe('perfect');
expect(judgeBang(TICK.audioTimeMs + 8 * INTERVAL, TICK, INTERVAL)).toBe('perfect');
});
it('grades halfway between beats as a miss', () => {
expect(judgeBang(TICK.audioTimeMs + INTERVAL / 2, TICK, INTERVAL)).toBe('miss');
expect(judgeBang(TICK.audioTimeMs + 2.5 * INTERVAL, TICK, INTERVAL)).toBe('miss');
});
it('treats PERFECT_MS as inclusive and just past it as ok', () => {
expect(judgeBang(TICK.audioTimeMs + PERFECT_MS, TICK, INTERVAL)).toBe('perfect');
expect(judgeBang(TICK.audioTimeMs - PERFECT_MS, TICK, INTERVAL)).toBe('perfect');
expect(judgeBang(TICK.audioTimeMs + PERFECT_MS + 0.5, TICK, INTERVAL)).toBe('ok');
});
it('treats OK_MS as inclusive and just past it as a miss', () => {
expect(judgeBang(TICK.audioTimeMs + OK_MS, TICK, INTERVAL)).toBe('ok');
expect(judgeBang(TICK.audioTimeMs - OK_MS, TICK, INTERVAL)).toBe('ok');
expect(judgeBang(TICK.audioTimeMs + OK_MS + 0.5, TICK, INTERVAL)).toBe('miss');
});
it('grades late bangs relative to whichever boundary is closest', () => {
// 100ms past the fourth boundary — ok, even though it is ~1875ms past the tick.
expect(judgeBang(TICK.audioTimeMs + 4 * INTERVAL + 100, TICK, INTERVAL)).toBe('ok');
});
});
describe('applyBang', () => {
it('does not mutate the input state', () => {
const s = freshStall();
const snapshot: StallState = { ...s };
applyBang(s, 'perfect');
expect(s).toEqual(snapshot);
});
it('advances the combo on perfect and ok alike', () => {
const a = applyBang(freshStall(), 'perfect');
const b = applyBang(a, 'ok');
expect(b.combo).toBe(2);
expect(b.bangs).toBe(2);
expect(b.aggro).toBe(0);
expect(b.lastGrade).toBe('ok');
});
it('resets the combo and adds aggro on a miss', () => {
const a = applyBang(applyBang(freshStall(), 'ok'), 'miss');
expect(a.combo).toBe(0);
expect(a.bangs).toBe(2);
expect(a.aggro).toBeCloseTo(0.8, 6);
expect(a.busted).toBe(false);
});
it('busts after three consecutive ok bangs', () => {
let s = freshStall();
for (let i = 0; i < BANGS_TO_BUST; i++) s = applyBang(s, 'ok');
expect(s.busted).toBe(true);
});
it('stays busted once busted, even after a later miss', () => {
let s = freshStall();
for (let i = 0; i < BANGS_TO_BUST; i++) s = applyBang(s, 'perfect');
s = applyBang(s, 'miss');
expect(s.busted).toBe(true);
expect(s.combo).toBe(0);
});
it('is not busted when a miss interrupts the run', () => {
let s = freshStall();
for (const g of ['ok', 'miss', 'ok', 'ok'] as const) s = applyBang(s, g);
expect(s.busted).toBe(false);
expect(s.combo).toBe(2);
expect(s.bangs).toBe(4);
});
});
describe('scoreStall', () => {
it('pays out big when the stall is busted', () => {
let s = freshStall();
for (let i = 0; i < BANGS_TO_BUST; i++) s = applyBang(s, 'perfect');
const out = scoreStall(s);
expect(out.solved).toBe(true);
expect(out.vibeDelta).toBe(9);
expect(out.aggroDelta).toBe(0);
});
it('costs vibe when the player made a scene and achieved nothing', () => {
const out = scoreStall(applyBang(freshStall(), 'miss'));
expect(out.solved).toBe(false);
expect(out.vibeDelta).toBe(-1.5);
expect(out.aggroDelta).toBeCloseTo(0.8, 6);
});
it('clamps accumulated aggro at 6', () => {
let s = freshStall();
for (let i = 0; i < 12; i++) s = applyBang(s, 'miss');
expect(s.aggro).toBeGreaterThan(6);
expect(scoreStall(s).aggroDelta).toBe(6);
});
it('carries aggro from misses through a bust', () => {
let s = freshStall();
for (const g of ['miss', 'miss', 'ok', 'ok', 'perfect'] as const) s = applyBang(s, g);
const out = scoreStall(s);
expect(out.solved).toBe(true);
expect(out.aggroDelta).toBeCloseTo(1.6, 6);
});
});

167
tests/floor/cone.test.ts Normal file
View File

@ -0,0 +1,167 @@
import { describe, expect, it } from 'vitest';
import {
NORMAL_CONE, UV_CONE, angleDelta, coneVisibility, conePolygon, inCone,
} from '../../src/scenes/floor/cone';
import type { ConeSpec } from '../../src/scenes/floor/cone';
const EAST: ConeSpec = { x: 100, y: 100, facing: 0, ...NORMAL_CONE };
const at = (spec: ConeSpec, dist: number, off: number) => ({
x: spec.x + Math.cos(spec.facing + off) * dist,
y: spec.y + Math.sin(spec.facing + off) * dist,
});
describe('angleDelta', () => {
it('is zero for identical angles and full turns apart', () => {
expect(angleDelta(0, 0)).toBeCloseTo(0, 12);
expect(angleDelta(Math.PI * 2, 0)).toBeCloseTo(0, 12);
expect(angleDelta(-Math.PI * 4, 0)).toBeCloseTo(0, 12);
});
it('takes the short way around the wrap point', () => {
expect(angleDelta(0.1, -0.1)).toBeCloseTo(0.2, 12);
// 175 deg and -175 deg are 10 deg apart, not 350.
const a = Math.PI - 0.087;
const b = -Math.PI + 0.087;
expect(Math.abs(angleDelta(a, b))).toBeCloseTo(0.174, 6);
expect(Math.abs(angleDelta(b, a))).toBeCloseTo(0.174, 6);
});
it('stays in (-PI, PI] for a sweep of inputs', () => {
for (let i = -40; i <= 40; i++) {
const d = angleDelta(i * 0.37, i * -0.61);
expect(d).toBeGreaterThan(-Math.PI);
expect(d).toBeLessThanOrEqual(Math.PI + 1e-12);
}
});
it('maps an exact half turn to +PI, not -PI', () => {
expect(angleDelta(Math.PI, 0)).toBeCloseTo(Math.PI, 12);
expect(angleDelta(0, Math.PI)).toBeCloseTo(Math.PI, 12);
});
});
describe('inCone', () => {
it('sees straight ahead', () => {
expect(inCone(EAST, 200, 100)).toBe(true);
expect(inCone(EAST, EAST.x, EAST.y)).toBe(true);
});
it('is blind behind and to the sides', () => {
expect(inCone(EAST, 0, 100)).toBe(false);
expect(inCone(EAST, 100, 20)).toBe(false);
expect(inCone(EAST, ...([at(EAST, 50, 1.2).x, at(EAST, 50, 1.2).y] as const))).toBe(false);
});
it('is blind beyond range', () => {
const far = at(EAST, EAST.range + 1, 0);
expect(inCone(EAST, far.x, far.y)).toBe(false);
const near = at(EAST, EAST.range - 1, 0);
expect(inCone(EAST, near.x, near.y)).toBe(true);
});
it('respects facing — rotating the cone rotates what it sees', () => {
const north: ConeSpec = { ...EAST, facing: -Math.PI / 2 };
expect(inCone(north, 100, 20)).toBe(true);
expect(inCone(north, 200, 100)).toBe(false);
});
it('works across the PI wrap', () => {
const west: ConeSpec = { ...EAST, facing: Math.PI };
const p = at(west, 60, 0.2);
expect(inCone(west, p.x, p.y)).toBe(true);
expect(inCone(west, 200, 100)).toBe(false);
});
it('UV torch is tighter and shorter than the normal one', () => {
expect(UV_CONE.halfAngle).toBeLessThan(NORMAL_CONE.halfAngle);
expect(UV_CONE.range).toBeLessThan(NORMAL_CONE.range);
const uv: ConeSpec = { x: 100, y: 100, facing: 0, ...UV_CONE };
const p = at(EAST, 160, 0.5);
expect(inCone(EAST, p.x, p.y)).toBe(true);
expect(inCone(uv, p.x, p.y)).toBe(false);
});
});
describe('coneVisibility', () => {
it('is 0 wherever inCone is false', () => {
for (let off = -2; off <= 2; off += 0.1) {
for (const dist of [10, 90, 189, 191, 400]) {
const p = at(EAST, dist, off);
if (!inCone(EAST, p.x, p.y)) expect(coneVisibility(EAST, p.x, p.y)).toBe(0);
}
}
});
it('is strictly positive inside and never above 1', () => {
for (const off of [0, 0.2, -0.2, 0.5, -0.5]) {
for (const dist of [1, 40, 120, 180]) {
const p = at(EAST, dist, off);
const v = coneVisibility(EAST, p.x, p.y);
expect(v).toBeGreaterThan(0);
expect(v).toBeLessThanOrEqual(1);
}
}
expect(coneVisibility(EAST, EAST.x, EAST.y)).toBe(1);
});
it('is brighter on-axis than off-axis at the same distance', () => {
const onAxis = at(EAST, 100, 0);
const offAxis = at(EAST, 100, 0.4);
expect(coneVisibility(EAST, onAxis.x, onAxis.y))
.toBeGreaterThan(coneVisibility(EAST, offAxis.x, offAxis.y));
});
it('decreases monotonically with distance along the axis', () => {
let prev = Infinity;
for (let d = 1; d < EAST.range; d += 10) {
const p = at(EAST, d, 0);
const v = coneVisibility(EAST, p.x, p.y);
expect(v).toBeLessThan(prev);
prev = v;
}
});
it('fades to nothing at the cone edge', () => {
const edge = at(EAST, 100, EAST.halfAngle);
expect(coneVisibility(EAST, edge.x, edge.y)).toBeCloseTo(0, 9);
const rim = at(EAST, EAST.range, 0);
expect(coneVisibility(EAST, rim.x, rim.y)).toBeCloseTo(0, 9);
});
});
describe('conePolygon', () => {
it('starts at the apex and closes back on it', () => {
const pts = conePolygon(EAST, 12);
expect(pts[0]).toEqual({ x: EAST.x, y: EAST.y });
expect(pts[pts.length - 1]).toEqual(pts[0]);
expect(pts).toHaveLength(12 + 3); // apex + (segments+1) arc points + closing apex
});
it('puts every arc point at exactly range, inside the cone', () => {
const pts = conePolygon(EAST, 16).slice(1, -1);
for (const p of pts) {
expect(Math.hypot(p.x - EAST.x, p.y - EAST.y)).toBeCloseTo(EAST.range, 9);
expect(Math.abs(angleDelta(Math.atan2(p.y - EAST.y, p.x - EAST.x), EAST.facing)))
.toBeLessThanOrEqual(EAST.halfAngle + 1e-9);
}
});
it('spans the full cone width, edge to edge', () => {
const pts = conePolygon(EAST, 8);
const firstArc = pts[1]!;
const lastArc = pts[pts.length - 2]!;
expect(angleDelta(Math.atan2(firstArc.y - EAST.y, firstArc.x - EAST.x), EAST.facing))
.toBeCloseTo(-EAST.halfAngle, 9);
expect(angleDelta(Math.atan2(lastArc.y - EAST.y, lastArc.x - EAST.x), EAST.facing))
.toBeCloseTo(EAST.halfAngle, 9);
});
it('tolerates a degenerate segment count', () => {
expect(conePolygon(EAST, 0)).toHaveLength(4);
expect(conePolygon(EAST, 1)).toHaveLength(4);
});
it('defaults to a smooth arc', () => {
expect(conePolygon(EAST).length).toBeGreaterThan(20);
});
});

View File

@ -0,0 +1,362 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { EventBus } from '../../src/core/EventBus';
import { SeededRNG } from '../../src/core/SeededRNG';
import type { 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 { VENUE, isWalkableWorld } from '../../src/scenes/floor/venueMap';
const NIGHT = new Date('2026-07-19T21:00:00');
function makeSim(seed = 1234): CrowdSim {
return new CrowdSim({ map: VENUE, bus: new EventBus(), rng: new SeededRNG(seed).stream('crowd') });
}
/** Same seed in, same crowd out — the sims under test must not share patrons. */
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;
}
const isEntry = (a: Agent): boolean =>
VENUE.anchors.entry.some((e) => e.x === a.x && e.y === a.y);
/** Park an agent in a fixed activity so the test can drive one thing at a time. */
function pin(a: Agent, activity: Agent['activity'], x?: number, y?: number): Agent {
a.activity = activity;
a.dwellMs = 10 * 60 * 1000;
if (x !== undefined) a.x = x;
if (y !== undefined) a.y = y;
return a;
}
beforeEach(() => {
_resetPatronSerial();
});
describe('spawn', () => {
it('puts the agent on an entry anchor, unhandled and present', () => {
const sim = makeSim();
for (const p of makeCrowd(6)) {
const a = sim.spawn(p);
expect(isEntry(a)).toBe(true);
expect(a.handled).toBe(false);
expect(a.gone).toBe(false);
expect(a.activity).toBe('walking');
}
expect(sim.agents).toHaveLength(6);
});
it('finds agents by patron id', () => {
const sim = makeSim();
const crowd = makeCrowd(3);
for (const p of crowd) sim.spawn(p);
expect(sim.find(crowd[1]!.id)?.patron).toBe(crowd[1]);
expect(sim.find('nobody')).toBeUndefined();
});
});
describe('movement', () => {
it('keeps every agent on walkable ground for a whole night', () => {
const sim = makeSim();
for (const p of makeCrowd(14)) sim.spawn(p);
for (let step = 0; step < 6000; step++) {
sim.update(16, step / 60);
for (const a of sim.agents) {
expect(
isWalkableWorld(VENUE, a.x, a.y),
`${a.patron.id} walked into a wall at ${a.x.toFixed(1)},${a.y.toFixed(1)}`,
).toBe(true);
}
}
});
it('actually gets people to their anchors — nobody walks all night', () => {
const sim = makeSim();
for (const p of makeCrowd(10)) sim.spawn(p);
const settled = new Set<string>();
for (let step = 0; step < 4000; step++) {
sim.update(16, step / 60);
for (const a of sim.agents) if (a.activity !== 'walking') settled.add(a.patron.id);
}
expect(settled.size).toBe(10);
});
});
describe('intoxication drift', () => {
it('rises with the clock, and faster at the bar', () => {
const sim = makeSim();
const [barP, danceP] = makeCrowd(2);
const bar = pin(sim.spawn(barP!), 'atBar');
const dance = pin(sim.spawn(danceP!), 'dancing');
for (const a of [bar, dance]) {
a.patron.tolerance = 0.5;
a.patron.intoxication = 0.3;
}
sim.update(16, 0);
sim.update(16, 90);
expect(bar.patron.intoxication).toBeGreaterThan(0.3);
expect(dance.patron.intoxication).toBeGreaterThan(0.3);
expect(bar.patron.intoxication).toBeGreaterThan(dance.patron.intoxication);
});
it('is framerate independent — only clock minutes count', () => {
const coarse = makeSim();
const fine = makeSim();
const [a1] = makeCrowd(1);
const [a2] = makeCrowd(1);
const slow = pin(coarse.spawn(a1!), 'atBar');
const fast = pin(fine.spawn(a2!), 'atBar');
for (const a of [slow, fast]) {
a.patron.tolerance = 0.4;
a.patron.intoxication = 0.2;
}
coarse.update(16, 0);
coarse.update(16, 60);
fine.update(16, 0);
for (let i = 1; i <= 60; i++) fine.update(16, i);
expect(fast.patron.intoxication).toBeCloseTo(slow.patron.intoxication, 10);
});
it('never tips past legless', () => {
const sim = makeSim();
const a = pin(sim.spawn(makeCrowd(1)[0]!), 'atBar');
a.patron.tolerance = 0;
a.patron.intoxication = 0.9;
sim.update(16, 0);
sim.update(16, 10000);
expect(a.patron.intoxication).toBe(1);
});
});
describe('beat bob', () => {
it('advances dancers only', () => {
const sim = makeSim();
const [p1, p2] = makeCrowd(2);
const dancer = pin(sim.spawn(p1!), 'dancing');
const drinker = pin(sim.spawn(p2!), 'atBar');
const before = { dancer: dancer.bobPhase, drinker: drinker.bobPhase };
sim.onBeat(1);
expect(dancer.bobPhase).not.toBe(before.dancer);
expect(drinker.bobPhase).toBe(before.drinker);
});
it('keeps the phase inside 0..1 across a long set', () => {
const sim = makeSim();
const dancer = pin(sim.spawn(makeCrowd(1)[0]!), 'dancing');
for (let beat = 0; beat < 500; beat++) {
sim.onBeat(beat);
expect(dancer.bobPhase).toBeGreaterThanOrEqual(0);
expect(dancer.bobPhase).toBeLessThan(1);
}
});
});
describe('stalls', () => {
it('reports who is in which cubicle', () => {
const sim = makeSim();
const [p1, p2, p3] = makeCrowd(3);
const a = pin(sim.spawn(p1!), 'inStall');
const b = pin(sim.spawn(p2!), 'inStall');
const c = pin(sim.spawn(p3!), 'inStall');
a.stallId = 's1';
b.stallId = 's1';
c.stallId = 's3';
const occ = sim.stallOccupancy();
expect(occ.get('s1')).toHaveLength(2);
expect(occ.get('s2')).toHaveLength(0);
expect(occ.get('s3')).toEqual([c]);
});
it('lets two punters end up in one cubicle on its own', () => {
const sim = makeSim();
for (const p of makeCrowd(14)) sim.spawn(p);
let doubled = false;
let maxOccupancy = 0;
for (let step = 0; step < 9000; step++) {
sim.update(16, step / 60);
for (const [, list] of sim.stallOccupancy()) {
maxOccupancy = Math.max(maxOccupancy, list.length);
if (list.length >= 2) doubled = true;
}
}
expect(doubled).toBe(true);
expect(maxOccupancy).toBeLessThanOrEqual(2); // the cap holds
});
});
describe('bumping', () => {
function bumpRig(): { sim: CrowdSim; hits: number[]; drunk: Agent } {
const bus = new EventBus();
const sim = new CrowdSim({ map: VENUE, bus, rng: new SeededRNG(5).stream('crowd') });
const hits: number[] = [];
bus.on('meters:delta', (d) => hits.push(d.aggro ?? 0));
const [p1, p2] = makeCrowd(2);
const drunk = pin(sim.spawn(p1!), 'dancing');
const mate = pin(sim.spawn(p2!), 'dancing');
drunk.patron.intoxication = 0.95;
drunk.patron.tolerance = 1; // no drift, so the stage under test stays put
mate.patron.intoxication = 0.05;
mate.patron.tolerance = 1;
mate.x = drunk.x;
mate.y = drunk.y;
return { sim, hits, drunk };
}
it('fires once, then not again until the cooldown lapses', () => {
const { sim, hits } = bumpRig();
for (let i = 0; i < 100; i++) sim.update(16, 0); // 1.6s of grinding together
expect(hits).toHaveLength(1);
expect(hits[0]).toBeGreaterThan(0);
// Second leak needs BOTH windows open: the 12s per-agent cooldown and the
// 8s sim-wide gate. 800 more frames puts total elapsed at ~14.4s.
for (let i = 0; i < 800; i++) sim.update(16, 0);
expect(hits).toHaveLength(2);
});
// Regression: the per-agent cooldown bounds one PAIR, but the leak rate used
// to scale with crowd size — a full late-night floor pinned vibe to 0 and
// aggro to 100 on its own, before the player had touched anything. The
// sim-wide gate is what keeps ambient chaos a background cost.
it('bounds the total leak no matter how big the rowdy crowd is', () => {
const bus = new EventBus();
const sim = new CrowdSim({ map: VENUE, bus, rng: new SeededRNG(9).stream('crowd') });
let vibe = 0;
bus.on('meters:delta', (d) => {
vibe += d.vibe ?? 0;
});
// 30 maggots stacked on one spot — the worst case the venue can produce.
const agents = makeCrowd(30).map((p) => pin(sim.spawn(p), 'dancing'));
const first = agents[0]!;
for (const a of agents) {
a.patron.intoxication = 0.95;
a.patron.tolerance = 1;
a.x = first.x;
a.y = first.y;
}
for (let i = 0; i < 3750; i++) sim.update(16, 0); // 60s of maximum chaos
// One leak per 8s, so a minute of it costs ~8 bumps, not hundreds.
expect(vibe).toBeGreaterThan(-5);
expect(vibe).toBeLessThan(0); // but it does still cost you something
});
it('stays quiet when the sober one is the only neighbour', () => {
const { sim, hits, drunk } = bumpRig();
drunk.patron.intoxication = 0.1; // nobody here is messy
for (let i = 0; i < 400; i++) sim.update(16, 0);
expect(hits).toHaveLength(0);
});
it('carries the contracted vibe/aggro payload', () => {
const bus = new EventBus();
const sim = new CrowdSim({ map: VENUE, bus, rng: new SeededRNG(5).stream('crowd') });
const seen: Array<{ vibe?: number; aggro?: number }> = [];
bus.on('meters:delta', (d) => seen.push(d));
const [p1, p2] = makeCrowd(2);
const drunk = pin(sim.spawn(p1!), 'dancing');
const mate = pin(sim.spawn(p2!), 'dancing');
drunk.patron.intoxication = 0.95;
drunk.patron.tolerance = 1;
mate.patron.tolerance = 1;
mate.x = drunk.x;
mate.y = drunk.y;
sim.update(16, 0);
expect(seen).toEqual([{ vibe: -0.4, aggro: 0.6 }]);
});
});
describe('determinism', () => {
it('same seed and same calls give identical positions', () => {
const snap = (sim: CrowdSim): string => sim.agents
.map((a) => `${a.patron.id}:${a.x.toFixed(6)},${a.y.toFixed(6)}:${a.activity}:${a.patron.intoxication.toFixed(6)}`)
.join('|');
const runOne = (): string => {
const sim = makeSim(4242);
for (const p of makeCrowd(9)) sim.spawn(p);
for (let step = 0; step < 3000; step++) sim.update(16, step / 60);
return snap(sim);
};
expect(runOne()).toBe(runOne());
});
});
describe('escort', () => {
it('walks them to the door when you follow, and only then', () => {
const sim = makeSim();
const a = sim.spawn(makeCrowd(1)[0]!);
// Start them deep in the dunnies — the nastiest route out of the venue.
a.x = VENUE.stalls[0]!.door.x;
a.y = VENUE.stalls[0]!.door.y;
expect(sim.beginEscort(a)).toBe(true);
expect(a.activity).toBe('escorted');
expect(a.handled).toBe(true);
expect(sim.beginEscort(a)).toBe(false);
// Stand off and they refuse to budge.
const parked = { x: a.x, y: a.y };
for (let i = 0; i < 100; i++) sim.updateEscorts(parked.x + 400, parked.y + 200, 16);
expect(a.x).toBe(parked.x);
expect(a.y).toBe(parked.y);
expect(a.gone).toBe(false);
// Stay on their heels and they go.
for (let i = 0; i < 12000 && !a.gone; i++) {
sim.updateEscorts(a.x, a.y, 16);
expect(isWalkableWorld(VENUE, a.x, a.y)).toBe(true);
}
expect(a.gone).toBe(true);
expect(a.activity).toBe('leaving');
expect(a.x).toBe(VENUE.anchors.exit[0]!.x);
expect(a.y).toBe(VENUE.anchors.exit[0]!.y);
});
it('refuses to escort someone already gone', () => {
const sim = makeSim();
const a = sim.spawn(makeCrowd(1)[0]!);
sim.beginEscort(a);
for (let i = 0; i < 12000 && !a.gone; i++) sim.updateEscorts(a.x, a.y, 16);
expect(a.gone).toBe(true);
expect(sim.beginEscort(a)).toBe(false);
});
it('pulls them out of the cubicle they were hiding in', () => {
const sim = makeSim();
const a = pin(sim.spawn(makeCrowd(1)[0]!), 'inStall');
a.stallId = 's2';
a.x = VENUE.stalls[1]!.inside.x;
a.y = VENUE.stalls[1]!.inside.y;
expect(sim.beginEscort(a)).toBe(true);
expect(a.stallId).toBeUndefined();
expect(a.x).toBe(VENUE.stalls[1]!.door.x);
expect(sim.stallOccupancy().get('s2')).toHaveLength(0);
});
});
describe('destroy', () => {
it('drops the crowd', () => {
const sim = makeSim();
for (const p of makeCrowd(4)) sim.spawn(p);
sim.destroy();
expect(sim.agents).toHaveLength(0);
expect(sim.find('p0')).toBeUndefined();
});
});

View File

@ -0,0 +1,139 @@
import { describe, expect, it } from 'vitest';
import {
CORRECT_LINE,
MAGGOT_GRACE_MIN,
judgeEscalation,
maggotOverdue,
} from '../../src/scenes/floor/escalation';
import type { DrunkStage } from '../../src/data/types';
import {
CUTOFF_REPLY_CLEAN,
CUTOFF_REPLY_SCENE,
CUTOFF_REPLY_SOFT,
ESCALATION_LINES,
type EscalationLine,
} from '../../src/data/strings/floor';
const STAGES: readonly DrunkStage[] = ['sober', 'tipsy', 'loose', 'messy', 'maggot'];
const LINES: readonly EscalationLine[] = ['water', 'done', 'escort'];
describe('judgeEscalation matrix', () => {
it('returns a coherent result for every (stage, line) pair', () => {
for (const stage of STAGES) {
for (const line of LINES) {
const r = judgeEscalation(stage, line);
expect(['clean', 'scene', 'staggerFree']).toContain(r.outcome);
expect(r.line).toBe(ESCALATION_LINES[line]);
expect(r.line.length).toBeGreaterThan(0);
expect(r.reply.length).toBeGreaterThan(0);
expect(Object.values(CUTOFF_REPLY_CLEAN)
.concat(Object.values(CUTOFF_REPLY_SCENE), Object.values(CUTOFF_REPLY_SOFT)))
.toContain(r.reply);
expect(r.aggroDelta).toBeGreaterThanOrEqual(0);
if (r.eject) expect(r.outcome).toBe('clean');
if (!r.resolved) expect(r.outcome).toBe('staggerFree');
}
}
});
it('pulls the reply from the bank matching its outcome', () => {
for (const stage of STAGES) {
for (const line of LINES) {
const r = judgeEscalation(stage, line);
const bank =
r.outcome === 'clean' ? CUTOFF_REPLY_CLEAN
: r.outcome === 'scene' ? CUTOFF_REPLY_SCENE
: CUTOFF_REPLY_SOFT;
expect(r.reply).toBe(bank[stage]);
}
}
});
it('is clean whenever the line matches the correct rung', () => {
for (const stage of STAGES) {
const r = judgeEscalation(stage, CORRECT_LINE[stage]);
expect(r.outcome).toBe('clean');
expect(r.vibeDelta).toBeGreaterThan(0);
expect(r.aggroDelta).toBe(0);
expect(r.resolved).toBe(true);
}
});
it('keeps the clean reward modest — never a jackpot for being right', () => {
for (const stage of STAGES) {
expect(judgeEscalation(stage, CORRECT_LINE[stage]).vibeDelta).toBeLessThanOrEqual(5);
}
});
});
describe('over-escalating', () => {
it('makes a scene with positive aggro on a loose patron', () => {
const r = judgeEscalation('loose', 'done');
expect(r.outcome).toBe('scene');
expect(r.aggroDelta).toBeGreaterThan(0);
expect(r.vibeDelta).toBeLessThan(0);
expect(r.eject).toBe(false);
expect(r.resolved).toBe(true);
});
it('scales aggro and vibe loss with the distance overreached', () => {
const oneRung = judgeEscalation('loose', 'done');
const twoRungs = judgeEscalation('loose', 'escort');
expect(twoRungs.aggroDelta).toBe(oneRung.aggroDelta * 2);
expect(twoRungs.vibeDelta).toBe(oneRung.vibeDelta * 2);
});
it('costs more than restraint does, so aggression never pays', () => {
const scene = judgeEscalation('sober', 'escort');
const soft = judgeEscalation('maggot', 'water');
expect(scene.vibeDelta).toBeLessThan(soft.vibeDelta);
});
});
describe('under-escalating', () => {
it('lets a maggot stagger free and leaves the infraction open', () => {
const r = judgeEscalation('maggot', 'water');
expect(r.outcome).toBe('staggerFree');
expect(r.resolved).toBe(false);
expect(r.eject).toBe(false);
expect(r.aggroDelta).toBe(0);
expect(r.vibeDelta).toBeLessThan(0);
});
it('does not eject or resolve when you water a messy patron', () => {
const r = judgeEscalation('messy', 'water');
expect(r.outcome).toBe('staggerFree');
expect(r.eject).toBe(false);
expect(r.resolved).toBe(false);
});
});
describe('ejection', () => {
it('escorts a maggot out', () => {
const r = judgeEscalation('maggot', 'escort');
expect(r.outcome).toBe('clean');
expect(r.eject).toBe(true);
});
it('walks a messy patron when you call them done', () => {
expect(judgeEscalation('messy', 'done').eject).toBe(true);
});
it('never ejects someone who only needed water', () => {
for (const stage of ['sober', 'tipsy', 'loose'] as const) {
expect(judgeEscalation(stage, 'water').eject).toBe(false);
}
});
});
describe('maggotOverdue', () => {
it('is false at the grace boundary and true one minute past', () => {
expect(maggotOverdue(100, 100 + MAGGOT_GRACE_MIN)).toBe(false);
expect(maggotOverdue(100, 100 + MAGGOT_GRACE_MIN + 1)).toBe(true);
});
it('is false before the grace window elapses', () => {
expect(maggotOverdue(100, 100)).toBe(false);
expect(maggotOverdue(100, 110)).toBe(false);
});
});

163
tests/floor/patDown.test.ts Normal file
View File

@ -0,0 +1,163 @@
import { describe, expect, it } from 'vitest';
import { SeededRNG } from '../../src/core/SeededRNG';
import { PATDOWN_CLEAR, PATDOWN_MISSED } from '../../src/data/strings/floor';
import {
foundLine,
layoutPockets,
scorePatDown,
POCKET_COUNT,
type Pocket,
} from '../../src/scenes/floor/patDown';
import type { Patron } from '../../src/data/types';
const patronWith = (contraband: string[]): Patron => ({
id: 'p1',
dollSeed: 42,
archetype: 'punter',
age: 24,
idCard: { name: 'Shazza Nguyen', dob: '2002-03-04', expiry: '2029-03-04', photoSeed: 42 },
outfit: [],
intoxication: 0.3,
tolerance: 0.5,
flags: contraband.length > 0 ? { contraband } : {},
});
const stream = (seed: number) => new SeededRNG(seed).stream('patdown');
const binAll = (pockets: Pocket[]): Pocket[] => pockets.map((p) => ({ ...p, binned: p.itemId !== undefined }));
describe('layoutPockets', () => {
it('returns POCKET_COUNT pockets with unique ids at distinct positions', () => {
const pockets = layoutPockets(patronWith(['flask']), stream(1));
expect(pockets).toHaveLength(POCKET_COUNT);
expect(new Set(pockets.map((p) => p.id)).size).toBe(POCKET_COUNT);
expect(new Set(pockets.map((p) => `${p.x},${p.y}`)).size).toBe(POCKET_COUNT);
});
it('keeps pockets inside the 64x96 outline and clear of each other', () => {
const pockets = layoutPockets(patronWith([]), stream(2));
for (const p of pockets) {
expect(p.x).toBeGreaterThanOrEqual(0);
expect(p.y).toBeGreaterThanOrEqual(0);
expect(p.x).toBeLessThanOrEqual(64);
expect(p.y).toBeLessThanOrEqual(96);
}
for (let i = 0; i < pockets.length; i++) {
for (let j = i + 1; j < pockets.length; j++) {
const a = pockets[i]!;
const b = pockets[j]!;
const clear = Math.abs(a.x - b.x) >= 12 || Math.abs(a.y - b.y) >= 12;
expect(clear, `${a.label} overlaps ${b.label}`).toBe(true);
}
}
});
it('places every contraband id in a distinct pocket', () => {
const items = ['flask', 'kebab', 'budgie'];
const pockets = layoutPockets(patronWith(items), stream(3));
const filled = pockets.filter((p) => p.itemId !== undefined);
expect(filled).toHaveLength(items.length);
expect(new Set(filled.map((p) => p.id)).size).toBe(items.length);
expect(filled.map((p) => p.itemId).sort()).toEqual([...items].sort());
});
it('leaves an empty-handed patron with no items and nothing revealed or binned', () => {
const pockets = layoutPockets(patronWith([]), stream(4));
expect(pockets.every((p) => p.itemId === undefined)).toBe(true);
expect(pockets.every((p) => !p.revealed && !p.binned)).toBe(true);
});
it('places only what fits when there is more contraband than pockets', () => {
const items = ['flask', 'kebab', 'budgie', 'vapes', 'marker', 'snags', 'tinnies', 'goonSack'];
const pockets = layoutPockets(patronWith(items), stream(5));
expect(pockets.filter((p) => p.itemId !== undefined)).toHaveLength(POCKET_COUNT);
});
it('is deterministic for the same seed and divergent across seeds', () => {
const items = ['flask', 'kebab', 'budgie'];
const a = layoutPockets(patronWith(items), stream(7)).map((p) => p.itemId);
const b = layoutPockets(patronWith(items), stream(7)).map((p) => p.itemId);
expect(a).toEqual(b);
const seeds = [11, 12, 13, 14, 15, 16].map((s) =>
layoutPockets(patronWith(items), stream(s))
.map((p) => p.itemId ?? '-')
.join(),
);
expect(new Set(seeds).size).toBeGreaterThan(1);
});
});
describe('scorePatDown', () => {
it('rewards a full sweep: every item found, positive vibe, no aggro', () => {
const patron = patronWith(['flask', 'kebab']);
const result = scorePatDown(patron, binAll(layoutPockets(patron, stream(21))));
expect(result.foundIds.sort()).toEqual(['flask', 'kebab']);
expect(result.missedIds).toEqual([]);
expect(result.vibeDelta).toBe(4);
expect(result.aggroDelta).toBe(0);
expect(result.summary).not.toContain(PATDOWN_MISSED);
});
it('leaks vibe for items left in the pockets when time runs out', () => {
const patron = patronWith(['flask', 'kebab']);
const result = scorePatDown(patron, layoutPockets(patron, stream(22)));
expect(result.foundIds).toEqual([]);
expect(result.missedIds.sort()).toEqual(['flask', 'kebab']);
expect(result.vibeDelta).toBe(-3);
expect(result.summary).toBe(PATDOWN_MISSED);
expect(result.eject).toBe(false);
});
it('mixes found and missed, and still flags the miss', () => {
const patron = patronWith(['flask', 'kebab']);
const pockets = layoutPockets(patron, stream(23)).map((p) => ({
...p,
binned: p.itemId === 'flask',
}));
const result = scorePatDown(patron, pockets);
expect(result.foundIds).toEqual(['flask']);
expect(result.missedIds).toEqual(['kebab']);
expect(result.vibeDelta).toBe(0.5);
expect(result.summary).toContain(PATDOWN_MISSED);
});
it('ejects on a binned severity-3 item but not a severity-1 one', () => {
const baggie = patronWith(['baggie']);
expect(scorePatDown(baggie, binAll(layoutPockets(baggie, stream(31)))).eject).toBe(true);
const kebab = patronWith(['kebab']);
const kebabResult = scorePatDown(kebab, binAll(layoutPockets(kebab, stream(32))));
expect(kebabResult.eject).toBe(false);
expect(kebabResult.foundIds).toEqual(['kebab']);
});
it('does not eject for a severity-3 item that was never binned', () => {
const patron = patronWith(['baggie']);
const result = scorePatDown(patron, layoutPockets(patron, stream(33)));
expect(result.eject).toBe(false);
expect(result.missedIds).toEqual(['baggie']);
});
it('charges aggro and reads PATDOWN_CLEAR when they had nothing on them', () => {
const patron = patronWith([]);
const result = scorePatDown(patron, layoutPockets(patron, stream(41)));
expect(result.foundIds).toEqual([]);
expect(result.missedIds).toEqual([]);
expect(result.aggroDelta).toBe(1.5);
expect(result.vibeDelta).toBe(0);
expect(result.eject).toBe(false);
expect(result.summary).toBe(PATDOWN_CLEAR);
});
});
describe('foundLine', () => {
it('uses the scripted line for a known item', () => {
expect(foundLine('budgie')).toContain('budgie');
});
it('falls back gracefully for an unknown id', () => {
const line = foundLine('a-second-budgie');
expect(typeof line).toBe('string');
expect(line.length).toBeGreaterThan(0);
});
});

134
tests/floor/player.test.ts Normal file
View File

@ -0,0 +1,134 @@
import { describe, expect, it } from 'vitest';
import {
PLAYER_ACCEL, PLAYER_FRICTION, PLAYER_MAX_SPEED, PLAYER_RADIUS,
freshPlayer, stepPlayer,
} from '../../src/scenes/floor/player';
import type { PlayerInput, PlayerState } from '../../src/scenes/floor/player';
import { TILE, VENUE, isWalkableWorld, tileToWorld } from '../../src/scenes/floor/venueMap';
const NONE: PlayerInput = { up: false, down: false, left: false, right: false };
const input = (over: Partial<PlayerInput>): PlayerInput => ({ ...NONE, ...over });
// Open dance floor, and a spot one tile north of the south border wall.
const OPEN = tileToWorld(38, 23);
const NEAR_SOUTH_WALL = tileToWorld(40, 43);
const SOUTH_WALL_Y = 44 * TILE;
function run(p: PlayerState, i: PlayerInput, steps: number, ms = 16): PlayerState {
let s = p;
for (let n = 0; n < steps; n++) s = stepPlayer(s, i, ms, VENUE);
return s;
}
const speedOf = (s: PlayerState): number => Math.hypot(s.vx, s.vy);
/** The collision test the mover itself uses. */
function standing(s: PlayerState): boolean {
return isWalkableWorld(VENUE, s.x + PLAYER_RADIUS, s.y)
&& isWalkableWorld(VENUE, s.x - PLAYER_RADIUS, s.y)
&& isWalkableWorld(VENUE, s.x, s.y + PLAYER_RADIUS)
&& isWalkableWorld(VENUE, s.x, s.y - PLAYER_RADIUS);
}
describe('freshPlayer', () => {
it('starts at rest where it was put', () => {
const p = freshPlayer(100, 200);
expect(p).toEqual({ x: 100, y: 200, vx: 0, vy: 0, facing: 0 });
});
it('exposes a heavy tuning — accel far outstrips top speed', () => {
expect(PLAYER_ACCEL).toBeGreaterThan(PLAYER_MAX_SPEED);
expect(PLAYER_FRICTION).toBeGreaterThan(0);
});
});
describe('acceleration', () => {
it('accelerates from rest', () => {
const p = stepPlayer(freshPlayer(OPEN.x, OPEN.y), input({ right: true }), 16, VENUE);
expect(p.vx).toBeGreaterThan(0);
expect(p.x).toBeGreaterThan(OPEN.x);
expect(p.vy).toBe(0);
});
it('clamps to PLAYER_MAX_SPEED however long you hold it', () => {
const p = run(freshPlayer(OPEN.x, OPEN.y), input({ right: true }), 40);
expect(speedOf(p)).toBeCloseTo(PLAYER_MAX_SPEED, 6);
expect(speedOf(p)).toBeLessThanOrEqual(PLAYER_MAX_SPEED + 1e-9);
});
it('caps diagonals at the same top speed, not 1.41x', () => {
const p = run(freshPlayer(OPEN.x, OPEN.y), input({ right: true, down: true }), 40);
expect(speedOf(p)).toBeLessThanOrEqual(PLAYER_MAX_SPEED + 1e-9);
expect(p.vx).toBeCloseTo(p.vy, 6);
});
});
describe('friction', () => {
it('decelerates with no input, without reversing', () => {
const moving = run(freshPlayer(OPEN.x, OPEN.y), input({ right: true }), 40);
const a = run(moving, NONE, 5);
const b = run(a, NONE, 5);
expect(speedOf(a)).toBeLessThan(speedOf(moving));
expect(speedOf(b)).toBeLessThan(speedOf(a));
expect(b.vx).toBeGreaterThan(0);
expect(run(b, NONE, 400).vx).toBeCloseTo(0, 3);
});
});
describe('facing', () => {
it('follows the direction of travel', () => {
const right = run(freshPlayer(OPEN.x, OPEN.y), input({ right: true }), 20);
expect(right.facing).toBeCloseTo(0, 4);
const up = run(freshPlayer(OPEN.x, OPEN.y), input({ up: true }), 20);
expect(up.facing).toBeCloseTo(-Math.PI / 2, 4);
});
it('persists when input stops and the body coasts to a halt', () => {
const up = run(freshPlayer(OPEN.x, OPEN.y), input({ up: true }), 20);
const stopped = run(up, NONE, 600);
expect(speedOf(stopped)).toBeLessThan(1);
expect(stopped.facing).toBeCloseTo(up.facing, 6);
});
});
describe('collision', () => {
it('cannot walk through a wall', () => {
const p = run(freshPlayer(NEAR_SOUTH_WALL.x, NEAR_SOUTH_WALL.y), input({ down: true }), 200);
expect(standing(p)).toBe(true);
expect(p.y + PLAYER_RADIUS).toBeLessThan(SOUTH_WALL_Y);
expect(p.vy).toBe(0);
});
it('slides along a wall when moving diagonally into it', () => {
const start = freshPlayer(NEAR_SOUTH_WALL.x, NEAR_SOUTH_WALL.y);
const p = run(start, input({ down: true, right: true }), 120);
expect(standing(p)).toBe(true);
expect(p.x).toBeGreaterThan(start.x + 50);
expect(p.y + PLAYER_RADIUS).toBeLessThan(SOUTH_WALL_Y);
});
it('does not tunnel on a monstrous delta', () => {
const one = stepPlayer(freshPlayer(NEAR_SOUTH_WALL.x, NEAR_SOUTH_WALL.y), input({ down: true }), 60000, VENUE);
expect(standing(one)).toBe(true);
expect(one.y + PLAYER_RADIUS).toBeLessThan(SOUTH_WALL_Y);
// ...and repeated giant deltas are still just clamped steps.
const many = run(one, input({ down: true }), 30, 9999);
expect(standing(many)).toBe(true);
expect(many.y + PLAYER_RADIUS).toBeLessThan(SOUTH_WALL_Y);
});
it('keeps a full patrol of the open floor on walkable ground', () => {
let p = freshPlayer(OPEN.x, OPEN.y);
const legs: PlayerInput[] = [
input({ right: true }), input({ down: true }), input({ left: true }),
input({ up: true }), input({ up: true, left: true }), input({ down: true, right: true }),
];
for (const leg of legs) {
for (let n = 0; n < 300; n++) {
p = stepPlayer(p, leg, 16, VENUE);
expect(standing(p)).toBe(true);
}
}
});
});

View File

@ -0,0 +1,228 @@
import { describe, expect, it } from 'vitest';
import {
MAP_H, MAP_W, TILE, VENUE,
isWalkable, isWalkableWorld, tileAt, tileColour, tileToWorld, worldToTile,
} from '../../src/scenes/floor/venueMap';
import type { AnchorKind, TileKind, WorldPoint } from '../../src/scenes/floor/venueMap';
const ANCHOR_KINDS: readonly AnchorKind[] = ['bar', 'dance', 'booth', 'toilet', 'smoke', 'entry', 'exit'];
const allAnchors = (): WorldPoint[] => ANCHOR_KINDS.flatMap((k) => [...VENUE.anchors[k]]);
const key = (tx: number, ty: number): number => ty * MAP_W + tx;
/** Every walkable tile 4-connected to the seed. */
function flood(seed: WorldPoint): Set<number> {
const start = worldToTile(seed.x, seed.y);
const seen = new Set<number>();
if (!isWalkable(VENUE, start.tx, start.ty)) return seen;
const stack = [start];
seen.add(key(start.tx, start.ty));
while (stack.length > 0) {
const cur = stack.pop()!;
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) {
const tx = cur.tx + dx;
const ty = cur.ty + dy;
const k = key(tx, ty);
if (seen.has(k) || !isWalkable(VENUE, tx, ty)) continue;
seen.add(k);
stack.push({ tx, ty });
}
}
return seen;
}
describe('venueMap dimensions', () => {
it('matches the shared geometry constants', () => {
expect(TILE).toBe(16);
expect(VENUE.width).toBe(MAP_W);
expect(VENUE.height).toBe(MAP_H);
expect(MAP_W * TILE).toBe(1280);
expect(MAP_H * TILE).toBe(720);
});
it('has one tile per cell, all of known kinds', () => {
expect(VENUE.tiles).toHaveLength(MAP_W * MAP_H);
const known = new Set<TileKind>([
'void', 'floor', 'wall', 'bar', 'stool', 'dance',
'booth', 'stall', 'stallDoor', 'exit', 'smokeDoor', 'neon',
]);
for (const t of VENUE.tiles) expect(known.has(t)).toBe(true);
});
it('is ringed by non-walkable edges apart from doors', () => {
for (let tx = 0; tx < MAP_W; tx++) {
for (const ty of [0, MAP_H - 1]) {
const kind = tileAt(VENUE, tx, ty);
if (kind === 'smokeDoor') continue;
expect(isWalkable(VENUE, tx, ty)).toBe(false);
}
}
});
it('contains every authored tile kind', () => {
const seen = new Set(VENUE.tiles);
for (const k of ['floor', 'wall', 'bar', 'stool', 'dance', 'booth', 'stall', 'stallDoor', 'exit', 'smokeDoor', 'neon'] as const) {
expect(seen.has(k)).toBe(true);
}
});
});
describe('tileAt / walkability', () => {
it('reports void out of bounds', () => {
expect(tileAt(VENUE, -1, 0)).toBe('void');
expect(tileAt(VENUE, 0, -1)).toBe('void');
expect(tileAt(VENUE, MAP_W, 0)).toBe('void');
expect(tileAt(VENUE, 0, MAP_H)).toBe('void');
expect(isWalkable(VENUE, -5, -5)).toBe(false);
});
it('walks floor/dance/exit/smokeDoor/stall and nothing else', () => {
const walkable = new Set<TileKind>(['floor', 'dance', 'exit', 'smokeDoor', 'stall']);
for (let ty = 0; ty < MAP_H; ty++) {
for (let tx = 0; tx < MAP_W; tx++) {
expect(isWalkable(VENUE, tx, ty)).toBe(walkable.has(tileAt(VENUE, tx, ty)));
}
}
});
it('agrees between tile and world walkability queries', () => {
for (const a of allAnchors()) expect(isWalkableWorld(VENUE, a.x, a.y)).toBe(true);
expect(isWalkableWorld(VENUE, -8, -8)).toBe(false);
});
});
describe('coordinate conversion', () => {
it('round-trips tile -> world -> tile', () => {
for (let ty = 0; ty < MAP_H; ty += 3) {
for (let tx = 0; tx < MAP_W; tx += 3) {
const w = tileToWorld(tx, ty);
expect(worldToTile(w.x, w.y)).toEqual({ tx, ty });
}
}
});
it('returns tile centres, and floors world coords', () => {
expect(tileToWorld(0, 0)).toEqual({ x: 8, y: 8 });
expect(tileToWorld(3, 5)).toEqual({ x: 56, y: 88 });
expect(worldToTile(0, 0)).toEqual({ tx: 0, ty: 0 });
expect(worldToTile(15.9, 31.9)).toEqual({ tx: 0, ty: 1 });
});
});
describe('anchors', () => {
it('meets the minimum counts per kind', () => {
const min: Record<AnchorKind, number> = {
bar: 6, dance: 8, booth: 4, toilet: 4, smoke: 1, entry: 1, exit: 1,
};
for (const k of ANCHOR_KINDS) {
expect(VENUE.anchors[k].length).toBeGreaterThanOrEqual(min[k]);
}
});
it('places every anchor on a walkable tile centre', () => {
for (const k of ANCHOR_KINDS) {
for (const a of VENUE.anchors[k]) {
expect(a.x % TILE).toBe(TILE / 2);
expect(a.y % TILE).toBe(TILE / 2);
expect(isWalkableWorld(VENUE, a.x, a.y)).toBe(true);
}
}
});
it('CRITICAL: every anchor is 4-connected to every other anchor', () => {
const anchors = allAnchors();
const first = anchors[0]!;
const reachable = flood(first);
for (const a of anchors) {
const t = worldToTile(a.x, a.y);
expect(
reachable.has(key(t.tx, t.ty)),
`anchor at tile ${t.tx},${t.ty} is cut off from ${JSON.stringify(worldToTile(first.x, first.y))}`,
).toBe(true);
}
});
it('leaves no orphaned open floor — every non-stall walkable tile is reachable', () => {
const reachable = flood(VENUE.anchors.entry[0]!);
const orphans: string[] = [];
for (let ty = 0; ty < MAP_H; ty++) {
for (let tx = 0; tx < MAP_W; tx++) {
if (!isWalkable(VENUE, tx, ty) || reachable.has(key(tx, ty))) continue;
// Cubicle interiors are sealed behind their doors by design.
if (tileAt(VENUE, tx, ty) === 'stall') continue;
orphans.push(`${tx},${ty}`);
}
}
expect(orphans, `unreachable open tiles: ${orphans.join(' ')}`).toEqual([]);
});
it('anchors of different kinds sit on distinct tiles within their kind', () => {
for (const k of ANCHOR_KINDS) {
const keys = VENUE.anchors[k].map((a) => {
const t = worldToTile(a.x, a.y);
return key(t.tx, t.ty);
});
expect(new Set(keys).size).toBe(keys.length);
}
});
});
describe('stalls', () => {
it('has four cubicles with unique ids', () => {
expect(VENUE.stalls).toHaveLength(4);
expect(new Set(VENUE.stalls.map((s) => s.id)).size).toBe(4);
for (const s of VENUE.stalls) expect(s.id).toMatch(/^s[1-4]$/);
});
it('door tiles are walkable and sit directly south of a stallDoor tile', () => {
for (const s of VENUE.stalls) {
const d = worldToTile(s.door.x, s.door.y);
expect(isWalkable(VENUE, d.tx, d.ty)).toBe(true);
expect(tileAt(VENUE, d.tx, d.ty - 1)).toBe('stallDoor');
}
});
it('inside points land on stall tiles', () => {
for (const s of VENUE.stalls) {
// The 2x2 interior centre is a tile corner; probe just inside it.
expect(isWalkableWorld(VENUE, s.inside.x - 1, s.inside.y - 1)).toBe(true);
const t = worldToTile(s.inside.x - 1, s.inside.y - 1);
expect(tileAt(VENUE, t.tx, t.ty)).toBe('stall');
}
});
it('seals each cubicle behind its door — the only way in is StallDef.inside', () => {
for (const s of VENUE.stalls) {
const d = worldToTile(s.door.x, s.door.y);
const reachable = flood(s.door);
const interior = worldToTile(s.inside.x - 1, s.inside.y - 1);
expect(reachable.has(key(interior.tx, interior.ty))).toBe(false);
// ...and the door tile still connects back to the rest of the venue.
expect(reachable.has(key(worldToTile(VENUE.anchors.entry[0]!.x, VENUE.anchors.entry[0]!.y).tx,
worldToTile(VENUE.anchors.entry[0]!.x, VENUE.anchors.entry[0]!.y).ty))).toBe(true);
expect(isWalkable(VENUE, d.tx, d.ty)).toBe(true);
}
});
it('exposes one toilet anchor per stall door', () => {
const doors = VENUE.stalls.map((s) => `${s.door.x},${s.door.y}`).sort();
const anchors = VENUE.anchors.toilet.map((a) => `${a.x},${a.y}`).sort();
expect(anchors).toEqual(doors);
});
});
describe('tileColour', () => {
it('gives every kind a distinct 24-bit colour', () => {
const kinds: readonly TileKind[] = [
'void', 'floor', 'wall', 'bar', 'stool', 'dance',
'booth', 'stall', 'stallDoor', 'exit', 'smokeDoor', 'neon',
];
const colours = kinds.map(tileColour);
for (const c of colours) {
expect(Number.isInteger(c)).toBe(true);
expect(c).toBeGreaterThanOrEqual(0);
expect(c).toBeLessThanOrEqual(0xffffff);
}
expect(new Set(colours).size).toBe(kinds.length);
});
});

279
tests/idCheck.test.ts Normal file
View File

@ -0,0 +1,279 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { SeededRNG } from '../src/core/SeededRNG';
import { idDisplayLines, idVerdict, type IdTell } from '../src/rules/idCheck';
import { generatePatron, _resetPatronSerial, type GeneratorContext } from '../src/patrons/generator';
import type { IdCard, Patron } from '../src/data/types';
const NIGHT = new Date('2026-07-18T00:00:00');
const card = (over: Partial<IdCard> = {}): IdCard => ({
name: 'Shazza Nguyen',
dob: '2000-06-01',
expiry: '2030-06-01',
photoSeed: 1,
...over,
});
const on = (iso: string): Date => new Date(`${iso}T00:00:00`);
describe('idVerdict — birthday boundary', () => {
it('18th birthday is tonight: 18, not underage, flagged', () => {
const v = idVerdict(card({ dob: '2008-07-18' }), NIGHT);
expect(v.claimedAge).toBe(18);
expect(v.underage).toBe(false);
expect(v.turnsEighteenTonight).toBe(true);
});
it('18th birthday is tomorrow: still 17, underage', () => {
const v = idVerdict(card({ dob: '2008-07-19' }), NIGHT);
expect(v.claimedAge).toBe(17);
expect(v.underage).toBe(true);
expect(v.turnsEighteenTonight).toBe(false);
});
it('18th birthday was yesterday: 18, no longer the birthday', () => {
const v = idVerdict(card({ dob: '2008-07-17' }), NIGHT);
expect(v.claimedAge).toBe(18);
expect(v.turnsEighteenTonight).toBe(false);
});
it('turnsEighteenTonight only ever fires at exactly 18', () => {
expect(idVerdict(card({ dob: '2007-07-18' }), NIGHT).turnsEighteenTonight).toBe(false);
expect(idVerdict(card({ dob: '2009-07-18' }), NIGHT).turnsEighteenTonight).toBe(false);
});
});
describe('idVerdict — leap day DOB', () => {
it('29 Feb DOB is still 17 on 28 Feb of a non-leap year', () => {
const v = idVerdict(card({ dob: '2008-02-29' }), on('2026-02-28'));
expect(v.claimedAge).toBe(17);
expect(v.underage).toBe(true);
});
it('29 Feb DOB turns 18 on 1 March of a non-leap year', () => {
const v = idVerdict(card({ dob: '2008-02-29' }), on('2026-03-01'));
expect(v.claimedAge).toBe(18);
expect(v.underage).toBe(false);
// Deliberate: leap babies get the flag on the day the age math grants it,
// rather than never getting it in a non-leap year.
expect(v.turnsEighteenTonight).toBe(true);
});
// NB: a leap baby's 18th birthday can never fall on a real 29 Feb (leap years
// are 4 apart, 18 is not a multiple of 4), so the interesting leap-night case
// is a 28 Feb DOB the day AFTER its birthday.
it('28 Feb DOB on a 29 Feb night: birthday was yesterday', () => {
const v = idVerdict(card({ dob: '2010-02-28' }), on('2028-02-29'));
expect(v.claimedAge).toBe(18);
expect(v.turnsEighteenTonight).toBe(false);
});
it('29 Feb DOB evaluated on a real 29 Feb is exactly on its birthday', () => {
const v = idVerdict(card({ dob: '2008-02-29' }), on('2028-02-29'));
expect(v.claimedAge).toBe(20);
expect(v.underage).toBe(false);
});
});
describe('idVerdict — expiry boundary', () => {
it('expiry yesterday is expired', () => {
const v = idVerdict(card({ expiry: '2026-07-17' }), NIGHT);
expect(v.expired).toBe(true);
expect(v.expiresTonight).toBe(false);
expect(v.tells).toEqual(['expired']);
});
it('expiry tonight is valid — the trap', () => {
const v = idVerdict(card({ expiry: '2026-07-18' }), NIGHT);
expect(v.expired).toBe(false);
expect(v.expiresTonight).toBe(true);
expect(v.tells).toEqual([]);
expect(v.looksFake).toBe(false);
});
it('expiry tomorrow is valid and unremarkable', () => {
const v = idVerdict(card({ expiry: '2026-07-19' }), NIGHT);
expect(v.expired).toBe(false);
expect(v.expiresTonight).toBe(false);
});
it('a nightDate carrying a time of day still compares day-to-day', () => {
const v = idVerdict(card({ expiry: '2026-07-18' }), new Date('2026-07-18T02:30:00'));
expect(v.expired).toBe(false);
expect(v.expiresTonight).toBe(true);
});
});
describe('idVerdict — tells', () => {
it('a single visible tell comes back alone', () => {
const v = idVerdict(card({ fake: { peelingLaminate: true } }), NIGHT);
expect(v.tells).toEqual(['peelingLaminate']);
expect(v.looksFake).toBe(true);
});
it('a clean card with a future expiry has no tells', () => {
const v = idVerdict(card(), NIGHT);
expect(v.tells).toEqual([]);
expect(v.looksFake).toBe(false);
});
it('no fake object but a past expiry is still catchable', () => {
const v = idVerdict(card({ expiry: '2024-01-01' }), NIGHT);
expect(v.tells).toEqual(['expired']);
expect(v.looksFake).toBe(true);
});
it('all tells come back in fixed reading order', () => {
const v = idVerdict(
card({
expiry: '2020-05-05',
fake: { photoMismatch: true, jokeName: true, wrongHologram: true, peelingLaminate: true },
}),
NIGHT,
);
expect(v.tells).toEqual([
'peelingLaminate',
'wrongHologram',
'jokeName',
'photoMismatch',
'expired',
]);
});
it('subsets keep that order regardless of how the flags were written', () => {
const v = idVerdict(card({ fake: { photoMismatch: true, wrongHologram: true } }), NIGHT);
expect(v.tells).toEqual(['wrongHologram', 'photoMismatch']);
});
it('an empty fake object alone is not a tell', () => {
const v = idVerdict(card({ fake: {} }), NIGHT);
expect(v.tells).toEqual([]);
expect(v.looksFake).toBe(false);
});
// Guards against a later "if underage, skip the tell scan" shortcut: the two
// are independent readings of the same card and must both survive.
it('a card that both claims underage and has expired reports both', () => {
const v = idVerdict(card({ dob: '2009-07-19', expiry: '2025-01-01' }), NIGHT);
expect(v.claimedAge).toBe(16);
expect(v.underage).toBe(true);
expect(v.expired).toBe(true);
expect(v.tells).toEqual(['expired']);
expect(v.looksFake).toBe(true);
});
it('TELL_ORDER covers every IdTell — no tell can be silently unreportable', () => {
const everyTell: IdTell[] = [
'peelingLaminate',
'wrongHologram',
'jokeName',
'photoMismatch',
'expired',
];
const v = idVerdict(
card({
expiry: '2020-05-05',
fake: { peelingLaminate: true, wrongHologram: true, jokeName: true, photoMismatch: true },
}),
NIGHT,
);
expect([...v.tells].sort()).toEqual([...everyTell].sort());
});
});
describe('idVerdict — calendar edges', () => {
it('DOB 31 Dec against a 1 Jan night', () => {
expect(idVerdict(card({ dob: '2008-12-31' }), on('2027-01-01')).claimedAge).toBe(18);
expect(idVerdict(card({ dob: '2009-01-01' }), on('2027-01-01')).turnsEighteenTonight).toBe(true);
});
it('DOB 31 Jan checked on 28 Feb', () => {
const v = idVerdict(card({ dob: '2008-01-31' }), on('2026-02-28'));
expect(v.claimedAge).toBe(18);
expect(v.turnsEighteenTonight).toBe(false);
});
});
describe('idDisplayLines', () => {
it('formats DD/MM/YYYY with zero padding', () => {
const lines = idDisplayLines(card({ dob: '2008-01-05', expiry: '2029-11-09' }), NIGHT);
expect(lines.dob).toBe('05/01/2008');
expect(lines.expiry).toBe('09/11/2029');
expect(lines.name).toBe('Shazza Nguyen');
expect(lines.age).toBe('18');
});
it('age line agrees with the verdict at the underage boundary', () => {
const id = card({ dob: '2008-07-19' });
expect(idDisplayLines(id, NIGHT).age).toBe('17');
expect(String(idVerdict(id, NIGHT).claimedAge)).toBe('17');
});
});
describe('integration sweep over generated patrons', () => {
const SWEEP_NIGHT = new Date('2026-07-18T00:00:00');
let crowd: Patron[];
let kids: Patron[];
beforeEach(() => {
_resetPatronSerial();
const ctx: GeneratorContext = { rng: new SeededRNG(11), nightDate: SWEEP_NIGHT };
crowd = Array.from({ length: 800 }, (_, i) => generatePatron(ctx, i % 360));
const kidCtx: GeneratorContext = { rng: new SeededRNG(11), nightDate: SWEEP_NIGHT };
kids = Array.from({ length: 60 }, () => generatePatron(kidCtx, 0, 'almost18'));
});
it('every fake in the crowd is actually catchable', () => {
const suspect = crowd.filter(
(p) => p.idCard.fake || new Date(`${p.idCard.expiry}T00:00:00`) < SWEEP_NIGHT,
);
expect(suspect.length).toBeGreaterThan(0);
for (const p of suspect) {
expect(idVerdict(p.idCard, SWEEP_NIGHT).looksFake).toBe(true);
}
});
it('clean cards in the crowd never read as fake', () => {
const clean = crowd.filter(
(p) => !p.idCard.fake && new Date(`${p.idCard.expiry}T00:00:00`) >= SWEEP_NIGHT,
);
expect(clean.length).toBeGreaterThan(0);
for (const p of clean) {
expect(idVerdict(p.idCard, SWEEP_NIGHT).looksFake).toBe(false);
}
});
it("almost18 cards claim 18+ while the kid is 17 — tells are the only catch", () => {
expect(kids.length).toBe(60);
for (const kid of kids) {
expect(kid.age).toBe(17);
const v = idVerdict(kid.idCard, SWEEP_NIGHT);
expect(v.claimedAge).toBeGreaterThanOrEqual(18);
expect(v.underage).toBe(false);
expect(v.looksFake).toBe(true);
}
});
// The override run above is a forced sample; this checks the almost18s that
// actually turn up in a naturally-weighted crowd, which is what ships.
it('almost18s occurring naturally in the crowd are also lying and catchable', () => {
const inCrowd = crowd.filter((p) => p.archetype === 'almost18');
expect(inCrowd.length).toBeGreaterThan(0);
for (const kid of inCrowd) {
expect(kid.age).toBe(17);
const v = idVerdict(kid.idCard, SWEEP_NIGHT);
expect(v.claimedAge).toBeGreaterThanOrEqual(18);
expect(v.underage).toBe(false);
expect(v.looksFake).toBe(true);
}
});
it('idVerdict never throws across the whole crowd', () => {
expect(() => {
for (const p of [...crowd, ...kids]) {
idVerdict(p.idCard, SWEEP_NIGHT);
idDisplayLines(p.idCard, SWEEP_NIGHT);
}
}).not.toThrow();
});
});

243
tests/inspect.test.ts Normal file
View File

@ -0,0 +1,243 @@
import { describe, expect, it } from 'vitest';
import { SeededRNG } from '../src/core/SeededRNG';
import { generatePatron, _resetPatronSerial, type GeneratorContext } from '../src/patrons/generator';
import { OUTFIT_VOCAB } from '../src/data/outfits';
import { drunkStage } from '../src/rules/drunk';
import type { DrunkStage, OutfitLayer, OutfitSlot, Patron } from '../src/data/types';
import {
ZONE_BANDS,
ZONE_ORDER,
describeEyes,
describeSlot,
describeStance,
describeZone,
greetingIndex,
hasWordFor,
type InspectZone,
} from '../src/scenes/door/inspect';
const BASE_OUTFIT: readonly OutfitLayer[] = [
{ slot: 'shoes', type: 'boot', colour: 'brown' },
{ slot: 'legs', type: 'jeans', colour: 'denim' },
{ slot: 'top', type: 'tee', colour: 'red' },
{ slot: 'outer', type: 'hoodie', colour: 'green' },
{ slot: 'hair', type: 'mullet', colour: 'brown' },
{ slot: 'accessory', type: 'cap', colour: 'navy' },
];
type SlotOverrides = Partial<Record<OutfitSlot, Partial<OutfitLayer>>>;
const patron = (overrides: SlotOverrides = {}, intoxication = 0): Patron => ({
id: 'p-test',
dollSeed: 12345,
archetype: 'punter',
age: 25,
idCard: { name: 'Shazza Vella', dob: '2001-03-02', expiry: '2030-01-01', photoSeed: 12345 },
outfit: BASE_OUTFIT.map((l) => ({ ...l, ...overrides[l.slot] })),
intoxication,
tolerance: 0.5,
flags: {},
});
const slots = Object.keys(OUTFIT_VOCAB) as OutfitSlot[];
const NIGHT = new Date('2026-07-18T00:00:00');
describe('describeSlot vocabulary coverage', () => {
it('has plain-English words for every type in OUTFIT_VOCAB', () => {
// Exact, not heuristic: a garment added to the vocabulary without a word
// here would otherwise reach the player as a raw id, and the tooltips are
// the only channel through which four of the dress-code rules are readable.
const missing: string[] = [];
for (const slot of Object.keys(OUTFIT_VOCAB) as OutfitSlot[]) {
for (const def of OUTFIT_VOCAB[slot]) {
if (!hasWordFor(slot, def.type)) missing.push(`${slot}.${def.type}`);
}
}
expect(missing).toEqual([]);
});
it('never leaks a camelCase type id into player-facing text', () => {
for (const slot of Object.keys(OUTFIT_VOCAB) as OutfitSlot[]) {
for (const def of OUTFIT_VOCAB[slot]) {
const line = describeSlot(patron({ [slot]: { type: def.type, colour: 'black' } }), slot);
expect(line).not.toMatch(/[a-z][A-Z]/);
expect(line.length).toBeGreaterThan(0);
}
}
});
it('prefixes a colour without producing "navy a blazer"', () => {
// The noun tables must hold BARE nouns; an article baked into one shows up
// here immediately.
for (const [slot, type] of [['outer', 'blazer'], ['top', 'singlet'], ['accessory', 'bucketHat'], ['legs', 'skirt']] as const) {
const line = describeSlot(patron({ [slot]: { type, colour: 'navy' } }), slot);
expect(line).not.toMatch(/\b(a|an) /);
expect(line.startsWith('navy ')).toBe(true);
}
});
it('never leaks a camelCase type id into player-facing text', () => {
for (const slot of slots) {
for (const def of OUTFIT_VOCAB[slot]) {
if (def.type === def.type.toLowerCase()) continue;
const colour = def.colours[0] ?? 'black';
const line = describeSlot(patron({ [slot]: { type: def.type, colour } }), slot);
expect(line).not.toContain(def.type);
}
}
});
});
describe('describeSlot', () => {
it('names the colour of clothing but not of hair', () => {
expect(describeSlot(patron({ top: { type: 'polo', colour: 'purple' } }), 'top')).toContain('purple');
expect(describeSlot(patron({ shoes: { type: 'heel', colour: 'pink' } }), 'shoes')).toContain('pink');
const hair = describeSlot(patron({ hair: { type: 'mullet', colour: 'brown' } }), 'hair');
expect(hair).not.toContain('brown');
expect(hair).toBe('a mullet');
});
it('uses absence phrasing when a slot is empty', () => {
expect(describeSlot(patron({ outer: { type: 'none' } }), 'outer')).toBe('no jacket');
expect(describeSlot(patron({ accessory: { type: 'none' } }), 'accessory')).toBe('no accessories');
});
it('calls out vintage as old, and pristine white sneakers as new', () => {
const old = describeSlot(patron({ top: { type: 'tee', colour: 'black', vintage: true } }), 'top');
expect(old).toMatch(/old/);
const fresh = describeSlot(patron({ shoes: { type: 'sneaker', colour: 'white' } }), 'shoes');
expect(fresh).toMatch(/new/);
// Only white sneakers read as new; the same shoe in another colour must not.
const black = describeSlot(patron({ shoes: { type: 'sneaker', colour: 'black' } }), 'shoes');
expect(black).not.toMatch(/new/);
});
it('mentions a logo when the layer has one', () => {
const line = describeSlot(patron({ top: { type: 'jersey', colour: 'red', logo: true } }), 'top');
expect(line).toContain('logo');
expect(describeSlot(patron({ top: { type: 'jersey', colour: 'red' } }), 'top')).not.toContain('logo');
});
});
describe('drunk tells', () => {
const byStage: Record<DrunkStage, number> = {
sober: 0.05,
tipsy: 0.3,
loose: 0.5,
messy: 0.75,
maggot: 0.95,
};
const stages = Object.keys(byStage) as DrunkStage[];
it('the chosen intoxication values really do span all five stages', () => {
for (const stage of stages) expect(drunkStage(byStage[stage])).toBe(stage);
});
it('describeEyes and describeStance give a distinct non-empty line per stage', () => {
for (const fn of [describeEyes, describeStance]) {
const lines = stages.map((s) => fn(patron({}, byStage[s])));
for (const line of lines) expect(line.length).toBeGreaterThan(0);
expect(new Set(lines).size).toBe(5);
}
});
it('eyes and stance are different observations, not the same line twice', () => {
for (const stage of stages) {
const p = patron({}, byStage[stage]);
expect(describeEyes(p)).not.toBe(describeStance(p));
}
});
});
describe('describeZone', () => {
it('surfaces every outfit slot across the five zones', () => {
const p = patron({
shoes: { type: 'thong', colour: 'orange' },
legs: { type: 'cargo', colour: 'denim' },
top: { type: 'crop', colour: 'purple' },
outer: { type: 'puffer', colour: 'cream' },
accessory: { type: 'bumbag', colour: 'yellow' },
hair: { type: 'shaved', colour: 'pink' },
});
const all = ZONE_ORDER.flatMap((z) => describeZone(p, z)).join(' | ');
for (const colour of ['orange', 'denim', 'purple', 'cream', 'yellow']) {
expect(all).toContain(colour);
}
// Hair deliberately carries no colour, so it is checked by its garment word.
expect(all).toContain('shaved head');
});
it('every zone returns at least one line', () => {
const p = patron();
for (const zone of ZONE_ORDER) {
const lines = describeZone(p, zone);
expect(lines.length).toBeGreaterThan(0);
for (const line of lines) expect(line.trim().length).toBeGreaterThan(0);
}
});
});
describe('ZONE_BANDS', () => {
it('tiles the 32x48 queue doll with no gaps or overlaps', () => {
const first = ZONE_ORDER[0];
const last = ZONE_ORDER[ZONE_ORDER.length - 1];
expect(first).toBeDefined();
expect(last).toBeDefined();
expect(ZONE_BANDS[first!].y0).toBe(0);
expect(ZONE_BANDS[last!].y1).toBe(48);
for (let i = 0; i < ZONE_ORDER.length - 1; i++) {
const here = ZONE_ORDER[i];
const next = ZONE_ORDER[i + 1];
expect(here).toBeDefined();
expect(next).toBeDefined();
expect(ZONE_BANDS[here!].y1).toBe(ZONE_BANDS[next!].y0);
expect(ZONE_BANDS[here!].y1).toBeGreaterThan(ZONE_BANDS[here!].y0);
}
});
it('has a band for every zone in ZONE_ORDER and no strays', () => {
expect(Object.keys(ZONE_BANDS).sort()).toEqual([...ZONE_ORDER].sort());
});
});
describe('greetingIndex', () => {
it('is deterministic and inside the pool', () => {
const p = patron();
for (const size of [1, 2, 3, 7, 13]) {
const i = greetingIndex(p, size);
expect(i).toBe(greetingIndex(p, size));
expect(i).toBeGreaterThanOrEqual(0);
expect(i).toBeLessThan(size);
expect(Number.isInteger(i)).toBe(true);
}
});
it('returns 0 rather than NaN for an empty pool', () => {
expect(greetingIndex(patron(), 0)).toBe(0);
});
it('spreads different patrons across the pool', () => {
_resetPatronSerial();
const ctx: GeneratorContext = { rng: new SeededRNG(5), nightDate: NIGHT };
const seen = new Set<number>();
for (let i = 0; i < 50; i++) seen.add(greetingIndex(generatePatron(ctx, 0), 5));
expect(seen.size).toBeGreaterThan(1);
});
});
describe('total over generated patrons', () => {
it('describes 200 random patrons without an empty or undefined line', () => {
_resetPatronSerial();
const ctx: GeneratorContext = { rng: new SeededRNG(3), nightDate: NIGHT };
const zones: readonly InspectZone[] = ZONE_ORDER;
for (let i = 0; i < 200; i++) {
const p = generatePatron(ctx, i);
for (const zone of zones) {
for (const line of describeZone(p, zone)) {
expect(line.trim().length).toBeGreaterThan(0);
expect(line).not.toContain('undefined');
}
}
}
});
});

448
tests/judge.test.ts Normal file
View File

@ -0,0 +1,448 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { IdCard, Patron, Verdict } from '../src/data/types';
import type { JudgeContext } from '../src/rules/doorTypes';
// dressCode and idCheck are mocked: judge's job is scoring, and pinning their
// outputs is the only way to construct the cases that matter (a flawless fake on
// a 17-year-old, four simultaneous rule breaches). Their own tests cover them.
const mocks = vi.hoisted(() => ({ violations: vi.fn(), idVerdict: vi.fn() }));
vi.mock('../src/rules/dressCode', () => ({ violations: mocks.violations }));
vi.mock('../src/rules/idCheck', () => ({ idVerdict: mocks.idVerdict }));
const { judge, JUDGE_TUNING: T } = await import('../src/rules/judge');
// The REAL rule labels, pulled past the mock. Hand-written shorts in a test are
// how a broken player-facing string ships: every shipped `short` is a caps
// prohibition ("NO VISIBLE LOGOS"), so any Dazza line built from one has to read
// correctly against that phrasing, not against a tidy lowercase invention.
const { DRESS_CODE_RULES } =
await vi.importActual<typeof import('../src/rules/dressCode')>('../src/rules/dressCode');
const REAL_SHORTS: string[] = DRESS_CODE_RULES.map((r) => r.short);
const NIGHT = new Date('2026-07-18T00:00:00');
const VERDICTS: Verdict[] = ['admit', 'deny', 'sobrietyTest', 'patDown', 'wait'];
const rule = (short: string) => ({
id: short,
text: `mate ${short}`,
short,
activeFrom: 30,
violates: () => true,
});
const idFor = (claimedAge: number, over: Partial<ReturnType<typeof cleanId>> = {}) => ({
...cleanId(claimedAge),
...over,
});
function cleanId(claimedAge: number) {
return {
claimedAge,
underage: claimedAge < 18,
expired: false,
expiresTonight: false,
turnsEighteenTonight: false,
tells: [] as string[],
looksFake: false,
};
}
const card = (over: Partial<IdCard> = {}): IdCard => ({
name: 'Shazza Nguyen',
dob: '2001-01-01',
expiry: '2029-01-01',
photoSeed: 1234,
...over,
});
const makePatron = (over: Partial<Patron> = {}): Patron => ({
id: 'p7',
dollSeed: 1234,
archetype: 'punter',
age: 25,
idCard: card(),
outfit: [
{ slot: 'shoes', type: 'boot', colour: 'black' },
{ slot: 'legs', type: 'jeans', colour: 'denim' },
{ slot: 'top', type: 'shirt', colour: 'blue' },
{ slot: 'outer', type: 'none', colour: 'black' },
{ slot: 'hair', type: 'short', colour: 'brown' },
{ slot: 'accessory', type: 'none', colour: 'black' },
],
intoxication: 0.1,
tolerance: 0.6,
flags: {},
...over,
});
const ctx = (over: Partial<JudgeContext> = {}): JudgeContext => ({
clockMin: 120,
nightDate: NIGHT,
lapRoll: 0.5,
insideCount: 40,
licensed: 200,
...over,
});
/** Pin what the (mocked) inspection surface reports for the next judge() call. */
const scene = (broken: string[], id = cleanId(25)): void => {
mocks.violations.mockReturnValue(broken.map(rule));
mocks.idVerdict.mockReturnValue(id);
};
beforeEach(() => {
vi.clearAllMocks();
scene([]);
});
describe('judge — denials', () => {
it('denying a genuine violator pays meaningful vibe for a small aggro bump', () => {
scene(['no thongs']);
const o = judge(makePatron(), 'deny', ctx());
expect(o.vibeDelta).toBe(T.denyViolatorVibe);
expect(o.vibeDelta).toBeGreaterThan(0);
expect(o.aggroDelta).toBe(T.denyViolatorAggro);
expect(o.aggroDelta).toBeGreaterThan(0);
});
it('denying a clean patron pays less vibe and costs more aggro than denying a violator', () => {
const violator = (scene(['no logos']), judge(makePatron(), 'deny', ctx()));
const clean = (scene([]), judge(makePatron(), 'deny', ctx()));
expect(clean.vibeDelta).toBeGreaterThan(0);
expect(clean.vibeDelta).toBeLessThan(violator.vibeDelta);
expect(clean.aggroDelta).toBeGreaterThan(violator.aggroDelta);
});
it('a card that reads underage justifies the denial on its own', () => {
scene([], idFor(17));
expect(judge(makePatron(), 'deny', ctx()).vibeDelta).toBe(T.denyViolatorVibe);
});
it('a fake-looking card justifies the denial on its own', () => {
scene([], idFor(19, { looksFake: true, tells: ['peelingLaminate'] }));
expect(judge(makePatron(), 'deny', ctx()).vibeDelta).toBe(T.denyViolatorVibe);
});
it('a real minor holding a flawless card is still a justified denial', () => {
scene([], cleanId(19));
expect(judge(makePatron({ age: 17 }), 'deny', ctx()).vibeDelta).toBe(T.denyViolatorVibe);
});
it("denying the owner's mate hits vibe hard, fires immediately, and gets a text", () => {
const o = judge(makePatron({ flags: { knowsOwner: true } }), 'deny', ctx());
expect(o.vibeDelta).toBe(T.denyOwnersMateVibe);
expect(o.vibeDelta).toBeLessThan(0);
expect(o.aggroDelta).toBeGreaterThan(0);
expect(o.dazzaText).toBeTruthy();
expect(o.deferred).toBeUndefined();
});
it("the owner's mate outranks anything else wrong with him", () => {
scene(['no thongs', 'no logos'], idFor(19, { looksFake: true, tells: ['jokeName'] }));
const o = judge(makePatron({ flags: { knowsOwner: true } }), 'deny', ctx());
expect(o.vibeDelta).toBe(T.denyOwnersMateVibe);
});
it("denying the owner's mate is the single worst vibe outcome in the table", () => {
const cases: Array<[string[], Patron, Partial<JudgeContext>]> = [
[[], makePatron(), {}],
[['no logos'], makePatron(), {}],
[['a', 'b', 'c', 'd', 'e'], makePatron(), {}],
[[], makePatron({ archetype: 'influencer' }), {}],
[[], makePatron({ archetype: 'regular', flags: { timesDeniedBefore: 4 } }), {}],
[['a'], makePatron({ archetype: 'regular', flags: { timesDeniedBefore: 9 } }), {}],
[[], makePatron({ age: 17 }), { insideCount: 300, licensed: 200 }],
];
const worstElsewhere = Math.min(
...cases.flatMap(([broken, p, c]) =>
(['admit', 'deny', 'sobrietyTest', 'patDown', 'wait'] as Verdict[]).flatMap((v) => {
scene(broken, idFor(19, { looksFake: broken.length > 0 }));
const o = judge(p, v, ctx(c));
return [o.vibeDelta, ...(o.deferred ?? []).map((d) => d.vibeDelta ?? 0)];
}),
),
);
scene([]);
const mate = judge(makePatron({ flags: { knowsOwner: true } }), 'deny', ctx()).vibeDelta;
expect(mate).toBeLessThan(worstElsewhere);
});
it('denying an influencer gains extra vibe AND extra aggro over the same call on a punter', () => {
const punter = judge(makePatron(), 'deny', ctx());
const influencer = judge(makePatron({ archetype: 'influencer' }), 'deny', ctx());
expect(influencer.vibeDelta).toBe(punter.vibeDelta + T.denyInfluencerVibe);
expect(influencer.aggroDelta).toBe(punter.aggroDelta + T.denyInfluencerAggro);
expect(T.denyInfluencerVibe).toBeGreaterThan(0);
expect(T.denyInfluencerAggro).toBeGreaterThan(0);
});
it('a regular with a grudge costs extra vibe and gets a note', () => {
const fresh = judge(makePatron({ archetype: 'regular' }), 'deny', ctx());
const grudged = judge(
makePatron({ archetype: 'regular', flags: { timesDeniedBefore: 2 } }),
'deny',
ctx(),
);
expect(T.denyGrudgedRegularVibe).toBeLessThan(0);
expect(grudged.vibeDelta).toBe(fresh.vibeDelta + T.denyGrudgedRegularVibe);
expect(grudged.note).toBeTruthy();
});
it('a regular denied for the first time carries no grudge penalty', () => {
const o = judge(makePatron({ archetype: 'regular', flags: { timesDeniedBefore: 0 } }), 'deny', ctx());
expect(o.vibeDelta).toBe(T.denyCleanVibe);
expect(o.note).toBeUndefined();
});
it('denying a clean fresh18 costs nothing extra but leaves a note', () => {
const o = judge(makePatron({ archetype: 'fresh18', age: 18 }), 'deny', ctx());
expect(o.vibeDelta).toBe(T.denyCleanVibe);
expect(o.aggroDelta).toBe(T.denyCleanAggro);
expect(o.note).toBeTruthy();
});
it('a fresh18 who actually broke a rule gets the violator score and no conscience note', () => {
scene(['no white sneakers']);
const o = judge(makePatron({ archetype: 'fresh18', age: 18 }), 'deny', ctx());
expect(o.vibeDelta).toBe(T.denyViolatorVibe);
expect(o.note).toBeUndefined();
});
it('the inspector scores identically to a punter — detectable meters would kill the mechanic', () => {
for (const v of VERDICTS) {
scene([]);
const punter = judge(makePatron({ archetype: 'punter' }), v, ctx());
scene([]);
const inspector = judge(makePatron({ archetype: 'inspector' }), v, ctx());
expect(inspector).toEqual(punter);
}
});
});
describe('judge — admissions', () => {
it('admitting a clean patron nudges vibe up and lets the queue breathe', () => {
const o = judge(makePatron(), 'admit', ctx());
expect(o.vibeDelta).toBe(T.admitCleanVibe);
expect(o.vibeDelta).toBeGreaterThan(0);
expect(o.aggroDelta).toBe(T.admitCleanAggro);
expect(o.aggroDelta).toBeLessThan(0);
});
it('admitting a clean 25-year-old yields no heat strike and no deferred tail', () => {
const o = judge(makePatron({ age: 25 }), 'admit', ctx());
expect(o.deferred).toBeUndefined();
// Checked in both places: `outcome.heatStrike` alone is vacuous, because
// judge never populates the top-level channel at all (see below).
expect(o.heatStrike).toBeUndefined();
expect((o.deferred ?? []).some((d) => d.heatStrike)).toBe(false);
});
it('heat strikes ride ONLY in deferred[], never top-level — one wiring, one application', () => {
// Load-bearing for the integrator: whoever wires door:verdict -> heat:strike
// must read the deferred array. If a strike ever appeared in both channels a
// naive wiring would burn two of the three licences for one mistake.
scene([], idFor(17));
const o = judge(makePatron({ age: 16 }), 'admit', ctx({ insideCount: 300, licensed: 200 }));
expect(o.heatStrike).toBeUndefined();
expect((o.deferred ?? []).filter((d) => d.heatStrike)).toHaveLength(2);
});
it('admitting a dress-code violator buys immediate queue relief and defers the pain', () => {
scene(['no thongs']);
const o = judge(makePatron(), 'admit', ctx());
expect(o.vibeDelta).toBe(0);
expect(o.aggroDelta).toBe(T.admitViolatorAggro);
expect(o.aggroDelta).toBeLessThan(0);
expect(o.deferred).toHaveLength(1);
expect(o.deferred?.[0]?.vibeDelta).toBeLessThan(0);
});
it('lapRoll 0 lands the lap at +2 minutes, lapRoll 1 at +5', () => {
scene(['no logos']);
const early = judge(makePatron(), 'admit', ctx({ clockMin: 100, lapRoll: 0 }));
scene(['no logos']);
const late = judge(makePatron(), 'admit', ctx({ clockMin: 100, lapRoll: 1 }));
expect(early.deferred?.[0]?.atClockMin).toBe(102);
expect(late.deferred?.[0]?.atClockMin).toBe(105);
});
it('the lap hit scales with rule count and stops at the cap', () => {
scene(['a']);
const one = judge(makePatron(), 'admit', ctx()).deferred?.[0]?.vibeDelta;
scene(['a', 'b']);
const two = judge(makePatron(), 'admit', ctx()).deferred?.[0]?.vibeDelta;
scene(['a', 'b', 'c', 'd', 'e', 'f']);
const many = judge(makePatron(), 'admit', ctx()).deferred?.[0]?.vibeDelta;
expect(one).toBe(T.lapVibePerRule);
expect(two).toBe(T.lapVibePerRule * 2);
expect(many).toBe(T.lapVibeCap);
});
it('the lap text names the rule that was broken', () => {
scene(['bucket hats']);
const hit = judge(makePatron(), 'admit', ctx()).deferred?.[0];
expect(hit?.dazzaText).toContain('bucket hats');
expect(hit?.reason).toContain('bucket hats');
});
it('the lap text QUOTES the sign rather than claiming to see it, for every real rule', () => {
// Regression: the line was "i can see ${short} from HERE", which against the
// real labels renders "i can see NO VISIBLE LOGOS from HERE" — an
// exoneration, not an accusation, for all eight rules. Quoting the sign is
// the invariant; narrating sight of a prohibition inverts the meaning.
expect(REAL_SHORTS.length).toBeGreaterThan(0);
for (const short of REAL_SHORTS) {
scene([short]);
const hit = judge(makePatron(), 'admit', ctx()).deferred?.[0];
expect(hit?.dazzaText).toContain(`"${short}"`);
expect(hit?.dazzaText).not.toMatch(/(?:can |could |i )see\s+NO\b/i);
}
});
it('a lap rolled near last drinks still lands inside the night', () => {
// At 2:58 AM an unclamped +2..5 lands at 360..363. Anything past 360 never
// fires, which would make the last minutes of every night consequence-free.
for (const lapRoll of [0, 0.5, 1]) {
scene(['no thongs']);
const hit = judge(makePatron(), 'admit', ctx({ clockMin: 358, lapRoll })).deferred?.[0];
expect(hit?.atClockMin).toBeLessThanOrEqual(T.auditClockMin);
expect(hit?.atClockMin).toBeGreaterThan(358);
}
});
it('a junk lapRoll still schedules a finite lap', () => {
for (const lapRoll of [NaN, Infinity, -Infinity, -3, 7]) {
scene(['no logos']);
const hit = judge(makePatron(), 'admit', ctx({ clockMin: 100, lapRoll })).deferred?.[0];
expect(Number.isFinite(hit?.atClockMin)).toBe(true);
expect(hit?.atClockMin).toBeGreaterThanOrEqual(100 + T.lapDelayMin);
expect(hit?.atClockMin).toBeLessThanOrEqual(100 + T.lapDelayMin + T.lapDelaySpread);
}
});
it('admitting an underage card feels good now and strikes at the audit', () => {
scene([], idFor(17));
const o = judge(makePatron({ age: 25 }), 'admit', ctx());
expect(o.vibeDelta).toBe(T.admitBadIdVibe);
expect(o.vibeDelta).toBeGreaterThan(0);
expect(o.deferred).toHaveLength(1);
expect(o.deferred?.[0]?.atClockMin).toBe(T.auditClockMin);
expect(o.deferred?.[0]?.heatStrike?.deferred).toBe(true);
});
it('admitting a card that looks fake strikes at the audit too', () => {
scene([], idFor(19, { looksFake: true, tells: ['wrongHologram'] }));
const o = judge(makePatron({ age: 22 }), 'admit', ctx());
expect(o.vibeDelta).toBe(T.admitBadIdVibe);
expect(o.deferred?.[0]?.heatStrike).toBeDefined();
});
it('admitting a minor on a FLAWLESS fake still strikes — and feels like a clean admit', () => {
// Card says 19, no tells, expiry years away. The player could not have seen
// it. The licence goes anyway.
scene([], cleanId(19));
const kid = makePatron({ age: 17, idCard: card({ dob: '2007-01-01', expiry: '2030-01-01' }) });
const o = judge(kid, 'admit', ctx());
expect(o.vibeDelta).toBe(T.admitCleanVibe);
expect(o.aggroDelta).toBe(T.admitCleanAggro);
expect(o.deferred).toHaveLength(1);
expect(o.deferred?.[0]?.heatStrike?.deferred).toBe(true);
expect(o.deferred?.[0]?.reason).toContain('17');
});
it('a bad card AND a real minor produce exactly one strike, not two', () => {
scene([], idFor(17, { looksFake: true, tells: ['jokeName'] }));
const o = judge(makePatron({ age: 16 }), 'admit', ctx());
const strikes = (o.deferred ?? []).filter((d) => d.heatStrike);
expect(strikes).toHaveLength(1);
expect(strikes[0]?.reason).toContain('16');
expect(strikes[0]?.reason).toContain('jokeName');
});
it('an expired but genuine licence is logged as expired, not as a forgery', () => {
// idCheck folds 'expired' into tells, so looksFake is true for a real card
// that merely lapsed. The strike is fair; calling it fake in the log is not.
scene([], idFor(40, { looksFake: true, expired: true, tells: ['expired'] }));
const o = judge(makePatron({ age: 40 }), 'admit', ctx());
const reason = o.deferred?.[0]?.reason ?? '';
expect(reason).toContain('expired');
expect(reason).not.toContain('dodgy');
expect(reason).not.toMatch(/fake|minor/i);
});
it('admitting at or over licensed capacity adds its own deferred strike', () => {
const under = judge(makePatron(), 'admit', ctx({ insideCount: 199, licensed: 200 }));
const at = judge(makePatron(), 'admit', ctx({ insideCount: 200, licensed: 200 }));
expect(under.deferred).toBeUndefined();
expect(at.deferred).toHaveLength(1);
expect(at.deferred?.[0]?.atClockMin).toBe(T.auditClockMin);
expect(at.deferred?.[0]?.heatStrike?.reason).toContain('capacity');
});
it('a bad ID admitted over capacity is two separate strikes', () => {
scene([], idFor(17));
const o = judge(makePatron({ age: 17 }), 'admit', ctx({ insideCount: 250, licensed: 200 }));
expect((o.deferred ?? []).filter((d) => d.heatStrike)).toHaveLength(2);
});
});
describe('judge — stalls and structure', () => {
it('sobriety tests and pat-downs only cost queue patience', () => {
for (const v of ['sobrietyTest', 'patDown'] as Verdict[]) {
const o = judge(makePatron(), v, ctx());
expect(o.vibeDelta).toBe(0);
expect(o.aggroDelta).toBe(T.stallAggro);
expect(o.aggroDelta).toBeGreaterThan(0);
expect(o.deferred).toBeUndefined();
}
});
it('stalling never scores the underlying patron — that waits for the real verdict', () => {
scene(['no thongs'], idFor(17, { looksFake: true }));
const o = judge(makePatron({ age: 17 }), 'sobrietyTest', ctx());
expect(o.vibeDelta).toBe(0);
expect(o.heatStrike).toBeUndefined();
expect(o.deferred).toBeUndefined();
});
it('making them wait is theatre: aggro up, hype up, vibe untouched', () => {
const o = judge(makePatron(), 'wait', ctx());
expect(o.vibeDelta).toBe(0);
expect(o.aggroDelta).toBe(T.waitAggro);
expect(o.hypeDelta).toBe(T.waitHype);
expect(T.waitHype).toBeGreaterThan(0);
});
it('every verdict returns finite numbers, never NaN or undefined', () => {
for (const v of VERDICTS) {
for (const broken of [[], ['a'], ['a', 'b', 'c']]) {
scene(broken, idFor(17, { looksFake: true, tells: ['expired'] }));
const o = judge(makePatron({ age: 17 }), v, ctx({ insideCount: 400, licensed: 200 }));
expect(Number.isFinite(o.vibeDelta)).toBe(true);
expect(Number.isFinite(o.aggroDelta)).toBe(true);
}
}
});
it('every deferred hit is attributable and lands in the future (or at the audit)', () => {
const c = ctx({ clockMin: 200, insideCount: 400, licensed: 200 });
scene(['no blazers', 'no sunnies'], idFor(17, { looksFake: true, tells: ['expired'] }));
const o = judge(makePatron({ id: 'p42', age: 17 }), 'admit', c);
expect(o.deferred?.length).toBeGreaterThan(0);
for (const d of o.deferred ?? []) {
expect(d.patronId).toBe('p42');
expect(d.reason.length).toBeGreaterThan(0);
expect(d.atClockMin > c.clockMin || d.atClockMin === T.auditClockMin).toBe(true);
}
});
it('is deterministic: identical inputs give a deeply equal outcome', () => {
const p = makePatron({ archetype: 'influencer', age: 17 });
const c = ctx({ lapRoll: 0.37, insideCount: 210, licensed: 200 });
scene(['no logos'], idFor(17));
const a = judge(p, 'admit', c);
scene(['no logos'], idFor(17));
const b = judge(p, 'admit', c);
expect(a).toEqual(b);
});
});

127
tests/nightSummary.test.ts Normal file
View File

@ -0,0 +1,127 @@
import { describe, expect, it, vi } from 'vitest';
// The functions under test are pure, but they live in modules that subclass
// Phaser.Scene, and Phaser touches `window` at import time under the node
// environment. Stubbing the module is the only way to reach them from a test.
vi.mock('phaser', () => ({ default: { Scene: class {} } }));
import { freshNightState } from '../src/core/meters';
import { DAZZA_SIGNOFF } from '../src/data/strings/door';
import { MAX_HEAT_STRIKES, shouldRecordStrike, type NightLog } from '../src/scenes/door/NightScene';
import { nightCash, signoffFor } from '../src/scenes/door/NightSummaryScene';
import type { NightState } from '../src/data/types';
const log = (over: Partial<NightLog> = {}): NightLog => ({
vibeHistory: [50],
verdicts: [],
admitted: 0,
denied: 0,
tested: 0,
pattedDown: 0,
hypePeak: 1,
heatStrikes: [],
endReason: 'clock',
seed: 1,
...over,
});
const stateWith = (...reasons: string[]): NightState => {
const s = freshNightState('voltage', 0, 80);
for (const reason of reasons) s.heatStrikes.push({ reason });
return s;
};
describe('nightCash', () => {
it('pays a flat wage for an uneventful night', () => {
expect(nightCash(log(), stateWith())).toBe(180);
});
it('pays $2 per admit on top of the wage', () => {
const clean = stateWith();
expect(nightCash(log({ admitted: 10 }), clean)).toBe(nightCash(log(), clean) + 20);
expect(nightCash(log({ admitted: 40 }), clean)).toBeGreaterThan(
nightCash(log({ admitted: 10 }), clean),
);
});
it('pays a hype bonus that scales with peak hype, not with final hype', () => {
const clean = stateWith();
const base = nightCash(log(), clean);
expect(nightCash(log({ hypePeak: 2 }), clean)).toBe(base + 90);
expect(nightCash(log({ hypePeak: 3 }), clean)).toBe(base + 180);
});
it('deducts $60 for every heat strike', () => {
const l = log({ admitted: 20, hypePeak: 2 });
const none = nightCash(l, stateWith());
expect(nightCash(l, stateWith('over capacity'))).toBe(none - 60);
expect(nightCash(l, stateWith('over capacity', 'minor admitted'))).toBe(none - 120);
});
it('floors at zero — a catastrophic night pays nothing, it does not invoice you', () => {
const strikes = Array.from({ length: 20 }, (_, i) => `offence ${i}`);
expect(nightCash(log(), stateWith(...strikes))).toBe(0);
});
it('returns a whole number even when hype is fractional', () => {
for (const hypePeak of [1, 1.3, 1.75, 2.4, 3]) {
const cash = nightCash(log({ hypePeak, admitted: 7 }), stateWith());
expect(Number.isInteger(cash)).toBe(true);
}
});
});
describe('signoffFor', () => {
it('gives the harshest line at two strikes no matter how good the room felt', () => {
expect(signoffFor(100, 2)).toBe(DAZZA_SIGNOFF[0]);
expect(signoffFor(100, 3)).toBe(DAZZA_SIGNOFF[0]);
// one strike is survivable — a great room still earns praise
expect(signoffFor(100, 1)).not.toBe(DAZZA_SIGNOFF[0]);
});
it('never moves to an earlier line as vibe rises (no strikes)', () => {
for (const strikes of [0, 1]) {
let lowest = 0;
for (let vibe = 0; vibe <= 100; vibe++) {
const idx = DAZZA_SIGNOFF.indexOf(signoffFor(vibe, strikes));
expect(idx).toBeGreaterThanOrEqual(lowest);
lowest = idx;
}
}
});
it('actually varies with vibe rather than always saying the same thing', () => {
expect(signoffFor(0, 0)).not.toBe(signoffFor(100, 0));
});
it('returns a real signoff line across the whole 0..100 band, endpoints included', () => {
for (let vibe = 0; vibe <= 100; vibe++) {
const line = signoffFor(vibe, 0);
expect(line.length).toBeGreaterThan(0);
expect(DAZZA_SIGNOFF).toContain(line);
}
});
});
describe('shouldRecordStrike', () => {
it('matches CONTRACTS.md §3: max 3 strikes', () => {
expect(MAX_HEAT_STRIKES).toBe(3);
});
it('records a strike the night has not seen before', () => {
expect(shouldRecordStrike(stateWith(), { reason: 'minor admitted' })).toBe(true);
expect(shouldRecordStrike(stateWith('over capacity'), { reason: 'minor admitted' })).toBe(true);
});
it('stops recording once the licence is already gone', () => {
const full = stateWith('a', 'b', 'c');
expect(full.heatStrikes).toHaveLength(MAX_HEAT_STRIKES);
expect(shouldRecordStrike(full, { reason: 'd' })).toBe(false);
});
it('treats a persistent breach as one offence, not one per patron', () => {
const s = stateWith('over capacity');
expect(shouldRecordStrike(s, { reason: 'over capacity' })).toBe(false);
expect(shouldRecordStrike(s, { reason: 'over capacity', deferred: true })).toBe(false);
});
});

287
tests/queueManager.test.ts Normal file
View File

@ -0,0 +1,287 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { EventBus } from '../src/core/EventBus';
import { SeededRNG } from '../src/core/SeededRNG';
import { _resetPatronSerial } from '../src/patrons/generator';
import { QueueManager, QUEUE_TUNING } from '../src/scenes/door/QueueManager';
import type { Patron } from '../src/data/types';
const NIGHT = new Date('2026-07-18T00:00:00');
type MeterDelta = { vibe?: number; aggro?: number; hype?: number };
const setup = (seed = 5) => {
const bus = new EventBus();
const q = new QueueManager(bus, new SeededRNG(seed), NIGHT);
const deltas: MeterDelta[] = [];
bus.on('meters:delta', (d) => deltas.push(d));
const total = (key: 'aggro' | 'hype'): number =>
deltas.reduce((sum, d) => sum + (d[key] ?? 0), 0);
return { bus, q, deltas, total };
};
/** Advance in-game minutes until the queue is long enough, without touching the rope. */
const fillTo = (q: QueueManager, want: number): void => {
for (let m = 1; m <= 360 && q.queueLength < want; m++) q.onClockMinute(m);
};
/** Play a whole night at one patron per minute, ruling the same way every time. */
const playNight = (q: QueueManager, denied: boolean): void => {
for (let m = 0; m <= 360; m++) {
q.onClockMinute(m);
if (q.callNext()) q.resolveUp(denied);
}
};
beforeEach(() => _resetPatronSerial());
describe('QueueManager — opening state', () => {
it('never opens on an empty street', () => {
for (const seed of [1, 2, 3, 99]) {
_resetPatronSerial();
const { q } = setup(seed);
expect(q.queueLength).toBeGreaterThanOrEqual(1);
expect(q.patronUp).toBeNull();
}
});
});
describe('QueueManager — the rope', () => {
it('callNext moves the front patron up, announces them once, and logs them as seen', () => {
const { bus, q } = setup();
const ups: Patron[] = [];
bus.on('door:patronUp', ({ patron }) => ups.push(patron));
const front = q.snapshot.waiting[0];
expect(front).toBeDefined();
const called = q.callNext();
expect(called).toBe(front);
expect(ups).toEqual([front]);
expect(q.patronUp).toBe(front);
expect(q.queueLength).toBe(0);
expect(q.seen).toEqual([front]);
});
it('callNext is a no-op while somebody is already at the rope', () => {
const { bus, q } = setup();
fillTo(q, 2);
const first = q.callNext();
const lenAfterFirst = q.queueLength;
let ups = 0;
bus.on('door:patronUp', () => ups++);
expect(q.callNext()).toBeNull();
expect(q.patronUp).toBe(first);
expect(q.queueLength).toBe(lenAfterFirst);
expect(q.seen).toHaveLength(1);
expect(ups).toBe(0);
});
it('callNext returns null with nobody left waiting', () => {
const { q } = setup();
q.callNext();
q.resolveUp(false);
expect(q.queueLength).toBe(0);
expect(q.callNext()).toBeNull();
expect(q.patronUp).toBeNull();
});
});
describe('QueueManager — impatience', () => {
it('leaving people standing there earns aggro, and the ramp makes it worse over time', () => {
const { q, deltas } = setup();
for (let i = 0; i < 6; i++) q.update(1000);
const aggro = deltas.map((d) => d.aggro ?? 0);
expect(aggro).toHaveLength(6);
const first = aggro[0];
const last = aggro[5];
expect(first).toBeDefined();
expect(last).toBeDefined();
expect(first!).toBeGreaterThan(0);
expect(last!).toBeGreaterThan(first!);
});
it('the ramp restarts once the wait is served', () => {
const { q, deltas } = setup();
fillTo(q, 2);
for (let i = 0; i < 10; i++) q.update(1000);
const rampedUp = deltas[deltas.length - 1]?.aggro;
expect(rampedUp).toBeDefined();
q.callNext();
q.resolveUp(false);
deltas.length = 0;
q.update(1000);
const fresh = deltas[0]?.aggro;
expect(fresh).toBeDefined();
expect(fresh!).toBeLessThan(rampedUp!);
});
it('hype from one wait is capped so you cannot farm a single punter', () => {
const { q, total } = setup();
for (let i = 0; i < 120; i++) q.update(1000); // two real minutes of theatre
expect(total('hype')).toBeLessThanOrEqual(QUEUE_TUNING.hypePerWaitCap + 1e-9);
expect(total('hype')).toBeGreaterThan(QUEUE_TUNING.hypePerWaitCap - 1e-6);
});
});
describe('QueueManager — the pressure valve', () => {
it('a short queue with somebody up bleeds aggro back off', () => {
const { q, deltas, total } = setup();
q.callNext();
expect(q.queueLength).toBeLessThan(QUEUE_TUNING.comfortableQueue);
deltas.length = 0;
q.update(1000);
expect(deltas).toHaveLength(1);
expect(total('aggro')).toBeLessThan(0);
});
it('relief stops entirely once the queue reaches comfortableQueue', () => {
const { q, deltas } = setup();
fillTo(q, QUEUE_TUNING.comfortableQueue + 1);
expect(q.callNext()).not.toBeNull();
expect(q.queueLength).toBeGreaterThanOrEqual(QUEUE_TUNING.comfortableQueue);
deltas.length = 0;
q.update(1000);
expect(deltas).toHaveLength(0);
});
it('relief scales with how short the queue is, not just with it being empty', () => {
const empty = setup();
empty.q.callNext();
empty.deltas.length = 0;
empty.q.update(1000);
_resetPatronSerial();
const short = setup();
fillTo(short.q, 2);
short.q.callNext();
short.deltas.length = 0;
short.q.update(1000);
expect(short.q.queueLength).toBeGreaterThan(empty.q.queueLength);
expect(short.total('aggro')).toBeLessThan(0);
expect(short.total('aggro')).toBeGreaterThan(empty.total('aggro'));
});
});
describe('QueueManager — the phone', () => {
it('pays out for an audience, then goes on cooldown', () => {
const { q, total } = setup();
const before = total('hype');
expect(q.lookAtPhone()).toBe(true);
expect(total('hype') - before).toBeCloseTo(QUEUE_TUNING.phoneHype, 6);
expect(q.lookAtPhone()).toBe(false);
expect(q.isPhoneReady).toBe(false);
q.update(QUEUE_TUNING.phoneCooldownMs);
expect(q.isPhoneReady).toBe(true);
expect(q.lookAtPhone()).toBe(true);
});
it('pays nothing with nobody watching', () => {
const { q, deltas } = setup();
q.callNext();
expect(q.queueLength).toBe(0);
deltas.length = 0;
expect(q.lookAtPhone()).toBe(false);
expect(deltas).toHaveLength(0);
expect(q.isPhoneReady).toBe(true); // a failed look must not burn the cooldown
});
});
describe('QueueManager — arrivals', () => {
it('delivers a full night of punters', () => {
const { q } = setup();
playNight(q, false);
expect(q.seen.length).toBeGreaterThan(40);
});
it('nobody new joins after the doors shut at 2:45', () => {
const { q } = setup();
for (let m = 0; m <= 344; m++) {
q.onClockMinute(m);
if (q.callNext()) q.resolveUp(false); // admitted: no comebacks to muddy the count
}
const atClose = q.queueLength;
for (let m = 345; m <= 360; m++) q.onClockMinute(m);
expect(q.queueLength).toBe(atClose);
});
it('the queue never grows past maxQueue — the rest wander off for a kebab', () => {
const { q } = setup();
for (let m = 0; m <= 360; m++) {
q.onClockMinute(m);
expect(q.queueLength).toBeLessThanOrEqual(QUEUE_TUNING.maxQueue);
}
expect(q.queueLength).toBe(QUEUE_TUNING.maxQueue);
});
});
describe('QueueManager — snapshot', () => {
it('caps the drawn dolls and accounts for the overflow exactly', () => {
const { q } = setup();
const small = q.snapshot;
expect(small.visible.length + small.offscreen).toBe(small.waiting.length);
expect(small.offscreen).toBe(0);
fillTo(q, QUEUE_TUNING.visibleSlots + 3);
const big = q.snapshot;
expect(big.waiting.length).toBeGreaterThan(QUEUE_TUNING.visibleSlots);
expect(big.visible).toHaveLength(QUEUE_TUNING.visibleSlots);
expect(big.visible.length + big.offscreen).toBe(big.waiting.length);
expect(big.up).toBeNull();
});
});
describe('QueueManager — regulars with a grudge', () => {
const idCounts = (seen: readonly Patron[]): Map<string, number> => {
const counts = new Map<string, number>();
for (const p of seen) counts.set(p.id, (counts.get(p.id) ?? 0) + 1);
return counts;
};
it('denied regulars come back, but no more than regularMaxComebacks times', () => {
const { q } = setup();
playNight(q, true);
const counts = idCounts(q.seen);
const most = Math.max(...counts.values());
expect(most).toBeGreaterThan(1); // somebody did come back — otherwise this test proves nothing
expect(most).toBeLessThanOrEqual(QUEUE_TUNING.regularMaxComebacks + 1);
});
it('admitted patrons never come back', () => {
const { q } = setup();
playNight(q, false);
expect(q.seen.some((p) => p.archetype === 'regular')).toBe(true);
const counts = idCounts(q.seen);
expect(Math.max(...counts.values())).toBe(1);
});
});
describe('QueueManager — determinism', () => {
const nightFingerprint = (seed: number): string[] => {
_resetPatronSerial();
const { q } = setup(seed);
playNight(q, true);
return q.seen.map((p) => `${p.id}:${p.dollSeed}:${p.archetype}`);
};
it('same seed, same play → identical patron sequence', () => {
const a = nightFingerprint(99);
expect(a.length).toBeGreaterThan(20);
expect(nightFingerprint(99)).toEqual(a);
});
it('different seeds → different nights', () => {
expect(nightFingerprint(1)).not.toEqual(nightFingerprint(2));
});
});

212
tests/scheduling.test.ts Normal file
View File

@ -0,0 +1,212 @@
import { describe, expect, it } from 'vitest';
import {
barOf,
beatInBar,
beatIntervalMs,
beatTimeMs,
dueBeats,
isDownbeat,
patternStep,
subdivisions,
type BeatGrid,
} from '../src/audio/scheduling';
const grid = (bpm: number, originMs = 0): BeatGrid => ({ bpm, originMs });
describe('beatIntervalMs', () => {
it('converts BPM to milliseconds per beat', () => {
expect(beatIntervalMs(120)).toBe(500);
expect(beatIntervalMs(128)).toBe(468.75);
});
});
describe('beatTimeMs', () => {
it('places beat 0 at the origin and steps by one interval', () => {
const g = grid(120);
expect(beatTimeMs(g, 0)).toBe(0);
expect(beatTimeMs(g, 1)).toBe(500);
expect(beatTimeMs(g, 8)).toBe(4000);
});
it('offsets every beat by a non-zero origin', () => {
const g = grid(128, 1234.5);
expect(beatTimeMs(g, 0)).toBe(1234.5);
for (let i = 0; i < 16; i++) {
expect(beatTimeMs(g, i)).toBeCloseTo(1234.5 + i * 468.75, 6);
}
});
it('handles negative indices (beats before the origin)', () => {
expect(beatTimeMs(grid(120, 1000), -2)).toBe(0);
});
});
describe('dueBeats', () => {
it('returns only beats inside the lookahead horizon, ascending from fromBeat', () => {
// 500ms grid, now 0, 1200ms horizon -> beats 0,1,2 (2 sounds at 1000)
const due = dueBeats(grid(120), 0, 0, 1200);
expect(due.map((b) => b.beatIndex)).toEqual([0, 1, 2]);
expect(due.map((b) => b.timeMs)).toEqual([0, 500, 1000]);
});
it('includes a beat landing exactly on the horizon', () => {
expect(dueBeats(grid(120), 0, 0, 1000).map((b) => b.beatIndex)).toEqual([0, 1, 2]);
});
it('returns nothing when the next beat is beyond the horizon', () => {
// beat 4 sounds at 2000; waking at 1600 with a 100ms lookahead is too early
expect(dueBeats(grid(120), 4, 1600, 100)).toEqual([]);
});
it('respects fromBeat rather than replaying earlier beats', () => {
const due = dueBeats(grid(120), 3, 0, 2500);
expect(due.map((b) => b.beatIndex)).toEqual([3, 4, 5]);
});
it('respects the grid origin', () => {
// origin 10s: nothing is due while audio time is still near zero
expect(dueBeats(grid(120, 10_000), 0, 0, 100)).toEqual([]);
expect(dueBeats(grid(120, 10_000), 0, 9_950, 100).map((b) => b.beatIndex)).toEqual([0]);
});
it('yields every beat exactly once, in order, across ragged wakeups', () => {
const g = grid(128, 40);
const lookahead = 100;
// Irregular timer wakeups: some shorter than the lookahead, some longer than a beat.
const wakes = [25, 31, 18, 40, 25, 7, 63, 25, 12, 90, 25, 33];
const seen: number[] = [];
let cursor = 0;
let now = 0;
// Bounded by wake count, not by beats collected: a regression that stops
// returning beats must go red, not spin forever and hang `npm test`.
for (let w = 0; w < 2000; w++) {
now += wakes[w % wakes.length] ?? 25;
const due = dueBeats(g, cursor, now, lookahead);
for (const b of due) {
expect(b.timeMs).toBeCloseTo(beatTimeMs(g, b.beatIndex), 6);
expect(b.timeMs).toBeLessThanOrEqual(now + lookahead);
seen.push(b.beatIndex);
}
const last = due.at(-1);
if (last) cursor = last.beatIndex + 1;
}
// Every beat whose time fell inside the final horizon should have sounded.
const expected = Math.floor((now + lookahead - 40) / beatIntervalMs(128)) + 1;
expect(expected).toBeGreaterThan(100); // sanity: the walk really is long
expect(seen.length).toBe(expected);
// No gaps, no duplicates, strictly ascending from 0.
expect(seen).toEqual(seen.map((_, i) => i));
});
it('never schedules a beat more than a lookahead ahead of the wake time', () => {
const g = grid(120);
let cursor = 0;
let scheduled = 0;
let lastNow = 0;
for (let now = 0; now < 20_000; now += 25) {
const due = dueBeats(g, cursor, now, 120);
for (const b of due) expect(b.timeMs).toBeLessThanOrEqual(now + 120);
scheduled += due.length;
const last = due.at(-1);
if (last) cursor = last.beatIndex + 1;
lastNow = now;
}
// Scheduling nothing would satisfy the bound above vacuously; silence is the
// failure mode this test exists to catch.
expect(scheduled).toBe(Math.floor((lastNow + 120) / beatIntervalMs(120)) + 1);
});
it('caps a suspended-tab backlog at MAX_BURST and drains it without loss', () => {
const g = grid(128);
const stalledNow = 5 * 60_000; // tab was hidden for five minutes
const lookahead = 100;
const expectedTotal = Math.floor((stalledNow + lookahead) / beatIntervalMs(128)) + 1;
expect(expectedTotal).toBeGreaterThan(600); // sanity: the burst really is huge
const first = dueBeats(g, 0, stalledNow, lookahead);
expect(first.length).toBe(64);
expect(first.map((b) => b.beatIndex)).toEqual(first.map((_, i) => i));
// Subsequent wakes at the same audio time drain the rest, 64 at a time.
const seen = first.map((b) => b.beatIndex);
let cursor = 64;
for (let guard = 0; guard < 100; guard++) {
const due = dueBeats(g, cursor, stalledNow, lookahead);
if (due.length === 0) break;
expect(due.length).toBeLessThanOrEqual(64);
for (const b of due) seen.push(b.beatIndex);
cursor = due[due.length - 1]!.beatIndex + 1;
}
expect(seen).toEqual(seen.map((_, i) => i));
expect(seen.length).toBe(expectedTotal);
});
});
describe('subdivisions', () => {
it('returns n evenly spaced times starting on the beat', () => {
const g = grid(120, 250);
const subs = subdivisions(g, 2, 4); // beat 2 sounds at 1250
expect(subs.length).toBe(4);
expect(subs[0]).toBe(beatTimeMs(g, 2));
expect(subs).toEqual([1250, 1375, 1500, 1625]);
});
it('lands the next beat exactly one step past the last subdivision', () => {
const g = grid(128, 12);
const subs = subdivisions(g, 7, 16);
const step = beatIntervalMs(128) / 16;
expect(subs.length).toBe(16);
expect(subs[15]! + step).toBeCloseTo(beatTimeMs(g, 8), 6);
});
});
describe('bar position (4/4)', () => {
it('maps beat indices to bar and position', () => {
expect(barOf(0)).toBe(0);
expect(beatInBar(0)).toBe(0);
expect(isDownbeat(0)).toBe(true);
for (let i = 0; i < 32; i++) {
expect(barOf(i)).toBe(Math.floor(i / 4));
expect(beatInBar(i)).toBe(i % 4);
expect(isDownbeat(i)).toBe(i % 4 === 0);
}
});
it('keeps beatInBar non-negative before the origin', () => {
expect(beatInBar(-1)).toBe(3);
expect(beatInBar(-4)).toBe(0);
expect(isDownbeat(-4)).toBe(true);
// barOf must floor, not truncate, or beat -1 lands in bar 0 alongside beat 3.
expect(barOf(-1)).toBe(-1);
expect(barOf(-4)).toBe(-1);
expect(barOf(-5)).toBe(-2);
});
});
describe('patternStep', () => {
it('walks 0..31 across two bars then wraps', () => {
const walk: number[] = [];
for (let beat = 0; beat < 8; beat++) {
for (let s = 0; s < 4; s++) walk.push(patternStep(beat, s));
}
expect(walk.length).toBe(32);
expect(walk).toEqual(walk.map((_, i) => i));
expect(patternStep(8, 0)).toBe(0);
});
it('repeats with period 32 rather than drifting', () => {
for (let beat = 0; beat < 64; beat++) {
for (let s = 0; s < 4; s++) {
expect(patternStep(beat, s)).toBe(patternStep(beat + 8, s));
expect(patternStep(beat, s)).toBe((beat * 4 + s) % 32);
}
}
});
it('honours a custom step count and negative indices', () => {
expect(patternStep(4, 0, 16)).toBe(0);
expect(patternStep(3, 2, 16)).toBe(14);
expect(patternStep(-1, 0)).toBe(28);
});
});

254
tests/sobriety.test.ts Normal file
View File

@ -0,0 +1,254 @@
import { describe, expect, it } from 'vitest';
import { SeededRNG, type RngStream } from '../src/core/SeededRNG';
import {
challengesFor,
perform,
PASS_THRESHOLD,
RIDDLES,
type Challenge,
type ChallengeKind,
} from '../src/rules/sobriety';
import type { Patron } from '../src/data/types';
const stream = (seed: number): RngStream => new SeededRNG(seed).stream('sobriety');
const patron = (intoxication: number, tolerance = 0.5): Patron => ({
id: 'p1',
dollSeed: 1,
archetype: 'punter',
age: 24,
idCard: { name: 'Shazza Vella', dob: '2002-03-04', expiry: '2029-03-04', photoSeed: 1 },
outfit: [],
intoxication,
tolerance,
flags: {},
});
const challenge = (kind: ChallengeKind, seed = 1): Challenge => {
const found = challengesFor(stream(seed)).find((c) => c.kind === kind);
if (!found) throw new Error(`no ${kind} challenge`);
return found;
};
const ALPHABET = challenge('alphabet');
const RIDDLE = challenge('riddle');
/** Mean quality over n independent performances of the given patron shape. */
const meanQuality = (make: (rng: RngStream) => Patron, seed: number, n: number): number => {
const rng = stream(seed);
let total = 0;
for (let i = 0; i < n; i++) total += perform(make(rng), ALPHABET, rng).quality;
return total / n;
};
const passRate = (make: (rng: RngStream) => Patron, seed: number, n: number): number => {
const rng = stream(seed);
let passes = 0;
for (let i = 0; i < n; i++) if (perform(make(rng), RIDDLE, rng).objectivelySober) passes++;
return passes / n;
};
const inBand = (lo: number, hi: number) => (rng: RngStream) => patron(lo + rng.next() * (hi - lo));
/** n answers for one patron shape, so the degradation machinery can be inspected. */
const answersFor = (
intoxication: number,
tolerance: number,
c: Challenge,
seed: number,
n: number,
): string[] => {
const rng = stream(seed);
const out: string[] = [];
for (let i = 0; i < n; i++) out.push(perform(patron(intoxication, tolerance), c, rng).answer);
return out;
};
/** Answers that recur verbatim are canned lines; typo'd ones essentially never repeat. */
const cannedShare = (answers: string[], key: (a: string) => string, minRepeats = 5): number => {
const counts = new Map<string, number>();
for (const a of answers) counts.set(key(a), (counts.get(key(a)) ?? 0) + 1);
let canned = 0;
for (const c of counts.values()) if (c >= minRepeats) canned += c;
return canned / answers.length;
};
describe('perform', () => {
it('is deterministic: same seed + same patron → identical Performance', () => {
const a = perform(patron(0.55), RIDDLE, stream(99));
const b = perform(patron(0.55), RIDDLE, stream(99));
expect(a).toEqual(b);
});
it('average quality falls monotonically across drunk stages', () => {
const sober = meanQuality(inBand(0, 0.2), 3, 200);
const tipsy = meanQuality(inBand(0.2, 0.45), 3, 200);
const messy = meanQuality(inBand(0.65, 0.85), 3, 200);
const maggot = meanQuality(inBand(0.85, 1), 3, 200);
expect(sober).toBeGreaterThan(tipsy);
expect(tipsy).toBeGreaterThan(messy);
expect(messy).toBeGreaterThan(maggot);
});
it('a sober patron passes objectively in >95% of rolls', () => {
expect(passRate(() => patron(0, 0.5), 5, 200)).toBeGreaterThan(0.95);
});
it('a maggot passes objectively in <5% of rolls', () => {
expect(passRate(() => patron(0.95, 0.5), 7, 200)).toBeLessThan(0.05);
});
it('the loose/messy band stays genuinely ambiguous', () => {
const rate = passRate(inBand(0.45, 0.85), 11, 300);
expect(rate).toBeGreaterThan(0.15);
expect(rate).toBeLessThan(0.85);
});
it('quality stays in 0..1 and the answer is never empty', () => {
const rng = stream(13);
for (let i = 0; i < 500; i++) {
const p = patron(rng.next(), rng.next());
const perf = perform(p, i % 2 === 0 ? ALPHABET : RIDDLE, rng);
expect(perf.quality).toBeGreaterThanOrEqual(0);
expect(perf.quality).toBeLessThanOrEqual(1);
expect(perf.answer.length).toBeGreaterThan(0);
expect(perf.objectivelySober).toBe(perf.quality >= PASS_THRESHOLD);
}
});
it('a flawless roll reproduces the ideal answer exactly', () => {
const rng = stream(17);
let checked = 0;
for (let i = 0; i < 400; i++) {
const c = i % 2 === 0 ? ALPHABET : RIDDLE;
const perf = perform(patron(0, 1), c, rng);
if (perf.quality < 0.99) continue;
expect(perf.answer).toBe(c.ideal);
checked++;
}
expect(checked).toBeGreaterThan(0);
});
it('a messy patron almost never gets the recital out clean', () => {
const rng = stream(19);
let clean = 0;
for (let i = 0; i < 200; i++) {
if (perform(patron(0.75, 0.4), ALPHABET, rng).answer === ALPHABET.ideal) clean++;
}
expect(clean / 200).toBeLessThan(0.05);
});
it('tolerance helps at equal intoxication', () => {
const hard = meanQuality(() => patron(0.7, 0.9), 23, 200);
const soft = meanQuality(() => patron(0.7, 0.1), 23, 200);
expect(hard).toBeGreaterThan(soft);
});
it('PASS_THRESHOLD is 0.6 and the comparison is inclusive', () => {
expect(PASS_THRESHOLD).toBe(0.6);
// A stub stream that always sits dead centre => zero jitter, so quality is
// exactly 1 - intoxication. 0.4 lands us precisely on the threshold.
const flat: RngStream = {
next: () => 0.5,
int: (min) => min,
pick: <T,>(arr: readonly T[]): T => arr[0] as T,
chance: () => false,
weighted: <T,>(e: ReadonlyArray<readonly [T, number]>): T => e[0]![0],
};
const perf = perform(patron(0.4, 0), RIDDLE, flat);
expect(perf.quality).toBe(PASS_THRESHOLD);
expect(perf.objectivelySober).toBe(true);
});
it('a maggot never reads as objectively sober, at any tolerance', () => {
// The honesty oracle must not be rescuable by seasoning. Sweep the whole
// maggot band against the whole tolerance range, including the corners.
const rng = stream(29);
let worst = 0;
let worstAt = '';
let sober = 0;
for (let i = 0; i <= 30; i++) {
const intox = 0.85 + (i / 30) * 0.15;
for (let t = 0; t <= 20; t++) {
const tol = t / 20;
for (let k = 0; k < 40; k++) {
const perf = perform(patron(intox, tol), RIDDLE, rng);
if (perf.objectivelySober) sober++;
if (perf.quality > worst) {
worst = perf.quality;
worstAt = `intoxication ${intox.toFixed(3)}, tolerance ${tol.toFixed(2)}`;
}
}
}
}
expect({ sober, worstAt }).toEqual({ sober: 0, worstAt });
expect(worst).toBeLessThan(PASS_THRESHOLD);
});
it('the alphabet recital loses its reach as quality falls, then gives up', () => {
const letters = (a: string): number => (a.match(/[A-Z]/g) ?? []).length;
const meanLetters = (intox: number, seed: number): number => {
const as = answersFor(intox, 0.5, ALPHABET, seed, 300);
return as.reduce((s, a) => s + letters(a), 0) / as.length;
};
const nearSober = meanLetters(0.1, 41);
const loose = meanLetters(0.55, 43);
const maggot = meanLetters(0.95, 47);
expect(nearSober).toBeGreaterThan(24);
expect(loose).toBeLessThan(nearSober - 3);
expect(maggot).toBeLessThan(loose - 3);
expect(maggot).toBeLessThan(14); // gave up well before Z..A ran out
// The bail is a canned line, so the tails repeat verbatim; a merely
// mangled recital would produce ~300 distinct endings.
const tails = answersFor(0.95, 0.5, ALPHABET, 53, 300);
expect(cannedShare(tails, (a) => a.slice(-14))).toBeGreaterThan(0.5);
});
it('riddle answers go from typo to outright non-answer as quality falls', () => {
const ideal = RIDDLE.ideal;
// Mid-band: recognisably an attempt at the answer, but mangled.
const mid = answersFor(0.55, 0.5, RIDDLE, 59, 400);
const mangled = mid.filter((a) => a !== ideal && a.length >= ideal.length - 2);
expect(mangled.length / mid.length).toBeGreaterThan(0.25);
// Bottom: getting it out verbatim is a rare fluke (~0.3%, and that fluke is
// deliberate — the minigame is not a reliable oracle), and a real share bail
// to canned non-answers.
const bottom = answersFor(0.99, 0.5, RIDDLE, 61, 2000);
expect(bottom.filter((a) => a === ideal).length / bottom.length).toBeLessThan(0.02);
expect(cannedShare(bottom, (a) => a, 20)).toBeGreaterThan(0.2);
// ...drawn from a pool, not one stock line.
const counts = new Map<string, number>();
for (const a of bottom) counts.set(a, (counts.get(a) ?? 0) + 1);
const pool = [...counts.values()].filter((c) => c >= 20).length;
expect(pool).toBeGreaterThanOrEqual(3);
});
});
describe('challenges', () => {
it('challengesFor returns exactly one alphabet and one riddle from RIDDLES', () => {
for (let seed = 0; seed < 25; seed++) {
const cs = challengesFor(stream(seed));
expect(cs.map((c) => c.kind).sort()).toEqual(['alphabet', 'riddle']);
const riddle = cs.find((c) => c.kind === 'riddle');
expect(RIDDLES.some((r) => r.prompt === riddle?.prompt && r.ideal === riddle?.ideal)).toBe(true);
}
});
it('the alphabet ideal is exactly the reversed alphabet', () => {
const reversed = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').reverse().join('');
expect(ALPHABET.ideal).toBe(reversed);
});
it('RIDDLES has 8 usable, uniquely-prompted entries', () => {
expect(RIDDLES).toHaveLength(8);
for (const r of RIDDLES) {
expect(r.prompt.trim().length).toBeGreaterThan(0);
expect(r.ideal.trim().length).toBeGreaterThan(0);
}
expect(new Set(RIDDLES.map((r) => r.prompt)).size).toBe(RIDDLES.length);
});
});

211
tests/typo.test.ts Normal file
View File

@ -0,0 +1,211 @@
import { describe, expect, it } from 'vitest';
import { SeededRNG } from '../src/core/SeededRNG';
import { drunkify, typedLength, wobbleAt, type RngLike } from '../src/ui/typo';
/** Fresh stream every call — drunkify consumes draws, so tests must not share state. */
function stream(seed: number, name = 'typo'): RngLike {
return new SeededRNG(seed).stream(name);
}
const LINE =
"Yeah nah mate the line's round the corner, hop in and grab a schooner before last drinks at half two.";
// Long enough that two different seeds colliding on an identical mangle is not plausible.
const CORPUS = Array.from({ length: 6 }, () => LINE).join(' ');
function levenshtein(a: string, b: string): number {
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
for (let i = 1; i <= a.length; i++) {
const row = [i];
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
row.push(Math.min(row[j - 1]! + 1, prev[j]! + 1, prev[j - 1]! + cost));
}
prev = row;
}
return prev[b.length]!;
}
const nonLetters = (s: string): string => s.replace(/[a-zA-Z]/g, '');
/**
* Matches exactly those strings that keep `input`'s non-letter skeleton verbatim
* while every letter run has become zero or more *letters*. Comparing against this
* rather than stripping non-letters first, which makes the assertion vacuous
* is what catches a letter position emitting something that is not a letter.
*/
function letterShape(input: string): RegExp {
const MARK = '\u0000'; // cannot occur in dialogue text, so it is a safe placeholder
const src = input
.replace(/[a-zA-Z]+/g, MARK)
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
.split(MARK)
.join('[a-zA-Z]*');
return new RegExp(`^${src}$`);
}
describe('drunkify', () => {
it('is byte-identical for the same text, drunkenness and seed', () => {
const a = drunkify(CORPUS, { drunkenness: 0.8, rng: stream(1234) });
const b = drunkify(CORPUS, { drunkenness: 0.8, rng: stream(1234) });
expect(a).toBe(b);
expect(a).not.toBe(CORPUS); // sanity: 0.8 actually mangles something
});
it('diverges between seeds', () => {
const a = drunkify(CORPUS, { drunkenness: 0.8, rng: stream(1) });
const b = drunkify(CORPUS, { drunkenness: 0.8, rng: stream(2) });
expect(a).not.toBe(b);
});
it('is stream-scoped, not seed-scoped', () => {
const a = drunkify(CORPUS, { drunkenness: 0.8, rng: stream(7, 'patron:dialogue') });
const b = drunkify(CORPUS, { drunkenness: 0.8, rng: stream(7, 'patron:heckle') });
expect(a).not.toBe(b);
});
it('leaves text untouched at drunkenness 0, without consuming draws', () => {
const rng = stream(99);
expect(drunkify(LINE, { drunkenness: 0, rng })).toBe(LINE);
expect(rng.next()).toBe(stream(99).next());
});
it('leaves text untouched at or below the 0.25 sober floor', () => {
for (const d of [0.05, 0.1, 0.2, 0.249, 0.25]) {
expect(drunkify(CORPUS, { drunkenness: d, rng: stream(5) })).toBe(CORPUS);
}
// Just above the floor the odds per letter are tiny, so check across seeds
// that *something* slips — the threshold is a ramp, not a hard cut.
const seeds = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const mangled = seeds.filter((s) => drunkify(CORPUS, { drunkenness: 0.3, rng: stream(s) }) !== CORPUS);
expect(mangled.length).toBeGreaterThan(0);
});
it('clamps drunkenness into 0..1', () => {
expect(drunkify(CORPUS, { drunkenness: 4, rng: stream(42) })).toBe(
drunkify(CORPUS, { drunkenness: 1, rng: stream(42) }),
);
expect(drunkify(CORPUS, { drunkenness: -3, rng: stream(42) })).toBe(CORPUS);
});
it('treats a non-finite drunkenness as sober, without consuming draws', () => {
for (const d of [NaN, Infinity, -Infinity]) {
const rng = stream(77);
expect(drunkify(CORPUS, { drunkenness: d, rng })).toBe(CORPUS);
expect(rng.next()).toBe(stream(77).next());
}
});
it('never substitutes or drops non-letters, even at maximum', () => {
const punctuated = 'Oi! 3 IDs, 2 mates — $15 cover (cash only)?? "no wuckas"...\tline 4\n';
for (const seed of [11, 12, 13, 14, 15]) {
const out = drunkify(punctuated, { drunkenness: 1, rng: stream(seed) });
expect(nonLetters(out)).toBe(nonLetters(punctuated));
}
});
it('only ever emits letters where letters were', () => {
const punctuated = 'Oi! 3 IDs, 2 mates — $15 cover (cash only)?? "no wuckas"...\tline 4\n';
for (const source of [CORPUS, punctuated]) {
const shape = letterShape(source);
for (const seed of [808, 809, 810]) {
expect(drunkify(source, { drunkenness: 1, rng: stream(seed) })).toMatch(shape);
}
}
});
it('mangles substantially more the drunker the speaker is', () => {
// Statistical, so average over several seeds and assert a loose ratio.
let tipsy = 0;
let maggot = 0;
for (const seed of [1, 2, 3, 4, 5, 6, 7, 8]) {
tipsy += levenshtein(CORPUS, drunkify(CORPUS, { drunkenness: 0.4, rng: stream(seed) }));
maggot += levenshtein(CORPUS, drunkify(CORPUS, { drunkenness: 1, rng: stream(seed) }));
}
expect(tipsy).toBeGreaterThan(0);
expect(maggot).toBeGreaterThan(tipsy * 2);
});
});
describe('typedLength', () => {
it('reveals nothing at zero elapsed', () => {
expect(typedLength(LINE, 0, 28)).toBe(0);
expect(typedLength(LINE, 0, 28, 1)).toBe(0);
});
it('reaches but never exceeds the full length', () => {
expect(typedLength(LINE, LINE.length * 28, 28)).toBe(LINE.length);
expect(typedLength(LINE, 10_000_000, 28, 1)).toBe(LINE.length);
expect(typedLength('', 10_000, 28)).toBe(0);
});
it('is monotonic in elapsed time', () => {
let prev = -1;
for (let ms = 0; ms <= 4000; ms += 17) {
const n = typedLength(LINE, ms, 28, 0.7);
expect(n).toBeGreaterThanOrEqual(prev);
prev = n;
}
});
it('makes drunk speakers type slower', () => {
const sober = typedLength(LINE, 900, 28, 0);
const drunk = typedLength(LINE, 900, 28, 1);
expect(drunk).toBeLessThan(sober);
expect(typedLength(LINE, 900, 28, 0.5)).toBeLessThan(sober);
});
it('clamps drunkenness like drunkify does', () => {
expect(typedLength(LINE, 900, 28, 5)).toBe(typedLength(LINE, 900, 28, 1));
expect(typedLength(LINE, 900, 28, -5)).toBe(typedLength(LINE, 900, 28, 0));
});
it('short-circuits to full length when charMs is non-positive', () => {
expect(typedLength(LINE, 0, 0)).toBe(LINE.length);
expect(typedLength(LINE, 0, -5, 1)).toBe(LINE.length);
});
// DialogueBox accumulates `elapsed += delta`, so a NaN that escaped here would
// stall the reveal for the rest of the night rather than for one frame.
it('treats a non-finite elapsed or drunkenness as zero', () => {
expect(typedLength(LINE, NaN, 28)).toBe(0);
expect(typedLength(LINE, NaN, 28, 1)).toBe(0);
expect(typedLength(LINE, 900, 28, NaN)).toBe(typedLength(LINE, 900, 28, 0));
});
});
describe('wobbleAt', () => {
it('is flat when sober', () => {
for (let i = 0; i < 20; i++) expect(wobbleAt(i, 0, i * 0.3)).toBe(0);
expect(wobbleAt(3, -1, 1.2)).toBe(0);
});
it('stays within the 1.6px amplitude at maximum', () => {
for (let i = 0; i < 200; i++) {
for (const phase of [0, 0.7, 2.5, 6.1]) {
expect(Math.abs(wobbleAt(i, 1, phase))).toBeLessThanOrEqual(1.6);
expect(Math.abs(wobbleAt(i, 4, phase))).toBeLessThanOrEqual(1.6); // clamped
}
}
});
it('scales with drunkenness', () => {
// Index/phase chosen so sin() is well away from a zero crossing.
const at = (d: number): number => Math.abs(wobbleAt(0, d, Math.PI / 2));
expect(at(0.5)).toBeGreaterThan(at(0.25));
expect(at(1)).toBeGreaterThan(at(0.5));
});
// Same reasoning as typedLength: phase is accumulated, and a NaN reaching
// text.y makes Phaser drop the glyph silently instead of erroring.
it('treats a non-finite phase or drunkenness as zero', () => {
expect(wobbleAt(0, 1, NaN)).toBe(0);
expect(wobbleAt(5, 1, Infinity)).toBe(0);
expect(wobbleAt(5, NaN, 1.2)).toBe(0);
});
it('is deterministic for the same index, drunkenness and phase', () => {
expect(wobbleAt(7, 0.6, 1.25)).toBe(wobbleAt(7, 0.6, 1.25));
expect(wobbleAt(8, 0.6, 1.25)).not.toBe(wobbleAt(7, 0.6, 1.25));
});
});

View File

@ -3,7 +3,7 @@ import { defineConfig } from 'vite';
export default defineConfig({
base: './',
server: { port: 5199 },
server: { port: Number(process.env.PORT) || 5199 },
test: {
include: ['tests/**/*.test.ts'],
environment: 'node',