not-tonight/tests/floor/fight.test.ts
type-two df70c99dd0 Merge branch 'lane/floor'
# Conflicts:
#	LANEHANDOVER.md
#	src/scenes/floor/FloorDemoScene.ts
2026-07-19 22:14:08 +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/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);
});
});