LANE-FLOOR: the Floor — patrol, flashlight/UV, cut-off, stalls, pat-down

FloorDemoScene (F from Parade): an 80x45-tile venue you patrol in the dark with
a flashlight cone that reveals detail, plus all three v0.2 infractions and the
UV stamp check. Self-populates from the patron generator; consumes Patron[] only,
so Phase 2 can hand it the real admitted list.

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-19 18:49:13 +10:00
parent 9805ff8fe5
commit 3eee8432d4
23 changed files with 4455 additions and 2 deletions

View File

@ -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. DOOR first, JUICE second, FLOOR third.
3. Every session ends with the gate + a SESSION block here + a push. Fable 3. Every session ends with the gate + a SESSION block here + a push. Fable
reviews on request ("review the lanes"). 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.

View File

@ -1,6 +1,7 @@
import Phaser from 'phaser'; import Phaser from 'phaser';
import { BootScene } from './scenes/shared/BootScene'; import { BootScene } from './scenes/shared/BootScene';
import { ParadeScene } from './scenes/shared/ParadeScene'; import { ParadeScene } from './scenes/shared/ParadeScene';
import { FloorDemoScene } from './scenes/floor/FloorDemoScene';
const game = new Phaser.Game({ const game = new Phaser.Game({
type: Phaser.AUTO, type: Phaser.AUTO,
@ -13,7 +14,7 @@ const game = new Phaser.Game({
autoCenter: Phaser.Scale.CENTER_BOTH, autoCenter: Phaser.Scale.CENTER_BOTH,
zoom: Phaser.Scale.MAX_ZOOM, zoom: Phaser.Scale.MAX_ZOOM,
}, },
scene: [BootScene, ParadeScene], scene: [BootScene, ParadeScene, FloorDemoScene],
}); });
// debug handle (harmless in prod; used by dev tooling) // debug handle (harmless in prod; used by dev tooling)

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,334 @@
import Phaser from 'phaser';
import type { EventBus } from '../../../core/EventBus';
import { PALETTE } from '../../../data/outfits';
import type { Patron } from '../../../data/types';
import {
BANGS_TO_BUST,
OK_MS,
PERFECT_MS,
applyBang,
freshStall,
judgeBang,
scoreStall,
type StallOutcome,
type StallState,
} from '../bangJudge';
import { STALL_BUSTED, STALL_MUFFLED, STALL_OFF_BEAT } from '../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));
}
}

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

@ -0,0 +1,120 @@
// The pat-down minigame's pure half: where the pockets are, what's in them,
// and what it costs you. Phaser-free so the scoring stays testable.
import type { RngStream } from '../../core/SeededRNG';
import { CONTRABAND } from '../../data/contraband';
import type { Patron } from '../../data/types';
import { PATDOWN_CLEAR, PATDOWN_FOUND, PATDOWN_MISSED } from './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.';
}

View File

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

View File

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

View File

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

View File

@ -45,6 +45,7 @@ export class ParadeScene extends Phaser.Scene {
this.resetRun(this.seed); this.resetRun(this.seed);
this.input.keyboard?.on('keydown-SPACE', () => this.resetRun(this.seed + 1)); 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.input.keyboard?.on('keydown-D', () => {
this.showIds = !this.showIds; this.showIds = !this.showIds;
for (const w of this.walkers) { 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.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.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 { private resetRun(seed: number): void {

View File

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

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

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

View File

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

View File

@ -0,0 +1,139 @@
import { describe, expect, it } from 'vitest';
import {
CORRECT_LINE,
MAGGOT_GRACE_MIN,
judgeEscalation,
maggotOverdue,
} from '../../src/scenes/floor/escalation';
import type { DrunkStage } from '../../src/data/types';
import {
CUTOFF_REPLY_CLEAN,
CUTOFF_REPLY_SCENE,
CUTOFF_REPLY_SOFT,
ESCALATION_LINES,
type EscalationLine,
} from '../../src/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);
});
});

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

@ -0,0 +1,163 @@
import { describe, expect, it } from 'vitest';
import { SeededRNG } from '../../src/core/SeededRNG';
import { PATDOWN_CLEAR, PATDOWN_MISSED } from '../../src/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);
});
});

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

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

View File

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