Finish magic bounce impl

This commit is contained in:
Sirz Benjie 2025-02-02 12:04:11 -06:00
parent 79fefa9f67
commit acd27eb1da
No known key found for this signature in database
GPG Key ID: D4BFA840253CD6D7
4 changed files with 182 additions and 56 deletions

View File

@ -5818,10 +5818,9 @@ export function initAbilities() {
new Ability(Abilities.MAGIC_BOUNCE, 5)
.attr(ReflectStatusMoveAbAttr)
.ignorable()
// Interactions with stomping tantrum, instruct, and other moves that
// Interactions with stomping tantrum, instruct, encore, and probably other moves that
// rely on move history
.edgeCase()
.unimplemented(),
.edgeCase(),
new Ability(Abilities.SAP_SIPPER, 5)
.attr(TypeImmunityStatStageChangeAbAttr, Type.GRASS, Stat.ATK, 1)
.ignorable(),

View File

@ -62,15 +62,16 @@ import {
} from "#app/modifier/modifier";
import { PokemonPhase } from "#app/phases/pokemon-phase";
import { BooleanHolder, executeIf, isNullOrUndefined, NumberHolder } from "#app/utils";
import { type nil } from "#app/utils";
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";
import { MoveEndPhase } from "./move-end-phase";
export class MoveEffectPhase extends PokemonPhase {
public move: PokemonMove;
@ -190,7 +191,7 @@ 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;
const mayBounce = move.hasFlag(MoveFlags.REFLECTABLE) && !this.reflected && targets.some(t => t.hasAbilityWithAttr(ReflectStatusMoveAbAttr) || !!t.getTag(BattlerTagType.MAGIC_COAT));
/**
* If no targets are left for the move to hit (FAIL), or the invoked move is non-reflectable, single-target
@ -223,22 +224,21 @@ export class MoveEffectPhase extends PokemonPhase {
// 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);
const magicCoatTargets = targets.filter(t => t.getTag(BattlerTagType.MAGIC_COAT) || t.hasAbilityWithAttr(ReflectStatusMoveAbAttr));
// only magic coat effect cares about order
if (!mayBounce || magicCoatTargets.length === 0) {
return [ targets[0] ];
} else if (magicCoatTargets.length === 1) {
return magicCoatTargets;
}
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)] ];
// Filter the list of magic coat targets to those with the highest speed, or lowest if trick room is active.
const speeds = magicCoatTargets.map(p => p.getEffectiveStat(Stat.SPD) ?? 0);
const targetSpeed = globalScene.arena.hasTag(ArenaTagType.TRICK_ROOM) ? Math.min(...speeds) : Math.max(...speeds);
const filteredTargets = magicCoatTargets.filter((_, idx) => speeds[idx] === targetSpeed);
// In the event of a speed tie, choose a pokemon at random that will bounce the move.
return filteredTargets.length === 1 ? filteredTargets : [ filteredTargets[globalScene.randBattleSeedInt(filteredTargets.length)] ];
})();
const queuedPhases: Phase[] = [];
@ -255,7 +255,7 @@ export class MoveEffectPhase extends PokemonPhase {
}
/** Is the target protected by Protect, etc. or a relevant conditional protection effect? */
const isProtected = (
const isProtected = !([ MoveTarget.ENEMY_SIDE, MoveTarget.BOTH_SIDES ].includes(this.move.getMove().moveTarget)) && (
bypassIgnoreProtect.value
|| !this.move.getMove().checkFlag(MoveFlags.IGNORE_PROTECT, user, target))
&& (hasConditionalProtectApplied.value
@ -268,21 +268,30 @@ export class MoveEffectPhase extends PokemonPhase {
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;
const isReflecting = !!target.getTag(BattlerTagType.MAGIC_COAT);
/** 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));
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);
const semiInvulnerableTag = target.getTag(SemiInvulnerableTag);
/** Is the target in a semi-invulnerable state that isn't being bypassed by this move? */
const activeSemiInvulnerability = !!semiInvulnerableTag &&
!(this.checkBypassSemiInvuln(semiInvulnerableTag)
|| this.checkBypassAccAndInvuln(target));
/** If the move will bounce, then queue the bounce and move on to the next target*/
/** Is the target reflecting the effect, not protected, and not in an semi-invulnerable state?*/
const willBounce = (!isProtected && !this.reflected && !isCommanding
&& move.hasFlag(MoveFlags.REFLECTABLE)
&& (isReflecting || canMagicBounce)
&& !activeSemiInvulnerability);
// 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) {
if (!isReflecting) {
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));
continue;
}
@ -290,7 +299,7 @@ export class MoveEffectPhase extends PokemonPhase {
/** 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);
&& !semiInvulnerableTag;
/**
@ -419,7 +428,9 @@ export class MoveEffectPhase extends PokemonPhase {
}
// Apply queued phases
queuedPhases.forEach(p => globalScene.unshiftPhase(p));
if (queuedPhases.length) {
globalScene.appendToPhase(queuedPhases, MoveEndPhase);
}
// 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) :
@ -635,12 +646,7 @@ export class MoveEffectPhase extends PokemonPhase {
}
}
if (user.hasAbilityWithAttr(AlwaysHitAbAttr) || target.hasAbilityWithAttr(AlwaysHitAbAttr)) {
return true;
}
// 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) {
if (this.checkBypassAccAndInvuln(target)) {
return true;
}
@ -648,15 +654,12 @@ export class MoveEffectPhase extends PokemonPhase {
return true;
}
if (target.getTag(BattlerTagType.TELEKINESIS) && !target.getTag(SemiInvulnerableTag) && !this.move.getMove().hasAttr(OneHitKOAttr)) {
const semiInvulnerableTag = target.getTag(SemiInvulnerableTag);
if (target.getTag(BattlerTagType.TELEKINESIS) && !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))
) {
if (this.checkBypassSemiInvuln(semiInvulnerableTag)) {
return false;
}
@ -672,6 +675,58 @@ export class MoveEffectPhase extends PokemonPhase {
return rand < (moveAccuracy * accuracyMultiplier);
}
/**
* Check whether the move should bypass *both* the accuracy *and* semi-invulnerable states.
* @param target - The {@linkcode Pokemon} targeted by the invoked move
* @returns `true` if the move should bypass accuracy and semi-invulnerability
*
* Accuracy and semi-invulnerability can be bypassed by:
* - An ability like {@linkcode Abilities.NO_GUARD | No Guard}
* - A poison type using {@linkcode Moves.TOXIC | Toxic}
* - A move like {@linkcode Moves.LOCK_ON | Lock-On} or {@linkcode Moves.MIND_READER | Mind Reader}.
* - A move like {@linkcode Moves.SPIEKS | Spikes} or {@linkcode Moves.SANDSTORM | Sandstorm} that targets the field
*
* Does *not* check against effects {@linkcode Moves.GLAIVE_RUSH | Glaive Rush} status (which
* should not bypass semi-invulnerability), or interactions like Earthquake hitting against Dig,
* (which should not bypass the accuracy check).
*
* @see {@linkcode hitCheck}
*/
public checkBypassAccAndInvuln(target: Pokemon) {
const user = this.getUserPokemon();
if (!user) {
return false;
}
if ([ MoveTarget.USER, MoveTarget.ENEMY_SIDE, MoveTarget.USER_SIDE, MoveTarget.BOTH_SIDES ].includes(this.move.getMove().moveTarget)) {
return true;
}
if (user.hasAbilityWithAttr(AlwaysHitAbAttr) || target.hasAbilityWithAttr(AlwaysHitAbAttr)) {
return true;
}
if ((this.move.getMove().hasAttr(ToxicAccuracyAttr) && user.isOfType(Type.POISON))) {
return true;
}
// TODO: Fix lock on / mind reader check.
if (user.getTag(BattlerTagType.IGNORE_ACCURACY) && (user.getLastXMoves().find(() => true)?.targets || []).indexOf(target.getBattlerIndex()) !== -1) {
return true;
}
}
/**
* Check whether the move is able to ignore the given `semiInvulnerableTag`
* @param semiInvulnerableTag - The semiInvulnerbale tag to check against
* @returns `true` if the move can ignore the semi-invulnerable state
*/
public checkBypassSemiInvuln(semiInvulnerableTag: SemiInvulnerableTag | nil): boolean {
if (!semiInvulnerableTag) {
return false;
}
const move = this.move.getMove();
/** Does the move target the field instead of the target itself? */
const isIndirectTarget = move.moveTarget in [ MoveTarget.USER, MoveTarget.ENEMY_SIDE, MoveTarget.USER_SIDE, MoveTarget.BOTH_SIDES ];
return isIndirectTarget || move.getAttrs(HitsTagAttr).some(hta => hta.tagType === semiInvulnerableTag.tagType);
}
/** @returns The {@linkcode Pokemon} using this phase's invoked move */
public getUserPokemon(): Pokemon | null {
if (this.battlerIndex > BattlerIndex.ENEMY_2) {

View File

@ -547,7 +547,7 @@ export class MovePhase extends BattlePhase {
return;
}
globalScene.queueMessage(i18next.t("battle:useMove", {
globalScene.queueMessage(i18next.t(this.reflected ? "battle:magicCoatExe" : "battle:useMove", {
pokemonNameWithAffix: getPokemonNameWithAffix(this.pokemon),
moveName: this.move.getName()
}), 500);

View File

@ -1,14 +1,15 @@
import { BattlerIndex } from "#app/battle";
import { allAbilities } from "#app/data/ability";
import { ArenaTagSide } from "#app/data/arena-tag";
import { allMoves } from "#app/data/move";
import { ArenaTagType } from "#app/enums/arena-tag-type";
import { BattlerTagType } from "#app/enums/battler-tag-type";
import { Stat } from "#app/enums/stat";
import { StatusEffect } from "#app/enums/status-effect";
import { Abilities } from "#enums/abilities";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import GameManager from "#test/utils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Abilities - Magic Bounce", () => {
@ -134,11 +135,12 @@ describe("Abilities - Magic Bounce", () => {
game.move.select(Moves.SPIKES);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER)).toBe(1);
expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY)).toBe(0);
expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER)!["layers"]).toBe(1);
expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY)).toBeUndefined();
});
it("should not bounce back curse", async() => {
game.override.starterSpecies(Species.GASTLY);
await game.classicMode.startBattle([ Species.GASTLY ]);
game.override.moveset([ Moves.CURSE ]);
@ -148,9 +150,67 @@ describe("Abilities - Magic Bounce", () => {
expect(game.scene.getEnemyPokemon()!.getTag(BattlerTagType.CURSED)).toBeDefined();
});
it("should cause stomping tantrum to double in power if the bounced move fails", async () => {
it("should not cause encore to be interrupted after bouncing", async () => {
game.override.moveset([ Moves.SPLASH, Moves.GROWL, Moves.ENCORE ]);
game.override.enemyMoveset([ Moves.TACKLE, Moves.GROWL ]);
// game.override.ability(Abilities.MOLD_BREAKER);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
const playerPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!;
// Give the player MOLD_BREAKER for this turn to bypass Magic Bounce.
vi.spyOn(playerPokemon, "getAbility").mockReturnValue(allAbilities[Abilities.MOLD_BREAKER]);
// turn 1
game.move.select(Moves.ENCORE);
await game.forceEnemyMove(Moves.TACKLE);
await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]);
await game.toNextTurn();
expect(enemyPokemon.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(Moves.TACKLE);
// turn 2
vi.spyOn(playerPokemon, "getAbility").mockRestore();
game.move.select(Moves.GROWL);
await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]);
await game.phaseInterceptor.to("BerryPhase");
expect(enemyPokemon.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(Moves.TACKLE);
expect(enemyPokemon.getLastXMoves()[0].move).toBe(Moves.TACKLE);
});
// TODO: encore is failing if the last move was virtual.
it.todo("should not cause the bounced move to count for encore", async () => {
game.override.moveset([ Moves.SPLASH, Moves.GROWL, Moves.ENCORE ]);
game.override.enemyMoveset([ Moves.GROWL, Moves.TACKLE ]);
game.override.enemyAbility(Abilities.MAGIC_BOUNCE);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
const playerPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!;
// turn 1
game.move.select(Moves.GROWL);
await game.forceEnemyMove(Moves.TACKLE);
await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]);
await game.toNextTurn();
// Give the player MOLD_BREAKER for this turn to bypass Magic Bounce.
vi.spyOn(playerPokemon, "getAbility").mockReturnValue(allAbilities[Abilities.MOLD_BREAKER]);
// turn 2
game.move.select(Moves.ENCORE);
await game.forceEnemyMove(Moves.TACKLE);
await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]);
await game.phaseInterceptor.to("BerryPhase");
expect(enemyPokemon.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(Moves.TACKLE);
expect(enemyPokemon.getLastXMoves()[0].move).toBe(Moves.TACKLE);
});
// TODO: stomping tantrum should consider moves that were bounced.
it.todo("should cause stomping tantrum to double in power when the last move was bounced", async () => {
game.override.battleType("single");
await game.classicMode.startBattle([ Species.MAGIKARP ]);
game.override.moveset([ Moves.STOMPING_TANTRUM, Moves.CHARM ]);
const stomping_tantrum = allMoves[Moves.STOMPING_TANTRUM];
vi.spyOn(stomping_tantrum, "calculateBattlePower");
@ -163,26 +223,25 @@ describe("Abilities - Magic Bounce", () => {
expect(stomping_tantrum.calculateBattlePower).toHaveReturnedWith(150);
});
it("should properly cause the enemy's stomping tantrum to be doubled in power after bouncing and failing", async () => {
game.override.battleType("double");
game.override.enemyMoveset([ Moves.GROWL, Moves.STOMPING_TANTRUM, Moves.CHARM, Moves.SPLASH ]);
game.override.enemyLevel(50);
await game.classicMode.startBattle([ Species.MAGIKARP ]);
// TODO: stomping tantrum should consider moves that were bounced.
it.todo("should properly cause the enemy's stomping tantrum to be doubled in power after bouncing and failing", async () => {
game.override.enemyMoveset([ Moves.STOMPING_TANTRUM, Moves.SPLASH, Moves.CHARM ]);
await game.classicMode.startBattle([ Species.BULBASAUR ]);
const stomping_tantrum = allMoves[Moves.STOMPING_TANTRUM];
const enemy = game.scene.getEnemyPokemon()!;
vi.spyOn(stomping_tantrum, "calculateBattlePower");
game.move.select(Moves.CHARM, 0, BattlerIndex.ENEMY);
game.move.select(Moves.SPLASH, 1);
game.move.select(Moves.SPORE);
await game.forceEnemyMove(Moves.CHARM);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemy.getLastXMoves(1)[0].result).toBe("success");
game.move.select(Moves.STOMPING_TANTRUM, 0, BattlerIndex.ENEMY_2);
await game.phaseInterceptor.to("BerryPhase");
expect(stomping_tantrum.calculateBattlePower).toHaveReturnedWith(150);
expect(stomping_tantrum.calculateBattlePower).toHaveReturnedWith(75);
await game.toNextTurn();
game.move.select(Moves.GROWL, 0);
game.move.select(Moves.SPLASH, 1);
game.move.select(Moves.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(stomping_tantrum.calculateBattlePower).toHaveReturnedWith(75);
});
@ -213,7 +272,7 @@ describe("Abilities - Magic Bounce", () => {
vi.spyOn(attacker, "getAccuracyMultiplier").mockReturnValue(0.0);
game.move.select(Moves.SPORE);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getPlayerPokemon()!.status).toBeUndefined();
expect(game.scene.getPlayerPokemon()!.status?.effect).toBe(StatusEffect.SLEEP);
});
it("should take the accuracy of the magic bounce user into account", async () => {
@ -240,14 +299,27 @@ describe("Abilities - Magic Bounce", () => {
game.move.select(Moves.TRICK_ROOM, 1);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.arena.getTagOnSide(ArenaTagType.STICKY_WEB, ArenaTagSide.PLAYER)?.getSourcePokemon()?.id).toBe(enemy_1.id);
expect(game.scene.arena.getTagOnSide(ArenaTagType.STICKY_WEB, ArenaTagSide.PLAYER)?.getSourcePokemon()?.getBattlerIndex()).toBe(BattlerIndex.ENEMY);
game.scene.arena.removeTagOnSide(ArenaTagType.STICKY_WEB, ArenaTagSide.PLAYER, true);
// turn 2
game.move.select(Moves.STICKY_WEB, 0);
game.move.select(Moves.TRICK_ROOM, 1);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.arena.getTagOnSide(ArenaTagType.STICKY_WEB, ArenaTagSide.PLAYER)?.getSourcePokemon()?.id).toBe(enemy_2.id);
expect(game.scene.arena.getTagOnSide(ArenaTagType.STICKY_WEB, ArenaTagSide.PLAYER)?.getSourcePokemon()?.getBattlerIndex()).toBe(BattlerIndex.ENEMY_2);
});
it("should bounce back moves like spikes when the magic bounce user is semi-invulnerable", async () => {
await game.classicMode.startBattle([ Species.MAGIKARP ]);
game.override.moveset([ Moves.SPIKES ]);
game.override.enemyMoveset([ Moves.FLY ]);
game.move.select(Moves.SPIKES);
await game.forceEnemyMove(Moves.FLY);
await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER)!["layers"]).toBe(1);
});
});