This commit is contained in:
Bertie690 2025-06-19 18:46:07 -06:00 committed by GitHub
commit f0c58fa687
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 864 additions and 462 deletions

View File

@ -1089,12 +1089,10 @@ export class GravityTag extends ArenaTag {
onAdd(_arena: Arena): void {
globalScene.phaseManager.queueMessage(i18next.t("arenaTag:gravityOnAdd"));
globalScene.getField(true).forEach(pokemon => {
if (pokemon !== null) {
pokemon.removeTag(BattlerTagType.FLOATING);
pokemon.removeTag(BattlerTagType.TELEKINESIS);
if (pokemon.getTag(BattlerTagType.FLYING)) {
pokemon.addTag(BattlerTagType.INTERRUPTED);
}
pokemon.removeTag(BattlerTagType.FLOATING);
pokemon.removeTag(BattlerTagType.TELEKINESIS);
if (pokemon.getTag(BattlerTagType.FLYING)) {
pokemon.addTag(BattlerTagType.INTERRUPTED);
}
});
}

View File

@ -633,6 +633,10 @@ export class FlinchedTag extends BattlerTag {
}
}
/**
* Tag to cancel the target's action when knocked out of a flying move by Smack Down or Gravity.
*/
// TODO: This is not a very good way to cancel a semi invulnerable turn
export class InterruptedTag extends BattlerTag {
constructor(sourceMove: MoveId) {
super(BattlerTagType.INTERRUPTED, BattlerTagLapseType.PRE_MOVE, 0, sourceMove);

View File

@ -5295,13 +5295,11 @@ export class VariableMoveTypeMultiplierAttr extends MoveAttr {
}
export class NeutralDamageAgainstFlyingTypeMultiplierAttr extends VariableMoveTypeMultiplierAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
if (!target.getTag(BattlerTagType.IGNORE_FLYING)) {
const multiplier = args[0] as NumberHolder;
//When a flying type is hit, the first hit is always 1x multiplier.
if (target.isOfType(PokemonType.FLYING)) {
multiplier.value = 1;
}
apply(user: Pokemon, target: Pokemon, move: Move, args: [NumberHolder]): boolean {
if (!target.isGrounded(true) && target.isOfType(PokemonType.FLYING)) {
const multiplier = args[0];
// When a flying type is hit, the first hit is always 1x multiplier.
multiplier.value = 1;
return true;
}
@ -5637,8 +5635,8 @@ export class LeechSeedAttr extends AddBattlerTagAttr {
}
/**
* Adds the appropriate battler tag for Smack Down and Thousand arrows
* @extends AddBattlerTagAttr
* Attribute to add the {@linkcode BattlerTagType.IGNORE_FLYING | IGNORE_FLYING} battler tag to the target
* and remove any prior sources of ungroundedness.
*/
export class FallDownAttr extends AddBattlerTagAttr {
constructor() {
@ -5647,16 +5645,28 @@ export class FallDownAttr extends AddBattlerTagAttr {
/**
* Adds Grounded Tag to the target and checks if fallDown message should be displayed
* @param user the {@linkcode Pokemon} using the move
* @param target the {@linkcode Pokemon} targeted by the move
* @param move the {@linkcode Move} invoking this effect
* @param user - The {@linkcode Pokemon} using the move
* @param target - The {@linkcode Pokemon} targeted by the move
* @param move - The {@linkcode Move} invoking this effect
* @param args n/a
* @returns `true` if the effect successfully applies; `false` otherwise
* @returns Whether the target was successfully brought down to earth.
*/
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
if (!target.isGrounded()) {
globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:fallDown", { targetPokemonName: getPokemonNameWithAffix(target) }));
// Smack down and similar only add their tag if the pokemon is already ungrounded
// TODO: Does this work if the target is semi-invulnerable?
if (target.isGrounded(true)) {
return false;
}
// Remove the target's flying/floating state and telekinesis, interrupting fly/bounce's activation.
// TODO: This is not a good way to remove flying...
target.removeTag(BattlerTagType.FLOATING);
target.removeTag(BattlerTagType.TELEKINESIS);
if (target.getTag(BattlerTagType.FLYING)) {
target.addTag(BattlerTagType.INTERRUPTED);
}
globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:fallDown", { targetPokemonName: getPokemonNameWithAffix(target) }));
return super.apply(user, target, move, args);
}
}
@ -7979,6 +7989,7 @@ const phaseForcedSlower = (phase: MovePhase, target: Pokemon, trickRoom: boolean
return phase.isForcedLast() && slower;
};
// TODO: This needs to become unselectable, not merely fail
const failOnGravityCondition: MoveConditionFunc = (user, target, move) => !globalScene.arena.getTag(ArenaTagType.GRAVITY);
const failOnBossCondition: MoveConditionFunc = (user, target, move) => !target.isBossImmune();
@ -9288,6 +9299,7 @@ export function initMoves() {
.attr(RandomMovesetMoveAttr, invalidAssistMoves, true),
new SelfStatusMove(MoveId.INGRAIN, PokemonType.GRASS, -1, 20, -1, 0, 3)
.attr(AddBattlerTagAttr, BattlerTagType.INGRAIN, true, true)
// NB: We add the tag directly to avoid removing Telekinesis' accuracy boost
.attr(AddBattlerTagAttr, BattlerTagType.IGNORE_FLYING, true, true)
.attr(RemoveBattlerTagAttr, [ BattlerTagType.FLOATING ], true),
new AttackMove(MoveId.SUPERPOWER, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 120, 100, 5, -1, 0, 3)
@ -9915,10 +9927,10 @@ export function initMoves() {
.unimplemented(),
new AttackMove(MoveId.SMACK_DOWN, PokemonType.ROCK, MoveCategory.PHYSICAL, 50, 100, 15, -1, 0, 5)
.attr(FallDownAttr)
.attr(AddBattlerTagAttr, BattlerTagType.INTERRUPTED)
.attr(RemoveBattlerTagAttr, [ BattlerTagType.FLYING, BattlerTagType.FLOATING, BattlerTagType.TELEKINESIS ])
.attr(HitsTagAttr, BattlerTagType.FLYING)
.makesContact(false),
.makesContact(false)
// TODO: Confirm if Smack Down & Thousand Arrows will ground semi-invulnerable Pokemon with No Guard, etc.
.edgeCase(),
new AttackMove(MoveId.STORM_THROW, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 60, 100, 10, -1, 0, 5)
.attr(CritOnlyAttr),
new AttackMove(MoveId.FLAME_BURST, PokemonType.FIRE, MoveCategory.SPECIAL, 70, 100, 15, -1, 0, 5)
@ -10377,10 +10389,10 @@ export function initMoves() {
.attr(FallDownAttr)
.attr(HitsTagAttr, BattlerTagType.FLYING)
.attr(HitsTagAttr, BattlerTagType.FLOATING)
.attr(AddBattlerTagAttr, BattlerTagType.INTERRUPTED)
.attr(RemoveBattlerTagAttr, [ BattlerTagType.FLYING, BattlerTagType.FLOATING, BattlerTagType.TELEKINESIS ])
.makesContact(false)
.target(MoveTarget.ALL_NEAR_ENEMIES),
.target(MoveTarget.ALL_NEAR_ENEMIES)
// TODO: Confirm if Smack Down & Thousand Arrows will ground semi-invulnerable Pokemon with No Guard, etc.
.edgeCase(),
new AttackMove(MoveId.THOUSAND_WAVES, PokemonType.GROUND, MoveCategory.PHYSICAL, 90, 100, 10, -1, 0, 6)
.attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, false, 1, 1, true)
.makesContact(false)

View File

@ -261,10 +261,9 @@ export function getTypeDamageMultiplier(attackType: PokemonType, defType: Pokemo
return 1;
}
case PokemonType.STELLAR:
default:
return 1;
}
return 1;
}
/**

View File

@ -2228,13 +2228,29 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
return this.teraType;
}
public isGrounded(): boolean {
/**
* Return whether this Pokemon is currently on the ground.
*
* To be considered grounded, a Pokemon must either:
* * Be {@linkcode GroundedTag | forcibly grounded} from an effect like Smack Down or Ingrain
* * Be under the effects of {@linkcode ArenaTagType.GRAVITY | harsh gravity}
* * **Not** be any of the following things:
* * {@linkcode PokemonType.FLYING | Flying-type}
* * {@linkcode Abilities.LEVITATE | Levitating}
* * {@linkcode BattlerTagType.FLOATING | Floating} from Magnet Rise, etc.
* * Currently {@linkcode SemiInvulnerableTag | semi-invulnerable} with `ignoreSemiInvulnerable` set to `false`
* @param ignoreSemiInvulnerable - Whether to ignore the target's semi-invulnerable state when determining groundedness;
default `false`
* @returns Whether this pokemon is currently grounded, as described above.
*/
public isGrounded(ignoreSemiInvulnerable = false): boolean {
return (
!!this.getTag(GroundedTag) ||
!!globalScene.arena.hasTag(ArenaTagType.GRAVITY) ||
(!this.isOfType(PokemonType.FLYING, true, true) &&
!this.hasAbility(AbilityId.LEVITATE) &&
!this.getTag(BattlerTagType.FLOATING) &&
!this.getTag(SemiInvulnerableTag))
(ignoreSemiInvulnerable || !this.getTag(SemiInvulnerableTag)))
);
}
@ -2412,7 +2428,8 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
// Handle flying v ground type immunity without removing flying type so effective types are still effective
// Related to https://github.com/pagefaultgames/pokerogue/issues/524
if (moveType === PokemonType.GROUND && (this.isGrounded() || arena.hasTag(ArenaTagType.GRAVITY))) {
//
if (moveType === PokemonType.GROUND && this.isGrounded()) {
const flyingIndex = types.indexOf(PokemonType.FLYING);
if (flyingIndex > -1) {
types.splice(flyingIndex, 1);

View File

@ -1,5 +1,4 @@
import { BattlerIndex } from "#enums/battler-index";
import { PostTurnRestoreBerryAbAttr } from "#app/data/abilities/ability";
import type Pokemon from "#app/field/pokemon";
import { BerryModifier, PreserveBerryModifier } from "#app/modifier/modifier";
import type { ModifierOverride } from "#app/modifier/modifier-type";
@ -53,63 +52,59 @@ describe("Abilities - Harvest", () => {
.enemyLevel(1)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset([MoveId.SPLASH, MoveId.NUZZLE, MoveId.KNOCK_OFF, MoveId.INCINERATE]);
.enemyMoveset([MoveId.SPLASH, MoveId.NUZZLE]);
});
it("replenishes eaten berries", async () => {
it("should replenish the user's eaten berries at end of turn", async () => {
game.override.startingHeldItems([{ name: "BERRY", type: BerryType.LUM, count: 1 }]);
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
game.move.select(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.NUZZLE);
await game.phaseInterceptor.to("BerryPhase");
expect(getPlayerBerries()).toHaveLength(0);
expectBerriesContaining();
expect(game.scene.getPlayerPokemon()?.battleData.berriesEaten).toHaveLength(1);
await game.phaseInterceptor.to("TurnEndPhase");
await game.toEndOfTurn();
expectBerriesContaining({ name: "BERRY", type: BerryType.LUM, count: 1 });
expect(game.scene.getPlayerPokemon()?.battleData.berriesEaten).toEqual([]);
});
it("tracks berries eaten while disabled/not present", async () => {
// Note: this also checks for harvest not being present as neutralizing gas works by making
// the game consider all other pokemon to *not* have their respective abilities.
it("tracks berries eaten while neutralized/not present", async () => {
game.override
.startingHeldItems([
{ name: "BERRY", type: BerryType.ENIGMA, count: 2 },
{ name: "BERRY", type: BerryType.LUM, count: 2 },
])
.enemyAbility(AbilityId.NEUTRALIZING_GAS);
.ability(AbilityId.BALL_FETCH);
await game.classicMode.startBattle([SpeciesId.MILOTIC]);
const milotic = game.scene.getPlayerPokemon()!;
expect(milotic).toBeDefined();
// Chug a few berries without harvest (should get tracked)
game.move.select(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.NUZZLE);
game.move.use(MoveId.SPLASH);
await game.move.forceEnemyMove(MoveId.NUZZLE);
await game.toNextTurn();
const milotic = game.field.getPlayerPokemon();
expect(milotic.battleData.berriesEaten).toEqual(expect.arrayContaining([BerryType.ENIGMA, BerryType.LUM]));
expect(getPlayerBerries()).toHaveLength(2);
// Give ourselves harvest and disable enemy neut gas,
// but force our roll to fail so we don't accidentally recover anything
vi.spyOn(PostTurnRestoreBerryAbAttr.prototype, "canApplyPostTurn").mockReturnValueOnce(false);
game.override.ability(AbilityId.HARVEST);
game.move.select(MoveId.GASTRO_ACID);
await game.move.selectEnemyMove(MoveId.NUZZLE);
// Give ourselves harvest, blocked by enemy neut gas
game.field.mockAbility(milotic, AbilityId.HARVEST);
game.field.mockAbility(game.field.getEnemyPokemon(), AbilityId.NEUTRALIZING_GAS);
game.move.use(MoveId.SPLASH);
await game.move.forceEnemyMove(MoveId.NUZZLE);
await game.toNextTurn();
expect(milotic.battleData.berriesEaten).toEqual(
expect.arrayContaining([BerryType.ENIGMA, BerryType.LUM, BerryType.ENIGMA, BerryType.LUM]),
);
expect(getPlayerBerries()).toHaveLength(0);
expectBerriesContaining();
// proc a high roll and we _should_ get a berry back!
// Disable neut gas and we _should_ get a berry back!
game.field.mockAbility(game.field.getEnemyPokemon(), AbilityId.BALL_FETCH);
game.move.select(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.toNextTurn();
expect(milotic.battleData.berriesEaten).toHaveLength(3);
@ -128,7 +123,7 @@ describe("Abilities - Harvest", () => {
game.move.select(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.doKillOpponents();
await game.phaseInterceptor.to("TurnEndPhase");
await game.toEndOfTurn();
// ate 1 berry without recovering (no harvest)
expect(regieleki.battleData.berriesEaten).toEqual([BerryType.PETAYA]);
@ -142,7 +137,7 @@ describe("Abilities - Harvest", () => {
expect(regieleki.getStatStage(Stat.SPATK)).toBe(1);
});
it("keeps harvested berries across reloads", async () => {
it("should keep harvested berries across reloads", async () => {
game.override
.startingHeldItems([{ name: "BERRY", type: BerryType.PETAYA, count: 1 }])
.moveset([MoveId.SPLASH, MoveId.EARTHQUAKE])
@ -179,7 +174,7 @@ describe("Abilities - Harvest", () => {
expect(game.scene.getPlayerPokemon()?.getStatStage(Stat.SPATK)).toBe(1);
});
it("cannot restore capped berries", async () => {
it("should not restore capped berries", async () => {
const initBerries: ModifierOverride[] = [
{ name: "BERRY", type: BerryType.LUM, count: 2 },
{ name: "BERRY", type: BerryType.STARF, count: 2 },
@ -198,7 +193,7 @@ describe("Abilities - Harvest", () => {
// This does nothing on a success (since there'd only be a starf left to grab),
// but ensures we don't accidentally let any false positives through.
vi.spyOn(Phaser.Math.RND, "integerInRange").mockReturnValue(0);
await game.phaseInterceptor.to("TurnEndPhase");
await game.toEndOfTurn();
// recovered a starf
expectBerriesContaining(
@ -207,7 +202,7 @@ describe("Abilities - Harvest", () => {
);
});
it("does nothing if all berries are capped", async () => {
it("should do nothing if all berries are capped", async () => {
const initBerries: ModifierOverride[] = [
{ name: "BERRY", type: BerryType.LUM, count: 2 },
{ name: "BERRY", type: BerryType.STARF, count: 3 },
@ -220,48 +215,41 @@ describe("Abilities - Harvest", () => {
game.move.select(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.phaseInterceptor.to("TurnEndPhase");
await game.toEndOfTurn();
expectBerriesContaining(...initBerries);
});
describe("move/ability interactions", () => {
it("cannot restore incinerated berries", async () => {
describe("Move/Ability interactions", () => {
it.each<{ name: string; move: MoveId }>([
{ name: "Incinerate", move: MoveId.INCINERATE },
{ name: "Knock Off", move: MoveId.KNOCK_OFF },
])("should not restore berries removed by $name", async ({ move }) => {
game.override.startingHeldItems([{ name: "BERRY", type: BerryType.STARF, count: 3 }]);
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
game.move.select(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.INCINERATE);
await game.phaseInterceptor.to("TurnEndPhase");
await game.move.forceEnemyMove(move);
await game.toEndOfTurn();
expect(game.scene.getPlayerPokemon()?.battleData.berriesEaten).toEqual([]);
expectBerriesContaining();
});
it("cannot restore knocked off berries", async () => {
game.override.startingHeldItems([{ name: "BERRY", type: BerryType.STARF, count: 3 }]);
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
game.move.select(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.KNOCK_OFF);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.getPlayerPokemon()?.battleData.berriesEaten).toEqual([]);
});
it("can restore berries eaten by Teatime", async () => {
it("should restore berries eaten by Teatime", async () => {
const initBerries: ModifierOverride[] = [{ name: "BERRY", type: BerryType.STARF, count: 1 }];
game.override.startingHeldItems(initBerries).enemyMoveset(MoveId.TEATIME);
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
// nom nom the berr berr yay yay
game.move.select(MoveId.SPLASH);
await game.phaseInterceptor.to("TurnEndPhase");
await game.toEndOfTurn();
expect(game.scene.getPlayerPokemon()?.battleData.berriesEaten).toEqual([]);
expectBerriesContaining(...initBerries);
});
it("cannot restore Plucked berries for either side", async () => {
it("should not restore Plucked berries for either side", async () => {
const initBerries: ModifierOverride[] = [{ name: "BERRY", type: BerryType.PETAYA, count: 1 }];
game.override.startingHeldItems(initBerries).enemyAbility(AbilityId.HARVEST).enemyMoveset(MoveId.PLUCK);
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
@ -273,10 +261,10 @@ describe("Abilities - Harvest", () => {
// pluck triggers harvest for neither side
expect(game.scene.getPlayerPokemon()?.battleData.berriesEaten).toEqual([]);
expect(game.scene.getEnemyPokemon()?.battleData.berriesEaten).toEqual([]);
expect(getPlayerBerries()).toEqual([]);
expectBerriesContaining();
});
it("cannot restore berries preserved via Berry Pouch", async () => {
it("should not restore berries preserved via Berry Pouch", async () => {
// mock berry pouch to have a 100% success rate
vi.spyOn(PreserveBerryModifier.prototype, "apply").mockImplementation(
(_pokemon: Pokemon, doPreserve: BooleanHolder): boolean => {
@ -297,7 +285,7 @@ describe("Abilities - Harvest", () => {
expectBerriesContaining(...initBerries);
});
it("can restore stolen berries", async () => {
it("should restore berries stolen from another Pokemon", async () => {
const initBerries: ModifierOverride[] = [{ name: "BERRY", type: BerryType.SITRUS, count: 1 }];
game.override.enemyHeldItems(initBerries).passiveAbility(AbilityId.MAGICIAN).hasPassiveAbility(true);
await game.classicMode.startBattle([SpeciesId.MEOWSCARADA]);
@ -312,22 +300,23 @@ describe("Abilities - Harvest", () => {
await game.phaseInterceptor.to("BerryPhase");
expect(player.battleData.berriesEaten).toEqual([BerryType.SITRUS]);
await game.phaseInterceptor.to("TurnEndPhase");
await game.toEndOfTurn();
expect(player.battleData.berriesEaten).toEqual([]);
expectBerriesContaining(...initBerries);
});
// TODO: Enable once fling actually works...???
it.todo("can restore berries flung at user", async () => {
game.override.enemyHeldItems([{ name: "BERRY", type: BerryType.STARF, count: 1 }]).enemyMoveset(MoveId.FLING);
it.todo("should restore berries flung at user", async () => {
game.override.enemyHeldItems([{ name: "BERRY", type: BerryType.STARF, count: 1 }]);
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
game.move.select(MoveId.SPLASH);
await game.phaseInterceptor.to("TurnEndPhase");
await game.move.forceEnemyMove(MoveId.FLING);
await game.toEndOfTurn();
expect(game.scene.getPlayerPokemon()?.battleData.berriesEaten).toBe([]);
expect(getPlayerBerries()).toEqual([]);
expectBerriesContaining();
});
// TODO: Enable once Nat Gift gets implemented...???

View File

@ -1,7 +1,5 @@
import { BattlerIndex } from "#enums/battler-index";
import { AbilityId } from "#enums/ability-id";
import { CommandPhase } from "#app/phases/command-phase";
import { TurnEndPhase } from "#app/phases/turn-end-phase";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { StatusEffect } from "#enums/status-effect";
@ -25,48 +23,34 @@ describe("Abilities - Pastel Veil", () => {
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.battleStyle("double")
.moveset([MoveId.TOXIC_THREAD, MoveId.SPLASH])
.enemyAbility(AbilityId.BALL_FETCH)
.enemySpecies(SpeciesId.SUNKERN)
.enemyMoveset(MoveId.SPLASH);
game.override.battleStyle("double").enemyAbility(AbilityId.BALL_FETCH).enemySpecies(SpeciesId.SUNKERN);
});
it("prevents the user and its allies from being afflicted by poison", async () => {
it("should prevent the user and its allies from being poisoned", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.GALAR_PONYTA]);
const ponyta = game.scene.getPlayerField()[1];
const magikarp = game.scene.getPlayerField()[0];
ponyta.abilityIndex = 1;
const [magikarp, ponyta] = game.scene.getPlayerField();
game.field.mockAbility(ponyta, AbilityId.PASTEL_VEIL);
expect(ponyta.hasAbility(AbilityId.PASTEL_VEIL)).toBe(true);
game.move.select(MoveId.SPLASH);
game.move.select(MoveId.TOXIC_THREAD, 1, BattlerIndex.PLAYER);
await game.phaseInterceptor.to(TurnEndPhase);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2);
await game.move.forceEnemyMove(MoveId.TOXIC, BattlerIndex.PLAYER);
await game.move.forceEnemyMove(MoveId.TOXIC, BattlerIndex.PLAYER_2);
await game.toEndOfTurn();
expect(magikarp.status?.effect).toBeUndefined();
});
it("it heals the poisoned status condition of allies if user is sent out into battle", async () => {
it("should heal allies' poison status if user is sent out into battle", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS, SpeciesId.GALAR_PONYTA]);
const ponyta = game.scene.getPlayerParty()[2];
const magikarp = game.scene.getPlayerField()[0];
ponyta.abilityIndex = 1;
const [magikarp, , ponyta] = game.scene.getPlayerParty();
game.field.mockAbility(ponyta, AbilityId.PASTEL_VEIL);
expect(ponyta.hasAbility(AbilityId.PASTEL_VEIL)).toBe(true);
game.move.select(MoveId.SPLASH);
game.move.select(MoveId.TOXIC_THREAD, 1, BattlerIndex.PLAYER);
await game.phaseInterceptor.to(TurnEndPhase);
magikarp.trySetStatus(StatusEffect.POISON);
expect(magikarp.status?.effect).toBe(StatusEffect.POISON);
await game.phaseInterceptor.to(CommandPhase);
game.move.select(MoveId.SPLASH);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER);
game.doSwitchPokemon(2);
await game.phaseInterceptor.to(TurnEndPhase);
await game.toEndOfTurn();
expect(magikarp.status?.effect).toBeUndefined();
});

View File

@ -1,9 +1,7 @@
import { BattlerIndex } from "#enums/battler-index";
import { PokemonType } from "#enums/pokemon-type";
import { AbilityId } from "#enums/ability-id";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { Stat } from "#enums/stat";
import GameManager from "#test/testUtils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
@ -29,15 +27,14 @@ describe("Abilities - Sheer Force", () => {
.battleStyle("single")
.ability(AbilityId.SHEER_FORCE)
.enemySpecies(SpeciesId.ONIX)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset([MoveId.SPLASH])
.enemyAbility(AbilityId.NO_GUARD)
.enemyMoveset(MoveId.SPLASH)
.criticalHits(false);
});
const SHEER_FORCE_MULT = 1.3;
it("Sheer Force should boost the power of the move but disable secondary effects", async () => {
game.override.moveset([MoveId.AIR_SLASH]);
it("should boost move power by 1.3x, disabling all secondary effects in the process", async () => {
await game.classicMode.startBattle([SpeciesId.SHUCKLE]);
const airSlashMove = allMoves[MoveId.AIR_SLASH];
@ -45,110 +42,95 @@ describe("Abilities - Sheer Force", () => {
const airSlashFlinchAttr = airSlashMove.getAttrs("FlinchAttr")[0];
vi.spyOn(airSlashFlinchAttr, "getMoveChance");
game.move.select(MoveId.AIR_SLASH);
game.move.use(MoveId.AIR_SLASH);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.move.forceHit();
await game.phaseInterceptor.to("BerryPhase", false);
await game.toEndOfTurn();
expect(airSlashMove.calculateBattlePower).toHaveLastReturnedWith(airSlashMove.power * SHEER_FORCE_MULT);
expect(airSlashFlinchAttr.getMoveChance).toHaveLastReturnedWith(0);
});
it("Sheer Force does not affect the base damage or secondary effects of binding moves", async () => {
game.override.moveset([MoveId.BIND]);
it("should affect the base power of binding moves", async () => {
await game.classicMode.startBattle([SpeciesId.SHUCKLE]);
const bindMove = allMoves[MoveId.BIND];
vi.spyOn(bindMove, "calculateBattlePower");
game.move.select(MoveId.BIND);
game.move.use(MoveId.BIND);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.move.forceHit();
await game.phaseInterceptor.to("BerryPhase", false);
await game.toEndOfTurn();
expect(bindMove.calculateBattlePower).toHaveLastReturnedWith(bindMove.power);
});
it("Sheer Force does not boost the base damage of moves with no secondary effect", async () => {
game.override.moveset([MoveId.TACKLE]);
it("should not boost power of moves lacking a secondary effect", async () => {
await game.classicMode.startBattle([SpeciesId.PIDGEOT]);
const tackleMove = allMoves[MoveId.TACKLE];
vi.spyOn(tackleMove, "calculateBattlePower");
game.move.select(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.move.forceHit();
await game.phaseInterceptor.to("BerryPhase", false);
game.move.use(MoveId.TACKLE);
await game.toEndOfTurn();
expect(tackleMove.calculateBattlePower).toHaveLastReturnedWith(tackleMove.power);
});
it("Sheer Force can disable the on-hit activation of specific abilities", async () => {
game.override
.moveset([MoveId.HEADBUTT])
.enemySpecies(SpeciesId.SQUIRTLE)
.enemyLevel(10)
.enemyAbility(AbilityId.COLOR_CHANGE);
it.each<{ name: string; ability: AbilityId }>([
{ name: "Color Change", ability: AbilityId.COLOR_CHANGE },
{ name: "Pickpocket", ability: AbilityId.PICKPOCKET },
{ name: "Berserk", ability: AbilityId.BERSERK },
{ name: "Anger Shell", ability: AbilityId.ANGER_SHELL },
{ name: "Wimp Out", ability: AbilityId.WIMP_OUT },
{ name: "Emergency Exit", ability: AbilityId.EMERGENCY_EXIT },
])("should disable $name on hit when using boosted moves", async ({ ability }) => {
game.override.enemySpecies(SpeciesId.SQUIRTLE).enemyLevel(100).enemyAbility(ability);
await game.classicMode.startBattle([SpeciesId.PIDGEOT]);
const enemyPokemon = game.scene.getEnemyPokemon();
const enemyPokemon = game.scene.getEnemyPokemon()!;
enemyPokemon.hp = enemyPokemon.getMaxHp() / 2 + 1; // ensures wimp out works
const headbuttMove = allMoves[MoveId.HEADBUTT];
vi.spyOn(headbuttMove, "calculateBattlePower");
const headbuttFlinchAttr = headbuttMove.getAttrs("FlinchAttr")[0];
vi.spyOn(headbuttFlinchAttr, "getMoveChance");
game.move.select(MoveId.HEADBUTT);
game.move.use(MoveId.HEADBUTT);
await game.toEndOfTurn();
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.move.forceHit();
await game.phaseInterceptor.to("BerryPhase", false);
expect(enemyPokemon?.getTypes()[0]).toBe(PokemonType.WATER);
// ability was disabled when using boosted attack
expect(game.field.getEnemyPokemon()).toBe(enemyPokemon); // covers wimp out switch stuff
expect(enemyPokemon.waveData.abilitiesApplied).not.toContain(ability);
expect(headbuttMove.calculateBattlePower).toHaveLastReturnedWith(headbuttMove.power * SHEER_FORCE_MULT);
expect(headbuttFlinchAttr.getMoveChance).toHaveLastReturnedWith(0);
});
it("Two Pokemon with abilities disabled by Sheer Force hitting each other should not cause a crash", async () => {
const moveToUse = MoveId.CRUNCH;
game.override
.enemyAbility(AbilityId.COLOR_CHANGE)
.ability(AbilityId.COLOR_CHANGE)
.moveset(moveToUse)
.enemyMoveset(moveToUse);
it("should not crash when 2 pokemon with disabled abilities attack each other", async () => {
game.override.enemyAbility(AbilityId.COLOR_CHANGE).ability(AbilityId.COLOR_CHANGE);
await game.classicMode.startBattle([SpeciesId.PIDGEOT]);
const pidgeot = game.scene.getPlayerParty()[0];
const onix = game.scene.getEnemyParty()[0];
const pidgeot = game.field.getPlayerPokemon();
const onix = game.field.getEnemyPokemon();
pidgeot.stats[Stat.DEF] = 10000;
onix.stats[Stat.DEF] = 10000;
game.move.select(moveToUse);
game.move.select(MoveId.CRUNCH);
await game.move.forceEnemyMove(MoveId.CRUNCH);
await game.toNextTurn();
// Check that both Pokemon's Color Change activated
const expectedTypes = [allMoves[moveToUse].type];
const expectedTypes = [allMoves[MoveId.CRUNCH].type];
expect(pidgeot.getTypes()).toStrictEqual(expectedTypes);
expect(onix.getTypes()).toStrictEqual(expectedTypes);
});
it("Sheer Force should disable Meloetta's transformation from Relic Song", async () => {
game.override
.ability(AbilityId.SHEER_FORCE)
.moveset([MoveId.RELIC_SONG])
.enemyMoveset([MoveId.SPLASH])
.enemyLevel(100);
it("should disable Meloetta's transformation from Relic Song", async () => {
game.override.moveset(MoveId.RELIC_SONG);
await game.classicMode.startBattle([SpeciesId.MELOETTA]);
const playerPokemon = game.scene.getPlayerPokemon();
const formKeyStart = playerPokemon?.getFormKey();
const playerPokemon = game.field.getPlayerPokemon();
const formKeyStart = playerPokemon.getFormKey();
game.move.select(MoveId.RELIC_SONG);
await game.phaseInterceptor.to("TurnEndPhase");
await game.toEndOfTurn();
expect(formKeyStart).toBe(playerPokemon?.getFormKey());
});
});

View File

@ -1,10 +1,10 @@
import { BattlerIndex } from "#enums/battler-index";
import { AbilityId } from "#enums/ability-id";
import { BattlerTagType } from "#app/enums/battler-tag-type";
import { CommandPhase } from "#app/phases/command-phase";
import { TurnEndPhase } from "#app/phases/turn-end-phase";
import { MoveResult } from "#enums/move-result";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { StatusEffect } from "#enums/status-effect";
import GameManager from "#test/testUtils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
@ -25,65 +25,107 @@ describe("Abilities - Sweet Veil", () => {
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.battleStyle("double")
.moveset([MoveId.SPLASH, MoveId.REST, MoveId.YAWN])
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.POWDER);
game.override.battleStyle("double").enemySpecies(SpeciesId.MAGIKARP).enemyAbility(AbilityId.BALL_FETCH);
});
it("prevents the user and its allies from falling asleep", async () => {
function expectNoStatus() {
game.scene.getPlayerField().forEach(p => expect.soft(p.status?.effect).toBeUndefined());
}
it("should prevent the user and its allies from falling asleep", async () => {
await game.classicMode.startBattle([SpeciesId.SWIRLIX, SpeciesId.MAGIKARP]);
game.move.select(MoveId.SPLASH);
game.move.select(MoveId.SPLASH, 1);
game.field.mockAbility(game.field.getPlayerPokemon(), AbilityId.SWEET_VEIL);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2);
await game.move.forceEnemyMove(MoveId.SPORE, BattlerIndex.PLAYER);
await game.move.forceEnemyMove(MoveId.SPORE, BattlerIndex.PLAYER_2);
await game.toEndOfTurn();
await game.phaseInterceptor.to(TurnEndPhase);
expect(game.scene.getPlayerField().every(p => p.status?.effect)).toBe(false);
expectNoStatus();
});
it("causes Rest to fail when used by the user or its allies", async () => {
game.override.enemyMoveset(MoveId.SPLASH);
it("should cause Rest to fail when used by the user or its allies, unless the ally has Mold Breaker", async () => {
await game.classicMode.startBattle([SpeciesId.SWIRLIX, SpeciesId.MAGIKARP]);
game.move.select(MoveId.SPLASH);
game.move.select(MoveId.REST, 1);
const [swirlix, magikarp] = game.scene.getPlayerField();
game.field.mockAbility(swirlix, AbilityId.SWEET_VEIL);
await game.phaseInterceptor.to(TurnEndPhase);
swirlix.hp = 1;
magikarp.hp = 1;
game.move.use(MoveId.REST, BattlerIndex.PLAYER);
game.move.use(MoveId.REST, BattlerIndex.PLAYER_2);
await game.toEndOfTurn();
expect(game.scene.getPlayerField().every(p => p.status?.effect)).toBe(false);
expectNoStatus();
expect(swirlix.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(magikarp.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
game.field.mockAbility(magikarp, AbilityId.MOLD_BREAKER);
game.move.use(MoveId.REST, BattlerIndex.PLAYER);
game.move.use(MoveId.REST, BattlerIndex.PLAYER_2);
await game.toEndOfTurn();
expect(magikarp.status?.effect).toBe(StatusEffect.SLEEP);
expect(magikarp.hp).toBe(magikarp.getMaxHp());
});
it("causes Yawn to fail if used on the user or its allies", async () => {
game.override.enemyMoveset(MoveId.YAWN);
it("should cause Yawn to fail if used on the user or its allies", async () => {
await game.classicMode.startBattle([SpeciesId.SWIRLIX, SpeciesId.MAGIKARP]);
game.move.select(MoveId.SPLASH);
game.move.select(MoveId.SPLASH, 1);
const [shuckle, swirlix] = game.scene.getPlayerField();
game.field.mockAbility(swirlix, AbilityId.SWEET_VEIL);
await game.phaseInterceptor.to(TurnEndPhase);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2);
await game.move.forceEnemyMove(MoveId.YAWN, BattlerIndex.PLAYER);
await game.move.forceEnemyMove(MoveId.YAWN, BattlerIndex.PLAYER_2);
await game.toEndOfTurn();
expect(game.scene.getPlayerField().every(p => !!p.getTag(BattlerTagType.DROWSY))).toBe(false);
expect(shuckle.getTag(BattlerTagType.DROWSY)).toBeUndefined();
expect(swirlix.getTag(BattlerTagType.DROWSY)).toBeUndefined();
// TODO: Currently ability blockages don't count in terms of failing status moves...??
/* const [karp1, karp2] = game.scene.getPlayerField();
expect(karp1.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(karp2.getLastXMoves()[0].result).toBe(MoveResult.FAIL); */
});
it("prevents the user and its allies already drowsy due to Yawn from falling asleep.", async () => {
game.override.enemySpecies(SpeciesId.PIKACHU).enemyLevel(5).startingLevel(5).enemyMoveset(MoveId.SPLASH);
it("should NOT cure allies' sleep status if user is sent out into battle", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS, SpeciesId.SWIRLIX]);
const [magikarp, , swirlix] = game.scene.getPlayerParty();
game.field.mockAbility(swirlix, AbilityId.PASTEL_VEIL);
await game.classicMode.startBattle([SpeciesId.SHUCKLE, SpeciesId.SHUCKLE, SpeciesId.SWIRLIX]);
game.move.select(MoveId.SPLASH);
game.move.select(MoveId.YAWN, 1, BattlerIndex.PLAYER);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getPlayerField().some(p => !!p.getTag(BattlerTagType.DROWSY))).toBe(true);
await game.phaseInterceptor.to(CommandPhase);
game.move.select(MoveId.SPLASH);
magikarp.trySetStatus(StatusEffect.SLEEP);
expect(magikarp.status?.effect).toBe(StatusEffect.SLEEP);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER);
game.doSwitchPokemon(2);
expect(game.scene.getPlayerField().every(p => p.status?.effect)).toBe(false);
expect(magikarp.status?.effect).toBe(StatusEffect.SLEEP);
});
it("should prevent an already-drowsy user or ally from falling asleep", async () => {
game.override.ability(AbilityId.BALL_FETCH);
await game.classicMode.startBattle([SpeciesId.SHUCKLE, SpeciesId.SWIRLIX]);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2);
await game.move.forceEnemyMove(MoveId.YAWN, BattlerIndex.PLAYER);
await game.move.forceEnemyMove(MoveId.YAWN, BattlerIndex.PLAYER_2);
await game.toEndOfTurn();
const [shuckle, swirlix, karp1, karp2] = game.scene.getField();
expect(shuckle.getTag(BattlerTagType.DROWSY)).toBeDefined();
expect(swirlix.getTag(BattlerTagType.DROWSY)).toBeDefined();
expect(karp1.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(karp2.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
game.field.mockAbility(shuckle, AbilityId.SWEET_VEIL);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2);
await game.toEndOfTurn();
expect(shuckle.getTag(BattlerTagType.DROWSY)).toBeDefined();
expect(swirlix.getTag(BattlerTagType.DROWSY)).toBeDefined();
expectNoStatus();
});
});

View File

@ -8,6 +8,8 @@ import { SpeciesId } from "#enums/species-id";
import GameManager from "#test/testUtils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { StatusEffect } from "#enums/status-effect";
import { MoveResult } from "#enums/move-result";
describe("Arena - Gravity", () => {
let phaserGame: Phaser.Game;
@ -27,130 +29,129 @@ describe("Arena - Gravity", () => {
game = new GameManager(phaserGame);
game.override
.battleStyle("single")
.moveset([MoveId.TACKLE, MoveId.GRAVITY, MoveId.FISSURE])
.ability(AbilityId.UNNERVE)
.enemyAbility(AbilityId.BALL_FETCH)
.enemySpecies(SpeciesId.SHUCKLE)
.enemyMoveset(MoveId.SPLASH)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyLevel(5);
});
// Reference: https://bulbapedia.bulbagarden.net/wiki/Gravity_(move)
it("non-OHKO move accuracy is multiplied by 1.67", async () => {
const moveToCheck = allMoves[MoveId.TACKLE];
vi.spyOn(moveToCheck, "calculateBattleAccuracy");
// Setup Gravity on first turn
it("should multiply all non-OHKO move accuracy by 1.67x", async () => {
const accSpy = vi.spyOn(allMoves[MoveId.TACKLE], "calculateBattleAccuracy");
await game.classicMode.startBattle([SpeciesId.PIKACHU]);
game.move.select(MoveId.GRAVITY);
await game.phaseInterceptor.to("TurnEndPhase");
game.move.use(MoveId.GRAVITY);
await game.move.forceEnemyMove(MoveId.TACKLE);
await game.toEndOfTurn();
expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined();
// Use non-OHKO move on second turn
await game.toNextTurn();
game.move.select(MoveId.TACKLE);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(moveToCheck.calculateBattleAccuracy).toHaveLastReturnedWith(100 * 1.67);
expect(accSpy).toHaveLastReturnedWith(allMoves[MoveId.TACKLE].accuracy * 1.67);
});
it("OHKO move accuracy is not affected", async () => {
/** See Fissure {@link https://bulbapedia.bulbagarden.net/wiki/Fissure_(move)} */
const moveToCheck = allMoves[MoveId.FISSURE];
vi.spyOn(moveToCheck, "calculateBattleAccuracy");
// Setup Gravity on first turn
it("should not affect OHKO move accuracy", async () => {
const accSpy = vi.spyOn(allMoves[MoveId.FISSURE], "calculateBattleAccuracy");
await game.classicMode.startBattle([SpeciesId.PIKACHU]);
game.move.select(MoveId.GRAVITY);
await game.phaseInterceptor.to("TurnEndPhase");
game.move.use(MoveId.GRAVITY);
await game.move.forceEnemyMove(MoveId.FISSURE);
await game.toEndOfTurn();
expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined();
// Use OHKO move on second turn
await game.toNextTurn();
game.move.select(MoveId.FISSURE);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(moveToCheck.calculateBattleAccuracy).toHaveLastReturnedWith(30);
expect(accSpy).toHaveLastReturnedWith(allMoves[MoveId.FISSURE].accuracy);
});
describe("Against flying types", () => {
it("can be hit by ground-type moves now", async () => {
game.override.enemySpecies(SpeciesId.PIDGEOT).moveset([MoveId.GRAVITY, MoveId.EARTHQUAKE]);
describe.each<{ name: string; overrides: () => unknown }>([
{ name: "Flying-type", overrides: () => game.override.enemySpecies(SpeciesId.MOLTRES) },
{ name: "Levitating", overrides: () => game.override.enemyAbility(AbilityId.LEVITATE) },
])("should ground $name Pokemon", ({ overrides }) => {
beforeEach(overrides);
it("should remove immunity to Ground-type moves", async () => {
await game.classicMode.startBattle([SpeciesId.PIKACHU]);
const pidgeot = game.scene.getEnemyPokemon()!;
vi.spyOn(pidgeot, "getAttackTypeEffectiveness");
const enemy = game.field.getEnemyPokemon();
const effectivenessSpy = vi.spyOn(enemy, "getAttackTypeEffectiveness");
// Try earthquake on 1st turn (fails!);
game.move.select(MoveId.EARTHQUAKE);
await game.phaseInterceptor.to("TurnEndPhase");
expect(pidgeot.getAttackTypeEffectiveness).toHaveLastReturnedWith(0);
// Setup Gravity on 2nd turn
game.move.use(MoveId.EARTHQUAKE);
await game.move.forceEnemyMove(MoveId.GRAVITY);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
game.move.select(MoveId.GRAVITY);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined();
// Use ground move on 3rd turn
await game.toNextTurn();
game.move.select(MoveId.EARTHQUAKE);
await game.phaseInterceptor.to("TurnEndPhase");
expect(pidgeot.getAttackTypeEffectiveness).toHaveLastReturnedWith(1);
expect(effectivenessSpy).toHaveLastReturnedWith(2);
expect(enemy.isGrounded()).toBe(true);
});
it("keeps super-effective moves super-effective after using gravity", async () => {
game.override.enemySpecies(SpeciesId.PIDGEOT).moveset([MoveId.GRAVITY, MoveId.THUNDERBOLT]);
it("should preserve normal move effectiveness for secondary type", async () => {
await game.classicMode.startBattle([SpeciesId.PIKACHU]);
const pidgeot = game.scene.getEnemyPokemon()!;
vi.spyOn(pidgeot, "getAttackTypeEffectiveness");
const enemy = game.field.getEnemyPokemon();
const effectivenessSpy = vi.spyOn(enemy, "getAttackTypeEffectiveness");
// Setup Gravity on 1st turn
game.move.select(MoveId.GRAVITY);
await game.phaseInterceptor.to("TurnEndPhase");
game.move.use(MoveId.THUNDERBOLT);
await game.move.forceEnemyMove(MoveId.GRAVITY);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toEndOfTurn();
expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined();
expect(effectivenessSpy).toHaveLastReturnedWith(2);
});
// Use electric move on 2nd turn
it("causes terrain to come into effect", async () => {
await game.classicMode.startBattle([SpeciesId.PIKACHU]);
const enemy = game.field.getEnemyPokemon();
enemy.hp = 1;
const statusSpy = vi.spyOn(enemy, "canSetStatus");
// Turn 1: set up electric terrain; spore works due to being ungrounded
game.move.use(MoveId.SPORE);
await game.move.forceEnemyMove(MoveId.ELECTRIC_TERRAIN);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
game.move.select(MoveId.THUNDERBOLT);
await game.phaseInterceptor.to("TurnEndPhase");
expect(pidgeot.getAttackTypeEffectiveness).toHaveLastReturnedWith(2);
expect(statusSpy).toHaveLastReturnedWith(true);
expect(enemy.status?.effect).toBe(StatusEffect.SLEEP);
enemy.resetStatus();
// Turn 2: gravity grounds enemy; makes spore fail
game.move.use(MoveId.SPORE);
await game.move.forceEnemyMove(MoveId.GRAVITY);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
expect(statusSpy).toHaveLastReturnedWith(true);
expect(enemy.status?.effect).toBeUndefined();
});
});
it("cancels Fly if its user is semi-invulnerable", async () => {
game.override.enemySpecies(SpeciesId.SNORLAX).enemyMoveset(MoveId.FLY).moveset([MoveId.GRAVITY, MoveId.SPLASH]);
it.each<{ name: string; move: MoveId }>([
{ name: "Fly", move: MoveId.FLY },
{ name: "Bounce", move: MoveId.BOUNCE },
{ name: "Sky Drop", move: MoveId.SKY_DROP },
])("cancels $name if its user is semi-invulnerable", async ({ move }) => {
await game.classicMode.startBattle([SpeciesId.CHARIZARD]);
const charizard = game.scene.getPlayerPokemon()!;
const snorlax = game.scene.getEnemyPokemon()!;
game.move.select(MoveId.SPLASH);
const charizard = game.field.getPlayerPokemon();
const snorlax = game.field.getEnemyPokemon();
game.move.use(MoveId.SPLASH);
await game.move.forceEnemyMove(move);
await game.toNextTurn();
expect(snorlax.getTag(BattlerTagType.FLYING)).toBeDefined();
game.move.select(MoveId.GRAVITY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(snorlax.getTag(BattlerTagType.INTERRUPTED)).toBeDefined();
await game.phaseInterceptor.to("TurnEndPhase");
await game.toEndOfTurn();
expect(charizard.hp).toBe(charizard.getMaxHp());
expect(snorlax.getTag(BattlerTagType.FLYING)).toBeUndefined();
expect(snorlax.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
});
});

View File

@ -1,69 +0,0 @@
import { allMoves } from "#app/data/data-lists";
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, vi } from "vitest";
describe("Arena - Grassy Terrain", () => {
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
.battleStyle("single")
.criticalHits(false)
.enemyLevel(1)
.enemySpecies(SpeciesId.SHUCKLE)
.enemyAbility(AbilityId.STURDY)
.enemyMoveset(MoveId.FLY)
.moveset([MoveId.GRASSY_TERRAIN, MoveId.EARTHQUAKE])
.ability(AbilityId.NO_GUARD);
});
it("halves the damage of Earthquake", async () => {
await game.classicMode.startBattle([SpeciesId.TAUROS]);
const eq = allMoves[MoveId.EARTHQUAKE];
vi.spyOn(eq, "calculateBattlePower");
game.move.select(MoveId.EARTHQUAKE);
await game.toNextTurn();
expect(eq.calculateBattlePower).toHaveReturnedWith(100);
game.move.select(MoveId.GRASSY_TERRAIN);
await game.toNextTurn();
game.move.select(MoveId.EARTHQUAKE);
await game.phaseInterceptor.to("BerryPhase");
expect(eq.calculateBattlePower).toHaveReturnedWith(50);
});
it("Does not halve the damage of Earthquake if opponent is not grounded", async () => {
await game.classicMode.startBattle([SpeciesId.NINJASK]);
const eq = allMoves[MoveId.EARTHQUAKE];
vi.spyOn(eq, "calculateBattlePower");
game.move.select(MoveId.GRASSY_TERRAIN);
await game.toNextTurn();
game.move.select(MoveId.EARTHQUAKE);
await game.phaseInterceptor.to("BerryPhase");
expect(eq.calculateBattlePower).toHaveReturnedWith(100);
});
});

382
test/arena/terrain.test.ts Normal file
View File

@ -0,0 +1,382 @@
import { BattlerIndex } from "#enums/battler-index";
import { allMoves } from "#app/data/data-lists";
import { getTerrainName, TerrainType } from "#app/data/terrain";
import { MoveResult } from "#enums/move-result";
import { getPokemonNameWithAffix } from "#app/messages";
import { capitalizeFirstLetter, randSeedInt, toDmgValue } from "#app/utils/common";
import { AbilityId } from "#enums/ability-id";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { PokemonType } from "#enums/pokemon-type";
import { SpeciesId } from "#enums/species-id";
import { Stat } from "#enums/stat";
import { StatusEffect } from "#enums/status-effect";
import GameManager from "#test/testUtils/gameManager";
import i18next from "i18next";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Terrain -", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
describe.each<{ terrain: string; type: PokemonType; ability: AbilityId; move: MoveId }>([
{ terrain: "Electric", type: PokemonType.ELECTRIC, ability: AbilityId.ELECTRIC_SURGE, move: MoveId.THUNDERBOLT },
{ terrain: "Psychic", type: PokemonType.PSYCHIC, ability: AbilityId.PSYCHIC_SURGE, move: MoveId.PSYCHIC },
{ terrain: "Grassy", type: PokemonType.GRASS, ability: AbilityId.GRASSY_SURGE, move: MoveId.GIGA_DRAIN },
])("Move Power Boosts - $terrain Terrain", ({ type, ability, move }) => {
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.battleStyle("single")
.criticalHits(false)
.enemySpecies(SpeciesId.SNOM)
.enemyAbility(AbilityId.STURDY)
.ability(ability)
.passiveAbility(AbilityId.NO_GUARD)
.enemyPassiveAbility(AbilityId.LEVITATE);
});
const typeStr = capitalizeFirstLetter(PokemonType[type].toLowerCase());
it(`should boost grounded uses of ${typeStr}-type moves by 1.3x, even against ungrounded targets`, async () => {
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
const powerSpy = vi.spyOn(allMoves[move], "calculateBattlePower");
game.move.use(move);
await game.move.forceEnemyMove(move);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toEndOfTurn();
// Player grounded attack got boosted while enemy ungrounded attack didn't
expect(powerSpy).toHaveLastReturnedWith(allMoves[move].power * 1.3);
expect(powerSpy).toHaveNthReturnedWith(1, allMoves[move].power);
});
it(`should change Terrain Pulse into a ${typeStr}-type move and double its base power`, async () => {
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
const powerSpy = vi.spyOn(allMoves[MoveId.TERRAIN_PULSE], "calculateBattlePower");
const playerTypeSpy = vi.spyOn(game.field.getPlayerPokemon(), "getMoveType");
const enemyTypeSpy = vi.spyOn(game.field.getEnemyPokemon(), "getMoveType");
game.move.use(MoveId.TERRAIN_PULSE);
await game.move.forceEnemyMove(MoveId.TERRAIN_PULSE);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toEndOfTurn();
// player grounded terrain pulse was boosted & type converted; enemy ungrounded one wasn't
expect(powerSpy).toHaveLastReturnedWith(allMoves[move].power * 2.6); // 2 * 1.3
expect(playerTypeSpy).toHaveLastReturnedWith(type);
expect(powerSpy).toHaveNthReturnedWith(1, allMoves[move].power);
expect(enemyTypeSpy).toHaveNthReturnedWith(1, allMoves[MoveId.TERRAIN_PULSE].type);
});
});
describe("Grassy Terrain", () => {
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.battleStyle("single")
.criticalHits(false)
.enemyLevel(100)
.enemySpecies(SpeciesId.SHUCKLE)
.enemyAbility(AbilityId.STURDY)
.enemyMoveset(MoveId.GRASSY_TERRAIN)
.ability(AbilityId.NO_GUARD);
});
it("should heal all grounded, non-semi-invulnerable pokemon for 1/16th max HP at end of turn", async () => {
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
// shuckle is grounded, pidgeot isn't
const blissey = game.field.getPlayerPokemon();
blissey.hp = toDmgValue(blissey.getMaxHp() / 2);
expect(blissey.getHpRatio()).toBeCloseTo(0.5, 1);
const shuckle = game.field.getEnemyPokemon();
game.field.mockAbility(shuckle, AbilityId.LEVITATE);
shuckle.hp = toDmgValue(shuckle.getMaxHp() / 2);
expect(shuckle.getHpRatio()).toBeCloseTo(0.5, 1);
game.move.use(MoveId.SPLASH);
await game.toNextTurn();
expect(game.phaseInterceptor.log).toContain("PokemonHealPhase");
expect(blissey.getHpRatio()).toBeCloseTo(0.5625, 1);
expect(shuckle.getHpRatio()).toBeCloseTo(0.5, 1);
game.move.use(MoveId.DIG);
await game.toNextTurn();
// shuckle is airborne and blissey is semi-invulnerable, so nobody gets healed
expect(blissey.getHpRatio()).toBeCloseTo(0.5625, 1);
expect(shuckle.getHpRatio()).toBeCloseTo(0.5, 1);
});
it.each<{ name: string; move: MoveId; basePower?: number }>([
{ name: "Bulldoze", move: MoveId.BULLDOZE },
{ name: "Earthquake", move: MoveId.EARTHQUAKE },
{ name: "Magnitude", move: MoveId.MAGNITUDE, basePower: 150 }, // magnitude 10
])(
"should halve $name's base power against grounded, on-field targets",
async ({ move, basePower = allMoves[move].power }) => {
await game.classicMode.startBattle([SpeciesId.TAUROS]);
// force high rolls for guaranteed magnitude 10s
vi.fn(randSeedInt).mockReturnValue(100);
const powerSpy = vi.spyOn(allMoves[move], "calculateBattlePower");
// Turn 1: attack before grassy terrain set up; 1x
game.move.use(move);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn();
expect(powerSpy).toHaveLastReturnedWith(basePower);
// Turn 2: attack with grassy terrain already active; 0.5x
game.move.use(move);
await game.toNextTurn();
expect(powerSpy).toHaveLastReturnedWith(basePower / 2);
// Turn 3: Give enemy levitate to make ungrounded and attack; 1x
game.field.mockAbility(game.field.getEnemyPokemon(), AbilityId.LEVITATE);
game.move.use(move);
await game.toNextTurn();
expect(powerSpy).toHaveLastReturnedWith(basePower);
// Turn 4: Remove levitate and make enemy semi-invulnerable; 1x
game.field.mockAbility(game.field.getEnemyPokemon(), AbilityId.BALL_FETCH);
game.move.use(move);
await game.move.forceEnemyMove(MoveId.FLY);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
expect(powerSpy).toHaveLastReturnedWith(basePower);
},
);
});
// TODO: Enable suites after terrain-fail-msg branch is merged
describe.skip("Electric Terrain", () => {
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.battleStyle("single")
.criticalHits(false)
.enemyLevel(100)
.enemySpecies(SpeciesId.SHUCKLE)
.enemyAbility(AbilityId.ELECTRIC_SURGE)
.ability(AbilityId.NO_GUARD);
});
it("should prevent all grounded pokemon from being put to sleep", async () => {
await game.classicMode.startBattle([SpeciesId.PIDGEOT]);
game.move.use(MoveId.SPORE);
await game.move.forceEnemyMove(MoveId.SPORE);
await game.toEndOfTurn();
const pidgeot = game.field.getPlayerPokemon();
const shuckle = game.field.getEnemyPokemon();
expect(pidgeot.status?.effect).toBeUndefined();
expect(shuckle.status?.effect).toBe(StatusEffect.SLEEP);
// TODO: These don't work due to how move failures are propagated
expect(pidgeot.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(shuckle.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(game.textInterceptor.logs).not.toContain(
i18next.t("terrain:defaultBlockMessage", {
pokemonNameWithAffix: getPokemonNameWithAffix(shuckle),
terrainName: getTerrainName(TerrainType.ELECTRIC),
}),
);
});
it("should prevent attack moves from applying sleep without showing text/failing move", async () => {
vi.spyOn(allMoves[MoveId.RELIC_SONG], "chance", "get").mockReturnValue(100);
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
const shuckle = game.field.getEnemyPokemon();
const statusSpy = vi.spyOn(shuckle, "canSetStatus");
game.move.use(MoveId.RELIC_SONG);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.toEndOfTurn();
const blissey = game.field.getPlayerPokemon();
expect(shuckle.status?.effect).toBeUndefined();
expect(statusSpy).toHaveLastReturnedWith(false);
expect(blissey.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(game.textInterceptor.logs).not.toContain(
i18next.t("terrain:defaultBlockMessage", {
pokemonNameWithAffix: getPokemonNameWithAffix(shuckle),
terrainName: getTerrainName(TerrainType.ELECTRIC),
}),
);
});
});
describe.skip("Misty Terrain", () => {
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.battleStyle("single")
.criticalHits(false)
.enemyLevel(100)
.enemySpecies(SpeciesId.SHUCKLE)
.enemyAbility(AbilityId.MISTY_SURGE)
.ability(AbilityId.NO_GUARD);
});
it("should prevent all grounded pokemon from being statused or confused", async () => {
game.override.confusionActivation(false); // prevent self hits from ruining things
await game.classicMode.startBattle([SpeciesId.PIDGEOT]);
// shuckle is grounded, pidgeot isn't
game.move.use(MoveId.TOXIC);
await game.move.forceEnemyMove(MoveId.TOXIC);
await game.toNextTurn();
const pidgeot = game.field.getPlayerPokemon();
const shuckle = game.field.getEnemyPokemon();
expect(pidgeot.status?.effect).toBeUndefined();
expect(shuckle.status?.effect).toBe(StatusEffect.TOXIC);
// TODO: These don't work due to how move failures are propagated
expect(pidgeot.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(shuckle.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(game.textInterceptor.logs).toContain(
i18next.t("terrain:mistyBlockMessage", {
pokemonNameWithAffix: getPokemonNameWithAffix(shuckle),
}),
);
game.textInterceptor.logs = [];
game.move.use(MoveId.CONFUSE_RAY);
await game.move.forceEnemyMove(MoveId.CONFUSE_RAY);
await game.toNextTurn();
expect(pidgeot.getTag(BattlerTagType.CONFUSED)).toBeUndefined();
expect(shuckle.getTag(BattlerTagType.CONFUSED)).toBeDefined();
});
it.each<{ status: string; move: MoveId }>([
{ status: "Sleep", move: MoveId.RELIC_SONG },
{ status: "Burn", move: MoveId.SACRED_FIRE },
{ status: "Freeze", move: MoveId.ICE_BEAM },
{ status: "Paralysis", move: MoveId.NUZZLE },
{ status: "Poison", move: MoveId.SLUDGE_BOMB },
{ status: "Toxic", move: MoveId.MALIGNANT_CHAIN },
{ status: "Confusion", move: MoveId.MAGICAL_TORQUE },
])("should prevent attack moves from applying $name without showing text/failing move", async ({ move }) => {
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
vi.spyOn(allMoves[move], "chance", "get").mockReturnValue(100);
game.move.use(move);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.toEndOfTurn();
const blissey = game.field.getPlayerPokemon();
const shuckle = game.field.getEnemyPokemon();
expect(shuckle.status?.effect).toBeUndefined();
expect(shuckle.getTag(BattlerTagType.CONFUSED)).toBeUndefined();
expect(blissey.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(game.textInterceptor.logs).not.toContain(
i18next.t("terrain:mistyBlockMessage", {
pokemonNameWithAffix: getPokemonNameWithAffix(shuckle),
}),
);
});
});
describe("Psychic Terrain", () => {
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.battleStyle("single")
.criticalHits(false)
.enemyLevel(100)
.enemySpecies(SpeciesId.SHUCKLE)
.enemyAbility(AbilityId.PSYCHIC_SURGE)
.enemyPassiveAbility(AbilityId.PRANKSTER)
.ability(AbilityId.NO_GUARD)
.passiveAbility(AbilityId.GALE_WINGS);
});
it("should block all opponent-targeted priority moves", async () => {
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
// normal + priority moves
game.move.use(MoveId.FAKE_OUT);
await game.move.forceEnemyMove(MoveId.FOLLOW_ME);
await game.toEndOfTurn();
const blissey = game.field.getPlayerPokemon();
const shuckle = game.field.getEnemyPokemon();
expect(blissey.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(shuckle.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(game.textInterceptor.logs).toContain(
i18next.t("terrain:defaultBlockMessage", {
pokemonNameWithAffix: getPokemonNameWithAffix(shuckle),
terrainName: getTerrainName(TerrainType.PSYCHIC),
}),
);
// moves that only become priority via ability
blissey.hp = 1;
game.move.use(MoveId.ROOST);
await game.move.forceEnemyMove(MoveId.TICKLE);
await game.toEndOfTurn();
expect(blissey.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(shuckle.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
});
// TODO: This needs to be fixed
it.todo("should not block enemy-targeting spread moves, even if they become priority", async () => {
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
game.move.use(MoveId.AIR_CUTTER);
await game.move.forceEnemyMove(MoveId.SWEET_SCENT);
await game.toEndOfTurn();
const blissey = game.field.getPlayerPokemon();
const shuckle = game.field.getEnemyPokemon();
expect(blissey.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(shuckle.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(shuckle.hp).toBeLessThan(shuckle.getMaxHp());
expect(blissey.getStatStage(Stat.EVA)).toBe(-2);
});
});
});

View File

@ -1,5 +1,4 @@
import { BattlerTagType } from "#enums/battler-tag-type";
import { allMoves } from "#app/data/data-lists";
import { AbilityId } from "#enums/ability-id";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
@ -8,6 +7,9 @@ import GameManager from "#test/testUtils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, it, expect, vi } from "vitest";
import { BattlerIndex } from "#enums/battler-index";
import type { MoveEffectPhase } from "#app/phases/move-effect-phase";
import { HitCheckResult } from "#enums/hit-check-result";
import { StatusEffect } from "#enums/status-effect";
describe("Moves - Telekinesis", () => {
let phaserGame: Phaser.Game;
@ -26,114 +28,129 @@ describe("Moves - Telekinesis", () => {
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.moveset([MoveId.TELEKINESIS, MoveId.TACKLE, MoveId.MUD_SHOT, MoveId.SMACK_DOWN])
.battleStyle("single")
.enemySpecies(SpeciesId.SNORLAX)
.enemyLevel(60)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset([MoveId.SPLASH]);
.enemyMoveset(MoveId.SPLASH);
});
it("Telekinesis makes the affected vulnerable to most attacking moves regardless of accuracy", async () => {
it("should cause opposing non-OHKO moves to always hit the target", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const enemyOpponent = game.scene.getEnemyPokemon()!;
game.move.select(MoveId.TELEKINESIS);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.getTag(BattlerTagType.TELEKINESIS)).toBeDefined();
expect(enemyOpponent.getTag(BattlerTagType.FLOATING)).toBeDefined();
const player = game.field.getPlayerPokemon();
const enemy = game.field.getEnemyPokemon();
game.move.use(MoveId.TELEKINESIS);
await game.toNextTurn();
vi.spyOn(allMoves[MoveId.TACKLE], "accuracy", "get").mockReturnValue(0);
game.move.select(MoveId.TACKLE);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.isFullHp()).toBe(false);
expect(enemy.getTag(BattlerTagType.TELEKINESIS)).toBeDefined();
expect(enemy.getTag(BattlerTagType.FLOATING)).toBeDefined();
game.move.use(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.move.forceMiss();
await game.toEndOfTurn();
expect(enemy.hp).toBeLessThan(enemy.getMaxHp());
expect(player.getLastXMoves()[0].result).toBe(MoveResult.MISS);
});
it("Telekinesis makes the affected airborne and immune to most Ground-moves", async () => {
it("should render the target immune to Ground-moves and terrain", async () => {
game.override.ability(AbilityId.ELECTRIC_SURGE);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const enemyOpponent = game.scene.getEnemyPokemon()!;
const enemy = game.field.getEnemyPokemon();
game.move.select(MoveId.TELEKINESIS);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.getTag(BattlerTagType.TELEKINESIS)).toBeDefined();
expect(enemyOpponent.getTag(BattlerTagType.FLOATING)).toBeDefined();
game.move.use(MoveId.TELEKINESIS);
await game.toNextTurn();
game.move.use(MoveId.EARTHQUAKE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase");
const hitSpy = vi.spyOn(game.scene.phaseManager.getCurrentPhase() as MoveEffectPhase, "hitCheck");
await game.toNextTurn();
vi.spyOn(allMoves[MoveId.MUD_SHOT], "accuracy", "get").mockReturnValue(100);
game.move.select(MoveId.MUD_SHOT);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.isFullHp()).toBe(true);
expect(enemy.hp).toBe(enemy.getMaxHp());
expect(hitSpy).toHaveLastReturnedWith([HitCheckResult.NO_EFFECT, 0]);
game.move.use(MoveId.SPORE);
await game.toNextTurn();
expect(enemy.status?.effect).toBe(StatusEffect.SLEEP);
});
it("Telekinesis can still affect Pokemon that have been transformed into invalid Pokemon", async () => {
game.override.enemyMoveset(MoveId.TRANSFORM);
it("should still affect enemies transformed into invalid Pokemon", async () => {
game.override.enemyAbility(AbilityId.IMPOSTER);
await game.classicMode.startBattle([SpeciesId.DIGLETT]);
const enemyOpponent = game.scene.getEnemyPokemon()!;
const enemyOpponent = game.field.getEnemyPokemon();
game.move.use(MoveId.TELEKINESIS);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.toNextTurn();
game.move.select(MoveId.TELEKINESIS);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.getTag(BattlerTagType.TELEKINESIS)).toBeDefined();
expect(enemyOpponent.getTag(BattlerTagType.FLOATING)).toBeDefined();
expect(enemyOpponent.summonData.speciesForm?.speciesId).toBe(SpeciesId.DIGLETT);
});
it("Moves like Smack Down and 1000 Arrows remove all effects of Telekinesis from the target Pokemon", async () => {
it.each<{ name: string; move: MoveId }>([
{ name: "Smack Down", move: MoveId.SMACK_DOWN },
{ name: "Thousand Arrows", move: MoveId.THOUSAND_ARROWS },
])("should be removed when hit by $name", async ({ move }) => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const enemyOpponent = game.scene.getEnemyPokemon()!;
const enemy = game.field.getEnemyPokemon();
game.move.select(MoveId.TELEKINESIS);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.getTag(BattlerTagType.TELEKINESIS)).toBeDefined();
expect(enemyOpponent.getTag(BattlerTagType.FLOATING)).toBeDefined();
await game.toNextTurn();
game.move.select(MoveId.SMACK_DOWN);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.getTag(BattlerTagType.TELEKINESIS)).toBeUndefined();
expect(enemyOpponent.getTag(BattlerTagType.FLOATING)).toBeUndefined();
game.move.use(move);
await game.toNextTurn();
expect(enemy.getTag(BattlerTagType.TELEKINESIS)).toBeUndefined();
expect(enemy.getTag(BattlerTagType.FLOATING)).toBeUndefined();
});
it("Ingrain will remove the floating effect of Telekinesis, but not the 100% hit", async () => {
game.override.enemyMoveset([MoveId.SPLASH, MoveId.INGRAIN]);
it("should become grounded when Ingrain is used, but not remove the guaranteed hit effect", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const playerPokemon = game.scene.getPlayerPokemon()!;
const enemyOpponent = game.scene.getEnemyPokemon()!;
game.move.select(MoveId.TELEKINESIS);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.getTag(BattlerTagType.TELEKINESIS)).toBeDefined();
expect(enemyOpponent.getTag(BattlerTagType.FLOATING)).toBeDefined();
const playerPokemon = game.field.getPlayerPokemon();
const enemy = game.field.getEnemyPokemon();
game.move.use(MoveId.TELEKINESIS);
await game.toNextTurn();
vi.spyOn(allMoves[MoveId.MUD_SHOT], "accuracy", "get").mockReturnValue(0);
game.move.select(MoveId.MUD_SHOT);
await game.move.selectEnemyMove(MoveId.INGRAIN);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.getTag(BattlerTagType.TELEKINESIS)).toBeDefined();
expect(enemyOpponent.getTag(BattlerTagType.INGRAIN)).toBeDefined();
expect(enemyOpponent.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined();
expect(enemyOpponent.getTag(BattlerTagType.FLOATING)).toBeUndefined();
game.move.use(MoveId.MUD_SHOT);
await game.move.forceEnemyMove(MoveId.INGRAIN);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("MoveEndPhase");
await game.move.forceMiss();
await game.toNextTurn();
expect(enemy.getTag(BattlerTagType.TELEKINESIS)).toBeDefined();
expect(enemy.getTag(BattlerTagType.INGRAIN)).toBeDefined();
expect(enemy.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined();
expect(enemy.getTag(BattlerTagType.FLOATING)).toBeUndefined();
expect(enemy.isGrounded()).toBe(false);
expect(playerPokemon.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
});
it("should not be baton passed onto a mega gengar", async () => {
it("should not be baton passable onto a mega gengar", async () => {
game.override
.moveset([MoveId.BATON_PASS])
.enemyMoveset([MoveId.TELEKINESIS])
.starterForms({ [SpeciesId.GENGAR]: 1 });
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.GENGAR]);
game.move.select(MoveId.BATON_PASS);
game.move.use(MoveId.BATON_PASS);
game.doSelectPartyPokemon(1);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getPlayerPokemon()!.getTag(BattlerTagType.TELEKINESIS)).toBeUndefined();
await game.toEndOfTurn();
expect(game.field.getPlayerPokemon().getTag(BattlerTagType.TELEKINESIS)).toBeUndefined();
});
});

View File

@ -1,12 +1,12 @@
import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index";
import { AbilityId } from "#app/enums/ability-id";
import { BattlerTagType } from "#app/enums/battler-tag-type";
import { BerryPhase } from "#app/phases/berry-phase";
import { MoveEffectPhase } from "#app/phases/move-effect-phase";
import type { MoveEffectPhase } from "#app/phases/move-effect-phase";
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";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Moves - Thousand Arrows", () => {
let phaserGame: Phaser.Game;
@ -26,64 +26,106 @@ describe("Moves - Thousand Arrows", () => {
game = new GameManager(phaserGame);
game.override
.battleStyle("single")
.enemySpecies(SpeciesId.TOGETIC)
.enemySpecies(SpeciesId.EELEKTROSS)
.startingLevel(100)
.enemyLevel(100)
.moveset([MoveId.THOUSAND_ARROWS])
.enemyLevel(50)
.criticalHits(false)
.ability(AbilityId.COMPOUND_EYES)
.enemyAbility(AbilityId.STURDY)
.moveset(MoveId.THOUSAND_ARROWS)
.enemyMoveset(MoveId.SPLASH);
});
it("move should hit and ground Flying-type targets", async () => {
it("should hit and ground Flying-type targets, dealing neutral damage", async () => {
game.override.enemySpecies(SpeciesId.ARCHEOPS);
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
const enemyPokemon = game.scene.getEnemyPokemon()!;
const archeops = game.scene.getEnemyPokemon()!;
expect(archeops.getTag(BattlerTagType.IGNORE_FLYING)).toBeUndefined();
game.move.select(MoveId.THOUSAND_ARROWS);
await game.phaseInterceptor.to("MoveEffectPhase", false);
const hitSpy = vi.spyOn(game.scene.phaseManager.getCurrentPhase() as MoveEffectPhase, "hitCheck");
await game.toEndOfTurn();
await game.phaseInterceptor.to(MoveEffectPhase, false);
// Enemy should not be grounded before move effect is applied
expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeUndefined();
await game.phaseInterceptor.to(BerryPhase, false);
expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined();
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
expect(hitSpy).toHaveReturnedWith([expect.anything(), 1]);
expect(archeops.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined();
expect(archeops.isGrounded()).toBe(true);
expect(archeops.hp).toBeLessThan(archeops.getMaxHp());
});
it("move should hit and ground targets with Levitate", async () => {
game.override.enemySpecies(SpeciesId.SNORLAX).enemyAbility(AbilityId.LEVITATE);
it("should hit and ground targets with Levitate without affecting effectiveness", async () => {
game.override.enemyPassiveAbility(AbilityId.LEVITATE);
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
const enemyPokemon = game.scene.getEnemyPokemon()!;
const eelektross = game.scene.getEnemyPokemon()!;
expect(eelektross.getTag(BattlerTagType.IGNORE_FLYING)).toBeUndefined();
game.move.select(MoveId.THOUSAND_ARROWS);
await game.phaseInterceptor.to("MoveEffectPhase", false);
const hitSpy = vi.spyOn(game.scene.phaseManager.getCurrentPhase() as MoveEffectPhase, "hitCheck");
await game.toEndOfTurn();
await game.phaseInterceptor.to(MoveEffectPhase, false);
// Enemy should not be grounded before move effect is applied
expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeUndefined();
await game.phaseInterceptor.to(BerryPhase, false);
expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined();
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
expect(eelektross.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined();
expect(eelektross.hp).toBeLessThan(eelektross.getMaxHp());
expect(hitSpy).toHaveReturnedWith([expect.anything(), 2]);
});
it("move should hit and ground targets under the effects of Magnet Rise", async () => {
game.override.enemySpecies(SpeciesId.SNORLAX);
it("should hit and ground targets under the effects of Magnet Rise", async () => {
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
const enemyPokemon = game.scene.getEnemyPokemon()!;
game.move.select(MoveId.THOUSAND_ARROWS);
await game.move.forceEnemyMove(MoveId.MAGNET_RISE);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("MoveEndPhase");
enemyPokemon.addTag(BattlerTagType.FLOATING, undefined, MoveId.MAGNET_RISE);
// ensure magnet rise suceeeded before getting knocked down
const eelektross = game.field.getEnemyPokemon();
expect(eelektross.getTag(BattlerTagType.FLOATING)).toBeDefined();
await game.toEndOfTurn();
expect(eelektross.getTag(BattlerTagType.FLOATING)).toBeUndefined();
expect(eelektross.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined();
expect(eelektross.hp).toBeLessThan(eelektross.getMaxHp());
});
it.each<{ name: string; move: MoveId }>([
{ name: "Fly", move: MoveId.FLY },
{ name: "Bounce", move: MoveId.BOUNCE },
])("should hit through and cancel the target's $name", async ({ move }) => {
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
game.move.select(MoveId.THOUSAND_ARROWS);
await game.move.forceEnemyMove(move);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("MoveEndPhase");
await game.phaseInterceptor.to(BerryPhase, false);
// Fly should've worked until hit
const eelektross = game.field.getEnemyPokemon();
expect(eelektross.getTag(BattlerTagType.FLYING)).toBeDefined();
expect(eelektross.getTag(BattlerTagType.IGNORE_FLYING)).toBeUndefined();
expect(enemyPokemon.getTag(BattlerTagType.FLOATING)).toBeUndefined();
expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined();
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
await game.toEndOfTurn();
expect(eelektross.getTag(BattlerTagType.FLYING)).toBeUndefined();
expect(eelektross.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined();
expect(eelektross.hp).toBeLessThan(eelektross.getMaxHp());
});
// TODO: verify behavior
it.todo("should not ground semi-invulnerable targets unless already ungrounded", async () => {
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
game.move.select(MoveId.THOUSAND_ARROWS);
await game.move.forceEnemyMove(MoveId.DIG);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toEndOfTurn();
const eelektross = game.field.getEnemyPokemon();
expect(eelektross.isGrounded()).toBe(false);
expect(eelektross.getTag(BattlerTagType.IGNORE_FLYING)).toBeUndefined();
expect(eelektross.hp).toBeLessThan(eelektross.getMaxHp());
});
// TODO: Sky drop is currently unimplemented
it.todo("should hit midair targets from Sky Drop without interrupting");
});

View File

@ -64,6 +64,7 @@ import { PostGameOverPhase } from "#app/phases/post-game-over-phase";
import { RevivalBlessingPhase } from "#app/phases/revival-blessing-phase";
import type { PhaseClass, PhaseString } from "#app/@types/phase-types";
import { PokemonHealPhase } from "#app/phases/pokemon-heal-phase";
export interface PromptHandler {
phaseTarget?: string;
@ -143,6 +144,7 @@ export default class PhaseInterceptor {
[AttemptRunPhase, this.startPhase],
[SelectBiomePhase, this.startPhase],
[MysteryEncounterPhase, this.startPhase],
[PokemonHealPhase, this.startPhase],
[MysteryEncounterOptionSelectedPhase, this.startPhase],
[MysteryEncounterBattlePhase, this.startPhase],
[MysteryEncounterRewardsPhase, this.startPhase],