From 3b3b9e39af529fd7660b28afa9fa8e9f799c5f77 Mon Sep 17 00:00:00 2001 From: innerthunder <168692175+innerthunder@users.noreply.github.com> Date: Mon, 17 Jun 2024 15:05:21 -0700 Subject: [PATCH] [Move] Implement Follow Me, Rage Powder, and Spotlight (#1834) * Implement "Center of Attention" battler tag + moves * Powder immunity logic for Rage Powder * Center of Attention unit tests --- src/data/battler-tags.ts | 29 +++++ src/data/move.ts | 10 +- src/enums/battler-tag-type.ts | 1 + src/phases.ts | 8 +- src/test/moves/follow_me.test.ts | 166 +++++++++++++++++++++++++++++ src/test/moves/rage_powder.test.ts | 107 +++++++++++++++++++ src/test/moves/spotlight.test.ts | 111 +++++++++++++++++++ src/test/utils/gameManager.ts | 19 +++- 8 files changed, 446 insertions(+), 5 deletions(-) create mode 100644 src/test/moves/follow_me.test.ts create mode 100644 src/test/moves/rage_powder.test.ts create mode 100644 src/test/moves/spotlight.test.ts diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 7f041e54f52..db02803c5ea 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -1009,6 +1009,33 @@ export class PerishSongTag extends BattlerTag { } } +/** + * Applies the "Center of Attention" volatile status effect, the effect applied by Follow Me, Rage Powder, and Spotlight. + * @see {@link https://bulbapedia.bulbagarden.net/wiki/Center_of_attention | Center of Attention} + */ +export class CenterOfAttentionTag extends BattlerTag { + public powder: boolean; + + constructor(sourceMove: Moves) { + super(BattlerTagType.CENTER_OF_ATTENTION, BattlerTagLapseType.TURN_END, 1, sourceMove); + + this.powder = (this.sourceMove === Moves.RAGE_POWDER); + } + + /** "Center of Attention" can't be added if an ally is already the Center of Attention. */ + canAdd(pokemon: Pokemon): boolean { + const activeTeam = pokemon.isPlayer() ? pokemon.scene.getPlayerField() : pokemon.scene.getEnemyField(); + + return !activeTeam.find(p => p.getTag(BattlerTagType.CENTER_OF_ATTENTION)); + } + + onAdd(pokemon: Pokemon): void { + super.onAdd(pokemon); + + pokemon.scene.queueMessage(getPokemonMessage(pokemon, " became the center\nof attention!")); + } +} + export class AbilityBattlerTag extends BattlerTag { public ability: Abilities; @@ -1494,6 +1521,8 @@ export function getBattlerTag(tagType: BattlerTagType, turnCount: integer, sourc return new SturdyTag(sourceMove); case BattlerTagType.PERISH_SONG: return new PerishSongTag(turnCount); + case BattlerTagType.CENTER_OF_ATTENTION: + return new CenterOfAttentionTag(sourceMove); case BattlerTagType.TRUANT: return new TruantTag(); case BattlerTagType.SLOW_START: diff --git a/src/data/move.ts b/src/data/move.ts index ba18f895ee7..34d64753978 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -284,6 +284,10 @@ export default class Move implements Localizable { * @returns boolean */ isTypeImmune(type: Type): boolean { + if (this.moveTarget === MoveTarget.USER) { + return false; + } + switch (type) { case Type.GRASS: if (this.hasFlag(MoveFlags.POWDER_MOVE)) { @@ -6301,7 +6305,7 @@ export function initMoves() { .attr(MovePowerMultiplierAttr, (user, target, move) => target.status?.effect === StatusEffect.PARALYSIS ? 2 : 1) .attr(HealStatusEffectAttr, true, StatusEffect.PARALYSIS), new SelfStatusMove(Moves.FOLLOW_ME, Type.NORMAL, -1, 20, -1, 2, 3) - .unimplemented(), + .attr(AddBattlerTagAttr, BattlerTagType.CENTER_OF_ATTENTION, true), new StatusMove(Moves.NATURE_POWER, Type.NORMAL, -1, 20, -1, 0, 3) .attr(NaturePowerAttr) .ignoresVirtual(), @@ -6866,7 +6870,7 @@ export function initMoves() { .partial(), new SelfStatusMove(Moves.RAGE_POWDER, Type.BUG, -1, 20, -1, 2, 5) .powderMove() - .unimplemented(), + .attr(AddBattlerTagAttr, BattlerTagType.CENTER_OF_ATTENTION, true), new StatusMove(Moves.TELEKINESIS, Type.PSYCHIC, -1, 15, -1, 0, 5) .condition(failOnGravityCondition) .unimplemented(), @@ -7434,7 +7438,7 @@ export function initMoves() { new AttackMove(Moves.LEAFAGE, Type.GRASS, MoveCategory.PHYSICAL, 40, 100, 40, -1, 0, 7) .makesContact(false), new StatusMove(Moves.SPOTLIGHT, Type.NORMAL, -1, 15, -1, 3, 7) - .unimplemented(), + .attr(AddBattlerTagAttr, BattlerTagType.CENTER_OF_ATTENTION, false), new StatusMove(Moves.TOXIC_THREAD, Type.POISON, 100, 20, 100, 0, 7) .attr(StatusEffectAttr, StatusEffect.POISON) .attr(StatChangeAttr, BattleStat.SPD, -1), diff --git a/src/enums/battler-tag-type.ts b/src/enums/battler-tag-type.ts index 98f01c9c375..4d212a2da12 100644 --- a/src/enums/battler-tag-type.ts +++ b/src/enums/battler-tag-type.ts @@ -58,5 +58,6 @@ export enum BattlerTagType { MAGNET_RISEN = "MAGNET_RISEN", MINIMIZED = "MINIMIZED", DESTINY_BOND = "DESTINY_BOND", + CENTER_OF_ATTENTION = "CENTER_OF_ATTENTION", ICE_FACE = "ICE_FACE" } diff --git a/src/phases.ts b/src/phases.ts index 8f355c477b3..fd4592d3ff9 100644 --- a/src/phases.ts +++ b/src/phases.ts @@ -19,7 +19,7 @@ import { biomeLinks, getBiomeName } from "./data/biomes"; import { ModifierTier } from "./modifier/modifier-tier"; import { FusePokemonModifierType, ModifierPoolType, ModifierType, ModifierTypeFunc, ModifierTypeOption, PokemonModifierType, PokemonMoveModifierType, PokemonPpRestoreModifierType, PokemonPpUpModifierType, RememberMoveModifierType, TmModifierType, getDailyRunStarterModifiers, getEnemyBuffModifierForWave, getModifierType, getPlayerModifierTypeOptions, getPlayerShopModifierTypeOptionsForWave, modifierTypes, regenerateModifierPoolThresholds } from "./modifier/modifier-type"; import SoundFade from "phaser3-rex-plugins/plugins/soundfade"; -import { BattlerTagLapseType, EncoreTag, HideSpriteTag as HiddenTag, ProtectedTag, TrappedTag } from "./data/battler-tags"; +import { BattlerTagLapseType, CenterOfAttentionTag, EncoreTag, HideSpriteTag as HiddenTag, ProtectedTag, TrappedTag } from "./data/battler-tags"; import { getPokemonMessage, getPokemonNameWithAffix } from "./messages"; import { Starter } from "./ui/starter-select-ui-handler"; import { Gender } from "./data/gender"; @@ -2606,6 +2606,12 @@ export class MovePhase extends BattlePhase { if (moveTarget) { const oldTarget = moveTarget.value; this.scene.getField(true).filter(p => p !== this.pokemon).forEach(p => applyAbAttrs(RedirectMoveAbAttr, p, null, this.move.moveId, moveTarget)); + this.pokemon.getOpponents().forEach(p => { + const redirectTag = p.getTag(CenterOfAttentionTag) as CenterOfAttentionTag; + if (redirectTag && (!redirectTag.powder || (!this.pokemon.isOfType(Type.GRASS) && !this.pokemon.hasAbility(Abilities.OVERCOAT)))) { + moveTarget.value = p.getBattlerIndex(); + } + }); //Check if this move is immune to being redirected, and restore its target to the intended target if it is. if ((this.pokemon.hasAbilityWithAttr(BlockRedirectAbAttr) || this.move.getMove().hasAttr(BypassRedirectAttr))) { //If an ability prevented this move from being redirected, display its ability pop up. diff --git a/src/test/moves/follow_me.test.ts b/src/test/moves/follow_me.test.ts new file mode 100644 index 00000000000..54b972e7cc0 --- /dev/null +++ b/src/test/moves/follow_me.test.ts @@ -0,0 +1,166 @@ +import {afterEach, beforeAll, beforeEach, describe, expect, test, 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 {Stat} from "#app/data/pokemon-stat"; +import {getMovePosition} from "#app/test/utils/gameManagerUtils"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import { BattlerIndex } from "#app/battle.js"; +import { Abilities } from "#app/enums/abilities.js"; + +const TIMEOUT = 20 * 1000; + +describe("Moves - Follow Me", () => { + 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, "DOUBLE_BATTLE_OVERRIDE", "get").mockReturnValue(true); + vi.spyOn(overrides, "STARTER_SPECIES_OVERRIDE", "get").mockReturnValue(Species.AMOONGUSS); + vi.spyOn(overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(Species.SNORLAX); + vi.spyOn(overrides, "STARTING_LEVEL_OVERRIDE", "get").mockReturnValue(100); + vi.spyOn(overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([ Moves.FOLLOW_ME, Moves.RAGE_POWDER, Moves.SPOTLIGHT, Moves.QUICK_ATTACK ]); + vi.spyOn(overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.TACKLE,Moves.TACKLE,Moves.TACKLE,Moves.TACKLE]); + }); + + test( + "move should redirect enemy attacks to the user", + async () => { + await game.startBattle([ Species.AMOONGUSS, Species.CHARIZARD ]); + + const playerPokemon = game.scene.getPlayerField(); + expect(playerPokemon.length).toBe(2); + playerPokemon.forEach(p => expect(p).not.toBe(undefined)); + + const enemyPokemon = game.scene.getEnemyField(); + expect(enemyPokemon.length).toBe(2); + enemyPokemon.forEach(p => expect(p).not.toBe(undefined)); + + playerPokemon.forEach(p => p.hp = 200); + + game.doAttack(getMovePosition(game.scene, 0, Moves.FOLLOW_ME)); + await game.phaseInterceptor.to(CommandPhase); + + game.doAttack(getMovePosition(game.scene, 1, Moves.QUICK_ATTACK)); + await game.phaseInterceptor.to(SelectTargetPhase, false); + + game.doSelectTarget(BattlerIndex.ENEMY); + await game.phaseInterceptor.to(TurnEndPhase); + + expect(playerPokemon[0].hp).toBeLessThan(200); + expect(playerPokemon[1].hp).toBe(200); + }, TIMEOUT + ); + + test( + "move should redirect enemy attacks to the first ally that uses it", + async () => { + await game.startBattle([ Species.AMOONGUSS, Species.CHARIZARD ]); + + const playerPokemon = game.scene.getPlayerField(); + expect(playerPokemon.length).toBe(2); + playerPokemon.forEach(p => expect(p).not.toBe(undefined)); + + const enemyPokemon = game.scene.getEnemyField(); + expect(enemyPokemon.length).toBe(2); + enemyPokemon.forEach(p => expect(p).not.toBe(undefined)); + + playerPokemon.forEach(p => p.hp = 200); + + game.doAttack(getMovePosition(game.scene, 0, Moves.FOLLOW_ME)); + await game.phaseInterceptor.to(CommandPhase); + + game.doAttack(getMovePosition(game.scene, 1, Moves.FOLLOW_ME)); + await game.phaseInterceptor.to(TurnEndPhase); + + playerPokemon.sort((a, b) => a.getBattleStat(Stat.SPD) - b.getBattleStat(Stat.SPD)); + + expect(playerPokemon[1].hp).toBeLessThan(200); + expect(playerPokemon[0].hp).toBe(200); + }, TIMEOUT + ); + + test( + "move effect should be bypassed by Stalwart", + async () => { + vi.spyOn(overrides, "ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.STALWART); + vi.spyOn(overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([ Moves.QUICK_ATTACK ]); + vi.spyOn(overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([ Moves.FOLLOW_ME, Moves.FOLLOW_ME, Moves.FOLLOW_ME, Moves.FOLLOW_ME ]); + + await game.startBattle([ Species.AMOONGUSS, Species.CHARIZARD ]); + + const playerPokemon = game.scene.getPlayerField(); + expect(playerPokemon.length).toBe(2); + playerPokemon.forEach(p => expect(p).not.toBe(undefined)); + + const enemyPokemon = game.scene.getEnemyField(); + expect(enemyPokemon.length).toBe(2); + enemyPokemon.forEach(p => expect(p).not.toBe(undefined)); + + enemyPokemon.forEach(p => p.hp = 200); + + game.doAttack(getMovePosition(game.scene, 0, Moves.QUICK_ATTACK)); + await game.phaseInterceptor.to(SelectTargetPhase, false); + game.doSelectTarget(BattlerIndex.ENEMY); + await game.phaseInterceptor.to(CommandPhase); + + game.doAttack(getMovePosition(game.scene, 1, Moves.QUICK_ATTACK)); + await game.phaseInterceptor.to(SelectTargetPhase, false); + game.doSelectTarget(BattlerIndex.ENEMY_2); + await game.phaseInterceptor.to(TurnEndPhase); + + // If redirection was bypassed, both enemies should be damaged + enemyPokemon.forEach(p => expect(p.hp).toBeLessThan(200)); + }, TIMEOUT + ); + + test( + "move effect should be bypassed by Snipe Shot", + async () => { + vi.spyOn(overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([ Moves.SNIPE_SHOT ]); + vi.spyOn(overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([ Moves.FOLLOW_ME, Moves.FOLLOW_ME, Moves.FOLLOW_ME, Moves.FOLLOW_ME ]); + + await game.startBattle([ Species.AMOONGUSS, Species.CHARIZARD ]); + + const playerPokemon = game.scene.getPlayerField(); + expect(playerPokemon.length).toBe(2); + playerPokemon.forEach(p => expect(p).not.toBe(undefined)); + + const enemyPokemon = game.scene.getEnemyField(); + expect(enemyPokemon.length).toBe(2); + enemyPokemon.forEach(p => expect(p).not.toBe(undefined)); + + enemyPokemon.forEach(p => p.hp = 200); + + game.doAttack(getMovePosition(game.scene, 0, Moves.SNIPE_SHOT)); + await game.phaseInterceptor.to(SelectTargetPhase, false); + game.doSelectTarget(BattlerIndex.ENEMY); + await game.phaseInterceptor.to(CommandPhase); + + game.doAttack(getMovePosition(game.scene, 1, Moves.SNIPE_SHOT)); + await game.phaseInterceptor.to(SelectTargetPhase, false); + game.doSelectTarget(BattlerIndex.ENEMY_2); + await game.phaseInterceptor.to(TurnEndPhase); + + // If redirection was bypassed, both enemies should be damaged + enemyPokemon.forEach(p => expect(p.hp).toBeLessThan(200)); + }, TIMEOUT + ); +}); diff --git a/src/test/moves/rage_powder.test.ts b/src/test/moves/rage_powder.test.ts new file mode 100644 index 00000000000..6a204877150 --- /dev/null +++ b/src/test/moves/rage_powder.test.ts @@ -0,0 +1,107 @@ +import {afterEach, beforeAll, beforeEach, describe, expect, test, 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 { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import { BattlerIndex } from "#app/battle.js"; + +const TIMEOUT = 20 * 1000; + +describe("Moves - Rage Powder", () => { + 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, "DOUBLE_BATTLE_OVERRIDE", "get").mockReturnValue(true); + vi.spyOn(overrides, "STARTER_SPECIES_OVERRIDE", "get").mockReturnValue(Species.AMOONGUSS); + vi.spyOn(overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(Species.SNORLAX); + vi.spyOn(overrides, "STARTING_LEVEL_OVERRIDE", "get").mockReturnValue(100); + vi.spyOn(overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([ Moves.FOLLOW_ME, Moves.RAGE_POWDER, Moves.SPOTLIGHT, Moves.QUICK_ATTACK ]); + vi.spyOn(overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.TACKLE,Moves.TACKLE,Moves.TACKLE,Moves.TACKLE]); + }); + + test( + "move effect should be bypassed by Grass type", + async () => { + vi.spyOn(overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([ Moves.RAGE_POWDER, Moves.RAGE_POWDER, Moves.RAGE_POWDER, Moves.RAGE_POWDER ]); + + await game.startBattle([ Species.AMOONGUSS, Species.VENUSAUR ]); + + const playerPokemon = game.scene.getPlayerField(); + expect(playerPokemon.length).toBe(2); + playerPokemon.forEach(p => expect(p).not.toBe(undefined)); + + const enemyPokemon = game.scene.getEnemyField(); + expect(enemyPokemon.length).toBe(2); + enemyPokemon.forEach(p => expect(p).not.toBe(undefined)); + + enemyPokemon.forEach(p => p.hp = 200); + + game.doAttack(getMovePosition(game.scene, 0, Moves.QUICK_ATTACK)); + await game.phaseInterceptor.to(SelectTargetPhase, false); + game.doSelectTarget(BattlerIndex.ENEMY); + await game.phaseInterceptor.to(CommandPhase); + + game.doAttack(getMovePosition(game.scene, 1, Moves.QUICK_ATTACK)); + await game.phaseInterceptor.to(SelectTargetPhase, false); + game.doSelectTarget(BattlerIndex.ENEMY_2); + await game.phaseInterceptor.to(TurnEndPhase); + + // If redirection was bypassed, both enemies should be damaged + enemyPokemon.forEach(p => expect(p.hp).toBeLessThan(200)); + }, TIMEOUT + ); + + test( + "move effect should be bypassed by Overcoat", + async () => { + vi.spyOn(overrides, "ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.OVERCOAT); + vi.spyOn(overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([ Moves.RAGE_POWDER, Moves.RAGE_POWDER, Moves.RAGE_POWDER, Moves.RAGE_POWDER ]); + + // Test with two non-Grass type player Pokemon + await game.startBattle([ Species.BLASTOISE, Species.CHARIZARD ]); + + const playerPokemon = game.scene.getPlayerField(); + expect(playerPokemon.length).toBe(2); + playerPokemon.forEach(p => expect(p).not.toBe(undefined)); + + const enemyPokemon = game.scene.getEnemyField(); + expect(enemyPokemon.length).toBe(2); + enemyPokemon.forEach(p => expect(p).not.toBe(undefined)); + + enemyPokemon.forEach(p => p.hp = 200); + + game.doAttack(getMovePosition(game.scene, 0, Moves.QUICK_ATTACK)); + await game.phaseInterceptor.to(SelectTargetPhase, false); + game.doSelectTarget(BattlerIndex.ENEMY); + await game.phaseInterceptor.to(CommandPhase); + + game.doAttack(getMovePosition(game.scene, 1, Moves.QUICK_ATTACK)); + await game.phaseInterceptor.to(SelectTargetPhase, false); + game.doSelectTarget(BattlerIndex.ENEMY_2); + await game.phaseInterceptor.to(TurnEndPhase); + + // If redirection was bypassed, both enemies should be damaged + enemyPokemon.forEach(p => expect(p.hp).toBeLessThan(200)); + }, TIMEOUT + ); +}); diff --git a/src/test/moves/spotlight.test.ts b/src/test/moves/spotlight.test.ts new file mode 100644 index 00000000000..188207b713c --- /dev/null +++ b/src/test/moves/spotlight.test.ts @@ -0,0 +1,111 @@ +import {afterEach, beforeAll, beforeEach, describe, expect, test, 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 {Stat} from "#app/data/pokemon-stat"; +import {getMovePosition} from "#app/test/utils/gameManagerUtils"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import { BattlerIndex } from "#app/battle.js"; + +const TIMEOUT = 20 * 1000; + +describe("Moves - Spotlight", () => { + 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, "DOUBLE_BATTLE_OVERRIDE", "get").mockReturnValue(true); + vi.spyOn(overrides, "STARTER_SPECIES_OVERRIDE", "get").mockReturnValue(Species.AMOONGUSS); + vi.spyOn(overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(Species.SNORLAX); + vi.spyOn(overrides, "STARTING_LEVEL_OVERRIDE", "get").mockReturnValue(100); + vi.spyOn(overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([ Moves.FOLLOW_ME, Moves.RAGE_POWDER, Moves.SPOTLIGHT, Moves.QUICK_ATTACK ]); + vi.spyOn(overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.TACKLE,Moves.TACKLE,Moves.TACKLE,Moves.TACKLE]); + }); + + test( + "move should redirect attacks to the target", + async () => { + await game.startBattle([ Species.AMOONGUSS, Species.CHARIZARD ]); + + const playerPokemon = game.scene.getPlayerField(); + expect(playerPokemon.length).toBe(2); + playerPokemon.forEach(p => expect(p).not.toBe(undefined)); + + const enemyPokemon = game.scene.getEnemyField(); + expect(enemyPokemon.length).toBe(2); + enemyPokemon.forEach(p => expect(p).not.toBe(undefined)); + + enemyPokemon.forEach(p => p.hp = 200); + + game.doAttack(getMovePosition(game.scene, 0, Moves.SPOTLIGHT)); + await game.phaseInterceptor.to(SelectTargetPhase, false); + game.doSelectTarget(BattlerIndex.ENEMY); + await game.phaseInterceptor.to(CommandPhase); + + game.doAttack(getMovePosition(game.scene, 1, Moves.QUICK_ATTACK)); + await game.phaseInterceptor.to(SelectTargetPhase, false); + game.doSelectTarget(BattlerIndex.ENEMY_2); + await game.phaseInterceptor.to(TurnEndPhase); + + expect(enemyPokemon[0].hp).toBeLessThan(200); + expect(enemyPokemon[1].hp).toBe(200); + }, TIMEOUT + ); + + test( + "move should cause other redirection moves to fail", + async () => { + vi.spyOn(overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([ Moves.FOLLOW_ME, Moves.FOLLOW_ME, Moves.FOLLOW_ME, Moves.FOLLOW_ME ]); + + await game.startBattle([ Species.AMOONGUSS, Species.CHARIZARD ]); + + const playerPokemon = game.scene.getPlayerField(); + expect(playerPokemon.length).toBe(2); + playerPokemon.forEach(p => expect(p).not.toBe(undefined)); + + const enemyPokemon = game.scene.getEnemyField(); + expect(enemyPokemon.length).toBe(2); + enemyPokemon.forEach(p => expect(p).not.toBe(undefined)); + + enemyPokemon.forEach(p => p.hp = 200); + + /** + * Spotlight will target the slower enemy. In this situation without Spotlight being used, + * the faster enemy would normally end up with the Center of Attention tag. + */ + enemyPokemon.sort((a, b) => b.getBattleStat(Stat.SPD) - a.getBattleStat(Stat.SPD)); + const spotTarget = enemyPokemon[1].getBattlerIndex(); + const attackTarget = enemyPokemon[0].getBattlerIndex(); + + game.doAttack(getMovePosition(game.scene, 0, Moves.SPOTLIGHT)); + await game.phaseInterceptor.to(SelectTargetPhase, false); + game.doSelectTarget(spotTarget); + await game.phaseInterceptor.to(CommandPhase); + + game.doAttack(getMovePosition(game.scene, 1, Moves.QUICK_ATTACK)); + await game.phaseInterceptor.to(SelectTargetPhase, false); + game.doSelectTarget(attackTarget); + await game.phaseInterceptor.to(TurnEndPhase); + + expect(enemyPokemon[1].hp).toBeLessThan(200); + expect(enemyPokemon[0].hp).toBe(200); + }, TIMEOUT + ); +}); diff --git a/src/test/utils/gameManager.ts b/src/test/utils/gameManager.ts index e0585395ff9..84d39024dc9 100644 --- a/src/test/utils/gameManager.ts +++ b/src/test/utils/gameManager.ts @@ -5,9 +5,11 @@ import { CommandPhase, EncounterPhase, FaintPhase, - LoginPhase, NewBattlePhase, + LoginPhase, + NewBattlePhase, SelectStarterPhase, TitlePhase, TurnInitPhase, + TurnStartPhase, } from "#app/phases"; import BattleScene from "#app/battle-scene.js"; import PhaseInterceptor from "#app/test/utils/phaseInterceptor"; @@ -29,6 +31,8 @@ import { GameDataType } from "#enums/game-data-type"; import { PlayerGender } from "#enums/player-gender"; import { Species } from "#enums/species"; import { Button } from "#enums/buttons"; +import { BattlerIndex } from "#app/battle.js"; +import TargetSelectUiHandler from "#app/ui/target-select-ui-handler.js"; /** * Class to manage the game state and transitions between phases. @@ -168,6 +172,19 @@ export default class GameManager { }); } + /** + * Emulate a player's target selection after an attack is chosen, + * usually called after {@linkcode doAttack} in a double battle. + * @param {BattlerIndex} targetIndex the index of the attack target + */ + doSelectTarget(targetIndex: BattlerIndex) { + this.onNextPrompt("SelectTargetPhase", Mode.TARGET_SELECT, () => { + const handler = this.scene.ui.getHandler() as TargetSelectUiHandler; + handler.setCursor(targetIndex); + handler.processInput(Button.ACTION); + }, () => this.isCurrentPhase(CommandPhase) || this.isCurrentPhase(TurnStartPhase)); + } + /** Faint all opponents currently on the field */ async doKillOpponents() { await this.killPokemon(this.scene.currentBattle.enemyParty[0]);