diff --git a/test/moves/baton_pass.test.ts b/test/moves/baton_pass.test.ts deleted file mode 100644 index 143ed285023..00000000000 --- a/test/moves/baton_pass.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { BattlerIndex } from "#app/battle"; -import GameManager from "#test/testUtils/gameManager"; -import { Abilities } from "#enums/abilities"; -import { BattlerTagType } from "#enums/battler-tag-type"; -import { Moves } from "#enums/moves"; -import { Species } from "#enums/species"; -import { Stat } from "#enums/stat"; -import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; - -describe("Moves - Baton Pass", () => { - 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 - .battleStyle("single") - .enemySpecies(Species.MAGIKARP) - .enemyAbility(Abilities.BALL_FETCH) - .moveset([Moves.BATON_PASS, Moves.NASTY_PLOT, Moves.SPLASH]) - .ability(Abilities.BALL_FETCH) - .enemyMoveset(Moves.SPLASH) - .disableCrits(); - }); - - it("transfers all stat stages when player uses it", async () => { - // arrange - await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]); - - // round 1 - buff - game.move.select(Moves.NASTY_PLOT); - await game.toNextTurn(); - - let playerPokemon = game.scene.getPlayerPokemon()!; - - expect(playerPokemon.getStatStage(Stat.SPATK)).toEqual(2); - - // round 2 - baton pass - game.move.select(Moves.BATON_PASS); - game.doSelectPartyPokemon(1); - await game.phaseInterceptor.to("TurnEndPhase"); - - // assert - playerPokemon = game.scene.getPlayerPokemon()!; - expect(playerPokemon.species.speciesId).toEqual(Species.SHUCKLE); - expect(playerPokemon.getStatStage(Stat.SPATK)).toEqual(2); - }, 20000); - - it("passes stat stage buffs when AI uses it", async () => { - // arrange - game.override.startingWave(5).enemyMoveset(new Array(4).fill([Moves.NASTY_PLOT])); - await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]); - - // round 1 - ai buffs - game.move.select(Moves.SPLASH); - await game.toNextTurn(); - - // round 2 - baton pass - game.scene.getEnemyPokemon()!.hp = 100; - game.override.enemyMoveset([Moves.BATON_PASS]); - // Force moveset to update mid-battle - // TODO: replace with enemy ai control function when it's added - game.scene.getEnemyParty()[0].getMoveset(); - game.move.select(Moves.SPLASH); - await game.phaseInterceptor.to("PostSummonPhase", false); - - // assert - // check buffs are still there - expect(game.scene.getEnemyPokemon()!.getStatStage(Stat.SPATK)).toEqual(2); - // confirm that a switch actually happened. can't use species because I - // can't find a way to override trainer parties with more than 1 pokemon species - expect(game.scene.getEnemyPokemon()!.hp).not.toEqual(100); - expect(game.phaseInterceptor.log.slice(-4)).toEqual([ - "MoveEffectPhase", - "SwitchSummonPhase", - "SummonPhase", - "PostSummonPhase", - ]); - }, 20000); - - it("doesn't transfer effects that aren't transferrable", async () => { - game.override.enemyMoveset([Moves.SALT_CURE]); - await game.classicMode.startBattle([Species.PIKACHU, Species.FEEBAS]); - - const [player1, player2] = game.scene.getPlayerParty(); - - game.move.select(Moves.BATON_PASS); - await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); - await game.phaseInterceptor.to("MoveEndPhase"); - expect(player1.findTag(t => t.tagType === BattlerTagType.SALT_CURED)).toBeTruthy(); - game.doSelectPartyPokemon(1); - await game.toNextTurn(); - - expect(player2.findTag(t => t.tagType === BattlerTagType.SALT_CURED)).toBeUndefined(); - }, 20000); - - it("doesn't allow binding effects from the user to persist", async () => { - game.override.moveset([Moves.FIRE_SPIN, Moves.BATON_PASS]); - - await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]); - - const enemy = game.scene.getEnemyPokemon()!; - - game.move.select(Moves.FIRE_SPIN); - await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); - await game.move.forceHit(); - - await game.toNextTurn(); - - expect(enemy.getTag(BattlerTagType.FIRE_SPIN)).toBeDefined(); - - game.move.select(Moves.BATON_PASS); - await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); - - game.doSelectPartyPokemon(1); - await game.toNextTurn(); - - expect(enemy.getTag(BattlerTagType.FIRE_SPIN)).toBeUndefined(); - }); -}); diff --git a/test/moves/dragon_tail.test.ts b/test/moves/dragon_tail.test.ts deleted file mode 100644 index 4e65cd4bac5..00000000000 --- a/test/moves/dragon_tail.test.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { BattlerIndex } from "#app/battle"; -import { allMoves } from "#app/data/moves/move"; -import { Challenges } from "#enums/challenges"; -import { PokemonType } from "#enums/pokemon-type"; -import { Abilities } from "#enums/abilities"; -import { Moves } from "#enums/moves"; -import { Species } from "#enums/species"; -import GameManager from "#test/testUtils/gameManager"; -import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { BattleType } from "#enums/battle-type"; -import { TrainerSlot } from "#enums/trainer-slot"; -import { TrainerType } from "#enums/trainer-type"; -import { splitArray } from "#app/utils/common"; -import { BattlerTagType } from "#enums/battler-tag-type"; - -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 - .battleStyle("single") - .moveset([Moves.DRAGON_TAIL, Moves.SPLASH, Moves.FLAMETHROWER]) - .enemySpecies(Species.WAILORD) - .enemyMoveset(Moves.SPLASH) - .startingLevel(5) - .enemyLevel(5); - - vi.spyOn(allMoves[Moves.DRAGON_TAIL], "accuracy", "get").mockReturnValue(100); - }); - - it("should cause opponent to flee, display ability, and not crash", async () => { - game.override.enemyAbility(Abilities.ROUGH_SKIN); - await game.classicMode.startBattle([Species.DRATINI]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - const enemyPokemon = game.scene.getEnemyPokemon()!; - - game.move.select(Moves.DRAGON_TAIL); - - await game.phaseInterceptor.to("BerryPhase"); - - const isVisible = enemyPokemon.visible; - const hasFled = enemyPokemon.switchOutStatus; - expect(isVisible).toBe(false); - expect(hasFled).toBe(true); - expect(leadPokemon.hp).toBeLessThan(leadPokemon.getMaxHp()); - }); - - it("should proceed without crashing in a double battle", async () => { - game.override.battleStyle("double").enemyMoveset(Moves.SPLASH).enemyAbility(Abilities.ROUGH_SKIN); - await game.classicMode.startBattle([Species.DRATINI, Species.DRATINI, Species.WAILORD, Species.WAILORD]); - - const leadPokemon = game.scene.getPlayerParty()[0]!; - - const enemyLeadPokemon = game.scene.getEnemyParty()[0]!; - const enemySecPokemon = game.scene.getEnemyParty()[1]!; - - game.move.select(Moves.DRAGON_TAIL, 0, BattlerIndex.ENEMY); - game.move.select(Moves.SPLASH, 1); - - await game.phaseInterceptor.to("TurnEndPhase"); - - const isVisibleLead = enemyLeadPokemon.visible; - const hasFledLead = enemyLeadPokemon.switchOutStatus; - const isVisibleSec = enemySecPokemon.visible; - const hasFledSec = enemySecPokemon.switchOutStatus; - expect(!isVisibleLead && hasFledLead && isVisibleSec && !hasFledSec).toBe(true); - expect(leadPokemon.hp).toBeLessThan(leadPokemon.getMaxHp()); - - // second turn - game.move.select(Moves.FLAMETHROWER, 0, BattlerIndex.ENEMY_2); - game.move.select(Moves.SPLASH, 1); - - await game.phaseInterceptor.to("BerryPhase"); - expect(enemySecPokemon.hp).toBeLessThan(enemySecPokemon.getMaxHp()); - }); - - it("should force trainers to switch randomly without selecting from a partner's party", async () => { - game.override - .battleStyle("double") - .enemyMoveset(Moves.SPLASH) - .enemyAbility(Abilities.STURDY) - .battleType(BattleType.TRAINER) - .randomTrainer({ trainerType: TrainerType.TATE, alwaysDouble: true }) - .enemySpecies(0); - await game.classicMode.startBattle([Species.WIMPOD, Species.TYRANITAR]); - - // Grab each trainer's pokemon based on species name - const [tateParty, lizaParty] = splitArray( - game.scene.getEnemyParty(), - pkmn => pkmn.trainerSlot === TrainerSlot.TRAINER, - ).map(a => a.map(p => p.species.name)); - - expect(tateParty).not.toEqual(lizaParty); - - // Force enemy trainers to switch to the first mon available. - // Due to how enemy trainer parties are laid out, this prevents false positives - // as Tate's pokemon are placed immediately before Liza's corresponding members. - vi.fn(Phaser.Math.RND.integerInRange).mockImplementation(min => min); - - // Spy on the function responsible for making informed switches - const choiceSwitchSpy = vi.spyOn(game.scene.currentBattle.trainer!, "getNextSummonIndex"); - - game.move.select(Moves.DRAGON_TAIL, BattlerIndex.PLAYER, BattlerIndex.ENEMY_2); - game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2); - await game.phaseInterceptor.to("BerryPhase"); - - const [tatePartyNew, lizaPartyNew] = splitArray( - game.scene.getEnemyParty(), - pkmn => pkmn.trainerSlot === TrainerSlot.TRAINER, - ).map(a => a.map(p => p.species.name)); - - // Forced switch move should have switched Liza's Pokemon with another one of her own at random - expect(tatePartyNew).toEqual(tateParty); - expect(lizaPartyNew).not.toEqual(lizaParty); - expect(choiceSwitchSpy).not.toHaveBeenCalled(); - }); - - it("should redirect targets upon opponent flee", async () => { - game.override.battleStyle("double").enemyMoveset(Moves.SPLASH).enemyAbility(Abilities.ROUGH_SKIN); - await game.classicMode.startBattle([Species.DRATINI, Species.DRATINI, Species.WAILORD, Species.WAILORD]); - - const leadPokemon = game.scene.getPlayerParty()[0]!; - const secPokemon = game.scene.getPlayerParty()[1]!; - - const enemyLeadPokemon = game.scene.getEnemyParty()[0]!; - const enemySecPokemon = game.scene.getEnemyParty()[1]!; - - game.move.select(Moves.DRAGON_TAIL, 0, BattlerIndex.ENEMY); - // target the same pokemon, second move should be redirected after first flees - game.move.select(Moves.DRAGON_TAIL, 1, BattlerIndex.ENEMY); - - await game.phaseInterceptor.to("BerryPhase"); - - const isVisibleLead = enemyLeadPokemon.visible; - const hasFledLead = enemyLeadPokemon.switchOutStatus; - const isVisibleSec = enemySecPokemon.visible; - const hasFledSec = enemySecPokemon.switchOutStatus; - 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()); - }); - - it("should not switch out a target with suction cups, unless the user has Mold Breaker", async () => { - game.override.enemyAbility(Abilities.SUCTION_CUPS); - await game.classicMode.startBattle([Species.REGIELEKI]); - - const enemy = game.scene.getEnemyPokemon()!; - - game.move.select(Moves.DRAGON_TAIL); - await game.phaseInterceptor.to("TurnEndPhase"); - - expect(enemy.isOnField()).toBe(true); - expect(enemy.isFullHp()).toBe(false); - - // Turn 2: Mold Breaker should ignore switch blocking ability and switch out the target - game.override.ability(Abilities.MOLD_BREAKER); - enemy.hp = enemy.getMaxHp(); - - game.move.select(Moves.DRAGON_TAIL); - await game.phaseInterceptor.to("TurnEndPhase"); - - expect(enemy.isOnField()).toBe(false); - expect(enemy.isFullHp()).toBe(false); - }); - - it("should not switch out a Commanded Dondozo", async () => { - game.override.battleStyle("double").enemySpecies(Species.DONDOZO); - await game.classicMode.startBattle([Species.REGIELEKI]); - - // pretend dondozo 2 commanded dondozo 1 (silly I know, but it works) - const [dondozo1, dondozo2] = game.scene.getEnemyField(); - dondozo1.addTag(BattlerTagType.COMMANDED, 1, Moves.NONE, dondozo2.id); - - game.move.select(Moves.DRAGON_TAIL); - await game.phaseInterceptor.to("TurnEndPhase"); - - expect(dondozo1.isOnField()).toBe(true); - expect(dondozo1.isFullHp()).toBe(false); - }); - - it("should force a switch upon fainting an opponent normally", async () => { - game.override.startingWave(5).startingLevel(1000); // To make sure Dragon Tail KO's the opponent - await game.classicMode.startBattle([Species.DRATINI]); - - game.move.select(Moves.DRAGON_TAIL); - - await game.toNextTurn(); - - // Make sure the enemy switched to a healthy Pokemon - const enemy = game.scene.getEnemyPokemon()!; - expect(enemy).toBeDefined(); - expect(enemy.isFullHp()).toBe(true); - - // Make sure the enemy has a fainted Pokemon in their party and not on the field - const faintedEnemy = game.scene.getEnemyParty().find(p => !p.isAllowedInBattle()); - expect(faintedEnemy).toBeDefined(); - expect(game.scene.getEnemyField().length).toBe(1); - }); - - it("should neither switch nor softlock when activating an opponent's reviver seed", async () => { - game.override - .battleType(BattleType.TRAINER) - .enemyHeldItems([{ name: "REVIVER_SEED" }]) - .startingLevel(1000); // make sure Dragon Tail KO's the opponent - await game.classicMode.startBattle([Species.DRATINI]); - - const [wailord1, wailord2] = game.scene.getEnemyParty()!; - expect(wailord1).toBeDefined(); - expect(wailord2).toBeDefined(); - - game.move.select(Moves.DRAGON_TAIL); - await game.toNextTurn(); - - // Wailord should have consumed the reviver seed and stayed on field - expect(wailord1.isOnField()).toBe(true); - expect(wailord1.getHpRatio()).toBeCloseTo(0.5); - expect(wailord1.getHeldItems()).toHaveLength(0); - expect(wailord2.isOnField()).toBe(false); - }); - - it("should neither switch nor softlock when activating a player's reviver seed", async () => { - game.override - .startingHeldItems([{ name: "REVIVER_SEED" }]) - .enemyMoveset(Moves.DRAGON_TAIL) - .enemyLevel(1000); // make sure Dragon Tail KO's the player - await game.classicMode.startBattle([Species.BLISSEY, Species.BULBASAUR]); - - const [blissey, bulbasaur] = game.scene.getPlayerParty(); - - game.move.select(Moves.SPLASH); - await game.toNextTurn(); - - // dratini should have consumed the reviver seed and stayed on field - expect(blissey.isOnField()).toBe(true); - expect(blissey.getHpRatio()).toBeCloseTo(0.5); - expect(blissey.getHeldItems()).toHaveLength(0); - expect(bulbasaur.isOnField()).toBe(false); - }); - - it("should force switches to a random off-field pokemon", async () => { - game.override.enemyMoveset(Moves.DRAGON_TAIL).startingLevel(100).enemyLevel(1); - await game.classicMode.startBattle([Species.BULBASAUR, Species.CHARMANDER, Species.SQUIRTLE]); - - const [bulbasaur, charmander, squirtle] = game.scene.getPlayerParty(); - - // Turn 1: Mock an RNG call that calls for switching to 1st backup Pokemon (Charmander) - vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => { - return min; - }); - game.move.select(Moves.SPLASH); - await game.forceEnemyMove(Moves.DRAGON_TAIL); - await game.toNextTurn(); - - expect(bulbasaur.isOnField()).toBe(false); - expect(charmander.isOnField()).toBe(true); - expect(squirtle.isOnField()).toBe(false); - expect(bulbasaur.getInverseHp()).toBeGreaterThan(0); - - // Turn 2: Mock an RNG call that calls for switching to 2nd backup Pokemon (Squirtle) - vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => { - return min + 1; - }); - game.move.select(Moves.SPLASH); - await game.toNextTurn(); - - expect(bulbasaur.isOnField()).toBe(false); - expect(charmander.isOnField()).toBe(false); - expect(squirtle.isOnField()).toBe(true); - expect(charmander.getInverseHp()).toBeGreaterThan(0); - }); - - it("should not force a switch to a fainted or challenge-ineligible Pokemon", async () => { - game.override.enemyMoveset(Moves.DRAGON_TAIL).startingLevel(100).enemyLevel(1); - // Mono-Water challenge, Eevee is ineligible - game.challengeMode.addChallenge(Challenges.SINGLE_TYPE, PokemonType.WATER + 1, 0); - await game.challengeMode.startBattle([Species.LAPRAS, Species.EEVEE, Species.TOXAPEX, Species.PRIMARINA]); - - const [lapras, eevee, toxapex, primarina] = game.scene.getPlayerParty(); - expect(toxapex).toBeDefined(); - toxapex.hp = 0; - - // Mock an RNG call to switch to the first eligible pokemon. - // Eevee is ineligible and Toxapex is fainted, so it should proc on Primarina instead - vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => { - return min; - }); - game.move.select(Moves.SPLASH); - await game.toNextTurn(); - - expect(lapras.isOnField()).toBe(false); - expect(eevee.isOnField()).toBe(false); - expect(toxapex.isOnField()).toBe(false); - expect(primarina.isOnField()).toBe(true); - expect(lapras.getInverseHp()).toBeGreaterThan(0); - }); - - it("should deal damage without switching if there are no available backup Pokemon to switch into", async () => { - game.override.enemyMoveset(Moves.DRAGON_TAIL).battleStyle("double").startingLevel(100).enemyLevel(1); - // Mono-Water challenge - game.challengeMode.addChallenge(Challenges.SINGLE_TYPE, PokemonType.WATER + 1, 0); - await game.challengeMode.startBattle([Species.LAPRAS, Species.KYOGRE, Species.EEVEE, Species.CLOYSTER]); - - const [lapras, kyogre, eevee, cloyster] = game.scene.getPlayerParty(); - expect(cloyster).toBeDefined(); - - game.move.select(Moves.SPLASH, BattlerIndex.PLAYER); - game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2); - await game.forceEnemyMove(Moves.DRAGON_TAIL, BattlerIndex.PLAYER); - await game.forceEnemyMove(Moves.DRAGON_TAIL, BattlerIndex.PLAYER_2); - await game.killPokemon(kyogre); - game.doSelectPartyPokemon(3); - await game.toNextTurn(); - - // Eevee is ineligble due to challenge and kyogre is fainted, leaving no backup pokemon able to switch in - expect(lapras.isOnField()).toBe(true); - expect(kyogre.isOnField()).toBe(false); - expect(eevee.isOnField()).toBe(false); - expect(cloyster.isOnField()).toBe(true); - expect(lapras.getInverseHp()).toBeGreaterThan(0); - expect(kyogre.getInverseHp()).toBeGreaterThan(0); - expect(game.scene.getBackupPartyMemberIndices(true)).toHaveLength(0); - expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); - }); -}); diff --git a/test/moves/force-switch.test.ts b/test/moves/force-switch.test.ts new file mode 100644 index 00000000000..ddc7e1e3d02 --- /dev/null +++ b/test/moves/force-switch.test.ts @@ -0,0 +1,718 @@ +import { BattlerIndex } from "#app/battle"; +import { Challenges } from "#enums/challenges"; +import { PokemonType } from "#enums/pokemon-type"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import GameManager from "#test/testUtils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { BattleType } from "#enums/battle-type"; +import { TrainerSlot } from "#enums/trainer-slot"; +import { TrainerType } from "#enums/trainer-type"; +import { splitArray } from "#app/utils/common"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { MoveResult } from "#app/field/pokemon"; +import { SubstituteTag } from "#app/data/battler-tags"; + +describe("Moves - Switching Moves", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + describe("Target Switch Moves", () => { + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .battleStyle("single") + .ability(Abilities.NO_GUARD) + .moveset([Moves.DRAGON_TAIL, Moves.SPLASH, Moves.FLAMETHROWER]) + .enemySpecies(Species.WAILORD) + .enemyMoveset(Moves.SPLASH); + }); + + it("should force switches to a random off-field pokemon", async () => { + game.override.enemyMoveset(Moves.DRAGON_TAIL).startingLevel(100).enemyLevel(1); + await game.classicMode.startBattle([Species.BULBASAUR, Species.CHARMANDER, Species.SQUIRTLE]); + + const [bulbasaur, charmander, squirtle] = game.scene.getPlayerParty(); + + // Turn 1: Mock an RNG call that calls for switching to 1st backup Pokemon (Charmander) + vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => { + return min; + }); + game.move.select(Moves.SPLASH); + await game.forceEnemyMove(Moves.DRAGON_TAIL); + await game.toNextTurn(); + + expect(bulbasaur.isOnField()).toBe(false); + expect(charmander.isOnField()).toBe(true); + expect(squirtle.isOnField()).toBe(false); + expect(bulbasaur.getInverseHp()).toBeGreaterThan(0); + + // Turn 2: Mock an RNG call that calls for switching to 2nd backup Pokemon (Squirtle) + vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => { + return min + 1; + }); + game.move.select(Moves.SPLASH); + await game.toNextTurn(); + + expect(bulbasaur.isOnField()).toBe(false); + expect(charmander.isOnField()).toBe(false); + expect(squirtle.isOnField()).toBe(true); + expect(charmander.getInverseHp()).toBeGreaterThan(0); + }); + + it("should force trainers to switch randomly without selecting from a partner's party", async () => { + game.override + .battleStyle("double") + .enemyMoveset(Moves.SPLASH) + .enemyAbility(Abilities.STURDY) + .battleType(BattleType.TRAINER) + .randomTrainer({ trainerType: TrainerType.TATE, alwaysDouble: true }) + .enemySpecies(0); + await game.classicMode.startBattle([Species.WIMPOD, Species.TYRANITAR]); + + // Grab each trainer's pokemon based on species name + const [tateParty, lizaParty] = splitArray( + game.scene.getEnemyParty(), + pkmn => pkmn.trainerSlot === TrainerSlot.TRAINER, + ).map(a => a.map(p => p.species.name)); + + expect(tateParty).not.toEqual(lizaParty); + + // Force enemy trainers to switch to the first mon available. + // Due to how enemy trainer parties are laid out, this prevents false positives + // as Tate's pokemon are placed immediately before Liza's corresponding members. + vi.fn(Phaser.Math.RND.integerInRange).mockImplementation(min => min); + + // Spy on the function responsible for making informed switches + const choiceSwitchSpy = vi.spyOn(game.scene.currentBattle.trainer!, "getNextSummonIndex"); + + game.move.select(Moves.DRAGON_TAIL, BattlerIndex.PLAYER, BattlerIndex.ENEMY_2); + game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2); + await game.phaseInterceptor.to("BerryPhase"); + + const [tatePartyNew, lizaPartyNew] = splitArray( + game.scene.getEnemyParty(), + pkmn => pkmn.trainerSlot === TrainerSlot.TRAINER, + ).map(a => a.map(p => p.species.name)); + + // Forced switch move should have switched Liza's Pokemon with another one of her own at random + expect(tatePartyNew).toEqual(tateParty); + expect(lizaPartyNew).not.toEqual(lizaParty); + expect(choiceSwitchSpy).not.toHaveBeenCalled(); + }); + + it("should force wild Pokemon to flee and redirect moves accordingly", async () => { + game.override.battleStyle("double").enemyMoveset(Moves.SPLASH).enemyAbility(Abilities.ROUGH_SKIN); + await game.classicMode.startBattle([Species.DRATINI, Species.DRATINI, Species.WAILORD, Species.WAILORD]); + + const leadPokemon = game.scene.getPlayerParty()[0]!; + const secPokemon = game.scene.getPlayerParty()[1]!; + + const enemyLeadPokemon = game.scene.getEnemyParty()[0]!; + const enemySecPokemon = game.scene.getEnemyParty()[1]!; + + game.move.select(Moves.DRAGON_TAIL, 0, BattlerIndex.ENEMY); + // target the same pokemon, second move should be redirected after first flees + game.move.select(Moves.DRAGON_TAIL, 1, BattlerIndex.ENEMY); + + await game.phaseInterceptor.to("BerryPhase"); + + const isVisibleLead = enemyLeadPokemon.visible; + const hasFledLead = enemyLeadPokemon.switchOutStatus; + const isVisibleSec = enemySecPokemon.visible; + const hasFledSec = enemySecPokemon.switchOutStatus; + 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()); + }); + + it("should not switch out a target with suction cups, unless the user has Mold Breaker", async () => { + game.override.enemyAbility(Abilities.SUCTION_CUPS); + await game.classicMode.startBattle([Species.REGIELEKI]); + + const enemy = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.DRAGON_TAIL); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(enemy.isOnField()).toBe(true); + expect(enemy.isFullHp()).toBe(false); + + // Turn 2: Mold Breaker should ignore switch blocking ability and switch out the target + game.override.ability(Abilities.MOLD_BREAKER); + enemy.hp = enemy.getMaxHp(); + + game.move.select(Moves.DRAGON_TAIL); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(enemy.isOnField()).toBe(false); + expect(enemy.isFullHp()).toBe(false); + }); + + it("should not switch out a Commanded Dondozo", async () => { + game.override.battleStyle("double").enemySpecies(Species.DONDOZO); + await game.classicMode.startBattle([Species.REGIELEKI]); + + // pretend dondozo 2 commanded dondozo 1 (silly I know, but it works) + const [dondozo1, dondozo2] = game.scene.getEnemyField(); + dondozo1.addTag(BattlerTagType.COMMANDED, 1, Moves.NONE, dondozo2.id); + + game.move.select(Moves.DRAGON_TAIL); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(dondozo1.isOnField()).toBe(true); + expect(dondozo1.isFullHp()).toBe(false); + }); + + it("should force a switch upon fainting an opponent normally", async () => { + game.override.startingWave(5).startingLevel(1000); // To make sure Dragon Tail KO's the opponent + await game.classicMode.startBattle([Species.DRATINI]); + + game.move.select(Moves.DRAGON_TAIL); + + await game.toNextTurn(); + + // Make sure the enemy switched to a healthy Pokemon + const enemy = game.scene.getEnemyPokemon()!; + expect(enemy).toBeDefined(); + expect(enemy.isFullHp()).toBe(true); + + // Make sure the enemy has a fainted Pokemon in their party and not on the field + const faintedEnemy = game.scene.getEnemyParty().find(p => !p.isAllowedInBattle()); + expect(faintedEnemy).toBeDefined(); + expect(game.scene.getEnemyField().length).toBe(1); + }); + + it("should neither switch nor softlock when activating an opponent's reviver seed", async () => { + game.override + .battleType(BattleType.TRAINER) + .enemyHeldItems([{ name: "REVIVER_SEED" }]) + .startingLevel(1000); // make sure Dragon Tail KO's the opponent + await game.classicMode.startBattle([Species.DRATINI]); + + const [wailord1, wailord2] = game.scene.getEnemyParty()!; + expect(wailord1).toBeDefined(); + expect(wailord2).toBeDefined(); + + game.move.select(Moves.DRAGON_TAIL); + await game.toNextTurn(); + + // Wailord should have consumed the reviver seed and stayed on field + expect(wailord1.isOnField()).toBe(true); + expect(wailord1.getHpRatio()).toBeCloseTo(0.5); + expect(wailord1.getHeldItems()).toHaveLength(0); + expect(wailord2.isOnField()).toBe(false); + }); + + it("should neither switch nor softlock when activating a player's reviver seed", async () => { + game.override + .startingHeldItems([{ name: "REVIVER_SEED" }]) + .enemyMoveset(Moves.DRAGON_TAIL) + .enemyLevel(1000); // make sure Dragon Tail KO's the player + await game.classicMode.startBattle([Species.BLISSEY, Species.BULBASAUR]); + + const [blissey, bulbasaur] = game.scene.getPlayerParty(); + + game.move.select(Moves.SPLASH); + await game.toNextTurn(); + + // dratini should have consumed the reviver seed and stayed on field + expect(blissey.isOnField()).toBe(true); + expect(blissey.getHpRatio()).toBeCloseTo(0.5); + expect(blissey.getHeldItems()).toHaveLength(0); + expect(bulbasaur.isOnField()).toBe(false); + }); + + it("should not force a switch to a fainted or challenge-ineligible Pokemon", async () => { + game.override.enemyMoveset(Moves.DRAGON_TAIL).startingLevel(100).enemyLevel(1); + // Mono-Water challenge, Eevee is ineligible + game.challengeMode.addChallenge(Challenges.SINGLE_TYPE, PokemonType.WATER + 1, 0); + await game.challengeMode.startBattle([Species.LAPRAS, Species.EEVEE, Species.TOXAPEX, Species.PRIMARINA]); + + const [lapras, eevee, toxapex, primarina] = game.scene.getPlayerParty(); + expect(toxapex).toBeDefined(); + toxapex.hp = 0; + + // Mock an RNG call to switch to the first eligible pokemon. + // Eevee is ineligible and Toxapex is fainted, so it should proc on Primarina instead + vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => { + return min; + }); + game.move.select(Moves.SPLASH); + await game.toNextTurn(); + + expect(lapras.isOnField()).toBe(false); + expect(eevee.isOnField()).toBe(false); + expect(toxapex.isOnField()).toBe(false); + expect(primarina.isOnField()).toBe(true); + expect(lapras.getInverseHp()).toBeGreaterThan(0); + }); + + // TODO: This is not implemented yet (needs locales) + it.todo.each<{ name: string; move: Moves }>([ + { name: "Whirlwind", move: Moves.WHIRLWIND }, + { name: "Roar", move: Moves.ROAR }, + { name: "Dragon Tail", move: Moves.DRAGON_TAIL }, + { name: "Circle Throw", move: Moves.CIRCLE_THROW }, + ])("should display custom text for forced switch outs", async ({ move }) => { + game.override.moveset(move).battleType(BattleType.TRAINER); + await game.classicMode.startBattle([Species.BLISSEY, Species.BULBASAUR]); + + const enemy = game.scene.getEnemyPokemon()!; + game.move.select(move); + await game.toNextTurn(); + + const newEnemy = game.scene.getEnemyPokemon()!; + expect(newEnemy).not.toBe(enemy); + expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); + expect(game.textInterceptor.logs).not.toContain( + i18next.t("battle:trainerGo", { + trainerName: game.scene.currentBattle.trainer?.getName(newEnemy.trainerSlot), + pokemonName: newEnemy.getNameToRender(), + }), + ); + }); + }); + + describe("Self-Switch Attack Moves", () => { + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .battleStyle("single") + .enemySpecies(Species.GENGAR) + .moveset(Moves.U_TURN) + .enemyMoveset(Moves.SPLASH) + .disableCrits(); + }); + + it("triggers rough skin on the u-turn user before a new pokemon is switched in", async () => { + game.override.enemyAbility(Abilities.ROUGH_SKIN); + await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]); + + const raichu = game.scene.getPlayerPokemon()!; + + game.move.select(Moves.U_TURN); + game.doSelectPartyPokemon(1); + // advance to the phase for picking party members to send out + await game.phaseInterceptor.to("SwitchPhase", false); + + expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); + const player = game.scene.getPlayerPokemon()!; + expect(player).toBe(raichu); + expect(player.isFullHp()).toBe(false); + expect(game.scene.getEnemyPokemon()!.waveData.abilityRevealed).toBe(true); // proxy for asserting ability activated + }); + + it("still forces a switch if u-turn KO's the opponent", async () => { + game.override.startingLevel(1000); + await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]); + const enemy = game.scene.getEnemyPokemon()!; + + // KO the opponent with U-Turn + game.move.select(Moves.U_TURN); + game.doSelectPartyPokemon(1); + await game.phaseInterceptor.to("TurnEndPhase"); + expect(enemy.isFainted()).toBe(true); + + // Check that U-Turn forced a switch + expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); + expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.SHUCKLE); + }); + }); + + describe("Failure Checks", () => { + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override.battleStyle("single").enemySpecies(Species.GENGAR).disableCrits().enemyAbility(Abilities.STURDY); + }); + + it.each<{ name: string; move: Moves }>([ + { name: "U-Turn", move: Moves.U_TURN }, + { name: "Flip Turn", move: Moves.FLIP_TURN }, + { name: "Volt Switch", move: Moves.VOLT_SWITCH }, + { name: "Baton Pass", move: Moves.BATON_PASS }, + { name: "Shed Tail", move: Moves.SHED_TAIL }, + { name: "Parting Shot", move: Moves.PARTING_SHOT }, + ])("$name should not allow wild pokemon to flee", async ({ move }) => { + game.override.moveset(Moves.SPLASH).enemyMoveset(move); + await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]); + + // reset species override so we get a different species + game.override.enemySpecies(Species.ARBOK); + + game.move.select(Moves.SPLASH); + game.doSelectPartyPokemon(1); + + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); + const player = game.scene.getPlayerPokemon()!; + expect(player.species.speciesId).toBe(Species.SHUCKLE); + expect(player.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); + + expect(game.phaseInterceptor.log).not.toContain("BattleEndPhase"); + const enemy = game.scene.getEnemyPokemon()!; + expect(enemy.switchOutStatus).toBe(false); + expect(enemy.species.speciesId).toBe(Species.GENGAR); + }); + + it.each<{ name: string; move: Moves }>([ + { name: "Teleport", move: Moves.TELEPORT }, + { name: "Whirlwind", move: Moves.WHIRLWIND }, + { name: "Roar", move: Moves.ROAR }, + { name: "Dragon Tail", move: Moves.DRAGON_TAIL }, + { name: "Circle Throw", move: Moves.CIRCLE_THROW }, + ])("$name should allow wild pokemon to flee", async ({ move }) => { + game.override.moveset(move).enemyMoveset(move); + await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]); + + const gengar = game.scene.getEnemyPokemon(); + game.move.select(move); + game.doSelectPartyPokemon(1); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.toNextTurn(); + + expect(game.phaseInterceptor.log).not.toContain("BattleEndPhase"); + expect(game.scene.getEnemyPokemon()).toBe(gengar); + }); + + it.each<{ name: string; move?: Moves; enemyMove?: Moves }>([ + { name: "U-Turn", move: Moves.U_TURN }, + { name: "Flip Turn", move: Moves.FLIP_TURN }, + { name: "Volt Switch", move: Moves.VOLT_SWITCH }, + // TODO: Enable once Parting shot is fixed + // {name: "Parting Shot", move: Moves.PARTING_SHOT}, + { name: "Dragon Tail", enemyMove: Moves.DRAGON_TAIL }, + { name: "Circle Throw", enemyMove: Moves.CIRCLE_THROW }, + ])( + "$name should not fail if no valid switch out target is found", + async ({ move = Moves.SPLASH, enemyMove = Moves.SPLASH }) => { + game.override.moveset(move).enemyMoveset(enemyMove); + await game.classicMode.startBattle([Species.RAICHU]); + + game.move.select(move); + game.doSelectPartyPokemon(1); + await game.toNextTurn(); + + expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); + expect(game.scene.getPlayerPokemon()?.getLastXMoves()[0].result).toBe(MoveResult.MISS); + }, + ); + + it.each<{ name: string; move?: Moves; enemyMove?: Moves }>([ + { name: "Teleport", move: Moves.TELEPORT }, + { name: "Baton Pass", move: Moves.BATON_PASS }, + { name: "Shed Tail", move: Moves.SHED_TAIL }, + { name: "Roar", enemyMove: Moves.ROAR }, + { name: "Whirlwind", enemyMove: Moves.WHIRLWIND }, + ])( + "$name should fail if no valid switch out target is found", + async ({ move = Moves.SPLASH, enemyMove = Moves.SPLASH }) => { + game.override.moveset(move).enemyMoveset(enemyMove); + await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]); + + // reset species override so we get a different species + game.override.enemySpecies(Species.ARBOK); + + game.move.select(move); + game.doSelectPartyPokemon(1); + + await game.toNextTurn(); + + expect(game.phaseInterceptor.log).not.toContain("BattleEndPhase"); + expect(game.scene.getEnemyPokemon()!.species.speciesId).toBe(Species.GENGAR); + }, + ); + + describe("Baton Pass", () => { + 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 + .battleStyle("single") + .enemySpecies(Species.MAGIKARP) + .enemyAbility(Abilities.BALL_FETCH) + .moveset([Moves.BATON_PASS, Moves.NASTY_PLOT, Moves.SPLASH, Moves.SUBSTITUTE]) + .ability(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH) + .disableCrits(); + }); + + it("should pass the user's stat stages and BattlerTags to an ally", async () => { + await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]); + + game.move.select(Moves.NASTY_PLOT); + await game.toNextTurn(); + + const [raichu, shuckle] = game.scene.getPlayerParty(); + expect(raichu.getStatStage(Stat.SPATK)).toEqual(2); + + game.move.select(Moves.SUBSTITUTE); + await game.toNextTurn(); + + expect(raichu.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined(); + + game.move.select(Moves.BATON_PASS); + game.doSelectPartyPokemon(1); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(game.scene.getPlayerPokemon()).toBe(shuckle); + expect(shuckle.getStatStage(Stat.SPATK)).toEqual(2); + expect(shuckle.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined(); + }); + + it("should pass stat stages when used by enemy trainers", async () => { + game.override.battleType(BattleType.TRAINER).enemyMoveset([Moves.NASTY_PLOT, Moves.BATON_PASS]); + await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]); + + const enemy = game.scene.getEnemyPokemon()!; + + // round 1 - ai buffs + game.move.select(Moves.SPLASH); + await game.forceEnemyMove(Moves.NASTY_PLOT); + await game.toNextTurn(); + + game.move.select(Moves.SPLASH); + await game.forceEnemyMove(Moves.BATON_PASS); + await game.toNextTurn(); + + // check buffs are still there + const newEnemy = game.scene.getEnemyPokemon()!; + expect(newEnemy).not.toBe(enemy); + expect(newEnemy.getStatStage(Stat.SPATK)).toBe(2); + expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); + }); + + it("should not transfer non-transferrable effects", async () => { + game.override.enemyMoveset([Moves.SALT_CURE]); + await game.classicMode.startBattle([Species.PIKACHU, Species.FEEBAS]); + + const [player1, player2] = game.scene.getPlayerParty(); + + game.move.select(Moves.BATON_PASS); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + + // enemy salt cure + await game.phaseInterceptor.to("MoveEndPhase"); + expect(player1.getTag(BattlerTagType.SALT_CURED)).toBeDefined(); + + game.doSelectPartyPokemon(1); + await game.toNextTurn(); + + expect(player1.isOnField()).toBe(false); + expect(player2.isOnField()).toBe(true); + expect(player2.getTag(BattlerTagType.SALT_CURED)).toBeUndefined(); + }); + + it("removes the user's binding effects", async () => { + game.override.moveset([Moves.FIRE_SPIN, Moves.BATON_PASS]); + + await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]); + + const enemy = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.FIRE_SPIN); + await game.move.forceHit(); + await game.toNextTurn(); + + expect(enemy.getTag(BattlerTagType.FIRE_SPIN)).toBeDefined(); + + game.move.select(Moves.BATON_PASS); + game.doSelectPartyPokemon(1); + await game.toNextTurn(); + + expect(enemy.getTag(BattlerTagType.FIRE_SPIN)).toBeUndefined(); + }); + }); + }); + + describe("Baton Pass", () => { + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .battleStyle("single") + .enemySpecies(Species.MAGIKARP) + .enemyAbility(Abilities.BALL_FETCH) + .moveset([Moves.BATON_PASS, Moves.NASTY_PLOT, Moves.SPLASH, Moves.SUBSTITUTE]) + .ability(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH) + .disableCrits(); + }); + + it("should pass the user's stat stages and BattlerTags to an ally", async () => { + await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]); + + game.move.select(Moves.NASTY_PLOT); + await game.toNextTurn(); + + const [raichu, shuckle] = game.scene.getPlayerParty(); + expect(raichu.getStatStage(Stat.SPATK)).toEqual(2); + + game.move.select(Moves.SUBSTITUTE); + await game.toNextTurn(); + + expect(raichu.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined(); + + game.move.select(Moves.BATON_PASS); + game.doSelectPartyPokemon(1); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(game.scene.getPlayerPokemon()).toBe(shuckle); + expect(shuckle.getStatStage(Stat.SPATK)).toEqual(2); + expect(shuckle.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined(); + }); + + it("should pass stat stages when used by enemy trainers", async () => { + game.override.battleType(BattleType.TRAINER).enemyMoveset([Moves.NASTY_PLOT, Moves.BATON_PASS]); + await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]); + + const enemy = game.scene.getEnemyPokemon()!; + + // round 1 - ai buffs + game.move.select(Moves.SPLASH); + await game.forceEnemyMove(Moves.NASTY_PLOT); + await game.toNextTurn(); + + game.move.select(Moves.SPLASH); + await game.forceEnemyMove(Moves.BATON_PASS); + await game.toNextTurn(); + + // check buffs are still there + const newEnemy = game.scene.getEnemyPokemon()!; + expect(newEnemy).not.toBe(enemy); + expect(newEnemy.getStatStage(Stat.SPATK)).toBe(2); + expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); + }); + + it("should not transfer non-transferrable effects", async () => { + game.override.enemyMoveset([Moves.SALT_CURE]); + await game.classicMode.startBattle([Species.PIKACHU, Species.FEEBAS]); + + const [player1, player2] = game.scene.getPlayerParty(); + + game.move.select(Moves.BATON_PASS); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + + // enemy salt cure + await game.phaseInterceptor.to("MoveEndPhase"); + expect(player1.getTag(BattlerTagType.SALT_CURED)).toBeDefined(); + + game.doSelectPartyPokemon(1); + await game.toNextTurn(); + + expect(player1.isOnField()).toBe(false); + expect(player2.isOnField()).toBe(true); + expect(player2.getTag(BattlerTagType.SALT_CURED)).toBeUndefined(); + }); + + it("removes the user's binding effects", async () => { + game.override.moveset([Moves.FIRE_SPIN, Moves.BATON_PASS]); + + await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]); + + const enemy = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.FIRE_SPIN); + await game.move.forceHit(); + await game.toNextTurn(); + + expect(enemy.getTag(BattlerTagType.FIRE_SPIN)).toBeDefined(); + + game.move.select(Moves.BATON_PASS); + game.doSelectPartyPokemon(1); + await game.toNextTurn(); + + expect(enemy.getTag(BattlerTagType.FIRE_SPIN)).toBeUndefined(); + }); + }); + + describe("Shed Tail", () => { + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .moveset(Moves.SHED_TAIL) + .battleStyle("single") + .enemySpecies(Species.SNORLAX) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH); + }); + + it("should consume 50% of the user's max HP (rounded up) to transfer a 25% HP Substitute doll", async () => { + await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]); + + const magikarp = game.scene.getPlayerPokemon()!; + + game.move.select(Moves.SHED_TAIL); + game.doSelectPartyPokemon(1); + + await game.phaseInterceptor.to("TurnEndPhase", false); + + const feebas = game.scene.getPlayerPokemon()!; + expect(feebas).not.toBe(magikarp); + expect(feebas.hp).toBe(feebas.getMaxHp()); + + const substituteTag = feebas.getTag(SubstituteTag)!; + expect(substituteTag).toBeDefined(); + + // Note: Altered the test to be consistent with the correct HP cost :yipeevee_static: + expect(magikarp.getInverseHp()).toBe(Math.ceil(magikarp.getMaxHp() / 2)); + expect(substituteTag.hp).toBe(Math.ceil(magikarp.getMaxHp() / 4)); + }); + + it("should fail if user's HP is insufficient", async () => { + await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]); + + const magikarp = game.scene.getPlayerPokemon()!; + magikarp.hp = Math.floor(magikarp.getMaxHp() / 2 - 1); + + game.move.select(Moves.SHED_TAIL); + await game.phaseInterceptor.to("TurnEndPhase", false); + + expect(magikarp.isOnField()).toBe(true); + expect(magikarp.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + expect(magikarp.hp).toBe(magikarp.getMaxHp() / 2 - 1); + }); + }); +}); diff --git a/test/moves/shed_tail.test.ts b/test/moves/shed_tail.test.ts deleted file mode 100644 index 845399f6c27..00000000000 --- a/test/moves/shed_tail.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { SubstituteTag } from "#app/data/battler-tags"; -import { MoveResult } from "#app/field/pokemon"; -import { Abilities } from "#enums/abilities"; -import { Moves } from "#enums/moves"; -import { Species } from "#enums/species"; -import GameManager from "#test/testUtils/gameManager"; -import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, it, expect } from "vitest"; - -describe("Moves - Shed 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 - .moveset([Moves.SHED_TAIL]) - .battleStyle("single") - .enemySpecies(Species.SNORLAX) - .enemyAbility(Abilities.BALL_FETCH) - .enemyMoveset(Moves.SPLASH); - }); - - it("transfers a Substitute doll to the switched in Pokemon", async () => { - await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]); - - const magikarp = game.scene.getPlayerPokemon()!; - - game.move.select(Moves.SHED_TAIL); - game.doSelectPartyPokemon(1); - - await game.phaseInterceptor.to("TurnEndPhase", false); - - const feebas = game.scene.getPlayerPokemon()!; - const substituteTag = feebas.getTag(SubstituteTag); - - expect(feebas).not.toBe(magikarp); - expect(feebas.hp).toBe(feebas.getMaxHp()); - // Note: Altered the test to be consistent with the correct HP cost :yipeevee_static: - expect(magikarp.hp).toBe(Math.floor(magikarp.getMaxHp() / 2)); - expect(substituteTag).toBeDefined(); - expect(substituteTag?.hp).toBe(Math.floor(magikarp.getMaxHp() / 4)); - }); - - it("should fail if no ally is available to switch in", async () => { - await game.classicMode.startBattle([Species.MAGIKARP]); - - const magikarp = game.scene.getPlayerPokemon()!; - expect(game.scene.getPlayerParty().length).toBe(1); - - game.move.select(Moves.SHED_TAIL); - - await game.phaseInterceptor.to("TurnEndPhase", false); - - expect(magikarp.isOnField()).toBeTruthy(); - expect(magikarp.getLastXMoves()[0].result).toBe(MoveResult.FAIL); - }); -}); diff --git a/test/moves/u_turn.test.ts b/test/moves/u_turn.test.ts deleted file mode 100644 index 9dca29414a1..00000000000 --- a/test/moves/u_turn.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { Abilities } from "#enums/abilities"; -import { Moves } from "#enums/moves"; -import { Species } from "#enums/species"; -import { StatusEffect } from "#enums/status-effect"; -import GameManager from "#test/testUtils/gameManager"; -import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; - -describe("Moves - U-turn", () => { - 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 - .battleStyle("single") - .enemySpecies(Species.GENGAR) - .startingLevel(90) - .startingWave(97) - .moveset([Moves.U_TURN]) - .enemyMoveset(Moves.SPLASH) - .disableCrits(); - }); - - it("triggers regenerator a single time when a regenerator user switches out with u-turn", async () => { - // arrange - const playerHp = 1; - game.override.ability(Abilities.REGENERATOR); - await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]); - game.scene.getPlayerPokemon()!.hp = playerHp; - - // act - game.move.select(Moves.U_TURN); - game.doSelectPartyPokemon(1); - await game.phaseInterceptor.to("TurnEndPhase"); - - // assert - expect(game.scene.getPlayerParty()[1].hp).toEqual( - Math.floor(game.scene.getPlayerParty()[1].getMaxHp() * 0.33 + playerHp), - ); - expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); - expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.SHUCKLE); - }, 20000); - - it("triggers rough skin on the u-turn user before a new pokemon is switched in", async () => { - // arrange - game.override.enemyAbility(Abilities.ROUGH_SKIN); - await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]); - - // act - game.move.select(Moves.U_TURN); - game.doSelectPartyPokemon(1); - await game.phaseInterceptor.to("SwitchPhase", false); - - // assert - const playerPkm = game.scene.getPlayerPokemon()!; - expect(playerPkm.hp).not.toEqual(playerPkm.getMaxHp()); - expect(game.scene.getEnemyPokemon()!.waveData.abilityRevealed).toBe(true); // proxy for asserting ability activated - expect(playerPkm.species.speciesId).toEqual(Species.RAICHU); - expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); - }, 20000); - - it("triggers contact abilities on the u-turn user (eg poison point) before a new pokemon is switched in", async () => { - // arrange - game.override.enemyAbility(Abilities.POISON_POINT); - await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]); - vi.spyOn(game.scene.getEnemyPokemon()!, "randBattleSeedInt").mockReturnValue(0); - - // act - game.move.select(Moves.U_TURN); - await game.phaseInterceptor.to("SwitchPhase", false); - - // assert - const playerPkm = game.scene.getPlayerPokemon()!; - expect(playerPkm.status?.effect).toEqual(StatusEffect.POISON); - expect(playerPkm.species.speciesId).toEqual(Species.RAICHU); - expect(game.scene.getEnemyPokemon()!.waveData.abilityRevealed).toBe(true); // proxy for asserting ability activated - expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); - }, 20000); - - it("still forces a switch if u-turn KO's the opponent", async () => { - game.override.startingLevel(1000); // Ensure that U-Turn KO's the opponent - await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]); - const enemy = game.scene.getEnemyPokemon()!; - - // KO the opponent with U-Turn - game.move.select(Moves.U_TURN); - game.doSelectPartyPokemon(1); - await game.phaseInterceptor.to("TurnEndPhase"); - expect(enemy.isFainted()).toBe(true); - - // Check that U-Turn forced a switch - expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); - expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.SHUCKLE); - }); -});