From 6c22ab94839a46fc5b8facec68a09b475d72d47d Mon Sep 17 00:00:00 2001 From: Wlowscha <54003515+Wlowscha@users.noreply.github.com> Date: Sun, 17 Aug 2025 10:40:43 +0200 Subject: [PATCH] Added tests for failing catches --- src/game-mode.ts | 8 ++ src/phases/command-phase.ts | 18 +-- test/field/catching.test.ts | 244 ++++++++++++++++++++++++++++++++++++ 3 files changed, 263 insertions(+), 7 deletions(-) create mode 100644 test/field/catching.test.ts diff --git a/src/game-mode.ts b/src/game-mode.ts index c555723d676..513a2baaf95 100644 --- a/src/game-mode.ts +++ b/src/game-mode.ts @@ -82,6 +82,14 @@ export class GameMode implements GameModeConfig { return this.challenges.some(c => c.id === challenge && c.value !== 0); } + /** + * Helper function to see if a GameMode has any challenges, needed in tests + * @returns true if the game mode has at least one challenge + */ + hasAnyChallenges(): boolean { + return this.challenges.length > 0; + } + /** * Helper function to see if the game mode is using fresh start * @returns true if a fresh start challenge is being applied diff --git a/src/phases/command-phase.ts b/src/phases/command-phase.ts index 2b6c93a826b..8d60e28a1fd 100644 --- a/src/phases/command-phase.ts +++ b/src/phases/command-phase.ts @@ -390,12 +390,12 @@ export class CommandPhase extends FieldPhase { const isClassicFinalBoss = globalScene.gameMode.isBattleClassicFinalBoss(globalScene.currentBattle.waveIndex); const isEndlessMinorBoss = globalScene.gameMode.isEndlessMinorBoss(globalScene.currentBattle.waveIndex); const isFullFreshStart = globalScene.gameMode.isFullFreshStartChallenge(); - const someUncaughtSpeciesOnField = globalScene .getEnemyField() .some(p => p.isActive() && !dexData[p.species.speciesId].caughtAttr); const missingMultipleStarters = gameData.getStarterCount(d => !!d.caughtAttr) < Object.keys(speciesStarterCosts).length - 1; + if (biomeType === BiomeId.END) { if ( (isClassic && !isClassicFinalBoss && someUncaughtSpeciesOnField) || @@ -410,7 +410,7 @@ export class CommandPhase extends FieldPhase { (isEndless && isEndlessMinorBoss) || isDaily ) { - // Uncatchable final boss in classic and endless + // Uncatchable final boss in classic, endless and daily this.queueShowText("battle:noPokeballForceFinalBoss"); } else { return true; @@ -446,10 +446,8 @@ export class CommandPhase extends FieldPhase { return false; } - // Restricts use of Master Ball against final boss in challenges - const restrictMasterBall = - globalScene.gameMode.isChallenge && - globalScene.gameMode.isBattleClassicFinalBoss(globalScene.currentBattle.waveIndex); + const isChallengeActive = globalScene.gameMode.hasAnyChallenges(); + const isFinalBoss = globalScene.gameMode.isBattleClassicFinalBoss(globalScene.currentBattle.waveIndex); const numBallTypes = 5; if (cursor < numBallTypes) { @@ -460,10 +458,16 @@ export class CommandPhase extends FieldPhase { // TODO: Decouple this hardcoded exception for wonder guard and just check the target... !targetPokemon?.hasAbility(AbilityId.WONDER_GUARD, false, true) ) { - if (restrictMasterBall) { + // When facing the final boss, it must be weakened unless a Master Ball is used AND no challenges are active. + // The message is customized for the final boss. + if ( + isFinalBoss && + (cursor < PokeballType.MASTER_BALL || (cursor === PokeballType.MASTER_BALL && isChallengeActive)) + ) { this.queueShowText("battle:noPokeballForceFinalBossCatchable"); return false; } + // When facing any other boss, Master Ball can always be used, and we use the standard message. if (cursor < PokeballType.MASTER_BALL) { this.queueShowText("battle:noPokeballStrong"); return false; diff --git a/test/field/catching.test.ts b/test/field/catching.test.ts new file mode 100644 index 00000000000..59cde8f0e7b --- /dev/null +++ b/test/field/catching.test.ts @@ -0,0 +1,244 @@ +import { pokerogueApi } from "#api/pokerogue-api"; +import { BiomeId } from "#enums/biome-id"; +import { Challenges } from "#enums/challenges"; +import { GameModes } from "#enums/game-modes"; +import { MoveId } from "#enums/move-id"; +import { PokeballType } from "#enums/pokeball"; +import { SpeciesId } from "#enums/species-id"; +import { GameManager } from "#test/test-utils/game-manager"; +import i18next from "i18next"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Helper function to run tests on cactching mons + * + * @remarks + * - Starts a run on the desired game mode, then attempts to throw a ball + * - If still in the command phase (meaning the ball did not catch) uses a move to proceed + * - If expecting success, checks that party length has increased by 1 + * - Otherwise, checks that {@link i18next} has been called on the requested error key + * + * @param game {@link GameManager} The game manager from the test + * @param ball {@link PokeballType} The type of pokéball to be used for the catch attempt + * @param expectedResult {@link string} Either "success", if the enemy should be caught, or the expected locales error key + * @param mode One between "classic", "daily", and "challenge", defaults to "classic". + */ +async function runPokeballTest( + game: GameManager, + ball: PokeballType, + expectedResult: string, + mode: "classic" | "daily" | "challenge" = "classic", +) { + if (mode === "classic") { + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + } else if (mode === "daily") { + // Have to do it this way because daily run is weird... + await game.runToFinalBossEncounter([SpeciesId.MAGIKARP], GameModes.DAILY); + } else if (mode === "challenge") { + await game.challengeMode.startBattle([SpeciesId.MAGIKARP]); + } + + const partyLength = game.scene.getPlayerParty().length; + + game.scene.pokeballCounts[ball] = 1; + + const tSpy = vi.spyOn(i18next, "t").mockImplementation((key: string) => key); + + game.doThrowPokeball(ball); + + // If still in the command phase due to ball failing, use a move to go on + if (game.isCurrentPhase("CommandPhase")) { + game.move.select(MoveId.SPLASH); + } + + await game.toEndOfTurn(); + if (expectedResult === "success") { + // Check that a mon has been caught by noticing that party length has increased + expect(game.scene.getPlayerParty()).toHaveLength(partyLength + 1); + } else { + expect(tSpy).toHaveBeenCalledWith(expectedResult); + } +} + +describe("Throwing balls in classic", () => { + 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 + .startingWave(199) + .startingBiome(BiomeId.END) + .battleStyle("single") + .moveset([MoveId.SPLASH]) + .enemyMoveset([MoveId.SPLASH]) + .startingLevel(9999); + }); + + it("throwing ball at paradox mon", async () => { + await runPokeballTest(game, PokeballType.MASTER_BALL, "battle:noPokeballForce"); + }); + + it("throwing ball at two paradox mons", async () => { + game.override.battleStyle("double"); + await runPokeballTest(game, PokeballType.MASTER_BALL, "battle:noPokeballMulti"); + }); + + it("throwing ball at previously caught paradox mon", async () => { + await game.importData("./test/test-utils/saves/everything.prsv"); + await runPokeballTest(game, PokeballType.MASTER_BALL, "success"); + }); + + it("throwing ball at final boss", async () => { + game.override.startingWave(200); + await runPokeballTest(game, PokeballType.MASTER_BALL, "battle:noPokeballForceFinalBoss"); + }); + + it("throwing rogue ball at final boss with full dex", async () => { + await game.importData("./test/test-utils/saves/everything.prsv"); + game.override.startingWave(200); + await runPokeballTest(game, PokeballType.ROGUE_BALL, "battle:noPokeballForceFinalBossCatchable"); + }); + + it("throwing master ball at final boss with full dex", async () => { + await game.importData("./test/test-utils/saves/everything.prsv"); + game.override.startingWave(200); + await runPokeballTest(game, PokeballType.MASTER_BALL, "success"); + }); +}); + +describe("Throwing balls in fresh start challenge", () => { + 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.challengeMode.addChallenge(Challenges.FRESH_START, 2, 1); + game.override + .startingWave(199) + .startingBiome(BiomeId.END) + .battleStyle("single") + .moveset([MoveId.SPLASH]) + .enemyMoveset([MoveId.SPLASH]) + .startingLevel(9999); + }); + + // Tests should give the same result as a normal classic run, except for the last one + it("throwing ball at paradox mon", async () => { + await runPokeballTest(game, PokeballType.MASTER_BALL, "battle:noPokeballForce", "challenge"); + }); + + it("throwing ball at previously caught paradox mon", async () => { + await game.importData("./test/test-utils/saves/everything.prsv"); + await runPokeballTest(game, PokeballType.MASTER_BALL, "success", "challenge"); + }); + + it("throwing ball at final boss", async () => { + game.override.startingWave(200); + await runPokeballTest(game, PokeballType.MASTER_BALL, "battle:noPokeballForceFinalBoss", "challenge"); + }); + + it("throwing rogue ball at final boss with full dex", async () => { + await game.importData("./test/test-utils/saves/everything.prsv"); + game.override.startingWave(200); + await runPokeballTest(game, PokeballType.ROGUE_BALL, "battle:noPokeballForceFinalBossCatchable", "challenge"); + }); + + // If a challenge is active, even if the dex is complete we still need to weaken the final boss to master ball it + it("throwing ball at final boss with full dex", async () => { + await game.importData("./test/test-utils/saves/everything.prsv"); + game.override.startingWave(200); + await runPokeballTest(game, PokeballType.MASTER_BALL, "battle:noPokeballForceFinalBossCatchable", "challenge"); + }); +}); + +describe("Throwing balls in full fresh start challenge", () => { + 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.challengeMode.addChallenge(Challenges.FRESH_START, 1, 1); + game.override + .startingWave(199) + .startingBiome(BiomeId.END) + .battleStyle("single") + .moveset([MoveId.SPLASH]) + .enemyMoveset([MoveId.SPLASH]) + .startingLevel(9999); + }); + + // Paradox and final boss can NEVER be caught in the full fresh start challenge + it("throwing ball at previously caught paradox mon", async () => { + await game.importData("./test/test-utils/saves/everything.prsv"); + await runPokeballTest(game, PokeballType.MASTER_BALL, "battle:noPokeballForce", "challenge"); + }); + + it("throwing ball at final boss with full dex", async () => { + await game.importData("./test/test-utils/saves/everything.prsv"); + game.override.startingWave(200); + await runPokeballTest(game, PokeballType.MASTER_BALL, "battle:noPokeballForceFinalBoss", "challenge"); + }); +}); + +describe("Throwing balls in daily run", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + vi.spyOn(pokerogueApi.daily, "getSeed").mockResolvedValue("test-seed"); + game.override + .startingWave(50) + .startingBiome(BiomeId.END) + .battleStyle("single") + .moveset([MoveId.SPLASH]) + .enemyMoveset([MoveId.SPLASH]) + .startingLevel(9999); + }); + + it("throwing ball at daily run boss", async () => { + await runPokeballTest(game, PokeballType.MASTER_BALL, "battle:noPokeballForceFinalBoss", "daily"); + }); +});