From ba24d63cc1a6766f1e4044bb39674a97ccc4dbe3 Mon Sep 17 00:00:00 2001 From: muscode13 Date: Mon, 21 Oct 2024 16:16:30 -0600 Subject: [PATCH] implement Wimp Out/Emergency Exit --- src/data/ability.ts | 697 +++++++++++++++++++++++++++- src/data/move.ts | 40 +- src/test/abilities/wimp_out.test.ts | 654 ++++++++++++++++++++++++++ 3 files changed, 1378 insertions(+), 13 deletions(-) create mode 100644 src/test/abilities/wimp_out.test.ts diff --git a/src/data/ability.ts b/src/data/ability.ts index 07fd48e2f91..bd45e78afce 100644 --- a/src/data/ability.ts +++ b/src/data/ability.ts @@ -1,15 +1,15 @@ -import Pokemon, { HitResult, PlayerPokemon, PokemonMove } from "../field/pokemon"; +import Pokemon, { EnemyPokemon, HitResult, PlayerPokemon, PokemonMove } from "../field/pokemon"; import { Type } from "./type"; import { Constructor } from "#app/utils"; import * as Utils from "../utils"; import { getPokemonNameWithAffix } from "../messages"; import { Weather, WeatherType } from "./weather"; -import { BattlerTag, GroundedTag } from "./battler-tags"; +import { BattlerTag, DamagingTrapTag, GroundedTag } from "./battler-tags"; import { StatusEffect, getNonVolatileStatusEffects, getStatusEffectDescriptor, getStatusEffectHealText } from "./status-effect"; import { Gender } from "./gender"; import Move, { AttackMove, MoveCategory, MoveFlags, MoveTarget, FlinchAttr, OneHitKOAttr, HitHealAttr, allMoves, StatusMove, SelfStatusMove, VariablePowerAttr, applyMoveAttrs, IncrementMovePriorityAttr, VariableMoveTypeAttr, RandomMovesetMoveAttr, RandomMoveAttr, NaturePowerAttr, CopyMoveAttr, MoveAttr, MultiHitAttr, ChargeAttr, SacrificialAttr, SacrificialAttrOnHit, NeutralDamageAgainstFlyingTypeMultiplierAttr, FixedDamageAttr } from "./move"; import { ArenaTagSide, ArenaTrapTag } from "./arena-tag"; -import { BerryModifier, PokemonHeldItemModifier } from "../modifier/modifier"; +import { BerryModifier, HitHealModifier, PokemonHeldItemModifier } from "../modifier/modifier"; import { TerrainType } from "./terrain"; import { SpeciesFormChangeManualTrigger, SpeciesFormChangeRevertWeatherFormTrigger, SpeciesFormChangeWeatherTrigger } from "./pokemon-forms"; import i18next from "i18next"; @@ -17,7 +17,7 @@ import { Localizable } from "#app/interfaces/locales"; import { Command } from "../ui/command-ui-handler"; import { BerryModifierType } from "#app/modifier/modifier-type"; import { getPokeballName } from "./pokeball"; -import { BattlerIndex } from "#app/battle"; +import { BattlerIndex, BattleType } from "#app/battle"; import { Abilities } from "#enums/abilities"; import { ArenaTagType } from "#enums/arena-tag-type"; import { BattlerTagType } from "#enums/battler-tag-type"; @@ -29,6 +29,14 @@ import { PokemonHealPhase } from "#app/phases/pokemon-heal-phase"; import { ShowAbilityPhase } from "#app/phases/show-ability-phase"; import { StatStageChangePhase } from "#app/phases/stat-stage-change-phase"; import BattleScene from "#app/battle-scene"; +import { SwitchType } from "#app/enums/switch-type"; +import { SwitchPhase } from "#app/phases/switch-phase"; +import { SwitchSummonPhase } from "#app/phases/switch-summon-phase"; +import { BattleEndPhase } from "#app/phases/battle-end-phase"; +import { NewBattlePhase } from "#app/phases/new-battle-phase"; +import { PostSummonPhase } from "#app/phases/post-summon-phase"; +import { WeatherEffectPhase } from "#app/phases/weather-effect-phase"; +import { TurnEndPhase } from "#app/phases/turn-end-phase"; export class Ability implements Localizable { public id: Abilities; @@ -3057,7 +3065,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, Wimp Out, Emergency Exit, Berserk, Anger Shell + * Disables: Color Change, Pickpocket, Berserk, Anger Shell * @returns {AbAttrCondition} If false disables the ability which the condition is applied to. */ function getSheerForceHitDisableAbCondition(): AbAttrCondition { @@ -3083,6 +3091,220 @@ function getSheerForceHitDisableAbCondition(): AbAttrCondition { }; } +/** + * Function to determine if the Pokémon's health has been knocked below half. + * + * This function checks different phases like {@linkcode PostSummonPhase}, {@linkcode WeatherEffectPhase}, + * {@linkcode TurnEndPhase}, and evaluates various conditions like Arena traps, status effects, passive damage sources, + * and healing items, to determine if the Pokémon's health has been knocked below half. + * + * Uses: + * - {@linkcode calculateTurnEndDamage} to calculate damage during the {@linkcode TurnEndPhase}. + * - {@linkcode calculateSleepDamage} to calculate additional damage while the Pokémon is sleeping. + * - {@linkcode calculateShellBellRecovery} to account for Shell Bell recovery. + * - {@linkcode checkSheerForceCondition} to verify if Sheer Force ability prevents effects of attacks. + * + * @returns {AbAttrCondition} A condition function that checks if the Pokémon's health has been knocked below half. + */ +function getHealthKnockedBelowHalf(): AbAttrCondition { + return (pokemon: Pokemon) => { + const maxPokemonHealth = pokemon.getMaxHp(); + const pokemonHealth = pokemon.hp; + const currentPhase = pokemon.scene.getCurrentPhase(); + + // Helper function to calculate initial health based on damage taken + const getInitialHealth = (damageTaken: number = 0) => pokemonHealth + damageTaken; + + // Helper function to check if health has dropped below half + const isHealthBelowHalf = (initialHealth: number, currentHealth: number) => + initialHealth > maxPokemonHealth / 2 && currentHealth < maxPokemonHealth / 2; + + // PostSummonPhase + /** + * In the {@linkcode PostSummonPhase}, the function checks for Arena traps on the + * Pokemon's side and calculates damage taken during the summon. + * If the initial health (current HP + damage taken) was above half but now is below half, + * it returns true. + */ + if (currentPhase instanceof PostSummonPhase) { + const side = pokemon.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; + if (pokemon.scene?.arena.findTagsOnSide(t => t instanceof ArenaTrapTag, side).length > 0) { + + const damageTaken = pokemon.turnData?.damageTaken; + const initialHealth = pokemonHealth + damageTaken; + + // Check if the initial health was above half + if (initialHealth <= maxPokemonHealth / 2) { + return false; + } else { + // Check if current health dropped below half + if (pokemonHealth < maxPokemonHealth / 2) { + return true; + } else { + return false; + } + } + } + } + + + // WeatherEffectPhase + /** + * In the {@linkcode WeatherEffectPhase}, this function automatically returns true. + * This is because certain weather effects (like Hail or Sandstorm) might reduce HP below half, + * and the specific weather effect calculations are handled {@linkcode PostWeatherForceSwitchOutAttr}. + */ + if (currentPhase instanceof WeatherEffectPhase) { + return true; + } + + // TurnEndPhase + /** + * During the {@linkcode TurnEndPhase}, the function calculates damage from various sources: + * - Poison, Toxic, Burn status effects + * - Leech Seed, Curse, Salt Cure, and traps like Whirlpool + * It uses {@linkcode calculateTurnEndDamage} for this purpose and checks if the Pokémon's health + * drops below half after the turn-end effects. + */ + if (currentPhase instanceof TurnEndPhase) { + const damageTaken = calculateTurnEndDamage(pokemon); + const initialHealth = getInitialHealth(damageTaken); + return isHealthBelowHalf(initialHealth, pokemonHealth); + } + + // TurnData checks + /** + * The function checks if the last move received was affected by Sheer Force. If so, + * Sheer Force prevents the activation of the ability. The function calls + * {@linkcode checkSheerForceCondition} to verify this condition. + */ + if (pokemon.turnData) { + const sheerForceCondition = checkSheerForceCondition(pokemon); + if (sheerForceCondition !== null) { + return sheerForceCondition; + } + + /** + * Additionally, the function calculates the health recovery from Shell Bell using + * {@linkcode calculateShellBellRecovery} and checks if the Pokemon's health dropped + * below half, irregardless of Shell Bell recovery + */ + const shellBellRecovery = calculateShellBellRecovery(pokemon); + const initialHealth = getInitialHealth(pokemon.turnData?.damageTaken ?? 0); + return isHealthBelowHalf(initialHealth, pokemonHealth - shellBellRecovery); + } + + return false; + }; +} + +/** + * Calculates the damage taken by the Pokémon during the {@linkcode TurnEndPhase}. + * + * This function considers different status effects like Poison, Toxic, Burn, and also + * additional damage from Leech Seed, Curse, Salt Cure, and traps like Whirlpool. + * + * @param {Pokemon} pokemon - The Pokémon whose damage is being calculated. + * @returns {number} The total damage taken during the turn end phase. + */ +function calculateTurnEndDamage(pokemon: Pokemon): number { + let damageTaken = 0; + const maxHp = pokemon.getMaxHp(); + + if (pokemon.status?.isPostTurn()) { + switch (pokemon.status.effect) { + case StatusEffect.POISON: + damageTaken += Math.max(maxHp >> 3, 1); // Poison damage + break; + case StatusEffect.TOXIC: + damageTaken += Math.max(Math.floor((maxHp / 16) * pokemon.status.turnCount), 1); // Toxic damage + break; + case StatusEffect.BURN: + damageTaken += Math.max(maxHp >> 4, 1); // Burn damage + break; + } + } + + // Additional damage sources + if (pokemon.status?.effect === StatusEffect.SLEEP) { + damageTaken += calculateSleepDamage(pokemon); + } + if (pokemon.getTag(BattlerTagType.SEEDED)) { + damageTaken += Utils.toDmgValue(maxHp / 8); // Seeded damage + } + if (pokemon.getTag(BattlerTagType.CURSED)) { + damageTaken += Utils.toDmgValue(maxHp / 4); // Cursed damage + } + if (pokemon.getTag(BattlerTagType.SALT_CURED)) { + const isSteelOrWater = pokemon.isOfType(Type.STEEL) || pokemon.isOfType(Type.WATER); + damageTaken += Utils.toDmgValue(isSteelOrWater ? maxHp / 4 : maxHp / 8); + } + if (pokemon.findTags(tag => tag instanceof DamagingTrapTag).length > 0) { + damageTaken += Utils.toDmgValue(maxHp / 8); // Trap damage + } + + return damageTaken; +} + +/** + * Calculates damage that occurs due to sleeping, if any opponents have abilities + * that hurt sleeping Pokémon during {@linkcode TurnEndPhase}. + * + * @param {Pokemon} pokemon - The Pokémon whose damage is being calculated. + * @returns {number} The additional damage caused by sleep-related abilities (Bad Dreams). + */ +function calculateSleepDamage(pokemon: Pokemon): number { + let damageTaken = 0; + for (const opponent of pokemon.getOpponents()) { + if (opponent.hasAbilityWithAttr(PostTurnHurtIfSleepingAbAttr)) { + damageTaken += Utils.toDmgValue(pokemon.getMaxHp() / 8); + } + } + return damageTaken; +} + +/** + * 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). This covers the case where if the pokemon's health falls + * below half and recovers back above half from a Shell Bell, + * Wimp Out will activate even after the Shell Bell recovery + * + * @param {Pokemon} pokemon - The Pokémon whose Shell Bell recovery is being calculated. + * @returns {number} 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 Utils.toDmgValue(pokemon.turnData.damageDealt / 8) * shellBellModifier.stackCount; + } + return 0; +} + +/** + * Checks whether Sheer Force prevents the effects of the last attack received by the Pokémon. + * + * If the last attack received by the Pokémon is affected by Sheer Force (meaning that + * additional effects like status changes are suppressed), the function returns `false` + * to indicate that the additional effects won't apply. Otherwise, the function returns `null`. + * + * @param {Pokemon} pokemon - The Pokémon being evaluated. + * @returns {boolean | null} `false` if Sheer Force prevents effects, otherwise `null` if not applicable. + */ +function checkSheerForceCondition(pokemon: Pokemon): boolean | null { + const lastReceivedAttack = pokemon.turnData?.attacksReceived[0]; + if (lastReceivedAttack) { + const lastAttacker = pokemon.getOpponents().find(p => p.id === lastReceivedAttack.sourceId); + if (lastAttacker) { + const isSheerForceAffected = allMoves[lastReceivedAttack.move].chance >= 0 && lastAttacker.hasAbility(Abilities.SHEER_FORCE); + return isSheerForceAffected ? false : null; + } + } + return null; +} + function getWeatherCondition(...weatherTypes: WeatherType[]): AbAttrCondition { return (pokemon: Pokemon) => { if (!pokemon.scene?.arena) { @@ -4684,6 +4906,453 @@ async function applyAbAttrsInternal( } } +// Shared helper class for common functionality +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} pokemon The Pokémon attempting to switch out. + * @returns {boolean} True if the switch is successful, false otherwise. + */ + + switchOutLogic(pokemon: Pokemon): boolean { + const switchOutTarget = pokemon; + // If the target is a Player Pokémon + /** + * If the switch-out target is a player-controlled Pokémon, the function checks: + * - Whether there are available party members to switch in. + * - If the Pokémon is still alive (hp > 0), and if so, it leaves the field and a new SwitchPhase is initiated. + */ + if (switchOutTarget instanceof PlayerPokemon) { + if (switchOutTarget.scene.getParty().filter((p) => p.isAllowedInBattle() && !p.isOnField()).length < 1) { + return false; + } + + if (switchOutTarget.hp > 0) { + switchOutTarget.leaveField(this.switchType === SwitchType.SWITCH); + pokemon.scene.unshiftPhase(new SwitchPhase(pokemon.scene, SwitchType.SWITCH, switchOutTarget.getFieldIndex(), true, true)); + return true; + } + // If the battle is not a wild Pokémon encounter + /** + * 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 (pokemon.scene.currentBattle.battleType !== BattleType.WILD) { + + if (switchOutTarget.scene.getEnemyParty().filter((p) => p.isAllowedInBattle() && !p.isOnField()).length < 1) { + return false; + } + + if (switchOutTarget.hp > 0) { + switchOutTarget.leaveField(this.switchType === SwitchType.SWITCH); + pokemon.scene.unshiftPhase(new SwitchSummonPhase(pokemon.scene, this.switchType, switchOutTarget.getFieldIndex(), + (pokemon.scene.currentBattle.trainer ? pokemon.scene.currentBattle.trainer.getNextSummonIndex((switchOutTarget as EnemyPokemon).trainerSlot) : 0), + false, false)); + return true; + } + // If it's a wild Pokémon encounter + /** + * For wild Pokémon battles, the Pokémon will flee if the conditions are met (waveIndex and double battles). + */ + } else { + if (!pokemon.scene.currentBattle.waveIndex && pokemon.scene.currentBattle.waveIndex % 10 === 0) { + return false; + } + + if (switchOutTarget.hp > 0) { + switchOutTarget.leaveField(false); + pokemon.scene.queueMessage(i18next.t("moveTriggers:fled", { pokemonName: getPokemonNameWithAffix(switchOutTarget) }), null, true, 500); + + if (switchOutTarget.scene.currentBattle.double) { + const allyPokemon = switchOutTarget.getAlly(); + switchOutTarget.scene.redirectPokemonMoves(switchOutTarget, allyPokemon); + } + } + + if (!switchOutTarget.getAlly()?.isActive(true)) { + pokemon.scene.clearEnemyHeldItemModifiers(); + + if (switchOutTarget.hp) { + pokemon.scene.pushPhase(new BattleEndPhase(pokemon.scene)); + pokemon.scene.pushPhase(new NewBattlePhase(pokemon.scene)); + } + } + } + return false; + } + + /** + * Determines if a Pokémon can switch out based on its status, the opponent's status, and battle conditions. + * + * @param {Pokemon} pokemon The Pokémon attempting to switch out. + * @param {Pokemon} opponent The opponent Pokémon. + * @returns {boolean} True if the switch-out condition is met, false otherwise. + */ + getSwitchOutCondition(pokemon: Pokemon, opponent: Pokemon): boolean { + const switchOutTarget = pokemon; + const player = switchOutTarget instanceof PlayerPokemon; + + if (switchOutTarget instanceof PlayerPokemon) { + if (!player && pokemon.scene.currentBattle.isBattleMysteryEncounter() && !pokemon.scene.currentBattle.mysteryEncounter?.fleeAllowed) { + return false; + } + + const blockedByAbility = new Utils.BooleanHolder(false); + applyAbAttrs(ForceSwitchOutImmunityAbAttr, opponent, blockedByAbility); + return !blockedByAbility.value; + } + + if (!player && pokemon.scene.currentBattle.battleType === BattleType.WILD) { + if (!pokemon.scene.currentBattle.waveIndex && pokemon.scene.currentBattle.waveIndex % 10 === 0) { + return false; + } + } + + const party = player ? pokemon.scene.getParty() : pokemon.scene.getEnemyParty(); + return (!player && !pokemon.scene.currentBattle.battleType) + || party.filter(p => p.isAllowedInBattle() + && (player || (p as EnemyPokemon).trainerSlot === (switchOutTarget as EnemyPokemon).trainerSlot)).length > pokemon.scene.currentBattle.getBattlerCount(); + } + + /** + * Returns a message if the switch-out attempt fails due to ability effects. + * + * @param {Pokemon} target The target Pokémon. + * @returns {string | null} The failure message, or null if no failure. + */ + getFailedText(target: Pokemon): string | null { + const blockedByAbility = new Utils.BooleanHolder(false); + applyAbAttrs(ForceSwitchOutImmunityAbAttr, target, blockedByAbility); + return blockedByAbility.value ? i18next.t("moveTriggers:cannotBeSwitchedOut", { pokemonName: getPokemonNameWithAffix(target) }) : null; + } +} + +/** + * Attribute that forces a switch-out after defending against an attack. + * This triggers after the Pokémon is attacked and takes damage. + */ +export class PostDefendForceSwitchOutAttr extends PostDefendAbAttr { + private helper: ForceSwitchOutHelper; + + constructor(private switchType: SwitchType = SwitchType.SWITCH) { + super(); + this.helper = new ForceSwitchOutHelper(switchType); + } + + /** + * Applies the switch-out logic after an attack hits the Pokemon. + * Checks if the Pokemon's health drops below half for a forced switch-out + * through {@linkcode getHealthKnockedBelowHalf}, which tracks the damage taken from the attack. + * + * If the attacker is attacking with a multi-hit move, the ability will trigger after the last hit + * If hit with a phasing move, the phasing move's effect is applied and not the ability + * + * @param {Pokemon} pokemon The defending Pokémon with this ability. + * @param {boolean} _passive N/A + * @param {boolean} simulated Whether the ability is being simulated. + * @param {Pokemon} attacker The attacking Pokémon. + * @param {Move} move The move that triggered the post-defend effect. + * @param {HitResult | null} hitResult N/A + * @param {any[]} args N/A + * @returns {boolean} True if the switch-out logic was successfully applied. + */ + + applyPostDefend(pokemon: Pokemon, _passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, _hitResult: HitResult, _args: any[]): boolean { + for (const opponent of pokemon.getOpponents()) { + if (!this.helper.getSwitchOutCondition(pokemon, opponent)) { + return false; + } + } + if (attacker.turnData.hitsLeft > 1) { + return false; + } + if (pokemon.getTag(BattlerTagType.TRAPPED) !== undefined) { + pokemon.removeTag(BattlerTagType.TRAPPED); + } + if (move.id === Moves.DRAGON_TAIL || move.id === Moves.CIRCLE_THROW) { + return false; + } + return this.helper.switchOutLogic(pokemon); + } + + getFailedText(user: Pokemon, target: Pokemon, move: Move, cancelled: Utils.BooleanHolder): string | null { + return this.helper.getFailedText(target); + } +} + +/** + * Ability attribute for forcing a Pokémon to switch out after an attack. + * This triggers after the Pokémon attacks and takes damage themselves. + * + * @extends PostAttackAbAttr + */ +export class PostAttackForceSwitchOutAttr extends PostAttackAbAttr { + private helper: ForceSwitchOutHelper; + + constructor(private switchType: SwitchType = SwitchType.SWITCH) { + super(); + this.helper = new ForceSwitchOutHelper(switchType); + } + + /** + * Applies the switch-out logic after an attack is made. Checks if the Pokemon's health drops + * below half for a forced switch-out through {@linkcode getHealthKnockedBelowHalf}, which + * tracks the damage taken following its attack. + * If the attacker cuts their own health with a move like Curse or Substitute, the ability is + * not triggered + * + * @param {Pokemon} pokemon The attacking Pokémon with this ability. + * @param {boolean} passive N/A + * @param {boolean} simulated Whether the ability is being simulated. + * @param {Pokemon} defender The defending Pokémon. + * @param {Move} move The move that triggered the post-attack effect. + * @param {HitResult | null} hitResult N/A + * @param {any[]} args N/A + * @returns {boolean} True if the switch-out logic was successfully applied. + */ + applyPostAttack(pokemon: Pokemon, passive: boolean, simulated: boolean, defender: Pokemon, move: Move, hitResult: HitResult | null, args: any[]): boolean { + + for (const opponent of pokemon.getOpponents()) { + if (!this.helper.getSwitchOutCondition(pokemon, opponent)) { + return false; + } + } + if (pokemon.getTag(BattlerTagType.TRAPPED) !== undefined) { + pokemon.removeTag(BattlerTagType.TRAPPED); + } + if ([ Moves.PAIN_SPLIT, Moves.SUBSTITUTE, Moves.BELLY_DRUM ].includes(move.id)) { + + return false; + } + if (pokemon.getTypes(true).includes(Type.GHOST) && move.id === Moves.CURSE) { + return false; + } + + return this.helper.switchOutLogic(pokemon); + } + + getFailedText(user: Pokemon, target: Pokemon, move: Move, cancelled: Utils.BooleanHolder): string | null { + return this.helper.getFailedText(target); + } +} + +/** + * Ability attribute for forcing a Pokémon to switch out after being summoned. + * This triggers after the Pokémon is summoned and takes damage from Arena Traps like + * Stealth Rocks. + * + * @extends PostSummonAbAttr + */ +export class PostSummonForceSwitchOutAttr extends PostSummonAbAttr { + private helper: ForceSwitchOutHelper; + + constructor(private switchType: SwitchType = SwitchType.SWITCH) { + super(); + this.helper = new ForceSwitchOutHelper(switchType); + } + + /** + * Applies the switch-out logic after the Pokémon is summoned. Checks if the Pokemon's health drops + * below half for a forced switch-out through {@linkcode getHealthKnockedBelowHalf}, which + * tracks the damage taken upon entry. If it drops below half, the pokemon is switched out + * + * @param {Pokemon} pokemon The Pokémon summoned with this ability. + * @param {boolean} passive N/A + * @param {boolean} simulated Whether the ability is being simulated. + * @param {any[]} args N/A + * @returns {boolean} True if the switch-out logic was successfully applied. + */ + applyPostSummon(pokemon: Pokemon, passive: boolean, simulated: boolean, args: any[]): boolean { + if (pokemon.hasAbilityWithAttr(BlockNonDirectDamageAbAttr)) { + return false; + } + for (const opponent of pokemon.getOpponents()) { + if (!this.helper.getSwitchOutCondition(pokemon, opponent)) { + return false; + } + } + if (pokemon.getTag(BattlerTagType.TRAPPED) !== undefined) { + pokemon.removeTag(BattlerTagType.TRAPPED); + } + + return this.helper.switchOutLogic(pokemon); + } + + getFailedText(user: Pokemon, target: Pokemon, move: Move, cancelled: Utils.BooleanHolder): string | null { + return this.helper.getFailedText(target); + } + +} + +/** + * Ability attribute for forcing a Pokémon to switch out at the end of the turn, + * if its health drops below half. This attribute triggers from effects like + * Poison, Leech Seed, Curse etc. + * + * @extends PostTurnAbAttr + */ +export class PostTurnForceSwitchOutAttr extends PostTurnAbAttr { + private helper: ForceSwitchOutHelper; + + constructor(private switchType: SwitchType = SwitchType.SWITCH) { + super(); + this.helper = new ForceSwitchOutHelper(switchType); + } + + /** + * Applies the switch-out logic at the end of the turn. Checks if the Pokemon's health drops + * below half for a forced switch-out through {@linkcode getHealthKnockedBelowHalf}, which + * simulates the damage that it would take. If it drops below half, the pokemon is switched out + * + * @param {Pokemon} pokemon The Pokémon attempting to switch out. + * @param {boolean} passive N/A + * @param {boolean} simulated Whether the ability is being simulated. + * @param {any[]} args N/A + * @returns {boolean | Promise} True if the switch-out logic was successfully applied. + */ + applyPostTurn(pokemon: Pokemon, passive: boolean, simulated: boolean, args: any[]): boolean | Promise { + if (pokemon.hasAbilityWithAttr(BlockNonDirectDamageAbAttr)) { + return false; + } + if (pokemon.status?.isPostTurn() && pokemon.hasAbilityWithAttr(BlockStatusDamageAbAttr)) { + return false; + } + for (const opponent of pokemon.getOpponents()) { + if (!this.helper.getSwitchOutCondition(pokemon, opponent)) { + return false; + } + } + if (pokemon.getTag(BattlerTagType.TRAPPED) !== undefined) { + pokemon.removeTag(BattlerTagType.TRAPPED); + } + return this.helper.switchOutLogic(pokemon); + + } + + getFailedText(user: Pokemon, target: Pokemon, move: Move, cancelled: Utils.BooleanHolder): string | null { + return this.helper.getFailedText(target); + } +} + +/** + * Ability attribute for forcing a Pokémon to switch out during weather. + * This attribute is triggered during a weather lapse and applies switch-out logic based on + * the damage taken during weather conditions. + * + * @extends PostWeatherLapseAbAttr + */ +export class PostWeatherForceSwitchOutAttr extends PostWeatherLapseAbAttr { + private helper: ForceSwitchOutHelper; + private damageFactor: integer; + + constructor(damageFactor: integer, ...weatherTypes: WeatherType[]) { + super(...weatherTypes); + this.damageFactor = damageFactor; + this.helper = new ForceSwitchOutHelper(SwitchType.SWITCH); + } + + /** + * Applies the switch-out logic during a weather that damages Pokemon such as Hail or Sandstorm. + * If the Pokemon would fall below half health from the simulated weather damage amount, the weather damage is + * applied and the Pokmeon is switched out. + * + * @param {Pokemon} pokemon The Pokémon affected by the weather. + * @param {boolean} passive N/A + * @param {boolean} simulated Whether the ability is being simulated. + * @param {Weather} weather The current weather condition. + * @param {any[]} args N/A + * @returns {boolean} True if the switch-out logic was successfully applied. + */ + + applyPostWeatherLapse(pokemon: Pokemon, passive: boolean, simulated: boolean, weather: Weather, args: any[]): boolean { + const scene = pokemon.scene; + if (pokemon.hasAbilityWithAttr(BlockNonDirectDamageAbAttr)) { + return false; + } + for (const opponent of pokemon.getOpponents()) { + if (!this.helper.getSwitchOutCondition(pokemon, opponent)) { + return false; + } + } + if (pokemon.getTag(BattlerTagType.TRAPPED) !== undefined) { + pokemon.removeTag(BattlerTagType.TRAPPED); + } + const damageAmount = Utils.toDmgValue(pokemon.getMaxHp() / (16 / this.damageFactor)); + if (pokemon.getHpRatio() > 0.5 && (pokemon.hp - damageAmount) / 2 < pokemon.getMaxHp()) { + if (getWeatherCondition(WeatherType.HAIL)) { + scene.queueMessage(i18next.t("weather:hailDamageMessage", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) })); + } else { + scene.queueMessage(i18next.t("weather:sandstormDamageMessage", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) })); + + } + pokemon.damageAndUpdate(Utils.toDmgValue(pokemon.getMaxHp() / (16 / this.damageFactor)), HitResult.OTHER); + } + return this.helper.switchOutLogic(pokemon); + } + + getFailedText(user: Pokemon, target: Pokemon, move: Move, cancelled: Utils.BooleanHolder): string | null { + return this.helper.getFailedText(target); + } +} + +/** + * Ability attribute for forcing a Pokémon to switch out after taking damage + * from KOing an enemy (Aftermath, Innards Out). + * + * @extends PostVictoryAbAttr + */ +export class PostVictoryForceSwitchOutAttr extends PostVictoryAbAttr { + private helper: ForceSwitchOutHelper; + + constructor(private switchType: SwitchType = SwitchType.SWITCH) { + super(); + this.helper = new ForceSwitchOutHelper(switchType); + } + + /** + * Applies the switch-out logic after a KO. Checks if the conditions for a forced switch-out + * are met based on whether the Pokémon's health dropped below half following damage taken during the KO. + * + * @param {Pokemon} pokemon the {@linkcode Pokemon} with this ability + * @param {boolean} passive N/A + * @param {boolean} simulated Whether the ability is being simulated. + * @param {any[]} args N/A + * @returns {boolean} True if the switch-out logic was successfully applied. + */ + applyPostVictory(pokemon: Pokemon, passive: boolean, simulated: boolean, args: any[]): boolean | Promise { + const pokemonHealth = pokemon.hp; + const maxPokemonHealth = pokemon.getMaxHp(); + const damageTaken = pokemon.turnData?.damageTaken; + const initialHealth = pokemonHealth + damageTaken; + // Check if the initial health was above half + if (initialHealth <= maxPokemonHealth / 2) { + return false; + } else { + // Check if current health dropped below half + if (pokemonHealth < maxPokemonHealth / 2) { + for (const opponent of pokemon.getOpponents()) { + if (!this.helper.getSwitchOutCondition(pokemon, opponent)) { + return false; + } + } + if (pokemon.getTag(BattlerTagType.TRAPPED) !== undefined) { + pokemon.removeTag(BattlerTagType.TRAPPED); + } + return this.helper.switchOutLogic(pokemon); + } else { + return false; + } + } + } + + getFailedText(user: Pokemon, target: Pokemon, move: Move, cancelled: Utils.BooleanHolder): string | null { + return this.helper.getFailedText(target); + } +} + + export function applyAbAttrs(attrType: Constructor, pokemon: Pokemon, cancelled: Utils.BooleanHolder | null, simulated: boolean = false, ...args: any[]): Promise { return applyAbAttrsInternal(attrType, pokemon, (attr, passive) => attr.apply(pokemon, passive, simulated, cancelled, args), args, false, simulated); } @@ -5459,11 +6128,21 @@ export function initAbilities() { new Ability(Abilities.STAMINA, 7) .attr(PostDefendStatStageChangeAbAttr, (target, user, move) => move.category !== MoveCategory.STATUS, Stat.DEF, 1), new Ability(Abilities.WIMP_OUT, 7) - .condition(getSheerForceHitDisableAbCondition()) - .unimplemented(), + .attr(PostDefendForceSwitchOutAttr) + .attr(PostAttackForceSwitchOutAttr) + .attr(PostSummonForceSwitchOutAttr) + .attr(PostTurnForceSwitchOutAttr) + .attr(PostWeatherForceSwitchOutAttr, 1, WeatherType.HAIL, WeatherType.SANDSTORM) + .attr(PostVictoryForceSwitchOutAttr) + .condition(getHealthKnockedBelowHalf()), new Ability(Abilities.EMERGENCY_EXIT, 7) - .condition(getSheerForceHitDisableAbCondition()) - .unimplemented(), + .attr(PostDefendForceSwitchOutAttr) + .attr(PostAttackForceSwitchOutAttr) + .attr(PostSummonForceSwitchOutAttr) + .attr(PostTurnForceSwitchOutAttr) + .attr(PostWeatherForceSwitchOutAttr, 1, WeatherType.HAIL, WeatherType.SANDSTORM) + .attr(PostVictoryForceSwitchOutAttr) + .condition(getHealthKnockedBelowHalf()), new Ability(Abilities.WATER_COMPACTION, 7) .attr(PostDefendStatStageChangeAbAttr, (target, user, move) => user.getMoveType(move) === Type.WATER && move.category !== MoveCategory.STATUS, Stat.DEF, 2), new Ability(Abilities.MERCILESS, 7) diff --git a/src/data/move.ts b/src/data/move.ts index a77e8096672..f1e7562ec68 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -8,7 +8,7 @@ import { Constructor, NumberHolder } from "#app/utils"; import * as Utils from "../utils"; import { WeatherType } from "./weather"; import { ArenaTagSide, ArenaTrapTag, WeakenMoveTypeTag } from "./arena-tag"; -import { allAbilities, AllyMoveCategoryPowerBoostAbAttr, applyAbAttrs, applyPostAttackAbAttrs, applyPreAttackAbAttrs, applyPreDefendAbAttrs, BlockItemTheftAbAttr, BlockNonDirectDamageAbAttr, BlockOneHitKOAbAttr, BlockRecoilDamageAttr, ConfusionOnStatusEffectAbAttr, FieldMoveTypePowerBoostAbAttr, FieldPreventExplosiveMovesAbAttr, ForceSwitchOutImmunityAbAttr, HealFromBerryUseAbAttr, IgnoreContactAbAttr, IgnoreMoveEffectsAbAttr, IgnoreProtectOnContactAbAttr, MaxMultiHitAbAttr, MoveAbilityBypassAbAttr, MoveEffectChanceMultiplierAbAttr, MoveTypeChangeAbAttr, ReverseDrainAbAttr, UncopiableAbilityAbAttr, UnsuppressableAbilityAbAttr, UnswappableAbilityAbAttr, UserFieldMoveTypePowerBoostAbAttr, VariableMovePowerAbAttr, WonderSkinAbAttr } from "./ability"; +import { allAbilities, AllyMoveCategoryPowerBoostAbAttr, applyAbAttrs, applyPostAttackAbAttrs, applyPreAttackAbAttrs, applyPreDefendAbAttrs, BlockItemTheftAbAttr, BlockNonDirectDamageAbAttr, BlockOneHitKOAbAttr, BlockRecoilDamageAttr, ConfusionOnStatusEffectAbAttr, FieldMoveTypePowerBoostAbAttr, FieldPreventExplosiveMovesAbAttr, ForceSwitchOutImmunityAbAttr, HealFromBerryUseAbAttr, IgnoreContactAbAttr, IgnoreMoveEffectsAbAttr, IgnoreProtectOnContactAbAttr, MaxMultiHitAbAttr, MoveAbilityBypassAbAttr, MoveEffectChanceMultiplierAbAttr, MoveTypeChangeAbAttr, PostDefendForceSwitchOutAttr, ReverseDrainAbAttr, UncopiableAbilityAbAttr, UnsuppressableAbilityAbAttr, UnswappableAbilityAbAttr, UserFieldMoveTypePowerBoostAbAttr, VariableMovePowerAbAttr, WonderSkinAbAttr } from "./ability"; import { AttackTypeBoosterModifier, BerryModifier, PokemonHeldItemModifier, PokemonMoveAccuracyBoosterModifier, PokemonMultiHitModifier, PreserveBerryModifier } from "../modifier/modifier"; import { BattlerIndex, BattleType } from "../battle"; import { TerrainType } from "./terrain"; @@ -5460,6 +5460,21 @@ export class RevivalBlessingAttr extends MoveEffectAttr { } } +/** +* 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. +*/ +function shouldPreventSwitchOut(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; +} + export class ForceSwitchOutAttr extends MoveEffectAttr { constructor( private selfSwitch: boolean = false, @@ -5479,11 +5494,17 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { } /** - * Move the switch out logic inside the conditional block - * This ensures that the switch out only happens when the conditions are met - */ + * 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. + */ const switchOutTarget = this.selfSwitch ? user : target; if (switchOutTarget instanceof PlayerPokemon) { + if (target.getAbility().hasAttr(PostDefendForceSwitchOutAttr) && + (move.id === Moves.U_TURN || move.id === Moves.VOLT_SWITCH)) { + if (shouldPreventSwitchOut(target)) { + return false; + } + } // Switch out logic for the player's Pokemon if (switchOutTarget.scene.getParty().filter((p) => p.isAllowedInBattle() && !p.isOnField()).length < 1) { return false; @@ -5509,6 +5530,17 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { false, false), MoveEndPhase); } } else { + /** + * 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(PostDefendForceSwitchOutAttr) && + (move.id === Moves.U_TURN || move.id === Moves.VOLT_SWITCH)) { + if (shouldPreventSwitchOut(target)) { + return false; + } + } + // Switch out logic for everything else (eg: WILD battles) if (user.scene.currentBattle.waveIndex % 10 === 0) { return false; diff --git a/src/test/abilities/wimp_out.test.ts b/src/test/abilities/wimp_out.test.ts new file mode 100644 index 00000000000..d49a313032a --- /dev/null +++ b/src/test/abilities/wimp_out.test.ts @@ -0,0 +1,654 @@ +import { BattlerIndex } from "#app/battle"; +import { ArenaTagSide } from "#app/data/arena-tag"; +import { allMoves } from "#app/data/move"; +import { Abilities } from "#app/enums/abilities"; +import { ArenaTagType } from "#app/enums/arena-tag-type"; +import { BattlerTagType } from "#app/enums/battler-tag-type"; +import { Stat } from "#app/enums/stat"; +import { StatusEffect } from "#app/enums/status-effect"; +import { WeatherType } from "#app/enums/weather-type"; +import { TurnEndPhase } from "#app/phases/turn-end-phase"; +import GameManager from "#app/test/utils/gameManager"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("Abilities - Wimp Out", () => { + 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 + .battleType("single") + .ability(Abilities.WIMP_OUT) + .enemySpecies(Species.NINJASK) + .startingLevel(90) + .startingWave(97) + .moveset([ Moves.SPLASH ]) + .enemyMoveset(Moves.FALSE_SWIPE) + .disableCrits(); + }); + + it("triggers regenerator passive single time when switching out with wimp out", async () => { + // arrange + game.override + .passiveAbility(Abilities.REGENERATOR) + .startingLevel(5) + .enemyLevel(100); + await game.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + + // act + game.move.select(Moves.FALSE_SWIPE); + game.doSelectPartyPokemon(1); + await game.phaseInterceptor.to(TurnEndPhase); + + // assert + expect(game.scene.getParty()[1].hp).toEqual(Math.floor(game.scene.getParty()[1].getMaxHp() * 0.33 + 1)); + expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); + expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.TYRUNT); + }); + + it("triggers status on the wimp out user before a new pokemon is switched in", async () => { + // arrange + game.override + .enemyMoveset(Moves.SLUDGE_BOMB) + .startingLevel(90); + await game.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + vi.spyOn(game.scene.getEnemyPokemon()!, "randSeedInt").mockReturnValue(0); + + // act + game.move.select(Moves.SLUDGE_BOMB); + game.doSelectPartyPokemon(1); + await game.phaseInterceptor.to(TurnEndPhase); + + // assert + const playerPkm = game.scene.getPlayerPokemon()!; + expect(game.scene.getParty()[1].status?.effect).toEqual(StatusEffect.POISON); + expect(playerPkm.species.speciesId).toEqual(Species.TYRUNT); + expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); + }); + it("It makes wild pokemon flee if triggered", async () => { + // arrange + game.override + .enemyAbility(Abilities.WIMP_OUT) + .startingLevel(150) + .moveset([ Moves.FALSE_SWIPE ]); + await game.startBattle([ + Species.GOLISOPOD, + Species.TYRUNT + ]); + + // act + game.move.select(Moves.FALSE_SWIPE); + await game.phaseInterceptor.to("BerryPhase"); + + // assert + const enemyPokemon = game.scene.getEnemyPokemon()!; + const isVisible = enemyPokemon.visible; + const hasFled = enemyPokemon.switchOutStatus; + expect(!isVisible && hasFled).toBe(true); + }); + + it("Does not trigger when HP already below half", async () => { + // arrange + game.override.moveset([ Moves.SPLASH ]); + const playerHp = 5; + await game.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + game.scene.getPlayerPokemon()!.hp = playerHp; + + + // act + game.move.select(Moves.FALSE_SWIPE); + game.move.select(Moves.SPLASH); + await game.phaseInterceptor.to(TurnEndPhase); + + // assert + expect(game.scene.getParty()[0].hp).toEqual(1); + expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); + expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.WIMPOD); + }); + + it("triggers after last hit of multi hit move", async () => { + // arrange + game.override + .enemyMoveset(Moves.BULLET_SEED) + .enemyAbility(Abilities.SKILL_LINK) + .startingLevel(110) + .enemyLevel(80); + await game.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + + // act + game.move.select(Moves.BULLET_SEED); + game.doSelectPartyPokemon(1); + await game.phaseInterceptor.to(TurnEndPhase); + + + // assert + const enemyPokemon = game.scene.getEnemyPokemon()!; + expect(enemyPokemon.turnData.hitsLeft).toBe(0); + expect(enemyPokemon.turnData.hitCount).toBe(5); + }); + it("Trapping moves do not prevent Wimp Out from activating.", async () => { + // arrange + game.override + .enemyMoveset([ Moves.SPIRIT_SHACKLE ]) + .startingLevel(53) + .enemyLevel(45); + await game.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + + // act + game.move.select(Moves.SPIRIT_SHACKLE); + game.doSelectPartyPokemon(1); + + + await game.phaseInterceptor.to(TurnEndPhase); + + // assert + expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); + expect(game.scene.getPlayerPokemon()!.getTag(BattlerTagType.TRAPPED)).toBeUndefined(); + expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.TYRUNT); + }); + + 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 () => { + // arrange + game.override + .startingLevel(95) + .enemyMoveset([ Moves.U_TURN ]); + await game.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + + // act + game.move.select(Moves.U_TURN); + game.doSelectPartyPokemon(1); + await game.phaseInterceptor.to(TurnEndPhase); + + // assert + const enemyPokemon = game.scene.getEnemyPokemon()!; + const hasFled = enemyPokemon.switchOutStatus; + expect(hasFled).toBe(false); + expect(game.scene.getParty()[1].getHpRatio()).toBeLessThan(0.5); + expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); + expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.TYRUNT); + }); + + 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 () => { + // arrange + game.override + .startingLevel(190) + .moveset([ Moves.SPLASH ]) + .enemyMoveset([ Moves.U_TURN ]); + await game.startBattle([ + Species.GOLISOPOD, + Species.TYRUNT + ]); + + // act + game.move.select(Moves.SPLASH); + game.move.select(Moves.U_TURN); + await game.phaseInterceptor.to(TurnEndPhase); + + // assert + const enemyPokemon = game.scene.getEnemyPokemon()!; + const hasFled = enemyPokemon.switchOutStatus; + expect(hasFled).toBe(true); + }); + it("Dragon Tail and Circle Throw switch out Pokémon before the Ability activates.", async () => { + // arrange + game.override + .startingLevel(69) + .moveset([ Moves.SPLASH ]) + .enemyMoveset([ Moves.DRAGON_TAIL ]); + await game.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + vi.spyOn(allMoves[Moves.DRAGON_TAIL], "accuracy", "get").mockReturnValue(100); + + + // act + game.move.select(Moves.DRAGON_TAIL); + game.doSelectPartyPokemon(1); + await game.phaseInterceptor.to(TurnEndPhase); + // assert + expect(game.scene.getParty()[1].getHpRatio()).toBeLessThan(0.5); + }); + it("triggers when recoil damage is taken", async () => { + // arrange + game.override + .moveset([ Moves.HEAD_SMASH ]) + .enemyMoveset([ Moves.SPLASH ]); + await game.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + vi.spyOn(allMoves[Moves.HEAD_SMASH], "accuracy", "get").mockReturnValue(100); + + + // act + game.move.select(Moves.HEAD_SMASH); + game.doSelectPartyPokemon(1); + await game.phaseInterceptor.to(TurnEndPhase); + + // assert + expect(game.scene.getParty()[1].getHpRatio()).toBeLessThan(0.5); + expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); + expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.TYRUNT); + }); + it("It does not activate when the Pokémon cuts its own HP", async () => { + // arrange + game.override + .moveset([ Moves.SUBSTITUTE ]) + .enemyMoveset([ Moves.SPLASH ]); + await game.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + const playerHp = game.scene.getPlayerPokemon()!.hp; + game.scene.getPlayerPokemon()!.hp = playerHp * 0.52; + + + // act + game.move.select(Moves.SUBSTITUTE); + game.move.select(Moves.SPLASH); + await game.phaseInterceptor.to(TurnEndPhase); + + // assert + expect(game.scene.getParty()[0].getHpRatio()).toBeLessThan(0.5); + expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); + expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.WIMPOD); + }); + it("Does not trigger when neutralized", async () => { + // arrange + game.override + .enemyAbility(Abilities.NEUTRALIZING_GAS) + .startingLevel(5); + await game.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + + // act + game.move.select(Moves.FALSE_SWIPE); + await game.phaseInterceptor.to(TurnEndPhase); + + // assert + const playerPkm = game.scene.getPlayerPokemon()!; + expect(playerPkm.species.speciesId).toEqual(Species.WIMPOD); + expect(playerPkm.getHpRatio()).toBeLessThan(0.5); + expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); + }); + it("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 () => { + // arrange + game.override + .moveset([ Moves.DOUBLE_EDGE ]) + .enemyMoveset([ Moves.SPLASH ]) + .startingHeldItems([ + { name: "SHELL_BELL", count: 3 }, + { name: "HEALING_CHARM", count: 5 }, + ]); + await game.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + const playerHp = game.scene.getPlayerPokemon()!.hp; + game.scene.getPlayerPokemon()!.hp = playerHp * 0.75; + + // act + game.move.select(Moves.DOUBLE_EDGE); + game.doSelectPartyPokemon(1); + await game.phaseInterceptor.to(TurnEndPhase); + + // assert + expect(game.scene.getParty()[1].getHpRatio()).toBeGreaterThan(0.5); + expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); + expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.TYRUNT); + }); + it("Wimp Out will activate due to weather damage", async () => { + // arrange + game.override + .moveset([ Moves.SPLASH ]) + .weather(WeatherType.HAIL) + .enemyMoveset([ Moves.SPLASH ]); + await game.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + const playerHp = game.scene.getPlayerPokemon()!.hp; + game.scene.getPlayerPokemon()!.hp = playerHp * 0.51; + + // act + game.move.select(Moves.SPLASH); + game.doSelectPartyPokemon(1); + await game.phaseInterceptor.to(TurnEndPhase); + + // assert + expect(game.scene.getParty()[1].getHpRatio()).toBeLessThan(0.5); + expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); + expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.TYRUNT); + }); + it("Does not trigger when enemy has sheer force", async () => { + // arrange + game.override + .enemyAbility(Abilities.SHEER_FORCE) + .enemyMoveset(Moves.SLUDGE_BOMB) + .startingLevel(90); + await game.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + + // act + game.move.select(Moves.SLUDGE_BOMB); + game.move.select(Moves.SPLASH); + await game.phaseInterceptor.to(TurnEndPhase); + + // assert + const playerPkm = game.scene.getPlayerPokemon()!; + expect(playerPkm.species.speciesId).toEqual(Species.WIMPOD); + expect(playerPkm.getHpRatio()).toBeLessThan(0.5); + expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); + }); + it("Wimp Out will activate due to post turn status damage", async () => { + // arrange + game.override + .moveset([ Moves.SPLASH ]) + .statusEffect(StatusEffect.POISON) + .enemyMoveset([ Moves.SPLASH ]); + await game.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + const playerHp = game.scene.getPlayerPokemon()!.hp; + game.scene.getPlayerPokemon()!.hp = playerHp * 0.51; + + // act + game.move.select(Moves.SPLASH); + game.doSelectPartyPokemon(1); + await game.toNextTurn(); + + + // assert + expect(game.scene.getParty()[1].getHpRatio()).toBeLessThan(0.5); + expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); + expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.TYRUNT); + }); + it("Wimp Out will activate due to bad dreams", async () => { + // arrange + game.override + .moveset([ Moves.SPLASH ]) + .statusEffect(StatusEffect.SLEEP) + .enemyAbility(Abilities.BAD_DREAMS); + await game.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + const playerHp = game.scene.getPlayerPokemon()!.hp; + game.scene.getPlayerPokemon()!.hp = playerHp * 0.52; + + // act + game.move.select(Moves.SPLASH); + game.doSelectPartyPokemon(1); + await game.toNextTurn(); + + // assert + expect(game.scene.getParty()[1].getHpRatio()).toBeGreaterThan(0); + expect(game.scene.getParty()[1].getHpRatio()).toBeLessThan(0.5); + expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); + expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.TYRUNT); + }); + it("Wimp Out will activate due to leech seed", async () => { + // arrange + game.override + .moveset([ Moves.SPLASH ]) + .enemyMoveset([ Moves.LEECH_SEED ]); + await game.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + vi.spyOn(allMoves[Moves.LEECH_SEED], "accuracy", "get").mockReturnValue(100); + const playerHp = game.scene.getPlayerPokemon()!.hp; + game.scene.getPlayerPokemon()!.hp = playerHp * 0.52; + + // act + game.move.select(Moves.LEECH_SEED); + game.doSelectPartyPokemon(1); + await game.toNextTurn(); + + // assert + expect(game.scene.getParty()[1].getHpRatio()).toBeGreaterThan(0); + expect(game.scene.getParty()[1].getHpRatio()).toBeLessThan(0.5); + expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); + expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.TYRUNT); + }); + it("Wimp Out will activate due to curse damage", async () => { + // arrange + game.override + .moveset([ Moves.SPLASH ]) + .enemySpecies(Species.DUSKNOIR) + .enemyMoveset([ Moves.CURSE ]); + await game.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + const playerHp = game.scene.getPlayerPokemon()!.hp; + game.scene.getPlayerPokemon()!.hp = playerHp * 0.52; + + // act + game.move.select(Moves.CURSE); + game.doSelectPartyPokemon(1); + await game.toNextTurn(); + + // assert + expect(game.scene.getParty()[1].getHpRatio()).toBeGreaterThan(0); + expect(game.scene.getParty()[1].getHpRatio()).toBeLessThan(0.5); + expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); + expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.TYRUNT); + }); + it("Wimp Out will activate due to salt cure damage", async () => { + // arrange + game.override + .moveset([ Moves.SPLASH ]) + .enemySpecies(Species.NACLI) + .enemyMoveset([ Moves.SALT_CURE ]) + .enemyLevel(1); + await game.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + const playerHp = game.scene.getPlayerPokemon()!.hp; + game.scene.getPlayerPokemon()!.hp = playerHp * 0.70; + + // act + game.move.select(Moves.SALT_CURE); + game.doSelectPartyPokemon(1); + await game.toNextTurn(); + + // assert + expect(game.scene.getParty()[1].getHpRatio()).toBeGreaterThan(0); + expect(game.scene.getParty()[1].getHpRatio()).toBeLessThan(0.5); + expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); + expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.TYRUNT); + }); + it("Wimp Out will activate due to damaging trap damage", async () => { + // arrange + game.override + .moveset([ Moves.SPLASH ]) + .enemySpecies(Species.MAGIKARP) + .enemyMoveset([ Moves.WHIRLPOOL ]) + .enemyLevel(1); + await game.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + vi.spyOn(allMoves[Moves.WHIRLPOOL], "accuracy", "get").mockReturnValue(100); + const playerHp = game.scene.getPlayerPokemon()!.hp; + game.scene.getPlayerPokemon()!.hp = playerHp * 0.55; + + // act + game.move.select(Moves.WHIRLPOOL); + game.doSelectPartyPokemon(1); + await game.toNextTurn(); + + // assert + expect(game.scene.getParty()[1].getHpRatio()).toBeGreaterThan(0); + expect(game.scene.getParty()[1].getHpRatio()).toBeLessThan(0.5); + expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); + expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.TYRUNT); + }); + it("Wimp Out will not activate if the Pokémon's HP falls below half due to hurting itself in confusion", async () => { + // arrange + game.override + .moveset([ Moves.SWORDS_DANCE ]) + .enemyMoveset([ Moves.SWAGGER ]); + await game.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + const playerPokemon = game.scene.getPlayerPokemon()!; + const playerHp = playerPokemon.hp; + playerPokemon.hp = playerHp * 0.51; + playerPokemon.setStatStage(Stat.ATK, 6); + playerPokemon.addTag(BattlerTagType.CONFUSED); + vi.spyOn(playerPokemon, "randSeedInt").mockReturnValue(0); + vi.spyOn(allMoves[Moves.SWAGGER], "accuracy", "get").mockReturnValue(100); + + // act + while (playerPokemon.getHpRatio() > 0.49) { + game.move.select(Moves.SWORDS_DANCE); + game.move.select(Moves.SWAGGER); + await game.phaseInterceptor.to(TurnEndPhase); + } + + // assert + expect(playerPokemon.getHpRatio()).toBeLessThan(0.5); + expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); + expect(playerPokemon.species.speciesId).toBe(Species.WIMPOD); + }); + it("Magic Guard passive should not allow indirect damage to trigger Wimp Out", async () => { + // arrange + game.scene.arena.addTag(ArenaTagType.STEALTH_ROCK, 1, Moves.STEALTH_ROCK, 0, ArenaTagSide.ENEMY); + game.scene.arena.addTag(ArenaTagType.SPIKES, 1, Moves.SPIKES, 0, ArenaTagSide.ENEMY); + game.override + .passiveAbility(Abilities.MAGIC_GUARD) + .moveset([ Moves.SPLASH ]) + .enemyMoveset([ Moves.LEECH_SEED ]) + .weather(WeatherType.HAIL) + .statusEffect(StatusEffect.POISON); + await game.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + vi.spyOn(allMoves[Moves.LEECH_SEED], "accuracy", "get").mockReturnValue(100); + const playerHp = game.scene.getPlayerPokemon()!.hp; + game.scene.getPlayerPokemon()!.hp = playerHp * 0.51; + + + // act + game.move.select(Moves.SPLASH); + game.move.select(Moves.LEECH_SEED); + await game.phaseInterceptor.to(TurnEndPhase); + + // assert + expect(game.scene.getParty()[0].getHpRatio()).toEqual(0.51); + expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); + expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.WIMPOD); + }); + it("Wimp Out activating should not cancel a double battle", async () => { + // arrange + game.override + .battleType("double") + .moveset([ Moves.FALSE_SWIPE, Moves.SPLASH ]) + .enemyAbility(Abilities.WIMP_OUT) + .enemyMoveset([ Moves.SPLASH ]) + .enemyLevel(1); + await game.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + const enemyLeadPokemon = game.scene.getEnemyParty()[0]!; + const enemySecPokemon = game.scene.getEnemyParty()[1]!; + + game.move.select(Moves.FALSE_SWIPE, 0, BattlerIndex.ENEMY); + game.move.select(Moves.SPLASH, 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(enemyLeadPokemon.hp).toBeLessThan(enemyLeadPokemon.getMaxHp()); + expect(enemySecPokemon.hp).toEqual(enemySecPokemon.getMaxHp()); + }); + it("Wimp Out will activate due to aftermath", async () => { + // arrange + game.override + .moveset([ Moves.THUNDER_PUNCH ]) + .enemySpecies(Species.MAGIKARP) + .enemyAbility(Abilities.AFTERMATH) + .enemyMoveset([ Moves.SPLASH ]) + .enemyLevel(1); + await game.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + const playerHp = game.scene.getPlayerPokemon()!.hp; + game.scene.getPlayerPokemon()!.hp = playerHp * 0.51; + + // act + game.move.select(Moves.THUNDER_PUNCH); + game.doSelectPartyPokemon(1); + await game.phaseInterceptor.to(TurnEndPhase); + + // assert + expect(game.scene.getParty()[1].getHpRatio()).toBeGreaterThan(0); + expect(game.scene.getParty()[1].getHpRatio()).toBeLessThan(0.5); + expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); + expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.TYRUNT); + }); + it("Activates due to entry hazards", async () => { + // arrange + game.scene.arena.addTag(ArenaTagType.STEALTH_ROCK, 1, Moves.STEALTH_ROCK, 0, ArenaTagSide.ENEMY); + game.scene.arena.addTag(ArenaTagType.SPIKES, 1, Moves.SPIKES, 0, ArenaTagSide.ENEMY); + game.override + .enemySpecies(Species.CENTISKORCH) + .enemyAbility(Abilities.WIMP_OUT); + await game.startBattle([ + Species.TYRUNT + ]); + + // assert + expect(game.phaseInterceptor.log).not.toContain("MovePhase"); + expect(game.phaseInterceptor.log).toContain("BattleEndPhase"); + }); +});