pokerogue/src/phases/move-phase.ts
geeilhan 38a6bf07e3
[Bug] Fix enemy faint causing Frenzy moves to mishandle paralysis (#4680)
* [BUG] Added frenzy reset function during move phase should move be cancelled (#4227)

* Revert [P2 BUG] Fix since it does not work and is faulty

* [P2 BUG] Implemented correct frenzy Tag and Movequeue reset should frenzy move fail (#4277)

* Cleaned up Bug Fix using frenzyMissFunc

* Added automated test case for frenzy move edge case

* Improvement to frenzy move reset test case speed

* Fix test

* Add missing import

---------

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
2024-10-29 08:36:24 -07:00

510 lines
20 KiB
TypeScript

import { BattlerIndex } from "#app/battle";
import BattleScene from "#app/battle-scene";
import { applyAbAttrs, applyPostMoveUsedAbAttrs, applyPreAttackAbAttrs, BlockRedirectAbAttr, IncreasePpAbAttr, PokemonTypeChangeAbAttr, PostMoveUsedAbAttr, RedirectMoveAbAttr, ReduceStatusEffectDurationAbAttr } from "#app/data/ability";
import { CommonAnim } from "#app/data/battle-anims";
import { BattlerTagLapseType, CenterOfAttentionTag } from "#app/data/battler-tags";
import { allMoves, applyMoveAttrs, BypassRedirectAttr, BypassSleepAttr, CopyMoveAttr, frenzyMissFunc, HealStatusEffectAttr, MoveFlags, PreMoveMessageAttr } from "#app/data/move";
import { SpeciesFormChangePreMoveTrigger } from "#app/data/pokemon-forms";
import { getStatusEffectActivationText, getStatusEffectHealText } from "#app/data/status-effect";
import { Type } from "#app/data/type";
import { getTerrainBlockMessage } from "#app/data/weather";
import { MoveUsedEvent } from "#app/events/battle-scene";
import Pokemon, { MoveResult, PokemonMove } from "#app/field/pokemon";
import { getPokemonNameWithAffix } from "#app/messages";
import Overrides from "#app/overrides";
import { BattlePhase } from "#app/phases/battle-phase";
import { CommonAnimPhase } from "#app/phases/common-anim-phase";
import { MoveEffectPhase } from "#app/phases/move-effect-phase";
import { MoveEndPhase } from "#app/phases/move-end-phase";
import { ShowAbilityPhase } from "#app/phases/show-ability-phase";
import { BooleanHolder, NumberHolder } from "#app/utils";
import { Abilities } from "#enums/abilities";
import { BattlerTagType } from "#enums/battler-tag-type";
import { Moves } from "#enums/moves";
import { StatusEffect } from "#enums/status-effect";
import i18next from "i18next";
import { MoveChargePhase } from "#app/phases/move-charge-phase";
export class MovePhase extends BattlePhase {
protected _pokemon: Pokemon;
protected _move: PokemonMove;
protected _targets: BattlerIndex[];
protected followUp: boolean;
protected ignorePp: boolean;
protected failed: boolean = false;
protected cancelled: boolean = false;
public get pokemon(): Pokemon {
return this._pokemon;
}
protected set pokemon(pokemon: Pokemon) {
this._pokemon = pokemon;
}
public get move(): PokemonMove {
return this._move;
}
protected set move(move: PokemonMove) {
this._move = move;
}
public get targets(): BattlerIndex[] {
return this._targets;
}
protected set targets(targets: BattlerIndex[]) {
this._targets = targets;
}
/**
* @param followUp Indicates that the move being uses is a "follow-up" - for example, a move being used by Metronome or Dancer.
* Follow-ups bypass a few failure conditions, including flinches, sleep/paralysis/freeze and volatile status checks, etc.
*/
constructor(scene: BattleScene, pokemon: Pokemon, targets: BattlerIndex[], move: PokemonMove, followUp: boolean = false, ignorePp: boolean = false) {
super(scene);
this.pokemon = pokemon;
this.targets = targets;
this.move = move;
this.followUp = followUp;
this.ignorePp = ignorePp;
}
/**
* Checks if the pokemon is active, if the move is usable, and that the move is targetting something.
* @param ignoreDisableTags `true` to not check if the move is disabled
* @returns `true` if all the checks pass
*/
public canMove(ignoreDisableTags: boolean = false): boolean {
return this.pokemon.isActive(true) && this.move.isUsable(this.pokemon, this.ignorePp, ignoreDisableTags) && !!this.targets.length;
}
/**Signifies the current move should fail but still use PP */
public fail(): void {
this.failed = true;
}
/**Signifies the current move should cancel and retain PP */
public cancel(): void {
this.cancelled = true;
}
public start(): void {
super.start();
console.log(Moves[this.move.moveId]);
// Check if move is unusable (e.g. because it's out of PP due to a mid-turn Spite).
if (!this.canMove(true)) {
if (this.pokemon.isActive(true) && this.move.ppUsed >= this.move.getMovePp()) {
this.fail();
this.showMoveText();
this.showFailedText();
}
return this.end();
}
this.pokemon.turnData.acted = true;
// Reset hit-related turn data when starting follow-up moves (e.g. Metronomed moves, Dancer repeats)
if (this.followUp) {
this.pokemon.turnData.hitsLeft = -1;
this.pokemon.turnData.hitCount = 0;
}
// Check move to see if arena.ignoreAbilities should be true.
if (!this.followUp) {
if (this.move.getMove().checkFlag(MoveFlags.IGNORE_ABILITIES, this.pokemon, null)) {
this.scene.arena.setIgnoreAbilities(true, this.pokemon.getBattlerIndex());
}
}
this.resolveRedirectTarget();
this.resolveCounterAttackTarget();
this.resolvePreMoveStatusEffects();
this.lapsePreMoveAndMoveTags();
if (!(this.failed || this.cancelled)) {
this.resolveFinalPreMoveCancellationChecks();
}
if (this.cancelled || this.failed) {
this.handlePreMoveFailures();
} else if (this.move.getMove().isChargingMove() && !this.pokemon.getTag(BattlerTagType.CHARGING)) {
this.chargeMove();
} else {
this.useMove();
}
this.end();
}
/** Check for cancellation edge cases - no targets remaining, or {@linkcode Moves.NONE} is in the queue */
protected resolveFinalPreMoveCancellationChecks(): void {
const targets = this.getActiveTargetPokemon();
const moveQueue = this.pokemon.getMoveQueue();
if (targets.length === 0 || (moveQueue.length && moveQueue[0].move === Moves.NONE)) {
this.showMoveText();
this.showFailedText();
this.cancel();
}
}
public getActiveTargetPokemon(): Pokemon[] {
return this.scene.getField(true).filter(p => this.targets.includes(p.getBattlerIndex()));
}
/**
* Handles {@link StatusEffect.SLEEP Sleep}/{@link StatusEffect.PARALYSIS Paralysis}/{@link StatusEffect.FREEZE Freeze} rolls and side effects.
*/
protected resolvePreMoveStatusEffects(): void {
if (!this.followUp && this.pokemon.status && !this.pokemon.status.isPostTurn()) {
this.pokemon.status.incrementTurn();
let activated = false;
let healed = false;
switch (this.pokemon.status.effect) {
case StatusEffect.PARALYSIS:
activated = (!this.pokemon.randSeedInt(4) || 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, this.pokemon, null, false, this.pokemon.status.effect, 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 instanceof HealStatusEffectAttr
&& attr.selfTarget
&& attr.isOfEffect(StatusEffect.FREEZE))
|| (!this.pokemon.randSeedInt(5) && Overrides.STATUS_ACTIVATION_OVERRIDE !== true)
|| Overrides.STATUS_ACTIVATION_OVERRIDE === false;
activated = !healed;
break;
}
if (activated) {
this.cancel();
this.scene.queueMessage(getStatusEffectActivationText(this.pokemon.status.effect, getPokemonNameWithAffix(this.pokemon)));
this.scene.unshiftPhase(new CommonAnimPhase(this.scene, this.pokemon.getBattlerIndex(), undefined, CommonAnim.POISON + (this.pokemon.status.effect - 1)));
} else if (healed) {
this.scene.queueMessage(getStatusEffectHealText(this.pokemon.status.effect, getPokemonNameWithAffix(this.pokemon)));
this.pokemon.resetStatus();
this.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 should be successful.
*/
protected lapsePreMoveAndMoveTags(): void {
this.pokemon.lapseTags(BattlerTagLapseType.PRE_MOVE);
// TODO: does this intentionally happen before the no targets/Moves.NONE on queue cancellation case is checked?
if (!this.followUp && this.canMove() && !this.cancelled) {
this.pokemon.lapseTags(BattlerTagLapseType.MOVE);
}
}
protected useMove(): void {
const targets = this.getActiveTargetPokemon();
const moveQueue = this.pokemon.getMoveQueue();
// form changes happen even before we know that the move wll execute.
this.scene.triggerPokemonFormChange(this.pokemon, SpeciesFormChangePreMoveTrigger);
this.showMoveText();
if (moveQueue.length > 0) {
// Using .shift here clears out two turn moves once they've been used
this.ignorePp = moveQueue.shift()?.ignorePP ?? false;
}
if (this.pokemon.getTag(BattlerTagType.CHARGING)?.sourceMove === this.move.moveId) {
this.pokemon.lapseTag(BattlerTagType.CHARGING);
}
// "commit" to using the move, deducting PP.
if (!this.ignorePp) {
const ppUsed = 1 + this.getPpIncreaseFromPressure(targets);
this.move.usePp(ppUsed);
this.scene.eventTarget.dispatchEvent(new MoveUsedEvent(this.pokemon?.id, this.move.getMove(), this.move.ppUsed));
}
// Update the battle's "last move" pointer, unless we're currently mimicking a move.
if (!allMoves[this.move.moveId].hasAttr(CopyMoveAttr)) {
this.scene.currentBattle.lastMove = this.move.moveId;
}
/**
* 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
* - The target's `ForceSwitchOutImmunityAbAttr` is not triggered (see {@linkcode Move.prototype.applyConditions})
* - Weather does not block the move
* - Terrain does not block the move
*
* TODO: These steps are straightforward, but the implementation below is extremely convoluted.
*/
const move = this.move.getMove();
/**
* Move conditions assume the move has a single target
* TODO: is this sustainable?
*/
const passesConditions = move.applyConditions(this.pokemon, targets[0], move);
const failedDueToWeather: boolean = this.scene.arena.isMoveWeatherCancelled(this.pokemon, move);
const failedDueToTerrain: boolean = this.scene.arena.isMoveTerrainCancelled(this.pokemon, this.targets, move);
const success = passesConditions && !failedDueToWeather && !failedDueToTerrain;
/**
* If the move has not failed, trigger ability-based user type changes and then execute it.
*
* Notably, Roar, Whirlwind, Trick-or-Treat, and Forest's Curse will trigger these type changes even
* if the move fails.
*/
if (success) {
applyPreAttackAbAttrs(PokemonTypeChangeAbAttr, this.pokemon, null, this.move.getMove());
this.scene.unshiftPhase(new MoveEffectPhase(this.scene, this.pokemon.getBattlerIndex(), this.targets, this.move));
} else {
if ([ Moves.ROAR, Moves.WHIRLWIND, Moves.TRICK_OR_TREAT, Moves.FORESTS_CURSE ].includes(this.move.moveId)) {
applyPreAttackAbAttrs(PokemonTypeChangeAbAttr, this.pokemon, null, this.move.getMove());
}
this.pokemon.pushMoveHistory({ move: this.move.moveId, targets: this.targets, result: MoveResult.FAIL, virtual: this.move.virtual });
let failedText: string | undefined;
const failureMessage = move.getFailedText(this.pokemon, targets[0], move, new BooleanHolder(false));
if (failureMessage) {
failedText = failureMessage;
} else if (failedDueToTerrain) {
failedText = getTerrainBlockMessage(this.pokemon, this.scene.arena.getTerrainType());
}
this.showFailedText(failedText);
// Remove the user from its semi-invulnerable state (if applicable)
this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT);
}
// Handle Dancer, which triggers immediately after a move is used (rather than waiting on `this.end()`).
// Note that the `!this.followUp` check here prevents an infinite Dancer loop.
if (this.move.getMove().hasFlag(MoveFlags.DANCE_MOVE) && !this.followUp) {
this.scene.getField(true).forEach(pokemon => {
applyPostMoveUsedAbAttrs(PostMoveUsedAbAttr, pokemon, this.move, this.pokemon, this.targets);
});
}
}
/** Queues a {@linkcode MoveChargePhase} for this phase's invoked move. */
protected chargeMove() {
const move = this.move.getMove();
const targets = this.getActiveTargetPokemon();
if (move.applyConditions(this.pokemon, targets[0], move)) {
// Protean and Libero apply on the charging turn of charge moves
applyPreAttackAbAttrs(PokemonTypeChangeAbAttr, this.pokemon, null, this.move.getMove());
this.showMoveText();
this.scene.unshiftPhase(new MoveChargePhase(this.scene, this.pokemon.getBattlerIndex(), this.targets[0], this.move));
} else {
this.pokemon.pushMoveHistory({ move: this.move.moveId, targets: this.targets, result: MoveResult.FAIL, virtual: this.move.virtual });
let failedText: string | undefined;
const failureMessage = move.getFailedText(this.pokemon, targets[0], move, new BooleanHolder(false));
if (failureMessage) {
failedText = failureMessage;
}
this.showMoveText();
this.showFailedText(failedText);
// Remove the user from its semi-invulnerable state (if applicable)
this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT);
}
}
/**
* Queues a {@linkcode MoveEndPhase} if the move wasn't a {@linkcode followUp} and {@linkcode canMove()} returns `true`,
* then ends the phase.
*/
public end(): void {
if (!this.followUp && this.canMove()) {
this.scene.unshiftPhase(new MoveEndPhase(this.scene, this.pokemon.getBattlerIndex()));
}
super.end();
}
/**
* Applies PP increasing abilities (currently only {@link Abilities.PRESSURE Pressure}) if they exist on the target pokemon.
* Note that targets must include only active pokemon.
*
* TODO: This hardcodes the PP increase at 1 per opponent, rather than deferring to the ability.
*/
public getPpIncreaseFromPressure(targets: Pokemon[]): number {
const foesWithPressure = this.pokemon.getOpponents().filter(o => targets.includes(o) && o.isActive(true) && o.hasAbilityWithAttr(IncreasePpAbAttr));
return foesWithPressure.length;
}
/**
* Modifies `this.targets` in place, based upon:
* - Move redirection abilities, effects, etc.
* - Counterattacks, which pass a special value into the `targets` constructor param (`[`{@linkcode BattlerIndex.ATTACKER}`]`).
*/
protected resolveRedirectTarget(): void {
if (this.targets.length === 1) {
const currentTarget = this.targets[0];
const redirectTarget = new NumberHolder(currentTarget);
// check move redirection abilities of every pokemon *except* the user.
this.scene.getField(true).filter(p => p !== this.pokemon).forEach(p => applyAbAttrs(RedirectMoveAbAttr, p, null, false, this.move.moveId, redirectTarget));
/** `true` if an Ability is responsible for redirecting the move to another target; `false` otherwise */
let redirectedByAbility = (currentTarget !== redirectTarget.value);
// check for center-of-attention tags (note that this will override redirect abilities)
this.pokemon.getOpponents().forEach(p => {
const redirectTag = p.getTag(CenterOfAttentionTag);
// TODO: don't hardcode this interaction.
// Handle interaction between the rage powder center-of-attention tag and moves used by grass types/overcoat-havers (which are immune to RP's redirect)
if (redirectTag && (!redirectTag.powder || (!this.pokemon.isOfType(Type.GRASS) && !this.pokemon.hasAbility(Abilities.OVERCOAT)))) {
redirectTarget.value = p.getBattlerIndex();
redirectedByAbility = false;
}
});
if (currentTarget !== redirectTarget.value) {
const bypassRedirectAttrs = this.move.getMove().getAttrs(BypassRedirectAttr);
bypassRedirectAttrs.forEach((attr) => {
if (!attr.abilitiesOnly || redirectedByAbility) {
redirectTarget.value = currentTarget;
}
});
if (this.pokemon.hasAbilityWithAttr(BlockRedirectAbAttr)) {
redirectTarget.value = currentTarget;
this.scene.unshiftPhase(new ShowAbilityPhase(this.scene, this.pokemon.getBattlerIndex(), this.pokemon.getPassiveAbility().hasAttr(BlockRedirectAbAttr)));
}
this.targets[0] = redirectTarget.value;
}
}
}
/**
* Counter-attacking moves pass in `[`{@linkcode BattlerIndex.ATTACKER}`]` into the constructor's `targets` param.
* This function modifies `this.targets` to reflect the actual battler index of the user's last
* attacker.
*
* If there is no last attacker, or they are no longer on the field, a message is displayed and the
* move is marked for failure.
*/
protected resolveCounterAttackTarget(): void {
if (this.targets.length === 1 && this.targets[0] === BattlerIndex.ATTACKER) {
if (this.pokemon.turnData.attacksReceived.length) {
this.targets[0] = this.pokemon.turnData.attacksReceived[0].sourceBattlerIndex;
// account for metal burst and comeuppance hitting remaining targets in double battles
// counterattack will redirect to remaining ally if original attacker faints
if (this.scene.currentBattle.double && this.move.getMove().hasFlag(MoveFlags.REDIRECT_COUNTER)) {
if (this.scene.getField()[this.targets[0]].hp === 0) {
const opposingField = this.pokemon.isPlayer() ? this.scene.getEnemyField() : this.scene.getPlayerField();
this.targets[0] = opposingField.find(p => p.hp > 0)?.getBattlerIndex() ?? BattlerIndex.ATTACKER;
}
}
}
if (this.targets[0] === BattlerIndex.ATTACKER) {
this.fail();
this.showMoveText();
this.showFailedText();
}
}
}
/**
* Handles the case where the move was cancelled or failed:
* - Uses PP if the move failed (not cancelled) and should use PP (failed moves are not affected by {@link Abilities.PRESSURE Pressure})
* - Records a cancelled OR failed move in move history, so abilities like {@link Abilities.TRUANT Truant} don't trigger on the
* next turn and soft-lock.
* - Lapses `MOVE_EFFECT` tags:
* - Semi-invulnerable battler tags (Fly/Dive/etc.) are intended to lapse on move effects, but also need
* to lapse on move failure/cancellation.
*
* TODO: ...this seems weird.
* - Lapses `AFTER_MOVE` tags:
* - This handles the effects of {@link Moves.SUBSTITUTE Substitute}
* - Removes the second turn of charge moves
*/
protected handlePreMoveFailures(): void {
if (this.cancelled || this.failed) {
if (this.failed) {
const ppUsed = this.ignorePp ? 0 : 1;
if (ppUsed) {
this.move.usePp();
}
this.scene.eventTarget.dispatchEvent(new MoveUsedEvent(this.pokemon?.id, this.move.getMove(), ppUsed));
}
if (this.cancelled && this.pokemon.summonData?.tags?.find(t => t.tagType === BattlerTagType.FRENZY)) {
frenzyMissFunc(this.pokemon, this.move.getMove());
}
this.pokemon.pushMoveHistory({ move: Moves.NONE, result: MoveResult.FAIL });
this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT);
this.pokemon.lapseTags(BattlerTagLapseType.AFTER_MOVE);
this.pokemon.getMoveQueue().shift();
}
}
/**
* Displays the move's usage text to the player, unless it's a charge turn (ie: {@link Moves.SOLAR_BEAM Solar Beam}),
* the pokemon is on a recharge turn (ie: {@link Moves.HYPER_BEAM Hyper Beam}), or a 2-turn move was interrupted (ie: {@link Moves.FLY Fly}).
*/
protected showMoveText(): void {
if (this.move.moveId === Moves.NONE) {
return;
}
if (this.pokemon.getTag(BattlerTagType.RECHARGING) || this.pokemon.getTag(BattlerTagType.INTERRUPTED)) {
return;
}
this.scene.queueMessage(i18next.t("battle:useMove", {
pokemonNameWithAffix: getPokemonNameWithAffix(this.pokemon),
moveName: this.move.getName()
}), 500);
applyMoveAttrs(PreMoveMessageAttr, this.pokemon, this.pokemon.getOpponents()[0], this.move.getMove());
}
protected showFailedText(failedText?: string): void {
this.scene.queueMessage(failedText ?? i18next.t("battle:attackFailed"));
}
}