not-tonight/src/scenes/floor/crowdSim.ts

653 lines
21 KiB
TypeScript

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