diff --git a/src/battle-scene.ts b/src/battle-scene.ts index c0b6e2ba91e..d8c34fefdd6 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -768,6 +768,27 @@ export default class BattleScene extends SceneBase { : ret; } + /** + * Used in doubles battles to redirect moves from one pokemon to another when one faints or is removed from the field + * @param removedPokemon {@linkcode Pokemon} the pokemon that is being removed from the field (flee, faint), moves to be redirected FROM + * @param allyPokemon {@linkcode Pokemon} the pokemon that will have the moves be redirected TO + */ + redirectPokemonMoves(removedPokemon: Pokemon, allyPokemon: Pokemon): void { + // failsafe: if not a double battle just return + if (this.currentBattle.double === false) { + return; + } + if (allyPokemon?.isActive(true)) { + let targetingMovePhase: MovePhase; + do { + targetingMovePhase = this.findPhase(mp => mp instanceof MovePhase && mp.targets.length === 1 && mp.targets[0] === removedPokemon.getBattlerIndex() && mp.pokemon.isPlayer() !== allyPokemon.isPlayer()) as MovePhase; + if (targetingMovePhase && targetingMovePhase.targets[0] !== allyPokemon.getBattlerIndex()) { + targetingMovePhase.targets[0] = allyPokemon.getBattlerIndex(); + } + } while (targetingMovePhase); + } + } + /** * Returns the ModifierBar of this scene, which is declared private and therefore not accessible elsewhere * @returns {ModifierBar} diff --git a/src/data/move.ts b/src/data/move.ts index 827c3716d9b..14e7738b948 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -4883,13 +4883,18 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { user.scene.prependToPhase(new SwitchSummonPhase(user.scene, switchOutTarget.getFieldIndex(), (user.scene.currentBattle.trainer ? user.scene.currentBattle.trainer.getNextSummonIndex((switchOutTarget as EnemyPokemon).trainerSlot) : 0), false, this.batonPass, false), MoveEndPhase); } } else { - // Switch out logic for everything else - switchOutTarget.setVisible(false); + // Switch out logic for everything else (eg: WILD battles) + switchOutTarget.leaveField(false); if (switchOutTarget.hp) { - switchOutTarget.hideInfo().then(() => switchOutTarget.destroy()); - switchOutTarget.scene.field.remove(switchOutTarget); + switchOutTarget.setWildFlee(true); user.scene.queueMessage(i18next.t("moveTriggers:fled", {pokemonName: getPokemonNameWithAffix(switchOutTarget)}), null, true, 500); + + // in double battles redirect potential moves off fled pokemon + if (switchOutTarget.scene.currentBattle.double) { + const allyPokemon = switchOutTarget.getAlly(); + switchOutTarget.scene.redirectPokemonMoves(switchOutTarget, allyPokemon); + } } if (!switchOutTarget.getAlly()?.isActive(true)) { @@ -7585,7 +7590,8 @@ export function initMoves() { new AttackMove(Moves.FROST_BREATH, Type.ICE, MoveCategory.SPECIAL, 60, 90, 10, 100, 0, 5) .attr(CritOnlyAttr), new AttackMove(Moves.DRAGON_TAIL, Type.DRAGON, MoveCategory.PHYSICAL, 60, 90, 10, -1, -6, 5) - .attr(ForceSwitchOutAttr), + .attr(ForceSwitchOutAttr) + .hidesTarget(), new SelfStatusMove(Moves.WORK_UP, Type.NORMAL, -1, 30, -1, 0, 5) .attr(StatChangeAttr, [ BattleStat.ATK, BattleStat.SPATK ], 1, true), new AttackMove(Moves.ELECTROWEB, Type.ELECTRIC, MoveCategory.SPECIAL, 55, 95, 15, 100, 0, 5) diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 84058cc656f..283f90f891c 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -88,6 +88,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { public luck: integer; public pauseEvolutions: boolean; public pokerus: boolean; + public wildFlee: boolean; public fusionSpecies: PokemonSpecies | null; public fusionFormIndex: integer; @@ -129,6 +130,8 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { this.species = species; this.pokeball = dataSource?.pokeball || PokeballType.POKEBALL; this.level = level; + this.wildFlee = false; + // Determine the ability index if (abilityIndex !== undefined) { this.abilityIndex = abilityIndex; // Use the provided ability index if it is defined @@ -298,14 +301,14 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Check if this pokemon is both not fainted and allowed to be in battle. + * Check if this pokemon is both not fainted (or a fled wild pokemon) and allowed to be in battle. * This is frequently a better alternative to {@link isFainted} * @returns {boolean} True if pokemon is allowed in battle */ isAllowedInBattle(): boolean { const challengeAllowed = new Utils.BooleanHolder(true); applyChallenges(this.scene.gameMode, ChallengeType.POKEMON_IN_BATTLE, this, challengeAllowed); - return !this.isFainted() && challengeAllowed.value; + return !this.isFainted() && !this.wildFlee && challengeAllowed.value; } isActive(onField?: boolean): boolean { @@ -1779,6 +1782,14 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { }); } + /** + * sets if the pokemon has fled (implies it's a wild pokemon) + * @param status - boolean + */ + setWildFlee(status: boolean): void { + this.wildFlee = status; + } + updateInfo(instant?: boolean): Promise { return this.battleInfo.updateInfo(this, instant); } diff --git a/src/phases.ts b/src/phases.ts index 7bc3ceeb55d..c4662df199d 100644 --- a/src/phases.ts +++ b/src/phases.ts @@ -2576,6 +2576,15 @@ export class BattleEndPhase extends BattlePhase { this.scene.updateModifiers().then(() => this.end()); } + + end() { + // removing pokemon at the end of a battle + for (const p of this.scene.getEnemyParty()) { + p.destroy(); + } + + super.end(); + } } export class NewBattlePhase extends BattlePhase { @@ -3830,6 +3839,7 @@ export class FaintPhase extends PokemonPhase { doFaint(): void { const pokemon = this.getPokemon(); + // Track total times pokemon have been KO'd for supreme overlord/last respects if (pokemon.isPlayer()) { this.scene.currentBattle.playerFaints += 1; @@ -3880,17 +3890,10 @@ export class FaintPhase extends PokemonPhase { } } + // in double battles redirect potential moves off fainted pokemon if (this.scene.currentBattle.double) { const allyPokemon = pokemon.getAlly(); - if (allyPokemon?.isActive(true)) { - let targetingMovePhase: MovePhase; - do { - targetingMovePhase = this.scene.findPhase(mp => mp instanceof MovePhase && mp.targets.length === 1 && mp.targets[0] === pokemon.getBattlerIndex() && mp.pokemon.isPlayer() !== allyPokemon.isPlayer()) as MovePhase; - if (targetingMovePhase && targetingMovePhase.targets[0] !== allyPokemon.getBattlerIndex()) { - targetingMovePhase.targets[0] = allyPokemon.getBattlerIndex(); - } - } while (targetingMovePhase); - } + this.scene.redirectPokemonMoves(pokemon, allyPokemon); } pokemon.lapseTags(BattlerTagLapseType.FAINT); diff --git a/src/test/moves/dragon_tail.test.ts b/src/test/moves/dragon_tail.test.ts new file mode 100644 index 00000000000..7374451e643 --- /dev/null +++ b/src/test/moves/dragon_tail.test.ts @@ -0,0 +1,166 @@ +import { allMoves } from "#app/data/move.js"; +import { SPLASH_ONLY } from "../utils/testUtils"; +import { BattleEndPhase, BerryPhase, TurnEndPhase} from "#app/phases.js"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; +import GameManager from "../utils/gameManager"; +import { getMovePosition } from "../utils/gameManagerUtils"; +import { BattlerIndex } from "#app/battle.js"; + +const TIMEOUT = 20 * 1000; + +describe("Moves - Dragon Tail", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override.battleType("single") + .moveset([Moves.DRAGON_TAIL, Moves.SPLASH]) + .enemySpecies(Species.WAILORD) + .enemyMoveset(SPLASH_ONLY) + .startingLevel(5) + .enemyLevel(5); + + vi.spyOn(allMoves[Moves.DRAGON_TAIL], "accuracy", "get").mockReturnValue(100); + }); + + test( + "Single battle should cause opponent to flee, and not crash", + async () => { + await game.startBattle([Species.DRATINI]); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + expect(enemyPokemon).toBeDefined(); + + game.doAttack(getMovePosition(game.scene, 0, Moves.DRAGON_TAIL)); + + await game.phaseInterceptor.to(BerryPhase); + + const isVisible = enemyPokemon.visible; + const hasFled = enemyPokemon.wildFlee; + expect(!isVisible && hasFled).toBe(true); + + // simply want to test that the game makes it this far without crashing + await game.phaseInterceptor.to(BattleEndPhase); + }, TIMEOUT + ); + + test( + "Single battle should cause opponent to flee, display ability, and not crash", + async () => { + game.override.enemyAbility(Abilities.ROUGH_SKIN); + await game.startBattle([Species.DRATINI]); + + const leadPokemon = game.scene.getPlayerPokemon()!; + expect(leadPokemon).toBeDefined(); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + expect(enemyPokemon).toBeDefined(); + + game.doAttack(getMovePosition(game.scene, 0, Moves.DRAGON_TAIL)); + + await game.phaseInterceptor.to(BerryPhase); + + const isVisible = enemyPokemon.visible; + const hasFled = enemyPokemon.wildFlee; + expect(!isVisible && hasFled).toBe(true); + expect(leadPokemon.hp).toBeLessThan(leadPokemon.getMaxHp()); + }, TIMEOUT + ); + + test( + "Double battles should proceed without crashing" , + async () => { + game.override.battleType("double").enemyMoveset(SPLASH_ONLY); + game.override.moveset([Moves.DRAGON_TAIL, Moves.SPLASH, Moves.FLAMETHROWER]) + .enemyAbility(Abilities.ROUGH_SKIN); + await game.startBattle([Species.DRATINI, Species.DRATINI, Species.WAILORD, Species.WAILORD]); + + const leadPokemon = game.scene.getParty()[0]!; + const secPokemon = game.scene.getParty()[1]!; + expect(leadPokemon).toBeDefined(); + expect(secPokemon).toBeDefined(); + + const enemyLeadPokemon = game.scene.currentBattle.enemyParty[0]!; + const enemySecPokemon = game.scene.currentBattle.enemyParty[1]!; + expect(enemyLeadPokemon).toBeDefined(); + expect(enemySecPokemon).toBeDefined(); + + game.doAttack(getMovePosition(game.scene, 0, Moves.DRAGON_TAIL)); + game.doSelectTarget(BattlerIndex.ENEMY); + + game.doAttack(getMovePosition(game.scene, 1, Moves.SPLASH)); + + await game.phaseInterceptor.to(TurnEndPhase); + + const isVisibleLead = enemyLeadPokemon.visible; + const hasFledLead = enemyLeadPokemon.wildFlee; + const isVisibleSec = enemySecPokemon.visible; + const hasFledSec = enemySecPokemon.wildFlee; + expect(!isVisibleLead && hasFledLead && isVisibleSec && !hasFledSec).toBe(true); + expect(leadPokemon.hp).toBeLessThan(leadPokemon.getMaxHp()); + + // second turn + + game.doAttack(getMovePosition(game.scene, 0, Moves.FLAMETHROWER)); + game.doSelectTarget(BattlerIndex.ENEMY_2); + game.doAttack(getMovePosition(game.scene, 1, Moves.SPLASH)); + + await game.phaseInterceptor.to(BerryPhase); + expect(enemySecPokemon.hp).toBeLessThan(enemySecPokemon.getMaxHp()); + }, TIMEOUT + ); + + test( + "Flee move redirection works" , + async () => { + game.override.battleType("double").enemyMoveset(SPLASH_ONLY); + game.override.moveset([Moves.DRAGON_TAIL, Moves.SPLASH, Moves.FLAMETHROWER]); + game.override.enemyAbility(Abilities.ROUGH_SKIN); + await game.startBattle([Species.DRATINI, Species.DRATINI, Species.WAILORD, Species.WAILORD]); + + const leadPokemon = game.scene.getParty()[0]!; + const secPokemon = game.scene.getParty()[1]!; + expect(leadPokemon).toBeDefined(); + expect(secPokemon).toBeDefined(); + + const enemyLeadPokemon = game.scene.currentBattle.enemyParty[0]!; + const enemySecPokemon = game.scene.currentBattle.enemyParty[1]!; + expect(enemyLeadPokemon).toBeDefined(); + expect(enemySecPokemon).toBeDefined(); + + game.doAttack(getMovePosition(game.scene, 0, Moves.DRAGON_TAIL)); + game.doSelectTarget(BattlerIndex.ENEMY); + + // target the same pokemon, second move should be redirected after first flees + game.doAttack(getMovePosition(game.scene, 0, Moves.DRAGON_TAIL)); + game.doSelectTarget(BattlerIndex.ENEMY); + + await game.phaseInterceptor.to(BerryPhase); + + const isVisibleLead = enemyLeadPokemon.visible; + const hasFledLead = enemyLeadPokemon.wildFlee; + const isVisibleSec = enemySecPokemon.visible; + const hasFledSec = enemySecPokemon.wildFlee; + expect(!isVisibleLead && hasFledLead && !isVisibleSec && hasFledSec).toBe(true); + expect(leadPokemon.hp).toBeLessThan(leadPokemon.getMaxHp()); + expect(secPokemon.hp).toBeLessThan(secPokemon.getMaxHp()); + expect(enemyLeadPokemon.hp).toBeLessThan(enemyLeadPokemon.getMaxHp()); + expect(enemySecPokemon.hp).toBeLessThan(enemySecPokemon.getMaxHp()); + }, TIMEOUT + ); +});