From 02a3a56ef67241d1f5d1c0434b9d1cd9de4a627b Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Sat, 17 May 2025 14:33:00 -0400 Subject: [PATCH] Fixed things with switching moves; refactored code and removed extraneous `resetSummonData` calls --- src/battle-scene.ts | 41 ++- src/data/abilities/ability.ts | 93 +++-- src/data/battler-tags.ts | 14 +- src/data/mixins/force-switch.ts | 74 ++-- src/data/moves/move.ts | 70 ++-- .../mystery-encounters/mystery-encounter.ts | 3 +- src/field/pokemon.ts | 33 +- src/modifier/modifier.ts | 27 +- src/phases/faint-phase.ts | 3 +- src/phases/move-effect-phase.ts | 18 +- src/phases/move-phase.ts | 4 +- src/phases/summon-phase.ts | 1 + src/phases/switch-phase.ts | 8 +- src/phases/switch-summon-phase.ts | 52 +-- src/utils/common.ts | 52 +++ test/abilities/wimp_out.test.ts | 342 ++++++++++-------- test/items/reviver_seed.test.ts | 5 +- test/moves/dragon_tail.test.ts | 162 +++++---- test/moves/powder.test.ts | 11 +- 19 files changed, 596 insertions(+), 417 deletions(-) diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 32d172fc623..1e0943e6d83 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -872,48 +872,51 @@ export default class BattleScene extends SceneBase { } /** - * Return all {@linkcode Pokemon} that are **not** currently {@linkcode Pokemon.isOnField | on field} + * Return the party positions of all {@linkcode Pokemon} that are **not** currently {@linkcode Pokemon.isOnField | on field} * but are still {@linkcode Pokemon.isAllowedInBattle | allowed in battle}. * Used for switch out logic checks. * @param player - Whether to search the player (`true`) or enemy (`false`) party; default `true` - * @returns An array of all {@linkcode PlayerPokemon} in reserve able to be switched into. + * @returns An array containing the **INDICES** of all {@linkcode Pokemon} in reserve able to be switched into. * @overload */ - public getBackupPartyMembers(player: true): PlayerPokemon[]; + public getBackupPartyMemberIndices(player: true): number[]; /** - * Return all {@linkcode Pokemon} that are **not** currently {@linkcode Pokemon.isOnField | on field} + * Return the party positions of all {@linkcode Pokemon} that are **not** currently {@linkcode Pokemon.isOnField | on field} * but are still {@linkcode Pokemon.isAllowedInBattle | allowed in battle}. * Used for switch out logic checks. * @param player - Whether to search the player (`true`) or enemy (`false`) party; default `true` - * @param trainerSlot - The {@linkcode EnemyPokemon.trainerSlot | trainer slot} of the Pokemon being switched out; + * @param trainerSlot - The {@linkcode TrainerSlot | trainer slot} to check against for enemy trainers; * used to verify ownership in multi battles. - * @returns An array of all {@linkcode EnemyPokemon} in reserve able to be switched into. + * @returns An array containing the **INDICES** of all {@linkcode Pokemon} in reserve able to be switched into. * @overload */ - public getBackupPartyMembers(player: false, trainerSlot: number): EnemyPokemon[]; + public getBackupPartyMemberIndices(player: false, trainerSlot: TrainerSlot): number[]; /** - * Return all {@linkcode Pokemon} that are **not** currently {@linkcode Pokemon.isOnField | on field} + * Return the party positions of all {@linkcode Pokemon} that are **not** currently {@linkcode Pokemon.isOnField | on field} * but are still {@linkcode Pokemon.isAllowedInBattle | allowed in battle}. * Used for switch out logic checks. * @param player - Whether to search the player (`true`) or enemy (`false`) party; default `true` - * @param trainerSlot - The enemy Pokemon's {@linkcode EnemyPokemon.trainerSlot | trainer slot} for opposing trainers; + * @param trainerSlot - The {@linkcode TrainerSlot | trainer slot} to check against for enemy trainers; * used to verify ownership in multi battles and unused for player pokemon. - * @returns An array of all {@linkcode PlayerPokemon}/{@linkcode EnemyPokemon} in reserve able to be switched into. + * @returns An array containing the **INDICES** of all {@linkcode Pokemon} in reserve able to be switched into. * @overload */ - public getBackupPartyMembers(player: boolean, trainerSlot: number | undefined): PlayerPokemon | EnemyPokemon[]; + public getBackupPartyMemberIndices(player: boolean, trainerSlot: TrainerSlot | undefined): number[]; - public getBackupPartyMembers( - player: B, - trainerSlot?: number, - ): R[] { - return (player ? this.getPlayerParty() : this.getEnemyParty()).filter( - (p: PlayerPokemon | EnemyPokemon) => - p.isAllowedInBattle() && !p.isOnField() && (p instanceof PlayerPokemon || p.trainerSlot !== trainerSlot), - ) as R[]; + public getBackupPartyMemberIndices(player: boolean, trainerSlot?: number): number[] { + // Note: We return the indices instead of the actual Pokemon because `SwitchSummonPhase` and co. take an index instead of a pokemon. + // If this is ever changed, this can be replaced with a simpler version involving `filter` and conditional type annotations. + const indices: number[] = []; + const party = player ? this.getPlayerParty() : this.getEnemyParty(); + party.forEach((p: PlayerPokemon | EnemyPokemon, i: number) => { + if (p.isAllowedInBattle() && !p.isOnField() && (player || (p as EnemyPokemon).trainerSlot === trainerSlot)) { + indices.push(i); + } + }); + return indices; } /** diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index a89a141bca5..f998efbcd9c 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -80,6 +80,7 @@ import type { ArenaTrapTag, SuppressAbilitiesTag } from "#app/data/arena-tag"; import { SelectBiomePhase } from "#app/phases/select-biome-phase"; import { noAbilityTypeOverrideMoves } from "../moves/invalid-moves"; import { ForceSwitch } from "../mixins/force-switch"; +import { MoveEffectPhase } from "#app/phases/move-effect-phase"; export class BlockRecoilDamageAttr extends AbAttr { constructor() { @@ -1301,7 +1302,6 @@ export class PokemonTypeChangeAbAttr extends PreAttackAbAttr { if (!pokemon.isTerastallized && move.id !== Moves.STRUGGLE && /** - * Skip moves that call other moves because these moves generate a following move that will trigger this ability attribute * @see {@link https://bulbapedia.bulbagarden.net/wiki/Category:Moves_that_call_other_moves} */ !move.findAttr((attr) => @@ -2818,8 +2818,12 @@ export class CommanderAbAttr extends AbAttr { // TODO: Should this work with X + Dondozo fusions? const ally = pokemon.getAlly(); - return globalScene.currentBattle?.double && !isNullOrUndefined(ally) && ally.species.speciesId === Species.DONDOZO - && !(ally.isFainted() || ally.getTag(BattlerTagType.COMMANDED)); + return ( + globalScene.currentBattle?.double + && ally?.species.speciesId === Species.DONDOZO + && !ally.isFainted() + && !ally.getTag(BattlerTagType.COMMANDED) + ); } override apply(pokemon: Pokemon, passive: boolean, simulated: boolean, cancelled: null, args: any[]): void { @@ -4105,7 +4109,6 @@ export class PostTurnRestoreBerryAbAttr extends PostTurnAbAttr { return false; } - // Clamp procChance to [0, 1]. Skip if didn't proc (less than pass) const pass = Phaser.Math.RND.realInRange(0, 1); return Phaser.Math.Clamp(this.procChance(pokemon), 0, 1) >= pass; } @@ -5578,7 +5581,7 @@ export class PostDamageForceSwitchAbAttr extends ForceSwitch(PostDamageAbAttr) { constructor(switchType: NormalSwitchType = SwitchType.SWITCH, hpRatio = 0.5) { super(); - this.selfSwitch = false; // TODO: change if any abilities get damage + this.selfSwitch = false; // TODO: change if any force switch abilities with red card exist this.switchType = switchType; this.hpRatio = hpRatio; } @@ -5600,34 +5603,64 @@ export class PostDamageForceSwitchAbAttr extends ForceSwitch(PostDamageAbAttr) { 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 forbiddenAttackingMoves = new Set([ Moves.BELLY_DRUM, Moves.SUBSTITUTE, Moves.CURSE, Moves.PAIN_SPLIT ]); - if (!isNullOrUndefined(userLastMove) && forbiddenAttackingMoves.has(userLastMove.move)) { + + // Skip move checks for damage not occurring due to a move (eg: hazards) + const currentPhase = globalScene.getCurrentPhase(); + if (currentPhase instanceof MoveEffectPhase && !this.passesMoveChecks(pokemon, source)) { return false; } - // Skip last move checks if no enemy move + if (!this.wasKnockedBelowHalf(pokemon, damage)) { + return false; + } + + return this.canSwitchOut(pokemon) + } + + /** + * Perform move checks to determine if this pokemon should switch out. + * @param pokemon - The {@linkcode Pokemon} with this ability + * @param source - The {@linkcode Pokemon} whose attack caused the user to switch out, + * or `undefined` if the damage source was indirect. + * @returns `true` if this Pokemon should be allowed to switch out. + */ + private passesMoveChecks(pokemon: Pokemon, source: Pokemon | undefined): boolean { + // Wimp Out and Emergency Exit... + const currentPhase = globalScene.getCurrentPhase() as MoveEffectPhase; + const currentMove = currentPhase.move; + + // will not activate from self-induced HP cutting... + // TODO: Verify that Fillet Away and Clangorous Soul proc wimp out + const hpCutMoves = new Set([ Moves.CURSE, Moves.BELLY_DRUM, Moves.SUBSTITUTE, Moves.PAIN_SPLIT, Moves.CLANGOROUS_SOUL, Moves.FILLET_AWAY]); + // NB: Given this attribute is only applied after _taking damage_ or recieving a damaging attack, + // a failed Substitute or non-ghost type Curse will not trigger this ability to begin with. + const notHpCut = !hpCutMoves.has(currentMove.id) + + // will not activate for forced switch moves (which trigger before wimp out activates)... + const notForceSwitched = ![Moves.DRAGON_TAIL, Moves.CIRCLE_THROW].includes(currentMove.id) + + // and will not activate if the Pokemon is currently in the air from Sky Drop. + // TODO: Make this check the user's tags once Sky Drop is fully implemented - + // we could be sky dropped by another Pokemon or take indirect damage while skybound (both of which render this check moot) 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; - } + const notSkyDropped = !(lastMove?.move === Moves.SKY_DROP && lastMove.result === MoveResult.OTHER) - // 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). + return notHpCut && notForceSwitched && notSkyDropped; + } + + /** + * Perform HP checks to determine if this pokemon should switch out. + * The switch fails if the pokemon was below {@linkcode hpRatio} before being hit + * or is still above it after the hit. + * @param pokemon - The {@linkcode Pokemon} with this ability + * @param damage - The amount of damage taken. + * @returns `true` if this Pokemon was knocked below half after `damage` was applied + */ + private wasKnockedBelowHalf(pokemon: Pokemon, damage: number) { + // NB: This occurs in `MoveEffectPhase` _after_ attack damage has been dealt, + // so `pokemon.hp` contains the post-taking damage hp value. const hpNeededToSwitch = pokemon.getMaxHp() * this.hpRatio; - if (pokemon.hp + damage < hpNeededToSwitch || pokemon.hp >= hpNeededToSwitch) { - return false; - } - - return this.canSwitchOut(pokemon, undefined) + return pokemon.hp < hpNeededToSwitch && pokemon.hp + damage >= hpNeededToSwitch } /** @@ -6913,12 +6946,10 @@ 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 + .condition(getSheerForceHitDisableAbCondition()), 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 + .condition(getSheerForceHitDisableAbCondition()), new Ability(Abilities.WATER_COMPACTION, 7) .attr(PostDefendStatStageChangeAbAttr, (target, user, move) => user.getMoveType(move) === PokemonType.WATER && move.category !== MoveCategory.STATUS, Stat.DEF, 2), new Ability(Abilities.MERCILESS, 7) diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 8a512f3c16c..a67fd54eb54 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -751,10 +751,16 @@ export class ConfusedTag extends BattlerTag { ); } + /** + * Tick down this Pokemon's confusion duration, randomly interrupting its move if not cured/ + * @param pokemon - The {@linkcode Pokemon} with this tag + * @param lapseType - The {@linkcode BattlerTagLapseType | lapse type} triggering this tag's effects. + * @returns Whether the tag should be kept. + */ lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { - const shouldLapse = lapseType !== BattlerTagLapseType.CUSTOM && super.lapse(pokemon, lapseType); + const shouldRemain = super.lapse(pokemon, lapseType); - if (!shouldLapse) { + if (!shouldRemain) { return false; } @@ -766,7 +772,9 @@ export class ConfusedTag extends BattlerTag { globalScene.unshiftPhase(new CommonAnimPhase(pokemon.getBattlerIndex(), undefined, CommonAnim.CONFUSION)); // 1/3 chance of hitting self with a 40 base power move - if (pokemon.randSeedInt(3) === 0 || Overrides.CONFUSION_ACTIVATION_OVERRIDE === true) { + const shouldInterruptMove = Overrides.CONFUSION_ACTIVATION_OVERRIDE ?? pokemon.randSeedInt(3) === 0; + if (shouldInterruptMove) { + // TODO: Are these calculations correct? We really shouldn't hardcode the damage formula here... const atk = pokemon.getEffectiveStat(Stat.ATK); const def = pokemon.getEffectiveStat(Stat.DEF); const damage = toDmgValue( diff --git a/src/data/mixins/force-switch.ts b/src/data/mixins/force-switch.ts index 964c11bf1e1..5381b35c8bb 100644 --- a/src/data/mixins/force-switch.ts +++ b/src/data/mixins/force-switch.ts @@ -17,51 +17,49 @@ import { applyAbAttrs, ForceSwitchOutImmunityAbAttr } from "../abilities/ability import type { MoveAttr } from "../moves/move"; import { getPokemonNameWithAffix } from "#app/messages"; import type { AbAttr } from "../abilities/ab-attrs/ab-attr"; -import type { TrainerSlot } from "#enums/trainer-slot"; -// NB: This shouldn't be terribly hard to extend from if switching items are added (à la Eject Button) +// NB: This shouldn't be terribly hard to extend from items if switching items are added (à la Eject Button/Red Card); type SubMoveOrAbAttr = (new (...args: any[]) => MoveAttr) | (new (...args: any[]) => AbAttr); -/** Mixin to handle shared logic for switch-in moves and abilities. */ +/** Mixin to handle shared logic for switching moves and abilities. */ export function ForceSwitch(Base: TBase) { return class ForceSwitchClass extends Base { protected selfSwitch = false; protected switchType: NormalSwitchType = SwitchType.SWITCH; /** - * Determines if a Pokémon can be forcibly switched out based on its status, the opponent's status and battle conditions. - * @see {@linkcode performOpponentChecks} for opponent-related check code. - - * @param switchOutTarget - The {@linkcode Pokemon} attempting to switch out. - * @param opponent - The {@linkcode Pokemon} opposing the currently switched out Pokemon. - * Unused if {@linkcode selfSwitch} is `true`, as it is only used to check Suction Cups. + * Determines if a Pokémon can be forcibly switched out based on its status and battle conditions. + * @param switchOutTarget - The {@linkcode Pokemon} being switched out. * @returns Whether {@linkcode switchOutTarget} can be switched out by the current Move or Ability. */ - protected canSwitchOut(switchOutTarget: Pokemon, opponent: Pokemon | undefined): boolean { + protected canSwitchOut(switchOutTarget: Pokemon): boolean { const isPlayer = switchOutTarget instanceof PlayerPokemon; - if (!this.selfSwitch && opponent && !this.performOpponentChecks(switchOutTarget, opponent)) { + // If we aren't switching ourself out, ensure the target in question can actually be switched out by us + if (!this.selfSwitch && !this.performForceSwitchChecks(switchOutTarget)) { return false; } + // Wild enemies should not be allowed to flee with baton pass, nor by any means on X0 waves (don't want easy boss wins) if (!isPlayer && globalScene.currentBattle.battleType === BattleType.WILD) { - // enemies should not be allowed to flee with baton pass, nor by any means on X0 waves (don't want easy boss wins) return this.switchType !== SwitchType.BATON_PASS && globalScene.currentBattle.waveIndex % 10 !== 0; } - // Finally, ensure that we have valid switch out targets. - const reservePartyMembers = globalScene.getBackupPartyMembers( + // Finally, ensure that a trainer switching out has at least 1 valid reserve member to send in. + const reservePartyMembers = globalScene.getBackupPartyMemberIndices( isPlayer, - (switchOutTarget as EnemyPokemon).trainerSlot as TrainerSlot | undefined, - ); // evaluates to `undefined` if not present - if (reservePartyMembers.length === 0) { - return false; - } - - return true; + !isPlayer ? (switchOutTarget as EnemyPokemon).trainerSlot : undefined, + ); + return reservePartyMembers.length > 0; } - protected performOpponentChecks(switchOutTarget: Pokemon, opponent: Pokemon): boolean { + /** + * Perform various checks to confirm the switched out target can be forcibly removed from the field + * by another Pokemon. + * @param switchOutTarget - The {@linkcode Pokemon} being switched out + * @returns Whether {@linkcode switchOutTarget} can be switched out by another Pokemon. + */ + private performForceSwitchChecks(switchOutTarget: Pokemon): boolean { // Dondozo with an allied Tatsugiri in its mouth cannot be forced out by enemies const commandedTag = switchOutTarget.getTag(BattlerTagType.COMMANDED); if (commandedTag?.getSourcePokemon()?.isActive(true)) { @@ -70,8 +68,8 @@ export function ForceSwitch(Base: TBase) { // Check for opposing switch block abilities (Suction Cups and co) const blockedByAbility = new BooleanHolder(false); - applyAbAttrs(ForceSwitchOutImmunityAbAttr, opponent, blockedByAbility); - if (!blockedByAbility.value) { + applyAbAttrs(ForceSwitchOutImmunityAbAttr, switchOutTarget, blockedByAbility); + if (blockedByAbility.value) { return false; } @@ -97,7 +95,10 @@ export function ForceSwitch(Base: TBase) { } if (!(switchOutTarget instanceof EnemyPokemon)) { - console.warn("Switched out target not instance of Player or enemy Pokemon!"); + console.warn( + "Switched out target (index %i) neither player nor enemy Pokemon!", + switchOutTarget.getFieldIndex(), + ); return; } @@ -119,12 +120,12 @@ export function ForceSwitch(Base: TBase) { return; } - // Pick a random player pokemon to switch out. - const reservePartyMembers = globalScene.getBackupPartyMembers(true); - const switchOutIndex = switchOutTarget.randSeedInt(reservePartyMembers.length); + // Pick a random eligible player pokemon to replace the switched out one. + const reservePartyMembers = globalScene.getBackupPartyMemberIndices(true); + const switchInIndex = reservePartyMembers[switchOutTarget.randSeedInt(reservePartyMembers.length)]; globalScene.appendToPhase( - new SwitchSummonPhase(this.switchType, switchOutTarget.getFieldIndex(), switchOutIndex, false, true), + new SwitchSummonPhase(this.switchType, switchOutTarget.getFieldIndex(), switchInIndex, false, true), MoveEndPhase, ); } @@ -132,15 +133,16 @@ export function ForceSwitch(Base: TBase) { private trySwitchTrainerPokemon(switchOutTarget: EnemyPokemon): void { // fallback for no trainer if (!globalScene.currentBattle.trainer) { - console.warn("Enemy trainer switch logic approached without a trainer!"); + console.warn("Enemy trainer switch logic triggered without a trainer!"); return; } - // Forced switches will to pick a random eligible pokemon, while - // choice-based switching uses the trainer's default switch behavior - const reservePartyMembers = globalScene.getBackupPartyMembers(false, switchOutTarget.trainerSlot); + + // Forced switches will pick a random eligible pokemon from this trainer's side, while + // choice-based switching uses the trainer's default switch behavior. + const reservePartyIndices = globalScene.getBackupPartyMemberIndices(false, switchOutTarget.trainerSlot); const summonIndex = this.switchType === SwitchType.FORCE_SWITCH - ? switchOutTarget.randSeedInt(reservePartyMembers.length) + ? reservePartyIndices[switchOutTarget.randSeedInt(reservePartyIndices.length)] : (globalScene.currentBattle.trainer.getNextSummonIndex(switchOutTarget.trainerSlot) ?? 0); globalScene.appendToPhase( new SwitchSummonPhase(this.switchType, switchOutTarget.getFieldIndex(), summonIndex, false, false), @@ -180,5 +182,9 @@ export function ForceSwitch(Base: TBase) { public isBatonPass(): boolean { return this.switchType === SwitchType.BATON_PASS; } + + public isForcedSwitch(): boolean { + return this.switchType === SwitchType.FORCE_SWITCH; + } }; } diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 534bdc2fa01..8e642da07d8 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -2002,6 +2002,13 @@ export class FlameBurstAttr extends MoveEffectAttr { } } +/** + * Attribute to KO the user while fully restoring HP/status of the next switched in Pokemon. + * + * Used for {@linkcode Moves.HEALING_WISH} and {@linkcode Moves.LUNAR_DANCE}. + * TODO: Implement "heal storing" if switched in pokemon is at full HP (likely with an end-of-turn ArenaTag). + * Will likely be blocked by the need for a "slot dependent ArenaTag" similar to Future Sight + */ export class SacrificialFullRestoreAttr extends SacrificialAttr { protected restorePP: boolean; protected moveMessage: string; @@ -2019,8 +2026,8 @@ export class SacrificialFullRestoreAttr extends SacrificialAttr { } // We don't know which party member will be chosen, so pick the highest max HP in the party - const party = user.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty(); - const maxPartyMemberHp = party.map(p => p.getMaxHp()).reduce((maxHp: number, hp: number) => Math.max(hp, maxHp), 0); + const party: Pokemon[] = user.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty(); + const maxPartyMemberHp = Math.max(...party.map(p => p.getMaxHp())); globalScene.pushPhase( new PokemonHealPhase( @@ -6210,7 +6217,9 @@ export class RevivalBlessingAttr extends MoveEffectAttr { } } - +/** + * Attribute to forcibly switch out the user or target of a Move. + */ export class ForceSwitchOutAttr extends ForceSwitch(MoveEffectAttr) { constructor( selfSwitch: boolean = false, @@ -6221,54 +6230,67 @@ export class ForceSwitchOutAttr extends ForceSwitch(MoveEffectAttr) { this.switchType = switchType; } - apply(user: Pokemon, target: Pokemon, _move: Move, _args: any[]): boolean { + apply(user: Pokemon, target: Pokemon, move: Move, _args: any[]): boolean { + if (!this.getSwitchOutCondition()(user, target, move)) { + return false; + } this.doSwitch(this.selfSwitch ? user : target) return true; } getCondition(): MoveConditionFunc { - return this.getSwitchOutCondition(); + // Damaging switch moves should not "fail" _per se_ upon a failed switch - + // they still succeed and deal damage (but just without actually switching out). + return (user, target, move) => (move.category !== MoveCategory.STATUS || this.getSwitchOutCondition()(user, target, move)); } + /** + * Check whether the target can be switched out. + * @returns A {@linkcode MoveConditionFunc} that returns `true` if the switch out attempt should succeed. + */ getSwitchOutCondition(): MoveConditionFunc { return (user, target, move) => { - const [switchOutTarget, opponent] = this.selfSwitch ? [user, target] : [target, user]; + const switchOutTarget = this.selfSwitch ? user : target; // Don't allow wild mons to flee with U-turn et al. - if (switchOutTarget instanceof EnemyPokemon && globalScene.currentBattle.battleType === BattleType.WILD && !(this.selfSwitch && move.category !== MoveCategory.STATUS)) { + if ( + switchOutTarget instanceof EnemyPokemon + && globalScene.currentBattle.battleType === BattleType.WILD + && this.selfSwitch + && move.category !== MoveCategory.STATUS + ) { return false; } - return this.canSwitchOut(switchOutTarget, opponent) + // Check for Wimp Out edge case - self-switching moves cannot proc if the attack also triggers Wimp Out/EE + const moveDmgDealt = user.turnData.lastMoveDamageDealt[target.getBattlerIndex()] + if ( + this.selfSwitch + && moveDmgDealt + && target.getAbilityAttrs(PostDamageForceSwitchAbAttr).some( + p => p.canApplyPostDamage(target, moveDmgDealt, false, user, [])) + ) { + return false; + } + + return this.canSwitchOut(switchOutTarget) }; } getUserBenefitScore(user: Pokemon, target: Pokemon, move: Move): number { - if (!globalScene.getEnemyParty().find(p => p.isActive() && !p.isOnField())) { + const reservePartyMembers = globalScene.getBackupPartyMemberIndices(user.isPlayer() === this.selfSwitch, !user.isPlayer() ? (user as EnemyPokemon).trainerSlot : undefined) + if (reservePartyMembers.length === 0) { return -20; } + let ret = this.selfSwitch ? Math.floor((1 - user.getHpRatio()) * 20) : super.getUserBenefitScore(user, target, move); if (this.selfSwitch && this.isBatonPass()) { const statStageTotal = user.getStatStages().reduce((s: number, total: number) => total += s, 0); + // TODO: Why do we use a sine tween? ret = ret / 2 + (Phaser.Tweens.Builders.GetEaseFunction("Sine.easeOut")(Math.min(Math.abs(statStageTotal), 10) / 10) * (statStageTotal >= 0 ? 10 : -10)); } return ret; } - - /** - * Helper function to check if the Pokémon's health is below half after taking damage. - * Used for an edge case interaction with Wimp Out/Emergency Exit. - * If the Ability activates due to being hit by U-turn or Volt Switch, the user of that move will not be switched out. - */ - hpDroppedBelowHalf(target: Pokemon): boolean { - const pokemonHealth = target.hp; - const maxPokemonHealth = target.getMaxHp(); - const damageTaken = target.turnData.damageTaken; - const initialHealth = pokemonHealth + damageTaken; - - // Check if the Pokémon's health has dropped below half after the damage - return initialHealth >= maxPokemonHealth / 2 && pokemonHealth < maxPokemonHealth / 2; - } } export class ChillyReceptionAttr extends ForceSwitchOutAttr { diff --git a/src/data/mystery-encounters/mystery-encounter.ts b/src/data/mystery-encounters/mystery-encounter.ts index 10069045ba7..30b26f81f46 100644 --- a/src/data/mystery-encounters/mystery-encounter.ts +++ b/src/data/mystery-encounters/mystery-encounter.ts @@ -344,12 +344,13 @@ export default class MysteryEncounter implements IMysteryEncounter { */ private meetsPrimaryRequirementAndPrimaryPokemonSelected(): boolean { let qualified: PlayerPokemon[] = globalScene.getPlayerParty(); - if (!this.primaryPokemonRequirements || this.primaryPokemonRequirements.length === 0) { + if (!this.primaryPokemonRequirements?.length) { // If we lack specified criterion, grab the first on-field pokemon, or else the first pokemon allowed in battle const activeMons = qualified.filter(p => p.isAllowedInBattle()); this.primaryPokemon = activeMons.find(p => p.isOnField()) ?? activeMons[0]; return true; } + for (const req of this.primaryPokemonRequirements) { if (req.meetsRequirement()) { qualified = qualified.filter(pkmn => req.queryParty(globalScene.getPlayerParty()).includes(pkmn)); diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 619c862ed0d..f301975cf90 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -4757,7 +4757,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * @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`. + * This will not trigger "on damage" effects for direct damage moves, instead occuring at the end of `MoveEffectPhase`. */ damageAndUpdate(damage: number, { @@ -4782,7 +4782,8 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { ); globalScene.unshiftPhase(damagePhase); - // TODO: Review if wimp out battle skip actually needs this anymore + // Prevent enemies not on field from taking damage. + // TODO: Review if wimp out actually needs this anymore if (this.switchOutStatus) { damage = 0; } @@ -4803,8 +4804,9 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { // Damage amount may have changed, but needed to be queued before calling damage function damagePhase.updateAmount(damage); - // Trigger PostDamageAbAttr (ie wimp out) for indirect damage only. - if (isIndirectDamage) { + // Trigger PostDamageAbAttr (ie wimp out) for indirect, non-confusion damage instances. + // We leave `source` as undefined for indirect hits to specify that the damage instance is indirect. + if (isIndirectDamage && result !== HitResult.CONFUSION) { applyPostDamageAbAttrs( PostDamageAbAttr, this, @@ -5726,8 +5728,8 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * Reset this Pokemon's {@linkcode PokemonSummonData | SummonData} and {@linkcode PokemonTempSummonData | TempSummonData} * in preparation for switching pokemon, as well as removing any relevant on-switch tags. * @remarks - * This **SHOULD NOT** be called when a `SummonPhase` or `SwitchSummonPhase` is already being added, - * both of which call this method (directly or indirectly) on both pokemon changing positions. + * This **SHOULD NOT** be called when {@linkcode leaveField} is already being called, + * which already calls this function. */ resetSummonData(): void { console.log(`resetSummonData called on Pokemon ${this.name}`) @@ -5801,6 +5803,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { } resetTurnData(): void { + console.log(`resetTurnData called on Pokemon ${this.name}`) this.turnData = new PokemonTurnData(); } @@ -6311,7 +6314,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * @param destroy - Whether to destroy this Pokemon once it leaves the field; default `false` * @remarks * This **SHOULD NOT** be called when a `SummonPhase` or `SwitchSummonPhase` is already being added, - * which can lead to erroneous resetting of {@linkcode turnData} or {@linkcode summonData}. + * which can lead to premature resetting of {@klinkcode turnData} and {@linkcode summonData}. */ leaveField(clearEffects = true, hideInfo = true, destroy = false) { console.log(`leaveField called on Pokemon ${this.name}`) @@ -7960,12 +7963,20 @@ export class PokemonTurnData { */ public hitsLeft = -1; /** - * 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. + * The final amount of damage dealt by this Pokemon's last attack against each of its targets, + * mapped by their respective `BattlerIndex`es. + * Reset to an empty array upon attempting to use a move, + * and is used to calculate various damage-related effects (Shell Bell, U-Turn + Wimp Out interactions, etc.). + */ + public lastMoveDamageDealt: number[] = []; + /** + * The amount of damage dealt by this Pokemon's last hit. + * Used to calculate recoil damage amounts. + * TODO: Merge with `lastMoveDamageDealt` if any spread recoil moves are added */ - public lastMoveDamageDealt = 0; public singleHitDamageDealt = 0; - // TODO: Rework this into "damage taken last" counter for metal burst and co. + // TODO: Make this into a "damage taken last" counter for metal burst and co. + // This is currently ONLY USED FOR ASSURANCE public damageTaken = 0; public attacksReceived: AttackMoveResult[] = []; public order: number; diff --git a/src/modifier/modifier.ts b/src/modifier/modifier.ts index 7ed2773d047..e8ff78d6414 100644 --- a/src/modifier/modifier.ts +++ b/src/modifier/modifier.ts @@ -1783,21 +1783,22 @@ export class HitHealModifier extends PokemonHeldItemModifier { * @returns `true` if the {@linkcode Pokemon} was healed */ override apply(pokemon: Pokemon): boolean { - if (pokemon.turnData.lastMoveDamageDealt && !pokemon.isFullHp()) { - // TODO: this shouldn't be undefined AFAIK - globalScene.unshiftPhase( - new PokemonHealPhase( - pokemon.getBattlerIndex(), - toDmgValue(pokemon.turnData.lastMoveDamageDealt / 8) * this.stackCount, - i18next.t("modifier:hitHealApply", { - pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - typeName: this.type.name, - }), - true, - ), - ); + if (pokemon.isFullHp()) { + return false; } + const totalDmgDealt = pokemon.turnData.lastMoveDamageDealt.reduce((r, d) => r + d, 0); + globalScene.unshiftPhase( + new PokemonHealPhase( + pokemon.getBattlerIndex(), + toDmgValue(totalDmgDealt / 8) * this.stackCount, + i18next.t("modifier:hitHealApply", { + pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), + typeName: this.type.name, + }), + true, + ), + ); return true; } diff --git a/src/phases/faint-phase.ts b/src/phases/faint-phase.ts index 1aa24d59fa0..b03b7ec9511 100644 --- a/src/phases/faint-phase.ts +++ b/src/phases/faint-phase.ts @@ -61,8 +61,6 @@ export class FaintPhase extends PokemonPhase { faintPokemon.getTag(BattlerTagType.GRUDGE)?.lapse(faintPokemon, BattlerTagLapseType.CUSTOM, this.source); } - faintPokemon.resetSummonData(); - if (!this.preventInstantRevive) { const instantReviveModifier = globalScene.applyModifier( PokemonInstantReviveModifier, @@ -71,6 +69,7 @@ export class FaintPhase extends PokemonPhase { ) as PokemonInstantReviveModifier; if (instantReviveModifier) { + faintPokemon.resetSummonData(); faintPokemon.loseHeldItem(instantReviveModifier); globalScene.updateModifiers(this.player); return this.end(); diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index 5f1c487f1b8..e96920142e9 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -3,6 +3,7 @@ import { globalScene } from "#app/global-scene"; import { AddSecondStrikeAbAttr, AlwaysHitAbAttr, + applyAbAttrs, applyPostAttackAbAttrs, applyPostDamageAbAttrs, applyPostDefendAbAttrs, @@ -78,6 +79,7 @@ import type Move from "#app/data/moves/move"; import { isFieldTargeted } from "#app/data/moves/move-utils"; import { FaintPhase } from "./faint-phase"; import { DamageAchv } from "#app/system/achv"; +import { userInfo } from "node:os"; type HitCheckEntry = [HitCheckResult, TypeDamageMultiplier]; @@ -95,7 +97,7 @@ export class MoveEffectPhase extends PokemonPhase { /** Is this the first strike of a move? */ private firstHit: boolean; - /** Is this the last strike of a move? */ + /** Is this the last strike of a move (either due to running out of hits or all targets being fainted/immune)? */ private lastHit: boolean; /** Phases queued during moves */ @@ -181,7 +183,6 @@ export class MoveEffectPhase extends PokemonPhase { * Queue the phaes that should occur when the target reflects the move back to the user * @param user - The {@linkcode Pokemon} using this phase's invoked move * @param target - The {@linkcode Pokemon} that is reflecting the move - * */ private queueReflectedMove(user: Pokemon, target: Pokemon): void { const newTargets = this.move.isMultiTarget() @@ -342,7 +343,7 @@ export class MoveEffectPhase extends PokemonPhase { const targets = this.conductHitChecks(user, fieldMove); this.firstHit = user.turnData.hitCount === user.turnData.hitsLeft; - this.lastHit = user.turnData.hitsLeft === 1 || !targets.some(t => t.isActive(true)); + this.lastHit = user.turnData.hitsLeft === 1 || targets.every(t => !t.isActive(true)); // Play the animation if the move was successful against any of its targets or it has a POST_TARGET effect (like self destruct) if ( @@ -766,6 +767,7 @@ export class MoveEffectPhase extends PokemonPhase { * - Triggering form changes and emergency exit / wimp out if this is the last hit * * @param target - the {@linkcode Pokemon} hit by this phase's move. + * @param targetIndex - The index of the target (used to update damage dealt amounts) * @param effectiveness - The effectiveness of the move (as previously evaluated in {@linkcode hitCheck}) * @param firstTarget - Whether this is the first target successfully struck by the move */ @@ -785,7 +787,13 @@ export class MoveEffectPhase extends PokemonPhase { } if (this.lastHit) { globalScene.triggerPokemonFormChange(user, SpeciesFormChangePostMoveTrigger); - applyPostDamageAbAttrs(PostDamageAbAttr, target, target.turnData.lastMoveDamageDealt); + applyPostDamageAbAttrs( + PostDamageAbAttr, + target, + user.turnData.lastMoveDamageDealt[target.getBattlerIndex()], + false, + user, + ); } } @@ -863,7 +871,7 @@ export class MoveEffectPhase extends PokemonPhase { globalScene.gameData.gameStats.highestDamage = Math.max(damage, globalScene.gameData.gameStats.highestDamage); } - user.turnData.lastMoveDamageDealt += damage; + user.turnData.lastMoveDamageDealt[target.getBattlerIndex()] += damage; user.turnData.singleHitDamageDealt = damage; target.battleData.hitCount++; // TODO: this might be incorrect for counter moves diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 3b8405621f2..f1574369ccd 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -43,7 +43,7 @@ import { CommonAnimPhase } from "#app/phases/common-anim-phase"; import { MoveChargePhase } from "#app/phases/move-charge-phase"; import { MoveEffectPhase } from "#app/phases/move-effect-phase"; import { MoveEndPhase } from "#app/phases/move-end-phase"; -import { NumberHolder } from "#app/utils/common"; +import { getEnumValues, NumberHolder } from "#app/utils/common"; import { Abilities } from "#enums/abilities"; import { ArenaTagType } from "#enums/arena-tag-type"; import { BattlerTagType } from "#enums/battler-tag-type"; @@ -160,7 +160,7 @@ export class MovePhase extends BattlePhase { } this.pokemon.turnData.acted = true; - this.pokemon.turnData.lastMoveDamageDealt = 0; + this.pokemon.turnData.lastMoveDamageDealt = Array(Math.max(...getEnumValues(BattlerIndex))).fill(0); // Reset hit-related turn data when starting follow-up moves (e.g. Metronomed moves, Dancer repeats) if (this.followUp) { diff --git a/src/phases/summon-phase.ts b/src/phases/summon-phase.ts index fef9b356348..92ad1e20de3 100644 --- a/src/phases/summon-phase.ts +++ b/src/phases/summon-phase.ts @@ -274,6 +274,7 @@ export class SummonPhase extends PartyMemberPokemonPhase { globalScene.unshiftPhase(new ShinySparklePhase(pokemon.getBattlerIndex())); } + // TODO: This might be a duplicate - check to see if can be removed without breaking things pokemon.resetTurnData(); if ( diff --git a/src/phases/switch-phase.ts b/src/phases/switch-phase.ts index b29641519bd..7bc35e0d61f 100644 --- a/src/phases/switch-phase.ts +++ b/src/phases/switch-phase.ts @@ -37,7 +37,7 @@ export class SwitchPhase extends BattlePhase { super.start(); // Failsafe: skip modal switches if impossible (no eligible party members in reserve). - if (this.isModal && globalScene.getBackupPartyMembers(true).length === 0) { + if (this.isModal && globalScene.getBackupPartyMemberIndices(true).length === 0) { return super.end(); } @@ -52,11 +52,11 @@ export class SwitchPhase extends BattlePhase { return super.end(); } - // Check if there is any space still in field + // Check if there is any space still on field. + // TODO: Do we need this? if ( this.isModal && - globalScene.getPlayerField().filter(p => p.isAllowedInBattle() && p.isActive(true)).length >= - globalScene.currentBattle.getBattlerCount() + globalScene.getPlayerField().filter(p => p.isActive(true)).length > globalScene.currentBattle.getBattlerCount() ) { return super.end(); } diff --git a/src/phases/switch-summon-phase.ts b/src/phases/switch-summon-phase.ts index 1b181bc9a95..445384d96fa 100644 --- a/src/phases/switch-summon-phase.ts +++ b/src/phases/switch-summon-phase.ts @@ -2,18 +2,15 @@ import { globalScene } from "#app/global-scene"; import { applyPreSummonAbAttrs, applyPreSwitchOutAbAttrs, - PostDamageForceSwitchAbAttr, PreSummonAbAttr, PreSwitchOutAbAttr, } from "#app/data/abilities/ability"; -import { allMoves, ForceSwitchOutAttr } from "#app/data/moves/move"; import { getPokeballTintColor } from "#app/data/pokeball"; import { SpeciesFormChangeActiveTrigger } from "#app/data/pokemon-forms"; import { TrainerSlot } from "#enums/trainer-slot"; import type Pokemon from "#app/field/pokemon"; import { getPokemonNameWithAffix } from "#app/messages"; import { SwitchEffectTransferModifier } from "#app/modifier/modifier"; -import { Command } from "#app/ui/command-ui-handler"; import i18next from "i18next"; import { PostSummonPhase } from "./post-summon-phase"; import { SummonPhase } from "./summon-phase"; @@ -74,13 +71,13 @@ export class SwitchSummonPhase extends SummonPhase { return; } - const pokemon = this.getPokemon(); + const lastPokemon = this.getPokemon(); (this.player ? globalScene.getEnemyField() : globalScene.getPlayerField()).forEach(enemyPokemon => - enemyPokemon.removeTagsBySourceId(pokemon.id), + enemyPokemon.removeTagsBySourceId(lastPokemon.id), ); if (this.switchType === SwitchType.SWITCH || this.switchType === SwitchType.INITIAL_SWITCH) { - const substitute = pokemon.getTag(SubstituteTag); + const substitute = lastPokemon.getTag(SubstituteTag); if (substitute) { globalScene.tweens.add({ targets: substitute.sprite, @@ -95,26 +92,26 @@ export class SwitchSummonPhase extends SummonPhase { globalScene.ui.showText( this.player ? i18next.t("battle:playerComeBack", { - pokemonName: getPokemonNameWithAffix(pokemon), + pokemonName: getPokemonNameWithAffix(lastPokemon), }) : i18next.t("battle:trainerComeBack", { trainerName: globalScene.currentBattle.trainer?.getName( !(this.fieldIndex % 2) ? TrainerSlot.TRAINER : TrainerSlot.TRAINER_PARTNER, ), - pokemonName: pokemon.getNameToRender(), + pokemonName: lastPokemon.getNameToRender(), }), ); globalScene.playSound("se/pb_rel"); - pokemon.hideInfo(); - pokemon.tint(getPokeballTintColor(pokemon.getPokeball(true)), 1, 250, "Sine.easeIn"); + lastPokemon.hideInfo(); + lastPokemon.tint(getPokeballTintColor(lastPokemon.getPokeball(true)), 1, 250, "Sine.easeIn"); globalScene.tweens.add({ - targets: pokemon, + targets: lastPokemon, duration: 250, ease: "Sine.easeIn", scale: 0.5, onComplete: () => { globalScene.time.delayedCall(750, () => this.switchAndSummon()); - pokemon.leaveField(this.switchType === SwitchType.SWITCH, false); + lastPokemon.leaveField(this.switchType === SwitchType.SWITCH, false); }, }); } @@ -192,8 +189,6 @@ export class SwitchSummonPhase extends SummonPhase { switchedInPokemon.y += this.lastPokemon.getSubstituteOffset()[1]; switchedInPokemon.setAlpha(0.5); } - } else { - switchedInPokemon.resetSummonData(); } this.summon(); }; @@ -214,26 +209,6 @@ export class SwitchSummonPhase extends SummonPhase { const pokemon = this.getPokemon(); - const moveId = globalScene.currentBattle.lastMove; - const lastUsedMove = moveId ? allMoves[moveId] : undefined; - - const currentCommand = globalScene.currentBattle.turnCommands[this.fieldIndex]?.command; - const lastPokemonIsForceSwitchedAndNotFainted = - lastUsedMove?.hasAttr(ForceSwitchOutAttr) && !this.lastPokemon.isFainted(); - const lastPokemonHasForceSwitchAbAttr = - this.lastPokemon.hasAbilityWithAttr(PostDamageForceSwitchAbAttr) && !this.lastPokemon.isFainted(); - - // Compensate for turn spent summoning/forced switch if switched out pokemon is not fainted. - // Needed as we increment turn counters in `TurnEndPhase`. - if ( - currentCommand === Command.POKEMON || - lastPokemonIsForceSwitchedAndNotFainted || - lastPokemonHasForceSwitchAbAttr - ) { - pokemon.tempSummonData.turnCount--; - pokemon.tempSummonData.waveTurnCount--; - } - if (this.switchType === SwitchType.BATON_PASS && pokemon) { pokemon.transferSummon(this.lastPokemon); } else if (this.switchType === SwitchType.SHED_TAIL && pokemon) { @@ -243,14 +218,17 @@ export class SwitchSummonPhase extends SummonPhase { } } - // Reset turn data if not initial switch (since it gets initialized to an empty object on turn start) + // If not switching at start of battle, reset turn counts and temp data. + // Needed as we increment turn counters in `TurnEndPhase`. if (this.switchType !== SwitchType.INITIAL_SWITCH) { + pokemon.tempSummonData.turnCount--; + pokemon.tempSummonData.waveTurnCount--; + // No need to reset turn/summon data for initial switch since both get initialized to an empty object on object creation pokemon.resetTurnData(); + pokemon.resetSummonData(); pokemon.turnData.switchedInThisTurn = true; } - this.lastPokemon.resetSummonData(); - globalScene.triggerPokemonFormChange(pokemon, SpeciesFormChangeActiveTrigger, true); // Reverts to weather-based forms when weather suppressors (Cloud Nine/Air Lock) are switched out globalScene.arena.triggerWeatherBasedFormChanges(); diff --git a/src/utils/common.ts b/src/utils/common.ts index 4cf7ceccff2..bb1af026752 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -566,3 +566,55 @@ export function animationFileName(move: Moves): string { export function camelCaseToKebabCase(str: string): string { return str.replace(/[A-Z]+(?![a-z])|[A-Z]/g, (s, o) => (o ? "-" : "") + s.toLowerCase()); } + +/** + * Split an array into a pair of arrays based on a conditional function. + * @param array - The array to split into 2. + * @param predicate - A function accepting up to 3 arguments. The split function calls the predicate function once per element of the array. + * @param thisArg - An object to which the this keyword can refer in the predicate function. If thisArg is omitted, `undefined` is used as the this value. + * @returns A pair of shallowly-copied arrays containing every element for which `predicate` did or did not return a value coercible to the boolean `true`. + */ +export function splitArray( + array: T[], + predicate: (value: T, index: number, array: T[]) => value is S, + thisArg?: unknown, +): [matches: S[], nonMatches: S[]]; + +/** + * Split an array into a pair of arrays based on a conditional function. + * @param array - The array to split into 2. + * @param predicate - A function accepting up to 3 arguments. The split function calls the function once per element of the array. + * @param thisArg - An object to which the this keyword can refer in the predicate function. If thisArg is omitted, `undefined` is used as the this value. + * @returns A pair of shallowly-copied arrays containing every element for which `predicate` did or did not return a value coercible to the boolean `true`. + */ +export function splitArray( + array: T[], + predicate: (value: T, index: number, array: T[]) => unknown, + thisArg?: unknown, +): [matches: T[], nonMatches: T[]]; + +/** + * Split an array into a pair of arrays based on a conditional function. + * @param array - The array to split into 2. + * @param predicate - A function accepting up to 3 arguments. The split function calls the function once per element of the array. + * @param thisArg - An object to which the this keyword can refer in the predicate function. If thisArg is omitted, `undefined` is used as the this value. + * @returns A pair of shallowly-copied arrays containing every element for which `predicate` did or did not return a value coercible to the boolean `true`. + */ +export function splitArray( + array: T[], + predicate: (value: T, index: number, array: T[]) => unknown, + thisArg?: any, +): [matches: T[], nonMatches: T[]] { + const matches: T[] = []; + const nonMatches: T[] = []; + + const p = predicate.bind(thisArg) as typeof predicate; + array.forEach((val, index, ar) => { + if (p(val, index, ar)) { + matches.push(val); + } else { + nonMatches.push(val); + } + }); + return [matches, nonMatches]; +} diff --git a/test/abilities/wimp_out.test.ts b/test/abilities/wimp_out.test.ts index 344b7b22a0d..c886cf8de8b 100644 --- a/test/abilities/wimp_out.test.ts +++ b/test/abilities/wimp_out.test.ts @@ -8,11 +8,12 @@ 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"; +import { BattleType } from "#enums/battle-type"; +import { HitResult } from "#app/field/pokemon"; +import type { ModifierOverride } from "#app/modifier/modifier-type"; describe("Abilities - Wimp Out", () => { let phaserGame: Phaser.Game; @@ -37,7 +38,7 @@ describe("Abilities - Wimp Out", () => { .enemyPassiveAbility(Abilities.NO_GUARD) .startingLevel(90) .enemyLevel(70) - .moveset([Moves.SPLASH, Moves.FALSE_SWIPE, Moves.ENDURE, Moves.THUNDER_PUNCH]) + .moveset([Moves.SPLASH, Moves.FALSE_SWIPE, Moves.ENDURE, Moves.GUILLOTINE]) .enemyMoveset(Moves.FALSE_SWIPE) .disableCrits(); }); @@ -72,16 +73,17 @@ describe("Abilities - Wimp Out", () => { const wimpod = game.scene.getPlayerPokemon()!; wimpod.hp *= 0.52; - game.move.select(Moves.THUNDER_PUNCH); + game.move.select(Moves.SPLASH); game.doSelectPartyPokemon(1); await game.phaseInterceptor.to("TurnEndPhase"); - // Wimpod switched out after taking a hit, canceling its upcoming MovePhase before it could attack + // Wimpod switched out after taking a hit, canceling its upcoming MoveEffectPhase 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, ); + expect(wimpod.turnData.acted).toBe(false); }); it("should not trigger if user faints from damage", async () => { @@ -91,12 +93,12 @@ describe("Abilities - Wimp Out", () => { const wimpod = game.scene.getPlayerPokemon()!; wimpod.hp *= 0.52; - game.move.select(Moves.THUNDER_PUNCH); + game.move.select(Moves.SPLASH); game.doSelectPartyPokemon(1); await game.phaseInterceptor.to("TurnEndPhase"); expect(wimpod.isFainted()).toBe(true); - confirmNoSwitch(); + expect(wimpod.waveData.abilitiesApplied).not.toContain(Abilities.WIMP_OUT); }); it("should trigger regenerator passive when switching out", async () => { @@ -128,15 +130,15 @@ describe("Abilities - Wimp Out", () => { expect(!isVisible && hasFled).toBe(true); }); - it("should not trigger when HP already below half", async () => { + it("should not trigger if HP already below half", async () => { await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]); const wimpod = game.scene.getPlayerPokemon()!; - wimpod.hp = 5; + wimpod.hp *= 0.1; game.move.select(Moves.SPLASH); await game.phaseInterceptor.to("TurnEndPhase"); - expect(wimpod.hp).toEqual(1); + expect(wimpod.getHpRatio()).toBeLessThan(0.1); confirmNoSwitch(); }); @@ -155,8 +157,35 @@ describe("Abilities - Wimp Out", () => { confirmSwitch(); }); + // TODO: Enable when dynamic speed order happens + it.todo("should trigger separately for each Pokemon hit in speed order", async () => { + game.override.battleStyle("double").enemyLevel(600).enemyMoveset(Moves.DRAGON_ENERGY); + await game.classicMode.startBattle([Species.WIMPOD, Species.GOLISOPOD, Species.TYRANITAR, Species.KINGAMBIT]); + + // Golisopod switches out, Wimpod switches back in immediately afterwards + game.move.select(Moves.ENDURE, BattlerIndex.PLAYER); + game.move.select(Moves.ENDURE, BattlerIndex.PLAYER_2); + game.doSelectPartyPokemon(3); + game.doSelectPartyPokemon(3); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(game.scene.getPlayerParty().map(p => p.species.speciesId)).toBe([ + Species.TYRANITAR, + Species.WIMPOD, + Species.KINGAMBIT, + Species.GOLISOPOD, + ]); + + // Ttar and Kingambit should be at full HP; wimpod and golisopod should not + // Ttar and Wimpod should be on field; kingambit and golisopod should not + game.scene.getPlayerParty().forEach((p, i) => { + expect(p.isOnField()).toBe(i < 2); + expect(p.isFullHp()).toBe(i % 2 === 1); + }); + }); + it("should block U-turn or Volt Switch on activation", async () => { - game.override.startingLevel(95).enemyMoveset([Moves.U_TURN]); + game.override.enemyMoveset(Moves.U_TURN); await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]); game.move.select(Moves.SPLASH); @@ -170,12 +199,20 @@ describe("Abilities - Wimp Out", () => { }); it("should not block U-turn or Volt Switch if not activated", async () => { - game.override.startingLevel(190).startingWave(8).enemyMoveset([Moves.U_TURN]); + game.override.enemyMoveset(Moves.U_TURN).battleType(BattleType.TRAINER); await game.classicMode.startBattle([Species.GOLISOPOD, Species.TYRUNT]); - const RIVAL_NINJASK1 = game.scene.getEnemyPokemon()?.id; + const ninjask1 = game.scene.getEnemyPokemon()!; + + vi.spyOn(game.scene.getPlayerPokemon()!, "getAttackDamage").mockReturnValue({ + cancelled: false, + damage: 1, + result: HitResult.EFFECTIVE, + }); + game.move.select(Moves.SPLASH); - await game.phaseInterceptor.to("BerryPhase", false); - expect(game.scene.getEnemyPokemon()?.id !== RIVAL_NINJASK1); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(ninjask1.isOnField()).toBe(true); }); it("should not activate from Dragon Tail and Circle Throw", async () => { @@ -185,7 +222,6 @@ describe("Abilities - Wimp Out", () => { const wimpod = game.scene.getPlayerPokemon()!; game.move.select(Moves.SPLASH); - game.doSelectPartyPokemon(1); await game.phaseInterceptor.to("SwitchSummonPhase", false); expect(wimpod.waveData.abilitiesApplied).not.toContain(Abilities.WIMP_OUT); @@ -193,42 +229,78 @@ describe("Abilities - Wimp Out", () => { await game.phaseInterceptor.to("TurnEndPhase"); expect(game.scene.getPlayerPokemon()!.species.speciesId).not.toBe(Species.WIMPOD); + // Force switches directly call `SwitchSummonPhase` to send in a random opponent, + // as opposed to `SwitchPhase` which allows for player choice + expect(game.phaseInterceptor.log).not.toContain("SwitchPhase"); }); - 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 }) => { + it.each<{ type: string; playerMove?: Moves; playerPassive?: Abilities; enemyMove?: Moves; enemyAbility?: Abilities }>( + [ + { type: "variable recoil moves", playerMove: Moves.HEAD_SMASH }, + { type: "HP-based recoil moves", playerMove: Moves.CHLOROBLAST }, + { type: "weather", enemyMove: Moves.HAIL }, + { type: "status", enemyMove: Moves.TOXIC }, + { type: "Ghost-type Curse", enemyMove: Moves.CURSE }, + { type: "Salt Cure", enemyMove: Moves.SALT_CURE }, + { type: "partial trapping moves", enemyMove: Moves.WHIRLPOOL }, // no guard passive makes this guaranteed + { type: "Leech Seed", enemyMove: Moves.LEECH_SEED }, + { type: "Powder", playerMove: Moves.EMBER, enemyMove: Moves.POWDER }, + { type: "Nightmare", playerPassive: Abilities.COMATOSE, enemyMove: Moves.NIGHTMARE }, + { type: "Bad Dreams", playerPassive: Abilities.COMATOSE, enemyAbility: Abilities.BAD_DREAMS }, + ], + )( + "should activate from damage caused by $type", + async ({ + playerMove = Moves.SPLASH, + playerPassive = Abilities.NONE, + enemyMove = Moves.SPLASH, + enemyAbility = Abilities.BALL_FETCH, + }) => { game.override - .passiveAbility(Abilities.COMATOSE) + .moveset(playerMove) + .passiveAbility(playerPassive) .enemySpecies(Species.GASTLY) .enemyMoveset(enemyMove) - .enemyAbility(enemyAbility) - .enemyLevel(1); + .enemyAbility(enemyAbility); await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]); const wimpod = game.scene.getPlayerPokemon()!; expect(wimpod).toBeDefined(); - wimpod.hp *= 0.55; + wimpod.hp = toDmgValue(wimpod.getMaxHp() / 2 + 5); + // mock enemy attack damage func to only do 1 dmg (for whirlpool) + vi.spyOn(wimpod, "getAttackDamage").mockReturnValueOnce({ + cancelled: false, + result: HitResult.EFFECTIVE, + damage: 1, + }); - game.move.select(Moves.THUNDER_PUNCH); + game.move.select(playerMove); game.doSelectPartyPokemon(1); - await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); - await game.phaseInterceptor.to("TurnEndPhase"); + await game.toNextTurn(); confirmSwitch(); }, ); + it.each<[name: string, ability: Abilities]>([ + ["Innards Out", Abilities.INNARDS_OUT], + ["Aftermath", Abilities.AFTERMATH], + ["Rough Skin", Abilities.ROUGH_SKIN], + ])("should trigger after taking damage from %s ability", async (_, ability) => { + game.override.enemyAbility(ability).enemyMoveset(Moves.SPLASH); + await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]); + + const wimpod = game.scene.getPlayerPokemon()!; + wimpod.hp *= 0.51; + game.scene.getEnemyPokemon()!.hp = wimpod.hp - 1; // Ensure innards out doesn't KO + + game.move.select(Moves.GUILLOTINE); + game.doSelectPartyPokemon(1); + await game.toNextWave(); + + confirmSwitch(); + }); + 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]); @@ -241,18 +313,7 @@ describe("Abilities - Wimp Out", () => { confirmNoSwitch(); }); - 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.move.select(Moves.HEAD_SMASH); - game.doSelectPartyPokemon(1); - await game.phaseInterceptor.to("TurnEndPhase"); - - confirmSwitch(); - }); - - it("should trigger from Flame Burst ally damage in doubles", async () => { + it("should trigger from Flame Burst splash damage in doubles", async () => { game.override.battleStyle("double").enemyMoveset([Moves.FLAME_BURST, Moves.SPLASH]); await game.classicMode.startBattle([Species.WIMPOD, Species.ZYGARDE, Species.TYRUNT]); @@ -267,20 +328,36 @@ describe("Abilities - Wimp Out", () => { game.doSelectPartyPokemon(2); await game.phaseInterceptor.to("TurnEndPhase"); - confirmSwitch(); + expect(wimpod.isOnField()).toBe(false); + expect(wimpod.getHpRatio()).toBeLessThan(0.5); }); - it("should not activate when the Pokémon cuts its own HP", async () => { - game.override.moveset(Moves.SUBSTITUTE).enemyMoveset(Moves.SPLASH); + it("should not activate when the Pokémon cuts its own HP below half", async () => { + game.override.moveset(Moves.SUBSTITUTE).enemyMoveset([Moves.TIDY_UP, Moves.ROUND]); await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]); + // Turn 1: Substitute knocks below half; no switch const wimpod = game.scene.getPlayerPokemon()!; wimpod.hp *= 0.52; game.move.select(Moves.SUBSTITUTE); - await game.phaseInterceptor.to("TurnEndPhase"); + await game.forceEnemyMove(Moves.TIDY_UP); + game.doSelectPartyPokemon(1); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.toNextTurn(); confirmNoSwitch(); + + // Turn 2: get back enough HP that substitute doesn't put us under + wimpod.hp = wimpod.getMaxHp() * 0.8; + + game.move.select(Moves.SUBSTITUTE); + game.doSelectPartyPokemon(1); + await game.forceEnemyMove(Moves.ROUND); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.phaseInterceptor.to("TurnEndPhase"); + + confirmSwitch(); }); it("should not trigger when neutralized", async () => { @@ -295,9 +372,9 @@ describe("Abilities - Wimp Out", () => { it("should disregard Shell Bell recovery while still activating it before switching", async () => { game.override - .moveset([Moves.DOUBLE_EDGE]) + .moveset(Moves.DOUBLE_EDGE) .enemyMoveset([Moves.SPLASH]) - .startingHeldItems([{ name: "SHELL_BELL", count: 4 }]); + .startingHeldItems([{ name: "SHELL_BELL", count: 4 }]); // heals 50% of damage dealt, more than recoil takes away await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]); const wimpod = game.scene.getPlayerPokemon()!; @@ -307,29 +384,34 @@ describe("Abilities - Wimp Out", () => { game.doSelectPartyPokemon(1); 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); + // Wimp out activated before shell bell healing + expect(wimpod.getHpRatio()).toBeGreaterThan(0.5); + confirmSwitch(); + expect(game.phaseInterceptor.log).toContain("PokemonHealPhase"); + }); + + 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); + await game.classicMode.startBattle([Species.TYRUNT]); + + expect(game.phaseInterceptor.log).not.toContain("MovePhase"); + expect(game.phaseInterceptor.log).toContain("BattleEndPhase"); }); 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 - .passiveAbility(Abilities.MAGIC_GUARD) - .enemyMoveset([Moves.LEECH_SEED]) - .weather(WeatherType.HAIL) - .statusEffect(StatusEffect.POISON); + game.override.passiveAbility(Abilities.MAGIC_GUARD).enemyMoveset(Moves.LEECH_SEED); await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]); - game.scene.getPlayerPokemon()!.hp *= 0.51; + + const wimpod = game.scene.getPlayerPokemon()!; + wimpod.hp *= 0.51; game.move.select(Moves.SPLASH); - await game.phaseInterceptor.to("TurnEndPhase"); + await game.toNextTurn(); - expect(game.scene.getPlayerParty()[0].getHpRatio()).toEqual(0.51); - expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); - expect(game.scene.getPlayerPokemon()!.species.speciesId).toBe(Species.WIMPOD); + confirmNoSwitch(); + expect(wimpod.getHpRatio()).toBeCloseTo(0.51); }); it("should not cancel a double battle on activation", async () => { @@ -352,17 +434,7 @@ describe("Abilities - Wimp Out", () => { expect(enemySecPokemon.hp).toEqual(enemySecPokemon.getMaxHp()); }); - 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); - await game.classicMode.startBattle([Species.TYRUNT]); - - expect(game.phaseInterceptor.log).not.toContain("MovePhase"); - expect(game.phaseInterceptor.log).toContain("BattleEndPhase"); - }); - - it("triggers status on the wimp out user before a new pokemon is switched in", async () => { + it("triggers move effects on the wimp out user before switching", 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); @@ -371,92 +443,64 @@ describe("Abilities - Wimp Out", () => { game.doSelectPartyPokemon(1); await game.phaseInterceptor.to("TurnEndPhase"); - expect(game.scene.getPlayerParty()[1].status?.effect).toEqual(StatusEffect.POISON); confirmSwitch(); + expect(game.scene.getPlayerParty()[1].status?.effect).toBe(StatusEffect.POISON); }); - 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]); + it.each<{ type: string; move?: Moves; ability?: Abilities; items?: ModifierOverride[] }>([ + { type: "normal", move: Moves.DUAL_CHOP }, + { type: "Parental Bond", ability: Abilities.PARENTAL_BOND }, + { type: "Multi Lens", items: [{ name: "MULTI_LENS", count: 1 }] }, + ])( + "should trigger after the last hit of $type multi-strike moves", + async ({ move = Moves.TACKLE, ability = Abilities.COMPOUND_EYES, items = [] }) => { + game.override.enemyMoveset(move).enemyAbility(ability).enemyHeldItems(items); + await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]); - game.scene.getPlayerPokemon()!.hp *= 0.51; + const wimpod = game.scene.getPlayerPokemon()!; + wimpod.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 moves from 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("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 - - while (playerPokemon.getHpRatio() > 0.49) { - game.move.select(Moves.SWORDS_DANCE); + 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(); + + // Switch triggered after the MEPs for both hits finished + const phaseLogs = game.phaseInterceptor.log; + expect(phaseLogs.filter(l => l === "MoveEffectPhase")).toHaveLength(3); // 1 for endure + 2 for dual hit + expect(phaseLogs.lastIndexOf("SwitchSummonPhase")).toBeGreaterThan(phaseLogs.lastIndexOf("MoveEffectPhase")); + }, + ); + + it("should not activate from confusion damage", async () => { + game.override.enemyMoveset(Moves.CONFUSE_RAY).confusionActivation(true); + await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]); + + const wimpod = game.scene.getPlayerPokemon()!; + wimpod.hp *= 0.51; + + game.move.select(Moves.SPLASH); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.phaseInterceptor.to("TurnEndPhase"); confirmNoSwitch(); }); it("should not activate on wave X0 bosses", async () => { - game.override.enemyAbility(Abilities.WIMP_OUT).startingLevel(5850).startingWave(10); + game.override.enemyAbility(Abilities.WIMP_OUT).startingLevel(5850).startingWave(10).enemyHealthSegments(3); await game.classicMode.startBattle([Species.GOLISOPOD]); const enemyPokemon = game.scene.getEnemyPokemon()!; - // Use 2 turns of False Swipe due to opponent's health bar shield - game.move.select(Moves.FALSE_SWIPE); - await game.toNextTurn(); game.move.select(Moves.FALSE_SWIPE); await game.toNextTurn(); - const isVisible = enemyPokemon.visible; - const hasFled = enemyPokemon.switchOutStatus; - expect(isVisible && !hasFled).toBe(true); + expect(enemyPokemon.visible).toBe(true); + expect(enemyPokemon.switchOutStatus).toBe(false); }); it("should not skip battles when triggered in a double battle", async () => { diff --git a/test/items/reviver_seed.test.ts b/test/items/reviver_seed.test.ts index c109794d3d2..707b9df278a 100644 --- a/test/items/reviver_seed.test.ts +++ b/test/items/reviver_seed.test.ts @@ -106,9 +106,10 @@ describe("Items - Reviver Seed", () => { // Self-damage tests it.each([ - { moveType: "Recoil", move: Moves.DOUBLE_EDGE }, + { moveType: "Relative Recoil", move: Moves.DOUBLE_EDGE }, + { moveType: "HP% Recoil", move: Moves.CHLOROBLAST }, { moveType: "Self-KO", move: Moves.EXPLOSION }, - { moveType: "Self-Deduction", move: Moves.CURSE }, + { moveType: "Ghost-type Curse", move: Moves.CURSE }, { moveType: "Liquid Ooze", move: Moves.GIGA_DRAIN }, ])("should not activate the holder's reviver seed from $moveType", async ({ move }) => { game.override diff --git a/test/moves/dragon_tail.test.ts b/test/moves/dragon_tail.test.ts index 31e5560d4e0..98b1436ffb8 100644 --- a/test/moves/dragon_tail.test.ts +++ b/test/moves/dragon_tail.test.ts @@ -1,8 +1,6 @@ import { BattlerIndex } from "#app/battle"; import { allMoves } from "#app/data/moves/move"; -import { Status } from "#app/data/status-effect"; import { Challenges } from "#enums/challenges"; -import { StatusEffect } from "#enums/status-effect"; import { PokemonType } from "#enums/pokemon-type"; import { Abilities } from "#enums/abilities"; import { Moves } from "#enums/moves"; @@ -10,6 +8,11 @@ import { Species } from "#enums/species"; import GameManager from "#test/testUtils/gameManager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { BattleType } from "#enums/battle-type"; +import { TrainerSlot } from "#enums/trainer-slot"; +import { TrainerType } from "#enums/trainer-type"; +import { splitArray } from "#app/utils/common"; +import { BattlerTagType } from "#enums/battler-tag-type"; describe("Moves - Dragon Tail", () => { let phaserGame: Phaser.Game; @@ -38,23 +41,6 @@ describe("Moves - Dragon Tail", () => { vi.spyOn(allMoves[Moves.DRAGON_TAIL], "accuracy", "get").mockReturnValue(100); }); - it("should cause opponent to flee, and not crash", async () => { - await game.classicMode.startBattle([Species.DRATINI]); - - const enemyPokemon = game.scene.getEnemyPokemon()!; - - game.move.select(Moves.DRAGON_TAIL); - - await game.phaseInterceptor.to("BerryPhase"); - - const isVisible = enemyPokemon.visible; - const hasFled = enemyPokemon.switchOutStatus; - expect(!isVisible && hasFled).toBe(true); - - // simply want to test that the game makes it this far without crashing - await game.phaseInterceptor.to("BattleEndPhase"); - }); - it("should cause opponent to flee, display ability, and not crash", async () => { game.override.enemyAbility(Abilities.ROUGH_SKIN); await game.classicMode.startBattle([Species.DRATINI]); @@ -68,7 +54,8 @@ describe("Moves - Dragon Tail", () => { const isVisible = enemyPokemon.visible; const hasFled = enemyPokemon.switchOutStatus; - expect(!isVisible && hasFled).toBe(true); + expect(isVisible).toBe(false); + expect(hasFled).toBe(true); expect(leadPokemon.hp).toBeLessThan(leadPokemon.getMaxHp()); }); @@ -101,6 +88,47 @@ describe("Moves - Dragon Tail", () => { expect(enemySecPokemon.hp).toBeLessThan(enemySecPokemon.getMaxHp()); }); + it("should force trainers to switch randomly without selecting from a partner's party", async () => { + game.override + .battleStyle("double") + .enemyMoveset(Moves.SPLASH) + .enemyAbility(Abilities.STURDY) + .battleType(BattleType.TRAINER) + .randomTrainer({ trainerType: TrainerType.TATE, alwaysDouble: true }) + .enemySpecies(0); + await game.classicMode.startBattle([Species.WIMPOD, Species.TYRANITAR]); + + // Grab each trainer's pokemon based on species name + const [tateParty, lizaParty] = splitArray( + game.scene.getEnemyParty(), + pkmn => pkmn.trainerSlot === TrainerSlot.TRAINER, + ).map(a => a.map(p => p.species.name)); + + expect(tateParty).not.toEqual(lizaParty); + + // Force enemy trainers to switch to the first mon available. + // Due to how enemy trainer parties are laid out, this prevents false positives + // as Tate's pokemon are placed immediately before Liza's corresponding members. + vi.fn(Phaser.Math.RND.integerInRange).mockImplementation(min => min); + + // Spy on the function responsible for making informed switches + const choiceSwitchSpy = vi.spyOn(game.scene.currentBattle.trainer!, "getNextSummonIndex"); + + game.move.select(Moves.DRAGON_TAIL, BattlerIndex.PLAYER, BattlerIndex.ENEMY_2); + game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2); + await game.phaseInterceptor.to("BerryPhase"); + + const [tatePartyNew, lizaPartyNew] = splitArray( + game.scene.getEnemyParty(), + pkmn => pkmn.trainerSlot === TrainerSlot.TRAINER, + ).map(a => a.map(p => p.species.name)); + + // Forced switch move should have switched Liza's Pokemon with another one of her own at random + expect(tatePartyNew).toEqual(tateParty); + expect(lizaPartyNew).not.toEqual(lizaParty); + expect(choiceSwitchSpy).not.toHaveBeenCalled(); + }); + it("should redirect targets upon opponent flee", async () => { game.override.battleStyle("double").enemyMoveset(Moves.SPLASH).enemyAbility(Abilities.ROUGH_SKIN); await game.classicMode.startBattle([Species.DRATINI, Species.DRATINI, Species.WAILORD, Species.WAILORD]); @@ -128,7 +156,7 @@ describe("Moves - Dragon Tail", () => { expect(enemySecPokemon.hp).toBeLessThan(enemySecPokemon.getMaxHp()); }); - it("doesn't switch out if the target has suction cups", async () => { + it("should not switch out a target with suction cups", async () => { game.override.enemyAbility(Abilities.SUCTION_CUPS); await game.classicMode.startBattle([Species.REGIELEKI]); @@ -137,9 +165,25 @@ describe("Moves - Dragon Tail", () => { game.move.select(Moves.DRAGON_TAIL); await game.phaseInterceptor.to("TurnEndPhase"); + expect(enemy.isOnField()).toBe(true); expect(enemy.isFullHp()).toBe(false); }); + it("should not switch out a Commanded Dondozo", async () => { + game.override.battleStyle("double").enemySpecies(Species.DONDOZO); + await game.classicMode.startBattle([Species.REGIELEKI]); + + // pretend dondozo 2 commanded dondozo 1 (silly I know, but it works) + const [dondozo1, dondozo2] = game.scene.getEnemyField(); + dondozo1.addTag(BattlerTagType.COMMANDED, 1, Moves.NONE, dondozo2.id); + + game.move.select(Moves.DRAGON_TAIL); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(dondozo1.isOnField()).toBe(true); + expect(dondozo1.isFullHp()).toBe(false); + }); + it("should force a switch upon fainting an opponent normally", async () => { game.override.startingWave(5).startingLevel(1000); // To make sure Dragon Tail KO's the opponent await game.classicMode.startBattle([Species.DRATINI]); @@ -227,81 +271,55 @@ describe("Moves - Dragon Tail", () => { expect(charmander.getInverseHp()).toBeGreaterThan(0); }); - it("should not force a switch to a challenge-ineligible Pokemon", async () => { + it("should not force a switch to a fainted or challenge-ineligible Pokemon", async () => { game.override.enemyMoveset(Moves.DRAGON_TAIL).startingLevel(100).enemyLevel(1); // Mono-Water challenge, Eevee is ineligible game.challengeMode.addChallenge(Challenges.SINGLE_TYPE, PokemonType.WATER + 1, 0); await game.challengeMode.startBattle([Species.LAPRAS, Species.EEVEE, Species.TOXAPEX, Species.PRIMARINA]); const [lapras, eevee, toxapex, primarina] = game.scene.getPlayerParty(); + expect(toxapex).toBeDefined(); - // Turn 1: Mock an RNG call that would normally call for switching to Eevee, but it is ineligible + // Mock an RNG call to switch to the first eligible pokemon. + // Eevee is ineligible and Toxapex is fainted, so it should proc on Primarina instead vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => { return min; }); game.move.select(Moves.SPLASH); + await game.killPokemon(toxapex); await game.toNextTurn(); expect(lapras.isOnField()).toBe(false); expect(eevee.isOnField()).toBe(false); - expect(toxapex.isOnField()).toBe(true); - expect(primarina.isOnField()).toBe(false); + expect(toxapex.isOnField()).toBe(false); + expect(primarina.isOnField()).toBe(true); expect(lapras.getInverseHp()).toBeGreaterThan(0); }); - it("should not force a switch to a fainted Pokemon", async () => { - game.override.enemyMoveset([Moves.SPLASH, Moves.DRAGON_TAIL]).startingLevel(100).enemyLevel(1); - await game.classicMode.startBattle([Species.LAPRAS, Species.EEVEE, Species.TOXAPEX, Species.PRIMARINA]); + it("should deal damage without switching if there are no available backup Pokemon to switch into", async () => { + game.override.enemyMoveset(Moves.DRAGON_TAIL).battleStyle("double").startingLevel(100).enemyLevel(1); + // Mono-Water challenge + game.challengeMode.addChallenge(Challenges.SINGLE_TYPE, PokemonType.WATER + 1, 0); + await game.challengeMode.startBattle([Species.LAPRAS, Species.KYOGRE, Species.EEVEE, Species.CLOYSTER]); - const [lapras, eevee, toxapex, primarina] = game.scene.getPlayerParty(); + const [lapras, kyogre, eevee, cloyster] = game.scene.getPlayerParty(); + expect(cloyster).toBeDefined(); - // Turn 1: Eevee faints - eevee.hp = 0; - eevee.status = new Status(StatusEffect.FAINT); - expect(eevee.isFainted()).toBe(true); - game.move.select(Moves.SPLASH); - await game.forceEnemyMove(Moves.SPLASH); - await game.toNextTurn(); - - // Turn 2: Mock an RNG call that would normally call for switching to Eevee, but it is fainted - vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => { - return min; - }); - game.move.select(Moves.SPLASH); - await game.forceEnemyMove(Moves.DRAGON_TAIL); - await game.toNextTurn(); - - expect(lapras.isOnField()).toBe(false); - expect(eevee.isOnField()).toBe(false); - expect(toxapex.isOnField()).toBe(true); - expect(primarina.isOnField()).toBe(false); - expect(lapras.getInverseHp()).toBeGreaterThan(0); - }); - - it("should not force a switch if there are no available Pokemon to switch into", async () => { - game.override.enemyMoveset([Moves.SPLASH, Moves.DRAGON_TAIL]).startingLevel(100).enemyLevel(1); - await game.classicMode.startBattle([Species.LAPRAS, Species.EEVEE]); - - const [lapras, eevee] = game.scene.getPlayerParty(); - - // Turn 1: Eevee faints - eevee.hp = 0; - eevee.status = new Status(StatusEffect.FAINT); - expect(eevee.isFainted()).toBe(true); - game.move.select(Moves.SPLASH); - await game.forceEnemyMove(Moves.SPLASH); - await game.toNextTurn(); - - // Turn 2: Mock an RNG call that would normally call for switching to Eevee, but it is fainted - vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((_range, min = 0) => { - return min; - }); - game.move.select(Moves.SPLASH); - await game.forceEnemyMove(Moves.DRAGON_TAIL); + game.move.select(Moves.SPLASH, BattlerIndex.PLAYER); + game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2); + await game.forceEnemyMove(Moves.DRAGON_TAIL, BattlerIndex.PLAYER); + await game.forceEnemyMove(Moves.DRAGON_TAIL, BattlerIndex.PLAYER_2); + await game.killPokemon(cloyster); await game.toNextTurn(); + // Eevee is ineligble due to challenge and cloyster is fainted, leaving no backup pokemon able to switch in expect(lapras.isOnField()).toBe(true); + expect(kyogre.isOnField()).toBe(true); expect(eevee.isOnField()).toBe(false); + expect(cloyster.isOnField()).toBe(false); expect(lapras.getInverseHp()).toBeGreaterThan(0); + expect(kyogre.getInverseHp()).toBeGreaterThan(0); + expect(game.scene.getBackupPartyMemberIndices(true)).toHaveLength(0); + expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); }); }); diff --git a/test/moves/powder.test.ts b/test/moves/powder.test.ts index e665b5a47db..e3dcffb13fc 100644 --- a/test/moves/powder.test.ts +++ b/test/moves/powder.test.ts @@ -7,9 +7,9 @@ import { Species } from "#enums/species"; import { BerryPhase } from "#app/phases/berry-phase"; import { MoveResult, PokemonMove } from "#app/field/pokemon"; import { PokemonType } from "#enums/pokemon-type"; -import { MoveEffectPhase } from "#app/phases/move-effect-phase"; import { StatusEffect } from "#enums/status-effect"; import { BattlerIndex } from "#app/battle"; +import { toDmgValue } from "#app/utils/common"; describe("Moves - Powder", () => { let phaserGame: Phaser.Game; @@ -168,18 +168,13 @@ describe("Moves - Powder", () => { game.move.select(Moves.FIERY_DANCE, 0, BattlerIndex.ENEMY); game.move.select(Moves.POWDER, 1, BattlerIndex.ENEMY); - await game.phaseInterceptor.to(MoveEffectPhase); - const enemyStartingHp = enemyPokemon.hp; - - await game.phaseInterceptor.to(BerryPhase, false); + await game.phaseInterceptor.to("BerryPhase", false); // player should not take damage expect(enemyPokemon.getLastXMoves()[0].result).toBe(MoveResult.FAIL); 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.lastMoveDamageDealt - 2 * Math.floor(enemyPokemon.getMaxHp() / 4), - ); + expect(enemyPokemon.hp).toBeLessThan(2 * toDmgValue(enemyPokemon.getMaxHp() / 4)); }); it("should cancel Fiery Dance, then prevent it from triggering Dancer", async () => {