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) // 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) { if (move.getMove() instanceof AttackMove || move.getMove() instanceof StatusMove) {
const target = this.getTarget(dancer, source, targets); 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) { } else if (move.getMove() instanceof SelfStatusMove) {
// If the move is a SelfStatusMove (ie. Swords Dance) the Dancer should replicate it on itself // 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. * and showing a message.
*/ */
override onAdd(pokemon: Pokemon): void { 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(); const move = pokemon.getLastNonVirtualMove();
if (isNullOrUndefined(move) || move.move === Moves.STRUGGLE) { if (isNullOrUndefined(move) || move.move === Moves.STRUGGLE) {
return; return;

View File

@ -5419,9 +5419,13 @@ export class FrenzyAttr extends MoveEffectAttr {
// TODO: Disable if used via dancer // TODO: Disable if used via dancer
// TODO: Add support for moves that don't add the frenzy tag (Uproar, Rollout, etc.) // 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); 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); user.addTag(BattlerTagType.FRENZY, turnCount, move.id, user.id);
} else { } else {
applyMoveAttrs(AddBattlerTagAttr, user, target, move, args); 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 // If not, target the Mirror Move recipient or else a random enemy in our target list
const targets = moveTargets.multiple || moveTargets.targets.length === 1 const targets = moveTargets.multiple || moveTargets.targets.length === 1
? moveTargets.targets ? 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 LoadMoveAnimPhase(move.id));
globalScene.unshiftPhase(new MovePhase(user, targets, new PokemonMove(move.id), MoveUseType.FOLLOW_UP)); globalScene.unshiftPhase(new MovePhase(user, targets, new PokemonMove(move.id), MoveUseType.FOLLOW_UP));
return true; return true;
@ -7083,6 +7089,7 @@ export class RepeatMoveAttr extends MoveEffectAttr {
Moves.PETAL_DANCE, Moves.PETAL_DANCE,
Moves.THRASH, Moves.THRASH,
Moves.ICE_BALL, Moves.ICE_BALL,
Moves.UPROAR,
// Multi-turn Moves // Multi-turn Moves
Moves.BIDE, Moves.BIDE,
Moves.SHELL_TRAP, Moves.SHELL_TRAP,
@ -7120,8 +7127,17 @@ export class RepeatMoveAttr extends MoveEffectAttr {
Moves.SOLAR_BEAM, Moves.SOLAR_BEAM,
Moves.SOLAR_BLADE, Moves.SOLAR_BLADE,
Moves.METEOR_BEAM, 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, Moves.INSTRUCT,
// Misc moves
Moves.KINGS_SHIELD, Moves.KINGS_SHIELD,
Moves.SKETCH, Moves.SKETCH,
Moves.TRANSFORM, Moves.TRANSFORM,
@ -7132,7 +7148,8 @@ export class RepeatMoveAttr extends MoveEffectAttr {
if (!lastMove?.move // no move to instruct if (!lastMove?.move // no move to instruct
|| !movesetMove // called move not in target's moveset (forgetting the move, etc.) || !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 || uninstructableMoves.includes(lastMove.move)) { // called move is in the banlist
return false; return false;
} }

View File

@ -15,22 +15,23 @@ export enum MoveUseType {
NORMAL, NORMAL,
/** /**
* Identical to {@linkcode MoveUseType.NORMAL}, except the move **does not consume PP** on use * This move was called by an effect that ignores PP, such as a consecutively executed move.
* and **will not fail** if none is left before its execution. * Identical to {@linkcode MoveUseType.NORMAL}, except the move **does not consume PP** on use
* PP can still be reduced by other effects (such as Spite or Eerie Spell). * 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).
*/
IGNORE_PP, 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}. * Currently only used by {@linkcode PostDancingMoveAbAttr | Dancer}.
* Indirect moves ignore PP checks similar to {@linkcode MoveUseType.IGNORE_PP}, but additionally **cannot be copied** * Indirect moves ignore PP checks similar to {@linkcode MoveUseType.IGNORE_PP}, but additionally **cannot be copied**
* by all move-copying effects (barring reflection). * by all move-copying effects (barring reflection).
* They are also **"skipped over" by most moveset and move history-related effects** (PP reduction, Last Resort, etc). * They are also **"skipped over" by most moveset and move history-related effects** (PP reduction, Last Resort, etc).
* They still respect the user's volatile status conditions and confusion (though will uniquely _cure freeze and sleep before use_). * They still respect the user's volatile status conditions and confusion (though will uniquely _cure freeze and sleep before use_).
*/ */
INDIRECT, INDIRECT,
/** /**
@ -56,3 +57,9 @@ export enum MoveUseType {
*/ */
REFLECTED 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, result: MoveResult.OTHER,
useType: this.useType, useType: this.useType,
}); });
super.end();
} }
public getUserPokemon(): Pokemon { public getUserPokemon(): Pokemon {

View File

@ -150,7 +150,8 @@ export class MovePhase extends BattlePhase {
console.log(Moves[this.move.moveId], MoveUseType[this.useType]); 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.canMove(true)) {
if (this.pokemon.isActive(true)) { if (this.pokemon.isActive(true)) {
this.fail(); this.fail();
@ -241,6 +242,7 @@ export class MovePhase extends BattlePhase {
return; return;
} }
this.pokemon.status.incrementTurn();
/** Whether to prevent us from using the move */ /** Whether to prevent us from using the move */
let activated = false; let activated = false;
/** Whether to cure the status */ /** Whether to cure the status */

View File

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

View File

@ -75,7 +75,7 @@ describe("Moves - After You", () => {
game.move.select(Moves.SPLASH, BattlerIndex.PLAYER); game.move.select(Moves.SPLASH, BattlerIndex.PLAYER);
game.move.select(Moves.OUTRAGE, BattlerIndex.PLAYER_2); 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); const outrageMove = rattata.getMoveset().find(m => m.moveId === Moves.OUTRAGE);
expect(outrageMove?.ppUsed).toBe(1); 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 () => { it("should deduct PP only on the 2nd turn of the move", async () => {
game.override.moveset([]);
await game.classicMode.startBattle([Species.MAGIKARP]); await game.classicMode.startBattle([Species.MAGIKARP]);
const playerPokemon = game.scene.getPlayerPokemon()!; const playerPokemon = game.scene.getPlayerPokemon()!;
game.move.changeMoveset(playerPokemon, Moves.DIG);
game.move.select(Moves.DIG); game.move.select(Moves.DIG);
await game.phaseInterceptor.to("TurnEndPhase"); await game.phaseInterceptor.to("TurnEndPhase");

View File

@ -48,7 +48,7 @@ describe("Moves - Disable", () => {
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn(); 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.SPLASH)).toBe(true);
expect(enemyMon.isMoveRestricted(Moves.GROWL)).toBe(false); 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]); 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]); game.override.enemyMoveset([Moves.STRUGGLE]);
await game.classicMode.startBattle([Species.PIKACHU]); await game.classicMode.startBattle([Species.PIKACHU]);
@ -120,28 +120,29 @@ describe("Moves - Disable", () => {
const enemyHistory = enemyMon.getLastXMoves(-1); const enemyHistory = enemyMon.getLastXMoves(-1);
expect(enemyHistory).toHaveLength(2); expect(enemyHistory).toHaveLength(2);
expect(enemyHistory[0]).toMatchObject({ expect(enemyHistory[0].result).toBe(MoveResult.FAIL);
move: Moves.SPLASH,
result: MoveResult.FAIL,
});
}); });
it.each([ it.each([
{ name: "Nature Power", moveId: Moves.NATURE_POWER }, { name: "Nature Power", moveId: Moves.NATURE_POWER },
{ name: "Mirror Move", moveId: Moves.MIRROR_MOVE }, { name: "Mirror Move", moveId: Moves.MIRROR_MOVE },
{ name: "Copycat", moveId: Moves.COPYCAT }, { name: "Copycat", moveId: Moves.COPYCAT },
{ name: "Copycat", moveId: Moves.COPYCAT }, { name: "Metronome", moveId: Moves.METRONOME },
])("should ignore virtual moves called by $name", async ({ moveId }) => { ])("should ignore virtual moves called by $name", async ({ moveId }) => {
game.override.enemyMoveset(moveId); game.override.enemyMoveset(moveId);
await game.classicMode.startBattle([Species.PIKACHU]); 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); game.move.select(Moves.DISABLE);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn(); 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.isMoveRestricted(moveId), `calling move ${Moves[moveId]} was not disabled`).toBe(true);
expect.soft(enemyMon.getLastXMoves(-1)).toHaveLength(2);
const calledMove = enemyMon.getLastXMoves()[0].move; const calledMove = enemyMon.getLastXMoves()[0].move;
expect( expect(
enemyMon.isMoveRestricted(calledMove), enemyMon.isMoveRestricted(calledMove),
@ -154,7 +155,6 @@ describe("Moves - Disable", () => {
.enemyAbility(Abilities.DANCER) .enemyAbility(Abilities.DANCER)
.moveset([Moves.DISABLE, Moves.SWORDS_DANCE]) .moveset([Moves.DISABLE, Moves.SWORDS_DANCE])
.enemyMoveset([Moves.SPLASH, Moves.SWORDS_DANCE]); .enemyMoveset([Moves.SPLASH, Moves.SWORDS_DANCE]);
await game.classicMode.startBattle([Species.PIKACHU]); await game.classicMode.startBattle([Species.PIKACHU]);
game.move.select(Moves.SWORDS_DANCE); 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.SPLASH)).toBe(true);
expect.soft(shuckle.isMoveRestricted(Moves.SWORDS_DANCE)).toBe(false); expect.soft(shuckle.isMoveRestricted(Moves.SWORDS_DANCE)).toBe(false);
expect(shuckle.getLastXMoves()[0]).toMatchObject({ move: Moves.SWORDS_DANCE, result: MoveResult.SUCCESS }); 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 { BattlerIndex } from "#app/battle";
import { RandomMoveAttr } from "#app/data/moves/move";
import type Pokemon from "#app/field/pokemon"; import type Pokemon from "#app/field/pokemon";
import { MoveResult } from "#app/field/pokemon"; import { MoveResult } from "#app/field/pokemon";
import type { MovePhase } from "#app/phases/move-phase"; import type { MovePhase } from "#app/phases/move-phase";
@ -8,7 +9,7 @@ import { Moves } from "#enums/moves";
import { Species } from "#enums/species"; import { Species } from "#enums/species";
import GameManager from "#test/testUtils/gameManager"; import GameManager from "#test/testUtils/gameManager";
import Phaser from "phaser"; 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", () => { describe("Moves - Instruct", () => {
let phaserGame: Phaser.Game; let phaserGame: Phaser.Game;
@ -139,6 +140,22 @@ describe("Moves - Instruct", () => {
expect(game.scene.getPlayerPokemon()!.turnData.attacksReceived.length).toBe(3); 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 () => { it("should respect enemy's status condition", async () => {
game.override.moveset([Moves.INSTRUCT, Moves.THUNDER_WAVE]).enemyMoveset(Moves.SONIC_BOOM); game.override.moveset([Moves.INSTRUCT, Moves.THUNDER_WAVE]).enemyMoveset(Moves.SONIC_BOOM);
await game.classicMode.startBattle([Species.AMOONGUSS]); 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.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER_2, BattlerIndex.PLAYER, BattlerIndex.ENEMY_2]);
await game.phaseInterceptor.to("TurnEndPhase", false); await game.phaseInterceptor.to("TurnEndPhase", false);
expect(game.scene.getPlayerField()[0].getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); expect(game.scene.getPlayerPokemon()!.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
const enemyMove = game.scene.getEnemyField()[0]!.getLastXMoves()[0]; expect(enemy1.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(enemyMove.result).toBe(MoveResult.FAIL); expect(enemy1.getMoveset().find(m => m.moveId === Moves.SONIC_BOOM)?.ppUsed).toBe(1);
expect(
game.scene
.getEnemyField()[0]
.getMoveset()
.find(m => m?.moveId === Moves.SONIC_BOOM)?.ppUsed,
).toBe(1);
}); });
it("should not repeat enemy's move through protect", async () => { 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.SPLASH, BattlerIndex.PLAYER);
game.move.select(Moves.OUTRAGE, BattlerIndex.PLAYER_2); 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); const outrageMove = rattata.getMoveset().find(m => m.moveId === Moves.OUTRAGE);
expect(outrageMove?.ppUsed).toBe(1); 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 * 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. * 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() { async toNextTurn() {
await this.phaseInterceptor.to(CommandPhase); await this.phaseInterceptor.to(CommandPhase);
console.log("==================[New Turn]=================="); 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. * @returns A promise that resolves once the fainted pokemon's FaintPhase finishes running.
*/ */
async killPokemon(pokemon: PlayerPokemon | EnemyPokemon) { async killPokemon(pokemon: PlayerPokemon | EnemyPokemon) {
pokemon.hp = 0;
this.scene.unshiftPhase(new FaintPhase(pokemon.getBattlerIndex(), true));
return new Promise<void>(async (resolve, reject) => { 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)); await this.phaseInterceptor.to(FaintPhase).catch(e => reject(e));
resolve(); resolve();
}); });