diff --git a/LANEHANDOVER.md b/LANEHANDOVER.md index 5935c25..250a739 100644 --- a/LANEHANDOVER.md +++ b/LANEHANDOVER.md @@ -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. diff --git a/src/data/strings/floor.ts b/src/data/strings/floor.ts index 765ed78..e9206bf 100644 --- a/src/data/strings/floor.ts +++ b/src/data/strings/floor.ts @@ -80,6 +80,43 @@ export const PATDOWN_FOUND: Record = { 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."', diff --git a/src/main.ts b/src/main.ts index eed7ac5..604e1c5 100644 --- a/src/main.ts +++ b/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'); + } + } }); diff --git a/src/scenes/door/NightScene.ts b/src/scenes/door/NightScene.ts index 899d5b3..0c8950b 100644 --- a/src/scenes/door/NightScene.ts +++ b/src/scenes/door/NightScene.ts @@ -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; diff --git a/src/scenes/floor/FloorDemoScene.ts b/src/scenes/floor/FloorDemoScene.ts index d5352f2..bd12ed1 100644 --- a/src/scenes/floor/FloorDemoScene.ts +++ b/src/scenes/floor/FloorDemoScene.ts @@ -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> = { + 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(); private spotted = new Set(); + private liveFight: { id: string; cause: string } | null = null; + /** Worst drunk stage we've already had words about, per patron. */ + private cutOffDealt = new Map(); 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(); diff --git a/src/scenes/floor/FloorView.ts b/src/scenes/floor/FloorView.ts index ce81d66..1263ffe 100644 --- a/src/scenes/floor/FloorView.ts +++ b/src/scenes/floor/FloorView.ts @@ -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(); + // 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(); 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; diff --git a/src/scenes/floor/crowdSim.ts b/src/scenes/floor/crowdSim.ts index c124eaa..80e8da6 100644 --- a/src/scenes/floor/crowdSim.ts +++ b/src/scenes/floor/crowdSim.ts @@ -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); } diff --git a/src/scenes/floor/fight.ts b/src/scenes/floor/fight.ts new file mode 100644 index 0000000..abb3498 --- /dev/null +++ b/src/scenes/floor/fight.ts @@ -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 + ); +} diff --git a/src/scenes/floor/fightDirector.ts b/src/scenes/floor/fightDirector.ts new file mode 100644 index 0000000..2c71cf8 --- /dev/null +++ b/src/scenes/floor/fightDirector.ts @@ -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 = new Set([ + '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; + } +} diff --git a/src/scenes/floor/overlays/PatDownOverlay.ts b/src/scenes/floor/overlays/PatDownOverlay.ts index ff72043..533cd9b 100644 --- a/src/scenes/floor/overlays/PatDownOverlay.ts +++ b/src/scenes/floor/overlays/PatDownOverlay.ts @@ -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 { diff --git a/tests/floor/fight.test.ts b/tests/floor/fight.test.ts new file mode 100644 index 0000000..6fdd40c --- /dev/null +++ b/tests/floor/fight.test.ts @@ -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); + }); +}); diff --git a/tests/floor/fightDirector.test.ts b/tests/floor/fightDirector.test.ts new file mode 100644 index 0000000..6d7d81a --- /dev/null +++ b/tests/floor/fightDirector.test.ts @@ -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; + heat: Array; + spotted: Array; +} + +/** + * `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 = []; + const heat: Array = []; + const spotted: Array = []; + 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)); + }); +});