/** * Multi-Piclet Battle Engine * Supports up to 4 Piclets on the field at once (2 per side) * Extends the single-Piclet battle system with party management and multi-targeting */ 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 { // Initialize active Piclets from parties const playerActive: Array = [null, null]; const opponentActive: Array = [null, null]; // Set up initial active Piclets 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 { // Same logic as original BattleEngine 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]; // Add move actions for each active Piclet 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 }); } }); } }); // Add switch actions for empty slots or when Piclets can be switched this.state.parties[side].forEach((partyMember, partyIndex) => { // Check if this party member is not currently active const isActive = activePiclets.some(active => active?.definition.name === partyMember.name ); if (!isActive) { // Can switch into any position that has a Piclet (replacement) or empty slot 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`); // Determine action order based on priority and speed const allActions = this.determineActionOrder(turnActions); // Execute actions in order for (const actionPriority of allActions) { if (this.state.phase === 'ended') break; this.executeAction(actionPriority.action, actionPriority.side, actionPriority.position); } // End of turn processing this.processTurnEnd(); // Check for battle end this.checkBattleEnd(); if (this.state.phase !== 'ended') { this.state.turn++; this.state.phase = 'selection'; } } private determineActionOrder(turnActions: TurnActions): ActionPriority[] { const allActionPriorities: ActionPriority[] = []; // Process player actions 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() }); }); // Process opponent actions 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() }); }); // Sort by priority (higher first), then speed (higher first), then 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; // Switches have highest priority 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}!`); // Consume PP moveData.currentPP--; // Check if move hits (simplified for now) if (!this.checkMoveHits(move, attacker)) { this.log(`${attacker.definition.name}'s attack missed!`); return; } // Process effects for each target 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; // Create battle instance of new Piclet const battlePiclet = this.createBattlePiclet(newPiclet, 50); // Switch out current Piclet (if any) if (currentPiclet) { this.log(`${currentPiclet.definition.name} switched out!`); // Trigger switch-out abilities here } // Switch in new Piclet this.state.activePiclets[side][position] = battlePiclet; this.log(`${battlePiclet.definition.name} switched in!`); // Trigger switch-in abilities here 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; // Check if move effects specify targets, default to opponent 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': // Target first available opponent (can be enhanced for player choice) 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': // Target ally (for double battles) 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': // Target all active Piclets 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': // Target random active Piclet 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': // Target Piclet with lowest HP percentage 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': // Target Piclet with highest HP percentage 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: // Fallback to first opponent 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 { // Process effect on each target for (const target of targets) { this.processEffect(effect, attacker, target, move); } } private processEffect(effect: BattleEffect, attacker: BattlePiclet, target: BattlePiclet, move: Move): void { // Check condition 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; // Add other effect types as needed default: this.log(`Effect ${effect.type} not implemented in multi-battle yet`); } } // Simplified effect processors (can be expanded) 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); // Simplified 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 { // Simplified stat modification 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 { // Simplified damage calculation 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 { // Process special ability triggers 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!`); // Process trigger effects } } } } private processTurnEnd(): void { // Process status effects for all active Piclets for (const side of ['player', 'opponent'] as BattleSide[]) { for (const piclet of this.state.activePiclets[side]) { if (piclet) { this.processStatusEffects(piclet); this.processTemporaryEffects(piclet); } } } // Process field effects this.processFieldEffects(); // Handle fainted Piclets 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!`); // Remove fainted Piclet from active slot this.state.activePiclets[side][position] = null; // Trigger faint abilities this.processAbilityTrigger(piclet, 'onKO'); // For now, we don't auto-switch reserves in this simplified implementation // In a full implementation, the player would choose a replacement } } } } 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 { // Count living active Piclets (not null and HP > 0) 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); // Check for healthy reserves 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; // Check victory conditions based on type 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[] { // Get party members that have never been used in battle // We need to track which party members have been on the field const usedPicletNames = new Set(); // Add currently active Piclets this.state.activePiclets[side].forEach(p => { if (p !== null) { usedPicletNames.add(p.definition.name); } }); // For a full implementation, we would also track previously active Piclets that fainted // For now, we estimate by checking the initial setup - if there are more party members // than active slots, the rest are reserves const activeSlots = this.state.activePiclets[side].length; const initialActiveCount = Math.min(this.state.parties[side].length, activeSlots); // Mark the first N party members as "used" (they were initially active) 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[] { // Strip battle prefixes from all log messages for display return this.state.log.map(message => this.stripBattlePrefixes(message)); } private stripBattlePrefixes(message: string): string { // Remove player- and enemy- prefixes from messages return message .replace(/player-/g, '') .replace(/enemy-/g, ''); } }