diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 5a22b352e73..1c64b28fa75 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -8164,6 +8164,9 @@ const failIfGhostTypeCondition: MoveConditionFunc = (user: Pokemon, target: Poke const failIfNoTargetHeldItemsCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => target.getHeldItems().filter(i => i.isTransferable)?.length > 0; const attackedByItemMessageFunc = (user: Pokemon, target: Pokemon, move: Move) => { + if (isNullOrUndefined(target)) { // Fix bug when used against targets that have both fainted + return ""; + } const heldItems = target.getHeldItems().filter(i => i.isTransferable); if (heldItems.length === 0) { return ""; diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 9a8e509e302..dd73227a4a8 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -24,6 +24,7 @@ import { applyMoveAttrs } from "#moves/apply-attrs"; import { frenzyMissFunc } from "#moves/move-utils"; import type { PokemonMove } from "#moves/pokemon-move"; import { BattlePhase } from "#phases/battle-phase"; +import type { TurnMove } from "#types/turn-move"; import { NumberHolder } from "#utils/common"; import { enumValueToKey } from "#utils/enums"; import i18next from "i18next"; @@ -41,6 +42,13 @@ export class MovePhase extends BattlePhase { /** Whether the current move should fail and retain PP. */ protected cancelled = false; + /** The move history entry object that is pushed to the pokemon's move history + * + * @remarks + * Can be edited _after_ being pushed to the history to adjust the result, targets, etc, for this move phase. + */ + protected moveHistoryEntry: TurnMove; + public get pokemon(): Pokemon { return this._pokemon; } @@ -82,6 +90,11 @@ export class MovePhase extends BattlePhase { this.move = move; this.useMode = useMode; this.forcedLast = forcedLast; + this.moveHistoryEntry = { + move: MoveId.NONE, + targets, + useMode, + }; } /** @@ -410,13 +423,9 @@ export class MovePhase extends BattlePhase { if (showText) { this.showMoveText(); } - - this.pokemon.pushMoveHistory({ - move: this.move.moveId, - targets: this.targets, - result: MoveResult.FAIL, - useMode: this.useMode, - }); + const moveHistoryEntry = this.moveHistoryEntry; + moveHistoryEntry.result = MoveResult.FAIL; + this.pokemon.pushMoveHistory(moveHistoryEntry); // Use move-specific failure messages if present before checking terrain/weather blockage // and falling back to the classic "But it failed!". @@ -630,12 +639,9 @@ export class MovePhase extends BattlePhase { frenzyMissFunc(this.pokemon, this.move.getMove()); } - this.pokemon.pushMoveHistory({ - move: MoveId.NONE, - result: MoveResult.FAIL, - targets: this.targets, - useMode: this.useMode, - }); + const moveHistoryEntry = this.moveHistoryEntry; + moveHistoryEntry.result = MoveResult.FAIL; + this.pokemon.pushMoveHistory(moveHistoryEntry); this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT); this.pokemon.lapseTags(BattlerTagLapseType.AFTER_MOVE); @@ -649,13 +655,16 @@ export class MovePhase extends BattlePhase { * Displays the move's usage text to the player as applicable for the move being used. */ public showMoveText(): void { + const moveId = this.move.moveId; if ( - this.move.moveId === MoveId.NONE || + moveId === MoveId.NONE || this.pokemon.getTag(BattlerTagType.RECHARGING) || this.pokemon.getTag(BattlerTagType.INTERRUPTED) ) { return; } + // Showing move text always adjusts the move history entry's move id + this.moveHistoryEntry.move = moveId; // TODO: This should be done by the move... globalScene.phaseManager.queueMessage( @@ -668,7 +677,7 @@ export class MovePhase extends BattlePhase { // Moves with pre-use messages (Magnitude, Chilly Reception, Fickle Beam, etc.) always display their messages even on failure // TODO: This assumes single target for message funcs - is this sustainable? - applyMoveAttrs("PreMoveMessageAttr", this.pokemon, this.pokemon.getOpponents(false)[0], this.move.getMove()); + applyMoveAttrs("PreMoveMessageAttr", this.pokemon, this.getActiveTargetPokemon()[0], this.move.getMove()); } /** diff --git a/test/moves/poltergeist.test.ts b/test/moves/poltergeist.test.ts new file mode 100644 index 00000000000..3e603702416 --- /dev/null +++ b/test/moves/poltergeist.test.ts @@ -0,0 +1,50 @@ +import { AbilityId } from "#enums/ability-id"; +import { BattlerIndex } from "#enums/battler-index"; +import { MoveId } from "#enums/move-id"; +import { MoveResult } from "#enums/move-result"; +import { SpeciesId } from "#enums/species-id"; +import { GameManager } from "#test/test-utils/game-manager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Move - Poltergeist", () => { + 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) + .enemyAbility(AbilityId.BALL_FETCH) + .enemyMoveset(MoveId.SPLASH) + .startingLevel(100) + .enemyLevel(100); + }); + + it("should not crash when used after both opponents have fainted", async () => { + game.override.battleStyle("double").enemyLevel(5); + await game.classicMode.startBattle([SpeciesId.STARYU, SpeciesId.SLOWPOKE]); + + game.move.use(MoveId.DAZZLING_GLEAM); + game.move.use(MoveId.POLTERGEIST, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY); + const [_, poltergeistUser] = game.scene.getPlayerField(); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]); + await game.toEndOfTurn(); + // Expect poltergeist to have failed + expect(poltergeistUser).toHaveUsedMove({ move: MoveId.POLTERGEIST, result: MoveResult.FAIL }); + // If the test makes it to the end of turn, no crash occurred. Nothing to assert + }); +});