diff --git a/src/data/ability.ts b/src/data/ability.ts index bf3b04e1f633..940b5f0c7d7a 100644 --- a/src/data/ability.ts +++ b/src/data/ability.ts @@ -1044,9 +1044,9 @@ export class PostDefendAbilitySwapAbAttr extends PostDefendAbAttr { if (move.checkFlag(MoveFlags.MAKES_CONTACT, attacker, pokemon) && !attacker.getAbility().hasAttr(UnswappableAbilityAbAttr) && !move.hitsSubstitute(attacker, pokemon)) { if (!simulated) { - const tempAbilityId = attacker.getAbility().id; - attacker.summonData.ability = pokemon.getAbility().id; - pokemon.summonData.ability = tempAbilityId; + const tempAbility = attacker.getAbility(); + attacker.setTempAbility(pokemon.getAbility()); + pokemon.setTempAbility(tempAbility); } return true; } @@ -1071,7 +1071,7 @@ export class PostDefendAbilityGiveAbAttr extends PostDefendAbAttr { if (move.checkFlag(MoveFlags.MAKES_CONTACT, attacker, pokemon) && !attacker.getAbility().hasAttr(UnsuppressableAbilityAbAttr) && !attacker.getAbility().hasAttr(PostDefendAbilityGiveAbAttr) && !move.hitsSubstitute(attacker, pokemon)) { if (!simulated) { - attacker.summonData.ability = this.ability; + attacker.setTempAbility(allAbilities[this.ability]); } return true; @@ -1908,8 +1908,8 @@ export class CopyFaintedAllyAbilityAbAttr extends PostKnockOutAbAttr { applyPostKnockOut(pokemon: Pokemon, passive: boolean, simulated: boolean, knockedOut: Pokemon, args: any[]): boolean | Promise { if (pokemon.isPlayer() === knockedOut.isPlayer() && !knockedOut.getAbility().hasAttr(UncopiableAbilityAbAttr)) { if (!simulated) { - pokemon.summonData.ability = knockedOut.getAbility().id; globalScene.queueMessage(i18next.t("abilityTriggers:copyFaintedAllyAbility", { pokemonNameWithAffix: getPokemonNameWithAffix(knockedOut), abilityName: allAbilities[knockedOut.getAbility().id].name })); + pokemon.setTempAbility(knockedOut.getAbility()); } return true; } @@ -1993,6 +1993,21 @@ export class PostIntimidateStatStageChangeAbAttr extends AbAttr { * @see {@linkcode applyPostSummon()} */ export class PostSummonAbAttr extends AbAttr { + /** Should the ability activate when gained in battle? This will almost always be true */ + private activateOnGain: boolean; + + constructor(showAbility: boolean = true, activateOnGain: boolean = true) { + super(showAbility); + this.activateOnGain = activateOnGain; + } + + /** + * @returns Whether the ability should activate when gained in battle + */ + shouldActivateOnGain(): boolean { + return this.activateOnGain; + } + /** * Applies ability post summon (after switching in) * @param pokemon {@linkcode Pokemon} with this ability @@ -2330,7 +2345,7 @@ export class PostSummonCopyAbilityAbAttr extends PostSummonAbAttr { if (!simulated) { this.target = target!; this.targetAbilityName = allAbilities[target!.getAbility().id].name; - pokemon.summonData.ability = target!.getAbility().id; + pokemon.setTempAbility(target!.getAbility()); setAbilityRevealed(target!); pokemon.updateInfo(); } @@ -2427,7 +2442,7 @@ export class PostSummonCopyAllyStatsAbAttr extends PostSummonAbAttr { */ export class PostSummonTransformAbAttr extends PostSummonAbAttr { constructor() { - super(true); + super(true, false); } async applyPostSummon(pokemon: Pokemon, _passive: boolean, simulated: boolean, _args: any[]): Promise { @@ -2462,7 +2477,6 @@ export class PostSummonTransformAbAttr extends PostSummonAbAttr { } pokemon.summonData.speciesForm = target.getSpeciesForm(); - pokemon.summonData.ability = target.getAbility().id; pokemon.summonData.gender = target.getGender(); // Copy all stats (except HP) @@ -2492,6 +2506,8 @@ export class PostSummonTransformAbAttr extends PostSummonAbAttr { promises.push(pokemon.loadAssets(false).then(() => { pokemon.playAnim(); pokemon.updateInfo(); + // If the new ability activates immediately, it needs to happen after all the transform animations + pokemon.setTempAbility(target.getAbility()); })); await Promise.all(promises); @@ -4852,50 +4868,69 @@ async function applyAbAttrsInternal( showAbilityInstant: boolean = false, simulated: boolean = false, messages: string[] = [], + gainedMidTurn: boolean = false ) { for (const passive of [ false, true ]) { - if (!pokemon?.canApplyAbility(passive) || (passive && pokemon.getPassiveAbility().id === pokemon.getAbility().id)) { + if (!pokemon?.canApplyAbility(passive) || (passive && (pokemon.getPassiveAbility().id === pokemon.getAbility().id))) { continue; } - const ability = passive ? pokemon.getPassiveAbility() : pokemon.getAbility(); - for (const attr of ability.getAttrs(attrType)) { - const condition = attr.getCondition(); - if (condition && !condition(pokemon)) { - continue; - } + applySingleAbAttrs(pokemon, passive, attrType, applyFunc, args, gainedMidTurn, simulated, showAbilityInstant, messages); + globalScene.clearPhaseQueueSplice(); + } +} + +async function applySingleAbAttrs( + pokemon: Pokemon, + passive: boolean, + attrType: Constructor, + applyFunc: AbAttrApplyFunc, + args: any[], + gainedMidTurn: boolean = false, + simulated: boolean = false, + showAbilityInstant: boolean = false, + messages: string[] = [] +) { + const ability = passive ? pokemon.getPassiveAbility() : pokemon.getAbility(); + if (gainedMidTurn && ability.getAttrs(attrType).some(attr => attr instanceof PostSummonAbAttr && !attr.shouldActivateOnGain())) { + return; + } - globalScene.setPhaseQueueSplice(); + for (const attr of ability.getAttrs(attrType)) { + const condition = attr.getCondition(); + if (condition && !condition(pokemon)) { + continue; + } + + globalScene.setPhaseQueueSplice(); - let result = applyFunc(attr, passive); - // TODO Remove this when promises get reworked - if (result instanceof Promise) { - result = await result; + let result = applyFunc(attr, passive); + // TODO Remove this when promises get reworked + if (result instanceof Promise) { + result = await result; + } + if (result) { + if (pokemon.summonData && !pokemon.summonData.abilitiesApplied.includes(ability.id)) { + pokemon.summonData.abilitiesApplied.push(ability.id); } - if (result) { - if (pokemon.summonData && !pokemon.summonData.abilitiesApplied.includes(ability.id)) { - pokemon.summonData.abilitiesApplied.push(ability.id); - } - if (pokemon.battleData && !simulated && !pokemon.battleData.abilitiesApplied.includes(ability.id)) { - pokemon.battleData.abilitiesApplied.push(ability.id); - } - if (attr.showAbility && !simulated) { - if (showAbilityInstant) { - globalScene.abilityBar.showAbility(pokemon, passive); - } else { - queueShowAbility(pokemon, passive); - } + if (pokemon.battleData && !simulated && !pokemon.battleData.abilitiesApplied.includes(ability.id)) { + pokemon.battleData.abilitiesApplied.push(ability.id); + } + if (attr.showAbility && !simulated) { + if (showAbilityInstant) { + globalScene.abilityBar.showAbility(pokemon, passive); + } else { + queueShowAbility(pokemon, passive); } - const message = attr.getTriggerMessage(pokemon, ability.name, args); - if (message) { - if (!simulated) { - globalScene.queueMessage(message); - } + } + const message = attr.getTriggerMessage(pokemon, ability.name, args); + if (message) { + if (!simulated) { + globalScene.queueMessage(message); } - messages.push(message!); } + messages.push(message!); } - globalScene.clearPhaseQueueSplice(); } } @@ -5285,6 +5320,21 @@ export function applyPostItemLostAbAttrs(attrType: Constructor(attrType, pokemon, (attr, passive) => attr.applyPostItemLost(pokemon, simulated, args), args); } +/** + * Applies abilities when they become active mid-turn (ability switch) + * + * Ignores passives as they don't change and shouldn't be reapplied when main abilities change + */ +export function applyOnGainAbAttrs(pokemon: Pokemon, passive: boolean = false, simulated: boolean = false, ...args: any[]): void { + applySingleAbAttrs(pokemon, passive, PostSummonAbAttr, (attr, passive) => attr.applyPostSummon(pokemon, passive, simulated, args), args, true, simulated); +} + +/** + * Clears primal weather during the turn if {@linkcode pokemon}'s ability corresponds to one + */ +export function applyOnLoseClearWeatherAbAttrs(pokemon: Pokemon, passive: boolean = false, simulated: boolean = false, ...args: any[]): void { + applySingleAbAttrs(pokemon, passive, PreLeaveFieldClearWeatherAbAttr, (attr, passive) => attr.applyPreLeaveField(pokemon, passive, simulated, [ ...args, true ]), args, true, simulated); +} function queueShowAbility(pokemon: Pokemon, passive: boolean): void { globalScene.unshiftPhase(new ShowAbilityPhase(pokemon.id, passive)); globalScene.clearPhaseQueueSplice(); diff --git a/src/data/move.ts b/src/data/move.ts index 016dae6ab0da..a81bbd4940ef 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -7521,11 +7521,11 @@ export class AbilityChangeAttr extends MoveEffectAttr { const moveTarget = this.selfTarget ? user : target; - moveTarget.summonData.ability = this.ability; - globalScene.triggerPokemonFormChange(moveTarget, SpeciesFormChangeRevertWeatherFormTrigger); - globalScene.queueMessage(i18next.t("moveTriggers:acquiredAbility", { pokemonName: getPokemonNameWithAffix((this.selfTarget ? user : target)), abilityName: allAbilities[this.ability].name })); + moveTarget.setTempAbility(allAbilities[this.ability]); + globalScene.triggerPokemonFormChange(moveTarget, SpeciesFormChangeRevertWeatherFormTrigger); + return true; } @@ -7548,13 +7548,13 @@ export class AbilityCopyAttr extends MoveEffectAttr { return false; } - user.summonData.ability = target.getAbility().id; - globalScene.queueMessage(i18next.t("moveTriggers:copiedTargetAbility", { pokemonName: getPokemonNameWithAffix(user), targetName: getPokemonNameWithAffix(target), abilityName: allAbilities[target.getAbility().id].name })); + user.setTempAbility(target.getAbility()); + if (this.copyToPartner && globalScene.currentBattle?.double && user.getAlly().hp) { - user.getAlly().summonData.ability = target.getAbility().id; globalScene.queueMessage(i18next.t("moveTriggers:copiedTargetAbility", { pokemonName: getPokemonNameWithAffix(user.getAlly()), targetName: getPokemonNameWithAffix(target), abilityName: allAbilities[target.getAbility().id].name })); + user.getAlly().setTempAbility(target.getAbility()); } return true; @@ -7585,10 +7585,10 @@ export class AbilityGiveAttr extends MoveEffectAttr { return false; } - target.summonData.ability = user.getAbility().id; - globalScene.queueMessage(i18next.t("moveTriggers:acquiredAbility", { pokemonName: getPokemonNameWithAffix(target), abilityName: allAbilities[user.getAbility().id].name })); + target.setTempAbility(user.getAbility()); + return true; } @@ -7603,15 +7603,14 @@ export class SwitchAbilitiesAttr extends MoveEffectAttr { return false; } - const tempAbilityId = user.getAbility().id; - user.summonData.ability = target.getAbility().id; - target.summonData.ability = tempAbilityId; + const tempAbility = user.getAbility(); globalScene.queueMessage(i18next.t("moveTriggers:swappedAbilitiesWithTarget", { pokemonName: getPokemonNameWithAffix(user) })); + + user.setTempAbility(target.getAbility()); + target.setTempAbility(tempAbility); // Swaps Forecast/Flower Gift from Castform/Cherrim globalScene.arena.triggerWeatherBasedFormChangesToNormal(); - // Swaps Forecast/Flower Gift to Castform/Cherrim (edge case) - globalScene.arena.triggerWeatherBasedFormChanges(); return true; } @@ -7690,7 +7689,6 @@ export class TransformAttr extends MoveEffectAttr { const promises: Promise[] = []; user.summonData.speciesForm = target.getSpeciesForm(); - user.summonData.ability = target.getAbility().id; user.summonData.gender = target.getGender(); // Power Trick's effect will not preserved after using Transform @@ -7723,6 +7721,8 @@ export class TransformAttr extends MoveEffectAttr { promises.push(user.loadAssets(false).then(() => { user.playAnim(); user.updateInfo(); + // If the new ability activates immediately, it needs to happen after all the transform animations + user.setTempAbility(target.getAbility()); })); await Promise.all(promises); diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 1377f11187b1..714f1ec70260 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -64,7 +64,7 @@ import { BattlerTag, BattlerTagLapseType, EncoreTag, GroundedTag, HighestStatBoo import { WeatherType } from "#enums/weather-type"; import { ArenaTagSide, NoCritTag, WeakenMoveScreenTag } from "#app/data/arena-tag"; import type { Ability, AbAttr } from "#app/data/ability"; -import { StatMultiplierAbAttr, BlockCritAbAttr, BonusCritAbAttr, BypassBurnDamageReductionAbAttr, FieldPriorityMoveImmunityAbAttr, IgnoreOpponentStatStagesAbAttr, MoveImmunityAbAttr, PreDefendFullHpEndureAbAttr, ReceivedMoveDamageMultiplierAbAttr, StabBoostAbAttr, StatusEffectImmunityAbAttr, TypeImmunityAbAttr, WeightMultiplierAbAttr, allAbilities, applyAbAttrs, applyStatMultiplierAbAttrs, applyPreApplyBattlerTagAbAttrs, applyPreAttackAbAttrs, applyPreDefendAbAttrs, applyPreSetStatusAbAttrs, UnsuppressableAbilityAbAttr, SuppressFieldAbilitiesAbAttr, NoFusionAbilityAbAttr, MultCritAbAttr, IgnoreTypeImmunityAbAttr, DamageBoostAbAttr, IgnoreTypeStatusEffectImmunityAbAttr, ConditionalCritAbAttr, applyFieldStatMultiplierAbAttrs, FieldMultiplyStatAbAttr, AddSecondStrikeAbAttr, UserFieldStatusEffectImmunityAbAttr, UserFieldBattlerTagImmunityAbAttr, BattlerTagImmunityAbAttr, MoveTypeChangeAbAttr, FullHpResistTypeAbAttr, applyCheckTrappedAbAttrs, CheckTrappedAbAttr, PostSetStatusAbAttr, applyPostSetStatusAbAttrs, InfiltratorAbAttr, AlliedFieldDamageReductionAbAttr, PostDamageAbAttr, applyPostDamageAbAttrs, CommanderAbAttr, applyPostItemLostAbAttrs, PostItemLostAbAttr, PreLeaveFieldAbAttr, applyPreLeaveFieldAbAttrs } from "#app/data/ability"; +import { StatMultiplierAbAttr, BlockCritAbAttr, BonusCritAbAttr, BypassBurnDamageReductionAbAttr, FieldPriorityMoveImmunityAbAttr, IgnoreOpponentStatStagesAbAttr, MoveImmunityAbAttr, PreDefendFullHpEndureAbAttr, ReceivedMoveDamageMultiplierAbAttr, StabBoostAbAttr, StatusEffectImmunityAbAttr, TypeImmunityAbAttr, WeightMultiplierAbAttr, allAbilities, applyAbAttrs, applyStatMultiplierAbAttrs, applyPreApplyBattlerTagAbAttrs, applyPreAttackAbAttrs, applyPreDefendAbAttrs, applyPreSetStatusAbAttrs, UnsuppressableAbilityAbAttr, SuppressFieldAbilitiesAbAttr, NoFusionAbilityAbAttr, MultCritAbAttr, IgnoreTypeImmunityAbAttr, DamageBoostAbAttr, IgnoreTypeStatusEffectImmunityAbAttr, ConditionalCritAbAttr, applyFieldStatMultiplierAbAttrs, FieldMultiplyStatAbAttr, AddSecondStrikeAbAttr, UserFieldStatusEffectImmunityAbAttr, UserFieldBattlerTagImmunityAbAttr, BattlerTagImmunityAbAttr, MoveTypeChangeAbAttr, FullHpResistTypeAbAttr, applyCheckTrappedAbAttrs, CheckTrappedAbAttr, PostSetStatusAbAttr, applyPostSetStatusAbAttrs, InfiltratorAbAttr, AlliedFieldDamageReductionAbAttr, PostDamageAbAttr, applyPostDamageAbAttrs, CommanderAbAttr, applyPostItemLostAbAttrs, PostItemLostAbAttr, applyOnGainAbAttrs, PreLeaveFieldAbAttr, applyPreLeaveFieldAbAttrs, applyOnLoseClearWeatherAbAttrs } from "#app/data/ability"; import type PokemonData from "#app/system/pokemon-data"; import { BattlerIndex } from "#app/battle"; import { Mode } from "#app/ui/ui"; @@ -1481,6 +1481,22 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { return abilityAttrs; } + /** + * Sets the {@linkcode Pokemon}'s ability and activates it if it normally activates on summon + * + * Also clears primal weather if it is from the ability being changed + * @param ability New Ability + */ + public setTempAbility(ability: Ability, passive: boolean = false): void { + applyOnLoseClearWeatherAbAttrs(this, passive); + if (passive) { + this.summonData.passiveAbility = ability.id; + } else { + this.summonData.ability = ability.id; + } + applyOnGainAbAttrs(this, passive); + } + /** * Checks if a pokemon has a passive either from: * - bought with starter candy diff --git a/src/test/abilities/imposter.test.ts b/src/test/abilities/imposter.test.ts index 3445b3b322ce..98f30c60505a 100644 --- a/src/test/abilities/imposter.test.ts +++ b/src/test/abilities/imposter.test.ts @@ -116,4 +116,15 @@ describe("Abilities - Imposter", () => { } }); }); + + it("should activate its ability if it copies one that activates on summon", async () => { + game.override.enemyAbility(Abilities.INTIMIDATE); + + await game.classicMode.startBattle([ Species.DITTO ]); + + game.move.select(Moves.TACKLE); + await game.phaseInterceptor.to("MoveEndPhase"); + + expect(game.scene.getEnemyPokemon()?.getStatStage(Stat.ATK)).toBe(-1); + }); }); diff --git a/src/test/abilities/mummy.test.ts b/src/test/abilities/mummy.test.ts new file mode 100644 index 000000000000..bf93cdcf61d1 --- /dev/null +++ b/src/test/abilities/mummy.test.ts @@ -0,0 +1,52 @@ +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Abilities - Mummy", () => { + 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 + .moveset([ Moves.SPLASH ]) + .ability(Abilities.MUMMY) + .battleType("single") + .disableCrits() + .enemySpecies(Species.MAGIKARP) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.TACKLE); + }); + + it("should set the enemy's ability to mummy when hit by a contact move", async () => { + await game.classicMode.startBattle([ Species.FEEBAS ]); + + game.move.select(Moves.SPLASH); + await game.phaseInterceptor.to("BerryPhase"); + + expect(game.scene.getEnemyPokemon()?.getAbility().id).toBe(Abilities.MUMMY); + }); + + it("should not change the enemy's ability hit by a non-contact move", async () => { + game.override.enemyMoveset(Moves.EARTHQUAKE); + await game.classicMode.startBattle([ Species.FEEBAS ]); + + game.move.select(Moves.SPLASH); + await game.phaseInterceptor.to("BerryPhase"); + + expect(game.scene.getEnemyPokemon()?.getAbility().id).toBe(Abilities.BALL_FETCH); + }); +}); diff --git a/src/test/abilities/trace.test.ts b/src/test/abilities/trace.test.ts new file mode 100644 index 000000000000..ffa76f597693 --- /dev/null +++ b/src/test/abilities/trace.test.ts @@ -0,0 +1,53 @@ +import { Stat } from "#app/enums/stat"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Abilities - Trace", () => { + 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 + .moveset([ Moves.SPLASH ]) + .ability(Abilities.TRACE) + .battleType("single") + .disableCrits() + .enemySpecies(Species.MAGIKARP) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH); + }); + + it("should copy the opponent's ability", async () => { + await game.classicMode.startBattle([ Species.FEEBAS ]); + + game.move.select(Moves.SPLASH); + await game.phaseInterceptor.to("BerryPhase"); + + expect(game.scene.getPlayerPokemon()?.getAbility().id).toBe(Abilities.BALL_FETCH); + }); + + it("should activate a copied post-summon ability", async () => { + game.override.enemyAbility(Abilities.INTIMIDATE); + await game.classicMode.startBattle([ Species.FEEBAS ]); + + game.move.select(Moves.SPLASH); + await game.phaseInterceptor.to("BerryPhase"); + + expect(game.scene.getEnemyPokemon()?.getStatStage(Stat.ATK)).toBe(-1); + }); +}); diff --git a/src/test/abilities/wandering_spirit.test.ts b/src/test/abilities/wandering_spirit.test.ts new file mode 100644 index 000000000000..4bc9298353f1 --- /dev/null +++ b/src/test/abilities/wandering_spirit.test.ts @@ -0,0 +1,65 @@ +import { Stat } from "#app/enums/stat"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Abilities - Wandering Spirit", () => { + 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 + .moveset([ Moves.SPLASH ]) + .ability(Abilities.WANDERING_SPIRIT) + .battleType("single") + .disableCrits() + .enemySpecies(Species.MAGIKARP) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.TACKLE); + }); + + it("should exchange abilities when hit with a contact move", async () => { + await game.classicMode.startBattle([ Species.FEEBAS ]); + + game.move.select(Moves.SPLASH); + await game.phaseInterceptor.to("BerryPhase"); + + expect(game.scene.getPlayerPokemon()?.getAbility().id).toBe(Abilities.BALL_FETCH); + expect(game.scene.getEnemyPokemon()?.getAbility().id).toBe(Abilities.WANDERING_SPIRIT); + }); + + it("should not exchange abilities when hit with a non-contact move", async () => { + game.override.enemyMoveset(Moves.EARTHQUAKE); + await game.classicMode.startBattle([ Species.FEEBAS ]); + + game.move.select(Moves.SPLASH); + await game.phaseInterceptor.to("BerryPhase"); + + expect(game.scene.getPlayerPokemon()?.getAbility().id).toBe(Abilities.WANDERING_SPIRIT); + expect(game.scene.getEnemyPokemon()?.getAbility().id).toBe(Abilities.BALL_FETCH); + }); + + it("should activate post-summon abilities", async () => { + game.override.enemyAbility(Abilities.INTIMIDATE); + await game.classicMode.startBattle([ Species.FEEBAS ]); + + game.move.select(Moves.SPLASH); + await game.phaseInterceptor.to("BerryPhase"); + + expect(game.scene.getEnemyPokemon()?.getStatStage(Stat.ATK)).toBe(-1); + }); +}); diff --git a/src/test/battle/ability_swap.test.ts b/src/test/battle/ability_swap.test.ts new file mode 100644 index 000000000000..9ce7a36c16ee --- /dev/null +++ b/src/test/battle/ability_swap.test.ts @@ -0,0 +1,67 @@ +import { allAbilities } from "#app/data/ability"; +import { Stat } from "#app/enums/stat"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Test Ability Swapping", () => { + 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 + .moveset([ Moves.SPLASH ]) + .ability(Abilities.BALL_FETCH) + .battleType("single") + .disableCrits() + .enemySpecies(Species.MAGIKARP) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH); + }); + + it("should activate post-summon abilities", async () => { + await game.classicMode.startBattle([ Species.FEEBAS ]); + + game.move.select(Moves.SPLASH); + game.scene.getPlayerPokemon()?.setTempAbility(allAbilities[Abilities.INTIMIDATE]); + await game.phaseInterceptor.to("BerryPhase"); + + expect(game.scene.getEnemyPokemon()?.getStatStage(Stat.ATK)).toBe(-1); + }); + + it("should remove primal weather when the setter's ability is removed", async () => { + game.override.ability(Abilities.DESOLATE_LAND); + await game.classicMode.startBattle([ Species.FEEBAS ]); + + game.move.select(Moves.SPLASH); + game.scene.getPlayerPokemon()?.setTempAbility(allAbilities[Abilities.BALL_FETCH]); + await game.phaseInterceptor.to("BerryPhase"); + + expect(game.scene.arena.weather?.weatherType).toBeUndefined(); + }); + + it("should not activate passive abilities", async () => { + game.override.passiveAbility(Abilities.INTREPID_SWORD); + await game.classicMode.startBattle([ Species.FEEBAS ]); + + game.move.select(Moves.SPLASH); + game.scene.getPlayerPokemon()?.setTempAbility(allAbilities[Abilities.BALL_FETCH]); + await game.phaseInterceptor.to("BerryPhase"); + + expect(game.scene.getPlayerPokemon()?.getStatStage(Stat.ATK)).toBe(1); // would be 2 if passive activated again + }); +}); diff --git a/src/test/moves/doodle.test.ts b/src/test/moves/doodle.test.ts new file mode 100644 index 000000000000..258abda392ae --- /dev/null +++ b/src/test/moves/doodle.test.ts @@ -0,0 +1,70 @@ +import { BattlerIndex } from "#app/battle"; +import { Stat } from "#app/enums/stat"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Moves - Doodle", () => { + 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 + .moveset([ Moves.SPLASH, Moves.DOODLE ]) + .ability(Abilities.ADAPTABILITY) + .battleType("single") + .disableCrits() + .enemySpecies(Species.MAGIKARP) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH); + }); + + it("should copy the opponent's ability in singles", async () => { + await game.classicMode.startBattle([ Species.FEEBAS ]); + + game.move.select(Moves.DOODLE); + await game.phaseInterceptor.to("BerryPhase"); + + expect(game.scene.getPlayerPokemon()?.getAbility().id).toBe(Abilities.BALL_FETCH); + }); + + it("should copy the opponent's ability to itself and its ally in doubles", async () => { + game.override.battleType("double"); + await game.classicMode.startBattle([ Species.FEEBAS, Species.MAGIKARP ]); + + game.move.select(Moves.DOODLE, 0, BattlerIndex.ENEMY); + game.move.select(Moves.SPLASH, 1); + await game.phaseInterceptor.to("BerryPhase"); + + expect(game.scene.getPlayerField()[0].getAbility().id).toBe(Abilities.BALL_FETCH); + expect(game.scene.getPlayerField()[1].getAbility().id).toBe(Abilities.BALL_FETCH); + }); + + it("should activate post-summon abilities", async () => { + game.override.battleType("double") + .enemyAbility(Abilities.INTIMIDATE); + + await game.classicMode.startBattle([ Species.FEEBAS, Species.MAGIKARP ]); + + game.move.select(Moves.DOODLE, 0, BattlerIndex.ENEMY); + game.move.select(Moves.SPLASH, 1); + await game.phaseInterceptor.to("BerryPhase"); + + // Enemies should have been intimidated twice + expect(game.scene.getEnemyPokemon()?.getStatStage(Stat.ATK)).toBe(-2); + }); +}); diff --git a/src/test/moves/entrainment.test.ts b/src/test/moves/entrainment.test.ts new file mode 100644 index 000000000000..5d0991c8dfd8 --- /dev/null +++ b/src/test/moves/entrainment.test.ts @@ -0,0 +1,53 @@ +import { Stat } from "#app/enums/stat"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Moves - Entrainment", () => { + 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 + .moveset([ Moves.SPLASH, Moves.ENTRAINMENT ]) + .ability(Abilities.ADAPTABILITY) + .battleType("single") + .disableCrits() + .enemySpecies(Species.MAGIKARP) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH); + }); + + it("gives its ability to the target", async () => { + await game.classicMode.startBattle([ Species.FEEBAS ]); + + game.move.select(Moves.ENTRAINMENT); + await game.phaseInterceptor.to("BerryPhase"); + + expect(game.scene.getEnemyPokemon()?.getAbility().id).toBe(Abilities.ADAPTABILITY); + }); + + it("should activate post-summon abilities", async () => { + game.override.ability(Abilities.INTIMIDATE); + await game.classicMode.startBattle([ Species.FEEBAS ]); + + game.move.select(Moves.ENTRAINMENT); + await game.phaseInterceptor.to("BerryPhase"); + + expect(game.scene.getPlayerPokemon()?.getStatStage(Stat.ATK)).toBe(-1); + }); +}); diff --git a/src/test/moves/role_play.test.ts b/src/test/moves/role_play.test.ts new file mode 100644 index 000000000000..a37f4faac9bb --- /dev/null +++ b/src/test/moves/role_play.test.ts @@ -0,0 +1,53 @@ +import { Stat } from "#app/enums/stat"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Moves - Role Play", () => { + 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 + .moveset([ Moves.SPLASH, Moves.ROLE_PLAY ]) + .ability(Abilities.ADAPTABILITY) + .battleType("single") + .disableCrits() + .enemySpecies(Species.MAGIKARP) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH); + }); + + it("should set the user's ability to the target's ability", async () => { + await game.classicMode.startBattle([ Species.FEEBAS ]); + + game.move.select(Moves.ROLE_PLAY); + await game.phaseInterceptor.to("BerryPhase"); + + expect(game.scene.getPlayerPokemon()?.getAbility().id).toBe(Abilities.BALL_FETCH); + }); + + it("should activate post-summon abilities", async () => { + game.override.enemyAbility(Abilities.INTIMIDATE); + await game.classicMode.startBattle([ Species.FEEBAS ]); + + game.move.select(Moves.ROLE_PLAY); + await game.phaseInterceptor.to("BerryPhase"); + + expect(game.scene.getEnemyPokemon()?.getStatStage(Stat.ATK)).toBe(-1); + }); +}); diff --git a/src/test/moves/simple_beam.test.ts b/src/test/moves/simple_beam.test.ts new file mode 100644 index 000000000000..b4566669e8dd --- /dev/null +++ b/src/test/moves/simple_beam.test.ts @@ -0,0 +1,42 @@ +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Moves - Simple Beam", () => { + 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 + .moveset([ Moves.SPLASH, Moves.SIMPLE_BEAM ]) + .ability(Abilities.BALL_FETCH) + .battleType("single") + .disableCrits() + .enemySpecies(Species.MAGIKARP) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH); + }); + + it("sets the target's ability to simple", async () => { + await game.classicMode.startBattle([ Species.FEEBAS ]); + + game.move.select(Moves.SIMPLE_BEAM); + await game.phaseInterceptor.to("BerryPhase"); + + expect(game.scene.getEnemyPokemon()?.getAbility().id).toBe(Abilities.SIMPLE); + }); +}); diff --git a/src/test/moves/skill_swap.test.ts b/src/test/moves/skill_swap.test.ts new file mode 100644 index 000000000000..9c0f0b75ade7 --- /dev/null +++ b/src/test/moves/skill_swap.test.ts @@ -0,0 +1,56 @@ +import { Stat } from "#app/enums/stat"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Moves - Skill Swap", () => { + 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 + .moveset([ Moves.SPLASH, Moves.SKILL_SWAP ]) + .ability(Abilities.BALL_FETCH) + .battleType("single") + .disableCrits() + .enemySpecies(Species.MAGIKARP) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH); + }); + + it("should swap the two abilities", async () => { + game.override.ability(Abilities.ADAPTABILITY); + await game.classicMode.startBattle([ Species.FEEBAS ]); + + game.move.select(Moves.SKILL_SWAP); + await game.phaseInterceptor.to("BerryPhase"); + + expect(game.scene.getPlayerPokemon()?.getAbility().id).toBe(Abilities.BALL_FETCH); + expect(game.scene.getEnemyPokemon()?.getAbility().id).toBe(Abilities.ADAPTABILITY); + }); + + it("should activate post-summon abilities", async () => { + game.override.ability(Abilities.INTIMIDATE); + await game.classicMode.startBattle([ Species.FEEBAS ]); + + game.move.select(Moves.SKILL_SWAP); + await game.phaseInterceptor.to("BerryPhase"); + + // player atk should be -1 after opponent gains intimidate and it activates + expect(game.scene.getPlayerPokemon()?.getStatStage(Stat.ATK)).toBe(-1); + }); +}); diff --git a/src/test/moves/transform.test.ts b/src/test/moves/transform.test.ts index adb97b42af78..ffe935aa61bc 100644 --- a/src/test/moves/transform.test.ts +++ b/src/test/moves/transform.test.ts @@ -116,4 +116,16 @@ describe("Moves - Transform", () => { } }); }); + + it("should activate its ability if it copies one that activates on summon", async () => { + game.override.enemyAbility(Abilities.INTIMIDATE) + .ability(Abilities.BALL_FETCH); + + await game.classicMode.startBattle([ Species.DITTO ]); + game.move.select(Moves.TRANSFORM); + + await game.phaseInterceptor.to("BerryPhase"); + + expect(game.scene.getEnemyPokemon()?.getStatStage(Stat.ATK)).toBe(-1); + }); });