This commit is contained in:
Bertie690 2025-05-11 08:08:04 -04:00
parent da6443562b
commit 4bac0ed3ec
13 changed files with 88 additions and 44 deletions

View File

@ -4454,10 +4454,10 @@ export class PostDancingMoveAbAttr extends PostMoveUsedAbAttr {
// TODO: fix in main dancer PR (currently keeping this purely semantic rather than actually fixing bug)
if (move.getMove() instanceof AttackMove || move.getMove() instanceof StatusMove) {
const target = this.getTarget(dancer, source, targets);
globalScene.unshiftPhase(new MovePhase(dancer, target, move, MoveUseType.FOLLOW_UP));
globalScene.unshiftPhase(new MovePhase(dancer, target, move, MoveUseType.INDIRECT));
} else if (move.getMove() instanceof SelfStatusMove) {
// If the move is a SelfStatusMove (ie. Swords Dance) the Dancer should replicate it on itself
globalScene.unshiftPhase(new MovePhase(dancer, [ dancer.getBattlerIndex() ], move, MoveUseType.FOLLOW_UP))
globalScene.unshiftPhase(new MovePhase(dancer, [ dancer.getBattlerIndex() ], move, MoveUseType.INDIRECT))
}
}
}

View File

@ -307,7 +307,8 @@ export class DisabledTag extends MoveRestrictionBattlerTag {
* and showing a message.
*/
override onAdd(pokemon: Pokemon): void {
// Disable fails against struggle or an empty move history
// Disable fails against struggle or an empty move history, but we still need to check for
// Cursed Body
const move = pokemon.getLastNonVirtualMove();
if (isNullOrUndefined(move) || move.move === Moves.STRUGGLE) {
return;

View File

@ -5419,9 +5419,13 @@ export class FrenzyAttr extends MoveEffectAttr {
// TODO: Disable if used via dancer
// TODO: Add support for moves that don't add the frenzy tag (Uproar, Rollout, etc.)
if (!user.getTag(BattlerTagType.FRENZY) && !user.getMoveQueue().length) {
// If frenzy is not active, add a tag and push 1-2 extra turns of attacks to the user's move queue.
// Otherwise, tick down the existing tag.
if (!user.getTag(BattlerTagType.FRENZY) && user.getMoveQueue().length === 0) {
const turnCount = user.randSeedIntRange(1, 2);
new Array(turnCount).fill(null).map(() => user.getMoveQueue().push({ move: move.id, targets: [ target.getBattlerIndex() ], ignorePP: true }));
for (let x = 0; x < turnCount; x++) {
user.pushMoveQueue({move: move.id, targets: [target.getBattlerIndex()], useType: MoveUseType.IGNORE_PP})
}
user.addTag(BattlerTagType.FRENZY, turnCount, move.id, user.id);
} else {
applyMoveAttrs(AddBattlerTagAttr, user, target, move, args);
@ -6746,7 +6750,9 @@ class CallMoveAttr extends OverrideMoveEffectAttr {
// If not, target the Mirror Move recipient or else a random enemy in our target list
const targets = moveTargets.multiple || moveTargets.targets.length === 1
? moveTargets.targets
: [ this.hasTarget ? target.getBattlerIndex() : moveTargets.targets[user.randSeedInt(moveTargets.targets.length)] ]; // account for Mirror Move having a target already
: [this.hasTarget
? target.getBattlerIndex()
: moveTargets.targets[user.randSeedInt(moveTargets.targets.length)]];
globalScene.unshiftPhase(new LoadMoveAnimPhase(move.id));
globalScene.unshiftPhase(new MovePhase(user, targets, new PokemonMove(move.id), MoveUseType.FOLLOW_UP));
return true;
@ -7083,6 +7089,7 @@ export class RepeatMoveAttr extends MoveEffectAttr {
Moves.PETAL_DANCE,
Moves.THRASH,
Moves.ICE_BALL,
Moves.UPROAR,
// Multi-turn Moves
Moves.BIDE,
Moves.SHELL_TRAP,
@ -7120,8 +7127,17 @@ export class RepeatMoveAttr extends MoveEffectAttr {
Moves.SOLAR_BEAM,
Moves.SOLAR_BLADE,
Moves.METEOR_BEAM,
// Other moves
// Copying/Move-Calling moves
Moves.ASSIST,
Moves.COPYCAT,
Moves.ME_FIRST,
Moves.METRONOME,
Moves.MIRROR_MOVE,
Moves.NATURE_POWER,
Moves.SLEEP_TALK,
Moves.SNATCH,
Moves.INSTRUCT,
// Misc moves
Moves.KINGS_SHIELD,
Moves.SKETCH,
Moves.TRANSFORM,
@ -7132,7 +7148,8 @@ export class RepeatMoveAttr extends MoveEffectAttr {
if (!lastMove?.move // no move to instruct
|| !movesetMove // called move not in target's moveset (forgetting the move, etc.)
|| !movesetMove.isUsable(target) // Move unusable due to PP shortage or similar
|| movesetMove.ppUsed === movesetMove.getMovePp() // move out of pp
|| allMoves[lastMove.move].isChargingMove() // called move is a charging/recharging move
|| uninstructableMoves.includes(lastMove.move)) { // called move is in the banlist
return false;
}

View File

@ -15,6 +15,7 @@ export enum MoveUseType {
NORMAL,
/**
* This move was called by an effect that ignores PP, such as a consecutively executed move.
* Identical to {@linkcode MoveUseType.NORMAL}, except the move **does not consume PP** on use
* and **will not fail** if none is left before its execution.
* PP can still be reduced by other effects (such as Spite or Eerie Spell).
@ -22,7 +23,7 @@ export enum MoveUseType {
IGNORE_PP,
/**
* This move was called indirectly by another effect other than Instruct or the user's previous move.
* This move was called indirectly by an out-of-turn effect other than Instruct or the user's previous move.
* Currently only used by {@linkcode PostDancingMoveAbAttr | Dancer}.
* Indirect moves ignore PP checks similar to {@linkcode MoveUseType.IGNORE_PP}, but additionally **cannot be copied**
@ -56,3 +57,9 @@ export enum MoveUseType {
*/
REFLECTED
}
/**
* Comment block to prevent auto-import removal.
* {@linkcode BattlerTagLapseType}
* {@linkcode PostDancingMoveAbAttr}
*/

View File

@ -86,6 +86,8 @@ export class MoveChargePhase extends PokemonPhase {
result: MoveResult.OTHER,
useType: this.useType,
});
super.end();
}
public getUserPokemon(): Pokemon {

View File

@ -150,7 +150,8 @@ export class MovePhase extends BattlePhase {
console.log(Moves[this.move.moveId], MoveUseType[this.useType]);
// Check if move is unusable (e.g. because it's out of PP due to a mid-turn Spite).
// Check if move is unusable (e.g. running out of PP due to a mid-turn Spite
// or the user no longer being on field).
if (!this.canMove(true)) {
if (this.pokemon.isActive(true)) {
this.fail();
@ -241,6 +242,7 @@ export class MovePhase extends BattlePhase {
return;
}
this.pokemon.status.incrementTurn();
/** Whether to prevent us from using the move */
let activated = false;
/** Whether to cure the status */

View File

@ -41,6 +41,7 @@ describe("Abilities - Early Bird", () => {
game.move.select(Moves.BELLY_DRUM);
await game.toNextTurn();
game.move.select(Moves.REST);
await game.toNextTurn();

View File

@ -75,7 +75,7 @@ describe("Moves - After You", () => {
game.move.select(Moves.SPLASH, BattlerIndex.PLAYER);
game.move.select(Moves.OUTRAGE, BattlerIndex.PLAYER_2);
await game.toNextTurn();
await game.phaseInterceptor.to("TurnEndPhase");
const outrageMove = rattata.getMoveset().find(m => m.moveId === Moves.OUTRAGE);
expect(outrageMove?.ppUsed).toBe(1);

View File

@ -58,9 +58,11 @@ describe("Moves - Dig", () => {
});
it("should deduct PP only on the 2nd turn of the move", async () => {
game.override.moveset([]);
await game.classicMode.startBattle([Species.MAGIKARP]);
const playerPokemon = game.scene.getPlayerPokemon()!;
game.move.changeMoveset(playerPokemon, Moves.DIG);
game.move.select(Moves.DIG);
await game.phaseInterceptor.to("TurnEndPhase");

View File

@ -48,7 +48,7 @@ describe("Moves - Disable", () => {
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
expect(enemyMon.getLastXMoves(-1)).toHaveLength(1);
expect(enemyMon.getLastXMoves(-1)).toHaveLength(2);
expect(enemyMon.isMoveRestricted(Moves.SPLASH)).toBe(true);
expect(enemyMon.isMoveRestricted(Moves.GROWL)).toBe(false);
});
@ -87,7 +87,7 @@ describe("Moves - Disable", () => {
expect(enemyHistory.map(m => m.move)).toEqual([Moves.STRUGGLE, Moves.SPLASH]);
});
it("cannot disable STRUGGLE", async () => {
it("should fail if it would otherwise disable struggle", async () => {
game.override.enemyMoveset([Moves.STRUGGLE]);
await game.classicMode.startBattle([Species.PIKACHU]);
@ -120,28 +120,29 @@ describe("Moves - Disable", () => {
const enemyHistory = enemyMon.getLastXMoves(-1);
expect(enemyHistory).toHaveLength(2);
expect(enemyHistory[0]).toMatchObject({
move: Moves.SPLASH,
result: MoveResult.FAIL,
});
expect(enemyHistory[0].result).toBe(MoveResult.FAIL);
});
it.each([
{ name: "Nature Power", moveId: Moves.NATURE_POWER },
{ name: "Mirror Move", moveId: Moves.MIRROR_MOVE },
{ name: "Copycat", moveId: Moves.COPYCAT },
{ name: "Copycat", moveId: Moves.COPYCAT },
{ name: "Metronome", moveId: Moves.METRONOME },
])("should ignore virtual moves called by $name", async ({ moveId }) => {
game.override.enemyMoveset(moveId);
await game.classicMode.startBattle([Species.PIKACHU]);
const enemyMon = game.scene.getEnemyPokemon()!;
const playerMon = game.scene.getEnemyPokemon()!;
playerMon.pushMoveHistory({ move: Moves.SPLASH, targets: [BattlerIndex.ENEMY], useType: MoveUseType.NORMAL });
game.scene.currentBattle.lastMove = Moves.SPLASH;
game.move.select(Moves.DISABLE);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
const enemyMon = game.scene.getEnemyPokemon()!;
expect.soft(enemyMon.isMoveRestricted(moveId), `calling move ${Moves[moveId]} was not disabled`).toBe(true);
expect.soft(enemyMon.getLastXMoves(-1)).toHaveLength(2);
const calledMove = enemyMon.getLastXMoves()[0].move;
expect(
enemyMon.isMoveRestricted(calledMove),
@ -154,7 +155,6 @@ describe("Moves - Disable", () => {
.enemyAbility(Abilities.DANCER)
.moveset([Moves.DISABLE, Moves.SWORDS_DANCE])
.enemyMoveset([Moves.SPLASH, Moves.SWORDS_DANCE]);
await game.classicMode.startBattle([Species.PIKACHU]);
game.move.select(Moves.SWORDS_DANCE);
@ -173,6 +173,6 @@ describe("Moves - Disable", () => {
expect.soft(shuckle.isMoveRestricted(Moves.SPLASH)).toBe(true);
expect.soft(shuckle.isMoveRestricted(Moves.SWORDS_DANCE)).toBe(false);
expect(shuckle.getLastXMoves()[0]).toMatchObject({ move: Moves.SWORDS_DANCE, result: MoveResult.SUCCESS });
expect(shuckle.getStatStage(Stat.ATK)).toBe(2);
expect(shuckle.getStatStage(Stat.ATK)).toBe(4);
});
});

View File

@ -1,4 +1,5 @@
import { BattlerIndex } from "#app/battle";
import { RandomMoveAttr } from "#app/data/moves/move";
import type Pokemon from "#app/field/pokemon";
import { MoveResult } from "#app/field/pokemon";
import type { MovePhase } from "#app/phases/move-phase";
@ -8,7 +9,7 @@ import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
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("Moves - Instruct", () => {
let phaserGame: Phaser.Game;
@ -139,6 +140,22 @@ describe("Moves - Instruct", () => {
expect(game.scene.getPlayerPokemon()!.turnData.attacksReceived.length).toBe(3);
});
it("should fail on metronomed moves, even if also in moveset", async () => {
game.override.moveset(Moves.INSTRUCT);
vi.spyOn(RandomMoveAttr.prototype, "getMoveOverride").mockReturnValue(Moves.ABSORB);
await game.classicMode.startBattle([Species.AMOONGUSS]);
const enemy = game.scene.getEnemyPokemon()!;
game.move.changeMoveset(enemy, [Moves.METRONOME, Moves.ABSORB]);
game.move.select(Moves.INSTRUCT);
await game.forceEnemyMove(Moves.METRONOME);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getPlayerPokemon()!.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
});
it("should respect enemy's status condition", async () => {
game.override.moveset([Moves.INSTRUCT, Moves.THUNDER_WAVE]).enemyMoveset(Moves.SONIC_BOOM);
await game.classicMode.startBattle([Species.AMOONGUSS]);
@ -249,15 +266,9 @@ describe("Moves - Instruct", () => {
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER_2, BattlerIndex.PLAYER, BattlerIndex.ENEMY_2]);
await game.phaseInterceptor.to("TurnEndPhase", false);
expect(game.scene.getPlayerField()[0].getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
const enemyMove = game.scene.getEnemyField()[0]!.getLastXMoves()[0];
expect(enemyMove.result).toBe(MoveResult.FAIL);
expect(
game.scene
.getEnemyField()[0]
.getMoveset()
.find(m => m?.moveId === Moves.SONIC_BOOM)?.ppUsed,
).toBe(1);
expect(game.scene.getPlayerPokemon()!.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(enemy1.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(enemy1.getMoveset().find(m => m.moveId === Moves.SONIC_BOOM)?.ppUsed).toBe(1);
});
it("should not repeat enemy's move through protect", async () => {

View File

@ -72,7 +72,7 @@ describe("Moves - Quash", () => {
game.move.select(Moves.SPLASH, BattlerIndex.PLAYER);
game.move.select(Moves.OUTRAGE, BattlerIndex.PLAYER_2);
await game.toNextTurn();
await game.phaseInterceptor.to("TurnEndPhase");
const outrageMove = rattata.getMoveset().find(m => m.moveId === Moves.OUTRAGE);
expect(outrageMove?.ppUsed).toBe(1);

View File

@ -427,6 +427,7 @@ export default class GameManager {
* If all active player Pokemon are using a rampaging, charging, recharging or other move that
* disables user input, this **will not resolve** until at least 1 player pokemon becomes actionable.
*/
// TODO: Make this not need to be called twice in doubles tests
async toNextTurn() {
await this.phaseInterceptor.to(CommandPhase);
console.log("==================[New Turn]==================");
@ -519,9 +520,9 @@ export default class GameManager {
* @returns A promise that resolves once the fainted pokemon's FaintPhase finishes running.
*/
async killPokemon(pokemon: PlayerPokemon | EnemyPokemon) {
pokemon.hp = 0;
this.scene.unshiftPhase(new FaintPhase(pokemon.getBattlerIndex(), true));
return new Promise<void>(async (resolve, reject) => {
pokemon.hp = 0;
this.scene.pushPhase(new FaintPhase(pokemon.getBattlerIndex(), true));
await this.phaseInterceptor.to(FaintPhase).catch(e => reject(e));
resolve();
});