diff --git a/src/battle.ts b/src/battle.ts index 7b6a58cbaca..00f3b04f2ab 100644 --- a/src/battle.ts +++ b/src/battle.ts @@ -5,7 +5,7 @@ import { BattleSpec } from "#enums/battle-spec"; import { BattleType } from "#enums/battle-type"; import { BattlerIndex } from "#enums/battler-index"; import type { Command } from "#enums/command"; -import type { MoveId } from "#enums/move-id"; +import { MoveId } from "#enums/move-id"; import { MysteryEncounterMode } from "#enums/mystery-encounter-mode"; import type { MysteryEncounterType } from "#enums/mystery-encounter-type"; import type { PokeballType } from "#enums/pokeball"; @@ -70,7 +70,11 @@ export class Battle { public battleScore = 0; public postBattleLoot: PokemonHeldItemModifier[] = []; public escapeAttempts = 0; - public lastMove: MoveId; + /** + * A tracker of the last {@linkcode MoveId} successfully used this battle. + * + */ + public lastMove: MoveId = MoveId.NONE; public battleSeed: string = randomString(16, true); private battleSeedState: string | null = null; public moneyScattered = 0; diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index e21065c184f..333aebab52e 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -1228,6 +1228,7 @@ export class EncoreTag extends MoveRestrictionBattlerTag { constructor(sourceId: number) { super( BattlerTagType.ENCORE, + // TODO: This should trigger on turn end [BattlerTagLapseType.CUSTOM, BattlerTagLapseType.AFTER_MOVE], 3, MoveId.ENCORE, diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 8557768bc03..90817159d8c 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -3130,7 +3130,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 @@ -3175,7 +3174,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() { @@ -6787,297 +6785,287 @@ 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; +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, + * as opposed to the move's selected target. + */ + override selfTarget = true, + ) { + super(selfTarget) + } - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - // Get eligible targets for move, failing if we can't target anything - 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")); - return false; - } + /** + * 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 {@linkcode 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); // Spread moves and ones with only 1 valid target will use their normal targeting. // If not, target the Mirror Move recipient or else a random enemy in our target list const targets = moveTargets.multiple || moveTargets.targets.length === 1 ? moveTargets.targets - : [this.hasTarget + : [this.selfTarget ? target.getBattlerIndex() : moveTargets.targets[user.randBattleSeedInt(moveTargets.targets.length)]]; - globalScene.phaseManager.unshiftNew("LoadMoveAnimPhase", move.id); - globalScene.phaseManager.unshiftNew("MovePhase", user, targets, new PokemonMove(move.id), MoveUseMode.FOLLOW_UP); + globalScene.phaseManager.unshiftNew("LoadMoveAnimPhase", copiedMove.id); + globalScene.phaseManager.unshiftNew("MovePhase", user, targets, new PokemonMove(copiedMove.id), MoveUseMode.FOLLOW_UP); 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 - */ -export class RandomMoveAttr extends CallMoveAttr { - constructor(invalidMoves: ReadonlySet) { - super(); - this.invalidMoves = invalidMoves; - } - - /** - * 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. - * - * 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 - */ - 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); - } -} /** - * Attribute used to call a random move in the user or party's moveset. - * 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 + * Attribute to call a different move based on the current terrain and biome. + * Used by {@linkcode MoveId.NATURE_POWER}. */ -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 NaturePowerAttr extends CallMoveAttr { + constructor() { + super(false) + } + + override getMove(user: Pokemon): MoveId { + const moveId = this.getMoveIdForTerrain(globalScene.arena.getTerrainType(), globalScene.arena.biomeType) + globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:naturePowerUse", { + pokemonName: getPokemonNameWithAffix(user), + moveName: allMoves[moveId].name, + })) + + return moveId; } /** - * 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 + * Helper function to retrieve the correct move for the current terrain and biome. + * Made into a separate function for brevity. */ - 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() : globalScene.getEnemyParty()).filter(p => p !== user); - } else { - allies = [ user ]; - } - const partyMoveset = allies.flatMap(p => p.moveset); - 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; - }; - } -} - -// TODO: extend CallMoveAttr -export class NaturePowerAttr extends OverrideMoveEffectAttr { - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - let moveId = MoveId.NONE; - 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; + private getMoveIdForTerrain(terrain: TerrainType, biome: BiomeId): MoveId { + switch (terrain) { case TerrainType.ELECTRIC: - moveId = MoveId.THUNDERBOLT; - break; + return MoveId.THUNDERBOLT; case TerrainType.GRASSY: - moveId = MoveId.ENERGY_BALL; - break; + return MoveId.ENERGY_BALL; case TerrainType.PSYCHIC: - moveId = MoveId.PSYCHIC; - break; - default: - // Just in case there's no match - moveId = MoveId.TRI_ATTACK; - break; - } + return MoveId.PSYCHIC; + case TerrainType.MISTY: + return MoveId.MOONBLAST; + } - // Load the move's animation if we didn't already and unshift a new usage phase - globalScene.phaseManager.unshiftNew("LoadMoveAnimPhase", moveId); - globalScene.phaseManager.unshiftNew("MovePhase", user, [ target.getBattlerIndex() ], new PokemonMove(moveId), MoveUseMode.FOLLOW_UP); - return true; + // 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 + biome satisfies never; + console.warn(`NaturePowerAttr lacks defined move to use for current biome ${toTitleCase(BiomeId[biome])}; 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} - * @see {@linkcode apply} for move selection and move call - * @extends CallMoveAttr to call a selected move + * Abstract class to encompass move-copying-moves with a banlist of invalid moves. */ -export class CopyMoveAttr extends CallMoveAttr { - private mirrorMove: boolean; - constructor(mirrorMove: boolean, invalidMoves: ReadonlySet = new Set()) { - super(); - this.mirrorMove = mirrorMove; +abstract class CallMoveAttrWithBanlist extends CallMoveAttr { + /** + * A {@linkcode ReadonlySet} containing all moves that this {@linkcode MoveAttr} cannot copy, + * in addition to unimplemented moves and {@linkcode MoveId.NONE}. + * The move should fail if the chosen move is inside this banlist. + */ + protected readonly invalidMoves: ReadonlySet + + constructor( + invalidMoves: ReadonlySet, + selfTarget = true, + ) { + super(selfTarget); this.invalidMoves = invalidMoves; } +} - apply(user: Pokemon, target: Pokemon, _move: Move, args: any[]): boolean { - this.hasTarget = this.mirrorMove; - // bang is correct as condition func returns `false` and fails move if no last move exists - const lastMove = this.mirrorMove ? target.getLastNonVirtualMove(false, false)!.move : globalScene.currentBattle.lastMove; - return super.apply(user, target, allMoves[lastMove], args); +/** + * Attribute used to copy the last move executed, either globally or by the specific target. + * Used for {@linkcode MoveId.COPYCAT} and {@linkcode MoveId.MIRROR_MOVE}. + */ +export class CopyMoveAttr extends CallMoveAttrWithBanlist { + override getMove(_user: Pokemon, target: Pokemon): MoveId { + // If `selfTarget` is `true`, return the last successful move used by anyone on-field. + // Otherwise, select the last move used by the target specifically. + return this.selfTarget + ? globalScene.currentBattle.lastMove + : target.getLastXMoves()[0]?.move ?? MoveId.NONE } getCondition(): MoveConditionFunc { return (_user, target, _move) => { - const lastMove = this.mirrorMove ? target.getLastNonVirtualMove(false, false)?.move : globalScene.currentBattle.lastMove; - return !isNullOrUndefined(lastMove) && !this.invalidMoves.has(lastMove); + 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 CallMoveAttrWithBanlist { + constructor(invalidMoves: ReadonlySet) { + super(invalidMoves, true); + } + + /** + * 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. + * + * @param user - The {@linkcode Pokemon} using the move + * @returns The {@linkcode MoveId} that will be called. + */ + 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)]; + } +} + +/** + * Attribute used to call a random move in the user or its allies' moveset. + * Used for {@linkcode MoveId.ASSIST} and {@linkcode MoveId.SLEEP_TALK}. + * + * Fails if the user has no callable moves. + */ +export class RandomMovesetMoveAttr extends RandomMoveAttr { + /** + * The previously-selected {@linkcode MoveId} for this attribute, or `MoveId.NONE` if none could be found. + * Reset to {@linkcode MoveId.NONE} after a successful use. + * @defaultValue `MoveId.NONE` + */ + private selectedMove: MoveId = MoveId.NONE + /** + * Whether to consider moves from the user's other party members (`true`) + * or the user's own moveset (`false`). + * @defaultValue `false`. + */ + private includeParty = false; + constructor(invalidMoves: ReadonlySet, includeParty = false) { + super(invalidMoves); + this.includeParty = includeParty + } + + /** + * 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. + */ + override getMove(user: Pokemon): MoveId { + // If we already have a selected move from the condition function, + // re-use and reset it rather than generating another random move + if (this.selectedMove) { + const m = this.selectedMove; + this.selectedMove = MoveId.NONE; + return m; + } + + // `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 ]; + + // 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; + } + + override getCondition(): MoveConditionFunc { + return (user) => this.getMove(user) !== MoveId.NONE; + } +} + /** * Attribute used for moves that cause the target to repeat their last used move. * @@ -8775,7 +8763,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) @@ -9648,7 +9636,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/src/phases/move-end-phase.ts b/src/phases/move-end-phase.ts index fd893c445ff..d24207c0539 100644 --- a/src/phases/move-end-phase.ts +++ b/src/phases/move-end-phase.ts @@ -2,20 +2,32 @@ import { applyAbAttrs } from "#abilities/apply-ab-attrs"; import { globalScene } from "#app/global-scene"; import type { BattlerIndex } from "#enums/battler-index"; import { BattlerTagLapseType } from "#enums/battler-tag-lapse-type"; +import { MoveId } from "#enums/move-id"; import type { Pokemon } from "#field/pokemon"; import { PokemonPhase } from "#phases/pokemon-phase"; export class MoveEndPhase extends PokemonPhase { public readonly phaseName = "MoveEndPhase"; + /** + * Whether the current move was a follow-up attack or not. + * Used to prevent ticking down Encore and similar effects when copying moves. + */ private wasFollowUp: boolean; + /** + * Whether the current move successfully executed and showed usage text. + * Used to update the "last move used" tracker after successful move usage. + */ + private passedPreUsageChecks: boolean; /** Targets from the preceding MovePhase */ private targets: Pokemon[]; - constructor(battlerIndex: BattlerIndex, targets: Pokemon[], wasFollowUp = false) { + + constructor(battlerIndex: BattlerIndex, targets: Pokemon[], wasFollowUp: boolean, passedPreUsageChecks: boolean) { super(battlerIndex); this.targets = targets; this.wasFollowUp = wasFollowUp; + this.passedPreUsageChecks = passedPreUsageChecks; } start() { @@ -26,6 +38,12 @@ export class MoveEndPhase extends PokemonPhase { pokemon.lapseTags(BattlerTagLapseType.AFTER_MOVE); } + // Update the "last move used" counter for Copycat and co. + if (this.passedPreUsageChecks) { + // TODO: Make this check a move in flight instead of a hackjob + globalScene.currentBattle.lastMove = pokemon.getLastXMoves()[0]?.move ?? MoveId.NONE; + } + // Remove effects which were set on a Pokemon which removes them on summon (i.e. via Mold Breaker) globalScene.arena.setIgnoreAbilities(false); for (const target of this.targets) { diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 82bb6b153ef..94cff99cd48 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -42,6 +42,12 @@ export class MovePhase extends BattlePhase { protected failed = false; /** Whether the current move should fail and retain PP. */ protected cancelled = false; + /** + * Whether the move was interrupted prior to showing usage text. + * Used to set the "last move" pointer after move end. + * @defaultValue `true` + */ + private wasPreInterrupted = true; public get pokemon(): Pokemon { return this._pokemon; @@ -294,6 +300,10 @@ export class MovePhase extends BattlePhase { const moveQueue = this.pokemon.getMoveQueue(); const move = this.move.getMove(); + // Set flag to update Copycat's "last move" counter + // TODO: Verify interaction with a failed Focus Punch + this.wasPreInterrupted = false; + // form changes happen even before we know that the move wll execute. globalScene.triggerPokemonFormChange(this.pokemon, SpeciesFormChangePreMoveTrigger); @@ -367,11 +377,6 @@ export class MovePhase extends BattlePhase { const move = this.move.getMove(); const targets = this.getActiveTargetPokemon(); - // Update the battle's "last move" pointer unless we're currently mimicking a move or triggering Dancer. - if (!move.hasAttr("CopyMoveAttr") && !isReflected(this.useMode)) { - globalScene.currentBattle.lastMove = move.id; - } - // Trigger ability-based user type changes, display move text and then execute move effects. // TODO: Investigate whether PokemonTypeChangeAbAttr can drop the "opponent" parameter applyAbAttrs("PokemonTypeChangeAbAttr", { pokemon: this.pokemon, move, opponent: targets[0] }); @@ -459,6 +464,9 @@ export class MovePhase extends BattlePhase { const move = this.move.getMove(); const targets = this.getActiveTargetPokemon(); + // Set flag to update Copycat's "last move" counter + this.wasPreInterrupted = false; + if (!move.applyConditions(this.pokemon, targets[0], move)) { this.failMove(true); return; @@ -490,6 +498,7 @@ export class MovePhase extends BattlePhase { this.pokemon.getBattlerIndex(), this.getActiveTargetPokemon(), isVirtual(this.useMode), + this.wasPreInterrupted, ); super.end(); diff --git a/test/abilities/gorilla-tactics.test.ts b/test/abilities/gorilla-tactics.test.ts index 83e6cdb156e..a96639133f9 100644 --- a/test/abilities/gorilla-tactics.test.ts +++ b/test/abilities/gorilla-tactics.test.ts @@ -5,10 +5,9 @@ import { MoveResult } from "#enums/move-result"; import { MoveUseMode } from "#enums/move-use-mode"; import { SpeciesId } from "#enums/species-id"; import { Stat } from "#enums/stat"; -import { RandomMoveAttr } from "#moves/move"; import { GameManager } from "#test/test-utils/game-manager"; import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; describe("Abilities - Gorilla Tactics", () => { let phaserGame: Phaser.Game; @@ -83,7 +82,7 @@ describe("Abilities - Gorilla Tactics", () => { }); it("should lock into calling moves, even if also in moveset", async () => { - vi.spyOn(RandomMoveAttr.prototype, "getMoveOverride").mockReturnValue(MoveId.TACKLE); + game.move.forceMetronomeMove(MoveId.TACKLE); await game.classicMode.startBattle([SpeciesId.GALAR_DARMANITAN]); const darmanitan = game.scene.getPlayerPokemon()!; diff --git a/test/abilities/sap-sipper.test.ts b/test/abilities/sap-sipper.test.ts index a1c034ab126..493b8709b7e 100644 --- a/test/abilities/sap-sipper.test.ts +++ b/test/abilities/sap-sipper.test.ts @@ -1,16 +1,12 @@ -import { allMoves } from "#data/data-lists"; import { TerrainType } from "#data/terrain"; import { AbilityId } from "#enums/ability-id"; import { BattlerTagType } from "#enums/battler-tag-type"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; import { Stat } from "#enums/stat"; -import { RandomMoveAttr } from "#moves/move"; -import { MoveEndPhase } from "#phases/move-end-phase"; -import { TurnEndPhase } from "#phases/turn-end-phase"; import { GameManager } from "#test/test-utils/game-manager"; import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; // See also: TypeImmunityAbAttr describe("Abilities - Sap Sipper", () => { @@ -38,131 +34,98 @@ describe("Abilities - Sap Sipper", () => { .enemyMoveset(MoveId.SPLASH); }); - it("raises ATK stat stage by 1 and block effects when activated against a grass attack", async () => { - const moveToUse = MoveId.LEAFAGE; - - game.override.moveset(moveToUse); - + it("should nullify all effects of Grass-type attacks and raise ATK by 1 stage", async () => { await game.classicMode.startBattle([SpeciesId.BULBASAUR]); - const enemyPokemon = game.scene.getEnemyPokemon()!; - const initialEnemyHp = enemyPokemon.hp; + game.move.use(MoveId.LEAFAGE); + await game.toNextTurn(); - game.move.select(moveToUse); - - await game.phaseInterceptor.to(TurnEndPhase); - - expect(initialEnemyHp - enemyPokemon.hp).toBe(0); + const enemyPokemon = game.field.getEnemyPokemon(); + expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp()); expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(1); }); - it("raises ATK stat stage by 1 and block effects when activated against a grass status move", async () => { - const moveToUse = MoveId.SPORE; - - game.override.moveset(moveToUse); - + it("should work on grass status moves", async () => { await game.classicMode.startBattle([SpeciesId.BULBASAUR]); const enemyPokemon = game.scene.getEnemyPokemon()!; - game.move.select(moveToUse); - - await game.phaseInterceptor.to(TurnEndPhase); + game.move.use(MoveId.SPORE); + await game.toNextTurn(); expect(enemyPokemon.status).toBeUndefined(); expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(1); }); - it("do not activate against status moves that target the field", async () => { - const moveToUse = MoveId.GRASSY_TERRAIN; - - game.override.moveset(moveToUse); - + it("should not activate on non Grass-type moves", async () => { await game.classicMode.startBattle([SpeciesId.BULBASAUR]); - game.move.select(moveToUse); + game.move.use(MoveId.TACKLE); + await game.toEndOfTurn(); - await game.phaseInterceptor.to(TurnEndPhase); - - expect(game.scene.arena.terrain).toBeDefined(); - expect(game.scene.arena.terrain!.terrainType).toBe(TerrainType.GRASSY); - expect(game.scene.getEnemyPokemon()!.getStatStage(Stat.ATK)).toBe(0); - }); - - it("activate once against multi-hit grass attacks", async () => { - const moveToUse = MoveId.BULLET_SEED; - - game.override.moveset(moveToUse); - - await game.classicMode.startBattle([SpeciesId.BULBASAUR]); - - const enemyPokemon = game.scene.getEnemyPokemon()!; - const initialEnemyHp = enemyPokemon.hp; - - game.move.select(moveToUse); - - await game.phaseInterceptor.to(TurnEndPhase); - - expect(initialEnemyHp - enemyPokemon.hp).toBe(0); - expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(1); - }); - - it("do not activate against status moves that target the user", async () => { - const moveToUse = MoveId.SPIKY_SHIELD; - - game.override.moveset(moveToUse); - - await game.classicMode.startBattle([SpeciesId.BULBASAUR]); - - const playerPokemon = game.scene.getPlayerPokemon()!; - - game.move.select(moveToUse); - - await game.phaseInterceptor.to(MoveEndPhase); - - expect(playerPokemon.getTag(BattlerTagType.SPIKY_SHIELD)).toBeDefined(); - - await game.phaseInterceptor.to(TurnEndPhase); - - expect(playerPokemon.getStatStage(Stat.ATK)).toBe(0); + const enemy = game.field.getEnemyPokemon(); + expect(enemy.hp).toBeLessThan(enemy.getMaxHp()); + expect(enemy.getStatStage(Stat.ATK)).toBe(0); expect(game.phaseInterceptor.log).not.toContain("ShowAbilityPhase"); }); - it("activate once against multi-hit grass attacks (metronome)", async () => { - const moveToUse = MoveId.METRONOME; - - const randomMoveAttr = allMoves[MoveId.METRONOME].findAttr( - attr => attr instanceof RandomMoveAttr, - ) as RandomMoveAttr; - vi.spyOn(randomMoveAttr, "getMoveOverride").mockReturnValue(MoveId.BULLET_SEED); - - game.override.moveset(moveToUse); - + it("should not activate against field-targeted moves", async () => { await game.classicMode.startBattle([SpeciesId.BULBASAUR]); - const enemyPokemon = game.scene.getEnemyPokemon()!; - const initialEnemyHp = enemyPokemon.hp; + game.move.use(MoveId.GRASSY_TERRAIN); + await game.toNextTurn(); - game.move.select(moveToUse); - - await game.phaseInterceptor.to(TurnEndPhase); - - expect(initialEnemyHp - enemyPokemon.hp).toBe(0); - expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(1); + expect(game.scene.arena.terrain).toBeDefined(); + expect(game.scene.arena.terrain!.terrainType).toBe(TerrainType.GRASSY); + expect(game.field.getEnemyPokemon().getStatStage(Stat.ATK)).toBe(0); }); - it("still activates regardless of accuracy check", async () => { - game.override.moveset(MoveId.LEAF_BLADE); - + it("should trigger and cancel multi-hit moves, including ones called indirectly", async () => { + game.move.forceMetronomeMove(MoveId.BULLET_SEED); await game.classicMode.startBattle([SpeciesId.BULBASAUR]); - const enemyPokemon = game.scene.getEnemyPokemon()!; + const player = game.field.getPlayerPokemon(); + const enemy = game.field.getEnemyPokemon(); - game.move.select(MoveId.LEAF_BLADE); - await game.phaseInterceptor.to("MoveEffectPhase"); + game.move.use(MoveId.BULLET_SEED); + await game.toEndOfTurn(); + expect(enemy.hp).toBe(enemy.getMaxHp()); + expect(enemy.getStatStage(Stat.ATK)).toBe(1); + expect(player.turnData.hitCount).toBe(1); + + game.move.use(MoveId.METRONOME); + await game.toEndOfTurn(); + + expect(enemy.hp).toBe(enemy.getMaxHp()); + expect(enemy.getStatStage(Stat.ATK)).toBe(2); + expect(player.turnData.hitCount).toBe(1); + }); + + it("should not activate on self-targeted status moves", async () => { + await game.classicMode.startBattle([SpeciesId.BULBASAUR]); + + const player = game.field.getPlayerPokemon(); + + game.move.use(MoveId.SPIKY_SHIELD); + await game.phaseInterceptor.to("MoveEndPhase"); + + expect(player.getTag(BattlerTagType.SPIKY_SHIELD)).toBeDefined(); + + await game.toEndOfTurn(); + + expect(player.getStatStage(Stat.ATK)).toBe(0); + expect(game.phaseInterceptor.log).not.toContain("ShowAbilityPhase"); + }); + + it("should activate even on missed moves", async () => { + await game.classicMode.startBattle([SpeciesId.BULBASAUR]); + + game.move.use(MoveId.LEAF_BLADE); await game.move.forceMiss(); - await game.phaseInterceptor.to("BerryPhase", false); + await game.toEndOfTurn(); + + const enemyPokemon = game.field.getEnemyPokemon(); expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(1); }); }); diff --git a/test/moves/ability-ignore-moves.test.ts b/test/moves/ability-ignore-moves.test.ts index 750d8fe2f20..e226b1aacd4 100644 --- a/test/moves/ability-ignore-moves.test.ts +++ b/test/moves/ability-ignore-moves.test.ts @@ -23,7 +23,6 @@ describe("Moves - Ability-Ignoring Moves", () => { beforeEach(() => { game = new GameManager(phaserGame); game.override - .moveset([MoveId.MOONGEIST_BEAM, MoveId.SUNSTEEL_STRIKE, MoveId.PHOTON_GEYSER, MoveId.METRONOME]) .ability(AbilityId.BALL_FETCH) .startingLevel(200) .battleStyle("single") @@ -43,25 +42,26 @@ describe("Moves - Ability-Ignoring Moves", () => { const player = game.field.getPlayerPokemon(); const enemy = game.field.getEnemyPokemon(); - game.move.select(move); + game.move.use(move); await game.phaseInterceptor.to("MoveEffectPhase"); expect(game.scene.arena.ignoreAbilities).toBe(true); expect(game.scene.arena.ignoringEffectSource).toBe(player.getBattlerIndex()); await game.toEndOfTurn(); + expect(game.scene.arena.ignoreAbilities).toBe(false); expect(enemy.isFainted()).toBe(true); }); it("should not ignore enemy abilities when called by Metronome", async () => { - await game.classicMode.startBattle([SpeciesId.MILOTIC]); game.move.forceMetronomeMove(MoveId.PHOTON_GEYSER, true); + await game.classicMode.startBattle([SpeciesId.MILOTIC]); - const enemy = game.field.getEnemyPokemon(); - game.move.select(MoveId.METRONOME); + game.move.use(MoveId.METRONOME); await game.toEndOfTurn(); + const enemy = game.field.getEnemyPokemon(); expect(enemy.isFainted()).toBe(false); expect(game.field.getPlayerPokemon().getLastXMoves()[0].move).toBe(MoveId.PHOTON_GEYSER); }); diff --git a/test/moves/assist.test.ts b/test/moves/assist.test.ts index 52467c2ba98..0ff5f7d2ecf 100644 --- a/test/moves/assist.test.ts +++ b/test/moves/assist.test.ts @@ -2,11 +2,12 @@ import { AbilityId } from "#enums/ability-id"; import { BattlerIndex } from "#enums/battler-index"; import { MoveId } from "#enums/move-id"; import { MoveResult } from "#enums/move-result"; +import { MoveUseMode } from "#enums/move-use-mode"; import { SpeciesId } from "#enums/species-id"; import { Stat } from "#enums/stat"; import { GameManager } from "#test/test-utils/game-manager"; 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; @@ -24,11 +25,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") + .ability(AbilityId.NO_GUARD) + .battleStyle("single") .criticalHits(false) .enemySpecies(SpeciesId.MAGIKARP) .enemyLevel(100) @@ -36,66 +38,72 @@ 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 + 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(-1).map(tm => tm.result)).toEqual([MoveResult.SUCCESS, MoveResult.SUCCESS]); + expect(shuckle.getLastXMoves(-1).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(2); + expect(feebas.getLastXMoves()[0]).toMatchObject({ + move: MoveId.HYPER_BEAM, + useMode: MoveUseMode.INDIRECT, + 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]); - 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); - 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 () => { - await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.SHUCKLE]); - - const [feebas, shuckle] = game.scene.getPlayerField(); - game.move.changeMoveset(feebas, [MoveId.ASSIST, MoveId.WOOD_HAMMER]); - game.move.changeMoveset(shuckle, [MoveId.ASSIST, MoveId.WOOD_HAMMER]); - - game.move.select(MoveId.ASSIST, 0); - 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/chilly-reception.test.ts b/test/moves/chilly-reception.test.ts index 948b42cb3f2..78032292b38 100644 --- a/test/moves/chilly-reception.test.ts +++ b/test/moves/chilly-reception.test.ts @@ -4,11 +4,10 @@ import { MoveId } from "#enums/move-id"; import { MoveResult } from "#enums/move-result"; import { SpeciesId } from "#enums/species-id"; import { WeatherType } from "#enums/weather-type"; -import { RandomMoveAttr } from "#moves/move"; import { GameManager } from "#test/test-utils/game-manager"; import i18next from "i18next"; import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; describe("Moves - Chilly Reception", () => { let phaserGame: Phaser.Game; @@ -116,7 +115,7 @@ describe("Moves - Chilly Reception", () => { }); it("should succeed without message if called indirectly", async () => { - vi.spyOn(RandomMoveAttr.prototype, "getMoveOverride").mockReturnValue(MoveId.CHILLY_RECEPTION); + game.move.forceMetronomeMove(MoveId.CHILLY_RECEPTION); await game.classicMode.startBattle([SpeciesId.SLOWKING, SpeciesId.MEOWTH]); const [slowking, meowth] = game.scene.getPlayerParty(); diff --git a/test/moves/copycat.test.ts b/test/moves/copycat.test.ts index 91e941e2845..3a24f4f89d6 100644 --- a/test/moves/copycat.test.ts +++ b/test/moves/copycat.test.ts @@ -2,9 +2,9 @@ import { AbilityId } from "#enums/ability-id"; import { BattlerIndex } from "#enums/battler-index"; import { MoveId } from "#enums/move-id"; import { MoveResult } from "#enums/move-result"; -import { MoveUseMode } from "#enums/move-use-mode"; import { SpeciesId } from "#enums/species-id"; import { Stat } from "#enums/stat"; +import { StatusEffect } from "#enums/status-effect"; import { GameManager } from "#test/test-utils/game-manager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; @@ -26,7 +26,6 @@ describe("Moves - Copycat", () => { beforeEach(() => { game = new GameManager(phaserGame); game.override - .moveset([MoveId.COPYCAT, MoveId.SPIKY_SHIELD, MoveId.SWORDS_DANCE, MoveId.SPLASH]) .ability(AbilityId.BALL_FETCH) .battleStyle("single") .criticalHits(false) @@ -35,58 +34,81 @@ describe("Moves - Copycat", () => { .enemyMoveset(MoveId.SPLASH); }); - it("should copy the last move successfully executed", async () => { - game.override.enemyMoveset(MoveId.SUCKER_PUNCH); + it("should copy the last move successfully executed by any Pokemon", async () => { await game.classicMode.startBattle([SpeciesId.FEEBAS]); - game.move.select(MoveId.SWORDS_DANCE); - await game.toNextTurn(); + game.move.use(MoveId.COPYCAT); + await game.move.forceEnemyMove(MoveId.SWORDS_DANCE); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.toEndOfTurn(); - game.move.select(MoveId.COPYCAT); // Last successful move should be Swords Dance - await game.toNextTurn(); - - expect(game.scene.getPlayerPokemon()!.getStatStage(Stat.ATK)).toBe(4); + const player = game.field.getPlayerPokemon(); + expect(player.getStatStage(Stat.ATK)).toBe(2); + expect(player.getLastXMoves()[0].move).toBe(MoveId.SWORDS_DANCE); }); - it("should fail when the last move used is not a valid Copycat move", async () => { - game.override.enemyMoveset(MoveId.PROTECT); // Protect is not a valid move for Copycat to copy + it('should update "last move" tracker for moves failing conditions, but not pre-move interrupts', async () => { + game.override.enemyStatusEffect(StatusEffect.SLEEP); await game.classicMode.startBattle([SpeciesId.FEEBAS]); - game.move.select(MoveId.SPIKY_SHIELD); // Spiky Shield is not a valid move for Copycat to copy - await game.toNextTurn(); + game.move.use(MoveId.SUCKER_PUNCH); + await game.move.forceEnemyMove(MoveId.SPLASH); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.phaseInterceptor.to("MoveEndPhase"); + + // Enemy is asleep and should not have updated tracker + expect(game.scene.currentBattle.lastMove).toBe(MoveId.NONE); + + await game.phaseInterceptor.to("MoveEndPhase"); + + // Player sucker punch failed conditions, but still updated tracker + expect(game.scene.currentBattle.lastMove).toBe(MoveId.SUCKER_PUNCH); + + const player = game.field.getPlayerPokemon(); + expect(player.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + }); + + it("should fail if no prior moves have been made", async () => { + await game.classicMode.startBattle([SpeciesId.FEEBAS]); game.move.select(MoveId.COPYCAT); + await game.move.forceEnemyMove(MoveId.SPLASH); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); await game.toNextTurn(); - expect(game.scene.getPlayerPokemon()!.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + expect(game.field.getPlayerPokemon().getLastXMoves()[0].result).toBe(MoveResult.FAIL); + }); + + it("should fail if the last move used is not a valid Copycat move", async () => { + await game.classicMode.startBattle([SpeciesId.FEEBAS]); + + game.move.use(MoveId.COPYCAT); + await game.move.forceEnemyMove(MoveId.PROTECT); + await game.toNextTurn(); + + expect(game.field.getPlayerPokemon().getLastXMoves()[0].result).toBe(MoveResult.FAIL); }); 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([SpeciesId.DRAMPA]); - game.move.forceMetronomeMove(MoveId.SWORDS_DANCE, true); + await game.classicMode.startBattle([SpeciesId.FEEBAS]); - game.move.select(MoveId.METRONOME); - await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); // Player moves first so enemy can copy Swords Dance + game.move.use(MoveId.METRONOME); + game.move.forceMetronomeMove(MoveId.SWORDS_DANCE, true); + await game.move.forceEnemyMove(MoveId.COPYCAT); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); // Player moves first, so enemy can copy Swords Dance await game.toNextTurn(); - const enemy = game.scene.getEnemyPokemon()!; - expect(enemy.getLastXMoves()[0]).toMatchObject({ - move: MoveId.SWORDS_DANCE, - result: MoveResult.SUCCESS, - useMode: MoveUseMode.FOLLOW_UP, - }); - expect(enemy.getStatStage(Stat.ATK)).toBe(2); + expect(game.field.getEnemyPokemon().getStatStage(Stat.ATK)).toBe(2); }); it("should apply move secondary effects", async () => { game.override.enemyMoveset(MoveId.ACID_SPRAY); // Secondary effect lowers SpDef by 2 stages await game.classicMode.startBattle([SpeciesId.FEEBAS]); - game.move.select(MoveId.COPYCAT); + game.move.use(MoveId.COPYCAT); await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.toNextTurn(); - expect(game.scene.getEnemyPokemon()!.getStatStage(Stat.SPDEF)).toBe(-2); + expect(game.field.getEnemyPokemon().getStatStage(Stat.SPDEF)).toBe(-2); }); }); diff --git a/test/moves/disable.test.ts b/test/moves/disable.test.ts index 5543c16fecf..f03f424417d 100644 --- a/test/moves/disable.test.ts +++ b/test/moves/disable.test.ts @@ -5,9 +5,8 @@ import { MoveResult } from "#enums/move-result"; import { MoveUseMode } from "#enums/move-use-mode"; import { SpeciesId } from "#enums/species-id"; import { Stat } from "#enums/stat"; -import { RandomMoveAttr } from "#moves/move"; import { GameManager } from "#test/test-utils/game-manager"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; describe("Moves - Disable", () => { let phaserGame: Phaser.Game; @@ -129,7 +128,7 @@ describe("Moves - Disable", () => { { name: "Copycat", moveId: MoveId.COPYCAT }, { name: "Metronome", moveId: MoveId.METRONOME }, ])("should ignore virtual moves called by $name", async ({ moveId }) => { - vi.spyOn(RandomMoveAttr.prototype, "getMoveOverride").mockReturnValue(MoveId.ABSORB); + game.move.forceMetronomeMove(MoveId.ABSORB); await game.classicMode.startBattle([SpeciesId.PIKACHU]); const playerMon = game.scene.getPlayerPokemon()!; diff --git a/test/moves/instruct.test.ts b/test/moves/instruct.test.ts index c34626f5e76..086676a1860 100644 --- a/test/moves/instruct.test.ts +++ b/test/moves/instruct.test.ts @@ -7,7 +7,6 @@ import { MoveUseMode } from "#enums/move-use-mode"; import { SpeciesId } from "#enums/species-id"; import { Stat } from "#enums/stat"; import type { Pokemon } from "#field/pokemon"; -import { RandomMoveAttr } from "#moves/move"; import type { MovePhase } from "#phases/move-phase"; import { GameManager } from "#test/test-utils/game-manager"; import type { TurnMove } from "#types/turn-move"; @@ -127,25 +126,24 @@ describe("Moves - Instruct", () => { expect(game.scene.getPlayerPokemon()!.turnData.attacksReceived.length).toBe(2); }); - it("should add moves to move queue for copycat", async () => { - game.override.battleStyle("double").moveset(MoveId.INSTRUCT).enemyLevel(5); + it("should be considered as the last move used for copycat", async () => { + game.override.battleStyle("double").enemyLevel(5); await game.classicMode.startBattle([SpeciesId.AMOONGUSS]); const [enemy1, enemy2] = game.scene.getEnemyField()!; game.move.changeMoveset(enemy1, MoveId.WATER_GUN); game.move.changeMoveset(enemy2, MoveId.COPYCAT); - game.move.select(MoveId.INSTRUCT, BattlerIndex.PLAYER, BattlerIndex.ENEMY); + game.move.use(MoveId.INSTRUCT, BattlerIndex.PLAYER, BattlerIndex.ENEMY); await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER, BattlerIndex.ENEMY_2]); await game.phaseInterceptor.to("BerryPhase"); instructSuccess(enemy1, MoveId.WATER_GUN); - // amoonguss gets hit by water gun thrice; once by original attack, once by instructed use and once by copycat - expect(game.scene.getPlayerPokemon()!.turnData.attacksReceived.length).toBe(3); + expect(enemy2.getLastXMoves()[0].move).toBe(MoveId.WATER_GUN); }); it("should fail on metronomed moves, even if also in moveset", async () => { - vi.spyOn(RandomMoveAttr.prototype, "getMoveOverride").mockReturnValue(MoveId.ABSORB); + game.move.forceMetronomeMove(MoveId.ABSORB); await game.classicMode.startBattle([SpeciesId.AMOONGUSS]); const enemy = game.field.getEnemyPokemon(); diff --git a/test/moves/metronome.test.ts b/test/moves/metronome.test.ts index e39d24c81db..6c74c213062 100644 --- a/test/moves/metronome.test.ts +++ b/test/moves/metronome.test.ts @@ -8,7 +8,6 @@ import { MoveResult } from "#enums/move-result"; import { MoveUseMode } from "#enums/move-use-mode"; import { SpeciesId } from "#enums/species-id"; import { Stat } from "#enums/stat"; -import type { RandomMoveAttr } from "#moves/move"; import { GameManager } from "#test/test-utils/game-manager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -17,8 +16,6 @@ describe("Moves - Metronome", () => { let phaserGame: Phaser.Game; let game: GameManager; - let randomMoveAttr: RandomMoveAttr; - beforeAll(() => { phaserGame = new Phaser.Game({ type: Phaser.HEADLESS, @@ -30,62 +27,102 @@ 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) .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 () => { + it("should not be able to copy MoveId.NONE", async () => { await game.classicMode.startBattle([SpeciesId.REGIELEKI]); - const player = game.scene.getPlayerPokemon()!; - const enemy = game.scene.getEnemyPokemon()!; - vi.spyOn(randomMoveAttr, "getMoveOverride").mockReturnValue(MoveId.DIVE); - game.move.select(MoveId.METRONOME); + // Pick the first move available to use + const player = game.field.getPlayerPokemon(); + vi.spyOn(player, "randBattleSeedInt").mockReturnValue(0); + game.move.use(MoveId.METRONOME); await game.toNextTurn(); - expect(player.getTag(SemiInvulnerableTag)).toBeTruthy(); - - await game.toNextTurn(); - expect(player.getTag(SemiInvulnerableTag)).toBeFalsy(); - expect(enemy.isFullHp()).toBeFalsy(); + const lastMoveStr = MoveId[player.getLastXMoves()[0].move]; + expect(lastMoveStr).not.toBe(MoveId[MoveId.NONE]); + expect(lastMoveStr).toBe(MoveId[1]); }); - it("should apply secondary effects of a move", async () => { + it("should become semi-invulnerable when using phasing moves", async () => { + game.move.forceMetronomeMove(MoveId.DIVE); await game.classicMode.startBattle([SpeciesId.REGIELEKI]); - const player = game.scene.getPlayerPokemon()!; - vi.spyOn(randomMoveAttr, "getMoveOverride").mockReturnValue(MoveId.WOOD_HAMMER); - game.move.select(MoveId.METRONOME); + const player = game.field.getPlayerPokemon(); + expect(player.getTag(SemiInvulnerableTag)).toBeUndefined(); + expect(player.visible).toBe(true); + + game.move.use(MoveId.METRONOME); await game.toNextTurn(); - expect(player.isFullHp()).toBeFalsy(); + expect(player.getTag(SemiInvulnerableTag)).toBeDefined(); + expect(player.visible).toBe(false); + + await game.toEndOfTurn(); + + expect(player.getTag(SemiInvulnerableTag)).toBeUndefined(); + expect(player.visible).toBe(true); + + const enemy = game.field.getEnemyPokemon(); + expect(enemy.hp).toBeLessThan(enemy.getMaxHp()); }); - it("should recharge after using recharge move", async () => { + it("should apply secondary effects of the called move", async () => { + game.move.forceMetronomeMove(MoveId.WOOD_HAMMER); await game.classicMode.startBattle([SpeciesId.REGIELEKI]); - const player = game.scene.getPlayerPokemon()!; - vi.spyOn(randomMoveAttr, "getMoveOverride").mockReturnValue(MoveId.HYPER_BEAM); + + game.move.use(MoveId.METRONOME); + await game.toNextTurn(); + + const player = game.field.getPlayerPokemon(); + const enemy = game.field.getEnemyPokemon(); + + expect(player.hp).toBeLessThan(player.getMaxHp()); + expect(enemy.hp).toBeLessThan(enemy.getMaxHp()); + }); + + it("should count as last move used for Copycat/Mirror Move", async () => { + game.move.forceMetronomeMove(MoveId.ABSORB, true); + await game.classicMode.startBattle([SpeciesId.REGIELEKI]); + + game.move.use(MoveId.METRONOME); + await game.move.forceEnemyMove(MoveId.MIRROR_MOVE); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.toNextTurn(); + + const player = game.field.getPlayerPokemon(); + const enemy = game.field.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 moves", async () => { + game.move.forceMetronomeMove(MoveId.HYPER_BEAM); + await game.classicMode.startBattle([SpeciesId.REGIELEKI]); + + const player = game.field.getPlayerPokemon(); 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 charge for charging moves while still maintaining follow-up status", async () => { - game.override.moveset([]).enemyMoveset(MoveId.SPITE); - vi.spyOn(randomMoveAttr, "getMoveOverride").mockReturnValue(MoveId.SOLAR_BEAM); + game.move.forceMetronomeMove(MoveId.SOLAR_BEAM); await game.classicMode.startBattle([SpeciesId.REGIELEKI]); + // We need to override movesets here const player = game.field.getPlayerPokemon(); game.move.changeMoveset(player, [MoveId.METRONOME, MoveId.SOLAR_BEAM]); @@ -93,19 +130,21 @@ describe("Moves - Metronome", () => { expect(metronomeMove).toBeDefined(); expect(solarBeamMove).toBeDefined(); - game.move.select(MoveId.METRONOME); + game.move.use(MoveId.METRONOME); + await game.move.forceEnemyMove(MoveId.SPITE); await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); - await game.phaseInterceptor.to("TurnEndPhase"); + await game.toEndOfTurn(); - expect(player.getTag(BattlerTagType.CHARGING)).toBeTruthy(); + // Solar beam charged up, but did not block Spite from reducing Metronome's PP + expect(player.getTag(BattlerTagType.CHARGING)).toBeDefined(); const turn1PpUsed = metronomeMove.ppUsed; expect.soft(turn1PpUsed).toBeGreaterThan(1); expect(solarBeamMove.ppUsed).toBe(0); - await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.toNextTurn(); - expect(player.getTag(BattlerTagType.CHARGING)).toBeFalsy(); + expect(player.getTag(BattlerTagType.CHARGING)).toBeUndefined(); const turn2PpUsed = metronomeMove.ppUsed - turn1PpUsed; expect(turn2PpUsed).toBeGreaterThan(1); expect(solarBeamMove.ppUsed).toBe(0); @@ -119,12 +158,13 @@ describe("Moves - Metronome", () => { 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); + game.move.forceMetronomeMove(MoveId.AROMATIC_MIST); - game.move.select(MoveId.METRONOME, 0); - game.move.select(MoveId.SPLASH, 1); + game.move.use(MoveId.METRONOME, BattlerIndex.PLAYER); + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2); await game.toNextTurn(); expect(rightPlayer.getStatStage(Stat.SPDEF)).toBe(1); @@ -133,18 +173,19 @@ describe("Moves - Metronome", () => { expect(rightOpp.getStatStage(Stat.SPDEF)).toBe(0); }); - it("should cause opponent to flee, and not crash for Roar", async () => { + it("should cause opponent to flee when using Roar", async () => { await game.classicMode.startBattle([SpeciesId.REGIELEKI]); - vi.spyOn(randomMoveAttr, "getMoveOverride").mockReturnValue(MoveId.ROAR); + game.move.forceMetronomeMove(MoveId.ROAR); - const enemyPokemon = game.scene.getEnemyPokemon()!; + const enemyPokemon = game.field.getEnemyPokemon(); - game.move.select(MoveId.METRONOME); - await game.phaseInterceptor.to("BerryPhase"); + game.move.use(MoveId.METRONOME); + await game.toEndOfTurn(); const isVisible = enemyPokemon.visible; const hasFled = enemyPokemon.switchOutStatus; - expect(!isVisible && hasFled).toBe(true); + expect(isVisible).toBe(false); + expect(hasFled).toBe(true); await game.toNextTurn(); }); diff --git a/test/moves/mirror-move.test.ts b/test/moves/mirror-move.test.ts index 0253932026b..cefe8fd71b6 100644 --- a/test/moves/mirror-move.test.ts +++ b/test/moves/mirror-move.test.ts @@ -2,6 +2,7 @@ import { AbilityId } from "#enums/ability-id"; import { BattlerIndex } from "#enums/battler-index"; import { MoveId } from "#enums/move-id"; import { MoveResult } from "#enums/move-result"; +import { MoveUseMode } from "#enums/move-use-mode"; import { SpeciesId } from "#enums/species-id"; import { Stat } from "#enums/stat"; import { GameManager } from "#test/test-utils/game-manager"; @@ -25,7 +26,6 @@ describe("Moves - Mirror Move", () => { beforeEach(() => { game = new GameManager(phaserGame); game.override - .moveset([MoveId.MIRROR_MOVE, MoveId.SPLASH]) .ability(AbilityId.BALL_FETCH) .battleStyle("single") .criticalHits(false) @@ -34,49 +34,52 @@ describe("Moves - Mirror Move", () => { .enemyMoveset(MoveId.SPLASH); }); - it("should use the last move that the target used on the user", async () => { - game.override.battleStyle("double").enemyMoveset([MoveId.TACKLE, MoveId.GROWL]); + it("should use the last move that the target used against it", async () => { + game.override.battleStyle("double"); await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.MAGIKARP]); - game.move.select(MoveId.MIRROR_MOVE, 0, BattlerIndex.ENEMY); // target's last move is Tackle, enemy should receive damage from Mirror Move copying Tackle - game.move.select(MoveId.SPLASH, 1); - await game.move.selectEnemyMove(MoveId.TACKLE, BattlerIndex.PLAYER_2); - await game.move.selectEnemyMove(MoveId.GROWL, BattlerIndex.PLAYER_2); + game.move.use(MoveId.MIRROR_MOVE, BattlerIndex.PLAYER, BattlerIndex.ENEMY); + game.move.use(MoveId.MIRROR_MOVE, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2); + await game.move.forceEnemyMove(MoveId.TACKLE, BattlerIndex.PLAYER_2); + await game.move.forceEnemyMove(MoveId.SWORDS_DANCE); await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER_2, BattlerIndex.PLAYER]); await game.toNextTurn(); - expect(game.scene.getEnemyField()[0].isFullHp()).toBeFalsy(); + // Feebas copied enemy tackle against it; + // player 2 copied enemy swords dance and used it on itself + const [feebas, magikarp] = game.scene.getPlayerField(); + expect(feebas.getLastXMoves()[0]).toMatchObject({ + move: MoveId.TACKLE, + targets: [BattlerIndex.ENEMY], + useMode: MoveUseMode.FOLLOW_UP, + }); + expect(game.field.getEnemyPokemon().isFullHp()).toBe(false); + expect(magikarp.getLastXMoves()[0]).toMatchObject({ + move: MoveId.SWORDS_DANCE, + targets: [BattlerIndex.PLAYER_2], + useMode: MoveUseMode.FOLLOW_UP, + }); + expect(magikarp.getStatStage(Stat.ATK)).toBe(2); }); - it("should apply secondary effects of a move", async () => { - game.override.enemyMoveset(MoveId.ACID_SPRAY); + it("should apply secondary effects of the called move", async () => { await game.classicMode.startBattle([SpeciesId.FEEBAS]); - game.move.select(MoveId.MIRROR_MOVE); + game.move.use(MoveId.MIRROR_MOVE); + await game.move.forceEnemyMove(MoveId.ACID_SPRAY); await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.toNextTurn(); - expect(game.scene.getEnemyPokemon()!.getStatStage(Stat.SPDEF)).toBe(-2); - }); - - it("should be able to copy status moves", async () => { - game.override.enemyMoveset(MoveId.GROWL); - await game.classicMode.startBattle([SpeciesId.FEEBAS]); - - game.move.select(MoveId.MIRROR_MOVE); - await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); - await game.toNextTurn(); - - expect(game.scene.getEnemyPokemon()!.getStatStage(Stat.ATK)).toBe(-1); + expect(game.field.getEnemyPokemon().getStatStage(Stat.SPDEF)).toBe(-2); }); it("should fail if the target has not used any moves", async () => { await game.classicMode.startBattle([SpeciesId.FEEBAS]); - game.move.select(MoveId.MIRROR_MOVE); + game.move.use(MoveId.MIRROR_MOVE); await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); await game.toNextTurn(); - expect(game.scene.getPlayerPokemon()!.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + expect(game.field.getPlayerPokemon().getLastXMoves()[0].result).toBe(MoveResult.FAIL); }); }); diff --git a/test/moves/nature-power.test.ts b/test/moves/nature-power.test.ts new file mode 100644 index 00000000000..e13bd0d9b57 --- /dev/null +++ b/test/moves/nature-power.test.ts @@ -0,0 +1,86 @@ +import { allMoves } from "#app/data/data-lists"; +import { TerrainType } from "#app/data/terrain"; +import { getPokemonNameWithAffix } from "#app/messages"; +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/test-utils/game-manager"; +import { getEnumValues } from "#utils/enums"; +import { toTitleCase } from "#utils/strings"; +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") + .criticalHits(false) + .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: toTitleCase(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: toTitleCase(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 fff9be97e2d..59e00434ee6 100644 --- a/test/moves/sketch.test.ts +++ b/test/moves/sketch.test.ts @@ -1,15 +1,13 @@ -import { allMoves } from "#data/data-lists"; import { AbilityId } from "#enums/ability-id"; import { BattlerIndex } from "#enums/battler-index"; import { MoveId } from "#enums/move-id"; import { MoveResult } from "#enums/move-result"; import { SpeciesId } from "#enums/species-id"; import { StatusEffect } from "#enums/status-effect"; -import { RandomMoveAttr } from "#moves/move"; import { PokemonMove } from "#moves/pokemon-move"; import { GameManager } from "#test/test-utils/game-manager"; import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; describe("Moves - Sketch", () => { let phaserGame: Phaser.Game; @@ -81,20 +79,18 @@ describe("Moves - Sketch", () => { }); it("should sketch moves that call other moves", async () => { - const randomMoveAttr = allMoves[MoveId.METRONOME].findAttr( - attr => attr instanceof RandomMoveAttr, - ) as RandomMoveAttr; - vi.spyOn(randomMoveAttr, "getMoveOverride").mockReturnValue(MoveId.FALSE_SWIPE); - - game.override.enemyMoveset([MoveId.METRONOME]); + game.move.forceMetronomeMove(MoveId.FALSE_SWIPE); await game.classicMode.startBattle([SpeciesId.REGIELEKI]); - const playerPokemon = game.scene.getPlayerPokemon()!; - playerPokemon.moveset = [new PokemonMove(MoveId.SKETCH)]; + + const playerPokemon = game.field.getPlayerPokemon()!; + game.move.changeMoveset(playerPokemon, MoveId.SKETCH); // Opponent uses Metronome -> False Swipe, then player uses Sketch, which should sketch Metronome game.move.select(MoveId.SKETCH); + await game.move.forceEnemyMove(MoveId.METRONOME); await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.phaseInterceptor.to("TurnEndPhase"); + expect(playerPokemon.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); expect(playerPokemon.moveset[0]?.moveId).toBe(MoveId.METRONOME); expect(playerPokemon.hp).toBeLessThan(playerPokemon.getMaxHp()); // Make sure opponent actually used False Swipe diff --git a/test/test-utils/helpers/move-helper.ts b/test/test-utils/helpers/move-helper.ts index 15605edf31d..fc2f883f85f 100644 --- a/test/test-utils/helpers/move-helper.ts +++ b/test/test-utils/helpers/move-helper.ts @@ -331,7 +331,7 @@ export class MoveHelper extends GameManagerHelper { * @returns The spy that for Metronome that was mocked (Usually unneeded). */ public forceMetronomeMove(move: MoveId, once = false): MockInstance { - const spy = vi.spyOn(allMoves[MoveId.METRONOME].getAttrs("RandomMoveAttr")[0], "getMoveOverride"); + const spy = vi.spyOn(allMoves[MoveId.METRONOME].getAttrs("RandomMoveAttr")[0], "getMove"); if (once) { spy.mockReturnValueOnce(move); } else {