diff --git a/src/data/ability.ts b/src/data/ability.ts index 3c73bb47c47..6c503bb4100 100644 --- a/src/data/ability.ts +++ b/src/data/ability.ts @@ -1,4 +1,4 @@ -import Pokemon, { HitResult, PlayerPokemon, PokemonMove } from "../field/pokemon"; +import Pokemon, { EnemyPokemon, HitResult, MoveResult, PlayerPokemon, PokemonMove } from "../field/pokemon"; import { Type } from "./type"; import { Constructor } from "#app/utils"; import * as Utils from "../utils"; @@ -9,7 +9,7 @@ import { StatusEffect, getNonVolatileStatusEffects, getStatusEffectDescriptor, g 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, 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,12 @@ 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 { MoveEndPhase } from "#app/phases/move-end-phase"; export class Ability implements Localizable { public id: Abilities; @@ -3092,7 +3098,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 { @@ -4833,6 +4839,239 @@ async function applyAbAttrsInternal( } } +class ForceSwitchOutHelper { + constructor(private switchType: SwitchType) {} + + /** + * Handles the logic for switching out a Pokémon based on battle conditions, HP, and the switch type. + * + * @param pokemon The {@linkcode Pokemon} attempting to switch out. + * @returns `true` if the switch is successful + */ + public switchOutLogic(pokemon: Pokemon): boolean { + const switchOutTarget = pokemon; + /** + * If the switch-out target is a player-controlled Pokémon, the function checks: + * - Whether there are available party members to switch in. + * - If the Pokémon is still alive (hp > 0), and if so, it leaves the field and a new SwitchPhase is initiated. + */ + if (switchOutTarget instanceof PlayerPokemon) { + if (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.prependToPhase(new SwitchPhase(pokemon.scene, this.switchType, switchOutTarget.getFieldIndex(), true, true), MoveEndPhase); + return true; + } + /** + * For non-wild battles, it checks if the opposing party has any available Pokémon to switch in. + * If yes, the Pokémon leaves the field and a new SwitchSummonPhase is initiated. + */ + } else if (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.prependToPhase(new SwitchSummonPhase(pokemon.scene, this.switchType, switchOutTarget.getFieldIndex(), + (pokemon.scene.currentBattle.trainer ? pokemon.scene.currentBattle.trainer.getNextSummonIndex((switchOutTarget as EnemyPokemon).trainerSlot) : 0), + false, false), MoveEndPhase); + return true; + } + /** + * 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 The Pokémon attempting to switch out. + * @param opponent The opponent Pokémon. + * @returns `true` if the switch-out condition is met + */ + public getSwitchOutCondition(pokemon: Pokemon, opponent: Pokemon): boolean { + const switchOutTarget = pokemon; + const player = switchOutTarget instanceof PlayerPokemon; + + if (player) { + const blockedByAbility = new 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; + } + } + + if (!player && pokemon.scene.currentBattle.isBattleMysteryEncounter() && !pokemon.scene.currentBattle.mysteryEncounter?.fleeAllowed) { + return false; + } + + const party = player ? pokemon.scene.getParty() : pokemon.scene.getEnemyParty(); + return (!player && pokemon.scene.currentBattle.battleType === BattleType.WILD) + || 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 target The target Pokémon. + * @returns The failure message, or `null` if no failure. + */ + public 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; + } +} + +/** + * 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 Utils.toDmgValue(pokemon.turnData.damageDealt / 8) * shellBellModifier.stackCount; + } + return 0; +} + +/** + * Triggers after the Pokemon takes any damage + * @extends AbAttr + */ +export class PostDamageAbAttr extends AbAttr { + public applyPostDamage(pokemon: Pokemon, damage: number, passive: boolean, simulated: boolean, args: any[], source?: Pokemon): boolean | Promise { + return false; + } +} + +/** + * Ability attribute for forcing a Pokémon to switch out after its health drops below half. + * 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 + * + * @extends PostDamageAbAttr + * @see {@linkcode applyPostDamage} + */ +export class PostDamageForceSwitchAbAttr extends PostDamageAbAttr { + private helper: ForceSwitchOutHelper = new ForceSwitchOutHelper(SwitchType.SWITCH); + private hpRatio: number; + + constructor(hpRatio: number = 0.5) { + super(); + this.hpRatio = hpRatio; + } + + /** + * Applies the switch-out logic after the Pokémon takes damage. + * Checks various conditions based on the moves used by the Pokémon, the opponents' moves, and + * the Pokémon's health after damage to determine whether the switch-out should occur. + * + * @param pokemon The Pokémon that took damage. + * @param damage The amount of damage taken by the Pokémon. + * @param passive N/A + * @param simulated Whether the ability is being simulated. + * @param args N/A + * @param source The Pokemon that dealt damage + * @returns `true` if the switch-out logic was successfully applied + */ + public override applyPostDamage(pokemon: Pokemon, damage: number, passive: boolean, simulated: boolean, args: any[], source?: Pokemon): boolean | Promise { + const moveHistory = pokemon.getMoveHistory(); + // Will not activate when the Pokémon's HP is lowered by cutting its own HP + const fordbiddenAttackingMoves = [ Moves.BELLY_DRUM, Moves.SUBSTITUTE, Moves.CURSE, Moves.PAIN_SPLIT ]; + if (moveHistory.length > 0) { + const lastMoveUsed = moveHistory[moveHistory.length - 1]; + if (fordbiddenAttackingMoves.includes(lastMoveUsed.move)) { + return false; + } + } + + // Dragon Tail and Circle Throw switch out Pokémon before the Ability activates. + const fordbiddenDefendingMoves = [ Moves.DRAGON_TAIL, Moves.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 === Moves.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. + } else if (allMoves[enemyLastMoveUsed.move].chance >= 0 && source.hasAbility(Abilities.SHEER_FORCE)) { + return false; + // Activate only after the last hit of multistrike moves + } else if (source.turnData.hitsLeft > 1) { + return false; + } + if (source.turnData.hitCount > 1) { + damage = pokemon.turnData.damageTaken; + } + } + } + + if (pokemon.hp + damage >= pokemon.getMaxHp() * this.hpRatio) { + // Activates if it falls below half and recovers back above half from a Shell Bell + 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 this.helper.switchOutLogic(pokemon); + } else { + return false; + } + } else { + return false; + } + } + public 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); } @@ -4866,6 +5105,11 @@ export function applyPostSetStatusAbAttrs(attrType: Constructor(attrType, pokemon, (attr, passive) => attr.applyPostSetStatus(pokemon, sourcePokemon, passive, effect, simulated, args), args, false, simulated); } +export function applyPostDamageAbAttrs(attrType: Constructor, + pokemon: Pokemon, damage: number, passive: boolean, simulated: boolean = false, args: any[], source?: Pokemon): Promise { + return applyAbAttrsInternal(attrType, pokemon, (attr, passive) => attr.applyPostDamage(pokemon, damage, passive, simulated, args, source), args); +} + /** * Applies a field Stat multiplier attribute * @param attrType {@linkcode FieldMultiplyStatAbAttr} should always be FieldMultiplyBattleStatAbAttr for the time being @@ -5062,7 +5306,8 @@ export function initAbilities() { .attr(TypeImmunityAddBattlerTagAbAttr, Type.FIRE, BattlerTagType.FIRE_BOOST, 1) .ignorable(), new Ability(Abilities.SHIELD_DUST, 3) - .attr(IgnoreMoveEffectsAbAttr), + .attr(IgnoreMoveEffectsAbAttr) + .ignorable(), new Ability(Abilities.OWN_TEMPO, 3) .attr(BattlerTagImmunityAbAttr, BattlerTagType.CONFUSED) .attr(IntimidateImmunityAbAttr) @@ -5180,11 +5425,9 @@ export function initAbilities() { new Ability(Abilities.CUTE_CHARM, 3) .attr(PostDefendContactApplyTagChanceAbAttr, 30, BattlerTagType.INFATUATED), new Ability(Abilities.PLUS, 3) - .conditionalAttr(p => p.scene.currentBattle.double && [ Abilities.PLUS, Abilities.MINUS ].some(a => p.getAlly().hasAbility(a)), StatMultiplierAbAttr, Stat.SPATK, 1.5) - .ignorable(), + .conditionalAttr(p => p.scene.currentBattle.double && [ Abilities.PLUS, Abilities.MINUS ].some(a => p.getAlly().hasAbility(a)), StatMultiplierAbAttr, Stat.SPATK, 1.5), new Ability(Abilities.MINUS, 3) - .conditionalAttr(p => p.scene.currentBattle.double && [ Abilities.PLUS, Abilities.MINUS ].some(a => p.getAlly().hasAbility(a)), StatMultiplierAbAttr, Stat.SPATK, 1.5) - .ignorable(), + .conditionalAttr(p => p.scene.currentBattle.double && [ Abilities.PLUS, Abilities.MINUS ].some(a => p.getAlly().hasAbility(a)), StatMultiplierAbAttr, Stat.SPATK, 1.5), new Ability(Abilities.FORECAST, 3) .attr(UncopiableAbilityAbAttr) .attr(NoFusionAbilityAbAttr) @@ -5522,7 +5765,8 @@ export function initAbilities() { .attr(PostSummonMessageAbAttr, (pokemon: Pokemon) => i18next.t("abilityTriggers:postSummonTeravolt", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) })) .attr(MoveAbilityBypassAbAttr), new Ability(Abilities.AROMA_VEIL, 6) - .attr(UserFieldBattlerTagImmunityAbAttr, [ BattlerTagType.INFATUATED, BattlerTagType.TAUNT, BattlerTagType.DISABLED, BattlerTagType.TORMENT, BattlerTagType.HEAL_BLOCK ]), + .attr(UserFieldBattlerTagImmunityAbAttr, [ BattlerTagType.INFATUATED, BattlerTagType.TAUNT, BattlerTagType.DISABLED, BattlerTagType.TORMENT, BattlerTagType.HEAL_BLOCK ]) + .ignorable(), new Ability(Abilities.FLOWER_VEIL, 6) .ignorable() .unimplemented(), @@ -5607,11 +5851,11 @@ 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(PostDamageForceSwitchAbAttr) + .edgeCase(), // Should not trigger when hurting itself in confusion new Ability(Abilities.EMERGENCY_EXIT, 7) - .condition(getSheerForceHitDisableAbCondition()) - .unimplemented(), + .attr(PostDamageForceSwitchAbAttr) + .edgeCase(), // Should not trigger when hurting itself in confusion 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) @@ -5973,16 +6217,14 @@ export function initAbilities() { .ignorable(), new Ability(Abilities.SWORD_OF_RUIN, 9) .attr(FieldMultiplyStatAbAttr, Stat.DEF, 0.75) - .attr(PostSummonMessageAbAttr, (user) => i18next.t("abilityTriggers:postSummonSwordOfRuin", { pokemonNameWithAffix: getPokemonNameWithAffix(user), statName: i18next.t(getStatKey(Stat.DEF)) })) - .ignorable(), + .attr(PostSummonMessageAbAttr, (user) => i18next.t("abilityTriggers:postSummonSwordOfRuin", { pokemonNameWithAffix: getPokemonNameWithAffix(user), statName: i18next.t(getStatKey(Stat.DEF)) })), new Ability(Abilities.TABLETS_OF_RUIN, 9) .attr(FieldMultiplyStatAbAttr, Stat.ATK, 0.75) .attr(PostSummonMessageAbAttr, (user) => i18next.t("abilityTriggers:postSummonTabletsOfRuin", { pokemonNameWithAffix: getPokemonNameWithAffix(user), statName: i18next.t(getStatKey(Stat.ATK)) })) .ignorable(), new Ability(Abilities.BEADS_OF_RUIN, 9) .attr(FieldMultiplyStatAbAttr, Stat.SPDEF, 0.75) - .attr(PostSummonMessageAbAttr, (user) => i18next.t("abilityTriggers:postSummonBeadsOfRuin", { pokemonNameWithAffix: getPokemonNameWithAffix(user), statName: i18next.t(getStatKey(Stat.SPDEF)) })) - .ignorable(), + .attr(PostSummonMessageAbAttr, (user) => i18next.t("abilityTriggers:postSummonBeadsOfRuin", { pokemonNameWithAffix: getPokemonNameWithAffix(user), statName: i18next.t(getStatKey(Stat.SPDEF)) })), new Ability(Abilities.ORICHALCUM_PULSE, 9) .attr(PostSummonWeatherChangeAbAttr, WeatherType.SUNNY) .attr(PostBiomeChangeWeatherChangeAbAttr, WeatherType.SUNNY) diff --git a/src/data/move.ts b/src/data/move.ts index ab1219c2906..2d0846e79a8 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, InfiltratorAbAttr, 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, InfiltratorAbAttr, MaxMultiHitAbAttr, MoveAbilityBypassAbAttr, MoveEffectChanceMultiplierAbAttr, MoveTypeChangeAbAttr, PostDamageForceSwitchAbAttr, 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"; @@ -4465,7 +4465,7 @@ export class FormChangeItemTypeAttr extends VariableMoveTypeAttr { } if ([ user.species.speciesId, user.fusionSpecies?.speciesId ].includes(Species.ARCEUS) || [ user.species.speciesId, user.fusionSpecies?.speciesId ].includes(Species.SILVALLY)) { - const form = user.species.speciesId === Species.ARCEUS || user.species.speciesId === Species.SILVALLY ? user.formIndex : user.fusionSpecies?.formIndex!; // TODO: is this bang correct? + const form = user.species.speciesId === Species.ARCEUS || user.species.speciesId === Species.SILVALLY ? user.formIndex : user.fusionSpecies?.formIndex!; moveType.value = Type[Type[form]]; return true; @@ -5761,6 +5761,7 @@ export class RevivalBlessingAttr extends MoveEffectAttr { } } + export class ForceSwitchOutAttr extends MoveEffectAttr { constructor( private selfSwitch: boolean = false, @@ -5779,12 +5780,19 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { return false; } - /** - * Move the switch out logic inside the conditional block - * This ensures that the switch out only happens when the conditions are met - */ const switchOutTarget = this.selfSwitch ? user : target; if (switchOutTarget instanceof PlayerPokemon) { + /** + * Check if Wimp Out/Emergency Exit activates due to being hit by U-turn or Volt Switch + * If it did, the user of U-turn or Volt Switch will not be switched out. + */ + if (target.getAbility().hasAttr(PostDamageForceSwitchAbAttr) && + (move.id === Moves.U_TURN || move.id === Moves.VOLT_SWITCH || move.id === Moves.FLIP_TURN) + ) { + if (this.hpDroppedBelowHalf(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; @@ -5810,6 +5818,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(PostDamageForceSwitchAbAttr) && + (move.id === Moves.U_TURN || move.id === Moves.VOLT_SWITCH) || move.id === Moves.FLIP_TURN) { + if (this.hpDroppedBelowHalf(target)) { + return false; + } + } + // Switch out logic for everything else (eg: WILD battles) if (user.scene.currentBattle.waveIndex % 10 === 0) { return false; @@ -5902,6 +5921,21 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { } 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; + } } export class ChillyReceptionAttr extends ForceSwitchOutAttr { diff --git a/src/data/pokemon-species.ts b/src/data/pokemon-species.ts index a93c35829ea..107fa5073af 100644 --- a/src/data/pokemon-species.ts +++ b/src/data/pokemon-species.ts @@ -47,7 +47,7 @@ export function getPokemonSpecies(species: Species | Species[] | undefined): Pok return allSpecies[species - 1]; } -export function getPokemonSpeciesForm(species: Species, formIndex: integer): PokemonSpeciesForm { +export function getPokemonSpeciesForm(species: Species, formIndex: number): PokemonSpeciesForm { const retSpecies: PokemonSpecies = species >= 2000 ? allSpecies.find(s => s.speciesId === species)! // TODO: is the bang correct? : allSpecies[species - 1]; @@ -129,26 +129,27 @@ export type PokemonSpeciesFilter = (species: PokemonSpecies) => boolean; export abstract class PokemonSpeciesForm { public speciesId: Species; - public formIndex: integer; - public generation: integer; - public type1: Type; - public type2: Type | null; - public height: number; - public weight: number; - public ability1: Abilities; - public ability2: Abilities; - public abilityHidden: Abilities; - public baseTotal: integer; - public baseStats: integer[]; - public catchRate: integer; - public baseFriendship: integer; - public baseExp: integer; - public genderDiffs: boolean; - public isStarterSelectable: boolean; + protected _formIndex: number; + protected _generation: number; + readonly type1: Type; + readonly type2: Type | null; + readonly height: number; + readonly weight: number; + readonly ability1: Abilities; + readonly ability2: Abilities; + readonly abilityHidden: Abilities; + readonly baseTotal: number; + readonly baseStats: number[]; + readonly catchRate: number; + readonly baseFriendship: number; + readonly baseExp: number; + readonly genderDiffs: boolean; + readonly isStarterSelectable: boolean; constructor(type1: Type, type2: Type | null, height: number, weight: number, ability1: Abilities, ability2: Abilities, abilityHidden: Abilities, - baseTotal: integer, baseHp: integer, baseAtk: integer, baseDef: integer, baseSpatk: integer, baseSpdef: integer, baseSpd: integer, - catchRate: integer, baseFriendship: integer, baseExp: integer, genderDiffs: boolean, isStarterSelectable: boolean) { + baseTotal: number, baseHp: number, baseAtk: number, baseDef: number, baseSpatk: number, baseSpdef: number, baseSpd: number, + catchRate: number, baseFriendship: number, baseExp: number, genderDiffs: boolean, isStarterSelectable: boolean + ) { this.type1 = type1; this.type2 = type2; this.height = height; @@ -180,7 +181,23 @@ export abstract class PokemonSpeciesForm { return ret; } - isOfType(type: integer): boolean { + get generation(): number { + return this._generation; + } + + set generation(generation: number) { + this._generation = generation; + } + + get formIndex(): number { + return this._formIndex; + } + + set formIndex(formIndex: number) { + this._formIndex = formIndex; + } + + isOfType(type: number): boolean { return this.type1 === type || (this.type2 !== null && this.type2 === type); } @@ -188,7 +205,7 @@ export abstract class PokemonSpeciesForm { * Method to get the total number of abilities a Pokemon species has. * @returns Number of abilities */ - getAbilityCount(): integer { + getAbilityCount(): number { return this.abilityHidden !== Abilities.NONE ? 3 : 2; } @@ -197,7 +214,7 @@ export abstract class PokemonSpeciesForm { * @param abilityIndex Which ability to get (should only be 0-2) * @returns The id of the Ability */ - getAbility(abilityIndex: integer): Abilities { + getAbility(abilityIndex: number): Abilities { let ret: Abilities; if (abilityIndex === 0) { ret = this.ability1; @@ -277,12 +294,12 @@ export abstract class PokemonSpeciesForm { return ret; } - getSpriteAtlasPath(female: boolean, formIndex?: integer, shiny?: boolean, variant?: integer): string { + getSpriteAtlasPath(female: boolean, formIndex?: number, shiny?: boolean, variant?: number): string { const spriteId = this.getSpriteId(female, formIndex, shiny, variant).replace(/\_{2}/g, "/"); return `${/_[1-3]$/.test(spriteId) ? "variant/" : ""}${spriteId}`; } - getSpriteId(female: boolean, formIndex?: integer, shiny?: boolean, variant: integer = 0, back?: boolean): string { + getSpriteId(female: boolean, formIndex?: number, shiny?: boolean, variant: number = 0, back?: boolean): string { if (formIndex === undefined || this instanceof PokemonForm) { formIndex = this.formIndex; } @@ -299,11 +316,11 @@ export abstract class PokemonSpeciesForm { return `${back ? "back__" : ""}${shiny && (!variantSet || (!variant && !variantSet[variant || 0])) ? "shiny__" : ""}${baseSpriteKey}${shiny && variantSet && variantSet[variant] === 2 ? `_${variant + 1}` : ""}`; } - getSpriteKey(female: boolean, formIndex?: integer, shiny?: boolean, variant?: integer): string { + getSpriteKey(female: boolean, formIndex?: number, shiny?: boolean, variant?: number): string { return `pkmn__${this.getSpriteId(female, formIndex, shiny, variant)}`; } - abstract getFormSpriteKey(formIndex?: integer): string; + abstract getFormSpriteKey(formIndex?: number): string; /** @@ -311,9 +328,9 @@ export abstract class PokemonSpeciesForm { * @param formIndex optional form index for pokemon with different forms * @returns species id if no additional forms, index with formkey if a pokemon with a form */ - getVariantDataIndex(formIndex?: integer) { + getVariantDataIndex(formIndex?: number) { let formkey: string | null = null; - let variantDataIndex: integer | string = this.speciesId; + let variantDataIndex: number | string = this.speciesId; const species = getPokemonSpecies(this.speciesId); if (species.forms.length > 0 && formIndex !== undefined) { formkey = species.forms[formIndex]?.getFormSpriteKey(formIndex); @@ -324,13 +341,13 @@ export abstract class PokemonSpeciesForm { return variantDataIndex; } - getIconAtlasKey(formIndex?: integer, shiny?: boolean, variant?: integer): string { + getIconAtlasKey(formIndex?: number, shiny?: boolean, variant?: number): string { const variantDataIndex = this.getVariantDataIndex(formIndex); const isVariant = shiny && variantData[variantDataIndex] && (variant !== undefined && variantData[variantDataIndex][variant]); return `pokemon_icons_${this.generation}${isVariant ? "v" : ""}`; } - getIconId(female: boolean, formIndex?: integer, shiny?: boolean, variant?: integer): string { + getIconId(female: boolean, formIndex?: number, shiny?: boolean, variant?: number): string { if (formIndex === undefined) { formIndex = this.formIndex; } @@ -379,7 +396,7 @@ export abstract class PokemonSpeciesForm { return ret; } - getCryKey(formIndex?: integer): string { + getCryKey(formIndex?: number): string { let speciesId = this.speciesId; if (this.speciesId > 2000) { switch (this.speciesId) { @@ -446,7 +463,7 @@ export abstract class PokemonSpeciesForm { return ret; } - validateStarterMoveset(moveset: StarterMoveset, eggMoves: integer): boolean { + validateStarterMoveset(moveset: StarterMoveset, eggMoves: number): boolean { const rootSpeciesId = this.getRootSpeciesId(); for (const moveId of moveset) { if (speciesEggMoves.hasOwnProperty(rootSpeciesId)) { @@ -467,7 +484,7 @@ export abstract class PokemonSpeciesForm { return true; } - loadAssets(scene: BattleScene, female: boolean, formIndex?: integer, shiny?: boolean, variant?: Variant, startLoad?: boolean): Promise { + loadAssets(scene: BattleScene, female: boolean, formIndex?: number, shiny?: boolean, variant?: Variant, startLoad?: boolean): Promise { return new Promise(resolve => { const spriteKey = this.getSpriteKey(female, formIndex, shiny, variant); scene.loadPokemonAtlas(spriteKey, this.getSpriteAtlasPath(female, formIndex, shiny, variant)); @@ -536,7 +553,7 @@ export abstract class PokemonSpeciesForm { return cry; } - generateCandyColors(scene: BattleScene): integer[][] { + generateCandyColors(scene: BattleScene): number[][] { const sourceTexture = scene.textures.get(this.getSpriteKey(false)); const sourceFrame = sourceTexture.frames[sourceTexture.firstFrame]; @@ -544,7 +561,7 @@ export abstract class PokemonSpeciesForm { const canvas = document.createElement("canvas"); - const spriteColors: integer[][] = []; + const spriteColors: number[][] = []; const context = canvas.getContext("2d"); const frame = sourceFrame; @@ -567,7 +584,7 @@ export abstract class PokemonSpeciesForm { } for (let i = 0; i < pixelData.length; i += 4) { - const total = pixelData.slice(i, i + 3).reduce((total: integer, value: integer) => total + value, 0); + const total = pixelData.slice(i, i + 3).reduce((total: number, value: number) => total + value, 0); if (!total) { continue; } @@ -586,27 +603,28 @@ export abstract class PokemonSpeciesForm { Math.random = originalRandom; - return Array.from(paletteColors.keys()).map(c => Object.values(rgbaFromArgb(c)) as integer[]); + return Array.from(paletteColors.keys()).map(c => Object.values(rgbaFromArgb(c)) as number[]); } } export default class PokemonSpecies extends PokemonSpeciesForm implements Localizable { public name: string; - public subLegendary: boolean; - public legendary: boolean; - public mythical: boolean; - public species: string; - public growthRate: GrowthRate; - public malePercent: number | null; - public genderDiffs: boolean; - public canChangeForm: boolean; - public forms: PokemonForm[]; + readonly subLegendary: boolean; + readonly legendary: boolean; + readonly mythical: boolean; + readonly species: string; + readonly growthRate: GrowthRate; + readonly malePercent: number | null; + readonly genderDiffs: boolean; + readonly canChangeForm: boolean; + readonly forms: PokemonForm[]; - constructor(id: Species, generation: integer, subLegendary: boolean, legendary: boolean, mythical: boolean, species: string, + constructor(id: Species, generation: number, subLegendary: boolean, legendary: boolean, mythical: boolean, species: string, type1: Type, type2: Type | null, height: number, weight: number, ability1: Abilities, ability2: Abilities, abilityHidden: Abilities, - baseTotal: integer, baseHp: integer, baseAtk: integer, baseDef: integer, baseSpatk: integer, baseSpdef: integer, baseSpd: integer, - catchRate: integer, baseFriendship: integer, baseExp: integer, growthRate: GrowthRate, malePercent: number | null, - genderDiffs: boolean, canChangeForm?: boolean, ...forms: PokemonForm[]) { + baseTotal: number, baseHp: number, baseAtk: number, baseDef: number, baseSpatk: number, baseSpdef: number, baseSpd: number, + catchRate: number, baseFriendship: number, baseExp: number, growthRate: GrowthRate, malePercent: number | null, + genderDiffs: boolean, canChangeForm?: boolean, ...forms: PokemonForm[] + ) { super(type1, type2, height, weight, ability1, ability2, abilityHidden, baseTotal, baseHp, baseAtk, baseDef, baseSpatk, baseSpdef, baseSpd, catchRate, baseFriendship, baseExp, genderDiffs, false); this.speciesId = id; @@ -631,7 +649,7 @@ export default class PokemonSpecies extends PokemonSpeciesForm implements Locali }); } - getName(formIndex?: integer): string { + getName(formIndex?: number): string { if (formIndex !== undefined && this.forms.length) { const form = this.forms[formIndex]; let key: string | null; @@ -662,11 +680,11 @@ export default class PokemonSpecies extends PokemonSpeciesForm implements Locali this.name = i18next.t(`pokemon:${Species[this.speciesId].toLowerCase()}`); } - getWildSpeciesForLevel(level: integer, allowEvolving: boolean, isBoss: boolean, gameMode: GameMode): Species { + getWildSpeciesForLevel(level: number, allowEvolving: boolean, isBoss: boolean, gameMode: GameMode): Species { return this.getSpeciesForLevel(level, allowEvolving, false, (isBoss ? PartyMemberStrength.WEAKER : PartyMemberStrength.AVERAGE) + (gameMode?.isEndless ? 1 : 0)); } - getTrainerSpeciesForLevel(level: integer, allowEvolving: boolean = false, strength: PartyMemberStrength, currentWave: number = 0): Species { + getTrainerSpeciesForLevel(level: number, allowEvolving: boolean = false, strength: PartyMemberStrength, currentWave: number = 0): Species { return this.getSpeciesForLevel(level, allowEvolving, true, strength, currentWave); } @@ -688,7 +706,7 @@ export default class PokemonSpecies extends PokemonSpeciesForm implements Locali * @param strength {@linkcode PartyMemberStrength} The strength of the party member in question * @returns {@linkcode integer} The level difference from expected evolution level tolerated for a mon to be unevolved. Lower value = higher evolution chance. */ - private getStrengthLevelDiff(strength: PartyMemberStrength): integer { + private getStrengthLevelDiff(strength: PartyMemberStrength): number { switch (Math.min(strength, PartyMemberStrength.STRONGER)) { case PartyMemberStrength.WEAKEST: return 60; @@ -705,7 +723,7 @@ export default class PokemonSpecies extends PokemonSpeciesForm implements Locali } } - getSpeciesForLevel(level: integer, allowEvolving: boolean = false, forTrainer: boolean = false, strength: PartyMemberStrength = PartyMemberStrength.WEAKER, currentWave: number = 0): Species { + getSpeciesForLevel(level: number, allowEvolving: boolean = false, forTrainer: boolean = false, strength: PartyMemberStrength = PartyMemberStrength.WEAKER, currentWave: number = 0): Species { const prevolutionLevels = this.getPrevolutionLevels(); if (prevolutionLevels.length) { @@ -847,7 +865,7 @@ export default class PokemonSpecies extends PokemonSpeciesForm implements Locali } // This could definitely be written better and more accurate to the getSpeciesForLevel logic, but it is only for generating movesets for evolved Pokemon - getSimulatedEvolutionChain(currentLevel: integer, forTrainer: boolean = false, isBoss: boolean = false, player: boolean = false): EvolutionLevel[] { + getSimulatedEvolutionChain(currentLevel: number, forTrainer: boolean = false, isBoss: boolean = false, player: boolean = false): EvolutionLevel[] { const ret: EvolutionLevel[] = []; if (pokemonPrevolutions.hasOwnProperty(this.speciesId)) { const prevolutionLevels = this.getPrevolutionLevels().reverse(); @@ -899,7 +917,7 @@ export default class PokemonSpecies extends PokemonSpeciesForm implements Locali return variantData.hasOwnProperty(variantDataIndex) || variantData.hasOwnProperty(this.speciesId); } - getFormSpriteKey(formIndex?: integer) { + getFormSpriteKey(formIndex?: number) { if (this.forms.length && (formIndex !== undefined && formIndex >= this.forms.length)) { console.warn(`Attempted accessing form with index ${formIndex} of species ${this.getName()} with only ${this.forms.length || 0} forms`); formIndex = Math.min(formIndex, this.forms.length - 1); @@ -919,16 +937,17 @@ export class PokemonForm extends PokemonSpeciesForm { private starterSelectableKeys: string[] = [ "10", "50", "10-pc", "50-pc", "red", "orange", "yellow", "green", "blue", "indigo", "violet" ]; constructor(formName: string, formKey: string, type1: Type, type2: Type | null, height: number, weight: number, ability1: Abilities, ability2: Abilities, abilityHidden: Abilities, - baseTotal: integer, baseHp: integer, baseAtk: integer, baseDef: integer, baseSpatk: integer, baseSpdef: integer, baseSpd: integer, - catchRate: integer, baseFriendship: integer, baseExp: integer, genderDiffs?: boolean, formSpriteKey?: string | null, isStarterSelectable?: boolean, ) { + baseTotal: number, baseHp: number, baseAtk: number, baseDef: number, baseSpatk: number, baseSpdef: number, baseSpd: number, + catchRate: number, baseFriendship: number, baseExp: number, genderDiffs: boolean = false, formSpriteKey: string | null = null, isStarterSelectable: boolean = false + ) { super(type1, type2, height, weight, ability1, ability2, abilityHidden, baseTotal, baseHp, baseAtk, baseDef, baseSpatk, baseSpdef, baseSpd, - catchRate, baseFriendship, baseExp, !!genderDiffs, (!!isStarterSelectable || !formKey)); + catchRate, baseFriendship, baseExp, genderDiffs, (isStarterSelectable || !formKey)); this.formName = formName; this.formKey = formKey; - this.formSpriteKey = formSpriteKey !== undefined ? formSpriteKey : null; + this.formSpriteKey = formSpriteKey; } - getFormSpriteKey(_formIndex?: integer) { + getFormSpriteKey(_formIndex?: number) { return this.formSpriteKey !== null ? this.formSpriteKey : this.formKey; } } diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 281e03566e7..625472a7c95 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -12,7 +12,7 @@ import * as Utils from "#app/utils"; import { Type, TypeDamageMultiplier, getTypeDamageMultiplier, getTypeRgb } from "#app/data/type"; import { getLevelTotalExp } from "#app/data/exp"; import { Stat, type PermanentStat, type BattleStat, type EffectiveStat, PERMANENT_STATS, BATTLE_STATS, EFFECTIVE_STATS } from "#enums/stat"; -import { DamageMoneyRewardModifier, EnemyDamageBoosterModifier, EnemyDamageReducerModifier, EnemyEndureChanceModifier, EnemyFusionChanceModifier, HiddenAbilityRateBoosterModifier, BaseStatModifier, PokemonFriendshipBoosterModifier, PokemonHeldItemModifier, PokemonNatureWeightModifier, ShinyRateBoosterModifier, SurviveDamageModifier, TempStatStageBoosterModifier, TempCritBoosterModifier, StatBoosterModifier, CritBoosterModifier, TerastallizeModifier, PokemonBaseStatFlatModifier, PokemonBaseStatTotalModifier, PokemonIncrementingStatModifier, EvoTrackerModifier } from "#app/modifier/modifier"; +import { DamageMoneyRewardModifier, EnemyDamageBoosterModifier, EnemyDamageReducerModifier, EnemyEndureChanceModifier, EnemyFusionChanceModifier, HiddenAbilityRateBoosterModifier, BaseStatModifier, PokemonFriendshipBoosterModifier, PokemonHeldItemModifier, PokemonNatureWeightModifier, ShinyRateBoosterModifier, SurviveDamageModifier, TempStatStageBoosterModifier, TempCritBoosterModifier, StatBoosterModifier, CritBoosterModifier, TerastallizeModifier, PokemonBaseStatFlatModifier, PokemonBaseStatTotalModifier, PokemonIncrementingStatModifier, EvoTrackerModifier, PokemonMultiHitModifier } from "#app/modifier/modifier"; import { PokeballType } from "#app/data/pokeball"; import { Gender } from "#app/data/gender"; import { initMoveAnim, loadMoveAnimAssets } from "#app/data/battle-anims"; @@ -22,7 +22,7 @@ import { reverseCompatibleTms, tmSpecies, tmPoolTiers } from "#app/data/balance/ import { BattlerTag, BattlerTagLapseType, EncoreTag, GroundedTag, HighestStatBoostTag, SubstituteTag, TypeImmuneTag, getBattlerTag, SemiInvulnerableTag, TypeBoostTag, MoveRestrictionBattlerTag, ExposedTag, DragonCheerTag, CritBoostTag, TrappedTag, TarShotTag, AutotomizedTag, PowerTrickTag } from "../data/battler-tags"; import { WeatherType } from "#app/data/weather"; import { ArenaTagSide, NoCritTag, WeakenMoveScreenTag } from "#app/data/arena-tag"; -import { Ability, AbAttr, StatMultiplierAbAttr, BlockCritAbAttr, BonusCritAbAttr, BypassBurnDamageReductionAbAttr, FieldPriorityMoveImmunityAbAttr, IgnoreOpponentStatStagesAbAttr, MoveImmunityAbAttr, PreDefendFullHpEndureAbAttr, ReceivedMoveDamageMultiplierAbAttr, StabBoostAbAttr, StatusEffectImmunityAbAttr, TypeImmunityAbAttr, WeightMultiplierAbAttr, allAbilities, applyAbAttrs, applyStatMultiplierAbAttrs, applyPreApplyBattlerTagAbAttrs, applyPreAttackAbAttrs, applyPreDefendAbAttrs, applyPreSetStatusAbAttrs, UnsuppressableAbilityAbAttr, SuppressFieldAbilitiesAbAttr, NoFusionAbilityAbAttr, MultCritAbAttr, IgnoreTypeImmunityAbAttr, DamageBoostAbAttr, IgnoreTypeStatusEffectImmunityAbAttr, ConditionalCritAbAttr, applyFieldStatMultiplierAbAttrs, FieldMultiplyStatAbAttr, AddSecondStrikeAbAttr, UserFieldStatusEffectImmunityAbAttr, UserFieldBattlerTagImmunityAbAttr, BattlerTagImmunityAbAttr, MoveTypeChangeAbAttr, FullHpResistTypeAbAttr, applyCheckTrappedAbAttrs, CheckTrappedAbAttr, PostSetStatusAbAttr, applyPostSetStatusAbAttrs, InfiltratorAbAttr, AlliedFieldDamageReductionAbAttr } from "#app/data/ability"; +import { Ability, AbAttr, StatMultiplierAbAttr, BlockCritAbAttr, BonusCritAbAttr, BypassBurnDamageReductionAbAttr, FieldPriorityMoveImmunityAbAttr, IgnoreOpponentStatStagesAbAttr, MoveImmunityAbAttr, PreDefendFullHpEndureAbAttr, ReceivedMoveDamageMultiplierAbAttr, StabBoostAbAttr, StatusEffectImmunityAbAttr, TypeImmunityAbAttr, WeightMultiplierAbAttr, allAbilities, applyAbAttrs, applyStatMultiplierAbAttrs, applyPreApplyBattlerTagAbAttrs, applyPreAttackAbAttrs, applyPreDefendAbAttrs, applyPreSetStatusAbAttrs, UnsuppressableAbilityAbAttr, SuppressFieldAbilitiesAbAttr, NoFusionAbilityAbAttr, MultCritAbAttr, IgnoreTypeImmunityAbAttr, DamageBoostAbAttr, IgnoreTypeStatusEffectImmunityAbAttr, ConditionalCritAbAttr, applyFieldStatMultiplierAbAttrs, FieldMultiplyStatAbAttr, AddSecondStrikeAbAttr, UserFieldStatusEffectImmunityAbAttr, UserFieldBattlerTagImmunityAbAttr, BattlerTagImmunityAbAttr, MoveTypeChangeAbAttr, FullHpResistTypeAbAttr, applyCheckTrappedAbAttrs, CheckTrappedAbAttr, PostSetStatusAbAttr, applyPostSetStatusAbAttrs, InfiltratorAbAttr, AlliedFieldDamageReductionAbAttr, PostDamageAbAttr, applyPostDamageAbAttrs, PostDamageForceSwitchAbAttr } from "#app/data/ability"; import PokemonData from "#app/system/pokemon-data"; import { BattlerIndex } from "#app/battle"; import { Mode } from "#app/ui/ui"; @@ -428,38 +428,26 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { resolve(); }; if (this.shiny) { - const populateVariantColors = (key: string, back: boolean = false): Promise => { + const populateVariantColors = (isBackSprite: boolean = false): Promise => { return new Promise(resolve => { - const battleSpritePath = this.getBattleSpriteAtlasPath(back, ignoreOverride).replace("variant/", "").replace(/_[1-3]$/, ""); + const battleSpritePath = this.getBattleSpriteAtlasPath(isBackSprite, ignoreOverride).replace("variant/", "").replace(/_[1-3]$/, ""); let config = variantData; - const useExpSprite = this.scene.experimentalSprites && this.scene.hasExpSprite(this.getBattleSpriteKey(back, ignoreOverride)); + const useExpSprite = this.scene.experimentalSprites && this.scene.hasExpSprite(this.getBattleSpriteKey(isBackSprite, ignoreOverride)); battleSpritePath.split("/").map(p => config ? config = config[p] : null); const variantSet: VariantSet = config as VariantSet; if (variantSet && variantSet[this.variant] === 1) { - if (variantColorCache.hasOwnProperty(key)) { - return resolve(); + const cacheKey = this.getBattleSpriteKey(isBackSprite); + if (!variantColorCache.hasOwnProperty(cacheKey)) { + this.populateVariantColorCache(cacheKey, useExpSprite, battleSpritePath); } - this.scene.cachedFetch(`./images/pokemon/variant/${useExpSprite ? "exp/" : ""}${battleSpritePath}.json`). - then(res => { - // Prevent the JSON from processing if it failed to load - if (!res.ok) { - console.error(`Could not load ${res.url}!`); - return; - } - return res.json(); - }).then(c => { - variantColorCache[key] = c; - resolve(); - }); - } else { - resolve(); } + resolve(); }); }; if (this.isPlayer()) { - Promise.all([ populateVariantColors(this.getBattleSpriteKey(false)), populateVariantColors(this.getBattleSpriteKey(true), true) ]).then(() => updateFusionPaletteAndResolve()); + Promise.all([ populateVariantColors(false), populateVariantColors(true) ]).then(() => updateFusionPaletteAndResolve()); } else { - populateVariantColors(this.getBattleSpriteKey(false)).then(() => updateFusionPaletteAndResolve()); + populateVariantColors(false).then(() => updateFusionPaletteAndResolve()); } } else { updateFusionPaletteAndResolve(); @@ -472,6 +460,45 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { }); } + /** + * Gracefully handle errors loading a variant sprite. Log if it fails and attempt to fall back on + * non-experimental sprites before giving up. + * + * @param cacheKey the cache key for the variant color sprite + * @param attemptedSpritePath the sprite path that failed to load + * @param useExpSprite was the attempted sprite experimental + * @param battleSpritePath the filename of the sprite + * @param optionalParams any additional params to log + */ + fallbackVariantColor(cacheKey: string, attemptedSpritePath: string, useExpSprite: boolean, battleSpritePath: string, ...optionalParams: any[]) { + console.warn(`Could not load ${attemptedSpritePath}!`, ...optionalParams); + if (useExpSprite) { + this.populateVariantColorCache(cacheKey, false, battleSpritePath); + } + } + + /** + * Attempt to process variant sprite. + * + * @param cacheKey the cache key for the variant color sprite + * @param useExpSprite should the experimental sprite be used + * @param battleSpritePath the filename of the sprite + */ + populateVariantColorCache(cacheKey: string, useExpSprite: boolean, battleSpritePath: string) { + const spritePath = `./images/pokemon/variant/${useExpSprite ? "exp/" : ""}${battleSpritePath}.json`; + this.scene.cachedFetch(spritePath).then(res => { + // Prevent the JSON from processing if it failed to load + if (!res.ok) { + return this.fallbackVariantColor(cacheKey, res.url, useExpSprite, battleSpritePath, res.status, res.statusText); + } + return res.json(); + }).catch(error => { + this.fallbackVariantColor(cacheKey, spritePath, useExpSprite, battleSpritePath, error); + }).then(c => { + variantColorCache[cacheKey] = c; + }); + } + getFormKey(): string { if (!this.species.forms.length || this.species.forms.length <= this.formIndex) { return ""; @@ -2792,7 +2819,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * We explicitly require to ignore the faint phase here, as we want to show the messages * about the critical hit and the super effective/not very effective messages before the faint phase. */ - const damage = this.damageAndUpdate(isBlockedBySubstitute ? 0 : dmg, result as DamageResult, isCritical, isOneHitKo, isOneHitKo, true); + const damage = this.damageAndUpdate(isBlockedBySubstitute ? 0 : dmg, result as DamageResult, isCritical, isOneHitKo, isOneHitKo, true, source); if (damage > 0) { if (source.isPlayer()) { @@ -2805,6 +2832,15 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { source.turnData.currDamageDealt = damage; this.turnData.damageTaken += damage; this.battleData.hitCount++; + + // Multi-Lens and Parental Bond check for Wimp Out/Emergency Exit + if (this.hasAbilityWithAttr(PostDamageForceSwitchAbAttr)) { + const multiHitModifier = source.getHeldItems().find(m => m instanceof PokemonMultiHitModifier); + if (multiHitModifier || source.hasAbilityWithAttr(AddSecondStrikeAbAttr)) { + applyPostDamageAbAttrs(PostDamageAbAttr, this, damage, this.hasPassive(), false, [], source); + } + } + const attackResult = { move: move.id, result: result as DamageResult, damage: damage, critical: isCritical, sourceId: source.id, sourceBattlerIndex: source.getBattlerIndex() }; this.turnData.attacksReceived.unshift(attackResult); if (source.isPlayer() && !this.isPlayer()) { @@ -2892,7 +2928,6 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { this.destroySubstitute(); this.resetSummonData(); } - return damage; } @@ -2906,12 +2941,13 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * @param ignoreFaintPhase boolean to ignore adding a FaintPhase, passsed to damage() * @returns integer of damage done */ - damageAndUpdate(damage: integer, result?: DamageResult, critical: boolean = false, ignoreSegments: boolean = false, preventEndure: boolean = false, ignoreFaintPhase: boolean = false): integer { + damageAndUpdate(damage: integer, result?: DamageResult, critical: boolean = false, ignoreSegments: boolean = false, preventEndure: boolean = false, ignoreFaintPhase: boolean = false, source?: Pokemon): integer { const damagePhase = new DamagePhase(this.scene, this.getBattlerIndex(), damage, result as DamageResult, critical); this.scene.unshiftPhase(damagePhase); damage = this.damage(damage, ignoreSegments, preventEndure, ignoreFaintPhase); // Damage amount may have changed, but needed to be queued before calling damage function damagePhase.updateAmount(damage); + applyPostDamageAbAttrs(PostDamageAbAttr, this, damage, this.hasPassive(), false, [], source); return damage; } diff --git a/src/phases/post-turn-status-effect-phase.ts b/src/phases/post-turn-status-effect-phase.ts index 2efd992a2b5..08e4d7cb952 100644 --- a/src/phases/post-turn-status-effect-phase.ts +++ b/src/phases/post-turn-status-effect-phase.ts @@ -1,6 +1,6 @@ import BattleScene from "#app/battle-scene"; import { BattlerIndex } from "#app/battle"; -import { applyAbAttrs, BlockNonDirectDamageAbAttr, BlockStatusDamageAbAttr, ReduceBurnDamageAbAttr } from "#app/data/ability"; +import { applyAbAttrs, applyPostDamageAbAttrs, BlockNonDirectDamageAbAttr, BlockStatusDamageAbAttr, PostDamageAbAttr, ReduceBurnDamageAbAttr } from "#app/data/ability"; import { CommonBattleAnim, CommonAnim } from "#app/data/battle-anims"; import { getStatusEffectActivationText } from "#app/data/status-effect"; import { BattleSpec } from "#app/enums/battle-spec"; @@ -41,6 +41,7 @@ export class PostTurnStatusEffectPhase extends PokemonPhase { // Set preventEndure flag to avoid pokemon surviving thanks to focus band, sturdy, endure ... this.scene.damageNumberHandler.add(this.getPokemon(), pokemon.damage(damage.value, false, true)); pokemon.updateInfo(); + applyPostDamageAbAttrs(PostDamageAbAttr, pokemon, damage.value, pokemon.hasPassive(), false, []); } new CommonBattleAnim(CommonAnim.POISON + (pokemon.status.effect - 1), pokemon).play(this.scene, false, () => this.end()); } else { diff --git a/src/test/abilities/wimp_out.test.ts b/src/test/abilities/wimp_out.test.ts new file mode 100644 index 00000000000..ee943befc16 --- /dev/null +++ b/src/test/abilities/wimp_out.test.ts @@ -0,0 +1,614 @@ +import { BattlerIndex } from "#app/battle"; +import { ArenaTagSide } from "#app/data/arena-tag"; +import { allMoves } from "#app/data/move"; +import GameManager from "#app/test/utils/gameManager"; +import { Abilities } from "#enums/abilities"; +import { ArenaTagType } from "#enums/arena-tag-type"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import { Stat } from "#enums/stat"; +import { StatusEffect } from "#enums/status-effect"; +import { WeatherType } from "#enums/weather-type"; +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) + .enemyPassiveAbility(Abilities.NO_GUARD) + .startingLevel(90) + .enemyLevel(70) + .moveset([ Moves.SPLASH, Moves.FALSE_SWIPE, Moves.ENDURE ]) + .enemyMoveset(Moves.FALSE_SWIPE) + .disableCrits(); + }); + + function confirmSwitch(): void { + const [ pokemon1, pokemon2 ] = game.scene.getParty(); + + expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); + + expect(pokemon1.species.speciesId).not.toBe(Species.WIMPOD); + + expect(pokemon2.species.speciesId).toBe(Species.WIMPOD); + expect(pokemon2.isFainted()).toBe(false); + expect(pokemon2.getHpRatio()).toBeLessThan(0.5); + } + + function confirmNoSwitch(): void { + const [ pokemon1, pokemon2 ] = game.scene.getParty(); + + expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); + + expect(pokemon2.species.speciesId).not.toBe(Species.WIMPOD); + + expect(pokemon1.species.speciesId).toBe(Species.WIMPOD); + expect(pokemon1.isFainted()).toBe(false); + expect(pokemon1.getHpRatio()).toBeLessThan(0.5); + } + + it("triggers regenerator passive single time when switching out with wimp out", async () => { + game.override + .passiveAbility(Abilities.REGENERATOR) + .startingLevel(5) + .enemyLevel(100); + await game.classicMode.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + + const wimpod = game.scene.getPlayerPokemon()!; + + game.move.select(Moves.SPLASH); + game.doSelectPartyPokemon(1); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(wimpod.hp).toEqual(Math.floor(wimpod.getMaxHp() * 0.33 + 1)); + confirmSwitch(); + }); + + it("It makes wild pokemon flee if triggered", async () => { + game.override.enemyAbility(Abilities.WIMP_OUT); + await game.classicMode.startBattle([ + Species.GOLISOPOD, + Species.TYRUNT + ]); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + enemyPokemon.hp *= 0.52; + + game.move.select(Moves.FALSE_SWIPE); + await game.phaseInterceptor.to("BerryPhase"); + + const isVisible = enemyPokemon.visible; + const hasFled = enemyPokemon.switchOutStatus; + expect(!isVisible && hasFled).toBe(true); + }); + + it("Does not trigger when HP already below half", async () => { + await game.classicMode.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + const wimpod = game.scene.getPlayerPokemon()!; + wimpod.hp = 5; + + game.move.select(Moves.SPLASH); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(wimpod.hp).toEqual(1); + confirmNoSwitch(); + }); + + it("Trapping moves do not prevent Wimp Out from activating.", async () => { + game.override + .enemyMoveset([ Moves.SPIRIT_SHACKLE ]) + .startingLevel(53) + .enemyLevel(45); + await game.classicMode.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + + game.move.select(Moves.SPLASH); + game.doSelectPartyPokemon(1); + + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); + expect(game.scene.getPlayerPokemon()!.getTag(BattlerTagType.TRAPPED)).toBeUndefined(); + expect(game.scene.getParty()[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(95) + .enemyMoveset([ Moves.U_TURN ]); + await game.classicMode.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + + game.move.select(Moves.SPLASH); + game.doSelectPartyPokemon(1); + await game.phaseInterceptor.to("TurnEndPhase"); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + const hasFled = enemyPokemon.switchOutStatus; + expect(hasFled).toBe(false); + confirmSwitch(); + }); + + 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([ Moves.U_TURN ]); + await game.classicMode.startBattle([ + Species.GOLISOPOD, + Species.TYRUNT + ]); + const RIVAL_NINJASK1 = game.scene.getEnemyPokemon()?.id; + game.move.select(Moves.SPLASH); + await game.phaseInterceptor.to("BerryPhase", false); + expect(game.scene.getEnemyPokemon()?.id !== RIVAL_NINJASK1); + }); + + it("Dragon Tail and Circle Throw switch out Pokémon before the Ability activates.", async () => { + game.override + .startingLevel(69) + .enemyMoveset([ Moves.DRAGON_TAIL ]); + await game.classicMode.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + + const wimpod = game.scene.getPlayerPokemon()!; + + game.move.select(Moves.SPLASH); + game.doSelectPartyPokemon(1); + await game.phaseInterceptor.to("SwitchSummonPhase", false); + + expect(wimpod.summonData.abilitiesApplied).not.toContain(Abilities.WIMP_OUT); + + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(game.scene.getPlayerPokemon()!.species.speciesId).not.toBe(Species.WIMPOD); + }); + + it("triggers when recoil damage is taken", async () => { + game.override + .moveset([ Moves.HEAD_SMASH ]) + .enemyMoveset([ Moves.SPLASH ]); + await game.classicMode.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + + game.move.select(Moves.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([ Moves.SUBSTITUTE ]) + .enemyMoveset([ Moves.SPLASH ]); + await game.classicMode.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + + const wimpod = game.scene.getPlayerPokemon()!; + wimpod.hp *= 0.52; + + game.move.select(Moves.SUBSTITUTE); + await game.phaseInterceptor.to("TurnEndPhase"); + + confirmNoSwitch(); + }); + + it("Does not trigger when neutralized", async () => { + game.override + .enemyAbility(Abilities.NEUTRALIZING_GAS) + .startingLevel(5); + await game.classicMode.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + + game.move.select(Moves.SPLASH); + await game.phaseInterceptor.to("TurnEndPhase"); + + confirmNoSwitch(); + }); + + 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 () => { + game.override + .moveset([ Moves.DOUBLE_EDGE ]) + .enemyMoveset([ Moves.SPLASH ]) + .startingHeldItems([ + { name: "SHELL_BELL", count: 3 }, + { name: "HEALING_CHARM", count: 5 }, + ]); + await game.classicMode.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + + game.scene.getPlayerPokemon()!.hp *= 0.75; + + game.move.select(Moves.DOUBLE_EDGE); + game.doSelectPartyPokemon(1); + await game.phaseInterceptor.to("TurnEndPhase"); + + 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 () => { + game.override + .weather(WeatherType.HAIL) + .enemyMoveset([ Moves.SPLASH ]); + await game.classicMode.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + + game.scene.getPlayerPokemon()!.hp *= 0.51; + + game.move.select(Moves.SPLASH); + game.doSelectPartyPokemon(1); + await game.phaseInterceptor.to("TurnEndPhase"); + + confirmSwitch(); + }); + + it("Does not trigger when enemy has sheer force", async () => { + game.override + .enemyAbility(Abilities.SHEER_FORCE) + .enemyMoveset(Moves.SLUDGE_BOMB) + .startingLevel(95); + await game.classicMode.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + + game.move.select(Moves.SPLASH); + await game.phaseInterceptor.to("TurnEndPhase"); + + confirmNoSwitch(); + }); + + it("Wimp Out will activate due to post turn status damage", async () => { + game.override + .statusEffect(StatusEffect.POISON) + .enemyMoveset([ Moves.SPLASH ]); + await game.classicMode.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + + game.scene.getPlayerPokemon()!.hp *= 0.51; + + game.move.select(Moves.SPLASH); + game.doSelectPartyPokemon(1); + await game.toNextTurn(); + + confirmSwitch(); + }); + + it("Wimp Out will activate due to bad dreams", async () => { + game.override + .statusEffect(StatusEffect.SLEEP) + .enemyAbility(Abilities.BAD_DREAMS); + await game.classicMode.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + + game.scene.getPlayerPokemon()!.hp *= 0.52; + + game.move.select(Moves.SPLASH); + game.doSelectPartyPokemon(1); + await game.toNextTurn(); + + confirmSwitch(); + }); + + it("Wimp Out will activate due to leech seed", async () => { + game.override + .enemyMoveset([ Moves.LEECH_SEED ]); + await game.classicMode.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + game.scene.getPlayerPokemon()!.hp *= 0.52; + + game.move.select(Moves.SPLASH); + game.doSelectPartyPokemon(1); + await game.toNextTurn(); + + confirmSwitch(); + }); + + it("Wimp Out will activate due to curse damage", async () => { + game.override + .enemySpecies(Species.DUSKNOIR) + .enemyMoveset([ Moves.CURSE ]); + await game.classicMode.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + game.scene.getPlayerPokemon()!.hp *= 0.52; + + game.move.select(Moves.SPLASH); + game.doSelectPartyPokemon(1); + await game.toNextTurn(); + + confirmSwitch(); + }); + + it("Wimp Out will activate due to salt cure damage", async () => { + game.override + .enemySpecies(Species.NACLI) + .enemyMoveset([ Moves.SALT_CURE ]) + .enemyLevel(1); + await game.classicMode.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + game.scene.getPlayerPokemon()!.hp *= 0.70; + + game.move.select(Moves.SPLASH); + game.doSelectPartyPokemon(1); + await game.toNextTurn(); + + confirmSwitch(); + }); + + it("Wimp Out will activate due to damaging trap damage", async () => { + game.override + .enemySpecies(Species.MAGIKARP) + .enemyMoveset([ Moves.WHIRLPOOL ]) + .enemyLevel(1); + await game.classicMode.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + game.scene.getPlayerPokemon()!.hp *= 0.55; + + game.move.select(Moves.SPLASH); + game.doSelectPartyPokemon(1); + await game.toNextTurn(); + + confirmSwitch(); + }); + + it("Magic Guard passive should not allow indirect damage to trigger Wimp Out", async () => { + 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) + .enemyMoveset([ Moves.LEECH_SEED ]) + .weather(WeatherType.HAIL) + .statusEffect(StatusEffect.POISON); + await game.classicMode.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + game.scene.getPlayerPokemon()!.hp *= 0.51; + + game.move.select(Moves.SPLASH); + await game.phaseInterceptor.to("TurnEndPhase"); + + 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 () => { + game.override + .battleType("double") + .enemyAbility(Abilities.WIMP_OUT) + .enemyMoveset([ Moves.SPLASH ]) + .enemyLevel(1); + await game.classicMode.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); + + 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 () => { + game.override + .moveset([ Moves.THUNDER_PUNCH ]) + .enemySpecies(Species.MAGIKARP) + .enemyAbility(Abilities.AFTERMATH) + .enemyMoveset([ Moves.SPLASH ]) + .enemyLevel(1); + await game.classicMode.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + game.scene.getPlayerPokemon()!.hp *= 0.51; + + game.move.select(Moves.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, 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) + .startingWave(4); + await game.classicMode.startBattle([ + Species.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([ Moves.NIGHTMARE ]) + .statusEffect(StatusEffect.SLEEP); + await game.classicMode.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + game.scene.getPlayerPokemon()!.hp *= 0.65; + + game.move.select(Moves.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(Moves.SLUDGE_BOMB) + .startingLevel(80); + await game.classicMode.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + vi.spyOn(allMoves[Moves.SLUDGE_BOMB], "chance", "get").mockReturnValue(100); + + game.move.select(Moves.SPLASH); + game.doSelectPartyPokemon(1); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(game.scene.getParty()[1].status?.effect).toEqual(StatusEffect.POISON); + confirmSwitch(); + }); + + it("triggers after last hit of multi hit move", async () => { + game.override + .enemyMoveset(Moves.BULLET_SEED) + .enemyAbility(Abilities.SKILL_LINK); + await game.classicMode.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + + game.scene.getPlayerPokemon()!.hp *= 0.51; + + game.move.select(Moves.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(Moves.TACKLE) + .enemyHeldItems([{ name: "MULTI_LENS", count: 1 }]); + await game.classicMode.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + + game.scene.getPlayerPokemon()!.hp *= 0.51; + + game.move.select(Moves.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(Moves.TACKLE) + .enemyAbility(Abilities.PARENTAL_BOND); + await game.classicMode.startBattle([ + Species.WIMPOD, + Species.TYRUNT + ]); + + game.scene.getPlayerPokemon()!.hp *= 0.51; + + game.move.select(Moves.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([ Moves.SWORDS_DANCE ]) + .enemyMoveset([ Moves.SWAGGER ]); + await game.classicMode.startBattle([ + Species.WIMPOD, + Species.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 + + while (playerPokemon.getHpRatio() > 0.49) { + game.move.select(Moves.SWORDS_DANCE); + await game.phaseInterceptor.to("TurnEndPhase"); + } + + confirmNoSwitch(); + }); +});