From 5e7caadc78eb945cfd635fa19e387a197d4b010c 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 01/43] Add failure conditions and move failures part 1 --- src/data/battler-tags.ts | 159 ++++++----- src/data/moves/move-condition.ts | 117 ++++++++ src/data/moves/move.ts | 219 +++++++++------ src/data/moves/pokemon-move.ts | 49 +++- src/enums/battler-tag-lapse-type.ts | 17 +- src/enums/move-flags.ts | 2 + src/field/pokemon.ts | 73 +++-- src/phases/command-phase.ts | 67 ++--- src/phases/move-header-phase.ts | 2 +- src/phases/move-phase.ts | 418 +++++++++++++++++++--------- test/moves/belch.test.ts | 49 ++++ 11 files changed, 793 insertions(+), 379 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 8abd98f4683..cad6726f65f 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -48,7 +48,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. @@ -303,12 +311,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); } /** @@ -357,13 +360,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 { @@ -512,7 +509,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); @@ -699,18 +699,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), @@ -720,7 +723,7 @@ export class FlinchedTag extends BattlerTag { return true; } - return super.lapse(pokemon, lapseType); + return lapseType === BattlerTagLapseType.TURN_END && super.lapse(pokemon, lapseType); } getDescriptor(): string { @@ -751,7 +754,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); } } @@ -762,7 +768,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 { @@ -805,8 +811,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; @@ -831,7 +847,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; @@ -907,7 +926,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 { @@ -970,7 +989,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; @@ -1095,7 +1117,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 { @@ -1117,23 +1139,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 + pokemon.getMoveType(move) !== PokemonType.FIRE || + (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(); @@ -1233,13 +1259,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 { @@ -2021,7 +2041,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 { @@ -2848,12 +2868,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 { @@ -3102,7 +3117,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: @@ -3260,7 +3275,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) { @@ -3315,13 +3330,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); } /** @@ -3514,7 +3523,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) { @@ -3535,23 +3544,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 075876d8ddd..fb979bfd5cb 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -46,13 +46,13 @@ import { FieldPosition } from "#enums/field-position"; import { HitResult } from "#enums/hit-result"; import { ModifierPoolType } from "#enums/modifier-pool-type"; import { ChargeAnim } from "#enums/move-anims-common"; -import { MoveId } from "#enums/move-id"; -import { MoveResult } from "#enums/move-result"; -import { isVirtual, MoveUseMode } from "#enums/move-use-mode"; import { MoveCategory } from "#enums/move-category"; import { MoveEffectTrigger } from "#enums/move-effect-trigger"; import { MoveFlags } from "#enums/move-flags"; +import { MoveId } from "#enums/move-id"; +import { MoveResult } from "#enums/move-result"; import { MoveTarget } from "#enums/move-target"; +import { isVirtual, MoveUseMode } from "#enums/move-use-mode"; import { MultiHitType } from "#enums/multi-hit-type"; import { PokemonType } from "#enums/pokemon-type"; import { PositionalTagType } from "#enums/positional-tag-type"; @@ -79,6 +79,7 @@ import { } from "#modifiers/modifier"; import { applyMoveAttrs } from "#moves/apply-attrs"; import { invalidAssistMoves, invalidCopycatMoves, invalidMetronomeMoves, invalidMirrorMoveMoves, invalidSketchMoves, invalidSleepTalkMoves } from "#moves/invalid-moves"; +import { ConsecutiveUseRestriction, FirstMoveCondition, GravityUseRestriction, MoveCondition, MoveRestriction, UpperHandCondition } from "#moves/move-condition"; import { frenzyMissFunc, getMoveTargets } from "#moves/move-utils"; import { PokemonMove } from "#moves/pokemon-move"; import { MovePhase } from "#phases/move-phase"; @@ -87,19 +88,19 @@ import type { AttackMoveResult } from "#types/attack-move-result"; import type { Localizable } from "#types/locales"; import type { ChargingMove, MoveAttrMap, MoveAttrString, MoveClassMap, MoveKindString, MoveMessageFunc } from "#types/move-types"; import type { TurnMove } from "#types/turn-move"; +import type { AbstractConstructor } from "#types/type-helpers"; +import { applyChallenges } from "#utils/challenge-utils"; import { BooleanHolder, coerceArray, type Constructor, NumberHolder, randSeedFloat, randSeedInt, randSeedItem, toDmgValue } from "#utils/common"; import { getEnumValues } from "#utils/enums"; import { toCamelCase, toTitleCase } from "#utils/strings"; import i18next from "i18next"; -import { applyChallenges } from "#utils/challenge-utils"; import { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier"; -import type { AbstractConstructor } from "#types/type-helpers"; /** * 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 { @@ -117,7 +118,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 = ""; @@ -371,7 +381,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") { @@ -382,6 +392,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, @@ -580,6 +632,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} @@ -703,8 +768,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, ""]; } /** @@ -1476,13 +1562,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; } /** @@ -7944,8 +8038,6 @@ export class ForceLastAttr extends MoveEffectAttr { } } -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; @@ -7991,57 +8083,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; @@ -8443,7 +8484,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), @@ -8458,7 +8499,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), @@ -8775,7 +8816,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) @@ -8829,7 +8870,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) @@ -9472,7 +9513,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) @@ -9524,6 +9565,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) @@ -9648,7 +9690,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) @@ -9877,7 +9920,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) => target.getTag(BattlerTagType.INGRAIN) == null && target.getTag(BattlerTagType.IGNORE_FLYING) == null) @@ -9981,7 +10024,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/: @@ -10169,14 +10212,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) => { @@ -10707,7 +10750,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) @@ -10761,11 +10805,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) @@ -11342,10 +11385,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() @@ -11370,10 +11410,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 23daf0a971b..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,32 +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 acd73f897e7..b03fa4144b6 100644 --- a/src/enums/move-flags.ts +++ b/src/enums/move-flags.ts @@ -51,4 +51,6 @@ export enum MoveFlags { 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, + /** 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 06e5e0d85aa..3956d381af6 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -3130,9 +3130,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, ""]; } /** Show this Pokémon's info panel */ @@ -4111,19 +4117,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 `BattlerTagType` to lapse - * @returns Whether 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} */ - public lapseTag(tagType: BattlerTagType): boolean { + public 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); } @@ -4135,7 +4150,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * `lapseType`, removing any whose durations fall below 0. * @param lapseType - The type of lapse to process */ - public lapseTags(lapseType: BattlerTagLapseType): void { + public lapseTags(lapseType: Exclude): void { const tags = this.summonData.tags; tags .filter( @@ -4247,13 +4262,37 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { /** * Get whether the given move is currently disabled for this Pokémon * + * @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 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); } /** @@ -6414,7 +6453,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; } @@ -6424,7 +6463,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 > 0) { // If there's only 1 move in the move pool, use it. @@ -6482,8 +6521,8 @@ export class EnemyPokemon extends Pokemon { move.category !== MoveCategory.STATUS && moveTargets.some(p => { const doesNotFail = - move.applyConditions(this, p, move) - || [MoveId.SUCKER_PUNCH, MoveId.UPPER_HAND, MoveId.THUNDERCLAP].includes(move.id); + move.applyConditions(this, p) || + [MoveId.SUCKER_PUNCH, MoveId.UPPER_HAND, MoveId.THUNDERCLAP].includes(move.id); return ( doesNotFail && p.getAttackDamage({ @@ -6543,8 +6582,8 @@ export class EnemyPokemon extends Pokemon { * target score to -20 */ if ( - (move.name.endsWith(" (N)") || !move.applyConditions(this, target, move)) - && ![MoveId.SUCKER_PUNCH, MoveId.UPPER_HAND, MoveId.THUNDERCLAP].includes(move.id) + (move.name.endsWith(" (N)") || !move.applyConditions(this, target)) && + ![MoveId.SUCKER_PUNCH, MoveId.UPPER_HAND, MoveId.THUNDERCLAP].includes(move.id) ) { targetScore = -20; } else if (move.is("AttackMove")) { diff --git a/src/phases/command-phase.ts b/src/phases/command-phase.ts index 2bf845776ca..bbd2a397fd0 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"; @@ -23,8 +22,6 @@ import { getMoveTargets } from "#moves/move-utils"; import type { PokemonMove } from "#moves/pokemon-move"; 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 { @@ -125,9 +122,9 @@ export class CommandPhase extends FieldPhase { for (const queuedMove of moveQueue) { const movesetQueuedMove = moveset.find(m => m.moveId === queuedMove.move); if ( - queuedMove.move !== MoveId.NONE - && !isVirtual(queuedMove.useMode) - && !movesetQueuedMove?.isUsable(playerPokemon, isIgnorePP(queuedMove.useMode)) + queuedMove.move !== MoveId.NONE && + !isVirtual(queuedMove.useMode) && + !movesetQueuedMove?.isUsable(playerPokemon, isIgnorePP(queuedMove.useMode), true) ) { entriesToDelete++; } else { @@ -205,39 +202,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 move - The move that cannot be used + * 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, move: PokemonMove) { - 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 }), + 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,22 +250,15 @@ export class CommandPhase extends FieldPhase { ): boolean { const playerPokemon = this.getPokemon(); const ignorePP = isIgnorePP(useMode); - - let canUse = cursor === -1 || playerPokemon.trySelectMove(cursor, ignorePP); - - const moveset = playerPokemon.getMoveset(); + 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 && !moveset.some(m => m.isUsable(playerPokemon)); + const useStruggle = canUse + ? false + : cursor > -1 && !playerPokemon.getMoveset().some(m => m.isUsable(playerPokemon)[0]); - canUse ||= useStruggle; - - if (!canUse) { - // Selected move *may* be undefined if the cursor is over a position that the mon does not have - const selectedMove: PokemonMove | undefined = moveset[cursor]; - if (selectedMove) { - this.queueFightErrorMessage(playerPokemon, moveset[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 5b8a6f998a1..ee8f294364b 100644 --- a/src/phases/move-header-phase.ts +++ b/src/phases/move-header-phase.ts @@ -20,7 +20,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 5e85401db77..a546705e69d 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -3,6 +3,8 @@ import { MOVE_COLOR } from "#app/constants/colors"; 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 { PokemonPhase } from "#app/phases/pokemon-phase"; import { CenterOfAttentionTag } from "#data/battler-tags"; import { SpeciesFormChangePreMoveTrigger } from "#data/form-change-triggers"; @@ -10,9 +12,11 @@ import { getStatusEffectActivationText, getStatusEffectHealText } from "#data/st 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"; @@ -26,8 +30,10 @@ import type { Pokemon } from "#field/pokemon"; import { applyMoveAttrs } from "#moves/apply-attrs"; import { frenzyMissFunc } from "#moves/move-utils"; import type { PokemonMove } from "#moves/pokemon-move"; -import type { TurnMove } from "#types/turn-move"; -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"; @@ -44,12 +50,8 @@ export class MovePhase extends PokemonPhase { /** Whether the current move should fail and retain PP. */ protected cancelled = false; - /** The move history entry object that is pushed to the pokemon's move history - * - * @remarks - * Can be edited _after_ being pushed to the history to adjust the result, targets, etc, for this move phase. - */ - protected moveHistoryEntry: TurnMove; + /** 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; @@ -97,19 +99,6 @@ export class MovePhase extends PokemonPhase { }; } - /** - * 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; @@ -120,27 +109,78 @@ export class MovePhase extends PokemonPhase { this.cancelled = true; } + /** + * 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( - `%cMove: ${MoveId[this.move.moveId]}\nUse Mode: ${enumValueToKey(MoveUseMode, this.useMode)}`, - `color:${MOVE_COLOR}`, - ); - - // 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)) { @@ -163,10 +203,6 @@ export class MovePhase extends PokemonPhase { this.resolveCounterAttackTarget(); - this.resolvePreMoveStatusEffects(); - - this.lapsePreMoveAndMoveTags(); - if (!(this.failed || this.cancelled)) { this.resolveFinalPreMoveCancellationChecks(); } @@ -195,6 +231,8 @@ export class MovePhase extends PokemonPhase { this.showMoveText(); this.showFailedText(); this.cancel(); + } else { + this.pokemon.lapseTags(BattlerTagLapseType.MOVE); } } @@ -203,96 +241,220 @@ export class MovePhase extends PokemonPhase { } /** - * 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)), - ); - // cannot use `asPhase=true` as it will cause status to be reset _after_ move condition checks fire - this.pokemon.resetStatus(false, false, false, false); - } + private cureStatus(effect: StatusEffect): void { + const pokemon = this.pokemon; + globalScene.phaseManager.queueMessage(getStatusEffectHealText(effect, getPokemonNameWithAffix(pokemon))); + pokemon.resetStatus(undefined, undefined, undefined, false); + 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 { @@ -303,14 +465,6 @@ export class MovePhase extends PokemonPhase { // 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 @@ -340,10 +494,10 @@ export class MovePhase extends PokemonPhase { * 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); @@ -446,7 +600,7 @@ export class MovePhase extends PokemonPhase { 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", + ); + }); +}); From eaae371a28b87a3355af6b0ee1f30f77de819c13 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 02/43] Add second and third failure sequences --- src/data/moves/move-condition.ts | 41 +++-- src/data/moves/move.ts | 76 ++++++-- src/field/pokemon.ts | 8 +- 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 fb979bfd5cb..c225fb6543b 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -123,6 +123,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 @@ -381,13 +385,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; } @@ -763,13 +769,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)); } @@ -9010,6 +9018,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); @@ -9298,6 +9307,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) @@ -9345,6 +9356,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 ]) @@ -9670,6 +9683,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) => { @@ -9902,9 +9917,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) @@ -9974,9 +9993,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() @@ -10562,7 +10585,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) @@ -10575,16 +10603,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), @@ -10811,8 +10844,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) @@ -11375,10 +11412,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) })); @@ -11477,7 +11515,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 3956d381af6..5d7d959b992 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -2409,8 +2409,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * modifiers from move and ability attributes * @param source - The attacking Pokémon. * @param 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 ignoreAbility - Whether to ignore abilities that might affect type effectiveness or immunity; default `false` + * @param simulated - Whether to apply abilities via simulated calls; default `true`; ⚠️ Should only ever be false during `moveEffect` phase * @param cancelled - Stores whether the move was cancelled by a non-type-based immunity. * @param useIllusion - Whether to consider an active illusion * @returns The type damage multiplier, indicating the effectiveness of the move @@ -2464,7 +2464,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: (typeof defendingSidePlayField)[0]) => { applyAbAttrs("FieldPriorityMoveImmunityAbAttr", { diff --git a/src/phases/command-phase.ts b/src/phases/command-phase.ts index bbd2a397fd0..2c1783711dd 100644 --- a/src/phases/command-phase.ts +++ b/src/phases/command-phase.ts @@ -258,6 +258,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 a546705e69d..e5d733b2f10 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -3,8 +3,6 @@ import { MOVE_COLOR } from "#app/constants/colors"; 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 { PokemonPhase } from "#app/phases/pokemon-phase"; import { CenterOfAttentionTag } from "#data/battler-tags"; import { SpeciesFormChangePreMoveTrigger } from "#data/form-change-triggers"; @@ -110,7 +108,9 @@ export class MovePhase extends PokemonPhase { } /** - * 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): @@ -144,6 +144,8 @@ export class MovePhase extends PokemonPhase { 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) || @@ -157,6 +159,115 @@ export class MovePhase extends PokemonPhase { 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(); @@ -165,25 +276,52 @@ export class MovePhase extends PokemonPhase { 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; } @@ -199,10 +337,30 @@ export class MovePhase extends PokemonPhase { 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(); } @@ -210,7 +368,7 @@ export class MovePhase extends PokemonPhase { // 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(); @@ -219,7 +377,7 @@ export class MovePhase extends PokemonPhase { 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(); @@ -228,7 +386,6 @@ export class MovePhase extends PokemonPhase { (targets.length === 0 && !this.move.getMove().hasAttr("AddArenaTrapTagAttr")) || (moveQueue.length > 0 && moveQueue[0].move === MoveId.NONE) ) { - this.showMoveText(); this.showFailedText(); this.cancel(); } else { @@ -243,10 +400,12 @@ export class MovePhase extends PokemonPhase { /** * 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(undefined, undefined, undefined, false); pokemon.updateInfo(); } @@ -314,9 +473,23 @@ export class MovePhase extends PokemonPhase { 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 @@ -367,7 +540,17 @@ export class MovePhase extends PokemonPhase { 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) && @@ -457,85 +640,54 @@ export class MovePhase extends PokemonPhase { 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 }); }); } } @@ -664,6 +816,7 @@ export class MovePhase extends PokemonPhase { 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) @@ -778,10 +931,7 @@ export class MovePhase extends PokemonPhase { } 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)) { From 8b9eeef74e72a74d1d444af75cfb5b27fe693cc5 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Mon, 18 Aug 2025 19:24:22 -0500 Subject: [PATCH 03/43] Refactor mostly complete, need to recheck tests --- src/data/battler-tags.ts | 1 - src/data/moves/move-condition.ts | 132 +++++++++++- src/data/moves/move-utils.ts | 24 +++ src/data/moves/move.ts | 343 ++++++++++++++++++++----------- src/enums/move-category.ts | 3 + src/enums/move-flags.ts | 6 +- src/field/pokemon.ts | 8 +- src/phases/command-phase.ts | 2 +- src/phases/move-phase.ts | 156 +++++++------- src/utils/pokemon-utils.ts | 17 ++ test/moves/throat-chop.test.ts | 3 + 11 files changed, 475 insertions(+), 220 deletions(-) diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index cad6726f65f..f4eedbe6702 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -1158,7 +1158,6 @@ export class PowderTag extends BattlerTag { } // Disable the target's fire type move and damage it (subject to Magic Guard) - currentPhase.showMoveText(); currentPhase.fail(); const idx = pokemon.getBattlerIndex(); diff --git a/src/data/moves/move-condition.ts b/src/data/moves/move-condition.ts index 5d03bc7926d..5c06ab9bebe 100644 --- a/src/data/moves/move-condition.ts +++ b/src/data/moves/move-condition.ts @@ -1,13 +1,16 @@ -// 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 { TrappedTag } from "#data/battler-tags"; 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 { MoveCategory, type MoveDamageCategory } from "#enums/move-category"; +import type { MoveId } from "#enums/move-id"; +import { isVirtual } from "#enums/move-use-mode"; +import { PokemonType } from "#enums/pokemon-type"; import type { Pokemon } from "#field/pokemon"; import type { Move, MoveConditionFunc, UserMoveConditionFunc } from "#moves/move"; +import { getCounterAttackTarget } from "#moves/move-utils"; import i18next from "i18next"; /** @@ -54,6 +57,81 @@ export class FirstMoveCondition extends MoveCondition { } } +/** + * Condition that fails the move if the user has less than 1/x of their max HP. + * @remarks + * Used by Clangorous Soul and Fillet Away + */ +export class FailIfInsufficientHpCondition extends MoveCondition { + /** + * Condition that fails the move if the user has less than 1/x of their max HP. + * @param ratio - The required HP ratio (the `x` in `1/x`) + */ + constructor(cutRatio: number) { + super(user => user.getHpRatio() > 1 / cutRatio); + } +} + +/** + * Teleport condition checks + * + * @remarks + * For trainer pokemon, just checks if there are any benched pokemon allowed in battle + * + * Wild pokemon cannot teleport if either: + * - The current battle is a double battle + * - They are under the effects of a *move-based* trapping effect like and are neither a ghost type nor have an active run away ability + */ +export const failTeleportCondition = new MoveCondition(user => { + if (user.hasTrainer()) { + const party = user.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty(); + for (const pokemon of party) { + if (!pokemon.isOnField() && pokemon.isAllowedInBattle()) { + return true; + } + } + return false; + } + + // Wild pokemon + + // Cannot teleport in double battles (even if last remaining) + if (globalScene.currentBattle.double) { + return false; + } + // If smoke ball / shed tail items are ever added, checks for them should be placed here + // If a conditional "run away" ability is ever added, then we should use the apply method instead of the `hasAbility` + if (user.isOfType(PokemonType.GHOST, true, true) || user.hasAbilityWithAttr("RunSuccessAbAttr")) { + return true; + } + + // Wild pokemon are prevented from fleeing if they are trapped *specifically* + if (globalScene.arena.hasTag(ArenaTagType.FAIRY_LOCK) || user.getTag(TrappedTag) !== undefined) { + // Fairy Lock prevents teleporting + return false; + } + + return true; +}); + +/** + * Condition that forces moves to fail if the target's selected move is not an attacking move + * + * @remarks + * Used by Sucker Punch and Thunderclap + */ +export const failIfTargetNotAttackingCondition = new MoveCondition((_user, target) => { + const turnCommand = globalScene.currentBattle.turnCommands[target.getBattlerIndex()]; + if (!turnCommand || !turnCommand.move) { + return false; + } + return ( + turnCommand.command === Command.FIGHT && + !target.turnData.acted && + allMoves[turnCommand.move.move].category !== MoveCategory.STATUS + ); +}); + /** * Condition that forces moves to fail against the final boss in classic and the major boss in endless * @remarks @@ -86,6 +164,54 @@ export const UpperHandCondition = new MoveCondition((_user, target) => { ); }); +/** + * Condition used by the move {@link https://bulbapedia.bulbagarden.net/wiki/Last_Resort_(move) | Last Resort} + * + * @remarks + * Last resort fails if + * - It is not in the user's moveset + * - The user does not know at least one other move + * - The user has not directly used each other move in its moveset since it was sent into battle + * - A move is considered *used* for this purpose if it passed the first failure check sequence in the move phase + */ +export const lastResortCondition = new MoveCondition((user, _target, move) => { + const otherMovesInMoveset = new Set(user.getMoveset().map(m => m.moveId)); + if (!otherMovesInMoveset.delete(move.id) || !otherMovesInMoveset.size) { + return false; // Last resort fails if used when not in user's moveset or no other moves exist + } + + const movesInHistory = new Set( + user + .getMoveHistory() + .filter(m => !isVirtual(m.useMode)) // Last resort ignores virtual moves + .map(m => m.move), + ); + + // Since `Set.intersection()` is only present in ESNext, we have to do this to check inclusion + return [...otherMovesInMoveset].every(m => movesInHistory.has(m)); +}); + +/** + * Condition used by counter-like moves if the user was hit by at least one qualifying attack this turn. + * Qualifying attacks are those that match the specified category (physical, special or either) + * that did not come from an ally. + */ +class CounterAttackConditon extends MoveCondition { + /** + * @param damageCategory - The category of move to counter (physical or special), or `undefined` to counter both + */ + constructor(damageCategory?: MoveDamageCategory) { + super(user => getCounterAttackTarget(user, damageCategory) !== null); + } +} + +/** Condition check for counterattacks that proc againt physical moves */ +export const counterAttackCondition_Physical = new CounterAttackConditon(MoveCategory.PHYSICAL); +/** Condition check for counterattacks that proc against special moves*/ +export const counterAttackCondition_Special = new CounterAttackConditon(MoveCategory.SPECIAL); +/** Condition check for counterattacks that proc against moves regardless of damage type */ +export const counterAttackCondition_Both = new CounterAttackConditon(); + /** * A restriction that prevents a move from being selected * diff --git a/src/data/moves/move-utils.ts b/src/data/moves/move-utils.ts index 1fe0880317b..d30f09a838c 100644 --- a/src/data/moves/move-utils.ts +++ b/src/data/moves/move-utils.ts @@ -1,6 +1,7 @@ import { allMoves } from "#data/data-lists"; import type { BattlerIndex } from "#enums/battler-index"; import { BattlerTagType } from "#enums/battler-tag-type"; +import { MoveCategory, type MoveDamageCategory } from "#enums/move-category"; import type { MoveId } from "#enums/move-id"; import { MoveTarget } from "#enums/move-target"; import { PokemonType } from "#enums/pokemon-type"; @@ -8,6 +9,7 @@ import type { Pokemon } from "#field/pokemon"; import { applyMoveAttrs } from "#moves/apply-attrs"; import type { Move, MoveTargetSet, UserMoveConditionFunc } from "#moves/move"; import { NumberHolder } from "#utils/common"; +import { areAllies } from "#utils/pokemon-utils"; /** * Return whether the move targets the field @@ -133,3 +135,25 @@ export const frenzyMissFunc: UserMoveConditionFunc = (user: Pokemon, move: Move) return true; }; + +/** + * Determine the target for the `user`'s counter-attack move + * @param user - The pokemon using the counter-like move + * @param damageCategory - The category of move to counter (physical or special), or `undefined` to counter both + * @returns - The battler index of the most recent, non-ally attacker using a move that matches the specified category, or `null` if no such attacker exists + */ +export function getCounterAttackTarget(user: Pokemon, damageCategory?: MoveDamageCategory): BattlerIndex | null { + for (const attackRecord of user.turnData.attacksReceived) { + // check if the attacker was an ally + const moveCategory = allMoves[attackRecord.move].category; + const sourceBattlerIndex = attackRecord.sourceBattlerIndex; + if ( + moveCategory !== MoveCategory.STATUS && + !areAllies(sourceBattlerIndex, user.getBattlerIndex()) && + (damageCategory === undefined || moveCategory === damageCategory) + ) { + return sourceBattlerIndex; + } + } + return null; +} diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index c225fb6543b..6c82d7760a4 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -37,7 +37,7 @@ import { AbilityId } from "#enums/ability-id"; import { ArenaTagSide } from "#enums/arena-tag-side"; import { ArenaTagType } from "#enums/arena-tag-type"; import { BattleType } from "#enums/battle-type"; -import type { BattlerIndex } from "#enums/battler-index"; +import { BattlerIndex } from "#enums/battler-index"; import { BattlerTagType } from "#enums/battler-tag-type"; import { BiomeId } from "#enums/biome-id"; import { ChallengeType } from "#enums/challenge-type"; @@ -46,13 +46,16 @@ import { FieldPosition } from "#enums/field-position"; import { HitResult } from "#enums/hit-result"; import { ModifierPoolType } from "#enums/modifier-pool-type"; import { ChargeAnim } from "#enums/move-anims-common"; -import { MoveCategory } from "#enums/move-category"; +import { MoveCategory, MoveDamageCategory } from "#enums/move-category"; import { MoveEffectTrigger } from "#enums/move-effect-trigger"; import { MoveFlags } from "#enums/move-flags"; import { MoveId } from "#enums/move-id"; import { MoveResult } from "#enums/move-result"; +import { MoveId } from "#enums/move-id"; +import { MoveResult } from "#enums/move-result"; import { MoveTarget } from "#enums/move-target"; import { isVirtual, MoveUseMode } from "#enums/move-use-mode"; +import { isVirtual, MoveUseMode } from "#enums/move-use-mode"; import { MultiHitType } from "#enums/multi-hit-type"; import { PokemonType } from "#enums/pokemon-type"; import { PositionalTagType } from "#enums/positional-tag-type"; @@ -79,18 +82,17 @@ import { } from "#modifiers/modifier"; import { applyMoveAttrs } from "#moves/apply-attrs"; import { invalidAssistMoves, invalidCopycatMoves, invalidMetronomeMoves, invalidMirrorMoveMoves, invalidSketchMoves, invalidSleepTalkMoves } from "#moves/invalid-moves"; -import { ConsecutiveUseRestriction, FirstMoveCondition, GravityUseRestriction, MoveCondition, MoveRestriction, UpperHandCondition } from "#moves/move-condition"; -import { frenzyMissFunc, getMoveTargets } from "#moves/move-utils"; +import { ConsecutiveUseRestriction, counterAttackCondition_Both, counterAttackCondition_Physical, counterAttackCondition_Special, failAgainstFinalBossCondition, FailIfInsufficientHpCondition, failIfTargetNotAttackingCondition, failTeleportCondition, FirstMoveCondition, GravityUseRestriction, lastResortCondition, MoveCondition, MoveRestriction, UpperHandCondition } from "#moves/move-condition"; +import { frenzyMissFunc, getCounterAttackTarget, getMoveTargets } from "#moves/move-utils"; import { PokemonMove } from "#moves/pokemon-move"; import { MovePhase } from "#phases/move-phase"; import { PokemonHealPhase } from "#phases/pokemon-heal-phase"; -import type { AttackMoveResult } from "#types/attack-move-result"; import type { Localizable } from "#types/locales"; import type { ChargingMove, MoveAttrMap, MoveAttrString, MoveClassMap, MoveKindString, MoveMessageFunc } from "#types/move-types"; import type { TurnMove } from "#types/turn-move"; import type { AbstractConstructor } from "#types/type-helpers"; import { applyChallenges } from "#utils/challenge-utils"; -import { BooleanHolder, coerceArray, type Constructor, NumberHolder, randSeedFloat, randSeedInt, randSeedItem, toDmgValue } from "#utils/common"; +import { BooleanHolder, coerceArray, type Constructor, isNullOrUndefined, NumberHolder, randSeedFloat, randSeedInt, randSeedItem, toDmgValue } from "#utils/common"; import { getEnumValues } from "#utils/enums"; import { toCamelCase, toTitleCase } from "#utils/strings"; import i18next from "i18next"; @@ -118,15 +120,56 @@ 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. + /** + * Conditions that must be met for the move to succeed when it is used. * + * @remarks + * These are the default conditions checked during the move effect phase (aka sequence 4). + * When adding a new condition, if unsure of where it occurs in the failure checks, it should go here. * @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 ) + * Move failure conditions that occur during the second sequence (after move message and before ) */ private conditionsSeq2: MoveCondition[] = []; + /** + * Move failure conditions that occur during the third sequence (after accuracy and before move effects). + * + * @remarks + * List of *move-based* conditions that occur in this sequence: + * - Battle mechanics research conducted by Smogon and manual checks from SirzBenjie + * - Steel Roller with no terrain + * - Follow Me / Rage Powder failing due to being used in single battle + * - Stockpile with >= 3 stacks already + * - Spit up / swallow with 0 stockpiles + * - Counter / Mirror Coat / Metal Burst with no damage taken from enemies this turn + * - Last resort's bespoke failure conditions + * - Snore while not asleep + * - Sucker punch failling due to the target having already used their selected move or not having selected a damaging move + * - Magic Coat failing due to being used as the last move in the turn + * - Protect-like moves failing due to consecutive use or being the last move in the turn + * - First turn moves (e.g. mat block, fake out) failing due to not being used on first turn + * - Rest failing due, in this order, to already being asleep (including from comatose), under the effect of heal block, being full hp, having insomnia / vital spirit + * - Teleport failing when used by a wild pokemon that can't use it to flee (because it is trapped, in a double battle, etc) or used by a trainer pokemon with no pokemon to switch to + * - Fling with no usable item (not done as Pokerogue does not yet implement fling) + * - Magnet rise failing while under the effect of ingrain, smack down, or gravity (gravity check not done as redundant with sequence 1) + * - Splash failing when gravity is in effect (Not done in Pokerogue as this is redundant with the check that occurs during sequence 1) + * - Stuff cheeks with no berry + * - Destiny bond failing on consecutive use + * - Quick Guard / Wide Guard failing due to being used as the last move in the turn + * - Ally switch's chance to fail on consecutive use + * - Species specific moves (like hyperspace fury, aura wheel, dark void) being used by a pokemon that is not hoopa (Not applicable to Pokerogue, which permits this) + * - Poltergeist against a target with no item + * - Shell Trap failing due to not being hit by a physical move + * - Aurora veil failing due to no hail + * - Clangorous soul and Fillet Awayfailing due to insufficient HP + * - Upper hand failing due to the target not selecting a priority move + * - (Various moves that fail when used against titans / raid bosses, not listed as pokerogue does not yet implement each) + * + * @see {@link https://www.smogon.com/forums/threads/sword-shield-battle-mechanics-research.3655528/page-54#post-8548957} + */ + private conditionsSeq3: 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 @@ -387,8 +430,10 @@ export abstract class Move implements Localizable { * @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 + * @param checkSequence - The sequence number where the failure check occurs + * @returns `this` for method chaining */ - condition(condition: MoveCondition | MoveConditionFunc, checkSequence: 2 | 3 = 3): this { + condition(condition: MoveCondition | MoveConditionFunc, checkSequence: 2 | 3 | 4 = 4): this { const conditionsArray = checkSequence === 2 ? this.conditionsSeq2 : this.conditions; if (typeof condition === "function") { condition = new MoveCondition(condition); @@ -413,10 +458,10 @@ export abstract class Move implements Localizable { * @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` + * @param conditionSeq - The sequence number where the failure check occurs; default `4` * @returns `this` for method chaining */ - - public restriction(restriction: UserMoveConditionFunc, i18nkey: string, alsoCondition?: boolean): this; + public restriction(restriction: UserMoveConditionFunc, i18nkey: string, alsoCondition?: boolean, conditionSeq?: number): this; /** * Adds a restriction condition to this move. * The move will not be selectable if at least 1 of its restrictions is met. @@ -425,13 +470,27 @@ export abstract class Move implements Localizable { * @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`. + * @param conditionSeq - The sequence number where the failure check occurs; default `4`. Ignored if `alsoCondition` + * is false * @returns `this` for method chaining */ - public restriction(restriction: T, i18nkey?: string, alsoCondition: typeof restriction extends MoveRestriction ? false : boolean = false): this { + public restriction(restriction: T, i18nkey?: string, alsoCondition: typeof restriction extends MoveRestriction ? false : boolean = false, conditionSeq = 4): this { if (typeof restriction === "function") { this.restrictions.push(new MoveRestriction(restriction)); if (alsoCondition) { - this.conditions.push(new MoveCondition((user, _, move) => restriction(user, move))); + let conditionArray: MoveCondition[]; + switch (conditionSeq) { + case 2: + conditionArray = this.conditionsSeq2; + break; + case 3: + conditionArray = this.conditionsSeq3; + break; + default: + conditionArray = this.conditions; + } + + conditionArray.push(new MoveCondition((user, _, move) => !restriction(user, move))); } } else { this.restrictions.push(restriction); @@ -642,8 +701,13 @@ export abstract class Move implements Localizable { * 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 + * + * @remarks + * No {@linkcode condition} is added, as gravity's condition is already checked + * during the first sequence of a move's failure check, and this would be redundant. + * + * @see {@linkcode MoveId.GRAVITY} */ affectedByGravity(): this { this.setFlag(MoveFlags.GRAVITY, true); @@ -681,16 +745,6 @@ export abstract class Move implements Localizable { return this; } - /** - * Sets the {@linkcode MoveFlags.REDIRECT_COUNTER} flag for the calling Move - * @see {@linkcode MoveId.METAL_BURST} - * @returns The {@linkcode Move} that called this function - */ - redirectCounter(): this { - this.setFlag(MoveFlags.REDIRECT_COUNTER, true); - return this; - } - /** * Sets the {@linkcode MoveFlags.REFLECTABLE} flag for the calling Move * @see {@linkcode MoveId.ATTRACT} @@ -772,11 +826,24 @@ export abstract class Move implements Localizable { * @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 + * @param sequence - The sequence number where the condition check occurs, or `-1` to check all; defaults to 3. Pass -1 to check all * @returns boolean: false if any of the apply()'s return false, else true */ - applyConditions(user: Pokemon, target: Pokemon, sequence: number = 3): boolean { - const conditionsArray = sequence === 2 ? this.conditionsSeq2 : this.conditions; + applyConditions(user: Pokemon, target: Pokemon, sequence: -1 | 2 | 3 | 4 = 4): boolean { + let conditionsArray: MoveCondition[]; + switch (sequence) { + case -1: + conditionsArray = [...this.conditionsSeq2, ...this.conditionsSeq3, ...this.conditions]; + case 2: + conditionsArray = this.conditionsSeq2; + break; + case 3: + conditionsArray = this.conditionsSeq3; + break; + case 4: + default: + conditionsArray = this.conditions; + } return conditionsArray.every(cond => cond.apply(user, target, this)); } @@ -1733,28 +1800,77 @@ export class MatchHpAttr extends FixedDamageAttr { }*/ } -type MoveFilter = (move: Move) => boolean; - export class CounterDamageAttr extends FixedDamageAttr { - private moveFilter: MoveFilter; + /** The damage category of counter attacks to process, or `undefined` for either */ + private moveFilter?: MoveDamageCategory; private multiplier: number; - constructor(moveFilter: MoveFilter, multiplier: number) { + /** + * @param multiplier - The damage multiplier to apply to the total damage received + * @param moveFilter - If set, only damage from moves of this category will be counted, otherwise all damage is counted + */ + constructor(multiplier: number, moveFilter?: MoveDamageCategory) { super(0); - this.moveFilter = moveFilter; this.multiplier = multiplier; } apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - const damage = user.turnData.attacksReceived.filter(ar => this.moveFilter(allMoves[ar.move])).reduce((total: number, ar: AttackMoveResult) => total + ar.damage, 0); + let damage = 0; + for (const ar of user.turnData.attacksReceived) { + // TODO: Adjust this for moves with variable damage categories + const category = allMoves[ar.move].category; + if (category === MoveCategory.STATUS || (this.moveFilter && category !== this.moveFilter)) { + continue; + } + damage += ar.damage; + + } (args[0] as NumberHolder).value = toDmgValue(damage * this.multiplier); return true; } +} - getCondition(): MoveConditionFunc { - return (user, target, move) => !!user.turnData.attacksReceived.filter(ar => this.moveFilter(allMoves[ar.move])).length; +/** + * Attribute for counter-like moves to redirect the move to a different target + */ +export class CounterRedirectAttr extends MoveAttr { + declare private moveFilter?: MoveDamageCategory; + constructor(moveFilter? : MoveDamageCategory) { + super(); + if (moveFilter !== undefined) { + this.moveFilter = moveFilter; + } + } + + /** + * Applies the counter redirect attribute to the move + * @param user - The user of the counter move + * @param target - The target of the move (unused) + * @param move - The move being used + * @param args - args[0] holds the battler index of the target that the move will be redirected to + */ + override apply(user: Pokemon, target: Pokemon | null, move: Move, args: [NumberHolder, ...any[]]): boolean { + const desiredTarget = getCounterAttackTarget(user, this.moveFilter); + if (desiredTarget !== null && desiredTarget !== BattlerIndex.ATTACKER) { + // check if the target is still alive + if ( + globalScene.currentBattle.double && + !globalScene.getField()[desiredTarget]?.isActive + ) { + const targetField = desiredTarget >= BattlerIndex.ENEMY ? globalScene.getEnemyField() : globalScene.getPlayerField(); + args[0].value = targetField.find(p => p.hp > 0)?.getBattlerIndex() ?? BattlerIndex.ATTACKER; + } else { + args[0].value = desiredTarget; + } + return true; + } + return false; + } + + override canApply(user: Pokemon, target: Pokemon, move: Move, args: [NumberHolder, ...any[]]): boolean { + return args[0].value === BattlerIndex.ATTACKER; } } @@ -3762,7 +3878,7 @@ export class CutHpStatStageBoostAttr extends StatStageChangeAttr { } getCondition(): MoveConditionFunc { - return (user, _target, _move) => user.getHpRatio() > 1 / this.cutRatio && this.stats.some(s => user.getStatStage(s) < 6); + return user => user.getHpRatio() > 1 / this.cutRatio && this.stats.some(s => user.getStatStage(s) < 6); } } @@ -7961,31 +8077,6 @@ export class StatusIfBoostedAttr extends MoveEffectAttr { } } -/** - * Attribute to fail move usage unless all of the user's other moves have been used at least once. - * Used by {@linkcode MoveId.LAST_RESORT}. - */ -export class LastResortAttr extends MoveAttr { - // TODO: Verify behavior as Bulbapedia page is _extremely_ poorly documented - getCondition(): MoveConditionFunc { - return (user: Pokemon, _target: Pokemon, move: Move) => { - const otherMovesInMoveset = new Set(user.getMoveset().map(m => m.moveId)); - if (!otherMovesInMoveset.delete(move.id) || !otherMovesInMoveset.size) { - return false; // Last resort fails if used when not in user's moveset or no other moves exist - } - - const movesInHistory = new Set( - user.getMoveHistory() - .filter(m => !isVirtual(m.useMode)) // Last resort ignores virtual moves - .map(m => m.move) - ); - - // Since `Set.intersection()` is only present in ESNext, we have to do this to check inclusion - return [...otherMovesInMoveset].every(m => movesInHistory.has(m)) - }; - } -} - export class VariableTargetAttr extends MoveAttr { private targetChangeFunc: (user: Pokemon, target: Pokemon, move: Move) => number; @@ -8237,6 +8328,7 @@ const MoveAttrs = Object.freeze({ TargetHalfHpDamageAttr, MatchHpAttr, CounterDamageAttr, + CounterRedirectAttr, LevelDamageAttr, RandomLevelDamageAttr, ModifiedDamageAttr, @@ -8427,7 +8519,6 @@ const MoveAttrs = Object.freeze({ DestinyBondAttr, AddBattlerTagIfBoostedAttr, StatusIfBoostedAttr, - LastResortAttr, VariableTargetAttr, AfterYouAttr, ForceLastAttr, @@ -8628,7 +8719,8 @@ export function initMoves() { new AttackMove(MoveId.LOW_KICK, PokemonType.FIGHTING, MoveCategory.PHYSICAL, -1, 100, 20, -1, 0, 1) .attr(WeightPowerAttr), new AttackMove(MoveId.COUNTER, PokemonType.FIGHTING, MoveCategory.PHYSICAL, -1, 100, 20, -1, -5, 1) - .attr(CounterDamageAttr, (move: Move) => move.category === MoveCategory.PHYSICAL, 2) + .attr(CounterDamageAttr, 2, MoveCategory.PHYSICAL) + .condition(counterAttackCondition_Physical, 3) .target(MoveTarget.ATTACKER), new AttackMove(MoveId.SEISMIC_TOSS, PokemonType.FIGHTING, MoveCategory.PHYSICAL, -1, 100, 20, -1, 0, 1) .attr(LevelDamageAttr), @@ -8728,7 +8820,8 @@ export function initMoves() { .partial(), // No effect implemented new SelfStatusMove(MoveId.TELEPORT, PokemonType.PSYCHIC, -1, 20, -1, -6, 1) .attr(ForceSwitchOutAttr, true) - .hidesUser(), + .hidesUser() + .condition(failTeleportCondition, 3), new AttackMove(MoveId.NIGHT_SHADE, PokemonType.GHOST, MoveCategory.SPECIAL, -1, 100, 15, -1, 0, 1) .attr(LevelDamageAttr), new StatusMove(MoveId.MIMIC, PokemonType.NORMAL, -1, 10, -1, 0, 1) @@ -8783,7 +8876,7 @@ export function initMoves() { new AttackMove(MoveId.SELF_DESTRUCT, PokemonType.NORMAL, MoveCategory.PHYSICAL, 200, 100, 5, -1, 0, 1) .attr(SacrificialAttr) .makesContact(false) - .condition(failIfDampCondition) + .condition(failIfDampCondition, 3) .target(MoveTarget.ALL_NEAR_OTHERS), new AttackMove(MoveId.EGG_BOMB, PokemonType.NORMAL, MoveCategory.PHYSICAL, 100, 75, 10, -1, 0, 1) .makesContact(false) @@ -8884,7 +8977,7 @@ export function initMoves() { new AttackMove(MoveId.CRABHAMMER, PokemonType.WATER, MoveCategory.PHYSICAL, 100, 90, 10, -1, 0, 1) .attr(HighCritAttr), new AttackMove(MoveId.EXPLOSION, PokemonType.NORMAL, MoveCategory.PHYSICAL, 250, 100, 5, -1, 0, 1) - .condition(failIfDampCondition) + .condition(failIfDampCondition, 3) .attr(SacrificialAttr) .makesContact(false) .target(MoveTarget.ALL_NEAR_OTHERS), @@ -8950,7 +9043,7 @@ export function initMoves() { new AttackMove(MoveId.SNORE, PokemonType.NORMAL, MoveCategory.SPECIAL, 50, 100, 15, 30, 0, 2) .attr(BypassSleepAttr) .attr(FlinchAttr) - .condition(userSleptOrComatoseCondition) + .condition(userSleptOrComatoseCondition, 3) .soundBased(), new StatusMove(MoveId.CURSE, PokemonType.GHOST, -1, 10, -1, 0, 2) .attr(CurseAttr) @@ -8982,7 +9075,7 @@ export function initMoves() { .target(MoveTarget.ALL_NEAR_ENEMIES), new SelfStatusMove(MoveId.PROTECT, PokemonType.NORMAL, -1, 10, -1, 4, 2) .attr(ProtectAttr) - .condition(failIfLastCondition), + .condition(failIfLastCondition, 3), new AttackMove(MoveId.MACH_PUNCH, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 40, 100, 30, -1, 1, 2) .punchingMove(), new StatusMove(MoveId.SCARY_FACE, PokemonType.NORMAL, 100, 10, -1, 0, 2) @@ -9042,7 +9135,7 @@ export function initMoves() { .target(MoveTarget.ALL_NEAR_ENEMIES), new SelfStatusMove(MoveId.DETECT, PokemonType.FIGHTING, -1, 5, -1, 4, 2) .attr(ProtectAttr) - .condition(failIfLastCondition), + .condition(failIfLastCondition, 3), new AttackMove(MoveId.BONE_RUSH, PokemonType.GROUND, MoveCategory.PHYSICAL, 25, 90, 10, -1, 0, 2) .attr(MultiHitAttr) .makesContact(false), @@ -9064,7 +9157,7 @@ export function initMoves() { .triageMove(), new SelfStatusMove(MoveId.ENDURE, PokemonType.NORMAL, -1, 10, -1, 4, 2) .attr(ProtectAttr, BattlerTagType.ENDURING) - .condition(failIfLastCondition), + .condition(failIfLastCondition, 3), new StatusMove(MoveId.CHARM, PokemonType.FAIRY, 100, 20, -1, 0, 2) .attr(StatStageChangeAttr, [ Stat.ATK ], -2) .reflectable(), @@ -9099,7 +9192,7 @@ export function initMoves() { new SelfStatusMove(MoveId.SLEEP_TALK, PokemonType.NORMAL, -1, 10, -1, 0, 2) .attr(BypassSleepAttr) .attr(RandomMovesetMoveAttr, invalidSleepTalkMoves, false) - .condition(userSleptOrComatoseCondition) + .condition(userSleptOrComatoseCondition, 3) .target(MoveTarget.NEAR_ENEMY), new StatusMove(MoveId.HEAL_BELL, PokemonType.NORMAL, -1, 5, -1, 0, 2) .attr(PartyStatusCureAttr, i18next.t("moveTriggers:bellChimed"), AbilityId.SOUNDPROOF) @@ -9202,7 +9295,8 @@ export function initMoves() { .attr(StatStageChangeAttr, [ Stat.DEF ], -1) .bitingMove(), new AttackMove(MoveId.MIRROR_COAT, PokemonType.PSYCHIC, MoveCategory.SPECIAL, -1, 100, 20, -1, -5, 2) - .attr(CounterDamageAttr, (move: Move) => move.category === MoveCategory.SPECIAL, 2) + .attr(CounterDamageAttr, 2, MoveCategory.SPECIAL) + .condition(counterAttackCondition_Special, 3) .target(MoveTarget.ATTACKER), new StatusMove(MoveId.PSYCH_UP, PokemonType.NORMAL, -1, 10, -1, 0, 2) .ignoresSubstitute() @@ -9231,22 +9325,22 @@ export function initMoves() { .makesContact(false), new AttackMove(MoveId.FAKE_OUT, PokemonType.NORMAL, MoveCategory.PHYSICAL, 40, 100, 10, 100, 3, 3) .attr(FlinchAttr) - .condition(new FirstMoveCondition()), + .condition(new FirstMoveCondition(), 3), new AttackMove(MoveId.UPROAR, PokemonType.NORMAL, MoveCategory.SPECIAL, 90, 100, 10, -1, 0, 3) .soundBased() .target(MoveTarget.RANDOM_NEAR_ENEMY) .partial(), // Does not lock the user, does not stop Pokemon from sleeping // Likely can make use of FrenzyAttr and an ArenaTag (just without the FrenzyMissFunc) new SelfStatusMove(MoveId.STOCKPILE, PokemonType.NORMAL, -1, 20, -1, 0, 3) - .condition(user => (user.getTag(StockpilingTag)?.stockpiledCount ?? 0) < 3) + .condition(user => (user.getTag(StockpilingTag)?.stockpiledCount ?? 0) < 3, 3) .attr(AddBattlerTagAttr, BattlerTagType.STOCKPILING, true), new AttackMove(MoveId.SPIT_UP, PokemonType.NORMAL, MoveCategory.SPECIAL, -1, 100, 10, -1, 0, 3) + .condition(hasStockpileStacksCondition, 3) .attr(SpitUpPowerAttr, 100) - .condition(hasStockpileStacksCondition) .attr(RemoveBattlerTagAttr, [ BattlerTagType.STOCKPILING ], true), new SelfStatusMove(MoveId.SWALLOW, PokemonType.NORMAL, -1, 10, -1, 0, 3) + .condition(hasStockpileStacksCondition, 3) .attr(SwallowHealAttr) - .condition(hasStockpileStacksCondition) .attr(RemoveBattlerTagAttr, [ BattlerTagType.STOCKPILING ], true) .triageMove() // TODO: Verify if using Swallow at full HP still consumes stacks or not @@ -9286,7 +9380,8 @@ export function initMoves() { .attr(MovePowerMultiplierAttr, (user, target, move) => target.status?.effect === StatusEffect.PARALYSIS ? 2 : 1) .attr(HealStatusEffectAttr, true, StatusEffect.PARALYSIS), new SelfStatusMove(MoveId.FOLLOW_ME, PokemonType.NORMAL, -1, 20, -1, 2, 3) - .attr(AddBattlerTagAttr, BattlerTagType.CENTER_OF_ATTENTION, true), + .attr(AddBattlerTagAttr, BattlerTagType.CENTER_OF_ATTENTION, true) + .condition(failIfSingleBattle, 3), new StatusMove(MoveId.NATURE_POWER, PokemonType.NORMAL, -1, 20, -1, 0, 3) .attr(NaturePowerAttr), new SelfStatusMove(MoveId.CHARGE, PokemonType.ELECTRIC, -1, 20, -1, 0, 3) @@ -9323,7 +9418,7 @@ export function initMoves() { .attr(StatStageChangeAttr, [ Stat.ATK, Stat.DEF ], -1, true), new SelfStatusMove(MoveId.MAGIC_COAT, PokemonType.PSYCHIC, -1, 15, -1, 4, 3) .attr(AddBattlerTagAttr, BattlerTagType.MAGIC_COAT, true, true, 0) - .condition(failIfLastCondition) + .condition(failIfLastCondition, 3) // Interactions with stomping tantrum, instruct, and other moves that // rely on move history // Also will not reflect roar / whirlwind if the target has ForceSwitchOutImmunityAbAttr @@ -9622,8 +9717,8 @@ export function initMoves() { .attr(AcupressureStatStageChangeAttr) .target(MoveTarget.USER_OR_NEAR_ALLY), new AttackMove(MoveId.METAL_BURST, PokemonType.STEEL, MoveCategory.PHYSICAL, -1, 100, 10, -1, 0, 4) - .attr(CounterDamageAttr, (move: Move) => (move.category === MoveCategory.PHYSICAL || move.category === MoveCategory.SPECIAL), 1.5) - .redirectCounter() + .attr(CounterDamageAttr, 1.5) + .condition(counterAttackCondition_Both, 3) .makesContact(false) .target(MoveTarget.ATTACKER), new AttackMove(MoveId.U_TURN, PokemonType.BUG, MoveCategory.PHYSICAL, 70, 100, 20, -1, 0, 4) @@ -9679,21 +9774,15 @@ export function initMoves() { .makesContact(true) .attr(PunishmentPowerAttr), new AttackMove(MoveId.LAST_RESORT, PokemonType.NORMAL, MoveCategory.PHYSICAL, 140, 100, 5, -1, 0, 4) - .attr(LastResortAttr) - .edgeCase(), // May or may not need to ignore remotely called moves depending on how it works + .condition(lastResortCondition, 3) + .edgeCase(), // When a move is overwritten and later relearned, Last Resort's tracking of it should be reset 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) => { - const turnCommand = globalScene.currentBattle.turnCommands[target.getBattlerIndex()]; - if (!turnCommand || !turnCommand.move) { - return false; - } - return (turnCommand.command === Command.FIGHT && !target.turnData.acted && allMoves[turnCommand.move.move].category !== MoveCategory.STATUS); - }), + .condition(failIfTargetNotAttackingCondition, 3), new StatusMove(MoveId.TOXIC_SPIKES, PokemonType.POISON, -1, 20, -1, 0, 4) .attr(AddArenaTrapTagAttr, ArenaTagType.TOXIC_SPIKES) .target(MoveTarget.ENEMY_SIDE) @@ -9705,7 +9794,7 @@ 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 => [ 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)), 3) .affectedByGravity(), new AttackMove(MoveId.FLARE_BLITZ, PokemonType.FIRE, MoveCategory.PHYSICAL, 120, 100, 15, 10, 0, 4) .attr(RecoilAttr, false, 0.33) @@ -9915,7 +10004,7 @@ export function initMoves() { new StatusMove(MoveId.WIDE_GUARD, PokemonType.ROCK, -1, 10, -1, 3, 5) .target(MoveTarget.USER_SIDE) .attr(AddArenaTagAttr, ArenaTagType.WIDE_GUARD, 1, true, true) - .condition(failIfLastCondition), + .condition(failIfLastCondition, 3), 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) @@ -9945,6 +10034,7 @@ export function initMoves() { .condition((_user, target, _move) => target.getTag(BattlerTagType.INGRAIN) == null && target.getTag(BattlerTagType.IGNORE_FLYING) == null) .attr(AddBattlerTagAttr, BattlerTagType.TELEKINESIS, false, true, 3) .attr(AddBattlerTagAttr, BattlerTagType.FLOATING, false, true, 3) + .affectedByGravity() .reflectable(), new StatusMove(MoveId.MAGIC_ROOM, PokemonType.PSYCHIC, -1, 10, -1, 0, 5) .ignoresProtect() @@ -10024,7 +10114,7 @@ export function initMoves() { new StatusMove(MoveId.QUICK_GUARD, PokemonType.FIGHTING, -1, 15, -1, 3, 5) .target(MoveTarget.USER_SIDE) .attr(AddArenaTagAttr, ArenaTagType.QUICK_GUARD, 1, true, true) - .condition(failIfLastCondition), + .condition(failIfLastCondition, 3), new SelfStatusMove(MoveId.ALLY_SWITCH, PokemonType.PSYCHIC, -1, 15, -1, 2, 5) .ignoresProtect() .unimplemented(), @@ -10239,8 +10329,8 @@ export function initMoves() { 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), + .condition(new FirstMoveCondition(), 3) + .condition(failIfLastCondition, 3), new AttackMove(MoveId.BELCH, PokemonType.POISON, MoveCategory.SPECIAL, 120, 90, 10, -1, 0, 6) .restriction(user => !user.battleData.hasEatenBerry, "battle:moveDisabledBelch", true), new StatusMove(MoveId.ROTOTILLER, PokemonType.GROUND, -1, 10, -1, 0, 6) @@ -10302,7 +10392,7 @@ export function initMoves() { new StatusMove(MoveId.CRAFTY_SHIELD, PokemonType.FAIRY, -1, 10, -1, 3, 6) .target(MoveTarget.USER_SIDE) .attr(AddArenaTagAttr, ArenaTagType.CRAFTY_SHIELD, 1, true, true) - .condition(failIfLastCondition), + .condition(failIfLastCondition, 3), new StatusMove(MoveId.FLOWER_SHIELD, PokemonType.FAIRY, -1, 10, -1, 0, 6) .target(MoveTarget.ALL) .attr(StatStageChangeAttr, [ Stat.DEF ], 1, false, { condition: (user, target, move) => target.getTypes().includes(PokemonType.GRASS) && !target.getTag(SemiInvulnerableTag) }), @@ -10330,7 +10420,7 @@ export function initMoves() { .attr(AddArenaTagAttr, ArenaTagType.FAIRY_LOCK, 2, true), new SelfStatusMove(MoveId.KINGS_SHIELD, PokemonType.STEEL, -1, 10, -1, 4, 6) .attr(ProtectAttr, BattlerTagType.KINGS_SHIELD) - .condition(failIfLastCondition), + .condition(failIfLastCondition, 3), new StatusMove(MoveId.PLAY_NICE, PokemonType.NORMAL, -1, 20, -1, 0, 6) .attr(StatStageChangeAttr, [ Stat.ATK ], -1) .ignoresSubstitute() @@ -10358,7 +10448,7 @@ export function initMoves() { .attr(StatStageChangeAttr, [ Stat.SPATK ], -1), new SelfStatusMove(MoveId.SPIKY_SHIELD, PokemonType.GRASS, -1, 10, -1, 4, 6) .attr(ProtectAttr, BattlerTagType.SPIKY_SHIELD) - .condition(failIfLastCondition), + .condition(failIfLastCondition, 3), new StatusMove(MoveId.AROMATIC_MIST, PokemonType.FAIRY, -1, 20, -1, 0, 6) .attr(StatStageChangeAttr, [ Stat.SPDEF ], 1) .ignoresSubstitute() @@ -10526,10 +10616,10 @@ export function initMoves() { .attr(SandHealAttr) .triageMove(), new AttackMove(MoveId.FIRST_IMPRESSION, PokemonType.BUG, MoveCategory.PHYSICAL, 90, 100, 10, -1, 2, 7) - .condition(new FirstMoveCondition()), + .condition(new FirstMoveCondition(), 3), new SelfStatusMove(MoveId.BANEFUL_BUNKER, PokemonType.POISON, -1, 10, -1, 4, 7) .attr(ProtectAttr, BattlerTagType.BANEFUL_BUNKER) - .condition(failIfLastCondition), + .condition(failIfLastCondition, 3), new AttackMove(MoveId.SPIRIT_SHACKLE, PokemonType.GHOST, MoveCategory.PHYSICAL, 80, 100, 10, 100, 0, 7) .attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, false, 1, 1, true) .makesContact(false), @@ -10662,7 +10752,16 @@ export function initMoves() { new AttackMove(MoveId.BRUTAL_SWING, PokemonType.DARK, MoveCategory.PHYSICAL, 60, 100, 20, -1, 0, 7) .target(MoveTarget.ALL_NEAR_OTHERS), new StatusMove(MoveId.AURORA_VEIL, PokemonType.ICE, -1, 20, -1, 0, 7) - .condition((user, target, move) => (globalScene.arena.weather?.weatherType === WeatherType.HAIL || globalScene.arena.weather?.weatherType === WeatherType.SNOW) && !globalScene.arena.weather?.isEffectSuppressed()) + .condition( + () => { + const weather = globalScene.arena.weather; + if (isNullOrUndefined(weather) || weather.isEffectSuppressed()) { + return false; + } + return weather.weatherType === WeatherType.HAIL || weather.weatherType === WeatherType.SNOW; + }, + 3 + ) .attr(AddArenaTagAttr, ArenaTagType.AURORA_VEIL, 5, true) .target(MoveTarget.USER_SIDE), /* Unused */ @@ -10699,7 +10798,10 @@ export function initMoves() { .attr(AddBattlerTagHeaderAttr, BattlerTagType.SHELL_TRAP) .target(MoveTarget.ALL_NEAR_ENEMIES) // Fails if the user was not hit by a physical attack during the turn - .condition((user, target, move) => user.getTag(ShellTrapTag)?.activated === true), + .condition( + user => user.getTag(ShellTrapTag)?.activated === true, + 3 + ), new AttackMove(MoveId.FLEUR_CANNON, PokemonType.FAIRY, MoveCategory.SPECIAL, 130, 90, 5, -1, 0, 7) .attr(StatStageChangeAttr, [ Stat.SPATK ], -2, true), new AttackMove(MoveId.PSYCHIC_FANGS, PokemonType.PSYCHIC, MoveCategory.PHYSICAL, 85, 100, 10, -1, 0, 7) @@ -10743,7 +10845,7 @@ export function initMoves() { .edgeCase(), // I assume it's because it needs thunderbolt and pikachu in a cap /* End Unused */ new AttackMove(MoveId.MIND_BLOWN, PokemonType.FIRE, MoveCategory.SPECIAL, 150, 100, 5, -1, 0, 7) - .condition(failIfDampCondition) + .condition(failIfDampCondition, 3) .attr(HalfSacrificialAttr) .target(MoveTarget.ALL_NEAR_OTHERS), new AttackMove(MoveId.PLASMA_FISTS, PokemonType.ELECTRIC, MoveCategory.PHYSICAL, 100, 100, 15, -1, 0, 7) @@ -10934,7 +11036,8 @@ export function initMoves() { new SelfStatusMove(MoveId.CLANGOROUS_SOUL, PokemonType.DRAGON, 100, 5, -1, 0, 8) .attr(CutHpStatStageBoostAttr, [ Stat.ATK, Stat.DEF, Stat.SPATK, Stat.SPDEF, Stat.SPD ], 1, 3) .soundBased() - .danceMove(), + .danceMove() + .condition(new FailIfInsufficientHpCondition(3), 3), new AttackMove(MoveId.BODY_PRESS, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 80, 100, 10, -1, 0, 8) .attr(DefAtkAttr), new StatusMove(MoveId.DECORATE, PokemonType.FAIRY, -1, 15, -1, 0, 8) @@ -10981,7 +11084,7 @@ export function initMoves() { .triageMove(), new SelfStatusMove(MoveId.OBSTRUCT, PokemonType.DARK, 100, 10, -1, 4, 8) .attr(ProtectAttr, BattlerTagType.OBSTRUCT) - .condition(failIfLastCondition), + .condition(failIfLastCondition, 3), new AttackMove(MoveId.FALSE_SURRENDER, PokemonType.DARK, MoveCategory.PHYSICAL, 80, -1, 10, -1, 0, 8), new AttackMove(MoveId.METEOR_ASSAULT, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 150, 100, 5, -1, 0, 8) .attr(RechargeAttr) @@ -10995,7 +11098,7 @@ export function initMoves() { .attr(VariableTargetAttr, (user, target, move) => globalScene.arena.getTerrainType() === TerrainType.PSYCHIC && user.isGrounded() ? MoveTarget.ALL_NEAR_ENEMIES : MoveTarget.NEAR_OTHER), new AttackMove(MoveId.STEEL_ROLLER, PokemonType.STEEL, MoveCategory.PHYSICAL, 130, 100, 5, -1, 0, 8) .attr(ClearTerrainAttr) - .condition((user, target, move) => !!globalScene.arena.terrain), + .condition(() => !!globalScene.arena.terrain, 3), new AttackMove(MoveId.SCALE_SHOT, PokemonType.DRAGON, MoveCategory.PHYSICAL, 25, 90, 20, -1, 0, 8) .attr(StatStageChangeAttr, [ Stat.SPD ], 1, true, { lastHitOnly: true }) .attr(StatStageChangeAttr, [ Stat.DEF ], -1, true, { lastHitOnly: true }) @@ -11012,7 +11115,7 @@ export function initMoves() { .attr(SacrificialAttr) .target(MoveTarget.ALL_NEAR_OTHERS) .attr(MovePowerMultiplierAttr, (user, target, move) => globalScene.arena.getTerrainType() === TerrainType.MISTY && user.isGrounded() ? 1.5 : 1) - .condition(failIfDampCondition) + .condition(failIfDampCondition, 3) .makesContact(false), new AttackMove(MoveId.GRASSY_GLIDE, PokemonType.GRASS, MoveCategory.PHYSICAL, 55, 100, 20, -1, 0, 8) .attr(IncrementMovePriorityAttr, (user, target, move) => globalScene.arena.getTerrainType() === TerrainType.GRASSY && user.isGrounded()), @@ -11030,7 +11133,7 @@ export function initMoves() { new AttackMove(MoveId.LASH_OUT, PokemonType.DARK, MoveCategory.PHYSICAL, 75, 100, 5, -1, 0, 8) .attr(MovePowerMultiplierAttr, (user, _target, _move) => user.turnData.statStagesDecreased ? 2 : 1), new AttackMove(MoveId.POLTERGEIST, PokemonType.GHOST, MoveCategory.PHYSICAL, 110, 90, 5, -1, 0, 8) - .condition(failIfNoTargetHeldItemsCondition) + .condition(failIfNoTargetHeldItemsCondition, 3) .attr(PreMoveMessageAttr, attackedByItemMessageFunc) .makesContact(false), new StatusMove(MoveId.CORROSIVE_GAS, PokemonType.POISON, 100, 40, -1, 0, 8) @@ -11274,7 +11377,7 @@ export function initMoves() { .attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], -1, true, { condition: (user, target, move) => user.isTerastallized && user.isOfType(PokemonType.STELLAR) }), new SelfStatusMove(MoveId.SILK_TRAP, PokemonType.BUG, -1, 10, -1, 4, 9) .attr(ProtectAttr, BattlerTagType.SILK_TRAP) - .condition(failIfLastCondition), + .condition(failIfLastCondition, 3), new AttackMove(MoveId.AXE_KICK, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 120, 90, 10, 30, 0, 9) .attr(MissEffectAttr, crashDamageFunc) .attr(NoEffectAttr, crashDamageFunc) @@ -11303,10 +11406,7 @@ export function initMoves() { .attr(ClearTerrainAttr), new AttackMove(MoveId.GLAIVE_RUSH, PokemonType.DRAGON, MoveCategory.PHYSICAL, 120, 100, 5, -1, 0, 9) .attr(AddBattlerTagAttr, BattlerTagType.ALWAYS_GET_HIT, true, false, 0, 0, true) - .attr(AddBattlerTagAttr, BattlerTagType.RECEIVE_DOUBLE_DAMAGE, true, false, 0, 0, true) - .condition((user, target, move) => { - return !(target.getTag(BattlerTagType.PROTECTED)?.tagType === "PROTECTED" || globalScene.arena.getTag(ArenaTagType.MAT_BLOCK)?.tagType === "MAT_BLOCK"); - }), + .attr(AddBattlerTagAttr, BattlerTagType.RECEIVE_DOUBLE_DAMAGE, true, false, 0, 0, true), new StatusMove(MoveId.REVIVAL_BLESSING, PokemonType.NORMAL, -1, 1, -1, 0, 9) .triageMove() .attr(RevivalBlessingAttr) @@ -11336,7 +11436,8 @@ export function initMoves() { new StatusMove(MoveId.DOODLE, PokemonType.NORMAL, 100, 10, -1, 0, 9) .attr(AbilityCopyAttr, true), new SelfStatusMove(MoveId.FILLET_AWAY, PokemonType.NORMAL, -1, 10, -1, 0, 9) - .attr(CutHpStatStageBoostAttr, [ Stat.ATK, Stat.SPATK, Stat.SPD ], 2, 2), + .attr(CutHpStatStageBoostAttr, [ Stat.ATK, Stat.SPATK, Stat.SPD ], 2, 2) + .condition(new FailIfInsufficientHpCondition(2), 3), new AttackMove(MoveId.KOWTOW_CLEAVE, PokemonType.DARK, MoveCategory.PHYSICAL, 85, -1, 10, -1, 0, 9) .slicingMove(), new AttackMove(MoveId.FLOWER_TRICK, PokemonType.GRASS, MoveCategory.PHYSICAL, 70, -1, 10, -1, 0, 9) @@ -11425,8 +11526,8 @@ export function initMoves() { .makesContact(false) .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() + .attr(CounterDamageAttr, 1.5) + .condition(counterAttackCondition_Both, 3) .target(MoveTarget.ATTACKER), new AttackMove(MoveId.AQUA_CUTTER, PokemonType.WATER, MoveCategory.PHYSICAL, 70, 100, 20, -1, 0, 9) .attr(HighCritAttr) @@ -11477,15 +11578,9 @@ export function initMoves() { .attr(DoublePowerChanceAttr, 30), new SelfStatusMove(MoveId.BURNING_BULWARK, PokemonType.FIRE, -1, 10, -1, 4, 9) .attr(ProtectAttr, BattlerTagType.BURNING_BULWARK) - .condition(failIfLastCondition), + .condition(failIfLastCondition, 3), new AttackMove(MoveId.THUNDERCLAP, PokemonType.ELECTRIC, MoveCategory.SPECIAL, 70, 100, 5, -1, 1, 9) - .condition((user, target, move) => { - const turnCommand = globalScene.currentBattle.turnCommands[target.getBattlerIndex()]; - if (!turnCommand || !turnCommand.move) { - return false; - } - return (turnCommand.command === Command.FIGHT && !target.turnData.acted && allMoves[turnCommand.move.move].category !== MoveCategory.STATUS); - }), + .condition(failIfTargetNotAttackingCondition, 3), new AttackMove(MoveId.MIGHTY_CLEAVE, PokemonType.ROCK, MoveCategory.PHYSICAL, 95, 100, 5, -1, 0, 9) .slicingMove() .ignoresProtect(), @@ -11515,7 +11610,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(UpperHandCondition), + .condition(UpperHandCondition, 3), new AttackMove(MoveId.MALIGNANT_CHAIN, PokemonType.POISON, MoveCategory.SPECIAL, 100, 100, 5, 50, 0, 9) .attr(StatusEffectAttr, StatusEffect.TOXIC) ); diff --git a/src/enums/move-category.ts b/src/enums/move-category.ts index f0a171b2fea..96875a46b0f 100644 --- a/src/enums/move-category.ts +++ b/src/enums/move-category.ts @@ -3,3 +3,6 @@ export enum MoveCategory { SPECIAL, STATUS, } + +/** Type of damage categories */ +export type MoveDamageCategory = Exclude; \ No newline at end of file diff --git a/src/enums/move-flags.ts b/src/enums/move-flags.ts index b03fa4144b6..d602d48a4b3 100644 --- a/src/enums/move-flags.ts +++ b/src/enums/move-flags.ts @@ -47,10 +47,8 @@ export enum MoveFlags { CHECK_ALL_HITS = 1 << 16, /** Indicates a move is able to bypass its target's Substitute (if the target has one) */ IGNORE_SUBSTITUTE = 1 << 17, - /** 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 << 18, /** Indicates a move that fails when {@link https://bulbapedia.bulbagarden.net/wiki/Gravity_(move) | Gravity} is in effect */ - GRAVITY = 1 << 20, + GRAVITY = 1 << 19, } diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 5d7d959b992..bcff1b9d28a 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -731,7 +731,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { abstract getFieldIndex(): number; - abstract getBattlerIndex(): BattlerIndex; + abstract getBattlerIndex(): Exclude; /** * Load all assets needed for this Pokemon's use in battle @@ -3140,7 +3140,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { */ 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, true) ?? [false, ""]; } /** Show this Pokémon's info panel */ @@ -5804,7 +5804,7 @@ export class PlayerPokemon extends Pokemon { return globalScene.getPlayerField().indexOf(this); } - getBattlerIndex(): BattlerIndex { + getBattlerIndex(): Exclude { return this.getFieldIndex(); } @@ -6941,7 +6941,7 @@ export class EnemyPokemon extends Pokemon { return globalScene.getEnemyField().indexOf(this); } - public getBattlerIndex(): BattlerIndex { + public getBattlerIndex(): Exclude { return BattlerIndex.ENEMY + this.getFieldIndex(); } diff --git a/src/phases/command-phase.ts b/src/phases/command-phase.ts index 2c1783711dd..df85fa6358e 100644 --- a/src/phases/command-phase.ts +++ b/src/phases/command-phase.ts @@ -255,7 +255,7 @@ export class CommandPhase extends FieldPhase { // Ternary here ensures we don't compute struggle conditions unless necessary const useStruggle = canUse ? false - : cursor > -1 && !playerPokemon.getMoveset().some(m => m.isUsable(playerPokemon)[0]); + : cursor > -1 && !playerPokemon.getMoveset().some(m => m.isUsable(playerPokemon, ignorePP, true)[0]); if (!canUse && !useStruggle) { console.error("Cannot use move:", reason); diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index e5d733b2f10..bab78a9b39b 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -29,7 +29,7 @@ import { applyMoveAttrs } from "#moves/apply-attrs"; import { frenzyMissFunc } from "#moves/move-utils"; import type { PokemonMove } from "#moves/pokemon-move"; // biome-ignore lint/correctness/noUnusedImports: Used in a tsdoc comment -import type { PreUseInterruptAttr } from "#types/move-types"; +import type { Move, PreUseInterruptAttr } from "#types/move-types"; import { applyChallenges } from "#utils/challenge-utils"; import { BooleanHolder, NumberHolder } from "#utils/common"; import { enumValueToKey } from "#utils/enums"; @@ -198,6 +198,7 @@ export class MovePhase extends PokemonPhase { * - 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 + * - Failure due to primal weather * - (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 * @@ -206,17 +207,26 @@ export class MovePhase extends PokemonPhase { protected secondFailureCheck(): boolean { const move = this.move.getMove(); const user = this.pokemon; + let failedText: string | undefined; + const arena = globalScene.arena; + if (!move.applyConditions(user, this.getActiveTargetPokemon()[0], 2)) { + // TODO: Make pollen puff failing from heal block use its own message 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; + } else if (arena.isMoveWeatherCancelled(user, move)) { + failedText = getWeatherBlockMessage(globalScene.arena.getWeatherType()); + this.failed = true; + } else { + // 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; + } + if (this.failed) { + this.showFailedText(failedText); } - // 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; } @@ -226,10 +236,13 @@ export class MovePhase extends PokemonPhase { * @returns Whether the move failed * * @remarks - * - Conditional attributes of the move + * - Anything in {@linkcode Move.conditionsSeq3} * - Weather blocking the move * - Terrain blocking the move * - Queenly Majesty / Dazzling + * - Damp (which is handled by move conditions in pokerogue rather than the ability, like queenly majesty / dazzling) + * + * The rest of the failure conditions are marked as sequence 4 and happen in the move effect phase. */ protected thirdFailureCheck(): boolean { /** @@ -241,9 +254,8 @@ export class MovePhase extends PokemonPhase { 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; + let failed = failsConditions || failedDueToTerrain; // Apply queenly majesty / dazzling if (!failed) { @@ -261,7 +273,7 @@ export class MovePhase extends PokemonPhase { } if (failed) { - this.failMove(true, failedDueToWeather, failedDueToTerrain); + this.failMove(failedDueToTerrain); return true; } @@ -305,17 +317,10 @@ export class MovePhase extends PokemonPhase { return; } - // 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. + // For now, this comment works as a placeholder until we rework how called moves are handled + // For correct alignment with mainline, this SHOULD go here, and this phase SHOULD rewrite its own move - // 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(); @@ -346,29 +351,36 @@ export class MovePhase extends PokemonPhase { // 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 + const move = this.move.getMove(); + + // 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; + } + + // Stance change happens now if the move is about to be executed and is not a charging move if (!charging) { + this.usePP(); globalScene.triggerPokemonFormChange(this.pokemon, SpeciesFormChangePreMoveTrigger); } - if (this.secondFailureCheck()) { - this.showFailedText(); + // At this point, if the target index has not moved on from attacker, the move must fail + if (this.targets[0] === BattlerIndex.ATTACKER) { + this.fail(); + } + if (this.targets[0] === BattlerIndex.ATTACKER || this.secondFailureCheck()) { this.handlePreMoveFailures(); this.end(); return; } - if (!(this.failed || this.cancelled)) { - this.resolveFinalPreMoveCancellationChecks(); + if (this.resolveFinalPreMoveCancellationChecks()) { + this.end(); } - // Cancel, charge or use the move as applicable. - if (this.cancelled || this.failed) { - this.handlePreMoveFailures(); - } else if (charging) { + if (charging) { this.chargeMove(); } else { this.useMove(); @@ -377,20 +389,25 @@ export class MovePhase extends PokemonPhase { this.end(); } - /** Check for cancellation edge cases - no targets remaining */ - protected resolveFinalPreMoveCancellationChecks(): void { + /** + * Check for cancellation edge cases - no targets remaining or the battler index being targeted is still the attacker + * @returns Whether the move fails + */ + protected resolveFinalPreMoveCancellationChecks(): boolean { const targets = this.getActiveTargetPokemon(); const moveQueue = this.pokemon.getMoveQueue(); if ( - (targets.length === 0 && !this.move.getMove().hasAttr("AddArenaTrapTagAttr")) - || (moveQueue.length > 0 && moveQueue[0].move === MoveId.NONE) + (targets.length === 0 && !this.move.getMove().hasAttr("AddArenaTrapTagAttr")) || + (moveQueue.length > 0 && moveQueue[0].move === MoveId.NONE) || + this.targets[0] === BattlerIndex.ATTACKER ) { this.showFailedText(); - this.cancel(); - } else { - this.pokemon.lapseTags(BattlerTagLapseType.MOVE); + this.fail(); + return true; } + this.pokemon.lapseTags(BattlerTagLapseType.MOVE); + return false; } public getActiveTargetPokemon(): Pokemon[] { @@ -669,25 +686,23 @@ export class MovePhase extends PokemonPhase { /** Execute the current move and apply its effects. */ private executeMove() { - const pokemon = this.pokemon; + const user = this.pokemon; const move = this.move.getMove(); 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, move, opponent }); - this.showMoveText(); - globalScene.phaseManager.unshiftNew("MoveEffectPhase", pokemon.getBattlerIndex(), targets, move, this.useMode); + applyAbAttrs("PokemonTypeChangeAbAttr", { pokemon: user, move, opponent }); + globalScene.phaseManager.unshiftNew("MoveEffectPhase", user.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: pokemon, targets: targets }); + applyAbAttrs("PostMoveUsedAbAttr", { pokemon, move: this.move, source: user, targets: targets }); }); } } @@ -695,11 +710,9 @@ export class MovePhase extends PokemonPhase { /** * Fail the move currently being used. * Handles failure messages, pushing to move history, etc. - * @param showText - Whether to show move text when failing the move. - * @param failedDueToWeather - Whether the move failed due to weather (default `false`) * @param failedDueToTerrain - Whether the move failed due to terrain (default `false`) */ - protected failMove(showText: boolean, failedDueToWeather = false, failedDueToTerrain = false) { + protected failMove(failedDueToTerrain = false) { const move = this.move.getMove(); const targets = this.getActiveTargetPokemon(); @@ -721,12 +734,12 @@ export class MovePhase extends PokemonPhase { }); } - if (showText) { - this.showMoveText(); - } - const moveHistoryEntry = this.moveHistoryEntry; - moveHistoryEntry.result = MoveResult.FAIL; - this.pokemon.pushMoveHistory(moveHistoryEntry); + this.pokemon.pushMoveHistory({ + move: this.move.moveId, + targets: this.targets, + result: MoveResult.FAIL, + useMode: this.useMode, + }); // Use move-specific failure messages if present before checking terrain/weather blockage // and falling back to the classic "But it failed!". @@ -734,9 +747,7 @@ export class MovePhase extends PokemonPhase { move.getFailedText(this.pokemon, targets[0], move) || (failedDueToTerrain ? getTerrainBlockMessage(targets[0], globalScene.arena.getTerrainType()) - : failedDueToWeather - ? getWeatherBlockMessage(globalScene.arena.getWeatherType()) - : i18next.t("battle:attackFailed")); + : i18next.t("battle:attackFailed")); this.showFailedText(failureMessage); @@ -753,7 +764,7 @@ export class MovePhase extends PokemonPhase { const targets = this.getActiveTargetPokemon(); if (!move.applyConditions(this.pokemon, targets[0])) { - this.failMove(true); + this.failMove(); return; } @@ -881,34 +892,17 @@ export class MovePhase extends PokemonPhase { * to reflect the actual battler index of the user's last attacker. * * If there is no last attacker or they are no longer on the field, a message is displayed and the - * move is marked for failure. - * @todo Make this a feature of the move rather than basing logic on {@linkcode BattlerIndex.ATTACKER} + * move is marked for failure */ protected resolveCounterAttackTarget(): void { if (this.targets.length !== 1 || this.targets[0] !== BattlerIndex.ATTACKER) { return; } - // TODO: This should be covered in move conditions - if (this.pokemon.turnData.attacksReceived.length === 0) { - this.fail(); - this.showMoveText(); - this.showFailedText(); - return; - } + const targetHolder = new NumberHolder(BattlerIndex.ATTACKER); - this.targets[0] = this.pokemon.turnData.attacksReceived[0].sourceBattlerIndex; - - // account for metal burst and comeuppance hitting remaining targets in double battles - // counterattack will redirect to remaining ally if original attacker faints - if ( - globalScene.currentBattle.double - && this.move.getMove().hasFlag(MoveFlags.REDIRECT_COUNTER) - && globalScene.getField()[this.targets[0]].hp === 0 - ) { - const opposingField = this.pokemon.isPlayer() ? globalScene.getEnemyField() : globalScene.getPlayerField(); - this.targets[0] = opposingField.find(p => p.hp > 0)?.getBattlerIndex() ?? BattlerIndex.ATTACKER; - } + applyMoveAttrs("CounterRedirectAttr", this.pokemon, null, this.move.getMove(), targetHolder); + this.targets[0] = targetHolder.value; } /** @@ -930,10 +924,6 @@ export class MovePhase extends PokemonPhase { return; } - if (this.failed) { - this.usePP(); - } - if (this.cancelled && this.pokemon.summonData.tags.some(t => t.tagType === BattlerTagType.FRENZY)) { frenzyMissFunc(this.pokemon, this.move.getMove()); } diff --git a/src/utils/pokemon-utils.ts b/src/utils/pokemon-utils.ts index 60a4d9e0ef7..75428203873 100644 --- a/src/utils/pokemon-utils.ts +++ b/src/utils/pokemon-utils.ts @@ -2,6 +2,7 @@ import { globalScene } from "#app/global-scene"; import { POKERUS_STARTER_COUNT, speciesStarterCosts } from "#balance/starters"; import { allSpecies } from "#data/data-lists"; import type { PokemonSpecies, PokemonSpeciesForm } from "#data/pokemon-species"; +import { BattlerIndex } from "#enums/battler-index"; import type { SpeciesId } from "#enums/species-id"; import { randSeedItem } from "./common"; @@ -123,3 +124,19 @@ export function getPokemonSpeciesForm(species: SpeciesId, formIndex: number): Po } return retSpecies; } + +/** + * Return whether two battler indices are allies + * @param a - First battler index + * @param b - Second battler index + * @returns Whether the two battler indices are allies. Always `false` if either index is `ATTACKER`. + */ +export function areAllies(a: BattlerIndex, b: BattlerIndex): boolean { + if (a === BattlerIndex.ATTACKER || b === BattlerIndex.ATTACKER) { + return false; + } + return ( + (a === BattlerIndex.PLAYER || a === BattlerIndex.PLAYER_2) === + (b === BattlerIndex.PLAYER || b === BattlerIndex.PLAYER_2) + ); +} diff --git a/test/moves/throat-chop.test.ts b/test/moves/throat-chop.test.ts index a4f090b2de1..335bfce2f40 100644 --- a/test/moves/throat-chop.test.ts +++ b/test/moves/throat-chop.test.ts @@ -35,6 +35,7 @@ describe("Moves - Throat Chop", () => { await game.classicMode.startBattle([SpeciesId.MAGIKARP]); const enemy = game.field.getEnemyPokemon(); + const player = game.field.getPlayerPokemon(); game.move.select(MoveId.GROWL); await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); @@ -46,6 +47,8 @@ describe("Moves - Throat Chop", () => { // Second turn, struggle if no valid moves await game.toNextTurn(); + expect(player.trySelectMove(MoveId.GROWL)[0]).toBe(false); + game.move.select(MoveId.GROWL); await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); From a072cf26eabd1f9451962a857e974ad4b006fcfe Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Mon, 18 Aug 2025 20:23:27 -0500 Subject: [PATCH 04/43] Adjust status checks to respect ignoreStatus useModes --- src/enums/battler-tag-lapse-type.ts | 12 ++++++++++- src/phases/move-phase.ts | 33 +++++++++++++++++------------ test/abilities/cud-chew.test.ts | 6 ++---- test/moves/gigaton-hammer.test.ts | 10 ++++----- 4 files changed, 38 insertions(+), 23 deletions(-) diff --git a/src/enums/battler-tag-lapse-type.ts b/src/enums/battler-tag-lapse-type.ts index f9486876993..cc93d6c6782 100644 --- a/src/enums/battler-tag-lapse-type.ts +++ b/src/enums/battler-tag-lapse-type.ts @@ -11,7 +11,17 @@ export enum BattlerTagLapseType { * @see MoveUseMode for more information */ MOVE, - /** Tag activates during (or just after) the first failure check sequence in the move phase. */ + /** + * Tag activates during (or just after) the first failure check sequence in the move phase. + * + * @remarks + * + * Note tags with this lapse type will lapse immediately after the first failure check sequence, + * regardless of whether the move was successful or not. + * + * To only lapse the tag between the first and second failure check sequences, use + * {@linkcode BattlerTagLapseType.MOVE} instead. + */ PRE_MOVE, /** Tag activates immediately after the holder's move finishes triggering (successful or not). */ AFTER_MOVE, diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index bab78a9b39b..32bb4e5d355 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -298,8 +298,8 @@ export class MovePhase extends PokemonPhase { // if it attempted to move at all. user.turnData.acted = true; const useMode = this.useMode; - const virtual = isVirtual(useMode); - if (!virtual && this.firstFailureCheck()) { + const ignoreStatus = isIgnoreStatus(useMode); + if (!ignoreStatus && this.firstFailureCheck()) { // Lapse all other pre-move tags user.lapseTags(BattlerTagLapseType.PRE_MOVE); this.end(); @@ -311,11 +311,13 @@ export class MovePhase extends PokemonPhase { - 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 + - and many others + In Pokerogue, these are instead handled elsewhere, and generally work in a way that aligns with cartridge behavior */ return; } + // Tags still need to be lapsed if no failure occured + user.lapseTags(BattlerTagLapseType.PRE_MOVE); // At this point, called moves should be decided. // For now, this comment works as a placeholder until we rework how called moves are handled @@ -326,7 +328,7 @@ export class MovePhase extends PokemonPhase { this.post1stFailSleepOrThaw(); // Reset hit-related turn data when starting follow-up moves (e.g. Metronomed moves, Dancer repeats) - if (virtual) { + if (isVirtual(useMode)) { this.pokemon.turnData.hitsLeft = -1; this.pokemon.turnData.hitCount = 0; } @@ -366,11 +368,7 @@ export class MovePhase extends PokemonPhase { globalScene.triggerPokemonFormChange(this.pokemon, SpeciesFormChangePreMoveTrigger); } - // At this point, if the target index has not moved on from attacker, the move must fail - if (this.targets[0] === BattlerIndex.ATTACKER) { - this.fail(); - } - if (this.targets[0] === BattlerIndex.ATTACKER || this.secondFailureCheck()) { + if (this.secondFailureCheck()) { this.handlePreMoveFailures(); this.end(); return; @@ -390,7 +388,7 @@ export class MovePhase extends PokemonPhase { } /** - * Check for cancellation edge cases - no targets remaining or the battler index being targeted is still the attacker + * Check for cancellation edge cases - no targets remaining * @returns Whether the move fails */ protected resolveFinalPreMoveCancellationChecks(): boolean { @@ -399,8 +397,7 @@ export class MovePhase extends PokemonPhase { if ( (targets.length === 0 && !this.move.getMove().hasAttr("AddArenaTrapTagAttr")) || - (moveQueue.length > 0 && moveQueue[0].move === MoveId.NONE) || - this.targets[0] === BattlerIndex.ATTACKER + (moveQueue.length > 0 && moveQueue[0].move === MoveId.NONE) ) { this.showFailedText(); this.fail(); @@ -452,6 +449,7 @@ export class MovePhase extends PokemonPhase { return false; } + // For some reason, dancer will immediately wake its user from sleep when triggering if (this.useMode === MoveUseMode.INDIRECT) { this.pokemon.resetStatus(false); return false; @@ -490,6 +488,12 @@ export class MovePhase extends PokemonPhase { return false; } + // For some reason, dancer will immediately its user + if (this.useMode === MoveUseMode.INDIRECT) { + this.pokemon.resetStatus(false); + return false; + } + // Check if move use would heal the user if (Overrides.STATUS_ACTIVATION_OVERRIDE) { @@ -903,6 +907,9 @@ export class MovePhase extends PokemonPhase { applyMoveAttrs("CounterRedirectAttr", this.pokemon, null, this.move.getMove(), targetHolder); this.targets[0] = targetHolder.value; + if (targetHolder.value === BattlerIndex.ATTACKER) { + this.fail(); + } } /** diff --git a/test/abilities/cud-chew.test.ts b/test/abilities/cud-chew.test.ts index 8d80ba119ca..b923405c3a4 100644 --- a/test/abilities/cud-chew.test.ts +++ b/test/abilities/cud-chew.test.ts @@ -1,4 +1,3 @@ -import { CudChewConsumeBerryAbAttr } from "#abilities/ability"; import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; import { AbilityId } from "#enums/ability-id"; @@ -6,7 +5,6 @@ import { BerryType } from "#enums/berry-type"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; import { Stat } from "#enums/stat"; -import { Pokemon } from "#field/pokemon"; import { GameManager } from "#test/test-utils/game-manager"; import i18next from "i18next"; import Phaser from "phaser"; @@ -111,7 +109,6 @@ describe("Abilities - Cud Chew", () => { it("can store multiple berries across 2 turns with teatime", async () => { // always eat first berry for stuff cheeks & company - vi.spyOn(Pokemon.prototype, "randBattleSeedInt").mockReturnValue(0); game.override .startingHeldItems([ { name: "BERRY", type: BerryType.PETAYA, count: 3 }, @@ -122,6 +119,7 @@ describe("Abilities - Cud Chew", () => { const farigiraf = game.field.getPlayerPokemon(); farigiraf.hp = 1; // needed to allow berry procs + vi.spyOn(farigiraf, "randBattleSeedInt").mockReturnValue(0); game.move.select(MoveId.STUFF_CHEEKS); await game.toNextTurn(); @@ -196,10 +194,10 @@ describe("Abilities - Cud Chew", () => { describe("regurgiates berries", () => { it("re-triggers effects on eater without pushing to array", async () => { - const apply = vi.spyOn(CudChewConsumeBerryAbAttr.prototype, "apply"); await game.classicMode.startBattle([SpeciesId.FARIGIRAF]); const farigiraf = game.field.getPlayerPokemon(); + const apply = vi.spyOn(farigiraf.getAbilityAttrs("CudChewConsumeBerryAbAttr")[0], "apply"); farigiraf.hp = 1; game.move.select(MoveId.SPLASH); diff --git a/test/moves/gigaton-hammer.test.ts b/test/moves/gigaton-hammer.test.ts index e5009310de6..d217290034b 100644 --- a/test/moves/gigaton-hammer.test.ts +++ b/test/moves/gigaton-hammer.test.ts @@ -33,7 +33,7 @@ describe("Moves - Gigaton Hammer", () => { }); it("can't be used two turns in a row", async () => { - await game.classicMode.startBattle(); + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); const enemy1 = game.field.getEnemyPokemon(); @@ -46,17 +46,17 @@ describe("Moves - Gigaton Hammer", () => { await game.doKillOpponents(); await game.toNextWave(); + // Attempting to use Gigaton Hammer again should result in struggle game.move.select(MoveId.GIGATON_HAMMER); await game.toNextTurn(); - const enemy2 = game.field.getEnemyPokemon(); - - expect(enemy2.hp).toBe(enemy2.getMaxHp()); + const player = game.field.getPlayerPokemon(); + expect(player.getLastXMoves()[0]?.move).toBe(MoveId.STRUGGLE); }); it("can be used again if recalled and sent back out", async () => { game.override.startingWave(4); - await game.classicMode.startBattle(); + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); const enemy1 = game.field.getEnemyPokemon(); From 62a1774e1f8248fb9e27342bc63dbc5d25dc8583 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Mon, 18 Aug 2025 20:28:26 -0500 Subject: [PATCH 05/43] Adjust restriction for stuff cheeks --- src/data/moves/move-condition.ts | 13 +++-- src/data/moves/move.ts | 10 ++-- src/enums/battler-tag-lapse-type.ts | 7 ++- src/phases/move-phase.ts | 79 ++++++++++++++++------------- test/abilities/cud-chew.test.ts | 2 + 5 files changed, 63 insertions(+), 48 deletions(-) diff --git a/src/data/moves/move-condition.ts b/src/data/moves/move-condition.ts index 5c06ab9bebe..13cded8834e 100644 --- a/src/data/moves/move-condition.ts +++ b/src/data/moves/move-condition.ts @@ -26,10 +26,8 @@ export class MoveCondition { /** * @param func - A condition function that determines if the move can be used successfully */ - constructor(func?: MoveConditionFunc) { - if (func) { - this.func = func; - } + constructor(func: MoveConditionFunc) { + this.func = func; } apply(user: Pokemon, target: Pokemon, move: Move): boolean { @@ -46,9 +44,9 @@ export class MoveCondition { */ export class FirstMoveCondition extends MoveCondition { - public override readonly func: MoveConditionFunc = user => { - return user.tempSummonData.waveTurnCount === 1; - }; + constructor() { + super(user => user.tempSummonData.waveTurnCount === 0 && user.tempSummonData.turnCount === 0); + } // 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 @@ -173,6 +171,7 @@ export const UpperHandCondition = new MoveCondition((_user, target) => { * - The user does not know at least one other move * - The user has not directly used each other move in its moveset since it was sent into battle * - A move is considered *used* for this purpose if it passed the first failure check sequence in the move phase + * (i.e. its usage message was displayed) */ export const lastResortCondition = new MoveCondition((user, _target, move) => { const otherMovesInMoveset = new Set(user.getMoveset().map(m => m.moveId)); diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 6c82d7760a4..495e94828b8 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -456,8 +456,8 @@ export abstract class Move implements Localizable { * @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` + * @param alsoCondition - If `true`, also adds an equivalent {@linkcode MoveCondition} that checks the same condition when the + * move is used (while taking care to invert the return value); default `false` * @param conditionSeq - The sequence number where the failure check occurs; default `4` * @returns `this` for method chaining */ @@ -10941,9 +10941,11 @@ export function initMoves() { .attr(EatBerryAttr, true) .attr(StatStageChangeAttr, [ Stat.DEF ], 2, true) .restriction( - user => globalScene.findModifiers(m => m instanceof BerryModifier, user.isPlayer()).length > 0, + user => globalScene.findModifiers(m => m instanceof BerryModifier, user.isPlayer()).length === 0, "battle:moveDisabledNoBerry", - true), + true, + 3 + ), 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, true /* NOT ADDED if already trapped */) diff --git a/src/enums/battler-tag-lapse-type.ts b/src/enums/battler-tag-lapse-type.ts index cc93d6c6782..f4fbcdda9a6 100644 --- a/src/enums/battler-tag-lapse-type.ts +++ b/src/enums/battler-tag-lapse-type.ts @@ -1,3 +1,5 @@ +import type { MoveUseMode } from "#enums/move-use-mode"; + /** * Enum representing the possible ways a given BattlerTag can activate and/or tick down. * Each tag can have multiple different behaviors attached to different lapse types. @@ -12,12 +14,13 @@ export enum BattlerTagLapseType { */ MOVE, /** - * Tag activates during (or just after) the first failure check sequence in the move phase. + * Tag activates during (or just after) the first failure check sequence in the move phase * * @remarks * * Note tags with this lapse type will lapse immediately after the first failure check sequence, - * regardless of whether the move was successful or not. + * regardless of whether the move was successful or not, but is skipped if the move is a + * {@linkcode MoveUseMode.FOLLOW_UP | follow-up} move. * * To only lapse the tag between the first and second failure check sequences, use * {@linkcode BattlerTagLapseType.MOVE} instead. diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 32bb4e5d355..9c412bf361f 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -135,28 +135,44 @@ export class MovePhase extends PokemonPhase { */ protected firstFailureCheck(): boolean { // A big if statement will handle the checks (that each have side effects!) in the correct order - if ( + return ( this.checkSleep() || this.checkFreeze() || this.checkPP() || this.checkValidity() || - this.checkTagCancel(BattlerTagType.TRUANT, true) || + this.checkTagCancel(BattlerTagType.TRUANT) || this.checkPreUseInterrupt() || this.checkTagCancel(BattlerTagType.FLINCHED) || - this.checkTagCancel(BattlerTagType.DISABLED, true) || + this.checkTagCancel(BattlerTagType.DISABLED) || this.checkTagCancel(BattlerTagType.HEAL_BLOCK) || this.checkTagCancel(BattlerTagType.THROAT_CHOPPED) || this.checkGravity() || - this.checkTagCancel(BattlerTagType.TAUNT, true) || + this.checkTagCancel(BattlerTagType.TAUNT) || this.checkTagCancel(BattlerTagType.IMPRISON) || this.checkTagCancel(BattlerTagType.CONFUSED) || this.checkPara() || this.checkTagCancel(BattlerTagType.INFATUATED) - ) { - this.handlePreMoveFailures(); - return true; - } - return false; + ); + } + + /** + * Follow up moves need to check a subset of the first failure checks + * + * @remarks + * + * Based on smogon battle mechanics research, checks happen in the following order: + * 1. Invalid move (skipped in pokerogue) + * 2. Move prevented by heal block + * 3. Move prevented by throat chop + * 4. Gravity + * 5. sky battle (unused in Pokerogue) + */ + protected followUpMoveFirstFailureCheck(): boolean { + return ( + this.checkTagCancel(BattlerTagType.HEAL_BLOCK) || + this.checkTagCancel(BattlerTagType.THROAT_CHOPPED) || + this.checkGravity() + ); } /** @@ -299,33 +315,29 @@ export class MovePhase extends PokemonPhase { user.turnData.acted = true; const useMode = this.useMode; const ignoreStatus = isIgnoreStatus(useMode); - if (!ignoreStatus && this.firstFailureCheck()) { - // Lapse all other pre-move tags + if (!ignoreStatus) { + this.firstFailureCheck(); user.lapseTags(BattlerTagLapseType.PRE_MOVE); + // At this point, called moves should be decided. + // For now, this comment works as a placeholder until called moves are reworked + // For correct alignment with mainline, this SHOULD go here, and this phase SHOULD rewrite its own move + } else if (useMode === MoveUseMode.FOLLOW_UP) { + this.followUpMoveFirstFailureCheck(); + } + // If the first failure check did not pass, then the move is cancelled + // Note: This only checks `cancelled`, as `failed` should NEVER be set by anything in the first failure check + if (this.cancelled) { + this.handlePreMoveFailures(); 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 many others - In Pokerogue, these are instead handled elsewhere, and generally work in a way that aligns with cartridge behavior - */ return; } - // Tags still need to be lapsed if no failure occured - user.lapseTags(BattlerTagLapseType.PRE_MOVE); + // If this is a follow-up move , at this point, we need to re-check a few conditions - // At this point, called moves should be decided. - // For now, this comment works as a placeholder until we rework how called moves are handled - // For correct alignment with mainline, this SHOULD go here, and this phase SHOULD rewrite its own move - - // 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(); + // If the first failure check passes (and this is not a sub-move) then thaw the user if its move will thaw it. + // The sleep message and animation should also play if the user is asleep but using a move anyway (snore, sleep talk, etc) + if (useMode !== MoveUseMode.FOLLOW_UP) { + this.post1stFailSleepOrThaw(); + } // Reset hit-related turn data when starting follow-up moves (e.g. Metronomed moves, Dancer repeats) if (isVirtual(useMode)) { @@ -615,10 +627,7 @@ export class MovePhase extends PokemonPhase { * @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; - } + private checkTagCancel(tag: BattlerTagType): boolean { this.pokemon.lapseTag(tag, BattlerTagLapseType.PRE_MOVE); return this.cancelled; } diff --git a/test/abilities/cud-chew.test.ts b/test/abilities/cud-chew.test.ts index b923405c3a4..fb4875c6fcf 100644 --- a/test/abilities/cud-chew.test.ts +++ b/test/abilities/cud-chew.test.ts @@ -118,8 +118,10 @@ describe("Abilities - Cud Chew", () => { await game.classicMode.startBattle([SpeciesId.FARIGIRAF]); const farigiraf = game.field.getPlayerPokemon(); + const enemy = game.field.getEnemyPokemon(); farigiraf.hp = 1; // needed to allow berry procs vi.spyOn(farigiraf, "randBattleSeedInt").mockReturnValue(0); + vi.spyOn(enemy, "randBattleSeedInt").mockReturnValue(0); game.move.select(MoveId.STUFF_CHEEKS); await game.toNextTurn(); From 81e26a8cbd5d223bb2ebeecefa7b3e32b16d47c5 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Mon, 18 Aug 2025 21:12:38 -0500 Subject: [PATCH 06/43] Address bertie's review comments --- src/data/moves/move.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 495e94828b8..3fc70acb5f4 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -126,11 +126,12 @@ export abstract class Move implements Localizable { * @remarks * These are the default conditions checked during the move effect phase (aka sequence 4). * When adding a new condition, if unsure of where it occurs in the failure checks, it should go here. - * @remarks Different from {@linkcode restrictions}, which is checked when the move is selected + * + * Different from {@linkcode restrictions}, which are checked when the move is selected */ private conditions: MoveCondition[] = []; /** - * Move failure conditions that occur during the second sequence (after move message and before ) + * Move failure conditions that occur during the second sequence (after move message but before the move is recorded as the last move used) */ private conditionsSeq2: MoveCondition[] = []; /** @@ -701,7 +702,7 @@ export abstract class Move implements Localizable { * Sets the {@linkcode MoveFlags.GRAVITY} flag for the calling Move and adds {@linkcode GravityUseRestriction} to the * move's restrictions. * - * @returns The {@linkcode Move} that called this function + * @returns `this` * * @remarks * No {@linkcode condition} is added, as gravity's condition is already checked From eaaedcd0a9258ce9e391f2cf680ae62cae7d05b3 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Mon, 18 Aug 2025 21:23:50 -0500 Subject: [PATCH 07/43] Add counterRedirectAttr to other counter-like moves --- src/data/moves/move.ts | 10 +++++----- src/phases/move-phase.ts | 2 ++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 3fc70acb5f4..e1011127e94 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -1858,21 +1858,18 @@ export class CounterRedirectAttr extends MoveAttr { // check if the target is still alive if ( globalScene.currentBattle.double && - !globalScene.getField()[desiredTarget]?.isActive + !globalScene.getField()[desiredTarget]?.isActive(true) ) { const targetField = desiredTarget >= BattlerIndex.ENEMY ? globalScene.getEnemyField() : globalScene.getPlayerField(); args[0].value = targetField.find(p => p.hp > 0)?.getBattlerIndex() ?? BattlerIndex.ATTACKER; } else { args[0].value = desiredTarget; } + console.log(`CounterRedirectAttr: Redirecting move to battler index ${BattlerIndex[args[0].value]}`); return true; } return false; } - - override canApply(user: Pokemon, target: Pokemon, move: Move, args: [NumberHolder, ...any[]]): boolean { - return args[0].value === BattlerIndex.ATTACKER; - } } export class LevelDamageAttr extends FixedDamageAttr { @@ -9297,6 +9294,7 @@ export function initMoves() { .bitingMove(), new AttackMove(MoveId.MIRROR_COAT, PokemonType.PSYCHIC, MoveCategory.SPECIAL, -1, 100, 20, -1, -5, 2) .attr(CounterDamageAttr, 2, MoveCategory.SPECIAL) + .attr(CounterRedirectAttr, MoveCategory.SPECIAL) .condition(counterAttackCondition_Special, 3) .target(MoveTarget.ATTACKER), new StatusMove(MoveId.PSYCH_UP, PokemonType.NORMAL, -1, 10, -1, 0, 2) @@ -9719,6 +9717,7 @@ export function initMoves() { .target(MoveTarget.USER_OR_NEAR_ALLY), new AttackMove(MoveId.METAL_BURST, PokemonType.STEEL, MoveCategory.PHYSICAL, -1, 100, 10, -1, 0, 4) .attr(CounterDamageAttr, 1.5) + .attr(CounterRedirectAttr) .condition(counterAttackCondition_Both, 3) .makesContact(false) .target(MoveTarget.ATTACKER), @@ -11530,6 +11529,7 @@ export function initMoves() { .restriction(ConsecutiveUseRestriction), new AttackMove(MoveId.COMEUPPANCE, PokemonType.DARK, MoveCategory.PHYSICAL, -1, 100, 10, -1, 0, 9) .attr(CounterDamageAttr, 1.5) + .attr(CounterRedirectAttr) .condition(counterAttackCondition_Both, 3) .target(MoveTarget.ATTACKER), new AttackMove(MoveId.AQUA_CUTTER, PokemonType.WATER, MoveCategory.PHYSICAL, 70, 100, 20, -1, 0, 9) diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 9c412bf361f..0081eeddcca 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -909,6 +909,7 @@ export class MovePhase extends PokemonPhase { */ protected resolveCounterAttackTarget(): void { if (this.targets.length !== 1 || this.targets[0] !== BattlerIndex.ATTACKER) { + console.log("%cSkipping counter attack target resolution", "color: blue"); return; } @@ -917,6 +918,7 @@ export class MovePhase extends PokemonPhase { applyMoveAttrs("CounterRedirectAttr", this.pokemon, null, this.move.getMove(), targetHolder); this.targets[0] = targetHolder.value; if (targetHolder.value === BattlerIndex.ATTACKER) { + console.log("%cSkipping counter attack target resolution", "color: red"); this.fail(); } } From 313f99a694ee3cb8a54d55e4a484573f4d38bebb Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Mon, 18 Aug 2025 22:30:52 -0500 Subject: [PATCH 08/43] Adjust some documentation for new methods --- src/data/moves/move.ts | 15 +++++++++++---- src/utils/pokemon-utils.ts | 5 ++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index e1011127e94..2105738a0c3 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -94,6 +94,7 @@ import type { AbstractConstructor } from "#types/type-helpers"; import { applyChallenges } from "#utils/challenge-utils"; import { BooleanHolder, coerceArray, type Constructor, isNullOrUndefined, NumberHolder, randSeedFloat, randSeedInt, randSeedItem, toDmgValue } from "#utils/common"; import { getEnumValues } from "#utils/enums"; +import { areAllies } from "#utils/pokemon-utils"; import { toCamelCase, toTitleCase } from "#utils/strings"; import i18next from "i18next"; import { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier"; @@ -1817,15 +1818,22 @@ export class CounterDamageAttr extends FixedDamageAttr { } apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + user.turnData.attacksReceived.find(ar => { + const category = allMoves[ar.move].category; + return (category !== MoveCategory.STATUS || areAllies(user.getBattlerIndex(), ar.sourceBattlerIndex)) + }) let damage = 0; + const userBattlerIndex = user.getBattlerIndex(); for (const ar of user.turnData.attacksReceived) { // TODO: Adjust this for moves with variable damage categories const category = allMoves[ar.move].category; - if (category === MoveCategory.STATUS || (this.moveFilter && category !== this.moveFilter)) { + if (category === MoveCategory.STATUS + || areAllies(userBattlerIndex, ar.sourceBattlerIndex) + || (this.moveFilter && category !== this.moveFilter)) { continue; } - damage += ar.damage; - + damage = ar.damage; + break; } (args[0] as NumberHolder).value = toDmgValue(damage * this.multiplier); @@ -1865,7 +1873,6 @@ export class CounterRedirectAttr extends MoveAttr { } else { args[0].value = desiredTarget; } - console.log(`CounterRedirectAttr: Redirecting move to battler index ${BattlerIndex[args[0].value]}`); return true; } return false; diff --git a/src/utils/pokemon-utils.ts b/src/utils/pokemon-utils.ts index 75428203873..8bb7d76a028 100644 --- a/src/utils/pokemon-utils.ts +++ b/src/utils/pokemon-utils.ts @@ -4,6 +4,8 @@ import { allSpecies } from "#data/data-lists"; import type { PokemonSpecies, PokemonSpeciesForm } from "#data/pokemon-species"; import { BattlerIndex } from "#enums/battler-index"; import type { SpeciesId } from "#enums/species-id"; +// biome-ignore lint/correctness/noUnusedImports: Used in a TSDoc comment +import type { Pokemon } from "#field/pokemon"; import { randSeedItem } from "./common"; /** @@ -126,7 +128,8 @@ export function getPokemonSpeciesForm(species: SpeciesId, formIndex: number): Po } /** - * Return whether two battler indices are allies + * Return whether two battler indices are considered allies. + * To instead check with {@linkcode Pokemon} objects, use {@linkcode Pokemon.isOpponent}. * @param a - First battler index * @param b - Second battler index * @returns Whether the two battler indices are allies. Always `false` if either index is `ATTACKER`. From 8feaa8c86b967e2acec3f988f1b881d2bd55f85f Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Mon, 18 Aug 2025 22:33:48 -0500 Subject: [PATCH 09/43] Make substitute use the move tag --- src/data/battler-tags.ts | 2 +- test/battler-tags/substitute.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index f4eedbe6702..8f1cddb9e38 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -3058,7 +3058,7 @@ export class SubstituteTag extends SerializableBattlerTag { constructor(sourceMove: MoveId, sourceId: number) { super( BattlerTagType.SUBSTITUTE, - [BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.AFTER_MOVE, BattlerTagLapseType.HIT], + [BattlerTagLapseType.MOVE, BattlerTagLapseType.AFTER_MOVE, BattlerTagLapseType.HIT], 0, sourceMove, sourceId, diff --git a/test/battler-tags/substitute.test.ts b/test/battler-tags/substitute.test.ts index a2ff539d2a8..d1e91da4fb7 100644 --- a/test/battler-tags/substitute.test.ts +++ b/test/battler-tags/substitute.test.ts @@ -142,7 +142,7 @@ describe("BattlerTag - SubstituteTag", () => { vi.spyOn(messages, "getPokemonNameWithAffix").mockReturnValue(""); }); - it("PRE_MOVE lapse triggers pre-move animation", async () => { + it("MOVE lapse triggers pre-move animation", async () => { const subject = new SubstituteTag(MoveId.SUBSTITUTE, mockPokemon.id); vi.spyOn(mockPokemon.scene as BattleScene, "triggerPokemonBattleAnim").mockImplementation( @@ -154,7 +154,7 @@ describe("BattlerTag - SubstituteTag", () => { vi.spyOn((mockPokemon.scene as BattleScene).phaseManager, "queueMessage").mockReturnValue(); - expect(subject.lapse(mockPokemon, BattlerTagLapseType.PRE_MOVE)).toBeTruthy(); + expect(subject.lapse(mockPokemon, BattlerTagLapseType.MOVE)).toBeTruthy(); expect(subject.sourceInFocus).toBeTruthy(); expect((mockPokemon.scene as BattleScene).triggerPokemonBattleAnim).toHaveBeenCalledTimes(1); From 5c7c87e07533cdda8222ba9b608af9ef5aa678d3 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Tue, 19 Aug 2025 00:00:03 -0500 Subject: [PATCH 10/43] Adjust counter attr to use array.find --- src/data/moves/move.ts | 24 +++++++----------------- src/phases/move-phase.ts | 1 - 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 2105738a0c3..abd2536cb6a 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -1818,25 +1818,15 @@ export class CounterDamageAttr extends FixedDamageAttr { } apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - user.turnData.attacksReceived.find(ar => { + const damage = user.turnData.attacksReceived.find(ar => { const category = allMoves[ar.move].category; - return (category !== MoveCategory.STATUS || areAllies(user.getBattlerIndex(), ar.sourceBattlerIndex)) - }) - let damage = 0; - const userBattlerIndex = user.getBattlerIndex(); - for (const ar of user.turnData.attacksReceived) { - // TODO: Adjust this for moves with variable damage categories - const category = allMoves[ar.move].category; - if (category === MoveCategory.STATUS - || areAllies(userBattlerIndex, ar.sourceBattlerIndex) - || (this.moveFilter && category !== this.moveFilter)) { - continue; - } - damage = ar.damage; - break; - } + return ( + category !== MoveCategory.STATUS + && !areAllies(user.getBattlerIndex(), ar.sourceBattlerIndex) + && (this.moveFilter === undefined || category === this.moveFilter) + ) + })?.damage ?? 0; (args[0] as NumberHolder).value = toDmgValue(damage * this.multiplier); - return true; } } diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 0081eeddcca..d66a32b5762 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -909,7 +909,6 @@ export class MovePhase extends PokemonPhase { */ protected resolveCounterAttackTarget(): void { if (this.targets.length !== 1 || this.targets[0] !== BattlerIndex.ATTACKER) { - console.log("%cSkipping counter attack target resolution", "color: blue"); return; } From d5c59218a649607bf4ae4802d1b05a21882121a1 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Tue, 19 Aug 2025 00:03:33 -0500 Subject: [PATCH 11/43] Adjust move condition check that occurs in the third failure check sequence --- src/phases/move-phase.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index d66a32b5762..76df12fc3cc 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -269,7 +269,8 @@ export class MovePhase extends PokemonPhase { const targets = this.getActiveTargetPokemon(); const arena = globalScene.arena; const user = this.pokemon; - const failsConditions = !move.applyConditions(user, targets[0]); + + const failsConditions = !move.applyConditions(user, targets[0], 3); const failedDueToTerrain = arena.isMoveTerrainCancelled(user, this.targets, move); let failed = failsConditions || failedDueToTerrain; From 61015bd363ca108bb0b8a9fcd9f08dccbb2b5359 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Tue, 19 Aug 2025 00:11:17 -0500 Subject: [PATCH 12/43] Insert move failure check sequence part 4 into move phase --- src/phases/move-phase.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 76df12fc3cc..f0b6096295c 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -289,6 +289,9 @@ export class MovePhase extends PokemonPhase { failed = cancelled.value; } + // TODO: Move this to the Move effect phase where it belongs. + failed ||= !move.applyConditions(user, targets[0], 4); + if (failed) { this.failMove(failedDueToTerrain); return true; From 4606e4e8bc61e0b9110ad1229179dd1cb2f5ced6 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Tue, 19 Aug 2025 10:27:59 -0500 Subject: [PATCH 13/43] Revert type adjustment to getBattlerIndex --- src/field/pokemon.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index bcff1b9d28a..b66c7339e4d 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -731,7 +731,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { abstract getFieldIndex(): number; - abstract getBattlerIndex(): Exclude; + abstract getBattlerIndex(): BattlerIndex; /** * Load all assets needed for this Pokemon's use in battle @@ -5804,7 +5804,7 @@ export class PlayerPokemon extends Pokemon { return globalScene.getPlayerField().indexOf(this); } - getBattlerIndex(): Exclude { + getBattlerIndex(): BattlerIndex { return this.getFieldIndex(); } @@ -6523,7 +6523,7 @@ export class EnemyPokemon extends Pokemon { move.category !== MoveCategory.STATUS && moveTargets.some(p => { const doesNotFail = - move.applyConditions(this, p) || + move.applyConditions(this, p, -1) || [MoveId.SUCKER_PUNCH, MoveId.UPPER_HAND, MoveId.THUNDERCLAP].includes(move.id); return ( doesNotFail @@ -6584,7 +6584,7 @@ export class EnemyPokemon extends Pokemon { * target score to -20 */ if ( - (move.name.endsWith(" (N)") || !move.applyConditions(this, target)) && + (move.name.endsWith(" (N)") || !move.applyConditions(this, target, -1)) && ![MoveId.SUCKER_PUNCH, MoveId.UPPER_HAND, MoveId.THUNDERCLAP].includes(move.id) ) { targetScore = -20; From 174c2113ac697520d4b2b70a31ec517b693dd5d8 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Tue, 19 Aug 2025 11:02:47 -0500 Subject: [PATCH 14/43] Make charging moves deduct pp on use instead of on release --- src/data/moves/move.ts | 3 ++- src/phases/move-phase.ts | 58 +++++++++++++++++++++++++++------------- 2 files changed, 41 insertions(+), 20 deletions(-) diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index abd2536cb6a..8c92b4c6016 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -828,7 +828,7 @@ export abstract class Move implements Localizable { * @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, or `-1` to check all; defaults to 3. Pass -1 to check all + * @param sequence - The sequence number where the condition check occurs, or `-1` to check all; defaults to 4. Pass -1 to check all * @returns boolean: false if any of the apply()'s return false, else true */ applyConditions(user: Pokemon, target: Pokemon, sequence: -1 | 2 | 3 | 4 = 4): boolean { @@ -9007,6 +9007,7 @@ export function initMoves() { new AttackMove(MoveId.STRUGGLE, PokemonType.NORMAL, MoveCategory.PHYSICAL, 50, -1, 1, -1, 0, 1) .attr(RecoilAttr, true, 0.25, true) .attr(TypelessAttr) + .attr(PreMoveMessageAttr, (user: Pokemon) => i18next.t("moveTriggers:struggleMessage", { pokemonName: getPokemonNameWithAffix(user) })) .target(MoveTarget.RANDOM_NEAR_ENEMY), new StatusMove(MoveId.SKETCH, PokemonType.NORMAL, -1, 1, -1, 0, 2) .ignoresSubstitute() diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index f0b6096295c..c6a567af72a 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -258,7 +258,7 @@ export class MovePhase extends PokemonPhase { * - Queenly Majesty / Dazzling * - Damp (which is handled by move conditions in pokerogue rather than the ability, like queenly majesty / dazzling) * - * The rest of the failure conditions are marked as sequence 4 and happen in the move effect phase. + * The rest of the failure conditions are marked as sequence 4 and *should* happen in the move effect phase (though happen here for now) */ protected thirdFailureCheck(): boolean { /** @@ -319,13 +319,15 @@ export class MovePhase extends PokemonPhase { user.turnData.acted = true; const useMode = this.useMode; const ignoreStatus = isIgnoreStatus(useMode); + const isFollowUp = useMode === MoveUseMode.FOLLOW_UP; if (!ignoreStatus) { this.firstFailureCheck(); user.lapseTags(BattlerTagLapseType.PRE_MOVE); // At this point, called moves should be decided. // For now, this comment works as a placeholder until called moves are reworked // For correct alignment with mainline, this SHOULD go here, and this phase SHOULD rewrite its own move - } else if (useMode === MoveUseMode.FOLLOW_UP) { + } else if (isFollowUp) { + // Follow up moves need to make sure the called move passes a few of the conditions to continue this.followUpMoveFirstFailureCheck(); } // If the first failure check did not pass, then the move is cancelled @@ -335,29 +337,31 @@ export class MovePhase extends PokemonPhase { this.end(); return; } - // If this is a follow-up move , at this point, we need to re-check a few conditions // If the first failure check passes (and this is not a sub-move) then thaw the user if its move will thaw it. // The sleep message and animation should also play if the user is asleep but using a move anyway (snore, sleep talk, etc) - if (useMode !== MoveUseMode.FOLLOW_UP) { + if (!isFollowUp) { this.post1stFailSleepOrThaw(); } // Reset hit-related turn data when starting follow-up moves (e.g. Metronomed moves, Dancer repeats) if (isVirtual(useMode)) { - this.pokemon.turnData.hitsLeft = -1; - this.pokemon.turnData.hitCount = 0; + const turnData = user.turnData; + turnData.hitsLeft = -1; + turnData.hitCount = 0; } + const pokemonMove = this.move; + // Check move to see if arena.ignoreAbilities should be true. if ( - this.move.getMove().doesFlagEffectApply({ + pokemonMove.getMove().doesFlagEffectApply({ flag: MoveFlags.IGNORE_ABILITIES, - user: this.pokemon, - isFollowUp: isVirtual(this.useMode), // Sunsteel strike and co. don't work when called indirectly + user, + isFollowUp: isVirtual(useMode), // Sunsteel strike and co. don't work when called indirectly }) ) { - globalScene.arena.setIgnoreAbilities(true, this.pokemon.getBattlerIndex()); + globalScene.arena.setIgnoreAbilities(true, user.getBattlerIndex()); } // At this point, move's type changing and multi-target effects *should* be applied @@ -367,23 +371,36 @@ export class MovePhase extends PokemonPhase { this.resolveRedirectTarget(); this.resolveCounterAttackTarget(); - // Move is announced - this.showMoveText(); - // Stance change happens - const charging = this.move.getMove().isChargingMove() && !this.pokemon.getTag(BattlerTagType.CHARGING); + // If this is the *release* turn of the charge move, PP is not deducted const move = this.move.getMove(); + const isChargingMove = move.isChargingMove(); + /** Indicates this is the charging turn of the move */ + const charging = isChargingMove && !user.getTag(BattlerTagType.CHARGING); + /** Indicates this is the release turn of the move */ + const releasing = isChargingMove && !charging; + // Update the battle's "last move" pointer unless we're currently mimicking a move or triggering Dancer. - if (!move.hasAttr("CopyMoveAttr") && !isReflected(this.useMode)) { + if (!move.hasAttr("CopyMoveAttr") && !isReflected(useMode)) { globalScene.currentBattle.lastMove = move.id; } - // Stance change happens now if the move is about to be executed and is not a charging move - if (!charging) { + // Charging moves consume PP when they begin charging, *not* when they release + if (!releasing) { this.usePP(); - globalScene.triggerPokemonFormChange(this.pokemon, SpeciesFormChangePreMoveTrigger); } + if (!isFollowUp) { + // Gorilla tactics lock in (and choice items if they are ever added) + // Stance Change form change + // Struggle's "There are no more moves it can use" message + + globalScene.triggerPokemonFormChange(user, SpeciesFormChangePreMoveTrigger); + // TODO: apply gorilla tactics + } + + this.showMoveText(); + if (this.secondFailureCheck()) { this.handlePreMoveFailures(); this.end(); @@ -675,6 +692,7 @@ export class MovePhase extends PokemonPhase { } protected usePP(): void { + // If this is the release turn of a charging move, PP shall not be deducted if (!isIgnorePP(this.useMode)) { const move = this.move; // "commit" to using the move, deducting PP. @@ -684,6 +702,9 @@ export class MovePhase extends PokemonPhase { } } + /** + * Clear out two turn moves, then schedule the move to be used if it passes the third failure check + */ protected useMove(): void { const user = this.pokemon; // Clear out any two turn moves once they've been used. @@ -774,7 +795,6 @@ export class MovePhase extends PokemonPhase { /** * Queue a {@linkcode MoveChargePhase} for this phase's invoked move. - * Does NOT consume PP (occurs on the 2nd strike of the move) */ protected chargeMove() { const move = this.move.getMove(); From e0f926a9d70e77d1f058ceeaf66a389239c30775 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Tue, 19 Aug 2025 11:21:22 -0500 Subject: [PATCH 15/43] Fix first move condition not using 1 based starting wave --- src/data/moves/move-condition.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/data/moves/move-condition.ts b/src/data/moves/move-condition.ts index 13cded8834e..e7e90757f4d 100644 --- a/src/data/moves/move-condition.ts +++ b/src/data/moves/move-condition.ts @@ -45,7 +45,9 @@ export class MoveCondition { export class FirstMoveCondition extends MoveCondition { constructor() { - super(user => user.tempSummonData.waveTurnCount === 0 && user.tempSummonData.turnCount === 0); + super(user => { + return user.tempSummonData.waveTurnCount === 1; + }); } // TODO: Update AI move selection logic to not require this method at all From 5c67d65e09743535e4a77855cc47c241dd0f936f Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Tue, 19 Aug 2025 12:14:16 -0500 Subject: [PATCH 16/43] Tweak charge move handling and protean timing --- src/data/moves/move-condition.ts | 2 + src/phases/move-phase.ts | 66 ++++++++++++++++---------------- 2 files changed, 36 insertions(+), 32 deletions(-) diff --git a/src/data/moves/move-condition.ts b/src/data/moves/move-condition.ts index e7e90757f4d..9cf34142107 100644 --- a/src/data/moves/move-condition.ts +++ b/src/data/moves/move-condition.ts @@ -61,6 +61,8 @@ export class FirstMoveCondition extends MoveCondition { * Condition that fails the move if the user has less than 1/x of their max HP. * @remarks * Used by Clangorous Soul and Fillet Away + * + * NOT used by Belly Drum, whose failure check occurs in phase 4 along with its stat increase condition */ export class FailIfInsufficientHpCondition extends MoveCondition { /** diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index c6a567af72a..e74cf282bb5 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -289,9 +289,6 @@ export class MovePhase extends PokemonPhase { failed = cancelled.value; } - // TODO: Move this to the Move effect phase where it belongs. - failed ||= !move.applyConditions(user, targets[0], 4); - if (failed) { this.failMove(failedDueToTerrain); return true; @@ -407,14 +404,8 @@ export class MovePhase extends PokemonPhase { return; } - if (this.resolveFinalPreMoveCancellationChecks()) { - this.end(); - } - - if (charging) { - this.chargeMove(); - } else { - this.useMove(); + if (!this.resolveFinalPreMoveCancellationChecks()) { + this.useMove(charging); } this.end(); @@ -705,7 +696,7 @@ export class MovePhase extends PokemonPhase { /** * Clear out two turn moves, then schedule the move to be used if it passes the third failure check */ - protected useMove(): void { + protected useMove(charging = false): void { const user = this.pokemon; // Clear out any two turn moves once they've been used. // TODO: Refactor move queues and remove this assignment; @@ -713,11 +704,39 @@ export class MovePhase extends PokemonPhase { // @ts-expect-error - useMode is readonly and shouldn't normally be assigned to this.useMode = user.getMoveQueue().shift()?.useMode ?? this.useMode; - if (user.getTag(BattlerTagType.CHARGING)?.sourceMove === this.move.moveId) { + if (!charging && user.getTag(BattlerTagType.CHARGING)?.sourceMove === this.move.moveId) { user.lapseTag(BattlerTagType.CHARGING); } - if (!this.thirdFailureCheck()) { + if (this.thirdFailureCheck()) { + return; + } + + const move = this.move.getMove(); + const opponent = this.getActiveTargetPokemon()[0]; + + // After the third failure check, the move is "locked in" + // The following things now occur on cartridge + // - Heal Bell / Aromatherapy's custom message is queued (but displayed after the move text) + // - The message for combined pledge moves is queued + // - The custom message for fickle beam is queued + // - Gulp missile's form change is triggered IF the user is using dive (surf happens later) + // - Protean / Libero trigger the type change and flyout + + // Currently, we only do the libero/protean type change here + + applyAbAttrs("PokemonTypeChangeAbAttr", { pokemon: user, move, opponent }); + + // TODO: Move this to the Move effect phase where it belongs. + // Fourth failure check happens _after_ protean + if (!move.applyConditions(user, opponent, 4)) { + this.failMove(); + return; + } + + if (charging) { + this.chargeMove(); + } else { this.executeMove(); } } @@ -726,12 +745,11 @@ export class MovePhase extends PokemonPhase { private executeMove() { const user = this.pokemon; const move = this.move.getMove(); - 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: user, move, opponent }); + globalScene.phaseManager.unshiftNew("MoveEffectPhase", user.getBattlerIndex(), targets, move, this.useMode); // Handle Dancer, which triggers immediately after a move is used (rather than waiting on `this.end()`). @@ -797,22 +815,6 @@ export class MovePhase extends PokemonPhase { * Queue a {@linkcode MoveChargePhase} for this phase's invoked move. */ protected chargeMove() { - const move = this.move.getMove(); - const targets = this.getActiveTargetPokemon(); - - if (!move.applyConditions(this.pokemon, targets[0])) { - this.failMove(); - return; - } - - // Protean and Libero apply on the charging turn of charge moves, even before showing usage text - applyAbAttrs("PokemonTypeChangeAbAttr", { - pokemon: this.pokemon, - move, - opponent: targets[0], - }); - - this.showMoveText(); globalScene.phaseManager.unshiftNew( "MoveChargePhase", this.pokemon.getBattlerIndex(), From f361b8efedd5f185d62a927097218d400821f71d Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Tue, 19 Aug 2025 12:14:35 -0500 Subject: [PATCH 17/43] Adjust fly tests to expect pp reduction properly --- test/moves/fly.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/moves/fly.test.ts b/test/moves/fly.test.ts index dc40b4a439b..4bc743750c2 100644 --- a/test/moves/fly.test.ts +++ b/test/moves/fly.test.ts @@ -85,6 +85,7 @@ describe("Moves - Fly", () => { const playerPokemon = game.field.getPlayerPokemon(); game.move.select(MoveId.FLY); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.phaseInterceptor.to("TurnEndPhase"); expect(playerPokemon.getTag(BattlerTagType.FLYING)).toBeUndefined(); @@ -94,7 +95,7 @@ describe("Moves - Fly", () => { expect(playerFly?.ppUsed).toBe(0); }); - it("should be cancelled when another Pokemon uses Gravity", async () => { + it("should be interrupted when another Pokemon uses Gravity", async () => { game.override.enemyMoveset([MoveId.SPLASH, MoveId.GRAVITY]); await game.classicMode.startBattle([SpeciesId.MAGIKARP]); @@ -115,6 +116,6 @@ describe("Moves - Fly", () => { expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp()); const playerFly = playerPokemon.getMoveset().find(mv => mv && mv.moveId === MoveId.FLY); - expect(playerFly?.ppUsed).toBe(0); + expect(playerFly?.ppUsed).toBe(1); }); }); From 53fb2585aa2335c3c98ee7838c521b61c1cc3c96 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Tue, 19 Aug 2025 12:22:40 -0500 Subject: [PATCH 18/43] Add missing attribute to counter --- src/data/moves/move.ts | 1 + src/phases/move-phase.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 8c92b4c6016..ce253e3288c 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -8715,6 +8715,7 @@ export function initMoves() { .attr(WeightPowerAttr), new AttackMove(MoveId.COUNTER, PokemonType.FIGHTING, MoveCategory.PHYSICAL, -1, 100, 20, -1, -5, 1) .attr(CounterDamageAttr, 2, MoveCategory.PHYSICAL) + .attr(CounterRedirectAttr, MoveCategory.PHYSICAL) .condition(counterAttackCondition_Physical, 3) .target(MoveTarget.ATTACKER), new AttackMove(MoveId.SEISMIC_TOSS, PokemonType.FIGHTING, MoveCategory.PHYSICAL, -1, 100, 20, -1, 0, 1) diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index e74cf282bb5..f7a60187f27 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -709,6 +709,7 @@ export class MovePhase extends PokemonPhase { } if (this.thirdFailureCheck()) { + console.log("Move failed during third failure check"); return; } @@ -730,6 +731,7 @@ export class MovePhase extends PokemonPhase { // TODO: Move this to the Move effect phase where it belongs. // Fourth failure check happens _after_ protean if (!move.applyConditions(user, opponent, 4)) { + console.log("Move failed during fourth failure check"); this.failMove(); return; } @@ -943,7 +945,6 @@ export class MovePhase extends PokemonPhase { applyMoveAttrs("CounterRedirectAttr", this.pokemon, null, this.move.getMove(), targetHolder); this.targets[0] = targetHolder.value; if (targetHolder.value === BattlerIndex.ATTACKER) { - console.log("%cSkipping counter attack target resolution", "color: red"); this.fail(); } } From 86122f6c0405decda326a653324fad4ec93af73f Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Tue, 19 Aug 2025 12:26:00 -0500 Subject: [PATCH 19/43] Adjust revival blessing hardcore test to respect new return value of isUsable --- test/challenges/hardcore.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/challenges/hardcore.test.ts b/test/challenges/hardcore.test.ts index 0f4ab1b9f02..dd12bba1fce 100644 --- a/test/challenges/hardcore.test.ts +++ b/test/challenges/hardcore.test.ts @@ -44,7 +44,7 @@ describe("Challenges - Hardcore", () => { const player = game.field.getPlayerPokemon(); const revBlessing = player.getMoveset()[0]; - expect(revBlessing.isUsable(player)).toBe(false); + expect(revBlessing.isUsable(player)[0]).toBe(false); game.move.select(MoveId.REVIVAL_BLESSING); await game.toEndOfTurn(); From 48b0d843d272ef7ee9c0918056a63f65730fa4ca Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Tue, 19 Aug 2025 14:24:14 -0500 Subject: [PATCH 20/43] Adjust copycat test to account for how it actually works --- test/moves/copycat.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/moves/copycat.test.ts b/test/moves/copycat.test.ts index bfe4dd2f954..5376ff67ecc 100644 --- a/test/moves/copycat.test.ts +++ b/test/moves/copycat.test.ts @@ -35,7 +35,7 @@ describe("Moves - Copycat", () => { .enemyMoveset(MoveId.SPLASH); }); - it("should copy the last move successfully executed", async () => { + it("should copy the last move executed across turns", async () => { game.override.enemyMoveset(MoveId.SUCKER_PUNCH); await game.classicMode.startBattle([SpeciesId.FEEBAS]); @@ -43,6 +43,7 @@ describe("Moves - Copycat", () => { await game.toNextTurn(); game.move.select(MoveId.COPYCAT); // Last successful move should be Swords Dance + await game.move.forceEnemyMove(MoveId.SPLASH); await game.toNextTurn(); expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(4); From 4273345371d6b452bfd4c22305d5bba919d69c70 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Tue, 19 Aug 2025 15:17:26 -0500 Subject: [PATCH 21/43] Play sleep animation and message --- src/phases/move-phase.ts | 75 +++++++++++++++++++++++----------------- 1 file changed, 44 insertions(+), 31 deletions(-) diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index f7a60187f27..8008739cdb0 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -120,13 +120,13 @@ export class MovePhase extends PokemonPhase { * 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 + * 7. Focus Punch's 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) + * 13. Move lock from choice items / gorilla tactics * 14. Failure from taunt * 15. Failure from imprison * 16. Failure from confusion @@ -184,21 +184,20 @@ export class MovePhase extends PokemonPhase { */ 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, - }), - ); - } + + if (isIgnoreStatus(this.useMode)) { + return; + } + if (user.status?.effect === StatusEffect.SLEEP) { + this.triggerStatus(StatusEffect.SLEEP, false); + } else if (this.thaw) { + this.cureStatus( + StatusEffect.FREEZE, + i18next.t("statusEffect:freeze.healByMove", { + pokemonName: getPokemonNameWithAffix(user), + moveName: this.move.getMove().name, + }), + ); } } @@ -307,8 +306,9 @@ export class MovePhase extends PokemonPhase { const user = this.pokemon; - // Removing gigaton hammer always happens first + // Removing gigaton hammer's two flags *always* happens first user.removeTag(BattlerTagType.ALWAYS_GET_HIT); + user.removeTag(BattlerTagType.RECEIVE_DOUBLE_DAMAGE); console.log(MoveId[this.move.moveId], enumValueToKey(MoveUseMode, this.useMode)); // For the purposes of payback and kin, the pokemon is considered to have acted @@ -450,18 +450,23 @@ export class MovePhase extends PokemonPhase { /** * Queue the status activation message, play its animation, and cancel the move + * * @param effect - The effect being triggered + * @param cancel - Whether to cancel the move after triggering the status effect; default `true`; + * pass `false` to only trigger the animation and message without cancelling the move (e.g. sleep talk/snore) */ - private triggerStatus(effect: StatusEffect): void { + private triggerStatus(effect: StatusEffect, cancel = true): void { const pokemon = this.pokemon; - this.showFailedText(getStatusEffectActivationText(effect, getPokemonNameWithAffix(pokemon))); + globalScene.phaseManager.queueMessage(getStatusEffectActivationText(effect, getPokemonNameWithAffix(pokemon))); globalScene.phaseManager.unshiftNew( "CommonAnimPhase", pokemon.getBattlerIndex(), undefined, CommonAnim.POISON + (effect - 1), // offset anim # by effect # ); - this.cancelled = true; + if (cancel) { + this.cancelled = true; + } } /** @@ -698,9 +703,10 @@ export class MovePhase extends PokemonPhase { */ protected useMove(charging = false): void { 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 + + /* 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 = user.getMoveQueue().shift()?.useMode ?? this.useMode; @@ -712,17 +718,24 @@ export class MovePhase extends PokemonPhase { console.log("Move failed during third failure check"); return; } + /* + At this point, delayed moves (future sight, wish, doom desire) are issued, and if they occur, are + Then, combined pledge moves are checked for. Interestingly, the "wasMoveEffective" flag is set to false if the delay occurs + In either case, the phase should end here without proceeding + */ const move = this.move.getMove(); const opponent = this.getActiveTargetPokemon()[0]; - // After the third failure check, the move is "locked in" - // The following things now occur on cartridge - // - Heal Bell / Aromatherapy's custom message is queued (but displayed after the move text) - // - The message for combined pledge moves is queued - // - The custom message for fickle beam is queued - // - Gulp missile's form change is triggered IF the user is using dive (surf happens later) - // - Protean / Libero trigger the type change and flyout + /* + After the third failure check, the move is "locked in" + The following things now occur on cartridge + - Heal Bell / Aromatherapy's custom message is queued (but displayed after the move text) + - The message for combined pledge moves is queued + - The custom message for fickle beam is queued + - Gulp missile's form change is triggered IF the user is using dive (surf happens later) + - Protean / Libero trigger the type change and flyout + */ // Currently, we only do the libero/protean type change here From 77948a8ff86612e00a1a27c103157444251fdea8 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Tue, 19 Aug 2025 16:22:55 -0500 Subject: [PATCH 22/43] Remove BYPASS_SLEEP battler tag in favor of boolean holder --- src/data/battler-tags.ts | 3 --- src/data/moves/move.ts | 16 ++++++++-------- src/enums/battler-tag-type.ts | 1 - src/phases/move-phase.ts | 35 +++++++++++++++++----------------- test/moves/sleep-talk.test.ts | 13 ++++++++++--- test/moves/throat-chop.test.ts | 1 - 6 files changed, 36 insertions(+), 33 deletions(-) diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 8f1cddb9e38..1b7736dfc6d 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -3788,8 +3788,6 @@ export function getBattlerTag( case BattlerTagType.ALWAYS_GET_HIT: case BattlerTagType.RECEIVE_DOUBLE_DAMAGE: return new SerializableBattlerTag(tagType, BattlerTagLapseType.PRE_MOVE, 1, sourceMove); - case BattlerTagType.BYPASS_SLEEP: - return new BattlerTag(tagType, BattlerTagLapseType.TURN_END, turnCount, sourceMove); case BattlerTagType.IGNORE_FLYING: return new GroundedTag(tagType, BattlerTagLapseType.CUSTOM, sourceMove); case BattlerTagType.ROOSTED: @@ -3963,7 +3961,6 @@ export type BattlerTagTypeMap = { [BattlerTagType.IGNORE_ACCURACY]: GenericSerializableBattlerTag; [BattlerTagType.ALWAYS_GET_HIT]: GenericSerializableBattlerTag; [BattlerTagType.RECEIVE_DOUBLE_DAMAGE]: GenericSerializableBattlerTag; - [BattlerTagType.BYPASS_SLEEP]: BattlerTag; [BattlerTagType.IGNORE_FLYING]: GroundedTag; [BattlerTagType.ROOSTED]: RoostedTag; [BattlerTagType.BURNED_UP]: RemovedTypeTag; diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index ce253e3288c..850c751772b 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -3187,19 +3187,19 @@ export class HealStatusEffectAttr extends MoveEffectAttr { } /** - * Attribute to add the {@linkcode BattlerTagType.BYPASS_SLEEP | BYPASS_SLEEP Battler Tag} for 1 turn to the user before move use. + * Attribute checked during the `MovePhase`'s {@linkcode MovePhase.checkSleep | checkSleep} failure sequence to allow + * the move to bypass the sleep condition * Used by {@linkcode MoveId.SNORE} and {@linkcode MoveId.SLEEP_TALK}. */ -// TODO: Should this use a battler tag? // TODO: Give this `userSleptOrComatoseCondition` by default export class BypassSleepAttr extends MoveAttr { - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - if (user.status?.effect === StatusEffect.SLEEP) { - user.addTag(BattlerTagType.BYPASS_SLEEP, 1, move.id, user.id); - return true; + apply(user: Pokemon, target: Pokemon, move: Move, args: [BooleanHolder, ...any[]]): boolean { + const bypassSleep = args[0]; + if (bypassSleep.value) { + return false; } - - return false; + bypassSleep.value = true; + return true } /** diff --git a/src/enums/battler-tag-type.ts b/src/enums/battler-tag-type.ts index 4f0ac491e8b..e4166619423 100644 --- a/src/enums/battler-tag-type.ts +++ b/src/enums/battler-tag-type.ts @@ -47,7 +47,6 @@ export enum BattlerTagType { CRIT_BOOST = "CRIT_BOOST", ALWAYS_CRIT = "ALWAYS_CRIT", IGNORE_ACCURACY = "IGNORE_ACCURACY", - BYPASS_SLEEP = "BYPASS_SLEEP", IGNORE_FLYING = "IGNORE_FLYING", SALT_CURED = "SALT_CURED", CURSED = "CURSED", diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 8008739cdb0..fc42c6fde39 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -182,15 +182,13 @@ export class MovePhase extends PokemonPhase { * - 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 { + private doThawCheck(): void { const user = this.pokemon; if (isIgnoreStatus(this.useMode)) { return; } - if (user.status?.effect === StatusEffect.SLEEP) { - this.triggerStatus(StatusEffect.SLEEP, false); - } else if (this.thaw) { + if (this.thaw) { this.cureStatus( StatusEffect.FREEZE, i18next.t("statusEffect:freeze.healByMove", { @@ -336,9 +334,8 @@ export class MovePhase extends PokemonPhase { } // If the first failure check passes (and this is not a sub-move) then thaw the user if its move will thaw it. - // The sleep message and animation should also play if the user is asleep but using a move anyway (snore, sleep talk, etc) if (!isFollowUp) { - this.post1stFailSleepOrThaw(); + this.doThawCheck(); } // Reset hit-related turn data when starting follow-up moves (e.g. Metronomed moves, Dancer repeats) @@ -474,32 +471,36 @@ export class MovePhase extends PokemonPhase { * @returns Whether the move was cancelled due to sleep */ protected checkSleep(): boolean { - if (this.pokemon.status?.effect !== StatusEffect.SLEEP) { + const user = this.pokemon; + if (user.status?.effect !== StatusEffect.SLEEP) { return false; } // For some reason, dancer will immediately wake its user from sleep when triggering if (this.useMode === MoveUseMode.INDIRECT) { - this.pokemon.resetStatus(false); + user.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); + user.status.incrementTurn(); + const turnsRemaining = new NumberHolder(user.status.sleepTurnsRemaining ?? 0); applyAbAttrs("ReduceStatusEffectDurationAbAttr", { - pokemon: this.pokemon, - statusEffect: this.pokemon.status.effect, + pokemon: user, + statusEffect: user.status.effect, duration: turnsRemaining, }); - this.pokemon.status.sleepTurnsRemaining = turnsRemaining.value; - if (this.pokemon.status.sleepTurnsRemaining <= 0) { + + user.status.sleepTurnsRemaining = turnsRemaining.value; + if (user.status.sleepTurnsRemaining <= 0) { this.cureStatus(StatusEffect.SLEEP); return false; } - this.triggerStatus(StatusEffect.SLEEP); - return true; + const bypassSleepHolder = new BooleanHolder(false); + applyMoveAttrs("BypassSleepAttr", this.pokemon, null, this.move.getMove(), bypassSleepHolder); + const cancel = !bypassSleepHolder.value; + this.triggerStatus(StatusEffect.SLEEP, cancel); + return cancel; } /** diff --git a/test/moves/sleep-talk.test.ts b/test/moves/sleep-talk.test.ts index 56dc7ba2121..ee639aaf1e8 100644 --- a/test/moves/sleep-talk.test.ts +++ b/test/moves/sleep-talk.test.ts @@ -97,9 +97,16 @@ describe("Moves - Sleep Talk", () => { game.move.select(MoveId.SLEEP_TALK); await game.toNextTurn(); + expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)); + }); - const feebas = game.field.getPlayerPokemon(); - expect(feebas.getStatStage(Stat.SPD)).toBe(1); - expect(feebas.getStatStage(Stat.DEF)).toBe(-1); + it("should apply secondary effects of a move", async () => { + game.override.moveset([MoveId.SLEEP_TALK, MoveId.DIG, MoveId.FLY, MoveId.WOOD_HAMMER]); // Dig and Fly are invalid moves, Wood Hammer should always be called + await game.classicMode.startBattle([SpeciesId.FEEBAS]); + + game.move.select(MoveId.SLEEP_TALK); + await game.toNextTurn(); + + expect(game.field.getPlayerPokemon().isFullHp()).toBeFalsy(); // Wood Hammer recoil effect should be applied }); }); diff --git a/test/moves/throat-chop.test.ts b/test/moves/throat-chop.test.ts index 335bfce2f40..b53b2c4c0e2 100644 --- a/test/moves/throat-chop.test.ts +++ b/test/moves/throat-chop.test.ts @@ -48,7 +48,6 @@ describe("Moves - Throat Chop", () => { await game.toNextTurn(); expect(player.trySelectMove(MoveId.GROWL)[0]).toBe(false); - game.move.select(MoveId.GROWL); await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); From 8681cec32ef57b3df26977c24b758680a15edfb6 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Tue, 19 Aug 2025 16:27:19 -0500 Subject: [PATCH 23/43] Finish unfinished docs --- src/data/battler-tags.ts | 5 +++-- src/field/pokemon.ts | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 1b7736dfc6d..d7f36605422 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -815,10 +815,11 @@ export class ConfusedTag extends SerializableBattlerTag { * Tick down the confusion duration and, if there are remaining turns, activate the confusion effect * * @remarks - * Rolls the + * Handles playing the confusion animation, displaying the message(s), rolling for self-damage, and cancelling the + * move phase if the user hurts itself. * @param pokemon - The pokemon with this tag * @param lapseType - The lapse type - * @returns + * @returns `true` if the tag should remain active (i.e. `turnCount` > 0) */ lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { // Duration is only ticked down for PRE_MOVE lapse type diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index b66c7339e4d..bc592e37da5 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -3134,9 +3134,9 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { /** * Attempt to select the move at the move index. - * @param moveIndex - * @param ignorePp - * @returns + * @param moveIndex - The index of the move to select + * @param ignorePp - Whether to ignore PP when checking if the move is usable (defaults to false) + * @returns A tuple containing a boolean indicating if the move can be selected, and a string with the reason if it cannot be selected */ public trySelectMove(moveIndex: number, ignorePp?: boolean): [boolean, string] { const move = this.getMoveset().length > moveIndex ? this.getMoveset()[moveIndex] : null; From bb99bebc7545d0543cd178390737a1c4846e8472 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Tue, 19 Aug 2025 16:44:38 -0500 Subject: [PATCH 24/43] Ensure move restrictions are only checked for players --- src/data/moves/pokemon-move.ts | 7 ++++--- test/challenges/hardcore.test.ts | 2 ++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/data/moves/pokemon-move.ts b/src/data/moves/pokemon-move.ts index c47445b9949..4adc7453f20 100644 --- a/src/data/moves/pokemon-move.ts +++ b/src/data/moves/pokemon-move.ts @@ -70,7 +70,7 @@ export class PokemonMove { } const usability = new BooleanHolder(true); - if (applyChallenges(ChallengeType.POKEMON_MOVE, this.moveId, usability) && !usability.value) { + if (pokemon.isPlayer() && applyChallenges(ChallengeType.POKEMON_MOVE, this.moveId, usability) && !usability.value) { return [false, i18next.t("battle:moveCannotUseChallenge", { moveName: move.name })]; } @@ -82,8 +82,9 @@ export class PokemonMove { } /** - * Determine whether the move can be selected by the pokemon - * + * Determine whether the move can be selected by the pokemon based on its own requirements + * @remarks + * Does not check for PP, moves blocked by challenges, or unimplemented moves, all of which are handled by {@linkcode isUsable} * @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 */ diff --git a/test/challenges/hardcore.test.ts b/test/challenges/hardcore.test.ts index dd12bba1fce..99358223820 100644 --- a/test/challenges/hardcore.test.ts +++ b/test/challenges/hardcore.test.ts @@ -43,8 +43,10 @@ describe("Challenges - Hardcore", () => { await game.challengeMode.startBattle([SpeciesId.NUZLEAF]); const player = game.field.getPlayerPokemon(); + const enemy = game.field.getEnemyPokemon(); const revBlessing = player.getMoveset()[0]; expect(revBlessing.isUsable(player)[0]).toBe(false); + expect(revBlessing.isUsable(enemy)[0]).toBe(true); game.move.select(MoveId.REVIVAL_BLESSING); await game.toEndOfTurn(); From e55fbc8f8e0cae621ff328205e0676cfd91991c5 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Tue, 19 Aug 2025 17:06:52 -0500 Subject: [PATCH 25/43] Adjust pollen puff condition, fix docs on `isOpponent` --- src/data/moves/move.ts | 2 +- src/field/pokemon.ts | 2 +- src/phases/move-phase.ts | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 850c751772b..f118506dcea 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -10677,7 +10677,7 @@ export function initMoves() { .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), + (user, target) => target.isOpponent(user) || !!target.getTag(BattlerTagType.HEAL_BLOCK), 2 ), new AttackMove(MoveId.ANCHOR_SHOT, PokemonType.STEEL, MoveCategory.PHYSICAL, 80, 100, 20, 100, 0, 7) diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index bc592e37da5..c0e3d85a2eb 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -3227,7 +3227,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { /** * Check whether the specified Pokémon is an opponent * @param target - The {@linkcode Pokemon} to compare against - * @returns `true` if the two pokemon are allies, `false` otherwise + * @returns `true` if the two pokemon are opponents, `false` otherwise */ public isOpponent(target: Pokemon): boolean { return this.isPlayer() !== target.isPlayer(); diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index fc42c6fde39..5d7aee6a03f 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -689,7 +689,6 @@ export class MovePhase extends PokemonPhase { } protected usePP(): void { - // If this is the release turn of a charging move, PP shall not be deducted if (!isIgnorePP(this.useMode)) { const move = this.move; // "commit" to using the move, deducting PP. From d296b1cdb88f3afdba96986dd60b7e9b80ce2d8e Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Tue, 19 Aug 2025 17:15:01 -0500 Subject: [PATCH 26/43] Fix failAgainstFinalBossCondition --- src/data/moves/move-condition.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/data/moves/move-condition.ts b/src/data/moves/move-condition.ts index 9cf34142107..1234a037d0e 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 { TrappedTag } from "#data/battler-tags"; @@ -144,8 +146,9 @@ export const failIfTargetNotAttackingCondition = new MoveCondition((_user, targe 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)) + return !( + target.isEnemy() && + (gameMode.isBattleClassicFinalBoss(currentWave) || gameMode.isEndlessMinorBoss(currentWave)) ); }); From 67e9154f498e6e943cc53a393fdc0a0927f9b0c6 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Tue, 19 Aug 2025 17:30:56 -0500 Subject: [PATCH 27/43] Fix dig test --- src/phases/move-phase.ts | 6 +++--- test/moves/dig.test.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 5d7aee6a03f..f6111c861a2 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -118,7 +118,7 @@ export class MovePhase extends PokemonPhase { * 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) + * 5. Sky battle (see {@linkcode https://github.com/pagefaultgames/pokerogue/pull/5983 | PR#5983}) * 6. Truant * 7. Focus Punch's loss of focus * 8. Flinch @@ -165,7 +165,7 @@ export class MovePhase extends PokemonPhase { * 2. Move prevented by heal block * 3. Move prevented by throat chop * 4. Gravity - * 5. sky battle (unused in Pokerogue) + * 5. Sky Battle (See {@link https://github.com/pagefaultgames/pokerogue/pull/5983 | PR#5983}) */ protected followUpMoveFirstFailureCheck(): boolean { return ( @@ -304,7 +304,7 @@ export class MovePhase extends PokemonPhase { const user = this.pokemon; - // Removing gigaton hammer's two flags *always* happens first + // Removing Glaive Rush's two flags *always* happens first user.removeTag(BattlerTagType.ALWAYS_GET_HIT); user.removeTag(BattlerTagType.RECEIVE_DOUBLE_DAMAGE); console.log(MoveId[this.move.moveId], enumValueToKey(MoveUseMode, this.useMode)); diff --git a/test/moves/dig.test.ts b/test/moves/dig.test.ts index fcc593b75da..5a261262bb9 100644 --- a/test/moves/dig.test.ts +++ b/test/moves/dig.test.ts @@ -90,7 +90,7 @@ describe("Moves - Dig", () => { expect(enemyPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS); }); - it("should not expend PP when the attack phase is cancelled", async () => { + it("should expend PP when the attack phase is cancelled", async () => { game.override.enemyAbility(AbilityId.NO_GUARD).enemyMoveset(MoveId.SPORE); await game.classicMode.startBattle([SpeciesId.MAGIKARP]); From f24c8532f01092db18376d66f9dd8b45a7779c48 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Tue, 19 Aug 2025 17:42:26 -0500 Subject: [PATCH 28/43] Adjust dive's test --- src/data/moves/pokemon-move.ts | 2 +- test/moves/dive.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/data/moves/pokemon-move.ts b/src/data/moves/pokemon-move.ts index 4adc7453f20..edaebe605a7 100644 --- a/src/data/moves/pokemon-move.ts +++ b/src/data/moves/pokemon-move.ts @@ -88,7 +88,7 @@ export class PokemonMove { * @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] { + public isSelectable(pokemon: Pokemon): [selectable: boolean, preventionText: string] { return pokemon.isMoveSelectable(this.moveId); } diff --git a/test/moves/dive.test.ts b/test/moves/dive.test.ts index 6464cb110b4..564b1886e17 100644 --- a/test/moves/dive.test.ts +++ b/test/moves/dive.test.ts @@ -74,7 +74,7 @@ describe("Moves - Dive", () => { expect(enemyPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS); }); - it("should not expend PP when the attack phase is cancelled", async () => { + it("should expend PP when the attack phase is cancelled", async () => { game.override.enemyAbility(AbilityId.NO_GUARD).enemyMoveset(MoveId.SPORE); await game.classicMode.startBattle([SpeciesId.MAGIKARP]); @@ -88,7 +88,7 @@ describe("Moves - Dive", () => { expect(playerPokemon.status?.effect).toBe(StatusEffect.SLEEP); const playerDive = playerPokemon.getMoveset().find(mv => mv && mv.moveId === MoveId.DIVE); - expect(playerDive?.ppUsed).toBe(0); + expect(playerDive?.ppUsed).toBe(1); }); it("should trigger on-contact post-defend ability effects", async () => { From f7cb01011c8c5f2f8b4e6b154559d7176a17421f Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Tue, 19 Aug 2025 18:01:29 -0500 Subject: [PATCH 29/43] Fix missing break in applyConditions --- src/data/moves/move.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index f118506dcea..1641788b03d 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -836,6 +836,7 @@ export abstract class Move implements Localizable { switch (sequence) { case -1: conditionsArray = [...this.conditionsSeq2, ...this.conditionsSeq3, ...this.conditions]; + break; case 2: conditionsArray = this.conditionsSeq2; break; From bbd6eb95798e3e9632562088c39f91ca7a945371 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Tue, 19 Aug 2025 18:04:45 -0500 Subject: [PATCH 30/43] Fix getBattlerIndex for enemyPokemon --- src/field/pokemon.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index c0e3d85a2eb..a1e542fdb02 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -6941,7 +6941,11 @@ export class EnemyPokemon extends Pokemon { return globalScene.getEnemyField().indexOf(this); } - public getBattlerIndex(): Exclude { + public getBattlerIndex(): BattlerIndex { + const fieldIndex = this.getFieldIndex(); + if (fieldIndex === -1) { + return BattlerIndex.ATTACKER; + } return BattlerIndex.ENEMY + this.getFieldIndex(); } From 514f09c53679721113f0adad4b16fd0fcb404e3b Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Tue, 19 Aug 2025 18:30:49 -0500 Subject: [PATCH 31/43] Adjust type hint test to not rely on teleport --- test/ui/type-hints.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/ui/type-hints.test.ts b/test/ui/type-hints.test.ts index b5fe0d9585a..c637bfcd724 100644 --- a/test/ui/type-hints.test.ts +++ b/test/ui/type-hints.test.ts @@ -88,7 +88,7 @@ describe("UI - Type Hints", () => { game.override .enemySpecies(SpeciesId.ABRA) .moveset([MoveId.SPLASH, MoveId.SHADOW_BALL, MoveId.SOAK]) - .enemyMoveset([MoveId.SPLASH, MoveId.TELEPORT]) + .enemyMoveset([MoveId.SPLASH, MoveId.MEMENTO]) .battleStyle("double"); await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]); @@ -97,7 +97,7 @@ describe("UI - Type Hints", () => { game.move.select(MoveId.SOAK, 1); await game.move.selectEnemyMove(MoveId.SPLASH); - await game.move.selectEnemyMove(MoveId.TELEPORT); + await game.move.selectEnemyMove(MoveId.MEMENTO); await game.toNextTurn(); game.onNextPrompt("CommandPhase", UiMode.COMMAND, () => { From 8b8567739d681a7fc2d2346d19bb134c4985400c Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Tue, 19 Aug 2025 18:37:10 -0500 Subject: [PATCH 32/43] Minor adjustments from code review Co-authored-by: Bertie690 <136088738+Bertie690@users.noreply.github.com> --- src/data/moves/move-condition.ts | 10 ++++------ src/data/moves/move.ts | 10 +++++----- src/enums/battler-tag-lapse-type.ts | 2 +- test/moves/dig.test.ts | 2 +- 4 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/data/moves/move-condition.ts b/src/data/moves/move-condition.ts index 1234a037d0e..e6db80277d7 100644 --- a/src/data/moves/move-condition.ts +++ b/src/data/moves/move-condition.ts @@ -47,9 +47,7 @@ export class MoveCondition { export class FirstMoveCondition extends MoveCondition { constructor() { - super(user => { - return user.tempSummonData.waveTurnCount === 1; - }); + super(user => user.tempSummonData.waveTurnCount === 1); } // TODO: Update AI move selection logic to not require this method at all @@ -212,11 +210,11 @@ class CounterAttackConditon extends MoveCondition { } /** Condition check for counterattacks that proc againt physical moves */ -export const counterAttackCondition_Physical = new CounterAttackConditon(MoveCategory.PHYSICAL); +export const counterAttackConditionPhysical = new CounterAttackConditon(MoveCategory.PHYSICAL); /** Condition check for counterattacks that proc against special moves*/ -export const counterAttackCondition_Special = new CounterAttackConditon(MoveCategory.SPECIAL); +export const counterAttackConditionSpecial = new CounterAttackConditon(MoveCategory.SPECIAL); /** Condition check for counterattacks that proc against moves regardless of damage type */ -export const counterAttackCondition_Both = new CounterAttackConditon(); +export const counterAttackConditionBoth = new CounterAttackConditon(); /** * A restriction that prevents a move from being selected diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 1641788b03d..4be5c8a04f5 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -82,7 +82,7 @@ import { } from "#modifiers/modifier"; import { applyMoveAttrs } from "#moves/apply-attrs"; import { invalidAssistMoves, invalidCopycatMoves, invalidMetronomeMoves, invalidMirrorMoveMoves, invalidSketchMoves, invalidSleepTalkMoves } from "#moves/invalid-moves"; -import { ConsecutiveUseRestriction, counterAttackCondition_Both, counterAttackCondition_Physical, counterAttackCondition_Special, failAgainstFinalBossCondition, FailIfInsufficientHpCondition, failIfTargetNotAttackingCondition, failTeleportCondition, FirstMoveCondition, GravityUseRestriction, lastResortCondition, MoveCondition, MoveRestriction, UpperHandCondition } from "#moves/move-condition"; +import { ConsecutiveUseRestriction, counterAttackConditionBoth, counterAttackConditionPhysical, counterAttackConditionSpecial, failAgainstFinalBossCondition, FailIfInsufficientHpCondition, failIfTargetNotAttackingCondition, failTeleportCondition, FirstMoveCondition, GravityUseRestriction, lastResortCondition, MoveCondition, MoveRestriction, UpperHandCondition } from "#moves/move-condition"; import { frenzyMissFunc, getCounterAttackTarget, getMoveTargets } from "#moves/move-utils"; import { PokemonMove } from "#moves/pokemon-move"; import { MovePhase } from "#phases/move-phase"; @@ -8717,7 +8717,7 @@ export function initMoves() { new AttackMove(MoveId.COUNTER, PokemonType.FIGHTING, MoveCategory.PHYSICAL, -1, 100, 20, -1, -5, 1) .attr(CounterDamageAttr, 2, MoveCategory.PHYSICAL) .attr(CounterRedirectAttr, MoveCategory.PHYSICAL) - .condition(counterAttackCondition_Physical, 3) + .condition(counterAttackConditionPhysical, 3) .target(MoveTarget.ATTACKER), new AttackMove(MoveId.SEISMIC_TOSS, PokemonType.FIGHTING, MoveCategory.PHYSICAL, -1, 100, 20, -1, 0, 1) .attr(LevelDamageAttr), @@ -9295,7 +9295,7 @@ export function initMoves() { new AttackMove(MoveId.MIRROR_COAT, PokemonType.PSYCHIC, MoveCategory.SPECIAL, -1, 100, 20, -1, -5, 2) .attr(CounterDamageAttr, 2, MoveCategory.SPECIAL) .attr(CounterRedirectAttr, MoveCategory.SPECIAL) - .condition(counterAttackCondition_Special, 3) + .condition(counterAttackConditionSpecial, 3) .target(MoveTarget.ATTACKER), new StatusMove(MoveId.PSYCH_UP, PokemonType.NORMAL, -1, 10, -1, 0, 2) .ignoresSubstitute() @@ -9718,7 +9718,7 @@ export function initMoves() { new AttackMove(MoveId.METAL_BURST, PokemonType.STEEL, MoveCategory.PHYSICAL, -1, 100, 10, -1, 0, 4) .attr(CounterDamageAttr, 1.5) .attr(CounterRedirectAttr) - .condition(counterAttackCondition_Both, 3) + .condition(counterAttackConditionBoth, 3) .makesContact(false) .target(MoveTarget.ATTACKER), new AttackMove(MoveId.U_TURN, PokemonType.BUG, MoveCategory.PHYSICAL, 70, 100, 20, -1, 0, 4) @@ -11530,7 +11530,7 @@ export function initMoves() { new AttackMove(MoveId.COMEUPPANCE, PokemonType.DARK, MoveCategory.PHYSICAL, -1, 100, 10, -1, 0, 9) .attr(CounterDamageAttr, 1.5) .attr(CounterRedirectAttr) - .condition(counterAttackCondition_Both, 3) + .condition(counterAttackConditionBoth, 3) .target(MoveTarget.ATTACKER), new AttackMove(MoveId.AQUA_CUTTER, PokemonType.WATER, MoveCategory.PHYSICAL, 70, 100, 20, -1, 0, 9) .attr(HighCritAttr) diff --git a/src/enums/battler-tag-lapse-type.ts b/src/enums/battler-tag-lapse-type.ts index f4fbcdda9a6..0735f1040a6 100644 --- a/src/enums/battler-tag-lapse-type.ts +++ b/src/enums/battler-tag-lapse-type.ts @@ -8,7 +8,7 @@ export enum BattlerTagLapseType { // TODO: This is unused... FAINT, /** - * Tag activate before the holder uses a non-virtual move, after passing the first failure check sequence during the + * Tag activates before the holder uses a non-virtual move, after passing the first failure check sequence during the * move phase. * @see MoveUseMode for more information */ diff --git a/test/moves/dig.test.ts b/test/moves/dig.test.ts index 5a261262bb9..d0693250416 100644 --- a/test/moves/dig.test.ts +++ b/test/moves/dig.test.ts @@ -104,7 +104,7 @@ describe("Moves - Dig", () => { expect(playerPokemon.status?.effect).toBe(StatusEffect.SLEEP); const playerDig = playerPokemon.getMoveset().find(mv => mv && mv.moveId === MoveId.DIG); - expect(playerDig?.ppUsed).toBe(0); + expect(playerDig?.ppUsed).toBe(1); }); it("should cause the user to take double damage from Earthquake", async () => { From 4b62dc8b564a8e3e7d1534a451e0afa43cba9116 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Tue, 19 Aug 2025 19:05:12 -0500 Subject: [PATCH 33/43] Add tests for teleport --- test/moves/teleport.test.ts | 102 ++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 test/moves/teleport.test.ts diff --git a/test/moves/teleport.test.ts b/test/moves/teleport.test.ts new file mode 100644 index 00000000000..856f0cd01ec --- /dev/null +++ b/test/moves/teleport.test.ts @@ -0,0 +1,102 @@ +import { AbilityId } from "#enums/ability-id"; +import { BattlerIndex } from "#enums/battler-index"; +import { MoveId } from "#enums/move-id"; +import { MoveResult } from "#enums/move-result"; +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 - Teleport", () => { + 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.SPLASH) + .enemySpecies(SpeciesId.MAGIKARP) + .enemyAbility(AbilityId.BALL_FETCH) + .enemyMoveset([MoveId.SPLASH, MoveId.TELEPORT, MoveId.MEMENTO]) + .startingLevel(100) + .enemyLevel(100); + }); + + describe("used by a wild pokemon", () => { + it("should fail in a double battle", async () => { + game.override.battleStyle("double"); + await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.FEEBAS]); + + const [enemy1, enemy2] = game.scene.getEnemyField(); + + game.move.select(MoveId.SPLASH); + game.move.select(MoveId.SPLASH, 1); + await game.move.selectEnemyMove(MoveId.TELEPORT); + await game.move.selectEnemyMove(MoveId.MEMENTO, 1); + await game.toEndOfTurn(); + expect(enemy1.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + expect(enemy2.isOnField()).toBe(false); + + // Fail teleport even if the other enemy faints + game.move.select(MoveId.SPLASH); + game.move.select(MoveId.SPLASH, 1); + await game.move.selectEnemyMove(MoveId.TELEPORT); + await game.toEndOfTurn(); + expect(enemy1.getLastXMoves()[0].result, "should fail even if last remaining pokemon").toBe(MoveResult.FAIL); + }); + + it("should fail if used by a wild pokemon under a trapping effect", async () => { + await game.classicMode.startBattle([SpeciesId.FEEBAS]); + + const enemy = game.field.getEnemyPokemon(); + + game.move.use(MoveId.FAIRY_LOCK); + + await game.move.selectEnemyMove(MoveId.TELEPORT); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.toEndOfTurn(); + expect(enemy.getLastXMoves()[0].result, "should fail while trapped").toBe(MoveResult.FAIL); + }); + }); + + it("should succeed if used by a trapped wild pokemon that is ghost type", async () => { + game.override.enemySpecies(SpeciesId.GASTLY); + await game.classicMode.startBattle([SpeciesId.FEEBAS]); + + const enemy = game.field.getEnemyPokemon(); + + game.move.use(MoveId.FAIRY_LOCK); + + await game.move.selectEnemyMove(MoveId.TELEPORT); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.phaseInterceptor.to("BerryPhase", false); + expect(enemy.isOnField(), "should not be on the field").toBe(false); + }); + + it("should succeed if used by a trapped wild pokemon that has run away", async () => { + game.override.enemyAbility(AbilityId.RUN_AWAY); + await game.classicMode.startBattle([SpeciesId.FEEBAS]); + + const enemy = game.field.getEnemyPokemon(); + + game.move.use(MoveId.FAIRY_LOCK); + + await game.move.selectEnemyMove(MoveId.TELEPORT); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.phaseInterceptor.to("BerryPhase", false); + expect(enemy.isOnField(), "should not be on the field").toBe(false); + }); +}); From 2eed38dc5567141552e7cf1343975db2fddee760 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Tue, 19 Aug 2025 19:10:31 -0500 Subject: [PATCH 34/43] Minor adjustments from code review Co-authored-by: Bertie690 <136088738+Bertie690@users.noreply.github.com> --- src/data/moves/pokemon-move.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/data/moves/pokemon-move.ts b/src/data/moves/pokemon-move.ts index edaebe605a7..086024f8fb9 100644 --- a/src/data/moves/pokemon-move.ts +++ b/src/data/moves/pokemon-move.ts @@ -47,9 +47,9 @@ export class PokemonMove { * @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 forSelection - Whether this is being checked for move selection; default `false` - * @returns Whether this {@linkcode PokemonMove} can be selected by this Pokemon. + * @returns A tuple containing a boolean indicating whether the move can be selected, and a string with the reason if it cannot */ - public isUsable(pokemon: Pokemon, ignorePp = false, forSelection = false): [boolean, string] { + public isUsable(pokemon: Pokemon, ignorePp = false, forSelection = false): [usable: boolean, preventionText: string] { const move = this.getMove(); const moveName = move.name; @@ -86,7 +86,7 @@ export class PokemonMove { * @remarks * Does not check for PP, moves blocked by challenges, or unimplemented moves, all of which are handled by {@linkcode isUsable} * @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 + * @returns An tuple containing a boolean indicating whether the move can be selected, and a string with the reason if it cannot */ public isSelectable(pokemon: Pokemon): [selectable: boolean, preventionText: string] { return pokemon.isMoveSelectable(this.moveId); From 9e49162012ab7c7f81115c5e5f9dc92016e3f594 Mon Sep 17 00:00:00 2001 From: NightKev <34855794+DayKev@users.noreply.github.com> Date: Tue, 19 Aug 2025 17:23:11 -0700 Subject: [PATCH 35/43] PR review changes Fix type hints test name Update Dig/Dive test name Separate TSDoc imports in `pokemon-utils.ts` Add missing `@returns` in `move-phase.ts` Fix comment typos Separate TSDoc imports in `move-phase.ts` Add return hints to `trySelectMove` Minor formatting Remove duplicate `.affectedByGravity()` on Telekinesis Fix docs for `checkRestrictions` Manually format method definition Fix comment spacing Fix variable naming --- src/data/moves/move-condition.ts | 6 +-- src/data/moves/move.ts | 63 +++++++++++++++----------------- src/field/pokemon.ts | 2 +- src/phases/move-phase.ts | 16 +++++--- src/utils/pokemon-utils.ts | 6 ++- test/moves/dig.test.ts | 2 +- test/moves/dive.test.ts | 2 +- test/ui/type-hints.test.ts | 2 +- 8 files changed, 50 insertions(+), 49 deletions(-) diff --git a/src/data/moves/move-condition.ts b/src/data/moves/move-condition.ts index e6db80277d7..37ac36b0e42 100644 --- a/src/data/moves/move-condition.ts +++ b/src/data/moves/move-condition.ts @@ -156,7 +156,7 @@ export const failAgainstFinalBossCondition = new MoveCondition((_user, target) = * a high-priority attack (after factoring in priority-boosting effects) and * hasn't moved yet this turn. */ -export const UpperHandCondition = new MoveCondition((_user, target) => { +export const upperHandCondition = new MoveCondition((_user, target) => { const targetCommand = globalScene.currentBattle.turnCommands[target.getBattlerIndex()]; return ( targetCommand?.command === Command.FIGHT && @@ -252,13 +252,13 @@ export class MoveRestriction { * @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( +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( +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 4be5c8a04f5..aa4ecf13c1e 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -82,7 +82,7 @@ import { } from "#modifiers/modifier"; import { applyMoveAttrs } from "#moves/apply-attrs"; import { invalidAssistMoves, invalidCopycatMoves, invalidMetronomeMoves, invalidMirrorMoveMoves, invalidSketchMoves, invalidSleepTalkMoves } from "#moves/invalid-moves"; -import { ConsecutiveUseRestriction, counterAttackConditionBoth, counterAttackConditionPhysical, counterAttackConditionSpecial, failAgainstFinalBossCondition, FailIfInsufficientHpCondition, failIfTargetNotAttackingCondition, failTeleportCondition, FirstMoveCondition, GravityUseRestriction, lastResortCondition, MoveCondition, MoveRestriction, UpperHandCondition } from "#moves/move-condition"; +import { consecutiveUseRestriction, counterAttackConditionBoth, counterAttackConditionPhysical, counterAttackConditionSpecial, failAgainstFinalBossCondition, FailIfInsufficientHpCondition, failIfTargetNotAttackingCondition, failTeleportCondition, FirstMoveCondition, gravityUseRestriction, lastResortCondition, MoveCondition, MoveRestriction, upperHandCondition } from "#moves/move-condition"; import { frenzyMissFunc, getCounterAttackTarget, getMoveTargets } from "#moves/move-utils"; import { PokemonMove } from "#moves/pokemon-move"; import { MovePhase } from "#phases/move-phase"; @@ -172,8 +172,14 @@ export abstract class Move implements Localizable { * @see {@link https://www.smogon.com/forums/threads/sword-shield-battle-mechanics-research.3655528/page-54#post-8548957} */ private conditionsSeq3: MoveCondition[] = []; +<<<<<<< HEAD /** Conditions that must be false for a move to be able to be selected. * +======= + /** + * Conditions that must be false for a move to be able to be selected. + * +>>>>>>> 02f941b3a05 (PR review changes) * @remarks Different from {@linkcode conditions}, which is checked when the move is invoked */ private restrictions: MoveRestriction[] = []; @@ -452,7 +458,7 @@ export abstract class Move implements Localizable { * @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 @@ -476,7 +482,12 @@ export abstract class Move implements Localizable { * is false * @returns `this` for method chaining */ - public restriction(restriction: T, i18nkey?: string, alsoCondition: typeof restriction extends MoveRestriction ? false : boolean = false, conditionSeq = 4): this { + public restriction( + restriction: T, + i18nkey?: string, + alsoCondition: typeof restriction extends MoveRestriction ? false : boolean = false, + conditionSeq = 4, + ): this { if (typeof restriction === "function") { this.restrictions.push(new MoveRestriction(restriction)); if (alsoCondition) { @@ -700,7 +711,7 @@ export abstract class Move implements Localizable { } /** - * Sets the {@linkcode MoveFlags.GRAVITY} flag for the calling Move and adds {@linkcode GravityUseRestriction} to the + * Sets the {@linkcode MoveFlags.GRAVITY} flag for the calling Move and adds {@linkcode gravityUseRestriction} to the * move's restrictions. * * @returns `this` @@ -713,7 +724,7 @@ export abstract class Move implements Localizable { */ affectedByGravity(): this { this.setFlag(MoveFlags.GRAVITY, true); - this.restrictions.push(GravityUseRestriction); + this.restrictions.push(gravityUseRestriction); return this; } @@ -859,9 +870,9 @@ export abstract class Move implements Localizable { * * @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. + * with the reason for the restriction, otherwise, `true` and the empty string. */ - public checkRestrictions(user: Pokemon): [boolean, string] { + public checkRestrictions(user: Pokemon): [isUsable: boolean, restrictionMessage: string] { for (const restriction of this.restrictions) { if (restriction.apply(user, this)) { return [false, restriction.getSelectionDeniedText(user, this)]; @@ -10034,7 +10045,6 @@ export function initMoves() { .condition((_user, target, _move) => target.getTag(BattlerTagType.INGRAIN) == null && target.getTag(BattlerTagType.IGNORE_FLYING) == null) .attr(AddBattlerTagAttr, BattlerTagType.TELEKINESIS, false, true, 3) .attr(AddBattlerTagAttr, BattlerTagType.FLOATING, false, true, 3) - .affectedByGravity() .reflectable(), new StatusMove(MoveId.MAGIC_ROOM, PokemonType.PSYCHIC, -1, 10, -1, 0, 5) .ignoresProtect() @@ -10677,10 +10687,7 @@ export function initMoves() { .attr(HealOnAllyAttr, 0.5, true, false) .ballBombMove() // Fail if used against an ally that is affected by heal block, during the second failure check - .condition( - (user, target) => target.isOpponent(user) || !!target.getTag(BattlerTagType.HEAL_BLOCK), - 2 - ), + .condition((user, target) => target.isOpponent(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) @@ -10693,11 +10700,8 @@ 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( - // 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 - ) + // Pass `true` to `ForDefend` as it should fail if the user is terastallized to a type that is not FIRE + .condition(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) => { @@ -10798,10 +10802,7 @@ export function initMoves() { .attr(AddBattlerTagHeaderAttr, BattlerTagType.SHELL_TRAP) .target(MoveTarget.ALL_NEAR_ENEMIES) // Fails if the user was not hit by a physical attack during the turn - .condition( - user => user.getTag(ShellTrapTag)?.activated === true, - 3 - ), + .condition(user => user.getTag(ShellTrapTag)?.activated === true, 3), new AttackMove(MoveId.FLEUR_CANNON, PokemonType.FAIRY, MoveCategory.SPECIAL, 130, 90, 5, -1, 0, 7) .attr(StatStageChangeAttr, [ Stat.SPATK ], -2, true), new AttackMove(MoveId.PSYCHIC_FANGS, PokemonType.PSYCHIC, MoveCategory.PHYSICAL, 85, 100, 10, -1, 0, 7) @@ -10949,11 +10950,8 @@ export function initMoves() { 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, 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 - ), + // fails if the user is currently trapped specifically from no retreat + .condition(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) @@ -11515,18 +11513,15 @@ export function initMoves() { .slicingMove() .triageMove(), new AttackMove(MoveId.DOUBLE_SHOCK, PokemonType.ELECTRIC, MoveCategory.PHYSICAL, 120, 100, 5, -1, 0, 9) - .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 - ) + // Pass `true` to `isOfType` to fail if the user is terastallized to a type other than ELECTRIC + .condition(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) })); }), new AttackMove(MoveId.GIGATON_HAMMER, PokemonType.STEEL, MoveCategory.PHYSICAL, 160, 100, 5, -1, 0, 9) .makesContact(false) - .restriction(ConsecutiveUseRestriction), + .restriction(consecutiveUseRestriction), new AttackMove(MoveId.COMEUPPANCE, PokemonType.DARK, MoveCategory.PHYSICAL, -1, 100, 10, -1, 0, 9) .attr(CounterDamageAttr, 1.5) .attr(CounterRedirectAttr) @@ -11552,7 +11547,7 @@ export function initMoves() { .attr(ConfuseAttr) .makesContact(false), new AttackMove(MoveId.BLOOD_MOON, PokemonType.NORMAL, MoveCategory.SPECIAL, 140, 100, 5, -1, 0, 9) - .restriction(ConsecutiveUseRestriction), + .restriction(consecutiveUseRestriction), new AttackMove(MoveId.MATCHA_GOTCHA, PokemonType.GRASS, MoveCategory.SPECIAL, 80, 90, 15, 20, 0, 9) .attr(HitHealAttr) .attr(HealStatusEffectAttr, true, StatusEffect.FREEZE) @@ -11613,7 +11608,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(UpperHandCondition, 3), + .condition(upperHandCondition, 3), 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 a1e542fdb02..7ea3e71da85 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -3138,7 +3138,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * @param ignorePp - Whether to ignore PP when checking if the move is usable (defaults to false) * @returns A tuple containing a boolean indicating if the move can be selected, and a string with the reason if it cannot be selected */ - public trySelectMove(moveIndex: number, ignorePp?: boolean): [boolean, string] { + public trySelectMove(moveIndex: number, ignorePp?: boolean): [isUsable: boolean, failureMessage: string] { const move = this.getMoveset().length > moveIndex ? this.getMoveset()[moveIndex] : null; return move?.isUsable(this, ignorePp, true) ?? [false, ""]; } diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index f6111c861a2..5265820447f 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -1,3 +1,7 @@ +// biome-ignore-start lint/correctness/noUnusedImports: Used in a tsdoc comment +import type { Move, PreUseInterruptAttr } from "#types/move-types"; +// biome-ignore-end lint/correctness/noUnusedImports: Used in a tsdoc comment + import { applyAbAttrs } from "#abilities/apply-ab-attrs"; import { MOVE_COLOR } from "#app/constants/colors"; import { globalScene } from "#app/global-scene"; @@ -28,8 +32,6 @@ import type { Pokemon } from "#field/pokemon"; import { applyMoveAttrs } from "#moves/apply-attrs"; import { frenzyMissFunc } from "#moves/move-utils"; import type { PokemonMove } from "#moves/pokemon-move"; -// biome-ignore lint/correctness/noUnusedImports: Used in a tsdoc comment -import type { Move, PreUseInterruptAttr } from "#types/move-types"; import { applyChallenges } from "#utils/challenge-utils"; import { BooleanHolder, NumberHolder } from "#utils/common"; import { enumValueToKey } from "#utils/enums"; @@ -205,7 +207,7 @@ export class MovePhase extends PokemonPhase { * * @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) + * Notably, this failure check only includes failure conditions intrinsic to the move itself, other than Powder (which marks the end of this failure check) * * * - Pollen puff used on an ally that is under effect of heal block @@ -512,13 +514,14 @@ export class MovePhase extends PokemonPhase { * - 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) + * @returns Whether the move was cancelled due to the pokemon being frozen */ protected checkFreeze(): boolean { if (this.pokemon.status?.effect !== StatusEffect.FREEZE) { return false; } - // For some reason, dancer will immediately its user + // For some reason, dancer will immediately thaw its user if (this.useMode === MoveUseMode.INDIRECT) { this.pokemon.resetStatus(false); return false; @@ -644,6 +647,7 @@ export class MovePhase extends PokemonPhase { * 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 + * @returns Whether the move was cancelled due to a `BattlerTag` effect */ private checkTagCancel(tag: BattlerTagType): boolean { this.pokemon.lapseTag(tag, BattlerTagLapseType.PRE_MOVE); @@ -652,7 +656,7 @@ export class MovePhase extends PokemonPhase { /** * Handle move failures due to Gravity, cancelling the move and showing the failure text - * @returns - Whether the move was cancelled due to Gravity + * @returns Whether the move was cancelled due to Gravity */ private checkGravity(): boolean { const move = this.move.getMove(); @@ -721,7 +725,7 @@ export class MovePhase extends PokemonPhase { /* At this point, delayed moves (future sight, wish, doom desire) are issued, and if they occur, are Then, combined pledge moves are checked for. Interestingly, the "wasMoveEffective" flag is set to false if the delay occurs - In either case, the phase should end here without proceeding + In either case, the phase should end here without proceeding */ const move = this.move.getMove(); diff --git a/src/utils/pokemon-utils.ts b/src/utils/pokemon-utils.ts index 8bb7d76a028..454bd93a54c 100644 --- a/src/utils/pokemon-utils.ts +++ b/src/utils/pokemon-utils.ts @@ -1,11 +1,13 @@ +// biome-ignore-start lint/correctness/noUnusedImports: Used in a TSDoc comment +import type { Pokemon } from "#field/pokemon"; +// biome-ignore-end lint/correctness/noUnusedImports: Used in a TSDoc comment + import { globalScene } from "#app/global-scene"; import { POKERUS_STARTER_COUNT, speciesStarterCosts } from "#balance/starters"; import { allSpecies } from "#data/data-lists"; import type { PokemonSpecies, PokemonSpeciesForm } from "#data/pokemon-species"; import { BattlerIndex } from "#enums/battler-index"; import type { SpeciesId } from "#enums/species-id"; -// biome-ignore lint/correctness/noUnusedImports: Used in a TSDoc comment -import type { Pokemon } from "#field/pokemon"; import { randSeedItem } from "./common"; /** diff --git a/test/moves/dig.test.ts b/test/moves/dig.test.ts index d0693250416..28cbf2882a6 100644 --- a/test/moves/dig.test.ts +++ b/test/moves/dig.test.ts @@ -90,7 +90,7 @@ describe("Moves - Dig", () => { expect(enemyPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS); }); - it("should expend PP when the attack phase is cancelled", async () => { + it("should expend PP when the attack phase is cancelled by sleep", async () => { game.override.enemyAbility(AbilityId.NO_GUARD).enemyMoveset(MoveId.SPORE); await game.classicMode.startBattle([SpeciesId.MAGIKARP]); diff --git a/test/moves/dive.test.ts b/test/moves/dive.test.ts index 564b1886e17..1ce07803f00 100644 --- a/test/moves/dive.test.ts +++ b/test/moves/dive.test.ts @@ -74,7 +74,7 @@ describe("Moves - Dive", () => { expect(enemyPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS); }); - it("should expend PP when the attack phase is cancelled", async () => { + it("should expend PP when the attack phase is cancelled by sleep", async () => { game.override.enemyAbility(AbilityId.NO_GUARD).enemyMoveset(MoveId.SPORE); await game.classicMode.startBattle([SpeciesId.MAGIKARP]); diff --git a/test/ui/type-hints.test.ts b/test/ui/type-hints.test.ts index c637bfcd724..9f1c030e3a2 100644 --- a/test/ui/type-hints.test.ts +++ b/test/ui/type-hints.test.ts @@ -84,7 +84,7 @@ describe("UI - Type Hints", () => { await game.phaseInterceptor.to("CommandPhase"); }); - it("should show the proper hint for a move in doubles after one of the enemy pokemon flees", async () => { + it("should show the proper hint for a move in doubles after one of the enemy pokemon faints", async () => { game.override .enemySpecies(SpeciesId.ABRA) .moveset([MoveId.SPLASH, MoveId.SHADOW_BALL, MoveId.SOAK]) From 826bf1fd33e045b9fc2c444d9efd0823f7fe7d01 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Sat, 6 Sep 2025 13:40:22 -0500 Subject: [PATCH 36/43] Address kev's review comments Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> --- src/data/moves/move.ts | 4 ++-- src/phases/move-phase.ts | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index aa4ecf13c1e..f0ddef8c4d8 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -489,7 +489,7 @@ export abstract class Move implements Localizable { conditionSeq = 4, ): this { if (typeof restriction === "function") { - this.restrictions.push(new MoveRestriction(restriction)); + this.restrictions.push(new MoveRestriction(restriction, i18nkey)); if (alsoCondition) { let conditionArray: MoveCondition[]; switch (conditionSeq) { @@ -501,7 +501,7 @@ export abstract class Move implements Localizable { break; default: conditionArray = this.conditions; - } + } conditionArray.push(new MoveCondition((user, _, move) => !restriction(user, move))); } diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 5265820447f..027d5bd5923 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -536,11 +536,11 @@ export class MovePhase extends PokemonPhase { // Check if the move will heal const move = this.move.getMove(); if ( - move.findAttr(attr => attr.selfTarget && attr.is("HealStatusEffectAttr") && attr.isOfEffect(StatusEffect.FREEZE)) + move.findAttr( + attr => attr.selfTarget && attr.is("HealStatusEffectAttr") && attr.isOfEffect(StatusEffect.FREEZE), + ) && + (move.id !== MoveId.BURN_UP || this.pokemon.isOfType(PokemonType.FIRE, true, true)) ) { - // 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; } @@ -634,7 +634,7 @@ export class MovePhase extends PokemonPhase { const move = this.move.getMove(); const user = this.pokemon; const target = this.getActiveTargetPokemon()[0]; - return !!move.getAttrs("PreUseInterruptAttr").some(attr => { + return move.getAttrs("PreUseInterruptAttr").some(attr => { attr.apply(user, target, move); if (this.cancelled) { return true; @@ -723,8 +723,8 @@ export class MovePhase extends PokemonPhase { return; } /* - At this point, delayed moves (future sight, wish, doom desire) are issued, and if they occur, are - Then, combined pledge moves are checked for. Interestingly, the "wasMoveEffective" flag is set to false if the delay occurs + At this point, delayed moves (future sight, wish, doom desire) are issued, and, if they occur, the move animations are played. + Then, combined pledge moves are checked for. Interestingly, the "wasMoveEffective" flag is set to false if the combined technique In either case, the phase should end here without proceeding */ From c378f2af1ff1ca686d806df1c995fa4337b99acd Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Sat, 6 Sep 2025 13:52:40 -0500 Subject: [PATCH 37/43] Minor adjustments from code review Co-authored-by: Bertie690 <136088738+Bertie690@users.noreply.github.com> --- src/data/moves/move-condition.ts | 2 +- src/enums/battler-tag-lapse-type.ts | 8 ++++---- src/enums/move-flags.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/data/moves/move-condition.ts b/src/data/moves/move-condition.ts index 37ac36b0e42..b2b1a7e10c4 100644 --- a/src/data/moves/move-condition.ts +++ b/src/data/moves/move-condition.ts @@ -40,11 +40,11 @@ export class MoveCondition { 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 => user.tempSummonData.waveTurnCount === 1); diff --git a/src/enums/battler-tag-lapse-type.ts b/src/enums/battler-tag-lapse-type.ts index 0735f1040a6..d6ff8626d01 100644 --- a/src/enums/battler-tag-lapse-type.ts +++ b/src/enums/battler-tag-lapse-type.ts @@ -49,10 +49,10 @@ export enum BattlerTagLapseType { /** * 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. + * Tags can use this lapse type to prevent them from automatically lapsing during automatic lapse instances, + * such as before a move is used or at the end of a turn. + * Such tags will only trigger upon being specifically lapsed with the tag and lapse type via + * {@linkcode Pokemon.lapseTag}. * */ CUSTOM, } diff --git a/src/enums/move-flags.ts b/src/enums/move-flags.ts index d602d48a4b3..014d5c467c9 100644 --- a/src/enums/move-flags.ts +++ b/src/enums/move-flags.ts @@ -49,6 +49,6 @@ export enum MoveFlags { IGNORE_SUBSTITUTE = 1 << 17, /** Indicates a move is able to be reflected by {@linkcode AbilityId.MAGIC_BOUNCE} and {@linkcode MoveId.MAGIC_COAT} */ REFLECTABLE = 1 << 18, - /** Indicates a move that fails when {@link https://bulbapedia.bulbagarden.net/wiki/Gravity_(move) | Gravity} is in effect */ + /** Indicates a move should fail when {@link https://bulbapedia.bulbagarden.net/wiki/Gravity_(move) | Gravity} is in effect */ GRAVITY = 1 << 19, } From 015d4780fcc98ffbbed3a778f81faf6906b2d406 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Sat, 6 Sep 2025 14:35:27 -0500 Subject: [PATCH 38/43] Remove optional chaining --- src/data/battler-tags.ts | 20 ++++++++++---------- src/data/moves/move.ts | 7 ++++--- src/ui/handlers/command-ui-handler.ts | 2 +- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index d7f36605422..f91e5619716 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -510,7 +510,7 @@ export class RechargingTag extends SerializableBattlerTag { }), ); const currentPhase = globalScene.phaseManager.getCurrentPhase(); - if (currentPhase?.is("MovePhase")) { + if (currentPhase.is("MovePhase")) { currentPhase.cancel(); } pokemon.getMoveQueue().shift(); @@ -711,7 +711,7 @@ export class FlinchedTag extends BattlerTag { lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { if (lapseType === BattlerTagLapseType.PRE_MOVE) { const currentPhase = globalScene.phaseManager.getCurrentPhase(); - if (currentPhase?.is("MovePhase")) { + if (currentPhase.is("MovePhase")) { currentPhase.cancel(); } globalScene.phaseManager.queueMessage( @@ -755,7 +755,7 @@ export class InterruptedTag extends BattlerTag { lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { const currentPhase = globalScene.phaseManager.getCurrentPhase(); - if (currentPhase?.is("MovePhase")) { + if (currentPhase.is("MovePhase")) { currentPhase.cancel(); } return super.lapse(pokemon, lapseType); @@ -849,7 +849,7 @@ export class ConfusedTag extends SerializableBattlerTag { phaseManager.queueMessage(i18next.t("battlerTags:confusedLapseHurtItself")); pokemon.damageAndUpdate(damage, { result: HitResult.CONFUSION }); const currentPhase = phaseManager.getCurrentPhase(); - if (currentPhase?.is("MovePhase") && currentPhase.pokemon === pokemon) { + if (currentPhase.is("MovePhase") && currentPhase.pokemon === pokemon) { currentPhase.cancel(); } } @@ -991,7 +991,7 @@ export class InfatuatedTag extends SerializableBattlerTag { }), ); const currentPhase = phaseManager.getCurrentPhase(); - if (currentPhase?.is("MovePhase")) { + if (currentPhase.is("MovePhase")) { currentPhase.cancel(); } } @@ -1145,7 +1145,7 @@ export class PowderTag extends BattlerTag { } const currentPhase = globalScene.phaseManager.getCurrentPhase(); - if (!currentPhase?.is("MovePhase")) { + if (!currentPhase.is("MovePhase")) { return true; } @@ -1757,7 +1757,7 @@ export class ProtectedTag extends BattlerTag { // Stop multi-hit moves early const effectPhase = globalScene.phaseManager.getCurrentPhase(); - if (effectPhase?.is("MoveEffectPhase")) { + if (effectPhase.is("MoveEffectPhase")) { effectPhase.stopMultiHit(pokemon); } return true; @@ -2771,7 +2771,7 @@ export class GulpMissileTag extends SerializableBattlerTag { } const moveEffectPhase = globalScene.phaseManager.getCurrentPhase(); - if (moveEffectPhase?.is("MoveEffectPhase")) { + if (moveEffectPhase.is("MoveEffectPhase")) { const attacker = moveEffectPhase.getUserPokemon(); if (!attacker) { @@ -3145,7 +3145,7 @@ export class SubstituteTag extends SerializableBattlerTag { /** If the Substitute redirects damage, queue a message to indicate it. */ onHit(pokemon: Pokemon): void { const moveEffectPhase = globalScene.phaseManager.getCurrentPhase(); - if (moveEffectPhase?.is("MoveEffectPhase")) { + if (moveEffectPhase.is("MoveEffectPhase")) { const attacker = moveEffectPhase.getUserPokemon(); if (!attacker) { return; @@ -3893,7 +3893,7 @@ export function loadBattlerTag(source: BattlerTag | BattlerTagData): BattlerTag */ function getMoveEffectPhaseData(_pokemon: Pokemon): { phase: MoveEffectPhase; attacker: Pokemon; move: Move } | null { const phase = globalScene.phaseManager.getCurrentPhase(); - if (phase?.is("MoveEffectPhase")) { + if (phase.is("MoveEffectPhase")) { return { phase, attacker: phase.getPokemon(), diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index f0ddef8c4d8..a0bdc89d3a2 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -1658,7 +1658,7 @@ export class PreUseInterruptAttr extends MoveAttr { */ override apply(user: Pokemon, target: Pokemon, move: Move): boolean { const currentPhase = globalScene.phaseManager.getCurrentPhase(); - if (!currentPhase?.is("MovePhase") || !this.conditionFunc(user, target, move)) { + if (!currentPhase.is("MovePhase") || !this.conditionFunc(user, target, move)) { return false; } currentPhase.cancel(); @@ -2219,8 +2219,9 @@ export class HealAttr extends MoveEffectAttr { if (healedPokemon.isFullHp()) { // Ensure the fail message isn't displayed when checking the move conditions outside of the move execution // TOOD: Fix this in PR#6276 - if (globalScene.phaseManager.getCurrentPhase()?.is("MovePhase")) { - globalScene.phaseManager.queueMessage(i18next.t("battle:hpIsFull", { + const phaseManager = globalScene.phaseManager; + if (phaseManager.getCurrentPhase().is("MovePhase")) { + phaseManager.queueMessage(i18next.t("battle:hpIsFull", { pokemonName: getPokemonNameWithAffix(healedPokemon), })) } diff --git a/src/ui/handlers/command-ui-handler.ts b/src/ui/handlers/command-ui-handler.ts index 693fe0eefef..0b24f59727d 100644 --- a/src/ui/handlers/command-ui-handler.ts +++ b/src/ui/handlers/command-ui-handler.ts @@ -74,7 +74,7 @@ export class CommandUiHandler extends UiHandler { let commandPhase: CommandPhase; const currentPhase = globalScene.phaseManager.getCurrentPhase(); - if (currentPhase?.is("CommandPhase")) { + if (currentPhase.is("CommandPhase")) { commandPhase = currentPhase; } else { commandPhase = globalScene.phaseManager.getStandbyPhase() as CommandPhase; From 0281751fa8a457b12fe6b4eb1e5bbc33428b63a4 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Sat, 6 Sep 2025 17:20:21 -0500 Subject: [PATCH 39/43] fix: type for InferKeys --- src/@types/helpers/type-helpers.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/@types/helpers/type-helpers.ts b/src/@types/helpers/type-helpers.ts index 048a86ab489..9e6cdae96c0 100644 --- a/src/@types/helpers/type-helpers.ts +++ b/src/@types/helpers/type-helpers.ts @@ -40,11 +40,9 @@ export type Mutable = { * @typeParam O - The type of the object * @typeParam V - The type of one of O's values. */ -export type InferKeys = V extends ObjectValues - ? { - [K in keyof O]: O[K] extends V ? K : never; - }[keyof O] - : never; +export type InferKeys = { + [K in keyof O]: O[K] extends V ? K : never; +}[keyof O]; /** * Utility type to obtain a union of the values of a given object. \ From ec34c975bb5bb26fd4bb4dfcc6f4aafbffdfb350 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Sat, 6 Sep 2025 17:22:50 -0500 Subject: [PATCH 40/43] chore: apply biome --- src/phases/command-phase.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/phases/command-phase.ts b/src/phases/command-phase.ts index df85fa6358e..a1008bf1270 100644 --- a/src/phases/command-phase.ts +++ b/src/phases/command-phase.ts @@ -19,7 +19,6 @@ import { UiMode } from "#enums/ui-mode"; import type { PlayerPokemon } from "#field/pokemon"; import type { MoveTargetSet } from "#moves/move"; import { getMoveTargets } from "#moves/move-utils"; -import type { PokemonMove } from "#moves/pokemon-move"; import { FieldPhase } from "#phases/field-phase"; import type { TurnMove } from "#types/turn-move"; import i18next from "i18next"; From 2561b7ea674bac0acb92fbfe7c6047cfc6657883 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Mon, 8 Sep 2025 12:08:52 -0500 Subject: [PATCH 41/43] chore: fix merge conflicts from Biome update --- src/data/battler-tags.ts | 4 +- src/data/moves/move-condition.ts | 22 ++++---- src/data/moves/move-utils.ts | 6 +-- src/data/moves/move.ts | 12 +---- src/enums/battler-tag-lapse-type.ts | 8 +-- src/enums/move-category.ts | 2 +- src/field/pokemon.ts | 8 +-- src/phases/command-phase.ts | 6 +-- src/phases/move-phase.ts | 84 ++++++++++++++++------------- src/utils/pokemon-utils.ts | 4 +- 10 files changed, 78 insertions(+), 78 deletions(-) diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index f91e5619716..458aba69018 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -1152,8 +1152,8 @@ export class PowderTag extends BattlerTag { const move = currentPhase.move.getMove(); const weather = globalScene.arena.weather; if ( - pokemon.getMoveType(move) !== PokemonType.FIRE || - (weather?.weatherType === WeatherType.HEAVY_RAIN && !weather.isEffectSuppressed()) // Since gen 7, Heavy rain takes priority over powder + pokemon.getMoveType(move) !== PokemonType.FIRE + || (weather?.weatherType === WeatherType.HEAVY_RAIN && !weather.isEffectSuppressed()) // Since gen 7, Heavy rain takes priority over powder ) { return true; } diff --git a/src/data/moves/move-condition.ts b/src/data/moves/move-condition.ts index b2b1a7e10c4..a3e1cb8c8fa 100644 --- a/src/data/moves/move-condition.ts +++ b/src/data/moves/move-condition.ts @@ -128,9 +128,9 @@ export const failIfTargetNotAttackingCondition = new MoveCondition((_user, targe return false; } return ( - turnCommand.command === Command.FIGHT && - !target.turnData.acted && - allMoves[turnCommand.move.move].category !== MoveCategory.STATUS + turnCommand.command === Command.FIGHT + && !target.turnData.acted + && allMoves[turnCommand.move.move].category !== MoveCategory.STATUS ); }); @@ -145,8 +145,8 @@ 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)) + target.isEnemy() + && (gameMode.isBattleClassicFinalBoss(currentWave) || gameMode.isEndlessMinorBoss(currentWave)) ); }); @@ -159,11 +159,11 @@ export const failAgainstFinalBossCondition = new MoveCondition((_user, target) = 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 + targetCommand?.command === Command.FIGHT + && !target.turnData.acted + && !!targetCommand.move?.move + && allMoves[targetCommand.move.move].category !== MoveCategory.STATUS + && allMoves[targetCommand.move.move].getPriority(target) > 0 ); }); @@ -180,7 +180,7 @@ export const upperHandCondition = new MoveCondition((_user, target) => { */ export const lastResortCondition = new MoveCondition((user, _target, move) => { const otherMovesInMoveset = new Set(user.getMoveset().map(m => m.moveId)); - if (!otherMovesInMoveset.delete(move.id) || !otherMovesInMoveset.size) { + if (!otherMovesInMoveset.delete(move.id) || otherMovesInMoveset.size === 0) { return false; // Last resort fails if used when not in user's moveset or no other moves exist } diff --git a/src/data/moves/move-utils.ts b/src/data/moves/move-utils.ts index d30f09a838c..4d209c90c07 100644 --- a/src/data/moves/move-utils.ts +++ b/src/data/moves/move-utils.ts @@ -148,9 +148,9 @@ export function getCounterAttackTarget(user: Pokemon, damageCategory?: MoveDamag const moveCategory = allMoves[attackRecord.move].category; const sourceBattlerIndex = attackRecord.sourceBattlerIndex; if ( - moveCategory !== MoveCategory.STATUS && - !areAllies(sourceBattlerIndex, user.getBattlerIndex()) && - (damageCategory === undefined || moveCategory === damageCategory) + moveCategory !== MoveCategory.STATUS + && !areAllies(sourceBattlerIndex, user.getBattlerIndex()) + && (damageCategory === undefined || moveCategory === damageCategory) ) { return sourceBattlerIndex; } diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index a0bdc89d3a2..37e532b047b 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -51,11 +51,8 @@ import { MoveEffectTrigger } from "#enums/move-effect-trigger"; import { MoveFlags } from "#enums/move-flags"; import { MoveId } from "#enums/move-id"; import { MoveResult } from "#enums/move-result"; -import { MoveId } from "#enums/move-id"; -import { MoveResult } from "#enums/move-result"; import { MoveTarget } from "#enums/move-target"; import { isVirtual, MoveUseMode } from "#enums/move-use-mode"; -import { isVirtual, MoveUseMode } from "#enums/move-use-mode"; import { MultiHitType } from "#enums/multi-hit-type"; import { PokemonType } from "#enums/pokemon-type"; import { PositionalTagType } from "#enums/positional-tag-type"; @@ -92,7 +89,7 @@ import type { ChargingMove, MoveAttrMap, MoveAttrString, MoveClassMap, MoveKindS import type { TurnMove } from "#types/turn-move"; import type { AbstractConstructor } from "#types/type-helpers"; import { applyChallenges } from "#utils/challenge-utils"; -import { BooleanHolder, coerceArray, type Constructor, isNullOrUndefined, NumberHolder, randSeedFloat, randSeedInt, randSeedItem, toDmgValue } from "#utils/common"; +import { BooleanHolder, coerceArray, type Constructor, NumberHolder, randSeedFloat, randSeedInt, randSeedItem, toDmgValue } from "#utils/common"; import { getEnumValues } from "#utils/enums"; import { areAllies } from "#utils/pokemon-utils"; import { toCamelCase, toTitleCase } from "#utils/strings"; @@ -172,14 +169,9 @@ export abstract class Move implements Localizable { * @see {@link https://www.smogon.com/forums/threads/sword-shield-battle-mechanics-research.3655528/page-54#post-8548957} */ private conditionsSeq3: MoveCondition[] = []; -<<<<<<< HEAD - /** Conditions that must be false for a move to be able to be selected. - * -======= /** * Conditions that must be false for a move to be able to be selected. - * ->>>>>>> 02f941b3a05 (PR review changes) + * * @remarks Different from {@linkcode conditions}, which is checked when the move is invoked */ private restrictions: MoveRestriction[] = []; diff --git a/src/enums/battler-tag-lapse-type.ts b/src/enums/battler-tag-lapse-type.ts index d6ff8626d01..391ef51c0ae 100644 --- a/src/enums/battler-tag-lapse-type.ts +++ b/src/enums/battler-tag-lapse-type.ts @@ -21,7 +21,7 @@ export enum BattlerTagLapseType { * Note tags with this lapse type will lapse immediately after the first failure check sequence, * regardless of whether the move was successful or not, but is skipped if the move is a * {@linkcode MoveUseMode.FOLLOW_UP | follow-up} move. - * + * * To only lapse the tag between the first and second failure check sequences, use * {@linkcode BattlerTagLapseType.MOVE} instead. */ @@ -50,12 +50,12 @@ export enum BattlerTagLapseType { * The tag has some other custom activation or removal condition. * @remarks * Tags can use this lapse type to prevent them from automatically lapsing during automatic lapse instances, - * such as before a move is used or at the end of a turn. + * such as before a move is used or at the end of a turn. * Such tags will only trigger upon being specifically lapsed with the tag and lapse type via * {@linkcode Pokemon.lapseTag}. - * */ + */ CUSTOM, } /** Same type as {@linkcode BattlerTagLapseType}, but excludes the {@linkcode BattlerTagLapseType.CUSTOM} type */ -export type NonCustomBattlerTagLapseType = Exclude; \ No newline at end of file +export type NonCustomBattlerTagLapseType = Exclude; diff --git a/src/enums/move-category.ts b/src/enums/move-category.ts index 96875a46b0f..a1c50eab9cf 100644 --- a/src/enums/move-category.ts +++ b/src/enums/move-category.ts @@ -5,4 +5,4 @@ export enum MoveCategory { } /** Type of damage categories */ -export type MoveDamageCategory = Exclude; \ No newline at end of file +export type MoveDamageCategory = Exclude; diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 7ea3e71da85..a4f6c0e3eb2 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -6523,8 +6523,8 @@ export class EnemyPokemon extends Pokemon { move.category !== MoveCategory.STATUS && moveTargets.some(p => { const doesNotFail = - move.applyConditions(this, p, -1) || - [MoveId.SUCKER_PUNCH, MoveId.UPPER_HAND, MoveId.THUNDERCLAP].includes(move.id); + move.applyConditions(this, p, -1) + || [MoveId.SUCKER_PUNCH, MoveId.UPPER_HAND, MoveId.THUNDERCLAP].includes(move.id); return ( doesNotFail && p.getAttackDamage({ @@ -6584,8 +6584,8 @@ export class EnemyPokemon extends Pokemon { * target score to -20 */ if ( - (move.name.endsWith(" (N)") || !move.applyConditions(this, target, -1)) && - ![MoveId.SUCKER_PUNCH, MoveId.UPPER_HAND, MoveId.THUNDERCLAP].includes(move.id) + (move.name.endsWith(" (N)") || !move.applyConditions(this, target, -1)) + && ![MoveId.SUCKER_PUNCH, MoveId.UPPER_HAND, MoveId.THUNDERCLAP].includes(move.id) ) { targetScore = -20; } else if (move.is("AttackMove")) { diff --git a/src/phases/command-phase.ts b/src/phases/command-phase.ts index a1008bf1270..15684ade408 100644 --- a/src/phases/command-phase.ts +++ b/src/phases/command-phase.ts @@ -121,9 +121,9 @@ export class CommandPhase extends FieldPhase { for (const queuedMove of moveQueue) { const movesetQueuedMove = moveset.find(m => m.moveId === queuedMove.move); if ( - queuedMove.move !== MoveId.NONE && - !isVirtual(queuedMove.useMode) && - !movesetQueuedMove?.isUsable(playerPokemon, isIgnorePP(queuedMove.useMode), true) + queuedMove.move !== MoveId.NONE + && !isVirtual(queuedMove.useMode) + && !movesetQueuedMove?.isUsable(playerPokemon, isIgnorePP(queuedMove.useMode), true) ) { entriesToDelete++; } else { diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 027d5bd5923..1ce66e7717a 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -3,7 +3,6 @@ import type { Move, PreUseInterruptAttr } from "#types/move-types"; // biome-ignore-end lint/correctness/noUnusedImports: Used in a tsdoc comment import { applyAbAttrs } from "#abilities/apply-ab-attrs"; -import { MOVE_COLOR } from "#app/constants/colors"; import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; import Overrides from "#app/overrides"; @@ -32,6 +31,7 @@ import type { Pokemon } from "#field/pokemon"; import { applyMoveAttrs } from "#moves/apply-attrs"; import { frenzyMissFunc } from "#moves/move-utils"; import type { PokemonMove } from "#moves/pokemon-move"; +import type { TurnMove } from "#types/turn-move"; import { applyChallenges } from "#utils/challenge-utils"; import { BooleanHolder, NumberHolder } from "#utils/common"; import { enumValueToKey } from "#utils/enums"; @@ -44,7 +44,7 @@ export class MovePhase extends PokemonPhase { protected _targets: BattlerIndex[]; public readonly useMode: MoveUseMode; // Made public for quash /** The timing modifier of the move (used by Quash and to force called moves to the front of their queue) */ - public timingModifier: MovePhaseTimingModifier; + public readonly timingModifier: MovePhaseTimingModifier; /** Whether the current move should fail but still use PP. */ protected failed = false; /** Whether the current move should fail and retain PP. */ @@ -53,6 +53,13 @@ export class MovePhase extends PokemonPhase { /** Flag set to `true` during {@linkcode checkFreeze} that indicates that the pokemon will thaw if it passes the failure conditions */ private declare thaw?: boolean; + /** The move history entry object that is pushed to the pokemon's move history + * + * @remarks + * Can be edited _after_ being pushed to the history to adjust the result, targets, etc, for this move phase. + */ + protected readonly moveHistoryEntry: TurnMove; + public get pokemon(): Pokemon { return this._pokemon; } @@ -101,12 +108,14 @@ export class MovePhase extends PokemonPhase { /** Signifies the current move should fail but still use PP */ public fail(): void { + this.moveHistoryEntry.result = MoveResult.FAIL; this.failed = true; } /** Signifies the current move should cancel and retain PP */ public cancel(): void { this.cancelled = true; + this.moveHistoryEntry.result = MoveResult.FAIL; } /** @@ -138,22 +147,22 @@ export class MovePhase extends PokemonPhase { protected firstFailureCheck(): boolean { // A big if statement will handle the checks (that each have side effects!) in the correct order return ( - this.checkSleep() || - this.checkFreeze() || - this.checkPP() || - this.checkValidity() || - this.checkTagCancel(BattlerTagType.TRUANT) || - this.checkPreUseInterrupt() || - this.checkTagCancel(BattlerTagType.FLINCHED) || - this.checkTagCancel(BattlerTagType.DISABLED) || - this.checkTagCancel(BattlerTagType.HEAL_BLOCK) || - this.checkTagCancel(BattlerTagType.THROAT_CHOPPED) || - this.checkGravity() || - this.checkTagCancel(BattlerTagType.TAUNT) || - this.checkTagCancel(BattlerTagType.IMPRISON) || - this.checkTagCancel(BattlerTagType.CONFUSED) || - this.checkPara() || - this.checkTagCancel(BattlerTagType.INFATUATED) + this.checkSleep() + || this.checkFreeze() + || this.checkPP() + || this.checkValidity() + || this.checkTagCancel(BattlerTagType.TRUANT) + || this.checkPreUseInterrupt() + || this.checkTagCancel(BattlerTagType.FLINCHED) + || this.checkTagCancel(BattlerTagType.DISABLED) + || this.checkTagCancel(BattlerTagType.HEAL_BLOCK) + || this.checkTagCancel(BattlerTagType.THROAT_CHOPPED) + || this.checkGravity() + || this.checkTagCancel(BattlerTagType.TAUNT) + || this.checkTagCancel(BattlerTagType.IMPRISON) + || this.checkTagCancel(BattlerTagType.CONFUSED) + || this.checkPara() + || this.checkTagCancel(BattlerTagType.INFATUATED) ); } @@ -171,9 +180,9 @@ export class MovePhase extends PokemonPhase { */ protected followUpMoveFirstFailureCheck(): boolean { return ( - this.checkTagCancel(BattlerTagType.HEAL_BLOCK) || - this.checkTagCancel(BattlerTagType.THROAT_CHOPPED) || - this.checkGravity() + this.checkTagCancel(BattlerTagType.HEAL_BLOCK) + || this.checkTagCancel(BattlerTagType.THROAT_CHOPPED) + || this.checkGravity() ); } @@ -419,11 +428,12 @@ export class MovePhase extends PokemonPhase { const moveQueue = this.pokemon.getMoveQueue(); if ( - (targets.length === 0 && !this.move.getMove().hasAttr("AddArenaTrapTagAttr")) || - (moveQueue.length > 0 && moveQueue[0].move === MoveId.NONE) + (targets.length === 0 && !this.move.getMove().hasAttr("AddArenaTrapTagAttr")) + || (moveQueue.length > 0 && moveQueue[0].move === MoveId.NONE) ) { this.showFailedText(); this.fail(); + this.pokemon.pushMoveHistory(this.moveHistoryEntry); return true; } this.pokemon.lapseTags(BattlerTagLapseType.MOVE); @@ -536,20 +546,18 @@ export class MovePhase extends PokemonPhase { // Check if the move will heal const move = this.move.getMove(); if ( - move.findAttr( - attr => attr.selfTarget && attr.is("HealStatusEffectAttr") && attr.isOfEffect(StatusEffect.FREEZE), - ) && - (move.id !== MoveId.BURN_UP || this.pokemon.isOfType(PokemonType.FIRE, true, true)) + move.findAttr(attr => attr.selfTarget && attr.is("HealStatusEffectAttr") && attr.isOfEffect(StatusEffect.FREEZE)) + && (move.id !== MoveId.BURN_UP || this.pokemon.isOfType(PokemonType.FIRE, true, true)) ) { this.thaw = true; return false; } if ( - Overrides.STATUS_ACTIVATION_OVERRIDE === false || - this.move + 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) + .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; @@ -606,10 +614,9 @@ export class MovePhase extends PokemonPhase { return true; } else if ( - this.pokemon.isPlayer() && - applyChallenges(ChallengeType.POKEMON_MOVE, moveId, usability) && - // check the value inside of usability after calling applyChallenges - !usability.value + 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 { @@ -683,8 +690,8 @@ export class MovePhase extends PokemonPhase { return false; } const proc = - (this.pokemon.randBattleSeedInt(4) === 0 || Overrides.STATUS_ACTIVATION_OVERRIDE === true) && - Overrides.STATUS_ACTIVATION_OVERRIDE !== false; + (this.pokemon.randBattleSeedInt(4) === 0 || Overrides.STATUS_ACTIVATION_OVERRIDE === true) + && Overrides.STATUS_ACTIVATION_OVERRIDE !== false; if (!proc) { return false; } @@ -777,7 +784,7 @@ export class MovePhase extends PokemonPhase { const dancerModes: MoveUseMode[] = [MoveUseMode.INDIRECT, MoveUseMode.REFLECTED] as const; if (this.move.getMove().hasFlag(MoveFlags.DANCE_MOVE) && !dancerModes.includes(this.useMode)) { globalScene.getField(true).forEach(pokemon => { - applyAbAttrs("PostMoveUsedAbAttr", { pokemon, move: this.move, source: user, targets: targets }); + applyAbAttrs("PostMoveUsedAbAttr", { pokemon, move: this.move, source: user, targets }); }); } } @@ -815,6 +822,7 @@ export class MovePhase extends PokemonPhase { result: MoveResult.FAIL, useMode: this.useMode, }); + console.log("==========PUSHING MOVE HISTORY WITH FAIL FOR %s=============", MoveId[this.move.moveId]); // Use move-specific failure messages if present before checking terrain/weather blockage // and falling back to the classic "But it failed!". diff --git a/src/utils/pokemon-utils.ts b/src/utils/pokemon-utils.ts index 454bd93a54c..96f3bf6b7cb 100644 --- a/src/utils/pokemon-utils.ts +++ b/src/utils/pokemon-utils.ts @@ -141,7 +141,7 @@ export function areAllies(a: BattlerIndex, b: BattlerIndex): boolean { return false; } return ( - (a === BattlerIndex.PLAYER || a === BattlerIndex.PLAYER_2) === - (b === BattlerIndex.PLAYER || b === BattlerIndex.PLAYER_2) + (a === BattlerIndex.PLAYER || a === BattlerIndex.PLAYER_2) + === (b === BattlerIndex.PLAYER || b === BattlerIndex.PLAYER_2) ); } From f743dc7648f71410b5c215e8fb20dede7e3f2424 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Tue, 16 Sep 2025 19:07:51 -0500 Subject: [PATCH 42/43] Remove latent isNullOrUndefined --- src/data/moves/move.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 37e532b047b..194e1915bec 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -10752,7 +10752,7 @@ export function initMoves() { .condition( () => { const weather = globalScene.arena.weather; - if (isNullOrUndefined(weather) || weather.isEffectSuppressed()) { + if (weather == null || weather.isEffectSuppressed()) { return false; } return weather.weatherType === WeatherType.HAIL || weather.weatherType === WeatherType.SNOW; From d6d2870f02b3fa1f341c302c59e29fe44ea644a8 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Sun, 21 Sep 2025 10:55:20 -0500 Subject: [PATCH 43/43] Drop readonly on timingModifier --- src/phases/move-phase.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 1ce66e7717a..04e4d68f3b2 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -44,7 +44,7 @@ export class MovePhase extends PokemonPhase { protected _targets: BattlerIndex[]; public readonly useMode: MoveUseMode; // Made public for quash /** The timing modifier of the move (used by Quash and to force called moves to the front of their queue) */ - public readonly timingModifier: MovePhaseTimingModifier; + public timingModifier: MovePhaseTimingModifier; /** Whether the current move should fail but still use PP. */ protected failed = false; /** Whether the current move should fail and retain PP. */