not-tonight/tests/floor/fight.test.ts
type-two 906a366ba8 LANE-FLOOR round 2: fight-brewing infraction + six defect fixes
Builds the last missing v0.2 floor mechanic (design §3.2). A fight is 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 — it spends the
floor's only real resource, where you are. The tell (shoving silhouettes +
pulsing marker) deliberately reads OUTSIDE the torch cone, because spotting it
is the gameplay.

Adversarially reviewed the whole lane, then hand-adjudicated the findings. Six
real defects fixed, several in code from round 1:
- PatDownOverlay compared 'Escape' against the scene's uppercased 'ESCAPE', so
  ESC was dead there while the on-screen hint promised it worked.
- The spawn cap counted departed agents (CrowdSim keeps them so find() works),
  so every ejection permanently shrank the venue and the floor drained.
- FloorView's sprite cache is keyed by patron id and outlived resetRun, while
  _resetPatronSerial recycles ids from p0 — reseeding gave the new crowd the
  old run's dolls.
- escalation's per-incident `resolved` was consumed as a permanent per-patron
  `handled`, retiring a correctly-watered `loose` patron for the whole night
  while they kept drinking. Now settles per stage and re-arms when they worsen.
- Stall busts retired the captured occupant list, but the crowd keeps simulating
  behind the modal. Re-queries at resolution.
- Stall infractions put a stall id in the frozen `patronId` field.
Also wired teardown to Phaser's SHUTDOWN event; a `shutdown()` method is never
called, so leaving the floor leaked the view, sim, overlays and bus listeners.

Fight balance was measured, not guessed: at a 20s cooldown an idle night brewed
28 fights and 28 heat strikes against a run cap of 3. Now 150s (~5/night) with a
deferred heat strike, matching §3.2's "possible Heat". An idle night ends at
vibe 0 / 5 strikes; an attentive one at vibe 54 / 0 strikes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:57:28 +10:00

307 lines
10 KiB
TypeScript

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/scenes/floor/strings';
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);
});
});