// 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' | 'squaringUp'; 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 /** In-game minutes this patron intends to stay before going home. */ stayMinutes: number; /** Clock minute they hit the floor (set on first update tick). */ arrivedAtMin?: number; /** Done for the night: walking to the entry, then gone (emits floor:leave). */ headingHome?: boolean; } export interface CrowdSimOpts { map: VenueMap; bus: EventBus; rng: RngStream; } type Haunt = 'bar' | 'dance' | 'booth' | 'toilet' | 'smoke'; const HAUNT_KINDS: readonly Haunt[] = ['bar', 'dance', 'booth', 'toilet', 'smoke']; // Where each sort of punter gravitates. Weights, not probabilities — every // archetype needs an entry or the crowd stops moving. const HAUNTS: Readonly>>> = { fresh18: { bar: 3, dance: 6, booth: 2, toilet: 2, smoke: 1 }, almost18: { bar: 2, dance: 5, booth: 3, toilet: 2, smoke: 2 }, financeBro: { bar: 8, dance: 2, booth: 3, toilet: 2, smoke: 1 }, influencer: { bar: 2, dance: 8, booth: 2, toilet: 2, smoke: 1 }, regular: { bar: 6, dance: 2, booth: 3, toilet: 1, smoke: 4 }, ownersMate: { bar: 7, dance: 2, booth: 5, toilet: 1, smoke: 2 }, quietMessy: { bar: 3, dance: 1, booth: 7, toilet: 3, smoke: 2 }, inspector: { bar: 2, dance: 1, booth: 4, toilet: 2, smoke: 1 }, punter: { bar: 4, dance: 4, booth: 3, toilet: 2, smoke: 2 }, }; const ACTIVITY_FOR: Readonly> = { bar: 'atBar', dance: 'dancing', booth: 'inBooth', toilet: 'inStall', smoke: 'smoking', }; const SPEED_MIN = 28; const SPEED_MAX = 44; // Transits across a 1280x720 venue take ~30s at these speeds, so short dwells // left ~68% of the crowd permanently walking and the bar/dance floor reading as // empty. The player has to be able to FIND infractions where they'd expect them. const DWELL_MIN_MS = 7000; const DWELL_MAX_MS = 22_000; const ARRIVE_PX = 2.5; const WAYPOINT_PX = 9; // half a tile — loose enough that a drunk sway can't orbit one const MAX_STEP_MS = 50; const STUCK_MS = 700; // Intoxication per in-game minute for a patron with zero tolerance. At the bar // you're actively buying; everywhere else you're nursing what you've got. const BAR_DRIFT_PER_MIN = 0.011; const AMBIENT_DRIFT_PER_MIN = 0.0012; const SWAY_BY_STAGE: Readonly> = { sober: 0, tipsy: 4, loose: 10, messy: 18, maggot: 26, }; const SWAY_RATE = 0.006; // radians per ms — roughly a one-second wobble const STUMBLE_CHANCE_PER_SEC = 0.5; const STUMBLE_MIN_MS = 180; const STUMBLE_MAX_MS = 520; const STUMBLE_ARC = 0.9; // worst heading error, radians const BUMP_DIST_PX = 10; const BUMP_COOLDOWN_MS = 12_000; // Per-agent cooldown alone doesn't bound the LEAK — the emission rate scales // with how many rowdy patrons are on the floor, so a full late-night venue // pinned vibe to 0 and aggro to 100 by ~1AM with the player doing nothing. // This gate makes ambient chaos cost a roughly fixed amount per night // regardless of crowd size; the player's own mistakes stay legible against it. const BUMP_GATE_MS = 8000; const BUMP_VIBE = -0.4; const BUMP_AGGRO = 0.6; // Chance a dunny-bound patron shoulders into a cubicle that's already busy. const STALL_SHARE_CHANCE = 0.3; const STALL_CAP = 2; const ESCORT_SPEED = 22; // the walk of a bloke who knows it's over const ESCORT_AHEAD_PX = 18; // they only shuffle on while you're this close behind const BOB_STEP = 0.25; // four beats to a full bob, i.e. one bar const NEIGHBOURS = [[1, 0], [-1, 0], [0, 1], [0, -1]] as const; interface Plan { haunt: Haunt; stallId?: string; // reserved while walking, so the cap survives the trip over path: WorldPoint[]; // detour waypoints; empty means steer straight at the target bestDist: number; // closest approach to the current steer point so far stuckMs: number; stumbleMs: number; stumbleAngle: number; } function rowdy(a: Agent): boolean { const s = drunkStage(a.patron.intoxication); return s === 'messy' || s === 'maggot'; } /** Deterministic per-patron pace — same doll, same dawdle, every run. */ function paceOf(patron: Patron): number { const span = SPEED_MAX - SPEED_MIN + 1; return SPEED_MIN + (Math.abs(patron.dollSeed) % span); } export class CrowdSim { readonly agents: Agent[] = []; private readonly map: VenueMap; private readonly bus: EventBus; private readonly rng: RngStream; private readonly plans = new WeakMap(); private elapsedMs = 0; private prevClockMin = Number.NaN; private bumpGateMs = BUMP_GATE_MS; constructor(opts: CrowdSimOpts) { this.map = opts.map; this.bus = opts.bus; this.rng = opts.rng; } spawn(patron: Patron): Agent { const entries = this.map.anchors.entry; const at = entries.length > 0 ? this.rng.pick(entries) : { x: 0, y: 0 }; const agent: Agent = { patron, x: at.x, y: at.y, vx: 0, vy: 0, activity: 'walking', target: { x: at.x, y: at.y }, dwellMs: 0, bobPhase: (Math.abs(patron.dollSeed) % 100) / 100, handled: false, gone: false, lastBumpMs: BUMP_COOLDOWN_MS, // Nobody stays all night. 50-140 in-game minutes, then they head home — // without this, capacity was a one-way ratchet and the door had to lock // shut from 1 AM (tuning pass 2026-07-19). Derived from dollSeed, not the // stream: drawing here would shift every later roll in the sim. stayMinutes: 50 + (Math.abs(patron.dollSeed) % 91), }; 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.arrivedAtMin === undefined) a.arrivedAtMin = clockMin; // Both are driven from outside: the player's escort, and the fight director. if (a.activity === 'escorted' || a.activity === 'squaringUp') continue; if ( !a.headingHome && a.activity !== 'inStall' && clockMin >= a.arrivedAtMin + a.stayMinutes ) { this.headHome(a); } if (a.activity === 'walking') this.walk(a, ms); else this.dwell(a, ms); } this.bumps(); } /** Call from a bus.on('beat:tick') handler. Advances dance bob only. */ onBeat(beatIndex: number): void { // The downbeat lands harder than the rest of the bar. const step = beatIndex % 4 === 0 ? BOB_STEP * 1.5 : BOB_STEP; for (const a of this.agents) { if (a.gone || a.activity !== 'dancing') continue; a.bobPhase = (a.bobPhase + step) % 1; } } /** Agents currently inside each stall, keyed by stall id. */ stallOccupancy(): Map { const out = new Map(); for (const s of this.map.stalls) out.set(s.id, []); for (const a of this.agents) { if (a.gone || a.activity !== 'inStall' || a.stallId === undefined) continue; const list = out.get(a.stallId); if (list) list.push(a); else out.set(a.stallId, [a]); } return out; } /** Attach to the player for a walk-to-the-exit escort. Returns false if already escorted. */ beginEscort(agent: Agent): boolean { if (agent.gone || agent.activity === 'escorted' || agent.activity === 'leaving') return false; const exit = this.nearestExit(agent.x, agent.y); if (!exit) return false; if (agent.activity === 'inStall') { const stall = this.stallById(agent.stallId); if (stall) { agent.x = stall.door.x; agent.y = stall.door.y; } } agent.stallId = undefined; agent.activity = 'escorted'; agent.handled = true; agent.dwellMs = 0; agent.target = exit; const plan = this.plans.get(agent); if (plan) { plan.stallId = undefined; plan.stumbleMs = 0; plan.bestDist = Infinity; plan.stuckMs = 0; // Always route the perp walk — the door is a long way from the dunnies and // a straight line would wedge them in a corner with you shouting at them. plan.path = this.findPath(agent.x, agent.y, exit) ?? []; } return true; } /** Called each frame with the player position so escorted agents walk ahead of you. */ updateEscorts(playerX: number, playerY: number, deltaMs: number): void { const dt = Math.max(0, Math.min(deltaMs, MAX_STEP_MS)) / 1000; const step = ESCORT_SPEED * dt; for (const a of this.agents) { if (a.gone || a.activity !== 'escorted') continue; const plan = this.plans.get(a); if (!plan) continue; const point = this.steerPoint(a, plan); const dx = point.x - a.x; const dy = point.y - a.y; const dist = Math.hypot(dx, dy); if (plan.path.length === 0 && dist <= Math.max(ARRIVE_PX, step)) { a.x = point.x; a.y = point.y; a.vx = 0; a.vy = 0; a.activity = 'leaving'; a.gone = true; continue; } // They shuffle on only while you're breathing down their neck; stop pushing // and they stop walking. if (Math.hypot(playerX - a.x, playerY - a.y) > ESCORT_AHEAD_PX) { a.vx = 0; a.vy = 0; continue; } const ux = dx / (dist || 1); const uy = dy / (dist || 1); a.vx = ux * ESCORT_SPEED; a.vy = uy * ESCORT_SPEED; this.tryMove(a, ux * step, uy * step); } } /** Pin an agent for a brewing fight. False if it isn't available (gone/escorted/in a stall). */ pinForFight(agent: Agent): boolean { if (agent.gone) return false; if ( agent.activity === 'escorted' || agent.activity === 'leaving' || agent.activity === 'inStall' || agent.activity === 'squaringUp' ) return false; agent.activity = 'squaringUp'; agent.vx = 0; agent.vy = 0; // Whatever they were nursing is over; the fight owns the dwell now. agent.dwellMs = 0; return true; } /** Release back into the crowd; they walk it off. */ releaseFromFight(agent: Agent): void { // Anything else got hold of them mid-fight (escorted, then gone) — leave it alone. if (agent.activity !== 'squaringUp') return; agent.activity = 'walking'; agent.dwellMs = 0; agent.stallId = undefined; const plan = this.plans.get(agent); if (plan) plan.stumbleMs = 0; this.chooseTarget(agent); } 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 } /** The bar cut them off: done for the night, they walk themselves out. */ sendHome(a: Agent): void { if (a.gone || a.activity === 'escorted') return; this.headHome(a); } /** Done for the night: point them at the way out. */ private headHome(a: Agent): void { const plan = this.plans.get(a); if (!plan) return; a.headingHome = true; a.activity = 'walking'; a.dwellMs = 0; plan.path = []; plan.bestDist = Infinity; plan.stuckMs = 0; plan.stallId = undefined; const entries = this.map.anchors.entry; if (entries.length > 0) a.target = this.rng.pick(entries); } private arrive(a: Agent, plan: Plan): void { a.vx = 0; a.vy = 0; if (a.headingHome) { a.activity = 'leaving'; a.gone = true; this.bus.emit('floor:leave', { patronId: a.patron.id }); return; } a.dwellMs = this.rng.int(DWELL_MIN_MS, DWELL_MAX_MS); if (plan.haunt === 'toilet' && plan.stallId !== undefined) { const stall = this.stallById(plan.stallId); if (stall) { a.activity = 'inStall'; a.stallId = stall.id; a.x = stall.inside.x; a.y = stall.inside.y; return; } } a.x = a.target.x; a.y = a.target.y; a.activity = ACTIVITY_FOR[plan.haunt]; } private chooseTarget(a: Agent): void { const plan = this.plans.get(a); if (!plan) return; plan.stallId = undefined; plan.path = []; plan.bestDist = Infinity; plan.stuckMs = 0; const weights = HAUNTS[a.patron.archetype]; let haunt = this.rng.weighted(HAUNT_KINDS.map((k) => [k, weights[k]] as const)); if (haunt === 'toilet') { const stall = this.claimStall(); if (stall) { plan.haunt = 'toilet'; plan.stallId = stall.id; a.target = stall.door; return; } haunt = 'bar'; // every dunny's full — go queue for another drink instead } const spots = this.map.anchors[haunt]; plan.haunt = haunt; if (spots.length > 0) a.target = this.rng.pick(spots); } /** Reserve a cubicle, occasionally squeezing into one that's already busy. */ private claimStall(): StallDef | undefined { const counts = new Map(); for (const s of this.map.stalls) counts.set(s.id, 0); const bump = (id: string | undefined): void => { if (id === undefined) return; counts.set(id, (counts.get(id) ?? 0) + 1); }; for (const other of this.agents) { if (other.gone) continue; bump(other.stallId); const p = this.plans.get(other); if (other.activity === 'walking' && p) bump(p.stallId); } const free = this.map.stalls.filter((s) => (counts.get(s.id) ?? 0) === 0); const busy = this.map.stalls.filter((s) => { const n = counts.get(s.id) ?? 0; return n > 0 && n < STALL_CAP; }); if (busy.length > 0 && (free.length === 0 || this.rng.chance(STALL_SHARE_CHANCE))) { return this.rng.pick(busy); } if (free.length > 0) return this.rng.pick(free); return undefined; } private bumps(): void { for (let i = 0; i < this.agents.length; i++) { const a = this.agents[i]; if (!a || a.gone) continue; for (let j = i + 1; j < this.agents.length; j++) { const b = this.agents[j]; if (!b || b.gone) continue; if (Math.hypot(a.x - b.x, a.y - b.y) >= BUMP_DIST_PX) continue; for (const who of [a, b]) { if (!rowdy(who) || who.lastBumpMs < BUMP_COOLDOWN_MS) continue; who.lastBumpMs = 0; // Still track the individual cooldown above (so one pair grinding // together can't chain), but only the gate opens the till. if (this.bumpGateMs < BUMP_GATE_MS) continue; this.bumpGateMs = 0; this.bus.emit('meters:delta', { vibe: BUMP_VIBE, aggro: BUMP_AGGRO }); } } } } private tryMove(a: Agent, dx: number, dy: number): void { if (this.open(a.x + dx, a.y + dy)) { a.x += dx; a.y += dy; return; } if (this.open(a.x + dx, a.y)) a.x += dx; // slide along the wall else if (this.open(a.x, a.y + dy)) a.y += dy; } private open(x: number, y: number): boolean { return isWalkableWorld(this.map, x, y); } /** 4-connected BFS over walkable tiles. Waypoints are tile centres. */ private findPath(fromX: number, fromY: number, to: WorldPoint): WorldPoint[] | undefined { const w = this.map.width; const h = this.map.height; const start = worldToTile(fromX, fromY); const goal = worldToTile(to.x, to.y); const startK = start.ty * w + start.tx; const goalK = goal.ty * w + goal.tx; if (startK === goalK) return []; if (!isWalkable(this.map, goal.tx, goal.ty)) return undefined; const prev = new Int32Array(w * h).fill(-1); const seen = new Uint8Array(w * h); const queue = [startK]; seen[startK] = 1; for (let head = 0; head < queue.length; head++) { const cur = queue[head] ?? -1; if (cur === goalK) break; const cx = cur % w; const cy = (cur - cx) / w; for (const [dx, dy] of NEIGHBOURS) { const nx = cx + dx; const ny = cy + dy; if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue; const k = ny * w + nx; if (seen[k] === 1 || !isWalkable(this.map, nx, ny)) continue; seen[k] = 1; prev[k] = cur; queue.push(k); } } if (seen[goalK] !== 1) return undefined; const out: WorldPoint[] = []; let cur = goalK; while (cur !== startK) { const cx = cur % w; out.push(tileToWorld(cx, (cur - cx) / w)); const p = prev[cur] ?? -1; if (p < 0) break; cur = p; } out.reverse(); return out; } private stallById(id: string | undefined): StallDef | undefined { if (id === undefined) return undefined; return this.map.stalls.find((s) => s.id === id); } private nearestExit(x: number, y: number): WorldPoint | undefined { let best: WorldPoint | undefined; let bestD = Infinity; for (const e of this.map.anchors.exit) { const d = Math.hypot(e.x - x, e.y - y); if (d < bestD) { bestD = d; best = e; } } return best; } }