From 0f7b9484399a80b175bd3bbefd2bbfd2d41be4ca Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 19 Jul 2026 20:20:18 +1000 Subject: [PATCH] =?UTF-8?q?Phase=202:=20night-flow=20integration=20?= =?UTF-8?q?=E2=80=94=20door/floor=20interleave,=20Kayden,=20TechnoEngine?= =?UTF-8?q?=20in=20the=20night,=20run=20progression;=20Phase=203=20lane=20?= =?UTF-8?q?instructions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 12 +- LANEHANDOVER.md | 58 +++++++ docs/BUILD_PLAN.md | 21 ++- lanes/LANE_CONTENT.md | 74 +++++++++ lanes/LANE_FLOOR2.md | 62 +++++++ lanes/LANE_JUICE2.md | 59 +++++++ src/audio/TechnoEngine.ts | 4 +- src/data/strings/door.ts | 4 + src/main.ts | 13 +- src/scenes/door/DoorScene.ts | 101 +++++++++++- src/scenes/door/NightScene.ts | 236 +++++++++++++++++++++------ src/scenes/door/NightSummaryScene.ts | 93 ++++++++--- src/scenes/door/StampFx.ts | 147 ----------------- src/scenes/floor/FloorDemoScene.ts | 139 +++++++++++++--- src/ui/MeterHud.ts | 11 ++ 15 files changed, 776 insertions(+), 258 deletions(-) create mode 100644 lanes/LANE_CONTENT.md create mode 100644 lanes/LANE_FLOOR2.md create mode 100644 lanes/LANE_JUICE2.md delete mode 100644 src/scenes/door/StampFx.ts diff --git a/CLAUDE.md b/CLAUDE.md index 3ce34f3..bc008d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,17 @@ by parallel "lanes" coordinated through files, reviewed by Fable. branch. An unpushed session didn't happen. - Remote is Gitea: `ssh://git@100.71.119.27:222/monster/not-tonight.git`. -## Dev notes (Phase 0 landed 2026-07-19) +## Dev notes (Phase 2 landed 2026-07-19) +- The game boots into the NIGHT (Thu→Fri→Sat run, saved). Controls: click the + rope/verdicts at the door; click the venue doorway to step inside; on the + floor WASD + E (E at the entry exits back to the door). While you're inside, + Kayden works the door — badly, on purpose. +- Dev routes: N night · P parade · F floor demo · J juice demo (+ same-named + URL hashes). `localStorage.removeItem('not-tonight-save')` for a clean run. +- Audio unlocks on first click (TechnoEngine + location filter). Before that a + StubBeatClock keeps `beat:tick` honest. + +## Dev notes (Phase 0, still true) - Gate: `npm run lint && npm run build && npm test` — all three must pass before any push. Dev server: `npm run dev` (port 5199, config in `.claude/launch.json`). - Key APIs (read the source, it's short): `core/EventBus` (`on` returns an diff --git a/LANEHANDOVER.md b/LANEHANDOVER.md index 556a52d..6b94e42 100644 --- a/LANEHANDOVER.md +++ b/LANEHANDOVER.md @@ -539,3 +539,61 @@ floor strings moved to `data/strings/floor.ts`, vite port now respects `$PORT` 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. + +--- +### SESSION — PHASE-2 INTEGRATION (executed by Fable, reviewer) — 2026-07-19 20:40 +**Branch/commits:** main (integration is reviewer-owned per the Phase-1 ruling) +**Done:** +- **The night is whole.** Door and Floor run CONCURRENTLY for the entire night; + the player toggles by clicking the venue doorway (it glows on radio calls) and + pressing E at the floor's entry. `NightScene` is the flow machine: it owns + clock, meters, audio, radio, Kayden's stamp accuracy, and the run save. +- **Kayden.** While you're inside, `DoorScene.kaydenRule()` works the REAL queue + through the REAL `judge()` — admits 4 in 5, never tests, never clicks the + clicker, forgets one UV stamp in seven. His mistakes cost actual vibe/heat and + his unstamped admits are the floor's noStamp supply. Leaving him alone all + night reliably ends in LICENCE PULLED (verified: 97 admits, 3 audit strikes). +- **Floor in night mode:** `FloorDemoScene` gained a second registration + ('Floor') that adopts the NightContext — fed by real door admissions with a + 6–14s walk-in, MeterHud (initialHeat per ruling), keeps simulating while + you're at the rope (maggots ripen → radio pulls), demo mode untouched. +- **Audio in the night:** TechnoEngine + Sfx created by NightScene; unlock on + first click; StubBeatClock hands over on `audio:unlocked`; the lowpass sweeps + on every door⇄floor toggle; stamp/clicker/rope/phone/radio SFX wired. + `audio:unlocked.atMs` now carries the anchored beat-0 origin (juice Q1 ruling). +- **Run progression:** Thu 07-16 → Fri 07-17 → Sat 07-18 at The Royal, night + seed = runSeed + index*101, heat carried run-scoped via save.ts, third strike + = immediate LICENCE PULLED cut (new endReason + FAIL_TEXT.licence), run-over + and week-done both clear the save into a fresh week. +- **Widget swaps:** door deny path now uses ui/Stamp (decal destroyed ~900ms + post-slam); StampFx.ts deleted. +**How to see it:** `npm run dev` → play. Doorway = step inside; E at entry = +step out. To watch Kayden: step inside and just... stand there. Check INSIDE +count and the incident log fill up. Preview-pane note in CLAUDE.md still applies +(drive frames in ONE javascript_exec call). +**Tests:** 413 passing / 0 failing (no new pure logic — flow wiring; the night +was verified by driving full nights in-browser, including a complete 6-hour +Kayden-only night, the licence-pulled cut, and the Thu→Fri chain with carried +heat). +**Bugs found while verifying (both fixed):** +1. `kaydenRule` emitted `door:verdict` on top of `applyOutcome`'s own emission — + every Kayden admission spawned TWO pending floor walk-ins. Caught by + inside-vs-crowd arithmetic, not by tests. +2. Pre-existing: a deferred hit's vibe delta could end the night, and + `endNight()` reassigns `this.deferred` while `fireDeferred` iterates it → + crash at scale. Collect-then-process now; surfaced only by simulating a full + unattended night. +**Broke / known-wonky:** +- Balance: a Kayden-only night ends licence-pulled (intended), but the + alternation rhythm (how long CAN you stay inside) is untuned — that's the + John-playtest tuning pass, together with the Phase-1 economy findings. +- Floor overlays (stall/pat-down) still silent — LANE-JUICE2/FLOOR2 item. +- DoorScene keeps its bespoke top-bar meters; MeterHud lives on the floor side + only. Cosmetic unification optional (LANE_JUICE2 §4). +- `radio:call` maggot trigger only fires while at the door (by design — you can + hear the floor, you can't see it). +**Contract changes:** none needed — Phase-1 folds covered everything. +**Next:** Phase 3 lanes are OPEN — LANE_CONTENT / LANE_FLOOR2 / LANE_JUICE2 +(files in lanes/). All three parallel, worktrees, own branches, usual protocol. +If only one session: CONTENT first (it's the game's heart), FLOOR2, then JUICE2 +— except run JUICE2 first if John is available for the ear pass. diff --git a/docs/BUILD_PLAN.md b/docs/BUILD_PLAN.md index 6b08d4b..459c948 100644 --- a/docs/BUILD_PLAN.md +++ b/docs/BUILD_PLAN.md @@ -68,15 +68,20 @@ scene code. Beat sync example: audio engine emits `beat:tick` on the bus; the to rhythm game consumes `beat:tick`; until integration, LANE-FLOOR uses the `StubBeatClock` from core (same event, fixed 128 BPM). -### Phase 2 — Integration (ONE lane, sequential) -Night flow state machine (Door ⇄ Floor interleave, radio pulls, Last Drinks, -Summary), real audio engine replaces stubs, save/progression, Kayden-at-door -simulation while player is on Floor. Playtest pass: one full night start→summary. +### Phase 2 — Integration — ✅ DONE 2026-07-19 (executed by Fable) +Night flow machine: Door and Floor run concurrently all night, player toggles +via the doorway / floor exit; Kayden rules the real queue while you're inside +(80% admit, 1-in-7 unstamped); TechnoEngine + location filter + SFX live in the +night; ui/Stamp + MeterHud swapped in; Thu→Fri→Sat run with saved run-scoped +heat; 3rd strike = immediate LICENCE PULLED, run over. See the Phase-2 SESSION +block in LANEHANDOVER.md. -### Phase 3 — Content & polish (parallelizable again) -Archetypes, scripted moral encounters, inspector, incident report memory, regulars, -venues 2–4, real pixel art swapped into the paper-doll layer slots, MODELBEAST- -assisted art generation if useful. +### Phase 3 — Content & polish (parallel lanes OPEN) +`lanes/LANE_CONTENT.md` (guest list, moral encounters, inspector, incident +report), `lanes/LANE_FLOOR2.md` (fights, smoking area, aggro decay), +`lanes/LANE_JUICE2.md` (ear pass, ambience, night arc). Later in this phase: +venues 2–4, real pixel art into the paper-doll slots (ASSETS.md), regulars' +cross-night memory, the economy tuning pass (John + reviewer, NOT a lane). ### Phase 4 — The role ladder (design §6; future, do not start) Glassie, bartender, DJ roles + roster board. Not scoped yet — but it imposes ONE diff --git a/lanes/LANE_CONTENT.md b/lanes/LANE_CONTENT.md new file mode 100644 index 0000000..ac94235 --- /dev/null +++ b/lanes/LANE_CONTENT.md @@ -0,0 +1,74 @@ +# LANE-CONTENT — Door depth & the moral layer (Phase 3, parallel) + +**Executor:** Opus 4.8. **Branch:** `lane/content` (from main after Phase 2). +**Owns:** `src/scenes/door/`, `src/rules/`, `src/data/strings/` (door-related +files), tests. **Prereq reading:** CLAUDE.md → `docs/GAME_DESIGN.md` §3.1/§4 → +the Phase-2 REVIEW block in `LANEHANDOVER.md`. + +## Phase-2 reality (read before coding) +- The night is now a real flow: `NightScene` owns clock/meters/audio/save and a + `NightContext` (see its interface — `sfx`, `requestLocation`, `beatIntervalMs` + are new since Phase 1). Door and Floor run concurrently ALL night; the player + toggles via the doorway ("step inside") and the floor's entry exit. +- While the player is on the floor, **Kayden rules the real queue** in + `DoorScene.kaydenRule()` (admit 80%, same `judge()`, no UV stamp 1-in-7 — + stamp accuracy is decided in `NightScene.onVerdict`). +- Heat is run-scoped and saved; 3rd strike ends the night AND the run + immediately (`endReason: 'licence'`). Nights chain Thu→Fri→Sat via + `NightSummaryScene.nextAction()`; `save.ts` carries `GameState`. +- `ui/Stamp` + sfx are live in the deny path; `data/strings/door.ts` has + `FAIL_TEXT.licence`. + +## Deliverables (in priority order) + +### 1. The Guest List clipboard (design §3.1 — specced, never built) +A desk clipboard panel: tonight's list (generated, 8–14 names, some misspelled +versions of real patron names). Patrons with `claimsGuestList` get a "'m on the +list" line when they step up; player cross-checks. `knowsOwner` patrons who are +DENIED trigger the existing furious-Dazza path (judge already scores this — +surface it: their step-up line should drop hints ("Terry said swing by"), and +`data/strings/dazza.ts` has the `owner-scare` text to foreshadow). +Misspelling rule: a name that ALMOST matches is usually legit (bad handwriting); +an exact-match name on a confident patron is sometimes a lift. Tells, not +certainty. Pure logic in `rules/guestList.ts` + tests. + +### 2. Scripted moral encounters (design §4.3 — the heart) +2–3 scripted patrons injected per night by the generator override hook +(`generatePatron(ctx, clockMin, archetypeOverride)` + a new scripted-encounter +scheduler in `rules/` or `data/`): the girl who needs back in to grab her mate +(no stamp, over capacity), the freshly-dumped regular who just wants one quiet +beer (already `loose`). Multi-beat dialogue via the existing toast/dialogue +patterns. Doing the human thing costs Vibe or risks Heat; the game never says +which choice was right. NO achievement chimes, no morality meter — the design +doc is explicit that this stays unscored. + +### 3. The Inspector (archetype exists, does nothing yet) +Activate `inspector`: reads as a boring `punter` at the rope (their doll is +deliberately unremarkable). If admitted, they observe for ~30 clock-min: +any live breach in that window (capacity over licensed, a `maggot` on the floor, +underage inside) converts its deferred strike to IMMEDIATE. Then they leave and +Dazza texts "that was the INSPECTOR you muppet". If denied — nothing happens, +ever (they were just a quiet person; paranoia is the tax). Pure logic +`rules/inspector.ts` + tests; wiring via bus events only. + +### 4. The Incident Report (design §3.3 — lying groundwork) +On the Night Summary: a short form (3–4 incidents sampled from +`NightState.incidents`) where the player picks a written account per incident — +truthful or flattering. Store both truth and account in +`GameState.pastReports`. Cross-night audit (contradictions → strikes) is +Phase 4; THIS lane only builds the form + storage + one taste: a lie about a +`kayden` incident can be contradicted by Kayden's own log line next morning +(Dazza text). Keep the form fast — 20 seconds, not homework. + +### 5. Sobriety line-walk (stretch) +The third challenge: cursor-follow line at the desk. Only if 1–4 land clean. + +## Constraints +- All scoring through `judge()`/`meters:delta`/`heat:strike` — no new direct + meter paths. New events need a CONTRACT CHANGE REQUEST (declaration-merge + bridges are BANNED now — request and wait for the fold). +- Kayden must interact sanely with everything you add (e.g. he auto-admits + guest-list liars — that's fine and funny; make sure it can't crash). +- Every pure module tested; play a full 3-night run before your handover and + report what the run FELT like — the economy findings from Phase 1 are still + open and your content changes the mix. diff --git a/lanes/LANE_FLOOR2.md b/lanes/LANE_FLOOR2.md new file mode 100644 index 0000000..c96211c --- /dev/null +++ b/lanes/LANE_FLOOR2.md @@ -0,0 +1,62 @@ +# LANE-FLOOR2 — Floor completion & aggro physiology (Phase 3, parallel) + +**Executor:** Opus 4.8. **Branch:** `lane/floor2` (from main after Phase 2). +**Owns:** `src/scenes/floor/`, `src/data/strings/floor.ts`, tests. **Prereq +reading:** CLAUDE.md → `docs/GAME_DESIGN.md` §3.2 → Phase-2 REVIEW block in +`LANEHANDOVER.md`. + +## Phase-2 reality (read before coding) +- `FloorDemoScene` now runs in TWO modes: standalone `'FloorDemo'` (key F, + unchanged) and night mode `'Floor'` (launched by NightScene with a + `NightContext`; see `bindNight()`). Night mode: shared bus/clock/state, fed by + real door admissions (`door:verdict` → delayed `crowd.spawn`), `MeterHud` for + HUD, exit via E at the entry anchor, sim keeps running while the player is at + the door (`present === false` path in `update()`). +- Incidents flow through `incident:log` (single writer in core/meters). +- `NightContext.sfx` exists (SFX handle, always crisp past the location filter). +- Kayden's unstamped admits (1 in 7) are your `noStamp` supply in night mode. + +## Deliverables (in priority order) + +### 1. Fight brewing (design §3.2 — the last missing v0.2 infraction) +Two agents in escalating shove animation (crowd sim picks pairs with rising +aggro contribution when they collide repeatedly). A swing timer (~8s from first +shove): player must physically walk BETWEEN them before it ends. Interposed = +defused (+vibe, both marked handled); missed = fight (big −vibe, +aggro, both +auto-ejected, `incident:log` kind `fight`, and if the inspector is inside — +LANE-CONTENT's problem — it's a heat risk). Pure pair-selection + timer logic +in `floor/fights.ts`, tested. While the player is at the door, fights still +happen (that's radio-pull pressure — emit a `radio:call` urgency 3 via the +night's existing pattern: log the incident, NightScene already radios on +`maggotUnhandled`; add `fight` to that trigger via CONTRACT CHANGE REQUEST or +ask the reviewer to widen the trigger — one-line, will be approved). + +### 2. Smoking area & pass-outs (closes the UV loop from the door side) +The smoking-area door becomes live: patrons drift out for a smoke (activity +'smoking' exists) and BACK IN through the entry. Re-entering smokers keep their +stamp — but a small % of NEW patrons try to slip in through the smoking door +(no stamp, never saw the front door). That's the noStamp mechanic getting a +source that isn't only Kayden. Numbers small (2–3 a night), tells readable +(they enter from the wrong door if you're watching). + +### 3. Aggro physiology (FLOOR-2 CCR, deferred at Phase-1 review — now approved) +Implement slow aggro decay on a calm floor: when no unhandled infraction exists +and no fight is brewing, drift aggro down ~0.3/clock-min (numbers yours to +tune, state the final value in the handover). Implementation note: this is a +sim-level `meters:delta` emit from the floor's update, gated on `present` +being EITHER value (a calm floor calms the night whether you're watching it or +not). Add tests for the gating logic. + +### 4. Night-mode polish +- Stall bangs and pat-down bin drops should hit `NightContext.sfx` + (`doorBang`, plus pick something for the bin thunk) — overlays take an + optional sfx handle via their constructors. +- Escorted patrons should exit via the ENTRY in night mode (they're being + walked out the front, past the queue — future door-side schadenfreude hook). +- Perf check at 44 agents + fights + smokers in night mode; report frame times. + +## Constraints +- Demo mode must stay working and identical-feeling (it's the tuning harness). +- No imports from `scenes/door/`; the night talks to you via bus + context. +- Pure logic tested; verify each mechanic by playing BOTH modes; handover per + protocol. diff --git a/lanes/LANE_JUICE2.md b/lanes/LANE_JUICE2.md new file mode 100644 index 0000000..907e2e0 --- /dev/null +++ b/lanes/LANE_JUICE2.md @@ -0,0 +1,59 @@ +# LANE-JUICE2 — The night has a sound (Phase 3, parallel) + +**Executor:** Opus 4.8. **Branch:** `lane/juice2` (from main after Phase 2). +**Owns:** `src/audio/`, `src/ui/`, tests. **Prereq reading:** CLAUDE.md → +LANE_JUICE.md (your Phase-1 spec) → Phase-2 REVIEW block in `LANEHANDOVER.md`. + +## Phase-2 reality (read before coding) +- `TechnoEngine` + `Sfx` now live inside the actual night (`NightScene` creates + them; unlock on first pointerdown; `StubBeatClock` hands over on + `audio:unlocked`). The location filter sweeps on every doorway/exit toggle. +- `audio:unlocked.atMs` now carries the ANCHORED beat-0 origin (your open + question, ruled and implemented). +- `MeterHud` has `initialHeat` (ruled: read run-scoped strikes at construction — + wired in the floor's night mode). +- `ui/Stamp` is the door's stamp, with `stampSlam`/`stampInk` wired. The decal + is destroyed ~900ms post-slam by DoorScene. +- Your `ui/juiceEvents.ts` was deleted at review — its events live in + `data/types.ts` now. Extensions go via CONTRACT CHANGE REQUEST only. + +## Deliverables (in priority order) + +### 1. Ear pass with John (the debt from Phase 1: "verified by ear: NOTHING") +Sit a human in front of the JuiceDemo AND a real night. Tune: does the kick +read as a kick, is the bassline musical, `typeTick` level (it fires per +character), stamp slam vs music balance, the door→floor sweep (does it feel +like a door opening). Document every constant you change and why. This is THE +deliverable — everything below is secondary. + +### 2. Ambience layer +- `rainLoop` at the door (start/stop on `audio:location` — rain is outside). +- A crowd-murmur bed INSIDE (filtered noise, level follows + `NightState.capacity.inside` — read via a bus listener on `meters:changed`? + No — capacity isn't in that payload; request what you need or derive from + `door:clicker`/`door:verdict` counting. State your approach in the handover). +- The kebab-shop fluoro hum if you're feeling artistic (tiny, dry, mono). + +### 3. The night has an arc +Track intensity should rise across the night: thin kick+hat at 9 PM, bassline +enters ~10, pads and full mix by midnight, strip back at Last Drinks. Drive it +from `clock:tick` (you know the clock minute). Keep every parameter externally +settable — this surface IS the future DJ role's control panel (BUILD_PLAN +Phase 4 rule). + +### 4. Widget debts +- `Phone` thread: scroll instead of truncating the newest over-tall message. +- `DialogueBox` drunk-typo mode: wire into the floor cut-off overlay (it + currently renders plain text — coordinate via handover, LANE-FLOOR2 owns the + call site; you own the widget). +- Optional: `MeterHud` variant sized for the door's top bar, so DoorScene can + drop its bespoke `MeterBar`s (currently duplicated look). Only if clean — + the door HUD works and this is cosmetic unification, not a rescue. + +## Constraints +- Location filter architecture is non-negotiable: music through the lowpass, + SFX and ambience around it (rain is OUTSIDE — it belongs on the sfx bus but + only audible at the door; that's a routing decision, justify yours). +- No imports from door/floor scenes. Bus + constructor config only. +- Pure-testable math tested; everything else honestly listed as ear-checked or + not, per your own Phase-1 standard. diff --git a/src/audio/TechnoEngine.ts b/src/audio/TechnoEngine.ts index 1e61d86..fa90800 100644 --- a/src/audio/TechnoEngine.ts +++ b/src/audio/TechnoEngine.ts @@ -178,7 +178,9 @@ export class TechnoEngine { this.grid.originMs = atMs + START_LEAD_MS; this.nextBeat = 0; this.unlockedFlag = true; - this.bus.emit('audio:unlocked', { atMs }); + // Review ruling: atMs carries the ANCHORED beat-0 origin, not the resume + // instant — consumers may treat beat 0 as landing exactly at atMs. + this.bus.emit('audio:unlocked', { atMs: this.grid.originMs }); })(); try { diff --git a/src/data/strings/door.ts b/src/data/strings/door.ts index b89fc4e..41ead7a 100644 --- a/src/data/strings/door.ts +++ b/src/data/strings/door.ts @@ -125,6 +125,10 @@ export const FAIL_TEXT = { title: 'THE QUEUE WENT UP', dazza: 'someone glassed the door. THE DOOR. u had one job and the job was the door', }, + licence: { + title: 'LICENCE PULLED', + dazza: 'the inspector just walked out with the licence in a SLEEVE. do not text me. do not call me. terry is going to hear about this', + }, } as const; /** Dazza's night-end verdicts, worst to best. Index chosen by final vibe. */ diff --git a/src/main.ts b/src/main.ts index 30b9f64..f0a5e4b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -21,7 +21,18 @@ const game = new Phaser.Game({ autoCenter: Phaser.Scale.CENTER_BOTH, zoom: Phaser.Scale.MAX_ZOOM, }, - scene: [NightScene, DoorScene, NightSummaryScene, BootScene, ParadeScene, FloorDemoScene, JuiceDemoScene], + // FloorDemoScene registers twice: 'Floor' is the night-mode instance NightScene + // launches with a NightContext; 'FloorDemo' stays the standalone demo (key F). + scene: [ + NightScene, + DoorScene, + NightSummaryScene, + new FloorDemoScene('Floor'), + BootScene, + ParadeScene, + new FloorDemoScene('FloorDemo'), + JuiceDemoScene, + ], }); // debug handle (harmless in prod; used by dev tooling) diff --git a/src/scenes/door/DoorScene.ts b/src/scenes/door/DoorScene.ts index 8f3c2a3..0563151 100644 --- a/src/scenes/door/DoorScene.ts +++ b/src/scenes/door/DoorScene.ts @@ -20,7 +20,7 @@ import { IdCardView } from './IdCardView'; import { PhoneWidget } from './PhoneWidget'; import { DressCodeCard } from './DressCodeCard'; import { SobrietyModal } from './SobrietyModal'; -import { StampFx } from './StampFx'; +import { stamp, type StampHandle } from '../../ui/Stamp'; import { Button, DOOR_PALETTE, MeterBar, MONO, panel, text } from './ui'; import { shouldRecordStrike, type NightContext } from './NightScene'; @@ -28,6 +28,8 @@ const W = 640; const H = 360; const DESK_Y = 250; const UP_X = 300; +/** Real-ms between Kayden's rulings while the player is on the floor (~2.3 clock-min). */ +const KAYDEN_RULE_MS = 5000; const UP_FOOT_Y = 244; const DOOR_X = 566; const QUEUE_FOOT_Y = 228; @@ -58,7 +60,6 @@ export class DoorScene extends Phaser.Scene { private phone!: PhoneWidget; private codeCard!: DressCodeCard; private sobriety!: SobrietyModal; - private stamp!: StampFx; private queueSprites: QueueSprite[] = []; private trayCard: Phaser.GameObjects.Container | null = null; @@ -80,6 +81,10 @@ export class DoorScene extends Phaser.Scene { private testedThisPatron = false; private pattedThisPatron = false; private offs: Array<() => void> = []; + /** Is the player at the rope? When false, Kayden works the door. Badly. */ + private present = true; + private kaydenAccMs = 0; + private venueDoorGlow: Phaser.GameObjects.Rectangle | null = null; constructor() { super('Door'); @@ -94,7 +99,8 @@ export class DoorScene extends Phaser.Scene { 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.present = true; + this.kaydenAccMs = 0; this.buildStreet(); this.buildDesk(); @@ -108,6 +114,7 @@ export class DoorScene extends Phaser.Scene { this.hypeText.setText(`HYPE x${hype.toFixed(1)}`); }), this.night.bus.on('dazza:text', ({ rule }) => { + this.night.sfx?.play('phoneBuzz'); // 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 @@ -115,6 +122,20 @@ export class DoorScene extends Phaser.Scene { if (isDoorRule(rule)) this.codeCard.add(this, rule, this.elapsedMs); }), this.night.bus.on('heat:strike', () => this.refreshCounters()), + this.night.bus.on('night:phaseChange', ({ location }) => { + this.present = location === 'door'; + if (this.present) this.refreshCounters(); + else this.kaydenAccMs = 0; + }), + this.night.bus.on('radio:call', ({ line, urgency }) => { + if (!this.present) return; + this.showToast(`RADIO — ${line}`); + // The venue door glows when the floor wants you: the way in IS the button. + if (this.venueDoorGlow) { + this.venueDoorGlow.setAlpha(urgency >= 3 ? 0.5 : 0.3); + this.tweens.add({ targets: this.venueDoorGlow, alpha: 0.08, duration: 6000 }); + } + }), ]; this.events.once('shutdown', () => this.teardown()); @@ -144,6 +165,21 @@ export class DoorScene extends Phaser.Scene { this.add.rectangle(DOOR_X, 140, 34, 74, 0x05050a); this.add.rectangle(DOOR_X, 214, 66, 8, DOOR_PALETTE.neon, 0.16); + // The doorway is how you get to the floor — click it to step inside. + // It glows when the radio wants you in there. + this.venueDoorGlow = this.add.rectangle(DOOR_X, 140, 38, 78, DOOR_PALETTE.neon, 0.08); + const doorway = this.add + .rectangle(DOOR_X, 140, 40, 80, 0xffffff, 0) + .setInteractive({ useHandCursor: true }); + doorway.on('pointerover', () => this.venueDoorGlow?.setAlpha(0.28)); + doorway.on('pointerout', () => this.venueDoorGlow?.setAlpha(0.08)); + doorway.on('pointerdown', () => { + if (!this.busy) this.night.requestLocation('floor'); + }); + this.add + .text(DOOR_X, 184, 'step inside', { fontFamily: MONO, fontSize: '7px', color: '#6a4a5a' }) + .setOrigin(0.5, 0); + this.buildRain(); this.buildRope(); } @@ -302,6 +338,7 @@ export class DoorScene extends Phaser.Scene { } this.testedThisPatron = false; this.pattedThisPatron = false; + this.night.sfx?.play('ropeUnhook'); this.up.show(p); this.showTray(p); this.setVerdictButtons(true); @@ -325,6 +362,7 @@ export class DoorScene extends Phaser.Scene { } private click(direction: 'in' | 'out'): void { + this.night.sfx?.play('clickerClunk'); this.night.bus.emit('door:clicker', { direction }); this.refreshCounters(); this.tweens.add({ targets: this.clickerText, scale: { from: 1.35, to: 1 }, duration: 120 }); @@ -416,15 +454,27 @@ export class DoorScene extends Phaser.Scene { this.time.delayedCall(2600, release); if (verdict === 'deny') { - this.stamp.play({ + // LANE-JUICE's stamp (screen shake, ink ring, sfx). The decal is ours to + // destroy — the denied patron takes their mark with them into the night. + let handle: StampHandle | null = null; + const sfx = this.night.sfx; + handle = stamp(this, { x: UP_X, y: UP_FOOT_Y - 70, - label: 'NOT TONIGHT', - onImpact: () => { + text: 'NOT TONIGHT', + depth: 320, + seed: p.dollSeed, + ...(sfx + ? { sfx: { stampSlam: () => sfx.play('stampSlam'), stampInk: () => sfx.play('stampInk') } } + : {}), + onSlam: () => { this.up.walkOffDenied(); this.showToast(this.pick(DENY_REACTIONS, p)); }, - onDone: release, + onDone: () => { + release(); + this.time.delayedCall(900, () => handle?.decal.destroy()); + }, }); return; } @@ -492,6 +542,31 @@ export class DoorScene extends Phaser.Scene { this.buttons.pat?.setEnabled(on); } + /** + * One Kayden decision. Synchronous — no animation, no busy flag, and the + * patron never appears in the up-view, so returning to the door always finds + * a clean rope. Kayden never tests, never pats down, and admits four in five. + */ + private kaydenRule(): void { + if (this.queue.patronUp) return; // player left mid-ruling; don't double-serve + const p = this.queue.callNext(); + if (!p) return; + const kayden = this.night.rng.stream('kayden'); + const verdict: Extract = kayden.chance(0.8) ? 'admit' : 'deny'; + const outcome = judge(p, verdict, this.judgeCtx()); + if (verdict === 'admit') this.night.state.capacity.inside++; + // applyOutcome emits door:verdict (and the verdict incident) — same path as + // a player ruling, so the floor is fed exactly once per admission. + this.applyOutcome(p, verdict, outcome); + this.queue.resolveUp(verdict === 'deny'); + this.night.bus.emit('incident:log', { + clockMin: this.night.state.clockMin, + kind: 'kayden', + patronId: p.id, + detail: `Kayden ${verdict === 'admit' ? 'waved through' : 'knocked back'} ${p.archetype}`, + }); + } + private refreshCounters(): void { const cap = this.night.state.capacity; this.clickerText.setText(String(cap.clickerShown)); @@ -511,6 +586,18 @@ export class DoorScene extends Phaser.Scene { this.codeCard.update(this.elapsedMs); this.night.reportQueue(this.queue.queueLength); + // Player is on the floor: Trainee Kayden works the rope. He is quick, + // confident, and wrong a lot — his admits go through the same judge(), so + // his mistakes cost the same vibe/heat yours do. (His stamp accuracy is + // NightScene's problem; his verdicts are made here on the real queue.) + if (!this.present) { + this.kaydenAccMs += deltaMs; + while (this.kaydenAccMs >= KAYDEN_RULE_MS) { + this.kaydenAccMs -= KAYDEN_RULE_MS; + this.kaydenRule(); + } + } + 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 }); diff --git a/src/scenes/door/NightScene.ts b/src/scenes/door/NightScene.ts index b65e8e5..4c42ee4 100644 --- a/src/scenes/door/NightScene.ts +++ b/src/scenes/door/NightScene.ts @@ -1,22 +1,22 @@ import Phaser from 'phaser'; import { EventBus } from '../../core/EventBus'; -import { GameClock, DEFAULT_CLOCK } from '../../core/GameClock'; +import { GameClock, DEFAULT_CLOCK, type ClockConfig } from '../../core/GameClock'; import { SeededRNG } from '../../core/SeededRNG'; import { StubBeatClock } from '../../core/StubBeatClock'; import { Meters, freshNightState } from '../../core/meters'; +import { loadGame, saveGame, clearSave, freshGameState } from '../../core/save'; import { _resetPatronSerial } from '../../patrons/generator'; import { ruleById } from '../../rules/dressCode'; +import { TechnoEngine } from '../../audio/TechnoEngine'; +import { Sfx } from '../../audio/Sfx'; import type { DeferredHit } from '../../rules/doorTypes'; -import type { HeatStrike, NightState, Patron, Verdict, VerdictOutcome } from '../../data/types'; +import type { GameState, 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. +// Phase-2: the night-flow machine. Door and Floor both run for the whole night; +// this scene owns WHERE THE PLAYER IS (visibility + input), the clock, the +// meters, the audio engine (and the location filter that goes with it), Kayden's +// stamp accuracy, the radio, and the run-level save. It still renders nothing. export const LICENSED_CAPACITY = 80; const VIBE_SAMPLE_EVERY_MIN = 5; @@ -24,18 +24,23 @@ 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. - */ +/** Thu → Fri → Sat at The Royal. Index = nightIndex. */ +export const NIGHT_DATES = ['2026-07-16', '2026-07-17', '2026-07-18'] as const; +export const TOTAL_NIGHTS = NIGHT_DATES.length; + +/** Kayden stamps most hands. Most. */ +export const KAYDEN_STAMP_CHANCE = 0.86; +/** Scripted "get in here" pulls: the floor demands a lap around these minutes. */ +const RADIO_PULL_MINS = [120, 250] as const; +/** Dynamic maggot-radio rate limit (clock minutes). */ +const RADIO_MAGGOT_COOLDOWN = 15; + 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 type NightEndReason = 'clock' | 'vibe' | 'aggro' | 'licence'; export interface VerdictRecord { patron: Patron; @@ -57,7 +62,17 @@ export interface NightLog { seed: number; } -/** Everything DoorScene needs from the night. Passed by reference, not copied. */ +/** Run-level result attached to the summary. */ +export interface RunInfo { + nightIndex: number; + totalNights: number; + /** licence pulled — the run is dead, only a fresh one remains */ + runOver: boolean; + /** survived all three nights */ + weekDone: boolean; +} + +/** Everything the door and floor need from the night. Passed by reference. */ export interface NightContext { bus: EventBus; rng: SeededRNG; @@ -65,51 +80,87 @@ export interface NightContext { state: NightState; seed: number; nightDate: Date; + /** UI sfx — routed around the location filter, always crisp. */ + sfx: Sfx | null; + beatIntervalMs: number; scheduleDeferred(hits: readonly DeferredHit[]): void; - /** DoorScene owns the queue; Dazza's queue-gated whinges need to see it. */ reportQueue(length: number): void; + /** Door/Floor ask to move the player; the night decides and flips the scenes. */ + requestLocation(loc: 'door' | 'floor'): void; } export class NightScene extends Phaser.Scene { private bus!: EventBus; private rng!: SeededRNG; private clock!: GameClock; - private beat!: StubBeatClock; + private stubBeat: StubBeatClock | null = null; + private engine: TechnoEngine | null = null; + private sfx: Sfx | null = null; private meters!: Meters; private state!: NightState; + private run!: GameState; private seed = 4207; + private nightIndex = 0; private ended = false; private deferred: DeferredHit[] = []; private readonly firedDazza = new Set(); private lastDazzaMin = -99; private lastVibeSampleMin = -99; + private lastMaggotRadioMin = -99; + private readonly firedPulls = new Set(); private queueLength = 0; private log!: NightLog; private offs: Array<() => void> = []; + private ctx!: NightContext; constructor() { super('Night'); } - init(data: { seed?: number }): void { - if (typeof data.seed === 'number') this.seed = data.seed; + init(data: { seed?: number; nightIndex?: number; freshRun?: boolean }): void { + if (data.freshRun) clearSave(); + this.run = loadGame(typeof data.seed === 'number' ? data.seed : 4207); + if (typeof data.seed === 'number' && this.run.seed !== data.seed) { + this.run = freshGameState(data.seed); + } + this.nightIndex = data.nightIndex ?? this.run.nightIndex; + if (this.nightIndex >= TOTAL_NIGHTS) this.nightIndex = 0; + // Each night is its own deterministic world derived from the run seed. + this.seed = this.run.seed + this.nightIndex * 101; } create(): void { this.ended = false; this.deferred = []; this.firedDazza.clear(); + this.firedPulls.clear(); this.lastDazzaMin = -99; this.lastVibeSampleMin = -99; + this.lastMaggotRadioMin = -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); + const clockCfg: ClockConfig = { ...DEFAULT_CLOCK, nightDate: NIGHT_DATES[this.nightIndex]! }; + this.clock = new GameClock(this.bus, clockCfg); + this.state = freshNightState('theRoyal', this.nightIndex, LICENSED_CAPACITY); + // Heat is run-scoped: strikes from earlier nights are already on the licence. + this.state.heatStrikes.push(...this.run.heatStrikes); this.meters = new Meters(this.bus, this.state); + // Audio: the engine is the beat authority once the browser lets it speak. + // Until the first gesture, a stub keeps beat:tick honest (then hands over). + this.engine = new TechnoEngine(this.bus); + this.sfx = new Sfx(this.engine.ctx, this.engine.sfxBus); + this.stubBeat = new StubBeatClock(this.bus); + this.stubBeat.start(); + this.engine.start(); + const unlock = (): void => { + void this.engine?.unlock(); + }; + this.input.on('pointerdown', unlock); + this.offs.push(() => this.input.off('pointerdown', unlock)); + this.log = { vibeHistory: [this.state.vibe], verdicts: [], @@ -123,45 +174,94 @@ export class NightScene extends Phaser.Scene { seed: this.seed, }; - this.offs = [ + this.offs.push( 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('heat:strike', (s) => this.onStrike(s)), this.bus.on('door:verdict', (v) => this.onVerdict(v)), - ]; + this.bus.on('floor:ejection', () => { + this.state.capacity.inside = Math.max(0, this.state.capacity.inside - 1); + }), + this.bus.on('audio:unlocked', () => { + this.stubBeat?.stop(); + this.stubBeat = null; + }), + this.bus.on('radio:call', () => this.sfx?.play('radioChirp')), + this.bus.on('incident:log', (inc) => { + if (inc.kind === 'maggotUnhandled' && this.state.location === 'door') this.maggotRadio(); + }), + ); - const ctx: NightContext = { + this.ctx = { bus: this.bus, rng: this.rng, clock: this.clock, state: this.state, seed: this.seed, nightDate: this.clock.nightDate, + sfx: this.sfx, + beatIntervalMs: 60_000 / 128, scheduleDeferred: (hits) => this.deferred.push(...hits), reportQueue: (length) => { this.queueLength = length; }, + requestLocation: (loc) => this.setLocation(loc), }; this.events.once('shutdown', () => this.teardown()); this.clock.start(); - this.beat.start(); - this.scene.launch('Door', { night: ctx }); + this.scene.launch('Door', { night: this.ctx }); + this.scene.launch('Floor', { night: this.ctx }); + // Door and Floor BOTH run all night; the flags below decide which one the + // player is inhabiting. Floor starts hidden. + this.time.delayedCall(0, () => this.applyLocation('door')); } + // ---- location ---------------------------------------------------------- + + private setLocation(loc: 'door' | 'floor'): void { + if (this.ended || this.state.location === loc) return; + this.applyLocation(loc); + this.bus.emit('night:phaseChange', { location: loc }); + this.bus.emit('audio:location', { location: loc }); + } + + private applyLocation(loc: 'door' | 'floor'): void { + const doorHere = loc === 'door'; + this.scene.setVisible(doorHere, 'Door'); + this.scene.setVisible(!doorHere, 'Floor'); + const door = this.scene.get('Door'); + const floor = this.scene.get('Floor'); + if (door) door.input.enabled = doorHere; + if (floor) floor.input.enabled = !doorHere; + this.state.location = loc; // meters also tracks via night:phaseChange; direct set covers the first frame + } + + // ---- per-event --------------------------------------------------------- + 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++; + if (verdict === 'admit') { + this.log.admitted++; + // The UV stamp happens at the door. The player never forgets; Kayden + // forgets one hand in seven — those are the floor's noStamp infractions. + patron.flags.uvStamped = + this.state.location === 'door' ? true : this.rng.stream('kaydenStamp').chance(KAYDEN_STAMP_CHANCE); + } else if (verdict === 'deny') this.log.denied++; else if (verdict === 'sobrietyTest') this.log.tested++; else if (verdict === 'patDown') this.log.pattedDown++; } + private onStrike(s: HeatStrike): void { + this.log.heatStrikes.push(s); + // Third strike doesn't wait for 3 AM: the inspector pulls the licence on the + // spot and the run is over (review ruling, design §2). + if (this.state.heatStrikes.length >= MAX_HEAT_STRIKES) this.endNight('licence'); + } + 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'); } @@ -176,16 +276,40 @@ export class NightScene extends Phaser.Scene { this.fireDeferred(clockMin); this.maybeDazza(clockMin); + this.maybePull(clockMin); if (clockMin >= this.clock.config.nightClockMinutes) this.endNight('clock'); } - /** Consequences you earned earlier arriving now, when you can no longer argue. */ + /** Scripted floor pulls: the radio wants you inside around these times. */ + private maybePull(clockMin: number): void { + for (const at of RADIO_PULL_MINS) { + if (clockMin < at || this.firedPulls.has(at)) continue; + this.firedPulls.add(at); + if (this.state.location === 'floor') continue; // already in there + this.bus.emit('radio:call', { + urgency: 2, + line: at === RADIO_PULL_MINS[0] ? 'mate do a lap, it is getting loose in here' : 'you are NEEDED inside. now.', + }); + } + } + + private maggotRadio(): void { + const clockMin = this.state.clockMin; + if (clockMin - this.lastMaggotRadioMin < RADIO_MAGGOT_COOLDOWN) return; + this.lastMaggotRadioMin = clockMin; + this.bus.emit('radio:call', { urgency: 3, line: 'someone is MAGGOT on the dance floor, get in here' }); + } + private fireDeferred(clockMin: number): void { + // Collect first, THEN process: a hit's vibe delta can end the night, and + // endNight() reassigns this.deferred — mutating the array we'd be iterating. + const due: DeferredHit[] = []; 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 (clockMin >= this.deferred[i]!.atClockMin) due.push(...this.deferred.splice(i, 1)); + } + for (const hit of due) { + if (this.ended) return; if (hit.vibeDelta !== undefined || hit.aggroDelta !== undefined) { this.bus.emit('meters:delta', { @@ -228,36 +352,54 @@ export class NightScene extends Phaser.Scene { this.ended = true; this.log.endReason = reason; this.clock.pause(); - this.beat.stop(); + this.stubBeat?.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); + // to closing or not. (Not after a pulled licence: the run is already dead.) + if (reason !== 'licence') { + for (const hit of this.deferred) { + if (hit.heatStrike && shouldRecordStrike(this.state, hit.heatStrike)) { + this.bus.emit('heat:strike', hit.heatStrike); + } } } this.deferred = []; this.log.vibeHistory.push(this.state.vibe); + // ---- run bookkeeping (save) ---- + const runOver = reason === 'licence'; + const survived = reason === 'clock'; + const weekDone = survived && this.nightIndex >= TOTAL_NIGHTS - 1; + this.run.heatStrikes = [...this.state.heatStrikes]; + this.run.pastReports.push([...this.state.incidents]); + if (survived) this.run.nightIndex = this.nightIndex + 1; + if (runOver || weekDone) { + clearSave(); + } else { + saveGame(this.run); + } + const runInfo: RunInfo = { nightIndex: this.nightIndex, totalNights: TOTAL_NIGHTS, runOver, weekDone }; + this.time.delayedCall(reason === 'clock' ? 600 : 1400, () => { this.scene.stop('Door'); - this.scene.start('NightSummary', { log: this.log, state: this.state }); + this.scene.stop('Floor'); + this.scene.start('NightSummary', { log: this.log, state: this.state, run: runInfo }); }); } override update(_time: number, deltaMs: number): void { if (this.ended) return; this.clock.update(deltaMs); - this.beat.update(deltaMs); + this.stubBeat?.update(deltaMs); } private teardown(): void { for (const off of this.offs) off(); this.offs = []; this.meters?.destroy(); + this.engine?.destroy(); + this.engine = null; + this.sfx = null; this.bus?.removeAll(); } } diff --git a/src/scenes/door/NightSummaryScene.ts b/src/scenes/door/NightSummaryScene.ts index 38b06b8..9927a5c 100644 --- a/src/scenes/door/NightSummaryScene.ts +++ b/src/scenes/door/NightSummaryScene.ts @@ -1,16 +1,17 @@ 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 { MAX_HEAT_STRIKES, NIGHT_DATES, type NightLog, type RunInfo } 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. +// Phase-2: run-aware summary. Still owes Phase 3 the incident-report form and +// the cross-night audit. const W = 640; const H = 360; +const NIGHT_NAMES = ['thursday', 'friday', 'saturday'] as const; + 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. @@ -30,25 +31,25 @@ export function signoffFor(vibe: number, strikes: number): string { export class NightSummaryScene extends Phaser.Scene { private log!: NightLog; private state!: NightState; + private run: RunInfo | null = null; constructor() { super('NightSummary'); } - init(data: { log: NightLog; state: NightState }): void { + init(data: { log: NightLog; state: NightState; run?: RunInfo }): void { this.log = data.log; this.state = data.state; + this.run = data.run ?? null; } create(): void { - const failed = this.log.endReason !== 'clock'; + const reason = this.log.endReason; + const failed = reason !== '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; + const title = reason !== 'clock' ? FAIL_TEXT[reason].title : SUMMARY_UI.title; + const nightName = this.run ? (NIGHT_NAMES[this.run.nightIndex] ?? '') : ''; this.add .text(W / 2, 26, title, { @@ -58,11 +59,18 @@ export class NightSummaryScene extends Phaser.Scene { }) .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, - }) + .text( + W / 2, + 46, + failed + ? `${this.state.venueId} · ${nightName} · night over early` + : `${SUMMARY_UI.subtitle}${nightName ? ` · ${nightName}` : ''}`, + { + fontFamily: MONO, + fontSize: '8px', + color: DOOR_PALETTE.inkDim, + }, + ) .setOrigin(0.5); this.drawVibeGraph(30, 64, 300, 88); @@ -70,16 +78,48 @@ export class NightSummaryScene extends Phaser.Scene { this.drawStats(354, 64); this.drawDazza(30, 250); + const { prompt, next } = this.nextAction(); this.add - .text(W / 2, H - 14, `${SUMMARY_UI.replay} · seed ${this.log.seed}`, { + .text(W / 2, H - 14, `${prompt} · seed ${this.log.seed}`, { fontFamily: MONO, fontSize: '8px', - color: DOOR_PALETTE.inkDim, + color: this.run?.runOver ? '#ff8080' : 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 })); + this.input.once('pointerdown', next); + this.input.keyboard?.once('keydown-SPACE', next); + } + + /** What clicking through leads to: the next shift, or a fresh run. */ + private nextAction(): { prompt: string; next: () => void } { + const r = this.run; + if (!r) { + return { + prompt: SUMMARY_UI.replay, + next: () => this.scene.start('Night', { seed: this.log.seed + 1 }), + }; + } + if (r.runOver) { + return { + prompt: 'licence gone · click to start a fresh week somewhere that has not heard of you', + next: () => this.scene.start('Night', { freshRun: true, seed: this.log.seed + 13 }), + }; + } + if (r.weekDone) { + return { + prompt: 'WEEK SURVIVED. click for a new week (new seed, same rain)', + next: () => this.scene.start('Night', { freshRun: true, seed: this.log.seed + 7 }), + }; + } + const failedNight = this.log.endReason !== 'clock'; + const nextName = failedNight + ? (NIGHT_NAMES[r.nightIndex] ?? 'the same night') + : (NIGHT_NAMES[r.nightIndex + 1] ?? 'the next night'); + return { + prompt: failedNight ? `click to run ${nextName} back` : `click for ${nextName} · ${NIGHT_DATES[r.nightIndex + 1] ?? ''}`, + next: () => this.scene.start('Night', {}), + }; } private drawVibeGraph(x: number, y: number, w: number, h: number): void { @@ -185,17 +225,16 @@ export class NightSummaryScene extends Phaser.Scene { 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); + const reason = this.log.endReason; + const body = + reason !== 'clock' + ? FAIL_TEXT[reason].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', + color: reason !== 'clock' ? '#ffb0b0' : '#bfe8c8', wordWrap: { width: w - 16 }, }); diff --git a/src/scenes/door/StampFx.ts b/src/scenes/door/StampFx.ts deleted file mode 100644 index b6b6a11..0000000 --- a/src/scenes/door/StampFx.ts +++ /dev/null @@ -1,147 +0,0 @@ -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()); - } -} diff --git a/src/scenes/floor/FloorDemoScene.ts b/src/scenes/floor/FloorDemoScene.ts index 01892e6..d5352f2 100644 --- a/src/scenes/floor/FloorDemoScene.ts +++ b/src/scenes/floor/FloorDemoScene.ts @@ -20,14 +20,19 @@ 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'; +import { MeterHud } from '../../ui/MeterHud'; +import type { NightContext } from '../door/NightScene'; +import type { Patron } from '../../data/types'; const VIEW_H = 360; const INTERACT_RANGE = 46; const MAX_CROWD = 44; const SPAWN_EVERY_MS = 420; +/** Admitted at the rope → appears on the floor after the walk in. */ +const WALK_IN_MS: [number, number] = [6000, 14000]; /** What pressing E on the current target would do. */ -type InteractKind = 'drunk' | 'stall' | 'contraband' | 'noStamp'; +type InteractKind = 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'exit'; interface InteractTarget { kind: InteractKind; @@ -37,19 +42,28 @@ interface InteractTarget { } /** - * 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[]. + * The venue floor. Two modes, one scene class: + * - 'FloorDemo' (standalone): self-populates from the patron generator, owns its + * own bus/clock/meters. The Phase-1 demo, unchanged. + * - 'Floor' (night mode): launched by NightScene with a NightContext. Shares the + * night's bus/clock/state, is populated by ACTUAL door admissions, runs its + * sim all night (even while the player works the rope), and hands the player + * back to the door via the exit. */ export class FloorDemoScene extends Phaser.Scene { private bus!: EventBus; private rng!: SeededRNG; private clock!: GameClock; - private beat!: StubBeatClock; - private meters!: Meters; + private beat: StubBeatClock | null = null; + private meters: Meters | null = null; private night!: NightState; - private hud!: HudStub; + private hud!: HudStub | MeterHud; + private nightCtx: NightContext | null = null; + /** In night mode: is the player physically on the floor right now? */ + private present = true; + private elapsedMs = 0; + private pendingSpawns: Array<{ patron: Patron; atMs: number }> = []; + private ctxOffs: Array<() => void> = []; private view!: FloorView; private crowd!: CrowdSim; private player!: PlayerState; @@ -73,11 +87,13 @@ export class FloorDemoScene extends Phaser.Scene { private radioText!: Phaser.GameObjects.Text; private radioMs = 0; - constructor() { - super('FloorDemo'); + constructor(key: string = 'FloorDemo') { + super(key); } - create(): void { + create(data?: { night?: NightContext }): void { + this.nightCtx = data?.night ?? null; + this.events.once('shutdown', () => this.shutdown()); this.cameras.main.setBounds(0, 0, VENUE.width * 16, VENUE.height * 16); this.cameras.main.setBackgroundColor('#04040a'); @@ -98,7 +114,49 @@ export class FloorDemoScene extends Phaser.Scene { // exactly one thing consumes input at a time. this.input.keyboard!.on('keydown', (ev: KeyboardEvent) => this.onKey(ev)); - this.resetRun(this.seed); + if (this.nightCtx) this.bindNight(this.nightCtx); + else this.resetRun(this.seed); + } + + /** Night mode: adopt the night's world instead of building our own. */ + private bindNight(ctx: NightContext): void { + this.seed = ctx.seed; + this.bus = ctx.bus; + this.rng = ctx.rng; + this.clock = ctx.clock; + this.night = ctx.state; + this.beat = null; // beat:tick arrives on the shared bus (stub, then TechnoEngine) + this.meters = null; // NightScene owns the single writer + this.hud = new MeterHud(this, this.bus, { initialHeat: this.night.heatStrikes.length }); + this.stall = new StallOverlay(this, this.bus, ctx.beatIntervalMs); + + this.crowd = new CrowdSim({ map: VENUE, bus: this.bus, rng: this.rng.stream('crowd') }); + + 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.present = false; // the night starts at the rope + this.elapsedMs = 0; + this.pendingSpawns = []; + this.maggotSeen.clear(); + this.spotted.clear(); + + this.ctxOffs = [ + // The floor is fed by the door: every admission walks in a few seconds later. + this.bus.on('door:verdict', ({ patron, verdict }) => { + if (verdict !== 'admit') return; + const delay = this.rng.stream('floorArrive').int(WALK_IN_MS[0], WALK_IN_MS[1]); + this.pendingSpawns.push({ patron, atMs: this.elapsedMs + delay }); + }), + this.bus.on('night:phaseChange', ({ location }) => { + this.present = location === 'floor'; + if (this.present) this.target = null; + }), + this.bus.on('beat:tick', ({ beatIndex }) => this.crowd.onBeat(beatIndex)), + ]; } private fixedText(x: number, y: number, colour: string): Phaser.GameObjects.Text { @@ -159,7 +217,7 @@ export class FloorDemoScene extends Phaser.Scene { this.uvMode = !this.uvMode; return; } - if (key === 'R') return this.resetRun(this.seed + 1); + if (key === 'R' && !this.nightCtx) return this.resetRun(this.seed + 1); if (key === 'E') return this.interact(); } @@ -188,8 +246,33 @@ export class FloorDemoScene extends Phaser.Scene { // 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); + // (In night mode NightScene drives both; this scene just consumes the bus.) + if (!this.nightCtx) { + this.clock.update(dt); + this.beat?.update(dt); + } + + this.elapsedMs += dt; + if (this.nightCtx) { + for (let i = this.pendingSpawns.length - 1; i >= 0; i--) { + const s = this.pendingSpawns[i]!; + if (this.elapsedMs < s.atMs) continue; + this.pendingSpawns.splice(i, 1); + // Licensed capacity is 80 but 44 agents is the sim's perf budget; past + // that, arrivals dissolve into the (conceptual) back room. log()-style + // honesty: this is a cap, not a simulation. + if (this.crowd.agents.length < MAX_CROWD) this.crowd.spawn(s.patron); + } + + // Player at the door: the floor keeps living (drinks keep being drunk, + // stalls keep filling, maggots keep ripening → radio pressure), but + // nothing needs the camera, the cone, or input. + if (!this.present) { + this.crowd.update(dt, this.clock.clockMin); + this.trackMaggots(); + return; + } + } if (this.overlayOpen) { this.cutOff.update(dt); @@ -206,12 +289,17 @@ export class FloorDemoScene extends Phaser.Scene { 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(); + if (!this.nightCtx) { + // Demo mode stands in for the door with a synthetic spawner. + this.spawnAccMs += dt; + while (this.spawnAccMs > SPAWN_EVERY_MS && this.crowd.agents.length < MAX_CROWD) { + this.spawnAccMs -= SPAWN_EVERY_MS; + this.spawnOne(); + } } + if (this.hud instanceof MeterHud) this.hud.update(dt); + this.crowd.update(dt, this.clock.clockMin); if (this.escorting) this.tickEscort(dt); @@ -271,6 +359,15 @@ export class FloorDemoScene extends Phaser.Scene { private findTarget(): InteractTarget | null { const spec = this.coneSpec(); + // Night mode: the way back out to the rope is itself an interaction. + if (this.nightCtx) { + const entry = VENUE.anchors.entry[0]; + if (entry) { + const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, entry.x, entry.y); + if (d <= INTERACT_RANGE) return { kind: 'exit', prompt: 'E — back out to the door' }; + } + } + // 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); @@ -318,6 +415,7 @@ export class FloorDemoScene extends Phaser.Scene { const t = this.target; if (!t || this.escorting) return; + if (t.kind === 'exit') return this.nightCtx?.requestLocation('door'); 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'); @@ -418,11 +516,14 @@ export class FloorDemoScene extends Phaser.Scene { } shutdown(): void { + for (const off of this.ctxOffs) off(); + this.ctxOffs = []; this.crowd?.destroy(); this.view?.destroy(); this.cutOff?.destroy(); this.stall?.destroy(); this.patDown?.destroy(); + // Night mode: the bus/meters belong to NightScene — touch nothing shared. this.meters?.destroy(); this.hud?.destroy(); } diff --git a/src/ui/MeterHud.ts b/src/ui/MeterHud.ts index 70e81f0..f609034 100644 --- a/src/ui/MeterHud.ts +++ b/src/ui/MeterHud.ts @@ -6,6 +6,12 @@ export interface MeterHudOptions { x?: number; y?: number; depth?: number; + /** + * Strikes already on the licence when this HUD is built (review ruling: read + * `NightState.heatStrikes.length` at construction — heat is run-scoped and a + * mid-run scene swap must not reset the display to zero). + */ + initialHeat?: number; } const BAR_X = 30; @@ -165,6 +171,11 @@ export class MeterHud { this.slots.push({ mark, scale: 1 }); } + for (let i = 0; i < Math.min(opts.initialHeat ?? 0, HEAT_SLOTS); i++) { + this.strikes++; + this.slots[i]?.mark.setVisible(true); + } + this.offs.push( bus.on('meters:changed', ({ vibe, aggro, hype }) => { this.vibe = vibe;