From 01435ed0e0e1fda1a032591ae05bb320e7672cb1 Mon Sep 17 00:00:00 2001 From: innerthunder <168692175+innerthunder@users.noreply.github.com> Date: Sat, 15 Jun 2024 19:14:49 -0700 Subject: [PATCH] [Ability] Implement Unseen Fist (#1776) * Implement Unseen Fist * Add unit tests for Unseen Fist * Fix unit test imports --- src/data/ability.ts | 5 +- src/data/move.ts | 10 ++- src/field/pokemon.ts | 2 +- src/phases.ts | 2 +- src/test/abilities/unseen_fist.test.ts | 91 ++++++++++++++++++++++++++ 5 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 src/test/abilities/unseen_fist.test.ts diff --git a/src/data/ability.ts b/src/data/ability.ts index 4c27e6c6859..3280f81dd6d 100755 --- a/src/data/ability.ts +++ b/src/data/ability.ts @@ -3413,6 +3413,9 @@ export class SuppressFieldAbilitiesAbAttr extends AbAttr { export class AlwaysHitAbAttr extends AbAttr { } +/** Attribute for abilities that allow moves that make contact to ignore protection (i.e. Unseen Fist) */ +export class IgnoreProtectOnContactAbAttr extends AbAttr { } + export class UncopiableAbilityAbAttr extends AbAttr { constructor() { super(false); @@ -4672,7 +4675,7 @@ export function initAbilities() { new Ability(Abilities.QUICK_DRAW, 8) .unimplemented(), new Ability(Abilities.UNSEEN_FIST, 8) - .unimplemented(), + .attr(IgnoreProtectOnContactAbAttr), new Ability(Abilities.CURIOUS_MEDICINE, 8) .attr(PostSummonClearAllyStatsAbAttr), new Ability(Abilities.TRANSISTOR, 8) diff --git a/src/data/move.ts b/src/data/move.ts index b7bfd9343f2..85b93c2a289 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -9,7 +9,7 @@ import { Type } from "./type"; import * as Utils from "../utils"; import { WeatherType } from "./weather"; import { ArenaTagSide, ArenaTrapTag } from "./arena-tag"; -import { UnswappableAbilityAbAttr, UncopiableAbilityAbAttr, UnsuppressableAbilityAbAttr, BlockRecoilDamageAttr, BlockOneHitKOAbAttr, IgnoreContactAbAttr, MaxMultiHitAbAttr, applyAbAttrs, BlockNonDirectDamageAbAttr, applyPreSwitchOutAbAttrs, PreSwitchOutAbAttr, applyPostDefendAbAttrs, PostDefendContactApplyStatusEffectAbAttr, MoveAbilityBypassAbAttr, ReverseDrainAbAttr, FieldPreventExplosiveMovesAbAttr, ForceSwitchOutImmunityAbAttr, BlockItemTheftAbAttr, applyPostAttackAbAttrs, ConfusionOnStatusEffectAbAttr, HealFromBerryUseAbAttr } from "./ability"; +import { UnswappableAbilityAbAttr, UncopiableAbilityAbAttr, UnsuppressableAbilityAbAttr, BlockRecoilDamageAttr, BlockOneHitKOAbAttr, IgnoreContactAbAttr, MaxMultiHitAbAttr, applyAbAttrs, BlockNonDirectDamageAbAttr, applyPreSwitchOutAbAttrs, PreSwitchOutAbAttr, applyPostDefendAbAttrs, PostDefendContactApplyStatusEffectAbAttr, MoveAbilityBypassAbAttr, ReverseDrainAbAttr, FieldPreventExplosiveMovesAbAttr, ForceSwitchOutImmunityAbAttr, BlockItemTheftAbAttr, applyPostAttackAbAttrs, ConfusionOnStatusEffectAbAttr, HealFromBerryUseAbAttr, IgnoreProtectOnContactAbAttr } from "./ability"; import { allAbilities } from "./ability"; import { PokemonHeldItemModifier, BerryModifier, PreserveBerryModifier } from "../modifier/modifier"; import { BattlerIndex } from "../battle"; @@ -559,6 +559,11 @@ export default class Move implements Localizable { return true; } } + case MoveFlags.IGNORE_PROTECT: + if (user.hasAbilityWithAttr(IgnoreProtectOnContactAbAttr) && + this.checkFlag(MoveFlags.MAKES_CONTACT, user, target)) { + return true; + } } return !!(this.flags & flag); @@ -811,7 +816,8 @@ export class MoveEffectAttr extends MoveAttr { */ canApply(user: Pokemon, target: Pokemon, move: Move, args: any[]) { return !! (this.selfTarget ? user.hp && !user.getTag(BattlerTagType.FRENZY) : target.hp) - && (this.selfTarget || !target.getTag(BattlerTagType.PROTECTED) || move.hasFlag(MoveFlags.IGNORE_PROTECT)); + && (this.selfTarget || !target.getTag(BattlerTagType.PROTECTED) || + move.checkFlag(MoveFlags.IGNORE_PROTECT, user, target)); } /** Applies move effects so long as they are able based on {@linkcode canApply} */ diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index c42e8b1b75c..396f3f3e539 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -1729,7 +1729,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { } // Apply arena tags for conditional protection - if (!move.hasFlag(MoveFlags.IGNORE_PROTECT) && !move.isAllyTarget()) { + if (!move.checkFlag(MoveFlags.IGNORE_PROTECT, source, this) && !move.isAllyTarget()) { const defendingSide = this.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; this.scene.arena.applyTagsForSide(ArenaTagType.QUICK_GUARD, defendingSide, cancelled, this, move.priority); this.scene.arena.applyTagsForSide(ArenaTagType.WIDE_GUARD, defendingSide, cancelled, this, move.moveTarget); diff --git a/src/phases.ts b/src/phases.ts index bb72f95a49e..ad5819edf30 100644 --- a/src/phases.ts +++ b/src/phases.ts @@ -2889,7 +2889,7 @@ export class MoveEffectPhase extends PokemonPhase { continue; } - const isProtected = !move.hasFlag(MoveFlags.IGNORE_PROTECT) && target.findTags(t => t instanceof ProtectedTag).find(t => target.lapseTag(t.tagType)); + const isProtected = !this.move.getMove().checkFlag(MoveFlags.IGNORE_PROTECT, user, target) && target.findTags(t => t instanceof ProtectedTag).find(t => target.lapseTag(t.tagType)); const firstHit = moveHistoryEntry.result !== MoveResult.SUCCESS; diff --git a/src/test/abilities/unseen_fist.test.ts b/src/test/abilities/unseen_fist.test.ts new file mode 100644 index 00000000000..a799e203f03 --- /dev/null +++ b/src/test/abilities/unseen_fist.test.ts @@ -0,0 +1,91 @@ +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; +import GameManager from "../utils/gameManager"; +import * as Overrides from "#app/overrides"; +import { Species } from "#enums/species"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { getMovePosition } from "../utils/gameManagerUtils"; +import { TurnEndPhase } from "#app/phases.js"; + +const TIMEOUT = 20 * 1000; + +describe("Abilities - Unseen Fist", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + vi.spyOn(Overrides, "SINGLE_BATTLE_OVERRIDE", "get").mockReturnValue(true); + vi.spyOn(Overrides, "STARTER_SPECIES_OVERRIDE", "get").mockReturnValue(Species.URSHIFU); + vi.spyOn(Overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(Species.SNORLAX); + vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.PROTECT, Moves.PROTECT, Moves.PROTECT, Moves.PROTECT]); + vi.spyOn(Overrides, "STARTING_LEVEL_OVERRIDE", "get").mockReturnValue(100); + }); + + test( + "ability causes a contact move to ignore Protect", + () => testUnseenFistHitResult(game, Moves.QUICK_ATTACK, Moves.PROTECT, true), + TIMEOUT + ); + + test( + "ability does not cause a non-contact move to ignore Protect", + () => testUnseenFistHitResult(game, Moves.ABSORB, Moves.PROTECT, false), + TIMEOUT + ); + + test( + "ability does not apply if the source has Long Reach", + () => { + vi.spyOn(Overrides, "PASSIVE_ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.LONG_REACH); + testUnseenFistHitResult(game, Moves.QUICK_ATTACK, Moves.PROTECT, false); + }, TIMEOUT + ); + + test( + "ability causes a contact move to ignore Wide Guard", + () => testUnseenFistHitResult(game, Moves.BREAKING_SWIPE, Moves.WIDE_GUARD, true), + TIMEOUT + ); + + test( + "ability does not cause a non-contact move to ignore Wide Guard", + () => testUnseenFistHitResult(game, Moves.BULLDOZE, Moves.WIDE_GUARD, false), + TIMEOUT + ); +}); + +async function testUnseenFistHitResult(game: GameManager, attackMove: Moves, protectMove: Moves, shouldSucceed: boolean = true): Promise { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([attackMove]); + vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([protectMove, protectMove, protectMove, protectMove]); + + await game.startBattle(); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + const enemyPokemon = game.scene.getEnemyPokemon(); + expect(enemyPokemon).not.toBe(undefined); + + const enemyStartingHp = enemyPokemon.hp; + + game.doAttack(getMovePosition(game.scene, 0, attackMove)); + await game.phaseInterceptor.to(TurnEndPhase); + + if (shouldSucceed) { + expect(enemyPokemon.hp).toBeLessThan(enemyStartingHp); + } else { + expect(enemyPokemon.hp).toBe(enemyStartingHp); + } +}