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) { if (this.currentBattle.double === false) {
return; return;
} }
// TODO: Remove while loop
if (allyPokemon?.isActive(true)) { if (allyPokemon?.isActive(true)) {
let targetingMovePhase: MovePhase; let targetingMovePhase: MovePhase;
do { do {

View File

@ -6947,12 +6947,10 @@ export function initAbilities() {
.attr(PostDefendStatStageChangeAbAttr, (target, user, move) => move.category !== MoveCategory.STATUS, Stat.DEF, 1), .attr(PostDefendStatStageChangeAbAttr, (target, user, move) => move.category !== MoveCategory.STATUS, Stat.DEF, 1),
new Ability(Abilities.WIMP_OUT, 7) new Ability(Abilities.WIMP_OUT, 7)
.attr(PostDamageForceSwitchAbAttr) .attr(PostDamageForceSwitchAbAttr)
.condition(getSheerForceHitDisableAbCondition()) .condition(getSheerForceHitDisableAbCondition()),
.bypassFaint(), // allows Wimp Out to activate with Reviver Seed
new Ability(Abilities.EMERGENCY_EXIT, 7) new Ability(Abilities.EMERGENCY_EXIT, 7)
.attr(PostDamageForceSwitchAbAttr) .attr(PostDamageForceSwitchAbAttr)
.condition(getSheerForceHitDisableAbCondition()) .condition(getSheerForceHitDisableAbCondition()),
.bypassFaint(),
new Ability(Abilities.WATER_COMPACTION, 7) new Ability(Abilities.WATER_COMPACTION, 7)
.attr(PostDefendStatStageChangeAbAttr, (target, user, move) => user.getMoveType(move) === PokemonType.WATER && move.category !== MoveCategory.STATUS, Stat.DEF, 2), .attr(PostDefendStatStageChangeAbAttr, (target, user, move) => user.getMoveType(move) === PokemonType.WATER && move.category !== MoveCategory.STATUS, Stat.DEF, 2),
new Ability(Abilities.MERCILESS, 7) 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 * Removes a fraction of the user's maximum HP to create a substitute.
* @param user - The {@linkcode Pokemon} that used the move. * @param user - The {@linkcode Pokemon} using the move.
* @param target - n/a * @param target - n/a
* @param move - The {@linkcode Move} with this attribute. * @param move - The {@linkcode Move} with this attribute.
* @param args - n/a * @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.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 { getHpRatio(precise = false): number {
return precise ? this.hp / this.getMaxHp() : Math.round((this.hp / this.getMaxHp()) * 100) / 100; 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, isCritical = false,
ignoreSegments = false, ignoreSegments = false,
ignoreFaintPhase = false, ignoreFaintPhase = false,
}: }: {
{ result?: DamageResult;
result?: DamageResult, isCritical?: boolean;
isCritical?: boolean, ignoreSegments?: boolean;
ignoreSegments?: boolean, ignoreFaintPhase?: boolean;
ignoreFaintPhase?: boolean, } = {},
} = {}
): number { ): 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); const damagePhase = new DamageAnimPhase(this.getBattlerIndex(), damage, result, isCritical);
globalScene.unshiftPhase(damagePhase); globalScene.unshiftPhase(damagePhase);
@ -4923,7 +4927,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
* which already calls this function. * which already calls this function.
*/ */
resetSummonData(): void { 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; const illusion: IllusionData | null = this.summonData.illusion;
if (this.summonData.speciesForm) { if (this.summonData.speciesForm) {
this.summonData.speciesForm = null; this.summonData.speciesForm = null;
@ -4965,7 +4969,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
} }
resetTurnData(): void { resetTurnData(): void {
console.log(`resetTurnData called on Pokemon ${this.name}`) console.log(`resetTurnData called on Pokemon ${this.name}`);
this.turnData = new PokemonTurnData(); 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 // TODO: Review where this is being called and where it is necessary to call it
leaveField(clearEffects = true, hideInfo = true, destroy = false) { 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(); this.resetSprite();
globalScene globalScene
.getField(true) .getField(true)
.filter(p => p !== this) .filter(p => p !== this)
.forEach(p => p.removeTagsBySourceId(this.id)); .forEach(p => p.removeTagsBySourceId(this.id));
if (clearEffects) { if (clearEffects) {
@ -6717,7 +6721,6 @@ export class EnemyPokemon extends Pokemon {
return ret; return ret;
} }
/** /**
* Show or hide the type effectiveness multiplier window * Show or hide the type effectiveness multiplier window
* Passing undefined will hide the 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 { SwitchSummonPhase } from "./switch-summon-phase";
import { ToggleDoublePositionPhase } from "./toggle-double-position-phase"; import { ToggleDoublePositionPhase } from "./toggle-double-position-phase";
import { VictoryPhase } from "./victory-phase"; import { VictoryPhase } from "./victory-phase";
import { isNullOrUndefined } from "#app/utils/common";
import { FRIENDSHIP_LOSS_FROM_FAINT } from "#app/data/balance/starters"; import { FRIENDSHIP_LOSS_FROM_FAINT } from "#app/data/balance/starters";
import { BattlerTagType } from "#enums/battler-tag-type"; import { BattlerTagType } from "#enums/battler-tag-type";
import { isNullOrUndefined } from "#app/utils/common";
export class FaintPhase extends PokemonPhase { export class FaintPhase extends PokemonPhase {
/** /**
@ -118,6 +118,7 @@ export class FaintPhase extends PokemonPhase {
pokemon.resetTera(); pokemon.resetTera();
// TODO: This could be simplified greatly with the concept of "move being used"
if (pokemon.turnData.attacksReceived?.length) { if (pokemon.turnData.attacksReceived?.length) {
const lastAttack = pokemon.turnData.attacksReceived[0]; const lastAttack = pokemon.turnData.attacksReceived[0];
applyPostFaintAbAttrs( 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) { 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(); const legalPlayerPokemon = globalScene.getPokemonAllowedInBattle();
/** The total number of legal player Pokemon that aren't currently on the field */ if (legalPlayerPokemon.length === 0) {
const legalPlayerPartyPokemon = legalPlayerPokemon.filter(p => !p.isActive(true)); // If the player doesn't have any legal Pokemon left in their party, end the game.
if (!legalPlayerPokemon.length) {
/** If the player doesn't have any legal Pokemon, end the game */
globalScene.unshiftPhase(new GameOverPhase()); globalScene.unshiftPhase(new GameOverPhase());
} else if ( } else if (globalScene.currentBattle.double && legalBackupPokemon.length === 0) {
globalScene.currentBattle.double && /*
legalPlayerPokemon.length === 1 && Otherwise, if the player has no reserve members left to switch in,
legalPlayerPartyPokemon.length === 0 unshift a phase to move the other on-field pokemon to center position.
) { */
/**
* 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.
*/
globalScene.unshiftPhase(new ToggleDoublePositionPhase(true)); globalScene.unshiftPhase(new ToggleDoublePositionPhase(true));
} else if (legalPlayerPartyPokemon.length > 0) { } else {
/** // If previous conditions weren't met, push a phase to prompt the player to select a pokemon from their party.
* 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.
*/
globalScene.pushPhase(new SwitchPhase(SwitchType.SWITCH, this.fieldIndex, true, false)); globalScene.pushPhase(new SwitchPhase(SwitchType.SWITCH, this.fieldIndex, true, false));
} }
} else { } else {
// Unshift a phase for EXP gains and/or one to switch in a replacement party member.
globalScene.unshiftPhase(new VictoryPhase(this.battlerIndex)); globalScene.unshiftPhase(new VictoryPhase(this.battlerIndex));
if ([BattleType.TRAINER, BattleType.MYSTERY_ENCOUNTER].includes(globalScene.currentBattle.battleType)) { if (
const hasReservePartyMember = !!globalScene [BattleType.TRAINER, BattleType.MYSTERY_ENCOUNTER].includes(globalScene.currentBattle.battleType) &&
.getEnemyParty() legalBackupPokemon.length > 0
.filter(p => p.isActive() && !p.isOnField() && p.trainerSlot === (pokemon as EnemyPokemon).trainerSlot) ) {
.length; globalScene.pushPhase(new SwitchSummonPhase(SwitchType.SWITCH, this.fieldIndex, -1, false, false));
if (hasReservePartyMember) {
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? // TODO: Why do we trigger these attributes even if the switch in target doesn't exist?
// (This should almost certainly go somewhere inside `preSummon`) // (This should almost certainly go somewhere inside `preSummon`)
applyPreSummonAbAttrs(PreSummonAbAttr, switchedInPokemon); applyPreSummonAbAttrs(PreSummonAbAttr, switchInPokemon);
applyPreSwitchOutAbAttrs(PreSwitchOutAbAttr, this.lastPokemon); applyPreSwitchOutAbAttrs(PreSwitchOutAbAttr, this.lastPokemon);
if (!switchInPokemon) { if (!switchInPokemon) {
this.end(); 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 { globalScene } from "#app/global-scene";
import { Abilities } from "#enums/abilities"; import { Abilities } from "#enums/abilities";
import { ArenaTagType } from "#enums/arena-tag-type"; import { ArenaTagType } from "#enums/arena-tag-type";
import { BattleType } from "#enums/battle-type";
import { Moves } from "#enums/moves"; import { Moves } from "#enums/moves";
import { Species } from "#enums/species"; import { Species } from "#enums/species";
import GameManager from "#test/testUtils/gameManager"; import GameManager from "#test/testUtils/gameManager";
@ -39,31 +40,28 @@ describe("Abilities - Mold Breaker", () => {
game.override.startingLevel(100).enemyLevel(2).enemyAbility(Abilities.STURDY); game.override.startingLevel(100).enemyLevel(2).enemyAbility(Abilities.STURDY);
await game.classicMode.startBattle([Species.MAGIKARP]); await game.classicMode.startBattle([Species.MAGIKARP]);
const player = game.scene.getPlayerPokemon()!;
game.move.select(Moves.ERUPTION); 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); expect(globalScene.arena.ignoreAbilities).toBe(false);
game.move.select(Moves.SPLASH); game.move.select(Moves.SPLASH);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase"); 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"); 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 () => { 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.TOXIC_SPIKES, -1, Moves.TOXIC_SPIKES, 1, ArenaTagSide.ENEMY);
game.scene.arena.addTag(ArenaTagType.SPIKES, -1, Moves.CEASELESS_EDGE, 1, ArenaTagSide.ENEMY); game.scene.arena.addTag(ArenaTagType.SPIKES, -1, Moves.CEASELESS_EDGE, 1, ArenaTagSide.ENEMY);
await game.classicMode.startBattle([Species.MAGIKARP]); await game.classicMode.startBattle([Species.MAGIKARP]);
@ -71,7 +69,7 @@ describe("Abilities - Mold Breaker", () => {
const [weezing1, weezing2] = game.scene.getEnemyParty(); const [weezing1, weezing2] = game.scene.getEnemyParty();
// Weezing's levitate prevented removal of Toxic Spikes, ignored Spikes damage // Weezing's levitate prevented removal of Toxic Spikes, ignored Spikes damage
expect(game.scene.arena.getTagOnSide(ArenaTagType.TOXIC_SPIKES, ArenaTagSide.ENEMY)).toBeDefined(); 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); game.move.select(Moves.DRAGON_TAIL);
await game.phaseInterceptor.to("TurnEndPhase"); 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 // Levitate was ignored during the switch, causing Toxic Spikes to be removed and Spikes to deal damage
expect(weezing1.isOnField()).toBe(false); expect(weezing1.isOnField()).toBe(false);
expect(weezing2.isOnField()).toBe(true); 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(); 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 { BattleType } from "#enums/battle-type";
import { TrainerSlot } from "#enums/trainer-slot"; import { TrainerSlot } from "#enums/trainer-slot";
import { TrainerType } from "#enums/trainer-type"; 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 { BattlerTagType } from "#enums/battler-tag-type";
import { MoveResult } from "#app/field/pokemon"; import { MoveResult } from "#app/field/pokemon";
import { SubstituteTag } from "#app/data/battler-tags"; 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", () => { describe("Moves - Switching Moves", () => {
let phaserGame: Phaser.Game; let phaserGame: Phaser.Game;
@ -25,7 +29,7 @@ describe("Moves - Switching Moves", () => {
}); });
}); });
describe("Target Switch Moves", () => { describe("Force Switch Moves", () => {
afterEach(() => { afterEach(() => {
game.phaseInterceptor.restoreOg(); game.phaseInterceptor.restoreOg();
}); });
@ -34,8 +38,8 @@ describe("Moves - Switching Moves", () => {
game = new GameManager(phaserGame); game = new GameManager(phaserGame);
game.override game.override
.battleStyle("single") .battleStyle("single")
.ability(Abilities.NO_GUARD) .passiveAbility(Abilities.NO_GUARD)
.moveset([Moves.DRAGON_TAIL, Moves.SPLASH, Moves.FLAMETHROWER]) .moveset([Moves.DRAGON_TAIL, Moves.SPLASH, Moves.FLAMETHROWER, Moves.FOCUS_PUNCH])
.enemySpecies(Species.WAILORD) .enemySpecies(Species.WAILORD)
.enemyMoveset(Moves.SPLASH); .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 () => { it("should force trainers to switch randomly without selecting from a partner's party", async () => {
game.override game.override
.battleStyle("double") .battleStyle("double")
.enemyMoveset(Moves.SPLASH)
.enemyAbility(Abilities.STURDY) .enemyAbility(Abilities.STURDY)
.battleType(BattleType.TRAINER) .battleType(BattleType.TRAINER)
.randomTrainer({ trainerType: TrainerType.TATE, alwaysDouble: true }) .randomTrainer({ trainerType: TrainerType.TATE, alwaysDouble: true })
.enemySpecies(0); .enemySpecies(0);
await game.classicMode.startBattle([Species.WIMPOD, Species.TYRANITAR]); 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 // Grab each trainer's pokemon based on species name
const [tateParty, lizaParty] = splitArray( const [tateParty, lizaParty] = splitArray(
game.scene.getEnemyParty(), game.scene.getEnemyParty(),
@ -95,9 +101,6 @@ describe("Moves - Switching Moves", () => {
// as Tate's pokemon are placed immediately before Liza's corresponding members. // as Tate's pokemon are placed immediately before Liza's corresponding members.
vi.fn(Phaser.Math.RND.integerInRange).mockImplementation(min => min); 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.DRAGON_TAIL, BattlerIndex.PLAYER, BattlerIndex.ENEMY_2);
game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2); game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2);
await game.phaseInterceptor.to("BerryPhase"); 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 () => { it("should force wild Pokemon to flee and redirect moves accordingly", async () => {
game.override.battleStyle("double").enemyMoveset(Moves.SPLASH).enemyAbility(Abilities.ROUGH_SKIN); game.override.battleStyle("double").enemyMoveset(Moves.SPLASH);
await game.classicMode.startBattle([Species.DRATINI, Species.DRATINI, Species.WAILORD, Species.WAILORD]); await game.classicMode.startBattle([Species.DRATINI, Species.DRATINI]);
const leadPokemon = game.scene.getPlayerParty()[0]!; const [enemyLeadPokemon, enemySecPokemon] = game.scene.getEnemyParty();
const secPokemon = game.scene.getPlayerParty()[1]!;
const enemyLeadPokemon = game.scene.getEnemyParty()[0]!; game.move.select(Moves.DRAGON_TAIL, BattlerIndex.PLAYER, BattlerIndex.ENEMY);
const enemySecPokemon = game.scene.getEnemyParty()[1]!;
game.move.select(Moves.DRAGON_TAIL, 0, BattlerIndex.ENEMY);
// target the same pokemon, second move should be redirected after first flees // 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"); await game.phaseInterceptor.to("BerryPhase");
const isVisibleLead = enemyLeadPokemon.visible; expect(enemyLeadPokemon.visible).toBe(false);
const hasFledLead = enemyLeadPokemon.switchOutStatus; expect(enemyLeadPokemon.switchOutStatus).toBe(true);
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(enemySecPokemon.hp).toBeLessThan(enemySecPokemon.getMaxHp()); expect(enemySecPokemon.hp).toBeLessThan(enemySecPokemon.getMaxHp());
}); });
@ -153,7 +147,7 @@ describe("Moves - Switching Moves", () => {
expect(enemy.isFullHp()).toBe(false); expect(enemy.isFullHp()).toBe(false);
// Turn 2: Mold Breaker should ignore switch blocking ability and switch out the target // 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(); enemy.hp = enemy.getMaxHp();
game.move.select(Moves.DRAGON_TAIL); game.move.select(Moves.DRAGON_TAIL);
@ -178,54 +172,53 @@ describe("Moves - Switching Moves", () => {
expect(dondozo1.isFullHp()).toBe(false); expect(dondozo1.isFullHp()).toBe(false);
}); });
it("should force a switch upon fainting an opponent normally", async () => { it("should perform a normal switch upon fainting an opponent", async () => {
game.override.startingWave(5).startingLevel(1000); // To make sure Dragon Tail KO's the opponent game.override.battleType(BattleType.TRAINER).startingLevel(1000); // To make sure Dragon Tail KO's the opponent
await game.classicMode.startBattle([Species.DRATINI]); 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); game.move.select(Moves.DRAGON_TAIL);
await game.toNextTurn(); await game.toNextTurn();
// Make sure the enemy switched to a healthy Pokemon
const enemy = game.scene.getEnemyPokemon()!; const enemy = game.scene.getEnemyPokemon()!;
expect(enemy).toBeDefined(); expect(enemy).toBeDefined();
expect(enemy.isFullHp()).toBe(true); expect(enemy.isFullHp()).toBe(true);
// Make sure the enemy has a fainted Pokemon in their party and not on the field expect(choiceSwitchSpy).toHaveBeenCalledTimes(1);
const faintedEnemy = game.scene.getEnemyParty().find(p => !p.isAllowedInBattle());
expect(faintedEnemy).toBeDefined();
expect(game.scene.getEnemyField().length).toBe(1);
}); });
it("should neither switch nor softlock when activating an opponent's reviver seed", async () => { it("should neither switch nor softlock when activating an opponent's reviver seed", async () => {
game.override game.override
.battleType(BattleType.TRAINER) .battleType(BattleType.TRAINER)
.enemyHeldItems([{ name: "REVIVER_SEED" }]) .enemySpecies(Species.BLISSEY)
.startingLevel(1000); // make sure Dragon Tail KO's the opponent .enemyHeldItems([{ name: "REVIVER_SEED" }]);
await game.classicMode.startBattle([Species.DRATINI]); await game.classicMode.startBattle([Species.DRATINI]);
const [wailord1, wailord2] = game.scene.getEnemyParty()!; const [blissey1, blissey2] = game.scene.getEnemyParty()!;
expect(wailord1).toBeDefined(); expect(blissey1).toBeDefined();
expect(wailord2).toBeDefined(); expect(blissey2).toBeDefined();
blissey1.hp = 1;
game.move.select(Moves.DRAGON_TAIL); game.move.select(Moves.DRAGON_TAIL);
await game.toNextTurn(); await game.toNextTurn();
// Wailord should have consumed the reviver seed and stayed on field // Bliseey #1 should have consumed the reviver seed and stayed on field
expect(wailord1.isOnField()).toBe(true); expect(blissey1.isOnField()).toBe(true);
expect(wailord1.getHpRatio()).toBeCloseTo(0.5); expect(blissey1.getHpRatio()).toBeCloseTo(0.5);
expect(wailord1.getHeldItems()).toHaveLength(0); expect(blissey1.getHeldItems()).toHaveLength(0);
expect(wailord2.isOnField()).toBe(false); expect(blissey2.isOnField()).toBe(false);
}); });
it("should neither switch nor softlock when activating a player's reviver seed", async () => { it("should neither switch nor softlock when activating a player's reviver seed", async () => {
game.override game.override
.startingHeldItems([{ name: "REVIVER_SEED" }]) .startingHeldItems([{ name: "REVIVER_SEED" }])
.enemyMoveset(Moves.DRAGON_TAIL) .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]); await game.classicMode.startBattle([Species.BLISSEY, Species.BULBASAUR]);
const [blissey, bulbasaur] = game.scene.getPlayerParty(); const [blissey, bulbasaur] = game.scene.getPlayerParty();
blissey.hp = 1;
game.move.select(Moves.SPLASH); game.move.select(Moves.SPLASH);
await game.toNextTurn(); await game.toNextTurn();
@ -279,6 +272,13 @@ describe("Moves - Switching Moves", () => {
const newEnemy = game.scene.getEnemyPokemon()!; const newEnemy = game.scene.getEnemyPokemon()!;
expect(newEnemy).not.toBe(enemy); expect(newEnemy).not.toBe(enemy);
expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase"); 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( expect(game.textInterceptor.logs).not.toContain(
i18next.t("battle:trainerGo", { i18next.t("battle:trainerGo", {
trainerName: game.scene.currentBattle.trainer?.getName(newEnemy.trainerSlot), trainerName: game.scene.currentBattle.trainer?.getName(newEnemy.trainerSlot),
@ -338,225 +338,175 @@ describe("Moves - Switching Moves", () => {
}); });
}); });
describe("Failure Checks", () => { describe("Baton Pass", () => {
afterEach(() => { afterEach(() => {
game.phaseInterceptor.restoreOg(); game.phaseInterceptor.restoreOg();
}); });
beforeEach(() => { beforeEach(() => {
game = new GameManager(phaserGame); 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 }>([ it("should pass the user's stat stages and BattlerTags to an ally", async () => {
{ 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]); await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]);
// reset species override so we get a different species game.move.select(Moves.NASTY_PLOT);
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]);
await game.toNextTurn(); await game.toNextTurn();
expect(game.phaseInterceptor.log).not.toContain("BattleEndPhase"); const [raichu, shuckle] = game.scene.getPlayerParty();
expect(game.scene.getEnemyPokemon()).toBe(gengar); 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 }>([ it("should pass stat stages when used by enemy trainers", async () => {
{ name: "U-Turn", move: Moves.U_TURN }, game.override.battleType(BattleType.TRAINER).enemyMoveset([Moves.NASTY_PLOT, Moves.BATON_PASS]);
{ name: "Flip Turn", move: Moves.FLIP_TURN }, await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]);
{ 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]);
game.move.select(move); const enemy = game.scene.getEnemyPokemon()!;
game.doSelectPartyPokemon(1);
await game.toNextTurn();
expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase"); // round 1 - ai buffs
expect(game.scene.getPlayerPokemon()?.getLastXMoves()[0].result).toBe(MoveResult.MISS); game.move.select(Moves.SPLASH);
}, await game.forceEnemyMove(Moves.NASTY_PLOT);
); await game.toNextTurn();
it.each<{ name: string; move?: Moves; enemyMove?: Moves }>([ game.move.select(Moves.SPLASH);
{ name: "Teleport", move: Moves.TELEPORT }, await game.forceEnemyMove(Moves.BATON_PASS);
{ name: "Baton Pass", move: Moves.BATON_PASS }, await game.toNextTurn();
{ 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]);
// reset species override so we get a different species // check buffs are still there
game.override.enemySpecies(Species.ARBOK); 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); it("should not transfer non-transferrable effects", async () => {
game.doSelectPartyPokemon(1); 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"); game.move.select(Moves.BATON_PASS);
expect(game.scene.getEnemyPokemon()!.species.speciesId).toBe(Species.GENGAR); await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
},
);
describe("Baton Pass", () => { // enemy salt cure
let phaserGame: Phaser.Game; await game.phaseInterceptor.to("MoveEndPhase");
let game: GameManager; expect(player1.getTag(BattlerTagType.SALT_CURED)).toBeDefined();
beforeAll(() => { game.doSelectPartyPokemon(1);
phaserGame = new Phaser.Game({ await game.toNextTurn();
type: Phaser.HEADLESS,
});
});
afterEach(() => { expect(player1.isOnField()).toBe(false);
game.phaseInterceptor.restoreOg(); expect(player2.isOnField()).toBe(true);
}); expect(player2.getTag(BattlerTagType.SALT_CURED)).toBeUndefined();
});
beforeEach(() => { it("should remove the user's binding effects", async () => {
game = new GameManager(phaserGame); game.override.moveset([Moves.FIRE_SPIN, Moves.BATON_PASS]);
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 pass the user's stat stages and BattlerTags to an ally", async () => { await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]);
await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]);
game.move.select(Moves.NASTY_PLOT); const enemy = game.scene.getEnemyPokemon()!;
await game.toNextTurn();
const [raichu, shuckle] = game.scene.getPlayerParty(); game.move.select(Moves.FIRE_SPIN);
expect(raichu.getStatStage(Stat.SPATK)).toEqual(2); await game.move.forceHit();
await game.toNextTurn();
game.move.select(Moves.SUBSTITUTE); expect(enemy.getTag(BattlerTagType.FIRE_SPIN)).toBeDefined();
await game.toNextTurn();
expect(raichu.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined(); game.move.select(Moves.BATON_PASS);
game.doSelectPartyPokemon(1);
await game.toNextTurn();
game.move.select(Moves.BATON_PASS); expect(enemy.getTag(BattlerTagType.FIRE_SPIN)).toBeUndefined();
game.doSelectPartyPokemon(1); });
await game.phaseInterceptor.to("TurnEndPhase"); });
expect(game.scene.getPlayerPokemon()).toBe(shuckle); describe("Shed Tail", () => {
expect(shuckle.getStatStage(Stat.SPATK)).toEqual(2); afterEach(() => {
expect(shuckle.getTag(BattlerTagType.SUBSTITUTE)).toBeDefined(); game.phaseInterceptor.restoreOg();
}); });
it("should pass stat stages when used by enemy trainers", async () => { beforeEach(() => {
game.override.battleType(BattleType.TRAINER).enemyMoveset([Moves.NASTY_PLOT, Moves.BATON_PASS]); game = new GameManager(phaserGame);
await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]); 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 const magikarp = game.scene.getPlayerPokemon()!;
game.move.select(Moves.SPLASH);
await game.forceEnemyMove(Moves.NASTY_PLOT);
await game.toNextTurn();
game.move.select(Moves.SPLASH); game.move.select(Moves.SHED_TAIL);
await game.forceEnemyMove(Moves.BATON_PASS); game.doSelectPartyPokemon(1);
await game.toNextTurn(); await game.phaseInterceptor.to("TurnEndPhase", false);
// check buffs are still there const feebas = game.scene.getPlayerPokemon()!;
const newEnemy = game.scene.getEnemyPokemon()!; expect(feebas).not.toBe(magikarp);
expect(newEnemy).not.toBe(enemy); expect(feebas.hp).toBe(feebas.getMaxHp());
expect(newEnemy.getStatStage(Stat.SPATK)).toBe(2);
expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase");
});
it("should not transfer non-transferrable effects", async () => { const substituteTag = feebas.getTag(SubstituteTag)!;
game.override.enemyMoveset([Moves.SALT_CURE]); expect(substituteTag).toBeDefined();
await game.classicMode.startBattle([Species.PIKACHU, Species.FEEBAS]);
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); it("should not transfer other effects", async () => {
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]);
// enemy salt cure const magikarp = game.scene.getPlayerPokemon()!;
await game.phaseInterceptor.to("MoveEndPhase"); magikarp.setStatStage(Stat.ATK, 6);
expect(player1.getTag(BattlerTagType.SALT_CURED)).toBeDefined();
game.doSelectPartyPokemon(1); game.move.select(Moves.SHED_TAIL);
await game.toNextTurn(); game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase", false);
expect(player1.isOnField()).toBe(false); const feebas = game.scene.getPlayerPokemon()!;
expect(player2.isOnField()).toBe(true); expect(feebas).not.toBe(magikarp);
expect(player2.getTag(BattlerTagType.SALT_CURED)).toBeUndefined(); expect(feebas.getStatStage(Stat.ATK)).toBe(0);
}); expect(magikarp.getStatStage(Stat.ATK)).toBe(0);
});
it("removes the user's binding effects", async () => { it("should fail if the user's HP is insufficient", async () => {
game.override.moveset([Moves.FIRE_SPIN, Moves.BATON_PASS]); 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); expect(magikarp.isOnField()).toBe(true);
await game.move.forceHit(); expect(magikarp.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
await game.toNextTurn(); expect(magikarp.hp).toBe(initHp);
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();
});
}); });
}); });
@ -664,7 +614,7 @@ describe("Moves - Switching Moves", () => {
}); });
}); });
describe("Shed Tail", () => { describe("Failure Checks", () => {
afterEach(() => { afterEach(() => {
game.phaseInterceptor.restoreOg(); game.phaseInterceptor.restoreOg();
}); });
@ -672,47 +622,97 @@ describe("Moves - Switching Moves", () => {
beforeEach(() => { beforeEach(() => {
game = new GameManager(phaserGame); game = new GameManager(phaserGame);
game.override game.override
.moveset(Moves.SHED_TAIL)
.battleStyle("single") .battleStyle("single")
.enemySpecies(Species.SNORLAX) .passiveAbility(Abilities.NO_GUARD)
.enemyAbility(Abilities.BALL_FETCH) .enemySpecies(Species.GENGAR)
.enemyMoveset(Moves.SPLASH); .disableCrits()
.enemyAbility(Abilities.STURDY);
}); });
it("should consume 50% of the user's max HP (rounded up) to transfer a 25% HP Substitute doll", async () => { it.each<{ name: string; move: Moves }>([
await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]); { 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); game.doSelectPartyPokemon(1);
await game.toNextTurn();
await game.phaseInterceptor.to("TurnEndPhase", false); expect(game.phaseInterceptor.log).toContain("BattleEndPhase");
expect(game.scene.getEnemyPokemon()).not.toBe(gengar);
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));
}); });
it("should fail if user's HP is insufficient", async () => { it.each<{ name: string; move?: Moves; enemyMove?: Moves }>([
await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]); { 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()!; game.move.select(move);
magikarp.hp = Math.floor(magikarp.getMaxHp() / 2 - 1); game.doSelectPartyPokemon(1);
await game.toNextTurn();
game.move.select(Moves.SHED_TAIL); expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase");
await game.phaseInterceptor.to("TurnEndPhase", false); const user = enemyMove === Moves.SPLASH ? game.scene.getPlayerPokemon()! : game.scene.getEnemyPokemon()!;
expect(user.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
},
);
expect(magikarp.isOnField()).toBe(true); it.each<{ name: string; move?: Moves; enemyMove?: Moves }>([
expect(magikarp.getLastXMoves()[0].result).toBe(MoveResult.FAIL); { name: "Teleport", move: Moves.TELEPORT },
expect(magikarp.hp).toBe(magikarp.getMaxHp() / 2 - 1); { 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);
},
);
}); });
}); });