diff --git a/src/data/ability.ts b/src/data/ability.ts index 11a3f48eff9..82d72e9ac52 100755 --- a/src/data/ability.ts +++ b/src/data/ability.ts @@ -9,7 +9,7 @@ import { BattlerTag } from "./battler-tags"; import { BattlerTagType } from "./enums/battler-tag-type"; import { StatusEffect, getNonVolatileStatusEffects, getStatusEffectDescriptor, getStatusEffectHealText } from "./status-effect"; import { Gender } from "./gender"; -import Move, { AttackMove, MoveCategory, MoveFlags, MoveTarget, FlinchAttr, OneHitKOAttr, HitHealAttr, allMoves, StatusMove, SelfStatusMove, VariablePowerAttr, applyMoveAttrs, IncrementMovePriorityAttr } from "./move"; +import Move, { AttackMove, MoveCategory, MoveFlags, MoveTarget, FlinchAttr, OneHitKOAttr, HitHealAttr, allMoves, StatusMove, SelfStatusMove, VariablePowerAttr, applyMoveAttrs, IncrementMovePriorityAttr, VariableMoveTypeAttr, RandomMovesetMoveAttr, RandomMoveAttr, NaturePowerAttr, CopyMoveAttr } from "./move"; import { ArenaTagSide, ArenaTrapTag } from "./arena-tag"; import { ArenaTagType } from "./enums/arena-tag-type"; import { Stat, getStatName } from "./pokemon-stat"; @@ -1085,21 +1085,20 @@ export class FieldMultiplyBattleStatAbAttr extends AbAttr { } export class MoveTypeChangeAttr extends PreAttackAbAttr { - private newType: Type; - private powerMultiplier: number; - private condition: PokemonAttackCondition; - - constructor(newType: Type, powerMultiplier: number, condition: PokemonAttackCondition) { + constructor( + private newType: Type, + private powerMultiplier: number, + private condition?: PokemonAttackCondition + ) { super(true); - this.newType = newType; - this.powerMultiplier = powerMultiplier; - this.condition = condition; } applyPreAttack(pokemon: Pokemon, passive: boolean, defender: Pokemon, move: Move, args: any[]): boolean { - if (this.condition(pokemon, defender, move)) { + if (this.condition && this.condition(pokemon, defender, move)) { move.type = this.newType; - (args[0] as Utils.NumberHolder).value *= this.powerMultiplier; + if (args[0] && args[0] instanceof Utils.NumberHolder) { + args[0].value *= this.powerMultiplier; + } return true; } @@ -1107,6 +1106,58 @@ export class MoveTypeChangeAttr extends PreAttackAbAttr { } } +/** Ability attribute for changing a pokemon's type before using a move */ +export class PokemonTypeChangeAbAttr extends PreAttackAbAttr { + private moveType: Type; + + constructor() { + super(true); + } + + applyPreAttack(pokemon: Pokemon, passive: boolean, defender: Pokemon, move: Move, args: any[]): boolean { + if ( + !pokemon.isTerastallized() && + move.id !== Moves.STRUGGLE && + /** + * Skip moves that call other moves because these moves generate a following move that will trigger this ability attribute + * @see {@link https://bulbapedia.bulbagarden.net/wiki/Category:Moves_that_call_other_moves} + */ + !move.findAttr((attr) => + attr instanceof RandomMovesetMoveAttr || + attr instanceof RandomMoveAttr || + attr instanceof NaturePowerAttr || + attr instanceof CopyMoveAttr + ) + ) { + // TODO remove this copy when phase order is changed so that damage, type, category, etc. + // TODO are all calculated prior to playing the move animation. + const moveCopy = new Move(move.id, move.type, move.category, move.moveTarget, move.power, move.accuracy, move.pp, move.chance, move.priority, move.generation); + moveCopy.attrs = move.attrs; + + // Moves like Weather Ball ignore effects of abilities like Normalize and Refrigerate + if (move.findAttr(attr => attr instanceof VariableMoveTypeAttr)) { + applyMoveAttrs(VariableMoveTypeAttr, pokemon, null, moveCopy); + } else { + applyPreAttackAbAttrs(MoveTypeChangeAttr, pokemon, null, moveCopy); + } + + if (pokemon.getTypes().some((t) => t !== moveCopy.type)) { + this.moveType = moveCopy.type; + pokemon.summonData.types = [moveCopy.type]; + pokemon.updateInfo(); + + return true; + } + } + + return false; + } + + getTriggerMessage(pokemon: Pokemon, abilityName: string, ...args: any[]): string { + return getPokemonMessage(pokemon, ` transformed into the ${Type[this.moveType]} type!`); + } +} + /** * Class for abilities that boost the damage of moves * For abilities that boost the base power of moves, see VariableMovePowerAbAttr @@ -3557,6 +3608,9 @@ function applyAbAttrsInternal(attrType: { new(...args: any } pokemon.scene.setPhaseQueueSplice(); const onApplySuccess = () => { + if (pokemon.summonData && !pokemon.summonData.abilitiesApplied.includes(ability.id)) { + pokemon.summonData.abilitiesApplied.push(ability.id); + } if (pokemon.battleData && !pokemon.battleData.abilitiesApplied.includes(ability.id)) { pokemon.battleData.abilitiesApplied.push(ability.id); } @@ -4039,8 +4093,9 @@ export function initAbilities() { .conditionalAttr(pokemon => pokemon.status ? pokemon.status.effect === StatusEffect.PARALYSIS : false, BattleStatMultiplierAbAttr, BattleStat.SPD, 2) .conditionalAttr(pokemon => !!pokemon.status || pokemon.hasAbility(Abilities.COMATOSE), BattleStatMultiplierAbAttr, BattleStat.SPD, 1.5), new Ability(Abilities.NORMALIZE, 4) - .attr(MoveTypeChangeAttr, Type.NORMAL, 1.2, (user, target, move) => move.id !== Moves.HIDDEN_POWER && move.id !== Moves.WEATHER_BALL && - move.id !== Moves.NATURAL_GIFT && move.id !== Moves.JUDGMENT && move.id !== Moves.TECHNO_BLAST), + .attr(MoveTypeChangeAttr, Type.NORMAL, 1.2, (user, target, move) => { + return ![Moves.HIDDEN_POWER, Moves.WEATHER_BALL, Moves.NATURAL_GIFT, Moves.JUDGMENT, Moves.TECHNO_BLAST].includes(move.id); + }), new Ability(Abilities.SNIPER, 4) .attr(MultCritAbAttr, 1.5), new Ability(Abilities.MAGIC_GUARD, 4) @@ -4259,7 +4314,8 @@ export function initAbilities() { .attr(HealFromBerryUseAbAttr, 1/3) .partial(), // Healing not blocked by Heal Block new Ability(Abilities.PROTEAN, 6) - .unimplemented(), + .attr(PokemonTypeChangeAbAttr) + .condition((p) => !p.summonData?.abilitiesApplied.includes(Abilities.PROTEAN)), new Ability(Abilities.FUR_COAT, 6) .attr(ReceivedMoveDamageMultiplierAbAttr, (target, user, move) => move.category === MoveCategory.PHYSICAL, 0.5) .ignorable(), @@ -4498,7 +4554,8 @@ export function initAbilities() { .attr(PostSummonStatChangeAbAttr, BattleStat.DEF, 1, true) .condition(getOncePerBattleCondition(Abilities.DAUNTLESS_SHIELD)), new Ability(Abilities.LIBERO, 8) - .unimplemented(), + .attr(PokemonTypeChangeAbAttr) + .condition((p) => !p.summonData?.abilitiesApplied.includes(Abilities.LIBERO)), new Ability(Abilities.BALL_FETCH, 8) .attr(FetchBallAbAttr) .condition(getOncePerBattleCondition(Abilities.BALL_FETCH)), diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 58556e46a4e..2da0cc8d15f 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -3811,6 +3811,7 @@ export class PokemonSummonData { public disabledTurns: integer = 0; public tags: BattlerTag[] = []; public abilitySuppressed: boolean = false; + public abilitiesApplied: Abilities[] = []; public speciesForm: PokemonSpeciesForm; public fusionSpeciesForm: PokemonSpeciesForm; @@ -3819,7 +3820,8 @@ export class PokemonSummonData { public fusionGender: Gender; public stats: integer[]; public moveset: PokemonMove[]; - public types: Type[]; + // If not initialized this value will not be populated from save data. + public types: Type[] = null; } export class PokemonBattleData { @@ -3831,7 +3833,9 @@ export class PokemonBattleData { } export class PokemonBattleSummonData { + /** The number of turns the pokemon has passed since entering the battle */ public turnCount: integer = 1; + /** The list of moves the pokemon has used since entering the battle */ public moveHistory: TurnMove[] = []; } diff --git a/src/phases.ts b/src/phases.ts index 74746ffd48c..dabbbb4c4eb 100644 --- a/src/phases.ts +++ b/src/phases.ts @@ -30,7 +30,7 @@ import { Weather, WeatherType, getRandomWeatherType, getTerrainBlockMessage, get import { TempBattleStat } from "./data/temp-battle-stat"; import { ArenaTagSide, ArenaTrapTag, MistTag, TrickRoomTag } from "./data/arena-tag"; import { ArenaTagType } from "./data/enums/arena-tag-type"; -import { CheckTrappedAbAttr, IgnoreOpponentStatChangesAbAttr, IgnoreOpponentEvasionAbAttr, 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, BattleStatMultiplierAbAttr, applyBattleStatMultiplierAbAttrs, IncrementMovePriorityAbAttr, applyPostVictoryAbAttrs, PostVictoryAbAttr, BlockNonDirectDamageAbAttr as BlockNonDirectDamageAbAttr, applyPostKnockOutAbAttrs, PostKnockOutAbAttr, PostBiomeChangeAbAttr, applyPostFaintAbAttrs, PostFaintAbAttr, IncreasePpAbAttr, PostStatChangeAbAttr, applyPostStatChangeAbAttrs, AlwaysHitAbAttr, PreventBerryUseAbAttr, StatChangeCopyAbAttr, applyPostMoveUsedAbAttrs, PostMoveUsedAbAttr, MaxMultiHitAbAttr, HealFromBerryUseAbAttr } from "./data/ability"; +import { CheckTrappedAbAttr, IgnoreOpponentStatChangesAbAttr, IgnoreOpponentEvasionAbAttr, 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, BattleStatMultiplierAbAttr, applyBattleStatMultiplierAbAttrs, IncrementMovePriorityAbAttr, applyPostVictoryAbAttrs, PostVictoryAbAttr, BlockNonDirectDamageAbAttr as BlockNonDirectDamageAbAttr, applyPostKnockOutAbAttrs, PostKnockOutAbAttr, PostBiomeChangeAbAttr, applyPostFaintAbAttrs, PostFaintAbAttr, IncreasePpAbAttr, PostStatChangeAbAttr, applyPostStatChangeAbAttrs, AlwaysHitAbAttr, PreventBerryUseAbAttr, StatChangeCopyAbAttr, PokemonTypeChangeAbAttr, applyPreAttackAbAttrs, applyPostMoveUsedAbAttrs, PostMoveUsedAbAttr, MaxMultiHitAbAttr, HealFromBerryUseAbAttr } from "./data/ability"; import { Unlockables, getUnlockableName } from "./system/unlockables"; import { getBiomeKey } from "./field/arena"; import { BattleType, BattlerIndex, TurnCommand } from "./battle"; @@ -2692,6 +2692,16 @@ export class MovePhase extends BattlePhase { failedText = getTerrainBlockMessage(targets[0], this.scene.arena.terrain.terrainType); } } + + /** + * Trigger pokemon type change before playing the move animation + * Will still change the user's type when using Roar, Whirlwind, Trick-or-Treat, and Forest's Curse, + * regardless of whether the move successfully executes or not. + */ + if (success || [Moves.ROAR, Moves.WHIRLWIND, Moves.TRICK_OR_TREAT, Moves.FORESTS_CURSE].includes(this.move.moveId)) { + applyPreAttackAbAttrs(PokemonTypeChangeAbAttr, this.pokemon, null, this.move.getMove()); + } + if (success) { this.scene.unshiftPhase(this.getEffectPhase()); } else { diff --git a/src/system/arena-data.ts b/src/system/arena-data.ts index 2b2aa01bec6..6eec27c32d3 100644 --- a/src/system/arena-data.ts +++ b/src/system/arena-data.ts @@ -2,16 +2,19 @@ import { Arena } from "../field/arena"; import { ArenaTag } from "../data/arena-tag"; import { Biome } from "../data/enums/biome"; import { Weather } from "../data/weather"; +import { Terrain } from "#app/data/terrain.js"; export default class ArenaData { public biome: Biome; public weather: Weather; + public terrain: Terrain; public tags: ArenaTag[]; constructor(source: Arena | any) { const sourceArena = source instanceof Arena ? source as Arena : null; this.biome = sourceArena ? sourceArena.biomeType : source.biome; this.weather = sourceArena ? sourceArena.weather : source.weather ? new Weather(source.weather.weatherType, source.weather.turnsLeft) : undefined; + this.terrain = sourceArena ? sourceArena.terrain : source.terrain ? new Terrain(source.terrain.terrainType, source.terrain.turnsLeft) : undefined; this.tags = sourceArena ? sourceArena.tags : []; } } diff --git a/src/system/game-data.ts b/src/system/game-data.ts index e9b67da2525..1be5cab8d97 100644 --- a/src/system/game-data.ts +++ b/src/system/game-data.ts @@ -829,7 +829,7 @@ export class GameData { loadSession(scene: BattleScene, slotId: integer, sessionData?: SessionSaveData): Promise { return new Promise(async (resolve, reject) => { try { - const initSessionFromData = async sessionData => { + const initSessionFromData = async (sessionData: SessionSaveData) => { console.debug(sessionData); scene.gameMode = getGameMode(sessionData.gameMode || GameModes.CLASSIC); diff --git a/src/system/pokemon-data.ts b/src/system/pokemon-data.ts index 0aa72f97801..b7dc2d67883 100644 --- a/src/system/pokemon-data.ts +++ b/src/system/pokemon-data.ts @@ -54,7 +54,7 @@ export default class PokemonData { public summonData: PokemonSummonData; constructor(source: Pokemon | any, forHistory: boolean = false) { - const sourcePokemon = source instanceof Pokemon ? source as Pokemon : null; + const sourcePokemon = source instanceof Pokemon ? source : null; this.id = source.id; this.player = sourcePokemon ? sourcePokemon.isPlayer() : source.player; this.species = sourcePokemon ? sourcePokemon.species.speciesId : source.species; @@ -121,6 +121,7 @@ export default class PokemonData { this.summonData.disabledMove = source.summonData.disabledMove; this.summonData.disabledTurns = source.summonData.disabledTurns; this.summonData.abilitySuppressed = source.summonData.abilitySuppressed; + this.summonData.abilitiesApplied = source.summonData.abilitiesApplied; this.summonData.ability = source.summonData.ability; this.summonData.moveset = source.summonData.moveset?.map(m => PokemonMove.loadMove(m)); diff --git a/src/test/abilities/libero.test.ts b/src/test/abilities/libero.test.ts new file mode 100644 index 00000000000..b479691f565 --- /dev/null +++ b/src/test/abilities/libero.test.ts @@ -0,0 +1,364 @@ +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 "#app/data/enums/species.js"; +import { Abilities } from "#app/data/enums/abilities.js"; +import { Moves } from "#app/data/enums/moves.js"; +import { getMovePosition } from "../utils/gameManagerUtils"; +import { MoveEffectPhase, TurnEndPhase } from "#app/phases.js"; +import { allMoves } from "#app/data/move.js"; +import { BattlerTagType } from "#app/data/enums/battler-tag-type.js"; +import { Weather, WeatherType } from "#app/data/weather.js"; +import { Type } from "#app/data/type.js"; +import { Biome } from "#app/data/enums/biome.js"; +import { PlayerPokemon } from "#app/field/pokemon.js"; + +const TIMEOUT = 20 * 1000; + +describe("Abilities - Protean", () => { + 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, "ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.LIBERO); + vi.spyOn(Overrides, "STARTING_LEVEL_OVERRIDE", "get").mockReturnValue(100); + vi.spyOn(Overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(Species.RATTATA); + vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.ENDURE, Moves.ENDURE, Moves.ENDURE, Moves.ENDURE]); + }); + + test( + "ability applies and changes a pokemon's type", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SPLASH]); + + await game.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.SPLASH)); + await game.phaseInterceptor.to(TurnEndPhase); + + testPokemonTypeMatchesDefaultMoveType(leadPokemon, Moves.SPLASH); + }, + TIMEOUT, + ); + + test( + "ability applies only once per switch in", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SPLASH, Moves.AGILITY]); + + await game.startBattle([Species.MAGIKARP, Species.BULBASAUR]); + + let leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.SPLASH)); + await game.phaseInterceptor.to(TurnEndPhase); + + testPokemonTypeMatchesDefaultMoveType(leadPokemon, Moves.SPLASH); + + game.doAttack(getMovePosition(game.scene, 0, Moves.AGILITY)); + await game.phaseInterceptor.to(TurnEndPhase); + + expect(leadPokemon.summonData.abilitiesApplied.filter((a) => a === Abilities.LIBERO)).toHaveLength(1); + const leadPokemonType = Type[leadPokemon.getTypes()[0]]; + const moveType = Type[allMoves[Moves.AGILITY].defaultType]; + expect(leadPokemonType).not.toBe(moveType); + + await game.toNextTurn(); + game.doSwitchPokemon(1); + await game.toNextTurn(); + game.doSwitchPokemon(1); + await game.toNextTurn(); + + leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.SPLASH)); + await game.phaseInterceptor.to(TurnEndPhase); + + testPokemonTypeMatchesDefaultMoveType(leadPokemon, Moves.SPLASH); + }, + TIMEOUT, + ); + + test( + "ability applies correctly even if the pokemon's move has a variable type", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.WEATHER_BALL]); + + await game.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + game.scene.arena.weather = new Weather(WeatherType.SUNNY); + game.doAttack(getMovePosition(game.scene, 0, Moves.WEATHER_BALL)); + await game.phaseInterceptor.to(TurnEndPhase); + + expect(leadPokemon.summonData.abilitiesApplied).toContain(Abilities.LIBERO); + expect(leadPokemon.getTypes()).toHaveLength(1); + const leadPokemonType = Type[leadPokemon.getTypes()[0]], + moveType = Type[Type.FIRE]; + expect(leadPokemonType).toBe(moveType); + }, + TIMEOUT, + ); + + test( + "ability applies correctly even if the type has changed by another ability", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.TACKLE]); + vi.spyOn(Overrides, "PASSIVE_ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.REFRIGERATE); + + await game.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.TACKLE)); + await game.phaseInterceptor.to(TurnEndPhase); + + expect(leadPokemon.summonData.abilitiesApplied).toContain(Abilities.LIBERO); + expect(leadPokemon.getTypes()).toHaveLength(1); + const leadPokemonType = Type[leadPokemon.getTypes()[0]], + moveType = Type[Type.ICE]; + expect(leadPokemonType).toBe(moveType); + }, + TIMEOUT, + ); + + test( + "ability applies correctly even if the pokemon's move calls another move", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.NATURE_POWER]); + + await game.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + game.scene.arena.biomeType = Biome.MOUNTAIN; + game.doAttack(getMovePosition(game.scene, 0, Moves.NATURE_POWER)); + await game.phaseInterceptor.to(TurnEndPhase); + + testPokemonTypeMatchesDefaultMoveType(leadPokemon, Moves.AIR_SLASH); + }, + TIMEOUT, + ); + + test( + "ability applies correctly even if the pokemon's move is delayed / charging", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.DIG]); + + await game.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.DIG)); + await game.phaseInterceptor.to(TurnEndPhase); + + testPokemonTypeMatchesDefaultMoveType(leadPokemon, Moves.DIG); + }, + TIMEOUT, + ); + + test( + "ability applies correctly even if the pokemon's move misses", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.TACKLE]); + vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SPLASH, Moves.SPLASH, Moves.SPLASH, Moves.SPLASH]); + + await game.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.TACKLE)); + await game.phaseInterceptor.to(MoveEffectPhase, false); + vi.spyOn(game.scene.getCurrentPhase() as MoveEffectPhase, "hitCheck").mockReturnValueOnce(false); + await game.phaseInterceptor.to(TurnEndPhase); + + const enemyPokemon = game.scene.getEnemyPokemon(); + expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp()); + testPokemonTypeMatchesDefaultMoveType(leadPokemon, Moves.TACKLE); + }, + TIMEOUT, + ); + + test( + "ability applies correctly even if the pokemon's move is protected against", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.TACKLE]); + vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.PROTECT, Moves.PROTECT, Moves.PROTECT, Moves.PROTECT]); + + await game.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.TACKLE)); + await game.phaseInterceptor.to(TurnEndPhase); + + testPokemonTypeMatchesDefaultMoveType(leadPokemon, Moves.TACKLE); + }, + TIMEOUT, + ); + + test( + "ability applies correctly even if the pokemon's move fails because of type immunity", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.TACKLE]); + vi.spyOn(Overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(Species.GASTLY); + + await game.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.TACKLE)); + await game.phaseInterceptor.to(TurnEndPhase); + + testPokemonTypeMatchesDefaultMoveType(leadPokemon, Moves.TACKLE); + }, + TIMEOUT, + ); + + test( + "ability is not applied if pokemon's type is the same as the move's type", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SPLASH]); + + await game.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + leadPokemon.summonData.types = [allMoves[Moves.SPLASH].defaultType]; + game.doAttack(getMovePosition(game.scene, 0, Moves.SPLASH)); + await game.phaseInterceptor.to(TurnEndPhase); + + expect(leadPokemon.summonData.abilitiesApplied).not.toContain(Abilities.LIBERO); + }, + TIMEOUT, + ); + + test( + "ability is not applied if pokemon is terastallized", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SPLASH]); + + await game.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + vi.spyOn(leadPokemon, "isTerastallized").mockReturnValue(true); + + game.doAttack(getMovePosition(game.scene, 0, Moves.SPLASH)); + await game.phaseInterceptor.to(TurnEndPhase); + + expect(leadPokemon.summonData.abilitiesApplied).not.toContain(Abilities.LIBERO); + }, + TIMEOUT, + ); + + test( + "ability is not applied if pokemon uses struggle", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.STRUGGLE]); + + await game.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.STRUGGLE)); + await game.phaseInterceptor.to(TurnEndPhase); + + expect(leadPokemon.summonData.abilitiesApplied).not.toContain(Abilities.LIBERO); + }, + TIMEOUT, + ); + + test( + "ability is not applied if the pokemon's move fails", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.BURN_UP]); + + await game.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.BURN_UP)); + await game.phaseInterceptor.to(TurnEndPhase); + + expect(leadPokemon.summonData.abilitiesApplied).not.toContain(Abilities.LIBERO); + }, + TIMEOUT, + ); + + test( + "ability applies correctly even if the pokemon's Trick-or-Treat fails", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.TRICK_OR_TREAT]); + vi.spyOn(Overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(Species.GASTLY); + + await game.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.TRICK_OR_TREAT)); + await game.phaseInterceptor.to(TurnEndPhase); + + testPokemonTypeMatchesDefaultMoveType(leadPokemon, Moves.TRICK_OR_TREAT); + }, + TIMEOUT, + ); + + test( + "ability applies correctly and the pokemon curses itself", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.CURSE]); + + await game.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.CURSE)); + await game.phaseInterceptor.to(TurnEndPhase); + + testPokemonTypeMatchesDefaultMoveType(leadPokemon, Moves.CURSE); + expect(leadPokemon.getTag(BattlerTagType.CURSED)).not.toBe(undefined); + }, + TIMEOUT, + ); +}); + +function testPokemonTypeMatchesDefaultMoveType(pokemon: PlayerPokemon, move: Moves) { + expect(pokemon.summonData.abilitiesApplied).toContain(Abilities.LIBERO); + expect(pokemon.getTypes()).toHaveLength(1); + const pokemonType = Type[pokemon.getTypes()[0]], + moveType = Type[allMoves[move].defaultType]; + expect(pokemonType).toBe(moveType); +} diff --git a/src/test/abilities/protean.test.ts b/src/test/abilities/protean.test.ts new file mode 100644 index 00000000000..cd3633156ee --- /dev/null +++ b/src/test/abilities/protean.test.ts @@ -0,0 +1,364 @@ +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 "#app/data/enums/species.js"; +import { Abilities } from "#app/data/enums/abilities.js"; +import { Moves } from "#app/data/enums/moves.js"; +import { getMovePosition } from "../utils/gameManagerUtils"; +import { MoveEffectPhase, TurnEndPhase } from "#app/phases.js"; +import { allMoves } from "#app/data/move.js"; +import { BattlerTagType } from "#app/data/enums/battler-tag-type.js"; +import { Weather, WeatherType } from "#app/data/weather.js"; +import { Type } from "#app/data/type.js"; +import { Biome } from "#app/data/enums/biome.js"; +import { PlayerPokemon } from "#app/field/pokemon.js"; + +const TIMEOUT = 20 * 1000; + +describe("Abilities - Protean", () => { + 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, "ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.PROTEAN); + vi.spyOn(Overrides, "STARTING_LEVEL_OVERRIDE", "get").mockReturnValue(100); + vi.spyOn(Overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(Species.RATTATA); + vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.ENDURE, Moves.ENDURE, Moves.ENDURE, Moves.ENDURE]); + }); + + test( + "ability applies and changes a pokemon's type", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SPLASH]); + + await game.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.SPLASH)); + await game.phaseInterceptor.to(TurnEndPhase); + + testPokemonTypeMatchesDefaultMoveType(leadPokemon, Moves.SPLASH); + }, + TIMEOUT, + ); + + test( + "ability applies only once per switch in", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SPLASH, Moves.AGILITY]); + + await game.startBattle([Species.MAGIKARP, Species.BULBASAUR]); + + let leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.SPLASH)); + await game.phaseInterceptor.to(TurnEndPhase); + + testPokemonTypeMatchesDefaultMoveType(leadPokemon, Moves.SPLASH); + + game.doAttack(getMovePosition(game.scene, 0, Moves.AGILITY)); + await game.phaseInterceptor.to(TurnEndPhase); + + expect(leadPokemon.summonData.abilitiesApplied.filter((a) => a === Abilities.PROTEAN)).toHaveLength(1); + const leadPokemonType = Type[leadPokemon.getTypes()[0]]; + const moveType = Type[allMoves[Moves.AGILITY].defaultType]; + expect(leadPokemonType).not.toBe(moveType); + + await game.toNextTurn(); + game.doSwitchPokemon(1); + await game.toNextTurn(); + game.doSwitchPokemon(1); + await game.toNextTurn(); + + leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.SPLASH)); + await game.phaseInterceptor.to(TurnEndPhase); + + testPokemonTypeMatchesDefaultMoveType(leadPokemon, Moves.SPLASH); + }, + TIMEOUT, + ); + + test( + "ability applies correctly even if the pokemon's move has a variable type", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.WEATHER_BALL]); + + await game.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + game.scene.arena.weather = new Weather(WeatherType.SUNNY); + game.doAttack(getMovePosition(game.scene, 0, Moves.WEATHER_BALL)); + await game.phaseInterceptor.to(TurnEndPhase); + + expect(leadPokemon.summonData.abilitiesApplied).toContain(Abilities.PROTEAN); + expect(leadPokemon.getTypes()).toHaveLength(1); + const leadPokemonType = Type[leadPokemon.getTypes()[0]], + moveType = Type[Type.FIRE]; + expect(leadPokemonType).toBe(moveType); + }, + TIMEOUT, + ); + + test( + "ability applies correctly even if the type has changed by another ability", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.TACKLE]); + vi.spyOn(Overrides, "PASSIVE_ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.REFRIGERATE); + + await game.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.TACKLE)); + await game.phaseInterceptor.to(TurnEndPhase); + + expect(leadPokemon.summonData.abilitiesApplied).toContain(Abilities.PROTEAN); + expect(leadPokemon.getTypes()).toHaveLength(1); + const leadPokemonType = Type[leadPokemon.getTypes()[0]], + moveType = Type[Type.ICE]; + expect(leadPokemonType).toBe(moveType); + }, + TIMEOUT, + ); + + test( + "ability applies correctly even if the pokemon's move calls another move", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.NATURE_POWER]); + + await game.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + game.scene.arena.biomeType = Biome.MOUNTAIN; + game.doAttack(getMovePosition(game.scene, 0, Moves.NATURE_POWER)); + await game.phaseInterceptor.to(TurnEndPhase); + + testPokemonTypeMatchesDefaultMoveType(leadPokemon, Moves.AIR_SLASH); + }, + TIMEOUT, + ); + + test( + "ability applies correctly even if the pokemon's move is delayed / charging", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.DIG]); + + await game.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.DIG)); + await game.phaseInterceptor.to(TurnEndPhase); + + testPokemonTypeMatchesDefaultMoveType(leadPokemon, Moves.DIG); + }, + TIMEOUT, + ); + + test( + "ability applies correctly even if the pokemon's move misses", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.TACKLE]); + vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SPLASH, Moves.SPLASH, Moves.SPLASH, Moves.SPLASH]); + + await game.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.TACKLE)); + await game.phaseInterceptor.to(MoveEffectPhase, false); + vi.spyOn(game.scene.getCurrentPhase() as MoveEffectPhase, "hitCheck").mockReturnValueOnce(false); + await game.phaseInterceptor.to(TurnEndPhase); + + const enemyPokemon = game.scene.getEnemyPokemon(); + expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp()); + testPokemonTypeMatchesDefaultMoveType(leadPokemon, Moves.TACKLE); + }, + TIMEOUT, + ); + + test( + "ability applies correctly even if the pokemon's move is protected against", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.TACKLE]); + vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.PROTECT, Moves.PROTECT, Moves.PROTECT, Moves.PROTECT]); + + await game.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.TACKLE)); + await game.phaseInterceptor.to(TurnEndPhase); + + testPokemonTypeMatchesDefaultMoveType(leadPokemon, Moves.TACKLE); + }, + TIMEOUT, + ); + + test( + "ability applies correctly even if the pokemon's move fails because of type immunity", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.TACKLE]); + vi.spyOn(Overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(Species.GASTLY); + + await game.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.TACKLE)); + await game.phaseInterceptor.to(TurnEndPhase); + + testPokemonTypeMatchesDefaultMoveType(leadPokemon, Moves.TACKLE); + }, + TIMEOUT, + ); + + test( + "ability is not applied if pokemon's type is the same as the move's type", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SPLASH]); + + await game.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + leadPokemon.summonData.types = [allMoves[Moves.SPLASH].defaultType]; + game.doAttack(getMovePosition(game.scene, 0, Moves.SPLASH)); + await game.phaseInterceptor.to(TurnEndPhase); + + expect(leadPokemon.summonData.abilitiesApplied).not.toContain(Abilities.PROTEAN); + }, + TIMEOUT, + ); + + test( + "ability is not applied if pokemon is terastallized", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SPLASH]); + + await game.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + vi.spyOn(leadPokemon, "isTerastallized").mockReturnValue(true); + + game.doAttack(getMovePosition(game.scene, 0, Moves.SPLASH)); + await game.phaseInterceptor.to(TurnEndPhase); + + expect(leadPokemon.summonData.abilitiesApplied).not.toContain(Abilities.PROTEAN); + }, + TIMEOUT, + ); + + test( + "ability is not applied if pokemon uses struggle", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.STRUGGLE]); + + await game.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.STRUGGLE)); + await game.phaseInterceptor.to(TurnEndPhase); + + expect(leadPokemon.summonData.abilitiesApplied).not.toContain(Abilities.PROTEAN); + }, + TIMEOUT, + ); + + test( + "ability is not applied if the pokemon's move fails", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.BURN_UP]); + + await game.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.BURN_UP)); + await game.phaseInterceptor.to(TurnEndPhase); + + expect(leadPokemon.summonData.abilitiesApplied).not.toContain(Abilities.PROTEAN); + }, + TIMEOUT, + ); + + test( + "ability applies correctly even if the pokemon's Trick-or-Treat fails", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.TRICK_OR_TREAT]); + vi.spyOn(Overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(Species.GASTLY); + + await game.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.TRICK_OR_TREAT)); + await game.phaseInterceptor.to(TurnEndPhase); + + testPokemonTypeMatchesDefaultMoveType(leadPokemon, Moves.TRICK_OR_TREAT); + }, + TIMEOUT, + ); + + test( + "ability applies correctly and the pokemon curses itself", + async () => { + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.CURSE]); + + await game.startBattle([Species.MAGIKARP]); + + const leadPokemon = game.scene.getPlayerPokemon(); + expect(leadPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.CURSE)); + await game.phaseInterceptor.to(TurnEndPhase); + + testPokemonTypeMatchesDefaultMoveType(leadPokemon, Moves.CURSE); + expect(leadPokemon.getTag(BattlerTagType.CURSED)).not.toBe(undefined); + }, + TIMEOUT, + ); +}); + +function testPokemonTypeMatchesDefaultMoveType(pokemon: PlayerPokemon, move: Moves) { + expect(pokemon.summonData.abilitiesApplied).toContain(Abilities.PROTEAN); + expect(pokemon.getTypes()).toHaveLength(1); + const pokemonType = Type[pokemon.getTypes()[0]], + moveType = Type[allMoves[move].defaultType]; + expect(pokemonType).toBe(moveType); +}