Add failure conditions and move failures part 1

This commit is contained in:
Sirz Benjie 2025-08-16 14:32:47 -05:00
parent dd03887d05
commit 6b34ea3c46
No known key found for this signature in database
GPG Key ID: 4A524B4D196C759E
11 changed files with 783 additions and 356 deletions

View File

@ -9,7 +9,7 @@ import { getStatusEffectHealText } from "#data/status-effect";
import { TerrainType } from "#data/terrain";
import { AbilityId } from "#enums/ability-id";
import type { BattlerIndex } from "#enums/battler-index";
import { BattlerTagLapseType } from "#enums/battler-tag-lapse-type";
import { BattlerTagLapseType, type NonCustomBattlerTagLapseType } from "#enums/battler-tag-lapse-type";
import { BattlerTagType } from "#enums/battler-tag-type";
import { HitResult } from "#enums/hit-result";
import { ChargeAnim, CommonAnim } from "#enums/move-anims-common";
@ -121,6 +121,11 @@ export class BattlerTag implements BaseBattlerTag {
}
#lapseTypes: readonly [BattlerTagLapseType, ...BattlerTagLapseType[]];
/**
* The set of lapse types that this tag can be automatically lapsed with.
* If this is exclusively {@linkcode BattlerTagLapseType.CUSTOM}, then the tag can only ever be lapsed
* manually via {@linkcode Pokemon.lapseTag} (or calling the tag's lapse method directly)
*/
public get lapseTypes(): readonly BattlerTagLapseType[] {
return this.#lapseTypes;
}
@ -128,7 +133,7 @@ export class BattlerTag implements BaseBattlerTag {
constructor(
tagType: BattlerTagType,
lapseType: BattlerTagLapseType | [BattlerTagLapseType, ...BattlerTagLapseType[]],
lapseType: BattlerTagLapseType | [NonCustomBattlerTagLapseType, ...NonCustomBattlerTagLapseType[]],
turnCount: number,
sourceMove?: MoveId,
sourceId?: number,
@ -160,7 +165,10 @@ export class BattlerTag implements BaseBattlerTag {
onOverlap(_pokemon: Pokemon): void {}
/**
* Tick down this {@linkcode BattlerTag}'s duration.
* Apply the battler tag's effects based on the lapse type
*
* @remarks
* Generally, this involves ticking down the tag's duration. The tag also initiates the effects it is responsbile for
* @param _pokemon - The {@linkcode Pokemon} whom this tag belongs to.
* Unused by default but can be used by subclasses.
* @param _lapseType - The {@linkcode BattlerTagLapseType} being lapsed.
@ -302,12 +310,7 @@ export abstract class MoveRestrictionBattlerTag extends SerializableBattlerTag {
export class ThroatChoppedTag extends MoveRestrictionBattlerTag {
public override readonly tagType = BattlerTagType.THROAT_CHOPPED;
constructor() {
super(
BattlerTagType.THROAT_CHOPPED,
[BattlerTagLapseType.TURN_END, BattlerTagLapseType.PRE_MOVE],
2,
MoveId.THROAT_CHOP,
);
super(BattlerTagType.THROAT_CHOPPED, BattlerTagLapseType.TURN_END, 2, MoveId.THROAT_CHOP);
}
/**
@ -356,13 +359,7 @@ export class DisabledTag extends MoveRestrictionBattlerTag {
public readonly moveId: MoveId = MoveId.NONE;
constructor(sourceId: number) {
super(
BattlerTagType.DISABLED,
[BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.TURN_END],
4,
MoveId.DISABLE,
sourceId,
);
super(BattlerTagType.DISABLED, BattlerTagLapseType.TURN_END, 4, MoveId.DISABLE, sourceId);
}
override isMoveRestricted(move: MoveId): boolean {
@ -511,7 +508,10 @@ export class RechargingTag extends SerializableBattlerTag {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
}),
);
(globalScene.phaseManager.getCurrentPhase() as MovePhase).cancel();
const currentPhase = globalScene.phaseManager.getCurrentPhase();
if (currentPhase?.is("MovePhase")) {
currentPhase.cancel();
}
pokemon.getMoveQueue().shift();
}
return super.lapse(pokemon, lapseType);
@ -708,18 +708,21 @@ class NoRetreatTag extends TrappedTag {
export class FlinchedTag extends BattlerTag {
public override readonly tagType = BattlerTagType.FLINCHED;
constructor(sourceMove: MoveId) {
super(BattlerTagType.FLINCHED, [BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.TURN_END], 1, sourceMove);
super(BattlerTagType.FLINCHED, BattlerTagLapseType.TURN_END, 1, sourceMove);
}
/**
* Cancels the flinched Pokemon's currently used move this turn if called mid-execution, or removes the tag at end of turn.
* @param pokemon - The {@linkcode Pokemon} with this tag.
* @param lapseType - The {@linkcode BattlerTagLapseType | lapse type} used for this function call.
* @param lapseType - The {@linkcode BattlerTagLapseType | lapse type} used for this function call. Must be {@linkcode BattlerTagLapseType.PRE_MOVE} in order to apply the flinch effect.
* @returns Whether the tag should remain active.
*/
lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
if (lapseType === BattlerTagLapseType.PRE_MOVE) {
(globalScene.phaseManager.getCurrentPhase() as MovePhase).cancel();
const currentPhase = globalScene.phaseManager.getCurrentPhase();
if (currentPhase?.is("MovePhase")) {
currentPhase.cancel();
}
globalScene.phaseManager.queueMessage(
i18next.t("battlerTags:flinchedLapse", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
@ -729,7 +732,7 @@ export class FlinchedTag extends BattlerTag {
return true;
}
return super.lapse(pokemon, lapseType);
return lapseType === BattlerTagLapseType.TURN_END && super.lapse(pokemon, lapseType);
}
getDescriptor(): string {
@ -760,7 +763,10 @@ export class InterruptedTag extends BattlerTag {
}
lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
(globalScene.phaseManager.getCurrentPhase() as MovePhase).cancel();
const currentPhase = globalScene.phaseManager.getCurrentPhase();
if (currentPhase?.is("MovePhase")) {
currentPhase.cancel();
}
return super.lapse(pokemon, lapseType);
}
}
@ -771,7 +777,7 @@ export class InterruptedTag extends BattlerTag {
export class ConfusedTag extends SerializableBattlerTag {
public override readonly tagType = BattlerTagType.CONFUSED;
constructor(turnCount: number, sourceMove: MoveId) {
super(BattlerTagType.CONFUSED, BattlerTagLapseType.MOVE, turnCount, sourceMove, undefined, true);
super(BattlerTagType.CONFUSED, BattlerTagLapseType.CUSTOM, turnCount, sourceMove, undefined, true);
}
canAdd(pokemon: Pokemon): boolean {
@ -814,8 +820,18 @@ export class ConfusedTag extends SerializableBattlerTag {
);
}
/**
* Tick down the confusion duration and, if there are remaining turns, activate the confusion effect
*
* @remarks
* Rolls the
* @param pokemon - The pokemon with this tag
* @param lapseType - The lapse type
* @returns
*/
lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
const shouldLapse = lapseType !== BattlerTagLapseType.CUSTOM && super.lapse(pokemon, lapseType);
// Duration is only ticked down for PRE_MOVE lapse type
const shouldLapse = lapseType === BattlerTagLapseType.PRE_MOVE && super.lapse(pokemon, lapseType);
if (!shouldLapse) {
return false;
@ -840,7 +856,10 @@ export class ConfusedTag extends SerializableBattlerTag {
// Intentionally don't increment rage fist's hitCount
phaseManager.queueMessage(i18next.t("battlerTags:confusedLapseHurtItself"));
pokemon.damageAndUpdate(damage, { result: HitResult.CONFUSION });
(phaseManager.getCurrentPhase() as MovePhase).cancel();
const currentPhase = phaseManager.getCurrentPhase();
if (currentPhase?.is("MovePhase") && currentPhase.pokemon === pokemon) {
currentPhase.cancel();
}
}
return true;
@ -916,7 +935,7 @@ export class DestinyBondTag extends SerializableBattlerTag {
export class InfatuatedTag extends SerializableBattlerTag {
public override readonly tagType = BattlerTagType.INFATUATED;
constructor(sourceMove: number, sourceId: number) {
super(BattlerTagType.INFATUATED, BattlerTagLapseType.MOVE, 1, sourceMove, sourceId);
super(BattlerTagType.INFATUATED, BattlerTagLapseType.CUSTOM, 1, sourceMove, sourceId);
}
canAdd(pokemon: Pokemon): boolean {
@ -979,7 +998,10 @@ export class InfatuatedTag extends SerializableBattlerTag {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
}),
);
(phaseManager.getCurrentPhase() as MovePhase).cancel();
const currentPhase = phaseManager.getCurrentPhase();
if (currentPhase?.is("MovePhase")) {
currentPhase.cancel();
}
}
return true;
@ -1105,7 +1127,7 @@ export class SeedTag extends SerializableBattlerTag {
export class PowderTag extends BattlerTag {
public override readonly tagType = BattlerTagType.POWDER;
constructor() {
super(BattlerTagType.POWDER, [BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.TURN_END], 1);
super(BattlerTagType.POWDER, BattlerTagLapseType.TURN_END, 1);
}
onAdd(pokemon: Pokemon): void {
@ -1127,23 +1149,27 @@ export class PowderTag extends BattlerTag {
* @returns `true` if the tag should remain active.
*/
lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
const movePhase = globalScene.phaseManager.getCurrentPhase();
if (lapseType !== BattlerTagLapseType.PRE_MOVE || !movePhase?.is("MovePhase")) {
if (lapseType === BattlerTagLapseType.TURN_END) {
return false;
}
const currentPhase = globalScene.phaseManager.getCurrentPhase();
const move = movePhase.move.getMove();
if (!currentPhase?.is("MovePhase")) {
return true;
}
const move = currentPhase.move.getMove();
const weather = globalScene.arena.weather;
if (
pokemon.getMoveType(move) !== PokemonType.FIRE ||
(weather?.weatherType === WeatherType.HEAVY_RAIN && !weather.isEffectSuppressed()) // Heavy rain takes priority over powder
(weather?.weatherType === WeatherType.HEAVY_RAIN && !weather.isEffectSuppressed()) // Since gen 7, Heavy rain takes priority over powder
) {
return true;
}
// Disable the target's fire type move and damage it (subject to Magic Guard)
movePhase.showMoveText();
movePhase.fail();
currentPhase.showMoveText();
currentPhase.fail();
const idx = pokemon.getBattlerIndex();
@ -1243,13 +1269,7 @@ export class EncoreTag extends MoveRestrictionBattlerTag {
public moveId: MoveId;
constructor(sourceId: number) {
super(
BattlerTagType.ENCORE,
[BattlerTagLapseType.CUSTOM, BattlerTagLapseType.AFTER_MOVE],
3,
MoveId.ENCORE,
sourceId,
);
super(BattlerTagType.ENCORE, BattlerTagLapseType.AFTER_MOVE, 3, MoveId.ENCORE, sourceId);
}
public override loadTag(source: BaseBattlerTag & Pick<EncoreTag, "tagType" | "moveId">): void {
@ -2044,7 +2064,7 @@ export class UnburdenTag extends AbilityBattlerTag {
export class TruantTag extends AbilityBattlerTag {
public override readonly tagType = BattlerTagType.TRUANT;
constructor() {
super(BattlerTagType.TRUANT, AbilityId.TRUANT, BattlerTagLapseType.MOVE, 1);
super(BattlerTagType.TRUANT, AbilityId.TRUANT, BattlerTagLapseType.CUSTOM, 1);
}
lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
@ -2880,12 +2900,7 @@ export class ExposedTag extends SerializableBattlerTag {
export class HealBlockTag extends MoveRestrictionBattlerTag {
public override readonly tagType = BattlerTagType.HEAL_BLOCK;
constructor(turnCount: number, sourceMove: MoveId) {
super(
BattlerTagType.HEAL_BLOCK,
[BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.TURN_END],
turnCount,
sourceMove,
);
super(BattlerTagType.HEAL_BLOCK, BattlerTagLapseType.TURN_END, turnCount, sourceMove);
}
onActivation(pokemon: Pokemon): string {
@ -3134,7 +3149,7 @@ export class SubstituteTag extends SerializableBattlerTag {
lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
switch (lapseType) {
case BattlerTagLapseType.PRE_MOVE:
case BattlerTagLapseType.MOVE:
this.onPreMove(pokemon);
break;
case BattlerTagLapseType.AFTER_MOVE:
@ -3292,7 +3307,7 @@ export class TormentTag extends MoveRestrictionBattlerTag {
export class TauntTag extends MoveRestrictionBattlerTag {
public override readonly tagType = BattlerTagType.TAUNT;
constructor() {
super(BattlerTagType.TAUNT, [BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.AFTER_MOVE], 4, MoveId.TAUNT);
super(BattlerTagType.TAUNT, BattlerTagLapseType.AFTER_MOVE, 4, MoveId.TAUNT);
}
override onAdd(pokemon: Pokemon) {
@ -3347,13 +3362,7 @@ export class TauntTag extends MoveRestrictionBattlerTag {
export class ImprisonTag extends MoveRestrictionBattlerTag {
public override readonly tagType = BattlerTagType.IMPRISON;
constructor(sourceId: number) {
super(
BattlerTagType.IMPRISON,
[BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.AFTER_MOVE],
1,
MoveId.IMPRISON,
sourceId,
);
super(BattlerTagType.IMPRISON, BattlerTagLapseType.AFTER_MOVE, 1, MoveId.IMPRISON, sourceId);
}
/**
@ -3546,7 +3555,7 @@ export class PowerTrickTag extends SerializableBattlerTag {
export class GrudgeTag extends SerializableBattlerTag {
public override readonly tagType = BattlerTagType.GRUDGE;
constructor() {
super(BattlerTagType.GRUDGE, [BattlerTagLapseType.CUSTOM, BattlerTagLapseType.PRE_MOVE], 1, MoveId.GRUDGE);
super(BattlerTagType.GRUDGE, BattlerTagLapseType.PRE_MOVE, 1, MoveId.GRUDGE);
}
onAdd(pokemon: Pokemon) {
@ -3567,23 +3576,23 @@ export class GrudgeTag extends SerializableBattlerTag {
*/
// TODO: Confirm whether this should interact with copying moves
override lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType, sourcePokemon?: Pokemon): boolean {
if (lapseType === BattlerTagLapseType.CUSTOM && sourcePokemon) {
if (sourcePokemon.isActive() && pokemon.isOpponent(sourcePokemon)) {
const lastMove = pokemon.turnData.attacksReceived[0];
const lastMoveData = sourcePokemon.getMoveset().find(m => m.moveId === lastMove.move);
if (lastMoveData && lastMove.move !== MoveId.STRUGGLE) {
lastMoveData.ppUsed = lastMoveData.getMovePp();
globalScene.phaseManager.queueMessage(
i18next.t("battlerTags:grudgeLapse", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
moveName: lastMoveData.getName(),
}),
);
}
}
return false;
if (!sourcePokemon || lapseType !== BattlerTagLapseType.CUSTOM) {
return super.lapse(pokemon, lapseType);
}
return super.lapse(pokemon, lapseType);
if (sourcePokemon.isActive() && pokemon.isOpponent(sourcePokemon)) {
const lastMove = pokemon.turnData.attacksReceived[0];
const lastMoveData = sourcePokemon.getMoveset().find(m => m.moveId === lastMove.move);
if (lastMoveData && lastMove.move !== MoveId.STRUGGLE) {
lastMoveData.ppUsed = lastMoveData.getMovePp();
globalScene.phaseManager.queueMessage(
i18next.t("battlerTags:grudgeLapse", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
moveName: lastMoveData.getName(),
}),
);
}
}
return false;
}
}

View File

@ -0,0 +1,117 @@
import { globalScene } from "#app/global-scene";
import { getPokemonNameWithAffix } from "#app/messages";
import { allMoves } from "#data/data-lists";
import { ArenaTagType } from "#enums/arena-tag-type";
import { Command } from "#enums/command";
import { MoveCategory } from "#enums/move-category";
import type { Pokemon } from "#field/pokemon";
import type { Move, MoveConditionFunc, UserMoveConditionFunc } from "#moves/move";
import i18next from "i18next";
/**
* A condition that determines whether a move can be used successfully.
*
* @remarks
* This is only checked when the move is attempted to be invoked. To prevent a move from being selected,
* use a {@linkcode MoveRestriction} instead.
*/
export class MoveCondition {
public declare readonly func: MoveConditionFunc;
/**
* @param func - A condition function that determines if the move can be used successfully
*/
constructor(func?: MoveConditionFunc) {
if (func) {
this.func = func;
}
}
apply(user: Pokemon, target: Pokemon, move: Move): boolean {
return this.func(user, target, move);
}
getUserBenefitScore(_user: Pokemon, _target: Pokemon, _move: Move): number {
return 0;
}
}
/**
* Condition to allow a move's use only on the first turn this Pokemon is sent into battle
* (or the start of a new wave, whichever comes first).
*/
export class FirstMoveCondition extends MoveCondition {
public override readonly func: MoveConditionFunc = user => {
return user.tempSummonData.waveTurnCount === 1;
};
getUserBenefitScore(user: Pokemon, _target: Pokemon, _move: Move): number {
return this.apply(user, _target, _move) ? 10 : -20;
}
}
/**
* Condition used by the move {@link https://bulbapedia.bulbagarden.net/wiki/Upper_Hand_(move) | Upper Hand}.
* Moves with this condition are only successful when the target has selected
* a high-priority attack (after factoring in priority-boosting effects) and
* hasn't moved yet this turn.
*/
export class UpperHandCondition extends MoveCondition {
public override readonly func: MoveConditionFunc = (_user, target) => {
const targetCommand = globalScene.currentBattle.turnCommands[target.getBattlerIndex()];
return (
targetCommand?.command === Command.FIGHT &&
!target.turnData.acted &&
!!targetCommand.move?.move &&
allMoves[targetCommand.move.move].category !== MoveCategory.STATUS &&
allMoves[targetCommand.move.move].getPriority(target) > 0
);
};
}
/**
* A restriction that prevents a move from being selected
*
* @remarks
* Only checked when the move is selected, but not when it is attempted to be invoked. To prevent a move from being used,
* use a {@linkcode MoveCondition} instead.
*/
export class MoveRestriction {
public declare readonly func: UserMoveConditionFunc;
public declare readonly i18nkey: string;
constructor(func: UserMoveConditionFunc, i18nkey = "battle:moveRestricted") {
this.func = func;
this.i18nkey = i18nkey;
}
/**
* @param user - The Pokemon attempting to select the move
* @param move - The move being selected
* @returns Whether the move is restricted for the user.
*/
apply(user: Pokemon, move: Move): boolean {
return this.func(user, move);
}
public getSelectionDeniedText(user: Pokemon, move: Move): string {
// While not all restriction texts use all the parameters, passing extra ones is harmless
return i18next.t(this.i18nkey, { pokemonNameWithAffix: getPokemonNameWithAffix(user), moveName: move.name });
}
}
/**
* Prevents a Pokemon from using the move if it was the last move it used
*
* @remarks
* Used by {@link https://bulbapedia.bulbagarden.net/wiki/Blood_Moon_(move) | Blood Moon} and {@link https://bulbapedia.bulbagarden.net/wiki/Gigaton_Hammer_(move) | Gigaton Hammer}
*/
export const ConsecutiveUseRestriction = new MoveRestriction(
(user, move) => user.getLastXMoves(1)[0]?.move === move.id,
"battle:moveDisabledConsecutive",
);
/** Prevents a move from being selected if Gravity is in effect */
export const GravityUseRestriction = new MoveRestriction(
() => globalScene.arena.hasTag(ArenaTagType.GRAVITY),
"battle:moveDisabledGravity",
);

View File

@ -93,12 +93,13 @@ import { getEnumValues } from "#utils/enums";
import { toCamelCase, toTitleCase } from "#utils/strings";
import i18next from "i18next";
import { applyChallenges } from "#utils/challenge-utils";
import { ConsecutiveUseRestriction, FirstMoveCondition, GravityUseRestriction, MoveCondition, MoveRestriction, UpperHandCondition } from "#moves/move-condition";
/**
* A function used to conditionally determine execution of a given {@linkcode MoveAttr}.
* Conventionally returns `true` for success and `false` for failure.
*/
type MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => boolean;
export type MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => boolean;
export type UserMoveConditionFunc = (user: Pokemon, move: Move) => boolean;
export abstract class Move implements Localizable {
@ -116,7 +117,16 @@ export abstract class Move implements Localizable {
public priority: number;
public generation: number;
public attrs: MoveAttr[] = [];
/** Conditions that must be met for the move to succeed when it is used.
*
* @remarks Different from {@linkcode restrictions}, which is checked when the move is selected
*/
private conditions: MoveCondition[] = [];
/** Conditions that must be false for a move to be able to be selected.
*
* @remarks Different from {@linkcode conditions}, which is checked when the move is invoked
*/
private restrictions: MoveRestriction[] = [];
/** The move's {@linkcode MoveFlags} */
private flags: number = 0;
private nameAppend: string = "";
@ -370,7 +380,7 @@ export abstract class Move implements Localizable {
* Adds a condition to this move (in addition to any provided by its prior {@linkcode MoveAttr}s).
* The move will fail upon use if at least 1 of its conditions is not met.
* @param condition - The {@linkcode MoveCondition} or {@linkcode MoveConditionFunc} to add to the conditions array.
* @returns `this`
* @returns `this` for method chaining
*/
condition(condition: MoveCondition | MoveConditionFunc): this {
if (typeof condition === "function") {
@ -381,6 +391,48 @@ export abstract class Move implements Localizable {
return this;
}
/**
* Adds a restriction condition to this move.
* The move will not be selectable if at least 1 of its restrictions is met.
* @param restriction - A function that evaluates to `true` if the move is restricted from being selected
* @returns `this` for method chaining
*/
public restriction(restriction: MoveRestriction): this;
/**
* Adds a restriction condition to this move.
* The move will not be selectable if at least 1 of its restrictions is met.
* @param restriction - The function or `MoveRestriction` that evaluates to `true` if the move is restricted from
* being selected
* @param i18nkey - The i18n key for the restriction text
* @param alsoCondition - If `true`, also adds a {@linkcode MoveCondition} that checks the same condition when the
* move is used; default `false`
* @returns `this` for method chaining
*/
public restriction(restriction: UserMoveConditionFunc, i18nkey: string, alsoCondition?: boolean): this;
/**
* Adds a restriction condition to this move.
* The move will not be selectable if at least 1 of its restrictions is met.
* @param restriction - The function or `MoveRestriction` that evaluates to `true` if the move is restricted from
* being selected
* @param i18nkey - The i18n key for the restriction text, ignored if `restriction` is a `MoveRestriction`
* @param alsoCondition - If `true`, also adds a {@linkcode MoveCondition} that checks the same condition when the
* move is used; default `false`. Ignored if `restriction` is a `MoveRestriction`.
* @returns `this` for method chaining
*/
public restriction<T extends UserMoveConditionFunc | MoveRestriction>(restriction: T, i18nkey?: string, alsoCondition: typeof restriction extends MoveRestriction ? false : boolean = false): this {
if (typeof restriction === "function") {
this.restrictions.push(new MoveRestriction(restriction));
if (alsoCondition) {
this.conditions.push(new MoveCondition((user, _, move) => restriction(user, move)));
}
} else {
this.restrictions.push(restriction);
}
return this;
}
/**
* Mark a move as having one or more edge cases.
* The move may lack certain niche interactions with other moves/abilities,
@ -579,6 +631,19 @@ export abstract class Move implements Localizable {
return this;
}
/**
* Sets the {@linkcode MoveFlags.GRAVITY} flag for the calling Move and adds {@linkcode GravityUseRestriction} to the
* move's restrictions.
*
* @see {@linkcode MoveId.GRAVITY}
* @returns The {@linkcode Move} that called this function
*/
affectedByGravity(): this {
this.setFlag(MoveFlags.GRAVITY, true);
this.restrictions.push(GravityUseRestriction);
return this;
}
/**
* Sets the {@linkcode MoveFlags.IGNORE_ABILITIES} flag for the calling Move
* @see {@linkcode MoveId.SUNSTEEL_STRIKE}
@ -702,8 +767,29 @@ export abstract class Move implements Localizable {
* @param move {@linkcode Move} to apply conditions to
* @returns boolean: false if any of the apply()'s return false, else true
*/
applyConditions(user: Pokemon, target: Pokemon, move: Move): boolean {
return this.conditions.every(cond => cond.apply(user, target, move));
applyConditions(user: Pokemon, target: Pokemon): boolean {
return this.conditions.every(cond => cond.apply(user, target, this));
}
/**
* Determine whether the move is restricted from being selected due to its own requirements.
*
* @remarks
* Does not check for external factors that prohibit move selection, such as disable
*
* @param user - The Pokemon using the move
* @returns - An array whose first element is `false` if the move is restricted, and the second element is a string
* with the reason for the restriction, otherwise, `false` and the empty string.
*/
public checkRestrictions(user: Pokemon): [boolean, string] {
for (const restriction of this.restrictions) {
if (restriction.apply(user, this)) {
return [false, restriction.getSelectionDeniedText(user, this)];
}
}
return [true, ""];
}
/**
@ -1473,13 +1559,21 @@ export class PreUseInterruptAttr extends MoveAttr {
}
/**
* Message to display when a move is interrupted.
* @param user {@linkcode Pokemon} using the move
* @param target {@linkcode Pokemon} target of the move
* @param move {@linkcode Move} with this attribute
* Cancel the current MovePhase and queue the interrupt message if the condition is met
* @param user - {@linkcode Pokemon} using the move
* @param target - {@linkcode Pokemon} target of the move
* @param move - {@linkcode Move} with this attribute
*/
override apply(user: Pokemon, target: Pokemon, move: Move): boolean {
return this.conditionFunc(user, target, move);
const currentPhase = globalScene.phaseManager.getCurrentPhase();
if (!currentPhase?.is("MovePhase") || !this.conditionFunc(user, target, move)) {
return false;
}
currentPhase.cancel();
globalScene.phaseManager.queueMessage(
typeof this.message === "string" ? this.message : this.message(user, target, move)
)
return true;
}
/**
@ -8040,8 +8134,6 @@ const phaseForcedSlower = (phase: MovePhase, target: Pokemon, trickRoom: boolean
return phase.isForcedLast() && slower;
};
const failOnGravityCondition: MoveConditionFunc = (user, target, move) => !globalScene.arena.getTag(ArenaTagType.GRAVITY);
const failOnBossCondition: MoveConditionFunc = (user, target, move) => !target.isBossImmune();
const failIfSingleBattle: MoveConditionFunc = (user, target, move) => globalScene.currentBattle.double;
@ -8081,57 +8173,6 @@ const attackedByItemMessageFunc = (user: Pokemon, target: Pokemon, move: Move) =
return message;
};
export class MoveCondition {
protected func: MoveConditionFunc;
constructor(func: MoveConditionFunc) {
this.func = func;
}
apply(user: Pokemon, target: Pokemon, move: Move): boolean {
return this.func(user, target, move);
}
getUserBenefitScore(user: Pokemon, target: Pokemon, move: Move): number {
return 0;
}
}
/**
* Condition to allow a move's use only on the first turn this Pokemon is sent into battle
* (or the start of a new wave, whichever comes first).
*/
export class FirstMoveCondition extends MoveCondition {
constructor() {
super((user, _target, _move) => user.tempSummonData.waveTurnCount === 1);
}
getUserBenefitScore(user: Pokemon, _target: Pokemon, _move: Move): number {
return this.apply(user, _target, _move) ? 10 : -20;
}
}
/**
* Condition used by the move {@link https://bulbapedia.bulbagarden.net/wiki/Upper_Hand_(move) | Upper Hand}.
* Moves with this condition are only successful when the target has selected
* a high-priority attack (after factoring in priority-boosting effects) and
* hasn't moved yet this turn.
*/
export class UpperHandCondition extends MoveCondition {
constructor() {
super((user, target, move) => {
const targetCommand = globalScene.currentBattle.turnCommands[target.getBattlerIndex()];
return targetCommand?.command === Command.FIGHT
&& !target.turnData.acted
&& !!targetCommand.move?.move
&& allMoves[targetCommand.move.move].category !== MoveCategory.STATUS
&& allMoves[targetCommand.move.move].getPriority(target) > 0;
});
}
}
export class HitsSameTypeAttr extends VariableMoveTypeMultiplierAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
const multiplier = args[0] as NumberHolder;
@ -8538,7 +8579,7 @@ export function initMoves() {
new ChargingAttackMove(MoveId.FLY, PokemonType.FLYING, MoveCategory.PHYSICAL, 90, 95, 15, -1, 0, 1)
.chargeText(i18next.t("moveTriggers:flewUpHigh", { pokemonName: "{USER}" }))
.chargeAttr(SemiInvulnerableAttr, BattlerTagType.FLYING)
.condition(failOnGravityCondition),
.affectedByGravity(),
new AttackMove(MoveId.BIND, PokemonType.NORMAL, MoveCategory.PHYSICAL, 15, 85, 20, -1, 0, 1)
.attr(TrapAttr, BattlerTagType.BIND),
new AttackMove(MoveId.SLAM, PokemonType.NORMAL, MoveCategory.PHYSICAL, 80, 75, 20, -1, 0, 1),
@ -8553,7 +8594,7 @@ export function initMoves() {
new AttackMove(MoveId.JUMP_KICK, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 100, 95, 10, -1, 0, 1)
.attr(MissEffectAttr, crashDamageFunc)
.attr(NoEffectAttr, crashDamageFunc)
.condition(failOnGravityCondition)
.affectedByGravity()
.recklessMove(),
new AttackMove(MoveId.ROLLING_KICK, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 60, 85, 15, 30, 0, 1)
.attr(FlinchAttr),
@ -8868,7 +8909,7 @@ export function initMoves() {
new AttackMove(MoveId.HIGH_JUMP_KICK, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 130, 90, 10, -1, 0, 1)
.attr(MissEffectAttr, crashDamageFunc)
.attr(NoEffectAttr, crashDamageFunc)
.condition(failOnGravityCondition)
.affectedByGravity()
.recklessMove(),
new StatusMove(MoveId.GLARE, PokemonType.NORMAL, 100, 30, -1, 0, 1)
.attr(StatusEffectAttr, StatusEffect.PARALYSIS)
@ -8922,7 +8963,7 @@ export function initMoves() {
.attr(RandomLevelDamageAttr),
new SelfStatusMove(MoveId.SPLASH, PokemonType.NORMAL, -1, 40, -1, 0, 1)
.attr(MessageAttr, i18next.t("moveTriggers:splash"))
.condition(failOnGravityCondition),
.affectedByGravity(),
new SelfStatusMove(MoveId.ACID_ARMOR, PokemonType.POISON, -1, 20, -1, 0, 1)
.attr(StatStageChangeAttr, [ Stat.DEF ], 2, true),
new AttackMove(MoveId.CRABHAMMER, PokemonType.WATER, MoveCategory.PHYSICAL, 100, 90, 10, -1, 0, 1)
@ -9563,7 +9604,7 @@ export function initMoves() {
.chargeText(i18next.t("moveTriggers:sprangUp", { pokemonName: "{USER}" }))
.chargeAttr(SemiInvulnerableAttr, BattlerTagType.FLYING)
.attr(StatusEffectAttr, StatusEffect.PARALYSIS)
.condition(failOnGravityCondition),
.affectedByGravity(),
new AttackMove(MoveId.MUD_SHOT, PokemonType.GROUND, MoveCategory.SPECIAL, 55, 95, 15, 100, 0, 3)
.attr(StatStageChangeAttr, [ Stat.SPD ], -1),
new AttackMove(MoveId.POISON_TAIL, PokemonType.POISON, MoveCategory.PHYSICAL, 50, 100, 25, 10, 0, 3)
@ -9615,6 +9656,7 @@ export function initMoves() {
new StatusMove(MoveId.GRAVITY, PokemonType.PSYCHIC, -1, 5, -1, 0, 4)
.ignoresProtect()
.attr(AddArenaTagAttr, ArenaTagType.GRAVITY, 5)
.condition(() => !globalScene.arena.hasTag(ArenaTagType.GRAVITY))
.target(MoveTarget.BOTH_SIDES),
new StatusMove(MoveId.MIRACLE_EYE, PokemonType.PSYCHIC, -1, 40, -1, 0, 4)
.attr(ExposedMoveAttr, BattlerTagType.IGNORE_DARK)
@ -9745,7 +9787,8 @@ export function initMoves() {
.attr(AddBattlerTagAttr, BattlerTagType.AQUA_RING, true, true),
new SelfStatusMove(MoveId.MAGNET_RISE, PokemonType.ELECTRIC, -1, 10, -1, 0, 4)
.attr(AddBattlerTagAttr, BattlerTagType.FLOATING, true, true, 5)
.condition((user, target, move) => !globalScene.arena.getTag(ArenaTagType.GRAVITY) && [ BattlerTagType.FLOATING, BattlerTagType.IGNORE_FLYING, BattlerTagType.INGRAIN ].every((tag) => !user.getTag(tag))),
.condition(user => [ BattlerTagType.FLOATING, BattlerTagType.IGNORE_FLYING, BattlerTagType.INGRAIN ].every((tag) => !user.getTag(tag)))
.affectedByGravity(),
new AttackMove(MoveId.FLARE_BLITZ, PokemonType.FIRE, MoveCategory.PHYSICAL, 120, 100, 15, 10, 0, 4)
.attr(RecoilAttr, false, 0.33)
.attr(HealStatusEffectAttr, true, StatusEffect.FREEZE)
@ -9974,7 +10017,7 @@ export function initMoves() {
.powderMove()
.attr(AddBattlerTagAttr, BattlerTagType.CENTER_OF_ATTENTION, true),
new StatusMove(MoveId.TELEKINESIS, PokemonType.PSYCHIC, -1, 15, -1, 0, 5)
.condition(failOnGravityCondition)
.affectedByGravity()
.condition((_user, target, _move) => ![ SpeciesId.DIGLETT, SpeciesId.DUGTRIO, SpeciesId.ALOLA_DIGLETT, SpeciesId.ALOLA_DUGTRIO, SpeciesId.SANDYGAST, SpeciesId.PALOSSAND, SpeciesId.WIGLETT, SpeciesId.WUGTRIO ].includes(target.species.speciesId))
.condition((_user, target, _move) => !(target.species.speciesId === SpeciesId.GENGAR && target.getFormKey() === "mega"))
.condition((_user, target, _move) => isNullOrUndefined(target.getTag(BattlerTagType.INGRAIN)) && isNullOrUndefined(target.getTag(BattlerTagType.IGNORE_FLYING)))
@ -10078,7 +10121,7 @@ export function initMoves() {
new ChargingAttackMove(MoveId.SKY_DROP, PokemonType.FLYING, MoveCategory.PHYSICAL, 60, 100, 10, -1, 0, 5)
.chargeText(i18next.t("moveTriggers:tookTargetIntoSky", { pokemonName: "{USER}", targetName: "{TARGET}" }))
.chargeAttr(SemiInvulnerableAttr, BattlerTagType.FLYING)
.condition(failOnGravityCondition)
.affectedByGravity()
.condition((user, target, move) => !target.getTag(BattlerTagType.SUBSTITUTE))
/*
* Cf https://bulbapedia.bulbagarden.net/wiki/Sky_Drop_(move) and https://www.smogon.com/dex/sv/moves/sky-drop/:
@ -10266,14 +10309,14 @@ export function initMoves() {
.attr(AlwaysHitMinimizeAttr)
.attr(FlyingTypeMultiplierAttr)
.attr(HitsTagForDoubleDamageAttr, BattlerTagType.MINIMIZED)
.condition(failOnGravityCondition),
.affectedByGravity(),
new StatusMove(MoveId.MAT_BLOCK, PokemonType.FIGHTING, -1, 10, -1, 0, 6)
.target(MoveTarget.USER_SIDE)
.attr(AddArenaTagAttr, ArenaTagType.MAT_BLOCK, 1, true, true)
.condition(new FirstMoveCondition())
.condition(failIfLastCondition),
new AttackMove(MoveId.BELCH, PokemonType.POISON, MoveCategory.SPECIAL, 120, 90, 10, -1, 0, 6)
.condition((user, target, move) => user.battleData.hasEatenBerry),
.restriction(user => !user.battleData.hasEatenBerry, "battle:moveDisabledBelch", true),
new StatusMove(MoveId.ROTOTILLER, PokemonType.GROUND, -1, 10, -1, 0, 6)
.target(MoveTarget.ALL)
.condition((user, target, move) => {
@ -10804,7 +10847,8 @@ export function initMoves() {
.attr(StatusEffectAttr, StatusEffect.PARALYSIS)
.target(MoveTarget.ALL_NEAR_ENEMIES),
new AttackMove(MoveId.FLOATY_FALL, PokemonType.FLYING, MoveCategory.PHYSICAL, 90, 95, 15, 30, 0, 7)
.attr(FlinchAttr),
.attr(FlinchAttr)
.affectedByGravity(),
new AttackMove(MoveId.PIKA_PAPOW, PokemonType.ELECTRIC, MoveCategory.SPECIAL, -1, -1, 20, -1, 0, 7)
.attr(FriendshipPowerAttr),
new AttackMove(MoveId.BOUNCY_BUBBLE, PokemonType.WATER, MoveCategory.SPECIAL, 60, 100, 20, -1, 0, 7)
@ -10858,11 +10902,10 @@ export function initMoves() {
new SelfStatusMove(MoveId.STUFF_CHEEKS, PokemonType.NORMAL, -1, 10, -1, 0, 8)
.attr(EatBerryAttr, true)
.attr(StatStageChangeAttr, [ Stat.DEF ], 2, true)
.condition((user) => {
const userBerries = globalScene.findModifiers(m => m instanceof BerryModifier, user.isPlayer());
return userBerries.length > 0;
})
.edgeCase(), // Stuff Cheeks should not be selectable when the user does not have a berry, see wiki
.restriction(
user => globalScene.findModifiers(m => m instanceof BerryModifier, user.isPlayer()).length > 0,
"battle:moveDisabledNoBerry",
true),
new SelfStatusMove(MoveId.NO_RETREAT, PokemonType.FIGHTING, -1, 5, -1, 0, 8)
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.DEF, Stat.SPATK, Stat.SPDEF, Stat.SPD ], 1, true)
.attr(AddBattlerTagAttr, BattlerTagType.NO_RETREAT, true, false)
@ -11439,10 +11482,7 @@ export function initMoves() {
}),
new AttackMove(MoveId.GIGATON_HAMMER, PokemonType.STEEL, MoveCategory.PHYSICAL, 160, 100, 5, -1, 0, 9)
.makesContact(false)
.condition((user, target, move) => {
const turnMove = user.getLastXMoves(1);
return !turnMove.length || turnMove[0].move !== move.id || turnMove[0].result !== MoveResult.SUCCESS;
}), // TODO Add Instruct/Encore interaction
.restriction(ConsecutiveUseRestriction),
new AttackMove(MoveId.COMEUPPANCE, PokemonType.DARK, MoveCategory.PHYSICAL, -1, 100, 10, -1, 0, 9)
.attr(CounterDamageAttr, (move: Move) => (move.category === MoveCategory.PHYSICAL || move.category === MoveCategory.SPECIAL), 1.5)
.redirectCounter()
@ -11467,10 +11507,7 @@ export function initMoves() {
.attr(ConfuseAttr)
.makesContact(false),
new AttackMove(MoveId.BLOOD_MOON, PokemonType.NORMAL, MoveCategory.SPECIAL, 140, 100, 5, -1, 0, 9)
.condition((user, target, move) => {
const turnMove = user.getLastXMoves(1);
return !turnMove.length || turnMove[0].move !== move.id || turnMove[0].result !== MoveResult.SUCCESS;
}), // TODO Add Instruct/Encore interaction
.restriction(ConsecutiveUseRestriction),
new AttackMove(MoveId.MATCHA_GOTCHA, PokemonType.GRASS, MoveCategory.SPECIAL, 80, 90, 15, 20, 0, 9)
.attr(HitHealAttr)
.attr(HealStatusEffectAttr, true, StatusEffect.FREEZE)

View File

@ -5,6 +5,7 @@ import type { Pokemon } from "#field/pokemon";
import type { Move } from "#moves/move";
import { applyChallenges } from "#utils/challenge-utils";
import { BooleanHolder, toDmgValue } from "#utils/common";
import i18next from "i18next";
/**
* Wrapper class for the {@linkcode Move} class for Pokemon to interact with.
@ -38,33 +39,58 @@ export class PokemonMove {
}
/**
* Checks whether this move can be selected/performed by a Pokemon, without consideration for the move's targets.
* Checks whether this move can be performed by a Pokemon, without consideration for the move's targets.
* The move is unusable if it is out of PP, restricted by an effect, or unimplemented.
*
* Should not be confused with {@linkcode isSelectable}, which only checks if the move can be selected by a Pokemon.
*
* @param pokemon - The {@linkcode Pokemon} attempting to use this move
* @param ignorePp - Whether to ignore checking if the move is out of PP; default `false`
* @param ignoreRestrictionTags - Whether to skip checks for {@linkcode MoveRestrictionBattlerTag}s; default `false`
* @param forSelection - Whether this is being checked for move selection; default `false`
* @returns Whether this {@linkcode PokemonMove} can be selected by this Pokemon.
*/
isUsable(pokemon: Pokemon, ignorePp = false, ignoreRestrictionTags = false): boolean {
public isUsable(pokemon: Pokemon, ignorePp = false, forSelection = false): [boolean, string] {
const move = this.getMove();
const moveName = move.name;
// TODO: Add Sky Drop's 1 turn stall
const usability = new BooleanHolder(
!move.name.endsWith(" (N)") &&
(ignorePp || this.ppUsed < this.getMovePp() || move.pp === -1) &&
// TODO: Review if the `MoveId.NONE` check is even necessary anymore
!(this.moveId !== MoveId.NONE && !ignoreRestrictionTags && pokemon.isMoveRestricted(this.moveId, pokemon)),
);
if (pokemon.isPlayer()) {
applyChallenges(ChallengeType.POKEMON_MOVE, move.id, usability);
if (this.moveId === MoveId.NONE || move.name.endsWith(" (N)")) {
return [false, i18next.t("battle:moveNotImplemented", moveName.replace(" (N)", ""))];
}
return usability.value;
if (!ignorePp && move.pp !== -1 && this.ppUsed >= this.getMovePp()) {
return [false, i18next.t("battle:moveNoPP", { moveName: move.name })];
}
if (forSelection) {
const result = pokemon.isMoveSelectable(this.moveId);
if (!result[0]) {
return result;
}
}
const usability = new BooleanHolder(true);
if (applyChallenges(ChallengeType.POKEMON_MOVE, this.moveId, usability) && !usability.value) {
return [false, i18next.t("battle:moveCannotUseChallenge", { moveName: move.name })];
}
return [true, ""];
}
getMove(): Move {
return allMoves[this.moveId];
}
/**
* Determine whether the move can be selected by the pokemon
*
* @param pokemon - The Pokemon under consideration
* @returns An array containing a boolean indicating whether the move can be selected, and a string with the reason if it cannot
*/
public isSelectable(pokemon: Pokemon): [boolean, string] {
return pokemon.isMoveSelectable(this.moveId);
}
/**
* Sets {@link ppUsed} for this move and ensures the value does not exceed {@link getMovePp}
* @param count Amount of PP to use

View File

@ -6,11 +6,12 @@ export enum BattlerTagLapseType {
// TODO: This is unused...
FAINT,
/**
* Tag activate before the holder uses a non-virtual move, possibly interrupting its action.
* Tag activate before the holder uses a non-virtual move, after passing the first failure check sequence during the
* move phase.
* @see MoveUseMode for more information
*/
MOVE,
/** Tag activates before the holder uses **any** move, triggering effects or interrupting its action. */
/** Tag activates during (or just after) the first failure check sequence in the move phase. */
PRE_MOVE,
/** Tag activates immediately after the holder's move finishes triggering (successful or not). */
AFTER_MOVE,
@ -32,6 +33,16 @@ export enum BattlerTagLapseType {
* but still triggers on being KO'd.
*/
AFTER_HIT,
/** The tag has some other custom activation or removal condition. */
/**
* The tag has some other custom activation or removal condition.
* @remarks
* Tags can use this lapse type to prevent them from being automatically lapsed during automatic lapse instances,
* such as before a move is used or at the end of a turn. Note that a tag's lapse method can still make use of
* these lapse types, which can be invoked via the `lapseTag` method on {@linkcode Pokemon} with the tag and
* lapse type.
* */
CUSTOM,
}
/** Same type as {@linkcode BattlerTagLapseType}, but excludes the {@linkcode BattlerTagLapseType.CUSTOM} type */
export type NonCustomBattlerTagLapseType = Exclude<BattlerTagLapseType, BattlerTagLapseType.CUSTOM>;

View File

@ -50,5 +50,7 @@ export enum MoveFlags {
/** Indicates a move is able to be redirected to allies in a double battle if the attacker faints */
REDIRECT_COUNTER = 1 << 18,
/** Indicates a move is able to be reflected by {@linkcode AbilityId.MAGIC_BOUNCE} and {@linkcode MoveId.MAGIC_COAT} */
REFLECTABLE = 1 << 19
REFLECTABLE = 1 << 19,
/** Indicates a move that fails when {@link https://bulbapedia.bulbagarden.net/wiki/Gravity_(move) | Gravity} is in effect */
GRAVITY = 1 << 20,
}

View File

@ -3295,9 +3295,15 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
}
}
public trySelectMove(moveIndex: number, ignorePp?: boolean): boolean {
/**
* Attempt to select the move at the move index.
* @param moveIndex
* @param ignorePp
* @returns
*/
public trySelectMove(moveIndex: number, ignorePp?: boolean): [boolean, string] {
const move = this.getMoveset().length > moveIndex ? this.getMoveset()[moveIndex] : null;
return move?.isUsable(this, ignorePp) ?? false;
return move?.isUsable(this, ignorePp) ?? [false, ""];
}
showInfo(): void {
@ -4267,19 +4273,28 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
}
/**
* Tick down the first {@linkcode BattlerTag} found matching the given {@linkcode BattlerTagType},
* removing it if its duration goes below 0.
* @param tagType the {@linkcode BattlerTagType} to check against
* @returns `true` if the tag was present
* Lapse the first {@linkcode BattlerTag} matching `tagType`
*
* @remarks
* Also responsible for removing the tag when the lapse method returns `false`.
*
*
* Lapse types other than `CUSTOM` are generally lapsed automatically. However, some tags
* support manually lapsing
*
* @param tagType - The {@linkcode BattlerTagType} to search for
* @param lapseType - The lapse type to use for the lapse method; defaults to {@linkcode BattlerTagLapseType.CUSTOM}
* @returns Whether a tag matching the given type was found
* @see {@linkcode BattlerTag.lapse}
*/
lapseTag(tagType: BattlerTagType): boolean {
lapseTag(tagType: BattlerTagType, lapseType = BattlerTagLapseType.CUSTOM): boolean {
const tags = this.summonData.tags;
const tag = tags.find(t => t.tagType === tagType);
if (!tag) {
return false;
}
if (!tag.lapse(this, BattlerTagLapseType.CUSTOM)) {
if (!tag.lapse(this, lapseType)) {
tag.onRemove(this);
tags.splice(tags.indexOf(tag), 1);
}
@ -4291,7 +4306,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
* removing any whose durations fall below 0.
* @param tagType the {@linkcode BattlerTagLapseType} to tick down
*/
lapseTags(lapseType: BattlerTagLapseType): void {
lapseTags(lapseType: Exclude<BattlerTagLapseType, BattlerTagLapseType.CUSTOM>): void {
const tags = this.summonData.tags;
tags
.filter(
@ -4381,13 +4396,37 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
/**
* Gets whether the given move is currently disabled for this Pokemon.
*
* @remarks
* Only checks for restrictions due to a battler tag, not due to the move's own attributes.
* (for that behavior, see {@linkcode isMoveSelectable}).
*
* @param moveId - The {@linkcode MoveId} ID of the move to check
* @returns `true` if the move is disabled for this Pokemon, otherwise `false`
*
* @see {@linkcode MoveRestrictionBattlerTag}
*/
public isMoveRestricted(moveId: MoveId, pokemon?: Pokemon): boolean {
return this.getRestrictingTag(moveId, pokemon) !== null;
// TODO: rename this method as it can be easily confused with a move being restricted
public isMoveRestricted(moveId: MoveId): boolean {
return this.getRestrictingTag(moveId, this) !== null;
}
/**
* Determine whether the given move is selectable by this Pokemon and the message to display if it is not.
*
* @remarks
* Checks both the move's own restrictions and any restrictions imposed by battler tags like disable or throat chop.
*
* @param moveId - The move ID to check
* @returns A tuple of the form [response, msg], where msg contains the text to display if `response` is false.
*
* @see {@linkcode isMoveRestricted}
*/
public isMoveSelectable(moveId: MoveId): [boolean, string] {
const restrictedTag = this.getRestrictingTag(moveId, this);
if (restrictedTag) {
return [false, restrictedTag.selectionDeniedText(this, moveId)];
}
return allMoves[moveId].checkRestrictions(this);
}
/**
@ -6398,7 +6437,7 @@ export class EnemyPokemon extends Pokemon {
// If the queued move was called indirectly, ignore all PP and usability checks.
// Otherwise, ensure that the move being used is actually usable & in our moveset.
// TODO: What should happen if a pokemon forgets a charging move mid-use?
if (isVirtual(queuedMove.useMode) || movesetMove?.isUsable(this, isIgnorePP(queuedMove.useMode))) {
if (isVirtual(queuedMove.useMode) || movesetMove?.isUsable(this, isIgnorePP(queuedMove.useMode), true)) {
moveQueue.splice(0, i); // TODO: This should not be done here
return queuedMove;
}
@ -6408,7 +6447,7 @@ export class EnemyPokemon extends Pokemon {
this.summonData.moveQueue = [];
// Filter out any moves this Pokemon cannot use
let movePool = this.getMoveset().filter(m => m.isUsable(this));
let movePool = this.getMoveset().filter(m => m.isUsable(this, false, true)[0]);
// If no moves are left, use Struggle. Otherwise, continue with move selection
if (movePool.length) {
// If there's only 1 move in the move pool, use it.
@ -6466,7 +6505,7 @@ export class EnemyPokemon extends Pokemon {
move.category !== MoveCategory.STATUS &&
moveTargets.some(p => {
const doesNotFail =
move.applyConditions(this, p, move) ||
move.applyConditions(this, p) ||
[MoveId.SUCKER_PUNCH, MoveId.UPPER_HAND, MoveId.THUNDERCLAP].includes(move.id);
return (
doesNotFail &&
@ -6525,7 +6564,7 @@ export class EnemyPokemon extends Pokemon {
* target score to -20
*/
if (
(move.name.endsWith(" (N)") || !move.applyConditions(this, target, move)) &&
(move.name.endsWith(" (N)") || !move.applyConditions(this, target)) &&
![MoveId.SUCKER_PUNCH, MoveId.UPPER_HAND, MoveId.THUNDERCLAP].includes(move.id)
) {
targetScore = -20;

View File

@ -9,7 +9,6 @@ import { ArenaTagType } from "#enums/arena-tag-type";
import { BattleType } from "#enums/battle-type";
import { BattlerTagType } from "#enums/battler-tag-type";
import { BiomeId } from "#enums/biome-id";
import { ChallengeType } from "#enums/challenge-type";
import { Command } from "#enums/command";
import { FieldPosition } from "#enums/field-position";
import { MoveId } from "#enums/move-id";
@ -22,8 +21,6 @@ import type { MoveTargetSet } from "#moves/move";
import { getMoveTargets } from "#moves/move-utils";
import { FieldPhase } from "#phases/field-phase";
import type { TurnMove } from "#types/turn-move";
import { applyChallenges } from "#utils/challenge-utils";
import { BooleanHolder } from "#utils/common";
import i18next from "i18next";
export class CommandPhase extends FieldPhase {
@ -126,7 +123,7 @@ export class CommandPhase extends FieldPhase {
if (
queuedMove.move !== MoveId.NONE &&
!isVirtual(queuedMove.useMode) &&
!movesetQueuedMove?.isUsable(playerPokemon, isIgnorePP(queuedMove.useMode))
!movesetQueuedMove?.isUsable(playerPokemon, isIgnorePP(queuedMove.useMode), true)
) {
entriesToDelete++;
} else {
@ -204,40 +201,18 @@ export class CommandPhase extends FieldPhase {
}
/**
* Submethod of {@linkcode handleFightCommand} responsible for queuing the appropriate
* error message when a move cannot be used.
* @param user - The pokemon using the move
* @param cursor - The index of the move in the moveset
* Submethod of {@linkcode handleFightCommand} responsible for queuing the provided error message when the move cannot be used
* @param msg - The reason why the move cannot be used
*/
private queueFightErrorMessage(user: PlayerPokemon, cursor: number) {
const move = user.getMoveset()[cursor];
globalScene.ui.setMode(UiMode.MESSAGE);
// Set the translation key for why the move cannot be selected
let cannotSelectKey: string;
const moveStatus = new BooleanHolder(true);
applyChallenges(ChallengeType.POKEMON_MOVE, move.moveId, moveStatus);
if (!moveStatus.value) {
cannotSelectKey = "battle:moveCannotUseChallenge";
} else if (move.getPpRatio() === 0) {
cannotSelectKey = "battle:moveNoPP";
} else if (move.getName().endsWith(" (N)")) {
cannotSelectKey = "battle:moveNotImplemented";
} else if (user.isMoveRestricted(move.moveId, user)) {
cannotSelectKey = user.getRestrictingTag(move.moveId, user)!.selectionDeniedText(user, move.moveId);
} else {
// TODO: Consider a message that signals a being unusable for an unknown reason
cannotSelectKey = "";
}
const moveName = move.getName().replace(" (N)", ""); // Trims off the indicator
globalScene.ui.showText(
i18next.t(cannotSelectKey, { moveName: moveName }),
private queueFightErrorMessage(msg: string) {
const ui = globalScene.ui;
ui.setMode(UiMode.MESSAGE);
ui.showText(
msg,
null,
() => {
globalScene.ui.clearText();
globalScene.ui.setMode(UiMode.FIGHT, this.fieldIndex);
ui.clearText();
ui.setMode(UiMode.FIGHT, this.fieldIndex);
},
null,
true,
@ -274,18 +249,15 @@ export class CommandPhase extends FieldPhase {
): boolean {
const playerPokemon = this.getPokemon();
const ignorePP = isIgnorePP(useMode);
let canUse = cursor === -1 || playerPokemon.trySelectMove(cursor, ignorePP);
const [canUse, reason] = cursor === -1 ? [true, ""] : playerPokemon.trySelectMove(cursor, ignorePP);
// Ternary here ensures we don't compute struggle conditions unless necessary
const useStruggle = canUse
? false
: cursor > -1 && !playerPokemon.getMoveset().some(m => m.isUsable(playerPokemon));
: cursor > -1 && !playerPokemon.getMoveset().some(m => m.isUsable(playerPokemon)[0]);
canUse ||= useStruggle;
if (!canUse) {
this.queueFightErrorMessage(playerPokemon, cursor);
if (!canUse && !useStruggle) {
this.queueFightErrorMessage(reason);
return false;
}

View File

@ -16,7 +16,7 @@ export class MoveHeaderPhase extends BattlePhase {
}
canMove(): boolean {
return this.pokemon.isActive(true) && this.move.isUsable(this.pokemon);
return this.pokemon.isActive(true) && this.move.isUsable(this.pokemon)[0];
}
start() {

View File

@ -2,15 +2,19 @@ import { applyAbAttrs } from "#abilities/apply-ab-attrs";
import { globalScene } from "#app/global-scene";
import { getPokemonNameWithAffix } from "#app/messages";
import Overrides from "#app/overrides";
// biome-ignore lint/correctness/noUnusedImports: Used in a tsdoc comment
import type { TauntTag, TruantTag } from "#data/battler-tags";
import { CenterOfAttentionTag } from "#data/battler-tags";
import { SpeciesFormChangePreMoveTrigger } from "#data/form-change-triggers";
import { getStatusEffectActivationText, getStatusEffectHealText } from "#data/status-effect";
import { getTerrainBlockMessage } from "#data/terrain";
import { getWeatherBlockMessage } from "#data/weather";
import { AbilityId } from "#enums/ability-id";
import { ArenaTagType } from "#enums/arena-tag-type";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagLapseType } from "#enums/battler-tag-lapse-type";
import { BattlerTagType } from "#enums/battler-tag-type";
import { ChallengeType } from "#enums/challenge-type";
import { CommonAnim } from "#enums/move-anims-common";
import { MoveFlags } from "#enums/move-flags";
import { MoveId } from "#enums/move-id";
@ -24,7 +28,10 @@ import { applyMoveAttrs } from "#moves/apply-attrs";
import { frenzyMissFunc } from "#moves/move-utils";
import type { PokemonMove } from "#moves/pokemon-move";
import { BattlePhase } from "#phases/battle-phase";
import { NumberHolder } from "#utils/common";
// biome-ignore lint/correctness/noUnusedImports: Used in a tsdoc comment
import type { PreUseInterruptAttr } from "#types/move-types";
import { applyChallenges } from "#utils/challenge-utils";
import { BooleanHolder, NumberHolder } from "#utils/common";
import { enumValueToKey } from "#utils/enums";
import i18next from "i18next";
@ -41,6 +48,9 @@ export class MovePhase extends BattlePhase {
/** Whether the current move should fail and retain PP. */
protected cancelled = false;
/** Flag set to `true` during {@linkcode checkFreeze} that indicates that the pokemon will thaw if it passes the failure conditions */
private declare thaw?: boolean;
public get pokemon(): Pokemon {
return this._pokemon;
}
@ -84,19 +94,6 @@ export class MovePhase extends BattlePhase {
this.forcedLast = forcedLast;
}
/**
* Checks if the pokemon is active, if the move is usable, and that the move is targeting something.
* @param ignoreDisableTags `true` to not check if the move is disabled
* @returns `true` if all the checks pass
*/
public canMove(ignoreDisableTags = false): boolean {
return (
this.pokemon.isActive(true) &&
this.move.isUsable(this.pokemon, isIgnorePP(this.useMode), ignoreDisableTags) &&
this.targets.length > 0
);
}
/** Signifies the current move should fail but still use PP */
public fail(): void {
this.failed = true;
@ -115,24 +112,78 @@ export class MovePhase extends BattlePhase {
return this.forcedLast;
}
/**
* Check the first round of failure checks.
*
* @remarks
* Based on battle mechanics research conducted primarily by Smogon, checks happen in the following order (as of Gen 9):
* 1. Sleep/Freeze
* 2. Disobedience due to overleveled (not implemented in Pokerogue)
* 3. Insufficient PP after being selected
* 4. (Pokerogue specific) Moves disabled because they are not implemented / prevented from a challenge / somehow have no targets
* 5. Sky battle (not implemented in Pokerogue)
* 6. Truant
* 7. Loss of focus
* 8. Flinch
* 9. Move was disabled after being selected
* 10. Healing move with heal block
* 11. Sound move with throat chop
* 12. Failure due to gravity
* 13. Move lock from choice items (not implemented in Pokerogue, can't occur here from gorilla tactics)
* 14. Failure from taunt
* 15. Failure from imprison
* 16. Failure from confusion
* 17. Failure from paralysis
* 18. Failure from infatuation
*/
protected firstFailureCheck(): boolean {
// A big if statement will handle the checks (that each have side effects!) in the correct order
if (
this.checkSleep() ||
this.checkFreeze() ||
this.checkPP() ||
this.checkValidity() ||
this.checkTagCancel(BattlerTagType.TRUANT, true) ||
this.checkPreUseInterrupt() ||
this.checkTagCancel(BattlerTagType.FLINCHED) ||
this.checkTagCancel(BattlerTagType.DISABLED, true) ||
this.checkGravity() ||
this.checkTagCancel(BattlerTagType.TAUNT, true) ||
this.checkTagCancel(BattlerTagType.IMPRISON) ||
this.checkTagCancel(BattlerTagType.CONFUSED) ||
this.checkPara() ||
this.checkTagCancel(BattlerTagType.INFATUATED)
) {
this.handlePreMoveFailures();
return true;
}
return false;
}
public start(): void {
super.start();
console.log(MoveId[this.move.moveId], enumValueToKey(MoveUseMode, this.useMode));
// Check if move is unusable (e.g. running out of PP due to a mid-turn Spite
// or the user no longer being on field), ending the phase early if not.
if (!this.canMove(true)) {
if (this.pokemon.isActive(true)) {
this.fail();
this.showMoveText();
this.showFailedText();
}
if (!this.pokemon.isActive(true)) {
this.end();
return;
}
// Removing gigaton hammer always happens first
this.pokemon.removeTag(BattlerTagType.ALWAYS_GET_HIT);
console.log(MoveId[this.move.moveId], enumValueToKey(MoveUseMode, this.useMode));
// For the purposes of payback and kin, the pokemon is considered to have acted
// if it attempted to move at all.
this.pokemon.turnData.acted = true;
// TODO: skip this check for moves like metronome.
if (this.firstFailureCheck()) {
// Lapse all other pre-move tags
this.pokemon.lapseTags(BattlerTagLapseType.PRE_MOVE);
this.end();
return;
}
// Begin second failure checks..
// Reset hit-related turn data when starting follow-up moves (e.g. Metronomed moves, Dancer repeats)
if (isVirtual(this.useMode)) {
@ -155,10 +206,6 @@ export class MovePhase extends BattlePhase {
this.resolveCounterAttackTarget();
this.resolvePreMoveStatusEffects();
this.lapsePreMoveAndMoveTags();
if (!(this.failed || this.cancelled)) {
this.resolveFinalPreMoveCancellationChecks();
}
@ -187,6 +234,8 @@ export class MovePhase extends BattlePhase {
this.showMoveText();
this.showFailedText();
this.cancel();
} else {
this.pokemon.lapseTags(BattlerTagLapseType.MOVE);
}
}
@ -195,96 +244,220 @@ export class MovePhase extends BattlePhase {
}
/**
* Handles {@link StatusEffect.SLEEP | Sleep}/{@link StatusEffect.PARALYSIS | Paralysis}/{@link StatusEffect.FREEZE | Freeze} rolls and side effects.
* Queue the status cure message, reset the status, and update the Pokemon info display
* @param effect - The effect being cured
*/
protected resolvePreMoveStatusEffects(): void {
// Skip for follow ups/reflected moves, no status condition or post turn statuses (e.g. Poison/Toxic)
if (!this.pokemon.status?.effect || this.pokemon.status.isPostTurn() || isIgnoreStatus(this.useMode)) {
return;
}
if (
this.useMode === MoveUseMode.INDIRECT &&
[StatusEffect.SLEEP, StatusEffect.FREEZE].includes(this.pokemon.status.effect)
) {
// Dancer thaws out or wakes up a frozen/sleeping user prior to use
this.pokemon.resetStatus(false);
return;
}
this.pokemon.status.incrementTurn();
/** Whether to prevent us from using the move */
let activated = false;
/** Whether to cure the status */
let healed = false;
switch (this.pokemon.status.effect) {
case StatusEffect.PARALYSIS:
activated =
(this.pokemon.randBattleSeedInt(4) === 0 || Overrides.STATUS_ACTIVATION_OVERRIDE === true) &&
Overrides.STATUS_ACTIVATION_OVERRIDE !== false;
break;
case StatusEffect.SLEEP: {
applyMoveAttrs("BypassSleepAttr", this.pokemon, null, this.move.getMove());
const turnsRemaining = new NumberHolder(this.pokemon.status.sleepTurnsRemaining ?? 0);
applyAbAttrs("ReduceStatusEffectDurationAbAttr", {
pokemon: this.pokemon,
statusEffect: this.pokemon.status.effect,
duration: turnsRemaining,
});
this.pokemon.status.sleepTurnsRemaining = turnsRemaining.value;
healed = this.pokemon.status.sleepTurnsRemaining <= 0;
activated = !healed && !this.pokemon.getTag(BattlerTagType.BYPASS_SLEEP);
break;
}
case StatusEffect.FREEZE:
healed =
!!this.move
.getMove()
.findAttr(
attr => attr.is("HealStatusEffectAttr") && attr.selfTarget && attr.isOfEffect(StatusEffect.FREEZE),
) ||
(!this.pokemon.randBattleSeedInt(5) && Overrides.STATUS_ACTIVATION_OVERRIDE !== true) ||
Overrides.STATUS_ACTIVATION_OVERRIDE === false;
activated = !healed;
break;
}
if (activated) {
// Cancel move activation and play effect
this.cancel();
globalScene.phaseManager.queueMessage(
getStatusEffectActivationText(this.pokemon.status.effect, getPokemonNameWithAffix(this.pokemon)),
);
globalScene.phaseManager.unshiftNew(
"CommonAnimPhase",
this.pokemon.getBattlerIndex(),
undefined,
CommonAnim.POISON + (this.pokemon.status.effect - 1), // offset anim # by effect #
);
} else if (healed) {
// cure status and play effect
globalScene.phaseManager.queueMessage(
getStatusEffectHealText(this.pokemon.status.effect, getPokemonNameWithAffix(this.pokemon)),
);
this.pokemon.resetStatus();
this.pokemon.updateInfo();
}
private cureStatus(effect: StatusEffect): void {
const pokemon = this.pokemon;
globalScene.phaseManager.queueMessage(getStatusEffectHealText(effect, getPokemonNameWithAffix(pokemon)));
pokemon.resetStatus();
pokemon.updateInfo();
}
/**
* Lapse {@linkcode BattlerTagLapseType.PRE_MOVE | PRE_MOVE} tags that trigger before a move is used, regardless of whether or not it failed.
* Also lapse {@linkcode BattlerTagLapseType.MOVE | MOVE} tags if the move is successful and not called indirectly.
* Queue the status activation message, play its animation, and cancel the move
* @param effect - The effect being triggered
*/
protected lapsePreMoveAndMoveTags(): void {
this.pokemon.lapseTags(BattlerTagLapseType.PRE_MOVE);
private triggerStatus(effect: StatusEffect): void {
const pokemon = this.pokemon;
this.showFailedText(getStatusEffectActivationText(effect, getPokemonNameWithAffix(pokemon)));
globalScene.phaseManager.unshiftNew(
"CommonAnimPhase",
pokemon.getBattlerIndex(),
undefined,
CommonAnim.POISON + (effect - 1), // offset anim # by effect #
);
this.cancelled = true;
}
// This intentionally happens before moves without targets are cancelled (Truant takes priority over lack of targets)
if (!isIgnoreStatus(this.useMode) && this.canMove() && !this.cancelled) {
this.pokemon.lapseTags(BattlerTagLapseType.MOVE);
/**
* Handle the sleep check
* @returns Whether the move was cancelled due to sleep
*/
protected checkSleep(): boolean {
if (this.pokemon.status?.effect !== StatusEffect.SLEEP) {
return false;
}
if (this.useMode === MoveUseMode.INDIRECT) {
this.pokemon.resetStatus(false);
return false;
}
this.pokemon.status.incrementTurn();
applyMoveAttrs("BypassSleepAttr", this.pokemon, null, this.move.getMove());
const turnsRemaining = new NumberHolder(this.pokemon.status.sleepTurnsRemaining ?? 0);
applyAbAttrs("ReduceStatusEffectDurationAbAttr", {
pokemon: this.pokemon,
statusEffect: this.pokemon.status.effect,
duration: turnsRemaining,
});
this.pokemon.status.sleepTurnsRemaining = turnsRemaining.value;
if (this.pokemon.status.sleepTurnsRemaining <= 0) {
this.cureStatus(StatusEffect.SLEEP);
return false;
}
this.triggerStatus(StatusEffect.SLEEP);
return true;
}
/**
* Handle the freeze status effect check
*
* @remarks
* Responsible for the following
* - Checking if the pokemon is frozen
* - Checking if the pokemon will thaw from random chance, OR from a thawing move.
* Thawing from a freeze move is not applied until AFTER all other failure checks.
* - Activating the freeze status effect (cancelling the move, playing the message, and displaying the animation)
*/
protected checkFreeze(): boolean {
if (this.pokemon.status?.effect !== StatusEffect.FREEZE) {
return false;
}
// Heal the user if it thaws from the move or random chance
// Check if the user will thaw due to a move
if (
Overrides.STATUS_ACTIVATION_OVERRIDE === false ||
this.move
.getMove()
.findAttr(attr => attr.selfTarget && attr.is("HealStatusEffectAttr") && attr.isOfEffect(StatusEffect.FREEZE)) ||
(!this.pokemon.randBattleSeedInt(5) && Overrides.STATUS_ACTIVATION_OVERRIDE !== true)
) {
this.cureStatus(StatusEffect.FREEZE);
return false;
}
// Otherwise, trigger the freeze status effect
this.triggerStatus(StatusEffect.FREEZE);
return true;
}
/**
* Check if the move is usable based on PP
* @returns Whether the move was cancelled due to insufficient PP
*/
protected checkPP(): boolean {
const move = this.move;
if (move.getMove().pp !== -1 && !isIgnorePP(this.useMode) && move.ppUsed >= move.getMovePp()) {
this.cancel();
this.showFailedText();
return true;
}
return false;
}
/**
* Check if the move is valid and not in an error state
*
* @remarks
* Checks occur in the following order
* 1. Move is not implemented
* 2. Move is somehow invalid (it is {@linkcode MoveId.NONE} or {@linkcode targets} is somehow empty)
* 3. Move cannot be used by the player due to a challenge
*
* @returns Whether the move was cancelled due to being invalid
*/
protected checkValidity(): boolean {
const move = this.move;
const moveId = move.moveId;
const moveName = move.getName();
let failedText: string | undefined;
const usability = new BooleanHolder(false);
if (moveName.endsWith(" (N)")) {
failedText = i18next.t("battle:moveNotImplemented", { moveName: moveName.replace(" (N)", "") });
} else if (moveId === MoveId.NONE || this.targets.length === 0) {
// TODO: Create a locale key with some failure text
} else if (
this.pokemon.isPlayer() &&
applyChallenges(ChallengeType.POKEMON_MOVE, moveId, usability) &&
// check the value inside of usability after calling applyChallenges
!usability.value
) {
failedText = i18next.t("battle:moveCannotUseChallenge", { moveName });
} else {
return false;
}
this.cancel();
this.showFailedText(failedText);
return true;
}
/**
* Cancel the move if its pre use condition fails
*
* @remarks
* The only official move with a pre-use condition is Focus Punch
*
* @returns Whether the move was cancelled due to a pre-use interruption
* @see {@linkcode PreUseInterruptAttr}
*/
private checkPreUseInterrupt(): boolean {
const move = this.move.getMove();
const user = this.pokemon;
const target = this.getActiveTargetPokemon()[0];
return !!move.getAttrs("PreUseInterruptAttr").some(attr => {
attr.apply(user, target, move);
if (this.cancelled) {
return true;
}
return false;
});
}
/**
* Lapse the tag type and check if the move is cancelled from it. Meant to be used during the first failure check
* @param tag - The tag type whose lapse method will be called with {@linkcode BattlerTagLapseType.PRE_MOVE}
* @param checkIgnoreStatus - Whether to check {@link isIgnoreStatus} for the current {@linkcode MoveUseMode} to skip this check
*/
private checkTagCancel(tag: BattlerTagType, checkIgnoreStatus = false): boolean {
if (checkIgnoreStatus && isIgnoreStatus(this.useMode)) {
return false;
}
this.pokemon.lapseTag(tag, BattlerTagLapseType.PRE_MOVE);
return this.cancelled;
}
/**
* Handle move failures due to Gravity, cancelling the move and showing the failure text
* @returns - Whether the move was cancelled due to Gravity
*/
private checkGravity(): boolean {
const move = this.move.getMove();
if (globalScene.arena.hasTag(ArenaTagType.GRAVITY) && move.hasFlag(MoveFlags.GRAVITY)) {
// Play the failure message
this.showFailedText(
i18next.t("battle:moveDisabledGravity", {
pokemonNameWithAffix: getPokemonNameWithAffix(this.pokemon),
moveName: move.name,
}),
);
return true;
}
return false;
}
/**
* Handle the paralysis status effect check, cancelling the move and queueing the activation message and animation
*
* @returns Whether the move was cancelled due to paralysis
*/
private checkPara(): boolean {
if (this.pokemon.status?.effect !== StatusEffect.PARALYSIS) {
return false;
}
const proc =
(this.pokemon.randBattleSeedInt(4) === 0 || Overrides.STATUS_ACTIVATION_OVERRIDE === true) &&
Overrides.STATUS_ACTIVATION_OVERRIDE !== false;
if (!proc) {
return false;
}
this.triggerStatus(StatusEffect.PARALYSIS);
return true;
}
protected useMove(): void {
@ -295,14 +468,6 @@ export class MovePhase extends BattlePhase {
// form changes happen even before we know that the move wll execute.
globalScene.triggerPokemonFormChange(this.pokemon, SpeciesFormChangePreMoveTrigger);
// Check if the move has any attributes that can interrupt its own use **before** displaying text.
// TODO: This should not rely on direct return values
let failed = move.getAttrs("PreUseInterruptAttr").some(attr => attr.apply(this.pokemon, targets[0], move));
if (failed) {
this.failMove(false);
return;
}
// Clear out any two turn moves once they've been used.
// TODO: Refactor move queues and remove this assignment;
// Move queues should be handled by the calling `CommandPhase` or a manager for it
@ -332,10 +497,10 @@ export class MovePhase extends BattlePhase {
* Move conditions assume the move has a single target
* TODO: is this sustainable?
*/
const failsConditions = !move.applyConditions(this.pokemon, targets[0], move);
const failsConditions = !move.applyConditions(this.pokemon, targets[0]);
const failedDueToWeather = globalScene.arena.isMoveWeatherCancelled(this.pokemon, move);
const failedDueToTerrain = globalScene.arena.isMoveTerrainCancelled(this.pokemon, this.targets, move);
failed ||= failsConditions || failedDueToWeather || failedDueToTerrain;
const failed = failsConditions || failedDueToWeather || failedDueToTerrain;
if (failed) {
this.failMove(true, failedDueToWeather, failedDueToTerrain);
@ -442,7 +607,7 @@ export class MovePhase extends BattlePhase {
const move = this.move.getMove();
const targets = this.getActiveTargetPokemon();
if (!move.applyConditions(this.pokemon, targets[0], move)) {
if (!move.applyConditions(this.pokemon, targets[0])) {
this.failMove(true);
return;
}

49
test/moves/belch.test.ts Normal file
View File

@ -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",
);
});
});