diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 6fcfebd5854..6ec013f61ef 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -944,6 +944,7 @@ export default class BattleScene extends SceneBase { if (this.currentBattle.double === false) { return; } + // TODO: Remove while loop if (allyPokemon?.isActive(true)) { let targetingMovePhase: MovePhase; do { diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index bf26e6592c3..8c078dce67c 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -6947,12 +6947,10 @@ export function initAbilities() { .attr(PostDefendStatStageChangeAbAttr, (target, user, move) => move.category !== MoveCategory.STATUS, Stat.DEF, 1), new Ability(Abilities.WIMP_OUT, 7) .attr(PostDamageForceSwitchAbAttr) - .condition(getSheerForceHitDisableAbCondition()) - .bypassFaint(), // allows Wimp Out to activate with Reviver Seed + .condition(getSheerForceHitDisableAbCondition()), new Ability(Abilities.EMERGENCY_EXIT, 7) .attr(PostDamageForceSwitchAbAttr) - .condition(getSheerForceHitDisableAbCondition()) - .bypassFaint(), + .condition(getSheerForceHitDisableAbCondition()), new Ability(Abilities.WATER_COMPACTION, 7) .attr(PostDefendStatStageChangeAbAttr, (target, user, move) => user.getMoveType(move) === PokemonType.WATER && move.category !== MoveCategory.STATUS, Stat.DEF, 2), new Ability(Abilities.MERCILESS, 7) diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 3a6f4a21521..55dd9ba8a34 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -1823,8 +1823,8 @@ export class AddSubstituteAttr extends MoveEffectAttr { } /** - * Removes 1/4 of the user's maximum HP (rounded down) to create a substitute for the user - * @param user - The {@linkcode Pokemon} that used the move. + * Removes a fraction of the user's maximum HP to create a substitute. + * @param user - The {@linkcode Pokemon} using the move. * @param target - n/a * @param move - The {@linkcode Move} with this attribute. * @param args - n/a diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 261dbd4ef41..6ea9762a933 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -1692,6 +1692,11 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { return this.getMaxHp() - this.hp; } + /** + * Return this Pokemon's current HP as a fraction of its maximum HP. + * @param precise - Whether to return the exact HP ratio (`true`) or rounded to the nearest 1% (`false`); default `false` + * @returns This pokemon's current HP ratio (current / max). + */ getHpRatio(precise = false): number { return precise ? this.hp / this.getMaxHp() : Math.round((this.hp / this.getMaxHp()) * 100) / 100; } @@ -4048,15 +4053,14 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { isCritical = false, ignoreSegments = false, ignoreFaintPhase = false, - }: - { - result?: DamageResult, - isCritical?: boolean, - ignoreSegments?: boolean, - ignoreFaintPhase?: boolean, - } = {} + }: { + result?: DamageResult; + isCritical?: boolean; + ignoreSegments?: boolean; + ignoreFaintPhase?: boolean; + } = {}, ): number { - const isIndirectDamage = [ HitResult.INDIRECT, HitResult.INDIRECT_KO ].includes(result); + const isIndirectDamage = [HitResult.INDIRECT, HitResult.INDIRECT_KO].includes(result); const damagePhase = new DamageAnimPhase(this.getBattlerIndex(), damage, result, isCritical); globalScene.unshiftPhase(damagePhase); @@ -4923,7 +4927,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * which already calls this function. */ resetSummonData(): void { - console.log(`resetSummonData called on Pokemon ${this.name}`) + console.log(`resetSummonData called on Pokemon ${this.name}`); const illusion: IllusionData | null = this.summonData.illusion; if (this.summonData.speciesForm) { this.summonData.speciesForm = null; @@ -4965,7 +4969,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { } resetTurnData(): void { - console.log(`resetTurnData called on Pokemon ${this.name}`) + console.log(`resetTurnData called on Pokemon ${this.name}`); this.turnData = new PokemonTurnData(); } @@ -5421,11 +5425,11 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { */ // TODO: Review where this is being called and where it is necessary to call it leaveField(clearEffects = true, hideInfo = true, destroy = false) { - console.log(`leaveField called on Pokemon ${this.name}`) + console.log(`leaveField called on Pokemon ${this.name}`); this.resetSprite(); globalScene - .getField(true) - .filter(p => p !== this) + .getField(true) + .filter(p => p !== this) .forEach(p => p.removeTagsBySourceId(this.id)); if (clearEffects) { @@ -6717,7 +6721,6 @@ export class EnemyPokemon extends Pokemon { return ret; } - /** * Show or hide the type effectiveness multiplier window * Passing undefined will hide the window diff --git a/src/phases/faint-phase.ts b/src/phases/faint-phase.ts index a2dedd28c2c..2bcbaaf717e 100644 --- a/src/phases/faint-phase.ts +++ b/src/phases/faint-phase.ts @@ -30,9 +30,9 @@ import { SwitchPhase } from "./switch-phase"; import { SwitchSummonPhase } from "./switch-summon-phase"; import { ToggleDoublePositionPhase } from "./toggle-double-position-phase"; import { VictoryPhase } from "./victory-phase"; -import { isNullOrUndefined } from "#app/utils/common"; import { FRIENDSHIP_LOSS_FROM_FAINT } from "#app/data/balance/starters"; import { BattlerTagType } from "#enums/battler-tag-type"; +import { isNullOrUndefined } from "#app/utils/common"; export class FaintPhase extends PokemonPhase { /** @@ -118,6 +118,7 @@ export class FaintPhase extends PokemonPhase { pokemon.resetTera(); + // TODO: This could be simplified greatly with the concept of "move being used" if (pokemon.turnData.attacksReceived?.length) { const lastAttack = pokemon.turnData.attacksReceived[0]; applyPostFaintAbAttrs( @@ -151,41 +152,35 @@ export class FaintPhase extends PokemonPhase { } } + const legalBackupPokemon = globalScene.getBackupPartyMemberIndices( + this.player, + !this.player ? (pokemon as EnemyPokemon).trainerSlot : undefined, + ); + if (this.player) { - /** The total number of Pokemon in the player's party that can legally fight */ + /** An array of Pokemon in the player's party that can legally fight. */ const legalPlayerPokemon = globalScene.getPokemonAllowedInBattle(); - /** The total number of legal player Pokemon that aren't currently on the field */ - const legalPlayerPartyPokemon = legalPlayerPokemon.filter(p => !p.isActive(true)); - if (!legalPlayerPokemon.length) { - /** If the player doesn't have any legal Pokemon, end the game */ + if (legalPlayerPokemon.length === 0) { + // If the player doesn't have any legal Pokemon left in their party, end the game. globalScene.unshiftPhase(new GameOverPhase()); - } else if ( - globalScene.currentBattle.double && - legalPlayerPokemon.length === 1 && - legalPlayerPartyPokemon.length === 0 - ) { - /** - * If the player has exactly one Pokemon in total at this point in a double battle, and that Pokemon - * is already on the field, unshift a phase that moves that Pokemon to center position. - */ + } else if (globalScene.currentBattle.double && legalBackupPokemon.length === 0) { + /* + Otherwise, if the player has no reserve members left to switch in, + unshift a phase to move the other on-field pokemon to center position. + */ globalScene.unshiftPhase(new ToggleDoublePositionPhase(true)); - } else if (legalPlayerPartyPokemon.length > 0) { - /** - * If previous conditions weren't met, and the player has at least 1 legal Pokemon off the field, - * push a phase that prompts the player to summon a Pokemon from their party. - */ + } else { + // If previous conditions weren't met, push a phase to prompt the player to select a pokemon from their party. globalScene.pushPhase(new SwitchPhase(SwitchType.SWITCH, this.fieldIndex, true, false)); } } else { + // Unshift a phase for EXP gains and/or one to switch in a replacement party member. globalScene.unshiftPhase(new VictoryPhase(this.battlerIndex)); - if ([BattleType.TRAINER, BattleType.MYSTERY_ENCOUNTER].includes(globalScene.currentBattle.battleType)) { - const hasReservePartyMember = !!globalScene - .getEnemyParty() - .filter(p => p.isActive() && !p.isOnField() && p.trainerSlot === (pokemon as EnemyPokemon).trainerSlot) - .length; - if (hasReservePartyMember) { - globalScene.pushPhase(new SwitchSummonPhase(SwitchType.SWITCH, this.fieldIndex, -1, false, false)); - } + if ( + [BattleType.TRAINER, BattleType.MYSTERY_ENCOUNTER].includes(globalScene.currentBattle.battleType) && + legalBackupPokemon.length > 0 + ) { + globalScene.pushPhase(new SwitchSummonPhase(SwitchType.SWITCH, this.fieldIndex, -1, false, false)); } } diff --git a/src/phases/switch-summon-phase.ts b/src/phases/switch-summon-phase.ts index a85342c6ab2..3b24474d43c 100644 --- a/src/phases/switch-summon-phase.ts +++ b/src/phases/switch-summon-phase.ts @@ -133,7 +133,7 @@ export class SwitchSummonPhase extends SummonPhase { // TODO: Why do we trigger these attributes even if the switch in target doesn't exist? // (This should almost certainly go somewhere inside `preSummon`) - applyPreSummonAbAttrs(PreSummonAbAttr, switchedInPokemon); + applyPreSummonAbAttrs(PreSummonAbAttr, switchInPokemon); applyPreSwitchOutAbAttrs(PreSwitchOutAbAttr, this.lastPokemon); if (!switchInPokemon) { this.end(); diff --git a/src/utils/array.ts b/src/utils/array.ts new file mode 100644 index 00000000000..41b00036b42 --- /dev/null +++ b/src/utils/array.ts @@ -0,0 +1,53 @@ +/** + * Split an array into a pair of arrays based on a conditional function. + * @param array - The array to split into 2. + * @param predicate - A function accepting up to 3 arguments. The split function calls the predicate function once per element of the array. + * @param thisArg - An object to which the `this` keyword can refer in the predicate function. If omitted, `undefined` is used as the `this` value. + * @returns A pair of shallowly-copied arrays containing every element for which `predicate` did or did not return a value coercible to the boolean `true`. + * @overload + */ +export function splitArray( + array: T[], + predicate: (value: T, index: number, array: T[]) => value is S, + thisArg?: unknown, +): [matches: S[], nonMatches: S[]]; + +/** + * Split an array into a pair of arrays based on a conditional function. + * @param array - The array to split into 2. + * @param predicate - A function accepting up to 3 arguments. The split function calls the function once per element of the array. + * @param thisArg - An object to which the `this` keyword can refer in the predicate function. If omitted, `undefined` is used as the `this` value. + * @returns A pair of shallowly-copied arrays containing every element for which `predicate` did or did not return a value coercible to the boolean `true`. + * @overload + */ +export function splitArray( + array: T[], + predicate: (value: T, index: number, array: T[]) => unknown, + thisArg?: unknown, +): [matches: T[], nonMatches: T[]]; +/** + * Split an array into a pair of arrays based on a conditional function. + * @param array - The array to split into 2. + * @param predicate - A function accepting up to 3 arguments. The split function calls the function once per element of the array. + * @param thisArg - An object to which the `this` keyword can refer in the predicate function. If omitted, `undefined` is used as the `this` value. + * @returns A pair of shallowly-copied arrays containing every element for which `predicate` did or did not return a value coercible to the boolean `true`. + * @overload + */ +export function splitArray( + array: T[], + predicate: (value: T, index: number, array: T[]) => unknown, + thisArg?: unknown, +): [matches: T[], nonMatches: T[]] { + const matches: T[] = []; + const nonMatches: T[] = []; + + const p = predicate.bind(thisArg) as typeof predicate; + array.forEach((val, index, ar) => { + if (p(val, index, ar)) { + matches.push(val); + } else { + nonMatches.push(val); + } + }); + return [matches, nonMatches]; +} diff --git a/test/abilities/mold_breaker.test.ts b/test/abilities/mold_breaker.test.ts index e9653f89970..44fa071cae7 100644 --- a/test/abilities/mold_breaker.test.ts +++ b/test/abilities/mold_breaker.test.ts @@ -3,6 +3,7 @@ import { ArenaTagSide } from "#app/data/arena-tag"; import { globalScene } from "#app/global-scene"; import { Abilities } from "#enums/abilities"; import { ArenaTagType } from "#enums/arena-tag-type"; +import { BattleType } from "#enums/battle-type"; import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; import GameManager from "#test/testUtils/gameManager"; @@ -39,31 +40,28 @@ describe("Abilities - Mold Breaker", () => { game.override.startingLevel(100).enemyLevel(2).enemyAbility(Abilities.STURDY); await game.classicMode.startBattle([Species.MAGIKARP]); + const player = game.scene.getPlayerPokemon()!; game.move.select(Moves.ERUPTION); - await game.phaseInterceptor.to("TurnEndPhase"); - - expect(game.scene.getEnemyPokemon()?.isFainted()).toBe(true); - }); - - it("should turn off ignore abilities arena variable after the user's move concludes", async () => { - game.override.startingLevel(100).enemyLevel(2); - await game.classicMode.startBattle([Species.MAGIKARP]); expect(globalScene.arena.ignoreAbilities).toBe(false); game.move.select(Moves.SPLASH); await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); await game.phaseInterceptor.to("MoveEffectPhase"); - expect(globalScene.arena.ignoreAbilities).toBe(true); + expect(game.scene.arena.ignoreAbilities).toBe(true); + expect(game.scene.arena.ignoringEffectSource).toBe(player.getBattlerIndex()); await game.phaseInterceptor.to("MoveEndPhase"); - expect(globalScene.arena.ignoreAbilities).toBe(false); + expect(game.scene.arena.ignoreAbilities).toBe(false); + + await game.phaseInterceptor.to("TurnEndPhase"); + expect(game.scene.getEnemyPokemon()?.isFainted()).toBe(true); }); it("should keep Levitate opponents grounded when using force switch moves", async () => { - game.override.enemyAbility(Abilities.LEVITATE).enemySpecies(Species.WEEZING).startingWave(8); // first rival battle; guaranteed 2 mon party + game.override.enemyAbility(Abilities.LEVITATE).enemySpecies(Species.WEEZING).battleType(BattleType.TRAINER); - // Setup toxic spikes and stealth rock + // Setup toxic spikes and spikes game.scene.arena.addTag(ArenaTagType.TOXIC_SPIKES, -1, Moves.TOXIC_SPIKES, 1, ArenaTagSide.ENEMY); game.scene.arena.addTag(ArenaTagType.SPIKES, -1, Moves.CEASELESS_EDGE, 1, ArenaTagSide.ENEMY); await game.classicMode.startBattle([Species.MAGIKARP]); @@ -71,7 +69,7 @@ describe("Abilities - Mold Breaker", () => { const [weezing1, weezing2] = game.scene.getEnemyParty(); // Weezing's levitate prevented removal of Toxic Spikes, ignored Spikes damage expect(game.scene.arena.getTagOnSide(ArenaTagType.TOXIC_SPIKES, ArenaTagSide.ENEMY)).toBeDefined(); - expect(weezing1.getHpRatio()).toBe(1); + expect(weezing1.hp).toBe(weezing1.getMaxHp()); game.move.select(Moves.DRAGON_TAIL); await game.phaseInterceptor.to("TurnEndPhase"); @@ -79,7 +77,7 @@ describe("Abilities - Mold Breaker", () => { // Levitate was ignored during the switch, causing Toxic Spikes to be removed and Spikes to deal damage expect(weezing1.isOnField()).toBe(false); expect(weezing2.isOnField()).toBe(true); - expect(weezing2.getHpRatio()).toBeCloseTo(0.75); + expect(weezing2.getHpRatio(true)).toBeCloseTo(0.75); expect(game.scene.arena.getTagOnSide(ArenaTagType.TOXIC_SPIKES, ArenaTagSide.ENEMY)).toBeUndefined(); }); }); diff --git a/test/moves/force-switch.test.ts b/test/moves/force-switch.test.ts index ddc7e1e3d02..742d7540a20 100644 --- a/test/moves/force-switch.test.ts +++ b/test/moves/force-switch.test.ts @@ -10,10 +10,14 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite 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 { splitArray } from "#app/utils/array"; import { BattlerTagType } from "#enums/battler-tag-type"; import { MoveResult } from "#app/field/pokemon"; import { SubstituteTag } from "#app/data/battler-tags"; +import { Stat } from "#enums/stat"; +import i18next from "i18next"; +import { toDmgValue } from "#app/utils/common"; +import { allAbilities } from "#app/data/data-lists"; describe("Moves - Switching Moves", () => { let phaserGame: Phaser.Game; @@ -25,7 +29,7 @@ describe("Moves - Switching Moves", () => { }); }); - describe("Target Switch Moves", () => { + describe("Force Switch Moves", () => { afterEach(() => { game.phaseInterceptor.restoreOg(); }); @@ -34,8 +38,8 @@ describe("Moves - Switching Moves", () => { game = new GameManager(phaserGame); game.override .battleStyle("single") - .ability(Abilities.NO_GUARD) - .moveset([Moves.DRAGON_TAIL, Moves.SPLASH, Moves.FLAMETHROWER]) + .passiveAbility(Abilities.NO_GUARD) + .moveset([Moves.DRAGON_TAIL, Moves.SPLASH, Moves.FLAMETHROWER, Moves.FOCUS_PUNCH]) .enemySpecies(Species.WAILORD) .enemyMoveset(Moves.SPLASH); }); @@ -75,13 +79,15 @@ describe("Moves - Switching Moves", () => { 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]); + expect(game.scene.currentBattle.trainer).not.toBeNull(); + const choiceSwitchSpy = vi.spyOn(game.scene.currentBattle.trainer!, "getNextSummonIndex"); + // Grab each trainer's pokemon based on species name const [tateParty, lizaParty] = splitArray( game.scene.getEnemyParty(), @@ -95,9 +101,6 @@ describe("Moves - Switching Moves", () => { // 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"); @@ -114,29 +117,20 @@ describe("Moves - Switching Moves", () => { }); 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]); + game.override.battleStyle("double").enemyMoveset(Moves.SPLASH); + await game.classicMode.startBattle([Species.DRATINI, Species.DRATINI]); - const leadPokemon = game.scene.getPlayerParty()[0]!; - const secPokemon = game.scene.getPlayerParty()[1]!; + const [enemyLeadPokemon, enemySecPokemon] = game.scene.getEnemyParty(); - 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.DRAGON_TAIL, BattlerIndex.PLAYER, BattlerIndex.ENEMY); // target the same pokemon, second move should be redirected after first flees - game.move.select(Moves.DRAGON_TAIL, 1, BattlerIndex.ENEMY); - + // Focus punch used due to having even lower priority than Dtail + game.move.select(Moves.FOCUS_PUNCH, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER, BattlerIndex.PLAYER_2]); 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(enemyLeadPokemon.visible).toBe(false); + expect(enemyLeadPokemon.switchOutStatus).toBe(true); expect(enemySecPokemon.hp).toBeLessThan(enemySecPokemon.getMaxHp()); }); @@ -153,7 +147,7 @@ describe("Moves - Switching Moves", () => { 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); + vi.spyOn(game.scene.getPlayerPokemon()!, "getAbility").mockReturnValue(allAbilities[Abilities.MOLD_BREAKER]); enemy.hp = enemy.getMaxHp(); game.move.select(Moves.DRAGON_TAIL); @@ -178,54 +172,53 @@ describe("Moves - Switching Moves", () => { 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 + it("should perform a normal switch upon fainting an opponent", async () => { + game.override.battleType(BattleType.TRAINER).startingLevel(1000); // To make sure Dragon Tail KO's the opponent await game.classicMode.startBattle([Species.DRATINI]); + expect(game.scene.getEnemyParty()).toHaveLength(2); + const choiceSwitchSpy = vi.spyOn(game.scene.currentBattle.trainer!, "getNextSummonIndex"); 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); + expect(choiceSwitchSpy).toHaveBeenCalledTimes(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 + .enemySpecies(Species.BLISSEY) + .enemyHeldItems([{ name: "REVIVER_SEED" }]); await game.classicMode.startBattle([Species.DRATINI]); - const [wailord1, wailord2] = game.scene.getEnemyParty()!; - expect(wailord1).toBeDefined(); - expect(wailord2).toBeDefined(); + const [blissey1, blissey2] = game.scene.getEnemyParty()!; + expect(blissey1).toBeDefined(); + expect(blissey2).toBeDefined(); + blissey1.hp = 1; 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); + // Bliseey #1 should have consumed the reviver seed and stayed on field + expect(blissey1.isOnField()).toBe(true); + expect(blissey1.getHpRatio()).toBeCloseTo(0.5); + expect(blissey1.getHeldItems()).toHaveLength(0); + expect(blissey2.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 + .startingLevel(1000); // make hp rounding consistent await game.classicMode.startBattle([Species.BLISSEY, Species.BULBASAUR]); const [blissey, bulbasaur] = game.scene.getPlayerParty(); + blissey.hp = 1; game.move.select(Moves.SPLASH); await game.toNextTurn(); @@ -279,6 +272,13 @@ describe("Moves - Switching Moves", () => { const newEnemy = game.scene.getEnemyPokemon()!; expect(newEnemy).not.toBe(enemy); expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); + // TODO: Replace this with the locale key in question + expect(game.textInterceptor.logs).toContain( + i18next.t("INSERT FORCE SWITCH LOCALES KEY HERE", { + pokemonName: newEnemy.getNameToRender(), + }), + ); + expect(game.textInterceptor.logs).not.toContain( i18next.t("battle:trainerGo", { trainerName: game.scene.currentBattle.trainer?.getName(newEnemy.trainerSlot), @@ -338,225 +338,175 @@ describe("Moves - Switching Moves", () => { }); }); - describe("Failure Checks", () => { + describe("Baton Pass", () => { afterEach(() => { game.phaseInterceptor.restoreOg(); }); beforeEach(() => { game = new GameManager(phaserGame); - game.override.battleStyle("single").enemySpecies(Species.GENGAR).disableCrits().enemyAbility(Abilities.STURDY); + 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.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); + it("should pass the user's stat stages and BattlerTags to an ally", async () => { 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]); + game.move.select(Moves.NASTY_PLOT); await game.toNextTurn(); - expect(game.phaseInterceptor.log).not.toContain("BattleEndPhase"); - expect(game.scene.getEnemyPokemon()).toBe(gengar); + 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.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]); + 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]); - game.move.select(move); - game.doSelectPartyPokemon(1); - await game.toNextTurn(); + const enemy = game.scene.getEnemyPokemon()!; - expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); - expect(game.scene.getPlayerPokemon()?.getLastXMoves()[0].result).toBe(MoveResult.MISS); - }, - ); + // round 1 - ai buffs + game.move.select(Moves.SPLASH); + await game.forceEnemyMove(Moves.NASTY_PLOT); + await game.toNextTurn(); - 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]); + game.move.select(Moves.SPLASH); + await game.forceEnemyMove(Moves.BATON_PASS); + await game.toNextTurn(); - // reset species override so we get a different species - game.override.enemySpecies(Species.ARBOK); + // 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"); + }); - game.move.select(move); - game.doSelectPartyPokemon(1); + it("should not transfer non-transferrable effects", async () => { + game.override.enemyMoveset([Moves.SALT_CURE]); + await game.classicMode.startBattle([Species.PIKACHU, Species.FEEBAS]); - await game.toNextTurn(); + const [player1, player2] = game.scene.getPlayerParty(); - expect(game.phaseInterceptor.log).not.toContain("BattleEndPhase"); - expect(game.scene.getEnemyPokemon()!.species.speciesId).toBe(Species.GENGAR); - }, - ); + game.move.select(Moves.BATON_PASS); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); - describe("Baton Pass", () => { - let phaserGame: Phaser.Game; - let game: GameManager; + // enemy salt cure + await game.phaseInterceptor.to("MoveEndPhase"); + expect(player1.getTag(BattlerTagType.SALT_CURED)).toBeDefined(); - beforeAll(() => { - phaserGame = new Phaser.Game({ - type: Phaser.HEADLESS, - }); - }); + game.doSelectPartyPokemon(1); + await game.toNextTurn(); - afterEach(() => { - game.phaseInterceptor.restoreOg(); - }); + expect(player1.isOnField()).toBe(false); + expect(player2.isOnField()).toBe(true); + expect(player2.getTag(BattlerTagType.SALT_CURED)).toBeUndefined(); + }); - 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 remove the user's binding effects", async () => { + game.override.moveset([Moves.FIRE_SPIN, Moves.BATON_PASS]); - it("should pass the user's stat stages and BattlerTags to an ally", async () => { - await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]); + await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]); - game.move.select(Moves.NASTY_PLOT); - await game.toNextTurn(); + const enemy = game.scene.getEnemyPokemon()!; - const [raichu, shuckle] = game.scene.getPlayerParty(); - expect(raichu.getStatStage(Stat.SPATK)).toEqual(2); + game.move.select(Moves.FIRE_SPIN); + await game.move.forceHit(); + await game.toNextTurn(); - game.move.select(Moves.SUBSTITUTE); - await game.toNextTurn(); + expect(enemy.getTag(BattlerTagType.FIRE_SPIN)).toBeDefined(); - expect(raichu.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined(); + game.move.select(Moves.BATON_PASS); + game.doSelectPartyPokemon(1); + await game.toNextTurn(); - game.move.select(Moves.BATON_PASS); - game.doSelectPartyPokemon(1); - await game.phaseInterceptor.to("TurnEndPhase"); + expect(enemy.getTag(BattlerTagType.FIRE_SPIN)).toBeUndefined(); + }); + }); - expect(game.scene.getPlayerPokemon()).toBe(shuckle); - expect(shuckle.getStatStage(Stat.SPATK)).toEqual(2); - expect(shuckle.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined(); - }); + describe("Shed Tail", () => { + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); - 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]); + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .moveset(Moves.SHED_TAIL) + .battleStyle("single") + .enemySpecies(Species.SNORLAX) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH); + }); - const enemy = game.scene.getEnemyPokemon()!; + 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]); - // round 1 - ai buffs - game.move.select(Moves.SPLASH); - await game.forceEnemyMove(Moves.NASTY_PLOT); - await game.toNextTurn(); + const magikarp = game.scene.getPlayerPokemon()!; - game.move.select(Moves.SPLASH); - await game.forceEnemyMove(Moves.BATON_PASS); - await game.toNextTurn(); + game.move.select(Moves.SHED_TAIL); + game.doSelectPartyPokemon(1); + await game.phaseInterceptor.to("TurnEndPhase", false); - // 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"); - }); + const feebas = game.scene.getPlayerPokemon()!; + expect(feebas).not.toBe(magikarp); + expect(feebas.hp).toBe(feebas.getMaxHp()); - it("should not transfer non-transferrable effects", async () => { - game.override.enemyMoveset([Moves.SALT_CURE]); - await game.classicMode.startBattle([Species.PIKACHU, Species.FEEBAS]); + const substituteTag = feebas.getTag(SubstituteTag)!; + expect(substituteTag).toBeDefined(); - const [player1, player2] = game.scene.getPlayerParty(); + // 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.floor(magikarp.getMaxHp() / 4)); + }); - game.move.select(Moves.BATON_PASS); - await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + it("should not transfer other effects", async () => { + await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]); - // enemy salt cure - await game.phaseInterceptor.to("MoveEndPhase"); - expect(player1.getTag(BattlerTagType.SALT_CURED)).toBeDefined(); + const magikarp = game.scene.getPlayerPokemon()!; + magikarp.setStatStage(Stat.ATK, 6); - game.doSelectPartyPokemon(1); - await game.toNextTurn(); + game.move.select(Moves.SHED_TAIL); + game.doSelectPartyPokemon(1); + await game.phaseInterceptor.to("TurnEndPhase", false); - expect(player1.isOnField()).toBe(false); - expect(player2.isOnField()).toBe(true); - expect(player2.getTag(BattlerTagType.SALT_CURED)).toBeUndefined(); - }); + const feebas = game.scene.getPlayerPokemon()!; + expect(feebas).not.toBe(magikarp); + expect(feebas.getStatStage(Stat.ATK)).toBe(0); + expect(magikarp.getStatStage(Stat.ATK)).toBe(0); + }); - it("removes the user's binding effects", async () => { - game.override.moveset([Moves.FIRE_SPIN, Moves.BATON_PASS]); + it("should fail if the user's HP is insufficient", async () => { + await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]); - await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]); + const magikarp = game.scene.getPlayerPokemon()!; + const initHp = toDmgValue(magikarp.getMaxHp() / 2 - 1); + magikarp.hp = initHp; - const enemy = game.scene.getEnemyPokemon()!; + game.move.select(Moves.SHED_TAIL); + await game.phaseInterceptor.to("TurnEndPhase", false); - 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(); - }); + expect(magikarp.isOnField()).toBe(true); + expect(magikarp.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + expect(magikarp.hp).toBe(initHp); }); }); @@ -664,7 +614,7 @@ describe("Moves - Switching Moves", () => { }); }); - describe("Shed Tail", () => { + describe("Failure Checks", () => { afterEach(() => { game.phaseInterceptor.restoreOg(); }); @@ -672,47 +622,97 @@ describe("Moves - Switching Moves", () => { beforeEach(() => { game = new GameManager(phaserGame); game.override - .moveset(Moves.SHED_TAIL) .battleStyle("single") - .enemySpecies(Species.SNORLAX) - .enemyAbility(Abilities.BALL_FETCH) - .enemyMoveset(Moves.SPLASH); + .passiveAbility(Abilities.NO_GUARD) + .enemySpecies(Species.GENGAR) + .disableCrits() + .enemyAbility(Abilities.STURDY); }); - 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]); + 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]); - const magikarp = game.scene.getPlayerPokemon()!; + const gengar = game.scene.getEnemyPokemon()!; + game.move.select(Moves.SPLASH); + await game.phaseInterceptor.to("TurnEndPhase"); - game.move.select(Moves.SHED_TAIL); + expect(game.phaseInterceptor.log).not.toContain("BattleEndPhase"); + const enemy = game.scene.getEnemyPokemon()!; + expect(enemy).toBe(gengar); + expect(enemy.switchOutStatus).toBe(false); + }); + + it.each<{ name: string; move?: Moves; enemyMove?: Moves }>([ + { name: "Teleport", enemyMove: 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 = Moves.SPLASH, enemyMove = Moves.SPLASH }) => { + game.override.moveset(move).enemyMoveset(enemyMove); + await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]); + + const gengar = game.scene.getEnemyPokemon(); + game.move.select(move); game.doSelectPartyPokemon(1); + await game.toNextTurn(); - 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)); + expect(game.phaseInterceptor.log).toContain("BattleEndPhase"); + expect(game.scene.getEnemyPokemon()).not.toBe(gengar); }); - it("should fail if user's HP is insufficient", async () => { - await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]); + 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]); - const magikarp = game.scene.getPlayerPokemon()!; - magikarp.hp = Math.floor(magikarp.getMaxHp() / 2 - 1); + game.move.select(move); + game.doSelectPartyPokemon(1); + await game.toNextTurn(); - game.move.select(Moves.SHED_TAIL); - await game.phaseInterceptor.to("TurnEndPhase", false); + expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); + const user = enemyMove === Moves.SPLASH ? game.scene.getPlayerPokemon()! : game.scene.getEnemyPokemon()!; + expect(user.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); + }, + ); - expect(magikarp.isOnField()).toBe(true); - expect(magikarp.getLastXMoves()[0].result).toBe(MoveResult.FAIL); - expect(magikarp.hp).toBe(magikarp.getMaxHp() / 2 - 1); - }); + 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]); + + game.move.select(move); + game.doSelectPartyPokemon(1); + await game.toNextTurn(); + + expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); + const user = enemyMove === Moves.SPLASH ? game.scene.getPlayerPokemon()! : game.scene.getEnemyPokemon()!; + expect(user.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + }, + ); }); });