diff --git a/src/data/move.ts b/src/data/move.ts index 3ff843d6b73f..2377c3200ffa 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -2795,33 +2795,53 @@ export class ResetStatsAttr extends MoveEffectAttr { } /** - * Attribute used for moves which swap the user and the target's stat stages. + * Attribute used for status moves, specifically Heart, Guard, and Power Swap, + * that swaps the user's and target's corresponding stat stages. + * @extends MoveEffectAttr + * @see {@linkcode apply} */ export class SwapStatStagesAttr extends MoveEffectAttr { + /** The stat stages to be swapped between the user and the target */ + private stats: readonly BattleStat[]; + + constructor(stats: readonly BattleStat[]) { + super(); + + this.stats = stats; + } + /** - * Swaps the user and the target's stat changes. - * @param user Pokemon that used the move - * @param target The target of the move - * @param move Move with this attribute + * For all {@linkcode stats}, swaps the user's and target's corresponding stat + * stage. + * @param user the {@linkcode Pokemon} that used the move + * @param target the {@linkcode Pokemon} that the move was used on + * @param move N/A * @param args N/A - * @returns true if the function succeeds + * @returns true if attribute application succeeds */ apply(user: Pokemon, target: Pokemon, move: Move, args: any []): boolean { - if (!super.apply(user, target, move, args)) { - return false; - } //Exits if the move can't apply + if (super.apply(user, target, move, args)) { + for (const s of BATTLE_STATS) { + const temp = user.getStatStage(s); + user.setStatStage(s, target.getStatStage(s)); + target.setStatStage(s, temp); + } - let temp: number; - for (const s of BATTLE_STATS) { - temp = user.getStatStage(s); - user.setStatStage(s, target.getStatStage(s)); - target.setStatStage(s, temp); + target.updateInfo(); + user.updateInfo(); + + if (this.stats.length === 7) { + target.scene.queueMessage(i18next.t("moveTriggers:switchedStatChanges", { pokemonName: getPokemonNameWithAffix(user) })); + } else if (this.stats.length === 2) { + target.scene.queueMessage(i18next.t("moveTriggers:switchedTwoStatChanges", { + pokemonName: getPokemonNameWithAffix(user), + firstStat: i18next.t(getStatKey(this.stats[0])), + secondStat: i18next.t(getStatKey(this.stats[1])) + })); + } + return true; } - - target.updateInfo(); - user.updateInfo(); - target.scene.queueMessage(i18next.t("moveTriggers:switchedStatChanges", {pokemonName: getPokemonNameWithAffix(user)})); - return true; + return false; } } @@ -7345,9 +7365,9 @@ export function initMoves() { .attr(CopyMoveAttr) .ignoresVirtual(), new StatusMove(Moves.POWER_SWAP, Type.PSYCHIC, -1, 10, 100, 0, 4) - .unimplemented(), + .attr(SwapStatStagesAttr, [ Stat.ATK, Stat.SPATK ]), new StatusMove(Moves.GUARD_SWAP, Type.PSYCHIC, -1, 10, 100, 0, 4) - .unimplemented(), + .attr(SwapStatStagesAttr, [ Stat.DEF, Stat.SPDEF ]), new AttackMove(Moves.PUNISHMENT, Type.DARK, MoveCategory.PHYSICAL, -1, 100, 5, -1, 0, 4) .makesContact(true) .attr(PunishmentPowerAttr), @@ -7361,7 +7381,7 @@ export function initMoves() { .attr(AddArenaTrapTagAttr, ArenaTagType.TOXIC_SPIKES) .target(MoveTarget.ENEMY_SIDE), new StatusMove(Moves.HEART_SWAP, Type.PSYCHIC, -1, 10, -1, 0, 4) - .attr(SwapStatStagesAttr), + .attr(SwapStatStagesAttr, BATTLE_STATS), new SelfStatusMove(Moves.AQUA_RING, Type.WATER, -1, 20, -1, 0, 4) .attr(AddBattlerTagAttr, BattlerTagType.AQUA_RING, true, true), new SelfStatusMove(Moves.MAGNET_RISE, Type.ELECTRIC, -1, 10, -1, 0, 4) diff --git a/src/locales/en/move-trigger.ts b/src/locales/en/move-trigger.ts index 5f9c6c0cdc28..fe46773970df 100644 --- a/src/locales/en/move-trigger.ts +++ b/src/locales/en/move-trigger.ts @@ -5,6 +5,7 @@ export const moveTriggers: SimpleTranslationEntries = { "cutHpPowerUpMove": "{{pokemonName}} cut its own HP to power up its move!", "absorbedElectricity": "{{pokemonName}} absorbed electricity!", "switchedStatChanges": "{{pokemonName}} switched stat changes with the target!", + "switchedTwoStatChanges": "{{pokemonName}} switched all changes to its {{firstStat}}\nand {{secondStat}} with its target!", "goingAllOutForAttack": "{{pokemonName}} is going all out for this attack!", "regainedHealth": "{{pokemonName}} regained\nhealth!", "keptGoingAndCrashed": "{{pokemonName}} kept going\nand crashed!", diff --git a/src/test/moves/guard_swap.test.ts b/src/test/moves/guard_swap.test.ts new file mode 100644 index 000000000000..f311fed40f74 --- /dev/null +++ b/src/test/moves/guard_swap.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import Phaser from "phaser"; +import GameManager from "#app/test/utils/gameManager"; +import { Species } from "#enums/species"; +import { TurnEndPhase } from "#app/phases/turn-end-phase"; +import { Moves } from "#enums/moves"; +import { Stat } from "#enums/stat"; +import { getMovePosition } from "#app/test/utils/gameManagerUtils"; +import { Abilities } from "#enums/abilities"; +import { MoveEndPhase } from "#app/phases/move-end-phase.js"; + +describe("Moves - Guard Swap", () => { + 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.battleType("single"); + game.override.enemyAbility(Abilities.BALL_FETCH); + game.override.enemyMoveset(new Array(4).fill(Moves.SHELL_SMASH)); + game.override.enemySpecies(Species.MEW); + game.override.enemyLevel(200); + game.override.moveset([ Moves.GUARD_SWAP ]); + game.override.ability(Abilities.NONE); + }); + + it("should swap the user's DEF AND SPDEF stat stages with the target's", async () => { + await game.startBattle([ + Species.INDEEDEE + ]); + + // Should start with no stat stages + const player = game.scene.getPlayerPokemon()!; + // After Shell Smash, should have +2 in ATK and SPATK, -1 in DEF and SPDEF + const enemy = game.scene.getEnemyPokemon()!; + + game.doAttack(getMovePosition(game.scene, 0, Moves.GUARD_SWAP)); + + await game.phaseInterceptor.to(MoveEndPhase); + + expect(player.getStatStage(Stat.DEF)).toBe(0); + expect(player.getStatStage(Stat.SPDEF)).toBe(0); + expect(enemy.getStatStage(Stat.DEF)).toBe(-1); + expect(enemy.getStatStage(Stat.SPDEF)).toBe(-1); + + await game.phaseInterceptor.to(TurnEndPhase); + + expect(player.getStatStage(Stat.DEF)).toBe(-1); + expect(player.getStatStage(Stat.SPDEF)).toBe(-1); + expect(enemy.getStatStage(Stat.DEF)).toBe(0); + expect(enemy.getStatStage(Stat.SPDEF)).toBe(0); + }, 20000); +}); diff --git a/src/test/moves/power_swap.test.ts b/src/test/moves/power_swap.test.ts new file mode 100644 index 000000000000..651776e29400 --- /dev/null +++ b/src/test/moves/power_swap.test.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import Phaser from "phaser"; +import GameManager from "#app/test/utils/gameManager"; +import { Species } from "#enums/species"; +import { TurnEndPhase } from "#app/phases/turn-end-phase"; +import { Moves } from "#enums/moves"; +import { Stat } from "#enums/stat"; +import { getMovePosition } from "#app/test/utils/gameManagerUtils"; +import { Abilities } from "#enums/abilities"; +import { MoveEndPhase } from "#app/phases/move-end-phase.js"; + +describe("Moves - Power Swap", () => { + 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.battleType("single"); + game.override.enemyAbility(Abilities.BALL_FETCH); + game.override.enemyMoveset(new Array(4).fill(Moves.SHELL_SMASH)); + game.override.enemySpecies(Species.MEW); + game.override.enemyLevel(200); + game.override.moveset([ Moves.POWER_SWAP ]); + game.override.ability(Abilities.NONE); + }); + + it("should swap the user's ATK AND SPATK stat stages with the target's", async () => { + await game.startBattle([ + Species.INDEEDEE + ]); + + // Should start with no stat stages + const player = game.scene.getPlayerPokemon()!; + // After Shell Smash, should have +2 in ATK and SPATK, -1 in DEF and SPDEF + const enemy = game.scene.getEnemyPokemon()!; + game.doAttack(getMovePosition(game.scene, 0, Moves.POWER_SWAP)); + + await game.phaseInterceptor.to(MoveEndPhase); + + expect(player.getStatStage(Stat.ATK)).toBe(0); + expect(player.getStatStage(Stat.SPATK)).toBe(0); + expect(enemy.getStatStage(Stat.ATK)).toBe(2); + expect(enemy.getStatStage(Stat.SPATK)).toBe(2); + + await game.phaseInterceptor.to(TurnEndPhase); + + expect(player.getStatStage(Stat.ATK)).toBe(2); + expect(player.getStatStage(Stat.SPATK)).toBe(2); + expect(enemy.getStatStage(Stat.ATK)).toBe(0); + expect(enemy.getStatStage(Stat.SPATK)).toBe(0); + }, 20000); +}); diff --git a/src/test/moves/speed_swap.test.ts b/src/test/moves/speed_swap.test.ts index 2815e79f277f..38ee39b07877 100644 --- a/src/test/moves/speed_swap.test.ts +++ b/src/test/moves/speed_swap.test.ts @@ -27,6 +27,7 @@ describe("Moves - Speed Swap", () => { game = new GameManager(phaserGame); game.override.battleType("single"); game.override.enemyAbility(Abilities.NONE); + game.override.enemyMoveset(SPLASH_ONLY); game.override.enemySpecies(Species.MEW); game.override.enemyLevel(200); game.override.moveset([ Moves.SPEED_SWAP ]); @@ -34,7 +35,6 @@ describe("Moves - Speed Swap", () => { }); it("should swap the user's SPD and the target's SPD stats", async () => { - game.override.enemyMoveset(SPLASH_ONLY); await game.startBattle([ Species.INDEEDEE ]);