From 309e31e196f005911b65eecc3fba8828e3e73b47 Mon Sep 17 00:00:00 2001 From: Bertie690 <136088738+Bertie690@users.noreply.github.com> Date: Fri, 5 Sep 2025 14:38:01 -0400 Subject: [PATCH] [Bug] Future Sight no longer crashes after catching the user (#6479) --- src/battle-scene.ts | 2 + src/data/battle-anims.ts | 2 +- src/data/positional-tags/positional-tag.ts | 4 +- test/moves/delayed-attack.test.ts | 69 ++++++++++++++++------ test/test-utils/phase-interceptor.ts | 2 + 5 files changed, 58 insertions(+), 21 deletions(-) diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 9ac6e385220..3a1de1bcc43 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -863,6 +863,8 @@ export class BattleScene extends SceneBase { * @param pokemonId - The ID whose Pokemon will be retrieved. * @returns The {@linkcode Pokemon} associated with the given id. * Returns `null` if the ID is `undefined` or not present in either party. + * @todo Change the `null` to `undefined` and update callers' signatures - + * this is weird and causes a lot of random jank */ getPokemonById(pokemonId: number | undefined): Pokemon | null { if (isNullOrUndefined(pokemonId)) { diff --git a/src/data/battle-anims.ts b/src/data/battle-anims.ts index 5ff4472d148..85b15c934be 100644 --- a/src/data/battle-anims.ts +++ b/src/data/battle-anims.ts @@ -835,7 +835,7 @@ export abstract class BattleAnim { // biome-ignore lint/complexity/noBannedTypes: callback is used liberally play(onSubstitute?: boolean, callback?: Function) { const isOppAnim = this.isOppAnim(); - const user = !isOppAnim ? this.user! : this.target!; // TODO: are those bangs correct? + const user = !isOppAnim ? this.user! : this.target!; // TODO: These bangs are LITERALLY not correct at all const target = !isOppAnim ? this.target! : this.user!; if (!target?.isOnField() && !this.playRegardlessOfIssues) { diff --git a/src/data/positional-tags/positional-tag.ts b/src/data/positional-tags/positional-tag.ts index 77ca6f0e9eb..a877b45b045 100644 --- a/src/data/positional-tags/positional-tag.ts +++ b/src/data/positional-tags/positional-tag.ts @@ -126,7 +126,9 @@ export class DelayedAttackTag extends PositionalTag implements DelayedAttackArgs // Silently disappear if either source or target are missing or happen to be the same pokemon // (i.e. targeting oneself) // We also need to check for fainted targets as they don't technically leave the field until _after_ the turn ends - return !!source && !!target && source !== target && !target.isFainted(); + // TODO: Figure out a way to store the target's offensive stat if they faint to allow pending attacks to persist + // TODO: Remove the `?.scene` checks once battle anims are cleaned up - needed to avoid catch+release crash + return !!source?.scene && !!target?.scene && source !== target && !target.isFainted(); } } diff --git a/test/moves/delayed-attack.test.ts b/test/moves/delayed-attack.test.ts index e8cf2871626..420ef6d1f00 100644 --- a/test/moves/delayed-attack.test.ts +++ b/test/moves/delayed-attack.test.ts @@ -4,12 +4,15 @@ import { allMoves } from "#data/data-lists"; import { AbilityId } from "#enums/ability-id"; import { BattleType } from "#enums/battle-type"; import { BattlerIndex } from "#enums/battler-index"; +import { Button } from "#enums/buttons"; import { MoveId } from "#enums/move-id"; import { MoveResult } from "#enums/move-result"; +import { PokeballType } from "#enums/pokeball"; import { PokemonType } from "#enums/pokemon-type"; import { PositionalTagType } from "#enums/positional-tag-type"; import { SpeciesId } from "#enums/species-id"; import { Stat } from "#enums/stat"; +import { UiMode } from "#enums/ui-mode"; import { GameManager } from "#test/test-utils/game-manager"; import i18next from "i18next"; import Phaser from "phaser"; @@ -95,7 +98,7 @@ describe("Moves - Delayed Attacks", () => { expectFutureSightActive(0); const enemy = game.field.getEnemyPokemon(); - expect(enemy.hp).toBeLessThan(enemy.getMaxHp()); + expect(enemy).not.toHaveFullHp(); expect(game.textInterceptor.logs).toContain( i18next.t("moveTriggers:tookMoveAttack", { pokemonName: getPokemonNameWithAffix(enemy), @@ -130,12 +133,12 @@ describe("Moves - Delayed Attacks", () => { expectFutureSightActive(); const enemy = game.field.getEnemyPokemon(); - expect(enemy.hp).toBe(enemy.getMaxHp()); + expect(enemy).toHaveFullHp(); await passTurns(2); expectFutureSightActive(0); - expect(enemy.hp).toBeLessThan(enemy.getMaxHp()); + expect(enemy).not.toHaveFullHp(); }); it("should work when used against different targets in doubles", async () => { @@ -149,15 +152,15 @@ describe("Moves - Delayed Attacks", () => { await game.toEndOfTurn(); expectFutureSightActive(2); - expect(enemy1.hp).toBe(enemy1.getMaxHp()); - expect(enemy2.hp).toBe(enemy2.getMaxHp()); + expect(enemy1).toHaveFullHp(); + expect(enemy2).toHaveFullHp(); expect(karp.getLastXMoves()[0].result).toBe(MoveResult.OTHER); expect(feebas.getLastXMoves()[0].result).toBe(MoveResult.OTHER); await passTurns(2); - expect(enemy1.hp).toBeLessThan(enemy1.getMaxHp()); - expect(enemy2.hp).toBeLessThan(enemy2.getMaxHp()); + expect(enemy1).not.toHaveFullHp(); + expect(enemy2).not.toHaveFullHp(); }); it("should trigger multiple pending attacks in order of creation, even if that order changes later on", async () => { @@ -222,8 +225,8 @@ describe("Moves - Delayed Attacks", () => { expect(game.scene.getPlayerParty()).toEqual([milotic, karp, feebas]); - expect(karp.hp).toBe(karp.getMaxHp()); - expect(feebas.hp).toBe(feebas.getMaxHp()); + expect(karp).toHaveFullHp(); + expect(feebas).toHaveFullHp(); expect(game.textInterceptor.logs).not.toContain( i18next.t("moveTriggers:tookMoveAttack", { pokemonName: getPokemonNameWithAffix(karp), @@ -245,15 +248,14 @@ describe("Moves - Delayed Attacks", () => { expect(enemy2.isFainted()).toBe(true); expectFutureSightActive(); - const attack = game.scene.arena.positionalTagManager.tags.find( - t => t.tagType === PositionalTagType.DELAYED_ATTACK, - )!; - expect(attack).toBeDefined(); - expect(attack.targetIndex).toBe(enemy1.getBattlerIndex()); + expect(game).toHavePositionalTag({ + tagType: PositionalTagType.DELAYED_ATTACK, + targetIndex: enemy1.getBattlerIndex(), + }); await passTurns(2); - expect(enemy1.hp).toBeLessThan(enemy1.getMaxHp()); + expect(enemy1).not.toHaveFullHp(); expect(game.textInterceptor.logs).toContain( i18next.t("moveTriggers:tookMoveAttack", { pokemonName: getPokemonNameWithAffix(enemy1), @@ -281,7 +283,7 @@ describe("Moves - Delayed Attacks", () => { await game.toNextTurn(); expectFutureSightActive(0); - expect(enemy1.hp).toBe(enemy1.getMaxHp()); + expect(enemy1).toHaveFullHp(); expect(game.textInterceptor.logs).not.toContain( i18next.t("moveTriggers:tookMoveAttack", { pokemonName: getPokemonNameWithAffix(enemy1), @@ -317,8 +319,8 @@ describe("Moves - Delayed Attacks", () => { await game.toEndOfTurn(); - expect(enemy1.hp).toBe(enemy1.getMaxHp()); - expect(enemy2.hp).toBeLessThan(enemy2.getMaxHp()); + expect(enemy1).toHaveFullHp(); + expect(enemy2).not.toHaveFullHp(); expect(game.textInterceptor.logs).toContain( i18next.t("moveTriggers:tookMoveAttack", { pokemonName: getPokemonNameWithAffix(enemy2), @@ -351,7 +353,7 @@ describe("Moves - Delayed Attacks", () => { // Player Normalize was not applied due to being off field const enemy = game.field.getEnemyPokemon(); - expect(enemy.hp).toBeLessThan(enemy.getMaxHp()); + expect(enemy).not.toHaveFullHp(); expect(game.textInterceptor.logs).toContain( i18next.t("moveTriggers:tookMoveAttack", { pokemonName: getPokemonNameWithAffix(enemy), @@ -384,6 +386,35 @@ describe("Moves - Delayed Attacks", () => { expect(typeBoostSpy).not.toHaveBeenCalled(); }); + it("should not crash when catching & releasing a Pokemon on the same turn its delayed attack expires", async () => { + game.override.startingModifier([{ name: "MASTER_BALL", count: 1 }]); + await game.classicMode.startBattle([ + SpeciesId.FEEBAS, + SpeciesId.FEEBAS, + SpeciesId.FEEBAS, + SpeciesId.FEEBAS, + SpeciesId.FEEBAS, + SpeciesId.FEEBAS, + ]); + + game.move.use(MoveId.SPLASH); + await game.move.forceEnemyMove(MoveId.FUTURE_SIGHT); + await game.toNextTurn(); + + expectFutureSightActive(1); + + await passTurns(1); + + // Throw master ball and release the enemy + game.doThrowPokeball(PokeballType.MASTER_BALL); + game.onNextPrompt("AttemptCapturePhase", UiMode.CONFIRM, () => { + game.scene.ui.processInput(Button.CANCEL); + }); + await game.toEndOfTurn(); + + expectFutureSightActive(0); + }); + // TODO: Implement and move to a power spot's test file it.todo("Should activate ally's power spot when switched in during single battles"); }); diff --git a/test/test-utils/phase-interceptor.ts b/test/test-utils/phase-interceptor.ts index 996f00806c6..f2f11db9d12 100644 --- a/test/test-utils/phase-interceptor.ts +++ b/test/test-utils/phase-interceptor.ts @@ -1,6 +1,7 @@ import type { BattleScene } from "#app/battle-scene"; import { Phase } from "#app/phase"; import { UiMode } from "#enums/ui-mode"; +import { AttemptCapturePhase } from "#phases/attempt-capture-phase"; import { AttemptRunPhase } from "#phases/attempt-run-phase"; import { BattleEndPhase } from "#phases/battle-end-phase"; import { BerryPhase } from "#phases/berry-phase"; @@ -183,6 +184,7 @@ export class PhaseInterceptor { PostGameOverPhase, RevivalBlessingPhase, PokemonHealPhase, + AttemptCapturePhase, ]; private endBySetMode = [