diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index 2f57df4a551..6b4de433880 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -1,6 +1,8 @@ /* biome-ignore-start lint/correctness/noUnusedImports: tsdoc imports */ import type { BattleScene } from "#app/battle-scene"; import type { SpeciesFormChangeRevertWeatherFormTrigger } from "#data/form-change-triggers"; +import type { MoveEffectPhase } from "#phases/move-effect-phase"; +import type { MoveEndPhase } from "#phases/move-end-phase"; /* biome-ignore-end lint/correctness/noUnusedImports: tsdoc imports */ import { applyAbAttrs } from "#abilities/apply-ab-attrs"; @@ -51,7 +53,8 @@ import { BerryModifierType } from "#modifiers/modifier-type"; import { applyMoveAttrs } from "#moves/apply-attrs"; import { noAbilityTypeOverrideMoves } from "#moves/invalid-moves"; import type { Move } from "#moves/move"; -import type { PokemonMove } from "#moves/pokemon-move"; +import { getMoveTargets } from "#moves/move-utils"; +import { PokemonMove } from "#moves/pokemon-move"; import type { StatStageChangePhase } from "#phases/stat-stage-change-phase"; import type { AbAttrCondition, @@ -5788,12 +5791,21 @@ export class InfiltratorAbAttr extends AbAttr { /** * Attribute implementing the effects of {@link https://bulbapedia.bulbagarden.net/wiki/Magic_Bounce_(ability) | Magic Bounce}. * Allows the source to bounce back {@linkcode MoveFlags.REFLECTABLE | Reflectable} - * moves as if the user had used {@linkcode MoveId.MAGIC_COAT | Magic Coat}. - * @sealed - * @todo Make reflection a part of this ability's effects + * moves as if the user had used {@linkcode MoveId.MAGIC_COAT | Magic Coat}. + * The calling {@linkcode MoveEffectPhase} will "skip" targets with a reflection effect active, + * showing the flyout and queueing the reaction during the move's {@linkcode MoveEndPhase}. */ -export class ReflectStatusMoveAbAttr extends AbAttr { - private declare readonly _: never; +export class ReflectStatusMoveAbAttr extends PreDefendAbAttr { + override apply({ pokemon, opponent, move }: AugmentMoveInteractionAbAttrParams): void { + const newTargets = move.isMultiTarget() ? getMoveTargets(pokemon, move.id).targets : [opponent.getBattlerIndex()]; + globalScene.phaseManager.unshiftNew( + "MovePhase", + pokemon, + newTargets, + new PokemonMove(move.id), + MoveUseMode.REFLECTED, + ); + } } // TODO: Make these ability attributes be flags instead of dummy attributes @@ -7250,10 +7262,7 @@ export function initAbilities() { .attr(PostIntimidateStatStageChangeAbAttr, [ Stat.SPD ], 1), new Ability(AbilityId.MAGIC_BOUNCE, 5) .attr(ReflectStatusMoveAbAttr) - .ignorable() - // Interactions with stomping tantrum, instruct, encore, and probably other moves that - // rely on move history - .edgeCase(), + .ignorable(), new Ability(AbilityId.SAP_SIPPER, 5) .attr(TypeImmunityStatStageChangeAbAttr, PokemonType.GRASS, Stat.ATK, 1) .ignorable(), @@ -7351,7 +7360,7 @@ export function initAbilities() { new Ability(AbilityId.GOOEY, 6) .attr(PostDefendStatStageChangeAbAttr, (_target, _user, move) => move.hasFlag(MoveFlags.MAKES_CONTACT), Stat.SPD, -1, false), new Ability(AbilityId.AERILATE, 6) - .attr(MoveTypeChangeAbAttr, PokemonType.FLYING, 1.2, (_user, _target, move) => move.type === PokemonType.NORMAL), + .attr(MoveTypeChangeAbAttr, PokemonType.FLYING, 1.2, (_user, _target, move) => move.type === PokemonType.NORMAL), new Ability(AbilityId.PARENTAL_BOND, 6) .attr(AddSecondStrikeAbAttr, 0.25), new Ability(AbilityId.DARK_AURA, 6) diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 25459844bd0..df40d4627cf 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -29,6 +29,7 @@ import { applyMoveAttrs } from "#moves/apply-attrs"; import { invalidEncoreMoves } from "#moves/invalid-moves"; import type { Move } from "#moves/move"; import { getMoveTargets } from "#moves/move-utils"; +import { PokemonMove } from "#moves/pokemon-move"; import type { MoveEffectPhase } from "#phases/move-effect-phase"; import type { MovePhase } from "#phases/move-phase"; import type { StatStageChangeCallback } from "#phases/stat-stage-change-phase"; @@ -175,6 +176,7 @@ export class BattlerTag implements BaseBattlerTag { return ""; } + // TODO: Make this a getter isSourceLinked(): boolean { return false; } @@ -3640,6 +3642,23 @@ export class MagicCoatTag extends BattlerTag { }), ); } + + /** + * Apply the tag to reflect a move. + * @param pokemon - The {@linkcode Pokemon} to whom this tag belongs + * @param opponent - The {@linkcode Pokemon} having originally used the move + * @param move - The {@linkcode Move} being used + */ + public apply(pokemon: Pokemon, opponent: Pokemon, move: Move): void { + const newTargets = move.isMultiTarget() ? getMoveTargets(pokemon, move.id).targets : [opponent.getBattlerIndex()]; + globalScene.phaseManager.unshiftNew( + "MovePhase", + pokemon, + newTargets, + new PokemonMove(move.id), + MoveUseMode.REFLECTED, + ); + } } /** diff --git a/src/data/moves/move-utils.ts b/src/data/moves/move-utils.ts index 241144599e5..83fbdb62906 100644 --- a/src/data/moves/move-utils.ts +++ b/src/data/moves/move-utils.ts @@ -1,8 +1,13 @@ +import { SemiInvulnerableTag } from "#data/battler-tags"; import { allMoves } from "#data/data-lists"; +// biome-ignore lint/correctness/noUnusedImports: +import type { AbilityId } from "#enums/ability-id"; import type { BattlerIndex } from "#enums/battler-index"; import { BattlerTagType } from "#enums/battler-tag-type"; +import { MoveFlags } from "#enums/move-flags"; import type { MoveId } from "#enums/move-id"; import { MoveTarget } from "#enums/move-target"; +import { isReflected, type MoveUseMode } from "#enums/move-use-mode"; import { PokemonType } from "#enums/pokemon-type"; import type { Pokemon } from "#field/pokemon"; import { applyMoveAttrs } from "#moves/apply-attrs"; @@ -133,3 +138,24 @@ export const frenzyMissFunc: UserMoveConditionFunc = (user: Pokemon, move: Move) return true; }; + +/** + * Check whether a given Move is able to be reflected by either + * {@linkcode MoveId.MAGIC_COAT | Magic Coat} or {@linkcode AbilityId.MAGIC_BOUNCE | Magic Bounce}. + * @param move - The {@linkcode Move} being used + * @param target - The targeted {@linkcode Pokemon} attempting to reflect the move + * @param useMode - The {@linkcode MoveUseMode} dictating how the move was used + * @returns Whether {@linkcode target} can reflect {@linkcode move}. + */ +export function isMoveReflectableBy(move: Move, target: Pokemon, useMode: MoveUseMode): boolean { + return ( + // The move must not have just been reflected + !isReflected(useMode) && + // Reflections cannot occur while semi invulnerable + !target.getTag(SemiInvulnerableTag) && + // Move must be reflectable + move.hasFlag(MoveFlags.REFLECTABLE) && + // target must have a reflection effect active + (!!target.getTag(BattlerTagType.MAGIC_COAT) || target.hasAbilityWithAttr("ReflectStatusMoveAbAttr")) + ); +} diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 0ab36fb1a51..7e4851930c6 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -674,20 +674,9 @@ export abstract class Move implements Localizable { return true; } break; - case MoveFlags.REFLECTABLE: - // If the target is not semi-invulnerable and either has magic coat active or an unignored magic bounce ability - if ( - target?.getTag(SemiInvulnerableTag) || - !(target?.getTag(BattlerTagType.MAGIC_COAT) || - (!this.doesFlagEffectApply({ flag: MoveFlags.IGNORE_ABILITIES, user, target }) && - target?.hasAbilityWithAttr("ReflectStatusMoveAbAttr"))) - ) { - return false; - } - break; } - return !!(this.flags & flag); + return this.hasFlag(flag) } /** diff --git a/src/phase-manager.ts b/src/phase-manager.ts index f5ac0922111..dacff01ccaf 100644 --- a/src/phase-manager.ts +++ b/src/phase-manager.ts @@ -44,6 +44,7 @@ import { MoveEffectPhase } from "#phases/move-effect-phase"; import { MoveEndPhase } from "#phases/move-end-phase"; import { MoveHeaderPhase } from "#phases/move-header-phase"; import { MovePhase } from "#phases/move-phase"; +import { MoveReflectPhase } from "#phases/move-reflect-phase"; import { MysteryEncounterBattlePhase, MysteryEncounterBattleStartCleanupPhase, @@ -157,6 +158,7 @@ const PHASES = Object.freeze({ MoveEffectPhase, MoveEndPhase, MoveHeaderPhase, + MoveReflectPhase, MovePhase, MysteryEncounterPhase, MysteryEncounterOptionSelectedPhase, diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index c57e0f6cead..6a43ae73923 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -1,7 +1,6 @@ import { applyAbAttrs } from "#abilities/apply-ab-attrs"; import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; -import type { Phase } from "#app/phase"; import { ConditionalProtectTag } from "#data/arena-tag"; import { MoveAnim } from "#data/battle-anims"; import { DamageProtectedTag, ProtectedTag, SemiInvulnerableTag, SubstituteTag, TypeBoostTag } from "#data/battler-tags"; @@ -19,7 +18,7 @@ import { MoveFlags } from "#enums/move-flags"; import { MoveId } from "#enums/move-id"; import { MoveResult } from "#enums/move-result"; import { MoveTarget } from "#enums/move-target"; -import { isReflected, MoveUseMode } from "#enums/move-use-mode"; +import { MoveUseMode } from "#enums/move-use-mode"; import { PokemonType } from "#enums/pokemon-type"; import type { Pokemon } from "#field/pokemon"; import { @@ -33,8 +32,7 @@ import { } from "#modifiers/modifier"; import { applyFilteredMoveAttrs, applyMoveAttrs } from "#moves/apply-attrs"; import type { Move, MoveAttr } from "#moves/move"; -import { getMoveTargets, isFieldTargeted } from "#moves/move-utils"; -import { PokemonMove } from "#moves/pokemon-move"; +import { isFieldTargeted, isMoveReflectableBy } from "#moves/move-utils"; import { PokemonPhase } from "#phases/pokemon-phase"; import { DamageAchv } from "#system/achv"; import type { DamageResult } from "#types/damage-result"; @@ -67,12 +65,6 @@ export class MoveEffectPhase extends PokemonPhase { /** Is this the last strike of a move? */ private lastHit: boolean; - /** - * Phases queued during moves; used to add a new MovePhase for reflected moves after triggering. - * TODO: Remove this and move the reflection logic to ability-side - */ - private queuedPhases: Phase[] = []; - /** * @param useMode - The {@linkcode MoveUseMode} corresponding to how this move was used. */ @@ -147,39 +139,6 @@ export class MoveEffectPhase extends PokemonPhase { return targets; } - /** - * Queue the phaes that should occur when the target reflects the move back to the user - * @param user - The {@linkcode Pokemon} using this phase's invoked move - * @param target - The {@linkcode Pokemon} that is reflecting the move - * TODO: Rework this to use `onApply` of Magic Coat - */ - private queueReflectedMove(user: Pokemon, target: Pokemon): void { - const newTargets = this.move.isMultiTarget() - ? getMoveTargets(target, this.move.id).targets - : [user.getBattlerIndex()]; - // TODO: ability displays should be handled by the ability - if (!target.getTag(BattlerTagType.MAGIC_COAT)) { - this.queuedPhases.push( - globalScene.phaseManager.create( - "ShowAbilityPhase", - target.getBattlerIndex(), - target.getPassiveAbility().hasAttr("ReflectStatusMoveAbAttr"), - ), - ); - this.queuedPhases.push(globalScene.phaseManager.create("HideAbilityPhase")); - } - - this.queuedPhases.push( - globalScene.phaseManager.create( - "MovePhase", - target, - newTargets, - new PokemonMove(this.move.id), - MoveUseMode.REFLECTED, - ), - ); - } - /** * Apply the move to each of the resolved targets. * @param targets - The resolved set of targets of the move @@ -217,7 +176,7 @@ export class MoveEffectPhase extends PokemonPhase { applyMoveAttrs("MissEffectAttr", user, target, this.move); break; case HitCheckResult.REFLECTED: - this.queueReflectedMove(user, target); + globalScene.phaseManager.appendNewToPhase("MoveEndPhase", "MoveReflectPhase", target, user, this.move); break; case HitCheckResult.PENDING: case HitCheckResult.ERROR: @@ -344,9 +303,6 @@ export class MoveEffectPhase extends PokemonPhase { return; } - if (this.queuedPhases.length) { - globalScene.phaseManager.appendToPhase(this.queuedPhases, "MoveEndPhase"); - } const moveType = user.getMoveType(this.move, true); if (this.move.category !== MoveCategory.STATUS && !user.stellarTypesBoosted.includes(moveType)) { user.stellarTypesBoosted.push(moveType); @@ -531,8 +487,8 @@ export class MoveEffectPhase extends PokemonPhase { return [HitCheckResult.PROTECTED, 0]; } - // Reflected moves cannot be reflected again - if (!isReflected(this.useMode) && move.doesFlagEffectApply({ flag: MoveFlags.REFLECTABLE, user, target })) { + // Check for magic bounce + if (isMoveReflectableBy(move, target, this.useMode)) { return [HitCheckResult.REFLECTED, 0]; } diff --git a/src/phases/move-reflect-phase.ts b/src/phases/move-reflect-phase.ts new file mode 100644 index 00000000000..4757f6e0684 --- /dev/null +++ b/src/phases/move-reflect-phase.ts @@ -0,0 +1,40 @@ +import { applyAbAttrs } from "#abilities/apply-ab-attrs"; +import { Phase } from "#app/phase"; +import type { MagicCoatTag } from "#data/battler-tags"; +import { BattlerTagType } from "#enums/battler-tag-type"; +// biome-ignore-start lint/correctness/noUnusedImports: TSDoc +import type { MoveId } from "#enums/move-id"; +import type { Pokemon } from "#field/pokemon"; +import type { Move } from "#types/move-types"; +// biome-ignore-end lint/correctness/noUnusedImports: TSDoc + +/** + * The phase where Pokemon reflect moves via {@linkcode MoveId.MAGIC_COAT | Magic Coat} or {@linkcode AbilityId.MAGIC_BOUNCE | Magic Bounce}. + */ +export class MoveReflectPhase extends Phase { + public override readonly phaseName = "MoveReflectPhase"; + /** The {@linkcode Pokemon} doing the reflecting. */ + private readonly pokemon: Pokemon; + /** The pokemon having originally used the move. */ + private opponent: Pokemon; + /** The {@linkcode Move} being reflected. */ + private readonly move: Move; + + constructor(pokemon: Pokemon, opponent: Pokemon, move: Move) { + super(); + this.pokemon = pokemon; + this.opponent = opponent; + this.move = move; + } + + override start(): void { + // Magic Coat takes precedeence over Magic Bounce if both apply at once + const magicCoatTag = this.pokemon.getTag(BattlerTagType.MAGIC_COAT) as MagicCoatTag | undefined; + if (magicCoatTag) { + magicCoatTag.apply(this.pokemon, this.opponent, this.move); + } else { + applyAbAttrs("ReflectStatusMoveAbAttr", { pokemon: this.pokemon, opponent: this.opponent, move: this.move }); + } + super.end(); + } +} diff --git a/test/moves/magic-coat-magic-bounce.test.ts b/test/moves/magic-coat-magic-bounce.test.ts index 1fbfa836705..275076c79f5 100644 --- a/test/moves/magic-coat-magic-bounce.test.ts +++ b/test/moves/magic-coat-magic-bounce.test.ts @@ -10,7 +10,6 @@ import { MoveUseMode } from "#enums/move-use-mode"; import { SpeciesId } from "#enums/species-id"; import { Stat } from "#enums/stat"; import { StatusEffect } from "#enums/status-effect"; -import type { EnemyPokemon } from "#field/pokemon"; import { GameManager } from "#test/test-utils/game-manager"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -70,7 +69,7 @@ describe("Moves - Reflecting effects", () => { expect(karp2).toHaveStatStage(Stat.ATK, -2); }); - // TODO: This is broken... + // TODO: This is broken - failed moves never make it to a MEP it.todo("should still bounce back a move that would otherwise fail", async () => { game.override.enemyAbility(AbilityId.INSOMNIA); await game.classicMode.startBattle([SpeciesId.MAGIKARP]); @@ -144,12 +143,13 @@ describe("Moves - Reflecting effects", () => { }); it("should not cause the bounced move to count for encore", async () => { - game.override.battleStyle("double").enemyAbility(AbilityId.MAGIC_BOUNCE); + game.override.battleStyle("double"); await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.ABRA]); // Fake abra having mold breaker and the enemy having used Tackle - const [, abra, enemy1, enemy2] = game.scene.getField(); + const [, abra, enemy1] = game.scene.getField(); game.field.mockAbility(abra, AbilityId.MOLD_BREAKER); + game.field.mockAbility(enemy1, AbilityId.MAGIC_BOUNCE); game.move.changeMoveset(enemy1, [MoveId.TACKLE, MoveId.SPLASH]); enemy1.pushMoveHistory({ move: MoveId.TACKLE, targets: [BattlerIndex.PLAYER], useMode: MoveUseMode.NORMAL }); @@ -157,17 +157,18 @@ describe("Moves - Reflecting effects", () => { game.move.use(MoveId.GROWL, BattlerIndex.PLAYER); game.move.use(MoveId.ENCORE, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY); await game.move.selectEnemyMove(MoveId.SPLASH); - await game.killPokemon(enemy2 as EnemyPokemon); + await game.move.forceEnemyMove(MoveId.SPLASH); await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]); - await game.toEndOfTurn(); + await game.toNextTurn(); + console.log(enemy1.getLastXMoves(-1)); // Encore locked into Tackle, replacing the enemy's Growl with another Tackle - expect(enemy1.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(MoveId.TACKLE); + expect(enemy1.getTag(BattlerTagType.ENCORE)?.["moveId"]).toBe(MoveId.TACKLE); expect(enemy1).toHaveUsedMove({ move: MoveId.TACKLE, useMode: MoveUseMode.NORMAL }); }); it("should boost stomping tantrum after a failed bounce", async () => { - await game.override.ability(AbilityId.INSOMNIA); + game.override.ability(AbilityId.INSOMNIA); await game.classicMode.startBattle([SpeciesId.BULBASAUR]); const enemy = game.field.getEnemyPokemon();