|
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(); |
|
}); |
|
}); |
|
}); |