From 6b34ea3c462d50323af3c8b447f17a42105400c2 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Sat, 16 Aug 2025 14:32:47 -0500 Subject: [PATCH] Add failure conditions and move failures part 1 --- src/data/battler-tags.ts | 157 ++++++----- src/data/moves/move-condition.ts | 117 ++++++++ src/data/moves/move.ts | 209 ++++++++------ src/data/moves/pokemon-move.ts | 50 +++- src/enums/battler-tag-lapse-type.ts | 17 +- src/enums/move-flags.ts | 4 +- src/field/pokemon.ts | 69 ++++- src/phases/command-phase.ts | 56 +--- src/phases/move-header-phase.ts | 2 +- src/phases/move-phase.ts | 409 +++++++++++++++++++--------- test/moves/belch.test.ts | 49 ++++ 11 files changed, 783 insertions(+), 356 deletions(-) create mode 100644 src/data/moves/move-condition.ts create mode 100644 test/moves/belch.test.ts diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 5fcc831f447..69e1812a164 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -9,7 +9,7 @@ import { getStatusEffectHealText } from "#data/status-effect"; import { TerrainType } from "#data/terrain"; import { AbilityId } from "#enums/ability-id"; import type { BattlerIndex } from "#enums/battler-index"; -import { BattlerTagLapseType } from "#enums/battler-tag-lapse-type"; +import { BattlerTagLapseType, type NonCustomBattlerTagLapseType } from "#enums/battler-tag-lapse-type"; import { BattlerTagType } from "#enums/battler-tag-type"; import { HitResult } from "#enums/hit-result"; import { ChargeAnim, CommonAnim } from "#enums/move-anims-common"; @@ -121,6 +121,11 @@ export class BattlerTag implements BaseBattlerTag { } #lapseTypes: readonly [BattlerTagLapseType, ...BattlerTagLapseType[]]; + /** + * The set of lapse types that this tag can be automatically lapsed with. + * If this is exclusively {@linkcode BattlerTagLapseType.CUSTOM}, then the tag can only ever be lapsed + * manually via {@linkcode Pokemon.lapseTag} (or calling the tag's lapse method directly) + */ public get lapseTypes(): readonly BattlerTagLapseType[] { return this.#lapseTypes; } @@ -128,7 +133,7 @@ export class BattlerTag implements BaseBattlerTag { constructor( tagType: BattlerTagType, - lapseType: BattlerTagLapseType | [BattlerTagLapseType, ...BattlerTagLapseType[]], + lapseType: BattlerTagLapseType | [NonCustomBattlerTagLapseType, ...NonCustomBattlerTagLapseType[]], turnCount: number, sourceMove?: MoveId, sourceId?: number, @@ -160,7 +165,10 @@ export class BattlerTag implements BaseBattlerTag { onOverlap(_pokemon: Pokemon): void {} /** - * Tick down this {@linkcode BattlerTag}'s duration. + * Apply the battler tag's effects based on the lapse type + * + * @remarks + * Generally, this involves ticking down the tag's duration. The tag also initiates the effects it is responsbile for * @param _pokemon - The {@linkcode Pokemon} whom this tag belongs to. * Unused by default but can be used by subclasses. * @param _lapseType - The {@linkcode BattlerTagLapseType} being lapsed. @@ -302,12 +310,7 @@ export abstract class MoveRestrictionBattlerTag extends SerializableBattlerTag { export class ThroatChoppedTag extends MoveRestrictionBattlerTag { public override readonly tagType = BattlerTagType.THROAT_CHOPPED; constructor() { - super( - BattlerTagType.THROAT_CHOPPED, - [BattlerTagLapseType.TURN_END, BattlerTagLapseType.PRE_MOVE], - 2, - MoveId.THROAT_CHOP, - ); + super(BattlerTagType.THROAT_CHOPPED, BattlerTagLapseType.TURN_END, 2, MoveId.THROAT_CHOP); } /** @@ -356,13 +359,7 @@ export class DisabledTag extends MoveRestrictionBattlerTag { public readonly moveId: MoveId = MoveId.NONE; constructor(sourceId: number) { - super( - BattlerTagType.DISABLED, - [BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.TURN_END], - 4, - MoveId.DISABLE, - sourceId, - ); + super(BattlerTagType.DISABLED, BattlerTagLapseType.TURN_END, 4, MoveId.DISABLE, sourceId); } override isMoveRestricted(move: MoveId): boolean { @@ -511,7 +508,10 @@ export class RechargingTag extends SerializableBattlerTag { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), }), ); - (globalScene.phaseManager.getCurrentPhase() as MovePhase).cancel(); + const currentPhase = globalScene.phaseManager.getCurrentPhase(); + if (currentPhase?.is("MovePhase")) { + currentPhase.cancel(); + } pokemon.getMoveQueue().shift(); } return super.lapse(pokemon, lapseType); @@ -708,18 +708,21 @@ class NoRetreatTag extends TrappedTag { export class FlinchedTag extends BattlerTag { public override readonly tagType = BattlerTagType.FLINCHED; constructor(sourceMove: MoveId) { - super(BattlerTagType.FLINCHED, [BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.TURN_END], 1, sourceMove); + super(BattlerTagType.FLINCHED, BattlerTagLapseType.TURN_END, 1, sourceMove); } /** * Cancels the flinched Pokemon's currently used move this turn if called mid-execution, or removes the tag at end of turn. * @param pokemon - The {@linkcode Pokemon} with this tag. - * @param lapseType - The {@linkcode BattlerTagLapseType | lapse type} used for this function call. + * @param lapseType - The {@linkcode BattlerTagLapseType | lapse type} used for this function call. Must be {@linkcode BattlerTagLapseType.PRE_MOVE} in order to apply the flinch effect. * @returns Whether the tag should remain active. */ lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { if (lapseType === BattlerTagLapseType.PRE_MOVE) { - (globalScene.phaseManager.getCurrentPhase() as MovePhase).cancel(); + const currentPhase = globalScene.phaseManager.getCurrentPhase(); + if (currentPhase?.is("MovePhase")) { + currentPhase.cancel(); + } globalScene.phaseManager.queueMessage( i18next.t("battlerTags:flinchedLapse", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), @@ -729,7 +732,7 @@ export class FlinchedTag extends BattlerTag { return true; } - return super.lapse(pokemon, lapseType); + return lapseType === BattlerTagLapseType.TURN_END && super.lapse(pokemon, lapseType); } getDescriptor(): string { @@ -760,7 +763,10 @@ export class InterruptedTag extends BattlerTag { } lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { - (globalScene.phaseManager.getCurrentPhase() as MovePhase).cancel(); + const currentPhase = globalScene.phaseManager.getCurrentPhase(); + if (currentPhase?.is("MovePhase")) { + currentPhase.cancel(); + } return super.lapse(pokemon, lapseType); } } @@ -771,7 +777,7 @@ export class InterruptedTag extends BattlerTag { export class ConfusedTag extends SerializableBattlerTag { public override readonly tagType = BattlerTagType.CONFUSED; constructor(turnCount: number, sourceMove: MoveId) { - super(BattlerTagType.CONFUSED, BattlerTagLapseType.MOVE, turnCount, sourceMove, undefined, true); + super(BattlerTagType.CONFUSED, BattlerTagLapseType.CUSTOM, turnCount, sourceMove, undefined, true); } canAdd(pokemon: Pokemon): boolean { @@ -814,8 +820,18 @@ export class ConfusedTag extends SerializableBattlerTag { ); } + /** + * Tick down the confusion duration and, if there are remaining turns, activate the confusion effect + * + * @remarks + * Rolls the + * @param pokemon - The pokemon with this tag + * @param lapseType - The lapse type + * @returns + */ lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { - const shouldLapse = lapseType !== BattlerTagLapseType.CUSTOM && super.lapse(pokemon, lapseType); + // Duration is only ticked down for PRE_MOVE lapse type + const shouldLapse = lapseType === BattlerTagLapseType.PRE_MOVE && super.lapse(pokemon, lapseType); if (!shouldLapse) { return false; @@ -840,7 +856,10 @@ export class ConfusedTag extends SerializableBattlerTag { // Intentionally don't increment rage fist's hitCount phaseManager.queueMessage(i18next.t("battlerTags:confusedLapseHurtItself")); pokemon.damageAndUpdate(damage, { result: HitResult.CONFUSION }); - (phaseManager.getCurrentPhase() as MovePhase).cancel(); + const currentPhase = phaseManager.getCurrentPhase(); + if (currentPhase?.is("MovePhase") && currentPhase.pokemon === pokemon) { + currentPhase.cancel(); + } } return true; @@ -916,7 +935,7 @@ export class DestinyBondTag extends SerializableBattlerTag { export class InfatuatedTag extends SerializableBattlerTag { public override readonly tagType = BattlerTagType.INFATUATED; constructor(sourceMove: number, sourceId: number) { - super(BattlerTagType.INFATUATED, BattlerTagLapseType.MOVE, 1, sourceMove, sourceId); + super(BattlerTagType.INFATUATED, BattlerTagLapseType.CUSTOM, 1, sourceMove, sourceId); } canAdd(pokemon: Pokemon): boolean { @@ -979,7 +998,10 @@ export class InfatuatedTag extends SerializableBattlerTag { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), }), ); - (phaseManager.getCurrentPhase() as MovePhase).cancel(); + const currentPhase = phaseManager.getCurrentPhase(); + if (currentPhase?.is("MovePhase")) { + currentPhase.cancel(); + } } return true; @@ -1105,7 +1127,7 @@ export class SeedTag extends SerializableBattlerTag { export class PowderTag extends BattlerTag { public override readonly tagType = BattlerTagType.POWDER; constructor() { - super(BattlerTagType.POWDER, [BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.TURN_END], 1); + super(BattlerTagType.POWDER, BattlerTagLapseType.TURN_END, 1); } onAdd(pokemon: Pokemon): void { @@ -1127,23 +1149,27 @@ export class PowderTag extends BattlerTag { * @returns `true` if the tag should remain active. */ lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { - const movePhase = globalScene.phaseManager.getCurrentPhase(); - if (lapseType !== BattlerTagLapseType.PRE_MOVE || !movePhase?.is("MovePhase")) { + if (lapseType === BattlerTagLapseType.TURN_END) { return false; } + const currentPhase = globalScene.phaseManager.getCurrentPhase(); - const move = movePhase.move.getMove(); + if (!currentPhase?.is("MovePhase")) { + return true; + } + + const move = currentPhase.move.getMove(); const weather = globalScene.arena.weather; if ( pokemon.getMoveType(move) !== PokemonType.FIRE || - (weather?.weatherType === WeatherType.HEAVY_RAIN && !weather.isEffectSuppressed()) // Heavy rain takes priority over powder + (weather?.weatherType === WeatherType.HEAVY_RAIN && !weather.isEffectSuppressed()) // Since gen 7, Heavy rain takes priority over powder ) { return true; } // Disable the target's fire type move and damage it (subject to Magic Guard) - movePhase.showMoveText(); - movePhase.fail(); + currentPhase.showMoveText(); + currentPhase.fail(); const idx = pokemon.getBattlerIndex(); @@ -1243,13 +1269,7 @@ export class EncoreTag extends MoveRestrictionBattlerTag { public moveId: MoveId; constructor(sourceId: number) { - super( - BattlerTagType.ENCORE, - [BattlerTagLapseType.CUSTOM, BattlerTagLapseType.AFTER_MOVE], - 3, - MoveId.ENCORE, - sourceId, - ); + super(BattlerTagType.ENCORE, BattlerTagLapseType.AFTER_MOVE, 3, MoveId.ENCORE, sourceId); } public override loadTag(source: BaseBattlerTag & Pick): void { @@ -2044,7 +2064,7 @@ export class UnburdenTag extends AbilityBattlerTag { export class TruantTag extends AbilityBattlerTag { public override readonly tagType = BattlerTagType.TRUANT; constructor() { - super(BattlerTagType.TRUANT, AbilityId.TRUANT, BattlerTagLapseType.MOVE, 1); + super(BattlerTagType.TRUANT, AbilityId.TRUANT, BattlerTagLapseType.CUSTOM, 1); } lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { @@ -2880,12 +2900,7 @@ export class ExposedTag extends SerializableBattlerTag { export class HealBlockTag extends MoveRestrictionBattlerTag { public override readonly tagType = BattlerTagType.HEAL_BLOCK; constructor(turnCount: number, sourceMove: MoveId) { - super( - BattlerTagType.HEAL_BLOCK, - [BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.TURN_END], - turnCount, - sourceMove, - ); + super(BattlerTagType.HEAL_BLOCK, BattlerTagLapseType.TURN_END, turnCount, sourceMove); } onActivation(pokemon: Pokemon): string { @@ -3134,7 +3149,7 @@ export class SubstituteTag extends SerializableBattlerTag { lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { switch (lapseType) { - case BattlerTagLapseType.PRE_MOVE: + case BattlerTagLapseType.MOVE: this.onPreMove(pokemon); break; case BattlerTagLapseType.AFTER_MOVE: @@ -3292,7 +3307,7 @@ export class TormentTag extends MoveRestrictionBattlerTag { export class TauntTag extends MoveRestrictionBattlerTag { public override readonly tagType = BattlerTagType.TAUNT; constructor() { - super(BattlerTagType.TAUNT, [BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.AFTER_MOVE], 4, MoveId.TAUNT); + super(BattlerTagType.TAUNT, BattlerTagLapseType.AFTER_MOVE, 4, MoveId.TAUNT); } override onAdd(pokemon: Pokemon) { @@ -3347,13 +3362,7 @@ export class TauntTag extends MoveRestrictionBattlerTag { export class ImprisonTag extends MoveRestrictionBattlerTag { public override readonly tagType = BattlerTagType.IMPRISON; constructor(sourceId: number) { - super( - BattlerTagType.IMPRISON, - [BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.AFTER_MOVE], - 1, - MoveId.IMPRISON, - sourceId, - ); + super(BattlerTagType.IMPRISON, BattlerTagLapseType.AFTER_MOVE, 1, MoveId.IMPRISON, sourceId); } /** @@ -3546,7 +3555,7 @@ export class PowerTrickTag extends SerializableBattlerTag { export class GrudgeTag extends SerializableBattlerTag { public override readonly tagType = BattlerTagType.GRUDGE; constructor() { - super(BattlerTagType.GRUDGE, [BattlerTagLapseType.CUSTOM, BattlerTagLapseType.PRE_MOVE], 1, MoveId.GRUDGE); + super(BattlerTagType.GRUDGE, BattlerTagLapseType.PRE_MOVE, 1, MoveId.GRUDGE); } onAdd(pokemon: Pokemon) { @@ -3567,23 +3576,23 @@ export class GrudgeTag extends SerializableBattlerTag { */ // TODO: Confirm whether this should interact with copying moves override lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType, sourcePokemon?: Pokemon): boolean { - if (lapseType === BattlerTagLapseType.CUSTOM && sourcePokemon) { - if (sourcePokemon.isActive() && pokemon.isOpponent(sourcePokemon)) { - const lastMove = pokemon.turnData.attacksReceived[0]; - const lastMoveData = sourcePokemon.getMoveset().find(m => m.moveId === lastMove.move); - if (lastMoveData && lastMove.move !== MoveId.STRUGGLE) { - lastMoveData.ppUsed = lastMoveData.getMovePp(); - globalScene.phaseManager.queueMessage( - i18next.t("battlerTags:grudgeLapse", { - pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - moveName: lastMoveData.getName(), - }), - ); - } - } - return false; + if (!sourcePokemon || lapseType !== BattlerTagLapseType.CUSTOM) { + return super.lapse(pokemon, lapseType); } - return super.lapse(pokemon, lapseType); + if (sourcePokemon.isActive() && pokemon.isOpponent(sourcePokemon)) { + const lastMove = pokemon.turnData.attacksReceived[0]; + const lastMoveData = sourcePokemon.getMoveset().find(m => m.moveId === lastMove.move); + if (lastMoveData && lastMove.move !== MoveId.STRUGGLE) { + lastMoveData.ppUsed = lastMoveData.getMovePp(); + globalScene.phaseManager.queueMessage( + i18next.t("battlerTags:grudgeLapse", { + pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), + moveName: lastMoveData.getName(), + }), + ); + } + } + return false; } } diff --git a/src/data/moves/move-condition.ts b/src/data/moves/move-condition.ts new file mode 100644 index 00000000000..ffb52294123 --- /dev/null +++ b/src/data/moves/move-condition.ts @@ -0,0 +1,117 @@ +import { globalScene } from "#app/global-scene"; +import { getPokemonNameWithAffix } from "#app/messages"; +import { allMoves } from "#data/data-lists"; +import { ArenaTagType } from "#enums/arena-tag-type"; +import { Command } from "#enums/command"; +import { MoveCategory } from "#enums/move-category"; +import type { Pokemon } from "#field/pokemon"; +import type { Move, MoveConditionFunc, UserMoveConditionFunc } from "#moves/move"; +import i18next from "i18next"; + +/** + * A condition that determines whether a move can be used successfully. + * + * @remarks + * This is only checked when the move is attempted to be invoked. To prevent a move from being selected, + * use a {@linkcode MoveRestriction} instead. + */ +export class MoveCondition { + public declare readonly func: MoveConditionFunc; + + /** + * @param func - A condition function that determines if the move can be used successfully + */ + constructor(func?: MoveConditionFunc) { + if (func) { + this.func = func; + } + } + + apply(user: Pokemon, target: Pokemon, move: Move): boolean { + return this.func(user, target, move); + } + + getUserBenefitScore(_user: Pokemon, _target: Pokemon, _move: Move): number { + return 0; + } +} +/** + * Condition to allow a move's use only on the first turn this Pokemon is sent into battle + * (or the start of a new wave, whichever comes first). + */ + +export class FirstMoveCondition extends MoveCondition { + public override readonly func: MoveConditionFunc = user => { + return user.tempSummonData.waveTurnCount === 1; + }; + + getUserBenefitScore(user: Pokemon, _target: Pokemon, _move: Move): number { + return this.apply(user, _target, _move) ? 10 : -20; + } +} + +/** + * 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 + ); + }; +} + +/** + * A restriction that prevents a move from being selected + * + * @remarks + * Only checked when the move is selected, but not when it is attempted to be invoked. To prevent a move from being used, + * use a {@linkcode MoveCondition} instead. + */ +export class MoveRestriction { + public declare readonly func: UserMoveConditionFunc; + public declare readonly i18nkey: string; + constructor(func: UserMoveConditionFunc, i18nkey = "battle:moveRestricted") { + this.func = func; + this.i18nkey = i18nkey; + } + + /** + * @param user - The Pokemon attempting to select the move + * @param move - The move being selected + * @returns Whether the move is restricted for the user. + */ + apply(user: Pokemon, move: Move): boolean { + return this.func(user, move); + } + + public getSelectionDeniedText(user: Pokemon, move: Move): string { + // While not all restriction texts use all the parameters, passing extra ones is harmless + return i18next.t(this.i18nkey, { pokemonNameWithAffix: getPokemonNameWithAffix(user), moveName: move.name }); + } +} + +/** + * Prevents a Pokemon from using the move if it was the last move it used + * + * @remarks + * Used by {@link https://bulbapedia.bulbagarden.net/wiki/Blood_Moon_(move) | Blood Moon} and {@link https://bulbapedia.bulbagarden.net/wiki/Gigaton_Hammer_(move) | Gigaton Hammer} + */ +export const ConsecutiveUseRestriction = new MoveRestriction( + (user, move) => user.getLastXMoves(1)[0]?.move === move.id, + "battle:moveDisabledConsecutive", +); + +/** Prevents a move from being selected if Gravity is in effect */ +export const GravityUseRestriction = new MoveRestriction( + () => globalScene.arena.hasTag(ArenaTagType.GRAVITY), + "battle:moveDisabledGravity", +); diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index ffcd844224c..4c6dd3ab599 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -93,12 +93,13 @@ 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"; /** * A function used to conditionally determine execution of a given {@linkcode MoveAttr}. * Conventionally returns `true` for success and `false` for failure. */ -type MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => boolean; +export type MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => boolean; export type UserMoveConditionFunc = (user: Pokemon, move: Move) => boolean; export abstract class Move implements Localizable { @@ -116,7 +117,16 @@ export abstract class Move implements Localizable { public priority: number; public generation: number; public attrs: MoveAttr[] = []; + /** Conditions that must be met for the move to succeed when it is used. + * + * @remarks Different from {@linkcode restrictions}, which is checked when the move is selected + */ private conditions: 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 + */ + private restrictions: MoveRestriction[] = []; /** The move's {@linkcode MoveFlags} */ private flags: number = 0; private nameAppend: string = ""; @@ -370,7 +380,7 @@ 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. - * @returns `this` + * @returns `this` for method chaining */ condition(condition: MoveCondition | MoveConditionFunc): this { if (typeof condition === "function") { @@ -381,6 +391,48 @@ export abstract class Move implements Localizable { return this; } + /** + * Adds a restriction condition to this move. + * The move will not be selectable if at least 1 of its restrictions is met. + * @param restriction - A function that evaluates to `true` if the move is restricted from being selected + * @returns `this` for method chaining + */ + public restriction(restriction: MoveRestriction): this; + /** + * Adds a restriction condition to this move. + * The move will not be selectable if at least 1 of its restrictions is met. + * @param restriction - The function or `MoveRestriction` that evaluates to `true` if the move is restricted from + * being selected + * @param i18nkey - The i18n key for the restriction text + * @param alsoCondition - If `true`, also adds a {@linkcode MoveCondition} that checks the same condition when the + * move is used; default `false` + * @returns `this` for method chaining + */ + + public restriction(restriction: UserMoveConditionFunc, i18nkey: string, alsoCondition?: boolean): this; + /** + * Adds a restriction condition to this move. + * The move will not be selectable if at least 1 of its restrictions is met. + * @param restriction - The function or `MoveRestriction` that evaluates to `true` if the move is restricted from + * being selected + * @param i18nkey - The i18n key for the restriction text, ignored if `restriction` is a `MoveRestriction` + * @param alsoCondition - If `true`, also adds a {@linkcode MoveCondition} that checks the same condition when the + * move is used; default `false`. Ignored if `restriction` is a `MoveRestriction`. + * @returns `this` for method chaining + */ + public restriction(restriction: T, i18nkey?: string, alsoCondition: typeof restriction extends MoveRestriction ? false : boolean = false): this { + if (typeof restriction === "function") { + this.restrictions.push(new MoveRestriction(restriction)); + if (alsoCondition) { + this.conditions.push(new MoveCondition((user, _, move) => restriction(user, move))); + } + } else { + this.restrictions.push(restriction); + } + + return this; + } + /** * Mark a move as having one or more edge cases. * The move may lack certain niche interactions with other moves/abilities, @@ -579,6 +631,19 @@ export abstract class Move implements Localizable { return this; } + /** + * Sets the {@linkcode MoveFlags.GRAVITY} flag for the calling Move and adds {@linkcode GravityUseRestriction} to the + * move's restrictions. + * + * @see {@linkcode MoveId.GRAVITY} + * @returns The {@linkcode Move} that called this function + */ + affectedByGravity(): this { + this.setFlag(MoveFlags.GRAVITY, true); + this.restrictions.push(GravityUseRestriction); + return this; + } + /** * Sets the {@linkcode MoveFlags.IGNORE_ABILITIES} flag for the calling Move * @see {@linkcode MoveId.SUNSTEEL_STRIKE} @@ -702,8 +767,29 @@ export abstract class Move implements Localizable { * @param move {@linkcode Move} to apply conditions to * @returns boolean: false if any of the apply()'s return false, else true */ - applyConditions(user: Pokemon, target: Pokemon, move: Move): boolean { - return this.conditions.every(cond => cond.apply(user, target, move)); + applyConditions(user: Pokemon, target: Pokemon): boolean { + return this.conditions.every(cond => cond.apply(user, target, this)); + } + + + /** + * Determine whether the move is restricted from being selected due to its own requirements. + * + * @remarks + * Does not check for external factors that prohibit move selection, such as disable + * + * @param user - The Pokemon using the move + * @returns - An array whose first element is `false` if the move is restricted, and the second element is a string + * with the reason for the restriction, otherwise, `false` and the empty string. + */ + public checkRestrictions(user: Pokemon): [boolean, string] { + for (const restriction of this.restrictions) { + if (restriction.apply(user, this)) { + return [false, restriction.getSelectionDeniedText(user, this)]; + } + } + + return [true, ""]; } /** @@ -1473,13 +1559,21 @@ export class PreUseInterruptAttr extends MoveAttr { } /** - * Message to display when a move is interrupted. - * @param user {@linkcode Pokemon} using the move - * @param target {@linkcode Pokemon} target of the move - * @param move {@linkcode Move} with this attribute + * Cancel the current MovePhase and queue the interrupt message if the condition is met + * @param user - {@linkcode Pokemon} using the move + * @param target - {@linkcode Pokemon} target of the move + * @param move - {@linkcode Move} with this attribute */ override apply(user: Pokemon, target: Pokemon, move: Move): boolean { - return this.conditionFunc(user, target, move); + const currentPhase = globalScene.phaseManager.getCurrentPhase(); + if (!currentPhase?.is("MovePhase") || !this.conditionFunc(user, target, move)) { + return false; + } + currentPhase.cancel(); + globalScene.phaseManager.queueMessage( + typeof this.message === "string" ? this.message : this.message(user, target, move) + ) + return true; } /** @@ -8040,8 +8134,6 @@ const phaseForcedSlower = (phase: MovePhase, target: Pokemon, trickRoom: boolean return phase.isForcedLast() && slower; }; -const failOnGravityCondition: MoveConditionFunc = (user, target, move) => !globalScene.arena.getTag(ArenaTagType.GRAVITY); - const failOnBossCondition: MoveConditionFunc = (user, target, move) => !target.isBossImmune(); const failIfSingleBattle: MoveConditionFunc = (user, target, move) => globalScene.currentBattle.double; @@ -8081,57 +8173,6 @@ const attackedByItemMessageFunc = (user: Pokemon, target: Pokemon, move: Move) = return message; }; -export class MoveCondition { - protected func: MoveConditionFunc; - - constructor(func: MoveConditionFunc) { - this.func = func; - } - - apply(user: Pokemon, target: Pokemon, move: Move): boolean { - return this.func(user, target, move); - } - - getUserBenefitScore(user: Pokemon, target: Pokemon, move: Move): number { - return 0; - } -} - -/** - * Condition to allow a move's use only on the first turn this Pokemon is sent into battle - * (or the start of a new wave, whichever comes first). - */ - -export class FirstMoveCondition extends MoveCondition { - constructor() { - super((user, _target, _move) => user.tempSummonData.waveTurnCount === 1); - } - - getUserBenefitScore(user: Pokemon, _target: Pokemon, _move: Move): number { - return this.apply(user, _target, _move) ? 10 : -20; - } -} - -/** - * 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 { - constructor() { - super((user, target, move) => { - 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 class HitsSameTypeAttr extends VariableMoveTypeMultiplierAttr { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { const multiplier = args[0] as NumberHolder; @@ -8538,7 +8579,7 @@ export function initMoves() { new ChargingAttackMove(MoveId.FLY, PokemonType.FLYING, MoveCategory.PHYSICAL, 90, 95, 15, -1, 0, 1) .chargeText(i18next.t("moveTriggers:flewUpHigh", { pokemonName: "{USER}" })) .chargeAttr(SemiInvulnerableAttr, BattlerTagType.FLYING) - .condition(failOnGravityCondition), + .affectedByGravity(), new AttackMove(MoveId.BIND, PokemonType.NORMAL, MoveCategory.PHYSICAL, 15, 85, 20, -1, 0, 1) .attr(TrapAttr, BattlerTagType.BIND), new AttackMove(MoveId.SLAM, PokemonType.NORMAL, MoveCategory.PHYSICAL, 80, 75, 20, -1, 0, 1), @@ -8553,7 +8594,7 @@ export function initMoves() { new AttackMove(MoveId.JUMP_KICK, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 100, 95, 10, -1, 0, 1) .attr(MissEffectAttr, crashDamageFunc) .attr(NoEffectAttr, crashDamageFunc) - .condition(failOnGravityCondition) + .affectedByGravity() .recklessMove(), new AttackMove(MoveId.ROLLING_KICK, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 60, 85, 15, 30, 0, 1) .attr(FlinchAttr), @@ -8868,7 +8909,7 @@ export function initMoves() { new AttackMove(MoveId.HIGH_JUMP_KICK, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 130, 90, 10, -1, 0, 1) .attr(MissEffectAttr, crashDamageFunc) .attr(NoEffectAttr, crashDamageFunc) - .condition(failOnGravityCondition) + .affectedByGravity() .recklessMove(), new StatusMove(MoveId.GLARE, PokemonType.NORMAL, 100, 30, -1, 0, 1) .attr(StatusEffectAttr, StatusEffect.PARALYSIS) @@ -8922,7 +8963,7 @@ export function initMoves() { .attr(RandomLevelDamageAttr), new SelfStatusMove(MoveId.SPLASH, PokemonType.NORMAL, -1, 40, -1, 0, 1) .attr(MessageAttr, i18next.t("moveTriggers:splash")) - .condition(failOnGravityCondition), + .affectedByGravity(), new SelfStatusMove(MoveId.ACID_ARMOR, PokemonType.POISON, -1, 20, -1, 0, 1) .attr(StatStageChangeAttr, [ Stat.DEF ], 2, true), new AttackMove(MoveId.CRABHAMMER, PokemonType.WATER, MoveCategory.PHYSICAL, 100, 90, 10, -1, 0, 1) @@ -9563,7 +9604,7 @@ export function initMoves() { .chargeText(i18next.t("moveTriggers:sprangUp", { pokemonName: "{USER}" })) .chargeAttr(SemiInvulnerableAttr, BattlerTagType.FLYING) .attr(StatusEffectAttr, StatusEffect.PARALYSIS) - .condition(failOnGravityCondition), + .affectedByGravity(), new AttackMove(MoveId.MUD_SHOT, PokemonType.GROUND, MoveCategory.SPECIAL, 55, 95, 15, 100, 0, 3) .attr(StatStageChangeAttr, [ Stat.SPD ], -1), new AttackMove(MoveId.POISON_TAIL, PokemonType.POISON, MoveCategory.PHYSICAL, 50, 100, 25, 10, 0, 3) @@ -9615,6 +9656,7 @@ export function initMoves() { new StatusMove(MoveId.GRAVITY, PokemonType.PSYCHIC, -1, 5, -1, 0, 4) .ignoresProtect() .attr(AddArenaTagAttr, ArenaTagType.GRAVITY, 5) + .condition(() => !globalScene.arena.hasTag(ArenaTagType.GRAVITY)) .target(MoveTarget.BOTH_SIDES), new StatusMove(MoveId.MIRACLE_EYE, PokemonType.PSYCHIC, -1, 40, -1, 0, 4) .attr(ExposedMoveAttr, BattlerTagType.IGNORE_DARK) @@ -9745,7 +9787,8 @@ export function initMoves() { .attr(AddBattlerTagAttr, BattlerTagType.AQUA_RING, true, true), new SelfStatusMove(MoveId.MAGNET_RISE, PokemonType.ELECTRIC, -1, 10, -1, 0, 4) .attr(AddBattlerTagAttr, BattlerTagType.FLOATING, true, true, 5) - .condition((user, target, move) => !globalScene.arena.getTag(ArenaTagType.GRAVITY) && [ BattlerTagType.FLOATING, BattlerTagType.IGNORE_FLYING, BattlerTagType.INGRAIN ].every((tag) => !user.getTag(tag))), + .condition(user => [ BattlerTagType.FLOATING, BattlerTagType.IGNORE_FLYING, BattlerTagType.INGRAIN ].every((tag) => !user.getTag(tag))) + .affectedByGravity(), new AttackMove(MoveId.FLARE_BLITZ, PokemonType.FIRE, MoveCategory.PHYSICAL, 120, 100, 15, 10, 0, 4) .attr(RecoilAttr, false, 0.33) .attr(HealStatusEffectAttr, true, StatusEffect.FREEZE) @@ -9974,7 +10017,7 @@ export function initMoves() { .powderMove() .attr(AddBattlerTagAttr, BattlerTagType.CENTER_OF_ATTENTION, true), new StatusMove(MoveId.TELEKINESIS, PokemonType.PSYCHIC, -1, 15, -1, 0, 5) - .condition(failOnGravityCondition) + .affectedByGravity() .condition((_user, target, _move) => ![ SpeciesId.DIGLETT, SpeciesId.DUGTRIO, SpeciesId.ALOLA_DIGLETT, SpeciesId.ALOLA_DUGTRIO, SpeciesId.SANDYGAST, SpeciesId.PALOSSAND, SpeciesId.WIGLETT, SpeciesId.WUGTRIO ].includes(target.species.speciesId)) .condition((_user, target, _move) => !(target.species.speciesId === SpeciesId.GENGAR && target.getFormKey() === "mega")) .condition((_user, target, _move) => isNullOrUndefined(target.getTag(BattlerTagType.INGRAIN)) && isNullOrUndefined(target.getTag(BattlerTagType.IGNORE_FLYING))) @@ -10078,7 +10121,7 @@ export function initMoves() { new ChargingAttackMove(MoveId.SKY_DROP, PokemonType.FLYING, MoveCategory.PHYSICAL, 60, 100, 10, -1, 0, 5) .chargeText(i18next.t("moveTriggers:tookTargetIntoSky", { pokemonName: "{USER}", targetName: "{TARGET}" })) .chargeAttr(SemiInvulnerableAttr, BattlerTagType.FLYING) - .condition(failOnGravityCondition) + .affectedByGravity() .condition((user, target, move) => !target.getTag(BattlerTagType.SUBSTITUTE)) /* * Cf https://bulbapedia.bulbagarden.net/wiki/Sky_Drop_(move) and https://www.smogon.com/dex/sv/moves/sky-drop/: @@ -10266,14 +10309,14 @@ export function initMoves() { .attr(AlwaysHitMinimizeAttr) .attr(FlyingTypeMultiplierAttr) .attr(HitsTagForDoubleDamageAttr, BattlerTagType.MINIMIZED) - .condition(failOnGravityCondition), + .affectedByGravity(), new StatusMove(MoveId.MAT_BLOCK, PokemonType.FIGHTING, -1, 10, -1, 0, 6) .target(MoveTarget.USER_SIDE) .attr(AddArenaTagAttr, ArenaTagType.MAT_BLOCK, 1, true, true) .condition(new FirstMoveCondition()) .condition(failIfLastCondition), new AttackMove(MoveId.BELCH, PokemonType.POISON, MoveCategory.SPECIAL, 120, 90, 10, -1, 0, 6) - .condition((user, target, move) => user.battleData.hasEatenBerry), + .restriction(user => !user.battleData.hasEatenBerry, "battle:moveDisabledBelch", true), new StatusMove(MoveId.ROTOTILLER, PokemonType.GROUND, -1, 10, -1, 0, 6) .target(MoveTarget.ALL) .condition((user, target, move) => { @@ -10804,7 +10847,8 @@ export function initMoves() { .attr(StatusEffectAttr, StatusEffect.PARALYSIS) .target(MoveTarget.ALL_NEAR_ENEMIES), new AttackMove(MoveId.FLOATY_FALL, PokemonType.FLYING, MoveCategory.PHYSICAL, 90, 95, 15, 30, 0, 7) - .attr(FlinchAttr), + .attr(FlinchAttr) + .affectedByGravity(), new AttackMove(MoveId.PIKA_PAPOW, PokemonType.ELECTRIC, MoveCategory.SPECIAL, -1, -1, 20, -1, 0, 7) .attr(FriendshipPowerAttr), new AttackMove(MoveId.BOUNCY_BUBBLE, PokemonType.WATER, MoveCategory.SPECIAL, 60, 100, 20, -1, 0, 7) @@ -10858,11 +10902,10 @@ export function initMoves() { new SelfStatusMove(MoveId.STUFF_CHEEKS, PokemonType.NORMAL, -1, 10, -1, 0, 8) .attr(EatBerryAttr, true) .attr(StatStageChangeAttr, [ Stat.DEF ], 2, true) - .condition((user) => { - const userBerries = globalScene.findModifiers(m => m instanceof BerryModifier, user.isPlayer()); - return userBerries.length > 0; - }) - .edgeCase(), // Stuff Cheeks should not be selectable when the user does not have a berry, see wiki + .restriction( + user => globalScene.findModifiers(m => m instanceof BerryModifier, user.isPlayer()).length > 0, + "battle:moveDisabledNoBerry", + 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) @@ -11439,10 +11482,7 @@ export function initMoves() { }), new AttackMove(MoveId.GIGATON_HAMMER, PokemonType.STEEL, MoveCategory.PHYSICAL, 160, 100, 5, -1, 0, 9) .makesContact(false) - .condition((user, target, move) => { - const turnMove = user.getLastXMoves(1); - return !turnMove.length || turnMove[0].move !== move.id || turnMove[0].result !== MoveResult.SUCCESS; - }), // TODO Add Instruct/Encore interaction + .restriction(ConsecutiveUseRestriction), new AttackMove(MoveId.COMEUPPANCE, PokemonType.DARK, MoveCategory.PHYSICAL, -1, 100, 10, -1, 0, 9) .attr(CounterDamageAttr, (move: Move) => (move.category === MoveCategory.PHYSICAL || move.category === MoveCategory.SPECIAL), 1.5) .redirectCounter() @@ -11467,10 +11507,7 @@ export function initMoves() { .attr(ConfuseAttr) .makesContact(false), new AttackMove(MoveId.BLOOD_MOON, PokemonType.NORMAL, MoveCategory.SPECIAL, 140, 100, 5, -1, 0, 9) - .condition((user, target, move) => { - const turnMove = user.getLastXMoves(1); - return !turnMove.length || turnMove[0].move !== move.id || turnMove[0].result !== MoveResult.SUCCESS; - }), // TODO Add Instruct/Encore interaction + .restriction(ConsecutiveUseRestriction), new AttackMove(MoveId.MATCHA_GOTCHA, PokemonType.GRASS, MoveCategory.SPECIAL, 80, 90, 15, 20, 0, 9) .attr(HitHealAttr) .attr(HealStatusEffectAttr, true, StatusEffect.FREEZE) diff --git a/src/data/moves/pokemon-move.ts b/src/data/moves/pokemon-move.ts index cdb8d628be1..c47445b9949 100644 --- a/src/data/moves/pokemon-move.ts +++ b/src/data/moves/pokemon-move.ts @@ -5,6 +5,7 @@ import type { Pokemon } from "#field/pokemon"; import type { Move } from "#moves/move"; import { applyChallenges } from "#utils/challenge-utils"; import { BooleanHolder, toDmgValue } from "#utils/common"; +import i18next from "i18next"; /** * Wrapper class for the {@linkcode Move} class for Pokemon to interact with. @@ -38,33 +39,58 @@ export class PokemonMove { } /** - * Checks whether this move can be selected/performed by a Pokemon, without consideration for the move's targets. + * Checks whether this move can be performed by a Pokemon, without consideration for the move's targets. * The move is unusable if it is out of PP, restricted by an effect, or unimplemented. * + * Should not be confused with {@linkcode isSelectable}, which only checks if the move can be selected by a Pokemon. + * * @param pokemon - The {@linkcode Pokemon} attempting to use this move * @param ignorePp - Whether to ignore checking if the move is out of PP; default `false` - * @param ignoreRestrictionTags - Whether to skip checks for {@linkcode MoveRestrictionBattlerTag}s; default `false` + * @param forSelection - Whether this is being checked for move selection; default `false` * @returns Whether this {@linkcode PokemonMove} can be selected by this Pokemon. */ - isUsable(pokemon: Pokemon, ignorePp = false, ignoreRestrictionTags = false): boolean { + public isUsable(pokemon: Pokemon, ignorePp = false, forSelection = false): [boolean, string] { const move = this.getMove(); + const moveName = move.name; + // TODO: Add Sky Drop's 1 turn stall - const usability = new BooleanHolder( - !move.name.endsWith(" (N)") && - (ignorePp || this.ppUsed < this.getMovePp() || move.pp === -1) && - // TODO: Review if the `MoveId.NONE` check is even necessary anymore - !(this.moveId !== MoveId.NONE && !ignoreRestrictionTags && pokemon.isMoveRestricted(this.moveId, pokemon)), - ); - if (pokemon.isPlayer()) { - applyChallenges(ChallengeType.POKEMON_MOVE, move.id, usability); + if (this.moveId === MoveId.NONE || move.name.endsWith(" (N)")) { + return [false, i18next.t("battle:moveNotImplemented", moveName.replace(" (N)", ""))]; } - return usability.value; + + if (!ignorePp && move.pp !== -1 && this.ppUsed >= this.getMovePp()) { + return [false, i18next.t("battle:moveNoPP", { moveName: move.name })]; + } + + if (forSelection) { + const result = pokemon.isMoveSelectable(this.moveId); + if (!result[0]) { + return result; + } + } + + const usability = new BooleanHolder(true); + if (applyChallenges(ChallengeType.POKEMON_MOVE, this.moveId, usability) && !usability.value) { + return [false, i18next.t("battle:moveCannotUseChallenge", { moveName: move.name })]; + } + + return [true, ""]; } getMove(): Move { return allMoves[this.moveId]; } + /** + * Determine whether the move can be selected by the pokemon + * + * @param pokemon - The Pokemon under consideration + * @returns An array containing a boolean indicating whether the move can be selected, and a string with the reason if it cannot + */ + public isSelectable(pokemon: Pokemon): [boolean, string] { + return pokemon.isMoveSelectable(this.moveId); + } + /** * Sets {@link ppUsed} for this move and ensures the value does not exceed {@link getMovePp} * @param count Amount of PP to use diff --git a/src/enums/battler-tag-lapse-type.ts b/src/enums/battler-tag-lapse-type.ts index 4375e87e4e0..f9486876993 100644 --- a/src/enums/battler-tag-lapse-type.ts +++ b/src/enums/battler-tag-lapse-type.ts @@ -6,11 +6,12 @@ export enum BattlerTagLapseType { // TODO: This is unused... FAINT, /** - * Tag activate before the holder uses a non-virtual move, possibly interrupting its action. + * Tag activate before the holder uses a non-virtual move, after passing the first failure check sequence during the + * move phase. * @see MoveUseMode for more information */ MOVE, - /** Tag activates before the holder uses **any** move, triggering effects or interrupting its action. */ + /** Tag activates during (or just after) the first failure check sequence in the move phase. */ PRE_MOVE, /** Tag activates immediately after the holder's move finishes triggering (successful or not). */ AFTER_MOVE, @@ -32,6 +33,16 @@ export enum BattlerTagLapseType { * but still triggers on being KO'd. */ AFTER_HIT, - /** The tag has some other custom activation or removal condition. */ + /** + * The tag has some other custom activation or removal condition. + * @remarks + * Tags can use this lapse type to prevent them from being automatically lapsed during automatic lapse instances, + * such as before a move is used or at the end of a turn. Note that a tag's lapse method can still make use of + * these lapse types, which can be invoked via the `lapseTag` method on {@linkcode Pokemon} with the tag and + * lapse type. + * */ CUSTOM, } + +/** Same type as {@linkcode BattlerTagLapseType}, but excludes the {@linkcode BattlerTagLapseType.CUSTOM} type */ +export type NonCustomBattlerTagLapseType = Exclude; \ No newline at end of file diff --git a/src/enums/move-flags.ts b/src/enums/move-flags.ts index 6cdc1e5f8cc..dc0964ad2a0 100644 --- a/src/enums/move-flags.ts +++ b/src/enums/move-flags.ts @@ -50,5 +50,7 @@ export enum MoveFlags { /** Indicates a move is able to be redirected to allies in a double battle if the attacker faints */ REDIRECT_COUNTER = 1 << 18, /** Indicates a move is able to be reflected by {@linkcode AbilityId.MAGIC_BOUNCE} and {@linkcode MoveId.MAGIC_COAT} */ - REFLECTABLE = 1 << 19 + REFLECTABLE = 1 << 19, + /** Indicates a move that fails when {@link https://bulbapedia.bulbagarden.net/wiki/Gravity_(move) | Gravity} is in effect */ + GRAVITY = 1 << 20, } diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index e12485a7272..9c6c817cc38 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -3295,9 +3295,15 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } } - public trySelectMove(moveIndex: number, ignorePp?: boolean): boolean { + /** + * Attempt to select the move at the move index. + * @param moveIndex + * @param ignorePp + * @returns + */ + public trySelectMove(moveIndex: number, ignorePp?: boolean): [boolean, string] { const move = this.getMoveset().length > moveIndex ? this.getMoveset()[moveIndex] : null; - return move?.isUsable(this, ignorePp) ?? false; + return move?.isUsable(this, ignorePp) ?? [false, ""]; } showInfo(): void { @@ -4267,19 +4273,28 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Tick down the first {@linkcode BattlerTag} found matching the given {@linkcode BattlerTagType}, - * removing it if its duration goes below 0. - * @param tagType the {@linkcode BattlerTagType} to check against - * @returns `true` if the tag was present + * Lapse the first {@linkcode BattlerTag} matching `tagType` + * + * @remarks + * Also responsible for removing the tag when the lapse method returns `false`. + * + * + * ⚠️ Lapse types other than `CUSTOM` are generally lapsed automatically. However, some tags + * support manually lapsing + * + * @param tagType - The {@linkcode BattlerTagType} to search for + * @param lapseType - The lapse type to use for the lapse method; defaults to {@linkcode BattlerTagLapseType.CUSTOM} + * @returns Whether a tag matching the given type was found + * @see {@linkcode BattlerTag.lapse} */ - lapseTag(tagType: BattlerTagType): boolean { + lapseTag(tagType: BattlerTagType, lapseType = BattlerTagLapseType.CUSTOM): boolean { const tags = this.summonData.tags; const tag = tags.find(t => t.tagType === tagType); if (!tag) { return false; } - if (!tag.lapse(this, BattlerTagLapseType.CUSTOM)) { + if (!tag.lapse(this, lapseType)) { tag.onRemove(this); tags.splice(tags.indexOf(tag), 1); } @@ -4291,7 +4306,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * removing any whose durations fall below 0. * @param tagType the {@linkcode BattlerTagLapseType} to tick down */ - lapseTags(lapseType: BattlerTagLapseType): void { + lapseTags(lapseType: Exclude): void { const tags = this.summonData.tags; tags .filter( @@ -4381,13 +4396,37 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { /** * Gets whether the given move is currently disabled for this Pokemon. * + * @remarks + * ⚠️ Only checks for restrictions due to a battler tag, not due to the move's own attributes. + * (for that behavior, see {@linkcode isMoveSelectable}). + * * @param moveId - The {@linkcode MoveId} ID of the move to check * @returns `true` if the move is disabled for this Pokemon, otherwise `false` * * @see {@linkcode MoveRestrictionBattlerTag} */ - public isMoveRestricted(moveId: MoveId, pokemon?: Pokemon): boolean { - return this.getRestrictingTag(moveId, pokemon) !== null; + // TODO: rename this method as it can be easily confused with a move being restricted + public isMoveRestricted(moveId: MoveId): boolean { + return this.getRestrictingTag(moveId, this) !== null; + } + + /** + * Determine whether the given move is selectable by this Pokemon and the message to display if it is not. + * + * @remarks + * Checks both the move's own restrictions and any restrictions imposed by battler tags like disable or throat chop. + * + * @param moveId - The move ID to check + * @returns A tuple of the form [response, msg], where msg contains the text to display if `response` is false. + * + * @see {@linkcode isMoveRestricted} + */ + public isMoveSelectable(moveId: MoveId): [boolean, string] { + const restrictedTag = this.getRestrictingTag(moveId, this); + if (restrictedTag) { + return [false, restrictedTag.selectionDeniedText(this, moveId)]; + } + return allMoves[moveId].checkRestrictions(this); } /** @@ -6398,7 +6437,7 @@ export class EnemyPokemon extends Pokemon { // If the queued move was called indirectly, ignore all PP and usability checks. // Otherwise, ensure that the move being used is actually usable & in our moveset. // TODO: What should happen if a pokemon forgets a charging move mid-use? - if (isVirtual(queuedMove.useMode) || movesetMove?.isUsable(this, isIgnorePP(queuedMove.useMode))) { + if (isVirtual(queuedMove.useMode) || movesetMove?.isUsable(this, isIgnorePP(queuedMove.useMode), true)) { moveQueue.splice(0, i); // TODO: This should not be done here return queuedMove; } @@ -6408,7 +6447,7 @@ export class EnemyPokemon extends Pokemon { this.summonData.moveQueue = []; // Filter out any moves this Pokemon cannot use - let movePool = this.getMoveset().filter(m => m.isUsable(this)); + let movePool = this.getMoveset().filter(m => m.isUsable(this, false, true)[0]); // If no moves are left, use Struggle. Otherwise, continue with move selection if (movePool.length) { // If there's only 1 move in the move pool, use it. @@ -6466,7 +6505,7 @@ export class EnemyPokemon extends Pokemon { move.category !== MoveCategory.STATUS && moveTargets.some(p => { const doesNotFail = - move.applyConditions(this, p, move) || + move.applyConditions(this, p) || [MoveId.SUCKER_PUNCH, MoveId.UPPER_HAND, MoveId.THUNDERCLAP].includes(move.id); return ( doesNotFail && @@ -6525,7 +6564,7 @@ export class EnemyPokemon extends Pokemon { * target score to -20 */ if ( - (move.name.endsWith(" (N)") || !move.applyConditions(this, target, move)) && + (move.name.endsWith(" (N)") || !move.applyConditions(this, target)) && ![MoveId.SUCKER_PUNCH, MoveId.UPPER_HAND, MoveId.THUNDERCLAP].includes(move.id) ) { targetScore = -20; diff --git a/src/phases/command-phase.ts b/src/phases/command-phase.ts index 87c33a4334b..e1d472ae650 100644 --- a/src/phases/command-phase.ts +++ b/src/phases/command-phase.ts @@ -9,7 +9,6 @@ import { ArenaTagType } from "#enums/arena-tag-type"; import { BattleType } from "#enums/battle-type"; import { BattlerTagType } from "#enums/battler-tag-type"; import { BiomeId } from "#enums/biome-id"; -import { ChallengeType } from "#enums/challenge-type"; import { Command } from "#enums/command"; import { FieldPosition } from "#enums/field-position"; import { MoveId } from "#enums/move-id"; @@ -22,8 +21,6 @@ import type { MoveTargetSet } from "#moves/move"; import { getMoveTargets } from "#moves/move-utils"; import { FieldPhase } from "#phases/field-phase"; import type { TurnMove } from "#types/turn-move"; -import { applyChallenges } from "#utils/challenge-utils"; -import { BooleanHolder } from "#utils/common"; import i18next from "i18next"; export class CommandPhase extends FieldPhase { @@ -126,7 +123,7 @@ export class CommandPhase extends FieldPhase { if ( queuedMove.move !== MoveId.NONE && !isVirtual(queuedMove.useMode) && - !movesetQueuedMove?.isUsable(playerPokemon, isIgnorePP(queuedMove.useMode)) + !movesetQueuedMove?.isUsable(playerPokemon, isIgnorePP(queuedMove.useMode), true) ) { entriesToDelete++; } else { @@ -204,40 +201,18 @@ export class CommandPhase extends FieldPhase { } /** - * Submethod of {@linkcode handleFightCommand} responsible for queuing the appropriate - * error message when a move cannot be used. - * @param user - The pokemon using the move - * @param cursor - The index of the move in the moveset + * Submethod of {@linkcode handleFightCommand} responsible for queuing the provided error message when the move cannot be used + * @param msg - The reason why the move cannot be used */ - private queueFightErrorMessage(user: PlayerPokemon, cursor: number) { - const move = user.getMoveset()[cursor]; - globalScene.ui.setMode(UiMode.MESSAGE); - - // Set the translation key for why the move cannot be selected - let cannotSelectKey: string; - const moveStatus = new BooleanHolder(true); - applyChallenges(ChallengeType.POKEMON_MOVE, move.moveId, moveStatus); - if (!moveStatus.value) { - cannotSelectKey = "battle:moveCannotUseChallenge"; - } else if (move.getPpRatio() === 0) { - cannotSelectKey = "battle:moveNoPP"; - } else if (move.getName().endsWith(" (N)")) { - cannotSelectKey = "battle:moveNotImplemented"; - } else if (user.isMoveRestricted(move.moveId, user)) { - cannotSelectKey = user.getRestrictingTag(move.moveId, user)!.selectionDeniedText(user, move.moveId); - } else { - // TODO: Consider a message that signals a being unusable for an unknown reason - cannotSelectKey = ""; - } - - const moveName = move.getName().replace(" (N)", ""); // Trims off the indicator - - globalScene.ui.showText( - i18next.t(cannotSelectKey, { moveName: moveName }), + private queueFightErrorMessage(msg: string) { + const ui = globalScene.ui; + ui.setMode(UiMode.MESSAGE); + ui.showText( + msg, null, () => { - globalScene.ui.clearText(); - globalScene.ui.setMode(UiMode.FIGHT, this.fieldIndex); + ui.clearText(); + ui.setMode(UiMode.FIGHT, this.fieldIndex); }, null, true, @@ -274,18 +249,15 @@ export class CommandPhase extends FieldPhase { ): boolean { const playerPokemon = this.getPokemon(); const ignorePP = isIgnorePP(useMode); - - let canUse = cursor === -1 || playerPokemon.trySelectMove(cursor, ignorePP); + const [canUse, reason] = cursor === -1 ? [true, ""] : playerPokemon.trySelectMove(cursor, ignorePP); // Ternary here ensures we don't compute struggle conditions unless necessary const useStruggle = canUse ? false - : cursor > -1 && !playerPokemon.getMoveset().some(m => m.isUsable(playerPokemon)); + : cursor > -1 && !playerPokemon.getMoveset().some(m => m.isUsable(playerPokemon)[0]); - canUse ||= useStruggle; - - if (!canUse) { - this.queueFightErrorMessage(playerPokemon, cursor); + if (!canUse && !useStruggle) { + this.queueFightErrorMessage(reason); return false; } diff --git a/src/phases/move-header-phase.ts b/src/phases/move-header-phase.ts index 5c69dcd1217..f2ef7f11c5b 100644 --- a/src/phases/move-header-phase.ts +++ b/src/phases/move-header-phase.ts @@ -16,7 +16,7 @@ export class MoveHeaderPhase extends BattlePhase { } canMove(): boolean { - return this.pokemon.isActive(true) && this.move.isUsable(this.pokemon); + return this.pokemon.isActive(true) && this.move.isUsable(this.pokemon)[0]; } start() { diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index f88f9d0cad1..96d28fff603 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -2,15 +2,19 @@ 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"; import { getTerrainBlockMessage } from "#data/terrain"; import { getWeatherBlockMessage } from "#data/weather"; import { AbilityId } from "#enums/ability-id"; +import { ArenaTagType } from "#enums/arena-tag-type"; import { BattlerIndex } from "#enums/battler-index"; import { BattlerTagLapseType } from "#enums/battler-tag-lapse-type"; import { BattlerTagType } from "#enums/battler-tag-type"; +import { ChallengeType } from "#enums/challenge-type"; import { CommonAnim } from "#enums/move-anims-common"; import { MoveFlags } from "#enums/move-flags"; import { MoveId } from "#enums/move-id"; @@ -24,7 +28,10 @@ import { applyMoveAttrs } from "#moves/apply-attrs"; import { frenzyMissFunc } from "#moves/move-utils"; import type { PokemonMove } from "#moves/pokemon-move"; import { BattlePhase } from "#phases/battle-phase"; -import { NumberHolder } from "#utils/common"; +// biome-ignore lint/correctness/noUnusedImports: Used in a tsdoc comment +import type { PreUseInterruptAttr } from "#types/move-types"; +import { applyChallenges } from "#utils/challenge-utils"; +import { BooleanHolder, NumberHolder } from "#utils/common"; import { enumValueToKey } from "#utils/enums"; import i18next from "i18next"; @@ -41,6 +48,9 @@ export class MovePhase extends BattlePhase { /** Whether the current move should fail and retain PP. */ protected cancelled = false; + /** Flag set to `true` during {@linkcode checkFreeze} that indicates that the pokemon will thaw if it passes the failure conditions */ + private declare thaw?: boolean; + public get pokemon(): Pokemon { return this._pokemon; } @@ -84,19 +94,6 @@ export class MovePhase extends BattlePhase { this.forcedLast = forcedLast; } - /** - * Checks if the pokemon is active, if the move is usable, and that the move is targeting something. - * @param ignoreDisableTags `true` to not check if the move is disabled - * @returns `true` if all the checks pass - */ - public canMove(ignoreDisableTags = false): boolean { - return ( - this.pokemon.isActive(true) && - this.move.isUsable(this.pokemon, isIgnorePP(this.useMode), ignoreDisableTags) && - this.targets.length > 0 - ); - } - /** Signifies the current move should fail but still use PP */ public fail(): void { this.failed = true; @@ -115,24 +112,78 @@ export class MovePhase extends BattlePhase { return this.forcedLast; } + /** + * Check the first round of failure checks. + * + * @remarks + * Based on battle mechanics research conducted primarily by Smogon, checks happen in the following order (as of Gen 9): + * 1. Sleep/Freeze + * 2. Disobedience due to overleveled (not implemented in Pokerogue) + * 3. Insufficient PP after being selected + * 4. (Pokerogue specific) Moves disabled because they are not implemented / prevented from a challenge / somehow have no targets + * 5. Sky battle (not implemented in Pokerogue) + * 6. Truant + * 7. Loss of focus + * 8. Flinch + * 9. Move was disabled after being selected + * 10. Healing move with heal block + * 11. Sound move with throat chop + * 12. Failure due to gravity + * 13. Move lock from choice items (not implemented in Pokerogue, can't occur here from gorilla tactics) + * 14. Failure from taunt + * 15. Failure from imprison + * 16. Failure from confusion + * 17. Failure from paralysis + * 18. Failure from infatuation + */ + protected firstFailureCheck(): boolean { + // A big if statement will handle the checks (that each have side effects!) in the correct order + if ( + this.checkSleep() || + this.checkFreeze() || + this.checkPP() || + this.checkValidity() || + this.checkTagCancel(BattlerTagType.TRUANT, true) || + this.checkPreUseInterrupt() || + this.checkTagCancel(BattlerTagType.FLINCHED) || + this.checkTagCancel(BattlerTagType.DISABLED, true) || + this.checkGravity() || + this.checkTagCancel(BattlerTagType.TAUNT, true) || + this.checkTagCancel(BattlerTagType.IMPRISON) || + this.checkTagCancel(BattlerTagType.CONFUSED) || + this.checkPara() || + this.checkTagCancel(BattlerTagType.INFATUATED) + ) { + this.handlePreMoveFailures(); + return true; + } + return false; + } + public start(): void { super.start(); - console.log(MoveId[this.move.moveId], enumValueToKey(MoveUseMode, this.useMode)); - - // Check if move is unusable (e.g. running out of PP due to a mid-turn Spite - // or the user no longer being on field), ending the phase early if not. - if (!this.canMove(true)) { - if (this.pokemon.isActive(true)) { - this.fail(); - this.showMoveText(); - this.showFailedText(); - } + if (!this.pokemon.isActive(true)) { this.end(); return; } + // Removing gigaton hammer always happens first + this.pokemon.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()) { + // Lapse all other pre-move tags + this.pokemon.lapseTags(BattlerTagLapseType.PRE_MOVE); + this.end(); + return; + } + + // Begin second failure checks.. // Reset hit-related turn data when starting follow-up moves (e.g. Metronomed moves, Dancer repeats) if (isVirtual(this.useMode)) { @@ -155,10 +206,6 @@ export class MovePhase extends BattlePhase { this.resolveCounterAttackTarget(); - this.resolvePreMoveStatusEffects(); - - this.lapsePreMoveAndMoveTags(); - if (!(this.failed || this.cancelled)) { this.resolveFinalPreMoveCancellationChecks(); } @@ -187,6 +234,8 @@ export class MovePhase extends BattlePhase { this.showMoveText(); this.showFailedText(); this.cancel(); + } else { + this.pokemon.lapseTags(BattlerTagLapseType.MOVE); } } @@ -195,96 +244,220 @@ export class MovePhase extends BattlePhase { } /** - * Handles {@link StatusEffect.SLEEP | Sleep}/{@link StatusEffect.PARALYSIS | Paralysis}/{@link StatusEffect.FREEZE | Freeze} rolls and side effects. + * Queue the status cure message, reset the status, and update the Pokemon info display + * @param effect - The effect being cured */ - protected resolvePreMoveStatusEffects(): void { - // Skip for follow ups/reflected moves, no status condition or post turn statuses (e.g. Poison/Toxic) - if (!this.pokemon.status?.effect || this.pokemon.status.isPostTurn() || isIgnoreStatus(this.useMode)) { - return; - } - - if ( - this.useMode === MoveUseMode.INDIRECT && - [StatusEffect.SLEEP, StatusEffect.FREEZE].includes(this.pokemon.status.effect) - ) { - // Dancer thaws out or wakes up a frozen/sleeping user prior to use - this.pokemon.resetStatus(false); - return; - } - - this.pokemon.status.incrementTurn(); - - /** Whether to prevent us from using the move */ - let activated = false; - /** Whether to cure the status */ - let healed = false; - - switch (this.pokemon.status.effect) { - case StatusEffect.PARALYSIS: - activated = - (this.pokemon.randBattleSeedInt(4) === 0 || Overrides.STATUS_ACTIVATION_OVERRIDE === true) && - Overrides.STATUS_ACTIVATION_OVERRIDE !== false; - break; - case StatusEffect.SLEEP: { - applyMoveAttrs("BypassSleepAttr", this.pokemon, null, this.move.getMove()); - const turnsRemaining = new NumberHolder(this.pokemon.status.sleepTurnsRemaining ?? 0); - applyAbAttrs("ReduceStatusEffectDurationAbAttr", { - pokemon: this.pokemon, - statusEffect: this.pokemon.status.effect, - duration: turnsRemaining, - }); - this.pokemon.status.sleepTurnsRemaining = turnsRemaining.value; - healed = this.pokemon.status.sleepTurnsRemaining <= 0; - activated = !healed && !this.pokemon.getTag(BattlerTagType.BYPASS_SLEEP); - break; - } - case StatusEffect.FREEZE: - healed = - !!this.move - .getMove() - .findAttr( - attr => attr.is("HealStatusEffectAttr") && attr.selfTarget && attr.isOfEffect(StatusEffect.FREEZE), - ) || - (!this.pokemon.randBattleSeedInt(5) && Overrides.STATUS_ACTIVATION_OVERRIDE !== true) || - Overrides.STATUS_ACTIVATION_OVERRIDE === false; - - activated = !healed; - break; - } - - if (activated) { - // Cancel move activation and play effect - this.cancel(); - globalScene.phaseManager.queueMessage( - getStatusEffectActivationText(this.pokemon.status.effect, getPokemonNameWithAffix(this.pokemon)), - ); - globalScene.phaseManager.unshiftNew( - "CommonAnimPhase", - this.pokemon.getBattlerIndex(), - undefined, - CommonAnim.POISON + (this.pokemon.status.effect - 1), // offset anim # by effect # - ); - } else if (healed) { - // cure status and play effect - globalScene.phaseManager.queueMessage( - getStatusEffectHealText(this.pokemon.status.effect, getPokemonNameWithAffix(this.pokemon)), - ); - this.pokemon.resetStatus(); - this.pokemon.updateInfo(); - } + private cureStatus(effect: StatusEffect): void { + const pokemon = this.pokemon; + globalScene.phaseManager.queueMessage(getStatusEffectHealText(effect, getPokemonNameWithAffix(pokemon))); + pokemon.resetStatus(); + pokemon.updateInfo(); } /** - * Lapse {@linkcode BattlerTagLapseType.PRE_MOVE | PRE_MOVE} tags that trigger before a move is used, regardless of whether or not it failed. - * Also lapse {@linkcode BattlerTagLapseType.MOVE | MOVE} tags if the move is successful and not called indirectly. + * Queue the status activation message, play its animation, and cancel the move + * @param effect - The effect being triggered */ - protected lapsePreMoveAndMoveTags(): void { - this.pokemon.lapseTags(BattlerTagLapseType.PRE_MOVE); + private triggerStatus(effect: StatusEffect): void { + const pokemon = this.pokemon; + this.showFailedText(getStatusEffectActivationText(effect, getPokemonNameWithAffix(pokemon))); + globalScene.phaseManager.unshiftNew( + "CommonAnimPhase", + pokemon.getBattlerIndex(), + undefined, + CommonAnim.POISON + (effect - 1), // offset anim # by effect # + ); + this.cancelled = true; + } - // 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); + /** + * Handle the sleep check + * @returns Whether the move was cancelled due to sleep + */ + protected checkSleep(): boolean { + if (this.pokemon.status?.effect !== StatusEffect.SLEEP) { + return false; } + + if (this.useMode === MoveUseMode.INDIRECT) { + this.pokemon.resetStatus(false); + return false; + } + + this.pokemon.status.incrementTurn(); + applyMoveAttrs("BypassSleepAttr", this.pokemon, null, this.move.getMove()); + const turnsRemaining = new NumberHolder(this.pokemon.status.sleepTurnsRemaining ?? 0); + applyAbAttrs("ReduceStatusEffectDurationAbAttr", { + pokemon: this.pokemon, + statusEffect: this.pokemon.status.effect, + duration: turnsRemaining, + }); + this.pokemon.status.sleepTurnsRemaining = turnsRemaining.value; + if (this.pokemon.status.sleepTurnsRemaining <= 0) { + this.cureStatus(StatusEffect.SLEEP); + return false; + } + + this.triggerStatus(StatusEffect.SLEEP); + return true; + } + + /** + * Handle the freeze status effect check + * + * @remarks + * Responsible for the following + * - Checking if the pokemon is frozen + * - Checking if the pokemon will thaw from random chance, OR from a thawing move. + * Thawing from a freeze move is not applied until AFTER all other failure checks. + * - Activating the freeze status effect (cancelling the move, playing the message, and displaying the animation) + */ + protected checkFreeze(): boolean { + if (this.pokemon.status?.effect !== StatusEffect.FREEZE) { + return false; + } + + // Heal the user if it thaws from the move or random chance + // Check if the user will thaw due to a move + + if ( + Overrides.STATUS_ACTIVATION_OVERRIDE === false || + this.move + .getMove() + .findAttr(attr => attr.selfTarget && attr.is("HealStatusEffectAttr") && attr.isOfEffect(StatusEffect.FREEZE)) || + (!this.pokemon.randBattleSeedInt(5) && Overrides.STATUS_ACTIVATION_OVERRIDE !== true) + ) { + this.cureStatus(StatusEffect.FREEZE); + return false; + } + + // Otherwise, trigger the freeze status effect + this.triggerStatus(StatusEffect.FREEZE); + return true; + } + + /** + * Check if the move is usable based on PP + * @returns Whether the move was cancelled due to insufficient PP + */ + protected checkPP(): boolean { + const move = this.move; + if (move.getMove().pp !== -1 && !isIgnorePP(this.useMode) && move.ppUsed >= move.getMovePp()) { + this.cancel(); + this.showFailedText(); + return true; + } + return false; + } + + /** + * Check if the move is valid and not in an error state + * + * @remarks + * Checks occur in the following order + * 1. Move is not implemented + * 2. Move is somehow invalid (it is {@linkcode MoveId.NONE} or {@linkcode targets} is somehow empty) + * 3. Move cannot be used by the player due to a challenge + * + * @returns Whether the move was cancelled due to being invalid + */ + protected checkValidity(): boolean { + const move = this.move; + const moveId = move.moveId; + const moveName = move.getName(); + let failedText: string | undefined; + const usability = new BooleanHolder(false); + 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 + } else if ( + this.pokemon.isPlayer() && + applyChallenges(ChallengeType.POKEMON_MOVE, moveId, usability) && + // check the value inside of usability after calling applyChallenges + !usability.value + ) { + failedText = i18next.t("battle:moveCannotUseChallenge", { moveName }); + } else { + return false; + } + + this.cancel(); + this.showFailedText(failedText); + return true; + } + + /** + * Cancel the move if its pre use condition fails + * + * @remarks + * The only official move with a pre-use condition is Focus Punch + * + * @returns Whether the move was cancelled due to a pre-use interruption + * @see {@linkcode PreUseInterruptAttr} + */ + private checkPreUseInterrupt(): boolean { + const move = this.move.getMove(); + const user = this.pokemon; + const target = this.getActiveTargetPokemon()[0]; + return !!move.getAttrs("PreUseInterruptAttr").some(attr => { + attr.apply(user, target, move); + if (this.cancelled) { + return true; + } + return false; + }); + } + + /** + * Lapse the tag type and check if the move is cancelled from it. Meant to be used during the first failure check + * @param tag - The tag type whose lapse method will be called with {@linkcode BattlerTagLapseType.PRE_MOVE} + * @param checkIgnoreStatus - Whether to check {@link isIgnoreStatus} for the current {@linkcode MoveUseMode} to skip this check + */ + private checkTagCancel(tag: BattlerTagType, checkIgnoreStatus = false): boolean { + if (checkIgnoreStatus && isIgnoreStatus(this.useMode)) { + return false; + } + this.pokemon.lapseTag(tag, BattlerTagLapseType.PRE_MOVE); + return this.cancelled; + } + + /** + * Handle move failures due to Gravity, cancelling the move and showing the failure text + * @returns - Whether the move was cancelled due to Gravity + */ + private checkGravity(): boolean { + const move = this.move.getMove(); + if (globalScene.arena.hasTag(ArenaTagType.GRAVITY) && move.hasFlag(MoveFlags.GRAVITY)) { + // Play the failure message + this.showFailedText( + i18next.t("battle:moveDisabledGravity", { + pokemonNameWithAffix: getPokemonNameWithAffix(this.pokemon), + moveName: move.name, + }), + ); + return true; + } + return false; + } + + /** + * Handle the paralysis status effect check, cancelling the move and queueing the activation message and animation + * + * @returns Whether the move was cancelled due to paralysis + */ + private checkPara(): boolean { + if (this.pokemon.status?.effect !== StatusEffect.PARALYSIS) { + return false; + } + const proc = + (this.pokemon.randBattleSeedInt(4) === 0 || Overrides.STATUS_ACTIVATION_OVERRIDE === true) && + Overrides.STATUS_ACTIVATION_OVERRIDE !== false; + if (!proc) { + return false; + } + this.triggerStatus(StatusEffect.PARALYSIS); + return true; } protected useMove(): void { @@ -295,14 +468,6 @@ export class MovePhase extends BattlePhase { // form changes happen even before we know that the move wll execute. globalScene.triggerPokemonFormChange(this.pokemon, SpeciesFormChangePreMoveTrigger); - // 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. // TODO: Refactor move queues and remove this assignment; // Move queues should be handled by the calling `CommandPhase` or a manager for it @@ -332,10 +497,10 @@ export class MovePhase extends BattlePhase { * Move conditions assume the move has a single target * TODO: is this sustainable? */ - const failsConditions = !move.applyConditions(this.pokemon, targets[0], move); + 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); - failed ||= failsConditions || failedDueToWeather || failedDueToTerrain; + const failed = failsConditions || failedDueToWeather || failedDueToTerrain; if (failed) { this.failMove(true, failedDueToWeather, failedDueToTerrain); @@ -442,7 +607,7 @@ export class MovePhase extends BattlePhase { const move = this.move.getMove(); const targets = this.getActiveTargetPokemon(); - if (!move.applyConditions(this.pokemon, targets[0], move)) { + if (!move.applyConditions(this.pokemon, targets[0])) { this.failMove(true); return; } diff --git a/test/moves/belch.test.ts b/test/moves/belch.test.ts new file mode 100644 index 00000000000..16eda37f89a --- /dev/null +++ b/test/moves/belch.test.ts @@ -0,0 +1,49 @@ +import { AbilityId } from "#enums/ability-id"; +import { MoveId } from "#enums/move-id"; +import { SpeciesId } from "#enums/species-id"; +import { GameManager } from "#test/test-utils/game-manager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Move - Belch", () => { + 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.BALL_FETCH) + .battleStyle("single") + .criticalHits(false) + .moveset(MoveId.BELCH) + .enemySpecies(SpeciesId.MAGIKARP) + .enemyAbility(AbilityId.BALL_FETCH) + .enemyMoveset(MoveId.SPLASH); + }); + + it("should only be selectable if the user has previously eaten a berry", async () => { + await game.classicMode.startBattle([SpeciesId.FEEBAS]); + const player = game.field.getPlayerPokemon(); + expect( + !game.field.getPlayerPokemon().isMoveSelectable(MoveId.BELCH), + "Belch should not be selectable without a berry", + ); + + player.battleData.hasEatenBerry = true; + + expect( + game.field.getPlayerPokemon().isMoveSelectable(MoveId.BELCH), + "Belch should be selectable after eating a berry", + ); + }); +});