diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 41a49270c9e..a664ac1e2bf 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -634,6 +634,32 @@ export class IngrainTag extends TrappedTag { } } +/** + * Octolock traps the target pokemon and reduces its DEF and SPDEF by one stage at the + * end of each turn. + */ +export class OctolockTag extends TrappedTag { + constructor(sourceId: number) { + super(BattlerTagType.OCTOLOCK, BattlerTagLapseType.TURN_END, 1, Moves.OCTOLOCK, sourceId); + } + + canAdd(pokemon: Pokemon): boolean { + const isOctolocked = pokemon.getTag(BattlerTagType.OCTOLOCK); + return !isOctolocked; + } + + lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { + const shouldLapse = lapseType !== BattlerTagLapseType.CUSTOM || super.lapse(pokemon, lapseType); + + if (shouldLapse) { + pokemon.scene.unshiftPhase(new StatChangePhase(pokemon.scene, pokemon.getBattlerIndex(), true, [BattleStat.DEF, BattleStat.SPDEF], -1)); + return true; + } + + return false; + } +} + export class AquaRingTag extends BattlerTag { constructor() { super(BattlerTagType.AQUA_RING, BattlerTagLapseType.TURN_END, 1, Moves.AQUA_RING, undefined); @@ -1662,6 +1688,8 @@ export function getBattlerTag(tagType: BattlerTagType, turnCount: integer, sourc return new DestinyBondTag(sourceMove, sourceId); case BattlerTagType.ICE_FACE: return new IceFaceTag(sourceMove); + case BattlerTagType.OCTOLOCK: + return new OctolockTag(sourceId); case BattlerTagType.NONE: default: return new BattlerTag(tagType, BattlerTagLapseType.CUSTOM, turnCount, sourceMove, sourceId); diff --git a/src/data/move.ts b/src/data/move.ts index 7d798689bea..f244b484e00 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -7796,8 +7796,7 @@ export function initMoves() { .attr(EatBerryAttr) .target(MoveTarget.ALL), new StatusMove(Moves.OCTOLOCK, Type.FIGHTING, 100, 15, -1, 0, 8) - .attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, true, 1) - .partial(), + .attr(AddBattlerTagAttr, BattlerTagType.OCTOLOCK, false, true, 1), new AttackMove(Moves.BOLT_BEAK, Type.ELECTRIC, MoveCategory.PHYSICAL, 85, 100, 10, -1, 0, 8) .attr(FirstAttackDoublePowerAttr), new AttackMove(Moves.FISHIOUS_REND, Type.WATER, MoveCategory.PHYSICAL, 85, 100, 10, -1, 0, 8) diff --git a/src/enums/battler-tag-type.ts b/src/enums/battler-tag-type.ts index 5cdabfe78c2..bb7f7c05b00 100644 --- a/src/enums/battler-tag-type.ts +++ b/src/enums/battler-tag-type.ts @@ -13,6 +13,7 @@ export enum BattlerTagType { ENCORE = "ENCORE", HELPING_HAND = "HELPING_HAND", INGRAIN = "INGRAIN", + OCTOLOCK = "OCTOLOCK", AQUA_RING = "AQUA_RING", DROWSY = "DROWSY", TRAPPED = "TRAPPED", diff --git a/src/test/battlerTags/octolock.test.ts b/src/test/battlerTags/octolock.test.ts new file mode 100644 index 00000000000..4c1c58d4345 --- /dev/null +++ b/src/test/battlerTags/octolock.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from "vitest"; +import Pokemon from "#app/field/pokemon.js"; +import BattleScene from "#app/battle-scene.js"; +import { BattlerTag, BattlerTagLapseType, OctolockTag, TrappedTag } from "#app/data/battler-tags.js"; +import { StatChangePhase } from "#app/phases.js"; +import { BattleStat } from "#app/data/battle-stat.js"; +import { BattlerTagType } from "#app/enums/battler-tag-type.js"; + +jest.mock("#app/battle-scene.js"); + +describe("BattlerTag - OctolockTag", () => { + describe("lapse behavior", () => { + it("unshifts a StatChangePhase with expected stat changes", { timeout: 10000 }, async () => { + const mockPokemon = { + scene: new BattleScene(), + getBattlerIndex: () => 0, + } as Pokemon; + + const subject = new OctolockTag(1); + + vi.spyOn(mockPokemon.scene, "unshiftPhase").mockImplementation(phase => { + expect(phase).toBeInstanceOf(StatChangePhase); + expect((phase as StatChangePhase)["levels"]).toEqual(-1); + expect((phase as StatChangePhase)["stats"]).toEqual([BattleStat.DEF, BattleStat.SPDEF]); + }); + + subject.lapse(mockPokemon, BattlerTagLapseType.TURN_END); + + expect(mockPokemon.scene.unshiftPhase).toBeCalledTimes(1); + }); + }); + + it ("traps its target (extends TrappedTag)", { timeout: 2000 }, async () => { + expect(new OctolockTag(1)).toBeInstanceOf(TrappedTag); + }); + + it("can be added to pokemon who are not octolocked", { timeout: 2000 }, async => { + const mockPokemon = { + getTag: vi.fn().mockReturnValue(undefined) as Pokemon["getTag"], + } as Pokemon; + + const subject = new OctolockTag(1); + + expect(subject.canAdd(mockPokemon)).toBeTruthy(); + + expect(mockPokemon.getTag).toHaveBeenCalledTimes(1); + expect(mockPokemon.getTag).toHaveBeenCalledWith(BattlerTagType.OCTOLOCK); + }); + + it("cannot be added to pokemon who are octolocked", { timeout: 2000 }, async => { + const mockPokemon = { + getTag: vi.fn().mockReturnValue(new BattlerTag(null, null, null, null)) as Pokemon["getTag"], + } as Pokemon; + + const subject = new OctolockTag(1); + + expect(subject.canAdd(mockPokemon)).toBeFalsy(); + + expect(mockPokemon.getTag).toHaveBeenCalledTimes(1); + expect(mockPokemon.getTag).toHaveBeenCalledWith(BattlerTagType.OCTOLOCK); + }); +}); diff --git a/src/test/moves/octolock.test.ts b/src/test/moves/octolock.test.ts new file mode 100644 index 00000000000..b2798fc15d5 --- /dev/null +++ b/src/test/moves/octolock.test.ts @@ -0,0 +1,78 @@ +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, MoveEndPhase, TurnInitPhase } from "#app/phases"; +import {getMovePosition} from "#app/test/utils/gameManagerUtils"; +import {BattleStat} from "#app/data/battle-stat"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import { TrappedTag } from "#app/data/battler-tags.js"; + +describe("Moves - Octolock", () => { + describe("integration tests", () => { + 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.RATTATA); + vi.spyOn(overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SPLASH, Moves.SPLASH, Moves.SPLASH, Moves.SPLASH]); + vi.spyOn(overrides, "OPP_ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.NONE); + + vi.spyOn(overrides, "STARTING_LEVEL_OVERRIDE", "get").mockReturnValue(2000); + vi.spyOn(overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.OCTOLOCK, Moves.SPLASH]); + vi.spyOn(overrides, "ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.NONE); + }); + + it("Reduces DEf and SPDEF by 1 each turn", { timeout: 10000 }, async () => { + await game.startBattle([Species.GRAPPLOCT]); + + const enemyPokemon = game.scene.getEnemyField(); + + // use Octolock and advance to init phase of next turn to check for stat changes + game.doAttack(getMovePosition(game.scene, 0, Moves.OCTOLOCK)); + await game.phaseInterceptor.to(TurnInitPhase); + + expect(enemyPokemon[0].summonData.battleStats[BattleStat.DEF]).toBe(-1); + expect(enemyPokemon[0].summonData.battleStats[BattleStat.SPDEF]).toBe(-1); + + // take a second turn to make sure stat changes occur again + await game.phaseInterceptor.to(CommandPhase); + game.doAttack(getMovePosition(game.scene, 0, Moves.SPLASH)); + + await game.phaseInterceptor.to(TurnInitPhase); + expect(enemyPokemon[0].summonData.battleStats[BattleStat.DEF]).toBe(-2); + expect(enemyPokemon[0].summonData.battleStats[BattleStat.SPDEF]).toBe(-2); + }); + + it("Traps the target pokemon", { timeout: 10000 }, async () => { + await game.startBattle([Species.GRAPPLOCT]); + + const enemyPokemon = game.scene.getEnemyField(); + + // before Octolock - enemy should not be trapped + expect(enemyPokemon[0].findTag(t => t instanceof TrappedTag)).toBeUndefined(); + + game.doAttack(getMovePosition(game.scene, 0, Moves.OCTOLOCK)); + + // after Octolock - enemy should be trapped + await game.phaseInterceptor.to(MoveEndPhase); + expect(enemyPokemon[0].findTag(t => t instanceof TrappedTag)).toBeDefined(); + }); + }); +});