diff --git a/src/data/move.ts b/src/data/move.ts index 893a6b8333a..591e704886a 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -6573,8 +6573,9 @@ export class CopyMoveAttr extends OverrideMoveEffectAttr { } /** - * Attribute used for moves that causes the target to repeat their last used move. - * Used for Instruct. + * Attribute used for moves that causes the target to repeat their last used move.4 + * + * Used for [Instruct](https://bulbapedia.bulbagarden.net/wiki/After_You_(move)). */ export class RepeatMoveAttr extends OverrideMoveEffectAttr { /** @@ -6587,7 +6588,7 @@ export class RepeatMoveAttr extends OverrideMoveEffectAttr { * @returns {boolean} true if the move succeeds */ apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - const lastMove = target.getLastXMoves().find(() => true); + const lastMove = target.getLastXMoves().find(m => m.move !== Moves.NONE); // get the last move used, excluding status based fails const movesetMove = target.getMoveset().find(m => m?.moveId === lastMove?.move); const moveTargets = lastMove?.targets; @@ -6603,32 +6604,40 @@ export class RepeatMoveAttr extends OverrideMoveEffectAttr { getCondition(): MoveConditionFunc { return (user, target, move) => { - const lastMove = target.getLastXMoves().find(() => true); + const lastMove = target.getLastXMoves().find(m => m.move !== Moves.NONE); const movesetMove = target.getMoveset().find(m => m?.moveId === lastMove?.move); const moveTargets = lastMove?.targets!; const unrepeatablemoves = [ + // Locking/Continually Executed moves Moves.OUTRAGE, Moves.RAGING_FURY, Moves.ROLLOUT, Moves.PETAL_DANCE, Moves.THRASH, Moves.ICE_BALL, + // Multi-turn Moves + Moves.BIDE, Moves.SHELL_TRAP, - Moves.KINGS_SHIELD, Moves.BEAK_BLAST, + Moves.FOCUS_PUNCH, + // "First Turn Only" moves + Moves.FAKE_OUT, + Moves.FIRST_IMPRESSION, + Moves.MAT_BLOCK, + // Other moves + Moves.KINGS_SHIELD, Moves.SKETCH, Moves.TRANSFORM, Moves.MIMIC, Moves.STRUGGLE, - Moves.FOCUS_PUNCH, // TODO: Add Z-move blockage once zmoves are implemented // as well as actually blocking move calling moves ]; - if (!movesetMove || - !moveTargets.length || - !targetMoveCopiableCondition(user, target, move) || - unrepeatablemoves.includes(lastMove?.move!)) { + if (!targetMoveCopiableCondition(user, target, move) || // called move doesn't exist or is a charging/recharging move + !movesetMove || // called move not in target's moveset (dancer, forgetting the move, etc.) + !moveTargets.length || // called move has no targets + unrepeatablemoves.includes(lastMove?.move!)) { // called move is explicitly in the banlist return false; } return true; diff --git a/src/test/moves/instruct.test.ts b/src/test/moves/instruct.test.ts new file mode 100644 index 00000000000..5839f9abb97 --- /dev/null +++ b/src/test/moves/instruct.test.ts @@ -0,0 +1,134 @@ +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import { BattlerIndex } from "#app/battle"; +import GameManager from "#test/utils/gameManager"; +import { MoveResult } from "#app/field/pokemon"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Moves - Instruct", () => { + 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.battleType("double"); + game.override.enemySpecies(Species.MAGIKARP); + game.override.battleType("double"); + game.override.enemyLevel(100); + game.override.starterSpecies(Species.AMOONGUSS); + game.override.startingLevel(100); + game.override.moveset([ Moves.INSTRUCT, Moves.SONIC_BOOM, Moves.SUBSTITUTE, Moves.TORMENT ]); + game.override.enemyMoveset([ Moves.SONIC_BOOM, Moves.PROTECT, Moves.SUBSTITUTE, Moves.HYPER_BEAM ]); + }); + + it("should repeat enemy's attack move when moving last", async () => { + await game.classicMode.startBattle([ Species.AMOONGUSS ]); + game.move.select(Moves.INSTRUCT, BattlerIndex.PLAYER, BattlerIndex.ENEMY); + await game.forceEnemyMove(Moves.SONIC_BOOM, BattlerIndex.PLAYER); + await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]); + await game.phaseInterceptor.to("TurnEndPhase", false); + + expect(game.scene.getPlayerPokemon()?.getInverseHp()).toBe(40); // lost 2 hp from 2 attacks + }); + it("should repeat enemy's move through substitute", async () => { + await game.classicMode.startBattle([ Species.AMOONGUSS ]); + game.move.select(Moves.INSTRUCT, BattlerIndex.PLAYER, BattlerIndex.ENEMY); + await game.forceEnemyMove(Moves.SUBSTITUTE, BattlerIndex.ATTACKER); + await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]); + await game.phaseInterceptor.to("CommandPhase", false); + + // fake move history + + game.move.select(Moves.INSTRUCT, BattlerIndex.PLAYER, BattlerIndex.ENEMY); + await game.forceEnemyMove(Moves.SONIC_BOOM, BattlerIndex.PLAYER); + await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]); + await game.phaseInterceptor.to("TurnEndPhase", false); + + expect(game.scene.getPlayerPokemon()?.getInverseHp()).toBe(40); // lost 40 hp from 2 attacks + }); + it("should try to repeat enemy's disabled move, but fail", async () => { + game.override.moveset([ Moves.INSTRUCT, Moves.SONIC_BOOM, Moves.DISABLE, Moves.SPLASH ]); + await game.classicMode.startBattle([ Species.AMOONGUSS, Species.DROWZEE ]); + game.move.select(Moves.DISABLE, BattlerIndex.PLAYER, BattlerIndex.ENEMY); + game.move.select(Moves.INSTRUCT, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY); + await game.forceEnemyMove(Moves.SONIC_BOOM, BattlerIndex.PLAYER); + await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER, BattlerIndex.PLAYER_2 ]); + await game.phaseInterceptor.to("TurnEndPhase", false); + + expect(game.scene.getEnemyPokemon()!.getLastXMoves()[0].result).toBe(MoveResult.FAIL); // failed due to disable + }); + it("should repeat tormented enemy's move", async () => { + await game.classicMode.startBattle([ Species.AMOONGUSS, Species.MIGHTYENA ]); + const enemyPokemon = game.scene.getEnemyPokemon()!; + // fake move history + enemyPokemon.battleSummonData.moveHistory = [{ move: Moves.SONIC_BOOM, targets: [ BattlerIndex.PLAYER ], result: MoveResult.SUCCESS, virtual: false }]; + + game.move.select(Moves.INSTRUCT, BattlerIndex.PLAYER, BattlerIndex.ENEMY); + game.move.select(Moves.TORMENT, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY); + await game.forceEnemyMove(Moves.SONIC_BOOM); + await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER_2, BattlerIndex.PLAYER ]); + await game.phaseInterceptor.to("TurnEndPhase", false); + + expect(game.scene.getPlayerPokemon()!.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); // should work + }); + it("should not repeat enemy's move thru protect", async () => { + await game.classicMode.startBattle([ Species.AMOONGUSS ]); + const enemyPokemon = game.scene.getEnemyPokemon()!; + // fake move history + enemyPokemon.battleSummonData.moveHistory = [{ move: Moves.SONIC_BOOM, targets: [ BattlerIndex.PLAYER ], result: MoveResult.SUCCESS, virtual: false }]; + + game.move.select(Moves.INSTRUCT, BattlerIndex.PLAYER, BattlerIndex.ENEMY); + await game.forceEnemyMove(Moves.PROTECT); + await game.phaseInterceptor.to("TurnEndPhase", false); + + expect(game.scene.getPlayerPokemon()!.getLastXMoves()[0].result).toBe(MoveResult.FAIL); // lost no hp as mon protected themself from instruct + }); + it("should not repeat enemy's charging move", async () => { + await game.classicMode.startBattle([ Species.DUSKNOIR ]); + const enemyPokemon = game.scene.getEnemyPokemon()!; + enemyPokemon.battleSummonData.moveHistory = [{ move: Moves.SONIC_BOOM, targets: [ BattlerIndex.PLAYER ], result: MoveResult.SUCCESS, virtual: false }]; + + game.move.select(Moves.INSTRUCT, BattlerIndex.PLAYER, BattlerIndex.ENEMY); + await game.forceEnemyMove(Moves.HYPER_BEAM); + await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]); + await game.phaseInterceptor.to("TurnEndPhase", false); + + expect(game.scene.getPlayerPokemon()!.getLastXMoves()[0].result).toBe(MoveResult.FAIL); // hyper beam charging prevented instruct from working + }); + it("should not repeat move not known by target", async () => { + await game.classicMode.startBattle([ Species.DUSKNOIR ]); + const enemyPokemon = game.scene.getEnemyPokemon()!; + enemyPokemon.battleSummonData.moveHistory = [{ move: Moves.ROLLOUT, targets: [ BattlerIndex.PLAYER ], result: MoveResult.SUCCESS, virtual: false }]; + + game.move.select(Moves.INSTRUCT, BattlerIndex.PLAYER, BattlerIndex.ENEMY); + await game.forceEnemyMove(Moves.HYPER_BEAM); + await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]); + await game.phaseInterceptor.to("TurnEndPhase", false); + + expect(game.scene.getPlayerPokemon()!.getLastXMoves()[0].result).toBe(MoveResult.FAIL); // hyper beam cannot be instructed + }); + it("should repeat ally's attack on enemy", async () => { + await game.classicMode.startBattle([ Species.AMOONGUSS, Species.SHUCKLE ]); + + game.move.select(Moves.INSTRUCT, BattlerIndex.PLAYER, BattlerIndex.PLAYER_2); + game.move.select(Moves.SONIC_BOOM, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY); + await game.forceEnemyMove(Moves.VINE_WHIP); + await game.setTurnOrder([ BattlerIndex.PLAYER_2, BattlerIndex.PLAYER, BattlerIndex.ENEMY ]); + + await game.phaseInterceptor.to("TurnEndPhase", false); + + const moveUsed = game.scene.getPlayerField()[1]!.getMoveset().find(m => m?.moveId === Moves.SONIC_BOOM)!; + expect(moveUsed.getMove().pp - moveUsed.getMovePp()).toBe(2); // used 2 pp and spanked enemy twice + }); +}); diff --git a/src/test/moves/instruct_test.ts b/src/test/moves/instruct_test.ts deleted file mode 100644 index e3a29c454e0..00000000000 --- a/src/test/moves/instruct_test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Moves } from "#enums/moves"; -import { Species } from "#enums/species"; -import { BattlerIndex } from "#app/battle"; -import GameManager from "#test/utils/gameManager"; -import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; - -describe("Moves - Instruct", () => { - let phaserGame: Phaser.Game; - let game: GameManager; - - beforeAll(() => { - phaserGame = new Phaser.Game({ - type: Phaser.HEADLESS, - }); - game.override.battleType("double"); - }); - - afterEach(() => { - game.phaseInterceptor.restoreOg(); - }); - - beforeEach(() => { - const moveToUse = Moves.INSTRUCT; - game = new GameManager(phaserGame); - game.override.enemySpecies(Species.MAGIKARP); - game.override.enemyLevel(1); - game.override.starterSpecies(Species.AMOONGUSS); - game.override.startingLevel(100); - game.override.moveset([ moveToUse ]); - game.override.enemyMoveset([ Moves.VINE_WHIP ]); - }); - - it("should repeat enemy's attack move when moving last", async () => { - await game.classicMode.startBattle([ Species.AMOONGUSS ]); - const enemyPokemon = game.scene.getEnemyPokemon(); - - game.move.select(Moves.INSTRUCT, 0, 2); - await game.forceEnemyMove(Moves.VINE_WHIP); - await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]); - await game.phaseInterceptor.to("TurnEndPhase", false); - - const moveUsed = enemyPokemon?.getMoveset().find(m => m?.moveId === Moves.VINE_WHIP); - expect(moveUsed).toBeCalledTimes(2); - }); -});