Fixed various bugs and added tests for previous bugfixes

This commit is contained in:
Bertie690 2025-05-10 14:39:48 -04:00
parent ee1b2176bc
commit c2e7c95620
9 changed files with 131 additions and 47 deletions

View File

@ -311,10 +311,9 @@ export class DisabledTag extends MoveRestrictionBattlerTag {
* and showing a message.
*/
override onAdd(pokemon: Pokemon): void {
// Disable fails against struggle or an empty move history, but we still need the nullish check
// for cursed body
// Disable fails against struggle or an empty move history
const move = pokemon.getLastNonVirtualMove();
if (isNullOrUndefined(move)) {
if (isNullOrUndefined(move) || move.move === Moves.STRUGGLE) {
return;
}
@ -368,7 +367,6 @@ export class DisabledTag extends MoveRestrictionBattlerTag {
/**
* Tag used by Gorilla Tactics to restrict the user to using only one move.
* @extends MoveRestrictionBattlerTag
*/
export class GorillaTacticsTag extends MoveRestrictionBattlerTag {
private moveId = Moves.NONE;
@ -383,27 +381,27 @@ export class GorillaTacticsTag extends MoveRestrictionBattlerTag {
}
/**
* @override
* @param {Pokemon} pokemon the {@linkcode Pokemon} to check if the tag can be added
* @returns `true` if the pokemon has a valid move and no existing {@linkcode GorillaTacticsTag}; `false` otherwise
* Ensures that move history exists on {@linkcode Pokemon} and has a valid move to lock into.
* @param pokemon - the {@linkcode Pokemon} to add the tag to
* @returns `true` if the tag can be added
*/
override canAdd(pokemon: Pokemon): boolean {
return !isNullOrUndefined(pokemon.getLastNonVirtualMove(true)) && !pokemon.getTag(GorillaTacticsTag);
// Choice items ignore struggle
// TODO: Check if struggle also gets the 50% power boost
const lastSelectedMove = pokemon.getLastNonVirtualMove(false);
return (
(isNullOrUndefined(lastSelectedMove) || lastSelectedMove.move === Moves.STRUGGLE) &&
!pokemon.getTag(GorillaTacticsTag)
);
}
/**
* Ensures that move history exists on {@linkcode Pokemon} and has a valid move.
* If so, sets the {@linkcode moveId} and increases the user's Attack by 50%.
* @override
* @param {Pokemon} pokemon the {@linkcode Pokemon} to add the tag to
* Sets this tag's {@linkcode moveId} and increases the user's Attack by 50%.
* @param pokemon - The {@linkcode Pokemon} to add the tag to
*/
override onAdd(pokemon: Pokemon): void {
const lastValidMove = pokemon.getLastNonVirtualMove(true); // TODO: Check if should work with struggle or not
if (isNullOrUndefined(lastValidMove)) {
return;
}
this.moveId = lastValidMove.move;
super.onAdd(pokemon);
this.moveId = pokemon.getLastNonVirtualMove(false)!.move; // `canAdd` returns false if no move
pokemon.setStat(Stat.ATK, pokemon.getStat(Stat.ATK, false) * 1.5, false);
}
@ -418,11 +416,10 @@ export class GorillaTacticsTag extends MoveRestrictionBattlerTag {
}
/**
*
* @override
* @param {Pokemon} pokemon n/a
* @param {Moves} _move {@linkcode Moves} ID of the move being denied
* @returns {string} text to display when the move is denied
* @param pokemon - The {@linkcode Pokemon} attempting to select a move
* @param _move - Unused
* @returns The text to display when the move is rendered unselectable
*/
override selectionDeniedText(pokemon: Pokemon, _move: Moves): string {
return i18next.t("battle:canOnlyUseMove", {

View File

@ -7895,7 +7895,7 @@ export class ForceLastAttr extends MoveEffectAttr {
override apply(user: Pokemon, target: Pokemon, _move: Move, _args: any[]): boolean {
globalScene.queueMessage(i18next.t("moveTriggers:forceLast", { targetPokemonName: getPokemonNameWithAffix(target) }));
// TODO: Refactor this to be more readable
// TODO: Refactor this to be more readable and less janky
const targetMovePhase = globalScene.findPhase<MovePhase>((phase) => phase.pokemon === target);
if (targetMovePhase && !targetMovePhase.isForcedLast() && globalScene.tryRemovePhase((phase: MovePhase) => phase.pokemon === target)) {
// Finding the phase to insert the move in front of -

View File

@ -5161,7 +5161,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
* - Non-virtual ({@linkcode MoveUseType | useType} < {@linkcode MoveUseType.INDIRECT})
* @param ignoreStruggle - Whether to additionally ignore {@linkcode Moves.STRUGGLE}; default `false`
* @param ignoreFollowUp - Whether to ignore moves with a use type of {@linkcode MoveUseType.FOLLOW_UP}
* (Copycat, Mirror Move, etc.); default `true`
* (Copycat, Mirror Move, etc.); default `true`.
* @returns The last move this Pokemon has used satisfying the aforementioned conditions,
* or `undefined` if no applicable moves have been used since switching in.
*/
@ -7250,12 +7250,13 @@ export class EnemyPokemon extends Pokemon {
const moveIndex = this.getMoveset().findIndex(m => m.moveId === queuedMove.move);
// If the queued move was called indirectly, ignore all PP and usability checks.
// Otherwise, ensure that the move being used is actually usable
// TODO: Virtual moves shouldn't use the move queue
if (
queuedMove.useType >= MoveUseType.INDIRECT ||
(moveIndex > -1 &&
this.getMoveset()[moveIndex].isUsable(
this,
queuedMove.useType >= MoveUseType.IGNORE_PP )
queuedMove.useType >= MoveUseType.IGNORE_PP)
)
) {
return queuedMove;

View File

@ -123,7 +123,7 @@ export class MovePhase extends BattlePhase {
return (
this.pokemon.isActive(true) &&
this.move.isUsable(this.pokemon, this.useType >= MoveUseType.IGNORE_PP, ignoreDisableTags) &&
!!this.targets.length
this.targets.length > 0
);
}

View File

@ -1,11 +1,12 @@
import { BattlerIndex } from "#app/battle";
import { RandomMoveAttr } from "#app/data/moves/move";
import { Moves } from "#app/enums/moves";
import { Species } from "#app/enums/species";
import { Stat } from "#app/enums/stat";
import { Abilities } from "#enums/abilities";
import GameManager from "#test/testUtils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Abilities - Gorilla Tactics", () => {
let phaserGame: Phaser.Game;
@ -25,10 +26,10 @@ describe("Abilities - Gorilla Tactics", () => {
game.override
.battleStyle("single")
.enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset([Moves.SPLASH, Moves.DISABLE])
.enemyMoveset(Moves.SPLASH)
.enemySpecies(Species.MAGIKARP)
.enemyLevel(30)
.moveset([Moves.SPLASH, Moves.TACKLE, Moves.GROWL])
.moveset([Moves.SPLASH, Moves.TACKLE, Moves.GROWL, Moves.METRONOME])
.ability(Abilities.GORILLA_TACTICS);
});
@ -39,8 +40,6 @@ describe("Abilities - Gorilla Tactics", () => {
const initialAtkStat = darmanitan.getStat(Stat.ATK);
game.move.select(Moves.SPLASH);
await game.forceEnemyMove(Moves.SPLASH);
await game.phaseInterceptor.to("TurnEndPhase");
expect(darmanitan.getStat(Stat.ATK, false)).toBeCloseTo(initialAtkStat * 1.5);
@ -50,6 +49,7 @@ describe("Abilities - Gorilla Tactics", () => {
});
it("should struggle if the only usable move is disabled", async () => {
game.override.enemyMoveset([Moves.DISABLE, Moves.SPLASH]);
await game.classicMode.startBattle([Species.GALAR_DARMANITAN]);
const darmanitan = game.scene.getPlayerPokemon()!;
@ -78,4 +78,18 @@ describe("Abilities - Gorilla Tactics", () => {
await game.phaseInterceptor.to("MoveEndPhase");
expect(darmanitan.hp).toBeLessThan(darmanitan.getMaxHp());
});
it("should lock into calling moves, even if also in moveset", async () => {
vi.spyOn(RandomMoveAttr.prototype, "getMoveOverride").mockReturnValue(Moves.TACKLE);
await game.classicMode.startBattle([Species.GALAR_DARMANITAN]);
const darmanitan = game.scene.getPlayerPokemon()!;
game.move.select(Moves.METRONOME);
await game.phaseInterceptor.to("TurnEndPhase");
// Gorilla Tactics should bypass dancer and instruct
expect(darmanitan.isMoveRestricted(Moves.TACKLE)).toBe(true);
expect(darmanitan.isMoveRestricted(Moves.METRONOME)).toBe(false);
});
});

View File

@ -2,6 +2,7 @@ import { BattlerIndex } from "#app/battle";
import { Abilities } from "#app/enums/abilities";
import { MoveResult } from "#app/field/pokemon";
import { MovePhase } from "#app/phases/move-phase";
import { MoveUseType } from "#enums/move-use-type";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import GameManager from "#test/testUtils/gameManager";
@ -60,4 +61,34 @@ describe("Moves - After You", () => {
expect(game.scene.getPlayerField()[1].getLastXMoves(1)[0].result).toBe(MoveResult.FAIL);
});
it("should maintain PP ignore status of rampaging moves", async () => {
game.override.moveset([]);
await game.classicMode.startBattle([Species.ACCELGOR, Species.RATTATA]);
const [accelgor, rattata] = game.scene.getPlayerField();
expect(accelgor).toBeDefined();
expect(rattata).toBeDefined();
game.move.changeMoveset(accelgor, [Moves.SPLASH, Moves.AFTER_YOU]);
game.move.changeMoveset(rattata, Moves.OUTRAGE);
game.move.select(Moves.SPLASH, BattlerIndex.PLAYER);
game.move.select(Moves.OUTRAGE, BattlerIndex.PLAYER_2);
await game.toNextTurn();
const outrageMove = rattata.getMoveset().find(m => m.moveId === Moves.OUTRAGE);
expect(outrageMove?.ppUsed).toBe(1);
game.move.select(Moves.AFTER_YOU, BattlerIndex.PLAYER, BattlerIndex.PLAYER_2);
await game.phaseInterceptor.to("TurnEndPhase");
expect(accelgor.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(outrageMove?.ppUsed).toBe(1);
expect(rattata.getLastXMoves()[0]).toMatchObject({
move: Moves.OUTRAGE,
result: MoveResult.SUCCESS,
useType: MoveUseType.IGNORE_PP,
});
});
});

View File

@ -1,8 +1,9 @@
import { BattlerIndex } from "#app/battle";
import { allMoves, RandomMoveAttr } from "#app/data/moves/move";
import { RandomMoveAttr } from "#app/data/moves/move";
import { Stat } from "#app/enums/stat";
import { MoveResult } from "#app/field/pokemon";
import { Abilities } from "#enums/abilities";
import { MoveUseType } from "#enums/move-use-type";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import GameManager from "#test/testUtils/gameManager";
@ -13,8 +14,6 @@ describe("Moves - Copycat", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
let randomMoveAttr: RandomMoveAttr;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
@ -26,14 +25,12 @@ describe("Moves - Copycat", () => {
});
beforeEach(() => {
randomMoveAttr = allMoves[Moves.METRONOME].getAttrs(RandomMoveAttr)[0];
game = new GameManager(phaserGame);
game.override
.moveset([Moves.COPYCAT, Moves.SPIKY_SHIELD, Moves.SWORDS_DANCE, Moves.SPLASH])
.ability(Abilities.BALL_FETCH)
.battleStyle("single")
.disableCrits()
.starterSpecies(Species.FEEBAS)
.enemySpecies(Species.MAGIKARP)
.enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset(Moves.SPLASH);
@ -41,7 +38,7 @@ describe("Moves - Copycat", () => {
it("should copy the last move successfully executed", async () => {
game.override.enemyMoveset(Moves.SUCKER_PUNCH);
await game.classicMode.startBattle();
await game.classicMode.startBattle([Species.FEEBAS]);
game.move.select(Moves.SWORDS_DANCE);
await game.toNextTurn();
@ -54,7 +51,7 @@ describe("Moves - Copycat", () => {
it("should fail when the last move used is not a valid Copycat move", async () => {
game.override.enemyMoveset(Moves.PROTECT); // Protect is not a valid move for Copycat to copy
await game.classicMode.startBattle();
await game.classicMode.startBattle([Species.FEEBAS]);
game.move.select(Moves.SPIKY_SHIELD); // Spiky Shield is not a valid move for Copycat to copy
await game.toNextTurn();
@ -67,19 +64,25 @@ describe("Moves - Copycat", () => {
it("should copy the called move when the last move successfully calls another", async () => {
game.override.moveset([Moves.SPLASH, Moves.METRONOME]).enemyMoveset(Moves.COPYCAT);
await game.classicMode.startBattle();
vi.spyOn(randomMoveAttr, "getMoveOverride").mockReturnValue(Moves.SWORDS_DANCE);
await game.classicMode.startBattle([Species.DRAMPA]);
vi.spyOn(RandomMoveAttr.prototype, "getMoveOverride").mockReturnValue(Moves.SWORDS_DANCE);
game.move.select(Moves.METRONOME);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); // Player moves first, so enemy can copy Swords Dance
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); // Player moves first so enemy can copy Swords Dance
await game.toNextTurn();
expect(game.scene.getEnemyPokemon()!.getStatStage(Stat.ATK)).toBe(2);
const enemy = game.scene.getEnemyPokemon()!;
expect(enemy.getLastXMoves()[0]).toMatchObject({
move: Moves.SWORDS_DANCE,
result: MoveResult.SUCCESS,
useType: MoveUseType.FOLLOW_UP,
});
expect(enemy.getStatStage(Stat.ATK)).toBe(2);
});
it("should apply secondary effects of a move", async () => {
it("should apply move secondary effects", async () => {
game.override.enemyMoveset(Moves.ACID_SPRAY); // Secondary effect lowers SpDef by 2 stages
await game.classicMode.startBattle();
await game.classicMode.startBattle([Species.FEEBAS]);
game.move.select(Moves.COPYCAT);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);

View File

@ -3,8 +3,10 @@ import { RechargingTag, SemiInvulnerableTag } from "#app/data/battler-tags";
import { allMoves, RandomMoveAttr } from "#app/data/moves/move";
import { Abilities } from "#app/enums/abilities";
import { Stat } from "#app/enums/stat";
import { MoveResult } from "#app/field/pokemon";
import { CommandPhase } from "#app/phases/command-phase";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveUseType } from "#enums/move-use-type";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import GameManager from "#test/testUtils/gameManager";
@ -79,7 +81,7 @@ describe("Moves - Metronome", () => {
expect(player.getTag(RechargingTag)).toBeTruthy();
});
it("should charge when calling charging moves while still maintaining follow-up status", async () => {
it("should charge for charging moves while still maintaining follow-up status", async () => {
game.override.moveset([]).enemyMoveset(Moves.SPITE);
vi.spyOn(randomMoveAttr, "getMoveOverride").mockReturnValue(Moves.SOLAR_BEAM);
await game.classicMode.startBattle([Species.REGIELEKI]);
@ -107,6 +109,11 @@ describe("Moves - Metronome", () => {
const turn2PpUsed = metronomeMove.ppUsed - turn1PpUsed;
expect(turn2PpUsed).toBeGreaterThan(1);
expect(solarBeamMove.ppUsed).toBe(0);
expect(player.getLastXMoves()[0]).toMatchObject({
move: Moves.SOLAR_BEAM,
result: MoveResult.SUCCESS,
useType: MoveUseType.FOLLOW_UP,
});
});
it("should only target ally for Aromatic Mist", async () => {

View File

@ -7,6 +7,7 @@ import { MoveResult } from "#app/field/pokemon";
import GameManager from "#test/testUtils/gameManager";
import Phaser from "phaser";
import { describe, beforeAll, afterEach, beforeEach, it, expect } from "vitest";
import { MoveUseType } from "#enums/move-use-type";
describe("Moves - Quash", () => {
let phaserGame: Phaser.Game;
@ -49,8 +50,8 @@ describe("Moves - Quash", () => {
it("fails if the target has already moved", async () => {
await game.classicMode.startBattle([Species.ACCELGOR, Species.RATTATA]);
game.move.select(Moves.SPLASH, 0);
game.move.select(Moves.QUASH, 1, BattlerIndex.PLAYER);
game.move.select(Moves.SPLASH, BattlerIndex.PLAYER);
game.move.select(Moves.QUASH, BattlerIndex.PLAYER_2, BattlerIndex.PLAYER);
await game.phaseInterceptor.to("MoveEndPhase");
await game.phaseInterceptor.to("MoveEndPhase");
@ -58,6 +59,36 @@ describe("Moves - Quash", () => {
expect(game.scene.getPlayerField()[1].getLastXMoves(1)[0].result).toBe(MoveResult.FAIL);
});
it("should maintain PP ignore status of rampaging moves", async () => {
game.override.moveset([]);
await game.classicMode.startBattle([Species.ACCELGOR, Species.RATTATA]);
const [accelgor, rattata] = game.scene.getPlayerField();
expect(accelgor).toBeDefined();
expect(rattata).toBeDefined();
game.move.changeMoveset(accelgor, [Moves.SPLASH, Moves.QUASH]);
game.move.changeMoveset(rattata, Moves.OUTRAGE);
game.move.select(Moves.SPLASH, BattlerIndex.PLAYER);
game.move.select(Moves.OUTRAGE, BattlerIndex.PLAYER_2);
await game.toNextTurn();
const outrageMove = rattata.getMoveset().find(m => m.moveId === Moves.OUTRAGE);
expect(outrageMove?.ppUsed).toBe(1);
game.move.select(Moves.QUASH, BattlerIndex.PLAYER, BattlerIndex.PLAYER_2);
await game.phaseInterceptor.to("TurnEndPhase");
expect(accelgor.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(outrageMove?.ppUsed).toBe(1);
expect(rattata.getLastXMoves()[0]).toMatchObject({
move: Moves.OUTRAGE,
result: MoveResult.SUCCESS,
useType: MoveUseType.IGNORE_PP,
});
});
it("makes multiple quashed targets move in speed order at the end of the turn", async () => {
game.override.enemySpecies(Species.NINJASK).enemyLevel(100);