diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 2c67c4d2cde..5b8f4f5c752 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -2352,14 +2352,14 @@ export default class BattleScene extends SceneBase { } /** - * Adds Phase to the end of phaseQueuePrepend, or at phaseQueuePrependSpliceIndex - * @param phase {@linkcode Phase} the phase to add + * Adds Phase(s) to the end of phaseQueuePrepend, or at phaseQueuePrependSpliceIndex + * @param phases {@linkcode Phase} the phase(s) to add */ - unshiftPhase(phase: Phase): void { + unshiftPhase(...phases: Phase[]): void { if (this.phaseQueuePrependSpliceIndex === -1) { - this.phaseQueuePrepend.push(phase); + this.phaseQueuePrepend.push(...phases); } else { - this.phaseQueuePrepend.splice(this.phaseQueuePrependSpliceIndex, 0, phase); + this.phaseQueuePrepend.splice(this.phaseQueuePrependSpliceIndex, 0, ...phases); } } @@ -2497,32 +2497,38 @@ export default class BattleScene extends SceneBase { * @param targetPhase {@linkcode Phase} the type of phase to search for in phaseQueue * @returns boolean if a targetPhase was found and added */ - prependToPhase(phase: Phase, targetPhase: Constructor): boolean { + prependToPhase(phase: Phase | Phase [], targetPhase: Constructor): boolean { + if (!Array.isArray(phase)) { + phase = [ phase ]; + } const targetIndex = this.phaseQueue.findIndex(ph => ph instanceof targetPhase); if (targetIndex !== -1) { - this.phaseQueue.splice(targetIndex, 0, phase); + this.phaseQueue.splice(targetIndex, 0, ...phase); return true; } else { - this.unshiftPhase(phase); + this.unshiftPhase(...phase); return false; } } /** - * Tries to add the input phase to index after target phase in the {@linkcode phaseQueue}, else simply calls {@linkcode unshiftPhase()} - * @param phase {@linkcode Phase} the phase to be added + * Tries to add the input phase(s) to index after target phase in the {@linkcode phaseQueue}, else simply calls {@linkcode unshiftPhase()} + * @param phase {@linkcode Phase} the phase(s) to be added * @param targetPhase {@linkcode Phase} the type of phase to search for in {@linkcode phaseQueue} * @returns `true` if a `targetPhase` was found to append to */ - appendToPhase(phase: Phase, targetPhase: Constructor): boolean { + appendToPhase(phase: Phase | Phase[], targetPhase: Constructor): boolean { + if (!Array.isArray(phase)) { + phase = [ phase ]; + } const targetIndex = this.phaseQueue.findIndex(ph => ph instanceof targetPhase); if (targetIndex !== -1 && this.phaseQueue.length > targetIndex) { - this.phaseQueue.splice(targetIndex + 1, 0, phase); + this.phaseQueue.splice(targetIndex + 1, 0, ...phase); return true; } else { - this.unshiftPhase(phase); + this.unshiftPhase(...phase); return false; } } diff --git a/src/data/ability.ts b/src/data/ability.ts index 8f0698e38b9..94318156734 100644 --- a/src/data/ability.ts +++ b/src/data/ability.ts @@ -4493,6 +4493,13 @@ 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 Moves.MAGIC_COAT | Magic Coat}. + */ +export class ReflectStatusMoveAbAttr extends AbAttr { } + export class UncopiableAbilityAbAttr extends AbAttr { constructor() { super(false); @@ -5809,7 +5816,11 @@ export function initAbilities() { }, Stat.SPD, 1) .attr(PostIntimidateStatStageChangeAbAttr, [ Stat.SPD ], 1), new Ability(Abilities.MAGIC_BOUNCE, 5) + .attr(ReflectStatusMoveAbAttr) .ignorable() + // Interactions with stomping tantrum, instruct, and other moves that + // rely on move history + .edgeCase() .unimplemented(), new Ability(Abilities.SAP_SIPPER, 5) .attr(TypeImmunityStatStageChangeAbAttr, Type.GRASS, Stat.ATK, 1) diff --git a/src/data/move.ts b/src/data/move.ts index 5d1034f84e7..acb0f65e54c 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -5343,6 +5343,7 @@ export class AddBattlerTagAttr extends MoveEffectAttr { case BattlerTagType.INGRAIN: case BattlerTagType.IGNORE_ACCURACY: case BattlerTagType.AQUA_RING: + case BattlerTagType.MAGIC_COAT: return 3; case BattlerTagType.PROTECTED: case BattlerTagType.FLYING: @@ -9147,8 +9148,11 @@ export function initMoves() { new AttackMove(Moves.SUPERPOWER, Type.FIGHTING, MoveCategory.PHYSICAL, 120, 100, 5, -1, 0, 3) .attr(StatStageChangeAttr, [ Stat.ATK, Stat.DEF ], -1, true), new SelfStatusMove(Moves.MAGIC_COAT, Type.PSYCHIC, -1, 15, -1, 4, 3) - .attr(AddBattlerTagAttr, BattlerTagType.MAGIC_COAT, true, true) - .unimplemented(), + .attr(AddBattlerTagAttr, BattlerTagType.MAGIC_COAT, true, false) + // Interactions with stomping tantrum, instruct, and other moves that + // rely on move history + // Also will not reflect roar / whirlwind if the target has ForceSwitchOutImmunityAbAttr + .edgeCase(), new SelfStatusMove(Moves.RECYCLE, Type.NORMAL, -1, 10, -1, 0, 3) .unimplemented(), new AttackMove(Moves.REVENGE, Type.FIGHTING, MoveCategory.PHYSICAL, 60, 100, 10, -1, -4, 3) diff --git a/src/enums/battler-tag-type.ts b/src/enums/battler-tag-type.ts index dc1ab7b0253..719b08c5b81 100644 --- a/src/enums/battler-tag-type.ts +++ b/src/enums/battler-tag-type.ts @@ -94,5 +94,5 @@ export enum BattlerTagType { PSYCHO_SHIFT = "PSYCHO_SHIFT", ENDURE_TOKEN = "ENDURE_TOKEN", POWDER = "POWDER", - MAGIC_COAT = "MAGIC_COAT" + MAGIC_COAT = "MAGIC_COAT", } diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index fff8caf38b5..cd1b219ad9d 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -12,6 +12,7 @@ import { PostAttackAbAttr, PostDamageAbAttr, PostDefendAbAttr, + ReflectStatusMoveAbAttr, TypeImmunityAbAttr, } from "#app/data/ability"; import { ArenaTagSide, ConditionalProtectTag } from "#app/data/arena-tag"; @@ -31,6 +32,7 @@ import { AttackMove, DelayedAttackAttr, FlinchAttr, + getMoveTargets, HitsTagAttr, MissEffectAttr, MoveCategory, @@ -47,7 +49,7 @@ import { } from "#app/data/move"; import { SpeciesFormChangePostMoveTrigger } from "#app/data/pokemon-forms"; import { Type } from "#enums/type"; -import type { PokemonMove } from "#app/field/pokemon"; +import { PokemonMove } from "#app/field/pokemon"; import type Pokemon from "#app/field/pokemon"; import { HitResult, MoveResult } from "#app/field/pokemon"; import { getPokemonNameWithAffix } from "#app/messages"; @@ -63,14 +65,25 @@ import { BooleanHolder, executeIf, isNullOrUndefined, NumberHolder } from "#app/ import { BattlerTagType } from "#enums/battler-tag-type"; import type { Moves } from "#enums/moves"; import i18next from "i18next"; +import { Stat } from "#app/enums/stat"; +import { ArenaTagType } from "#app/enums/arena-tag-type"; +import { MessagePhase } from "./message-phase"; +import type { Phase } from "#app/phase"; +import { ShowAbilityPhase } from "./show-ability-phase"; +import { MovePhase } from "./move-phase"; export class MoveEffectPhase extends PokemonPhase { public move: PokemonMove; protected targets: BattlerIndex[]; + protected reflected: boolean = false; - constructor(battlerIndex: BattlerIndex, targets: BattlerIndex[], move: PokemonMove) { + /** + * @param reflected Indicates that the move was reflected by the user due to magic coat or magic bounce + */ + constructor(battlerIndex: BattlerIndex, targets: BattlerIndex[], move: PokemonMove, reflected: boolean = false) { super(battlerIndex); this.move = move; + this.reflected = reflected; /** * In double battles, if the right Pokemon selects a spread move and the left Pokemon dies * with no party members available to switch in, then the right Pokemon takes the index @@ -177,12 +190,14 @@ export class MoveEffectPhase extends PokemonPhase { && (targets[0]?.getAbility()?.getAttrs(TypeImmunityAbAttr)?.[0]?.getImmuneType() === user.getMoveType(move)) && !targets[0]?.getTag(SemiInvulnerableTag); + const mayBounce = move.hasFlag(MoveFlags.REFLECTABLE) && !this.reflected; + /** - * If no targets are left for the move to hit (FAIL), or the invoked move is single-target + * If no targets are left for the move to hit (FAIL), or the invoked move is non-reflectable, 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)) { + if (!hasActiveTargets || (mayBounce && !move.hasAttr(VariableTargetAttr) && !move.isMultiTarget() && !targetHitChecks[this.targets[0]] && !targets[0].getTag(ProtectedTag) && !isImmune)) { this.stopMultiHit(); if (hasActiveTargets) { globalScene.queueMessage(i18next.t("battle:attackMissed", { pokemonNameWithAffix: this.getFirstTarget() ? getPokemonNameWithAffix(this.getFirstTarget()!) : "" })); @@ -204,12 +219,30 @@ export class MoveEffectPhase extends PokemonPhase { new MoveAnim(move.id as Moves, user, this.getFirstTarget()!.getBattlerIndex(), playOnEmptyField).play(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; + + // Prevent ENEMY_SIDE targeted moves from occurring twice in double battles + // and determine which enemy will magic bounce based on speed order, respecting trick room + const trueTargets: Pokemon[] = move.moveTarget !== MoveTarget.ENEMY_SIDE ? targets : (() => { + const magicCoatTargets = targets.filter(t => t.getTag(BattlerTagType.MAGIC_COAT) !== null); + + // only magic coat effect cares about order + if (!mayBounce || magicCoatTargets.length === 0) { + return [ targets[0] ]; } + const reversed = globalScene.arena.hasTag(ArenaTagType.TRICK_ROOM); + const sortedTargets = targets + .sort((a: Pokemon, b: Pokemon) => { + const aSpeed = a?.getEffectiveStat(Stat.SPD) ?? 0; + const bSpeed = b?.getEffectiveStat(Stat.SPD) ?? 0; + return reversed ? aSpeed - bSpeed : bSpeed - aSpeed; + }) + .filter(t => t.getTag(BattlerTagType.MAGIC_COAT) !== null); + return sortedTargets.length === 1 ? sortedTargets : [ sortedTargets[globalScene.randBattleSeedInt(sortedTargets.length)] ]; + })(); + + const queuedPhases: Phase[] = []; + for (const target of trueTargets) { /** 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)? */ @@ -231,13 +264,34 @@ export class MoveEffectPhase extends PokemonPhase { || (this.move.getMove().category !== MoveCategory.STATUS && target.findTags(t => t instanceof DamageProtectedTag).find(t => target.lapseTag(t.tagType)))); + /** Is the target hidden by the effects of its Commander ability? */ + const isCommanding = globalScene.currentBattle.double && target.getAlly()?.getTag(BattlerTagType.COMMANDED)?.getSourcePokemon() === target; + + /** Is the target reflecting status moves from the magic coat move? */ + const isReflecting = target.getTag(BattlerTagType.MAGIC_COAT) !== null; + + /** Is the target's magic bounce ability not ignored and able to reflect this move? */ + const canMagicBounce = !(isReflecting || move.checkFlag(MoveFlags.IGNORE_ABILITIES, user, target) && target.hasAbilityWithAttr(ReflectStatusMoveAbAttr)); + + /** Is the target protected and reflecting the effect */ + const willBounce = !isProtected && !this.reflected && !isCommanding && (isReflecting || canMagicBounce); + + /** If the move will bounce, then queue the bounce and move on to the next target*/ + if (!target.switchOutStatus && willBounce) { + const newTargets = move.isMultiTarget() ? getMoveTargets(target, move.id).targets : [ user.getBattlerIndex() ]; + if (!isReflecting && canMagicBounce) { + queuedPhases.push(new ShowAbilityPhase(target.getBattlerIndex(), target.getPassiveAbility().hasAttr(ReflectStatusMoveAbAttr))); + } + queuedPhases.push(new MessagePhase(i18next.t("battle:magicCoatBounce", { pokemonNameWithAffix: getPokemonNameWithAffix(target) }))); + queuedPhases.push(new MovePhase(target, newTargets, new PokemonMove(move.id, 0, 0, true), true, true, true)); + + } + /** 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); - /** Is the target hidden by the effects of its Commander ability? */ - const isCommanding = globalScene.currentBattle.double && target.getAlly()?.getTag(BattlerTagType.COMMANDED)?.getSourcePokemon() === target; /** * If the move missed a target, stop all future hits against that target @@ -364,6 +418,8 @@ export class MoveEffectPhase extends PokemonPhase { applyAttrs.push(k); } + // Apply queued phases + queuedPhases.forEach(p => globalScene.unshiftPhase(p)); // 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) : diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 5330540c8b2..fdd8029edb4 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -58,6 +58,7 @@ export class MovePhase extends BattlePhase { protected ignorePp: boolean; protected failed: boolean = false; protected cancelled: boolean = false; + protected reflected: boolean = false; public get pokemon(): Pokemon { return this._pokemon; @@ -84,10 +85,12 @@ export class MovePhase extends BattlePhase { } /** - * @param followUp Indicates that the move being uses is a "follow-up" - for example, a move being used by Metronome or Dancer. + * @param followUp Indicates that the move being used is a "follow-up" - for example, a move being used by Metronome or Dancer. * Follow-ups bypass a few failure conditions, including flinches, sleep/paralysis/freeze and volatile status checks, etc. + * @param reflected Indicates that the move was reflected by Magic Coat or Magic Bounce. + * Reflected moves cannot be reflected again and will not trigger Dancer. */ - constructor(pokemon: Pokemon, targets: BattlerIndex[], move: PokemonMove, followUp: boolean = false, ignorePp: boolean = false) { + constructor(pokemon: Pokemon, targets: BattlerIndex[], move: PokemonMove, followUp: boolean = false, ignorePp: boolean = false, reflected: boolean = false) { super(); this.pokemon = pokemon; @@ -95,6 +98,7 @@ export class MovePhase extends BattlePhase { this.move = move; this.followUp = followUp; this.ignorePp = ignorePp; + this.reflected = reflected; } /** @@ -140,7 +144,7 @@ export class MovePhase extends BattlePhase { } // Check move to see if arena.ignoreAbilities should be true. - if (!this.followUp) { + if (!this.followUp || this.reflected) { if (this.move.getMove().checkFlag(MoveFlags.IGNORE_ABILITIES, this.pokemon, null)) { globalScene.arena.setIgnoreAbilities(true, this.pokemon.getBattlerIndex()); } @@ -335,7 +339,7 @@ export class MovePhase extends BattlePhase { */ if (success) { applyPreAttackAbAttrs(PokemonTypeChangeAbAttr, this.pokemon, null, this.move.getMove()); - globalScene.unshiftPhase(new MoveEffectPhase(this.pokemon.getBattlerIndex(), this.targets, this.move)); + globalScene.unshiftPhase(new MoveEffectPhase(this.pokemon.getBattlerIndex(), this.targets, this.move, this.reflected)); } else { if ([ Moves.ROAR, Moves.WHIRLWIND, Moves.TRICK_OR_TREAT, Moves.FORESTS_CURSE ].includes(this.move.moveId)) { diff --git a/src/test/abilities/magic_bounce.test.ts b/src/test/abilities/magic_bounce.test.ts index 67971af4571..8563f4df3a3 100644 --- a/src/test/abilities/magic_bounce.test.ts +++ b/src/test/abilities/magic_bounce.test.ts @@ -148,22 +148,6 @@ describe("Abilities - Magic Bounce", () => { expect(game.scene.getEnemyPokemon()!.getTag(BattlerTagType.CURSED)).toBeDefined(); }); - // todo: a move reflected by magic bounce counts as though it failed. - - it("should not count the bounced move as the last move used", async () => { - game.override.enemyMoveset([ Moves.INSTRUCT, Moves.GROWL, Moves.SPLASH ]); - game.override.battleType("double"); - await game.classicMode.startBattle([ Species.MAGIKARP, Species.MAGIKARP ]); - - game.move.select(Moves.GROWL, 0); - game.move.select(Moves.SPLASH, 1); - game.forceEnemyMove(Moves.SPLASH); - game.forceEnemyMove(Moves.INSTRUCT); - game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2 ]); - await game.phaseInterceptor.to("BerryPhase"); - expect(game.scene.getEnemyPokemon()!.getLastXMoves(1)[0].move).toEqual([ Moves.SPLASH ]); - }); - it("should cause stomping tantrum to double in power if the bounced move fails", async () => { game.override.moveset([ Moves.SPLASH ]); await game.classicMode.startBattle([ Species.MAGIKARP ]);