From 1f7c67423b7c7fe212e0960ea0011c32ec6d8109 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Wed, 14 May 2025 19:00:26 -0400 Subject: [PATCH] Wimp Out changes --- src/data/abilities/ability.ts | 117 ++++---- src/data/moves/move.ts | 5 +- src/field/pokemon.ts | 49 ++-- src/modifier/modifier.ts | 4 +- src/phases/move-effect-phase.ts | 34 +-- src/phases/post-turn-status-effect-phase.ts | 3 +- test/abilities/wimp_out.test.ts | 298 +++++++++----------- test/moves/focus_punch.test.ts | 14 +- test/moves/powder.test.ts | 2 +- 9 files changed, 240 insertions(+), 286 deletions(-) diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index b137b31145a..af6d53bfda9 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -21,6 +21,7 @@ import { NeutralDamageAgainstFlyingTypeMultiplierAttr, FixedDamageAttr, type MoveAttr, + ForceSwitchOutAttr, } from "#app/data/moves/move"; import { ArenaTagSide } from "#app/data/arena-tag"; import { BerryModifier, HitHealModifier, PokemonHeldItemModifier } from "#app/modifier/modifier"; @@ -3662,7 +3663,7 @@ export class SuppressWeatherEffectAbAttr extends PreWeatherEffectAbAttr { /** * Condition function to applied to abilities related to Sheer Force. * Checks if last move used against target was affected by a Sheer Force user and: - * Disables: Color Change, Pickpocket, Berserk, Anger Shell + * Disables: Color Change, Pickpocket, Berserk, Anger Shell, Wimp Out and Emergency Exit. * @returns An {@linkcode AbAttrCondition} to disable the ability under the proper conditions. */ function getSheerForceHitDisableAbCondition(): AbAttrCondition { @@ -5547,12 +5548,12 @@ function applySingleAbAttrs( * 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. + * @returns The amount of health recovered by Shell Bell, or `0` if none are present. */ function calculateShellBellRecovery(pokemon: Pokemon): number { const shellBellModifier = pokemon.getHeldItems().find(m => m instanceof HitHealModifier); if (shellBellModifier) { - return toDmgValue(pokemon.turnData.totalDamageDealt / 8) * shellBellModifier.stackCount; + return toDmgValue(pokemon.turnData.lastMoveDamageDealt / 8) * shellBellModifier.stackCount; } return 0; } @@ -5565,20 +5566,19 @@ export class PostDamageAbAttr extends AbAttr { public canApplyPostDamage( pokemon: Pokemon, damage: number, - passive: boolean, simulated: boolean, - args: any[], - source?: Pokemon): boolean { + source: Pokemon | undefined, + args: any[] + ): boolean { return true; } public applyPostDamage( pokemon: Pokemon, damage: number, - passive: boolean, simulated: boolean, - args: any[], - source?: Pokemon, + source: Pokemon | undefined, + args: any[] ): void {} } @@ -5587,75 +5587,65 @@ export class PostDamageAbAttr extends AbAttr { * This attribute checks various conditions related to the damage received, the moves used by the Pokémon * and its opponents, and determines whether a forced switch-out should occur. * - * Used by Wimp Out and Emergency Exit + * Used for Wimp Out and Emergency Exit * - * @extends PostDamageAbAttr * @see {@linkcode applyPostDamage} */ export class PostDamageForceSwitchAbAttr extends ForceSwitch(PostDamageAbAttr) { private hpRatio: number; - constructor(selfSwitch = true, switchType: NormalSwitchType = SwitchType.SWITCH, hpRatio = 0.5) { + constructor(switchType: NormalSwitchType = SwitchType.SWITCH, hpRatio = 0.5) { super(); - this.selfSwitch = selfSwitch; + this.selfSwitch = false; // TODO: change if any abilities get damage this.switchType = switchType; this.hpRatio = hpRatio; } - // TODO: Refactor to use more early returns + /** + * Check to see if the user should be switched out after taking damage. + * @param pokemon - The {@linkcode Pokemon} with this ability; will be switched out if conditions are met. + * @param damage - The amount of damage dealt by the triggering damage instance. + * @param _simulated - unused + * @param source - The {@linkcode Pokemon} having damaged the user with an attack, or `undefined` + * if the damage source was indirect. + * @param _args - unused + * @returns Whether this pokemon should be switched out upon move conclusion. + */ public override canApplyPostDamage( pokemon: Pokemon, damage: number, - passive: boolean, - simulated: boolean, - args: any[], - source?: Pokemon): boolean { - const moveHistory = pokemon.getMoveHistory(); + _simulated: boolean, + source: Pokemon | undefined, + _args: any[], + ): boolean { + const userLastMove = pokemon.getLastXMoves()[0]; // 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; - } + const forbiddenAttackingMoves = new Set([ Moves.BELLY_DRUM, Moves.SUBSTITUTE, Moves.CURSE, Moves.PAIN_SPLIT ]); + if (!isNullOrUndefined(userLastMove) && forbiddenAttackingMoves.has(userLastMove.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. - // TODO: Make this use the sheer force disable condition - } 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; - } - } + // Skip last move checks if no enemy move + const lastMove = source?.getLastXMoves()[0] + if ( + lastMove && + // Will not activate for forced switch moves (triggers before wimp out activates) + (allMoves[lastMove.move].hasAttr(ForceSwitchOutAttr) + // Will not activate if the Pokémon's HP falls below half while it is in the air during Sky Drop + // TODO: Make this check the user's tags rather than the last move used by the target - we could be lifted by another pokemon + || (lastMove.move === Moves.SKY_DROP && lastMove.result === MoveResult.OTHER)) + ) { + return false; } - if (pokemon.hp + damage >= pokemon.getMaxHp() * this.hpRatio) { - const shellBellHeal = calculateShellBellRecovery(pokemon); - if (pokemon.hp - shellBellHeal < pokemon.getMaxHp() * this.hpRatio) { - for (const opponent of pokemon.getOpponents()) { - if (!this.canSwitchOut(pokemon, opponent)) { - return false; - } - } - return true; - } + // Check for HP percents - don't switch if the move didn't knock us below our switch threshold + // (either because we were below it to begin with or are still above it after the hit). + const hpNeededToSwitch = pokemon.getMaxHp() * this.hpRatio; + if (pokemon.hp + damage < hpNeededToSwitch || pokemon.hp >= hpNeededToSwitch) { + return false; } - return false; + return this.canSwitchOut(pokemon, oppponent) } /** @@ -5663,7 +5653,7 @@ export class PostDamageForceSwitchAbAttr extends ForceSwitch(PostDamageAbAttr) { * * @param pokemon The Pokémon that took damage. */ - public override applyPostDamage(pokemon: Pokemon, damage: number, passive: boolean, simulated: boolean, args: any[], source?: Pokemon): void { + public override applyPostDamage(pokemon: Pokemon, _damage: number, _simulated: boolean, _source: Pokemon | undefined, args: any[]): void { this.doSwitch(pokemon); } } @@ -5837,16 +5827,15 @@ export function applyPostDamageAbAttrs( attrType: Constructor, pokemon: Pokemon, damage: number, - passive: boolean, simulated = false, - args: any[], - source?: Pokemon, + source: Pokemon | undefined = undefined, + ...args: any[] ): void { applyAbAttrsInternal( attrType, pokemon, - (attr, passive) => attr.applyPostDamage(pokemon, damage, passive, simulated, args, source), - (attr, passive) => attr.canApplyPostDamage(pokemon, damage, passive, simulated, args, source), + (attr, passive) => attr.applyPostDamage(pokemon, damage, simulated, source, args), + (attr, passive) => attr.canApplyPostDamage(pokemon, damage, simulated, source, args), args, ); } @@ -6942,9 +6931,11 @@ export function initAbilities() { .attr(PostDefendStatStageChangeAbAttr, (target, user, move) => move.category !== MoveCategory.STATUS, Stat.DEF, 1), new Ability(Abilities.WIMP_OUT, 7) .attr(PostDamageForceSwitchAbAttr) + .condition(getSheerForceHitDisableAbCondition()) .edgeCase(), // Should not trigger when hurting itself in confusion, causes Fake Out to fail turn 1 and succeed turn 2 if pokemon is switched out before battle start via playing in Switch Mode new Ability(Abilities.EMERGENCY_EXIT, 7) .attr(PostDamageForceSwitchAbAttr) + .condition(getSheerForceHitDisableAbCondition()) .edgeCase(), // Should not trigger when hurting itself in confusion, causes Fake Out to fail turn 1 and succeed turn 2 if pokemon is switched out before battle start via playing in Switch Mode new Ability(Abilities.WATER_COMPACTION, 7) .attr(PostDefendStatStageChangeAbAttr, (target, user, move) => user.getMoveType(move) === PokemonType.WATER && move.category !== MoveCategory.STATUS, Stat.DEF, 2), diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index c0cc1bf2733..c5079ee9c48 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -1551,6 +1551,7 @@ export class MatchHpAttr extends FixedDamageAttr { type MoveFilter = (move: Move) => boolean; +// TODO: fix this to check the last direct damage instance taken export class CounterDamageAttr extends FixedDamageAttr { private moveFilter: MoveFilter; private multiplier: number; @@ -1664,8 +1665,8 @@ export class RecoilAttr extends MoveEffectAttr { return false; } - const damageValue = (!this.useHp ? user.turnData.totalDamageDealt : user.getMaxHp()) * this.damageRatio; - const minValue = user.turnData.totalDamageDealt ? 1 : 0; + const damageValue = (!this.useHp ? user.turnData.lastMoveDamageDealt : user.getMaxHp()) * this.damageRatio; + const minValue = user.turnData.lastMoveDamageDealt ? 1 : 0; const recoilDamage = toDmgValue(damageValue, minValue); if (!recoilDamage) { return false; diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 775d4370f35..619c862ed0d 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -342,6 +342,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { public luck: number; public pauseEvolutions: boolean; public pokerus: boolean; + // TODO: Document these public switchOutStatus = false; public evoCounter: number; public teraType: PokemonType; @@ -4748,13 +4749,15 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * Given the damage, adds a new DamagePhase and update HP values, etc. * * Checks for 'Indirect' HitResults to account for Endure/Reviver Seed applying correctly - * @param damage integer - passed to damage() - * @param result an enum if it's super effective, not very, etc. - * @param isCritical boolean if move is a critical hit - * @param ignoreSegments boolean, passed to damage() and not used currently - * @param preventEndure boolean, ignore endure properties of pokemon, passed to damage() - * @param ignoreFaintPhase boolean to ignore adding a FaintPhase, passsed to damage() - * @returns integer of damage done + * @param damage - Amount of damage to deal + * @param result - The {@linkcode HitResult} of the damage instance; default `HitResult.EFFECTIVE` + * @param isCritical - Whether the move being used (if any) was a critical hit; default `false` + * @param ignoreSegments - Whether to ignore boss segments; default `false` and currently unused + * @param preventEndure - Whether to ignore {@linkcode Moves.ENDURE} and similar effects when applying damage; default `false` + * @param ignoreFaintPhase - Whether to ignore adding a faint phase if the damage causes the target to faint; default `false` + * @returns The amount of damage actually dealt. + * @remarks + * This will not trigger "on damage" effects for direct damage moves, which instead occurs at the end of `MoveEffectPhase`. */ damageAndUpdate(damage: number, { @@ -4762,53 +4765,52 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { isCritical = false, ignoreSegments = false, ignoreFaintPhase = false, - source = undefined, }: { result?: DamageResult, isCritical?: boolean, ignoreSegments?: boolean, ignoreFaintPhase?: boolean, - source?: Pokemon, } = {} ): number { const isIndirectDamage = [ HitResult.INDIRECT, HitResult.INDIRECT_KO ].includes(result); const damagePhase = new DamageAnimPhase( this.getBattlerIndex(), damage, - result as DamageResult, + result, isCritical ); globalScene.unshiftPhase(damagePhase); - if (this.switchOutStatus && source) { + + // TODO: Review if wimp out battle skip actually needs this anymore + if (this.switchOutStatus) { damage = 0; } + damage = this.damage( damage, ignoreSegments, isIndirectDamage, ignoreFaintPhase, ); + // Ensure the battle-info bar's HP is updated, though only if the battle info is visible // TODO: When battle-info UI is refactored, make this only update the HP bar if (this.battleInfo.visible) { this.updateInfo(); } + // Damage amount may have changed, but needed to be queued before calling damage function damagePhase.updateAmount(damage); - /** - * Run PostDamageAbAttr from any source of damage that is not from a multi-hit - * Multi-hits are handled in move-effect-phase.ts for PostDamageAbAttr - */ - if (!source || source.turnData.hitCount <= 1) { + + // Trigger PostDamageAbAttr (ie wimp out) for indirect damage only. + if (isIndirectDamage) { applyPostDamageAbAttrs( PostDamageAbAttr, this, damage, - this.hasPassive(), false, - [], - source, + undefined ); } return damage; @@ -7957,8 +7959,13 @@ export class PokemonTurnData { * - `0` = Move is finished */ public hitsLeft = -1; - public totalDamageDealt = 0; + /** + * The amount of damage dealt by this Pokemon's last attack. + * Reset upon successfully using a move and used to enable internal tracking of damage amounts. + */ + public lastMoveDamageDealt = 0; public singleHitDamageDealt = 0; + // TODO: Rework this into "damage taken last" counter for metal burst and co. public damageTaken = 0; public attacksReceived: AttackMoveResult[] = []; public order: number; @@ -8007,7 +8014,7 @@ export enum HitResult { FAIL, MISS, INDIRECT, - IMMUNE, + IMMUNE, // TODO: Why is this used exclusively for sheer cold? CONFUSION, INDIRECT_KO, } diff --git a/src/modifier/modifier.ts b/src/modifier/modifier.ts index 763b40c8555..7ed2773d047 100644 --- a/src/modifier/modifier.ts +++ b/src/modifier/modifier.ts @@ -1783,12 +1783,12 @@ export class HitHealModifier extends PokemonHeldItemModifier { * @returns `true` if the {@linkcode Pokemon} was healed */ override apply(pokemon: Pokemon): boolean { - if (pokemon.turnData.totalDamageDealt && !pokemon.isFullHp()) { + if (pokemon.turnData.lastMoveDamageDealt && !pokemon.isFullHp()) { // TODO: this shouldn't be undefined AFAIK globalScene.unshiftPhase( new PokemonHealPhase( pokemon.getBattlerIndex(), - toDmgValue(pokemon.turnData.totalDamageDealt / 8) * this.stackCount, + toDmgValue(pokemon.turnData.lastMoveDamageDealt / 8) * this.stackCount, i18next.t("modifier:hitHealApply", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), typeName: this.type.name, diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index d067807486d..7a7208ecd07 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -10,7 +10,6 @@ import { IgnoreMoveEffectsAbAttr, MaxMultiHitAbAttr, PostAttackAbAttr, - PostDamageAbAttr, PostDefendAbAttr, ReflectStatusMoveAbAttr, } from "#app/data/abilities/ability"; @@ -48,7 +47,7 @@ import { MoveTarget } from "#enums/MoveTarget"; import { MoveCategory } from "#enums/MoveCategory"; import { SpeciesFormChangePostMoveTrigger } from "#app/data/pokemon-forms"; import { PokemonType } from "#enums/pokemon-type"; -import { DamageResult, PokemonMove, type TurnMove } from "#app/field/pokemon"; +import { type DamageResult, PokemonMove, type TurnMove } from "#app/field/pokemon"; import type Pokemon from "#app/field/pokemon"; import { HitResult, MoveResult } from "#app/field/pokemon"; import { getPokemonNameWithAffix } from "#app/messages"; @@ -72,7 +71,7 @@ import { ShowAbilityPhase } from "./show-ability-phase"; import { MovePhase } from "./move-phase"; import { MoveEndPhase } from "./move-end-phase"; import { HideAbilityPhase } from "#app/phases/hide-ability-phase"; -import { TypeDamageMultiplier } from "#app/data/type"; +import type { TypeDamageMultiplier } from "#app/data/type"; import { HitCheckResult } from "#enums/hit-check-result"; import type Move from "#app/data/moves/move"; import { isFieldTargeted } from "#app/data/moves/move-utils"; @@ -101,6 +100,9 @@ export class MoveEffectPhase extends PokemonPhase { /** Phases queued during moves */ private queuedPhases: Phase[] = []; + /** The amount of direct attack damage taken by each of this Phase's targets. */ + private targetDamageTaken: number[] = []; + /** * @param reflected Indicates that the move was reflected by the user due to magic coat or magic bounce * @param virtual Indicates that the move is a virtual move (i.e. called by metronome) @@ -123,6 +125,7 @@ export class MoveEffectPhase extends PokemonPhase { this.targets = targets; this.hitChecks = Array(this.targets.length).fill([HitCheckResult.PENDING, 0]); + this.targetDamageTaken = Array(this.targets.length).fill(0); } /** @@ -785,11 +788,6 @@ export class MoveEffectPhase extends PokemonPhase { } if (this.lastHit) { globalScene.triggerPokemonFormChange(user, SpeciesFormChangePostMoveTrigger); - - // Multi-hit check for Wimp Out/Emergency Exit - if (user.turnData.hitCount > 1) { - applyPostDamageAbAttrs(PostDamageAbAttr, target, 0, target.hasPassive(), false, [], user); - } } } @@ -821,6 +819,7 @@ export class MoveEffectPhase extends PokemonPhase { isCritical, }); + // Apply and/or remove type boosting tags (Flash Fire, Charge, etc.) const typeBoost = user.findTag( t => t instanceof TypeBoostTag && t.boostedType === user.getMoveType(this.move), ) as TypeBoostTag; @@ -828,18 +827,17 @@ export class MoveEffectPhase extends PokemonPhase { user.removeTag(typeBoost.tagType); } - const isOneHitKo = result === HitResult.ONE_HIT_KO; - - if (!dmg) { + if (dmg === 0) { return result; } + const isOneHitKo = result === HitResult.ONE_HIT_KO; target.lapseTags(BattlerTagLapseType.HIT); - const substitute = target.getTag(SubstituteTag); - const isBlockedBySubstitute = substitute && this.move.hitsSubstitute(user, target); + const substituteTag = target.getTag(SubstituteTag); + const isBlockedBySubstitute = substituteTag && this.move.hitsSubstitute(user, target); if (isBlockedBySubstitute) { - substitute.hp -= dmg; + substituteTag.hp -= dmg; } else if (!target.isPlayer() && dmg >= target.hp) { globalScene.applyModifiers(EnemyEndureChanceModifier, false, target); } @@ -851,7 +849,6 @@ export class MoveEffectPhase extends PokemonPhase { ignoreFaintPhase: true, ignoreSegments: isOneHitKo, isCritical, - source: user, }); if (isCritical) { @@ -865,14 +862,13 @@ export class MoveEffectPhase extends PokemonPhase { if (user.isPlayer()) { globalScene.validateAchvs(DamageAchv, new NumberHolder(damage)); - if (damage > globalScene.gameData.gameStats.highestDamage) { - globalScene.gameData.gameStats.highestDamage = damage; - } + globalScene.gameData.gameStats.highestDamage = Math.max(damage, globalScene.gameData.gameStats.highestDamage); } - user.turnData.totalDamageDealt += damage; + user.turnData.lastMoveDamageDealt += damage; user.turnData.singleHitDamageDealt = damage; target.battleData.hitCount++; + // TODO: this might be incorrect for counter moves target.turnData.damageTaken += damage; target.turnData.attacksReceived.unshift({ diff --git a/src/phases/post-turn-status-effect-phase.ts b/src/phases/post-turn-status-effect-phase.ts index 9b530d48196..10f25005b40 100644 --- a/src/phases/post-turn-status-effect-phase.ts +++ b/src/phases/post-turn-status-effect-phase.ts @@ -49,9 +49,10 @@ export class PostTurnStatusEffectPhase extends PokemonPhase { } if (damage.value) { // Set preventEndure flag to avoid pokemon surviving thanks to focus band, sturdy, endure ... + // TODO: why don't we call `damageAndUpdate` here? globalScene.damageNumberHandler.add(this.getPokemon(), pokemon.damage(damage.value, false, true)); pokemon.updateInfo(); - applyPostDamageAbAttrs(PostDamageAbAttr, pokemon, damage.value, pokemon.hasPassive(), false, []); + applyPostDamageAbAttrs(PostDamageAbAttr, pokemon, damage.value); } new CommonBattleAnim(CommonAnim.POISON + (pokemon.status.effect - 1), pokemon).play(false, () => this.end()); } else { diff --git a/test/abilities/wimp_out.test.ts b/test/abilities/wimp_out.test.ts index f558efdb103..344b7b22a0d 100644 --- a/test/abilities/wimp_out.test.ts +++ b/test/abilities/wimp_out.test.ts @@ -37,7 +37,7 @@ describe("Abilities - Wimp Out", () => { .enemyPassiveAbility(Abilities.NO_GUARD) .startingLevel(90) .enemyLevel(70) - .moveset([Moves.SPLASH, Moves.FALSE_SWIPE, Moves.ENDURE]) + .moveset([Moves.SPLASH, Moves.FALSE_SWIPE, Moves.ENDURE, Moves.THUNDER_PUNCH]) .enemyMoveset(Moves.FALSE_SWIPE) .disableCrits(); }); @@ -66,7 +66,40 @@ describe("Abilities - Wimp Out", () => { expect(pokemon1.getHpRatio()).toBeLessThan(0.5); } - it("triggers regenerator passive single time when switching out with wimp out", async () => { + it("should switch the user out when falling below half HP, canceling its subsequent moves", async () => { + await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]); + + const wimpod = game.scene.getPlayerPokemon()!; + wimpod.hp *= 0.52; + + game.move.select(Moves.THUNDER_PUNCH); + game.doSelectPartyPokemon(1); + await game.phaseInterceptor.to("TurnEndPhase"); + + // Wimpod switched out after taking a hit, canceling its upcoming MovePhase before it could attack + confirmSwitch(); + expect(game.scene.getEnemyPokemon()!.getInverseHp()).toBe(0); + expect(game.phaseInterceptor.log.reduce((count, phase) => count + (phase === "MoveEffectPhase" ? 1 : 0), 0)).toBe( + 1, + ); + }); + + it("should not trigger if user faints from damage", async () => { + game.override.enemyMoveset(Moves.BRAVE_BIRD).enemyLevel(1000); + await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]); + + const wimpod = game.scene.getPlayerPokemon()!; + wimpod.hp *= 0.52; + + game.move.select(Moves.THUNDER_PUNCH); + game.doSelectPartyPokemon(1); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(wimpod.isFainted()).toBe(true); + confirmNoSwitch(); + }); + + it("should trigger regenerator passive when switching out", async () => { game.override.passiveAbility(Abilities.REGENERATOR).startingLevel(5).enemyLevel(100); await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]); @@ -80,7 +113,7 @@ describe("Abilities - Wimp Out", () => { confirmSwitch(); }); - it("It makes wild pokemon flee if triggered", async () => { + it("should cause wild pokemon to flee when triggered", async () => { game.override.enemyAbility(Abilities.WIMP_OUT); await game.classicMode.startBattle([Species.GOLISOPOD, Species.TYRUNT]); @@ -95,7 +128,7 @@ describe("Abilities - Wimp Out", () => { expect(!isVisible && hasFled).toBe(true); }); - it("Does not trigger when HP already below half", async () => { + it("should not trigger when HP already below half", async () => { await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]); const wimpod = game.scene.getPlayerPokemon()!; wimpod.hp = 5; @@ -107,7 +140,7 @@ describe("Abilities - Wimp Out", () => { confirmNoSwitch(); }); - it("Trapping moves do not prevent Wimp Out from activating.", async () => { + it("should bypass trapping moves", async () => { game.override.enemyMoveset([Moves.SPIRIT_SHACKLE]).startingLevel(53).enemyLevel(45); await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]); @@ -122,7 +155,7 @@ describe("Abilities - Wimp Out", () => { 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 () => { + it("should block U-turn or Volt Switch on activation", async () => { game.override.startingLevel(95).enemyMoveset([Moves.U_TURN]); await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]); @@ -136,7 +169,7 @@ describe("Abilities - Wimp Out", () => { 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 () => { + it("should not block U-turn or Volt Switch if not activated", 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; @@ -145,7 +178,7 @@ describe("Abilities - Wimp Out", () => { expect(game.scene.getEnemyPokemon()?.id !== RIVAL_NINJASK1); }); - it("Dragon Tail and Circle Throw switch out Pokémon before the Ability activates.", async () => { + it("should not activate from Dragon Tail and Circle Throw", async () => { game.override.startingLevel(69).enemyMoveset([Moves.DRAGON_TAIL]); await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]); @@ -162,81 +195,41 @@ describe("Abilities - Wimp Out", () => { 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(); - }); - - // TODO: Enable when this behavior is fixed (currently Shell Bell won't activate if Wimp Out activates because - // the pokemon is removed from the field before the Shell Bell modifier is applied, so it can't see the - // damage dealt and doesn't heal the pokemon) - it.todo( - "If it falls below half and recovers back above half from a Shell Bell, Wimp Out will activate even after the Shell Bell recovery", - async () => { + it.each<{ type: string; enemyMove?: Moves; enemyAbility?: Abilities }>([ + { type: "weather", enemyMove: Moves.HAIL }, + { type: "status", enemyMove: Moves.TOXIC }, + { type: "Curse", enemyMove: Moves.CURSE }, + { type: "Salt Cure", enemyMove: Moves.SALT_CURE }, + { type: "partial trapping moves", enemyMove: Moves.WHIRLPOOL }, + { type: "Leech Seed", enemyMove: Moves.LEECH_SEED }, + { type: "Nightmare", enemyMove: Moves.NIGHTMARE }, + { type: "Aftermath", enemyAbility: Abilities.AFTERMATH }, + { type: "Bad Dreams", enemyAbility: Abilities.BAD_DREAMS }, + ])( + "should activate from damage caused by $name", + async ({ enemyMove = Moves.SPLASH, enemyAbility = Abilities.BALL_FETCH }) => { game.override - .moveset([Moves.DOUBLE_EDGE]) - .enemyMoveset([Moves.SPLASH]) - .startingHeldItems([{ name: "SHELL_BELL", count: 4 }]); + .passiveAbility(Abilities.COMATOSE) + .enemySpecies(Species.GASTLY) + .enemyMoveset(enemyMove) + .enemyAbility(enemyAbility) + .enemyLevel(1); await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]); const wimpod = game.scene.getPlayerPokemon()!; + expect(wimpod).toBeDefined(); + wimpod.hp *= 0.55; - wimpod.damageAndUpdate(toDmgValue(wimpod.getMaxHp() * 0.4)); - - game.move.select(Moves.DOUBLE_EDGE); + game.move.select(Moves.THUNDER_PUNCH); game.doSelectPartyPokemon(1); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.phaseInterceptor.to("TurnEndPhase"); - expect(game.scene.getPlayerParty()[1]).toBe(wimpod); - expect(wimpod.hp).toBeGreaterThan(toDmgValue(wimpod.getMaxHp() / 2)); - expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); - expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.TYRUNT); + confirmSwitch(); }, ); - 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 () => { + it("should not trigger from Sheer Force-boosted moves", async () => { game.override.enemyAbility(Abilities.SHEER_FORCE).enemyMoveset(Moves.SLUDGE_BOMB).startingLevel(95); await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]); @@ -248,81 +241,79 @@ describe("Abilities - Wimp Out", () => { confirmNoSwitch(); }); - it("Wimp Out will activate due to post turn status damage", async () => { - game.override.statusEffect(StatusEffect.POISON).enemyMoveset([Moves.SPLASH]); + it("should trigger from recoil damage", async () => { + game.override.moveset(Moves.HEAD_SMASH).enemyMoveset(Moves.SPLASH); await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]); - game.scene.getPlayerPokemon()!.hp *= 0.51; - - game.move.select(Moves.SPLASH); + game.move.select(Moves.HEAD_SMASH); game.doSelectPartyPokemon(1); - await game.toNextTurn(); + await game.phaseInterceptor.to("TurnEndPhase"); 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]); + it("should trigger from Flame Burst ally damage in doubles", async () => { + game.override.battleStyle("double").enemyMoveset([Moves.FLAME_BURST, Moves.SPLASH]); + await game.classicMode.startBattle([Species.WIMPOD, Species.ZYGARDE, Species.TYRUNT]); - game.scene.getPlayerPokemon()!.hp *= 0.52; + const wimpod = game.scene.getPlayerPokemon()!; + expect(wimpod).toBeDefined(); + wimpod.hp *= 0.55; - game.move.select(Moves.SPLASH); - game.doSelectPartyPokemon(1); - await game.toNextTurn(); + game.move.select(Moves.SPLASH, BattlerIndex.PLAYER); + game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2); + await game.forceEnemyMove(Moves.FLAME_BURST, BattlerIndex.PLAYER_2); + await game.forceEnemyMove(Moves.SPLASH); + game.doSelectPartyPokemon(2); + await game.phaseInterceptor.to("TurnEndPhase"); confirmSwitch(); }); - it("Wimp Out will activate due to leech seed", async () => { - game.override.enemyMoveset([Moves.LEECH_SEED]); + it("should 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]); - game.scene.getPlayerPokemon()!.hp *= 0.52; - game.move.select(Moves.SPLASH); - game.doSelectPartyPokemon(1); - await game.toNextTurn(); + const wimpod = game.scene.getPlayerPokemon()!; + wimpod.hp *= 0.52; - confirmSwitch(); + game.move.select(Moves.SUBSTITUTE); + await game.phaseInterceptor.to("TurnEndPhase"); + + confirmNoSwitch(); }); - it("Wimp Out will activate due to curse damage", async () => { - game.override.enemySpecies(Species.DUSKNOIR).enemyMoveset([Moves.CURSE]); + it("should not trigger when neutralized", async () => { + game.override.enemyAbility(Abilities.NEUTRALIZING_GAS).startingLevel(5); 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(); + await game.phaseInterceptor.to("TurnEndPhase"); - confirmSwitch(); + confirmNoSwitch(); }); - it("Wimp Out will activate due to salt cure damage", async () => { - game.override.enemySpecies(Species.NACLI).enemyMoveset([Moves.SALT_CURE]).enemyLevel(1); + it("should disregard Shell Bell recovery while still activating it before switching", async () => { + game.override + .moveset([Moves.DOUBLE_EDGE]) + .enemyMoveset([Moves.SPLASH]) + .startingHeldItems([{ name: "SHELL_BELL", count: 4 }]); await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]); - game.scene.getPlayerPokemon()!.hp *= 0.7; - game.move.select(Moves.SPLASH); + const wimpod = game.scene.getPlayerPokemon()!; + wimpod.hp *= 0.51; + + game.move.select(Moves.DOUBLE_EDGE); game.doSelectPartyPokemon(1); - await game.toNextTurn(); + await game.phaseInterceptor.to("TurnEndPhase"); - confirmSwitch(); + expect(game.scene.getPlayerParty()[1]).toBe(wimpod); + expect(wimpod.hp).toBeGreaterThan(toDmgValue(wimpod.getMaxHp() / 2)); + expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); + expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.TYRUNT); }); - 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 () => { + it("should not switch if Magic Guard prevents damage", 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 @@ -341,7 +332,7 @@ describe("Abilities - Wimp Out", () => { expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.WIMPOD); }); - it("Wimp Out activating should not cancel a double battle", async () => { + it("should not cancel a double battle on activation", async () => { game.override.battleStyle("double").enemyAbility(Abilities.WIMP_OUT).enemyMoveset([Moves.SPLASH]).enemyLevel(1); await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]); const enemyLeadPokemon = game.scene.getEnemyParty()[0]; @@ -361,24 +352,7 @@ describe("Abilities - Wimp Out", () => { 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 () => { + it("should activate from entry hazard damage", 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); @@ -388,18 +362,6 @@ describe("Abilities - Wimp Out", () => { 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]); @@ -413,7 +375,7 @@ describe("Abilities - Wimp Out", () => { confirmSwitch(); }); - it("triggers after last hit of multi hit move", async () => { + it("triggers after last hit of multi hit moves", async () => { game.override.enemyMoveset(Moves.BULLET_SEED).enemyAbility(Abilities.SKILL_LINK); await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]); @@ -429,7 +391,7 @@ describe("Abilities - Wimp Out", () => { confirmSwitch(); }); - it("triggers after last hit of multi hit move (multi lens)", async () => { + it("triggers after last hit of multi hit moves from multi lens", async () => { game.override.enemyMoveset(Moves.TACKLE).enemyHeldItems([{ name: "MULTI_LENS", count: 1 }]); await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]); @@ -444,6 +406,7 @@ describe("Abilities - Wimp Out", () => { 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]); @@ -461,26 +424,23 @@ describe("Abilities - Wimp Out", () => { }); // 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); + it.todo("should 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 + // 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"); - } + while (playerPokemon.getHpRatio() > 0.49) { + game.move.select(Moves.SWORDS_DANCE); + await game.phaseInterceptor.to("TurnEndPhase"); + } - confirmNoSwitch(); - }, - ); + confirmNoSwitch(); + }); it("should not activate on wave X0 bosses", async () => { game.override.enemyAbility(Abilities.WIMP_OUT).startingLevel(5850).startingWave(10); @@ -499,7 +459,7 @@ describe("Abilities - Wimp Out", () => { expect(isVisible && !hasFled).toBe(true); }); - it("wimp out will not skip battles when triggered in a double battle", async () => { + it("should not skip battles when triggered in a double battle", async () => { const wave = 2; game.override .enemyMoveset(Moves.SPLASH) @@ -527,7 +487,7 @@ describe("Abilities - Wimp Out", () => { expect(game.scene.currentBattle.waveIndex).toBe(wave + 1); }); - it("wimp out should not skip battles when triggering the same turn as another enemy faints", async () => { + it("should not skip battles when triggering the same turn as another enemy faints", async () => { const wave = 2; game.override .enemySpecies(Species.WIMPOD) diff --git a/test/moves/focus_punch.test.ts b/test/moves/focus_punch.test.ts index e05eb008af7..232ddb06317 100644 --- a/test/moves/focus_punch.test.ts +++ b/test/moves/focus_punch.test.ts @@ -44,20 +44,18 @@ describe("Moves - Focus Punch", () => { const leadPokemon = game.scene.getPlayerPokemon()!; const enemyPokemon = game.scene.getEnemyPokemon()!; - const enemyStartingHp = enemyPokemon.hp; - game.move.select(Moves.FOCUS_PUNCH); await game.phaseInterceptor.to(MessagePhase); - expect(enemyPokemon.hp).toBe(enemyStartingHp); + expect(enemyPokemon.getInverseHp()).toBe(0); expect(leadPokemon.getMoveHistory().length).toBe(0); await game.phaseInterceptor.to(BerryPhase, false); - expect(enemyPokemon.hp).toBeLessThan(enemyStartingHp); + expect(enemyPokemon.getInverseHp()).toBe(0); expect(leadPokemon.getMoveHistory().length).toBe(1); - expect(leadPokemon.turnData.totalDamageDealt).toBe(enemyStartingHp - enemyPokemon.hp); + expect(enemyPokemon.getInverseHp()).toBeGreaterThan(0); }); it("should fail if the user is hit", async () => { @@ -72,16 +70,16 @@ describe("Moves - Focus Punch", () => { game.move.select(Moves.FOCUS_PUNCH); - await game.phaseInterceptor.to(MessagePhase); + await game.phaseInterceptor.to("MessagePhase"); expect(enemyPokemon.hp).toBe(enemyStartingHp); expect(leadPokemon.getMoveHistory().length).toBe(0); - await game.phaseInterceptor.to(BerryPhase, false); + await game.phaseInterceptor.to("BerryPhase", false); expect(enemyPokemon.hp).toBe(enemyStartingHp); expect(leadPokemon.getMoveHistory().length).toBe(1); - expect(leadPokemon.turnData.totalDamageDealt).toBe(0); + expect(enemyPokemon.getInverseHp()).toBe(0); }); it("should be cancelled if the user falls asleep mid-turn", async () => { diff --git a/test/moves/powder.test.ts b/test/moves/powder.test.ts index 457beb60f91..e665b5a47db 100644 --- a/test/moves/powder.test.ts +++ b/test/moves/powder.test.ts @@ -178,7 +178,7 @@ describe("Moves - Powder", () => { expect(playerPokemon.hp).toBe(playerPokemon.getMaxHp()); // enemy should have taken damage from player's Fiery Dance + 2 Powder procs expect(enemyPokemon.hp).toBe( - enemyStartingHp - playerPokemon.turnData.totalDamageDealt - 2 * Math.floor(enemyPokemon.getMaxHp() / 4), + enemyStartingHp - playerPokemon.turnData.lastMoveDamageDealt - 2 * Math.floor(enemyPokemon.getMaxHp() / 4), ); });