diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 5835ee08af5..32d172fc623 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -871,6 +871,51 @@ export default class BattleScene extends SceneBase { return party.slice(0, Math.min(party.length, this.currentBattle?.double ? 2 : 1)); } + /** + * Return all {@linkcode Pokemon} that are **not** currently {@linkcode Pokemon.isOnField | on field} + * but are still {@linkcode Pokemon.isAllowedInBattle | allowed in battle}. + + * Used for switch out logic checks. + * @param player - Whether to search the player (`true`) or enemy (`false`) party; default `true` + * @returns An array of all {@linkcode PlayerPokemon} in reserve able to be switched into. + * @overload + */ + public getBackupPartyMembers(player: true): PlayerPokemon[]; + /** + * Return all {@linkcode Pokemon} that are **not** currently {@linkcode Pokemon.isOnField | on field} + * but are still {@linkcode Pokemon.isAllowedInBattle | allowed in battle}. + + * Used for switch out logic checks. + * @param player - Whether to search the player (`true`) or enemy (`false`) party; default `true` + * @param trainerSlot - The {@linkcode EnemyPokemon.trainerSlot | trainer slot} of the Pokemon being switched out; + * used to verify ownership in multi battles. + * @returns An array of all {@linkcode EnemyPokemon} in reserve able to be switched into. + * @overload + */ + public getBackupPartyMembers(player: false, trainerSlot: number): EnemyPokemon[]; + /** + * Return all {@linkcode Pokemon} that are **not** currently {@linkcode Pokemon.isOnField | on field} + * but are still {@linkcode Pokemon.isAllowedInBattle | allowed in battle}. + + * Used for switch out logic checks. + * @param player - Whether to search the player (`true`) or enemy (`false`) party; default `true` + * @param trainerSlot - The enemy Pokemon's {@linkcode EnemyPokemon.trainerSlot | trainer slot} for opposing trainers; + * used to verify ownership in multi battles and unused for player pokemon. + * @returns An array of all {@linkcode PlayerPokemon}/{@linkcode EnemyPokemon} in reserve able to be switched into. + * @overload + */ + public getBackupPartyMembers(player: boolean, trainerSlot: number | undefined): PlayerPokemon | EnemyPokemon[]; + + public getBackupPartyMembers( + player: B, + trainerSlot?: number, + ): R[] { + return (player ? this.getPlayerParty() : this.getEnemyParty()).filter( + (p: PlayerPokemon | EnemyPokemon) => + p.isAllowedInBattle() && !p.isOnField() && (p instanceof PlayerPokemon || p.trainerSlot !== trainerSlot), + ) as R[]; + } + /** * Returns an array of Pokemon on both sides of the battle - player first, then enemy. * Does not actually check if the pokemon are on the field or not, and always has length 4 regardless of battle type. diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index 4609ff6ec1a..b137b31145a 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -20,6 +20,7 @@ import { CopyMoveAttr, NeutralDamageAgainstFlyingTypeMultiplierAttr, FixedDamageAttr, + type MoveAttr, } from "#app/data/moves/move"; import { ArenaTagSide } from "#app/data/arena-tag"; import { BerryModifier, HitHealModifier, PokemonHeldItemModifier } from "#app/modifier/modifier"; @@ -55,7 +56,7 @@ import { ArenaTagType } from "#enums/arena-tag-type"; import { BattlerTagType } from "#enums/battler-tag-type"; import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; -import { SwitchType } from "#enums/switch-type"; +import { SwitchType, type NormalSwitchType } from "#enums/switch-type"; import { MoveFlags } from "#enums/MoveFlags"; import { MoveTarget } from "#enums/MoveTarget"; import { MoveCategory } from "#enums/MoveCategory"; @@ -67,7 +68,7 @@ import { BerryUsedEvent } from "#app/events/battle-scene"; // Type imports -import type { EnemyPokemon, PokemonMove } from "#app/field/pokemon"; +import { EnemyPokemon, PokemonMove } from "#app/field/pokemon"; import type Pokemon from "#app/field/pokemon"; import type { Weather } from "#app/data/weather"; import type { BattlerTag } from "#app/data/battler-tags"; @@ -77,6 +78,7 @@ import type Move from "#app/data/moves/move"; import type { ArenaTrapTag, SuppressAbilitiesTag } from "#app/data/arena-tag"; import { SelectBiomePhase } from "#app/phases/select-biome-phase"; import { noAbilityTypeOverrideMoves } from "../moves/invalid-moves"; +import { ForceSwitch } from "../mixins/force-switch"; export class BlockRecoilDamageAttr extends AbAttr { constructor() { @@ -1246,7 +1248,7 @@ export class MoveTypeChangeAbAttr extends PreAttackAbAttr { /** * Determine if the move type change attribute can be applied - * + * * Can be applied if: * - The ability's condition is met, e.g. pixilate only boosts normal moves, * - The move is not forbidden from having its type changed by an ability, e.g. {@linkcode Moves.MULTI_ATTACK} @@ -1262,7 +1264,7 @@ export class MoveTypeChangeAbAttr extends PreAttackAbAttr { */ override canApplyPreAttack(pokemon: Pokemon, _passive: boolean, _simulated: boolean, _defender: Pokemon | null, move: Move, _args: [NumberHolder?, NumberHolder?, ...any]): boolean { return (!this.condition || this.condition(pokemon, _defender, move)) && - !noAbilityTypeOverrideMoves.has(move.id) && + !noAbilityTypeOverrideMoves.has(move.id) && (!pokemon.isTerastallized || (move.id !== Moves.TERA_BLAST && (move.id !== Moves.TERA_STARSTORM || pokemon.getTeraType() !== PokemonType.STELLAR || !pokemon.hasSpecies(Species.TERAPAGOS)))); @@ -5537,128 +5539,6 @@ function applySingleAbAttrs( } } -class ForceSwitchOutHelper { - constructor(private switchType: SwitchType) {} - - /** - * Handles the logic for switching out a Pokémon based on battle conditions, HP, and the switch type. - * - * @param pokemon The {@linkcode Pokemon} attempting to switch out. - * @returns `true` if the switch is successful - */ - public switchOutLogic(pokemon: Pokemon): boolean { - const switchOutTarget = pokemon; - /** - * If the switch-out target is a player-controlled Pokémon, the function checks: - * - Whether there are available party members to switch in. - * - If the Pokémon is still alive (hp > 0), and if so, it leaves the field and a new SwitchPhase is initiated. - */ - if (switchOutTarget instanceof PlayerPokemon) { - if (globalScene.getPlayerParty().filter((p) => p.isAllowedInBattle() && !p.isOnField()).length < 1) { - return false; - } - - if (switchOutTarget.hp > 0) { - switchOutTarget.leaveField(this.switchType === SwitchType.SWITCH); - globalScene.prependToPhase(new SwitchPhase(this.switchType, switchOutTarget.getFieldIndex(), true, true), MoveEndPhase); - return true; - } - /** - * For non-wild battles, it checks if the opposing party has any available Pokémon to switch in. - * If yes, the Pokémon leaves the field and a new SwitchSummonPhase is initiated. - */ - } else if (globalScene.currentBattle.battleType !== BattleType.WILD) { - if (globalScene.getEnemyParty().filter((p) => p.isAllowedInBattle() && !p.isOnField()).length < 1) { - return false; - } - if (switchOutTarget.hp > 0) { - switchOutTarget.leaveField(this.switchType === SwitchType.SWITCH); - const summonIndex = (globalScene.currentBattle.trainer ? globalScene.currentBattle.trainer.getNextSummonIndex((switchOutTarget as EnemyPokemon).trainerSlot) : 0); - globalScene.prependToPhase(new SwitchSummonPhase(this.switchType, switchOutTarget.getFieldIndex(), summonIndex, false, false), MoveEndPhase); - return true; - } - /** - * For wild Pokémon battles, the Pokémon will flee if the conditions are met (waveIndex and double battles). - * It will not flee if it is a Mystery Encounter with fleeing disabled (checked in `getSwitchOutCondition()`) or if it is a wave 10x wild boss - */ - } else { - const allyPokemon = switchOutTarget.getAlly(); - - if (!globalScene.currentBattle.waveIndex || globalScene.currentBattle.waveIndex % 10 === 0) { - return false; - } - - if (switchOutTarget.hp > 0) { - switchOutTarget.leaveField(false); - globalScene.queueMessage(i18next.t("moveTriggers:fled", { pokemonName: getPokemonNameWithAffix(switchOutTarget) }), null, true, 500); - if (globalScene.currentBattle.double && !isNullOrUndefined(allyPokemon)) { - globalScene.redirectPokemonMoves(switchOutTarget, allyPokemon); - } - } - - if (!allyPokemon?.isActive(true)) { - globalScene.clearEnemyHeldItemModifiers(); - - if (switchOutTarget.hp) { - globalScene.pushPhase(new BattleEndPhase(false)); - - if (globalScene.gameMode.hasRandomBiomes || globalScene.isNewBiome()) { - globalScene.pushPhase(new SelectBiomePhase()); - } - - globalScene.pushPhase(new NewBattlePhase()); - } - } - } - return false; - } - - /** - * Determines if a Pokémon can switch out based on its status, the opponent's status, and battle conditions. - * - * @param pokemon The Pokémon attempting to switch out. - * @param opponent The opponent Pokémon. - * @returns `true` if the switch-out condition is met - */ - public getSwitchOutCondition(pokemon: Pokemon, opponent: Pokemon): boolean { - const switchOutTarget = pokemon; - const player = switchOutTarget instanceof PlayerPokemon; - - if (player) { - const blockedByAbility = new BooleanHolder(false); - applyAbAttrs(ForceSwitchOutImmunityAbAttr, opponent, blockedByAbility); - return !blockedByAbility.value; - } - - if (!player && globalScene.currentBattle.battleType === BattleType.WILD) { - if (!globalScene.currentBattle.waveIndex && globalScene.currentBattle.waveIndex % 10 === 0) { - return false; - } - } - - if (!player && globalScene.currentBattle.isBattleMysteryEncounter() && !globalScene.currentBattle.mysteryEncounter?.fleeAllowed) { - return false; - } - - const party = player ? globalScene.getPlayerParty() : globalScene.getEnemyParty(); - return (!player && globalScene.currentBattle.battleType === BattleType.WILD) - || party.filter(p => p.isAllowedInBattle() && !p.isOnField() - && (player || (p as EnemyPokemon).trainerSlot === (switchOutTarget as EnemyPokemon).trainerSlot)).length > 0; - } - - /** - * Returns a message if the switch-out attempt fails due to ability effects. - * - * @param target The target Pokémon. - * @returns The failure message, or `null` if no failure. - */ - public getFailedText(target: Pokemon): string | null { - const blockedByAbility = new BooleanHolder(false); - applyAbAttrs(ForceSwitchOutImmunityAbAttr, target, blockedByAbility); - return blockedByAbility.value ? i18next.t("moveTriggers:cannotBeSwitchedOut", { pokemonName: getPokemonNameWithAffix(target) }) : null; - } -} - /** * Calculates the amount of recovery from the Shell Bell item. * @@ -5712,12 +5592,13 @@ export class PostDamageAbAttr extends AbAttr { * @extends PostDamageAbAttr * @see {@linkcode applyPostDamage} */ -export class PostDamageForceSwitchAbAttr extends PostDamageAbAttr { - private helper: ForceSwitchOutHelper = new ForceSwitchOutHelper(SwitchType.SWITCH); +export class PostDamageForceSwitchAbAttr extends ForceSwitch(PostDamageAbAttr) { private hpRatio: number; - constructor(hpRatio = 0.5) { + constructor(selfSwitch = true, switchType: NormalSwitchType = SwitchType.SWITCH, hpRatio = 0.5) { super(); + this.selfSwitch = selfSwitch; + this.switchType = switchType; this.hpRatio = hpRatio; } @@ -5766,7 +5647,7 @@ export class PostDamageForceSwitchAbAttr extends PostDamageAbAttr { const shellBellHeal = calculateShellBellRecovery(pokemon); if (pokemon.hp - shellBellHeal < pokemon.getMaxHp() * this.hpRatio) { for (const opponent of pokemon.getOpponents()) { - if (!this.helper.getSwitchOutCondition(pokemon, opponent)) { + if (!this.canSwitchOut(pokemon, opponent)) { return false; } } @@ -5779,20 +5660,14 @@ export class PostDamageForceSwitchAbAttr extends PostDamageAbAttr { /** * Applies the switch-out logic after the Pokémon takes damage. - * Checks various conditions based on the moves used by the Pokémon, the opponents' moves, and - * the Pokémon's health after damage to determine whether the switch-out should occur. * * @param pokemon The Pokémon that took damage. - * @param damage N/A - * @param passive N/A - * @param simulated Whether the ability is being simulated. - * @param args N/A - * @param source N/A */ public override applyPostDamage(pokemon: Pokemon, damage: number, passive: boolean, simulated: boolean, args: any[], source?: Pokemon): void { - this.helper.switchOutLogic(pokemon); + this.doSwitch(pokemon); } } + function applyAbAttrsInternal( attrType: Constructor, pokemon: Pokemon | null, diff --git a/src/data/mixins/force-switch.ts b/src/data/mixins/force-switch.ts new file mode 100644 index 00000000000..12e9b34e43b --- /dev/null +++ b/src/data/mixins/force-switch.ts @@ -0,0 +1,184 @@ +import type Pokemon from "#app/field/pokemon"; +import { PlayerPokemon, EnemyPokemon } from "#app/field/pokemon"; +import { globalScene } from "#app/global-scene"; +import { BattleEndPhase } from "#app/phases/battle-end-phase"; +import { MoveEndPhase } from "#app/phases/move-end-phase"; +import { NewBattlePhase } from "#app/phases/new-battle-phase"; +import { SelectBiomePhase } from "#app/phases/select-biome-phase"; +import { SwitchPhase } from "#app/phases/switch-phase"; +import { SwitchSummonPhase } from "#app/phases/switch-summon-phase"; +import { BooleanHolder } from "#app/utils/common"; +import { BattleType } from "#enums/battle-type"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { type NormalSwitchType, SwitchType } from "#enums/switch-type"; +import i18next from "i18next"; +import { isNullOrUndefined } from "#app/utils/common"; +import { applyAbAttrs, ForceSwitchOutImmunityAbAttr } from "../abilities/ability"; +import type { MoveAttr } from "../moves/move"; +import { getPokemonNameWithAffix } from "#app/messages"; +import type { AbAttr } from "../abilities/ab-attrs/ab-attr"; +import type { TrainerSlot } from "#enums/trainer-slot"; + +// NB: This shouldn't be terribly hard to extend from if switching items are added (à la Eject Button) +type SubMoveOrAbAttr = (new (...args: any[]) => MoveAttr) | (new (...args: any[]) => AbAttr); + +/** Mixin to handle shared logic for switch-in moves and abilities. */ +export function ForceSwitch(Base: TBase) { + return class ForceSwitchClass extends Base { + protected selfSwitch = false; + protected switchType: NormalSwitchType = SwitchType.SWITCH; + + /** + * Determines if a Pokémon can be forcibly switched out based on its status, the opponent's status and battle conditions. + * @see {@linkcode performOpponentChecks} for opponent-related check code. + + * @param switchOutTarget - The {@linkcode Pokemon} attempting to switch out. + * @param opponent - The {@linkcode Pokemon} opposing the currently switched out Pokemon. + * Unused if {@linkcode selfSwitch} is `true`, in which case it should conventionally be set to `undefined`. + * @returns Whether {@linkcode switchOutTarget} can be switched out by the current Move or Ability. + */ + protected canSwitchOut(switchOutTarget: Pokemon, opponent: Pokemon | undefined): boolean { + const isPlayer = switchOutTarget instanceof PlayerPokemon; + + if (!this.selfSwitch && opponent && !this.performOpponentChecks(switchOutTarget, opponent)) { + return false; + } + + if (!isPlayer && globalScene.currentBattle.battleType === BattleType.WILD) { + // enemies should not be allowed to flee with baton pass, nor by any means on X0 waves (don't want easy boss wins) + return this.switchType !== SwitchType.BATON_PASS && globalScene.currentBattle.waveIndex % 10 !== 0; + } + + // Finally, ensure that we have valid switch out targets. + const reservePartyMembers = globalScene.getBackupPartyMembers( + isPlayer, + (switchOutTarget as EnemyPokemon).trainerSlot as TrainerSlot | undefined, + ); // evaluates to `undefined` if not present + if (reservePartyMembers.length === 0) { + return false; + } + + return true; + } + + protected performOpponentChecks(switchOutTarget: Pokemon, opponent: Pokemon): boolean { + // Dondozo with an allied Tatsugiri in its mouth cannot be forced out by enemies + const commandedTag = switchOutTarget.getTag(BattlerTagType.COMMANDED); + if (commandedTag?.getSourcePokemon()?.isActive(true)) { + return false; + } + + // Check for opposing switch block abilities (Suction Cups and co) + const blockedByAbility = new BooleanHolder(false); + applyAbAttrs(ForceSwitchOutImmunityAbAttr, opponent, blockedByAbility); + if (!blockedByAbility.value) { + return false; + } + + if ( + !(switchOutTarget instanceof PlayerPokemon) && + globalScene.currentBattle.isBattleMysteryEncounter() && + !globalScene.currentBattle.mysteryEncounter?.fleeAllowed + ) { + // Wild opponents cannot be force switched during MEs with flee disabled + return false; + } + return true; + } + + /** + * Wrapper function to handle the actual "switching out" of Pokemon. + * @param switchOutTarget - The {@linkcode Pokemon} (player or enemy) attempting to switch out. + */ + protected doSwitch(switchOutTarget: Pokemon): void { + if (switchOutTarget instanceof PlayerPokemon) { + this.trySwitchPlayerPokemon(switchOutTarget); + return; + } + + if (!(switchOutTarget instanceof EnemyPokemon)) { + console.warn("Switched out target not instance of Player or enemy Pokemon!"); + return; + } + + if (globalScene.currentBattle.battleType !== BattleType.WILD) { + this.trySwitchTrainerPokemon(switchOutTarget); + return; + } + + this.tryFleeWildPokemon(switchOutTarget); + } + + 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) { + globalScene.appendToPhase( + new SwitchPhase(this.switchType, switchOutTarget.getFieldIndex(), true, true), + MoveEndPhase, + ); + return; + } + + // Pick a random player pokemon to switch out. + const reservePartyMembers = globalScene.getBackupPartyMembers(true); + const switchOutIndex = switchOutTarget.randSeedInt(reservePartyMembers.length); + + globalScene.appendToPhase( + new SwitchSummonPhase(this.switchType, switchOutTarget.getFieldIndex(), switchOutIndex, false, true), + MoveEndPhase, + ); + } + + private trySwitchTrainerPokemon(switchOutTarget: EnemyPokemon): void { + // fallback for no trainer + if (!globalScene.currentBattle.trainer) { + console.warn("Enemy trainer switch logic approached without a trainer!"); + return; + } + // Forced switches will to pick a random eligible pokemon, while + // choice-based switching uses the trainer's default switch behavior + const reservePartyMembers = globalScene.getBackupPartyMembers(false, switchOutTarget.trainerSlot); + const summonIndex = + this.switchType === SwitchType.FORCE_SWITCH + ? switchOutTarget.randSeedInt(reservePartyMembers.length) + : (globalScene.currentBattle.trainer.getNextSummonIndex(switchOutTarget.trainerSlot) ?? 0); + globalScene.appendToPhase( + new SwitchSummonPhase(this.switchType, switchOutTarget.getFieldIndex(), summonIndex, false, false), + MoveEndPhase, + ); + } + + private tryFleeWildPokemon(switchOutTarget: EnemyPokemon): void { + // flee wild pokemon, redirecting moves to an ally in doubles as applicable. + switchOutTarget.leaveField(false); + globalScene.queueMessage( + i18next.t("moveTriggers:fled", { pokemonName: getPokemonNameWithAffix(switchOutTarget) }), + null, + true, + 500, + ); + + const allyPokemon = switchOutTarget.getAlly(); + if (globalScene.currentBattle.double && !isNullOrUndefined(allyPokemon)) { + globalScene.redirectPokemonMoves(switchOutTarget, allyPokemon); + } + + // End battle if no enemies are active and enemy wasn't already KO'd (kos do ) + if (!allyPokemon?.isActive(true) && !switchOutTarget.isFainted()) { + globalScene.clearEnemyHeldItemModifiers(); + + globalScene.pushPhase(new BattleEndPhase(false)); + + if (globalScene.gameMode.hasRandomBiomes || globalScene.isNewBiome()) { + globalScene.pushPhase(new SelectBiomePhase()); + } + + globalScene.pushPhase(new NewBattlePhase()); + } + } + + public isBatonPass(): boolean { + return this.switchType === SwitchType.BATON_PASS; + } + }; +} diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 3ef70fd75be..c0cc1bf2733 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -48,7 +48,6 @@ import { ConfusionOnStatusEffectAbAttr, FieldMoveTypePowerBoostAbAttr, FieldPreventExplosiveMovesAbAttr, - ForceSwitchOutImmunityAbAttr, HealFromBerryUseAbAttr, IgnoreContactAbAttr, IgnoreMoveEffectsAbAttr, @@ -108,7 +107,7 @@ import { SwitchSummonPhase } from "#app/phases/switch-summon-phase"; import { SpeciesFormChangeRevertWeatherFormTrigger } from "../pokemon-forms"; import type { GameMode } from "#app/game-mode"; import { applyChallenges, ChallengeType } from "../challenge"; -import { SwitchType } from "#enums/switch-type"; +import { SwitchType, type NormalSwitchType } from "#enums/switch-type"; import { StatusEffect } from "#enums/status-effect"; import { globalScene } from "#app/global-scene"; import { RevivalBlessingPhase } from "#app/phases/revival-blessing-phase"; @@ -122,7 +121,7 @@ import { MoveFlags } from "#enums/MoveFlags"; import { MoveEffectTrigger } from "#enums/MoveEffectTrigger"; import { MultiHitType } from "#enums/MultiHitType"; import { invalidAssistMoves, invalidCopycatMoves, invalidMetronomeMoves, invalidMirrorMoveMoves, invalidSleepTalkMoves } from "./invalid-moves"; -import { SelectBiomePhase } from "#app/phases/select-biome-phase"; +import { ForceSwitch } from "../mixins/force-switch"; type MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => boolean; type UserMoveConditionFunc = (user: Pokemon, move: Move) => boolean; @@ -6210,218 +6209,35 @@ export class RevivalBlessingAttr extends MoveEffectAttr { } -export class ForceSwitchOutAttr extends MoveEffectAttr { +export class ForceSwitchOutAttr extends ForceSwitch(MoveEffectAttr) { constructor( - private selfSwitch: boolean = false, - private switchType: SwitchType = SwitchType.SWITCH + selfSwitch: boolean = false, + switchType: NormalSwitchType = SwitchType.SWITCH ) { super(false, { lastHitOnly: true }); + this.selfSwitch = selfSwitch; + this.switchType = switchType; } - isBatonPass() { - return this.switchType === SwitchType.BATON_PASS; - } - - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - // Check if the move category is not STATUS or if the switch out condition is not met - if (!this.getSwitchOutCondition()(user, target, move)) { - return false; - } - - /** The {@linkcode Pokemon} to be switched out with this effect */ - const switchOutTarget = this.selfSwitch ? user : target; - - // If the switch-out target is a Dondozo with a Tatsugiri in its mouth - // (e.g. when it uses Flip Turn), make it spit out the Tatsugiri before switching out. - switchOutTarget.lapseTag(BattlerTagType.COMMANDED); - - if (switchOutTarget instanceof PlayerPokemon) { - /** - * Check if Wimp Out/Emergency Exit activates due to being hit by U-turn or Volt Switch - * If it did, the user of U-turn or Volt Switch will not be switched out. - */ - if (target.getAbility().hasAttr(PostDamageForceSwitchAbAttr) - && [ Moves.U_TURN, Moves.VOLT_SWITCH, Moves.FLIP_TURN ].includes(move.id) - ) { - if (this.hpDroppedBelowHalf(target)) { - return false; - } - } - - // Find indices of off-field Pokemon that are eligible to be switched into - const eligibleNewIndices: number[] = []; - globalScene.getPlayerParty().forEach((pokemon, index) => { - if (pokemon.isAllowedInBattle() && !pokemon.isOnField()) { - eligibleNewIndices.push(index); - } - }); - - if (eligibleNewIndices.length < 1) { - return false; - } - - if (switchOutTarget.hp > 0) { - if (this.switchType === SwitchType.FORCE_SWITCH) { - switchOutTarget.leaveField(true); - const slotIndex = eligibleNewIndices[user.randSeedInt(eligibleNewIndices.length)]; - globalScene.prependToPhase( - new SwitchSummonPhase( - this.switchType, - switchOutTarget.getFieldIndex(), - slotIndex, - false, - true - ), - MoveEndPhase - ); - } else { - switchOutTarget.leaveField(this.switchType === SwitchType.SWITCH); - globalScene.prependToPhase( - new SwitchPhase( - this.switchType, - switchOutTarget.getFieldIndex(), - true, - true - ), - MoveEndPhase - ); - return true; - } - } - return false; - } else if (globalScene.currentBattle.battleType !== BattleType.WILD) { // Switch out logic for enemy trainers - // Find indices of off-field Pokemon that are eligible to be switched into - const isPartnerTrainer = globalScene.currentBattle.trainer?.isPartner(); - const eligibleNewIndices: number[] = []; - globalScene.getEnemyParty().forEach((pokemon, index) => { - if (pokemon.isAllowedInBattle() && !pokemon.isOnField() && (!isPartnerTrainer || pokemon.trainerSlot === (switchOutTarget as EnemyPokemon).trainerSlot)) { - eligibleNewIndices.push(index); - } - }); - - if (eligibleNewIndices.length < 1) { - return false; - } - - if (switchOutTarget.hp > 0) { - if (this.switchType === SwitchType.FORCE_SWITCH) { - switchOutTarget.leaveField(true); - const slotIndex = eligibleNewIndices[user.randSeedInt(eligibleNewIndices.length)]; - globalScene.prependToPhase( - new SwitchSummonPhase( - this.switchType, - switchOutTarget.getFieldIndex(), - slotIndex, - false, - false - ), - MoveEndPhase - ); - } else { - switchOutTarget.leaveField(this.switchType === SwitchType.SWITCH); - globalScene.prependToPhase( - new SwitchSummonPhase( - this.switchType, - switchOutTarget.getFieldIndex(), - (globalScene.currentBattle.trainer ? globalScene.currentBattle.trainer.getNextSummonIndex((switchOutTarget as EnemyPokemon).trainerSlot) : 0), - false, - false - ), - MoveEndPhase - ); - } - } - } else { // Switch out logic for wild pokemon - /** - * Check if Wimp Out/Emergency Exit activates due to being hit by U-turn or Volt Switch - * If it did, the user of U-turn or Volt Switch will not be switched out. - */ - if (target.getAbility().hasAttr(PostDamageForceSwitchAbAttr) - && [ Moves.U_TURN, Moves.VOLT_SWITCH, Moves.FLIP_TURN ].includes(move.id) - ) { - if (this.hpDroppedBelowHalf(target)) { - return false; - } - } - - const allyPokemon = switchOutTarget.getAlly(); - - if (switchOutTarget.hp > 0) { - switchOutTarget.leaveField(false); - globalScene.queueMessage(i18next.t("moveTriggers:fled", { pokemonName: getPokemonNameWithAffix(switchOutTarget) }), null, true, 500); - - // in double battles redirect potential moves off fled pokemon - if (globalScene.currentBattle.double && !isNullOrUndefined(allyPokemon)) { - globalScene.redirectPokemonMoves(switchOutTarget, allyPokemon); - } - } - - // clear out enemy held item modifiers of the switch out target - globalScene.clearEnemyHeldItemModifiers(switchOutTarget); - - if (!allyPokemon?.isActive(true) && switchOutTarget.hp) { - globalScene.pushPhase(new BattleEndPhase(false)); - - if (globalScene.gameMode.hasRandomBiomes || globalScene.isNewBiome()) { - globalScene.pushPhase(new SelectBiomePhase()); - } - - globalScene.pushPhase(new NewBattlePhase()); - } - } - - return true; + apply(user: Pokemon, target: Pokemon, _move: Move, _args: any[]): boolean { + this.doSwitch(this.selfSwitch ? user : target) + return true; } getCondition(): MoveConditionFunc { - return (user, target, move) => (move.category !== MoveCategory.STATUS || this.getSwitchOutCondition()(user, target, move)); + return this.getSwitchOutCondition(); } - 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) }); - } - } - - getSwitchOutCondition(): MoveConditionFunc { return (user, target, move) => { - const switchOutTarget = (this.selfSwitch ? user : target); - const player = switchOutTarget instanceof PlayerPokemon; + const [switchOutTarget, opponent] = this.selfSwitch ? [user, target] : [target, user]; - if (!this.selfSwitch) { - // Dondozo with an allied Tatsugiri in its mouth cannot be forced out - const commandedTag = switchOutTarget.getTag(BattlerTagType.COMMANDED); - if (commandedTag?.getSourcePokemon()?.isActive(true)) { - return false; - } - - if (!player && globalScene.currentBattle.isBattleMysteryEncounter() && !globalScene.currentBattle.mysteryEncounter?.fleeAllowed) { - // Don't allow wild opponents to be force switched during MEs with flee disabled - return false; - } - - const blockedByAbility = new BooleanHolder(false); - applyAbAttrs(ForceSwitchOutImmunityAbAttr, target, blockedByAbility); - if (blockedByAbility.value) { - return false; - } + // Don't allow wild mons to flee with U-turn et al. + if (switchOutTarget instanceof EnemyPokemon && globalScene.currentBattle.battleType === BattleType.WILD && !(this.selfSwitch && move.category !== MoveCategory.STATUS)) { + return false; } - - if (!player && globalScene.currentBattle.battleType === BattleType.WILD) { - // wild pokemon cannot switch out with baton pass. - return !this.isBatonPass() - && globalScene.currentBattle.waveIndex % 10 !== 0 - // Don't allow wild mons to flee with U-turn et al. - && !(this.selfSwitch && MoveCategory.STATUS !== move.category); - } - - const party = player ? globalScene.getPlayerParty() : globalScene.getEnemyParty(); - return party.filter(p => p.isAllowedInBattle() && !p.isOnField() - && (player || (p as EnemyPokemon).trainerSlot === (switchOutTarget as EnemyPokemon).trainerSlot)).length > 0; + return this.canSwitchOut(switchOutTarget, opponent) }; } @@ -6460,7 +6276,8 @@ export class ChillyReceptionAttr extends ForceSwitchOutAttr { } getCondition(): MoveConditionFunc { - // chilly reception move will go through if the weather is change-able to snow, or the user can switch out, else move will fail + // chilly reception will succeed if the weather is changeable to snow OR the user can be switched out, + // only failing if neither is the case. return (user, target, move) => globalScene.arena.weather?.weatherType !== WeatherType.SNOW || super.getSwitchOutCondition()(user, target, move); } } diff --git a/src/data/mystery-encounters/mystery-encounter.ts b/src/data/mystery-encounters/mystery-encounter.ts index e305252ed0f..10069045ba7 100644 --- a/src/data/mystery-encounters/mystery-encounter.ts +++ b/src/data/mystery-encounters/mystery-encounter.ts @@ -343,16 +343,13 @@ export default class MysteryEncounter implements IMysteryEncounter { * can cause scenarios where there are not enough Pokemon that are sufficient for all requirements. */ private meetsPrimaryRequirementAndPrimaryPokemonSelected(): boolean { + let qualified: PlayerPokemon[] = globalScene.getPlayerParty(); if (!this.primaryPokemonRequirements || this.primaryPokemonRequirements.length === 0) { - const activeMon = globalScene.getPlayerParty().filter(p => p.isActive(true)); - if (activeMon.length > 0) { - this.primaryPokemon = activeMon[0]; - } else { - this.primaryPokemon = globalScene.getPlayerParty().filter(p => p.isAllowedInBattle())[0]; - } + // If we lack specified criterion, grab the first on-field pokemon, or else the first pokemon allowed in battle + const activeMons = qualified.filter(p => p.isAllowedInBattle()); + this.primaryPokemon = activeMons.find(p => p.isOnField()) ?? activeMons[0]; return true; } - let qualified: PlayerPokemon[] = globalScene.getPlayerParty(); for (const req of this.primaryPokemonRequirements) { if (req.meetsRequirement()) { qualified = qualified.filter(pkmn => req.queryParty(globalScene.getPlayerParty()).includes(pkmn)); diff --git a/src/enums/switch-type.ts b/src/enums/switch-type.ts index d55872ae83b..fcdc1a25e95 100644 --- a/src/enums/switch-type.ts +++ b/src/enums/switch-type.ts @@ -14,3 +14,5 @@ export enum SwitchType { /** Force switchout to a random party member */ FORCE_SWITCH, } + +export type NormalSwitchType = Exclude \ No newline at end of file diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index eec20beb01c..775d4370f35 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -5678,7 +5678,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { /** * Performs the action of clearing a Pokemon's status - * + * * This is a helper to {@linkcode resetStatus}, which should be called directly instead of this method */ public clearStatus(confusion: boolean, reloadAssets: boolean) { @@ -5723,8 +5723,12 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { /** * Reset this Pokemon's {@linkcode PokemonSummonData | SummonData} and {@linkcode PokemonTempSummonData | TempSummonData} * in preparation for switching pokemon, as well as removing any relevant on-switch tags. + * @remarks + * This **SHOULD NOT** be called when a `SummonPhase` or `SwitchSummonPhase` is already being added, + * both of which call this method (directly or indirectly) on both pokemon changing positions. */ resetSummonData(): void { + console.log(`resetSummonData called on Pokemon ${this.name}`) const illusion: IllusionData | null = this.summonData.illusion; if (this.summonData.speciesForm) { this.summonData.speciesForm = null; @@ -6299,13 +6303,16 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Causes a Pokemon to leave the field (such as in preparation for a switch out/escape). - * @param clearEffects Indicates if effects should be cleared (true) or passed - * to the next pokemon, such as during a baton pass (false) - * @param hideInfo Indicates if this should also play the animation to hide the Pokemon's - * info container. + * Cause this {@linkcode Pokemon} to leave the field (such as in preparation for a switch out/escape). + * @param clearEffects - Whether to clear (`true`) or transfer (`false`) transient effects upon switching; default `true` + * @param hideInfo - Whether to play the animation to hide the Pokemon's info container; default `true`. + * @param destroy - Whether to destroy this Pokemon once it leaves the field; default `false` + * @remarks + * This **SHOULD NOT** be called when a `SummonPhase` or `SwitchSummonPhase` is already being added, + * which can lead to erroneous resetting of {@linkcode turnData} or {@linkcode summonData}. */ leaveField(clearEffects = true, hideInfo = true, destroy = false) { + console.log(`leaveField called on Pokemon ${this.name}`) this.resetSprite(); this.resetTurnData(); globalScene diff --git a/src/phases/switch-phase.ts b/src/phases/switch-phase.ts index c056b186021..b29641519bd 100644 --- a/src/phases/switch-phase.ts +++ b/src/phases/switch-phase.ts @@ -17,13 +17,12 @@ export class SwitchPhase extends BattlePhase { private readonly doReturn: boolean; /** - * Creates a new SwitchPhase - * @param switchType {@linkcode SwitchType} The type of switch logic this phase implements - * @param fieldIndex Field index to switch out - * @param isModal Indicates if the switch should be forced (true) or is - * optional (false). - * @param doReturn Indicates if the party member on the field should be - * recalled to ball or has already left the field. Passed to {@linkcode SwitchSummonPhase}. + * Creates a new {@linkcode SwitchPhase}, the phase where players select a Pokemon to send into battle. + * @param switchType - The {@linkcode SwitchType} dictating this switch's logic. + * @param fieldIndex - The 0-indexed field position of the Pokemon being switched out. + * @param isModal - Whether the switch should be forced (`true`) or optional (`false`). + * @param doReturn - Whether to render the "Come back!" dialogue for recalling player pokemon. + * @see {@linkcode SwitchSummonPhase} for the phase which does the actual switching. */ constructor(switchType: SwitchType, fieldIndex: number, isModal: boolean, doReturn: boolean) { super(); @@ -37,8 +36,8 @@ export class SwitchPhase extends BattlePhase { start() { super.start(); - // Skip modal switch if impossible (no remaining party members that aren't in battle) - if (this.isModal && !globalScene.getPlayerParty().filter(p => p.isAllowedInBattle() && !p.isActive(true)).length) { + // Failsafe: skip modal switches if impossible (no eligible party members in reserve). + if (this.isModal && globalScene.getBackupPartyMembers(true).length === 0) { return super.end(); } diff --git a/src/phases/switch-summon-phase.ts b/src/phases/switch-summon-phase.ts index bb31f87cc3d..1b181bc9a95 100644 --- a/src/phases/switch-summon-phase.ts +++ b/src/phases/switch-summon-phase.ts @@ -28,7 +28,7 @@ export class SwitchSummonPhase extends SummonPhase { private lastPokemon: Pokemon; /** - * Constructor for creating a new SwitchSummonPhase + * Constructor for creating a new {@linkcode SwitchSummonPhase}, the phase where player and enemy Pokemon are switched out. * @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 diff --git a/test/moves/u_turn.test.ts b/test/moves/u_turn.test.ts index 4ceb6865be0..2c04c3aa405 100644 --- a/test/moves/u_turn.test.ts +++ b/test/moves/u_turn.test.ts @@ -32,25 +32,34 @@ describe("Moves - U-turn", () => { .disableCrits(); }); - it("triggers regenerator a single time when a regenerator user switches out with u-turn", async () => { - // arrange - const playerHp = 1; - game.override.ability(Abilities.REGENERATOR); + it("should switch the user out upon use", async () => { await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]); - game.scene.getPlayerPokemon()!.hp = playerHp; + const [raichu, shuckle] = game.scene.getPlayerParty(); + expect(raichu).toBeDefined(); + expect(shuckle).toBeDefined(); - // act + expect(game.scene.getPlayerPokemon()!).toBe(raichu); game.move.select(Moves.U_TURN); game.doSelectPartyPokemon(1); await game.phaseInterceptor.to("TurnEndPhase"); - // assert - expect(game.scene.getPlayerParty()[1].hp).toEqual( - Math.floor(game.scene.getPlayerParty()[1].getMaxHp() * 0.33 + playerHp), - ); + expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); + expect(game.scene.getPlayerPokemon()!).toBe(shuckle); + }); + + it("triggers regenerator passive once upon switch", async () => { + game.override.ability(Abilities.REGENERATOR); + await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]); + game.scene.getPlayerPokemon()!.hp = 1; + + game.move.select(Moves.U_TURN); + game.doSelectPartyPokemon(1); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(game.scene.getPlayerParty()[1].hp).toBeGreaterThan(1); expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.SHUCKLE); - }, 20000); + }); it("triggers rough skin on the u-turn user before a new pokemon is switched in", async () => { // arrange