Phase 2: night-flow integration — door/floor interleave, Kayden, TechnoEngine in the night, run progression; Phase 3 lane instructions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-19 20:20:18 +10:00
parent 595371f684
commit 0f7b948439
15 changed files with 776 additions and 258 deletions

View File

@ -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

View File

@ -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
614s 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.

View File

@ -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 24, 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 24, 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

74
lanes/LANE_CONTENT.md Normal file
View File

@ -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, 814 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)
23 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 (34 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 14 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.

62
lanes/LANE_FLOOR2.md Normal file
View File

@ -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 (23 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.

59
lanes/LANE_JUICE2.md Normal file
View File

@ -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.

View File

@ -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 {

View File

@ -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. */

View File

@ -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)

View File

@ -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<Verdict, 'admit' | 'deny'> = 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 });

View File

@ -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<string>();
private lastDazzaMin = -99;
private lastVibeSampleMin = -99;
private lastMaggotRadioMin = -99;
private readonly firedPulls = new Set<number>();
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();
}
}

View File

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

View File

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

View File

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

View File

@ -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;