diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index 43de6e02dcb..fce46f0c98d 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -1203,6 +1203,24 @@ class GrassWaterPledgeTag extends ArenaTag { } } +/** + * Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Fairy_Lock_(move) Fairy Lock}. + * Fairy Lock prevents all Pokémon (except Ghost types) on the field from switching out or + * fleeing during their next turn. + * If a Pokémon that's on the field when Fairy Lock is used goes on to faint later in the same turn, + * the Pokémon that replaces it will still be unable to switch out in the following turn. + */ +export class FairyLockTag extends ArenaTag { + constructor(turnCount: number, sourceId: number) { + super(ArenaTagType.FAIRY_LOCK, turnCount, Moves.FAIRY_LOCK, sourceId); + } + + onAdd(arena: Arena): void { + arena.scene.queueMessage(i18next.t("arenaTag:fairyLockOnAdd")); + } + +} + // TODO: swap `sourceMove` and `sourceId` and make `sourceMove` an optional parameter export function getArenaTag(tagType: ArenaTagType, turnCount: number, sourceMove: Moves | undefined, sourceId: number, targetIndex?: BattlerIndex, side: ArenaTagSide = ArenaTagSide.BOTH): ArenaTag | null { switch (tagType) { @@ -1261,6 +1279,8 @@ export function getArenaTag(tagType: ArenaTagType, turnCount: number, sourceMove return new WaterFirePledgeTag(sourceId, side); case ArenaTagType.GRASS_WATER_PLEDGE: return new GrassWaterPledgeTag(sourceId, side); + case ArenaTagType.FAIRY_LOCK: + return new FairyLockTag(turnCount, sourceId); default: return null; } diff --git a/src/data/move.ts b/src/data/move.ts index 2fc34d13df5..43a57db1b6d 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -9258,8 +9258,9 @@ export function initMoves() { .target(MoveTarget.ALL_NEAR_OTHERS), new StatusMove(Moves.FAIRY_LOCK, Type.FAIRY, -1, 10, -1, 0, 6) .ignoresSubstitute() + .ignoresProtect() .target(MoveTarget.BOTH_SIDES) - .unimplemented(), + .attr(AddArenaTagAttr, ArenaTagType.FAIRY_LOCK, 2, true), new SelfStatusMove(Moves.KINGS_SHIELD, Type.STEEL, -1, 10, -1, 4, 6) .attr(ProtectAttr, BattlerTagType.KINGS_SHIELD) .condition(failIfLastCondition), diff --git a/src/enums/arena-tag-type.ts b/src/enums/arena-tag-type.ts index c73f4ec2ae5..1c62ccb14a6 100644 --- a/src/enums/arena-tag-type.ts +++ b/src/enums/arena-tag-type.ts @@ -28,4 +28,5 @@ export enum ArenaTagType { FIRE_GRASS_PLEDGE = "FIRE_GRASS_PLEDGE", WATER_FIRE_PLEDGE = "WATER_FIRE_PLEDGE", GRASS_WATER_PLEDGE = "GRASS_WATER_PLEDGE", + FAIRY_LOCK = "FAIRY_LOCK", } diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 2dcef4b3965..2214dfecac5 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -1565,7 +1565,8 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { applyCheckTrappedAbAttrs(CheckTrappedAbAttr, opponent, trappedByAbility, this, trappedAbMessages, simulated) ); - return (trappedByAbility.value || !!this.getTag(TrappedTag)); + const side = this.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; + return (trappedByAbility.value || !!this.getTag(TrappedTag) || !!this.scene.arena.getTagOnSide(ArenaTagType.FAIRY_LOCK, side)); } /** diff --git a/src/phases/command-phase.ts b/src/phases/command-phase.ts index 6d4d46c51c9..c1d38815c2f 100644 --- a/src/phases/command-phase.ts +++ b/src/phases/command-phase.ts @@ -17,6 +17,8 @@ import { FieldPhase } from "./field-phase"; import { SelectTargetPhase } from "./select-target-phase"; import { MysteryEncounterMode } from "#enums/mystery-encounter-mode"; import { isNullOrUndefined } from "#app/utils"; +import { ArenaTagSide } from "#app/data/arena-tag"; +import { ArenaTagType } from "#app/enums/arena-tag-type"; export class CommandPhase extends FieldPhase { protected fieldIndex: integer; @@ -228,32 +230,43 @@ export class CommandPhase extends FieldPhase { }, null, true); } else { const trapTag = playerPokemon.getTag(TrappedTag); + const fairyLockTag = playerPokemon.scene.arena.getTagOnSide(ArenaTagType.FAIRY_LOCK, ArenaTagSide.PLAYER); // trapTag should be defined at this point, but just in case... - if (!trapTag) { + if (!trapTag && !fairyLockTag) { currentBattle.turnCommands[this.fieldIndex] = isSwitch ? { command: Command.POKEMON, cursor: cursor, args: args } : { command: Command.RUN }; break; } - if (!isSwitch) { this.scene.ui.setMode(Mode.COMMAND, this.fieldIndex); this.scene.ui.setMode(Mode.MESSAGE); } - this.scene.ui.showText( - i18next.t("battle:noEscapePokemon", { - pokemonName: trapTag.sourceId && this.scene.getPokemonById(trapTag.sourceId) ? getPokemonNameWithAffix(this.scene.getPokemonById(trapTag.sourceId)!) : "", - moveName: trapTag.getMoveName(), - escapeVerb: isSwitch ? i18next.t("battle:escapeVerbSwitch") : i18next.t("battle:escapeVerbFlee") - }), - null, - () => { - this.scene.ui.showText("", 0); - if (!isSwitch) { - this.scene.ui.setMode(Mode.COMMAND, this.fieldIndex); - } - }, null, true); + const showNoEscapeText = (tag: any) => { + this.scene.ui.showText( + i18next.t("battle:noEscapePokemon", { + pokemonName: tag.sourceId && this.scene.getPokemonById(tag.sourceId) ? getPokemonNameWithAffix(this.scene.getPokemonById(tag.sourceId)!) : "", + moveName: tag.getMoveName(), + escapeVerb: isSwitch ? i18next.t("battle:escapeVerbSwitch") : i18next.t("battle:escapeVerbFlee") + }), + null, + () => { + this.scene.ui.showText("", 0); + if (!isSwitch) { + this.scene.ui.setMode(Mode.COMMAND, this.fieldIndex); + } + }, + null, + true + ); + }; + + if (trapTag) { + showNoEscapeText(trapTag); + } else if (fairyLockTag) { + showNoEscapeText(fairyLockTag); + } } } break; diff --git a/src/test/moves/fairy_lock.test.ts b/src/test/moves/fairy_lock.test.ts new file mode 100644 index 00000000000..ceb298ed0fe --- /dev/null +++ b/src/test/moves/fairy_lock.test.ts @@ -0,0 +1,152 @@ +import { ArenaTagSide } from "#app/data/arena-tag"; +import { ArenaTagType } from "#app/enums/arena-tag-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, expect, it } from "vitest"; + +describe("Moves - Fairy Lock", () => { + 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.FAIRY_LOCK, Moves.SPLASH ]) + .ability(Abilities.BALL_FETCH) + .battleType("double") + .disableCrits() + .enemySpecies(Species.MAGIKARP) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset([ Moves.SPLASH, Moves.U_TURN ]); + }); + + it("Applies Fairy Lock tag for two turns", async () => { + await game.classicMode.startBattle([ Species.KLEFKI, Species.TYRUNT ]); + const playerPokemon = game.scene.getPlayerField(); + const enemyField = game.scene.getEnemyField(); + + game.move.select(Moves.FAIRY_LOCK); + game.move.select(Moves.SPLASH, 1); + await game.forceEnemyMove(Moves.SPLASH, 1); + await game.forceEnemyMove(Moves.SPLASH, 1); + await game.phaseInterceptor.to("BerryPhase"); + expect(game.scene.arena.getTagOnSide(ArenaTagType.FAIRY_LOCK, ArenaTagSide.PLAYER)).toBeDefined(); + expect(game.scene.arena.getTagOnSide(ArenaTagType.FAIRY_LOCK, ArenaTagSide.ENEMY)).toBeDefined(); + + await game.toNextTurn(); + + game.move.select(Moves.SPLASH); + game.move.select(Moves.SPLASH); + await game.forceEnemyMove(Moves.SPLASH, 1); + await game.forceEnemyMove(Moves.SPLASH, 1); + await game.phaseInterceptor.to("BerryPhase"); + expect(playerPokemon[0].isTrapped()).toEqual(true); + expect(playerPokemon[1].isTrapped()).toEqual(true); + expect(enemyField[0].isTrapped()).toEqual(true); + expect(enemyField[1].isTrapped()).toEqual(true); + + await game.toNextTurn(); + expect(playerPokemon[0].isTrapped()).toEqual(false); + expect(playerPokemon[1].isTrapped()).toEqual(false); + expect(enemyField[0].isTrapped()).toEqual(false); + expect(enemyField[1].isTrapped()).toEqual(false); + }); + + it("Ghost types can escape Fairy Lock", async () => { + await game.classicMode.startBattle([ Species.DUSKNOIR, Species.GENGAR, Species.TYRUNT ]); + + game.move.select(Moves.FAIRY_LOCK); + game.move.select(Moves.SPLASH, 1); + await game.forceEnemyMove(Moves.SPLASH, 1); + await game.forceEnemyMove(Moves.SPLASH, 1); + await game.phaseInterceptor.to("BerryPhase"); + + expect(game.scene.arena.getTagOnSide(ArenaTagType.FAIRY_LOCK, ArenaTagSide.PLAYER)).toBeDefined(); + expect(game.scene.arena.getTagOnSide(ArenaTagType.FAIRY_LOCK, ArenaTagSide.ENEMY)).toBeDefined(); + + await game.toNextTurn(); + + expect(game.scene.getPlayerField()[0].isTrapped()).toEqual(false); + expect(game.scene.getPlayerField()[1].isTrapped()).toEqual(false); + + game.move.select(Moves.SPLASH); + game.doSwitchPokemon(2); + await game.forceEnemyMove(Moves.SPLASH, 1); + await game.forceEnemyMove(Moves.SPLASH, 1); + await game.phaseInterceptor.to("BerryPhase"); + await game.toNextTurn(); + + expect(game.scene.getPlayerField()[1].species.speciesId).not.toBe(Species.GENGAR); + }); + + it("Phasing moves will still switch out", async () => { + game.override.enemyMoveset([ Moves.SPLASH, Moves.WHIRLWIND ]); + await game.classicMode.startBattle([ Species.KLEFKI, Species.TYRUNT, Species.ZYGARDE ]); + + game.move.select(Moves.FAIRY_LOCK); + game.move.select(Moves.SPLASH, 1); + await game.forceEnemyMove(Moves.SPLASH, 1); + await game.forceEnemyMove(Moves.SPLASH, 1); + await game.phaseInterceptor.to("BerryPhase"); + + expect(game.scene.arena.getTagOnSide(ArenaTagType.FAIRY_LOCK, ArenaTagSide.PLAYER)).toBeDefined(); + expect(game.scene.arena.getTagOnSide(ArenaTagType.FAIRY_LOCK, ArenaTagSide.ENEMY)).toBeDefined(); + + await game.toNextTurn(); + game.move.select(Moves.SPLASH); + game.move.select(Moves.SPLASH); + await game.forceEnemyMove(Moves.WHIRLWIND, 0); + game.doSelectPartyPokemon(2); + await game.forceEnemyMove(Moves.WHIRLWIND, 1); + game.doSelectPartyPokemon(2); + await game.phaseInterceptor.to("BerryPhase"); + await game.toNextTurn(); + + expect(game.scene.getPlayerField()[0].species.speciesId).not.toBe(Species.KLEFKI); + expect(game.scene.getPlayerField()[1].species.speciesId).not.toBe(Species.TYRUNT); + }); + + it("If a Pokemon faints and is replaced the replacement is also trapped", async () => { + game.override.moveset([ Moves.FAIRY_LOCK, Moves.SPLASH, Moves.MEMENTO ]); + await game.classicMode.startBattle([ Species.KLEFKI, Species.GUZZLORD, Species.TYRUNT, Species.ZYGARDE ]); + + game.move.select(Moves.FAIRY_LOCK); + game.move.select(Moves.MEMENTO, 1); + game.doSelectPartyPokemon(2); + await game.forceEnemyMove(Moves.SPLASH, 1); + await game.forceEnemyMove(Moves.SPLASH, 1); + await game.phaseInterceptor.to("BerryPhase"); + expect(game.scene.arena.getTagOnSide(ArenaTagType.FAIRY_LOCK, ArenaTagSide.PLAYER)).toBeDefined(); + expect(game.scene.arena.getTagOnSide(ArenaTagType.FAIRY_LOCK, ArenaTagSide.ENEMY)).toBeDefined(); + + await game.toNextTurn(); + game.move.select(Moves.SPLASH); + game.move.select(Moves.SPLASH); + await game.forceEnemyMove(Moves.SPLASH, 1); + await game.forceEnemyMove(Moves.SPLASH, 1); + await game.phaseInterceptor.to("BerryPhase"); + expect(game.scene.getPlayerField()[0].isTrapped()).toEqual(true); + expect(game.scene.getPlayerField()[1].isTrapped()).toEqual(true); + expect(game.scene.getEnemyField()[0].isTrapped()).toEqual(true); + expect(game.scene.getEnemyField()[1].isTrapped()).toEqual(true); + + await game.toNextTurn(); + expect(game.scene.getPlayerField()[0].isTrapped()).toEqual(false); + expect(game.scene.getPlayerField()[1].isTrapped()).toEqual(false); + expect(game.scene.getEnemyField()[0].isTrapped()).toEqual(false); + expect(game.scene.getEnemyField()[1].isTrapped()).toEqual(false); + }); +});