From c2e7c95620c31efe2a887ffdd3571ac400b6a3bb Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Sat, 10 May 2025 14:39:48 -0400 Subject: [PATCH] Fixed various bugs and added tests for previous bugfixes --- src/data/battler-tags.ts | 41 ++++++++++++-------------- src/data/moves/move.ts | 2 +- src/field/pokemon.ts | 5 ++-- src/phases/move-phase.ts | 2 +- test/abilities/gorilla_tactics.test.ts | 24 +++++++++++---- test/moves/after_you.test.ts | 31 +++++++++++++++++++ test/moves/copycat.test.ts | 29 ++++++++++-------- test/moves/metronome.test.ts | 9 +++++- test/moves/quash.test.ts | 35 ++++++++++++++++++++-- 9 files changed, 131 insertions(+), 47 deletions(-) diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 904bdc01bbf..1eb3b9efbae 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -311,10 +311,9 @@ export class DisabledTag extends MoveRestrictionBattlerTag { * and showing a message. */ override onAdd(pokemon: Pokemon): void { - // Disable fails against struggle or an empty move history, but we still need the nullish check - // for cursed body + // Disable fails against struggle or an empty move history const move = pokemon.getLastNonVirtualMove(); - if (isNullOrUndefined(move)) { + if (isNullOrUndefined(move) || move.move === Moves.STRUGGLE) { return; } @@ -368,7 +367,6 @@ export class DisabledTag extends MoveRestrictionBattlerTag { /** * Tag used by Gorilla Tactics to restrict the user to using only one move. - * @extends MoveRestrictionBattlerTag */ export class GorillaTacticsTag extends MoveRestrictionBattlerTag { private moveId = Moves.NONE; @@ -383,27 +381,27 @@ export class GorillaTacticsTag extends MoveRestrictionBattlerTag { } /** - * @override - * @param {Pokemon} pokemon the {@linkcode Pokemon} to check if the tag can be added - * @returns `true` if the pokemon has a valid move and no existing {@linkcode GorillaTacticsTag}; `false` otherwise + * Ensures that move history exists on {@linkcode Pokemon} and has a valid move to lock into. + * @param pokemon - the {@linkcode Pokemon} to add the tag to + * @returns `true` if the tag can be added */ override canAdd(pokemon: Pokemon): boolean { - return !isNullOrUndefined(pokemon.getLastNonVirtualMove(true)) && !pokemon.getTag(GorillaTacticsTag); + // Choice items ignore struggle + // TODO: Check if struggle also gets the 50% power boost + const lastSelectedMove = pokemon.getLastNonVirtualMove(false); + return ( + (isNullOrUndefined(lastSelectedMove) || lastSelectedMove.move === Moves.STRUGGLE) && + !pokemon.getTag(GorillaTacticsTag) + ); } /** - * Ensures that move history exists on {@linkcode Pokemon} and has a valid move. - * If so, sets the {@linkcode moveId} and increases the user's Attack by 50%. - * @override - * @param {Pokemon} pokemon the {@linkcode Pokemon} to add the tag to + * Sets this tag's {@linkcode moveId} and increases the user's Attack by 50%. + * @param pokemon - The {@linkcode Pokemon} to add the tag to */ override onAdd(pokemon: Pokemon): void { - const lastValidMove = pokemon.getLastNonVirtualMove(true); // TODO: Check if should work with struggle or not - if (isNullOrUndefined(lastValidMove)) { - return; - } - - this.moveId = lastValidMove.move; + super.onAdd(pokemon); + this.moveId = pokemon.getLastNonVirtualMove(false)!.move; // `canAdd` returns false if no move pokemon.setStat(Stat.ATK, pokemon.getStat(Stat.ATK, false) * 1.5, false); } @@ -418,11 +416,10 @@ export class GorillaTacticsTag extends MoveRestrictionBattlerTag { } /** - * * @override - * @param {Pokemon} pokemon n/a - * @param {Moves} _move {@linkcode Moves} ID of the move being denied - * @returns {string} text to display when the move is denied + * @param pokemon - The {@linkcode Pokemon} attempting to select a move + * @param _move - Unused + * @returns The text to display when the move is rendered unselectable */ override selectionDeniedText(pokemon: Pokemon, _move: Moves): string { return i18next.t("battle:canOnlyUseMove", { diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 7a825800f0e..83bd204865c 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -7895,7 +7895,7 @@ export class ForceLastAttr extends MoveEffectAttr { override apply(user: Pokemon, target: Pokemon, _move: Move, _args: any[]): boolean { globalScene.queueMessage(i18next.t("moveTriggers:forceLast", { targetPokemonName: getPokemonNameWithAffix(target) })); - // TODO: Refactor this to be more readable + // TODO: Refactor this to be more readable and less janky const targetMovePhase = globalScene.findPhase((phase) => phase.pokemon === target); if (targetMovePhase && !targetMovePhase.isForcedLast() && globalScene.tryRemovePhase((phase: MovePhase) => phase.pokemon === target)) { // Finding the phase to insert the move in front of - diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 90f09cb1530..f2d00732ccf 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -5161,7 +5161,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * - Non-virtual ({@linkcode MoveUseType | useType} < {@linkcode MoveUseType.INDIRECT}) * @param ignoreStruggle - Whether to additionally ignore {@linkcode Moves.STRUGGLE}; default `false` * @param ignoreFollowUp - Whether to ignore moves with a use type of {@linkcode MoveUseType.FOLLOW_UP} - * (Copycat, Mirror Move, etc.); default `true` + * (Copycat, Mirror Move, etc.); default `true`. * @returns The last move this Pokemon has used satisfying the aforementioned conditions, * or `undefined` if no applicable moves have been used since switching in. */ @@ -7250,12 +7250,13 @@ export class EnemyPokemon extends Pokemon { const moveIndex = this.getMoveset().findIndex(m => m.moveId === queuedMove.move); // If the queued move was called indirectly, ignore all PP and usability checks. // Otherwise, ensure that the move being used is actually usable + // TODO: Virtual moves shouldn't use the move queue if ( queuedMove.useType >= MoveUseType.INDIRECT || (moveIndex > -1 && this.getMoveset()[moveIndex].isUsable( this, - queuedMove.useType >= MoveUseType.IGNORE_PP ) + queuedMove.useType >= MoveUseType.IGNORE_PP) ) ) { return queuedMove; diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 2b16dd7a512..1bd07e31311 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -123,7 +123,7 @@ export class MovePhase extends BattlePhase { return ( this.pokemon.isActive(true) && this.move.isUsable(this.pokemon, this.useType >= MoveUseType.IGNORE_PP, ignoreDisableTags) && - !!this.targets.length + this.targets.length > 0 ); } diff --git a/test/abilities/gorilla_tactics.test.ts b/test/abilities/gorilla_tactics.test.ts index edaf1669809..50ad0d4d65b 100644 --- a/test/abilities/gorilla_tactics.test.ts +++ b/test/abilities/gorilla_tactics.test.ts @@ -1,11 +1,12 @@ import { BattlerIndex } from "#app/battle"; +import { RandomMoveAttr } from "#app/data/moves/move"; import { Moves } from "#app/enums/moves"; import { Species } from "#app/enums/species"; import { Stat } from "#app/enums/stat"; import { Abilities } from "#enums/abilities"; 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("Abilities - Gorilla Tactics", () => { let phaserGame: Phaser.Game; @@ -25,10 +26,10 @@ describe("Abilities - Gorilla Tactics", () => { game.override .battleStyle("single") .enemyAbility(Abilities.BALL_FETCH) - .enemyMoveset([Moves.SPLASH, Moves.DISABLE]) + .enemyMoveset(Moves.SPLASH) .enemySpecies(Species.MAGIKARP) .enemyLevel(30) - .moveset([Moves.SPLASH, Moves.TACKLE, Moves.GROWL]) + .moveset([Moves.SPLASH, Moves.TACKLE, Moves.GROWL, Moves.METRONOME]) .ability(Abilities.GORILLA_TACTICS); }); @@ -39,8 +40,6 @@ describe("Abilities - Gorilla Tactics", () => { const initialAtkStat = darmanitan.getStat(Stat.ATK); game.move.select(Moves.SPLASH); - await game.forceEnemyMove(Moves.SPLASH); - await game.phaseInterceptor.to("TurnEndPhase"); expect(darmanitan.getStat(Stat.ATK, false)).toBeCloseTo(initialAtkStat * 1.5); @@ -50,6 +49,7 @@ describe("Abilities - Gorilla Tactics", () => { }); it("should struggle if the only usable move is disabled", async () => { + game.override.enemyMoveset([Moves.DISABLE, Moves.SPLASH]); await game.classicMode.startBattle([Species.GALAR_DARMANITAN]); const darmanitan = game.scene.getPlayerPokemon()!; @@ -78,4 +78,18 @@ describe("Abilities - Gorilla Tactics", () => { await game.phaseInterceptor.to("MoveEndPhase"); expect(darmanitan.hp).toBeLessThan(darmanitan.getMaxHp()); }); + + it("should lock into calling moves, even if also in moveset", async () => { + vi.spyOn(RandomMoveAttr.prototype, "getMoveOverride").mockReturnValue(Moves.TACKLE); + await game.classicMode.startBattle([Species.GALAR_DARMANITAN]); + + const darmanitan = game.scene.getPlayerPokemon()!; + + game.move.select(Moves.METRONOME); + await game.phaseInterceptor.to("TurnEndPhase"); + + // Gorilla Tactics should bypass dancer and instruct + expect(darmanitan.isMoveRestricted(Moves.TACKLE)).toBe(true); + expect(darmanitan.isMoveRestricted(Moves.METRONOME)).toBe(false); + }); }); diff --git a/test/moves/after_you.test.ts b/test/moves/after_you.test.ts index 3fa7c9ceb0a..ae35237be16 100644 --- a/test/moves/after_you.test.ts +++ b/test/moves/after_you.test.ts @@ -2,6 +2,7 @@ import { BattlerIndex } from "#app/battle"; import { Abilities } from "#app/enums/abilities"; import { MoveResult } from "#app/field/pokemon"; import { MovePhase } from "#app/phases/move-phase"; +import { MoveUseType } from "#enums/move-use-type"; import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; import GameManager from "#test/testUtils/gameManager"; @@ -60,4 +61,34 @@ describe("Moves - After You", () => { expect(game.scene.getPlayerField()[1].getLastXMoves(1)[0].result).toBe(MoveResult.FAIL); }); + + it("should maintain PP ignore status of rampaging moves", async () => { + game.override.moveset([]); + await game.classicMode.startBattle([Species.ACCELGOR, Species.RATTATA]); + + const [accelgor, rattata] = game.scene.getPlayerField(); + expect(accelgor).toBeDefined(); + expect(rattata).toBeDefined(); + + game.move.changeMoveset(accelgor, [Moves.SPLASH, Moves.AFTER_YOU]); + game.move.changeMoveset(rattata, Moves.OUTRAGE); + + game.move.select(Moves.SPLASH, BattlerIndex.PLAYER); + game.move.select(Moves.OUTRAGE, BattlerIndex.PLAYER_2); + await game.toNextTurn(); + + const outrageMove = rattata.getMoveset().find(m => m.moveId === Moves.OUTRAGE); + expect(outrageMove?.ppUsed).toBe(1); + + game.move.select(Moves.AFTER_YOU, BattlerIndex.PLAYER, BattlerIndex.PLAYER_2); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(accelgor.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); + expect(outrageMove?.ppUsed).toBe(1); + expect(rattata.getLastXMoves()[0]).toMatchObject({ + move: Moves.OUTRAGE, + result: MoveResult.SUCCESS, + useType: MoveUseType.IGNORE_PP, + }); + }); }); diff --git a/test/moves/copycat.test.ts b/test/moves/copycat.test.ts index 2e6e8098835..780421222f1 100644 --- a/test/moves/copycat.test.ts +++ b/test/moves/copycat.test.ts @@ -1,8 +1,9 @@ import { BattlerIndex } from "#app/battle"; -import { allMoves, RandomMoveAttr } from "#app/data/moves/move"; +import { RandomMoveAttr } from "#app/data/moves/move"; import { Stat } from "#app/enums/stat"; import { MoveResult } from "#app/field/pokemon"; import { Abilities } from "#enums/abilities"; +import { MoveUseType } from "#enums/move-use-type"; import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; import GameManager from "#test/testUtils/gameManager"; @@ -13,8 +14,6 @@ describe("Moves - Copycat", () => { let phaserGame: Phaser.Game; let game: GameManager; - let randomMoveAttr: RandomMoveAttr; - beforeAll(() => { phaserGame = new Phaser.Game({ type: Phaser.HEADLESS, @@ -26,14 +25,12 @@ describe("Moves - Copycat", () => { }); beforeEach(() => { - randomMoveAttr = allMoves[Moves.METRONOME].getAttrs(RandomMoveAttr)[0]; game = new GameManager(phaserGame); game.override .moveset([Moves.COPYCAT, Moves.SPIKY_SHIELD, Moves.SWORDS_DANCE, Moves.SPLASH]) .ability(Abilities.BALL_FETCH) .battleStyle("single") .disableCrits() - .starterSpecies(Species.FEEBAS) .enemySpecies(Species.MAGIKARP) .enemyAbility(Abilities.BALL_FETCH) .enemyMoveset(Moves.SPLASH); @@ -41,7 +38,7 @@ describe("Moves - Copycat", () => { it("should copy the last move successfully executed", async () => { game.override.enemyMoveset(Moves.SUCKER_PUNCH); - await game.classicMode.startBattle(); + await game.classicMode.startBattle([Species.FEEBAS]); game.move.select(Moves.SWORDS_DANCE); await game.toNextTurn(); @@ -54,7 +51,7 @@ describe("Moves - Copycat", () => { it("should fail when the last move used is not a valid Copycat move", async () => { game.override.enemyMoveset(Moves.PROTECT); // Protect is not a valid move for Copycat to copy - await game.classicMode.startBattle(); + await game.classicMode.startBattle([Species.FEEBAS]); game.move.select(Moves.SPIKY_SHIELD); // Spiky Shield is not a valid move for Copycat to copy await game.toNextTurn(); @@ -67,19 +64,25 @@ describe("Moves - Copycat", () => { it("should copy the called move when the last move successfully calls another", async () => { game.override.moveset([Moves.SPLASH, Moves.METRONOME]).enemyMoveset(Moves.COPYCAT); - await game.classicMode.startBattle(); - vi.spyOn(randomMoveAttr, "getMoveOverride").mockReturnValue(Moves.SWORDS_DANCE); + await game.classicMode.startBattle([Species.DRAMPA]); + vi.spyOn(RandomMoveAttr.prototype, "getMoveOverride").mockReturnValue(Moves.SWORDS_DANCE); game.move.select(Moves.METRONOME); - await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); // Player moves first, so enemy can copy Swords Dance + 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); + const enemy = game.scene.getEnemyPokemon()!; + expect(enemy.getLastXMoves()[0]).toMatchObject({ + move: Moves.SWORDS_DANCE, + result: MoveResult.SUCCESS, + useType: MoveUseType.FOLLOW_UP, + }); + expect(enemy.getStatStage(Stat.ATK)).toBe(2); }); - it("should apply secondary effects of a move", async () => { + it("should apply move secondary effects", async () => { game.override.enemyMoveset(Moves.ACID_SPRAY); // Secondary effect lowers SpDef by 2 stages - await game.classicMode.startBattle(); + await game.classicMode.startBattle([Species.FEEBAS]); game.move.select(Moves.COPYCAT); await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); diff --git a/test/moves/metronome.test.ts b/test/moves/metronome.test.ts index b6acd019f1e..e8541bd2bb0 100644 --- a/test/moves/metronome.test.ts +++ b/test/moves/metronome.test.ts @@ -3,8 +3,10 @@ import { RechargingTag, SemiInvulnerableTag } from "#app/data/battler-tags"; import { allMoves, RandomMoveAttr } from "#app/data/moves/move"; import { Abilities } from "#app/enums/abilities"; import { Stat } from "#app/enums/stat"; +import { MoveResult } from "#app/field/pokemon"; import { CommandPhase } from "#app/phases/command-phase"; import { BattlerTagType } from "#enums/battler-tag-type"; +import { MoveUseType } from "#enums/move-use-type"; import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; import GameManager from "#test/testUtils/gameManager"; @@ -79,7 +81,7 @@ describe("Moves - Metronome", () => { expect(player.getTag(RechargingTag)).toBeTruthy(); }); - it("should charge when calling charging moves while still maintaining follow-up status", async () => { + it("should charge for charging moves while still maintaining follow-up status", async () => { game.override.moveset([]).enemyMoveset(Moves.SPITE); vi.spyOn(randomMoveAttr, "getMoveOverride").mockReturnValue(Moves.SOLAR_BEAM); await game.classicMode.startBattle([Species.REGIELEKI]); @@ -107,6 +109,11 @@ describe("Moves - Metronome", () => { const turn2PpUsed = metronomeMove.ppUsed - turn1PpUsed; expect(turn2PpUsed).toBeGreaterThan(1); expect(solarBeamMove.ppUsed).toBe(0); + expect(player.getLastXMoves()[0]).toMatchObject({ + move: Moves.SOLAR_BEAM, + result: MoveResult.SUCCESS, + useType: MoveUseType.FOLLOW_UP, + }); }); it("should only target ally for Aromatic Mist", async () => { diff --git a/test/moves/quash.test.ts b/test/moves/quash.test.ts index 5bf8271320b..3b9053448db 100644 --- a/test/moves/quash.test.ts +++ b/test/moves/quash.test.ts @@ -7,6 +7,7 @@ import { MoveResult } from "#app/field/pokemon"; import GameManager from "#test/testUtils/gameManager"; import Phaser from "phaser"; import { describe, beforeAll, afterEach, beforeEach, it, expect } from "vitest"; +import { MoveUseType } from "#enums/move-use-type"; describe("Moves - Quash", () => { let phaserGame: Phaser.Game; @@ -49,8 +50,8 @@ describe("Moves - Quash", () => { it("fails if the target has already moved", async () => { await game.classicMode.startBattle([Species.ACCELGOR, Species.RATTATA]); - game.move.select(Moves.SPLASH, 0); - game.move.select(Moves.QUASH, 1, BattlerIndex.PLAYER); + game.move.select(Moves.SPLASH, BattlerIndex.PLAYER); + game.move.select(Moves.QUASH, BattlerIndex.PLAYER_2, BattlerIndex.PLAYER); await game.phaseInterceptor.to("MoveEndPhase"); await game.phaseInterceptor.to("MoveEndPhase"); @@ -58,6 +59,36 @@ describe("Moves - Quash", () => { expect(game.scene.getPlayerField()[1].getLastXMoves(1)[0].result).toBe(MoveResult.FAIL); }); + it("should maintain PP ignore status of rampaging moves", async () => { + game.override.moveset([]); + await game.classicMode.startBattle([Species.ACCELGOR, Species.RATTATA]); + + const [accelgor, rattata] = game.scene.getPlayerField(); + expect(accelgor).toBeDefined(); + expect(rattata).toBeDefined(); + + game.move.changeMoveset(accelgor, [Moves.SPLASH, Moves.QUASH]); + game.move.changeMoveset(rattata, Moves.OUTRAGE); + + game.move.select(Moves.SPLASH, BattlerIndex.PLAYER); + game.move.select(Moves.OUTRAGE, BattlerIndex.PLAYER_2); + await game.toNextTurn(); + + const outrageMove = rattata.getMoveset().find(m => m.moveId === Moves.OUTRAGE); + expect(outrageMove?.ppUsed).toBe(1); + + game.move.select(Moves.QUASH, BattlerIndex.PLAYER, BattlerIndex.PLAYER_2); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(accelgor.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); + expect(outrageMove?.ppUsed).toBe(1); + expect(rattata.getLastXMoves()[0]).toMatchObject({ + move: Moves.OUTRAGE, + result: MoveResult.SUCCESS, + useType: MoveUseType.IGNORE_PP, + }); + }); + it("makes multiple quashed targets move in speed order at the end of the turn", async () => { game.override.enemySpecies(Species.NINJASK).enemyLevel(100);