201 lines
6.2 KiB
TypeScript
201 lines
6.2 KiB
TypeScript
// 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<Activity> = new Set<Activity>([
|
|
'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;
|
|
}
|
|
}
|