diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 721b55020f9..c46db878a00 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -884,8 +884,8 @@ export default class BattleScene extends SceneBase { } // store info toggles to be accessible by the ui - addInfoToggle(infoToggle: InfoToggle): void { - this.infoToggles.push(infoToggle); + addInfoToggle(...infoToggles: InfoToggle[]): void { + this.infoToggles.push(...infoToggles); } // return the stored info toggles; used by ui-inputs diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index 794becbe1c5..a8ba791f0d6 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -1610,37 +1610,38 @@ export class MoveTypeChangeAbAttr extends PreAttackAbAttr { } } -/** Ability attribute for changing a pokemon's type before using a move */ +/** + * Attribute to change the user's type to that of the move currently being executed. + * Used by {@linkcode AbilityId.PROTEAN} and {@linkcode AbilityId.LIBERO}. + */ export class PokemonTypeChangeAbAttr extends PreAttackAbAttr { - private moveType: PokemonType; - + private moveType: PokemonType = PokemonType.UNKNOWN; constructor() { super(true); } override canApply({ move, pokemon }: AugmentMoveInteractionAbAttrParams): boolean { if ( - !pokemon.isTerastallized && - move.id !== MoveId.STRUGGLE && - /** + pokemon.isTerastallized || + move.id === MoveId.STRUGGLE || + /* * Skip moves that call other moves because these moves generate a following move that will trigger this ability attribute - * @see {@link https://bulbapedia.bulbagarden.net/wiki/Category:Moves_that_call_other_moves} + * See: https://bulbapedia.bulbagarden.net/wiki/Category:Moves_that_call_other_moves */ - !move.findAttr( - attr => - attr.is("RandomMovesetMoveAttr") || - attr.is("RandomMoveAttr") || - attr.is("NaturePowerAttr") || - attr.is("CopyMoveAttr"), - ) + move.hasAttr("CallMoveAttr") || + move.hasAttr("NaturePowerAttr") // TODO: remove this line when nature power is made to extend from `CallMoveAttr` ) { - const moveType = pokemon.getMoveType(move); - if (pokemon.getTypes().some(t => t !== moveType)) { - this.moveType = moveType; - return true; - } + return false; } - return false; + + // Skip changing type if we're already of the given type as-is + const moveType = pokemon.getMoveType(move); + if (pokemon.getTypes().every(t => t === moveType)) { + return false; + } + + this.moveType = moveType; + return true; } override apply({ simulated, pokemon, move }: AugmentMoveInteractionAbAttrParams): void { @@ -7190,8 +7191,10 @@ export function initAbilities() { new Ability(AbilityId.CHEEK_POUCH, 6) .attr(HealFromBerryUseAbAttr, 1 / 3), new Ability(AbilityId.PROTEAN, 6) - .attr(PokemonTypeChangeAbAttr), - //.condition((p) => !p.summonData.abilitiesApplied.includes(AbilityId.PROTEAN)), //Gen 9 Implementation + .attr(PokemonTypeChangeAbAttr) + // .condition((p) => !p.summonData.abilitiesApplied.includes(Abilities.PROTEAN)) //Gen 9 Implementation + // TODO: needs testing on interaction with weather blockage + .edgeCase(), new Ability(AbilityId.FUR_COAT, 6) .attr(ReceivedMoveDamageMultiplierAbAttr, (_target, _user, move) => move.category === MoveCategory.PHYSICAL, 0.5) .ignorable(), @@ -7443,8 +7446,10 @@ export function initAbilities() { new Ability(AbilityId.DAUNTLESS_SHIELD, 8) .attr(PostSummonStatStageChangeAbAttr, [ Stat.DEF ], 1, true), new Ability(AbilityId.LIBERO, 8) - .attr(PokemonTypeChangeAbAttr), - //.condition((p) => !p.summonData.abilitiesApplied.includes(AbilityId.LIBERO)), //Gen 9 Implementation + .attr(PokemonTypeChangeAbAttr) + //.condition((p) => !p.summonData.abilitiesApplied.includes(Abilities.LIBERO)), //Gen 9 Implementation + // TODO: needs testing on interaction with weather blockage + .edgeCase(), new Ability(AbilityId.BALL_FETCH, 8) .attr(FetchBallAbAttr) .condition(getOncePerBattleCondition(AbilityId.BALL_FETCH)), diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 925212ab87a..540075c8e4f 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -681,13 +681,7 @@ export default abstract class Move implements Localizable { * @returns boolean: false if any of the apply()'s return false, else true */ applyConditions(user: Pokemon, target: Pokemon, move: Move): boolean { - for (const condition of this.conditions) { - if (!condition.apply(user, target, move)) { - return false; - } - } - - return true; + return this.conditions.every(cond => cond.apply(user, target, move)); } /** @@ -4160,30 +4154,6 @@ export class OpponentHighHpPowerAttr extends VariablePowerAttr { } } -/** - * Attribute to double this move's power if the target hasn't acted yet in the current turn. - * Used by {@linkcode Moves.BOLT_BEAK} and {@linkcode Moves.FISHIOUS_REND} - */ -export class FirstAttackDoublePowerAttr extends VariablePowerAttr { - /** - * Double this move's power if the user is acting before the target. - * @param user - Unused - * @param target - The {@linkcode Pokemon} being targeted by this move - * @param move - Unused - * @param args `[0]` - A {@linkcode NumberHolder} containing move base power - * @returns Whether the attribute was successfully applied - */ - apply(_user: Pokemon, target: Pokemon, move: Move, args: [NumberHolder]): boolean { - if (target.turnData.acted) { - return false; - } - - args[0].value *= 2; - return true; - } -} - - export class TurnDamagedDoublePowerAttr extends VariablePowerAttr { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { if (user.turnData.attacksReceived.find(r => r.damage && r.sourceId === target.id)) { @@ -8367,7 +8337,6 @@ const MoveAttrs = Object.freeze({ CompareWeightPowerAttr, HpPowerAttr, OpponentHighHpPowerAttr, - FirstAttackDoublePowerAttr, TurnDamagedDoublePowerAttr, MagnitudePowerAttr, AntiSunlightPowerDecreaseAttr, @@ -9670,7 +9639,8 @@ export function initMoves() { new AttackMove(MoveId.CLOSE_COMBAT, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 120, 100, 5, -1, 0, 4) .attr(StatStageChangeAttr, [ Stat.DEF, Stat.SPDEF ], -1, true), new AttackMove(MoveId.PAYBACK, PokemonType.DARK, MoveCategory.PHYSICAL, 50, 100, 10, -1, 0, 4) - .attr(MovePowerMultiplierAttr, (user, target, move) => target.getLastXMoves(1).find(m => m.turn === globalScene.currentBattle.turn) || globalScene.currentBattle.turnCommands[target.getBattlerIndex()]?.command === Command.BALL ? 2 : 1), + // Payback boosts power on item use + .attr(MovePowerMultiplierAttr, (_user, target) => target.turnData.acted || globalScene.currentBattle.turnCommands[target.getBattlerIndex()]?.command === Command.BALL ? 2 : 1), new AttackMove(MoveId.ASSURANCE, PokemonType.DARK, MoveCategory.PHYSICAL, 60, 100, 10, -1, 0, 4) .attr(MovePowerMultiplierAttr, (user, target, move) => target.turnData.damageTaken > 0 ? 2 : 1), new StatusMove(MoveId.EMBARGO, PokemonType.DARK, 100, 15, -1, 0, 4) @@ -10876,9 +10846,9 @@ export function initMoves() { .condition(failIfGhostTypeCondition) .attr(AddBattlerTagAttr, BattlerTagType.OCTOLOCK, false, true, 1), new AttackMove(MoveId.BOLT_BEAK, PokemonType.ELECTRIC, MoveCategory.PHYSICAL, 85, 100, 10, -1, 0, 8) - .attr(FirstAttackDoublePowerAttr), + .attr(MovePowerMultiplierAttr, (_user, target) => target.turnData.acted ? 1 : 2), new AttackMove(MoveId.FISHIOUS_REND, PokemonType.WATER, MoveCategory.PHYSICAL, 85, 100, 10, -1, 0, 8) - .attr(FirstAttackDoublePowerAttr) + .attr(MovePowerMultiplierAttr, (_user, target) => target.turnData.acted ? 1 : 2) .bitingMove(), new StatusMove(MoveId.COURT_CHANGE, PokemonType.NORMAL, 100, 10, -1, 0, 8) .attr(SwapArenaTagsAttr, [ ArenaTagType.AURORA_VEIL, ArenaTagType.LIGHT_SCREEN, ArenaTagType.MIST, ArenaTagType.REFLECT, ArenaTagType.SPIKES, ArenaTagType.STEALTH_ROCK, ArenaTagType.STICKY_WEB, ArenaTagType.TAILWIND, ArenaTagType.TOXIC_SPIKES ]), diff --git a/src/phases/attempt-capture-phase.ts b/src/phases/attempt-capture-phase.ts index 51c167d3c28..ed4e7fd7749 100644 --- a/src/phases/attempt-capture-phase.ts +++ b/src/phases/attempt-capture-phase.ts @@ -25,6 +25,7 @@ import i18next from "i18next"; import { globalScene } from "#app/global-scene"; import { Gender } from "#app/data/gender"; +// TODO: Refactor and split up to allow for overriding capture chance export class AttemptCapturePhase extends PokemonPhase { public readonly phaseName = "AttemptCapturePhase"; private pokeballType: PokeballType; diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index f8db7e6b2ff..f0bcc771bb8 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -6,7 +6,6 @@ import { CommonAnim } from "#enums/move-anims-common"; import { CenterOfAttentionTag } from "#app/data/battler-tags"; import { BattlerTagLapseType } from "#enums/battler-tag-lapse-type"; import { applyMoveAttrs } from "#app/data/moves/apply-attrs"; -import { allMoves } from "#app/data/data-lists"; import { MoveFlags } from "#enums/MoveFlags"; import { SpeciesFormChangePreMoveTrigger } from "#app/data/pokemon-forms/form-change-triggers"; import { getStatusEffectActivationText, getStatusEffectHealText } from "#app/data/status-effect"; @@ -36,11 +35,11 @@ export class MovePhase extends BattlePhase { protected _move: PokemonMove; protected _targets: BattlerIndex[]; public readonly useMode: MoveUseMode; // Made public for quash + /** Whether the current move is forced last (used for Quash). */ protected forcedLast: boolean; - - /** Whether the current move should fail but still use PP */ + /** Whether the current move should fail but still use PP. */ protected failed = false; - /** Whether the current move should cancel and retain PP */ + /** Whether the current move should fail and retain PP. */ protected cancelled = false; public get pokemon(): Pokemon { @@ -165,6 +164,7 @@ export class MovePhase extends BattlePhase { this.resolveFinalPreMoveCancellationChecks(); } + // Cancel, charge or use the move as applicable. if (this.cancelled || this.failed) { this.handlePreMoveFailures(); } else if (this.move.getMove().isChargingMove() && !this.pokemon.getTag(BattlerTagType.CHARGING)) { @@ -282,8 +282,7 @@ export class MovePhase extends BattlePhase { protected lapsePreMoveAndMoveTags(): void { this.pokemon.lapseTags(BattlerTagLapseType.PRE_MOVE); - // TODO: does this intentionally happen before the no targets/MoveId.NONE on queue cancellation case is checked? - // (In other words, check if truant can proc on a move w/o targets) + // This intentionally happens before moves without targets are cancelled (Truant takes priority over lack of targets) if (!isIgnoreStatus(this.useMode) && this.canMove() && !this.cancelled) { this.pokemon.lapseTags(BattlerTagLapseType.MOVE); } @@ -297,44 +296,27 @@ export class MovePhase extends BattlePhase { // form changes happen even before we know that the move wll execute. globalScene.triggerPokemonFormChange(this.pokemon, SpeciesFormChangePreMoveTrigger); - const isDelayedAttack = move.hasAttr("DelayedAttackAttr"); - if (isDelayedAttack) { - // Check the player side arena if future sight is active - const futureSightTags = globalScene.arena.findTags(t => t.tagType === ArenaTagType.FUTURE_SIGHT); - const doomDesireTags = globalScene.arena.findTags(t => t.tagType === ArenaTagType.DOOM_DESIRE); - let fail = false; + // Check the player side arena if another delayed attack is active and hitting the same slot. + if (move.hasAttr("DelayedAttackAttr")) { const currentTargetIndex = targets[0].getBattlerIndex(); - for (const tag of futureSightTags) { - if ((tag as DelayedAttackTag).targetIndex === currentTargetIndex) { - fail = true; - break; - } - } - for (const tag of doomDesireTags) { - if ((tag as DelayedAttackTag).targetIndex === currentTargetIndex) { - fail = true; - break; - } - } - if (fail) { - this.showMoveText(); - this.showFailedText(); - this.end(); + const delayedAttackHittingSameSlot = globalScene.arena.tags.some( + tag => + (tag.tagType === ArenaTagType.FUTURE_SIGHT || tag.tagType === ArenaTagType.DOOM_DESIRE) && + (tag as DelayedAttackTag).targetIndex === currentTargetIndex, + ); + + if (delayedAttackHittingSameSlot) { + this.failMove(true); return; } } - let success = true; - // Check if there are any attributes that can interrupt the move, overriding the fail message. - for (const move of this.move.getMove().getAttrs("PreUseInterruptAttr")) { - if (move.apply(this.pokemon, targets[0], this.move.getMove())) { - success = false; - break; - } - } - - if (success) { - this.showMoveText(); + // Check if the move has any attributes that can interrupt its own use **before** displaying text. + // TODO: This should not rely on direct return values + let failed = move.getAttrs("PreUseInterruptAttr").some(attr => attr.apply(this.pokemon, targets[0], move)); + if (failed) { + this.failMove(false); + return; } // Clear out any two turn moves once they've been used. @@ -342,6 +324,7 @@ export class MovePhase extends BattlePhase { // Move queues should be handled by the calling `CommandPhase` or a manager for it // @ts-expect-error - useMode is readonly and shouldn't normally be assigned to this.useMode = moveQueue.shift()?.useMode ?? this.useMode; + if (this.pokemon.getTag(BattlerTagType.CHARGING)?.sourceMove === this.move.moveId) { this.pokemon.lapseTag(BattlerTagType.CHARGING); } @@ -349,136 +332,145 @@ export class MovePhase extends BattlePhase { if (!isIgnorePP(this.useMode)) { // "commit" to using the move, deducting PP. const ppUsed = 1 + this.getPpIncreaseFromPressure(targets); - this.move.usePp(ppUsed); - globalScene.eventTarget.dispatchEvent(new MoveUsedEvent(this.pokemon?.id, move, this.move.ppUsed)); + globalScene.eventTarget.dispatchEvent(new MoveUsedEvent(this.pokemon.id, move, this.move.ppUsed)); } /** * Determine if the move is successful (meaning that its damage/effects can be attempted) * by checking that all of the following are true: * - Conditional attributes of the move are all met - * - The target's `ForceSwitchOutImmunityAbAttr` is not triggered (see {@linkcode Move.prototype.applyConditions}) * - Weather does not block the move * - Terrain does not block the move - * - * TODO: These steps are straightforward, but the implementation below is extremely convoluted. */ /** * Move conditions assume the move has a single target * TODO: is this sustainable? */ - let failedDueToTerrain = false; - let failedDueToWeather = false; - if (success) { - const passesConditions = move.applyConditions(this.pokemon, targets[0], move); - failedDueToWeather = globalScene.arena.isMoveWeatherCancelled(this.pokemon, move); - failedDueToTerrain = globalScene.arena.isMoveTerrainCancelled(this.pokemon, this.targets, move); - success = passesConditions && !failedDueToWeather && !failedDueToTerrain; + const failsConditions = !move.applyConditions(this.pokemon, targets[0], move); + const failedDueToWeather = globalScene.arena.isMoveWeatherCancelled(this.pokemon, move); + const failedDueToTerrain = globalScene.arena.isMoveTerrainCancelled(this.pokemon, this.targets, move); + failed ||= failsConditions || failedDueToWeather || failedDueToTerrain; + + if (failed) { + this.failMove(true, failedDueToWeather, failedDueToTerrain); + return; } - // Update the battle's "last move" pointer, unless we're currently mimicking a move. - if (!allMoves[this.move.moveId].hasAttr("CopyMoveAttr")) { - // The last move used is unaffected by moves that fail - if (success) { - globalScene.currentBattle.lastMove = this.move.moveId; - } + this.executeMove(); + } + + /** Execute the current move and apply its effects. */ + private executeMove() { + const move = this.move.getMove(); + const targets = this.getActiveTargetPokemon(); + + // Update the battle's "last move" pointer unless we're currently mimicking a move or triggering Dancer. + if (!move.hasAttr("CopyMoveAttr") && !isReflected(this.useMode)) { + globalScene.currentBattle.lastMove = move.id; } - /** - * If the move has not failed, trigger ability-based user type changes and then execute it. - * - * Notably, Roar, Whirlwind, Trick-or-Treat, and Forest's Curse will trigger these type changes even - * if the move fails. - */ - if (success) { - const move = this.move.getMove(); - // TODO: Investigate whether PokemonTypeChangeAbAttr can drop the "opponent" parameter - applyAbAttrs("PokemonTypeChangeAbAttr", { pokemon: this.pokemon, move, opponent: targets[0] }); - globalScene.phaseManager.unshiftNew( - "MoveEffectPhase", - this.pokemon.getBattlerIndex(), - this.targets, - move, - this.useMode, - ); - } else { - if ([MoveId.ROAR, MoveId.WHIRLWIND, MoveId.TRICK_OR_TREAT, MoveId.FORESTS_CURSE].includes(this.move.moveId)) { - applyAbAttrs("PokemonTypeChangeAbAttr", { - pokemon: this.pokemon, - move: this.move.getMove(), - opponent: targets[0], - }); - } - - this.pokemon.pushMoveHistory({ - move: this.move.moveId, - targets: this.targets, - result: MoveResult.FAIL, - useMode: this.useMode, - }); - - const failureMessage = move.getFailedText(this.pokemon, targets[0], move); - let failedText: string | undefined; - if (failureMessage) { - failedText = failureMessage; - } else if (failedDueToTerrain) { - failedText = getTerrainBlockMessage(targets[0], globalScene.arena.getTerrainType()); - } else if (failedDueToWeather) { - failedText = getWeatherBlockMessage(globalScene.arena.getWeatherType()); - } - - this.showFailedText(failedText); - - // Remove the user from its semi-invulnerable state (if applicable) - this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT); - } + // Trigger ability-based user type changes, display move text and then execute move effects. + // TODO: Investigate whether PokemonTypeChangeAbAttr can drop the "opponent" parameter + applyAbAttrs("PokemonTypeChangeAbAttr", { pokemon: this.pokemon, move, opponent: targets[0] }); + this.showMoveText(); + globalScene.phaseManager.unshiftNew( + "MoveEffectPhase", + this.pokemon.getBattlerIndex(), + this.targets, + move, + this.useMode, + ); // Handle Dancer, which triggers immediately after a move is used (rather than waiting on `this.end()`). // Note the MoveUseMode check here prevents an infinite Dancer loop. + // TODO: This needs to go at the end of `MoveEffectPhase` to check move results const dancerModes: MoveUseMode[] = [MoveUseMode.INDIRECT, MoveUseMode.REFLECTED] as const; if (this.move.getMove().hasFlag(MoveFlags.DANCE_MOVE) && !dancerModes.includes(this.useMode)) { - // TODO: Fix in dancer PR to move to MEP for hit checks globalScene.getField(true).forEach(pokemon => { applyAbAttrs("PostMoveUsedAbAttr", { pokemon, move: this.move, source: this.pokemon, targets: this.targets }); }); } } - /** Queues a {@linkcode MoveChargePhase} for this phase's invoked move. */ + /** + * Fail the move currently being used. + * Handles failure messages, pushing to move history, etc. + * @param showText - Whether to show move text when failing the move. + * @param failedDueToWeather - Whether the move failed due to weather (default `false`) + * @param failedDueToTerrain - Whether the move failed due to terrain (default `false`) + */ + protected failMove(showText: boolean, failedDueToWeather = false, failedDueToTerrain = false) { + const move = this.move.getMove(); + const targets = this.getActiveTargetPokemon(); + + // DO NOT CHANGE THE ORDER OF OPERATIONS HERE! + // Protean is supposed to trigger its effects first, _then_ move text is displayed, + // _then_ any blockage messages are shown. + + // Roar, Whirlwind, Trick-or-Treat, and Forest's Curse will trigger Protean/Libero + // even on failure, as will all moves blocked by terrain. + // TODO: Verify if this also applies to primal weather failures + if ( + failedDueToTerrain || + [MoveId.ROAR, MoveId.WHIRLWIND, MoveId.TRICK_OR_TREAT, MoveId.FORESTS_CURSE].includes(this.move.moveId) + ) { + applyAbAttrs("PokemonTypeChangeAbAttr", { + pokemon: this.pokemon, + move, + opponent: targets[0], + }); + } + + if (showText) { + this.showMoveText(); + } + + this.pokemon.pushMoveHistory({ + move: this.move.moveId, + targets: this.targets, + result: MoveResult.FAIL, + useMode: this.useMode, + }); + + // Use move-specific failure messages if present before checking terrain/weather blockage + // and falling back to the classic "But it failed!". + const failureMessage = + move.getFailedText(this.pokemon, targets[0], move) || + (failedDueToTerrain + ? getTerrainBlockMessage(targets[0], globalScene.arena.getTerrainType()) + : failedDueToWeather + ? getWeatherBlockMessage(globalScene.arena.getWeatherType()) + : i18next.t("battle:attackFailed")); + + this.showFailedText(failureMessage); + + // Remove the user from its semi-invulnerable state (if applicable) + this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT); + } + + /** + * Queue a {@linkcode MoveChargePhase} for this phase's invoked move. + * Does NOT consume PP (occurs on the 2nd strike of the move) + */ protected chargeMove() { const move = this.move.getMove(); const targets = this.getActiveTargetPokemon(); - this.showMoveText(); - - // Conditions currently assume single target - // TODO: Is this sustainable? if (!move.applyConditions(this.pokemon, targets[0], move)) { - this.pokemon.pushMoveHistory({ - move: this.move.moveId, - targets: this.targets, - result: MoveResult.FAIL, - useMode: this.useMode, - }); - - const failureMessage = move.getFailedText(this.pokemon, targets[0], move); - this.showMoveText(); - this.showFailedText(failureMessage ?? undefined); - - // Remove the user from its semi-invulnerable state (if applicable) - this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT); + this.failMove(true); return; } - // Protean and Libero apply on the charging turn of charge moves + // Protean and Libero apply on the charging turn of charge moves, even before showing usage text applyAbAttrs("PokemonTypeChangeAbAttr", { pokemon: this.pokemon, - move: this.move.getMove(), + move, opponent: targets[0], }); + this.showMoveText(); globalScene.phaseManager.unshiftNew( "MoveChargePhase", this.pokemon.getBattlerIndex(), @@ -489,7 +481,7 @@ export class MovePhase extends BattlePhase { } /** - * Queues a {@linkcode MoveEndPhase} and then ends the phase + * Queue a {@linkcode MoveEndPhase} and then end this phase. */ public end(): void { globalScene.phaseManager.unshiftNew( @@ -503,15 +495,15 @@ export class MovePhase extends BattlePhase { } /** - * Applies PP increasing abilities (currently only {@link AbilityId.PRESSURE Pressure}) if they exist on the target pokemon. + * Applies PP increasing abilities (currently only {@linkcode AbilityId.PRESSURE | Pressure}) if they exist on the target pokemon. * Note that targets must include only active pokemon. * * TODO: This hardcodes the PP increase at 1 per opponent, rather than deferring to the ability. */ public getPpIncreaseFromPressure(targets: Pokemon[]): number { const foesWithPressure = this.pokemon - .getOpponents() - .filter(o => targets.includes(o) && o.isActive(true) && o.hasAbilityWithAttr("IncreasePpAbAttr")); + .getOpponents(true) + .filter(o => targets.includes(o) && o.hasAbilityWithAttr("IncreasePpAbAttr")); return foesWithPressure.length; } @@ -521,105 +513,113 @@ export class MovePhase extends BattlePhase { * - Counterattacks, which pass a special value into the `targets` constructor param (`[`{@linkcode BattlerIndex.ATTACKER}`]`). */ protected resolveRedirectTarget(): void { - if (this.targets.length === 1) { - const currentTarget = this.targets[0]; - const redirectTarget = new NumberHolder(currentTarget); + if (this.targets.length !== 1) { + // Spread moves cannot be redirected + return; + } - // check move redirection abilities of every pokemon *except* the user. - globalScene - .getField(true) - .filter(p => p !== this.pokemon) - .forEach(p => - applyAbAttrs("RedirectMoveAbAttr", { - pokemon: p, - moveId: this.move.moveId, - targetIndex: redirectTarget, - sourcePokemon: this.pokemon, - }), - ); + const currentTarget = this.targets[0]; + const redirectTarget = new NumberHolder(currentTarget); - /** `true` if an Ability is responsible for redirecting the move to another target; `false` otherwise */ - let redirectedByAbility = currentTarget !== redirectTarget.value; + // check move redirection abilities of every pokemon *except* the user. + globalScene + .getField(true) + .filter(p => p !== this.pokemon) + .forEach(pokemon => { + applyAbAttrs("RedirectMoveAbAttr", { + pokemon, + moveId: this.move.moveId, + targetIndex: redirectTarget, + sourcePokemon: this.pokemon, + }); + }); - // check for center-of-attention tags (note that this will override redirect abilities) - this.pokemon.getOpponents().forEach(p => { - const redirectTag = p.getTag(CenterOfAttentionTag); + /** `true` if an Ability is responsible for redirecting the move to another target; `false` otherwise */ + let redirectedByAbility = currentTarget !== redirectTarget.value; - // TODO: don't hardcode this interaction. - // Handle interaction between the rage powder center-of-attention tag and moves used by grass types/overcoat-havers (which are immune to RP's redirect) - if ( - redirectTag && - (!redirectTag.powder || - (!this.pokemon.isOfType(PokemonType.GRASS) && !this.pokemon.hasAbility(AbilityId.OVERCOAT))) - ) { - redirectTarget.value = p.getBattlerIndex(); - redirectedByAbility = false; + // check for center-of-attention tags (note that this will override redirect abilities) + this.pokemon.getOpponents(true).forEach(p => { + const redirectTag = p.getTag(CenterOfAttentionTag); + + // TODO: don't hardcode this interaction. + // Handle interaction between the rage powder center-of-attention tag and moves used by grass types/overcoat-havers (which are immune to RP's redirect) + if ( + redirectTag && + (!redirectTag.powder || + (!this.pokemon.isOfType(PokemonType.GRASS) && !this.pokemon.hasAbility(AbilityId.OVERCOAT))) + ) { + redirectTarget.value = p.getBattlerIndex(); + redirectedByAbility = false; + } + }); + + // TODO: Don't hardcode these ability interactions + if (currentTarget !== redirectTarget.value) { + const bypassRedirectAttrs = this.move.getMove().getAttrs("BypassRedirectAttr"); + bypassRedirectAttrs.forEach(attr => { + if (!attr.abilitiesOnly || redirectedByAbility) { + redirectTarget.value = currentTarget; } }); - if (currentTarget !== redirectTarget.value) { - const bypassRedirectAttrs = this.move.getMove().getAttrs("BypassRedirectAttr"); - bypassRedirectAttrs.forEach(attr => { - if (!attr.abilitiesOnly || redirectedByAbility) { - redirectTarget.value = currentTarget; - } - }); - - if (this.pokemon.hasAbilityWithAttr("BlockRedirectAbAttr")) { - redirectTarget.value = currentTarget; - // TODO: Ability displays should be handled by the ability - globalScene.phaseManager.queueAbilityDisplay( - this.pokemon, - this.pokemon.getPassiveAbility().hasAttr("BlockRedirectAbAttr"), - true, - ); - globalScene.phaseManager.queueAbilityDisplay( - this.pokemon, - this.pokemon.getPassiveAbility().hasAttr("BlockRedirectAbAttr"), - false, - ); - } - - this.targets[0] = redirectTarget.value; + if (this.pokemon.hasAbilityWithAttr("BlockRedirectAbAttr")) { + redirectTarget.value = currentTarget; + // TODO: Ability displays should be handled by the ability + globalScene.phaseManager.queueAbilityDisplay( + this.pokemon, + this.pokemon.getPassiveAbility().hasAttr("BlockRedirectAbAttr"), + true, + ); + globalScene.phaseManager.queueAbilityDisplay( + this.pokemon, + this.pokemon.getPassiveAbility().hasAttr("BlockRedirectAbAttr"), + false, + ); } + + this.targets[0] = redirectTarget.value; } } /** - * Counter-attacking moves pass in `[`{@linkcode BattlerIndex.ATTACKER}`]` into the constructor's `targets` param. - * This function modifies `this.targets` to reflect the actual battler index of the user's last - * attacker. + * Update the targets of any counter-attacking moves with `[`{@linkcode BattlerIndex.ATTACKER}`]` set + * to reflect the actual battler index of the user's last attacker. * - * If there is no last attacker, or they are no longer on the field, a message is displayed and the + * If there is no last attacker or they are no longer on the field, a message is displayed and the * move is marked for failure. + * @todo Make this a feature of the move rather than basing logic on {@linkcode BattlerIndex.ATTACKER} */ protected resolveCounterAttackTarget(): void { - if (this.targets.length === 1 && this.targets[0] === BattlerIndex.ATTACKER) { - if (this.pokemon.turnData.attacksReceived.length) { - this.targets[0] = this.pokemon.turnData.attacksReceived[0].sourceBattlerIndex; + if (this.targets.length !== 1 || this.targets[0] !== BattlerIndex.ATTACKER) { + return; + } - // account for metal burst and comeuppance hitting remaining targets in double battles - // counterattack will redirect to remaining ally if original attacker faints - if (globalScene.currentBattle.double && this.move.getMove().hasFlag(MoveFlags.REDIRECT_COUNTER)) { - if (globalScene.getField()[this.targets[0]].hp === 0) { - const opposingField = this.pokemon.isPlayer() ? globalScene.getEnemyField() : globalScene.getPlayerField(); - this.targets[0] = opposingField.find(p => p.hp > 0)?.getBattlerIndex() ?? BattlerIndex.ATTACKER; - } - } - } + // TODO: This should be covered in move conditions + if (this.pokemon.turnData.attacksReceived.length === 0) { + this.fail(); + this.showMoveText(); + this.showFailedText(); + return; + } - if (this.targets[0] === BattlerIndex.ATTACKER) { - this.fail(); - this.showMoveText(); - this.showFailedText(); - } + this.targets[0] = this.pokemon.turnData.attacksReceived[0].sourceBattlerIndex; + + // account for metal burst and comeuppance hitting remaining targets in double battles + // counterattack will redirect to remaining ally if original attacker faints + if ( + globalScene.currentBattle.double && + this.move.getMove().hasFlag(MoveFlags.REDIRECT_COUNTER) && + globalScene.getField()[this.targets[0]].hp === 0 + ) { + const opposingField = this.pokemon.isPlayer() ? globalScene.getEnemyField() : globalScene.getPlayerField(); + this.targets[0] = opposingField.find(p => p.hp > 0)?.getBattlerIndex() ?? BattlerIndex.ATTACKER; } } /** * Handles the case where the move was cancelled or failed: - * - Uses PP if the move failed (not cancelled) and should use PP (failed moves are not affected by {@link AbilityId.PRESSURE Pressure}) - * - Records a cancelled OR failed move in move history, so abilities like {@link AbilityId.TRUANT Truant} don't trigger on the + * - Uses PP if the move failed (not cancelled) and should use PP (failed moves are not affected by {@linkcode AbilityId.PRESSURE | Pressure}) + * - Records a cancelled OR failed move in move history, so abilities like {@linkcode AbilityId.TRUANT | Truant} don't trigger on the * next turn and soft-lock. * - Lapses `MOVE_EFFECT` tags: * - Semi-invulnerable battler tags (Fly/Dive/etc.) are intended to lapse on move effects, but also need @@ -627,52 +627,55 @@ export class MovePhase extends BattlePhase { * * TODO: ...this seems weird. * - Lapses `AFTER_MOVE` tags: - * - This handles the effects of {@link MoveId.SUBSTITUTE Substitute} + * - This handles the effects of {@linkcode MoveId.SUBSTITUTE | Substitute} * - Removes the second turn of charge moves */ protected handlePreMoveFailures(): void { - if (this.cancelled || this.failed) { - if (this.failed) { - const ppUsed = isIgnorePP(this.useMode) ? 0 : 1; - - if (ppUsed) { - this.move.usePp(); - } - - globalScene.eventTarget.dispatchEvent(new MoveUsedEvent(this.pokemon?.id, this.move.getMove(), ppUsed)); - } - - if (this.cancelled && this.pokemon.summonData.tags?.find(t => t.tagType === BattlerTagType.FRENZY)) { - frenzyMissFunc(this.pokemon, this.move.getMove()); - } - - this.pokemon.pushMoveHistory({ - move: MoveId.NONE, - result: MoveResult.FAIL, - targets: this.targets, - useMode: this.useMode, - }); - - this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT); - this.pokemon.lapseTags(BattlerTagLapseType.AFTER_MOVE); - - this.pokemon.getMoveQueue().shift(); + if (!this.cancelled && !this.failed) { + return; } + + if (this.failed) { + // TODO: should this consider struggle? + const ppUsed = isIgnorePP(this.useMode) ? 0 : 1; + this.move.usePp(ppUsed); + globalScene.eventTarget.dispatchEvent(new MoveUsedEvent(this.pokemon?.id, this.move.getMove(), ppUsed)); + } + + if (this.cancelled && this.pokemon.summonData.tags.some(t => t.tagType === BattlerTagType.FRENZY)) { + frenzyMissFunc(this.pokemon, this.move.getMove()); + } + + this.pokemon.pushMoveHistory({ + move: MoveId.NONE, + result: MoveResult.FAIL, + targets: this.targets, + useMode: this.useMode, + }); + + this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT); + this.pokemon.lapseTags(BattlerTagLapseType.AFTER_MOVE); + + // This clears out 2 turn moves after they've been used + // TODO: Remove post move queue refactor + this.pokemon.getMoveQueue().shift(); } /** - * Displays the move's usage text to the player, unless it's a charge turn (ie: {@link MoveId.SOLAR_BEAM Solar Beam}), - * the pokemon is on a recharge turn (ie: {@link MoveId.HYPER_BEAM Hyper Beam}), or a 2-turn move was interrupted (ie: {@link MoveId.FLY Fly}). + * Displays the move's usage text to the player as applicable for the move being used. */ public showMoveText(): void { - if (this.move.moveId === MoveId.NONE) { - return; - } - - if (this.pokemon.getTag(BattlerTagType.RECHARGING) || this.pokemon.getTag(BattlerTagType.INTERRUPTED)) { + // No text for Moves.NONE, recharging/2-turn moves or interrupted moves + if ( + this.move.moveId === MoveId.NONE || + this.pokemon.getTag(BattlerTagType.RECHARGING) || + this.pokemon.getTag(BattlerTagType.INTERRUPTED) + ) { return; } + // Play message for magic coat reflection + // TODO: This should be done by the move... globalScene.phaseManager.queueMessage( i18next.t(isReflected(this.useMode) ? "battle:magicCoatActivated" : "battle:useMove", { pokemonNameWithAffix: getPokemonNameWithAffix(this.pokemon), @@ -686,7 +689,12 @@ export class MovePhase extends BattlePhase { applyMoveAttrs("PreMoveMessageAttr", this.pokemon, this.pokemon.getOpponents(false)[0], this.move.getMove()); } - public showFailedText(failedText: string = i18next.t("battle:attackFailed")): void { + /** + * Display the text for a move failing to execute. + * @param failedText - The failure text to display; defaults to `"battle:attackFailed"` locale key + * ("But it failed!" in english) + */ + public showFailedText(failedText = i18next.t("battle:attackFailed")): void { globalScene.phaseManager.queueMessage(failedText); } } diff --git a/src/ui/fight-ui-handler.ts b/src/ui/fight-ui-handler.ts index 14cd10d0d6f..4ddbbff4d7f 100644 --- a/src/ui/fight-ui-handler.ts +++ b/src/ui/fight-ui-handler.ts @@ -42,62 +42,67 @@ export default class FightUiHandler extends UiHandler implements InfoToggle { super(UiMode.FIGHT); } + /** + * Set the visibility of the objects in the move info container. + */ + private setInfoVis(visibility: boolean): void { + this.moveInfoContainer.iterate((o: Phaser.GameObjects.Components.Visible) => o.setVisible(visibility)); + } + setup() { const ui = this.getUi(); - this.movesContainer = globalScene.add.container(18, -38.7); - this.movesContainer.setName(FightUiHandler.MOVES_CONTAINER_NAME); + this.movesContainer = globalScene.add.container(18, -38.7).setName(FightUiHandler.MOVES_CONTAINER_NAME); ui.add(this.movesContainer); - this.moveInfoContainer = globalScene.add.container(1, 0); - this.moveInfoContainer.setName("move-info"); + this.moveInfoContainer = globalScene.add.container(1, 0).setName("move-info"); ui.add(this.moveInfoContainer); - this.typeIcon = globalScene.add.sprite( - globalScene.scaledCanvas.width - 57, - -36, - getLocalizedSpriteKey("types"), - "unknown", - ); - this.typeIcon.setVisible(false); - this.moveInfoContainer.add(this.typeIcon); + this.typeIcon = globalScene.add + .sprite(globalScene.scaledCanvas.width - 57, -36, getLocalizedSpriteKey("types"), "unknown") + .setVisible(false); - this.moveCategoryIcon = globalScene.add.sprite(globalScene.scaledCanvas.width - 25, -36, "categories", "physical"); - this.moveCategoryIcon.setVisible(false); - this.moveInfoContainer.add(this.moveCategoryIcon); + this.moveCategoryIcon = globalScene.add + .sprite(globalScene.scaledCanvas.width - 25, -36, "categories", "physical") + .setVisible(false); - this.ppLabel = addTextObject(globalScene.scaledCanvas.width - 70, -26, "PP", TextStyle.MOVE_INFO_CONTENT); - this.ppLabel.setOrigin(0.0, 0.5); - this.ppLabel.setVisible(false); - this.ppLabel.setText(i18next.t("fightUiHandler:pp")); - this.moveInfoContainer.add(this.ppLabel); + this.ppLabel = addTextObject(globalScene.scaledCanvas.width - 70, -26, "PP", TextStyle.MOVE_INFO_CONTENT) + .setOrigin(0.0, 0.5) + .setVisible(false) + .setText(i18next.t("fightUiHandler:pp")); - this.ppText = addTextObject(globalScene.scaledCanvas.width - 12, -26, "--/--", TextStyle.MOVE_INFO_CONTENT); - this.ppText.setOrigin(1, 0.5); - this.ppText.setVisible(false); - this.moveInfoContainer.add(this.ppText); + this.ppText = addTextObject(globalScene.scaledCanvas.width - 12, -26, "--/--", TextStyle.MOVE_INFO_CONTENT) + .setOrigin(1, 0.5) + .setVisible(false); - this.powerLabel = addTextObject(globalScene.scaledCanvas.width - 70, -18, "POWER", TextStyle.MOVE_INFO_CONTENT); - this.powerLabel.setOrigin(0.0, 0.5); - this.powerLabel.setVisible(false); - this.powerLabel.setText(i18next.t("fightUiHandler:power")); - this.moveInfoContainer.add(this.powerLabel); + this.powerLabel = addTextObject(globalScene.scaledCanvas.width - 70, -18, "POWER", TextStyle.MOVE_INFO_CONTENT) + .setOrigin(0.0, 0.5) + .setVisible(false) + .setText(i18next.t("fightUiHandler:power")); - this.powerText = addTextObject(globalScene.scaledCanvas.width - 12, -18, "---", TextStyle.MOVE_INFO_CONTENT); - this.powerText.setOrigin(1, 0.5); - this.powerText.setVisible(false); - this.moveInfoContainer.add(this.powerText); + this.powerText = addTextObject(globalScene.scaledCanvas.width - 12, -18, "---", TextStyle.MOVE_INFO_CONTENT) + .setOrigin(1, 0.5) + .setVisible(false); - this.accuracyLabel = addTextObject(globalScene.scaledCanvas.width - 70, -10, "ACC", TextStyle.MOVE_INFO_CONTENT); - this.accuracyLabel.setOrigin(0.0, 0.5); - this.accuracyLabel.setVisible(false); - this.accuracyLabel.setText(i18next.t("fightUiHandler:accuracy")); - this.moveInfoContainer.add(this.accuracyLabel); + this.accuracyLabel = addTextObject(globalScene.scaledCanvas.width - 70, -10, "ACC", TextStyle.MOVE_INFO_CONTENT) + .setOrigin(0.0, 0.5) + .setVisible(false) + .setText(i18next.t("fightUiHandler:accuracy")); - this.accuracyText = addTextObject(globalScene.scaledCanvas.width - 12, -10, "---", TextStyle.MOVE_INFO_CONTENT); - this.accuracyText.setOrigin(1, 0.5); - this.accuracyText.setVisible(false); - this.moveInfoContainer.add(this.accuracyText); + this.accuracyText = addTextObject(globalScene.scaledCanvas.width - 12, -10, "---", TextStyle.MOVE_INFO_CONTENT) + .setOrigin(1, 0.5) + .setVisible(false); + + this.moveInfoContainer.add([ + this.typeIcon, + this.moveCategoryIcon, + this.ppLabel, + this.ppText, + this.powerLabel, + this.powerText, + this.accuracyLabel, + this.accuracyText, + ]); // prepare move overlay const overlayScale = 1; @@ -114,15 +119,14 @@ export default class FightUiHandler extends UiHandler implements InfoToggle { }); ui.add(this.moveInfoOverlay); // register the overlay to receive toggle events - globalScene.addInfoToggle(this.moveInfoOverlay); - globalScene.addInfoToggle(this); + globalScene.addInfoToggle(this.moveInfoOverlay, this); } - show(args: any[]): boolean { + override show(args: [number?, Command?]): boolean { super.show(args); - this.fieldIndex = args.length ? (args[0] as number) : 0; - this.fromCommand = args.length > 1 ? (args[1] as Command) : Command.FIGHT; + this.fieldIndex = args[0] ?? 0; + this.fromCommand = args[1] ?? Command.FIGHT; const messageHandler = this.getUi().getMessageHandler(); messageHandler.bg.setVisible(false); @@ -131,8 +135,6 @@ export default class FightUiHandler extends UiHandler implements InfoToggle { const pokemon = (globalScene.phaseManager.getCurrentPhase() as CommandPhase).getPokemon(); if (pokemon.tempSummonData.turnCount <= 1) { this.setCursor(0); - } else { - this.setCursor(this.getCursor()); } this.displayMoves(); this.toggleInfo(false); // in case cancel was pressed while info toggle is active @@ -147,21 +149,10 @@ export default class FightUiHandler extends UiHandler implements InfoToggle { */ processInput(button: Button): boolean { const ui = this.getUi(); - const cursor = this.getCursor(); let success = false; + const cursor = this.getCursor(); switch (button) { - case Button.CANCEL: - { - // Attempts to back out of the move selection pane are blocked in certain MEs - // TODO: Should we allow showing the summary menu at least? - const { battleType, mysteryEncounter } = globalScene.currentBattle; - if (battleType !== BattleType.MYSTERY_ENCOUNTER || !mysteryEncounter?.skipToFightInput) { - ui.setMode(UiMode.COMMAND, this.fieldIndex); - success = true; - } - } - break; case Button.ACTION: if ( (globalScene.phaseManager.getCurrentPhase() as CommandPhase).handleCommand( @@ -175,6 +166,15 @@ export default class FightUiHandler extends UiHandler implements InfoToggle { ui.playError(); } break; + case Button.CANCEL: { + // Cannot back out of fight menu if skipToFightInput is enabled + const { battleType, mysteryEncounter } = globalScene.currentBattle; + if (battleType !== BattleType.MYSTERY_ENCOUNTER || !mysteryEncounter?.skipToFightInput) { + ui.setMode(UiMode.COMMAND, this.fieldIndex); + success = true; + } + break; + } case Button.UP: if (cursor >= 2) { success = this.setCursor(cursor - 2); @@ -195,8 +195,6 @@ export default class FightUiHandler extends UiHandler implements InfoToggle { success = this.setCursor(cursor + 1); } break; - default: - // other inputs do nothing while in fight menu } if (success) { @@ -206,21 +204,26 @@ export default class FightUiHandler extends UiHandler implements InfoToggle { return success; } + /** + * Adjust the visibility of move names and the cursor icon when the info overlay is toggled + * @param visible - The visibility of the info overlay; the move names and cursor's visibility will be set to the opposite + */ toggleInfo(visible: boolean): void { + // The info overlay will already fade in, so we should hide the move name text and cursor immediately + // rather than adjusting alpha via a tween. if (visible) { - this.movesContainer.setVisible(false); - this.cursorObj?.setVisible(false); + this.movesContainer.setVisible(false).setAlpha(0); + this.cursorObj?.setVisible(false).setAlpha(0); + return; } globalScene.tweens.add({ targets: [this.movesContainer, this.cursorObj], duration: fixedInt(125), ease: "Sine.easeInOut", - alpha: visible ? 0 : 1, + alpha: 1, }); - if (!visible) { - this.movesContainer.setVisible(true); - this.cursorObj?.setVisible(true); - } + this.movesContainer.setVisible(true); + this.cursorObj?.setVisible(true); } isActive(): boolean { @@ -231,6 +234,64 @@ export default class FightUiHandler extends UiHandler implements InfoToggle { return !this.fieldIndex ? this.cursor : this.cursor2; } + /** @returns TextStyle according to percentage of PP remaining */ + private static ppRatioToColor(ppRatio: number): TextStyle { + if (ppRatio > 0.25 && ppRatio <= 0.5) { + return TextStyle.MOVE_PP_HALF_FULL; + } + if (ppRatio > 0 && ppRatio <= 0.25) { + return TextStyle.MOVE_PP_NEAR_EMPTY; + } + if (ppRatio === 0) { + return TextStyle.MOVE_PP_EMPTY; + } + return TextStyle.MOVE_PP_FULL; // default to full if ppRatio is invalid + } + + /** + * Populate the move info overlay with the information of the move at the given cursor index + * @param cursor - The cursor position to set the move info for + */ + private setMoveInfo(cursor: number): void { + const pokemon = (globalScene.phaseManager.getCurrentPhase() as CommandPhase).getPokemon(); + const moveset = pokemon.getMoveset(); + + const hasMove = cursor < moveset.length; + this.setInfoVis(hasMove); + + if (!hasMove) { + return; + } + + const pokemonMove = moveset[cursor]; + const moveType = pokemon.getMoveType(pokemonMove.getMove()); + const textureKey = getLocalizedSpriteKey("types"); + this.typeIcon.setTexture(textureKey, PokemonType[moveType].toLowerCase()).setScale(0.8); + + const moveCategory = pokemonMove.getMove().category; + this.moveCategoryIcon.setTexture("categories", MoveCategory[moveCategory].toLowerCase()).setScale(1.0); + const power = pokemonMove.getMove().power; + const accuracy = pokemonMove.getMove().accuracy; + const maxPP = pokemonMove.getMovePp(); + const pp = maxPP - pokemonMove.ppUsed; + + const ppLeftStr = padInt(pp, 2, " "); + const ppMaxStr = padInt(maxPP, 2, " "); + this.ppText.setText(`${ppLeftStr}/${ppMaxStr}`); + this.powerText.setText(`${power >= 0 ? power : "---"}`); + this.accuracyText.setText(`${accuracy >= 0 ? accuracy : "---"}`); + + const ppColorStyle = FightUiHandler.ppRatioToColor(pp / maxPP); + + //** Changes the text color and shadow according to the determined TextStyle */ + this.ppText.setColor(this.getTextColor(ppColorStyle, false)).setShadowColor(this.getTextColor(ppColorStyle, true)); + this.moveInfoOverlay.show(pokemonMove.getMove()); + + pokemon.getOpponents().forEach(opponent => { + (opponent as EnemyPokemon).updateEffectiveness(this.getEffectivenessText(pokemon, opponent, pokemonMove)); + }); + } + setCursor(cursor: number): boolean { const ui = this.getUi(); @@ -244,6 +305,8 @@ export default class FightUiHandler extends UiHandler implements InfoToggle { } } + this.setMoveInfo(cursor); + if (!this.cursorObj) { const isTera = this.fromCommand === Command.TERA; this.cursorObj = globalScene.add.image(0, 0, isTera ? "cursor_tera" : "cursor"); @@ -251,61 +314,6 @@ export default class FightUiHandler extends UiHandler implements InfoToggle { ui.add(this.cursorObj); } - const pokemon = (globalScene.phaseManager.getCurrentPhase() as CommandPhase).getPokemon(); - const moveset = pokemon.getMoveset(); - - const hasMove = cursor < moveset.length; - - if (hasMove) { - const pokemonMove = moveset[cursor]; - const moveType = pokemon.getMoveType(pokemonMove.getMove()); - const textureKey = getLocalizedSpriteKey("types"); - this.typeIcon.setTexture(textureKey, PokemonType[moveType].toLowerCase()).setScale(0.8); - - const moveCategory = pokemonMove.getMove().category; - this.moveCategoryIcon.setTexture("categories", MoveCategory[moveCategory].toLowerCase()).setScale(1.0); - const power = pokemonMove.getMove().power; - const accuracy = pokemonMove.getMove().accuracy; - const maxPP = pokemonMove.getMovePp(); - const pp = maxPP - pokemonMove.ppUsed; - - const ppLeftStr = padInt(pp, 2, " "); - const ppMaxStr = padInt(maxPP, 2, " "); - this.ppText.setText(`${ppLeftStr}/${ppMaxStr}`); - this.powerText.setText(`${power >= 0 ? power : "---"}`); - this.accuracyText.setText(`${accuracy >= 0 ? accuracy : "---"}`); - - const ppPercentLeft = pp / maxPP; - - //** Determines TextStyle according to percentage of PP remaining */ - let ppColorStyle = TextStyle.MOVE_PP_FULL; - if (ppPercentLeft > 0.25 && ppPercentLeft <= 0.5) { - ppColorStyle = TextStyle.MOVE_PP_HALF_FULL; - } else if (ppPercentLeft > 0 && ppPercentLeft <= 0.25) { - ppColorStyle = TextStyle.MOVE_PP_NEAR_EMPTY; - } else if (ppPercentLeft === 0) { - ppColorStyle = TextStyle.MOVE_PP_EMPTY; - } - - //** Changes the text color and shadow according to the determined TextStyle */ - this.ppText.setColor(this.getTextColor(ppColorStyle, false)); - this.ppText.setShadowColor(this.getTextColor(ppColorStyle, true)); - this.moveInfoOverlay.show(pokemonMove.getMove()); - - pokemon.getOpponents().forEach(opponent => { - (opponent as EnemyPokemon).updateEffectiveness(this.getEffectivenessText(pokemon, opponent, pokemonMove)); - }); - } - - this.typeIcon.setVisible(hasMove); - this.ppLabel.setVisible(hasMove); - this.ppText.setVisible(hasMove); - this.powerLabel.setVisible(hasMove); - this.powerText.setVisible(hasMove); - this.accuracyLabel.setVisible(hasMove); - this.accuracyText.setVisible(hasMove); - this.moveCategoryIcon.setVisible(hasMove); - this.cursorObj.setPosition(13 + (cursor % 2 === 1 ? 114 : 0), -31 + (cursor >= 2 ? 15 : 0)); return changed; @@ -336,14 +344,19 @@ export default class FightUiHandler extends UiHandler implements InfoToggle { const moveset = pokemon.getMoveset(); for (let moveIndex = 0; moveIndex < 4; moveIndex++) { - const moveText = addTextObject(moveIndex % 2 === 0 ? 0 : 114, moveIndex < 2 ? 0 : 16, "-", TextStyle.WINDOW); - moveText.setName("text-empty-move"); + const moveText = addTextObject( + moveIndex % 2 === 0 ? 0 : 114, + moveIndex < 2 ? 0 : 16, + "-", + TextStyle.WINDOW, + ).setName("text-empty-move"); if (moveIndex < moveset.length) { const pokemonMove = moveset[moveIndex]!; // TODO is the bang correct? - moveText.setText(pokemonMove.getName()); - moveText.setName(pokemonMove.getName()); - moveText.setColor(this.getMoveColor(pokemon, pokemonMove) ?? moveText.style.color); + moveText + .setText(pokemonMove.getName()) + .setName(pokemonMove.getName()) + .setColor(this.getMoveColor(pokemon, pokemonMove) ?? moveText.style.color); } this.movesContainer.add(moveText); @@ -386,14 +399,7 @@ export default class FightUiHandler extends UiHandler implements InfoToggle { super.clear(); const messageHandler = this.getUi().getMessageHandler(); this.clearMoves(); - this.typeIcon.setVisible(false); - this.ppLabel.setVisible(false); - this.ppText.setVisible(false); - this.powerLabel.setVisible(false); - this.powerText.setVisible(false); - this.accuracyLabel.setVisible(false); - this.accuracyText.setVisible(false); - this.moveCategoryIcon.setVisible(false); + this.setInfoVis(false); this.moveInfoOverlay.clear(); messageHandler.bg.setVisible(true); this.eraseCursor(); diff --git a/test/abilities/guard-dog.test.ts b/test/abilities/guard-dog.test.ts new file mode 100644 index 00000000000..ee559a79239 --- /dev/null +++ b/test/abilities/guard-dog.test.ts @@ -0,0 +1,39 @@ +import { AbilityId } from "#enums/ability-id"; +import { SpeciesId } from "#enums/species-id"; +import { Stat } from "#enums/stat"; +import GameManager from "#test/testUtils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Ability - Guard Dog", () => { + 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 + .ability(AbilityId.GUARD_DOG) + .battleStyle("single") + .enemySpecies(SpeciesId.MAGIKARP) + .enemyAbility(AbilityId.INTIMIDATE); + }); + + it("should raise attack by 1 stage when Intimidated instead of being lowered", async () => { + await game.classicMode.startBattle([SpeciesId.MABOSSTIFF]); + + const mabostiff = game.field.getPlayerPokemon(); + expect(mabostiff.getStatStage(Stat.ATK)).toBe(1); + expect(mabostiff.waveData.abilitiesApplied.has(AbilityId.GUARD_DOG)).toBe(true); + expect(game.phaseInterceptor.log.filter(l => l === "StatStageChangePhase")).toHaveLength(1); + }); +}); diff --git a/test/abilities/intimidate.test.ts b/test/abilities/intimidate.test.ts index 6790e2b98d3..673ede9e126 100644 --- a/test/abilities/intimidate.test.ts +++ b/test/abilities/intimidate.test.ts @@ -1,11 +1,11 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import Phaser from "phaser"; import GameManager from "#test/testUtils/gameManager"; -import { UiMode } from "#enums/ui-mode"; import { Stat } from "#enums/stat"; import { AbilityId } from "#enums/ability-id"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; +import { BattleType } from "#enums/battle-type"; describe("Abilities - Intimidate", () => { let phaserGame: Phaser.Game; @@ -27,106 +27,64 @@ describe("Abilities - Intimidate", () => { .battleStyle("single") .enemySpecies(SpeciesId.RATTATA) .enemyAbility(AbilityId.INTIMIDATE) - .enemyPassiveAbility(AbilityId.HYDRATION) .ability(AbilityId.INTIMIDATE) - .startingWave(3) + .passiveAbility(AbilityId.NO_GUARD) .enemyMoveset(MoveId.SPLASH); }); - it("should lower ATK stat stage by 1 of enemy Pokemon on entry and player switch", async () => { - await game.classicMode.runToSummon([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]); - game.onNextPrompt( - "CheckSwitchPhase", - UiMode.CONFIRM, - () => { - game.setMode(UiMode.MESSAGE); - game.endPhase(); - }, - () => game.isCurrentPhase("CommandPhase") || game.isCurrentPhase("TurnInitPhase"), - ); - await game.phaseInterceptor.to("CommandPhase", false); + it("should lower all opponents' ATK by 1 stage on entry and switch", async () => { + await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]); - let playerPokemon = game.scene.getPlayerPokemon()!; - const enemyPokemon = game.scene.getEnemyPokemon()!; - - expect(playerPokemon.species.speciesId).toBe(SpeciesId.MIGHTYENA); - expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(-1); - expect(playerPokemon.getStatStage(Stat.ATK)).toBe(-1); + const enemy = game.field.getEnemyPokemon(); + expect(enemy.getStatStage(Stat.ATK)).toBe(-1); game.doSwitchPokemon(1); - await game.phaseInterceptor.run("CommandPhase"); - await game.phaseInterceptor.to("CommandPhase"); - - playerPokemon = game.scene.getPlayerPokemon()!; - expect(playerPokemon.species.speciesId).toBe(SpeciesId.POOCHYENA); - expect(playerPokemon.getStatStage(Stat.ATK)).toBe(0); - expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(-2); - }); - - it("should lower ATK stat stage by 1 for every enemy Pokemon in a double battle on entry", async () => { - game.override.battleStyle("double").startingWave(3); - await game.classicMode.runToSummon([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]); - game.onNextPrompt( - "CheckSwitchPhase", - UiMode.CONFIRM, - () => { - game.setMode(UiMode.MESSAGE); - game.endPhase(); - }, - () => game.isCurrentPhase("CommandPhase") || game.isCurrentPhase("TurnInitPhase"), - ); - await game.phaseInterceptor.to("CommandPhase", false); - - const playerField = game.scene.getPlayerField()!; - const enemyField = game.scene.getEnemyField()!; - - expect(enemyField[0].getStatStage(Stat.ATK)).toBe(-2); - expect(enemyField[1].getStatStage(Stat.ATK)).toBe(-2); - expect(playerField[0].getStatStage(Stat.ATK)).toBe(-2); - expect(playerField[1].getStatStage(Stat.ATK)).toBe(-2); - }); - - it("should not activate again if there is no switch or new entry", async () => { - game.override.startingWave(2).moveset([MoveId.SPLASH]); - await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]); - - const playerPokemon = game.scene.getPlayerPokemon()!; - const enemyPokemon = game.scene.getEnemyPokemon()!; - - expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(-1); - expect(playerPokemon.getStatStage(Stat.ATK)).toBe(-1); - - game.move.select(MoveId.SPLASH); await game.toNextTurn(); - expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(-1); - expect(playerPokemon.getStatStage(Stat.ATK)).toBe(-1); + expect(enemy.getStatStage(Stat.ATK)).toBe(-2); }); - it("should lower ATK stat stage by 1 for every switch", async () => { - game.override.moveset([MoveId.SPLASH]).enemyMoveset([MoveId.VOLT_SWITCH]).startingWave(5); - await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]); + it("should lower ATK of all opponents in a double battle", async () => { + game.override.battleStyle("double"); + await game.classicMode.startBattle([SpeciesId.MIGHTYENA]); - const playerPokemon = game.scene.getPlayerPokemon()!; - let enemyPokemon = game.scene.getEnemyPokemon()!; + const [enemy1, enemy2] = game.scene.getEnemyField(); - expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(-1); - expect(playerPokemon.getStatStage(Stat.ATK)).toBe(-1); + expect(enemy1.getStatStage(Stat.ATK)).toBe(-1); + expect(enemy2.getStatStage(Stat.ATK)).toBe(-1); + }); - game.move.select(MoveId.SPLASH); + it("should not trigger on switching moves used by wild Pokemon", async () => { + game.override.enemyMoveset(MoveId.VOLT_SWITCH).battleType(BattleType.WILD); + await game.classicMode.startBattle([SpeciesId.MIGHTYENA]); + + const player = game.field.getPlayerPokemon(); + expect(player.getStatStage(Stat.ATK)).toBe(-1); + + game.move.use(MoveId.SPLASH); await game.toNextTurn(); - enemyPokemon = game.scene.getEnemyPokemon()!; + // doesn't lower attack due to not actually switching out + expect(player.getStatStage(Stat.ATK)).toBe(-1); + }); - expect(playerPokemon.getStatStage(Stat.ATK)).toBe(-2); - expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(0); + it("should trigger on moves that switch user/target out during trainer battles", async () => { + game.override.battleType(BattleType.TRAINER).startingWave(50); + await game.classicMode.startBattle([SpeciesId.MIGHTYENA]); - game.move.select(MoveId.SPLASH); + const player = game.field.getPlayerPokemon(); + expect(player.getStatStage(Stat.ATK)).toBe(-1); + + game.move.use(MoveId.SPLASH); + await game.move.forceEnemyMove(MoveId.TELEPORT); await game.toNextTurn(); - enemyPokemon = game.scene.getEnemyPokemon()!; + expect(player.getStatStage(Stat.ATK)).toBe(-2); - expect(playerPokemon.getStatStage(Stat.ATK)).toBe(-3); - expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(0); + game.move.use(MoveId.DRAGON_TAIL); + await game.move.forceEnemyMove(MoveId.SPLASH); + await game.toNextTurn(); + + expect(player.getStatStage(Stat.ATK)).toBe(-3); }); }); diff --git a/test/abilities/libero.test.ts b/test/abilities/libero.test.ts deleted file mode 100644 index eaa2630e90d..00000000000 --- a/test/abilities/libero.test.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { allMoves } from "#app/data/data-lists"; -import { PokemonType } from "#enums/pokemon-type"; -import { Weather } from "#app/data/weather"; -import type { PlayerPokemon } from "#app/field/pokemon"; -import { TurnEndPhase } from "#app/phases/turn-end-phase"; -import { AbilityId } from "#enums/ability-id"; -import { BattlerTagType } from "#enums/battler-tag-type"; -import { BiomeId } from "#enums/biome-id"; -import { MoveId } from "#enums/move-id"; -import { SpeciesId } from "#enums/species-id"; -import { WeatherType } from "#enums/weather-type"; -import GameManager from "#test/testUtils/gameManager"; -import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest"; - -describe("Abilities - Libero", () => { - 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 - .battleStyle("single") - .ability(AbilityId.LIBERO) - .startingLevel(100) - .enemySpecies(SpeciesId.RATTATA) - .enemyMoveset(MoveId.ENDURE); - }); - - test("ability applies and changes a pokemon's type", async () => { - game.override.moveset([MoveId.SPLASH]); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - game.move.select(MoveId.SPLASH); - await game.phaseInterceptor.to(TurnEndPhase); - - testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.SPLASH); - }); - - // Test for Gen9+ functionality, we are using previous funcionality - test.skip("ability applies only once per switch in", async () => { - game.override.moveset([MoveId.SPLASH, MoveId.AGILITY]); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.BULBASAUR]); - - let leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - game.move.select(MoveId.SPLASH); - await game.phaseInterceptor.to(TurnEndPhase); - - testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.SPLASH); - - game.move.select(MoveId.AGILITY); - await game.phaseInterceptor.to(TurnEndPhase); - - expect(leadPokemon.waveData.abilitiesApplied).toContain(AbilityId.LIBERO); - const leadPokemonType = PokemonType[leadPokemon.getTypes()[0]]; - const moveType = PokemonType[allMoves[MoveId.AGILITY].type]; - expect(leadPokemonType).not.toBe(moveType); - - await game.toNextTurn(); - game.doSwitchPokemon(1); - await game.toNextTurn(); - game.doSwitchPokemon(1); - await game.toNextTurn(); - - leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - game.move.select(MoveId.SPLASH); - await game.phaseInterceptor.to(TurnEndPhase); - - testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.SPLASH); - }); - - test("ability applies correctly even if the pokemon's move has a variable type", async () => { - game.override.moveset([MoveId.WEATHER_BALL]); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - game.scene.arena.weather = new Weather(WeatherType.SUNNY); - game.move.select(MoveId.WEATHER_BALL); - await game.phaseInterceptor.to(TurnEndPhase); - - expect(leadPokemon.waveData.abilitiesApplied).toContain(AbilityId.LIBERO); - expect(leadPokemon.getTypes()).toHaveLength(1); - const leadPokemonType = PokemonType[leadPokemon.getTypes()[0]], - moveType = PokemonType[PokemonType.FIRE]; - expect(leadPokemonType).toBe(moveType); - }); - - test("ability applies correctly even if the type has changed by another ability", async () => { - game.override.moveset([MoveId.TACKLE]).passiveAbility(AbilityId.REFRIGERATE); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - game.move.select(MoveId.TACKLE); - await game.phaseInterceptor.to(TurnEndPhase); - - expect(leadPokemon.waveData.abilitiesApplied).toContain(AbilityId.LIBERO); - expect(leadPokemon.getTypes()).toHaveLength(1); - const leadPokemonType = PokemonType[leadPokemon.getTypes()[0]], - moveType = PokemonType[PokemonType.ICE]; - expect(leadPokemonType).toBe(moveType); - }); - - test("ability applies correctly even if the pokemon's move calls another move", async () => { - game.override.moveset([MoveId.NATURE_POWER]); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - game.scene.arena.biomeType = BiomeId.MOUNTAIN; - game.move.select(MoveId.NATURE_POWER); - await game.phaseInterceptor.to(TurnEndPhase); - - testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.AIR_SLASH); - }); - - test("ability applies correctly even if the pokemon's move is delayed / charging", async () => { - game.override.moveset([MoveId.DIG]); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - game.move.select(MoveId.DIG); - await game.phaseInterceptor.to(TurnEndPhase); - - testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.DIG); - }); - - test("ability applies correctly even if the pokemon's move misses", async () => { - game.override.moveset([MoveId.TACKLE]).enemyMoveset(MoveId.SPLASH); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - game.move.select(MoveId.TACKLE); - await game.move.forceMiss(); - await game.phaseInterceptor.to(TurnEndPhase); - - const enemyPokemon = game.scene.getEnemyPokemon()!; - expect(enemyPokemon.isFullHp()).toBe(true); - testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.TACKLE); - }); - - test("ability applies correctly even if the pokemon's move is protected against", async () => { - game.override.moveset([MoveId.TACKLE]).enemyMoveset(MoveId.PROTECT); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - game.move.select(MoveId.TACKLE); - await game.phaseInterceptor.to(TurnEndPhase); - - testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.TACKLE); - }); - - test("ability applies correctly even if the pokemon's move fails because of type immunity", async () => { - game.override.moveset([MoveId.TACKLE]).enemySpecies(SpeciesId.GASTLY); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - game.move.select(MoveId.TACKLE); - await game.phaseInterceptor.to(TurnEndPhase); - - testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.TACKLE); - }); - - test("ability is not applied if pokemon's type is the same as the move's type", async () => { - game.override.moveset([MoveId.SPLASH]); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - leadPokemon.summonData.types = [allMoves[MoveId.SPLASH].type]; - game.move.select(MoveId.SPLASH); - await game.phaseInterceptor.to(TurnEndPhase); - - expect(leadPokemon.waveData.abilitiesApplied).not.toContain(AbilityId.LIBERO); - }); - - test("ability is not applied if pokemon is terastallized", async () => { - game.override.moveset([MoveId.SPLASH]); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - leadPokemon.isTerastallized = true; - - game.move.select(MoveId.SPLASH); - await game.phaseInterceptor.to(TurnEndPhase); - - expect(leadPokemon.waveData.abilitiesApplied).not.toContain(AbilityId.LIBERO); - }); - - test("ability is not applied if pokemon uses struggle", async () => { - game.override.moveset([MoveId.STRUGGLE]); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - game.move.select(MoveId.STRUGGLE); - await game.phaseInterceptor.to(TurnEndPhase); - - expect(leadPokemon.waveData.abilitiesApplied).not.toContain(AbilityId.LIBERO); - }); - - test("ability is not applied if the pokemon's move fails", async () => { - game.override.moveset([MoveId.BURN_UP]); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - game.move.select(MoveId.BURN_UP); - await game.phaseInterceptor.to(TurnEndPhase); - - expect(leadPokemon.waveData.abilitiesApplied).not.toContain(AbilityId.LIBERO); - }); - - test("ability applies correctly even if the pokemon's Trick-or-Treat fails", async () => { - game.override.moveset([MoveId.TRICK_OR_TREAT]).enemySpecies(SpeciesId.GASTLY); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - game.move.select(MoveId.TRICK_OR_TREAT); - await game.phaseInterceptor.to(TurnEndPhase); - - testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.TRICK_OR_TREAT); - }); - - test("ability applies correctly and the pokemon curses itself", async () => { - game.override.moveset([MoveId.CURSE]); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - game.move.select(MoveId.CURSE); - await game.phaseInterceptor.to(TurnEndPhase); - - testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.CURSE); - expect(leadPokemon.getTag(BattlerTagType.CURSED)).not.toBe(undefined); - }); -}); - -function testPokemonTypeMatchesDefaultMoveType(pokemon: PlayerPokemon, move: MoveId) { - expect(pokemon.waveData.abilitiesApplied).toContain(AbilityId.LIBERO); - expect(pokemon.getTypes()).toHaveLength(1); - const pokemonType = PokemonType[pokemon.getTypes()[0]], - moveType = PokemonType[allMoves[move].type]; - expect(pokemonType).toBe(moveType); -} diff --git a/test/abilities/protean-libero.test.ts b/test/abilities/protean-libero.test.ts new file mode 100644 index 00000000000..9965333c8eb --- /dev/null +++ b/test/abilities/protean-libero.test.ts @@ -0,0 +1,272 @@ +import { allMoves } from "#app/data/data-lists"; +import { PokemonType } from "#enums/pokemon-type"; +import type { PlayerPokemon } from "#app/field/pokemon"; +import { MoveResult } from "#enums/move-result"; +import { AbilityId } from "#enums/ability-id"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { MoveId } from "#enums/move-id"; +import { SpeciesId } from "#enums/species-id"; +import GameManager from "#test/testUtils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { BattleType } from "#enums/battle-type"; +import { BattlerIndex } from "#enums/battler-index"; + +describe("Abilities - Protean/Libero", () => { + 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 + .battleStyle("single") + .ability(AbilityId.PROTEAN) + .startingLevel(100) + .moveset([MoveId.CURSE, MoveId.DIG, MoveId.SPLASH]) + .enemySpecies(SpeciesId.RATTATA) + .enemyMoveset(MoveId.SPLASH); + }); + + /** + * Assert that the protean/libero ability triggered to change the user's type to + * the type of its most recently used move. + * Takes into account type overrides from effects. + * @param pokemon - The {@linkcode PlayerPokemon} being checked. + * @remarks + * This will clear the given Pokemon's `abilitiesApplied` set after being called to allow for easier multi-turn testing. + */ + function expectTypeChange(pokemon: PlayerPokemon) { + expect(pokemon.waveData.abilitiesApplied).toContainEqual(expect.toBeOneOf([AbilityId.PROTEAN, AbilityId.LIBERO])); + const lastMove = allMoves[pokemon.getLastXMoves()[0].move]!; + + const pokemonTypes = pokemon.getTypes().map(pt => PokemonType[pt]); + const moveType = PokemonType[pokemon.getMoveType(lastMove)]; + expect(pokemonTypes).toEqual([moveType]); + pokemon.waveData.abilitiesApplied.clear(); + } + + /** + * Assert that the protean/libero ability did NOT trigger to change the user's type to + * the type of its most recently used move. + * Takes into account type overrides from effects. + * @param pokemon - The {@linkcode PlayerPokemon} being checked. + * @remarks + * This will clear the given Pokemon's `abilitiesApplied` set after being called to allow for easier multi-turn testing. + */ + function expectNoTypeChange(pokemon: PlayerPokemon) { + expect(pokemon.waveData.abilitiesApplied).not.toContainEqual( + expect.toBeOneOf([AbilityId.PROTEAN, AbilityId.LIBERO]), + ); + const lastMove = allMoves[pokemon.getLastXMoves()[0].move]!; + + const pokemonTypes = pokemon.getTypes().map(pt => PokemonType[pt]); + const moveType = PokemonType[pokemon.getMoveType(lastMove, true)]; + expect(pokemonTypes).not.toEqual([moveType]); + pokemon.waveData.abilitiesApplied.clear(); + } + + it.each([ + { name: "Protean", ability: AbilityId.PROTEAN }, + { name: "Libero", ability: AbilityId.PROTEAN }, + ])("$name should change the user's type to the type of the move being used", async ({ ability }) => { + game.override.ability(ability); + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + + const leadPokemon = game.field.getPlayerPokemon(); + + game.move.use(MoveId.SPLASH); + await game.toEndOfTurn(); + + expectTypeChange(leadPokemon); + }); + + // Test for Gen9+ functionality, we are using previous funcionality + it.skip("should apply only once per switch in", async () => { + game.override.moveset([MoveId.SPLASH, MoveId.AGILITY]); + await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.BULBASAUR]); + + const bulbasaur = game.field.getPlayerPokemon(); + + game.move.select(MoveId.SPLASH); + await game.toEndOfTurn(); + + expectTypeChange(bulbasaur); + + game.move.select(MoveId.AGILITY); + await game.toEndOfTurn(); + + expectNoTypeChange(bulbasaur); + + // switch out and back in + game.doSwitchPokemon(1); + await game.toNextTurn(); + game.doSwitchPokemon(1); + await game.toNextTurn(); + + expect(bulbasaur.isOnField()).toBe(true); + + game.move.select(MoveId.SPLASH); + await game.toEndOfTurn(); + + expectTypeChange(bulbasaur); + }); + + it.each<{ category: string; move?: MoveId; passive?: AbilityId; enemyMove?: MoveId }>([ + { category: "Variable type Moves'", move: MoveId.WEATHER_BALL, passive: AbilityId.DROUGHT }, + { category: "Type Change Abilities'", passive: AbilityId.REFRIGERATE }, + { category: "Move-calling Moves'", move: MoveId.NATURE_POWER, passive: AbilityId.PSYCHIC_SURGE }, + { category: "Ion Deluge's", enemyMove: MoveId.ION_DELUGE }, + { category: "Electrify's", enemyMove: MoveId.ELECTRIFY }, + ])( + "should respect $category final type", + async ({ move = MoveId.TACKLE, passive = AbilityId.NONE, enemyMove = MoveId.SPLASH }) => { + game.override.passiveAbility(passive); + await game.classicMode.startBattle([SpeciesId.LINOONE]); // Pure normal type for move overrides + + const linoone = game.field.getPlayerPokemon(); + + game.move.use(move); + await game.move.forceEnemyMove(enemyMove); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + // We stop before running `TurnEndPhase` so that the effects of `BattlerTag`s (such as from Electrify) + // are still active when checking the move's type + await game.phaseInterceptor.to("TurnEndPhase", false); + + expectTypeChange(linoone); + }, + ); + + it.each<{ cause: string; move?: MoveId; passive?: AbilityId; enemyMove?: MoveId }>([ + { cause: "misses", move: MoveId.FOCUS_BLAST }, + { cause: "is protected against", enemyMove: MoveId.PROTECT }, + { cause: "is ineffective", move: MoveId.EARTHQUAKE }, + { cause: "matches only one of its types", move: MoveId.NIGHT_SLASH }, + { cause: "is blocked by terrain", move: MoveId.SHADOW_SNEAK, passive: AbilityId.PSYCHIC_SURGE }, + ])( + "should still trigger if the user's move $cause", + async ({ move = MoveId.TACKLE, passive = AbilityId.NONE, enemyMove = MoveId.SPLASH }) => { + game.override.passiveAbility(passive).enemySpecies(SpeciesId.SKARMORY); + await game.classicMode.startBattle([SpeciesId.MEOWSCARADA]); + + vi.spyOn(allMoves[MoveId.FOCUS_BLAST], "accuracy", "get").mockReturnValue(0); + + const meow = game.field.getPlayerPokemon(); + + game.move.use(move); + await game.move.forceEnemyMove(enemyMove); + await game.toEndOfTurn(); + + expectTypeChange(meow); + }, + ); + + it.each<{ cause: string; move?: MoveId; tera?: boolean; passive?: AbilityId }>([ + { cause: "user is terastallized to any type", tera: true }, + { cause: "user uses Struggle", move: MoveId.STRUGGLE }, + { cause: "the user's move is blocked by weather", move: MoveId.FIRE_BLAST, passive: AbilityId.PRIMORDIAL_SEA }, + { cause: "the user's move fails", move: MoveId.BURN_UP }, + ])("should not apply if $cause", async ({ move = MoveId.TACKLE, tera = false, passive = AbilityId.NONE }) => { + game.override.enemyPassiveAbility(passive); + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + + const karp = game.field.getPlayerPokemon(); + + karp.teraType = PokemonType.STEEL; + + game.move.use(move, BattlerIndex.PLAYER, undefined, tera); + await game.toEndOfTurn(); + + expectNoTypeChange(karp); + }); + + it("should not apply if user is already the move's type", async () => { + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + + const karp = game.field.getPlayerPokemon(); + + game.move.use(MoveId.WATERFALL); + await game.toEndOfTurn(); + + expect(karp.waveData.abilitiesApplied.size).toBe(0); + expect(karp.getTypes()).toEqual([allMoves[MoveId.WATERFALL].type]); + }); + + it.each<{ moveName: string; move: MoveId }>([ + { moveName: "Roar", move: MoveId.ROAR }, + { moveName: "Whirlwind", move: MoveId.WHIRLWIND }, + { moveName: "Forest's Curse", move: MoveId.FORESTS_CURSE }, + { moveName: "Trick-or-Treat", move: MoveId.TRICK_OR_TREAT }, + ])("should still apply if the user's $moveName fails", async ({ move }) => { + game.override.battleType(BattleType.TRAINER).enemySpecies(SpeciesId.TREVENANT); // ghost/grass makes both moves fail + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + + const leadPokemon = game.field.getPlayerPokemon(); + + game.move.use(move); + // KO all off-field opponents for Whirlwind and co. + for (const enemyMon of game.scene.getEnemyParty()) { + if (!enemyMon.isActive()) { + enemyMon.hp = 0; + } + } + await game.toEndOfTurn(); + + expectTypeChange(leadPokemon); + }); + + it("should trigger on the first turn of charging moves", async () => { + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + + const karp = game.field.getPlayerPokemon(); + + game.move.select(MoveId.DIG); + await game.toEndOfTurn(); + + expectTypeChange(karp); + + await game.toEndOfTurn(); + expect(karp.waveData.abilitiesApplied).not.toContain(AbilityId.PROTEAN); + }); + + it("should cause the user to cast Ghost-type Curse on itself", async () => { + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + + const karp = game.field.getPlayerPokemon(); + expect(karp.isOfType(PokemonType.GHOST)).toBe(false); + + game.move.select(MoveId.CURSE); + await game.toEndOfTurn(); + + expectTypeChange(karp); + expect(karp.getHpRatio(true)).toBeCloseTo(0.25); + expect(karp.getTag(BattlerTagType.CURSED)).toBeDefined(); + }); + + it("should not trigger during Focus Punch's start-of-turn message or being interrupted", async () => { + game.override.moveset(MoveId.FOCUS_PUNCH).enemyMoveset(MoveId.ABSORB); + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + + const karp = game.field.getPlayerPokemon(); + expect(karp.isOfType(PokemonType.FIGHTING)).toBe(false); + + game.move.select(MoveId.FOCUS_PUNCH); + + await game.phaseInterceptor.to("MessagePhase"); + expect(karp.isOfType(PokemonType.FIGHTING)).toBe(false); + + await game.toEndOfTurn(); + + expectNoTypeChange(karp); + expect(karp.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + }); +}); diff --git a/test/abilities/protean.test.ts b/test/abilities/protean.test.ts deleted file mode 100644 index 09c9addbc35..00000000000 --- a/test/abilities/protean.test.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { allMoves } from "#app/data/data-lists"; -import { PokemonType } from "#enums/pokemon-type"; -import { Weather } from "#app/data/weather"; -import type { PlayerPokemon } from "#app/field/pokemon"; -import { TurnEndPhase } from "#app/phases/turn-end-phase"; -import { AbilityId } from "#enums/ability-id"; -import { BattlerTagType } from "#enums/battler-tag-type"; -import { BiomeId } from "#enums/biome-id"; -import { MoveId } from "#enums/move-id"; -import { SpeciesId } from "#enums/species-id"; -import { WeatherType } from "#enums/weather-type"; -import GameManager from "#test/testUtils/gameManager"; -import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest"; - -describe("Abilities - Protean", () => { - 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 - .battleStyle("single") - .ability(AbilityId.PROTEAN) - .startingLevel(100) - .enemySpecies(SpeciesId.RATTATA) - .enemyMoveset(MoveId.ENDURE); - }); - - test("ability applies and changes a pokemon's type", async () => { - game.override.moveset([MoveId.SPLASH]); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - game.move.select(MoveId.SPLASH); - await game.phaseInterceptor.to(TurnEndPhase); - - testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.SPLASH); - }); - - // Test for Gen9+ functionality, we are using previous funcionality - test.skip("ability applies only once per switch in", async () => { - game.override.moveset([MoveId.SPLASH, MoveId.AGILITY]); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.BULBASAUR]); - - let leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - game.move.select(MoveId.SPLASH); - await game.phaseInterceptor.to(TurnEndPhase); - - testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.SPLASH); - - game.move.select(MoveId.AGILITY); - await game.phaseInterceptor.to(TurnEndPhase); - - expect(leadPokemon.waveData.abilitiesApplied).toContain(AbilityId.PROTEAN); - const leadPokemonType = PokemonType[leadPokemon.getTypes()[0]]; - const moveType = PokemonType[allMoves[MoveId.AGILITY].type]; - expect(leadPokemonType).not.toBe(moveType); - - await game.toNextTurn(); - game.doSwitchPokemon(1); - await game.toNextTurn(); - game.doSwitchPokemon(1); - await game.toNextTurn(); - - leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - game.move.select(MoveId.SPLASH); - await game.phaseInterceptor.to(TurnEndPhase); - - testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.SPLASH); - }); - - test("ability applies correctly even if the pokemon's move has a variable type", async () => { - game.override.moveset([MoveId.WEATHER_BALL]); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - game.scene.arena.weather = new Weather(WeatherType.SUNNY); - game.move.select(MoveId.WEATHER_BALL); - await game.phaseInterceptor.to(TurnEndPhase); - - expect(leadPokemon.waveData.abilitiesApplied).toContain(AbilityId.PROTEAN); - expect(leadPokemon.getTypes()).toHaveLength(1); - const leadPokemonType = PokemonType[leadPokemon.getTypes()[0]], - moveType = PokemonType[PokemonType.FIRE]; - expect(leadPokemonType).toBe(moveType); - }); - - test("ability applies correctly even if the type has changed by another ability", async () => { - game.override.moveset([MoveId.TACKLE]).passiveAbility(AbilityId.REFRIGERATE); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - game.move.select(MoveId.TACKLE); - await game.phaseInterceptor.to(TurnEndPhase); - - expect(leadPokemon.waveData.abilitiesApplied).toContain(AbilityId.PROTEAN); - expect(leadPokemon.getTypes()).toHaveLength(1); - const leadPokemonType = PokemonType[leadPokemon.getTypes()[0]], - moveType = PokemonType[PokemonType.ICE]; - expect(leadPokemonType).toBe(moveType); - }); - - test("ability applies correctly even if the pokemon's move calls another move", async () => { - game.override.moveset([MoveId.NATURE_POWER]); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - game.scene.arena.biomeType = BiomeId.MOUNTAIN; - game.move.select(MoveId.NATURE_POWER); - await game.phaseInterceptor.to(TurnEndPhase); - - testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.AIR_SLASH); - }); - - test("ability applies correctly even if the pokemon's move is delayed / charging", async () => { - game.override.moveset([MoveId.DIG]); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - game.move.select(MoveId.DIG); - await game.phaseInterceptor.to(TurnEndPhase); - - testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.DIG); - }); - - test("ability applies correctly even if the pokemon's move misses", async () => { - game.override.moveset([MoveId.TACKLE]).enemyMoveset(MoveId.SPLASH); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - game.move.select(MoveId.TACKLE); - await game.move.forceMiss(); - await game.phaseInterceptor.to(TurnEndPhase); - - const enemyPokemon = game.scene.getEnemyPokemon()!; - expect(enemyPokemon.isFullHp()).toBe(true); - testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.TACKLE); - }); - - test("ability applies correctly even if the pokemon's move is protected against", async () => { - game.override.moveset([MoveId.TACKLE]).enemyMoveset(MoveId.PROTECT); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - game.move.select(MoveId.TACKLE); - await game.phaseInterceptor.to(TurnEndPhase); - - testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.TACKLE); - }); - - test("ability applies correctly even if the pokemon's move fails because of type immunity", async () => { - game.override.moveset([MoveId.TACKLE]).enemySpecies(SpeciesId.GASTLY); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - game.move.select(MoveId.TACKLE); - await game.phaseInterceptor.to(TurnEndPhase); - - testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.TACKLE); - }); - - test("ability is not applied if pokemon's type is the same as the move's type", async () => { - game.override.moveset([MoveId.SPLASH]); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - leadPokemon.summonData.types = [allMoves[MoveId.SPLASH].type]; - game.move.select(MoveId.SPLASH); - await game.phaseInterceptor.to(TurnEndPhase); - - expect(leadPokemon.waveData.abilitiesApplied).not.toContain(AbilityId.PROTEAN); - }); - - test("ability is not applied if pokemon is terastallized", async () => { - game.override.moveset([MoveId.SPLASH]); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - leadPokemon.isTerastallized = true; - - game.move.select(MoveId.SPLASH); - await game.phaseInterceptor.to(TurnEndPhase); - - expect(leadPokemon.waveData.abilitiesApplied).not.toContain(AbilityId.PROTEAN); - }); - - test("ability is not applied if pokemon uses struggle", async () => { - game.override.moveset([MoveId.STRUGGLE]); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - game.move.select(MoveId.STRUGGLE); - await game.phaseInterceptor.to(TurnEndPhase); - - expect(leadPokemon.waveData.abilitiesApplied).not.toContain(AbilityId.PROTEAN); - }); - - test("ability is not applied if the pokemon's move fails", async () => { - game.override.moveset([MoveId.BURN_UP]); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - game.move.select(MoveId.BURN_UP); - await game.phaseInterceptor.to(TurnEndPhase); - - expect(leadPokemon.waveData.abilitiesApplied).not.toContain(AbilityId.PROTEAN); - }); - - test("ability applies correctly even if the pokemon's Trick-or-Treat fails", async () => { - game.override.moveset([MoveId.TRICK_OR_TREAT]).enemySpecies(SpeciesId.GASTLY); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - game.move.select(MoveId.TRICK_OR_TREAT); - await game.phaseInterceptor.to(TurnEndPhase); - - testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.TRICK_OR_TREAT); - }); - - test("ability applies correctly and the pokemon curses itself", async () => { - game.override.moveset([MoveId.CURSE]); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - game.move.select(MoveId.CURSE); - await game.phaseInterceptor.to(TurnEndPhase); - - testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.CURSE); - expect(leadPokemon.getTag(BattlerTagType.CURSED)).not.toBe(undefined); - }); -}); - -function testPokemonTypeMatchesDefaultMoveType(pokemon: PlayerPokemon, move: MoveId) { - expect(pokemon.waveData.abilitiesApplied).toContain(AbilityId.PROTEAN); - expect(pokemon.getTypes()).toHaveLength(1); - const pokemonType = PokemonType[pokemon.getTypes()[0]], - moveType = PokemonType[allMoves[move].type]; - expect(pokemonType).toBe(moveType); -} diff --git a/test/moves/ability-ignore-moves.test.ts b/test/moves/ability-ignore-moves.test.ts new file mode 100644 index 00000000000..52214c61bc7 --- /dev/null +++ b/test/moves/ability-ignore-moves.test.ts @@ -0,0 +1,109 @@ +import { BattlerIndex } from "#enums/battler-index"; +import { RandomMoveAttr } from "#app/data/moves/move"; +import { AbilityId } from "#enums/ability-id"; +import { MoveId } from "#enums/move-id"; +import { SpeciesId } from "#enums/species-id"; +import GameManager from "#test/testUtils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("Moves - Ability-Ignoring Moves", () => { + 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([MoveId.MOONGEIST_BEAM, MoveId.SUNSTEEL_STRIKE, MoveId.PHOTON_GEYSER, MoveId.METRONOME]) + .ability(AbilityId.BALL_FETCH) + .startingLevel(200) + .battleStyle("single") + .criticalHits(false) + .enemySpecies(SpeciesId.MAGIKARP) + .enemyAbility(AbilityId.STURDY) + .enemyMoveset(MoveId.SPLASH); + }); + + it.each<{ name: string; move: MoveId }>([ + { name: "Sunsteel Strike", move: MoveId.SUNSTEEL_STRIKE }, + { name: "Moongeist Beam", move: MoveId.MOONGEIST_BEAM }, + { name: "Photon Geyser", move: MoveId.PHOTON_GEYSER }, + ])("$name should ignore enemy abilities during move use", async ({ move }) => { + await game.classicMode.startBattle([SpeciesId.NECROZMA]); + + const player = game.field.getPlayerPokemon(); + const enemy = game.field.getEnemyPokemon(); + + game.move.select(move); + await game.phaseInterceptor.to("MoveEffectPhase"); + + expect(game.scene.arena.ignoreAbilities).toBe(true); + expect(game.scene.arena.ignoringEffectSource).toBe(player.getBattlerIndex()); + + await game.toEndOfTurn(); + expect(game.scene.arena.ignoreAbilities).toBe(false); + expect(enemy.isFainted()).toBe(true); + }); + + it("should not ignore enemy abilities when called by Metronome", async () => { + await game.classicMode.startBattle([SpeciesId.MILOTIC]); + vi.spyOn(RandomMoveAttr.prototype, "getMoveOverride").mockReturnValue(MoveId.PHOTON_GEYSER); + + const enemy = game.field.getEnemyPokemon(); + game.move.select(MoveId.METRONOME); + await game.toEndOfTurn(); + + expect(enemy.isFainted()).toBe(false); + expect(game.field.getPlayerPokemon().getLastXMoves()[0].move).toBe(MoveId.PHOTON_GEYSER); + }); + + it("should not ignore enemy abilities when called by Mirror Move", async () => { + game.override.moveset(MoveId.MIRROR_MOVE).enemyMoveset(MoveId.SUNSTEEL_STRIKE); + + await game.classicMode.startBattle([SpeciesId.MILOTIC]); + + const enemy = game.field.getEnemyPokemon(); + game.move.select(MoveId.MIRROR_MOVE); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.toEndOfTurn(); + + expect(enemy.isFainted()).toBe(false); + expect(game.scene.getPlayerPokemon()?.getLastXMoves()[0].move).toBe(MoveId.SUNSTEEL_STRIKE); + }); + + // TODO: Verify this behavior on cart + it("should ignore enemy abilities when called by Instruct", async () => { + game.override.moveset([MoveId.SUNSTEEL_STRIKE, MoveId.INSTRUCT]).battleStyle("double"); + await game.classicMode.startBattle([SpeciesId.SOLGALEO, SpeciesId.LUNALA]); + + const solgaleo = game.field.getPlayerPokemon(); + + game.move.select(MoveId.SUNSTEEL_STRIKE, BattlerIndex.PLAYER, BattlerIndex.ENEMY); + game.move.select(MoveId.INSTRUCT, BattlerIndex.PLAYER_2, BattlerIndex.PLAYER); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]); + + await game.phaseInterceptor.to("MoveEffectPhase"); // initial attack + await game.phaseInterceptor.to("MoveEffectPhase"); // instruct + await game.phaseInterceptor.to("MoveEffectPhase"); // instructed move use + + expect(game.scene.arena.ignoreAbilities).toBe(true); + expect(game.scene.arena.ignoringEffectSource).toBe(solgaleo.getBattlerIndex()); + + await game.toEndOfTurn(); + + // Both the initial and redirected instruct use ignored sturdy + const [enemy1, enemy2] = game.scene.getEnemyField(); + expect(enemy1.isFainted()).toBe(true); + expect(enemy2.isFainted()).toBe(true); + }); +}); diff --git a/test/moves/first-attack-double-power.test.ts b/test/moves/first-attack-double-power.test.ts new file mode 100644 index 00000000000..0c63cc24832 --- /dev/null +++ b/test/moves/first-attack-double-power.test.ts @@ -0,0 +1,122 @@ +import { BattlerIndex } from "#enums/battler-index"; +import { allMoves } from "#app/data/data-lists"; +import { AbilityId } from "#enums/ability-id"; +import { BattleType } from "#enums/battle-type"; +import { MoveId } from "#enums/move-id"; +import { SpeciesId } from "#enums/species-id"; +import GameManager from "#test/testUtils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest"; +import { MoveUseMode } from "#enums/move-use-mode"; + +describe("Moves - Fishious Rend & Bolt Beak", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let powerSpy: MockInstance; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .ability(AbilityId.STURDY) + .battleStyle("single") + .battleType(BattleType.TRAINER) + .criticalHits(false) + .enemyLevel(100) + .enemySpecies(SpeciesId.DRACOVISH) + .enemyAbility(AbilityId.BALL_FETCH) + .enemyMoveset(MoveId.SPLASH); + + powerSpy = vi.spyOn(allMoves[MoveId.BOLT_BEAK], "calculateBattlePower"); + }); + + it.each<{ name: string; move: MoveId }>([ + { name: "Bolt Beak", move: MoveId.BOLT_BEAK }, + { name: "Fishious Rend", move: MoveId.FISHIOUS_REND }, + ])("$name should double power if the user moves before the target", async ({ move }) => { + powerSpy = vi.spyOn(allMoves[move], "calculateBattlePower"); + await game.classicMode.startBattle([SpeciesId.FEEBAS]); + + // turn 1: enemy, then player (no boost) + game.move.use(move); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.toNextTurn(); + + expect(powerSpy).toHaveLastReturnedWith(allMoves[move].power); + + // turn 2: player, then enemy (boost) + game.move.use(move); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.toEndOfTurn(); + + expect(powerSpy).toHaveLastReturnedWith(allMoves[move].power * 2); + }); + + it("should only consider the selected target in Double Battles", async () => { + game.override.battleStyle("double"); + await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.MILOTIC]); + + // Use move after everyone but P1 and enemy 1 have already moved + game.move.use(MoveId.BOLT_BEAK, BattlerIndex.PLAYER, BattlerIndex.ENEMY); + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2); + await game.setTurnOrder([BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.toEndOfTurn(); + + expect(powerSpy).toHaveLastReturnedWith(allMoves[MoveId.BOLT_BEAK].power * 2); + }); + + it("should double power on the turn the target switches in", async () => { + await game.classicMode.startBattle([SpeciesId.FEEBAS]); + + game.move.use(MoveId.BOLT_BEAK); + game.forceEnemyToSwitch(); + await game.toEndOfTurn(); + + expect(powerSpy).toHaveLastReturnedWith(allMoves[MoveId.BOLT_BEAK].power * 2); + }); + + it("should double power on forced switch-induced sendouts", async () => { + await game.classicMode.startBattle([SpeciesId.FEEBAS]); + + game.move.use(MoveId.BOLT_BEAK); + await game.move.forceEnemyMove(MoveId.U_TURN); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.toEndOfTurn(); + + expect(powerSpy).toHaveLastReturnedWith(allMoves[MoveId.BOLT_BEAK].power * 2); + }); + + it.each<{ type: string; allyMove: MoveId }>([ + { type: "a Dancer-induced", allyMove: MoveId.FIERY_DANCE }, + { type: "an Instructed", allyMove: MoveId.INSTRUCT }, + ])("should double power if $type move is used as the target's first action that turn", async ({ allyMove }) => { + game.override.battleStyle("double").enemyAbility(AbilityId.DANCER); + powerSpy = vi.spyOn(allMoves[MoveId.FISHIOUS_REND], "calculateBattlePower"); + await game.classicMode.startBattle([SpeciesId.DRACOVISH, SpeciesId.ARCTOZOLT]); + + // Simulate enemy having used splash last turn to allow Instruct to copy it + const enemy = game.field.getEnemyPokemon(); + enemy.pushMoveHistory({ + move: MoveId.SPLASH, + targets: [BattlerIndex.ENEMY], + turn: game.scene.currentBattle.turn - 1, + useMode: MoveUseMode.NORMAL, + }); + + game.move.use(MoveId.FISHIOUS_REND, BattlerIndex.PLAYER, BattlerIndex.ENEMY); + game.move.use(allyMove, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY); + await game.setTurnOrder([BattlerIndex.PLAYER_2, BattlerIndex.PLAYER, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]); + await game.toEndOfTurn(); + + expect(powerSpy).toHaveLastReturnedWith(allMoves[MoveId.FISHIOUS_REND].power); + }); +}); diff --git a/test/moves/moongeist_beam.test.ts b/test/moves/moongeist_beam.test.ts deleted file mode 100644 index 367fe67cff5..00000000000 --- a/test/moves/moongeist_beam.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { allMoves } from "#app/data/data-lists"; -import { AbilityId } from "#enums/ability-id"; -import { MoveId } from "#enums/move-id"; -import { SpeciesId } from "#enums/species-id"; -import GameManager from "#test/testUtils/gameManager"; -import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; - -describe("Moves - Moongeist Beam", () => { - 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([MoveId.MOONGEIST_BEAM, MoveId.METRONOME]) - .ability(AbilityId.BALL_FETCH) - .startingLevel(200) - .battleStyle("single") - .criticalHits(false) - .enemySpecies(SpeciesId.MAGIKARP) - .enemyAbility(AbilityId.STURDY) - .enemyMoveset(MoveId.SPLASH); - }); - - // Also covers Photon Geyser and Sunsteel Strike - it("should ignore enemy abilities", async () => { - await game.classicMode.startBattle([SpeciesId.MILOTIC]); - - const enemy = game.scene.getEnemyPokemon()!; - - game.move.select(MoveId.MOONGEIST_BEAM); - await game.phaseInterceptor.to("BerryPhase"); - - expect(enemy.isFainted()).toBe(true); - }); - - // Also covers Photon Geyser and Sunsteel Strike - it("should not ignore enemy abilities when called by another move, such as metronome", async () => { - await game.classicMode.startBattle([SpeciesId.MILOTIC]); - vi.spyOn(allMoves[MoveId.METRONOME].getAttrs("RandomMoveAttr")[0], "getMoveOverride").mockReturnValue( - MoveId.MOONGEIST_BEAM, - ); - - game.move.select(MoveId.METRONOME); - await game.phaseInterceptor.to("BerryPhase"); - - expect(game.scene.getEnemyPokemon()!.isFainted()).toBe(false); - expect(game.scene.getPlayerPokemon()!.getLastXMoves()[0].move).toBe(MoveId.MOONGEIST_BEAM); - }); -}); diff --git a/test/moves/payback.test.ts b/test/moves/payback.test.ts new file mode 100644 index 00000000000..0c32037a58e --- /dev/null +++ b/test/moves/payback.test.ts @@ -0,0 +1,69 @@ +import { allMoves } from "#app/data/data-lists"; +import { AbilityId } from "#enums/ability-id"; +import { BattlerIndex } from "#enums/battler-index"; +import { MoveId } from "#enums/move-id"; +import { PokeballType } from "#enums/pokeball"; +import { SpeciesId } from "#enums/species-id"; +import GameManager from "#test/testUtils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest"; + +describe("Move - Payback", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let powerSpy: MockInstance; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .ability(AbilityId.BALL_FETCH) + .battleStyle("single") + .criticalHits(false) + .enemyLevel(100) + .enemySpecies(SpeciesId.DRACOVISH) + .enemyAbility(AbilityId.BALL_FETCH) + .enemyMoveset(MoveId.SPLASH) + .startingModifier([{ name: "POKEBALL", count: 5 }]); + + powerSpy = vi.spyOn(allMoves[MoveId.PAYBACK], "calculateBattlePower"); + }); + + it("should double power if the user moves after the target", async () => { + await game.classicMode.startBattle([SpeciesId.FEEBAS]); + + // turn 1: enemy, then player (boost) + game.move.use(MoveId.PAYBACK); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.toNextTurn(); + + expect(powerSpy).toHaveLastReturnedWith(allMoves[MoveId.PAYBACK].power * 2); + + // turn 2: player, then enemy (no boost) + game.move.use(MoveId.PAYBACK); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.toEndOfTurn(); + + expect(powerSpy).toHaveLastReturnedWith(allMoves[MoveId.PAYBACK].power); + }); + + // TODO: Enable test once ability to force catch failure is added + it.todo("should trigger for enemies on player failed ball catch", async () => { + await game.classicMode.startBattle([SpeciesId.FEEBAS]); + + game.doThrowPokeball(PokeballType.POKEBALL); + await game.move.forceEnemyMove(MoveId.PAYBACK); + await game.toEndOfTurn(); + + expect(powerSpy).toHaveLastReturnedWith(allMoves[MoveId.PAYBACK].power * 2); + }); +}); diff --git a/test/testUtils/gameManager.ts b/test/testUtils/gameManager.ts index 91f9096f3d4..a1d9936beaa 100644 --- a/test/testUtils/gameManager.ts +++ b/test/testUtils/gameManager.ts @@ -56,6 +56,7 @@ import TextInterceptor from "#test/testUtils/TextInterceptor"; import { AES, enc } from "crypto-js"; import fs from "node:fs"; import { expect, vi } from "vitest"; +import type { PokeballType } from "#enums/pokeball"; /** * Class to manage the game state and transitions between phases. @@ -507,9 +508,9 @@ export default class GameManager { /** * Select the BALL option from the command menu, then press Action; in the BALL * menu, select a pokéball type and press Action again to throw it. - * @param ballIndex - The index of the pokeball to throw + * @param ballIndex - The {@linkcode PokeballType} to throw */ - public doThrowPokeball(ballIndex: number) { + public doThrowPokeball(ballIndex: PokeballType) { this.onNextPrompt("CommandPhase", UiMode.COMMAND, () => { (this.scene.ui.getHandler() as CommandUiHandler).setCursor(1); (this.scene.ui.getHandler() as CommandUiHandler).processInput(Button.ACTION);