From 77f0fe6e4b409f1f705b03086cf853d9499d1e1a Mon Sep 17 00:00:00 2001 From: innerthunder <168692175+innerthunder@users.noreply.github.com> Date: Fri, 13 Sep 2024 22:54:22 -0700 Subject: [PATCH] [Enhancement][EnemyAI] Add support for simulated damage calculations and "Search for KO" move filtering (#3975) * Create getAttackDamage function * Add ignoreAbility params to getBattleStat * Rewrite Pokemon.apply * renamed damage variables * Add `ignoreSourceAbility` arg to `getAttackDamage` * Enemy AI now searches for KO moves * Add probabilistic test for KO search * Add tests to `damage_calculation` * "killMoves" --> "koMoves" * Clean up `randomMultiplier` * Clean up damage calculation test Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> * Fix stabMultiplier using base type for Tera bonus * Restore simulation capabilities for Unaware * move sourceTeraType closer to where it's used * Add base damage test * Exclude counter moves from KO search --------- Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> --- src/data/move.ts | 2 +- src/field/pokemon.ts | 697 ++++++++++++--------- src/test/battle/damage_calculation.test.ts | 109 +++- src/test/enemy_command.test.ts | 75 +++ 4 files changed, 568 insertions(+), 315 deletions(-) create mode 100644 src/test/enemy_command.test.ts diff --git a/src/data/move.ts b/src/data/move.ts index e828c425d08..732a1feea0c 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -3977,7 +3977,7 @@ export class StatusCategoryOnAllyAttr extends VariableMoveCategoryAttr { export class ShellSideArmCategoryAttr extends VariableMoveCategoryAttr { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - const category = (args[0] as Utils.IntegerHolder); + const category = (args[0] as Utils.NumberHolder); const atkRatio = user.getEffectiveStat(Stat.ATK, target, move) / target.getEffectiveStat(Stat.DEF, user, move); const specialRatio = user.getEffectiveStat(Stat.SPATK, target, move) / target.getEffectiveStat(Stat.SPDEF, user, move); diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index ea1b7fba372..a74b2643eef 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -3,7 +3,7 @@ import BattleScene, { AnySound } from "../battle-scene"; import { Variant, VariantSet, variantColorCache } from "#app/data/variant"; import { variantData } from "#app/data/variant"; import BattleInfo, { PlayerBattleInfo, EnemyBattleInfo } from "../ui/battle-info"; -import Move, { HighCritAttr, HitsTagAttr, applyMoveAttrs, FixedDamageAttr, VariableAtkAttr, allMoves, MoveCategory, TypelessAttr, CritOnlyAttr, getMoveTargets, OneHitKOAttr, VariableMoveTypeAttr, VariableDefAttr, AttackMove, ModifiedDamageAttr, VariableMoveTypeMultiplierAttr, IgnoreOpponentStatStagesAttr, SacrificialAttr, VariableMoveCategoryAttr, CounterDamageAttr, StatStageChangeAttr, RechargeAttr, ChargeAttr, IgnoreWeatherTypeDebuffAttr, BypassBurnDamageReductionAttr, SacrificialAttrOnHit, OneHitKOAccuracyAttr, RespectAttackTypeImmunityAttr } from "../data/move"; +import Move, { HighCritAttr, HitsTagAttr, applyMoveAttrs, FixedDamageAttr, VariableAtkAttr, allMoves, MoveCategory, TypelessAttr, CritOnlyAttr, getMoveTargets, OneHitKOAttr, VariableMoveTypeAttr, VariableDefAttr, AttackMove, ModifiedDamageAttr, VariableMoveTypeMultiplierAttr, IgnoreOpponentStatStagesAttr, SacrificialAttr, VariableMoveCategoryAttr, CounterDamageAttr, StatStageChangeAttr, RechargeAttr, ChargeAttr, IgnoreWeatherTypeDebuffAttr, BypassBurnDamageReductionAttr, SacrificialAttrOnHit, OneHitKOAccuracyAttr, RespectAttackTypeImmunityAttr, MoveTarget } from "../data/move"; import { default as PokemonSpecies, PokemonSpeciesForm, SpeciesFormKey, getFusedSpeciesName, getPokemonSpecies, getPokemonSpeciesForm, getStarterValueFriendshipCap, speciesStarters, starterPassiveAbilities } from "../data/pokemon-species"; import { Constructor, isNullOrUndefined, randSeedInt } from "#app/utils"; import * as Utils from "../utils"; @@ -870,22 +870,29 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * @param stat the desired {@linkcode EffectiveStat} * @param opponent the target {@linkcode Pokemon} * @param move the {@linkcode Move} being used + * @param ignoreAbility determines whether this Pokemon's abilities should be ignored during the stat calculation + * @param ignoreOppAbility during an attack, determines whether the opposing Pokemon's abilities should be ignored during the stat calculation. * @param isCritical determines whether a critical hit has occurred or not (`false` by default) + * @param simulated if `true`, nullifies any effects that produce any changes to game state from triggering * @returns the final in-battle value of a stat */ - getEffectiveStat(stat: EffectiveStat, opponent?: Pokemon, move?: Move, isCritical: boolean = false): integer { + getEffectiveStat(stat: EffectiveStat, opponent?: Pokemon, move?: Move, ignoreAbility: boolean = false, ignoreOppAbility: boolean = false, isCritical: boolean = false, simulated: boolean = true): integer { const statValue = new Utils.NumberHolder(this.getStat(stat, false)); this.scene.applyModifiers(StatBoosterModifier, this.isPlayer(), this, stat, statValue); + // The Ruin abilities here are never ignored, but they reveal themselves on summon anyway const fieldApplied = new Utils.BooleanHolder(false); for (const pokemon of this.scene.getField(true)) { - applyFieldStatMultiplierAbAttrs(FieldMultiplyStatAbAttr, pokemon, stat, statValue, this, fieldApplied); + applyFieldStatMultiplierAbAttrs(FieldMultiplyStatAbAttr, pokemon, stat, statValue, this, fieldApplied, simulated); if (fieldApplied.value) { break; } } - applyStatMultiplierAbAttrs(StatMultiplierAbAttr, this, stat, statValue); - let ret = statValue.value * this.getStatStageMultiplier(stat, opponent, move, isCritical); + if (!ignoreAbility) { + applyStatMultiplierAbAttrs(StatMultiplierAbAttr, this, stat, statValue, simulated); + } + + let ret = statValue.value * this.getStatStageMultiplier(stat, opponent, move, ignoreOppAbility, isCritical, simulated); switch (stat) { case Stat.ATK: @@ -2223,10 +2230,12 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * @param stat the desired {@linkcode EffectiveStat} * @param opponent the target {@linkcode Pokemon} * @param move the {@linkcode Move} being used + * @param ignoreOppAbility determines whether the effects of the opponent's abilities (i.e. Unaware) should be ignored (`false` by default) * @param isCritical determines whether a critical hit has occurred or not (`false` by default) + * @param simulated determines whether effects are applied without altering game state (`true` by default) * @return the stat stage multiplier to be used for effective stat calculation */ - getStatStageMultiplier(stat: EffectiveStat, opponent?: Pokemon, move?: Move, isCritical: boolean = false): number { + getStatStageMultiplier(stat: EffectiveStat, opponent?: Pokemon, move?: Move, ignoreOppAbility: boolean = false, isCritical: boolean = false, simulated: boolean = true): number { const statStage = new Utils.IntegerHolder(this.getStatStage(stat)); const ignoreStatStage = new Utils.BooleanHolder(false); @@ -2243,7 +2252,9 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { break; } } - applyAbAttrs(IgnoreOpponentStatStagesAbAttr, opponent, null, false, stat, ignoreStatStage); + if (!ignoreOppAbility) { + applyAbAttrs(IgnoreOpponentStatStagesAbAttr, opponent, null, simulated, stat, ignoreStatStage); + } if (move) { applyMoveAttrs(IgnoreOpponentStatStagesAttr, this, opponent, move, ignoreStatStage); } @@ -2308,13 +2319,18 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Apply the results of a move to this pokemon - * @param {Pokemon} source The pokemon using the move - * @param {PokemonMove} battlerMove The move being used - * @returns {HitResult} The result of the attack - */ - apply(source: Pokemon, move: Move): HitResult { - let result: HitResult; + * Calculates the damage of an attack made by another Pokemon against this Pokemon + * @param source {@linkcode Pokemon} the attacking Pokemon + * @param move {@linkcode Pokemon} the move used in the attack + * @param ignoreAbility If `true`, ignores this Pokemon's defensive ability effects + * @param isCritical If `true`, calculates damage for a critical hit. + * @param simulated If `true`, suppresses changes to game state during the calculation. + * @returns a {@linkcode DamageCalculationResult} object with three fields: + * - `cancelled`: `true` if the move was cancelled by another effect. + * - `result`: {@linkcode HitResult} indicates the attack's type effectiveness. + * - `damage`: `number` the attack's final damage output. + */ + getAttackDamage(source: Pokemon, move: Move, ignoreAbility: boolean = false, ignoreSourceAbility: boolean = false, isCritical: boolean = false, simulated: boolean = true): DamageCalculationResult { const damage = new Utils.NumberHolder(0); const defendingSide = this.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; @@ -2332,291 +2348,354 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * The effectiveness of the move being used. Along with type matchups, this * accounts for changes in effectiveness from the move's attributes and the * abilities of both the source and this Pokemon. + * + * Note that the source's abilities are not ignored here */ - const typeMultiplier = this.getMoveEffectiveness(source, move, false, false, cancelled); + const typeMultiplier = this.getMoveEffectiveness(source, move, ignoreAbility, simulated, cancelled); - switch (moveCategory) { - case MoveCategory.PHYSICAL: - case MoveCategory.SPECIAL: - const isPhysical = moveCategory === MoveCategory.PHYSICAL; - const sourceTeraType = source.getTeraType(); + const isPhysical = moveCategory === MoveCategory.PHYSICAL; - const power = move.calculateBattlePower(source, this); + /** Combined damage multiplier from field effects such as weather, terrain, etc. */ + const arenaAttackTypeMultiplier = new Utils.NumberHolder(this.scene.arena.getAttackTypeMultiplier(moveType, source.isGrounded())); + applyMoveAttrs(IgnoreWeatherTypeDebuffAttr, source, this, move, arenaAttackTypeMultiplier); - if (cancelled.value) { - // Cancelled moves fail silently - source.stopMultiHit(this); - return HitResult.NO_EFFECT; - } else { - const typeBoost = source.findTag(t => t instanceof TypeBoostTag && t.boostedType === moveType) as TypeBoostTag; - if (typeBoost?.oneUse) { - source.removeTag(typeBoost.tagType); + const isTypeImmune = (typeMultiplier * arenaAttackTypeMultiplier.value) === 0; + + if (cancelled.value || isTypeImmune) { + return { + cancelled: cancelled.value, + result: move.id === Moves.SHEER_COLD ? HitResult.IMMUNE : HitResult.NO_EFFECT, + damage: 0 + }; + } + + // If the attack deals fixed damaged, return a result with that much damage + const fixedDamage = new Utils.IntegerHolder(0); + applyMoveAttrs(FixedDamageAttr, source, this, move, fixedDamage); + if (fixedDamage.value) { + return { + cancelled: false, + result: HitResult.EFFECTIVE, + damage: fixedDamage.value + }; + } + + // If the attack is a one-hit KO move, return a result with damage equal to this Pokemon's HP + const isOneHitKo = new Utils.BooleanHolder(false); + applyMoveAttrs(OneHitKOAttr, source, this, move, isOneHitKo); + if (isOneHitKo.value) { + return { + cancelled: false, + result: HitResult.ONE_HIT_KO, + damage: this.hp + }; + } + + // ----- BEGIN BASE DAMAGE MULTIPLIERS ----- + + /** A base damage multiplier based on the source's level */ + const levelMultiplier = (2 * source.level / 5 + 2); + + /** The power of the move after power boosts from abilities, etc. have applied */ + const power = move.calculateBattlePower(source, this, simulated); + + /** + * The attacker's offensive stat for the given move's category. + * Critical hits ignore negative stat stages. + */ + const sourceAtk = new Utils.NumberHolder(source.getEffectiveStat(isPhysical ? Stat.ATK : Stat.SPATK, this, undefined, ignoreSourceAbility, ignoreAbility, isCritical, simulated)); + applyMoveAttrs(VariableAtkAttr, source, this, move, sourceAtk); + + /** + * This Pokemon's defensive stat for the given move's category. + * Critical hits ignore positive stat stages. + */ + const targetDef = new Utils.NumberHolder(this.getEffectiveStat(isPhysical ? Stat.DEF : Stat.SPDEF, source, move, ignoreAbility, ignoreSourceAbility, isCritical, simulated)); + applyMoveAttrs(VariableDefAttr, source, this, move, targetDef); + + /** + * The attack's base damage, as determined by the source's level, move power + * and Attack stat as well as this Pokemon's Defense stat + */ + const baseDamage = ((levelMultiplier * power * sourceAtk.value / targetDef.value) / 50) + 2; + + // ------ END BASE DAMAGE MULTIPLIERS ------ + + /** 25% damage debuff on moves hitting more than one non-fainted target (regardless of immunities) */ + const { targets, multiple } = getMoveTargets(source, move.id); + const numTargets = multiple ? targets.length : 1; + const targetMultiplier = (numTargets > 1) ? 0.75 : 1; + + /** 0.25x multiplier if this is an added strike from the attacker's Parental Bond */ + const parentalBondMultiplier = new Utils.NumberHolder(1); + if (!ignoreSourceAbility) { + applyPreAttackAbAttrs(AddSecondStrikeAbAttr, source, this, move, simulated, numTargets, new Utils.IntegerHolder(0), parentalBondMultiplier); + } + + /** Doubles damage if this Pokemon's last move was Glaive Rush */ + const glaiveRushMultiplier = new Utils.IntegerHolder(1); + if (this.getTag(BattlerTagType.RECEIVE_DOUBLE_DAMAGE)) { + glaiveRushMultiplier.value = 2; + } + + /** The damage multiplier when the given move critically hits */ + const criticalMultiplier = new Utils.NumberHolder(isCritical ? 1.5 : 1); + applyAbAttrs(MultCritAbAttr, source, null, simulated, criticalMultiplier); + + /** + * A multiplier for random damage spread in the range [0.85, 1] + * This is always 1 for simulated calls. + */ + const randomMultiplier = simulated ? 1 : ((this.randSeedIntRange(85, 100)) / 100); + + const sourceTypes = source.getTypes(); + const sourceTeraType = source.getTeraType(); + const matchesSourceType = sourceTypes.includes(moveType); + /** A damage multiplier for when the attack is of the attacker's type and/or Tera type. */ + const stabMultiplier = new Utils.NumberHolder(1); + if (matchesSourceType) { + stabMultiplier.value += 0.5; + } + if (sourceTeraType !== Type.UNKNOWN && sourceTeraType === moveType) { + stabMultiplier.value += 0.5; + } + + if (!ignoreSourceAbility) { + applyAbAttrs(StabBoostAbAttr, source, null, simulated, stabMultiplier); + } + + stabMultiplier.value = Math.min(stabMultiplier.value, 2.25); + + /** Halves damage if the attacker is using a physical attack while burned */ + const burnMultiplier = new Utils.NumberHolder(1); + if (isPhysical && source.status && source.status.effect === StatusEffect.BURN) { + if (!move.hasAttr(BypassBurnDamageReductionAttr)) { + const burnDamageReductionCancelled = new Utils.BooleanHolder(false); + if (!ignoreSourceAbility) { + applyAbAttrs(BypassBurnDamageReductionAbAttr, source, burnDamageReductionCancelled, simulated); } - - /** Combined damage multiplier from field effects such as weather, terrain, etc. */ - const arenaAttackTypeMultiplier = new Utils.NumberHolder(this.scene.arena.getAttackTypeMultiplier(moveType, source.isGrounded())); - applyMoveAttrs(IgnoreWeatherTypeDebuffAttr, source, this, move, arenaAttackTypeMultiplier); - - /** - * Whether or not this Pokemon is immune to the incoming move. - * Note that this isn't fully resolved in `getMoveEffectiveness` because - * of possible type-suppressing field effects (e.g. Desolate Land's effect on Water-type attacks). - */ - const isTypeImmune = (typeMultiplier * arenaAttackTypeMultiplier.value) === 0; - if (isTypeImmune) { - // Moves with no effect that were not cancelled queue a "no effect" message before failing - source.stopMultiHit(this); - result = (move.id === Moves.SHEER_COLD) - ? HitResult.IMMUNE - : HitResult.NO_EFFECT; - - if (result === HitResult.IMMUNE) { - this.scene.queueMessage(i18next.t("battle:hitResultImmune", { pokemonName: this.name })); - } else { - this.scene.queueMessage(i18next.t("battle:hitResultNoEffect", { pokemonName: getPokemonNameWithAffix(this) })); - } - - return result; - } - - const glaiveRushModifier = new Utils.IntegerHolder(1); - if (this.getTag(BattlerTagType.RECEIVE_DOUBLE_DAMAGE)) { - glaiveRushModifier.value = 2; - } - let isCritical: boolean; - const critOnly = new Utils.BooleanHolder(false); - const critAlways = source.getTag(BattlerTagType.ALWAYS_CRIT); - applyMoveAttrs(CritOnlyAttr, source, this, move, critOnly); - applyAbAttrs(ConditionalCritAbAttr, source, null, false, critOnly, this, move); - if (critOnly.value || critAlways) { - isCritical = true; - } else { - const critChance = [24, 8, 2, 1][Math.max(0, Math.min(this.getCritStage(source, move), 3))]; - isCritical = critChance === 1 || !this.scene.randBattleSeedInt(critChance); - if (Overrides.NEVER_CRIT_OVERRIDE) { - isCritical = false; - } - } - if (isCritical) { - const noCritTag = this.scene.arena.getTagOnSide(NoCritTag, defendingSide); - const blockCrit = new Utils.BooleanHolder(false); - applyAbAttrs(BlockCritAbAttr, this, null, false, blockCrit); - if (noCritTag || blockCrit.value) { - isCritical = false; - } - } - const sourceAtk = new Utils.IntegerHolder(source.getEffectiveStat(isPhysical ? Stat.ATK : Stat.SPATK, this, undefined, isCritical)); - const targetDef = new Utils.IntegerHolder(this.getEffectiveStat(isPhysical ? Stat.DEF : Stat.SPDEF, source, move, isCritical)); - const criticalMultiplier = new Utils.NumberHolder(isCritical ? 1.5 : 1); - applyAbAttrs(MultCritAbAttr, source, null, false, criticalMultiplier); - const screenMultiplier = new Utils.NumberHolder(1); - if (!isCritical) { - this.scene.arena.applyTagsForSide(WeakenMoveScreenTag, defendingSide, move.category, this.scene.currentBattle.double, screenMultiplier); - } - const sourceTypes = source.getTypes(); - const matchesSourceType = sourceTypes[0] === moveType || (sourceTypes.length > 1 && sourceTypes[1] === moveType); - const stabMultiplier = new Utils.NumberHolder(1); - if (sourceTeraType === Type.UNKNOWN && matchesSourceType) { - stabMultiplier.value += 0.5; - } else if (sourceTeraType !== Type.UNKNOWN && sourceTeraType === moveType) { - stabMultiplier.value += 0.5; - } - - applyAbAttrs(StabBoostAbAttr, source, null, false, stabMultiplier); - - if (sourceTeraType !== Type.UNKNOWN && matchesSourceType) { - stabMultiplier.value = Math.min(stabMultiplier.value + 0.5, 2.25); - } - - // 25% damage debuff on moves hitting more than one non-fainted target (regardless of immunities) - const { targets, multiple } = getMoveTargets(source, move.id); - const targetMultiplier = (multiple && targets.length > 1) ? 0.75 : 1; - - applyMoveAttrs(VariableAtkAttr, source, this, move, sourceAtk); - applyMoveAttrs(VariableDefAttr, source, this, move, targetDef); - - const effectPhase = this.scene.getCurrentPhase(); - let numTargets = 1; - if (effectPhase instanceof MoveEffectPhase) { - numTargets = effectPhase.getTargets().length; - } - const twoStrikeMultiplier = new Utils.NumberHolder(1); - applyPreAttackAbAttrs(AddSecondStrikeAbAttr, source, this, move, false, numTargets, new Utils.IntegerHolder(0), twoStrikeMultiplier); - - if (!isTypeImmune) { - const levelMultiplier = (2 * source.level / 5 + 2); - const randomMultiplier = (this.randSeedIntRange(85, 100) / 100); - damage.value = Utils.toDmgValue((((levelMultiplier * power * sourceAtk.value / targetDef.value) / 50) + 2) - * stabMultiplier.value - * typeMultiplier - * arenaAttackTypeMultiplier.value - * screenMultiplier.value - * twoStrikeMultiplier.value - * targetMultiplier - * criticalMultiplier.value - * glaiveRushModifier.value - * randomMultiplier); - - if (isPhysical && source.status && source.status.effect === StatusEffect.BURN) { - if (!move.hasAttr(BypassBurnDamageReductionAttr)) { - const burnDamageReductionCancelled = new Utils.BooleanHolder(false); - applyAbAttrs(BypassBurnDamageReductionAbAttr, source, burnDamageReductionCancelled, false); - if (!burnDamageReductionCancelled.value) { - damage.value = Utils.toDmgValue(damage.value / 2); - } - } - } - - applyPreAttackAbAttrs(DamageBoostAbAttr, source, this, move, false, damage); - - /** - * For each {@link HitsTagAttr} the move has, doubles the damage of the move if: - * The target has a {@link BattlerTagType} that this move interacts with - * AND - * The move doubles damage when used against that tag - */ - move.getAttrs(HitsTagAttr).filter(hta => hta.doubleDamage).forEach(hta => { - if (this.getTag(hta.tagType)) { - damage.value *= 2; - } - }); - } - - if (this.scene.arena.terrain?.terrainType === TerrainType.MISTY && this.isGrounded() && moveType === Type.DRAGON) { - damage.value = Utils.toDmgValue(damage.value / 2); - } - - const fixedDamage = new Utils.IntegerHolder(0); - applyMoveAttrs(FixedDamageAttr, source, this, move, fixedDamage); - if (!isTypeImmune && fixedDamage.value) { - damage.value = fixedDamage.value; - isCritical = false; - result = HitResult.EFFECTIVE; - } - result = result!; // telling TS compiler that result is defined! - - if (!result) { - const isOneHitKo = new Utils.BooleanHolder(false); - applyMoveAttrs(OneHitKOAttr, source, this, move, isOneHitKo); - if (isOneHitKo.value) { - result = HitResult.ONE_HIT_KO; - isCritical = false; - damage.value = this.hp; - } else if (typeMultiplier >= 2) { - result = HitResult.SUPER_EFFECTIVE; - } else if (typeMultiplier >= 1) { - result = HitResult.EFFECTIVE; - } else { - result = HitResult.NOT_VERY_EFFECTIVE; - } - } - - const isOneHitKo = result === HitResult.ONE_HIT_KO; - - if (!fixedDamage.value && !isOneHitKo) { - if (!source.isPlayer()) { - this.scene.applyModifiers(EnemyDamageBoosterModifier, false, damage); - } - if (!this.isPlayer()) { - this.scene.applyModifiers(EnemyDamageReducerModifier, false, damage); - } - - applyPreDefendAbAttrs(ReceivedMoveDamageMultiplierAbAttr, this, source, move, cancelled, false, damage); - } - - // This attribute may modify damage arbitrarily, so be careful about changing its order of application. - applyMoveAttrs(ModifiedDamageAttr, source, this, move, damage); - - console.log("damage", damage.value, move.name, power, sourceAtk, targetDef); - - // In case of fatal damage, this tag would have gotten cleared before we could lapse it. - const destinyTag = this.getTag(BattlerTagType.DESTINY_BOND); - - if (damage.value) { - this.lapseTags(BattlerTagLapseType.HIT); - - const substitute = this.getTag(SubstituteTag); - if (substitute && move.hitsSubstitute(source, this)) { - substitute.hp -= damage.value; - damage.value = 0; - } - if (this.isFullHp()) { - applyPreDefendAbAttrs(PreDefendFullHpEndureAbAttr, this, source, move, cancelled, false, damage); - } else if (!this.isPlayer() && damage.value >= this.hp) { - this.scene.applyModifiers(EnemyEndureChanceModifier, false, this); - } - - /** - * We explicitly require to ignore the faint phase here, as we want to show the messages - * about the critical hit and the super effective/not very effective messages before the faint phase. - */ - damage.value = this.damageAndUpdate(damage.value, result as DamageResult, isCritical, isOneHitKo, isOneHitKo, true); - this.turnData.damageTaken += damage.value; - - if (isCritical) { - this.scene.queueMessage(i18next.t("battle:hitResultCriticalHit")); - } - if (source.isPlayer()) { - this.scene.validateAchvs(DamageAchv, damage); - if (damage.value > this.scene.gameData.gameStats.highestDamage) { - this.scene.gameData.gameStats.highestDamage = damage.value; - } - } - - if (damage.value > 0) { - source.turnData.damageDealt += damage.value; - source.turnData.currDamageDealt = damage.value; - this.battleData.hitCount++; - const attackResult = { move: move.id, result: result as DamageResult, damage: damage.value, critical: isCritical, sourceId: source.id, sourceBattlerIndex: source.getBattlerIndex() }; - this.turnData.attacksReceived.unshift(attackResult); - - if (source.isPlayer() && !this.isPlayer()) { - this.scene.applyModifiers(DamageMoneyRewardModifier, true, source, damage); - } - } - } - - // want to include is.Fainted() in case multi hit move ends early, still want to render message - if (source.turnData.hitsLeft === 1 || this.isFainted()) { - switch (result) { - case HitResult.SUPER_EFFECTIVE: - this.scene.queueMessage(i18next.t("battle:hitResultSuperEffective")); - break; - case HitResult.NOT_VERY_EFFECTIVE: - this.scene.queueMessage(i18next.t("battle:hitResultNotVeryEffective")); - break; - case HitResult.ONE_HIT_KO: - this.scene.queueMessage(i18next.t("battle:hitResultOneHitKO")); - break; - case HitResult.IMMUNE: - case HitResult.NO_EFFECT: - console.error("Unhandled move immunity!"); - break; - } - } - - if (this.isFainted()) { - // set splice index here, so future scene queues happen before FaintedPhase - this.scene.setPhaseQueueSplice(); - this.scene.unshiftPhase(new FaintPhase(this.scene, this.getBattlerIndex(), isOneHitKo)); - this.destroySubstitute(); - this.resetSummonData(); - } - - if (damage) { - destinyTag?.lapse(source, BattlerTagLapseType.CUSTOM); + if (!burnDamageReductionCancelled.value) { + burnMultiplier.value = 0.5; } } - break; - case MoveCategory.STATUS: + } + + /** Reduces damage if this Pokemon has a relevant screen (e.g. Light Screen for special attacks) */ + const screenMultiplier = new Utils.NumberHolder(1); + this.scene.arena.applyTagsForSide(WeakenMoveScreenTag, defendingSide, move.category, this.scene.currentBattle.double, screenMultiplier); + + /** + * For each {@linkcode HitsTagAttr} the move has, doubles the damage of the move if: + * The target has a {@linkcode BattlerTagType} that this move interacts with + * AND + * The move doubles damage when used against that tag + */ + const hitsTagMultiplier = new Utils.NumberHolder(1); + move.getAttrs(HitsTagAttr).filter(hta => hta.doubleDamage).forEach(hta => { + if (this.getTag(hta.tagType)) { + hitsTagMultiplier.value *= 2; + } + }); + + /** Halves damage if this Pokemon is grounded in Misty Terrain against a Dragon-type attack */ + const mistyTerrainMultiplier = (this.scene.arena.terrain?.terrainType === TerrainType.MISTY && this.isGrounded() && moveType === Type.DRAGON) + ? 0.5 + : 1; + + damage.value = Utils.toDmgValue( + baseDamage + * targetMultiplier + * parentalBondMultiplier.value + * arenaAttackTypeMultiplier.value + * glaiveRushMultiplier.value + * criticalMultiplier.value + * randomMultiplier + * stabMultiplier.value + * typeMultiplier + * burnMultiplier.value + * screenMultiplier.value + * hitsTagMultiplier.value + * mistyTerrainMultiplier + ); + + /** Doubles damage if the attacker has Tinted Lens and is using a resisted move */ + if (!ignoreSourceAbility) { + applyPreAttackAbAttrs(DamageBoostAbAttr, source, this, move, simulated, damage); + } + + /** Apply the enemy's Damage and Resistance tokens */ + if (!source.isPlayer()) { + this.scene.applyModifiers(EnemyDamageBoosterModifier, false, damage); + } + if (!this.isPlayer()) { + this.scene.applyModifiers(EnemyDamageReducerModifier, false, damage); + } + + /** Apply this Pokemon's post-calc defensive modifiers (e.g. Fur Coat) */ + if (!ignoreAbility) { + applyPreDefendAbAttrs(ReceivedMoveDamageMultiplierAbAttr, this, source, move, cancelled, simulated, damage); + } + + // This attribute may modify damage arbitrarily, so be careful about changing its order of application. + applyMoveAttrs(ModifiedDamageAttr, source, this, move, damage); + + if (this.isFullHp() && !ignoreAbility) { + applyPreDefendAbAttrs(PreDefendFullHpEndureAbAttr, this, source, move, cancelled, false, damage); + } + + // debug message for when damage is applied (i.e. not simulated) + if (!simulated) { + console.log("damage", damage.value, move.name, power, sourceAtk, targetDef); + } + + let hitResult: HitResult; + if (typeMultiplier < 1) { + hitResult = HitResult.NOT_VERY_EFFECTIVE; + } else if (typeMultiplier > 1) { + hitResult = HitResult.SUPER_EFFECTIVE; + } else { + hitResult = HitResult.EFFECTIVE; + } + + return { + cancelled: cancelled.value, + result: hitResult, + damage: damage.value + }; + } + + /** + * Applies the results of a move to this pokemon + * @param source The {@linkcode Pokemon} using the move + * @param move The {@linkcode Move} being used + * @returns The {@linkcode HitResult} of the attack + */ + apply(source: Pokemon, move: Move): HitResult { + const defendingSide = this.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; + if (move.category === MoveCategory.STATUS) { + const cancelled = new Utils.BooleanHolder(false); + const typeMultiplier = this.getMoveEffectiveness(source, move, false, false, cancelled); + if (!cancelled.value && typeMultiplier === 0) { this.scene.queueMessage(i18next.t("battle:hitResultNoEffect", { pokemonName: getPokemonNameWithAffix(this) })); } - result = (cancelled.value || typeMultiplier === 0) ? HitResult.NO_EFFECT : HitResult.STATUS; - break; - } + return (typeMultiplier === 0) ? HitResult.NO_EFFECT : HitResult.STATUS; + } else { + /** Determines whether the attack critically hits */ + let isCritical: boolean; + const critOnly = new Utils.BooleanHolder(false); + const critAlways = source.getTag(BattlerTagType.ALWAYS_CRIT); + applyMoveAttrs(CritOnlyAttr, source, this, move, critOnly); + applyAbAttrs(ConditionalCritAbAttr, source, null, false, critOnly, this, move); + if (critOnly.value || critAlways) { + isCritical = true; + } else { + const critChance = [24, 8, 2, 1][Math.max(0, Math.min(this.getCritStage(source, move), 3))]; + isCritical = critChance === 1 || !this.scene.randBattleSeedInt(critChance); + } - return result; + const noCritTag = this.scene.arena.getTagOnSide(NoCritTag, defendingSide); + const blockCrit = new Utils.BooleanHolder(false); + applyAbAttrs(BlockCritAbAttr, this, null, false, blockCrit); + if (noCritTag || blockCrit.value || Overrides.NEVER_CRIT_OVERRIDE) { + isCritical = false; + } + + const { cancelled, result, damage: dmg } = this.getAttackDamage(source, move, false, false, isCritical, false); + + const typeBoost = source.findTag(t => t instanceof TypeBoostTag && t.boostedType === source.getMoveType(move)) as TypeBoostTag; + if (typeBoost?.oneUse) { + source.removeTag(typeBoost.tagType); + } + + if (cancelled || result === HitResult.IMMUNE || result === HitResult.NO_EFFECT) { + source.stopMultiHit(this); + + if (!cancelled) { + if (result === HitResult.IMMUNE) { + this.scene.queueMessage(i18next.t("battle:hitResultImmune", { pokemonName: getPokemonNameWithAffix(this) })); + } else { + this.scene.queueMessage(i18next.t("battle:hitResultNoEffect")); + } + } + return result; + } + + if (isCritical) { + this.scene.queueMessage(i18next.t("battle:hitResultCriticalHit")); + } + + // In case of fatal damage, this tag would have gotten cleared before we could lapse it. + const destinyTag = this.getTag(BattlerTagType.DESTINY_BOND); + + const isOneHitKo = result === HitResult.ONE_HIT_KO; + + if (dmg) { + this.lapseTags(BattlerTagLapseType.HIT); + + const substitute = this.getTag(SubstituteTag); + const isBlockedBySubstitute = !!substitute && move.hitsSubstitute(source, this); + if (isBlockedBySubstitute) { + substitute.hp -= dmg; + } + if (!this.isPlayer() && dmg >= this.hp) { + this.scene.applyModifiers(EnemyEndureChanceModifier, false, this); + } + + /** + * We explicitly require to ignore the faint phase here, as we want to show the messages + * about the critical hit and the super effective/not very effective messages before the faint phase. + */ + const damage = this.damageAndUpdate(isBlockedBySubstitute ? 0 : dmg, result as DamageResult, isCritical, isOneHitKo, isOneHitKo, true); + + if (damage > 0) { + if (source.isPlayer()) { + this.scene.validateAchvs(DamageAchv, damage); + if (damage > this.scene.gameData.gameStats.highestDamage) { + this.scene.gameData.gameStats.highestDamage = damage; + } + } + source.turnData.damageDealt += damage; + source.turnData.currDamageDealt = damage; + this.turnData.damageTaken += damage; + this.battleData.hitCount++; + const attackResult = { move: move.id, result: result as DamageResult, damage: damage, critical: isCritical, sourceId: source.id, sourceBattlerIndex: source.getBattlerIndex() }; + this.turnData.attacksReceived.unshift(attackResult); + if (source.isPlayer() && !this.isPlayer()) { + this.scene.applyModifiers(DamageMoneyRewardModifier, true, source, damage); + } + } + } + + // want to include is.Fainted() in case multi hit move ends early, still want to render message + if (source.turnData.hitsLeft === 1 || this.isFainted()) { + switch (result) { + case HitResult.SUPER_EFFECTIVE: + this.scene.queueMessage(i18next.t("battle:hitResultSuperEffective")); + break; + case HitResult.NOT_VERY_EFFECTIVE: + this.scene.queueMessage(i18next.t("battle:hitResultNotVeryEffective")); + break; + case HitResult.ONE_HIT_KO: + this.scene.queueMessage(i18next.t("battle:hitResultOneHitKO")); + break; + } + } + + if (this.isFainted()) { + // set splice index here, so future scene queues happen before FaintedPhase + this.scene.setPhaseQueueSplice(); + this.scene.unshiftPhase(new FaintPhase(this.scene, this.getBattlerIndex(), isOneHitKo)); + this.destroySubstitute(); + this.resetSummonData(); + } + + if (dmg) { + destinyTag?.lapse(source, BattlerTagLapseType.CUSTOM); + } + + return result; + } } /** @@ -4287,7 +4366,7 @@ export class EnemyPokemon extends Pokemon { } // Filter out any moves this Pokemon cannot use - const movePool = this.getMoveset().filter(m => m?.isUsable(this)); + let movePool = this.getMoveset().filter(m => m?.isUsable(this)); // If no moves are left, use Struggle. Otherwise, continue with move selection if (movePool.length) { // If there's only 1 move in the move pool, use it. @@ -4308,6 +4387,36 @@ export class EnemyPokemon extends Pokemon { return { move: moveId, targets: this.getNextTargets(moveId) }; case AiType.SMART_RANDOM: case AiType.SMART: + /** + * Search this Pokemon's move pool for moves that will KO an opposing target. + * If there are any moves that can KO an opponent (i.e. a player Pokemon), + * those moves are the only ones considered for selection on this turn. + */ + const koMoves = movePool.filter(pkmnMove => { + if (!pkmnMove) { + return false; + } + + const move = pkmnMove.getMove()!; + if (move.moveTarget === MoveTarget.ATTACKER) { + return false; + } + + const fieldPokemon = this.scene.getField(); + const moveTargets = getMoveTargets(this, move.id).targets + .map(ind => fieldPokemon[ind]) + .filter(p => this.isPlayer() !== p.isPlayer()); + // Only considers critical hits for crit-only moves or when this Pokemon is under the effect of Laser Focus + const isCritical = move.hasAttr(CritOnlyAttr) || !!this.getTag(BattlerTagType.ALWAYS_CRIT); + + return move.category !== MoveCategory.STATUS + && moveTargets.some(p => p.getAttackDamage(this, move, !p.battleData.abilityRevealed, false, isCritical).damage >= p.hp); + }, this); + + if (koMoves.length > 0) { + movePool = koMoves; + } + /** * Move selection is based on the move's calculated "benefit score" against the * best possible target(s) (as determined by {@linkcode getNextTargets}). @@ -4779,6 +4888,16 @@ export enum HitResult { export type DamageResult = HitResult.EFFECTIVE | HitResult.SUPER_EFFECTIVE | HitResult.NOT_VERY_EFFECTIVE | HitResult.ONE_HIT_KO | HitResult.OTHER; +/** Interface containing the results of a damage calculation for a given move */ +export interface DamageCalculationResult { + /** `true` if the move was cancelled (thus suppressing "No Effect" messages) */ + cancelled: boolean; + /** The effectiveness of the move */ + result: HitResult; + /** The damage dealt by the move */ + damage: number; +} + /** * Wrapper class for the {@linkcode Move} class for Pokemon to interact with. * These are the moves assigned to a {@linkcode Pokemon} object. diff --git a/src/test/battle/damage_calculation.test.ts b/src/test/battle/damage_calculation.test.ts index 89f2bb4c269..a348df6c085 100644 --- a/src/test/battle/damage_calculation.test.ts +++ b/src/test/battle/damage_calculation.test.ts @@ -1,14 +1,13 @@ -import { DamagePhase } from "#app/phases/damage-phase"; -import { toDmgValue } from "#app/utils"; +import { allMoves } from "#app/data/move"; import { Abilities } from "#enums/abilities"; import { ArenaTagType } from "#enums/arena-tag-type"; import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; import GameManager from "#test/utils/gameManager"; import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -describe("Round Down and Minimun 1 test in Damage Calculation", () => { +describe("Battle Mechanics - Damage Calculation", () => { let phaserGame: Phaser.Game; let game: GameManager; @@ -24,24 +23,86 @@ describe("Round Down and Minimun 1 test in Damage Calculation", () => { beforeEach(() => { game = new GameManager(phaserGame); - game.override.battleType("single"); - game.override.startingLevel(10); + game.override + .battleType("single") + .enemySpecies(Species.SNORLAX) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH) + .startingLevel(100) + .enemyLevel(100) + .disableCrits() + .moveset([Moves.TACKLE, Moves.DRAGON_RAGE, Moves.FISSURE, Moves.JUMP_KICK]); + }); + + it("Tackle deals expected base damage", async () => { + await game.classicMode.startBattle([Species.CHARIZARD]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + vi.spyOn(playerPokemon, "getEffectiveStat").mockReturnValue(80); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + vi.spyOn(enemyPokemon, "getEffectiveStat").mockReturnValue(90); + + // expected base damage = [(2*level/5 + 2) * power * playerATK / enemyDEF / 50] + 2 + // = 31.8666... + expect(enemyPokemon.getAttackDamage(playerPokemon, allMoves[Moves.TACKLE]).damage).toBeCloseTo(31); + }); + + it("Attacks deal 1 damage at minimum", async () => { + game.override + .startingLevel(1) + .enemySpecies(Species.AGGRON); + + await game.classicMode.startBattle([Species.MAGIKARP]); + + const aggron = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.TACKLE); + + await game.phaseInterceptor.to("BerryPhase", false); + + // Lvl 1 0 Atk Magikarp Tackle vs. 0 HP / 0 Def Aggron: 1-1 (0.3 - 0.3%) -- possibly the worst move ever + expect(aggron.hp).toBe(aggron.getMaxHp() - 1); + }); + + it("Fixed-damage moves ignore damage multipliers", async () => { + game.override + .enemySpecies(Species.DRAGONITE) + .enemyAbility(Abilities.MULTISCALE); + + await game.classicMode.startBattle([Species.MAGIKARP]); + + const magikarp = game.scene.getPlayerPokemon()!; + const dragonite = game.scene.getEnemyPokemon()!; + + expect(dragonite.getAttackDamage(magikarp, allMoves[Moves.DRAGON_RAGE]).damage).toBe(40); + }); + + it("One-hit KO moves ignore damage multipliers", async () => { + game.override + .enemySpecies(Species.AGGRON) + .enemyAbility(Abilities.MULTISCALE); + + await game.classicMode.startBattle([Species.MAGIKARP]); + + const magikarp = game.scene.getPlayerPokemon()!; + const aggron = game.scene.getEnemyPokemon()!; + + expect(aggron.getAttackDamage(magikarp, allMoves[Moves.FISSURE]).damage).toBe(aggron.hp); }); it("When the user fails to use Jump Kick with Wonder Guard ability, the damage should be 1.", async () => { - game.override.enemySpecies(Species.GASTLY); - game.override.enemyMoveset(Moves.SPLASH); - game.override.starterSpecies(Species.SHEDINJA); - game.override.moveset([Moves.JUMP_KICK]); - game.override.ability(Abilities.WONDER_GUARD); + game.override + .enemySpecies(Species.GASTLY) + .ability(Abilities.WONDER_GUARD); - await game.startBattle(); + await game.classicMode.startBattle([Species.SHEDINJA]); const shedinja = game.scene.getPlayerPokemon()!; game.move.select(Moves.JUMP_KICK); - await game.phaseInterceptor.to(DamagePhase); + await game.phaseInterceptor.to("DamagePhase"); expect(shedinja.hp).toBe(shedinja.getMaxHp() - 1); }); @@ -49,21 +110,19 @@ describe("Round Down and Minimun 1 test in Damage Calculation", () => { it("Charizard with odd HP survives Stealth Rock damage twice", async () => { game.scene.arena.addTag(ArenaTagType.STEALTH_ROCK, 1, Moves.STEALTH_ROCK, 0); - game.override.seed("Charizard Stealth Rock test"); - game.override.enemySpecies(Species.CHARIZARD); - game.override.enemyAbility(Abilities.BLAZE); - game.override.starterSpecies(Species.PIKACHU); - game.override.enemyLevel(100); + game.override + .seed("Charizard Stealth Rock test") + .enemySpecies(Species.CHARIZARD) + .enemyAbility(Abilities.BLAZE); - await game.startBattle(); + await game.classicMode.startBattle([Species.PIKACHU]); const charizard = game.scene.getEnemyPokemon()!; - const maxHp = charizard.getMaxHp(); - const damage_prediction = toDmgValue(charizard.getMaxHp() / 2); - const currentHp = charizard.hp; - const expectedHP = maxHp - damage_prediction; - - expect(currentHp).toBe(expectedHP); + if (charizard.getMaxHp() % 2 === 1) { + expect(charizard.hp).toBeGreaterThan(charizard.getMaxHp() / 2); + } else { + expect(charizard.hp).toBe(charizard.getMaxHp() / 2); + } }); }); diff --git a/src/test/enemy_command.test.ts b/src/test/enemy_command.test.ts new file mode 100644 index 00000000000..39b77845ea9 --- /dev/null +++ b/src/test/enemy_command.test.ts @@ -0,0 +1,75 @@ +import { allMoves, MoveCategory } from "#app/data/move"; +import { Abilities } from "#app/enums/abilities"; +import { Moves } from "#app/enums/moves"; +import { Species } from "#app/enums/species"; +import { AiType, EnemyPokemon } from "#app/field/pokemon"; +import { randSeedInt } from "#app/utils"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; + +const TIMEOUT = 20 * 1000; +const NUM_TRIALS = 300; + +type MoveChoiceSet = { [key: number]: number }; + +function getEnemyMoveChoices(pokemon: EnemyPokemon, moveChoices: MoveChoiceSet): void { + // Use an unseeded random number generator in place of the mocked-out randBattleSeedInt + vi.spyOn(pokemon.scene, "randBattleSeedInt").mockImplementation((range, min?) => { + return randSeedInt(range, min); + }); + for (let i = 0; i < NUM_TRIALS; i++) { + const queuedMove = pokemon.getNextMove(); + moveChoices[queuedMove.move]++; + } + + for (const [moveId, count] of Object.entries(moveChoices)) { + console.log(`Move: ${allMoves[moveId].name} Count: ${count} (${count / NUM_TRIALS * 100}%)`); + } +} + +describe("Enemy Commands - Move Selection", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + game = new GameManager(phaserGame); + game.override.ability(Abilities.BALL_FETCH); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + it( + "should never use Status moves if an attack can KO", + async () => { + game.override + .enemySpecies(Species.ETERNATUS) + .enemyMoveset([Moves.ETERNABEAM, Moves.SLUDGE_BOMB, Moves.DRAGON_DANCE, Moves.COSMIC_POWER]) + .enemyAbility(Abilities.BALL_FETCH) + .ability(Abilities.BALL_FETCH) + .startingLevel(1) + .enemyLevel(100); + + await game.classicMode.startBattle([Species.MAGIKARP]); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + enemyPokemon.aiType = AiType.SMART_RANDOM; + + const moveChoices: MoveChoiceSet = {}; + const enemyMoveset = enemyPokemon.getMoveset(); + enemyMoveset.forEach(mv => moveChoices[mv!.moveId] = 0); + getEnemyMoveChoices(enemyPokemon, moveChoices); + + enemyMoveset.forEach(mv => { + if (mv?.getMove().category === MoveCategory.STATUS) { + expect(moveChoices[mv.moveId]).toBe(0); + } + }); + }, TIMEOUT + ); +});