import { describe, expect, it } from 'vitest'; import { applyBang, BANGS_TO_BUST, beatError, freshStall, judgeBang, OK_MS, PERFECT_MS, scoreStall, type StallState, } from '../../src/scenes/floor/bangJudge'; const INTERVAL = 60_000 / 128; const TICK = { beatIndex: 4, audioTimeMs: 10_000 }; describe('beatError', () => { it('is zero exactly on the tick', () => { expect(beatError(TICK.audioTimeMs, TICK, INTERVAL)).toBe(0); }); it('measures to the nearest boundary, not to the tick itself', () => { expect(beatError(TICK.audioTimeMs + INTERVAL, TICK, INTERVAL)).toBeCloseTo(0, 6); expect(beatError(TICK.audioTimeMs + 3 * INTERVAL + 25, TICK, INTERVAL)).toBeCloseTo(25, 6); }); it('judges a bang landing before the tick against the previous boundary', () => { expect(beatError(TICK.audioTimeMs - INTERVAL + 30, TICK, INTERVAL)).toBeCloseTo(30, 6); expect(beatError(TICK.audioTimeMs - 20, TICK, INTERVAL)).toBeCloseTo(20, 6); }); it('peaks halfway between beats', () => { expect(beatError(TICK.audioTimeMs + INTERVAL / 2, TICK, INTERVAL)).toBeCloseTo(INTERVAL / 2, 6); }); }); describe('judgeBang', () => { it('grades an exact hit as perfect', () => { expect(judgeBang(TICK.audioTimeMs, TICK, INTERVAL)).toBe('perfect'); }); it('still grades perfect a full beat interval later', () => { expect(judgeBang(TICK.audioTimeMs + INTERVAL, TICK, INTERVAL)).toBe('perfect'); expect(judgeBang(TICK.audioTimeMs + 8 * INTERVAL, TICK, INTERVAL)).toBe('perfect'); }); it('grades halfway between beats as a miss', () => { expect(judgeBang(TICK.audioTimeMs + INTERVAL / 2, TICK, INTERVAL)).toBe('miss'); expect(judgeBang(TICK.audioTimeMs + 2.5 * INTERVAL, TICK, INTERVAL)).toBe('miss'); }); it('treats PERFECT_MS as inclusive and just past it as ok', () => { expect(judgeBang(TICK.audioTimeMs + PERFECT_MS, TICK, INTERVAL)).toBe('perfect'); expect(judgeBang(TICK.audioTimeMs - PERFECT_MS, TICK, INTERVAL)).toBe('perfect'); expect(judgeBang(TICK.audioTimeMs + PERFECT_MS + 0.5, TICK, INTERVAL)).toBe('ok'); }); it('treats OK_MS as inclusive and just past it as a miss', () => { expect(judgeBang(TICK.audioTimeMs + OK_MS, TICK, INTERVAL)).toBe('ok'); expect(judgeBang(TICK.audioTimeMs - OK_MS, TICK, INTERVAL)).toBe('ok'); expect(judgeBang(TICK.audioTimeMs + OK_MS + 0.5, TICK, INTERVAL)).toBe('miss'); }); it('grades late bangs relative to whichever boundary is closest', () => { // 100ms past the fourth boundary — ok, even though it is ~1875ms past the tick. expect(judgeBang(TICK.audioTimeMs + 4 * INTERVAL + 100, TICK, INTERVAL)).toBe('ok'); }); }); describe('applyBang', () => { it('does not mutate the input state', () => { const s = freshStall(); const snapshot: StallState = { ...s }; applyBang(s, 'perfect'); expect(s).toEqual(snapshot); }); it('advances the combo on perfect and ok alike', () => { const a = applyBang(freshStall(), 'perfect'); const b = applyBang(a, 'ok'); expect(b.combo).toBe(2); expect(b.bangs).toBe(2); expect(b.aggro).toBe(0); expect(b.lastGrade).toBe('ok'); }); it('resets the combo and adds aggro on a miss', () => { const a = applyBang(applyBang(freshStall(), 'ok'), 'miss'); expect(a.combo).toBe(0); expect(a.bangs).toBe(2); expect(a.aggro).toBeCloseTo(0.8, 6); expect(a.busted).toBe(false); }); it('busts after three consecutive ok bangs', () => { let s = freshStall(); for (let i = 0; i < BANGS_TO_BUST; i++) s = applyBang(s, 'ok'); expect(s.busted).toBe(true); }); it('stays busted once busted, even after a later miss', () => { let s = freshStall(); for (let i = 0; i < BANGS_TO_BUST; i++) s = applyBang(s, 'perfect'); s = applyBang(s, 'miss'); expect(s.busted).toBe(true); expect(s.combo).toBe(0); }); it('is not busted when a miss interrupts the run', () => { let s = freshStall(); for (const g of ['ok', 'miss', 'ok', 'ok'] as const) s = applyBang(s, g); expect(s.busted).toBe(false); expect(s.combo).toBe(2); expect(s.bangs).toBe(4); }); }); describe('scoreStall', () => { it('pays out big when the stall is busted', () => { let s = freshStall(); for (let i = 0; i < BANGS_TO_BUST; i++) s = applyBang(s, 'perfect'); const out = scoreStall(s); expect(out.solved).toBe(true); expect(out.vibeDelta).toBe(9); expect(out.aggroDelta).toBe(0); }); it('costs vibe when the player made a scene and achieved nothing', () => { const out = scoreStall(applyBang(freshStall(), 'miss')); expect(out.solved).toBe(false); expect(out.vibeDelta).toBe(-1.5); expect(out.aggroDelta).toBeCloseTo(0.8, 6); }); it('clamps accumulated aggro at 6', () => { let s = freshStall(); for (let i = 0; i < 12; i++) s = applyBang(s, 'miss'); expect(s.aggro).toBeGreaterThan(6); expect(scoreStall(s).aggroDelta).toBe(6); }); it('carries aggro from misses through a bust', () => { let s = freshStall(); for (const g of ['miss', 'miss', 'ok', 'ok', 'perfect'] as const) s = applyBang(s, g); const out = scoreStall(s); expect(out.solved).toBe(true); expect(out.aggroDelta).toBeCloseTo(1.6, 6); }); });