Compare commits

...

11 Commits

Author SHA1 Message Date
Bertie690
3f31f0e13e
Merge b393cf8914 into 1ff2701964 2025-06-19 22:29:37 -04:00
lnuvy
1ff2701964
[Bug] Fix when using arrow keys in Pokédex after catching a Pokémon from mystery event (#6000)
fix: wrap setOverlayMode args in array to mystery-encounter

Co-authored-by: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com>
2025-06-19 20:45:54 -04:00
Bertie690
1e306e25b5
[Move] Fixed Chilly Reception displaying message when used virtually
https://github.com/pagefaultgames/pokerogue/pull/5843

* Fixed Chilly Reception displaying message when used virtually

* Fixed lack of message causing Chilly Reception to fail

* Fixed tests

* Reverted bool change + fixed test

* Fixed test
2025-06-19 17:14:05 -07:00
Bertie690
b393cf8914
Merge branch 'beta' into test-cleanup 2025-06-19 20:05:44 -04:00
Madmadness65
43aa772603
[UI/UX] Add Pokémon category flavor text to Pokédex (#5957)
* Add Pokémon category flavor text to Pokédex

* Append `_category` to locale entry
2025-06-20 00:04:57 +00:00
Bertie690
f231755db2 fixed using sizzly slide instead of will o wisp to inflict burn 2025-06-17 18:27:27 -04:00
Bertie690
6d777bb7c8 Fixed innards out 2025-06-17 17:14:44 -04:00
Bertie690
fcd270ed9b fixed tests 2025-06-17 17:08:43 -04:00
Bertie690
86a3f6d711 Merge remote-tracking branch 'upstream/beta' into test-cleanup 2025-06-17 17:03:40 -04:00
Bertie690
12a626d2a5 Fixde magic guard aftermath 2025-06-15 10:25:49 -04:00
Bertie690
be3c42a531 Fixed tests for Magic Guard, Battle phase; fixed innards out stuff 2025-06-13 13:45:54 -04:00
12 changed files with 376 additions and 504 deletions

View File

@ -6542,13 +6542,26 @@ export class PostFaintContactDamageAbAttr extends PostFaintAbAttr {
_hitResult?: HitResult, _hitResult?: HitResult,
..._args: any[] ..._args: any[]
): boolean { ): boolean {
const diedToDirectDamage = // Return early if ability user did not die to a direct-contact attack.
move !== undefined && if (
attacker !== undefined && move === undefined ||
move.doesFlagEffectApply({ flag: MoveFlags.MAKES_CONTACT, user: attacker, target: pokemon }); attacker === undefined ||
!move.doesFlagEffectApply({ flag: MoveFlags.MAKES_CONTACT, user: attacker, target: pokemon })
) {
return false;
}
const cancelled = new BooleanHolder(false); const cancelled = new BooleanHolder(false);
globalScene.getField(true).map(p => applyAbAttrs("FieldPreventExplosiveMovesAbAttr", p, cancelled, simulated)); // TODO: This should be in speed order
return !(!diedToDirectDamage || cancelled.value || attacker!.hasAbilityWithAttr("BlockNonDirectDamageAbAttr")); globalScene.getField(true).forEach(p => applyAbAttrs("FieldPreventExplosiveMovesAbAttr", p, cancelled, simulated));
if (cancelled.value) {
return false;
}
// TODO: Does aftermath display text if the attacker has Magic Guard?
applyAbAttrs("BlockNonDirectDamageAbAttr", attacker, cancelled);
return !cancelled.value;
} }
override applyPostFaint( override applyPostFaint(
@ -6557,15 +6570,15 @@ export class PostFaintContactDamageAbAttr extends PostFaintAbAttr {
simulated: boolean, simulated: boolean,
attacker?: Pokemon, attacker?: Pokemon,
_move?: Move, _move?: Move,
_hitResult?: HitResult,
..._args: any[]
): void { ): void {
if (!simulated) { if (simulated) {
attacker!.damageAndUpdate(toDmgValue(attacker!.getMaxHp() * (1 / this.damageRatio)), { return;
result: HitResult.INDIRECT,
});
attacker!.turnData.damageTaken += toDmgValue(attacker!.getMaxHp() * (1 / this.damageRatio));
} }
attacker!.damageAndUpdate(toDmgValue(attacker!.getMaxHp() * (1 / this.damageRatio)), {
result: HitResult.INDIRECT,
});
attacker!.turnData.damageTaken += toDmgValue(attacker!.getMaxHp() * (1 / this.damageRatio));
} }
getTriggerMessage(pokemon: Pokemon, abilityName: string, ..._args: any[]): string { getTriggerMessage(pokemon: Pokemon, abilityName: string, ..._args: any[]): string {
@ -6577,27 +6590,38 @@ export class PostFaintContactDamageAbAttr extends PostFaintAbAttr {
} }
/** /**
* Attribute used for abilities (Innards Out) that damage the opponent based on how much HP the last attack used to knock out the owner of the ability. * Attribute used for abilities that damage an opponent who faints the ability holder
* equal to the amount of damage the last attack inflicted.
*
* Used for {@linkcode Abilities.INNARDS_OUT}.
*/ */
export class PostFaintHPDamageAbAttr extends PostFaintAbAttr { export class PostFaintHPDamageAbAttr extends PostFaintAbAttr {
override applyPostFaint( override applyPostFaint(
pokemon: Pokemon, pokemon: Pokemon,
_passive: boolean, _passive: boolean,
simulated: boolean, _simulated: boolean,
attacker?: Pokemon, attacker?: Pokemon,
move?: Move, move?: Move,
_hitResult?: HitResult,
..._args: any[]
): void { ): void {
//If the mon didn't die to indirect damage // return early if the user died to indirect damage, target has magic guard or was KO'd by an ally
if (move !== undefined && attacker !== undefined && !simulated) { if (move === undefined || attacker === undefined || attacker.getAlly() === pokemon) {
const damage = pokemon.turnData.attacksReceived[0].damage; return;
attacker.damageAndUpdate(damage, { result: HitResult.INDIRECT });
attacker.turnData.damageTaken += damage;
} }
// TODO: Confirm that magic guard's flyout shows here?
const cancelled = new BooleanHolder(false);
applyAbAttrs("BlockNonDirectDamageAbAttr", attacker, cancelled);
if (cancelled.value) {
return;
}
const damage = pokemon.turnData.attacksReceived[0].damage;
attacker.damageAndUpdate(damage, { result: HitResult.INDIRECT });
attacker.turnData.damageTaken += damage;
} }
getTriggerMessage(pokemon: Pokemon, abilityName: string, ..._args: any[]): string { // Oddly, Innards Out still shows a flyout if the effect was blocked due to Magic Guard...
override getTriggerMessage(pokemon: Pokemon, abilityName: string): string {
return i18next.t("abilityTriggers:postFaintHpDamage", { return i18next.t("abilityTriggers:postFaintHpDamage", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
abilityName, abilityName,

View File

@ -93,6 +93,10 @@ import { ChargingMove, MoveAttrMap, MoveAttrString, MoveKindString, MoveClassMap
import { applyMoveAttrs } from "./apply-attrs"; import { applyMoveAttrs } from "./apply-attrs";
import { frenzyMissFunc, getMoveTargets } from "./move-utils"; import { frenzyMissFunc, getMoveTargets } from "./move-utils";
/**
* A function used to conditionally determine execution of a given {@linkcode MoveAttr}.
* Conventionally returns `true` for success and `false` for failure.
*/
type MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => boolean; type MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => boolean;
export type UserMoveConditionFunc = (user: Pokemon, move: Move) => boolean; export type UserMoveConditionFunc = (user: Pokemon, move: Move) => boolean;
@ -1390,18 +1394,31 @@ export class BeakBlastHeaderAttr extends AddBattlerTagHeaderAttr {
} }
} }
/**
* Attribute to display a message before a move is executed.
*/
export class PreMoveMessageAttr extends MoveAttr { export class PreMoveMessageAttr extends MoveAttr {
private message: string | ((user: Pokemon, target: Pokemon, move: Move) => string); /** The message to display or a function returning one */
private message: string | ((user: Pokemon, target: Pokemon, move: Move) => string | undefined);
/**
* Create a new {@linkcode PreMoveMessageAttr} to display a message before move execution.
* @param message - The message to display before move use, either as a string or a function producing one.
* @remarks
* If {@linkcode message} evaluates to an empty string (`''`), no message will be displayed
* (though the move will still succeed).
*/
constructor(message: string | ((user: Pokemon, target: Pokemon, move: Move) => string)) { constructor(message: string | ((user: Pokemon, target: Pokemon, move: Move) => string)) {
super(); super();
this.message = message; this.message = message;
} }
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { apply(user: Pokemon, target: Pokemon, move: Move, _args: any[]): boolean {
const message = typeof this.message === "string" const message = typeof this.message === "function"
? this.message as string ? this.message(user, target, move)
: this.message(user, target, move); : this.message;
// TODO: Consider changing if/when MoveAttr `apply` return values become significant
if (message) { if (message) {
globalScene.phaseManager.queueMessage(message, 500); globalScene.phaseManager.queueMessage(message, 500);
return true; return true;
@ -11299,7 +11316,11 @@ export function initMoves() {
.attr(ForceSwitchOutAttr, true, SwitchType.SHED_TAIL) .attr(ForceSwitchOutAttr, true, SwitchType.SHED_TAIL)
.condition(failIfLastInPartyCondition), .condition(failIfLastInPartyCondition),
new SelfStatusMove(MoveId.CHILLY_RECEPTION, PokemonType.ICE, -1, 10, -1, 0, 9) new SelfStatusMove(MoveId.CHILLY_RECEPTION, PokemonType.ICE, -1, 10, -1, 0, 9)
.attr(PreMoveMessageAttr, (user, move) => i18next.t("moveTriggers:chillyReception", { pokemonName: getPokemonNameWithAffix(user) })) .attr(PreMoveMessageAttr, (user, _target, _move) =>
// Don't display text if current move phase is follow up (ie move called indirectly)
isVirtual((globalScene.phaseManager.getCurrentPhase() as MovePhase).useMode)
? ""
: i18next.t("moveTriggers:chillyReception", { pokemonName: getPokemonNameWithAffix(user) }))
.attr(ChillyReceptionAttr, true), .attr(ChillyReceptionAttr, true),
new SelfStatusMove(MoveId.TIDY_UP, PokemonType.NORMAL, -1, 10, -1, 0, 9) new SelfStatusMove(MoveId.TIDY_UP, PokemonType.NORMAL, -1, 10, -1, 0, 9)
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPD ], 1, true) .attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPD ], 1, true)

View File

@ -751,7 +751,7 @@ export async function catchPokemon(
UiMode.POKEDEX_PAGE, UiMode.POKEDEX_PAGE,
pokemon.species, pokemon.species,
pokemon.formIndex, pokemon.formIndex,
attributes, [attributes],
null, null,
() => { () => {
globalScene.ui.setMode(UiMode.MESSAGE).then(() => { globalScene.ui.setMode(UiMode.MESSAGE).then(() => {

View File

@ -764,7 +764,7 @@ export default class PokemonSpecies extends PokemonSpeciesForm implements Locali
readonly subLegendary: boolean; readonly subLegendary: boolean;
readonly legendary: boolean; readonly legendary: boolean;
readonly mythical: boolean; readonly mythical: boolean;
readonly species: string; public category: string;
readonly growthRate: GrowthRate; readonly growthRate: GrowthRate;
/** The chance (as a decimal) for this Species to be male, or `null` for genderless species */ /** The chance (as a decimal) for this Species to be male, or `null` for genderless species */
readonly malePercent: number | null; readonly malePercent: number | null;
@ -778,7 +778,7 @@ export default class PokemonSpecies extends PokemonSpeciesForm implements Locali
subLegendary: boolean, subLegendary: boolean,
legendary: boolean, legendary: boolean,
mythical: boolean, mythical: boolean,
species: string, category: string,
type1: PokemonType, type1: PokemonType,
type2: PokemonType | null, type2: PokemonType | null,
height: number, height: number,
@ -829,7 +829,7 @@ export default class PokemonSpecies extends PokemonSpeciesForm implements Locali
this.subLegendary = subLegendary; this.subLegendary = subLegendary;
this.legendary = legendary; this.legendary = legendary;
this.mythical = mythical; this.mythical = mythical;
this.species = species; this.category = category;
this.growthRate = growthRate; this.growthRate = growthRate;
this.malePercent = malePercent; this.malePercent = malePercent;
this.genderDiffs = genderDiffs; this.genderDiffs = genderDiffs;
@ -968,6 +968,7 @@ export default class PokemonSpecies extends PokemonSpeciesForm implements Locali
localize(): void { localize(): void {
this.name = i18next.t(`pokemon:${SpeciesId[this.speciesId].toLowerCase()}`); this.name = i18next.t(`pokemon:${SpeciesId[this.speciesId].toLowerCase()}`);
this.category = i18next.t(`pokemonCategory:${SpeciesId[this.speciesId].toLowerCase()}_category`);
} }
getWildSpeciesForLevel(level: number, allowEvolving: boolean, isBoss: boolean, gameMode: GameMode): SpeciesId { getWildSpeciesForLevel(level: number, allowEvolving: boolean, isBoss: boolean, gameMode: GameMode): SpeciesId {

View File

@ -668,6 +668,9 @@ export class MovePhase extends BattlePhase {
}), }),
500, 500,
); );
// Moves with pre-use messages (Magnitude, Chilly Reception, Fickle Beam, etc.) always display their messages even on failure
// TODO: This assumes single target for message funcs - is this sustainable?
applyMoveAttrs("PreMoveMessageAttr", this.pokemon, this.pokemon.getOpponents(false)[0], this.move.getMove()); applyMoveAttrs("PreMoveMessageAttr", this.pokemon, this.pokemon.getOpponents(false)[0], this.move.getMove());
} }

View File

@ -245,6 +245,7 @@ export async function initI18n(): Promise<void> {
"pokeball", "pokeball",
"pokedexUiHandler", "pokedexUiHandler",
"pokemon", "pokemon",
"pokemonCategory",
"pokemonEvolutions", "pokemonEvolutions",
"pokemonForm", "pokemonForm",
"pokemonInfo", "pokemonInfo",

View File

@ -174,6 +174,7 @@ export default class PokedexPageUiHandler extends MessageUiHandler {
private pokemonCaughtHatchedContainer: Phaser.GameObjects.Container; private pokemonCaughtHatchedContainer: Phaser.GameObjects.Container;
private pokemonCaughtCountText: Phaser.GameObjects.Text; private pokemonCaughtCountText: Phaser.GameObjects.Text;
private pokemonFormText: Phaser.GameObjects.Text; private pokemonFormText: Phaser.GameObjects.Text;
private pokemonCategoryText: Phaser.GameObjects.Text;
private pokemonHatchedIcon: Phaser.GameObjects.Sprite; private pokemonHatchedIcon: Phaser.GameObjects.Sprite;
private pokemonHatchedCountText: Phaser.GameObjects.Text; private pokemonHatchedCountText: Phaser.GameObjects.Text;
private pokemonShinyIcons: Phaser.GameObjects.Sprite[]; private pokemonShinyIcons: Phaser.GameObjects.Sprite[];
@ -409,6 +410,12 @@ export default class PokedexPageUiHandler extends MessageUiHandler {
this.pokemonFormText.setOrigin(0, 0); this.pokemonFormText.setOrigin(0, 0);
this.starterSelectContainer.add(this.pokemonFormText); this.starterSelectContainer.add(this.pokemonFormText);
this.pokemonCategoryText = addTextObject(100, 18, "Category", TextStyle.WINDOW_ALT, {
fontSize: "42px",
});
this.pokemonCategoryText.setOrigin(1, 0);
this.starterSelectContainer.add(this.pokemonCategoryText);
this.pokemonCaughtHatchedContainer = globalScene.add.container(2, 25); this.pokemonCaughtHatchedContainer = globalScene.add.container(2, 25);
this.pokemonCaughtHatchedContainer.setScale(0.5); this.pokemonCaughtHatchedContainer.setScale(0.5);
this.starterSelectContainer.add(this.pokemonCaughtHatchedContainer); this.starterSelectContainer.add(this.pokemonCaughtHatchedContainer);
@ -2354,6 +2361,7 @@ export default class PokedexPageUiHandler extends MessageUiHandler {
this.pokemonCaughtHatchedContainer.setVisible(true); this.pokemonCaughtHatchedContainer.setVisible(true);
this.pokemonCandyContainer.setVisible(false); this.pokemonCandyContainer.setVisible(false);
this.pokemonFormText.setVisible(false); this.pokemonFormText.setVisible(false);
this.pokemonCategoryText.setVisible(false);
const defaultDexAttr = globalScene.gameData.getSpeciesDefaultDexAttr(species, true, true); const defaultDexAttr = globalScene.gameData.getSpeciesDefaultDexAttr(species, true, true);
const props = globalScene.gameData.getSpeciesDexAttrProps(species, defaultDexAttr); const props = globalScene.gameData.getSpeciesDexAttrProps(species, defaultDexAttr);
@ -2382,6 +2390,7 @@ export default class PokedexPageUiHandler extends MessageUiHandler {
this.pokemonCaughtHatchedContainer.setVisible(false); this.pokemonCaughtHatchedContainer.setVisible(false);
this.pokemonCandyContainer.setVisible(false); this.pokemonCandyContainer.setVisible(false);
this.pokemonFormText.setVisible(false); this.pokemonFormText.setVisible(false);
this.pokemonCategoryText.setVisible(false);
this.setSpeciesDetails(species!, { this.setSpeciesDetails(species!, {
// TODO: is this bang correct? // TODO: is this bang correct?
@ -2534,6 +2543,13 @@ export default class PokedexPageUiHandler extends MessageUiHandler {
this.pokemonNameText.setText(species ? "???" : ""); this.pokemonNameText.setText(species ? "???" : "");
} }
// Setting the category
if (isFormCaught) {
this.pokemonCategoryText.setText(species.category);
} else {
this.pokemonCategoryText.setText("");
}
// Setting tint of the sprite // Setting tint of the sprite
if (isFormCaught) { if (isFormCaught) {
this.species.loadAssets(female!, formIndex, shiny, variant as Variant, true).then(() => { this.species.loadAssets(female!, formIndex, shiny, variant as Variant, true).then(() => {

View File

@ -0,0 +1,62 @@
import { AbilityId } from "#enums/ability-id";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import GameManager from "#test/testUtils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Abilities - Innards Out", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.ability(AbilityId.BALL_FETCH)
.battleStyle("single")
.criticalHits(false)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.INNARDS_OUT)
.enemyMoveset(MoveId.SPLASH)
.startingLevel(100);
});
it("should damage opppnents that faint the ability holder for equal damage", async () => {
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const magikarp = game.field.getEnemyPokemon();
magikarp.hp = 20;
game.move.use(MoveId.X_SCISSOR);
await game.toEndOfTurn();
expect(magikarp.isFainted()).toBe(true);
const feebas = game.field.getPlayerPokemon();
expect(feebas.getInverseHp()).toBe(20);
});
it("should not damage an ally in Double Battles", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const [magikarp1, magikarp2] = game.scene.getEnemyField();
magikarp1.hp = 1;
game.move.use(MoveId.PROTECT);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.move.forceEnemyMove(MoveId.SURF);
await game.toEndOfTurn();
expect(magikarp1.isFainted()).toBe(true);
expect(magikarp2.getInverseHp()).toBe(0);
});
});

View File

@ -1,19 +1,18 @@
import { getArenaTag } from "#app/data/arena-tag"; import { BattlerIndex } from "#enums/battler-index";
import { ArenaTagSide } from "#enums/arena-tag-side"; import { ArenaTagSide } from "#enums/arena-tag-side";
import { allMoves } from "#app/data/data-lists";
import { getStatusEffectCatchRateMultiplier } from "#app/data/status-effect"; import { getStatusEffectCatchRateMultiplier } from "#app/data/status-effect";
import { TurnEndPhase } from "#app/phases/turn-end-phase"; import { toDmgValue } from "#app/utils/common";
import { AbilityId } from "#enums/ability-id"; import { AbilityId } from "#enums/ability-id";
import { ArenaTagType } from "#enums/arena-tag-type"; import { ArenaTagType } from "#enums/arena-tag-type";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id"; import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id"; import { SpeciesId } from "#enums/species-id";
import { StatusEffect } from "#enums/status-effect"; import { StatusEffect } from "#enums/status-effect";
import { WeatherType } from "#enums/weather-type";
import GameManager from "#test/testUtils/gameManager"; import GameManager from "#test/testUtils/gameManager";
import Phaser from "phaser"; import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Abilities - Magic Guard", () => { describe("AbilityId - Magic Guard", () => {
let phaserGame: Phaser.Game; let phaserGame: Phaser.Game;
let game: GameManager; let game: GameManager;
@ -29,404 +28,142 @@ describe("Abilities - Magic Guard", () => {
beforeEach(() => { beforeEach(() => {
game = new GameManager(phaserGame); game = new GameManager(phaserGame);
game.override game.override
/** Player Pokemon overrides */
.ability(AbilityId.MAGIC_GUARD) .ability(AbilityId.MAGIC_GUARD)
.moveset([MoveId.SPLASH]) .enemySpecies(SpeciesId.BLISSEY)
.enemyAbility(AbilityId.NO_GUARD)
.startingLevel(100) .startingLevel(100)
/** Enemy Pokemon overrides */
.enemySpecies(SpeciesId.SNORLAX)
.enemyAbility(AbilityId.INSOMNIA)
.enemyMoveset(MoveId.SPLASH)
.enemyLevel(100); .enemyLevel(100);
}); });
//Bulbapedia Reference: https://bulbapedia.bulbagarden.net/wiki/Magic_Guard_(Ability) //Bulbapedia Reference: https://bulbapedia.bulbagarden.net/wiki/Magic_Guard_(Ability)
it("ability should prevent damage caused by weather", async () => { it.each<{ name: string; move?: MoveId; enemyMove?: MoveId }>([
game.override.weather(WeatherType.SANDSTORM); { name: "Non-Volatile Status Conditions", enemyMove: MoveId.TOXIC },
{ name: "Volatile Status Conditions", enemyMove: MoveId.LEECH_SEED },
{ name: "Crash Damage", move: MoveId.HIGH_JUMP_KICK },
{ name: "Variable Recoil Moves", move: MoveId.DOUBLE_EDGE },
{ name: "HP% Recoil Moves", move: MoveId.CHLOROBLAST },
])("should prevent damage from $name", async ({ move = MoveId.SPLASH, enemyMove = MoveId.SPLASH }) => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]); await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
// force a miss on HJK
vi.spyOn(allMoves[MoveId.HIGH_JUMP_KICK], "accuracy", "get").mockReturnValue(0);
const leadPokemon = game.scene.getPlayerPokemon()!; game.move.use(move);
await game.move.forceEnemyMove(enemyMove);
await game.toEndOfTurn();
const enemyPokemon = game.scene.getEnemyPokemon()!; const magikarp = game.field.getPlayerPokemon();
expect(enemyPokemon).toBeDefined(); expect(magikarp.hp).toBe(magikarp.getMaxHp());
game.move.select(MoveId.SPLASH);
await game.phaseInterceptor.to(TurnEndPhase);
/**
* Expect:
* - The player Pokemon (with Magic Guard) has not taken damage from weather
* - The enemy Pokemon (without Magic Guard) has taken damage from weather
*/
expect(leadPokemon.hp).toBe(leadPokemon.getMaxHp());
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
}); });
it("ability should prevent damage caused by status effects but other non-damage effects still apply", async () => { it.each<{ abName: string; move?: MoveId; enemyMove?: MoveId; passive?: AbilityId; enemyAbility?: AbilityId }>([
//Toxic keeps track of the turn counters -> important that Magic Guard keeps track of post-Toxic turns { abName: "Bad Dreams", enemyMove: MoveId.SPORE, enemyAbility: AbilityId.BAD_DREAMS },
game.override.statusEffect(StatusEffect.POISON); { abName: "Aftermath", move: MoveId.PSYCHIC_FANGS, enemyAbility: AbilityId.AFTERMATH },
{ abName: "Innards Out", move: MoveId.PSYCHIC_FANGS, enemyAbility: AbilityId.INNARDS_OUT },
{ abName: "Rough Skin", move: MoveId.PSYCHIC_FANGS, enemyAbility: AbilityId.ROUGH_SKIN },
{ abName: "Dry Skin", move: MoveId.SUNNY_DAY, passive: AbilityId.DRY_SKIN },
{ abName: "Liquid Ooze", move: MoveId.DRAIN_PUNCH, enemyAbility: AbilityId.LIQUID_OOZE },
])(
"should prevent damage from $abName",
async ({
move = MoveId.SPLASH,
enemyMove = MoveId.SPLASH,
passive = AbilityId.BALL_FETCH,
enemyAbility = AbilityId.BALL_FETCH,
}) => {
game.override.enemyLevel(1).passiveAbility(passive).enemyAbility(enemyAbility);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.use(move);
await game.move.forceEnemyMove(enemyMove);
await game.toEndOfTurn();
const magikarp = game.field.getPlayerPokemon();
expect(magikarp.hp).toBe(magikarp.getMaxHp());
},
);
it.each<{ name: string; move?: MoveId; enemyMove?: MoveId }>([
{ name: "Struggle recoil", move: MoveId.STRUGGLE },
{ name: "Self-induced HP cutting", move: MoveId.BELLY_DRUM },
{ name: "Confusion self-damage", enemyMove: MoveId.CONFUSE_RAY },
])("should not prevent damage from $name", async ({ move = MoveId.SPLASH, enemyMove = MoveId.SPLASH }) => {
game.override.confusionActivation(true);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]); await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const leadPokemon = game.scene.getPlayerPokemon()!; game.move.use(move);
await game.move.forceEnemyMove(enemyMove);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); // For confuse ray
await game.toEndOfTurn();
game.move.select(MoveId.SPLASH); const leadPokemon = game.field.getPlayerPokemon();
await game.phaseInterceptor.to(TurnEndPhase);
/**
* Expect:
* - The player Pokemon (with Magic Guard) has not taken damage from poison
* - The Pokemon's CatchRateMultiplier should be 1.5
*/
expect(leadPokemon.hp).toBe(leadPokemon.getMaxHp());
expect(getStatusEffectCatchRateMultiplier(leadPokemon.status!.effect)).toBe(1.5);
});
it("ability effect should not persist when the ability is replaced", async () => {
game.override.enemyMoveset(MoveId.WORRY_SEED).statusEffect(StatusEffect.POISON);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const leadPokemon = game.scene.getPlayerPokemon()!;
game.move.select(MoveId.SPLASH);
await game.phaseInterceptor.to(TurnEndPhase);
/**
* Expect:
* - The player Pokemon (that just lost its Magic Guard ability) has taken damage from poison
*/
expect(leadPokemon.hp).toBeLessThan(leadPokemon.getMaxHp()); expect(leadPokemon.hp).toBeLessThan(leadPokemon.getMaxHp());
}); });
it("Magic Guard prevents damage caused by burn but other non-damaging effects are still applied", async () => { it("should preserve toxic turn count and deal appropriate damage when disabled", async () => {
game.override.enemyStatusEffect(StatusEffect.BURN).enemyAbility(AbilityId.MAGIC_GUARD); game.override.statusEffect(StatusEffect.TOXIC);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]); await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.select(MoveId.SPLASH); game.move.use(MoveId.SPLASH);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.toNextTurn();
const enemyPokemon = game.scene.getEnemyPokemon()!; const magikarp = game.field.getPlayerPokemon();
expect(magikarp.hp).toBe(magikarp.getMaxHp());
expect(magikarp.status?.toxicTurnCount).toBe(1);
await game.phaseInterceptor.to(TurnEndPhase); // have a few turns pass
game.move.use(MoveId.SPLASH);
await game.toNextTurn();
game.move.use(MoveId.SPLASH);
await game.toNextTurn();
game.move.use(MoveId.SPLASH);
await game.toNextTurn();
expect(magikarp.status?.toxicTurnCount).toBe(4);
/** game.move.use(MoveId.SPLASH);
* Expect: await game.move.forceEnemyMove(MoveId.GASTRO_ACID);
* - The enemy Pokemon (with Magic Guard) has not taken damage from burn await game.toNextTurn();
* - The enemy Pokemon's physical attack damage is halved (TBD)
* - The enemy Pokemon's hypothetical CatchRateMultiplier should be 1.5 expect(magikarp.status?.toxicTurnCount).toBe(5);
*/ expect(magikarp.getHpRatio(true)).toBeCloseTo(11 / 16, 1);
expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp());
expect(getStatusEffectCatchRateMultiplier(enemyPokemon.status!.effect)).toBe(1.5);
}); });
it("Magic Guard prevents damage caused by toxic but other non-damaging effects are still applied", async () => { it("should preserve burn physical damage halving & status catch boost", async () => {
game.override.enemyStatusEffect(StatusEffect.TOXIC).enemyAbility(AbilityId.MAGIC_GUARD);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]); await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.select(MoveId.SPLASH); // NB: Burn applies directly to the physical dmg formula, so we can't just check attack here
game.move.use(MoveId.TACKLE);
await game.move.forceEnemyMove(MoveId.WILL_O_WISP);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn();
const enemyPokemon = game.scene.getEnemyPokemon()!; const magikarp = game.field.getPlayerPokemon();
expect(magikarp.hp).toBe(magikarp.getMaxHp());
expect(magikarp.status?.effect).toBe(StatusEffect.BURN);
const toxicStartCounter = enemyPokemon.status!.toxicTurnCount; const blissey = game.field.getEnemyPokemon();
//should be 0 const prevDmg = blissey.getInverseHp();
blissey.hp = blissey.getMaxHp();
await game.phaseInterceptor.to(TurnEndPhase); expect(getStatusEffectCatchRateMultiplier(magikarp.status!.effect)).toBe(1.5);
/** game.move.use(MoveId.TACKLE);
* Expect: await game.toNextTurn();
* - The enemy Pokemon (with Magic Guard) has not taken damage from toxic
* - The enemy Pokemon's status effect duration should be incremented const burntDmg = blissey.getInverseHp();
* - The enemy Pokemon's hypothetical CatchRateMultiplier should be 1.5 expect(burntDmg).toBeCloseTo(toDmgValue(prevDmg / 2), 0);
*/
expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp());
expect(enemyPokemon.status!.toxicTurnCount).toBeGreaterThan(toxicStartCounter);
expect(getStatusEffectCatchRateMultiplier(enemyPokemon.status!.effect)).toBe(1.5);
}); });
it("Magic Guard prevents damage caused by entry hazards", async () => { it("should prevent damage from entry hazards, but not Toxic Spikes poison", async () => {
//Adds and applies Spikes to both sides of the arena game.scene.arena.addTag(ArenaTagType.SPIKES, -1, MoveId.SPIKES, 0, ArenaTagSide.PLAYER);
const newTag = getArenaTag(ArenaTagType.SPIKES, 5, MoveId.SPIKES, 0, 0, ArenaTagSide.BOTH)!; game.scene.arena.addTag(ArenaTagType.TOXIC_SPIKES, -1, MoveId.TOXIC_SPIKES, 0, ArenaTagSide.PLAYER);
game.scene.arena.tags.push(newTag);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const leadPokemon = game.scene.getPlayerPokemon()!;
game.move.select(MoveId.SPLASH);
const enemyPokemon = game.scene.getEnemyPokemon()!;
await game.phaseInterceptor.to(TurnEndPhase);
/**
* Expect:
* - The player Pokemon (with Magic Guard) has not taken damage from spikes
* - The enemy Pokemon (without Magic Guard) has taken damage from spikes
*/
expect(leadPokemon.hp).toBe(leadPokemon.getMaxHp());
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
});
it("Magic Guard does not prevent poison from Toxic Spikes", async () => {
//Adds and applies Spikes to both sides of the arena
const playerTag = getArenaTag(ArenaTagType.TOXIC_SPIKES, 5, MoveId.TOXIC_SPIKES, 0, 0, ArenaTagSide.PLAYER)!;
const enemyTag = getArenaTag(ArenaTagType.TOXIC_SPIKES, 5, MoveId.TOXIC_SPIKES, 0, 0, ArenaTagSide.ENEMY)!;
game.scene.arena.tags.push(playerTag);
game.scene.arena.tags.push(enemyTag);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const leadPokemon = game.scene.getPlayerPokemon()!;
game.move.select(MoveId.SPLASH);
const enemyPokemon = game.scene.getEnemyPokemon()!;
await game.phaseInterceptor.to(TurnEndPhase);
/**
* Expect:
* - Both Pokemon gain the poison status effect
* - The player Pokemon (with Magic Guard) has not taken damage from poison
* - The enemy Pokemon (without Magic Guard) has taken damage from poison
*/
expect(leadPokemon.status!.effect).toBe(StatusEffect.POISON);
expect(enemyPokemon.status!.effect).toBe(StatusEffect.POISON);
expect(leadPokemon.hp).toBe(leadPokemon.getMaxHp());
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
});
it("Magic Guard prevents against damage from volatile status effects", async () => {
await game.classicMode.startBattle([SpeciesId.DUSKULL]);
game.override.moveset([MoveId.CURSE]).enemyAbility(AbilityId.MAGIC_GUARD);
const leadPokemon = game.scene.getPlayerPokemon()!;
game.move.select(MoveId.CURSE);
const enemyPokemon = game.scene.getEnemyPokemon()!;
await game.phaseInterceptor.to(TurnEndPhase);
/**
* Expect:
* - The player Pokemon (with Magic Guard) has cut its HP to inflict curse
* - The enemy Pokemon (with Magic Guard) is cursed
* - The enemy Pokemon (with Magic Guard) does not lose HP from being cursed
*/
expect(leadPokemon.hp).toBeLessThan(leadPokemon.getMaxHp());
expect(enemyPokemon.getTag(BattlerTagType.CURSED)).not.toBe(undefined);
expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp());
});
it("Magic Guard prevents crash damage", async () => {
game.override.moveset([MoveId.HIGH_JUMP_KICK]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]); await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const leadPokemon = game.scene.getPlayerPokemon()!; // Magic guard prevented damage but not poison
const player = game.field.getPlayerPokemon();
game.move.select(MoveId.HIGH_JUMP_KICK); expect(player.hp).toBe(player.getMaxHp());
await game.move.forceMiss(); expect(player.status?.effect).toBe(StatusEffect.POISON);
await game.phaseInterceptor.to(TurnEndPhase);
/**
* Expect:
* - The player Pokemon (with Magic Guard) misses High Jump Kick but does not lose HP as a result
*/
expect(leadPokemon.hp).toBe(leadPokemon.getMaxHp());
});
it("Magic Guard prevents damage from recoil", async () => {
game.override.moveset([MoveId.TAKE_DOWN]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const leadPokemon = game.scene.getPlayerPokemon()!;
game.move.select(MoveId.TAKE_DOWN);
await game.phaseInterceptor.to(TurnEndPhase);
/**
* Expect:
* - The player Pokemon (with Magic Guard) uses a recoil move but does not lose HP from recoil
*/
expect(leadPokemon.hp).toBe(leadPokemon.getMaxHp());
});
it("Magic Guard does not prevent damage from Struggle's recoil", async () => {
game.override.moveset([MoveId.STRUGGLE]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const leadPokemon = game.scene.getPlayerPokemon()!;
game.move.select(MoveId.STRUGGLE);
await game.phaseInterceptor.to(TurnEndPhase);
/**
* Expect:
* - The player Pokemon (with Magic Guard) uses Struggle but does lose HP from Struggle's recoil
*/
expect(leadPokemon.hp).toBeLessThan(leadPokemon.getMaxHp());
});
//This tests different move attributes than the recoil tests above
it("Magic Guard prevents self-damage from attacking moves", async () => {
game.override.moveset([MoveId.STEEL_BEAM]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const leadPokemon = game.scene.getPlayerPokemon()!;
game.move.select(MoveId.STEEL_BEAM);
await game.phaseInterceptor.to(TurnEndPhase);
/**
* Expect:
* - The player Pokemon (with Magic Guard) uses a move with an HP cost but does not lose HP from using it
*/
expect(leadPokemon.hp).toBe(leadPokemon.getMaxHp());
});
/*
it("Magic Guard does not prevent self-damage from confusion", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.select(MoveId.CHARM);
await game.phaseInterceptor.to(TurnEndPhase);
});
*/
it("Magic Guard does not prevent self-damage from non-attacking moves", async () => {
game.override.moveset([MoveId.BELLY_DRUM]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const leadPokemon = game.scene.getPlayerPokemon()!;
game.move.select(MoveId.BELLY_DRUM);
await game.phaseInterceptor.to(TurnEndPhase);
/**
* Expect:
* - The player Pokemon (with Magic Guard) uses a non-attacking move with an HP cost and thus loses HP from using it
*/
expect(leadPokemon.hp).toBeLessThan(leadPokemon.getMaxHp());
});
it("Magic Guard prevents damage from abilities with PostTurnHurtIfSleepingAbAttr", async () => {
//Tests the ability Bad Dreams
game.override.statusEffect(StatusEffect.SLEEP);
//enemy pokemon is given Spore just in case player pokemon somehow awakens during test
game.override
.enemyMoveset([MoveId.SPORE, MoveId.SPORE, MoveId.SPORE, MoveId.SPORE])
.enemyAbility(AbilityId.BAD_DREAMS);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const leadPokemon = game.scene.getPlayerPokemon()!;
game.move.select(MoveId.SPLASH);
await game.phaseInterceptor.to(TurnEndPhase);
/**
* Expect:
* - The player Pokemon (with Magic Guard) should not lose HP due to this ability attribute
* - The player Pokemon is asleep
*/
expect(leadPokemon.hp).toBe(leadPokemon.getMaxHp());
expect(leadPokemon.status!.effect).toBe(StatusEffect.SLEEP);
});
it("Magic Guard prevents damage from abilities with PostFaintContactDamageAbAttr", async () => {
//Tests the abilities Innards Out/Aftermath
game.override.moveset([MoveId.TACKLE]).enemyAbility(AbilityId.AFTERMATH);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const leadPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!;
enemyPokemon.hp = 1;
game.move.select(MoveId.TACKLE);
await game.phaseInterceptor.to(TurnEndPhase);
/**
* Expect:
* - The player Pokemon (with Magic Guard) should not lose HP due to this ability attribute
* - The enemy Pokemon has fainted
*/
expect(enemyPokemon.hp).toBe(0);
expect(leadPokemon.hp).toBe(leadPokemon.getMaxHp());
});
it("Magic Guard prevents damage from abilities with PostDefendContactDamageAbAttr", async () => {
//Tests the abilities Iron Barbs/Rough Skin
game.override.moveset([MoveId.TACKLE]).enemyAbility(AbilityId.IRON_BARBS);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const leadPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!;
game.move.select(MoveId.TACKLE);
await game.phaseInterceptor.to(TurnEndPhase);
/**
* Expect:
* - The player Pokemon (with Magic Guard) should not lose HP due to this ability attribute
* - The player Pokemon's move should have connected
*/
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
expect(leadPokemon.hp).toBe(leadPokemon.getMaxHp());
});
it("Magic Guard prevents damage from abilities with ReverseDrainAbAttr", async () => {
//Tests the ability Liquid Ooze
game.override.moveset([MoveId.ABSORB]).enemyAbility(AbilityId.LIQUID_OOZE);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const leadPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!;
game.move.select(MoveId.ABSORB);
await game.phaseInterceptor.to(TurnEndPhase);
/**
* Expect:
* - The player Pokemon (with Magic Guard) should not lose HP due to this ability attribute
* - The player Pokemon's move should have connected
*/
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
expect(leadPokemon.hp).toBe(leadPokemon.getMaxHp());
});
it("Magic Guard prevents HP loss from abilities with PostWeatherLapseDamageAbAttr", async () => {
game.override.passiveAbility(AbilityId.SOLAR_POWER).weather(WeatherType.SUNNY);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const leadPokemon = game.scene.getPlayerPokemon()!;
game.move.select(MoveId.SPLASH);
await game.phaseInterceptor.to(TurnEndPhase);
/**
* Expect:
* - The player Pokemon (with Magic Guard) should not lose HP due to this ability attribute
*/
expect(leadPokemon.hp).toBe(leadPokemon.getMaxHp());
}); });
}); });

View File

@ -26,7 +26,7 @@ import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { BiomeId } from "#enums/biome-id"; import { BiomeId } from "#enums/biome-id";
describe("Test Battle Phase", () => { describe("Phase - Battle Phase", () => {
let phaserGame: Phaser.Game; let phaserGame: Phaser.Game;
let game: GameManager; let game: GameManager;
@ -197,47 +197,25 @@ describe("Test Battle Phase", () => {
await game.phaseInterceptor.runFrom(SelectGenderPhase).to(SummonPhase); await game.phaseInterceptor.runFrom(SelectGenderPhase).to(SummonPhase);
}); });
it("2vs1", async () => { it.each([
game.override.battleStyle("single"); { name: "1v1", double: false, qty: 1 },
game.override.enemySpecies(SpeciesId.MIGHTYENA); { name: "2v1", double: false, qty: 2 },
game.override.enemyAbility(AbilityId.HYDRATION); { name: "2v2", double: true, qty: 2 },
game.override.ability(AbilityId.HYDRATION); { name: "4v2", double: true, qty: 4 },
await game.classicMode.startBattle([SpeciesId.BLASTOISE, SpeciesId.CHARIZARD]); ])("should not crash when starting $name battle", async ({ double, qty }) => {
expect(game.scene.ui?.getMode()).toBe(UiMode.COMMAND); game.override
expect(game.scene.phaseManager.getCurrentPhase()!.constructor.name).toBe(CommandPhase.name); .battleStyle(double ? "double" : "single")
}, 20000); .enemySpecies(SpeciesId.MIGHTYENA)
.enemyAbility(AbilityId.HYDRATION)
.ability(AbilityId.HYDRATION);
it("1vs1", async () => { await game.classicMode.startBattle(
game.override.battleStyle("single"); [SpeciesId.BLASTOISE, SpeciesId.CHARIZARD, SpeciesId.DARKRAI, SpeciesId.GABITE].slice(0, qty),
game.override.enemySpecies(SpeciesId.MIGHTYENA); );
game.override.enemyAbility(AbilityId.HYDRATION);
game.override.ability(AbilityId.HYDRATION);
await game.classicMode.startBattle([SpeciesId.BLASTOISE]);
expect(game.scene.ui?.getMode()).toBe(UiMode.COMMAND);
expect(game.scene.phaseManager.getCurrentPhase()!.constructor.name).toBe(CommandPhase.name);
}, 20000);
it("2vs2", async () => {
game.override.battleStyle("double");
game.override.enemySpecies(SpeciesId.MIGHTYENA);
game.override.enemyAbility(AbilityId.HYDRATION);
game.override.ability(AbilityId.HYDRATION);
game.override.startingWave(3);
await game.classicMode.startBattle([SpeciesId.BLASTOISE, SpeciesId.CHARIZARD]);
expect(game.scene.ui?.getMode()).toBe(UiMode.COMMAND); expect(game.scene.ui?.getMode()).toBe(UiMode.COMMAND);
expect(game.scene.phaseManager.getCurrentPhase()!.constructor.name).toBe(CommandPhase.name); expect(game.scene.phaseManager.getCurrentPhase()).toBeInstanceOf(CommandPhase);
}, 20000); });
it("4vs2", async () => {
game.override.battleStyle("double");
game.override.enemySpecies(SpeciesId.MIGHTYENA);
game.override.enemyAbility(AbilityId.HYDRATION);
game.override.ability(AbilityId.HYDRATION);
game.override.startingWave(3);
await game.classicMode.startBattle([SpeciesId.BLASTOISE, SpeciesId.CHARIZARD, SpeciesId.DARKRAI, SpeciesId.GABITE]);
expect(game.scene.ui?.getMode()).toBe(UiMode.COMMAND);
expect(game.scene.phaseManager.getCurrentPhase()!.constructor.name).toBe(CommandPhase.name);
}, 20000);
it("kill opponent pokemon", async () => { it("kill opponent pokemon", async () => {
const moveToUse = MoveId.SPLASH; const moveToUse = MoveId.SPLASH;

View File

@ -1,11 +1,14 @@
import { AbilityId } from "#enums/ability-id"; import { RandomMoveAttr } from "#app/data/moves/move";
import { MoveResult } from "#enums/move-result";
import { getPokemonNameWithAffix } from "#app/messages";
import { MoveId } from "#enums/move-id"; import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id"; import { SpeciesId } from "#enums/species-id";
import { AbilityId } from "#app/enums/ability-id";
import { WeatherType } from "#enums/weather-type"; import { WeatherType } from "#enums/weather-type";
import GameManager from "#test/testUtils/gameManager"; import GameManager from "#test/testUtils/gameManager";
import i18next from "i18next";
import Phaser from "phaser"; import Phaser from "phaser";
//import { TurnInitPhase } from "#app/phases/turn-init-phase"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Moves - Chilly Reception", () => { describe("Moves - Chilly Reception", () => {
let phaserGame: Phaser.Game; let phaserGame: Phaser.Game;
@ -25,95 +28,121 @@ describe("Moves - Chilly Reception", () => {
game = new GameManager(phaserGame); game = new GameManager(phaserGame);
game.override game.override
.battleStyle("single") .battleStyle("single")
.moveset([MoveId.CHILLY_RECEPTION, MoveId.SNOWSCAPE]) .moveset([MoveId.CHILLY_RECEPTION, MoveId.SNOWSCAPE, MoveId.SPLASH, MoveId.METRONOME])
.enemyMoveset(MoveId.SPLASH) .enemyMoveset(MoveId.SPLASH)
.enemyAbility(AbilityId.BALL_FETCH) .enemyAbility(AbilityId.BALL_FETCH)
.ability(AbilityId.BALL_FETCH); .ability(AbilityId.BALL_FETCH);
}); });
it("should still change the weather if user can't switch out", async () => { it("should display message before use, switch the user out and change the weather to snow", async () => {
await game.classicMode.startBattle([SpeciesId.SLOWKING, SpeciesId.MEOWTH]);
const [slowking, meowth] = game.scene.getPlayerParty();
game.move.select(MoveId.CHILLY_RECEPTION);
game.doSelectPartyPokemon(1);
await game.toEndOfTurn();
expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SNOW);
expect(game.scene.getPlayerPokemon()).toBe(meowth);
expect(slowking.isOnField()).toBe(false);
expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase");
expect(game.textInterceptor.logs).toContain(
i18next.t("moveTriggers:chillyReception", { pokemonName: getPokemonNameWithAffix(slowking) }),
);
});
it("should still change weather if user can't switch out", async () => {
await game.classicMode.startBattle([SpeciesId.SLOWKING]); await game.classicMode.startBattle([SpeciesId.SLOWKING]);
game.move.select(MoveId.CHILLY_RECEPTION); game.move.select(MoveId.CHILLY_RECEPTION);
await game.toEndOfTurn();
await game.phaseInterceptor.to("BerryPhase", false);
expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SNOW); expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SNOW);
expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase");
expect(game.scene.getPlayerPokemon()?.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
}); });
it("should switch out even if it's snowing", async () => { it("should still switch out even if weather cannot be changed", async () => {
await game.classicMode.startBattle([SpeciesId.SLOWKING, SpeciesId.MEOWTH]); await game.classicMode.startBattle([SpeciesId.SLOWKING, SpeciesId.MEOWTH]);
// first turn set up snow with snowscape, try chilly reception on second turn
expect(game.scene.arena.weather?.weatherType).not.toBe(WeatherType.SNOW);
const [slowking, meowth] = game.scene.getPlayerParty();
game.move.select(MoveId.SNOWSCAPE); game.move.select(MoveId.SNOWSCAPE);
await game.phaseInterceptor.to("BerryPhase", false); await game.toNextTurn();
expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SNOW); expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SNOW);
await game.phaseInterceptor.to("TurnInitPhase", false);
game.move.select(MoveId.CHILLY_RECEPTION); game.move.select(MoveId.CHILLY_RECEPTION);
game.doSelectPartyPokemon(1); game.doSelectPartyPokemon(1);
// TODO: Uncomment lines once wimp out PR fixes force switches to not reset summon data immediately
// await game.phaseInterceptor.to("SwitchSummonPhase", false);
// expect(slowking.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
await game.toEndOfTurn();
await game.phaseInterceptor.to("BerryPhase", false);
expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SNOW); expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SNOW);
expect(game.scene.getPlayerField()[0].species.speciesId).toBe(SpeciesId.MEOWTH); expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase");
expect(game.scene.getPlayerPokemon()).toBe(meowth);
expect(slowking.isOnField()).toBe(false);
}); });
it("happy case - switch out and weather changes", async () => { // Source: https://replay.pokemonshowdown.com/gen9ou-2367532550
it("should fail (while still displaying message) if neither weather change nor switch out succeeds", async () => {
await game.classicMode.startBattle([SpeciesId.SLOWKING]);
expect(game.scene.arena.weather?.weatherType).not.toBe(WeatherType.SNOW);
const slowking = game.scene.getPlayerPokemon()!;
game.move.select(MoveId.SNOWSCAPE);
await game.toNextTurn();
expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SNOW);
game.move.select(MoveId.CHILLY_RECEPTION);
game.doSelectPartyPokemon(1);
await game.toEndOfTurn();
expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SNOW);
expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase");
expect(game.scene.getPlayerPokemon()).toBe(slowking);
expect(slowking.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(game.textInterceptor.logs).toContain(
i18next.t("moveTriggers:chillyReception", { pokemonName: getPokemonNameWithAffix(slowking) }),
);
});
it("should succeed without message if called indirectly", async () => {
vi.spyOn(RandomMoveAttr.prototype, "getMoveOverride").mockReturnValue(MoveId.CHILLY_RECEPTION);
await game.classicMode.startBattle([SpeciesId.SLOWKING, SpeciesId.MEOWTH]); await game.classicMode.startBattle([SpeciesId.SLOWKING, SpeciesId.MEOWTH]);
game.move.select(MoveId.CHILLY_RECEPTION); const [slowking, meowth] = game.scene.getPlayerParty();
game.doSelectPartyPokemon(1);
game.move.select(MoveId.METRONOME);
game.doSelectPartyPokemon(1);
await game.toEndOfTurn();
await game.phaseInterceptor.to("BerryPhase", false);
expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SNOW); expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SNOW);
expect(game.scene.getPlayerField()[0].species.speciesId).toBe(SpeciesId.MEOWTH); expect(game.scene.getPlayerPokemon()).toBe(meowth);
expect(slowking.isOnField()).toBe(false);
expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase");
expect(game.textInterceptor.logs).not.toContain(
i18next.t("moveTriggers:chillyReception", { pokemonName: getPokemonNameWithAffix(slowking) }),
);
}); });
// enemy uses another move and weather doesn't change // Bugcheck test for enemy AI bug
it("check case - enemy not selecting chilly reception doesn't change weather ", async () => { it("check case - enemy not selecting chilly reception doesn't change weather", async () => {
game.override.battleStyle("single").enemyMoveset([MoveId.CHILLY_RECEPTION, MoveId.TACKLE]).moveset(MoveId.SPLASH); game.override.enemyMoveset([MoveId.CHILLY_RECEPTION, MoveId.TACKLE]);
await game.classicMode.startBattle([SpeciesId.SLOWKING, SpeciesId.MEOWTH]); await game.classicMode.startBattle([SpeciesId.SLOWKING, SpeciesId.MEOWTH]);
game.move.select(MoveId.SPLASH); game.move.select(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.TACKLE); await game.move.selectEnemyMove(MoveId.TACKLE);
await game.toEndOfTurn();
await game.phaseInterceptor.to("BerryPhase", false); expect(game.scene.arena.weather?.weatherType).toBeUndefined();
expect(game.scene.arena.weather?.weatherType).toBe(undefined);
});
it("enemy trainer - expected behavior ", async () => {
game.override
.battleStyle("single")
.startingWave(8)
.enemyMoveset(MoveId.CHILLY_RECEPTION)
.enemySpecies(SpeciesId.MAGIKARP)
.moveset([MoveId.SPLASH, MoveId.THUNDERBOLT]);
await game.classicMode.startBattle([SpeciesId.JOLTEON]);
const RIVAL_MAGIKARP1 = game.scene.getEnemyPokemon()?.id;
game.move.select(MoveId.SPLASH);
await game.phaseInterceptor.to("BerryPhase", false);
expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SNOW);
expect(game.scene.getEnemyPokemon()?.id !== RIVAL_MAGIKARP1);
await game.phaseInterceptor.to("TurnInitPhase", false);
game.move.select(MoveId.SPLASH);
// second chilly reception should still switch out
await game.phaseInterceptor.to("BerryPhase", false);
expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SNOW);
await game.phaseInterceptor.to("TurnInitPhase", false);
expect(game.scene.getEnemyPokemon()?.id === RIVAL_MAGIKARP1);
game.move.select(MoveId.THUNDERBOLT);
// enemy chilly recep move should fail: it's snowing and no option to switch out
// no crashing
await game.phaseInterceptor.to("BerryPhase", false);
expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SNOW);
await game.phaseInterceptor.to("TurnInitPhase", false);
expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SNOW);
game.move.select(MoveId.SPLASH);
await game.phaseInterceptor.to("BerryPhase", false);
expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SNOW);
}); });
}); });

View File

@ -209,9 +209,9 @@ export class OverridesHelper extends GameManagerHelper {
} }
/** /**
* Override the player pokemon's {@linkcode StatusEffect | status-effect} * Override the player pokemon's initial {@linkcode StatusEffect | status-effect},
* @param statusEffect - The {@linkcode StatusEffect | status-effect} to set * @param statusEffect - The {@linkcode StatusEffect | status-effect} to set
* @returns * @returns `this`
*/ */
public statusEffect(statusEffect: StatusEffect): this { public statusEffect(statusEffect: StatusEffect): this {
vi.spyOn(Overrides, "STATUS_OVERRIDE", "get").mockReturnValue(statusEffect); vi.spyOn(Overrides, "STATUS_OVERRIDE", "get").mockReturnValue(statusEffect);
@ -401,9 +401,9 @@ export class OverridesHelper extends GameManagerHelper {
} }
/** /**
* Override the enemy {@linkcode StatusEffect | status-effect} for enemy pokemon * Override the enemy pokemon's initial {@linkcode StatusEffect | status-effect}.
* @param statusEffect - The {@linkcode StatusEffect | status-effect} to set * @param statusEffect - The {@linkcode StatusEffect | status-effect} to set
* @returns * @returns `this`
*/ */
public enemyStatusEffect(statusEffect: StatusEffect): this { public enemyStatusEffect(statusEffect: StatusEffect): this {
vi.spyOn(Overrides, "OPP_STATUS_OVERRIDE", "get").mockReturnValue(statusEffect); vi.spyOn(Overrides, "OPP_STATUS_OVERRIDE", "get").mockReturnValue(statusEffect);