From 6fd3ba284c0df0926460b81855c68e4c380685f6 Mon Sep 17 00:00:00 2001 From: innerthunder <168692175+innerthunder@users.noreply.github.com> Date: Tue, 5 Nov 2024 09:35:43 -0800 Subject: [PATCH] [Move][Ability] Implement Commander and Order Up (#4670) * Implement Order Up (mostly untested) * Commander unit tests + bug fixes * Implement Order Up (misnamed the other commit...) * Order Up unit tests * applying Temp's suggestions + other bugfixes * add TODO comment * Reviver Seed reapplies Commander * ESLint woes * Some animation fixes * Update locales * Order Up now uses attr option * Prevent semi-invulnerability lapsing out while Commanding * semi-invulnerability test * Add `edgeCase` --- src/battle-scene.ts | 3 + src/data/ability.ts | 42 +++- src/data/battler-tags.ts | 33 +++ src/data/move.ts | 57 ++++- .../the-winstrate-challenge-encounter.ts | 2 + src/enums/battler-tag-type.ts | 3 +- src/enums/pokemon-anim-type.ts | 12 +- src/field/pokemon.ts | 7 + src/modifier/modifier.ts | 7 + src/phases/command-phase.ts | 16 +- src/phases/encounter-phase.ts | 2 + src/phases/enemy-command-phase.ts | 7 + src/phases/move-effect-phase.ts | 5 +- src/phases/mystery-encounter-phases.ts | 1 + src/phases/pokemon-anim-phase.ts | 132 ++++++++++- src/phases/post-summon-phase.ts | 5 +- src/test/abilities/commander.test.ts | 224 ++++++++++++++++++ src/test/moves/order_up.test.ts | 85 +++++++ 18 files changed, 623 insertions(+), 20 deletions(-) create mode 100644 src/test/abilities/commander.test.ts create mode 100644 src/test/moves/order_up.test.ts diff --git a/src/battle-scene.ts b/src/battle-scene.ts index d82acec1c20..d17c62c6e0e 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -96,6 +96,7 @@ import { ExpPhase } from "#app/phases/exp-phase"; import { ShowPartyExpBarPhase } from "#app/phases/show-party-exp-bar-phase"; import { MysteryEncounterMode } from "#enums/mystery-encounter-mode"; import { ExpGainsSpeed } from "#enums/exp-gains-speed"; +import { BattlerTagType } from "#enums/battler-tag-type"; import { FRIENDSHIP_GAIN_FROM_BATTLE } from "#app/data/balance/starters"; export const bypassLogin = import.meta.env.VITE_BYPASS_LOGIN === "1"; @@ -1278,6 +1279,8 @@ export default class BattleScene extends SceneBase { if (resetArenaState) { this.arena.resetArenaEffects(); + playerField.forEach((pokemon) => pokemon.lapseTag(BattlerTagType.COMMANDED)); + playerField.forEach((pokemon, p) => { if (pokemon.isOnField()) { this.pushPhase(new ReturnPhase(this, p)); diff --git a/src/data/ability.ts b/src/data/ability.ts index e8a242ae3ea..7d54459d7d4 100644 --- a/src/data/ability.ts +++ b/src/data/ability.ts @@ -4,7 +4,7 @@ import { Constructor } from "#app/utils"; import * as Utils from "../utils"; import { getPokemonNameWithAffix } from "../messages"; import { Weather, WeatherType } from "./weather"; -import { BattlerTag, GroundedTag } from "./battler-tags"; +import { BattlerTag, BattlerTagLapseType, GroundedTag } from "./battler-tags"; import { StatusEffect, getNonVolatileStatusEffects, getStatusEffectDescriptor, getStatusEffectHealText } from "./status-effect"; import { Gender } from "./gender"; import Move, { AttackMove, MoveCategory, MoveFlags, MoveTarget, FlinchAttr, OneHitKOAttr, HitHealAttr, allMoves, StatusMove, SelfStatusMove, VariablePowerAttr, applyMoveAttrs, IncrementMovePriorityAttr, VariableMoveTypeAttr, RandomMovesetMoveAttr, RandomMoveAttr, NaturePowerAttr, CopyMoveAttr, MoveAttr, MultiHitAttr, SacrificialAttr, SacrificialAttrOnHit, NeutralDamageAgainstFlyingTypeMultiplierAttr, FixedDamageAttr } from "./move"; @@ -35,6 +35,7 @@ import { SwitchSummonPhase } from "#app/phases/switch-summon-phase"; import { BattleEndPhase } from "#app/phases/battle-end-phase"; import { NewBattlePhase } from "#app/phases/new-battle-phase"; import { MoveEndPhase } from "#app/phases/move-end-phase"; +import { PokemonAnimType } from "#enums/pokemon-anim-type"; export class Ability implements Localizable { public id: Abilities; @@ -2591,6 +2592,42 @@ export class PostSummonFormChangeByWeatherAbAttr extends PostSummonAbAttr { } } +/** + * Attribute implementing the effects of {@link https://bulbapedia.bulbagarden.net/wiki/Commander_(Ability) | Commander}. + * When the source of an ability with this attribute detects a Dondozo as their active ally, the source "jumps + * into the Dondozo's mouth," sharply boosting the Dondozo's stats, cancelling the source's moves, and + * causing attacks that target the source to always miss. + */ +export class CommanderAbAttr extends AbAttr { + constructor() { + super(true); + } + + override apply(pokemon: Pokemon, passive: boolean, simulated: boolean, cancelled: null, args: any[]): boolean { + // TODO: Should this work with X + Dondozo fusions? + if (pokemon.scene.currentBattle?.double && pokemon.getAlly()?.species.speciesId === Species.DONDOZO) { + // If the ally Dondozo is fainted or was previously "commanded" by + // another Pokemon, this effect cannot apply. + if (pokemon.getAlly().isFainted() || pokemon.getAlly().getTag(BattlerTagType.COMMANDED)) { + return false; + } + + if (!simulated) { + // Lapse the source's semi-invulnerable tags (to avoid visual inconsistencies) + pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT); + // Play an animation of the source jumping into the ally Dondozo's mouth + pokemon.scene.triggerPokemonBattleAnim(pokemon, PokemonAnimType.COMMANDER_APPLY); + // Apply boosts from this effect to the ally Dondozo + pokemon.getAlly().addTag(BattlerTagType.COMMANDED, 0, Moves.NONE, pokemon.id); + // Cancel the source Pokemon's next move (if a move is queued) + pokemon.scene.tryRemovePhase((phase) => phase instanceof MovePhase && phase.pokemon === pokemon); + } + return true; + } + return false; + } +} + export class PreSwitchOutAbAttr extends AbAttr { constructor() { super(true); @@ -6243,9 +6280,10 @@ export function initAbilities() { .attr(PreSwitchOutFormChangeAbAttr, (pokemon) => !pokemon.isFainted() ? 1 : pokemon.formIndex) .bypassFaint(), new Ability(Abilities.COMMANDER, 9) + .attr(CommanderAbAttr) .attr(UncopiableAbilityAbAttr) .attr(UnswappableAbilityAbAttr) - .unimplemented(), + .edgeCase(), // Encore, Frenzy, and other non-`TURN_END` tags don't lapse correctly on the commanding Pokemon. new Ability(Abilities.ELECTROMORPHOSIS, 9) .attr(PostDefendApplyBattlerTagAbAttr, (target, user, move) => move.category !== MoveCategory.STATUS, BattlerTagType.CHARGED), new Ability(Abilities.PROTOSYNTHESIS, 9) diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 15bc745ec9d..2012c4330cc 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -2091,6 +2091,37 @@ export class IceFaceBlockDamageTag extends FormBlockDamageTag { } } +/** + * Battler tag indicating a Tatsugiri with {@link https://bulbapedia.bulbagarden.net/wiki/Commander_(Ability) | Commander} + * has entered the tagged Pokemon's mouth. + */ +export class CommandedTag extends BattlerTag { + private _tatsugiriFormKey: string; + + constructor(sourceId: number) { + super(BattlerTagType.COMMANDED, BattlerTagLapseType.CUSTOM, 0, Moves.NONE, sourceId); + } + + public get tatsugiriFormKey(): string { + return this._tatsugiriFormKey; + } + + /** Caches the Tatsugiri's form key and sharply boosts the tagged Pokemon's stats */ + override onAdd(pokemon: Pokemon): void { + this._tatsugiriFormKey = this.getSourcePokemon(pokemon.scene)?.getFormKey() ?? "curly"; + pokemon.scene.unshiftPhase(new StatStageChangePhase( + pokemon.scene, pokemon.getBattlerIndex(), true, [ Stat.ATK, Stat.DEF, Stat.SPATK, Stat.SPDEF, Stat.SPD ], 2 + )); + } + + /** Triggers an {@linkcode PokemonAnimType | animation} of the tagged Pokemon "spitting out" Tatsugiri */ + override onRemove(pokemon: Pokemon): void { + if (this.getSourcePokemon(pokemon.scene)?.isActive(true)) { + pokemon.scene.triggerPokemonBattleAnim(pokemon, PokemonAnimType.COMMANDER_REMOVE); + } + } +} + /** * Battler tag enabling the Stockpile mechanic. This tag handles: * - Stack tracking, including max limit enforcement (which is replicated in Stockpile for redundancy). @@ -2932,6 +2963,8 @@ export function getBattlerTag(tagType: BattlerTagType, turnCount: number, source return new IceFaceBlockDamageTag(tagType); case BattlerTagType.DISGUISE: return new FormBlockDamageTag(tagType); + case BattlerTagType.COMMANDED: + return new CommandedTag(sourceId); case BattlerTagType.STOCKPILING: return new StockpilingTag(sourceMove); case BattlerTagType.OCTOLOCK: diff --git a/src/data/move.ts b/src/data/move.ts index 5ae37c3b092..c97749e0357 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -1,5 +1,5 @@ import { ChargeAnim, initMoveAnim, loadMoveAnimAssets, MoveChargeAnim } from "./battle-anims"; -import { EncoreTag, GulpMissileTag, HelpingHandTag, SemiInvulnerableTag, ShellTrapTag, StockpilingTag, SubstituteTag, TrappedTag, TypeBoostTag } from "./battler-tags"; +import { CommandedTag, EncoreTag, GulpMissileTag, HelpingHandTag, SemiInvulnerableTag, ShellTrapTag, StockpilingTag, SubstituteTag, TrappedTag, TypeBoostTag } from "./battler-tags"; import { getPokemonNameWithAffix } from "../messages"; import Pokemon, { AttackMoveResult, EnemyPokemon, HitResult, MoveResult, PlayerPokemon, PokemonMove, TurnMove } from "../field/pokemon"; import { getNonVolatileStatusEffects, getStatusEffectHealText, isNonVolatileStatusEffect, StatusEffect } from "./status-effect"; @@ -714,6 +714,10 @@ export default class Move implements Localizable { getTargetBenefitScore(user: Pokemon, target: Pokemon, move: Move): integer { let score = 0; + if (target.getAlly()?.getTag(BattlerTagType.COMMANDED)?.getSourcePokemon(target.scene) === target) { + return 20 * (target.isPlayer() === user.isPlayer() ? -1 : 1); // always -20 with how the AI handles this score + } + for (const attr of this.attrs) { // conditionals to check if the move is self targeting (if so then you are applying the move to yourself, not the target) score += attr.getTargetBenefitScore(user, !attr.selfTarget ? target : user, move) * (target !== user && attr.selfTarget ? -1 : 1); @@ -3245,6 +3249,41 @@ export class CutHpStatStageBoostAttr extends StatStageChangeAttr { } } +/** + * Attribute implementing the stat boosting effect of {@link https://bulbapedia.bulbagarden.net/wiki/Order_Up_(move) | Order Up}. + * If the user has a Pokemon with {@link https://bulbapedia.bulbagarden.net/wiki/Commander_(Ability) | Commander} in their mouth, + * one of the user's stats are increased by 1 stage, depending on the "commanding" Pokemon's form. This effect does not respect + * effect chance, but Order Up itself may be boosted by Sheer Force. + */ +export class OrderUpStatBoostAttr extends MoveEffectAttr { + constructor() { + super(true, { trigger: MoveEffectTrigger.HIT }); + } + + override apply(user: Pokemon, target: Pokemon, move: Move, args?: any[]): boolean { + const commandedTag = user.getTag(CommandedTag); + if (!commandedTag) { + return false; + } + + let increasedStat: EffectiveStat = Stat.ATK; + switch (commandedTag.tatsugiriFormKey) { + case "curly": + increasedStat = Stat.ATK; + break; + case "droopy": + increasedStat = Stat.DEF; + break; + case "stretchy": + increasedStat = Stat.SPD; + break; + } + + user.scene.unshiftPhase(new StatStageChangePhase(user.scene, user.getBattlerIndex(), this.selfTarget, [ increasedStat ], 1)); + return true; + } +} + export class CopyStatsAttr extends MoveEffectAttr { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { if (!super.apply(user, target, move, args)) { @@ -5852,7 +5891,13 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { return false; } + /** The {@linkcode Pokemon} to be switched out with this effect */ const switchOutTarget = this.selfSwitch ? user : target; + + // If the switch-out target is a Dondozo with a Tatsugiri in its mouth + // (e.g. when it uses Flip Turn), make it spit out the Tatsugiri before switching out. + switchOutTarget.lapseTag(BattlerTagType.COMMANDED); + if (switchOutTarget instanceof PlayerPokemon) { /** * Check if Wimp Out/Emergency Exit activates due to being hit by U-turn or Volt Switch @@ -5955,6 +6000,12 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { return false; } + // Dondozo with an allied Tatsugiri in its mouth cannot be forced out + const commandedTag = switchOutTarget.getTag(BattlerTagType.COMMANDED); + if (commandedTag?.getSourcePokemon(switchOutTarget.scene)?.isActive(true)) { + return false; + } + if (!player && user.scene.currentBattle.isBattleMysteryEncounter() && !user.scene.currentBattle.mysteryEncounter?.fleeAllowed) { // Don't allow wild opponents to be force switched during MEs with flee disabled return false; @@ -10300,8 +10351,8 @@ export function initMoves() { new AttackMove(Moves.LUMINA_CRASH, Type.PSYCHIC, MoveCategory.SPECIAL, 80, 100, 10, 100, 0, 9) .attr(StatStageChangeAttr, [ Stat.SPDEF ], -2), new AttackMove(Moves.ORDER_UP, Type.DRAGON, MoveCategory.PHYSICAL, 80, 100, 10, 100, 0, 9) - .makesContact(false) - .partial(), // No effect implemented (requires Commander) + .attr(OrderUpStatBoostAttr) + .makesContact(false), new AttackMove(Moves.JET_PUNCH, Type.WATER, MoveCategory.PHYSICAL, 60, 100, 15, -1, 1, 9) .punchingMove(), new StatusMove(Moves.SPICY_EXTRACT, Type.GRASS, -1, 15, -1, 0, 9) diff --git a/src/data/mystery-encounters/encounters/the-winstrate-challenge-encounter.ts b/src/data/mystery-encounters/encounters/the-winstrate-challenge-encounter.ts index 7d9b531c9ab..5c1af43c426 100644 --- a/src/data/mystery-encounters/encounters/the-winstrate-challenge-encounter.ts +++ b/src/data/mystery-encounters/encounters/the-winstrate-challenge-encounter.ts @@ -23,6 +23,7 @@ import { ReturnPhase } from "#app/phases/return-phase"; import i18next from "i18next"; import { ModifierTier } from "#app/modifier/modifier-tier"; import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { BattlerTagType } from "#enums/battler-tag-type"; /** the i18n namespace for the encounter */ const namespace = "mysteryEncounters/theWinstrateChallenge"; @@ -187,6 +188,7 @@ function endTrainerBattleAndShowDialogue(scene: BattleScene): Promise { } else { scene.arena.resetArenaEffects(); const playerField = scene.getPlayerField(); + playerField.forEach((pokemon) => pokemon.lapseTag(BattlerTagType.COMMANDED)); playerField.forEach((_, p) => scene.unshiftPhase(new ReturnPhase(scene, p))); for (const pokemon of scene.getPlayerParty()) { diff --git a/src/enums/battler-tag-type.ts b/src/enums/battler-tag-type.ts index 0eace9a1607..c53157c68d4 100644 --- a/src/enums/battler-tag-type.ts +++ b/src/enums/battler-tag-type.ts @@ -88,5 +88,6 @@ export enum BattlerTagType { IMPRISON = "IMPRISON", SYRUP_BOMB = "SYRUP_BOMB", ELECTRIFIED = "ELECTRIFIED", - TELEKINESIS = "TELEKINESIS" + TELEKINESIS = "TELEKINESIS", + COMMANDED = "COMMANDED" } diff --git a/src/enums/pokemon-anim-type.ts b/src/enums/pokemon-anim-type.ts index 5a0a0c2f622..b153fb2e652 100644 --- a/src/enums/pokemon-anim-type.ts +++ b/src/enums/pokemon-anim-type.ts @@ -12,5 +12,15 @@ export enum PokemonAnimType { * Removes a Pokemon's Substitute doll from the field. * The Pokemon then moves back to its original position. */ - SUBSTITUTE_REMOVE + SUBSTITUTE_REMOVE, + /** + * Brings Tatsugiri and Dondozo to the center of the field, with + * Tatsugiri jumping into the Dondozo's mouth + */ + COMMANDER_APPLY, + /** + * Dondozo "spits out" Tatsugiri, moving Tatsugiri back to its original + * field position. + */ + COMMANDER_REMOVE } diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 78f82e929de..77881d36c56 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -1563,6 +1563,11 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * @returns `true` if the pokemon is trapped */ public isTrapped(trappedAbMessages: string[] = [], simulated: boolean = true): boolean { + const commandedTag = this.getTag(BattlerTagType.COMMANDED); + if (commandedTag?.getSourcePokemon(this.scene)?.isActive(true)) { + return true; + } + if (this.isOfType(Type.GHOST)) { return false; } @@ -2914,6 +2919,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { this.scene.unshiftPhase(new FaintPhase(this.scene, this.getBattlerIndex(), isOneHitKo)); } this.destroySubstitute(); + this.lapseTag(BattlerTagType.COMMANDED); this.resetSummonData(); } @@ -2962,6 +2968,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { this.scene.setPhaseQueueSplice(); this.scene.unshiftPhase(new FaintPhase(this.scene, this.getBattlerIndex(), preventEndure)); this.destroySubstitute(); + this.lapseTag(BattlerTagType.COMMANDED); this.resetSummonData(); } return damage; diff --git a/src/modifier/modifier.ts b/src/modifier/modifier.ts index 0891262649c..c3b1f15fab4 100644 --- a/src/modifier/modifier.ts +++ b/src/modifier/modifier.ts @@ -31,6 +31,7 @@ import i18next from "i18next"; import { type DoubleBattleChanceBoosterModifierType, type EvolutionItemModifierType, type FormChangeItemModifierType, type ModifierOverride, type ModifierType, type PokemonBaseStatTotalModifierType, type PokemonExpBoosterModifierType, type PokemonFriendshipBoosterModifierType, type PokemonMoveAccuracyBoosterModifierType, type PokemonMultiHitModifierType, type TerastallizeModifierType, type TmModifierType, getModifierType, ModifierPoolType, ModifierTypeGenerator, modifierTypes, PokemonHeldItemModifierType } from "./modifier-type"; import { Color, ShadowColor } from "#enums/color"; import { FRIENDSHIP_GAIN_FROM_RARE_CANDY } from "#app/data/balance/starters"; +import { applyAbAttrs, CommanderAbAttr } from "#app/data/ability"; export type ModifierPredicate = (modifier: Modifier) => boolean; @@ -1937,10 +1938,16 @@ export class PokemonInstantReviveModifier extends PokemonHeldItemModifier { * @returns always `true` */ override apply(pokemon: Pokemon): boolean { + // Restore the Pokemon to half HP pokemon.scene.unshiftPhase(new PokemonHealPhase(pokemon.scene, pokemon.getBattlerIndex(), toDmgValue(pokemon.getMaxHp() / 2), i18next.t("modifier:pokemonInstantReviveApply", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), typeName: this.type.name }), false, false, true)); + // Remove the Pokemon's FAINT status pokemon.resetStatus(true, false, true); + + // Reapply Commander on the Pokemon's side of the field, if applicable + const field = pokemon.isPlayer() ? pokemon.scene.getPlayerField() : pokemon.scene.getEnemyField(); + field.forEach((p) => applyAbAttrs(CommanderAbAttr, p, null, false)); return true; } diff --git a/src/phases/command-phase.ts b/src/phases/command-phase.ts index c1d38815c2f..b99eb8d1b2e 100644 --- a/src/phases/command-phase.ts +++ b/src/phases/command-phase.ts @@ -54,6 +54,11 @@ export class CommandPhase extends FieldPhase { } } + // If the Pokemon has applied Commander's effects to its ally, skip this command + if (this.scene.currentBattle?.double && this.getPokemon().getAlly()?.getTag(BattlerTagType.COMMANDED)?.getSourcePokemon(this.scene) === this.getPokemon()) { + this.scene.currentBattle.turnCommands[this.fieldIndex] = { command: Command.FIGHT, move: { move: Moves.NONE, targets: []}, skip: true }; + } + if (this.scene.currentBattle.turnCommands[this.fieldIndex]?.skip) { return this.end(); } @@ -92,7 +97,7 @@ export class CommandPhase extends FieldPhase { handleCommand(command: Command, cursor: integer, ...args: any[]): boolean { const playerPokemon = this.scene.getPlayerField()[this.fieldIndex]; - let success: boolean; + let success: boolean = false; switch (command) { case Command.FIGHT: @@ -232,11 +237,8 @@ export class CommandPhase extends FieldPhase { const trapTag = playerPokemon.getTag(TrappedTag); const fairyLockTag = playerPokemon.scene.arena.getTagOnSide(ArenaTagType.FAIRY_LOCK, ArenaTagSide.PLAYER); - // trapTag should be defined at this point, but just in case... if (!trapTag && !fairyLockTag) { - currentBattle.turnCommands[this.fieldIndex] = isSwitch - ? { command: Command.POKEMON, cursor: cursor, args: args } - : { command: Command.RUN }; + i18next.t(`battle:noEscape${isSwitch ? "Switch" : "Flee"}`); break; } if (!isSwitch) { @@ -272,11 +274,11 @@ export class CommandPhase extends FieldPhase { break; } - if (success!) { // TODO: is the bang correct? + if (success) { this.end(); } - return success!; // TODO: is the bang correct? + return success; } cancel() { diff --git a/src/phases/encounter-phase.ts b/src/phases/encounter-phase.ts index c4d919c0325..123f9ded9fc 100644 --- a/src/phases/encounter-phase.ts +++ b/src/phases/encounter-phase.ts @@ -36,6 +36,7 @@ import { PlayerGender } from "#enums/player-gender"; import { Species } from "#enums/species"; import i18next from "i18next"; import { WEIGHT_INCREMENT_ON_SPAWN_MISS } from "#app/data/mystery-encounters/mystery-encounters"; +import { BattlerTagType } from "#enums/battler-tag-type"; export class EncounterPhase extends BattlePhase { private loaded: boolean; @@ -482,6 +483,7 @@ export class EncounterPhase extends BattlePhase { } } else { if (availablePartyMembers.length > 1 && availablePartyMembers[1].isOnField()) { + this.scene.getPlayerField().forEach((pokemon) => pokemon.lapseTag(BattlerTagType.COMMANDED)); this.scene.pushPhase(new ReturnPhase(this.scene, 1)); } this.scene.pushPhase(new ToggleDoublePositionPhase(this.scene, false)); diff --git a/src/phases/enemy-command-phase.ts b/src/phases/enemy-command-phase.ts index 3647a237ef1..83a85009ae0 100644 --- a/src/phases/enemy-command-phase.ts +++ b/src/phases/enemy-command-phase.ts @@ -2,6 +2,8 @@ import BattleScene from "#app/battle-scene"; import { BattlerIndex } from "#app/battle"; import { Command } from "#app/ui/command-ui-handler"; import { FieldPhase } from "./field-phase"; +import { Abilities } from "#enums/abilities"; +import { BattlerTagType } from "#enums/battler-tag-type"; /** * Phase for determining an enemy AI's action for the next turn. @@ -34,6 +36,11 @@ export class EnemyCommandPhase extends FieldPhase { const trainer = battle.trainer; + if (battle.double && enemyPokemon.hasAbility(Abilities.COMMANDER) + && enemyPokemon.getAlly().getTag(BattlerTagType.COMMANDED)) { + this.skipTurn = true; + } + /** * If the enemy has a trainer, decide whether or not the enemy should switch * to another member in its party. diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index 62ece3769b5..48150ffaf05 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -211,11 +211,14 @@ export class MoveEffectPhase extends PokemonPhase { && (target.getAbility()?.getAttrs(TypeImmunityAbAttr)?.[0]?.getImmuneType() === user.getMoveType(move)) && !target.getTag(SemiInvulnerableTag); + /** Is the target hidden by the effects of its Commander ability? */ + const isCommanding = this.scene.currentBattle.double && target.getAlly()?.getTag(BattlerTagType.COMMANDED)?.getSourcePokemon(this.scene) === target; + /** * If the move missed a target, stop all future hits against that target * and move on to the next target (if there is one). */ - if (!isImmune && !isProtected && !targetHitChecks[target.getBattlerIndex()]) { + if (isCommanding || (!isImmune && !isProtected && !targetHitChecks[target.getBattlerIndex()])) { this.stopMultiHit(target); this.scene.queueMessage(i18next.t("battle:attackMissed", { pokemonNameWithAffix: getPokemonNameWithAffix(target) })); if (moveHistoryEntry.result === MoveResult.PENDING) { diff --git a/src/phases/mystery-encounter-phases.ts b/src/phases/mystery-encounter-phases.ts index e7d1f7e9074..2d1c3c4ae31 100644 --- a/src/phases/mystery-encounter-phases.ts +++ b/src/phases/mystery-encounter-phases.ts @@ -417,6 +417,7 @@ export class MysteryEncounterBattlePhase extends Phase { } } else { if (availablePartyMembers.length > 1 && availablePartyMembers[1].isOnField()) { + scene.getPlayerField().forEach((pokemon) => pokemon.lapseTag(BattlerTagType.COMMANDED)); scene.pushPhase(new ReturnPhase(scene, 1)); } scene.pushPhase(new ToggleDoublePositionPhase(scene, false)); diff --git a/src/phases/pokemon-anim-phase.ts b/src/phases/pokemon-anim-phase.ts index 26ae11d1026..e9e1129f158 100644 --- a/src/phases/pokemon-anim-phase.ts +++ b/src/phases/pokemon-anim-phase.ts @@ -3,6 +3,7 @@ import { SubstituteTag } from "#app/data/battler-tags"; import { PokemonAnimType } from "#enums/pokemon-anim-type"; import Pokemon from "#app/field/pokemon"; import { BattlePhase } from "#app/phases/battle-phase"; +import { Species } from "#enums/species"; export class PokemonAnimPhase extends BattlePhase { @@ -37,12 +38,18 @@ export class PokemonAnimPhase extends BattlePhase { case PokemonAnimType.SUBSTITUTE_REMOVE: this.doSubstituteRemoveAnim(); break; + case PokemonAnimType.COMMANDER_APPLY: + this.doCommanderApplyAnim(); + break; + case PokemonAnimType.COMMANDER_REMOVE: + this.doCommanderRemoveAnim(); + break; default: this.end(); } } - doSubstituteAddAnim(): void { + private doSubstituteAddAnim(): void { const substitute = this.pokemon.getTag(SubstituteTag); if (substitute === null) { return this.end(); @@ -106,7 +113,7 @@ export class PokemonAnimPhase extends BattlePhase { }); } - doSubstitutePreMoveAnim(): void { + private doSubstitutePreMoveAnim(): void { if (this.fieldAssets.length !== 1) { return this.end(); } @@ -135,7 +142,7 @@ export class PokemonAnimPhase extends BattlePhase { }); } - doSubstitutePostMoveAnim(): void { + private doSubstitutePostMoveAnim(): void { if (this.fieldAssets.length !== 1) { return this.end(); } @@ -164,7 +171,7 @@ export class PokemonAnimPhase extends BattlePhase { }); } - doSubstituteRemoveAnim(): void { + private doSubstituteRemoveAnim(): void { if (this.fieldAssets.length !== 1) { return this.end(); } @@ -233,4 +240,121 @@ export class PokemonAnimPhase extends BattlePhase { } }); } + + private doCommanderApplyAnim(): void { + if (!this.scene.currentBattle?.double) { + return this.end(); + } + const dondozo = this.pokemon.getAlly(); + + if (dondozo?.species?.speciesId !== Species.DONDOZO) { + return this.end(); + } + + const tatsugiriX = this.pokemon.x + this.pokemon.getSprite().x; + const tatsugiriY = this.pokemon.y + this.pokemon.getSprite().y; + + const getSourceSprite = () => { + const sprite = this.scene.addPokemonSprite(this.pokemon, tatsugiriX, tatsugiriY, this.pokemon.getSprite().texture, this.pokemon.getSprite()!.frame.name, true); + [ "spriteColors", "fusionSpriteColors" ].map(k => sprite.pipelineData[k] = this.pokemon.getSprite().pipelineData[k]); + sprite.setPipelineData("spriteKey", this.pokemon.getBattleSpriteKey()); + sprite.setPipelineData("shiny", this.pokemon.shiny); + sprite.setPipelineData("variant", this.pokemon.variant); + sprite.setPipelineData("ignoreFieldPos", true); + sprite.setOrigin(0.5, 1); + this.pokemon.getSprite().on("animationupdate", (_anim, frame) => sprite.setFrame(frame.textureFrame)); + this.scene.field.add(sprite); + return sprite; + }; + + const sourceSprite = getSourceSprite(); + + this.pokemon.setVisible(false); + + const sourceFpOffset = this.pokemon.getFieldPositionOffset(); + const dondozoFpOffset = dondozo.getFieldPositionOffset(); + + this.scene.playSound("se/pb_throw"); + + this.scene.tweens.add({ + targets: sourceSprite, + duration: 375, + scale: 0.5, + x: { value: tatsugiriX + (dondozoFpOffset[0] - sourceFpOffset[0]) / 2, ease: "Linear" }, + y: { value: (this.pokemon.isPlayer() ? 100 : 65) + sourceFpOffset[1], ease: "Sine.easeOut" }, + onComplete: () => { + this.scene.field.bringToTop(dondozo); + this.scene.tweens.add({ + targets: sourceSprite, + duration: 375, + scale: 0.01, + x: { value: dondozo.x, ease: "Linear" }, + y: { value: dondozo.y + dondozo.height / 2, ease: "Sine.easeIn" }, + onComplete: () => { + sourceSprite.destroy(); + this.scene.playSound("battle_anims/PRSFX- Liquidation1.wav"); + this.scene.tweens.add({ + targets: dondozo, + duration: 250, + ease: "Sine.easeInOut", + scale: 0.85, + yoyo: true, + onComplete: () => this.end() + }); + } + }); + } + }); + } + + private doCommanderRemoveAnim(): void { + // Note: unlike the other Commander animation, this is played through the + // Dondozo instead of the Tatsugiri. + const tatsugiri = this.pokemon.getAlly(); + + const tatsuSprite = this.scene.addPokemonSprite( + tatsugiri, + this.pokemon.x + this.pokemon.getSprite().x, + this.pokemon.y + this.pokemon.getSprite().y + this.pokemon.height / 2, + tatsugiri.getSprite().texture, + tatsugiri.getSprite()!.frame.name, + true + ); + [ "spriteColors", "fusionSpriteColors" ].map(k => tatsuSprite.pipelineData[k] = tatsugiri.getSprite().pipelineData[k]); + tatsuSprite.setPipelineData("spriteKey", tatsugiri.getBattleSpriteKey()); + tatsuSprite.setPipelineData("shiny", tatsugiri.shiny); + tatsuSprite.setPipelineData("variant", tatsugiri.variant); + tatsuSprite.setPipelineData("ignoreFieldPos", true); + this.pokemon.getSprite().on("animationupdate", (_anim, frame) => tatsuSprite.setFrame(frame.textureFrame)); + + tatsuSprite.setOrigin(0.5, 1); + tatsuSprite.setScale(0.01); + + this.scene.field.add(tatsuSprite); + this.scene.field.bringToTop(this.pokemon); + tatsuSprite.setVisible(true); + + this.scene.tweens.add({ + targets: this.pokemon, + duration: 250, + ease: "Sine.easeInOut", + scale: 1.15, + yoyo: true, + onComplete: () => { + this.scene.playSound("battle_anims/PRSFX- Liquidation4.wav"); + this.scene.tweens.add({ + targets: tatsuSprite, + duration: 500, + scale: 1, + x: { value: tatsugiri.x + tatsugiri.getSprite().x, ease: "Linear" }, + y: { value: tatsugiri.y + tatsugiri.getSprite().y, ease: "Sine.easeIn" }, + onComplete: () => { + tatsugiri.setVisible(true); + tatsuSprite.destroy(); + this.end(); + } + }); + } + }); + } } diff --git a/src/phases/post-summon-phase.ts b/src/phases/post-summon-phase.ts index 3db98d9926c..644a6235a42 100644 --- a/src/phases/post-summon-phase.ts +++ b/src/phases/post-summon-phase.ts @@ -1,6 +1,6 @@ import BattleScene from "#app/battle-scene"; import { BattlerIndex } from "#app/battle"; -import { applyPostSummonAbAttrs, PostSummonAbAttr } from "#app/data/ability"; +import { applyAbAttrs, applyPostSummonAbAttrs, CommanderAbAttr, PostSummonAbAttr } from "#app/data/ability"; import { ArenaTrapTag } from "#app/data/arena-tag"; import { StatusEffect } from "#app/enums/status-effect"; import { PokemonPhase } from "./pokemon-phase"; @@ -28,5 +28,8 @@ export class PostSummonPhase extends PokemonPhase { } applyPostSummonAbAttrs(PostSummonAbAttr, pokemon).then(() => this.end()); + + const field = pokemon.isPlayer() ? this.scene.getPlayerField() : this.scene.getEnemyField(); + field.forEach((p) => applyAbAttrs(CommanderAbAttr, p, null, false)); } } diff --git a/src/test/abilities/commander.test.ts b/src/test/abilities/commander.test.ts new file mode 100644 index 00000000000..99b3d50797c --- /dev/null +++ b/src/test/abilities/commander.test.ts @@ -0,0 +1,224 @@ +import { BattlerIndex } from "#app/battle"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { PokemonAnimType } from "#enums/pokemon-anim-type"; +import { EffectiveStat, Stat } from "#enums/stat"; +import { StatusEffect } from "#enums/status-effect"; +import { WeatherType } from "#enums/weather-type"; +import { MoveResult } from "#app/field/pokemon"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("Abilities - Commander", () => { + 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 + .startingLevel(100) + .enemyLevel(100) + .moveset([ Moves.LIQUIDATION, Moves.MEMENTO, Moves.SPLASH, Moves.FLIP_TURN ]) + .ability(Abilities.COMMANDER) + .battleType("double") + .disableCrits() + .enemySpecies(Species.SNORLAX) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.TACKLE); + + vi.spyOn(game.scene, "triggerPokemonBattleAnim").mockReturnValue(true); + }); + + it("causes the source to jump into Dondozo's mouth, granting a stat boost and hiding the source", async () => { + await game.classicMode.startBattle([ Species.TATSUGIRI, Species.DONDOZO ]); + + const [ tatsugiri, dondozo ] = game.scene.getPlayerField(); + + const affectedStats: EffectiveStat[] = [ Stat.ATK, Stat.DEF, Stat.SPATK, Stat.SPDEF, Stat.SPD ]; + + expect(game.scene.triggerPokemonBattleAnim).toHaveBeenLastCalledWith(tatsugiri, PokemonAnimType.COMMANDER_APPLY); + expect(dondozo.getTag(BattlerTagType.COMMANDED)).toBeDefined(); + affectedStats.forEach((stat) => expect(dondozo.getStatStage(stat)).toBe(2)); + + game.move.select(Moves.SPLASH, 1); + + expect(game.scene.currentBattle.turnCommands[0]?.skip).toBeTruthy(); + + // Force both enemies to target the Tatsugiri + await game.forceEnemyMove(Moves.TACKLE, BattlerIndex.PLAYER); + await game.forceEnemyMove(Moves.TACKLE, BattlerIndex.PLAYER); + + await game.phaseInterceptor.to("BerryPhase", false); + game.scene.getEnemyField().forEach(enemy => expect(enemy.getLastXMoves(1)[0].result).toBe(MoveResult.MISS)); + expect(tatsugiri.isFullHp()).toBeTruthy(); + }); + + it("should activate when a Dondozo switches in and cancel the source's move", async () => { + game.override.enemyMoveset(Moves.SPLASH); + + await game.classicMode.startBattle([ Species.TATSUGIRI, Species.MAGIKARP, Species.DONDOZO ]); + + const tatsugiri = game.scene.getPlayerField()[0]; + + game.move.select(Moves.LIQUIDATION, 0, BattlerIndex.ENEMY); + game.doSwitchPokemon(2); + + await game.phaseInterceptor.to("MovePhase", false); + expect(game.scene.triggerPokemonBattleAnim).toHaveBeenCalledWith(tatsugiri, PokemonAnimType.COMMANDER_APPLY); + + const dondozo = game.scene.getPlayerField()[1]; + expect(dondozo.getTag(BattlerTagType.COMMANDED)).toBeDefined(); + + await game.phaseInterceptor.to("BerryPhase", false); + expect(tatsugiri.getMoveHistory()).toHaveLength(0); + expect(game.scene.getEnemyField()[0].isFullHp()).toBeTruthy(); + }); + + it("source should reenter the field when Dondozo faints", async () => { + await game.classicMode.startBattle([ Species.TATSUGIRI, Species.DONDOZO ]); + + const [ tatsugiri, dondozo ] = game.scene.getPlayerField(); + + expect(game.scene.triggerPokemonBattleAnim).toHaveBeenLastCalledWith(tatsugiri, PokemonAnimType.COMMANDER_APPLY); + expect(dondozo.getTag(BattlerTagType.COMMANDED)).toBeDefined(); + + game.move.select(Moves.MEMENTO, 1, BattlerIndex.ENEMY); + + expect(game.scene.currentBattle.turnCommands[0]?.skip).toBeTruthy(); + + await game.forceEnemyMove(Moves.TACKLE, BattlerIndex.PLAYER); + await game.forceEnemyMove(Moves.TACKLE, BattlerIndex.PLAYER); + + await game.setTurnOrder([ BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER ]); + + await game.phaseInterceptor.to("FaintPhase", false); + expect(dondozo.getTag(BattlerTagType.COMMANDED)).toBeUndefined(); + expect(game.scene.triggerPokemonBattleAnim).toHaveBeenLastCalledWith(dondozo, PokemonAnimType.COMMANDER_REMOVE); + + await game.phaseInterceptor.to("BerryPhase", false); + expect(tatsugiri.isFullHp()).toBeFalsy(); + }); + + it("source should still take damage from Poison while hidden", async () => { + game.override + .statusEffect(StatusEffect.POISON) + .enemyMoveset(Moves.SPLASH); + + await game.classicMode.startBattle([ Species.TATSUGIRI, Species.DONDOZO ]); + + const [ tatsugiri, dondozo ] = game.scene.getPlayerField(); + + expect(game.scene.triggerPokemonBattleAnim).toHaveBeenLastCalledWith(tatsugiri, PokemonAnimType.COMMANDER_APPLY); + expect(dondozo.getTag(BattlerTagType.COMMANDED)).toBeDefined(); + + game.move.select(Moves.SPLASH, 1); + + expect(game.scene.currentBattle.turnCommands[0]?.skip).toBeTruthy(); + + await game.phaseInterceptor.to("TurnEndPhase"); + expect(tatsugiri.isFullHp()).toBeFalsy(); + }); + + it("source should still take damage from Salt Cure while hidden", async () => { + game.override.enemyMoveset(Moves.SPLASH); + + await game.classicMode.startBattle([ Species.TATSUGIRI, Species.DONDOZO ]); + + const [ tatsugiri, dondozo ] = game.scene.getPlayerField(); + + expect(game.scene.triggerPokemonBattleAnim).toHaveBeenLastCalledWith(tatsugiri, PokemonAnimType.COMMANDER_APPLY); + expect(dondozo.getTag(BattlerTagType.COMMANDED)).toBeDefined(); + + tatsugiri.addTag(BattlerTagType.SALT_CURED, 0, Moves.NONE, game.scene.getField()[BattlerIndex.ENEMY].id); + + game.move.select(Moves.SPLASH, 1); + + expect(game.scene.currentBattle.turnCommands[0]?.skip).toBeTruthy(); + + await game.phaseInterceptor.to("TurnEndPhase"); + expect(tatsugiri.isFullHp()).toBeFalsy(); + }); + + it("source should still take damage from Sandstorm while hidden", async () => { + game.override + .weather(WeatherType.SANDSTORM) + .enemyMoveset(Moves.SPLASH); + + await game.classicMode.startBattle([ Species.TATSUGIRI, Species.DONDOZO ]); + + const [ tatsugiri, dondozo ] = game.scene.getPlayerField(); + + expect(game.scene.triggerPokemonBattleAnim).toHaveBeenLastCalledWith(tatsugiri, PokemonAnimType.COMMANDER_APPLY); + expect(dondozo.getTag(BattlerTagType.COMMANDED)).toBeDefined(); + + game.move.select(Moves.SPLASH, 1); + + expect(game.scene.currentBattle.turnCommands[0]?.skip).toBeTruthy(); + + await game.phaseInterceptor.to("TurnEndPhase"); + expect(tatsugiri.isFullHp()).toBeFalsy(); + }); + + it("should make Dondozo immune to being forced out", async () => { + game.override.enemyMoveset([ Moves.SPLASH, Moves.WHIRLWIND ]); + + await game.classicMode.startBattle([ Species.TATSUGIRI, Species.DONDOZO ]); + + const [ tatsugiri, dondozo ] = game.scene.getPlayerField(); + + expect(game.scene.triggerPokemonBattleAnim).toHaveBeenLastCalledWith(tatsugiri, PokemonAnimType.COMMANDER_APPLY); + expect(dondozo.getTag(BattlerTagType.COMMANDED)).toBeDefined(); + + game.move.select(Moves.SPLASH, 1); + + expect(game.scene.currentBattle.turnCommands[0]?.skip).toBeTruthy(); + + await game.forceEnemyMove(Moves.WHIRLWIND, BattlerIndex.PLAYER_2); + await game.forceEnemyMove(Moves.SPLASH); + + // Test may time out here if Whirlwind forced out a Pokemon + await game.phaseInterceptor.to("TurnEndPhase"); + expect(dondozo.isActive(true)).toBeTruthy(); + }); + + it("should interrupt the source's semi-invulnerability", async () => { + game.override + .moveset([ Moves.SPLASH, Moves.DIVE ]) + .enemyMoveset(Moves.SPLASH); + + await game.classicMode.startBattle([ Species.TATSUGIRI, Species.MAGIKARP, Species.DONDOZO ]); + + const tatsugiri = game.scene.getPlayerField()[0]; + + game.move.select(Moves.DIVE, 0, BattlerIndex.ENEMY); + game.move.select(Moves.SPLASH, 1); + + await game.phaseInterceptor.to("CommandPhase"); + await game.toNextTurn(); + + expect(tatsugiri.getTag(BattlerTagType.UNDERWATER)).toBeDefined(); + game.doSwitchPokemon(2); + + await game.phaseInterceptor.to("MovePhase", false); + const dondozo = game.scene.getPlayerField()[1]; + expect(tatsugiri.getTag(BattlerTagType.UNDERWATER)).toBeUndefined(); + expect(dondozo.getTag(BattlerTagType.COMMANDED)).toBeDefined(); + + await game.toNextTurn(); + const enemy = game.scene.getEnemyField()[0]; + expect(enemy.isFullHp()).toBeTruthy(); + }); +}); diff --git a/src/test/moves/order_up.test.ts b/src/test/moves/order_up.test.ts new file mode 100644 index 00000000000..d0b52dc1a9d --- /dev/null +++ b/src/test/moves/order_up.test.ts @@ -0,0 +1,85 @@ +import { BattlerIndex } from "#app/battle"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { PokemonAnimType } from "#enums/pokemon-anim-type"; +import { EffectiveStat, Stat } from "#enums/stat"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("Moves - Order Up", () => { + 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 + .moveset(Moves.ORDER_UP) + .ability(Abilities.COMMANDER) + .battleType("double") + .disableCrits() + .enemySpecies(Species.SNORLAX) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH) + .startingLevel(100) + .enemyLevel(100); + + vi.spyOn(game.scene, "triggerPokemonBattleAnim").mockReturnValue(true); + }); + + it.each([ + { formIndex: 0, formName: "Curly", stat: Stat.ATK, statName: "Attack" }, + { formIndex: 1, formName: "Droopy", stat: Stat.DEF, statName: "Defense" }, + { formIndex: 2, formName: "Stretchy", stat: Stat.SPD, statName: "Speed" } + ])("should raise the user's $statName when the user is commanded by a $formName Tatsugiri", async ({ formIndex, stat }) => { + game.override.starterForms({ [Species.TATSUGIRI]: formIndex }); + + await game.classicMode.startBattle([ Species.TATSUGIRI, Species.DONDOZO ]); + + const [ tatsugiri, dondozo ] = game.scene.getPlayerField(); + + expect(game.scene.triggerPokemonBattleAnim).toHaveBeenLastCalledWith(tatsugiri, PokemonAnimType.COMMANDER_APPLY); + expect(dondozo.getTag(BattlerTagType.COMMANDED)).toBeDefined(); + + game.move.select(Moves.ORDER_UP, 1, BattlerIndex.ENEMY); + expect(game.scene.currentBattle.turnCommands[0]?.skip).toBeTruthy(); + + await game.phaseInterceptor.to("BerryPhase", false); + + const affectedStats: EffectiveStat[] = [ Stat.ATK, Stat.DEF, Stat.SPATK, Stat.SPDEF, Stat.SPD ]; + affectedStats.forEach(st => expect(dondozo.getStatStage(st)).toBe(st === stat ? 3 : 2)); + }); + + it("should be boosted by Sheer Force while still applying a stat boost", async () => { + game.override + .passiveAbility(Abilities.SHEER_FORCE) + .starterForms({ [Species.TATSUGIRI]: 0 }); + + await game.classicMode.startBattle([ Species.TATSUGIRI, Species.DONDOZO ]); + + const [ tatsugiri, dondozo ] = game.scene.getPlayerField(); + + expect(game.scene.triggerPokemonBattleAnim).toHaveBeenLastCalledWith(tatsugiri, PokemonAnimType.COMMANDER_APPLY); + expect(dondozo.getTag(BattlerTagType.COMMANDED)).toBeDefined(); + + game.move.select(Moves.ORDER_UP, 1, BattlerIndex.ENEMY); + expect(game.scene.currentBattle.turnCommands[0]?.skip).toBeTruthy(); + + await game.phaseInterceptor.to("BerryPhase", false); + + expect(dondozo.battleData.abilitiesApplied.includes(Abilities.SHEER_FORCE)).toBeTruthy(); + expect(dondozo.getStatStage(Stat.ATK)).toBe(3); + }); +});