diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index 413a8efc136..5eddc685ca2 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -4454,10 +4454,10 @@ export class PostDancingMoveAbAttr extends PostMoveUsedAbAttr { // TODO: fix in main dancer PR (currently keeping this purely semantic rather than actually fixing bug) if (move.getMove() instanceof AttackMove || move.getMove() instanceof StatusMove) { const target = this.getTarget(dancer, source, targets); - globalScene.unshiftPhase(new MovePhase(dancer, target, move, MoveUseType.FOLLOW_UP)); + globalScene.unshiftPhase(new MovePhase(dancer, target, move, MoveUseType.INDIRECT)); } else if (move.getMove() instanceof SelfStatusMove) { // If the move is a SelfStatusMove (ie. Swords Dance) the Dancer should replicate it on itself - globalScene.unshiftPhase(new MovePhase(dancer, [ dancer.getBattlerIndex() ], move, MoveUseType.FOLLOW_UP)) + globalScene.unshiftPhase(new MovePhase(dancer, [ dancer.getBattlerIndex() ], move, MoveUseType.INDIRECT)) } } } diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 120cb3aa701..554b4e5022d 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -307,7 +307,8 @@ export class DisabledTag extends MoveRestrictionBattlerTag { * and showing a message. */ override onAdd(pokemon: Pokemon): void { - // Disable fails against struggle or an empty move history + // Disable fails against struggle or an empty move history, but we still need to check for + // Cursed Body const move = pokemon.getLastNonVirtualMove(); if (isNullOrUndefined(move) || move.move === Moves.STRUGGLE) { return; diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index c40a9f64164..157359a0f99 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -5419,9 +5419,13 @@ export class FrenzyAttr extends MoveEffectAttr { // TODO: Disable if used via dancer // TODO: Add support for moves that don't add the frenzy tag (Uproar, Rollout, etc.) - if (!user.getTag(BattlerTagType.FRENZY) && !user.getMoveQueue().length) { + // If frenzy is not active, add a tag and push 1-2 extra turns of attacks to the user's move queue. + // Otherwise, tick down the existing tag. + if (!user.getTag(BattlerTagType.FRENZY) && user.getMoveQueue().length === 0) { const turnCount = user.randSeedIntRange(1, 2); - new Array(turnCount).fill(null).map(() => user.getMoveQueue().push({ move: move.id, targets: [ target.getBattlerIndex() ], ignorePP: true })); + for (let x = 0; x < turnCount; x++) { + user.pushMoveQueue({move: move.id, targets: [target.getBattlerIndex()], useType: MoveUseType.IGNORE_PP}) + } user.addTag(BattlerTagType.FRENZY, turnCount, move.id, user.id); } else { applyMoveAttrs(AddBattlerTagAttr, user, target, move, args); @@ -6746,7 +6750,9 @@ class CallMoveAttr extends OverrideMoveEffectAttr { // If not, target the Mirror Move recipient or else a random enemy in our target list const targets = moveTargets.multiple || moveTargets.targets.length === 1 ? moveTargets.targets - : [ this.hasTarget ? target.getBattlerIndex() : moveTargets.targets[user.randSeedInt(moveTargets.targets.length)] ]; // account for Mirror Move having a target already + : [this.hasTarget + ? target.getBattlerIndex() + : moveTargets.targets[user.randSeedInt(moveTargets.targets.length)]]; globalScene.unshiftPhase(new LoadMoveAnimPhase(move.id)); globalScene.unshiftPhase(new MovePhase(user, targets, new PokemonMove(move.id), MoveUseType.FOLLOW_UP)); return true; @@ -7083,6 +7089,7 @@ export class RepeatMoveAttr extends MoveEffectAttr { Moves.PETAL_DANCE, Moves.THRASH, Moves.ICE_BALL, + Moves.UPROAR, // Multi-turn Moves Moves.BIDE, Moves.SHELL_TRAP, @@ -7120,8 +7127,17 @@ export class RepeatMoveAttr extends MoveEffectAttr { Moves.SOLAR_BEAM, Moves.SOLAR_BLADE, Moves.METEOR_BEAM, - // Other moves + // Copying/Move-Calling moves + Moves.ASSIST, + Moves.COPYCAT, + Moves.ME_FIRST, + Moves.METRONOME, + Moves.MIRROR_MOVE, + Moves.NATURE_POWER, + Moves.SLEEP_TALK, + Moves.SNATCH, Moves.INSTRUCT, + // Misc moves Moves.KINGS_SHIELD, Moves.SKETCH, Moves.TRANSFORM, @@ -7132,7 +7148,8 @@ export class RepeatMoveAttr extends MoveEffectAttr { if (!lastMove?.move // no move to instruct || !movesetMove // called move not in target's moveset (forgetting the move, etc.) - || !movesetMove.isUsable(target) // Move unusable due to PP shortage or similar + || movesetMove.ppUsed === movesetMove.getMovePp() // move out of pp + || allMoves[lastMove.move].isChargingMove() // called move is a charging/recharging move || uninstructableMoves.includes(lastMove.move)) { // called move is in the banlist return false; } diff --git a/src/enums/move-use-type.ts b/src/enums/move-use-type.ts index 8ea3c934064..755168565f3 100644 --- a/src/enums/move-use-type.ts +++ b/src/enums/move-use-type.ts @@ -15,22 +15,23 @@ export enum MoveUseType { NORMAL, /** - * Identical to {@linkcode MoveUseType.NORMAL}, except the move **does not consume PP** on use - * and **will not fail** if none is left before its execution. - * PP can still be reduced by other effects (such as Spite or Eerie Spell). - */ + * This move was called by an effect that ignores PP, such as a consecutively executed move. + * Identical to {@linkcode MoveUseType.NORMAL}, except the move **does not consume PP** on use + * and **will not fail** if none is left before its execution. + * PP can still be reduced by other effects (such as Spite or Eerie Spell). + */ IGNORE_PP, /** - * This move was called indirectly by another effect other than Instruct or the user's previous move. - * Currently only used by {@linkcode PostDancingMoveAbAttr | Dancer}. + * This move was called indirectly by an out-of-turn effect other than Instruct or the user's previous move. + * Currently only used by {@linkcode PostDancingMoveAbAttr | Dancer}. - * Indirect moves ignore PP checks similar to {@linkcode MoveUseType.IGNORE_PP}, but additionally **cannot be copied** - * by all move-copying effects (barring reflection). - * They are also **"skipped over" by most moveset and move history-related effects** (PP reduction, Last Resort, etc). + * Indirect moves ignore PP checks similar to {@linkcode MoveUseType.IGNORE_PP}, but additionally **cannot be copied** + * by all move-copying effects (barring reflection). + * They are also **"skipped over" by most moveset and move history-related effects** (PP reduction, Last Resort, etc). - * They still respect the user's volatile status conditions and confusion (though will uniquely _cure freeze and sleep before use_). - */ + * They still respect the user's volatile status conditions and confusion (though will uniquely _cure freeze and sleep before use_). + */ INDIRECT, /** @@ -56,3 +57,9 @@ export enum MoveUseType { */ REFLECTED } + +/** + * Comment block to prevent auto-import removal. + * {@linkcode BattlerTagLapseType} + * {@linkcode PostDancingMoveAbAttr} + */ \ No newline at end of file diff --git a/src/phases/move-charge-phase.ts b/src/phases/move-charge-phase.ts index 23ec7788558..e520e1cf2f0 100644 --- a/src/phases/move-charge-phase.ts +++ b/src/phases/move-charge-phase.ts @@ -86,6 +86,8 @@ export class MoveChargePhase extends PokemonPhase { result: MoveResult.OTHER, useType: this.useType, }); + + super.end(); } public getUserPokemon(): Pokemon { diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 1bd07e31311..55c0456a092 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -150,7 +150,8 @@ export class MovePhase extends BattlePhase { console.log(Moves[this.move.moveId], MoveUseType[this.useType]); - // Check if move is unusable (e.g. because it's out of PP due to a mid-turn Spite). + // Check if move is unusable (e.g. running out of PP due to a mid-turn Spite + // or the user no longer being on field). if (!this.canMove(true)) { if (this.pokemon.isActive(true)) { this.fail(); @@ -241,6 +242,7 @@ export class MovePhase extends BattlePhase { return; } + this.pokemon.status.incrementTurn(); /** Whether to prevent us from using the move */ let activated = false; /** Whether to cure the status */ diff --git a/test/abilities/early_bird.test.ts b/test/abilities/early_bird.test.ts index 0f298ba479d..b7071d0af9d 100644 --- a/test/abilities/early_bird.test.ts +++ b/test/abilities/early_bird.test.ts @@ -41,6 +41,7 @@ describe("Abilities - Early Bird", () => { game.move.select(Moves.BELLY_DRUM); await game.toNextTurn(); + game.move.select(Moves.REST); await game.toNextTurn(); diff --git a/test/moves/after_you.test.ts b/test/moves/after_you.test.ts index ae35237be16..cf630a71fde 100644 --- a/test/moves/after_you.test.ts +++ b/test/moves/after_you.test.ts @@ -75,7 +75,7 @@ describe("Moves - After You", () => { game.move.select(Moves.SPLASH, BattlerIndex.PLAYER); game.move.select(Moves.OUTRAGE, BattlerIndex.PLAYER_2); - await game.toNextTurn(); + await game.phaseInterceptor.to("TurnEndPhase"); const outrageMove = rattata.getMoveset().find(m => m.moveId === Moves.OUTRAGE); expect(outrageMove?.ppUsed).toBe(1); diff --git a/test/moves/dig.test.ts b/test/moves/dig.test.ts index 0691d81c23f..caaa1e58fea 100644 --- a/test/moves/dig.test.ts +++ b/test/moves/dig.test.ts @@ -58,9 +58,11 @@ describe("Moves - Dig", () => { }); it("should deduct PP only on the 2nd turn of the move", async () => { + game.override.moveset([]); await game.classicMode.startBattle([Species.MAGIKARP]); const playerPokemon = game.scene.getPlayerPokemon()!; + game.move.changeMoveset(playerPokemon, Moves.DIG); game.move.select(Moves.DIG); await game.phaseInterceptor.to("TurnEndPhase"); diff --git a/test/moves/disable.test.ts b/test/moves/disable.test.ts index bd8f56ef46b..985e3cd8ab0 100644 --- a/test/moves/disable.test.ts +++ b/test/moves/disable.test.ts @@ -48,7 +48,7 @@ describe("Moves - Disable", () => { await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.toNextTurn(); - expect(enemyMon.getLastXMoves(-1)).toHaveLength(1); + expect(enemyMon.getLastXMoves(-1)).toHaveLength(2); expect(enemyMon.isMoveRestricted(Moves.SPLASH)).toBe(true); expect(enemyMon.isMoveRestricted(Moves.GROWL)).toBe(false); }); @@ -87,7 +87,7 @@ describe("Moves - Disable", () => { expect(enemyHistory.map(m => m.move)).toEqual([Moves.STRUGGLE, Moves.SPLASH]); }); - it("cannot disable STRUGGLE", async () => { + it("should fail if it would otherwise disable struggle", async () => { game.override.enemyMoveset([Moves.STRUGGLE]); await game.classicMode.startBattle([Species.PIKACHU]); @@ -120,28 +120,29 @@ describe("Moves - Disable", () => { const enemyHistory = enemyMon.getLastXMoves(-1); expect(enemyHistory).toHaveLength(2); - expect(enemyHistory[0]).toMatchObject({ - move: Moves.SPLASH, - result: MoveResult.FAIL, - }); + expect(enemyHistory[0].result).toBe(MoveResult.FAIL); }); it.each([ { name: "Nature Power", moveId: Moves.NATURE_POWER }, { name: "Mirror Move", moveId: Moves.MIRROR_MOVE }, { name: "Copycat", moveId: Moves.COPYCAT }, - { name: "Copycat", moveId: Moves.COPYCAT }, + { name: "Metronome", moveId: Moves.METRONOME }, ])("should ignore virtual moves called by $name", async ({ moveId }) => { game.override.enemyMoveset(moveId); await game.classicMode.startBattle([Species.PIKACHU]); - const enemyMon = game.scene.getEnemyPokemon()!; + const playerMon = game.scene.getEnemyPokemon()!; + playerMon.pushMoveHistory({ move: Moves.SPLASH, targets: [BattlerIndex.ENEMY], useType: MoveUseType.NORMAL }); + game.scene.currentBattle.lastMove = Moves.SPLASH; game.move.select(Moves.DISABLE); await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.toNextTurn(); + const enemyMon = game.scene.getEnemyPokemon()!; expect.soft(enemyMon.isMoveRestricted(moveId), `calling move ${Moves[moveId]} was not disabled`).toBe(true); + expect.soft(enemyMon.getLastXMoves(-1)).toHaveLength(2); const calledMove = enemyMon.getLastXMoves()[0].move; expect( enemyMon.isMoveRestricted(calledMove), @@ -154,7 +155,6 @@ describe("Moves - Disable", () => { .enemyAbility(Abilities.DANCER) .moveset([Moves.DISABLE, Moves.SWORDS_DANCE]) .enemyMoveset([Moves.SPLASH, Moves.SWORDS_DANCE]); - await game.classicMode.startBattle([Species.PIKACHU]); game.move.select(Moves.SWORDS_DANCE); @@ -173,6 +173,6 @@ describe("Moves - Disable", () => { expect.soft(shuckle.isMoveRestricted(Moves.SPLASH)).toBe(true); expect.soft(shuckle.isMoveRestricted(Moves.SWORDS_DANCE)).toBe(false); expect(shuckle.getLastXMoves()[0]).toMatchObject({ move: Moves.SWORDS_DANCE, result: MoveResult.SUCCESS }); - expect(shuckle.getStatStage(Stat.ATK)).toBe(2); + expect(shuckle.getStatStage(Stat.ATK)).toBe(4); }); }); diff --git a/test/moves/instruct.test.ts b/test/moves/instruct.test.ts index 440f8492a61..20312dc89ed 100644 --- a/test/moves/instruct.test.ts +++ b/test/moves/instruct.test.ts @@ -1,4 +1,5 @@ import { BattlerIndex } from "#app/battle"; +import { RandomMoveAttr } from "#app/data/moves/move"; import type Pokemon from "#app/field/pokemon"; import { MoveResult } from "#app/field/pokemon"; import type { MovePhase } from "#app/phases/move-phase"; @@ -8,7 +9,7 @@ import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; 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 - Instruct", () => { let phaserGame: Phaser.Game; @@ -139,6 +140,22 @@ describe("Moves - Instruct", () => { expect(game.scene.getPlayerPokemon()!.turnData.attacksReceived.length).toBe(3); }); + it("should fail on metronomed moves, even if also in moveset", async () => { + game.override.moveset(Moves.INSTRUCT); + vi.spyOn(RandomMoveAttr.prototype, "getMoveOverride").mockReturnValue(Moves.ABSORB); + await game.classicMode.startBattle([Species.AMOONGUSS]); + + const enemy = game.scene.getEnemyPokemon()!; + game.move.changeMoveset(enemy, [Moves.METRONOME, Moves.ABSORB]); + + game.move.select(Moves.INSTRUCT); + await game.forceEnemyMove(Moves.METRONOME); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.phaseInterceptor.to("BerryPhase"); + + expect(game.scene.getPlayerPokemon()!.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + }); + it("should respect enemy's status condition", async () => { game.override.moveset([Moves.INSTRUCT, Moves.THUNDER_WAVE]).enemyMoveset(Moves.SONIC_BOOM); await game.classicMode.startBattle([Species.AMOONGUSS]); @@ -249,15 +266,9 @@ describe("Moves - Instruct", () => { await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER_2, BattlerIndex.PLAYER, BattlerIndex.ENEMY_2]); await game.phaseInterceptor.to("TurnEndPhase", false); - expect(game.scene.getPlayerField()[0].getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); - const enemyMove = game.scene.getEnemyField()[0]!.getLastXMoves()[0]; - expect(enemyMove.result).toBe(MoveResult.FAIL); - expect( - game.scene - .getEnemyField()[0] - .getMoveset() - .find(m => m?.moveId === Moves.SONIC_BOOM)?.ppUsed, - ).toBe(1); + expect(game.scene.getPlayerPokemon()!.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); + expect(enemy1.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + expect(enemy1.getMoveset().find(m => m.moveId === Moves.SONIC_BOOM)?.ppUsed).toBe(1); }); it("should not repeat enemy's move through protect", async () => { diff --git a/test/moves/quash.test.ts b/test/moves/quash.test.ts index 3b9053448db..c72248eaac7 100644 --- a/test/moves/quash.test.ts +++ b/test/moves/quash.test.ts @@ -72,7 +72,7 @@ describe("Moves - Quash", () => { game.move.select(Moves.SPLASH, BattlerIndex.PLAYER); game.move.select(Moves.OUTRAGE, BattlerIndex.PLAYER_2); - await game.toNextTurn(); + await game.phaseInterceptor.to("TurnEndPhase"); const outrageMove = rattata.getMoveset().find(m => m.moveId === Moves.OUTRAGE); expect(outrageMove?.ppUsed).toBe(1); diff --git a/test/testUtils/gameManager.ts b/test/testUtils/gameManager.ts index b7870beaf42..008bcc24b43 100644 --- a/test/testUtils/gameManager.ts +++ b/test/testUtils/gameManager.ts @@ -427,6 +427,7 @@ export default class GameManager { * If all active player Pokemon are using a rampaging, charging, recharging or other move that * disables user input, this **will not resolve** until at least 1 player pokemon becomes actionable. */ + // TODO: Make this not need to be called twice in doubles tests async toNextTurn() { await this.phaseInterceptor.to(CommandPhase); console.log("==================[New Turn]=================="); @@ -519,9 +520,9 @@ export default class GameManager { * @returns A promise that resolves once the fainted pokemon's FaintPhase finishes running. */ async killPokemon(pokemon: PlayerPokemon | EnemyPokemon) { - pokemon.hp = 0; - this.scene.unshiftPhase(new FaintPhase(pokemon.getBattlerIndex(), true)); return new Promise(async (resolve, reject) => { + pokemon.hp = 0; + this.scene.pushPhase(new FaintPhase(pokemon.getBattlerIndex(), true)); await this.phaseInterceptor.to(FaintPhase).catch(e => reject(e)); resolve(); });