Compare commits
9 Commits
9805ff8fe5
...
595371f684
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
595371f684 | ||
|
|
ca8fd75aac | ||
|
|
7aff70c1c0 | ||
|
|
e0b5831166 | ||
|
|
f5e1151c6e | ||
|
|
2eb3c94ccd | ||
|
|
30162d331d | ||
|
|
3eee8432d4 | ||
|
|
c262ac8261 |
@ -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
2
.gitignore
vendored
@ -1,2 +1,2 @@
|
||||
node_modules/
|
||||
node_modules
|
||||
dist/
|
||||
|
||||
444
LANEHANDOVER.md
444
LANEHANDOVER.md
@ -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 749–1115 raw vibe into 50 points of headroom (≈94% discarded by the clamp),
|
||||
vibe pins at 100 by ~10:00 PM and stays there 81–86% 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:30–12:30 slam" is currently fiction
|
||||
(measured avg queue 0.1–0.2, max 3–5 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, 75–98ms 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.
|
||||
|
||||
@ -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
455
src/audio/Sfx.ts
Normal 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
459
src/audio/TechnoEngine.ts
Normal 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
66
src/audio/filterCurve.ts
Normal 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
69
src/audio/scheduling.ts
Normal 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;
|
||||
@ -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,
|
||||
|
||||
@ -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
136
src/data/strings/door.ts
Normal 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
91
src/data/strings/floor.ts
Normal 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';
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
44
src/main.ts
44
src/main.ts
@ -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
47
src/rules/doorTypes.ts
Normal file
@ -0,0 +1,47 @@
|
||||
// LANE-DOOR type extensions. CCR-1 (PatronFlags.handHolding) and CCR-2
|
||||
// (VerdictOutcome.hypeDelta) were folded into data/types.ts at the Phase-1
|
||||
// review — this file now only holds door-domain types.
|
||||
|
||||
import type { HeatStrike, Patron, VerdictOutcome } from '../data/types';
|
||||
|
||||
/**
|
||||
* A consequence that lands LATER: Dazza does a lap and spots the bloke in
|
||||
* thongs you waved through, the inspector audits at 3 AM. Deferred hits are the
|
||||
* reason admitting is scary — the cost never arrives while you can still argue.
|
||||
*/
|
||||
export interface DeferredHit {
|
||||
/** clock-minute at which this fires (absolute, not relative). */
|
||||
atClockMin: number;
|
||||
vibeDelta?: number;
|
||||
aggroDelta?: number;
|
||||
dazzaText?: string;
|
||||
heatStrike?: HeatStrike;
|
||||
/** why, for the incident log + night summary */
|
||||
reason: string;
|
||||
patronId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structurally a VerdictOutcome (so it drops straight into the `door:verdict`
|
||||
* event payload), plus the deferred tail that the night shell schedules.
|
||||
*/
|
||||
export interface DoorOutcome extends VerdictOutcome {
|
||||
deferred?: DeferredHit[];
|
||||
/** short line shown on the verdict toast — player-facing feedback. */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** Everything judge() needs to score a call. Pure data — no Phaser, no scene. */
|
||||
export interface JudgeContext {
|
||||
clockMin: number;
|
||||
nightDate: Date;
|
||||
/** 0..1 roll used for the "Dazza does a lap" delay; caller passes rng.next(). */
|
||||
lapRoll: number;
|
||||
insideCount: number;
|
||||
licensed: number;
|
||||
}
|
||||
|
||||
/** Result of a sobriety test the player has already ruled on. */
|
||||
export type SobrietyRuling = 'pass' | 'fail';
|
||||
|
||||
export type { Patron };
|
||||
171
src/rules/dressCode.ts
Normal file
171
src/rules/dressCode.ts
Normal 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
104
src/rules/idCheck.ts
Normal 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
214
src/rules/judge.ts
Normal 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
191
src/rules/sobriety.ts
Normal file
@ -0,0 +1,191 @@
|
||||
// The sobriety-test minigame: content + performance model.
|
||||
//
|
||||
// The player picks a challenge, the patron performs, and then the player rules
|
||||
// PASS or FAIL regardless of what they just heard (design §3.1). So this module
|
||||
// never decides anything — it only produces a performance ambiguous enough that
|
||||
// ruling on it feels like a choice. `objectivelySober` exists so the night can
|
||||
// score the player's honesty later, not so it can score the patron.
|
||||
|
||||
import type { DrunkStage, Patron } from '../data/types';
|
||||
import type { RngStream } from '../core/SeededRNG';
|
||||
import { drunkStage } from './drunk';
|
||||
|
||||
export type ChallengeKind = 'alphabet' | 'riddle';
|
||||
|
||||
export interface Challenge {
|
||||
kind: ChallengeKind;
|
||||
prompt: string;
|
||||
ideal: string;
|
||||
}
|
||||
|
||||
export interface Performance {
|
||||
challenge: Challenge;
|
||||
answer: string;
|
||||
quality: number;
|
||||
objectivelySober: boolean;
|
||||
}
|
||||
|
||||
export const PASS_THRESHOLD = 0.6;
|
||||
|
||||
const ALPHABET_BACKWARDS = 'ZYXWVUTSRQPONMLKJIHGFEDCBA';
|
||||
|
||||
const ALPHABET_PROMPT = 'alphabet. backwards. any time you like.';
|
||||
|
||||
/** Asked BY the player, AT 1 AM, with a torch. Unanswerable is the point. */
|
||||
export const RIDDLES: readonly { prompt: string; ideal: string }[] = [
|
||||
{ prompt: 'kebab shop shuts at four, we shut at three. where are ya at half three?', ideal: 'in the kebab queue' },
|
||||
{ prompt: 'name three things that are not a schooner.', ideal: 'a pot, a midi, a jug' },
|
||||
{ prompt: 'whats heavier, a kilo of ice or a kilo of the ice machine?', ideal: 'same weight, different problem' },
|
||||
{ prompt: 'ya mate says hes five minutes away. how long is that in real minutes?', ideal: 'forty, maybe fifty' },
|
||||
{ prompt: 'spell sober backwards and tell me what it means.', ideal: 'rebos, and it means nothing' },
|
||||
{ prompt: 'whats the difference between a line and a queue?', ideal: 'nothing, ones just angrier' },
|
||||
{ prompt: 'the DJ plays one more song and then one more song. how many songs is that?', ideal: 'none, hes packing up' },
|
||||
{ prompt: 'if the smoking area is outside, and youre outside, are ya in the club?', ideal: 'no, but i was, and i will be' },
|
||||
];
|
||||
|
||||
// How much a night of practice buys you at the same blood alcohol. Seasoned
|
||||
// drinkers hold the shape of a sentence together long past the point of sense.
|
||||
//
|
||||
// The ceiling is load-bearing, not taste: `objectivelySober` is the oracle the
|
||||
// night uses to score the player's honesty, so a 'maggot' must NEVER read as
|
||||
// sober no matter how seasoned. Worst case is the bottom of the maggot band with
|
||||
// maximum tolerance, riding the top of the wobble:
|
||||
// qMax = 1 - 0.85 * (1 - TOLERANCE_RELIEF) + WOBBLE.maggot < PASS_THRESHOLD
|
||||
// which needs TOLERANCE_RELIEF < 0.365. At the old 0.4 an intoxication-0.85,
|
||||
// tolerance-1.0 patron passed ~11% of the time. `maggot never reads sober` in
|
||||
// the test file pins this against future retunes.
|
||||
const TOLERANCE_RELIEF = 0.33;
|
||||
|
||||
// Roll spread per stage. 'loose' and 'messy' are deliberately the widest: that
|
||||
// band is where the player has to actually decide something. A maggot is narrow
|
||||
// because there is nothing ambiguous about a maggot.
|
||||
const WOBBLE: Record<DrunkStage, number> = {
|
||||
sober: 0.06,
|
||||
tipsy: 0.16,
|
||||
loose: 0.24,
|
||||
messy: 0.28,
|
||||
maggot: 0.14,
|
||||
};
|
||||
|
||||
// Above this the recital is clean — the ideal, no theatre.
|
||||
const FLAWLESS = 0.99;
|
||||
|
||||
const clamp01 = (v: number): number => Math.max(0, Math.min(1, v));
|
||||
|
||||
const ALPHABET_GIVE_UPS = [
|
||||
' ... nah thats the good half',
|
||||
' ... hang on',
|
||||
' ... does it count if i sing it',
|
||||
' ... i know the rest i just dont like it',
|
||||
] as const;
|
||||
|
||||
const NON_ANSWERS = [
|
||||
'yeah nah',
|
||||
'is this a test or are we just talkin',
|
||||
'my mate outside knows this one',
|
||||
'ask me the alphabet one instead',
|
||||
'thats a trick question and i respect that',
|
||||
'whats the prize',
|
||||
] as const;
|
||||
|
||||
// Roughly QWERTY-adjacent. Not exact — a drunk thumb isn't exact either.
|
||||
const KEY_NEIGHBOURS: Record<string, string> = {
|
||||
a: 'sqw', b: 'vgn', c: 'xdv', d: 'sfe', e: 'wrd', f: 'dgr', g: 'fht',
|
||||
h: 'gjy', i: 'uok', j: 'hkn', k: 'jlm', l: 'ko', m: 'nk', n: 'bm',
|
||||
o: 'ipl', p: 'ol', q: 'wa', r: 'etf', s: 'adw', t: 'ryg', u: 'yih',
|
||||
v: 'cbf', w: 'qes', x: 'zsc', y: 'tuh', z: 'xa',
|
||||
};
|
||||
|
||||
const VOWELS = 'aeiou';
|
||||
|
||||
/**
|
||||
* Dropped letters, swapped pairs, a stall, and eventually giving up partway.
|
||||
* Reach shrinks faster than accuracy does — people lose the thread before they
|
||||
* lose the letters.
|
||||
*/
|
||||
function reciteAlphabet(ideal: string, quality: number, rng: RngStream): string {
|
||||
if (quality >= FLAWLESS) return ideal;
|
||||
|
||||
const reach = Math.max(3, Math.round(ideal.length * Math.min(1, 0.3 + quality * 0.85)));
|
||||
const slip = (1 - quality) * 0.45;
|
||||
const out: string[] = [];
|
||||
|
||||
for (let i = 0; i < reach; i++) {
|
||||
const ch = ideal[i] ?? '';
|
||||
if (!rng.chance(slip)) {
|
||||
out.push(ch);
|
||||
continue;
|
||||
}
|
||||
const kind = rng.int(0, 2);
|
||||
if (kind === 0) continue; // dropped
|
||||
if (kind === 1) {
|
||||
out.push(ideal[i + 1] ?? '', ch); // swapped with the next one
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
out.push('...', ch); // stall
|
||||
}
|
||||
|
||||
const said = out.join('');
|
||||
const tail = reach < ideal.length ? rng.pick(ALPHABET_GIVE_UPS) : '';
|
||||
const answer = `${said}${tail}`;
|
||||
return answer.trim().length > 0 ? answer : rng.pick(ALPHABET_GIVE_UPS).trim();
|
||||
}
|
||||
|
||||
function drunkTypos(ideal: string, quality: number, rng: RngStream): string {
|
||||
const slip = (1 - quality) * 0.4;
|
||||
let out = '';
|
||||
|
||||
for (const ch of ideal) {
|
||||
const lower = ch.toLowerCase();
|
||||
const isLetter = lower >= 'a' && lower <= 'z';
|
||||
if (!isLetter || !rng.chance(slip)) {
|
||||
out += ch;
|
||||
continue;
|
||||
}
|
||||
const kind = rng.int(0, 2);
|
||||
if (kind === 0) {
|
||||
const nb = KEY_NEIGHBOURS[lower] ?? '';
|
||||
out += nb.length > 0 ? (nb[rng.int(0, nb.length - 1)] ?? ch) : ch;
|
||||
} else if (kind === 1) {
|
||||
out += ch + ch;
|
||||
} else if (!VOWELS.includes(lower)) {
|
||||
out += ch; // only vowels get swallowed
|
||||
}
|
||||
}
|
||||
|
||||
return out.trim().length > 0 ? out : ideal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Riddles fail sideways rather than sadly — past a point the patron stops
|
||||
* answering and starts negotiating. Tone guard: absurd, never pitiable.
|
||||
*/
|
||||
function answerRiddle(ideal: string, quality: number, rng: RngStream): string {
|
||||
if (quality >= FLAWLESS) return ideal;
|
||||
const bail = clamp01((0.4 - quality) / 0.4);
|
||||
if (rng.chance(bail)) return rng.pick(NON_ANSWERS);
|
||||
return drunkTypos(ideal, quality, rng);
|
||||
}
|
||||
|
||||
/** One of each kind, so the player is choosing a flavour of humiliation. */
|
||||
export function challengesFor(rng: RngStream): Challenge[] {
|
||||
const riddle = rng.pick(RIDDLES);
|
||||
return [
|
||||
{ kind: 'alphabet', prompt: ALPHABET_PROMPT, ideal: ALPHABET_BACKWARDS },
|
||||
{ kind: 'riddle', prompt: riddle.prompt, ideal: riddle.ideal },
|
||||
];
|
||||
}
|
||||
|
||||
export function perform(patron: Patron, challenge: Challenge, rng: RngStream): Performance {
|
||||
const effective = patron.intoxication * (1 - TOLERANCE_RELIEF * patron.tolerance);
|
||||
const wobble = WOBBLE[drunkStage(patron.intoxication)];
|
||||
const quality = clamp01(1 - effective + (rng.next() * 2 - 1) * wobble);
|
||||
|
||||
const answer =
|
||||
challenge.kind === 'alphabet'
|
||||
? reciteAlphabet(challenge.ideal, quality, rng)
|
||||
: answerRiddle(challenge.ideal, quality, rng);
|
||||
|
||||
return { challenge, answer, quality, objectivelySober: quality >= PASS_THRESHOLD };
|
||||
}
|
||||
586
src/scenes/door/DoorScene.ts
Normal file
586
src/scenes/door/DoorScene.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
80
src/scenes/door/DressCodeCard.ts
Normal file
80
src/scenes/door/DressCodeCard.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
190
src/scenes/door/IdCardView.ts
Normal file
190
src/scenes/door/IdCardView.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
263
src/scenes/door/NightScene.ts
Normal file
263
src/scenes/door/NightScene.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
210
src/scenes/door/NightSummaryScene.ts
Normal file
210
src/scenes/door/NightSummaryScene.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
201
src/scenes/door/PatronUpView.ts
Normal file
201
src/scenes/door/PatronUpView.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
182
src/scenes/door/PhoneWidget.ts
Normal file
182
src/scenes/door/PhoneWidget.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
247
src/scenes/door/QueueManager.ts
Normal file
247
src/scenes/door/QueueManager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
165
src/scenes/door/SobrietyModal.ts
Normal file
165
src/scenes/door/SobrietyModal.ts
Normal 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
147
src/scenes/door/StampFx.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
81
src/scenes/door/arrivalCurve.ts
Normal file
81
src/scenes/door/arrivalCurve.ts
Normal 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;
|
||||
}
|
||||
42
src/scenes/door/dazzaSchedule.ts
Normal file
42
src/scenes/door/dazzaSchedule.ts
Normal 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
179
src/scenes/door/inspect.ts
Normal 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
157
src/scenes/door/ui.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
429
src/scenes/floor/FloorDemoScene.ts
Normal file
429
src/scenes/floor/FloorDemoScene.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
301
src/scenes/floor/FloorView.ts
Normal file
301
src/scenes/floor/FloorView.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
76
src/scenes/floor/bangJudge.ts
Normal file
76
src/scenes/floor/bangJudge.ts
Normal 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
65
src/scenes/floor/cone.ts
Normal 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;
|
||||
}
|
||||
574
src/scenes/floor/crowdSim.ts
Normal file
574
src/scenes/floor/crowdSim.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
98
src/scenes/floor/escalation.ts
Normal file
98
src/scenes/floor/escalation.ts
Normal 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;
|
||||
}
|
||||
216
src/scenes/floor/overlays/CutOffOverlay.ts
Normal file
216
src/scenes/floor/overlays/CutOffOverlay.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
469
src/scenes/floor/overlays/PatDownOverlay.ts
Normal file
469
src/scenes/floor/overlays/PatDownOverlay.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
334
src/scenes/floor/overlays/StallOverlay.ts
Normal file
334
src/scenes/floor/overlays/StallOverlay.ts
Normal 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
120
src/scenes/floor/patDown.ts
Normal 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.';
|
||||
}
|
||||
76
src/scenes/floor/player.ts
Normal file
76
src/scenes/floor/player.ts
Normal 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 };
|
||||
}
|
||||
175
src/scenes/floor/venueMap.ts
Normal file
175
src/scenes/floor/venueMap.ts
Normal 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];
|
||||
}
|
||||
@ -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
178
src/ui/Clicker.ts
Normal 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
288
src/ui/DialogueBox.ts
Normal 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
563
src/ui/JuiceDemoScene.ts
Normal 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
250
src/ui/MeterHud.ts
Normal 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
395
src/ui/Phone.ts
Normal 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
268
src/ui/Stamp.ts
Normal 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
114
src/ui/style.ts
Normal 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
110
src/ui/typo.ts
Normal 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
163
tests/arrivalCurve.test.ts
Normal 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:30–12: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 60–90 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
157
tests/dazzaSchedule.test.ts
Normal 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
227
tests/dressCode.test.ts
Normal 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
200
tests/filterCurve.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
149
tests/floor/bangJudge.test.ts
Normal file
149
tests/floor/bangJudge.test.ts
Normal 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
167
tests/floor/cone.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
362
tests/floor/crowdSim.test.ts
Normal file
362
tests/floor/crowdSim.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
139
tests/floor/escalation.test.ts
Normal file
139
tests/floor/escalation.test.ts
Normal 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
163
tests/floor/patDown.test.ts
Normal 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
134
tests/floor/player.test.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
228
tests/floor/venueMap.test.ts
Normal file
228
tests/floor/venueMap.test.ts
Normal 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
279
tests/idCheck.test.ts
Normal 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
243
tests/inspect.test.ts
Normal 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
448
tests/judge.test.ts
Normal 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
127
tests/nightSummary.test.ts
Normal 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
287
tests/queueManager.test.ts
Normal 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
212
tests/scheduling.test.ts
Normal 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
254
tests/sobriety.test.ts
Normal 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
211
tests/typo.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user