From 3413d075e95c2311596aa57fa67404c49a6bcbd3 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Thu, 12 Jun 2025 17:22:24 -0400 Subject: [PATCH 01/18] 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]); From cc3c68d3b671c21bf205aae48eb5db1cdb6d259f Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Thu, 12 Jun 2025 17:43:07 -0400 Subject: [PATCH 02/18] Undid accidental class shuffling; fixed Sap Sipper tests --- src/data/moves/move.ts | 163 +++++++++++++++--------------- test/abilities/sap_sipper.test.ts | 158 +++++++++++------------------ 2 files changed, 144 insertions(+), 177 deletions(-) diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index a5efe4988dd..b122769967d 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -6788,6 +6788,89 @@ export abstract class CallMoveAttr extends OverrideMoveEffectAttr { } } +/** + * Attribute to call a random move among moves not in a banlist. + * Used for {@linkcode MoveId.METRONOME}. + */ +export class RandomMoveAttr 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, + ) { + super(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 party's 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 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); + } + + /** + * 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 (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 to call a different move based on the current terrain and biome. * Used by {@linkcode MoveId.NATURE_POWER} @@ -6935,88 +7018,8 @@ export class CopyMoveAttr extends CallMoveAttr { }; } } -/** - * Attribute to call a random move among moves not in a banlist. - * Used for {@linkcode MoveId.METRONOME}. - */ -export class RandomMoveAttr 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, - ) { - super(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 party's 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 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); - } - - /** - * 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 (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 causes the target to repeat their last used move. diff --git a/test/abilities/sap_sipper.test.ts b/test/abilities/sap_sipper.test.ts index 16559fb563f..0a2b54c63a7 100644 --- a/test/abilities/sap_sipper.test.ts +++ b/test/abilities/sap_sipper.test.ts @@ -1,7 +1,5 @@ import { Stat } from "#enums/stat"; import { TerrainType } from "#app/data/terrain"; -import { MoveEndPhase } from "#app/phases/move-end-phase"; -import { TurnEndPhase } from "#app/phases/turn-end-phase"; import { AbilityId } from "#enums/ability-id"; import { BattlerTagType } from "#enums/battler-tag-type"; import { MoveId } from "#enums/move-id"; @@ -10,7 +8,6 @@ import GameManager from "#test/testUtils/gameManager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { RandomMoveAttr } from "#app/data/moves/move"; -import { allMoves } from "#app/data/data-lists"; // See also: TypeImmunityAbAttr describe("Abilities - Sap Sipper", () => { @@ -38,131 +35,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 () => { + vi.spyOn(RandomMoveAttr.prototype, "getMove").mockReturnValue(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.toNextTurn(); + 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(1); + 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); }); }); From 470e7adce0dbb2180cd990335c4765e8b9906816 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Thu, 12 Jun 2025 17:49:33 -0400 Subject: [PATCH 03/18] Fixed test --- test/abilities/sap_sipper.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/abilities/sap_sipper.test.ts b/test/abilities/sap_sipper.test.ts index 0a2b54c63a7..a74c99a0b42 100644 --- a/test/abilities/sap_sipper.test.ts +++ b/test/abilities/sap_sipper.test.ts @@ -89,7 +89,7 @@ describe("Abilities - Sap Sipper", () => { const enemy = game.field.getEnemyPokemon(); game.move.use(MoveId.BULLET_SEED); - await game.toNextTurn(); + await game.toEndOfTurn(); expect(enemy.hp).toBe(enemy.getMaxHp()); expect(enemy.getStatStage(Stat.ATK)).toBe(1); @@ -99,7 +99,7 @@ describe("Abilities - Sap Sipper", () => { await game.toEndOfTurn(); expect(enemy.hp).toBe(enemy.getMaxHp()); - expect(enemy.getStatStage(Stat.ATK)).toBe(1); + expect(enemy.getStatStage(Stat.ATK)).toBe(2); expect(player.turnData.hitCount).toBe(1); }); From fa7ae9c3882074c23947e6a47ed7482cde8f23e8 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Thu, 12 Jun 2025 18:59:55 -0400 Subject: [PATCH 04/18] Fixed all the bug --- src/data/moves/move.ts | 7 +++--- src/phases/move-phase.ts | 10 ++++----- test/moves/copycat.test.ts | 45 +++++++++++++++++++++++--------------- 3 files changed, 34 insertions(+), 28 deletions(-) diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index b122769967d..4c8d3e82eae 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -6781,7 +6781,7 @@ export abstract class CallMoveAttr extends OverrideMoveEffectAttr { const targets = moveTargets.multiple || moveTargets.targets.length === 1 ? moveTargets.targets : [ 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; @@ -6883,7 +6883,6 @@ export class NaturePowerAttr extends CallMoveAttr { 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, @@ -7007,8 +7006,8 @@ export class CopyMoveAttr extends CallMoveAttr { override getMove(_user: Pokemon, target: Pokemon): MoveId { return this.selfTarget - ? target.getLastXMoves()[0]?.move ?? MoveId.NONE - : globalScene.currentBattle.lastMove; + ? globalScene.currentBattle.lastMove + : target.getLastXMoves()[0]?.move ?? MoveId.NONE } getCondition(): MoveConditionFunc { diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index d72c7396f1f..654992a103f 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -370,12 +370,10 @@ export class MovePhase extends BattlePhase { success = passesConditions && !failedDueToWeather && !failedDueToTerrain; } - // Update the battle's "last move" pointer, unless we're currently mimicking a move. - if (!allMoves[this.move.moveId].hasAttr("CopyMoveAttr")) { - // The last move used is unaffected by moves that fail - if (success) { - globalScene.currentBattle.lastMove = this.move.moveId; - } + // Update the battle's "last move" pointer, unless we're currently mimicking a move + // or the move failed. + if (!allMoves[this.move.moveId].hasAttr("CallMoveAttr") && success) { + globalScene.currentBattle.lastMove = this.move.moveId; } /** diff --git a/test/moves/copycat.test.ts b/test/moves/copycat.test.ts index f55c072db35..dae7587b89e 100644 --- a/test/moves/copycat.test.ts +++ b/test/moves/copycat.test.ts @@ -34,58 +34,67 @@ describe("Moves - Copycat", () => { .ability(AbilityId.BALL_FETCH) .battleStyle("single") .disableCrits() - .starterSpecies(SpeciesId.FEEBAS) .enemySpecies(SpeciesId.MAGIKARP) .enemyAbility(AbilityId.BALL_FETCH) .enemyMoveset(MoveId.SPLASH); }); it("should copy the last move successfully executed", async () => { - game.override.enemyMoveset(MoveId.SUCKER_PUNCH); - await game.classicMode.startBattle(); + await game.classicMode.startBattle([SpeciesId.FEEBAS]); game.move.select(MoveId.SWORDS_DANCE); await game.toNextTurn(); game.move.select(MoveId.COPYCAT); // Last successful move should be Swords Dance + await game.move.forceEnemyMove(MoveId.SUCKER_PUNCH); await game.toNextTurn(); - expect(game.scene.getPlayerPokemon()!.getStatStage(Stat.ATK)).toBe(4); + const player = game.field.getPlayerPokemon(); + expect(player.getStatStage(Stat.ATK)).toBe(4); + 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 - await game.classicMode.startBattle(); - - game.move.select(MoveId.SPIKY_SHIELD); // Spiky Shield is not a valid move for Copycat to copy - await game.toNextTurn(); + 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(); vi.spyOn(randomMoveAttr, "getMove").mockReturnValue(MoveId.SWORDS_DANCE); + await game.classicMode.startBattle([SpeciesId.FEEBAS]); - game.move.select(MoveId.METRONOME); + game.move.use(MoveId.METRONOME); + 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(); - expect(game.scene.getEnemyPokemon()!.getStatStage(Stat.ATK)).toBe(2); + expect(game.field.getEnemyPokemon().getStatStage(Stat.ATK)).toBe(2); }); it("should apply secondary effects of a move", async () => { game.override.enemyMoveset(MoveId.ACID_SPRAY); // Secondary effect lowers SpDef by 2 stages - await game.classicMode.startBattle(); + 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); }); }); From 646b5d8048f0cf715fd36cbc12abe4714a71b53f Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Tue, 17 Jun 2025 18:34:22 -0400 Subject: [PATCH 05/18] added ts compiler error for missing nature power biome --- src/data/moves/move.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 4c8d3e82eae..785e08ef821 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -6981,7 +6981,8 @@ export class NaturePowerAttr extends CallMoveAttr { 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.`) + biome satisfies never; + console.warn(`NaturePowerAttr lacks defined move to use for current biome ${toReadableString(BiomeId[biome])}; consider adding an appropriate move to the attribute's selection table.`) return MoveId.TRI_ATTACK; } } From 69d329a4601c6c742e0b91a90a988e4fb0a60da9 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Tue, 17 Jun 2025 19:04:45 -0400 Subject: [PATCH 06/18] Fixed tests --- test/moves/assist.test.ts | 10 +++++----- test/moves/copycat.test.ts | 4 +++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/test/moves/assist.test.ts b/test/moves/assist.test.ts index 294ba0434e9..17f3da3d2f8 100644 --- a/test/moves/assist.test.ts +++ b/test/moves/assist.test.ts @@ -45,7 +45,7 @@ describe("Moves - Assist", () => { 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]); - // Force rolling the first eligible move for both mons (ensuring the user's own moves don't count) + // Force rolling the first eligible move for both mons vi.spyOn(feebas, "randBattleSeedInt").mockImplementation(() => 0); vi.spyOn(shuckle, "randBattleSeedInt").mockImplementation(() => 0); @@ -58,8 +58,8 @@ describe("Moves - Assist", () => { 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]); + 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 consider off-field allies", async () => { @@ -71,7 +71,7 @@ describe("Moves - Assist", () => { game.move.use(MoveId.ASSIST); await game.toEndOfTurn(); - expect(feebas.getLastXMoves(-1)).toHaveLength(1); + expect(feebas.getLastXMoves(-1)).toHaveLength(2); expect(feebas.getLastXMoves()[0]).toMatchObject({ move: MoveId.HYPER_BEAM, target: [BattlerIndex.ENEMY], @@ -98,7 +98,7 @@ describe("Moves - Assist", () => { 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.changeMoveset(shuckle, [MoveId.METRONOME, MoveId.DIG, MoveId.FLY]); game.move.use(MoveId.ASSIST); await game.toEndOfTurn(); diff --git a/test/moves/copycat.test.ts b/test/moves/copycat.test.ts index 41e1f2812bc..35b02c9122f 100644 --- a/test/moves/copycat.test.ts +++ b/test/moves/copycat.test.ts @@ -3,7 +3,6 @@ import { RandomMoveAttr } from "#app/data/moves/move"; import { Stat } from "#app/enums/stat"; import { MoveResult } from "#enums/move-result"; import { AbilityId } from "#enums/ability-id"; -import { MoveUseMode } from "#enums/move-use-mode"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; import GameManager from "#test/testUtils/gameManager"; @@ -40,10 +39,13 @@ describe("Moves - Copycat", () => { await game.classicMode.startBattle([SpeciesId.FEEBAS]); game.move.select(MoveId.SWORDS_DANCE); + await game.move.forceEnemyMove(MoveId.SPLASH); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.toNextTurn(); game.move.select(MoveId.COPYCAT); // Last successful move should be Swords Dance await game.move.forceEnemyMove(MoveId.SUCKER_PUNCH); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.toNextTurn(); const player = game.field.getPlayerPokemon(); From c021f3206e99d3e32399bb4312fb59052ad2f808 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Tue, 17 Jun 2025 19:17:39 -0400 Subject: [PATCH 07/18] Fixed the test and such --- src/battle.ts | 4 ++-- test/abilities/gorilla_tactics.test.ts | 2 +- test/moves/disable.test.ts | 2 +- test/moves/instruct.test.ts | 2 +- test/moves/metronome.test.ts | 3 +-- 5 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/battle.ts b/src/battle.ts index 245705f4801..d29c73651e3 100644 --- a/src/battle.ts +++ b/src/battle.ts @@ -21,7 +21,7 @@ import type { EnemyPokemon, PlayerPokemon, TurnMove } from "#app/field/pokemon"; import type Pokemon from "#app/field/pokemon"; import { ArenaTagType } from "#enums/arena-tag-type"; import { BattleSpec } from "#enums/battle-spec"; -import type { MoveId } from "#enums/move-id"; +import { MoveId } from "#enums/move-id"; import { PlayerGender } from "#enums/player-gender"; import { MusicPreference } from "#app/system/settings/settings"; import { SpeciesId } from "#enums/species-id"; @@ -73,7 +73,7 @@ export default class Battle { public battleScore = 0; public postBattleLoot: PokemonHeldItemModifier[] = []; public escapeAttempts = 0; - public lastMove: MoveId; + public lastMove: MoveId = MoveId.NONE; public battleSeed: string = randomString(16, true); private battleSeedState: string | null = null; public moneyScattered = 0; diff --git a/test/abilities/gorilla_tactics.test.ts b/test/abilities/gorilla_tactics.test.ts index a8b09461ea0..ac7df8c8825 100644 --- a/test/abilities/gorilla_tactics.test.ts +++ b/test/abilities/gorilla_tactics.test.ts @@ -83,7 +83,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); + vi.spyOn(RandomMoveAttr.prototype, "getMove").mockReturnValue(MoveId.TACKLE); await game.classicMode.startBattle([SpeciesId.GALAR_DARMANITAN]); const darmanitan = game.scene.getPlayerPokemon()!; diff --git a/test/moves/disable.test.ts b/test/moves/disable.test.ts index b113acb9525..99395110cee 100644 --- a/test/moves/disable.test.ts +++ b/test/moves/disable.test.ts @@ -129,7 +129,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); + vi.spyOn(RandomMoveAttr.prototype, "getMove").mockReturnValue(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 d12859301b6..f62ef72097f 100644 --- a/test/moves/instruct.test.ts +++ b/test/moves/instruct.test.ts @@ -144,7 +144,7 @@ describe("Moves - Instruct", () => { }); it("should fail on metronomed moves, even if also in moveset", async () => { - vi.spyOn(RandomMoveAttr.prototype, "getMoveOverride").mockReturnValue(MoveId.ABSORB); + vi.spyOn(RandomMoveAttr.prototype, "getMove").mockReturnValue(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 4f274ceae99..15eb41b0df4 100644 --- a/test/moves/metronome.test.ts +++ b/test/moves/metronome.test.ts @@ -4,7 +4,6 @@ import { allMoves } from "#app/data/data-lists"; import { AbilityId } from "#enums/ability-id"; import { Stat } from "#app/enums/stat"; import { MoveResult } from "#enums/move-result"; -import { CommandPhase } from "#app/phases/command-phase"; import { BattlerTagType } from "#enums/battler-tag-type"; import { MoveUseMode } from "#enums/move-use-mode"; import { MoveId } from "#enums/move-id"; @@ -122,7 +121,7 @@ describe("Moves - Metronome", () => { 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); + vi.spyOn(randomMoveAttr, "getMove").mockReturnValue(MoveId.SOLAR_BEAM); await game.classicMode.startBattle([SpeciesId.REGIELEKI]); const player = game.field.getPlayerPokemon(); From 8348e400abc9d97d8b1b886bbea967bbd073723e Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Tue, 24 Jun 2025 19:18:20 -0400 Subject: [PATCH 08/18] Fixed syntactical issues --- test/moves/chilly_reception.test.ts | 2 +- test/moves/nature-power.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/moves/chilly_reception.test.ts b/test/moves/chilly_reception.test.ts index 4d15bfff284..8c54a4df85a 100644 --- a/test/moves/chilly_reception.test.ts +++ b/test/moves/chilly_reception.test.ts @@ -116,7 +116,7 @@ describe("Moves - Chilly Reception", () => { }); it("should succeed without message if called indirectly", async () => { - vi.spyOn(RandomMoveAttr.prototype, "getMoveOverride").mockReturnValue(MoveId.CHILLY_RECEPTION); + vi.spyOn(RandomMoveAttr.prototype, "getMove").mockReturnValue(MoveId.CHILLY_RECEPTION); await game.classicMode.startBattle([SpeciesId.SLOWKING, SpeciesId.MEOWTH]); const [slowking, meowth] = game.scene.getPlayerParty(); diff --git a/test/moves/nature-power.test.ts b/test/moves/nature-power.test.ts index 729e7fe991b..e0a70a2dc10 100644 --- a/test/moves/nature-power.test.ts +++ b/test/moves/nature-power.test.ts @@ -30,7 +30,7 @@ describe("Move - Nature Power", () => { game.override .ability(AbilityId.BALL_FETCH) .battleStyle("single") - .disableCrits() + .criticalHits(false) .enemySpecies(SpeciesId.MAGIKARP) .enemyAbility(AbilityId.NO_GUARD) .enemyMoveset(MoveId.SPLASH) From 999801160d3f5e80aa55a255e2e9fca781c3a062 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Tue, 24 Jun 2025 19:25:38 -0400 Subject: [PATCH 09/18] Fixed oopsie with mirror move/copycat --- src/data/moves/move.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index a34b7222c34..41d954d2d14 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -7031,7 +7031,7 @@ export class NaturePowerAttr extends CallMoveAttr { } /** - * Attribute used to copy a previously-used move. + * Attribute used to copy the last move executed. * Used for {@linkcode MoveId.COPYCAT} and {@linkcode MoveId.MIRROR_MOVE}. */ export class CopyMoveAttr extends CallMoveAttr { @@ -7047,10 +7047,14 @@ export class CopyMoveAttr extends CallMoveAttr { super(selfTarget); } + /** + * If `selfTarget` is `true`, grab the last successful move used by anyone. + * Otherwise, select the last move used by the target. + */ override getMove(_user: Pokemon, target: Pokemon): MoveId { return this.selfTarget - ? globalScene.currentBattle.lastMove - : target.getLastXMoves()[0]?.move ?? MoveId.NONE + ? target.getLastXMoves()[0]?.move ?? MoveId.NONE + : globalScene.currentBattle.lastMove } getCondition(): MoveConditionFunc { From a7676fd33c89e9c184bd602c42e267c493bb7cab Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Tue, 24 Jun 2025 19:35:22 -0400 Subject: [PATCH 10/18] mauybe fixed tests --- test/moves/instruct.test.ts | 9 ++++----- test/moves/metronome.test.ts | 17 ++++++++--------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/test/moves/instruct.test.ts b/test/moves/instruct.test.ts index f62ef72097f..4b97536f580 100644 --- a/test/moves/instruct.test.ts +++ b/test/moves/instruct.test.ts @@ -126,21 +126,20 @@ 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 () => { diff --git a/test/moves/metronome.test.ts b/test/moves/metronome.test.ts index 15eb41b0df4..0695ff88407 100644 --- a/test/moves/metronome.test.ts +++ b/test/moves/metronome.test.ts @@ -11,13 +11,12 @@ import { SpeciesId } from "#enums/species-id"; import GameManager from "#test/testUtils/gameManager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, it, expect, vi } from "vitest"; +import { RandomMoveAttr } from "#app/data/moves/move"; describe("Moves - Metronome", () => { let phaserGame: Phaser.Game; let game: GameManager; - const randomMoveAttr = allMoves[MoveId.METRONOME].getAttrs("RandomMoveAttr")[0]; - beforeAll(() => { phaserGame = new Phaser.Game({ type: Phaser.HEADLESS, @@ -55,7 +54,7 @@ describe("Moves - Metronome", () => { }); it("should become semi-invulnerable when using phasing moves", async () => { - vi.spyOn(randomMoveAttr, "getMove").mockReturnValue(MoveId.DIVE); + vi.spyOn(RandomMoveAttr.prototype, "getMove").mockReturnValue(MoveId.DIVE); await game.classicMode.startBattle([SpeciesId.REGIELEKI]); const player = game.field.getPlayerPokemon(); @@ -77,7 +76,7 @@ describe("Moves - Metronome", () => { }); it("should apply secondary effects of the called move", async () => { - vi.spyOn(randomMoveAttr, "getMove").mockReturnValue(MoveId.WOOD_HAMMER); + vi.spyOn(RandomMoveAttr.prototype, "getMove").mockReturnValue(MoveId.WOOD_HAMMER); await game.classicMode.startBattle([SpeciesId.REGIELEKI]); game.move.select(MoveId.METRONOME); @@ -91,7 +90,7 @@ describe("Moves - Metronome", () => { }); it("should count as last move used for Copycat/Mirror Move", async () => { - vi.spyOn(randomMoveAttr, "getMove").mockReturnValue(MoveId.ABSORB); + vi.spyOn(RandomMoveAttr.prototype, "getMove").mockReturnValue(MoveId.ABSORB); await game.classicMode.startBattle([SpeciesId.REGIELEKI]); game.move.select(MoveId.METRONOME); @@ -110,7 +109,7 @@ describe("Moves - Metronome", () => { it("should recharge after using recharge moves", async () => { await game.classicMode.startBattle([SpeciesId.REGIELEKI]); const player = game.field.getPlayerPokemon(); - vi.spyOn(randomMoveAttr, "getMove").mockReturnValue(MoveId.HYPER_BEAM); + vi.spyOn(RandomMoveAttr.prototype, "getMove").mockReturnValue(MoveId.HYPER_BEAM); vi.spyOn(allMoves[MoveId.HYPER_BEAM], "accuracy", "get").mockReturnValue(100); game.move.select(MoveId.METRONOME); @@ -121,7 +120,7 @@ describe("Moves - Metronome", () => { it("should charge for charging moves while still maintaining follow-up status", async () => { game.override.moveset([]).enemyMoveset(MoveId.SPITE); - vi.spyOn(randomMoveAttr, "getMove").mockReturnValue(MoveId.SOLAR_BEAM); + vi.spyOn(RandomMoveAttr.prototype, "getMove").mockReturnValue(MoveId.SOLAR_BEAM); await game.classicMode.startBattle([SpeciesId.REGIELEKI]); const player = game.field.getPlayerPokemon(); @@ -160,7 +159,7 @@ describe("Moves - Metronome", () => { const [leftPlayer, rightPlayer] = game.scene.getPlayerField(); const [leftOpp, rightOpp] = game.scene.getEnemyField(); - vi.spyOn(randomMoveAttr, "getMove").mockReturnValue(MoveId.AROMATIC_MIST); + vi.spyOn(RandomMoveAttr.prototype, "getMove").mockReturnValue(MoveId.AROMATIC_MIST); game.move.select(MoveId.METRONOME, 0); game.move.select(MoveId.SPLASH, 1); @@ -174,7 +173,7 @@ describe("Moves - Metronome", () => { it("should cause opponent to flee when using Roar", async () => { await game.classicMode.startBattle([SpeciesId.REGIELEKI]); - vi.spyOn(randomMoveAttr, "getMove").mockReturnValue(MoveId.ROAR); + vi.spyOn(RandomMoveAttr.prototype, "getMove").mockReturnValue(MoveId.ROAR); const enemyPokemon = game.field.getEnemyPokemon(); From c93421542692440dd132586de14b2e1d69feee98 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Tue, 24 Jun 2025 20:49:52 -0400 Subject: [PATCH 11/18] hhhhh --- src/data/moves/move.ts | 4 ++-- test/moves/assist.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 41d954d2d14..13955b94dd6 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -7053,8 +7053,8 @@ export class CopyMoveAttr extends CallMoveAttr { */ override getMove(_user: Pokemon, target: Pokemon): MoveId { return this.selfTarget - ? target.getLastXMoves()[0]?.move ?? MoveId.NONE - : globalScene.currentBattle.lastMove + ? globalScene.currentBattle.lastMove + : target.getLastXMoves()[0]?.move ?? MoveId.NONE } getCondition(): MoveConditionFunc { diff --git a/test/moves/assist.test.ts b/test/moves/assist.test.ts index 17f3da3d2f8..98fe1ee6f60 100644 --- a/test/moves/assist.test.ts +++ b/test/moves/assist.test.ts @@ -28,7 +28,7 @@ describe("Moves - Assist", () => { // 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) + .ability(AbilityId.NO_GUARD) .battleStyle("single") .criticalHits(false) .enemySpecies(SpeciesId.MAGIKARP) From 10e42905f784741b26b4bc30ea09dcb611697e80 Mon Sep 17 00:00:00 2001 From: Bertie690 <136088738+Bertie690@users.noreply.github.com> Date: Sun, 6 Jul 2025 13:14:54 +0200 Subject: [PATCH 12/18] Update assist.test.ts --- test/moves/assist.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/moves/assist.test.ts b/test/moves/assist.test.ts index 98fe1ee6f60..33d94013914 100644 --- a/test/moves/assist.test.ts +++ b/test/moves/assist.test.ts @@ -74,8 +74,7 @@ describe("Moves - Assist", () => { expect(feebas.getLastXMoves(-1)).toHaveLength(2); expect(feebas.getLastXMoves()[0]).toMatchObject({ move: MoveId.HYPER_BEAM, - target: [BattlerIndex.ENEMY], - virtual: true, + useMode: MoveUseMode.INDIRECT, result: MoveResult.SUCCESS, }); }); From a140e075cd8edbd7836154a06573c93eb6674c37 Mon Sep 17 00:00:00 2001 From: Bertie690 <136088738+Bertie690@users.noreply.github.com> Date: Sun, 6 Jul 2025 13:17:08 +0200 Subject: [PATCH 13/18] Revert move-phase.ts --- src/phases/move-phase.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 7049a49e6ee..092a8c65dd4 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -11,7 +11,7 @@ import { MoveFlags } from "#enums/MoveFlags"; import { SpeciesFormChangePreMoveTrigger } from "#app/data/pokemon-forms/form-change-triggers"; import { getStatusEffectActivationText, getStatusEffectHealText } from "#app/data/status-effect"; import { PokemonType } from "#enums/pokemon-type"; -import { getTerrainBlockMessage, getWeatherBlockMessage } from "#app/data/weather"; +import { getWeatherBlockMessage } from "#app/data/weather"; import { MoveUsedEvent } from "#app/events/battle-scene"; import type { PokemonMove } from "#app/data/moves/pokemon-move"; import type Pokemon from "#app/field/pokemon"; @@ -26,6 +26,7 @@ import { BattlerTagType } from "#enums/battler-tag-type"; import { MoveId } from "#enums/move-id"; import { StatusEffect } from "#enums/status-effect"; import i18next from "i18next"; +import { getTerrainBlockMessage } from "#app/data/terrain"; import { isVirtual, isIgnorePP, isReflected, MoveUseMode, isIgnoreStatus } from "#enums/move-use-mode"; import { frenzyMissFunc } from "#app/data/moves/move-utils"; @@ -377,10 +378,12 @@ export class MovePhase extends BattlePhase { success = passesConditions && !failedDueToWeather && !failedDueToTerrain; } - // Update the battle's "last move" pointer, unless we're currently mimicking a move - // or the move failed. - if (!allMoves[this.move.moveId].hasAttr("CallMoveAttr") && success) { - globalScene.currentBattle.lastMove = this.move.moveId; + // Update the battle's "last move" pointer, unless we're currently mimicking a move. + if (!allMoves[this.move.moveId].hasAttr("CopyMoveAttr")) { + // The last move used is unaffected by moves that fail + if (success) { + globalScene.currentBattle.lastMove = this.move.moveId; + } } /** @@ -686,4 +689,4 @@ export class MovePhase extends BattlePhase { public showFailedText(failedText: string = i18next.t("battle:attackFailed")): void { globalScene.phaseManager.queueMessage(failedText); } -} +} \ No newline at end of file From c56ed1d56467f90ded5b763a1a761e4596515194 Mon Sep 17 00:00:00 2001 From: Bertie690 <136088738+Bertie690@users.noreply.github.com> Date: Sun, 6 Jul 2025 13:18:38 +0200 Subject: [PATCH 14/18] Update assist.test.ts import --- test/moves/assist.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/moves/assist.test.ts b/test/moves/assist.test.ts index 33d94013914..ba602bad281 100644 --- a/test/moves/assist.test.ts +++ b/test/moves/assist.test.ts @@ -4,6 +4,7 @@ import { MoveResult } from "#enums/move-result"; import { AbilityId } from "#enums/ability-id"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; +import { MoveUseMode } from "#enums/move-use-mode"; import GameManager from "#test/testUtils/gameManager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; From adf892bb4e98b4c10bac1ec90b7d01e75c04de17 Mon Sep 17 00:00:00 2001 From: Bertie690 <136088738+Bertie690@users.noreply.github.com> Date: Mon, 14 Jul 2025 08:35:33 +0200 Subject: [PATCH 15/18] Update mirror_move.test.ts --- test/moves/mirror_move.test.ts | 40 +++++++++++++--------------------- 1 file changed, 15 insertions(+), 25 deletions(-) diff --git a/test/moves/mirror_move.test.ts b/test/moves/mirror_move.test.ts index 03de93c56dc..53b84c1e25c 100644 --- a/test/moves/mirror_move.test.ts +++ b/test/moves/mirror_move.test.ts @@ -25,7 +25,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 +33,40 @@ 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 any pokemon", 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.SWORDS_DANCE, 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(); + const [feebas, magikarp] = game.scene.getPlayerField(); + expect(feebas.getLastXMoves()[0].move).toBe(MoveId.TACKLE); + expect(magikarp.getLastXMoves()[0].move).toBe(MoveId.SWORDS_DANCE); }); - 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); }); }); From 9b6d4ce5944ca80759f5d9f6a6348f9287b45f65 Mon Sep 17 00:00:00 2001 From: Bertie690 <136088738+Bertie690@users.noreply.github.com> Date: Mon, 14 Jul 2025 11:28:54 +0200 Subject: [PATCH 16/18] Update mirror_move.test.ts --- test/moves/mirror_move.test.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/test/moves/mirror_move.test.ts b/test/moves/mirror_move.test.ts index 53b84c1e25c..df502b49709 100644 --- a/test/moves/mirror_move.test.ts +++ b/test/moves/mirror_move.test.ts @@ -1,5 +1,6 @@ import { BattlerIndex } from "#enums/battler-index"; -import { Stat } from "#app/enums/stat"; +import { Stat } from "#enums/stat"; +import { MoveUseMode } from "#enums/move-use-mode"; import { MoveResult } from "#enums/move-result"; import { AbilityId } from "#enums/ability-id"; import { MoveId } from "#enums/move-id"; @@ -33,7 +34,7 @@ describe("Moves - Mirror Move", () => { .enemyMoveset(MoveId.SPLASH); }); - it("should use the last move that the target used against any pokemon", async () => { + 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]); @@ -44,9 +45,20 @@ describe("Moves - Mirror Move", () => { await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER_2, BattlerIndex.PLAYER]); await game.toNextTurn(); + // Feebas copied enemy tackle against it const [feebas, magikarp] = game.scene.getPlayerField(); - expect(feebas.getLastXMoves()[0].move).toBe(MoveId.TACKLE); - expect(magikarp.getLastXMoves()[0].move).toBe(MoveId.SWORDS_DANCE); + 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 the called move", async () => { From 7ceb6019795520c98babc8b9c5ea7c2c506de4bb Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Mon, 28 Jul 2025 13:37:32 -0400 Subject: [PATCH 17/18] Did some fixes and some cleanups --- src/battle.ts | 4 + src/data/battler-tags.ts | 1 + src/data/moves/move.ts | 201 +++++++++++++------------ src/phases/move-end-phase.ts | 19 ++- src/phases/move-phase.ts | 21 ++- test/moves/copycat.test.ts | 39 +++-- test/moves/metronome.test.ts | 5 +- test/moves/mirror-move.test.ts | 5 +- test/moves/nature-power.test.ts | 11 +- test/moves/sketch.test.ts | 18 +-- test/test-utils/helpers/move-helper.ts | 2 +- 11 files changed, 185 insertions(+), 141 deletions(-) diff --git a/src/battle.ts b/src/battle.ts index 0a6147aa064..00f3b04f2ab 100644 --- a/src/battle.ts +++ b/src/battle.ts @@ -70,6 +70,10 @@ export class Battle { public battleScore = 0; public postBattleLoot: PokemonHeldItemModifier[] = []; public escapeAttempts = 0; + /** + * 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; diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index c8ddfe32f0b..0b12dc0fbbd 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -1129,6 +1129,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 7b7396cfdee..bba58d7fdde 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -6795,7 +6795,7 @@ export abstract class CallMoveAttr extends OverrideMoveEffectAttr { /** * 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. + * as opposed to the move's selected target. */ override selfTarget = true, ) { @@ -6806,7 +6806,7 @@ export abstract class CallMoveAttr extends OverrideMoveEffectAttr { * 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. + * @returns The {@linkcode MoveId} that will be called and used. */ protected abstract getMove(user: Pokemon, target: Pokemon): MoveId; @@ -6830,92 +6830,10 @@ export abstract class CallMoveAttr extends OverrideMoveEffectAttr { } } -/** - * Attribute to call a random move among moves not in a banlist. - * Used for {@linkcode MoveId.METRONOME}. - */ -export class RandomMoveAttr extends CallMoveAttr { - constructor( - /** - * A {@linkcode ReadonlySet} containing all moves that this {@linkcode MoveAttr} cannot copy, - * in addition to unimplemented moves and {@linkcode MoveId.NONE}. - * The move will fail if the chosen move is inside this banlist (if it exists). - */ - protected readonly invalidMoves: ReadonlySet, - ) { - super(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 party's 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. - * Reset to {@linkcode 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); - } - - /** - * 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 (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 to call a different move based on the current terrain and biome. - * Used by {@linkcode MoveId.NATURE_POWER} + * Used by {@linkcode MoveId.NATURE_POWER}. */ export class NaturePowerAttr extends CallMoveAttr { constructor() { @@ -6924,7 +6842,6 @@ export class NaturePowerAttr extends CallMoveAttr { 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.queueMessage(i18next.t("moveTriggers:naturePowerUse", { pokemonName: getPokemonNameWithAffix(user), moveName: allMoves[moveId].name, @@ -7024,34 +6941,40 @@ export class NaturePowerAttr extends CallMoveAttr { default: // Fallback for no match biome satisfies never; - console.warn(`NaturePowerAttr lacks defined move to use for current biome ${toReadableString(BiomeId[biome])}; consider adding an appropriate move to the attribute's selection table.`) + 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 the last move executed. - * Used for {@linkcode MoveId.COPYCAT} and {@linkcode MoveId.MIRROR_MOVE}. + * Abstract class to encompass move-copying-moves with a banlist of invalid moves. */ -export class CopyMoveAttr extends CallMoveAttr { +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( - /** - * A {@linkcode ReadonlySet} containing all moves that this {@linkcode MoveAttr} cannot copy, - * in addition to unimplemented moves and {@linkcode MoveId.NONE}. - * The move will fail if the chosen move is inside this banlist (if it exists). - */ - protected readonly invalidMoves: ReadonlySet, + invalidMoves: ReadonlySet, selfTarget = true, ) { super(selfTarget); + this.invalidMoves = invalidMoves; } +} - /** - * If `selfTarget` is `true`, grab the last successful move used by anyone. - * Otherwise, select the last move used by the target. - */ +/** + * 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 @@ -7065,7 +6988,85 @@ export class CopyMoveAttr extends CallMoveAttr { } } +/** + * 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. diff --git a/src/phases/move-end-phase.ts b/src/phases/move-end-phase.ts index fd893c445ff..843d14ee26c 100644 --- a/src/phases/move-end-phase.ts +++ b/src/phases/move-end-phase.ts @@ -7,15 +7,26 @@ 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 +37,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; + } + // 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 98566c32ed2..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(); @@ -698,4 +707,4 @@ export class MovePhase extends BattlePhase { public showFailedText(failedText = i18next.t("battle:attackFailed")): void { globalScene.phaseManager.queueMessage(failedText); } -} \ No newline at end of file +} diff --git a/test/moves/copycat.test.ts b/test/moves/copycat.test.ts index 5e988ac1f5a..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,24 +34,40 @@ describe("Moves - Copycat", () => { .enemyMoveset(MoveId.SPLASH); }); - it("should copy the last move successfully executed", async () => { + 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.move.forceEnemyMove(MoveId.SPLASH); + game.move.use(MoveId.COPYCAT); + await game.move.forceEnemyMove(MoveId.SWORDS_DANCE); await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); - await game.toNextTurn(); - - game.move.select(MoveId.COPYCAT); // Last successful move should be Swords Dance - await game.move.forceEnemyMove(MoveId.SUCKER_PUNCH); - await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); - await game.toNextTurn(); + await game.toEndOfTurn(); const player = game.field.getPlayerPokemon(); - expect(player.getStatStage(Stat.ATK)).toBe(4); + expect(player.getStatStage(Stat.ATK)).toBe(2); expect(player.getLastXMoves()[0].move).toBe(MoveId.SWORDS_DANCE); }); + 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.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]); diff --git a/test/moves/metronome.test.ts b/test/moves/metronome.test.ts index 189cddd367b..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"; @@ -132,7 +131,7 @@ describe("Moves - Metronome", () => { expect(solarBeamMove).toBeDefined(); game.move.use(MoveId.METRONOME); - await game.move.forceEnemyMove(MoveId.SPITE) + await game.move.forceEnemyMove(MoveId.SPITE); await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); await game.toEndOfTurn(); @@ -181,7 +180,7 @@ describe("Moves - Metronome", () => { const enemyPokemon = game.field.getEnemyPokemon(); game.move.use(MoveId.METRONOME); - await game.toEndOfTurn() + await game.toEndOfTurn(); const isVisible = enemyPokemon.visible; const hasFled = enemyPokemon.switchOutStatus; diff --git a/test/moves/mirror-move.test.ts b/test/moves/mirror-move.test.ts index 6420ad37f50..cefe8fd71b6 100644 --- a/test/moves/mirror-move.test.ts +++ b/test/moves/mirror-move.test.ts @@ -39,13 +39,14 @@ describe("Moves - Mirror Move", () => { await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.MAGIKARP]); game.move.use(MoveId.MIRROR_MOVE, BattlerIndex.PLAYER, BattlerIndex.ENEMY); - game.move.use(MoveId.SWORDS_DANCE, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2); + 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(); - // Feebas copied enemy tackle against it + // 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, diff --git a/test/moves/nature-power.test.ts b/test/moves/nature-power.test.ts index e0a70a2dc10..e13bd0d9b57 100644 --- a/test/moves/nature-power.test.ts +++ b/test/moves/nature-power.test.ts @@ -1,12 +1,13 @@ 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 { 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"; @@ -43,7 +44,7 @@ describe("Move - Nature Power", () => { it.each( getEnumValues(BiomeId).map(biome => ({ move: getNaturePowerType(TerrainType.NONE, biome), - moveName: toReadableString(MoveId[getNaturePowerType(TerrainType.NONE, biome)]), + moveName: toTitleCase(MoveId[getNaturePowerType(TerrainType.NONE, biome)]), biome, biomeName: BiomeId[biome], })), @@ -68,11 +69,11 @@ describe("Move - Nature Power", () => { it.todo.each( getEnumValues(TerrainType).map(terrain => ({ move: getNaturePowerType(terrain, BiomeId.TOWN), - moveName: toReadableString(MoveId[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 */ }) => { + )("should select $moveName if the current terrain is $terrainName", async ({ move /*, terrain */ }) => { // game.override.terrain(terrainType); await game.classicMode.startBattle([SpeciesId.FEEBAS]); diff --git a/test/moves/sketch.test.ts b/test/moves/sketch.test.ts index 34e76b26881..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, "getMove").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 { From e7c37e7135ee71e0215c3e267c36567e286aa3f2 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Tue, 29 Jul 2025 22:56:09 -0400 Subject: [PATCH 18/18] Fixed minor syntax error --- src/phases/move-end-phase.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/phases/move-end-phase.ts b/src/phases/move-end-phase.ts index 843d14ee26c..d24207c0539 100644 --- a/src/phases/move-end-phase.ts +++ b/src/phases/move-end-phase.ts @@ -2,6 +2,7 @@ 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"; @@ -40,7 +41,7 @@ export class MoveEndPhase extends PokemonPhase { // 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; + 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)