|
|
|
|
|
|
|
|
|
|
|
import type { |
|
BattleState, |
|
BattlePiclet, |
|
PicletDefinition, |
|
BattleAction, |
|
MoveAction, |
|
SwitchAction, |
|
BattleEffect, |
|
DamageAmount, |
|
StatModification, |
|
HealAmount, |
|
StatusEffect, |
|
BaseStats, |
|
Move, |
|
Trigger |
|
} from './types'; |
|
import { getEffectivenessMultiplier } from '../types/picletTypes'; |
|
|
|
export class BattleEngine { |
|
private state: BattleState; |
|
private playerRoster: PicletDefinition[]; |
|
private opponentRoster: PicletDefinition[]; |
|
private playerRosterStates: Array<{ currentHp: number; maxHp: number; fainted: boolean; moves: Array<{move: Move; currentPP: number}> }>; |
|
private opponentRosterStates: Array<{ currentHp: number; maxHp: number; fainted: boolean; moves: Array<{move: Move; currentPP: number}> }>; |
|
|
|
constructor( |
|
playerPiclet: PicletDefinition | PicletDefinition[], |
|
opponentPiclet: PicletDefinition | PicletDefinition[], |
|
playerLevel = 50, |
|
opponentLevel = 50 |
|
) { |
|
|
|
this.playerRoster = (Array.isArray(playerPiclet) ? playerPiclet : [playerPiclet]) |
|
.map(piclet => ({ ...piclet, name: `player-${piclet.name}` })); |
|
this.opponentRoster = (Array.isArray(opponentPiclet) ? opponentPiclet : [opponentPiclet]) |
|
.map(piclet => ({ ...piclet, name: `enemy-${piclet.name}` })); |
|
|
|
|
|
this.playerRosterStates = this.initializeRosterStates(this.playerRoster, playerLevel); |
|
this.opponentRosterStates = this.initializeRosterStates(this.opponentRoster, opponentLevel); |
|
this.state = { |
|
turn: 1, |
|
phase: 'selection', |
|
playerPiclet: this.createBattlePiclet(this.playerRoster[0], playerLevel), |
|
opponentPiclet: this.createBattlePiclet(this.opponentRoster[0], opponentLevel), |
|
fieldEffects: [], |
|
log: [], |
|
winner: undefined |
|
}; |
|
|
|
|
|
this.syncActivePicketToRoster('player'); |
|
this.syncActivePicketToRoster('opponent'); |
|
|
|
} |
|
|
|
private initializeRosterStates(roster: PicletDefinition[], level: number): Array<{ currentHp: number; maxHp: number; fainted: boolean; moves: Array<{move: Move; currentPP: number}> }> { |
|
return roster.map(piclet => { |
|
|
|
const hp = piclet.baseStats.hp; |
|
return { |
|
currentHp: hp, |
|
maxHp: hp, |
|
fainted: false, |
|
moves: piclet.movepool.slice(0, 4).map(move => ({ |
|
move, |
|
currentPP: move.pp |
|
})) |
|
}; |
|
}); |
|
} |
|
|
|
private createBattlePiclet(definition: PicletDefinition, level: number): BattlePiclet { |
|
|
|
|
|
const hp = definition.baseStats.hp; |
|
const attack = definition.baseStats.attack; |
|
const defense = definition.baseStats.defense; |
|
const speed = definition.baseStats.speed; |
|
|
|
const piclet: BattlePiclet = { |
|
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: [] |
|
}; |
|
|
|
|
|
if (definition.specialAbility?.effects) { |
|
for (const effect of definition.specialAbility.effects) { |
|
this.applyEffectToPiclet(effect, piclet); |
|
} |
|
} |
|
|
|
return piclet; |
|
} |
|
|
|
public getState(): BattleState { |
|
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 executeActions(playerAction: BattleAction, opponentAction: BattleAction): void { |
|
if (this.state.phase !== 'selection') { |
|
throw new Error('Cannot execute actions - battle is not in selection phase'); |
|
} |
|
|
|
this.state.phase = 'execution'; |
|
|
|
|
|
const actions = this.determineActionOrder(playerAction, opponentAction); |
|
|
|
|
|
for (const action of actions) { |
|
if ((this.state.phase as string) === 'ended') break; |
|
this.executeAction(action); |
|
|
|
|
|
this.checkBattleEnd(); |
|
if ((this.state.phase as string) === 'ended') break; |
|
} |
|
|
|
|
|
if ((this.state.phase as string) !== 'ended') { |
|
this.processTurnEnd(); |
|
} |
|
|
|
|
|
if ((this.state.phase as string) !== 'ended') { |
|
this.syncActivePicketToRoster('player'); |
|
this.syncActivePicketToRoster('opponent'); |
|
} |
|
|
|
|
|
this.checkBattleEnd(); |
|
|
|
if ((this.state.phase as string) !== 'ended') { |
|
this.state.turn++; |
|
this.state.phase = 'selection'; |
|
} |
|
} |
|
|
|
private determineActionOrder(playerAction: BattleAction, opponentAction: BattleAction): Array<BattleAction & { executor: 'player' | 'opponent' }> { |
|
const playerPriority = this.getActionPriority(playerAction, this.state.playerPiclet); |
|
const opponentPriority = this.getActionPriority(opponentAction, this.state.opponentPiclet); |
|
|
|
const playerSpeed = this.state.playerPiclet.speed; |
|
const opponentSpeed = this.state.opponentPiclet.speed; |
|
|
|
|
|
let playerFirst = false; |
|
if (playerPriority > opponentPriority) { |
|
playerFirst = true; |
|
} else if (playerPriority < opponentPriority) { |
|
playerFirst = false; |
|
} else if (playerSpeed > opponentSpeed) { |
|
playerFirst = true; |
|
} else if (playerSpeed < opponentSpeed) { |
|
playerFirst = false; |
|
} else { |
|
playerFirst = Math.random() < 0.5; |
|
} |
|
|
|
return playerFirst |
|
? [ |
|
{ ...playerAction, executor: 'player' as const }, |
|
{ ...opponentAction, executor: 'opponent' as const } |
|
] |
|
: [ |
|
{ ...opponentAction, executor: 'opponent' as const }, |
|
{ ...playerAction, executor: 'player' as const } |
|
]; |
|
} |
|
|
|
private getActionPriority(action: BattleAction, piclet: BattlePiclet): number { |
|
let priority = 0; |
|
|
|
if (action.type === 'move') { |
|
const move = piclet.moves[action.moveIndex]?.move; |
|
priority = move?.priority || 0; |
|
|
|
|
|
if (move?.effects) { |
|
for (const effect of move.effects) { |
|
if (effect.type === 'priority' && (!effect.condition || this.checkCondition(effect.condition, piclet, piclet))) { |
|
priority += (effect as any).value || 0; |
|
} |
|
} |
|
} |
|
|
|
|
|
const priorityMod = piclet.statModifiers.priority || 0; |
|
priority += priorityMod; |
|
} else { |
|
priority = 6; |
|
} |
|
|
|
return priority; |
|
} |
|
|
|
private executeAction(action: BattleAction & { executor: 'player' | 'opponent' }): void { |
|
if (action.type === 'move') { |
|
this.executeMove(action); |
|
} else if (action.type === 'switch') { |
|
this.executeSwitch(action as SwitchAction & { executor: 'player' | 'opponent' }); |
|
} |
|
} |
|
|
|
private executeMove(action: MoveAction & { executor: 'player' | 'opponent' }): void { |
|
const attacker = action.executor === 'player' ? this.state.playerPiclet : this.state.opponentPiclet; |
|
const defender = action.executor === 'player' ? this.state.opponentPiclet : this.state.playerPiclet; |
|
|
|
|
|
if (!this.canPicletAct(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.triggerBeforeMoveUse(attacker, move); |
|
|
|
this.log(`${attacker.definition.name} used ${move.name}!`); |
|
|
|
|
|
moveData.currentPP--; |
|
|
|
|
|
const moveHit = this.checkMoveHits(move, attacker, defender); |
|
if (!moveHit) { |
|
this.log(`${attacker.definition.name}'s attack missed!`); |
|
this.triggerAfterMoveUse(attacker, move, false); |
|
return; |
|
} |
|
|
|
|
|
this.triggerOnOpponentContactMove(defender, attacker, move); |
|
|
|
|
|
const luckyRoll = Math.random() < 0.5; |
|
|
|
|
|
for (const effect of move.effects) { |
|
this.processEffect(effect, attacker, defender, move, luckyRoll); |
|
} |
|
|
|
|
|
this.triggerAfterMoveUse(attacker, move, true); |
|
} |
|
|
|
private executeSwitch(action: SwitchAction & { executor: 'player' | 'opponent' }): void { |
|
const isPlayer = action.executor === 'player'; |
|
const roster = isPlayer ? this.playerRoster : this.opponentRoster; |
|
const rosterStates = isPlayer ? this.playerRosterStates : this.opponentRosterStates; |
|
const currentPiclet = isPlayer ? this.state.playerPiclet : this.state.opponentPiclet; |
|
|
|
|
|
if (action.newPicletIndex < 0 || action.newPicletIndex >= roster.length) { |
|
this.log(`${action.executor} cannot switch - invalid piclet index!`); |
|
return; |
|
} |
|
|
|
if (action.newPicletIndex === this.getCurrentPicletIndex(action.executor)) { |
|
this.log(`${roster[action.newPicletIndex].name} is already active!`); |
|
return; |
|
} |
|
|
|
if (rosterStates[action.newPicletIndex].fainted) { |
|
this.log(`${roster[action.newPicletIndex].name} is unable to battle!`); |
|
return; |
|
} |
|
|
|
const oldPiclet = currentPiclet; |
|
const newPicletDef = roster[action.newPicletIndex]; |
|
|
|
|
|
this.triggerOnSwitchOut(oldPiclet); |
|
|
|
|
|
this.savePicletToRoster(oldPiclet, action.executor); |
|
|
|
|
|
const newPiclet = this.loadPicletFromRoster(action.newPicletIndex, action.executor); |
|
|
|
|
|
if (isPlayer) { |
|
this.state.playerPiclet = newPiclet; |
|
} else { |
|
this.state.opponentPiclet = newPiclet; |
|
} |
|
|
|
this.log(`${action.executor} switched to ${newPicletDef.name}!`); |
|
|
|
|
|
this.applyEntryHazards(newPiclet); |
|
|
|
|
|
this.triggerOnSwitchIn(newPiclet); |
|
} |
|
|
|
private getCurrentPicletIndex(executor: 'player' | 'opponent'): number { |
|
const isPlayer = executor === 'player'; |
|
const roster = isPlayer ? this.playerRoster : this.opponentRoster; |
|
const currentPiclet = isPlayer ? this.state.playerPiclet : this.state.opponentPiclet; |
|
|
|
return roster.findIndex(piclet => piclet.name === currentPiclet.definition.name); |
|
} |
|
|
|
private savePicletToRoster(piclet: BattlePiclet, executor: 'player' | 'opponent'): void { |
|
const isPlayer = executor === 'player'; |
|
const rosterStates = isPlayer ? this.playerRosterStates : this.opponentRosterStates; |
|
const currentIndex = this.getCurrentPicletIndex(executor); |
|
|
|
if (currentIndex !== -1) { |
|
|
|
rosterStates[currentIndex].currentHp = piclet.currentHp; |
|
rosterStates[currentIndex].fainted = piclet.currentHp <= 0; |
|
|
|
|
|
for (let i = 0; i < piclet.moves.length; i++) { |
|
if (rosterStates[currentIndex].moves[i]) { |
|
rosterStates[currentIndex].moves[i].currentPP = piclet.moves[i].currentPP; |
|
} |
|
} |
|
} |
|
} |
|
|
|
private syncActivePicketToRoster(executor: 'player' | 'opponent'): void { |
|
const piclet = executor === 'player' ? this.state.playerPiclet : this.state.opponentPiclet; |
|
this.savePicletToRoster(piclet, executor); |
|
} |
|
|
|
private loadPicletFromRoster(index: number, executor: 'player' | 'opponent'): BattlePiclet { |
|
const isPlayer = executor === 'player'; |
|
const roster = isPlayer ? this.playerRoster : this.opponentRoster; |
|
const rosterStates = isPlayer ? this.playerRosterStates : this.opponentRosterStates; |
|
const level = isPlayer ? this.state.playerPiclet.level : this.state.opponentPiclet.level; |
|
|
|
const definition = roster[index]; |
|
const savedState = rosterStates[index]; |
|
|
|
|
|
const piclet = this.createBattlePiclet(definition, level); |
|
|
|
|
|
piclet.currentHp = savedState.currentHp; |
|
|
|
|
|
for (let i = 0; i < piclet.moves.length; i++) { |
|
if (savedState.moves[i]) { |
|
piclet.moves[i].currentPP = savedState.moves[i].currentPP; |
|
} |
|
} |
|
|
|
|
|
piclet.statModifiers = {}; |
|
|
|
return piclet; |
|
} |
|
|
|
private triggerOnSwitchIn(piclet: BattlePiclet): void { |
|
this.triggerAbilities('onSwitchIn', piclet); |
|
} |
|
|
|
private triggerOnSwitchOut(piclet: BattlePiclet): void { |
|
this.triggerAbilities('onSwitchOut', piclet); |
|
} |
|
|
|
private checkMoveHits(move: Move, _attacker: BattlePiclet, _defender: BattlePiclet): boolean { |
|
|
|
const accuracy = move.accuracy; |
|
const roll = Math.random() * 100; |
|
return roll < accuracy; |
|
} |
|
|
|
private processEffect(effect: BattleEffect, attacker: BattlePiclet, defender: BattlePiclet, move: Move, luckyRoll?: boolean): void { |
|
|
|
if (effect.condition && !this.checkCondition(effect.condition, attacker, defender, luckyRoll)) { |
|
return; |
|
} |
|
|
|
switch (effect.type) { |
|
case 'damage': |
|
if (effect.target === 'all') { |
|
|
|
this.processDamageEffect(effect, attacker, attacker, move); |
|
this.processDamageEffect(effect, attacker, defender, move); |
|
} else { |
|
const damageTarget = this.resolveTarget(effect.target, attacker, defender); |
|
if (damageTarget) this.processDamageEffect(effect, attacker, damageTarget, move); |
|
} |
|
break; |
|
case 'modifyStats': |
|
const statsTarget = this.resolveTarget(effect.target, attacker, defender); |
|
if (statsTarget) this.processModifyStatsEffect(effect, statsTarget); |
|
break; |
|
case 'applyStatus': |
|
const statusTarget = this.resolveTarget(effect.target, attacker, defender); |
|
if (statusTarget) this.processApplyStatusEffect(effect, statusTarget); |
|
break; |
|
case 'heal': |
|
const healTarget = this.resolveTarget(effect.target, attacker, defender); |
|
if (healTarget) this.processHealEffect(effect, healTarget); |
|
break; |
|
case 'manipulatePP': |
|
const ppTarget = this.resolveTarget(effect.target, attacker, defender); |
|
if (ppTarget) this.processManipulatePPEffect(effect, ppTarget); |
|
break; |
|
case 'fieldEffect': |
|
this.processFieldEffect(effect); |
|
break; |
|
case 'counter': |
|
this.processCounterEffect(effect, attacker, defender); |
|
break; |
|
case 'priority': |
|
const priorityTarget = this.resolveTarget(effect.target, attacker, defender); |
|
if (priorityTarget) this.processPriorityEffect(effect, priorityTarget); |
|
break; |
|
case 'removeStatus': |
|
const removeStatusTarget = this.resolveTarget(effect.target, attacker, defender); |
|
if (removeStatusTarget) this.processRemoveStatusEffect(effect, removeStatusTarget); |
|
break; |
|
case 'mechanicOverride': |
|
|
|
this.processMechanicOverrideEffect(effect, attacker); |
|
break; |
|
default: |
|
this.log(`Effect ${(effect as any).type} not implemented yet`); |
|
} |
|
} |
|
|
|
private checkCondition(condition: string, attacker: BattlePiclet, _defender: BattlePiclet, luckyRoll?: boolean): boolean { |
|
switch (condition) { |
|
case 'always': |
|
return true; |
|
case 'ifLowHp': |
|
return attacker.currentHp / attacker.maxHp < 0.25; |
|
case 'ifHighHp': |
|
return attacker.currentHp / attacker.maxHp > 0.75; |
|
case 'ifLucky50': |
|
return luckyRoll !== undefined ? luckyRoll : Math.random() < 0.5; |
|
case 'ifUnlucky50': |
|
return luckyRoll !== undefined ? !luckyRoll : Math.random() >= 0.5; |
|
case 'whileFrozen': |
|
return attacker.statusEffects.includes('freeze'); |
|
|
|
case 'ifMoveType:flora': |
|
case 'ifMoveType:space': |
|
case 'ifMoveType:beast': |
|
case 'ifMoveType:bug': |
|
case 'ifMoveType:aquatic': |
|
case 'ifMoveType:mineral': |
|
case 'ifMoveType:machina': |
|
case 'ifMoveType:structure': |
|
case 'ifMoveType:culture': |
|
case 'ifMoveType:cuisine': |
|
case 'ifMoveType:normal': |
|
|
|
return true; |
|
|
|
case 'ifStatus:burn': |
|
return attacker.statusEffects.includes('burn'); |
|
case 'ifStatus:freeze': |
|
return attacker.statusEffects.includes('freeze'); |
|
case 'ifStatus:paralyze': |
|
return attacker.statusEffects.includes('paralyze'); |
|
case 'ifStatus:poison': |
|
return attacker.statusEffects.includes('poison'); |
|
case 'ifStatus:sleep': |
|
return attacker.statusEffects.includes('sleep'); |
|
case 'ifStatus:confuse': |
|
return attacker.statusEffects.includes('confuse'); |
|
|
|
case 'ifWeather:storm': |
|
case 'ifWeather:rain': |
|
case 'ifWeather:sun': |
|
case 'ifWeather:snow': |
|
return false; |
|
|
|
case 'ifDamagedThisTurn': |
|
|
|
|
|
|
|
return attacker.currentHp < attacker.maxHp; |
|
case 'ifNotSuperEffective': |
|
|
|
return false; |
|
case 'ifStatusMove': |
|
|
|
return false; |
|
case 'afterUse': |
|
|
|
return true; |
|
default: |
|
return true; |
|
} |
|
} |
|
|
|
private resolveTarget(target: string, attacker: BattlePiclet, defender: BattlePiclet): BattlePiclet | null { |
|
switch (target) { |
|
case 'self': |
|
return attacker; |
|
case 'opponent': |
|
return defender; |
|
default: |
|
return null; |
|
} |
|
} |
|
|
|
private processDamageEffect(effect: { amount?: DamageAmount; formula?: string; value?: number; multiplier?: number }, attacker: BattlePiclet, target: BattlePiclet, move: Move): void { |
|
let damage = 0; |
|
|
|
|
|
if (this.checkTypeImmunity(target, move.type)) { |
|
this.log(`${target.definition.name} is immune to ${move.type} type moves!`); |
|
return; |
|
} |
|
|
|
|
|
if (this.checkFlagBasedTypeImmunity(target, move.flags)) { |
|
this.log(`${target.definition.name} had no effect!`); |
|
return; |
|
} |
|
|
|
|
|
const flagInteraction = this.checkFlagInteraction(target, move.flags); |
|
if (flagInteraction === 'immune') { |
|
this.log(`It had no effect on ${target.definition.name}!`); |
|
return; |
|
} |
|
|
|
|
|
if (effect.formula) { |
|
damage = this.calculateDamageByFormula(effect, attacker, target, move); |
|
} else if (effect.amount) { |
|
damage = this.calculateStandardDamage(effect.amount, attacker, target, move); |
|
} |
|
|
|
|
|
if (flagInteraction === 'weak') { |
|
damage = Math.floor(damage * 1.5); |
|
this.log("It's super effective!"); |
|
} else if (flagInteraction === 'resist') { |
|
damage = Math.floor(damage * 0.5); |
|
this.log("It's not very effective..."); |
|
} |
|
|
|
|
|
const damageMultiplier = this.getDamageMultiplier(attacker); |
|
damage = Math.floor(damage * damageMultiplier); |
|
|
|
|
|
const isPlayerAttacking = attacker === this.state.playerPiclet; |
|
const fieldEffectMultiplier = this.getFieldEffectDamageMultiplier(move, isPlayerAttacking); |
|
damage = Math.floor(damage * fieldEffectMultiplier); |
|
|
|
|
|
const critMod = this.checkCriticalHitModification(attacker, target); |
|
const isCriticalHit = critMod === 'always' || (critMod === 'normal' && Math.random() < 0.0625); |
|
if (isCriticalHit) { |
|
damage = Math.floor(damage * 1.5); |
|
this.log("A critical hit!"); |
|
|
|
this.triggerOnCriticalHit(attacker, target); |
|
} |
|
|
|
|
|
if (damage > 0) { |
|
target.currentHp = Math.max(0, target.currentHp - damage); |
|
this.log(`${target.definition.name} took ${damage} damage!`); |
|
|
|
|
|
this.wakeUpFromSleep(target); |
|
|
|
|
|
this.triggerOnDamageTaken(target, damage, move.flags.includes('contact')); |
|
this.triggerOnDamageDealt(attacker, damage, target); |
|
this.triggerOnLowHP(target); |
|
|
|
|
|
this.checkCounterEffects(target, attacker, move); |
|
} |
|
|
|
|
|
if (effect.formula === 'drain') { |
|
const healAmount = Math.floor(damage * (effect.value || 0.5)); |
|
attacker.currentHp = Math.min(attacker.maxHp, attacker.currentHp + healAmount); |
|
if (healAmount > 0) { |
|
this.log(`${attacker.definition.name} recovered ${healAmount} HP from draining!`); |
|
this.triggerOnHPDrained(attacker, target, healAmount); |
|
} |
|
} else if (effect.formula === 'recoil') { |
|
const recoilDamage = Math.floor(damage * (effect.value || 0.25)); |
|
attacker.currentHp = Math.max(0, attacker.currentHp - recoilDamage); |
|
if (recoilDamage > 0) { |
|
this.log(`${attacker.definition.name} took ${recoilDamage} recoil damage!`); |
|
} |
|
} |
|
} |
|
|
|
private calculateDamageByFormula(effect: { formula?: string; value?: number; multiplier?: number }, attacker: BattlePiclet, target: BattlePiclet, move: Move): number { |
|
switch (effect.formula) { |
|
case 'fixed': |
|
return effect.value || 0; |
|
|
|
case 'percentage': |
|
return Math.floor(target.maxHp * ((effect.value || 0) / 100)); |
|
|
|
case 'recoil': |
|
case 'drain': |
|
case 'standard': |
|
|
|
return this.calculateStandardDamageWithPower(move.power, attacker, target, move) * (effect.multiplier || 1); |
|
|
|
default: |
|
return 0; |
|
} |
|
} |
|
|
|
private calculateStandardDamageWithPower(power: number, attacker: BattlePiclet, target: BattlePiclet, move: Move): number { |
|
const baseDamage = power; |
|
|
|
|
|
const effectiveness = getEffectivenessMultiplier( |
|
move.type, |
|
target.definition.primaryType, |
|
target.definition.secondaryType |
|
); |
|
|
|
|
|
const stab = (move.type.toString() === attacker.definition.primaryType?.toString() || |
|
move.type.toString() === attacker.definition.secondaryType?.toString()) ? 1.5 : 1; |
|
|
|
|
|
const attackStat = attacker.attack; |
|
const defenseStat = target.defense; |
|
|
|
let damage = Math.floor((baseDamage * (attackStat / defenseStat) * 0.5) + 10); |
|
damage = Math.floor(damage * effectiveness * stab); |
|
|
|
|
|
damage = Math.floor(damage * (0.85 + Math.random() * 0.15)); |
|
|
|
|
|
if (effectiveness > 0 && damage < 1) { |
|
damage = 1; |
|
} |
|
|
|
|
|
if (effectiveness === 0) { |
|
this.log("It had no effect!"); |
|
} else if (effectiveness > 1) { |
|
this.log("It's super effective!"); |
|
} else if (effectiveness < 1) { |
|
this.log("It's not very effective..."); |
|
} |
|
|
|
return damage; |
|
} |
|
|
|
private calculateStandardDamage(amount: DamageAmount, attacker: BattlePiclet, target: BattlePiclet, move: Move): number { |
|
const baseDamage = this.getDamageAmount(amount); |
|
|
|
|
|
const effectiveness = getEffectivenessMultiplier( |
|
move.type, |
|
target.definition.primaryType, |
|
target.definition.secondaryType |
|
); |
|
|
|
|
|
const stab = (move.type.toString() === attacker.definition.primaryType?.toString() || |
|
move.type.toString() === attacker.definition.secondaryType?.toString()) ? 1.5 : 1; |
|
|
|
|
|
const attackStat = attacker.attack; |
|
const defenseStat = target.defense; |
|
|
|
let damage = Math.floor((baseDamage * (attackStat / defenseStat) * 0.5) + 10); |
|
damage = Math.floor(damage * effectiveness * stab); |
|
|
|
|
|
damage = Math.floor(damage * (0.85 + Math.random() * 0.15)); |
|
|
|
|
|
if (effectiveness > 0 && damage < 1) { |
|
damage = 1; |
|
} |
|
|
|
|
|
if (effectiveness === 0) { |
|
this.log("It had no effect!"); |
|
} else if (effectiveness > 1) { |
|
this.log("It's super effective!"); |
|
} else if (effectiveness < 1) { |
|
this.log("It's not very effective..."); |
|
} |
|
|
|
return damage; |
|
} |
|
|
|
private processModifyStatsEffect(effect: { stats: Partial<Record<keyof BaseStats | 'accuracy', StatModification>> }, target: BattlePiclet): void { |
|
for (const [stat, modification] of Object.entries(effect.stats)) { |
|
const multiplier = this.getStatModifier(modification); |
|
if (stat === 'accuracy') { |
|
target.accuracy = Math.floor(target.accuracy * multiplier); |
|
} else { |
|
const statKey = stat as keyof BaseStats; |
|
(target as any)[statKey] = Math.floor((target as any)[statKey] * multiplier); |
|
} |
|
|
|
const changeType = modification.includes('increase') ? 'increase' : 'decrease'; |
|
this.log(`${target.definition.name}'s ${stat} ${modification.includes('increase') ? 'rose' : 'fell'}!`); |
|
this.triggerOnStatChange(target, stat, changeType); |
|
} |
|
} |
|
|
|
|
|
private processHealEffect(effect: { amount?: HealAmount; formula?: string; value?: number }, target: BattlePiclet): void { |
|
let healAmount = 0; |
|
|
|
if (effect.formula) { |
|
switch (effect.formula) { |
|
case 'percentage': |
|
healAmount = Math.floor(target.maxHp * ((effect.value || 0) / 100)); |
|
break; |
|
case 'fixed': |
|
healAmount = effect.value || 0; |
|
break; |
|
default: |
|
healAmount = this.getHealAmount(effect.amount || 'medium', target.maxHp); |
|
} |
|
} else if (effect.amount) { |
|
healAmount = this.getHealAmount(effect.amount, target.maxHp); |
|
} |
|
|
|
|
|
if (this.shouldInvertHealing(target)) { |
|
|
|
const oldHp = target.currentHp; |
|
target.currentHp = Math.max(0, target.currentHp - healAmount); |
|
const actualDamage = oldHp - target.currentHp; |
|
|
|
if (actualDamage > 0) { |
|
this.log(`${target.definition.name} took ${actualDamage} damage from inverted healing!`); |
|
} |
|
} else { |
|
|
|
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!`); |
|
this.triggerOnFullHP(target); |
|
} |
|
} |
|
} |
|
|
|
private getDamageAmount(amount: DamageAmount): number { |
|
switch (amount) { |
|
case 'weak': return 40; |
|
case 'normal': return 70; |
|
case 'strong': return 100; |
|
case 'extreme': return 140; |
|
default: return 70; |
|
} |
|
} |
|
|
|
private getStatModifier(modification: StatModification): number { |
|
switch (modification) { |
|
case 'increase': return 1.25; |
|
case 'decrease': return 0.75; |
|
case 'greatly_increase': return 1.5; |
|
case 'greatly_decrease': return 0.5; |
|
default: return 1.0; |
|
} |
|
} |
|
|
|
private getHealAmount(amount: HealAmount, maxHp: number): number { |
|
switch (amount) { |
|
case 'small': return Math.floor(maxHp * 0.25); |
|
case 'medium': return Math.floor(maxHp * 0.5); |
|
case 'large': return Math.floor(maxHp * 0.75); |
|
case 'full': return maxHp; |
|
default: return Math.floor(maxHp * 0.5); |
|
} |
|
} |
|
|
|
private processTurnEnd(): void { |
|
|
|
this.processStatusEffects(this.state.playerPiclet); |
|
this.processStatusEffects(this.state.opponentPiclet); |
|
|
|
|
|
this.applyFieldHealingEffects(); |
|
|
|
|
|
this.processFieldEffects(); |
|
|
|
|
|
this.triggerEndOfTurn(); |
|
|
|
|
|
this.processTemporaryEffects(this.state.playerPiclet); |
|
this.processTemporaryEffects(this.state.opponentPiclet); |
|
} |
|
|
|
private processStatusEffects(piclet: BattlePiclet): void { |
|
|
|
const statusesToRemove: string[] = []; |
|
|
|
for (let i = 0; i < piclet.statusEffects.length; i++) { |
|
const status = piclet.statusEffects[i]; |
|
|
|
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} was hurt by ${status}!`); |
|
break; |
|
|
|
case 'freeze': |
|
|
|
if ((piclet as any).freezeJustApplied) { |
|
delete (piclet as any).freezeJustApplied; |
|
} else { |
|
|
|
if (Math.random() < 0.2) { |
|
statusesToRemove.push(status); |
|
this.log(`${piclet.definition.name} thawed out!`); |
|
} |
|
} |
|
break; |
|
|
|
case 'sleep': |
|
|
|
if ((piclet as any).sleepJustApplied) { |
|
delete (piclet as any).sleepJustApplied; |
|
} else { |
|
|
|
const sleepTurns = (piclet as any).sleepTurns || 0; |
|
if (sleepTurns <= 1) { |
|
statusesToRemove.push(status); |
|
this.log(`${piclet.definition.name} woke up!`); |
|
delete (piclet as any).sleepTurns; |
|
} else { |
|
(piclet as any).sleepTurns = sleepTurns - 1; |
|
} |
|
} |
|
break; |
|
|
|
case 'confuse': |
|
|
|
const confusionTurns = (piclet as any).confusionTurns || 0; |
|
if (confusionTurns <= 1) { |
|
statusesToRemove.push(status); |
|
this.log(`${piclet.definition.name} snapped out of confusion!`); |
|
delete (piclet as any).confusionTurns; |
|
} else { |
|
(piclet as any).confusionTurns = confusionTurns - 1; |
|
} |
|
break; |
|
} |
|
} |
|
|
|
|
|
for (const statusToRemove of statusesToRemove) { |
|
const index = piclet.statusEffects.indexOf(statusToRemove as any); |
|
if (index > -1) { |
|
piclet.statusEffects.splice(index, 1); |
|
} |
|
} |
|
} |
|
|
|
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(`${this.formatFieldEffectName(effect.name)} faded away!`); |
|
return false; |
|
} |
|
return true; |
|
}); |
|
} |
|
|
|
private formatFieldEffectName(effectName: string): string { |
|
switch (effectName) { |
|
case 'entryHazardSpikes': return 'Entry spikes'; |
|
case 'contactDamageReduction': return 'Contact damage barrier'; |
|
case 'nonContactDamageReduction': return 'Non-contact damage barrier'; |
|
case 'healingField': return 'Healing field'; |
|
case 'poisonousField': return 'Poisonous field'; |
|
default: return effectName; |
|
} |
|
} |
|
|
|
private getFieldEffectDamageMultiplier(move: Move, isPlayerAttacking: boolean): number { |
|
let multiplier = 1.0; |
|
|
|
|
|
const isContactMove = move.flags.includes('contact'); |
|
|
|
|
|
for (const fieldEffect of this.state.fieldEffects) { |
|
const targetSide = fieldEffect.effect.target; |
|
|
|
|
|
const protectsDefender = (!isPlayerAttacking && targetSide === 'playerSide') || |
|
(isPlayerAttacking && targetSide === 'opponentSide'); |
|
|
|
if (!protectsDefender) continue; |
|
|
|
switch (fieldEffect.name) { |
|
case 'contactDamageReduction': |
|
if (isContactMove) { |
|
multiplier *= 0.5; |
|
} |
|
break; |
|
case 'nonContactDamageReduction': |
|
if (!isContactMove) { |
|
multiplier *= 0.5; |
|
} |
|
break; |
|
} |
|
} |
|
|
|
return multiplier; |
|
} |
|
|
|
private applyEntryHazards(piclet: BattlePiclet): void { |
|
|
|
for (const fieldEffect of this.state.fieldEffects) { |
|
if (fieldEffect.name === 'spikes' || fieldEffect.name === 'entryHazardSpikes') { |
|
const targetSide = fieldEffect.effect.target; |
|
const isPlayerSide = piclet === this.state.playerPiclet; |
|
const appliesTo = (isPlayerSide && targetSide === 'playerSide') || |
|
(!isPlayerSide && targetSide === 'opponentSide'); |
|
|
|
if (appliesTo) { |
|
const damage = Math.floor(piclet.maxHp * 0.125); |
|
piclet.currentHp = Math.max(0, piclet.currentHp - damage); |
|
this.log(`${piclet.definition.name} was hurt by spikes!`); |
|
} |
|
} else if (fieldEffect.name === 'toxicSpikes') { |
|
const targetSide = fieldEffect.effect.target; |
|
const isPlayerSide = piclet === this.state.playerPiclet; |
|
const appliesTo = (isPlayerSide && targetSide === 'playerSide') || |
|
(!isPlayerSide && targetSide === 'opponentSide'); |
|
|
|
if (appliesTo && !piclet.statusEffects.includes('poison')) { |
|
piclet.statusEffects.push('poison'); |
|
this.log(`${piclet.definition.name} was poisoned by toxic spikes!`); |
|
} |
|
} else if (fieldEffect.name === 'poisonousField') { |
|
const targetSide = fieldEffect.effect.target; |
|
const isPlayerSide = piclet === this.state.playerPiclet; |
|
const appliesTo = (isPlayerSide && targetSide === 'playerSide') || |
|
(!isPlayerSide && targetSide === 'opponentSide'); |
|
|
|
if (appliesTo && !piclet.statusEffects.includes('poison')) { |
|
piclet.statusEffects.push('poison'); |
|
this.log(`${piclet.definition.name} was poisoned by toxic spikes!`); |
|
} |
|
} |
|
} |
|
} |
|
|
|
private applyFieldHealingEffects(): void { |
|
|
|
const healingFields = this.state.fieldEffects.filter(effect => effect.name === 'healingField'); |
|
|
|
for (const healingField of healingFields) { |
|
const targetSide = healingField.effect.target; |
|
|
|
if (targetSide === 'playerSide' || targetSide === 'field') { |
|
const healAmount = Math.floor(this.state.playerPiclet.maxHp * 0.0625); |
|
if (this.state.playerPiclet.currentHp < this.state.playerPiclet.maxHp) { |
|
this.state.playerPiclet.currentHp = Math.min( |
|
this.state.playerPiclet.maxHp, |
|
this.state.playerPiclet.currentHp + healAmount |
|
); |
|
this.log(`${this.state.playerPiclet.definition.name} was healed by the healing field!`); |
|
} |
|
} |
|
|
|
if (targetSide === 'opponentSide' || targetSide === 'field') { |
|
const healAmount = Math.floor(this.state.opponentPiclet.maxHp * 0.0625); |
|
if (this.state.opponentPiclet.currentHp < this.state.opponentPiclet.maxHp) { |
|
this.state.opponentPiclet.currentHp = Math.min( |
|
this.state.opponentPiclet.maxHp, |
|
this.state.opponentPiclet.currentHp + healAmount |
|
); |
|
this.log(`${this.state.opponentPiclet.definition.name} was healed by the healing field!`); |
|
} |
|
} |
|
} |
|
} |
|
|
|
|
|
private checkBattleEnd(): void { |
|
const playerFainted = this.state.playerPiclet.currentHp <= 0; |
|
const opponentFainted = this.state.opponentPiclet.currentHp <= 0; |
|
|
|
|
|
if (playerFainted) { |
|
const playerIndex = this.getCurrentPicletIndex('player'); |
|
if (playerIndex !== -1) { |
|
this.playerRosterStates[playerIndex].fainted = true; |
|
this.triggerOnKO(this.state.playerPiclet, this.state.opponentPiclet); |
|
} |
|
} |
|
|
|
if (opponentFainted) { |
|
const opponentIndex = this.getCurrentPicletIndex('opponent'); |
|
if (opponentIndex !== -1) { |
|
this.opponentRosterStates[opponentIndex].fainted = true; |
|
this.triggerOnKO(this.state.opponentPiclet, this.state.playerPiclet); |
|
} |
|
} |
|
|
|
|
|
const playerHasViablePiclets = this.playerRosterStates.some(state => !state.fainted); |
|
const opponentHasViablePiclets = this.opponentRosterStates.some(state => !state.fainted); |
|
|
|
if (!playerHasViablePiclets && !opponentHasViablePiclets) { |
|
this.state.winner = 'draw'; |
|
this.state.phase = 'ended'; |
|
this.log('Battle ended in a draw!'); |
|
} else if (!playerHasViablePiclets) { |
|
this.state.winner = 'opponent'; |
|
this.state.phase = 'ended'; |
|
|
|
} else if (!opponentHasViablePiclets) { |
|
this.state.winner = 'player'; |
|
this.state.phase = 'ended'; |
|
|
|
} else if (playerFainted || opponentFainted) { |
|
|
|
this.handleForcedSwitching(playerFainted, opponentFainted); |
|
} |
|
} |
|
|
|
private handleForcedSwitching(playerFainted: boolean, opponentFainted: boolean): void { |
|
if (playerFainted) { |
|
this.log(`${this.state.playerPiclet.definition.name} fainted!`); |
|
const viablePiclets = this.playerRosterStates.map((state, index) => ({ index, state })) |
|
.filter(entry => !entry.state.fainted); |
|
|
|
if (viablePiclets.length === 1) { |
|
|
|
const autoSwitchIndex = viablePiclets[0].index; |
|
this.log(`Player must choose a new piclet! Auto-switching to ${this.playerRoster[autoSwitchIndex].name}!`); |
|
this.executeSwitch({ type: 'switch', piclet: 'player', newPicletIndex: autoSwitchIndex, executor: 'player' }); |
|
} else { |
|
this.log(`Player must choose a new piclet from ${viablePiclets.length} remaining options!`); |
|
|
|
|
|
} |
|
} |
|
|
|
if (opponentFainted) { |
|
this.log(`${this.state.opponentPiclet.definition.name} fainted!`); |
|
const viablePiclets = this.opponentRosterStates.map((state, index) => ({ index, state })) |
|
.filter(entry => !entry.state.fainted); |
|
|
|
if (viablePiclets.length === 1) { |
|
|
|
const autoSwitchIndex = viablePiclets[0].index; |
|
this.log(`Opponent must choose a new piclet! Auto-switching to ${this.opponentRoster[autoSwitchIndex].name}!`); |
|
this.executeSwitch({ type: 'switch', piclet: 'opponent', newPicletIndex: autoSwitchIndex, executor: 'opponent' }); |
|
} else { |
|
this.log(`Opponent must choose a new piclet from ${viablePiclets.length} remaining options!`); |
|
|
|
} |
|
} |
|
} |
|
|
|
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, ''); |
|
} |
|
|
|
|
|
private processManipulatePPEffect(effect: { action: string; amount?: string; value?: number; targetMove?: string }, target: BattlePiclet): void { |
|
const ppChange = this.getPPAmount(effect.amount, effect.value || 5); |
|
|
|
switch (effect.action) { |
|
case 'drain': |
|
|
|
for (const moveSlot of target.moves) { |
|
if (moveSlot.currentPP > 0) { |
|
const drained = Math.min(moveSlot.currentPP, ppChange); |
|
moveSlot.currentPP -= drained; |
|
this.log(`${target.definition.name}'s PP was drained from ${moveSlot.move.name}!`); |
|
break; |
|
} |
|
} |
|
break; |
|
case 'restore': |
|
|
|
for (const moveSlot of target.moves) { |
|
if (moveSlot.currentPP < moveSlot.move.pp) { |
|
const restored = Math.min(moveSlot.move.pp - moveSlot.currentPP, ppChange); |
|
moveSlot.currentPP += restored; |
|
this.log(`${target.definition.name}'s PP was restored to ${moveSlot.move.name}!`); |
|
break; |
|
} |
|
} |
|
break; |
|
case 'disable': |
|
|
|
for (const moveSlot of target.moves) { |
|
if (moveSlot.currentPP > 0) { |
|
moveSlot.currentPP = 0; |
|
this.log(`${target.definition.name}'s ${moveSlot.move.name} was disabled!`); |
|
break; |
|
} |
|
} |
|
break; |
|
} |
|
} |
|
|
|
private getPPAmount(amount?: string, value?: number): number { |
|
if (value !== undefined) return value; |
|
switch (amount) { |
|
case 'small': return 3; |
|
case 'medium': return 5; |
|
case 'large': return 8; |
|
default: return 5; |
|
} |
|
} |
|
|
|
private processFieldEffect(effect: { effect: string; target: string; stackable?: boolean }): void { |
|
|
|
const effectNameMap: Record<string, string> = { |
|
'spikes': 'entryHazardSpikes', |
|
'reflect': 'contactDamageReduction', |
|
'lightScreen': 'nonContactDamageReduction', |
|
'healingMist': 'healingField', |
|
'toxicSpikes': 'poisonousField' |
|
}; |
|
|
|
const mappedName = effectNameMap[effect.effect] || effect.effect; |
|
|
|
|
|
const fieldEffect = { |
|
name: mappedName, |
|
duration: 5, |
|
effect: effect |
|
}; |
|
|
|
|
|
if (!effect.stackable) { |
|
this.state.fieldEffects = this.state.fieldEffects.filter(fe => fe.name !== mappedName); |
|
} |
|
|
|
this.state.fieldEffects.push(fieldEffect); |
|
|
|
|
|
switch (mappedName) { |
|
case 'entryHazardSpikes': |
|
this.log('Entry spikes were scattered on the battlefield!'); |
|
break; |
|
case 'contactDamageReduction': |
|
this.log('A barrier was raised to reduce contact move damage!'); |
|
break; |
|
case 'nonContactDamageReduction': |
|
this.log('A barrier was raised to reduce non-contact move damage!'); |
|
break; |
|
case 'healingField': |
|
this.log('A healing field was created!'); |
|
break; |
|
case 'poisonousField': |
|
this.log('A poisonous field was created!'); |
|
break; |
|
default: |
|
this.log(`${mappedName} was applied to the field!`); |
|
} |
|
} |
|
|
|
private processCounterEffect(effect: { strength: string }, attacker: BattlePiclet, _target: BattlePiclet): void { |
|
|
|
|
|
attacker.temporaryEffects.push({ |
|
effect: { |
|
type: 'counter', |
|
strength: effect.strength |
|
} as any, |
|
duration: 5 |
|
}); |
|
this.log(`${attacker.definition.name} is preparing to counter!`); |
|
} |
|
|
|
private processPriorityEffect(effect: { value: number; condition?: string }, target: BattlePiclet): void { |
|
|
|
target.statModifiers.priority = (target.statModifiers.priority || 0) + effect.value; |
|
this.log(`${target.definition.name}'s move priority changed by ${effect.value}!`); |
|
} |
|
|
|
private processRemoveStatusEffect(effect: { status: string }, target: BattlePiclet): void { |
|
if (target.statusEffects.includes(effect.status as any)) { |
|
target.statusEffects = target.statusEffects.filter(s => s !== effect.status); |
|
this.log(`${target.definition.name} was cured of ${effect.status}!`); |
|
} |
|
} |
|
|
|
private processMechanicOverrideEffect(effect: { mechanic: string; value: any; condition?: string }, target: BattlePiclet): void { |
|
|
|
target.temporaryEffects.push({ |
|
effect: { |
|
type: 'mechanicOverride', |
|
mechanic: effect.mechanic, |
|
value: effect.value, |
|
condition: effect.condition, |
|
target: 'self' |
|
} as any, |
|
duration: effect.condition === 'restOfBattle' ? 999 : 1 |
|
}); |
|
|
|
this.log(`Mechanic override '${effect.mechanic}' applied to ${target.definition.name}!`); |
|
} |
|
|
|
|
|
private hasMechanicOverride(piclet: BattlePiclet, mechanic: string): any { |
|
const override = piclet.temporaryEffects.find( |
|
effect => effect.effect.type === 'mechanicOverride' && |
|
(effect.effect as any).mechanic === mechanic |
|
); |
|
return override ? (override.effect as any).value : null; |
|
} |
|
|
|
private checkCriticalHitModification(attacker: BattlePiclet, target: BattlePiclet): 'always' | 'never' | 'normal' { |
|
|
|
const attackerOverride = this.hasMechanicOverride(attacker, 'criticalHits'); |
|
if (attackerOverride === true) return 'always'; |
|
|
|
|
|
const targetOverride = this.hasMechanicOverride(target, 'criticalHits'); |
|
if (targetOverride === false) return 'never'; |
|
|
|
return 'normal'; |
|
} |
|
|
|
private checkStatusImmunity(target: BattlePiclet, status: string): boolean { |
|
const immunity = this.hasMechanicOverride(target, 'statusImmunity'); |
|
if (Array.isArray(immunity)) { |
|
return immunity.includes(status); |
|
} |
|
return false; |
|
} |
|
|
|
private checkTypeImmunity(target: BattlePiclet, attackType: string): boolean { |
|
const immunity = this.hasMechanicOverride(target, 'typeImmunity'); |
|
if (Array.isArray(immunity)) { |
|
return immunity.includes(attackType); |
|
} |
|
return false; |
|
} |
|
|
|
private checkFlagBasedTypeImmunity(target: BattlePiclet, flags: string[]): boolean { |
|
const immunity = this.hasMechanicOverride(target, 'typeImmunity'); |
|
if (Array.isArray(immunity)) { |
|
|
|
return flags.some(flag => immunity.includes(flag)); |
|
} |
|
return false; |
|
} |
|
|
|
private checkFlagInteraction(target: BattlePiclet, flags: string[]): 'immune' | 'weak' | 'resist' | 'normal' { |
|
|
|
const immunity = this.hasMechanicOverride(target, 'flagImmunity'); |
|
if (Array.isArray(immunity) && flags.some(flag => immunity.includes(flag))) { |
|
return 'immune'; |
|
} |
|
|
|
|
|
const weakness = this.hasMechanicOverride(target, 'flagWeakness'); |
|
if (Array.isArray(weakness) && flags.some(flag => weakness.includes(flag))) { |
|
return 'weak'; |
|
} |
|
|
|
|
|
const resistance = this.hasMechanicOverride(target, 'flagResistance'); |
|
if (Array.isArray(resistance) && flags.some(flag => resistance.includes(flag))) { |
|
return 'resist'; |
|
} |
|
|
|
return 'normal'; |
|
} |
|
|
|
private getDamageMultiplier(piclet: BattlePiclet): number { |
|
const multiplier = this.hasMechanicOverride(piclet, 'damageMultiplier'); |
|
return typeof multiplier === 'number' ? multiplier : 1.0; |
|
} |
|
|
|
private shouldInvertHealing(target: BattlePiclet): boolean { |
|
return !!this.hasMechanicOverride(target, 'healingInversion'); |
|
} |
|
|
|
private applyEffectToPiclet(effect: BattleEffect, piclet: BattlePiclet): void { |
|
switch (effect.type) { |
|
case 'modifyStats': |
|
|
|
for (const [stat, modification] of Object.entries(effect.stats)) { |
|
const multiplier = this.getStatModifier(modification); |
|
if (stat === 'accuracy') { |
|
piclet.accuracy = Math.floor(piclet.accuracy * multiplier); |
|
} else { |
|
const statKey = stat as keyof BaseStats; |
|
(piclet as any)[statKey] = Math.floor((piclet as any)[statKey] * multiplier); |
|
} |
|
} |
|
break; |
|
case 'mechanicOverride': |
|
|
|
piclet.temporaryEffects.push({ |
|
effect: effect, |
|
duration: 999 |
|
}); |
|
break; |
|
|
|
} |
|
} |
|
|
|
private checkCounterEffects(target: BattlePiclet, attacker: BattlePiclet, move: Move): void { |
|
|
|
for (let i = target.temporaryEffects.length - 1; i >= 0; i--) { |
|
const tempEffect = target.temporaryEffects[i]; |
|
if (tempEffect.effect.type === 'counter') { |
|
const counterEffect = tempEffect.effect as any; |
|
const shouldCounter = true; |
|
|
|
if (shouldCounter) { |
|
|
|
let counterDamage = 0; |
|
switch (counterEffect.strength) { |
|
case 'weak': counterDamage = 20; break; |
|
case 'normal': counterDamage = 40; break; |
|
case 'strong': counterDamage = 60; break; |
|
default: counterDamage = 40; |
|
} |
|
|
|
attacker.currentHp = Math.max(0, attacker.currentHp - counterDamage); |
|
this.log(`${target.definition.name} countered with ${counterDamage} damage!`); |
|
|
|
|
|
target.temporaryEffects.splice(i, 1); |
|
} |
|
} |
|
} |
|
} |
|
|
|
|
|
private canPicletAct(piclet: BattlePiclet): boolean { |
|
|
|
for (const status of piclet.statusEffects) { |
|
switch (status) { |
|
case 'freeze': |
|
this.log(`${piclet.definition.name} is frozen solid and cannot move!`); |
|
return false; |
|
|
|
case 'sleep': |
|
this.log(`${piclet.definition.name} is fast asleep and cannot wake up!`); |
|
return false; |
|
|
|
case 'paralyze': |
|
|
|
if (Math.random() < 0.25) { |
|
this.log(`${piclet.definition.name} is fully paralyzed and cannot move!`); |
|
return false; |
|
} |
|
break; |
|
|
|
case 'confuse': |
|
|
|
if (Math.random() < 0.33) { |
|
const selfDamage = Math.floor(piclet.maxHp * 0.125); |
|
piclet.currentHp = Math.max(0, piclet.currentHp - selfDamage); |
|
this.log(`${piclet.definition.name} hurt itself in confusion for ${selfDamage} damage!`); |
|
return false; |
|
} |
|
break; |
|
} |
|
} |
|
return true; |
|
} |
|
|
|
|
|
private processApplyStatusEffect(effect: { status: StatusEffect; chance?: number }, target: BattlePiclet): void { |
|
|
|
if (effect.chance !== undefined) { |
|
const roll = Math.random() * 100; |
|
if (roll >= effect.chance) { |
|
return; |
|
} |
|
} |
|
|
|
|
|
if (this.checkStatusImmunity(target, effect.status)) { |
|
this.log(`${target.definition.name} is immune to ${effect.status}!`); |
|
return; |
|
} |
|
|
|
|
|
const majorStatuses = ['freeze', 'paralyze', 'sleep']; |
|
if (majorStatuses.includes(effect.status)) { |
|
const hasMajorStatus = target.statusEffects.some(status => majorStatuses.includes(status)); |
|
if (hasMajorStatus) { |
|
this.log(`${target.definition.name} is already affected by a major status condition!`); |
|
return; |
|
} |
|
} |
|
|
|
if (!target.statusEffects.includes(effect.status)) { |
|
target.statusEffects.push(effect.status); |
|
|
|
|
|
this.triggerOnStatusInflicted(target, effect.status); |
|
|
|
|
|
switch (effect.status) { |
|
case 'freeze': |
|
this.log(`${target.definition.name} was frozen solid!`); |
|
|
|
(target as any).freezeJustApplied = true; |
|
break; |
|
case 'paralyze': |
|
this.log(`${target.definition.name} was paralyzed!`); |
|
|
|
target.speed = Math.floor(target.speed * 0.5); |
|
break; |
|
case 'sleep': |
|
this.log(`${target.definition.name} fell asleep!`); |
|
|
|
(target as any).sleepTurns = 1 + Math.floor(Math.random() * 3); |
|
(target as any).sleepJustApplied = true; |
|
break; |
|
case 'confuse': |
|
this.log(`${target.definition.name} became confused!`); |
|
|
|
(target as any).confusionTurns = 2 + Math.floor(Math.random() * 4); |
|
break; |
|
default: |
|
this.log(`${target.definition.name} was ${effect.status}ed!`); |
|
} |
|
} |
|
} |
|
|
|
|
|
private wakeUpFromSleep(target: BattlePiclet): void { |
|
if (target.statusEffects.includes('sleep')) { |
|
const sleepIndex = target.statusEffects.indexOf('sleep'); |
|
if (sleepIndex > -1) { |
|
target.statusEffects.splice(sleepIndex, 1); |
|
this.log(`${target.definition.name} woke up from the attack!`); |
|
delete (target as any).sleepTurns; |
|
} |
|
} |
|
} |
|
|
|
|
|
private triggerAbilities(event: string, piclet: BattlePiclet, context?: any): void { |
|
if (!piclet.definition.specialAbility?.triggers) return; |
|
|
|
for (const trigger of piclet.definition.specialAbility.triggers) { |
|
if (trigger.event === event && this.checkTriggerCondition(trigger, piclet, context)) { |
|
this.log(`${piclet.definition.name}'s ${piclet.definition.specialAbility.name} triggered!`); |
|
|
|
|
|
for (const effect of trigger.effects) { |
|
this.processAbilityTriggerEffect(effect, piclet, context); |
|
} |
|
} |
|
} |
|
} |
|
|
|
private checkTriggerCondition(trigger: Trigger, piclet: BattlePiclet, context?: any): boolean { |
|
if (!trigger.condition || trigger.condition === 'always') { |
|
return true; |
|
} |
|
|
|
|
|
switch (trigger.condition) { |
|
case 'ifLowHp': |
|
return (piclet.currentHp / piclet.maxHp) < 0.25; |
|
case 'ifHighHp': |
|
return piclet.currentHp === piclet.maxHp; |
|
case 'onCritical': |
|
return context?.isCriticalHit === true; |
|
case 'ifStatusMove': |
|
return context?.isStatusMove === true; |
|
default: |
|
return true; |
|
} |
|
} |
|
|
|
private processAbilityTriggerEffect(effect: BattleEffect, owner: BattlePiclet, context?: any): void { |
|
|
|
let targetType = 'self'; |
|
if ('target' in effect) { |
|
targetType = effect.target; |
|
} |
|
const target = this.resolveAbilityTarget(targetType, owner); |
|
if (!target) return; |
|
|
|
|
|
switch (effect.type) { |
|
case 'damage': |
|
|
|
const dummyMove: Move = { |
|
name: `${owner.definition.specialAbility?.name} Effect`, |
|
type: 'normal' as any, |
|
power: 0, |
|
accuracy: 100, |
|
pp: 1, |
|
priority: 0, |
|
flags: [], |
|
effects: [] |
|
}; |
|
this.processDamageEffect(effect, owner, target, dummyMove); |
|
break; |
|
case 'modifyStats': |
|
this.processModifyStatsEffect(effect, target); |
|
break; |
|
case 'heal': |
|
this.processHealEffect(effect, target); |
|
break; |
|
case 'applyStatus': |
|
this.processApplyStatusEffect(effect, target); |
|
break; |
|
case 'removeStatus': |
|
this.processRemoveStatusEffect(effect, target); |
|
break; |
|
default: |
|
this.log(`Ability effect ${effect.type} not implemented yet`); |
|
} |
|
} |
|
|
|
private resolveAbilityTarget(targetType: string, owner: BattlePiclet): BattlePiclet | null { |
|
switch (targetType) { |
|
case 'self': |
|
return owner; |
|
case 'opponent': |
|
return owner === this.state.playerPiclet ? this.state.opponentPiclet : this.state.playerPiclet; |
|
default: |
|
return null; |
|
} |
|
} |
|
|
|
|
|
private triggerOnDamageTaken(piclet: BattlePiclet, damage: number, isContactMove: boolean): void { |
|
this.triggerAbilities('onDamageTaken', piclet, { damage, isContactMove }); |
|
if (isContactMove) { |
|
this.triggerAbilities('onContactDamage', piclet, { damage }); |
|
} |
|
} |
|
|
|
private triggerOnDamageDealt(piclet: BattlePiclet, damage: number, target: BattlePiclet): void { |
|
this.triggerAbilities('onDamageDealt', piclet, { damage, target }); |
|
} |
|
|
|
private triggerOnCriticalHit(piclet: BattlePiclet, target: BattlePiclet): void { |
|
this.triggerAbilities('onCriticalHit', piclet, { target, isCriticalHit: true }); |
|
} |
|
|
|
private triggerOnLowHP(piclet: BattlePiclet): void { |
|
if ((piclet.currentHp / piclet.maxHp) < 0.25) { |
|
this.triggerAbilities('onLowHP', piclet); |
|
} |
|
} |
|
|
|
private triggerEndOfTurn(): void { |
|
this.triggerAbilities('endOfTurn', this.state.playerPiclet); |
|
this.triggerAbilities('endOfTurn', this.state.opponentPiclet); |
|
} |
|
|
|
private triggerOnStatusInflicted(piclet: BattlePiclet, status: string): void { |
|
this.triggerAbilities('onStatusInflicted', piclet, { status }); |
|
} |
|
|
|
private triggerOnHPDrained(attacker: BattlePiclet, target: BattlePiclet, drainAmount: number): void { |
|
this.triggerAbilities('onHPDrained', attacker, { target, drainAmount }); |
|
} |
|
|
|
private triggerOnKO(knockedOut: BattlePiclet, attacker: BattlePiclet): void { |
|
this.triggerAbilities('onKO', knockedOut, { attacker }); |
|
this.triggerAbilities('onKO', attacker, { target: knockedOut, causedKO: true }); |
|
} |
|
|
|
private triggerBeforeMoveUse(piclet: BattlePiclet, move: Move): void { |
|
this.triggerAbilities('beforeMoveUse', piclet, { move }); |
|
} |
|
|
|
private triggerAfterMoveUse(piclet: BattlePiclet, move: Move, success: boolean): void { |
|
this.triggerAbilities('afterMoveUse', piclet, { move, success }); |
|
} |
|
|
|
private triggerOnFullHP(piclet: BattlePiclet): void { |
|
if (piclet.currentHp === piclet.maxHp) { |
|
this.triggerAbilities('onFullHP', piclet); |
|
} |
|
} |
|
|
|
private triggerOnOpponentContactMove(defender: BattlePiclet, attacker: BattlePiclet, move: Move): void { |
|
if (move.flags.includes('contact')) { |
|
this.triggerAbilities('onOpponentContactMove', defender, { attacker, move }); |
|
} |
|
} |
|
|
|
private triggerOnStatChange(piclet: BattlePiclet, stat: string, change: string): void { |
|
this.triggerAbilities('onStatChange', piclet, { stat, change }); |
|
} |
|
} |