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 { applyMoveAttrs } from "#moves/apply-attrs";
import { invalidEncoreMoves } from "#moves/invalid-moves"; import { invalidEncoreMoves } from "#moves/invalid-moves";
import type { Move } from "#moves/move"; 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 { MoveEffectPhase } from "#phases/move-effect-phase";
import type { MovePhase } from "#phases/move-phase"; import type { MovePhase } from "#phases/move-phase";
import type { StatStageChangeCallback } from "#phases/stat-stage-change-phase"; import type { StatStageChangeCallback } from "#phases/stat-stage-change-phase";
@ -1242,13 +1244,7 @@ export class EncoreTag extends MoveRestrictionBattlerTag {
public moveId: MoveId; public moveId: MoveId;
constructor(sourceId: number) { constructor(sourceId: number) {
super( super(BattlerTagType.ENCORE, BattlerTagLapseType.TURN_END, 3, MoveId.ENCORE, sourceId);
BattlerTagType.ENCORE,
[BattlerTagLapseType.CUSTOM, BattlerTagLapseType.AFTER_MOVE],
3,
MoveId.ENCORE,
sourceId,
);
} }
public override loadTag(source: BaseBattlerTag & Pick<EncoreTag, "tagType" | "moveId">): void { 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 the target has not moved yet,
if (movePhase) { // replace their upcoming move with the encored move against randomized targets
const movesetMove = pokemon.getMoveset().find(m => m.moveId === this.moveId); const movePhase = globalScene.phaseManager.findPhase(
if (movesetMove) { (m): m is MovePhase => m.is("MovePhase") && m.pokemon === pokemon,
const lastMove = pokemon.getLastXMoves(1)[0]; );
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( globalScene.phaseManager.tryReplacePhase(
m => m.is("MovePhase") && m.pokemon === pokemon, m => m.is("MovePhase") && m.pokemon === pokemon,
globalScene.phaseManager.create( globalScene.phaseManager.create(
"MovePhase", "MovePhase",
pokemon, pokemon,
lastMove.targets ?? [], targets,
movesetMove, movesetMove,
MoveUseMode.NORMAL, 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 * @returns `true` to persist | `false` to end and be removed
*/ */
override lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { override lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
if (lapseType === BattlerTagLapseType.CUSTOM) {
const encoredMove = pokemon.getMoveset().find(m => m.moveId === this.moveId); const encoredMove = pokemon.getMoveset().find(m => m.moveId === this.moveId);
return !isNullOrUndefined(encoredMove) && encoredMove.getPpRatio() > 0; if (isNullOrUndefined(encoredMove) || encoredMove.getPpRatio() <= 0) {
return false;
} }
return super.lapse(pokemon, lapseType); return super.lapse(pokemon, lapseType);
} }
@ -1489,12 +1502,8 @@ export class MinimizeTag extends SerializableBattlerTag {
export class DrowsyTag extends SerializableBattlerTag { export class DrowsyTag extends SerializableBattlerTag {
public override readonly tagType = BattlerTagType.DROWSY; public override readonly tagType = BattlerTagType.DROWSY;
constructor() { constructor(sourceId: number) {
super(BattlerTagType.DROWSY, BattlerTagLapseType.TURN_END, 2, MoveId.YAWN); super(BattlerTagType.DROWSY, BattlerTagLapseType.TURN_END, 2, MoveId.YAWN, sourceId);
}
canAdd(pokemon: Pokemon): boolean {
return globalScene.arena.terrain?.terrainType !== TerrainType.ELECTRIC || !pokemon.isGrounded();
} }
onAdd(pokemon: Pokemon): void { onAdd(pokemon: Pokemon): void {
@ -1509,6 +1518,7 @@ export class DrowsyTag extends SerializableBattlerTag {
lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
if (!super.lapse(pokemon, lapseType)) { if (!super.lapse(pokemon, lapseType)) {
// TODO: Safeguard should not prevent yawn from setting sleep after tag use
pokemon.trySetStatus(StatusEffect.SLEEP, true); pokemon.trySetStatus(StatusEffect.SLEEP, true);
return false; return false;
} }
@ -3675,7 +3685,7 @@ export function getBattlerTag(
case BattlerTagType.AQUA_RING: case BattlerTagType.AQUA_RING:
return new AquaRingTag(); return new AquaRingTag();
case BattlerTagType.DROWSY: case BattlerTagType.DROWSY:
return new DrowsyTag(); return new DrowsyTag(sourceId);
case BattlerTagType.TRAPPED: case BattlerTagType.TRAPPED:
return new TrappedTag(tagType, BattlerTagLapseType.CUSTOM, turnCount, sourceMove, sourceId); return new TrappedTag(tagType, BattlerTagLapseType.CUSTOM, turnCount, sourceMove, sourceId);
case BattlerTagType.NO_RETREAT: case BattlerTagType.NO_RETREAT:

View File

@ -11,6 +11,7 @@ import { WeakenMoveTypeTag } from "#data/arena-tag";
import { MoveChargeAnim } from "#data/battle-anims"; import { MoveChargeAnim } from "#data/battle-anims";
import { import {
CommandedTag, CommandedTag,
DrowsyTag,
EncoreTag, EncoreTag,
GulpMissileTag, GulpMissileTag,
HelpingHandTag, 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 * Adds a {@link https://bulbapedia.bulbagarden.net/wiki/Seeding | Seeding} effect to the target
* as seen with Leech Seed and Sappy Seed. * as seen with Leech Seed and Sappy Seed.
@ -9377,9 +9403,9 @@ export function initMoves() {
new AttackMove(MoveId.BRICK_BREAK, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 75, 100, 15, -1, 0, 3) new AttackMove(MoveId.BRICK_BREAK, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 75, 100, 15, -1, 0, 3)
.attr(RemoveScreensAttr), .attr(RemoveScreensAttr),
new StatusMove(MoveId.YAWN, PokemonType.NORMAL, -1, 10, -1, 0, 3) new StatusMove(MoveId.YAWN, PokemonType.NORMAL, -1, 10, -1, 0, 3)
.attr(AddBattlerTagAttr, BattlerTagType.DROWSY, false, true) .attr(YawnAttr)
.condition((user, target, move) => !target.status && !target.isSafeguarded(user)) .reflectable()
.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) 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(MovePowerMultiplierAttr, (user, target, move) => target.getHeldItems().filter(i => i.isTransferable).length > 0 ? 1.5 : 1)
.attr(RemoveHeldItemAttr, false) .attr(RemoveHeldItemAttr, false)

View File

@ -4429,14 +4429,19 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
/** /**
* Return this Pokemon's move history. * Return this Pokemon's move history.
* Entries are sorted in order of OLDEST to NEWEST * Entries are sorted in order of OLDEST to NEWEST.
* @returns An array of {@linkcode TurnMove}, as described above. * @returns An array of {@linkcode TurnMove}s, as described above.
* @see {@linkcode getLastXMoves} * @see {@linkcode getLastXMoves}
*/ */
public getMoveHistory(): TurnMove[] { public getMoveHistory(): TurnMove[] {
return this.summonData.moveHistory; 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 { public pushMoveHistory(turnMove: TurnMove): void {
if (!this.isOnField()) { if (!this.isOnField()) {
return; return;

View File

@ -414,6 +414,8 @@ export class PhaseManager {
* @param phaseFilter filter function to use to find the wanted phase * @param phaseFilter filter function to use to find the wanted phase
* @returns the found phase or undefined if none found * @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 { findPhase<P extends Phase = Phase>(phaseFilter: (phase: P) => boolean): P | undefined {
return this.phaseQueue.find(phaseFilter) as P | undefined; return this.phaseQueue.find(phaseFilter) as P | undefined;
} }

View File

@ -175,11 +175,6 @@ export class CommandPhase extends FieldPhase {
this.checkCommander(); 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) { if (globalScene.currentBattle.turnCommands[this.fieldIndex]?.skip) {
this.end(); this.end();
return; 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 { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id"; import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result"; import { MoveResult } from "#enums/move-result";
import { MoveUseMode } from "#enums/move-use-mode";
import { SpeciesId } from "#enums/species-id"; import { SpeciesId } from "#enums/species-id";
import { invalidEncoreMoves } from "#moves/invalid-moves";
import { GameManager } from "#test/test-utils/game-manager"; import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser"; import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
@ -31,7 +33,6 @@ describe("Moves - Encore", () => {
.criticalHits(false) .criticalHits(false)
.enemySpecies(SpeciesId.MAGIKARP) .enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH) .enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset([MoveId.SPLASH, MoveId.TACKLE])
.startingLevel(100) .startingLevel(100)
.enemyLevel(100); .enemyLevel(100);
}); });
@ -41,74 +42,93 @@ describe("Moves - Encore", () => {
const enemyPokemon = game.field.getEnemyPokemon(); const enemyPokemon = game.field.getEnemyPokemon();
game.move.select(MoveId.ENCORE); game.move.use(MoveId.ENCORE);
await game.move.selectEnemyMove(MoveId.SPLASH); await game.move.forceEnemyMove(MoveId.SPLASH);
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();
});
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);
await game.classicMode.startBattle([SpeciesId.SNORLAX]);
const playerPokemon = game.field.getPlayerPokemon();
const enemyPokemon = game.field.getEnemyPokemon();
if (delay) {
game.move.select(MoveId.SPLASH);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn(); await game.toNextTurn();
}
game.move.select(MoveId.ENCORE); expect(enemyPokemon).toHaveBattlerTag(BattlerTagType.ENCORE);
expect(enemyPokemon.isMoveRestricted(MoveId.TACKLE)).toBe(true);
const turnOrder = delay ? [BattlerIndex.PLAYER, BattlerIndex.ENEMY] : [BattlerIndex.ENEMY, BattlerIndex.PLAYER]; expect(enemyPokemon.isMoveRestricted(MoveId.SPLASH)).toBe(false);
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();
});
}); });
it("Pokemon under both Encore and Torment should alternate between Struggle and restricted move", async () => { it("should override any pending move phases with the Encored move, while still consuming PP", async () => {
const turnOrder = [BattlerIndex.ENEMY, BattlerIndex.PLAYER]; await game.classicMode.startBattle([SpeciesId.SNORLAX]);
game.override.moveset([MoveId.ENCORE, MoveId.TORMENT, MoveId.SPLASH]);
// Fake enemy having used tackle the turn prior
const enemy = game.field.getEnemyPokemon();
enemy.pushMoveHistory({ move: MoveId.TACKLE, targets: [BattlerIndex.PLAYER], useMode: MoveUseMode.NORMAL });
game.move.use(MoveId.ENCORE);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn();
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.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]); await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const enemyPokemon = game.field.getEnemyPokemon(); const enemy = game.field.getEnemyPokemon();
game.move.select(MoveId.ENCORE);
await game.setTurnOrder(turnOrder);
await game.phaseInterceptor.to("BerryPhase");
expect(enemyPokemon.getTag(BattlerTagType.ENCORE)).toBeDefined();
game.move.use(MoveId.ENCORE);
await game.move.forceEnemyMove(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn(); 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(); await game.toNextTurn();
game.move.select(MoveId.SPLASH);
await game.setTurnOrder(turnOrder); expect(enemy).toHaveBattlerTag(BattlerTagType.ENCORE);
await game.phaseInterceptor.to("BerryPhase"); expect(enemy).toHaveBattlerTag(BattlerTagType.TORMENT);
const lastMove = enemyPokemon.getLastXMoves()[0];
expect(lastMove?.move).toBe(MoveId.STRUGGLE); 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 { SpeciesId } from "#enums/species-id";
import { Stat } from "#enums/stat"; import { Stat } from "#enums/stat";
import { StatusEffect } from "#enums/status-effect"; import { StatusEffect } from "#enums/status-effect";
import type { EnemyPokemon } from "#field/pokemon";
import { GameManager } from "#test/test-utils/game-manager"; import { GameManager } from "#test/test-utils/game-manager";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; 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); 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); game.override.enemyAbility(AbilityId.MIRROR_ARMOR);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]); await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.use(MoveId.GROWL); game.move.use(MoveId.GROWL);
await game.toEndOfTurn(); 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]); await game.classicMode.startBattle([SpeciesId.GASTLY]);
game.move.use(MoveId.CURSE); game.move.use(MoveId.CURSE);
await game.toEndOfTurn(); 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 () => { 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 () => { 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 // turn 1: Magikarp uses growl as Abra attempts to encore
game.move.use(MoveId.GROWL); game.move.use(MoveId.GROWL, BattlerIndex.PLAYER);
await game.move.forceEnemyMove(MoveId.MAGIC_COAT); 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(); await game.toNextTurn();
// turn 2 // Encore locked into Tackle, replacing the enemy's Growl with another Tackle
game.move.use(MoveId.ENCORE); expect(enemy1.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(MoveId.TACKLE);
await game.move.forceEnemyMove(MoveId.TACKLE); expect(enemy1).toHaveUsedMove({ move: MoveId.TACKLE, useMode: MoveUseMode.NORMAL });
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);
}); });
it("should boost stomping tantrum after a failed bounce", async () => { it("should boost stomping tantrum after a failed bounce", async () => {
@ -171,6 +176,7 @@ describe("Moves - Reflecting effects", () => {
game.move.use(MoveId.YAWN); game.move.use(MoveId.YAWN);
await game.move.forceEnemyMove(MoveId.MAGIC_COAT); await game.move.forceEnemyMove(MoveId.MAGIC_COAT);
await game.toNextTurn(); await game.toNextTurn();
expect(enemy).toHaveUsedMove({ move: MoveId.YAWN, result: MoveResult.FAIL, useMode: MoveUseMode.REFLECTED }); expect(enemy).toHaveUsedMove({ move: MoveId.YAWN, result: MoveResult.FAIL, useMode: MoveUseMode.REFLECTED });
game.move.use(MoveId.SPLASH); game.move.use(MoveId.SPLASH);
@ -303,6 +309,17 @@ describe("Moves - Reflecting effects", () => {
expect(karp1).toHaveStatStage(Stat.ATK, -1); expect(karp1).toHaveStatStage(Stat.ATK, -1);
expect(karp2).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", () => { describe("Magic Coat", () => {