not-tonight/src/scenes/floor/fightDirector.ts
type-two df70c99dd0 Merge branch 'lane/floor'
# Conflicts:
#	LANEHANDOVER.md
#	src/scenes/floor/FloorDemoScene.ts
2026-07-19 22:14:08 +10:00

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;
}
}