From a77e3c911f43d3cf6884967795d073f856394a24 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Sun, 17 Aug 2025 21:26:45 -0500 Subject: [PATCH] Add second and third failure sequences --- src/data/moves/move-condition.ts | 41 +++-- src/data/moves/move.ts | 78 ++++++-- src/field/pokemon.ts | 6 +- src/phases/command-phase.ts | 1 + src/phases/move-phase.ts | 304 +++++++++++++++++++++++-------- 5 files changed, 319 insertions(+), 111 deletions(-) diff --git a/src/data/moves/move-condition.ts b/src/data/moves/move-condition.ts index ffb52294123..5d03bc7926d 100644 --- a/src/data/moves/move-condition.ts +++ b/src/data/moves/move-condition.ts @@ -1,3 +1,5 @@ +// biome-ignore lint/correctness/noUnusedImports: Used in a tsdoc comment +import type { GameMode } from "#app/game-mode"; import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; import { allMoves } from "#data/data-lists"; @@ -45,29 +47,44 @@ export class FirstMoveCondition extends MoveCondition { return user.tempSummonData.waveTurnCount === 1; }; + // TODO: Update AI move selection logic to not require this method at all + // Currently, it is used to avoid having the AI select the move if its condition will fail getUserBenefitScore(user: Pokemon, _target: Pokemon, _move: Move): number { return this.apply(user, _target, _move) ? 10 : -20; } } +/** + * Condition that forces moves to fail against the final boss in classic and the major boss in endless + * @remarks + * ⚠️ Only works reliably for single-target moves as only one target is provided; should not be used for multi-target moves + * @see {@linkcode GameMode.isBattleClassicFinalBoss} + * @see {@linkcode GameMode.isEndlessMinorBoss} + */ +export const failAgainstFinalBossCondition = new MoveCondition((_user, target) => { + const gameMode = globalScene.gameMode; + const currentWave = globalScene.currentBattle.waveIndex; + return ( + target.isEnemy() && (gameMode.isBattleClassicFinalBoss(currentWave) || gameMode.isEndlessMinorBoss(currentWave)) + ); +}); + /** * Condition used by the move {@link https://bulbapedia.bulbagarden.net/wiki/Upper_Hand_(move) | Upper Hand}. * Moves with this condition are only successful when the target has selected * a high-priority attack (after factoring in priority-boosting effects) and * hasn't moved yet this turn. */ -export class UpperHandCondition extends MoveCondition { - public override readonly func: MoveConditionFunc = (_user, target) => { - const targetCommand = globalScene.currentBattle.turnCommands[target.getBattlerIndex()]; - return ( - targetCommand?.command === Command.FIGHT && - !target.turnData.acted && - !!targetCommand.move?.move && - allMoves[targetCommand.move.move].category !== MoveCategory.STATUS && - allMoves[targetCommand.move.move].getPriority(target) > 0 - ); - }; -} +export const UpperHandCondition = new MoveCondition((_user, target) => { + const targetCommand = globalScene.currentBattle.turnCommands[target.getBattlerIndex()]; + return ( + targetCommand?.command === Command.FIGHT && + !target.turnData.acted && + !!targetCommand.move?.move && + allMoves[targetCommand.move.move].category !== MoveCategory.STATUS && + allMoves[targetCommand.move.move].getPriority(target) > 0 + ); +}); /** * A restriction that prevents a move from being selected diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 4c6dd3ab599..7ef35de9422 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -93,7 +93,7 @@ import { getEnumValues } from "#utils/enums"; import { toCamelCase, toTitleCase } from "#utils/strings"; import i18next from "i18next"; import { applyChallenges } from "#utils/challenge-utils"; -import { ConsecutiveUseRestriction, FirstMoveCondition, GravityUseRestriction, MoveCondition, MoveRestriction, UpperHandCondition } from "#moves/move-condition"; +import { ConsecutiveUseRestriction, failAgainstFinalBossCondition, FirstMoveCondition, GravityUseRestriction, MoveCondition, MoveRestriction, UpperHandCondition } from "#moves/move-condition"; /** * A function used to conditionally determine execution of a given {@linkcode MoveAttr}. @@ -122,6 +122,10 @@ export abstract class Move implements Localizable { * @remarks Different from {@linkcode restrictions}, which is checked when the move is selected */ private conditions: MoveCondition[] = []; + /** + * Move failure conditions that occur during the second check (after move message and before ) + */ + private conditionsSeq2: MoveCondition[] = []; /** Conditions that must be false for a move to be able to be selected. * * @remarks Different from {@linkcode conditions}, which is checked when the move is invoked @@ -380,13 +384,15 @@ export abstract class Move implements Localizable { * Adds a condition to this move (in addition to any provided by its prior {@linkcode MoveAttr}s). * The move will fail upon use if at least 1 of its conditions is not met. * @param condition - The {@linkcode MoveCondition} or {@linkcode MoveConditionFunc} to add to the conditions array. + * @param checkSequence - The sequence number where the failure check occurs * @returns `this` for method chaining */ - condition(condition: MoveCondition | MoveConditionFunc): this { + condition(condition: MoveCondition | MoveConditionFunc, checkSequence: 2 | 3 = 3): this { + const conditionsArray = checkSequence === 2 ? this.conditionsSeq2 : this.conditions; if (typeof condition === "function") { condition = new MoveCondition(condition); } - this.conditions.push(condition); + conditionsArray.push(condition); return this; } @@ -762,13 +768,15 @@ export abstract class Move implements Localizable { /** * Applies each {@linkcode MoveCondition} function of this move to the params, determines if the move can be used prior to calling each attribute's apply() - * @param user {@linkcode Pokemon} to apply conditions to - * @param target {@linkcode Pokemon} to apply conditions to - * @param move {@linkcode Move} to apply conditions to + * @param user - {@linkcode Pokemon} to apply conditions to + * @param target - {@linkcode Pokemon} to apply conditions to + * @param move - {@linkcode Move} to apply conditions to + * @param sequence - The sequence number where the condition check occurs, defaults to 3 * @returns boolean: false if any of the apply()'s return false, else true */ - applyConditions(user: Pokemon, target: Pokemon): boolean { - return this.conditions.every(cond => cond.apply(user, target, this)); + applyConditions(user: Pokemon, target: Pokemon, sequence: number = 3): boolean { + const conditionsArray = sequence === 2 ? this.conditionsSeq2 : this.conditions; + return conditionsArray.every(cond => cond.apply(user, target, this)); } @@ -9105,6 +9113,7 @@ export function initMoves() { new SelfStatusMove(MoveId.DESTINY_BOND, PokemonType.GHOST, -1, 5, -1, 0, 2) .ignoresProtect() .attr(DestinyBondAttr) + .condition(failAgainstFinalBossCondition, 2) .condition((user, target, move) => { // Retrieves user's previous move, returns empty array if no moves have been used const lastTurnMove = user.getLastXMoves(1); @@ -9389,6 +9398,8 @@ export function initMoves() { .unimplemented(), new StatusMove(MoveId.ROLE_PLAY, PokemonType.PSYCHIC, -1, 10, -1, 0, 3) .ignoresSubstitute() + // TODO: Enable / remove once balance reaches a consensus on ability overrides during boss fights + // .condition(failAgainstFinalBossCondition, 3) .attr(AbilityCopyAttr), new SelfStatusMove(MoveId.WISH, PokemonType.NORMAL, -1, 10, -1, 0, 3) .attr(WishAttr) @@ -9436,6 +9447,8 @@ export function initMoves() { new StatusMove(MoveId.IMPRISON, PokemonType.PSYCHIC, 100, 10, -1, 0, 3) .ignoresSubstitute() .attr(AddArenaTagAttr, ArenaTagType.IMPRISON, 1, true, false) + // TODO: Enable / remove once balance reaches a consensus on imprison interaction during the final boss fight + // .condition(failAgainstFinalBossCondition, 2) .target(MoveTarget.ENEMY_SIDE), new SelfStatusMove(MoveId.REFRESH, PokemonType.NORMAL, -1, 20, -1, 0, 3) .attr(HealStatusEffectAttr, true, [ StatusEffect.PARALYSIS, StatusEffect.POISON, StatusEffect.TOXIC, StatusEffect.BURN ]) @@ -9767,6 +9780,8 @@ export function initMoves() { .edgeCase(), // May or may not need to ignore remotely called moves depending on how it works new StatusMove(MoveId.WORRY_SEED, PokemonType.GRASS, 100, 10, -1, 0, 4) .attr(AbilityChangeAttr, AbilityId.INSOMNIA) + // TODO: Enable / remove once balance reaches a consensus on ability overrides during boss fights + // .condition(failAgainstFinalBossCondition, 3) .reflectable(), new AttackMove(MoveId.SUCKER_PUNCH, PokemonType.DARK, MoveCategory.PHYSICAL, 70, 100, 5, -1, 1, 4) .condition((user, target, move) => { @@ -9999,9 +10014,13 @@ export function initMoves() { .attr(AddArenaTagAttr, ArenaTagType.WIDE_GUARD, 1, true, true) .condition(failIfLastCondition), new StatusMove(MoveId.GUARD_SPLIT, PokemonType.PSYCHIC, -1, 10, -1, 0, 5) + // TODO: Enable / remove once balance reaches a consensus on imprison interaction during the final boss fight + // .condition(failAgainstFinalBossCondition, 2) .attr(AverageStatsAttr, [ Stat.DEF, Stat.SPDEF ], "moveTriggers:sharedGuard"), new StatusMove(MoveId.POWER_SPLIT, PokemonType.PSYCHIC, -1, 10, -1, 0, 5) .attr(AverageStatsAttr, [ Stat.ATK, Stat.SPATK ], "moveTriggers:sharedPower"), + // TODO: Enable / remove once balance reaches a consensus on imprison interaction during the final boss fight + // .condition(failAgainstFinalBossCondition, 2) new StatusMove(MoveId.WONDER_ROOM, PokemonType.PSYCHIC, -1, 10, -1, 0, 5) .ignoresProtect() .target(MoveTarget.BOTH_SIDES) @@ -10071,9 +10090,13 @@ export function initMoves() { .attr(TargetAtkUserAtkAttr), new StatusMove(MoveId.SIMPLE_BEAM, PokemonType.NORMAL, 100, 15, -1, 0, 5) .attr(AbilityChangeAttr, AbilityId.SIMPLE) + // TODO: Enable / remove once balance reaches a consensus on ability overrides during boss fights + // .condition(failAgainstFinalBossCondition, 3) .reflectable(), new StatusMove(MoveId.ENTRAINMENT, PokemonType.NORMAL, 100, 15, -1, 0, 5) .attr(AbilityGiveAttr) + // TODO: Enable / remove once balance reaches a consensus on ability overrides during boss fights + // .condition(failAgainstFinalBossCondition, 3) .reflectable(), new StatusMove(MoveId.AFTER_YOU, PokemonType.NORMAL, -1, 15, -1, 0, 5) .ignoresProtect() @@ -10659,7 +10682,12 @@ export function initMoves() { new AttackMove(MoveId.POLLEN_PUFF, PokemonType.BUG, MoveCategory.SPECIAL, 90, 100, 15, -1, 0, 7) .attr(StatusCategoryOnAllyAttr) .attr(HealOnAllyAttr, 0.5, true, false) - .ballBombMove(), + .ballBombMove() + // Fail if used against an ally that is affected by heal block, during the second failure check + .condition( + (user, target) => target.getAlly() === user && !!target.getTag(BattlerTagType.HEAL_BLOCK), + 2 + ), new AttackMove(MoveId.ANCHOR_SHOT, PokemonType.STEEL, MoveCategory.PHYSICAL, 80, 100, 20, 100, 0, 7) .attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, false, 1, 1, true), new StatusMove(MoveId.PSYCHIC_TERRAIN, PokemonType.PSYCHIC, -1, 10, -1, 0, 7) @@ -10672,16 +10700,21 @@ export function initMoves() { new AttackMove(MoveId.POWER_TRIP, PokemonType.DARK, MoveCategory.PHYSICAL, 20, 100, 10, -1, 0, 7) .attr(PositiveStatStagePowerAttr), new AttackMove(MoveId.BURN_UP, PokemonType.FIRE, MoveCategory.SPECIAL, 130, 100, 5, -1, 0, 7) - .condition((user) => { - const userTypes = user.getTypes(true); - return userTypes.includes(PokemonType.FIRE); - }) + .condition( + // Pass `true` to `ForDefend` as it should fail if the user is terastallized to a type that is not FIRE + user => user.isOfType(PokemonType.FIRE, true, true), + 2 + ) .attr(HealStatusEffectAttr, true, StatusEffect.FREEZE) .attr(AddBattlerTagAttr, BattlerTagType.BURNED_UP, true, false) .attr(RemoveTypeAttr, PokemonType.FIRE, (user) => { globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:burnedItselfOut", { pokemonName: getPokemonNameWithAffix(user) })); }), new StatusMove(MoveId.SPEED_SWAP, PokemonType.PSYCHIC, -1, 10, -1, 0, 7) + // Note: the 3 is NOT a typo; unlike power split / guard split which happen in the second failure sequence, speed + // swap's check happens in the third + // TODO: Enable / remove once balance reaches a consensus on imprison interaction during the final boss fight + // .condition(failAgainstFinalBossCondition, 3) .attr(SwapStatAttr, Stat.SPD) .ignoresSubstitute(), new AttackMove(MoveId.SMART_STRIKE, PokemonType.STEEL, MoveCategory.PHYSICAL, 70, -1, 10, -1, 0, 7), @@ -10908,8 +10941,12 @@ export function initMoves() { true), new SelfStatusMove(MoveId.NO_RETREAT, PokemonType.FIGHTING, -1, 5, -1, 0, 8) .attr(StatStageChangeAttr, [ Stat.ATK, Stat.DEF, Stat.SPATK, Stat.SPDEF, Stat.SPD ], 1, true) - .attr(AddBattlerTagAttr, BattlerTagType.NO_RETREAT, true, false) - .condition((user, target, move) => user.getTag(TrappedTag)?.tagType !== BattlerTagType.NO_RETREAT), // fails if the user is currently trapped by No Retreat + .attr(AddBattlerTagAttr, BattlerTagType.NO_RETREAT, true, true /* NOT ADDED if already trapped */) + .condition( + // fails if the user is currently trapped specifically from no retreat + user => user.getTag(TrappedTag)?.tagType !== BattlerTagType.NO_RETREAT, + 2 + ), new StatusMove(MoveId.TAR_SHOT, PokemonType.ROCK, 100, 15, -1, 0, 8) .attr(StatStageChangeAttr, [ Stat.SPD ], -1) .attr(AddBattlerTagAttr, BattlerTagType.TAR_SHOT, false) @@ -11472,10 +11509,11 @@ export function initMoves() { .slicingMove() .triageMove(), new AttackMove(MoveId.DOUBLE_SHOCK, PokemonType.ELECTRIC, MoveCategory.PHYSICAL, 120, 100, 5, -1, 0, 9) - .condition((user) => { - const userTypes = user.getTypes(true); - return userTypes.includes(PokemonType.ELECTRIC); - }) + .condition( + // Pass `true` to `isOfType` to fail if the user is terastallized to a type other than ELECTRIC + user => user.isOfType(PokemonType.ELECTRIC, true, true), + 2 + ) .attr(AddBattlerTagAttr, BattlerTagType.DOUBLE_SHOCKED, true, false) .attr(RemoveTypeAttr, PokemonType.ELECTRIC, (user) => { globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:usedUpAllElectricity", { pokemonName: getPokemonNameWithAffix(user) })); @@ -11573,7 +11611,7 @@ export function initMoves() { .attr(AddBattlerTagAttr, BattlerTagType.HEAL_BLOCK, false, false, 2), new AttackMove(MoveId.UPPER_HAND, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 65, 100, 15, 100, 3, 9) .attr(FlinchAttr) - .condition(new UpperHandCondition()), + .condition(UpperHandCondition), new AttackMove(MoveId.MALIGNANT_CHAIN, PokemonType.POISON, MoveCategory.SPECIAL, 100, 100, 5, 50, 0, 9) .attr(StatusEffectAttr, StatusEffect.TOXIC) ); diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 9c6c817cc38..f7542b84034 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -2375,7 +2375,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * @param source {@linkcode Pokemon} The attacking Pokémon. * @param move {@linkcode Move} The move being used by the attacking Pokémon. * @param ignoreAbility Whether to ignore abilities that might affect type effectiveness or immunity (defaults to `false`). - * @param simulated Whether to apply abilities via simulated calls (defaults to `true`) + * @param simulated - Whether to apply abilities via simulated calls (defaults to `true`). This should only be false during the move effect phase * @param cancelled {@linkcode BooleanHolder} Stores whether the move was cancelled by a non-type-based immunity. * @param useIllusion - Whether we want the attack move effectiveness on the illusion or not * @returns The type damage multiplier, indicating the effectiveness of the move @@ -2429,7 +2429,9 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { applyAbAttrs("MoveImmunityAbAttr", commonAbAttrParams); } - if (!cancelledHolder.value) { + // Do not check queenly majesty unless this is being simulated + // This is because the move effect phase should not check queenly majesty, as that is handled by the move phase + if (simulated && !cancelledHolder.value) { const defendingSidePlayField = this.isPlayer() ? globalScene.getPlayerField() : globalScene.getEnemyField(); defendingSidePlayField.forEach(p => applyAbAttrs("FieldPriorityMoveImmunityAbAttr", { diff --git a/src/phases/command-phase.ts b/src/phases/command-phase.ts index e1d472ae650..6b51bb379a5 100644 --- a/src/phases/command-phase.ts +++ b/src/phases/command-phase.ts @@ -257,6 +257,7 @@ export class CommandPhase extends FieldPhase { : cursor > -1 && !playerPokemon.getMoveset().some(m => m.isUsable(playerPokemon)[0]); if (!canUse && !useStruggle) { + console.error("Cannot use move:", reason); this.queueFightErrorMessage(reason); return false; } diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 96d28fff603..115cbfb1ecc 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -2,8 +2,6 @@ import { applyAbAttrs } from "#abilities/apply-ab-attrs"; import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; import Overrides from "#app/overrides"; -// biome-ignore lint/correctness/noUnusedImports: Used in a tsdoc comment -import type { TauntTag, TruantTag } from "#data/battler-tags"; import { CenterOfAttentionTag } from "#data/battler-tags"; import { SpeciesFormChangePreMoveTrigger } from "#data/form-change-triggers"; import { getStatusEffectActivationText, getStatusEffectHealText } from "#data/status-effect"; @@ -113,7 +111,9 @@ export class MovePhase extends BattlePhase { } /** - * Check the first round of failure checks. + * Check the first round of failure checks + * + * @returns Whether the move failed * * @remarks * Based on battle mechanics research conducted primarily by Smogon, checks happen in the following order (as of Gen 9): @@ -147,6 +147,8 @@ export class MovePhase extends BattlePhase { this.checkPreUseInterrupt() || this.checkTagCancel(BattlerTagType.FLINCHED) || this.checkTagCancel(BattlerTagType.DISABLED, true) || + this.checkTagCancel(BattlerTagType.HEAL_BLOCK) || + this.checkTagCancel(BattlerTagType.THROAT_CHOPPED) || this.checkGravity() || this.checkTagCancel(BattlerTagType.TAUNT, true) || this.checkTagCancel(BattlerTagType.IMPRISON) || @@ -160,6 +162,115 @@ export class MovePhase extends BattlePhase { return false; } + /** + * Handle the status interactions for sleep and freeze that happen after passing the first failure check + * + * @remarks + * - If the user is asleep but can use the move, the sleep animation and message is still shown + * - If the user is frozen but is thawed from its move, the user's status is cured and the thaw message is shown + */ + private post1stFailSleepOrThaw(): void { + const user = this.pokemon; + // If the move was successful, then... play the "sleeping" animation if the user is asleep but uses something like rest / snore + // Cure the user's freeze and queue the thaw message from unfreezing due to move use + if (!isIgnoreStatus(this.useMode)) { + if (user.status?.effect === StatusEffect.SLEEP) { + // Commence the sleeping animation and message, which happens anyway + // TODO... + } else if (this.thaw) { + this.cureStatus( + StatusEffect.FREEZE, + i18next.t("statusEffect:freeze.healByMove", { + pokemonName: getPokemonNameWithAffix(user), + moveName: this.move.getMove().name, + }), + ); + } + } + } + + /** + * Second failure check that occurs after the "Pokemon used move" text is shown but BEFORE the move has been registered + * as being the last move used (for the purposes of something like Copycat) + * + * @remarks + * Other than powder, each failure condition is mutually exclusive (as they are tied to specific moves), so order does not matter. + * Notably, this failure check only includes failure conditions intrinsic to the move itself, ther than Powder (which marks the end of this failure check) + * + * + * - Pollen puff used on an ally that is under effect of heal block + * - Burn up / Double shock when the user does not have the required type + * - No Retreat while already under its effects + * - (on cart, not applicable to Pokerogue) Moves that fail if used ON a raid / special boss: selfdestruct/explosion/imprision/power split / guard split + * - (on cart, not applicable to Pokerogue) Moves that fail during a "co-op" battle (like when Arven helps during raid boss): ally switch / teatime + * + * After all checks, Powder causing the user to explode + */ + protected secondFailureCheck(): boolean { + const move = this.move.getMove(); + const user = this.pokemon; + if (!move.applyConditions(user, this.getActiveTargetPokemon()[0], 2)) { + this.failed = true; + // Note: If any of the moves have custom failure messages, this needs to be changed + // As of Gen 9, none do. (Except maybe pollen puff? Need to check) + return true; + } + // Powder *always* happens last + // Note: Powder's lapse method handles everything: messages, damage, animation, primal weather interaction, + // determining type of type changing moves, etc. + // It will set this phase's `failed` flag to true if it procs + user.lapseTag(BattlerTagType.POWDER, BattlerTagLapseType.PRE_MOVE); + return this.failed; + } + + /** + * Third failure check is from moves and abilities themselves + * + * @returns Whether the move failed + * + * @remarks + * - Conditional attributes of the move + * - Weather blocking the move + * - Terrain blocking the move + * - Queenly Majesty / Dazzling + */ + protected thirdFailureCheck(): boolean { + /** + * Move conditions assume the move has a single target + * TODO: is this sustainable? + */ + const move = this.move.getMove(); + const targets = this.getActiveTargetPokemon(); + const arena = globalScene.arena; + const user = this.pokemon; + const failsConditions = !move.applyConditions(user, targets[0]); + const failedDueToWeather = arena.isMoveWeatherCancelled(user, move); + const failedDueToTerrain = arena.isMoveTerrainCancelled(user, this.targets, move); + let failed = failsConditions || failedDueToWeather || failedDueToTerrain; + + // Apply queenly majesty / dazzling + if (!failed) { + const defendingSidePlayField = user.isPlayer() ? globalScene.getPlayerField() : globalScene.getEnemyField(); + const cancelled = new BooleanHolder(false); + defendingSidePlayField.forEach((pokemon: Pokemon) => { + applyAbAttrs("FieldPriorityMoveImmunityAbAttr", { + pokemon, + opponent: user, + move, + cancelled, + }); + }); + failed = cancelled.value; + } + + if (failed) { + this.failMove(true, failedDueToWeather, failedDueToTerrain); + return true; + } + + return false; + } + public start(): void { super.start(); @@ -168,25 +279,52 @@ export class MovePhase extends BattlePhase { return; } + const user = this.pokemon; + // Removing gigaton hammer always happens first - this.pokemon.removeTag(BattlerTagType.ALWAYS_GET_HIT); + user.removeTag(BattlerTagType.ALWAYS_GET_HIT); console.log(MoveId[this.move.moveId], enumValueToKey(MoveUseMode, this.useMode)); // For the purposes of payback and kin, the pokemon is considered to have acted // if it attempted to move at all. - this.pokemon.turnData.acted = true; - // TODO: skip this check for moves like metronome. - if (this.firstFailureCheck()) { + user.turnData.acted = true; + const useMode = this.useMode; + const virtual = isVirtual(useMode); + if (!virtual && this.firstFailureCheck()) { // Lapse all other pre-move tags - this.pokemon.lapseTags(BattlerTagLapseType.PRE_MOVE); + user.lapseTags(BattlerTagLapseType.PRE_MOVE); this.end(); + + /* + On cartridge, certain things *react* to move failures, depending on failure reason + The following would happen at this time on cartridge: + - Steadfast giving user speed boost if failed due to flinch + - Protect, detect, ally switch, etc, resetting consecutive use count + - Rollout / ice ball "unlocking" + - protect / ally switch / other moves resetting their consecutive use count + - and others + In Pokerogue, these are instead handled by their respective methods, which generally + */ return; } - // Begin second failure checks.. + // Now, issue the second failure checks + + // If the user was asleep but is using a move anyway, it should STILL display the "user is sleeping" message! + // At this point, cure the user's freeze + + // At this point, called moves should be decided. + // For now, this is a placeholder until we rework how called moves are handled + // For correct alignment with mainline, this SHOULD go here, and it SHOULD rewrite its own move + // Though, this is not the case in pokerogue. + + // At this point... + // If the first failure check passes, then thaw the user if its move will thaw it. + // The sleep message and animation are also played if the user is asleep but using a move anyway (snore, sleep talk, etc) + this.post1stFailSleepOrThaw(); // Reset hit-related turn data when starting follow-up moves (e.g. Metronomed moves, Dancer repeats) - if (isVirtual(this.useMode)) { + if (virtual) { this.pokemon.turnData.hitsLeft = -1; this.pokemon.turnData.hitCount = 0; } @@ -202,10 +340,30 @@ export class MovePhase extends BattlePhase { globalScene.arena.setIgnoreAbilities(true, this.pokemon.getBattlerIndex()); } - this.resolveRedirectTarget(); + // At this point, move's type changing and multi-target effects *should* be applied + // Pokerogue's current implementation applies these effects during the move effect phase + // as there is not (yet) a notion of a move-in-flight for determinations to occur + this.resolveRedirectTarget(); this.resolveCounterAttackTarget(); + // Move is announced + this.showMoveText(); + + // Stance change happens + const charging = this.move.getMove().isChargingMove() && !this.pokemon.getTag(BattlerTagType.CHARGING); + // Stance change happens now if the move is about to be executed + if (!charging) { + globalScene.triggerPokemonFormChange(this.pokemon, SpeciesFormChangePreMoveTrigger); + } + + if (this.secondFailureCheck()) { + this.showFailedText(); + this.handlePreMoveFailures(); + this.end(); + return; + } + if (!(this.failed || this.cancelled)) { this.resolveFinalPreMoveCancellationChecks(); } @@ -213,7 +371,7 @@ export class MovePhase extends BattlePhase { // 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)) { + } else if (charging) { this.chargeMove(); } else { this.useMove(); @@ -222,7 +380,7 @@ export class MovePhase extends BattlePhase { this.end(); } - /** Check for cancellation edge cases - no targets remaining, or {@linkcode MoveId.NONE} is in the queue */ + /** Check for cancellation edge cases - no targets remaining */ protected resolveFinalPreMoveCancellationChecks(): void { const targets = this.getActiveTargetPokemon(); const moveQueue = this.pokemon.getMoveQueue(); @@ -231,7 +389,6 @@ export class MovePhase extends BattlePhase { (targets.length === 0 && !this.move.getMove().hasAttr("AddArenaTrapTagAttr")) || (moveQueue.length > 0 && moveQueue[0].move === MoveId.NONE) ) { - this.showMoveText(); this.showFailedText(); this.cancel(); } else { @@ -246,10 +403,12 @@ export class MovePhase extends BattlePhase { /** * Queue the status cure message, reset the status, and update the Pokemon info display * @param effect - The effect being cured + * @param msg - A custom message to display when curing the status effect (used for curing freeze due to move use) */ - private cureStatus(effect: StatusEffect): void { + private cureStatus(effect: StatusEffect, msg?: string): void { const pokemon = this.pokemon; - globalScene.phaseManager.queueMessage(getStatusEffectHealText(effect, getPokemonNameWithAffix(pokemon))); + // Freeze healed by move uses its own msg + globalScene.phaseManager.queueMessage(msg ?? getStatusEffectHealText(effect, getPokemonNameWithAffix(pokemon))); pokemon.resetStatus(); pokemon.updateInfo(); } @@ -317,9 +476,23 @@ export class MovePhase extends BattlePhase { return false; } - // Heal the user if it thaws from the move or random chance - // Check if the user will thaw due to a move + // Check if move use would heal the user + if (Overrides.STATUS_ACTIVATION_OVERRIDE) { + return false; + } + + // Check if the move will heal + const move = this.move.getMove(); + if ( + move.findAttr(attr => attr.selfTarget && attr.is("HealStatusEffectAttr") && attr.isOfEffect(StatusEffect.FREEZE)) + ) { + // On cartridge, burn up will not cure if it would fail + if (move.id === MoveId.BURN_UP && !this.pokemon.isOfType(PokemonType.FIRE)) { + } + this.thaw = true; + return false; + } if ( Overrides.STATUS_ACTIVATION_OVERRIDE === false || this.move @@ -370,7 +543,17 @@ export class MovePhase extends BattlePhase { if (moveName.endsWith(" (N)")) { failedText = i18next.t("battle:moveNotImplemented", { moveName: moveName.replace(" (N)", "") }); } else if (moveId === MoveId.NONE || this.targets.length === 0) { - // TODO: Create a locale key with some failure text + this.cancel(); + + const pokemonName = this.pokemon.name; + const warningText = + moveId === MoveId.NONE + ? `${pokemonName} is attempting to use MoveId.NONE` + : `${pokemonName} is attempting to use a move with no targets`; + + console.warn(warningText); + + return true; } else if ( this.pokemon.isPlayer() && applyChallenges(ChallengeType.POKEMON_MOVE, moveId, usability) && @@ -460,85 +643,54 @@ export class MovePhase extends BattlePhase { return true; } + protected usePP(): void { + if (!isIgnorePP(this.useMode)) { + const move = this.move; + // "commit" to using the move, deducting PP. + const ppUsed = 1 + this.getPpIncreaseFromPressure(this.getActiveTargetPokemon()); + move.usePp(ppUsed); + globalScene.eventTarget.dispatchEvent(new MoveUsedEvent(this.pokemon.id, this.move.getMove(), this.move.ppUsed)); + } + } + protected useMove(): void { - const targets = this.getActiveTargetPokemon(); - const moveQueue = this.pokemon.getMoveQueue(); - const move = this.move.getMove(); - - // form changes happen even before we know that the move wll execute. - globalScene.triggerPokemonFormChange(this.pokemon, SpeciesFormChangePreMoveTrigger); - + const user = this.pokemon; // Clear out any two turn moves once they've been used. // TODO: Refactor move queues and remove this assignment; // 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; + this.useMode = user.getMoveQueue().shift()?.useMode ?? this.useMode; - if (this.pokemon.getTag(BattlerTagType.CHARGING)?.sourceMove === this.move.moveId) { - this.pokemon.lapseTag(BattlerTagType.CHARGING); + if (user.getTag(BattlerTagType.CHARGING)?.sourceMove === this.move.moveId) { + user.lapseTag(BattlerTagType.CHARGING); } - 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)); + if (!this.thirdFailureCheck()) { + this.executeMove(); } - - /** - * 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 - * - Weather does not block the move - * - Terrain does not block the move - */ - - /** - * Move conditions assume the move has a single target - * TODO: is this sustainable? - */ - const failsConditions = !move.applyConditions(this.pokemon, targets[0]); - const failedDueToWeather = globalScene.arena.isMoveWeatherCancelled(this.pokemon, move); - const failedDueToTerrain = globalScene.arena.isMoveTerrainCancelled(this.pokemon, this.targets, move); - const failed = failsConditions || failedDueToWeather || failedDueToTerrain; - - if (failed) { - this.failMove(true, failedDueToWeather, failedDueToTerrain); - return; - } - - this.executeMove(); } /** Execute the current move and apply its effects. */ private executeMove() { + const pokemon = this.pokemon; 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; - } + const opponent = this.getActiveTargetPokemon()[0]; + const targets = this.targets; // 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] }); + applyAbAttrs("PokemonTypeChangeAbAttr", { pokemon, move, opponent }); this.showMoveText(); - globalScene.phaseManager.unshiftNew( - "MoveEffectPhase", - this.pokemon.getBattlerIndex(), - this.targets, - move, - this.useMode, - ); + globalScene.phaseManager.unshiftNew("MoveEffectPhase", pokemon.getBattlerIndex(), 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)) { + // biome-ignore lint/nursery/noShadow: We don't need to access `pokemon` from the outer scope globalScene.getField(true).forEach(pokemon => { - applyAbAttrs("PostMoveUsedAbAttr", { pokemon, move: this.move, source: this.pokemon, targets: this.targets }); + applyAbAttrs("PostMoveUsedAbAttr", { pokemon, move: this.move, source: pokemon, targets: targets }); }); } } @@ -671,6 +823,7 @@ export class MovePhase extends BattlePhase { const redirectTarget = new NumberHolder(currentTarget); // check move redirection abilities of every pokemon *except* the user. + // TODO: Make storm drain, lightning rod, etc, redirect at this point for type changing moves globalScene .getField(true) .filter(p => p !== this.pokemon) @@ -785,10 +938,7 @@ export class MovePhase extends BattlePhase { } 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)); + this.usePP(); } if (this.cancelled && this.pokemon.summonData.tags.some(t => t.tagType === BattlerTagType.FRENZY)) {