Add second and third failure sequences

This commit is contained in:
Sirz Benjie 2025-08-17 21:26:45 -05:00
parent 6b34ea3c46
commit a77e3c911f
No known key found for this signature in database
GPG Key ID: 4A524B4D196C759E
5 changed files with 319 additions and 111 deletions

View File

@ -1,3 +1,5 @@
// biome-ignore lint/correctness/noUnusedImports: Used in a tsdoc comment
import type { GameMode } from "#app/game-mode";
import { globalScene } from "#app/global-scene";
import { getPokemonNameWithAffix } from "#app/messages";
import { allMoves } from "#data/data-lists";
@ -45,19 +47,35 @@ export class FirstMoveCondition extends MoveCondition {
return user.tempSummonData.waveTurnCount === 1;
};
// TODO: Update AI move selection logic to not require this method at all
// Currently, it is used to avoid having the AI select the move if its condition will fail
getUserBenefitScore(user: Pokemon, _target: Pokemon, _move: Move): number {
return this.apply(user, _target, _move) ? 10 : -20;
}
}
/**
* Condition that forces moves to fail against the final boss in classic and the major boss in endless
* @remarks
* Only works reliably for single-target moves as only one target is provided; should not be used for multi-target moves
* @see {@linkcode GameMode.isBattleClassicFinalBoss}
* @see {@linkcode GameMode.isEndlessMinorBoss}
*/
export const failAgainstFinalBossCondition = new MoveCondition((_user, target) => {
const gameMode = globalScene.gameMode;
const currentWave = globalScene.currentBattle.waveIndex;
return (
target.isEnemy() && (gameMode.isBattleClassicFinalBoss(currentWave) || gameMode.isEndlessMinorBoss(currentWave))
);
});
/**
* Condition used by the move {@link https://bulbapedia.bulbagarden.net/wiki/Upper_Hand_(move) | Upper Hand}.
* Moves with this condition are only successful when the target has selected
* a high-priority attack (after factoring in priority-boosting effects) and
* hasn't moved yet this turn.
*/
export class UpperHandCondition extends MoveCondition {
public override readonly func: MoveConditionFunc = (_user, target) => {
export const UpperHandCondition = new MoveCondition((_user, target) => {
const targetCommand = globalScene.currentBattle.turnCommands[target.getBattlerIndex()];
return (
targetCommand?.command === Command.FIGHT &&
@ -66,8 +84,7 @@ export class UpperHandCondition extends MoveCondition {
allMoves[targetCommand.move.move].category !== MoveCategory.STATUS &&
allMoves[targetCommand.move.move].getPriority(target) > 0
);
};
}
});
/**
* A restriction that prevents a move from being selected

View File

@ -93,7 +93,7 @@ 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";
import { ConsecutiveUseRestriction, failAgainstFinalBossCondition, FirstMoveCondition, GravityUseRestriction, MoveCondition, MoveRestriction, UpperHandCondition } from "#moves/move-condition";
/**
* A function used to conditionally determine execution of a given {@linkcode MoveAttr}.
@ -122,6 +122,10 @@ export abstract class Move implements Localizable {
* @remarks Different from {@linkcode restrictions}, which is checked when the move is selected
*/
private conditions: MoveCondition[] = [];
/**
* Move failure conditions that occur during the second check (after move message and before )
*/
private conditionsSeq2: MoveCondition[] = [];
/** Conditions that must be false for a move to be able to be selected.
*
* @remarks Different from {@linkcode conditions}, which is checked when the move is invoked
@ -380,13 +384,15 @@ export abstract class Move implements Localizable {
* Adds a condition to this move (in addition to any provided by its prior {@linkcode MoveAttr}s).
* The move will fail upon use if at least 1 of its conditions is not met.
* @param condition - The {@linkcode MoveCondition} or {@linkcode MoveConditionFunc} to add to the conditions array.
* @param checkSequence - The sequence number where the failure check occurs
* @returns `this` for method chaining
*/
condition(condition: MoveCondition | MoveConditionFunc): this {
condition(condition: MoveCondition | MoveConditionFunc, checkSequence: 2 | 3 = 3): this {
const conditionsArray = checkSequence === 2 ? this.conditionsSeq2 : this.conditions;
if (typeof condition === "function") {
condition = new MoveCondition(condition);
}
this.conditions.push(condition);
conditionsArray.push(condition);
return this;
}
@ -762,13 +768,15 @@ export abstract class Move implements Localizable {
/**
* Applies each {@linkcode MoveCondition} function of this move to the params, determines if the move can be used prior to calling each attribute's apply()
* @param user {@linkcode Pokemon} to apply conditions to
* @param target {@linkcode Pokemon} to apply conditions to
* @param move {@linkcode Move} to apply conditions to
* @param user - {@linkcode Pokemon} to apply conditions to
* @param target - {@linkcode Pokemon} to apply conditions to
* @param move - {@linkcode Move} to apply conditions to
* @param sequence - The sequence number where the condition check occurs, defaults to 3
* @returns boolean: false if any of the apply()'s return false, else true
*/
applyConditions(user: Pokemon, target: Pokemon): boolean {
return this.conditions.every(cond => cond.apply(user, target, this));
applyConditions(user: Pokemon, target: Pokemon, sequence: number = 3): boolean {
const conditionsArray = sequence === 2 ? this.conditionsSeq2 : this.conditions;
return conditionsArray.every(cond => cond.apply(user, target, this));
}
@ -9105,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);
@ -9389,6 +9398,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)
@ -9436,6 +9447,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 ])
@ -9767,6 +9780,8 @@ export function initMoves() {
.edgeCase(), // May or may not need to ignore remotely called moves depending on how it works
new StatusMove(MoveId.WORRY_SEED, PokemonType.GRASS, 100, 10, -1, 0, 4)
.attr(AbilityChangeAttr, AbilityId.INSOMNIA)
// TODO: Enable / remove once balance reaches a consensus on ability overrides during boss fights
// .condition(failAgainstFinalBossCondition, 3)
.reflectable(),
new AttackMove(MoveId.SUCKER_PUNCH, PokemonType.DARK, MoveCategory.PHYSICAL, 70, 100, 5, -1, 1, 4)
.condition((user, target, move) => {
@ -9999,9 +10014,13 @@ export function initMoves() {
.attr(AddArenaTagAttr, ArenaTagType.WIDE_GUARD, 1, true, true)
.condition(failIfLastCondition),
new StatusMove(MoveId.GUARD_SPLIT, PokemonType.PSYCHIC, -1, 10, -1, 0, 5)
// TODO: Enable / remove once balance reaches a consensus on imprison interaction during the final boss fight
// .condition(failAgainstFinalBossCondition, 2)
.attr(AverageStatsAttr, [ Stat.DEF, Stat.SPDEF ], "moveTriggers:sharedGuard"),
new StatusMove(MoveId.POWER_SPLIT, PokemonType.PSYCHIC, -1, 10, -1, 0, 5)
.attr(AverageStatsAttr, [ Stat.ATK, Stat.SPATK ], "moveTriggers:sharedPower"),
// TODO: Enable / remove once balance reaches a consensus on imprison interaction during the final boss fight
// .condition(failAgainstFinalBossCondition, 2)
new StatusMove(MoveId.WONDER_ROOM, PokemonType.PSYCHIC, -1, 10, -1, 0, 5)
.ignoresProtect()
.target(MoveTarget.BOTH_SIDES)
@ -10071,9 +10090,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()
@ -10659,7 +10682,12 @@ export function initMoves() {
new AttackMove(MoveId.POLLEN_PUFF, PokemonType.BUG, MoveCategory.SPECIAL, 90, 100, 15, -1, 0, 7)
.attr(StatusCategoryOnAllyAttr)
.attr(HealOnAllyAttr, 0.5, true, false)
.ballBombMove(),
.ballBombMove()
// Fail if used against an ally that is affected by heal block, during the second failure check
.condition(
(user, target) => target.getAlly() === user && !!target.getTag(BattlerTagType.HEAL_BLOCK),
2
),
new AttackMove(MoveId.ANCHOR_SHOT, PokemonType.STEEL, MoveCategory.PHYSICAL, 80, 100, 20, 100, 0, 7)
.attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, false, 1, 1, true),
new StatusMove(MoveId.PSYCHIC_TERRAIN, PokemonType.PSYCHIC, -1, 10, -1, 0, 7)
@ -10672,16 +10700,21 @@ export function initMoves() {
new AttackMove(MoveId.POWER_TRIP, PokemonType.DARK, MoveCategory.PHYSICAL, 20, 100, 10, -1, 0, 7)
.attr(PositiveStatStagePowerAttr),
new AttackMove(MoveId.BURN_UP, PokemonType.FIRE, MoveCategory.SPECIAL, 130, 100, 5, -1, 0, 7)
.condition((user) => {
const userTypes = user.getTypes(true);
return userTypes.includes(PokemonType.FIRE);
})
.condition(
// Pass `true` to `ForDefend` as it should fail if the user is terastallized to a type that is not FIRE
user => user.isOfType(PokemonType.FIRE, true, true),
2
)
.attr(HealStatusEffectAttr, true, StatusEffect.FREEZE)
.attr(AddBattlerTagAttr, BattlerTagType.BURNED_UP, true, false)
.attr(RemoveTypeAttr, PokemonType.FIRE, (user) => {
globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:burnedItselfOut", { pokemonName: getPokemonNameWithAffix(user) }));
}),
new StatusMove(MoveId.SPEED_SWAP, PokemonType.PSYCHIC, -1, 10, -1, 0, 7)
// Note: the 3 is NOT a typo; unlike power split / guard split which happen in the second failure sequence, speed
// swap's check happens in the third
// TODO: Enable / remove once balance reaches a consensus on imprison interaction during the final boss fight
// .condition(failAgainstFinalBossCondition, 3)
.attr(SwapStatAttr, Stat.SPD)
.ignoresSubstitute(),
new AttackMove(MoveId.SMART_STRIKE, PokemonType.STEEL, MoveCategory.PHYSICAL, 70, -1, 10, -1, 0, 7),
@ -10908,8 +10941,12 @@ export function initMoves() {
true),
new SelfStatusMove(MoveId.NO_RETREAT, PokemonType.FIGHTING, -1, 5, -1, 0, 8)
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.DEF, Stat.SPATK, Stat.SPDEF, Stat.SPD ], 1, true)
.attr(AddBattlerTagAttr, BattlerTagType.NO_RETREAT, true, false)
.condition((user, target, move) => user.getTag(TrappedTag)?.tagType !== BattlerTagType.NO_RETREAT), // fails if the user is currently trapped by No Retreat
.attr(AddBattlerTagAttr, BattlerTagType.NO_RETREAT, true, true /* NOT ADDED if already trapped */)
.condition(
// fails if the user is currently trapped specifically from no retreat
user => user.getTag(TrappedTag)?.tagType !== BattlerTagType.NO_RETREAT,
2
),
new StatusMove(MoveId.TAR_SHOT, PokemonType.ROCK, 100, 15, -1, 0, 8)
.attr(StatStageChangeAttr, [ Stat.SPD ], -1)
.attr(AddBattlerTagAttr, BattlerTagType.TAR_SHOT, false)
@ -11472,10 +11509,11 @@ export function initMoves() {
.slicingMove()
.triageMove(),
new AttackMove(MoveId.DOUBLE_SHOCK, PokemonType.ELECTRIC, MoveCategory.PHYSICAL, 120, 100, 5, -1, 0, 9)
.condition((user) => {
const userTypes = user.getTypes(true);
return userTypes.includes(PokemonType.ELECTRIC);
})
.condition(
// Pass `true` to `isOfType` to fail if the user is terastallized to a type other than ELECTRIC
user => user.isOfType(PokemonType.ELECTRIC, true, true),
2
)
.attr(AddBattlerTagAttr, BattlerTagType.DOUBLE_SHOCKED, true, false)
.attr(RemoveTypeAttr, PokemonType.ELECTRIC, (user) => {
globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:usedUpAllElectricity", { pokemonName: getPokemonNameWithAffix(user) }));
@ -11573,7 +11611,7 @@ export function initMoves() {
.attr(AddBattlerTagAttr, BattlerTagType.HEAL_BLOCK, false, false, 2),
new AttackMove(MoveId.UPPER_HAND, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 65, 100, 15, 100, 3, 9)
.attr(FlinchAttr)
.condition(new UpperHandCondition()),
.condition(UpperHandCondition),
new AttackMove(MoveId.MALIGNANT_CHAIN, PokemonType.POISON, MoveCategory.SPECIAL, 100, 100, 5, 50, 0, 9)
.attr(StatusEffectAttr, StatusEffect.TOXIC)
);

View File

@ -2375,7 +2375,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
* @param source {@linkcode Pokemon} The attacking Pokémon.
* @param move {@linkcode 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 simulated - Whether to apply abilities via simulated calls (defaults to `true`). This should only be false during the move effect phase
* @param cancelled {@linkcode BooleanHolder} Stores whether the move was cancelled by a non-type-based immunity.
* @param useIllusion - Whether we want the attack move effectiveness on the illusion or not
* @returns The type damage multiplier, indicating the effectiveness of the move
@ -2429,7 +2429,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 =>
applyAbAttrs("FieldPriorityMoveImmunityAbAttr", {

View File

@ -257,6 +257,7 @@ export class CommandPhase extends FieldPhase {
: cursor > -1 && !playerPokemon.getMoveset().some(m => m.isUsable(playerPokemon)[0]);
if (!canUse && !useStruggle) {
console.error("Cannot use move:", reason);
this.queueFightErrorMessage(reason);
return false;
}

View File

@ -2,8 +2,6 @@ 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";
@ -113,7 +111,9 @@ export class MovePhase extends BattlePhase {
}
/**
* Check the first round of failure checks.
* Check the first round of failure checks
*
* @returns Whether the move failed
*
* @remarks
* Based on battle mechanics research conducted primarily by Smogon, checks happen in the following order (as of Gen 9):
@ -147,6 +147,8 @@ export class MovePhase extends BattlePhase {
this.checkPreUseInterrupt() ||
this.checkTagCancel(BattlerTagType.FLINCHED) ||
this.checkTagCancel(BattlerTagType.DISABLED, true) ||
this.checkTagCancel(BattlerTagType.HEAL_BLOCK) ||
this.checkTagCancel(BattlerTagType.THROAT_CHOPPED) ||
this.checkGravity() ||
this.checkTagCancel(BattlerTagType.TAUNT, true) ||
this.checkTagCancel(BattlerTagType.IMPRISON) ||
@ -160,6 +162,115 @@ export class MovePhase extends BattlePhase {
return false;
}
/**
* Handle the status interactions for sleep and freeze that happen after passing the first failure check
*
* @remarks
* - If the user is asleep but can use the move, the sleep animation and message is still shown
* - If the user is frozen but is thawed from its move, the user's status is cured and the thaw message is shown
*/
private post1stFailSleepOrThaw(): void {
const user = this.pokemon;
// If the move was successful, then... play the "sleeping" animation if the user is asleep but uses something like rest / snore
// Cure the user's freeze and queue the thaw message from unfreezing due to move use
if (!isIgnoreStatus(this.useMode)) {
if (user.status?.effect === StatusEffect.SLEEP) {
// Commence the sleeping animation and message, which happens anyway
// TODO...
} else if (this.thaw) {
this.cureStatus(
StatusEffect.FREEZE,
i18next.t("statusEffect:freeze.healByMove", {
pokemonName: getPokemonNameWithAffix(user),
moveName: this.move.getMove().name,
}),
);
}
}
}
/**
* Second failure check that occurs after the "Pokemon used move" text is shown but BEFORE the move has been registered
* as being the last move used (for the purposes of something like Copycat)
*
* @remarks
* Other than powder, each failure condition is mutually exclusive (as they are tied to specific moves), so order does not matter.
* Notably, this failure check only includes failure conditions intrinsic to the move itself, ther than Powder (which marks the end of this failure check)
*
*
* - Pollen puff used on an ally that is under effect of heal block
* - Burn up / Double shock when the user does not have the required type
* - No Retreat while already under its effects
* - (on cart, not applicable to Pokerogue) Moves that fail if used ON a raid / special boss: selfdestruct/explosion/imprision/power split / guard split
* - (on cart, not applicable to Pokerogue) Moves that fail during a "co-op" battle (like when Arven helps during raid boss): ally switch / teatime
*
* After all checks, Powder causing the user to explode
*/
protected secondFailureCheck(): boolean {
const move = this.move.getMove();
const user = this.pokemon;
if (!move.applyConditions(user, this.getActiveTargetPokemon()[0], 2)) {
this.failed = true;
// Note: If any of the moves have custom failure messages, this needs to be changed
// As of Gen 9, none do. (Except maybe pollen puff? Need to check)
return true;
}
// Powder *always* happens last
// Note: Powder's lapse method handles everything: messages, damage, animation, primal weather interaction,
// determining type of type changing moves, etc.
// It will set this phase's `failed` flag to true if it procs
user.lapseTag(BattlerTagType.POWDER, BattlerTagLapseType.PRE_MOVE);
return this.failed;
}
/**
* Third failure check is from moves and abilities themselves
*
* @returns Whether the move failed
*
* @remarks
* - Conditional attributes of the move
* - Weather blocking the move
* - Terrain blocking the move
* - Queenly Majesty / Dazzling
*/
protected thirdFailureCheck(): boolean {
/**
* Move conditions assume the move has a single target
* TODO: is this sustainable?
*/
const move = this.move.getMove();
const targets = this.getActiveTargetPokemon();
const arena = globalScene.arena;
const user = this.pokemon;
const failsConditions = !move.applyConditions(user, targets[0]);
const failedDueToWeather = arena.isMoveWeatherCancelled(user, move);
const failedDueToTerrain = arena.isMoveTerrainCancelled(user, this.targets, move);
let failed = failsConditions || failedDueToWeather || failedDueToTerrain;
// Apply queenly majesty / dazzling
if (!failed) {
const defendingSidePlayField = user.isPlayer() ? globalScene.getPlayerField() : globalScene.getEnemyField();
const cancelled = new BooleanHolder(false);
defendingSidePlayField.forEach((pokemon: Pokemon) => {
applyAbAttrs("FieldPriorityMoveImmunityAbAttr", {
pokemon,
opponent: user,
move,
cancelled,
});
});
failed = cancelled.value;
}
if (failed) {
this.failMove(true, failedDueToWeather, failedDueToTerrain);
return true;
}
return false;
}
public start(): void {
super.start();
@ -168,25 +279,52 @@ export class MovePhase extends BattlePhase {
return;
}
const user = this.pokemon;
// Removing gigaton hammer always happens first
this.pokemon.removeTag(BattlerTagType.ALWAYS_GET_HIT);
user.removeTag(BattlerTagType.ALWAYS_GET_HIT);
console.log(MoveId[this.move.moveId], enumValueToKey(MoveUseMode, this.useMode));
// For the purposes of payback and kin, the pokemon is considered to have acted
// if it attempted to move at all.
this.pokemon.turnData.acted = true;
// TODO: skip this check for moves like metronome.
if (this.firstFailureCheck()) {
user.turnData.acted = true;
const useMode = this.useMode;
const virtual = isVirtual(useMode);
if (!virtual && this.firstFailureCheck()) {
// Lapse all other pre-move tags
this.pokemon.lapseTags(BattlerTagLapseType.PRE_MOVE);
user.lapseTags(BattlerTagLapseType.PRE_MOVE);
this.end();
/*
On cartridge, certain things *react* to move failures, depending on failure reason
The following would happen at this time on cartridge:
- Steadfast giving user speed boost if failed due to flinch
- Protect, detect, ally switch, etc, resetting consecutive use count
- Rollout / ice ball "unlocking"
- protect / ally switch / other moves resetting their consecutive use count
- and others
In Pokerogue, these are instead handled by their respective methods, which generally
*/
return;
}
// Begin second failure checks..
// Now, issue the second failure checks
// If the user was asleep but is using a move anyway, it should STILL display the "user is sleeping" message!
// At this point, cure the user's freeze
// At this point, called moves should be decided.
// For now, this is a placeholder until we rework how called moves are handled
// For correct alignment with mainline, this SHOULD go here, and it SHOULD rewrite its own move
// Though, this is not the case in pokerogue.
// At this point...
// If the first failure check passes, then thaw the user if its move will thaw it.
// The sleep message and animation are also played if the user is asleep but using a move anyway (snore, sleep talk, etc)
this.post1stFailSleepOrThaw();
// Reset hit-related turn data when starting follow-up moves (e.g. Metronomed moves, Dancer repeats)
if (isVirtual(this.useMode)) {
if (virtual) {
this.pokemon.turnData.hitsLeft = -1;
this.pokemon.turnData.hitCount = 0;
}
@ -202,10 +340,30 @@ export class MovePhase extends BattlePhase {
globalScene.arena.setIgnoreAbilities(true, this.pokemon.getBattlerIndex());
}
this.resolveRedirectTarget();
// At this point, move's type changing and multi-target effects *should* be applied
// Pokerogue's current implementation applies these effects during the move effect phase
// as there is not (yet) a notion of a move-in-flight for determinations to occur
this.resolveRedirectTarget();
this.resolveCounterAttackTarget();
// Move is announced
this.showMoveText();
// Stance change happens
const charging = this.move.getMove().isChargingMove() && !this.pokemon.getTag(BattlerTagType.CHARGING);
// Stance change happens now if the move is about to be executed
if (!charging) {
globalScene.triggerPokemonFormChange(this.pokemon, SpeciesFormChangePreMoveTrigger);
}
if (this.secondFailureCheck()) {
this.showFailedText();
this.handlePreMoveFailures();
this.end();
return;
}
if (!(this.failed || this.cancelled)) {
this.resolveFinalPreMoveCancellationChecks();
}
@ -213,7 +371,7 @@ export class MovePhase extends BattlePhase {
// Cancel, charge or use the move as applicable.
if (this.cancelled || this.failed) {
this.handlePreMoveFailures();
} else if (this.move.getMove().isChargingMove() && !this.pokemon.getTag(BattlerTagType.CHARGING)) {
} else if (charging) {
this.chargeMove();
} else {
this.useMove();
@ -222,7 +380,7 @@ export class MovePhase extends BattlePhase {
this.end();
}
/** Check for cancellation edge cases - no targets remaining, or {@linkcode MoveId.NONE} is in the queue */
/** Check for cancellation edge cases - no targets remaining */
protected resolveFinalPreMoveCancellationChecks(): void {
const targets = this.getActiveTargetPokemon();
const moveQueue = this.pokemon.getMoveQueue();
@ -231,7 +389,6 @@ export class MovePhase extends BattlePhase {
(targets.length === 0 && !this.move.getMove().hasAttr("AddArenaTrapTagAttr")) ||
(moveQueue.length > 0 && moveQueue[0].move === MoveId.NONE)
) {
this.showMoveText();
this.showFailedText();
this.cancel();
} else {
@ -246,10 +403,12 @@ export class MovePhase extends BattlePhase {
/**
* Queue the status cure message, reset the status, and update the Pokemon info display
* @param effect - The effect being cured
* @param msg - A custom message to display when curing the status effect (used for curing freeze due to move use)
*/
private cureStatus(effect: StatusEffect): void {
private cureStatus(effect: StatusEffect, msg?: string): void {
const pokemon = this.pokemon;
globalScene.phaseManager.queueMessage(getStatusEffectHealText(effect, getPokemonNameWithAffix(pokemon)));
// Freeze healed by move uses its own msg
globalScene.phaseManager.queueMessage(msg ?? getStatusEffectHealText(effect, getPokemonNameWithAffix(pokemon)));
pokemon.resetStatus();
pokemon.updateInfo();
}
@ -317,9 +476,23 @@ export class MovePhase extends BattlePhase {
return false;
}
// Heal the user if it thaws from the move or random chance
// Check if the user will thaw due to a move
// Check if move use would heal the user
if (Overrides.STATUS_ACTIVATION_OVERRIDE) {
return false;
}
// Check if the move will heal
const move = this.move.getMove();
if (
move.findAttr(attr => attr.selfTarget && attr.is("HealStatusEffectAttr") && attr.isOfEffect(StatusEffect.FREEZE))
) {
// On cartridge, burn up will not cure if it would fail
if (move.id === MoveId.BURN_UP && !this.pokemon.isOfType(PokemonType.FIRE)) {
}
this.thaw = true;
return false;
}
if (
Overrides.STATUS_ACTIVATION_OVERRIDE === false ||
this.move
@ -370,7 +543,17 @@ export class MovePhase extends BattlePhase {
if (moveName.endsWith(" (N)")) {
failedText = i18next.t("battle:moveNotImplemented", { moveName: moveName.replace(" (N)", "") });
} else if (moveId === MoveId.NONE || this.targets.length === 0) {
// TODO: Create a locale key with some failure text
this.cancel();
const pokemonName = this.pokemon.name;
const warningText =
moveId === MoveId.NONE
? `${pokemonName} is attempting to use MoveId.NONE`
: `${pokemonName} is attempting to use a move with no targets`;
console.warn(warningText);
return true;
} else if (
this.pokemon.isPlayer() &&
applyChallenges(ChallengeType.POKEMON_MOVE, moveId, usability) &&
@ -460,85 +643,54 @@ export class MovePhase extends BattlePhase {
return true;
}
protected usePP(): void {
if (!isIgnorePP(this.useMode)) {
const move = this.move;
// "commit" to using the move, deducting PP.
const ppUsed = 1 + this.getPpIncreaseFromPressure(this.getActiveTargetPokemon());
move.usePp(ppUsed);
globalScene.eventTarget.dispatchEvent(new MoveUsedEvent(this.pokemon.id, this.move.getMove(), this.move.ppUsed));
}
}
protected useMove(): void {
const targets = this.getActiveTargetPokemon();
const moveQueue = this.pokemon.getMoveQueue();
const move = this.move.getMove();
// form changes happen even before we know that the move wll execute.
globalScene.triggerPokemonFormChange(this.pokemon, SpeciesFormChangePreMoveTrigger);
const user = this.pokemon;
// Clear out any two turn moves once they've been used.
// TODO: Refactor move queues and remove this assignment;
// Move queues should be handled by the calling `CommandPhase` or a manager for it
// @ts-expect-error - useMode is readonly and shouldn't normally be assigned to
this.useMode = moveQueue.shift()?.useMode ?? this.useMode;
this.useMode = user.getMoveQueue().shift()?.useMode ?? this.useMode;
if (this.pokemon.getTag(BattlerTagType.CHARGING)?.sourceMove === this.move.moveId) {
this.pokemon.lapseTag(BattlerTagType.CHARGING);
}
if (!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));
}
/**
* Determine if the move is successful (meaning that its damage/effects can be attempted)
* by checking that all of the following are true:
* - Conditional attributes of the move are all met
* - Weather does not block the move
* - Terrain does not block the move
*/
/**
* Move conditions assume the move has a single target
* TODO: is this sustainable?
*/
const failsConditions = !move.applyConditions(this.pokemon, targets[0]);
const failedDueToWeather = globalScene.arena.isMoveWeatherCancelled(this.pokemon, move);
const failedDueToTerrain = globalScene.arena.isMoveTerrainCancelled(this.pokemon, this.targets, move);
const failed = failsConditions || failedDueToWeather || failedDueToTerrain;
if (failed) {
this.failMove(true, failedDueToWeather, failedDueToTerrain);
return;
if (user.getTag(BattlerTagType.CHARGING)?.sourceMove === this.move.moveId) {
user.lapseTag(BattlerTagType.CHARGING);
}
if (!this.thirdFailureCheck()) {
this.executeMove();
}
}
/** Execute the current move and apply its effects. */
private executeMove() {
const pokemon = this.pokemon;
const move = this.move.getMove();
const targets = this.getActiveTargetPokemon();
// Update the battle's "last move" pointer unless we're currently mimicking a move or triggering Dancer.
if (!move.hasAttr("CopyMoveAttr") && !isReflected(this.useMode)) {
globalScene.currentBattle.lastMove = move.id;
}
const opponent = this.getActiveTargetPokemon()[0];
const targets = this.targets;
// Trigger ability-based user type changes, display move text and then execute move effects.
// TODO: Investigate whether PokemonTypeChangeAbAttr can drop the "opponent" parameter
applyAbAttrs("PokemonTypeChangeAbAttr", { pokemon: this.pokemon, move, opponent: targets[0] });
applyAbAttrs("PokemonTypeChangeAbAttr", { pokemon, move, opponent });
this.showMoveText();
globalScene.phaseManager.unshiftNew(
"MoveEffectPhase",
this.pokemon.getBattlerIndex(),
this.targets,
move,
this.useMode,
);
globalScene.phaseManager.unshiftNew("MoveEffectPhase", pokemon.getBattlerIndex(), targets, move, this.useMode);
// Handle Dancer, which triggers immediately after a move is used (rather than waiting on `this.end()`).
// Note the MoveUseMode check here prevents an infinite Dancer loop.
// TODO: This needs to go at the end of `MoveEffectPhase` to check move results
const dancerModes: MoveUseMode[] = [MoveUseMode.INDIRECT, MoveUseMode.REFLECTED] as const;
if (this.move.getMove().hasFlag(MoveFlags.DANCE_MOVE) && !dancerModes.includes(this.useMode)) {
// biome-ignore lint/nursery/noShadow: We don't need to access `pokemon` from the outer scope
globalScene.getField(true).forEach(pokemon => {
applyAbAttrs("PostMoveUsedAbAttr", { pokemon, move: this.move, source: this.pokemon, targets: this.targets });
applyAbAttrs("PostMoveUsedAbAttr", { pokemon, move: this.move, source: pokemon, targets: targets });
});
}
}
@ -671,6 +823,7 @@ export class MovePhase extends BattlePhase {
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)
@ -785,10 +938,7 @@ export class MovePhase extends BattlePhase {
}
if (this.failed) {
// TODO: should this consider struggle?
const ppUsed = isIgnorePP(this.useMode) ? 0 : 1;
this.move.usePp(ppUsed);
globalScene.eventTarget.dispatchEvent(new MoveUsedEvent(this.pokemon?.id, this.move.getMove(), ppUsed));
this.usePP();
}
if (this.cancelled && this.pokemon.summonData.tags.some(t => t.tagType === BattlerTagType.FRENZY)) {