|
|
|
|
|
|
|
|
|
|
|
|
|
import { |
|
MultiBattleState, |
|
MultiBattleAction, |
|
MultiMoveAction, |
|
MultiSwitchAction, |
|
TurnActions, |
|
PicletTarget, |
|
BattleSide, |
|
FieldPosition, |
|
MultiBattleConfig, |
|
ActionPriority, |
|
VictoryCondition, |
|
MultiEffectTarget |
|
} from './multi-piclet-types'; |
|
|
|
import { |
|
BattlePiclet, |
|
PicletDefinition, |
|
BattleEffect, |
|
Move, |
|
BaseStats, |
|
StatusEffect |
|
} from './types'; |
|
|
|
import { getEffectivenessMultiplier } from '../types/picletTypes'; |
|
|
|
export class MultiBattleEngine { |
|
private state: MultiBattleState; |
|
private victoryCondition: VictoryCondition; |
|
|
|
constructor(config: MultiBattleConfig, victoryCondition: VictoryCondition = { type: 'allFainted' }) { |
|
this.victoryCondition = victoryCondition; |
|
this.state = this.initializeBattle(config); |
|
this.log('Multi-Piclet battle started!'); |
|
this.logActivePiclets(); |
|
} |
|
|
|
private initializeBattle(config: MultiBattleConfig): MultiBattleState { |
|
|
|
const playerActive: Array<BattlePiclet | null> = [null, null]; |
|
const opponentActive: Array<BattlePiclet | null> = [null, null]; |
|
|
|
|
|
for (let i = 0; i < config.playerActiveCount && i < config.playerParty.length; i++) { |
|
playerActive[i] = this.createBattlePiclet(config.playerParty[i], 50); |
|
} |
|
|
|
for (let i = 0; i < config.opponentActiveCount && i < config.opponentParty.length; i++) { |
|
opponentActive[i] = this.createBattlePiclet(config.opponentParty[i], 50); |
|
} |
|
|
|
return { |
|
turn: 1, |
|
phase: 'selection', |
|
activePiclets: { |
|
player: playerActive, |
|
opponent: opponentActive |
|
}, |
|
parties: { |
|
player: config.playerParty, |
|
opponent: config.opponentParty |
|
}, |
|
fieldEffects: [], |
|
log: [], |
|
winner: undefined |
|
}; |
|
} |
|
|
|
private createBattlePiclet(definition: PicletDefinition, level: number): BattlePiclet { |
|
|
|
const statMultiplier = 1 + (level - 50) * 0.02; |
|
|
|
const hp = Math.floor(definition.baseStats.hp * statMultiplier); |
|
const attack = Math.floor(definition.baseStats.attack * statMultiplier); |
|
const defense = Math.floor(definition.baseStats.defense * statMultiplier); |
|
const speed = Math.floor(definition.baseStats.speed * statMultiplier); |
|
|
|
return { |
|
definition, |
|
currentHp: hp, |
|
maxHp: hp, |
|
level, |
|
attack, |
|
defense, |
|
speed, |
|
accuracy: 100, |
|
statusEffects: [], |
|
moves: definition.movepool.slice(0, 4).map(move => ({ |
|
move, |
|
currentPP: move.pp |
|
})), |
|
statModifiers: {}, |
|
temporaryEffects: [] |
|
}; |
|
} |
|
|
|
public getState(): MultiBattleState { |
|
return JSON.parse(JSON.stringify(this.state)); |
|
} |
|
|
|
public isGameOver(): boolean { |
|
return this.state.phase === 'ended'; |
|
} |
|
|
|
public getWinner(): 'player' | 'opponent' | 'draw' | undefined { |
|
return this.state.winner; |
|
} |
|
|
|
public getActivePiclets(): { player: BattlePiclet[], opponent: BattlePiclet[] } { |
|
return { |
|
player: this.state.activePiclets.player.filter(p => p !== null) as BattlePiclet[], |
|
opponent: this.state.activePiclets.opponent.filter(p => p !== null) as BattlePiclet[] |
|
}; |
|
} |
|
|
|
public getAvailableSwitches(side: BattleSide): Array<{ partyIndex: number, piclet: PicletDefinition }> { |
|
const available: Array<{ partyIndex: number, piclet: PicletDefinition }> = []; |
|
const activePicletNames = this.state.activePiclets[side] |
|
.filter(p => p !== null) |
|
.map(p => p!.definition.name); |
|
|
|
this.state.parties[side].forEach((partyMember, index) => { |
|
if (!activePicletNames.includes(partyMember.name)) { |
|
available.push({ partyIndex: index, piclet: partyMember }); |
|
} |
|
}); |
|
|
|
return available; |
|
} |
|
|
|
public getValidActions(side: BattleSide): MultiBattleAction[] { |
|
const actions: MultiBattleAction[] = []; |
|
const activePiclets = this.state.activePiclets[side]; |
|
|
|
|
|
activePiclets.forEach((piclet, position) => { |
|
if (piclet) { |
|
piclet.moves.forEach((moveData, moveIndex) => { |
|
if (moveData.currentPP > 0) { |
|
actions.push({ |
|
type: 'move', |
|
side, |
|
position: position as FieldPosition, |
|
moveIndex |
|
}); |
|
} |
|
}); |
|
} |
|
}); |
|
|
|
|
|
this.state.parties[side].forEach((partyMember, partyIndex) => { |
|
|
|
const isActive = activePiclets.some(active => |
|
active?.definition.name === partyMember.name |
|
); |
|
|
|
if (!isActive) { |
|
|
|
activePiclets.forEach((slot, position) => { |
|
actions.push({ |
|
type: 'switch', |
|
side, |
|
position: position as FieldPosition, |
|
partyIndex |
|
}); |
|
}); |
|
} |
|
}); |
|
|
|
return actions; |
|
} |
|
|
|
public executeTurn(turnActions: TurnActions): void { |
|
if (this.state.phase !== 'selection') { |
|
throw new Error('Cannot execute turn - battle is not in selection phase'); |
|
} |
|
|
|
this.state.phase = 'execution'; |
|
this.log(`Turn ${this.state.turn} - Executing actions`); |
|
|
|
|
|
const allActions = this.determineActionOrder(turnActions); |
|
|
|
|
|
for (const actionPriority of allActions) { |
|
if (this.state.phase === 'ended') break; |
|
this.executeAction(actionPriority.action, actionPriority.side, actionPriority.position); |
|
} |
|
|
|
|
|
this.processTurnEnd(); |
|
|
|
|
|
this.checkBattleEnd(); |
|
|
|
if (this.state.phase !== 'ended') { |
|
this.state.turn++; |
|
this.state.phase = 'selection'; |
|
} |
|
} |
|
|
|
private determineActionOrder(turnActions: TurnActions): ActionPriority[] { |
|
const allActionPriorities: ActionPriority[] = []; |
|
|
|
|
|
turnActions.player.forEach(action => { |
|
const priority = this.getActionPriority(action); |
|
const piclet = this.state.activePiclets.player[action.position]; |
|
allActionPriorities.push({ |
|
action, |
|
side: 'player', |
|
position: action.position, |
|
priority, |
|
speed: piclet?.speed || 0, |
|
randomTiebreaker: Math.random() |
|
}); |
|
}); |
|
|
|
|
|
turnActions.opponent.forEach(action => { |
|
const priority = this.getActionPriority(action); |
|
const piclet = this.state.activePiclets.opponent[action.position]; |
|
allActionPriorities.push({ |
|
action, |
|
side: 'opponent', |
|
position: action.position, |
|
priority, |
|
speed: piclet?.speed || 0, |
|
randomTiebreaker: Math.random() |
|
}); |
|
}); |
|
|
|
|
|
return allActionPriorities.sort((a, b) => { |
|
if (a.priority !== b.priority) return b.priority - a.priority; |
|
if (a.speed !== b.speed) return b.speed - a.speed; |
|
return a.randomTiebreaker - b.randomTiebreaker; |
|
}); |
|
} |
|
|
|
private getActionPriority(action: MultiBattleAction): number { |
|
if (action.type === 'switch') return 6; |
|
|
|
const piclet = this.state.activePiclets[action.side][action.position]; |
|
if (!piclet) return 0; |
|
|
|
const move = piclet.moves[action.moveIndex]?.move; |
|
return move?.priority || 0; |
|
} |
|
|
|
private executeAction(action: MultiBattleAction, side: BattleSide, position: FieldPosition): void { |
|
const piclet = this.state.activePiclets[side][position]; |
|
if (!piclet) return; |
|
|
|
if (action.type === 'move') { |
|
this.executeMove(action as MultiMoveAction, side, position); |
|
} else if (action.type === 'switch') { |
|
this.executeSwitch(action as MultiSwitchAction, side, position); |
|
} |
|
} |
|
|
|
private executeMove(action: MultiMoveAction, side: BattleSide, position: FieldPosition): void { |
|
const attacker = this.state.activePiclets[side][position]; |
|
if (!attacker) return; |
|
|
|
const moveData = attacker.moves[action.moveIndex]; |
|
if (!moveData || moveData.currentPP <= 0) { |
|
this.log(`${attacker.definition.name} has no PP left for that move!`); |
|
return; |
|
} |
|
|
|
const move = moveData.move; |
|
this.log(`${attacker.definition.name} used ${move.name}!`); |
|
|
|
|
|
moveData.currentPP--; |
|
|
|
|
|
if (!this.checkMoveHits(move, attacker)) { |
|
this.log(`${attacker.definition.name}'s attack missed!`); |
|
return; |
|
} |
|
|
|
|
|
const targets = this.resolveTargets(move, side, position, action.targets); |
|
|
|
for (const effect of move.effects) { |
|
this.processMultiEffect(effect, attacker, targets, move); |
|
} |
|
} |
|
|
|
private executeSwitch(action: MultiSwitchAction, side: BattleSide, position: FieldPosition): void { |
|
const currentPiclet = this.state.activePiclets[side][position]; |
|
const newPiclet = this.state.parties[side][action.partyIndex]; |
|
|
|
if (!newPiclet) return; |
|
|
|
|
|
const battlePiclet = this.createBattlePiclet(newPiclet, 50); |
|
|
|
|
|
if (currentPiclet) { |
|
this.log(`${currentPiclet.definition.name} switched out!`); |
|
|
|
} |
|
|
|
|
|
this.state.activePiclets[side][position] = battlePiclet; |
|
this.log(`${battlePiclet.definition.name} switched in!`); |
|
|
|
|
|
this.processAbilityTrigger(battlePiclet, 'onSwitchIn'); |
|
} |
|
|
|
private resolveTargets(move: Move, attackerSide: BattleSide, attackerPosition: FieldPosition, targetOverride?: any): BattlePiclet[] { |
|
const targets: BattlePiclet[] = []; |
|
const attacker = this.state.activePiclets[attackerSide][attackerPosition]; |
|
if (!attacker) return targets; |
|
|
|
|
|
const effectTargets = move.effects.map(e => (e as any).target).filter(t => t); |
|
const primaryTarget = effectTargets[0] || 'opponent'; |
|
|
|
switch (primaryTarget) { |
|
case 'self': |
|
targets.push(attacker); |
|
break; |
|
|
|
case 'opponent': |
|
|
|
const opponentSide = attackerSide === 'player' ? 'opponent' : 'player'; |
|
const opponents = this.state.activePiclets[opponentSide].filter(p => p !== null) as BattlePiclet[]; |
|
if (opponents.length > 0) { |
|
targets.push(opponents[0]); |
|
} |
|
break; |
|
|
|
case 'allOpponents': |
|
const oppSide = attackerSide === 'player' ? 'opponent' : 'player'; |
|
const allOpponents = this.state.activePiclets[oppSide].filter(p => p !== null) as BattlePiclet[]; |
|
targets.push(...allOpponents); |
|
break; |
|
|
|
case 'ally': |
|
|
|
const allies = this.state.activePiclets[attackerSide].filter(p => p !== null && p !== attacker) as BattlePiclet[]; |
|
if (allies.length > 0) { |
|
targets.push(allies[0]); |
|
} |
|
break; |
|
|
|
case 'allAllies': |
|
const allAllies = this.state.activePiclets[attackerSide].filter(p => p !== null && p !== attacker) as BattlePiclet[]; |
|
targets.push(...allAllies); |
|
break; |
|
|
|
case 'all': |
|
|
|
for (const side of ['player', 'opponent'] as BattleSide[]) { |
|
const activePiclets = this.state.activePiclets[side].filter(p => p !== null) as BattlePiclet[]; |
|
targets.push(...activePiclets); |
|
} |
|
break; |
|
|
|
case 'random': |
|
|
|
const allActive: BattlePiclet[] = []; |
|
for (const side of ['player', 'opponent'] as BattleSide[]) { |
|
const activePiclets = this.state.activePiclets[side].filter(p => p !== null) as BattlePiclet[]; |
|
allActive.push(...activePiclets); |
|
} |
|
if (allActive.length > 0) { |
|
const randomIndex = Math.floor(Math.random() * allActive.length); |
|
targets.push(allActive[randomIndex]); |
|
} |
|
break; |
|
|
|
case 'weakest': |
|
|
|
const allActivePiclets: BattlePiclet[] = []; |
|
for (const side of ['player', 'opponent'] as BattleSide[]) { |
|
const activePiclets = this.state.activePiclets[side].filter(p => p !== null) as BattlePiclet[]; |
|
allActivePiclets.push(...activePiclets); |
|
} |
|
if (allActivePiclets.length > 0) { |
|
const weakest = allActivePiclets.reduce((weak, current) => |
|
(current.currentHp / current.maxHp) < (weak.currentHp / weak.maxHp) ? current : weak |
|
); |
|
targets.push(weakest); |
|
} |
|
break; |
|
|
|
case 'strongest': |
|
|
|
const allActiveForStrongest: BattlePiclet[] = []; |
|
for (const side of ['player', 'opponent'] as BattleSide[]) { |
|
const activePiclets = this.state.activePiclets[side].filter(p => p !== null) as BattlePiclet[]; |
|
allActiveForStrongest.push(...activePiclets); |
|
} |
|
if (allActiveForStrongest.length > 0) { |
|
const strongest = allActiveForStrongest.reduce((strong, current) => |
|
(current.currentHp / current.maxHp) > (strong.currentHp / strong.maxHp) ? current : strong |
|
); |
|
targets.push(strongest); |
|
} |
|
break; |
|
|
|
default: |
|
|
|
const defaultOpponentSide = attackerSide === 'player' ? 'opponent' : 'player'; |
|
const defaultOpponents = this.state.activePiclets[defaultOpponentSide].filter(p => p !== null) as BattlePiclet[]; |
|
if (defaultOpponents.length > 0) { |
|
targets.push(defaultOpponents[0]); |
|
} |
|
} |
|
|
|
return targets; |
|
} |
|
|
|
private processMultiEffect(effect: BattleEffect, attacker: BattlePiclet, targets: BattlePiclet[], move: Move): void { |
|
|
|
for (const target of targets) { |
|
this.processEffect(effect, attacker, target, move); |
|
} |
|
} |
|
|
|
private processEffect(effect: BattleEffect, attacker: BattlePiclet, target: BattlePiclet, move: Move): void { |
|
|
|
if (effect.condition && !this.checkCondition(effect.condition, attacker, target)) { |
|
return; |
|
} |
|
|
|
switch (effect.type) { |
|
case 'damage': |
|
this.processDamageEffect(effect, attacker, target, move); |
|
break; |
|
case 'heal': |
|
this.processHealEffect(effect, target); |
|
break; |
|
case 'modifyStats': |
|
this.processModifyStatsEffect(effect, target); |
|
break; |
|
case 'applyStatus': |
|
this.processApplyStatusEffect(effect, target); |
|
break; |
|
|
|
default: |
|
this.log(`Effect ${effect.type} not implemented in multi-battle yet`); |
|
} |
|
} |
|
|
|
|
|
private processDamageEffect(effect: any, attacker: BattlePiclet, target: BattlePiclet, move: Move): void { |
|
const damage = this.calculateDamage(attacker, target, move); |
|
target.currentHp = Math.max(0, target.currentHp - damage); |
|
this.log(`${target.definition.name} took ${damage} damage!`); |
|
} |
|
|
|
private processHealEffect(effect: any, target: BattlePiclet): void { |
|
const healAmount = Math.floor(target.maxHp * 0.5); |
|
const oldHp = target.currentHp; |
|
target.currentHp = Math.min(target.maxHp, target.currentHp + healAmount); |
|
const actualHeal = target.currentHp - oldHp; |
|
|
|
if (actualHeal > 0) { |
|
this.log(`${target.definition.name} recovered ${actualHeal} HP!`); |
|
} |
|
} |
|
|
|
private processModifyStatsEffect(effect: any, target: BattlePiclet): void { |
|
|
|
if (effect.stats?.attack === 'increase') { |
|
target.attack = Math.floor(target.attack * 1.25); |
|
this.log(`${target.definition.name}'s attack rose!`); |
|
} |
|
} |
|
|
|
private processApplyStatusEffect(effect: any, target: BattlePiclet): void { |
|
if (!target.statusEffects.includes(effect.status)) { |
|
target.statusEffects.push(effect.status); |
|
this.log(`${target.definition.name} was ${effect.status}ed!`); |
|
} |
|
} |
|
|
|
private calculateDamage(attacker: BattlePiclet, target: BattlePiclet, move: Move): number { |
|
|
|
const baseDamage = move.power || 50; |
|
const effectiveness = getEffectivenessMultiplier( |
|
move.type, |
|
target.definition.primaryType, |
|
target.definition.secondaryType |
|
); |
|
|
|
let damage = Math.floor((baseDamage * (attacker.attack / target.defense) * 0.5) + 10); |
|
damage = Math.floor(damage * effectiveness); |
|
|
|
return Math.max(1, damage); |
|
} |
|
|
|
private checkMoveHits(move: Move, attacker: BattlePiclet): boolean { |
|
return Math.random() * 100 < move.accuracy; |
|
} |
|
|
|
private checkCondition(condition: string, attacker: BattlePiclet, target: BattlePiclet): boolean { |
|
switch (condition) { |
|
case 'always': |
|
return true; |
|
case 'ifLowHp': |
|
return attacker.currentHp / attacker.maxHp < 0.25; |
|
default: |
|
return true; |
|
} |
|
} |
|
|
|
private processAbilityTrigger(piclet: BattlePiclet, trigger: string): void { |
|
|
|
if (piclet.definition.specialAbility.triggers) { |
|
for (const abilityTrigger of piclet.definition.specialAbility.triggers) { |
|
if (abilityTrigger.event === trigger) { |
|
this.log(`${piclet.definition.name}'s ${piclet.definition.specialAbility.name} activated!`); |
|
|
|
} |
|
} |
|
} |
|
} |
|
|
|
private processTurnEnd(): void { |
|
|
|
for (const side of ['player', 'opponent'] as BattleSide[]) { |
|
for (const piclet of this.state.activePiclets[side]) { |
|
if (piclet) { |
|
this.processStatusEffects(piclet); |
|
this.processTemporaryEffects(piclet); |
|
} |
|
} |
|
} |
|
|
|
|
|
this.processFieldEffects(); |
|
|
|
|
|
this.handleFaintedPiclets(); |
|
} |
|
|
|
private handleFaintedPiclets(): void { |
|
for (const side of ['player', 'opponent'] as BattleSide[]) { |
|
for (let position = 0; position < this.state.activePiclets[side].length; position++) { |
|
const piclet = this.state.activePiclets[side][position]; |
|
if (piclet && piclet.currentHp <= 0) { |
|
this.log(`${piclet.definition.name} fainted!`); |
|
|
|
|
|
this.state.activePiclets[side][position] = null; |
|
|
|
|
|
this.processAbilityTrigger(piclet, 'onKO'); |
|
|
|
|
|
|
|
} |
|
} |
|
} |
|
} |
|
|
|
private processStatusEffects(piclet: BattlePiclet): void { |
|
for (const status of piclet.statusEffects) { |
|
switch (status) { |
|
case 'burn': |
|
case 'poison': |
|
const damage = Math.floor(piclet.maxHp / 8); |
|
piclet.currentHp = Math.max(0, piclet.currentHp - damage); |
|
this.log(`${piclet.definition.name} hurt by ${status}!`); |
|
break; |
|
} |
|
} |
|
} |
|
|
|
private processTemporaryEffects(piclet: BattlePiclet): void { |
|
piclet.temporaryEffects = piclet.temporaryEffects.filter(effect => { |
|
effect.duration--; |
|
return effect.duration > 0; |
|
}); |
|
} |
|
|
|
private processFieldEffects(): void { |
|
this.state.fieldEffects = this.state.fieldEffects.filter(effect => { |
|
effect.duration--; |
|
if (effect.duration <= 0) { |
|
this.log(`Field effect '${effect.name}' ended!`); |
|
return false; |
|
} |
|
return true; |
|
}); |
|
} |
|
|
|
private checkBattleEnd(): void { |
|
const winner = this.determineWinner(); |
|
if (winner) { |
|
this.state.winner = winner; |
|
this.state.phase = 'ended'; |
|
this.log(`Battle ended! Winner: ${winner}`); |
|
} |
|
} |
|
|
|
private determineWinner(): 'player' | 'opponent' | 'draw' | null { |
|
|
|
const playerActiveLiving = this.state.activePiclets.player.filter(p => p !== null && p.currentHp > 0); |
|
const opponentActiveLiving = this.state.activePiclets.opponent.filter(p => p !== null && p.currentHp > 0); |
|
|
|
|
|
const playerHealthyReserves = this.getHealthyReserves('player'); |
|
const opponentHealthyReserves = this.getHealthyReserves('opponent'); |
|
|
|
const playerHasUsablePiclets = playerActiveLiving.length > 0 || playerHealthyReserves.length > 0; |
|
const opponentHasUsablePiclets = opponentActiveLiving.length > 0 || opponentHealthyReserves.length > 0; |
|
|
|
|
|
switch (this.victoryCondition.type) { |
|
case 'allFainted': |
|
if (!playerHasUsablePiclets) { |
|
if (!opponentHasUsablePiclets) { |
|
return 'draw'; |
|
} |
|
return 'opponent'; |
|
} |
|
if (!opponentHasUsablePiclets) { |
|
return 'player'; |
|
} |
|
break; |
|
|
|
case 'custom': |
|
if (this.victoryCondition.customCheck) { |
|
return this.victoryCondition.customCheck(this.state); |
|
} |
|
break; |
|
} |
|
|
|
return null; |
|
} |
|
|
|
private getHealthyReserves(side: BattleSide): PicletDefinition[] { |
|
|
|
|
|
const usedPicletNames = new Set<string>(); |
|
|
|
|
|
this.state.activePiclets[side].forEach(p => { |
|
if (p !== null) { |
|
usedPicletNames.add(p.definition.name); |
|
} |
|
}); |
|
|
|
|
|
|
|
|
|
const activeSlots = this.state.activePiclets[side].length; |
|
const initialActiveCount = Math.min(this.state.parties[side].length, activeSlots); |
|
|
|
|
|
for (let i = 0; i < initialActiveCount; i++) { |
|
if (this.state.parties[side][i]) { |
|
usedPicletNames.add(this.state.parties[side][i].name); |
|
} |
|
} |
|
|
|
return this.state.parties[side].filter(partyMember => |
|
!usedPicletNames.has(partyMember.name) |
|
); |
|
} |
|
|
|
private logActivePiclets(): void { |
|
const playerActives = this.state.activePiclets.player.filter(p => p !== null) as BattlePiclet[]; |
|
const opponentActives = this.state.activePiclets.opponent.filter(p => p !== null) as BattlePiclet[]; |
|
|
|
this.log(`Player active: ${playerActives.map(p => p.definition.name).join(', ')}`); |
|
this.log(`Opponent active: ${opponentActives.map(p => p.definition.name).join(', ')}`); |
|
} |
|
|
|
private log(message: string): void { |
|
this.state.log.push(message); |
|
} |
|
|
|
public getLog(): string[] { |
|
|
|
return this.state.log.map(message => this.stripBattlePrefixes(message)); |
|
} |
|
|
|
private stripBattlePrefixes(message: string): string { |
|
|
|
return message |
|
.replace(/player-/g, '') |
|
.replace(/enemy-/g, ''); |
|
} |
|
} |