Merge branch 'lane/floor'
# Conflicts: # LANEHANDOVER.md # src/scenes/floor/FloorDemoScene.ts
This commit is contained in:
commit
df70c99dd0
@ -769,3 +769,96 @@ than requesting `capacity.inside` in a payload (reasoning above).
|
||||
- Wire `Ambience` into `NightScene` (integration-owned).
|
||||
- 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`.
|
||||
|
||||
---
|
||||
### 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.
|
||||
|
||||
@ -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_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. */
|
||||
export const NO_STAMP_RADIO: readonly string[] = [
|
||||
'"Yeah sorry, that one might\'ve been me. Sorry. Sorry."',
|
||||
|
||||
11
src/main.ts
11
src/main.ts
@ -7,6 +7,7 @@ import { NightSummaryScene } from './scenes/door/NightSummaryScene';
|
||||
import { IncidentReportScene } from './scenes/door/IncidentReportScene';
|
||||
import { FloorDemoScene } from './scenes/floor/FloorDemoScene';
|
||||
import { JuiceDemoScene } from './ui/JuiceDemoScene';
|
||||
import { MixDeskScene } from './ui/MixDeskScene';
|
||||
|
||||
// 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
|
||||
@ -34,6 +35,7 @@ const game = new Phaser.Game({
|
||||
ParadeScene,
|
||||
new FloorDemoScene('FloorDemo'),
|
||||
JuiceDemoScene,
|
||||
MixDeskScene,
|
||||
],
|
||||
});
|
||||
|
||||
@ -72,4 +74,13 @@ window.addEventListener('keydown', (e) => {
|
||||
else if (k === 'p') showScene('Parade');
|
||||
else if (k === 'f') showScene('FloorDemo');
|
||||
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');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -8,6 +8,7 @@ import { loadGame, saveGame, clearSave, freshGameState } from '../../core/save';
|
||||
import { _resetPatronSerial } from '../../patrons/generator';
|
||||
import { ruleById } from '../../rules/dressCode';
|
||||
import { TechnoEngine } from '../../audio/TechnoEngine';
|
||||
import { Ambience } from '../../audio/Ambience';
|
||||
import { Sfx } from '../../audio/Sfx';
|
||||
import type { DeferredHit } from '../../rules/doorTypes';
|
||||
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 engine: TechnoEngine | null = null;
|
||||
private sfx: Sfx | null = null;
|
||||
private ambience: Ambience | null = null;
|
||||
private meters!: Meters;
|
||||
private state!: NightState;
|
||||
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).
|
||||
this.engine = new TechnoEngine(this.bus);
|
||||
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.start();
|
||||
this.engine.start();
|
||||
@ -194,6 +198,7 @@ export class NightScene extends Phaser.Scene {
|
||||
this.bus.on('audio:unlocked', () => {
|
||||
this.stubBeat?.stop();
|
||||
this.stubBeat = null;
|
||||
this.ambience?.start();
|
||||
}),
|
||||
this.bus.on('radio:call', () => this.sfx?.play('radioChirp')),
|
||||
this.bus.on('incident:log', (inc) => {
|
||||
@ -201,6 +206,10 @@ export class NightScene extends Phaser.Scene {
|
||||
this.maggotLive = true;
|
||||
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.
|
||||
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();
|
||||
this.offs = [];
|
||||
this.meters?.destroy();
|
||||
this.ambience?.destroy();
|
||||
this.ambience = null;
|
||||
this.engine?.destroy();
|
||||
this.engine = null;
|
||||
this.sfx = null;
|
||||
|
||||
@ -6,12 +6,13 @@ import { StubBeatClock } from '../../core/StubBeatClock';
|
||||
import { Meters, freshNightState } from '../../core/meters';
|
||||
import { drunkStage } from '../../rules/drunk';
|
||||
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 { VENUE } from './venueMap';
|
||||
import { NORMAL_CONE, UV_CONE, inCone, type ConeSpec } from './cone';
|
||||
import { CrowdSim, type Agent } from './crowdSim';
|
||||
import { FightDirector } from './fightDirector';
|
||||
import { freshPlayer, stepPlayer, type PlayerInput, type PlayerState } from './player';
|
||||
import { FloorView } from './FloorView';
|
||||
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. */
|
||||
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. */
|
||||
type InteractKind = 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'exit';
|
||||
|
||||
@ -66,6 +71,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
private ctxOffs: Array<() => void> = [];
|
||||
private view!: FloorView;
|
||||
private crowd!: CrowdSim;
|
||||
private fights!: FightDirector;
|
||||
private player!: PlayerState;
|
||||
|
||||
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). */
|
||||
private maggotSeen = new Map<string, number>();
|
||||
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 statusText!: Phaser.GameObjects.Text;
|
||||
private radioText!: Phaser.GameObjects.Text;
|
||||
private radioMs = 0;
|
||||
private calmAccMs = 0;
|
||||
|
||||
constructor(key: string = 'FloorDemo') {
|
||||
super(key);
|
||||
@ -93,7 +103,9 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
|
||||
create(data?: { night?: NightContext }): void {
|
||||
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.setBackgroundColor('#04040a');
|
||||
|
||||
@ -131,18 +143,23 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
this.stall = new StallOverlay(this, this.bus, ctx.beatIntervalMs);
|
||||
|
||||
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 };
|
||||
this.player = freshPlayer(entry.x, entry.y);
|
||||
|
||||
this.view.reset();
|
||||
this.escorting = null;
|
||||
this.target = null;
|
||||
this.liveFight = null;
|
||||
this.uvMode = false;
|
||||
this.present = false; // the night starts at the rope
|
||||
this.elapsedMs = 0;
|
||||
this.pendingSpawns = [];
|
||||
this.maggotSeen.clear();
|
||||
this.spotted.clear();
|
||||
this.cutOffDealt.clear();
|
||||
|
||||
this.ctxOffs = [
|
||||
// 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.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));
|
||||
|
||||
const entry = VENUE.anchors.entry[0] ?? { x: 64, y: 64 };
|
||||
this.player = freshPlayer(entry.x, entry.y);
|
||||
|
||||
this.view.reset();
|
||||
this.escorting = null;
|
||||
this.target = null;
|
||||
this.liveFight = null;
|
||||
this.uvMode = false;
|
||||
this.spawnAccMs = 0;
|
||||
this.maggotSeen.clear();
|
||||
this.spotted.clear();
|
||||
this.cutOffDealt.clear();
|
||||
|
||||
this.clock.start();
|
||||
this.beat.start();
|
||||
@ -260,8 +282,9 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
this.pendingSpawns.splice(i, 1);
|
||||
// Licensed capacity is 80 but 44 agents is the sim's perf budget; past
|
||||
// that, arrivals dissolve into the (conceptual) back room. log()-style
|
||||
// honesty: this is a cap, not a simulation.
|
||||
if (this.crowd.agents.length < MAX_CROWD) this.crowd.spawn(s.patron);
|
||||
// honesty: this is a cap, not a simulation. Counts LIVE bodies (ejections
|
||||
// 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,
|
||||
@ -269,11 +292,23 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
// nothing needs the camera, the cone, or input.
|
||||
if (!this.present) {
|
||||
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.tickCalm(dt);
|
||||
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) {
|
||||
this.cutOff.update(dt);
|
||||
this.stall.update(dt);
|
||||
@ -291,8 +326,11 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
|
||||
if (!this.nightCtx) {
|
||||
// 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;
|
||||
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.spawnOne();
|
||||
}
|
||||
@ -304,6 +342,7 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
if (this.escorting) this.tickEscort(dt);
|
||||
|
||||
this.trackMaggots();
|
||||
this.tickCalm(dt);
|
||||
this.target = this.escorting ? null : this.findTarget();
|
||||
|
||||
this.cameras.main.centerOn(this.player.x, this.player.y);
|
||||
@ -315,6 +354,43 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
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 {
|
||||
const c = this.uvMode ? UV_CONE : NORMAL_CONE;
|
||||
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) {
|
||||
if (a.gone || a.handled) continue;
|
||||
if (drunkStage(a.patron.intoxication) !== 'maggot') continue;
|
||||
if (this.settledAt(a, 'maggot')) continue;
|
||||
const seen = this.maggotSeen.get(a.patron.id);
|
||||
if (seen === undefined) {
|
||||
this.maggotSeen.set(a.patron.id, now);
|
||||
@ -374,7 +451,11 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
if (d > INTERACT_RANGE) continue;
|
||||
const occupants = this.crowd.stallOccupancy().get(s.id) ?? [];
|
||||
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' };
|
||||
}
|
||||
|
||||
@ -400,7 +481,12 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
|
||||
const stage = drunkStage(best.patron.intoxication);
|
||||
if (stage === 'messy' || stage === 'maggot' || stage === 'loose') {
|
||||
return { kind: 'drunk', agent: best, prompt: 'E — have a word' };
|
||||
// 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' };
|
||||
}
|
||||
}
|
||||
if (best.patron.flags.contraband?.length) {
|
||||
this.spot(best.patron.id, 'contraband');
|
||||
@ -430,14 +516,18 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
(r) => {
|
||||
this.bus.emit('meters:delta', { vibe: r.vibeDelta, aggro: r.aggroDelta });
|
||||
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
|
||||
// frame: both get put back at the door and independently re-target,
|
||||
// which reads exactly like scattering in opposite directions.
|
||||
for (const a of occupants) {
|
||||
for (const a of inThere) {
|
||||
a.handled = true;
|
||||
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) => {
|
||||
if (!r) return;
|
||||
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) {
|
||||
this.startEscort(agent, r.outcome === 'clean' ? 'clean' : 'messy', 'drunk');
|
||||
} else {
|
||||
@ -478,6 +570,25 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
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 {
|
||||
const a = this.escorting;
|
||||
if (!a) return;
|
||||
@ -491,7 +602,12 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
// ---- chrome ------------------------------------------------------------
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@ -504,20 +620,26 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
this.radioMs -= dt;
|
||||
if (this.radioMs <= 0) this.radioText.setText('');
|
||||
}
|
||||
// A brewing fight outranks everything — it's the one thing on a clock.
|
||||
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(
|
||||
(a) => !a.gone && !a.handled && drunkStage(a.patron.intoxication) === 'maggot',
|
||||
).length;
|
||||
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();
|
||||
this.ctxOffs = [];
|
||||
this.fights?.destroy();
|
||||
this.crowd?.destroy();
|
||||
this.view?.destroy();
|
||||
this.cutOff?.destroy();
|
||||
|
||||
@ -2,6 +2,7 @@ import Phaser from 'phaser';
|
||||
import { renderDoll } from '../../patrons/doll';
|
||||
import { dollPlan } from '../../patrons/dollPlan';
|
||||
import { conePolygon, type ConeSpec } from './cone';
|
||||
import { FIGHT_FUSE_MS } from './fight';
|
||||
import { TILE, tileAt, tileColour, type VenueMap } from './venueMap';
|
||||
import type { Agent } from './crowdSim';
|
||||
|
||||
@ -35,6 +36,22 @@ const NEON_TINTS = [0xff3d9a, 0x38e0ff, 0xb45cff] as const;
|
||||
|
||||
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 {
|
||||
sil: Phaser.GameObjects.Image;
|
||||
detail: Phaser.GameObjects.Image;
|
||||
@ -59,8 +76,14 @@ export class FloorView {
|
||||
private readonly playerLight: Phaser.GameObjects.Image;
|
||||
private readonly playerImg: 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>();
|
||||
// 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 uv = false;
|
||||
|
||||
@ -118,6 +141,20 @@ export class FloorView {
|
||||
|
||||
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);
|
||||
|
||||
// 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 {
|
||||
@ -150,9 +187,14 @@ export class FloorView {
|
||||
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;
|
||||
|
||||
g.sil.setPosition(x, y);
|
||||
g.detail.setPosition(x, y);
|
||||
g.uvDot.setPosition(x + 4, y + 2);
|
||||
// Empty unless this punter is squaring up, so the common path is a miss.
|
||||
const sh = this.shove.get(a.patron.id);
|
||||
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);
|
||||
// No stamp, no dot — the absence is the tell.
|
||||
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)));
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
this.shove.clear();
|
||||
this.fightHalo.destroy();
|
||||
this.fightPip.destroy();
|
||||
for (const g of this.agents.values()) this.destroyGfx(g);
|
||||
this.agents.clear();
|
||||
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 {
|
||||
const g = this.agents.get(id);
|
||||
if (!g) return;
|
||||
|
||||
@ -9,7 +9,8 @@ import { isWalkable, isWalkableWorld, tileToWorld, worldToTile } from './venueMa
|
||||
import type { StallDef, VenueMap, WorldPoint } from './venueMap';
|
||||
|
||||
export type Activity =
|
||||
| 'walking' | 'atBar' | 'dancing' | 'inBooth' | 'inStall' | 'smoking' | 'escorted' | 'leaving';
|
||||
| 'walking' | 'atBar' | 'dancing' | 'inBooth' | 'inStall' | 'smoking' | 'escorted' | 'leaving'
|
||||
| 'squaringUp';
|
||||
|
||||
export interface Agent {
|
||||
patron: Patron;
|
||||
@ -178,7 +179,8 @@ export class CrowdSim {
|
||||
for (const a of this.agents) {
|
||||
if (a.gone) continue;
|
||||
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);
|
||||
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 {
|
||||
return this.agents.find((a) => a.patron.id === patronId);
|
||||
}
|
||||
|
||||
124
src/scenes/floor/fight.ts
Normal file
124
src/scenes/floor/fight.ts
Normal 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
|
||||
);
|
||||
}
|
||||
200
src/scenes/floor/fightDirector.ts
Normal file
200
src/scenes/floor/fightDirector.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -227,7 +227,10 @@ export class PatDownOverlay {
|
||||
|
||||
handleKey(key: string): void {
|
||||
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 {
|
||||
|
||||
306
tests/floor/fight.test.ts
Normal file
306
tests/floor/fight.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
290
tests/floor/fightDirector.test.ts
Normal file
290
tests/floor/fightDirector.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user