piclets / src /lib /battle-engine /BattleEngine.test.ts
Fraser's picture
better logs
ba9896a
raw
history blame
15.2 kB
/**
* 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);
});
});
});