From e7bb0d2e1e088db13640b0ff795d5f56f0f41b92 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Fri, 6 Jun 2025 23:13:02 -0400 Subject: [PATCH 01/12] Cleaned up Thousand arrows code + tests --- src/data/moves/move.ts | 6 +- src/data/type.ts | 3 +- src/field/pokemon.ts | 19 ++++- test/moves/thousand_arrows.test.ts | 109 +++++++++++++++++++---------- 4 files changed, 95 insertions(+), 42 deletions(-) diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index e98b28e2a48..e245b0f2e64 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -5580,10 +5580,12 @@ export class FallDownAttr extends AddBattlerTagAttr { * @returns `true` if the effect successfully applies; `false` otherwise */ apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - if (!target.isGrounded()) { + // Smack down and similar only add their tag if the pokemon is already ungrounded without considering semi-invulnerability + if (!target.isGrounded(true)) { globalScene.queueMessage(i18next.t("moveTriggers:fallDown", { targetPokemonName: getPokemonNameWithAffix(target) })); + return super.apply(user, target, move, args); } - return super.apply(user, target, move, args); + return 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 df953f06834..cda83cac469 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -2303,13 +2303,28 @@ 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 + * * **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) || (!this.isOfType(PokemonType.FLYING, true, true) && !this.hasAbility(AbilityId.LEVITATE) && !this.getTag(BattlerTagType.FLOATING) && - !this.getTag(SemiInvulnerableTag)) + !ignoreSemiInvulnerable) || + !this.getTag(SemiInvulnerableTag) ); } diff --git a/test/moves/thousand_arrows.test.ts b/test/moves/thousand_arrows.test.ts index 428e7c4fbe4..5f2642ac985 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 "#app/battle"; +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; @@ -24,66 +24,103 @@ describe("Moves - Thousand Arrows", () => { beforeEach(() => { game = new GameManager(phaserGame); - game.override.battleStyle("single"); - game.override.enemySpecies(SpeciesId.TOGETIC); - game.override.startingLevel(100); - game.override.enemyLevel(100); - game.override.moveset([MoveId.THOUSAND_ARROWS]); - game.override.enemyMoveset([MoveId.SPLASH, MoveId.SPLASH, MoveId.SPLASH, MoveId.SPLASH]); + game.override + .battleStyle("single") + .enemySpecies(SpeciesId.SHUCKLE) + .startingLevel(100) + .enemyLevel(100) + .ability(AbilityId.COMPOUND_EYES) + .enemyAbility(AbilityId.BALL_FETCH) + .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 shuckle = game.scene.getEnemyPokemon()!; + expect(shuckle.getTag(BattlerTagType.IGNORE_FLYING)).toBeUndefined(); game.move.select(MoveId.THOUSAND_ARROWS); + await game.phaseInterceptor.to("MoveEffectPhase", false); + const hitSpy = vi.spyOn(game.scene.getCurrentPhase() as MoveEffectPhase, "hitCheck"); - 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.toEndOfTurn(); - 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(shuckle.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined(); + expect(shuckle.hp).toBeLessThan(shuckle.getMaxHp()); }); - it("move should hit and ground targets with Levitate", async () => { - game.override.enemySpecies(SpeciesId.SNORLAX); + it("should hit and ground targets with Levitate", async () => { game.override.enemyAbility(AbilityId.LEVITATE); - await game.classicMode.startBattle([SpeciesId.ILLUMISE]); const enemyPokemon = game.scene.getEnemyPokemon()!; - - game.move.select(MoveId.THOUSAND_ARROWS); - - 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); + game.move.select(MoveId.THOUSAND_ARROWS); + await game.toEndOfTurn(); expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined(); expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp()); }); - 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()!; - - enemyPokemon.addTag(BattlerTagType.FLOATING, undefined, MoveId.MAGNET_RISE); - 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"); - await game.phaseInterceptor.to(BerryPhase, false); + // ensure magnet rise suceeeded before getting knocked down + const enemyPokemon = game.field.getEnemyPokemon(); + expect(enemyPokemon.getTag(BattlerTagType.FLOATING)).toBeDefined(); + await game.toEndOfTurn(); expect(enemyPokemon.getTag(BattlerTagType.FLOATING)).toBeUndefined(); expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined(); expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp()); }); + + it.each<{ name: string; move: MoveId }>([ + { name: "Fly", move: MoveId.FLY }, + { name: "Bounce", move: MoveId.BOUNCE }, + ])("should cancel the target's Fly", 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"); + + // Fly should've worked... until we smacked them + const shuckle = game.field.getEnemyPokemon(); + expect(shuckle.getTag(BattlerTagType.FLYING)).toBeDefined(); + expect(shuckle.getTag(BattlerTagType.IGNORE_FLYING)).toBeUndefined(); + + await game.phaseInterceptor.to("MoveEndPhase"); + expect(shuckle.getTag(BattlerTagType.FLYING)).toBeUndefined(); + expect(shuckle.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined(); + expect(shuckle.hp).toBeLessThan(shuckle.getMaxHp()); + }); + + it("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 shuckle = game.field.getEnemyPokemon(); + expect(shuckle.isGrounded()).toBe(false); + expect(shuckle.getTag(BattlerTagType.IGNORE_FLYING)).toBeUndefined(); + expect(shuckle.hp).toBeLessThan(shuckle.getMaxHp()); + }); + + // TODO: Sky drop is currently unimplemented + it.todo("should hit midair targets from Sky Drop without interrupting"); }); From 3e83b3d9d4a45e06ece608752919ec910f0ddd1d Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Fri, 6 Jun 2025 23:21:30 -0400 Subject: [PATCH 02/12] Cleaned up Sheer force tests --- test/abilities/sheer_force.test.ts | 101 ++++++++++++----------------- 1 file changed, 41 insertions(+), 60 deletions(-) diff --git a/test/abilities/sheer_force.test.ts b/test/abilities/sheer_force.test.ts index a5b1cf3b5b2..dce01fa2277 100644 --- a/test/abilities/sheer_force.test.ts +++ b/test/abilities/sheer_force.test.ts @@ -1,9 +1,7 @@ import { BattlerIndex } from "#app/battle"; -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"; @@ -30,15 +28,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) .disableCrits(); }); 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]; @@ -46,110 +43,94 @@ 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); - }, 20000); + }); - 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(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()); }); }); From fd290386b312042fa535f7f04027343f367f8f8a Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Fri, 6 Jun 2025 23:26:38 -0400 Subject: [PATCH 03/12] Modernized Pastel/Sweet Veil tests; added tests to confirm no status healing on sweet veil --- test/abilities/pastel_veil.test.ts | 48 ++++------- test/abilities/sweet_veil.test.ts | 125 +++++++++++++++++++---------- 2 files changed, 98 insertions(+), 75 deletions(-) diff --git a/test/abilities/pastel_veil.test.ts b/test/abilities/pastel_veil.test.ts index 8a3aec918d0..7b1366dc5c9 100644 --- a/test/abilities/pastel_veil.test.ts +++ b/test/abilities/pastel_veil.test.ts @@ -1,7 +1,5 @@ import { BattlerIndex } from "#app/battle"; -import { AbilityId } from "#enums/ability-id"; -import { CommandPhase } from "#app/phases/command-phase"; -import { TurnEndPhase } from "#app/phases/turn-end-phase"; +import { AbilityId } from "#app/enums/ability-id"; 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/sweet_veil.test.ts b/test/abilities/sweet_veil.test.ts index 131feaf7f56..3822bfafe6a 100644 --- a/test/abilities/sweet_veil.test.ts +++ b/test/abilities/sweet_veil.test.ts @@ -1,10 +1,10 @@ import { BattlerIndex } from "#app/battle"; 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 "#app/field/pokemon"; 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,68 +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); - game.override.enemyLevel(5); - game.override.startingLevel(5); - game.override.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(); }); }); From adea255d35cf1ab6fdee6235213d356eaaa2b199 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Fri, 6 Jun 2025 23:29:33 -0400 Subject: [PATCH 04/12] Cleaned up Harvest tests to use updated test utils --- test/abilities/harvest.test.ts | 99 +++++++++++++++------------------- 1 file changed, 44 insertions(+), 55 deletions(-) diff --git a/test/abilities/harvest.test.ts b/test/abilities/harvest.test.ts index 5a6ddf35459..5bf5a45a4c6 100644 --- a/test/abilities/harvest.test.ts +++ b/test/abilities/harvest.test.ts @@ -1,5 +1,4 @@ import { BattlerIndex } from "#app/battle"; -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...??? From a66263fd6a947f232e129f8f050232ba62f80299 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Sat, 7 Jun 2025 07:47:58 -0400 Subject: [PATCH 05/12] Added terrain tests from another branch (still failng, IDK why --- test/arena/grassy_terrain.test.ts | 69 ------ test/arena/terrain.test.ts | 368 ++++++++++++++++++++++++++++++ 2 files changed, 368 insertions(+), 69 deletions(-) delete mode 100644 test/arena/grassy_terrain.test.ts create mode 100644 test/arena/terrain.test.ts diff --git a/test/arena/grassy_terrain.test.ts b/test/arena/grassy_terrain.test.ts deleted file mode 100644 index 832f4400cf4..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") - .disableCrits() - .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..d011b5a7239 --- /dev/null +++ b/test/arena/terrain.test.ts @@ -0,0 +1,368 @@ +import { BattlerIndex } from "#app/battle"; +import { allMoves } from "#app/data/data-lists"; +import { getTerrainName, TerrainType } from "#app/data/terrain"; +import { MoveResult } from "#app/field/pokemon"; +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") + .disableCrits() + .enemySpecies(SpeciesId.SNOM) + .enemyAbility(AbilityId.STURDY) + .ability(AbilityId.NO_GUARD) + .passiveAbility(ability) + .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.PLAYER, BattlerIndex.ENEMY]); + await game.toEndOfTurn(); + + // Player grounded attack got boost while enemy ungrounded attack didn't + expect(powerSpy).toHaveLastReturnedWith(allMoves[move].power); + expect(powerSpy).toHaveNthReturnedWith(2, allMoves[move].power * 1.3); + }); + + it(`should change Terrain Pulse into a ${typeStr}-type move and double its base power`, async () => { + await game.classicMode.startBattle([SpeciesId.BLISSEY]); + + const blissey = game.field.getPlayerPokemon(); + const powerSpy = vi.spyOn(allMoves[MoveId.TERRAIN_PULSE], "calculateBattlePower"); + const typeSpy = vi.spyOn(blissey, "getMoveType"); + + game.move.use(move); + await game.move.forceEnemyMove(MoveId.SPLASH); + await game.toEndOfTurn(); + + expect(powerSpy).toHaveLastReturnedWith(allMoves[move].power * 2.6); // 2 * 1.3 + expect(typeSpy).toHaveLastReturnedWith(type); + }); + }); + + describe("Grassy Terrain", () => { + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .battleStyle("single") + .disableCrits() + .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]); + + 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.phaseInterceptor.clearLogs(); + + 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 after terrain block PR is added + describe.skip("Electric Terrain", () => { + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .battleStyle("single") + .disableCrits() + .enemyLevel(100) + .enemySpecies(SpeciesId.SHUCKLE) + .enemyAbility(AbilityId.ELECTRIC_SURGE) + .enemyPassiveAbility(AbilityId.LEVITATE) + .ability(AbilityId.NO_GUARD); + }); + + it("should prevent all grounded pokemon from being put to sleep", async () => { + await game.classicMode.startBattle([SpeciesId.BLISSEY]); + + game.move.use(MoveId.SPORE); + await game.move.forceEnemyMove(MoveId.SPORE); + await game.toEndOfTurn(); + + const blissey = game.field.getPlayerPokemon(); + const shuckle = game.field.getEnemyPokemon(); + expect(blissey.status?.effect).toBeUndefined(); + expect(shuckle.status?.effect).toBe(StatusEffect.SLEEP); + // TODO: These don't work due to how move failures are propagated + expect(blissey.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); + expect(shuckle.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + + 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 () => { + await game.classicMode.startBattle([SpeciesId.BLISSEY]); + vi.spyOn(allMoves[MoveId.RELIC_SONG], "chance", "get").mockReturnValue(100); + + game.move.use(MoveId.RELIC_SONG); + 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(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("Misty Terrain", () => { + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .battleStyle("single") + .disableCrits() + .enemyLevel(100) + .enemySpecies(SpeciesId.SHUCKLE) + .enemyAbility(AbilityId.MISTY_SURGE) + .enemyPassiveAbility(AbilityId.LEVITATE) + .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.BLISSEY]); + + // blissey is grounded, shuckle isn't + game.move.use(MoveId.TOXIC); + await game.move.forceEnemyMove(MoveId.TOXIC); + await game.toNextTurn(); + + const blissey = game.field.getPlayerPokemon(); + const shuckle = game.field.getEnemyPokemon(); + expect(blissey.status?.effect).toBeUndefined(); + expect(shuckle.status?.effect).toBe(StatusEffect.TOXIC); + // TODO: These don't work due to how move failures are propagated + expect(blissey.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); + expect(shuckle.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + + 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(blissey.getTag(BattlerTagType.CONFUSED)).toBeUndefined(); + expect(shuckle.getTag(BattlerTagType.CONFUSED)).toBeUndefined(); + }); + + it("should prevent attack moves from applying status without showing text/failing move", async () => { + await game.classicMode.startBattle([SpeciesId.BLISSEY]); + vi.spyOn(allMoves[MoveId.SACRED_FIRE], "chance", "get").mockReturnValue(100); + + game.move.use(MoveId.SACRED_FIRE); + 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(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") + .disableCrits() + .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); + }); + }); +}); From cd6f8b9c3de8b8d44771c84cff14d1f8528ca41d Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Sat, 7 Jun 2025 08:11:40 -0400 Subject: [PATCH 06/12] Fix SF test --- test/abilities/sheer_force.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/abilities/sheer_force.test.ts b/test/abilities/sheer_force.test.ts index dce01fa2277..c6d77a4fe25 100644 --- a/test/abilities/sheer_force.test.ts +++ b/test/abilities/sheer_force.test.ts @@ -99,6 +99,7 @@ describe("Abilities - Sheer Force", () => { await game.toEndOfTurn(); // 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); From cc44067c89c6e314615e47694d953ba859079f8f Mon Sep 17 00:00:00 2001 From: Bertie690 <136088738+Bertie690@users.noreply.github.com> Date: Sat, 7 Jun 2025 17:09:50 -0400 Subject: [PATCH 07/12] Fiz pokemon.ts Thx jummy --- src/field/pokemon.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index cda83cac469..8b6fce9f8e7 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -1,4 +1,4 @@ -import Phaser from "phaser"; +qqimport Phaser from "phaser"; import type { AnySound } from "#app/battle-scene"; import type BattleScene from "#app/battle-scene"; import { globalScene } from "#app/global-scene"; @@ -2323,8 +2323,8 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { (!this.isOfType(PokemonType.FLYING, true, true) && !this.hasAbility(AbilityId.LEVITATE) && !this.getTag(BattlerTagType.FLOATING) && - !ignoreSemiInvulnerable) || - !this.getTag(SemiInvulnerableTag) + !ignoreSemiInvulnerable || !this.getTag(SemiInvulnerableTag) + ) ); } From a7d699bf976f1e7747930b894b9388a214d4aec6 Mon Sep 17 00:00:00 2001 From: Bertie690 <136088738+Bertie690@users.noreply.github.com> Date: Sat, 7 Jun 2025 17:17:56 -0400 Subject: [PATCH 08/12] Update pokemon.ts --- src/field/pokemon.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 8b6fce9f8e7..0099532e640 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -1,4 +1,4 @@ -qqimport Phaser from "phaser"; +import Phaser from "phaser"; import type { AnySound } from "#app/battle-scene"; import type BattleScene from "#app/battle-scene"; import { globalScene } from "#app/global-scene"; From 9c2119eaed2086f6975ad8b4f3147bd792355128 Mon Sep 17 00:00:00 2001 From: Bertie690 <136088738+Bertie690@users.noreply.github.com> Date: Sat, 7 Jun 2025 17:22:01 -0400 Subject: [PATCH 09/12] Update pokemon.ts --- src/field/pokemon.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 0099532e640..b51bf2e551c 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -2323,7 +2323,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { (!this.isOfType(PokemonType.FLYING, true, true) && !this.hasAbility(AbilityId.LEVITATE) && !this.getTag(BattlerTagType.FLOATING) && - !ignoreSemiInvulnerable || !this.getTag(SemiInvulnerableTag) + ignoreSemiInvulnerable || !this.getTag(SemiInvulnerableTag) ) ); } From e59bf5254fa94f24d775c4bffc56d17df1c8b174 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Tue, 10 Jun 2025 09:42:47 -0400 Subject: [PATCH 10/12] Fixed groundedness checks in code --- src/data/moves/move.ts | 8 +- src/field/pokemon.ts | 8 +- test/abilities/sweet_veil.test.ts | 2 +- test/arena/arena_gravity.test.ts | 155 +++++++++++++++-------------- test/arena/terrain.test.ts | 59 ++++++----- test/moves/thousand_arrows.test.ts | 4 +- 6 files changed, 127 insertions(+), 109 deletions(-) diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 803aff8894d..573d3a96aab 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -9910,7 +9910,9 @@ export function initMoves() { .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) @@ -10365,7 +10367,9 @@ export function initMoves() { .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/field/pokemon.ts b/src/field/pokemon.ts index 9ae8577741d..7c72a469156 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -2268,6 +2268,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * * 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} @@ -2280,11 +2281,11 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { 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) && - ignoreSemiInvulnerable || !this.getTag(SemiInvulnerableTag) - ) + (ignoreSemiInvulnerable || !this.getTag(SemiInvulnerableTag))) ); } @@ -2462,7 +2463,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(true)) { const flyingIndex = types.indexOf(PokemonType.FLYING); if (flyingIndex > -1) { types.splice(flyingIndex, 1); diff --git a/test/abilities/sweet_veil.test.ts b/test/abilities/sweet_veil.test.ts index 8aa402d0918..323eb51651b 100644 --- a/test/abilities/sweet_veil.test.ts +++ b/test/abilities/sweet_veil.test.ts @@ -1,7 +1,7 @@ import { BattlerIndex } from "#enums/battler-index"; import { AbilityId } from "#enums/ability-id"; import { BattlerTagType } from "#app/enums/battler-tag-type"; -import { MoveResult } from "#app/field/pokemon"; +import { MoveResult } from "#enums/move-result"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; import { StatusEffect } from "#enums/status-effect"; 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/terrain.test.ts b/test/arena/terrain.test.ts index d011b5a7239..d0229b2baf2 100644 --- a/test/arena/terrain.test.ts +++ b/test/arena/terrain.test.ts @@ -1,7 +1,7 @@ -import { BattlerIndex } from "#app/battle"; +import { BattlerIndex } from "#enums/battler-index"; import { allMoves } from "#app/data/data-lists"; import { getTerrainName, TerrainType } from "#app/data/terrain"; -import { MoveResult } from "#app/field/pokemon"; +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"; @@ -99,6 +99,7 @@ describe("Terrain -", () => { 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); @@ -114,7 +115,6 @@ describe("Terrain -", () => { expect(game.phaseInterceptor.log).toContain("PokemonHealPhase"); expect(blissey.getHpRatio()).toBeCloseTo(0.5625, 1); expect(shuckle.getHpRatio()).toBeCloseTo(0.5, 1); - game.phaseInterceptor.clearLogs(); game.move.use(MoveId.DIG); await game.toNextTurn(); @@ -169,7 +169,7 @@ describe("Terrain -", () => { ); }); - // TODO: Enable after terrain block PR is added + // TODO: Enable tests after terrain-msg branch is merged describe.skip("Electric Terrain", () => { afterEach(() => { game.phaseInterceptor.restoreOg(); @@ -183,24 +183,23 @@ describe("Terrain -", () => { .enemyLevel(100) .enemySpecies(SpeciesId.SHUCKLE) .enemyAbility(AbilityId.ELECTRIC_SURGE) - .enemyPassiveAbility(AbilityId.LEVITATE) .ability(AbilityId.NO_GUARD); }); it("should prevent all grounded pokemon from being put to sleep", async () => { - await game.classicMode.startBattle([SpeciesId.BLISSEY]); + await game.classicMode.startBattle([SpeciesId.PIDGEOT]); game.move.use(MoveId.SPORE); await game.move.forceEnemyMove(MoveId.SPORE); await game.toEndOfTurn(); - const blissey = game.field.getPlayerPokemon(); + const pidgeot = game.field.getPlayerPokemon(); const shuckle = game.field.getEnemyPokemon(); - expect(blissey.status?.effect).toBeUndefined(); + 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(blissey.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); - expect(shuckle.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + 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", { @@ -211,16 +210,19 @@ describe("Terrain -", () => { }); it("should prevent attack moves from applying sleep without showing text/failing move", async () => { - await game.classicMode.startBattle([SpeciesId.BLISSEY]); 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(); - const shuckle = game.field.getEnemyPokemon(); expect(shuckle.status?.effect).toBeUndefined(); + expect(statusSpy).toHaveLastReturnedWith(false); expect(blissey.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); expect(game.textInterceptor.logs).not.toContain( @@ -232,6 +234,7 @@ describe("Terrain -", () => { }); }); + // TODO: Enable tests after terrain-msg branch is merged describe("Misty Terrain", () => { afterEach(() => { game.phaseInterceptor.restoreOg(); @@ -245,26 +248,25 @@ describe("Terrain -", () => { .enemyLevel(100) .enemySpecies(SpeciesId.SHUCKLE) .enemyAbility(AbilityId.MISTY_SURGE) - .enemyPassiveAbility(AbilityId.LEVITATE) .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.BLISSEY]); + await game.classicMode.startBattle([SpeciesId.PIDGEOT]); - // blissey is grounded, shuckle isn't + // shuckle is grounded, pidgeot isn't game.move.use(MoveId.TOXIC); await game.move.forceEnemyMove(MoveId.TOXIC); await game.toNextTurn(); - const blissey = game.field.getPlayerPokemon(); + const pidgeot = game.field.getPlayerPokemon(); const shuckle = game.field.getEnemyPokemon(); - expect(blissey.status?.effect).toBeUndefined(); + 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(blissey.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); - expect(shuckle.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + 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", { @@ -277,21 +279,30 @@ describe("Terrain -", () => { await game.move.forceEnemyMove(MoveId.CONFUSE_RAY); await game.toNextTurn(); - expect(blissey.getTag(BattlerTagType.CONFUSED)).toBeUndefined(); - expect(shuckle.getTag(BattlerTagType.CONFUSED)).toBeUndefined(); + expect(pidgeot.getTag(BattlerTagType.CONFUSED)).toBeUndefined(); + expect(shuckle.getTag(BattlerTagType.CONFUSED)).toBeDefined(); }); - it("should prevent attack moves from applying status without showing text/failing move", async () => { + 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[MoveId.SACRED_FIRE], "chance", "get").mockReturnValue(100); + vi.spyOn(allMoves[move], "chance", "get").mockReturnValue(100); - game.move.use(MoveId.SACRED_FIRE); + 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( diff --git a/test/moves/thousand_arrows.test.ts b/test/moves/thousand_arrows.test.ts index 5f2642ac985..5d21c7ec190 100644 --- a/test/moves/thousand_arrows.test.ts +++ b/test/moves/thousand_arrows.test.ts @@ -1,4 +1,4 @@ -import { BattlerIndex } from "#app/battle"; +import { BattlerIndex } from "#enums/battler-index"; import { AbilityId } from "#app/enums/ability-id"; import { BattlerTagType } from "#app/enums/battler-tag-type"; import type { MoveEffectPhase } from "#app/phases/move-effect-phase"; @@ -44,7 +44,7 @@ describe("Moves - Thousand Arrows", () => { game.move.select(MoveId.THOUSAND_ARROWS); await game.phaseInterceptor.to("MoveEffectPhase", false); - const hitSpy = vi.spyOn(game.scene.getCurrentPhase() as MoveEffectPhase, "hitCheck"); + const hitSpy = vi.spyOn(game.scene.phaseManager.getCurrentPhase() as MoveEffectPhase, "hitCheck"); await game.toEndOfTurn(); From 06b6143931888b4580ad2f5e615f46cfa2cf355b Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Tue, 10 Jun 2025 16:51:11 -0400 Subject: [PATCH 11/12] Revamp telekinesis tests; fix up stuff --- src/data/arena-tag.ts | 10 +-- src/data/battler-tags.ts | 4 + src/data/moves/move.ts | 50 ++++++----- src/field/pokemon.ts | 2 +- test/arena/terrain.test.ts | 31 ++++--- test/moves/telekinesis.test.ts | 139 ++++++++++++++++------------- test/moves/thousand_arrows.test.ts | 70 ++++++++------- 7 files changed, 169 insertions(+), 137 deletions(-) diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index e18ee5ac556..325ce0b7500 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -1096,12 +1096,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 ffa179c6aab..d90964492e8 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -660,6 +660,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 573d3a96aab..ff92f5c34d8 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -5299,13 +5299,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; } @@ -5634,8 +5632,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() { @@ -5644,19 +5642,29 @@ 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 { - // Smack down and similar only add their tag if the pokemon is already ungrounded without considering semi-invulnerability - if (!target.isGrounded(true)) { - globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:fallDown", { targetPokemonName: getPokemonNameWithAffix(target) })); - return super.apply(user, target, move, args); + // 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; } - 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); } } @@ -7975,6 +7983,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(); @@ -9280,6 +9289,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) @@ -9907,8 +9917,6 @@ 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) // TODO: Confirm if Smack Down & Thousand Arrows will ground semi-invulnerable Pokemon with No Guard, etc. @@ -10364,8 +10372,6 @@ 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) // TODO: Confirm if Smack Down & Thousand Arrows will ground semi-invulnerable Pokemon with No Guard, etc. diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 7c72a469156..6d522ebcf6b 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -2464,7 +2464,7 @@ 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(true)) { + if (moveType === PokemonType.GROUND && this.isGrounded()) { const flyingIndex = types.indexOf(PokemonType.FLYING); if (flyingIndex > -1) { types.splice(flyingIndex, 1); diff --git a/test/arena/terrain.test.ts b/test/arena/terrain.test.ts index d0229b2baf2..f90e903976b 100644 --- a/test/arena/terrain.test.ts +++ b/test/arena/terrain.test.ts @@ -42,8 +42,8 @@ describe("Terrain -", () => { .disableCrits() .enemySpecies(SpeciesId.SNOM) .enemyAbility(AbilityId.STURDY) - .ability(AbilityId.NO_GUARD) - .passiveAbility(ability) + .ability(ability) + .passiveAbility(AbilityId.NO_GUARD) .enemyPassiveAbility(AbilityId.LEVITATE); }); @@ -55,27 +55,31 @@ describe("Terrain -", () => { const powerSpy = vi.spyOn(allMoves[move], "calculateBattlePower"); game.move.use(move); await game.move.forceEnemyMove(move); - await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.toEndOfTurn(); - // Player grounded attack got boost while enemy ungrounded attack didn't - expect(powerSpy).toHaveLastReturnedWith(allMoves[move].power); - expect(powerSpy).toHaveNthReturnedWith(2, allMoves[move].power * 1.3); + // 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 blissey = game.field.getPlayerPokemon(); const powerSpy = vi.spyOn(allMoves[MoveId.TERRAIN_PULSE], "calculateBattlePower"); - const typeSpy = vi.spyOn(blissey, "getMoveType"); + const playerTypeSpy = vi.spyOn(game.field.getPlayerPokemon(), "getMoveType"); + const enemyTypeSpy = vi.spyOn(game.field.getEnemyPokemon(), "getMoveType"); - game.move.use(move); - await game.move.forceEnemyMove(MoveId.SPLASH); + 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(typeSpy).toHaveLastReturnedWith(type); + expect(playerTypeSpy).toHaveLastReturnedWith(type); + expect(powerSpy).toHaveNthReturnedWith(1, allMoves[move].power); + expect(enemyTypeSpy).toHaveNthReturnedWith(1, allMoves[MoveId.TERRAIN_PULSE].type); }); }); @@ -169,7 +173,7 @@ describe("Terrain -", () => { ); }); - // TODO: Enable tests after terrain-msg branch is merged + // TODO: Enable suites after terrain-fail-msg branch is merged describe.skip("Electric Terrain", () => { afterEach(() => { game.phaseInterceptor.restoreOg(); @@ -234,8 +238,7 @@ describe("Terrain -", () => { }); }); - // TODO: Enable tests after terrain-msg branch is merged - describe("Misty Terrain", () => { + describe.skip("Misty Terrain", () => { afterEach(() => { game.phaseInterceptor.restoreOg(); }); 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 5d21c7ec190..20139ddad69 100644 --- a/test/moves/thousand_arrows.test.ts +++ b/test/moves/thousand_arrows.test.ts @@ -26,11 +26,11 @@ describe("Moves - Thousand Arrows", () => { game = new GameManager(phaserGame); game.override .battleStyle("single") - .enemySpecies(SpeciesId.SHUCKLE) + .enemySpecies(SpeciesId.EELEKTROSS) .startingLevel(100) - .enemyLevel(100) + .enemyLevel(50) .ability(AbilityId.COMPOUND_EYES) - .enemyAbility(AbilityId.BALL_FETCH) + .enemyAbility(AbilityId.STURDY) .moveset(MoveId.THOUSAND_ARROWS) .enemyMoveset(MoveId.SPLASH); }); @@ -39,32 +39,35 @@ describe("Moves - Thousand Arrows", () => { game.override.enemySpecies(SpeciesId.ARCHEOPS); await game.classicMode.startBattle([SpeciesId.ILLUMISE]); - const shuckle = game.scene.getEnemyPokemon()!; - expect(shuckle.getTag(BattlerTagType.IGNORE_FLYING)).toBeUndefined(); + 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(); expect(hitSpy).toHaveReturnedWith([expect.anything(), 1]); - expect(shuckle.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined(); - expect(shuckle.hp).toBeLessThan(shuckle.getMaxHp()); + expect(archeops.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined(); + expect(archeops.isGrounded()).toBe(true); + expect(archeops.hp).toBeLessThan(archeops.getMaxHp()); }); - it("should hit and ground targets with Levitate", async () => { - game.override.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()!; - expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeUndefined(); + 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(); - 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("should hit and ground targets under the effects of Magnet Rise", async () => { @@ -76,19 +79,19 @@ describe("Moves - Thousand Arrows", () => { await game.phaseInterceptor.to("MoveEndPhase"); // ensure magnet rise suceeeded before getting knocked down - const enemyPokemon = game.field.getEnemyPokemon(); - expect(enemyPokemon.getTag(BattlerTagType.FLOATING)).toBeDefined(); + const eelektross = game.field.getEnemyPokemon(); + expect(eelektross.getTag(BattlerTagType.FLOATING)).toBeDefined(); await game.toEndOfTurn(); - expect(enemyPokemon.getTag(BattlerTagType.FLOATING)).toBeUndefined(); - expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined(); - expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp()); + 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 cancel the target's Fly", async ({ move }) => { + ])("should hit through and cancel the target's $name", async ({ move }) => { await game.classicMode.startBattle([SpeciesId.ILLUMISE]); game.move.select(MoveId.THOUSAND_ARROWS); @@ -96,18 +99,19 @@ describe("Moves - Thousand Arrows", () => { await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.phaseInterceptor.to("MoveEndPhase"); - // Fly should've worked... until we smacked them - const shuckle = game.field.getEnemyPokemon(); - expect(shuckle.getTag(BattlerTagType.FLYING)).toBeDefined(); - expect(shuckle.getTag(BattlerTagType.IGNORE_FLYING)).toBeUndefined(); + // Fly should've worked until hit + const eelektross = game.field.getEnemyPokemon(); + expect(eelektross.getTag(BattlerTagType.FLYING)).toBeDefined(); + expect(eelektross.getTag(BattlerTagType.IGNORE_FLYING)).toBeUndefined(); - await game.phaseInterceptor.to("MoveEndPhase"); - expect(shuckle.getTag(BattlerTagType.FLYING)).toBeUndefined(); - expect(shuckle.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined(); - expect(shuckle.hp).toBeLessThan(shuckle.getMaxHp()); + await game.toEndOfTurn(); + expect(eelektross.getTag(BattlerTagType.FLYING)).toBeUndefined(); + expect(eelektross.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined(); + expect(eelektross.hp).toBeLessThan(eelektross.getMaxHp()); }); - it("should NOT ground semi-invulnerable targets unless already ungrounded", async () => { + // 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); @@ -115,10 +119,10 @@ describe("Moves - Thousand Arrows", () => { await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.toEndOfTurn(); - const shuckle = game.field.getEnemyPokemon(); - expect(shuckle.isGrounded()).toBe(false); - expect(shuckle.getTag(BattlerTagType.IGNORE_FLYING)).toBeUndefined(); - expect(shuckle.hp).toBeLessThan(shuckle.getMaxHp()); + 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 From b0fa48f8c8a0e05ba225305d6b30a1135e4a5338 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Wed, 18 Jun 2025 14:47:54 -0400 Subject: [PATCH 12/12] Added heal phase to interceptor --- test/testUtils/phaseInterceptor.ts | 2 ++ 1 file changed, 2 insertions(+) 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],