From 3092c771599c692466511deeeca83347b83a9b50 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Wed, 23 Jul 2025 15:53:10 -0400 Subject: [PATCH 1/5] Squashed changes --- src/battle-scene.ts | 53 ++ src/data/abilities/ability.ts | 327 +++------ src/data/battler-tags.ts | 14 +- src/data/helpers/force-switch.ts | 221 ++++++ src/data/moves/move.ts | 344 +++------ .../mystery-encounters/mystery-encounter.ts | 14 +- src/data/pokemon/pokemon-data.ts | 9 +- src/enums/switch-type.ts | 3 + src/field/pokemon.ts | 67 +- src/modifier/modifier.ts | 30 +- src/phases/check-switch-phase.ts | 8 +- src/phases/faint-phase.ts | 48 +- src/phases/move-effect-phase.ts | 41 +- src/phases/move-phase.ts | 2 + src/phases/post-turn-status-effect-phase.ts | 1 + src/phases/summon-phase.ts | 2 +- src/phases/switch-phase.ts | 21 +- src/phases/switch-summon-phase.ts | 157 ++-- src/utils/array.ts | 45 ++ test/abilities/arena_trap.test.ts | 98 +-- test/abilities/disguise.test.ts | 8 +- test/abilities/mold_breaker.test.ts | 35 +- test/abilities/wimp_out.test.ts | 679 +++++++++--------- test/items/reviver_seed.test.ts | 200 +++--- test/moves/baton_pass.test.ts | 126 ---- test/moves/chilly_reception.test.ts | 5 +- test/moves/dragon_tail.test.ts | 307 -------- test/moves/focus_punch.test.ts | 14 +- test/moves/force-switch.test.ts | 503 +++++++++++++ test/moves/powder.test.ts | 2 +- test/moves/shed_tail.test.ts | 68 -- test/moves/u_turn.test.ts | 106 --- test/testUtils/gameManager.ts | 2 + 33 files changed, 1790 insertions(+), 1770 deletions(-) create mode 100644 src/data/helpers/force-switch.ts create mode 100644 src/utils/array.ts delete mode 100644 test/moves/baton_pass.test.ts delete mode 100644 test/moves/dragon_tail.test.ts create mode 100644 test/moves/force-switch.test.ts delete mode 100644 test/moves/shed_tail.test.ts delete mode 100644 test/moves/u_turn.test.ts diff --git a/src/battle-scene.ts b/src/battle-scene.ts index bb28fb0d5b6..a39a840af92 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -812,6 +812,58 @@ export class BattleScene extends SceneBase { return party.slice(0, Math.min(party.length, this.currentBattle?.double ? 2 : 1)); } + /** + * Return the party positions of 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 pokemon - A {@linkcode Pokemon} on the desired side of the field, used to infer the side and trainer slot (as applicable) + * @returns An array containing the **INDICES** of all {@linkcode Pokemon} in reserve able to be switched into. + * @overload + */ + public getBackupPartyMemberIndices(pokemon: Pokemon): number[]; + /** + * Return the party positions of 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 containing the **INDICES** of all {@linkcode Pokemon} in reserve able to be switched into. + * @overload + */ + public getBackupPartyMemberIndices(player: true): number[]; + /** + * Return the party positions of 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 TrainerSlot | trainer slot} to check against for enemy trainers; + * used to verify ownership in multi battles + * @returns An array containing the **INDICES** of all {@linkcode Pokemon} in reserve able to be switched into. + * @overload + */ + public getBackupPartyMemberIndices(player: false, trainerSlot: TrainerSlot): number[]; + + public getBackupPartyMemberIndices(player: boolean | Pokemon, trainerSlot?: number): number[] { + // Note: We return the indices instead of the actual Pokemon because `SwitchSummonPhase` and co. take an index instead of a pokemon. + // If this is ever changed, this can be replaced with a simpler version involving `filter` and conditional type annotations. + if (typeof player === "object") { + // Marginally faster than using a ternary + trainerSlot = (player as unknown as EnemyPokemon)["trainerSlot"]; + player = player.isPlayer(); + } + + const indices: number[] = []; + const party = player ? this.getPlayerParty() : this.getEnemyParty(); + party.forEach((p: PlayerPokemon | EnemyPokemon, i: number) => { + if (p.isAllowedInBattle() && !p.isOnField() && (player || (p as EnemyPokemon).trainerSlot === trainerSlot)) { + indices.push(i); + } + }); + return indices; + } + /** * 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. @@ -838,6 +890,7 @@ export class BattleScene extends SceneBase { if (this.currentBattle.double === false) { return; } + // TODO: Remove while loop if (allyPokemon?.isActive(true)) { let targetingMovePhase: MovePhase; do { diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index 30f6f6fb8b2..5ff83cedaea 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -13,6 +13,7 @@ import { getBerryEffectFunc } from "#data/berry"; import { allAbilities, allMoves } from "#data/data-lists"; import { SpeciesFormChangeAbilityTrigger, SpeciesFormChangeWeatherTrigger } from "#data/form-change-triggers"; import { Gender } from "#data/gender"; +import { ForceSwitchOutHelper } from "#data/helpers/force-switch"; import { getPokeballName } from "#data/pokeball"; import { pokemonFormChanges } from "#data/pokemon-forms"; import { getNonVolatileStatusEffects, getStatusEffectDescriptor, getStatusEffectHealText } from "#data/status-effect"; @@ -21,7 +22,6 @@ import type { Weather } from "#data/weather"; import { AbilityId } from "#enums/ability-id"; import { ArenaTagSide } from "#enums/arena-tag-side"; import { ArenaTagType } from "#enums/arena-tag-type"; -import { BattleType } from "#enums/battle-type"; import { BattlerIndex } from "#enums/battler-index"; import { BattlerTagLapseType } from "#enums/battler-tag-lapse-type"; import { BattlerTagType } from "#enums/battler-tag-type"; @@ -41,16 +41,17 @@ import { SpeciesId } from "#enums/species-id"; import type { BattleStat, EffectiveStat } from "#enums/stat"; import { BATTLE_STATS, EFFECTIVE_STATS, getStatKey, Stat } from "#enums/stat"; import { StatusEffect } from "#enums/status-effect"; -import { SwitchType } from "#enums/switch-type"; +import { type NormalSwitchType, SwitchType } from "#enums/switch-type"; import { WeatherType } from "#enums/weather-type"; import { BerryUsedEvent } from "#events/battle-scene"; -import type { EnemyPokemon, Pokemon } from "#field/pokemon"; -import { BerryModifier, HitHealModifier, PokemonHeldItemModifier } from "#modifiers/modifier"; +import type { Pokemon } from "#field/pokemon"; +import { BerryModifier, PokemonHeldItemModifier } from "#modifiers/modifier"; import { BerryModifierType } from "#modifiers/modifier-type"; import { applyMoveAttrs } from "#moves/apply-attrs"; import { noAbilityTypeOverrideMoves } from "#moves/invalid-moves"; import type { Move } from "#moves/move"; import type { PokemonMove } from "#moves/pokemon-move"; +import type { MoveEffectPhase } from "#phases/move-effect-phase"; import type { StatStageChangePhase } from "#phases/stat-stage-change-phase"; import type { AbAttrCondition, @@ -3253,9 +3254,9 @@ export class CommanderAbAttr extends AbAttr { const ally = pokemon.getAlly(); return ( globalScene.currentBattle?.double && - !isNullOrUndefined(ally) && - ally.species.speciesId === SpeciesId.DONDOZO && - !(ally.isFainted() || ally.getTag(BattlerTagType.COMMANDED)) + ally?.species.speciesId === SpeciesId.DONDOZO && + !ally.isFainted() && + !ally.getTag(BattlerTagType.COMMANDED) ); } @@ -4158,7 +4159,7 @@ export class SuppressWeatherEffectAbAttr extends PreWeatherEffectAbAttr { /** * Condition function to applied to abilities related to Sheer Force. * Checks if last move used against target was affected by a Sheer Force user and: - * Disables: Color Change, Pickpocket, Berserk, Anger Shell + * Disables: Color Change, Pickpocket, Berserk, Anger Shell, Wimp Out and Emergency Exit. * @returns An {@linkcode AbAttrCondition} to disable the ability under the proper conditions. */ function getSheerForceHitDisableAbCondition(): AbAttrCondition { @@ -6223,187 +6224,13 @@ export class TerrainEventTypeChangeAbAttr extends PostSummonAbAttr { } } -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 - */ - // TODO: Make this cancel pending move phases on the switched out target - 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.isPlayer()) { - if (globalScene.getPlayerParty().filter(p => p.isAllowedInBattle() && !p.isOnField()).length < 1) { - return false; - } - - if (switchOutTarget.hp > 0) { - switchOutTarget.leaveField(this.switchType === SwitchType.SWITCH); - globalScene.phaseManager.prependNewToPhase( - "MoveEndPhase", - "SwitchPhase", - this.switchType, - switchOutTarget.getFieldIndex(), - true, - true, - ); - 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.phaseManager.prependNewToPhase( - "MoveEndPhase", - "SwitchSummonPhase", - this.switchType, - switchOutTarget.getFieldIndex(), - summonIndex, - false, - false, - ); - 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.phaseManager.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.phaseManager.pushNew("BattleEndPhase", false); - - if (globalScene.gameMode.hasRandomBiomes || globalScene.isNewBiome()) { - globalScene.phaseManager.pushNew("SelectBiomePhase"); - } - - globalScene.phaseManager.pushNew("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.isPlayer(); - - if (player) { - const blockedByAbility = new BooleanHolder(false); - applyAbAttrs("ForceSwitchOutImmunityAbAttr", { pokemon: opponent, cancelled: 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", { pokemon: target, cancelled: blockedByAbility }); - return blockedByAbility.value - ? i18next.t("moveTriggers:cannotBeSwitchedOut", { pokemonName: getPokemonNameWithAffix(target) }) - : null; - } -} - -/** - * Calculates the amount of recovery from the Shell Bell item. - * - * If the Pokémon is holding a Shell Bell, this function computes the amount of health - * recovered based on the damage dealt in the current turn. The recovery is multiplied by the - * Shell Bell's modifier (if any). - * - * @param pokemon - The Pokémon whose Shell Bell recovery is being calculated. - * @returns The amount of health recovered by Shell Bell. - */ -function calculateShellBellRecovery(pokemon: Pokemon): number { - const shellBellModifier = pokemon.getHeldItems().find(m => m instanceof HitHealModifier); - if (shellBellModifier) { - return toDmgValue(pokemon.turnData.totalDamageDealt / 8) * shellBellModifier.stackCount; - } - return 0; -} - export interface PostDamageAbAttrParams extends AbAttrBaseParams { /** The pokemon that caused the damage; omitted if the damage was not from a pokemon */ - source?: Pokemon; + readonly source?: Pokemon; /** The amount of damage that was dealt */ readonly damage: number; } + /** * Triggers after the Pokemon takes any damage */ @@ -6420,83 +6247,99 @@ export class PostDamageAbAttr extends AbAttr { * This attribute checks various conditions related to the damage received, the moves used by the Pokémon * and its opponents, and determines whether a forced switch-out should occur. * - * Used by Wimp Out and Emergency Exit - * - * @see {@linkcode applyPostDamage} - * @sealed + * Used for {@linkcode AbilityId.WIMP_OUT} and {@linkcode AbilityId.EMERGENCY_EXIT}. */ export class PostDamageForceSwitchAbAttr extends PostDamageAbAttr { - private helper: ForceSwitchOutHelper = new ForceSwitchOutHelper(SwitchType.SWITCH); private hpRatio: number; + private helper: ForceSwitchOutHelper; - constructor(hpRatio = 0.5) { + constructor(switchType: NormalSwitchType = SwitchType.SWITCH, hpRatio = 0.5) { super(); this.hpRatio = hpRatio; + this.helper = new ForceSwitchOutHelper({ selfSwitch: true, switchType, allowFlee: true }); + // TODO: change if any force switch abilities with red card-like effects are added } - // TODO: Refactor to use more early returns + /** + * Check to see if the user should be switched out after taking damage. + * @param pokemon - The {@linkcode Pokemon} with this ability; will be switched out if conditions are met + * @param damage - The amount of damage dealt by the triggering damage instance + * @param source - The {@linkcode Pokemon} having damaged the user with an attack, or `undefined` + * if the damage source was indirect + * @returns Whether `pokemon` should be switched out upon move conclusion. + */ public override canApply({ pokemon, source, damage }: PostDamageAbAttrParams): boolean { - const moveHistory = pokemon.getMoveHistory(); - // Will not activate when the Pokémon's HP is lowered by cutting its own HP - const fordbiddenAttackingMoves = [MoveId.BELLY_DRUM, MoveId.SUBSTITUTE, MoveId.CURSE, MoveId.PAIN_SPLIT]; - if (moveHistory.length > 0) { - const lastMoveUsed = moveHistory[moveHistory.length - 1]; - if (fordbiddenAttackingMoves.includes(lastMoveUsed.move)) { - return false; - } + // Skip move checks for damage not occurring due to a move (eg: hazards) + const currentPhase = globalScene.phaseManager.getCurrentPhase(); + if (currentPhase?.is("MoveEffectPhase") && !this.passesMoveChecks(source)) { + return false; } - // Dragon Tail and Circle Throw switch out Pokémon before the Ability activates. - const fordbiddenDefendingMoves = [MoveId.DRAGON_TAIL, MoveId.CIRCLE_THROW]; - if (source) { - const enemyMoveHistory = source.getMoveHistory(); - if (enemyMoveHistory.length > 0) { - const enemyLastMoveUsed = enemyMoveHistory[enemyMoveHistory.length - 1]; - // Will not activate if the Pokémon's HP falls below half while it is in the air during Sky Drop. - if ( - fordbiddenDefendingMoves.includes(enemyLastMoveUsed.move) || - (enemyLastMoveUsed.move === MoveId.SKY_DROP && enemyLastMoveUsed.result === MoveResult.OTHER) - ) { - return false; - // Will not activate if the Pokémon's HP falls below half by a move affected by Sheer Force. - // TODO: Make this use the sheer force disable condition - } - if (allMoves[enemyLastMoveUsed.move].chance >= 0 && source.hasAbility(AbilityId.SHEER_FORCE)) { - return false; - } - // Activate only after the last hit of multistrike moves - if (source.turnData.hitsLeft > 1) { - return false; - } - if (source.turnData.hitCount > 1) { - damage = pokemon.turnData.damageTaken; - } - } + if (!this.wasKnockedBelowHalf(pokemon, damage)) { + return false; } - if (pokemon.hp + damage >= pokemon.getMaxHp() * this.hpRatio) { - const shellBellHeal = calculateShellBellRecovery(pokemon); - if (pokemon.hp - shellBellHeal < pokemon.getMaxHp() * this.hpRatio) { - for (const opponent of pokemon.getOpponents()) { - if (!this.helper.getSwitchOutCondition(pokemon, opponent)) { - return false; - } - } - return true; - } - } + return this.helper.canSwitchOut(pokemon); + } - return false; + /** + * Perform move checks to determine if this pokemon should switch out. + * @param source - The {@linkcode Pokemon} whose attack caused the user to switch out, + * or `undefined` if the damage source was indirect. + * @returns `true` if this Pokemon should be allowed to switch out. + */ + private passesMoveChecks(source: Pokemon | undefined): boolean { + // Wimp Out and Emergency Exit... + const currentPhase = globalScene.phaseManager.getCurrentPhase() as MoveEffectPhase; + const currentMove = currentPhase.move; + + // will not activate from self-induced HP cutting, + // TODO: Verify that Fillet Away and Clangorous Soul do not proc wimp out + const hpCutMoves = new Set([ + MoveId.CURSE, + MoveId.BELLY_DRUM, + MoveId.SUBSTITUTE, + MoveId.PAIN_SPLIT, + MoveId.CLANGOROUS_SOUL, + MoveId.FILLET_AWAY, + ]); + // NB: Given this attribute is only applied after _taking damage_ or recieving a damaging attack, + // a failed Substitute or non-Ghost type Curse will not trigger this code. + const notHpCut = !hpCutMoves.has(currentMove.id); + + // will not activate for forced switch moves (which trigger before wimp out activates), + const notForceSwitched = ![MoveId.DRAGON_TAIL, MoveId.CIRCLE_THROW].includes(currentMove.id); + + // and will not activate if the Pokemon is currently in the air from Sky Drop. + // TODO: Make this check the user's tags and move to main `canApply` block once Sky Drop is fully implemented - + // we could be sky dropped by another Pokemon or take indirect damage while skybound (both of which render this check useless) + const lastMove = source?.getLastXMoves()[0]; + const notSkyDropped = !(lastMove?.move === MoveId.SKY_DROP && lastMove.result === MoveResult.OTHER); + + return notHpCut && notForceSwitched && notSkyDropped; + } + + /** + * Perform HP checks to determine if this pokemon should switch out. + * The switch fails if the pokemon was below {@linkcode hpRatio} before being hit + * or is still above it after the hit. + * @param pokemon - The {@linkcode Pokemon} with this ability + * @param damage - The amount of damage taken + * @returns Whether the Pokemon was knocked below half after `damage` was applied + */ + private wasKnockedBelowHalf(pokemon: Pokemon, damage: number) { + // NB: This occurs _after_ the damage instance has been dealt, + // so `pokemon.hp` contains the post-taking damage hp value. + const hpNeededToSwitch = pokemon.getMaxHp() * this.hpRatio; + return pokemon.hp < hpNeededToSwitch && pokemon.hp + damage >= hpNeededToSwitch; } /** * 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. */ public override apply({ pokemon }: PostDamageAbAttrParams): void { // TODO: Consider respecting the `simulated` flag here - this.helper.switchOutLogic(pokemon); + this.helper.doSwitch(pokemon); } } @@ -7379,10 +7222,10 @@ export function initAbilities() { .attr(PostDefendStatStageChangeAbAttr, (_target, _user, move) => move.category !== MoveCategory.STATUS, Stat.DEF, 1), new Ability(AbilityId.WIMP_OUT, 7) .attr(PostDamageForceSwitchAbAttr) - .edgeCase(), // Should not trigger when hurting itself in confusion, causes Fake Out to fail turn 1 and succeed turn 2 if pokemon is switched out before battle start via playing in Switch Mode + .condition(getSheerForceHitDisableAbCondition()), new Ability(AbilityId.EMERGENCY_EXIT, 7) .attr(PostDamageForceSwitchAbAttr) - .edgeCase(), // Should not trigger when hurting itself in confusion, causes Fake Out to fail turn 1 and succeed turn 2 if pokemon is switched out before battle start via playing in Switch Mode + .condition(getSheerForceHitDisableAbCondition()), new Ability(AbilityId.WATER_COMPACTION, 7) .attr(PostDefendStatStageChangeAbAttr, (_target, user, move) => user.getMoveType(move) === PokemonType.WATER && move.category !== MoveCategory.STATUS, Stat.DEF, 2), new Ability(AbilityId.MERCILESS, 7) diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 060b17e889b..fc006674e76 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -713,10 +713,16 @@ export class ConfusedTag extends BattlerTag { ); } + /** + * Tick down this Pokemon's confusion duration, randomly interrupting its move if not cured. + * @param pokemon - The {@linkcode Pokemon} with this tag + * @param lapseType - The {@linkcode BattlerTagLapseType | lapse type} triggering this tag's effects. + * @returns Whether the tag should be kept. + */ lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { - const shouldLapse = lapseType !== BattlerTagLapseType.CUSTOM && super.lapse(pokemon, lapseType); + const shouldRemain = super.lapse(pokemon, lapseType); - if (!shouldLapse) { + if (!shouldRemain) { return false; } @@ -730,7 +736,9 @@ export class ConfusedTag extends BattlerTag { phaseManager.unshiftNew("CommonAnimPhase", pokemon.getBattlerIndex(), undefined, CommonAnim.CONFUSION); // 1/3 chance of hitting self with a 40 base power move - if (pokemon.randBattleSeedInt(3) === 0 || Overrides.CONFUSION_ACTIVATION_OVERRIDE === true) { + const shouldInterruptMove = Overrides.CONFUSION_ACTIVATION_OVERRIDE ?? pokemon.randBattleSeedInt(3) === 0; + if (shouldInterruptMove) { + // TODO: Are these calculations correct? We probably shouldn't hardcode the damage formula here... const atk = pokemon.getEffectiveStat(Stat.ATK); const def = pokemon.getEffectiveStat(Stat.DEF); const damage = toDmgValue( diff --git a/src/data/helpers/force-switch.ts b/src/data/helpers/force-switch.ts new file mode 100644 index 00000000000..bfd70ce0757 --- /dev/null +++ b/src/data/helpers/force-switch.ts @@ -0,0 +1,221 @@ +import { applyAbAttrs } from "#app/data/abilities/apply-ab-attrs"; +import type { EnemyPokemon, PlayerPokemon, Pokemon } from "#app/field/pokemon"; +import { globalScene } from "#app/global-scene"; +import { getPokemonNameWithAffix } from "#app/messages"; +import { BooleanHolder, isNullOrUndefined } from "#app/utils/common"; +import { BattleType } from "#enums/battle-type"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import type { NormalSwitchType } from "#enums/switch-type"; +import { SwitchType } from "#enums/switch-type"; +import i18next from "i18next"; + +export interface ForceSwitchOutHelperArgs { + /** + * Whether to switch out the user (`true`) or target (`false`). + * @defaultValue `false` + */ + selfSwitch?: boolean; + /** + * The {@linkcode NormalSwitchType} corresponding to the type of switch logic to implement. + * @defaultValue {@linkcode SwitchType.SWITCH} + */ + switchType?: NormalSwitchType; + /** + * Whether to allow non-boss wild Pokemon to flee when using the move. + * @defaultValue `false` + */ + allowFlee?: boolean; +} + +/** + * Helper class to handle shared logic for force switching effects. + */ +export class ForceSwitchOutHelper implements ForceSwitchOutHelperArgs { + public readonly selfSwitch: boolean; + public readonly switchType: NormalSwitchType; + public readonly allowFlee: boolean; + + constructor({ selfSwitch = false, switchType = SwitchType.SWITCH, allowFlee = false }: ForceSwitchOutHelperArgs) { + this.selfSwitch = selfSwitch; + this.switchType = switchType; + this.allowFlee = allowFlee; + } + + /** + * Determine if a Pokémon can be forcibly switched out based on its status and battle conditions. + * @param switchOutTarget - The {@linkcode Pokemon} being switched out + * @returns Whether {@linkcode switchOutTarget} can be switched out by the current effect. + */ + public canSwitchOut(switchOutTarget: Pokemon): boolean { + const isPlayer = switchOutTarget.isPlayer(); + + if (switchOutTarget.isFainted()) { + // Fainted Pokemon cannot be switched out by any means. + // This is already checked in `MoveEffectAttr.canApply`, but better safe than sorry + return false; + } + + // If we aren't switching ourself out, ensure the target in question can actually be switched out by us + if (!this.selfSwitch && !this.performForceSwitchChecks(switchOutTarget)) { + return false; + } + + // Wild enemies should not be allowed to flee with fleeing moves, nor by any means on X0 waves (don't want easy boss wins) + // TODO: Do we want to show a message for wave X0 failures? + if (!isPlayer && globalScene.currentBattle.battleType === BattleType.WILD) { + return this.allowFlee && globalScene.currentBattle.waveIndex % 10 !== 0; + } + + // Finally, ensure that a trainer switching out has at least 1 valid reserve member to send in. + const reservePartyMembers = globalScene.getBackupPartyMemberIndices(switchOutTarget); + return reservePartyMembers.length > 0; + } + + /** + * Perform various checks to confirm the switched out target can be forcibly removed from the field + * by another Pokemon. + * @param switchOutTarget - The {@linkcode Pokemon} being switched out + * @returns Whether {@linkcode switchOutTarget} can be switched out by another Pokemon. + */ + private performForceSwitchChecks(switchOutTarget: 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", { pokemon: switchOutTarget, cancelled: blockedByAbility }); + if (blockedByAbility.value) { + return false; + } + + // Finally, wild opponents cannot be force switched during MEs with flee disabled + return !( + switchOutTarget.isEnemy() && + globalScene.currentBattle.isBattleMysteryEncounter() && + !globalScene.currentBattle.mysteryEncounter?.fleeAllowed + ); + } + + /** + * Wrapper function to handle the actual "switching out" of Pokemon. + * @param switchOutTarget - The {@linkcode Pokemon} (player or enemy) to be switched out + */ + public doSwitch(switchOutTarget: Pokemon): void { + if (switchOutTarget.isPlayer()) { + this.trySwitchPlayerPokemon(switchOutTarget); + return; + } + + if (globalScene.currentBattle.battleType === BattleType.TRAINER) { + this.trySwitchTrainerPokemon(switchOutTarget as unknown as EnemyPokemon); + } else { + this.tryFleeWildPokemon(switchOutTarget as unknown as EnemyPokemon); + } + + // Hide the info container as soon as the switch out occurs. + // Effects are kept to ensure correct Shell Bell interactions. + // TODO: Should we hide the info container for wild fleeing? + // Currently keeping it same as prior logic for consistency + if (globalScene.currentBattle.battleType === BattleType.WILD) { + switchOutTarget.hideInfo(); + } + } + + /** + * 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) { + globalScene.phaseManager.prependNewToPhase( + "MoveEndPhase", + "SwitchPhase", + this.switchType, + switchOutTarget.getFieldIndex(), + true, + true, + ); + return; + } + + // Pick a random eligible player pokemon to replace the switched out one. + const reservePartyMembers = globalScene.getBackupPartyMemberIndices(true); + // TODO: Change to use rand seed item + const switchInIndex = reservePartyMembers[switchOutTarget.randBattleSeedInt(reservePartyMembers.length)]; + + globalScene.phaseManager.prependNewToPhase( + "MoveEndPhase", + "SwitchSummonPhase", + this.switchType, + switchOutTarget.getFieldIndex(), + switchInIndex, + false, + true, + ); + } + + /** + * 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) { + console.warn("Enemy trainer switch logic triggered without a trainer!"); + return; + } + + // Forced switches will pick a random eligible pokemon from this trainer's side, while + // choice-based switching uses the trainer's default switch behavior. + const reservePartyIndices = globalScene.getBackupPartyMemberIndices(false, switchOutTarget.trainerSlot); + const summonIndex = + this.switchType === SwitchType.FORCE_SWITCH + ? reservePartyIndices[switchOutTarget.randBattleSeedInt(reservePartyIndices.length)] + : globalScene.currentBattle.trainer.getNextSummonIndex(switchOutTarget.trainerSlot); + globalScene.phaseManager.prependNewToPhase( + "MoveEndPhase", + "SwitchSummonPhase", + this.switchType, + switchOutTarget.getFieldIndex(), + summonIndex, + false, + true, + ); + } + + /** + * 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 { + switchOutTarget.leaveField(true); + globalScene.phaseManager.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 + if (!allyPokemon?.isActive(true) && !switchOutTarget.isFainted()) { + globalScene.clearEnemyHeldItemModifiers(); + + globalScene.phaseManager.pushNew("BattleEndPhase", false); + + if (globalScene.gameMode.hasRandomBiomes || globalScene.isNewBiome()) { + globalScene.phaseManager.pushNew("SelectBiomePhase"); + } + + globalScene.phaseManager.pushNew("NewBattlePhase"); + } + } +} diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index d8863016002..e5659082176 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -25,6 +25,7 @@ import { getBerryEffectFunc } from "#data/berry"; import { applyChallenges } from "#data/challenge"; import { allAbilities, allMoves } from "#data/data-lists"; import { SpeciesFormChangeRevertWeatherFormTrigger } from "#data/form-change-triggers"; +import { ForceSwitchOutHelper, ForceSwitchOutHelperArgs } from "#data/helpers/force-switch"; import { getNonVolatileStatusEffects, getStatusEffectHealText, @@ -1297,10 +1298,12 @@ export class MoveEffectAttr extends MoveAttr { * @param user {@linkcode Pokemon} using the move * @param target {@linkcode Pokemon} target of the move * @param move {@linkcode Move} with this attribute - * @param args Set of unique arguments needed by this attribute - * @returns true if basic application of the ability attribute should be possible + * @param args - Any unique arguments needed by this attribute + * @returns `true` if basic application of the ability attribute should be possible. + * By default, checks that the target is not fainted and (for non self-targeting moves) not protected by an effect. */ canApply(user: Pokemon, target: Pokemon, move: Move, args?: any[]) { + // TODO: why do we check frenzy tag here? return !! (this.selfTarget ? user.hp && !user.getTag(BattlerTagType.FRENZY) : target.hp) && (this.selfTarget || !target.getTag(BattlerTagType.PROTECTED) || move.doesFlagEffectApply({ flag: MoveFlags.IGNORE_PROTECT, user, target })); @@ -1622,6 +1625,7 @@ export class MatchHpAttr extends FixedDamageAttr { type MoveFilter = (move: Move) => boolean; +// TODO: fix this to check the last direct damage instance taken export class CounterDamageAttr extends FixedDamageAttr { private moveFilter: MoveFilter; private multiplier: number; @@ -1703,6 +1707,7 @@ export class CelebrateAttr extends MoveEffectAttr { } export class RecoilAttr extends MoveEffectAttr { + /** Whether the recoil damage should use the user's max HP (`true`) or damage dealt `false`. */ private useHp: boolean; private damageRatio: number; private unblockable: boolean; @@ -1736,8 +1741,8 @@ export class RecoilAttr extends MoveEffectAttr { return false; } - const damageValue = (!this.useHp ? user.turnData.totalDamageDealt : user.getMaxHp()) * this.damageRatio; - const minValue = user.turnData.totalDamageDealt ? 1 : 0; + const damageValue = (!this.useHp ? user.turnData.singleHitDamageDealt : user.getMaxHp()) * this.damageRatio; + const minValue = user.turnData.singleHitDamageDealt ? 1 : 0; const recoilDamage = toDmgValue(damageValue, minValue); if (!recoilDamage) { return false; @@ -1890,8 +1895,8 @@ export class AddSubstituteAttr extends MoveEffectAttr { } /** - * Removes 1/4 of the user's maximum HP (rounded down) to create a substitute for the user - * @param user - The {@linkcode Pokemon} that used the move. + * Removes a fraction of the user's maximum HP to create a substitute. + * @param user - The {@linkcode Pokemon} using the move. * @param target - n/a * @param move - The {@linkcode Move} with this attribute. * @param args - n/a @@ -1902,7 +1907,7 @@ export class AddSubstituteAttr extends MoveEffectAttr { return false; } - const damageTaken = this.roundUp ? Math.ceil(user.getMaxHp() * this.hpCost) : Math.floor(user.getMaxHp() * this.hpCost); + const damageTaken = (this.roundUp ? Math.ceil : Math.floor)(user.getMaxHp() * this.hpCost); user.damageAndUpdate(damageTaken, { result: HitResult.INDIRECT, ignoreSegments: true, ignoreFaintPhase: true }); user.addTag(BattlerTagType.SUBSTITUTE, 0, move.id, user.id); return true; @@ -2072,6 +2077,13 @@ export class FlameBurstAttr extends MoveEffectAttr { } } +/** + * Attribute to KO the user while fully restoring HP/status of the next switched in Pokemon. + * + * Used for {@linkcode Moves.HEALING_WISH} and {@linkcode Moves.LUNAR_DANCE}. + * TODO: Implement "heal storing" if switched in pokemon is at full HP (likely with an end-of-turn ArenaTag). + * Will likely be blocked by the need for a "slot dependent ArenaTag" similar to Future Sight + */ export class SacrificialFullRestoreAttr extends SacrificialAttr { protected restorePP: boolean; protected moveMessage: string; @@ -2089,8 +2101,8 @@ export class SacrificialFullRestoreAttr extends SacrificialAttr { } // We don't know which party member will be chosen, so pick the highest max HP in the party - const party = user.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty(); - const maxPartyMemberHp = party.map(p => p.getMaxHp()).reduce((maxHp: number, hp: number) => Math.max(hp, maxHp), 0); + const party: Pokemon[] = user.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty(); + const maxPartyMemberHp = Math.max(...party.map(p => p.getMaxHp())); const pm = globalScene.phaseManager; @@ -2115,7 +2127,10 @@ export class SacrificialFullRestoreAttr extends SacrificialAttr { } getCondition(): MoveConditionFunc { - return (user, _target, _move) => globalScene.getPlayerParty().filter(p => p.isActive()).length > globalScene.currentBattle.getBattlerCount(); + return (user) => { + const otherPartyIndices = globalScene.getBackupPartyMemberIndices(user) + return otherPartyIndices.length > 0; + } } } @@ -6278,168 +6293,61 @@ export class RevivalBlessingAttr extends MoveEffectAttr { } } - +/** + * Attribute to forcibly switch out the user or target of a Move. + */ +// TODO: Add locales for forced recall moves export class ForceSwitchOutAttr extends MoveEffectAttr { - constructor( - private selfSwitch: boolean = false, - private switchType: SwitchType = SwitchType.SWITCH - ) { - super(false, { lastHitOnly: true }); + private readonly helper: ForceSwitchOutHelper; + + constructor(args: ForceSwitchOutHelperArgs) { + super(false, { lastHitOnly: true }); // procy to + this.helper = new ForceSwitchOutHelper(args); } - isBatonPass() { - return this.switchType === SwitchType.BATON_PASS; + apply(user: Pokemon, target: Pokemon, move: Move, _args: any[]): boolean { + if (!super.apply(user, target, move, _args)) { + return false; + }; + + this.helper.doSwitch(this.helper.selfSwitch ? user : target) + return true; } - 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)) { + /** + * Check whether the target can be switched out. + */ + override canApply(user: Pokemon, target: Pokemon, _move: Move, _args: any[]) { + if (!super.canApply(user, target, _move, _args)) { return false; } - /** The {@linkcode Pokemon} to be switched out with this effect */ - const switchOutTarget = this.selfSwitch ? user : target; + const switchOutTarget = this.helper.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.isPlayer()) { - /** - * 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") - && [ MoveId.U_TURN, MoveId.VOLT_SWITCH, MoveId.FLIP_TURN ].includes(move.id) + // Check for Wimp Out edge case - self-switching moves cannot proc if the attack also triggers Wimp Out/EE + // TODO: This can be improved with a move in flight global object + const moveDmgDealt = user.turnData.lastMoveDamageDealt[target.getBattlerIndex()] + if ( + this.helper.selfSwitch + && moveDmgDealt + && target.getAbilityAttrs("PostDamageForceSwitchAbAttr").some( + p => p.canApply({pokemon: target, damage: moveDmgDealt, simulated: false, source: user})) ) { - 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.randBattleSeedInt(eligibleNewIndices.length)]; - globalScene.phaseManager.prependNewToPhase( - "MoveEndPhase", - "SwitchSummonPhase", - this.switchType, - switchOutTarget.getFieldIndex(), - slotIndex, - false, - true - ); - } else { - switchOutTarget.leaveField(this.switchType === SwitchType.SWITCH); - globalScene.phaseManager.prependNewToPhase("MoveEndPhase", - "SwitchPhase", - this.switchType, - switchOutTarget.getFieldIndex(), - true, - true - ); - 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.randBattleSeedInt(eligibleNewIndices.length)]; - globalScene.phaseManager.prependNewToPhase("MoveEndPhase", - "SwitchSummonPhase", - this.switchType, - switchOutTarget.getFieldIndex(), - slotIndex, - false, - false - ); - } else { - switchOutTarget.leaveField(this.switchType === SwitchType.SWITCH); - globalScene.phaseManager.prependNewToPhase("MoveEndPhase", - "SwitchSummonPhase", - this.switchType, - switchOutTarget.getFieldIndex(), - (globalScene.currentBattle.trainer ? globalScene.currentBattle.trainer.getNextSummonIndex((switchOutTarget as EnemyPokemon).trainerSlot) : 0), - false, - false - ); - } - } - } 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") - && [ MoveId.U_TURN, MoveId.VOLT_SWITCH, MoveId.FLIP_TURN ].includes(move.id) - ) { - if (this.hpDroppedBelowHalf(target)) { - return false; - } - } - - const allyPokemon = switchOutTarget.getAlly(); - - if (switchOutTarget.hp > 0) { - switchOutTarget.leaveField(false); - globalScene.phaseManager.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.phaseManager.pushNew("BattleEndPhase", false); - - if (globalScene.gameMode.hasRandomBiomes || globalScene.isNewBiome()) { - globalScene.phaseManager.pushNew("SelectBiomePhase"); - } - - globalScene.phaseManager.pushNew("NewBattlePhase"); - } - } - - return true; + return this.helper.canSwitchOut(switchOutTarget) } getCondition(): MoveConditionFunc { - return (user, target, move) => (move.category !== MoveCategory.STATUS || this.getSwitchOutCondition()(user, target, move)); + // Damaging switch moves and ones w/o a secondary effect do not "fail" + // upon an unsuccessful switch - they still succeed and perform secondary effects + // (just without actually switching out). + // TODO: Remove attr check once move attribute application is cleaned up + return (user, target, move) => (move.category !== MoveCategory.STATUS || move.attrs.length > 1 || this.canApply(user, target)); } - getFailedText(_user: Pokemon, target: Pokemon, _move: Move): string | undefined { + getFailedText(_user: Pokemon, target: Pokemon): string | undefined { const cancelled = new BooleanHolder(false); applyAbAttrs("ForceSwitchOutImmunityAbAttr", {pokemon: target, cancelled}); if (cancelled.value) { @@ -6447,86 +6355,24 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { } } - - getSwitchOutCondition(): MoveConditionFunc { - return (user, target, move) => { - const switchOutTarget = (this.selfSwitch ? user : target); - const player = switchOutTarget.isPlayer(); - const forceSwitchAttr = move.getAttrs("ForceSwitchOutAttr").find(attr => attr.switchType === SwitchType.FORCE_SWITCH); - - if (!this.selfSwitch) { - if (move.hitsSubstitute(user, target)) { - return false; - } - - // Check if the move is Roar or Whirlwind and if there is a trainer with only Pokémon left. - if (forceSwitchAttr && globalScene.currentBattle.trainer) { - const enemyParty = globalScene.getEnemyParty(); - // Filter out any Pokémon that are not allowed in battle (e.g. fainted ones) - const remainingPokemon = enemyParty.filter(p => p.hp > 0 && p.isAllowedInBattle()); - if (remainingPokemon.length <= 1) { - return false; - } - } - - // 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", {pokemon: target, cancelled: blockedByAbility}); - if (blockedByAbility.value) { - 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; - }; - } - getUserBenefitScore(user: Pokemon, target: Pokemon, move: Move): number { - if (!globalScene.getEnemyParty().find(p => p.isActive() && !p.isOnField())) { + const switchOutTarget = this.helper.selfSwitch ? user : target; + const reservePartyMembers = globalScene.getBackupPartyMemberIndices(switchOutTarget) + if (reservePartyMembers.length === 0) { return -20; } - let ret = this.selfSwitch ? Math.floor((1 - user.getHpRatio()) * 20) : super.getUserBenefitScore(user, target, move); - if (this.selfSwitch && this.isBatonPass()) { - const statStageTotal = user.getStatStages().reduce((s: number, total: number) => total += s, 0); + + let ret = this.helper.selfSwitch ? Math.floor((1 - user.getHpRatio()) * 20) : super.getUserBenefitScore(user, target, move); + if (this.helper.selfSwitch && this.isBatonPass()) { + const statStageTotal = user.getStatStages().reduce((total, s) => total + s, 0); + // TODO: Why do we use a sine tween? ret = ret / 2 + (Phaser.Tweens.Builders.GetEaseFunction("Sine.easeOut")(Math.min(Math.abs(statStageTotal), 10) / 10) * (statStageTotal >= 0 ? 10 : -10)); } return ret; } - /** - * Helper function to check if the Pokémon's health is below half after taking damage. - * Used for an edge case interaction with Wimp Out/Emergency Exit. - * If the Ability activates due to being hit by U-turn or Volt Switch, the user of that move will not be switched out. - */ - hpDroppedBelowHalf(target: Pokemon): boolean { - const pokemonHealth = target.hp; - const maxPokemonHealth = target.getMaxHp(); - const damageTaken = target.turnData.damageTaken; - const initialHealth = pokemonHealth + damageTaken; - - // Check if the Pokémon's health has dropped below half after the damage - return initialHealth >= maxPokemonHealth / 2 && pokemonHealth < maxPokemonHealth / 2; + public isBatonPass(): boolean { + return this.helper.switchType === SwitchType.BATON_PASS; } } @@ -6537,10 +6383,12 @@ 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 - return (user, target, move) => globalScene.arena.weather?.weatherType !== WeatherType.SNOW || super.getSwitchOutCondition()(user, target, move); + // 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.getCondition()(user, target, move); } } + export class RemoveTypeAttr extends MoveEffectAttr { private removedType: PokemonType; @@ -8008,11 +7856,6 @@ const targetSleptOrComatoseCondition: MoveConditionFunc = (user: Pokemon, target const failIfLastCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => globalScene.phaseManager.phaseQueue.find(phase => phase.is("MovePhase")) !== undefined; -const failIfLastInPartyCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => { - const party: Pokemon[] = user.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty(); - return party.some(pokemon => pokemon.isActive() && !pokemon.isOnField()); -}; - const failIfGhostTypeCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => !target.isOfType(PokemonType.GHOST); const failIfNoTargetHeldItemsCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => target.getHeldItems().filter(i => i.isTransferable)?.length > 0; @@ -8479,7 +8322,7 @@ export function initMoves() { .windMove(), new AttackMove(MoveId.WING_ATTACK, PokemonType.FLYING, MoveCategory.PHYSICAL, 60, 100, 35, -1, 0, 1), new StatusMove(MoveId.WHIRLWIND, PokemonType.NORMAL, -1, 20, -1, -6, 1) - .attr(ForceSwitchOutAttr, false, SwitchType.FORCE_SWITCH) + .attr(ForceSwitchOutAttr, {switchType: SwitchType.FORCE_SWITCH, allowFlee: true}) .ignoresSubstitute() .hidesTarget() .windMove() @@ -8562,7 +8405,7 @@ export function initMoves() { .target(MoveTarget.ALL_NEAR_ENEMIES) .reflectable(), new StatusMove(MoveId.ROAR, PokemonType.NORMAL, -1, 20, -1, -6, 1) - .attr(ForceSwitchOutAttr, false, SwitchType.FORCE_SWITCH) + .attr(ForceSwitchOutAttr, {selfSwitch: true, switchType: SwitchType.FORCE_SWITCH, allowFlee: true}) .soundBased() .hidesTarget() .reflectable(), @@ -8722,7 +8565,7 @@ export function initMoves() { new AttackMove(MoveId.RAGE, PokemonType.NORMAL, MoveCategory.PHYSICAL, 20, 100, 20, -1, 0, 1) .partial(), // No effect implemented new SelfStatusMove(MoveId.TELEPORT, PokemonType.PSYCHIC, -1, 20, -1, -6, 1) - .attr(ForceSwitchOutAttr, true) + .attr(ForceSwitchOutAttr, {selfSwitch: true, switchType: SwitchType.SWITCH, allowFlee: true}) .hidesUser(), new AttackMove(MoveId.NIGHT_SHADE, PokemonType.GHOST, MoveCategory.SPECIAL, -1, 100, 15, -1, 0, 1) .attr(LevelDamageAttr), @@ -9122,8 +8965,7 @@ export function initMoves() { new AttackMove(MoveId.DRAGON_BREATH, PokemonType.DRAGON, MoveCategory.SPECIAL, 60, 100, 20, 30, 0, 2) .attr(StatusEffectAttr, StatusEffect.PARALYSIS), new SelfStatusMove(MoveId.BATON_PASS, PokemonType.NORMAL, -1, 40, -1, 0, 2) - .attr(ForceSwitchOutAttr, true, SwitchType.BATON_PASS) - .condition(failIfLastInPartyCondition) + .attr(ForceSwitchOutAttr, {selfSwitch: true, switchType: SwitchType.BATON_PASS}) .hidesUser(), new StatusMove(MoveId.ENCORE, PokemonType.NORMAL, 100, 5, -1, 0, 2) .attr(AddBattlerTagAttr, BattlerTagType.ENCORE, false, true) @@ -9566,8 +9408,7 @@ export function initMoves() { .ballBombMove(), new SelfStatusMove(MoveId.HEALING_WISH, PokemonType.PSYCHIC, -1, 10, -1, 0, 4) .attr(SacrificialFullRestoreAttr, false, "moveTriggers:sacrificialFullRestore") - .triageMove() - .condition(failIfLastInPartyCondition), + .triageMove(), new AttackMove(MoveId.BRINE, PokemonType.WATER, MoveCategory.SPECIAL, 65, 100, 10, -1, 0, 4) .attr(MovePowerMultiplierAttr, (user, target, move) => target.getHpRatio() < 0.5 ? 2 : 1), new AttackMove(MoveId.NATURAL_GIFT, PokemonType.NORMAL, MoveCategory.PHYSICAL, -1, 100, 15, -1, 0, 4) @@ -9598,7 +9439,7 @@ export function initMoves() { .makesContact(false) .target(MoveTarget.ATTACKER), new AttackMove(MoveId.U_TURN, PokemonType.BUG, MoveCategory.PHYSICAL, 70, 100, 20, -1, 0, 4) - .attr(ForceSwitchOutAttr, true), + .attr(ForceSwitchOutAttr, {selfSwitch: true}), new AttackMove(MoveId.CLOSE_COMBAT, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 120, 100, 5, -1, 0, 4) .attr(StatStageChangeAttr, [ Stat.DEF, Stat.SPDEF ], -1, true), new AttackMove(MoveId.PAYBACK, PokemonType.DARK, MoveCategory.PHYSICAL, 50, 100, 10, -1, 0, 4) @@ -9865,8 +9706,7 @@ export function initMoves() { new SelfStatusMove(MoveId.LUNAR_DANCE, PokemonType.PSYCHIC, -1, 10, -1, 0, 4) .attr(SacrificialFullRestoreAttr, true, "moveTriggers:lunarDanceRestore") .danceMove() - .triageMove() - .condition(failIfLastInPartyCondition), + .triageMove(), new AttackMove(MoveId.CRUSH_GRIP, PokemonType.NORMAL, MoveCategory.PHYSICAL, -1, 100, 5, -1, 0, 4) .attr(OpponentHighHpPowerAttr, 120), new AttackMove(MoveId.MAGMA_STORM, PokemonType.FIRE, MoveCategory.SPECIAL, 100, 75, 5, -1, 0, 4) @@ -10027,7 +9867,7 @@ export function initMoves() { .attr(StatStageChangeAttr, [ Stat.ATK ], 1, true) .attr(StatStageChangeAttr, [ Stat.SPD ], 2, true), new AttackMove(MoveId.CIRCLE_THROW, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 60, 90, 10, -1, -6, 5) - .attr(ForceSwitchOutAttr, false, SwitchType.FORCE_SWITCH) + .attr(ForceSwitchOutAttr, {switchType: SwitchType.FORCE_SWITCH, allowFlee: true}) .hidesTarget(), new AttackMove(MoveId.INCINERATE, PokemonType.FIRE, MoveCategory.SPECIAL, 60, 100, 15, -1, 0, 5) .target(MoveTarget.ALL_NEAR_ENEMIES) @@ -10087,7 +9927,7 @@ export function initMoves() { .attr(AddPledgeEffectAttr, ArenaTagType.FIRE_GRASS_PLEDGE, MoveId.FIRE_PLEDGE) .attr(BypassRedirectAttr, true), new AttackMove(MoveId.VOLT_SWITCH, PokemonType.ELECTRIC, MoveCategory.SPECIAL, 70, 100, 20, -1, 0, 5) - .attr(ForceSwitchOutAttr, true), + .attr(ForceSwitchOutAttr, {selfSwitch: true}), new AttackMove(MoveId.STRUGGLE_BUG, PokemonType.BUG, MoveCategory.SPECIAL, 50, 100, 20, 100, 0, 5) .attr(StatStageChangeAttr, [ Stat.SPATK ], -1) .target(MoveTarget.ALL_NEAR_ENEMIES), @@ -10099,7 +9939,7 @@ export function initMoves() { new AttackMove(MoveId.FROST_BREATH, PokemonType.ICE, MoveCategory.SPECIAL, 60, 90, 10, -1, 0, 5) .attr(CritOnlyAttr), new AttackMove(MoveId.DRAGON_TAIL, PokemonType.DRAGON, MoveCategory.PHYSICAL, 60, 90, 10, -1, -6, 5) - .attr(ForceSwitchOutAttr, false, SwitchType.FORCE_SWITCH) + .attr(ForceSwitchOutAttr, {switchType: SwitchType.FORCE_SWITCH, allowFlee: true}) .hidesTarget(), new SelfStatusMove(MoveId.WORK_UP, PokemonType.NORMAL, -1, 30, -1, 0, 5) .attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], 1, true), @@ -10255,9 +10095,10 @@ export function initMoves() { .target(MoveTarget.ALL_NEAR_ENEMIES), new StatusMove(MoveId.PARTING_SHOT, PokemonType.DARK, 100, 20, -1, 0, 6) .attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], -1, false, { trigger: MoveEffectTrigger.PRE_APPLY }) - .attr(ForceSwitchOutAttr, true) + .attr(ForceSwitchOutAttr, {selfSwitch: true}) .soundBased() - .reflectable(), + .reflectable() + .edgeCase(), // should not fail if no target is switched out new StatusMove(MoveId.TOPSY_TURVY, PokemonType.DARK, -1, 20, -1, 0, 6) .attr(InvertStatsAttr) .reflectable(), @@ -10987,7 +10828,7 @@ export function initMoves() { .target(MoveTarget.NEAR_ALLY) .condition(failIfSingleBattle), new AttackMove(MoveId.FLIP_TURN, PokemonType.WATER, MoveCategory.PHYSICAL, 60, 100, 20, -1, 0, 8) - .attr(ForceSwitchOutAttr, true), + .attr(ForceSwitchOutAttr, {selfSwitch: true}), new AttackMove(MoveId.TRIPLE_AXEL, PokemonType.ICE, MoveCategory.PHYSICAL, 20, 90, 10, -1, 0, 8) .attr(MultiHitAttr, MultiHitType._3) .attr(MultiHitPowerIncrementAttr, 3) @@ -11320,15 +11161,14 @@ export function initMoves() { .makesContact(), new SelfStatusMove(MoveId.SHED_TAIL, PokemonType.NORMAL, -1, 10, -1, 0, 9) .attr(AddSubstituteAttr, 0.5, true) - .attr(ForceSwitchOutAttr, true, SwitchType.SHED_TAIL) - .condition(failIfLastInPartyCondition), + .attr(ForceSwitchOutAttr, {selfSwitch: true, switchType: SwitchType.SHED_TAIL}), new SelfStatusMove(MoveId.CHILLY_RECEPTION, PokemonType.ICE, -1, 10, -1, 0, 9) .attr(PreMoveMessageAttr, (user, _target, _move) => // Don't display text if current move phase is follow up (ie move called indirectly) isVirtual((globalScene.phaseManager.getCurrentPhase() as MovePhase).useMode) ? "" : i18next.t("moveTriggers:chillyReception", { pokemonName: getPokemonNameWithAffix(user) })) - .attr(ChillyReceptionAttr, true), + .attr(ChillyReceptionAttr, {selfSwitch: true}), new SelfStatusMove(MoveId.TIDY_UP, PokemonType.NORMAL, -1, 10, -1, 0, 9) .attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPD ], 1, true) .attr(RemoveArenaTrapAttr, true) diff --git a/src/data/mystery-encounters/mystery-encounter.ts b/src/data/mystery-encounters/mystery-encounter.ts index a2ca2b20ce7..83497b460c5 100644 --- a/src/data/mystery-encounters/mystery-encounter.ts +++ b/src/data/mystery-encounters/mystery-encounter.ts @@ -340,16 +340,14 @@ export class MysteryEncounter implements IMysteryEncounter { * can cause scenarios where there are not enough Pokemon that are sufficient for all requirements. */ private meetsPrimaryRequirementAndPrimaryPokemonSelected(): boolean { - 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]; - } + let qualified: PlayerPokemon[] = globalScene.getPlayerParty(); + if (!this.primaryPokemonRequirements?.length) { + // 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/data/pokemon/pokemon-data.ts b/src/data/pokemon/pokemon-data.ts index 6ae86bed5e7..2c11ec7cbcd 100644 --- a/src/data/pokemon/pokemon-data.ts +++ b/src/data/pokemon/pokemon-data.ts @@ -181,7 +181,14 @@ export class PokemonTurnData { * - `0` = Move is finished */ public hitsLeft = -1; - public totalDamageDealt = 0; + /** + * The final amount of damage dealt by this Pokemon's last attack against each of its targets, + * indexed by their respective `BattlerIndex`es. \ + * Reset to an empty array upon attempting to use a move, + * and is used to calculate various damage-related effects (Shell Bell, U-Turn + Wimp Out interactions, etc.). + */ + // TODO: move this or something like it to some sort of "move in flight" object + public lastMoveDamageDealt: number[] = [0, 0, 0, 0]; public singleHitDamageDealt = 0; public damageTaken = 0; public attacksReceived: AttackMoveResult[] = []; diff --git a/src/enums/switch-type.ts b/src/enums/switch-type.ts index d55872ae83b..a33f96a37da 100644 --- a/src/enums/switch-type.ts +++ b/src/enums/switch-type.ts @@ -14,3 +14,6 @@ export enum SwitchType { /** Force switchout to a random party member */ FORCE_SWITCH, } + +/** Union type of all "normal" switch types that can be used by force switch moves. */ +export type NormalSwitchType = Exclude \ No newline at end of file diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 01f137b28fd..8cbe478a670 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -241,6 +241,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { public luck: number; public pauseEvolutions: boolean; public pokerus: boolean; + /** Whether this Pokemon is currently attempting to switch in. */ public switchOutStatus = false; public evoCounter: number; public teraType: PokemonType; @@ -1234,7 +1235,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * @see {@linkcode SubstituteTag} * @see {@linkcode getFieldPositionOffset} */ - getSubstituteOffset(): [number, number] { + getSubstituteOffset(): [x: number, y: number] { return this.isPlayer() ? [-30, 10] : [30, -10]; } @@ -1647,6 +1648,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { return this.getMaxHp() - this.hp; } + /** + * Return this Pokemon's current HP as a fraction of its maximum HP. + * @param precise - Whether to return the exact HP ratio (`true`) or rounded to the nearest 1% (`false`); default `false` + * @returns This pokemon's current HP ratio (current / max). + */ getHpRatio(precise = false): number { return precise ? this.hp / this.getMaxHp() : Math.round((this.hp / this.getMaxHp()) * 100) / 100; } @@ -4106,13 +4112,15 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * Given the damage, adds a new DamagePhase and update HP values, etc. * * Checks for 'Indirect' HitResults to account for Endure/Reviver Seed applying correctly - * @param damage integer - passed to damage() - * @param result an enum if it's super effective, not very, etc. - * @param isCritical boolean if move is a critical hit - * @param ignoreSegments boolean, passed to damage() and not used currently - * @param preventEndure boolean, ignore endure properties of pokemon, passed to damage() - * @param ignoreFaintPhase boolean to ignore adding a FaintPhase, passsed to damage() - * @returns integer of damage done + * @param damage - Amount of damage to deal + * @param result - The {@linkcode HitResult} of the damage instance; default `HitResult.EFFECTIVE` + * @param isCritical - Whether the move being used (if any) was a critical hit; default `false` + * @param ignoreSegments - Whether to ignore boss segments; default `false` and currently unused + * @param preventEndure - Whether to ignore {@linkcode Moves.ENDURE} and similar effects when applying damage; default `false` + * @param ignoreFaintPhase - Whether to ignore adding a faint phase if the damage causes the target to faint; default `false` + * @returns The amount of damage actually dealt. + * @remarks + * This will not trigger "on damage" effects for direct damage moves, instead occuring at the end of `MoveEffectPhase`. */ damageAndUpdate( damage: number, @@ -4121,13 +4129,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { isCritical = false, ignoreSegments = false, ignoreFaintPhase = false, - source = undefined, }: { result?: DamageResult; isCritical?: boolean; ignoreSegments?: boolean; ignoreFaintPhase?: boolean; - source?: Pokemon; } = {}, ): number { const isIndirectDamage = [HitResult.INDIRECT, HitResult.INDIRECT_KO].includes(result); @@ -4135,27 +4141,30 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { "DamageAnimPhase", this.getBattlerIndex(), damage, - result as DamageResult, + result, isCritical, ); globalScene.phaseManager.unshiftPhase(damagePhase); - if (this.switchOutStatus && source) { + + // Prevent enemies not on field from taking damage. + // TODO: Review if wimp out actually needs this anymore + if (this.switchOutStatus) { damage = 0; } + damage = this.damage(damage, ignoreSegments, isIndirectDamage, ignoreFaintPhase); // Ensure the battle-info bar's HP is updated, though only if the battle info is visible // TODO: When battle-info UI is refactored, make this only update the HP bar if (this.battleInfo.visible) { this.updateInfo(); } + // Damage amount may have changed, but needed to be queued before calling damage function damagePhase.updateAmount(damage); - /** - * Run PostDamageAbAttr from any source of damage that is not from a multi-hit - * Multi-hits are handled in move-effect-phase.ts for PostDamageAbAttr - */ - if (!source || source.turnData.hitCount <= 1) { - applyAbAttrs("PostDamageAbAttr", { pokemon: this, damage, source }); + + // Trigger PostDamageAbAttr (ie wimp out) for indirect, non-confusion damage instances. + if (isIndirectDamage && result !== HitResult.CONFUSION) { + applyAbAttrs("PostDamageAbAttr", { pokemon: this, damage }); } return damage; } @@ -4369,6 +4378,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } for (const tag of source.summonData.tags) { + // Skip non-Baton Passable tags (or telekinesis for mega gengar; cf. https://bulbapedia.bulbagarden.net/wiki/Telekinesis_(move)) if ( !tag.isBatonPassable || (tag.tagType === BattlerTagType.TELEKINESIS && @@ -5077,8 +5087,12 @@ export 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 {@linkcode leaveField} is already being called, + * which already calls this function. */ 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; @@ -5120,6 +5134,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } resetTurnData(): void { + console.log(`resetTurnData called on Pokemon ${this.name}`); this.turnData = new PokemonTurnData(); } @@ -5562,15 +5577,18 @@ export 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 with `clearEffects=true` when a `SummonPhase` or `SwitchSummonPhase` is already being added, + * both of which do so already and can lead to premature resetting of {@linkcode turnData} and {@linkcode summonData}. */ + // TODO: Review where this is being called and where it is necessary to call it leaveField(clearEffects = true, hideInfo = true, destroy = false) { + console.log(`leaveField called on Pokemon ${this.name}`); this.resetSprite(); - this.resetTurnData(); globalScene .getField(true) .filter(p => p !== this) @@ -5579,6 +5597,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { if (clearEffects) { this.destroySubstitute(); this.resetSummonData(); + this.resetTurnData(); } if (hideInfo) { this.hideInfo(); diff --git a/src/modifier/modifier.ts b/src/modifier/modifier.ts index f8c35b3e8f9..2ff4ea86a86 100644 --- a/src/modifier/modifier.ts +++ b/src/modifier/modifier.ts @@ -1764,20 +1764,26 @@ export class HitHealModifier extends PokemonHeldItemModifier { * @returns `true` if the {@linkcode Pokemon} was healed */ override apply(pokemon: Pokemon): boolean { - if (pokemon.turnData.totalDamageDealt && !pokemon.isFullHp()) { - // TODO: this shouldn't be undefined AFAIK - globalScene.phaseManager.unshiftNew( - "PokemonHealPhase", - pokemon.getBattlerIndex(), - toDmgValue(pokemon.turnData.totalDamageDealt / 8) * this.stackCount, - i18next.t("modifier:hitHealApply", { - pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - typeName: this.type.name, - }), - true, - ); + if (pokemon.isFullHp()) { + return false; } + // Collate the amount of damage this attack did against all its targets. + const totalDmgDealt = pokemon.turnData.lastMoveDamageDealt.reduce((r, d) => r + d, 0); + if (totalDmgDealt === 0) { + return false; + } + + globalScene.phaseManager.unshiftNew( + "PokemonHealPhase", + pokemon.getBattlerIndex(), + toDmgValue(totalDmgDealt / 8) * this.stackCount, + i18next.t("modifier:hitHealApply", { + pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), + typeName: this.type.name, + }), + true, + ); return true; } diff --git a/src/phases/check-switch-phase.ts b/src/phases/check-switch-phase.ts index f4e8ee56c55..acea57009d5 100644 --- a/src/phases/check-switch-phase.ts +++ b/src/phases/check-switch-phase.ts @@ -38,16 +38,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/faint-phase.ts b/src/phases/faint-phase.ts index d1bd0ed0804..9bf22ede82a 100644 --- a/src/phases/faint-phase.ts +++ b/src/phases/faint-phase.ts @@ -49,8 +49,6 @@ export class FaintPhase extends PokemonPhase { faintPokemon.getTag(BattlerTagType.GRUDGE)?.lapse(faintPokemon, BattlerTagLapseType.CUSTOM, this.source); } - faintPokemon.resetSummonData(); - if (!this.preventInstantRevive) { const instantReviveModifier = globalScene.applyModifier( PokemonInstantReviveModifier, @@ -59,6 +57,7 @@ export class FaintPhase extends PokemonPhase { ) as PokemonInstantReviveModifier; if (instantReviveModifier) { + faintPokemon.resetSummonData(); faintPokemon.loseHeldItem(instantReviveModifier); globalScene.updateModifiers(this.player); return this.end(); @@ -146,41 +145,32 @@ export class FaintPhase extends PokemonPhase { } } + const legalBackupPokemon = globalScene.getBackupPartyMemberIndices(pokemon); + if (this.player) { - /** The total number of Pokemon in the player's party that can legally fight */ + /** An array of Pokemon in the player's party that can legally fight. */ const legalPlayerPokemon = globalScene.getPokemonAllowedInBattle(); - /** The total number of legal player Pokemon that aren't currently on the field */ - const legalPlayerPartyPokemon = legalPlayerPokemon.filter(p => !p.isActive(true)); - if (!legalPlayerPokemon.length) { - /** If the player doesn't have any legal Pokemon, end the game */ + if (legalPlayerPokemon.length === 0) { + // If the player doesn't have any legal Pokemon left in their party, end the game. globalScene.phaseManager.unshiftNew("GameOverPhase"); - } else if ( - globalScene.currentBattle.double && - legalPlayerPokemon.length === 1 && - legalPlayerPartyPokemon.length === 0 - ) { - /** - * If the player has exactly one Pokemon in total at this point in a double battle, and that Pokemon - * is already on the field, unshift a phase that moves that Pokemon to center position. - */ + } else if (globalScene.currentBattle.double && legalBackupPokemon.length === 0) { + /* + Otherwise, if the player has no reserve members left to switch in, + unshift a phase to move the other on-field pokemon to center position. + */ globalScene.phaseManager.unshiftNew("ToggleDoublePositionPhase", true); - } else if (legalPlayerPartyPokemon.length > 0) { - /** - * If previous conditions weren't met, and the player has at least 1 legal Pokemon off the field, - * push a phase that prompts the player to summon a Pokemon from their party. - */ + } else { + // If previous conditions weren't met, push a phase to prompt the player to select a new pokemon from their party. globalScene.phaseManager.pushNew("SwitchPhase", SwitchType.SWITCH, this.fieldIndex, true, false); } } else { + // Unshift a phase for EXP gains and/or one to switch in a replacement party member. globalScene.phaseManager.unshiftNew("VictoryPhase", this.battlerIndex); - if ([BattleType.TRAINER, BattleType.MYSTERY_ENCOUNTER].includes(globalScene.currentBattle.battleType)) { - const hasReservePartyMember = !!globalScene - .getEnemyParty() - .filter(p => p.isActive() && !p.isOnField() && p.trainerSlot === (pokemon as EnemyPokemon).trainerSlot) - .length; - if (hasReservePartyMember) { - globalScene.phaseManager.pushNew("SwitchSummonPhase", SwitchType.SWITCH, this.fieldIndex, -1, false, false); - } + if ( + [BattleType.TRAINER, BattleType.MYSTERY_ENCOUNTER].includes(globalScene.currentBattle.battleType) && + legalBackupPokemon.length > 0 + ) { + globalScene.phaseManager.pushNew("SwitchSummonPhase", SwitchType.SWITCH, this.fieldIndex, -1, false, false); } } diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index 4446b2071d0..32017850cbc 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -64,7 +64,7 @@ export class MoveEffectPhase extends PokemonPhase { /** Is this the first strike of a move? */ private firstHit: boolean; - /** Is this the last strike of a move? */ + /** Is this the last strike of a move (either due to running out of hits or all targets being fainted/immune)? */ private lastHit: boolean; /** @@ -329,7 +329,7 @@ export class MoveEffectPhase extends PokemonPhase { const targets = this.conductHitChecks(user, fieldMove); this.firstHit = user.turnData.hitCount === user.turnData.hitsLeft; - this.lastHit = user.turnData.hitsLeft === 1 || !targets.some(t => t.isActive(true)); + this.lastHit = user.turnData.hitsLeft === 1 || targets.every(t => !t.isActive(true)); // Play the animation if the move was successful against any of its targets or it has a POST_TARGET effect (like self destruct) if ( @@ -793,15 +793,16 @@ export class MoveEffectPhase extends PokemonPhase { if (!this.move.hitsSubstitute(user, target)) { this.applyOnTargetEffects(user, target, hitResult, firstTarget, wasCritical); } + if (this.lastHit) { globalScene.triggerPokemonFormChange(user, SpeciesFormChangePostMoveTrigger); - - // Multi-hit check for Wimp Out/Emergency Exit - if (user.turnData.hitCount > 1) { - // TODO: Investigate why 0 is being passed for damage amount here - // and then determing if refactoring `applyMove` to return the damage dealt is appropriate. - applyAbAttrs("PostDamageAbAttr", { pokemon: target, damage: 0, source: user }); - } + // Trigger Form changes on the final hit, alongside Wimp Out. + applyAbAttrs("PostDamageAbAttr", { + pokemon: target, + damage: user.turnData.lastMoveDamageDealt[target.getBattlerIndex()], + simulated: false, + source: user, + }); } } @@ -834,6 +835,7 @@ export class MoveEffectPhase extends PokemonPhase { isCritical, }); + // Apply and/or remove type boosting tags (Flash Fire, Charge, etc.) const typeBoost = user.findTag( t => t instanceof TypeBoostTag && t.boostedType === user.getMoveType(this.move), ) as TypeBoostTag; @@ -841,18 +843,17 @@ export class MoveEffectPhase extends PokemonPhase { user.removeTag(typeBoost.tagType); } - const isOneHitKo = result === HitResult.ONE_HIT_KO; - - if (!dmg) { + if (dmg === 0) { return [result, false]; } + const isOneHitKo = result === HitResult.ONE_HIT_KO; target.lapseTags(BattlerTagLapseType.HIT); - const substitute = target.getTag(SubstituteTag); - const isBlockedBySubstitute = substitute && this.move.hitsSubstitute(user, target); + const substituteTag = target.getTag(SubstituteTag); + const isBlockedBySubstitute = substituteTag && this.move.hitsSubstitute(user, target); if (isBlockedBySubstitute) { - substitute.hp -= dmg; + substituteTag.hp -= dmg; } else if (!target.isPlayer() && dmg >= target.hp) { globalScene.applyModifiers(EnemyEndureChanceModifier, false, target); } @@ -861,10 +862,9 @@ export class MoveEffectPhase extends PokemonPhase { ? 0 : target.damageAndUpdate(dmg, { result: result as DamageResult, - ignoreFaintPhase: true, + ignoreFaintPhase: true, // ignore faint phase so we can handle it ourselves ignoreSegments: isOneHitKo, isCritical, - source: user, }); if (isCritical) { @@ -878,14 +878,13 @@ export class MoveEffectPhase extends PokemonPhase { if (user.isPlayer()) { globalScene.validateAchvs(DamageAchv, new NumberHolder(damage)); - if (damage > globalScene.gameData.gameStats.highestDamage) { - globalScene.gameData.gameStats.highestDamage = damage; - } + globalScene.gameData.gameStats.highestDamage = Math.max(damage, globalScene.gameData.gameStats.highestDamage); } - user.turnData.totalDamageDealt += damage; + user.turnData.lastMoveDamageDealt[target.getBattlerIndex()] += damage; user.turnData.singleHitDamageDealt = damage; target.battleData.hitCount++; + // TODO: this might be incorrect for counter moves target.turnData.damageTaken += damage; target.turnData.attacksReceived.unshift({ diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 09a542861be..fb42720d8c6 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -135,6 +135,8 @@ export class MovePhase extends BattlePhase { } this.pokemon.turnData.acted = true; + // TODO: Increase this if triple battles are added + this.pokemon.turnData.lastMoveDamageDealt = Array(4).fill(0); // Reset hit-related turn data when starting follow-up moves (e.g. Metronomed moves, Dancer repeats) if (isVirtual(this.useMode)) { diff --git a/src/phases/post-turn-status-effect-phase.ts b/src/phases/post-turn-status-effect-phase.ts index 9d5172180b8..fc378bb8f02 100644 --- a/src/phases/post-turn-status-effect-phase.ts +++ b/src/phases/post-turn-status-effect-phase.ts @@ -44,6 +44,7 @@ export class PostTurnStatusEffectPhase extends PokemonPhase { } if (damage.value) { // Set preventEndure flag to avoid pokemon surviving thanks to focus band, sturdy, endure ... + // TODO: why don't we call `damageAndUpdate` here? globalScene.damageNumberHandler.add(this.getPokemon(), pokemon.damage(damage.value, false, true)); pokemon.updateInfo(); applyAbAttrs("PostDamageAbAttr", { pokemon, damage: damage.value }); diff --git a/src/phases/summon-phase.ts b/src/phases/summon-phase.ts index e4c8aa9af7a..720c54838ed 100644 --- a/src/phases/summon-phase.ts +++ b/src/phases/summon-phase.ts @@ -275,7 +275,7 @@ export class SummonPhase extends PartyMemberPokemonPhase { globalScene.phaseManager.unshiftNew("ShinySparklePhase", pokemon.getBattlerIndex()); } - pokemon.resetTurnData(); + pokemon.resetTurnData(); // TODO: this can probably be removed...??? if ( !this.loaded || diff --git a/src/phases/switch-phase.ts b/src/phases/switch-phase.ts index a431d973a02..b2592abdf27 100644 --- a/src/phases/switch-phase.ts +++ b/src/phases/switch-phase.ts @@ -16,13 +16,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,7 +36,7 @@ export class SwitchPhase extends BattlePhase { 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) { + if (this.isModal && globalScene.getBackupPartyMemberIndices(true).length === 0) { return super.end(); } @@ -52,11 +51,11 @@ export class SwitchPhase extends BattlePhase { return super.end(); } - // Check if there is any space still in field + // Check if there is any space still on field. + // We use > here as the prior pokemon still technically hasn't "left" the field _per se_ (unless they fainted). if ( this.isModal && - globalScene.getPlayerField().filter(p => p.isAllowedInBattle() && p.isActive(true)).length >= - globalScene.currentBattle.getBattlerCount() + globalScene.getPlayerField().filter(p => p.isActive(true)).length > globalScene.currentBattle.getBattlerCount() ) { return super.end(); } diff --git a/src/phases/switch-summon-phase.ts b/src/phases/switch-summon-phase.ts index b7460e77569..c6ef4e8ca0e 100644 --- a/src/phases/switch-summon-phase.ts +++ b/src/phases/switch-summon-phase.ts @@ -2,10 +2,8 @@ import { applyAbAttrs } from "#abilities/apply-ab-attrs"; import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; import { SubstituteTag } from "#data/battler-tags"; -import { allMoves } from "#data/data-lists"; import { SpeciesFormChangeActiveTrigger } from "#data/form-change-triggers"; import { getPokeballTintColor } from "#data/pokeball"; -import { Command } from "#enums/command"; import { SwitchType } from "#enums/switch-type"; import { TrainerSlot } from "#enums/trainer-slot"; import type { Pokemon } from "#field/pokemon"; @@ -13,6 +11,7 @@ import { SwitchEffectTransferModifier } from "#modifiers/modifier"; import { SummonPhase } from "#phases/summon-phase"; import i18next from "i18next"; +// TODO: This and related phases desperately need to be refactored export class SwitchSummonPhase extends SummonPhase { public readonly phaseName: "SwitchSummonPhase" | "ReturnPhase" = "SwitchSummonPhase"; private readonly switchType: SwitchType; @@ -22,10 +21,11 @@ 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 + * 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` */ @@ -33,48 +33,56 @@ export class SwitchSummonPhase extends SummonPhase { super(fieldIndex, player); this.switchType = switchType; - this.slotIndex = slotIndex; + // -1 = "use trainer switch logic" + this.slotIndex = + slotIndex > -1 + ? this.slotIndex + : globalScene.currentBattle.trainer!.getNextSummonIndex(this.getTrainerSlotFromFieldIndex()); 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(); } - preSummon(): void { - if (!this.player) { - if (this.slotIndex === -1) { - //@ts-expect-error - this.slotIndex = globalScene.currentBattle.trainer?.getNextSummonIndex( - !this.fieldIndex ? TrainerSlot.TRAINER : TrainerSlot.TRAINER_PARTNER, - ); // TODO: what would be the default trainer-slot fallback? - } - if (this.slotIndex > -1) { - this.showEnemyTrainer(!(this.fieldIndex % 2) ? TrainerSlot.TRAINER : TrainerSlot.TRAINER_PARTNER); - globalScene.pbTrayEnemy.showPbTray(globalScene.getEnemyParty()); - } + override preSummon(): void { + const switchOutPokemon = this.getPokemon(); + + if (!this.player && globalScene.currentBattle.trainer) { + 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; } - const pokemon = this.getPokemon(); (this.player ? globalScene.getEnemyField() : globalScene.getPlayerField()).forEach(enemyPokemon => - enemyPokemon.removeTagsBySourceId(pokemon.id), + enemyPokemon.removeTagsBySourceId(switchOutPokemon.id), ); - if (this.switchType === SwitchType.SWITCH || this.switchType === SwitchType.INITIAL_SWITCH) { - const substitute = pokemon.getTag(SubstituteTag); + // 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({ targets: substitute.sprite, @@ -89,26 +97,24 @@ export class SwitchSummonPhase extends SummonPhase { globalScene.ui.showText( this.player ? i18next.t("battle:playerComeBack", { - pokemonName: getPokemonNameWithAffix(pokemon), + pokemonName: getPokemonNameWithAffix(switchOutPokemon), }) : i18next.t("battle:trainerComeBack", { - trainerName: globalScene.currentBattle.trainer?.getName( - !(this.fieldIndex % 2) ? TrainerSlot.TRAINER : TrainerSlot.TRAINER_PARTNER, - ), - pokemonName: pokemon.getNameToRender(), + trainerName: globalScene.currentBattle.trainer?.getName(this.getTrainerSlotFromFieldIndex()), + pokemonName: switchOutPokemon.getNameToRender(), }), ); globalScene.playSound("se/pb_rel"); - pokemon.hideInfo(); - pokemon.tint(getPokeballTintColor(pokemon.getPokeball(true)), 1, 250, "Sine.easeIn"); + switchOutPokemon.hideInfo(); + switchOutPokemon.tint(getPokeballTintColor(switchOutPokemon.getPokeball(true)), 1, 250, "Sine.easeIn"); globalScene.tweens.add({ - targets: pokemon, + targets: switchOutPokemon, duration: 250, ease: "Sine.easeIn", scale: 0.5, onComplete: () => { globalScene.time.delayedCall(750, () => this.switchAndSummon()); - pokemon.leaveField(this.switchType === SwitchType.SWITCH, false); + switchOutPokemon.leaveField(this.switchType === SwitchType.SWITCH, false); // TODO: do we have to do this right here right now }, }); } @@ -118,12 +124,8 @@ export class SwitchSummonPhase extends SummonPhase { const switchedInPokemon: Pokemon | undefined = party[this.slotIndex]; this.lastPokemon = this.getPokemon(); - // Defensive programming: Overcome the bug where the summon data has somehow not been reset - // prior to switching in a new Pokemon. - // Force the switch to occur and load the assets for the new pokemon, ignoring override. - switchedInPokemon.resetSummonData(); - switchedInPokemon.loadAssets(true); - + // TODO: Why do we trigger these attributes even if the switch in target doesn't exist? + // (This should almost certainly go somewhere inside `preSummon`) applyAbAttrs("PreSummonAbAttr", { pokemon: switchedInPokemon }); applyAbAttrs("PreSwitchOutAbAttr", { pokemon: this.lastPokemon }); if (!switchedInPokemon) { @@ -131,6 +133,13 @@ export class SwitchSummonPhase extends SummonPhase { return; } + // Defensive programming: Overcome the bug where the summon data has somehow not been reset + // prior to switching in a new Pokemon. + // Force the switch to occur and load the assets for the new pokemon, ignoring override. + // TODO: Assess whether this is needed anymore and remove if needed + switchedInPokemon.resetSummonData(); + switchedInPokemon.loadAssets(true); + 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) => @@ -149,7 +158,7 @@ export class SwitchSummonPhase extends SummonPhase { m => m instanceof SwitchEffectTransferModifier && (m as SwitchEffectTransferModifier).pokemonId === this.lastPokemon.id, - ) as SwitchEffectTransferModifier; + ) as SwitchEffectTransferModifier | undefined; if (batonPassModifier) { globalScene.tryTransferHeldItemModifier( @@ -167,13 +176,14 @@ export class SwitchSummonPhase extends SummonPhase { party[this.slotIndex] = this.lastPokemon; party[this.fieldIndex] = switchedInPokemon; + // TODO: Make this a method const showTextAndSummon = () => { globalScene.ui.showText(this.getSendOutText(switchedInPokemon)); /** * 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]; @@ -181,7 +191,7 @@ export class SwitchSummonPhase extends SummonPhase { switchedInPokemon.setAlpha(0.5); } } else { - switchedInPokemon.fieldSetup(true); + switchedInPokemon.fieldSetup(); } this.summon(); }; @@ -200,46 +210,35 @@ export class SwitchSummonPhase extends SummonPhase { onEnd(): void { super.onEnd(); - const pokemon = this.getPokemon(); + const activePokemon = this.getPokemon(); - const moveId = globalScene.currentBattle.lastMove; - const lastUsedMove = moveId ? allMoves[moveId] : undefined; - - const currentCommand = globalScene.currentBattle.turnCommands[this.fieldIndex]?.command; - const lastPokemonIsForceSwitchedAndNotFainted = - lastUsedMove?.hasAttr("ForceSwitchOutAttr") && !this.lastPokemon.isFainted(); - const lastPokemonHasForceSwitchAbAttr = - this.lastPokemon.hasAbilityWithAttr("PostDamageForceSwitchAbAttr") && !this.lastPokemon.isFainted(); - - // Compensate for turn spent summoning/forced switch if switched out pokemon is not fainted. + // If not switching at start of battle, reset turn counts and temp data on the newly sent in Pokemon // Needed as we increment turn counters in `TurnEndPhase`. - if ( - currentCommand === Command.POKEMON || - lastPokemonIsForceSwitchedAndNotFainted || - lastPokemonHasForceSwitchAbAttr - ) { - pokemon.tempSummonData.turnCount--; - pokemon.tempSummonData.waveTurnCount--; + if (this.switchType !== SwitchType.INITIAL_SWITCH) { + // No need to reset turn/summon data for initial switch + // (since both get initialized to defaults on object creation) + activePokemon.resetTurnData(); + activePokemon.resetSummonData(); + activePokemon.tempSummonData.turnCount--; + activePokemon.tempSummonData.waveTurnCount--; + activePokemon.turnData.switchedInThisTurn = true; } - if (this.switchType === SwitchType.BATON_PASS && pokemon) { - pokemon.transferSummon(this.lastPokemon); - } else if (this.switchType === SwitchType.SHED_TAIL && pokemon) { + // Baton Pass over any eligible effects or substitutes before resetting the last pokemon's temporary data. + if (this.switchType === SwitchType.BATON_PASS) { + activePokemon.transferSummon(this.lastPokemon); + this.lastPokemon.resetTurnData(); + this.lastPokemon.resetSummonData(); + } else if (this.switchType === SwitchType.SHED_TAIL) { const subTag = this.lastPokemon.getTag(SubstituteTag); if (subTag) { - pokemon.summonData.tags.push(subTag); + activePokemon.summonData.tags.push(subTag); } + this.lastPokemon.resetTurnData(); + this.lastPokemon.resetSummonData(); } - // Reset turn data if not initial switch (since it gets initialized to an empty object on turn start) - if (this.switchType !== SwitchType.INITIAL_SWITCH) { - pokemon.resetTurnData(); - pokemon.turnData.switchedInThisTurn = true; - } - - this.lastPokemon.resetSummonData(); - - globalScene.triggerPokemonFormChange(pokemon, SpeciesFormChangeActiveTrigger, true); + globalScene.triggerPokemonFormChange(activePokemon, SpeciesFormChangeActiveTrigger, true); // Reverts to weather-based forms when weather suppressors (Cloud Nine/Air Lock) are switched out globalScene.arena.triggerWeatherBasedFormChanges(); } @@ -275,4 +274,16 @@ export class SwitchSummonPhase extends SummonPhase { pokemonName: this.getPokemon().getNameToRender(), }); } + + 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/src/utils/array.ts b/src/utils/array.ts new file mode 100644 index 00000000000..3f8595718d7 --- /dev/null +++ b/src/utils/array.ts @@ -0,0 +1,45 @@ +/** + * Split an array into a pair of arrays based on a conditional function. + * @param arr - The array to split into 2 + * @param predicate - A function accepting up to 3 arguments. The split function calls the predicate function once per element of the array. + * @param thisArg - An object to which the `this` keyword can refer in the predicate function. If omitted, `undefined` is used as the `this` value. + * @returns A pair of shallowly-copied arrays containing every element for which `predicate` did or did not return a value coercible to the boolean `true`. + * @overload + */ +export function splitArray( + arr: T[], + predicate: (value: T, index: number, array: T[]) => value is S, + thisArg?: unknown, +): [matches: S[], nonMatches: S[]]; +/** + * Split an array into a pair of arrays based on a conditional function. + * @param array - The array to split into 2 + * @param predicate - A function accepting up to 3 arguments. The split function calls the function once per element of the array. + * @param thisArg - An object to which the `this` keyword can refer in the predicate function. If omitted, `undefined` is used as the `this` value. + * @returns A pair of shallowly-copied arrays containing every element for which `predicate` did or did not return a value coercible to the boolean `true`. + * @overload + */ +export function splitArray( + arr: T[], + predicate: (value: T, index: number, array: T[]) => unknown, + thisArg?: unknown, +): [matches: T[], nonMatches: T[]]; + +export function splitArray( + arr: T[], + predicate: (value: T, index: number, array: T[]) => unknown, + thisArg?: unknown, +): [matches: T[], nonMatches: T[]] { + const matches: T[] = []; + const nonMatches: T[] = []; + + const p = predicate.bind(thisArg) as typeof predicate; + arr.forEach((value, index, array) => { + if (p(value, index, array)) { + matches.push(value); + } else { + nonMatches.push(value); + } + }); + return [matches, nonMatches]; +} diff --git a/test/abilities/arena_trap.test.ts b/test/abilities/arena_trap.test.ts index c47cf3cd327..dfeade49c28 100644 --- a/test/abilities/arena_trap.test.ts +++ b/test/abilities/arena_trap.test.ts @@ -4,7 +4,7 @@ import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; import { GameManager } from "#test/testUtils/gameManager"; import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; describe("Abilities - Arena Trap", () => { let phaserGame: Phaser.Game; @@ -23,68 +23,86 @@ describe("Abilities - Arena Trap", () => { beforeEach(() => { game = new GameManager(phaserGame); game.override - .moveset(MoveId.SPLASH) .ability(AbilityId.ARENA_TRAP) .enemySpecies(SpeciesId.RALTS) .enemyAbility(AbilityId.BALL_FETCH) - .enemyMoveset(MoveId.TELEPORT); + .enemyMoveset(MoveId.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([SpeciesId.DUGTRIO, SpeciesId.GOTHITELLE]); - await game.classicMode.startBattle(); + const enemy = game.field.getEnemyPokemon(); - const enemy = game.scene.getEnemyPokemon(); + // flee stuff goes here - game.move.select(MoveId.SPLASH); + game.onNextPrompt("CommandPhase", UiMode.COMMAND, () => { + // no switch out command should be queued due to arena trap + expect(game.scene.currentBattle.turnCommands[0]).toBeNull(); + + // back out and cancel the flee to avoid timeout + (game.scene.ui.getHandler() as CommandUiHandler).processInput(Button.CANCEL); + game.move.use(MoveId.SPLASH); + }); await game.toNextTurn(); + expect(game.textInterceptor.logs).toContain( + i18next.t("abilityTriggers:arenaTrap", { + pokemonNameWithAffix: getPokemonNameWithAffix(enemy), + abilityName: allAbilities[AbilityId.ARENA_TRAP].name, + }), + ); + }); - expect(enemy).toBe(game.scene.getEnemyPokemon()); + it("should interrupt player switch attempt and display message", async () => { + game.override.battleStyle("single").enemyAbility(AbilityId.ARENA_TRAP); + await game.classicMode.startBattle([SpeciesId.DUGTRIO, SpeciesId.GOTHITELLE]); + + const enemy = game.field.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.use(MoveId.SPLASH); + }); + + await game.toNextTurn(); + expect(game.textInterceptor.logs).toContain( + i18next.t("abilityTriggers:arenaTrap", { + pokemonNameWithAffix: getPokemonNameWithAffix(enemy), + abilityName: allAbilities[AbilityId.ARENA_TRAP].name, + }), + ); }); it("should guarantee double battle with any one LURE", async () => { game.override.startingModifier([{ name: "LURE" }]).startingWave(2); + await game.classicMode.startBattle([SpeciesId.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 AbilityId.ARENA_TRAP} - * is forcefully moved out of the field from moves such as Roar {@linkcode MoveId.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(MoveId.SPLASH) - .moveset([MoveId.ROAR, MoveId.SPLASH]) - .ability(AbilityId.BALL_FETCH); - await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.SUDOWOODO, SpeciesId.LUNATONE]); + game.override.battleStyle("single"); + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - const [enemy1, enemy2] = game.scene.getEnemyField(); - const [player1, player2] = game.scene.getPlayerField(); + const player = game.field.getPlayerPokemon(); + const enemy = game.field.getEnemyPokemon(); - vi.spyOn(enemy1, "getAbility").mockReturnValue(allAbilities[AbilityId.ARENA_TRAP]); + expect(player.isTrapped()).toBe(true); + expect(enemy.isOnField()).toBe(true); - game.move.select(MoveId.ROAR); - game.move.select(MoveId.SPLASH, 1); + game.move.use(MoveId.ROAR); + await game.toEndOfTurn(); - // 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(MoveId.SPLASH); - game.move.select(MoveId.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/abilities/disguise.test.ts b/test/abilities/disguise.test.ts index 9bd36def8d7..865ba98209a 100644 --- a/test/abilities/disguise.test.ts +++ b/test/abilities/disguise.test.ts @@ -47,7 +47,7 @@ describe("Abilities - Disguise", () => { await game.phaseInterceptor.to("MoveEndPhase"); - expect(mimikyu.hp).equals(maxHp - disguiseDamage); + expect(mimikyu.hp).toBe(maxHp - disguiseDamage); expect(mimikyu.formIndex).toBe(bustedForm); }); @@ -79,12 +79,12 @@ describe("Abilities - Disguise", () => { // First hit await game.phaseInterceptor.to("MoveEffectPhase"); - expect(mimikyu.hp).equals(maxHp - disguiseDamage); + expect(mimikyu.hp).toBe(maxHp - disguiseDamage); expect(mimikyu.formIndex).toBe(disguisedForm); // Second hit await game.phaseInterceptor.to("MoveEffectPhase"); - expect(mimikyu.hp).lessThan(maxHp - disguiseDamage); + expect(mimikyu.hp).toBeLessThan(maxHp - disguiseDamage); expect(mimikyu.formIndex).toBe(bustedForm); }); @@ -118,7 +118,7 @@ describe("Abilities - Disguise", () => { await game.phaseInterceptor.to("TurnEndPhase"); expect(mimikyu.formIndex).toBe(bustedForm); - expect(mimikyu.hp).equals(maxHp - disguiseDamage); + expect(mimikyu.hp).toBe(maxHp - disguiseDamage); await game.toNextTurn(); game.doSwitchPokemon(1); diff --git a/test/abilities/mold_breaker.test.ts b/test/abilities/mold_breaker.test.ts index c3214cdc224..bde21135bec 100644 --- a/test/abilities/mold_breaker.test.ts +++ b/test/abilities/mold_breaker.test.ts @@ -1,4 +1,8 @@ import { AbilityId } from "#enums/ability-id"; +import { ArenaTagSide } from "#enums/arena-tag-side"; +import { ArenaTagType } from "#enums/arena-tag-type"; +import { BattleType } from "#enums/battle-type"; +import { BattlerIndex } from "#enums/battler-index"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; import { GameManager } from "#test/testUtils/gameManager"; @@ -37,13 +41,40 @@ describe("Abilities - Mold Breaker", () => { const enemy = game.field.getEnemyPokemon(); game.move.use(MoveId.X_SCISSOR); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); await game.phaseInterceptor.to("MoveEffectPhase"); expect(game.scene.arena.ignoreAbilities).toBe(true); expect(game.scene.arena.ignoringEffectSource).toBe(player.getBattlerIndex()); - await game.toEndOfTurn(); + await game.phaseInterceptor.to("MoveEndPhase"); expect(game.scene.arena.ignoreAbilities).toBe(false); - expect(enemy.isFainted()).toBe(true); + + await game.phaseInterceptor.to("TurnEndPhase"); + expect(enemy).toBe(true); + }); + + it("should keep Levitate opponents grounded when using force switch moves", async () => { + game.override.enemyAbility(AbilityId.LEVITATE).enemySpecies(SpeciesId.WEEZING).battleType(BattleType.TRAINER); + + // Setup toxic spikes and spikes + game.scene.arena.addTag(ArenaTagType.TOXIC_SPIKES, -1, MoveId.TOXIC_SPIKES, 1, ArenaTagSide.ENEMY); + game.scene.arena.addTag(ArenaTagType.SPIKES, -1, MoveId.CEASELESS_EDGE, 1, ArenaTagSide.ENEMY); + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + + const [weezing1, weezing2] = game.scene.getEnemyParty(); + + // Weezing's levitate prevented removal of Toxic Spikes, ignored Spikes damage + expect(game.scene.arena.getTagOnSide(ArenaTagType.TOXIC_SPIKES, ArenaTagSide.ENEMY)).toBeDefined(); + expect(weezing1.hp).toBe(weezing1.getMaxHp()); + + game.move.use(MoveId.DRAGON_TAIL); + await game.toEndOfTurn(); + + // Levitate was ignored during the switch, causing Toxic Spikes to be removed and Spikes to deal damage + expect(weezing1.isOnField()).toBe(false); + expect(weezing2.isOnField()).toBe(true); + expect(weezing2.getHpRatio()).toBeCloseTo(0.75); + expect(game.scene.arena.getTagOnSide(ArenaTagType.TOXIC_SPIKES, ArenaTagSide.ENEMY)).toBeUndefined(); }); }); diff --git a/test/abilities/wimp_out.test.ts b/test/abilities/wimp_out.test.ts index d6e6908e19b..c15b0e77eed 100644 --- a/test/abilities/wimp_out.test.ts +++ b/test/abilities/wimp_out.test.ts @@ -1,20 +1,20 @@ -import { allMoves } from "#data/data-lists"; +import type { ModifierOverride } from "#app/modifier/modifier-type"; +import type { SwitchSummonPhase } from "#app/phases/switch-summon-phase"; +import { toDmgValue } from "#app/utils/common"; import { AbilityId } from "#enums/ability-id"; import { ArenaTagSide } from "#enums/arena-tag-side"; import { ArenaTagType } from "#enums/arena-tag-type"; +import { BattleType } from "#enums/battle-type"; import { BattlerIndex } from "#enums/battler-index"; import { BattlerTagType } from "#enums/battler-tag-type"; +import { HitResult } from "#enums/hit-result"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; -import { Stat } from "#enums/stat"; -import { StatusEffect } from "#enums/status-effect"; -import { WeatherType } from "#enums/weather-type"; import { GameManager } from "#test/testUtils/gameManager"; -import { toDmgValue } from "#utils/common"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -describe("Abilities - Wimp Out", () => { +describe("Abilities - Wimp Out/Emergency Exit", () => { let phaserGame: Phaser.Game; let game: GameManager; @@ -35,9 +35,6 @@ describe("Abilities - Wimp Out", () => { .ability(AbilityId.WIMP_OUT) .enemySpecies(SpeciesId.NINJASK) .enemyPassiveAbility(AbilityId.NO_GUARD) - .startingLevel(90) - .enemyLevel(70) - .moveset([MoveId.SPLASH, MoveId.FALSE_SWIPE, MoveId.ENDURE]) .enemyMoveset(MoveId.FALSE_SWIPE) .criticalHits(false); }); @@ -66,291 +63,390 @@ describe("Abilities - Wimp Out", () => { expect(pokemon1.getHpRatio()).toBeLessThan(0.5); } - it("triggers regenerator passive single time when switching out with wimp out", async () => { + it.each<{ name: string; ability: AbilityId }>([ + { name: "Wimp Out", ability: AbilityId.WIMP_OUT }, + { name: "Emergency Exit", ability: AbilityId.EMERGENCY_EXIT }, + ])("should switch the user out when falling below half HP, canceling its subsequent moves", async ({ ability }) => { + game.override.ability(ability); + await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); + + const wimpod = game.field.getPlayerPokemon(); + wimpod.hp *= 0.52; + + game.move.use(MoveId.SPLASH); + game.doSelectPartyPokemon(1); + await game.toEndOfTurn(); + + // Wimpod switched out after taking a hit, canceling its upcoming MoveEffectPhase before it could attack + confirmSwitch(); + expect(game.field.getEnemyPokemon().getInverseHp()).toBe(0); + expect(game.phaseInterceptor.log.filter(phase => phase === "MoveEffectPhase")).toHaveLength(1); + }); + + it("should not trigger if user faints from damage and is revived", async () => { + game.override + .startingHeldItems([{ name: "REVIVER_SEED", count: 1 }]) + .enemyMoveset(MoveId.BRAVE_BIRD) + .enemyLevel(1000); + await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); + + const wimpod = game.field.getPlayerPokemon(); + wimpod.hp *= 0.52; + + game.move.use(MoveId.SPLASH); + game.doSelectPartyPokemon(1); + await game.toNextTurn(); + + expect(wimpod.isFainted()).toBe(false); + expect(wimpod.isOnField()).toBe(true); + expect(wimpod.getHpRatio()).toBeCloseTo(0.5); + expect(wimpod.getHeldItems()).toHaveLength(0); + expect(wimpod.waveData.abilitiesApplied).not.toContain(AbilityId.WIMP_OUT); + }); + + it("should trigger regenerator passive when switching out", async () => { game.override.passiveAbility(AbilityId.REGENERATOR).startingLevel(5).enemyLevel(100); await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); - const wimpod = game.scene.getPlayerPokemon()!; + const wimpod = game.field.getPlayerPokemon(); - game.move.select(MoveId.SPLASH); + game.move.use(MoveId.SPLASH); game.doSelectPartyPokemon(1); - await game.phaseInterceptor.to("TurnEndPhase"); + await game.toEndOfTurn(); expect(wimpod.hp).toEqual(Math.floor(wimpod.getMaxHp() * 0.33 + 1)); confirmSwitch(); }); - it("It makes wild pokemon flee if triggered", async () => { + it("should cause wild pokemon to flee when triggered", async () => { game.override.enemyAbility(AbilityId.WIMP_OUT); await game.classicMode.startBattle([SpeciesId.GOLISOPOD, SpeciesId.TYRUNT]); - const enemyPokemon = game.scene.getEnemyPokemon()!; + const enemyPokemon = game.field.getEnemyPokemon(); enemyPokemon.hp *= 0.52; - game.move.select(MoveId.FALSE_SWIPE); - await game.phaseInterceptor.to("BerryPhase"); + game.move.use(MoveId.FALSE_SWIPE); + await game.toEndOfTurn(); - const isVisible = enemyPokemon.visible; - const hasFled = enemyPokemon.switchOutStatus; - expect(!isVisible && hasFled).toBe(true); + expect(enemyPokemon.visible).toBe(false); + expect(enemyPokemon.switchOutStatus).toBe(true); }); - it("Does not trigger when HP already below half", async () => { + it("should not trigger if HP already below half", async () => { await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); - const wimpod = game.scene.getPlayerPokemon()!; - wimpod.hp = 5; + const wimpod = game.field.getPlayerPokemon(); + wimpod.hp *= 0.1; - game.move.select(MoveId.SPLASH); - await game.phaseInterceptor.to("TurnEndPhase"); + game.move.use(MoveId.SPLASH); + await game.toEndOfTurn(); - expect(wimpod.hp).toEqual(1); + expect(wimpod.getHpRatio()).toBeLessThan(0.1); confirmNoSwitch(); }); - it("Trapping moves do not prevent Wimp Out from activating.", async () => { - game.override.enemyMoveset([MoveId.SPIRIT_SHACKLE]).startingLevel(1).passiveAbility(AbilityId.STURDY); + it("should bypass trapping moves", async () => { + game.override.enemyMoveset([MoveId.SPIRIT_SHACKLE]).startingLevel(53).enemyLevel(45); await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); - game.move.select(MoveId.SPLASH); + game.move.use(MoveId.SPLASH); game.doSelectPartyPokemon(1); - await game.phaseInterceptor.to("TurnEndPhase"); + await game.toEndOfTurn(); expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); - expect(game.scene.getPlayerPokemon()!.getTag(BattlerTagType.TRAPPED)).toBeUndefined(); + expect(game.field.getPlayerPokemon().getTag(BattlerTagType.TRAPPED)).toBeUndefined(); expect(game.scene.getPlayerParty()[1].getTag(BattlerTagType.TRAPPED)).toBeUndefined(); confirmSwitch(); }); - it("If this Ability activates due to being hit by U-turn or Volt Switch, the user of that move will not be switched out.", async () => { - game.override.startingLevel(1).enemyMoveset([MoveId.U_TURN]).passiveAbility(AbilityId.STURDY); + // TODO: Enable when dynamic speed order happens + it.todo("should trigger separately for each Pokemon hit in speed order", async () => { + game.override.battleStyle("double").enemyLevel(600).enemyMoveset(MoveId.DRAGON_ENERGY); + await game.classicMode.startBattle([ + SpeciesId.WIMPOD, + SpeciesId.GOLISOPOD, + SpeciesId.TYRANITAR, + SpeciesId.KINGAMBIT, + ]); + + // Golisopod switches out, Wimpod switches back in immediately afterwards + game.move.use(MoveId.ENDURE, BattlerIndex.PLAYER); + game.move.use(MoveId.ENDURE, BattlerIndex.PLAYER_2); + game.doSelectPartyPokemon(3); + game.doSelectPartyPokemon(3); + await game.toEndOfTurn(); + + expect(game.scene.getPlayerParty().map(p => p.species.speciesId)).toBe([ + SpeciesId.TYRANITAR, + SpeciesId.WIMPOD, + SpeciesId.KINGAMBIT, + SpeciesId.GOLISOPOD, + ]); + + // Ttar and Kingambit should be at full HP; wimpod and golisopod should not + // Ttar and Wimpod should be on field; kingambit and golisopod should not + game.scene.getPlayerParty().forEach((p, i) => { + expect(p.isOnField()).toBe(i < 2); + expect(p.isFullHp()).toBe(i % 2 === 1); + }); + }); + + it("should block U-turn or Volt Switch on activation", async () => { + game.override.battleType(BattleType.TRAINER); await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); - game.move.select(MoveId.SPLASH); - game.doSelectPartyPokemon(1); - await game.phaseInterceptor.to("TurnEndPhase"); + const wimpod = game.field.getPlayerPokemon(); + wimpod.hp *= 0.52; + + game.move.use(MoveId.SPLASH); + game.doSelectPartyPokemon(1); + await game.move.forceEnemyMove(MoveId.U_TURN); + await game.toEndOfTurn(); - const enemyPokemon = game.scene.getEnemyPokemon()!; - const hasFled = enemyPokemon.switchOutStatus; - expect(hasFled).toBe(false); confirmSwitch(); + const ninjask = game.field.getEnemyPokemon(); + expect(ninjask.isOnField()).toBe(true); }); - it("If this Ability does not activate due to being hit by U-turn or Volt Switch, the user of that move will be switched out.", async () => { - game.override.startingLevel(190).startingWave(8).enemyMoveset([MoveId.U_TURN]); + it("should not block U-turn or Volt Switch if not activated", async () => { + game.override.battleType(BattleType.TRAINER); await game.classicMode.startBattle([SpeciesId.GOLISOPOD, SpeciesId.TYRUNT]); - const RIVAL_NINJASK1 = game.scene.getEnemyPokemon()?.id; - game.move.select(MoveId.SPLASH); - await game.phaseInterceptor.to("BerryPhase", false); - expect(game.scene.getEnemyPokemon()?.id !== RIVAL_NINJASK1); + + const wimpod = game.field.getPlayerPokemon(); + const ninjask = game.field.getEnemyPokemon(); + + // force enemy u turn to do 1 dmg + vi.spyOn(wimpod, "getAttackDamage").mockReturnValueOnce({ + cancelled: false, + damage: 1, + result: HitResult.EFFECTIVE, + }); + + game.move.use(MoveId.SPLASH); + await game.phaseInterceptor.to("SwitchSummonPhase", false); + const switchSummonPhase = game.scene.phaseManager.getCurrentPhase() as SwitchSummonPhase; + expect(switchSummonPhase.getPokemon()).toBe(ninjask); + + await game.toEndOfTurn(); + + expect(wimpod.isOnField()).toBe(true); + expect(ninjask.isOnField()).toBe(false); }); - it("Dragon Tail and Circle Throw switch out Pokémon before the Ability activates.", async () => { - game.override.startingLevel(69).enemyMoveset([MoveId.DRAGON_TAIL]); + it("should not activate when hit by force switch moves", async () => { await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); - const wimpod = game.scene.getPlayerPokemon()!; + const wimpod = game.field.getPlayerPokemon(); + wimpod.hp *= 0.52; - game.move.select(MoveId.SPLASH); - game.doSelectPartyPokemon(1); + game.move.use(MoveId.SPLASH); + await game.move.forceEnemyMove(MoveId.CIRCLE_THROW); await game.phaseInterceptor.to("SwitchSummonPhase", false); expect(wimpod.waveData.abilitiesApplied).not.toContain(AbilityId.WIMP_OUT); - await game.phaseInterceptor.to("TurnEndPhase"); - - expect(game.scene.getPlayerPokemon()!.species.speciesId).not.toBe(SpeciesId.WIMPOD); + // Force switches directly call `SwitchSummonPhase` to send in a random opponent, + // so wimp out triggering will stall out the test waiting for input + await game.toEndOfTurn(); + expect(game.field.getPlayerPokemon().species.speciesId).not.toBe(SpeciesId.WIMPOD); }); - it("triggers when recoil damage is taken", async () => { - game.override.moveset([MoveId.HEAD_SMASH]).enemyMoveset([MoveId.SPLASH]); - await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); - - game.move.select(MoveId.HEAD_SMASH); - game.doSelectPartyPokemon(1); - await game.phaseInterceptor.to("TurnEndPhase"); - - confirmSwitch(); - }); - - it("It does not activate when the Pokémon cuts its own HP", async () => { - game.override.moveset([MoveId.SUBSTITUTE]).enemyMoveset([MoveId.SPLASH]); - await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); - - const wimpod = game.scene.getPlayerPokemon()!; - wimpod.hp *= 0.52; - - game.move.select(MoveId.SUBSTITUTE); - await game.phaseInterceptor.to("TurnEndPhase"); - - confirmNoSwitch(); - }); - - it("Does not trigger when neutralized", async () => { - game.override.enemyAbility(AbilityId.NEUTRALIZING_GAS).startingLevel(5); - await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); - - game.move.select(MoveId.SPLASH); - await game.phaseInterceptor.to("TurnEndPhase"); - - confirmNoSwitch(); - }); - - // TODO: Enable when this behavior is fixed (currently Shell Bell won't activate if Wimp Out activates because - // the pokemon is removed from the field before the Shell Bell modifier is applied, so it can't see the - // damage dealt and doesn't heal the pokemon) - it.todo( - "If it falls below half and recovers back above half from a Shell Bell, Wimp Out will activate even after the Shell Bell recovery", - async () => { + it.each<{ + type: string; + playerMove?: MoveId; + playerPassive?: AbilityId; + enemyMove?: MoveId; + enemyAbility?: AbilityId; + }>([ + { type: "variable recoil moves", playerMove: MoveId.HEAD_SMASH }, + { type: "HP-based recoil moves", playerMove: MoveId.CHLOROBLAST }, + { type: "weather", enemyMove: MoveId.HAIL }, + { type: "status", enemyMove: MoveId.TOXIC }, + { type: "Ghost-type Curse", enemyMove: MoveId.CURSE }, + { type: "Salt Cure", enemyMove: MoveId.SALT_CURE }, + { type: "partial trapping moves", enemyMove: MoveId.WHIRLPOOL }, // no guard passive makes this 100% accurate + { type: "Leech Seed", enemyMove: MoveId.LEECH_SEED }, + { type: "Powder", playerMove: MoveId.EMBER, enemyMove: MoveId.POWDER }, + { type: "Nightmare", playerPassive: AbilityId.COMATOSE, enemyMove: MoveId.NIGHTMARE }, + { type: "Bad Dreams", playerPassive: AbilityId.COMATOSE, enemyAbility: AbilityId.BAD_DREAMS }, + ])( + "should activate from damage caused by $type", + async ({ + playerMove = MoveId.SPLASH, + playerPassive = AbilityId.NONE, + enemyMove = MoveId.SPLASH, + enemyAbility = AbilityId.STURDY, + }) => { game.override - .moveset([MoveId.DOUBLE_EDGE]) - .enemyMoveset([MoveId.SPLASH]) - .startingHeldItems([{ name: "SHELL_BELL", count: 4 }]); + .enemyLevel(1) + .passiveAbility(playerPassive) + .enemySpecies(SpeciesId.GASTLY) + .enemyMoveset(enemyMove) + .enemyAbility(enemyAbility); await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); - const wimpod = game.scene.getPlayerPokemon()!; + const wimpod = game.field.getPlayerPokemon(); + expect(wimpod).toBeDefined(); + wimpod.hp = toDmgValue(wimpod.getMaxHp() / 2 + 2); + // mock enemy attack damage func to only do 1 dmg (for whirlpool) + vi.spyOn(wimpod, "getAttackDamage").mockReturnValueOnce({ + cancelled: false, + result: HitResult.EFFECTIVE, + damage: 1, + }); - wimpod.damageAndUpdate(toDmgValue(wimpod.getMaxHp() * 0.4)); - - game.move.select(MoveId.DOUBLE_EDGE); + game.move.use(playerMove); game.doSelectPartyPokemon(1); - await game.phaseInterceptor.to("TurnEndPhase"); + await game.toNextTurn(); - expect(game.scene.getPlayerParty()[1]).toBe(wimpod); - expect(wimpod.hp).toBeGreaterThan(toDmgValue(wimpod.getMaxHp() / 2)); - expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); - expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(SpeciesId.TYRUNT); + confirmSwitch(); }, ); - it("Wimp Out will activate due to weather damage", async () => { - game.override.weather(WeatherType.HAIL).enemyMoveset([MoveId.SPLASH]); + it.each<{ name: string; ability: AbilityId }>([ + { name: "Innards Out", ability: AbilityId.INNARDS_OUT }, + { name: "Aftermath", ability: AbilityId.AFTERMATH }, + { name: "Rough Skin", ability: AbilityId.ROUGH_SKIN }, + ])("should trigger after taking damage from %s ability", async ({ ability }) => { + game.override.enemyAbility(ability).enemyMoveset(MoveId.SPLASH); await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); - game.scene.getPlayerPokemon()!.hp *= 0.51; + const wimpod = game.field.getPlayerPokemon(); + wimpod.hp *= 0.51; + game.field.getEnemyPokemon().hp = wimpod.hp - 1; // Ensure innards out doesn't KO - game.move.select(MoveId.SPLASH); + game.move.use(MoveId.GUILLOTINE); game.doSelectPartyPokemon(1); - await game.phaseInterceptor.to("TurnEndPhase"); + await game.toNextWave(); confirmSwitch(); }); - it("Does not trigger when enemy has sheer force", async () => { - game.override.enemyAbility(AbilityId.SHEER_FORCE).enemyMoveset(MoveId.SLUDGE_BOMB).startingLevel(95); + it("should not trigger from Sheer Force-boosted moves", async () => { + game.override.enemyAbility(AbilityId.SHEER_FORCE).startingLevel(1); await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); - game.scene.getPlayerPokemon()!.hp *= 0.51; + game.field.getPlayerPokemon().hp *= 0.51; - game.move.select(MoveId.ENDURE); - await game.phaseInterceptor.to("TurnEndPhase"); + game.move.use(MoveId.ENDURE); + await game.move.forceEnemyMove(MoveId.SLUDGE_BOMB); + await game.toEndOfTurn(); confirmNoSwitch(); }); - it("Wimp Out will activate due to post turn status damage", async () => { - game.override.statusEffect(StatusEffect.POISON).enemyMoveset([MoveId.SPLASH]); + it("should trigger from Flame Burst splash damage in doubles", async () => { + game.override.battleStyle("double"); + await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.ZYGARDE, SpeciesId.TYRUNT]); + + const wimpod = game.field.getPlayerPokemon(); + expect(wimpod).toBeDefined(); + wimpod.hp *= 0.52; + + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER); + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2); + await game.move.forceEnemyMove(MoveId.FLAME_BURST, BattlerIndex.PLAYER_2); + await game.move.forceEnemyMove(MoveId.SPLASH); + game.doSelectPartyPokemon(2); + await game.toEndOfTurn(); + + expect(wimpod.isOnField()).toBe(false); + expect(wimpod.getHpRatio()).toBeLessThan(0.5); + }); + + it("should not activate when the Pokémon cuts its own HP below half", async () => { await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); - game.scene.getPlayerPokemon()!.hp *= 0.51; + // Turn 1: Substitute knocks below half; no switch + const wimpod = game.field.getPlayerPokemon(); + wimpod.hp *= 0.52; - game.move.select(MoveId.SPLASH); - game.doSelectPartyPokemon(1); + game.move.use(MoveId.SUBSTITUTE); + await game.move.forceEnemyMove(MoveId.TIDY_UP); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); await game.toNextTurn(); + confirmNoSwitch(); + // Turn 2: get back enough HP that substitute doesn't put us under + wimpod.hp = wimpod.getMaxHp() * 0.8; + + game.move.use(MoveId.SUBSTITUTE); + await game.move.forceEnemyMove(MoveId.ROUND); + game.doSelectPartyPokemon(1); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.toEndOfTurn(); + confirmSwitch(); }); - it("Wimp Out will activate due to bad dreams", async () => { - game.override.statusEffect(StatusEffect.SLEEP).enemyAbility(AbilityId.BAD_DREAMS); + it("should not trigger when neutralized", async () => { + game.override.enemyAbility(AbilityId.NEUTRALIZING_GAS).startingLevel(5); await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); - game.scene.getPlayerPokemon()!.hp *= 0.52; + game.move.use(MoveId.SPLASH); + await game.toEndOfTurn(); - game.move.select(MoveId.SPLASH); + confirmNoSwitch(); + }); + + it("should disregard Shell Bell recovery while still activating it before switching", async () => { + game.override + .moveset(MoveId.DOUBLE_EDGE) + .enemyMoveset(MoveId.SPLASH) + .startingHeldItems([{ name: "SHELL_BELL", count: 4 }]); // heals 50% of damage dealt, more than recoil takes away + await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); + + const wimpod = game.field.getPlayerPokemon(); + wimpod.hp *= 0.51; + + game.move.use(MoveId.DOUBLE_EDGE); game.doSelectPartyPokemon(1); - await game.toNextTurn(); + await game.phaseInterceptor.to("MoveEffectPhase"); + + // Wimp out check activated from recoil before shell bell procced, but did not deny the pokemon its recovery + expect(wimpod.turnData.damageTaken).toBeGreaterThan(0); + expect(wimpod.getHpRatio()).toBeGreaterThan(0.5); + + await game.toEndOfTurn(); confirmSwitch(); }); - it("Wimp Out will activate due to leech seed", async () => { - game.override.enemyMoveset([MoveId.LEECH_SEED]); - await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); - game.scene.getPlayerPokemon()!.hp *= 0.52; - - game.move.select(MoveId.SPLASH); - game.doSelectPartyPokemon(1); - await game.toNextTurn(); - - confirmSwitch(); - }); - - it("Wimp Out will activate due to curse damage", async () => { - game.override.enemySpecies(SpeciesId.DUSKNOIR).enemyMoveset([MoveId.CURSE]); - await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); - game.scene.getPlayerPokemon()!.hp *= 0.52; - - game.move.select(MoveId.SPLASH); - game.doSelectPartyPokemon(1); - await game.toNextTurn(); - - confirmSwitch(); - }); - - it("Wimp Out will activate due to salt cure damage", async () => { - game.override.enemySpecies(SpeciesId.NACLI).enemyMoveset([MoveId.SALT_CURE]).enemyLevel(1); - await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); - game.scene.getPlayerPokemon()!.hp *= 0.7; - - game.move.select(MoveId.SPLASH); - game.doSelectPartyPokemon(1); - await game.toNextTurn(); - - confirmSwitch(); - }); - - it("Wimp Out will activate due to damaging trap damage", async () => { - game.override.enemySpecies(SpeciesId.MAGIKARP).enemyMoveset([MoveId.WHIRLPOOL]).enemyLevel(1); - await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); - game.scene.getPlayerPokemon()!.hp *= 0.55; - - game.move.select(MoveId.SPLASH); - game.doSelectPartyPokemon(1); - await game.toNextTurn(); - - confirmSwitch(); - }); - - it("Magic Guard passive should not allow indirect damage to trigger Wimp Out", async () => { + it("should activate from entry hazard damage", async () => { + // enemy centiscorch switches in... then dies game.scene.arena.addTag(ArenaTagType.STEALTH_ROCK, 1, MoveId.STEALTH_ROCK, 0, ArenaTagSide.ENEMY); game.scene.arena.addTag(ArenaTagType.SPIKES, 1, MoveId.SPIKES, 0, ArenaTagSide.ENEMY); - game.override - .passiveAbility(AbilityId.MAGIC_GUARD) - .enemyMoveset([MoveId.LEECH_SEED]) - .weather(WeatherType.HAIL) - .statusEffect(StatusEffect.POISON); - await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); - game.scene.getPlayerPokemon()!.hp *= 0.51; + game.override.enemySpecies(SpeciesId.CENTISKORCH).enemyAbility(AbilityId.WIMP_OUT); + await game.classicMode.startBattle([SpeciesId.TYRUNT]); - game.move.select(MoveId.SPLASH); - await game.phaseInterceptor.to("TurnEndPhase"); - - expect(game.scene.getPlayerParty()[0].getHpRatio()).toEqual(0.51); - expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); - expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(SpeciesId.WIMPOD); + expect(game.phaseInterceptor.log).not.toContain("MovePhase"); + expect(game.phaseInterceptor.log).toContain("BattleEndPhase"); }); - it("Wimp Out activating should not cancel a double battle", async () => { - game.override.battleStyle("double").enemyAbility(AbilityId.WIMP_OUT).enemyMoveset([MoveId.SPLASH]).enemyLevel(1); + it("should not switch if Magic Guard prevents damage", async () => { + game.override.passiveAbility(AbilityId.MAGIC_GUARD).enemyMoveset(MoveId.LEECH_SEED); + await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); + + const wimpod = game.field.getPlayerPokemon(); + wimpod.hp *= 0.51; + + game.move.use(MoveId.SPLASH); + await game.toNextTurn(); + + expect(wimpod.isOnField()).toBe(true); + expect(wimpod.getHpRatio()).toBeCloseTo(0.51); + }); + + it("should not cancel a double battle on activation", async () => { + game.override.battleStyle("double").enemyAbility(AbilityId.WIMP_OUT).enemyLevel(1); await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); const enemyLeadPokemon = game.scene.getEnemyParty()[0]; const enemySecPokemon = game.scene.getEnemyParty()[1]; - game.move.select(MoveId.FALSE_SWIPE, 0, BattlerIndex.ENEMY); - game.move.select(MoveId.SPLASH, 1); + game.move.use(MoveId.FALSE_SWIPE, 0, BattlerIndex.ENEMY); + game.move.use(MoveId.SPLASH, 1); - await game.phaseInterceptor.to("BerryPhase"); + await game.toEndOfTurn(); const isVisibleLead = enemyLeadPokemon.visible; const hasFledLead = enemyLeadPokemon.switchOutStatus; @@ -361,145 +457,63 @@ describe("Abilities - Wimp Out", () => { expect(enemySecPokemon.hp).toEqual(enemySecPokemon.getMaxHp()); }); - it("Wimp Out will activate due to aftermath", async () => { - game.override - .moveset([MoveId.THUNDER_PUNCH]) - .enemySpecies(SpeciesId.MAGIKARP) - .enemyAbility(AbilityId.AFTERMATH) - .enemyMoveset([MoveId.SPLASH]) - .enemyLevel(1); - await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); - game.scene.getPlayerPokemon()!.hp *= 0.51; - - game.move.select(MoveId.THUNDER_PUNCH); - game.doSelectPartyPokemon(1); - await game.phaseInterceptor.to("TurnEndPhase"); - - confirmSwitch(); - }); - - it("Activates due to entry hazards", async () => { - game.scene.arena.addTag(ArenaTagType.STEALTH_ROCK, 1, MoveId.STEALTH_ROCK, 0, ArenaTagSide.ENEMY); - game.scene.arena.addTag(ArenaTagType.SPIKES, 1, MoveId.SPIKES, 0, ArenaTagSide.ENEMY); - game.override.enemySpecies(SpeciesId.CENTISKORCH).enemyAbility(AbilityId.WIMP_OUT).startingWave(4); - await game.classicMode.startBattle([SpeciesId.TYRUNT]); - - expect(game.phaseInterceptor.log).not.toContain("MovePhase"); - expect(game.phaseInterceptor.log).toContain("BattleEndPhase"); - }); - - it("Wimp Out will activate due to Nightmare", async () => { - game.override.enemyMoveset([MoveId.NIGHTMARE]).statusEffect(StatusEffect.SLEEP); - await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); - game.scene.getPlayerPokemon()!.hp *= 0.65; - - game.move.select(MoveId.SPLASH); - game.doSelectPartyPokemon(1); - await game.toNextTurn(); - - confirmSwitch(); - }); - - it("triggers status on the wimp out user before a new pokemon is switched in", async () => { - game.override.enemyMoveset(MoveId.SLUDGE_BOMB).startingLevel(80); - await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); - vi.spyOn(allMoves[MoveId.SLUDGE_BOMB], "chance", "get").mockReturnValue(100); - - game.move.select(MoveId.SPLASH); - game.doSelectPartyPokemon(1); - await game.phaseInterceptor.to("TurnEndPhase"); - - expect(game.scene.getPlayerParty()[1].status?.effect).toEqual(StatusEffect.POISON); - confirmSwitch(); - }); - - it("triggers after last hit of multi hit move", async () => { - game.override.enemyMoveset(MoveId.BULLET_SEED).enemyAbility(AbilityId.SKILL_LINK); - await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); - - game.scene.getPlayerPokemon()!.hp *= 0.51; - - game.move.select(MoveId.ENDURE); - game.doSelectPartyPokemon(1); - await game.phaseInterceptor.to("TurnEndPhase"); - - const enemyPokemon = game.scene.getEnemyPokemon()!; - expect(enemyPokemon.turnData.hitsLeft).toBe(0); - expect(enemyPokemon.turnData.hitCount).toBe(5); - confirmSwitch(); - }); - - it("triggers after last hit of multi hit move (multi lens)", async () => { - game.override.enemyMoveset(MoveId.TACKLE).enemyHeldItems([{ name: "MULTI_LENS", count: 1 }]); - await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); - - game.scene.getPlayerPokemon()!.hp *= 0.51; - - game.move.select(MoveId.ENDURE); - game.doSelectPartyPokemon(1); - await game.phaseInterceptor.to("TurnEndPhase"); - - const enemyPokemon = game.scene.getEnemyPokemon()!; - expect(enemyPokemon.turnData.hitsLeft).toBe(0); - expect(enemyPokemon.turnData.hitCount).toBe(2); - confirmSwitch(); - }); - it("triggers after last hit of Parental Bond", async () => { - game.override.enemyMoveset(MoveId.TACKLE).enemyAbility(AbilityId.PARENTAL_BOND); - await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); - - game.scene.getPlayerPokemon()!.hp *= 0.51; - - game.move.select(MoveId.ENDURE); - game.doSelectPartyPokemon(1); - await game.phaseInterceptor.to("TurnEndPhase"); - - const enemyPokemon = game.scene.getEnemyPokemon()!; - expect(enemyPokemon.turnData.hitsLeft).toBe(0); - expect(enemyPokemon.turnData.hitCount).toBe(2); - confirmSwitch(); - }); - - // TODO: This interaction is not implemented yet - it.todo( - "Wimp Out will not activate if the Pokémon's HP falls below half due to hurting itself in confusion", - async () => { - game.override.moveset([MoveId.SWORDS_DANCE]).enemyMoveset([MoveId.SWAGGER]); + it.each<{ type: string; move?: MoveId; ability?: AbilityId; items?: ModifierOverride[] }>([ + { type: "normal", move: MoveId.DUAL_CHOP }, + { type: "Parental Bond", ability: AbilityId.PARENTAL_BOND }, + { type: "Multi Lens", items: [{ name: "MULTI_LENS", count: 1 }] }, + ])( + "should trigger after the last hit of $type multi-strike moves", + async ({ move = MoveId.TACKLE, ability = AbilityId.COMPOUND_EYES, items = [] }) => { + game.override.enemyMoveset(move).enemyAbility(ability).enemyHeldItems(items); await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); - const playerPokemon = game.scene.getPlayerPokemon()!; - playerPokemon.hp *= 0.51; - playerPokemon.setStatStage(Stat.ATK, 6); - playerPokemon.addTag(BattlerTagType.CONFUSED); - // TODO: add helper function to force confusion self-hits + const wimpod = game.field.getPlayerPokemon(); + wimpod.hp *= 0.51; - while (playerPokemon.getHpRatio() > 0.49) { - game.move.select(MoveId.SWORDS_DANCE); - await game.phaseInterceptor.to("TurnEndPhase"); - } + game.move.use(MoveId.ENDURE); + game.doSelectPartyPokemon(1); + await game.toEndOfTurn(); - confirmNoSwitch(); + const enemyPokemon = game.field.getEnemyPokemon(); + expect(enemyPokemon.turnData.hitsLeft).toBe(0); + expect(enemyPokemon.turnData.hitCount).toBe(2); + confirmSwitch(); + + // Switch triggered after the MEPs for both hits finished + const phaseLogs = game.phaseInterceptor.log; + expect(phaseLogs.filter(l => l === "MoveEffectPhase")).toHaveLength(3); // 1 for endure + 2 for dual hit + expect(phaseLogs.lastIndexOf("SwitchSummonPhase")).toBeGreaterThan(phaseLogs.lastIndexOf("MoveEffectPhase")); }, ); - it("should not activate on wave X0 bosses", async () => { - game.override.enemyAbility(AbilityId.WIMP_OUT).startingLevel(5850).startingWave(10); - await game.classicMode.startBattle([SpeciesId.GOLISOPOD]); + it("should not activate from confusion damage", async () => { + game.override.enemyMoveset(MoveId.CONFUSE_RAY).confusionActivation(true); + await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRUNT]); - const enemyPokemon = game.scene.getEnemyPokemon()!; + const wimpod = game.field.getPlayerPokemon(); + wimpod.hp *= 0.51; - // Use 2 turns of False Swipe due to opponent's health bar shield - game.move.select(MoveId.FALSE_SWIPE); - await game.toNextTurn(); - game.move.select(MoveId.FALSE_SWIPE); - await game.toNextTurn(); + game.move.use(MoveId.SPLASH); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.toEndOfTurn(); - const isVisible = enemyPokemon.visible; - const hasFled = enemyPokemon.switchOutStatus; - expect(isVisible && !hasFled).toBe(true); + confirmNoSwitch(); }); - it("wimp out will not skip battles when triggered in a double battle", async () => { + it("should not activate on wave X0 bosses", async () => { + game.override.enemyAbility(AbilityId.WIMP_OUT).startingLevel(5000).startingWave(10).enemyHealthSegments(3); + await game.classicMode.startBattle([SpeciesId.GOLISOPOD]); + + const enemyPokemon = game.field.getEnemyPokemon(); + + game.move.use(MoveId.FALSE_SWIPE); + await game.toNextTurn(); + + expect(enemyPokemon.visible).toBe(true); + expect(enemyPokemon.switchOutStatus).toBe(false); + }); + + it("should not skip battles when triggered in a double battle", async () => { const wave = 2; game.override .enemyMoveset(MoveId.SPLASH) @@ -513,10 +527,10 @@ describe("Abilities - Wimp Out", () => { await game.classicMode.startBattle([SpeciesId.RAICHU, SpeciesId.PIKACHU]); const [wimpod0, wimpod1] = game.scene.getEnemyField(); - game.move.select(MoveId.FALSE_SWIPE, 0, BattlerIndex.ENEMY); - game.move.select(MoveId.MATCHA_GOTCHA, 1); + game.move.use(MoveId.FALSE_SWIPE, 0, BattlerIndex.ENEMY); + game.move.use(MoveId.MATCHA_GOTCHA, 1); await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]); - await game.phaseInterceptor.to("TurnEndPhase"); + await game.toEndOfTurn(); expect(wimpod0.hp).toBeGreaterThan(0); expect(wimpod0.switchOutStatus).toBe(true); @@ -527,27 +541,24 @@ describe("Abilities - Wimp Out", () => { expect(game.scene.currentBattle.waveIndex).toBe(wave + 1); }); - it("wimp out should not skip battles when triggering the same turn as another enemy faints", async () => { - const wave = 2; + it("should not skip battles when triggering the same turn as another enemy faints", async () => { game.override .enemySpecies(SpeciesId.WIMPOD) .enemyAbility(AbilityId.WIMP_OUT) .startingLevel(50) .enemyLevel(1) - .enemyMoveset([MoveId.SPLASH, MoveId.ENDURE]) .battleStyle("double") - .moveset([MoveId.DRAGON_ENERGY, MoveId.SPLASH]) - .startingWave(wave); + .startingWave(2); await game.classicMode.startBattle([SpeciesId.REGIDRAGO, SpeciesId.MAGIKARP]); - // turn 1 - game.move.select(MoveId.DRAGON_ENERGY, 0); - game.move.select(MoveId.SPLASH, 1); - await game.move.selectEnemyMove(MoveId.SPLASH); - await game.move.selectEnemyMove(MoveId.ENDURE); + // turn 1 - 1st wimpod faints while the 2nd one flees + game.move.use(MoveId.DRAGON_ENERGY, BattlerIndex.PLAYER); + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2); + await game.move.forceEnemyMove(MoveId.SPLASH); + await game.move.forceEnemyMove(MoveId.ENDURE); - await game.phaseInterceptor.to("SelectModifierPhase"); - expect(game.scene.currentBattle.waveIndex).toBe(wave + 1); + await game.toNextWave(); + expect(game.scene.currentBattle.waveIndex).toBe(3); }); }); diff --git a/test/items/reviver_seed.test.ts b/test/items/reviver_seed.test.ts index f0929ee0993..038a31ffa5c 100644 --- a/test/items/reviver_seed.test.ts +++ b/test/items/reviver_seed.test.ts @@ -1,10 +1,10 @@ -import { allMoves } from "#data/data-lists"; import { AbilityId } from "#enums/ability-id"; import { BattlerIndex } from "#enums/battler-index"; -import { BattlerTagType } from "#enums/battler-tag-type"; +import { HitResult } from "#enums/hit-result"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; -import type { PokemonInstantReviveModifier } from "#modifiers/modifier"; +import { Stat } from "#enums/stat"; +import { PokemonInstantReviveModifier } from "#modifiers/modifier"; import { GameManager } from "#test/testUtils/gameManager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -26,112 +26,136 @@ describe("Items - Reviver Seed", () => { beforeEach(() => { game = new GameManager(phaserGame); game.override - .moveset([MoveId.SPLASH, MoveId.TACKLE, MoveId.ENDURE]) .ability(AbilityId.BALL_FETCH) .battleStyle("single") .criticalHits(false) .enemySpecies(SpeciesId.MAGIKARP) - .enemyAbility(AbilityId.BALL_FETCH) + .enemyAbility(AbilityId.NO_GUARD) .startingHeldItems([{ name: "REVIVER_SEED" }]) .enemyHeldItems([{ name: "REVIVER_SEED" }]) - .enemyMoveset(MoveId.SPLASH); - vi.spyOn(allMoves[MoveId.SHEER_COLD], "accuracy", "get").mockReturnValue(100); - vi.spyOn(allMoves[MoveId.LEECH_SEED], "accuracy", "get").mockReturnValue(100); - vi.spyOn(allMoves[MoveId.WHIRLPOOL], "accuracy", "get").mockReturnValue(100); - vi.spyOn(allMoves[MoveId.WILL_O_WISP], "accuracy", "get").mockReturnValue(100); - }); - - it.each([ - { moveType: "Special Move", move: MoveId.WATER_GUN }, - { moveType: "Physical Move", move: MoveId.TACKLE }, - { moveType: "Fixed Damage Move", move: MoveId.SEISMIC_TOSS }, - { moveType: "Final Gambit", move: MoveId.FINAL_GAMBIT }, - { moveType: "Counter", move: MoveId.COUNTER }, - { moveType: "OHKO", move: MoveId.SHEER_COLD }, - ])("should activate the holder's reviver seed from a $moveType", async ({ move }) => { - game.override.enemyLevel(100).startingLevel(1).enemyMoveset(move); - await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS]); - const player = game.scene.getPlayerPokemon()!; - player.damageAndUpdate(player.hp - 1); - - const reviverSeed = player.getHeldItems()[0] as PokemonInstantReviveModifier; - vi.spyOn(reviverSeed, "apply"); - - game.move.select(MoveId.TACKLE); - await game.phaseInterceptor.to("BerryPhase"); - - expect(player.isFainted()).toBeFalsy(); - }); - - it("should activate the holder's reviver seed from confusion self-hit", async () => { - game.override.enemyLevel(1).startingLevel(100).enemyMoveset(MoveId.SPLASH); - await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS]); - const player = game.scene.getPlayerPokemon()!; - player.damageAndUpdate(player.hp - 1); - player.addTag(BattlerTagType.CONFUSED, 3); - - const reviverSeed = player.getHeldItems()[0] as PokemonInstantReviveModifier; - vi.spyOn(reviverSeed, "apply"); - - vi.spyOn(player, "randBattleSeedInt").mockReturnValue(0); // Force confusion self-hit - game.move.select(MoveId.TACKLE); - await game.phaseInterceptor.to("BerryPhase"); - - expect(player.isFainted()).toBeFalsy(); - }); - - // Damaging opponents tests - it.each([ - { moveType: "Damaging Move Chip Damage", move: MoveId.SALT_CURE }, - { moveType: "Chip Damage", move: MoveId.LEECH_SEED }, - { moveType: "Trapping Chip Damage", move: MoveId.WHIRLPOOL }, - { moveType: "Status Effect Damage", move: MoveId.WILL_O_WISP }, - { moveType: "Weather", move: MoveId.SANDSTORM }, - ])("should not activate the holder's reviver seed from $moveType", async ({ move }) => { - game.override - .enemyLevel(1) + .enemyMoveset(MoveId.SPLASH) .startingLevel(100) - .enemySpecies(SpeciesId.MAGIKARP) - .moveset(move) - .enemyMoveset(MoveId.ENDURE); - await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS]); - const enemy = game.scene.getEnemyPokemon()!; - enemy.damageAndUpdate(enemy.hp - 1); + .enemyLevel(100); // makes hp tests more accurate due to rounding + }); - game.move.select(move); + it("should be consumed upon fainting to revive the holder, removing temporary effects and healing to 50% max HP", async () => { + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + + const enemy = game.field.getEnemyPokemon()!; + enemy.hp = 1; + enemy.setStatStage(Stat.ATK, 6); + + expect(enemy.getHeldItems()[0]).toBeInstanceOf(PokemonInstantReviveModifier); + game.move.use(MoveId.TACKLE); await game.phaseInterceptor.to("TurnEndPhase"); - expect(enemy.isFainted()).toBeTruthy(); + // Enemy ate seed, was revived and healed to half HP, clearing its attack boost at the same time. + expect(enemy.isFainted()).toBeFalsy(); + expect(enemy.getHpRatio()).toBeCloseTo(0.5); + expect(enemy.getHeldItems()[0]).toBeUndefined(); + expect(enemy.getStatStage(Stat.ATK)).toBe(0); + expect(enemy.turnData.acted).toBe(true); + }); + + it("should nullify move effects on the killing blow and interrupt multi hits", async () => { + // Give player a 4 hit lumina crash that lowers spdef by 2 stages per hit + game.override.ability(AbilityId.PARENTAL_BOND).startingHeldItems([ + { name: "REVIVER_SEED", count: 1 }, + { name: "MULTI_LENS", count: 2 }, + ]); + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + + // give enemy 3 hp, dying 3 hits into the move + const enemy = game.field.getEnemyPokemon()!; + enemy.hp = 3; + vi.spyOn(enemy, "getAttackDamage").mockReturnValue({ cancelled: false, damage: 1, result: HitResult.EFFECTIVE }); + + game.move.use(MoveId.LUMINA_CRASH); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.phaseInterceptor.to("FaintPhase", false); + + expect(enemy.getStatStage(Stat.SPDEF)).toBe(-4); // killing hit effect got nullified due to fainting the target + expect(enemy.getAttackDamage).toHaveBeenCalledTimes(3); + + await game.toEndOfTurn(); + + // Attack was cut short due to lack of targets, after which the enemy was revived and their stat stages reset + expect(enemy.isFainted()).toBeFalsy(); + expect(enemy.getStatStage(Stat.SPDEF)).toBe(0); + expect(enemy.getHpRatio()).toBeCloseTo(0.5); + expect(enemy.getHeldItems()[0]).toBeUndefined(); + + const player = game.scene.getPlayerPokemon()!; + expect(player.turnData.hitsLeft).toBe(1); + }); + + it.each([ + { moveType: "Special Moves", move: MoveId.WATER_GUN }, + { moveType: "Physical Moves", move: MoveId.TACKLE }, + { moveType: "Fixed Damage Moves", move: MoveId.SEISMIC_TOSS }, + { moveType: "Final Gambit", move: MoveId.FINAL_GAMBIT }, + { moveType: "Counter Moves", move: MoveId.COUNTER }, + { moveType: "OHKOs", move: MoveId.SHEER_COLD }, + { moveType: "Confusion Self-hits", move: MoveId.CONFUSE_RAY }, + ])("should activate from $moveType", async ({ move }) => { + game.override.enemyMoveset(move).confusionActivation(true); + await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS]); + const player = game.scene.getPlayerPokemon()!; + player.hp = 1; + + const reviverSeed = player.getHeldItems()[0] as PokemonInstantReviveModifier; + const seedSpy = vi.spyOn(reviverSeed, "apply"); + + game.move.use(MoveId.TACKLE); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.toEndOfTurn(); + + expect(player.isFainted()).toBe(false); + expect(seedSpy).toHaveBeenCalled(); + }); + + // Damaging tests + it.each([ + { moveType: "Salt Cure", move: MoveId.SALT_CURE }, + { moveType: "Leech Seed", move: MoveId.LEECH_SEED }, + { moveType: "Partial Trapping Move", move: MoveId.WHIRLPOOL }, + { moveType: "Status Effect", move: MoveId.WILL_O_WISP }, + { moveType: "Weather", move: MoveId.SANDSTORM }, + ])("should not activate from $moveType damage", async ({ move }) => { + game.override.enemyMoveset(MoveId.ENDURE); + await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS]); + const enemy = game.field.getEnemyPokemon(); + enemy.hp = 1; + + game.move.use(move); + await game.toEndOfTurn(); + + expect(enemy.isFainted()).toBe(true); }); // Self-damage tests it.each([ { moveType: "Recoil", move: MoveId.DOUBLE_EDGE }, - { moveType: "Self-KO", move: MoveId.EXPLOSION }, - { moveType: "Self-Deduction", move: MoveId.CURSE }, + { moveType: "Sacrificial", move: MoveId.EXPLOSION }, + { moveType: "Ghost-type Curse", move: MoveId.CURSE }, { moveType: "Liquid Ooze", move: MoveId.GIGA_DRAIN }, - ])("should not activate the holder's reviver seed from $moveType", async ({ move }) => { - game.override - .enemyLevel(100) - .startingLevel(1) - .enemySpecies(SpeciesId.MAGIKARP) - .moveset(move) - .enemyAbility(AbilityId.LIQUID_OOZE) - .enemyMoveset(MoveId.SPLASH); + ])("should not activate from $moveType self-damage", async ({ move }) => { + game.override.enemyAbility(AbilityId.LIQUID_OOZE); await game.classicMode.startBattle([SpeciesId.GASTLY, SpeciesId.FEEBAS]); const player = game.scene.getPlayerPokemon()!; - player.damageAndUpdate(player.hp - 1); + player.hp = 1; const playerSeed = player.getHeldItems()[0] as PokemonInstantReviveModifier; - vi.spyOn(playerSeed, "apply"); + const seedSpy = vi.spyOn(playerSeed, "apply"); - game.move.select(move); - await game.phaseInterceptor.to("TurnEndPhase"); + game.move.use(move); + await game.toEndOfTurn(); - expect(player.isFainted()).toBeTruthy(); + expect(player.isFainted()).toBe(true); + expect(seedSpy).not.toHaveBeenCalled(); }); - it("should not activate the holder's reviver seed from Destiny Bond fainting", async () => { + it("should not activate from Destiny Bond fainting", async () => { game.override .enemyLevel(100) .startingLevel(1) @@ -140,9 +164,9 @@ describe("Items - Reviver Seed", () => { .startingHeldItems([]) // reset held items to nothing so user doesn't revive and not trigger Destiny Bond .enemyMoveset(MoveId.TACKLE); await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS]); - const player = game.scene.getPlayerPokemon()!; - player.damageAndUpdate(player.hp - 1); - const enemy = game.scene.getEnemyPokemon()!; + const player = game.field.getPlayerPokemon(); + player.hp = 1; + const enemy = game.field.getEnemyPokemon(); game.move.select(MoveId.DESTINY_BOND); await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); diff --git a/test/moves/baton_pass.test.ts b/test/moves/baton_pass.test.ts deleted file mode 100644 index af49ac0db9f..00000000000 --- a/test/moves/baton_pass.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { AbilityId } from "#enums/ability-id"; -import { BattlerIndex } from "#enums/battler-index"; -import { BattlerTagType } from "#enums/battler-tag-type"; -import { MoveId } from "#enums/move-id"; -import { SpeciesId } from "#enums/species-id"; -import { Stat } from "#enums/stat"; -import { GameManager } from "#test/testUtils/gameManager"; -import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; - -describe("Moves - Baton Pass", () => { - 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 - .battleStyle("single") - .enemySpecies(SpeciesId.MAGIKARP) - .enemyAbility(AbilityId.BALL_FETCH) - .moveset([MoveId.BATON_PASS, MoveId.NASTY_PLOT, MoveId.SPLASH]) - .ability(AbilityId.BALL_FETCH) - .enemyMoveset(MoveId.SPLASH) - .criticalHits(false); - }); - - it("transfers all stat stages when player uses it", async () => { - // arrange - await game.classicMode.startBattle([SpeciesId.RAICHU, SpeciesId.SHUCKLE]); - - // round 1 - buff - game.move.select(MoveId.NASTY_PLOT); - await game.toNextTurn(); - - let playerPokemon = game.scene.getPlayerPokemon()!; - - expect(playerPokemon.getStatStage(Stat.SPATK)).toEqual(2); - - // round 2 - baton pass - game.move.select(MoveId.BATON_PASS); - game.doSelectPartyPokemon(1); - await game.phaseInterceptor.to("TurnEndPhase"); - - // assert - playerPokemon = game.scene.getPlayerPokemon()!; - expect(playerPokemon.species.speciesId).toEqual(SpeciesId.SHUCKLE); - expect(playerPokemon.getStatStage(Stat.SPATK)).toEqual(2); - }); - - it("passes stat stage buffs when AI uses it", async () => { - // arrange - game.override.startingWave(5).enemyMoveset([MoveId.NASTY_PLOT, MoveId.BATON_PASS]); - await game.classicMode.startBattle([SpeciesId.RAICHU, SpeciesId.SHUCKLE]); - - // round 1 - ai buffs - game.move.select(MoveId.SPLASH); - await game.move.forceEnemyMove(MoveId.NASTY_PLOT); - await game.toNextTurn(); - - // round 2 - baton pass - game.move.select(MoveId.SPLASH); - await game.move.forceEnemyMove(MoveId.BATON_PASS); - await game.phaseInterceptor.to("PostSummonPhase", false); - - // check buffs are still there - expect(game.scene.getEnemyPokemon()?.getStatStage(Stat.SPATK)).toEqual(2); - // confirm that a switch actually happened. can't use species because I - // can't find a way to override trainer parties with more than 1 pokemon species - expect(game.phaseInterceptor.log.slice(-4)).toEqual([ - "MoveEffectPhase", - "SwitchSummonPhase", - "SummonPhase", - "PostSummonPhase", - ]); - }); - - it("doesn't transfer effects that aren't transferrable", async () => { - game.override.enemyMoveset([MoveId.SALT_CURE]); - await game.classicMode.startBattle([SpeciesId.PIKACHU, SpeciesId.FEEBAS]); - - const [player1, player2] = game.scene.getPlayerParty(); - - game.move.select(MoveId.BATON_PASS); - await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); - await game.phaseInterceptor.to("MoveEndPhase"); - expect(player1.findTag(t => t.tagType === BattlerTagType.SALT_CURED)).toBeTruthy(); - game.doSelectPartyPokemon(1); - await game.toNextTurn(); - - expect(player2.findTag(t => t.tagType === BattlerTagType.SALT_CURED)).toBeUndefined(); - }); - - it("doesn't allow binding effects from the user to persist", async () => { - game.override.moveset([MoveId.FIRE_SPIN, MoveId.BATON_PASS]); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS]); - - const enemy = game.scene.getEnemyPokemon()!; - - game.move.select(MoveId.FIRE_SPIN); - await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); - await game.move.forceHit(); - - await game.toNextTurn(); - - expect(enemy.getTag(BattlerTagType.FIRE_SPIN)).toBeDefined(); - - game.move.select(MoveId.BATON_PASS); - await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); - - game.doSelectPartyPokemon(1); - await game.toNextTurn(); - - expect(enemy.getTag(BattlerTagType.FIRE_SPIN)).toBeUndefined(); - }); -}); diff --git a/test/moves/chilly_reception.test.ts b/test/moves/chilly_reception.test.ts index f34e61873cc..9429830563d 100644 --- a/test/moves/chilly_reception.test.ts +++ b/test/moves/chilly_reception.test.ts @@ -77,9 +77,8 @@ describe("Moves - Chilly Reception", () => { game.move.select(MoveId.CHILLY_RECEPTION); game.doSelectPartyPokemon(1); - // TODO: Uncomment lines once wimp out PR fixes force switches to not reset summon data immediately - // await game.phaseInterceptor.to("SwitchSummonPhase", false); - // expect(slowking.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); + await game.phaseInterceptor.to("SwitchSummonPhase", false); + expect(slowking.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); await game.toEndOfTurn(); diff --git a/test/moves/dragon_tail.test.ts b/test/moves/dragon_tail.test.ts deleted file mode 100644 index c5a77716c56..00000000000 --- a/test/moves/dragon_tail.test.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { allMoves } from "#data/data-lists"; -import { Status } from "#data/status-effect"; -import { AbilityId } from "#enums/ability-id"; -import { BattlerIndex } from "#enums/battler-index"; -import { Challenges } from "#enums/challenges"; -import { MoveId } from "#enums/move-id"; -import { PokemonType } from "#enums/pokemon-type"; -import { SpeciesId } from "#enums/species-id"; -import { StatusEffect } from "#enums/status-effect"; -import { GameManager } from "#test/testUtils/gameManager"; -import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; - -describe("Moves - Dragon Tail", () => { - 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 - .battleStyle("single") - .moveset([MoveId.DRAGON_TAIL, MoveId.SPLASH, MoveId.FLAMETHROWER]) - .enemySpecies(SpeciesId.WAILORD) - .enemyMoveset(MoveId.SPLASH) - .startingLevel(5) - .enemyLevel(5); - - vi.spyOn(allMoves[MoveId.DRAGON_TAIL], "accuracy", "get").mockReturnValue(100); - }); - - it("should cause opponent to flee, and not crash", async () => { - await game.classicMode.startBattle([SpeciesId.DRATINI]); - - const enemyPokemon = game.scene.getEnemyPokemon()!; - - game.move.select(MoveId.DRAGON_TAIL); - - await game.phaseInterceptor.to("BerryPhase"); - - const isVisible = enemyPokemon.visible; - const hasFled = enemyPokemon.switchOutStatus; - expect(!isVisible && hasFled).toBe(true); - - // simply want to test that the game makes it this far without crashing - await game.phaseInterceptor.to("BattleEndPhase"); - }); - - it("should cause opponent to flee, display ability, and not crash", async () => { - game.override.enemyAbility(AbilityId.ROUGH_SKIN); - await game.classicMode.startBattle([SpeciesId.DRATINI]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - const enemyPokemon = game.scene.getEnemyPokemon()!; - - game.move.select(MoveId.DRAGON_TAIL); - - await game.phaseInterceptor.to("BerryPhase"); - - const isVisible = enemyPokemon.visible; - const hasFled = enemyPokemon.switchOutStatus; - expect(!isVisible && hasFled).toBe(true); - expect(leadPokemon.hp).toBeLessThan(leadPokemon.getMaxHp()); - }); - - it("should proceed without crashing in a double battle", async () => { - game.override.battleStyle("double").enemyMoveset(MoveId.SPLASH).enemyAbility(AbilityId.ROUGH_SKIN); - await game.classicMode.startBattle([SpeciesId.DRATINI, SpeciesId.DRATINI, SpeciesId.WAILORD, SpeciesId.WAILORD]); - - const leadPokemon = game.scene.getPlayerParty()[0]!; - - const enemyLeadPokemon = game.scene.getEnemyParty()[0]!; - const enemySecPokemon = game.scene.getEnemyParty()[1]!; - - game.move.select(MoveId.DRAGON_TAIL, 0, BattlerIndex.ENEMY); - game.move.select(MoveId.SPLASH, 1); - - await game.phaseInterceptor.to("TurnEndPhase"); - - const isVisibleLead = enemyLeadPokemon.visible; - const hasFledLead = enemyLeadPokemon.switchOutStatus; - const isVisibleSec = enemySecPokemon.visible; - const hasFledSec = enemySecPokemon.switchOutStatus; - expect(!isVisibleLead && hasFledLead && isVisibleSec && !hasFledSec).toBe(true); - expect(leadPokemon.hp).toBeLessThan(leadPokemon.getMaxHp()); - - // second turn - game.move.select(MoveId.FLAMETHROWER, 0, BattlerIndex.ENEMY_2); - game.move.select(MoveId.SPLASH, 1); - - await game.phaseInterceptor.to("BerryPhase"); - expect(enemySecPokemon.hp).toBeLessThan(enemySecPokemon.getMaxHp()); - }); - - it("should redirect targets upon opponent flee", async () => { - game.override.battleStyle("double").enemyMoveset(MoveId.SPLASH).enemyAbility(AbilityId.ROUGH_SKIN); - await game.classicMode.startBattle([SpeciesId.DRATINI, SpeciesId.DRATINI, SpeciesId.WAILORD, SpeciesId.WAILORD]); - - const leadPokemon = game.scene.getPlayerParty()[0]!; - const secPokemon = game.scene.getPlayerParty()[1]!; - - const enemyLeadPokemon = game.scene.getEnemyParty()[0]!; - const enemySecPokemon = game.scene.getEnemyParty()[1]!; - - game.move.select(MoveId.DRAGON_TAIL, 0, BattlerIndex.ENEMY); - // target the same pokemon, second move should be redirected after first flees - game.move.select(MoveId.DRAGON_TAIL, 1, BattlerIndex.ENEMY); - - await game.phaseInterceptor.to("BerryPhase"); - - const isVisibleLead = enemyLeadPokemon.visible; - const hasFledLead = enemyLeadPokemon.switchOutStatus; - const isVisibleSec = enemySecPokemon.visible; - const hasFledSec = enemySecPokemon.switchOutStatus; - expect(!isVisibleLead && hasFledLead && !isVisibleSec && hasFledSec).toBe(true); - expect(leadPokemon.hp).toBeLessThan(leadPokemon.getMaxHp()); - expect(secPokemon.hp).toBeLessThan(secPokemon.getMaxHp()); - expect(enemyLeadPokemon.hp).toBeLessThan(enemyLeadPokemon.getMaxHp()); - expect(enemySecPokemon.hp).toBeLessThan(enemySecPokemon.getMaxHp()); - }); - - it("doesn't switch out if the target has suction cups", async () => { - game.override.enemyAbility(AbilityId.SUCTION_CUPS); - await game.classicMode.startBattle([SpeciesId.REGIELEKI]); - - const enemy = game.scene.getEnemyPokemon()!; - - game.move.select(MoveId.DRAGON_TAIL); - await game.phaseInterceptor.to("TurnEndPhase"); - - expect(enemy.isFullHp()).toBe(false); - }); - - it("should force a switch upon fainting an opponent normally", async () => { - game.override.startingWave(5).startingLevel(1000); // To make sure Dragon Tail KO's the opponent - await game.classicMode.startBattle([SpeciesId.DRATINI]); - - game.move.select(MoveId.DRAGON_TAIL); - - await game.toNextTurn(); - - // Make sure the enemy switched to a healthy Pokemon - const enemy = game.scene.getEnemyPokemon()!; - expect(enemy).toBeDefined(); - expect(enemy.isFullHp()).toBe(true); - - // Make sure the enemy has a fainted Pokemon in their party and not on the field - const faintedEnemy = game.scene.getEnemyParty().find(p => !p.isAllowedInBattle()); - expect(faintedEnemy).toBeDefined(); - expect(game.scene.getEnemyField().length).toBe(1); - }); - - it("should not cause a softlock when activating an opponent trainer's reviver seed", async () => { - game.override - .startingWave(5) - .enemyHeldItems([{ name: "REVIVER_SEED" }]) - .startingLevel(1000); // To make sure Dragon Tail KO's the opponent - await game.classicMode.startBattle([SpeciesId.DRATINI]); - - game.move.select(MoveId.DRAGON_TAIL); - - await game.toNextTurn(); - - // Make sure the enemy field is not empty and has a revived Pokemon - const enemy = game.scene.getEnemyPokemon()!; - expect(enemy).toBeDefined(); - expect(enemy.hp).toBe(Math.floor(enemy.getMaxHp() / 2)); - expect(game.scene.getEnemyField().length).toBe(1); - }); - - it("should not cause a softlock when activating a player's reviver seed", async () => { - game.override - .startingHeldItems([{ name: "REVIVER_SEED" }]) - .enemyMoveset(MoveId.DRAGON_TAIL) - .enemyLevel(1000); // To make sure Dragon Tail KO's the player - await game.classicMode.startBattle([SpeciesId.DRATINI, SpeciesId.BULBASAUR]); - - game.move.select(MoveId.SPLASH); - - await game.toNextTurn(); - - // Make sure the player's field is not empty and has a revived Pokemon - const dratini = game.scene.getPlayerPokemon()!; - expect(dratini).toBeDefined(); - expect(dratini.hp).toBe(Math.floor(dratini.getMaxHp() / 2)); - expect(game.scene.getPlayerField().length).toBe(1); - }); - - it("should force switches randomly", async () => { - game.override.enemyMoveset(MoveId.DRAGON_TAIL).startingLevel(100).enemyLevel(1); - await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.CHARMANDER, SpeciesId.SQUIRTLE]); - - const [bulbasaur, charmander, squirtle] = game.scene.getPlayerParty(); - - // Turn 1: Mock an RNG call that calls for switching to 1st backup Pokemon (Charmander) - vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => { - return min; - }); - game.move.select(MoveId.SPLASH); - await game.move.selectEnemyMove(MoveId.DRAGON_TAIL); - await game.toNextTurn(); - - expect(bulbasaur.isOnField()).toBe(false); - expect(charmander.isOnField()).toBe(true); - expect(squirtle.isOnField()).toBe(false); - expect(bulbasaur.getInverseHp()).toBeGreaterThan(0); - - // Turn 2: Mock an RNG call that calls for switching to 2nd backup Pokemon (Squirtle) - vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => { - return min + 1; - }); - game.move.select(MoveId.SPLASH); - await game.toNextTurn(); - - expect(bulbasaur.isOnField()).toBe(false); - expect(charmander.isOnField()).toBe(false); - expect(squirtle.isOnField()).toBe(true); - expect(charmander.getInverseHp()).toBeGreaterThan(0); - }); - - it("should not force a switch to a challenge-ineligible Pokemon", async () => { - game.override.enemyMoveset(MoveId.DRAGON_TAIL).startingLevel(100).enemyLevel(1); - // Mono-Water challenge, Eevee is ineligible - game.challengeMode.addChallenge(Challenges.SINGLE_TYPE, PokemonType.WATER + 1, 0); - await game.challengeMode.startBattle([SpeciesId.LAPRAS, SpeciesId.EEVEE, SpeciesId.TOXAPEX, SpeciesId.PRIMARINA]); - - const [lapras, eevee, toxapex, primarina] = game.scene.getPlayerParty(); - - // Turn 1: Mock an RNG call that would normally call for switching to Eevee, but it is ineligible - vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => { - return min; - }); - game.move.select(MoveId.SPLASH); - await game.toNextTurn(); - - expect(lapras.isOnField()).toBe(false); - expect(eevee.isOnField()).toBe(false); - expect(toxapex.isOnField()).toBe(true); - expect(primarina.isOnField()).toBe(false); - expect(lapras.getInverseHp()).toBeGreaterThan(0); - }); - - it("should not force a switch to a fainted Pokemon", async () => { - game.override.enemyMoveset([MoveId.SPLASH, MoveId.DRAGON_TAIL]).startingLevel(100).enemyLevel(1); - await game.classicMode.startBattle([SpeciesId.LAPRAS, SpeciesId.EEVEE, SpeciesId.TOXAPEX, SpeciesId.PRIMARINA]); - - const [lapras, eevee, toxapex, primarina] = game.scene.getPlayerParty(); - - // Turn 1: Eevee faints - eevee.hp = 0; - eevee.status = new Status(StatusEffect.FAINT); - expect(eevee.isFainted()).toBe(true); - game.move.select(MoveId.SPLASH); - await game.move.selectEnemyMove(MoveId.SPLASH); - await game.toNextTurn(); - - // Turn 2: Mock an RNG call that would normally call for switching to Eevee, but it is fainted - vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => { - return min; - }); - game.move.select(MoveId.SPLASH); - await game.move.selectEnemyMove(MoveId.DRAGON_TAIL); - await game.toNextTurn(); - - expect(lapras.isOnField()).toBe(false); - expect(eevee.isOnField()).toBe(false); - expect(toxapex.isOnField()).toBe(true); - expect(primarina.isOnField()).toBe(false); - expect(lapras.getInverseHp()).toBeGreaterThan(0); - }); - - it("should not force a switch if there are no available Pokemon to switch into", async () => { - game.override.enemyMoveset([MoveId.SPLASH, MoveId.DRAGON_TAIL]).startingLevel(100).enemyLevel(1); - await game.classicMode.startBattle([SpeciesId.LAPRAS, SpeciesId.EEVEE]); - - const [lapras, eevee] = game.scene.getPlayerParty(); - - // Turn 1: Eevee faints - eevee.hp = 0; - eevee.status = new Status(StatusEffect.FAINT); - expect(eevee.isFainted()).toBe(true); - game.move.select(MoveId.SPLASH); - await game.move.selectEnemyMove(MoveId.SPLASH); - await game.toNextTurn(); - - // Turn 2: Mock an RNG call that would normally call for switching to Eevee, but it is fainted - vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => { - return min; - }); - game.move.select(MoveId.SPLASH); - await game.move.selectEnemyMove(MoveId.DRAGON_TAIL); - await game.toNextTurn(); - - expect(lapras.isOnField()).toBe(true); - expect(eevee.isOnField()).toBe(false); - expect(lapras.getInverseHp()).toBeGreaterThan(0); - }); -}); diff --git a/test/moves/focus_punch.test.ts b/test/moves/focus_punch.test.ts index 5123e99c6ff..a6d53ccac70 100644 --- a/test/moves/focus_punch.test.ts +++ b/test/moves/focus_punch.test.ts @@ -44,20 +44,18 @@ describe("Moves - Focus Punch", () => { const leadPokemon = game.scene.getPlayerPokemon()!; const enemyPokemon = game.scene.getEnemyPokemon()!; - const enemyStartingHp = enemyPokemon.hp; - game.move.select(MoveId.FOCUS_PUNCH); await game.phaseInterceptor.to(MessagePhase); - expect(enemyPokemon.hp).toBe(enemyStartingHp); + expect(enemyPokemon.getInverseHp()).toBe(0); expect(leadPokemon.getMoveHistory().length).toBe(0); await game.phaseInterceptor.to(BerryPhase, false); - expect(enemyPokemon.hp).toBeLessThan(enemyStartingHp); + expect(enemyPokemon.getInverseHp()).toBe(0); expect(leadPokemon.getMoveHistory().length).toBe(1); - expect(leadPokemon.turnData.totalDamageDealt).toBe(enemyStartingHp - enemyPokemon.hp); + expect(enemyPokemon.getInverseHp()).toBeGreaterThan(0); }); it("should fail if the user is hit", async () => { @@ -72,16 +70,16 @@ describe("Moves - Focus Punch", () => { game.move.select(MoveId.FOCUS_PUNCH); - await game.phaseInterceptor.to(MessagePhase); + await game.phaseInterceptor.to("MessagePhase"); expect(enemyPokemon.hp).toBe(enemyStartingHp); expect(leadPokemon.getMoveHistory().length).toBe(0); - await game.phaseInterceptor.to(BerryPhase, false); + await game.phaseInterceptor.to("BerryPhase", false); expect(enemyPokemon.hp).toBe(enemyStartingHp); expect(leadPokemon.getMoveHistory().length).toBe(1); - expect(leadPokemon.turnData.totalDamageDealt).toBe(0); + expect(enemyPokemon.getInverseHp()).toBe(0); }); it("should be cancelled if the user falls asleep mid-turn", async () => { diff --git a/test/moves/force-switch.test.ts b/test/moves/force-switch.test.ts new file mode 100644 index 00000000000..31fc82592c7 --- /dev/null +++ b/test/moves/force-switch.test.ts @@ -0,0 +1,503 @@ +import { SubstituteTag } from "#app/data/battler-tags"; +import { getPokemonNameWithAffix } from "#app/messages"; +import { splitArray } from "#app/utils/array"; +import { toDmgValue } from "#app/utils/common"; +import { AbilityId } from "#enums/ability-id"; +import { BattleType } from "#enums/battle-type"; +import { BattlerIndex } from "#enums/battler-index"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { Challenges } from "#enums/challenges"; +import { MoveId } from "#enums/move-id"; +import { MoveResult } from "#enums/move-result"; +import { PokemonType } from "#enums/pokemon-type"; +import { SpeciesId } from "#enums/species-id"; +import { Stat } from "#enums/stat"; +import { TrainerSlot } from "#enums/trainer-slot"; +import { TrainerType } from "#enums/trainer-type"; +import { GameManager } from "#test/testUtils/gameManager"; +import i18next from "i18next"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("Moves - Switching Moves", () => { + 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 + .battleStyle("single") + .ability(AbilityId.STURDY) + .passiveAbility(AbilityId.NO_GUARD) + .enemySpecies(SpeciesId.WAILORD) + .enemyAbility(AbilityId.STURDY) + .enemyMoveset(MoveId.SPLASH) + .criticalHits(false); + }); + + describe("Force Switch Moves", () => { + it("should force switches to a random off-field pokemon", async () => { + await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.CHARMANDER, SpeciesId.SQUIRTLE]); + + const [bulbasaur, charmander, squirtle] = game.scene.getPlayerParty(); + + // Turn 1: Mock an RNG call that calls for switching to 1st backup Pokemon (Charmander) + vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => { + return min; + }); + game.move.use(MoveId.SPLASH); + await game.move.forceEnemyMove(MoveId.DRAGON_TAIL); + await game.toNextTurn(); + + expect(bulbasaur.isOnField()).toBe(false); + expect(charmander.isOnField()).toBe(true); + expect(squirtle.isOnField()).toBe(false); + expect(bulbasaur.getInverseHp()).toBeGreaterThan(0); + + // Turn 2: Mock an RNG call that calls for switching to 2nd backup Pokemon (Squirtle) + vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => { + return min + 1; + }); + game.move.use(MoveId.SPLASH); + await game.toNextTurn(); + + expect(bulbasaur.isOnField()).toBe(false); + expect(charmander.isOnField()).toBe(false); + expect(squirtle.isOnField()).toBe(true); + expect(charmander.getInverseHp()).toBeGreaterThan(0); + }); + + it("should force trainers to switch randomly without selecting from a partner's party", async () => { + game.override + .battleStyle("double") + .battleType(BattleType.TRAINER) + .randomTrainer({ trainerType: TrainerType.TATE, alwaysDouble: true }) + .enemySpecies(0); + await game.classicMode.startBattle([SpeciesId.WIMPOD, SpeciesId.TYRANITAR]); + + expect(game.scene.currentBattle.trainer).not.toBeNull(); + const choiceSwitchSpy = vi.spyOn(game.scene.currentBattle.trainer!, "getNextSummonIndex"); + + // Grab each trainer's pokemon based on species name + const [tateParty, lizaParty] = splitArray( + game.scene.getEnemyParty(), + pkmn => pkmn.trainerSlot === TrainerSlot.TRAINER, + ).map(a => a.map(p => p.species.name)); + + expect(tateParty).not.toEqual(lizaParty); + + // Force enemy trainers to switch to the first mon available. + // Due to how enemy trainer parties are laid out, this prevents false positives + // as Tate's pokemon are placed immediately before Liza's corresponding members. + vi.spyOn(Phaser.Math.RND, "integerInRange").mockImplementation(min => min); + + game.move.use(MoveId.DRAGON_TAIL, BattlerIndex.PLAYER, BattlerIndex.ENEMY_2); + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2); + await game.toEndOfTurn(); + + const [tatePartyNew, lizaPartyNew] = splitArray( + game.scene.getEnemyParty(), + pkmn => pkmn.trainerSlot === TrainerSlot.TRAINER, + ).map(a => a.map(p => p.species.name)); + + // Forced switch move should have switched Liza's Pokemon with another one of her own at random + expect(tatePartyNew).toEqual(tateParty); + expect(lizaPartyNew).not.toEqual(lizaParty); + expect(choiceSwitchSpy).not.toHaveBeenCalled(); + }); + + it("should force wild Pokemon to flee and redirect moves accordingly", async () => { + game.override.battleStyle("double"); + await game.classicMode.startBattle([SpeciesId.DRATINI, SpeciesId.DRATINI]); + + const [enemyLeadPokemon, enemySecPokemon] = game.scene.getEnemyParty(); + + game.move.use(MoveId.DRAGON_TAIL, BattlerIndex.PLAYER, BattlerIndex.ENEMY); + // target the same pokemon, second move should be redirected after first flees + // Focus punch used due to having even lower priority than Dtail + game.move.use(MoveId.FOCUS_PUNCH, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER, BattlerIndex.PLAYER_2]); + await game.toEndOfTurn(); + + expect(enemyLeadPokemon.visible).toBe(false); + expect(enemyLeadPokemon.switchOutStatus).toBe(true); + expect(enemySecPokemon.hp).toBeLessThan(enemySecPokemon.getMaxHp()); + }); + + it("should not switch out a target with suction cups, unless the user has Mold Breaker", async () => { + game.override.enemyAbility(AbilityId.SUCTION_CUPS); + await game.classicMode.startBattle([SpeciesId.REGIELEKI]); + + const enemy = game.field.getEnemyPokemon(); + + game.move.use(MoveId.DRAGON_TAIL); + await game.toEndOfTurn(); + + expect(enemy.isOnField()).toBe(true); + expect(enemy.isFullHp()).toBe(false); + + // Turn 2: Mold Breaker should ignore switch blocking ability and switch out the target + game.field.mockAbility(game.field.getPlayerPokemon(), AbilityId.MOLD_BREAKER); + enemy.hp = enemy.getMaxHp(); + + game.move.use(MoveId.DRAGON_TAIL); + await game.toEndOfTurn(); + + expect(enemy.isOnField()).toBe(false); + expect(enemy.isFullHp()).toBe(false); + }); + + it("should not switch out a Commanded Dondozo", async () => { + game.override.battleStyle("double").enemySpecies(SpeciesId.DONDOZO); + await game.classicMode.startBattle([SpeciesId.REGIELEKI]); + + // pretend dondozo 2 commanded dondozo 1 (silly I know, but it works) + const [dondozo1, dondozo2] = game.scene.getEnemyField(); + dondozo1.addTag(BattlerTagType.COMMANDED, 1, MoveId.NONE, dondozo2.id); + + game.move.use(MoveId.DRAGON_TAIL); + await game.toEndOfTurn(); + + expect(dondozo1.isOnField()).toBe(true); + expect(dondozo1.isFullHp()).toBe(false); + }); + + it("should perform a normal switch upon fainting an opponent", async () => { + game.override.battleType(BattleType.TRAINER).startingLevel(1000); // To make sure Dragon Tail KO's the opponent + await game.classicMode.startBattle([SpeciesId.DRATINI]); + + expect(game.scene.getEnemyParty()).toHaveLength(2); + const choiceSwitchSpy = vi.spyOn(game.scene.currentBattle.trainer!, "getNextSummonIndex"); + + game.move.use(MoveId.DRAGON_TAIL); + await game.toNextTurn(); + + const enemy = game.field.getEnemyPokemon(); + expect(enemy).toBeDefined(); + expect(enemy.isFullHp()).toBe(true); + + expect(choiceSwitchSpy).toHaveBeenCalledTimes(1); + }); + + it("should neither switch nor softlock when activating an opponent's reviver seed", async () => { + game.override + .battleType(BattleType.TRAINER) + .enemySpecies(SpeciesId.BLISSEY) + .enemyHeldItems([{ name: "REVIVER_SEED" }]); + await game.classicMode.startBattle([SpeciesId.DRATINI]); + + const [blissey1, blissey2] = game.scene.getEnemyParty()!; + blissey1.hp = 1; + + game.move.use(MoveId.DRAGON_TAIL); + await game.toNextTurn(); + + // Bliseey #1 should have consumed the reviver seed and stayed on field + expect(blissey1.isOnField()).toBe(true); + expect(blissey1.getHpRatio()).toBeCloseTo(0.5); + expect(blissey1.getHeldItems()).toHaveLength(0); + expect(blissey2.isOnField()).toBe(false); + }); + + it("should neither switch nor softlock when activating a player's reviver seed", async () => { + game.override.startingHeldItems([{ name: "REVIVER_SEED" }]).startingLevel(1000); // make hp rounding consistent + await game.classicMode.startBattle([SpeciesId.BLISSEY, SpeciesId.BULBASAUR]); + + const [blissey, bulbasaur] = game.scene.getPlayerParty(); + blissey.hp = 1; + + game.move.use(MoveId.SPLASH); + await game.move.forceEnemyMove(MoveId.DRAGON_TAIL); + await game.toNextTurn(); + + // dratini should have consumed the reviver seed and stayed on field + expect(blissey.isOnField()).toBe(true); + expect(blissey.getHpRatio()).toBeCloseTo(0.5); + expect(blissey.getHeldItems()).toHaveLength(0); + expect(bulbasaur.isOnField()).toBe(false); + }); + + it("should not force a switch to a fainted or challenge-ineligible Pokemon", async () => { + game.override.startingLevel(100).enemyLevel(1); + // Mono-Water challenge, Eevee is ineligible + game.challengeMode.addChallenge(Challenges.SINGLE_TYPE, PokemonType.WATER + 1, 0); + await game.challengeMode.startBattle([SpeciesId.LAPRAS, SpeciesId.EEVEE, SpeciesId.TOXAPEX, SpeciesId.PRIMARINA]); + + const [lapras, eevee, toxapex, primarina] = game.scene.getPlayerParty(); + toxapex.hp = 0; + + // Mock an RNG call to switch to the first eligible pokemon. + // Eevee is ineligible and Toxapex is fainted, so it should proc on Primarina instead + vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => { + return min; + }); + game.move.use(MoveId.SPLASH); + await game.move.forceEnemyMove(MoveId.DRAGON_TAIL); + await game.toNextTurn(); + + expect(lapras.isOnField()).toBe(false); + expect(eevee.isOnField()).toBe(false); + expect(toxapex.isOnField()).toBe(false); + expect(primarina.isOnField()).toBe(true); + expect(lapras.getInverseHp()).toBeGreaterThan(0); + }); + + it.each<{ name: string; move: MoveId }>([ + { name: "Whirlwind", move: MoveId.WHIRLWIND }, + { name: "Roar", move: MoveId.ROAR }, + { name: "Dragon Tail", move: MoveId.DRAGON_TAIL }, + { name: "Circle Throw", move: MoveId.CIRCLE_THROW }, + ])("should display custom text for forced switch outs", async ({ move }) => { + game.override.battleType(BattleType.TRAINER); + await game.classicMode.startBattle([SpeciesId.BLISSEY, SpeciesId.BULBASAUR]); + + const enemy = game.field.getEnemyPokemon(); + game.move.use(move); + await game.toNextTurn(); + + const newEnemy = game.field.getEnemyPokemon(); + expect(newEnemy).not.toBe(enemy); + expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); + // TODO: Replace this with the locale key in question + expect(game.textInterceptor.logs).toContain( + i18next.t("battle:pokemonDraggedOut", { + pokemonName: getPokemonNameWithAffix(newEnemy), + }), + ); + }); + }); + + describe("Self-Switch Attack Moves", () => { + it("should trigger post defend abilities before a new pokemon is switched in", async () => { + game.override.enemyAbility(AbilityId.ROUGH_SKIN); + await game.classicMode.startBattle([SpeciesId.RAICHU, SpeciesId.SHUCKLE]); + + const raichu = game.field.getPlayerPokemon(); + + game.move.use(MoveId.U_TURN); + game.doSelectPartyPokemon(1); + // advance to the phase for picking party members to send out + await game.phaseInterceptor.to("SwitchPhase", false); + + expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); + const player = game.field.getPlayerPokemon(); + expect(player).toBe(raichu); + expect(player.isFullHp()).toBe(false); + expect(game.field.getEnemyPokemon().waveData.abilityRevealed).toBe(true); // proxy for asserting ability activated + }); + }); + + describe("Baton Pass", () => { + it("should pass the user's stat stages and BattlerTags to an ally", async () => { + await game.classicMode.startBattle([SpeciesId.RAICHU, SpeciesId.SHUCKLE]); + + game.move.use(MoveId.NASTY_PLOT); + await game.toNextTurn(); + + const [raichu, shuckle] = game.scene.getPlayerParty(); + expect(raichu.getStatStage(Stat.SPATK)).toEqual(2); + + game.move.use(MoveId.SUBSTITUTE); + await game.toNextTurn(); + + expect(raichu.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined(); + + game.move.use(MoveId.BATON_PASS); + game.doSelectPartyPokemon(1); + await game.toEndOfTurn(); + + expect(game.field.getPlayerPokemon()).toBe(shuckle); + expect(shuckle.getStatStage(Stat.SPATK)).toEqual(2); + expect(shuckle.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined(); + }); + + it("should not transfer non-transferrable effects", async () => { + await game.classicMode.startBattle([SpeciesId.PIKACHU, SpeciesId.FEEBAS]); + + const [player1, player2] = game.scene.getPlayerParty(); + + game.move.use(MoveId.BATON_PASS); + await game.move.forceEnemyMove(MoveId.SALT_CURE); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + + await game.phaseInterceptor.to("MoveEndPhase"); + expect(player1.getTag(BattlerTagType.SALT_CURED)).toBeDefined(); + + game.doSelectPartyPokemon(1); + await game.toNextTurn(); + + expect(player1.isOnField()).toBe(false); + expect(player2.isOnField()).toBe(true); + expect(player2.getTag(BattlerTagType.SALT_CURED)).toBeUndefined(); + }); + + it("should remove the user's binding effects on end", async () => { + await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS]); + + game.move.use(MoveId.FIRE_SPIN); + await game.move.forceHit(); + await game.toNextTurn(); + + const enemy = game.field.getEnemyPokemon(); + expect(enemy.getTag(BattlerTagType.FIRE_SPIN)).toBeDefined(); + + game.move.use(MoveId.BATON_PASS); + game.doSelectPartyPokemon(1); + await game.toNextTurn(); + + expect(enemy.getTag(BattlerTagType.FIRE_SPIN)).toBeUndefined(); + }); + }); + + describe("Shed Tail", () => { + it("should consume 50% of the user's max HP (rounded up) to transfer a 25% HP Substitute doll", async () => { + await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS]); + + const magikarp = game.field.getPlayerPokemon(); + + game.move.use(MoveId.SHED_TAIL); + game.doSelectPartyPokemon(1); + await game.toEndOfTurn(); + + const feebas = game.field.getPlayerPokemon(); + expect(feebas).not.toBe(magikarp); + expect(feebas.hp).toBe(feebas.getMaxHp()); + + const substituteTag = feebas.getTag(SubstituteTag)!; + expect(substituteTag).toBeDefined(); + + expect(magikarp.getInverseHp()).toBe(Math.ceil(magikarp.getMaxHp() / 2)); + expect(substituteTag.hp).toBe(Math.floor(magikarp.getMaxHp() / 4)); + }); + + it("should not transfer other effects", async () => { + await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS]); + + const magikarp = game.field.getPlayerPokemon(); + magikarp.setStatStage(Stat.ATK, 6); + + game.move.use(MoveId.SHED_TAIL); + game.doSelectPartyPokemon(1); + await game.toEndOfTurn(); + + const newMon = game.field.getPlayerPokemon(); + expect(newMon).not.toBe(magikarp); + expect(newMon.getStatStage(Stat.ATK)).toBe(0); + expect(magikarp.getStatStage(Stat.ATK)).toBe(0); + }); + + it("should fail if the user's HP is insufficient", async () => { + await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS]); + + const magikarp = game.field.getPlayerPokemon(); + const initHp = toDmgValue(magikarp.getMaxHp() / 2 - 1); + magikarp.hp = initHp; + + game.move.use(MoveId.SHED_TAIL); + await game.toEndOfTurn(); + + expect(magikarp.isOnField()).toBe(true); + expect(magikarp.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + expect(magikarp.hp).toBe(initHp); + }); + }); + + describe("Failure Checks", () => { + it.each<{ name: string; move: MoveId }>([ + { name: "U-Turn", move: MoveId.U_TURN }, + { name: "Flip Turn", move: MoveId.FLIP_TURN }, + { name: "Volt Switch", move: MoveId.VOLT_SWITCH }, + { name: "Baton Pass", move: MoveId.BATON_PASS }, + { name: "Shed Tail", move: MoveId.SHED_TAIL }, + { name: "Parting Shot", move: MoveId.PARTING_SHOT }, + ])("$name should not allow wild pokemon to flee", async ({ move }) => { + game.override.enemyMoveset(move); + await game.classicMode.startBattle([SpeciesId.RAICHU, SpeciesId.SHUCKLE]); + + const karp = game.field.getEnemyPokemon(); + game.move.use(MoveId.SPLASH); + await game.toEndOfTurn(); + + expect(game.phaseInterceptor.log).not.toContain("BattleEndPhase"); + const enemy = game.field.getEnemyPokemon(); + expect(enemy).toBe(karp); + expect(enemy.switchOutStatus).toBe(false); + }); + + it.each<{ name: string; move?: MoveId; enemyMove?: MoveId }>([ + { name: "Teleport", enemyMove: MoveId.TELEPORT }, + { name: "Whirlwind", move: MoveId.WHIRLWIND }, + { name: "Roar", move: MoveId.ROAR }, + { name: "Dragon Tail", move: MoveId.DRAGON_TAIL }, + { name: "Circle Throw", move: MoveId.CIRCLE_THROW }, + ])("$name should allow wild pokemon to flee", async ({ move = MoveId.SPLASH, enemyMove = MoveId.SPLASH }) => { + await game.classicMode.startBattle([SpeciesId.RAICHU, SpeciesId.SHUCKLE]); + + const enemy = game.field.getEnemyPokemon(); + + game.move.use(move); + await game.move.forceEnemyMove(enemyMove); + game.doSelectPartyPokemon(1); + await game.toNextTurn(); + + expect(game.phaseInterceptor.log).toContain("BattleEndPhase"); + expect(game.field.getEnemyPokemon()).not.toBe(enemy); + }); + + it.each<{ name: string; move?: MoveId; enemyMove?: MoveId }>([ + { name: "U-Turn", move: MoveId.U_TURN }, + { name: "Flip Turn", move: MoveId.FLIP_TURN }, + { name: "Volt Switch", move: MoveId.VOLT_SWITCH }, + // TODO: Enable once Parting shot is fixed + { name: "Parting Shot", move: MoveId.PARTING_SHOT }, + { name: "Dragon Tail", enemyMove: MoveId.DRAGON_TAIL }, + { name: "Circle Throw", enemyMove: MoveId.CIRCLE_THROW }, + ])( + "$name should not fail if no valid switch out target is found", + async ({ move = MoveId.SPLASH, enemyMove = MoveId.SPLASH }) => { + await game.classicMode.startBattle([SpeciesId.RAICHU]); + + game.move.use(move); + await game.move.forceEnemyMove(enemyMove); + game.doSelectPartyPokemon(1); + await game.toNextTurn(); + + expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); + const user = enemyMove === MoveId.SPLASH ? game.field.getPlayerPokemon() : game.field.getEnemyPokemon(); + expect(user.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); + }, + ); + + it.each<{ name: string; move?: MoveId; enemyMove?: MoveId }>([ + { name: "Teleport", move: MoveId.TELEPORT }, + { name: "Baton Pass", move: MoveId.BATON_PASS }, + { name: "Shed Tail", move: MoveId.SHED_TAIL }, + { name: "Roar", enemyMove: MoveId.ROAR }, + { name: "Whirlwind", enemyMove: MoveId.WHIRLWIND }, + ])( + "$name should fail if no valid switch out target is found", + async ({ move = MoveId.SPLASH, enemyMove = MoveId.SPLASH }) => { + await game.classicMode.startBattle([SpeciesId.RAICHU, SpeciesId.SHUCKLE]); + + game.move.use(move); + await game.move.forceEnemyMove(enemyMove); + game.doSelectPartyPokemon(1); + await game.toNextTurn(); + + expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); + const user = enemyMove === MoveId.SPLASH ? game.field.getPlayerPokemon() : game.field.getEnemyPokemon(); + expect(user.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + }, + ); + }); +}); diff --git a/test/moves/powder.test.ts b/test/moves/powder.test.ts index 3e60c0d9fec..13fc67261de 100644 --- a/test/moves/powder.test.ts +++ b/test/moves/powder.test.ts @@ -175,7 +175,7 @@ describe("Moves - Powder", () => { expect(playerPokemon.hp).toBe(playerPokemon.getMaxHp()); // enemy should have taken damage from player's Fiery Dance + 2 Powder procs expect(enemyPokemon.hp).toBe( - enemyStartingHp - playerPokemon.turnData.totalDamageDealt - 2 * Math.floor(enemyPokemon.getMaxHp() / 4), + enemyStartingHp - playerPokemon.turnData.singleHitDamageDealt - 2 * Math.floor(enemyPokemon.getMaxHp() / 4), ); }); diff --git a/test/moves/shed_tail.test.ts b/test/moves/shed_tail.test.ts deleted file mode 100644 index 8a837a642c9..00000000000 --- a/test/moves/shed_tail.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { SubstituteTag } from "#data/battler-tags"; -import { AbilityId } from "#enums/ability-id"; -import { MoveId } from "#enums/move-id"; -import { MoveResult } from "#enums/move-result"; -import { SpeciesId } from "#enums/species-id"; -import { GameManager } from "#test/testUtils/gameManager"; -import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; - -describe("Moves - Shed Tail", () => { - 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 - .moveset([MoveId.SHED_TAIL]) - .battleStyle("single") - .enemySpecies(SpeciesId.SNORLAX) - .enemyAbility(AbilityId.BALL_FETCH) - .enemyMoveset(MoveId.SPLASH); - }); - - it("transfers a Substitute doll to the switched in Pokemon", async () => { - await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS]); - - const magikarp = game.scene.getPlayerPokemon()!; - - game.move.select(MoveId.SHED_TAIL); - game.doSelectPartyPokemon(1); - - await game.phaseInterceptor.to("TurnEndPhase", false); - - const feebas = game.scene.getPlayerPokemon()!; - const substituteTag = feebas.getTag(SubstituteTag); - - expect(feebas).not.toBe(magikarp); - expect(feebas.hp).toBe(feebas.getMaxHp()); - // Note: Altered the test to be consistent with the correct HP cost :yipeevee_static: - expect(magikarp.hp).toBe(Math.floor(magikarp.getMaxHp() / 2)); - expect(substituteTag).toBeDefined(); - expect(substituteTag?.hp).toBe(Math.floor(magikarp.getMaxHp() / 4)); - }); - - it("should fail if no ally is available to switch in", async () => { - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const magikarp = game.scene.getPlayerPokemon()!; - expect(game.scene.getPlayerParty().length).toBe(1); - - game.move.select(MoveId.SHED_TAIL); - - await game.phaseInterceptor.to("TurnEndPhase", false); - - expect(magikarp.isOnField()).toBeTruthy(); - expect(magikarp.getLastXMoves()[0].result).toBe(MoveResult.FAIL); - }); -}); diff --git a/test/moves/u_turn.test.ts b/test/moves/u_turn.test.ts deleted file mode 100644 index cf8b91511ca..00000000000 --- a/test/moves/u_turn.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { AbilityId } from "#enums/ability-id"; -import { MoveId } from "#enums/move-id"; -import { SpeciesId } from "#enums/species-id"; -import { StatusEffect } from "#enums/status-effect"; -import { GameManager } from "#test/testUtils/gameManager"; -import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; - -describe("Moves - U-turn", () => { - 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 - .battleStyle("single") - .enemySpecies(SpeciesId.GENGAR) - .startingLevel(90) - .startingWave(97) - .moveset([MoveId.U_TURN]) - .enemyMoveset(MoveId.SPLASH) - .criticalHits(false); - }); - - it("triggers regenerator a single time when a regenerator user switches out with u-turn", async () => { - // arrange - const playerHp = 1; - game.override.ability(AbilityId.REGENERATOR); - await game.classicMode.startBattle([SpeciesId.RAICHU, SpeciesId.SHUCKLE]); - game.scene.getPlayerPokemon()!.hp = playerHp; - - // act - game.move.select(MoveId.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()!.species.speciesId).toBe(SpeciesId.SHUCKLE); - }); - - it("triggers rough skin on the u-turn user before a new pokemon is switched in", async () => { - // arrange - game.override.enemyAbility(AbilityId.ROUGH_SKIN); - await game.classicMode.startBattle([SpeciesId.RAICHU, SpeciesId.SHUCKLE]); - - // act - game.move.select(MoveId.U_TURN); - game.doSelectPartyPokemon(1); - await game.phaseInterceptor.to("SwitchPhase", false); - - // assert - const playerPkm = game.scene.getPlayerPokemon()!; - expect(playerPkm.hp).not.toEqual(playerPkm.getMaxHp()); - expect(game.scene.getEnemyPokemon()!.waveData.abilityRevealed).toBe(true); // proxy for asserting ability activated - expect(playerPkm.species.speciesId).toEqual(SpeciesId.RAICHU); - expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); - }); - - it("triggers contact abilities on the u-turn user (eg poison point) before a new pokemon is switched in", async () => { - // arrange - game.override.enemyAbility(AbilityId.POISON_POINT); - await game.classicMode.startBattle([SpeciesId.RAICHU, SpeciesId.SHUCKLE]); - vi.spyOn(game.scene.getEnemyPokemon()!, "randBattleSeedInt").mockReturnValue(0); - - // act - game.move.select(MoveId.U_TURN); - await game.phaseInterceptor.to("SwitchPhase", false); - - // assert - const playerPkm = game.scene.getPlayerPokemon()!; - expect(playerPkm.status?.effect).toEqual(StatusEffect.POISON); - expect(playerPkm.species.speciesId).toEqual(SpeciesId.RAICHU); - expect(game.scene.getEnemyPokemon()!.waveData.abilityRevealed).toBe(true); // proxy for asserting ability activated - expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); - }); - - it("still forces a switch if u-turn KO's the opponent", async () => { - game.override.startingLevel(1000); // Ensure that U-Turn KO's the opponent - await game.classicMode.startBattle([SpeciesId.RAICHU, SpeciesId.SHUCKLE]); - const enemy = game.scene.getEnemyPokemon()!; - - // KO the opponent with U-Turn - game.move.select(MoveId.U_TURN); - game.doSelectPartyPokemon(1); - await game.phaseInterceptor.to("TurnEndPhase"); - expect(enemy.isFainted()).toBe(true); - - // Check that U-Turn forced a switch - expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); - expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(SpeciesId.SHUCKLE); - }); -}); diff --git a/test/testUtils/gameManager.ts b/test/testUtils/gameManager.ts index b6d0da49902..15ffb77d874 100644 --- a/test/testUtils/gameManager.ts +++ b/test/testUtils/gameManager.ts @@ -166,6 +166,8 @@ export 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, From da404660dca3c4e09b6a675d12dab41ba591fc1d Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Wed, 23 Jul 2025 16:06:26 -0400 Subject: [PATCH 2/5] Fixed stuff --- src/data/abilities/ability.ts | 6 ++++-- src/data/helpers/force-switch.ts | 14 +++++++------- src/data/moves/move.ts | 4 ++-- src/phases/switch-summon-phase.ts | 12 +++++------- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index 5ff83cedaea..530b77fef14 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -6337,8 +6337,10 @@ export class PostDamageForceSwitchAbAttr extends PostDamageAbAttr { /** * Applies the switch-out logic after the Pokémon takes damage. */ - public override apply({ pokemon }: PostDamageAbAttrParams): void { - // TODO: Consider respecting the `simulated` flag here + public override apply({ pokemon, simulated }: PostDamageAbAttrParams): void { + if (simulated) { + return; + } this.helper.doSwitch(pokemon); } } diff --git a/src/data/helpers/force-switch.ts b/src/data/helpers/force-switch.ts index bfd70ce0757..bfcc86afd7d 100644 --- a/src/data/helpers/force-switch.ts +++ b/src/data/helpers/force-switch.ts @@ -12,6 +12,7 @@ import i18next from "i18next"; export interface ForceSwitchOutHelperArgs { /** * Whether to switch out the user (`true`) or target (`false`). + * If `true`, will ignore certain effects that would otherwise block forced switches. * @defaultValue `false` */ selfSwitch?: boolean; @@ -21,7 +22,7 @@ export interface ForceSwitchOutHelperArgs { */ switchType?: NormalSwitchType; /** - * Whether to allow non-boss wild Pokemon to flee when using the move. + * Whether to allow non-boss wild Pokemon to flee from this effect's activation. * @defaultValue `false` */ allowFlee?: boolean; @@ -47,8 +48,6 @@ export class ForceSwitchOutHelper implements ForceSwitchOutHelperArgs { * @returns Whether {@linkcode switchOutTarget} can be switched out by the current effect. */ public canSwitchOut(switchOutTarget: Pokemon): boolean { - const isPlayer = switchOutTarget.isPlayer(); - if (switchOutTarget.isFainted()) { // Fainted Pokemon cannot be switched out by any means. // This is already checked in `MoveEffectAttr.canApply`, but better safe than sorry @@ -60,8 +59,9 @@ export class ForceSwitchOutHelper implements ForceSwitchOutHelperArgs { return false; } - // Wild enemies should not be allowed to flee with fleeing moves, nor by any means on X0 waves (don't want easy boss wins) + // Wild enemies should not be allowed to flee with ineligible fleeing moves, nor by any means on X0 waves (don't want easy boss wins) // TODO: Do we want to show a message for wave X0 failures? + const isPlayer = switchOutTarget.isPlayer(); if (!isPlayer && globalScene.currentBattle.battleType === BattleType.WILD) { return this.allowFlee && globalScene.currentBattle.waveIndex % 10 !== 0; } @@ -126,7 +126,7 @@ export class ForceSwitchOutHelper implements ForceSwitchOutHelperArgs { /** * Method to handle switching out a player Pokemon. - * @param switchOutTarget - The {@linkcode PlayerPokemon} to be switched out. + * @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. @@ -160,7 +160,7 @@ export class ForceSwitchOutHelper implements ForceSwitchOutHelperArgs { /** * Method to handle switching out an opposing trainer's Pokemon. - * @param switchOutTarget - The {@linkcode EnemyPokemon} to be switched out. + * @param switchOutTarget - The {@linkcode EnemyPokemon} to be switched out */ private trySwitchTrainerPokemon(switchOutTarget: EnemyPokemon): void { // fallback for no trainer @@ -189,7 +189,7 @@ export class ForceSwitchOutHelper implements ForceSwitchOutHelperArgs { /** * Method to handle fleeing a wild enemy Pokemon, redirecting incoming moves to its ally as applicable. - * @param switchOutTarget - The {@linkcode EnemyPokemon} fleeing the battle. + * @param switchOutTarget - The {@linkcode EnemyPokemon} fleeing the battle */ private tryFleeWildPokemon(switchOutTarget: EnemyPokemon): void { switchOutTarget.leaveField(true); diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index e5659082176..1a179c1cb51 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -6301,7 +6301,7 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { private readonly helper: ForceSwitchOutHelper; constructor(args: ForceSwitchOutHelperArgs) { - super(false, { lastHitOnly: true }); // procy to + super(false, { lastHitOnly: true }); this.helper = new ForceSwitchOutHelper(args); } @@ -6344,7 +6344,7 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { // upon an unsuccessful switch - they still succeed and perform secondary effects // (just without actually switching out). // TODO: Remove attr check once move attribute application is cleaned up - return (user, target, move) => (move.category !== MoveCategory.STATUS || move.attrs.length > 1 || this.canApply(user, target)); + return (user, target, move) => (move.category !== MoveCategory.STATUS || move.attrs.length > 1 || this.canApply(user, target, move, [])); } getFailedText(_user: Pokemon, target: Pokemon): string | undefined { diff --git a/src/phases/switch-summon-phase.ts b/src/phases/switch-summon-phase.ts index c6ef4e8ca0e..1a146bb92d8 100644 --- a/src/phases/switch-summon-phase.ts +++ b/src/phases/switch-summon-phase.ts @@ -36,7 +36,7 @@ export class SwitchSummonPhase extends SummonPhase { // -1 = "use trainer switch logic" this.slotIndex = slotIndex > -1 - ? this.slotIndex + ? slotIndex : globalScene.currentBattle.trainer!.getNextSummonIndex(this.getTrainerSlotFromFieldIndex()); this.doReturn = doReturn; } @@ -65,7 +65,7 @@ export class SwitchSummonPhase extends SummonPhase { // 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); + switchOutPokemon.leaveField(false, switchOutPokemon.getBattleInfo().visible); } if (this.player) { @@ -114,7 +114,7 @@ export class SwitchSummonPhase extends SummonPhase { scale: 0.5, onComplete: () => { globalScene.time.delayedCall(750, () => this.switchAndSummon()); - switchOutPokemon.leaveField(this.switchType === SwitchType.SWITCH, false); // TODO: do we have to do this right here right now + switchOutPokemon.leaveField(this.switchType === SwitchType.SWITCH, false); // TODO: this reset effects call is dubious }, }); } @@ -227,16 +227,14 @@ export class SwitchSummonPhase extends SummonPhase { // Baton Pass over any eligible effects or substitutes before resetting the last pokemon's temporary data. if (this.switchType === SwitchType.BATON_PASS) { activePokemon.transferSummon(this.lastPokemon); - this.lastPokemon.resetTurnData(); - this.lastPokemon.resetSummonData(); } else if (this.switchType === SwitchType.SHED_TAIL) { const subTag = this.lastPokemon.getTag(SubstituteTag); if (subTag) { activePokemon.summonData.tags.push(subTag); } - this.lastPokemon.resetTurnData(); - this.lastPokemon.resetSummonData(); } + this.lastPokemon.resetTurnData(); + this.lastPokemon.resetSummonData(); globalScene.triggerPokemonFormChange(activePokemon, SpeciesFormChangeActiveTrigger, true); // Reverts to weather-based forms when weather suppressors (Cloud Nine/Air Lock) are switched out From 1da7637ab359c5b5d228102c503ba3e44ae4484c Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Sun, 3 Aug 2025 19:31:56 -0400 Subject: [PATCH 3/5] Reverted unneeded test file changes --- test/abilities/disguise.test.ts | 8 +- test/items/reviver-seed.test.ts | 199 +++++++++++++------------------- 2 files changed, 86 insertions(+), 121 deletions(-) diff --git a/test/abilities/disguise.test.ts b/test/abilities/disguise.test.ts index 21ab3c41c25..bf271c81e4d 100644 --- a/test/abilities/disguise.test.ts +++ b/test/abilities/disguise.test.ts @@ -47,7 +47,7 @@ describe("Abilities - Disguise", () => { await game.phaseInterceptor.to("MoveEndPhase"); - expect(mimikyu.hp).toBe(maxHp - disguiseDamage); + expect(mimikyu.hp).equals(maxHp - disguiseDamage); expect(mimikyu.formIndex).toBe(bustedForm); }); @@ -79,12 +79,12 @@ describe("Abilities - Disguise", () => { // First hit await game.phaseInterceptor.to("MoveEffectPhase"); - expect(mimikyu.hp).toBe(maxHp - disguiseDamage); + expect(mimikyu.hp).equals(maxHp - disguiseDamage); expect(mimikyu.formIndex).toBe(disguisedForm); // Second hit await game.phaseInterceptor.to("MoveEffectPhase"); - expect(mimikyu.hp).toBeLessThan(maxHp - disguiseDamage); + expect(mimikyu.hp).lessThan(maxHp - disguiseDamage); expect(mimikyu.formIndex).toBe(bustedForm); }); @@ -118,7 +118,7 @@ describe("Abilities - Disguise", () => { await game.phaseInterceptor.to("TurnEndPhase"); expect(mimikyu.formIndex).toBe(bustedForm); - expect(mimikyu.hp).toBe(maxHp - disguiseDamage); + expect(mimikyu.hp).equals(maxHp - disguiseDamage); await game.toNextTurn(); game.doSwitchPokemon(1); diff --git a/test/items/reviver-seed.test.ts b/test/items/reviver-seed.test.ts index e92e97e6971..268c5497899 100644 --- a/test/items/reviver-seed.test.ts +++ b/test/items/reviver-seed.test.ts @@ -1,10 +1,10 @@ +import { allMoves } from "#data/data-lists"; import { AbilityId } from "#enums/ability-id"; import { BattlerIndex } from "#enums/battler-index"; -import { HitResult } from "#enums/hit-result"; +import { BattlerTagType } from "#enums/battler-tag-type"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; -import { Stat } from "#enums/stat"; -import { PokemonInstantReviveModifier } from "#modifiers/modifier"; +import type { PokemonInstantReviveModifier } from "#modifiers/modifier"; import { GameManager } from "#test/test-utils/game-manager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -26,146 +26,112 @@ describe("Items - Reviver Seed", () => { beforeEach(() => { game = new GameManager(phaserGame); game.override + .moveset([MoveId.SPLASH, MoveId.TACKLE, MoveId.ENDURE]) .ability(AbilityId.BALL_FETCH) .battleStyle("single") .criticalHits(false) .enemySpecies(SpeciesId.MAGIKARP) - .enemyAbility(AbilityId.NO_GUARD) + .enemyAbility(AbilityId.BALL_FETCH) .startingHeldItems([{ name: "REVIVER_SEED" }]) .enemyHeldItems([{ name: "REVIVER_SEED" }]) - .enemyMoveset(MoveId.SPLASH) - .startingLevel(100) - .enemyLevel(100); // makes hp tests more accurate due to rounding - }); - - it("should be consumed upon fainting to revive the holder, removing temporary effects and healing to 50% max HP", async () => { - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const enemy = game.field.getEnemyPokemon(); - enemy.hp = 1; - enemy.setStatStage(Stat.ATK, 6); - - expect(enemy.getHeldItems()[0]).toBeInstanceOf(PokemonInstantReviveModifier); - game.move.use(MoveId.TACKLE); - await game.toEndOfTurn(); - - // Enemy ate seed, was revived and healed to half HP, clearing its attack boost at the same time. - expect(enemy).not.toHaveFainted(); - expect(enemy.getHpRatio()).toBeCloseTo(0.5); - expect(enemy.getHeldItems()[0]).toBeUndefined(); - expect(enemy).toHaveStatStage(Stat.ATK, 0); - expect(enemy.turnData.acted).toBe(true); - }); - - it("should nullify move effects on the killing blow and interrupt multi hits", async () => { - // Give player a 4 hit lumina crash that lowers spdef by 2 stages per hit - game.override.ability(AbilityId.PARENTAL_BOND).startingHeldItems([ - { name: "REVIVER_SEED", count: 1 }, - { name: "MULTI_LENS", count: 2 }, - ]); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - // give enemy 3 hp, dying 3 hits into the move - const enemy = game.field.getEnemyPokemon(); - enemy.hp = 3; - vi.spyOn(enemy, "getAttackDamage").mockReturnValue({ cancelled: false, damage: 1, result: HitResult.EFFECTIVE }); - - game.move.use(MoveId.LUMINA_CRASH); - await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); - await game.phaseInterceptor.to("FaintPhase", false); - - // killing hit effect got nullified due to fainting the target - expect(enemy).toHaveStatStage(Stat.SPDEF, 4); - expect(enemy.getAttackDamage).toHaveBeenCalledTimes(3); - - await game.toEndOfTurn(); - - // Attack was cut short due to lack of targets, after which the enemy was revived and their stat stages reset - expect(enemy).not.toHaveFainted(); - expect(enemy).toHaveStatStage(Stat.SPDEF, 0); - expect(enemy.getHpRatio()).toBeCloseTo(0.5); - expect(enemy.getHeldItems()[0]).toBeUndefined(); - - const player = game.field.getPlayerPokemon(); - expect(player.turnData.hitsLeft).toBe(1); + .enemyMoveset(MoveId.SPLASH); + vi.spyOn(allMoves[MoveId.SHEER_COLD], "accuracy", "get").mockReturnValue(100); + vi.spyOn(allMoves[MoveId.LEECH_SEED], "accuracy", "get").mockReturnValue(100); + vi.spyOn(allMoves[MoveId.WHIRLPOOL], "accuracy", "get").mockReturnValue(100); + vi.spyOn(allMoves[MoveId.WILL_O_WISP], "accuracy", "get").mockReturnValue(100); }); it.each([ - { moveType: "Physical Moves", move: MoveId.TACKLE }, - { moveType: "Special Moves", move: MoveId.WATER_GUN }, - { moveType: "Fixed Damage Moves", move: MoveId.SEISMIC_TOSS }, + { moveType: "Special Move", move: MoveId.WATER_GUN }, + { moveType: "Physical Move", move: MoveId.TACKLE }, + { moveType: "Fixed Damage Move", move: MoveId.SEISMIC_TOSS }, { moveType: "Final Gambit", move: MoveId.FINAL_GAMBIT }, - { moveType: "Counter Moves", move: MoveId.COUNTER }, - { moveType: "OHKOs", move: MoveId.SHEER_COLD }, - { moveType: "Confusion Self-hits", move: MoveId.CONFUSE_RAY }, - ])("should activate from $moveType", async ({ move }) => { - game.override.confusionActivation(true); + { moveType: "Counter", move: MoveId.COUNTER }, + { moveType: "OHKO", move: MoveId.SHEER_COLD }, + ])("should activate the holder's reviver seed from a $moveType", async ({ move }) => { + game.override.enemyLevel(100).startingLevel(1).enemyMoveset(move); await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS]); - - const player = game.field.getPlayerPokemon(); - player.hp = 1; + const player = game.scene.getPlayerPokemon()!; + player.damageAndUpdate(player.hp - 1); const reviverSeed = player.getHeldItems()[0] as PokemonInstantReviveModifier; - const seedSpy = vi.spyOn(reviverSeed, "apply"); + vi.spyOn(reviverSeed, "apply"); - game.move.use(MoveId.TACKLE); - await game.move.forceEnemyMove(move); - await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); - await game.toEndOfTurn(); + game.move.select(MoveId.TACKLE); + await game.phaseInterceptor.to("BerryPhase"); - expect(player).not.toHaveFainted(); - expect(seedSpy).toHaveBeenCalled(); + expect(player.isFainted()).toBeFalsy(); }); - // Damaging tests - it.each([ - { moveType: "Salt Cure", move: MoveId.SALT_CURE }, - { moveType: "Leech Seed", move: MoveId.LEECH_SEED }, - { moveType: "Partial Trapping Move", move: MoveId.WHIRLPOOL }, - { moveType: "Status Effect", move: MoveId.WILL_O_WISP }, - { moveType: "Weather", move: MoveId.SANDSTORM }, - ])("should not activate from $moveType damage", async ({ move }) => { - game.override.enemyMoveset(MoveId.ENDURE); + it("should activate the holder's reviver seed from confusion self-hit", async () => { + game.override.enemyLevel(1).startingLevel(100).enemyMoveset(MoveId.SPLASH); await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS]); - const enemy = game.field.getEnemyPokemon(); - enemy.hp = 2; - // Mock any direct attacks to deal 1 damage (ensuring Whirlpool/etc do not kill) - vi.spyOn(enemy, "getAttackDamage").mockReturnValueOnce({ - cancelled: false, - damage: 1, - result: HitResult.EFFECTIVE, - }); + const player = game.scene.getPlayerPokemon()!; + player.damageAndUpdate(player.hp - 1); + player.addTag(BattlerTagType.CONFUSED, 3); - game.move.use(move); - await game.toEndOfTurn(); + const reviverSeed = player.getHeldItems()[0] as PokemonInstantReviveModifier; + vi.spyOn(reviverSeed, "apply"); - expect(enemy).toHaveFainted(); + vi.spyOn(player, "randBattleSeedInt").mockReturnValue(0); // Force confusion self-hit + game.move.select(MoveId.TACKLE); + await game.phaseInterceptor.to("BerryPhase"); + + expect(player.isFainted()).toBeFalsy(); + }); + + // Damaging opponents tests + it.each([ + { moveType: "Damaging Move Chip Damage", move: MoveId.SALT_CURE }, + { moveType: "Chip Damage", move: MoveId.LEECH_SEED }, + { moveType: "Trapping Chip Damage", move: MoveId.WHIRLPOOL }, + { moveType: "Status Effect Damage", move: MoveId.WILL_O_WISP }, + { moveType: "Weather", move: MoveId.SANDSTORM }, + ])("should not activate the holder's reviver seed from $moveType", async ({ move }) => { + game.override + .enemyLevel(1) + .startingLevel(100) + .enemySpecies(SpeciesId.MAGIKARP) + .moveset(move) + .enemyMoveset(MoveId.ENDURE); + await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS]); + const enemy = game.scene.getEnemyPokemon()!; + enemy.damageAndUpdate(enemy.hp - 1); + + game.move.select(move); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(enemy.isFainted()).toBeTruthy(); }); // Self-damage tests it.each([ { moveType: "Recoil", move: MoveId.DOUBLE_EDGE }, - { moveType: "Sacrificial", move: MoveId.EXPLOSION }, - { moveType: "Ghost-type Curse", move: MoveId.CURSE }, + { moveType: "Self-KO", move: MoveId.EXPLOSION }, + { moveType: "Self-Deduction", move: MoveId.CURSE }, { moveType: "Liquid Ooze", move: MoveId.GIGA_DRAIN }, - ])("should not activate from $moveType self-damage", async ({ move }) => { - game.override.enemyAbility(AbilityId.LIQUID_OOZE); + ])("should not activate the holder's reviver seed from $moveType", async ({ move }) => { + game.override + .enemyLevel(100) + .startingLevel(1) + .enemySpecies(SpeciesId.MAGIKARP) + .moveset(move) + .enemyAbility(AbilityId.LIQUID_OOZE) + .enemyMoveset(MoveId.SPLASH); await game.classicMode.startBattle([SpeciesId.GASTLY, SpeciesId.FEEBAS]); - - const player = game.field.getPlayerPokemon(); - player.hp = 1; + const player = game.scene.getPlayerPokemon()!; + player.damageAndUpdate(player.hp - 1); const playerSeed = player.getHeldItems()[0] as PokemonInstantReviveModifier; - const seedSpy = vi.spyOn(playerSeed, "apply"); + vi.spyOn(playerSeed, "apply"); - game.move.use(move); - await game.toEndOfTurn(); + game.move.select(move); + await game.phaseInterceptor.to("TurnEndPhase"); - expect(player).toHaveFainted(); - expect(seedSpy).not.toHaveBeenCalled(); + expect(player.isFainted()).toBeTruthy(); }); - it("should not activate from Destiny Bond fainting", async () => { + it("should not activate the holder's reviver seed from Destiny Bond fainting", async () => { game.override .enemyLevel(100) .startingLevel(1) @@ -174,15 +140,14 @@ describe("Items - Reviver Seed", () => { .startingHeldItems([]) // reset held items to nothing so user doesn't revive and not trigger Destiny Bond .enemyMoveset(MoveId.TACKLE); await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS]); + const player = game.scene.getPlayerPokemon()!; + player.damageAndUpdate(player.hp - 1); + const enemy = game.scene.getEnemyPokemon()!; - const player = game.field.getPlayerPokemon(); - player.hp = 1; - const enemy = game.field.getEnemyPokemon(); - - game.move.use(MoveId.DESTINY_BOND); + game.move.select(MoveId.DESTINY_BOND); await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); - await game.toEndOfTurn(); + await game.phaseInterceptor.to("TurnEndPhase"); - expect(enemy).toHaveFainted(); + expect(enemy.isFainted()).toBeTruthy(); }); }); From a22a6b89b957c9b60f1e7e61449a64f2a090ebf7 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Sun, 3 Aug 2025 19:43:45 -0400 Subject: [PATCH 4/5] fixed tests methinks + removed whirlwind test file --- test/abilities/wimp-out.test.ts | 4 +- test/moves/force-switch.test.ts | 93 ++++++------ test/moves/whirlwind.test.ts | 252 -------------------------------- 3 files changed, 48 insertions(+), 301 deletions(-) delete mode 100644 test/moves/whirlwind.test.ts diff --git a/test/abilities/wimp-out.test.ts b/test/abilities/wimp-out.test.ts index b108b4a1dff..bea05ee1998 100644 --- a/test/abilities/wimp-out.test.ts +++ b/test/abilities/wimp-out.test.ts @@ -59,7 +59,7 @@ describe("Abilities - Wimp Out/Emergency Exit", () => { expect(pokemon2.species.speciesId).not.toBe(SpeciesId.WIMPOD); expect(pokemon1.species.speciesId).toBe(SpeciesId.WIMPOD); - expect(pokemon1).toBeFainted(); + expect(pokemon1).toHaveFainted(); expect(pokemon1.getHpRatio()).toBeLessThan(0.5); } @@ -79,7 +79,7 @@ describe("Abilities - Wimp Out/Emergency Exit", () => { // Wimpod switched out after taking a hit, canceling its upcoming MoveEffectPhase before it could attack confirmSwitch(); - expect(game.field.getEnemyPokemon().).toHaveFullHp(); + expect(game.field.getEnemyPokemon()).toHaveFullHp(); expect(game.phaseInterceptor.log.filter(phase => phase === "MoveEffectPhase")).toHaveLength(1); }); diff --git a/test/moves/force-switch.test.ts b/test/moves/force-switch.test.ts index 31fc82592c7..05445861d0a 100644 --- a/test/moves/force-switch.test.ts +++ b/test/moves/force-switch.test.ts @@ -14,7 +14,7 @@ import { SpeciesId } from "#enums/species-id"; import { Stat } from "#enums/stat"; import { TrainerSlot } from "#enums/trainer-slot"; import { TrainerType } from "#enums/trainer-type"; -import { GameManager } from "#test/testUtils/gameManager"; +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"; @@ -46,6 +46,29 @@ describe("Moves - Switching Moves", () => { }); describe("Force Switch Moves", () => { + it.each<{ name: string; move: MoveId }>([ + { name: "Whirlwind", move: MoveId.WHIRLWIND }, + { name: "Roar", move: MoveId.ROAR }, + { name: "Dragon Tail", move: MoveId.DRAGON_TAIL }, + { name: "Circle Throw", move: MoveId.CIRCLE_THROW }, + ])("$name should switch the target out and display custom text", async ({ move }) => { + game.override.battleType(BattleType.TRAINER); + await game.classicMode.startBattle([SpeciesId.BLISSEY, SpeciesId.BULBASAUR]); + + const enemy = game.field.getEnemyPokemon(); + game.move.use(move); + await game.toNextTurn(); + + const newEnemy = game.field.getEnemyPokemon(); + expect(newEnemy).not.toBe(enemy); + expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); + expect(game.textInterceptor.logs).toContain( + i18next.t("battle:pokemonDraggedOut", { + pokemonName: getPokemonNameWithAffix(newEnemy), + }), + ); + }); + it("should force switches to a random off-field pokemon", async () => { await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.CHARMANDER, SpeciesId.SQUIRTLE]); @@ -62,7 +85,7 @@ describe("Moves - Switching Moves", () => { expect(bulbasaur.isOnField()).toBe(false); expect(charmander.isOnField()).toBe(true); expect(squirtle.isOnField()).toBe(false); - expect(bulbasaur.getInverseHp()).toBeGreaterThan(0); + expect(bulbasaur).not.toHaveFullHp(); // Turn 2: Mock an RNG call that calls for switching to 2nd backup Pokemon (Squirtle) vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => { @@ -74,7 +97,7 @@ describe("Moves - Switching Moves", () => { expect(bulbasaur.isOnField()).toBe(false); expect(charmander.isOnField()).toBe(false); expect(squirtle.isOnField()).toBe(true); - expect(charmander.getInverseHp()).toBeGreaterThan(0); + expect(charmander).not.toHaveFullHp(); }); it("should force trainers to switch randomly without selecting from a partner's party", async () => { @@ -131,7 +154,7 @@ describe("Moves - Switching Moves", () => { expect(enemyLeadPokemon.visible).toBe(false); expect(enemyLeadPokemon.switchOutStatus).toBe(true); - expect(enemySecPokemon.hp).toBeLessThan(enemySecPokemon.getMaxHp()); + expect(enemySecPokemon).not.toHaveFullHp(); }); it("should not switch out a target with suction cups, unless the user has Mold Breaker", async () => { @@ -144,7 +167,7 @@ describe("Moves - Switching Moves", () => { await game.toEndOfTurn(); expect(enemy.isOnField()).toBe(true); - expect(enemy.isFullHp()).toBe(false); + expect(enemy).not.toHaveFullHp(); // Turn 2: Mold Breaker should ignore switch blocking ability and switch out the target game.field.mockAbility(game.field.getPlayerPokemon(), AbilityId.MOLD_BREAKER); @@ -154,7 +177,7 @@ describe("Moves - Switching Moves", () => { await game.toEndOfTurn(); expect(enemy.isOnField()).toBe(false); - expect(enemy.isFullHp()).toBe(false); + expect(enemy).not.toHaveFullHp(); }); it("should not switch out a Commanded Dondozo", async () => { @@ -169,7 +192,7 @@ describe("Moves - Switching Moves", () => { await game.toEndOfTurn(); expect(dondozo1.isOnField()).toBe(true); - expect(dondozo1.isFullHp()).toBe(false); + expect(dondozo1).not.toHaveFullHp(); }); it("should perform a normal switch upon fainting an opponent", async () => { @@ -184,7 +207,7 @@ describe("Moves - Switching Moves", () => { const enemy = game.field.getEnemyPokemon(); expect(enemy).toBeDefined(); - expect(enemy.isFullHp()).toBe(true); + expect(enemy).toHaveFullHp(); expect(choiceSwitchSpy).toHaveBeenCalledTimes(1); }); @@ -249,31 +272,7 @@ describe("Moves - Switching Moves", () => { expect(eevee.isOnField()).toBe(false); expect(toxapex.isOnField()).toBe(false); expect(primarina.isOnField()).toBe(true); - expect(lapras.getInverseHp()).toBeGreaterThan(0); - }); - - it.each<{ name: string; move: MoveId }>([ - { name: "Whirlwind", move: MoveId.WHIRLWIND }, - { name: "Roar", move: MoveId.ROAR }, - { name: "Dragon Tail", move: MoveId.DRAGON_TAIL }, - { name: "Circle Throw", move: MoveId.CIRCLE_THROW }, - ])("should display custom text for forced switch outs", async ({ move }) => { - game.override.battleType(BattleType.TRAINER); - await game.classicMode.startBattle([SpeciesId.BLISSEY, SpeciesId.BULBASAUR]); - - const enemy = game.field.getEnemyPokemon(); - game.move.use(move); - await game.toNextTurn(); - - const newEnemy = game.field.getEnemyPokemon(); - expect(newEnemy).not.toBe(enemy); - expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); - // TODO: Replace this with the locale key in question - expect(game.textInterceptor.logs).toContain( - i18next.t("battle:pokemonDraggedOut", { - pokemonName: getPokemonNameWithAffix(newEnemy), - }), - ); + expect(lapras).not.toHaveFullHp(); }); }); @@ -292,7 +291,7 @@ describe("Moves - Switching Moves", () => { expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); const player = game.field.getPlayerPokemon(); expect(player).toBe(raichu); - expect(player.isFullHp()).toBe(false); + expect(player).not.toHaveFullHp(); expect(game.field.getEnemyPokemon().waveData.abilityRevealed).toBe(true); // proxy for asserting ability activated }); }); @@ -305,20 +304,20 @@ describe("Moves - Switching Moves", () => { await game.toNextTurn(); const [raichu, shuckle] = game.scene.getPlayerParty(); - expect(raichu.getStatStage(Stat.SPATK)).toEqual(2); + expect(raichu).toHaveStatStage(Stat.SPATK, 2); game.move.use(MoveId.SUBSTITUTE); await game.toNextTurn(); - expect(raichu.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined(); + expect(raichu).toHaveBattlerTag(BattlerTagType.SUBSTITUTE); game.move.use(MoveId.BATON_PASS); game.doSelectPartyPokemon(1); await game.toEndOfTurn(); expect(game.field.getPlayerPokemon()).toBe(shuckle); - expect(shuckle.getStatStage(Stat.SPATK)).toEqual(2); - expect(shuckle.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined(); + expect(shuckle).toHaveStatStage(Stat.SPATK, 2); + expect(shuckle).toHaveBattlerTag(BattlerTagType.SUBSTITUTE); }); it("should not transfer non-transferrable effects", async () => { @@ -329,16 +328,16 @@ describe("Moves - Switching Moves", () => { game.move.use(MoveId.BATON_PASS); await game.move.forceEnemyMove(MoveId.SALT_CURE); await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); - await game.phaseInterceptor.to("MoveEndPhase"); - expect(player1.getTag(BattlerTagType.SALT_CURED)).toBeDefined(); + + expect(player1).toHaveBattlerTag(BattlerTagType.SALT_CURED); game.doSelectPartyPokemon(1); await game.toNextTurn(); expect(player1.isOnField()).toBe(false); expect(player2.isOnField()).toBe(true); - expect(player2.getTag(BattlerTagType.SALT_CURED)).toBeUndefined(); + expect(player2).not.toHaveBattlerTag(BattlerTagType.SALT_CURED); }); it("should remove the user's binding effects on end", async () => { @@ -349,13 +348,13 @@ describe("Moves - Switching Moves", () => { await game.toNextTurn(); const enemy = game.field.getEnemyPokemon(); - expect(enemy.getTag(BattlerTagType.FIRE_SPIN)).toBeDefined(); + expect(enemy).toHaveBattlerTag(BattlerTagType.FIRE_SPIN); game.move.use(MoveId.BATON_PASS); game.doSelectPartyPokemon(1); await game.toNextTurn(); - expect(enemy.getTag(BattlerTagType.FIRE_SPIN)).toBeUndefined(); + expect(enemy).not.toHaveBattlerTag(BattlerTagType.FIRE_SPIN); }); }); @@ -376,7 +375,7 @@ describe("Moves - Switching Moves", () => { const substituteTag = feebas.getTag(SubstituteTag)!; expect(substituteTag).toBeDefined(); - expect(magikarp.getInverseHp()).toBe(Math.ceil(magikarp.getMaxHp() / 2)); + expect(magikarp).toHaveTakenDamage(Math.ceil(magikarp.getMaxHp() / 2)); expect(substituteTag.hp).toBe(Math.floor(magikarp.getMaxHp() / 4)); }); @@ -407,8 +406,8 @@ describe("Moves - Switching Moves", () => { await game.toEndOfTurn(); expect(magikarp.isOnField()).toBe(true); - expect(magikarp.getLastXMoves()[0].result).toBe(MoveResult.FAIL); - expect(magikarp.hp).toBe(initHp); + expect(magikarp).toHaveUsedMove({ move: MoveId.SHED_TAIL, result: MoveResult.FAIL }); + expect(magikarp).toHaveHp(initHp); }); }); @@ -459,7 +458,7 @@ describe("Moves - Switching Moves", () => { { name: "Flip Turn", move: MoveId.FLIP_TURN }, { name: "Volt Switch", move: MoveId.VOLT_SWITCH }, // TODO: Enable once Parting shot is fixed - { name: "Parting Shot", move: MoveId.PARTING_SHOT }, + // { name: "Parting Shot", move: MoveId.PARTING_SHOT }, { name: "Dragon Tail", enemyMove: MoveId.DRAGON_TAIL }, { name: "Circle Throw", enemyMove: MoveId.CIRCLE_THROW }, ])( diff --git a/test/moves/whirlwind.test.ts b/test/moves/whirlwind.test.ts deleted file mode 100644 index 2aadb76b019..00000000000 --- a/test/moves/whirlwind.test.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { globalScene } from "#app/global-scene"; -import { Status } from "#data/status-effect"; -import { AbilityId } from "#enums/ability-id"; -import { BattleType } from "#enums/battle-type"; -import { BattlerIndex } from "#enums/battler-index"; -import { BattlerTagType } from "#enums/battler-tag-type"; -import { Challenges } from "#enums/challenges"; -import { MoveId } from "#enums/move-id"; -import { MoveResult } from "#enums/move-result"; -import { PokemonType } from "#enums/pokemon-type"; -import { SpeciesId } from "#enums/species-id"; -import { StatusEffect } from "#enums/status-effect"; -import { TrainerType } from "#enums/trainer-type"; -import { GameManager } from "#test/test-utils/game-manager"; -import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; - -describe("Moves - Whirlwind", () => { - 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 - .battleStyle("single") - .moveset([MoveId.SPLASH]) - .enemyAbility(AbilityId.BALL_FETCH) - .enemyMoveset([MoveId.SPLASH, MoveId.WHIRLWIND]) - .enemySpecies(SpeciesId.PIDGEY); - }); - - it.each([ - { move: MoveId.FLY, name: "Fly" }, - { move: MoveId.BOUNCE, name: "Bounce" }, - { move: MoveId.SKY_DROP, name: "Sky Drop" }, - ])("should not hit a flying target: $name (=$move)", async ({ move }) => { - game.override.moveset([move]); - // Must have a pokemon in the back so that the move misses instead of fails. - await game.classicMode.startBattle([SpeciesId.STARAPTOR, SpeciesId.MAGIKARP]); - - const staraptor = game.scene.getPlayerPokemon()!; - - game.move.select(move); - await game.move.selectEnemyMove(MoveId.WHIRLWIND); - - await game.phaseInterceptor.to("BerryPhase", false); - - expect(staraptor.findTag(t => t.tagType === BattlerTagType.FLYING)).toBeDefined(); - expect(game.scene.getEnemyPokemon()!.getLastXMoves(1)[0].result).toBe(MoveResult.MISS); - }); - - it("should force switches randomly", async () => { - await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.CHARMANDER, SpeciesId.SQUIRTLE]); - - const [bulbasaur, charmander, squirtle] = game.scene.getPlayerParty(); - - // Turn 1: Mock an RNG call that calls for switching to 1st backup Pokemon (Charmander) - vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => { - return min; - }); - game.move.select(MoveId.SPLASH); - await game.move.selectEnemyMove(MoveId.WHIRLWIND); - await game.toNextTurn(); - - expect(bulbasaur.isOnField()).toBe(false); - expect(charmander.isOnField()).toBe(true); - expect(squirtle.isOnField()).toBe(false); - - // Turn 2: Mock an RNG call that calls for switching to 2nd backup Pokemon (Squirtle) - vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => { - return min + 1; - }); - game.move.select(MoveId.SPLASH); - await game.move.selectEnemyMove(MoveId.WHIRLWIND); - await game.toNextTurn(); - - expect(bulbasaur.isOnField()).toBe(false); - expect(charmander.isOnField()).toBe(false); - expect(squirtle.isOnField()).toBe(true); - }); - - it("should not force a switch to a challenge-ineligible Pokemon", async () => { - // Mono-Water challenge, Eevee is ineligible - game.challengeMode.addChallenge(Challenges.SINGLE_TYPE, PokemonType.WATER + 1, 0); - await game.challengeMode.startBattle([SpeciesId.LAPRAS, SpeciesId.EEVEE, SpeciesId.TOXAPEX, SpeciesId.PRIMARINA]); - - const [lapras, eevee, toxapex, primarina] = game.scene.getPlayerParty(); - - // Turn 1: Mock an RNG call that would normally call for switching to Eevee, but it is ineligible - vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => { - return min; - }); - game.move.select(MoveId.SPLASH); - await game.move.selectEnemyMove(MoveId.WHIRLWIND); - await game.toNextTurn(); - - expect(lapras.isOnField()).toBe(false); - expect(eevee.isOnField()).toBe(false); - expect(toxapex.isOnField()).toBe(true); - expect(primarina.isOnField()).toBe(false); - }); - - it("should not force a switch to a fainted Pokemon", async () => { - await game.classicMode.startBattle([SpeciesId.LAPRAS, SpeciesId.EEVEE, SpeciesId.TOXAPEX, SpeciesId.PRIMARINA]); - - const [lapras, eevee, toxapex, primarina] = game.scene.getPlayerParty(); - - // Turn 1: Eevee faints - eevee.hp = 0; - eevee.status = new Status(StatusEffect.FAINT); - expect(eevee.isFainted()).toBe(true); - game.move.select(MoveId.SPLASH); - await game.move.selectEnemyMove(MoveId.SPLASH); - await game.toNextTurn(); - - // Turn 2: Mock an RNG call that would normally call for switching to Eevee, but it is fainted - vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => { - return min; - }); - game.move.select(MoveId.SPLASH); - await game.move.selectEnemyMove(MoveId.WHIRLWIND); - await game.toNextTurn(); - - expect(lapras.isOnField()).toBe(false); - expect(eevee.isOnField()).toBe(false); - expect(toxapex.isOnField()).toBe(true); - expect(primarina.isOnField()).toBe(false); - }); - - it("should not force a switch if there are no available Pokemon to switch into", async () => { - await game.classicMode.startBattle([SpeciesId.LAPRAS, SpeciesId.EEVEE]); - - const [lapras, eevee] = game.scene.getPlayerParty(); - - // Turn 1: Eevee faints - eevee.hp = 0; - eevee.status = new Status(StatusEffect.FAINT); - expect(eevee.isFainted()).toBe(true); - game.move.select(MoveId.SPLASH); - await game.move.selectEnemyMove(MoveId.SPLASH); - await game.toNextTurn(); - - // Turn 2: Mock an RNG call that would normally call for switching to Eevee, but it is fainted - vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => { - return min; - }); - game.move.select(MoveId.SPLASH); - await game.move.selectEnemyMove(MoveId.WHIRLWIND); - await game.toNextTurn(); - - expect(lapras.isOnField()).toBe(true); - expect(eevee.isOnField()).toBe(false); - }); - - it("should fail when player uses Whirlwind against an opponent with only one available Pokémon", async () => { - // Set up the battle scenario with the player knowing Whirlwind - game.override.startingWave(5).enemySpecies(SpeciesId.PIDGEY).moveset([MoveId.WHIRLWIND]); - await game.classicMode.startBattle(); - - const enemyParty = game.scene.getEnemyParty(); - - // Ensure the opponent has only one available Pokémon - if (enemyParty.length > 1) { - enemyParty.slice(1).forEach(p => { - p.hp = 0; - p.status = new Status(StatusEffect.FAINT); - }); - } - const eligibleEnemy = enemyParty.filter(p => p.hp > 0 && p.isAllowedInBattle()); - expect(eligibleEnemy.length).toBe(1); - - // Spy on the queueMessage function - const queueSpy = vi.spyOn(globalScene.phaseManager, "queueMessage"); - - // Player uses Whirlwind; opponent uses Splash - game.move.select(MoveId.WHIRLWIND); - await game.move.selectEnemyMove(MoveId.SPLASH); - await game.toNextTurn(); - - // Verify that the failure message is displayed for Whirlwind - expect(queueSpy).toHaveBeenCalledWith(expect.stringContaining("But it failed")); - // Verify the opponent's Splash message - expect(queueSpy).toHaveBeenCalledWith(expect.stringContaining("But nothing happened!")); - }); - - it("should not pull in the other trainer's pokemon in a partner trainer battle", async () => { - game.override - .startingWave(2) - .battleType(BattleType.TRAINER) - .randomTrainer({ - trainerType: TrainerType.BREEDER, - alwaysDouble: true, - }) - .enemyMoveset([MoveId.SPLASH, MoveId.LUNAR_DANCE]) - .moveset([MoveId.WHIRLWIND, MoveId.SPLASH]); - await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.TOTODILE]); - - // expect the enemy to have at least 4 pokemon, necessary for this check to even work - expect(game.scene.getEnemyParty().length, "enemy must have exactly 4 pokemon").toBeGreaterThanOrEqual(4); - - const user = game.scene.getPlayerPokemon()!; - - console.log(user.getMoveset(false)); - - game.move.select(MoveId.SPLASH); - game.move.select(MoveId.SPLASH); - await game.move.selectEnemyMove(MoveId.MEMENTO); - await game.move.selectEnemyMove(MoveId.SPLASH); - await game.toNextTurn(); - - // Get the enemy pokemon id so we can check if is the same after switch. - const enemy_id = game.scene.getEnemyPokemon()!.id; - - // Hit the enemy that fainted with whirlwind. - game.move.select(MoveId.WHIRLWIND, 0, BattlerIndex.ENEMY); - game.move.select(MoveId.SPLASH, 1); - - await game.move.selectEnemyMove(MoveId.SPLASH); - await game.move.selectEnemyMove(MoveId.SPLASH); - - await game.toNextTurn(); - - // Expect the enemy pokemon to not have switched out. - expect(game.scene.getEnemyPokemon()!.id).toBe(enemy_id); - }); - - it("should force a wild pokemon to flee", async () => { - game.override - .battleType(BattleType.WILD) - .moveset([MoveId.WHIRLWIND, MoveId.SPLASH]) - .enemyMoveset(MoveId.SPLASH) - .ability(AbilityId.BALL_FETCH); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const user = game.scene.getPlayerPokemon()!; - - game.move.select(MoveId.WHIRLWIND); - await game.phaseInterceptor.to("BerryPhase"); - - expect(user.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS); - }); -}); From eed5f0c011639c182c1423ee85addf390e40046f Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Sun, 3 Aug 2025 19:47:28 -0400 Subject: [PATCH 5/5] reverted another test file --- test/abilities/arena-trap.test.ts | 98 +++++++++++++------------------ 1 file changed, 40 insertions(+), 58 deletions(-) diff --git a/test/abilities/arena-trap.test.ts b/test/abilities/arena-trap.test.ts index a8b0c463002..f85fae5b259 100644 --- a/test/abilities/arena-trap.test.ts +++ b/test/abilities/arena-trap.test.ts @@ -4,7 +4,7 @@ import { MoveId } from "#enums/move-id"; 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"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; describe("Abilities - Arena Trap", () => { let phaserGame: Phaser.Game; @@ -23,86 +23,68 @@ describe("Abilities - Arena Trap", () => { beforeEach(() => { game = new GameManager(phaserGame); game.override + .moveset(MoveId.SPLASH) .ability(AbilityId.ARENA_TRAP) .enemySpecies(SpeciesId.RALTS) .enemyAbility(AbilityId.BALL_FETCH) - .enemyMoveset(MoveId.SPLASH); + .enemyMoveset(MoveId.TELEPORT); }); - // 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 () => { + // TODO: Enable test when Issue #935 is addressed + it.todo("should not allow grounded Pokémon to flee", async () => { game.override.battleStyle("single"); - await game.classicMode.startBattle([SpeciesId.DUGTRIO, SpeciesId.GOTHITELLE]); - const enemy = game.field.getEnemyPokemon(); + await game.classicMode.startBattle(); - // flee stuff goes here + 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(); - - // back out and cancel the flee to avoid timeout - (game.scene.ui.getHandler() as CommandUiHandler).processInput(Button.CANCEL); - game.move.use(MoveId.SPLASH); - }); + game.move.select(MoveId.SPLASH); await game.toNextTurn(); - expect(game.textInterceptor.logs).toContain( - i18next.t("abilityTriggers:arenaTrap", { - pokemonNameWithAffix: getPokemonNameWithAffix(enemy), - abilityName: allAbilities[AbilityId.ARENA_TRAP].name, - }), - ); - }); - it("should interrupt player switch attempt and display message", async () => { - game.override.battleStyle("single").enemyAbility(AbilityId.ARENA_TRAP); - await game.classicMode.startBattle([SpeciesId.DUGTRIO, SpeciesId.GOTHITELLE]); - - const enemy = game.field.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.use(MoveId.SPLASH); - }); - - await game.toNextTurn(); - expect(game.textInterceptor.logs).toContain( - i18next.t("abilityTriggers:arenaTrap", { - pokemonNameWithAffix: getPokemonNameWithAffix(enemy), - abilityName: allAbilities[AbilityId.ARENA_TRAP].name, - }), - ); + expect(enemy).toBe(game.scene.getEnemyPokemon()); }); it("should guarantee double battle with any one LURE", async () => { game.override.startingModifier([{ name: "LURE" }]).startingWave(2); - await game.classicMode.startBattle([SpeciesId.DUGTRIO]); - expect(game.scene.getEnemyField()).toHaveLength(2); + await game.classicMode.startBattle(); + + expect(game.scene.getEnemyField().length).toBe(2); }); + /** + * This checks if the Player Pokemon is able to switch out/run away after the Enemy Pokemon with {@linkcode AbilityId.ARENA_TRAP} + * is forcefully moved out of the field from moves such as Roar {@linkcode MoveId.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("single"); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + game.override + .battleStyle("double") + .enemyMoveset(MoveId.SPLASH) + .moveset([MoveId.ROAR, MoveId.SPLASH]) + .ability(AbilityId.BALL_FETCH); + await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.SUDOWOODO, SpeciesId.LUNATONE]); - const player = game.field.getPlayerPokemon(); - const enemy = game.field.getEnemyPokemon(); + const [enemy1, enemy2] = game.scene.getEnemyField(); + const [player1, player2] = game.scene.getPlayerField(); - expect(player.isTrapped()).toBe(true); - expect(enemy.isOnField()).toBe(true); + vi.spyOn(enemy1, "getAbility").mockReturnValue(allAbilities[AbilityId.ARENA_TRAP]); - game.move.use(MoveId.ROAR); - await game.toEndOfTurn(); + game.move.select(MoveId.ROAR); + game.move.select(MoveId.SPLASH, 1); - expect(player.isTrapped()).toBe(false); - expect(enemy.isOnField()).toBe(false); + // 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(MoveId.SPLASH); + game.move.select(MoveId.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); }); });