From 3eee8432d4e15f3eac4e6f7fd4b9ded202329bce Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 19 Jul 2026 18:49:13 +1000 Subject: [PATCH] =?UTF-8?q?LANE-FLOOR:=20the=20Floor=20=E2=80=94=20patrol,?= =?UTF-8?q?=20flashlight/UV,=20cut-off,=20stalls,=20pat-down?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FloorDemoScene (F from Parade): an 80x45-tile venue you patrol in the dark with a flashlight cone that reveals detail, plus all three v0.2 infractions and the UV stamp check. Self-populates from the patron generator; consumes Patron[] only, so Phase 2 can hand it the real admitted list. Pure, tested logic (venueMap, cone, player, crowdSim, bangJudge, escalation, patDown); Phaser layer stays thin (FloorView + three overlays). 120 new tests, 169 total. Two bugs found by running it rather than by the suite, both fixed: - bump penalties had a per-agent cooldown but no sim-wide bound, so the leak rate scaled with crowd size and an unattended venue pinned vibe to 0 / aggro to 100 by ~1AM. Sim-wide gate added, with a regression test. - beat.update() sat below the overlay early-return, freezing the beat clock behind modals and making the stall rhythm game unwinnable. Clock and beat now always run. Also retuned dwell times and the tile/darkness palette for readability, and dropped Light2D in favour of the cone mask it was fighting. Co-Authored-By: Claude Opus 4.8 --- LANEHANDOVER.md | 83 +++ src/main.ts | 3 +- src/scenes/floor/FloorDemoScene.ts | 431 +++++++++++++++ src/scenes/floor/FloorView.ts | 301 ++++++++++ src/scenes/floor/bangJudge.ts | 76 +++ src/scenes/floor/cone.ts | 65 +++ src/scenes/floor/crowdSim.ts | 574 ++++++++++++++++++++ src/scenes/floor/escalation.ts | 98 ++++ src/scenes/floor/overlays/CutOffOverlay.ts | 216 ++++++++ src/scenes/floor/overlays/PatDownOverlay.ts | 469 ++++++++++++++++ src/scenes/floor/overlays/StallOverlay.ts | 334 ++++++++++++ src/scenes/floor/patDown.ts | 120 ++++ src/scenes/floor/player.ts | 76 +++ src/scenes/floor/strings.ts | 91 ++++ src/scenes/floor/venueMap.ts | 175 ++++++ src/scenes/shared/ParadeScene.ts | 3 +- tests/floor/bangJudge.test.ts | 149 +++++ tests/floor/cone.test.ts | 167 ++++++ tests/floor/crowdSim.test.ts | 362 ++++++++++++ tests/floor/escalation.test.ts | 139 +++++ tests/floor/patDown.test.ts | 163 ++++++ tests/floor/player.test.ts | 134 +++++ tests/floor/venueMap.test.ts | 228 ++++++++ 23 files changed, 4455 insertions(+), 2 deletions(-) create mode 100644 src/scenes/floor/FloorDemoScene.ts create mode 100644 src/scenes/floor/FloorView.ts create mode 100644 src/scenes/floor/bangJudge.ts create mode 100644 src/scenes/floor/cone.ts create mode 100644 src/scenes/floor/crowdSim.ts create mode 100644 src/scenes/floor/escalation.ts create mode 100644 src/scenes/floor/overlays/CutOffOverlay.ts create mode 100644 src/scenes/floor/overlays/PatDownOverlay.ts create mode 100644 src/scenes/floor/overlays/StallOverlay.ts create mode 100644 src/scenes/floor/patDown.ts create mode 100644 src/scenes/floor/player.ts create mode 100644 src/scenes/floor/strings.ts create mode 100644 src/scenes/floor/venueMap.ts create mode 100644 tests/floor/bangJudge.test.ts create mode 100644 tests/floor/cone.test.ts create mode 100644 tests/floor/crowdSim.test.ts create mode 100644 tests/floor/escalation.test.ts create mode 100644 tests/floor/patDown.test.ts create mode 100644 tests/floor/player.test.ts create mode 100644 tests/floor/venueMap.test.ts diff --git a/LANEHANDOVER.md b/LANEHANDOVER.md index 05b1f95..e1557e9 100644 --- a/LANEHANDOVER.md +++ b/LANEHANDOVER.md @@ -95,3 +95,86 @@ walking under an ID card claiming 18+ — that's the whole game in one image. DOOR first, JUICE second, FLOOR third. 3. Every session ends with the gate + a SESSION block here + a push. Fable reviews on request ("review the lanes"). + +--- +### SESSION — LANE-FLOOR — 2026-07-19 18:50 +**Branch/commits:** lane/floor @ 9805ff8..(this commit) — branched from main, worktree `../not-tonight-floor` +**Done:** +- `FloorDemoScene` (menu key **F** from Parade): 80x45-tile venue (1280x720, 2x screen, + camera-follow), 44-patron crowd sim, flashlight cone, UV mode, all three v0.2 + infractions + the stamp check, playable end-to-end. +- Pure logic, all tested: `venueMap` (programmatic layout + anchors + stalls), + `cone` (vision maths), `player` (heavy accel + wall slide), `crowdSim` + (archetype-weighted anchor wandering, clock-driven intoxication drift, beat-driven + dance bob, stall double-occupancy, escort-out), `bangJudge` (nearest-beat-boundary + timing), `escalation` (5x3 cut-off matrix), `patDown` (pocket layout + scoring). +- Phaser layer: `FloorView` (baked tile texture, silhouette/detail split with a + cone GeometryMask, neon glow pools, UV stamp dots) + three overlays + (`CutOffOverlay`, `StallOverlay`, `PatDownOverlay`). +- All floor dialogue in `src/scenes/floor/strings.ts` (see deviation #1). +**How to see it:** `npm run dev` → press **F** at the Parade → WASD patrol, **TAB** UV, +**E** interact, **SPACE** bang, **ESC** cancel, **R** reseed. Walk into the dark until +the torch finds someone. In Claude's preview pane drive frames per CLAUDE.md. +**Tests:** 169 passing / 0 failing (49 pre-existing + 120 new floor tests). +**Verified in-browser, not just green:** cut-off (correct rung ejects, one rung too +soft on a maggot lets them stagger free), escort walks to the exit and logs an +ejection, stall busts on 3 on-beat bangs and *never* busts on 8 off-beat ones, +pat-down bins an item (+2 vibe, right flavour line) and leaks it on timeout (-1.5), +UV shows the stamp glow and marches an unstamped patron out with a Kayden radio line. +**Broke / known-wonky:** +- **Two bugs the test suite could not catch, both found by running it and both fixed:** + 1. *Ambient meter collapse.* The bump penalty had a per-agent cooldown but no + sim-wide bound, so leak rate scaled with crowd size — an unattended venue + pinned vibe to 0 and aggro to 100 by ~1AM before the player touched anything. + Added a sim-wide gate (`BUMP_GATE_MS`); an unattended night now drifts + 50→27 vibe / 0→34 aggro. Regression test added. + 2. *Frozen beat behind modals.* `beat.update()` sat below the overlay early-return + in the scene loop, so opening the stall minigame stopped the beat clock and the + rhythm game could never receive a tick. Clock + beat now run regardless of overlays. +- Dwell times raised (2.5-9s → 7-22s): at the old values ~68% of the crowd was + permanently in transit and the bar/dance floor read as empty. +- Tile palette + darkness alpha retuned; at the original values the venue + multiplied down to near-black and you could not tell a dance floor from a wall. +- Light2D dropped in `FloorView` (documented in-file) — the pipeline swap fights the + cone GeometryMask, and the mask is the actual gameplay. The look is carried by a + darkness sheet + additive cone + neon glow pools instead. +- `GeometryMask` on a container is a WebGL-first path; fine under `Phaser.AUTO`, + would misbehave if anything ever forced the Canvas renderer. +- The 16x16 top-down doll reads as a coloured ring from above (dark hair over + outer-layer shoulders). Correct per Phase 0's `dollPlan`, just noting it. +- Stall interiors are deliberately sealed (`stall` walkable, `stallDoor` not), so + patrons are moved in/out via `StallDef.inside`/`.door` rather than pathed. +**Contract change requests:** +1. **`incident:log` event.** `core` exposes no incident API and `EventMap` is frozen, + so `FloorDemoScene` appends to `NightState.incidents` directly (marked + `// TODO(contract):`). Three lanes writing that array directly is exactly the + shared-mutable-state fight the bus discipline exists to prevent, and Phase 3's + inspector/audit needs a single writer. Requesting + `'incident:log': IncidentRecord` plus a handler in `core/meters.ts`. +2. **Aggro has no decay.** `Meters` only ratchets aggro upward, so it is a one-way + trip to 100 over a long night. Ambient pressure is now bounded (above), but a slow + downward drift on a quiet floor would make the meter a *state* rather than a + countdown. Flagging as a design/contract question, not patching it unilaterally. +**Questions for reviewer:** +1. **Floor strings placement.** CONTRACTS.md §7 says Aussie dialogue lives in + `src/data/strings/`, but `data/` is LANE-0's and lanes commit only in their own + dirs. Parked in `src/scenes/floor/strings.ts`. Move it to + `src/data/strings/floor.ts` on your word — it is a pure file move. +2. **`.gitignore` gap (affects every lane using a worktree).** It lists + `node_modules/` with a trailing slash, which does not match a *symlinked* + `node_modules` — worktrees that symlink it show `?? node_modules` as untracked. + I staged paths explicitly, but a lane running `git add -A` would commit a symlink + pointing at an absolute path on John's machine. One-char fix (drop the slash); + left alone since `.gitignore` is not in my lane. +3. Escalation numbers (clean +4 vibe, scene -3/+5 per rung of overreach) are + deliberately small so aggression never feels rewarded. Confirm the balance intent + before LANE-DOOR mirrors it at the rope. +**Next session should:** +- Phase 2 integration: replace the self-populating spawner with the real admitted-patron + list from LANE-DOOR (the scene already only ever consumes `Patron[]`). +- Swap `StubBeatClock` for LANE-JUICE's `TechnoEngine` — `StallOverlay` takes + `beatIntervalMs` in its constructor and reads `beat:tick` off the bus, so it should + be a one-line change. +- Fight-brewing infraction (design §3.2) is specced but NOT built — the only v0.2 + floor mechanic still missing. +- Replace `HudStub` with LANE-JUICE's real meter HUD. diff --git a/src/main.ts b/src/main.ts index 38c90af..b03613e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,7 @@ import Phaser from 'phaser'; import { BootScene } from './scenes/shared/BootScene'; import { ParadeScene } from './scenes/shared/ParadeScene'; +import { FloorDemoScene } from './scenes/floor/FloorDemoScene'; const game = new Phaser.Game({ type: Phaser.AUTO, @@ -13,7 +14,7 @@ const game = new Phaser.Game({ autoCenter: Phaser.Scale.CENTER_BOTH, zoom: Phaser.Scale.MAX_ZOOM, }, - scene: [BootScene, ParadeScene], + scene: [BootScene, ParadeScene, FloorDemoScene], }); // debug handle (harmless in prod; used by dev tooling) diff --git a/src/scenes/floor/FloorDemoScene.ts b/src/scenes/floor/FloorDemoScene.ts new file mode 100644 index 0000000..1c43bd9 --- /dev/null +++ b/src/scenes/floor/FloorDemoScene.ts @@ -0,0 +1,431 @@ +import Phaser from 'phaser'; +import { EventBus } from '../../core/EventBus'; +import { GameClock } from '../../core/GameClock'; +import { SeededRNG } from '../../core/SeededRNG'; +import { StubBeatClock } from '../../core/StubBeatClock'; +import { Meters, freshNightState } from '../../core/meters'; +import { drunkStage } from '../../rules/drunk'; +import { generatePatron, _resetPatronSerial } from '../../patrons/generator'; +import type { NightState } from '../../data/types'; +import { HudStub } from '../shared/HudStub'; + +import { VENUE } from './venueMap'; +import { NORMAL_CONE, UV_CONE, inCone, type ConeSpec } from './cone'; +import { CrowdSim, type Agent } from './crowdSim'; +import { freshPlayer, stepPlayer, type PlayerInput, type PlayerState } from './player'; +import { FloorView } from './FloorView'; +import { maggotOverdue } from './escalation'; +import { layoutPockets, type Pocket } from './patDown'; +import { CutOffOverlay } from './overlays/CutOffOverlay'; +import { StallOverlay } from './overlays/StallOverlay'; +import { PatDownOverlay } from './overlays/PatDownOverlay'; +import { HELP_TEXT, NO_STAMP_RADIO } from './strings'; + +const VIEW_H = 360; +const INTERACT_RANGE = 46; +const MAX_CROWD = 44; +const SPAWN_EVERY_MS = 420; + +/** What pressing E on the current target would do. */ +type InteractKind = 'drunk' | 'stall' | 'contraband' | 'noStamp'; + +interface InteractTarget { + kind: InteractKind; + agent?: Agent; + stallId?: string; + prompt: string; +} + +/** + * LANE-FLOOR demo: a populated venue you patrol in the dark, with all three v0.2 + * infractions plus the UV stamp check live. Self-populates from the patron + * generator; Phase 2 integration will hand it the real admitted-patron list + * instead, which is why everything downstream only ever sees Patron[]. + */ +export class FloorDemoScene extends Phaser.Scene { + private bus!: EventBus; + private rng!: SeededRNG; + private clock!: GameClock; + private beat!: StubBeatClock; + private meters!: Meters; + private night!: NightState; + private hud!: HudStub; + private view!: FloorView; + private crowd!: CrowdSim; + private player!: PlayerState; + + private cutOff!: CutOffOverlay; + private stall!: StallOverlay; + private patDown!: PatDownOverlay; + + private keys!: Record; + private uvMode = false; + private seed = 4207; + private spawnAccMs = 0; + private escorting: Agent | null = null; + private target: InteractTarget | null = null; + /** patronId -> clock minute we first saw them as a maggot (deferred heat risk). */ + private maggotSeen = new Map(); + private spotted = new Set(); + + private promptText!: Phaser.GameObjects.Text; + private statusText!: Phaser.GameObjects.Text; + private radioText!: Phaser.GameObjects.Text; + private radioMs = 0; + + constructor() { + super('FloorDemo'); + } + + create(): void { + this.cameras.main.setBounds(0, 0, VENUE.width * 16, VENUE.height * 16); + this.cameras.main.setBackgroundColor('#04040a'); + + this.view = new FloorView(this, VENUE); + this.cutOff = new CutOffOverlay(this); + this.patDown = new PatDownOverlay(this); + + this.promptText = this.fixedText(4, VIEW_H - 24, '#9fe8a0'); + this.statusText = this.fixedText(4, VIEW_H - 12, '#556'); + this.radioText = this.fixedText(4, 26, '#d8a020'); + this.fixedText(4, 14, '#445').setText(HELP_TEXT); + + this.keys = this.input.keyboard!.addKeys( + 'W,A,S,D,UP,DOWN,LEFT,RIGHT,E,TAB,SPACE,ESC,R', + ) as Record; + + // Overlays own no keyboard listeners of their own — the scene routes keys so + // exactly one thing consumes input at a time. + this.input.keyboard!.on('keydown', (ev: KeyboardEvent) => this.onKey(ev)); + + this.resetRun(this.seed); + } + + private fixedText(x: number, y: number, colour: string): Phaser.GameObjects.Text { + return this.add + .text(x, y, '', { fontFamily: 'monospace', fontSize: '8px', color: colour }) + .setScrollFactor(0) + .setDepth(4000); + } + + private resetRun(seed: number): void { + this.seed = seed; + _resetPatronSerial(); + + this.crowd?.destroy(); + this.stall?.destroy(); + this.bus?.removeAll(); + this.meters?.destroy(); + this.hud?.destroy(); + + this.bus = new EventBus(); + this.rng = new SeededRNG(seed); + this.clock = new GameClock(this.bus); + this.beat = new StubBeatClock(this.bus); + this.night = freshNightState('voltage', 0, 200); + this.night.location = 'floor'; + this.meters = new Meters(this.bus, this.night); + this.hud = new HudStub(this, this.bus); + this.stall = new StallOverlay(this, this.bus, this.beat.beatIntervalMs); + + this.crowd = new CrowdSim({ map: VENUE, bus: this.bus, rng: this.rng.stream('crowd') }); + this.bus.on('beat:tick', ({ beatIndex }) => this.crowd.onBeat(beatIndex)); + + const entry = VENUE.anchors.entry[0] ?? { x: 64, y: 64 }; + this.player = freshPlayer(entry.x, entry.y); + + this.escorting = null; + this.target = null; + this.uvMode = false; + this.spawnAccMs = 0; + this.maggotSeen.clear(); + this.spotted.clear(); + + this.clock.start(); + this.beat.start(); + } + + // ---- input ------------------------------------------------------------- + + private onKey(ev: KeyboardEvent): void { + const key = ev.key === ' ' ? 'SPACE' : ev.key.toUpperCase(); + + if (this.cutOff.isOpen) return this.cutOff.handleKey(key); + if (this.stall.isOpen) return this.stall.handleKey(key); + if (this.patDown.isOpen) return this.patDown.handleKey(key); + + if (key === 'TAB') { + ev.preventDefault(); + this.uvMode = !this.uvMode; + return; + } + if (key === 'R') return this.resetRun(this.seed + 1); + if (key === 'E') return this.interact(); + } + + private get overlayOpen(): boolean { + return this.cutOff.isOpen || this.stall.isOpen || this.patDown.isOpen; + } + + private readInput(): PlayerInput { + const k = this.keys; + const down = (a?: Phaser.Input.Keyboard.Key, b?: Phaser.Input.Keyboard.Key) => + Boolean(a?.isDown || b?.isDown); + return { + up: down(k['W'], k['UP']), + down: down(k['S'], k['DOWN']), + left: down(k['A'], k['LEFT']), + right: down(k['D'], k['RIGHT']), + }; + } + + // ---- the loop ---------------------------------------------------------- + + override update(_time: number, deltaMs: number): void { + const dt = Math.min(deltaMs, 50); + + // These run REGARDLESS of overlays. The music doesn't stop because you + // opened a cubicle door — and the stall minigame judges against beat:tick, + // so freezing the beat clock behind a modal makes it literally unplayable. + // The night clock keeps running too: time is the one thing you never get back. + this.clock.update(dt); + this.beat.update(dt); + + if (this.overlayOpen) { + this.cutOff.update(dt); + this.stall.update(dt); + this.patDown.update(dt); + // World keeps breathing behind the panel, but you can't move while you're + // dealing with someone — that's the point of the mechanic. + this.crowd.update(dt, this.clock.clockMin); + this.view.syncAgents(this.crowd.agents); + return; + } + + // Escorting costs you the walk: no interacting, slower going. + const input = this.readInput(); + this.player = stepPlayer(this.player, input, dt, VENUE); + + this.spawnAccMs += dt; + while (this.spawnAccMs > SPAWN_EVERY_MS && this.crowd.agents.length < MAX_CROWD) { + this.spawnAccMs -= SPAWN_EVERY_MS; + this.spawnOne(); + } + + this.crowd.update(dt, this.clock.clockMin); + if (this.escorting) this.tickEscort(dt); + + this.trackMaggots(); + this.target = this.escorting ? null : this.findTarget(); + + this.cameras.main.centerOn(this.player.x, this.player.y); + this.view.updatePlayer(this.player.x, this.player.y, this.player.facing); + this.view.updateCone(this.coneSpec(), this.uvMode); + this.view.syncAgents(this.crowd.agents); + this.view.setInteractTarget(this.target?.agent ?? null); + + this.drawHud(dt); + } + + private coneSpec(): ConeSpec { + const c = this.uvMode ? UV_CONE : NORMAL_CONE; + return { x: this.player.x, y: this.player.y, facing: this.player.facing, ...c }; + } + + private spawnOne(): void { + const patron = generatePatron( + { rng: this.rng, nightDate: this.clock.nightDate }, + this.clock.clockMin, + ); + // Standing in for the door phase: most people got stamped on the way in. + // The ones who didn't snuck past Kayden — that's the UV infraction. + patron.flags.uvStamped = this.rng.stream('floorStamp').chance(0.86); + this.crowd.spawn(patron); + } + + // ---- infraction detection --------------------------------------------- + + private trackMaggots(): void { + const now = this.clock.clockMin; + for (const a of this.crowd.agents) { + if (a.gone || a.handled) continue; + if (drunkStage(a.patron.intoxication) !== 'maggot') continue; + const seen = this.maggotSeen.get(a.patron.id); + if (seen === undefined) { + this.maggotSeen.set(a.patron.id, now); + this.spot(a.patron.id, 'drunk'); + } else if (maggotOverdue(seen, now)) { + this.maggotSeen.set(a.patron.id, now); // re-arm so it logs once per grace period + this.logIncident('maggotUnhandled', a.patron.id, 'Left on the floor past the grace period.'); + } + } + } + + private spot(patronId: string, kind: 'drunk' | 'stall' | 'contraband' | 'noStamp'): void { + const key = `${patronId}:${kind}`; + if (this.spotted.has(key)) return; + this.spotted.add(key); + this.bus.emit('floor:infractionSpotted', { patronId, kind }); + } + + private findTarget(): InteractTarget | null { + const spec = this.coneSpec(); + + // Stall doors first — you interact with the door, not a person. + for (const s of VENUE.stalls) { + const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, s.door.x, s.door.y); + if (d > INTERACT_RANGE) continue; + const occupants = this.crowd.stallOccupancy().get(s.id) ?? []; + if (occupants.length < 2) continue; + this.spot(s.id, 'stall'); + return { kind: 'stall', stallId: s.id, prompt: 'E — two pairs of feet under that door' }; + } + + let best: Agent | null = null; + let bestDist = INTERACT_RANGE; + for (const a of this.crowd.agents) { + if (a.gone || a.handled || a.activity === 'inStall' || a.activity === 'escorted') continue; + if (!inCone(spec, a.x, a.y)) continue; + const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, a.x, a.y); + if (d < bestDist) { + bestDist = d; + best = a; + } + } + if (!best) return null; + + // UV mode is a different lens on the same crowd: it only surfaces stamps. + if (this.uvMode) { + if (best.patron.flags.uvStamped) return null; + this.spot(best.patron.id, 'noStamp'); + return { kind: 'noStamp', agent: best, prompt: 'E — no stamp. Walk them back to the door' }; + } + + const stage = drunkStage(best.patron.intoxication); + if (stage === 'messy' || stage === 'maggot' || stage === 'loose') { + return { kind: 'drunk', agent: best, prompt: 'E — have a word' }; + } + if (best.patron.flags.contraband?.length) { + this.spot(best.patron.id, 'contraband'); + return { kind: 'contraband', agent: best, prompt: 'E — pat them down' }; + } + return null; + } + + // ---- interactions ------------------------------------------------------ + + private interact(): void { + const t = this.target; + if (!t || this.escorting) return; + + if (t.kind === 'stall' && t.stallId) return this.openStall(t.stallId); + if (!t.agent) return; + if (t.kind === 'noStamp') return this.startEscort(t.agent, 'clean', 'noStamp'); + if (t.kind === 'drunk') return this.openCutOff(t.agent); + if (t.kind === 'contraband') return this.openPatDown(t.agent); + } + + private openStall(stallId: string): void { + const occupants = this.crowd.stallOccupancy().get(stallId) ?? []; + this.stall.open( + { stallId, patrons: occupants.map((a) => a.patron) }, + (r) => { + this.bus.emit('meters:delta', { vibe: r.vibeDelta, aggro: r.aggroDelta }); + if (r.solved) { + // Zeroing the dwell makes the sim's own stall-exit path fire next + // frame: both get put back at the door and independently re-target, + // which reads exactly like scattering in opposite directions. + for (const a of occupants) { + a.handled = true; + a.dwellMs = 0; + } + this.logIncident('stallBusted', occupants[0]?.patron.id, 'Two out of one cubicle.'); + } + }, + ); + } + + private openCutOff(agent: Agent): void { + const stage = drunkStage(agent.patron.intoxication); + this.cutOff.open({ patron: agent.patron, stage }, (r) => { + if (!r) return; + this.bus.emit('meters:delta', { vibe: r.vibeDelta, aggro: r.aggroDelta }); + if (r.resolved) agent.handled = true; + if (r.eject) { + this.startEscort(agent, r.outcome === 'clean' ? 'clean' : 'messy', 'drunk'); + } else { + this.logIncident('cutOff', agent.patron.id, `${stage} — ${r.outcome}`); + } + }); + } + + private openPatDown(agent: Agent): void { + const pockets: Pocket[] = layoutPockets(agent.patron, this.rng.stream('patdown')); + this.patDown.open({ patron: agent.patron, pockets }, (r) => { + this.bus.emit('meters:delta', { vibe: r.vibeDelta, aggro: r.aggroDelta }); + agent.handled = true; + this.logIncident('patDown', agent.patron.id, r.summary); + if (r.eject) this.startEscort(agent, 'clean', 'contraband'); + }); + } + + private startEscort(agent: Agent, style: 'clean' | 'messy', kind: InteractKind): void { + if (!this.crowd.beginEscort(agent)) return; + agent.handled = true; + this.escorting = agent; + if (kind === 'noStamp') { + this.radio(NO_STAMP_RADIO[this.rng.stream('radio').int(0, NO_STAMP_RADIO.length - 1)] ?? ''); + } + this.bus.emit('floor:ejection', { patronId: agent.patron.id, style }); + this.logIncident('ejection', agent.patron.id, `${kind} / ${style}`); + } + + private tickEscort(dt: number): void { + const a = this.escorting; + if (!a) return; + this.crowd.updateEscorts(this.player.x, this.player.y, dt); + if (a.gone) { + this.escorting = null; + this.bus.emit('meters:delta', { vibe: 1 }); + } + } + + // ---- chrome ------------------------------------------------------------ + + private radio(line: string): void { + this.radioText.setText(line ? `KAYDEN: ${line}` : ''); + this.radioMs = 5000; + } + + private logIncident(kind: string, patronId: string | undefined, detail: string): void { + // TODO(contract): core has no incident API and EventMap is frozen, so the + // floor appends to its own NightState. Requested `incident:log` in LANEHANDOVER. + this.night.incidents.push({ clockMin: this.clock.clockMin, kind, patronId, detail }); + } + + private drawHud(dt: number): void { + if (this.radioMs > 0) { + this.radioMs -= dt; + if (this.radioMs <= 0) this.radioText.setText(''); + } + this.promptText.setText( + this.escorting ? 'escorting — walk them to the door (no interacting)' : (this.target?.prompt ?? ''), + ); + const maggots = this.crowd.agents.filter( + (a) => !a.gone && !a.handled && drunkStage(a.patron.intoxication) === 'maggot', + ).length; + this.statusText.setText( + `${this.clock.label} seed ${this.seed} crowd ${this.crowd.agents.length} maggots ${maggots} ${this.uvMode ? 'UV' : 'torch'} incidents ${this.night.incidents.length}`, + ); + } + + shutdown(): void { + this.crowd?.destroy(); + this.view?.destroy(); + this.cutOff?.destroy(); + this.stall?.destroy(); + this.patDown?.destroy(); + this.meters?.destroy(); + this.hud?.destroy(); + } +} diff --git a/src/scenes/floor/FloorView.ts b/src/scenes/floor/FloorView.ts new file mode 100644 index 0000000..ce81d66 --- /dev/null +++ b/src/scenes/floor/FloorView.ts @@ -0,0 +1,301 @@ +import Phaser from 'phaser'; +import { renderDoll } from '../../patrons/doll'; +import { dollPlan } from '../../patrons/dollPlan'; +import { conePolygon, type ConeSpec } from './cone'; +import { TILE, tileAt, tileColour, type VenueMap } from './venueMap'; +import type { Agent } from './crowdSim'; + +// Pure drawing for the floor. Darkness is the mechanic: the venue is painted +// once into a flat texture, buried under a black sheet, and the torch cone is +// the only thing that puts colour back on a punter's face. + +// Phaser Light2D is deliberately NOT used — the pipeline swap fights the +// geometry mask that drives the reveal, and the mask is the gameplay. + +const TILES_TEX = 'floor:tiles'; +const GLOW_TEX = 'floor:glow'; +const PLAYER_TEX = 'floor:player'; +const MARKER_TEX = 'floor:marker'; + +const D_TILES = 0; +const D_SIL = 10; +const D_DARK = 15; +const D_CONE = 16; +const D_NEON = 17; +const D_DETAIL = 20; +const D_MARKER = 40; + +// A body in the dark should read as a presence, not vanish — the cone supplies +// identity (who, what they're wearing, how drunk), never the fact of them. +const SIL_TINT = 0x3d3d55; +const CONE_WARM = 0xffe0a8; +const CONE_UV = 0x7b2fd0; +const UV_STAMP = 0x50ff90; +const NEON_TINTS = [0xff3d9a, 0x38e0ff, 0xb45cff] as const; + +const GLOW_SIZE = 64; + +interface AgentGfx { + sil: Phaser.GameObjects.Image; + detail: Phaser.GameObjects.Image; + uvDot: Phaser.GameObjects.Image; + swayPx: number; + intox: number; + phase: number; // sway offset from dollSeed, so two neighbours don't wobble in lockstep + stamp: number; +} + +export class FloorView { + private readonly scene: Phaser.Scene; + private readonly map: VenueMap; + + private readonly tileImg: Phaser.GameObjects.Image; + private readonly neon: Phaser.GameObjects.Image[] = []; + private readonly dark: Phaser.GameObjects.Rectangle; + private readonly coneG: Phaser.GameObjects.Graphics; + private readonly maskG: Phaser.GameObjects.Graphics; + private readonly mask: Phaser.Display.Masks.GeometryMask; + private readonly detailC: Phaser.GameObjects.Container; + private readonly playerLight: Phaser.GameObjects.Image; + private readonly playerImg: Phaser.GameObjects.Image; + private readonly marker: Phaser.GameObjects.Image; + + private readonly agents = new Map(); + private frame = 0; + private uv = false; + + constructor(scene: Phaser.Scene, map: VenueMap) { + this.scene = scene; + this.map = map; + const worldW = map.width * TILE; + const worldH = map.height * TILE; + + this.bakeGlow(); + this.bakePlayer(); + this.bakeMarker(); + this.bakeTiles(worldW, worldH); + + this.tileImg = scene.add.image(0, 0, TILES_TEX).setOrigin(0, 0).setDepth(D_TILES); + + this.dark = scene.add + .rectangle(0, 0, worldW, worldH, 0x000000, 0.5) + .setOrigin(0, 0) + .setDepth(D_DARK); + + this.coneG = scene.add.graphics().setDepth(D_CONE); + this.coneG.setBlendMode(Phaser.BlendModes.ADD); + + this.playerLight = scene.add + .image(0, 0, GLOW_TEX) + .setDepth(D_CONE) + .setBlendMode(Phaser.BlendModes.ADD) + .setTint(0xffd9a0) + .setAlpha(0.35) + .setScale(1.1); + + // Neon lives above the black sheet so the room has colour pools you can + // navigate by with the torch off. + for (let ty = 0; ty < map.height; ty++) { + for (let tx = 0; tx < map.width; tx++) { + if (tileAt(map, tx, ty) !== 'neon') continue; + const tint = NEON_TINTS[(tx * 7 + ty * 3) % NEON_TINTS.length] ?? NEON_TINTS[0]; + this.neon.push( + scene.add + .image(tx * TILE + TILE / 2, ty * TILE + TILE / 2, GLOW_TEX) + .setDepth(D_NEON) + .setBlendMode(Phaser.BlendModes.ADD) + .setTint(tint) + .setAlpha(0.72) + .setScale(3), + ); + } + } + + this.maskG = scene.make.graphics({}, false); + this.mask = this.maskG.createGeometryMask(); + this.detailC = scene.add.container(0, 0).setDepth(D_DETAIL); + this.detailC.setMask(this.mask); + + this.playerImg = scene.add.image(0, 0, PLAYER_TEX).setDepth(D_MARKER); + this.marker = scene.add.image(0, 0, MARKER_TEX).setDepth(D_MARKER).setVisible(false); + } + + syncAgents(agents: readonly Agent[]): void { + this.frame++; + const t = this.scene.time.now; + + for (const a of agents) { + if (a.gone) { + this.drop(a.patron.id); + continue; + } + let g = this.agents.get(a.patron.id); + if (!g) { + g = this.create(a); + this.agents.set(a.patron.id, g); + } + g.stamp = this.frame; + + // Drunk tells are baked into the doll texture, so a punter who has kept + // drinking needs a fresh one — but only when they actually have. + if (Math.abs(g.intox - a.patron.intoxication) > 1e-3) { + g.intox = a.patron.intoxication; + const key = renderDoll(this.scene, a.patron, 'floorTopDown'); + g.swayPx = dollPlan(a.patron, 'floorTopDown').swayPx; + g.sil.setTexture(key); + g.detail.setTexture(key); + } + + const bob = a.activity === 'dancing' ? 2.5 : 0.8; + const y = a.y - Math.abs(Math.sin(a.bobPhase * Math.PI * 2)) * bob; + const x = g.swayPx > 0 ? a.x + Math.sin(t / 260 + g.phase) * g.swayPx : a.x; + + g.sil.setPosition(x, y); + g.detail.setPosition(x, y); + g.uvDot.setPosition(x + 4, y + 2); + g.detail.setVisible(!this.uv); + // No stamp, no dot — the absence is the tell. + g.uvDot.setVisible(this.uv && a.patron.flags.uvStamped === true); + } + + for (const [id, g] of this.agents) { + if (g.stamp !== this.frame) { + this.destroyGfx(g); + this.agents.delete(id); + } + } + } + + updateCone(spec: ConeSpec, uv: boolean): void { + this.uv = uv; + const pts = conePolygon(spec); + + this.maskG.clear(); + this.maskG.fillStyle(0xffffff, 1); + this.maskG.fillPoints(pts, true); + + // Wide wash plus a hotter core, so the beam reads as a beam and not a wedge + // of flat colour. + const tint = uv ? CONE_UV : CONE_WARM; + this.coneG.clear(); + this.coneG.fillStyle(tint, uv ? 0.16 : 0.2); + this.coneG.fillPoints(pts, true); + this.coneG.fillStyle(tint, uv ? 0.12 : 0.18); + this.coneG.fillPoints(conePolygon({ ...spec, range: spec.range * 0.55 }, 16), true); + + this.playerLight.setTint(uv ? CONE_UV : 0xffd9a0); + } + + updatePlayer(x: number, y: number, facing: number): void { + this.playerImg.setPosition(x, y).setRotation(facing); + this.playerLight.setPosition(x, y); + } + + setInteractTarget(agent: Agent | null): void { + if (!agent) { + this.marker.setVisible(false); + return; + } + this.marker.setVisible(true).setPosition(agent.x, agent.y - 12); + this.marker.setAlpha(0.55 + 0.45 * Math.abs(Math.sin(this.scene.time.now / 260))); + } + + destroy(): void { + for (const g of this.agents.values()) this.destroyGfx(g); + this.agents.clear(); + this.detailC.clearMask(true); + this.detailC.destroy(); + this.maskG.destroy(); + this.coneG.destroy(); + this.dark.destroy(); + this.tileImg.destroy(); + for (const n of this.neon) n.destroy(); + this.neon.length = 0; + this.playerLight.destroy(); + this.playerImg.destroy(); + this.marker.destroy(); + } + + private create(a: Agent): AgentGfx { + const key = renderDoll(this.scene, a.patron, 'floorTopDown'); + const sil = this.scene.add.image(a.x, a.y, key).setDepth(D_SIL).setTint(SIL_TINT); + const detail = this.scene.add.image(a.x, a.y, key); + const uvDot = this.scene.add + .image(a.x, a.y, GLOW_TEX) + .setBlendMode(Phaser.BlendModes.ADD) + .setTint(UV_STAMP) + .setScale(0.16) + .setVisible(false); + this.detailC.add(detail); + this.detailC.add(uvDot); + return { + sil, + detail, + uvDot, + swayPx: dollPlan(a.patron, 'floorTopDown').swayPx, + intox: a.patron.intoxication, + phase: (a.patron.dollSeed % 628) / 100, + stamp: this.frame, + }; + } + + private drop(id: string): void { + const g = this.agents.get(id); + if (!g) return; + this.destroyGfx(g); + this.agents.delete(id); + } + + private destroyGfx(g: AgentGfx): void { + g.sil.destroy(); + g.detail.destroy(); + g.uvDot.destroy(); + } + + private bakeTiles(worldW: number, worldH: number): void { + if (this.scene.textures.exists(TILES_TEX)) return; + const g = this.scene.make.graphics({}, false); + for (let ty = 0; ty < this.map.height; ty++) { + for (let tx = 0; tx < this.map.width; tx++) { + g.fillStyle(tileColour(tileAt(this.map, tx, ty)), 1); + g.fillRect(tx * TILE, ty * TILE, TILE, TILE); + } + } + g.generateTexture(TILES_TEX, worldW, worldH); + g.destroy(); + } + + private bakeGlow(): void { + if (this.scene.textures.exists(GLOW_TEX)) return; + const g = this.scene.make.graphics({}, false); + const c = GLOW_SIZE / 2; + // Stacked translucent discs — cheap radial falloff without a canvas gradient. + for (let i = 16; i >= 1; i--) { + g.fillStyle(0xffffff, 0.055); + g.fillCircle(c, c, (i / 16) * c); + } + g.generateTexture(GLOW_TEX, GLOW_SIZE, GLOW_SIZE); + g.destroy(); + } + + private bakePlayer(): void { + if (this.scene.textures.exists(PLAYER_TEX)) return; + const g = this.scene.make.graphics({}, false); + g.fillStyle(0x0a0a10, 1); + g.fillCircle(7, 7, 5); + g.fillStyle(0xdfe6ff, 1); + g.fillCircle(7, 7, 4); + g.fillRect(7, 6, 7, 3); // torch nose, points +x so rotation == facing + g.generateTexture(PLAYER_TEX, 14, 14); + g.destroy(); + } + + private bakeMarker(): void { + if (this.scene.textures.exists(MARKER_TEX)) return; + const g = this.scene.make.graphics({}, false); + g.fillStyle(0xffd23f, 1); + g.fillTriangle(0, 0, 8, 0, 4, 6); + g.generateTexture(MARKER_TEX, 8, 6); + g.destroy(); + } +} diff --git a/src/scenes/floor/bangJudge.ts b/src/scenes/floor/bangJudge.ts new file mode 100644 index 0000000..ac14efa --- /dev/null +++ b/src/scenes/floor/bangJudge.ts @@ -0,0 +1,76 @@ +export interface BeatTick { + beatIndex: number; + audioTimeMs: number; +} + +export type BangGrade = 'perfect' | 'ok' | 'miss'; + +export const PERFECT_MS = 60; +export const OK_MS = 130; +export const BANGS_TO_BUST = 3; + +const AGGRO_PER_MISS = 0.8; +const AGGRO_CAP = 6; +const VIBE_BUSTED = 9; +const VIBE_FIZZLED = -1.5; + +/** + * How far (ms, absolute) a bang landed from the NEAREST beat boundary implied by + * lastTick. Boundaries are lastTick.audioTimeMs + k*beatIntervalMs for integer k + * (k may be negative — a bang can land just BEFORE the tick it is judged against). + */ +export function beatError(bangTimeMs: number, lastTick: BeatTick, beatIntervalMs: number): number { + const delta = bangTimeMs - lastTick.audioTimeMs; + // Degenerate clock: every point is its own boundary, so the tick is all we have. + if (!(beatIntervalMs > 0)) return Math.abs(delta); + const nearest = Math.round(delta / beatIntervalMs) * beatIntervalMs; + return Math.abs(delta - nearest); +} + +export function judgeBang(bangTimeMs: number, lastTick: BeatTick, beatIntervalMs: number): BangGrade { + const err = beatError(bangTimeMs, lastTick, beatIntervalMs); + if (err <= PERFECT_MS) return 'perfect'; + if (err <= OK_MS) return 'ok'; + return 'miss'; +} + +export interface StallState { + combo: number; + bangs: number; + busted: boolean; + aggro: number; + lastGrade: BangGrade | null; +} + +export function freshStall(): StallState { + return { combo: 0, bangs: 0, busted: false, aggro: 0, lastGrade: null }; +} + +/** Pure: returns the NEXT state, never mutates the input. */ +export function applyBang(s: StallState, grade: BangGrade): StallState { + const hit = grade !== 'miss'; + const combo = hit ? s.combo + 1 : 0; + return { + combo, + bangs: s.bangs + 1, + // Once the door gives, it stays given. + busted: s.busted || combo >= BANGS_TO_BUST, + aggro: hit ? s.aggro : s.aggro + AGGRO_PER_MISS, + lastGrade: grade, + }; +} + +export interface StallOutcome { + vibeDelta: number; + aggroDelta: number; + solved: boolean; +} + +/** Final scoring when the stall minigame ends (busted, or the player walked away). */ +export function scoreStall(s: StallState): StallOutcome { + return { + vibeDelta: s.busted ? VIBE_BUSTED : VIBE_FIZZLED, + aggroDelta: Math.min(s.aggro, AGGRO_CAP), + solved: s.busted, + }; +} diff --git a/src/scenes/floor/cone.ts b/src/scenes/floor/cone.ts new file mode 100644 index 0000000..5c57800 --- /dev/null +++ b/src/scenes/floor/cone.ts @@ -0,0 +1,65 @@ +// Flashlight maths. Pure — the scene turns these numbers into a mask, the AI +// uses them to know whether it's being watched. + +import type { WorldPoint } from './venueMap'; + +export interface ConeSpec { + x: number; y: number; // world px, cone apex (the player) + facing: number; // radians + halfAngle: number; // radians + range: number; // world px +} + +export const NORMAL_CONE = { halfAngle: 0.55, range: 190 } as const; // ~63 deg wide +export const UV_CONE = { halfAngle: 0.42, range: 140 } as const; // tighter, shorter + +const TAU = Math.PI * 2; + +/** Shortest signed angular difference a-b, wrapped to (-PI, PI]. */ +export function angleDelta(a: number, b: number): number { + let d = (a - b) % TAU; + if (d <= -Math.PI) d += TAU; + else if (d > Math.PI) d -= TAU; + return d; +} + +export function inCone(spec: ConeSpec, x: number, y: number): boolean { + const dx = x - spec.x; + const dy = y - spec.y; + const dist = Math.hypot(dx, dy); + if (dist > spec.range) return false; + if (dist === 0) return true; // standing on the torch + return Math.abs(angleDelta(Math.atan2(dy, dx), spec.facing)) <= spec.halfAngle; +} + +/** + * 0 = invisible, 1 = fully lit. Falls off toward the cone edge AND with distance. + * Must return 0 outside the cone and >0 strictly inside it. + */ +export function coneVisibility(spec: ConeSpec, x: number, y: number): number { + const dx = x - spec.x; + const dy = y - spec.y; + const dist = Math.hypot(dx, dy); + if (dist > spec.range) return 0; + if (dist === 0) return 1; + const off = Math.abs(angleDelta(Math.atan2(dy, dx), spec.facing)); + if (off > spec.halfAngle) return 0; + const t = spec.halfAngle === 0 ? 0 : off / spec.halfAngle; + const d = spec.range === 0 ? 0 : dist / spec.range; + return (1 - t * t) * (1 - d * d); +} + +/** Polygon points (world px) approximating the cone, for a Phaser mask/graphics fill. */ +export function conePolygon(spec: ConeSpec, segments = 24): WorldPoint[] { + const steps = Math.max(1, Math.floor(segments)); + const apex: WorldPoint = { x: spec.x, y: spec.y }; + const pts: WorldPoint[] = [apex]; + const start = spec.facing - spec.halfAngle; + const span = spec.halfAngle * 2; + for (let i = 0; i <= steps; i++) { + const a = start + (span * i) / steps; + pts.push({ x: spec.x + Math.cos(a) * spec.range, y: spec.y + Math.sin(a) * spec.range }); + } + pts.push({ ...apex }); // explicit close so callers can stroke it without fixing up + return pts; +} diff --git a/src/scenes/floor/crowdSim.ts b/src/scenes/floor/crowdSim.ts new file mode 100644 index 0000000..c124eaa --- /dev/null +++ b/src/scenes/floor/crowdSim.ts @@ -0,0 +1,574 @@ +// The living crowd. Renderer-agnostic on purpose: this owns where everyone is +// and what they're doing, the Phaser layer just draws whatever it finds here. + +import type { EventBus } from '../../core/EventBus'; +import type { RngStream } from '../../core/SeededRNG'; +import type { Archetype, DrunkStage, Patron } from '../../data/types'; +import { drunkStage } from '../../rules/drunk'; +import { isWalkable, isWalkableWorld, tileToWorld, worldToTile } from './venueMap'; +import type { StallDef, VenueMap, WorldPoint } from './venueMap'; + +export type Activity = + | 'walking' | 'atBar' | 'dancing' | 'inBooth' | 'inStall' | 'smoking' | 'escorted' | 'leaving'; + +export interface Agent { + patron: Patron; + x: number; y: number; + vx: number; vy: number; + activity: Activity; + target: WorldPoint; + dwellMs: number; // ms left at the current anchor + bobPhase: number; // 0..1, advanced on beat:tick, drives the dance bob + stallId?: string; // set while activity === 'inStall' + handled: boolean; // player has already dealt with this one + gone: boolean; // reached the exit / left the sim; scene should drop the sprite + lastBumpMs: number; // cooldown so bumping can't spam meters +} + +export interface CrowdSimOpts { + map: VenueMap; + bus: EventBus; + rng: RngStream; +} + +type Haunt = 'bar' | 'dance' | 'booth' | 'toilet' | 'smoke'; + +const HAUNT_KINDS: readonly Haunt[] = ['bar', 'dance', 'booth', 'toilet', 'smoke']; + +// Where each sort of punter gravitates. Weights, not probabilities — every +// archetype needs an entry or the crowd stops moving. +const HAUNTS: Readonly>>> = { + fresh18: { bar: 3, dance: 6, booth: 2, toilet: 2, smoke: 1 }, + almost18: { bar: 2, dance: 5, booth: 3, toilet: 2, smoke: 2 }, + financeBro: { bar: 8, dance: 2, booth: 3, toilet: 2, smoke: 1 }, + influencer: { bar: 2, dance: 8, booth: 2, toilet: 2, smoke: 1 }, + regular: { bar: 6, dance: 2, booth: 3, toilet: 1, smoke: 4 }, + ownersMate: { bar: 7, dance: 2, booth: 5, toilet: 1, smoke: 2 }, + quietMessy: { bar: 3, dance: 1, booth: 7, toilet: 3, smoke: 2 }, + inspector: { bar: 2, dance: 1, booth: 4, toilet: 2, smoke: 1 }, + punter: { bar: 4, dance: 4, booth: 3, toilet: 2, smoke: 2 }, +}; + +const ACTIVITY_FOR: Readonly> = { + bar: 'atBar', + dance: 'dancing', + booth: 'inBooth', + toilet: 'inStall', + smoke: 'smoking', +}; + +const SPEED_MIN = 28; +const SPEED_MAX = 44; +// Transits across a 1280x720 venue take ~30s at these speeds, so short dwells +// left ~68% of the crowd permanently walking and the bar/dance floor reading as +// empty. The player has to be able to FIND infractions where they'd expect them. +const DWELL_MIN_MS = 7000; +const DWELL_MAX_MS = 22_000; +const ARRIVE_PX = 2.5; +const WAYPOINT_PX = 9; // half a tile — loose enough that a drunk sway can't orbit one +const MAX_STEP_MS = 50; +const STUCK_MS = 700; + +// Intoxication per in-game minute for a patron with zero tolerance. At the bar +// you're actively buying; everywhere else you're nursing what you've got. +const BAR_DRIFT_PER_MIN = 0.011; +const AMBIENT_DRIFT_PER_MIN = 0.0012; + +const SWAY_BY_STAGE: Readonly> = { + sober: 0, tipsy: 4, loose: 10, messy: 18, maggot: 26, +}; +const SWAY_RATE = 0.006; // radians per ms — roughly a one-second wobble +const STUMBLE_CHANCE_PER_SEC = 0.5; +const STUMBLE_MIN_MS = 180; +const STUMBLE_MAX_MS = 520; +const STUMBLE_ARC = 0.9; // worst heading error, radians + +const BUMP_DIST_PX = 10; +const BUMP_COOLDOWN_MS = 12_000; +// Per-agent cooldown alone doesn't bound the LEAK — the emission rate scales +// with how many rowdy patrons are on the floor, so a full late-night venue +// pinned vibe to 0 and aggro to 100 by ~1AM with the player doing nothing. +// This gate makes ambient chaos cost a roughly fixed amount per night +// regardless of crowd size; the player's own mistakes stay legible against it. +const BUMP_GATE_MS = 8000; +const BUMP_VIBE = -0.4; +const BUMP_AGGRO = 0.6; + +// Chance a dunny-bound patron shoulders into a cubicle that's already busy. +const STALL_SHARE_CHANCE = 0.3; +const STALL_CAP = 2; + +const ESCORT_SPEED = 22; // the walk of a bloke who knows it's over +const ESCORT_AHEAD_PX = 18; // they only shuffle on while you're this close behind +const BOB_STEP = 0.25; // four beats to a full bob, i.e. one bar + +const NEIGHBOURS = [[1, 0], [-1, 0], [0, 1], [0, -1]] as const; + +interface Plan { + haunt: Haunt; + stallId?: string; // reserved while walking, so the cap survives the trip over + path: WorldPoint[]; // detour waypoints; empty means steer straight at the target + bestDist: number; // closest approach to the current steer point so far + stuckMs: number; + stumbleMs: number; + stumbleAngle: number; +} + +function rowdy(a: Agent): boolean { + const s = drunkStage(a.patron.intoxication); + return s === 'messy' || s === 'maggot'; +} + +/** Deterministic per-patron pace — same doll, same dawdle, every run. */ +function paceOf(patron: Patron): number { + const span = SPEED_MAX - SPEED_MIN + 1; + return SPEED_MIN + (Math.abs(patron.dollSeed) % span); +} + +export class CrowdSim { + readonly agents: Agent[] = []; + + private readonly map: VenueMap; + private readonly bus: EventBus; + private readonly rng: RngStream; + private readonly plans = new WeakMap(); + private elapsedMs = 0; + private prevClockMin = Number.NaN; + private bumpGateMs = BUMP_GATE_MS; + + constructor(opts: CrowdSimOpts) { + this.map = opts.map; + this.bus = opts.bus; + this.rng = opts.rng; + } + + spawn(patron: Patron): Agent { + const entries = this.map.anchors.entry; + const at = entries.length > 0 ? this.rng.pick(entries) : { x: 0, y: 0 }; + const agent: Agent = { + patron, + x: at.x, + y: at.y, + vx: 0, + vy: 0, + activity: 'walking', + target: { x: at.x, y: at.y }, + dwellMs: 0, + bobPhase: (Math.abs(patron.dollSeed) % 100) / 100, + handled: false, + gone: false, + lastBumpMs: BUMP_COOLDOWN_MS, + }; + this.plans.set(agent, { + haunt: 'bar', path: [], bestDist: Infinity, stuckMs: 0, stumbleMs: 0, stumbleAngle: 0, + }); + this.agents.push(agent); + this.chooseTarget(agent); + return agent; + } + + /** deltaMs of real time; clockMin is the in-game minute (drives intoxication drift). */ + update(deltaMs: number, clockMin: number): void { + const ms = Math.max(0, Math.min(deltaMs, MAX_STEP_MS)); + this.elapsedMs += ms; + this.bumpGateMs += ms; + + this.drift(clockMin); + + for (const a of this.agents) { + if (a.gone) continue; + a.lastBumpMs += ms; + if (a.activity === 'escorted') continue; + if (a.activity === 'walking') this.walk(a, ms); + else this.dwell(a, ms); + } + + this.bumps(); + } + + /** Call from a bus.on('beat:tick') handler. Advances dance bob only. */ + onBeat(beatIndex: number): void { + // The downbeat lands harder than the rest of the bar. + const step = beatIndex % 4 === 0 ? BOB_STEP * 1.5 : BOB_STEP; + for (const a of this.agents) { + if (a.gone || a.activity !== 'dancing') continue; + a.bobPhase = (a.bobPhase + step) % 1; + } + } + + /** Agents currently inside each stall, keyed by stall id. */ + stallOccupancy(): Map { + const out = new Map(); + for (const s of this.map.stalls) out.set(s.id, []); + for (const a of this.agents) { + if (a.gone || a.activity !== 'inStall' || a.stallId === undefined) continue; + const list = out.get(a.stallId); + if (list) list.push(a); + else out.set(a.stallId, [a]); + } + return out; + } + + /** Attach to the player for a walk-to-the-exit escort. Returns false if already escorted. */ + beginEscort(agent: Agent): boolean { + if (agent.gone || agent.activity === 'escorted' || agent.activity === 'leaving') return false; + const exit = this.nearestExit(agent.x, agent.y); + if (!exit) return false; + + if (agent.activity === 'inStall') { + const stall = this.stallById(agent.stallId); + if (stall) { + agent.x = stall.door.x; + agent.y = stall.door.y; + } + } + agent.stallId = undefined; + agent.activity = 'escorted'; + agent.handled = true; + agent.dwellMs = 0; + agent.target = exit; + + const plan = this.plans.get(agent); + if (plan) { + plan.stallId = undefined; + plan.stumbleMs = 0; + plan.bestDist = Infinity; + plan.stuckMs = 0; + // Always route the perp walk — the door is a long way from the dunnies and + // a straight line would wedge them in a corner with you shouting at them. + plan.path = this.findPath(agent.x, agent.y, exit) ?? []; + } + return true; + } + + /** Called each frame with the player position so escorted agents walk ahead of you. */ + updateEscorts(playerX: number, playerY: number, deltaMs: number): void { + const dt = Math.max(0, Math.min(deltaMs, MAX_STEP_MS)) / 1000; + const step = ESCORT_SPEED * dt; + + for (const a of this.agents) { + if (a.gone || a.activity !== 'escorted') continue; + const plan = this.plans.get(a); + if (!plan) continue; + + const point = this.steerPoint(a, plan); + const dx = point.x - a.x; + const dy = point.y - a.y; + const dist = Math.hypot(dx, dy); + + if (plan.path.length === 0 && dist <= Math.max(ARRIVE_PX, step)) { + a.x = point.x; + a.y = point.y; + a.vx = 0; + a.vy = 0; + a.activity = 'leaving'; + a.gone = true; + continue; + } + + // They shuffle on only while you're breathing down their neck; stop pushing + // and they stop walking. + if (Math.hypot(playerX - a.x, playerY - a.y) > ESCORT_AHEAD_PX) { + a.vx = 0; + a.vy = 0; + continue; + } + + const ux = dx / (dist || 1); + const uy = dy / (dist || 1); + a.vx = ux * ESCORT_SPEED; + a.vy = uy * ESCORT_SPEED; + this.tryMove(a, ux * step, uy * step); + } + } + + find(patronId: string): Agent | undefined { + return this.agents.find((a) => a.patron.id === patronId); + } + + destroy(): void { + // Nothing to unsubscribe — beat ticks are pushed in by the owning scene. + this.agents.length = 0; + } + + // ---- internals ---- + + private drift(clockMin: number): void { + const prev = this.prevClockMin; + this.prevClockMin = clockMin; + if (Number.isNaN(prev)) return; + const mins = clockMin - prev; + if (mins <= 0) return; + + for (const a of this.agents) { + if (a.gone) continue; + const base = a.activity === 'atBar' ? BAR_DRIFT_PER_MIN : AMBIENT_DRIFT_PER_MIN; + a.patron.intoxication = Math.min(1, a.patron.intoxication + base * (1 - a.patron.tolerance) * mins); + } + } + + private dwell(a: Agent, ms: number): void { + a.vx = 0; + a.vy = 0; + a.dwellMs -= ms; + if (a.dwellMs > 0) return; + + if (a.activity === 'inStall') { + const stall = this.stallById(a.stallId); + if (stall) { + a.x = stall.door.x; + a.y = stall.door.y; + } + a.stallId = undefined; + } + a.dwellMs = 0; + a.activity = 'walking'; + this.chooseTarget(a); + } + + private walk(a: Agent, ms: number): void { + const plan = this.plans.get(a); + if (!plan) return; + + const dt = ms / 1000; + const speed = paceOf(a.patron); + const step = speed * dt; + const point = this.steerPoint(a, plan); + const dx = point.x - a.x; + const dy = point.y - a.y; + const dist = Math.hypot(dx, dy); + + if (plan.path.length === 0 && dist <= Math.max(ARRIVE_PX, step)) { + this.arrive(a, plan); + return; + } + + // Walked into geometry the straight line can't solve (the dunny room is one + // long concave corner). Fall back to a real route rather than pushing at a wall. + if (dist < plan.bestDist - 0.5) { + plan.bestDist = dist; + plan.stuckMs = 0; + } else { + plan.stuckMs += ms; + if (plan.stuckMs >= STUCK_MS) this.unstick(a, plan); + } + + let heading = Math.atan2(dy, dx); + const stage = drunkStage(a.patron.intoxication); + + if (plan.stumbleMs > 0) { + plan.stumbleMs -= ms; + heading += plan.stumbleAngle; + } else if ((stage === 'messy' || stage === 'maggot') && this.rng.chance(STUMBLE_CHANCE_PER_SEC * dt)) { + plan.stumbleMs = this.rng.int(STUMBLE_MIN_MS, STUMBLE_MAX_MS); + plan.stumbleAngle = (this.rng.next() * 2 - 1) * STUMBLE_ARC; + heading += plan.stumbleAngle; + } + + const ux = Math.cos(heading); + const uy = Math.sin(heading); + // Sway rides perpendicular to travel — the wider the wobble, the drunker the walk. + const sway = Math.sin(this.elapsedMs * SWAY_RATE + a.patron.dollSeed) * SWAY_BY_STAGE[stage]; + a.vx = ux * speed - uy * sway; + a.vy = uy * speed + ux * sway; + this.tryMove(a, a.vx * dt, a.vy * dt); + } + + /** Current thing to walk at: the next detour waypoint, else the target itself. */ + private steerPoint(a: Agent, plan: Plan): WorldPoint { + while (plan.path.length > 0) { + const wp = plan.path[0]; + if (!wp) { + plan.path.shift(); + continue; + } + if (Math.hypot(wp.x - a.x, wp.y - a.y) > WAYPOINT_PX) return wp; + plan.path.shift(); + plan.bestDist = Infinity; + plan.stuckMs = 0; + } + return a.target; + } + + private unstick(a: Agent, plan: Plan): void { + plan.stuckMs = 0; + plan.bestDist = Infinity; + const path = this.findPath(a.x, a.y, a.target); + if (path && path.length > 0) plan.path = path; + else this.chooseTarget(a); // nowhere to go from here — find somewhere else to be + } + + private arrive(a: Agent, plan: Plan): void { + a.vx = 0; + a.vy = 0; + a.dwellMs = this.rng.int(DWELL_MIN_MS, DWELL_MAX_MS); + + if (plan.haunt === 'toilet' && plan.stallId !== undefined) { + const stall = this.stallById(plan.stallId); + if (stall) { + a.activity = 'inStall'; + a.stallId = stall.id; + a.x = stall.inside.x; + a.y = stall.inside.y; + return; + } + } + a.x = a.target.x; + a.y = a.target.y; + a.activity = ACTIVITY_FOR[plan.haunt]; + } + + private chooseTarget(a: Agent): void { + const plan = this.plans.get(a); + if (!plan) return; + plan.stallId = undefined; + plan.path = []; + plan.bestDist = Infinity; + plan.stuckMs = 0; + + const weights = HAUNTS[a.patron.archetype]; + let haunt = this.rng.weighted(HAUNT_KINDS.map((k) => [k, weights[k]] as const)); + + if (haunt === 'toilet') { + const stall = this.claimStall(); + if (stall) { + plan.haunt = 'toilet'; + plan.stallId = stall.id; + a.target = stall.door; + return; + } + haunt = 'bar'; // every dunny's full — go queue for another drink instead + } + + const spots = this.map.anchors[haunt]; + plan.haunt = haunt; + if (spots.length > 0) a.target = this.rng.pick(spots); + } + + /** Reserve a cubicle, occasionally squeezing into one that's already busy. */ + private claimStall(): StallDef | undefined { + const counts = new Map(); + for (const s of this.map.stalls) counts.set(s.id, 0); + const bump = (id: string | undefined): void => { + if (id === undefined) return; + counts.set(id, (counts.get(id) ?? 0) + 1); + }; + for (const other of this.agents) { + if (other.gone) continue; + bump(other.stallId); + const p = this.plans.get(other); + if (other.activity === 'walking' && p) bump(p.stallId); + } + + const free = this.map.stalls.filter((s) => (counts.get(s.id) ?? 0) === 0); + const busy = this.map.stalls.filter((s) => { + const n = counts.get(s.id) ?? 0; + return n > 0 && n < STALL_CAP; + }); + + if (busy.length > 0 && (free.length === 0 || this.rng.chance(STALL_SHARE_CHANCE))) { + return this.rng.pick(busy); + } + if (free.length > 0) return this.rng.pick(free); + return undefined; + } + + private bumps(): void { + for (let i = 0; i < this.agents.length; i++) { + const a = this.agents[i]; + if (!a || a.gone) continue; + for (let j = i + 1; j < this.agents.length; j++) { + const b = this.agents[j]; + if (!b || b.gone) continue; + if (Math.hypot(a.x - b.x, a.y - b.y) >= BUMP_DIST_PX) continue; + for (const who of [a, b]) { + if (!rowdy(who) || who.lastBumpMs < BUMP_COOLDOWN_MS) continue; + who.lastBumpMs = 0; + // Still track the individual cooldown above (so one pair grinding + // together can't chain), but only the gate opens the till. + if (this.bumpGateMs < BUMP_GATE_MS) continue; + this.bumpGateMs = 0; + this.bus.emit('meters:delta', { vibe: BUMP_VIBE, aggro: BUMP_AGGRO }); + } + } + } + } + + private tryMove(a: Agent, dx: number, dy: number): void { + if (this.open(a.x + dx, a.y + dy)) { + a.x += dx; + a.y += dy; + return; + } + if (this.open(a.x + dx, a.y)) a.x += dx; // slide along the wall + else if (this.open(a.x, a.y + dy)) a.y += dy; + } + + private open(x: number, y: number): boolean { + return isWalkableWorld(this.map, x, y); + } + + /** 4-connected BFS over walkable tiles. Waypoints are tile centres. */ + private findPath(fromX: number, fromY: number, to: WorldPoint): WorldPoint[] | undefined { + const w = this.map.width; + const h = this.map.height; + const start = worldToTile(fromX, fromY); + const goal = worldToTile(to.x, to.y); + const startK = start.ty * w + start.tx; + const goalK = goal.ty * w + goal.tx; + if (startK === goalK) return []; + if (!isWalkable(this.map, goal.tx, goal.ty)) return undefined; + + const prev = new Int32Array(w * h).fill(-1); + const seen = new Uint8Array(w * h); + const queue = [startK]; + seen[startK] = 1; + for (let head = 0; head < queue.length; head++) { + const cur = queue[head] ?? -1; + if (cur === goalK) break; + const cx = cur % w; + const cy = (cur - cx) / w; + for (const [dx, dy] of NEIGHBOURS) { + const nx = cx + dx; + const ny = cy + dy; + if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue; + const k = ny * w + nx; + if (seen[k] === 1 || !isWalkable(this.map, nx, ny)) continue; + seen[k] = 1; + prev[k] = cur; + queue.push(k); + } + } + if (seen[goalK] !== 1) return undefined; + + const out: WorldPoint[] = []; + let cur = goalK; + while (cur !== startK) { + const cx = cur % w; + out.push(tileToWorld(cx, (cur - cx) / w)); + const p = prev[cur] ?? -1; + if (p < 0) break; + cur = p; + } + out.reverse(); + return out; + } + + private stallById(id: string | undefined): StallDef | undefined { + if (id === undefined) return undefined; + return this.map.stalls.find((s) => s.id === id); + } + + private nearestExit(x: number, y: number): WorldPoint | undefined { + let best: WorldPoint | undefined; + let bestD = Infinity; + for (const e of this.map.anchors.exit) { + const d = Math.hypot(e.x - x, e.y - y); + if (d < bestD) { + bestD = d; + best = e; + } + } + return best; + } +} diff --git a/src/scenes/floor/escalation.ts b/src/scenes/floor/escalation.ts new file mode 100644 index 0000000..e94a3d4 --- /dev/null +++ b/src/scenes/floor/escalation.ts @@ -0,0 +1,98 @@ +// The Cut-Off judgement matrix. Pure — no Phaser, no bus. The scene decides +// what to do with the result; this only decides what the room thinks of you. + +import type { DrunkStage } from '../../data/types'; +import { + CUTOFF_REPLY_CLEAN, + CUTOFF_REPLY_SCENE, + CUTOFF_REPLY_SOFT, + ESCALATION_LINES, + type EscalationLine, +} from './strings'; + +export type EscalationOutcome = 'clean' | 'scene' | 'staggerFree'; + +export interface EscalationResult { + outcome: EscalationOutcome; + vibeDelta: number; + aggroDelta: number; + /** What you said (from ESCALATION_LINES). */ + line: string; + /** What they said back (from CUTOFF_REPLY_*). */ + reply: string; + /** Eject: attach them to the escort-out walk. */ + eject: boolean; + /** Resolved: stop treating them as an open infraction. */ + resolved: boolean; +} + +/** Softest first — comparing rungs is the whole judgement. */ +const RUNG: Readonly> = { + water: 0, + done: 1, + escort: 2, +}; + +/** The correct rung for each drunk stage. */ +export const CORRECT_LINE: Readonly> = { + sober: 'water', + tipsy: 'water', + loose: 'water', + messy: 'done', + maggot: 'escort', +}; + +// Deliberately small. Being right about a drunk shouldn't feel like a jackpot, +// and overreach should sting more than restraint pays (design doc §4.3). +const CLEAN_VIBE = 4; +const SCENE_VIBE_PER_RUNG = -3; +const SCENE_AGGRO_PER_RUNG = 5; +const SOFT_VIBE = -1; + +export function judgeEscalation(stage: DrunkStage, line: EscalationLine): EscalationResult { + const said = ESCALATION_LINES[line]; + const overreach = RUNG[line] - RUNG[CORRECT_LINE[stage]]; + + if (overreach === 0) { + return { + outcome: 'clean', + vibeDelta: CLEAN_VIBE, + aggroDelta: 0, + line: said, + reply: CUTOFF_REPLY_CLEAN[stage], + // Water is a kindness, not an exit — only the top two stages walk. + eject: stage === 'messy' || stage === 'maggot', + resolved: true, + }; + } + + if (overreach > 0) { + return { + outcome: 'scene', + vibeDelta: SCENE_VIBE_PER_RUNG * overreach, + aggroDelta: SCENE_AGGRO_PER_RUNG * overreach, + line: said, + reply: CUTOFF_REPLY_SCENE[stage], + // They're not going anywhere with you now, but the moment is spent. + eject: false, + resolved: true, + }; + } + + return { + outcome: 'staggerFree', + vibeDelta: SOFT_VIBE, + aggroDelta: 0, + line: said, + reply: CUTOFF_REPLY_SOFT[stage], + eject: false, + resolved: false, + }; +} + +/** A maggot left alone too long becomes a deferred heat risk. */ +export const MAGGOT_GRACE_MIN = 25; + +export function maggotOverdue(spottedAtClockMin: number, nowClockMin: number): boolean { + return nowClockMin - spottedAtClockMin > MAGGOT_GRACE_MIN; +} diff --git a/src/scenes/floor/overlays/CutOffOverlay.ts b/src/scenes/floor/overlays/CutOffOverlay.ts new file mode 100644 index 0000000..5132e9e --- /dev/null +++ b/src/scenes/floor/overlays/CutOffOverlay.ts @@ -0,0 +1,216 @@ +import Phaser from 'phaser'; +import { renderDoll } from '../../../patrons/doll'; +import type { DrunkStage, Patron } from '../../../data/types'; +import { judgeEscalation, type EscalationResult } from '../escalation'; +import { ESCALATION_LINES, type EscalationLine } from '../strings'; + +const W = 640; +const H = 360; + +const PANEL_X = 60; +const PANEL_Y = 56; +const PANEL_W = 520; +const PANEL_H = 248; + +const ROW_X = 214; +const ROW_W = 348; +const ROW_H = 34; +const ROW_TOP = 126; +const ROW_GAP = 42; + +const PINK = 0xd03470; +const AMBER = 0xd8a020; +const PURPLE = 0x7b2fd0; + +const OUTCOME_COLOUR: Readonly> = { + clean: '#9fe8a0', + scene: '#d03470', + staggerFree: '#d8a020', +}; + +/** Softest first — index maps straight to the 1/2/3 keys. */ +const ORDER: readonly EscalationLine[] = ['water', 'done', 'escort']; + +// What the player can actually SEE across a dark room. The raw intoxication +// float never reaches the screen — you read the person, not a stat. +const LOOK: Readonly> = { + sober: 'steady as a rock', + tipsy: 'talking with her hands', + loose: 'leaning on things that move', + messy: 'wearing some of the drink', + maggot: 'gone. absolutely gone.', +}; + +const HOLD_MS = 1700; + +interface Row { + box: Phaser.GameObjects.Rectangle; + text: Phaser.GameObjects.Text; +} + +const mono = (size: number, colour: string): Phaser.Types.GameObjects.Text.TextStyle => ({ + fontFamily: 'monospace', + fontSize: `${size}px`, + color: colour, +}); + +/** Modal cut-off dialogue tree. A thin shell over judgeEscalation(). */ +export class CutOffOverlay { + private readonly root: Phaser.GameObjects.Container; + private readonly doll: Phaser.GameObjects.Image; + private readonly whoText: Phaser.GameObjects.Text; + private readonly lookText: Phaser.GameObjects.Text; + private readonly hintText: Phaser.GameObjects.Text; + private readonly rows: Row[] = []; + private readonly saidText: Phaser.GameObjects.Text; + private readonly replyText: Phaser.GameObjects.Text; + + private open_ = false; + private stage: DrunkStage = 'sober'; + private holdMs = 0; + private pending: EscalationResult | null = null; + private done: ((r: EscalationResult | null) => void) | null = null; + + constructor(private readonly scene: Phaser.Scene) { + this.root = scene.add.container(0, 0).setDepth(5000).setScrollFactor(0).setVisible(false); + + this.root.add(scene.add.rectangle(W / 2, H / 2, W, H, 0x05050a, 0.82)); + this.root.add( + scene.add + .rectangle(PANEL_X + PANEL_W / 2, PANEL_Y + PANEL_H / 2, PANEL_W, PANEL_H, 0x0b0b12, 0.98) + .setStrokeStyle(1, PURPLE), + ); + this.root.add(scene.add.rectangle(PANEL_X + PANEL_W / 2, PANEL_Y + 1, PANEL_W, 2, PINK, 0.8)); + this.root.add(scene.add.text(PANEL_X + 12, PANEL_Y + 10, 'CUT-OFF', mono(9, '#d03470'))); + + // '__DEFAULT' is Phaser's always-present blank texture — a placeholder so + // every object exists before the first open(). + this.doll = scene.add.image(134, 252, '__DEFAULT').setOrigin(0.5, 1).setScale(2); + this.root.add(this.doll); + + this.whoText = scene.add.text(134, 258, '', mono(8, '#9fe8a0')).setOrigin(0.5, 0); + this.lookText = scene.add + .text(134, 270, '', mono(7, '#8a7a96')) + .setOrigin(0.5, 0) + .setAlign('center') + .setWordWrapWidth(120); + this.root.add(this.whoText); + this.root.add(this.lookText); + + ORDER.forEach((line, i) => { + const y = ROW_TOP + i * ROW_GAP; + const box = scene.add + .rectangle(ROW_X + ROW_W / 2, y, ROW_W, ROW_H, 0x14141f) + .setStrokeStyle(1, 0x2a2a3a) + .setInteractive({ useHandCursor: true }); + const text = scene.add + .text(ROW_X + 10, y, `${i + 1} ${ESCALATION_LINES[line]}`, mono(8, '#cfc6d8')) + .setOrigin(0, 0.5); + + box.on('pointerover', () => box.setStrokeStyle(1, AMBER)); + box.on('pointerout', () => box.setStrokeStyle(1, 0x2a2a3a)); + box.on('pointerup', () => this.pick(line)); + + this.root.add(box); + this.root.add(text); + this.rows.push({ box, text }); + }); + + this.saidText = scene.add + .text(ROW_X, 136, '', mono(9, '#cfc6d8')) + .setOrigin(0, 0) + .setWordWrapWidth(ROW_W); + this.replyText = scene.add + .text(ROW_X, 176, '', mono(8, '#9fe8a0')) + .setOrigin(0, 0) + .setLineSpacing(3) + .setWordWrapWidth(ROW_W); + this.root.add(this.saidText); + this.root.add(this.replyText); + + this.hintText = scene.add + .text(W / 2, PANEL_Y + PANEL_H - 16, '1/2/3 choose · ESC back off', mono(7, '#5a5a70')) + .setOrigin(0.5, 0); + this.root.add(this.hintText); + } + + get isOpen(): boolean { + return this.open_; + } + + open(args: { patron: Patron; stage: DrunkStage }, done: (r: EscalationResult | null) => void): void { + this.done = done; + this.stage = args.stage; + this.pending = null; + this.holdMs = 0; + this.open_ = true; + + this.doll.setTexture(renderDoll(this.scene, args.patron, 'queue')); + this.whoText.setText(args.patron.archetype); + this.lookText.setText(LOOK[args.stage]); + this.showChoices(true); + this.root.setVisible(true); + } + + update(deltaMs: number): void { + if (!this.open_ || this.holdMs <= 0) return; + this.holdMs -= deltaMs; + if (this.holdMs > 0) return; + const result = this.pending; + this.hide(); + this.resolve(result); + } + + handleKey(key: string): void { + if (!this.open_ || this.pending) return; + const k = key.toUpperCase(); + if (k === 'ESC' || k === 'ESCAPE') { + this.hide(); + this.resolve(null); + return; + } + const line = ORDER[Number(k) - 1]; + if (line) this.pick(line); + } + + destroy(): void { + this.open_ = false; + this.done = null; + this.root.destroy(); + } + + private pick(line: EscalationLine): void { + if (!this.open_ || this.pending) return; + const result = judgeEscalation(this.stage, line); + this.pending = result; + this.holdMs = HOLD_MS; + + this.showChoices(false); + this.saidText.setText(`"${result.line}"`); + this.replyText.setText(result.reply).setColor(OUTCOME_COLOUR[result.outcome]); + } + + private showChoices(on: boolean): void { + for (const r of this.rows) { + r.box.setVisible(on).setStrokeStyle(1, 0x2a2a3a); + r.text.setVisible(on); + } + this.hintText.setVisible(on); + this.saidText.setVisible(!on); + this.replyText.setVisible(!on); + } + + private hide(): void { + this.open_ = false; + this.pending = null; + this.holdMs = 0; + this.root.setVisible(false); + } + + /** done() fires exactly once per open(), always after hide(). */ + private resolve(result: EscalationResult | null): void { + const cb = this.done; + this.done = null; + cb?.(result); + } +} diff --git a/src/scenes/floor/overlays/PatDownOverlay.ts b/src/scenes/floor/overlays/PatDownOverlay.ts new file mode 100644 index 0000000..41f2092 --- /dev/null +++ b/src/scenes/floor/overlays/PatDownOverlay.ts @@ -0,0 +1,469 @@ +import Phaser from 'phaser'; +import { CONTRABAND } from '../../../data/contraband'; +import type { Patron } from '../../../data/types'; +import { PATDOWN_INTRO } from '../strings'; +import { PATDOWN_MS, POCKET_COUNT, foundLine, scorePatDown } from '../patDown'; +import type { PatDownResult, Pocket } from '../patDown'; + +const VIEW_W = 640; +const VIEW_H = 360; +const DEPTH = 5000; + +/** patDown.ts lays pockets out on a 64x96 outline; blow it up so 12px zones are clickable. */ +const SCALE = 2.5; +const OUTLINE_X = 46; +const OUTLINE_Y = 62; +const ZONE = 24; + +const BIN_X = 430; +const BIN_Y = 190; +const BIN_W = 160; +const BIN_H = 170; + +const PINK = 0xd03470; +const GREEN = 0x9fe8a0; +const AMBER = 0xd8a020; +const PURPLE = 0x7b2fd0; + +const MONO = 'monospace'; + +interface TokenArt { + readonly w: number; + readonly h: number; + readonly colour: number; +} + +/** Placeholder art, one entry per CONTRABAND spriteHint. Real sprites land in the v0.4 pass. */ +const TOKEN_ART: Readonly> = { + baggie: { w: 14, h: 10, colour: 0xf2f2f0 }, + flask: { w: 12, h: 22, colour: 0xb8c0c8 }, + goonSack: { w: 24, h: 16, colour: 0xc8ccd4 }, + tinnies: { w: 18, h: 20, colour: AMBER }, + kebab: { w: 12, h: 26, colour: 0xd8cea4 }, + someoneElsesId: { w: 22, h: 14, colour: 0xdad4c4 }, + budgie: { w: 18, h: 14, colour: 0x6fd06f }, + vapes: { w: 22, h: 12, colour: PURPLE }, + marker: { w: 8, h: 24, colour: 0x24242c }, + snags: { w: 24, h: 12, colour: 0xe08c94 }, +}; + +const DEFAULT_ART: TokenArt = { w: 16, h: 14, colour: 0x9aa0b0 }; +const NAME_BY_ID = new Map(CONTRABAND.map((c) => [c.id, c.name] as const)); + +interface ZoneView { + readonly g: Phaser.GameObjects.Graphics; + readonly label: Phaser.GameObjects.Text; + readonly hit: Phaser.GameObjects.Zone; +} + +interface TokenView { + readonly root: Phaser.GameObjects.Container; + readonly body: Phaser.GameObjects.Rectangle; + readonly label: Phaser.GameObjects.Text; + pocketIndex: number; + itemId: string; + homeX: number; + homeY: number; + grabOffX: number; + grabOffY: number; + /** Owns a pocket's item this round. One token per pocket, so slots are never recycled mid-round. */ + live: boolean; + /** In or past the bin tween: no longer draggable, no longer flapping. */ + gone: boolean; +} + +// Dashes along each edge, corners left open — reads as a rounded dashed hint box +// without paying for an actual rounded path per zone. +function dashRect(g: Phaser.GameObjects.Graphics, x: number, y: number, w: number, h: number): void { + const step = 6; + const dash = 3; + const r = 3; + for (let px = x + r; px < x + w - r; px += step) { + const len = Math.min(dash, x + w - r - px); + g.lineBetween(px, y, px + len, y); + g.lineBetween(px, y + h, px + len, y + h); + } + for (let py = y + r; py < y + h - r; py += step) { + const len = Math.min(dash, y + h - r - py); + g.lineBetween(x, py, x, py + len); + g.lineBetween(x + w, py, x + w, py + len); + } +} + +/** + * The timed pat-down. Thin shell: all scoring lives in patDown.ts — this only + * flips `revealed` / `binned` on the caller's pockets, which is the agreed channel. + */ +export class PatDownOverlay { + private readonly scene: Phaser.Scene; + private readonly root: Phaser.GameObjects.Container; + private readonly timerBar: Phaser.GameObjects.Rectangle; + private readonly timerText: Phaser.GameObjects.Text; + private readonly lineText: Phaser.GameObjects.Text; + private readonly logText: Phaser.GameObjects.Text; + private readonly binGeom = new Phaser.Geom.Rectangle(BIN_X - BIN_W / 2, BIN_Y - BIN_H / 2, BIN_W, BIN_H); + private readonly zones: ZoneView[] = []; + private readonly tokens: TokenView[] = []; + + private pockets: Pocket[] = []; + private patron: Patron | null = null; + private doneFn: ((r: PatDownResult) => void) | null = null; + private remainingMs = 0; + private flapMs = 0; + private open_ = false; + private found: string[] = []; + + constructor(scene: Phaser.Scene) { + this.scene = scene; + this.root = scene.add.container(0, 0); + + this.root.add(scene.add.rectangle(VIEW_W / 2, VIEW_H / 2, VIEW_W, VIEW_H, 0x05050a, 0.82)); + this.root.add( + scene.add.rectangle(VIEW_W / 2, VIEW_H / 2, 600, 328, 0x0d0d16, 0.96).setStrokeStyle(1, PINK, 0.6), + ); + + this.root.add(scene.add.rectangle(32, 30, 576, 8, 0x1c1c2a).setOrigin(0, 0)); + this.timerBar = scene.add.rectangle(32, 30, 576, 8, GREEN).setOrigin(0, 0); + this.root.add(this.timerBar); + + this.root.add(scene.add.text(32, 44, PATDOWN_INTRO, { fontFamily: MONO, fontSize: '9px', color: '#d03470' })); + this.timerText = scene.add + .text(608, 44, '', { fontFamily: MONO, fontSize: '9px', color: '#9fe8a0' }) + .setOrigin(1, 0); + this.root.add(this.timerText); + + this.buildOutline(); + this.buildBin(); + + for (let i = 0; i < POCKET_COUNT; i++) this.zones.push(this.buildZone(i)); + for (let i = 0; i < POCKET_COUNT; i++) this.tokens.push(this.buildToken()); + + this.logText = scene.add.text(524, 104, '', { + fontFamily: MONO, + fontSize: '7px', + color: '#d8a020', + wordWrap: { width: 88 }, + lineSpacing: 2, + }); + this.root.add(this.logText); + + this.lineText = scene.add.text(46, 316, '', { + fontFamily: MONO, + fontSize: '8px', + color: '#e8e4d8', + wordWrap: { width: 390 }, + }); + this.root.add(this.lineText); + + this.root.add( + scene.add + .text(608, 318, 'CLICK pockets · DRAG to bin · ESC done', { + fontFamily: MONO, + fontSize: '7px', + color: '#556', + }) + .setOrigin(1, 0), + ); + + this.root.setDepth(DEPTH).setScrollFactor(0, 0, true).setVisible(false); + scene.input.on('dragstart', this.onDragStart); + scene.input.on('drag', this.onDrag); + scene.input.on('dragend', this.onDragEnd); + } + + get isOpen(): boolean { + return this.open_; + } + + open(args: { patron: Patron; pockets: Pocket[] }, done: (r: PatDownResult) => void): void { + if (this.open_) return; + this.patron = args.patron; + this.pockets = args.pockets; + this.doneFn = done; + this.remainingMs = PATDOWN_MS; + this.flapMs = 0; + this.found = []; + this.open_ = true; + + this.lineText.setText(''); + this.logText.setText(''); + + for (let i = 0; i < this.zones.length; i++) { + const z = this.zones[i]; + const p = this.pockets[i]; + if (z === undefined) continue; + const on = p !== undefined; + z.g.setVisible(on); + z.label.setVisible(on); + z.hit.setVisible(on); + if (z.hit.input) z.hit.input.enabled = on; + if (on) this.paintZone(i); + } + + for (const t of this.tokens) this.hideToken(t); + + this.root.setVisible(true); + this.tick(); + } + + update(deltaMs: number): void { + if (!this.open_) return; + this.flapMs += deltaMs; + this.remainingMs -= deltaMs; + + for (const t of this.tokens) { + // The budgie is alive and it would like everyone to know. + if (t.live && !t.gone && t.itemId === 'budgie') t.root.setRotation(Math.sin(this.flapMs / 70) * 0.3); + } + + if (this.remainingMs <= 0) { + this.remainingMs = 0; + this.tick(); + this.resolve(); + return; + } + this.tick(); + } + + handleKey(key: string): void { + if (!this.open_) return; + if (key === 'ESC' || key === 'Escape') this.resolve(); + } + + destroy(): void { + this.scene.input.off('dragstart', this.onDragStart); + this.scene.input.off('drag', this.onDrag); + this.scene.input.off('dragend', this.onDragEnd); + this.root.destroy(true); + this.doneFn = null; + this.open_ = false; + } + + // ---- build (constructor only) ---- + + private buildOutline(): void { + const g = this.scene.add.graphics(); + const px = (v: number): number => OUTLINE_X + v * SCALE; + const py = (v: number): number => OUTLINE_Y + v * SCALE; + const box = (x: number, y: number, w: number, h: number): void => { + g.fillRect(px(x), py(y), w * SCALE, h * SCALE); + g.strokeRect(px(x), py(y), w * SCALE, h * SCALE); + }; + g.fillStyle(0x14141f, 1); + g.lineStyle(1, 0x3c3c5a, 1); + g.fillCircle(px(32), py(10), 8 * SCALE); + g.strokeCircle(px(32), py(10), 8 * SCALE); + box(30, 17, 4, 4); // neck + box(20, 21, 24, 29); // torso + box(2, 24, 18, 5); // arms out, mate + box(44, 24, 18, 5); + box(20, 50, 24, 10); // hips + box(21, 60, 9, 34); // legs + box(34, 60, 9, 34); + this.root.add(g); + } + + private buildBin(): void { + const s = this.scene; + this.root.add( + s.add.rectangle(BIN_X, BIN_Y, BIN_W, BIN_H, 0x120c1c, 0.9).setStrokeStyle(1, GREEN, 0.75), + ); + this.root.add(s.add.rectangle(BIN_X, BIN_Y - BIN_H / 2, BIN_W + 12, 12, GREEN, 0.22)); + this.root.add(s.add.rectangle(BIN_X, BIN_Y - BIN_H / 2 + 2, 90, 4, 0x05050a)); // slot + this.root.add( + s.add + .text(BIN_X, BIN_Y - BIN_H / 2 + 18, 'AMNESTY BIN', { + fontFamily: MONO, + fontSize: '9px', + color: '#9fe8a0', + }) + .setOrigin(0.5, 0), + ); + this.root.add( + s.add + .text(BIN_X, BIN_Y + BIN_H / 2 - 16, 'no questions asked', { + fontFamily: MONO, + fontSize: '7px', + color: '#4a6a50', + }) + .setOrigin(0.5, 0), + ); + } + + private buildZone(index: number): ZoneView { + const s = this.scene; + const g = s.add.graphics(); + const label = s.add.text(0, 0, '', { fontFamily: MONO, fontSize: '6px', color: '#6f7aa8' }).setOrigin(0.5, 0); + const hit = s.add.zone(0, 0, ZONE + 8, ZONE + 8).setInteractive({ useHandCursor: true }); + hit.on('pointerdown', () => this.revealPocket(index)); + this.root.add([g, label, hit]); + return { g, label, hit }; + } + + private buildToken(): TokenView { + const s = this.scene; + const root = s.add.container(0, 0); + const body = s.add.rectangle(0, 0, 16, 14, 0xffffff).setStrokeStyle(1, 0x05050a, 0.8); + const label = s.add + .text(0, 12, '', { fontFamily: MONO, fontSize: '6px', color: '#e8e4d8', align: 'center' }) + .setOrigin(0.5, 0); + root.add([body, label]); + root.setSize(40, 40).setInteractive(new Phaser.Geom.Rectangle(-20, -20, 40, 40), Phaser.Geom.Rectangle.Contains); + s.input.setDraggable(root); + this.root.add(root); + return { + root, + body, + label, + pocketIndex: -1, + itemId: '', + homeX: 0, + homeY: 0, + grabOffX: 0, + grabOffY: 0, + live: false, + gone: false, + }; + } + + // ---- runtime ---- + + private tick(): void { + const frac = Phaser.Math.Clamp(this.remainingMs / PATDOWN_MS, 0, 1); + this.timerBar.setSize(Math.max(576 * frac, 0.001), 8); + this.timerBar.setFillStyle(frac > 0.5 ? GREEN : frac > 0.2 ? AMBER : PINK, 1); + this.timerText.setText(`${(this.remainingMs / 1000).toFixed(1)}s`); + this.timerText.setColor(frac > 0.5 ? '#9fe8a0' : frac > 0.2 ? '#d8a020' : '#d03470'); + } + + private paintZone(index: number): void { + const z = this.zones[index]; + const p = this.pockets[index]; + if (z === undefined || p === undefined) return; + const cx = OUTLINE_X + (p.x + 6) * SCALE; + const cy = OUTLINE_Y + (p.y + 6) * SCALE; + const half = ZONE / 2; + + z.hit.setPosition(cx, cy); + z.label.setPosition(cx, cy + half + 2).setText(p.label); + + z.g.clear(); + if (!p.revealed) { + z.g.lineStyle(1, 0x7b8cd0, 0.9); + dashRect(z.g, cx - half, cy - half, ZONE, ZONE); + z.label.setColor('#6f7aa8'); + return; + } + const hot = p.itemId !== undefined && !p.binned; + const c = hot ? AMBER : GREEN; + z.g.fillStyle(c, 0.12); + z.g.fillRect(cx - half, cy - half, ZONE, ZONE); + z.g.lineStyle(1, c, 0.9); + z.g.strokeRect(cx - half, cy - half, ZONE, ZONE); + z.label.setColor(hot ? '#d8a020' : '#9fe8a0'); + } + + private revealPocket(index: number): void { + if (!this.open_) return; + const p = this.pockets[index]; + if (p === undefined || p.revealed) return; + p.revealed = true; + this.paintZone(index); + if (p.itemId !== undefined) this.spawnToken(index, p.itemId); + } + + private spawnToken(index: number, itemId: string): void { + const p = this.pockets[index]; + const t = this.tokens.find((x) => !x.live); + if (p === undefined || t === undefined) return; + + const art = TOKEN_ART[itemId] ?? DEFAULT_ART; + t.itemId = itemId; + t.pocketIndex = index; + t.live = true; + t.gone = false; + t.homeX = OUTLINE_X + (p.x + 6) * SCALE; + t.homeY = OUTLINE_Y + (p.y + 6) * SCALE; + + t.body.setSize(art.w, art.h).setFillStyle(art.colour, 1); + t.label.setText(NAME_BY_ID.get(itemId) ?? itemId).setPosition(0, art.h / 2 + 2); + t.root.setPosition(t.homeX, t.homeY).setScale(1).setAlpha(1).setRotation(0).setVisible(true); + if (t.root.input) t.root.input.enabled = true; + this.root.bringToTop(t.root); + } + + private hideToken(t: TokenView): void { + this.scene.tweens.killTweensOf(t.root); + t.live = false; + t.gone = false; + t.itemId = ''; + t.pocketIndex = -1; + t.root.setVisible(false).setRotation(0).setScale(1).setAlpha(1); + if (t.root.input) t.root.input.enabled = false; + } + + private tokenFor(obj: Phaser.GameObjects.GameObject): TokenView | undefined { + return this.tokens.find((t) => t.live && !t.gone && t.root === obj); + } + + private readonly onDragStart = (pointer: Phaser.Input.Pointer, obj: Phaser.GameObjects.GameObject): void => { + if (!this.open_) return; + const t = this.tokenFor(obj); + if (t === undefined) return; + // Overlay is scrollFactor 0, so track screen-space pointer, not world drag coords. + t.grabOffX = t.root.x - pointer.x; + t.grabOffY = t.root.y - pointer.y; + this.root.bringToTop(t.root); + }; + + private readonly onDrag = (pointer: Phaser.Input.Pointer, obj: Phaser.GameObjects.GameObject): void => { + if (!this.open_) return; + const t = this.tokenFor(obj); + if (t === undefined) return; + t.root.setPosition(pointer.x + t.grabOffX, pointer.y + t.grabOffY); + }; + + private readonly onDragEnd = (_pointer: Phaser.Input.Pointer, obj: Phaser.GameObjects.GameObject): void => { + if (!this.open_) return; + const t = this.tokenFor(obj); + if (t === undefined) return; + if (this.binGeom.contains(t.root.x, t.root.y)) this.binToken(t); + else this.scene.tweens.add({ targets: t.root, x: t.homeX, y: t.homeY, duration: 160, ease: 'Back.easeOut' }); + }; + + private binToken(t: TokenView): void { + const p = this.pockets[t.pocketIndex]; + if (p === undefined) return; + p.binned = true; + const itemId = t.itemId; + + if (t.root.input) t.root.input.enabled = false; + t.gone = true; // stops the flap and the drag, but keeps the slot so nothing reuses it mid-tween + this.scene.tweens.add({ + targets: t.root, + x: BIN_X, + y: BIN_Y, + scale: 0.2, + alpha: 0, + rotation: 0, + duration: 240, + ease: 'Quad.easeIn', + onComplete: () => t.root.setVisible(false), + }); + + this.lineText.setText(foundLine(itemId)); + this.found.push(NAME_BY_ID.get(itemId) ?? itemId); + this.logText.setText(this.found.join('\n')); + this.paintZone(t.pocketIndex); + } + + private resolve(): void { + const done = this.doneFn; + const patron = this.patron; + if (!this.open_ || done === null || patron === null) return; + this.open_ = false; + this.doneFn = null; + this.root.setVisible(false); + done(scorePatDown(patron, this.pockets)); + } +} diff --git a/src/scenes/floor/overlays/StallOverlay.ts b/src/scenes/floor/overlays/StallOverlay.ts new file mode 100644 index 0000000..c6c3357 --- /dev/null +++ b/src/scenes/floor/overlays/StallOverlay.ts @@ -0,0 +1,334 @@ +import Phaser from 'phaser'; +import type { EventBus } from '../../../core/EventBus'; +import { PALETTE } from '../../../data/outfits'; +import type { Patron } from '../../../data/types'; +import { + BANGS_TO_BUST, + OK_MS, + PERFECT_MS, + applyBang, + freshStall, + judgeBang, + scoreStall, + type StallOutcome, + type StallState, +} from '../bangJudge'; +import { STALL_BUSTED, STALL_MUFFLED, STALL_OFF_BEAT } from '../strings'; + +const W = 640; +const H = 360; + +const PINK = 0xd03470; +const GREEN = 0x9fe8a0; +const AMBER = 0xd8a020; +const PURPLE = 0x7b2fd0; +const INK = 0x0a0a12; + +const DOOR_W = 250; +const DOOR_H = 160; +const DOOR_CX = 320; +const DOOR_CY = 145; +const DOOR_HINGE_X = DOOR_CX - DOOR_W / 2; +const DOOR_BOTTOM = DOOR_CY + DOOR_H / 2; + +const BAR_W = 380; +const BAR_H = 14; +const BAR_CX = 320; +const BAR_CY = 292; + +const BUST_HOLD_MS = 1600; +/** How far the door swings before it's "open" — a sliver of edge stays visible. */ +const DOOR_SWUNG_SCALE = 0.06; + +interface TickMark { + beatIndex: number; + /** The tick's arrival stamped on OUR clock, so bangs and beats compare directly. */ + atElapsedMs: number; +} + +function shoeColour(p: Patron): number { + const layer = p.outfit.find((l) => l.slot === 'shoes'); + if (!layer || layer.type === 'none') return 0x14141c; + return PALETTE[layer.colour] ?? 0x14141c; +} + +export class StallOverlay { + private readonly root: Phaser.GameObjects.Container; + private readonly doorNode: Phaser.GameObjects.Container; + private readonly barFlash: Phaser.GameObjects.Rectangle; + private readonly marker: Phaser.GameObjects.Rectangle; + private readonly titleText: Phaser.GameObjects.Text; + private readonly gradeText: Phaser.GameObjects.Text; + private readonly flavourText: Phaser.GameObjects.Text; + private readonly noteText: Phaser.GameObjects.Text; + private readonly pips: Phaser.GameObjects.Rectangle[] = []; + private readonly feet: Phaser.GameObjects.Rectangle[] = []; + private readonly feetBaseX: number[] = []; + private readonly offBeat: () => void; + + private open_ = false; + private resolved = true; + private done: ((r: StallOutcome) => void) | null = null; + private state: StallState = freshStall(); + + private elapsedMs = 0; + private lastTick: TickMark | null = null; + private bustHoldMs = 0; + private shakeMs = 0; + private shakeMag = 0; + private flashMs = 0; + + constructor( + scene: Phaser.Scene, + bus: EventBus, + private readonly beatIntervalMs: number, + ) { + const t = (x: number, y: number, size: number, colour: string): Phaser.GameObjects.Text => + scene.add.text(x, y, '', { fontFamily: 'monospace', fontSize: `${size}px`, color: colour }); + + this.root = scene.add.container(0, 0); + + this.root.add(scene.add.rectangle(W / 2, H / 2, W, H, 0x000000, 0.82)); + this.root.add(scene.add.rectangle(W / 2, 180, 470, 310, INK).setStrokeStyle(1, PINK, 0.7)); + + this.titleText = t(105, 40, 9, '#d03470'); + this.root.add(this.titleText); + + // Stall interior: revealed as the door swings away. + this.root.add(scene.add.rectangle(DOOR_CX, DOOR_CY, DOOR_W, DOOR_H, 0x05050a)); + this.root.add(scene.add.rectangle(DOOR_CX, DOOR_CY - 30, 40, 60, 0x11111a)); // cistern, in shadow + + // Door hangs off its hinge so the bust can swing it, not slide it. + this.doorNode = scene.add.container(DOOR_HINGE_X, DOOR_CY); + const face = scene.add.rectangle(DOOR_W / 2, 0, DOOR_W, DOOR_H, 0x2b2018).setStrokeStyle(1, 0x120c08); + this.doorNode.add(face); + this.doorNode.add(scene.add.rectangle(DOOR_W / 2, -34, DOOR_W - 40, 54, 0x342519)); + this.doorNode.add(scene.add.rectangle(DOOR_W / 2, 36, DOOR_W - 40, 54, 0x342519)); + this.doorNode.add(scene.add.rectangle(DOOR_W - 26, 0, 14, 5, 0xc0c0c8)); // latch + this.doorNode.add(scene.add.rectangle(DOOR_W - 26, -12, 22, 9, 0x8a1020)); // ENGAGED tab + this.doorNode.add( + scene.add + .text(DOOR_W - 26, -12, 'ENGAGED', { fontFamily: 'monospace', fontSize: '5px', color: '#f0c0c8' }) + .setOrigin(0.5), + ); + this.doorNode.add( + scene.add + .text(DOOR_W / 2, -46, 'DAZZA WOZ HERE', { fontFamily: 'monospace', fontSize: '7px', color: '#5c4630' }) + .setOrigin(0.5) + .setAngle(-6), + ); + this.root.add(this.doorNode); + + // The infraction itself: two pairs of feet in a one-person cubicle. + this.root.add(scene.add.rectangle(DOOR_CX, DOOR_BOTTOM + 7, DOOR_W, 14, 0x1c1c24)); + for (let i = 0; i < 4; i++) { + const pairX = i < 2 ? DOOR_CX - 64 : DOOR_CX + 52; + const x = pairX + (i % 2) * 13; + this.feetBaseX.push(x); + const foot = scene.add.rectangle(x, DOOR_BOTTOM + 6, 9, 6, 0x14141c); + this.feet.push(foot); + this.root.add(foot); + } + + this.flavourText = t(120, 244, 8, '#c8c8d4').setWordWrapWidth(400); + this.root.add(this.flavourText); + this.noteText = t(120, 268, 7, '#6a6a80').setWordWrapWidth(400); + this.root.add(this.noteText); + + // Beat bar: the full width is one beat, dead centre is the kick. + const zone = (ms: number, colour: number, alpha: number): Phaser.GameObjects.Rectangle => { + const frac = beatIntervalMs > 0 ? Math.min(1, (2 * ms) / beatIntervalMs) : 1; + return scene.add.rectangle(BAR_CX, BAR_CY, BAR_W * frac, BAR_H, colour, alpha); + }; + this.root.add(scene.add.rectangle(BAR_CX, BAR_CY, BAR_W, BAR_H, 0x14141c).setStrokeStyle(1, 0x30304a)); + this.root.add(zone(OK_MS, AMBER, 0.22)); + this.root.add(zone(PERFECT_MS, GREEN, 0.32)); + this.barFlash = scene.add.rectangle(BAR_CX, BAR_CY, BAR_W, BAR_H, GREEN, 0); + this.root.add(this.barFlash); + this.marker = scene.add.rectangle(BAR_CX, BAR_CY, 3, BAR_H + 8, 0xffffff); + this.root.add(this.marker); + + this.gradeText = t(BAR_CX + BAR_W / 2 + 6, BAR_CY - 5, 8, '#9fe8a0'); + this.root.add(this.gradeText); + + for (let i = 0; i < BANGS_TO_BUST; i++) { + const pip = scene.add.rectangle(BAR_CX - (BANGS_TO_BUST - 1) * 8 + i * 16, 314, 11, 7, PURPLE, 0.25); + this.pips.push(pip); + this.root.add(pip); + } + + this.root.add( + scene.add + .text(BAR_CX, 328, 'SPACE bang on the door · ESC walk away', { + fontFamily: 'monospace', + fontSize: '7px', + color: '#556', + }) + .setOrigin(0.5, 0), + ); + + this.root.setScrollFactor(0, 0, true); + this.root.setDepth(5200); + this.root.setVisible(false); + + this.offBeat = bus.on('beat:tick', (p) => { + if (!this.open_) return; + this.lastTick = { beatIndex: p.beatIndex, atElapsedMs: this.elapsedMs }; + }); + } + + get isOpen(): boolean { + return this.open_; + } + + open(args: { stallId: string; patrons: Patron[] }, done: (r: StallOutcome) => void): void { + this.done = done; + this.resolved = false; + this.open_ = true; + this.state = freshStall(); + this.elapsedMs = 0; + this.lastTick = null; + this.bustHoldMs = 0; + this.shakeMs = 0; + this.shakeMag = 0; + this.flashMs = 0; + + this.titleText.setText(`CUBICLE ${args.stallId.toUpperCase()} — occupied. Twice.`); + this.flavourText.setText('Two pairs of shoes. One cubicle. Knock on the beat, mate.'); + this.noteText.setText(''); + this.gradeText.setText(''); + + this.feet.forEach((foot, i) => { + const patron = args.patrons[i < 2 ? 0 : 1]; + foot.setVisible(patron !== undefined); + foot.setAlpha(1); + foot.x = this.feetBaseX[i] ?? foot.x; + if (patron) foot.setFillStyle(shoeColour(patron)); + }); + + this.doorNode.setScale(1, 1); + this.doorNode.x = DOOR_HINGE_X; + this.doorNode.y = DOOR_CY; + this.barFlash.setAlpha(0); + this.paintPips(); + this.root.setVisible(true); + } + + handleKey(key: string): void { + if (!this.open_) return; + const k = key.toUpperCase(); + if (k === 'ESC' || k === 'ESCAPE') return this.resolve(); + // Door's already open; the last beat is just theatre. + if (this.bustHoldMs > 0) return; + if (k === 'SPACE' || k === ' ') this.bang(); + } + + update(deltaMs: number): void { + if (!this.open_) return; + this.elapsedMs += deltaMs; + + if (this.shakeMs > 0) this.shakeMs = Math.max(0, this.shakeMs - deltaMs); + if (this.flashMs > 0) { + this.flashMs = Math.max(0, this.flashMs - deltaMs); + this.barFlash.setAlpha((this.flashMs / 180) * 0.5); + } + + if (this.bustHoldMs > 0) { + this.bustHoldMs = Math.max(0, this.bustHoldMs - deltaMs); + const progress = 1 - this.bustHoldMs / BUST_HOLD_MS; + const swing = Math.min(1, progress * 2.2); // door goes fast, the shame lingers + this.doorNode.setScale(1 - swing * (1 - DOOR_SWUNG_SCALE), 1); + this.feet.forEach((foot, i) => { + const base = this.feetBaseX[i] ?? foot.x; + foot.x = base + (i < 2 ? -1 : 1) * swing * 70; + foot.setAlpha(1 - swing); + }); + if (this.bustHoldMs === 0) this.resolve(); + return; + } + + this.doorNode.x = DOOR_HINGE_X + this.shakeWobble(); + this.doorNode.y = DOOR_CY + this.shakeWobble() * 0.4; + this.marker.x = BAR_CX - BAR_W / 2 + this.beatPhase() * BAR_W; + this.marker.setAlpha(this.lastTick ? 1 : 0.25); + } + + destroy(): void { + this.offBeat(); + this.done = null; + this.open_ = false; + this.root.destroy(true); + } + + private bang(): void { + const tick = this.lastTick; + // No beat authority yet — swinging at silence shouldn't cost you a combo. + if (!tick) return; + + const grade = judgeBang( + this.elapsedMs, + { beatIndex: tick.beatIndex, audioTimeMs: tick.atElapsedMs }, + this.beatIntervalMs, + ); + const brokeCombo = grade === 'miss' && this.state.combo > 0; + this.state = applyBang(this.state, grade); + + this.shakeMs = grade === 'miss' ? 90 : 220; + this.shakeMag = grade === 'miss' ? 1.5 : 4.5; + this.flashMs = 180; + this.barFlash.setFillStyle(grade === 'perfect' ? GREEN : grade === 'ok' ? AMBER : PINK); + this.gradeText.setText(grade.toUpperCase()); + this.gradeText.setColor(grade === 'perfect' ? '#9fe8a0' : grade === 'ok' ? '#d8a020' : '#d03470'); + this.paintPips(); + + if (this.state.busted) { + this.flavourText.setText(this.line(STALL_BUSTED)); + this.noteText.setText(''); + this.bustHoldMs = BUST_HOLD_MS; + this.gradeText.setText('BUSTED'); + return; + } + if (grade === 'miss') { + this.flavourText.setText(this.line(STALL_MUFFLED)); + this.noteText.setText(brokeCombo ? STALL_OFF_BEAT : ''); + } else { + this.flavourText.setText('The door rattles. Something in there stops talking.'); + this.noteText.setText(''); + } + } + + /** Deterministic line pick — the beat index is the only entropy we're allowed. */ + private line(lines: readonly string[]): string { + const i = ((this.lastTick?.beatIndex ?? 0) + this.state.bangs) % lines.length; + return lines[i] ?? lines[0] ?? ''; + } + + /** 0..1 across the bar, offset so a kick lands the marker dead centre. */ + private beatPhase(): number { + if (!this.lastTick || !(this.beatIntervalMs > 0)) return 0.5; + const since = (this.elapsedMs - this.lastTick.atElapsedMs) / this.beatIntervalMs; + return ((since % 1) + 1.5) % 1; + } + + private shakeWobble(): number { + if (this.shakeMs <= 0) return 0; + return Math.sin(this.shakeMs * 0.9) * this.shakeMag * (this.shakeMs / 220); + } + + private paintPips(): void { + this.pips.forEach((pip, i) => { + const lit = i < this.state.combo; + pip.setFillStyle(lit ? GREEN : PURPLE, lit ? 0.95 : 0.25); + }); + } + + private resolve(): void { + if (this.resolved) return; + this.resolved = true; + this.open_ = false; + this.root.setVisible(false); + const done = this.done; + this.done = null; + done?.(scoreStall(this.state)); + } +} diff --git a/src/scenes/floor/patDown.ts b/src/scenes/floor/patDown.ts new file mode 100644 index 0000000..522dd11 --- /dev/null +++ b/src/scenes/floor/patDown.ts @@ -0,0 +1,120 @@ +// The pat-down minigame's pure half: where the pockets are, what's in them, +// and what it costs you. Phaser-free so the scoring stays testable. + +import type { RngStream } from '../../core/SeededRNG'; +import { CONTRABAND } from '../../data/contraband'; +import type { Patron } from '../../data/types'; +import { PATDOWN_CLEAR, PATDOWN_FOUND, PATDOWN_MISSED } from './strings'; + +export const PATDOWN_MS = 10_000; +export const POCKET_COUNT = 6; + +export interface Pocket { + id: string; + label: string; + /** Position on a 64x96 patron-outline overlay coordinate space, top-left origin. */ + x: number; + y: number; + itemId?: string; + revealed: boolean; + binned: boolean; +} + +/** + * Fixed anchors down the outline — chest, jacket, hips, back, sock. Spaced so no + * two hit areas touch at a ~12px footprint; the search is about time pressure, + * not pixel-hunting. + */ +const POCKET_SPOTS: ReadonlyArray<{ readonly label: string; readonly x: number; readonly y: number }> = [ + { label: 'inside pocket', x: 26, y: 22 }, + { label: 'left jacket', x: 10, y: 30 }, + { label: 'right jacket', x: 40, y: 30 }, + { label: 'front hip', x: 12, y: 52 }, + { label: 'back pocket', x: 42, y: 54 }, + { label: 'sock', x: 20, y: 80 }, +]; + +const SEVERITY_BY_ID = new Map(CONTRABAND.map((c) => [c.id, c.severity] as const)); +const NAME_BY_ID = new Map(CONTRABAND.map((c) => [c.id, c.name] as const)); + +/** Deterministic: same patron + same rng stream state => same layout. */ +export function layoutPockets(patron: Patron, rng: RngStream): Pocket[] { + const pockets: Pocket[] = POCKET_SPOTS.map((spot, i) => ({ + id: `pk${i}`, + label: spot.label, + x: spot.x, + y: spot.y, + revealed: false, + binned: false, + })); + + // Fisher-Yates over slot indices, then fill from the front: distinct pockets + // for free, and anything past POCKET_COUNT simply doesn't fit on them. + const order = pockets.map((_, i) => i); + for (let i = order.length - 1; i > 0; i--) { + const j = rng.int(0, i); + const a = order[i]!; + order[i] = order[j]!; + order[j] = a; + } + + const items = patron.flags.contraband ?? []; + const placeable = Math.min(items.length, POCKET_COUNT); + for (let i = 0; i < placeable; i++) { + pockets[order[i]!]!.itemId = items[i]!; + } + return pockets; +} + +export interface PatDownResult { + foundIds: string[]; + missedIds: string[]; + vibeDelta: number; + aggroDelta: number; + /** Severity-3 contraband found => they are out. */ + eject: boolean; + /** Player-facing summary line, built from strings.ts. */ + summary: string; +} + +// Scores the pockets as laid out, not the patron's flags — what you didn't open, +// you didn't find, even if the generator put it there. +export function scorePatDown(_patron: Patron, pockets: readonly Pocket[]): PatDownResult { + const foundIds: string[] = []; + const missedIds: string[] = []; + for (const p of pockets) { + if (p.itemId === undefined) continue; + (p.binned ? foundIds : missedIds).push(p.itemId); + } + + const carried = foundIds.length + missedIds.length; + // An unnecessary pat-down is a small indignity — that's the moral cost. + const aggroDelta = carried === 0 ? 1.5 : 0; + const eject = foundIds.some((id) => SEVERITY_BY_ID.get(id) === 3); + + let summary: string; + if (carried === 0) { + summary = PATDOWN_CLEAR; + } else { + const parts = foundIds.map(foundLine); + if (missedIds.length > 0) parts.push(PATDOWN_MISSED); + summary = parts.join(' '); + } + + return { + foundIds, + missedIds, + vibeDelta: foundIds.length * 2 - missedIds.length * 1.5, + aggroDelta, + eject, + summary, + }; +} + +/** Flavour line for a found item; falls back gracefully on an unknown id. */ +export function foundLine(itemId: string): string { + const line = PATDOWN_FOUND[itemId]; + if (line !== undefined) return line; + const name = NAME_BY_ID.get(itemId); + return name !== undefined ? `${name}. Into the bin, mate.` : 'Into the bin, mate.'; +} diff --git a/src/scenes/floor/player.ts b/src/scenes/floor/player.ts new file mode 100644 index 0000000..4561e4b --- /dev/null +++ b/src/scenes/floor/player.ts @@ -0,0 +1,76 @@ +// Player movement. Pure maths — the scene owns the sprite, this owns the weight. +// You are a big unit: slow to start, slower to stop, and you slide off walls +// rather than sticking to them. + +import { isWalkableWorld } from './venueMap'; +import type { VenueMap } from './venueMap'; + +export interface PlayerInput { up: boolean; down: boolean; left: boolean; right: boolean } + +export interface PlayerState { + x: number; y: number; // world px + vx: number; vy: number; // px/sec + facing: number; // radians, persists when stationary +} + +export const PLAYER_ACCEL = 620; // px/s^2 +export const PLAYER_MAX_SPEED = 116; +export const PLAYER_FRICTION = 7; // per second, exponential damping +export const PLAYER_RADIUS = 5; + +// A stalled tab hands back one enormous delta; cap it so nobody blinks through a +// wall. At the cap a full-speed step is ~6px, well under a tile. +const MAX_STEP_MS = 50; + +// Below this the heading is noise, so the torch keeps pointing where it was. +const FACING_EPS = 4; + +export function freshPlayer(x: number, y: number): PlayerState { + return { x, y, vx: 0, vy: 0, facing: 0 }; +} + +/** The 4 cardinal edge points of the body circle must all be on walkable floor. */ +function canStand(map: VenueMap, x: number, y: number): boolean { + return isWalkableWorld(map, x + PLAYER_RADIUS, y) + && isWalkableWorld(map, x - PLAYER_RADIUS, y) + && isWalkableWorld(map, x, y + PLAYER_RADIUS) + && isWalkableWorld(map, x, y - PLAYER_RADIUS); +} + +/** Mutates nothing; returns the next state. Slides along walls (axis-separated collision). */ +export function stepPlayer(s: PlayerState, input: PlayerInput, deltaMs: number, map: VenueMap): PlayerState { + const dt = Math.max(0, Math.min(deltaMs, MAX_STEP_MS)) / 1000; + + let ix = (input.right ? 1 : 0) - (input.left ? 1 : 0); + let iy = (input.down ? 1 : 0) - (input.up ? 1 : 0); + const mag = Math.hypot(ix, iy); + if (mag > 0) { ix /= mag; iy /= mag; } + + let vx = s.vx + ix * PLAYER_ACCEL * dt; + let vy = s.vy + iy * PLAYER_ACCEL * dt; + + // Friction only bites on an axis you aren't driving, so held input still tops + // out at PLAYER_MAX_SPEED instead of settling at accel/friction. + const damp = Math.exp(-PLAYER_FRICTION * dt); + if (ix === 0) vx *= damp; + if (iy === 0) vy *= damp; + + const speed = Math.hypot(vx, vy); + if (speed > PLAYER_MAX_SPEED) { + const k = PLAYER_MAX_SPEED / speed; + vx *= k; + vy *= k; + } + + let x = s.x; + let y = s.y; + const nx = x + vx * dt; + if (canStand(map, nx, y)) x = nx; else vx = 0; + const ny = y + vy * dt; + if (canStand(map, x, ny)) y = ny; else vy = 0; + + const moved = Math.hypot(vx, vy); + const facing = moved > FACING_EPS ? Math.atan2(vy, vx) : s.facing; + + return { x, y, vx, vy, facing }; +} diff --git a/src/scenes/floor/strings.ts b/src/scenes/floor/strings.ts new file mode 100644 index 0000000..765ed78 --- /dev/null +++ b/src/scenes/floor/strings.ts @@ -0,0 +1,91 @@ +// Floor dialogue & flavour. Typed const maps, never inline in scene code +// (CONTRACTS.md §7). Tone: Aussie hospitality satire — affectionate, absurd, +// with a moral undertow. Nobody here is a villain; they're just having a night. +// +// NOTE(convention): CONTRACTS.md §7 puts dialogue in `src/data/strings/`, but +// `data/` is LANE-0 territory and lanes commit only inside their owned dirs. +// Parked here; see LANEHANDOVER.md — happy to move it on the reviewer's word. + +import type { DrunkStage } from '../../data/types'; + +/** The three rungs of the cut-off ladder, softest first. */ +export type EscalationLine = 'water' | 'done' | 'escort'; + +export const ESCALATION_LINES: Record = { + water: 'Have some water, mate. On the house.', + done: "Righto — you're done for the night.", + escort: "Come on. Walk with me. Out you go.", +}; + +/** What you get back when you pitch it right for their state. */ +export const CUTOFF_REPLY_CLEAN: Record = { + sober: '"...yeah, nah, cheers?" He drinks the water. Confused, but hydrated.', + tipsy: '"Aw, legend." She takes the water and means it.', + loose: '"Yeah fair, fair. Good on ya." He sips it like medicine.', + messy: '"Nah I get it. I GET it." He shakes your hand for slightly too long.', + maggot: 'No words. Just a slow, dignified nod, and the walk of the defeated.', +}; + +/** Too much boot for their state — the whole room watches you overdo it. */ +export const CUTOFF_REPLY_SCENE: Record = { + sober: '"I have had ONE lemon squash." The booth behind you starts filming.', + tipsy: '"Mate. MATE. I\'m on my second." Her friends boo you in unison.', + loose: '"Are you serious? I\'m FINE." He is fine. That\'s the problem.', + messy: '"Don\'t TOUCH me—" Arms up. Drink goes sideways. Room turns.', + maggot: 'He goes limp on purpose. All ninety kilos of him. Crowd cheers.', +}; + +/** Too gentle for their state — they take the water and keep going. */ +export const CUTOFF_REPLY_SOFT: Record = { + sober: '"Ta." Nothing happened. Nothing was going to happen.', + tipsy: '"Sweet as." She wanders off, entirely unbothered.', + loose: '"Yeah yeah, water, love it." Straight back to the bar.', + messy: '"You\'re a good bloke." He pours the water into a pot plant.', + maggot: 'He accepts the water, salutes you, and staggers into the dance floor.', +}; + +/** Muffled through a stall door. They are not coming out politely. */ +export const STALL_MUFFLED: readonly string[] = [ + '"Piss off mate, I\'m BUSY."', + '"Occupied! ...by one person!"', + '"We are having a private conversation."', + '"Nobody in here but me and my reflection."', + '"Two seconds! ...Two more seconds!"', +]; + +export const STALL_BUSTED: readonly string[] = [ + 'The door swings. Two people leave in two directions at four times the speed of sound.', + 'Busted. One goes left, one goes through the smoking door, neither makes eye contact.', + 'They scatter like startled ibises. Someone drops a vape. Nobody comes back for it.', +]; + +export const STALL_OFF_BEAT = 'Your knuckles land in the gap between kicks. Even the door is embarrassed.'; + +/** Pat-down patter. Confiscation is a bit, not a punishment. */ +export const PATDOWN_INTRO = 'Arms out, mate. You know the drill.'; + +export const PATDOWN_FOUND: Record = { + baggie: 'Well. That\'s not a mint.', + flask: 'A hip flask. In this economy.', + goonSack: 'Sir, this is a licensed venue and that is a pillow of wine.', + tinnies: 'Two warm tinnies. Warm. That\'s the real crime.', + kebab: 'An entire kebab. Still wrapped. Honestly? Respect.', + someoneElsesId: 'This is Kylie. You are not Kylie.', + budgie: 'That is a live budgie. It is looking at me. Into the bin, mate.', + vapes: 'Fourteen vapes. Fourteen. Are you a shop?', + marker: 'Fat marker. We just repainted that cubicle.', + snags: 'Raw sausages. In your jacket. Why. WHY.', +}; + +export const PATDOWN_CLEAR = 'Nothing on him. "Told ya," he says, delighted.'; +export const PATDOWN_MISSED = 'Time. Whatever\'s left in there walks out the door with him.'; + +/** Kayden, on the radio, when a stampless patron turns up inside. */ +export const NO_STAMP_RADIO: readonly string[] = [ + '"Yeah sorry, that one might\'ve been me. Sorry. Sorry."', + '"He said he was getting his jacket. Forty minutes ago."', + '"I stamped SOMEONE. Pretty sure I stamped someone."', +]; + +export const HELP_TEXT = + 'WASD move · TAB uv · E interact · SPACE bang · ESC cancel · R reseed'; diff --git a/src/scenes/floor/venueMap.ts b/src/scenes/floor/venueMap.ts new file mode 100644 index 0000000..2cd2e7d --- /dev/null +++ b/src/scenes/floor/venueMap.ts @@ -0,0 +1,175 @@ +// Hand-authored top-down venue. Pure data + maths, no Phaser — the floor scene +// paints it, the AI walks it, the tests prove it hangs together. + +export const TILE = 16; +export const MAP_W = 80; // tiles +export const MAP_H = 45; // tiles + +export type TileKind = + | 'void' | 'floor' | 'wall' | 'bar' | 'stool' | 'dance' + | 'booth' | 'stall' | 'stallDoor' | 'exit' | 'smokeDoor' | 'neon'; + +export type AnchorKind = 'bar' | 'dance' | 'booth' | 'toilet' | 'smoke' | 'entry' | 'exit'; + +export interface TilePoint { tx: number; ty: number } +export interface WorldPoint { x: number; y: number } + +export interface StallDef { + id: string; // 's1'..'s4' + door: WorldPoint; // world px, the tile just OUTSIDE the door (where you stand) + inside: WorldPoint; // world px, centre of the cubicle +} + +export interface VenueMap { + width: number; + height: number; + tiles: readonly TileKind[]; // row-major, length MAP_W*MAP_H + anchors: Readonly>; // world px + stalls: readonly StallDef[]; +} + +// 'stall' is walkable, but each cubicle interior is sealed behind a non-walkable +// 'stallDoor' — no path leads in. Movers must not pick stall tiles as pathing +// targets; send a patron inside via StallDef.inside instead. Everything else +// walkable is mutually reachable (see tests/floor/venueMap.test.ts). +const WALKABLE: ReadonlySet = new Set([ + 'floor', 'dance', 'exit', 'smokeDoor', 'stall', +]); + +// Tuned UNDER the renderer's darkness sheet, not in isolation — at the original +// values the venue multiplied down to near-black and you couldn't tell the dance +// floor from a wall. Dark enough that you need the torch, light enough to navigate. +const COLOURS: Record = { + void: 0x05050a, + floor: 0x221e2b, + wall: 0x3a3448, + bar: 0x5c3f28, + stool: 0x6d4726, + dance: 0x33284a, + booth: 0x452c3c, + stall: 0x2e3838, + stallDoor: 0x4c6058, + exit: 0x2c5636, + smokeDoor: 0x3d4d5c, + neon: 0xd8409a, +}; + +// Cubicle west edges. Each cubicle is a 4x4 walled box; the last one shares the +// dunny room's east wall, which is why they stop at 73. CUBICLE_Y sits flush +// under the room's north wall — a gap there would strand a dead strip of floor. +const CUBICLE_X = [61, 65, 69, 73] as const; +const CUBICLE_Y = 4; + +function buildTiles(): TileKind[] { + const tiles = new Array(MAP_W * MAP_H).fill('floor'); + const set = (tx: number, ty: number, kind: TileKind): void => { + if (tx < 0 || tx >= MAP_W || ty < 0 || ty >= MAP_H) return; + tiles[ty * MAP_W + tx] = kind; + }; + const rect = (x0: number, y0: number, x1: number, y1: number, kind: TileKind): void => { + for (let ty = y0; ty <= y1; ty++) for (let tx = x0; tx <= x1; tx++) set(tx, ty, kind); + }; + const box = (x0: number, y0: number, x1: number, y1: number, kind: TileKind): void => { + rect(x0, y0, x1, y0, kind); + rect(x0, y1, x1, y1, kind); + rect(x0, y0, x0, y1, kind); + rect(x1, y0, x1, y1, kind); + }; + + box(0, 0, MAP_W - 1, MAP_H - 1, 'wall'); + + // Dead space west of the main room, carved back out for the entry corridor. + rect(1, 1, 13, MAP_H - 2, 'wall'); + rect(1, 21, 14, 23, 'floor'); + rect(1, 21, 1, 23, 'exit'); + + // Bar run along the north wall, stools hugging its south face. + rect(22, 4, 52, 5, 'bar'); + rect(22, 6, 52, 6, 'stool'); + + rect(26, 16, 50, 30, 'dance'); + + // Booths: south wall then east wall. + for (const tx of [22, 30, 38, 46]) rect(tx, 41, tx + 1, 42, 'booth'); + for (const ty of [22, 27, 32]) rect(76, ty, 77, ty + 1, 'booth'); + + // Dunnies: walled room in the north-east, one doorway on its south face. + box(60, 3, 76, 16, 'wall'); + rect(62, 16, 63, 16, 'floor'); + for (const x of CUBICLE_X) { + box(x, CUBICLE_Y, x + 3, CUBICLE_Y + 3, 'wall'); + rect(x + 1, CUBICLE_Y + 1, x + 2, CUBICLE_Y + 2, 'stall'); + set(x + 1, CUBICLE_Y + 3, 'stallDoor'); + } + + set(18, MAP_H - 1, 'smokeDoor'); + + // Accent strips the renderer hangs point lights off. + for (const tx of [20, 30, 40, 50, 60]) set(tx, 0, 'neon'); + for (const ty of [10, 20, 30]) set(MAP_W - 1, ty, 'neon'); + + return tiles; +} + +export function worldToTile(x: number, y: number): TilePoint { + return { tx: Math.floor(x / TILE), ty: Math.floor(y / TILE) }; +} + +/** Centre of the tile, not its corner — patrons stand on centres. */ +export function tileToWorld(tx: number, ty: number): WorldPoint { + return { x: tx * TILE + TILE / 2, y: ty * TILE + TILE / 2 }; +} + +function buildStalls(): StallDef[] { + return CUBICLE_X.map((x, i) => ({ + id: `s${i + 1}`, + door: tileToWorld(x + 1, CUBICLE_Y + 4), + // Centre of the 2x2 interior, which lands on a tile corner rather than a centre. + inside: { x: (x + 2) * TILE, y: (CUBICLE_Y + 2) * TILE }, + })); +} + +function buildAnchors(): Record { + const dance: WorldPoint[] = []; + for (const ty of [20, 26]) for (const tx of [30, 36, 42, 48]) dance.push(tileToWorld(tx, ty)); + return { + bar: [24, 29, 34, 39, 44, 49].map((tx) => tileToWorld(tx, 7)), + dance, + booth: [ + ...[22, 30, 38, 46].map((tx) => tileToWorld(tx, 40)), + tileToWorld(75, 22), + tileToWorld(75, 27), + ], + toilet: CUBICLE_X.map((x) => tileToWorld(x + 1, CUBICLE_Y + 4)), + smoke: [tileToWorld(18, MAP_H - 1)], + entry: [tileToWorld(4, 22), tileToWorld(9, 22)], + exit: [tileToWorld(1, 22)], + }; +} + +export const VENUE: VenueMap = Object.freeze({ + width: MAP_W, + height: MAP_H, + tiles: Object.freeze(buildTiles()), + anchors: Object.freeze(buildAnchors()), + stalls: Object.freeze(buildStalls()), +}); + +export function tileAt(map: VenueMap, tx: number, ty: number): TileKind { + if (tx < 0 || tx >= map.width || ty < 0 || ty >= map.height) return 'void'; + return map.tiles[ty * map.width + tx] ?? 'void'; +} + +export function isWalkable(map: VenueMap, tx: number, ty: number): boolean { + return WALKABLE.has(tileAt(map, tx, ty)); +} + +export function isWalkableWorld(map: VenueMap, x: number, y: number): boolean { + const { tx, ty } = worldToTile(x, y); + return isWalkable(map, tx, ty); +} + +/** Colour for painting a tile in the dark venue. */ +export function tileColour(kind: TileKind): number { + return COLOURS[kind]; +} diff --git a/src/scenes/shared/ParadeScene.ts b/src/scenes/shared/ParadeScene.ts index 78aee52..85744f9 100644 --- a/src/scenes/shared/ParadeScene.ts +++ b/src/scenes/shared/ParadeScene.ts @@ -45,6 +45,7 @@ export class ParadeScene extends Phaser.Scene { this.resetRun(this.seed); this.input.keyboard?.on('keydown-SPACE', () => this.resetRun(this.seed + 1)); + this.input.keyboard?.on('keydown-F', () => this.scene.start('FloorDemo')); // LANE-FLOOR demo this.input.keyboard?.on('keydown-D', () => { this.showIds = !this.showIds; for (const w of this.walkers) { @@ -89,7 +90,7 @@ export class ParadeScene extends Phaser.Scene { this.seedText = this.add.text(4, H - 12, '', { fontFamily: 'monospace', fontSize: '8px', color: '#667' }); this.clockText = this.add.text(W - 60, 4, '', { fontFamily: 'monospace', fontSize: '8px', color: '#9fe8a0' }); - this.add.text(4, 14, 'SPACE reseed · D toggle IDs', { fontFamily: 'monospace', fontSize: '8px', color: '#556' }); + this.add.text(4, 14, 'SPACE reseed · D toggle IDs · F floor demo', { fontFamily: 'monospace', fontSize: '8px', color: '#556' }); } private resetRun(seed: number): void { diff --git a/tests/floor/bangJudge.test.ts b/tests/floor/bangJudge.test.ts new file mode 100644 index 0000000..5c85b7e --- /dev/null +++ b/tests/floor/bangJudge.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; +import { + applyBang, + BANGS_TO_BUST, + beatError, + freshStall, + judgeBang, + OK_MS, + PERFECT_MS, + scoreStall, + type StallState, +} from '../../src/scenes/floor/bangJudge'; + +const INTERVAL = 60_000 / 128; +const TICK = { beatIndex: 4, audioTimeMs: 10_000 }; + +describe('beatError', () => { + it('is zero exactly on the tick', () => { + expect(beatError(TICK.audioTimeMs, TICK, INTERVAL)).toBe(0); + }); + + it('measures to the nearest boundary, not to the tick itself', () => { + expect(beatError(TICK.audioTimeMs + INTERVAL, TICK, INTERVAL)).toBeCloseTo(0, 6); + expect(beatError(TICK.audioTimeMs + 3 * INTERVAL + 25, TICK, INTERVAL)).toBeCloseTo(25, 6); + }); + + it('judges a bang landing before the tick against the previous boundary', () => { + expect(beatError(TICK.audioTimeMs - INTERVAL + 30, TICK, INTERVAL)).toBeCloseTo(30, 6); + expect(beatError(TICK.audioTimeMs - 20, TICK, INTERVAL)).toBeCloseTo(20, 6); + }); + + it('peaks halfway between beats', () => { + expect(beatError(TICK.audioTimeMs + INTERVAL / 2, TICK, INTERVAL)).toBeCloseTo(INTERVAL / 2, 6); + }); +}); + +describe('judgeBang', () => { + it('grades an exact hit as perfect', () => { + expect(judgeBang(TICK.audioTimeMs, TICK, INTERVAL)).toBe('perfect'); + }); + + it('still grades perfect a full beat interval later', () => { + expect(judgeBang(TICK.audioTimeMs + INTERVAL, TICK, INTERVAL)).toBe('perfect'); + expect(judgeBang(TICK.audioTimeMs + 8 * INTERVAL, TICK, INTERVAL)).toBe('perfect'); + }); + + it('grades halfway between beats as a miss', () => { + expect(judgeBang(TICK.audioTimeMs + INTERVAL / 2, TICK, INTERVAL)).toBe('miss'); + expect(judgeBang(TICK.audioTimeMs + 2.5 * INTERVAL, TICK, INTERVAL)).toBe('miss'); + }); + + it('treats PERFECT_MS as inclusive and just past it as ok', () => { + expect(judgeBang(TICK.audioTimeMs + PERFECT_MS, TICK, INTERVAL)).toBe('perfect'); + expect(judgeBang(TICK.audioTimeMs - PERFECT_MS, TICK, INTERVAL)).toBe('perfect'); + expect(judgeBang(TICK.audioTimeMs + PERFECT_MS + 0.5, TICK, INTERVAL)).toBe('ok'); + }); + + it('treats OK_MS as inclusive and just past it as a miss', () => { + expect(judgeBang(TICK.audioTimeMs + OK_MS, TICK, INTERVAL)).toBe('ok'); + expect(judgeBang(TICK.audioTimeMs - OK_MS, TICK, INTERVAL)).toBe('ok'); + expect(judgeBang(TICK.audioTimeMs + OK_MS + 0.5, TICK, INTERVAL)).toBe('miss'); + }); + + it('grades late bangs relative to whichever boundary is closest', () => { + // 100ms past the fourth boundary — ok, even though it is ~1875ms past the tick. + expect(judgeBang(TICK.audioTimeMs + 4 * INTERVAL + 100, TICK, INTERVAL)).toBe('ok'); + }); +}); + +describe('applyBang', () => { + it('does not mutate the input state', () => { + const s = freshStall(); + const snapshot: StallState = { ...s }; + applyBang(s, 'perfect'); + expect(s).toEqual(snapshot); + }); + + it('advances the combo on perfect and ok alike', () => { + const a = applyBang(freshStall(), 'perfect'); + const b = applyBang(a, 'ok'); + expect(b.combo).toBe(2); + expect(b.bangs).toBe(2); + expect(b.aggro).toBe(0); + expect(b.lastGrade).toBe('ok'); + }); + + it('resets the combo and adds aggro on a miss', () => { + const a = applyBang(applyBang(freshStall(), 'ok'), 'miss'); + expect(a.combo).toBe(0); + expect(a.bangs).toBe(2); + expect(a.aggro).toBeCloseTo(0.8, 6); + expect(a.busted).toBe(false); + }); + + it('busts after three consecutive ok bangs', () => { + let s = freshStall(); + for (let i = 0; i < BANGS_TO_BUST; i++) s = applyBang(s, 'ok'); + expect(s.busted).toBe(true); + }); + + it('stays busted once busted, even after a later miss', () => { + let s = freshStall(); + for (let i = 0; i < BANGS_TO_BUST; i++) s = applyBang(s, 'perfect'); + s = applyBang(s, 'miss'); + expect(s.busted).toBe(true); + expect(s.combo).toBe(0); + }); + + it('is not busted when a miss interrupts the run', () => { + let s = freshStall(); + for (const g of ['ok', 'miss', 'ok', 'ok'] as const) s = applyBang(s, g); + expect(s.busted).toBe(false); + expect(s.combo).toBe(2); + expect(s.bangs).toBe(4); + }); +}); + +describe('scoreStall', () => { + it('pays out big when the stall is busted', () => { + let s = freshStall(); + for (let i = 0; i < BANGS_TO_BUST; i++) s = applyBang(s, 'perfect'); + const out = scoreStall(s); + expect(out.solved).toBe(true); + expect(out.vibeDelta).toBe(9); + expect(out.aggroDelta).toBe(0); + }); + + it('costs vibe when the player made a scene and achieved nothing', () => { + const out = scoreStall(applyBang(freshStall(), 'miss')); + expect(out.solved).toBe(false); + expect(out.vibeDelta).toBe(-1.5); + expect(out.aggroDelta).toBeCloseTo(0.8, 6); + }); + + it('clamps accumulated aggro at 6', () => { + let s = freshStall(); + for (let i = 0; i < 12; i++) s = applyBang(s, 'miss'); + expect(s.aggro).toBeGreaterThan(6); + expect(scoreStall(s).aggroDelta).toBe(6); + }); + + it('carries aggro from misses through a bust', () => { + let s = freshStall(); + for (const g of ['miss', 'miss', 'ok', 'ok', 'perfect'] as const) s = applyBang(s, g); + const out = scoreStall(s); + expect(out.solved).toBe(true); + expect(out.aggroDelta).toBeCloseTo(1.6, 6); + }); +}); diff --git a/tests/floor/cone.test.ts b/tests/floor/cone.test.ts new file mode 100644 index 0000000..fe21e75 --- /dev/null +++ b/tests/floor/cone.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest'; +import { + NORMAL_CONE, UV_CONE, angleDelta, coneVisibility, conePolygon, inCone, +} from '../../src/scenes/floor/cone'; +import type { ConeSpec } from '../../src/scenes/floor/cone'; + +const EAST: ConeSpec = { x: 100, y: 100, facing: 0, ...NORMAL_CONE }; +const at = (spec: ConeSpec, dist: number, off: number) => ({ + x: spec.x + Math.cos(spec.facing + off) * dist, + y: spec.y + Math.sin(spec.facing + off) * dist, +}); + +describe('angleDelta', () => { + it('is zero for identical angles and full turns apart', () => { + expect(angleDelta(0, 0)).toBeCloseTo(0, 12); + expect(angleDelta(Math.PI * 2, 0)).toBeCloseTo(0, 12); + expect(angleDelta(-Math.PI * 4, 0)).toBeCloseTo(0, 12); + }); + + it('takes the short way around the wrap point', () => { + expect(angleDelta(0.1, -0.1)).toBeCloseTo(0.2, 12); + // 175 deg and -175 deg are 10 deg apart, not 350. + const a = Math.PI - 0.087; + const b = -Math.PI + 0.087; + expect(Math.abs(angleDelta(a, b))).toBeCloseTo(0.174, 6); + expect(Math.abs(angleDelta(b, a))).toBeCloseTo(0.174, 6); + }); + + it('stays in (-PI, PI] for a sweep of inputs', () => { + for (let i = -40; i <= 40; i++) { + const d = angleDelta(i * 0.37, i * -0.61); + expect(d).toBeGreaterThan(-Math.PI); + expect(d).toBeLessThanOrEqual(Math.PI + 1e-12); + } + }); + + it('maps an exact half turn to +PI, not -PI', () => { + expect(angleDelta(Math.PI, 0)).toBeCloseTo(Math.PI, 12); + expect(angleDelta(0, Math.PI)).toBeCloseTo(Math.PI, 12); + }); +}); + +describe('inCone', () => { + it('sees straight ahead', () => { + expect(inCone(EAST, 200, 100)).toBe(true); + expect(inCone(EAST, EAST.x, EAST.y)).toBe(true); + }); + + it('is blind behind and to the sides', () => { + expect(inCone(EAST, 0, 100)).toBe(false); + expect(inCone(EAST, 100, 20)).toBe(false); + expect(inCone(EAST, ...([at(EAST, 50, 1.2).x, at(EAST, 50, 1.2).y] as const))).toBe(false); + }); + + it('is blind beyond range', () => { + const far = at(EAST, EAST.range + 1, 0); + expect(inCone(EAST, far.x, far.y)).toBe(false); + const near = at(EAST, EAST.range - 1, 0); + expect(inCone(EAST, near.x, near.y)).toBe(true); + }); + + it('respects facing — rotating the cone rotates what it sees', () => { + const north: ConeSpec = { ...EAST, facing: -Math.PI / 2 }; + expect(inCone(north, 100, 20)).toBe(true); + expect(inCone(north, 200, 100)).toBe(false); + }); + + it('works across the PI wrap', () => { + const west: ConeSpec = { ...EAST, facing: Math.PI }; + const p = at(west, 60, 0.2); + expect(inCone(west, p.x, p.y)).toBe(true); + expect(inCone(west, 200, 100)).toBe(false); + }); + + it('UV torch is tighter and shorter than the normal one', () => { + expect(UV_CONE.halfAngle).toBeLessThan(NORMAL_CONE.halfAngle); + expect(UV_CONE.range).toBeLessThan(NORMAL_CONE.range); + const uv: ConeSpec = { x: 100, y: 100, facing: 0, ...UV_CONE }; + const p = at(EAST, 160, 0.5); + expect(inCone(EAST, p.x, p.y)).toBe(true); + expect(inCone(uv, p.x, p.y)).toBe(false); + }); +}); + +describe('coneVisibility', () => { + it('is 0 wherever inCone is false', () => { + for (let off = -2; off <= 2; off += 0.1) { + for (const dist of [10, 90, 189, 191, 400]) { + const p = at(EAST, dist, off); + if (!inCone(EAST, p.x, p.y)) expect(coneVisibility(EAST, p.x, p.y)).toBe(0); + } + } + }); + + it('is strictly positive inside and never above 1', () => { + for (const off of [0, 0.2, -0.2, 0.5, -0.5]) { + for (const dist of [1, 40, 120, 180]) { + const p = at(EAST, dist, off); + const v = coneVisibility(EAST, p.x, p.y); + expect(v).toBeGreaterThan(0); + expect(v).toBeLessThanOrEqual(1); + } + } + expect(coneVisibility(EAST, EAST.x, EAST.y)).toBe(1); + }); + + it('is brighter on-axis than off-axis at the same distance', () => { + const onAxis = at(EAST, 100, 0); + const offAxis = at(EAST, 100, 0.4); + expect(coneVisibility(EAST, onAxis.x, onAxis.y)) + .toBeGreaterThan(coneVisibility(EAST, offAxis.x, offAxis.y)); + }); + + it('decreases monotonically with distance along the axis', () => { + let prev = Infinity; + for (let d = 1; d < EAST.range; d += 10) { + const p = at(EAST, d, 0); + const v = coneVisibility(EAST, p.x, p.y); + expect(v).toBeLessThan(prev); + prev = v; + } + }); + + it('fades to nothing at the cone edge', () => { + const edge = at(EAST, 100, EAST.halfAngle); + expect(coneVisibility(EAST, edge.x, edge.y)).toBeCloseTo(0, 9); + const rim = at(EAST, EAST.range, 0); + expect(coneVisibility(EAST, rim.x, rim.y)).toBeCloseTo(0, 9); + }); +}); + +describe('conePolygon', () => { + it('starts at the apex and closes back on it', () => { + const pts = conePolygon(EAST, 12); + expect(pts[0]).toEqual({ x: EAST.x, y: EAST.y }); + expect(pts[pts.length - 1]).toEqual(pts[0]); + expect(pts).toHaveLength(12 + 3); // apex + (segments+1) arc points + closing apex + }); + + it('puts every arc point at exactly range, inside the cone', () => { + const pts = conePolygon(EAST, 16).slice(1, -1); + for (const p of pts) { + expect(Math.hypot(p.x - EAST.x, p.y - EAST.y)).toBeCloseTo(EAST.range, 9); + expect(Math.abs(angleDelta(Math.atan2(p.y - EAST.y, p.x - EAST.x), EAST.facing))) + .toBeLessThanOrEqual(EAST.halfAngle + 1e-9); + } + }); + + it('spans the full cone width, edge to edge', () => { + const pts = conePolygon(EAST, 8); + const firstArc = pts[1]!; + const lastArc = pts[pts.length - 2]!; + expect(angleDelta(Math.atan2(firstArc.y - EAST.y, firstArc.x - EAST.x), EAST.facing)) + .toBeCloseTo(-EAST.halfAngle, 9); + expect(angleDelta(Math.atan2(lastArc.y - EAST.y, lastArc.x - EAST.x), EAST.facing)) + .toBeCloseTo(EAST.halfAngle, 9); + }); + + it('tolerates a degenerate segment count', () => { + expect(conePolygon(EAST, 0)).toHaveLength(4); + expect(conePolygon(EAST, 1)).toHaveLength(4); + }); + + it('defaults to a smooth arc', () => { + expect(conePolygon(EAST).length).toBeGreaterThan(20); + }); +}); diff --git a/tests/floor/crowdSim.test.ts b/tests/floor/crowdSim.test.ts new file mode 100644 index 0000000..f937cdb --- /dev/null +++ b/tests/floor/crowdSim.test.ts @@ -0,0 +1,362 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { EventBus } from '../../src/core/EventBus'; +import { SeededRNG } from '../../src/core/SeededRNG'; +import type { Patron } from '../../src/data/types'; +import { _resetPatronSerial, generatePatron } from '../../src/patrons/generator'; +import { CrowdSim } from '../../src/scenes/floor/crowdSim'; +import type { Agent } from '../../src/scenes/floor/crowdSim'; +import { VENUE, isWalkableWorld } from '../../src/scenes/floor/venueMap'; + +const NIGHT = new Date('2026-07-19T21:00:00'); + +function makeSim(seed = 1234): CrowdSim { + return new CrowdSim({ map: VENUE, bus: new EventBus(), rng: new SeededRNG(seed).stream('crowd') }); +} + +/** Same seed in, same crowd out — the sims under test must not share patrons. */ +function makeCrowd(count: number, seed = 99): Patron[] { + _resetPatronSerial(); + const rng = new SeededRNG(seed); + const out: Patron[] = []; + for (let i = 0; i < count; i++) out.push(generatePatron({ rng, nightDate: NIGHT }, 60)); + return out; +} + +const isEntry = (a: Agent): boolean => + VENUE.anchors.entry.some((e) => e.x === a.x && e.y === a.y); + +/** Park an agent in a fixed activity so the test can drive one thing at a time. */ +function pin(a: Agent, activity: Agent['activity'], x?: number, y?: number): Agent { + a.activity = activity; + a.dwellMs = 10 * 60 * 1000; + if (x !== undefined) a.x = x; + if (y !== undefined) a.y = y; + return a; +} + +beforeEach(() => { + _resetPatronSerial(); +}); + +describe('spawn', () => { + it('puts the agent on an entry anchor, unhandled and present', () => { + const sim = makeSim(); + for (const p of makeCrowd(6)) { + const a = sim.spawn(p); + expect(isEntry(a)).toBe(true); + expect(a.handled).toBe(false); + expect(a.gone).toBe(false); + expect(a.activity).toBe('walking'); + } + expect(sim.agents).toHaveLength(6); + }); + + it('finds agents by patron id', () => { + const sim = makeSim(); + const crowd = makeCrowd(3); + for (const p of crowd) sim.spawn(p); + expect(sim.find(crowd[1]!.id)?.patron).toBe(crowd[1]); + expect(sim.find('nobody')).toBeUndefined(); + }); +}); + +describe('movement', () => { + it('keeps every agent on walkable ground for a whole night', () => { + const sim = makeSim(); + for (const p of makeCrowd(14)) sim.spawn(p); + + for (let step = 0; step < 6000; step++) { + sim.update(16, step / 60); + for (const a of sim.agents) { + expect( + isWalkableWorld(VENUE, a.x, a.y), + `${a.patron.id} walked into a wall at ${a.x.toFixed(1)},${a.y.toFixed(1)}`, + ).toBe(true); + } + } + }); + + it('actually gets people to their anchors — nobody walks all night', () => { + const sim = makeSim(); + for (const p of makeCrowd(10)) sim.spawn(p); + const settled = new Set(); + for (let step = 0; step < 4000; step++) { + sim.update(16, step / 60); + for (const a of sim.agents) if (a.activity !== 'walking') settled.add(a.patron.id); + } + expect(settled.size).toBe(10); + }); +}); + +describe('intoxication drift', () => { + it('rises with the clock, and faster at the bar', () => { + const sim = makeSim(); + const [barP, danceP] = makeCrowd(2); + const bar = pin(sim.spawn(barP!), 'atBar'); + const dance = pin(sim.spawn(danceP!), 'dancing'); + for (const a of [bar, dance]) { + a.patron.tolerance = 0.5; + a.patron.intoxication = 0.3; + } + + sim.update(16, 0); + sim.update(16, 90); + + expect(bar.patron.intoxication).toBeGreaterThan(0.3); + expect(dance.patron.intoxication).toBeGreaterThan(0.3); + expect(bar.patron.intoxication).toBeGreaterThan(dance.patron.intoxication); + }); + + it('is framerate independent — only clock minutes count', () => { + const coarse = makeSim(); + const fine = makeSim(); + const [a1] = makeCrowd(1); + const [a2] = makeCrowd(1); + const slow = pin(coarse.spawn(a1!), 'atBar'); + const fast = pin(fine.spawn(a2!), 'atBar'); + for (const a of [slow, fast]) { + a.patron.tolerance = 0.4; + a.patron.intoxication = 0.2; + } + + coarse.update(16, 0); + coarse.update(16, 60); + fine.update(16, 0); + for (let i = 1; i <= 60; i++) fine.update(16, i); + + expect(fast.patron.intoxication).toBeCloseTo(slow.patron.intoxication, 10); + }); + + it('never tips past legless', () => { + const sim = makeSim(); + const a = pin(sim.spawn(makeCrowd(1)[0]!), 'atBar'); + a.patron.tolerance = 0; + a.patron.intoxication = 0.9; + sim.update(16, 0); + sim.update(16, 10000); + expect(a.patron.intoxication).toBe(1); + }); +}); + +describe('beat bob', () => { + it('advances dancers only', () => { + const sim = makeSim(); + const [p1, p2] = makeCrowd(2); + const dancer = pin(sim.spawn(p1!), 'dancing'); + const drinker = pin(sim.spawn(p2!), 'atBar'); + const before = { dancer: dancer.bobPhase, drinker: drinker.bobPhase }; + + sim.onBeat(1); + expect(dancer.bobPhase).not.toBe(before.dancer); + expect(drinker.bobPhase).toBe(before.drinker); + }); + + it('keeps the phase inside 0..1 across a long set', () => { + const sim = makeSim(); + const dancer = pin(sim.spawn(makeCrowd(1)[0]!), 'dancing'); + for (let beat = 0; beat < 500; beat++) { + sim.onBeat(beat); + expect(dancer.bobPhase).toBeGreaterThanOrEqual(0); + expect(dancer.bobPhase).toBeLessThan(1); + } + }); +}); + +describe('stalls', () => { + it('reports who is in which cubicle', () => { + const sim = makeSim(); + const [p1, p2, p3] = makeCrowd(3); + const a = pin(sim.spawn(p1!), 'inStall'); + const b = pin(sim.spawn(p2!), 'inStall'); + const c = pin(sim.spawn(p3!), 'inStall'); + a.stallId = 's1'; + b.stallId = 's1'; + c.stallId = 's3'; + + const occ = sim.stallOccupancy(); + expect(occ.get('s1')).toHaveLength(2); + expect(occ.get('s2')).toHaveLength(0); + expect(occ.get('s3')).toEqual([c]); + }); + + it('lets two punters end up in one cubicle on its own', () => { + const sim = makeSim(); + for (const p of makeCrowd(14)) sim.spawn(p); + let doubled = false; + let maxOccupancy = 0; + for (let step = 0; step < 9000; step++) { + sim.update(16, step / 60); + for (const [, list] of sim.stallOccupancy()) { + maxOccupancy = Math.max(maxOccupancy, list.length); + if (list.length >= 2) doubled = true; + } + } + expect(doubled).toBe(true); + expect(maxOccupancy).toBeLessThanOrEqual(2); // the cap holds + }); +}); + +describe('bumping', () => { + function bumpRig(): { sim: CrowdSim; hits: number[]; drunk: Agent } { + const bus = new EventBus(); + const sim = new CrowdSim({ map: VENUE, bus, rng: new SeededRNG(5).stream('crowd') }); + const hits: number[] = []; + bus.on('meters:delta', (d) => hits.push(d.aggro ?? 0)); + + const [p1, p2] = makeCrowd(2); + const drunk = pin(sim.spawn(p1!), 'dancing'); + const mate = pin(sim.spawn(p2!), 'dancing'); + drunk.patron.intoxication = 0.95; + drunk.patron.tolerance = 1; // no drift, so the stage under test stays put + mate.patron.intoxication = 0.05; + mate.patron.tolerance = 1; + mate.x = drunk.x; + mate.y = drunk.y; + return { sim, hits, drunk }; + } + + it('fires once, then not again until the cooldown lapses', () => { + const { sim, hits } = bumpRig(); + for (let i = 0; i < 100; i++) sim.update(16, 0); // 1.6s of grinding together + expect(hits).toHaveLength(1); + expect(hits[0]).toBeGreaterThan(0); + + // Second leak needs BOTH windows open: the 12s per-agent cooldown and the + // 8s sim-wide gate. 800 more frames puts total elapsed at ~14.4s. + for (let i = 0; i < 800; i++) sim.update(16, 0); + expect(hits).toHaveLength(2); + }); + + // Regression: the per-agent cooldown bounds one PAIR, but the leak rate used + // to scale with crowd size — a full late-night floor pinned vibe to 0 and + // aggro to 100 on its own, before the player had touched anything. The + // sim-wide gate is what keeps ambient chaos a background cost. + it('bounds the total leak no matter how big the rowdy crowd is', () => { + const bus = new EventBus(); + const sim = new CrowdSim({ map: VENUE, bus, rng: new SeededRNG(9).stream('crowd') }); + let vibe = 0; + bus.on('meters:delta', (d) => { + vibe += d.vibe ?? 0; + }); + + // 30 maggots stacked on one spot — the worst case the venue can produce. + const agents = makeCrowd(30).map((p) => pin(sim.spawn(p), 'dancing')); + const first = agents[0]!; + for (const a of agents) { + a.patron.intoxication = 0.95; + a.patron.tolerance = 1; + a.x = first.x; + a.y = first.y; + } + + for (let i = 0; i < 3750; i++) sim.update(16, 0); // 60s of maximum chaos + // One leak per 8s, so a minute of it costs ~8 bumps, not hundreds. + expect(vibe).toBeGreaterThan(-5); + expect(vibe).toBeLessThan(0); // but it does still cost you something + }); + + it('stays quiet when the sober one is the only neighbour', () => { + const { sim, hits, drunk } = bumpRig(); + drunk.patron.intoxication = 0.1; // nobody here is messy + for (let i = 0; i < 400; i++) sim.update(16, 0); + expect(hits).toHaveLength(0); + }); + + it('carries the contracted vibe/aggro payload', () => { + const bus = new EventBus(); + const sim = new CrowdSim({ map: VENUE, bus, rng: new SeededRNG(5).stream('crowd') }); + const seen: Array<{ vibe?: number; aggro?: number }> = []; + bus.on('meters:delta', (d) => seen.push(d)); + const [p1, p2] = makeCrowd(2); + const drunk = pin(sim.spawn(p1!), 'dancing'); + const mate = pin(sim.spawn(p2!), 'dancing'); + drunk.patron.intoxication = 0.95; + drunk.patron.tolerance = 1; + mate.patron.tolerance = 1; + mate.x = drunk.x; + mate.y = drunk.y; + sim.update(16, 0); + expect(seen).toEqual([{ vibe: -0.4, aggro: 0.6 }]); + }); +}); + +describe('determinism', () => { + it('same seed and same calls give identical positions', () => { + const snap = (sim: CrowdSim): string => sim.agents + .map((a) => `${a.patron.id}:${a.x.toFixed(6)},${a.y.toFixed(6)}:${a.activity}:${a.patron.intoxication.toFixed(6)}`) + .join('|'); + + const runOne = (): string => { + const sim = makeSim(4242); + for (const p of makeCrowd(9)) sim.spawn(p); + for (let step = 0; step < 3000; step++) sim.update(16, step / 60); + return snap(sim); + }; + + expect(runOne()).toBe(runOne()); + }); +}); + +describe('escort', () => { + it('walks them to the door when you follow, and only then', () => { + const sim = makeSim(); + const a = sim.spawn(makeCrowd(1)[0]!); + // Start them deep in the dunnies — the nastiest route out of the venue. + a.x = VENUE.stalls[0]!.door.x; + a.y = VENUE.stalls[0]!.door.y; + + expect(sim.beginEscort(a)).toBe(true); + expect(a.activity).toBe('escorted'); + expect(a.handled).toBe(true); + expect(sim.beginEscort(a)).toBe(false); + + // Stand off and they refuse to budge. + const parked = { x: a.x, y: a.y }; + for (let i = 0; i < 100; i++) sim.updateEscorts(parked.x + 400, parked.y + 200, 16); + expect(a.x).toBe(parked.x); + expect(a.y).toBe(parked.y); + expect(a.gone).toBe(false); + + // Stay on their heels and they go. + for (let i = 0; i < 12000 && !a.gone; i++) { + sim.updateEscorts(a.x, a.y, 16); + expect(isWalkableWorld(VENUE, a.x, a.y)).toBe(true); + } + expect(a.gone).toBe(true); + expect(a.activity).toBe('leaving'); + expect(a.x).toBe(VENUE.anchors.exit[0]!.x); + expect(a.y).toBe(VENUE.anchors.exit[0]!.y); + }); + + it('refuses to escort someone already gone', () => { + const sim = makeSim(); + const a = sim.spawn(makeCrowd(1)[0]!); + sim.beginEscort(a); + for (let i = 0; i < 12000 && !a.gone; i++) sim.updateEscorts(a.x, a.y, 16); + expect(a.gone).toBe(true); + expect(sim.beginEscort(a)).toBe(false); + }); + + it('pulls them out of the cubicle they were hiding in', () => { + const sim = makeSim(); + const a = pin(sim.spawn(makeCrowd(1)[0]!), 'inStall'); + a.stallId = 's2'; + a.x = VENUE.stalls[1]!.inside.x; + a.y = VENUE.stalls[1]!.inside.y; + + expect(sim.beginEscort(a)).toBe(true); + expect(a.stallId).toBeUndefined(); + expect(a.x).toBe(VENUE.stalls[1]!.door.x); + expect(sim.stallOccupancy().get('s2')).toHaveLength(0); + }); +}); + +describe('destroy', () => { + it('drops the crowd', () => { + const sim = makeSim(); + for (const p of makeCrowd(4)) sim.spawn(p); + sim.destroy(); + expect(sim.agents).toHaveLength(0); + expect(sim.find('p0')).toBeUndefined(); + }); +}); diff --git a/tests/floor/escalation.test.ts b/tests/floor/escalation.test.ts new file mode 100644 index 0000000..9e90e33 --- /dev/null +++ b/tests/floor/escalation.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; +import { + CORRECT_LINE, + MAGGOT_GRACE_MIN, + judgeEscalation, + maggotOverdue, +} from '../../src/scenes/floor/escalation'; +import type { DrunkStage } from '../../src/data/types'; +import { + CUTOFF_REPLY_CLEAN, + CUTOFF_REPLY_SCENE, + CUTOFF_REPLY_SOFT, + ESCALATION_LINES, + type EscalationLine, +} from '../../src/scenes/floor/strings'; + +const STAGES: readonly DrunkStage[] = ['sober', 'tipsy', 'loose', 'messy', 'maggot']; +const LINES: readonly EscalationLine[] = ['water', 'done', 'escort']; + +describe('judgeEscalation matrix', () => { + it('returns a coherent result for every (stage, line) pair', () => { + for (const stage of STAGES) { + for (const line of LINES) { + const r = judgeEscalation(stage, line); + expect(['clean', 'scene', 'staggerFree']).toContain(r.outcome); + expect(r.line).toBe(ESCALATION_LINES[line]); + expect(r.line.length).toBeGreaterThan(0); + expect(r.reply.length).toBeGreaterThan(0); + expect(Object.values(CUTOFF_REPLY_CLEAN) + .concat(Object.values(CUTOFF_REPLY_SCENE), Object.values(CUTOFF_REPLY_SOFT))) + .toContain(r.reply); + expect(r.aggroDelta).toBeGreaterThanOrEqual(0); + if (r.eject) expect(r.outcome).toBe('clean'); + if (!r.resolved) expect(r.outcome).toBe('staggerFree'); + } + } + }); + + it('pulls the reply from the bank matching its outcome', () => { + for (const stage of STAGES) { + for (const line of LINES) { + const r = judgeEscalation(stage, line); + const bank = + r.outcome === 'clean' ? CUTOFF_REPLY_CLEAN + : r.outcome === 'scene' ? CUTOFF_REPLY_SCENE + : CUTOFF_REPLY_SOFT; + expect(r.reply).toBe(bank[stage]); + } + } + }); + + it('is clean whenever the line matches the correct rung', () => { + for (const stage of STAGES) { + const r = judgeEscalation(stage, CORRECT_LINE[stage]); + expect(r.outcome).toBe('clean'); + expect(r.vibeDelta).toBeGreaterThan(0); + expect(r.aggroDelta).toBe(0); + expect(r.resolved).toBe(true); + } + }); + + it('keeps the clean reward modest — never a jackpot for being right', () => { + for (const stage of STAGES) { + expect(judgeEscalation(stage, CORRECT_LINE[stage]).vibeDelta).toBeLessThanOrEqual(5); + } + }); +}); + +describe('over-escalating', () => { + it('makes a scene with positive aggro on a loose patron', () => { + const r = judgeEscalation('loose', 'done'); + expect(r.outcome).toBe('scene'); + expect(r.aggroDelta).toBeGreaterThan(0); + expect(r.vibeDelta).toBeLessThan(0); + expect(r.eject).toBe(false); + expect(r.resolved).toBe(true); + }); + + it('scales aggro and vibe loss with the distance overreached', () => { + const oneRung = judgeEscalation('loose', 'done'); + const twoRungs = judgeEscalation('loose', 'escort'); + expect(twoRungs.aggroDelta).toBe(oneRung.aggroDelta * 2); + expect(twoRungs.vibeDelta).toBe(oneRung.vibeDelta * 2); + }); + + it('costs more than restraint does, so aggression never pays', () => { + const scene = judgeEscalation('sober', 'escort'); + const soft = judgeEscalation('maggot', 'water'); + expect(scene.vibeDelta).toBeLessThan(soft.vibeDelta); + }); +}); + +describe('under-escalating', () => { + it('lets a maggot stagger free and leaves the infraction open', () => { + const r = judgeEscalation('maggot', 'water'); + expect(r.outcome).toBe('staggerFree'); + expect(r.resolved).toBe(false); + expect(r.eject).toBe(false); + expect(r.aggroDelta).toBe(0); + expect(r.vibeDelta).toBeLessThan(0); + }); + + it('does not eject or resolve when you water a messy patron', () => { + const r = judgeEscalation('messy', 'water'); + expect(r.outcome).toBe('staggerFree'); + expect(r.eject).toBe(false); + expect(r.resolved).toBe(false); + }); +}); + +describe('ejection', () => { + it('escorts a maggot out', () => { + const r = judgeEscalation('maggot', 'escort'); + expect(r.outcome).toBe('clean'); + expect(r.eject).toBe(true); + }); + + it('walks a messy patron when you call them done', () => { + expect(judgeEscalation('messy', 'done').eject).toBe(true); + }); + + it('never ejects someone who only needed water', () => { + for (const stage of ['sober', 'tipsy', 'loose'] as const) { + expect(judgeEscalation(stage, 'water').eject).toBe(false); + } + }); +}); + +describe('maggotOverdue', () => { + it('is false at the grace boundary and true one minute past', () => { + expect(maggotOverdue(100, 100 + MAGGOT_GRACE_MIN)).toBe(false); + expect(maggotOverdue(100, 100 + MAGGOT_GRACE_MIN + 1)).toBe(true); + }); + + it('is false before the grace window elapses', () => { + expect(maggotOverdue(100, 100)).toBe(false); + expect(maggotOverdue(100, 110)).toBe(false); + }); +}); diff --git a/tests/floor/patDown.test.ts b/tests/floor/patDown.test.ts new file mode 100644 index 0000000..ac9c040 --- /dev/null +++ b/tests/floor/patDown.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest'; +import { SeededRNG } from '../../src/core/SeededRNG'; +import { PATDOWN_CLEAR, PATDOWN_MISSED } from '../../src/scenes/floor/strings'; +import { + foundLine, + layoutPockets, + scorePatDown, + POCKET_COUNT, + type Pocket, +} from '../../src/scenes/floor/patDown'; +import type { Patron } from '../../src/data/types'; + +const patronWith = (contraband: string[]): Patron => ({ + id: 'p1', + dollSeed: 42, + archetype: 'punter', + age: 24, + idCard: { name: 'Shazza Nguyen', dob: '2002-03-04', expiry: '2029-03-04', photoSeed: 42 }, + outfit: [], + intoxication: 0.3, + tolerance: 0.5, + flags: contraband.length > 0 ? { contraband } : {}, +}); + +const stream = (seed: number) => new SeededRNG(seed).stream('patdown'); +const binAll = (pockets: Pocket[]): Pocket[] => pockets.map((p) => ({ ...p, binned: p.itemId !== undefined })); + +describe('layoutPockets', () => { + it('returns POCKET_COUNT pockets with unique ids at distinct positions', () => { + const pockets = layoutPockets(patronWith(['flask']), stream(1)); + expect(pockets).toHaveLength(POCKET_COUNT); + expect(new Set(pockets.map((p) => p.id)).size).toBe(POCKET_COUNT); + expect(new Set(pockets.map((p) => `${p.x},${p.y}`)).size).toBe(POCKET_COUNT); + }); + + it('keeps pockets inside the 64x96 outline and clear of each other', () => { + const pockets = layoutPockets(patronWith([]), stream(2)); + for (const p of pockets) { + expect(p.x).toBeGreaterThanOrEqual(0); + expect(p.y).toBeGreaterThanOrEqual(0); + expect(p.x).toBeLessThanOrEqual(64); + expect(p.y).toBeLessThanOrEqual(96); + } + for (let i = 0; i < pockets.length; i++) { + for (let j = i + 1; j < pockets.length; j++) { + const a = pockets[i]!; + const b = pockets[j]!; + const clear = Math.abs(a.x - b.x) >= 12 || Math.abs(a.y - b.y) >= 12; + expect(clear, `${a.label} overlaps ${b.label}`).toBe(true); + } + } + }); + + it('places every contraband id in a distinct pocket', () => { + const items = ['flask', 'kebab', 'budgie']; + const pockets = layoutPockets(patronWith(items), stream(3)); + const filled = pockets.filter((p) => p.itemId !== undefined); + expect(filled).toHaveLength(items.length); + expect(new Set(filled.map((p) => p.id)).size).toBe(items.length); + expect(filled.map((p) => p.itemId).sort()).toEqual([...items].sort()); + }); + + it('leaves an empty-handed patron with no items and nothing revealed or binned', () => { + const pockets = layoutPockets(patronWith([]), stream(4)); + expect(pockets.every((p) => p.itemId === undefined)).toBe(true); + expect(pockets.every((p) => !p.revealed && !p.binned)).toBe(true); + }); + + it('places only what fits when there is more contraband than pockets', () => { + const items = ['flask', 'kebab', 'budgie', 'vapes', 'marker', 'snags', 'tinnies', 'goonSack']; + const pockets = layoutPockets(patronWith(items), stream(5)); + expect(pockets.filter((p) => p.itemId !== undefined)).toHaveLength(POCKET_COUNT); + }); + + it('is deterministic for the same seed and divergent across seeds', () => { + const items = ['flask', 'kebab', 'budgie']; + const a = layoutPockets(patronWith(items), stream(7)).map((p) => p.itemId); + const b = layoutPockets(patronWith(items), stream(7)).map((p) => p.itemId); + expect(a).toEqual(b); + + const seeds = [11, 12, 13, 14, 15, 16].map((s) => + layoutPockets(patronWith(items), stream(s)) + .map((p) => p.itemId ?? '-') + .join(), + ); + expect(new Set(seeds).size).toBeGreaterThan(1); + }); +}); + +describe('scorePatDown', () => { + it('rewards a full sweep: every item found, positive vibe, no aggro', () => { + const patron = patronWith(['flask', 'kebab']); + const result = scorePatDown(patron, binAll(layoutPockets(patron, stream(21)))); + expect(result.foundIds.sort()).toEqual(['flask', 'kebab']); + expect(result.missedIds).toEqual([]); + expect(result.vibeDelta).toBe(4); + expect(result.aggroDelta).toBe(0); + expect(result.summary).not.toContain(PATDOWN_MISSED); + }); + + it('leaks vibe for items left in the pockets when time runs out', () => { + const patron = patronWith(['flask', 'kebab']); + const result = scorePatDown(patron, layoutPockets(patron, stream(22))); + expect(result.foundIds).toEqual([]); + expect(result.missedIds.sort()).toEqual(['flask', 'kebab']); + expect(result.vibeDelta).toBe(-3); + expect(result.summary).toBe(PATDOWN_MISSED); + expect(result.eject).toBe(false); + }); + + it('mixes found and missed, and still flags the miss', () => { + const patron = patronWith(['flask', 'kebab']); + const pockets = layoutPockets(patron, stream(23)).map((p) => ({ + ...p, + binned: p.itemId === 'flask', + })); + const result = scorePatDown(patron, pockets); + expect(result.foundIds).toEqual(['flask']); + expect(result.missedIds).toEqual(['kebab']); + expect(result.vibeDelta).toBe(0.5); + expect(result.summary).toContain(PATDOWN_MISSED); + }); + + it('ejects on a binned severity-3 item but not a severity-1 one', () => { + const baggie = patronWith(['baggie']); + expect(scorePatDown(baggie, binAll(layoutPockets(baggie, stream(31)))).eject).toBe(true); + + const kebab = patronWith(['kebab']); + const kebabResult = scorePatDown(kebab, binAll(layoutPockets(kebab, stream(32)))); + expect(kebabResult.eject).toBe(false); + expect(kebabResult.foundIds).toEqual(['kebab']); + }); + + it('does not eject for a severity-3 item that was never binned', () => { + const patron = patronWith(['baggie']); + const result = scorePatDown(patron, layoutPockets(patron, stream(33))); + expect(result.eject).toBe(false); + expect(result.missedIds).toEqual(['baggie']); + }); + + it('charges aggro and reads PATDOWN_CLEAR when they had nothing on them', () => { + const patron = patronWith([]); + const result = scorePatDown(patron, layoutPockets(patron, stream(41))); + expect(result.foundIds).toEqual([]); + expect(result.missedIds).toEqual([]); + expect(result.aggroDelta).toBe(1.5); + expect(result.vibeDelta).toBe(0); + expect(result.eject).toBe(false); + expect(result.summary).toBe(PATDOWN_CLEAR); + }); +}); + +describe('foundLine', () => { + it('uses the scripted line for a known item', () => { + expect(foundLine('budgie')).toContain('budgie'); + }); + + it('falls back gracefully for an unknown id', () => { + const line = foundLine('a-second-budgie'); + expect(typeof line).toBe('string'); + expect(line.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/floor/player.test.ts b/tests/floor/player.test.ts new file mode 100644 index 0000000..574dca1 --- /dev/null +++ b/tests/floor/player.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; +import { + PLAYER_ACCEL, PLAYER_FRICTION, PLAYER_MAX_SPEED, PLAYER_RADIUS, + freshPlayer, stepPlayer, +} from '../../src/scenes/floor/player'; +import type { PlayerInput, PlayerState } from '../../src/scenes/floor/player'; +import { TILE, VENUE, isWalkableWorld, tileToWorld } from '../../src/scenes/floor/venueMap'; + +const NONE: PlayerInput = { up: false, down: false, left: false, right: false }; +const input = (over: Partial): PlayerInput => ({ ...NONE, ...over }); + +// Open dance floor, and a spot one tile north of the south border wall. +const OPEN = tileToWorld(38, 23); +const NEAR_SOUTH_WALL = tileToWorld(40, 43); +const SOUTH_WALL_Y = 44 * TILE; + +function run(p: PlayerState, i: PlayerInput, steps: number, ms = 16): PlayerState { + let s = p; + for (let n = 0; n < steps; n++) s = stepPlayer(s, i, ms, VENUE); + return s; +} + +const speedOf = (s: PlayerState): number => Math.hypot(s.vx, s.vy); + +/** The collision test the mover itself uses. */ +function standing(s: PlayerState): boolean { + return isWalkableWorld(VENUE, s.x + PLAYER_RADIUS, s.y) + && isWalkableWorld(VENUE, s.x - PLAYER_RADIUS, s.y) + && isWalkableWorld(VENUE, s.x, s.y + PLAYER_RADIUS) + && isWalkableWorld(VENUE, s.x, s.y - PLAYER_RADIUS); +} + +describe('freshPlayer', () => { + it('starts at rest where it was put', () => { + const p = freshPlayer(100, 200); + expect(p).toEqual({ x: 100, y: 200, vx: 0, vy: 0, facing: 0 }); + }); + + it('exposes a heavy tuning — accel far outstrips top speed', () => { + expect(PLAYER_ACCEL).toBeGreaterThan(PLAYER_MAX_SPEED); + expect(PLAYER_FRICTION).toBeGreaterThan(0); + }); +}); + +describe('acceleration', () => { + it('accelerates from rest', () => { + const p = stepPlayer(freshPlayer(OPEN.x, OPEN.y), input({ right: true }), 16, VENUE); + expect(p.vx).toBeGreaterThan(0); + expect(p.x).toBeGreaterThan(OPEN.x); + expect(p.vy).toBe(0); + }); + + it('clamps to PLAYER_MAX_SPEED however long you hold it', () => { + const p = run(freshPlayer(OPEN.x, OPEN.y), input({ right: true }), 40); + expect(speedOf(p)).toBeCloseTo(PLAYER_MAX_SPEED, 6); + expect(speedOf(p)).toBeLessThanOrEqual(PLAYER_MAX_SPEED + 1e-9); + }); + + it('caps diagonals at the same top speed, not 1.41x', () => { + const p = run(freshPlayer(OPEN.x, OPEN.y), input({ right: true, down: true }), 40); + expect(speedOf(p)).toBeLessThanOrEqual(PLAYER_MAX_SPEED + 1e-9); + expect(p.vx).toBeCloseTo(p.vy, 6); + }); +}); + +describe('friction', () => { + it('decelerates with no input, without reversing', () => { + const moving = run(freshPlayer(OPEN.x, OPEN.y), input({ right: true }), 40); + const a = run(moving, NONE, 5); + const b = run(a, NONE, 5); + expect(speedOf(a)).toBeLessThan(speedOf(moving)); + expect(speedOf(b)).toBeLessThan(speedOf(a)); + expect(b.vx).toBeGreaterThan(0); + expect(run(b, NONE, 400).vx).toBeCloseTo(0, 3); + }); +}); + +describe('facing', () => { + it('follows the direction of travel', () => { + const right = run(freshPlayer(OPEN.x, OPEN.y), input({ right: true }), 20); + expect(right.facing).toBeCloseTo(0, 4); + const up = run(freshPlayer(OPEN.x, OPEN.y), input({ up: true }), 20); + expect(up.facing).toBeCloseTo(-Math.PI / 2, 4); + }); + + it('persists when input stops and the body coasts to a halt', () => { + const up = run(freshPlayer(OPEN.x, OPEN.y), input({ up: true }), 20); + const stopped = run(up, NONE, 600); + expect(speedOf(stopped)).toBeLessThan(1); + expect(stopped.facing).toBeCloseTo(up.facing, 6); + }); +}); + +describe('collision', () => { + it('cannot walk through a wall', () => { + const p = run(freshPlayer(NEAR_SOUTH_WALL.x, NEAR_SOUTH_WALL.y), input({ down: true }), 200); + expect(standing(p)).toBe(true); + expect(p.y + PLAYER_RADIUS).toBeLessThan(SOUTH_WALL_Y); + expect(p.vy).toBe(0); + }); + + it('slides along a wall when moving diagonally into it', () => { + const start = freshPlayer(NEAR_SOUTH_WALL.x, NEAR_SOUTH_WALL.y); + const p = run(start, input({ down: true, right: true }), 120); + expect(standing(p)).toBe(true); + expect(p.x).toBeGreaterThan(start.x + 50); + expect(p.y + PLAYER_RADIUS).toBeLessThan(SOUTH_WALL_Y); + }); + + it('does not tunnel on a monstrous delta', () => { + const one = stepPlayer(freshPlayer(NEAR_SOUTH_WALL.x, NEAR_SOUTH_WALL.y), input({ down: true }), 60000, VENUE); + expect(standing(one)).toBe(true); + expect(one.y + PLAYER_RADIUS).toBeLessThan(SOUTH_WALL_Y); + + // ...and repeated giant deltas are still just clamped steps. + const many = run(one, input({ down: true }), 30, 9999); + expect(standing(many)).toBe(true); + expect(many.y + PLAYER_RADIUS).toBeLessThan(SOUTH_WALL_Y); + }); + + it('keeps a full patrol of the open floor on walkable ground', () => { + let p = freshPlayer(OPEN.x, OPEN.y); + const legs: PlayerInput[] = [ + input({ right: true }), input({ down: true }), input({ left: true }), + input({ up: true }), input({ up: true, left: true }), input({ down: true, right: true }), + ]; + for (const leg of legs) { + for (let n = 0; n < 300; n++) { + p = stepPlayer(p, leg, 16, VENUE); + expect(standing(p)).toBe(true); + } + } + }); +}); diff --git a/tests/floor/venueMap.test.ts b/tests/floor/venueMap.test.ts new file mode 100644 index 0000000..fd7f8aa --- /dev/null +++ b/tests/floor/venueMap.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from 'vitest'; +import { + MAP_H, MAP_W, TILE, VENUE, + isWalkable, isWalkableWorld, tileAt, tileColour, tileToWorld, worldToTile, +} from '../../src/scenes/floor/venueMap'; +import type { AnchorKind, TileKind, WorldPoint } from '../../src/scenes/floor/venueMap'; + +const ANCHOR_KINDS: readonly AnchorKind[] = ['bar', 'dance', 'booth', 'toilet', 'smoke', 'entry', 'exit']; +const allAnchors = (): WorldPoint[] => ANCHOR_KINDS.flatMap((k) => [...VENUE.anchors[k]]); + +const key = (tx: number, ty: number): number => ty * MAP_W + tx; + +/** Every walkable tile 4-connected to the seed. */ +function flood(seed: WorldPoint): Set { + const start = worldToTile(seed.x, seed.y); + const seen = new Set(); + if (!isWalkable(VENUE, start.tx, start.ty)) return seen; + const stack = [start]; + seen.add(key(start.tx, start.ty)); + while (stack.length > 0) { + const cur = stack.pop()!; + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) { + const tx = cur.tx + dx; + const ty = cur.ty + dy; + const k = key(tx, ty); + if (seen.has(k) || !isWalkable(VENUE, tx, ty)) continue; + seen.add(k); + stack.push({ tx, ty }); + } + } + return seen; +} + +describe('venueMap dimensions', () => { + it('matches the shared geometry constants', () => { + expect(TILE).toBe(16); + expect(VENUE.width).toBe(MAP_W); + expect(VENUE.height).toBe(MAP_H); + expect(MAP_W * TILE).toBe(1280); + expect(MAP_H * TILE).toBe(720); + }); + + it('has one tile per cell, all of known kinds', () => { + expect(VENUE.tiles).toHaveLength(MAP_W * MAP_H); + const known = new Set([ + 'void', 'floor', 'wall', 'bar', 'stool', 'dance', + 'booth', 'stall', 'stallDoor', 'exit', 'smokeDoor', 'neon', + ]); + for (const t of VENUE.tiles) expect(known.has(t)).toBe(true); + }); + + it('is ringed by non-walkable edges apart from doors', () => { + for (let tx = 0; tx < MAP_W; tx++) { + for (const ty of [0, MAP_H - 1]) { + const kind = tileAt(VENUE, tx, ty); + if (kind === 'smokeDoor') continue; + expect(isWalkable(VENUE, tx, ty)).toBe(false); + } + } + }); + + it('contains every authored tile kind', () => { + const seen = new Set(VENUE.tiles); + for (const k of ['floor', 'wall', 'bar', 'stool', 'dance', 'booth', 'stall', 'stallDoor', 'exit', 'smokeDoor', 'neon'] as const) { + expect(seen.has(k)).toBe(true); + } + }); +}); + +describe('tileAt / walkability', () => { + it('reports void out of bounds', () => { + expect(tileAt(VENUE, -1, 0)).toBe('void'); + expect(tileAt(VENUE, 0, -1)).toBe('void'); + expect(tileAt(VENUE, MAP_W, 0)).toBe('void'); + expect(tileAt(VENUE, 0, MAP_H)).toBe('void'); + expect(isWalkable(VENUE, -5, -5)).toBe(false); + }); + + it('walks floor/dance/exit/smokeDoor/stall and nothing else', () => { + const walkable = new Set(['floor', 'dance', 'exit', 'smokeDoor', 'stall']); + for (let ty = 0; ty < MAP_H; ty++) { + for (let tx = 0; tx < MAP_W; tx++) { + expect(isWalkable(VENUE, tx, ty)).toBe(walkable.has(tileAt(VENUE, tx, ty))); + } + } + }); + + it('agrees between tile and world walkability queries', () => { + for (const a of allAnchors()) expect(isWalkableWorld(VENUE, a.x, a.y)).toBe(true); + expect(isWalkableWorld(VENUE, -8, -8)).toBe(false); + }); +}); + +describe('coordinate conversion', () => { + it('round-trips tile -> world -> tile', () => { + for (let ty = 0; ty < MAP_H; ty += 3) { + for (let tx = 0; tx < MAP_W; tx += 3) { + const w = tileToWorld(tx, ty); + expect(worldToTile(w.x, w.y)).toEqual({ tx, ty }); + } + } + }); + + it('returns tile centres, and floors world coords', () => { + expect(tileToWorld(0, 0)).toEqual({ x: 8, y: 8 }); + expect(tileToWorld(3, 5)).toEqual({ x: 56, y: 88 }); + expect(worldToTile(0, 0)).toEqual({ tx: 0, ty: 0 }); + expect(worldToTile(15.9, 31.9)).toEqual({ tx: 0, ty: 1 }); + }); +}); + +describe('anchors', () => { + it('meets the minimum counts per kind', () => { + const min: Record = { + bar: 6, dance: 8, booth: 4, toilet: 4, smoke: 1, entry: 1, exit: 1, + }; + for (const k of ANCHOR_KINDS) { + expect(VENUE.anchors[k].length).toBeGreaterThanOrEqual(min[k]); + } + }); + + it('places every anchor on a walkable tile centre', () => { + for (const k of ANCHOR_KINDS) { + for (const a of VENUE.anchors[k]) { + expect(a.x % TILE).toBe(TILE / 2); + expect(a.y % TILE).toBe(TILE / 2); + expect(isWalkableWorld(VENUE, a.x, a.y)).toBe(true); + } + } + }); + + it('CRITICAL: every anchor is 4-connected to every other anchor', () => { + const anchors = allAnchors(); + const first = anchors[0]!; + const reachable = flood(first); + for (const a of anchors) { + const t = worldToTile(a.x, a.y); + expect( + reachable.has(key(t.tx, t.ty)), + `anchor at tile ${t.tx},${t.ty} is cut off from ${JSON.stringify(worldToTile(first.x, first.y))}`, + ).toBe(true); + } + }); + + it('leaves no orphaned open floor — every non-stall walkable tile is reachable', () => { + const reachable = flood(VENUE.anchors.entry[0]!); + const orphans: string[] = []; + for (let ty = 0; ty < MAP_H; ty++) { + for (let tx = 0; tx < MAP_W; tx++) { + if (!isWalkable(VENUE, tx, ty) || reachable.has(key(tx, ty))) continue; + // Cubicle interiors are sealed behind their doors by design. + if (tileAt(VENUE, tx, ty) === 'stall') continue; + orphans.push(`${tx},${ty}`); + } + } + expect(orphans, `unreachable open tiles: ${orphans.join(' ')}`).toEqual([]); + }); + + it('anchors of different kinds sit on distinct tiles within their kind', () => { + for (const k of ANCHOR_KINDS) { + const keys = VENUE.anchors[k].map((a) => { + const t = worldToTile(a.x, a.y); + return key(t.tx, t.ty); + }); + expect(new Set(keys).size).toBe(keys.length); + } + }); +}); + +describe('stalls', () => { + it('has four cubicles with unique ids', () => { + expect(VENUE.stalls).toHaveLength(4); + expect(new Set(VENUE.stalls.map((s) => s.id)).size).toBe(4); + for (const s of VENUE.stalls) expect(s.id).toMatch(/^s[1-4]$/); + }); + + it('door tiles are walkable and sit directly south of a stallDoor tile', () => { + for (const s of VENUE.stalls) { + const d = worldToTile(s.door.x, s.door.y); + expect(isWalkable(VENUE, d.tx, d.ty)).toBe(true); + expect(tileAt(VENUE, d.tx, d.ty - 1)).toBe('stallDoor'); + } + }); + + it('inside points land on stall tiles', () => { + for (const s of VENUE.stalls) { + // The 2x2 interior centre is a tile corner; probe just inside it. + expect(isWalkableWorld(VENUE, s.inside.x - 1, s.inside.y - 1)).toBe(true); + const t = worldToTile(s.inside.x - 1, s.inside.y - 1); + expect(tileAt(VENUE, t.tx, t.ty)).toBe('stall'); + } + }); + + it('seals each cubicle behind its door — the only way in is StallDef.inside', () => { + for (const s of VENUE.stalls) { + const d = worldToTile(s.door.x, s.door.y); + const reachable = flood(s.door); + const interior = worldToTile(s.inside.x - 1, s.inside.y - 1); + expect(reachable.has(key(interior.tx, interior.ty))).toBe(false); + // ...and the door tile still connects back to the rest of the venue. + expect(reachable.has(key(worldToTile(VENUE.anchors.entry[0]!.x, VENUE.anchors.entry[0]!.y).tx, + worldToTile(VENUE.anchors.entry[0]!.x, VENUE.anchors.entry[0]!.y).ty))).toBe(true); + expect(isWalkable(VENUE, d.tx, d.ty)).toBe(true); + } + }); + + it('exposes one toilet anchor per stall door', () => { + const doors = VENUE.stalls.map((s) => `${s.door.x},${s.door.y}`).sort(); + const anchors = VENUE.anchors.toilet.map((a) => `${a.x},${a.y}`).sort(); + expect(anchors).toEqual(doors); + }); +}); + +describe('tileColour', () => { + it('gives every kind a distinct 24-bit colour', () => { + const kinds: readonly TileKind[] = [ + 'void', 'floor', 'wall', 'bar', 'stool', 'dance', + 'booth', 'stall', 'stallDoor', 'exit', 'smokeDoor', 'neon', + ]; + const colours = kinds.map(tileColour); + for (const c of colours) { + expect(Number.isInteger(c)).toBe(true); + expect(c).toBeGreaterThanOrEqual(0); + expect(c).toBeLessThanOrEqual(0xffffff); + } + expect(new Set(colours).size).toBe(kinds.length); + }); +});