| import { describe, it, expect, beforeEach } from 'vitest'; |
| import { BattleEngine } from './BattleEngine'; |
| import type { PicletDefinition } from './types'; |
| import { PicletType, AttackType } from './types'; |
|
|
| describe('Switching System', () => { |
| let basicPiclet: PicletDefinition; |
| let reservePiclet: PicletDefinition; |
| let hazardSetter: PicletDefinition; |
| let switchTriggerPiclet: PicletDefinition; |
|
|
| beforeEach(() => { |
| |
| basicPiclet = { |
| name: "Basic Fighter", |
| description: "Standard test piclet", |
| tier: 'medium', |
| primaryType: PicletType.BEAST, |
| baseStats: { hp: 80, attack: 60, defense: 60, speed: 60 }, |
| nature: "Hardy", |
| specialAbility: { name: "No Ability", description: "" }, |
| movepool: [ |
| { |
| name: "Basic Attack", |
| type: AttackType.BEAST, |
| power: 50, |
| accuracy: 100, |
| pp: 20, |
| priority: 0, |
| flags: ['contact'], |
| effects: [{ type: 'damage', target: 'opponent', amount: 'normal' }] |
| } |
| ] |
| }; |
|
|
| |
| reservePiclet = { |
| name: "Reserve Fighter", |
| description: "Backup piclet", |
| tier: 'medium', |
| primaryType: PicletType.FLORA, |
| baseStats: { hp: 90, attack: 50, defense: 70, speed: 40 }, |
| nature: "Calm", |
| specialAbility: { name: "No Ability", description: "" }, |
| movepool: [ |
| { |
| name: "Leaf Strike", |
| type: AttackType.FLORA, |
| power: 45, |
| accuracy: 100, |
| pp: 25, |
| priority: 0, |
| flags: [], |
| effects: [{ type: 'damage', target: 'opponent', amount: 'normal' }] |
| } |
| ] |
| }; |
|
|
| |
| hazardSetter = { |
| name: "Hazard Master", |
| description: "Sets entry hazards", |
| tier: 'medium', |
| primaryType: PicletType.MINERAL, |
| baseStats: { hp: 70, attack: 40, defense: 80, speed: 50 }, |
| nature: "Bold", |
| specialAbility: { name: "No Ability", description: "" }, |
| movepool: [ |
| { |
| name: "Spike Trap", |
| type: AttackType.MINERAL, |
| power: 0, |
| accuracy: 100, |
| pp: 20, |
| priority: 0, |
| flags: [], |
| effects: [ |
| { |
| type: 'fieldEffect', |
| effect: 'spikes', |
| target: 'opponentSide', |
| stackable: true |
| } |
| ] |
| }, |
| { |
| name: "Toxic Spikes", |
| type: AttackType.MINERAL, |
| power: 0, |
| accuracy: 100, |
| pp: 20, |
| priority: 0, |
| flags: [], |
| effects: [ |
| { |
| type: 'fieldEffect', |
| effect: 'toxicSpikes', |
| target: 'opponentSide', |
| stackable: true |
| } |
| ] |
| } |
| ] |
| }; |
|
|
| |
| switchTriggerPiclet = { |
| name: "Switch Specialist", |
| description: "Has switch-in/out abilities", |
| tier: 'medium', |
| primaryType: PicletType.CULTURE, |
| baseStats: { hp: 85, attack: 55, defense: 65, speed: 75 }, |
| nature: "Timid", |
| specialAbility: { |
| name: "Intimidate", |
| description: "Lowers opponent's attack on switch-in", |
| triggers: [ |
| { |
| event: 'onSwitchIn', |
| condition: 'always', |
| effects: [ |
| { |
| type: 'modifyStats', |
| target: 'opponent', |
| stats: { |
| attack: 'decrease' |
| } |
| } |
| ] |
| } |
| ] |
| }, |
| movepool: [ |
| { |
| name: "Quick Strike", |
| type: AttackType.CULTURE, |
| power: 40, |
| accuracy: 100, |
| pp: 30, |
| priority: 1, |
| flags: [], |
| effects: [{ type: 'damage', target: 'opponent', amount: 'normal' }] |
| } |
| ] |
| }; |
| }); |
|
|
| describe('Basic Switch Actions', () => { |
| it('should allow switching to a different piclet', () => { |
| |
| const engine = new BattleEngine([basicPiclet, reservePiclet], [basicPiclet]); |
|
|
| const initialPlayerName = engine.getState().playerPiclet.definition.name; |
| expect(initialPlayerName).toBe("Basic Fighter"); |
|
|
| |
| engine.executeActions( |
| { type: 'switch', piclet: 'player', newPicletIndex: 1 }, |
| { type: 'move', piclet: 'opponent', moveIndex: 0 } |
| ); |
|
|
| const finalPlayerName = engine.getState().playerPiclet.definition.name; |
| const log = engine.getLog(); |
|
|
| expect(finalPlayerName).toBe("Reserve Fighter"); |
| expect(log.some(msg => msg.includes('switched') && msg.includes('Reserve Fighter'))).toBe(true); |
| }); |
|
|
| it('should handle switch action priority correctly', () => { |
| const engine = new BattleEngine([basicPiclet, reservePiclet], [basicPiclet]); |
|
|
| |
| engine.executeActions( |
| { type: 'switch', piclet: 'player', newPicletIndex: 1 }, |
| { type: 'move', piclet: 'opponent', moveIndex: 0 } |
| ); |
|
|
| const log = engine.getLog(); |
| |
| |
| const switchIndex = log.findIndex(msg => msg.includes('switched')); |
| const moveIndex = log.findIndex(msg => msg.includes('used') && msg.includes('Basic Attack')); |
| |
| expect(switchIndex).toBeLessThan(moveIndex); |
| }); |
|
|
| it('should not allow switching to same piclet', () => { |
| const engine = new BattleEngine([basicPiclet, reservePiclet], [basicPiclet]); |
|
|
| |
| engine.executeActions( |
| { type: 'switch', piclet: 'player', newPicletIndex: 0 }, |
| { type: 'move', piclet: 'opponent', moveIndex: 0 } |
| ); |
|
|
| const log = engine.getLog(); |
| expect(log.some(msg => msg.includes('already active') || msg.includes('cannot switch'))).toBe(true); |
| }); |
|
|
| it('should not allow switching to fainted piclet', () => { |
| const faintedPiclet = { ...reservePiclet }; |
| const engine = new BattleEngine([basicPiclet, faintedPiclet], [basicPiclet]); |
|
|
| |
| (engine as any).playerRosterStates[1].fainted = true; |
| (engine as any).playerRosterStates[1].currentHp = 0; |
|
|
| engine.executeActions( |
| { type: 'switch', piclet: 'player', newPicletIndex: 1 }, |
| { type: 'move', piclet: 'opponent', moveIndex: 0 } |
| ); |
|
|
| const log = engine.getLog(); |
| expect(log.some(msg => msg.includes('fainted') || msg.includes('unable to battle'))).toBe(true); |
| }); |
| }); |
|
|
| describe('Entry Hazards', () => { |
| it('should apply spikes damage on switch-in', () => { |
| const engine = new BattleEngine([hazardSetter, basicPiclet], [basicPiclet, reservePiclet]); |
|
|
| |
| engine.executeActions( |
| { type: 'move', piclet: 'player', moveIndex: 0 }, |
| { type: 'move', piclet: 'opponent', moveIndex: 0 } |
| ); |
|
|
| const log = engine.getLog(); |
| expect(log.some(msg => msg.includes('spikes') || msg.includes('hazard'))).toBe(true); |
|
|
| |
| const initialHp = engine.getState().opponentPiclet.currentHp; |
| |
| engine.executeActions( |
| { type: 'move', piclet: 'player', moveIndex: 0 }, |
| { type: 'switch', piclet: 'opponent', newPicletIndex: 1 } |
| ); |
|
|
| const finalLog = engine.getLog(); |
| expect(finalLog.some(msg => msg.includes('hurt by spikes') || msg.includes('stepped on spikes'))).toBe(true); |
| }); |
|
|
| it('should apply toxic spikes status on switch-in', () => { |
| const engine = new BattleEngine([hazardSetter], [basicPiclet, reservePiclet]); |
|
|
| |
| engine.executeActions( |
| { type: 'move', piclet: 'player', moveIndex: 1 }, |
| { type: 'move', piclet: 'opponent', moveIndex: 0 } |
| ); |
|
|
| |
| engine.executeActions( |
| { type: 'move', piclet: 'player', moveIndex: 0 }, |
| { type: 'switch', piclet: 'opponent', newPicletIndex: 1 } |
| ); |
|
|
| const finalState = engine.getState().opponentPiclet; |
| const log = engine.getLog(); |
|
|
| expect(finalState.statusEffects).toContain('poison'); |
| expect(log.some(msg => msg.includes('poisoned by toxic spikes'))).toBe(true); |
| }); |
|
|
| it('should stack multiple layers of spikes', () => { |
| const engine = new BattleEngine([hazardSetter], [basicPiclet, reservePiclet]); |
|
|
| |
| engine.executeActions( |
| { type: 'move', piclet: 'player', moveIndex: 0 }, |
| { type: 'move', piclet: 'opponent', moveIndex: 0 } |
| ); |
|
|
| engine.executeActions( |
| { type: 'move', piclet: 'player', moveIndex: 0 }, |
| { type: 'move', piclet: 'opponent', moveIndex: 0 } |
| ); |
|
|
| const fieldEffects = engine.getState().fieldEffects; |
| const spikeCount = fieldEffects.filter(effect => effect.name === 'entryHazardSpikes').length; |
| |
| expect(spikeCount).toBeGreaterThan(1); |
| }); |
| }); |
|
|
| describe('Switch-In/Out Ability Triggers', () => { |
| it('should trigger onSwitchIn ability when piclet enters battle', () => { |
| const engine = new BattleEngine([basicPiclet, switchTriggerPiclet], [basicPiclet]); |
|
|
| const initialOpponentAttack = engine.getState().opponentPiclet.attack; |
|
|
| |
| engine.executeActions( |
| { type: 'switch', piclet: 'player', newPicletIndex: 1 }, |
| { type: 'move', piclet: 'opponent', moveIndex: 0 } |
| ); |
|
|
| const finalOpponentAttack = engine.getState().opponentPiclet.attack; |
| const log = engine.getLog(); |
|
|
| expect(finalOpponentAttack).toBeLessThan(initialOpponentAttack); |
| expect(log.some(msg => msg.includes('Intimidate') && msg.includes('triggered'))).toBe(true); |
| }); |
|
|
| it('should trigger onSwitchOut ability when piclet leaves battle', () => { |
| const switchOutPiclet: PicletDefinition = { |
| ...switchTriggerPiclet, |
| specialAbility: { |
| name: "Parting Shot", |
| description: "Lowers opponent's stats on switch-out", |
| triggers: [ |
| { |
| event: 'onSwitchOut', |
| condition: 'always', |
| effects: [ |
| { |
| type: 'modifyStats', |
| target: 'opponent', |
| stats: { |
| attack: 'decrease', |
| defense: 'decrease' |
| } |
| } |
| ] |
| } |
| ] |
| } |
| }; |
|
|
| const engine = new BattleEngine([switchOutPiclet, reservePiclet], [basicPiclet]); |
|
|
| const initialOpponentAttack = engine.getState().opponentPiclet.attack; |
| const initialOpponentDefense = engine.getState().opponentPiclet.defense; |
|
|
| |
| engine.executeActions( |
| { type: 'switch', piclet: 'player', newPicletIndex: 1 }, |
| { type: 'move', piclet: 'opponent', moveIndex: 0 } |
| ); |
|
|
| const finalOpponentAttack = engine.getState().opponentPiclet.attack; |
| const finalOpponentDefense = engine.getState().opponentPiclet.defense; |
| const log = engine.getLog(); |
|
|
| expect(finalOpponentAttack).toBeLessThan(initialOpponentAttack); |
| expect(finalOpponentDefense).toBeLessThan(initialOpponentDefense); |
| expect(log.some(msg => msg.includes('Parting Shot') && msg.includes('triggered'))).toBe(true); |
| }); |
| }); |
|
|
| describe('Forced Switching', () => { |
| it('should handle forced switch when active piclet faints', () => { |
| const engine = new BattleEngine([basicPiclet, reservePiclet], [basicPiclet]); |
|
|
| |
| engine['state'].playerPiclet.currentHp = 1; |
|
|
| engine.executeActions( |
| { type: 'move', piclet: 'player', moveIndex: 0 }, |
| { type: 'move', piclet: 'opponent', moveIndex: 0 } |
| ); |
|
|
| const log = engine.getLog(); |
| |
| |
| expect(log.some(msg => |
| msg.includes('fainted') || |
| msg.includes('must choose') || |
| msg.includes('forced switch') |
| )).toBe(true); |
| }); |
|
|
| it('should end battle if no valid switches remain', () => { |
| const engine = new BattleEngine([basicPiclet], [basicPiclet]); |
|
|
| |
| engine['state'].playerPiclet.currentHp = 1; |
|
|
| engine.executeActions( |
| { type: 'move', piclet: 'player', moveIndex: 0 }, |
| { type: 'move', piclet: 'opponent', moveIndex: 0 } |
| ); |
|
|
| expect(engine.isGameOver()).toBe(true); |
| expect(engine.getState().winner).toBe('opponent'); |
| }); |
| }); |
|
|
| describe('Switch Action Integration', () => { |
| it('should preserve PP and status when switching back', () => { |
| const engine = new BattleEngine([basicPiclet, reservePiclet], [basicPiclet]); |
|
|
| |
| engine.executeActions( |
| { type: 'move', piclet: 'player', moveIndex: 0 }, |
| { type: 'move', piclet: 'opponent', moveIndex: 0 } |
| ); |
|
|
| const ppAfterMove = engine.getState().playerPiclet.moves[0].currentPP; |
|
|
| |
| engine.executeActions( |
| { type: 'switch', piclet: 'player', newPicletIndex: 1 }, |
| { type: 'move', piclet: 'opponent', moveIndex: 0 } |
| ); |
|
|
| engine.executeActions( |
| { type: 'switch', piclet: 'player', newPicletIndex: 0 }, |
| { type: 'move', piclet: 'opponent', moveIndex: 0 } |
| ); |
|
|
| const ppAfterReturn = engine.getState().playerPiclet.moves[0].currentPP; |
| |
| |
| expect(ppAfterReturn).toBe(ppAfterMove); |
| }); |
|
|
| it('should reset stat modifications when switching', () => { |
| const engine = new BattleEngine([basicPiclet, reservePiclet], [basicPiclet]); |
|
|
| |
| engine['state'].playerPiclet.attack += 20; |
| engine['state'].playerPiclet.statModifiers.attack = 1; |
|
|
| const boostedAttack = engine.getState().playerPiclet.attack; |
|
|
| |
| engine.executeActions( |
| { type: 'switch', piclet: 'player', newPicletIndex: 1 }, |
| { type: 'move', piclet: 'opponent', moveIndex: 0 } |
| ); |
|
|
| engine.executeActions( |
| { type: 'switch', piclet: 'player', newPicletIndex: 0 }, |
| { type: 'move', piclet: 'opponent', moveIndex: 0 } |
| ); |
|
|
| const finalAttack = engine.getState().playerPiclet.attack; |
| |
| |
| expect(finalAttack).toBeLessThan(boostedAttack); |
| expect(engine.getState().playerPiclet.statModifiers.attack).toBeFalsy(); |
| }); |
| }); |
| }); |