From 513adf779f126d10f6dcaf6bf6ad8e2759b89c0e Mon Sep 17 00:00:00 2001 From: Mumble Date: Tue, 13 Aug 2024 13:25:58 -0700 Subject: [PATCH] [Ability] Stall + Mycelium Might (#3484) * Implemented Stall * Fixed implementation * AbAttr Name Change * Wrote test for Stall * Update src/test/abilities/stall.test.ts Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> * Update src/data/ability.ts Co-authored-by: Adrian T. <68144167+torranx@users.noreply.github.com> * Updated ability variables and test * Apply suggestions from code review Co-authored-by: Amani H. <109637146+xsn34kzx@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Amani H. <109637146+xsn34kzx@users.noreply.github.com> * eslint fixes * Update src/test/abilities/stall.test.ts Co-authored-by: Adrian T. <68144167+torranx@users.noreply.github.com> * added documentation and implemented mycelium might * added note on quick claw * Documentation + Quick Claw implementation * This is where I would test quick claw-stall/m.m. if i could override modifierstacks * Forgot to add edits oops --------- Co-authored-by: Frutescens Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> Co-authored-by: Adrian T. <68144167+torranx@users.noreply.github.com> Co-authored-by: Amani H. <109637146+xsn34kzx@users.noreply.github.com> --- src/battle-scene.ts | 4 +- src/data/ability.ts | 75 ++++++++++++---- src/data/terrain.ts | 4 +- src/phases.ts | 17 +++- src/test/abilities/mycelium_might.test.ts | 105 ++++++++++++++++++++++ src/test/abilities/stall.test.ts | 95 ++++++++++++++++++++ 6 files changed, 277 insertions(+), 23 deletions(-) create mode 100644 src/test/abilities/mycelium_might.test.ts create mode 100644 src/test/abilities/stall.test.ts diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 66b966270fd..dec6abb4f30 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -16,7 +16,7 @@ import { TextStyle, addTextObject, getTextColor } from "./ui/text"; import { allMoves } from "./data/move"; import { ModifierPoolType, getDefaultModifierTypeForTier, getEnemyModifierTypesForWave, getLuckString, getLuckTextTint, getModifierPoolForType, getModifierType, getPartyLuckValue, modifierTypes } from "./modifier/modifier-type"; import AbilityBar from "./ui/ability-bar"; -import { BlockItemTheftAbAttr, DoubleBattleChanceAbAttr, IncrementMovePriorityAbAttr, PostBattleInitAbAttr, applyAbAttrs, applyPostBattleInitAbAttrs } from "./data/ability"; +import { BlockItemTheftAbAttr, DoubleBattleChanceAbAttr, ChangeMovePriorityAbAttr, PostBattleInitAbAttr, applyAbAttrs, applyPostBattleInitAbAttrs } from "./data/ability"; import { allAbilities } from "./data/ability"; import Battle, { BattleType, FixedBattleConfig } from "./battle"; import { GameMode, GameModes, getGameMode } from "./game-mode"; @@ -2121,7 +2121,7 @@ export default class BattleScene extends SceneBase { pushMovePhase(movePhase: MovePhase, priorityOverride?: integer): void { const movePriority = new Utils.IntegerHolder(priorityOverride !== undefined ? priorityOverride : movePhase.move.getMove().priority); - applyAbAttrs(IncrementMovePriorityAbAttr, movePhase.pokemon, null, movePhase.move.getMove(), movePriority); + applyAbAttrs(ChangeMovePriorityAbAttr, movePhase.pokemon, null, movePhase.move.getMove(), movePriority); const lowerPriorityPhase = this.phaseQueue.find(p => p instanceof MovePhase && p.move.getMove().priority < movePriority.value); if (lowerPriorityPhase) { this.phaseQueue.splice(this.phaseQueue.indexOf(lowerPriorityPhase), 0, movePhase); diff --git a/src/data/ability.ts b/src/data/ability.ts index 0605cb7aa20..7e270f4d3f2 100644 --- a/src/data/ability.ts +++ b/src/data/ability.ts @@ -525,7 +525,7 @@ export class FieldPriorityMoveImmunityAbAttr extends PreDefendAbAttr { applyPreDefend(pokemon: Pokemon, passive: boolean, attacker: Pokemon, move: Move, cancelled: Utils.BooleanHolder, args: any[]): boolean { const attackPriority = new Utils.IntegerHolder(move.priority); applyMoveAttrs(IncrementMovePriorityAttr,attacker,null,move,attackPriority); - applyAbAttrs(IncrementMovePriorityAbAttr, attacker, null, move, attackPriority); + applyAbAttrs(ChangeMovePriorityAbAttr, attacker, null, move, attackPriority); if (move.moveTarget===MoveTarget.USER || move.moveTarget===MoveTarget.NEAR_ALLY) { return false; @@ -2682,23 +2682,32 @@ export class BlockOneHitKOAbAttr extends AbAttr { } } -export class IncrementMovePriorityAbAttr extends AbAttr { - private moveIncrementFunc: (pokemon: Pokemon, move: Move) => boolean; - private increaseAmount: integer; +/** + * This governs abilities that alter the priority of moves + * Abilities: Prankster, Gale Wings, Triage, Mycelium Might, Stall + * Note - Quick Claw has a separate and distinct implementation outside of priority + */ +export class ChangeMovePriorityAbAttr extends AbAttr { + private moveFunc: (pokemon: Pokemon, move: Move) => boolean; + private changeAmount: number; - constructor(moveIncrementFunc: (pokemon: Pokemon, move: Move) => boolean, increaseAmount = 1) { + /** + * @param {(pokemon, move) => boolean} moveFunc applies priority-change to moves within a provided category + * @param {number} changeAmount the amount of priority added or subtracted + */ + constructor(moveFunc: (pokemon: Pokemon, move: Move) => boolean, changeAmount: number) { super(true); - this.moveIncrementFunc = moveIncrementFunc; - this.increaseAmount = increaseAmount; + this.moveFunc = moveFunc; + this.changeAmount = changeAmount; } apply(pokemon: Pokemon, passive: boolean, cancelled: Utils.BooleanHolder, args: any[]): boolean { - if (!this.moveIncrementFunc(pokemon, args[0] as Move)) { + if (!this.moveFunc(pokemon, args[0] as Move)) { return false; } - (args[1] as Utils.IntegerHolder).value += this.increaseAmount; + (args[1] as Utils.IntegerHolder).value += this.changeAmount; return true; } } @@ -4092,6 +4101,41 @@ export class BypassSpeedChanceAbAttr extends AbAttr { } } +/** + * This attribute checks if a Pokemon's move meets a provided condition to determine if the Pokemon can use Quick Claw + * It was created because Pokemon with the ability Mycelium Might cannot access Quick Claw's benefits when using status moves. +*/ +export class PreventBypassSpeedChanceAbAttr extends AbAttr { + private condition: ((pokemon: Pokemon, move: Move) => boolean); + + /** + * @param {function} condition - checks if a move meets certain conditions + */ + constructor(condition: (pokemon: Pokemon, move: Move) => boolean) { + super(true); + this.condition = condition; + } + + /** + * @argument {boolean} bypassSpeed - determines if a Pokemon is able to bypass speed at the moment + * @argument {boolean} canCheckHeldItems - determines if a Pokemon has access to Quick Claw's effects or not + */ + apply(pokemon: Pokemon, passive: boolean, cancelled: Utils.BooleanHolder, args: any[]): boolean { + const bypassSpeed = args[0] as Utils.BooleanHolder; + const canCheckHeldItems = args[1] as Utils.BooleanHolder; + + const turnCommand = pokemon.scene.currentBattle.turnCommands[pokemon.getBattlerIndex()]; + const isCommandFight = turnCommand?.command === Command.FIGHT; + const move = turnCommand?.move?.move ? allMoves[turnCommand.move.move] : null; + if (this.condition(pokemon, move!) && isCommandFight) { + bypassSpeed.value = false; + canCheckHeldItems.value = false; + return false; + } + return true; + } +} + async function applyAbAttrsInternal( attrType: Constructor, pokemon: Pokemon | null, @@ -4613,7 +4657,7 @@ export function initAbilities() { .attr(AlwaysHitAbAttr) .attr(DoubleBattleChanceAbAttr), new Ability(Abilities.STALL, 4) - .unimplemented(), + .attr(ChangeMovePriorityAbAttr, (pokemon, move: Move) => true, -0.5), new Ability(Abilities.TECHNICIAN, 4) .attr(MovePowerBoostAbAttr, (user, target, move) => { const power = new Utils.NumberHolder(move.power); @@ -4790,7 +4834,7 @@ export function initAbilities() { .attr(TypeImmunityStatChangeAbAttr, Type.GRASS, BattleStat.ATK, 1) .ignorable(), new Ability(Abilities.PRANKSTER, 5) - .attr(IncrementMovePriorityAbAttr, (pokemon, move: Move) => move.category === MoveCategory.STATUS), + .attr(ChangeMovePriorityAbAttr, (pokemon, move: Move) => move.category === MoveCategory.STATUS, 1), new Ability(Abilities.SAND_FORCE, 5) .attr(MoveTypePowerBoostAbAttr, Type.ROCK, 1.3) .attr(MoveTypePowerBoostAbAttr, Type.GROUND, 1.3) @@ -4855,7 +4899,7 @@ export function initAbilities() { .attr(UnsuppressableAbilityAbAttr) .attr(NoFusionAbilityAbAttr), new Ability(Abilities.GALE_WINGS, 6) - .attr(IncrementMovePriorityAbAttr, (pokemon, move) => pokemon.isFullHp() && move.type === Type.FLYING), + .attr(ChangeMovePriorityAbAttr, (pokemon, move) => pokemon.isFullHp() && move.type === Type.FLYING, 1), new Ability(Abilities.MEGA_LAUNCHER, 6) .attr(MovePowerBoostAbAttr, (user, target, move) => move.hasFlag(MoveFlags.PULSE_MOVE), 1.5), new Ability(Abilities.GRASS_PELT, 6) @@ -4944,7 +4988,7 @@ export function initAbilities() { new Ability(Abilities.LIQUID_VOICE, 7) .attr(MoveTypeChangeAttr, Type.WATER, 1, (user, target, move) => move.hasFlag(MoveFlags.SOUND_BASED)), new Ability(Abilities.TRIAGE, 7) - .attr(IncrementMovePriorityAbAttr, (pokemon, move) => move.hasFlag(MoveFlags.TRIAGE_MOVE), 3), + .attr(ChangeMovePriorityAbAttr, (pokemon, move) => move.hasFlag(MoveFlags.TRIAGE_MOVE), 3), new Ability(Abilities.GALVANIZE, 7) .attr(MoveTypeChangeAttr, Type.ELECTRIC, 1.2, (user, target, move) => move.type === Type.NORMAL), new Ability(Abilities.SURGE_SURFER, 7) @@ -5299,8 +5343,9 @@ export function initAbilities() { .partial() // Healing not blocked by Heal Block .ignorable(), new Ability(Abilities.MYCELIUM_MIGHT, 9) - .attr(MoveAbilityBypassAbAttr, (pokemon, move: Move) => move.category === MoveCategory.STATUS) - .partial(), + .attr(ChangeMovePriorityAbAttr, (pokemon, move) => move.category === MoveCategory.STATUS, -0.5) + .attr(PreventBypassSpeedChanceAbAttr, (pokemon, move) => move.category === MoveCategory.STATUS) + .attr(MoveAbilityBypassAbAttr, (pokemon, move: Move) => move.category === MoveCategory.STATUS), new Ability(Abilities.MINDS_EYE, 9) .attr(IgnoreTypeImmunityAbAttr, Type.GHOST, [Type.NORMAL, Type.FIGHTING]) .attr(ProtectStatAbAttr, BattleStat.ACC) diff --git a/src/data/terrain.ts b/src/data/terrain.ts index d0b2fb53d3e..e29344ffea2 100644 --- a/src/data/terrain.ts +++ b/src/data/terrain.ts @@ -2,7 +2,7 @@ import Pokemon from "../field/pokemon"; import Move from "./move"; import { Type } from "./type"; import * as Utils from "../utils"; -import { IncrementMovePriorityAbAttr, applyAbAttrs } from "./ability"; +import { ChangeMovePriorityAbAttr, applyAbAttrs } from "./ability"; import { ProtectAttr } from "./move"; import { BattlerIndex } from "#app/battle.js"; import i18next from "i18next"; @@ -59,7 +59,7 @@ export class Terrain { case TerrainType.PSYCHIC: if (!move.hasAttr(ProtectAttr)) { const priority = new Utils.IntegerHolder(move.priority); - applyAbAttrs(IncrementMovePriorityAbAttr, user, null, move, priority); + applyAbAttrs(ChangeMovePriorityAbAttr, user, null, move, priority); // Cancels move if the move has positive priority and targets a Pokemon grounded on the Psychic Terrain return priority.value > 0 && user.getOpponents().some(o => targets.includes(o.getBattlerIndex()) && o.isGrounded()); } diff --git a/src/phases.ts b/src/phases.ts index 3550b3f599c..5b66ee3e771 100644 --- a/src/phases.ts +++ b/src/phases.ts @@ -25,7 +25,7 @@ import { Starter } from "./ui/starter-select-ui-handler"; import { Gender } from "./data/gender"; import { Weather, WeatherType, getRandomWeatherType, getTerrainBlockMessage, getWeatherDamageMessage, getWeatherLapseMessage } from "./data/weather"; import { ArenaTagSide, ArenaTrapTag, MistTag, TrickRoomTag } from "./data/arena-tag"; -import { CheckTrappedAbAttr, PostAttackAbAttr, PostBattleAbAttr, PostDefendAbAttr, PostSummonAbAttr, PostTurnAbAttr, PostWeatherLapseAbAttr, PreSwitchOutAbAttr, PreWeatherDamageAbAttr, ProtectStatAbAttr, RedirectMoveAbAttr, BlockRedirectAbAttr, RunSuccessAbAttr, StatChangeMultiplierAbAttr, SuppressWeatherEffectAbAttr, SyncEncounterNatureAbAttr, applyAbAttrs, applyCheckTrappedAbAttrs, applyPostAttackAbAttrs, applyPostBattleAbAttrs, applyPostDefendAbAttrs, applyPostSummonAbAttrs, applyPostTurnAbAttrs, applyPostWeatherLapseAbAttrs, applyPreStatChangeAbAttrs, applyPreSwitchOutAbAttrs, applyPreWeatherEffectAbAttrs, IncrementMovePriorityAbAttr, applyPostVictoryAbAttrs, PostVictoryAbAttr, BlockNonDirectDamageAbAttr as BlockNonDirectDamageAbAttr, applyPostKnockOutAbAttrs, PostKnockOutAbAttr, PostBiomeChangeAbAttr, applyPostFaintAbAttrs, PostFaintAbAttr, IncreasePpAbAttr, PostStatChangeAbAttr, applyPostStatChangeAbAttrs, AlwaysHitAbAttr, PreventBerryUseAbAttr, StatChangeCopyAbAttr, PokemonTypeChangeAbAttr, applyPreAttackAbAttrs, applyPostMoveUsedAbAttrs, PostMoveUsedAbAttr, MaxMultiHitAbAttr, HealFromBerryUseAbAttr, IgnoreMoveEffectsAbAttr, BlockStatusDamageAbAttr, BypassSpeedChanceAbAttr, AddSecondStrikeAbAttr } from "./data/ability"; +import { CheckTrappedAbAttr, PostAttackAbAttr, PostBattleAbAttr, PostDefendAbAttr, PostSummonAbAttr, PostTurnAbAttr, PostWeatherLapseAbAttr, PreSwitchOutAbAttr, PreWeatherDamageAbAttr, ProtectStatAbAttr, RedirectMoveAbAttr, BlockRedirectAbAttr, RunSuccessAbAttr, StatChangeMultiplierAbAttr, SuppressWeatherEffectAbAttr, SyncEncounterNatureAbAttr, applyAbAttrs, applyCheckTrappedAbAttrs, applyPostAttackAbAttrs, applyPostBattleAbAttrs, applyPostDefendAbAttrs, applyPostSummonAbAttrs, applyPostTurnAbAttrs, applyPostWeatherLapseAbAttrs, applyPreStatChangeAbAttrs, applyPreSwitchOutAbAttrs, applyPreWeatherEffectAbAttrs, ChangeMovePriorityAbAttr, applyPostVictoryAbAttrs, PostVictoryAbAttr, BlockNonDirectDamageAbAttr as BlockNonDirectDamageAbAttr, applyPostKnockOutAbAttrs, PostKnockOutAbAttr, PostBiomeChangeAbAttr, PreventBypassSpeedChanceAbAttr, applyPostFaintAbAttrs, PostFaintAbAttr, IncreasePpAbAttr, PostStatChangeAbAttr, applyPostStatChangeAbAttrs, AlwaysHitAbAttr, PreventBerryUseAbAttr, StatChangeCopyAbAttr, PokemonTypeChangeAbAttr, applyPreAttackAbAttrs, applyPostMoveUsedAbAttrs, PostMoveUsedAbAttr, MaxMultiHitAbAttr, HealFromBerryUseAbAttr, IgnoreMoveEffectsAbAttr, BlockStatusDamageAbAttr, BypassSpeedChanceAbAttr, AddSecondStrikeAbAttr } from "./data/ability"; import { Unlockables, getUnlockableName } from "./system/unlockables"; import { getBiomeKey } from "./field/arena"; import { BattleType, BattlerIndex, TurnCommand } from "./battle"; @@ -2315,8 +2315,12 @@ export class TurnStartPhase extends FieldPhase { this.scene.getField(true).filter(p => p.summonData).map(p => { const bypassSpeed = new Utils.BooleanHolder(false); + const canCheckHeldItems = new Utils.BooleanHolder(true); applyAbAttrs(BypassSpeedChanceAbAttr, p, null, bypassSpeed); - this.scene.applyModifiers(BypassSpeedChanceModifier, p.isPlayer(), p, bypassSpeed); + applyAbAttrs(PreventBypassSpeedChanceAbAttr, p, null, bypassSpeed, canCheckHeldItems); + if (canCheckHeldItems.value) { + this.scene.applyModifiers(BypassSpeedChanceModifier, p.isPlayer(), p, bypassSpeed); + } battlerBypassSpeed[p.getBattlerIndex()] = bypassSpeed; }); @@ -2342,10 +2346,15 @@ export class TurnStartPhase extends FieldPhase { applyMoveAttrs(IncrementMovePriorityAttr, this.scene.getField().find(p => p?.isActive() && p.getBattlerIndex() === a)!, null, aMove, aPriority); //TODO: is the bang correct here? applyMoveAttrs(IncrementMovePriorityAttr, this.scene.getField().find(p => p?.isActive() && p.getBattlerIndex() === b)!, null, bMove, bPriority); //TODO: is the bang correct here? - applyAbAttrs(IncrementMovePriorityAbAttr, this.scene.getField().find(p => p?.isActive() && p.getBattlerIndex() === a)!, null, aMove, aPriority); //TODO: is the bang correct here? - applyAbAttrs(IncrementMovePriorityAbAttr, this.scene.getField().find(p => p?.isActive() && p.getBattlerIndex() === b)!, null, bMove, bPriority); //TODO: is the bang correct here? + applyAbAttrs(ChangeMovePriorityAbAttr, this.scene.getField().find(p => p?.isActive() && p.getBattlerIndex() === a)!, null, aMove, aPriority); //TODO: is the bang correct here? + applyAbAttrs(ChangeMovePriorityAbAttr, this.scene.getField().find(p => p?.isActive() && p.getBattlerIndex() === b)!, null, bMove, bPriority); //TODO: is the bang correct here? if (aPriority.value !== bPriority.value) { + const bracketDifference = Math.ceil(aPriority.value) - Math.ceil(bPriority.value); + const hasSpeedDifference = battlerBypassSpeed[a].value !== battlerBypassSpeed[b].value; + if (bracketDifference === 0 && hasSpeedDifference) { + return battlerBypassSpeed[a].value ? -1 : 1; + } return aPriority.value < bPriority.value ? 1 : -1; } } diff --git a/src/test/abilities/mycelium_might.test.ts b/src/test/abilities/mycelium_might.test.ts new file mode 100644 index 00000000000..d519eb67626 --- /dev/null +++ b/src/test/abilities/mycelium_might.test.ts @@ -0,0 +1,105 @@ +import { MovePhase, TurnEndPhase } from "#app/phases"; +import GameManager from "#test/utils/gameManager"; +import { getMovePosition } from "#test/utils/gameManagerUtils"; +import { Abilities } from "#enums/abilities"; +import { BattleStat } from "#app/data/battle-stat"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + + +describe("Abilities - Mycelium Might", () => { + 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("single"); + game.override.disableCrits(); + game.override.enemySpecies(Species.SHUCKLE); + game.override.enemyAbility(Abilities.CLEAR_BODY); + game.override.enemyMoveset([Moves.QUICK_ATTACK, Moves.QUICK_ATTACK, Moves.QUICK_ATTACK, Moves.QUICK_ATTACK]); + game.override.ability(Abilities.MYCELIUM_MIGHT); + game.override.moveset([Moves.QUICK_ATTACK, Moves.BABY_DOLL_EYES]); + }); + + /** + * Bulbapedia References: + * https://bulbapedia.bulbagarden.net/wiki/Mycelium_Might_(Ability) + * https://bulbapedia.bulbagarden.net/wiki/Priority + * https://www.smogon.com/forums/threads/scarlet-violet-battle-mechanics-research.3709545/page-24 + **/ + + it("If a Pokemon with Mycelium Might uses a status move, it will always move last but the status move will ignore protective abilities", async() => { + await game.startBattle([ Species.REGIELEKI ]); + + const leadIndex = game.scene.getPlayerPokemon()!.getBattlerIndex(); + const enemyPokemon = game.scene.getEnemyPokemon(); + const enemyIndex = enemyPokemon?.getBattlerIndex(); + + game.doAttack(getMovePosition(game.scene, 0, Moves.BABY_DOLL_EYES)); + + await game.phaseInterceptor.to(MovePhase, false); + // The opponent Pokemon (without Mycelium Might) goes first despite having lower speed than the player Pokemon. + expect((game.scene.getCurrentPhase() as MovePhase).pokemon.getBattlerIndex()).toBe(enemyIndex); + + await game.phaseInterceptor.run(MovePhase); + await game.phaseInterceptor.to(MovePhase, false); + + // The player Pokemon (with Mycelium Might) goes last despite having higher speed than the opponent. + expect((game.scene.getCurrentPhase() as MovePhase).pokemon.getBattlerIndex()).toBe(leadIndex); + await game.phaseInterceptor.to(TurnEndPhase); + expect(enemyPokemon?.summonData.battleStats[BattleStat.ATK]).toBe(-1); + }, 20000); + + it("Pokemon with Mycelium Might will go first if a status move that is in a higher priority bracket than the opponent's move is used", async() => { + game.override.enemyMoveset([Moves.TACKLE, Moves.TACKLE, Moves.TACKLE, Moves.TACKLE]); + await game.startBattle([ Species.REGIELEKI ]); + + const leadIndex = game.scene.getPlayerPokemon()!.getBattlerIndex(); + const enemyPokemon = game.scene.getEnemyPokemon(); + const enemyIndex = enemyPokemon?.getBattlerIndex(); + + game.doAttack(getMovePosition(game.scene, 0, Moves.BABY_DOLL_EYES)); + + await game.phaseInterceptor.to(MovePhase, false); + // The player Pokemon (with M.M.) goes first because its move is still within a higher priority bracket than its opponent. + expect((game.scene.getCurrentPhase() as MovePhase).pokemon.getBattlerIndex()).toBe(leadIndex); + + await game.phaseInterceptor.run(MovePhase); + await game.phaseInterceptor.to(MovePhase, false); + // The enemy Pokemon goes second because its move is in a lower priority bracket. + expect((game.scene.getCurrentPhase() as MovePhase).pokemon.getBattlerIndex()).toBe(enemyIndex); + await game.phaseInterceptor.to(TurnEndPhase); + expect(enemyPokemon?.summonData.battleStats[BattleStat.ATK]).toBe(-1); + }, 20000); + + it("Order is established normally if the Pokemon uses a non-status move", async() => { + await game.startBattle([ Species.REGIELEKI ]); + + const leadIndex = game.scene.getPlayerPokemon()!.getBattlerIndex(); + const enemyIndex = game.scene.getEnemyPokemon()!.getBattlerIndex(); + + game.doAttack(getMovePosition(game.scene, 0, Moves.QUICK_ATTACK)); + + await game.phaseInterceptor.to(MovePhase, false); + // The player Pokemon (with M.M.) goes first because it has a higher speed and did not use a status move. + expect((game.scene.getCurrentPhase() as MovePhase).pokemon.getBattlerIndex()).toBe(leadIndex); + + await game.phaseInterceptor.run(MovePhase); + await game.phaseInterceptor.to(MovePhase, false); + // The enemy Pokemon (without M.M.) goes second because its speed is lower. + expect((game.scene.getCurrentPhase() as MovePhase).pokemon.getBattlerIndex()).toBe(enemyIndex); + }, 20000); +}); diff --git a/src/test/abilities/stall.test.ts b/src/test/abilities/stall.test.ts new file mode 100644 index 00000000000..44519064300 --- /dev/null +++ b/src/test/abilities/stall.test.ts @@ -0,0 +1,95 @@ +import { MovePhase } from "#app/phases"; +import GameManager from "#test/utils/gameManager"; +import { getMovePosition } from "#test/utils/gameManagerUtils"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + + +describe("Abilities - Stall", () => { + 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("single"); + game.override.disableCrits(); + game.override.enemySpecies(Species.REGIELEKI); + game.override.enemyAbility(Abilities.STALL); + game.override.enemyMoveset([Moves.QUICK_ATTACK, Moves.QUICK_ATTACK, Moves.QUICK_ATTACK, Moves.QUICK_ATTACK]); + game.override.moveset([Moves.QUICK_ATTACK, Moves.TACKLE]); + }); + + /** + * Bulbapedia References: + * https://bulbapedia.bulbagarden.net/wiki/Stall_(Ability) + * https://bulbapedia.bulbagarden.net/wiki/Priority + **/ + + it("Pokemon with Stall should move last in its priority bracket regardless of speed", async() => { + await game.startBattle([ Species.SHUCKLE ]); + + const leadIndex = game.scene.getPlayerPokemon()!.getBattlerIndex(); + const enemyIndex = game.scene.getEnemyPokemon()!.getBattlerIndex(); + + game.doAttack(getMovePosition(game.scene, 0, Moves.QUICK_ATTACK)); + + await game.phaseInterceptor.to(MovePhase, false); + // The player Pokemon (without Stall) goes first despite having lower speed than the opponent. + expect((game.scene.getCurrentPhase() as MovePhase).pokemon.getBattlerIndex()).toBe(leadIndex); + + await game.phaseInterceptor.run(MovePhase); + await game.phaseInterceptor.to(MovePhase, false); + // The opponent Pokemon (with Stall) goes last despite having higher speed than the player Pokemon. + expect((game.scene.getCurrentPhase() as MovePhase).pokemon.getBattlerIndex()).toBe(enemyIndex); + }, 20000); + + it("Pokemon with Stall will go first if a move that is in a higher priority bracket than the opponent's move is used", async() => { + await game.startBattle([ Species.SHUCKLE ]); + + const leadIndex = game.scene.getPlayerPokemon()!.getBattlerIndex(); + const enemyIndex = game.scene.getEnemyPokemon()!.getBattlerIndex(); + + game.doAttack(getMovePosition(game.scene, 0, Moves.TACKLE)); + + await game.phaseInterceptor.to(MovePhase, false); + // The opponent Pokemon (with Stall) goes first because its move is still within a higher priority bracket than its opponent. + expect((game.scene.getCurrentPhase() as MovePhase).pokemon.getBattlerIndex()).toBe(enemyIndex); + + await game.phaseInterceptor.run(MovePhase); + await game.phaseInterceptor.to(MovePhase, false); + // The player Pokemon goes second because its move is in a lower priority bracket. + expect((game.scene.getCurrentPhase() as MovePhase).pokemon.getBattlerIndex()).toBe(leadIndex); + }, 20000); + + it("If both Pokemon have stall and use the same move, speed is used to determine who goes first.", async() => { + game.override.ability(Abilities.STALL); + await game.startBattle([ Species.SHUCKLE ]); + + const leadIndex = game.scene.getPlayerPokemon()!.getBattlerIndex(); + const enemyIndex = game.scene.getEnemyPokemon()!.getBattlerIndex(); + + game.doAttack(getMovePosition(game.scene, 0, Moves.TACKLE)); + + await game.phaseInterceptor.to(MovePhase, false); + // The opponent Pokemon (with Stall) goes first because it has a higher speed. + expect((game.scene.getCurrentPhase() as MovePhase).pokemon.getBattlerIndex()).toBe(enemyIndex); + + await game.phaseInterceptor.run(MovePhase); + await game.phaseInterceptor.to(MovePhase, false); + // The player Pokemon (with Stall) goes second because its speed is lower. + expect((game.scene.getCurrentPhase() as MovePhase).pokemon.getBattlerIndex()).toBe(leadIndex); + }, 20000); +});