diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index 7c67271b0dc..fdfcd4d076a 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -1,13 +1,13 @@ import { Arena } from "../field/arena"; import { Type } from "./type"; import * as Utils from "../utils"; -import { MoveCategory, allMoves, MoveTarget } from "./move"; +import { MoveCategory, allMoves, MoveTarget, IncrementMovePriorityAttr, applyMoveAttrs } from "./move"; import { getPokemonNameWithAffix } from "../messages"; import Pokemon, { HitResult, PokemonMove } from "../field/pokemon"; import { MoveEffectPhase, PokemonHealPhase, ShowAbilityPhase, StatChangePhase } from "../phases"; import { StatusEffect } from "./status-effect"; import { BattlerIndex } from "../battle"; -import { BlockNonDirectDamageAbAttr, ProtectStatAbAttr, applyAbAttrs } from "./ability"; +import { BlockNonDirectDamageAbAttr, ChangeMovePriorityAbAttr, ProtectStatAbAttr, applyAbAttrs } from "./ability"; import { BattleStat } from "./battle-stat"; import { CommonAnim, CommonBattleAnim } from "./battle-anims"; import i18next from "i18next"; @@ -186,20 +186,23 @@ class AuroraVeilTag extends WeakenMoveScreenTag { } } -type ProtectConditionFunc = (...args: any[]) => boolean; +type ProtectConditionFunc = (arena: Arena, moveId: Moves) => boolean; /** - * Abstract class to implement conditional team protection + * Class to implement conditional team protection * applies protection based on the attributes of incoming moves - * @param protectConditionFunc: The function determining if an incoming move is negated */ -abstract class ConditionalProtectTag extends ArenaTag { +export class ConditionalProtectTag extends ArenaTag { + /** The condition function to determine which moves are negated */ protected protectConditionFunc: ProtectConditionFunc; + /** Does this apply to all moves, including those that ignore other forms of protection? */ + protected ignoresBypass: boolean; - constructor(tagType: ArenaTagType, sourceMove: Moves, sourceId: integer, side: ArenaTagSide, condition: ProtectConditionFunc) { + constructor(tagType: ArenaTagType, sourceMove: Moves, sourceId: integer, side: ArenaTagSide, condition: ProtectConditionFunc, ignoresBypass: boolean = false) { super(tagType, 1, sourceMove, sourceId, side); this.protectConditionFunc = condition; + this.ignoresBypass = ignoresBypass; } onAdd(arena: Arena): void { @@ -213,42 +216,91 @@ abstract class ConditionalProtectTag extends ArenaTag { * apply(): Checks incoming moves against the condition function * and protects the target if conditions are met * @param arena The arena containing this tag - * @param args[0] (Utils.BooleanHolder) Signals if the move is cancelled - * @param args[1] (Pokemon) The intended target of the move - * @param args[2...] (any[]) The parameters to the condition function + * @param args\[0\] (Utils.BooleanHolder) Signals if the move is cancelled + * @param args\[1\] (Pokemon) The Pokemon using the move + * @param args\[2\] (Pokemon) The intended target of the move + * @param args\[3\] (Moves) The parameters to the condition function + * @param args\[4\] (Utils.BooleanHolder) Signals if the applied protection supercedes protection-ignoring effects * @returns */ apply(arena: Arena, args: any[]): boolean { - if ((args[0] as Utils.BooleanHolder).value) { - return false; - } + const [ cancelled, user, target, moveId, ignoresBypass ] = args; - const target = args[1] as Pokemon; - if ((this.side === ArenaTagSide.PLAYER) === target.isPlayer() - && this.protectConditionFunc(...args.slice(2))) { - (args[0] as Utils.BooleanHolder).value = true; - new CommonBattleAnim(CommonAnim.PROTECT, target).play(arena.scene); - arena.scene.queueMessage(i18next.t("arenaTag:conditionalProtectApply", { moveName: super.getMoveName(), pokemonNameWithAffix: getPokemonNameWithAffix(target) })); - return true; + if (cancelled instanceof Utils.BooleanHolder + && user instanceof Pokemon + && target instanceof Pokemon + && typeof moveId === "number" + && ignoresBypass instanceof Utils.BooleanHolder) { + + if ((this.side === ArenaTagSide.PLAYER) === target.isPlayer() + && this.protectConditionFunc(arena, moveId)) { + if (!cancelled.value) { + cancelled.value = true; + user.stopMultiHit(target); + + new CommonBattleAnim(CommonAnim.PROTECT, target).play(arena.scene); + arena.scene.queueMessage(i18next.t("arenaTag:conditionalProtectApply", { moveName: super.getMoveName(), pokemonNameWithAffix: getPokemonNameWithAffix(target) })); + } + + ignoresBypass.value = ignoresBypass.value || this.ignoresBypass; + return true; + } } return false; } } +/** + * Condition function for {@link https://bulbapedia.bulbagarden.net/wiki/Quick_Guard_(move) Quick Guard's} + * protection effect. + * @param arena {@linkcode Arena} The arena containing the protection effect + * @param moveId {@linkcode Moves} The move to check against this condition + * @returns `true` if the incoming move's priority is greater than 0. This includes + * moves with modified priorities from abilities (e.g. Prankster) + */ +const QuickGuardConditionFunc: ProtectConditionFunc = (arena, moveId) => { + const move = allMoves[moveId]; + const priority = new Utils.IntegerHolder(move.priority); + const effectPhase = arena.scene.getCurrentPhase(); + + if (effectPhase instanceof MoveEffectPhase) { + const attacker = effectPhase.getUserPokemon()!; + applyMoveAttrs(IncrementMovePriorityAttr, attacker, null, move, priority); + applyAbAttrs(ChangeMovePriorityAbAttr, attacker, null, move, priority); + } + return priority.value > 0; +}; + /** * Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Quick_Guard_(move) Quick Guard} * Condition: The incoming move has increased priority. */ class QuickGuardTag extends ConditionalProtectTag { constructor(sourceId: integer, side: ArenaTagSide) { - super(ArenaTagType.QUICK_GUARD, Moves.QUICK_GUARD, sourceId, side, - (priority: integer) : boolean => { - return priority > 0; - } - ); + super(ArenaTagType.QUICK_GUARD, Moves.QUICK_GUARD, sourceId, side, QuickGuardConditionFunc); } } +/** + * Condition function for {@link https://bulbapedia.bulbagarden.net/wiki/Wide_Guard_(move) Wide Guard's} + * protection effect. + * @param arena {@linkcode Arena} The arena containing the protection effect + * @param moveId {@linkcode Moves} The move to check against this condition + * @returns `true` if the incoming move is multi-targeted (even if it's only used against one Pokemon). + */ +const WideGuardConditionFunc: ProtectConditionFunc = (arena, moveId) : boolean => { + const move = allMoves[moveId]; + + switch (move.moveTarget) { + case MoveTarget.ALL_ENEMIES: + case MoveTarget.ALL_NEAR_ENEMIES: + case MoveTarget.ALL_OTHERS: + case MoveTarget.ALL_NEAR_OTHERS: + return true; + } + return false; +}; + /** * Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Wide_Guard_(move) Wide Guard} * Condition: The incoming move can target multiple Pokemon. The move's source @@ -256,32 +308,29 @@ class QuickGuardTag extends ConditionalProtectTag { */ class WideGuardTag extends ConditionalProtectTag { constructor(sourceId: integer, side: ArenaTagSide) { - super(ArenaTagType.WIDE_GUARD, Moves.WIDE_GUARD, sourceId, side, - (moveTarget: MoveTarget) : boolean => { - switch (moveTarget) { - case MoveTarget.ALL_ENEMIES: - case MoveTarget.ALL_NEAR_ENEMIES: - case MoveTarget.ALL_OTHERS: - case MoveTarget.ALL_NEAR_OTHERS: - return true; - } - return false; - } - ); + super(ArenaTagType.WIDE_GUARD, Moves.WIDE_GUARD, sourceId, side, WideGuardConditionFunc); } } +/** + * Condition function for {@link https://bulbapedia.bulbagarden.net/wiki/Mat_Block_(move) Mat Block's} + * protection effect. + * @param arena {@linkcode Arena} The arena containing the protection effect. + * @param moveId {@linkcode Moves} The move to check against this condition. + * @returns `true` if the incoming move is not a Status move. + */ +const MatBlockConditionFunc: ProtectConditionFunc = (arena, moveId) : boolean => { + const move = allMoves[moveId]; + return move.category !== MoveCategory.STATUS; +}; + /** * Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Mat_Block_(move) Mat Block} * Condition: The incoming move is a Physical or Special attack move. */ class MatBlockTag extends ConditionalProtectTag { constructor(sourceId: integer, side: ArenaTagSide) { - super(ArenaTagType.MAT_BLOCK, Moves.MAT_BLOCK, sourceId, side, - (moveCategory: MoveCategory) : boolean => { - return moveCategory !== MoveCategory.STATUS; - } - ); + super(ArenaTagType.MAT_BLOCK, Moves.MAT_BLOCK, sourceId, side, MatBlockConditionFunc); } onAdd(arena: Arena) { @@ -296,6 +345,22 @@ class MatBlockTag extends ConditionalProtectTag { } } +/** + * Condition function for {@link https://bulbapedia.bulbagarden.net/wiki/Crafty_Shield_(move) Crafty Shield's} + * protection effect. + * @param arena {@linkcode Arena} The arena containing the protection effect + * @param moveId {@linkcode Moves} The move to check against this condition + * @returns `true` if the incoming move is a Status move, is not a hazard, and does not target all + * Pokemon or sides of the field. + */ +const CraftyShieldConditionFunc: ProtectConditionFunc = (arena, moveId) => { + const move = allMoves[moveId]; + return move.category === MoveCategory.STATUS + && move.moveTarget !== MoveTarget.ENEMY_SIDE + && move.moveTarget !== MoveTarget.BOTH_SIDES + && move.moveTarget !== MoveTarget.ALL; +}; + /** * Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Crafty_Shield_(move) Crafty Shield} * Condition: The incoming move is a Status move, is not a hazard, and does @@ -303,14 +368,7 @@ class MatBlockTag extends ConditionalProtectTag { */ class CraftyShieldTag extends ConditionalProtectTag { constructor(sourceId: integer, side: ArenaTagSide) { - super(ArenaTagType.CRAFTY_SHIELD, Moves.CRAFTY_SHIELD, sourceId, side, - (moveCategory: MoveCategory, moveTarget: MoveTarget) : boolean => { - return moveCategory === MoveCategory.STATUS - && moveTarget !== MoveTarget.ENEMY_SIDE - && moveTarget !== MoveTarget.BOTH_SIDES - && moveTarget !== MoveTarget.ALL; - } - ); + super(ArenaTagType.CRAFTY_SHIELD, Moves.CRAFTY_SHIELD, sourceId, side, CraftyShieldConditionFunc, true); } } diff --git a/src/data/move.ts b/src/data/move.ts index 28b7835639c..79e67ece581 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -4298,15 +4298,25 @@ export class AddBattlerTagAttr extends MoveEffectAttr { public tagType: BattlerTagType; public turnCountMin: integer; public turnCountMax: integer; + protected cancelOnFail: boolean; private failOnOverlap: boolean; - constructor(tagType: BattlerTagType, selfTarget: boolean = false, failOnOverlap: boolean = false, turnCountMin: integer = 0, turnCountMax?: integer, lastHitOnly: boolean = false) { + constructor(tagType: BattlerTagType, selfTarget: boolean = false, failOnOverlap: boolean = false, turnCountMin: integer = 0, turnCountMax?: integer, lastHitOnly: boolean = false, cancelOnFail: boolean = false) { super(selfTarget, MoveEffectTrigger.POST_APPLY, false, lastHitOnly); this.tagType = tagType; this.turnCountMin = turnCountMin; this.turnCountMax = turnCountMax !== undefined ? turnCountMax : turnCountMin; this.failOnOverlap = !!failOnOverlap; + this.cancelOnFail = cancelOnFail; + } + + canApply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + if (!super.canApply(user, target, move, args) || (this.cancelOnFail === true && user.getLastXMoves(1)[0].result === MoveResult.FAIL)) { + return false; + } else { + return true; + } } apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { @@ -4505,7 +4515,7 @@ export class ConfuseAttr extends AddBattlerTagAttr { export class RechargeAttr extends AddBattlerTagAttr { constructor() { - super(BattlerTagType.RECHARGING, true, false, 1, 1, true); + super(BattlerTagType.RECHARGING, true, false, 1, 1, true, true); } } @@ -4690,7 +4700,7 @@ export class AddArenaTrapTagHitAttr extends AddArenaTagAttr { const moveChance = this.getMoveChance(user,target,move,this.selfTarget, true); const side = (this.selfSideTarget ? user : target).isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; const tag = user.scene.arena.getTagOnSide(this.tagType, side) as ArenaTrapTag; - if ((moveChance < 0 || moveChance === 100 || user.randSeedInt(100) < moveChance)) { + if ((moveChance < 0 || moveChance === 100 || user.randSeedInt(100) < moveChance) && user.getLastXMoves(1)[0].result === MoveResult.SUCCESS) { user.scene.arena.addTag(this.tagType, 0, move.id, user.id, side); if (!tag) { return true; @@ -8295,8 +8305,8 @@ export function initMoves() { .attr(HighCritAttr) .attr(BypassRedirectAttr), new AttackMove(Moves.JAW_LOCK, Type.DARK, MoveCategory.PHYSICAL, 80, 100, 10, -1, 0, 8) - .attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, false, 1) - .attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, true, false, 1) + .attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, false, 1, 1, false, true) + .attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, true, false, 1, 1, false, true) .bitingMove(), new SelfStatusMove(Moves.STUFF_CHEEKS, Type.NORMAL, -1, 10, -1, 0, 8) // TODO: Stuff Cheeks should not be selectable when the user does not have a berry, see wiki .attr(EatBerryAttr) @@ -8772,7 +8782,10 @@ export function initMoves() { .attr(ClearTerrainAttr), new AttackMove(Moves.GLAIVE_RUSH, Type.DRAGON, MoveCategory.PHYSICAL, 120, 100, 5, -1, 0, 9) .attr(AddBattlerTagAttr, BattlerTagType.ALWAYS_GET_HIT, true, false, 0, 0, true) - .attr(AddBattlerTagAttr, BattlerTagType.RECEIVE_DOUBLE_DAMAGE, true, false, 0, 0, true), + .attr(AddBattlerTagAttr, BattlerTagType.RECEIVE_DOUBLE_DAMAGE, true, false, 0, 0, true) + .condition((user, target, move) => { + return !(target.getTag(BattlerTagType.PROTECTED)?.tagType === "PROTECTED" || target.scene.arena.getTag(ArenaTagType.MAT_BLOCK)?.tagType === "MAT_BLOCK"); + }), new StatusMove(Moves.REVIVAL_BLESSING, Type.NORMAL, -1, 1, -1, 0, 9) .triageMove() .attr(RevivalBlessingAttr) diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 588b6839d70..4a46e7b2947 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -3,7 +3,7 @@ import BattleScene, { AnySound } from "../battle-scene"; import { Variant, VariantSet, variantColorCache } from "#app/data/variant"; import { variantData } from "#app/data/variant"; import BattleInfo, { PlayerBattleInfo, EnemyBattleInfo } from "../ui/battle-info"; -import Move, { HighCritAttr, HitsTagAttr, applyMoveAttrs, FixedDamageAttr, VariableAtkAttr, allMoves, MoveCategory, TypelessAttr, CritOnlyAttr, getMoveTargets, OneHitKOAttr, VariableMoveTypeAttr, StatusMoveTypeImmunityAttr, VariableDefAttr, AttackMove, ModifiedDamageAttr, VariableMoveTypeMultiplierAttr, IgnoreOpponentStatChangesAttr, SacrificialAttr, VariableMoveCategoryAttr, CounterDamageAttr, StatChangeAttr, RechargeAttr, ChargeAttr, IgnoreWeatherTypeDebuffAttr, BypassBurnDamageReductionAttr, SacrificialAttrOnHit, MoveFlags, NeutralDamageAgainstFlyingTypeMultiplierAttr, OneHitKOAccuracyAttr } from "../data/move"; +import Move, { HighCritAttr, HitsTagAttr, applyMoveAttrs, FixedDamageAttr, VariableAtkAttr, allMoves, MoveCategory, TypelessAttr, CritOnlyAttr, getMoveTargets, OneHitKOAttr, VariableMoveTypeAttr, StatusMoveTypeImmunityAttr, VariableDefAttr, AttackMove, ModifiedDamageAttr, VariableMoveTypeMultiplierAttr, IgnoreOpponentStatChangesAttr, SacrificialAttr, VariableMoveCategoryAttr, CounterDamageAttr, StatChangeAttr, RechargeAttr, ChargeAttr, IgnoreWeatherTypeDebuffAttr, BypassBurnDamageReductionAttr, SacrificialAttrOnHit, NeutralDamageAgainstFlyingTypeMultiplierAttr, OneHitKOAccuracyAttr } from "../data/move"; import { default as PokemonSpecies, PokemonSpeciesForm, SpeciesFormKey, getFusedSpeciesName, getPokemonSpecies, getPokemonSpeciesForm, getStarterValueFriendshipCap, speciesStarters, starterPassiveAbilities } from "../data/pokemon-species"; import { Constructor } from "#app/utils"; import * as Utils from "../utils"; @@ -1942,20 +1942,6 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { typeMultiplier.value = 0; } - // Apply arena tags for conditional protection - if (!move.checkFlag(MoveFlags.IGNORE_PROTECT, source, this) && !move.isAllyTarget()) { - this.scene.arena.applyTagsForSide(ArenaTagType.QUICK_GUARD, defendingSide, cancelled, this, move.priority); - this.scene.arena.applyTagsForSide(ArenaTagType.WIDE_GUARD, defendingSide, cancelled, this, move.moveTarget); - this.scene.arena.applyTagsForSide(ArenaTagType.MAT_BLOCK, defendingSide, cancelled, this, move.category); - this.scene.arena.applyTagsForSide(ArenaTagType.CRAFTY_SHIELD, defendingSide, cancelled, this, move.category, move.moveTarget); - } - - // Apply exceptional condition of Crafty Shield if the move used is Curse - if (move.id === Moves.CURSE) { - const defendingSide = this.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; - this.scene.arena.applyTagsForSide(ArenaTagType.CRAFTY_SHIELD, defendingSide, cancelled, this, move.category, move.moveTarget); - } - switch (moveCategory) { case MoveCategory.PHYSICAL: case MoveCategory.SPECIAL: diff --git a/src/phases.ts b/src/phases.ts index c9d2517fbef..9edd851970e 100644 --- a/src/phases.ts +++ b/src/phases.ts @@ -24,7 +24,7 @@ import { getPokemonNameWithAffix } from "./messages"; import { Starter } from "./ui/starter-select-ui-handler"; import { Gender } from "./data/gender"; import { Weather, WeatherType, getRandomWeatherType, getTerrainBlockMessage, getWeatherDamageMessage, getWeatherLapseMessage } from "./data/weather"; -import { ArenaTagSide, ArenaTrapTag, MistTag, TrickRoomTag } from "./data/arena-tag"; +import { ArenaTagSide, ArenaTrapTag, ConditionalProtectTag, MistTag, TrickRoomTag } from "./data/arena-tag"; import { CheckTrappedAbAttr, PostAttackAbAttr, PostBattleAbAttr, PostDefendAbAttr, PostSummonAbAttr, PostTurnAbAttr, PostWeatherLapseAbAttr, PreSwitchOutAbAttr, PreWeatherDamageAbAttr, ProtectStatAbAttr, RedirectMoveAbAttr, BlockRedirectAbAttr, RunSuccessAbAttr, StatChangeMultiplierAbAttr, SuppressWeatherEffectAbAttr, SyncEncounterNatureAbAttr, applyAbAttrs, applyCheckTrappedAbAttrs, applyPostAttackAbAttrs, applyPostBattleAbAttrs, applyPostDefendAbAttrs, applyPostSummonAbAttrs, applyPostTurnAbAttrs, applyPostWeatherLapseAbAttrs, applyPreStatChangeAbAttrs, applyPreSwitchOutAbAttrs, applyPreWeatherEffectAbAttrs, ChangeMovePriorityAbAttr, applyPostVictoryAbAttrs, PostVictoryAbAttr, BlockNonDirectDamageAbAttr as BlockNonDirectDamageAbAttr, applyPostKnockOutAbAttrs, PostKnockOutAbAttr, PostBiomeChangeAbAttr, PreventBypassSpeedChanceAbAttr, applyPostFaintAbAttrs, PostFaintAbAttr, IncreasePpAbAttr, PostStatChangeAbAttr, applyPostStatChangeAbAttrs, AlwaysHitAbAttr, PreventBerryUseAbAttr, StatChangeCopyAbAttr, PokemonTypeChangeAbAttr, applyPreAttackAbAttrs, applyPostMoveUsedAbAttrs, PostMoveUsedAbAttr, MaxMultiHitAbAttr, HealFromBerryUseAbAttr, IgnoreMoveEffectsAbAttr, BlockStatusDamageAbAttr, BypassSpeedChanceAbAttr, AddSecondStrikeAbAttr } from "./data/ability"; import { Unlockables, getUnlockableName } from "./system/unlockables"; import { getBiomeKey } from "./field/arena"; @@ -3038,8 +3038,7 @@ export class MoveEffectPhase extends PokemonPhase { /** * Log to be entered into the user's move history once the move result is resolved. * Note that `result` (a {@linkcode MoveResult}) logs whether the move was successfully - * used in the sense of it not failing or missing; it does not account for the move's - * effectiveness (which is logged as a {@linkcode HitResult}). + * used in the sense of "Does it have an effect on the user?". */ const moveHistoryEntry = { move: this.move.moveId, targets: this.targets, result: MoveResult.PENDING, virtual: this.move.virtual }; @@ -3091,8 +3090,20 @@ export class MoveEffectPhase extends PokemonPhase { continue; } - /** Is the invoked move blocked by a protection effect on the target? */ - const isProtected = !this.move.getMove().checkFlag(MoveFlags.IGNORE_PROTECT, user, target) && target.findTags(t => t instanceof ProtectedTag).find(t => target.lapseTag(t.tagType)); + /** 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 Utils.BooleanHolder(false); + /** Does the applied conditional protection bypass Protect-ignoring effects? */ + const bypassIgnoreProtect = new Utils.BooleanHolder(false); + // If the move is not targeting a Pokemon on the user's side, try to apply conditional protection effects + if (!this.move.getMove().isAllyTarget()) { + this.scene.arena.applyTagsForSide(ConditionalProtectTag, targetSide, hasConditionalProtectApplied, user, target, move.id, bypassIgnoreProtect); + } + + /** Is the target protected by Protect, etc. or a relevant conditional protection effect? */ + const isProtected = (bypassIgnoreProtect.value || !this.move.getMove().checkFlag(MoveFlags.IGNORE_PROTECT, user, target)) + && (hasConditionalProtectApplied.value || target.findTags(t => t instanceof ProtectedTag).find(t => target.lapseTag(t.tagType))); /** Does this phase represent the invoked move's first strike? */ const firstHit = (user.turnData.hitsLeft === user.turnData.hitCount); @@ -3104,8 +3115,8 @@ export class MoveEffectPhase extends PokemonPhase { /** * Since all fail/miss checks have applied, the move is considered successfully applied. - * It's worth noting that if the move has no effect or is protected against, it's move - * result is still logged as a SUCCESS. + * It's worth noting that if the move has no effect or is protected against, this assignment + * is overwritten and the move is logged as a FAIL. */ moveHistoryEntry.result = MoveResult.SUCCESS; @@ -3136,6 +3147,14 @@ export class MoveEffectPhase extends PokemonPhase { hasHit = true; } + /** + * If the move has no effect on the target (i.e. the target is protected or immune), + * change the logged move result to FAIL. + */ + if (hitResult === HitResult.NO_EFFECT) { + moveHistoryEntry.result = MoveResult.FAIL; + } + /** Does this phase represent the invoked move's last strike? */ const lastHit = (user.turnData.hitsLeft === 1 || !this.getTarget()?.isActive()); diff --git a/src/test/moves/crafty_shield.test.ts b/src/test/moves/crafty_shield.test.ts new file mode 100644 index 00000000000..de2829aacf6 --- /dev/null +++ b/src/test/moves/crafty_shield.test.ts @@ -0,0 +1,124 @@ +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest"; +import GameManager from "../utils/gameManager"; +import { Species } from "#enums/species"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { getMovePosition } from "../utils/gameManagerUtils"; +import { BerryPhase, CommandPhase } from "#app/phases.js"; +import { BattleStat } from "#app/data/battle-stat.js"; +import { BattlerTagType } from "#app/enums/battler-tag-type.js"; + +const TIMEOUT = 20 * 1000; + +describe("Moves - Crafty Shield", () => { + 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.battleType("double"); + + game.override.moveset([Moves.CRAFTY_SHIELD, Moves.SPLASH, Moves.SWORDS_DANCE]); + + game.override.enemySpecies(Species.SNORLAX); + game.override.enemyMoveset(Array(4).fill(Moves.GROWL)); + game.override.enemyAbility(Abilities.INSOMNIA); + + game.override.startingLevel(100); + game.override.enemyLevel(100); + }); + + test( + "should protect the user and allies from status moves", + async () => { + await game.startBattle([Species.CHARIZARD, Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerField(); + + game.doAttack(getMovePosition(game.scene, 0, Moves.CRAFTY_SHIELD)); + + await game.phaseInterceptor.to(CommandPhase); + + game.doAttack(getMovePosition(game.scene, 1, Moves.SPLASH)); + + await game.phaseInterceptor.to(BerryPhase, false); + + leadPokemon.forEach(p => expect(p.summonData.battleStats[BattleStat.ATK]).toBe(0)); + }, TIMEOUT + ); + + test( + "should not protect the user and allies from attack moves", + async () => { + game.override.enemyMoveset(Array(4).fill(Moves.TACKLE)); + + await game.startBattle([Species.CHARIZARD, Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerField(); + + game.doAttack(getMovePosition(game.scene, 0, Moves.CRAFTY_SHIELD)); + + await game.phaseInterceptor.to(CommandPhase); + + game.doAttack(getMovePosition(game.scene, 1, Moves.SPLASH)); + + await game.phaseInterceptor.to(BerryPhase, false); + + expect(leadPokemon.some(p => p.hp < p.getMaxHp())).toBeTruthy(); + }, TIMEOUT + ); + + test( + "should protect the user and allies from moves that ignore other protection", + async () => { + game.override.enemySpecies(Species.DUSCLOPS); + game.override.enemyMoveset(Array(4).fill(Moves.CURSE)); + + await game.startBattle([Species.CHARIZARD, Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerField(); + + game.doAttack(getMovePosition(game.scene, 0, Moves.CRAFTY_SHIELD)); + + await game.phaseInterceptor.to(CommandPhase); + + game.doAttack(getMovePosition(game.scene, 1, Moves.SPLASH)); + + await game.phaseInterceptor.to(BerryPhase, false); + + leadPokemon.forEach(p => expect(p.getTag(BattlerTagType.CURSED)).toBeUndefined()); + }, TIMEOUT + ); + + test( + "should not block allies' self-targeted moves", + async () => { + await game.startBattle([Species.CHARIZARD, Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerField(); + + game.doAttack(getMovePosition(game.scene, 0, Moves.CRAFTY_SHIELD)); + + await game.phaseInterceptor.to(CommandPhase); + + game.doAttack(getMovePosition(game.scene, 1, Moves.SWORDS_DANCE)); + + await game.phaseInterceptor.to(BerryPhase, false); + + expect(leadPokemon[0].summonData.battleStats[BattleStat.ATK]).toBe(0); + expect(leadPokemon[1].summonData.battleStats[BattleStat.ATK]).toBe(2); + } + ); +}); diff --git a/src/test/moves/glaive_rush.test.ts b/src/test/moves/glaive_rush.test.ts index ce63da6b565..b9c9d2199d3 100644 --- a/src/test/moves/glaive_rush.test.ts +++ b/src/test/moves/glaive_rush.test.ts @@ -128,4 +128,28 @@ describe("Moves - Glaive Rush", () => { expect(player.hp).toBe(player.getMaxHp()); }, 20000); + + it("secondary effects don't activate if move fails", async() => { + game.override.moveset([Moves.SHADOW_SNEAK, Moves.PROTECT, Moves.SPLASH, Moves.GLAIVE_RUSH]); + await game.startBattle(); + const player = game.scene.getPlayerPokemon()!; + const enemy = game.scene.getEnemyPokemon()!; + enemy.hp = 1000; + player.hp = 1000; + + game.doAttack(getMovePosition(game.scene, 0, Moves.PROTECT)); + await game.phaseInterceptor.to(TurnEndPhase); + + game.doAttack(getMovePosition(game.scene, 0, Moves.SHADOW_SNEAK)); + await game.phaseInterceptor.to(TurnEndPhase); + game.override.enemyMoveset(Array(4).fill(Moves.SPLASH)); + const damagedHP1 = 1000 - enemy.hp; + enemy.hp = 1000; + + game.doAttack(getMovePosition(game.scene, 0, Moves.SHADOW_SNEAK)); + await game.phaseInterceptor.to(TurnEndPhase); + const damagedHP2 = 1000 - enemy.hp; + + expect(damagedHP2).toBeGreaterThanOrEqual((damagedHP1 * 2) - 1); + }, 20000); }); diff --git a/src/test/moves/mat_block.test.ts b/src/test/moves/mat_block.test.ts new file mode 100644 index 00000000000..3a4d23d1497 --- /dev/null +++ b/src/test/moves/mat_block.test.ts @@ -0,0 +1,107 @@ +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest"; +import GameManager from "../utils/gameManager"; +import { Species } from "#enums/species"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { getMovePosition } from "../utils/gameManagerUtils"; +import { BerryPhase, CommandPhase, TurnEndPhase } from "#app/phases.js"; +import { BattleStat } from "#app/data/battle-stat.js"; + +const TIMEOUT = 20 * 1000; + +describe("Moves - Mat Block", () => { + 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.battleType("double"); + + game.override.moveset([Moves.MAT_BLOCK, Moves.SPLASH]); + + game.override.enemySpecies(Species.SNORLAX); + game.override.enemyMoveset(Array(4).fill(Moves.TACKLE)); + game.override.enemyAbility(Abilities.INSOMNIA); + + game.override.startingLevel(100); + game.override.enemyLevel(100); + }); + + test( + "should protect the user and allies from attack moves", + async () => { + await game.startBattle([Species.CHARIZARD, Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerField(); + + game.doAttack(getMovePosition(game.scene, 0, Moves.MAT_BLOCK)); + + await game.phaseInterceptor.to(CommandPhase); + + game.doAttack(getMovePosition(game.scene, 1, Moves.SPLASH)); + + await game.phaseInterceptor.to(BerryPhase, false); + + leadPokemon.forEach(p => expect(p.hp).toBe(p.getMaxHp())); + }, TIMEOUT + ); + + test( + "should not protect the user and allies from status moves", + async () => { + game.override.enemyMoveset(Array(4).fill(Moves.GROWL)); + + await game.startBattle([Species.CHARIZARD, Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerField(); + + game.doAttack(getMovePosition(game.scene, 0, Moves.MAT_BLOCK)); + + await game.phaseInterceptor.to(CommandPhase); + + game.doAttack(getMovePosition(game.scene, 1, Moves.SPLASH)); + + await game.phaseInterceptor.to(BerryPhase, false); + + leadPokemon.forEach(p => expect(p.summonData.battleStats[BattleStat.ATK]).toBe(-2)); + }, TIMEOUT + ); + + test( + "should fail when used after the first turn", + async () => { + await game.startBattle([Species.BLASTOISE, Species.CHARIZARD]); + + const leadPokemon = game.scene.getPlayerField(); + + game.doAttack(getMovePosition(game.scene, 0, Moves.SPLASH)); + await game.phaseInterceptor.to(CommandPhase); + game.doAttack(getMovePosition(game.scene, 1, Moves.SPLASH)); + + await game.phaseInterceptor.to(TurnEndPhase); + + const leadStartingHp = leadPokemon.map(p => p.hp); + + await game.phaseInterceptor.to(CommandPhase, false); + game.doAttack(getMovePosition(game.scene, 0, Moves.MAT_BLOCK)); + await game.phaseInterceptor.to(CommandPhase); + game.doAttack(getMovePosition(game.scene, 1, Moves.MAT_BLOCK)); + + await game.phaseInterceptor.to(BerryPhase, false); + + expect(leadPokemon.some((p, i) => p.hp < leadStartingHp[i])).toBeTruthy(); + }, TIMEOUT + ); +}); diff --git a/src/test/moves/protect.test.ts b/src/test/moves/protect.test.ts new file mode 100644 index 00000000000..34e208e0914 --- /dev/null +++ b/src/test/moves/protect.test.ts @@ -0,0 +1,114 @@ +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; +import GameManager from "../utils/gameManager"; +import { Species } from "#enums/species"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { getMovePosition } from "../utils/gameManagerUtils"; +import { BerryPhase } from "#app/phases.js"; +import { BattleStat } from "#app/data/battle-stat.js"; +import { allMoves } from "#app/data/move.js"; +import { ArenaTagSide, ArenaTrapTag } from "#app/data/arena-tag.js"; + +const TIMEOUT = 20 * 1000; + +describe("Moves - Protect", () => { + 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.battleType("single"); + + game.override.moveset([Moves.PROTECT]); + game.override.enemySpecies(Species.SNORLAX); + + game.override.enemyAbility(Abilities.INSOMNIA); + game.override.enemyMoveset(Array(4).fill(Moves.TACKLE)); + + game.override.startingLevel(100); + game.override.enemyLevel(100); + }); + + test( + "should protect the user from attacks", + async () => { + await game.startBattle([Species.CHARIZARD]); + + const leadPokemon = game.scene.getPlayerPokemon()!; + + game.doAttack(getMovePosition(game.scene, 0, Moves.PROTECT)); + + await game.phaseInterceptor.to(BerryPhase, false); + + expect(leadPokemon.hp).toBe(leadPokemon.getMaxHp()); + }, TIMEOUT + ); + + test( + "should prevent secondary effects from the opponent's attack", + async () => { + game.override.enemyMoveset(Array(4).fill(Moves.CEASELESS_EDGE)); + vi.spyOn(allMoves[Moves.CEASELESS_EDGE], "accuracy", "get").mockReturnValue(100); + + await game.startBattle([Species.CHARIZARD]); + + const leadPokemon = game.scene.getPlayerPokemon()!; + + game.doAttack(getMovePosition(game.scene, 0, Moves.PROTECT)); + + await game.phaseInterceptor.to(BerryPhase, false); + + expect(leadPokemon.hp).toBe(leadPokemon.getMaxHp()); + expect(game.scene.arena.getTagOnSide(ArenaTrapTag, ArenaTagSide.ENEMY)).toBeUndefined(); + }, TIMEOUT + ); + + test( + "should protect the user from status moves", + async () => { + game.override.enemyMoveset(Array(4).fill(Moves.CHARM)); + + await game.startBattle([Species.CHARIZARD]); + + const leadPokemon = game.scene.getPlayerPokemon()!; + + game.doAttack(getMovePosition(game.scene, 0, Moves.PROTECT)); + + await game.phaseInterceptor.to(BerryPhase, false); + + expect(leadPokemon.summonData.battleStats[BattleStat.ATK]).toBe(0); + }, TIMEOUT + ); + + test( + "should stop subsequent hits of a multi-hit move", + async () => { + game.override.enemyMoveset(Array(4).fill(Moves.TACHYON_CUTTER)); + + await game.startBattle([Species.CHARIZARD]); + + const leadPokemon = game.scene.getPlayerPokemon()!; + + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.doAttack(getMovePosition(game.scene, 0, Moves.PROTECT)); + + await game.phaseInterceptor.to(BerryPhase, false); + + expect(leadPokemon.hp).toBe(leadPokemon.getMaxHp()); + expect(enemyPokemon.turnData.hitCount).toBe(1); + }, TIMEOUT + ); +}); diff --git a/src/test/moves/quick_guard.test.ts b/src/test/moves/quick_guard.test.ts new file mode 100644 index 00000000000..58165f3d916 --- /dev/null +++ b/src/test/moves/quick_guard.test.ts @@ -0,0 +1,105 @@ +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest"; +import GameManager from "../utils/gameManager"; +import { Species } from "#enums/species"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { getMovePosition } from "../utils/gameManagerUtils"; +import { BerryPhase, CommandPhase } from "#app/phases.js"; +import { BattleStat } from "#app/data/battle-stat.js"; + +const TIMEOUT = 20 * 1000; + +describe("Moves - Quick Guard", () => { + 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.battleType("double"); + + game.override.moveset([Moves.QUICK_GUARD, Moves.SPLASH, Moves.FOLLOW_ME]); + + game.override.enemySpecies(Species.SNORLAX); + game.override.enemyMoveset(Array(4).fill(Moves.QUICK_ATTACK)); + game.override.enemyAbility(Abilities.INSOMNIA); + + game.override.startingLevel(100); + game.override.enemyLevel(100); + }); + + test( + "should protect the user and allies from priority moves", + async () => { + await game.startBattle([Species.CHARIZARD, Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerField(); + + game.doAttack(getMovePosition(game.scene, 0, Moves.QUICK_GUARD)); + + await game.phaseInterceptor.to(CommandPhase); + + game.doAttack(getMovePosition(game.scene, 1, Moves.SPLASH)); + + await game.phaseInterceptor.to(BerryPhase, false); + + leadPokemon.forEach(p => expect(p.hp).toBe(p.getMaxHp())); + }, TIMEOUT + ); + + test( + "should protect the user and allies from Prankster-boosted moves", + async () => { + game.override.enemyAbility(Abilities.PRANKSTER); + game.override.enemyMoveset(Array(4).fill(Moves.GROWL)); + + await game.startBattle([Species.CHARIZARD, Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerField(); + + game.doAttack(getMovePosition(game.scene, 0, Moves.QUICK_GUARD)); + + await game.phaseInterceptor.to(CommandPhase); + + game.doAttack(getMovePosition(game.scene, 1, Moves.SPLASH)); + + await game.phaseInterceptor.to(BerryPhase, false); + + leadPokemon.forEach(p => expect(p.summonData.battleStats[BattleStat.ATK]).toBe(0)); + }, TIMEOUT + ); + + test( + "should stop subsequent hits of a multi-hit priority move", + async () => { + game.override.enemyMoveset(Array(4).fill(Moves.WATER_SHURIKEN)); + + await game.startBattle([Species.CHARIZARD, Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerField(); + const enemyPokemon = game.scene.getEnemyField(); + + game.doAttack(getMovePosition(game.scene, 0, Moves.QUICK_GUARD)); + + await game.phaseInterceptor.to(CommandPhase); + + game.doAttack(getMovePosition(game.scene, 1, Moves.FOLLOW_ME)); + + await game.phaseInterceptor.to(BerryPhase, false); + + leadPokemon.forEach(p => expect(p.hp).toBe(p.getMaxHp())); + enemyPokemon.forEach(p => expect(p.turnData.hitCount).toBe(1)); + } + ); +}); diff --git a/src/test/moves/wide_guard.test.ts b/src/test/moves/wide_guard.test.ts new file mode 100644 index 00000000000..94f382022c2 --- /dev/null +++ b/src/test/moves/wide_guard.test.ts @@ -0,0 +1,125 @@ +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest"; +import GameManager from "../utils/gameManager"; +import { Species } from "#enums/species"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { getMovePosition } from "../utils/gameManagerUtils"; +import { BerryPhase, CommandPhase } from "#app/phases.js"; +import { BattleStat } from "#app/data/battle-stat.js"; + +const TIMEOUT = 20 * 1000; + +describe("Moves - Wide Guard", () => { + 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.battleType("double"); + + game.override.moveset([Moves.WIDE_GUARD, Moves.SPLASH, Moves.SURF]); + + game.override.enemySpecies(Species.SNORLAX); + game.override.enemyMoveset(Array(4).fill(Moves.SWIFT)); + game.override.enemyAbility(Abilities.INSOMNIA); + + game.override.startingLevel(100); + game.override.enemyLevel(100); + }); + + test( + "should protect the user and allies from multi-target attack moves", + async () => { + await game.startBattle([Species.CHARIZARD, Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerField(); + + game.doAttack(getMovePosition(game.scene, 0, Moves.WIDE_GUARD)); + + await game.phaseInterceptor.to(CommandPhase); + + game.doAttack(getMovePosition(game.scene, 1, Moves.SPLASH)); + + await game.phaseInterceptor.to(BerryPhase, false); + + leadPokemon.forEach(p => expect(p.hp).toBe(p.getMaxHp())); + }, TIMEOUT + ); + + test( + "should protect the user and allies from multi-target status moves", + async () => { + game.override.enemyMoveset(Array(4).fill(Moves.GROWL)); + + await game.startBattle([Species.CHARIZARD, Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerField(); + + game.doAttack(getMovePosition(game.scene, 0, Moves.WIDE_GUARD)); + + await game.phaseInterceptor.to(CommandPhase); + + game.doAttack(getMovePosition(game.scene, 1, Moves.SPLASH)); + + await game.phaseInterceptor.to(BerryPhase, false); + + leadPokemon.forEach(p => expect(p.summonData.battleStats[BattleStat.ATK]).toBe(0)); + }, TIMEOUT + ); + + test( + "should not protect the user and allies from single-target moves", + async () => { + game.override.enemyMoveset(Array(4).fill(Moves.TACKLE)); + + await game.startBattle([Species.CHARIZARD, Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerField(); + + game.doAttack(getMovePosition(game.scene, 0, Moves.WIDE_GUARD)); + + await game.phaseInterceptor.to(CommandPhase); + + game.doAttack(getMovePosition(game.scene, 1, Moves.SPLASH)); + + await game.phaseInterceptor.to(BerryPhase, false); + + expect(leadPokemon.some(p => p.hp < p.getMaxHp())).toBeTruthy(); + }, TIMEOUT + ); + + test( + "should protect the user from its ally's multi-target move", + async () => { + game.override.enemyMoveset(Array(4).fill(Moves.SPLASH)); + + await game.startBattle([Species.CHARIZARD, Species.BLASTOISE]); + + const leadPokemon = game.scene.getPlayerField(); + const enemyPokemon = game.scene.getEnemyField(); + + game.doAttack(getMovePosition(game.scene, 0, Moves.WIDE_GUARD)); + + await game.phaseInterceptor.to(CommandPhase); + + game.doAttack(getMovePosition(game.scene, 1, Moves.SURF)); + + await game.phaseInterceptor.to(BerryPhase, false); + + expect(leadPokemon[0].hp).toBe(leadPokemon[0].getMaxHp()); + enemyPokemon.forEach(p => expect(p.hp).toBeLessThan(p.getMaxHp())); + }, TIMEOUT + ); +});