From 5f1c98cac63b6822d4ea565b09c41ca159d4a9da Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Mon, 19 May 2025 13:12:39 -0400 Subject: [PATCH] Fixed Arena Trap tests and refactored `SwitchSummonPhase` to be slightly less janky --- src/data/mixins/force-switch.ts | 14 +++- src/data/moves/move.ts | 10 +++ src/field/pokemon.ts | 2 +- src/phases/check-switch-phase.ts | 8 +-- src/phases/summon-phase.ts | 2 +- src/phases/switch-summon-phase.ts | 92 +++++++++++++++---------- test/abilities/arena_trap.test.ts | 111 +++++++++++++++++++----------- test/testUtils/gameManager.ts | 6 +- 8 files changed, 157 insertions(+), 88 deletions(-) diff --git a/src/data/mixins/force-switch.ts b/src/data/mixins/force-switch.ts index 21f3e9b3ba3..0d58aba0ec5 100644 --- a/src/data/mixins/force-switch.ts +++ b/src/data/mixins/force-switch.ts @@ -129,6 +129,10 @@ export function ForceSwitch(Base: TBase) { This ensures ability ignore effects will persist for the duration of the switch (for hazards, etc). */ + /** + * Method to handle switching out a player Pokemon. + * @param switchOutTarget - The {@linkcode PlayerPokemon} to be switched out. + */ private trySwitchPlayerPokemon(switchOutTarget: PlayerPokemon): void { // If not forced to switch, add a SwitchPhase to allow picking the next switched in Pokemon. if (this.switchType !== SwitchType.FORCE_SWITCH) { @@ -149,6 +153,10 @@ export function ForceSwitch(Base: TBase) { ); } + /** + * Method to handle switching out an opposing trainer's Pokemon. + * @param switchOutTarget - The {@linkcode EnemyPokemon} to be switched out. + */ private trySwitchTrainerPokemon(switchOutTarget: EnemyPokemon): void { // fallback for no trainer if (!globalScene.currentBattle.trainer) { @@ -169,8 +177,12 @@ export function ForceSwitch(Base: TBase) { ); } + /** + * Method to handle fleeing a wild enemy Pokemon, redirecting incoming moves to its ally as applicable. + * @param switchOutTarget - The {@linkcode EnemyPokemon} fleeing the battle. + */ private tryFleeWildPokemon(switchOutTarget: EnemyPokemon): void { - // flee wild pokemon, redirecting moves to an ally in doubles as applicable. + switchOutTarget.leaveField(true); globalScene.queueMessage( i18next.t("moveTriggers:fled", { pokemonName: getPokemonNameWithAffix(switchOutTarget) }), null, diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 763f7987887..f4cd1a9f00a 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -48,6 +48,7 @@ import { ConfusionOnStatusEffectAbAttr, FieldMoveTypePowerBoostAbAttr, FieldPreventExplosiveMovesAbAttr, + ForceSwitchOutImmunityAbAttr, HealFromBerryUseAbAttr, IgnoreContactAbAttr, IgnoreMoveEffectsAbAttr, @@ -6222,6 +6223,7 @@ export class RevivalBlessingAttr extends MoveEffectAttr { /** * Attribute to forcibly switch out the user or target of a Move. */ +// TODO: Add custom failure text & locales export class ForceSwitchOutAttr extends ForceSwitch(MoveEffectAttr) { constructor( selfSwitch: boolean = false, @@ -6279,6 +6281,14 @@ export class ForceSwitchOutAttr extends ForceSwitch(MoveEffectAttr) { }; } + getFailedText(_user: Pokemon, target: Pokemon, _move: Move): string | undefined { + const blockedByAbility = new BooleanHolder(false); + applyAbAttrs(ForceSwitchOutImmunityAbAttr, target, blockedByAbility); + if (blockedByAbility.value) { + return i18next.t("moveTriggers:cannotBeSwitchedOut", { pokemonName: getPokemonNameWithAffix(target) }); + } + } + getUserBenefitScore(user: Pokemon, target: Pokemon, move: Move): number { const reservePartyMembers = globalScene.getBackupPartyMemberIndices(user.isPlayer() === this.selfSwitch, !user.isPlayer() ? (user as EnemyPokemon).trainerSlot : undefined) if (reservePartyMembers.length === 0) { diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index ed223926f92..e7fe49b8341 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -342,7 +342,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { public luck: number; public pauseEvolutions: boolean; public pokerus: boolean; - // TODO: Document these + /** Whether this Pokemon is currently attempting to switch in. */ public switchOutStatus = false; public evoCounter: number; public teraType: PokemonType; diff --git a/src/phases/check-switch-phase.ts b/src/phases/check-switch-phase.ts index 9d73411fd37..2251556c47a 100644 --- a/src/phases/check-switch-phase.ts +++ b/src/phases/check-switch-phase.ts @@ -39,16 +39,12 @@ export class CheckSwitchPhase extends BattlePhase { } // ...if there are no other allowed Pokemon in the player's party to switch with - if ( - !globalScene - .getPlayerParty() - .slice(1) - .filter(p => p.isActive()).length - ) { + if (globalScene.getBackupPartyMemberIndices(true).length === 0) { return super.end(); } // ...or if any player Pokemon has an effect that prevents the checked Pokemon from switching + // TODO: Ignore trapping check if baton item is held (since those bypass trapping) if ( pokemon.getTag(BattlerTagType.FRENZY) || pokemon.isTrapped() || diff --git a/src/phases/summon-phase.ts b/src/phases/summon-phase.ts index c217583f163..c372e538d21 100644 --- a/src/phases/summon-phase.ts +++ b/src/phases/summon-phase.ts @@ -276,7 +276,7 @@ export class SummonPhase extends PartyMemberPokemonPhase { globalScene.unshiftPhase(new ShinySparklePhase(pokemon.getBattlerIndex())); } - pokemon.resetTurnData(); + pokemon.resetTurnData(); // TODO: this can probably be removed...??? if ( !this.loaded || diff --git a/src/phases/switch-summon-phase.ts b/src/phases/switch-summon-phase.ts index edf1e127f3e..9b6e8250a9a 100644 --- a/src/phases/switch-summon-phase.ts +++ b/src/phases/switch-summon-phase.ts @@ -19,16 +19,17 @@ import { SwitchType } from "#enums/switch-type"; export class SwitchSummonPhase extends SummonPhase { private readonly switchType: SwitchType; - private readonly slotIndex: number; private readonly doReturn: boolean; + private slotIndex: number; private lastPokemon: Pokemon; /** - * Constructor for creating a new {@linkcode SwitchSummonPhase}, the phase where player and enemy Pokemon are switched out. + * Constructor for creating a new {@linkcode SwitchSummonPhase}, the phase where player and enemy Pokemon are switched out + * and replaced by another Pokemon from the same party. * @param switchType - The type of switch behavior - * @param fieldIndex - Position on the battle field - * @param slotIndex - The index of pokemon (in party of 6) to switch into + * @param fieldIndex - The position on field of the Pokemon being switched out + * @param slotIndex - The 0-indexed party position of the Pokemon switching in, or `-1` to use the default trainer switch logic. * @param doReturn - Whether to render "comeback" dialogue * @param player - Whether the switch came from the player or enemy; default `true` */ @@ -40,6 +41,9 @@ export class SwitchSummonPhase extends SummonPhase { this.doReturn = doReturn; } + // TODO: This is calling `applyPreSummonAbAttrs` both far too early and on the wrong pokemon; + // `super.start` calls applyPreSummonAbAttrs(PreSummonAbAttr, this.getPokemon()), + // and `this.getPokemon` is the pokemon SWITCHING OUT, NOT IN start(): void { super.start(); } @@ -47,36 +51,35 @@ export class SwitchSummonPhase extends SummonPhase { preSummon(): void { const switchOutPokemon = this.getPokemon(); - // if the target is still on-field, remove it and/or hide its info container. - // Effects are kept to be transferred to the new Pokemon if applicable - // TODO: Make moves that switch out pokemon defer to this phase - if (switchOutPokemon.isOnField()) { - switchOutPokemon.leaveField(false, switchOutPokemon.getBattleInfo()?.visible); - } - - if (!this.player) { + // For enemy trainers, pick a pokemon to switch to and/or display the opposing pokeball tray + if (!this.player && globalScene.currentBattle.trainer) { if (this.slotIndex === -1) { - //@ts-ignore - this.slotIndex = globalScene.currentBattle.trainer?.getNextSummonIndex( - !this.fieldIndex ? TrainerSlot.TRAINER : TrainerSlot.TRAINER_PARTNER, - ); // TODO: what would be the default trainer-slot fallback? + this.slotIndex = globalScene.currentBattle.trainer.getNextSummonIndex(this.getTrainerSlotFromFieldIndex()); } + // TODO: Remove this check since `getNextSummonIndex` _should_ always return a number between 0 and party length inclusive if (this.slotIndex > -1) { - this.showEnemyTrainer(!(this.fieldIndex % 2) ? TrainerSlot.TRAINER : TrainerSlot.TRAINER_PARTNER); + this.showEnemyTrainer(this.getTrainerSlotFromFieldIndex()); globalScene.pbTrayEnemy.showPbTray(globalScene.getEnemyParty()); } } if ( !this.doReturn || + // TODO: this part of the check need not exist `- `switchAndSummon` returns near immediately if we have no pokemon to switch into (this.slotIndex !== -1 && !(this.player ? globalScene.getPlayerParty() : globalScene.getEnemyParty())[this.slotIndex]) ) { + // If the target is still on-field, remove it and/or hide its info container. + // Effects are kept to be transferred to the new Pokemon later on. + if (switchOutPokemon.isOnField()) { + switchOutPokemon.leaveField(false, switchOutPokemon.getBattleInfo()?.visible); + } + if (this.player) { this.switchAndSummon(); - return; + } else { + globalScene.time.delayedCall(750, () => this.switchAndSummon()); } - globalScene.time.delayedCall(750, () => this.switchAndSummon()); return; } @@ -84,7 +87,8 @@ export class SwitchSummonPhase extends SummonPhase { enemyPokemon.removeTagsBySourceId(switchOutPokemon.id), ); - if (this.switchType === SwitchType.SWITCH || this.switchType === SwitchType.INITIAL_SWITCH) { + // If not transferring a substitute, play animation to remove it from the field + if (!this.shouldKeepEffects()) { const substitute = switchOutPokemon.getTag(SubstituteTag); if (substitute) { globalScene.tweens.add({ @@ -103,9 +107,7 @@ export class SwitchSummonPhase extends SummonPhase { pokemonName: getPokemonNameWithAffix(switchOutPokemon), }) : i18next.t("battle:trainerComeBack", { - trainerName: globalScene.currentBattle.trainer?.getName( - !(this.fieldIndex % 2) ? TrainerSlot.TRAINER : TrainerSlot.TRAINER_PARTNER, - ), + trainerName: globalScene.currentBattle.trainer?.getName(this.getTrainerSlotFromFieldIndex()), pokemonName: switchOutPokemon.getNameToRender(), }), ); @@ -119,19 +121,21 @@ export class SwitchSummonPhase extends SummonPhase { scale: 0.5, onComplete: () => { globalScene.time.delayedCall(750, () => this.switchAndSummon()); - switchOutPokemon.leaveField(this.switchType === SwitchType.SWITCH, false); + switchOutPokemon.leaveField(this.switchType === SwitchType.SWITCH, false); // TODO: do we have to do this right here right now }, }); } switchAndSummon() { const party = this.player ? this.getParty() : globalScene.getEnemyParty(); - const switchedInPokemon: Pokemon | undefined = party[this.slotIndex]; + const switchInPokemon: Pokemon | undefined = party[this.slotIndex]; this.lastPokemon = this.getPokemon(); - applyPreSummonAbAttrs(PreSummonAbAttr, switchedInPokemon); + applyPreSummonAbAttrs(PreSummonAbAttr, switchInPokemon); applyPreSwitchOutAbAttrs(PreSwitchOutAbAttr, this.lastPokemon); - if (!switchedInPokemon) { + // TODO: Why do we trigger post switch out attributes even if the switch in target doesn't exist? + // (This should almost certainly go somewhere inside `preSummon`) + if (!switchInPokemon) { this.end(); return; } @@ -139,7 +143,7 @@ export class SwitchSummonPhase extends SummonPhase { if (this.switchType === SwitchType.BATON_PASS) { // If switching via baton pass, update opposing tags coming from the prior pokemon (this.player ? globalScene.getEnemyField() : globalScene.getPlayerField()).forEach((enemyPokemon: Pokemon) => - enemyPokemon.transferTagsBySourceId(this.lastPokemon.id, switchedInPokemon.id), + enemyPokemon.transferTagsBySourceId(this.lastPokemon.id, switchInPokemon.id), ); // If the recipient pokemon lacks a baton, give our baton to it during the swap @@ -147,7 +151,7 @@ export class SwitchSummonPhase extends SummonPhase { !globalScene.findModifier( m => m instanceof SwitchEffectTransferModifier && - (m as SwitchEffectTransferModifier).pokemonId === switchedInPokemon.id, + (m as SwitchEffectTransferModifier).pokemonId === switchInPokemon.id, ) ) { const batonPassModifier = globalScene.findModifier( @@ -159,7 +163,7 @@ export class SwitchSummonPhase extends SummonPhase { if (batonPassModifier) { globalScene.tryTransferHeldItemModifier( batonPassModifier, - switchedInPokemon, + switchInPokemon, false, undefined, undefined, @@ -171,12 +175,14 @@ export class SwitchSummonPhase extends SummonPhase { } party[this.slotIndex] = this.lastPokemon; - party[this.fieldIndex] = switchedInPokemon; + party[this.fieldIndex] = switchInPokemon; + // TODO: Make this text configurable for Dragon Tail & co. + // TODO: Make this a method const showTextAndSummon = () => { globalScene.ui.showText( this.player ? i18next.t("battle:playerGo", { - pokemonName: getPokemonNameWithAffix(switchedInPokemon), + pokemonName: getPokemonNameWithAffix(switchInPokemon), }) : i18next.t("battle:trainerGo", { trainerName: globalScene.currentBattle.trainer?.getName( @@ -190,15 +196,15 @@ export class SwitchSummonPhase extends SummonPhase { * If this switch is passing a Substitute, make the switched Pokemon matches the returned Pokemon's state as it left. * Otherwise, clear any persisting tags on the returned Pokemon. */ - if (this.switchType === SwitchType.BATON_PASS || this.switchType === SwitchType.SHED_TAIL) { + if (this.shouldKeepEffects()) { const substitute = this.lastPokemon.getTag(SubstituteTag); if (substitute) { - switchedInPokemon.x += this.lastPokemon.getSubstituteOffset()[0]; - switchedInPokemon.y += this.lastPokemon.getSubstituteOffset()[1]; - switchedInPokemon.setAlpha(0.5); + switchInPokemon.x += this.lastPokemon.getSubstituteOffset()[0]; + switchInPokemon.y += this.lastPokemon.getSubstituteOffset()[1]; + switchInPokemon.setAlpha(0.5); } } else { - switchedInPokemon.fieldSetup(); + switchInPokemon.fieldSetup(); } this.summon(); }; @@ -253,4 +259,16 @@ export class SwitchSummonPhase extends SummonPhase { queuePostSummon(): void { globalScene.unshiftPhase(new PostSummonPhase(this.getPokemon().getBattlerIndex())); } + + private shouldKeepEffects(): boolean { + return [SwitchType.BATON_PASS, SwitchType.SHED_TAIL].includes(this.switchType); + } + + private getTrainerSlotFromFieldIndex(): TrainerSlot { + return this.player || !globalScene.currentBattle.trainer + ? TrainerSlot.NONE + : this.fieldIndex % 2 === 0 + ? TrainerSlot.TRAINER + : TrainerSlot.TRAINER_PARTNER; + } } diff --git a/test/abilities/arena_trap.test.ts b/test/abilities/arena_trap.test.ts index f37b8a2859f..9a27af3bde9 100644 --- a/test/abilities/arena_trap.test.ts +++ b/test/abilities/arena_trap.test.ts @@ -1,10 +1,15 @@ import { allAbilities } from "#app/data/data-lists"; +import { getPokemonNameWithAffix } from "#app/messages"; +import type CommandUiHandler from "#app/ui/command-ui-handler"; import { Abilities } from "#enums/abilities"; +import { Button } from "#enums/buttons"; import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; +import { UiMode } from "#enums/ui-mode"; import GameManager from "#test/testUtils/gameManager"; +import i18next from "i18next"; import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, it, expect, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, it, expect } from "vitest"; describe("Abilities - Arena Trap", () => { let phaserGame: Phaser.Game; @@ -23,68 +28,94 @@ describe("Abilities - Arena Trap", () => { beforeEach(() => { game = new GameManager(phaserGame); game.override - .moveset(Moves.SPLASH) + .moveset([Moves.SPLASH, Moves.TELEPORT]) .ability(Abilities.ARENA_TRAP) .enemySpecies(Species.RALTS) - .enemyAbility(Abilities.BALL_FETCH) - .enemyMoveset(Moves.TELEPORT); + .enemyAbility(Abilities.ARENA_TRAP) + .enemyMoveset(Moves.SPLASH); }); - // TODO: Enable test when Issue #935 is addressed - it.todo("should not allow grounded Pokémon to flee", async () => { + // NB: Since switching moves bypass trapping, the only way fleeing can occur is from the player + // TODO: Implement once forced flee helper exists + it.todo("should interrupt player flee attempt and display message, unless user has Run Away", async () => { game.override.battleStyle("single"); + await game.classicMode.startBattle([Species.DUGTRIO, Species.GOTHITELLE]); - await game.classicMode.startBattle(); + const enemy = game.scene.getEnemyPokemon()!; - const enemy = game.scene.getEnemyPokemon(); + game.onNextPrompt("CommandPhase", UiMode.COMMAND, () => { + // no switch out command should be queued due to arena trap + expect(game.scene.currentBattle.turnCommands[0]).toBeNull(); - game.move.select(Moves.SPLASH); + // back out and cancel the flee to avoid timeout + (game.scene.ui.getHandler() as CommandUiHandler).processInput(Button.CANCEL); + game.move.select(Moves.SPLASH); + }); + + await game.toNextTurn(); + expect(game.textInterceptor.logs).toContain( + i18next.t("abilityTriggers:arenaTrap", { + pokemonNameWithAffix: getPokemonNameWithAffix(enemy), + abilityName: allAbilities[Abilities.ARENA_TRAP].name, + }), + ); + + game.override.ability(Abilities.RUN_AWAY); + + // do switch stuff await game.toNextTurn(); - expect(enemy).toBe(game.scene.getEnemyPokemon()); + expect(game.scene.currentBattle.waveIndex).toBe(2); + }); + + it("should interrupt player switch attempt and display message", async () => { + game.override.battleStyle("single").enemyAbility(Abilities.ARENA_TRAP); + + await game.classicMode.startBattle([Species.DUGTRIO, Species.GOTHITELLE]); + + const enemy = game.scene.getEnemyPokemon()!; + + game.doSwitchPokemon(1); + game.onNextPrompt("CommandPhase", UiMode.PARTY, () => { + // no switch out command should be queued due to arena trap + expect(game.scene.currentBattle.turnCommands[0]).toBeNull(); + + // back out and cancel the switch to avoid timeout + (game.scene.ui.getHandler() as CommandUiHandler).processInput(Button.CANCEL); + game.move.select(Moves.SPLASH); + }); + + await game.toNextTurn(); + expect(game.textInterceptor.logs).toContain( + i18next.t("abilityTriggers:arenaTrap", { + pokemonNameWithAffix: getPokemonNameWithAffix(enemy), + abilityName: allAbilities[Abilities.ARENA_TRAP].name, + }), + ); }); it("should guarantee double battle with any one LURE", async () => { game.override.startingModifier([{ name: "LURE" }]).startingWave(2); + await game.classicMode.startBattle([Species.DUGTRIO]); - await game.classicMode.startBattle(); - - expect(game.scene.getEnemyField().length).toBe(2); + expect(game.scene.getEnemyField()).toHaveLength(2); }); - /** - * This checks if the Player Pokemon is able to switch out/run away after the Enemy Pokemon with {@linkcode Abilities.ARENA_TRAP} - * is forcefully moved out of the field from moves such as Roar {@linkcode Moves.ROAR} - * - * Note: It should be able to switch out/run away - */ it("should lift if pokemon with this ability leaves the field", async () => { - game.override - .battleStyle("double") - .enemyMoveset(Moves.SPLASH) - .moveset([Moves.ROAR, Moves.SPLASH]) - .ability(Abilities.BALL_FETCH); - await game.classicMode.startBattle([Species.MAGIKARP, Species.SUDOWOODO, Species.LUNATONE]); + game.override.battleStyle("single").enemyMoveset(Moves.SPLASH).moveset(Moves.ROAR); + await game.classicMode.startBattle([Species.MAGIKARP]); - const [enemy1, enemy2] = game.scene.getEnemyField(); - const [player1, player2] = game.scene.getPlayerField(); + const player = game.scene.getPlayerPokemon()!; + const enemy = game.scene.getEnemyPokemon()!; - vi.spyOn(enemy1, "getAbility").mockReturnValue(allAbilities[Abilities.ARENA_TRAP]); + expect(player.isTrapped()).toBe(true); + expect(enemy.isOnField()).toBe(true); game.move.select(Moves.ROAR); - game.move.select(Moves.SPLASH, 1); + await game.phaseInterceptor.to("TurnEndPhase"); - // This runs the fist command phase where the moves are selected - await game.toNextTurn(); - // During the next command phase the player pokemons should not be trapped anymore - game.move.select(Moves.SPLASH); - game.move.select(Moves.SPLASH, 1); - await game.toNextTurn(); - - expect(player1.isTrapped()).toBe(false); - expect(player2.isTrapped()).toBe(false); - expect(enemy1.isOnField()).toBe(false); - expect(enemy2.isOnField()).toBe(true); + expect(player.isTrapped()).toBe(false); + expect(enemy.isOnField()).toBe(false); }); }); diff --git a/test/testUtils/gameManager.ts b/test/testUtils/gameManager.ts index 8dd90decf1a..a3dc6d03ab5 100644 --- a/test/testUtils/gameManager.ts +++ b/test/testUtils/gameManager.ts @@ -165,6 +165,8 @@ export default class GameManager { * @param mode - The mode to wait for. * @param callback - The callback function to execute on next prompt. * @param expireFn - Optional function to determine if the prompt has expired. + * @remarks + * If multiple callbacks are queued for the same phase, they will be executed in the order they were added. */ onNextPrompt( phaseTarget: string, @@ -541,8 +543,8 @@ export default class GameManager { } /** - * Select a pokemon from the party menu during the given phase. - * Only really handles the basic case of "navigate to party slot and press Action twice" - + * Select a pokemon from the party menu during the given phase. + * Only really handles the basic case of "navigate to party slot and press Action twice" - * any menus that come up afterwards are ignored and must be handled separately by the caller. * @param slot - The 0-indexed position of the pokemon in your party to switch to * @param inPhase - Which phase to expect the selection to occur in. Defaults to `SwitchPhase`