[Ability] Implement Protean and Libero abilities (#1309)

* Add generic to util holders to reduce manual type casting

* implement protean and libero abilities

* remove use only once per turn trigger

* Revert Attack Attribute Conditions back to requiring unused vars

* Remove conditional before invoking type change ability

* update protean to properly trigger and skip certain moves

* remove some dangerous typecasts

* revert autoformatting changes

* not all autoformatting changes were reverted

* Revert "Add generic to util holders to reduce manual type casting"

This reverts commit 3ee7f1d5ff.

* change some variable names

* remove incorrect comment

* update abilities so they use gen 9 logic

* fix typescript error from missing Terrain type

* update gameManager switchPokemon to match other menu utilities

* add test cases for protean and libero
This commit is contained in:
Dmitriy K 2024-06-13 10:54:23 -04:00 committed by GitHub
parent 048993b2c2
commit 819fe9b4a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 822 additions and 19 deletions

View File

@ -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<TAttr extends AbAttr>(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)),

View File

@ -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[] = [];
}

View File

@ -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 {

View File

@ -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 : [];
}
}

View File

@ -829,7 +829,7 @@ export class GameData {
loadSession(scene: BattleScene, slotId: integer, sessionData?: SessionSaveData): Promise<boolean> {
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);

View File

@ -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));

View File

@ -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);
}

View File

@ -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);
}