Moved utility functon to new file, fixed tests and such

This commit is contained in:
Bertie690 2025-05-29 14:35:12 -04:00
parent 09b828f4fb
commit d4b2b0af26
9 changed files with 360 additions and 312 deletions

View File

@ -944,6 +944,7 @@ export default class BattleScene extends SceneBase {
if (this.currentBattle.double === false) {
return;
}
// TODO: Remove while loop
if (allyPokemon?.isActive(true)) {
let targetingMovePhase: MovePhase;
do {

View File

@ -6947,12 +6947,10 @@ export function initAbilities() {
.attr(PostDefendStatStageChangeAbAttr, (target, user, move) => move.category !== MoveCategory.STATUS, Stat.DEF, 1),
new Ability(Abilities.WIMP_OUT, 7)
.attr(PostDamageForceSwitchAbAttr)
.condition(getSheerForceHitDisableAbCondition())
.bypassFaint(), // allows Wimp Out to activate with Reviver Seed
.condition(getSheerForceHitDisableAbCondition()),
new Ability(Abilities.EMERGENCY_EXIT, 7)
.attr(PostDamageForceSwitchAbAttr)
.condition(getSheerForceHitDisableAbCondition())
.bypassFaint(),
.condition(getSheerForceHitDisableAbCondition()),
new Ability(Abilities.WATER_COMPACTION, 7)
.attr(PostDefendStatStageChangeAbAttr, (target, user, move) => user.getMoveType(move) === PokemonType.WATER && move.category !== MoveCategory.STATUS, Stat.DEF, 2),
new Ability(Abilities.MERCILESS, 7)

View File

@ -1823,8 +1823,8 @@ export class AddSubstituteAttr extends MoveEffectAttr {
}
/**
* Removes 1/4 of the user's maximum HP (rounded down) to create a substitute for the user
* @param user - The {@linkcode Pokemon} that used the move.
* Removes a fraction of the user's maximum HP to create a substitute.
* @param user - The {@linkcode Pokemon} using the move.
* @param target - n/a
* @param move - The {@linkcode Move} with this attribute.
* @param args - n/a

View File

@ -1692,6 +1692,11 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
return this.getMaxHp() - this.hp;
}
/**
* Return this Pokemon's current HP as a fraction of its maximum HP.
* @param precise - Whether to return the exact HP ratio (`true`) or rounded to the nearest 1% (`false`); default `false`
* @returns This pokemon's current HP ratio (current / max).
*/
getHpRatio(precise = false): number {
return precise ? this.hp / this.getMaxHp() : Math.round((this.hp / this.getMaxHp()) * 100) / 100;
}
@ -4048,15 +4053,14 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
isCritical = false,
ignoreSegments = false,
ignoreFaintPhase = false,
}:
{
result?: DamageResult,
isCritical?: boolean,
ignoreSegments?: boolean,
ignoreFaintPhase?: boolean,
} = {}
}: {
result?: DamageResult;
isCritical?: boolean;
ignoreSegments?: boolean;
ignoreFaintPhase?: boolean;
} = {},
): number {
const isIndirectDamage = [ HitResult.INDIRECT, HitResult.INDIRECT_KO ].includes(result);
const isIndirectDamage = [HitResult.INDIRECT, HitResult.INDIRECT_KO].includes(result);
const damagePhase = new DamageAnimPhase(this.getBattlerIndex(), damage, result, isCritical);
globalScene.unshiftPhase(damagePhase);
@ -4923,7 +4927,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
* which already calls this function.
*/
resetSummonData(): void {
console.log(`resetSummonData called on Pokemon ${this.name}`)
console.log(`resetSummonData called on Pokemon ${this.name}`);
const illusion: IllusionData | null = this.summonData.illusion;
if (this.summonData.speciesForm) {
this.summonData.speciesForm = null;
@ -4965,7 +4969,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
}
resetTurnData(): void {
console.log(`resetTurnData called on Pokemon ${this.name}`)
console.log(`resetTurnData called on Pokemon ${this.name}`);
this.turnData = new PokemonTurnData();
}
@ -5421,11 +5425,11 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
*/
// TODO: Review where this is being called and where it is necessary to call it
leaveField(clearEffects = true, hideInfo = true, destroy = false) {
console.log(`leaveField called on Pokemon ${this.name}`)
console.log(`leaveField called on Pokemon ${this.name}`);
this.resetSprite();
globalScene
.getField(true)
.filter(p => p !== this)
.getField(true)
.filter(p => p !== this)
.forEach(p => p.removeTagsBySourceId(this.id));
if (clearEffects) {
@ -6717,7 +6721,6 @@ export class EnemyPokemon extends Pokemon {
return ret;
}
/**
* Show or hide the type effectiveness multiplier window
* Passing undefined will hide the window

View File

@ -30,9 +30,9 @@ import { SwitchPhase } from "./switch-phase";
import { SwitchSummonPhase } from "./switch-summon-phase";
import { ToggleDoublePositionPhase } from "./toggle-double-position-phase";
import { VictoryPhase } from "./victory-phase";
import { isNullOrUndefined } from "#app/utils/common";
import { FRIENDSHIP_LOSS_FROM_FAINT } from "#app/data/balance/starters";
import { BattlerTagType } from "#enums/battler-tag-type";
import { isNullOrUndefined } from "#app/utils/common";
export class FaintPhase extends PokemonPhase {
/**
@ -118,6 +118,7 @@ export class FaintPhase extends PokemonPhase {
pokemon.resetTera();
// TODO: This could be simplified greatly with the concept of "move being used"
if (pokemon.turnData.attacksReceived?.length) {
const lastAttack = pokemon.turnData.attacksReceived[0];
applyPostFaintAbAttrs(
@ -151,41 +152,35 @@ export class FaintPhase extends PokemonPhase {
}
}
const legalBackupPokemon = globalScene.getBackupPartyMemberIndices(
this.player,
!this.player ? (pokemon as EnemyPokemon).trainerSlot : undefined,
);
if (this.player) {
/** The total number of Pokemon in the player's party that can legally fight */
/** An array of Pokemon in the player's party that can legally fight. */
const legalPlayerPokemon = globalScene.getPokemonAllowedInBattle();
/** The total number of legal player Pokemon that aren't currently on the field */
const legalPlayerPartyPokemon = legalPlayerPokemon.filter(p => !p.isActive(true));
if (!legalPlayerPokemon.length) {
/** If the player doesn't have any legal Pokemon, end the game */
if (legalPlayerPokemon.length === 0) {
// If the player doesn't have any legal Pokemon left in their party, end the game.
globalScene.unshiftPhase(new GameOverPhase());
} else if (
globalScene.currentBattle.double &&
legalPlayerPokemon.length === 1 &&
legalPlayerPartyPokemon.length === 0
) {
/**
* If the player has exactly one Pokemon in total at this point in a double battle, and that Pokemon
* is already on the field, unshift a phase that moves that Pokemon to center position.
*/
} else if (globalScene.currentBattle.double && legalBackupPokemon.length === 0) {
/*
Otherwise, if the player has no reserve members left to switch in,
unshift a phase to move the other on-field pokemon to center position.
*/
globalScene.unshiftPhase(new ToggleDoublePositionPhase(true));
} else if (legalPlayerPartyPokemon.length > 0) {
/**
* If previous conditions weren't met, and the player has at least 1 legal Pokemon off the field,
* push a phase that prompts the player to summon a Pokemon from their party.
*/
} else {
// If previous conditions weren't met, push a phase to prompt the player to select a pokemon from their party.
globalScene.pushPhase(new SwitchPhase(SwitchType.SWITCH, this.fieldIndex, true, false));
}
} else {
// Unshift a phase for EXP gains and/or one to switch in a replacement party member.
globalScene.unshiftPhase(new VictoryPhase(this.battlerIndex));
if ([BattleType.TRAINER, BattleType.MYSTERY_ENCOUNTER].includes(globalScene.currentBattle.battleType)) {
const hasReservePartyMember = !!globalScene
.getEnemyParty()
.filter(p => p.isActive() && !p.isOnField() && p.trainerSlot === (pokemon as EnemyPokemon).trainerSlot)
.length;
if (hasReservePartyMember) {
globalScene.pushPhase(new SwitchSummonPhase(SwitchType.SWITCH, this.fieldIndex, -1, false, false));
}
if (
[BattleType.TRAINER, BattleType.MYSTERY_ENCOUNTER].includes(globalScene.currentBattle.battleType) &&
legalBackupPokemon.length > 0
) {
globalScene.pushPhase(new SwitchSummonPhase(SwitchType.SWITCH, this.fieldIndex, -1, false, false));
}
}

View File

@ -133,7 +133,7 @@ export class SwitchSummonPhase extends SummonPhase {
// TODO: Why do we trigger these attributes even if the switch in target doesn't exist?
// (This should almost certainly go somewhere inside `preSummon`)
applyPreSummonAbAttrs(PreSummonAbAttr, switchedInPokemon);
applyPreSummonAbAttrs(PreSummonAbAttr, switchInPokemon);
applyPreSwitchOutAbAttrs(PreSwitchOutAbAttr, this.lastPokemon);
if (!switchInPokemon) {
this.end();

53
src/utils/array.ts Normal file
View File

@ -0,0 +1,53 @@
/**
* Split an array into a pair of arrays based on a conditional function.
* @param array - The array to split into 2.
* @param predicate - A function accepting up to 3 arguments. The split function calls the predicate function once per element of the array.
* @param thisArg - An object to which the `this` keyword can refer in the predicate function. If omitted, `undefined` is used as the `this` value.
* @returns A pair of shallowly-copied arrays containing every element for which `predicate` did or did not return a value coercible to the boolean `true`.
* @overload
*/
export function splitArray<T, S extends T>(
array: T[],
predicate: (value: T, index: number, array: T[]) => value is S,
thisArg?: unknown,
): [matches: S[], nonMatches: S[]];
/**
* Split an array into a pair of arrays based on a conditional function.
* @param array - The array to split into 2.
* @param predicate - A function accepting up to 3 arguments. The split function calls the function once per element of the array.
* @param thisArg - An object to which the `this` keyword can refer in the predicate function. If omitted, `undefined` is used as the `this` value.
* @returns A pair of shallowly-copied arrays containing every element for which `predicate` did or did not return a value coercible to the boolean `true`.
* @overload
*/
export function splitArray<T>(
array: T[],
predicate: (value: T, index: number, array: T[]) => unknown,
thisArg?: unknown,
): [matches: T[], nonMatches: T[]];
/**
* Split an array into a pair of arrays based on a conditional function.
* @param array - The array to split into 2.
* @param predicate - A function accepting up to 3 arguments. The split function calls the function once per element of the array.
* @param thisArg - An object to which the `this` keyword can refer in the predicate function. If omitted, `undefined` is used as the `this` value.
* @returns A pair of shallowly-copied arrays containing every element for which `predicate` did or did not return a value coercible to the boolean `true`.
* @overload
*/
export function splitArray<T>(
array: T[],
predicate: (value: T, index: number, array: T[]) => unknown,
thisArg?: unknown,
): [matches: T[], nonMatches: T[]] {
const matches: T[] = [];
const nonMatches: T[] = [];
const p = predicate.bind(thisArg) as typeof predicate;
array.forEach((val, index, ar) => {
if (p(val, index, ar)) {
matches.push(val);
} else {
nonMatches.push(val);
}
});
return [matches, nonMatches];
}

View File

@ -3,6 +3,7 @@ import { ArenaTagSide } from "#app/data/arena-tag";
import { globalScene } from "#app/global-scene";
import { Abilities } from "#enums/abilities";
import { ArenaTagType } from "#enums/arena-tag-type";
import { BattleType } from "#enums/battle-type";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import GameManager from "#test/testUtils/gameManager";
@ -39,31 +40,28 @@ describe("Abilities - Mold Breaker", () => {
game.override.startingLevel(100).enemyLevel(2).enemyAbility(Abilities.STURDY);
await game.classicMode.startBattle([Species.MAGIKARP]);
const player = game.scene.getPlayerPokemon()!;
game.move.select(Moves.ERUPTION);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.getEnemyPokemon()?.isFainted()).toBe(true);
});
it("should turn off ignore abilities arena variable after the user's move concludes", async () => {
game.override.startingLevel(100).enemyLevel(2);
await game.classicMode.startBattle([Species.MAGIKARP]);
expect(globalScene.arena.ignoreAbilities).toBe(false);
game.move.select(Moves.SPLASH);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(globalScene.arena.ignoreAbilities).toBe(true);
expect(game.scene.arena.ignoreAbilities).toBe(true);
expect(game.scene.arena.ignoringEffectSource).toBe(player.getBattlerIndex());
await game.phaseInterceptor.to("MoveEndPhase");
expect(globalScene.arena.ignoreAbilities).toBe(false);
expect(game.scene.arena.ignoreAbilities).toBe(false);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.getEnemyPokemon()?.isFainted()).toBe(true);
});
it("should keep Levitate opponents grounded when using force switch moves", async () => {
game.override.enemyAbility(Abilities.LEVITATE).enemySpecies(Species.WEEZING).startingWave(8); // first rival battle; guaranteed 2 mon party
game.override.enemyAbility(Abilities.LEVITATE).enemySpecies(Species.WEEZING).battleType(BattleType.TRAINER);
// Setup toxic spikes and stealth rock
// Setup toxic spikes and spikes
game.scene.arena.addTag(ArenaTagType.TOXIC_SPIKES, -1, Moves.TOXIC_SPIKES, 1, ArenaTagSide.ENEMY);
game.scene.arena.addTag(ArenaTagType.SPIKES, -1, Moves.CEASELESS_EDGE, 1, ArenaTagSide.ENEMY);
await game.classicMode.startBattle([Species.MAGIKARP]);
@ -71,7 +69,7 @@ describe("Abilities - Mold Breaker", () => {
const [weezing1, weezing2] = game.scene.getEnemyParty();
// Weezing's levitate prevented removal of Toxic Spikes, ignored Spikes damage
expect(game.scene.arena.getTagOnSide(ArenaTagType.TOXIC_SPIKES, ArenaTagSide.ENEMY)).toBeDefined();
expect(weezing1.getHpRatio()).toBe(1);
expect(weezing1.hp).toBe(weezing1.getMaxHp());
game.move.select(Moves.DRAGON_TAIL);
await game.phaseInterceptor.to("TurnEndPhase");
@ -79,7 +77,7 @@ describe("Abilities - Mold Breaker", () => {
// Levitate was ignored during the switch, causing Toxic Spikes to be removed and Spikes to deal damage
expect(weezing1.isOnField()).toBe(false);
expect(weezing2.isOnField()).toBe(true);
expect(weezing2.getHpRatio()).toBeCloseTo(0.75);
expect(weezing2.getHpRatio(true)).toBeCloseTo(0.75);
expect(game.scene.arena.getTagOnSide(ArenaTagType.TOXIC_SPIKES, ArenaTagSide.ENEMY)).toBeUndefined();
});
});

View File

@ -10,10 +10,14 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite
import { BattleType } from "#enums/battle-type";
import { TrainerSlot } from "#enums/trainer-slot";
import { TrainerType } from "#enums/trainer-type";
import { splitArray } from "#app/utils/common";
import { splitArray } from "#app/utils/array";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveResult } from "#app/field/pokemon";
import { SubstituteTag } from "#app/data/battler-tags";
import { Stat } from "#enums/stat";
import i18next from "i18next";
import { toDmgValue } from "#app/utils/common";
import { allAbilities } from "#app/data/data-lists";
describe("Moves - Switching Moves", () => {
let phaserGame: Phaser.Game;
@ -25,7 +29,7 @@ describe("Moves - Switching Moves", () => {
});
});
describe("Target Switch Moves", () => {
describe("Force Switch Moves", () => {
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
@ -34,8 +38,8 @@ describe("Moves - Switching Moves", () => {
game = new GameManager(phaserGame);
game.override
.battleStyle("single")
.ability(Abilities.NO_GUARD)
.moveset([Moves.DRAGON_TAIL, Moves.SPLASH, Moves.FLAMETHROWER])
.passiveAbility(Abilities.NO_GUARD)
.moveset([Moves.DRAGON_TAIL, Moves.SPLASH, Moves.FLAMETHROWER, Moves.FOCUS_PUNCH])
.enemySpecies(Species.WAILORD)
.enemyMoveset(Moves.SPLASH);
});
@ -75,13 +79,15 @@ describe("Moves - Switching Moves", () => {
it("should force trainers to switch randomly without selecting from a partner's party", async () => {
game.override
.battleStyle("double")
.enemyMoveset(Moves.SPLASH)
.enemyAbility(Abilities.STURDY)
.battleType(BattleType.TRAINER)
.randomTrainer({ trainerType: TrainerType.TATE, alwaysDouble: true })
.enemySpecies(0);
await game.classicMode.startBattle([Species.WIMPOD, Species.TYRANITAR]);
expect(game.scene.currentBattle.trainer).not.toBeNull();
const choiceSwitchSpy = vi.spyOn(game.scene.currentBattle.trainer!, "getNextSummonIndex");
// Grab each trainer's pokemon based on species name
const [tateParty, lizaParty] = splitArray(
game.scene.getEnemyParty(),
@ -95,9 +101,6 @@ describe("Moves - Switching Moves", () => {
// as Tate's pokemon are placed immediately before Liza's corresponding members.
vi.fn(Phaser.Math.RND.integerInRange).mockImplementation(min => min);
// Spy on the function responsible for making informed switches
const choiceSwitchSpy = vi.spyOn(game.scene.currentBattle.trainer!, "getNextSummonIndex");
game.move.select(Moves.DRAGON_TAIL, BattlerIndex.PLAYER, BattlerIndex.ENEMY_2);
game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2);
await game.phaseInterceptor.to("BerryPhase");
@ -114,29 +117,20 @@ describe("Moves - Switching Moves", () => {
});
it("should force wild Pokemon to flee and redirect moves accordingly", async () => {
game.override.battleStyle("double").enemyMoveset(Moves.SPLASH).enemyAbility(Abilities.ROUGH_SKIN);
await game.classicMode.startBattle([Species.DRATINI, Species.DRATINI, Species.WAILORD, Species.WAILORD]);
game.override.battleStyle("double").enemyMoveset(Moves.SPLASH);
await game.classicMode.startBattle([Species.DRATINI, Species.DRATINI]);
const leadPokemon = game.scene.getPlayerParty()[0]!;
const secPokemon = game.scene.getPlayerParty()[1]!;
const [enemyLeadPokemon, enemySecPokemon] = game.scene.getEnemyParty();
const enemyLeadPokemon = game.scene.getEnemyParty()[0]!;
const enemySecPokemon = game.scene.getEnemyParty()[1]!;
game.move.select(Moves.DRAGON_TAIL, 0, BattlerIndex.ENEMY);
game.move.select(Moves.DRAGON_TAIL, BattlerIndex.PLAYER, BattlerIndex.ENEMY);
// target the same pokemon, second move should be redirected after first flees
game.move.select(Moves.DRAGON_TAIL, 1, BattlerIndex.ENEMY);
// Focus punch used due to having even lower priority than Dtail
game.move.select(Moves.FOCUS_PUNCH, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER, BattlerIndex.PLAYER_2]);
await game.phaseInterceptor.to("BerryPhase");
const isVisibleLead = enemyLeadPokemon.visible;
const hasFledLead = enemyLeadPokemon.switchOutStatus;
const isVisibleSec = enemySecPokemon.visible;
const hasFledSec = enemySecPokemon.switchOutStatus;
expect(!isVisibleLead && hasFledLead && !isVisibleSec && hasFledSec).toBe(true);
expect(leadPokemon.hp).toBeLessThan(leadPokemon.getMaxHp());
expect(secPokemon.hp).toBeLessThan(secPokemon.getMaxHp());
expect(enemyLeadPokemon.hp).toBeLessThan(enemyLeadPokemon.getMaxHp());
expect(enemyLeadPokemon.visible).toBe(false);
expect(enemyLeadPokemon.switchOutStatus).toBe(true);
expect(enemySecPokemon.hp).toBeLessThan(enemySecPokemon.getMaxHp());
});
@ -153,7 +147,7 @@ describe("Moves - Switching Moves", () => {
expect(enemy.isFullHp()).toBe(false);
// Turn 2: Mold Breaker should ignore switch blocking ability and switch out the target
game.override.ability(Abilities.MOLD_BREAKER);
vi.spyOn(game.scene.getPlayerPokemon()!, "getAbility").mockReturnValue(allAbilities[Abilities.MOLD_BREAKER]);
enemy.hp = enemy.getMaxHp();
game.move.select(Moves.DRAGON_TAIL);
@ -178,54 +172,53 @@ describe("Moves - Switching Moves", () => {
expect(dondozo1.isFullHp()).toBe(false);
});
it("should force a switch upon fainting an opponent normally", async () => {
game.override.startingWave(5).startingLevel(1000); // To make sure Dragon Tail KO's the opponent
it("should perform a normal switch upon fainting an opponent", async () => {
game.override.battleType(BattleType.TRAINER).startingLevel(1000); // To make sure Dragon Tail KO's the opponent
await game.classicMode.startBattle([Species.DRATINI]);
expect(game.scene.getEnemyParty()).toHaveLength(2);
const choiceSwitchSpy = vi.spyOn(game.scene.currentBattle.trainer!, "getNextSummonIndex");
game.move.select(Moves.DRAGON_TAIL);
await game.toNextTurn();
// Make sure the enemy switched to a healthy Pokemon
const enemy = game.scene.getEnemyPokemon()!;
expect(enemy).toBeDefined();
expect(enemy.isFullHp()).toBe(true);
// Make sure the enemy has a fainted Pokemon in their party and not on the field
const faintedEnemy = game.scene.getEnemyParty().find(p => !p.isAllowedInBattle());
expect(faintedEnemy).toBeDefined();
expect(game.scene.getEnemyField().length).toBe(1);
expect(choiceSwitchSpy).toHaveBeenCalledTimes(1);
});
it("should neither switch nor softlock when activating an opponent's reviver seed", async () => {
game.override
.battleType(BattleType.TRAINER)
.enemyHeldItems([{ name: "REVIVER_SEED" }])
.startingLevel(1000); // make sure Dragon Tail KO's the opponent
.enemySpecies(Species.BLISSEY)
.enemyHeldItems([{ name: "REVIVER_SEED" }]);
await game.classicMode.startBattle([Species.DRATINI]);
const [wailord1, wailord2] = game.scene.getEnemyParty()!;
expect(wailord1).toBeDefined();
expect(wailord2).toBeDefined();
const [blissey1, blissey2] = game.scene.getEnemyParty()!;
expect(blissey1).toBeDefined();
expect(blissey2).toBeDefined();
blissey1.hp = 1;
game.move.select(Moves.DRAGON_TAIL);
await game.toNextTurn();
// Wailord should have consumed the reviver seed and stayed on field
expect(wailord1.isOnField()).toBe(true);
expect(wailord1.getHpRatio()).toBeCloseTo(0.5);
expect(wailord1.getHeldItems()).toHaveLength(0);
expect(wailord2.isOnField()).toBe(false);
// Bliseey #1 should have consumed the reviver seed and stayed on field
expect(blissey1.isOnField()).toBe(true);
expect(blissey1.getHpRatio()).toBeCloseTo(0.5);
expect(blissey1.getHeldItems()).toHaveLength(0);
expect(blissey2.isOnField()).toBe(false);
});
it("should neither switch nor softlock when activating a player's reviver seed", async () => {
game.override
.startingHeldItems([{ name: "REVIVER_SEED" }])
.enemyMoveset(Moves.DRAGON_TAIL)
.enemyLevel(1000); // make sure Dragon Tail KO's the player
.startingLevel(1000); // make hp rounding consistent
await game.classicMode.startBattle([Species.BLISSEY, Species.BULBASAUR]);
const [blissey, bulbasaur] = game.scene.getPlayerParty();
blissey.hp = 1;
game.move.select(Moves.SPLASH);
await game.toNextTurn();
@ -279,6 +272,13 @@ describe("Moves - Switching Moves", () => {
const newEnemy = game.scene.getEnemyPokemon()!;
expect(newEnemy).not.toBe(enemy);
expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase");
// TODO: Replace this with the locale key in question
expect(game.textInterceptor.logs).toContain(
i18next.t("INSERT FORCE SWITCH LOCALES KEY HERE", {
pokemonName: newEnemy.getNameToRender(),
}),
);
expect(game.textInterceptor.logs).not.toContain(
i18next.t("battle:trainerGo", {
trainerName: game.scene.currentBattle.trainer?.getName(newEnemy.trainerSlot),
@ -338,225 +338,175 @@ describe("Moves - Switching Moves", () => {
});
});
describe("Failure Checks", () => {
describe("Baton Pass", () => {
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override.battleStyle("single").enemySpecies(Species.GENGAR).disableCrits().enemyAbility(Abilities.STURDY);
game.override
.battleStyle("single")
.enemySpecies(Species.MAGIKARP)
.enemyAbility(Abilities.BALL_FETCH)
.moveset([Moves.BATON_PASS, Moves.NASTY_PLOT, Moves.SPLASH, Moves.SUBSTITUTE])
.ability(Abilities.BALL_FETCH)
.enemyMoveset(Moves.SPLASH)
.disableCrits();
});
it.each<{ name: string; move: Moves }>([
{ name: "U-Turn", move: Moves.U_TURN },
{ name: "Flip Turn", move: Moves.FLIP_TURN },
{ name: "Volt Switch", move: Moves.VOLT_SWITCH },
{ name: "Baton Pass", move: Moves.BATON_PASS },
{ name: "Shed Tail", move: Moves.SHED_TAIL },
{ name: "Parting Shot", move: Moves.PARTING_SHOT },
])("$name should not allow wild pokemon to flee", async ({ move }) => {
game.override.moveset(Moves.SPLASH).enemyMoveset(move);
it("should pass the user's stat stages and BattlerTags to an ally", async () => {
await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]);
// reset species override so we get a different species
game.override.enemySpecies(Species.ARBOK);
game.move.select(Moves.SPLASH);
game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase");
const player = game.scene.getPlayerPokemon()!;
expect(player.species.speciesId).toBe(Species.SHUCKLE);
expect(player.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(game.phaseInterceptor.log).not.toContain("BattleEndPhase");
const enemy = game.scene.getEnemyPokemon()!;
expect(enemy.switchOutStatus).toBe(false);
expect(enemy.species.speciesId).toBe(Species.GENGAR);
});
it.each<{ name: string; move: Moves }>([
{ name: "Teleport", move: Moves.TELEPORT },
{ name: "Whirlwind", move: Moves.WHIRLWIND },
{ name: "Roar", move: Moves.ROAR },
{ name: "Dragon Tail", move: Moves.DRAGON_TAIL },
{ name: "Circle Throw", move: Moves.CIRCLE_THROW },
])("$name should allow wild pokemon to flee", async ({ move }) => {
game.override.moveset(move).enemyMoveset(move);
await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]);
const gengar = game.scene.getEnemyPokemon();
game.move.select(move);
game.doSelectPartyPokemon(1);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
game.move.select(Moves.NASTY_PLOT);
await game.toNextTurn();
expect(game.phaseInterceptor.log).not.toContain("BattleEndPhase");
expect(game.scene.getEnemyPokemon()).toBe(gengar);
const [raichu, shuckle] = game.scene.getPlayerParty();
expect(raichu.getStatStage(Stat.SPATK)).toEqual(2);
game.move.select(Moves.SUBSTITUTE);
await game.toNextTurn();
expect(raichu.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined();
game.move.select(Moves.BATON_PASS);
game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.getPlayerPokemon()).toBe(shuckle);
expect(shuckle.getStatStage(Stat.SPATK)).toEqual(2);
expect(shuckle.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined();
});
it.each<{ name: string; move?: Moves; enemyMove?: Moves }>([
{ name: "U-Turn", move: Moves.U_TURN },
{ name: "Flip Turn", move: Moves.FLIP_TURN },
{ name: "Volt Switch", move: Moves.VOLT_SWITCH },
// TODO: Enable once Parting shot is fixed
// {name: "Parting Shot", move: Moves.PARTING_SHOT},
{ name: "Dragon Tail", enemyMove: Moves.DRAGON_TAIL },
{ name: "Circle Throw", enemyMove: Moves.CIRCLE_THROW },
])(
"$name should not fail if no valid switch out target is found",
async ({ move = Moves.SPLASH, enemyMove = Moves.SPLASH }) => {
game.override.moveset(move).enemyMoveset(enemyMove);
await game.classicMode.startBattle([Species.RAICHU]);
it("should pass stat stages when used by enemy trainers", async () => {
game.override.battleType(BattleType.TRAINER).enemyMoveset([Moves.NASTY_PLOT, Moves.BATON_PASS]);
await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]);
game.move.select(move);
game.doSelectPartyPokemon(1);
await game.toNextTurn();
const enemy = game.scene.getEnemyPokemon()!;
expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase");
expect(game.scene.getPlayerPokemon()?.getLastXMoves()[0].result).toBe(MoveResult.MISS);
},
);
// round 1 - ai buffs
game.move.select(Moves.SPLASH);
await game.forceEnemyMove(Moves.NASTY_PLOT);
await game.toNextTurn();
it.each<{ name: string; move?: Moves; enemyMove?: Moves }>([
{ name: "Teleport", move: Moves.TELEPORT },
{ name: "Baton Pass", move: Moves.BATON_PASS },
{ name: "Shed Tail", move: Moves.SHED_TAIL },
{ name: "Roar", enemyMove: Moves.ROAR },
{ name: "Whirlwind", enemyMove: Moves.WHIRLWIND },
])(
"$name should fail if no valid switch out target is found",
async ({ move = Moves.SPLASH, enemyMove = Moves.SPLASH }) => {
game.override.moveset(move).enemyMoveset(enemyMove);
await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]);
game.move.select(Moves.SPLASH);
await game.forceEnemyMove(Moves.BATON_PASS);
await game.toNextTurn();
// reset species override so we get a different species
game.override.enemySpecies(Species.ARBOK);
// check buffs are still there
const newEnemy = game.scene.getEnemyPokemon()!;
expect(newEnemy).not.toBe(enemy);
expect(newEnemy.getStatStage(Stat.SPATK)).toBe(2);
expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase");
});
game.move.select(move);
game.doSelectPartyPokemon(1);
it("should not transfer non-transferrable effects", async () => {
game.override.enemyMoveset([Moves.SALT_CURE]);
await game.classicMode.startBattle([Species.PIKACHU, Species.FEEBAS]);
await game.toNextTurn();
const [player1, player2] = game.scene.getPlayerParty();
expect(game.phaseInterceptor.log).not.toContain("BattleEndPhase");
expect(game.scene.getEnemyPokemon()!.species.speciesId).toBe(Species.GENGAR);
},
);
game.move.select(Moves.BATON_PASS);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
describe("Baton Pass", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
// enemy salt cure
await game.phaseInterceptor.to("MoveEndPhase");
expect(player1.getTag(BattlerTagType.SALT_CURED)).toBeDefined();
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
game.doSelectPartyPokemon(1);
await game.toNextTurn();
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
expect(player1.isOnField()).toBe(false);
expect(player2.isOnField()).toBe(true);
expect(player2.getTag(BattlerTagType.SALT_CURED)).toBeUndefined();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.battleStyle("single")
.enemySpecies(Species.MAGIKARP)
.enemyAbility(Abilities.BALL_FETCH)
.moveset([Moves.BATON_PASS, Moves.NASTY_PLOT, Moves.SPLASH, Moves.SUBSTITUTE])
.ability(Abilities.BALL_FETCH)
.enemyMoveset(Moves.SPLASH)
.disableCrits();
});
it("should remove the user's binding effects", async () => {
game.override.moveset([Moves.FIRE_SPIN, Moves.BATON_PASS]);
it("should pass the user's stat stages and BattlerTags to an ally", async () => {
await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]);
await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]);
game.move.select(Moves.NASTY_PLOT);
await game.toNextTurn();
const enemy = game.scene.getEnemyPokemon()!;
const [raichu, shuckle] = game.scene.getPlayerParty();
expect(raichu.getStatStage(Stat.SPATK)).toEqual(2);
game.move.select(Moves.FIRE_SPIN);
await game.move.forceHit();
await game.toNextTurn();
game.move.select(Moves.SUBSTITUTE);
await game.toNextTurn();
expect(enemy.getTag(BattlerTagType.FIRE_SPIN)).toBeDefined();
expect(raichu.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined();
game.move.select(Moves.BATON_PASS);
game.doSelectPartyPokemon(1);
await game.toNextTurn();
game.move.select(Moves.BATON_PASS);
game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemy.getTag(BattlerTagType.FIRE_SPIN)).toBeUndefined();
});
});
expect(game.scene.getPlayerPokemon()).toBe(shuckle);
expect(shuckle.getStatStage(Stat.SPATK)).toEqual(2);
expect(shuckle.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined();
});
describe("Shed Tail", () => {
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
it("should pass stat stages when used by enemy trainers", async () => {
game.override.battleType(BattleType.TRAINER).enemyMoveset([Moves.NASTY_PLOT, Moves.BATON_PASS]);
await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]);
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.moveset(Moves.SHED_TAIL)
.battleStyle("single")
.enemySpecies(Species.SNORLAX)
.enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset(Moves.SPLASH);
});
const enemy = game.scene.getEnemyPokemon()!;
it("should consume 50% of the user's max HP (rounded up) to transfer a 25% HP Substitute doll", async () => {
await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]);
// round 1 - ai buffs
game.move.select(Moves.SPLASH);
await game.forceEnemyMove(Moves.NASTY_PLOT);
await game.toNextTurn();
const magikarp = game.scene.getPlayerPokemon()!;
game.move.select(Moves.SPLASH);
await game.forceEnemyMove(Moves.BATON_PASS);
await game.toNextTurn();
game.move.select(Moves.SHED_TAIL);
game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase", false);
// check buffs are still there
const newEnemy = game.scene.getEnemyPokemon()!;
expect(newEnemy).not.toBe(enemy);
expect(newEnemy.getStatStage(Stat.SPATK)).toBe(2);
expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase");
});
const feebas = game.scene.getPlayerPokemon()!;
expect(feebas).not.toBe(magikarp);
expect(feebas.hp).toBe(feebas.getMaxHp());
it("should not transfer non-transferrable effects", async () => {
game.override.enemyMoveset([Moves.SALT_CURE]);
await game.classicMode.startBattle([Species.PIKACHU, Species.FEEBAS]);
const substituteTag = feebas.getTag(SubstituteTag)!;
expect(substituteTag).toBeDefined();
const [player1, player2] = game.scene.getPlayerParty();
// Note: Altered the test to be consistent with the correct HP cost :yipeevee_static:
expect(magikarp.getInverseHp()).toBe(Math.ceil(magikarp.getMaxHp() / 2));
expect(substituteTag.hp).toBe(Math.floor(magikarp.getMaxHp() / 4));
});
game.move.select(Moves.BATON_PASS);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
it("should not transfer other effects", async () => {
await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]);
// enemy salt cure
await game.phaseInterceptor.to("MoveEndPhase");
expect(player1.getTag(BattlerTagType.SALT_CURED)).toBeDefined();
const magikarp = game.scene.getPlayerPokemon()!;
magikarp.setStatStage(Stat.ATK, 6);
game.doSelectPartyPokemon(1);
await game.toNextTurn();
game.move.select(Moves.SHED_TAIL);
game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase", false);
expect(player1.isOnField()).toBe(false);
expect(player2.isOnField()).toBe(true);
expect(player2.getTag(BattlerTagType.SALT_CURED)).toBeUndefined();
});
const feebas = game.scene.getPlayerPokemon()!;
expect(feebas).not.toBe(magikarp);
expect(feebas.getStatStage(Stat.ATK)).toBe(0);
expect(magikarp.getStatStage(Stat.ATK)).toBe(0);
});
it("removes the user's binding effects", async () => {
game.override.moveset([Moves.FIRE_SPIN, Moves.BATON_PASS]);
it("should fail if the user's HP is insufficient", async () => {
await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]);
await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]);
const magikarp = game.scene.getPlayerPokemon()!;
const initHp = toDmgValue(magikarp.getMaxHp() / 2 - 1);
magikarp.hp = initHp;
const enemy = game.scene.getEnemyPokemon()!;
game.move.select(Moves.SHED_TAIL);
await game.phaseInterceptor.to("TurnEndPhase", false);
game.move.select(Moves.FIRE_SPIN);
await game.move.forceHit();
await game.toNextTurn();
expect(enemy.getTag(BattlerTagType.FIRE_SPIN)).toBeDefined();
game.move.select(Moves.BATON_PASS);
game.doSelectPartyPokemon(1);
await game.toNextTurn();
expect(enemy.getTag(BattlerTagType.FIRE_SPIN)).toBeUndefined();
});
expect(magikarp.isOnField()).toBe(true);
expect(magikarp.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(magikarp.hp).toBe(initHp);
});
});
@ -664,7 +614,7 @@ describe("Moves - Switching Moves", () => {
});
});
describe("Shed Tail", () => {
describe("Failure Checks", () => {
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
@ -672,47 +622,97 @@ describe("Moves - Switching Moves", () => {
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.moveset(Moves.SHED_TAIL)
.battleStyle("single")
.enemySpecies(Species.SNORLAX)
.enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset(Moves.SPLASH);
.passiveAbility(Abilities.NO_GUARD)
.enemySpecies(Species.GENGAR)
.disableCrits()
.enemyAbility(Abilities.STURDY);
});
it("should consume 50% of the user's max HP (rounded up) to transfer a 25% HP Substitute doll", async () => {
await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]);
it.each<{ name: string; move: Moves }>([
{ name: "U-Turn", move: Moves.U_TURN },
{ name: "Flip Turn", move: Moves.FLIP_TURN },
{ name: "Volt Switch", move: Moves.VOLT_SWITCH },
{ name: "Baton Pass", move: Moves.BATON_PASS },
{ name: "Shed Tail", move: Moves.SHED_TAIL },
{ name: "Parting Shot", move: Moves.PARTING_SHOT },
])("$name should not allow wild pokemon to flee", async ({ move }) => {
game.override.moveset(Moves.SPLASH).enemyMoveset(move);
await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]);
const magikarp = game.scene.getPlayerPokemon()!;
const gengar = game.scene.getEnemyPokemon()!;
game.move.select(Moves.SPLASH);
await game.phaseInterceptor.to("TurnEndPhase");
game.move.select(Moves.SHED_TAIL);
expect(game.phaseInterceptor.log).not.toContain("BattleEndPhase");
const enemy = game.scene.getEnemyPokemon()!;
expect(enemy).toBe(gengar);
expect(enemy.switchOutStatus).toBe(false);
});
it.each<{ name: string; move?: Moves; enemyMove?: Moves }>([
{ name: "Teleport", enemyMove: Moves.TELEPORT },
{ name: "Whirlwind", move: Moves.WHIRLWIND },
{ name: "Roar", move: Moves.ROAR },
{ name: "Dragon Tail", move: Moves.DRAGON_TAIL },
{ name: "Circle Throw", move: Moves.CIRCLE_THROW },
])("$name should allow wild pokemon to flee", async ({ move = Moves.SPLASH, enemyMove = Moves.SPLASH }) => {
game.override.moveset(move).enemyMoveset(enemyMove);
await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]);
const gengar = game.scene.getEnemyPokemon();
game.move.select(move);
game.doSelectPartyPokemon(1);
await game.toNextTurn();
await game.phaseInterceptor.to("TurnEndPhase", false);
const feebas = game.scene.getPlayerPokemon()!;
expect(feebas).not.toBe(magikarp);
expect(feebas.hp).toBe(feebas.getMaxHp());
const substituteTag = feebas.getTag(SubstituteTag)!;
expect(substituteTag).toBeDefined();
// Note: Altered the test to be consistent with the correct HP cost :yipeevee_static:
expect(magikarp.getInverseHp()).toBe(Math.ceil(magikarp.getMaxHp() / 2));
expect(substituteTag.hp).toBe(Math.ceil(magikarp.getMaxHp() / 4));
expect(game.phaseInterceptor.log).toContain("BattleEndPhase");
expect(game.scene.getEnemyPokemon()).not.toBe(gengar);
});
it("should fail if user's HP is insufficient", async () => {
await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]);
it.each<{ name: string; move?: Moves; enemyMove?: Moves }>([
{ name: "U-Turn", move: Moves.U_TURN },
{ name: "Flip Turn", move: Moves.FLIP_TURN },
{ name: "Volt Switch", move: Moves.VOLT_SWITCH },
// TODO: Enable once Parting shot is fixed
// { name: "Parting Shot", move: Moves.PARTING_SHOT },
{ name: "Dragon Tail", enemyMove: Moves.DRAGON_TAIL },
{ name: "Circle Throw", enemyMove: Moves.CIRCLE_THROW },
])(
"$name should not fail if no valid switch out target is found",
async ({ move = Moves.SPLASH, enemyMove = Moves.SPLASH }) => {
game.override.moveset(move).enemyMoveset(enemyMove);
await game.classicMode.startBattle([Species.RAICHU]);
const magikarp = game.scene.getPlayerPokemon()!;
magikarp.hp = Math.floor(magikarp.getMaxHp() / 2 - 1);
game.move.select(move);
game.doSelectPartyPokemon(1);
await game.toNextTurn();
game.move.select(Moves.SHED_TAIL);
await game.phaseInterceptor.to("TurnEndPhase", false);
expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase");
const user = enemyMove === Moves.SPLASH ? game.scene.getPlayerPokemon()! : game.scene.getEnemyPokemon()!;
expect(user.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
},
);
expect(magikarp.isOnField()).toBe(true);
expect(magikarp.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(magikarp.hp).toBe(magikarp.getMaxHp() / 2 - 1);
});
it.each<{ name: string; move?: Moves; enemyMove?: Moves }>([
{ name: "Teleport", move: Moves.TELEPORT },
{ name: "Baton Pass", move: Moves.BATON_PASS },
{ name: "Shed Tail", move: Moves.SHED_TAIL },
{ name: "Roar", enemyMove: Moves.ROAR },
{ name: "Whirlwind", enemyMove: Moves.WHIRLWIND },
])(
"$name should fail if no valid switch out target is found",
async ({ move = Moves.SPLASH, enemyMove = Moves.SPLASH }) => {
game.override.moveset(move).enemyMoveset(enemyMove);
await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]);
game.move.select(move);
game.doSelectPartyPokemon(1);
await game.toNextTurn();
expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase");
const user = enemyMove === Moves.SPLASH ? game.scene.getPlayerPokemon()! : game.scene.getEnemyPokemon()!;
expect(user.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
},
);
});
});