From 914b41aaf71ef6940d88de046a50614458bdcd7d Mon Sep 17 00:00:00 2001 From: Zach Day Date: Sun, 16 Jun 2024 01:28:13 -0400 Subject: [PATCH] Squashed commit of the following: Merge branch 'main' into damp_test Cleanup tests Add tests for interaction with Aftermath Prevent ability popup when suppressing Aftermath damage Adds test for damp visual functionality Fix missing import Prevent explosion effect and animation with Damp Split the PostFaintContactDamage portion of Damp into its own AbAttr Standardize Damp effect via MoveAttr --- src/data/ability.ts | 58 +++++++++++++--- src/data/move.ts | 32 +++++---- src/field/pokemon.ts | 9 ++- src/phases.ts | 11 ++- src/test/abilities/aftermath.test.ts | 80 +++++++++++++++++++++ src/test/abilities/damp.test.ts | 100 +++++++++++++++++++++++++++ 6 files changed, 262 insertions(+), 28 deletions(-) create mode 100644 src/test/abilities/aftermath.test.ts create mode 100644 src/test/abilities/damp.test.ts diff --git a/src/data/ability.ts b/src/data/ability.ts index cb3db27ddc1..015e3cfea31 100644 --- a/src/data/ability.ts +++ b/src/data/ability.ts @@ -1115,13 +1115,6 @@ export class VariableMovePowerAbAttr extends PreAttackAbAttr { } } -export class FieldPreventExplosiveMovesAbAttr extends AbAttr { - apply(pokemon: Pokemon, passive: boolean, cancelled: Utils.BooleanHolder, args: any[]): boolean | Promise { - cancelled.value = true; - return true; - } -} - /** * Multiplies a BattleStat if the checked Pokemon lacks this ability. * If this ability cannot stack, a BooleanHolder can be used to prevent this from stacking. @@ -3639,10 +3632,11 @@ export class PostFaintContactDamageAbAttr extends PostFaintAbAttr { applyPostFaint(pokemon: Pokemon, passive: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean { if (move.checkFlag(MoveFlags.MAKES_CONTACT, attacker, pokemon)) { const cancelled = new Utils.BooleanHolder(false); - pokemon.scene.getField(true).map(p=>applyAbAttrs(FieldPreventExplosiveMovesAbAttr, p, cancelled)); + pokemon.scene.getField(true).forEach(p => applyAbAttrs(PreventPostFaintContactDamageAbAttr, p, cancelled)); if (cancelled.value || attacker.hasAbilityWithAttr(BlockNonDirectDamageAbAttr)) { return false; } + attacker.damageAndUpdate(Math.ceil(attacker.getMaxHp() * (1 / this.damageRatio)), HitResult.OTHER); attacker.turnData.damageTaken += Math.ceil(attacker.getMaxHp() * (1 / this.damageRatio)); return true; @@ -3656,6 +3650,19 @@ export class PostFaintContactDamageAbAttr extends PostFaintAbAttr { } } +/** + * Prevents the effects of another Pokemon's {@link PostFaintContactDamageAbAttr} ability. + * {@linkcode apply} always returns true. + */ +export class PreventPostFaintContactDamageAbAttr extends AbAttr { + showAbility: boolean = false; + + apply(pokemon: Pokemon, passive: boolean, cancelled: Utils.BooleanHolder, args: any[]): boolean { + cancelled.value = true; + return true; + } +} + /** * Attribute used for abilities (Innards Out) that damage the opponent based on how much HP the last attack used to knock out the owner of the ability. */ @@ -3711,6 +3718,36 @@ export class RedirectTypeMoveAbAttr extends RedirectMoveAbAttr { export class BlockRedirectAbAttr extends AbAttr { } +/** + * Prevents other Pokemon from using moves that match the given {@linkcode moveCondition}. + * + * @param args [0] {@linkcode Move} The move being checked + * @param args [1] {@linkcode Pokemon} The user of the attack + */ +export class FieldPreventMovesAbAttr extends AbAttr { + public moveCondition: (Moves) => boolean; + + constructor(moveCondition: (Moves) => boolean) { + super(); + this.moveCondition = moveCondition; + } + + /** @param args See {@linkcode FieldPreventMovesAbAttr}. */ + apply(pokemon: Pokemon, passive: boolean, cancelled: Utils.BooleanHolder, args: any[]): boolean { + if (this.moveCondition((args[0] as Move).id)) { + cancelled.value = true; + return true; + } + + return false; + } + + /** @param args See {@linkcode FieldPreventMovesAbAttr}. */ + getTriggerMessage(pokemon: Pokemon, abilityName: string, ...args: any[]): string { + return (getPokemonMessage(args[1] as Pokemon, ` cannot use ${(args[0] as Move).name}`)); + } +} + export class ReduceStatusEffectDurationAbAttr extends AbAttr { private statusEffect: StatusEffect; @@ -4137,7 +4174,7 @@ async function applyAbAttrsInternal( } if (!quiet) { - const message = attr.getTriggerMessage(pokemon, ability.name, args); + const message = attr.getTriggerMessage(pokemon, (!passive ? pokemon.getAbility() : pokemon.getPassiveAbility()).name, args); if (message) { pokemon.scene.queueMessage(message); } @@ -4320,7 +4357,8 @@ export function initAbilities() { .attr(BlockOneHitKOAbAttr) .ignorable(), new Ability(Abilities.DAMP, 3) - .attr(FieldPreventExplosiveMovesAbAttr) + .attr(FieldPreventMovesAbAttr, (move) => [Moves.EXPLOSION, Moves.SELF_DESTRUCT, Moves.MIND_BLOWN, Moves.MISTY_EXPLOSION].includes(move)) + .attr(PreventPostFaintContactDamageAbAttr) .ignorable(), new Ability(Abilities.LIMBER, 3) .attr(StatusEffectImmunityAbAttr, StatusEffect.PARALYSIS) diff --git a/src/data/move.ts b/src/data/move.ts index 0b0af00a370..bf54e5b0128 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -10,7 +10,7 @@ import { Constructor } from "#app/utils"; import * as Utils from "../utils"; import { WeatherType } from "./weather"; import { ArenaTagSide, ArenaTrapTag, WeakenMoveTypeTag } from "./arena-tag"; -import { UnswappableAbilityAbAttr, UncopiableAbilityAbAttr, UnsuppressableAbilityAbAttr, BlockRecoilDamageAttr, BlockOneHitKOAbAttr, IgnoreContactAbAttr, MaxMultiHitAbAttr, applyAbAttrs, BlockNonDirectDamageAbAttr, MoveAbilityBypassAbAttr, ReverseDrainAbAttr, FieldPreventExplosiveMovesAbAttr, ForceSwitchOutImmunityAbAttr, BlockItemTheftAbAttr, applyPostAttackAbAttrs, ConfusionOnStatusEffectAbAttr, HealFromBerryUseAbAttr, IgnoreProtectOnContactAbAttr, IgnoreMoveEffectsAbAttr, applyPreDefendAbAttrs, MoveEffectChanceMultiplierAbAttr, WonderSkinAbAttr, applyPreAttackAbAttrs, MoveTypeChangeAttr, UserFieldMoveTypePowerBoostAbAttr, FieldMoveTypePowerBoostAbAttr, AllyMoveCategoryPowerBoostAbAttr, VariableMovePowerAbAttr } from "./ability"; +import { UnswappableAbilityAbAttr, UncopiableAbilityAbAttr, UnsuppressableAbilityAbAttr, BlockRecoilDamageAttr, BlockOneHitKOAbAttr, IgnoreContactAbAttr, MaxMultiHitAbAttr, applyAbAttrs, BlockNonDirectDamageAbAttr, MoveAbilityBypassAbAttr, ReverseDrainAbAttr, BlockItemTheftAbAttr, applyPostAttackAbAttrs, ConfusionOnStatusEffectAbAttr, HealFromBerryUseAbAttr, IgnoreProtectOnContactAbAttr, ForceSwitchOutImmunityAbAttr, IgnoreMoveEffectsAbAttr, applyPreDefendAbAttrs, MoveEffectChanceMultiplierAbAttr, WonderSkinAbAttr, applyPreAttackAbAttrs, MoveTypeChangeAttr, UserFieldMoveTypePowerBoostAbAttr, FieldMoveTypePowerBoostAbAttr, AllyMoveCategoryPowerBoostAbAttr, VariableMovePowerAbAttr } from "./ability"; import { allAbilities } from "./ability"; import { PokemonHeldItemModifier, BerryModifier, PreserveBerryModifier, PokemonMoveAccuracyBoosterModifier, AttackTypeBoosterModifier, PokemonMultiHitModifier } from "../modifier/modifier"; import { BattlerIndex, BattleType } from "../battle"; @@ -96,6 +96,7 @@ export enum MoveFlags { * Indicates a move is able to be redirected to allies in a double battle if the attacker faints */ REDIRECT_COUNTER = 1 << 18, + EXPLOSIVE_MOVE = 1 << 19 } type MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => boolean; @@ -520,6 +521,17 @@ export default class Move implements Localizable { return this; } + /** + * Sets the {@linkcode MoveFlags.EXPLOSIVE_MOVE} flag for the calling Move + * @param explosiveMove The value (boolean) to set the flag to + * example: @see {@linkcode Moves.EXPLOSION} + * @returns The {@linkcode Move} that called this function + */ + explosiveMove(explosiveMove?: boolean): this { + this.setFlag(MoveFlags.EXPLOSIVE_MOVE, explosiveMove); + return this; + } + /** * Sets the {@linkcode MoveFlags.TRIAGE_MOVE} flag for the calling Move * @param triageMove The value (boolean) to set the flag to @@ -5916,16 +5928,6 @@ const failOnBossCondition: MoveConditionFunc = (user, target, move) => !target.i const failOnMaxCondition: MoveConditionFunc = (user, target, move) => !target.isMax(); -const failIfDampCondition: MoveConditionFunc = (user, target, move) => { - const cancelled = new Utils.BooleanHolder(false); - user.scene.getField(true).map(p=>applyAbAttrs(FieldPreventExplosiveMovesAbAttr, p, cancelled)); - // Queue a message if an ability prevented usage of the move - if (cancelled.value) { - user.scene.queueMessage(i18next.t("moveTriggers:cannotUseMove", {pokemonName: getPokemonNameWithAffix(user), moveName: move.name})); - } - return !cancelled.value; -}; - const userSleptOrComatoseCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => user.status?.effect === StatusEffect.SLEEP || user.hasAbility(Abilities.COMATOSE); const targetSleptOrComatoseCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => target.status?.effect === StatusEffect.SLEEP || target.hasAbility(Abilities.COMATOSE); @@ -6470,7 +6472,7 @@ export function initMoves() { new AttackMove(Moves.SELF_DESTRUCT, Type.NORMAL, MoveCategory.PHYSICAL, 200, 100, 5, -1, 0, 1) .attr(SacrificialAttr) .makesContact(false) - .condition(failIfDampCondition) + .explosiveMove() .target(MoveTarget.ALL_NEAR_OTHERS), new AttackMove(Moves.EGG_BOMB, Type.NORMAL, MoveCategory.PHYSICAL, 100, 75, 10, -1, 0, 1) .makesContact(false) @@ -6560,9 +6562,9 @@ export function initMoves() { new AttackMove(Moves.CRABHAMMER, Type.WATER, MoveCategory.PHYSICAL, 100, 90, 10, -1, 0, 1) .attr(HighCritAttr), new AttackMove(Moves.EXPLOSION, Type.NORMAL, MoveCategory.PHYSICAL, 250, 100, 5, -1, 0, 1) - .condition(failIfDampCondition) .attr(SacrificialAttr) .makesContact(false) + .explosiveMove() .target(MoveTarget.ALL_NEAR_OTHERS), new AttackMove(Moves.FURY_SWIPES, Type.NORMAL, MoveCategory.PHYSICAL, 18, 80, 15, -1, 0, 1) .attr(MultiHitAttr), @@ -8184,8 +8186,8 @@ export function initMoves() { .ignoresVirtual(), /* End Unused */ new AttackMove(Moves.MIND_BLOWN, Type.FIRE, MoveCategory.SPECIAL, 150, 100, 5, -1, 0, 7) - .condition(failIfDampCondition) .attr(HalfSacrificialAttr) + .explosiveMove() .target(MoveTarget.ALL_NEAR_OTHERS), new AttackMove(Moves.PLASMA_FISTS, Type.ELECTRIC, MoveCategory.PHYSICAL, 100, 100, 15, -1, 0, 7) .punchingMove() @@ -8466,7 +8468,7 @@ export function initMoves() { .attr(SacrificialAttr) .target(MoveTarget.ALL_NEAR_OTHERS) .attr(MovePowerMultiplierAttr, (user, target, move) => user.scene.arena.getTerrainType() === TerrainType.MISTY && user.isGrounded() ? 1.5 : 1) - .condition(failIfDampCondition) + .explosiveMove() .makesContact(false), new AttackMove(Moves.GRASSY_GLIDE, Type.GRASS, MoveCategory.PHYSICAL, 55, 100, 20, -1, 0, 8) .attr(IncrementMovePriorityAttr,(user,target,move) =>user.scene.arena.getTerrainType()===TerrainType.GRASSY&&user.isGrounded()), diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 588b6839d70..9c52e250a3d 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -22,8 +22,8 @@ import { BattleStat } from "../data/battle-stat"; import { BattlerTag, BattlerTagLapseType, EncoreTag, GroundedTag, HighestStatBoostTag, TypeImmuneTag, getBattlerTag, SemiInvulnerableTag, TypeBoostTag, ExposedTag } from "../data/battler-tags"; import { WeatherType } from "../data/weather"; import { TempBattleStat } from "../data/temp-battle-stat"; -import { ArenaTagSide, NoCritTag, WeakenMoveScreenTag } from "../data/arena-tag"; -import { Ability, AbAttr, BattleStatMultiplierAbAttr, BlockCritAbAttr, BonusCritAbAttr, BypassBurnDamageReductionAbAttr, FieldPriorityMoveImmunityAbAttr, IgnoreOpponentStatChangesAbAttr, MoveImmunityAbAttr, PreDefendFullHpEndureAbAttr, ReceivedMoveDamageMultiplierAbAttr, ReduceStatusEffectDurationAbAttr, StabBoostAbAttr, StatusEffectImmunityAbAttr, TypeImmunityAbAttr, WeightMultiplierAbAttr, allAbilities, applyAbAttrs, applyBattleStatMultiplierAbAttrs, applyPreApplyBattlerTagAbAttrs, applyPreAttackAbAttrs, applyPreDefendAbAttrs, applyPreSetStatusAbAttrs, UnsuppressableAbilityAbAttr, SuppressFieldAbilitiesAbAttr, NoFusionAbilityAbAttr, MultCritAbAttr, IgnoreTypeImmunityAbAttr, DamageBoostAbAttr, IgnoreTypeStatusEffectImmunityAbAttr, ConditionalCritAbAttr, applyFieldBattleStatMultiplierAbAttrs, FieldMultiplyBattleStatAbAttr, AddSecondStrikeAbAttr, IgnoreOpponentEvasionAbAttr, UserFieldStatusEffectImmunityAbAttr, UserFieldBattlerTagImmunityAbAttr, BattlerTagImmunityAbAttr } from "../data/ability"; +import { ArenaTagSide, WeakenMoveScreenTag, WeakenMoveTypeTag } from "../data/arena-tag"; +import { Ability, AbAttr, BattleStatMultiplierAbAttr, BlockCritAbAttr, BonusCritAbAttr, BypassBurnDamageReductionAbAttr, FieldPriorityMoveImmunityAbAttr, FieldVariableMovePowerAbAttr, IgnoreOpponentStatChangesAbAttr, MoveImmunityAbAttr, MoveTypeChangeAttr, PreApplyBattlerTagAbAttr, PreDefendFullHpEndureAbAttr, ReceivedMoveDamageMultiplierAbAttr, ReduceStatusEffectDurationAbAttr, StabBoostAbAttr, StatusEffectImmunityAbAttr, TypeImmunityAbAttr, VariableMovePowerAbAttr, WeightMultiplierAbAttr, allAbilities, applyAbAttrs, applyBattleStatMultiplierAbAttrs, applyPreApplyBattlerTagAbAttrs, applyPreAttackAbAttrs, applyPreDefendAbAttrs, applyPreSetStatusAbAttrs, UnsuppressableAbilityAbAttr, SuppressFieldAbilitiesAbAttr, NoFusionAbilityAbAttr, MultCritAbAttr, IgnoreTypeImmunityAbAttr, DamageBoostAbAttr, IgnoreTypeStatusEffectImmunityAbAttr, ConditionalCritAbAttr, applyFieldBattleStatMultiplierAbAttrs, FieldMultiplyBattleStatAbAttr, FieldPreventMovesAbAttr } from "../data/ability"; import PokemonData from "../system/pokemon-data"; import { BattlerIndex } from "../battle"; import { Mode } from "../ui/ui"; @@ -1942,6 +1942,11 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { typeMultiplier.value = 0; } + // Check if other Pokemon on the field have an ability that prevents this move + for (const other of this.scene.getField(true).filter(p => source.id !== p.id)) { + applyAbAttrs(FieldPreventMovesAbAttr, other, cancelled, move, source); + } + // 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); diff --git a/src/phases.ts b/src/phases.ts index 73413b248a4..ddd57018c42 100644 --- a/src/phases.ts +++ b/src/phases.ts @@ -25,7 +25,7 @@ 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 { 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, IncrementMovePriorityAbAttr, applyPostVictoryAbAttrs, PostVictoryAbAttr, BlockNonDirectDamageAbAttr as BlockNonDirectDamageAbAttr, applyPostKnockOutAbAttrs, PostKnockOutAbAttr, PostBiomeChangeAbAttr, applyPostFaintAbAttrs, PostFaintAbAttr, IncreasePpAbAttr, PostStatChangeAbAttr, applyPostStatChangeAbAttrs, AlwaysHitAbAttr, PreventBerryUseAbAttr, StatChangeCopyAbAttr, PokemonTypeChangeAbAttr, applyPreAttackAbAttrs, applyPostMoveUsedAbAttrs, PostMoveUsedAbAttr, MaxMultiHitAbAttr, HealFromBerryUseAbAttr, IgnoreMoveEffectsAbAttr, BlockStatusDamageAbAttr, BypassSpeedChanceAbAttr, AddSecondStrikeAbAttr } from "./data/ability"; +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, IncrementMovePriorityAbAttr, applyPostVictoryAbAttrs, PostVictoryAbAttr, BlockNonDirectDamageAbAttr as BlockNonDirectDamageAbAttr, applyPostKnockOutAbAttrs, PostKnockOutAbAttr, PostBiomeChangeAbAttr, applyPostFaintAbAttrs, PostFaintAbAttr, IncreasePpAbAttr, PostStatChangeAbAttr, applyPostStatChangeAbAttrs, AlwaysHitAbAttr, PreventBerryUseAbAttr, StatChangeCopyAbAttr, PokemonTypeChangeAbAttr, applyPreAttackAbAttrs, applyPostMoveUsedAbAttrs, PostMoveUsedAbAttr, MaxMultiHitAbAttr, HealFromBerryUseAbAttr, IgnoreMoveEffectsAbAttr, BlockStatusDamageAbAttr, BypassSpeedChanceAbAttr, AddSecondStrikeAbAttr, FieldPreventMovesAbAttr } from "./data/ability"; import { Unlockables, getUnlockableName } from "./system/unlockables"; import { getBiomeKey } from "./field/arena"; import { BattleType, BattlerIndex, TurnCommand } from "./battle"; @@ -3048,6 +3048,15 @@ export class MoveEffectPhase extends PokemonPhase { return this.end(); } + // Check if other Pokemon on the field have an ability that prevents this move + const prevented = new Utils.BooleanHolder(false); + for (const other of this.scene.getField(true).filter(p => user.id !== p.id)) { + applyAbAttrs(FieldPreventMovesAbAttr, other, prevented, move, user as Pokemon); + } + if (prevented.value) { + return this.end(); + } + /** All move effect attributes are chained together in this array to be applied asynchronously. */ const applyAttrs: Promise[] = []; diff --git a/src/test/abilities/aftermath.test.ts b/src/test/abilities/aftermath.test.ts new file mode 100644 index 00000000000..db857e87c95 --- /dev/null +++ b/src/test/abilities/aftermath.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import GameManager from "#test/utils/gameManager"; +import { getMovePosition } from "#test/utils/gameManagerUtils"; +import * as overrides from "#app/overrides"; +import { Moves } from "#enums/moves"; +import { Abilities } from "#enums/abilities"; +import { Species } from "#enums/species"; +import { TurnEndPhase } from "#app/phases.js"; + +const TIMEOUT = 20 * 1000; + +describe("Abilities - Aftermath", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + const moveToUse = Moves.SPLASH; + vi.spyOn(overrides, "SINGLE_BATTLE_OVERRIDE", "get").mockReturnValue(true); + vi.spyOn(overrides, "ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.BATTLE_BOND); + vi.spyOn(overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([moveToUse]); + vi.spyOn(overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.TACKLE, Moves.TACKLE, Moves.TACKLE, Moves.TACKLE]); + }); + + it("deals 25% of attacker's HP as damage to attacker when defeated by contact move", async () => { + const moveToUse = Moves.TACKLE; + const enemyAbility = Abilities.AFTERMATH; + + vi.spyOn(overrides, "ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.NONE); + vi.spyOn(overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([moveToUse]); + vi.spyOn(overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SPLASH, Moves.NONE, Moves.NONE, Moves.NONE]); + vi.spyOn(overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(Species.BIDOOF); + vi.spyOn(overrides, "OPP_ABILITY_OVERRIDE", "get").mockReturnValue(enemyAbility); + + await game.startBattle(); + + game.scene.getEnemyParty()[0].hp = 1; + + game.doAttack(getMovePosition(game.scene, 0, moveToUse)); + + await game.phaseInterceptor.to(TurnEndPhase); + + expect(game.phaseInterceptor.log).toContain("FaintPhase"); + expect(game.phaseInterceptor.log).toContain("ShowAbilityPhase"); + expect(game.scene.getParty()[0].hp).toBeCloseTo(Math.floor(game.scene.getParty()[0].getMaxHp() * 0.75)); + }, TIMEOUT); + + it("does not activate on non-contact moves", async () => { + const moveToUse = Moves.WATER_GUN; + const enemyAbility = Abilities.AFTERMATH; + + vi.spyOn(overrides, "ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.NONE); + vi.spyOn(overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([moveToUse]); + vi.spyOn(overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SPLASH, Moves.NONE, Moves.NONE, Moves.NONE]); + vi.spyOn(overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(Species.BIDOOF); + vi.spyOn(overrides, "OPP_ABILITY_OVERRIDE", "get").mockReturnValue(enemyAbility); + + await game.startBattle(); + + game.scene.getEnemyParty()[0].hp = 1; + + game.doAttack(getMovePosition(game.scene, 0, moveToUse)); + + await game.phaseInterceptor.to(TurnEndPhase); + + expect(game.phaseInterceptor.log).toContain("FaintPhase"); + expect(game.phaseInterceptor.log).not.toContain("ShowAbilityPhase"); + expect(game.scene.getParty()[0].getHpRatio()).toBeCloseTo(1); + }, TIMEOUT); +}); diff --git a/src/test/abilities/damp.test.ts b/src/test/abilities/damp.test.ts new file mode 100644 index 00000000000..71c4c7b6315 --- /dev/null +++ b/src/test/abilities/damp.test.ts @@ -0,0 +1,100 @@ +import { Abilities } from "#app/enums/abilities.js"; +import { Moves } from "#app/enums/moves"; +import { Species } from "#app/enums/species"; +import * as overrides from "#app/overrides"; +import { TurnEndPhase } from "#app/phases"; +import GameManager from "#app/test/utils/gameManager"; +import { getMovePosition } from "#app/test/utils/gameManagerUtils"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const TIMEOUT = 20 * 1000; + +describe("Abilities - Damp", () => { + 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, "NEVER_CRIT_OVERRIDE", "get").mockReturnValue(true); + }); + + it("prevents self-destruction effect on explosive attacks", async() => { + const moveToUse = Moves.EXPLOSION; + const enemyAbility = Abilities.DAMP; + + vi.spyOn(overrides, "ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.NONE); + vi.spyOn(overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([moveToUse]); + vi.spyOn(overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SPLASH, Moves.NONE, Moves.NONE, Moves.NONE]); + vi.spyOn(overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(Species.BIDOOF); + vi.spyOn(overrides, "OPP_ABILITY_OVERRIDE", "get").mockReturnValue(enemyAbility); + + await game.startBattle(); + + game.doAttack(getMovePosition(game.scene, 0, moveToUse)); + + await game.phaseInterceptor.to(TurnEndPhase); + + expect(game.phaseInterceptor.log).toContain("ShowAbilityPhase"); + expect(game.phaseInterceptor.log).not.toContain("FaintPhase"); + }, TIMEOUT); + + // Invalid if aftermath.test.ts has a failure. + it("silently prevents Aftermath from triggering", async() => { + const moveToUse = Moves.TACKLE; + const playerAbility = Abilities.DAMP; + const enemyAbility = Abilities.AFTERMATH; + + vi.spyOn(overrides, "ABILITY_OVERRIDE", "get").mockReturnValue(playerAbility); + vi.spyOn(overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([moveToUse]); + vi.spyOn(overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SPLASH, Moves.NONE, Moves.NONE, Moves.NONE]); + vi.spyOn(overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(Species.BIDOOF); + vi.spyOn(overrides, "OPP_ABILITY_OVERRIDE", "get").mockReturnValue(enemyAbility); + + await game.startBattle(); + + game.scene.getEnemyParty()[0].hp = 1; + + game.doAttack(getMovePosition(game.scene, 0, moveToUse)); + + await game.phaseInterceptor.to(TurnEndPhase); + + expect(game.phaseInterceptor.log).toContain("FaintPhase"); + expect(game.phaseInterceptor.log).not.toContain("ShowAbilityPhase"); + expect(game.scene.getParty()[0].getHpRatio()).toBe(1); + }, TIMEOUT); + + // Ensures fix of #1476. + it("does not show ability popup during AI calculations", async() => { + const moveToUse = Moves.EXPLOSION; + const enemyAbility = Abilities.DAMP; + + vi.spyOn(overrides, "ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.NONE); + vi.spyOn(overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([moveToUse]); + vi.spyOn(overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SPLASH, Moves.NONE, Moves.NONE, Moves.NONE]); + vi.spyOn(overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(Species.BIDOOF); + vi.spyOn(overrides, "OPP_ABILITY_OVERRIDE", "get").mockReturnValue(enemyAbility); + + await game.startBattle(); + + game.doAttack(getMovePosition(game.scene, 0, moveToUse)); + + await game.phaseInterceptor.to(TurnEndPhase); + + expect(game.phaseInterceptor.log).toContain("ShowAbilityPhase"); + }, TIMEOUT); + + // TODO Test some of the other AbAttrs that use `args` + // BattlerTagImmunityAbAttr, StatusEffectImmunityAbAttr +});