piclets / src /lib /battle-engine /MultiBattleEngine.ts
Fraser's picture
better battle
94e4b64
/**
* 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<BattlePiclet | null> = [null, null];
const opponentActive: Array<BattlePiclet | null> = [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<string>();
// 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, '');
}
}