From 2f26afd6fdd1b7421fb33d39b590c71e6111c8b3 Mon Sep 17 00:00:00 2001 From: innerthunder Date: Sat, 16 Nov 2024 12:31:52 -0800 Subject: [PATCH] Rewrite Move Effect Phase --- src/data/ability.ts | 9 +- src/data/arena-tag.ts | 2 - src/data/battler-tags.ts | 10 +- src/data/move.ts | 14 +- src/field/pokemon.ts | 223 ++----- src/phases/move-effect-phase.ts | 801 +++++++++++++------------ src/test/abilities/sheer_force.test.ts | 3 +- src/test/utils/helpers/moveHelper.ts | 10 +- 8 files changed, 489 insertions(+), 583 deletions(-) diff --git a/src/data/ability.ts b/src/data/ability.ts index b77b18947be..b207904c2c1 100644 --- a/src/data/ability.ts +++ b/src/data/ability.ts @@ -2943,8 +2943,13 @@ export class UserFieldBattlerTagImmunityAbAttr extends PreApplyBattlerTagImmunit export class BlockCritAbAttr extends AbAttr { apply(pokemon: Pokemon, passive: boolean, simulated: boolean, cancelled: Utils.BooleanHolder, args: any[]): boolean { - (args[0] as Utils.BooleanHolder).value = true; - return true; + const isCritical = args[0] as Utils.BooleanHolder; + + if (isCritical.value) { + isCritical.value = false; + return true; + } + return false; } } diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index 2f57650c65d..151461a4ebf 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -294,8 +294,6 @@ export class ConditionalProtectTag extends ArenaTag { if (!isProtected.value) { isProtected.value = true; if (!simulated) { - attacker.stopMultiHit(defender); - new CommonBattleAnim(CommonAnim.PROTECT, defender).play(arena.scene); arena.scene.queueMessage(i18next.t("arenaTag:conditionalProtectApply", { moveName: super.getMoveName(), pokemonNameWithAffix: getPokemonNameWithAffix(defender) })); } diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index ce25b56157c..31df2128638 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -1363,17 +1363,15 @@ export class ProtectedTag extends BattlerTag { if (lapseType === BattlerTagLapseType.CUSTOM) { new CommonBattleAnim(CommonAnim.PROTECT, pokemon).play(pokemon.scene); pokemon.scene.queueMessage(i18next.t("battlerTags:protectedLapse", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) })); - - // Stop multi-hit moves early - const effectPhase = pokemon.scene.getCurrentPhase(); - if (effectPhase instanceof MoveEffectPhase) { - effectPhase.stopMultiHit(pokemon); - } return true; } return super.lapse(pokemon, lapseType); } + + apply(pokemon: Pokemon, hitCheckResult: NumberHolder) { + + } } /** Base class for `BattlerTag`s that block damaging moves but not status moves */ diff --git a/src/data/move.ts b/src/data/move.ts index 2ac4d74b712..fb6076c7d98 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -307,6 +307,16 @@ export default class Move implements Localizable { return false; } + isFieldTarget(): boolean { + switch (this.moveTarget) { + case MoveTarget.BOTH_SIDES: + case MoveTarget.USER_SIDE: + case MoveTarget.ENEMY_SIDE: + return true; + } + return false; + } + isChargingMove(): this is ChargingMove { return false; } @@ -4037,8 +4047,8 @@ export class PresentPowerAttr extends VariablePowerAttr { } else if (70 < powerSeed && powerSeed <= 80) { (args[0] as Utils.NumberHolder).value = 120; } else if (80 < powerSeed && powerSeed <= 100) { - // If this move is multi-hit, disable all other hits - user.stopMultiHit(); + user.turnData.hitCount -= user.turnData.hitsLeft - 1; + user.turnData.hitsLeft = 1; target.scene.unshiftPhase(new PokemonHealPhase(target.scene, target.getBattlerIndex(), Utils.toDmgValue(target.getMaxHp() / 4), i18next.t("moveTriggers:regainedHealth", { pokemonName: getPokemonNameWithAffix(target) }), true)); } diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 07a958fe27b..807405c6a99 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -13,24 +13,24 @@ import { TypeDamageMultiplier, getTypeDamageMultiplier, getTypeRgb } from "#app/ import { Type } from "#enums/type"; import { getLevelTotalExp } from "#app/data/exp"; import { Stat, type PermanentStat, type BattleStat, type EffectiveStat, PERMANENT_STATS, BATTLE_STATS, EFFECTIVE_STATS } from "#enums/stat"; -import { DamageMoneyRewardModifier, EnemyDamageBoosterModifier, EnemyDamageReducerModifier, EnemyEndureChanceModifier, EnemyFusionChanceModifier, HiddenAbilityRateBoosterModifier, BaseStatModifier, PokemonFriendshipBoosterModifier, PokemonHeldItemModifier, PokemonNatureWeightModifier, ShinyRateBoosterModifier, SurviveDamageModifier, TempStatStageBoosterModifier, TempCritBoosterModifier, StatBoosterModifier, CritBoosterModifier, TerastallizeModifier, PokemonBaseStatFlatModifier, PokemonBaseStatTotalModifier, PokemonIncrementingStatModifier, EvoTrackerModifier, PokemonMultiHitModifier } from "#app/modifier/modifier"; +import { EnemyDamageBoosterModifier, EnemyFusionChanceModifier, HiddenAbilityRateBoosterModifier, BaseStatModifier, PokemonFriendshipBoosterModifier, PokemonHeldItemModifier, PokemonNatureWeightModifier, ShinyRateBoosterModifier, SurviveDamageModifier, TempStatStageBoosterModifier, TempCritBoosterModifier, StatBoosterModifier, CritBoosterModifier, TerastallizeModifier, PokemonBaseStatFlatModifier, PokemonBaseStatTotalModifier, PokemonIncrementingStatModifier, EvoTrackerModifier, PokemonMultiHitModifier, EnemyDamageReducerModifier } from "#app/modifier/modifier"; import { PokeballType } from "#enums/pokeball"; import { Gender } from "#app/data/gender"; import { initMoveAnim, loadMoveAnimAssets } from "#app/data/battle-anims"; import { Status, getRandomStatus } from "#app/data/status-effect"; import { pokemonEvolutions, pokemonPrevolutions, SpeciesFormEvolution, SpeciesEvolutionCondition, FusionSpeciesFormEvolution } from "#app/data/balance/pokemon-evolutions"; import { reverseCompatibleTms, tmSpecies, tmPoolTiers } from "#app/data/balance/tms"; -import { BattlerTag, BattlerTagLapseType, EncoreTag, GroundedTag, HighestStatBoostTag, SubstituteTag, TypeImmuneTag, getBattlerTag, SemiInvulnerableTag, TypeBoostTag, MoveRestrictionBattlerTag, ExposedTag, DragonCheerTag, CritBoostTag, TrappedTag, TarShotTag, AutotomizedTag, PowerTrickTag } from "../data/battler-tags"; +import { BattlerTag, BattlerTagLapseType, EncoreTag, GroundedTag, HighestStatBoostTag, SubstituteTag, TypeImmuneTag, getBattlerTag, SemiInvulnerableTag, MoveRestrictionBattlerTag, ExposedTag, DragonCheerTag, CritBoostTag, TrappedTag, TarShotTag, AutotomizedTag, PowerTrickTag } from "../data/battler-tags"; import { WeatherType } from "#enums/weather-type"; import { ArenaTagSide, NoCritTag, WeakenMoveScreenTag } from "#app/data/arena-tag"; -import { Ability, AbAttr, StatMultiplierAbAttr, BlockCritAbAttr, BonusCritAbAttr, BypassBurnDamageReductionAbAttr, FieldPriorityMoveImmunityAbAttr, IgnoreOpponentStatStagesAbAttr, MoveImmunityAbAttr, PreDefendFullHpEndureAbAttr, ReceivedMoveDamageMultiplierAbAttr, StabBoostAbAttr, StatusEffectImmunityAbAttr, TypeImmunityAbAttr, WeightMultiplierAbAttr, allAbilities, applyAbAttrs, applyStatMultiplierAbAttrs, applyPreApplyBattlerTagAbAttrs, applyPreAttackAbAttrs, applyPreDefendAbAttrs, applyPreSetStatusAbAttrs, UnsuppressableAbilityAbAttr, SuppressFieldAbilitiesAbAttr, NoFusionAbilityAbAttr, MultCritAbAttr, IgnoreTypeImmunityAbAttr, DamageBoostAbAttr, IgnoreTypeStatusEffectImmunityAbAttr, ConditionalCritAbAttr, applyFieldStatMultiplierAbAttrs, FieldMultiplyStatAbAttr, AddSecondStrikeAbAttr, UserFieldStatusEffectImmunityAbAttr, UserFieldBattlerTagImmunityAbAttr, BattlerTagImmunityAbAttr, MoveTypeChangeAbAttr, FullHpResistTypeAbAttr, applyCheckTrappedAbAttrs, CheckTrappedAbAttr, PostSetStatusAbAttr, applyPostSetStatusAbAttrs, InfiltratorAbAttr, AlliedFieldDamageReductionAbAttr, PostDamageAbAttr, applyPostDamageAbAttrs, PostDamageForceSwitchAbAttr, CommanderAbAttr, applyPostItemLostAbAttrs, PostItemLostAbAttr } from "#app/data/ability"; +import { Ability, AbAttr, StatMultiplierAbAttr, BlockCritAbAttr, BonusCritAbAttr, BypassBurnDamageReductionAbAttr, FieldPriorityMoveImmunityAbAttr, IgnoreOpponentStatStagesAbAttr, MoveImmunityAbAttr, PreDefendFullHpEndureAbAttr, ReceivedMoveDamageMultiplierAbAttr, StabBoostAbAttr, StatusEffectImmunityAbAttr, TypeImmunityAbAttr, WeightMultiplierAbAttr, allAbilities, applyAbAttrs, applyStatMultiplierAbAttrs, applyPreApplyBattlerTagAbAttrs, applyPreAttackAbAttrs, applyPreDefendAbAttrs, applyPreSetStatusAbAttrs, UnsuppressableAbilityAbAttr, SuppressFieldAbilitiesAbAttr, NoFusionAbilityAbAttr, MultCritAbAttr, IgnoreTypeImmunityAbAttr, DamageBoostAbAttr, IgnoreTypeStatusEffectImmunityAbAttr, ConditionalCritAbAttr, applyFieldStatMultiplierAbAttrs, FieldMultiplyStatAbAttr, AddSecondStrikeAbAttr, UserFieldStatusEffectImmunityAbAttr, UserFieldBattlerTagImmunityAbAttr, BattlerTagImmunityAbAttr, MoveTypeChangeAbAttr, FullHpResistTypeAbAttr, applyCheckTrappedAbAttrs, CheckTrappedAbAttr, PostSetStatusAbAttr, applyPostSetStatusAbAttrs, InfiltratorAbAttr, AlliedFieldDamageReductionAbAttr, PostDamageAbAttr, applyPostDamageAbAttrs, CommanderAbAttr, applyPostItemLostAbAttrs, PostItemLostAbAttr } from "#app/data/ability"; import PokemonData from "#app/system/pokemon-data"; import { BattlerIndex } from "#app/battle"; import { Mode } from "#app/ui/ui"; import PartyUiHandler, { PartyOption, PartyUiMode } from "#app/ui/party-ui-handler"; import SoundFade from "phaser3-rex-plugins/plugins/soundfade"; import { LevelMoves } from "#app/data/balance/pokemon-level-moves"; -import { DamageAchv, achvs } from "#app/system/achv"; +import { achvs } from "#app/system/achv"; import { DexAttr, StarterDataEntry, StarterMoveset } from "#app/system/game-data"; import { QuantizerCelebi, argbFromRgba, rgbaFromArgb } from "@material/material-color-utilities"; import { getNatureStatMultiplier } from "#app/data/nature"; @@ -50,7 +50,6 @@ import { BerryType } from "#enums/berry-type"; import { Biome } from "#enums/biome"; import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; -import { getPokemonNameWithAffix } from "#app/messages"; import { DamagePhase } from "#app/phases/damage-phase"; import { FaintPhase } from "#app/phases/faint-phase"; import { LearnMovePhase } from "#app/phases/learn-move-phase"; @@ -896,19 +895,18 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * who used it. * @param source the {@linkcode Pokemon} who using the move * @param move the {@linkcode Move} being used + * @param simulated if `true`, obtains the critical-hit stage quietly * @returns the final critical-hit stage value */ - getCritStage(source: Pokemon, move: Move): number { - const critStage = new Utils.IntegerHolder(0); + getCritStage(source: Pokemon, move: Move, simulated: boolean = true): number { + const critStage = new Utils.NumberHolder(0); applyMoveAttrs(HighCritAttr, source, this, move, critStage); this.scene.applyModifiers(CritBoosterModifier, source.isPlayer(), source, critStage); this.scene.applyModifiers(TempCritBoosterModifier, source.isPlayer(), critStage); const bonusCrit = new Utils.BooleanHolder(false); - //@ts-ignore - if (applyAbAttrs(BonusCritAbAttr, source, null, false, bonusCrit)) { // TODO: resolve ts-ignore. This is a promise. Checking a promise is bogus. - if (bonusCrit.value) { - critStage.value += 1; - } + applyAbAttrs(BonusCritAbAttr, source, null, simulated, bonusCrit); + if (bonusCrit.value) { + critStage.value += 1; } const critBoostTag = source.getTag(CritBoostTag); if (critBoostTag) { @@ -1591,8 +1589,8 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { /** * Calculates the type of a move when used by this Pokemon after * type-changing move and ability attributes have applied. - * @param move - {@linkcode Move} The move being used. - * @param simulated - If `true`, prevents showing abilities applied in this calculation. + * @param move The {@linkcode Move} being used. + * @param simulated If `true`, prevents showing abilities applied in this calculation. * @returns The {@linkcode Type} of the move after attributes are applied */ public getMoveType(move: Move, simulated: boolean = true): Type { @@ -1609,6 +1607,19 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { return moveTypeHolder.value as Type; } + /** + * Calculates the category of a move when used by this Pokemon after + * category-changing move effects are applied. + * @param target The {@linkcode Pokemon} targeted by the move + * @param move The {@linkcode Move} being used + * @returns The given move's final category when used against the target + */ + public getMoveCategory(target: Pokemon, move: Move): MoveCategory { + const moveCategory = new Utils.NumberHolder(move.category); + applyMoveAttrs(VariableMoveCategoryAttr, this, target, move, moveCategory); + return moveCategory.value; + } + /** * Calculates the effectiveness of a move against the Pokémon. @@ -2569,12 +2580,14 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * @param ignoreSourceAbility If `true`, ignores the attacking Pokemon's ability effects * @param isCritical If `true`, calculates damage for a critical hit. * @param simulated If `true`, suppresses changes to game state during the calculation. + * @param effectiveness If defined, this is used in place of calculated effectiveness values. * @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 { + getAttackDamage(source: Pokemon, move: Move, ignoreAbility: boolean = false, ignoreSourceAbility: boolean = false, + isCritical: boolean = false, simulated: boolean = true, effectiveness?: TypeDamageMultiplier): DamageCalculationResult { const damage = new Utils.NumberHolder(0); const defendingSide = this.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; @@ -2595,7 +2608,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * * Note that the source's abilities are not ignored here */ - const typeMultiplier = this.getMoveEffectiveness(source, move, ignoreAbility, simulated, cancelled); + const typeMultiplier = effectiveness ?? this.getMoveEffectiveness(source, move, ignoreAbility, simulated, cancelled); const isPhysical = moveCategory === MoveCategory.PHYSICAL; @@ -2793,147 +2806,32 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * 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 + * Calculates whether the given move critically hits against this Pokemon + * @param source the {@linkcode Pokemon} using the move + * @param move the {@linkcode Move} being used + * @param simulated if `true`, the calculation is resolved quietly (e.g. without Ability pop-ups) + * @returns `true` if the move critically hits; `false` otherwise */ - apply(source: Pokemon, move: Move): HitResult { + getCriticalHitResult(source: Pokemon, move: Move, simulated: boolean = true): boolean { const defendingSide = this.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; - const moveCategory = new Utils.NumberHolder(move.category); - applyMoveAttrs(VariableMoveCategoryAttr, source, this, move, moveCategory); - if (moveCategory.value === 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) })); - } - 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); - } - - 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", { pokemonName: getPokemonNameWithAffix(this) })); - } - } - return result; - } - - // In case of fatal damage, this tag would have gotten cleared before we could lapse it. - const destinyTag = this.getTag(BattlerTagType.DESTINY_BOND); - const grudgeTag = this.getTag(BattlerTagType.GRUDGE); - - 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, source); - - if (damage > 0) { - if (source.isPlayer()) { - this.scene.validateAchvs(DamageAchv, new Utils.NumberHolder(damage)); - if (damage > this.scene.gameData.gameStats.highestDamage) { - this.scene.gameData.gameStats.highestDamage = damage; - } - } - source.turnData.totalDamageDealt += damage; - source.turnData.singleHitDamageDealt = damage; - this.turnData.damageTaken += damage; - this.battleData.hitCount++; - - // Multi-Lens and Parental Bond check for Wimp Out/Emergency Exit - if (this.hasAbilityWithAttr(PostDamageForceSwitchAbAttr)) { - const multiHitModifier = source.getHeldItems().find(m => m instanceof PokemonMultiHitModifier); - if (multiHitModifier || source.hasAbilityWithAttr(AddSecondStrikeAbAttr)) { - applyPostDamageAbAttrs(PostDamageAbAttr, this, damage, this.hasPassive(), false, [], source); - } - } - - 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, new Utils.NumberHolder(damage)); - } - } - } - - if (isCritical) { - this.scene.queueMessage(i18next.t("battle:hitResultCriticalHit")); - } - - // 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, destinyTag, grudgeTag, source)); - - this.destroySubstitute(); - this.lapseTag(BattlerTagType.COMMANDED); - this.resetSummonData(); - } - - return result; + const noCritTag = this.scene.arena.getTagOnSide(NoCritTag, defendingSide); + if (noCritTag || Overrides.NEVER_CRIT_OVERRIDE || move.hasAttr(FixedDamageAttr)) { + return false; } + + const isCritical = new Utils.BooleanHolder(false); + if (source.getTag(BattlerTagType.ALWAYS_CRIT)) { + isCritical.value = true; + } + applyMoveAttrs(CritOnlyAttr, source, this, move, isCritical); + applyAbAttrs(ConditionalCritAbAttr, source, null, simulated, isCritical, this, move); + if (!isCritical.value) { + const critChance = [ 24, 8, 2, 1 ][Math.max(0, Math.min(this.getCritStage(source, move, false), 3))]; + isCritical.value = critChance === 1 || !this.scene.randBattleSeedInt(critChance); + } + applyAbAttrs(BlockCritAbAttr, this, null, simulated, isCritical); + + return isCritical.value; } /** @@ -3258,17 +3156,6 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { return this.summonData.moveQueue; } - /** - * If this Pokemon is using a multi-hit move, cancels all subsequent strikes - * @param {Pokemon} target If specified, this only cancels subsequent strikes against the given target - */ - stopMultiHit(target?: Pokemon): void { - const effectPhase = this.scene.getCurrentPhase(); - if (effectPhase instanceof MoveEffectPhase && effectPhase.getUserPokemon() === this) { - effectPhase.stopMultiHit(target); - } - } - changeForm(formChange: SpeciesFormChange): Promise { return new Promise(resolve => { this.formIndex = Math.max(this.species.forms.findIndex(f => f.formKey === formChange.formKey), 0); @@ -3558,7 +3445,11 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * cancel the attack's subsequent hits. */ if (effect === StatusEffect.SLEEP || effect === StatusEffect.FREEZE) { - this.stopMultiHit(); + const currentPhase = this.scene.getCurrentPhase(); + if (currentPhase instanceof MoveEffectPhase && currentPhase.getUserPokemon() === this) { + this.turnData.hitCount -= this.turnData.hitsLeft - 1; + this.turnData.hitsLeft = 1; + } } if (asPhase) { diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index afc8dd0475d..531b753227d 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -4,13 +4,15 @@ import { AddSecondStrikeAbAttr, AlwaysHitAbAttr, applyPostAttackAbAttrs, + applyPostDamageAbAttrs, applyPostDefendAbAttrs, applyPreAttackAbAttrs, IgnoreMoveEffectsAbAttr, MaxMultiHitAbAttr, PostAttackAbAttr, + PostDamageAbAttr, + PostDamageForceSwitchAbAttr, PostDefendAbAttr, - TypeImmunityAbAttr, } from "#app/data/ability"; import { ArenaTagSide, ConditionalProtectTag } from "#app/data/arena-tag"; import { MoveAnim } from "#app/data/battle-anims"; @@ -20,6 +22,7 @@ import { ProtectedTag, SemiInvulnerableTag, SubstituteTag, + TypeBoostTag, } from "#app/data/battler-tags"; import { applyFilteredMoveAttrs, @@ -40,29 +43,47 @@ import { OneHitKOAttr, OverrideMoveEffectAttr, ToxicAccuracyAttr, - VariableTargetAttr, } from "#app/data/move"; import { SpeciesFormChangePostMoveTrigger } from "#app/data/pokemon-forms"; import { Type } from "#enums/type"; -import Pokemon, { HitResult, MoveResult, PokemonMove } from "#app/field/pokemon"; +import Pokemon, { DamageResult, HitResult, MoveResult, PokemonMove, TurnMove } from "#app/field/pokemon"; import { getPokemonNameWithAffix } from "#app/messages"; import { ContactHeldItemTransferChanceModifier, + DamageMoneyRewardModifier, EnemyAttackStatusEffectChanceModifier, + EnemyEndureChanceModifier, FlinchChanceModifier, HitHealModifier, PokemonMultiHitModifier, } from "#app/modifier/modifier"; import { PokemonPhase } from "#app/phases/pokemon-phase"; -import { BooleanHolder, executeIf, NumberHolder } from "#app/utils"; +import { BooleanHolder, executeIf, isNullOrUndefined, NumberHolder } from "#app/utils"; import { BattlerTagType } from "#enums/battler-tag-type"; import { Moves } from "#enums/moves"; import i18next from "i18next"; +import { TypeDamageMultiplier } from "#app/data/type"; +import { DamageAchv } from "#app/system/achv"; +import { FaintPhase } from "./faint-phase"; + +type HitCheckEntry = [ HitCheckResult, TypeDamageMultiplier ]; export class MoveEffectPhase extends PokemonPhase { public move: PokemonMove; protected targets: BattlerIndex[]; + private hitChecks: HitCheckEntry[]; + private moveHistoryEntry: TurnMove; + + /** MOVE EFFECT TRIGGER CONDITIONS */ + + /** Is this the first strike of a move? */ + private firstHit: boolean; + /** Is this the last strike of a move? */ + private lastHit: boolean; + /** Is this the first target to be hit by this strike? */ + private firstTarget: boolean = true; + constructor(scene: BattleScene, battlerIndex: BattlerIndex, targets: BattlerIndex[], move: PokemonMove) { super(scene, battlerIndex); this.move = move; @@ -76,9 +97,11 @@ export class MoveEffectPhase extends PokemonPhase { targets.splice(i, i + 1); } this.targets = targets; + + this.hitChecks = Array(this.targets.length).fill([ HitCheckResult.PENDING, 0 ]); } - public override start(): void { + public override start() { super.start(); /** The Pokemon using this phase's invoked move */ @@ -86,7 +109,7 @@ export class MoveEffectPhase extends PokemonPhase { /** All Pokemon targeted by this phase's invoked move */ const targets = this.getTargets(); - if (!user) { + if (isNullOrUndefined(user)) { return super.end(); } @@ -98,265 +121,153 @@ export class MoveEffectPhase extends PokemonPhase { /** * Does an effect from this move override other effects on this turn? - * e.g. Charging moves (Fly, etc.) on their first turn of use. + * e.g. Metronome/Nature Power/etc. when queueing a generated move. */ const overridden = new BooleanHolder(false); /** The {@linkcode Move} object from {@linkcode allMoves} invoked by this phase */ const move = this.move.getMove(); - // Assume single target for override - applyMoveAttrs(OverrideMoveEffectAttr, user, this.getFirstTarget() ?? null, move, overridden, this.move.virtual).then(() => { - // If other effects were overriden, stop this phase before they can be applied - if (overridden.value) { - return this.end(); - } + // This assumes single target for override + applyMoveAttrs(OverrideMoveEffectAttr, user, this.getFirstTarget() ?? null, move, overridden, this.move.virtual); + // If other effects were overridden, stop this phase before they can be applied + if (overridden.value) { + return this.end(); + } - user.lapseTags(BattlerTagLapseType.MOVE_EFFECT); + user.lapseTags(BattlerTagLapseType.MOVE_EFFECT); - /** - * If this phase is for the first hit of the invoked move, - * resolve the move's total hit count. This block combines the - * effects of the move itself, Parental Bond, and Multi-Lens to do so. - */ - if (user.turnData.hitsLeft === -1) { - const hitCount = new NumberHolder(1); - // Assume single target for multi hit - applyMoveAttrs(MultiHitAttr, user, this.getFirstTarget() ?? null, move, hitCount); - // If Parental Bond is applicable, add another hit - applyPreAttackAbAttrs(AddSecondStrikeAbAttr, user, null, move, false, hitCount, null); - // If Multi-Lens is applicable, add hits equal to the number of held Multi-Lenses - this.scene.applyModifiers(PokemonMultiHitModifier, user.isPlayer(), user, move.id, hitCount); - // Set the user's relevant turnData fields to reflect the final hit count - user.turnData.hitCount = hitCount.value; - user.turnData.hitsLeft = hitCount.value; - } + this.moveHistoryEntry = { move: move.id, targets: this.targets, result: MoveResult.PENDING, virtual: this.move.virtual }; - /** - * Log to be entered into the user's move history once the move result is resolved. - * Note that `result` (a {@linkcode MoveResult}) logs whether the move was successfully - * used in the sense of "Does it have an effect on the user?". - */ - const moveHistoryEntry = { move: this.move.moveId, targets: this.targets, result: MoveResult.PENDING, virtual: this.move.virtual }; + targets.forEach((t, i) => this.hitChecks[i] = this.hitCheck(t)); - /** - * Stores results of hit checks of the invoked move against all targets, organized by battler index. - * @see {@linkcode hitCheck} - */ - const targetHitChecks = Object.fromEntries(targets.map(p => [ p.getBattlerIndex(), this.hitCheck(p) ])); - const hasActiveTargets = targets.some(t => t.isActive(true)); + if (!targets.some(t => t.isActive(true))) { + this.scene.queueMessage(i18next.t("battle:attackFailed")); + this.moveHistoryEntry.result = MoveResult.FAIL; + } - /** Check if the target is immune via ability to the attacking move, and NOT in semi invulnerable state */ - const isImmune = targets[0]?.hasAbilityWithAttr(TypeImmunityAbAttr) - && (targets[0]?.getAbility()?.getAttrs(TypeImmunityAbAttr)?.[0]?.getImmuneType() === user.getMoveType(move)) - && !targets[0]?.getTag(SemiInvulnerableTag); - - /** - * If no targets are left for the move to hit (FAIL), or the invoked move is single-target - * (and not random target) and failed the hit check against its target (MISS), log the move - * as FAILed or MISSed (depending on the conditions above) and end this phase. - */ - if (!hasActiveTargets || (!move.hasAttr(VariableTargetAttr) && !move.isMultiTarget() && !targetHitChecks[this.targets[0]] && !targets[0].getTag(ProtectedTag) && !isImmune)) { - this.stopMultiHit(); - if (hasActiveTargets) { - this.scene.queueMessage(i18next.t("battle:attackMissed", { pokemonNameWithAffix: this.getFirstTarget() ? getPokemonNameWithAffix(this.getFirstTarget()!) : "" })); - moveHistoryEntry.result = MoveResult.MISS; - applyMoveAttrs(MissEffectAttr, user, null, this.move.getMove()); - } else { - this.scene.queueMessage(i18next.t("battle:attackFailed")); - moveHistoryEntry.result = MoveResult.FAIL; - } - user.pushMoveHistory(moveHistoryEntry); - return this.end(); - } - - /** All move effect attributes are chained together in this array to be applied asynchronously. */ - const applyAttrs: Promise[] = []; + if (this.hitChecks.some(hc => hc[0] === HitCheckResult.HIT)) { + this.moveHistoryEntry.result = MoveResult.SUCCESS; + } else if (this.hitChecks.every(hc => hc[0] === HitCheckResult.MISS)) { + this.moveHistoryEntry.result = MoveResult.MISS; + } else { + this.moveHistoryEntry.result = MoveResult.FAIL; + } + // If the move has a post-target effect (e.g. Explosion), but doesn't + // successfully hit a target, play the move's animation and return + if (move.getAttrs(MoveEffectAttr).some(attr => attr.trigger === MoveEffectTrigger.POST_TARGET) + && this.hitChecks.every(hc => hc[1] === 0, this)) { const playOnEmptyField = this.scene.currentBattle?.mysteryEncounter?.hasBattleAnimationsWithoutTargets ?? false; - // Move animation only needs one target - new MoveAnim(move.id as Moves, user, this.getFirstTarget()!.getBattlerIndex()!, playOnEmptyField).play(this.scene, move.hitsSubstitute(user, this.getFirstTarget()!), () => { - /** Has the move successfully hit a target (for damage) yet? */ - let hasHit: boolean = false; - for (const target of targets) { - // Prevent ENEMY_SIDE targeted moves from occurring twice in double battles - if (move.moveTarget === MoveTarget.ENEMY_SIDE && target !== targets[targets.length - 1]) { - continue; + return new MoveAnim(move.id, user, this.getFirstTarget()!.getBattlerIndex(), playOnEmptyField).play(this.scene, false, () => + this.triggerMoveEffects(MoveEffectTrigger.POST_TARGET, user, null).then(() => this.end()) + ); + } + + // If this phase represents the first strike of the given move, + // log the move in the user's move history. + if (user.turnData.hitsLeft === -1) { + user.pushMoveHistory(this.moveHistoryEntry); + } + + console.log(this.hitChecks); + + targets.forEach((target, i) => { + const [ hitCheckResult, effectiveness ] = this.hitChecks[i]; + + switch (hitCheckResult) { + case HitCheckResult.HIT: + this.applyMoveEffects(target, effectiveness); + this.firstTarget = false; + break; + case HitCheckResult.NO_EFFECT: + if (move.id === Moves.SHEER_COLD) { + this.scene.queueMessage(i18next.t("battle:hitResultImmune", { pokemonName: getPokemonNameWithAffix(target) })); + } else { + this.scene.queueMessage(i18next.t("battle:hitResultNoEffect", { pokemonName: getPokemonNameWithAffix(target) })); } + case HitCheckResult.PROTECTED: + case HitCheckResult.NO_EFFECT_NO_MESSAGE: + applyMoveAttrs(NoEffectAttr, user, target, move); + break; + case HitCheckResult.MISS: + this.scene.queueMessage(i18next.t("battle:attackMissed", { pokemonNameWithAffix: this.getFirstTarget() ? getPokemonNameWithAffix(this.getFirstTarget()!) : "" })); + applyMoveAttrs(MissEffectAttr, user, target, move); + break; + case HitCheckResult.PENDING: + case HitCheckResult.ERROR: + console.log(`Unexpected hit check result ${HitCheckResult[hitCheckResult]}. Aborting phase.`); + return this.end(); + } + }); - /** The {@linkcode ArenaTagSide} to which the target belongs */ - const targetSide = target.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; - /** Has the invoked move been cancelled by conditional protection (e.g Quick Guard)? */ - const hasConditionalProtectApplied = new BooleanHolder(false); - /** Does the applied conditional protection bypass Protect-ignoring effects? */ - const bypassIgnoreProtect = new BooleanHolder(false); - /** If the move is not targeting a Pokemon on the user's side, try to apply conditional protection effects */ - if (!this.move.getMove().isAllyTarget()) { - this.scene.arena.applyTagsForSide(ConditionalProtectTag, targetSide, false, hasConditionalProtectApplied, user, target, move.id, bypassIgnoreProtect); - } + const doPostTarget = this.lastHit ? this.triggerMoveEffects(MoveEffectTrigger.POST_TARGET, user, null) : Promise.resolve(); + doPostTarget.then(() => { + this.updateSubstitutes(); + this.end(); + }); + } - /** Is the target protected by Protect, etc. or a relevant conditional protection effect? */ - const isProtected = ( - bypassIgnoreProtect.value - || !this.move.getMove().checkFlag(MoveFlags.IGNORE_PROTECT, user, target)) - && (hasConditionalProtectApplied.value - || (!target.findTags(t => t instanceof DamageProtectedTag).length - && target.findTags(t => t instanceof ProtectedTag).find(t => target.lapseTag(t.tagType))) - || (this.move.getMove().category !== MoveCategory.STATUS - && target.findTags(t => t instanceof DamageProtectedTag).find(t => target.lapseTag(t.tagType)))); + protected applyMoveEffects(target: Pokemon, effectiveness: TypeDamageMultiplier): void { + const user = this.getUserPokemon(); + const move = this.move.getMove(); - /** Is the pokemon immune due to an ablility, and also not in a semi invulnerable state? */ - const isImmune = target.hasAbilityWithAttr(TypeImmunityAbAttr) - && (target.getAbility()?.getAttrs(TypeImmunityAbAttr)?.[0]?.getImmuneType() === user.getMoveType(move)) - && !target.getTag(SemiInvulnerableTag); + if (isNullOrUndefined(user)) { + return; + } - /** Is the target hidden by the effects of its Commander ability? */ - const isCommanding = this.scene.currentBattle.double && target.getAlly()?.getTag(BattlerTagType.COMMANDED)?.getSourcePokemon(this.scene) === target; + // prevent field-targeted moves from activating multiple times + if (move.isFieldTarget() && target !== this.getTargets()[this.targets.length - 1]) { + return; + } - /** - * If the move missed a target, stop all future hits against that target - * and move on to the next target (if there is one). - */ - if (isCommanding || (!isImmune && !isProtected && !targetHitChecks[target.getBattlerIndex()])) { - this.stopMultiHit(target); - this.scene.queueMessage(i18next.t("battle:attackMissed", { pokemonNameWithAffix: getPokemonNameWithAffix(target) })); - if (moveHistoryEntry.result === MoveResult.PENDING) { - moveHistoryEntry.result = MoveResult.MISS; - } - user.pushMoveHistory(moveHistoryEntry); - applyMoveAttrs(MissEffectAttr, user, null, move); - continue; - } + /** + * If this phase is for the first hit of the invoked move, + * resolve the move's total hit count. This block combines the + * effects of the move itself, Parental Bond, and Multi-Lens to do so. + */ + if (user.turnData.hitsLeft === -1) { + const hitCount = new NumberHolder(1); + // Assume single target for multi hit + applyMoveAttrs(MultiHitAttr, user, this.getFirstTarget() ?? null, move, hitCount); + // If Parental Bond is applicable, add another hit + applyPreAttackAbAttrs(AddSecondStrikeAbAttr, user, null, move, false, hitCount, null); + // If Multi-Lens is applicable, add hits equal to the number of held Multi-Lenses + this.scene.applyModifiers(PokemonMultiHitModifier, user.isPlayer(), user, move.id, hitCount); + // Set the user's relevant turnData fields to reflect the final hit count + user.turnData.hitCount = hitCount.value; + user.turnData.hitsLeft = hitCount.value; + } - /** Does this phase represent the invoked move's first strike? */ - const firstHit = (user.turnData.hitsLeft === user.turnData.hitCount); + this.firstHit = user.turnData.hitsLeft === user.turnData.hitCount; + this.lastHit = user.turnData.hitsLeft === 1 || !this.getTargets().some(t => t.isActive(true)); - // Only log the move's result on the first strike - if (firstHit) { - user.pushMoveHistory(moveHistoryEntry); - } + const playOnEmptyField = this.scene.currentBattle?.mysteryEncounter?.hasBattleAnimationsWithoutTargets ?? false; + return new MoveAnim(move.id as Moves, user, this.getFirstTarget()!.getBattlerIndex()!, playOnEmptyField).play(this.scene, move.hitsSubstitute(user, this.getFirstTarget()!), async () => { + await this.triggerMoveEffects(MoveEffectTrigger.PRE_APPLY, user, target); - /** - * Since all fail/miss checks have applied, the move is considered successfully applied. - * It's worth noting that if the move has no effect or is protected against, this assignment - * is overwritten and the move is logged as a FAIL. - */ - moveHistoryEntry.result = MoveResult.SUCCESS; + const hitResult = this.applyMove(target, effectiveness); - /** - * Stores the result of applying the invoked move to the target. - * If the target is protected, the result is always `NO_EFFECT`. - * Otherwise, the hit result is based on type effectiveness, immunities, - * and other factors that may negate the attack or status application. - * - * Internally, the call to {@linkcode Pokemon.apply} is where damage is calculated - * (for attack moves) and the target's HP is updated. However, this isn't - * made visible to the user until the resulting {@linkcode DamagePhase} - * is invoked. - */ - const hitResult = !isProtected ? target.apply(user, move) : HitResult.NO_EFFECT; + /** Does {@linkcode hitResult} indicate that damage was dealt to the target? */ + const dealsDamage = [ + HitResult.EFFECTIVE, + HitResult.SUPER_EFFECTIVE, + HitResult.NOT_VERY_EFFECTIVE, + HitResult.ONE_HIT_KO + ].includes(hitResult); - /** Does {@linkcode hitResult} indicate that damage was dealt to the target? */ - const dealsDamage = [ - HitResult.EFFECTIVE, - HitResult.SUPER_EFFECTIVE, - HitResult.NOT_VERY_EFFECTIVE, - HitResult.ONE_HIT_KO - ].includes(hitResult); + await this.triggerMoveEffects(MoveEffectTrigger.POST_APPLY, user, target); + this.applyHeldItemFlinchCheck(user, target, dealsDamage); + await this.triggerMoveEffects(MoveEffectTrigger.HIT, user, target); + await this.applyOnGetHitAbEffects(user, target, hitResult); + await applyPostAttackAbAttrs(PostAttackAbAttr, user, target, move, hitResult); - /** Is this target the first one hit by the move on its current strike? */ - const firstTarget = dealsDamage && !hasHit; - if (firstTarget) { - hasHit = true; - } + if (move instanceof AttackMove) { + this.scene.applyModifiers(ContactHeldItemTransferChanceModifier, this.player, user, target); + } - /** - * If the move has no effect on the target (i.e. the target is protected or immune), - * change the logged move result to FAIL. - */ - if (hitResult === HitResult.NO_EFFECT) { - moveHistoryEntry.result = MoveResult.FAIL; - } - - /** Does this phase represent the invoked move's last strike? */ - const lastHit = (user.turnData.hitsLeft === 1 || !this.getFirstTarget()?.isActive()); - - /** - * If the user can change forms by using the invoked move, - * it only changes forms after the move's last hit - * (see Relic Song's interaction with Parental Bond when used by Meloetta). - */ - if (lastHit) { - this.scene.triggerPokemonFormChange(user, SpeciesFormChangePostMoveTrigger); - } - - /** - * Create a Promise that applys *all* effects from the invoked move's MoveEffectAttrs. - * These are ordered by trigger type (see {@linkcode MoveEffectTrigger}), and each trigger - * type requires different conditions to be met with respect to the move's hit result. - */ - const k = new Promise((resolve) => { - //Start promise chain and apply PRE_APPLY move attributes - let promiseChain: Promise = applyFilteredMoveAttrs((attr: MoveAttr) => - attr instanceof MoveEffectAttr - && attr.trigger === MoveEffectTrigger.PRE_APPLY - && (!attr.firstHitOnly || firstHit) - && (!attr.lastHitOnly || lastHit) - && hitResult !== HitResult.NO_EFFECT, user, target, move); - - /** Don't complete if the move failed */ - if (hitResult === HitResult.FAIL) { - return resolve(); - } - - /** Apply Move/Ability Effects in correct order */ - promiseChain = promiseChain - .then(this.applySelfTargetEffects(user, target, firstHit, lastHit)); - - if (hitResult !== HitResult.NO_EFFECT) { - promiseChain - .then(this.applyPostApplyEffects(user, target, firstHit, lastHit)) - .then(this.applyHeldItemFlinchCheck(user, target, dealsDamage)) - .then(this.applySuccessfulAttackEffects(user, target, firstHit, lastHit, !!isProtected, hitResult, firstTarget)) - .then(() => resolve()); - } else { - promiseChain - .then(() => applyMoveAttrs(NoEffectAttr, user, null, move)) - .then(resolve); - } - }); - - applyAttrs.push(k); - } - - // Apply the move's POST_TARGET effects on the move's last hit, after all targeted effects have resolved - const postTarget = (user.turnData.hitsLeft === 1 || !this.getFirstTarget()?.isActive()) ? - applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && attr.trigger === MoveEffectTrigger.POST_TARGET, user, null, move) : - null; - - if (postTarget) { - if (applyAttrs.length) { // If there is a pending asynchronous move effect, do this after - applyAttrs[applyAttrs.length - 1].then(() => postTarget); - } else { // Otherwise, push a new asynchronous move effect - applyAttrs.push(postTarget); - } - } - - // Wait for all move effects to finish applying, then end this phase - Promise.allSettled(applyAttrs).then(() => { - /** - * Remove the target's substitute (if it exists and has expired) - * after all targeted effects have applied. - * This prevents blocked effects from applying until after this hit resolves. - */ - targets.forEach(target => { - const substitute = target.getTag(SubstituteTag); - if (substitute && substitute.hp <= 0) { - target.lapseTag(BattlerTagType.SUBSTITUTE); - } - }); - this.end(); - }); - }); + if (this.lastHit) { + this.scene.triggerPokemonFormChange(user, SpeciesFormChangePostMoveTrigger); + } }); } @@ -371,6 +282,8 @@ export class MoveEffectPhase extends PokemonPhase { */ if (user) { if (user.turnData.hitsLeft && --user.turnData.hitsLeft >= 1 && this.getFirstTarget()?.isActive()) { + // Only apply the next phase to previously hit targets + this.targets = this.targets.filter((_, i) => this.hitChecks[i][0] === HitCheckResult.HIT); this.scene.unshiftPhase(this.getNewHitPhase()); } else { // Queue message for number of hits made by multi-move @@ -390,60 +303,135 @@ export class MoveEffectPhase extends PokemonPhase { } /** - * Apply self-targeted effects that trigger `POST_APPLY` - * - * @param user - The {@linkcode Pokemon} using this phase's invoked move - * @param target - {@linkcode Pokemon} the current target of this phase's invoked move - * @param firstHit - `true` if this is the first hit in a multi-hit attack - * @param lastHit - `true` if this is the last hit in a multi-hit attack - * @returns a function intended to be passed into a `then()` call. + * Triggers move effects of the given move effect trigger. + * @param triggerType The {@linkcode MoveEffectTrigger} being applied + * @param user The {@linkcode Pokemon} using the move + * @param target The {@linkcode Pokemon} targeted by the move + * @param selfTarget If defined, limits the effects triggered to either self-targeted + * effects (if set to `true`) or targeted effects (if set to `false`). + * @returns a `Promise` applying the relevant move effects. */ - protected applySelfTargetEffects(user: Pokemon, target: Pokemon, firstHit: boolean, lastHit: boolean): () => Promise { - return () => applyFilteredMoveAttrs((attr: MoveAttr) => - attr instanceof MoveEffectAttr - && attr.trigger === MoveEffectTrigger.POST_APPLY - && attr.selfTarget - && (!attr.firstHitOnly || firstHit) - && (!attr.lastHitOnly || lastHit), user, target, this.move.getMove()); - } - - /** - * Applies non-self-targeted effects that trigger `POST_APPLY` - * (i.e. Smelling Salts curing Paralysis, and the forced switch from U-Turn, Dragon Tail, etc) - * @param user - The {@linkcode Pokemon} using this phase's invoked move - * @param target - {@linkcode Pokemon} the current target of this phase's invoked move - * @param firstHit - `true` if this is the first hit in a multi-hit attack - * @param lastHit - `true` if this is the last hit in a multi-hit attack - * @returns a function intended to be passed into a `then()` call. - */ - protected applyPostApplyEffects(user: Pokemon, target: Pokemon, firstHit: boolean, lastHit: boolean): () => Promise { - return () => applyFilteredMoveAttrs((attr: MoveAttr) => - attr instanceof MoveEffectAttr - && attr.trigger === MoveEffectTrigger.POST_APPLY - && !attr.selfTarget - && (!attr.firstHitOnly || firstHit) - && (!attr.lastHitOnly || lastHit), user, target, this.move.getMove()); - } - - /** - * Applies effects that trigger on HIT - * (i.e. Final Gambit, Power-Up Punch, Drain Punch) - * @param user - The {@linkcode Pokemon} using this phase's invoked move - * @param target - {@linkcode Pokemon} the current target of this phase's invoked move - * @param firstHit - `true` if this is the first hit in a multi-hit attack - * @param lastHit - `true` if this is the last hit in a multi-hit attack - * @param firstTarget - `true` if {@linkcode target} is the first target hit by this strike of {@linkcode move} - * @returns a function intended to be passed into a `then()` call. - */ - protected applyOnHitEffects(user: Pokemon, target: Pokemon, firstHit : boolean, lastHit: boolean, firstTarget: boolean): Promise { + protected triggerMoveEffects(triggerType: MoveEffectTrigger, user: Pokemon, target: Pokemon | null, selfTarget?: boolean): Promise { return applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr - && attr.trigger === MoveEffectTrigger.HIT - && (!attr.firstHitOnly || firstHit) - && (!attr.lastHitOnly || lastHit) - && (!attr.firstTargetOnly || firstTarget), user, target, this.move.getMove()); + && attr.trigger === triggerType + && (isNullOrUndefined(selfTarget) || (attr.selfTarget === selfTarget)) + && (!attr.firstHitOnly || this.firstHit) + && (!attr.lastHitOnly || this.lastHit) + && (!attr.firstTargetOnly || this.firstTarget), + user, target, this.move.getMove()); } + /** + * Apply the results of this phase's move to the given target + * @param target The {@linkcode Pokemon} struck by the move + */ + protected applyMove(target: Pokemon, effectiveness: TypeDamageMultiplier): HitResult { + /** The {@linkcode Pokemon} using the move */ + const user = this.getUserPokemon()!; + + /** The {@linkcode Move} being used */ + const move = this.move.getMove(); + const moveCategory = user.getMoveCategory(target, move); + + if (moveCategory === MoveCategory.STATUS) { + return HitResult.STATUS; + } + + const isCritical = target.getCriticalHitResult(user, move, false); + + const { result: result, damage: dmg } = target.getAttackDamage(user, move, false, false, isCritical, false, effectiveness); + + const typeBoost = user.findTag(t => t instanceof TypeBoostTag && t.boostedType === user.getMoveType(move)) as TypeBoostTag; + if (typeBoost?.oneUse) { + user.removeTag(typeBoost.tagType); + } + + // In case of fatal damage, this tag would have gotten cleared before we could lapse it. + const destinyTag = target.getTag(BattlerTagType.DESTINY_BOND); + const grudgeTag = target.getTag(BattlerTagType.GRUDGE); + + const isOneHitKo = result === HitResult.ONE_HIT_KO; + + if (dmg) { + target.lapseTags(BattlerTagLapseType.HIT); + + const substitute = target.getTag(SubstituteTag); + const isBlockedBySubstitute = !!substitute && move.hitsSubstitute(user, target); + if (isBlockedBySubstitute) { + substitute.hp -= dmg; + } + if (!target.isPlayer() && dmg >= target.hp) { + this.scene.applyModifiers(EnemyEndureChanceModifier, false, target); + } + + /** + * 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 = target.damageAndUpdate(isBlockedBySubstitute ? 0 : dmg, result as DamageResult, isCritical, isOneHitKo, isOneHitKo, true, user); + + if (damage > 0) { + if (user.isPlayer()) { + this.scene.validateAchvs(DamageAchv, new NumberHolder(damage)); + if (damage > this.scene.gameData.gameStats.highestDamage) { + this.scene.gameData.gameStats.highestDamage = damage; + } + } + user.turnData.totalDamageDealt += damage; + user.turnData.singleHitDamageDealt = damage; + target.turnData.damageTaken += damage; + target.battleData.hitCount++; + + // Multi-Lens and Parental Bond check for Wimp Out/Emergency Exit + if (target.hasAbilityWithAttr(PostDamageForceSwitchAbAttr)) { + const multiHitModifier = user.getHeldItems().find(m => m instanceof PokemonMultiHitModifier); + if (multiHitModifier || user.hasAbilityWithAttr(AddSecondStrikeAbAttr)) { + applyPostDamageAbAttrs(PostDamageAbAttr, target, damage, target.hasPassive(), false, [], user); + } + } + + const attackResult = { move: move.id, result: result as DamageResult, damage: damage, critical: isCritical, sourceId: user.id, sourceBattlerIndex: user.getBattlerIndex() }; + target.turnData.attacksReceived.unshift(attackResult); + if (user.isPlayer() && !target.isPlayer()) { + this.scene.applyModifiers(DamageMoneyRewardModifier, true, user, new NumberHolder(damage)); + } + } + } + + if (isCritical) { + this.scene.queueMessage(i18next.t("battle:hitResultCriticalHit")); + } + + // want to include is.Fainted() in case multi hit move ends early, still want to render message + if (user.turnData.hitsLeft === 1 || target.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 (target.isFainted()) { + // set splice index here, so future scene queues happen before FaintedPhase + this.scene.setPhaseQueueSplice(); + this.scene.unshiftPhase(new FaintPhase(this.scene, target.getBattlerIndex(), isOneHitKo, destinyTag, grudgeTag, user)); + + target.destroySubstitute(); + target.lapseTag(BattlerTagType.COMMANDED); + target.resetSummonData(); + } + + return result; + } + + /** * Applies reactive effects that occur when a Pokémon is hit. * (i.e. Effect Spore, Disguise, Liquid Ooze, Beak Blast) @@ -470,114 +458,141 @@ export class MoveEffectPhase extends PokemonPhase { } /** - * Applies all effects and attributes that require a move to connect with a target, - * namely reactive effects like Weak Armor, on-hit effects like that of Power-Up Punch, and item stealing effects - * @param user - The {@linkcode Pokemon} using this phase's invoked move - * @param target - {@linkcode Pokemon} the current target of this phase's invoked move - * @param firstHit - `true` if this is the first hit in a multi-hit attack - * @param lastHit - `true` if this is the last hit in a multi-hit attack - * @param isProtected - `true` if the target is protected by effects such as Protect - * @param hitResult - The {@linkcode HitResult} of the attempted move - * @param firstTarget - `true` if {@linkcode target} is the first target hit by this strike of {@linkcode move} - * @returns a function intended to be passed into a `then()` call. - */ - protected applySuccessfulAttackEffects(user: Pokemon, target: Pokemon, firstHit : boolean, lastHit: boolean, isProtected : boolean, hitResult: HitResult, firstTarget: boolean) : () => Promise { - return () => executeIf(!isProtected, () => - this.applyOnHitEffects(user, target, firstHit, lastHit, firstTarget).then(() => - this.applyOnGetHitAbEffects(user, target, hitResult)).then(() => - applyPostAttackAbAttrs(PostAttackAbAttr, user, target, this.move.getMove(), hitResult)).then(() => { // Item Stealing Effects - - if (this.move.getMove() instanceof AttackMove) { - this.scene.applyModifiers(ContactHeldItemTransferChanceModifier, this.player, user, target); - } - }) - ); - } - - /** - * Handles checking for and applying Flinches + * Handles checking for and applying flinches from held items (i.e. King's Rock) * @param user - The {@linkcode Pokemon} using this phase's invoked move * @param target - {@linkcode Pokemon} the current target of this phase's invoked move * @param dealsDamage - `true` if the attempted move successfully dealt damage * @returns a function intended to be passed into a `then()` call. */ - protected applyHeldItemFlinchCheck(user: Pokemon, target: Pokemon, dealsDamage: boolean) : () => void { - return () => { - if (this.move.getMove().hasAttr(FlinchAttr)) { - return; - } + protected applyHeldItemFlinchCheck(user: Pokemon, target: Pokemon, dealsDamage: boolean) : void { + if (this.move.getMove().hasAttr(FlinchAttr)) { + return; + } - if (dealsDamage && !target.hasAbilityWithAttr(IgnoreMoveEffectsAbAttr) && !this.move.getMove().hitsSubstitute(user, target)) { - const flinched = new BooleanHolder(false); - user.scene.applyModifiers(FlinchChanceModifier, user.isPlayer(), user, flinched); - if (flinched.value) { - target.addTag(BattlerTagType.FLINCHED, undefined, this.move.moveId, user.id); - } + if (dealsDamage && !target.hasAbilityWithAttr(IgnoreMoveEffectsAbAttr) && !this.move.getMove().hitsSubstitute(user, target)) { + const flinched = new BooleanHolder(false); + user.scene.applyModifiers(FlinchChanceModifier, user.isPlayer(), user, flinched); + if (flinched.value) { + target.addTag(BattlerTagType.FLINCHED, undefined, this.move.moveId, user.id); } - }; + } } /** * Resolves whether this phase's invoked move hits the given target * @param target - The {@linkcode Pokemon} targeted by the invoked move - * @returns `true` if the move hits the target + * @returns A {@linkcode HitCheckEntry} which specifies the move's outcome and + * effectiveness (if applicable) */ - public hitCheck(target: Pokemon): boolean { - // Moves targeting the user and entry hazards can't miss - if ([ MoveTarget.USER, MoveTarget.ENEMY_SIDE ].includes(this.move.getMove().moveTarget)) { - return true; - } - + public hitCheck(target: Pokemon): HitCheckEntry { const user = this.getUserPokemon(); + const move = this.move.getMove(); - if (!user) { - return false; + if (isNullOrUndefined(user)) { + return [ HitCheckResult.ERROR, 0 ]; } - // Hit check only calculated on first hit for multi-hit moves unless flag is set to check all hits. - // However, if an ability with the MaxMultiHitAbAttr, namely Skill Link, is present, act as a normal - // multi-hit move and proceed with all hits + // Moves targeting the user or field bypass accuracy and effectiveness checks + if (move.moveTarget === MoveTarget.USER || move.isFieldTarget()) { + return [ HitCheckResult.HIT, 1 ]; + } + + // If the target is somehow not on the field, cancel the hit check silently + if (!target.isActive(true)) { + return [ HitCheckResult.NO_EFFECT_NO_MESSAGE, 0 ]; + } + + /** Is the target hidden by the effects of its Commander ability? */ + const isCommanding = this.scene.currentBattle.double && target.getAlly()?.getTag(BattlerTagType.COMMANDED)?.getSourcePokemon(this.scene) === target; + if (isCommanding) { + return [ HitCheckResult.MISS, 0 ]; + } + + /** Is there an effect that causes the move to bypass accuracy checks, including semi-invulnerability? */ + const alwaysHit = [ user, target ].some(p => p.hasAbilityWithAttr(AlwaysHitAbAttr)) + || (user.getTag(BattlerTagType.IGNORE_ACCURACY) && (user.getLastXMoves().find(() => true)?.targets || []).indexOf(target.getBattlerIndex()) !== -1) + || !!target.getTag(BattlerTagType.ALWAYS_GET_HIT); + + const semiInvulnerableTag = target.getTag(SemiInvulnerableTag); + /** Should the move miss due to the target's semi-invulnerability? */ + const targetIsSemiInvulnerable = !!semiInvulnerableTag + && !this.move.getMove().getAttrs(HitsTagAttr).some(hta => hta.tagType === semiInvulnerableTag.tagType) + && !(this.move.getMove().hasAttr(ToxicAccuracyAttr) && user.isOfType(Type.POISON)); + + if (targetIsSemiInvulnerable && !alwaysHit) { + return [ HitCheckResult.MISS, 0 ]; + } + + // Check if the target is protected by any effect + /** The {@linkcode ArenaTagSide} to which the target belongs */ + const targetSide = target.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; + /** Has the invoked move been cancelled by conditional protection (e.g Quick Guard)? */ + const hasConditionalProtectApplied = new BooleanHolder(false); + /** Does the applied conditional protection bypass Protect-ignoring effects? */ + const bypassIgnoreProtect = new BooleanHolder(false); + /** If the move is not targeting a Pokemon on the user's side, try to apply conditional protection effects */ + if (!this.move.getMove().isAllyTarget()) { + this.scene.arena.applyTagsForSide(ConditionalProtectTag, targetSide, false, hasConditionalProtectApplied, user, target, move.id, bypassIgnoreProtect); + } + + /** Is the target protected by Protect, etc. or a relevant conditional protection effect? */ + const isProtected = ( + bypassIgnoreProtect.value + || !this.move.getMove().checkFlag(MoveFlags.IGNORE_PROTECT, user, target)) + && (hasConditionalProtectApplied.value + || (!target.findTags(t => t instanceof DamageProtectedTag).length + && target.findTags(t => t instanceof ProtectedTag).find(t => target.lapseTag(t.tagType))) + || (this.move.getMove().category !== MoveCategory.STATUS + && target.findTags(t => t instanceof DamageProtectedTag).find(t => target.lapseTag(t.tagType)))); + + if (isProtected) { + return [ HitCheckResult.PROTECTED, 0 ]; + } + + const cancelNoEffectMessage = new BooleanHolder(false); + const effectiveness = target.getMoveEffectiveness(user, move, false, false, cancelNoEffectMessage); + if (effectiveness === 0) { + return cancelNoEffectMessage.value + ? [ HitCheckResult.NO_EFFECT_NO_MESSAGE, effectiveness ] + : [ HitCheckResult.NO_EFFECT, effectiveness ]; + } + + // Strikes after the first in a multi-strike move are guaranteed to hit, + // unless the move is flagged to check all hits and the user does not have Skill Link. if (user.turnData.hitsLeft < user.turnData.hitCount) { - if (!this.move.getMove().hasFlag(MoveFlags.CHECK_ALL_HITS) || user.hasAbilityWithAttr(MaxMultiHitAbAttr)) { - return true; + if (!move.hasFlag(MoveFlags.CHECK_ALL_HITS) || user.hasAbilityWithAttr(MaxMultiHitAbAttr)) { + return [ HitCheckResult.HIT, effectiveness ]; } } - if (user.hasAbilityWithAttr(AlwaysHitAbAttr) || target.hasAbilityWithAttr(AlwaysHitAbAttr)) { - return true; + if (alwaysHit || target.getTag(BattlerTagType.TELEKINESIS) && !move.hasAttr(OneHitKOAttr)) { + return [ HitCheckResult.HIT, effectiveness ]; } - // If the user should ignore accuracy on a target, check who the user targeted last turn and see if they match - if (user.getTag(BattlerTagType.IGNORE_ACCURACY) && (user.getLastXMoves().find(() => true)?.targets || []).indexOf(target.getBattlerIndex()) !== -1) { - return true; - } - - if (target.getTag(BattlerTagType.ALWAYS_GET_HIT)) { - return true; - } - - if (target.getTag(BattlerTagType.TELEKINESIS) && !target.getTag(SemiInvulnerableTag) && !this.move.getMove().hasAttr(OneHitKOAttr)) { - return true; - } - - const semiInvulnerableTag = target.getTag(SemiInvulnerableTag); - if (semiInvulnerableTag - && !this.move.getMove().getAttrs(HitsTagAttr).some(hta => hta.tagType === semiInvulnerableTag.tagType) - && !(this.move.getMove().hasAttr(ToxicAccuracyAttr) && user.isOfType(Type.POISON)) - ) { - return false; - } - - const moveAccuracy = this.move.getMove().calculateBattleAccuracy(user, target); + const moveAccuracy = move.calculateBattleAccuracy(user, target); if (moveAccuracy === -1) { - return true; + return [ HitCheckResult.HIT, effectiveness ]; } - const accuracyMultiplier = user.getAccuracyMultiplier(target, this.move.getMove()); + const accuracyMultiplier = user.getAccuracyMultiplier(target, move); const rand = user.randSeedInt(100); - return rand < (moveAccuracy * accuracyMultiplier); + if (rand < (moveAccuracy * accuracyMultiplier)) { + return [ HitCheckResult.HIT, effectiveness ]; + } else { + return [ HitCheckResult.MISS, 0 ]; + } + } + + protected updateSubstitutes(): void { + const targets = this.getTargets(); + targets.forEach(target => { + const substitute = target.getTag(SubstituteTag); + if (substitute && substitute.hp <= 0) { + target.lapseTag(BattlerTagType.SUBSTITUTE); + } + }); } /** @returns The {@linkcode Pokemon} using this phase's invoked move */ @@ -598,40 +613,26 @@ export class MoveEffectPhase extends PokemonPhase { return this.getTargets()[0]; } - /** - * Removes the given {@linkcode Pokemon} from this phase's target list - * @param target - The {@linkcode Pokemon} to be removed - */ - protected removeTarget(target: Pokemon): void { - const targetIndex = this.targets.findIndex(ind => ind === target.getBattlerIndex()); - if (targetIndex !== -1) { - this.targets.splice(this.targets.findIndex(ind => ind === target.getBattlerIndex()), 1); - } - } - - /** - * Prevents subsequent strikes of this phase's invoked move from occurring - * @param target - If defined, only stop subsequent strikes against this {@linkcode Pokemon} - */ - public stopMultiHit(target?: Pokemon): void { - // If given a specific target, remove the target from subsequent strikes - if (target) { - this.removeTarget(target); - } - const user = this.getUserPokemon(); - if (!user) { - return; - } - // If no target specified, or the specified target was the last of this move's - // targets, completely cancel all subsequent strikes. - if (!target || this.targets.length === 0 ) { - user.turnData.hitCount = 1; - user.turnData.hitsLeft = 1; - } - } - /** @returns A new `MoveEffectPhase` with the same properties as this phase */ protected getNewHitPhase(): MoveEffectPhase { return new MoveEffectPhase(this.scene, this.battlerIndex, this.targets, this.move); } } + +/** Descriptor */ +export enum HitCheckResult { + /** Hit checks haven't been evaluated yet in this pass */ + PENDING, + /** The move hits the target successfully */ + HIT, + /** The move has no effect on the target */ + NO_EFFECT, + /** The move has no effect on the target, but doesn't proc the default "no effect" message. */ + NO_EFFECT_NO_MESSAGE, + /** The target protected itself against the move */ + PROTECTED, + /** The move missed the target */ + MISS, + /** The move failed unexpectedly */ + ERROR +} diff --git a/src/test/abilities/sheer_force.test.ts b/src/test/abilities/sheer_force.test.ts index 826694752b7..f2d53965f52 100644 --- a/src/test/abilities/sheer_force.test.ts +++ b/src/test/abilities/sheer_force.test.ts @@ -10,6 +10,7 @@ import GameManager from "#test/utils/gameManager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { allMoves } from "#app/data/move"; +import { HitResult } from "#app/field/pokemon"; describe("Abilities - Sheer Force", () => { let phaserGame: Phaser.Game; @@ -156,7 +157,7 @@ describe("Abilities - Sheer Force", () => { applyAbAttrs(MoveEffectChanceMultiplierAbAttr, user, null, false, chance, move, target, false); applyPreAttackAbAttrs(MovePowerBoostAbAttr, user, target, move, false, power); - applyPostDefendAbAttrs(PostDefendTypeChangeAbAttr, target, user, move, target.apply(user, move)); + applyPostDefendAbAttrs(PostDefendTypeChangeAbAttr, target, user, move, HitResult.EFFECTIVE); expect(chance.value).toBe(0); expect(power.value).toBe(move.power * 5461 / 4096); diff --git a/src/test/utils/helpers/moveHelper.ts b/src/test/utils/helpers/moveHelper.ts index 73fe63395fd..8d7c4bf8ef9 100644 --- a/src/test/utils/helpers/moveHelper.ts +++ b/src/test/utils/helpers/moveHelper.ts @@ -20,7 +20,8 @@ export class MoveHelper extends GameManagerHelper { */ public async forceHit(): Promise { await this.game.phaseInterceptor.to(MoveEffectPhase, false); - vi.spyOn(this.game.scene.getCurrentPhase() as MoveEffectPhase, "hitCheck").mockReturnValue(true); + const moveEffectPhase = this.game.scene.getCurrentPhase() as MoveEffectPhase; + vi.spyOn(moveEffectPhase.move.getMove(), "accuracy", "get").mockReturnValue(-1); } /** @@ -31,12 +32,13 @@ export class MoveHelper extends GameManagerHelper { */ public async forceMiss(firstTargetOnly: boolean = false): Promise { await this.game.phaseInterceptor.to(MoveEffectPhase, false); - const hitCheck = vi.spyOn(this.game.scene.getCurrentPhase() as MoveEffectPhase, "hitCheck"); + const moveEffectPhase = this.game.scene.getCurrentPhase() as MoveEffectPhase; + const accuracy = vi.spyOn(moveEffectPhase.move.getMove(), "accuracy", "get"); if (firstTargetOnly) { - hitCheck.mockReturnValueOnce(false); + accuracy.mockReturnValueOnce(0); } else { - hitCheck.mockReturnValue(false); + accuracy.mockReturnValue(0); } }