diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index 494a0438b18..92bcab7fddb 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -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); } }); } diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 8405fd1dd4d..bde7bfcafdb 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -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); diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index f94c59bb463..20bf89ec876 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -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) diff --git a/src/data/type.ts b/src/data/type.ts index c9bf346fb85..d2d2825b8f4 100644 --- a/src/data/type.ts +++ b/src/data/type.ts @@ -261,10 +261,9 @@ export function getTypeDamageMultiplier(attackType: PokemonType, defType: Pokemo return 1; } case PokemonType.STELLAR: + default: return 1; } - - return 1; } /** diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index e9cc4f70d70..c645af4abb8 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -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); diff --git a/test/abilities/harvest.test.ts b/test/abilities/harvest.test.ts index 662eeed6dd0..c04af8899b8 100644 --- a/test/abilities/harvest.test.ts +++ b/test/abilities/harvest.test.ts @@ -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...??? diff --git a/test/abilities/pastel_veil.test.ts b/test/abilities/pastel_veil.test.ts index b8e8873ed36..ce2812d7dce 100644 --- a/test/abilities/pastel_veil.test.ts +++ b/test/abilities/pastel_veil.test.ts @@ -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(); }); diff --git a/test/abilities/sheer_force.test.ts b/test/abilities/sheer_force.test.ts index e90b8d08611..30ae9aef4a6 100644 --- a/test/abilities/sheer_force.test.ts +++ b/test/abilities/sheer_force.test.ts @@ -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()); }); }); diff --git a/test/abilities/sweet_veil.test.ts b/test/abilities/sweet_veil.test.ts index ed9cb20afcc..323eb51651b 100644 --- a/test/abilities/sweet_veil.test.ts +++ b/test/abilities/sweet_veil.test.ts @@ -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(); }); }); diff --git a/test/arena/arena_gravity.test.ts b/test/arena/arena_gravity.test.ts index 36fe0b58308..6ed16e9633a 100644 --- a/test/arena/arena_gravity.test.ts +++ b/test/arena/arena_gravity.test.ts @@ -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); }); }); diff --git a/test/arena/grassy_terrain.test.ts b/test/arena/grassy_terrain.test.ts deleted file mode 100644 index 5f78d8f801d..00000000000 --- a/test/arena/grassy_terrain.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/test/arena/terrain.test.ts b/test/arena/terrain.test.ts new file mode 100644 index 00000000000..11c1a8e48b8 --- /dev/null +++ b/test/arena/terrain.test.ts @@ -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); + }); + }); +}); diff --git a/test/moves/telekinesis.test.ts b/test/moves/telekinesis.test.ts index 5c9f1e22395..9aef84f86bf 100644 --- a/test/moves/telekinesis.test.ts +++ b/test/moves/telekinesis.test.ts @@ -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(); }); }); diff --git a/test/moves/thousand_arrows.test.ts b/test/moves/thousand_arrows.test.ts index 9ecdd94a94f..6721745499f 100644 --- a/test/moves/thousand_arrows.test.ts +++ b/test/moves/thousand_arrows.test.ts @@ -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"); }); diff --git a/test/testUtils/phaseInterceptor.ts b/test/testUtils/phaseInterceptor.ts index 9d046fc85ba..e1033f87b15 100644 --- a/test/testUtils/phaseInterceptor.ts +++ b/test/testUtils/phaseInterceptor.ts @@ -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],