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).
|
- 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.
|
||||||
|
|||||||
@ -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."',
|
||||||
|
|||||||
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 { 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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,7 +481,12 @@ 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') {
|
||||||
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) {
|
if (best.patron.flags.contraband?.length) {
|
||||||
this.spot(best.patron.id, 'contraband');
|
this.spot(best.patron.id, 'contraband');
|
||||||
@ -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();
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
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 {
|
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
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