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); }); });