Merge branch 'lane/floor'

# Conflicts:
#	LANEHANDOVER.md
#	src/scenes/floor/FloorDemoScene.ts
This commit is contained in:
type-two 2026-07-19 22:14:08 +10:00
commit df70c99dd0
12 changed files with 1354 additions and 20 deletions

View File

@ -769,3 +769,96 @@ than requesting `capacity.inside` in a payload (reasoning above).
- Wire `Ambience` into `NightScene` (integration-owned). - Wire `Ambience` into `NightScene` (integration-owned).
- LANE-FLOOR2 owns the cut-off overlay call site; `DialogueBox` drunk-typo mode - LANE-FLOOR2 owns the cut-off overlay call site; `DialogueBox` drunk-typo mode
is ready for it and needs no widget change — just pass `drunkenness` + `rng`. is ready for it and needs no widget change — just pass `drunkenness` + `rng`.
---
### SESSION — LANE-FLOOR (round 2) — 2026-07-19 20:56
**Branch/commits:** lane/floor @ 3eee843..(this commit)
**Done:**
- **Fight-brewing infraction built** — the last missing v0.2 floor mechanic (design §3.2).
Not a modal: two patrons square up, a 7s fuse burns through five escalating shove
rungs, and you stop it by physically standing between them. `fight.ts` (pure:
`isBetween` segment maths, `stepFight`, `scoreFight`, eligibility) +
`fightDirector.ts` (lifecycle, one fight at a time, cooldown, pin/release) +
a `setFight` tell in `FloorView` + scene wiring. `crowdSim` gained a
`'squaringUp'` activity that pins an agent, plus `pinForFight`/`releaseFromFight`.
- **Adversarial review of the whole lane** (5 dimensions x ~3k lines), then I
hand-adjudicated the findings and fixed six real defects — see below.
**How to see it:** `npm run dev`**F** → patrol. A fight announces itself with a
pulsing marker and shoving silhouettes **visible outside the torch cone** (verified
by screenshot with the beam pointed away) — spotting it is the gameplay. Prompt
reads GET BETWEEN THEM; stand in the gap before the fuse burns.
**Tests:** 225 passing / 0 failing (+56 this round: `fight.test.ts`, `fightDirector.test.ts`).
**Verified in-browser:** fight breaks up (+6 vibe, both released, no heat) and swings
(12 vibe, +20 aggro, exactly one heat strike, neither marked handled); shove rungs
escalate 0→4; the tell reads with the torch facing away; all four round-1 mechanics
still work after the fixes.
**Balance, measured rather than guessed** — same seed, whole night:
| | idle player | attentive player |
|---|---|---|
| vibe | 0 | 54 |
| aggro | 100 | 45 |
| heat strikes | 5 | 0 |
Doing nothing is ruinous; doing the job holds the room. That gradient is the point.
**Bugs found and fixed this round (all confirmed by reading the code, several in
code I wrote myself last session):**
1. `PatDownOverlay` checked `'Escape'` while the scene routes `'ESCAPE'` (it
uppercases) — ESC was dead in the pat-down while the on-screen hint promised it.
The other two overlays handled both spellings, which is why round 1 missed it.
2. The spawn cap and HUD counted `crowd.agents.length`, but CrowdSim deliberately
keeps departed agents so `find()` works — so **every ejection permanently shrank
the venue** and the floor drained over the night. Now counts live bodies.
(Verified: culled 15 → refilled to 44.)
3. `FloorView`'s sprite cache is keyed by `patron.id` and survives `resetRun`, but
`_resetPatronSerial()` recycles ids from `p0` — so **R** gave the new crowd the
previous run's dolls, sway and stamps. Added `FloorView.reset()`.
4. `escalation`'s per-incident `resolved` was consumed as a permanent per-patron
`handled`, so correctly watering a `loose` patron **retired them from all
tracking for the night while they kept drinking**. The Quiet Messy One could
never come back around. Now settled per-stage: they re-arm when they get worse.
5. Stall busts marked the **captured** occupant list handled, but the crowd keeps
simulating behind the modal — unrelated patrons who had wandered off got retired.
Now re-queries occupancy at resolution.
6. `floor:infractionSpotted` for a stall put a **stall id in the frozen `patronId`
field** (resolves to nobody), and the dedupe key meant one report per cubicle per
night. Now reports an occupant, so a fresh pair reports again.
7. Also wired `teardown()` to `Phaser.Scenes.Events.SHUTDOWN` — a `shutdown()`
*method* is not invoked by Phaser, so the previous version leaked FloorView,
CrowdSim, overlays and bus listeners every time you left the floor.
**Fight rebalance (found by running it, not by tests):** at a 20s cooldown an idle
night brewed **28 fights, all missed, 28 heat strikes** against a run cap of 3 —
fights swamped every other meter. Cooldown → 150s (≈5/night) and the heat strike is
now `deferred: true`, matching §3.2's "possible Heat" and the contract's
inspection/audit semantics. Two tests updated to match; the cooldown test now derives
its window from the constants so it can't silently pass vacuously again.
**Broke / known-wonky:**
- **My review harness under-reported and I overrode it.** 16 raw findings, and the
adversarial verify stage confirmed **zero** — I had required both skeptics to fail
to refute *and* told them to default to "refuted" when uncertain. That combination
produced a 100% false-negative rate: six of the sixteen were real, including two
found independently by three reviewers. I adjudicated them by hand instead.
Flagging because the *pattern* matters for anyone reusing that harness: a
refute-biased verifier plus unanimity is not a quality bar, it is an off switch.
- Fights burn their fuse through overlays. Deliberate — you can't be in a cubicle
and between two blokes at once, and ESC always exits — but it means a fight
starting during a 10s pat-down is a forced choice. Confirm you want that.
- An idle night still pins aggro at 100 by ~1AM. Aggro has no decay (see round 1
request #2); with that in place the idle curve would breathe rather than saturate.
**Contract change requests:** both from round 1 still open and unchanged —
(1) `incident:log` event + single writer in `core/meters.ts`; (2) aggro decay.
No new ones: the fight reuses the existing `'fight'` infraction kind and `heat:strike`.
**Questions for reviewer:**
1. `patrons/doll.ts` caches textures by key and never releases them, so each reseed
mints a fresh set with no upper bound (a long session leaks canvases). It is
LANE-0's file and frozen, so I left it — worth a Phase 2 ticket.
2. Fight numbers (broken +6 / swung 12 vibe, +20 aggro, deferred heat) and the
150s cooldown are my calls from the measured table above. Confirm before
LANE-DOOR mirrors any of it.
3. Round 1's questions on floor strings placement and the `.gitignore` symlink gap
are both still open.
**Next session should:**
- Phase 2 integration: real admitted-patron list from LANE-DOOR, LANE-JUICE's
`TechnoEngine` for `beat:tick`, real meter HUD.
- All five v0.2 floor infractions are now built — the floor is feature-complete
against design §3.2.

View File

@ -80,6 +80,43 @@ export const PATDOWN_FOUND: Record<string, string> = {
export const PATDOWN_CLEAR = 'Nothing on him. "Told ya," he says, delighted.'; export const PATDOWN_CLEAR = 'Nothing on him. "Told ya," he says, delighted.';
export const PATDOWN_MISSED = 'Time. Whatever\'s left in there walks out the door with him.'; export const PATDOWN_MISSED = 'Time. Whatever\'s left in there walks out the door with him.';
/**
* Two blokes squaring up. Pub fights start over nothing and everyone involved
* knows it keep the stakes stupid so the tension stays funny, and so getting
* between them reads as defusing a farce, not breaking up a war.
*/
export const FIGHT_CAUSE: readonly string[] = [
'Someone stepped on someone\'s very white shoe.',
'A disagreement about whether this song is "the actual version".',
'One of them called the other one\'s cousin "alright, I guess".',
'A pool table was, allegedly, next-ed out of turn.',
'Someone said the kebab shop over the road has "gone downhill".',
'It is about a text message from 2019.',
];
/** Escalating shove chatter — you hear this before you see anything. */
export const FIGHT_SHOVES: readonly string[] = [
'"Say it again."',
'"Nah nah nah, say it AGAIN."',
'"I\'m not even angry, mate. I\'m not."',
'"Hold my drink. HOLD it."',
'"You reckon? You RECKON?"',
];
/** You got between them in time. */
export const FIGHT_BROKEN: readonly string[] = [
'You step into the gap. Both of them discover they were leaving anyway.',
'A wall of hi-vis arrives. Suddenly everyone is best mates and very tired.',
'You say nothing. They remember they have work in the morning.',
];
/** You didn't. */
export const FIGHT_SWUNG: readonly string[] = [
'The swing lands. So does the drink, the stool, and the mood of the entire room.',
'One punch, no contact, both on the floor anyway. The room turns on you, not them.',
'It is over in a second and everyone saw you standing too far away.',
];
/** Kayden, on the radio, when a stampless patron turns up inside. */ /** Kayden, on the radio, when a stampless patron turns up inside. */
export const NO_STAMP_RADIO: readonly string[] = [ export const NO_STAMP_RADIO: readonly string[] = [
'"Yeah sorry, that one might\'ve been me. Sorry. Sorry."', '"Yeah sorry, that one might\'ve been me. Sorry. Sorry."',

View File

@ -7,6 +7,7 @@ import { NightSummaryScene } from './scenes/door/NightSummaryScene';
import { IncidentReportScene } from './scenes/door/IncidentReportScene'; import { IncidentReportScene } from './scenes/door/IncidentReportScene';
import { FloorDemoScene } from './scenes/floor/FloorDemoScene'; import { FloorDemoScene } from './scenes/floor/FloorDemoScene';
import { JuiceDemoScene } from './ui/JuiceDemoScene'; import { JuiceDemoScene } from './ui/JuiceDemoScene';
import { MixDeskScene } from './ui/MixDeskScene';
// TODO(integration): Phase 2 owns scene routing (menu -> roster -> night). For // TODO(integration): Phase 2 owns scene routing (menu -> roster -> night). For
// v0.1 the game IS the Door night, so it boots straight into it. The demo scenes // v0.1 the game IS the Door night, so it boots straight into it. The demo scenes
@ -34,6 +35,7 @@ const game = new Phaser.Game({
ParadeScene, ParadeScene,
new FloorDemoScene('FloorDemo'), new FloorDemoScene('FloorDemo'),
JuiceDemoScene, JuiceDemoScene,
MixDeskScene,
], ],
}); });
@ -72,4 +74,13 @@ window.addEventListener('keydown', (e) => {
else if (k === 'p') showScene('Parade'); else if (k === 'p') showScene('Parade');
else if (k === 'f') showScene('FloorDemo'); else if (k === 'f') showScene('FloorDemo');
else if (k === 'j') showScene('JuiceDemo'); else if (k === 'j') showScene('JuiceDemo');
else if (k === 'm') {
// The mix desk OVERLAYS whatever is running (it attaches to the active
// engine), so the ear pass can happen during a real night. Toggle, not route.
if (game.scene.isActive('MixDesk')) game.scene.stop('MixDesk');
else {
game.scene.run('MixDesk');
game.scene.bringToTop('MixDesk');
}
}
}); });

View File

@ -8,6 +8,7 @@ import { loadGame, saveGame, clearSave, freshGameState } from '../../core/save';
import { _resetPatronSerial } from '../../patrons/generator'; import { _resetPatronSerial } from '../../patrons/generator';
import { ruleById } from '../../rules/dressCode'; import { ruleById } from '../../rules/dressCode';
import { TechnoEngine } from '../../audio/TechnoEngine'; import { TechnoEngine } from '../../audio/TechnoEngine';
import { Ambience } from '../../audio/Ambience';
import { Sfx } from '../../audio/Sfx'; import { Sfx } from '../../audio/Sfx';
import type { DeferredHit } from '../../rules/doorTypes'; import type { DeferredHit } from '../../rules/doorTypes';
import type { GameState, HeatStrike, NightState, Patron, Verdict, VerdictOutcome } from '../../data/types'; import type { GameState, HeatStrike, NightState, Patron, Verdict, VerdictOutcome } from '../../data/types';
@ -99,6 +100,7 @@ export class NightScene extends Phaser.Scene {
private stubBeat: StubBeatClock | null = null; private stubBeat: StubBeatClock | null = null;
private engine: TechnoEngine | null = null; private engine: TechnoEngine | null = null;
private sfx: Sfx | null = null; private sfx: Sfx | null = null;
private ambience: Ambience | null = null;
private meters!: Meters; private meters!: Meters;
private state!: NightState; private state!: NightState;
private run!: GameState; private run!: GameState;
@ -161,6 +163,8 @@ export class NightScene extends Phaser.Scene {
// Until the first gesture, a stub keeps beat:tick honest (then hands over). // Until the first gesture, a stub keeps beat:tick honest (then hands over).
this.engine = new TechnoEngine(this.bus); this.engine = new TechnoEngine(this.bus);
this.sfx = new Sfx(this.engine.ctx, this.engine.sfxBus); this.sfx = new Sfx(this.engine.ctx, this.engine.sfxBus);
// Rain outside, murmur inside, fluoro hum — starts once audio unlocks.
this.ambience = new Ambience(this.bus, this.engine.ctx, this.engine.sfxBus, this.sfx);
this.stubBeat = new StubBeatClock(this.bus); this.stubBeat = new StubBeatClock(this.bus);
this.stubBeat.start(); this.stubBeat.start();
this.engine.start(); this.engine.start();
@ -194,6 +198,7 @@ export class NightScene extends Phaser.Scene {
this.bus.on('audio:unlocked', () => { this.bus.on('audio:unlocked', () => {
this.stubBeat?.stop(); this.stubBeat?.stop();
this.stubBeat = null; this.stubBeat = null;
this.ambience?.start();
}), }),
this.bus.on('radio:call', () => this.sfx?.play('radioChirp')), this.bus.on('radio:call', () => this.sfx?.play('radioChirp')),
this.bus.on('incident:log', (inc) => { this.bus.on('incident:log', (inc) => {
@ -201,6 +206,10 @@ export class NightScene extends Phaser.Scene {
this.maggotLive = true; this.maggotLive = true;
if (this.state.location === 'door') this.maggotRadio(); if (this.state.location === 'door') this.maggotRadio();
} }
// A swing on the floor while you're at the rope: the radio tells you.
if (inc.kind === 'fight' && this.state.location === 'door' && inc.detail.startsWith('swung')) {
this.bus.emit('radio:call', { urgency: 3, line: 'there was a SWING inside. get in here. NOW' });
}
// The floor cleared them out; the room is legal again. // The floor cleared them out; the room is legal again.
if (inc.kind === 'ejection' || inc.kind === 'cutOff') this.maggotLive = false; if (inc.kind === 'ejection' || inc.kind === 'cutOff') this.maggotLive = false;
}), }),
@ -450,6 +459,8 @@ export class NightScene extends Phaser.Scene {
for (const off of this.offs) off(); for (const off of this.offs) off();
this.offs = []; this.offs = [];
this.meters?.destroy(); this.meters?.destroy();
this.ambience?.destroy();
this.ambience = null;
this.engine?.destroy(); this.engine?.destroy();
this.engine = null; this.engine = null;
this.sfx = null; this.sfx = null;

View File

@ -6,12 +6,13 @@ import { StubBeatClock } from '../../core/StubBeatClock';
import { Meters, freshNightState } from '../../core/meters'; import { Meters, freshNightState } from '../../core/meters';
import { drunkStage } from '../../rules/drunk'; import { drunkStage } from '../../rules/drunk';
import { generatePatron, _resetPatronSerial } from '../../patrons/generator'; import { generatePatron, _resetPatronSerial } from '../../patrons/generator';
import type { NightState } from '../../data/types'; import type { DrunkStage, NightState } from '../../data/types';
import { HudStub } from '../shared/HudStub'; import { HudStub } from '../shared/HudStub';
import { VENUE } from './venueMap'; import { VENUE } from './venueMap';
import { NORMAL_CONE, UV_CONE, inCone, type ConeSpec } from './cone'; import { NORMAL_CONE, UV_CONE, inCone, type ConeSpec } from './cone';
import { CrowdSim, type Agent } from './crowdSim'; import { CrowdSim, type Agent } from './crowdSim';
import { FightDirector } from './fightDirector';
import { freshPlayer, stepPlayer, type PlayerInput, type PlayerState } from './player'; import { freshPlayer, stepPlayer, type PlayerInput, type PlayerState } from './player';
import { FloorView } from './FloorView'; import { FloorView } from './FloorView';
import { maggotOverdue } from './escalation'; import { maggotOverdue } from './escalation';
@ -31,6 +32,10 @@ const SPAWN_EVERY_MS = 420;
/** Admitted at the rope → appears on the floor after the walk in. */ /** Admitted at the rope → appears on the floor after the walk in. */
const WALK_IN_MS: [number, number] = [6000, 14000]; const WALK_IN_MS: [number, number] = [6000, 14000];
const STAGE_RANK: Readonly<Record<DrunkStage, number>> = {
sober: 0, tipsy: 1, loose: 2, messy: 3, maggot: 4,
};
/** What pressing E on the current target would do. */ /** What pressing E on the current target would do. */
type InteractKind = 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'exit'; type InteractKind = 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'exit';
@ -66,6 +71,7 @@ export class FloorDemoScene extends Phaser.Scene {
private ctxOffs: Array<() => void> = []; private ctxOffs: Array<() => void> = [];
private view!: FloorView; private view!: FloorView;
private crowd!: CrowdSim; private crowd!: CrowdSim;
private fights!: FightDirector;
private player!: PlayerState; private player!: PlayerState;
private cutOff!: CutOffOverlay; private cutOff!: CutOffOverlay;
@ -81,11 +87,15 @@ export class FloorDemoScene extends Phaser.Scene {
/** patronId -> clock minute we first saw them as a maggot (deferred heat risk). */ /** patronId -> clock minute we first saw them as a maggot (deferred heat risk). */
private maggotSeen = new Map<string, number>(); private maggotSeen = new Map<string, number>();
private spotted = new Set<string>(); private spotted = new Set<string>();
private liveFight: { id: string; cause: string } | null = null;
/** Worst drunk stage we've already had words about, per patron. */
private cutOffDealt = new Map<string, DrunkStage>();
private promptText!: Phaser.GameObjects.Text; private promptText!: Phaser.GameObjects.Text;
private statusText!: Phaser.GameObjects.Text; private statusText!: Phaser.GameObjects.Text;
private radioText!: Phaser.GameObjects.Text; private radioText!: Phaser.GameObjects.Text;
private radioMs = 0; private radioMs = 0;
private calmAccMs = 0;
constructor(key: string = 'FloorDemo') { constructor(key: string = 'FloorDemo') {
super(key); super(key);
@ -93,7 +103,9 @@ export class FloorDemoScene extends Phaser.Scene {
create(data?: { night?: NightContext }): void { create(data?: { night?: NightContext }): void {
this.nightCtx = data?.night ?? null; this.nightCtx = data?.night ?? null;
this.events.once('shutdown', () => this.shutdown()); // Wired explicitly: Phaser does not invoke a `shutdown` METHOD on a Scene
// subclass; without this, listeners and sprites leak on every scene stop.
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.teardown());
this.cameras.main.setBounds(0, 0, VENUE.width * 16, VENUE.height * 16); this.cameras.main.setBounds(0, 0, VENUE.width * 16, VENUE.height * 16);
this.cameras.main.setBackgroundColor('#04040a'); this.cameras.main.setBackgroundColor('#04040a');
@ -131,18 +143,23 @@ export class FloorDemoScene extends Phaser.Scene {
this.stall = new StallOverlay(this, this.bus, ctx.beatIntervalMs); this.stall = new StallOverlay(this, this.bus, ctx.beatIntervalMs);
this.crowd = new CrowdSim({ map: VENUE, bus: this.bus, rng: this.rng.stream('crowd') }); this.crowd = new CrowdSim({ map: VENUE, bus: this.bus, rng: this.rng.stream('crowd') });
this.fights?.destroy();
this.fights = new FightDirector({ bus: this.bus, rng: this.rng.stream('fights'), crowd: this.crowd });
const entry = VENUE.anchors.entry[0] ?? { x: 64, y: 64 }; const entry = VENUE.anchors.entry[0] ?? { x: 64, y: 64 };
this.player = freshPlayer(entry.x, entry.y); this.player = freshPlayer(entry.x, entry.y);
this.view.reset();
this.escorting = null; this.escorting = null;
this.target = null; this.target = null;
this.liveFight = null;
this.uvMode = false; this.uvMode = false;
this.present = false; // the night starts at the rope this.present = false; // the night starts at the rope
this.elapsedMs = 0; this.elapsedMs = 0;
this.pendingSpawns = []; this.pendingSpawns = [];
this.maggotSeen.clear(); this.maggotSeen.clear();
this.spotted.clear(); this.spotted.clear();
this.cutOffDealt.clear();
this.ctxOffs = [ this.ctxOffs = [
// The floor is fed by the door: every admission walks in a few seconds later. // The floor is fed by the door: every admission walks in a few seconds later.
@ -187,17 +204,22 @@ export class FloorDemoScene extends Phaser.Scene {
this.stall = new StallOverlay(this, this.bus, this.beat.beatIntervalMs); this.stall = new StallOverlay(this, this.bus, this.beat.beatIntervalMs);
this.crowd = new CrowdSim({ map: VENUE, bus: this.bus, rng: this.rng.stream('crowd') }); this.crowd = new CrowdSim({ map: VENUE, bus: this.bus, rng: this.rng.stream('crowd') });
this.fights?.destroy();
this.fights = new FightDirector({ bus: this.bus, rng: this.rng.stream('fights'), crowd: this.crowd });
this.bus.on('beat:tick', ({ beatIndex }) => this.crowd.onBeat(beatIndex)); this.bus.on('beat:tick', ({ beatIndex }) => this.crowd.onBeat(beatIndex));
const entry = VENUE.anchors.entry[0] ?? { x: 64, y: 64 }; const entry = VENUE.anchors.entry[0] ?? { x: 64, y: 64 };
this.player = freshPlayer(entry.x, entry.y); this.player = freshPlayer(entry.x, entry.y);
this.view.reset();
this.escorting = null; this.escorting = null;
this.target = null; this.target = null;
this.liveFight = null;
this.uvMode = false; this.uvMode = false;
this.spawnAccMs = 0; this.spawnAccMs = 0;
this.maggotSeen.clear(); this.maggotSeen.clear();
this.spotted.clear(); this.spotted.clear();
this.cutOffDealt.clear();
this.clock.start(); this.clock.start();
this.beat.start(); this.beat.start();
@ -260,8 +282,9 @@ export class FloorDemoScene extends Phaser.Scene {
this.pendingSpawns.splice(i, 1); this.pendingSpawns.splice(i, 1);
// Licensed capacity is 80 but 44 agents is the sim's perf budget; past // Licensed capacity is 80 but 44 agents is the sim's perf budget; past
// that, arrivals dissolve into the (conceptual) back room. log()-style // that, arrivals dissolve into the (conceptual) back room. log()-style
// honesty: this is a cap, not a simulation. // honesty: this is a cap, not a simulation. Counts LIVE bodies (ejections
if (this.crowd.agents.length < MAX_CROWD) this.crowd.spawn(s.patron); // must not permanently shrink the venue).
if (this.liveCrowd < MAX_CROWD) this.crowd.spawn(s.patron);
} }
// Player at the door: the floor keeps living (drinks keep being drunk, // Player at the door: the floor keeps living (drinks keep being drunk,
@ -269,11 +292,23 @@ export class FloorDemoScene extends Phaser.Scene {
// nothing needs the camera, the cone, or input. // nothing needs the camera, the cone, or input.
if (!this.present) { if (!this.present) {
this.crowd.update(dt, this.clock.clockMin); this.crowd.update(dt, this.clock.clockMin);
// Fights don't wait for you. An off-map player position means the
// interpose check can never accidentally succeed — unattended fights
// swing, and the incident radios the door.
this.fights.update(-9999, -9999, dt);
this.tickFight();
this.trackMaggots(); this.trackMaggots();
this.tickCalm(dt);
return; return;
} }
} }
// Fights burn their fuse through overlays too. You can't be in a cubicle and
// between two blokes at once — choosing which one to abandon IS the floor.
// ESC is always the way out of a modal, so it's a decision, not a trap.
this.fights.update(this.player.x, this.player.y, dt);
this.tickFight();
if (this.overlayOpen) { if (this.overlayOpen) {
this.cutOff.update(dt); this.cutOff.update(dt);
this.stall.update(dt); this.stall.update(dt);
@ -291,8 +326,11 @@ export class FloorDemoScene extends Phaser.Scene {
if (!this.nightCtx) { if (!this.nightCtx) {
// Demo mode stands in for the door with a synthetic spawner. // Demo mode stands in for the door with a synthetic spawner.
// CrowdSim deliberately keeps departed agents around so find() still works,
// so the cap has to count LIVE bodies — otherwise every ejection permanently
// shrinks the venue and the floor drains to empty over the night.
this.spawnAccMs += dt; this.spawnAccMs += dt;
while (this.spawnAccMs > SPAWN_EVERY_MS && this.crowd.agents.length < MAX_CROWD) { while (this.spawnAccMs > SPAWN_EVERY_MS && this.liveCrowd < MAX_CROWD) {
this.spawnAccMs -= SPAWN_EVERY_MS; this.spawnAccMs -= SPAWN_EVERY_MS;
this.spawnOne(); this.spawnOne();
} }
@ -304,6 +342,7 @@ export class FloorDemoScene extends Phaser.Scene {
if (this.escorting) this.tickEscort(dt); if (this.escorting) this.tickEscort(dt);
this.trackMaggots(); this.trackMaggots();
this.tickCalm(dt);
this.target = this.escorting ? null : this.findTarget(); this.target = this.escorting ? null : this.findTarget();
this.cameras.main.centerOn(this.player.x, this.player.y); this.cameras.main.centerOn(this.player.x, this.player.y);
@ -315,6 +354,43 @@ export class FloorDemoScene extends Phaser.Scene {
this.drawHud(dt); this.drawHud(dt);
} }
/**
* Aggro physiology (Phase-1 floor request, approved): a calm floor slowly
* cools the night whether or not the player is watching it. "Calm" means no
* fight brewing and no unhandled maggot; the drift is deliberately slower
* than any single mistake so it reads as breathing, not forgiveness.
* ~0.3 aggro per clock-minute (1 clock-min 2.17s real at DEFAULT_CLOCK).
*/
private tickCalm(dt: number): void {
if (this.fights.active) {
this.calmAccMs = 0;
return;
}
for (const a of this.crowd.agents) {
if (!a.gone && !a.handled && drunkStage(a.patron.intoxication) === 'maggot') {
this.calmAccMs = 0;
return;
}
}
this.calmAccMs += dt;
if (this.calmAccMs >= 1000) {
this.calmAccMs -= 1000;
this.bus.emit('meters:delta', { aggro: -0.14 });
}
}
/** Have we already dealt with this patron at their current level of drunk? */
private settledAt(agent: Agent, stage: DrunkStage): boolean {
const dealt = this.cutOffDealt.get(agent.patron.id);
return dealt !== undefined && STAGE_RANK[stage] <= STAGE_RANK[dealt];
}
private get liveCrowd(): number {
let n = 0;
for (const a of this.crowd.agents) if (!a.gone) n++;
return n;
}
private coneSpec(): ConeSpec { private coneSpec(): ConeSpec {
const c = this.uvMode ? UV_CONE : NORMAL_CONE; const c = this.uvMode ? UV_CONE : NORMAL_CONE;
return { x: this.player.x, y: this.player.y, facing: this.player.facing, ...c }; return { x: this.player.x, y: this.player.y, facing: this.player.facing, ...c };
@ -338,6 +414,7 @@ export class FloorDemoScene extends Phaser.Scene {
for (const a of this.crowd.agents) { for (const a of this.crowd.agents) {
if (a.gone || a.handled) continue; if (a.gone || a.handled) continue;
if (drunkStage(a.patron.intoxication) !== 'maggot') continue; if (drunkStage(a.patron.intoxication) !== 'maggot') continue;
if (this.settledAt(a, 'maggot')) continue;
const seen = this.maggotSeen.get(a.patron.id); const seen = this.maggotSeen.get(a.patron.id);
if (seen === undefined) { if (seen === undefined) {
this.maggotSeen.set(a.patron.id, now); this.maggotSeen.set(a.patron.id, now);
@ -374,7 +451,11 @@ export class FloorDemoScene extends Phaser.Scene {
if (d > INTERACT_RANGE) continue; if (d > INTERACT_RANGE) continue;
const occupants = this.crowd.stallOccupancy().get(s.id) ?? []; const occupants = this.crowd.stallOccupancy().get(s.id) ?? [];
if (occupants.length < 2) continue; if (occupants.length < 2) continue;
this.spot(s.id, 'stall'); // Report a real patron, not the cubicle: `patronId` is a frozen contract
// field and a stall id resolves to nobody. Keying on the occupant also
// means a fresh pair in the same cubicle reports again later in the night.
const first = occupants[0];
if (first) this.spot(first.patron.id, 'stall');
return { kind: 'stall', stallId: s.id, prompt: 'E — two pairs of feet under that door' }; return { kind: 'stall', stallId: s.id, prompt: 'E — two pairs of feet under that door' };
} }
@ -400,8 +481,13 @@ export class FloorDemoScene extends Phaser.Scene {
const stage = drunkStage(best.patron.intoxication); const stage = drunkStage(best.patron.intoxication);
if (stage === 'messy' || stage === 'maggot' || stage === 'loose') { if (stage === 'messy' || stage === 'maggot' || stage === 'loose') {
// Dealing with someone settles that stage, not the whole night. The Quiet
// Messy One you sensibly watered at 'loose' has to come back around when
// they hit 'maggot' — otherwise a correct early call blinds you to them.
if (!this.settledAt(best, stage)) {
return { kind: 'drunk', agent: best, prompt: 'E — have a word' }; return { kind: 'drunk', agent: best, prompt: 'E — have a word' };
} }
}
if (best.patron.flags.contraband?.length) { if (best.patron.flags.contraband?.length) {
this.spot(best.patron.id, 'contraband'); this.spot(best.patron.id, 'contraband');
return { kind: 'contraband', agent: best, prompt: 'E — pat them down' }; return { kind: 'contraband', agent: best, prompt: 'E — pat them down' };
@ -430,14 +516,18 @@ export class FloorDemoScene extends Phaser.Scene {
(r) => { (r) => {
this.bus.emit('meters:delta', { vibe: r.vibeDelta, aggro: r.aggroDelta }); this.bus.emit('meters:delta', { vibe: r.vibeDelta, aggro: r.aggroDelta });
if (r.solved) { if (r.solved) {
// Re-query: the crowd keeps simulating behind the modal, so whoever was
// in there when you started banging may have wandered off. Scattering
// the captured list would retire innocent patrons standing elsewhere.
const inThere = this.crowd.stallOccupancy().get(stallId) ?? [];
// Zeroing the dwell makes the sim's own stall-exit path fire next // Zeroing the dwell makes the sim's own stall-exit path fire next
// frame: both get put back at the door and independently re-target, // frame: both get put back at the door and independently re-target,
// which reads exactly like scattering in opposite directions. // which reads exactly like scattering in opposite directions.
for (const a of occupants) { for (const a of inThere) {
a.handled = true; a.handled = true;
a.dwellMs = 0; a.dwellMs = 0;
} }
this.logIncident('stallBusted', occupants[0]?.patron.id, 'Two out of one cubicle.'); this.logIncident('stallBusted', inThere[0]?.patron.id, 'Two out of one cubicle.');
} }
}, },
); );
@ -448,7 +538,9 @@ export class FloorDemoScene extends Phaser.Scene {
this.cutOff.open({ patron: agent.patron, stage }, (r) => { this.cutOff.open({ patron: agent.patron, stage }, (r) => {
if (!r) return; if (!r) return;
this.bus.emit('meters:delta', { vibe: r.vibeDelta, aggro: r.aggroDelta }); this.bus.emit('meters:delta', { vibe: r.vibeDelta, aggro: r.aggroDelta });
if (r.resolved) agent.handled = true; // Only an ejection retires someone for good. A resolved-but-staying patron
// is settled at THIS stage and becomes your problem again if they get worse.
if (r.resolved && !r.eject) this.cutOffDealt.set(agent.patron.id, stage);
if (r.eject) { if (r.eject) {
this.startEscort(agent, r.outcome === 'clean' ? 'clean' : 'messy', 'drunk'); this.startEscort(agent, r.outcome === 'clean' ? 'clean' : 'messy', 'drunk');
} else { } else {
@ -478,6 +570,25 @@ export class FloorDemoScene extends Phaser.Scene {
this.logIncident('ejection', agent.patron.id, `${kind} / ${style}`); this.logIncident('ejection', agent.patron.id, `${kind} / ${style}`);
} }
/** Draw the brewing-fight tell and narrate the resolution when one lands. */
private tickFight(): void {
const f = this.fights.active;
const pair = this.fights.pair();
this.view.setFight(pair, f?.shoves ?? 0, f?.msLeft ?? 0);
// The director releases both agents the instant it resolves, so pair() and
// cause are already gone by the time we're told about it — remember them
// while the fight is still live.
if (pair) this.liveFight = { id: pair[0].patron.id, cause: this.fights.cause };
const res = this.fights.takeResolution();
if (!res) return;
const was = this.liveFight;
this.liveFight = null;
this.toast(res.line);
this.logIncident('fight', was?.id, `${res.phase === 'broken' ? 'broken up' : 'swung'}${was?.cause ?? ''}`);
}
private tickEscort(dt: number): void { private tickEscort(dt: number): void {
const a = this.escorting; const a = this.escorting;
if (!a) return; if (!a) return;
@ -491,7 +602,12 @@ export class FloorDemoScene extends Phaser.Scene {
// ---- chrome ------------------------------------------------------------ // ---- chrome ------------------------------------------------------------
private radio(line: string): void { private radio(line: string): void {
this.radioText.setText(line ? `KAYDEN: ${line}` : ''); this.toast(line ? `KAYDEN: ${line}` : '');
}
/** Transient line in the corner — radio chatter, fight outcomes, anything shouted. */
private toast(line: string): void {
this.radioText.setText(line);
this.radioMs = 5000; this.radioMs = 5000;
} }
@ -504,20 +620,26 @@ export class FloorDemoScene extends Phaser.Scene {
this.radioMs -= dt; this.radioMs -= dt;
if (this.radioMs <= 0) this.radioText.setText(''); if (this.radioMs <= 0) this.radioText.setText('');
} }
// A brewing fight outranks everything — it's the one thing on a clock.
this.promptText.setText( this.promptText.setText(
this.escorting ? 'escorting — walk them to the door (no interacting)' : (this.target?.prompt ?? ''), this.fights.active
? 'GET BETWEEN THEM'
: this.escorting
? 'escorting — walk them to the door (no interacting)'
: (this.target?.prompt ?? ''),
); );
const maggots = this.crowd.agents.filter( const maggots = this.crowd.agents.filter(
(a) => !a.gone && !a.handled && drunkStage(a.patron.intoxication) === 'maggot', (a) => !a.gone && !a.handled && drunkStage(a.patron.intoxication) === 'maggot',
).length; ).length;
this.statusText.setText( this.statusText.setText(
`${this.clock.label} seed ${this.seed} crowd ${this.crowd.agents.length} maggots ${maggots} ${this.uvMode ? 'UV' : 'torch'} incidents ${this.night.incidents.length}`, `${this.clock.label} seed ${this.seed} crowd ${this.liveCrowd} maggots ${maggots} ${this.uvMode ? 'UV' : 'torch'} incidents ${this.night.incidents.length}`,
); );
} }
shutdown(): void { private teardown(): void {
for (const off of this.ctxOffs) off(); for (const off of this.ctxOffs) off();
this.ctxOffs = []; this.ctxOffs = [];
this.fights?.destroy();
this.crowd?.destroy(); this.crowd?.destroy();
this.view?.destroy(); this.view?.destroy();
this.cutOff?.destroy(); this.cutOff?.destroy();

View File

@ -2,6 +2,7 @@ import Phaser from 'phaser';
import { renderDoll } from '../../patrons/doll'; import { renderDoll } from '../../patrons/doll';
import { dollPlan } from '../../patrons/dollPlan'; import { dollPlan } from '../../patrons/dollPlan';
import { conePolygon, type ConeSpec } from './cone'; import { conePolygon, type ConeSpec } from './cone';
import { FIGHT_FUSE_MS } from './fight';
import { TILE, tileAt, tileColour, type VenueMap } from './venueMap'; import { TILE, tileAt, tileColour, type VenueMap } from './venueMap';
import type { Agent } from './crowdSim'; import type { Agent } from './crowdSim';
@ -35,6 +36,22 @@ const NEON_TINTS = [0xff3d9a, 0x38e0ff, 0xb45cff] as const;
const GLOW_SIZE = 64; const GLOW_SIZE = 64;
// A fight is spotted by motion, not by torchlight (design §3.2) — the tell has to
// survive the darkness sheet, so the marker rides at D_MARKER and the shove is
// carried by the silhouettes, which stay legible through the 50% black.
const FIGHT_PINK = 0xd03470;
const FIGHT_AMBER = 0xd8a020;
const SHOVE_STEPS = 5;
// Wobble: bigger and faster every rung, so a fight you clocked late still looks
// worse than one you clocked early.
const SHOVE_AMP_PX = 1.2;
const SHOVE_AMP_STEP_PX = 1.1;
const SHOVE_MS = 260;
const SHOVE_MS_STEP = 22;
// Pulse period at a full fuse, and how much of it the last second buys back.
const PULSE_SLOW_MS = 520;
const PULSE_SPAN_MS = 390;
interface AgentGfx { interface AgentGfx {
sil: Phaser.GameObjects.Image; sil: Phaser.GameObjects.Image;
detail: Phaser.GameObjects.Image; detail: Phaser.GameObjects.Image;
@ -59,8 +76,14 @@ export class FloorView {
private readonly playerLight: Phaser.GameObjects.Image; private readonly playerLight: Phaser.GameObjects.Image;
private readonly playerImg: Phaser.GameObjects.Image; private readonly playerImg: Phaser.GameObjects.Image;
private readonly marker: Phaser.GameObjects.Image; private readonly marker: Phaser.GameObjects.Image;
private readonly fightHalo: Phaser.GameObjects.Image;
private readonly fightPip: Phaser.GameObjects.Image;
private readonly agents = new Map<string, AgentGfx>(); private readonly agents = new Map<string, AgentGfx>();
// Render-only shove displacement by patron id. The sim owns agent.x/agent.y;
// syncAgents adds this on top so a shoving pair jitters without the crowd
// simulation ever hearing about it.
private readonly shove = new Map<string, { dx: number; dy: number }>();
private frame = 0; private frame = 0;
private uv = false; private uv = false;
@ -118,6 +141,20 @@ export class FloorView {
this.playerImg = scene.add.image(0, 0, PLAYER_TEX).setDepth(D_MARKER); this.playerImg = scene.add.image(0, 0, PLAYER_TEX).setDepth(D_MARKER);
this.marker = scene.add.image(0, 0, MARKER_TEX).setDepth(D_MARKER).setVisible(false); this.marker = scene.add.image(0, 0, MARKER_TEX).setDepth(D_MARKER).setVisible(false);
// Built here and only ever shown/hidden — a fight starts on a frame where
// there is already plenty going on.
this.fightHalo = scene.add
.image(0, 0, GLOW_TEX)
.setDepth(D_MARKER)
.setBlendMode(Phaser.BlendModes.ADD)
.setTint(FIGHT_PINK)
.setVisible(false);
this.fightPip = scene.add
.image(0, 0, MARKER_TEX)
.setDepth(D_MARKER)
.setTint(FIGHT_AMBER)
.setVisible(false);
} }
syncAgents(agents: readonly Agent[]): void { syncAgents(agents: readonly Agent[]): void {
@ -150,9 +187,14 @@ export class FloorView {
const y = a.y - Math.abs(Math.sin(a.bobPhase * Math.PI * 2)) * bob; const y = a.y - Math.abs(Math.sin(a.bobPhase * Math.PI * 2)) * bob;
const x = g.swayPx > 0 ? a.x + Math.sin(t / 260 + g.phase) * g.swayPx : a.x; const x = g.swayPx > 0 ? a.x + Math.sin(t / 260 + g.phase) * g.swayPx : a.x;
g.sil.setPosition(x, y); // Empty unless this punter is squaring up, so the common path is a miss.
g.detail.setPosition(x, y); const sh = this.shove.get(a.patron.id);
g.uvDot.setPosition(x + 4, y + 2); const sx = sh ? x + sh.dx : x;
const sy = sh ? y + sh.dy : y;
g.sil.setPosition(sx, sy);
g.detail.setPosition(sx, sy);
g.uvDot.setPosition(sx + 4, sy + 2);
g.detail.setVisible(!this.uv); g.detail.setVisible(!this.uv);
// No stamp, no dot — the absence is the tell. // No stamp, no dot — the absence is the tell.
g.uvDot.setVisible(this.uv && a.patron.flags.uvStamped === true); g.uvDot.setVisible(this.uv && a.patron.flags.uvStamped === true);
@ -200,7 +242,58 @@ export class FloorView {
this.marker.setAlpha(0.55 + 0.45 * Math.abs(Math.sin(this.scene.time.now / 260))); this.marker.setAlpha(0.55 + 0.45 * Math.abs(Math.sin(this.scene.time.now / 260)));
} }
/** Draw the brewing-fight tell between two agents. Pass null when no fight is live. */
setFight(pair: readonly [Agent, Agent] | null, shoves: number, msLeft: number): void {
this.shove.clear();
if (!pair) {
this.fightHalo.setVisible(false);
this.fightPip.setVisible(false);
return;
}
const [a, b] = pair;
const t = this.scene.time.now;
const rung = Math.max(0, Math.min(SHOVE_STEPS, shoves));
// Unit vector a -> b. Two punters standing in exactly the same spot still
// need somewhere to shove, so fall back to sideways.
const dx = b.x - a.x;
const dy = b.y - a.y;
const dist = Math.hypot(dx, dy);
const ux = dist > 0 ? dx / dist : 1;
const uy = dist > 0 ? dy / dist : 0;
// Positive swings them into each other, negative rocks them back off it.
const amp = SHOVE_AMP_PX + rung * SHOVE_AMP_STEP_PX;
const period = SHOVE_MS - rung * SHOVE_MS_STEP;
const off = Math.sin((t / period) * Math.PI * 2) * amp;
this.shove.set(a.patron.id, { dx: ux * off, dy: uy * off });
this.shove.set(b.patron.id, { dx: -ux * off, dy: -uy * off });
const mx = (a.x + b.x) / 2;
const my = (a.y + b.y) / 2;
// Urgency drives the pulse, not the rung: the fuse is what you're racing.
const u = Math.max(0, Math.min(1, 1 - msLeft / FIGHT_FUSE_MS));
const pulseMs = PULSE_SLOW_MS - PULSE_SPAN_MS * u;
const p = 0.5 + 0.5 * Math.sin((t / pulseMs) * Math.PI * 2);
this.fightHalo
.setVisible(true)
.setPosition(mx, my)
.setAlpha(0.22 + 0.5 * p)
.setScale(1.5 + 0.55 * p + 0.3 * u);
this.fightPip
.setVisible(true)
.setPosition(mx, my - 15 - 3 * p)
.setAlpha(0.55 + 0.45 * p)
.setScale(1 + 0.35 * u);
}
destroy(): void { destroy(): void {
this.shove.clear();
this.fightHalo.destroy();
this.fightPip.destroy();
for (const g of this.agents.values()) this.destroyGfx(g); for (const g of this.agents.values()) this.destroyGfx(g);
this.agents.clear(); this.agents.clear();
this.detailC.clearMask(true); this.detailC.clearMask(true);
@ -239,6 +332,20 @@ export class FloorView {
}; };
} }
/**
* Drop every cached sprite. The scene must call this on reseed: patron ids are
* recycled from p0 by _resetPatronSerial(), so without it the new run's crowd
* inherits the old run's dolls, sway and stamps.
*/
reset(): void {
for (const g of this.agents.values()) this.destroyGfx(g);
this.agents.clear();
this.shove.clear();
this.fightHalo.setVisible(false);
this.fightPip.setVisible(false);
this.marker.setVisible(false);
}
private drop(id: string): void { private drop(id: string): void {
const g = this.agents.get(id); const g = this.agents.get(id);
if (!g) return; if (!g) return;

View File

@ -9,7 +9,8 @@ import { isWalkable, isWalkableWorld, tileToWorld, worldToTile } from './venueMa
import type { StallDef, VenueMap, WorldPoint } from './venueMap'; import type { StallDef, VenueMap, WorldPoint } from './venueMap';
export type Activity = export type Activity =
| 'walking' | 'atBar' | 'dancing' | 'inBooth' | 'inStall' | 'smoking' | 'escorted' | 'leaving'; | 'walking' | 'atBar' | 'dancing' | 'inBooth' | 'inStall' | 'smoking' | 'escorted' | 'leaving'
| 'squaringUp';
export interface Agent { export interface Agent {
patron: Patron; patron: Patron;
@ -178,7 +179,8 @@ export class CrowdSim {
for (const a of this.agents) { for (const a of this.agents) {
if (a.gone) continue; if (a.gone) continue;
a.lastBumpMs += ms; a.lastBumpMs += ms;
if (a.activity === 'escorted') continue; // Both are driven from outside: the player's escort, and the fight director.
if (a.activity === 'escorted' || a.activity === 'squaringUp') continue;
if (a.activity === 'walking') this.walk(a, ms); if (a.activity === 'walking') this.walk(a, ms);
else this.dwell(a, ms); else this.dwell(a, ms);
} }
@ -282,6 +284,34 @@ export class CrowdSim {
} }
} }
/** 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 { find(patronId: string): Agent | undefined {
return this.agents.find((a) => a.patron.id === patronId); return this.agents.find((a) => a.patron.id === patronId);
} }

124
src/scenes/floor/fight.ts Normal file
View File

@ -0,0 +1,124 @@
// Fight brewing (design §3.2). Unlike the stall and pat-down, this one is not a
// modal minigame — it spends the floor's only real resource, where you are
// standing. Pure: no Phaser, no bus. The scene owns spawning and cooldowns.
import { FIGHT_SHOVES } from '../../data/strings/floor';
export const FIGHT_FUSE_MS = 7000;
export const BREAK_RADIUS_PX = 26;
// A fight is an EVENT, not a heartbeat. At 20s an idle night produced 28 brawls,
// which swamped every other meter on the floor and generated 28 heat strikes
// against a run cap of 3. At 150s a busy night brews four or five.
export const FIGHT_COOLDOWN_MS = 150_000;
export const FIGHT_MIN_DIST_PX = 34;
// Both must be at least this drunk to square up. 0.45 is exactly where
// drunkStage() flips tipsy -> loose, so this is a loose-and-above problem: a
// merely tipsy pair never kicks off.
export const FIGHT_MIN_INTOX = 0.45;
/** Escalation rungs the fuse burns through; also the cap on `shoves`. */
const SHOVE_STEPS = 5;
export type FightPhase = 'shoving' | 'broken' | 'swung';
export interface FightState {
aId: string;
bId: string;
msLeft: number;
phase: FightPhase;
/** Cosmetic 0..SHOVE_STEPS tell; monotonic, derived from the burnt fuse. */
shoves: number;
}
export interface FightOutcome {
vibeDelta: number;
aggroDelta: number;
heat: boolean;
}
// You still got shoved about breaking it up, hence the aggro on a win.
const BROKEN_VIBE = 6;
const BROKEN_AGGRO = 1;
// A landed swing is the worst thing that can happen on the floor (design §3.2).
const SWUNG_VIBE = -12;
const SWUNG_AGGRO = 20;
export function freshFight(aId: string, bId: string): FightState {
return { aId, bId, msLeft: FIGHT_FUSE_MS, phase: 'shoving', shoves: 0 };
}
/** Rungs burnt so far. Clamped both ends so a mangled msLeft can't escape 0..5. */
function shovesFor(msLeft: number): number {
const burnt = FIGHT_FUSE_MS - msLeft;
const step = Math.floor(burnt / (FIGHT_FUSE_MS / SHOVE_STEPS));
return Math.max(0, Math.min(SHOVE_STEPS, step));
}
/** Shove chatter for the current rung. Clamped — FIGHT_SHOVES has no 6th line. */
export function shoveLine(shoves: number): string {
const i = Math.max(0, Math.min(FIGHT_SHOVES.length - 1, Math.floor(shoves)));
return FIGHT_SHOVES[i] ?? '';
}
/**
* Is the player physically between A and B? True when the point is within
* `radius` of the A-B SEGMENT (not the infinite line) so standing past either
* shoulder does not count. Handle the degenerate A==B case without dividing by zero.
*/
export function isBetween(
px: number,
py: number,
ax: number,
ay: number,
bx: number,
by: number,
radius: number = BREAK_RADIUS_PX,
): boolean {
const abx = bx - ax;
const aby = by - ay;
const apx = px - ax;
const apy = py - ay;
const lenSq = abx * abx + aby * aby;
// Two patrons standing in the same spot: the segment is a point.
const t = lenSq > 0 ? Math.max(0, Math.min(1, (apx * abx + apy * aby) / lenSq)) : 0;
const dx = apx - abx * t;
const dy = apy - aby * t;
return dx * dx + dy * dy <= radius * radius;
}
/** Pure: returns the next state, never mutates the input. */
export function stepFight(s: FightState, deltaMs: number, intervening: boolean): FightState {
// Once it's broken or swung it's history — later frames can't relitigate it.
if (s.phase !== 'shoving') return s;
if (intervening) {
return { ...s, phase: 'broken' };
}
const msLeft = Math.max(0, s.msLeft - deltaMs);
return {
...s,
msLeft,
phase: msLeft <= 0 ? 'swung' : 'shoving',
// Never goes backwards: msLeft only ever decreases while shoving.
shoves: Math.max(s.shoves, shovesFor(msLeft)),
};
}
export function scoreFight(s: FightState): FightOutcome {
if (s.phase === 'broken') {
return { vibeDelta: BROKEN_VIBE, aggroDelta: BROKEN_AGGRO, heat: false };
}
if (s.phase === 'swung') {
return { vibeDelta: SWUNG_VIBE, aggroDelta: SWUNG_AGGRO, heat: true };
}
return { vibeDelta: 0, aggroDelta: 0, heat: false };
}
/** Both drunk enough and close enough to kick off. */
export function fightEligible(aIntox: number, bIntox: number, distPx: number): boolean {
return (
aIntox >= FIGHT_MIN_INTOX && bIntox >= FIGHT_MIN_INTOX && distPx <= FIGHT_MIN_DIST_PX
);
}

View File

@ -0,0 +1,200 @@
// 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;
}
}

View File

@ -227,7 +227,10 @@ export class PatDownOverlay {
handleKey(key: string): void { handleKey(key: string): void {
if (!this.open_) return; if (!this.open_) return;
if (key === 'ESC' || key === 'Escape') this.resolve(); // The scene uppercases before routing, so 'Escape' never matched and ESC was
// dead here while the on-screen hint promised it worked.
const k = key.toUpperCase();
if (k === 'ESC' || k === 'ESCAPE') this.resolve();
} }
destroy(): void { destroy(): void {

306
tests/floor/fight.test.ts Normal file
View File

@ -0,0 +1,306 @@
import { describe, expect, it } from 'vitest';
import {
BREAK_RADIUS_PX,
FIGHT_FUSE_MS,
FIGHT_MIN_DIST_PX,
FIGHT_MIN_INTOX,
fightEligible,
freshFight,
isBetween,
scoreFight,
shoveLine,
stepFight,
type FightState,
} from '../../src/scenes/floor/fight';
import { FIGHT_SHOVES } from '../../src/data/strings/floor';
import { drunkStage } from '../../src/rules/drunk';
/** A 200px-apart pair on a horizontal line — long enough to have real shoulders. */
const A = { x: 100, y: 100 };
const B = { x: 300, y: 100 };
const MID = { x: 200, y: 100 };
describe('freshFight', () => {
it('starts shoving with a full fuse and no escalation', () => {
const f = freshFight('a', 'b');
expect(f.phase).toBe('shoving');
expect(f.msLeft).toBe(FIGHT_FUSE_MS);
expect(f.shoves).toBe(0);
expect(f.aId).toBe('a');
expect(f.bId).toBe('b');
});
});
describe('isBetween', () => {
it('is true dead centre', () => {
expect(isBetween(MID.x, MID.y, A.x, A.y, B.x, B.y)).toBe(true);
});
it('is true anywhere along the segment', () => {
for (let x = A.x; x <= B.x; x += 10) {
expect(isBetween(x, A.y, A.x, A.y, B.x, B.y)).toBe(true);
}
});
it('is FALSE past A on the extended line — the segment, not the infinite line', () => {
const past = A.x - BREAK_RADIUS_PX - 1;
expect(isBetween(past, A.y, A.x, A.y, B.x, B.y)).toBe(false);
});
it('is FALSE past B on the extended line', () => {
const past = B.x + BREAK_RADIUS_PX + 1;
expect(isBetween(past, B.y, A.x, A.y, B.x, B.y)).toBe(false);
});
it('is false a long way past either shoulder', () => {
expect(isBetween(-5000, A.y, A.x, A.y, B.x, B.y)).toBe(false);
expect(isBetween(5000, A.y, A.x, A.y, B.x, B.y)).toBe(false);
});
it('is false perpendicular beyond the radius', () => {
expect(isBetween(MID.x, MID.y + BREAK_RADIUS_PX + 1, A.x, A.y, B.x, B.y)).toBe(false);
});
it('honours the radius at its exact boundary', () => {
expect(isBetween(MID.x, MID.y + BREAK_RADIUS_PX, A.x, A.y, B.x, B.y)).toBe(true);
expect(isBetween(MID.x, MID.y + BREAK_RADIUS_PX + 0.001, A.x, A.y, B.x, B.y)).toBe(false);
});
it('honours an explicit radius override at its boundary', () => {
expect(isBetween(MID.x, MID.y + 10, A.x, A.y, B.x, B.y, 10)).toBe(true);
expect(isBetween(MID.x, MID.y + 10.001, A.x, A.y, B.x, B.y, 10)).toBe(false);
});
it('measures the shoulder radially, not just along the axis', () => {
// Diagonally off A's corner, just outside the radius.
const d = BREAK_RADIUS_PX;
expect(isBetween(A.x - d, A.y - d, A.x, A.y, B.x, B.y)).toBe(false);
expect(isBetween(A.x - 1, A.y - 1, A.x, A.y, B.x, B.y)).toBe(true);
});
it('does not throw or NaN when A and B are the same point', () => {
expect(isBetween(A.x, A.y, A.x, A.y, A.x, A.y)).toBe(true);
expect(isBetween(A.x + BREAK_RADIUS_PX, A.y, A.x, A.y, A.x, A.y)).toBe(true);
expect(isBetween(A.x + BREAK_RADIUS_PX + 1, A.y, A.x, A.y, A.x, A.y)).toBe(false);
});
it('works on a vertical pair too', () => {
expect(isBetween(100, 200, 100, 100, 100, 300)).toBe(true);
expect(isBetween(100, 300 + BREAK_RADIUS_PX + 1, 100, 100, 100, 300)).toBe(false);
});
});
describe('stepFight purity', () => {
it('never mutates the input state', () => {
const f = freshFight('a', 'b');
const snapshot = { ...f };
stepFight(f, 3000, false);
stepFight(f, 3000, true);
expect(f).toEqual(snapshot);
});
it('returns a new object while shoving', () => {
const f = freshFight('a', 'b');
expect(stepFight(f, 100, false)).not.toBe(f);
});
});
describe('stepFight fuse', () => {
it('burns msLeft down by the delta', () => {
const f = stepFight(freshFight('a', 'b'), 1000, false);
expect(f.msLeft).toBe(FIGHT_FUSE_MS - 1000);
expect(f.phase).toBe('shoving');
});
it('is still shoving one millisecond before the fuse ends', () => {
const f = stepFight(freshFight('a', 'b'), FIGHT_FUSE_MS - 1, false);
expect(f.phase).toBe('shoving');
expect(f.msLeft).toBe(1);
});
it('swings at exactly zero', () => {
const f = stepFight(freshFight('a', 'b'), FIGHT_FUSE_MS, false);
expect(f.phase).toBe('swung');
expect(f.msLeft).toBe(0);
});
it('lands on swung — not past it — for one huge delta', () => {
const f = stepFight(freshFight('a', 'b'), 10_000_000, false);
expect(f.phase).toBe('swung');
expect(f.msLeft).toBe(0);
expect(f.shoves).toBe(5);
});
it('reaches the same place in many small steps as in one big one', () => {
let f = freshFight('a', 'b');
for (let i = 0; i < 100; i++) f = stepFight(f, 100, false);
expect(f.phase).toBe('swung');
expect(f.msLeft).toBe(0);
});
});
describe('stepFight intervention', () => {
it('breaks immediately when you get between them', () => {
const f = stepFight(freshFight('a', 'b'), 16, true);
expect(f.phase).toBe('broken');
});
it('freezes msLeft where it was when broken', () => {
const live = stepFight(freshFight('a', 'b'), 2000, false);
const broken = stepFight(live, 500, true);
expect(broken.msLeft).toBe(live.msLeft);
});
it('beats the fuse when both land on the same step', () => {
const f = stepFight(freshFight('a', 'b'), FIGHT_FUSE_MS * 2, true);
expect(f.phase).toBe('broken');
});
});
describe('stepFight terminal phases absorb', () => {
it('a swung fight can never become broken', () => {
let f = stepFight(freshFight('a', 'b'), FIGHT_FUSE_MS, false);
expect(f.phase).toBe('swung');
for (let i = 0; i < 10; i++) f = stepFight(f, 1000, true);
expect(f.phase).toBe('swung');
expect(f.msLeft).toBe(0);
});
it('a broken fight can never become swung', () => {
let f = stepFight(freshFight('a', 'b'), 100, true);
expect(f.phase).toBe('broken');
const frozen = f.msLeft;
for (let i = 0; i < 10; i++) f = stepFight(f, 100_000, false);
expect(f.phase).toBe('broken');
expect(f.msLeft).toBe(frozen);
});
it('returns the identical object once terminal', () => {
const swung = stepFight(freshFight('a', 'b'), FIGHT_FUSE_MS, false);
expect(stepFight(swung, 50, false)).toBe(swung);
const broken = stepFight(freshFight('a', 'b'), 50, true);
expect(stepFight(broken, 50, true)).toBe(broken);
});
});
describe('stepFight shoves counter', () => {
it('is monotonic and capped at 5 across the whole fuse', () => {
let f = freshFight('a', 'b');
let prev = f.shoves;
for (let i = 0; i < 200; i++) {
f = stepFight(f, 50, false);
expect(f.shoves).toBeGreaterThanOrEqual(prev);
expect(f.shoves).toBeLessThanOrEqual(5);
expect(f.shoves).toBeGreaterThanOrEqual(0);
prev = f.shoves;
}
expect(f.phase).toBe('swung');
expect(f.shoves).toBe(5);
});
it('ticks one rung per fifth of the fuse', () => {
const fifth = FIGHT_FUSE_MS / 5;
for (let rung = 0; rung <= 4; rung++) {
const f = stepFight(freshFight('a', 'b'), fifth * rung, false);
expect(f.shoves).toBe(rung);
}
});
it('does not tick before the first fifth has burned', () => {
expect(stepFight(freshFight('a', 'b'), FIGHT_FUSE_MS / 5 - 1, false).shoves).toBe(0);
});
it('holds its rung when the fight is broken', () => {
const live = stepFight(freshFight('a', 'b'), FIGHT_FUSE_MS * 0.6, false);
expect(live.shoves).toBe(3);
expect(stepFight(live, 5000, true).shoves).toBe(3);
});
});
describe('shoveLine', () => {
it('gives a real line for every reachable rung, including the capped one', () => {
for (let s = 0; s <= 5; s++) {
expect(shoveLine(s).length).toBeGreaterThan(0);
expect(FIGHT_SHOVES).toContain(shoveLine(s));
}
});
it('clamps rubbish input rather than returning undefined', () => {
expect(shoveLine(-3)).toBe(FIGHT_SHOVES[0]);
expect(shoveLine(999)).toBe(FIGHT_SHOVES[FIGHT_SHOVES.length - 1]);
});
});
describe('scoreFight', () => {
const at = (phase: FightState['phase']): FightState => ({
aId: 'a', bId: 'b', msLeft: 1000, phase, shoves: 2,
});
it('rewards a broken fight with vibe and no heat', () => {
const o = scoreFight(at('broken'));
expect(o.vibeDelta).toBeGreaterThan(0);
expect(o.heat).toBe(false);
expect(o.aggroDelta).toBeGreaterThan(0);
});
it('punishes a landed swing hard, with heat', () => {
const o = scoreFight(at('swung'));
expect(o.vibeDelta).toBeLessThan(0);
expect(o.heat).toBe(true);
expect(o.aggroDelta).toBeGreaterThan(0);
});
it('costs more vibe to miss than it pays to break up', () => {
expect(Math.abs(scoreFight(at('swung')).vibeDelta))
.toBeGreaterThan(scoreFight(at('broken')).vibeDelta);
expect(scoreFight(at('swung')).aggroDelta)
.toBeGreaterThan(scoreFight(at('broken')).aggroDelta);
});
it('scores a live fight at zero', () => {
const o = scoreFight(at('shoving'));
expect(o).toEqual({ vibeDelta: 0, aggroDelta: 0, heat: false });
});
it('only ever raises heat on a swing', () => {
expect(scoreFight(at('shoving')).heat).toBe(false);
expect(scoreFight(at('broken')).heat).toBe(false);
expect(scoreFight(at('swung')).heat).toBe(true);
});
});
describe('fightEligible', () => {
it('is false for a sober pair standing on each other', () => {
expect(fightEligible(0, 0, 1)).toBe(false);
});
it('is false when only one of them is drunk', () => {
expect(fightEligible(0.9, 0.1, 10)).toBe(false);
expect(fightEligible(0.1, 0.9, 10)).toBe(false);
});
it('is false for a drunk pair on opposite sides of the room', () => {
expect(fightEligible(0.9, 0.9, FIGHT_MIN_DIST_PX + 1)).toBe(false);
expect(fightEligible(0.9, 0.9, 400)).toBe(false);
});
it('is true for a close, drunk pair', () => {
expect(fightEligible(0.7, 0.6, 20)).toBe(true);
});
it('is true at both boundaries exactly', () => {
expect(fightEligible(FIGHT_MIN_INTOX, FIGHT_MIN_INTOX, FIGHT_MIN_DIST_PX)).toBe(true);
});
it('is false a hair under the intoxication boundary', () => {
expect(fightEligible(FIGHT_MIN_INTOX - 0.001, FIGHT_MIN_INTOX, FIGHT_MIN_DIST_PX)).toBe(false);
expect(fightEligible(FIGHT_MIN_INTOX, FIGHT_MIN_INTOX - 0.001, FIGHT_MIN_DIST_PX)).toBe(false);
});
it('gates on the tipsy/loose boundary, so a loose pair qualifies', () => {
expect(drunkStage(FIGHT_MIN_INTOX)).toBe('loose');
expect(drunkStage(FIGHT_MIN_INTOX - 0.001)).toBe('tipsy');
expect(fightEligible(0.5, 0.5, 30)).toBe(true);
expect(fightEligible(0.44, 0.44, 30)).toBe(false);
});
});

View File

@ -0,0 +1,290 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { EventBus } from '../../src/core/EventBus';
import { SeededRNG } from '../../src/core/SeededRNG';
import type { EventMap, Patron } from '../../src/data/types';
import { _resetPatronSerial, generatePatron } from '../../src/patrons/generator';
import { CrowdSim } from '../../src/scenes/floor/crowdSim';
import type { Agent } from '../../src/scenes/floor/crowdSim';
import { FIGHT_COOLDOWN_MS, FIGHT_FUSE_MS } from '../../src/scenes/floor/fight';
import { FightDirector } from '../../src/scenes/floor/fightDirector';
import { FIGHT_CAUSE } from '../../src/data/strings/floor';
import { VENUE } from '../../src/scenes/floor/venueMap';
const NIGHT = new Date('2026-07-19T21:00:00');
// Nowhere near the pair, so nothing accidentally counts as intervening.
const AWAY_X = 20;
const AWAY_Y = 20;
function makeCrowd(count: number, seed = 99): Patron[] {
_resetPatronSerial();
const rng = new SeededRNG(seed);
const out: Patron[] = [];
for (let i = 0; i < count; i++) out.push(generatePatron({ rng, nightDate: NIGHT }, 60));
return out;
}
interface Rig {
bus: EventBus;
crowd: CrowdSim;
dir: FightDirector;
agents: Agent[];
meters: Array<EventMap['meters:delta']>;
heat: Array<EventMap['heat:strike']>;
spotted: Array<EventMap['floor:infractionSpotted']>;
}
/**
* `count` agents parked shoulder-to-shoulder and all well past FIGHT_MIN_INTOX,
* so every pair is eligible and the director has a real choice to make. Nothing
* calls crowd.update() unless a test wants it that keeps the bus to fight events.
*/
function rig(count = 2, seed = 7): Rig {
const bus = new EventBus();
const rng = new SeededRNG(seed);
const crowd = new CrowdSim({ map: VENUE, bus, rng: rng.stream('crowd') });
const agents = makeCrowd(count).map((p, i) => {
const a = crowd.spawn(p);
a.patron.intoxication = 0.8;
a.activity = 'dancing';
a.dwellMs = 10 * 60 * 1000;
// Open floor, 20px apart: inside FIGHT_MIN_DIST_PX (so neighbours are
// eligible), outside the ambient bump radius, and clear of every wall so a
// released agent can actually walk off.
a.x = 240 + i * 20;
a.y = 100;
return a;
});
const meters: Array<EventMap['meters:delta']> = [];
const heat: Array<EventMap['heat:strike']> = [];
const spotted: Array<EventMap['floor:infractionSpotted']> = [];
bus.on('meters:delta', (p) => meters.push(p));
bus.on('heat:strike', (p) => heat.push(p));
bus.on('floor:infractionSpotted', (p) => spotted.push(p));
return {
bus, crowd, agents, meters, heat, spotted,
dir: new FightDirector({ bus, rng: rng.stream('fights'), crowd }),
};
}
function runAway(dir: FightDirector, ms: number, step = 100): void {
for (let t = 0; t < ms; t += step) dir.update(AWAY_X, AWAY_Y, step);
}
/** The point the player has to stand on to get between them. */
function midpoint(p: [Agent, Agent]): [number, number] {
return [(p[0].x + p[1].x) / 2, (p[0].y + p[1].y) / 2];
}
beforeEach(() => {
_resetPatronSerial();
});
describe('starting', () => {
it('kicks off a fight in a drunk crowd and flags it as an infraction', () => {
const r = rig(6);
runAway(r.dir, 500);
const pair = r.dir.pair();
expect(pair).not.toBeNull();
expect(r.dir.active?.phase).toBe('shoving');
expect(FIGHT_CAUSE).toContain(r.dir.cause);
expect(r.spotted).toEqual([{ patronId: pair?.[0].patron.id, kind: 'fight' }]);
});
it('never runs two fights at once', () => {
const r = rig(6);
// Derived from the constants rather than hardcoded: a wall of frames that
// covered several fights at a 20s cooldown covers less than one at 150s,
// and the >1 assertion below is what proves the window actually exercised
// the between-fights transition rather than passing vacuously.
const dt = 50;
const frames = Math.ceil((3 * (FIGHT_FUSE_MS + FIGHT_COOLDOWN_MS)) / dt);
for (let i = 0; i < frames; i++) {
r.dir.update(AWAY_X, AWAY_Y, dt);
const squaring = r.agents.filter((a) => a.activity === 'squaringUp');
expect(squaring.length).toBeLessThanOrEqual(2);
expect(squaring.length).toBe(r.dir.active ? 2 : 0);
}
expect(r.spotted.length).toBeGreaterThan(1);
});
it('leaves a sober crowd alone', () => {
const r = rig(6);
for (const a of r.agents) a.patron.intoxication = 0.2;
runAway(r.dir, 60_000);
expect(r.dir.active).toBeNull();
expect(r.spotted).toHaveLength(0);
});
it('pins both agents so the sim stops moving them', () => {
const r = rig(3);
// Walking, not parked — without the pin the sim would carry them off, which
// is what makes the unchanged coordinates below mean anything.
for (const a of r.agents) a.activity = 'walking';
r.dir.update(AWAY_X, AWAY_Y, 16);
const pair = r.dir.pair();
expect(pair).not.toBeNull();
if (!pair) return;
const loose = r.agents.find((a) => !pair.includes(a));
const before = [...pair, loose].map((a) => ({ x: a?.x, y: a?.y }));
for (let i = 0; i < 20; i++) {
r.crowd.update(16, 60 + i / 60);
r.dir.update(AWAY_X, AWAY_Y, 16);
}
for (const [i, a] of pair.entries()) {
expect(a.activity).toBe('squaringUp');
expect(a.x).toBe(before[i]?.x);
expect(a.y).toBe(before[i]?.y);
}
// Control: the one nobody pinned kept walking.
expect(loose?.x).not.toBe(before[2]?.x);
});
});
describe('resolving', () => {
it('breaks up when you stand between them', () => {
const r = rig(2);
r.dir.update(AWAY_X, AWAY_Y, 16);
const pair = r.dir.pair();
expect(pair).not.toBeNull();
if (!pair) return;
const [px, py] = midpoint(pair);
r.dir.update(px, py, 16);
expect(r.dir.active).toBeNull();
expect(r.meters).toEqual([{ vibe: 6, aggro: 1 }]);
expect(r.heat).toHaveLength(0);
for (const a of pair) {
expect(a.handled).toBe(true);
expect(a.activity).toBe('walking');
}
});
it('lets the swing land if you walk away, and takes a heat strike for it', () => {
const r = rig(2);
runAway(r.dir, FIGHT_FUSE_MS + 500);
expect(r.dir.active).toBeNull();
expect(r.meters).toEqual([{ vibe: -12, aggro: 20 }]);
// Deferred: a missed brawl surfaces at inspection rather than striking on the spot.
expect(r.heat).toEqual([{ reason: 'Fight on the floor', deferred: true }]);
});
it('does not count a swung fight as dealt with', () => {
const r = rig(2);
r.dir.update(AWAY_X, AWAY_Y, 16);
const pair = r.dir.pair();
runAway(r.dir, FIGHT_FUSE_MS + 500);
expect(r.dir.takeResolution()?.phase).toBe('swung');
for (const a of pair ?? []) {
expect(a.handled).toBe(false);
expect(a.activity).toBe('walking');
}
});
it('hands the resolution over exactly once', () => {
const r = rig(2);
r.dir.update(AWAY_X, AWAY_Y, 16);
const pair = r.dir.pair();
expect(r.dir.takeResolution()).toBeNull(); // nothing to say mid-fight
if (!pair) return;
const [px, py] = midpoint(pair);
r.dir.update(px, py, 16);
const res = r.dir.takeResolution();
expect(res?.phase).toBe('broken');
expect(res?.outcome.heat).toBe(false);
expect(res?.line).toBeTruthy();
expect(r.dir.takeResolution()).toBeNull();
});
});
describe('cooldown', () => {
it('will not start another fight straight after one resolves', () => {
const r = rig(6);
runAway(r.dir, FIGHT_FUSE_MS + 200);
expect(r.spotted).toHaveLength(1);
expect(r.dir.active).toBeNull();
runAway(r.dir, FIGHT_COOLDOWN_MS - 1000);
expect(r.dir.active).toBeNull();
expect(r.spotted).toHaveLength(1);
runAway(r.dir, 2000);
expect(r.spotted).toHaveLength(2);
});
});
describe('bail-outs', () => {
it('aborts without scoring if one of them leaves mid-fight', () => {
const r = rig(2);
r.dir.update(AWAY_X, AWAY_Y, 16);
const pair = r.dir.pair();
expect(pair).not.toBeNull();
if (!pair) return;
const [a, b] = pair;
a.gone = true;
r.dir.update(AWAY_X, AWAY_Y, 16);
expect(r.dir.active).toBeNull();
expect(r.dir.takeResolution()).toBeNull();
expect(r.meters).toHaveLength(0);
expect(r.heat).toHaveLength(0);
expect(b.activity).toBe('walking');
expect(b.handled).toBe(false);
// Nobody drags a departed patron back onto the dance floor.
expect(a.activity).toBe('squaringUp');
// And the cooldown started, so the survivor isn't instantly in another one.
runAway(r.dir, FIGHT_COOLDOWN_MS - 2000);
expect(r.dir.active).toBeNull();
});
it('leaves nobody frozen when destroyed', () => {
const r = rig(6);
r.dir.update(AWAY_X, AWAY_Y, 16);
expect(r.agents.filter((a) => a.activity === 'squaringUp')).toHaveLength(2);
r.dir.destroy();
expect(r.dir.active).toBeNull();
expect(r.dir.pair()).toBeNull();
expect(r.agents.filter((a) => a.activity === 'squaringUp')).toHaveLength(0);
});
});
describe('determinism', () => {
it('picks the same pair and the same cause from the same seed', () => {
const runOnce = (): { ids: string[]; cause: string } => {
const r = rig(6, 4242);
r.dir.update(AWAY_X, AWAY_Y, 16);
const pair = r.dir.pair();
return { ids: (pair ?? []).map((a) => a.patron.id), cause: r.dir.cause };
};
const first = runOnce();
expect(first.ids).toHaveLength(2);
expect(runOnce()).toEqual(first);
});
it('diverges on a different seed', () => {
const causeFor = (seed: number): string => {
const r = rig(6, seed);
runAway(r.dir, 200_000); // several fights, so the streams have room to drift
return r.spotted.map((s) => s.patronId).join(',');
};
expect(causeFor(1)).not.toBe(causeFor(2));
});
});