diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index 7c67271b0dc..2dd9195a5e5 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -844,7 +844,30 @@ class HappyHourTag extends ArenaTag { } } -export function getArenaTag(tagType: ArenaTagType, turnCount: integer, sourceMove: Moves | undefined, sourceId: integer, targetIndex?: BattlerIndex, side: ArenaTagSide = ArenaTagSide.BOTH): ArenaTag | null { +class SafegaurdTag extends ArenaTag { + constructor(turnCount: integer, sourceId: integer, side: ArenaTagSide) { + super(ArenaTagType.SAFEGUARD, turnCount, Moves.SAFEGUARD, sourceId, side); + } + + onAdd(arena: Arena): void { + if (this.side === ArenaTagSide.PLAYER) { + arena.scene.queueMessage("Your team cloaked itself in a mystical veil!"); + } else { + arena.scene.queueMessage("Enemy team cloaked itself in a mystical veil!"); + } + } + + onRemove(arena: Arena): void { + if (this.side === ArenaTagSide.PLAYER) { + arena.scene.queueMessage("Your team is not longer protected by the mystical veil!"); + } else { + arena.scene.queueMessage("Enemy team is not longer protected by the mystical veil!"); + } + } +} + + +export function getArenaTag(tagType: ArenaTagType, turnCount: integer, sourceMove: Moves, sourceId: integer, targetIndex?: BattlerIndex, side: ArenaTagSide = ArenaTagSide.BOTH): ArenaTag { switch (tagType) { case ArenaTagType.MIST: return new MistTag(turnCount, sourceId, side); @@ -889,6 +912,8 @@ export function getArenaTag(tagType: ArenaTagType, turnCount: integer, sourceMov return new TailwindTag(turnCount, sourceId, side); case ArenaTagType.HAPPY_HOUR: return new HappyHourTag(turnCount, sourceId, side); + case ArenaTagType.SAFEGUARD: + return new SafegaurdTag(turnCount, sourceId, side); default: return null; } diff --git a/src/data/move.ts b/src/data/move.ts index 2d0ea11c3a0..59895177dd4 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -4,7 +4,7 @@ import { BattleStat, getBattleStatName } from "./battle-stat"; import { EncoreTag, GulpMissileTag, HelpingHandTag, SemiInvulnerableTag, StockpilingTag, TypeBoostTag } from "./battler-tags"; import { getPokemonNameWithAffix } from "../messages"; import Pokemon, { AttackMoveResult, EnemyPokemon, HitResult, MoveResult, PlayerPokemon, PokemonMove, TurnMove } from "../field/pokemon"; -import { StatusEffect, getStatusEffectHealText, isNonVolatileStatusEffect, getNonVolatileStatusEffects} from "./status-effect"; +import { StatusEffect, getStatusEffectHealText, isNonVolatileStatusEffect, getNonVolatileStatusEffects } from "./status-effect"; import { getTypeResistances, Type } from "./type"; import { Constructor } from "#app/utils"; import * as Utils from "../utils"; @@ -1918,6 +1918,7 @@ export class StatusEffectAttr extends MoveEffectAttr { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { const moveChance = this.getMoveChance(user, target, move, this.selfTarget, true); const statusCheck = moveChance < 0 || moveChance === 100 || user.randSeedInt(100) < moveChance; + const targetSide = target instanceof EnemyPokemon ? ArenaTagSide.ENEMY : ArenaTagSide.PLAYER; if (statusCheck) { const pokemon = this.selfTarget ? user : target; if (pokemon.status) { @@ -1927,6 +1928,16 @@ export class StatusEffectAttr extends MoveEffectAttr { return false; } } + + if (user.scene.arena.getTagOnSide(ArenaTagType.SAFEGUARD, targetSide)) { + if (move.category === MoveCategory.STATUS) { + user.scene.pushPhase( + new MessagePhase(user.scene, + `${target.name} is protected by Safeguard!`, + 0, false, 0), false); + } + return false; + } if ((!pokemon.status || (pokemon.status.effect === this.effect && moveChance < 0)) && pokemon.trySetStatus(this.effect, true, user, this.cureTurn)) { applyPostAttackAbAttrs(ConfusionOnStatusEffectAbAttr, user, target, move, null, this.effect); @@ -6740,7 +6751,7 @@ export function initMoves() { .attr(FriendshipPowerAttr, true), new StatusMove(Moves.SAFEGUARD, Type.NORMAL, -1, 25, -1, 0, 2) .target(MoveTarget.USER_SIDE) - .unimplemented(), + .attr(AddArenaTagAttr, ArenaTagType.SAFEGUARD, 5, true, true), new StatusMove(Moves.PAIN_SPLIT, Type.NORMAL, -1, 20, -1, 0, 2) .attr(HpSplitAttr) .condition(failOnBossCondition), diff --git a/src/enums/arena-tag-type.ts b/src/enums/arena-tag-type.ts index 1265b815bf4..1c79750c91a 100644 --- a/src/enums/arena-tag-type.ts +++ b/src/enums/arena-tag-type.ts @@ -22,5 +22,6 @@ export enum ArenaTagType { CRAFTY_SHIELD = "CRAFTY_SHIELD", TAILWIND = "TAILWIND", HAPPY_HOUR = "HAPPY_HOUR", + SAFEGUARD = "SAFEGUARD", NO_CRIT = "NO_CRIT" } diff --git a/src/test/moves/safeguard.test.ts b/src/test/moves/safeguard.test.ts new file mode 100644 index 00000000000..87f1e81d566 --- /dev/null +++ b/src/test/moves/safeguard.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import Phaser from "phaser"; +import GameManager from "#app/test/utils/gameManager"; +import * as overrides from "#app/overrides"; +import { + CommandPhase, + SelectTargetPhase, + TurnEndPhase, +} from "#app/phases"; +import { getMovePosition } from "#app/test/utils/gameManagerUtils"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import { BattlerIndex } from "#app/battle.js"; +import { Stat } from "#app/data/pokemon-stat"; +import { ArenaTagType } from "#app/enums/arena-tag-type.js"; +import { ArenaTagSide } from "#app/data/arena-tag.js"; + +const TIMEOUT = 20 * 1000; + +const SAFEGUARD = Moves.SAFEGUARD; + +describe("Moves - Safeguard", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + vi.spyOn(overrides, "SINGLE_BATTLE_OVERRIDE", "get").mockReturnValue(true); + vi.spyOn(overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(Species.AMOONGUSS); + vi.spyOn(overrides, "STARTER_SPECIES_OVERRIDE", "get").mockReturnValue(Species.DEOXYS); + vi.spyOn(overrides, "STARTING_LEVEL_OVERRIDE", "get").mockReturnValue(100); + vi.spyOn(overrides, "OPP_LEVEL_OVERRIDE", "get").mockReturnValue(100); + vi.spyOn(overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SPORE, Moves.SPORE, Moves.SPORE, Moves.SPORE]); + vi.spyOn(overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SAFEGUARD, Moves.SAFEGUARD, Moves.SAFEGUARD, Moves.SAFEGUARD]); + }); + it("protects from nuzzle status", + async () => { + + vi.spyOn(overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(Species.SENTRET); + vi.spyOn(overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.NUZZLE, + Moves.NUZZLE, + Moves.NUZZLE, + Moves.NUZZLE]); + await game.startBattle([Species.DEOXYS]); + const enemyPokemon = game.scene.getEnemyField(); + const playerPokemon = game.scene.getPlayerField(); + + game.doAttack(getMovePosition(game.scene, 0, SAFEGUARD)); + + expect(enemyPokemon[0].status).toBe(undefined); + expect(playerPokemon[0].status).toBe(undefined); + }, TIMEOUT + ); + it("protects from spore", + async () => { + + await game.startBattle([Species.DEOXYS]); + const enemyPokemon = game.scene.getEnemyField(); + const playerPokemon = game.scene.getPlayerField(); + + game.doAttack(getMovePosition(game.scene, 0, Moves.SAFEGUARD)); + + expect(enemyPokemon[0].status).toBe(undefined); + expect(playerPokemon[0].status).toBe(undefined); + }, TIMEOUT + ); + it("protects ally from status", + async () => { + vi.spyOn(overrides, "SINGLE_BATTLE_OVERRIDE", "get").mockReturnValue(false); + vi.spyOn(overrides, "DOUBLE_BATTLE_OVERRIDE", "get").mockReturnValue(true); + + vi.spyOn(overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(Species.DEOXYS); + vi.spyOn(overrides, "STARTER_SPECIES_OVERRIDE", "get").mockReturnValue(Species.AMOONGUSS); + vi.spyOn(overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SAFEGUARD, Moves.SAFEGUARD, Moves.SAFEGUARD, Moves.SAFEGUARD]); + vi.spyOn(overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SPORE, Moves.NUZZLE, Moves.SPORE, Moves.SPORE]); + + await game.startBattle([Species.AMOONGUSS, Species.FURRET]); + game.scene.currentBattle.enemyParty[1].stats[Stat.SPD] = 1; + + game.doAttack(getMovePosition(game.scene, 0, Moves.SPORE)); + await game.phaseInterceptor.to(SelectTargetPhase, false); + game.doSelectTarget(BattlerIndex.ENEMY_2); + + await game.phaseInterceptor.to(CommandPhase); + + game.doAttack(getMovePosition(game.scene, 1, Moves.NUZZLE)); + await game.phaseInterceptor.to(SelectTargetPhase, false); + game.doSelectTarget(BattlerIndex.ENEMY_2); + + await game.phaseInterceptor.to(TurnEndPhase); + + const enemyPokemon = game.scene.getEnemyField(); + const playerPokemon = game.scene.getPlayerField(); + + expect(enemyPokemon[0].status).toBe(undefined); + expect(enemyPokemon[1].status).toBe(undefined); + expect(playerPokemon[0].status).toBe(undefined); + expect(playerPokemon[1].status).toBe(undefined); + }, TIMEOUT + ); + it("applys arena tag for 5 turns", + async () => { + + await game.startBattle([Species.DEOXYS]); + + for (let i=0;i<5;i++) { + game.doAttack(getMovePosition(game.scene, 0, Moves.SAFEGUARD)); + await game.phaseInterceptor.to(CommandPhase); + } + + expect(game.scene.arena.getTagOnSide(ArenaTagType.SAFEGUARD, ArenaTagSide.PLAYER)).toBeUndefined(); + }, TIMEOUT + ); +});