diff --git a/src/data/move.ts b/src/data/move.ts index 572fbf4c2ac..9e2a9a5572f 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -7922,6 +7922,56 @@ export class AfterYouAttr extends MoveEffectAttr { } } +/** + * Move effect to force the target to move last, ignoring priority. + * If applied to multiple targets, they move in speed order after all other moves. + * @extends MoveEffectAttr + */ +export class ForceLastAttr extends MoveEffectAttr { + /** + * Forces the target of this move to move last. + * + * @param user {@linkcode Pokemon} that is using the move. + * @param target {@linkcode Pokemon} that will be forced to move last. + * @param move {@linkcode Move} {@linkcode Moves.QUASH} + * @param _args N/A + * @returns true + */ + override apply(user: Pokemon, target: Pokemon, _move: Move, _args: any[]): boolean { + globalScene.queueMessage(i18next.t("moveTriggers:forceLast", { targetPokemonName: getPokemonNameWithAffix(target) })); + + const targetMovePhase = globalScene.findPhase((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 - + // Either the end of the turn or in front of another, slower move which has also been forced last + const prependPhase = globalScene.findPhase((phase) => + [ MovePhase, MoveEndPhase ].every(cls => !(phase instanceof cls)) + || (phase instanceof MovePhase) && phaseForcedSlower(phase, target, !!globalScene.arena.getTag(ArenaTagType.TRICK_ROOM)) + ); + if (prependPhase) { + globalScene.phaseQueue.splice( + globalScene.phaseQueue.indexOf(prependPhase), + 0, + new MovePhase(target, [ ...targetMovePhase.targets ], targetMovePhase.move, false, false, true) + ); + } + } + return true; + } +} + +/** Returns whether a {@linkcode MovePhase} has been forced last and the corresponding pokemon is slower than {@linkcode target} */ +const phaseForcedSlower = (phase: MovePhase, target: Pokemon, trickRoom: boolean): boolean => { + let slower: boolean; + // quashed pokemon still have speed ties + if (phase.pokemon.getEffectiveStat(Stat.SPD) === target.getEffectiveStat(Stat.SPD)) { + slower = !!target.randSeedInt(2); + } else { + slower = !trickRoom ? phase.pokemon.getEffectiveStat(Stat.SPD) < target.getEffectiveStat(Stat.SPD) : phase.pokemon.getEffectiveStat(Stat.SPD) > target.getEffectiveStat(Stat.SPD); + } + return phase.isForcedLast() && slower; +}; + const failOnGravityCondition: MoveConditionFunc = (user, target, move) => !globalScene.arena.getTag(ArenaTagType.GRAVITY); const failOnBossCondition: MoveConditionFunc = (user, target, move) => !target.isBossImmune(); @@ -9725,7 +9775,8 @@ export function initMoves() { .attr(RemoveHeldItemAttr, true), new StatusMove(Moves.QUASH, Type.DARK, 100, 15, -1, 0, 5) .condition(failIfSingleBattle) - .unimplemented(), + .condition((user, target, move) => !target.turnData.acted) + .attr(ForceLastAttr), new AttackMove(Moves.ACROBATICS, Type.FLYING, MoveCategory.PHYSICAL, 55, 100, 15, -1, 0, 5) .attr(MovePowerMultiplierAttr, (user, target, move) => Math.max(1, 2 - 0.2 * user.getHeldItems().filter(i => i.isTransferable).reduce((v, m) => v + m.stackCount, 0))), new StatusMove(Moves.REFLECT_TYPE, Type.NORMAL, -1, 15, -1, 0, 5) diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 5330540c8b2..f4d32d8ea06 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -56,6 +56,7 @@ export class MovePhase extends BattlePhase { protected _targets: BattlerIndex[]; protected followUp: boolean; protected ignorePp: boolean; + protected forcedLast: boolean; protected failed: boolean = false; protected cancelled: boolean = false; @@ -87,7 +88,8 @@ export class MovePhase extends BattlePhase { * @param followUp Indicates that the move being uses is a "follow-up" - for example, a move being used by Metronome or Dancer. * Follow-ups bypass a few failure conditions, including flinches, sleep/paralysis/freeze and volatile status checks, etc. */ - constructor(pokemon: Pokemon, targets: BattlerIndex[], move: PokemonMove, followUp: boolean = false, ignorePp: boolean = false) { + + constructor(pokemon: Pokemon, targets: BattlerIndex[], move: PokemonMove, followUp: boolean = false, ignorePp: boolean = false, forcedLast: boolean = false) { super(); this.pokemon = pokemon; @@ -95,6 +97,7 @@ export class MovePhase extends BattlePhase { this.move = move; this.followUp = followUp; this.ignorePp = ignorePp; + this.forcedLast = forcedLast; } /** @@ -116,6 +119,15 @@ export class MovePhase extends BattlePhase { this.cancelled = true; } + /** + * Shows whether the current move has been forced to the end of the turn + * Needed for speed order, see {@linkcode Moves.QUASH} + * */ + public isForcedLast(): boolean { + return this.forcedLast; + } + + public start(): void { super.start(); diff --git a/src/test/moves/quash.test.ts b/src/test/moves/quash.test.ts new file mode 100644 index 00000000000..3cbe79d7bfe --- /dev/null +++ b/src/test/moves/quash.test.ts @@ -0,0 +1,99 @@ +import { Species } from "#enums/species"; +import { Moves } from "#enums/moves"; +import { Abilities } from "#app/enums/abilities"; +import { BattlerIndex } from "#app/battle"; +import { WeatherType } from "#enums/weather-type"; +import { MoveResult } from "#app/field/pokemon"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { describe, beforeAll, afterEach, beforeEach, it, expect } from "vitest"; + +describe("Moves - Quash", () => { + 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 + .battleType("double") + .enemyLevel(1) + .enemySpecies(Species.SLOWPOKE) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset([ Moves.RAIN_DANCE, Moves.SPLASH ]) + .ability(Abilities.BALL_FETCH) + .moveset([ Moves.QUASH, Moves.SUNNY_DAY, Moves.RAIN_DANCE, Moves.SPLASH ]); + }); + + it("makes the target move last in a turn, ignoring priority", async () => { + await game.classicMode.startBattle([ Species.ACCELGOR, Species.RATTATA ]); + + game.move.select(Moves.QUASH, 0, BattlerIndex.PLAYER_2); + game.move.select(Moves.SUNNY_DAY, 1); + await game.forceEnemyMove(Moves.SPLASH); + await game.forceEnemyMove(Moves.RAIN_DANCE); + + await game.phaseInterceptor.to("TurnEndPhase", false); + // will be sunny if player_2 moved last because of quash, rainy otherwise + expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SUNNY); + }); + + 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); + + await game.phaseInterceptor.to("MoveEndPhase"); + await game.phaseInterceptor.to("MoveEndPhase"); + + expect(game.scene.getPlayerField()[1].getLastXMoves(1)[0].result).toBe(MoveResult.FAIL); + }); + + it("makes multiple quashed targets move in speed order at the end of the turn", async () => { + game.override.enemySpecies(Species.NINJASK) + .enemyLevel(100); + + await game.classicMode.startBattle([ Species.ACCELGOR, Species.RATTATA ]); + + // both users are quashed - rattata is slower so sun should be up at end of turn + game.move.select(Moves.RAIN_DANCE, 0); + game.move.select(Moves.SUNNY_DAY, 1); + + await game.forceEnemyMove(Moves.QUASH, BattlerIndex.PLAYER); + await game.forceEnemyMove(Moves.QUASH, BattlerIndex.PLAYER_2); + + await game.phaseInterceptor.to("TurnEndPhase", false); + expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SUNNY); + }); + + it("respects trick room", async () => { + game.override.enemyMoveset([ Moves.RAIN_DANCE, Moves.SPLASH, Moves.TRICK_ROOM ]); + + await game.classicMode.startBattle([ Species.ACCELGOR, Species.RATTATA ]); + game.move.select(Moves.SPLASH, 0); + game.move.select(Moves.SPLASH, 1); + + await game.forceEnemyMove(Moves.TRICK_ROOM); + await game.forceEnemyMove(Moves.SPLASH); + await game.phaseInterceptor.to("TurnInitPhase"); + // both users are quashed - accelgor should move last w/ TR so rain should be up at end of turn + game.move.select(Moves.RAIN_DANCE, 0); + game.move.select(Moves.SUNNY_DAY, 1); + + await game.forceEnemyMove(Moves.QUASH, BattlerIndex.PLAYER); + await game.forceEnemyMove(Moves.QUASH, BattlerIndex.PLAYER_2); + + await game.phaseInterceptor.to("TurnEndPhase", false); + expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.RAIN); + }); + +});