diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index f5fd9b19f72..f5144f3bca8 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -1,6 +1,8 @@ /* biome-ignore-start lint/correctness/noUnusedImports: tsdoc imports */ import type { BattleScene } from "#app/battle-scene"; import type { SpeciesFormChangeRevertWeatherFormTrigger } from "#data/form-change-triggers"; +import type { MoveEffectPhase } from "#phases/move-effect-phase"; +import type { MoveEndPhase } from "#phases/move-end-phase"; /* biome-ignore-end lint/correctness/noUnusedImports: tsdoc imports */ import { applyAbAttrs } from "#abilities/apply-ab-attrs"; @@ -51,7 +53,8 @@ import { BerryModifierType } from "#modifiers/modifier-type"; import { applyMoveAttrs } from "#moves/apply-attrs"; import { noAbilityTypeOverrideMoves } from "#moves/invalid-moves"; import type { Move } from "#moves/move"; -import type { PokemonMove } from "#moves/pokemon-move"; +import { getMoveTargets } from "#moves/move-utils"; +import { PokemonMove } from "#moves/pokemon-move"; import type { StatStageChangePhase } from "#phases/stat-stage-change-phase"; import type { AbAttrCondition, @@ -5788,12 +5791,21 @@ export class InfiltratorAbAttr extends AbAttr { /** * Attribute implementing the effects of {@link https://bulbapedia.bulbagarden.net/wiki/Magic_Bounce_(ability) | Magic Bounce}. * Allows the source to bounce back {@linkcode MoveFlags.REFLECTABLE | Reflectable} - * moves as if the user had used {@linkcode MoveId.MAGIC_COAT | Magic Coat}. - * @sealed - * @todo Make reflection a part of this ability's effects + * moves as if the user had used {@linkcode MoveId.MAGIC_COAT | Magic Coat}. + * The calling {@linkcode MoveEffectPhase} will "skip" targets with a reflection effect active, + * showing the flyout and queueing the reaction during the move's {@linkcode MoveEndPhase}. */ -export class ReflectStatusMoveAbAttr extends AbAttr { - private declare readonly _: never; +export class ReflectStatusMoveAbAttr extends PreDefendAbAttr { + override apply({ pokemon, opponent, move }: AugmentMoveInteractionAbAttrParams): void { + const newTargets = move.isMultiTarget() ? getMoveTargets(pokemon, move.id).targets : [opponent.getBattlerIndex()]; + globalScene.phaseManager.unshiftNew( + "MovePhase", + pokemon, + newTargets, + new PokemonMove(move.id), + MoveUseMode.REFLECTED, + ); + } } // TODO: Make these ability attributes be flags instead of dummy attributes @@ -7250,10 +7262,7 @@ export function initAbilities() { .attr(PostIntimidateStatStageChangeAbAttr, [ Stat.SPD ], 1), new Ability(AbilityId.MAGIC_BOUNCE, 5) .attr(ReflectStatusMoveAbAttr) - .ignorable() - // Interactions with stomping tantrum, instruct, encore, and probably other moves that - // rely on move history - .edgeCase(), + .ignorable(), new Ability(AbilityId.SAP_SIPPER, 5) .attr(TypeImmunityStatStageChangeAbAttr, PokemonType.GRASS, Stat.ATK, 1) .ignorable(), @@ -7287,7 +7296,7 @@ export function initAbilities() { .attr(PostSummonMessageAbAttr, (pokemon: Pokemon) => i18next.t("abilityTriggers:postSummonTeravolt", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) })) .attr(MoveAbilityBypassAbAttr), new Ability(AbilityId.AROMA_VEIL, 6) - .attr(UserFieldBattlerTagImmunityAbAttr, [ BattlerTagType.INFATUATED, BattlerTagType.TAUNT, BattlerTagType.DISABLED, BattlerTagType.TORMENT, BattlerTagType.HEAL_BLOCK ]) + .attr(UserFieldBattlerTagImmunityAbAttr, [ BattlerTagType.INFATUATED, BattlerTagType.TAUNT, BattlerTagType.DISABLED, BattlerTagType.TORMENT, BattlerTagType.HEAL_BLOCK, BattlerTagType.ENCORE ]) .ignorable(), new Ability(AbilityId.FLOWER_VEIL, 6) .attr(ConditionalUserFieldStatusEffectImmunityAbAttr, (target: Pokemon, source: Pokemon | null) => { @@ -7351,7 +7360,7 @@ export function initAbilities() { new Ability(AbilityId.GOOEY, 6) .attr(PostDefendStatStageChangeAbAttr, (_target, _user, move) => move.hasFlag(MoveFlags.MAKES_CONTACT), Stat.SPD, -1, false), new Ability(AbilityId.AERILATE, 6) - .attr(MoveTypeChangeAbAttr, PokemonType.FLYING, 1.2, (_user, _target, move) => move.type === PokemonType.NORMAL), + .attr(MoveTypeChangeAbAttr, PokemonType.FLYING, 1.2, (_user, _target, move) => move.type === PokemonType.NORMAL), new Ability(AbilityId.PARENTAL_BOND, 6) .attr(AddSecondStrikeAbAttr, 0.25), new Ability(AbilityId.DARK_AURA, 6) diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 1199ac581a6..aa98e723767 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -28,6 +28,8 @@ import type { Pokemon } from "#field/pokemon"; import { applyMoveAttrs } from "#moves/apply-attrs"; import { invalidEncoreMoves } from "#moves/invalid-moves"; import type { Move } from "#moves/move"; +import { getMoveTargets } from "#moves/move-utils"; +import { PokemonMove } from "#moves/pokemon-move"; import type { MoveEffectPhase } from "#phases/move-effect-phase"; import type { MovePhase } from "#phases/move-phase"; import type { StatStageChangeCallback } from "#phases/stat-stage-change-phase"; @@ -174,6 +176,7 @@ export class BattlerTag implements BaseBattlerTag { return ""; } + // TODO: Make this a getter isSourceLinked(): boolean { return false; } @@ -1238,13 +1241,16 @@ export class FrenzyTag extends SerializableBattlerTag { */ export class EncoreTag extends MoveRestrictionBattlerTag { public override readonly tagType = BattlerTagType.ENCORE; - /** The ID of the move the user is locked into using */ + /** The {@linkcode MoveID} the tag holder is locked into */ public moveId: MoveId; constructor(sourceId: number) { + // Encore ends at the end of the 3rd turn it procs. + // If used on turn X when faster, it ends at the end of turn X+2. + // If used on turn X when slower, it ends at the end of turn X+3. super( BattlerTagType.ENCORE, - [BattlerTagLapseType.CUSTOM, BattlerTagLapseType.AFTER_MOVE], + [BattlerTagLapseType.AFTER_MOVE, BattlerTagLapseType.TURN_END], 3, MoveId.ENCORE, sourceId, @@ -1266,6 +1272,14 @@ export class EncoreTag extends MoveRestrictionBattlerTag { return false; } + if (!pokemon.getMoveset().some(m => m.moveId === lastMove.move && !m.isOutOfPp())) { + return false; + } + + if (pokemon.getTag(BattlerTagType.SHELL_TRAP)) { + return false; + } + this.moveId = lastMove.move; return true; @@ -1278,35 +1292,57 @@ export class EncoreTag extends MoveRestrictionBattlerTag { }), ); - const movePhase = globalScene.phaseManager.findPhase(m => m.is("MovePhase") && m.pokemon === pokemon); - if (movePhase) { - const movesetMove = pokemon.getMoveset().find(m => m.moveId === this.moveId); - if (movesetMove) { - const lastMove = pokemon.getLastXMoves(1)[0]; - globalScene.phaseManager.tryReplacePhase( - m => m.is("MovePhase") && m.pokemon === pokemon, - globalScene.phaseManager.create( - "MovePhase", - pokemon, - lastMove.targets ?? [], - movesetMove, - MoveUseMode.NORMAL, - ), - ); - } + // If the target has not moved yet, + // replace their upcoming move with the encored move against randomized targets + const movePhase = globalScene.phaseManager.findPhase( + (m): m is MovePhase => m.is("MovePhase") && m.pokemon === pokemon, + ); + if (!movePhase) { + return; } + + // Use the prior move in the moveset. + // Bang is justified as `canAdd` returns false if not found + const movesetMove = pokemon.getMoveset().find(m => m.moveId === this.moveId)!; + + const moveTargets = getMoveTargets(pokemon, this.moveId); + // Spread moves and ones with only 1 valid target will use their normal targeting. + // If not, target a random enemy in our target list + const targets = + moveTargets.multiple || moveTargets.targets.length === 1 + ? moveTargets.targets + : [moveTargets.targets[pokemon.randBattleSeedInt(moveTargets.targets.length)]]; + + globalScene.phaseManager.tryReplacePhase( + m => m.is("MovePhase") && m.pokemon === pokemon, + globalScene.phaseManager.create( + "MovePhase", + pokemon, + targets, + movesetMove, + movePhase.useMode, + movePhase.isForcedLast(), + ), + ); } /** - * If the encored move has run out of PP, Encore ends early. Otherwise, Encore lapses based on the AFTER_MOVE battler tag lapse type. - * @returns `true` to persist | `false` to end and be removed + * If the encored move has run out of PP or the tag's turn count has elapsed, + * Encore ends at the END of the turn. + * Otherwise, Encore's duration reduces when the target attempts to use a move. + * @returns Whether the tag should remain active. */ override lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { - if (lapseType === BattlerTagLapseType.CUSTOM) { - const encoredMove = pokemon.getMoveset().find(m => m.moveId === this.moveId); - return !isNullOrUndefined(encoredMove) && encoredMove.getPpRatio() > 0; + if (lapseType === BattlerTagLapseType.AFTER_MOVE) { + this.turnCount--; + return true; } - return super.lapse(pokemon, lapseType); + + const encoredMove = pokemon.getMoveset().find(m => m.moveId === this.moveId); + if (isNullOrUndefined(encoredMove) || encoredMove.isOutOfPp()) { + return false; + } + return this.turnCount > 0; } /** @@ -1489,12 +1525,8 @@ export class MinimizeTag extends SerializableBattlerTag { export class DrowsyTag extends SerializableBattlerTag { public override readonly tagType = BattlerTagType.DROWSY; - constructor() { - super(BattlerTagType.DROWSY, BattlerTagLapseType.TURN_END, 2, MoveId.YAWN); - } - - canAdd(pokemon: Pokemon): boolean { - return globalScene.arena.terrain?.terrainType !== TerrainType.ELECTRIC || !pokemon.isGrounded(); + constructor(sourceId: number) { + super(BattlerTagType.DROWSY, BattlerTagLapseType.TURN_END, 2, MoveId.YAWN, sourceId); } onAdd(pokemon: Pokemon): void { @@ -1509,6 +1541,7 @@ export class DrowsyTag extends SerializableBattlerTag { lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { if (!super.lapse(pokemon, lapseType)) { + // TODO: Safeguard should not prevent yawn from setting sleep after tag use pokemon.trySetStatus(StatusEffect.SLEEP, true); return false; } @@ -3632,6 +3665,23 @@ export class MagicCoatTag extends BattlerTag { }), ); } + + /** + * Apply the tag to reflect a move. + * @param pokemon - The {@linkcode Pokemon} to whom this tag belongs + * @param opponent - The {@linkcode Pokemon} having originally used the move + * @param move - The {@linkcode Move} being used + */ + public apply(pokemon: Pokemon, opponent: Pokemon, move: Move): void { + const newTargets = move.isMultiTarget() ? getMoveTargets(pokemon, move.id).targets : [opponent.getBattlerIndex()]; + globalScene.phaseManager.unshiftNew( + "MovePhase", + pokemon, + newTargets, + new PokemonMove(move.id), + MoveUseMode.REFLECTED, + ); + } } /** @@ -3679,7 +3729,7 @@ export function getBattlerTag( case BattlerTagType.AQUA_RING: return new AquaRingTag(); case BattlerTagType.DROWSY: - return new DrowsyTag(); + return new DrowsyTag(sourceId); case BattlerTagType.TRAPPED: return new TrappedTag(tagType, BattlerTagLapseType.CUSTOM, turnCount, sourceMove, sourceId); case BattlerTagType.NO_RETREAT: diff --git a/src/data/moves/invalid-moves.ts b/src/data/moves/invalid-moves.ts index e55eedc29aa..af13bbe416b 100644 --- a/src/data/moves/invalid-moves.ts +++ b/src/data/moves/invalid-moves.ts @@ -280,3 +280,68 @@ export const invalidEncoreMoves: ReadonlySet = new Set([ MoveId.SLEEP_TALK, MoveId.ENCORE, ]); + +export const invalidInstructMoves: ReadonlySet = new Set([ + // Locking/Continually Executed moves + MoveId.OUTRAGE, + MoveId.RAGING_FURY, + MoveId.ROLLOUT, + MoveId.PETAL_DANCE, + MoveId.THRASH, + MoveId.ICE_BALL, + MoveId.UPROAR, + // Multi-turn Moves + MoveId.BIDE, + MoveId.SHELL_TRAP, + MoveId.BEAK_BLAST, + MoveId.FOCUS_PUNCH, + // "First Turn Only" moves + MoveId.FAKE_OUT, + MoveId.FIRST_IMPRESSION, + MoveId.MAT_BLOCK, + // Moves with a recharge turn + MoveId.HYPER_BEAM, + MoveId.ETERNABEAM, + MoveId.FRENZY_PLANT, + MoveId.BLAST_BURN, + MoveId.HYDRO_CANNON, + MoveId.GIGA_IMPACT, + MoveId.PRISMATIC_LASER, + MoveId.ROAR_OF_TIME, + MoveId.ROCK_WRECKER, + MoveId.METEOR_ASSAULT, + // Charging & 2-turn moves + MoveId.DIG, + MoveId.FLY, + MoveId.BOUNCE, + MoveId.SHADOW_FORCE, + MoveId.PHANTOM_FORCE, + MoveId.DIVE, + MoveId.ELECTRO_SHOT, + MoveId.ICE_BURN, + MoveId.GEOMANCY, + MoveId.FREEZE_SHOCK, + MoveId.SKY_DROP, + MoveId.SKY_ATTACK, + MoveId.SKULL_BASH, + MoveId.SOLAR_BEAM, + MoveId.SOLAR_BLADE, + MoveId.METEOR_BEAM, + // Copying/Move-Calling moves + MoveId.ASSIST, + MoveId.COPYCAT, + MoveId.ME_FIRST, + MoveId.METRONOME, + MoveId.MIRROR_MOVE, + MoveId.NATURE_POWER, + MoveId.SLEEP_TALK, + MoveId.SNATCH, + MoveId.INSTRUCT, + // Misc moves + MoveId.KINGS_SHIELD, + MoveId.SKETCH, + MoveId.TRANSFORM, + MoveId.MIMIC, + MoveId.STRUGGLE, + // TODO: Add Max/G-Max/Z-Move blockage if or when they are implemented +]); diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 067bd05c2ae..85185b5e0fd 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -11,6 +11,7 @@ import { WeakenMoveTypeTag } from "#data/arena-tag"; import { MoveChargeAnim } from "#data/battle-anims"; import { CommandedTag, + DrowsyTag, EncoreTag, GulpMissileTag, HelpingHandTag, @@ -77,7 +78,7 @@ import { PreserveBerryModifier, } from "#modifiers/modifier"; import { applyMoveAttrs } from "#moves/apply-attrs"; -import { invalidAssistMoves, invalidCopycatMoves, invalidMetronomeMoves, invalidMirrorMoveMoves, invalidSketchMoves, invalidSleepTalkMoves } from "#moves/invalid-moves"; +import { invalidAssistMoves, invalidCopycatMoves, invalidInstructMoves, invalidMetronomeMoves, invalidMirrorMoveMoves, invalidSketchMoves, invalidSleepTalkMoves } from "#moves/invalid-moves"; import { frenzyMissFunc, getMoveTargets } from "#moves/move-utils"; import { PokemonMove } from "#moves/pokemon-move"; import { MoveEndPhase } from "#phases/move-end-phase"; @@ -673,20 +674,9 @@ export abstract class Move implements Localizable { return true; } break; - case MoveFlags.REFLECTABLE: - // If the target is not semi-invulnerable and either has magic coat active or an unignored magic bounce ability - if ( - target?.getTag(SemiInvulnerableTag) || - !(target?.getTag(BattlerTagType.MAGIC_COAT) || - (!this.doesFlagEffectApply({ flag: MoveFlags.IGNORE_ABILITIES, user, target }) && - target?.hasAbilityWithAttr("ReflectStatusMoveAbAttr"))) - ) { - return false; - } - break; } - return !!(this.flags & flag); + return this.hasFlag(flag) } /** @@ -5689,6 +5679,34 @@ export class AddBattlerTagAttr extends MoveEffectAttr { } } +/** + * Attribute to implement {@linkcode MoveId.YAWN}. + * Yawn adds a BattlerTag to its target that puts them to sleep at the end + * of the next turn, retaining many of the same checks as normal status setting moves. + */ +export class YawnAttr extends AddBattlerTagAttr { + constructor() { + super(BattlerTagType.DROWSY, false, true) + } + + getCondition(): MoveConditionFunc { + return (user, target, move) => { + if (!super.getCondition()!(user, target, move)) { + return false; + } + + // Statused opponents or ones with safeguard active use a generic failure message + if (target.status || target.isSafeguarded(user)) { + return false; + } + + // TODO: This does not display the cause of the "but it failed" message, + // but fixing it would require a rework of the move failure system + return target.canSetStatus(StatusEffect.SLEEP, true, false, user) + } + } +} + /** * Adds a {@link https://bulbapedia.bulbagarden.net/wiki/Seeding | Seeding} effect to the target * as seen with Leech Seed and Sappy Seed. @@ -5916,8 +5934,8 @@ export class ProtectAttr extends AddBattlerTagAttr { for (const turnMove of user.getLastXMoves(-1).slice()) { if ( // Quick & Wide guard increment the Protect counter without using it for fail chance - !(allMoves[turnMove.move].hasAttr("ProtectAttr") || - [MoveId.QUICK_GUARD, MoveId.WIDE_GUARD].includes(turnMove.move)) || + !(allMoves[turnMove.move].hasAttr("ProtectAttr") || + [MoveId.QUICK_GUARD, MoveId.WIDE_GUARD].includes(turnMove.move)) || turnMove.result !== MoveResult.SUCCESS ) { break; @@ -7166,7 +7184,6 @@ export class RepeatMoveAttr extends MoveEffectAttr { // bangs are justified as Instruct fails if no prior move or moveset move exists // TODO: How does instruct work when copying a move called via Copycat that the user itself knows? const lastMove = target.getLastNonVirtualMove()!; - const movesetMove = target.getMoveset().find(m => m.moveId === lastMove?.move)! // If the last move used can hit more than one target or has variable targets, // re-compute the targets for the attack (mainly for alternating double/single battles) @@ -7190,12 +7207,18 @@ export class RepeatMoveAttr extends MoveEffectAttr { } } + // If the target is currently affected by Encore, increase its duration by 1 (to offset decrease during move use) + const targetEncore = target.getTag(BattlerTagType.ENCORE) as EncoreTag | undefined; + if (targetEncore) { + targetEncore.turnCount++ + } + globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:instructingMove", { userPokemonName: getPokemonNameWithAffix(user), targetPokemonName: getPokemonNameWithAffix(target) })); target.turnData.extraTurns++; - globalScene.phaseManager.appendNewToPhase("MoveEndPhase", "MovePhase", target, moveTargets, movesetMove, MoveUseMode.NORMAL); + globalScene.phaseManager.appendNewToPhase("MoveEndPhase", "MovePhase", target, moveTargets, this.movesetMove, MoveUseMode.NORMAL); return true; } @@ -7204,77 +7227,13 @@ export class RepeatMoveAttr extends MoveEffectAttr { // TODO: Check instruct behavior with struggle - ignore, fail or success const lastMove = target.getLastNonVirtualMove(); const movesetMove = target.getMoveset().find(m => m.moveId === lastMove?.move); - const uninstructableMoves = [ - // Locking/Continually Executed moves - MoveId.OUTRAGE, - MoveId.RAGING_FURY, - MoveId.ROLLOUT, - MoveId.PETAL_DANCE, - MoveId.THRASH, - MoveId.ICE_BALL, - MoveId.UPROAR, - // Multi-turn Moves - MoveId.BIDE, - MoveId.SHELL_TRAP, - MoveId.BEAK_BLAST, - MoveId.FOCUS_PUNCH, - // "First Turn Only" moves - MoveId.FAKE_OUT, - MoveId.FIRST_IMPRESSION, - MoveId.MAT_BLOCK, - // Moves with a recharge turn - MoveId.HYPER_BEAM, - MoveId.ETERNABEAM, - MoveId.FRENZY_PLANT, - MoveId.BLAST_BURN, - MoveId.HYDRO_CANNON, - MoveId.GIGA_IMPACT, - MoveId.PRISMATIC_LASER, - MoveId.ROAR_OF_TIME, - MoveId.ROCK_WRECKER, - MoveId.METEOR_ASSAULT, - // Charging & 2-turn moves - MoveId.DIG, - MoveId.FLY, - MoveId.BOUNCE, - MoveId.SHADOW_FORCE, - MoveId.PHANTOM_FORCE, - MoveId.DIVE, - MoveId.ELECTRO_SHOT, - MoveId.ICE_BURN, - MoveId.GEOMANCY, - MoveId.FREEZE_SHOCK, - MoveId.SKY_DROP, - MoveId.SKY_ATTACK, - MoveId.SKULL_BASH, - MoveId.SOLAR_BEAM, - MoveId.SOLAR_BLADE, - MoveId.METEOR_BEAM, - // Copying/Move-Calling moves - MoveId.ASSIST, - MoveId.COPYCAT, - MoveId.ME_FIRST, - MoveId.METRONOME, - MoveId.MIRROR_MOVE, - MoveId.NATURE_POWER, - MoveId.SLEEP_TALK, - MoveId.SNATCH, - MoveId.INSTRUCT, - // Misc moves - MoveId.KINGS_SHIELD, - MoveId.SKETCH, - MoveId.TRANSFORM, - MoveId.MIMIC, - MoveId.STRUGGLE, - // TODO: Add Max/G-Max/Z-Move blockage if or when they are implemented - ]; - if (!lastMove?.move // no move to instruct + if ( + !lastMove?.move // no move to instruct || !movesetMove // called move not in target's moveset (forgetting the move, etc.) - || movesetMove.ppUsed === movesetMove.getMovePp() // move out of pp - // TODO: This next line is likely redundant as all charging moves are in the above list - || allMoves[lastMove.move].isChargingMove() // called move is a charging/recharging move - || uninstructableMoves.includes(lastMove.move)) { // called move is in the banlist + || movesetMove.isOutOfPp() // move out of pp + || invalidInstructMoves.has(lastMove.move) // called move is in the banlist + ) { return false; } this.movesetMove = movesetMove; @@ -7988,7 +7947,7 @@ export class AfterYouAttr extends MoveEffectAttr { globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:afterYou", { targetName: getPokemonNameWithAffix(target) })); // Will find next acting phase of the targeted pokémon, delete it and queue it right after us. - const targetNextPhase = globalScene.phaseManager.findPhase(phase => phase.pokemon === target); + const targetNextPhase = globalScene.phaseManager.findPhase((phase): phase is MovePhase => phase.is("MovePhase") && phase.pokemon === target); if (targetNextPhase && globalScene.phaseManager.tryRemovePhase((phase: MovePhase) => phase.pokemon === target)) { globalScene.phaseManager.prependToPhase(targetNextPhase, "MovePhase"); } @@ -8016,7 +7975,7 @@ export class ForceLastAttr extends MoveEffectAttr { globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:forceLast", { targetPokemonName: getPokemonNameWithAffix(target) })); // TODO: Refactor this to be more readable and less janky - const targetMovePhase = globalScene.phaseManager.findPhase((phase) => phase.pokemon === target); + const targetMovePhase = globalScene.phaseManager.findPhase((phase): phase is MovePhase => phase.is("MovePhase") && phase.pokemon === target); if (targetMovePhase && !targetMovePhase.isForcedLast() && globalScene.phaseManager.tryRemovePhase((phase: MovePhase) => phase.pokemon === target)) { // Finding the phase to insert the move in front of - // Either the end of the turn or in front of another, slower move which has also been forced last @@ -9195,11 +9154,11 @@ export function initMoves() { .hidesUser(), new StatusMove(MoveId.ENCORE, PokemonType.NORMAL, 100, 5, -1, 0, 2) .attr(AddBattlerTagAttr, BattlerTagType.ENCORE, false, true) - .ignoresSubstitute() .condition((user, target, move) => new EncoreTag(user.id).canAdd(target)) + .ignoresSubstitute() .reflectable() - // Can lock infinitely into struggle; has incorrect interactions with Blood Moon/Gigaton Hammer - // Also may or may not incorrectly select targets for replacement move (needs verification) + // has incorrect interactions with Blood Moon/Gigaton Hammer + // TODO: Verify if Encore's duration decreases during status based move failures .edgeCase(), new AttackMove(MoveId.PURSUIT, PokemonType.DARK, MoveCategory.PHYSICAL, 40, 100, 20, -1, 0, 2) .partial(), // No effect implemented @@ -9372,9 +9331,7 @@ export function initMoves() { new SelfStatusMove(MoveId.MAGIC_COAT, PokemonType.PSYCHIC, -1, 15, -1, 4, 3) .attr(AddBattlerTagAttr, BattlerTagType.MAGIC_COAT, true, true, 0) .condition(failIfLastCondition) - // Interactions with stomping tantrum, instruct, and other moves that - // rely on move history - // Also will not reflect roar / whirlwind if the target has ForceSwitchOutImmunityAbAttr + // Will not reflect roar / whirlwind if the target has ForceSwitchOutImmunityAbAttr .edgeCase(), new SelfStatusMove(MoveId.RECYCLE, PokemonType.NORMAL, -1, 10, -1, 0, 3) .unimplemented(), @@ -9383,11 +9340,11 @@ export function initMoves() { new AttackMove(MoveId.BRICK_BREAK, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 75, 100, 15, -1, 0, 3) .attr(RemoveScreensAttr), new StatusMove(MoveId.YAWN, PokemonType.NORMAL, -1, 10, -1, 0, 3) - .attr(AddBattlerTagAttr, BattlerTagType.DROWSY, false, true) - .condition((user, target, move) => !target.status && !target.isSafeguarded(user)) - .reflectable(), + .attr(YawnAttr) + .reflectable() + .edgeCase(), // Should not be blocked by safeguard once tag is applied new AttackMove(MoveId.KNOCK_OFF, PokemonType.DARK, MoveCategory.PHYSICAL, 65, 100, 20, -1, 0, 3) - .attr(MovePowerMultiplierAttr, (user, target, move) => target.getHeldItems().filter(i => i.isTransferable).length > 0 ? 1.5 : 1) + .attr(MovePowerMultiplierAttr, (_user, target, _move) => target.getHeldItems().some(i => i.isTransferable) ? 1.5 : 1) .attr(RemoveHeldItemAttr, false) .edgeCase(), // Should not be able to remove held item if user faints due to Rough Skin, Iron Barbs, etc. @@ -10665,11 +10622,10 @@ export function initMoves() { new AttackMove(MoveId.TROP_KICK, PokemonType.GRASS, MoveCategory.PHYSICAL, 70, 100, 15, 100, 0, 7) .attr(StatStageChangeAttr, [ Stat.ATK ], -1), new StatusMove(MoveId.INSTRUCT, PokemonType.PSYCHIC, -1, 15, -1, 0, 7) - .ignoresSubstitute() .attr(RepeatMoveAttr) + .ignoresSubstitute() /* * Incorrect interactions with Gigaton Hammer, Blood Moon & Torment due to them _failing on use_, not merely being unselectable. - * Incorrectly ticks down Encore's fail counter * TODO: Verify whether Instruct can repeat Struggle * TODO: Verify whether Instruct can fail when using a copied move also in one's own moveset */ diff --git a/src/data/moves/pokemon-move.ts b/src/data/moves/pokemon-move.ts index 3c96cbea598..61945fc768e 100644 --- a/src/data/moves/pokemon-move.ts +++ b/src/data/moves/pokemon-move.ts @@ -73,6 +73,7 @@ export class PokemonMove { this.ppUsed = Math.min(this.ppUsed + count, this.getMovePp()); } + // TODO: Rename to `getMaxPP` getMovePp(): number { return this.maxPpOverride || this.getMove().pp + this.ppUp * toDmgValue(this.getMove().pp / 5); } @@ -81,6 +82,10 @@ export class PokemonMove { return 1 - this.ppUsed / this.getMovePp(); } + public isOutOfPp(): boolean { + return this.ppUsed >= this.getMovePp(); + } + getName(): string { return this.getMove().name; } diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 29f775ad094..c5128b30b8d 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -4430,14 +4430,19 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { /** * Return this Pokemon's move history. - * Entries are sorted in order of OLDEST to NEWEST - * @returns An array of {@linkcode TurnMove}, as described above. + * Entries are sorted in order of OLDEST to NEWEST. + * @returns An array of {@linkcode TurnMove}s, as described above. * @see {@linkcode getLastXMoves} */ public getMoveHistory(): TurnMove[] { return this.summonData.moveHistory; } + /** + * Add a move to the end of this {@linkcode Pokemon}'s move history, + * used to record its most recently executed actions. + * @param turnMove - The {@linkcode TurnMove} to add + */ public pushMoveHistory(turnMove: TurnMove): void { if (!this.isOnField()) { return; diff --git a/src/phase-manager.ts b/src/phase-manager.ts index aa01a0ffc10..dacff01ccaf 100644 --- a/src/phase-manager.ts +++ b/src/phase-manager.ts @@ -44,6 +44,7 @@ import { MoveEffectPhase } from "#phases/move-effect-phase"; import { MoveEndPhase } from "#phases/move-end-phase"; import { MoveHeaderPhase } from "#phases/move-header-phase"; import { MovePhase } from "#phases/move-phase"; +import { MoveReflectPhase } from "#phases/move-reflect-phase"; import { MysteryEncounterBattlePhase, MysteryEncounterBattleStartCleanupPhase, @@ -157,6 +158,7 @@ const PHASES = Object.freeze({ MoveEffectPhase, MoveEndPhase, MoveHeaderPhase, + MoveReflectPhase, MovePhase, MysteryEncounterPhase, MysteryEncounterOptionSelectedPhase, @@ -414,6 +416,8 @@ export class PhaseManager { * @param phaseFilter filter function to use to find the wanted phase * @returns the found phase or undefined if none found */ + findPhase

(phaseFilter: (phase: Phase) => phase is P): P | undefined; + findPhase

(phaseFilter: (phase: P) => boolean): P | undefined; findPhase

(phaseFilter: (phase: P) => boolean): P | undefined { return this.phaseQueue.find(phaseFilter) as P | undefined; } diff --git a/src/phases/command-phase.ts b/src/phases/command-phase.ts index ff9ee7cc197..b778d2dc475 100644 --- a/src/phases/command-phase.ts +++ b/src/phases/command-phase.ts @@ -178,11 +178,6 @@ export class CommandPhase extends FieldPhase { this.checkCommander(); - const playerPokemon = this.getPokemon(); - - // Note: It is OK to call this if the target is not under the effect of encore; it will simply do nothing. - playerPokemon.lapseTag(BattlerTagType.ENCORE); - if (globalScene.currentBattle.turnCommands[this.fieldIndex]?.skip) { this.end(); return; diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index 767d7a79968..32ddc5ba542 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -1,7 +1,6 @@ import { applyAbAttrs } from "#abilities/apply-ab-attrs"; import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; -import type { Phase } from "#app/phase"; import { ConditionalProtectTag } from "#data/arena-tag"; import { MoveAnim } from "#data/battle-anims"; import { DamageProtectedTag, ProtectedTag, SemiInvulnerableTag, SubstituteTag, TypeBoostTag } from "#data/battler-tags"; @@ -33,8 +32,7 @@ import { } from "#modifiers/modifier"; import { applyFilteredMoveAttrs, applyMoveAttrs } from "#moves/apply-attrs"; import type { Move, MoveAttr } from "#moves/move"; -import { getMoveTargets, isFieldTargeted } from "#moves/move-utils"; -import { PokemonMove } from "#moves/pokemon-move"; +import { isFieldTargeted } from "#moves/move-utils"; import { PokemonPhase } from "#phases/pokemon-phase"; import { DamageAchv } from "#system/achv"; import type { DamageResult } from "#types/damage-result"; @@ -67,12 +65,6 @@ export class MoveEffectPhase extends PokemonPhase { /** Is this the last strike of a move? */ private lastHit: boolean; - /** - * Phases queued during moves; used to add a new MovePhase for reflected moves after triggering. - * TODO: Remove this and move the reflection logic to ability-side - */ - private queuedPhases: Phase[] = []; - /** * @param useMode - The {@linkcode MoveUseMode} corresponding to how this move was used. */ @@ -95,143 +87,11 @@ export class MoveEffectPhase extends PokemonPhase { this.hitChecks = Array(this.targets.length).fill([HitCheckResult.PENDING, 0]); } - /** - * Compute targets and the results of hit checks of the invoked move against all targets, - * organized by battler index. - * - * **This is *not* a pure function**; it has the following side effects - * - `this.hitChecks` - The results of the hit checks against each target - * - `this.moveHistoryEntry` - Sets success or failure based on the hit check results - * - user.turnData.hitCount and user.turnData.hitsLeft - Both set to 1 if the - * move was unsuccessful against all targets - * - * @returns The targets of the invoked move - * @see {@linkcode hitCheck} - */ - private conductHitChecks(user: Pokemon, fieldMove: boolean): Pokemon[] { - /** All Pokemon targeted by this phase's invoked move */ - /** Whether any hit check ended in a success */ - let anySuccess = false; - /** Whether the attack missed all of its targets */ - let allMiss = true; - - let targets = this.getTargets(); - - // For field targeted moves, we only look for the first target that may magic bounce - - for (const [i, target] of targets.entries()) { - const hitCheck = this.hitCheck(target); - // If the move bounced and was a field targeted move, - // then immediately stop processing other targets - if (fieldMove && hitCheck[0] === HitCheckResult.REFLECTED) { - targets = [target]; - this.hitChecks = [hitCheck]; - break; - } - if (hitCheck[0] === HitCheckResult.HIT) { - anySuccess = true; - } else { - allMiss ||= hitCheck[0] === HitCheckResult.MISS; - } - this.hitChecks[i] = hitCheck; - } - - if (anySuccess) { - this.moveHistoryEntry.result = MoveResult.SUCCESS; - } else { - user.turnData.hitCount = 1; - user.turnData.hitsLeft = 1; - this.moveHistoryEntry.result = allMiss ? MoveResult.MISS : MoveResult.FAIL; - } - - return targets; - } - - /** - * Queue the phaes that should occur when the target reflects the move back to the user - * @param user - The {@linkcode Pokemon} using this phase's invoked move - * @param target - The {@linkcode Pokemon} that is reflecting the move - * TODO: Rework this to use `onApply` of Magic Coat - */ - private queueReflectedMove(user: Pokemon, target: Pokemon): void { - const newTargets = this.move.isMultiTarget() - ? getMoveTargets(target, this.move.id).targets - : [user.getBattlerIndex()]; - // TODO: ability displays should be handled by the ability - if (!target.getTag(BattlerTagType.MAGIC_COAT)) { - this.queuedPhases.push( - globalScene.phaseManager.create( - "ShowAbilityPhase", - target.getBattlerIndex(), - target.getPassiveAbility().hasAttr("ReflectStatusMoveAbAttr"), - ), - ); - this.queuedPhases.push(globalScene.phaseManager.create("HideAbilityPhase")); - } - - this.queuedPhases.push( - globalScene.phaseManager.create( - "MovePhase", - target, - newTargets, - new PokemonMove(this.move.id), - MoveUseMode.REFLECTED, - ), - ); - } - - /** - * Apply the move to each of the resolved targets. - * @param targets - The resolved set of targets of the move - * @throws Error if there was an unexpected hit check result - */ - private applyToTargets(user: Pokemon, targets: Pokemon[]): void { - let firstHit = true; - for (const [i, target] of targets.entries()) { - const [hitCheckResult, effectiveness] = this.hitChecks[i]; - switch (hitCheckResult) { - case HitCheckResult.HIT: - this.applyMoveEffects(target, effectiveness, firstHit); - firstHit = false; - if (isFieldTargeted(this.move)) { - // Stop processing other targets if the move is a field move - return; - } - break; - // biome-ignore lint/suspicious/noFallthroughSwitchClause: The fallthrough is intentional - case HitCheckResult.NO_EFFECT: - globalScene.phaseManager.queueMessage( - i18next.t(this.move.id === MoveId.SHEER_COLD ? "battle:hitResultImmune" : "battle:hitResultNoEffect", { - pokemonName: getPokemonNameWithAffix(target), - }), - ); - case HitCheckResult.NO_EFFECT_NO_MESSAGE: - case HitCheckResult.PROTECTED: - case HitCheckResult.TARGET_NOT_ON_FIELD: - applyMoveAttrs("NoEffectAttr", user, target, this.move); - break; - case HitCheckResult.MISS: - globalScene.phaseManager.queueMessage( - i18next.t("battle:attackMissed", { pokemonNameWithAffix: getPokemonNameWithAffix(target) }), - ); - applyMoveAttrs("MissEffectAttr", user, target, this.move); - break; - case HitCheckResult.REFLECTED: - this.queueReflectedMove(user, target); - break; - case HitCheckResult.PENDING: - case HitCheckResult.ERROR: - throw new Error("Unexpected hit check result"); - } - } - } - public override start(): void { super.start(); /** The Pokemon using this phase's invoked move */ const user = this.getUserPokemon(); - if (!user) { super.end(); return; @@ -326,6 +186,58 @@ export class MoveEffectPhase extends PokemonPhase { this.postAnimCallback(user, targets); } + /** + * Compute targets and the results of hit checks of the invoked move against all targets, + * organized by battler index. + * + * **This is *not* a pure function**; it has the following side effects + * - `this.hitChecks` - The results of the hit checks against each target + * - `this.moveHistoryEntry` - Sets success or failure based on the hit check results + * - user.turnData.hitCount and user.turnData.hitsLeft - Both set to 1 if the + * move was unsuccessful against all targets + * + * @returns The targets of the invoked move + * @see {@linkcode hitCheck} + */ + private conductHitChecks(user: Pokemon, fieldMove: boolean): Pokemon[] { + /** All Pokemon targeted by this phase's invoked move */ + /** Whether any hit check ended in a success */ + let anySuccess = false; + /** Whether the attack missed all of its targets */ + let allMiss = true; + + let targets = this.getTargets(); + + // For field targeted moves, we only look for the first target that may magic bounce + + for (const [i, target] of targets.entries()) { + const hitCheck = this.hitCheck(target); + // If the move bounced and was a field targeted move, + // then immediately stop processing other targets + if (fieldMove && hitCheck[0] === HitCheckResult.REFLECTED) { + targets = [target]; + this.hitChecks = [hitCheck]; + break; + } + if (hitCheck[0] === HitCheckResult.HIT) { + anySuccess = true; + } else { + allMiss ||= hitCheck[0] === HitCheckResult.MISS; + } + this.hitChecks[i] = hitCheck; + } + + if (anySuccess) { + this.moveHistoryEntry.result = MoveResult.SUCCESS; + } else { + user.turnData.hitCount = 1; + user.turnData.hitsLeft = 1; + this.moveHistoryEntry.result = allMiss ? MoveResult.MISS : MoveResult.FAIL; + } + + return targets; + } + /** * Callback to be called after the move animation is played */ @@ -344,9 +256,6 @@ export class MoveEffectPhase extends PokemonPhase { return; } - if (this.queuedPhases.length) { - globalScene.phaseManager.appendToPhase(this.queuedPhases, "MoveEndPhase"); - } const moveType = user.getMoveType(this.move, true); if (this.move.category !== MoveCategory.STATUS && !user.stellarTypesBoosted.includes(moveType)) { user.stellarTypesBoosted.push(moveType); @@ -360,121 +269,52 @@ export class MoveEffectPhase extends PokemonPhase { this.end(); } - public override end(): void { - const user = this.getUserPokemon(); - if (!user) { - super.end(); - return; - } - - /** - * If this phase isn't for the invoked move's last strike (and we still have something to hit), - * unshift another MoveEffectPhase for the next strike before ending this phase. - */ - if (--user.turnData.hitsLeft >= 1 && this.getFirstTarget()) { - this.addNextHitPhase(); - super.end(); - return; - } - - /** - * All hits of the move have resolved by now. - * Queue message for multi-strike moves before applying Shell Bell heals & proccing Dancer-like effects. - */ - const hitsTotal = user.turnData.hitCount - Math.max(user.turnData.hitsLeft, 0); - if (hitsTotal > 1 || user.turnData.hitsLeft > 0) { - // Queue message if multiple hits occurred or were slated to occur (such as a Triple Axel miss) - globalScene.phaseManager.queueMessage(i18next.t("battle:attackHitsCount", { count: hitsTotal })); - } - - globalScene.applyModifiers(HitHealModifier, this.player, user); - this.getTargets().forEach(target => { - target.turnData.moveEffectiveness = null; - }); - super.end(); - } - /** - * Applies reactive effects that occur when a Pokémon is hit. - * (i.e. Effect Spore, Disguise, Liquid Ooze, Beak Blast) - * @param user - The {@linkcode Pokemon} using this phase's invoked move - * @param target - {@linkcode Pokemon} the current target of this phase's invoked move - * @param hitResult - The {@linkcode HitResult} of the attempted move - * @param wasCritical - `true` if the move was a critical hit + * Apply the move to each of the resolved targets. + * @param targets - The resolved set of targets of the move + * @throws Error if there was an unexpected hit check result */ - protected applyOnGetHitAbEffects(user: Pokemon, target: Pokemon, hitResult: HitResult, wasCritical = false): void { - const params = { pokemon: target, opponent: user, move: this.move, hitResult }; - applyAbAttrs("PostDefendAbAttr", params); - - if (wasCritical) { - applyAbAttrs("PostReceiveCritStatStageChangeAbAttr", params); - } - target.lapseTags(BattlerTagLapseType.AFTER_HIT); - } - - /** - * Handles checking for and applying Flinches - * @param user - The {@linkcode Pokemon} using this phase's invoked move - * @param target - {@linkcode Pokemon} the current target of this phase's invoked move - * @param dealsDamage - `true` if the attempted move successfully dealt damage - */ - protected applyHeldItemFlinchCheck(user: Pokemon, target: Pokemon, dealsDamage: boolean): void { - if (this.move.hasAttr("FlinchAttr")) { - return; - } - - if ( - dealsDamage && - !target.hasAbilityWithAttr("IgnoreMoveEffectsAbAttr") && - !this.move.hitsSubstitute(user, target) - ) { - const flinched = new BooleanHolder(false); - globalScene.applyModifiers(FlinchChanceModifier, user.isPlayer(), user, flinched); - if (flinched.value) { - target.addTag(BattlerTagType.FLINCHED, undefined, this.move.id, user.id); + private applyToTargets(user: Pokemon, targets: Pokemon[]): void { + let firstHit = true; + for (const [i, target] of targets.entries()) { + const [hitCheckResult, effectiveness] = this.hitChecks[i]; + switch (hitCheckResult) { + case HitCheckResult.HIT: + this.applyMoveEffects(target, effectiveness, firstHit); + firstHit = false; + if (isFieldTargeted(this.move)) { + // Stop processing other targets if the move is a field move + return; + } + break; + // biome-ignore lint/suspicious/noFallthroughSwitchClause: The fallthrough is intentional + case HitCheckResult.NO_EFFECT: + globalScene.phaseManager.queueMessage( + i18next.t(this.move.id === MoveId.SHEER_COLD ? "battle:hitResultImmune" : "battle:hitResultNoEffect", { + pokemonName: getPokemonNameWithAffix(target), + }), + ); + case HitCheckResult.NO_EFFECT_NO_MESSAGE: + case HitCheckResult.PROTECTED: + case HitCheckResult.TARGET_NOT_ON_FIELD: + applyMoveAttrs("NoEffectAttr", user, target, this.move); + break; + case HitCheckResult.MISS: + globalScene.phaseManager.queueMessage( + i18next.t("battle:attackMissed", { pokemonNameWithAffix: getPokemonNameWithAffix(target) }), + ); + applyMoveAttrs("MissEffectAttr", user, target, this.move); + break; + case HitCheckResult.REFLECTED: + globalScene.phaseManager.appendNewToPhase("MoveEndPhase", "MoveReflectPhase", target, user, this.move); + break; + case HitCheckResult.PENDING: + case HitCheckResult.ERROR: + throw new Error("Unexpected hit check result"); } } } - /** Return whether the target is protected by protect or a relevant conditional protection - * @param user - The {@linkcode Pokemon} using this phase's invoked move - * @param target - {@linkcode Pokemon} the target to check for protection - * @param move - The {@linkcode Move} being used - * @returns Whether the pokemon was protected - */ - private protectedCheck(user: Pokemon, target: Pokemon): boolean { - /** The {@linkcode ArenaTagSide} to which the target belongs */ - const targetSide = target.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; - /** Has the invoked move been cancelled by conditional protection (e.g Quick Guard)? */ - const hasConditionalProtectApplied = new BooleanHolder(false); - /** Does the applied conditional protection bypass Protect-ignoring effects? */ - const bypassIgnoreProtect = new BooleanHolder(false); - /** If the move is not targeting a Pokemon on the user's side, try to apply conditional protection effects */ - if (!this.move.isAllyTarget()) { - globalScene.arena.applyTagsForSide( - ConditionalProtectTag, - targetSide, - false, - hasConditionalProtectApplied, - user, - target, - this.move.id, - bypassIgnoreProtect, - ); - } - - // TODO: Break up this chunky boolean to make it more palatable - return ( - ![MoveTarget.ENEMY_SIDE, MoveTarget.BOTH_SIDES].includes(this.move.moveTarget) && - (bypassIgnoreProtect.value || !this.move.doesFlagEffectApply({ flag: MoveFlags.IGNORE_PROTECT, user, target })) && - (hasConditionalProtectApplied.value || - (!target.findTags(t => t instanceof DamageProtectedTag).length && - target.findTags(t => t instanceof ProtectedTag).some(t => target.lapseTag(t.tagType))) || - (this.move.category !== MoveCategory.STATUS && - target.findTags(t => t instanceof DamageProtectedTag).some(t => target.lapseTag(t.tagType)))) - ); - } - /** * Conduct the hit check and type effectiveness for this move against the target * @@ -495,10 +335,6 @@ export class MoveEffectPhase extends PokemonPhase { const user = this.getUserPokemon(); const move = this.move; - if (!user) { - return [HitCheckResult.ERROR, 0]; - } - // Moves targeting the user bypass all checks if (move.moveTarget === MoveTarget.USER) { return [HitCheckResult.HIT, 1]; @@ -532,7 +368,7 @@ export class MoveEffectPhase extends PokemonPhase { } // Reflected moves cannot be reflected again - if (!isReflected(this.useMode) && move.doesFlagEffectApply({ flag: MoveFlags.REFLECTABLE, user, target })) { + if (isMoveReflectableBy(this.move, target, this.useMode)) { return [HitCheckResult.REFLECTED, 0]; } @@ -603,9 +439,6 @@ export class MoveEffectPhase extends PokemonPhase { */ public checkBypassAccAndInvuln(target: Pokemon) { const user = this.getUserPokemon(); - if (!user) { - return false; - } if (user.hasAbilityWithAttr("AlwaysHitAbAttr") || target.hasAbilityWithAttr("AlwaysHitAbAttr")) { return true; } @@ -637,79 +470,43 @@ export class MoveEffectPhase extends PokemonPhase { return move.getAttrs("HitsTagAttr").some(hta => hta.tagType === semiInvulnerableTag.tagType); } - /** @returns The {@linkcode Pokemon} using this phase's invoked move */ - public getUserPokemon(): Pokemon | null { - // TODO: Make this purely a battler index - if (this.battlerIndex > BattlerIndex.ENEMY_2) { - return globalScene.getPokemonById(this.battlerIndex); - } - return (this.player ? globalScene.getPlayerField() : globalScene.getEnemyField())[this.fieldIndex]; - } - /** - * @returns An array of {@linkcode Pokemon} that are: - * - On-field and active - * - Non-fainted - * - Targeted by this phase's invoked move + * Check whether the target is protected by protect or a relevant conditional protection. + * @param user - The {@linkcode Pokemon} using this phase's invoked move + * @param target - The target {@linkcode Pokemon} to check for protection + * @returns Whether the target was protected */ - public getTargets(): Pokemon[] { - return globalScene.getField(true).filter(p => this.targets.indexOf(p.getBattlerIndex()) > -1); - } - - /** @returns The first active, non-fainted target of this phase's invoked move. */ - public getFirstTarget(): Pokemon | undefined { - return this.getTargets()[0]; - } - - /** - * Removes the given {@linkcode Pokemon} from this phase's target list - * @param target - The {@linkcode Pokemon} to be removed - */ - protected removeTarget(target: Pokemon): void { - const targetIndex = this.targets.indexOf(target.getBattlerIndex()); - if (targetIndex !== -1) { - this.targets.splice(this.targets.indexOf(target.getBattlerIndex()), 1); + private protectedCheck(user: Pokemon, target: Pokemon): boolean { + /** The {@linkcode ArenaTagSide} to which the target belongs */ + const targetSide = target.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; + /** Has the invoked move been cancelled by conditional protection (e.g Quick Guard)? */ + const hasConditionalProtectApplied = new BooleanHolder(false); + /** Does the applied conditional protection bypass Protect-ignoring effects? */ + const bypassIgnoreProtect = new BooleanHolder(false); + /** If the move is not targeting a Pokemon on the user's side, try to apply conditional protection effects */ + if (!this.move.isAllyTarget()) { + globalScene.arena.applyTagsForSide( + ConditionalProtectTag, + targetSide, + false, + hasConditionalProtectApplied, + user, + target, + this.move.id, + bypassIgnoreProtect, + ); } - } - /** - * Prevents subsequent strikes of this phase's invoked move from occurring - * @param target - If defined, only stop subsequent strikes against this {@linkcode Pokemon} - */ - public stopMultiHit(target?: Pokemon): void { - // If given a specific target, remove the target from subsequent strikes - if (target) { - this.removeTarget(target); - } - const user = this.getUserPokemon(); - if (!user) { - return; - } - // If no target specified, or the specified target was the last of this move's - // targets, completely cancel all subsequent strikes. - if (!target || this.targets.length === 0) { - user.turnData.hitCount = 1; - user.turnData.hitsLeft = 1; - } - } - - /** - * Unshifts a new `MoveEffectPhase` with the same properties as this phase. - * Used to queue the next hit of multi-strike moves. - */ - protected addNextHitPhase(): void { - globalScene.phaseManager.unshiftNew("MoveEffectPhase", this.battlerIndex, this.targets, this.move, this.useMode); - } - - /** Removes all substitutes that were broken by this phase's invoked move */ - protected updateSubstitutes(): void { - const targets = this.getTargets(); - for (const target of targets) { - const substitute = target.getTag(SubstituteTag); - if (substitute && substitute.hp <= 0) { - target.lapseTag(BattlerTagType.SUBSTITUTE); - } - } + // TODO: Break up this chunky boolean to make it more palatable + return ( + ![MoveTarget.ENEMY_SIDE, MoveTarget.BOTH_SIDES].includes(this.move.moveTarget) && + (bypassIgnoreProtect.value || !this.move.doesFlagEffectApply({ flag: MoveFlags.IGNORE_PROTECT, user, target })) && + (hasConditionalProtectApplied.value || + (!target.findTags(t => t instanceof DamageProtectedTag).length && + target.findTags(t => t instanceof ProtectedTag).some(t => target.lapseTag(t.tagType))) || + (this.move.category !== MoveCategory.STATUS && + target.findTags(t => t instanceof DamageProtectedTag).some(t => target.lapseTag(t.tagType)))) + ); } /** @@ -757,9 +554,6 @@ export class MoveEffectPhase extends PokemonPhase { */ protected applyMoveEffects(target: Pokemon, effectiveness: TypeDamageMultiplier, firstTarget: boolean): void { const user = this.getUserPokemon(); - if (isNullOrUndefined(user)) { - return; - } this.triggerMoveEffects(MoveEffectTrigger.PRE_APPLY, user, target); @@ -783,7 +577,33 @@ export class MoveEffectPhase extends PokemonPhase { } /** - * Sub-method of for {@linkcode applyMoveEffects} that applies damage to the target. + * Apply the result of this phase's move to the given target + * @param user - The {@linkcode Pokemon} using this phase's invoked move + * @param target - The {@linkcode Pokemon} struck by the move + * @param effectiveness - The effectiveness of the move against the target + */ + protected applyMove(user: Pokemon, target: Pokemon, effectiveness: TypeDamageMultiplier): [HitResult, boolean] { + const moveCategory = user.getMoveCategory(target, this.move); + + if (moveCategory === MoveCategory.STATUS) { + return [HitResult.STATUS, false]; + } + + const result = this.applyMoveDamage(user, target, effectiveness); + + if (user.turnData.hitsLeft === 1 || target.isFainted()) { + this.queueHitResultMessage(result[0]); + } + + if (target.isFainted()) { + this.onFaintTarget(user, target); + } + + return result; + } + + /** + * Sub-method of {@linkcode applyMove} that applies damage to the target. * * @param user - The {@linkcode Pokemon} using this phase's invoked move * @param target - The {@linkcode Pokemon} targeted by the move @@ -882,6 +702,29 @@ export class MoveEffectPhase extends PokemonPhase { return [result, isCritical]; } + /** + * Sub-method of {@linkcode applyMove} that queues the hit-result message + * on the final strike of the move against a target + * @param result - The {@linkcode HitResult} of the move + */ + protected queueHitResultMessage(result: HitResult) { + let msg: string | undefined; + switch (result) { + case HitResult.SUPER_EFFECTIVE: + msg = i18next.t("battle:hitResultSuperEffective"); + break; + case HitResult.NOT_VERY_EFFECTIVE: + msg = i18next.t("battle:hitResultNotVeryEffective"); + break; + case HitResult.ONE_HIT_KO: + msg = i18next.t("battle:hitResultOneHitKO"); + break; + } + if (msg) { + globalScene.phaseManager.queueMessage(msg); + } + } + /** * Sub-method of {@linkcode applyMove} that handles the event of a target fainting. * @param user - The {@linkcode Pokemon} using this phase's invoked move @@ -906,55 +749,7 @@ export class MoveEffectPhase extends PokemonPhase { } /** - * Sub-method of {@linkcode applyMove} that queues the hit-result message - * on the final strike of the move against a target - * @param result - The {@linkcode HitResult} of the move - */ - protected queueHitResultMessage(result: HitResult) { - let msg: string | undefined; - switch (result) { - case HitResult.SUPER_EFFECTIVE: - msg = i18next.t("battle:hitResultSuperEffective"); - break; - case HitResult.NOT_VERY_EFFECTIVE: - msg = i18next.t("battle:hitResultNotVeryEffective"); - break; - case HitResult.ONE_HIT_KO: - msg = i18next.t("battle:hitResultOneHitKO"); - break; - } - if (msg) { - globalScene.phaseManager.queueMessage(msg); - } - } - - /** Apply the result of this phase's move to the given target - * @param user - The {@linkcode Pokemon} using this phase's invoked move - * @param target - The {@linkcode Pokemon} struck by the move - * @param effectiveness - The effectiveness of the move against the target - */ - protected applyMove(user: Pokemon, target: Pokemon, effectiveness: TypeDamageMultiplier): [HitResult, boolean] { - const moveCategory = user.getMoveCategory(target, this.move); - - if (moveCategory === MoveCategory.STATUS) { - return [HitResult.STATUS, false]; - } - - const result = this.applyMoveDamage(user, target, effectiveness); - - if (user.turnData.hitsLeft === 1 || target.isFainted()) { - this.queueHitResultMessage(result[0]); - } - - if (target.isFainted()) { - this.onFaintTarget(user, target); - } - - return result; - } - - /** - * Applies all effects aimed at the move's target. + * Sub-method of {@linkcode applyMovetEffects} that applies all effects aimed at the move's target. * To be used when the target is successfully and directly hit by the move. * @param user - The {@linkcode Pokemon} using the move * @param target - The {@linkcode Pokemon} targeted by the move @@ -992,4 +787,173 @@ export class MoveEffectPhase extends PokemonPhase { globalScene.applyModifiers(ContactHeldItemTransferChanceModifier, this.player, user, target); } } + + /** + * Sub-method of {@linkcode applyOnTargetEffects} that applies reactive effects that occur when a Pokémon is hit. + * (i.e. Effect Spore, Disguise, Liquid Ooze, Beak Blast) + * @param user - The {@linkcode Pokemon} using this phase's invoked move + * @param target - {@linkcode Pokemon} the current target of this phase's invoked move + * @param hitResult - The {@linkcode HitResult} of the attempted move + * @param wasCritical - `true` if the move was a critical hit + */ + protected applyOnGetHitAbEffects(user: Pokemon, target: Pokemon, hitResult: HitResult, wasCritical = false): void { + const params = { pokemon: target, opponent: user, move: this.move, hitResult }; + applyAbAttrs("PostDefendAbAttr", params); + + if (wasCritical) { + applyAbAttrs("PostReceiveCritStatStageChangeAbAttr", params); + } + target.lapseTags(BattlerTagLapseType.AFTER_HIT); + } + + /** + * Sub-method of {@linkcode applyOnTargetEffects} that handles checking for and applying flinches. + * @param user - The {@linkcode Pokemon} using this phase's invoked move + * @param target - {@linkcode Pokemon} the current target of this phase's invoked move + * @param dealsDamage - `true` if the attempted move successfully dealt damage + */ + protected applyHeldItemFlinchCheck(user: Pokemon, target: Pokemon, dealsDamage: boolean): void { + if (this.move.hasAttr("FlinchAttr")) { + return; + } + + if ( + dealsDamage && + !target.hasAbilityWithAttr("IgnoreMoveEffectsAbAttr") && + !this.move.hitsSubstitute(user, target) + ) { + const flinched = new BooleanHolder(false); + globalScene.applyModifiers(FlinchChanceModifier, user.isPlayer(), user, flinched); + if (flinched.value) { + target.addTag(BattlerTagType.FLINCHED, undefined, this.move.id, user.id); + } + } + } + + public override end(): void { + const user = this.getUserPokemon(); + + /** + * If this phase isn't for the invoked move's last strike (and we still have something to hit), + * unshift another MoveEffectPhase for the next strike before ending this phase. + */ + if (--user.turnData.hitsLeft >= 1 && this.getFirstTarget()) { + this.addNextHitPhase(); + super.end(); + return; + } + + /** + * All hits of the move have resolved by now. + * Queue message for multi-strike moves before applying Shell Bell heals & proccing Dancer-like effects. + */ + const hitsTotal = user.turnData.hitCount - Math.max(user.turnData.hitsLeft, 0); + if (hitsTotal > 1 || user.turnData.hitsLeft > 0) { + // Queue message if multiple hits occurred or were slated to occur (such as a Triple Axel miss) + globalScene.phaseManager.queueMessage(i18next.t("battle:attackHitsCount", { count: hitsTotal })); + } + + globalScene.applyModifiers(HitHealModifier, this.player, user); + this.getTargets().forEach(target => { + target.turnData.moveEffectiveness = null; + }); + super.end(); + } + + // #region Helpers + + /** + * @returns The {@linkcode Pokemon} using this phase's invoked move. + * Is never null during the move execution itself, as {@linkcode start} ends the phase immediately if a source is missing. + * @todo Delete in favor of {@linkcode PokemonPhase.getPokemon} + */ + public getUserPokemon(): Pokemon { + return super.getPokemon()!; + } + + /** + * @returns An array of {@linkcode Pokemon} that are: + * - On-field and active + * - Non-fainted + * - Targeted by this phase's invoked move + */ + public getTargets(): Pokemon[] { + return globalScene.getField(true).filter(p => this.targets.indexOf(p.getBattlerIndex()) > -1); + } + + /** @returns The first active, non-fainted target of this phase's invoked move. */ + public getFirstTarget(): Pokemon | undefined { + return this.getTargets()[0]; + } + + /** + * Removes the given {@linkcode Pokemon} from this phase's target list + * @param target - The {@linkcode Pokemon} to be removed + */ + protected removeTarget(target: Pokemon): void { + const targetIndex = this.targets.indexOf(target.getBattlerIndex()); + if (targetIndex !== -1) { + this.targets.splice(this.targets.indexOf(target.getBattlerIndex()), 1); + } + } + + /** + * Prevents subsequent strikes of this phase's invoked move from occurring + * @param target - If defined, only stop subsequent strikes against this {@linkcode Pokemon} + */ + public stopMultiHit(target?: Pokemon): void { + // If given a specific target, remove the target from subsequent strikes + if (target) { + this.removeTarget(target); + } + const user = this.getUserPokemon(); + // If no target specified, or the specified target was the last of this move's + // targets, completely cancel all subsequent strikes. + if (!target || this.targets.length === 0) { + user.turnData.hitCount = 1; + user.turnData.hitsLeft = 1; + } + } + + /** + * Unshifts a new `MoveEffectPhase` with the same properties as this phase. + * Used to queue the next hit of multi-strike moves. + */ + protected addNextHitPhase(): void { + globalScene.phaseManager.unshiftNew("MoveEffectPhase", this.battlerIndex, this.targets, this.move, this.useMode); + } + + /** Removes all substitutes that were broken by this phase's invoked move */ + protected updateSubstitutes(): void { + const targets = this.getTargets(); + for (const target of targets) { + const substitute = target.getTag(SubstituteTag); + if (substitute && substitute.hp <= 0) { + target.lapseTag(BattlerTagType.SUBSTITUTE); + } + } + } + + // # endregion Helpers +} + +/** + * Check whether a given Move is able to be reflected by either + * {@linkcode MoveId.MAGIC_COAT | Magic Coat} or {@linkcode AbilityId.MAGIC_BOUNCE | Magic Bounce}. + * @param move - The {@linkcode Move} being used + * @param target - The targeted {@linkcode Pokemon} attempting to reflect the move + * @param useMode - The {@linkcode MoveUseMode} dictating how the move was used + * @returns Whether {@linkcode target} can reflect {@linkcode move}. + */ +function isMoveReflectableBy(move: Move, target: Pokemon, useMode: MoveUseMode): boolean { + return ( + // The move must not have just been reflected + !isReflected(useMode) && + // Reflections cannot occur while semi invulnerable + !target.getTag(SemiInvulnerableTag) && + // Move must be reflectable + move.hasFlag(MoveFlags.REFLECTABLE) && + // target must have a reflection effect active + (!!target.getTag(BattlerTagType.MAGIC_COAT) || target.hasAbilityWithAttr("ReflectStatusMoveAbAttr")) + ); } diff --git a/src/phases/move-reflect-phase.ts b/src/phases/move-reflect-phase.ts new file mode 100644 index 00000000000..ee0acd1e2ba --- /dev/null +++ b/src/phases/move-reflect-phase.ts @@ -0,0 +1,41 @@ +import { applyAbAttrs } from "#abilities/apply-ab-attrs"; +import { Phase } from "#app/phase"; +import type { MagicCoatTag } from "#data/battler-tags"; +import { BattlerTagType } from "#enums/battler-tag-type"; +// biome-ignore-start lint/correctness/noUnusedImports: TSDoc +import type { MoveId } from "#enums/move-id"; +import type { Pokemon } from "#field/pokemon"; +import type { Move } from "#types/move-types"; +// biome-ignore-end lint/correctness/noUnusedImports: TSDoc + +/** + * The phase where Pokemon reflect moves via {@linkcode MoveId.MAGIC_COAT | Magic Coat} or {@linkcode AbilityId.MAGIC_BOUNCE | Magic Bounce}. + */ +export class MoveReflectPhase extends Phase { + public override readonly phaseName = "MoveReflectPhase"; + /** The {@linkcode Pokemon} doing the reflecting. */ + private readonly pokemon: Pokemon; + /** The pokemon having originally used the move. */ + private opponent: Pokemon; + /** The {@linkcode Move} being reflected. */ + private readonly move: Move; + + constructor(pokemon: Pokemon, opponent: Pokemon, move: Move) { + super(); + this.pokemon = pokemon; + this.opponent = opponent; + this.move = move; + } + + override start(): void { + this.pokemon.turnData.extraTurns++; + // Magic Coat takes precedeence over Magic Bounce if both apply at once + const magicCoatTag = this.pokemon.getTag(BattlerTagType.MAGIC_COAT) as MagicCoatTag | undefined; + if (magicCoatTag) { + magicCoatTag.apply(this.pokemon, this.opponent, this.move); + } else { + applyAbAttrs("ReflectStatusMoveAbAttr", { pokemon: this.pokemon, opponent: this.opponent, move: this.move }); + } + super.end(); + } +} diff --git a/test/abilities/magic-bounce.test.ts b/test/abilities/magic-bounce.test.ts deleted file mode 100644 index c15690c3f5d..00000000000 --- a/test/abilities/magic-bounce.test.ts +++ /dev/null @@ -1,345 +0,0 @@ -import { allAbilities, allMoves } from "#data/data-lists"; -import { AbilityId } from "#enums/ability-id"; -import { ArenaTagSide } from "#enums/arena-tag-side"; -import { ArenaTagType } from "#enums/arena-tag-type"; -import { BattlerIndex } from "#enums/battler-index"; -import { BattlerTagType } from "#enums/battler-tag-type"; -import { MoveId } from "#enums/move-id"; -import { SpeciesId } from "#enums/species-id"; -import { Stat } from "#enums/stat"; -import { StatusEffect } from "#enums/status-effect"; -import { GameManager } from "#test/test-utils/game-manager"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; - -describe("Abilities - Magic Bounce", () => { - let phaserGame: Phaser.Game; - let game: GameManager; - - beforeAll(() => { - phaserGame = new Phaser.Game({ - type: Phaser.HEADLESS, - }); - }); - - afterEach(() => { - game.phaseInterceptor.restoreOg(); - }); - - beforeEach(() => { - game = new GameManager(phaserGame); - game.override - .ability(AbilityId.BALL_FETCH) - .battleStyle("single") - .moveset([MoveId.GROWL, MoveId.SPLASH]) - .criticalHits(false) - .enemySpecies(SpeciesId.MAGIKARP) - .enemyAbility(AbilityId.MAGIC_BOUNCE) - .enemyMoveset(MoveId.SPLASH); - }); - - it("should reflect basic status moves", async () => { - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - game.move.use(MoveId.GROWL); - await game.phaseInterceptor.to("BerryPhase"); - expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(-1); - }); - - it("should not bounce moves while the target is in the semi-invulnerable state", async () => { - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - game.move.use(MoveId.GROWL); - await game.move.forceEnemyMove(MoveId.FLY); - await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); - await game.phaseInterceptor.to("BerryPhase"); - - expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(0); - }); - - it("should individually bounce back multi-target moves", async () => { - game.override.battleStyle("double"); - await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]); - - game.move.use(MoveId.GROWL, 0); - game.move.use(MoveId.SPLASH, 1); - await game.phaseInterceptor.to("BerryPhase"); - - const user = game.scene.getPlayerField()[0]; - expect(user.getStatStage(Stat.ATK)).toBe(-2); - }); - - it("should still bounce back a move that would otherwise fail", async () => { - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - game.field.getEnemyPokemon().setStatStage(Stat.ATK, -6); - - game.move.use(MoveId.GROWL); - await game.phaseInterceptor.to("BerryPhase"); - - expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(-1); - }); - - it("should not bounce back a move that was just bounced", async () => { - game.override.ability(AbilityId.MAGIC_BOUNCE); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - game.move.select(MoveId.GROWL); - await game.phaseInterceptor.to("BerryPhase"); - - expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(-1); - }); - - it("should receive the stat change after reflecting a move back to a mirror armor user", async () => { - game.override.ability(AbilityId.MIRROR_ARMOR); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - game.move.select(MoveId.GROWL); - await game.phaseInterceptor.to("BerryPhase"); - - expect(game.field.getEnemyPokemon().getStatStage(Stat.ATK)).toBe(-1); - }); - - it("should not bounce back a move from a mold breaker user", async () => { - game.override.ability(AbilityId.MOLD_BREAKER); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - game.move.use(MoveId.GROWL); - await game.phaseInterceptor.to("BerryPhase"); - - expect(game.field.getEnemyPokemon().getStatStage(Stat.ATK)).toBe(-1); - }); - - it("should bounce back a spread status move against both pokemon", async () => { - game.override.battleStyle("double").enemyMoveset([MoveId.SPLASH]); - await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]); - - game.move.use(MoveId.GROWL, 0); - game.move.use(MoveId.SPLASH, 1); - - await game.phaseInterceptor.to("BerryPhase"); - expect(game.scene.getPlayerField().every(p => p.getStatStage(Stat.ATK) === -2)).toBeTruthy(); - }); - - it("should only bounce spikes back once in doubles when both targets have magic bounce", async () => { - game.override.battleStyle("double").moveset([MoveId.SPIKES]); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - game.move.select(MoveId.SPIKES); - await game.phaseInterceptor.to("BerryPhase"); - - expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER)!["layers"]).toBe(1); - expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY)).toBeUndefined(); - }); - - it("should bounce spikes even when the target is protected", async () => { - game.override.moveset([MoveId.SPIKES]).enemyMoveset([MoveId.PROTECT]); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - game.move.select(MoveId.SPIKES); - await game.phaseInterceptor.to("BerryPhase"); - expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER)!["layers"]).toBe(1); - }); - - it("should not bounce spikes when the target is in the semi-invulnerable state", async () => { - game.override.moveset([MoveId.SPIKES]).enemyMoveset([MoveId.FLY]); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - game.move.select(MoveId.SPIKES); - await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); - await game.phaseInterceptor.to("BerryPhase"); - expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY)!["layers"]).toBe(1); - }); - - it("should not bounce back curse", async () => { - game.override.moveset([MoveId.CURSE]); - await game.classicMode.startBattle([SpeciesId.GASTLY]); - - game.move.select(MoveId.CURSE); - await game.phaseInterceptor.to("BerryPhase"); - - expect(game.field.getEnemyPokemon().getTag(BattlerTagType.CURSED)).toBeDefined(); - }); - - // TODO: enable when Magic Bounce is fixed to properly reset the hit count - it.todo("should not cause encore to be interrupted after bouncing", async () => { - game.override.moveset([MoveId.SPLASH, MoveId.GROWL, MoveId.ENCORE]).enemyMoveset([MoveId.TACKLE, MoveId.GROWL]); - // game.override.ability(AbilityId.MOLD_BREAKER); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - const playerPokemon = game.field.getPlayerPokemon(); - const enemyPokemon = game.field.getEnemyPokemon(); - - // Give the player MOLD_BREAKER for this turn to bypass Magic Bounce. - const playerAbilitySpy = game.field.mockAbility(playerPokemon, AbilityId.MOLD_BREAKER); - - // turn 1 - game.move.select(MoveId.ENCORE); - await game.move.selectEnemyMove(MoveId.TACKLE); - await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); - await game.toNextTurn(); - expect(enemyPokemon.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(MoveId.TACKLE); - - // turn 2 - playerAbilitySpy.mockRestore(); - game.move.select(MoveId.GROWL); - await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); - await game.phaseInterceptor.to("BerryPhase"); - expect(enemyPokemon.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(MoveId.TACKLE); - expect(enemyPokemon.getLastXMoves()[0].move).toBe(MoveId.TACKLE); - }); - - // TODO: encore is failing if the last move was virtual. - it.todo("should not cause the bounced move to count for encore", async () => { - game.override - .moveset([MoveId.SPLASH, MoveId.GROWL, MoveId.ENCORE]) - .enemyMoveset([MoveId.GROWL, MoveId.TACKLE]) - .enemyAbility(AbilityId.MAGIC_BOUNCE); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - const playerPokemon = game.field.getPlayerPokemon(); - const enemyPokemon = game.field.getEnemyPokemon(); - - // turn 1 - game.move.select(MoveId.GROWL); - await game.move.selectEnemyMove(MoveId.TACKLE); - await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); - await game.toNextTurn(); - - // Give the player MOLD_BREAKER for this turn to bypass Magic Bounce. - vi.spyOn(playerPokemon, "getAbility").mockReturnValue(allAbilities[AbilityId.MOLD_BREAKER]); - - // turn 2 - game.move.select(MoveId.ENCORE); - await game.move.selectEnemyMove(MoveId.TACKLE); - await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); - await game.phaseInterceptor.to("BerryPhase"); - expect(enemyPokemon.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(MoveId.TACKLE); - expect(enemyPokemon.getLastXMoves()[0].move).toBe(MoveId.TACKLE); - }); - - // TODO: stomping tantrum should consider moves that were bounced. - it.todo("should cause stomping tantrum to double in power when the last move was bounced", async () => { - game.override.battleStyle("single").moveset([MoveId.STOMPING_TANTRUM, MoveId.CHARM]); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const stomping_tantrum = allMoves[MoveId.STOMPING_TANTRUM]; - vi.spyOn(stomping_tantrum, "calculateBattlePower"); - - game.move.select(MoveId.CHARM); - await game.toNextTurn(); - - game.move.select(MoveId.STOMPING_TANTRUM); - await game.phaseInterceptor.to("BerryPhase"); - expect(stomping_tantrum.calculateBattlePower).toHaveReturnedWith(150); - }); - - // TODO: stomping tantrum should consider moves that were bounced - it.todo("should boost enemy's stomping tantrum after failed bounce", async () => { - game.override.enemyMoveset([MoveId.STOMPING_TANTRUM, MoveId.SPLASH, MoveId.CHARM]); - await game.classicMode.startBattle([SpeciesId.BULBASAUR]); - - const stomping_tantrum = allMoves[MoveId.STOMPING_TANTRUM]; - const enemy = game.field.getEnemyPokemon(); - vi.spyOn(stomping_tantrum, "calculateBattlePower"); - - // Spore gets reflected back onto us - game.move.select(MoveId.SPORE); - await game.move.selectEnemyMove(MoveId.CHARM); - await game.toNextTurn(); - expect(enemy.getLastXMoves(1)[0].result).toBe("success"); - - game.move.select(MoveId.SPORE); - await game.move.selectEnemyMove(MoveId.STOMPING_TANTRUM); - await game.toNextTurn(); - expect(stomping_tantrum.calculateBattlePower).toHaveReturnedWith(150); - }); - - it("should respect immunities when bouncing a move", async () => { - vi.spyOn(allMoves[MoveId.THUNDER_WAVE], "accuracy", "get").mockReturnValue(100); - game.override.moveset([MoveId.THUNDER_WAVE, MoveId.GROWL]).ability(AbilityId.SOUNDPROOF); - await game.classicMode.startBattle([SpeciesId.PHANPY]); - - // Turn 1 - thunder wave immunity test - game.move.select(MoveId.THUNDER_WAVE); - await game.phaseInterceptor.to("BerryPhase"); - expect(game.field.getPlayerPokemon().status).toBeUndefined(); - - // Turn 2 - soundproof immunity test - game.move.select(MoveId.GROWL); - await game.phaseInterceptor.to("BerryPhase"); - expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(0); - }); - - it("should bounce back a move before the accuracy check", async () => { - game.override.moveset([MoveId.SPORE]); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const attacker = game.field.getPlayerPokemon(); - - vi.spyOn(attacker, "getAccuracyMultiplier").mockReturnValue(0.0); - game.move.select(MoveId.SPORE); - await game.phaseInterceptor.to("BerryPhase"); - expect(game.field.getPlayerPokemon().status?.effect).toBe(StatusEffect.SLEEP); - }); - - it("should take the accuracy of the magic bounce user into account", async () => { - game.override.moveset([MoveId.SPORE]); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - const opponent = game.field.getEnemyPokemon(); - - vi.spyOn(opponent, "getAccuracyMultiplier").mockReturnValue(0); - game.move.select(MoveId.SPORE); - await game.phaseInterceptor.to("BerryPhase"); - expect(game.field.getPlayerPokemon().status).toBeUndefined(); - }); - - it("should always apply the leftmost available target's magic bounce when bouncing moves like sticky webs in doubles", async () => { - game.override.battleStyle("double").moveset([MoveId.STICKY_WEB, MoveId.SPLASH, MoveId.TRICK_ROOM]); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]); - const [enemy_1, enemy_2] = game.scene.getEnemyField(); - // set speed just incase logic erroneously checks for speed order - enemy_1.setStat(Stat.SPD, enemy_2.getStat(Stat.SPD) + 1); - - // turn 1 - game.move.select(MoveId.STICKY_WEB, 0); - game.move.select(MoveId.TRICK_ROOM, 1); - await game.phaseInterceptor.to("TurnEndPhase"); - - expect( - game.scene.arena - .getTagOnSide(ArenaTagType.STICKY_WEB, ArenaTagSide.PLAYER) - ?.getSourcePokemon() - ?.getBattlerIndex(), - ).toBe(BattlerIndex.ENEMY); - game.scene.arena.removeTagOnSide(ArenaTagType.STICKY_WEB, ArenaTagSide.PLAYER, true); - - // turn 2 - game.move.select(MoveId.STICKY_WEB, 0); - game.move.select(MoveId.TRICK_ROOM, 1); - await game.phaseInterceptor.to("BerryPhase"); - expect( - game.scene.arena - .getTagOnSide(ArenaTagType.STICKY_WEB, ArenaTagSide.PLAYER) - ?.getSourcePokemon() - ?.getBattlerIndex(), - ).toBe(BattlerIndex.ENEMY); - }); - - it("should not bounce back status moves that hit through semi-invulnerable states", async () => { - game.override.moveset([MoveId.TOXIC, MoveId.CHARM]); - await game.classicMode.startBattle([SpeciesId.BULBASAUR]); - - game.move.select(MoveId.TOXIC); - await game.move.selectEnemyMove(MoveId.FLY); - await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); - await game.phaseInterceptor.to("BerryPhase"); - expect(game.field.getEnemyPokemon().status?.effect).toBe(StatusEffect.TOXIC); - expect(game.field.getPlayerPokemon().status).toBeUndefined(); - - game.override.ability(AbilityId.NO_GUARD); - game.move.select(MoveId.CHARM); - await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); - await game.phaseInterceptor.to("BerryPhase"); - expect(game.field.getEnemyPokemon().getStatStage(Stat.ATK)).toBe(-2); - expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(0); - }); -}); diff --git a/test/abilities/mirror-armor.test.ts b/test/abilities/mirror-armor.test.ts index b2bd9be4755..154fc1ea34f 100644 --- a/test/abilities/mirror-armor.test.ts +++ b/test/abilities/mirror-armor.test.ts @@ -7,8 +7,6 @@ import { GameManager } from "#test/test-utils/game-manager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; -// TODO: When Magic Bounce is implemented, make a test for its interaction with mirror guard, use screech - describe("Ability - Mirror Armor", () => { let phaserGame: Phaser.Game; let game: GameManager; diff --git a/test/moves/delayed-attack.test.ts b/test/moves/delayed-attack.test.ts index e8cf2871626..ed16a22939c 100644 --- a/test/moves/delayed-attack.test.ts +++ b/test/moves/delayed-attack.test.ts @@ -385,5 +385,5 @@ describe("Moves - Delayed Attacks", () => { }); // TODO: Implement and move to a power spot's test file - it.todo("Should activate ally's power spot when switched in during single battles"); + it.todo("should activate ally's power spot when switched in during single battles"); }); diff --git a/test/moves/encore.test.ts b/test/moves/encore.test.ts index 0840346c3b1..d7fdbe1586a 100644 --- a/test/moves/encore.test.ts +++ b/test/moves/encore.test.ts @@ -1,10 +1,14 @@ +import { getPokemonNameWithAffix } from "#app/messages"; import { AbilityId } from "#enums/ability-id"; import { BattlerIndex } from "#enums/battler-index"; import { BattlerTagType } from "#enums/battler-tag-type"; import { MoveId } from "#enums/move-id"; import { MoveResult } from "#enums/move-result"; +import { MoveUseMode } from "#enums/move-use-mode"; import { SpeciesId } from "#enums/species-id"; +import { invalidEncoreMoves } from "#moves/invalid-moves"; import { GameManager } from "#test/test-utils/game-manager"; +import i18next from "i18next"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; @@ -31,7 +35,6 @@ describe("Moves - Encore", () => { .criticalHits(false) .enemySpecies(SpeciesId.MAGIKARP) .enemyAbility(AbilityId.BALL_FETCH) - .enemyMoveset([MoveId.SPLASH, MoveId.TACKLE]) .startingLevel(100) .enemyLevel(100); }); @@ -39,76 +42,154 @@ describe("Moves - Encore", () => { it("should prevent the target from using any move except the last used move", async () => { await game.classicMode.startBattle([SpeciesId.SNORLAX]); - const enemyPokemon = game.field.getEnemyPokemon(); + const enemy = game.field.getEnemyPokemon(); - game.move.select(MoveId.ENCORE); - await game.move.selectEnemyMove(MoveId.SPLASH); + game.move.use(MoveId.ENCORE); + await game.move.forceEnemyMove(MoveId.SPLASH); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.toNextTurn(); + + expect(enemy).toHaveBattlerTag(BattlerTagType.ENCORE); + expect(enemy.isMoveRestricted(MoveId.TACKLE)).toBe(true); + expect(enemy.isMoveRestricted(MoveId.SPLASH)).toBe(false); + }); + + it("should be removed on turn end after triggering thrice, ignoring Instruct", async () => { + await game.classicMode.startBattle([SpeciesId.SNORLAX]); + + const enemy = game.field.getEnemyPokemon(); + enemy.pushMoveHistory({ move: MoveId.SPLASH, targets: [BattlerIndex.PLAYER], useMode: MoveUseMode.NORMAL }); + + game.move.use(MoveId.ENCORE); + await game.move.forceEnemyMove(MoveId.SPLASH); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.toNextTurn(); + + // Should have ticked down once + expect(enemy).toHaveBattlerTag(BattlerTagType.ENCORE); + expect(enemy.getTag(BattlerTagType.ENCORE)!.turnCount).toBe(2); + + game.move.use(MoveId.INSTRUCT); + await game.toNextTurn(); + + expect(enemy.getTag(BattlerTagType.ENCORE)!.turnCount).toBe(1); + + game.move.use(MoveId.INSTRUCT); + await game.toEndOfTurn(false); + + // Tag should still be present until the `TurnEndPhase` ticks it down + expect(enemy).toHaveBattlerTag(BattlerTagType.ENCORE); await game.toNextTurn(); - expect(enemyPokemon.getTag(BattlerTagType.ENCORE)).toBeDefined(); - game.move.select(MoveId.SPLASH); - // The enemy AI would normally be inclined to use Tackle, but should be - // forced into using Splash. - await game.phaseInterceptor.to("BerryPhase", false); - - expect(enemyPokemon.getLastXMoves().every(turnMove => turnMove.move === MoveId.SPLASH)).toBeTruthy(); + expect(enemy).not.toHaveBattlerTag(BattlerTagType.ENCORE); + expect(game.textInterceptor.logs).toContain( + i18next.t("battlerTags:encoreOnRemove", { + pokemonNameWithAffix: getPokemonNameWithAffix(enemy), + }), + ); }); - describe("should fail against the following moves:", () => { - it.each([ - { moveId: MoveId.TRANSFORM, name: "Transform", delay: false }, - { moveId: MoveId.MIMIC, name: "Mimic", delay: true }, - { moveId: MoveId.SKETCH, name: "Sketch", delay: true }, - { moveId: MoveId.ENCORE, name: "Encore", delay: false }, - { moveId: MoveId.STRUGGLE, name: "Struggle", delay: false }, - ])("$name", async ({ moveId, delay }) => { - game.override.enemyMoveset(moveId); + it("should override any upcoming moves with the Encored move, while still consuming PP", async () => { + await game.classicMode.startBattle([SpeciesId.SNORLAX]); - await game.classicMode.startBattle([SpeciesId.SNORLAX]); + // Fake enemy having used tackle the turn prior + const enemy = game.field.getEnemyPokemon(); + game.move.changeMoveset(enemy, [MoveId.SPLASH, MoveId.TACKLE]); + enemy.pushMoveHistory({ move: MoveId.TACKLE, targets: [BattlerIndex.PLAYER], useMode: MoveUseMode.NORMAL }); - const playerPokemon = game.field.getPlayerPokemon(); - const enemyPokemon = game.field.getEnemyPokemon(); + game.move.use(MoveId.ENCORE); + await game.move.selectEnemyMove(MoveId.SPLASH); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.toNextTurn(); - if (delay) { - game.move.select(MoveId.SPLASH); - await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); - await game.toNextTurn(); - } - - game.move.select(MoveId.ENCORE); - - const turnOrder = delay ? [BattlerIndex.PLAYER, BattlerIndex.ENEMY] : [BattlerIndex.ENEMY, BattlerIndex.PLAYER]; - await game.setTurnOrder(turnOrder); - - await game.phaseInterceptor.to("BerryPhase", false); - expect(playerPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.FAIL); - expect(enemyPokemon.getTag(BattlerTagType.ENCORE)).toBeUndefined(); - }); + expect(enemy).toHaveUsedMove({ move: MoveId.TACKLE, targets: [BattlerIndex.PLAYER], useMode: MoveUseMode.NORMAL }); + expect(enemy).toHaveUsedPP(MoveId.TACKLE, 1); }); - it("Pokemon under both Encore and Torment should alternate between Struggle and restricted move", async () => { - const turnOrder = [BattlerIndex.ENEMY, BattlerIndex.PLAYER]; - game.override.moveset([MoveId.ENCORE, MoveId.TORMENT, MoveId.SPLASH]); + // TODO: Make test using `changeMoveset` + it.todo("should end at turn end if the user forgets the Encored move"); + + it("should be removed at turn end if the Encored move runs out of PP", async () => { + await game.classicMode.startBattle([SpeciesId.SNORLAX]); + + // Fake enemy having used tackle the turn prior + const enemy = game.field.getEnemyPokemon(); + game.move.changeMoveset(enemy, [MoveId.SPLASH, MoveId.TACKLE]); + enemy.pushMoveHistory({ move: MoveId.TACKLE, targets: [BattlerIndex.PLAYER], useMode: MoveUseMode.NORMAL }); + enemy.moveset[1].ppUsed = enemy.moveset[1].getMovePp() - 2; + + game.move.use(MoveId.ENCORE); + await game.move.selectEnemyMove(MoveId.SPLASH); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.toNextTurn(); + + expect(enemy).toHaveUsedMove({ move: MoveId.TACKLE, targets: [BattlerIndex.PLAYER], useMode: MoveUseMode.NORMAL }); + expect(enemy).toHaveUsedPP(MoveId.TACKLE, enemy.moveset[1].getMovePp() - 1); + expect(enemy).toHaveBattlerTag(BattlerTagType.ENCORE); + + game.move.use(MoveId.SPLASH); + await game.toEndOfTurn(); + + expect(enemy).toHaveUsedPP(MoveId.TACKLE, "all"); + expect(enemy).not.toHaveBattlerTag(BattlerTagType.ENCORE); + }); + + const invalidMoves = [...invalidEncoreMoves].map(m => ({ + name: MoveId[m], + move: m, + })); + it.each(invalidMoves)("should fail if the target's last move is $name", async ({ move }) => { + await game.classicMode.startBattle([SpeciesId.SNORLAX]); + + const player = game.field.getPlayerPokemon(); + const enemy = game.field.getEnemyPokemon(); + enemy.pushMoveHistory({ move, targets: [BattlerIndex.PLAYER], useMode: MoveUseMode.NORMAL }); + + game.move.use(MoveId.ENCORE); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.toEndOfTurn(); + + expect(player.getLastXMoves(1)[0].result).toBe(MoveResult.FAIL); + expect(enemy).not.toHaveBattlerTag(BattlerTagType.ENCORE); + }); + + it("should fail if the target has not made a move", async () => { + await game.classicMode.startBattle([SpeciesId.SNORLAX]); + + const player = game.field.getPlayerPokemon(); + const enemy = game.field.getEnemyPokemon(); + + game.move.use(MoveId.ENCORE); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.toEndOfTurn(); + + expect(player.getLastXMoves(1)[0].result).toBe(MoveResult.FAIL); + expect(enemy).not.toHaveBattlerTag(BattlerTagType.ENCORE); + }); + + it("should force a Tormented target to alternate between Struggle and the Encored move", async () => { await game.classicMode.startBattle([SpeciesId.FEEBAS]); - const enemyPokemon = game.field.getEnemyPokemon(); - game.move.select(MoveId.ENCORE); - await game.setTurnOrder(turnOrder); - await game.phaseInterceptor.to("BerryPhase"); - expect(enemyPokemon.getTag(BattlerTagType.ENCORE)).toBeDefined(); + const enemy = game.field.getEnemyPokemon(); + game.move.use(MoveId.ENCORE); + await game.move.forceEnemyMove(MoveId.TACKLE); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.toNextTurn(); - game.move.select(MoveId.TORMENT); - await game.setTurnOrder(turnOrder); - await game.phaseInterceptor.to("BerryPhase"); - expect(enemyPokemon.getTag(BattlerTagType.TORMENT)).toBeDefined(); + expect(enemy).toHaveBattlerTag(BattlerTagType.ENCORE); + + game.move.use(MoveId.TORMENT); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.toNextTurn(); - game.move.select(MoveId.SPLASH); - await game.setTurnOrder(turnOrder); - await game.phaseInterceptor.to("BerryPhase"); - const lastMove = enemyPokemon.getLastXMoves()[0]; - expect(lastMove?.move).toBe(MoveId.STRUGGLE); + + expect(enemy).toHaveBattlerTag(BattlerTagType.ENCORE); + expect(enemy).toHaveBattlerTag(BattlerTagType.TORMENT); + + game.move.use(MoveId.SPLASH); + await game.toEndOfTurn(); + + expect(enemy).toHaveUsedMove(MoveId.STRUGGLE); }); }); diff --git a/test/moves/magic-coat-magic-bounce.test.ts b/test/moves/magic-coat-magic-bounce.test.ts new file mode 100644 index 00000000000..37b8b51b5de --- /dev/null +++ b/test/moves/magic-coat-magic-bounce.test.ts @@ -0,0 +1,390 @@ +import { allMoves } from "#data/data-lists"; +import { AbilityId } from "#enums/ability-id"; +import { ArenaTagSide } from "#enums/arena-tag-side"; +import { ArenaTagType } from "#enums/arena-tag-type"; +import { BattlerIndex } from "#enums/battler-index"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { MoveId } from "#enums/move-id"; +import { MoveResult } from "#enums/move-result"; +import { MoveUseMode } from "#enums/move-use-mode"; +import { SpeciesId } from "#enums/species-id"; +import { Stat } from "#enums/stat"; +import { StatusEffect } from "#enums/status-effect"; +import { GameManager } from "#test/test-utils/game-manager"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("Moves - Reflecting effects", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .ability(AbilityId.BALL_FETCH) + .battleStyle("single") + .criticalHits(false) + .enemySpecies(SpeciesId.MAGIKARP) + .enemyAbility(AbilityId.BALL_FETCH) + .enemyMoveset(MoveId.MAGIC_COAT); + }); + + describe("Reflecting effects", () => { + it("should reflect basic status moves, copying them against the user", async () => { + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + + game.move.use(MoveId.GROWL); + await game.toEndOfTurn(); + + const player = game.field.getPlayerPokemon(); + const enemy = game.field.getEnemyPokemon(); + + expect(enemy).toHaveUsedMove({ + move: MoveId.GROWL, + useMode: MoveUseMode.REFLECTED, + targets: [BattlerIndex.PLAYER], + }); + expect(player).toHaveStatStage(Stat.ATK, -1); + }); + + it("should bounce back multi-target moves against each target", async () => { + game.override.battleStyle("double"); + await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]); + + game.move.use(MoveId.GROWL, BattlerIndex.PLAYER); + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2); + await game.toEndOfTurn(); + + const [karp1, karp2] = game.scene.getPlayerField(); + expect(karp1).toHaveStatStage(Stat.ATK, -2); + expect(karp2).toHaveStatStage(Stat.ATK, -2); + }); + + // TODO: This is broken - failed moves never make it to a MEP + it.todo("should still bounce back a move that would otherwise fail", async () => { + game.override.enemyAbility(AbilityId.INSOMNIA); + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + + game.move.use(MoveId.YAWN); + await game.toEndOfTurn(); + + expect(game.field.getPlayerPokemon()).toHaveBattlerTag(BattlerTagType.DROWSY); + }); + + it("should not bounce back a move that was just bounced", async () => { + game.override.battleStyle("double").ability(AbilityId.MAGIC_BOUNCE); + await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]); + + game.move.use(MoveId.MAGIC_COAT, BattlerIndex.PLAYER); + game.move.use(MoveId.GROWL, BattlerIndex.PLAYER_2); + await game.move.forceEnemyMove(MoveId.MAGIC_COAT); + await game.move.forceEnemyMove(MoveId.SPLASH); + await game.toEndOfTurn(); + + expect(game.field.getEnemyPokemon()).toHaveStatStage(Stat.ATK, 0); + }); + + it("should take precedence over Mirror Armor", async () => { + game.override.enemyAbility(AbilityId.MIRROR_ARMOR); + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + + game.move.use(MoveId.GROWL); + await game.toEndOfTurn(); + + const enemy = game.field.getPlayerPokemon(); + expect(enemy).toHaveStatStage(Stat.ATK, -1); + expect(enemy).not.toHaveAbilityApplied(AbilityId.MIRROR_ARMOR); + }); + + it("should not bounce back non-reflectable effects", async () => { + await game.classicMode.startBattle([SpeciesId.GASTLY]); + + game.move.use(MoveId.CURSE); + await game.toEndOfTurn(); + + expect(game.field.getEnemyPokemon()).toHaveBattlerTag(BattlerTagType.CURSED); + }); + + it("should not cause encore to be interrupted after bouncing", async () => { + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + + const playerPokemon = game.field.getPlayerPokemon(); + const enemyPokemon = game.field.getEnemyPokemon(); + + // Give the player MOLD_BREAKER for this turn to bypass Magic Bounce. + const playerAbilitySpy = game.field.mockAbility(playerPokemon, AbilityId.MOLD_BREAKER); + + // turn 1 + game.move.use(MoveId.ENCORE); + await game.move.forceEnemyMove(MoveId.TACKLE); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.toNextTurn(); + + expect(enemyPokemon.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(MoveId.TACKLE); + + // turn 2 + playerAbilitySpy.mockRestore(); + + game.move.use(MoveId.GROWL); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.toEndOfTurn(); + + expect(enemyPokemon.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(MoveId.TACKLE); + expect(enemyPokemon.getLastXMoves()[0].move).toBe(MoveId.TACKLE); + }); + + it("should not cause the bounced move to count for encore", async () => { + game.override.battleStyle("double"); + await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.ABRA]); + + // Fake abra having mold breaker and the enemy having used Tackle + const [, abra, enemy1] = game.scene.getField(); + game.field.mockAbility(abra, AbilityId.MOLD_BREAKER); + game.field.mockAbility(enemy1, AbilityId.MAGIC_BOUNCE); + game.move.changeMoveset(enemy1, [MoveId.TACKLE, MoveId.SPLASH]); + enemy1.pushMoveHistory({ move: MoveId.TACKLE, targets: [BattlerIndex.PLAYER], useMode: MoveUseMode.NORMAL }); + + // Magikarp uses growl as Abra attempts to encore enemy 1 + game.move.use(MoveId.GROWL, BattlerIndex.PLAYER); + game.move.use(MoveId.ENCORE, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY); + await game.move.selectEnemyMove(MoveId.SPLASH); + await game.move.forceEnemyMove(MoveId.SPLASH); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]); + await game.toNextTurn(); + + console.log(enemy1.getLastXMoves(-1)); + // Encore locked into Tackle, replacing the enemy's Growl with another Tackle + expect(enemy1.getTag(BattlerTagType.ENCORE)?.["moveId"]).toBe(MoveId.TACKLE); + expect(enemy1).toHaveUsedMove({ move: MoveId.TACKLE, useMode: MoveUseMode.NORMAL }); + }); + + it("should boost stomping tantrum after a failed bounce", async () => { + game.override.ability(AbilityId.INSOMNIA); + await game.classicMode.startBattle([SpeciesId.BULBASAUR]); + + const enemy = game.field.getEnemyPokemon(); + const powerSpy = vi.spyOn(allMoves[MoveId.STOMPING_TANTRUM], "calculateBattlePower"); + + // Yawn gets reflected back onto us, failing due to Insomnia + game.move.use(MoveId.YAWN); + await game.move.forceEnemyMove(MoveId.MAGIC_COAT); + await game.toNextTurn(); + + expect(enemy).toHaveUsedMove({ move: MoveId.YAWN, result: MoveResult.FAIL, useMode: MoveUseMode.REFLECTED }); + + game.move.use(MoveId.SPLASH); + await game.move.forceEnemyMove(MoveId.STOMPING_TANTRUM); + await game.toNextTurn(); + + expect(powerSpy).toHaveReturnedWith(150); + }); + + it("should respect immunities when bouncing a move", async () => { + vi.spyOn(allMoves[MoveId.THUNDER_WAVE], "accuracy", "get").mockReturnValue(100); + game.override.ability(AbilityId.SOUNDPROOF); + await game.classicMode.startBattle([SpeciesId.PHANPY]); + + // Turn 1 - thunder wave immunity test + game.move.use(MoveId.THUNDER_WAVE); + await game.toEndOfTurn(); + expect(game.field.getPlayerPokemon().status).toBeUndefined(); + + // Turn 2 - soundproof immunity test + game.move.use(MoveId.GROWL); + await game.toEndOfTurn(); + expect(game.field.getPlayerPokemon()).toHaveStatStage(Stat.ATK, 0); + }); + + it("should ignore the original move's accuracy and use the user's accuracy", async () => { + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + + const magikarp = game.field.getPlayerPokemon(); + const feebas = game.field.getEnemyPokemon(); + const karpMissSpy = vi.spyOn(magikarp, "getAccuracyMultiplier").mockReturnValue(0); + + // Turn 1: Force a miss on initial move + game.move.use(MoveId.SPORE); + await game.phaseInterceptor.to("MoveEndPhase"); + await game.toEndOfTurn(); + + // todo change once matchers fixed + expect(magikarp.status?.effect).toBe(StatusEffect.SLEEP); + + magikarp.clearStatus(false, false); + + karpMissSpy.mockRestore(); + vi.spyOn(feebas, "getAccuracyMultiplier").mockReturnValue(0); + + // Turn 2: Force a miss on Feebas' reflected move + game.move.use(MoveId.SPORE); + await game.toEndOfTurn(); + + expect(magikarp.status?.effect).toBeFalsy(); + }); + }); + + describe("Magic Bounce", () => { + beforeEach(() => { + game.override.enemyAbility(AbilityId.MAGIC_BOUNCE).enemyMoveset(MoveId.SPLASH); + }); + + // TODO: Change post speed order rework to check the FASTER pokemon's ability + it("should only apply the leftmost available target's magic bounce when bouncing field-targeted moves in doubles", async () => { + game.override.battleStyle("double"); + await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]); + + const [enemy1, enemy2] = game.scene.getEnemyField(); + // set speed to different values just in case logic erroneously checks for speed order + enemy1.setStat(Stat.SPD, enemy2.getStat(Stat.SPD) + 1); + + // turn 1 + game.move.use(MoveId.SPIKES, 0); + game.move.use(MoveId.TRICK_ROOM, 1); + await game.toNextTurn(); + + // TODO: Replace this with `expect(game).toHaveArenaTag({tagType: ArenaTagType.SPIKES, side: ArenaTagSide.PLAYER, sourceId: enemy1.id, layers: 1}) + const tag = game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER)!; + expect(tag).toBeDefined(); + expect(tag.getSourcePokemon()).toBe(enemy1); + expect(tag["layers"]).toBe(1); + game.scene.arena.removeTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER, true); + + // turn 2 + game.move.use(MoveId.SPIKES, 0); + game.move.use(MoveId.TRICK_ROOM, 1); + await game.toEndOfTurn(); + + // TODO: Replace this with `expect(game).toHaveArenaTag({tagType: ArenaTagType.SPIKES, side: ArenaTagSide.PLAYER, sourceId: enemy1.id, layers: 1}) + expect( + game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER)?.getSourcePokemon()?.getBattlerIndex(), + ).toBe(BattlerIndex.ENEMY); + }); + + it("should not bounce back status moves against semi-invulnerable Pokemon, even with No Guard", async () => { + await game.classicMode.startBattle([SpeciesId.BULBASAUR]); + + const player = game.field.getPlayerPokemon(); + const enemy = game.field.getEnemyPokemon(); + + // Turn 1: use charm while enemy is airborne; misses + game.move.use(MoveId.CHARM); + await game.move.forceEnemyMove(MoveId.FLY); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.toNextTurn(); + + expect(player).toHaveStatStage(Stat.ATK, 0); + expect(enemy).toHaveStatStage(Stat.ATK, 0); + + // Turn 2: Use Charm through No Guard; should not be reflected + game.field.mockAbility(player, AbilityId.NO_GUARD); + + game.move.use(MoveId.CHARM); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.toEndOfTurn(); + + expect(player).toHaveStatStage(Stat.ATK, 0); + expect(enemy).toHaveStatStage(Stat.ATK, -2); + }); + + it("should be overridden by Magic Coat without stacking", async () => { + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + + const karp = game.field.getPlayerPokemon(); + + game.move.use(MoveId.GROWL); + await game.move.forceEnemyMove(MoveId.MAGIC_COAT); + await game.toEndOfTurn(); + + expect(karp).toHaveStatStage(Stat.ATK, -1); + expect(game.field.getEnemyPokemon()).not.toHaveAbilityApplied(AbilityId.MAGIC_BOUNCE); + }); + + it("should bounce spikes even when the target is protected", async () => { + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + + game.move.use(MoveId.SPIKES); + await game.move.forceEnemyMove(MoveId.PROTECT); + await game.toEndOfTurn(); + + // TODO: Replace this with `expect(game).toHaveArenaTag({tagType: ArenaTagType.SPIKES, side: ArenaTagSide.PLAYER, layers: 1}) + expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER)!["layers"]).toBe(1); + }); + + it("should not break subsequent multi-strike moves", async () => { + await game.classicMode.startBattle([SpeciesId.PALKIA]); + + game.move.use(MoveId.GROWL); + await game.move.forceEnemyMove(MoveId.SURGING_STRIKES); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.toEndOfTurn(); + + const enemy = game.field.getEnemyPokemon(); + expect(enemy.turnData.hitCount).toBe(3); + }); + }); + + describe("Magic Coat", () => { + it("should fail if the user goes last in the turn", async () => { + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + + game.move.use(MoveId.PROTECT); + await game.toEndOfTurn(); + + expect(game.field.getEnemyPokemon().getLastXMoves()[0].result).toBe(MoveResult.FAIL); + }); + + it("should fail if called again in the same turn from Instruct", async () => { + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + + game.move.use(MoveId.INSTRUCT); + await game.toEndOfTurn(); + expect(game.field.getEnemyPokemon().getLastXMoves()[0].result).toBe(MoveResult.FAIL); + }); + + it("should not reflect moves used on the next turn", async () => { + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + + // turn 1 + game.move.use(MoveId.SPLASH); + await game.move.forceEnemyMove(MoveId.MAGIC_COAT); + await game.toNextTurn(); + + // turn 2 + game.move.use(MoveId.GROWL); + await game.move.forceEnemyMove(MoveId.SPLASH); + await game.toEndOfTurn(); + expect(game.field.getEnemyPokemon()).toHaveStatStage(Stat.ATK, -1); + }); + + it("should still bounce back a move from a mold breaker user", async () => { + game.override.ability(AbilityId.MOLD_BREAKER).moveset([MoveId.GROWL]); + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + + game.move.use(MoveId.GROWL); + await game.toEndOfTurn(); + + expect(game.field.getEnemyPokemon()).toHaveStatStage(Stat.ATK, 0); + expect(game.field.getPlayerPokemon()).toHaveStatStage(Stat.ATK, -1); + }); + + it("should only bounce spikes back once when both targets use magic coat in doubles", async () => { + game.override.battleStyle("double"); + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + + game.move.use(MoveId.SPIKES); + await game.toEndOfTurn(); + + expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER)!["layers"]).toBe(1); + expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY)).toBeUndefined(); + }); + }); +}); diff --git a/test/moves/magic-coat.test.ts b/test/moves/magic-coat.test.ts deleted file mode 100644 index 7c1c703119d..00000000000 --- a/test/moves/magic-coat.test.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { allMoves } from "#data/data-lists"; -import { AbilityId } from "#enums/ability-id"; -import { ArenaTagSide } from "#enums/arena-tag-side"; -import { ArenaTagType } from "#enums/arena-tag-type"; -import { BattlerIndex } from "#enums/battler-index"; -import { BattlerTagType } from "#enums/battler-tag-type"; -import { MoveId } from "#enums/move-id"; -import { MoveResult } from "#enums/move-result"; -import { SpeciesId } from "#enums/species-id"; -import { Stat } from "#enums/stat"; -import { StatusEffect } from "#enums/status-effect"; -import { GameManager } from "#test/test-utils/game-manager"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; - -describe("Moves - Magic Coat", () => { - let phaserGame: Phaser.Game; - let game: GameManager; - - beforeAll(() => { - phaserGame = new Phaser.Game({ - type: Phaser.HEADLESS, - }); - }); - - afterEach(() => { - game.phaseInterceptor.restoreOg(); - }); - - beforeEach(() => { - game = new GameManager(phaserGame); - game.override - .ability(AbilityId.BALL_FETCH) - .battleStyle("single") - .criticalHits(false) - .enemySpecies(SpeciesId.MAGIKARP) - .enemyAbility(AbilityId.BALL_FETCH) - .enemyMoveset(MoveId.MAGIC_COAT); - }); - - it("should fail if the user goes last in the turn", async () => { - game.override.moveset([MoveId.PROTECT]); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - game.move.select(MoveId.PROTECT); - await game.phaseInterceptor.to("BerryPhase"); - expect(game.field.getEnemyPokemon().getLastXMoves()[0].result).toBe(MoveResult.FAIL); - }); - - it("should fail if called again in the same turn due to moves like instruct", async () => { - game.override.moveset([MoveId.INSTRUCT]); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - game.move.select(MoveId.INSTRUCT); - await game.phaseInterceptor.to("BerryPhase"); - expect(game.field.getEnemyPokemon().getLastXMoves()[0].result).toBe(MoveResult.FAIL); - }); - - it("should not reflect moves used on the next turn", async () => { - game.override.moveset([MoveId.GROWL, MoveId.SPLASH]).enemyMoveset([MoveId.MAGIC_COAT, MoveId.SPLASH]); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - // turn 1 - game.move.select(MoveId.SPLASH); - await game.move.selectEnemyMove(MoveId.MAGIC_COAT); - await game.toNextTurn(); - - // turn 2 - game.move.select(MoveId.GROWL); - await game.move.selectEnemyMove(MoveId.SPLASH); - await game.phaseInterceptor.to("BerryPhase"); - expect(game.field.getEnemyPokemon().getStatStage(Stat.ATK)).toBe(-1); - }); - - it("should reflect basic status moves", async () => { - game.override.moveset([MoveId.GROWL]); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - game.move.select(MoveId.GROWL); - await game.phaseInterceptor.to("BerryPhase"); - expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(-1); - }); - - it("should individually bounce back multi-target moves when used by both targets in doubles", async () => { - game.override.battleStyle("double").moveset([MoveId.GROWL, MoveId.SPLASH]); - await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]); - - game.move.select(MoveId.GROWL, 0); - game.move.select(MoveId.SPLASH, 1); - await game.phaseInterceptor.to("BerryPhase"); - - const user = game.scene.getPlayerField()[0]; - expect(user.getStatStage(Stat.ATK)).toBe(-2); - }); - - it("should bounce back a spread status move against both pokemon", async () => { - game.override - .battleStyle("double") - .moveset([MoveId.GROWL, MoveId.SPLASH]) - .enemyMoveset([MoveId.SPLASH, MoveId.MAGIC_COAT]); - await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]); - - game.move.select(MoveId.GROWL, 0); - game.move.select(MoveId.SPLASH, 1); - await game.move.selectEnemyMove(MoveId.SPLASH); - await game.move.selectEnemyMove(MoveId.MAGIC_COAT); - - await game.phaseInterceptor.to("BerryPhase"); - expect(game.scene.getPlayerField().every(p => p.getStatStage(Stat.ATK) === -1)).toBeTruthy(); - }); - - it("should still bounce back a move that would otherwise fail", async () => { - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - game.field.getEnemyPokemon().setStatStage(Stat.ATK, -6); - game.override.moveset([MoveId.GROWL]); - - game.move.select(MoveId.GROWL); - await game.phaseInterceptor.to("BerryPhase"); - - expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(-1); - }); - - it("should not bounce back a move that was just bounced", async () => { - game.override - .battleStyle("double") - .ability(AbilityId.MAGIC_BOUNCE) - .moveset([MoveId.GROWL, MoveId.MAGIC_COAT]) - .enemyMoveset([MoveId.SPLASH, MoveId.MAGIC_COAT]); - await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]); - - game.move.select(MoveId.MAGIC_COAT, 0); - game.move.select(MoveId.GROWL, 1); - await game.move.selectEnemyMove(MoveId.MAGIC_COAT); - await game.move.selectEnemyMove(MoveId.SPLASH); - await game.phaseInterceptor.to("BerryPhase"); - - expect(game.scene.getEnemyField()[0].getStatStage(Stat.ATK)).toBe(0); - }); - - // todo while Mirror Armor is not implemented - it.todo("should receive the stat change after reflecting a move back to a mirror armor user", async () => { - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - game.move.select(MoveId.GROWL); - await game.phaseInterceptor.to("BerryPhase"); - - expect(game.field.getEnemyPokemon().getStatStage(Stat.ATK)).toBe(-1); - }); - - it("should still bounce back a move from a mold breaker user", async () => { - game.override.ability(AbilityId.MOLD_BREAKER).moveset([MoveId.GROWL]); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - game.move.select(MoveId.GROWL); - await game.phaseInterceptor.to("BerryPhase"); - - expect(game.field.getEnemyPokemon().getStatStage(Stat.ATK)).toBe(0); - expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(-1); - }); - - it("should only bounce spikes back once when both targets use magic coat in doubles", async () => { - game.override.battleStyle("double").moveset([MoveId.SPIKES]); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - game.move.select(MoveId.SPIKES); - await game.phaseInterceptor.to("BerryPhase"); - - expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER)!["layers"]).toBe(1); - expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY)).toBeUndefined(); - }); - - it("should not bounce back curse", async () => { - game.override.moveset([MoveId.CURSE]); - await game.classicMode.startBattle([SpeciesId.GASTLY]); - - game.move.select(MoveId.CURSE); - await game.phaseInterceptor.to("BerryPhase"); - - expect(game.field.getEnemyPokemon().getTag(BattlerTagType.CURSED)).toBeDefined(); - }); - - // TODO: encore is failing if the last move was virtual. - it.todo("should not cause the bounced move to count for encore", async () => { - game.override - .moveset([MoveId.GROWL, MoveId.ENCORE]) - .enemyMoveset([MoveId.MAGIC_COAT, MoveId.TACKLE]) - .enemyAbility(AbilityId.MAGIC_BOUNCE); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - const enemyPokemon = game.field.getEnemyPokemon(); - - // turn 1 - game.move.select(MoveId.GROWL); - await game.move.selectEnemyMove(MoveId.MAGIC_COAT); - await game.toNextTurn(); - - // turn 2 - game.move.select(MoveId.ENCORE); - await game.move.selectEnemyMove(MoveId.TACKLE); - await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); - await game.phaseInterceptor.to("BerryPhase"); - expect(enemyPokemon.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(MoveId.TACKLE); - expect(enemyPokemon.getLastXMoves()[0].move).toBe(MoveId.TACKLE); - }); - - // TODO: stomping tantrum should consider moves that were bounced. - it.todo("should cause stomping tantrum to double in power when the last move was bounced", async () => { - game.override.battleStyle("single").moveset([MoveId.STOMPING_TANTRUM, MoveId.CHARM]); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const stomping_tantrum = allMoves[MoveId.STOMPING_TANTRUM]; - vi.spyOn(stomping_tantrum, "calculateBattlePower"); - - game.move.select(MoveId.CHARM); - await game.toNextTurn(); - - game.move.select(MoveId.STOMPING_TANTRUM); - await game.phaseInterceptor.to("BerryPhase"); - expect(stomping_tantrum.calculateBattlePower).toHaveReturnedWith(150); - }); - - // TODO: stomping tantrum should consider moves that were bounced. - it.todo( - "should properly cause the enemy's stomping tantrum to be doubled in power after bouncing and failing", - async () => { - game.override.enemyMoveset([MoveId.STOMPING_TANTRUM, MoveId.SPLASH, MoveId.CHARM]); - await game.classicMode.startBattle([SpeciesId.BULBASAUR]); - - const stomping_tantrum = allMoves[MoveId.STOMPING_TANTRUM]; - const enemy = game.field.getEnemyPokemon(); - vi.spyOn(stomping_tantrum, "calculateBattlePower"); - - game.move.select(MoveId.SPORE); - await game.move.selectEnemyMove(MoveId.CHARM); - await game.phaseInterceptor.to("TurnEndPhase"); - expect(enemy.getLastXMoves(1)[0].result).toBe("success"); - - await game.phaseInterceptor.to("BerryPhase"); - expect(stomping_tantrum.calculateBattlePower).toHaveReturnedWith(75); - - await game.toNextTurn(); - game.move.select(MoveId.GROWL); - await game.phaseInterceptor.to("BerryPhase"); - expect(stomping_tantrum.calculateBattlePower).toHaveReturnedWith(75); - }, - ); - - it("should respect immunities when bouncing a move", async () => { - vi.spyOn(allMoves[MoveId.THUNDER_WAVE], "accuracy", "get").mockReturnValue(100); - game.override.moveset([MoveId.THUNDER_WAVE, MoveId.GROWL]).ability(AbilityId.SOUNDPROOF); - await game.classicMode.startBattle([SpeciesId.PHANPY]); - - // Turn 1 - thunder wave immunity test - game.move.select(MoveId.THUNDER_WAVE); - await game.phaseInterceptor.to("BerryPhase"); - expect(game.field.getPlayerPokemon().status).toBeUndefined(); - - // Turn 2 - soundproof immunity test - game.move.select(MoveId.GROWL); - await game.phaseInterceptor.to("BerryPhase"); - expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(0); - }); - - it("should bounce back a move before the accuracy check", async () => { - game.override.moveset([MoveId.SPORE]); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const attacker = game.field.getPlayerPokemon(); - - vi.spyOn(attacker, "getAccuracyMultiplier").mockReturnValue(0.0); - game.move.select(MoveId.SPORE); - await game.phaseInterceptor.to("BerryPhase"); - expect(game.field.getPlayerPokemon().status?.effect).toBe(StatusEffect.SLEEP); - }); - - it("should take the accuracy of the magic bounce user into account", async () => { - game.override.moveset([MoveId.SPORE]); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - const opponent = game.field.getEnemyPokemon(); - - vi.spyOn(opponent, "getAccuracyMultiplier").mockReturnValue(0); - game.move.select(MoveId.SPORE); - await game.phaseInterceptor.to("BerryPhase"); - expect(game.field.getPlayerPokemon().status).toBeUndefined(); - }); -}); diff --git a/test/test-utils/game-manager.ts b/test/test-utils/game-manager.ts index f952557bb69..49446c906ca 100644 --- a/test/test-utils/game-manager.ts +++ b/test/test-utils/game-manager.ts @@ -371,9 +371,13 @@ export class GameManager { console.log("==================[New Turn]=================="); } - /** Transition to the {@linkcode TurnEndPhase | end of the current turn}. */ - async toEndOfTurn() { - await this.phaseInterceptor.to("TurnEndPhase"); + /** + * Transition to the {@linkcode TurnEndPhase | end of the current turn}. + * @param runTarget - Whether or not to run the {@linkcode TurnEndPhase}; default `true` + * @returns A Promise that resolves once the turn has ended. + */ + async toEndOfTurn(runTarget = true): Promise { + await this.phaseInterceptor.to("TurnEndPhase", runTarget); console.log("==================[End of Turn]=================="); } @@ -532,14 +536,16 @@ export class GameManager { } /** - * Intercepts `TurnStartPhase` and mocks {@linkcode TurnStartPhase.getSpeedOrder}'s return value. + * Intercepts `TurnStartPhase` and mocks {@linkcode TurnStartPhase.getSpeedOrder}'s return value. \ * Used to manually modify Pokemon turn order. - * Note: This *DOES NOT* account for priority. - * @param order - The turn order to set as an array of {@linkcode BattlerIndex}es. + * + * @param order - The turn order to set, as an array of {@linkcode BattlerIndex}es * @example * ```ts * await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER_2]); * ``` + * @remarks + * This *does not* account for priority and will override Trick Room's effect. */ async setTurnOrder(order: BattlerIndex[]): Promise { await this.phaseInterceptor.to(TurnStartPhase, false);