From 3413d075e95c2311596aa57fa67404c49a6bcbd3 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Thu, 12 Jun 2025 17:22:24 -0400 Subject: [PATCH] Fixed up move-copying moves; added Natural Gift move usage text --- src/data/moves/invalid-moves.ts | 2 +- src/data/moves/move.ts | 495 ++++++++++++++---------------- test/moves/assist.test.ts | 106 ++++--- test/moves/copycat.test.ts | 2 +- test/moves/metronome.test.ts | 107 ++++--- test/moves/moongeist_beam.test.ts | 2 +- test/moves/nature-power.test.ts | 85 +++++ test/moves/sketch.test.ts | 2 +- 8 files changed, 453 insertions(+), 348 deletions(-) create mode 100644 test/moves/nature-power.test.ts diff --git a/src/data/moves/invalid-moves.ts b/src/data/moves/invalid-moves.ts index 559b679752d..616de021484 100644 --- a/src/data/moves/invalid-moves.ts +++ b/src/data/moves/invalid-moves.ts @@ -1,6 +1,6 @@ import { MoveId } from "#enums/move-id"; -/** Set of moves that cannot be called by {@linkcode MoveId.METRONOME Metronome} */ +/** Set of moves that cannot be called by {@linkcode MoveId.METRONOME | Metronome}. */ export const invalidMetronomeMoves: ReadonlySet = new Set([ MoveId.AFTER_YOU, MoveId.ASSIST, diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index e713020cf9c..a5efe4988dd 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -126,10 +126,10 @@ export default abstract class Move implements Localizable { /** * Check if the move is of the given subclass without requiring `instanceof`. - * + * * ⚠️ Does _not_ work for {@linkcode ChargingAttackMove} and {@linkcode ChargingSelfStatusMove} subclasses. For those, * use {@linkcode isChargingMove} instead. - * + * * @param moveKind - The string name of the move to check against * @returns Whether this move is of the provided type. */ @@ -3092,7 +3092,6 @@ export class OverrideMoveEffectAttr extends MoveAttr { /** * Attack Move that doesn't hit the turn it is played and doesn't allow for multiple * uses on the same target. Examples are Future Sight or Doom Desire. - * @extends OverrideMoveEffectAttr * @param tagType The {@linkcode ArenaTagType} that will be placed on the field when the move is used * @param chargeAnim The {@linkcode ChargeAnim | Charging Animation} used for the move * @param chargeText The text to display when the move is used @@ -3137,7 +3136,6 @@ export class DelayedAttackAttr extends OverrideMoveEffectAttr { /** * Attribute that cancels the associated move's effects when set to be combined with the user's ally's * subsequent move this turn. Used for Grass Pledge, Water Pledge, and Fire Pledge. - * @extends OverrideMoveEffectAttr */ export class AwaitCombinedPledgeAttr extends OverrideMoveEffectAttr { constructor() { @@ -6751,68 +6749,219 @@ export class FirstMoveTypeAttr extends MoveEffectAttr { } /** - * Attribute used to call a move. - * Used by other move attributes: {@linkcode RandomMoveAttr}, {@linkcode RandomMovesetMoveAttr}, {@linkcode CopyMoveAttr} - * @see {@linkcode apply} for move call - * @extends OverrideMoveEffectAttr + * Abstract attribute used for all move-calling moves, containing common functionality + * for executing called moves. */ -class CallMoveAttr extends OverrideMoveEffectAttr { - protected invalidMoves: ReadonlySet; - protected hasTarget: boolean; - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - const replaceMoveTarget = move.moveTarget === MoveTarget.NEAR_OTHER ? MoveTarget.NEAR_ENEMY : undefined; - const moveTargets = getMoveTargets(user, move.id, replaceMoveTarget); - if (moveTargets.targets.length === 0) { - globalScene.phaseManager.queueMessage(i18next.t("battle:attackFailed")); - console.log("CallMoveAttr failed due to no targets."); - return false; - } +export abstract class CallMoveAttr extends OverrideMoveEffectAttr { + constructor( + /** + * Whether this move should target the user; default `true`. + * If `true`, will unleash non-spread moves against a random eligible target, + * or else the move's selected target. + */ + override selfTarget = true, + ) { + super(selfTarget) + } + + /** + * Abstract function yielding the move to be used. + * @param user - The {@linkcode Pokemon} using the move + * @param target - The {@linkcode Pokemon} being targeted by the move + * @returns The MoveId that will be called and used. + */ + protected abstract getMove(user: Pokemon, target: Pokemon): MoveId; + + override apply(user: Pokemon, target: Pokemon): boolean { + const copiedMove = allMoves[this.getMove(user, target)]; + + const replaceMoveTarget = copiedMove.moveTarget === MoveTarget.NEAR_OTHER ? MoveTarget.NEAR_ENEMY : undefined; + const moveTargets = getMoveTargets(user, copiedMove.id, replaceMoveTarget); + const targets = moveTargets.multiple || moveTargets.targets.length === 1 ? moveTargets.targets - : [ this.hasTarget ? target.getBattlerIndex() : moveTargets.targets[user.randBattleSeedInt(moveTargets.targets.length)] ]; // account for Mirror Move having a target already - user.getMoveQueue().push({ move: move.id, targets: targets, virtual: true, ignorePP: true }); - globalScene.phaseManager.unshiftNew("LoadMoveAnimPhase", move.id); - globalScene.phaseManager.unshiftNew("MovePhase", user, targets, new PokemonMove(move.id, 0, 0, true), true, true); + : [ this.selfTarget ? target.getBattlerIndex() : moveTargets.targets[user.randBattleSeedInt(moveTargets.targets.length)] ]; // account for Mirror Move having a target already + user.getMoveQueue().push({ move: copiedMove.id, targets: targets, virtual: true, ignorePP: true }); + globalScene.phaseManager.unshiftNew("LoadMoveAnimPhase", copiedMove.id); + globalScene.phaseManager.unshiftNew("MovePhase", user, targets, new PokemonMove(copiedMove.id, 0, 0, true), true, true); return true; } } /** - * Attribute used to call a random move. - * Used for {@linkcode MoveId.METRONOME} - * @see {@linkcode apply} for move selection and move call - * @extends CallMoveAttr to call a selected move + * Attribute to call a different move based on the current terrain and biome. + * Used by {@linkcode MoveId.NATURE_POWER} + */ +export class NaturePowerAttr extends CallMoveAttr { + constructor() { + super(false) + } + + override getMove(user: Pokemon): MoveId { + const moveId = this.getMoveIdForTerrain(globalScene.arena.getTerrainType(), globalScene.arena.biomeType) + // Unshift a phase to load the move's animation (in case it isn't already), then use the move. + globalScene.phaseManager.unshiftNew("LoadMoveAnimPhase", moveId); + globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:naturePowerUse", { + pokemonName: getPokemonNameWithAffix(user), + moveName: allMoves[moveId].name, + })) + + return moveId; + } + + /** + * Helper function to retrieve the correct move for the current terrain and biome. + * Made into a separate function for brevity. + */ + private getMoveIdForTerrain(terrain: TerrainType, biome: BiomeId): MoveId { + switch (terrain) { + case TerrainType.ELECTRIC: + return MoveId.THUNDERBOLT; + case TerrainType.GRASSY: + return MoveId.ENERGY_BALL; + case TerrainType.PSYCHIC: + return MoveId.PSYCHIC; + case TerrainType.MISTY: + return MoveId.MOONBLAST; + } + + // No terrain means check biome + switch (biome) { + case BiomeId.TOWN: + return MoveId.ROUND; + case BiomeId.METROPOLIS: + return MoveId.TRI_ATTACK; + case BiomeId.SLUM: + return MoveId.SLUDGE_BOMB; + case BiomeId.PLAINS: + return MoveId.SILVER_WIND; + case BiomeId.GRASS: + return MoveId.GRASS_KNOT; + case BiomeId.TALL_GRASS: + return MoveId.POLLEN_PUFF; + case BiomeId.MEADOW: + return MoveId.GIGA_DRAIN; + case BiomeId.FOREST: + return MoveId.BUG_BUZZ; + case BiomeId.JUNGLE: + return MoveId.LEAF_STORM; + case BiomeId.SEA: + return MoveId.HYDRO_PUMP; + case BiomeId.SWAMP: + return MoveId.MUD_BOMB; + case BiomeId.BEACH: + return MoveId.SCALD; + case BiomeId.LAKE: + return MoveId.BUBBLE_BEAM; + case BiomeId.SEABED: + return MoveId.BRINE; + case BiomeId.ISLAND: + return MoveId.LEAF_TORNADO; + case BiomeId.MOUNTAIN: + return MoveId.AIR_SLASH; + case BiomeId.BADLANDS: + return MoveId.EARTH_POWER; + case BiomeId.DESERT: + return MoveId.SCORCHING_SANDS; + case BiomeId.WASTELAND: + return MoveId.DRAGON_PULSE; + case BiomeId.CONSTRUCTION_SITE: + return MoveId.STEEL_BEAM; + case BiomeId.CAVE: + return MoveId.POWER_GEM; + case BiomeId.ICE_CAVE: + return MoveId.ICE_BEAM; + case BiomeId.SNOWY_FOREST: + return MoveId.FROST_BREATH; + case BiomeId.VOLCANO: + return MoveId.LAVA_PLUME; + case BiomeId.GRAVEYARD: + return MoveId.SHADOW_BALL; + case BiomeId.RUINS: + return MoveId.ANCIENT_POWER; + case BiomeId.TEMPLE: + return MoveId.EXTRASENSORY; + case BiomeId.DOJO: + return MoveId.FOCUS_BLAST; + case BiomeId.FAIRY_CAVE: + return MoveId.ALLURING_VOICE; + case BiomeId.ABYSS: + return MoveId.OMINOUS_WIND; + case BiomeId.SPACE: + return MoveId.DRACO_METEOR; + case BiomeId.FACTORY: + return MoveId.FLASH_CANNON; + case BiomeId.LABORATORY: + return MoveId.ZAP_CANNON; + case BiomeId.POWER_PLANT: + return MoveId.CHARGE_BEAM; + case BiomeId.END: + return MoveId.ETERNABEAM; + default: + // Fallback for no match + console.log(`NaturePowerAttr lacks defined move to use for current biome ${toReadableString(BiomeId[globalScene.arena.biomeType])}; consider adding an appropriate move to the attribute's selection table.`) + return MoveId.TRI_ATTACK; + } + } +} + +/** + * Attribute used to copy a previously-used move. + * Used for {@linkcode MoveId.COPYCAT} and {@linkcode MoveId.MIRROR_MOVE}. + */ +export class CopyMoveAttr extends CallMoveAttr { + constructor( + /** + * A {@linkcode ReadonlySet} containing all moves that this MoveAttr cannot copy, + * in addition to unimplemented moves and `MoveId.NONE`. + * The move will fail if the chosen move is inside this banlist (if it exists). + */ + protected readonly invalidMoves: ReadonlySet, + selfTarget = true, + ) { + super(selfTarget); + } + + override getMove(_user: Pokemon, target: Pokemon): MoveId { + return this.selfTarget + ? target.getLastXMoves()[0]?.move ?? MoveId.NONE + : globalScene.currentBattle.lastMove; + } + + getCondition(): MoveConditionFunc { + return (_user, target, _move) => { + const chosenMove = this.getMove(_user, target); + return chosenMove !== MoveId.NONE && !this.invalidMoves.has(chosenMove); + }; + } +} +/** + * Attribute to call a random move among moves not in a banlist. + * Used for {@linkcode MoveId.METRONOME}. */ export class RandomMoveAttr extends CallMoveAttr { - constructor(invalidMoves: ReadonlySet) { - super(); - this.invalidMoves = invalidMoves; + constructor( + /** + * A {@linkcode ReadonlySet} containing all moves that this MoveAttr cannot copy, + * in addition to unimplemented moves and `MoveId.NONE`. + * The move will fail if the chosen move is inside this banlist (if it exists). + */ + protected readonly invalidMoves: ReadonlySet, + ) { + super(true); } /** - * This function exists solely to allow tests to override the randomly selected move by mocking this function. - */ - public getMoveOverride(): MoveId | null { - return null; - } - - /** - * User calls a random moveId. + * Pick a random move to execute, barring unimplemented moves and ones + * in this move's {@linkcode invalidMetronomeMoves | exclusion list}. + * Overridden as public to allow tests to override move choice using mocks. * - * Invalid moves are indicated by what is passed in to invalidMoves: {@linkcode invalidMetronomeMoves} - * @param user Pokemon that used the move and will call a random move - * @param target Pokemon that will be targeted by the random move (if single target) - * @param move Move being used - * @param args Unused + * @param user - The {@linkcode Pokemon} using the move + * @returns The {@linkcode MoveId} that will be called. */ - override apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - const moveIds = getEnumValues(MoveId).map(m => !this.invalidMoves.has(m) && !allMoves[m].name.endsWith(" (N)") ? m : MoveId.NONE); - let moveId: MoveId = MoveId.NONE; - do { - moveId = this.getMoveOverride() ?? moveIds[user.randBattleSeedInt(moveIds.length)]; - } - while (moveId === MoveId.NONE); - return super.apply(user, target, allMoves[moveId], args); + public override getMove(user: Pokemon): MoveId { + const moveIds = getEnumValues(MoveId).filter(m => m !== MoveId.NONE && !this.invalidMoves.has(m) && !allMoves[m].name.endsWith(" (N)")); + return moveIds[user.randBattleSeedInt(moveIds.length)]; } } @@ -6821,221 +6970,51 @@ export class RandomMoveAttr extends CallMoveAttr { * Used for {@linkcode MoveId.ASSIST} and {@linkcode MoveId.SLEEP_TALK} * * Fails if the user has no callable moves. - * - * Invalid moves are indicated by what is passed in to invalidMoves: {@linkcode invalidAssistMoves} or {@linkcode invalidSleepTalkMoves} - * @extends RandomMoveAttr to use the callMove function on a moveId - * @see {@linkcode getCondition} for move selection */ -export class RandomMovesetMoveAttr extends CallMoveAttr { - private includeParty: boolean; - private moveId: number; - constructor(invalidMoves: ReadonlySet, includeParty: boolean = false) { - super(); - this.includeParty = includeParty; - this.invalidMoves = invalidMoves; +export class RandomMovesetMoveAttr extends RandomMoveAttr { + /** + * The previously-selected MoveId for this attribute. + * Reset to `MoveId.NONE` after successful use. + */ + private selectedMove: MoveId = MoveId.NONE + constructor(invalidMoves: ReadonlySet, + /** + * Whether to consider all moves from the user's party (`true`) or the user's own moveset (`false`); + * default `false`. + */ + private includeParty = false + ) { + super(invalidMoves); } /** - * User calls a random moveId selected in {@linkcode getCondition} - * @param user Pokemon that used the move and will call a random move - * @param target Pokemon that will be targeted by the random move (if single target) - * @param move Move being used - * @param args Unused + * Select a random move from either the user's or its party members' movesets, + * or return an already-selected one if one exists. + * + * @param user - The {@linkcode Pokemon} using the move. + * @returns The {@linkcode MoveId} that will be called. */ - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - return super.apply(user, target, allMoves[this.moveId], args); - } - - getCondition(): MoveConditionFunc { - return (user, target, move) => { - // includeParty will be true for Assist, false for Sleep Talk - let allies: Pokemon[]; - if (this.includeParty) { - allies = user.isPlayer() ? globalScene.getPlayerParty().filter(p => p !== user) : globalScene.getEnemyParty().filter(p => p !== user); - } else { - allies = [ user ]; - } - const partyMoveset = allies.map(p => p.moveset).flat(); - const moves = partyMoveset.filter(m => !this.invalidMoves.has(m!.moveId) && !m!.getMove().name.endsWith(" (N)")); - if (moves.length === 0) { - return false; - } - - this.moveId = moves[user.randBattleSeedInt(moves.length)].moveId; - return true; - }; - } -} - -export class NaturePowerAttr extends OverrideMoveEffectAttr { - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - let moveId; - switch (globalScene.arena.getTerrainType()) { - // this allows terrains to 'override' the biome move - case TerrainType.NONE: - switch (globalScene.arena.biomeType) { - case BiomeId.TOWN: - moveId = MoveId.ROUND; - break; - case BiomeId.METROPOLIS: - moveId = MoveId.TRI_ATTACK; - break; - case BiomeId.SLUM: - moveId = MoveId.SLUDGE_BOMB; - break; - case BiomeId.PLAINS: - moveId = MoveId.SILVER_WIND; - break; - case BiomeId.GRASS: - moveId = MoveId.GRASS_KNOT; - break; - case BiomeId.TALL_GRASS: - moveId = MoveId.POLLEN_PUFF; - break; - case BiomeId.MEADOW: - moveId = MoveId.GIGA_DRAIN; - break; - case BiomeId.FOREST: - moveId = MoveId.BUG_BUZZ; - break; - case BiomeId.JUNGLE: - moveId = MoveId.LEAF_STORM; - break; - case BiomeId.SEA: - moveId = MoveId.HYDRO_PUMP; - break; - case BiomeId.SWAMP: - moveId = MoveId.MUD_BOMB; - break; - case BiomeId.BEACH: - moveId = MoveId.SCALD; - break; - case BiomeId.LAKE: - moveId = MoveId.BUBBLE_BEAM; - break; - case BiomeId.SEABED: - moveId = MoveId.BRINE; - break; - case BiomeId.ISLAND: - moveId = MoveId.LEAF_TORNADO; - break; - case BiomeId.MOUNTAIN: - moveId = MoveId.AIR_SLASH; - break; - case BiomeId.BADLANDS: - moveId = MoveId.EARTH_POWER; - break; - case BiomeId.DESERT: - moveId = MoveId.SCORCHING_SANDS; - break; - case BiomeId.WASTELAND: - moveId = MoveId.DRAGON_PULSE; - break; - case BiomeId.CONSTRUCTION_SITE: - moveId = MoveId.STEEL_BEAM; - break; - case BiomeId.CAVE: - moveId = MoveId.POWER_GEM; - break; - case BiomeId.ICE_CAVE: - moveId = MoveId.ICE_BEAM; - break; - case BiomeId.SNOWY_FOREST: - moveId = MoveId.FROST_BREATH; - break; - case BiomeId.VOLCANO: - moveId = MoveId.LAVA_PLUME; - break; - case BiomeId.GRAVEYARD: - moveId = MoveId.SHADOW_BALL; - break; - case BiomeId.RUINS: - moveId = MoveId.ANCIENT_POWER; - break; - case BiomeId.TEMPLE: - moveId = MoveId.EXTRASENSORY; - break; - case BiomeId.DOJO: - moveId = MoveId.FOCUS_BLAST; - break; - case BiomeId.FAIRY_CAVE: - moveId = MoveId.ALLURING_VOICE; - break; - case BiomeId.ABYSS: - moveId = MoveId.OMINOUS_WIND; - break; - case BiomeId.SPACE: - moveId = MoveId.DRACO_METEOR; - break; - case BiomeId.FACTORY: - moveId = MoveId.FLASH_CANNON; - break; - case BiomeId.LABORATORY: - moveId = MoveId.ZAP_CANNON; - break; - case BiomeId.POWER_PLANT: - moveId = MoveId.CHARGE_BEAM; - break; - case BiomeId.END: - moveId = MoveId.ETERNABEAM; - break; - } - break; - case TerrainType.MISTY: - moveId = MoveId.MOONBLAST; - break; - case TerrainType.ELECTRIC: - moveId = MoveId.THUNDERBOLT; - break; - case TerrainType.GRASSY: - moveId = MoveId.ENERGY_BALL; - break; - case TerrainType.PSYCHIC: - moveId = MoveId.PSYCHIC; - break; - default: - // Just in case there's no match - moveId = MoveId.TRI_ATTACK; - break; + override getMove(user: Pokemon): MoveId { + if (this.selectedMove) { + const m = this.selectedMove; + this.selectedMove = MoveId.NONE; + return m; } - user.getMoveQueue().push({ move: moveId, targets: [ target.getBattlerIndex() ], ignorePP: true }); - globalScene.phaseManager.unshiftNew("LoadMoveAnimPhase", moveId); - globalScene.phaseManager.unshiftNew("MovePhase", user, [ target.getBattlerIndex() ], new PokemonMove(moveId, 0, 0, true), true); - return true; - } -} + // includeParty will be true for Assist, false for Sleep Talk + const allies: Pokemon[] = this.includeParty + ? (user.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty()).filter(p => p !== user) + : [ user ]; -/** - * Attribute used to copy a previously-used move. - * Used for {@linkcode MoveId.COPYCAT} and {@linkcode MoveId.MIRROR_MOVE} - * @see {@linkcode apply} for move selection and move call - * @extends CallMoveAttr to call a selected move - */ -export class CopyMoveAttr extends CallMoveAttr { - private mirrorMove: boolean; - constructor(mirrorMove: boolean, invalidMoves: ReadonlySet = new Set()) { - super(); - this.mirrorMove = mirrorMove; - this.invalidMoves = invalidMoves; + // Assist & Sleep Talk consider duplicate moves for their selection (hence why we use an array instead of a set) + const moveset = allies.flatMap(p => p.moveset); + const eligibleMoves = moveset.filter(m => m.moveId !== MoveId.NONE && !this.invalidMoves.has(m.moveId) && !m.getMove().name.endsWith(" (N)")); + this.selectedMove = eligibleMoves[user.randBattleSeedInt(eligibleMoves.length)]?.moveId ?? MoveId.NONE; // will fail if 0 length array + return this.selectedMove; } - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - this.hasTarget = this.mirrorMove; - const lastMove = this.mirrorMove ? target.getLastXMoves()[0].move : globalScene.currentBattle.lastMove; - return super.apply(user, target, allMoves[lastMove], args); - } - - getCondition(): MoveConditionFunc { - return (user, target, move) => { - if (this.mirrorMove) { - const lastMove = target.getLastXMoves()[0]?.move; - return !!lastMove && !this.invalidMoves.has(lastMove); - } else { - const lastMove = globalScene.currentBattle.lastMove; - return lastMove !== undefined && !this.invalidMoves.has(lastMove); - } - }; + override getCondition(): MoveConditionFunc { + return (user) => this.getMove(user) !== MoveId.NONE; } } @@ -8733,7 +8712,7 @@ export function initMoves() { new SelfStatusMove(MoveId.METRONOME, PokemonType.NORMAL, -1, 10, -1, 0, 1) .attr(RandomMoveAttr, invalidMetronomeMoves), new StatusMove(MoveId.MIRROR_MOVE, PokemonType.FLYING, -1, 20, -1, 0, 1) - .attr(CopyMoveAttr, true, invalidMirrorMoveMoves), + .attr(CopyMoveAttr, invalidMirrorMoveMoves, true), new AttackMove(MoveId.SELF_DESTRUCT, PokemonType.NORMAL, MoveCategory.PHYSICAL, 200, 100, 5, -1, 0, 1) .attr(SacrificialAttr) .makesContact(false) @@ -9602,7 +9581,7 @@ export function initMoves() { .target(MoveTarget.NEAR_ENEMY) .unimplemented(), new SelfStatusMove(MoveId.COPYCAT, PokemonType.NORMAL, -1, 20, -1, 0, 4) - .attr(CopyMoveAttr, false, invalidCopycatMoves), + .attr(CopyMoveAttr, invalidCopycatMoves), new StatusMove(MoveId.POWER_SWAP, PokemonType.PSYCHIC, -1, 10, 100, 0, 4) .attr(SwapStatStagesAttr, [ Stat.ATK, Stat.SPATK ]) .ignoresSubstitute(), diff --git a/test/moves/assist.test.ts b/test/moves/assist.test.ts index bc51f8bd06a..a0d1719668e 100644 --- a/test/moves/assist.test.ts +++ b/test/moves/assist.test.ts @@ -1,13 +1,12 @@ import { BattlerIndex } from "#enums/battler-index"; import { Stat } from "#app/enums/stat"; import { MoveResult } from "#enums/move-result"; -import { CommandPhase } from "#app/phases/command-phase"; import { AbilityId } from "#enums/ability-id"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; import GameManager from "#test/testUtils/gameManager"; import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; describe("Moves - Assist", () => { let phaserGame: Phaser.Game; @@ -25,11 +24,12 @@ describe("Moves - Assist", () => { beforeEach(() => { game = new GameManager(phaserGame); + // Manual moveset overrides are required for the player pokemon in these tests // because the normal moveset override doesn't allow for accurate testing of moveset changes game.override .ability(AbilityId.BALL_FETCH) - .battleStyle("double") + .battleStyle("single") .disableCrits() .enemySpecies(SpeciesId.MAGIKARP) .enemyLevel(100) @@ -37,69 +37,73 @@ describe("Moves - Assist", () => { .enemyMoveset(MoveId.SPLASH); }); - it("should only use an ally's moves", async () => { - game.override.enemyMoveset(MoveId.SWORDS_DANCE); + it("should call a random eligible move from an ally's moveset and apply secondary effects", async () => { + game.override.battleStyle("double"); await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.SHUCKLE]); const [feebas, shuckle] = game.scene.getPlayerField(); - // These are all moves Assist cannot call; Sketch will be used to test that it can call other moves properly - game.move.changeMoveset(feebas, [MoveId.ASSIST, MoveId.SKETCH, MoveId.PROTECT, MoveId.DRAGON_TAIL]); - game.move.changeMoveset(shuckle, [MoveId.ASSIST, MoveId.SKETCH, MoveId.PROTECT, MoveId.DRAGON_TAIL]); + game.move.changeMoveset(feebas, [MoveId.CIRCLE_THROW, MoveId.ASSIST, MoveId.WOOD_HAMMER, MoveId.ACID_SPRAY]); + game.move.changeMoveset(shuckle, [MoveId.COPYCAT, MoveId.ASSIST, MoveId.TORCH_SONG, MoveId.TACKLE]); - game.move.select(MoveId.ASSIST, 0); - game.move.select(MoveId.SKETCH, 1); - await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER_2, BattlerIndex.PLAYER]); - // Player_2 uses Sketch, copies Swords Dance, Player_1 uses Assist, uses Player_2's Sketched Swords Dance - await game.toNextTurn(); + // Force rolling the first eligible move for both mons (ensuring the user's own moves don't count) + vi.spyOn(feebas, "randBattleSeedInt").mockImplementation(() => 0); + vi.spyOn(shuckle, "randBattleSeedInt").mockImplementation(() => 0); - expect(game.scene.getPlayerPokemon()!.getStatStage(Stat.ATK)).toBe(2); // Stat raised from Assist -> Swords Dance + game.move.select(MoveId.ASSIST, BattlerIndex.PLAYER); + game.move.select(MoveId.ASSIST, BattlerIndex.PLAYER_2); + await game.toEndOfTurn(); + + expect(feebas.getLastXMoves()[0].move).toBe(MoveId.TORCH_SONG); + expect(shuckle.getLastXMoves()[0].move).toBe(MoveId.WOOD_HAMMER); + expect(feebas.getStatStage(Stat.SPATK)).toBe(1); // Stat raised from Assist --> Torch Song + expect(shuckle.hp).toBeLessThan(shuckle.getMaxHp()); // recoil dmg taken from Assist --> Wood Hammer + + expect(feebas.getLastXMoves().map(tm => tm.result)).toEqual([MoveResult.SUCCESS, MoveResult.SUCCESS]); + expect(shuckle.getLastXMoves().map(tm => tm.result)).toEqual([MoveResult.SUCCESS, MoveResult.SUCCESS]); }); - it("should fail if there are no allies", async () => { + it("should consider off-field allies", async () => { + await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.SHUCKLE]); + + const [feebas, shuckle] = game.scene.getPlayerParty(); + game.move.changeMoveset(shuckle, MoveId.HYPER_BEAM); + + game.move.use(MoveId.ASSIST); + await game.toEndOfTurn(); + + expect(feebas.getLastXMoves(-1)).toHaveLength(1); + expect(feebas.getLastXMoves()[0]).toMatchObject({ + move: MoveId.HYPER_BEAM, + target: [BattlerIndex.ENEMY], + virtual: true, + result: MoveResult.SUCCESS, + }); + }); + + it("should fail if there are no allies, even if user has eligible moves", async () => { await game.classicMode.startBattle([SpeciesId.FEEBAS]); - const feebas = game.scene.getPlayerPokemon()!; - game.move.changeMoveset(feebas, [MoveId.ASSIST, MoveId.SKETCH, MoveId.PROTECT, MoveId.DRAGON_TAIL]); + const feebas = game.field.getPlayerPokemon(); + game.move.changeMoveset(feebas, [MoveId.ASSIST, MoveId.TACKLE]); - game.move.select(MoveId.ASSIST, 0); - await game.toNextTurn(); - expect(game.scene.getPlayerPokemon()!.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + game.move.select(MoveId.ASSIST); + await game.toEndOfTurn(); + + expect(feebas.getLastXMoves(-1)).toHaveLength(1); + expect(feebas.getLastXMoves()[0].result).toBe(MoveResult.FAIL); }); - it("should fail if ally has no usable moves and user has usable moves", async () => { - game.override.enemyMoveset(MoveId.SWORDS_DANCE); + it("should fail if allies have no eligible moves", async () => { await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.SHUCKLE]); - const [feebas, shuckle] = game.scene.getPlayerField(); - game.move.changeMoveset(feebas, [MoveId.ASSIST, MoveId.SKETCH, MoveId.PROTECT, MoveId.DRAGON_TAIL]); - game.move.changeMoveset(shuckle, [MoveId.ASSIST, MoveId.SKETCH, MoveId.PROTECT, MoveId.DRAGON_TAIL]); + const [feebas, shuckle] = game.scene.getPlayerParty(); + // All of these are ineligible moves + game.move.changeMoveset(shuckle, [MoveId.METRONOME, MoveId.DIG, MoveId.FLY, MoveId.INSTRUCT]); - game.move.select(MoveId.SKETCH, 0); - game.move.select(MoveId.PROTECT, 1); - await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER, BattlerIndex.PLAYER_2]); - // Player uses Sketch to copy Swords Dance, Player_2 stalls a turn. Player will attempt Assist and should have no usable moves - await game.toNextTurn(); - game.move.select(MoveId.ASSIST, 0); - await game.phaseInterceptor.to(CommandPhase); - game.move.select(MoveId.PROTECT, 1); - await game.toNextTurn(); + game.move.use(MoveId.ASSIST); + await game.toEndOfTurn(); - expect(game.scene.getPlayerPokemon()!.getLastXMoves()[0].result).toBe(MoveResult.FAIL); - }); - - it("should apply secondary effects of a move", async () => { - game.override.moveset([MoveId.ASSIST, MoveId.WOOD_HAMMER, MoveId.WOOD_HAMMER, MoveId.WOOD_HAMMER]); - await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.SHUCKLE]); - - const [feebas, shuckle] = game.scene.getPlayerField(); - game.move.changeMoveset(feebas, [MoveId.ASSIST, MoveId.SKETCH, MoveId.PROTECT, MoveId.DRAGON_TAIL]); - game.move.changeMoveset(shuckle, [MoveId.ASSIST, MoveId.SKETCH, MoveId.PROTECT, MoveId.DRAGON_TAIL]); - - game.move.select(MoveId.ASSIST, 0); - await game.phaseInterceptor.to(CommandPhase); - game.move.select(MoveId.ASSIST, 1); - await game.toNextTurn(); - - expect(game.scene.getPlayerPokemon()!.isFullHp()).toBeFalsy(); // should receive recoil damage from Wood Hammer + expect(feebas.getLastXMoves(-1)).toHaveLength(1); + expect(feebas.getLastXMoves()[0].result).toBe(MoveResult.FAIL); }); }); diff --git a/test/moves/copycat.test.ts b/test/moves/copycat.test.ts index 1691cc1478c..f55c072db35 100644 --- a/test/moves/copycat.test.ts +++ b/test/moves/copycat.test.ts @@ -69,7 +69,7 @@ describe("Moves - Copycat", () => { it("should copy the called move when the last move successfully calls another", async () => { game.override.moveset([MoveId.SPLASH, MoveId.METRONOME]).enemyMoveset(MoveId.COPYCAT); await game.classicMode.startBattle(); - vi.spyOn(randomMoveAttr, "getMoveOverride").mockReturnValue(MoveId.SWORDS_DANCE); + vi.spyOn(randomMoveAttr, "getMove").mockReturnValue(MoveId.SWORDS_DANCE); game.move.select(MoveId.METRONOME); await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); // Player moves first, so enemy can copy Swords Dance diff --git a/test/moves/metronome.test.ts b/test/moves/metronome.test.ts index ec610eeed45..5e6706328ba 100644 --- a/test/moves/metronome.test.ts +++ b/test/moves/metronome.test.ts @@ -1,9 +1,9 @@ +import { BattlerIndex } from "#enums/battler-index"; import { RechargingTag, SemiInvulnerableTag } from "#app/data/battler-tags"; -import type { RandomMoveAttr } from "#app/data/moves/move"; import { allMoves } from "#app/data/data-lists"; import { AbilityId } from "#enums/ability-id"; import { Stat } from "#app/enums/stat"; -import { CommandPhase } from "#app/phases/command-phase"; +import { MoveResult } from "#enums/move-result"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; import GameManager from "#test/testUtils/gameManager"; @@ -14,7 +14,7 @@ describe("Moves - Metronome", () => { let phaserGame: Phaser.Game; let game: GameManager; - let randomMoveAttr: RandomMoveAttr; + const randomMoveAttr = allMoves[MoveId.METRONOME].getAttrs("RandomMoveAttr")[0]; beforeAll(() => { phaserGame = new Phaser.Game({ @@ -27,67 +27,103 @@ describe("Moves - Metronome", () => { }); beforeEach(() => { - randomMoveAttr = allMoves[MoveId.METRONOME].getAttrs("RandomMoveAttr")[0]; game = new GameManager(phaserGame); game.override .moveset([MoveId.METRONOME, MoveId.SPLASH]) .battleStyle("single") - .startingLevel(100) - .starterSpecies(SpeciesId.REGIELEKI) - .enemyLevel(100) .enemySpecies(SpeciesId.SHUCKLE) .enemyMoveset(MoveId.SPLASH) - .enemyAbility(AbilityId.BALL_FETCH); + .enemyAbility(AbilityId.STURDY); }); - it("should have one semi-invulnerable turn and deal damage on the second turn when a semi-invulnerable move is called", async () => { - await game.classicMode.startBattle(); + it("should not be able to copy MoveId.NONE", async () => { + await game.classicMode.startBattle([SpeciesId.REGIELEKI]); + + // Pick the first move available to use + const player = game.field.getPlayerPokemon(); + vi.spyOn(player, "randBattleSeedInt").mockReturnValue(0); + game.move.select(MoveId.METRONOME); + await game.toNextTurn(); + + const lastMoveStr = MoveId[player.getLastXMoves()[0].move]; + expect(lastMoveStr).not.toBe(MoveId[MoveId.NONE]); + expect(lastMoveStr).toBe(MoveId[1]); + }); + + it("should become semi-invulnerable when using phasing moves", async () => { + vi.spyOn(randomMoveAttr, "getMove").mockReturnValue(MoveId.DIVE); + await game.classicMode.startBattle([SpeciesId.REGIELEKI]); + + const player = game.scene.getPlayerPokemon()!; + expect(player.getTag(SemiInvulnerableTag)).toBeUndefined(); + expect(player.visible).toBe(true); + + game.move.select(MoveId.METRONOME); + await game.toNextTurn(); + + expect(player.getTag(SemiInvulnerableTag)).toBeDefined(); + expect(player.visible).toBe(false); + + await game.phaseInterceptor.to("TurnEndPhase"); + expect(player.getTag(SemiInvulnerableTag)).toBeUndefined(); + expect(player.visible).toBe(true); + + const enemy = game.scene.getEnemyPokemon()!; + expect(enemy.hp).toBeLessThan(enemy.getMaxHp()); + }); + + it("should apply secondary effects of the called move", async () => { + vi.spyOn(randomMoveAttr, "getMove").mockReturnValue(MoveId.WOOD_HAMMER); + await game.classicMode.startBattle([SpeciesId.REGIELEKI]); + + game.move.select(MoveId.METRONOME); + await game.toNextTurn(); + const player = game.scene.getPlayerPokemon()!; const enemy = game.scene.getEnemyPokemon()!; - vi.spyOn(randomMoveAttr, "getMoveOverride").mockReturnValue(MoveId.DIVE); - game.move.select(MoveId.METRONOME); - await game.toNextTurn(); - - expect(player.getTag(SemiInvulnerableTag)).toBeTruthy(); - - await game.toNextTurn(); - expect(player.getTag(SemiInvulnerableTag)).toBeFalsy(); - expect(enemy.isFullHp()).toBeFalsy(); + expect(player.hp).toBeLessThan(player.getMaxHp()); + expect(enemy.hp).toBeLessThan(enemy.getMaxHp()); }); - it("should apply secondary effects of a move", async () => { - await game.classicMode.startBattle(); - const player = game.scene.getPlayerPokemon()!; - vi.spyOn(randomMoveAttr, "getMoveOverride").mockReturnValue(MoveId.WOOD_HAMMER); + it("should count as last move used for Copycat/Mirror Move", async () => { + vi.spyOn(randomMoveAttr, "getMove").mockReturnValue(MoveId.ABSORB); + await game.classicMode.startBattle([SpeciesId.REGIELEKI]); game.move.select(MoveId.METRONOME); + await game.move.forceEnemyMove(MoveId.MIRROR_MOVE); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); await game.toNextTurn(); - expect(player.isFullHp()).toBeFalsy(); + const player = game.scene.getPlayerPokemon()!; + const enemy = game.scene.getEnemyPokemon()!; + + expect(player.hp).toBeLessThan(player.getMaxHp()); + expect(enemy.hp).toBeLessThan(enemy.getMaxHp()); + expect(enemy.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); }); - it("should recharge after using recharge move", async () => { - await game.classicMode.startBattle(); + it("should recharge after using recharge moves", async () => { + await game.classicMode.startBattle([SpeciesId.REGIELEKI]); const player = game.scene.getPlayerPokemon()!; - vi.spyOn(randomMoveAttr, "getMoveOverride").mockReturnValue(MoveId.HYPER_BEAM); + vi.spyOn(randomMoveAttr, "getMove").mockReturnValue(MoveId.HYPER_BEAM); vi.spyOn(allMoves[MoveId.HYPER_BEAM], "accuracy", "get").mockReturnValue(100); game.move.select(MoveId.METRONOME); await game.toNextTurn(); - expect(player.getTag(RechargingTag)).toBeTruthy(); + expect(player.getTag(RechargingTag)).toBeDefined(); }); it("should only target ally for Aromatic Mist", async () => { game.override.battleStyle("double"); await game.classicMode.startBattle([SpeciesId.REGIELEKI, SpeciesId.RATTATA]); + const [leftPlayer, rightPlayer] = game.scene.getPlayerField(); const [leftOpp, rightOpp] = game.scene.getEnemyField(); - vi.spyOn(randomMoveAttr, "getMoveOverride").mockReturnValue(MoveId.AROMATIC_MIST); + vi.spyOn(randomMoveAttr, "getMove").mockReturnValue(MoveId.AROMATIC_MIST); game.move.select(MoveId.METRONOME, 0); - await game.phaseInterceptor.to(CommandPhase); game.move.select(MoveId.SPLASH, 1); await game.toNextTurn(); @@ -97,9 +133,9 @@ describe("Moves - Metronome", () => { expect(rightOpp.getStatStage(Stat.SPDEF)).toBe(0); }); - it("should cause opponent to flee, and not crash for Roar", async () => { - await game.classicMode.startBattle(); - vi.spyOn(randomMoveAttr, "getMoveOverride").mockReturnValue(MoveId.ROAR); + it("should cause opponent to flee when using Roar", async () => { + await game.classicMode.startBattle([SpeciesId.REGIELEKI]); + vi.spyOn(randomMoveAttr, "getMove").mockReturnValue(MoveId.ROAR); const enemyPokemon = game.scene.getEnemyPokemon()!; @@ -108,8 +144,9 @@ describe("Moves - Metronome", () => { const isVisible = enemyPokemon.visible; const hasFled = enemyPokemon.switchOutStatus; - expect(!isVisible && hasFled).toBe(true); + expect(isVisible).toBe(false); + expect(hasFled).toBe(true); - await game.phaseInterceptor.to("CommandPhase"); + await game.toNextTurn(); // Check no crash }); }); diff --git a/test/moves/moongeist_beam.test.ts b/test/moves/moongeist_beam.test.ts index 28bd3f70daa..763659e3f0a 100644 --- a/test/moves/moongeist_beam.test.ts +++ b/test/moves/moongeist_beam.test.ts @@ -48,7 +48,7 @@ describe("Moves - Moongeist Beam", () => { // Also covers Photon Geyser and Sunsteel Strike it("should not ignore enemy abilities when called by another move, such as metronome", async () => { await game.classicMode.startBattle([SpeciesId.MILOTIC]); - vi.spyOn(allMoves[MoveId.METRONOME].getAttrs("RandomMoveAttr")[0], "getMoveOverride").mockReturnValue( + vi.spyOn(allMoves[MoveId.METRONOME].getAttrs("RandomMoveAttr")[0], "getMove").mockReturnValue( MoveId.MOONGEIST_BEAM, ); diff --git a/test/moves/nature-power.test.ts b/test/moves/nature-power.test.ts new file mode 100644 index 00000000000..729e7fe991b --- /dev/null +++ b/test/moves/nature-power.test.ts @@ -0,0 +1,85 @@ +import { allMoves } from "#app/data/data-lists"; +import { TerrainType } from "#app/data/terrain"; +import { getPokemonNameWithAffix } from "#app/messages"; +import { getEnumValues, toReadableString } from "#app/utils/common"; +import { AbilityId } from "#enums/ability-id"; +import { BiomeId } from "#enums/biome-id"; +import { MoveId } from "#enums/move-id"; +import { SpeciesId } from "#enums/species-id"; +import GameManager from "#test/testUtils/gameManager"; +import i18next from "i18next"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Move - Nature Power", () => { + 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 + .ability(AbilityId.BALL_FETCH) + .battleStyle("single") + .disableCrits() + .enemySpecies(SpeciesId.MAGIKARP) + .enemyAbility(AbilityId.NO_GUARD) + .enemyMoveset(MoveId.SPLASH) + .startingLevel(100) + .enemyLevel(100); + }); + + const getNaturePowerType = allMoves[MoveId.NATURE_POWER].getAttrs("NaturePowerAttr")[0]["getMoveIdForTerrain"]; + + it.each( + getEnumValues(BiomeId).map(biome => ({ + move: getNaturePowerType(TerrainType.NONE, biome), + moveName: toReadableString(MoveId[getNaturePowerType(TerrainType.NONE, biome)]), + biome, + biomeName: BiomeId[biome], + })), + )("should select $moveName if the current biome is $biomeName", async ({ move, biome }) => { + game.override.startingBiome(biome); + await game.classicMode.startBattle([SpeciesId.FEEBAS]); + + game.move.use(MoveId.NATURE_POWER); + await game.toEndOfTurn(); + + const player = game.field.getPlayerPokemon(); + expect(player.getLastXMoves(-1).map(m => m.move)).toEqual([move, MoveId.NATURE_POWER]); + expect(game.textInterceptor.logs).toContain( + i18next.t("moveTriggers:naturePowerUse", { + pokemonName: getPokemonNameWithAffix(player), + moveName: allMoves[move].name, + }), + ); + }); + + // TODO: Add after terrain override is added + it.todo.each( + getEnumValues(TerrainType).map(terrain => ({ + move: getNaturePowerType(terrain, BiomeId.TOWN), + moveName: toReadableString(MoveId[getNaturePowerType(terrain, BiomeId.TOWN)]), + terrain: terrain, + terrainName: TerrainType[terrain], + })), + )("should select $moveName if the current terrain is $terrainName", async ({ move /* terrain */ }) => { + // game.override.terrain(terrainType); + await game.classicMode.startBattle([SpeciesId.FEEBAS]); + + game.move.use(MoveId.NATURE_POWER); + await game.toEndOfTurn(); + + const player = game.field.getPlayerPokemon(); + expect(player.getLastXMoves(-1).map(m => m.move)).toEqual([move, MoveId.NATURE_POWER]); + }); +}); diff --git a/test/moves/sketch.test.ts b/test/moves/sketch.test.ts index c6fb7b4a32a..8ac800ea5ae 100644 --- a/test/moves/sketch.test.ts +++ b/test/moves/sketch.test.ts @@ -84,7 +84,7 @@ describe("Moves - Sketch", () => { const randomMoveAttr = allMoves[MoveId.METRONOME].findAttr( attr => attr instanceof RandomMoveAttr, ) as RandomMoveAttr; - vi.spyOn(randomMoveAttr, "getMoveOverride").mockReturnValue(MoveId.FALSE_SWIPE); + vi.spyOn(randomMoveAttr, "getMove").mockReturnValue(MoveId.FALSE_SWIPE); game.override.enemyMoveset([MoveId.METRONOME]); await game.classicMode.startBattle([SpeciesId.REGIELEKI]);