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();