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. \ diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 8abd98f4683..458aba69018 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,19 @@ export class ConfusedTag extends SerializableBattlerTag { ); } + /** + * Tick down the confusion duration and, if there are remaining turns, activate the confusion effect + * + * @remarks + * 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 `true` if the tag should remain active (i.e. `turnCount` > 0) + */ 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 +848,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 +927,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 +990,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 +1118,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 +1140,26 @@ export class PowderTag extends BattlerTag { * @returns `true` if the tag should remain active. */ lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { - const movePhase = globalScene.phaseManager.getCurrentPhase(); - if (lapseType !== BattlerTagLapseType.PRE_MOVE || !movePhase?.is("MovePhase")) { + if (lapseType === BattlerTagLapseType.TURN_END) { return false; } + const currentPhase = globalScene.phaseManager.getCurrentPhase(); - const move = movePhase.move.getMove(); + if (!currentPhase.is("MovePhase")) { + return true; + } + + const move = currentPhase.move.getMove(); const weather = globalScene.arena.weather; if ( pokemon.getMoveType(move) !== PokemonType.FIRE - || (weather?.weatherType === WeatherType.HEAVY_RAIN && !weather.isEffectSuppressed()) // Heavy rain takes priority over powder + || (weather?.weatherType === WeatherType.HEAVY_RAIN && !weather.isEffectSuppressed()) // Since gen 7, Heavy rain takes priority over powder ) { return true; } // Disable the target's fire type move and damage it (subject to Magic Guard) - movePhase.showMoveText(); - movePhase.fail(); + currentPhase.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 { @@ -1737,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; @@ -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 { @@ -2751,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) { @@ -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 { @@ -3044,7 +3059,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, @@ -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: @@ -3130,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; @@ -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; } } @@ -3780,8 +3789,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: @@ -3886,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(), @@ -3955,7 +3962,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-condition.ts b/src/data/moves/move-condition.ts new file mode 100644 index 00000000000..a3e1cb8c8fa --- /dev/null +++ b/src/data/moves/move-condition.ts @@ -0,0 +1,264 @@ +// 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, 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"; + +/** + * 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) { + 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 => 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 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 { + /** + * 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 + * ⚠️ 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 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 + ); +}); + +/** + * 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 + * (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)); + 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 + } + + 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 counterAttackConditionPhysical = new CounterAttackConditon(MoveCategory.PHYSICAL); +/** Condition check for counterattacks that proc against special moves*/ +export const counterAttackConditionSpecial = new CounterAttackConditon(MoveCategory.SPECIAL); +/** Condition check for counterattacks that proc against moves regardless of damage type */ +export const counterAttackConditionBoth = new CounterAttackConditon(); + +/** + * 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-utils.ts b/src/data/moves/move-utils.ts index 1fe0880317b..4d209c90c07 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 075876d8ddd..194e1915bec 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,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 { 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 { 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,27 +79,28 @@ import { } from "#modifiers/modifier"; import { applyMoveAttrs } from "#moves/apply-attrs"; import { invalidAssistMoves, invalidCopycatMoves, invalidMetronomeMoves, invalidMirrorMoveMoves, invalidSketchMoves, invalidSleepTalkMoves } from "#moves/invalid-moves"; -import { frenzyMissFunc, getMoveTargets } from "#moves/move-utils"; +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"; 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 { getEnumValues } from "#utils/enums"; +import { areAllies } from "#utils/pokemon-utils"; 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,63 @@ 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 + * 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. + * + * 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 but before the move is recorded as the last move used) + */ + 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 + */ + private restrictions: MoveRestriction[] = []; /** The move's {@linkcode MoveFlags} */ private flags: number = 0; private nameAppend: string = ""; @@ -371,13 +428,78 @@ 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` + * @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): 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); } - this.conditions.push(condition); + conditionsArray.push(condition); + + 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 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 + */ + 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. + * @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`. + * @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, + conditionSeq = 4, + ): this { + if (typeof restriction === "function") { + this.restrictions.push(new MoveRestriction(restriction, i18nkey)); + if (alsoCondition) { + 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); + } return this; } @@ -580,6 +702,24 @@ 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. + * + * @returns `this` + * + * @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); + this.restrictions.push(gravityUseRestriction); + return this; + } + /** * Sets the {@linkcode MoveFlags.IGNORE_ABILITIES} flag for the calling Move * @see {@linkcode MoveId.SUNSTEEL_STRIKE} @@ -610,16 +750,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} @@ -698,13 +828,50 @@ 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, 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, move: Move): boolean { - return this.conditions.every(cond => cond.apply(user, target, move)); + 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]; + break; + 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)); + } + + + /** + * 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, `true` and the empty 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)]; + } + } + + return [true, ""]; } /** @@ -1476,13 +1643,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; } /** @@ -1631,28 +1806,70 @@ 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); + const damage = user.turnData.attacksReceived.find(ar => { + const category = allMoves[ar.move].category; + 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; } +} - 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(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; + } + return true; + } + return false; } } @@ -1994,8 +2211,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), })) } @@ -2974,19 +3192,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 } /** @@ -3660,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); } } @@ -7859,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; @@ -7944,8 +8137,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 +8182,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; @@ -8188,6 +8328,7 @@ const MoveAttrs = Object.freeze({ TargetHalfHpDamageAttr, MatchHpAttr, CounterDamageAttr, + CounterRedirectAttr, LevelDamageAttr, RandomLevelDamageAttr, ModifiedDamageAttr, @@ -8378,7 +8519,6 @@ const MoveAttrs = Object.freeze({ DestinyBondAttr, AddBattlerTagIfBoostedAttr, StatusIfBoostedAttr, - LastResortAttr, VariableTargetAttr, AfterYouAttr, ForceLastAttr, @@ -8443,7 +8583,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 +8598,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), @@ -8579,7 +8719,9 @@ 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) + .attr(CounterRedirectAttr, MoveCategory.PHYSICAL) + .condition(counterAttackConditionPhysical, 3) .target(MoveTarget.ATTACKER), new AttackMove(MoveId.SEISMIC_TOSS, PokemonType.FIGHTING, MoveCategory.PHYSICAL, -1, 100, 20, -1, 0, 1) .attr(LevelDamageAttr), @@ -8679,7 +8821,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) @@ -8734,7 +8877,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) @@ -8775,7 +8918,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,13 +8972,13 @@ 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) .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), @@ -8870,6 +9013,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() @@ -8901,7 +9045,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) @@ -8933,7 +9077,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) @@ -8969,6 +9113,7 @@ export function initMoves() { new SelfStatusMove(MoveId.DESTINY_BOND, PokemonType.GHOST, -1, 5, -1, 0, 2) .ignoresProtect() .attr(DestinyBondAttr) + .condition(failAgainstFinalBossCondition, 2) .condition((user, target, move) => { // Retrieves user's previous move, returns empty array if no moves have been used const lastTurnMove = user.getLastXMoves(1); @@ -8992,7 +9137,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), @@ -9014,7 +9159,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(), @@ -9049,7 +9194,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) @@ -9152,7 +9297,9 @@ 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) + .attr(CounterRedirectAttr, MoveCategory.SPECIAL) + .condition(counterAttackConditionSpecial, 3) .target(MoveTarget.ATTACKER), new StatusMove(MoveId.PSYCH_UP, PokemonType.NORMAL, -1, 10, -1, 0, 2) .ignoresSubstitute() @@ -9181,22 +9328,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 @@ -9236,7 +9383,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) @@ -9257,6 +9405,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) @@ -9271,7 +9421,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 @@ -9304,6 +9454,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 ]) @@ -9472,7 +9624,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 +9676,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) @@ -9567,8 +9720,9 @@ 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) + .attr(CounterRedirectAttr) + .condition(counterAttackConditionBoth, 3) .makesContact(false) .target(MoveTarget.ATTACKER), new AttackMove(MoveId.U_TURN, PokemonType.BUG, MoveCategory.PHYSICAL, 70, 100, 20, -1, 0, 4) @@ -9624,19 +9778,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) @@ -9648,7 +9798,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)), 3) + .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) @@ -9857,11 +10008,15 @@ 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) .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) @@ -9877,7 +10032,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) @@ -9931,9 +10086,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() @@ -9958,7 +10117,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(), @@ -9981,7 +10140,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 +10328,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), + .condition(new FirstMoveCondition(), 3) + .condition(failIfLastCondition, 3), 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) => { @@ -10236,7 +10395,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) }), @@ -10264,7 +10423,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() @@ -10292,7 +10451,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() @@ -10460,10 +10619,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), @@ -10519,7 +10678,9 @@ 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.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) @@ -10532,16 +10693,18 @@ 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); - }) + // 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) => { 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), @@ -10586,7 +10749,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 (weather == null || 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 */ @@ -10623,7 +10795,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, 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) @@ -10667,7 +10839,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) @@ -10707,7 +10879,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,15 +10934,17 @@ 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, + 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, 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 */) + // 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) @@ -10854,7 +11029,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) @@ -10901,7 +11077,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) @@ -10915,7 +11091,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 }) @@ -10932,7 +11108,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()), @@ -10950,7 +11126,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) @@ -11194,7 +11370,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) @@ -11223,10 +11399,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) @@ -11256,7 +11429,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) @@ -11332,23 +11506,19 @@ 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); - }) + // 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) - .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() + .attr(CounterDamageAttr, 1.5) + .attr(CounterRedirectAttr) + .condition(counterAttackConditionBoth, 3) .target(MoveTarget.ATTACKER), new AttackMove(MoveId.AQUA_CUTTER, PokemonType.WATER, MoveCategory.PHYSICAL, 70, 100, 20, -1, 0, 9) .attr(HighCritAttr) @@ -11370,10 +11540,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) @@ -11402,15 +11569,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(), @@ -11440,7 +11601,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, 3), new AttackMove(MoveId.MALIGNANT_CHAIN, PokemonType.POISON, MoveCategory.SPECIAL, 100, 100, 5, 50, 0, 9) .attr(StatusEffectAttr, StatusEffect.TOXIC) ); diff --git a/src/data/moves/pokemon-move.ts b/src/data/moves/pokemon-move.ts index 23daf0a971b..086024f8fb9 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,59 @@ 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` - * @returns Whether this {@linkcode PokemonMove} can be selected by this Pokemon. + * @param forSelection - Whether this is being checked for move selection; default `false` + * @returns A tuple containing a boolean indicating whether the move can be selected, and a string with the reason if it cannot */ - isUsable(pokemon: Pokemon, ignorePp = false, ignoreRestrictionTags = false): boolean { + public isUsable(pokemon: Pokemon, ignorePp = false, forSelection = false): [usable: boolean, preventionText: 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 (pokemon.isPlayer() && 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 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 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); + } + /** * 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..391ef51c0ae 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. @@ -6,11 +8,23 @@ export enum BattlerTagLapseType { // TODO: This is unused... FAINT, /** - * Tag activate before the holder uses a non-virtual move, possibly interrupting its action. + * 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 */ 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 + * + * @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, 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. + */ PRE_MOVE, /** Tag activates immediately after the holder's move finishes triggering (successful or not). */ AFTER_MOVE, @@ -32,6 +46,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 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, } + +/** Same type as {@linkcode BattlerTagLapseType}, but excludes the {@linkcode BattlerTagLapseType.CUSTOM} type */ +export type NonCustomBattlerTagLapseType = Exclude; 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/enums/move-category.ts b/src/enums/move-category.ts index f0a171b2fea..a1c50eab9cf 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; diff --git a/src/enums/move-flags.ts b/src/enums/move-flags.ts index acd73f897e7..014d5c467c9 100644 --- a/src/enums/move-flags.ts +++ b/src/enums/move-flags.ts @@ -47,8 +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 should fail when {@link https://bulbapedia.bulbagarden.net/wiki/Gravity_(move) | Gravity} is in effect */ + GRAVITY = 1 << 19, } diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index cbad6caaafa..4d8b17618d9 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -2410,8 +2410,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 @@ -2465,7 +2465,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", { @@ -3131,9 +3133,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 - 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): [isUsable: boolean, failureMessage: 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 */ @@ -3220,7 +3228,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(); @@ -4112,19 +4120,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); } @@ -4136,7 +4153,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( @@ -4248,13 +4265,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); } /** @@ -6415,7 +6456,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; } @@ -6425,7 +6466,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. @@ -6483,7 +6524,7 @@ export class EnemyPokemon extends Pokemon { move.category !== MoveCategory.STATUS && moveTargets.some(p => { const doesNotFail = - move.applyConditions(this, p, move) + move.applyConditions(this, p, -1) || [MoveId.SUCKER_PUNCH, MoveId.UPPER_HAND, MoveId.THUNDERCLAP].includes(move.id); return ( doesNotFail @@ -6544,7 +6585,7 @@ export class EnemyPokemon extends Pokemon { * target score to -20 */ if ( - (move.name.endsWith(" (N)") || !move.applyConditions(this, target, move)) + (move.name.endsWith(" (N)") || !move.applyConditions(this, target, -1)) && ![MoveId.SUCKER_PUNCH, MoveId.UPPER_HAND, MoveId.THUNDERCLAP].includes(move.id) ) { targetScore = -20; @@ -6882,6 +6923,10 @@ export class EnemyPokemon extends Pokemon { } public getBattlerIndex(): BattlerIndex { + const fieldIndex = this.getFieldIndex(); + if (fieldIndex === -1) { + return BattlerIndex.ATTACKER; + } return BattlerIndex.ENEMY + this.getFieldIndex(); } diff --git a/src/phases/command-phase.ts b/src/phases/command-phase.ts index 2bf845776ca..15684ade408 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"; @@ -20,11 +19,8 @@ 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 { applyChallenges } from "#utils/challenge-utils"; -import { BooleanHolder } from "#utils/common"; import i18next from "i18next"; export class CommandPhase extends FieldPhase { @@ -127,7 +123,7 @@ export class CommandPhase extends FieldPhase { if ( queuedMove.move !== MoveId.NONE && !isVirtual(queuedMove.useMode) - && !movesetQueuedMove?.isUsable(playerPokemon, isIgnorePP(queuedMove.useMode)) + && !movesetQueuedMove?.isUsable(playerPokemon, isIgnorePP(queuedMove.useMode), true) ) { entriesToDelete++; } else { @@ -205,39 +201,18 @@ export class CommandPhase extends FieldPhase { } /** - * Submethod of {@linkcode handleFightCommand} responsible for queuing the appropriate - * error message when a move cannot be used. - * @param user - The pokemon using the move - * @param 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 +249,16 @@ 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, ignorePP, true)[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) { + console.error("Cannot use move:", reason); + 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..04e4d68f3b2 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -1,5 +1,8 @@ +// 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"; import { getPokemonNameWithAffix } from "#app/messages"; import Overrides from "#app/overrides"; @@ -10,9 +13,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"; @@ -27,7 +32,8 @@ 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"; +import { applyChallenges } from "#utils/challenge-utils"; +import { BooleanHolder, NumberHolder } from "#utils/common"; import { enumValueToKey } from "#utils/enums"; import i18next from "i18next"; @@ -44,12 +50,15 @@ export class MovePhase extends PokemonPhase { /** Whether the current move should fail and retain PP. */ protected cancelled = false; + /** Flag set to `true` during {@linkcode checkFreeze} that indicates that the pokemon will thaw if it passes the failure conditions */ + private declare thaw?: boolean; + /** 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; + protected readonly moveHistoryEntry: TurnMove; public get pokemon(): Pokemon { return this._pokemon; @@ -97,94 +106,324 @@ 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.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; + } + + /** + * 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): + * 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 (see {@linkcode https://github.com/pagefaultgames/pokerogue/pull/5983 | PR#5983}) + * 6. Truant + * 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 / 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 + 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) + ); + } + + /** + * 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 (See {@link https://github.com/pagefaultgames/pokerogue/pull/5983 | PR#5983}) + */ + protected followUpMoveFirstFailureCheck(): boolean { + return ( + this.checkTagCancel(BattlerTagType.HEAL_BLOCK) + || this.checkTagCancel(BattlerTagType.THROAT_CHOPPED) + || this.checkGravity() + ); + } + + /** + * 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 doThawCheck(): void { + const user = this.pokemon; + + if (isIgnoreStatus(this.useMode)) { + return; + } + 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, other 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 + * - 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 + * + * After all checks, Powder causing the user to explode + */ + 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; + } 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); + } + return this.failed; + } + + /** + * Third failure check is from moves and abilities themselves + * + * @returns Whether the move failed + * + * @remarks + * - 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 *should* happen in the move effect phase (though happen here for now) + */ + 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], 3); + const failedDueToTerrain = arena.isMoveTerrainCancelled(user, this.targets, move); + let failed = failsConditions || 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(failedDueToTerrain); + 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; } - this.pokemon.turnData.acted = true; + const user = this.pokemon; + + // 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)); + + // For the purposes of payback and kin, the pokemon is considered to have acted + // if it attempted to move at all. + 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 (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 + // 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(); + return; + } + + // If the first failure check passes (and this is not a sub-move) then thaw the user if its move will thaw it. + if (!isFollowUp) { + this.doThawCheck(); + } // Reset hit-related turn data when starting follow-up moves (e.g. Metronomed moves, Dancer repeats) - if (isVirtual(this.useMode)) { - this.pokemon.turnData.hitsLeft = -1; - this.pokemon.turnData.hitCount = 0; + if (isVirtual(useMode)) { + 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 + // 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(); - this.resolvePreMoveStatusEffects(); + // If this is the *release* turn of the charge move, PP is not deducted + const move = this.move.getMove(); - this.lapsePreMoveAndMoveTags(); + 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; - if (!(this.failed || this.cancelled)) { - this.resolveFinalPreMoveCancellationChecks(); + // Update the battle's "last move" pointer unless we're currently mimicking a move or triggering Dancer. + if (!move.hasAttr("CopyMoveAttr") && !isReflected(useMode)) { + globalScene.currentBattle.lastMove = move.id; } - // Cancel, charge or use the move as applicable. - if (this.cancelled || this.failed) { + // Charging moves consume PP when they begin charging, *not* when they release + if (!releasing) { + this.usePP(); + } + + 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(); - } else if (this.move.getMove().isChargingMove() && !this.pokemon.getTag(BattlerTagType.CHARGING)) { - this.chargeMove(); - } else { - this.useMove(); + this.end(); + return; + } + + if (!this.resolveFinalPreMoveCancellationChecks()) { + this.useMove(charging); } this.end(); } - /** Check for cancellation edge cases - no targets remaining, or {@linkcode MoveId.NONE} is in the queue */ - protected resolveFinalPreMoveCancellationChecks(): void { + /** + * Check for cancellation edge cases - no targets remaining + * @returns Whether the move fails + */ + protected resolveFinalPreMoveCancellationChecks(): boolean { const targets = this.getActiveTargetPokemon(); const moveQueue = this.pokemon.getMoveQueue(); @@ -192,10 +431,13 @@ 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(); + this.fail(); + this.pokemon.pushMoveHistory(this.moveHistoryEntry); + return true; } + this.pokemon.lapseTags(BattlerTagLapseType.MOVE); + return false; } public getActiveTargetPokemon(): Pokemon[] { @@ -203,177 +445,338 @@ 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 + * @param msg - A custom message to display when curing the status effect (used for curing freeze due to move use) */ - 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; - } + private cureStatus(effect: StatusEffect, msg?: string): void { + const pokemon = this.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(); + } - 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); + /** + * 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, cancel = true): void { + const pokemon = this.pokemon; + globalScene.phaseManager.queueMessage(getStatusEffectActivationText(effect, getPokemonNameWithAffix(pokemon))); + globalScene.phaseManager.unshiftNew( + "CommonAnimPhase", + pokemon.getBattlerIndex(), + undefined, + CommonAnim.POISON + (effect - 1), // offset anim # by effect # + ); + if (cancel) { + this.cancelled = true; } } /** - * 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. + * Handle the sleep check + * @returns Whether the move was cancelled due to sleep */ - protected lapsePreMoveAndMoveTags(): void { - this.pokemon.lapseTags(BattlerTagLapseType.PRE_MOVE); + protected checkSleep(): boolean { + const user = this.pokemon; + if (user.status?.effect !== StatusEffect.SLEEP) { + return false; + } - // 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); + // For some reason, dancer will immediately wake its user from sleep when triggering + if (this.useMode === MoveUseMode.INDIRECT) { + user.resetStatus(false); + return false; + } + + user.status.incrementTurn(); + const turnsRemaining = new NumberHolder(user.status.sleepTurnsRemaining ?? 0); + applyAbAttrs("ReduceStatusEffectDurationAbAttr", { + pokemon: user, + statusEffect: user.status.effect, + duration: turnsRemaining, + }); + + user.status.sleepTurnsRemaining = turnsRemaining.value; + if (user.status.sleepTurnsRemaining <= 0) { + this.cureStatus(StatusEffect.SLEEP); + return false; + } + + 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; + } + + /** + * 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) + * @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 thaw 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) { + 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)) + && (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 + .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) { + 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) // 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 + * @returns Whether the move was cancelled due to a `BattlerTag` effect + */ + private checkTagCancel(tag: BattlerTagType): boolean { + 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 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(); + /** + * Clear out two turn moves, then schedule the move to be used if it passes the third failure check + */ + protected useMove(charging = false): void { + const user = this.pokemon; - // 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 + /* 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 (!charging && 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()) { + 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, 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 + */ - /** - * 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 - */ + const move = this.move.getMove(); + const opponent = this.getActiveTargetPokemon()[0]; - /** - * Move conditions assume the move has a single target - * TODO: is this sustainable? - */ - const failsConditions = !move.applyConditions(this.pokemon, targets[0], move); - const failedDueToWeather = globalScene.arena.isMoveWeatherCancelled(this.pokemon, move); - const failedDueToTerrain = globalScene.arena.isMoveTerrainCancelled(this.pokemon, this.targets, move); - failed ||= failsConditions || failedDueToWeather || failedDueToTerrain; + /* + 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 + */ - if (failed) { - this.failMove(true, failedDueToWeather, failedDueToTerrain); + // 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)) { + console.log("Move failed during fourth failure check"); + this.failMove(); return; } - this.executeMove(); + if (charging) { + this.chargeMove(); + } else { + this.executeMove(); + } } /** Execute the current move and apply its effects. */ private executeMove() { + const user = 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 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] }); - this.showMoveText(); - globalScene.phaseManager.unshiftNew( - "MoveEffectPhase", - this.pokemon.getBattlerIndex(), - this.targets, - move, - this.useMode, - ); + + 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. @@ -381,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: this.pokemon, targets: this.targets }); + applyAbAttrs("PostMoveUsedAbAttr", { pokemon, move: this.move, source: user, targets }); }); } } @@ -389,11 +792,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(); @@ -415,12 +816,13 @@ 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, + }); + 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!". @@ -428,9 +830,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); @@ -440,25 +840,8 @@ 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(); - const targets = this.getActiveTargetPokemon(); - - if (!move.applyConditions(this.pokemon, targets[0], move)) { - this.failMove(true); - 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(), @@ -510,6 +893,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) @@ -574,33 +958,19 @@ 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) { + const targetHolder = new NumberHolder(BattlerIndex.ATTACKER); + + applyMoveAttrs("CounterRedirectAttr", this.pokemon, null, this.move.getMove(), targetHolder); + this.targets[0] = targetHolder.value; + if (targetHolder.value === BattlerIndex.ATTACKER) { this.fail(); - this.showMoveText(); - this.showFailedText(); - return; - } - - 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; } } @@ -623,13 +993,6 @@ export class MovePhase extends PokemonPhase { return; } - if (this.failed) { - // TODO: should this consider struggle? - const ppUsed = isIgnorePP(this.useMode) ? 0 : 1; - this.move.usePp(ppUsed); - globalScene.eventTarget.dispatchEvent(new MoveUsedEvent(this.pokemon?.id, this.move.getMove(), ppUsed)); - } - if (this.cancelled && this.pokemon.summonData.tags.some(t => t.tagType === BattlerTagType.FRENZY)) { frenzyMissFunc(this.pokemon, this.move.getMove()); } 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; diff --git a/src/utils/pokemon-utils.ts b/src/utils/pokemon-utils.ts index 60a4d9e0ef7..96f3bf6b7cb 100644 --- a/src/utils/pokemon-utils.ts +++ b/src/utils/pokemon-utils.ts @@ -1,7 +1,12 @@ +// 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"; import { randSeedItem } from "./common"; @@ -123,3 +128,20 @@ export function getPokemonSpeciesForm(species: SpeciesId, formIndex: number): Po } return retSpecies; } + +/** + * 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`. + */ +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/abilities/cud-chew.test.ts b/test/abilities/cud-chew.test.ts index 8d80ba119ca..fb4875c6fcf 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 }, @@ -121,7 +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(); @@ -196,10 +196,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/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); diff --git a/test/challenges/hardcore.test.ts b/test/challenges/hardcore.test.ts index 0f4ab1b9f02..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)).toBe(false); + expect(revBlessing.isUsable(player)[0]).toBe(false); + expect(revBlessing.isUsable(enemy)[0]).toBe(true); game.move.select(MoveId.REVIVAL_BLESSING); await game.toEndOfTurn(); 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", + ); + }); +}); 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); diff --git a/test/moves/dig.test.ts b/test/moves/dig.test.ts index fcc593b75da..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 not 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]); @@ -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 () => { diff --git a/test/moves/dive.test.ts b/test/moves/dive.test.ts index 6464cb110b4..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 not 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]); @@ -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 () => { 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); }); }); 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(); 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/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); + }); +}); diff --git a/test/moves/throat-chop.test.ts b/test/moves/throat-chop.test.ts index a4f090b2de1..b53b2c4c0e2 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,7 @@ 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]); diff --git a/test/ui/type-hints.test.ts b/test/ui/type-hints.test.ts index b5fe0d9585a..9f1c030e3a2 100644 --- a/test/ui/type-hints.test.ts +++ b/test/ui/type-hints.test.ts @@ -84,11 +84,11 @@ 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]) - .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, () => {