import { AttackController, IJumpStateProvider } from '@logic/AttackController'; import { WeaponType } from '@data/Interfaces'; import { PlayerColorState } from '@common/Constants'; function makeJumpState(ts?: number): IJumpStateProvider { return { lastJumpPressTs: () => ts, isGrounded: () => ts === undefined, }; } describe('AttackController — mutual exclusion (req 3.1-3.3)', () => { it('first-pressed weapon wins when both buttons go down together', () => { const ac = new AttackController(); ac.press(WeaponType.Shuriken, 10); ac.press(WeaponType.NinjaSword, 11); expect(ac.getActive()).toBe(WeaponType.Shuriken); }); it('releasing the active weapon transfers activation to the still-held one', () => { const ac = new AttackController(); ac.press(WeaponType.Shuriken, 0); ac.press(WeaponType.NinjaSword, 10); ac.release(WeaponType.Shuriken); expect(ac.getActive()).toBe(WeaponType.NinjaSword); }); it('releasing the only pressed weapon deactivates everything', () => { const ac = new AttackController(); ac.press(WeaponType.Shuriken, 0); ac.release(WeaponType.Shuriken); expect(ac.getActive()).toBe('none'); }); }); describe('AttackController — firing intervals (req 3.4, 3.6)', () => { it('shuriken fires every 300ms for red/green, 250ms for yellow', () => { const ac = new AttackController(); ac.press(WeaponType.Shuriken, 0); expect(ac.tick(0, PlayerColorState.Red).length).toBe(1); expect(ac.tick(299, PlayerColorState.Red).length).toBe(0); expect(ac.tick(300, PlayerColorState.Red).length).toBe(1); const fast = new AttackController(); fast.press(WeaponType.Shuriken, 0); fast.tick(0, PlayerColorState.Yellow); expect(fast.tick(249, PlayerColorState.Yellow).length).toBe(0); expect(fast.tick(250, PlayerColorState.Yellow).length).toBe(1); }); it('sword fires every 500ms', () => { const ac = new AttackController(); ac.press(WeaponType.NinjaSword, 0); ac.tick(0, PlayerColorState.Red); expect(ac.tick(499, PlayerColorState.Red).length).toBe(0); expect(ac.tick(500, PlayerColorState.Red).length).toBe(1); }); it('shuriken burst index caps at SHURIKEN_BURST_MAX', () => { const ac = new AttackController(); ac.press(WeaponType.Shuriken, 0); const indexes: number[] = []; for (let i = 0; i <= 600; i += 300) { const fires = ac.tick(i, PlayerColorState.Red); if (fires.length) indexes.push(fires[0].burstIndex); } expect(indexes).toEqual([1, 2, 3]); // One more attempt must still cap at 3 not 4. const more = ac.tick(900, PlayerColorState.Red); expect(more[0].burstIndex).toBe(3); }); }); describe('AttackController — combo window (req 4.1)', () => { it('comboWithJump is true when jump timestamp is within 100ms', () => { const ac = new AttackController(makeJumpState(95)); ac.press(WeaponType.Shuriken, 100); const fires = ac.tick(100, PlayerColorState.Red); expect(fires[0].comboWithJump).toBe(true); }); it('comboWithJump is false when jump was pressed >100ms ago', () => { const ac = new AttackController(makeJumpState(0)); ac.press(WeaponType.Shuriken, 200); const fires = ac.tick(200, PlayerColorState.Red); expect(fires[0].comboWithJump).toBe(false); }); it('comboWithJump is false when the player has not jumped yet', () => { const ac = new AttackController(makeJumpState(undefined)); ac.press(WeaponType.Shuriken, 0); const fires = ac.tick(0, PlayerColorState.Red); expect(fires[0].comboWithJump).toBe(false); }); }); describe('AttackController — reset()', () => { it('clears active / pressed / cooldowns', () => { const ac = new AttackController(); ac.press(WeaponType.Shuriken, 0); ac.tick(0, PlayerColorState.Red); ac.reset(); expect(ac.getActive()).toBe('none'); expect(ac.isPressed(WeaponType.Shuriken)).toBe(false); }); });