Fixed Encore interactions with Magic Bounce, Magic Coat, etc etc

This commit is contained in:
Bertie690 2025-08-05 20:52:18 -04:00
parent 69157f07bc
commit 664bf555bd
8 changed files with 193 additions and 414 deletions

View File

@ -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<EncoreTag, "tagType" | "moveId">): 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:

View File

@ -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)

View File

@ -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;

View File

@ -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<P extends Phase = Phase>(phaseFilter: (phase: Phase) => phase is P): P | undefined;
findPhase<P extends Phase = Phase>(phaseFilter: (phase: P) => boolean): P | undefined;
findPhase<P extends Phase = Phase>(phaseFilter: (phase: P) => boolean): P | undefined {
return this.phaseQueue.find(phaseFilter) as P | undefined;
}

View File

@ -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;

View File

@ -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();
});
});

View File

@ -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);
});
});

View File

@ -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", () => {