| |
| |
| |
| |
| |
|
|
| 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]; |
| } |
| } |