// Owns the fight lifecycle end to end: when one brews, who's in it, whether you // got between them in time, and what it cost. The scene only draws and narrates. // fight.ts stays pure; this is the bit that touches the crowd and the bus. import type { EventBus } from '../../core/EventBus'; import type { RngStream } from '../../core/SeededRNG'; import type { Activity, Agent, CrowdSim } from './crowdSim'; import { FIGHT_COOLDOWN_MS, fightEligible, freshFight, isBetween, scoreFight, stepFight, } from './fight'; import type { FightOutcome, FightPhase, FightState } from './fight'; import { FIGHT_BROKEN, FIGHT_CAUSE, FIGHT_SWUNG } from '../../data/strings/floor'; // Not spoken dialogue — this lands in the heat log, so it reads as a licensing // clerk's shorthand rather than as anything Dazza would say out loud. const HEAT_REASON = 'Fight on the floor'; // You can only square up from somewhere you can actually swing. Anyone mid-stall, // mid-escort or already pinned is somebody else's problem. const AVAILABLE: ReadonlySet = new Set([ 'walking', 'atBar', 'dancing', 'inBooth', 'smoking', ]); export interface FightDirectorOpts { bus: EventBus; rng: RngStream; crowd: CrowdSim; } interface Live { state: FightState; a: Agent; b: Agent; cause: string; } export interface FightResolution { outcome: FightOutcome; phase: FightPhase; line: string; } export class FightDirector { private readonly bus: EventBus; private readonly rng: RngStream; private readonly crowd: CrowdSim; private live: Live | null = null; private resolution: FightResolution | null = null; // Starts full so the first fight of the night doesn't need a warm-up. private cooldownMs = FIGHT_COOLDOWN_MS; constructor(opts: FightDirectorOpts) { this.bus = opts.bus; this.rng = opts.rng; this.crowd = opts.crowd; } /** The live fight, or null. At most ONE at a time — two is unreadable in the dark. */ get active(): FightState | null { return this.live?.state ?? null; } /** Flavour for the current fight, from strings.ts FIGHT_CAUSE. */ get cause(): string { return this.live?.cause ?? ''; } /** The two agents squaring up, or null. */ pair(): [Agent, Agent] | null { return this.live ? [this.live.a, this.live.b] : null; } /** Step the fight and/or consider starting one. */ update(playerX: number, playerY: number, deltaMs: number): void { const ms = Math.max(0, deltaMs); const live = this.live; if (live) { this.step(live, playerX, playerY, ms); return; } // Only counted while the floor is calm — a long fight doesn't earn you a rest. this.cooldownMs += ms; if (this.cooldownMs >= FIGHT_COOLDOWN_MS) this.tryStart(); } /** Latest resolution for the scene to narrate, consumed once (returns then clears). */ takeResolution(): FightResolution | null { const r = this.resolution; this.resolution = null; return r; } destroy(): void { if (this.live) { this.release(this.live.a); this.release(this.live.b); } this.live = null; this.resolution = null; } // ---- internals ---- private step(live: Live, playerX: number, playerY: number, ms: number): void { // Chucked out (or otherwise removed) mid-argument: there's no fight to score, // and nobody dealt with anything. if (live.a.gone || live.b.gone) { this.abort(live); return; } const intervening = isBetween(playerX, playerY, live.a.x, live.a.y, live.b.x, live.b.y); const next = stepFight(live.state, ms, intervening); live.state = next; if (next.phase === 'shoving') return; this.resolve(live, next); } private resolve(live: Live, state: FightState): void { const outcome = scoreFight(state); const broken = state.phase === 'broken'; this.bus.emit('meters:delta', { vibe: outcome.vibeDelta, aggro: outcome.aggroDelta }); // Deferred, not on the spot: design §3.2 hedges a missed fight as "possible // Heat", and the contract's deferred flag means it surfaces at inspection or // audit instead. A brawl you didn't stop is exactly the sort of thing that // comes back at you later — that's the game's long-memory conscience mechanic. if (outcome.heat) this.bus.emit('heat:strike', { reason: HEAT_REASON, deferred: true }); const line = this.rng.pick(broken ? FIGHT_BROKEN : FIGHT_SWUNG); this.release(live.a); this.release(live.b); // A fight you missed is not a fight you dealt with — a swing leaves them both // still on your list. if (broken) { live.a.handled = true; live.b.handled = true; } this.resolution = { outcome, phase: state.phase, line }; this.end(); } private abort(live: Live): void { this.release(live.a); this.release(live.b); this.end(); } private end(): void { this.live = null; this.cooldownMs = 0; } /** Never drag someone back onto the floor after they've left it. */ private release(agent: Agent): void { if (agent.gone) return; this.crowd.releaseFromFight(agent); } private tryStart(): void { const pairs = this.candidates(); if (pairs.length === 0) return; const [a, b] = this.rng.pick(pairs); if (!this.crowd.pinForFight(a)) return; if (!this.crowd.pinForFight(b)) { this.crowd.releaseFromFight(a); return; } this.live = { state: freshFight(a.patron.id, b.patron.id), a, b, cause: this.rng.pick(FIGHT_CAUSE) }; this.bus.emit('floor:infractionSpotted', { patronId: a.patron.id, kind: 'fight' }); } /** Every pair drunk enough and close enough right now. Small crowd, so O(n²) is fine. */ private candidates(): Array<[Agent, Agent]> { const pool = this.crowd.agents.filter( (a) => !a.gone && !a.handled && AVAILABLE.has(a.activity), ); const out: Array<[Agent, Agent]> = []; for (let i = 0; i < pool.length; i++) { const a = pool[i]; if (!a) continue; for (let j = i + 1; j < pool.length; j++) { const b = pool[j]; if (!b) continue; const dist = Math.hypot(a.x - b.x, a.y - b.y); if (fightEligible(a.patron.intoxication, b.patron.intoxication, dist)) out.push([a, b]); } } return out; } }