import { BattlerIndex } from "#enums/battler-index"; import { getMoveTargets } from "#app/data/moves/move-utils"; import type Pokemon from "#app/field/pokemon"; import { PokemonMove } from "#app/data/moves/pokemon-move"; import Overrides from "#app/overrides"; import type { CommandPhase } from "#app/phases/command-phase"; import type { EnemyCommandPhase } from "#app/phases/enemy-command-phase"; import { MoveEffectPhase } from "#app/phases/move-effect-phase"; import { Command } from "#enums/command"; import { MoveId } from "#enums/move-id"; import { UiMode } from "#enums/ui-mode"; import { GameManagerHelper } from "#test/testUtils/helpers/gameManagerHelper"; import { expect, vi } from "vitest"; import { coerceArray, toReadableString } from "#app/utils/common"; import { MoveUseMode } from "#enums/move-use-mode"; /** * Helper to handle using a Pokemon's moves. */ export class MoveHelper extends GameManagerHelper { /** * Intercepts {@linkcode MoveEffectPhase} and mocks the phase's move's * accuracy to -1, guaranteeing a hit. * @returns A promise that resolves once the next MoveEffectPhase has been reached (not run). */ public async forceHit(): Promise { await this.game.phaseInterceptor.to(MoveEffectPhase, false); const moveEffectPhase = this.game.scene.phaseManager.getCurrentPhase() as MoveEffectPhase; vi.spyOn(moveEffectPhase.move, "calculateBattleAccuracy").mockReturnValue(-1); } /** * Intercepts {@linkcode MoveEffectPhase} and mocks the phase's move's accuracy * to 0, guaranteeing a miss. * @param firstTargetOnly - Whether to only force a miss on the first target hit; default `false`. * @returns A promise that resolves once the next MoveEffectPhase has been reached (not run). */ public async forceMiss(firstTargetOnly = false): Promise { await this.game.phaseInterceptor.to(MoveEffectPhase, false); const moveEffectPhase = this.game.scene.phaseManager.getCurrentPhase() as MoveEffectPhase; const accuracy = vi.spyOn(moveEffectPhase.move, "calculateBattleAccuracy"); if (firstTargetOnly) { accuracy.mockReturnValueOnce(0); } else { accuracy.mockReturnValue(0); } } /** * Select a move _already in the player's moveset_ to be used during the next {@linkcode CommandPhase}. * @param move - The {@linkcode MoveId} to use. * @param pkmIndex - The {@linkcode BattlerIndex} of the player Pokemon using the move. Relevant for double battles only and defaults to {@linkcode BattlerIndex.PLAYER} if not specified. * @param targetIndex - The {@linkcode BattlerIndex} of the Pokemon to target for single-target moves; should be omitted for multi-target moves. * If set to `null`, will forgo normal target selection entirely (useful for UI tests). * @remarks * Will fail the current test if the move being selected is not in the user's moveset. */ public select( move: MoveId, pkmIndex: BattlerIndex.PLAYER | BattlerIndex.PLAYER_2 = BattlerIndex.PLAYER, targetIndex?: BattlerIndex | null, ) { const movePosition = this.getMovePosition(pkmIndex, move); if (movePosition === -1) { expect.fail( `MoveHelper.selectWithTera called with move '${toReadableString(MoveId[move])}' not in moveset! Battler Index: ${toReadableString(BattlerIndex[pkmIndex])}; Moveset: [${this.game.scene .getPlayerParty() [pkmIndex].getMoveset() .map(pm => toReadableString(MoveId[pm.moveId])) .join(", ")}]`, ); } this.game.onNextPrompt("CommandPhase", UiMode.COMMAND, () => { this.game.scene.ui.setMode( UiMode.FIGHT, (this.game.scene.phaseManager.getCurrentPhase() as CommandPhase).getFieldIndex(), ); }); this.game.onNextPrompt("CommandPhase", UiMode.FIGHT, () => { (this.game.scene.phaseManager.getCurrentPhase() as CommandPhase).handleCommand( Command.FIGHT, movePosition, MoveUseMode.NORMAL, ); }); if (targetIndex !== null) { this.game.selectTarget(movePosition, targetIndex); } } /** * Select a move _already in the player's moveset_ to be used during the next {@linkcode CommandPhase}, **which will also terastallize on this turn**. * @param move - The {@linkcode MoveId} to use. * @param pkmIndex - The {@linkcode BattlerIndex} of the player Pokemon using the move. Relevant for double battles only and defaults to {@linkcode BattlerIndex.PLAYER} if not specified. * @param targetIndex - The {@linkcode BattlerIndex} of the Pokemon to target for single-target moves; should be omitted for multi-target moves. * If set to `null`, will forgo normal target selection entirely (useful for UI tests) */ public selectWithTera( move: MoveId, pkmIndex: BattlerIndex.PLAYER | BattlerIndex.PLAYER_2 = BattlerIndex.PLAYER, targetIndex?: BattlerIndex | null, ) { const movePosition = this.getMovePosition(pkmIndex, move); if (movePosition === -1) { expect.fail( `MoveHelper.selectWithTera called with move '${toReadableString(MoveId[move])}' not in moveset! Battler Index: ${toReadableString(BattlerIndex[pkmIndex])}; Moveset: [${this.game.scene .getPlayerParty() [pkmIndex].getMoveset() .map(pm => toReadableString(MoveId[pm.moveId])) .join(", ")}]`, ); } this.game.scene.getPlayerParty()[pkmIndex].isTerastallized = false; this.game.onNextPrompt("CommandPhase", UiMode.COMMAND, () => { this.game.scene.ui.setMode( UiMode.FIGHT, (this.game.scene.phaseManager.getCurrentPhase() as CommandPhase).getFieldIndex(), Command.TERA, ); }); this.game.onNextPrompt("CommandPhase", UiMode.FIGHT, () => { (this.game.scene.phaseManager.getCurrentPhase() as CommandPhase).handleCommand( Command.TERA, movePosition, MoveUseMode.NORMAL, ); }); if (targetIndex !== null) { this.game.selectTarget(movePosition, targetIndex); } } /** Helper function to get the index of the selected move in the selected part member's moveset. */ private getMovePosition(pokemonIndex: BattlerIndex.PLAYER | BattlerIndex.PLAYER_2, move: MoveId): number { const playerPokemon = this.game.scene.getPlayerField()[pokemonIndex]; const moveset = playerPokemon.getMoveset(); const index = moveset.findIndex(m => m.moveId === move && m.ppUsed < m.getMovePp()); console.log(`Move position for ${MoveId[move]} (=${move}):`, index); return index; } /** * Modifies a player pokemon's moveset to contain only the selected move and then * selects it to be used during the next {@linkcode CommandPhase}. * * Warning: Will disable the player moveset override if it is enabled! * * Note: If you need to check for changes in the player's moveset as part of the test, it may be * best to use {@linkcode changeMoveset} and {@linkcode select} instead. * @param moveId - the move to use * @param pkmIndex - The {@linkcode BattlerIndex} of the player Pokemon using the move. Relevant for double battles only and defaults to {@linkcode BattlerIndex.PLAYER} if not specified. * @param targetIndex - The {@linkcode BattlerIndex} of the Pokemon to target for single-target moves; should be omitted for multi-target moves. * @param useTera - If `true`, the Pokemon will attempt to Terastallize even without a Tera Orb; default `false`. */ public use( moveId: MoveId, pkmIndex: BattlerIndex.PLAYER | BattlerIndex.PLAYER_2 = BattlerIndex.PLAYER, targetIndex?: BattlerIndex, useTera = false, ): void { if ([Overrides.MOVESET_OVERRIDE].flat().length > 0) { vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([]); console.warn("Warning: `MoveHelper.use` overwriting player pokemon moveset and disabling moveset override!"); } const pokemon = this.game.scene.getPlayerField()[pkmIndex]; pokemon.moveset = [new PokemonMove(moveId)]; if (useTera) { this.selectWithTera(moveId, pkmIndex, targetIndex); return; } this.select(moveId, pkmIndex, targetIndex); } /** * Forces the Paralysis or Freeze status to activate on the next move by temporarily mocking {@linkcode Overrides.STATUS_ACTIVATION_OVERRIDE}, * advancing to the next `MovePhase`, and then resetting the override to `null` * @param activated - `true` to force the status to activate, `false` to force the status to not activate (will cause Freeze to heal) */ public async forceStatusActivation(activated: boolean): Promise { vi.spyOn(Overrides, "STATUS_ACTIVATION_OVERRIDE", "get").mockReturnValue(activated); await this.game.phaseInterceptor.to("MovePhase"); vi.spyOn(Overrides, "STATUS_ACTIVATION_OVERRIDE", "get").mockReturnValue(null); } /** * Forces the Confusion status to activate on the next move by temporarily mocking {@linkcode Overrides.CONFUSION_ACTIVATION_OVERRIDE}, * advancing to the next `MovePhase`, and then resetting the override to `null` * @param activated - `true` to force the Pokemon to hit themself, `false` to forcibly disable it */ public async forceConfusionActivation(activated: boolean): Promise { vi.spyOn(Overrides, "CONFUSION_ACTIVATION_OVERRIDE", "get").mockReturnValue(activated); await this.game.phaseInterceptor.to("MovePhase"); vi.spyOn(Overrides, "CONFUSION_ACTIVATION_OVERRIDE", "get").mockReturnValue(null); } /** * Changes a pokemon's moveset to the given move(s). * Used when the normal moveset override can't be used (such as when it's necessary to check or update properties of the moveset). * @param pokemon - The {@linkcode Pokemon} being modified * @param moveset - The {@linkcode MoveId} (single or array) to change the Pokemon's moveset to. */ public changeMoveset(pokemon: Pokemon, moveset: MoveId | MoveId[]): void { moveset = coerceArray(moveset); pokemon.moveset = []; moveset.forEach(move => { pokemon.moveset.push(new PokemonMove(move)); }); const movesetStr = moveset.map(moveId => MoveId[moveId]).join(", "); console.log(`Pokemon ${pokemon.species.name}'s moveset manually set to ${movesetStr} (=[${moveset.join(", ")}])!`); } /** * Forces the next enemy selecting a move to use the given move _in its moveset_ * against the given target (if applicable). * @param moveId - The {@linkcode Move | move ID} the enemy will be forced to use. * @param target - The {@linkcode BattlerIndex | target} against which the enemy will use the given move; * defaults to normal target selection priorities if omitted or not single-target. * @remarks * If you do not need to check for changes in the enemy's moveset as part of the test, it may be * best to use {@linkcode forceEnemyMove} instead. */ public async selectEnemyMove(moveId: MoveId, target?: BattlerIndex) { // Wait for the next EnemyCommandPhase to start await this.game.phaseInterceptor.to("EnemyCommandPhase", false); const enemy = this.game.scene.getEnemyField()[ (this.game.scene.phaseManager.getCurrentPhase() as EnemyCommandPhase).getFieldIndex() ]; const legalTargets = getMoveTargets(enemy, moveId); vi.spyOn(enemy, "getNextMove").mockReturnValueOnce({ move: moveId, targets: target !== undefined && !legalTargets.multiple && legalTargets.targets.includes(target) ? [target] : enemy.getNextTargets(moveId), useMode: MoveUseMode.NORMAL, }); /** * Run the EnemyCommandPhase to completion. * This allows this function to be called consecutively to * force a move for each enemy in a double battle. */ await this.game.phaseInterceptor.to("EnemyCommandPhase"); } /** * Modify the moveset of the next enemy selecting a move to contain only the given move, and then * selects it to be used during the next {@linkcode EnemyCommandPhase} against the given targets. * * Does not require the given move to be in the enemy's moveset beforehand, * but **overwrites the pokemon's moveset** and **disables any prior moveset overrides**! * * @param moveId - The {@linkcode Move | move ID} the enemy will be forced to use. * @param target - The {@linkcode BattlerIndex | target} against which the enemy will use the given move; * defaults to normal target selection priorities if omitted or not single-target. * @remarks * If you need to check for changes in the enemy's moveset as part of the test, it may be * best to use {@linkcode changeMoveset} and {@linkcode selectEnemyMove} instead. */ public async forceEnemyMove(moveId: MoveId, target?: BattlerIndex) { // Wait for the next EnemyCommandPhase to start await this.game.phaseInterceptor.to("EnemyCommandPhase", false); const enemy = this.game.scene.getEnemyField()[ (this.game.scene.phaseManager.getCurrentPhase() as EnemyCommandPhase).getFieldIndex() ]; if ([Overrides.OPP_MOVESET_OVERRIDE].flat().length > 0) { vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([]); console.warn( "Warning: `forceEnemyMove` overwrites the Pokemon's moveset and disables the enemy moveset override!", ); } enemy.moveset = [new PokemonMove(moveId)]; const legalTargets = getMoveTargets(enemy, moveId); vi.spyOn(enemy, "getNextMove").mockReturnValueOnce({ move: moveId, targets: target !== undefined && !legalTargets.multiple && legalTargets.targets.includes(target) ? [target] : enemy.getNextTargets(moveId), useMode: MoveUseMode.NORMAL, }); /** * Run the EnemyCommandPhase to completion. * This allows this function to be called consecutively to * force a move for each enemy in a double battle. */ await this.game.phaseInterceptor.to("EnemyCommandPhase"); } }