From cd92d4d7d39dc2e3f6689c4fc5039c42f780aa90 Mon Sep 17 00:00:00 2001 From: innerthunder <168692175+innerthunder@users.noreply.github.com> Date: Wed, 26 Jun 2024 13:03:14 -0700 Subject: [PATCH] [Ability] Implement Parental Bond (#2384) * Parental Bond basic implementation * Parental Bond unit tests * ESLint * PBond AbAttr rework, documentation, and multi-target unit tests * Update post-target move attribute logic For Parental Bond interaction. * AddSecondStrikeAbAttr now uses Constructor util * Unit tests for PBond + Multi-Lens interaction * Remove random damage spread in unit test * Add null checks to PBond AbAttr * Set player pokemon for unit test * Fixed Post Target check to account for KO's * Fix multi-strike moves applying effects at wrong times * Test cases for updated effect timing * Add Wake-Up Slap test case * Fix Fury Cutter/Echoed Voice multi-hit interaction * Fix Pay Day, Relic Song, and Fury Cutter (again) * Add early stopping to multi-hit moves * RecoilAttr now uses lastHitOnly * Add faint check to last hit logic --- src/data/ability.ts | 82 ++- src/data/battler-tags.ts | 7 + src/data/move.ts | 46 +- src/field/pokemon.ts | 20 +- src/phases.ts | 42 +- src/test/abilities/parental_bond.test.ts | 612 +++++++++++++++++++++++ 6 files changed, 772 insertions(+), 37 deletions(-) create mode 100644 src/test/abilities/parental_bond.test.ts diff --git a/src/data/ability.ts b/src/data/ability.ts index c3881bd4856..34401b3fd98 100644 --- a/src/data/ability.ts +++ b/src/data/ability.ts @@ -9,7 +9,7 @@ import { Weather, WeatherType } from "./weather"; import { BattlerTag, GroundedTag } from "./battler-tags"; import { StatusEffect, getNonVolatileStatusEffects, getStatusEffectDescriptor, getStatusEffectHealText } from "./status-effect"; import { Gender } from "./gender"; -import Move, { AttackMove, MoveCategory, MoveFlags, MoveTarget, FlinchAttr, OneHitKOAttr, HitHealAttr, allMoves, StatusMove, SelfStatusMove, VariablePowerAttr, applyMoveAttrs, IncrementMovePriorityAttr, VariableMoveTypeAttr, RandomMovesetMoveAttr, RandomMoveAttr, NaturePowerAttr, CopyMoveAttr } from "./move"; +import Move, { AttackMove, MoveCategory, MoveFlags, MoveTarget, FlinchAttr, OneHitKOAttr, HitHealAttr, allMoves, StatusMove, SelfStatusMove, VariablePowerAttr, applyMoveAttrs, IncrementMovePriorityAttr, VariableMoveTypeAttr, RandomMovesetMoveAttr, RandomMoveAttr, NaturePowerAttr, CopyMoveAttr, MoveAttr, MultiHitAttr, ChargeAttr, SacrificialAttr, SacrificialAttrOnHit } from "./move"; import { ArenaTagSide, ArenaTrapTag } from "./arena-tag"; import { Stat, getStatName } from "./pokemon-stat"; import { BerryModifier, PokemonHeldItemModifier } from "../modifier/modifier"; @@ -1231,6 +1231,84 @@ export class PokemonTypeChangeAbAttr extends PreAttackAbAttr { } } +/** + * Class for abilities that convert single-strike moves to two-strike moves (i.e. Parental Bond). + * @param damageMultiplier the damage multiplier for the second strike, relative to the first. + */ +export class AddSecondStrikeAbAttr extends PreAttackAbAttr { + private damageMultiplier: number; + + constructor(damageMultiplier: number) { + super(false); + + this.damageMultiplier = damageMultiplier; + } + + /** + * Determines whether this attribute can apply to a given move. + * @param {Move} move the move to which this attribute may apply + * @param numTargets the number of {@linkcode Pokemon} targeted by this move + * @returns true if the attribute can apply to the move, false otherwise + */ + canApplyPreAttack(move: Move, numTargets: integer): boolean { + /** + * Parental Bond cannot apply to multi-hit moves, charging moves, or + * moves that cause the user to faint. + */ + const exceptAttrs: Constructor[] = [ + MultiHitAttr, + ChargeAttr, + SacrificialAttr, + SacrificialAttrOnHit + ]; + + /** Parental Bond cannot apply to these specific moves */ + const exceptMoves: Moves[] = [ + Moves.FLING, + Moves.UPROAR, + Moves.ROLLOUT, + Moves.ICE_BALL, + Moves.ENDEAVOR + ]; + + /** Also check if this move is an Attack move and if it's only targeting one Pokemon */ + return numTargets === 1 + && !exceptAttrs.some(attr => move.hasAttr(attr)) + && !exceptMoves.some(id => move.id === id) + && move.category !== MoveCategory.STATUS; + } + + /** + * If conditions are met, this doubles the move's hit count (via args[1]) + * or multiplies the damage of secondary strikes (via args[2]) + * @param {Pokemon} pokemon the Pokemon using the move + * @param passive n/a + * @param defender n/a + * @param {Move} move the move used by the ability source + * @param args\[0\] the number of Pokemon this move is targeting + * @param {Utils.IntegerHolder} args\[1\] the number of strikes with this move + * @param {Utils.NumberHolder} args\[2\] the damage multiplier for the current strike + * @returns + */ + applyPreAttack(pokemon: Pokemon, passive: boolean, defender: Pokemon, move: Move, args: any[]): boolean { + const numTargets = args[0] as integer; + const hitCount = args[1] as Utils.IntegerHolder; + const multiplier = args[2] as Utils.NumberHolder; + + if (this.canApplyPreAttack(move, numTargets)) { + if (!!hitCount?.value) { + hitCount.value *= 2; + } + + if (!!multiplier?.value && pokemon.turnData.hitsLeft % 2 === 1) { + multiplier.value *= this.damageMultiplier; + } + return true; + } + return false; + } +} + /** * Class for abilities that boost the damage of moves * For abilities that boost the base power of moves, see VariableMovePowerAbAttr @@ -4632,7 +4710,7 @@ export function initAbilities() { new Ability(Abilities.AERILATE, 6) .attr(MoveTypeChangeAttr, Type.FLYING, 1.2, (user, target, move) => move.type === Type.NORMAL), new Ability(Abilities.PARENTAL_BOND, 6) - .unimplemented(), + .attr(AddSecondStrikeAbAttr, 0.25), new Ability(Abilities.DARK_AURA, 6) .attr(PostSummonMessageAbAttr, (pokemon: Pokemon) => getPokemonMessage(pokemon, " is radiating a Dark Aura!")) .attr(FieldMoveTypePowerBoostAbAttr, Type.DARK, 4 / 3), diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 1a4a590e2a7..766217d36f3 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -896,6 +896,13 @@ export class ProtectedTag extends BattlerTag { if (lapseType === BattlerTagLapseType.CUSTOM) { new CommonBattleAnim(CommonAnim.PROTECT, pokemon).play(pokemon.scene); pokemon.scene.queueMessage(i18next.t("battle:battlerTagsProtectedLapse", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) })); + + // Stop multi-hit moves early + const effectPhase = pokemon.scene.getCurrentPhase(); + if (effectPhase instanceof MoveEffectPhase) { + const attacker = effectPhase.getPokemon(); + attacker.stopMultiHit(); + } return true; } diff --git a/src/data/move.ts b/src/data/move.ts index 6a48e588827..86a753224f9 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -811,11 +811,14 @@ export class MoveEffectAttr extends MoveAttr { public trigger: MoveEffectTrigger; /** Should this effect only apply on the first hit? */ public firstHitOnly: boolean; + /** Should this effect only apply on the last hit? */ + public lastHitOnly: boolean; - constructor(selfTarget?: boolean, trigger?: MoveEffectTrigger, firstHitOnly: boolean = false) { + constructor(selfTarget?: boolean, trigger?: MoveEffectTrigger, firstHitOnly: boolean = false, lastHitOnly: boolean = false) { super(selfTarget); this.trigger = trigger !== undefined ? trigger : MoveEffectTrigger.POST_APPLY; this.firstHitOnly = firstHitOnly; + this.lastHitOnly = lastHitOnly; } /** @@ -1064,7 +1067,7 @@ export class RecoilAttr extends MoveEffectAttr { private unblockable: boolean; constructor(useHp: boolean = false, damageRatio: number = 0.25, unblockable: boolean = false) { - super(true); + super(true, MoveEffectTrigger.POST_APPLY, false, true); this.useHp = useHp; this.damageRatio = damageRatio; @@ -1085,8 +1088,8 @@ export class RecoilAttr extends MoveEffectAttr { return false; } - const recoilDamage = Math.max(Math.floor((!this.useHp ? user.turnData.currDamageDealt : user.getMaxHp()) * this.damageRatio), - user.turnData.currDamageDealt ? 1 : 0); + const recoilDamage = Math.max(Math.floor((!this.useHp ? user.turnData.damageDealt : user.getMaxHp()) * this.damageRatio), + user.turnData.damageDealt ? 1 : 0); if (!recoilDamage) { return false; } @@ -2013,7 +2016,7 @@ export class HealStatusEffectAttr extends MoveEffectAttr { * @param ...effects - List of status effects to cure */ constructor(selfTarget: boolean, ...effects: StatusEffect[]) { - super(selfTarget); + super(selfTarget, MoveEffectTrigger.POST_APPLY, false, true); this.effects = effects; } @@ -2770,7 +2773,7 @@ export class DoublePowerChanceAttr extends VariablePowerAttr { export abstract class ConsecutiveUsePowerMultiplierAttr extends MovePowerMultiplierAttr { constructor(limit: integer, resetOnFail: boolean, resetOnLimit?: boolean, ...comboMoves: Moves[]) { super((user: Pokemon, target: Pokemon, move: Move): number => { - const moveHistory = user.getMoveHistory().reverse().slice(1); + const moveHistory = user.getLastXMoves(limit + 1).slice(1); let count = 0; let turnMove: TurnMove; @@ -3139,8 +3142,13 @@ export class PunishmentPowerAttr extends VariablePowerAttr { export class PresentPowerAttr extends VariablePowerAttr { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + /** + * If this move is multi-hit, and this attribute is applied to any hit + * other than the first, this move cannot result in a heal. + */ + const firstHit = (user.turnData.hitCount === user.turnData.hitsLeft); - const powerSeed = Utils.randSeedInt(100); + const powerSeed = Utils.randSeedInt(firstHit ? 100 : 80); if (powerSeed <= 40) { (args[0] as Utils.NumberHolder).value = 40; } else if (40 < powerSeed && powerSeed <= 70) { @@ -3148,6 +3156,8 @@ export class PresentPowerAttr extends VariablePowerAttr { } else if (70 < powerSeed && powerSeed <= 80) { (args[0] as Utils.NumberHolder).value = 120; } else if (80 < powerSeed && powerSeed <= 100) { + // If this move is multi-hit, disable all other hits + user.stopMultiHit(); target.scene.unshiftPhase(new PokemonHealPhase(target.scene, target.getBattlerIndex(), Math.max(Math.floor(target.getMaxHp() / 4), 1), getPokemonMessage(target, " regained\nhealth!"), true)); } @@ -3905,8 +3915,8 @@ export class AddBattlerTagAttr extends MoveEffectAttr { public turnCountMax: integer; private failOnOverlap: boolean; - constructor(tagType: BattlerTagType, selfTarget: boolean = false, failOnOverlap: boolean = false, turnCountMin: integer = 0, turnCountMax?: integer) { - super(selfTarget); + constructor(tagType: BattlerTagType, selfTarget: boolean = false, failOnOverlap: boolean = false, turnCountMin: integer = 0, turnCountMax?: integer, lastHitOnly: boolean = false) { + super(selfTarget, MoveEffectTrigger.POST_APPLY, false, lastHitOnly); this.tagType = tagType; this.turnCountMin = turnCountMin; @@ -4071,7 +4081,7 @@ export class ConfuseAttr extends AddBattlerTagAttr { export class RechargeAttr extends AddBattlerTagAttr { constructor() { - super(BattlerTagType.RECHARGING, true); + super(BattlerTagType.RECHARGING, true, false, 1, 1, true); } } @@ -4468,7 +4478,7 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { private batonPass: boolean; constructor(user?: boolean, batonPass?: boolean) { - super(false, MoveEffectTrigger.POST_APPLY, true); + super(false, MoveEffectTrigger.POST_APPLY, false, true); this.user = !!user; this.batonPass = !!batonPass; } @@ -4583,7 +4593,7 @@ export class RemoveTypeAttr extends MoveEffectAttr { private messageCallback: ((user: Pokemon) => void) | undefined; constructor(removedType: Type, messageCallback?: (user: Pokemon) => void) { - super(true, MoveEffectTrigger.POST_APPLY); + super(true, MoveEffectTrigger.POST_TARGET); this.removedType = removedType; this.messageCallback = messageCallback; @@ -5391,7 +5401,7 @@ export class DiscourageFrequentUseAttr extends MoveAttr { export class MoneyAttr extends MoveEffectAttr { constructor() { - super(true, MoveEffectTrigger.HIT); + super(true, MoveEffectTrigger.HIT, true); } apply(user: Pokemon, target: Pokemon, move: Move): boolean { @@ -6947,7 +6957,7 @@ export function initMoves() { .target(MoveTarget.BOTH_SIDES) .unimplemented(), new AttackMove(Moves.SMACK_DOWN, Type.ROCK, MoveCategory.PHYSICAL, 50, 100, 15, 100, 0, 5) - .attr(AddBattlerTagAttr, BattlerTagType.IGNORE_FLYING, false, false) + .attr(AddBattlerTagAttr, BattlerTagType.IGNORE_FLYING, false, false, 1, 1, true) .attr(AddBattlerTagAttr, BattlerTagType.INTERRUPTED) .attr(RemoveBattlerTagAttr, [BattlerTagType.FLYING, BattlerTagType.MAGNET_RISEN]) .attr(HitsTagAttr, BattlerTagType.FLYING, false) @@ -7331,14 +7341,14 @@ export function initMoves() { .triageMove(), new AttackMove(Moves.THOUSAND_ARROWS, Type.GROUND, MoveCategory.PHYSICAL, 90, 100, 10, -1, 0, 6) .attr(NeutralDamageAgainstFlyingTypeMultiplierAttr) - .attr(AddBattlerTagAttr, BattlerTagType.IGNORE_FLYING, false, false) + .attr(AddBattlerTagAttr, BattlerTagType.IGNORE_FLYING, false, false, 1, 1, true) .attr(HitsTagAttr, BattlerTagType.FLYING, false) .attr(AddBattlerTagAttr, BattlerTagType.INTERRUPTED) .attr(RemoveBattlerTagAttr, [BattlerTagType.FLYING, BattlerTagType.MAGNET_RISEN]) .makesContact(false) .target(MoveTarget.ALL_NEAR_ENEMIES), new AttackMove(Moves.THOUSAND_WAVES, Type.GROUND, MoveCategory.PHYSICAL, 90, 100, 10, -1, 0, 6) - .attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, false, 1) + .attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, false, 1, 1, true) .makesContact(false) .target(MoveTarget.ALL_NEAR_ENEMIES), new AttackMove(Moves.LANDS_WRATH, Type.GROUND, MoveCategory.PHYSICAL, 90, 100, 10, -1, 0, 6) @@ -7480,7 +7490,7 @@ export function initMoves() { new SelfStatusMove(Moves.BANEFUL_BUNKER, Type.POISON, -1, 10, -1, 4, 7) .attr(ProtectAttr, BattlerTagType.BANEFUL_BUNKER), new AttackMove(Moves.SPIRIT_SHACKLE, Type.GHOST, MoveCategory.PHYSICAL, 80, 100, 10, 100, 0, 7) - .attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, false, 1) + .attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, false, 1, 1, true) .makesContact(false), new AttackMove(Moves.DARKEST_LARIAT, Type.DARK, MoveCategory.PHYSICAL, 85, 100, 10, -1, 0, 7) .attr(IgnoreOpponentStatChangesAttr), @@ -7524,7 +7534,7 @@ export function initMoves() { .attr(HealOnAllyAttr, 0.5, true, false) .ballBombMove(), new AttackMove(Moves.ANCHOR_SHOT, Type.STEEL, MoveCategory.PHYSICAL, 80, 100, 20, 100, 0, 7) - .attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, false, 1), + .attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, false, 1, 1, true), new StatusMove(Moves.PSYCHIC_TERRAIN, Type.PSYCHIC, -1, 10, -1, 0, 7) .attr(TerrainChangeAttr, TerrainType.PSYCHIC) .target(MoveTarget.BOTH_SIDES), diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index ec7b2df8988..9fd00bdf1f2 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -23,7 +23,7 @@ import { BattlerTag, BattlerTagLapseType, EncoreTag, GroundedTag, HelpingHandTag import { WeatherType } from "../data/weather"; import { TempBattleStat } from "../data/temp-battle-stat"; import { ArenaTagSide, WeakenMoveScreenTag, WeakenMoveTypeTag } from "../data/arena-tag"; -import { Ability, AbAttr, BattleStatMultiplierAbAttr, BlockCritAbAttr, BonusCritAbAttr, BypassBurnDamageReductionAbAttr, FieldPriorityMoveImmunityAbAttr, IgnoreOpponentStatChangesAbAttr, MoveImmunityAbAttr, MoveTypeChangeAttr, PreApplyBattlerTagAbAttr, PreDefendFullHpEndureAbAttr, ReceivedMoveDamageMultiplierAbAttr, ReduceStatusEffectDurationAbAttr, StabBoostAbAttr, StatusEffectImmunityAbAttr, TypeImmunityAbAttr, VariableMovePowerAbAttr, WeightMultiplierAbAttr, allAbilities, applyAbAttrs, applyBattleStatMultiplierAbAttrs, applyPreApplyBattlerTagAbAttrs, applyPreAttackAbAttrs, applyPreDefendAbAttrs, applyPreSetStatusAbAttrs, UnsuppressableAbilityAbAttr, SuppressFieldAbilitiesAbAttr, NoFusionAbilityAbAttr, MultCritAbAttr, IgnoreTypeImmunityAbAttr, DamageBoostAbAttr, IgnoreTypeStatusEffectImmunityAbAttr, ConditionalCritAbAttr, applyFieldBattleStatMultiplierAbAttrs, FieldMultiplyBattleStatAbAttr, AllyMoveCategoryPowerBoostAbAttr, FieldMoveTypePowerBoostAbAttr } from "../data/ability"; +import { Ability, AbAttr, BattleStatMultiplierAbAttr, BlockCritAbAttr, BonusCritAbAttr, BypassBurnDamageReductionAbAttr, FieldPriorityMoveImmunityAbAttr, IgnoreOpponentStatChangesAbAttr, MoveImmunityAbAttr, MoveTypeChangeAttr, PreApplyBattlerTagAbAttr, PreDefendFullHpEndureAbAttr, ReceivedMoveDamageMultiplierAbAttr, ReduceStatusEffectDurationAbAttr, StabBoostAbAttr, StatusEffectImmunityAbAttr, TypeImmunityAbAttr, VariableMovePowerAbAttr, WeightMultiplierAbAttr, allAbilities, applyAbAttrs, applyBattleStatMultiplierAbAttrs, applyPreApplyBattlerTagAbAttrs, applyPreAttackAbAttrs, applyPreDefendAbAttrs, applyPreSetStatusAbAttrs, UnsuppressableAbilityAbAttr, SuppressFieldAbilitiesAbAttr, NoFusionAbilityAbAttr, MultCritAbAttr, IgnoreTypeImmunityAbAttr, DamageBoostAbAttr, IgnoreTypeStatusEffectImmunityAbAttr, ConditionalCritAbAttr, applyFieldBattleStatMultiplierAbAttrs, FieldMultiplyBattleStatAbAttr, AllyMoveCategoryPowerBoostAbAttr, FieldMoveTypePowerBoostAbAttr, AddSecondStrikeAbAttr } from "../data/ability"; import PokemonData from "../system/pokemon-data"; import { BattlerIndex } from "../battle"; import { Mode } from "../ui/ui"; @@ -1800,6 +1800,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { } if (cancelled.value) { + source.stopMultiHit(); result = HitResult.NO_EFFECT; } else { const typeBoost = source.findTag(t => t instanceof TypeBoostTag && t.boostedType === move.type) as TypeBoostTag; @@ -1885,8 +1886,11 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { applyMoveAttrs(VariableAtkAttr, source, this, move, sourceAtk); applyMoveAttrs(VariableDefAttr, source, this, move, targetDef); + const twoStrikeMultiplier = new Utils.NumberHolder(1); + applyPreAttackAbAttrs(AddSecondStrikeAbAttr, source, this, move, 1, new Utils.IntegerHolder(0), twoStrikeMultiplier); + if (!isTypeImmune) { - damage.value = Math.ceil(((((2 * source.level / 5 + 2) * power.value * sourceAtk.value / targetDef.value) / 50) + 2) * stabMultiplier.value * typeMultiplier.value * arenaAttackTypeMultiplier.value * screenMultiplier.value * ((this.scene.randBattleSeedInt(15) + 85) / 100) * criticalMultiplier.value); + damage.value = Math.ceil(((((2 * source.level / 5 + 2) * power.value * sourceAtk.value / targetDef.value) / 50) + 2) * stabMultiplier.value * typeMultiplier.value * arenaAttackTypeMultiplier.value * screenMultiplier.value * twoStrikeMultiplier.value * ((this.scene.randBattleSeedInt(15) + 85) / 100) * criticalMultiplier.value); if (isPhysical && source.status && source.status.effect === StatusEffect.BURN) { if (!move.hasAttr(BypassBurnDamageReductionAttr)) { const burnDamageReductionCancelled = new Utils.BooleanHolder(false); @@ -2251,6 +2255,18 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { return this.summonData.moveQueue; } + /** + * If this Pokemon is using a multi-hit move, stop the move + * after the next hit resolves. + */ + stopMultiHit(): void { + if (!this.turnData) { + return; + } + this.turnData.hitCount = 1; + this.turnData.hitsLeft = 1; + } + changeForm(formChange: SpeciesFormChange): Promise { return new Promise(resolve => { this.formIndex = Math.max(this.species.forms.findIndex(f => f.formKey === formChange.formKey), 0); diff --git a/src/phases.ts b/src/phases.ts index 110d87d527b..43208f83743 100644 --- a/src/phases.ts +++ b/src/phases.ts @@ -26,7 +26,7 @@ import { Gender } from "./data/gender"; import { Weather, WeatherType, getRandomWeatherType, getTerrainBlockMessage, getWeatherDamageMessage, getWeatherLapseMessage } from "./data/weather"; import { TempBattleStat } from "./data/temp-battle-stat"; import { ArenaTagSide, ArenaTrapTag, MistTag, TrickRoomTag } from "./data/arena-tag"; -import { CheckTrappedAbAttr, IgnoreOpponentStatChangesAbAttr, IgnoreOpponentEvasionAbAttr, PostAttackAbAttr, PostBattleAbAttr, PostDefendAbAttr, PostSummonAbAttr, PostTurnAbAttr, PostWeatherLapseAbAttr, PreSwitchOutAbAttr, PreWeatherDamageAbAttr, ProtectStatAbAttr, RedirectMoveAbAttr, BlockRedirectAbAttr, RunSuccessAbAttr, StatChangeMultiplierAbAttr, SuppressWeatherEffectAbAttr, SyncEncounterNatureAbAttr, applyAbAttrs, applyCheckTrappedAbAttrs, applyPostAttackAbAttrs, applyPostBattleAbAttrs, applyPostDefendAbAttrs, applyPostSummonAbAttrs, applyPostTurnAbAttrs, applyPostWeatherLapseAbAttrs, applyPreStatChangeAbAttrs, applyPreSwitchOutAbAttrs, applyPreWeatherEffectAbAttrs, BattleStatMultiplierAbAttr, applyBattleStatMultiplierAbAttrs, IncrementMovePriorityAbAttr, applyPostVictoryAbAttrs, PostVictoryAbAttr, BlockNonDirectDamageAbAttr as BlockNonDirectDamageAbAttr, applyPostKnockOutAbAttrs, PostKnockOutAbAttr, PostBiomeChangeAbAttr, applyPostFaintAbAttrs, PostFaintAbAttr, IncreasePpAbAttr, PostStatChangeAbAttr, applyPostStatChangeAbAttrs, AlwaysHitAbAttr, PreventBerryUseAbAttr, StatChangeCopyAbAttr, PokemonTypeChangeAbAttr, applyPreAttackAbAttrs, applyPostMoveUsedAbAttrs, PostMoveUsedAbAttr, MaxMultiHitAbAttr, HealFromBerryUseAbAttr, WonderSkinAbAttr, applyPreDefendAbAttrs, IgnoreMoveEffectsAbAttr, BlockStatusDamageAbAttr, BypassSpeedChanceAbAttr } from "./data/ability"; +import { CheckTrappedAbAttr, IgnoreOpponentStatChangesAbAttr, IgnoreOpponentEvasionAbAttr, PostAttackAbAttr, PostBattleAbAttr, PostDefendAbAttr, PostSummonAbAttr, PostTurnAbAttr, PostWeatherLapseAbAttr, PreSwitchOutAbAttr, PreWeatherDamageAbAttr, ProtectStatAbAttr, RedirectMoveAbAttr, BlockRedirectAbAttr, RunSuccessAbAttr, StatChangeMultiplierAbAttr, SuppressWeatherEffectAbAttr, SyncEncounterNatureAbAttr, applyAbAttrs, applyCheckTrappedAbAttrs, applyPostAttackAbAttrs, applyPostBattleAbAttrs, applyPostDefendAbAttrs, applyPostSummonAbAttrs, applyPostTurnAbAttrs, applyPostWeatherLapseAbAttrs, applyPreStatChangeAbAttrs, applyPreSwitchOutAbAttrs, applyPreWeatherEffectAbAttrs, BattleStatMultiplierAbAttr, applyBattleStatMultiplierAbAttrs, IncrementMovePriorityAbAttr, applyPostVictoryAbAttrs, PostVictoryAbAttr, BlockNonDirectDamageAbAttr as BlockNonDirectDamageAbAttr, applyPostKnockOutAbAttrs, PostKnockOutAbAttr, PostBiomeChangeAbAttr, applyPostFaintAbAttrs, PostFaintAbAttr, IncreasePpAbAttr, PostStatChangeAbAttr, applyPostStatChangeAbAttrs, AlwaysHitAbAttr, PreventBerryUseAbAttr, StatChangeCopyAbAttr, PokemonTypeChangeAbAttr, applyPreAttackAbAttrs, applyPostMoveUsedAbAttrs, PostMoveUsedAbAttr, MaxMultiHitAbAttr, HealFromBerryUseAbAttr, WonderSkinAbAttr, applyPreDefendAbAttrs, IgnoreMoveEffectsAbAttr, BlockStatusDamageAbAttr, BypassSpeedChanceAbAttr, AddSecondStrikeAbAttr } from "./data/ability"; import { Unlockables, getUnlockableName } from "./system/unlockables"; import { getBiomeKey } from "./field/arena"; import { BattleType, BattlerIndex, TurnCommand } from "./battle"; @@ -2878,6 +2878,7 @@ export class MoveEffectPhase extends PokemonPhase { const hitCount = new Utils.IntegerHolder(1); // Assume single target for multi hit applyMoveAttrs(MultiHitAttr, user, this.getTarget(), move, hitCount); + applyPreAttackAbAttrs(AddSecondStrikeAbAttr, user, null, move, targets.length, hitCount, new Utils.IntegerHolder(0)); if (move instanceof AttackMove && !move.hasAttr(FixedDamageAttr)) { this.scene.applyModifiers(PokemonMultiHitModifier, user.isPlayer(), user, hitCount, new Utils.IntegerHolder(0)); } @@ -2885,7 +2886,6 @@ export class MoveEffectPhase extends PokemonPhase { } const moveHistoryEntry = { move: this.move.moveId, targets: this.targets, result: MoveResult.PENDING, virtual: this.move.virtual }; - user.pushMoveHistory(moveHistoryEntry); const targetHitChecks = Object.fromEntries(targets.map(p => [p.getBattlerIndex(), this.hitCheck(p)])); const activeTargets = targets.map(t => t.isActive(true)); @@ -2900,6 +2900,7 @@ export class MoveEffectPhase extends PokemonPhase { this.scene.queueMessage(i18next.t("battle:attackFailed")); moveHistoryEntry.result = MoveResult.FAIL; } + user.pushMoveHistory(moveHistoryEntry); return this.end(); } @@ -2921,25 +2922,33 @@ export class MoveEffectPhase extends PokemonPhase { const isProtected = !this.move.getMove().checkFlag(MoveFlags.IGNORE_PROTECT, user, target) && target.findTags(t => t instanceof ProtectedTag).find(t => target.lapseTag(t.tagType)); - const firstHit = moveHistoryEntry.result !== MoveResult.SUCCESS; + const firstHit = (user.turnData.hitsLeft === user.turnData.hitCount); + + if (firstHit) { + user.pushMoveHistory(moveHistoryEntry); + } moveHistoryEntry.result = MoveResult.SUCCESS; const hitResult = !isProtected ? target.apply(user, move) : HitResult.NO_EFFECT; - this.scene.triggerPokemonFormChange(user, SpeciesFormChangePostMoveTrigger); + const lastHit = (user.turnData.hitsLeft === 1 || !this.getTarget()?.isActive()); + + if (lastHit) { + this.scene.triggerPokemonFormChange(user, SpeciesFormChangePostMoveTrigger); + } applyAttrs.push(new Promise(resolve => { - applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && attr.trigger === MoveEffectTrigger.PRE_APPLY && (!attr.firstHitOnly || firstHit), + applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && attr.trigger === MoveEffectTrigger.PRE_APPLY && (!attr.firstHitOnly || firstHit) && (!attr.lastHitOnly || lastHit), user, target, move).then(() => { if (hitResult !== HitResult.FAIL) { const chargeEffect = !!move.getAttrs(ChargeAttr).find(ca => ca.usedChargeEffect(user, this.getTarget(), move)); // Charge attribute with charge effect takes all effect attributes and applies them to charge stage, so ignore them if this is present Utils.executeIf(!chargeEffect, () => applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && attr.trigger === MoveEffectTrigger.POST_APPLY - && attr.selfTarget && (!attr.firstHitOnly || firstHit), user, target, move)).then(() => { + && attr.selfTarget && (!attr.firstHitOnly || firstHit) && (!attr.lastHitOnly || lastHit), user, target, move)).then(() => { if (hitResult !== HitResult.NO_EFFECT) { applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && (attr as MoveEffectAttr).trigger === MoveEffectTrigger.POST_APPLY - && !(attr as MoveEffectAttr).selfTarget && (!attr.firstHitOnly || firstHit), user, target, this.move.getMove()).then(() => { + && !(attr as MoveEffectAttr).selfTarget && (!attr.firstHitOnly || firstHit) && (!attr.lastHitOnly || lastHit), user, target, this.move.getMove()).then(() => { if (hitResult < HitResult.NO_EFFECT && !target.hasAbilityWithAttr(IgnoreMoveEffectsAbAttr)) { const flinched = new Utils.BooleanHolder(false); user.scene.applyModifiers(FlinchChanceModifier, user.isPlayer(), user, flinched); @@ -2947,7 +2956,7 @@ export class MoveEffectPhase extends PokemonPhase { target.addTag(BattlerTagType.FLINCHED, undefined, this.move.moveId, user.id); } } - Utils.executeIf(!isProtected && !chargeEffect, () => applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && (attr as MoveEffectAttr).trigger === MoveEffectTrigger.HIT && (!attr.firstHitOnly || firstHit), + Utils.executeIf(!isProtected && !chargeEffect, () => applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && (attr as MoveEffectAttr).trigger === MoveEffectTrigger.HIT && (!attr.firstHitOnly || firstHit) && (!attr.lastHitOnly || lastHit), user, target, this.move.getMove()).then(() => { return Utils.executeIf(!target.isFainted() || target.canApplyAbility(), () => applyPostDefendAbAttrs(PostDefendAbAttr, target, user, this.move.getMove(), hitResult).then(() => { if (!user.isPlayer() && this.move.getMove() instanceof AttackMove) { @@ -2974,14 +2983,17 @@ export class MoveEffectPhase extends PokemonPhase { }); })); } - // Trigger effect which should only apply one time after all targeted effects have already applied - const postTarget = applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && attr.trigger === MoveEffectTrigger.POST_TARGET, - user, null, move); + // Trigger effect which should only apply one time on the last hit after all targeted effects have already applied + const postTarget = (user.turnData.hitsLeft === 1 || !this.getTarget()?.isActive()) ? + applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && attr.trigger === MoveEffectTrigger.POST_TARGET, user, null, move) : + null; - if (applyAttrs.length) { // If there is a pending asynchronous move effect, do this after - applyAttrs[applyAttrs.length - 1]?.then(() => postTarget); - } else { // Otherwise, push a new asynchronous move effect - applyAttrs.push(postTarget); + if (!!postTarget) { + if (applyAttrs.length) { // If there is a pending asynchronous move effect, do this after + applyAttrs[applyAttrs.length - 1]?.then(() => postTarget); + } else { // Otherwise, push a new asynchronous move effect + applyAttrs.push(postTarget); + } } Promise.allSettled(applyAttrs).then(() => this.end()); diff --git a/src/test/abilities/parental_bond.test.ts b/src/test/abilities/parental_bond.test.ts new file mode 100644 index 00000000000..77010f73253 --- /dev/null +++ b/src/test/abilities/parental_bond.test.ts @@ -0,0 +1,612 @@ +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; +import GameManager from "../utils/gameManager"; +import * as Overrides from "#app/overrides"; +import { Species } from "#enums/species"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { getMovePosition } from "../utils/gameManagerUtils"; +import { CommandPhase, DamagePhase, MoveEffectPhase, MoveEndPhase, TurnEndPhase } from "#app/phases.js"; +import { BattleStat } from "#app/data/battle-stat.js"; +import { Type } from "#app/data/type.js"; +import { BattlerTagType } from "#app/enums/battler-tag-type.js"; +import { StatusEffect } from "#app/data/status-effect.js"; + +const TIMEOUT = 20 * 1000; + +describe("Abilities - Parental Bond", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + vi.spyOn(Overrides, "SINGLE_BATTLE_OVERRIDE", "get").mockReturnValue(true); + vi.spyOn(Overrides, "ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.PARENTAL_BOND); + vi.spyOn(Overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(Species.SNORLAX); + vi.spyOn(Overrides, "OPP_ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.INSOMNIA); + vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SPLASH, Moves.SPLASH, Moves.SPLASH, Moves.SPLASH]); + vi.spyOn(Overrides, "STARTING_LEVEL_OVERRIDE", "get").mockReturnValue(100); + vi.spyOn(Overrides, "OPP_LEVEL_OVERRIDE", "get").mockReturnValue(100); + }); + + test( + "ability should add second strike to attack move", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.TACKLE]); + + await game.startBattle([Species.CHARIZARD]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + const enemyPokemon = game.scene.getEnemyPokemon(); + expect(enemyPokemon).not.toBe(undefined); + + let enemyStartingHp = enemyPokemon.hp; + + game.doAttack(getMovePosition(game.scene, 0, Moves.TACKLE)); + + await game.phaseInterceptor.to(MoveEffectPhase, false); + vi.spyOn(game.scene, "randBattleSeedInt").mockReturnValue(15); + + await game.phaseInterceptor.to(DamagePhase); + const firstStrikeDamage = enemyStartingHp - enemyPokemon.hp; + enemyStartingHp = enemyPokemon.hp; + + await game.phaseInterceptor.to(TurnEndPhase, false); + + const secondStrikeDamage = enemyStartingHp - enemyPokemon.hp; + + expect(leadPokemon.turnData.hitCount).toBe(2); + expect(secondStrikeDamage).toBe(Math.ceil(0.25 * firstStrikeDamage)); + }, TIMEOUT + ); + + test( + "ability should apply secondary effects to both strikes", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.POWER_UP_PUNCH]); + vi.spyOn(Overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(Species.AMOONGUSS); + + await game.startBattle([Species.CHARIZARD]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + const enemyPokemon = game.scene.getEnemyPokemon(); + expect(enemyPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.POWER_UP_PUNCH)); + + await game.phaseInterceptor.to(TurnEndPhase, false); + + expect(leadPokemon.turnData.hitCount).toBe(2); + expect(leadPokemon.summonData.battleStats[BattleStat.ATK]).toBe(2); + }, TIMEOUT + ); + + test( + "ability should not apply to Status moves", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.BABY_DOLL_EYES]); + + await game.startBattle([Species.CHARIZARD]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + const enemyPokemon = game.scene.getEnemyPokemon(); + expect(enemyPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.BABY_DOLL_EYES)); + await game.phaseInterceptor.to(TurnEndPhase, false); + + expect(enemyPokemon.summonData.battleStats[BattleStat.ATK]).toBe(-1); + }, TIMEOUT + ); + + test( + "ability should not apply to multi-hit moves", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.DOUBLE_HIT]); + + await game.startBattle([Species.CHARIZARD]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + const enemyPokemon = game.scene.getEnemyPokemon(); + expect(enemyPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.DOUBLE_HIT)); + + await game.phaseInterceptor.to(MoveEffectPhase, false); + + vi.spyOn(game.scene.getCurrentPhase() as MoveEffectPhase, "hitCheck").mockReturnValue(true); + + await game.phaseInterceptor.to(TurnEndPhase, false); + + expect(leadPokemon.turnData.hitCount).toBe(2); + }, TIMEOUT + ); + + test( + "ability should not apply to self-sacrifice moves", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SELF_DESTRUCT]); + + await game.startBattle([Species.CHARIZARD]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + const enemyPokemon = game.scene.getEnemyPokemon(); + expect(enemyPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.SELF_DESTRUCT)); + + await game.phaseInterceptor.to(DamagePhase, false); + + expect(leadPokemon.turnData.hitCount).toBe(1); + }, TIMEOUT + ); + + test( + "ability should not apply to Rollout", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.ROLLOUT]); + + await game.startBattle([Species.CHARIZARD]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + const enemyPokemon = game.scene.getEnemyPokemon(); + expect(enemyPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.ROLLOUT)); + + await game.phaseInterceptor.to(MoveEffectPhase, false); + vi.spyOn(game.scene.getCurrentPhase() as MoveEffectPhase, "hitCheck").mockReturnValue(true); + + await game.phaseInterceptor.to(DamagePhase, false); + + expect(leadPokemon.turnData.hitCount).toBe(1); + }, TIMEOUT + ); + + test( + "ability should not apply multiplier to fixed-damage moves", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.DRAGON_RAGE]); + + await game.startBattle([Species.CHARIZARD]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + const enemyPokemon = game.scene.getEnemyPokemon(); + expect(enemyPokemon).not.toBe(undefined); + + const enemyStartingHp = enemyPokemon.hp; + + game.doAttack(getMovePosition(game.scene, 0, Moves.DRAGON_RAGE)); + await game.phaseInterceptor.to(TurnEndPhase, false); + + expect(enemyPokemon.hp).toBe(enemyStartingHp - 80); + }, TIMEOUT + ); + + test( + "ability should not apply multiplier to counter moves", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.COUNTER]); + vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.TACKLE,Moves.TACKLE,Moves.TACKLE,Moves.TACKLE]); + + await game.startBattle([Species.CHARIZARD]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + const enemyPokemon = game.scene.getEnemyPokemon(); + expect(enemyPokemon).not.toBe(undefined); + + const playerStartingHp = leadPokemon.hp; + const enemyStartingHp = enemyPokemon.hp; + + game.doAttack(getMovePosition(game.scene, 0, Moves.COUNTER)); + await game.phaseInterceptor.to(DamagePhase); + + const playerDamage = playerStartingHp - leadPokemon.hp; + + await game.phaseInterceptor.to(TurnEndPhase, false); + + expect(enemyPokemon.hp).toBe(enemyStartingHp - 4*playerDamage); + }, TIMEOUT + ); + + test( + "ability should not apply to multi-target moves", + async () => { + vi.spyOn(Overrides, "DOUBLE_BATTLE_OVERRIDE", "get").mockReturnValue(true); + vi.spyOn(Overrides, "SINGLE_BATTLE_OVERRIDE", "get").mockReturnValue(false); + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.EARTHQUAKE]); + + await game.startBattle([Species.CHARIZARD, Species.PIDGEOT]); + + const playerPokemon = game.scene.getPlayerField(); + expect(playerPokemon.length).toBe(2); + playerPokemon.forEach(p => expect(p).not.toBe(undefined)); + + const enemyPokemon = game.scene.getEnemyField(); + expect(enemyPokemon.length).toBe(2); + enemyPokemon.forEach(p => expect(p).not.toBe(undefined)); + + game.doAttack(getMovePosition(game.scene, 0, Moves.EARTHQUAKE)); + await game.phaseInterceptor.to(CommandPhase); + + game.doAttack(getMovePosition(game.scene, 1, Moves.EARTHQUAKE)); + await game.phaseInterceptor.to(TurnEndPhase, false); + + playerPokemon.forEach(p => expect(p.turnData.hitCount).toBe(1)); + }, TIMEOUT + ); + + test( + "ability should apply to multi-target moves when hitting only one target", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.EARTHQUAKE]); + + await game.startBattle([Species.CHARIZARD]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + const enemyPokemon = game.scene.getEnemyPokemon(); + expect(enemyPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.EARTHQUAKE)); + await game.phaseInterceptor.to(DamagePhase, false); + + expect(leadPokemon.turnData.hitCount).toBe(2); + }, TIMEOUT + ); + + test( + "ability should only trigger post-target move effects once", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.MIND_BLOWN]); + + await game.startBattle([Species.PIDGEOT]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + const enemyPokemon = game.scene.getEnemyPokemon(); + expect(enemyPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.MIND_BLOWN)); + + await game.phaseInterceptor.to(DamagePhase, false); + + expect(leadPokemon.turnData.hitCount).toBe(2); + + // This test will time out if the user faints + await game.phaseInterceptor.to(TurnEndPhase, false); + + expect(leadPokemon.hp).toBe(Math.floor(leadPokemon.getMaxHp()/2)); + }, TIMEOUT + ); + + test( + "Burn Up only removes type after second strike with this ability", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.BURN_UP]); + + await game.startBattle([Species.CHARIZARD]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + const enemyPokemon = game.scene.getEnemyPokemon(); + expect(enemyPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.BURN_UP)); + + await game.phaseInterceptor.to(DamagePhase); + + expect(leadPokemon.turnData.hitCount).toBe(2); + expect(enemyPokemon.hp).toBeGreaterThan(0); + expect(leadPokemon.isOfType(Type.FIRE)).toBe(true); + + await game.phaseInterceptor.to(TurnEndPhase, false); + + expect(leadPokemon.isOfType(Type.FIRE)).toBe(false); + }, TIMEOUT + ); + + test( + "Moves boosted by this ability and Multi-Lens should strike 4 times", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.TACKLE]); + vi.spyOn(Overrides, "STARTING_HELD_ITEMS_OVERRIDE", "get").mockReturnValue([{name: "MULTI_LENS", count: 1}]); + + await game.startBattle([Species.CHARIZARD]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + const enemyPokemon = game.scene.getEnemyPokemon(); + expect(enemyPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.TACKLE)); + + await game.phaseInterceptor.to(DamagePhase); + + expect(leadPokemon.turnData.hitCount).toBe(4); + }, TIMEOUT + ); + + test( + "Super Fang boosted by this ability and Multi-Lens should strike twice", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SUPER_FANG]); + vi.spyOn(Overrides, "STARTING_HELD_ITEMS_OVERRIDE", "get").mockReturnValue([{name: "MULTI_LENS", count: 1}]); + + await game.startBattle([Species.CHARIZARD]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + const enemyPokemon = game.scene.getEnemyPokemon(); + expect(enemyPokemon).not.toBe(undefined); + + const enemyStartingHp = enemyPokemon.hp; + + game.doAttack(getMovePosition(game.scene, 0, Moves.SUPER_FANG)); + + await game.phaseInterceptor.to(MoveEffectPhase, false); + vi.spyOn(game.scene.getCurrentPhase() as MoveEffectPhase, "hitCheck").mockReturnValue(true); + + await game.phaseInterceptor.to(DamagePhase); + + expect(leadPokemon.turnData.hitCount).toBe(2); + + await game.phaseInterceptor.to(TurnEndPhase); + + expect(enemyPokemon.hp).toBe(Math.ceil(enemyStartingHp * 0.25)); + }, TIMEOUT + ); + + test( + "Seismic Toss boosted by this ability and Multi-Lens should strike twice", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SEISMIC_TOSS]); + vi.spyOn(Overrides, "STARTING_HELD_ITEMS_OVERRIDE", "get").mockReturnValue([{name: "MULTI_LENS", count: 1}]); + + await game.startBattle([Species.CHARIZARD]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + const enemyPokemon = game.scene.getEnemyPokemon(); + expect(enemyPokemon).not.toBe(undefined); + + const enemyStartingHp = enemyPokemon.hp; + + game.doAttack(getMovePosition(game.scene, 0, Moves.SEISMIC_TOSS)); + + await game.phaseInterceptor.to(MoveEffectPhase, false); + vi.spyOn(game.scene.getCurrentPhase() as MoveEffectPhase, "hitCheck").mockReturnValue(true); + + await game.phaseInterceptor.to(DamagePhase); + + expect(leadPokemon.turnData.hitCount).toBe(2); + + await game.phaseInterceptor.to(TurnEndPhase); + + expect(enemyPokemon.hp).toBe(enemyStartingHp - 200); + }, TIMEOUT + ); + + test( + "Hyper Beam boosted by this ability should strike twice, then recharge", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.HYPER_BEAM]); + + await game.startBattle([Species.CHARIZARD]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + const enemyPokemon = game.scene.getEnemyPokemon(); + expect(enemyPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.HYPER_BEAM)); + + await game.phaseInterceptor.to(MoveEffectPhase, false); + vi.spyOn(game.scene.getCurrentPhase() as MoveEffectPhase, "hitCheck").mockReturnValue(true); + + await game.phaseInterceptor.to(DamagePhase); + + expect(leadPokemon.turnData.hitCount).toBe(2); + expect(leadPokemon.getTag(BattlerTagType.RECHARGING)).toBeUndefined(); + + await game.phaseInterceptor.to(TurnEndPhase); + + expect(leadPokemon.getTag(BattlerTagType.RECHARGING)).toBeDefined(); + }, TIMEOUT + ); + + /** TODO: Fix TRAPPED tag lapsing incorrectly, then run this test */ + test.skip( + "Anchor Shot boosted by this ability should only trap the target after the second hit", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.ANCHOR_SHOT]); + + await game.startBattle([Species.CHARIZARD]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + const enemyPokemon = game.scene.getEnemyPokemon(); + expect(enemyPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.ANCHOR_SHOT)); + + await game.phaseInterceptor.to(MoveEffectPhase, false); + vi.spyOn(game.scene.getCurrentPhase() as MoveEffectPhase, "hitCheck").mockReturnValue(true); + + await game.phaseInterceptor.to(DamagePhase); + + expect(leadPokemon.turnData.hitCount).toBe(2); + expect(enemyPokemon.getTag(BattlerTagType.TRAPPED)).toBeUndefined(); // Passes + + await game.phaseInterceptor.to(MoveEndPhase); + expect(enemyPokemon.getTag(BattlerTagType.TRAPPED)).toBeDefined(); // Passes + + await game.phaseInterceptor.to(TurnEndPhase); + + expect(enemyPokemon.getTag(BattlerTagType.TRAPPED)).toBeDefined(); // Fails :( + }, TIMEOUT + ); + + test( + "Smack Down boosted by this ability should only ground the target after the second hit", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SMACK_DOWN]); + + await game.startBattle([Species.CHARIZARD]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + const enemyPokemon = game.scene.getEnemyPokemon(); + expect(enemyPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.SMACK_DOWN)); + + await game.phaseInterceptor.to(MoveEffectPhase, false); + vi.spyOn(game.scene.getCurrentPhase() as MoveEffectPhase, "hitCheck").mockReturnValue(true); + + await game.phaseInterceptor.to(DamagePhase); + + expect(leadPokemon.turnData.hitCount).toBe(2); + expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeUndefined(); + + await game.phaseInterceptor.to(TurnEndPhase); + + expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined(); + }, TIMEOUT + ); + + test( + "U-turn boosted by this ability should strike twice before forcing a switch", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.U_TURN]); + + await game.startBattle([Species.CHARIZARD, Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + const enemyPokemon = game.scene.getEnemyPokemon(); + expect(enemyPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.U_TURN)); + + await game.phaseInterceptor.to(MoveEffectPhase, false); + vi.spyOn(game.scene.getCurrentPhase() as MoveEffectPhase, "hitCheck").mockReturnValue(true); + + await game.phaseInterceptor.to(MoveEffectPhase); + expect(leadPokemon.turnData.hitCount).toBe(2); + + // This will cause this test to time out if the switch was forced on the first hit. + await game.phaseInterceptor.to(MoveEffectPhase, false); + }, TIMEOUT + ); + + test( + "Wake-Up Slap boosted by this ability should only wake up the target after the second hit", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.WAKE_UP_SLAP]); + vi.spyOn(Overrides, "OPP_STATUS_OVERRIDE", "get").mockReturnValue(StatusEffect.SLEEP); + + await game.startBattle([Species.CHARIZARD]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + const enemyPokemon = game.scene.getEnemyPokemon(); + expect(enemyPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.WAKE_UP_SLAP)); + + await game.phaseInterceptor.to(MoveEffectPhase, false); + vi.spyOn(game.scene.getCurrentPhase() as MoveEffectPhase, "hitCheck").mockReturnValue(true); + + await game.phaseInterceptor.to(DamagePhase); + + expect(leadPokemon.turnData.hitCount).toBe(2); + expect(enemyPokemon.status?.effect).toBe(StatusEffect.SLEEP); + + await game.phaseInterceptor.to(TurnEndPhase); + + expect(enemyPokemon.status?.effect).toBeUndefined(); + }, TIMEOUT + ); + + test( + "ability should not cause user to hit into King's Shield more than once", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.TACKLE]); + vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.KINGS_SHIELD,Moves.KINGS_SHIELD,Moves.KINGS_SHIELD,Moves.KINGS_SHIELD]); + + await game.startBattle([Species.CHARIZARD]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + const enemyPokemon = game.scene.getEnemyPokemon(); + expect(enemyPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.TACKLE)); + + await game.phaseInterceptor.to(TurnEndPhase, false); + + expect(leadPokemon.summonData.battleStats[BattleStat.ATK]).toBe(-1); + }, TIMEOUT + ); + + test( + "ability should not cause user to hit into Storm Drain more than once", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.WATER_GUN]); + vi.spyOn(Overrides, "OPP_ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.STORM_DRAIN); + + await game.startBattle([Species.CHARIZARD]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + const enemyPokemon = game.scene.getEnemyPokemon(); + expect(enemyPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.WATER_GUN)); + + await game.phaseInterceptor.to(TurnEndPhase, false); + + expect(enemyPokemon.summonData.battleStats[BattleStat.SPATK]).toBe(1); + }, TIMEOUT + ); +});