[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
This commit is contained in:
innerthunder 2024-06-26 13:03:14 -07:00 committed by GitHub
parent f7f8988cdb
commit cd92d4d7d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 772 additions and 37 deletions

View File

@ -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<MoveAttr>[] = [
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),

View File

@ -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;
}

View File

@ -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),

View File

@ -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<void> {
return new Promise(resolve => {
this.formIndex = Math.max(this.species.forms.findIndex(f => f.formKey === formChange.formKey), 0);

View File

@ -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());

View File

@ -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
);
});