From 4e080465b9a1bea6bc8e9f1a06c471abaf981964 Mon Sep 17 00:00:00 2001 From: Bertie690 <136088738+Bertie690@users.noreply.github.com> Date: Fri, 14 Nov 2025 03:27:53 -0500 Subject: [PATCH] [Bug] Fix Ball Fetch activating on enemy Pokemon (#6777) --- src/data/abilities/ability.ts | 18 +++--- test/abilities/ball-fetch.test.ts | 92 +++++++++++++++++++++++++++++++ test/test-utils/game-manager.ts | 10 +++- 3 files changed, 110 insertions(+), 10 deletions(-) create mode 100644 test/abilities/ball-fetch.test.ts diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index 21a00e53aed..0036075f04a 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -2707,6 +2707,7 @@ export class PostSummonAddArenaTagAbAttr extends PostSummonAbAttr { private readonly turnCount: number; private readonly side?: ArenaTagSide; private readonly quiet?: boolean; + // TODO: This should not need to track the source ID in a tempvar private sourceId: number; constructor(showAbility: boolean, tagType: ArenaTagType, turnCount: number, side?: ArenaTagSide, quiet?: boolean) { @@ -2741,6 +2742,7 @@ export class PostSummonMessageAbAttr extends PostSummonAbAttr { } } +// TODO: This should be merged with message func export class PostSummonUnnamedMessageAbAttr extends PostSummonAbAttr { //Attr doesn't force pokemon name on the message private readonly message: string; @@ -2811,13 +2813,13 @@ export class PostSummonStatStageChangeAbAttr extends PostSummonAbAttr { private readonly selfTarget: boolean; private readonly intimidate: boolean; - constructor(stats: readonly BattleStat[], stages: number, selfTarget?: boolean, intimidate?: boolean) { + constructor(stats: readonly BattleStat[], stages: number, selfTarget = false, intimidate = true) { super(true); this.stats = stats; this.stages = stages; - this.selfTarget = !!selfTarget; - this.intimidate = !!intimidate; + this.selfTarget = selfTarget; + this.intimidate = intimidate; } override apply({ pokemon, simulated }: AbAttrBaseParams): void { @@ -5012,25 +5014,26 @@ export class PostTurnHurtIfSleepingAbAttr extends PostTurnAbAttr { */ export class FetchBallAbAttr extends PostTurnAbAttr { override canApply({ simulated, pokemon }: AbAttrBaseParams): boolean { - return !simulated && globalScene.currentBattle.lastUsedPokeball != null && !!pokemon.isPlayer; + return !simulated && globalScene.currentBattle.lastUsedPokeball != null && pokemon.isPlayer(); } /** * Adds the last used Pokeball back into the player's inventory */ override apply({ pokemon }: AbAttrBaseParams): void { - const lastUsed = globalScene.currentBattle.lastUsedPokeball; - globalScene.pokeballCounts[lastUsed!]++; + const lastUsed = globalScene.currentBattle.lastUsedPokeball!; + globalScene.pokeballCounts[lastUsed]++; globalScene.currentBattle.lastUsedPokeball = null; globalScene.phaseManager.queueMessage( i18next.t("abilityTriggers:fetchBall", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - pokeballName: getPokeballName(lastUsed!), + pokeballName: getPokeballName(lastUsed), }), ); } } +// TODO: Remove this and just replace it with applying `PostSummonChangeTerrainAbAttr` again export class PostBiomeChangeAbAttr extends AbAttr { private declare readonly _: never; } @@ -5055,6 +5058,7 @@ export class PostBiomeChangeWeatherChangeAbAttr extends PostBiomeChangeAbAttr { } } +// TODO: Remove this and just replace it with applying `PostSummonChangeTerrainAbAttr` again /** @sealed */ export class PostBiomeChangeTerrainChangeAbAttr extends PostBiomeChangeAbAttr { private readonly terrainType: TerrainType; diff --git a/test/abilities/ball-fetch.test.ts b/test/abilities/ball-fetch.test.ts new file mode 100644 index 00000000000..e322cc1d568 --- /dev/null +++ b/test/abilities/ball-fetch.test.ts @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: 2024-2025 Pagefault Games + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { getPokemonNameWithAffix } from "#app/messages"; +import { getPokeballName } from "#data/pokeball"; +import { AbilityId } from "#enums/ability-id"; +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"; + +// NB: These tests pass when done locally, but we currently have no mechanism to make catches fail +// due to battle scene RNG overrides making ball shake checks always succeed. +// +// TODO: Enable suite once `AttemptCapturePhase` is made sane +describe.todo("Ability - Ball Fetch", () => { + 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") + .criticalHits(false) + .enemySpecies(SpeciesId.MAGIKARP) + .enemyMoveset(MoveId.SPLASH) + .startingLevel(100) + .enemyLevel(100); + }); + + it("should restore the user's first failed ball throw at end of turn", async () => { + await game.classicMode.startBattle([SpeciesId.FEEBAS]); + + const feebas = game.field.getPlayerPokemon(); + const karp = game.field.getEnemyPokemon(); + + vi.spyOn(karp.species, "catchRate", "get").mockReturnValue(0); + + game.doThrowPokeball(PokeballType.POKEBALL); + await game.toEndOfTurn(false); + + expect(game.scene.pokeballCounts[PokeballType.POKEBALL]).toBe(4); + + await game.toEndOfTurn(); + + expect(feebas).toHaveAbilityApplied(AbilityId.BALL_FETCH); + expect(game.scene.pokeballCounts[PokeballType.POKEBALL]).toBe(5); + expect(game).toHaveShownMessage( + i18next.t("abilityTriggers:fetchBall", { + pokemonNameWithAffix: getPokemonNameWithAffix(feebas), + pokeballName: getPokeballName(PokeballType.POKEBALL), + }), + ); + }); + + it("should not work on enemies", async () => { + game.override.ability(AbilityId.AIR_LOCK).enemyAbility(AbilityId.BALL_FETCH); + await game.classicMode.startBattle([SpeciesId.FEEBAS]); + + const karp = game.field.getEnemyPokemon(); + + vi.spyOn(karp.species, "catchRate", "get").mockReturnValue(0); + + game.doThrowPokeball(PokeballType.POKEBALL); + await game.toEndOfTurn(false); + + expect(game.scene.pokeballCounts[PokeballType.POKEBALL]).toBe(4); + + await game.toEndOfTurn(); + + // did nothing; still at 4 balls + expect(karp).not.toHaveAbilityApplied(AbilityId.BALL_FETCH); + expect(game.scene.pokeballCounts[PokeballType.POKEBALL]).toBe(4); + }); +}); diff --git a/test/test-utils/game-manager.ts b/test/test-utils/game-manager.ts index aab7ebabb2e..1106c22c13f 100644 --- a/test/test-utils/game-manager.ts +++ b/test/test-utils/game-manager.ts @@ -372,9 +372,13 @@ export class GameManager { console.log("==================[New Turn]=================="); } - /** Transition to the {@linkcode TurnEndPhase | end of the current turn}. */ - async toEndOfTurn() { - await this.phaseInterceptor.to("TurnEndPhase"); + /** + * Transition to the {@linkcode TurnEndPhase | end of the current turn}. + * @param endTurn - Whether to run the `TurnEndPhase` or not; default `true` + * @returns A Promise that resolves once the current turn has ended. + */ + async toEndOfTurn(endTurn = true): Promise { + await this.phaseInterceptor.to("TurnEndPhase", endTurn); console.log("==================[End of Turn]=================="); }