From c58b5e943b986f084b3592889e8eac352ed9b06a Mon Sep 17 00:00:00 2001 From: schmidtc1 <62030095+schmidtc1@users.noreply.github.com> Date: Thu, 3 Oct 2024 11:49:33 -0400 Subject: [PATCH 1/2] [P2] Fixes party status cure moves only curing the player's pokemon, even when used by enemy pokemon (#3369) * Fixes bug with Status Cure moves only curing player pokemon, refactors PartyStatusCureAttr, removes PartyStatusCurePhase * Adds check for user ID, since user always cures its own status regardless of ability * Adds unit tests for sparkly swirl * Merge and fix conflicts * Fix conflicts with SPLASH_ONLY * Fix failing sparkly swirl test due to splash_only * Adds unit tests for heal bell and aromatherapy * Update src/data/move.ts --------- Co-authored-by: flx-sta <50131232+flx-sta@users.noreply.github.com> --- src/data/move.ts | 27 ++++++- src/phases/party-status-cure-phase.ts | 48 ------------ src/test/moves/aromatherapy.test.ts | 101 ++++++++++++++++++++++++++ src/test/moves/heal_bell.test.ts | 101 ++++++++++++++++++++++++++ src/test/moves/sparkly_swirl.test.ts | 86 ++++++++++++++++++++++ 5 files changed, 311 insertions(+), 52 deletions(-) delete mode 100644 src/phases/party-status-cure-phase.ts create mode 100644 src/test/moves/aromatherapy.test.ts create mode 100644 src/test/moves/heal_bell.test.ts create mode 100644 src/test/moves/sparkly_swirl.test.ts diff --git a/src/data/move.ts b/src/data/move.ts index 13ab84e8898..d547cd56524 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -25,7 +25,6 @@ import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; import { MoveUsedEvent } from "#app/events/battle-scene"; import { BATTLE_STATS, type BattleStat, EFFECTIVE_STATS, type EffectiveStat, getStatKey, Stat } from "#app/enums/stat"; -import { PartyStatusCurePhase } from "#app/phases/party-status-cure-phase"; import { BattleEndPhase } from "#app/phases/battle-end-phase"; import { MoveEndPhase } from "#app/phases/move-end-phase"; import { MovePhase } from "#app/phases/move-phase"; @@ -34,6 +33,7 @@ import { PokemonHealPhase } from "#app/phases/pokemon-heal-phase"; import { StatStageChangePhase } from "#app/phases/stat-stage-change-phase"; import { SwitchPhase } from "#app/phases/switch-phase"; import { SwitchSummonPhase } from "#app/phases/switch-summon-phase"; +import { ShowAbilityPhase } from "#app/phases/show-ability-phase"; import { SpeciesFormChangeRevertWeatherFormTrigger } from "./pokemon-forms"; import { GameMode } from "#app/game-mode"; import { applyChallenges, ChallengeType } from "./challenge"; @@ -1585,12 +1585,31 @@ export class PartyStatusCureAttr extends MoveEffectAttr { if (!this.canApply(user, target, move, args)) { return false; } - this.addPartyCurePhase(user); + const partyPokemon = user.isPlayer() ? user.scene.getParty() : user.scene.getEnemyParty(); + partyPokemon.forEach(p => this.cureStatus(p, user.id)); + + if (this.message) { + user.scene.queueMessage(this.message); + } + return true; } - addPartyCurePhase(user: Pokemon) { - user.scene.unshiftPhase(new PartyStatusCurePhase(user.scene, user, this.message, this.abilityCondition)); + /** + * Tries to cure the status of the given {@linkcode Pokemon} + * @param pokemon The {@linkcode Pokemon} to cure. + * @param userId The ID of the (move) {@linkcode Pokemon | user}. + */ + public cureStatus(pokemon: Pokemon, userId: number) { + if (!pokemon.isOnField() || pokemon.id === userId) { // user always cures its own status, regardless of ability + pokemon.resetStatus(false); + pokemon.updateInfo(); + } else if (!pokemon.hasAbility(this.abilityCondition)) { + pokemon.resetStatus(); + pokemon.updateInfo(); + } else { + pokemon.scene.unshiftPhase(new ShowAbilityPhase(pokemon.scene, pokemon.id, pokemon.getPassiveAbility()?.id === this.abilityCondition)); + } } } diff --git a/src/phases/party-status-cure-phase.ts b/src/phases/party-status-cure-phase.ts deleted file mode 100644 index e4903c7fc1f..00000000000 --- a/src/phases/party-status-cure-phase.ts +++ /dev/null @@ -1,48 +0,0 @@ -import BattleScene from "#app/battle-scene"; -import { Abilities } from "#app/enums/abilities"; -import Pokemon from "#app/field/pokemon"; -import { BattlePhase } from "./battle-phase"; -import { ShowAbilityPhase } from "./show-ability-phase"; - -/** - * Cures the party of all non-volatile status conditions, shows a message - * @param {BattleScene} scene The current scene - * @param {Pokemon} user The user of the move that cures the party - * @param {string} message The message that should be displayed - * @param {Abilities} abilityCondition Pokemon with this ability will not be affected ie. Soundproof - */ -export class PartyStatusCurePhase extends BattlePhase { - private user: Pokemon; - private message: string; - private abilityCondition: Abilities; - - constructor(scene: BattleScene, user: Pokemon, message: string, abilityCondition: Abilities) { - super(scene); - - this.user = user; - this.message = message; - this.abilityCondition = abilityCondition; - } - - start() { - super.start(); - for (const pokemon of this.scene.getParty()) { - if (!pokemon.isOnField() || pokemon === this.user) { - pokemon.resetStatus(false); - pokemon.updateInfo(true); - } else { - if (!pokemon.hasAbility(this.abilityCondition)) { - pokemon.resetStatus(); - pokemon.updateInfo(true); - } else { - // Manually show ability bar, since we're not hooked into the targeting system - pokemon.scene.unshiftPhase(new ShowAbilityPhase(pokemon.scene, pokemon.id, pokemon.getPassiveAbility()?.id === this.abilityCondition)); - } - } - } - if (this.message) { - this.scene.queueMessage(this.message); - } - this.end(); - } -} diff --git a/src/test/moves/aromatherapy.test.ts b/src/test/moves/aromatherapy.test.ts new file mode 100644 index 00000000000..acc2e9c5fae --- /dev/null +++ b/src/test/moves/aromatherapy.test.ts @@ -0,0 +1,101 @@ +import { StatusEffect } from "#app/enums/status-effect"; +import { CommandPhase } from "#app/phases/command-phase"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, it, expect, vi } from "vitest"; + +describe("Moves - Aromatherapy", () => { + 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 + .moveset([Moves.AROMATHERAPY, Moves.SPLASH]) + .statusEffect(StatusEffect.BURN) + .battleType("double") + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH); + }); + + it("should cure status effect of the user, its ally, and all party pokemon", async () => { + await game.classicMode.startBattle([Species.RATTATA, Species.RATTATA, Species.RATTATA]); + const [leftPlayer, rightPlayer, partyPokemon] = game.scene.getParty(); + + vi.spyOn(leftPlayer, "resetStatus"); + vi.spyOn(rightPlayer, "resetStatus"); + vi.spyOn(partyPokemon, "resetStatus"); + + game.move.select(Moves.AROMATHERAPY, 0); + await game.phaseInterceptor.to(CommandPhase); + game.move.select(Moves.SPLASH, 1); + await game.toNextTurn(); + + expect(leftPlayer.resetStatus).toHaveBeenCalledOnce(); + expect(rightPlayer.resetStatus).toHaveBeenCalledOnce(); + expect(partyPokemon.resetStatus).toHaveBeenCalledOnce(); + + expect(leftPlayer.status?.effect).toBeUndefined(); + expect(rightPlayer.status?.effect).toBeUndefined(); + expect(partyPokemon.status?.effect).toBeUndefined(); + }); + + it("should not cure status effect of the target/target's allies", async () => { + game.override.enemyStatusEffect(StatusEffect.BURN); + await game.classicMode.startBattle([Species.RATTATA, Species.RATTATA]); + const [leftOpp, rightOpp] = game.scene.getEnemyField(); + + vi.spyOn(leftOpp, "resetStatus"); + vi.spyOn(rightOpp, "resetStatus"); + + game.move.select(Moves.AROMATHERAPY, 0); + await game.phaseInterceptor.to(CommandPhase); + game.move.select(Moves.SPLASH, 1); + await game.toNextTurn(); + + expect(leftOpp.resetStatus).toHaveBeenCalledTimes(0); + expect(rightOpp.resetStatus).toHaveBeenCalledTimes(0); + + expect(leftOpp.status?.effect).toBeTruthy(); + expect(rightOpp.status?.effect).toBeTruthy(); + + expect(leftOpp.status?.effect).toBe(StatusEffect.BURN); + expect(rightOpp.status?.effect).toBe(StatusEffect.BURN); + }); + + it("should not cure status effect of allies ON FIELD with Sap Sipper, should still cure allies in party", async () => { + game.override.ability(Abilities.SAP_SIPPER); + await game.classicMode.startBattle([Species.RATTATA, Species.RATTATA, Species.RATTATA]); + const [leftPlayer, rightPlayer, partyPokemon] = game.scene.getParty(); + + vi.spyOn(leftPlayer, "resetStatus"); + vi.spyOn(rightPlayer, "resetStatus"); + vi.spyOn(partyPokemon, "resetStatus"); + + game.move.select(Moves.AROMATHERAPY, 0); + await game.phaseInterceptor.to(CommandPhase); + game.move.select(Moves.SPLASH, 1); + await game.toNextTurn(); + + expect(leftPlayer.resetStatus).toHaveBeenCalledOnce(); + expect(rightPlayer.resetStatus).toHaveBeenCalledTimes(0); + expect(partyPokemon.resetStatus).toHaveBeenCalledOnce(); + + expect(leftPlayer.status?.effect).toBeUndefined(); + expect(rightPlayer.status?.effect).toBe(StatusEffect.BURN); + expect(partyPokemon.status?.effect).toBeUndefined(); + }); +}); diff --git a/src/test/moves/heal_bell.test.ts b/src/test/moves/heal_bell.test.ts new file mode 100644 index 00000000000..1421809cf6c --- /dev/null +++ b/src/test/moves/heal_bell.test.ts @@ -0,0 +1,101 @@ +import { StatusEffect } from "#app/enums/status-effect"; +import { CommandPhase } from "#app/phases/command-phase"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, it, expect, vi } from "vitest"; + +describe("Moves - Heal Bell", () => { + 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 + .moveset([Moves.HEAL_BELL, Moves.SPLASH]) + .statusEffect(StatusEffect.BURN) + .battleType("double") + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH); + }); + + it("should cure status effect of the user, its ally, and all party pokemon", async () => { + await game.classicMode.startBattle([Species.RATTATA, Species.RATTATA, Species.RATTATA]); + const [leftPlayer, rightPlayer, partyPokemon] = game.scene.getParty(); + + vi.spyOn(leftPlayer, "resetStatus"); + vi.spyOn(rightPlayer, "resetStatus"); + vi.spyOn(partyPokemon, "resetStatus"); + + game.move.select(Moves.HEAL_BELL, 0); + await game.phaseInterceptor.to(CommandPhase); + game.move.select(Moves.SPLASH, 1); + await game.toNextTurn(); + + expect(leftPlayer.resetStatus).toHaveBeenCalledOnce(); + expect(rightPlayer.resetStatus).toHaveBeenCalledOnce(); + expect(partyPokemon.resetStatus).toHaveBeenCalledOnce(); + + expect(leftPlayer.status?.effect).toBeUndefined(); + expect(rightPlayer.status?.effect).toBeUndefined(); + expect(partyPokemon.status?.effect).toBeUndefined(); + }); + + it("should not cure status effect of the target/target's allies", async () => { + game.override.enemyStatusEffect(StatusEffect.BURN); + await game.classicMode.startBattle([Species.RATTATA, Species.RATTATA]); + const [leftOpp, rightOpp] = game.scene.getEnemyField(); + + vi.spyOn(leftOpp, "resetStatus"); + vi.spyOn(rightOpp, "resetStatus"); + + game.move.select(Moves.HEAL_BELL, 0); + await game.phaseInterceptor.to(CommandPhase); + game.move.select(Moves.SPLASH, 1); + await game.toNextTurn(); + + expect(leftOpp.resetStatus).toHaveBeenCalledTimes(0); + expect(rightOpp.resetStatus).toHaveBeenCalledTimes(0); + + expect(leftOpp.status?.effect).toBeTruthy(); + expect(rightOpp.status?.effect).toBeTruthy(); + + expect(leftOpp.status?.effect).toBe(StatusEffect.BURN); + expect(rightOpp.status?.effect).toBe(StatusEffect.BURN); + }); + + it("should not cure status effect of allies ON FIELD with Soundproof, should still cure allies in party", async () => { + game.override.ability(Abilities.SOUNDPROOF); + await game.classicMode.startBattle([Species.RATTATA, Species.RATTATA, Species.RATTATA]); + const [leftPlayer, rightPlayer, partyPokemon] = game.scene.getParty(); + + vi.spyOn(leftPlayer, "resetStatus"); + vi.spyOn(rightPlayer, "resetStatus"); + vi.spyOn(partyPokemon, "resetStatus"); + + game.move.select(Moves.HEAL_BELL, 0); + await game.phaseInterceptor.to(CommandPhase); + game.move.select(Moves.SPLASH, 1); + await game.toNextTurn(); + + expect(leftPlayer.resetStatus).toHaveBeenCalledOnce(); + expect(rightPlayer.resetStatus).toHaveBeenCalledTimes(0); + expect(partyPokemon.resetStatus).toHaveBeenCalledOnce(); + + expect(leftPlayer.status?.effect).toBeUndefined(); + expect(rightPlayer.status?.effect).toBe(StatusEffect.BURN); + expect(partyPokemon.status?.effect).toBeUndefined(); + }); +}); diff --git a/src/test/moves/sparkly_swirl.test.ts b/src/test/moves/sparkly_swirl.test.ts new file mode 100644 index 00000000000..83c154e57e7 --- /dev/null +++ b/src/test/moves/sparkly_swirl.test.ts @@ -0,0 +1,86 @@ +import { allMoves } from "#app/data/move"; +import { StatusEffect } from "#app/enums/status-effect"; +import { CommandPhase } from "#app/phases/command-phase"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("Moves - Sparkly Swirl", () => { + 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 + .enemySpecies(Species.SHUCKLE) + .enemyLevel(100) + .enemyMoveset(Moves.SPLASH) + .enemyAbility(Abilities.BALL_FETCH) + .moveset([Moves.SPARKLY_SWIRL, Moves.SPLASH]) + .ability(Abilities.BALL_FETCH); + + vi.spyOn(allMoves[Moves.SPARKLY_SWIRL], "accuracy", "get").mockReturnValue(100); + }); + + it("should cure status effect of the user, its ally, and all party pokemon", async () => { + game.override + .battleType("double") + .statusEffect(StatusEffect.BURN); + await game.classicMode.startBattle([Species.RATTATA, Species.RATTATA, Species.RATTATA]); + const [leftPlayer, rightPlayer, partyPokemon] = game.scene.getParty(); + const leftOpp = game.scene.getEnemyPokemon()!; + + vi.spyOn(leftPlayer, "resetStatus"); + vi.spyOn(rightPlayer, "resetStatus"); + vi.spyOn(partyPokemon, "resetStatus"); + + game.move.select(Moves.SPARKLY_SWIRL, 0, leftOpp.getBattlerIndex()); + await game.phaseInterceptor.to(CommandPhase); + game.move.select(Moves.SPLASH, 1); + await game.toNextTurn(); + + expect(leftPlayer.resetStatus).toHaveBeenCalledOnce(); + expect(rightPlayer.resetStatus).toHaveBeenCalledOnce(); + expect(partyPokemon.resetStatus).toHaveBeenCalledOnce(); + + expect(leftPlayer.status?.effect).toBeUndefined(); + expect(rightPlayer.status?.effect).toBeUndefined(); + expect(partyPokemon.status?.effect).toBeUndefined(); + }); + + it("should not cure status effect of the target/target's allies", async () => { + game.override + .battleType("double") + .enemyStatusEffect(StatusEffect.BURN); + await game.classicMode.startBattle([Species.RATTATA, Species.RATTATA]); + const [leftOpp, rightOpp] = game.scene.getEnemyField(); + + vi.spyOn(leftOpp, "resetStatus"); + vi.spyOn(rightOpp, "resetStatus"); + + game.move.select(Moves.SPARKLY_SWIRL, 0, leftOpp.getBattlerIndex()); + await game.phaseInterceptor.to(CommandPhase); + game.move.select(Moves.SPLASH, 1); + await game.toNextTurn(); + + expect(leftOpp.resetStatus).toHaveBeenCalledTimes(0); + expect(rightOpp.resetStatus).toHaveBeenCalledTimes(0); + + expect(leftOpp.status?.effect).toBeTruthy(); + expect(rightOpp.status?.effect).toBeTruthy(); + + expect(leftOpp.status?.effect).toBe(StatusEffect.BURN); + expect(rightOpp.status?.effect).toBe(StatusEffect.BURN); + }); +}); From 76e25a6d6f316dd78e59a3792f39d3b8f999478c Mon Sep 17 00:00:00 2001 From: "Adrian T." <68144167+torranx@users.noreply.github.com> Date: Fri, 4 Oct 2024 00:58:21 +0800 Subject: [PATCH 2/2] [Move] Update Tera Starstorm (still Partial), Readd Partial tag to Tera Blast (#4549) * fully implement tera starstorm * add docs * add tests * add override keyword * account for fusion * swap party positions * add partial tag to tera blast * address comments --- src/data/move.ts | 46 +++++++++++-- src/field/pokemon.ts | 9 +++ src/test/moves/tera_starstorm.test.ts | 98 +++++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 src/test/moves/tera_starstorm.test.ts diff --git a/src/data/move.ts b/src/data/move.ts index d547cd56524..8c9e8b0fb99 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -3942,7 +3942,14 @@ export class PhotonGeyserCategoryAttr extends VariableMoveCategoryAttr { } } -export class TeraBlastCategoryAttr extends VariableMoveCategoryAttr { +/** + * Attribute used for tera moves that change category based on the user's Atk and SpAtk stats + * Note: Currently, `getEffectiveStat` does not ignore all abilities that affect stats except those + * with the attribute of `StatMultiplierAbAttr` + * TODO: Remove the `.partial()` tag from Tera Blast and Tera Starstorm when the above issue is resolved + * @extends VariableMoveCategoryAttr + */ +export class TeraMoveCategoryAttr extends VariableMoveCategoryAttr { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { const category = (args[0] as Utils.NumberHolder); @@ -4031,6 +4038,30 @@ export class VariableMoveTypeAttr extends MoveAttr { } } +/** + * Attribute used for Tera Starstorm that changes the move type to Stellar + * @extends VariableMoveTypeAttr + */ +export class TeraStarstormTypeAttr extends VariableMoveTypeAttr { + /** + * + * @param user the {@linkcode Pokemon} using the move + * @param target n/a + * @param move n/a + * @param args[0] {@linkcode Utils.NumberHolder} the move type + * @returns `true` if the move type is changed to {@linkcode Type.STELLAR}, `false` otherwise + */ + override apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + if (user.isTerastallized() && (user.hasFusionSpecies(Species.TERAPAGOS) || user.species.speciesId === Species.TERAPAGOS)) { + const moveType = args[0] as Utils.NumberHolder; + + moveType.value = Type.STELLAR; + return true; + } + return false; + } +} + export class FormChangeItemTypeAttr extends VariableMoveTypeAttr { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { const moveType = args[0]; @@ -9190,7 +9221,7 @@ export function initMoves() { .attr(HalfSacrificialAttr), new AttackMove(Moves.EXPANDING_FORCE, Type.PSYCHIC, MoveCategory.SPECIAL, 80, 100, 10, -1, 0, 8) .attr(MovePowerMultiplierAttr, (user, target, move) => user.scene.arena.getTerrainType() === TerrainType.PSYCHIC && user.isGrounded() ? 1.5 : 1) - .attr(VariableTargetAttr, (user, target, move) => user.scene.arena.getTerrainType() === TerrainType.PSYCHIC && user.isGrounded() ? 6 : 3), + .attr(VariableTargetAttr, (user, target, move) => user.scene.arena.getTerrainType() === TerrainType.PSYCHIC && user.isGrounded() ? MoveTarget.ALL_NEAR_ENEMIES : MoveTarget.NEAR_OTHER), new AttackMove(Moves.STEEL_ROLLER, Type.STEEL, MoveCategory.PHYSICAL, 130, 100, 5, -1, 0, 8) .attr(ClearTerrainAttr) .condition((user, target, move) => !!user.scene.arena.terrain), @@ -9464,10 +9495,11 @@ export function initMoves() { .unimplemented(), End Unused */ new AttackMove(Moves.TERA_BLAST, Type.NORMAL, MoveCategory.SPECIAL, 80, 100, 10, -1, 0, 9) - .attr(TeraBlastCategoryAttr) + .attr(TeraMoveCategoryAttr) .attr(TeraBlastTypeAttr) .attr(TeraBlastPowerAttr) - .attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], -1, true, (user, target, move) => user.isTerastallized() && user.isOfType(Type.STELLAR)), + .attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], -1, true, (user, target, move) => user.isTerastallized() && user.isOfType(Type.STELLAR)) + .partial(), /** Does not ignore abilities that affect stats, relevant in determining the move's category {@see TeraMoveCategoryAttr} */ new SelfStatusMove(Moves.SILK_TRAP, Type.BUG, -1, 10, -1, 4, 9) .attr(ProtectAttr, BattlerTagType.SILK_TRAP) .condition(failIfLastCondition), @@ -9657,8 +9689,10 @@ export function initMoves() { .attr(ElectroShotChargeAttr) .ignoresVirtual(), new AttackMove(Moves.TERA_STARSTORM, Type.NORMAL, MoveCategory.SPECIAL, 120, 100, 5, -1, 0, 9) - .attr(TeraBlastCategoryAttr) - .partial(), + .attr(TeraMoveCategoryAttr) + .attr(TeraStarstormTypeAttr) + .attr(VariableTargetAttr, (user, target, move) => (user.hasFusionSpecies(Species.TERAPAGOS) || user.species.speciesId === Species.TERAPAGOS) && user.isTerastallized() ? MoveTarget.ALL_NEAR_ENEMIES : MoveTarget.NEAR_OTHER) + .partial(), /** Does not ignore abilities that affect stats, relevant in determining the move's category {@see TeraMoveCategoryAttr} */ new AttackMove(Moves.FICKLE_BEAM, Type.DRAGON, MoveCategory.SPECIAL, 80, 100, 5, 30, 0, 9) .attr(PreMoveMessageAttr, doublePowerChanceMessageFunc) .attr(DoublePowerChanceAttr), diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index d5411496223..de18dfb74eb 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -1087,6 +1087,15 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { return !!this.fusionSpecies; } + /** + * Checks if the {@linkcode Pokemon} has a fusion with the specified {@linkcode Species}. + * @param species the pokemon {@linkcode Species} to check + * @returns `true` if the {@linkcode Pokemon} has a fusion with the specified {@linkcode Species}, `false` otherwise + */ + hasFusionSpecies(species: Species): boolean { + return this.fusionSpecies?.speciesId === species; + } + abstract isBoss(): boolean; getMoveset(ignoreOverride?: boolean): (PokemonMove | null)[] { diff --git a/src/test/moves/tera_starstorm.test.ts b/src/test/moves/tera_starstorm.test.ts new file mode 100644 index 00000000000..20dbc0b77d6 --- /dev/null +++ b/src/test/moves/tera_starstorm.test.ts @@ -0,0 +1,98 @@ +import { BattlerIndex } from "#app/battle"; +import { Type } from "#app/data/type"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, it, expect, vi } from "vitest"; + +describe("Moves - Tera Starstorm", () => { + 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 + .moveset([Moves.TERA_STARSTORM, Moves.SPLASH]) + .battleType("double") + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH) + .enemyLevel(30) + .enemySpecies(Species.MAGIKARP) + .startingHeldItems([{ name: "TERA_SHARD", type: Type.FIRE }]); + }); + + it("changes type to Stellar when used by Terapagos in its Stellar Form", async () => { + game.override.battleType("single"); + await game.classicMode.startBattle([Species.TERAPAGOS]); + + const terapagos = game.scene.getPlayerPokemon()!; + + vi.spyOn(terapagos, "getMoveType"); + + game.move.select(Moves.TERA_STARSTORM); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(terapagos.isTerastallized()).toBe(true); + expect(terapagos.getMoveType).toHaveReturnedWith(Type.STELLAR); + }); + + it("targets both opponents in a double battle when used by Terapagos in its Stellar Form", async () => { + await game.classicMode.startBattle([Species.MAGIKARP, Species.TERAPAGOS]); + + game.move.select(Moves.TERA_STARSTORM, 0, BattlerIndex.ENEMY); + game.move.select(Moves.TERA_STARSTORM, 1); + + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]); + + const enemyField = game.scene.getEnemyField(); + + // Pokemon other than Terapagos should not be affected - only hits one target + await game.phaseInterceptor.to("MoveEndPhase"); + expect(enemyField.some(pokemon => pokemon.isFullHp())).toBe(true); + + // Terapagos in Stellar Form should hit both targets + await game.phaseInterceptor.to("MoveEndPhase"); + expect(enemyField.every(pokemon => pokemon.isFullHp())).toBe(false); + }); + + it("applies the effects when Terapagos in Stellar Form is fused with another Pokemon", async () => { + await game.classicMode.startBattle([Species.TERAPAGOS, Species.CHARMANDER, Species.MAGIKARP]); + + const fusionedMon = game.scene.getParty()[0]; + const magikarp = game.scene.getParty()[2]; + + // Fuse party members (taken from PlayerPokemon.fuse(...) function) + fusionedMon.fusionSpecies = magikarp.species; + fusionedMon.fusionFormIndex = magikarp.formIndex; + fusionedMon.fusionAbilityIndex = magikarp.abilityIndex; + fusionedMon.fusionShiny = magikarp.shiny; + fusionedMon.fusionVariant = magikarp.variant; + fusionedMon.fusionGender = magikarp.gender; + fusionedMon.fusionLuck = magikarp.luck; + + vi.spyOn(fusionedMon, "getMoveType"); + + game.move.select(Moves.TERA_STARSTORM, 0); + game.move.select(Moves.SPLASH, 1); + await game.phaseInterceptor.to("TurnEndPhase"); + + // Fusion and terastallized + expect(fusionedMon.isFusion()).toBe(true); + expect(fusionedMon.isTerastallized()).toBe(true); + // Move effects should be applied + expect(fusionedMon.getMoveType).toHaveReturnedWith(Type.STELLAR); + expect(game.scene.getEnemyField().every(pokemon => pokemon.isFullHp())).toBe(false); + }); +});