diff --git a/src/data/ability.ts b/src/data/ability.ts index 2b24fc5d09e1..9e9c423623d8 100644 --- a/src/data/ability.ts +++ b/src/data/ability.ts @@ -366,6 +366,10 @@ export class TypeImmunityAbAttr extends PreDefendAbAttr { return false; } + getImmuneType(): Type | null { + return this.immuneType; + } + override getCondition(): AbAttrCondition | null { return this.condition; } diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index c3199166e840..263a576c4f08 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -1,6 +1,6 @@ import BattleScene from "#app/battle-scene"; import { BattlerIndex } from "#app/battle"; -import { applyPreAttackAbAttrs, AddSecondStrikeAbAttr, IgnoreMoveEffectsAbAttr, applyPostDefendAbAttrs, PostDefendAbAttr, applyPostAttackAbAttrs, PostAttackAbAttr, MaxMultiHitAbAttr, AlwaysHitAbAttr } from "#app/data/ability"; +import { applyPreAttackAbAttrs, AddSecondStrikeAbAttr, IgnoreMoveEffectsAbAttr, applyPostDefendAbAttrs, PostDefendAbAttr, applyPostAttackAbAttrs, PostAttackAbAttr, MaxMultiHitAbAttr, AlwaysHitAbAttr, TypeImmunityAbAttr } from "#app/data/ability"; import { ArenaTagSide, ConditionalProtectTag } from "#app/data/arena-tag"; import { MoveAnim } from "#app/data/battle-anims"; import { BattlerTagLapseType, DamageProtectedTag, ProtectedTag, SemiInvulnerableTag, SubstituteTag } from "#app/data/battler-tags"; @@ -97,12 +97,17 @@ export class MoveEffectPhase extends PokemonPhase { */ const targetHitChecks = Object.fromEntries(targets.map(p => [p.getBattlerIndex(), this.hitCheck(p)])); const hasActiveTargets = targets.some(t => t.isActive(true)); + + /** Check if the target is immune via ability to the attacking move */ + const isImmune = targets[0].hasAbilityWithAttr(TypeImmunityAbAttr) && (targets[0].getAbility()?.getAttrs(TypeImmunityAbAttr)?.[0]?.getImmuneType() === user.getMoveType(move)); + /** * If no targets are left for the move to hit (FAIL), or the invoked move is single-target * (and not random target) and failed the hit check against its target (MISS), log the move * as FAILed or MISSed (depending on the conditions above) and end this phase. */ - if (!hasActiveTargets || (!move.hasAttr(VariableTargetAttr) && !move.isMultiTarget() && !targetHitChecks[this.targets[0]] && !targets[0].getTag(ProtectedTag))) { + + if (!hasActiveTargets || (!move.hasAttr(VariableTargetAttr) && !move.isMultiTarget() && !targetHitChecks[this.targets[0]] && !targets[0].getTag(ProtectedTag) && !isImmune)) { this.stopMultiHit(); if (hasActiveTargets) { this.scene.queueMessage(i18next.t("battle:attackMissed", { pokemonNameWithAffix: this.getTarget()? getPokemonNameWithAffix(this.getTarget()!) : "" })); @@ -132,7 +137,7 @@ export class MoveEffectPhase extends PokemonPhase { 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 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); } @@ -142,11 +147,14 @@ export class MoveEffectPhase extends PokemonPhase { && (hasConditionalProtectApplied.value || (!target.findTags(t => t instanceof DamageProtectedTag).length && target.findTags(t => t instanceof ProtectedTag).find(t => target.lapseTag(t.tagType))) || (this.move.getMove().category !== MoveCategory.STATUS && target.findTags(t => t instanceof DamageProtectedTag).find(t => target.lapseTag(t.tagType)))); + /** Is the pokemon immune due to an ablility? */ + const isImmune = target.hasAbilityWithAttr(TypeImmunityAbAttr) && (target.getAbility()?.getAttrs(TypeImmunityAbAttr)?.[0]?.getImmuneType() === user.getMoveType(move)); + /** * If the move missed a target, stop all future hits against that target * and move on to the next target (if there is one). */ - if (!isProtected && !targetHitChecks[target.getBattlerIndex()]) { + if (!isImmune && !isProtected && !targetHitChecks[target.getBattlerIndex()]) { this.stopMultiHit(target); this.scene.queueMessage(i18next.t("battle:attackMissed", { pokemonNameWithAffix: getPokemonNameWithAffix(target) })); if (moveHistoryEntry.result === MoveResult.PENDING) { diff --git a/src/test/abilities/dry_skin.test.ts b/src/test/abilities/dry_skin.test.ts index 1af8831f25be..a97914660bc2 100644 --- a/src/test/abilities/dry_skin.test.ts +++ b/src/test/abilities/dry_skin.test.ts @@ -141,4 +141,18 @@ describe("Abilities - Dry Skin", () => { expect(healthGainedFromWaterShuriken).toBe(healthGainedFromWaterGun); }); + + it("opposing water moves still heal regardless of accuracy check", async () => { + await game.classicMode.startBattle(); + + const enemy = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.WATER_GUN); + enemy.hp = enemy.hp - 1; + await game.phaseInterceptor.to("MoveEffectPhase"); + + await game.move.forceMiss(); + await game.phaseInterceptor.to("BerryPhase", false); + expect(enemy.hp).toBe(enemy.getMaxHp()); + }); }); diff --git a/src/test/abilities/flash_fire.test.ts b/src/test/abilities/flash_fire.test.ts index c3cf31496ea4..9c78de995759 100644 --- a/src/test/abilities/flash_fire.test.ts +++ b/src/test/abilities/flash_fire.test.ts @@ -38,7 +38,7 @@ describe("Abilities - Flash Fire", () => { it("immune to Fire-type moves", async () => { game.override.enemyMoveset([Moves.EMBER]).moveset(Moves.SPLASH); - await game.startBattle([Species.BLISSEY]); + await game.classicMode.startBattle([Species.BLISSEY]); const blissey = game.scene.getPlayerPokemon()!; @@ -49,7 +49,7 @@ describe("Abilities - Flash Fire", () => { it("not activate if the Pokémon is protected from the Fire-type move", async () => { game.override.enemyMoveset([Moves.EMBER]).moveset([Moves.PROTECT]); - await game.startBattle([Species.BLISSEY]); + await game.classicMode.startBattle([Species.BLISSEY]); const blissey = game.scene.getPlayerPokemon()!; @@ -60,7 +60,7 @@ describe("Abilities - Flash Fire", () => { it("activated by Will-O-Wisp", async () => { game.override.enemyMoveset([Moves.WILL_O_WISP]).moveset(Moves.SPLASH); - await game.startBattle([Species.BLISSEY]); + await game.classicMode.startBattle([Species.BLISSEY]); const blissey = game.scene.getPlayerPokemon()!; @@ -76,7 +76,7 @@ describe("Abilities - Flash Fire", () => { it("activated after being frozen", async () => { game.override.enemyMoveset([Moves.EMBER]).moveset(Moves.SPLASH); game.override.statusEffect(StatusEffect.FREEZE); - await game.startBattle([Species.BLISSEY]); + await game.classicMode.startBattle([Species.BLISSEY]); const blissey = game.scene.getPlayerPokemon()!; @@ -88,7 +88,7 @@ describe("Abilities - Flash Fire", () => { it("not passing with baton pass", async () => { game.override.enemyMoveset([Moves.EMBER]).moveset([Moves.BATON_PASS]); - await game.startBattle([Species.BLISSEY, Species.CHANSEY]); + await game.classicMode.startBattle([Species.BLISSEY, Species.CHANSEY]); // ensure use baton pass after enemy moved game.move.select(Moves.BATON_PASS); @@ -105,7 +105,7 @@ describe("Abilities - Flash Fire", () => { it("boosts Fire-type move when the ability is activated", async () => { game.override.enemyMoveset([Moves.FIRE_PLEDGE]).moveset([Moves.EMBER, Moves.SPLASH]); game.override.enemyAbility(Abilities.FLASH_FIRE).ability(Abilities.NONE); - await game.startBattle([Species.BLISSEY]); + await game.classicMode.startBattle([Species.BLISSEY]); const blissey = game.scene.getPlayerPokemon()!; const initialHP = 1000; blissey.hp = initialHP; @@ -126,4 +126,33 @@ describe("Abilities - Flash Fire", () => { expect(flashFireDmg).toBeGreaterThan(originalDmg); }, 20000); + + it("still activates regardless of accuracy check", async () => { + game.override.moveset(Moves.FIRE_PLEDGE).enemyMoveset(Moves.EMBER); + game.override.enemyAbility(Abilities.NONE).ability(Abilities.FLASH_FIRE); + game.override.enemySpecies(Species.BLISSEY); + await game.classicMode.startBattle([Species.RATTATA]); + + const blissey = game.scene.getEnemyPokemon()!; + const initialHP = 1000; + blissey.hp = initialHP; + + // first turn + game.move.select(Moves.FIRE_PLEDGE); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.phaseInterceptor.to("MoveEffectPhase"); + await game.move.forceMiss(); + await game.phaseInterceptor.to(TurnEndPhase); + const originalDmg = initialHP - blissey.hp; + + expect(blissey.hp > 0); + blissey.hp = initialHP; + + // second turn + game.move.select(Moves.FIRE_PLEDGE); + await game.phaseInterceptor.to(TurnEndPhase); + const flashFireDmg = initialHP - blissey.hp; + + expect(flashFireDmg).toBeGreaterThan(originalDmg); + }, 20000); }); diff --git a/src/test/abilities/sap_sipper.test.ts b/src/test/abilities/sap_sipper.test.ts index 5e8cac74c952..b73bc3d9e27f 100644 --- a/src/test/abilities/sap_sipper.test.ts +++ b/src/test/abilities/sap_sipper.test.ts @@ -165,4 +165,22 @@ describe("Abilities - Sap Sipper", () => { expect(initialEnemyHp - enemyPokemon.hp).toBe(0); expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(1); }); + + it("still activates regardless of accuracy check", async () => { + game.override.moveset(Moves.LEAF_BLADE); + game.override.enemyMoveset(Moves.SPLASH); + game.override.enemySpecies(Species.MAGIKARP); + game.override.enemyAbility(Abilities.SAP_SIPPER); + + await game.classicMode.startBattle(); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.LEAF_BLADE); + await game.phaseInterceptor.to("MoveEffectPhase"); + + await game.move.forceMiss(); + await game.phaseInterceptor.to("BerryPhase", false); + expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(1); + }); }); diff --git a/src/test/abilities/volt_absorb.test.ts b/src/test/abilities/volt_absorb.test.ts index 7f3e160c7d01..9bd5de7df574 100644 --- a/src/test/abilities/volt_absorb.test.ts +++ b/src/test/abilities/volt_absorb.test.ts @@ -7,6 +7,7 @@ import { Species } from "#enums/species"; import GameManager from "#test/utils/gameManager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { BattlerIndex } from "#app/battle"; // See also: TypeImmunityAbAttr describe("Abilities - Volt Absorb", () => { @@ -39,7 +40,7 @@ describe("Abilities - Volt Absorb", () => { game.override.enemySpecies(Species.DUSKULL); game.override.enemyAbility(Abilities.BALL_FETCH); - await game.startBattle(); + await game.classicMode.startBattle(); const playerPokemon = game.scene.getPlayerPokemon()!; @@ -51,4 +52,23 @@ describe("Abilities - Volt Absorb", () => { expect(playerPokemon.getTag(BattlerTagType.CHARGED)).toBeDefined(); expect(game.phaseInterceptor.log).not.toContain("ShowAbilityPhase"); }); + it("should activate regardless of accuracy checks", async () => { + game.override.moveset(Moves.THUNDERBOLT); + game.override.enemyMoveset(Moves.SPLASH); + game.override.enemySpecies(Species.MAGIKARP); + game.override.enemyAbility(Abilities.VOLT_ABSORB); + + await game.classicMode.startBattle(); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.THUNDERBOLT); + enemyPokemon.hp = enemyPokemon.hp - 1; + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.phaseInterceptor.to("MoveEffectPhase"); + + await game.move.forceMiss(); + await game.phaseInterceptor.to("BerryPhase", false); + expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp()); + }); });