From c31657d95206f0cf4bb398337aae01adc0975543 Mon Sep 17 00:00:00 2001 From: Mason S <132116525+ElizaAlex@users.noreply.github.com> Date: Sat, 2 Nov 2024 22:55:15 -0400 Subject: [PATCH] [Refactor] Reduced nesting in MoveEffectPhase (#3541) * Re-structured MoveEffectPhase.run() to reduce nesting and improve readability. * Re-structured MoveEffectPhase.run() to reduce nesting and improve readability. * Added Substitute Implementation * Fix indentation, tsdocs, add `public`/etc * Prevent ENEMY_SIDE targeted moves from occurring twice in double battles * Add `override` to `start()` and `end()` * Minor indentation fix --------- Co-authored-by: innerthunder <168692175+innerthunder@users.noreply.github.com> Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> --- public/locales | 2 +- src/phases/move-effect-phase.ts | 398 ++++++++++++++++-------- src/test/abilities/serene_grace.test.ts | 4 +- src/test/abilities/sheer_force.test.ts | 14 +- src/test/abilities/shield_dust.test.ts | 4 +- src/test/moves/dynamax_cannon.test.ts | 10 +- 6 files changed, 287 insertions(+), 145 deletions(-) diff --git a/public/locales b/public/locales index 3cf6d553541..fc4a1effd51 160000 --- a/public/locales +++ b/public/locales @@ -1 +1 @@ -Subproject commit 3cf6d553541d79ba165387bc73fb06544d00f1f9 +Subproject commit fc4a1effd5170def3c8314208a52cd0d8e6913ef diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index 2b898f7d66b..8b4b462380c 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -1,20 +1,62 @@ -import BattleScene from "#app/battle-scene"; import { BattlerIndex } from "#app/battle"; -import { applyPreAttackAbAttrs, AddSecondStrikeAbAttr, IgnoreMoveEffectsAbAttr, applyPostDefendAbAttrs, PostDefendAbAttr, applyPostAttackAbAttrs, PostAttackAbAttr, MaxMultiHitAbAttr, AlwaysHitAbAttr, TypeImmunityAbAttr } from "#app/data/ability"; +import BattleScene from "#app/battle-scene"; +import { + AddSecondStrikeAbAttr, + AlwaysHitAbAttr, + applyPostAttackAbAttrs, + applyPostDefendAbAttrs, + applyPreAttackAbAttrs, + IgnoreMoveEffectsAbAttr, + MaxMultiHitAbAttr, + PostAttackAbAttr, + PostDefendAbAttr, + TypeImmunityAbAttr, +} from "#app/data/ability"; import { ArenaTagSide, ConditionalProtectTag } from "#app/data/arena-tag"; import { MoveAnim } from "#app/data/battle-anims"; -import { BattlerTagLapseType, DamageProtectedTag, ProtectedTag, SemiInvulnerableTag, SubstituteTag } from "#app/data/battler-tags"; -import { MoveTarget, applyMoveAttrs, OverrideMoveEffectAttr, MultiHitAttr, AttackMove, FixedDamageAttr, VariableTargetAttr, MissEffectAttr, MoveFlags, applyFilteredMoveAttrs, MoveAttr, MoveEffectAttr, OneHitKOAttr, MoveEffectTrigger, MoveCategory, NoEffectAttr, HitsTagAttr, ToxicAccuracyAttr } from "#app/data/move"; +import { + BattlerTagLapseType, + DamageProtectedTag, + ProtectedTag, + SemiInvulnerableTag, + SubstituteTag, +} from "#app/data/battler-tags"; +import { + applyFilteredMoveAttrs, + applyMoveAttrs, + AttackMove, + FixedDamageAttr, + HitsTagAttr, + MissEffectAttr, + MoveAttr, + MoveCategory, + MoveEffectAttr, + MoveEffectTrigger, + MoveFlags, + MoveTarget, + MultiHitAttr, + NoEffectAttr, + OneHitKOAttr, + OverrideMoveEffectAttr, + ToxicAccuracyAttr, + VariableTargetAttr, +} from "#app/data/move"; import { SpeciesFormChangePostMoveTrigger } from "#app/data/pokemon-forms"; -import { BattlerTagType } from "#app/enums/battler-tag-type"; -import { Moves } from "#app/enums/moves"; -import Pokemon, { PokemonMove, MoveResult, HitResult } from "#app/field/pokemon"; -import { getPokemonNameWithAffix } from "#app/messages"; -import { PokemonMultiHitModifier, FlinchChanceModifier, EnemyAttackStatusEffectChanceModifier, ContactHeldItemTransferChanceModifier, HitHealModifier } from "#app/modifier/modifier"; -import i18next from "i18next"; -import * as Utils from "#app/utils"; -import { PokemonPhase } from "./pokemon-phase"; import { Type } from "#app/data/type"; +import Pokemon, { HitResult, MoveResult, PokemonMove } from "#app/field/pokemon"; +import { getPokemonNameWithAffix } from "#app/messages"; +import { + ContactHeldItemTransferChanceModifier, + EnemyAttackStatusEffectChanceModifier, + FlinchChanceModifier, + HitHealModifier, + PokemonMultiHitModifier, +} from "#app/modifier/modifier"; +import { BooleanHolder, executeIf, NumberHolder } from "#app/utils"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { Moves } from "#enums/moves"; +import i18next from "i18next"; +import { PokemonPhase } from "./pokemon-phase"; export class MoveEffectPhase extends PokemonPhase { public move: PokemonMove; @@ -35,7 +77,7 @@ export class MoveEffectPhase extends PokemonPhase { this.targets = targets; } - start() { + public override start(): void { super.start(); /** The Pokemon using this phase's invoked move */ @@ -52,12 +94,12 @@ 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. */ - const overridden = new Utils.BooleanHolder(false); + 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.getTarget() ?? null, move, overridden, this.move.virtual).then(() => { + 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(); @@ -71,14 +113,14 @@ export class MoveEffectPhase extends PokemonPhase { * effects of the move itself, Parental Bond, and Multi-Lens to do so. */ if (user.turnData.hitsLeft === -1) { - const hitCount = new Utils.IntegerHolder(1); + const hitCount = new NumberHolder(1); // Assume single target for multi hit - applyMoveAttrs(MultiHitAttr, user, this.getTarget() ?? null, move, hitCount); + applyMoveAttrs(MultiHitAttr, user, this.getFirstTarget() ?? null, move, hitCount); // If Parental Bond is applicable, double the hit count - applyPreAttackAbAttrs(AddSecondStrikeAbAttr, user, null, move, false, targets.length, hitCount, new Utils.IntegerHolder(0)); + applyPreAttackAbAttrs(AddSecondStrikeAbAttr, user, null, move, false, targets.length, hitCount, new NumberHolder(0)); // If Multi-Lens is applicable, multiply the hit count by 1 + the number of Multi-Lenses held by the user if (move instanceof AttackMove && !move.hasAttr(FixedDamageAttr)) { - this.scene.applyModifiers(PokemonMultiHitModifier, user.isPlayer(), user, hitCount, new Utils.IntegerHolder(0)); + this.scene.applyModifiers(PokemonMultiHitModifier, user.isPlayer(), user, hitCount, new NumberHolder(0)); } // Set the user's relevant turnData fields to reflect the final hit count user.turnData.hitCount = hitCount.value; @@ -100,8 +142,9 @@ export class MoveEffectPhase extends PokemonPhase { const hasActiveTargets = targets.some(t => t.isActive(true)); /** 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); + 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 @@ -111,7 +154,7 @@ export class MoveEffectPhase extends PokemonPhase { 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.getTarget() ? getPokemonNameWithAffix(this.getTarget()!) : "" })); + this.scene.queueMessage(i18next.t("battle:attackMissed", { pokemonNameWithAffix: this.getFirstTarget() ? getPokemonNameWithAffix(this.getFirstTarget()!) : "" })); moveHistoryEntry.result = MoveResult.MISS; applyMoveAttrs(MissEffectAttr, user, null, move); } else { @@ -127,30 +170,40 @@ export class MoveEffectPhase extends PokemonPhase { const playOnEmptyField = this.scene.currentBattle?.mysteryEncounter?.hasBattleAnimationsWithoutTargets ?? false; // Move animation only needs one target - new MoveAnim(move.id as Moves, user, this.getTarget()!.getBattlerIndex()!, playOnEmptyField).play(this.scene, move.hitsSubstitute(user, this.getTarget()!), () => { + 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; + } /** 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 Utils.BooleanHolder(false); + const hasConditionalProtectApplied = new BooleanHolder(false); /** Does the applied conditional protection bypass Protect-ignoring effects? */ - const bypassIgnoreProtect = new Utils.BooleanHolder(false); + 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)))); + 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)))); /** 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); + const isImmune = target.hasAbilityWithAttr(TypeImmunityAbAttr) + && (target.getAbility()?.getAttrs(TypeImmunityAbAttr)?.[0]?.getImmuneType() === user.getMoveType(move)) + && !target.getTag(SemiInvulnerableTag); /** * If the move missed a target, stop all future hits against that target @@ -218,7 +271,7 @@ export class MoveEffectPhase extends PokemonPhase { } /** Does this phase represent the invoked move's last strike? */ - const lastHit = (user.turnData.hitsLeft === 1 || !this.getTarget()?.isActive()); + const lastHit = (user.turnData.hitsLeft === 1 || !this.getFirstTarget()?.isActive()); /** * If the user can change forms by using the invoked move, @@ -234,85 +287,48 @@ export class MoveEffectPhase extends PokemonPhase { * 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. */ - applyAttrs.push(new Promise(resolve => { - // Apply all effects with PRE_MOVE triggers (if the target isn't immune to the move) - applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && attr.trigger === MoveEffectTrigger.PRE_APPLY && (!attr.firstHitOnly || firstHit) && (!attr.lastHitOnly || lastHit) && hitResult !== HitResult.NO_EFFECT, - user, target, move).then(() => { - // All other effects require the move to not have failed or have been cancelled to trigger - if (hitResult !== HitResult.FAIL) { - /** - * If the invoked move's effects are meant to trigger during the move's "charge turn," - * ignore all effects after this point. - * Otherwise, apply all self-targeted POST_APPLY effects. - */ - applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && attr.trigger === MoveEffectTrigger.POST_APPLY - && attr.selfTarget && (!attr.firstHitOnly || firstHit) && (!attr.lastHitOnly || lastHit), user, target, move).then(() => { - // All effects past this point require the move to have hit the target - if (hitResult !== HitResult.NO_EFFECT) { - // Apply all non-self-targeted POST_APPLY effects - applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && (attr as MoveEffectAttr).trigger === MoveEffectTrigger.POST_APPLY - && !(attr as MoveEffectAttr).selfTarget && (!attr.firstHitOnly || firstHit) && (!attr.lastHitOnly || lastHit), user, target, this.move.getMove()).then(() => { - /** - * If the move hit, and the target doesn't have Shield Dust, - * apply the chance to flinch the target gained from King's Rock - */ - if (dealsDamage && !target.hasAbilityWithAttr(IgnoreMoveEffectsAbAttr) && !move.hitsSubstitute(user, target)) { - const flinched = new Utils.BooleanHolder(false); - user.scene.applyModifiers(FlinchChanceModifier, user.isPlayer(), user, flinched); - if (flinched.value) { - target.addTag(BattlerTagType.FLINCHED, undefined, this.move.moveId, user.id); - } - } - // If the move was not protected against, apply all HIT effects - Utils.executeIf(!isProtected, () => applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && (attr as MoveEffectAttr).trigger === MoveEffectTrigger.HIT - && (!attr.firstHitOnly || firstHit) && (!attr.lastHitOnly || lastHit) && (!attr.firstTargetOnly || firstTarget), user, target, this.move.getMove()).then(() => { - // Apply the target's post-defend ability effects (as long as the target is active or can otherwise apply them) - return Utils.executeIf(!target.isFainted() || target.canApplyAbility(), () => applyPostDefendAbAttrs(PostDefendAbAttr, target, user, this.move.getMove(), hitResult).then(() => { - // Only apply the following effects if the move was not deflected by a substitute - if (move.hitsSubstitute(user, target)) { - return resolve(); - } + 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); - // If the invoked move is an enemy attack, apply the enemy's status effect-inflicting tokens - if (!user.isPlayer() && this.move.getMove() instanceof AttackMove) { - user.scene.applyShuffledModifiers(this.scene, EnemyAttackStatusEffectChanceModifier, false, target); - } - target.lapseTags(BattlerTagLapseType.AFTER_HIT); + /** Don't complete if the move failed */ + if (hitResult === HitResult.FAIL) { + return resolve(); + } - })).then(() => { - // Apply the user's post-attack ability effects - applyPostAttackAbAttrs(PostAttackAbAttr, user, target, this.move.getMove(), hitResult).then(() => { - /** - * If the invoked move is an attack, apply the user's chance to - * steal an item from the target granted by Grip Claw - */ - if (this.move.getMove() instanceof AttackMove) { - this.scene.applyModifiers(ContactHeldItemTransferChanceModifier, this.player, user, target); - } - resolve(); - }); - }); - }) - ).then(() => resolve()); - }); - } else { - applyMoveAttrs(NoEffectAttr, user, null, move).then(() => resolve()); - } - }); - } else { - 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.getTarget()?.isActive()) ? + 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 (postTarget) { if (applyAttrs.length) { // If there is a pending asynchronous move effect, do this after - applyAttrs[applyAttrs.length - 1]?.then(() => postTarget); + applyAttrs[applyAttrs.length - 1].then(() => postTarget); } else { // Otherwise, push a new asynchronous move effect applyAttrs.push(postTarget); } @@ -327,7 +343,7 @@ export class MoveEffectPhase extends PokemonPhase { */ targets.forEach(target => { const substitute = target.getTag(SubstituteTag); - if (!!substitute && substitute.hp <= 0) { + if (substitute && substitute.hp <= 0) { target.lapseTag(BattlerTagType.SUBSTITUTE); } }); @@ -337,7 +353,7 @@ export class MoveEffectPhase extends PokemonPhase { }); } - end() { + public override end(): void { const user = this.getUserPokemon(); /** * If this phase isn't for the invoked move's last strike, @@ -347,7 +363,7 @@ export class MoveEffectPhase extends PokemonPhase { * to the user. */ if (user) { - if (user.turnData.hitsLeft && --user.turnData.hitsLeft >= 1 && this.getTarget()?.isActive()) { + if (user.turnData.hitsLeft && --user.turnData.hitsLeft >= 1 && this.getFirstTarget()?.isActive()) { this.scene.unshiftPhase(this.getNewHitPhase()); } else { // Queue message for number of hits made by multi-move @@ -367,11 +383,135 @@ export class MoveEffectPhase extends PokemonPhase { } /** - * Resolves whether this phase's invoked move hits or misses the given target - * @param target {@linkcode Pokemon} the Pokemon targeted by the invoked move - * @returns `true` if the move does not miss the target; `false` otherwise - */ - hitCheck(target: Pokemon): boolean { + * 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. + */ + 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 { + 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()); + } + + /** + * Applies reactive effects that occur when a Pokémon is hit. + * (i.e. Effect Spore, Disguise, Liquid Ooze, Beak Blast) + * @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 hitResult - The {@linkcode HitResult} of the attempted move + * @returns a `Promise` intended to be passed into a `then()` call. + */ + protected applyOnGetHitAbEffects(user: Pokemon, target: Pokemon, hitResult: HitResult): Promise { + return executeIf(!target.isFainted() || target.canApplyAbility(), () => + applyPostDefendAbAttrs(PostDefendAbAttr, target, user, this.move.getMove(), hitResult) + .then(() => { + + if (!this.move.getMove().hitsSubstitute(user, target)) { + if (!user.isPlayer() && this.move.getMove() instanceof AttackMove) { + user.scene.applyShuffledModifiers(this.scene, EnemyAttackStatusEffectChanceModifier, false, target); + } + + target.lapseTags(BattlerTagLapseType.AFTER_HIT); + } + + }) + ); + } + + /** + * 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 + * @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 (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 + */ + 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; @@ -425,29 +565,29 @@ export class MoveEffectPhase extends PokemonPhase { return rand < (moveAccuracy * accuracyMultiplier); } - /** Returns the {@linkcode Pokemon} using this phase's invoked move */ - getUserPokemon(): Pokemon | undefined { + /** @returns The {@linkcode Pokemon} using this phase's invoked move */ + public getUserPokemon(): Pokemon | undefined { if (this.battlerIndex > BattlerIndex.ENEMY_2) { return this.scene.getPokemonById(this.battlerIndex) ?? undefined; } return (this.player ? this.scene.getPlayerField() : this.scene.getEnemyField())[this.fieldIndex]; } - /** Returns an array of all {@linkcode Pokemon} targeted by this phase's invoked move */ - getTargets(): Pokemon[] { + /** @returns An array of all {@linkcode Pokemon} targeted by this phase's invoked move */ + public getTargets(): Pokemon[] { return this.scene.getField(true).filter(p => this.targets.indexOf(p.getBattlerIndex()) > -1); } - /** Returns the first target of this phase's invoked move */ - getTarget(): Pokemon | undefined { + /** @returns The first target of this phase's invoked move */ + public getFirstTarget(): Pokemon | undefined { return this.getTargets()[0]; } /** * Removes the given {@linkcode Pokemon} from this phase's target list - * @param target {@linkcode Pokemon} the Pokemon to be removed + * @param target - The {@linkcode Pokemon} to be removed */ - removeTarget(target: Pokemon): void { + 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); @@ -459,23 +599,25 @@ export class MoveEffectPhase extends PokemonPhase { * @param target {@linkcode Pokemon} if defined, only stop subsequent * strikes against this Pokemon */ - stopMultiHit(target?: Pokemon): void { - /** If given a specific target, remove the target from subsequent strikes */ + public stopMultiHit(target?: Pokemon): void { + // If given a specific target, remove the target from subsequent strikes if (target) { this.removeTarget(target); } - /** - * If no target specified, or the specified target was the last of this move's - * targets, completely cancel all subsequent strikes. - */ + 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 ) { - this.getUserPokemon()!.turnData.hitCount = 1; // TODO: is the bang correct here? - this.getUserPokemon()!.turnData.hitsLeft = 1; // TODO: is the bang correct here? + user.turnData.hitCount = 1; + user.turnData.hitsLeft = 1; } } - /** Returns a new MoveEffectPhase with the same properties as this phase */ - getNewHitPhase() { + /** @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); } } diff --git a/src/test/abilities/serene_grace.test.ts b/src/test/abilities/serene_grace.test.ts index 3155594c81d..ddca87496e9 100644 --- a/src/test/abilities/serene_grace.test.ts +++ b/src/test/abilities/serene_grace.test.ts @@ -57,7 +57,7 @@ describe("Abilities - Serene Grace", () => { const chance = new Utils.IntegerHolder(move.chance); console.log(move.chance + " Their ability is " + phase.getUserPokemon()!.getAbility().name); - applyAbAttrs(MoveEffectChanceMultiplierAbAttr, phase.getUserPokemon()!, null, false, chance, move, phase.getTarget(), false); + applyAbAttrs(MoveEffectChanceMultiplierAbAttr, phase.getUserPokemon()!, null, false, chance, move, phase.getFirstTarget(), false); expect(chance.value).toBe(30); }, 20000); @@ -83,7 +83,7 @@ describe("Abilities - Serene Grace", () => { expect(move.id).toBe(Moves.AIR_SLASH); const chance = new Utils.IntegerHolder(move.chance); - applyAbAttrs(MoveEffectChanceMultiplierAbAttr, phase.getUserPokemon()!, null, false, chance, move, phase.getTarget(), false); + applyAbAttrs(MoveEffectChanceMultiplierAbAttr, phase.getUserPokemon()!, null, false, chance, move, phase.getFirstTarget(), false); expect(chance.value).toBe(60); }, 20000); diff --git a/src/test/abilities/sheer_force.test.ts b/src/test/abilities/sheer_force.test.ts index a2600476d6d..63c81e9aafc 100644 --- a/src/test/abilities/sheer_force.test.ts +++ b/src/test/abilities/sheer_force.test.ts @@ -60,8 +60,8 @@ describe("Abilities - Sheer Force", () => { const power = new Utils.IntegerHolder(move.power); const chance = new Utils.IntegerHolder(move.chance); - applyAbAttrs(MoveEffectChanceMultiplierAbAttr, phase.getUserPokemon()!, null, false, chance, move, phase.getTarget(), false); - applyPreAttackAbAttrs(MovePowerBoostAbAttr, phase.getUserPokemon()!, phase.getTarget()!, move, false, power); + applyAbAttrs(MoveEffectChanceMultiplierAbAttr, phase.getUserPokemon()!, null, false, chance, move, phase.getFirstTarget(), false); + applyPreAttackAbAttrs(MovePowerBoostAbAttr, phase.getUserPokemon()!, phase.getFirstTarget()!, move, false, power); expect(chance.value).toBe(0); expect(power.value).toBe(move.power * 5461 / 4096); @@ -93,8 +93,8 @@ describe("Abilities - Sheer Force", () => { const power = new Utils.IntegerHolder(move.power); const chance = new Utils.IntegerHolder(move.chance); - applyAbAttrs(MoveEffectChanceMultiplierAbAttr, phase.getUserPokemon()!, null, false, chance, move, phase.getTarget(), false); - applyPreAttackAbAttrs(MovePowerBoostAbAttr, phase.getUserPokemon()!, phase.getTarget()!, move, false, power); + applyAbAttrs(MoveEffectChanceMultiplierAbAttr, phase.getUserPokemon()!, null, false, chance, move, phase.getFirstTarget(), false); + applyPreAttackAbAttrs(MovePowerBoostAbAttr, phase.getUserPokemon()!, phase.getFirstTarget()!, move, false, power); expect(chance.value).toBe(-1); expect(power.value).toBe(move.power); @@ -126,8 +126,8 @@ describe("Abilities - Sheer Force", () => { const power = new Utils.IntegerHolder(move.power); const chance = new Utils.IntegerHolder(move.chance); - applyAbAttrs(MoveEffectChanceMultiplierAbAttr, phase.getUserPokemon()!, null, false, chance, move, phase.getTarget(), false); - applyPreAttackAbAttrs(MovePowerBoostAbAttr, phase.getUserPokemon()!, phase.getTarget()!, move, false, power); + applyAbAttrs(MoveEffectChanceMultiplierAbAttr, phase.getUserPokemon()!, null, false, chance, move, phase.getFirstTarget(), false); + applyPreAttackAbAttrs(MovePowerBoostAbAttr, phase.getUserPokemon()!, phase.getFirstTarget()!, move, false, power); expect(chance.value).toBe(-1); expect(power.value).toBe(move.power); @@ -161,7 +161,7 @@ describe("Abilities - Sheer Force", () => { const power = new Utils.IntegerHolder(move.power); const chance = new Utils.IntegerHolder(move.chance); const user = phase.getUserPokemon()!; - const target = phase.getTarget()!; + const target = phase.getFirstTarget()!; const opponentType = target.getTypes()[0]; applyAbAttrs(MoveEffectChanceMultiplierAbAttr, user, null, false, chance, move, target, false); diff --git a/src/test/abilities/shield_dust.test.ts b/src/test/abilities/shield_dust.test.ts index 0f831fcf3fa..ccedf6873d7 100644 --- a/src/test/abilities/shield_dust.test.ts +++ b/src/test/abilities/shield_dust.test.ts @@ -57,8 +57,8 @@ describe("Abilities - Shield Dust", () => { expect(move.id).toBe(Moves.AIR_SLASH); const chance = new Utils.IntegerHolder(move.chance); - applyAbAttrs(MoveEffectChanceMultiplierAbAttr, phase.getUserPokemon()!, null, false, chance, move, phase.getTarget(), false); - applyPreDefendAbAttrs(IgnoreMoveEffectsAbAttr, phase.getTarget()!, phase.getUserPokemon()!, null, null, false, chance); + applyAbAttrs(MoveEffectChanceMultiplierAbAttr, phase.getUserPokemon()!, null, false, chance, move, phase.getFirstTarget(), false); + applyPreDefendAbAttrs(IgnoreMoveEffectsAbAttr, phase.getFirstTarget()!, phase.getUserPokemon()!, null, null, false, chance); expect(chance.value).toBe(0); }, 20000); diff --git a/src/test/moves/dynamax_cannon.test.ts b/src/test/moves/dynamax_cannon.test.ts index 9dd48d3c94c..001f986bd52 100644 --- a/src/test/moves/dynamax_cannon.test.ts +++ b/src/test/moves/dynamax_cannon.test.ts @@ -81,7 +81,7 @@ describe("Moves - Dynamax Cannon", () => { const phase = game.scene.getCurrentPhase() as MoveEffectPhase; expect(phase.move.moveId).toBe(dynamaxCannon.id); // Force level cap to be 100 - vi.spyOn(phase.getTarget()!.scene, "getMaxExpLevel").mockReturnValue(100); + vi.spyOn(phase.getFirstTarget()!.scene, "getMaxExpLevel").mockReturnValue(100); await game.phaseInterceptor.to(DamagePhase, false); expect(dynamaxCannon.calculateBattlePower).toHaveLastReturnedWith(120); }, 20000); @@ -98,7 +98,7 @@ describe("Moves - Dynamax Cannon", () => { const phase = game.scene.getCurrentPhase() as MoveEffectPhase; expect(phase.move.moveId).toBe(dynamaxCannon.id); // Force level cap to be 100 - vi.spyOn(phase.getTarget()!.scene, "getMaxExpLevel").mockReturnValue(100); + vi.spyOn(phase.getFirstTarget()!.scene, "getMaxExpLevel").mockReturnValue(100); await game.phaseInterceptor.to(DamagePhase, false); expect(dynamaxCannon.calculateBattlePower).toHaveLastReturnedWith(140); }, 20000); @@ -115,7 +115,7 @@ describe("Moves - Dynamax Cannon", () => { const phase = game.scene.getCurrentPhase() as MoveEffectPhase; expect(phase.move.moveId).toBe(dynamaxCannon.id); // Force level cap to be 100 - vi.spyOn(phase.getTarget()!.scene, "getMaxExpLevel").mockReturnValue(100); + vi.spyOn(phase.getFirstTarget()!.scene, "getMaxExpLevel").mockReturnValue(100); await game.phaseInterceptor.to(DamagePhase, false); expect(dynamaxCannon.calculateBattlePower).toHaveLastReturnedWith(160); }, 20000); @@ -132,7 +132,7 @@ describe("Moves - Dynamax Cannon", () => { const phase = game.scene.getCurrentPhase() as MoveEffectPhase; expect(phase.move.moveId).toBe(dynamaxCannon.id); // Force level cap to be 100 - vi.spyOn(phase.getTarget()!.scene, "getMaxExpLevel").mockReturnValue(100); + vi.spyOn(phase.getFirstTarget()!.scene, "getMaxExpLevel").mockReturnValue(100); await game.phaseInterceptor.to(DamagePhase, false); expect(dynamaxCannon.calculateBattlePower).toHaveLastReturnedWith(180); }, 20000); @@ -149,7 +149,7 @@ describe("Moves - Dynamax Cannon", () => { const phase = game.scene.getCurrentPhase() as MoveEffectPhase; expect(phase.move.moveId).toBe(dynamaxCannon.id); // Force level cap to be 100 - vi.spyOn(phase.getTarget()!.scene, "getMaxExpLevel").mockReturnValue(100); + vi.spyOn(phase.getFirstTarget()!.scene, "getMaxExpLevel").mockReturnValue(100); await game.phaseInterceptor.to(DamagePhase, false); expect(dynamaxCannon.calculateBattlePower).toHaveLastReturnedWith(200); }, 20000);