pokerogue/src/data/moves/move-condition.ts
Sirz Benjie ec4ddab8be
[Bug] [Refactor] [Move] Add selection prevention and move failures (#6276)
* Add failure conditions and move failures part 1

* Add second and third failure sequences

* Refactor mostly complete, need to recheck tests

* Adjust status checks to respect ignoreStatus useModes

* Adjust restriction for stuff cheeks

* Address bertie's review comments

* Add counterRedirectAttr to other counter-like moves

* Adjust some documentation for new methods

* Make substitute use the move tag

* Adjust counter attr to use array.find

* Adjust move condition check that occurs in the third failure check sequence

* Insert move failure check sequence part 4 into move phase

* Revert type adjustment to getBattlerIndex

* Make charging moves deduct pp on use instead of on release

* Fix first move condition not using 1 based starting wave

* Tweak charge move handling and protean timing

* Adjust fly tests to expect pp reduction properly

* Add missing attribute to counter

* Adjust revival blessing hardcore test to respect new return value of isUsable

* Adjust copycat test to account for how it actually works

* Play sleep animation and message

* Remove BYPASS_SLEEP battler tag in favor of boolean holder

* Finish unfinished docs

* Ensure move restrictions are only checked for players

* Adjust pollen puff condition, fix docs on `isOpponent`

* Fix failAgainstFinalBossCondition

* Fix dig test

* Adjust dive's test

* Fix missing break in applyConditions

* Fix getBattlerIndex for enemyPokemon

* Adjust type hint test to not rely on teleport

* Minor adjustments from code review

Co-authored-by: Bertie690 <136088738+Bertie690@users.noreply.github.com>

* Add tests for teleport

* Minor adjustments from code review

Co-authored-by: Bertie690 <136088738+Bertie690@users.noreply.github.com>

* PR review changes

Fix type hints test name

Update Dig/Dive test name

Separate TSDoc imports in `pokemon-utils.ts`

Add missing `@returns` in `move-phase.ts`

Fix comment typos

Separate TSDoc imports in `move-phase.ts`

Add return hints to `trySelectMove`

Minor formatting

Remove duplicate `.affectedByGravity()` on Telekinesis

Fix docs for `checkRestrictions`

Manually format method definition

Fix comment spacing

Fix variable naming

* Address kev's review comments

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>

* Minor adjustments from code review

Co-authored-by: Bertie690 <136088738+Bertie690@users.noreply.github.com>

* Remove optional chaining

* fix: type for InferKeys

* chore: apply biome

* chore: fix merge conflicts from Biome update

* Remove latent isNullOrUndefined

* Drop readonly on timingModifier

* docs: Add class comment

* Address comments from code review

* Drop readonly from timingModifier

* Cleanup proc chance computation

* Move `cureStatus` into the Pokemon class

* Final touchups

---------

Co-authored-by: Bertie690 <136088738+Bertie690@users.noreply.github.com>
Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
2025-09-29 12:08:42 -05:00

265 lines
9.9 KiB
TypeScript

// 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<MoveId>(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<MoveId>(
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 readonly func: UserMoveConditionFunc;
public 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",
);