[Test] Add/update test utils

- Add `FieldHelper` which has methods to mock a pokemon's ability
or force a pokemon to be Terastallized

- Add `MoveHelper#use` which can be used to remove the need
for setting pokemon move overrides by modifying the
moveset of the pokemon

- Add `MoveHelper#selectEnemyMove` to make an enemy pokemon
select a specific move

- Add `MoveHelper#forceEnemyMove` which modifies
the pokemon's moveset and then uses `selectEnemyMove`

- Fix `GameManager#toNextTurn` to work correctly in double battles

- Add `GameManager#toEndOfTurn` which advances to the end of the turn
This commit is contained in:
NightKev 2025-05-19 21:13:18 -07:00
parent ff6f9131ae
commit f7cb6acb1f
3 changed files with 210 additions and 11 deletions

View File

@ -5,6 +5,7 @@ import { getMoveTargets } from "#app/data/moves/move";
import type { EnemyPokemon, PlayerPokemon } from "#app/field/pokemon";
import Trainer from "#app/field/trainer";
import { GameModes, getGameMode } from "#app/game-mode";
import { globalScene } from "#app/global-scene";
import { ModifierTypeOption, modifierTypes } from "#app/modifier/modifier-type";
import overrides from "#app/overrides";
import { CheckSwitchPhase } from "#app/phases/check-switch-phase";
@ -22,15 +23,13 @@ import { TitlePhase } from "#app/phases/title-phase";
import { TurnEndPhase } from "#app/phases/turn-end-phase";
import { TurnInitPhase } from "#app/phases/turn-init-phase";
import { TurnStartPhase } from "#app/phases/turn-start-phase";
import ErrorInterceptor from "#test/testUtils/errorInterceptor";
import type InputsHandler from "#test/testUtils/inputsHandler";
import type BallUiHandler from "#app/ui/ball-ui-handler";
import type BattleMessageUiHandler from "#app/ui/battle-message-ui-handler";
import type CommandUiHandler from "#app/ui/command-ui-handler";
import type ModifierSelectUiHandler from "#app/ui/modifier-select-ui-handler";
import type PartyUiHandler from "#app/ui/party-ui-handler";
import type StarterSelectUiHandler from "#app/ui/starter-select-ui-handler";
import type TargetSelectUiHandler from "#app/ui/target-select-ui-handler";
import { UiMode } from "#enums/ui-mode";
import { isNullOrUndefined } from "#app/utils/common";
import { BattleStyle } from "#enums/battle-style";
import { Button } from "#enums/buttons";
@ -40,24 +39,26 @@ import type { Moves } from "#enums/moves";
import type { MysteryEncounterType } from "#enums/mystery-encounter-type";
import { PlayerGender } from "#enums/player-gender";
import type { Species } from "#enums/species";
import { UiMode } from "#enums/ui-mode";
import ErrorInterceptor from "#test/testUtils/errorInterceptor";
import { generateStarter, waitUntil } from "#test/testUtils/gameManagerUtils";
import GameWrapper from "#test/testUtils/gameWrapper";
import { ChallengeModeHelper } from "#test/testUtils/helpers/challengeModeHelper";
import { ClassicModeHelper } from "#test/testUtils/helpers/classicModeHelper";
import { DailyModeHelper } from "#test/testUtils/helpers/dailyModeHelper";
import { FieldHelper } from "#test/testUtils/helpers/field-helper";
import { ModifierHelper } from "#test/testUtils/helpers/modifiersHelper";
import { MoveHelper } from "#test/testUtils/helpers/moveHelper";
import { OverridesHelper } from "#test/testUtils/helpers/overridesHelper";
import { ReloadHelper } from "#test/testUtils/helpers/reloadHelper";
import { SettingsHelper } from "#test/testUtils/helpers/settingsHelper";
import type InputsHandler from "#test/testUtils/inputsHandler";
import { MockFetch } from "#test/testUtils/mocks/mockFetch";
import PhaseInterceptor from "#test/testUtils/phaseInterceptor";
import TextInterceptor from "#test/testUtils/TextInterceptor";
import { AES, enc } from "crypto-js";
import fs from "node:fs";
import { expect, vi } from "vitest";
import { globalScene } from "#app/global-scene";
import type StarterSelectUiHandler from "#app/ui/starter-select-ui-handler";
import { MockFetch } from "#test/testUtils/mocks/mockFetch";
/**
* Class to manage the game state and transitions between phases.
@ -76,6 +77,7 @@ export default class GameManager {
public readonly settings: SettingsHelper;
public readonly reload: ReloadHelper;
public readonly modifiers: ModifierHelper;
public readonly field: FieldHelper;
/**
* Creates an instance of GameManager.
@ -123,6 +125,7 @@ export default class GameManager {
this.settings = new SettingsHelper(this);
this.reload = new ReloadHelper(this);
this.modifiers = new ModifierHelper(this);
this.field = new FieldHelper(this);
this.override.sanitizeOverrides();
// Disables Mystery Encounters on all tests (can be overridden at test level)
@ -383,6 +386,7 @@ export default class GameManager {
* @param moveId - The {@linkcode Moves | move} the enemy will use
* @param target - The {@linkcode BattlerIndex} of the target against which the enemy will use the given move;
* will use normal target selection priorities if omitted.
* @deprecated Use {@linkcode MoveHelper.forceEnemyMove} or {@linkcode MoveHelper.selectEnemyMove}
*/
async forceEnemyMove(moveId: Moves, target?: BattlerIndex) {
// Wait for the next EnemyCommandPhase to start
@ -417,9 +421,15 @@ export default class GameManager {
};
}
/** Transition to the next upcoming {@linkcode CommandPhase} */
/** Transition to the first {@linkcode CommandPhase} of the next turn. */
async toNextTurn() {
await this.phaseInterceptor.to(CommandPhase);
await this.phaseInterceptor.to("TurnInitPhase");
await this.phaseInterceptor.to("CommandPhase");
}
/** Transition to the {@linkcode TurnEndPhase | end of the current turn}. */
async toEndOfTurn() {
await this.phaseInterceptor.to("TurnEndPhase");
}
/**
@ -541,8 +551,8 @@ export default class GameManager {
}
/**
* Select a pokemon from the party menu during the given phase.
* Only really handles the basic case of "navigate to party slot and press Action twice" -
* Select a pokemon from the party menu during the given phase.
* Only really handles the basic case of "navigate to party slot and press Action twice" -
* any menus that come up afterwards are ignored and must be handled separately by the caller.
* @param slot - The 0-indexed position of the pokemon in your party to switch to
* @param inPhase - Which phase to expect the selection to occur in. Defaults to `SwitchPhase`

View File

@ -0,0 +1,87 @@
// -- start tsdoc imports --
// biome-ignore lint/correctness/noUnusedImports: TSDoc import
import type { globalScene } from "#app/global-scene";
// -- end tsdoc imports --
import type { BattlerIndex } from "#app/battle";
import type { Ability } from "#app/data/abilities/ability-class";
import { allAbilities } from "#app/data/data-lists";
import type Pokemon from "#app/field/pokemon";
import type { EnemyPokemon, PlayerPokemon } from "#app/field/pokemon";
import type { Abilities } from "#enums/abilities";
import type { PokemonType } from "#enums/pokemon-type";
import { Stat } from "#enums/stat";
import { GameManagerHelper } from "#test/testUtils/helpers/gameManagerHelper";
import { expect, type MockInstance, vi } from "vitest";
/** Helper to manage pokemon */
export class FieldHelper extends GameManagerHelper {
/**
* Passthrough for {@linkcode globalScene.getPlayerPokemon} that adds an `undefined` check for
* the Pokemon so that the return type for the function doesn't have `undefined`.
* This removes the need to add a `!` like when calling `game.scene.getPlayerPokemon()!`.
* @param includeSwitching Whether a pokemon that is currently switching out is valid, default `true`
* @returns The first {@linkcode PlayerPokemon} that is {@linkcode globalScene.getPlayerField on the field}
* and {@linkcode PlayerPokemon.isActive is active}
* (aka {@linkcode PlayerPokemon.isAllowedInBattle is allowed in battle}).
*/
public getPlayerPokemon(includeSwitching = true): PlayerPokemon {
const pokemon = this.game.scene.getPlayerPokemon(includeSwitching);
expect(pokemon).toBeDefined();
return pokemon!;
}
/**
* Passthrough for {@linkcode globalScene.getEnemyPokemon} that adds an `undefined` check for
* the Pokemon so that the return type for the function doesn't have `undefined`.
* This removes the need to add a `!` like when calling `game.scene.getEnemyPokemon()!`.
* @param includeSwitching Whether a pokemon that is currently switching out is valid, default `true`
* @returns The first {@linkcode EnemyPokemon} that is {@linkcode globalScene.getEnemyField on the field}
* and {@linkcode EnemyPokemon.isActive is active}
* (aka {@linkcode EnemyPokemon.isAllowedInBattle is allowed in battle}).
*/
public getEnemyPokemon(includeSwitching = true): EnemyPokemon {
const pokemon = this.game.scene.getEnemyPokemon(includeSwitching);
expect(pokemon).toBeDefined();
return pokemon!;
}
/**
* @returns The {@linkcode BattlerIndex | indexes} of Pokemon on the field in order of decreasing Speed.
* Speed ties are returned in increasing order of index.
*
* Note: Trick Room does not modify the speed of Pokemon on the field.
*/
public getSpeedOrder(): BattlerIndex[] {
return this.game.scene
.getField(true)
.sort((pA, pB) => pB.getEffectiveStat(Stat.SPD) - pA.getEffectiveStat(Stat.SPD))
.map(p => p.getBattlerIndex());
}
/**
* Mocks a pokemon's ability, overriding its existing ability (takes precedence over global overrides)
* @param pokemon - The pokemon to mock the ability of
* @param ability - The ability to be mocked
* @returns A {@linkcode MockInstance} object
* @see {@linkcode vi.spyOn}
* @see https://vitest.dev/api/mock#mockreturnvalue
*/
public mockAbility(pokemon: Pokemon, ability: Abilities): MockInstance<(baseOnly?: boolean) => Ability> {
return vi.spyOn(pokemon, "getAbility").mockReturnValue(allAbilities[ability]);
}
/**
* Forces a pokemon to be terastallized. Defaults to the pokemon's primary type if not specified.
*
* This function only mocks the Pokemon's tera-related variables; it does NOT activate any tera-related abilities.
*
* @param pokemon - The pokemon to terastallize.
* @param teraType - (optional) The {@linkcode PokemonType} to terastallize it as.
*/
public forceTera(pokemon: Pokemon, teraType?: PokemonType): void {
vi.spyOn(pokemon, "isTerastallized", "get").mockReturnValue(true);
teraType ??= pokemon.getSpeciesForm(true).type1;
vi.spyOn(pokemon, "teraType", "get").mockReturnValue(teraType);
}
}

View File

@ -1,14 +1,16 @@
import type { BattlerIndex } from "#app/battle";
import { getMoveTargets } from "#app/data/moves/move";
import { Button } from "#app/enums/buttons";
import type Pokemon from "#app/field/pokemon";
import { PokemonMove } from "#app/field/pokemon";
import Overrides from "#app/overrides";
import type { CommandPhase } from "#app/phases/command-phase";
import type { EnemyCommandPhase } from "#app/phases/enemy-command-phase";
import { LearnMovePhase } from "#app/phases/learn-move-phase";
import { MoveEffectPhase } from "#app/phases/move-effect-phase";
import { Command } from "#app/ui/command-ui-handler";
import { UiMode } from "#enums/ui-mode";
import { Moves } from "#enums/moves";
import { UiMode } from "#enums/ui-mode";
import { getMovePosition } from "#test/testUtils/gameManagerUtils";
import { GameManagerHelper } from "#test/testUtils/helpers/gameManagerHelper";
import { vi } from "vitest";
@ -92,6 +94,35 @@ export class MoveHelper extends GameManagerHelper {
}
}
/**
* 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 pokemon index. Relevant for double-battles only (defaults to 0)
* @param targetIndex - (optional) The {@linkcode BattlerIndex} of the Pokemon to target for single-target moves, or `null` if a manual call to `selectTarget()` is required
* @param useTera - If `true`, the Pokemon also chooses to Terastallize. This does not require a Tera Orb. Default: `false`.
*/
public use(moveId: Moves, pkmIndex: 0 | 1 = 0, targetIndex?: BattlerIndex | null, useTera = false): void {
if ([Overrides.MOVESET_OVERRIDE].flat().length > 0) {
vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([]);
console.warn("Warning: `use` overwrites the Pokemon's moveset and disables the player 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`
@ -132,6 +163,77 @@ export class MoveHelper extends GameManagerHelper {
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 MoveId | move} the enemy will use
* @param target (Optional) the {@linkcode BattlerIndex | target} which the enemy will use the given move against
*/
public async selectEnemyMove(moveId: Moves, 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.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),
});
/**
* 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");
}
/**
* Forces the next enemy selecting a move to use the given move against the given target (if applicable).
*
* Warning: Overwrites the pokemon's moveset and disables the moveset override!
*
* Note: 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.
* @param moveId The {@linkcode MoveId | move} the enemy will use
* @param target (Optional) the {@linkcode BattlerIndex | target} which the enemy will use the given move against
*/
public async forceEnemyMove(moveId: Moves, 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.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),
});
/**
* 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");
}
/**
* Simulates learning a move for a player pokemon.
* @param move The {@linkcode Moves} being learnt