/** * Test suite for the Battle Engine * Tests battle flow, damage calculation, effects, and type effectiveness */ import { describe, it, expect, beforeEach } from 'vitest'; import { BattleEngine } from './BattleEngine'; import { STELLAR_WOLF, TOXIC_CRAWLER, BERSERKER_BEAST, AQUA_GUARDIAN, BASIC_TACKLE, FLAME_BURST, HEALING_LIGHT, POWER_UP, BERSERKER_END, TOXIC_STING } from './test-data'; import { BattleAction } from './types'; describe('BattleEngine', () => { let engine: BattleEngine; beforeEach(() => { engine = new BattleEngine(STELLAR_WOLF, TOXIC_CRAWLER); }); describe('Battle Initialization', () => { it('should initialize battle state correctly', () => { const state = engine.getState(); expect(state.turn).toBe(1); expect(state.phase).toBe('selection'); expect(state.playerPiclet.definition.name).toBe('Stellar Wolf'); expect(state.opponentPiclet.definition.name).toBe('Toxic Crawler'); expect(state.winner).toBeUndefined(); expect(state.log.length).toBe(0); }); it('should calculate battle stats correctly', () => { const state = engine.getState(); const player = state.playerPiclet; // Level 50 should have base stats (no modifier) expect(player.maxHp).toBe(STELLAR_WOLF.baseStats.hp); expect(player.attack).toBe(STELLAR_WOLF.baseStats.attack); expect(player.defense).toBe(STELLAR_WOLF.baseStats.defense); expect(player.speed).toBe(STELLAR_WOLF.baseStats.speed); expect(player.currentHp).toBe(player.maxHp); }); it('should initialize moves with correct PP', () => { const state = engine.getState(); const playerMoves = state.playerPiclet.moves; expect(playerMoves).toHaveLength(4); expect(playerMoves[0].move.name).toBe('Tackle'); expect(playerMoves[0].currentPP).toBe(35); expect(playerMoves[1].move.name).toBe('Flame Burst'); expect(playerMoves[1].currentPP).toBe(15); }); }); describe('Basic Battle Flow', () => { it('should execute a basic turn', () => { const playerAction: BattleAction = { type: 'move', piclet: 'player', moveIndex: 0 }; const opponentAction: BattleAction = { type: 'move', piclet: 'opponent', moveIndex: 0 }; engine.executeActions(playerAction, opponentAction); const state = engine.getState(); expect(state.turn).toBe(2); expect(state.phase).toBe('selection'); expect(state.log.length).toBeGreaterThan(2); }); it('should consume PP when moves are used', () => { const playerAction: BattleAction = { type: 'move', piclet: 'player', moveIndex: 0 }; const opponentAction: BattleAction = { type: 'move', piclet: 'opponent', moveIndex: 0 }; const initialPP = engine.getState().playerPiclet.moves[0].currentPP; engine.executeActions(playerAction, opponentAction); const finalPP = engine.getState().playerPiclet.moves[0].currentPP; expect(finalPP).toBe(initialPP - 1); }); it('should handle moves with no PP', () => { // Manually set PP to 0 by getting mutable state const state = engine.getState(); engine['state'].playerPiclet.moves[0].currentPP = 0; const playerAction: BattleAction = { type: 'move', piclet: 'player', moveIndex: 0 }; const opponentAction: BattleAction = { type: 'move', piclet: 'opponent', moveIndex: 0 }; engine.executeActions(playerAction, opponentAction); const log = engine.getLog(); expect(log.some(msg => msg.includes('no PP left'))).toBe(true); }); }); describe('Damage Calculation', () => { it('should calculate basic damage correctly', () => { const playerAction: BattleAction = { type: 'move', piclet: 'player', moveIndex: 0 }; // Tackle const opponentAction: BattleAction = { type: 'move', piclet: 'opponent', moveIndex: 0 }; const initialHp = engine.getState().opponentPiclet.currentHp; engine.executeActions(playerAction, opponentAction); const finalHp = engine.getState().opponentPiclet.currentHp; expect(finalHp).toBeLessThan(initialHp); expect(finalHp).toBeGreaterThan(0); // Should not be a one-hit KO }); it('should apply type effectiveness correctly', () => { // Create engine with type advantage: Space vs Bug (Space is 2x effective vs Bug) const spaceVsBug = new BattleEngine(STELLAR_WOLF, TOXIC_CRAWLER); const playerAction: BattleAction = { type: 'move', piclet: 'player', moveIndex: 1 }; // Flame Burst (Space type) const opponentAction: BattleAction = { type: 'move', piclet: 'opponent', moveIndex: 0 }; const initialHp = spaceVsBug.getState().opponentPiclet.currentHp; spaceVsBug.executeActions(playerAction, opponentAction); const log = spaceVsBug.getLog(); expect(log.some(msg => msg.includes("It's super effective!"))).toBe(true); }); it('should apply STAB (Same Type Attack Bonus)', () => { // Stellar Wolf using Flame Burst (Space type move, matches primary type) const playerAction: BattleAction = { type: 'move', piclet: 'player', moveIndex: 1 }; const opponentAction: BattleAction = { type: 'move', piclet: 'opponent', moveIndex: 0 }; const initialHp = engine.getState().opponentPiclet.currentHp; engine.executeActions(playerAction, opponentAction); const finalHp = engine.getState().opponentPiclet.currentHp; // With STAB, damage should be higher than without expect(finalHp).toBeLessThan(initialHp); }); }); describe('Status Effects', () => { it('should apply poison status', () => { const toxicEngine = new BattleEngine(TOXIC_CRAWLER, STELLAR_WOLF); const playerAction: BattleAction = { type: 'move', piclet: 'player', moveIndex: 1 }; // Toxic Sting const opponentAction: BattleAction = { type: 'move', piclet: 'opponent', moveIndex: 0 }; toxicEngine.executeActions(playerAction, opponentAction); const state = toxicEngine.getState(); expect(state.opponentPiclet.statusEffects).toContain('poison'); }); it('should process poison damage at turn end', () => { const toxicEngine = new BattleEngine(TOXIC_CRAWLER, STELLAR_WOLF); const playerAction: BattleAction = { type: 'move', piclet: 'player', moveIndex: 1 }; // Toxic Sting const opponentAction: BattleAction = { type: 'move', piclet: 'opponent', moveIndex: 0 }; toxicEngine.executeActions(playerAction, opponentAction); const hpAfterPoison = toxicEngine.getState().opponentPiclet.currentHp; // Execute another turn to trigger poison damage toxicEngine.executeActions( { type: 'move', piclet: 'player', moveIndex: 0 }, { type: 'move', piclet: 'opponent', moveIndex: 0 } ); const hpAfterSecondTurn = toxicEngine.getState().opponentPiclet.currentHp; expect(hpAfterSecondTurn).toBeLessThan(hpAfterPoison); const log = toxicEngine.getLog(); expect(log.some(msg => msg.includes('hurt by poison'))).toBe(true); }); }); describe('Stat Modifications', () => { it('should increase attack stat', () => { const playerAction: BattleAction = { type: 'move', piclet: 'player', moveIndex: 3 }; // Power Up const opponentAction: BattleAction = { type: 'move', piclet: 'opponent', moveIndex: 0 }; const initialAttack = engine.getState().playerPiclet.attack; engine.executeActions(playerAction, opponentAction); const finalAttack = engine.getState().playerPiclet.attack; expect(finalAttack).toBeGreaterThan(initialAttack); const log = engine.getLog(); expect(log.some(msg => msg.includes("attack rose"))).toBe(true); }); }); describe('Healing Effects', () => { it('should heal HP correctly', () => { // Damage the player first by directly modifying the internal state engine['state'].playerPiclet.currentHp = Math.floor(engine['state'].playerPiclet.maxHp * 0.5); const playerAction: BattleAction = { type: 'move', piclet: 'player', moveIndex: 2 }; // Healing Light const opponentAction: BattleAction = { type: 'move', piclet: 'opponent', moveIndex: 0 }; const hpBeforeHeal = engine.getState().playerPiclet.currentHp; engine.executeActions(playerAction, opponentAction); const hpAfterHeal = engine.getState().playerPiclet.currentHp; expect(hpAfterHeal).toBeGreaterThan(hpBeforeHeal); const log = engine.getLog(); expect(log.some(msg => msg.includes('recovered') && msg.includes('HP'))).toBe(true); }); it('should not heal above max HP', () => { const playerAction: BattleAction = { type: 'move', piclet: 'player', moveIndex: 2 }; // Healing Light const opponentAction: BattleAction = { type: 'move', piclet: 'opponent', moveIndex: 0 }; engine.executeActions(playerAction, opponentAction); const state = engine.getState(); expect(state.playerPiclet.currentHp).toBeLessThanOrEqual(state.playerPiclet.maxHp); }); }); describe('Conditional Effects', () => { it('should trigger conditional effects when conditions are met', () => { const berserkerEngine = new BattleEngine(BERSERKER_BEAST, STELLAR_WOLF); // Set player to low HP to trigger condition berserkerEngine['state'].playerPiclet.currentHp = Math.floor(berserkerEngine['state'].playerPiclet.maxHp * 0.2); const playerAction: BattleAction = { type: 'move', piclet: 'player', moveIndex: 1 }; // Berserker's End const opponentAction: BattleAction = { type: 'move', piclet: 'opponent', moveIndex: 0 }; const initialDefense = berserkerEngine.getState().playerPiclet.defense; berserkerEngine.executeActions(playerAction, opponentAction); const finalDefense = berserkerEngine.getState().playerPiclet.defense; // Defense should be greatly decreased due to low HP condition expect(finalDefense).toBeLessThan(initialDefense); }); it('should not trigger conditional effects when conditions are not met', () => { const berserkerEngine = new BattleEngine(BERSERKER_BEAST, STELLAR_WOLF); // Player at full HP - condition not met const playerAction: BattleAction = { type: 'move', piclet: 'player', moveIndex: 1 }; // Berserker's End const opponentAction: BattleAction = { type: 'move', piclet: 'opponent', moveIndex: 0 }; const initialDefense = berserkerEngine.getState().playerPiclet.defense; berserkerEngine.executeActions(playerAction, opponentAction); const finalDefense = berserkerEngine.getState().playerPiclet.defense; // Defense should remain unchanged expect(finalDefense).toBe(initialDefense); }); }); describe('Battle End Conditions', () => { it('should end battle when player Piclet faints', () => { // Set player HP to 0 to guarantee fainting engine['state'].playerPiclet.currentHp = 0; // Force battle end check engine['checkBattleEnd'](); expect(engine.isGameOver()).toBe(true); expect(engine.getWinner()).toBe('opponent'); }); it('should end battle when opponent Piclet faints', () => { engine['state'].opponentPiclet.currentHp = 1; // Set to very low HP const playerAction: BattleAction = { type: 'move', piclet: 'player', moveIndex: 0 }; const opponentAction: BattleAction = { type: 'move', piclet: 'opponent', moveIndex: 0 }; engine.executeActions(playerAction, opponentAction); expect(engine.isGameOver()).toBe(true); expect(engine.getWinner()).toBe('player'); }); it('should handle draw when both Piclets faint', () => { // Set both HP to 0 to guarantee draw engine['state'].playerPiclet.currentHp = 0; engine['state'].opponentPiclet.currentHp = 0; // Force battle end check engine['checkBattleEnd'](); expect(engine.isGameOver()).toBe(true); expect(engine.getWinner()).toBe('draw'); }); }); describe('Move Accuracy', () => { it('should handle move misses', () => { // Mock Math.random to force a miss const originalRandom = Math.random; Math.random = () => 0.99; // Force miss for 90% accuracy moves const berserkerEngine = new BattleEngine(BERSERKER_BEAST, STELLAR_WOLF); const playerAction: BattleAction = { type: 'move', piclet: 'player', moveIndex: 1 }; // Berserker's End (90% accuracy) const opponentAction: BattleAction = { type: 'move', piclet: 'opponent', moveIndex: 0 }; const initialHp = berserkerEngine.getState().opponentPiclet.currentHp; berserkerEngine.executeActions(playerAction, opponentAction); const finalHp = berserkerEngine.getState().opponentPiclet.currentHp; // HP should be unchanged due to miss expect(finalHp).toBe(initialHp); const log = berserkerEngine.getLog(); expect(log.some(msg => msg.includes('attack missed'))).toBe(true); // Restore original Math.random Math.random = originalRandom; }); }); describe('Action Priority', () => { it('should execute higher priority moves first', () => { // Create a custom high-priority move for testing const highPriorityMove = { ...BASIC_TACKLE, name: "Quick Attack", priority: 1 }; const customWolf = { ...STELLAR_WOLF, movepool: [highPriorityMove, BASIC_TACKLE, HEALING_LIGHT, POWER_UP] }; const priorityEngine = new BattleEngine(customWolf, TOXIC_CRAWLER); const playerAction: BattleAction = { type: 'move', piclet: 'player', moveIndex: 0 }; // Quick Attack (priority 1) const opponentAction: BattleAction = { type: 'move', piclet: 'opponent', moveIndex: 0 }; // Tackle (priority 0) priorityEngine.executeActions(playerAction, opponentAction); const log = priorityEngine.getLog(); const playerMoveIndex = log.findIndex(msg => msg.includes('used Quick Attack')); const opponentMoveIndex = log.findIndex(msg => msg.includes('used Tackle')); expect(playerMoveIndex).toBeLessThan(opponentMoveIndex); }); it('should use speed for same priority moves', () => { // Both using same priority moves, faster should go first const playerAction: BattleAction = { type: 'move', piclet: 'player', moveIndex: 0 }; // Tackle const opponentAction: BattleAction = { type: 'move', piclet: 'opponent', moveIndex: 0 }; // Tackle engine.executeActions(playerAction, opponentAction); const log = engine.getLog(); const stellarWolfIndex = log.findIndex(msg => msg.includes('Stellar Wolf used')); const toxicCrawlerIndex = log.findIndex(msg => msg.includes('Toxic Crawler used')); // Stellar Wolf has higher speed (70 vs 55), so should go first expect(stellarWolfIndex).toBeLessThan(toxicCrawlerIndex); }); }); });