diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 455beec6901..da6b58cb7fc 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -28,6 +28,8 @@ import type { Pokemon } from "#field/pokemon"; import { applyMoveAttrs } from "#moves/apply-attrs"; import { invalidEncoreMoves } from "#moves/invalid-moves"; import type { Move } from "#moves/move"; +import { getMoveTargets } from "#moves/move-utils"; +import { PokemonMove } from "#moves/pokemon-move"; import type { MoveEffectPhase } from "#phases/move-effect-phase"; import type { MovePhase } from "#phases/move-phase"; import type { StatStageChangeCallback } from "#phases/stat-stage-change-phase"; @@ -1242,13 +1244,7 @@ export class EncoreTag extends MoveRestrictionBattlerTag { public moveId: MoveId; constructor(sourceId: number) { - super( - BattlerTagType.ENCORE, - [BattlerTagLapseType.CUSTOM, BattlerTagLapseType.AFTER_MOVE], - 3, - MoveId.ENCORE, - sourceId, - ); + super(BattlerTagType.ENCORE, BattlerTagLapseType.TURN_END, 3, MoveId.ENCORE, sourceId); } public override loadTag(source: BaseBattlerTag & Pick): void { @@ -1278,33 +1274,50 @@ export class EncoreTag extends MoveRestrictionBattlerTag { }), ); - const movePhase = globalScene.phaseManager.findPhase(m => m.is("MovePhase") && m.pokemon === pokemon); - if (movePhase) { - const movesetMove = pokemon.getMoveset().find(m => m.moveId === this.moveId); - if (movesetMove) { - const lastMove = pokemon.getLastXMoves(1)[0]; - globalScene.phaseManager.tryReplacePhase( - m => m.is("MovePhase") && m.pokemon === pokemon, - globalScene.phaseManager.create( - "MovePhase", - pokemon, - lastMove.targets ?? [], - movesetMove, - MoveUseMode.NORMAL, - ), - ); - } + // If the target has not moved yet, + // replace their upcoming move with the encored move against randomized targets + const movePhase = globalScene.phaseManager.findPhase( + (m): m is MovePhase => m.is("MovePhase") && m.pokemon === pokemon, + ); + if (!movePhase) { + return; } + + // Use the prior move in the moveset. If it isn't there (presumably due to move forgetting), + // just make a new one for time being as the tag will be removed on turn end. + // TODO: Investigate this... + const movesetMove = pokemon.getMoveset().find(m => m.moveId === this.moveId) ?? new PokemonMove(this.moveId); + + const moveTargets = getMoveTargets(pokemon, movePhase.move.moveId); + // Spread moves and ones with only 1 valid target will use their normal targeting. + // If not, target a random enemy in our target list + const targets = + moveTargets.multiple || moveTargets.targets.length === 1 + ? moveTargets.targets + : [moveTargets.targets[pokemon.randBattleSeedInt(moveTargets.targets.length)]]; + + globalScene.phaseManager.tryReplacePhase( + m => m.is("MovePhase") && m.pokemon === pokemon, + globalScene.phaseManager.create( + "MovePhase", + pokemon, + targets, + movesetMove, + movePhase.useMode, + movePhase.isForcedLast(), + ), + ); } /** - * If the encored move has run out of PP, Encore ends early. Otherwise, Encore lapses based on the AFTER_MOVE battler tag lapse type. + * If the encored move has run out of PP, Encore ends early. + * Otherwise, Encore's duration reduces at the end of the turn. * @returns `true` to persist | `false` to end and be removed */ override lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { - if (lapseType === BattlerTagLapseType.CUSTOM) { - const encoredMove = pokemon.getMoveset().find(m => m.moveId === this.moveId); - return !isNullOrUndefined(encoredMove) && encoredMove.getPpRatio() > 0; + const encoredMove = pokemon.getMoveset().find(m => m.moveId === this.moveId); + if (isNullOrUndefined(encoredMove) || encoredMove.getPpRatio() <= 0) { + return false; } return super.lapse(pokemon, lapseType); } @@ -1489,12 +1502,8 @@ export class MinimizeTag extends SerializableBattlerTag { export class DrowsyTag extends SerializableBattlerTag { public override readonly tagType = BattlerTagType.DROWSY; - constructor() { - super(BattlerTagType.DROWSY, BattlerTagLapseType.TURN_END, 2, MoveId.YAWN); - } - - canAdd(pokemon: Pokemon): boolean { - return globalScene.arena.terrain?.terrainType !== TerrainType.ELECTRIC || !pokemon.isGrounded(); + constructor(sourceId: number) { + super(BattlerTagType.DROWSY, BattlerTagLapseType.TURN_END, 2, MoveId.YAWN, sourceId); } onAdd(pokemon: Pokemon): void { @@ -1509,6 +1518,7 @@ export class DrowsyTag extends SerializableBattlerTag { lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { if (!super.lapse(pokemon, lapseType)) { + // TODO: Safeguard should not prevent yawn from setting sleep after tag use pokemon.trySetStatus(StatusEffect.SLEEP, true); return false; } @@ -3675,7 +3685,7 @@ export function getBattlerTag( case BattlerTagType.AQUA_RING: return new AquaRingTag(); case BattlerTagType.DROWSY: - return new DrowsyTag(); + return new DrowsyTag(sourceId); case BattlerTagType.TRAPPED: return new TrappedTag(tagType, BattlerTagLapseType.CUSTOM, turnCount, sourceMove, sourceId); case BattlerTagType.NO_RETREAT: diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 0dfbc78d7ae..1a9d479f63f 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -11,6 +11,7 @@ import { WeakenMoveTypeTag } from "#data/arena-tag"; import { MoveChargeAnim } from "#data/battle-anims"; import { CommandedTag, + DrowsyTag, EncoreTag, GulpMissileTag, HelpingHandTag, @@ -5689,6 +5690,31 @@ export class AddBattlerTagAttr extends MoveEffectAttr { } } +/** + * Attribute to implement {@linkcode MoveId.YAWN}. + * Yawn adds a BattlerTag to its target that puts them to sleep at the end + * of the next turn, retaining many of the same checks as normal status setting moves. + */ +export class YawnAttr extends AddBattlerTagAttr { + constructor() { + super(BattlerTagType.DROWSY, false, true) + } + + getCondition(): MoveConditionFunc { + return (user, target, move) => { + if (!super.getCondition()!(user, target, move)) { + return false; + } + + // Statused opponents or ones with safeguard active use a generic failure message + if (target.status || target.isSafeguarded(user)) { + return false; + } + + + } +} + /** * Adds a {@link https://bulbapedia.bulbagarden.net/wiki/Seeding | Seeding} effect to the target * as seen with Leech Seed and Sappy Seed. @@ -5916,8 +5942,8 @@ export class ProtectAttr extends AddBattlerTagAttr { for (const turnMove of user.getLastXMoves(-1).slice()) { if ( // Quick & Wide guard increment the Protect counter without using it for fail chance - !(allMoves[turnMove.move].hasAttr("ProtectAttr") || - [MoveId.QUICK_GUARD, MoveId.WIDE_GUARD].includes(turnMove.move)) || + !(allMoves[turnMove.move].hasAttr("ProtectAttr") || + [MoveId.QUICK_GUARD, MoveId.WIDE_GUARD].includes(turnMove.move)) || turnMove.result !== MoveResult.SUCCESS ) { break; @@ -9377,9 +9403,9 @@ export function initMoves() { new AttackMove(MoveId.BRICK_BREAK, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 75, 100, 15, -1, 0, 3) .attr(RemoveScreensAttr), new StatusMove(MoveId.YAWN, PokemonType.NORMAL, -1, 10, -1, 0, 3) - .attr(AddBattlerTagAttr, BattlerTagType.DROWSY, false, true) - .condition((user, target, move) => !target.status && !target.isSafeguarded(user)) - .reflectable(), + .attr(YawnAttr) + .reflectable() + .edgeCase(), // Should not be blocked by safeguard on turn of use new AttackMove(MoveId.KNOCK_OFF, PokemonType.DARK, MoveCategory.PHYSICAL, 65, 100, 20, -1, 0, 3) .attr(MovePowerMultiplierAttr, (user, target, move) => target.getHeldItems().filter(i => i.isTransferable).length > 0 ? 1.5 : 1) .attr(RemoveHeldItemAttr, false) diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index d4f332d887c..807725c69dc 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -4429,14 +4429,19 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { /** * Return this Pokemon's move history. - * Entries are sorted in order of OLDEST to NEWEST - * @returns An array of {@linkcode TurnMove}, as described above. + * Entries are sorted in order of OLDEST to NEWEST. + * @returns An array of {@linkcode TurnMove}s, as described above. * @see {@linkcode getLastXMoves} */ public getMoveHistory(): TurnMove[] { return this.summonData.moveHistory; } + /** + * Add a move to the end of this {@linkcode Pokemon}'s move history, + * used to record its most recently executed actions. + * @param turnMove - The {@linkcode TurnMove} to add + */ public pushMoveHistory(turnMove: TurnMove): void { if (!this.isOnField()) { return; diff --git a/src/phase-manager.ts b/src/phase-manager.ts index aa01a0ffc10..f5ac0922111 100644 --- a/src/phase-manager.ts +++ b/src/phase-manager.ts @@ -414,6 +414,8 @@ export class PhaseManager { * @param phaseFilter filter function to use to find the wanted phase * @returns the found phase or undefined if none found */ + findPhase

(phaseFilter: (phase: Phase) => phase is P): P | undefined; + findPhase

(phaseFilter: (phase: P) => boolean): P | undefined; findPhase

(phaseFilter: (phase: P) => boolean): P | undefined { return this.phaseQueue.find(phaseFilter) as P | undefined; } diff --git a/src/phases/command-phase.ts b/src/phases/command-phase.ts index 016d4ff5d3b..e163dd26e40 100644 --- a/src/phases/command-phase.ts +++ b/src/phases/command-phase.ts @@ -175,11 +175,6 @@ export class CommandPhase extends FieldPhase { this.checkCommander(); - const playerPokemon = this.getPokemon(); - - // Note: It is OK to call this if the target is not under the effect of encore; it will simply do nothing. - playerPokemon.lapseTag(BattlerTagType.ENCORE); - if (globalScene.currentBattle.turnCommands[this.fieldIndex]?.skip) { this.end(); return; diff --git a/test/abilities/magic-bounce.test.ts b/test/abilities/magic-bounce.test.ts deleted file mode 100644 index 92d150e16b1..00000000000 --- a/test/abilities/magic-bounce.test.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { allAbilities, allMoves } from "#data/data-lists"; -import { AbilityId } from "#enums/ability-id"; -import { ArenaTagSide } from "#enums/arena-tag-side"; -import { ArenaTagType } from "#enums/arena-tag-type"; -import { BattlerIndex } from "#enums/battler-index"; -import { BattlerTagType } from "#enums/battler-tag-type"; -import { MoveId } from "#enums/move-id"; -import { SpeciesId } from "#enums/species-id"; -import { Stat } from "#enums/stat"; -import { StatusEffect } from "#enums/status-effect"; -import { GameManager } from "#test/test-utils/game-manager"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; - -describe("Abilities - Magic Bounce", () => { - 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 - .ability(AbilityId.BALL_FETCH) - .battleStyle("single") - .moveset([MoveId.GROWL, MoveId.SPLASH]) - .criticalHits(false) - .enemySpecies(SpeciesId.MAGIKARP) - .enemyAbility(AbilityId.MAGIC_BOUNCE) - .enemyMoveset(MoveId.SPLASH); - }); - - it("should reflect basic status moves", async () => { - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - game.move.use(MoveId.GROWL); - await game.phaseInterceptor.to("BerryPhase"); - expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(-1); - }); - - it("should not bounce moves while the target is in the semi-invulnerable state", async () => { - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - game.move.use(MoveId.GROWL); - await game.move.forceEnemyMove(MoveId.FLY); - await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); - await game.phaseInterceptor.to("BerryPhase"); - - expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(0); - }); - - it("should individually bounce back multi-target moves", async () => { - game.override.battleStyle("double"); - await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]); - - game.move.use(MoveId.GROWL, 0); - game.move.use(MoveId.SPLASH, 1); - await game.phaseInterceptor.to("BerryPhase"); - - const user = game.scene.getPlayerField()[0]; - expect(user.getStatStage(Stat.ATK)).toBe(-2); - }); - - it("should still bounce back a move that would otherwise fail", async () => { - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - game.field.getEnemyPokemon().setStatStage(Stat.ATK, -6); - - game.move.use(MoveId.GROWL); - await game.phaseInterceptor.to("BerryPhase"); - - expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(-1); - }); - - it("should not bounce back a move that was just bounced", async () => { - game.override.ability(AbilityId.MAGIC_BOUNCE); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - game.move.select(MoveId.GROWL); - await game.phaseInterceptor.to("BerryPhase"); - - expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(-1); - }); - - it("should receive the stat change after reflecting a move back to a mirror armor user", async () => { - game.override.ability(AbilityId.MIRROR_ARMOR); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - game.move.select(MoveId.GROWL); - await game.phaseInterceptor.to("BerryPhase"); - - expect(game.field.getEnemyPokemon().getStatStage(Stat.ATK)).toBe(-1); - }); - - it("should not bounce back a move from a mold breaker user", async () => { - game.override.ability(AbilityId.MOLD_BREAKER); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - game.move.use(MoveId.GROWL); - await game.phaseInterceptor.to("BerryPhase"); - - expect(game.field.getEnemyPokemon().getStatStage(Stat.ATK)).toBe(-1); - }); - - it("should bounce back a spread status move against both pokemon", async () => { - game.override.battleStyle("double").enemyMoveset([MoveId.SPLASH]); - await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]); - - game.move.use(MoveId.GROWL, 0); - game.move.use(MoveId.SPLASH, 1); - - await game.phaseInterceptor.to("BerryPhase"); - expect(game.scene.getPlayerField().every(p => p.getStatStage(Stat.ATK) === -2)).toBeTruthy(); - }); - - it("should only bounce spikes back once in doubles when both targets have magic bounce", async () => { - game.override.battleStyle("double").moveset([MoveId.SPIKES]); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - game.move.select(MoveId.SPIKES); - await game.phaseInterceptor.to("BerryPhase"); - - expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER)!["layers"]).toBe(1); - expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY)).toBeUndefined(); - }); - - it("should bounce spikes even when the target is protected", async () => { - game.override.moveset([MoveId.SPIKES]).enemyMoveset([MoveId.PROTECT]); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - game.move.select(MoveId.SPIKES); - await game.phaseInterceptor.to("BerryPhase"); - expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER)!["layers"]).toBe(1); - }); - - it("should not bounce spikes when the target is in the semi-invulnerable state", async () => { - game.override.moveset([MoveId.SPIKES]).enemyMoveset([MoveId.FLY]); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - game.move.select(MoveId.SPIKES); - await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); - await game.phaseInterceptor.to("BerryPhase"); - expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY)!["layers"]).toBe(1); - }); - - it("should not bounce back curse", async () => { - game.override.moveset([MoveId.CURSE]); - await game.classicMode.startBattle([SpeciesId.GASTLY]); - - game.move.select(MoveId.CURSE); - await game.phaseInterceptor.to("BerryPhase"); - - expect(game.field.getEnemyPokemon().getTag(BattlerTagType.CURSED)).toBeDefined(); - }); - - // TODO: enable when Magic Bounce is fixed to properly reset the hit count - it("should not cause encore to be interrupted after bouncing", async () => { - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const playerPokemon = game.field.getPlayerPokemon(); - const enemyPokemon = game.field.getEnemyPokemon(); - - // Give the player MOLD_BREAKER for this turn to bypass Magic Bounce. - const playerAbilitySpy = game.field.mockAbility(playerPokemon, AbilityId.MOLD_BREAKER); - - // turn 1 - game.move.use(MoveId.ENCORE); - await game.move.forceEnemyMove(MoveId.TACKLE); - await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); - await game.toNextTurn(); - - expect(enemyPokemon.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(MoveId.TACKLE); - - // turn 2 - playerAbilitySpy.mockRestore(); - - game.move.use(MoveId.GROWL); - await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); - await game.toEndOfTurn(); - - expect(enemyPokemon.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(MoveId.TACKLE); - expect(enemyPokemon.getLastXMoves()[0].move).toBe(MoveId.TACKLE); - }); - - // TODO: encore is failing if the last move was virtual. - it("should not cause the bounced move to count for encore", async () => { - game.override - .moveset([MoveId.SPLASH, MoveId.GROWL, MoveId.ENCORE]) - .enemyMoveset([MoveId.GROWL, MoveId.TACKLE]) - .enemyAbility(AbilityId.MAGIC_BOUNCE); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const playerPokemon = game.field.getPlayerPokemon(); - const enemyPokemon = game.field.getEnemyPokemon(); - - // turn 1 - game.move.use(MoveId.GROWL); - await game.move.forceEnemyMove(MoveId.TACKLE); - await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); - await game.toNextTurn(); - - // Give the player MOLD_BREAKER for this turn to bypass Magic Bounce. - vi.spyOn(playerPokemon, "getAbility").mockReturnValue(allAbilities[AbilityId.MOLD_BREAKER]); - - // turn 2 - game.move.use(MoveId.ENCORE); - await game.move.forceEnemyMove(MoveId.TACKLE); - await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); - await game.toEndOfTurn(); - expect(enemyPokemon.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(MoveId.TACKLE); - expect(enemyPokemon.getLastXMoves()[0].move).toBe(MoveId.TACKLE); - }); - - // TODO: Move to a stomping tantrum test file - it("should cause stomping tantrum to double in power when the last move was bounced", async () => { - game.override.battleStyle("single").moveset([MoveId.STOMPING_TANTRUM, MoveId.CHARM]); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const stomping_tantrum = allMoves[MoveId.STOMPING_TANTRUM]; - vi.spyOn(stomping_tantrum, "calculateBattlePower"); - - game.move.select(MoveId.CHARM); - await game.toNextTurn(); - - game.move.select(MoveId.STOMPING_TANTRUM); - await game.phaseInterceptor.to("BerryPhase"); - expect(stomping_tantrum.calculateBattlePower).toHaveReturnedWith(150); - }); - - // TODO: stomping tantrum should consider moves that were bounced - it("should boost enemy's stomping tantrum after failed bounce", async () => { - game.override.enemyMoveset([MoveId.STOMPING_TANTRUM, MoveId.SPLASH, MoveId.CHARM]); - await game.classicMode.startBattle([SpeciesId.BULBASAUR]); - - const stomping_tantrum = allMoves[MoveId.STOMPING_TANTRUM]; - const enemy = game.field.getEnemyPokemon(); - vi.spyOn(stomping_tantrum, "calculateBattlePower"); - - // Spore gets reflected back onto us - game.move.select(MoveId.SPORE); - await game.move.selectEnemyMove(MoveId.CHARM); - await game.toNextTurn(); - expect(enemy.getLastXMoves(1)[0].result).toBe("success"); - - game.move.select(MoveId.SPORE); - await game.move.selectEnemyMove(MoveId.STOMPING_TANTRUM); - await game.toNextTurn(); - expect(stomping_tantrum.calculateBattlePower).toHaveReturnedWith(150); - }); - - it("should respect immunities when bouncing a move", async () => { - vi.spyOn(allMoves[MoveId.THUNDER_WAVE], "accuracy", "get").mockReturnValue(100); - game.override.moveset([MoveId.THUNDER_WAVE, MoveId.GROWL]).ability(AbilityId.SOUNDPROOF); - await game.classicMode.startBattle([SpeciesId.PHANPY]); - - // Turn 1 - thunder wave immunity test - game.move.select(MoveId.THUNDER_WAVE); - await game.phaseInterceptor.to("BerryPhase"); - expect(game.field.getPlayerPokemon().status).toBeUndefined(); - - // Turn 2 - soundproof immunity test - game.move.select(MoveId.GROWL); - await game.phaseInterceptor.to("BerryPhase"); - expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(0); - }); - - it("should bounce back a move before the accuracy check", async () => { - game.override.moveset([MoveId.SPORE]); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const attacker = game.field.getPlayerPokemon(); - - vi.spyOn(attacker, "getAccuracyMultiplier").mockReturnValue(0.0); - game.move.select(MoveId.SPORE); - await game.phaseInterceptor.to("BerryPhase"); - expect(game.field.getPlayerPokemon().status?.effect).toBe(StatusEffect.SLEEP); - }); - - it("should take the accuracy of the magic bounce user into account", async () => { - game.override.moveset([MoveId.SPORE]); - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - const opponent = game.field.getEnemyPokemon(); - - vi.spyOn(opponent, "getAccuracyMultiplier").mockReturnValue(0); - game.move.select(MoveId.SPORE); - await game.phaseInterceptor.to("BerryPhase"); - expect(game.field.getPlayerPokemon().status).toBeUndefined(); - }); -}); diff --git a/test/moves/encore.test.ts b/test/moves/encore.test.ts index 0840346c3b1..d9b60357a4a 100644 --- a/test/moves/encore.test.ts +++ b/test/moves/encore.test.ts @@ -3,7 +3,9 @@ import { BattlerIndex } from "#enums/battler-index"; import { BattlerTagType } from "#enums/battler-tag-type"; import { MoveId } from "#enums/move-id"; import { MoveResult } from "#enums/move-result"; +import { MoveUseMode } from "#enums/move-use-mode"; import { SpeciesId } from "#enums/species-id"; +import { invalidEncoreMoves } from "#moves/invalid-moves"; import { GameManager } from "#test/test-utils/game-manager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; @@ -31,7 +33,6 @@ describe("Moves - Encore", () => { .criticalHits(false) .enemySpecies(SpeciesId.MAGIKARP) .enemyAbility(AbilityId.BALL_FETCH) - .enemyMoveset([MoveId.SPLASH, MoveId.TACKLE]) .startingLevel(100) .enemyLevel(100); }); @@ -41,74 +42,93 @@ describe("Moves - Encore", () => { const enemyPokemon = game.field.getEnemyPokemon(); - game.move.select(MoveId.ENCORE); - await game.move.selectEnemyMove(MoveId.SPLASH); - + game.move.use(MoveId.ENCORE); + await game.move.forceEnemyMove(MoveId.SPLASH); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); await game.toNextTurn(); - expect(enemyPokemon.getTag(BattlerTagType.ENCORE)).toBeDefined(); - game.move.select(MoveId.SPLASH); - // The enemy AI would normally be inclined to use Tackle, but should be - // forced into using Splash. - await game.phaseInterceptor.to("BerryPhase", false); - - expect(enemyPokemon.getLastXMoves().every(turnMove => turnMove.move === MoveId.SPLASH)).toBeTruthy(); + expect(enemyPokemon).toHaveBattlerTag(BattlerTagType.ENCORE); + expect(enemyPokemon.isMoveRestricted(MoveId.TACKLE)).toBe(true); + expect(enemyPokemon.isMoveRestricted(MoveId.SPLASH)).toBe(false); }); - describe("should fail against the following moves:", () => { - it.each([ - { moveId: MoveId.TRANSFORM, name: "Transform", delay: false }, - { moveId: MoveId.MIMIC, name: "Mimic", delay: true }, - { moveId: MoveId.SKETCH, name: "Sketch", delay: true }, - { moveId: MoveId.ENCORE, name: "Encore", delay: false }, - { moveId: MoveId.STRUGGLE, name: "Struggle", delay: false }, - ])("$name", async ({ moveId, delay }) => { - game.override.enemyMoveset(moveId); + it("should override any pending move phases with the Encored move, while still consuming PP", async () => { + await game.classicMode.startBattle([SpeciesId.SNORLAX]); - await game.classicMode.startBattle([SpeciesId.SNORLAX]); + // Fake enemy having used tackle the turn prior + const enemy = game.field.getEnemyPokemon(); + enemy.pushMoveHistory({ move: MoveId.TACKLE, targets: [BattlerIndex.PLAYER], useMode: MoveUseMode.NORMAL }); - const playerPokemon = game.field.getPlayerPokemon(); - const enemyPokemon = game.field.getEnemyPokemon(); + game.move.use(MoveId.ENCORE); + await game.move.forceEnemyMove(MoveId.SPLASH); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.toNextTurn(); - if (delay) { - game.move.select(MoveId.SPLASH); - await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); - await game.toNextTurn(); - } - - game.move.select(MoveId.ENCORE); - - const turnOrder = delay ? [BattlerIndex.PLAYER, BattlerIndex.ENEMY] : [BattlerIndex.ENEMY, BattlerIndex.PLAYER]; - await game.setTurnOrder(turnOrder); - - await game.phaseInterceptor.to("BerryPhase", false); - expect(playerPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.FAIL); - expect(enemyPokemon.getTag(BattlerTagType.ENCORE)).toBeUndefined(); - }); + expect(enemy).toHaveUsedMove({ move: MoveId.TACKLE, targets: [BattlerIndex.PLAYER], useMode: MoveUseMode.NORMAL }); + expect(enemy.isMoveRestricted(MoveId.TACKLE)).toBe(true); + expect(enemy.isMoveRestricted(MoveId.SPLASH)).toBe(false); }); - it("Pokemon under both Encore and Torment should alternate between Struggle and restricted move", async () => { - const turnOrder = [BattlerIndex.ENEMY, BattlerIndex.PLAYER]; - game.override.moveset([MoveId.ENCORE, MoveId.TORMENT, MoveId.SPLASH]); + it.todo("should end at turn end if the user forgets the Encored move"); + + // TODO: Make test (presumably involving Spite) + it.todo("should end immediately if the move runs out of PP"); + + const invalidMoves = [...invalidEncoreMoves].map(m => ({ + name: MoveId[m], + move: m, + })); + it.each(invalidMoves)("should fail if the target's last move is $name", async ({ move }) => { + await game.classicMode.startBattle([SpeciesId.SNORLAX]); + + const player = game.field.getPlayerPokemon(); + const enemy = game.field.getEnemyPokemon(); + enemy.pushMoveHistory({ move, targets: [BattlerIndex.PLAYER], useMode: MoveUseMode.NORMAL }); + + game.move.use(MoveId.ENCORE); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.toEndOfTurn(); + + expect(player.getLastXMoves(1)[0].result).toBe(MoveResult.FAIL); + expect(enemy).not.toHaveBattlerTag(BattlerTagType.ENCORE); + }); + + it("should fail if the target has not made a move", async () => { + await game.classicMode.startBattle([SpeciesId.SNORLAX]); + + const player = game.field.getPlayerPokemon(); + const enemy = game.field.getEnemyPokemon(); + + game.move.use(MoveId.ENCORE); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.toEndOfTurn(); + + expect(player.getLastXMoves(1)[0].result).toBe(MoveResult.FAIL); + expect(enemy).not.toHaveBattlerTag(BattlerTagType.ENCORE); + }); + + it("should force a Tormented target to alternate between Struggle and the Encored move", async () => { await game.classicMode.startBattle([SpeciesId.FEEBAS]); - const enemyPokemon = game.field.getEnemyPokemon(); - game.move.select(MoveId.ENCORE); - await game.setTurnOrder(turnOrder); - await game.phaseInterceptor.to("BerryPhase"); - expect(enemyPokemon.getTag(BattlerTagType.ENCORE)).toBeDefined(); + const enemy = game.field.getEnemyPokemon(); + game.move.use(MoveId.ENCORE); + await game.move.forceEnemyMove(MoveId.TACKLE); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.toNextTurn(); - game.move.select(MoveId.TORMENT); - await game.setTurnOrder(turnOrder); - await game.phaseInterceptor.to("BerryPhase"); - expect(enemyPokemon.getTag(BattlerTagType.TORMENT)).toBeDefined(); + expect(enemy).toHaveBattlerTag(BattlerTagType.ENCORE); + + game.move.use(MoveId.TORMENT); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.toNextTurn(); - game.move.select(MoveId.SPLASH); - await game.setTurnOrder(turnOrder); - await game.phaseInterceptor.to("BerryPhase"); - const lastMove = enemyPokemon.getLastXMoves()[0]; - expect(lastMove?.move).toBe(MoveId.STRUGGLE); + + expect(enemy).toHaveBattlerTag(BattlerTagType.ENCORE); + expect(enemy).toHaveBattlerTag(BattlerTagType.TORMENT); + + game.move.use(MoveId.SPLASH); + await game.toEndOfTurn(); + + expect(enemy).toHaveUsedMove(MoveId.STRUGGLE); }); }); diff --git a/test/moves/magic-coat-magic-bounce.test.ts b/test/moves/magic-coat-magic-bounce.test.ts index 88a3ade6b13..848a1f7f7c6 100644 --- a/test/moves/magic-coat-magic-bounce.test.ts +++ b/test/moves/magic-coat-magic-bounce.test.ts @@ -10,6 +10,7 @@ import { MoveUseMode } from "#enums/move-use-mode"; import { SpeciesId } from "#enums/species-id"; import { Stat } from "#enums/stat"; import { StatusEffect } from "#enums/status-effect"; +import type { EnemyPokemon } from "#field/pokemon"; import { GameManager } from "#test/test-utils/game-manager"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -93,23 +94,25 @@ describe("Moves - Reflecting effects", () => { expect(game.field.getEnemyPokemon()).toHaveStatStage(Stat.ATK, 0); }); - it("should receive the stat change after reflecting a move back to a mirror armor user", async () => { + it("should take precedence over Mirror Armor", async () => { game.override.enemyAbility(AbilityId.MIRROR_ARMOR); await game.classicMode.startBattle([SpeciesId.MAGIKARP]); game.move.use(MoveId.GROWL); await game.toEndOfTurn(); - expect(game.field.getEnemyPokemon()).toHaveStatStage(Stat.ATK, -1); + const enemy = game.field.getPlayerPokemon(); + expect(enemy).toHaveStatStage(Stat.ATK, -1); + expect(enemy).not.toHaveAbilityApplied(AbilityId.MIRROR_ARMOR); }); - it("should not bounce back curse", async () => { + it("should not bounce back non-reflectable effects", async () => { await game.classicMode.startBattle([SpeciesId.GASTLY]); game.move.use(MoveId.CURSE); await game.toEndOfTurn(); - expect(game.field.getEnemyPokemon().getTag(BattlerTagType.CURSED)).toBeDefined(); + expect(game.field.getEnemyPokemon()).toHaveBattlerTag(BattlerTagType.CURSED); }); it("should not cause encore to be interrupted after bouncing", async () => { @@ -141,23 +144,25 @@ describe("Moves - Reflecting effects", () => { }); it("should not cause the bounced move to count for encore", async () => { - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + game.override.battleStyle("double").enemyAbility(AbilityId.MAGIC_BOUNCE); + await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.ABRA]); - const enemyPokemon = game.field.getEnemyPokemon(); + // Fake abra having mold breaker and the enemy having used Tackle + const [abra, enemy1, enemy2] = game.scene.getField(); + game.field.mockAbility(abra, AbilityId.MOLD_BREAKER); + enemy1.pushMoveHistory({ move: MoveId.TACKLE, targets: [BattlerIndex.PLAYER], useMode: MoveUseMode.NORMAL }); - // turn 1 - game.move.use(MoveId.GROWL); - await game.move.forceEnemyMove(MoveId.MAGIC_COAT); + // turn 1: Magikarp uses growl as Abra attempts to encore + game.move.use(MoveId.GROWL, BattlerIndex.PLAYER); + game.move.use(MoveId.ENCORE, BattlerIndex.PLAYER_2); + await game.move.forceEnemyMove(MoveId.SPLASH); + await game.killPokemon(enemy2 as EnemyPokemon); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY]); await game.toNextTurn(); - // turn 2 - game.move.use(MoveId.ENCORE); - await game.move.forceEnemyMove(MoveId.TACKLE); - await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); - await game.toEndOfTurn(); - - expect(enemyPokemon.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(MoveId.TACKLE); - expect(enemyPokemon.getLastXMoves()[0].move).toBe(MoveId.TACKLE); + // Encore locked into Tackle, replacing the enemy's Growl with another Tackle + expect(enemy1.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(MoveId.TACKLE); + expect(enemy1).toHaveUsedMove({ move: MoveId.TACKLE, useMode: MoveUseMode.NORMAL }); }); it("should boost stomping tantrum after a failed bounce", async () => { @@ -171,6 +176,7 @@ describe("Moves - Reflecting effects", () => { game.move.use(MoveId.YAWN); await game.move.forceEnemyMove(MoveId.MAGIC_COAT); await game.toNextTurn(); + expect(enemy).toHaveUsedMove({ move: MoveId.YAWN, result: MoveResult.FAIL, useMode: MoveUseMode.REFLECTED }); game.move.use(MoveId.SPLASH); @@ -303,6 +309,17 @@ describe("Moves - Reflecting effects", () => { expect(karp1).toHaveStatStage(Stat.ATK, -1); expect(karp2).toHaveStatStage(Stat.ATK, -1); }); + + it("should bounce spikes even when the target is protected", async () => { + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + + game.move.use(MoveId.SPIKES); + await game.move.forceEnemyMove(MoveId.PROTECT); + await game.toEndOfTurn(); + + // TODO: Replace this with `expect(game).toHaveArenaTag({tagType: ArenaTagType.SPIKES, side: ArenaTagSide.PLAYER, layers: 1}) + expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER)!["layers"]).toBe(1); + }); }); describe("Magic Coat", () => {