import { PlayerStateMachine } from '@logic/PlayerStateMachine'; import { PlayerColorState, PLAYER_IFRAME_SECONDS } from '@common/Constants'; describe('PlayerStateMachine — auto-upgrade (req 5.1-5.2)', () => { it('Red → Green on first crystal pickup', () => { const sm = new PlayerStateMachine(); expect(sm.pickupCrystalJade()).toBe(PlayerColorState.Green); }); it('Green → Yellow on second crystal pickup', () => { const sm = new PlayerStateMachine(); sm.pickupCrystalJade(); expect(sm.pickupCrystalJade()).toBe(PlayerColorState.Yellow); }); it('Yellow → Yellow on additional crystal pickups (no overflow)', () => { const sm = new PlayerStateMachine(); sm.pickupCrystalJade(); sm.pickupCrystalJade(); expect(sm.pickupCrystalJade()).toBe(PlayerColorState.Yellow); }); it('Zeng Wan adds one life (req 7.5)', () => { const sm = new PlayerStateMachine(1); expect(sm.pickupZengWan()).toBe(2); }); }); describe('PlayerStateMachine — damage (req 5.3-5.6, 10.4-10.5)', () => { it('downgrades from Yellow to Red on shuriken hit', () => { const sm = new PlayerStateMachine(); sm.pickupCrystalJade(); sm.pickupCrystalJade(); const out = sm.takeHit('shuriken'); expect(out).toEqual({ kind: 'downgraded', from: PlayerColorState.Yellow, to: PlayerColorState.Red }); expect(sm.color).toBe(PlayerColorState.Red); }); it('downgrades from Green to Red on sword hit', () => { const sm = new PlayerStateMachine(); sm.pickupCrystalJade(); const out = sm.takeHit('sword'); expect(out.kind).toBe('downgraded'); expect(sm.color).toBe(PlayerColorState.Red); }); it('Red + shuriken → death, consumes one life', () => { const sm = new PlayerStateMachine(2); const out = sm.takeHit('shuriken'); expect(out.kind).toBe('died'); expect(sm.lives).toBe(1); expect(sm.isDead).toBe(false); }); it('fireball is always lethal regardless of color', () => { const sm = new PlayerStateMachine(2); sm.pickupCrystalJade(); sm.pickupCrystalJade(); const out = sm.takeHit('fireball'); expect(out).toEqual({ kind: 'died', cause: 'fireball' }); expect(sm.color).toBe(PlayerColorState.Red); }); it('smoke bomb is always lethal', () => { const sm = new PlayerStateMachine(); sm.pickupCrystalJade(); const out = sm.takeHit('smoke_bomb'); expect(out.kind).toBe('died'); }); it('zero lives → isDead=true', () => { const sm = new PlayerStateMachine(1); sm.takeHit('sword'); expect(sm.lives).toBe(0); expect(sm.isDead).toBe(true); }); }); describe('PlayerStateMachine — iframes & sword parry (req 3.7-3.8, 10.2-10.3)', () => { it('i-frames start at PLAYER_IFRAME_SECONDS after a hit', () => { const sm = new PlayerStateMachine(); sm.pickupCrystalJade(); sm.takeHit('shuriken'); expect(sm.snapshot.iframeSec).toBe(PLAYER_IFRAME_SECONDS); }); it('hits during i-frames are ignored', () => { const sm = new PlayerStateMachine(); sm.pickupCrystalJade(); sm.takeHit('shuriken'); const out = sm.takeHit('shuriken'); expect(out).toEqual({ kind: 'no_effect', reason: 'iframe' }); }); it('tick() drains i-frames so the player is vulnerable again', () => { const sm = new PlayerStateMachine(); sm.pickupCrystalJade(); sm.takeHit('shuriken'); sm.tick(PLAYER_IFRAME_SECONDS + 0.01); expect(sm.snapshot.iframeSec).toBe(0); }); it('sword parry nullifies shuriken/sword damage', () => { const sm = new PlayerStateMachine(); sm.setSwordActive(true); const out = sm.takeHit('shuriken'); expect(out).toEqual({ kind: 'no_effect', reason: 'parried' }); expect(sm.color).toBe(PlayerColorState.Red); }); it('sword parry does NOT nullify fireball / smoke_bomb (req 3.8, 10.4-10.5)', () => { const sm = new PlayerStateMachine(); sm.setSwordActive(true); expect(sm.takeHit('fireball').kind).toBe('died'); const sm2 = new PlayerStateMachine(); sm2.setSwordActive(true); expect(sm2.takeHit('smoke_bomb').kind).toBe('died'); }); });