diff --git a/src/battle.ts b/src/battle.ts index 1b789707806..c0d3d247471 100644 --- a/src/battle.ts +++ b/src/battle.ts @@ -74,7 +74,10 @@ export class Battle { public battleSeed: string = randomString(16, true); private battleSeedState: string | null = null; public moneyScattered = 0; - /** Primarily for double battles, keeps track of last enemy and player pokemon that triggered its ability or used a move */ + /** + * Primarily for double battles, keeps track of last enemy and player pokemon that triggered its ability or used a move + * @todo THis is only used for Sticky Web and in a really jank way... + */ public lastEnemyInvolved: number; public lastPlayerInvolved: number; public lastUsedPokeball: PokeballType | null = null; diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index f6494548b99..699dbaffa50 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 { MoveReflectPhase } from "#phases/move-reflect-phase"; /* biome-ignore-end lint/correctness/noUnusedImports: tsdoc imports */ import { applyAbAttrs } from "#abilities/apply-ab-attrs"; @@ -52,7 +54,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, @@ -5748,13 +5751,24 @@ 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 pending {@linkcode MoveReflectPhase}. */ -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 @@ -7200,10 +7214,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(), @@ -7237,7 +7248,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) => { @@ -7301,7 +7312,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 8abd98f4683..4f1186c37c7 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -56,6 +56,7 @@ import { MoveCategory } from "#enums/move-category"; import { MoveFlags } from "#enums/move-flags"; import { MoveId } from "#enums/move-id"; import { MoveResult } from "#enums/move-result"; +import { MoveTarget } from "#enums/move-target"; import { MoveUseMode } from "#enums/move-use-mode"; import { PokemonAnimType } from "#enums/pokemon-anim-type"; import { PokemonType } from "#enums/pokemon-type"; @@ -67,6 +68,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"; @@ -175,6 +178,7 @@ export class BattlerTag implements BaseBattlerTag { return ""; } + // TODO: Make this a getter isSourceLinked(): boolean { return false; } @@ -1229,13 +1233,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 */ - public moveId: MoveId; + /** The {@linkcode MoveId} the tag holder is locked into using. */ + public readonly 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, @@ -1244,20 +1251,22 @@ export class EncoreTag extends MoveRestrictionBattlerTag { public override loadTag(source: BaseBattlerTag & Pick): void { super.loadTag(source); - this.moveId = source.moveId; + (this as Mutable).moveId = source.moveId; } override canAdd(pokemon: Pokemon): boolean { const lastMove = pokemon.getLastNonVirtualMove(); - if (!lastMove) { + if ( + // No prior move has been used + !lastMove // Move is in the banlist + || invalidEncoreMoves.has(lastMove.move) // Move is out of PP + || !pokemon.getMoveset().some(m => m.moveId === lastMove.move && !m.isOutOfPp()) // Target has an active Shell Trap in waiting + || pokemon.getTag(BattlerTagType.SHELL_TRAP) + ) { return false; } - if (invalidEncoreMoves.has(lastMove.move)) { - return false; - } - - this.moveId = lastMove.move; + (this as Mutable).moveId = lastMove.move; return true; } @@ -1269,31 +1278,51 @@ export class EncoreTag extends MoveRestrictionBattlerTag { }), ); - const movesetMove = pokemon.getMoveset().find(m => m.moveId === this.moveId); - if (movesetMove) { - globalScene.phaseManager.changePhaseMove((phase: MovePhase) => phase.pokemon === pokemon, movesetMove); + // If the target has not moved yet, + // replace their upcoming move with the encored move against randomized targets + const movePhase = globalScene.phaseManager.getMovePhase(m => 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 && !m.isOutOfPp())!; + + // TODO: Resolve this after the move failure PR to occur during the start of the MP - + // after sleep but before all usability checks + movePhase.move = movesetMove; + movePhase["targets"] = this.getTargets(pokemon); + } + + private getTargets(pokemon: Pokemon): BattlerIndex[] { + // Edge case for Acupressure - always targets self + if (allMoves[this.moveId].moveTarget === MoveTarget.USER_OR_NEAR_ALLY) { + return [pokemon.getBattlerIndex()]; + } + + 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 + return moveTargets.multiple || moveTargets.targets.length === 1 + ? moveTargets.targets + : [moveTargets.targets[pokemon.randBattleSeedInt(moveTargets.targets.length)]]; } - /** - * 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 - */ override lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { - if (lapseType === BattlerTagLapseType.CUSTOM) { - const encoredMove = pokemon.getMoveset().find(m => m.moveId === this.moveId); - return encoredMove != null && encoredMove.getPpRatio() > 0; + // 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. + if (lapseType === BattlerTagLapseType.AFTER_MOVE) { + this.turnCount--; + return true; } - return super.lapse(pokemon, lapseType); + + const encoredMove = pokemon.getMoveset().find(m => m.moveId === this.moveId && !m.isOutOfPp()); + return encoredMove && this.turnCount > 0; } - /** - * Checks if the move matches the moveId stored within the tag and returns a boolean value - * @param move - The ID of the move selected - * @param user N/A - * @returns `true` if the move does not match with the moveId stored and as a result, restricted - */ - override isMoveRestricted(move: MoveId, _user?: Pokemon): boolean { + public override isMoveRestricted(move: MoveId): boolean { return move !== this.moveId; } @@ -1487,6 +1516,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); return false; } @@ -3620,6 +3650,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, + ); + } } /** @@ -3702,7 +3749,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..fed72bf8d8d 100644 --- a/src/data/moves/invalid-moves.ts +++ b/src/data/moves/invalid-moves.ts @@ -1,5 +1,20 @@ import { MoveId } from "#enums/move-id"; +/** + * Array containing all move-calling moves, used for DRY when writing move banlists + * + */ +const moveCallingMoves = [ + MoveId.ASSIST, + MoveId.COPYCAT, + MoveId.ME_FIRST, + MoveId.METRONOME, + MoveId.MIRROR_MOVE, + MoveId.NATURE_POWER, + MoveId.SLEEP_TALK, + MoveId.SNATCH, +] as const; + /** Set of moves that cannot be called by {@linkcode MoveId.METRONOME | Metronome}. */ export const invalidMetronomeMoves: ReadonlySet = new Set([ MoveId.AFTER_YOU, @@ -272,11 +287,72 @@ export const invalidSketchMoves: ReadonlySet = new Set([ /** Set of all moves that cannot be locked into by {@linkcode MoveId.ENCORE}. */ export const invalidEncoreMoves: ReadonlySet = new Set([ - MoveId.MIMIC, - MoveId.MIRROR_MOVE, + ...moveCallingMoves, MoveId.TRANSFORM, - MoveId.STRUGGLE, + MoveId.MIMIC, MoveId.SKETCH, + MoveId.STRUGGLE, + MoveId.DYNAMAX_CANNON, MoveId.SLEEP_TALK, MoveId.ENCORE, + // NB: Add Max/G-Max/Z-Move blockage if or when they are implemented +]); + +/** Set of all moves that cannot be repeated by {@linkcode MoveId.INSTRUCT}. */ +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 + Instruct + ...moveCallingMoves, + MoveId.INSTRUCT, + // Misc moves + MoveId.KINGS_SHIELD, + MoveId.SKETCH, + MoveId.TRANSFORM, + MoveId.MIMIC, + MoveId.STRUGGLE, + // NB: 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 075876d8ddd..f4390bc6b6a 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -78,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 { MovePhase } from "#phases/move-phase"; @@ -680,20 +680,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) } /** @@ -7095,10 +7084,11 @@ export class CopyMoveAttr extends CallMoveAttr { * Attribute used for moves that cause the target to repeat their last used move. * * Used by {@linkcode MoveId.INSTRUCT | Instruct}. - * @see [Instruct on Bulbapedia](https://bulbapedia.bulbagarden.net/wiki/Instruct_(move)) + * @see {@link https://bulbapedia.bulbagarden.net/wiki/Instruct_(move) | Instruct on Bulbapedia} */ export class RepeatMoveAttr extends MoveEffectAttr { private movesetMove: PokemonMove; + private lastMoveTargets: BattlerIndex[]; constructor() { super(false, { trigger: MoveEffectTrigger.POST_APPLY }); // needed to ensure correct protect interaction } @@ -7110,17 +7100,14 @@ export class RepeatMoveAttr extends MoveEffectAttr { * @returns `true` if the move succeeds */ apply(user: Pokemon, target: Pokemon): boolean { - // get the last move used (excluding status based failures) as well as the corresponding moveset slot - // 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) // Rampaging moves (e.g. Outrage) are not included due to being incompatible with Instruct, // nor is Dragon Darts (due to its smart targeting bypassing normal target selection) - let moveTargets = this.movesetMove.getMove().isMultiTarget() ? getMoveTargets(target, this.movesetMove.moveId).targets : lastMove.targets; + // TODO: Revisit this once target computation is moved to mid-use + let moveTargets = this.movesetMove.getMove().isMultiTarget() + ? getMoveTargets(target, this.movesetMove.moveId).targets + : this.lastMoveTargets; // In the event the instructed move's only target is a fainted opponent, redirect it to an alive ally if possible. // Normally, all yet-unexecuted move phases would swap targets after any foe faints or flees (see `redirectPokemonMoves` in `battle-scene.ts`), @@ -7138,6 +7125,13 @@ export class RepeatMoveAttr extends MoveEffectAttr { } } + // If the target is currently affected by Encore, increase its duration by 1 (to offset decrease during move use) + // TODO: There might be a better way of doing this... + 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) @@ -7150,82 +7144,20 @@ export class RepeatMoveAttr extends MoveEffectAttr { getCondition(): MoveConditionFunc { return (_user, target, _move) => { // TODO: Check instruct behavior with struggle - ignore, fail or success + // 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); - 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; + this.lastMoveTargets = lastMove.targets return true; }; } @@ -9091,11 +9023,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 @@ -9272,9 +9204,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 + // Should reflect moves that would otherwise fail .edgeCase(), new SelfStatusMove(MoveId.RECYCLE, PokemonType.NORMAL, -1, 10, -1, 0, 3) .unimplemented(), @@ -9285,9 +9215,11 @@ export function initMoves() { 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(), - 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) + .reflectable() + // Does not count as failed for terrain-based failures; should not check Safeguard when triggering drowsiness + .edgeCase(), + new AttackMove(MoveId.KNOCK_OFF, PokemonType.DARK, MoveCategory.PHYSICAL, 65, 100, 20, -1, 0, 3) + .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. @@ -10565,11 +10497,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 23daf0a971b..9635a4bdeb9 100644 --- a/src/data/moves/pokemon-move.ts +++ b/src/data/moves/pokemon-move.ts @@ -72,6 +72,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); } @@ -80,6 +81,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 cbad6caaafa..7d23b9fa17a 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -3532,6 +3532,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * Calculates the damage of an attack made by another Pokemon against this Pokemon * @param __namedParameters.source - Needed for proper typedoc rendering * @returns The {@linkcode DamageCalculationResult} + * @todo Condense various multipliers into a single function */ getAttackDamage({ source, @@ -4089,6 +4090,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { : this.summonData.tags.find(t => t.tagType === tagType); } + findTag(tagFilter: (tag: BattlerTag) => tag is T): T | undefined; + findTag(tagFilter: (tag: BattlerTag) => boolean): BattlerTag | undefined; /** * Find the first `BattlerTag` matching the specified predicate * @remarks @@ -4299,8 +4302,8 @@ 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[] { @@ -4308,9 +4311,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Add a new entry to this Pokemon's move history - * @remarks - * Does nothing if this Pokemon is not currently on the field. + * Add a move to the end of this {@linkcode Pokemon}'s move history, + * used to record its most recently executed actions. * @param turnMove - The move to add to the history */ public pushMoveHistory(turnMove: TurnMove): void { diff --git a/src/phase-manager.ts b/src/phase-manager.ts index 350e77e52eb..be987cd3f95 100644 --- a/src/phase-manager.ts +++ b/src/phase-manager.ts @@ -57,6 +57,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, @@ -163,6 +164,7 @@ const PHASES = Object.freeze({ MoveEffectPhase, MoveEndPhase, MoveHeaderPhase, + MoveReflectPhase, MovePhase, MysteryEncounterPhase, MysteryEncounterOptionSelectedPhase, diff --git a/src/phases/command-phase.ts b/src/phases/command-phase.ts index 2bf845776ca..59d02fa31e3 100644 --- a/src/phases/command-phase.ts +++ b/src/phases/command-phase.ts @@ -179,11 +179,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 be6d0164698..a7270a1192a 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -33,8 +33,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"; @@ -45,6 +44,18 @@ import i18next from "i18next"; export type HitCheckEntry = [HitCheckResult, TypeDamageMultiplier]; +/** + * Type representing the resolved of a move's damage processing. + */ +type MoveDamageTuple = [ + /** The {@linkcode HitResult} of the interaction. */ + result: HitResult, + /** The final amount of damage that was dealt. */ + damage: number, + /** Whether the attack was a critical hit. */ + wasCritical: boolean, +]; + export class MoveEffectPhase extends PokemonPhase { public readonly phaseName = "MoveEffectPhase"; public move: Move; @@ -89,140 +100,11 @@ export class MoveEffectPhase extends PokemonPhase { this.hitChecks = new 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 phases 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)) { - globalScene.phaseManager.unshiftNew( - "ShowAbilityPhase", - target.getBattlerIndex(), - target.getPassiveAbility().hasAttr("ReflectStatusMoveAbAttr"), - ); - globalScene.phaseManager.unshiftNew("HideAbilityPhase"); - } - - globalScene.phaseManager.unshiftNew( - "MovePhase", - target, - newTargets, - new PokemonMove(this.move.id), - MoveUseMode.REFLECTED, - MovePhaseTimingModifier.FIRST, - ); - } - - /** - * 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; @@ -317,6 +199,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 */ @@ -348,128 +282,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 damage - The amount of damage dealt to the target in the interaction - * @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, - damage: number, - wasCritical = false, - ): void { - const params = { pokemon: target, opponent: user, move: this.move, hitResult, damage }; - 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.unshiftNew("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 === 0 - && 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 * @@ -490,10 +348,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]; @@ -527,7 +381,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]; } @@ -599,9 +453,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; } @@ -634,83 +485,42 @@ export class MoveEffectPhase extends PokemonPhase { } /** - * @todo Investigate why this doesn't use `BattlerIndex` - * @returns The {@linkcode Pokemon} using 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 getUserPokemon(): Pokemon | undefined { - // TODO: Make this purely a battler index - if (this.battlerIndex > BattlerIndex.ENEMY_2) { - return globalScene.getPokemonById(this.battlerIndex); + 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: Figure out why this uses `fieldIndex` instead of `BattlerIndex` - // TODO: Remove `?? undefined` once field pokemon getters are made sane - return (this.player ? globalScene.getPlayerField() : globalScene.getEnemyField())[this.fieldIndex] ?? undefined; - } - /** - * @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 (!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 === 0 + && 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)))) + ); } /** @@ -758,18 +568,15 @@ export class MoveEffectPhase extends PokemonPhase { */ protected applyMoveEffects(target: Pokemon, effectiveness: TypeDamageMultiplier, firstTarget: boolean): void { const user = this.getUserPokemon(); - if (user == null) { - return; - } this.triggerMoveEffects(MoveEffectTrigger.PRE_APPLY, user, target); - const [hitResult, wasCritical, dmg] = this.applyMove(user, target, effectiveness); + const result = this.applyMove(user, target, effectiveness); // Apply effects to the user (always) and the target (if not blocked by substitute). this.triggerMoveEffects(MoveEffectTrigger.POST_APPLY, user, target, firstTarget, true); if (!this.move.hitsSubstitute(user, target)) { - this.applyOnTargetEffects(user, target, hitResult, firstTarget, dmg, wasCritical); + this.applyOnTargetEffects(user, target, firstTarget, result); } if (this.lastHit) { globalScene.triggerPokemonFormChange(user, SpeciesFormChangePostMoveTrigger); @@ -784,18 +591,43 @@ 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 + * @returns A {@linkcode MoveDamageTuple} containing the results of damage application. + */ + protected applyMove(user: Pokemon, target: Pokemon, effectiveness: TypeDamageMultiplier): MoveDamageTuple { + const moveCategory = user.getMoveCategory(target, this.move); + + if (moveCategory === MoveCategory.STATUS) { + return [HitResult.STATUS, 0, 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 - * @param effectiveness - The effectiveness of the move against the target - * @returns The {@linkcode HitResult} of the move against the target, a boolean indicating whether the target was crit, and the amount of damage dealt + * @param effectiveness - The type effectiveness of the move against the target + * @returns A tuple containing: + * 1. The {@linkcode HitResult} of the move against the target + * 2. The final amount of damage dealt + * 3. Whether the attack was a critical hit */ - protected applyMoveDamage( - user: Pokemon, - target: Pokemon, - effectiveness: TypeDamageMultiplier, - ): [result: HitResult, critical: boolean, damage: number] { + protected applyMoveDamage(user: Pokemon, target: Pokemon, effectiveness: TypeDamageMultiplier): MoveDamageTuple { const isCritical = target.getCriticalHitResult(user, this.move); /* @@ -816,19 +648,19 @@ export class MoveEffectPhase extends PokemonPhase { isCritical, }); + // TODO: Verify if flash fire/charge are consumed if damage is prevented const typeBoost = user.findTag( - t => t instanceof TypeBoostTag && t.boostedType === user.getMoveType(this.move), - ) as TypeBoostTag; + (t): t is TypeBoostTag => t instanceof TypeBoostTag && t.boostedType === user.getMoveType(this.move), + ); if (typeBoost?.oneUse) { user.removeTag(typeBoost.tagType); } - const isOneHitKo = result === HitResult.ONE_HIT_KO; - - if (!dmg) { - return [result, false, 0]; + if (dmg <= 0) { + return [result, 0, false]; } + const isOneHitKo = result === HitResult.ONE_HIT_KO; target.lapseTags(BattlerTagLapseType.HIT); const substitute = target.getTag(SubstituteTag); @@ -855,7 +687,7 @@ export class MoveEffectPhase extends PokemonPhase { } if (damage <= 0) { - return [result, isCritical, damage]; + return [result, 0, isCritical]; } if (user.isPlayer()) { @@ -884,7 +716,30 @@ export class MoveEffectPhase extends PokemonPhase { globalScene.applyModifiers(DamageMoneyRewardModifier, true, user, new NumberHolder(damage)); } - return [result, isCritical, damage]; + return [result, damage, 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); + } } /** @@ -908,76 +763,15 @@ 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 - * @returns The {@linkcode HitResult} of the move against the target, a boolean indicating whether the target was crit, and the amount of damage dealt - */ - protected applyMove( - user: Pokemon, - target: Pokemon, - effectiveness: TypeDamageMultiplier, - ): [HitResult, critical: boolean, damage: number] { - const moveCategory = user.getMoveCategory(target, this.move); - - if (moveCategory === MoveCategory.STATUS) { - return [HitResult.STATUS, false, 0]; - } - - 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 - * @param hitResult - The {@linkcode HitResult} obtained from applying the move * @param firstTarget - `true` if the target is the first Pokemon hit by the attack - * @param damage - The amount of damage dealt to the target in the interaction - * @param wasCritical - `true` if the move was a critical hit + * @param tuple - A {@linkcode MoveDamageTuple} containing the resolved damage result. */ - protected applyOnTargetEffects( - user: Pokemon, - target: Pokemon, - hitResult: HitResult, - firstTarget: boolean, - damage: number, - wasCritical = false, - ): void { + protected applyOnTargetEffects(user: Pokemon, target: Pokemon, firstTarget: boolean, tuple: MoveDamageTuple): void { + const [hitResult, damage] = tuple; /** Does {@linkcode hitResult} indicate that damage was dealt to the target? */ const dealsDamage = [ HitResult.EFFECTIVE, @@ -988,7 +782,7 @@ export class MoveEffectPhase extends PokemonPhase { this.triggerMoveEffects(MoveEffectTrigger.POST_APPLY, user, target, firstTarget, false); this.applyHeldItemFlinchCheck(user, target, dealsDamage); - this.applyOnGetHitAbEffects(user, target, hitResult, damage, wasCritical); + this.applyOnGetHitAbEffects(user, target, tuple); applyAbAttrs("PostAttackAbAttr", { pokemon: user, opponent: target, move: this.move, hitResult, damage }); // We assume only enemy Pokemon are able to have the EnemyAttackStatusEffectChanceModifier from tokens @@ -1001,4 +795,176 @@ 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 damage - The amount of damage dealt by the attack + * @param wasCritical - `true` if the move was a critical hit + */ + protected applyOnGetHitAbEffects( + user: Pokemon, + target: Pokemon, + [hitResult, damage, wasCritical]: MoveDamageTuple, + ): void { + const params = { pokemon: target, opponent: user, move: this.move, hitResult, damage }; + 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 nullish during the move execution itself, as the `start` method + * 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 6b7bc7453ed..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.field.getPlayerPokemon(); - 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 85d821d0683..9cdd53df02a 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 e31c7f28e48..8b030c54568 100644 --- a/test/moves/delayed-attack.test.ts +++ b/test/moves/delayed-attack.test.ts @@ -416,5 +416,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 abe0b8cfcf6..a7bfb773bdb 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]=================="); } @@ -536,14 +540,16 @@ export class GameManager { } /** - * Modifies the queue manager to return move phases in a particular order + * Modifies the queue manager to return move phases in a particular order. * 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);