diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index 494a0438b18..454a31e2c07 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -22,6 +22,14 @@ import { MoveId } from "#enums/move-id"; import { ArenaTagSide } from "#enums/arena-tag-side"; import { MoveUseMode } from "#enums/move-use-mode"; +/** biome-ignore lint/correctness/noUnusedImports: Type-only import for doc comment */ +import type { BattlerTag } from "#app/data/battler-tags"; + +/** + * An {@linkcode ArenaTag} represents a semi-persistent effect affecting a given side of the field. + * Unlike {@linkcode BattlerTag}s (which are tied to individual {@linkcode Pokemon}), `ArenaTag`s function independently of + * the Pokemon currently on-field, only cleared on arena reset or through their respective {@linkcode ArenaTag.lapse | lapse} methods. + */ export abstract class ArenaTag { constructor( public tagType: ArenaTagType, @@ -852,44 +860,104 @@ class ToxicSpikesTag extends ArenaTrapTag { } /** - * Arena Tag class for delayed attacks, such as {@linkcode MoveId.FUTURE_SIGHT} or {@linkcode MoveId.DOOM_DESIRE}. - * Delays the attack's effect by a set amount of turns, usually 3 (including the turn the move is used), - * and deals damage after the turn count is reached. + * Interface representing a delayed attack command. + * @see {@linkcode DelayedAttackTag} + */ +interface DelayedAttack { + sourceId: number; + move: MoveId; + targetIndex: BattlerIndex; + turnCount: number; +} + +/** + * Arena Tag to manage execution of delayed attacks, such as {@linkcode MoveId.FUTURE_SIGHT} or {@linkcode MoveId.DOOM_DESIRE}. + * Delayed attacks do nothing for the first 3 turns after use (including the turn the move is used), + * dealing damage to the specified slot after the turn count has been elapsed. */ export class DelayedAttackTag extends ArenaTag { - public targetIndex: BattlerIndex; + /** Contains all queued delayed attacks on the field */ + private delayedAttacks: DelayedAttack[] = []; - constructor( - tagType: ArenaTagType, - sourceMove: MoveId | undefined, - sourceId: number, - targetIndex: BattlerIndex, - side: ArenaTagSide = ArenaTagSide.BOTH, - ) { - super(tagType, 3, sourceMove, sourceId, side); - - this.targetIndex = targetIndex; - this.side = side; + constructor() { + super(ArenaTagType.DELAYED_ATTACK, 0); } - lapse(arena: Arena): boolean { - const ret = super.lapse(arena); + loadTag(source: ArenaTag | any): void { + super.loadTag(source); + this.delayedAttacks = source.delayedAttacks; + } + + /** + * Queue a delayed attack to be used in some indeterminate number of turns. + * @param source - The {@linkcode Pokemon} using the move + * @param move - The {@linkcode MoveId} being used + * @param targetIndex - The {@linkcode BattlerIndex} being targeted + * @param turnCount - The number of turns to delay the attack; default `3` + */ + public queueAttack(source: Pokemon, move: MoveId, targetIndex: BattlerIndex, turnCount = 3): void { + this.delayedAttacks.push({ sourceId: source.id, move, targetIndex, turnCount }); + } + + /** + * Check whether a delayed attack can be queued against the given target. + * @param targetIndex - The {@linkcode BattlerIndex} of the target Pokemon. + */ + public canAddAttack(targetIndex: BattlerIndex): boolean { + return this.delayedAttacks.every(atk => atk.targetIndex !== targetIndex); + } + + /** + * Tick down all existing delayed attacks, activating them if their timers have elapsed. + * @returns `true` if at least 1 delayed attack has not been completed + */ + override lapse(_arena: Arena): boolean { + for (const attack of this.delayedAttacks) { + const source = globalScene.getPokemonById(attack.sourceId); + const target: Pokemon | null = globalScene.getField()[attack.targetIndex]; + + if (--attack.turnCount > 0) { + // attack still cooking + continue; + } + + if (!source || !target || source === target || target.isFainted()) { + // source/target either not present or the exact same pokemon; silently mark for deletion + attack.turnCount = -1; + continue; + } + + // Queue attack message and then unshift a new MoveEffectPhase for this move's attack phase. + globalScene.phaseManager.queueMessage( + i18next.t("moveTriggers:tookMoveAttack", { + pokemonName: getPokemonNameWithAffix(target), + moveName: allMoves[attack.move].name, + }), + ); - if (!ret) { - // TODO: This should not add to move history (for Spite) globalScene.phaseManager.unshiftNew( "MoveEffectPhase", - this.sourceId!, - [this.targetIndex], - allMoves[this.sourceMove!], - MoveUseMode.FOLLOW_UP, - ); // TODO: are those bangs correct? + attack.sourceId, + [attack.targetIndex], + allMoves[attack.move], + MoveUseMode.TRANSPARENT, + ); } - return ret; + return this.removeDoneAttacks(); } - onRemove(_arena: Arena): void {} + /** + * Remove all finished attacks from the current queue. + * @returns Whether at least 1 attack has not finished triggering + */ + removeDoneAttacks(): boolean { + this.delayedAttacks = this.delayedAttacks.filter(a => a.turnCount > 0); + return this.delayedAttacks.length > 0; + } + + /** Override on remove func to do nothing. */ + override onRemove(_arena: Arena): void {} } /** @@ -1481,7 +1549,6 @@ export function getArenaTag( turnCount: number, sourceMove: MoveId | undefined, sourceId: number, - targetIndex?: BattlerIndex, side: ArenaTagSide = ArenaTagSide.BOTH, ): ArenaTag | null { switch (tagType) { @@ -1507,9 +1574,8 @@ export function getArenaTag( return new SpikesTag(sourceId, side); case ArenaTagType.TOXIC_SPIKES: return new ToxicSpikesTag(sourceId, side); - case ArenaTagType.FUTURE_SIGHT: - case ArenaTagType.DOOM_DESIRE: - return new DelayedAttackTag(tagType, sourceMove, sourceId, targetIndex!, side); // TODO:questionable bang + case ArenaTagType.DELAYED_ATTACK: + return new DelayedAttackTag(); case ArenaTagType.WISH: return new WishTag(turnCount, sourceId, side); case ArenaTagType.STEALTH_ROCK: @@ -1556,14 +1622,7 @@ export function getArenaTag( */ export function loadArenaTag(source: ArenaTag | any): ArenaTag { const tag = - getArenaTag( - source.tagType, - source.turnCount, - source.sourceMove, - source.sourceId, - source.targetIndex, - source.side, - ) ?? new NoneTag(); + getArenaTag(source.tagType, source.sourceId, source.sourceMove, source.turnCount, source.side) ?? new NoneTag(); tag.loadTag(source); return tag; } diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index f94c59bb463..04160695961 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -30,7 +30,7 @@ import { PokemonType } from "#enums/pokemon-type"; import { BooleanHolder, NumberHolder, isNullOrUndefined, toDmgValue, randSeedItem, randSeedInt, getEnumValues, toReadableString, type Constructor, randSeedFloat } from "#app/utils/common"; import { WeatherType } from "#enums/weather-type"; import type { ArenaTrapTag } from "../arena-tag"; -import { WeakenMoveTypeTag } from "../arena-tag"; +import { DelayedAttackTag, WeakenMoveTypeTag } from "../arena-tag"; import { ArenaTagSide } from "#enums/arena-tag-side"; import { applyAbAttrs, @@ -3094,52 +3094,80 @@ export class OverrideMoveEffectAttr extends MoveAttr { * Its sole purpose is to ensure that typescript is able to properly narrow when the `is` method is called. */ declare private _: never; - override apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + /** + * Apply the move attribute to override a move effect. + * @param user - The {@linkcode Pokemon} using the move + * @param target - The {@linkcode Pokemon} targeted by the move + * @param move - The {@linkcode Move} being used + * @param args - + * `[0]`: A {@linkcode BooleanHolder} containing whether move effects were successfully overridden; should be set to `true` on success + * `[1]`: The {@linkcode MoveUseMode} dictating how this move was used. + * @returns `true` if the move effect was successfully overridden. + */ + override apply(_user: Pokemon, _target: Pokemon, _move: Move, _args: [overridden: BooleanHolder, useMode: MoveUseMode]): boolean { return true; } } /** - * Attack Move that doesn't hit the turn it is played and doesn't allow for multiple - * uses on the same target. Examples are Future Sight or Doom Desire. - * @extends OverrideMoveEffectAttr - * @param tagType The {@linkcode ArenaTagType} that will be placed on the field when the move is used - * @param chargeAnim The {@linkcode ChargeAnim | Charging Animation} used for the move - * @param chargeText The text to display when the move is used + * Attribute to implement delayed attacks, such as {@linkcode MoveId.FUTURE_SIGHT} or {@linkcode MoveId.DOOM_DESIRE}. + * Delays the attack's effect by adding an arena tag, + * only after the turn count is fully elapsed. */ export class DelayedAttackAttr extends OverrideMoveEffectAttr { - public tagType: ArenaTagType; public chargeAnim: ChargeAnim; private chargeText: string; - constructor(tagType: ArenaTagType, chargeAnim: ChargeAnim, chargeText: string) { + /** + * @param chargeAnim - The {@linkcode ChargeAnim | charging animation} used for the move's charging phase. + * @param chargeKey - The `i18next` locales **key** to show when the delayed attack is used. + * In the displayed text, `{{pokemonName}}` and `{{targetName}}` will be populated with the user's & target's names respectively. + */ + constructor(chargeAnim: ChargeAnim, chargeKey: string) { super(); - this.tagType = tagType; this.chargeAnim = chargeAnim; - this.chargeText = chargeText; + this.chargeText = chargeKey; } - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - // Edge case for the move applied on a pokemon that has fainted - if (!target) { + getCondition(): MoveConditionFunc { + return (_user, target, _move) => { + // Check the arena if another delayed attack is active and hitting the same slot + const delayedTag = globalScene.arena.getTag(DelayedAttackTag); + return delayedTag?.canAddAttack(target.getBattlerIndex()) ?? true; + } + } + + apply(user: Pokemon, target: Pokemon, move: Move, args: [overridden: BooleanHolder, useMode: MoveUseMode]): boolean { + const useMode = args[1]; + if (useMode === MoveUseMode.TRANSPARENT) { + // don't trigger if already queueing an indirect attack return true; } - const overridden = args[0] as BooleanHolder; - const virtual = args[1] as boolean; + const overridden = args[0]; + overridden.value = true; - if (!virtual) { - overridden.value = true; - globalScene.phaseManager.unshiftNew("MoveAnimPhase", new MoveChargeAnim(this.chargeAnim, move.id, user)); - globalScene.phaseManager.queueMessage(this.chargeText.replace("{TARGET}", getPokemonNameWithAffix(target)).replace("{USER}", getPokemonNameWithAffix(user))); - user.pushMoveHistory({ move: move.id, targets: [ target.getBattlerIndex() ], result: MoveResult.OTHER, useMode: MoveUseMode.NORMAL }); - const side = target.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; - globalScene.arena.addTag(this.tagType, 3, move.id, user.id, side, false, target.getBattlerIndex()); - } else { - globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:tookMoveAttack", { pokemonName: getPokemonNameWithAffix(globalScene.getPokemonById(target.id) ?? undefined), moveName: move.name })); + // Display the move animation to foresee an attack + globalScene.phaseManager.unshiftNew("MoveAnimPhase", new MoveChargeAnim(this.chargeAnim, move.id, user)); + globalScene.phaseManager.queueMessage(i18next.t(this.chargeText, + // uncomment if any new delayed moves actually use target in the move text. + {pokemonName: getPokemonNameWithAffix(user)/*, targetName: getPokemonNameWithAffix(target) */})) + + user.pushMoveHistory({move: move.id, targets: [target.getBattlerIndex()], result: MoveResult.OTHER, useMode: useMode, turn: globalScene.currentBattle.turn}) + + // Add a Delayed Attack tag to the arena if it doesn't already exist and queue up an extra attack. + // TODO: Remove unused params once signature is tweaked to make more sense (none of these get used) + globalScene.arena.addTag(ArenaTagType.DELAYED_ATTACK, 123, 69, 420); + + // Queue an attack on the added (or existing) tag + const delayedAttackTag = globalScene.arena.getTag(DelayedAttackTag) + if (!delayedAttackTag) { + console.warn("Delayed attack tag not present!") + return false; } + delayedAttackTag.queueAttack(user, move.id, target.getBattlerIndex()); return true; } } @@ -3159,8 +3187,8 @@ export class AwaitCombinedPledgeAttr extends OverrideMoveEffectAttr { * @param user the {@linkcode Pokemon} using this move * @param target n/a * @param move the {@linkcode Move} being used - * @param args - * - [0] a {@linkcode BooleanHolder} indicating whether the move's base + * @param args - + * `[0]`: A {@linkcode BooleanHolder} indicating whether the move's base * effects should be overridden this turn. * @returns `true` if base move effects were overridden; `false` otherwise */ @@ -9194,9 +9222,13 @@ export function initMoves() { .attr(StatStageChangeAttr, [ Stat.SPDEF ], -1) .ballBombMove(), new AttackMove(MoveId.FUTURE_SIGHT, PokemonType.PSYCHIC, MoveCategory.SPECIAL, 120, 100, 10, -1, 0, 2) - .partial() // cannot be used on multiple Pokemon on the same side in a double battle, hits immediately when called by Metronome/etc, should not apply abilities or held items if user is off the field + .attr(DelayedAttackAttr, ChargeAnim.FUTURE_SIGHT_CHARGING, "moveTriggers:foresawAnAttack") .ignoresProtect() - .attr(DelayedAttackAttr, ArenaTagType.FUTURE_SIGHT, ChargeAnim.FUTURE_SIGHT_CHARGING, i18next.t("moveTriggers:foresawAnAttack", { pokemonName: "{USER}" })), + /* + * Should not apply abilities or held items if user is off the field + * Triggered move phase occurs after Electrify tag is removed + */ + .edgeCase(), new AttackMove(MoveId.ROCK_SMASH, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 40, 100, 15, 50, 0, 2) .attr(StatStageChangeAttr, [ Stat.DEF ], -1), new AttackMove(MoveId.WHIRLPOOL, PokemonType.WATER, MoveCategory.SPECIAL, 35, 85, 15, -1, 0, 2) @@ -9532,9 +9564,13 @@ export function initMoves() { .attr(ConfuseAttr) .pulseMove(), new AttackMove(MoveId.DOOM_DESIRE, PokemonType.STEEL, MoveCategory.SPECIAL, 140, 100, 5, -1, 0, 3) - .partial() // cannot be used on multiple Pokemon on the same side in a double battle, hits immediately when called by Metronome/etc, should not apply abilities or held items if user is off the field + .attr(DelayedAttackAttr, ChargeAnim.DOOM_DESIRE_CHARGING, "moveTriggers:choseDoomDesireAsDestiny") .ignoresProtect() - .attr(DelayedAttackAttr, ArenaTagType.DOOM_DESIRE, ChargeAnim.DOOM_DESIRE_CHARGING, i18next.t("moveTriggers:choseDoomDesireAsDestiny", { pokemonName: "{USER}" })), + /* + * Should not apply abilities or held items if user is off the field + * Triggered move phase occurs after Electrify tag is removed + */ + .edgeCase(), new AttackMove(MoveId.PSYCHO_BOOST, PokemonType.PSYCHIC, MoveCategory.SPECIAL, 140, 90, 5, -1, 0, 3) .attr(StatStageChangeAttr, [ Stat.SPATK ], -2, true), new SelfStatusMove(MoveId.ROOST, PokemonType.FLYING, -1, 5, -1, 0, 4) diff --git a/src/enums/arena-tag-type.ts b/src/enums/arena-tag-type.ts index 4180aa00ef5..b0d273a493e 100644 --- a/src/enums/arena-tag-type.ts +++ b/src/enums/arena-tag-type.ts @@ -5,8 +5,7 @@ export enum ArenaTagType { SPIKES = "SPIKES", TOXIC_SPIKES = "TOXIC_SPIKES", MIST = "MIST", - FUTURE_SIGHT = "FUTURE_SIGHT", - DOOM_DESIRE = "DOOM_DESIRE", + DELAYED_ATTACK = "DELAYED_ATTACK", WISH = "WISH", STEALTH_ROCK = "STEALTH_ROCK", STICKY_WEB = "STICKY_WEB", diff --git a/src/enums/move-use-mode.ts b/src/enums/move-use-mode.ts index 31694ad4081..92b380264a3 100644 --- a/src/enums/move-use-mode.ts +++ b/src/enums/move-use-mode.ts @@ -1,5 +1,6 @@ import type { PostDancingMoveAbAttr } from "#app/data/abilities/ability"; import type { BattlerTagLapseType } from "#enums/battler-tag-lapse-type"; +import type { DelayedAttackAttr } from "#app/@types/move-types"; /** * Enum representing all the possible means through which a given move can be executed. @@ -59,8 +60,15 @@ export const MoveUseMode = { * and retain the same copy prevention as {@linkcode MoveUseMode.FOLLOW_UP}, but additionally * **cannot be reflected by other reflecting effects**. */ - REFLECTED: 5 - // TODO: Add use type TRANSPARENT for Future Sight and Doom Desire to prevent move history pushing + REFLECTED: 5, + /** + * This "move" was created by a transparent effect that **does not count as using a move**, + * such as {@linkcode DelayedAttackAttr | Future Sight/Doom Desire}. + * + * In addition to inheriting the cancellation ignores and copy prevention from {@linkcode MoveUseMode.REFLECTED}, + * transparent moves are ignored by **all forms of move usage checks** due to **not pushing to move history**. + */ + TRANSPARENT: 6 } as const; export type MoveUseMode = (typeof MoveUseMode)[keyof typeof MoveUseMode]; diff --git a/src/field/arena.ts b/src/field/arena.ts index 8d7e5037852..62cb421060c 100644 --- a/src/field/arena.ts +++ b/src/field/arena.ts @@ -687,15 +687,15 @@ export class Arena { } /** - * Adds a new tag to the arena - * @param tagType {@linkcode ArenaTagType} the tag being added - * @param turnCount How many turns the tag lasts - * @param sourceMove {@linkcode MoveId} the move the tag came from, or `undefined` if not from a move - * @param sourceId The ID of the pokemon in play the tag came from (see {@linkcode BattleScene.getPokemonById}) - * @param side {@linkcode ArenaTagSide} which side(s) the tag applies to - * @param quiet If a message should be queued on screen to announce the tag being added - * @param targetIndex The {@linkcode BattlerIndex} of the target pokemon - * @returns `false` if there already exists a tag of this type in the Arena + * Add a new {@linkcode ArenaTag} to the arena, triggering overlap effects on existing tags as applicable. + * @param tagType - The {@linkcode ArenaTagType} of the tag to add. + * @param turnCount - The number of turns the newly-added tag should last. + * @param sourceId - The {@linkcode Pokemon.id | PID} of the Pokemon creating the tag. + * @param sourceMove - The {@linkcode MoveId} of the move creating the tag, or `undefined` if not from a move. + * @param side - The {@linkcode ArenaTagSide}(s) to which the tag should apply; default `ArenaTagSide.BOTH`. + * @param quiet - Whether to suppress messages produced by tag addition; default `false`. + * @returns `true` if the tag was successfully added without overlapping. + // TODO: Do we need the return value here? literally nothing uses it */ addTag( tagType: ArenaTagType, @@ -704,7 +704,6 @@ export class Arena { sourceId: number, side: ArenaTagSide = ArenaTagSide.BOTH, quiet = false, - targetIndex?: BattlerIndex, ): boolean { const existingTag = this.getTagOnSide(tagType, side); if (existingTag) { @@ -719,7 +718,7 @@ export class Arena { } // creates a new tag object - const newTag = getArenaTag(tagType, turnCount || 0, sourceMove, sourceId, targetIndex, side); + const newTag = getArenaTag(tagType, turnCount || 0, sourceMove, sourceId, side); if (newTag) { newTag.onAdd(this, quiet); this.tags.push(newTag); @@ -735,10 +734,21 @@ export class Arena { } /** - * Attempts to get a tag from the Arena via {@linkcode getTagOnSide} that applies to both sides - * @param tagType The {@linkcode ArenaTagType} or {@linkcode ArenaTag} to get - * @returns either the {@linkcode ArenaTag}, or `undefined` if it isn't there + * Attempt to get a tag from the Arena via {@linkcode getTagOnSide} that applies to both sides + * @param tagType The {@linkcode ArenaTagType} to retrieve + * @returns The existing {@linkcode ArenaTag}, or `undefined` if not present. + * @overload */ + getTag(tagType: ArenaTagType): ArenaTag | undefined; + + /** + * Attempt to get a tag from the Arena via {@linkcode getTagOnSide} that applies to both sides + * @param tagType The {@linkcode ArenaTag} to retrieve + * @returns The existing {@linkcode ArenaTag}, or `undefined` if not present. + * @overload + */ + getTag(tagType: Constructor): T | undefined; + getTag(tagType: ArenaTagType | Constructor): ArenaTag | undefined { return this.getTagOnSide(tagType, ArenaTagSide.BOTH); } diff --git a/src/phases/attempt-run-phase.ts b/src/phases/attempt-run-phase.ts index 5e24f3474a6..229c9b78374 100644 --- a/src/phases/attempt-run-phase.ts +++ b/src/phases/attempt-run-phase.ts @@ -7,6 +7,7 @@ import i18next from "i18next"; import { NumberHolder } from "#app/utils/common"; import { PokemonPhase } from "./pokemon-phase"; import { globalScene } from "#app/global-scene"; +import { ArenaTagType } from "#enums/arena-tag-type"; export class AttemptRunPhase extends PokemonPhase { public readonly phaseName = "AttemptRunPhase"; @@ -45,6 +46,9 @@ export class AttemptRunPhase extends PokemonPhase { globalScene.clearEnemyHeldItemModifiers(); + // clear all queued delayed attacks (e.g. from Future Sight) + globalScene.arena.removeTag(ArenaTagType.DELAYED_ATTACK); + // biome-ignore lint/complexity/noForEach: TODO enemyField.forEach(enemyPokemon => { enemyPokemon.hideInfo().then(() => enemyPokemon.destroy()); diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index d7da1ab996c..06cb1446f5e 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -275,12 +275,13 @@ export class MoveEffectPhase extends PokemonPhase { } } + const move = this.move; + /** * Does an effect from this move override other effects on this turn? * e.g. Charging moves (Fly, etc.) on their first turn of use. */ const overridden = new BooleanHolder(false); - const move = this.move; // Apply effects to override a move effect. // Assuming single target here works as this is (currently) @@ -368,7 +369,7 @@ export class MoveEffectPhase extends PokemonPhase { */ private postAnimCallback(user: Pokemon, targets: Pokemon[]) { // Add to the move history entry - if (this.firstHit) { + if (this.firstHit && this.useMode !== MoveUseMode.TRANSPARENT) { user.pushMoveHistory(this.moveHistoryEntry); applyExecutedMoveAbAttrs("ExecutedMoveAbAttr", user); } diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 2e94b085948..1827de58ccb 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -1,7 +1,6 @@ import { BattlerIndex } from "#enums/battler-index"; import { globalScene } from "#app/global-scene"; import { applyAbAttrs, applyPostMoveUsedAbAttrs, applyPreAttackAbAttrs } from "#app/data/abilities/apply-ab-attrs"; -import type { DelayedAttackTag } from "#app/data/arena-tag"; import { CommonAnim } from "#enums/move-anims-common"; import { CenterOfAttentionTag } from "#app/data/battler-tags"; import { BattlerTagLapseType } from "#enums/battler-tag-lapse-type"; @@ -21,7 +20,6 @@ import Overrides from "#app/overrides"; import { BattlePhase } from "#app/phases/battle-phase"; import { enumValueToKey, NumberHolder } from "#app/utils/common"; import { AbilityId } from "#enums/ability-id"; -import { ArenaTagType } from "#enums/arena-tag-type"; import { BattlerTagType } from "#enums/battler-tag-type"; import { MoveId } from "#enums/move-id"; import { StatusEffect } from "#enums/status-effect"; @@ -299,33 +297,6 @@ export class MovePhase extends BattlePhase { // form changes happen even before we know that the move wll execute. globalScene.triggerPokemonFormChange(this.pokemon, SpeciesFormChangePreMoveTrigger); - const isDelayedAttack = move.hasAttr("DelayedAttackAttr"); - if (isDelayedAttack) { - // Check the player side arena if future sight is active - const futureSightTags = globalScene.arena.findTags(t => t.tagType === ArenaTagType.FUTURE_SIGHT); - const doomDesireTags = globalScene.arena.findTags(t => t.tagType === ArenaTagType.DOOM_DESIRE); - let fail = false; - const currentTargetIndex = targets[0].getBattlerIndex(); - for (const tag of futureSightTags) { - if ((tag as DelayedAttackTag).targetIndex === currentTargetIndex) { - fail = true; - break; - } - } - for (const tag of doomDesireTags) { - if ((tag as DelayedAttackTag).targetIndex === currentTargetIndex) { - fail = true; - break; - } - } - if (fail) { - this.showMoveText(); - this.showFailedText(); - this.end(); - return; - } - } - let success = true; // Check if there are any attributes that can interrupt the move, overriding the fail message. for (const move of this.move.getMove().getAttrs("PreUseInterruptAttr")) { diff --git a/src/phases/turn-end-phase.ts b/src/phases/turn-end-phase.ts index ab46292c1d2..80761644b70 100644 --- a/src/phases/turn-end-phase.ts +++ b/src/phases/turn-end-phase.ts @@ -61,6 +61,7 @@ export class TurnEndPhase extends FieldPhase { this.executeForAll(handlePokemon); + // TODO: This needs to be moved up before the `handlePokemon` call for Electrify, while also globalScene.arena.lapseTags(); if (globalScene.arena.weather && !globalScene.arena.weather.lapse()) { diff --git a/test/moves/delayed_attack.test.ts b/test/moves/delayed_attack.test.ts new file mode 100644 index 00000000000..0a93af270ac --- /dev/null +++ b/test/moves/delayed_attack.test.ts @@ -0,0 +1,326 @@ +import { BattlerIndex } from "#enums/battler-index"; +import { allMoves } from "#app/data/data-lists"; +import { DelayedAttackTag } from "#app/data/arena-tag"; +import { allAbilities } from "#app/data/data-lists"; +import { RandomMoveAttr } from "#app/data/moves/move"; +import { MoveResult } from "#enums/move-result"; +import { getPokemonNameWithAffix } from "#app/messages"; +import { AttackTypeBoosterModifier } from "#app/modifier/modifier"; +import { AbilityId } from "#enums/ability-id"; +import { MoveId } from "#enums/move-id"; +import { PokemonType } from "#enums/pokemon-type"; +import { SpeciesId } from "#enums/species-id"; +import GameManager from "#test/testUtils/gameManager"; +import i18next from "i18next"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { BattleType } from "#enums/battle-type"; + +describe("Moves - Delayed Attacks", () => { + 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.NO_GUARD) + .battleStyle("single") + .enemySpecies(SpeciesId.MAGIKARP) + .enemyAbility(AbilityId.STURDY) + .enemyMoveset(MoveId.SPLASH); + }); + + /** + * Wait until a number of turns have passed. + * @param numTurns - Number of turns to pass. + * @returns: A Promise that resolves once the specified number of turns has elapsed. + */ + async function passTurns(numTurns: number): Promise { + for (let i = 0; i < numTurns; i++) { + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER); + if (game.scene.currentBattle.double && game.scene.getPlayerField()[1]) { + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2); + } + await game.toNextTurn(); + } + } + + /** + * Expect that future sight is active with the specified number of attacks. + * @param numAttacks - The number of delayed attacks that should be queued; default `1` + */ + function expectFutureSightActive(numAttacks = 1) { + const tag = game.scene.arena.getTag(DelayedAttackTag)!; + expect(tag).toBeDefined(); + expect(tag["delayedAttacks"]).toHaveLength(numAttacks); + } + + it.each<{ name: string; move: MoveId }>([ + { name: "Future Sight", move: MoveId.FUTURE_SIGHT }, + { name: "Doom Desire", move: MoveId.DOOM_DESIRE }, + ])("$name should show message and strike 2 turns after use, ignoring player/enemy switches", async ({ move }) => { + game.override.battleType(BattleType.TRAINER) + await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.MILOTIC]); + + game.move.use(move); + await game.toNextTurn(); + + expectFutureSightActive(); + + game.doSwitchPokemon(1); + game.forceEnemyToSwitch(); + await game.toNextTurn(); + + game.move.use(MoveId.SPLASH); + await game.toEndOfTurn(); + + const enemy = game.field.getEnemyPokemon(); + expect(enemy.hp).toBeLessThan(enemy.getMaxHp()); + expect(game.textInterceptor.logs).toContain( + i18next.t("moveTriggers:tookMoveAttack", { + pokemonName: getPokemonNameWithAffix(enemy), + moveName: allMoves[move].name, + }), + ); + }); + + it("should fail (preserving prior instances) when used against the same target", async () => { + await game.classicMode.startBattle([SpeciesId.BRONZONG]); + + game.move.use(MoveId.FUTURE_SIGHT); + await game.toNextTurn(); + + expectFutureSightActive(); + const bronzong = game.field.getPlayerPokemon(); + expect(bronzong.getLastXMoves()[0].result).toBe(MoveResult.OTHER); + + game.move.use(MoveId.FUTURE_SIGHT); + await game.toNextTurn(); + + expectFutureSightActive(); + expect(bronzong.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + }); + + it("should still be delayed when copied by other moves", async () => { + vi.spyOn(RandomMoveAttr.prototype, "getMoveOverride").mockReturnValue(MoveId.FUTURE_SIGHT); + await game.classicMode.startBattle([SpeciesId.BRONZONG]); + + game.move.use(MoveId.METRONOME); + await game.toNextTurn(); + + const enemy = game.field.getEnemyPokemon(); + expect(enemy.hp).toBe(enemy.getMaxHp()); + + expectFutureSightActive(); + + await passTurns(2); + + expect(enemy.hp).toBeLessThan(enemy.getMaxHp()); + }); + + it("should work when used against different targets in doubles", async () => { + game.override.battleStyle("double"); + await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS]); + + const [karp, feebas, enemy1, enemy2] = game.scene.getField(); + + game.move.use(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER, BattlerIndex.ENEMY); + game.move.use(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2); + await game.toEndOfTurn() + + expectFutureSightActive(2); + expect(enemy1.hp).toBe(enemy1.getMaxHp()); + expect(enemy2.hp).toBe(enemy2.getMaxHp()); + expect(karp.getLastXMoves()[0].result).toBe(MoveResult.OTHER); + expect(feebas.getLastXMoves()[0].result).toBe(MoveResult.OTHER); + + await passTurns(2); + + expect(enemy1.hp).toBeLessThan(enemy1.getMaxHp()); + expect(enemy2.hp).toBeLessThan(enemy2.getMaxHp()); + }); + + it("should vanish silently if it would otherwise hit the user", async () => { + game.override.battleStyle("double"); + await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS, SpeciesId.MIENFOO]); + + const [karp, feebas] = game.scene.getPlayerField(); + + game.move.use(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER, BattlerIndex.PLAYER_2); + // Karp / Feebas / Milotic + game.doSwitchPokemon(2); + await game.toNextTurn(); + + expectFutureSightActive(1); + + // Milotic / Feebas // Karp + game.doSwitchPokemon(2); + // Feebas / Karp // Milotic + game.doSwitchPokemon(2); + await game.toNextTurn(); + + await passTurns(1); + + expect(karp.hp).toBe(karp.getMaxHp()); + expect(feebas.hp).toBe(feebas.getMaxHp()); + expect(game.textInterceptor.logs).not.toContain( + i18next.t("moveTriggers:tookMoveAttack", { + pokemonName: getPokemonNameWithAffix(karp), + moveName: allMoves[MoveId.FUTURE_SIGHT].name, + }), + ); + }); + + it("should redirect normally if target is fainted when attack is launched", async () => { + game.override.battleStyle("double"); + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + + const [enemy1, enemy2] = game.scene.getEnemyField(); + + game.move.use(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER, BattlerIndex.ENEMY_2); + await game.killPokemon(enemy2); + await game.toNextTurn(); + + expect(enemy2.isFainted()).toBe(true); + expectFutureSightActive(1); + + await passTurns(2); + + expect(enemy1.hp).toBeLessThan(enemy1.getMaxHp()); + expect(game.textInterceptor.logs).toContain( + i18next.t("moveTriggers:tookMoveAttack", { + pokemonName: getPokemonNameWithAffix(enemy1), + moveName: allMoves[MoveId.FUTURE_SIGHT].name, + }), + ); + }); + + it("should vanish silently if target is fainted when attack lands", async () => { + game.override.battleStyle("double"); + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + + const [enemy1, enemy2] = game.scene.getEnemyField(); + + game.move.use(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER, BattlerIndex.ENEMY_2); + await game.toNextTurn(); + + expectFutureSightActive(1); + + await passTurns(1); + + game.move.use(MoveId.SPLASH); + await game.killPokemon(enemy2); + await game.toNextTurn(); + + expect(enemy1.hp).toBe(enemy1.getMaxHp()); + expect(game.textInterceptor.logs).not.toContain( + i18next.t("moveTriggers:tookMoveAttack", { + pokemonName: getPokemonNameWithAffix(enemy1), + moveName: allMoves[MoveId.FUTURE_SIGHT].name, + }), + ); + }); + + // TODO: ArenaTags currently proc concurrently with battler tag removal in `TurnEndPhase`, + // meaning the queued `MoveEffectPhase` no longer has Electrify applied to it + it.todo("should consider type changes at moment of execution & ignore Lightning Rod redirection", async () => { + game.override.battleStyle("double"); + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + + // fake left enemy having lightning rod + const [enemy1, enemy2] = game.scene.getEnemyField(); + vi.spyOn(enemy1, "getAbility").mockReturnValue(allAbilities[AbilityId.LIGHTNING_ROD]); + // helps with logging + vi.spyOn(enemy1, "getNameToRender").mockReturnValue("Karp 1"); + vi.spyOn(enemy2, "getNameToRender").mockReturnValue("Karp 2"); + + game.move.use(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER, BattlerIndex.ENEMY_2); + await game.toNextTurn(); + + expectFutureSightActive(1); + + await passTurns(1); + + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER); + game.move.changeMoveset(enemy1, MoveId.ELECTRIFY); + await game.move.forceEnemyMove(MoveId.ELECTRIFY, BattlerIndex.PLAYER); + await game.phaseInterceptor.to("TurnEndPhase"); + await game.phaseInterceptor.to("MoveEffectPhase", false); + + // Wait until all normal attacks have triggered, then check pending MEP + const karp = game.field.getPlayerPokemon(); + const typeMock = vi.spyOn(karp, "getMoveType"); + + await game.toNextTurn(); + + expect(enemy1.hp).toBe(enemy1.getMaxHp()); + expect(enemy2.hp).toBeLessThan(enemy2.getMaxHp()); + expect(game.textInterceptor.logs).toContain( + i18next.t("moveTriggers:tookMoveAttack", { + pokemonName: getPokemonNameWithAffix(enemy2), + moveName: allMoves[MoveId.FUTURE_SIGHT].name, + }), + ); + expect(typeMock).toHaveLastReturnedWith(PokemonType.ELECTRIC); + }); + + // TODO: Enable once code is added to MEP to do this + it.todo("should not apply the user's abilities when dealing damage if the user is inactive", async () => { + game.override.ability(AbilityId.NORMALIZE).enemySpecies(SpeciesId.LUNALA); + await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.MILOTIC]); + + game.move.use(MoveId.DOOM_DESIRE); + await game.toNextTurn(); + + expectFutureSightActive(); + + await passTurns(1); + + game.doSwitchPokemon(1); + const karp = game.field.getPlayerPokemon(); + const typeMock = vi.spyOn(karp, "getMoveType"); + await game.toNextTurn(); + + const enemy = game.field.getEnemyPokemon(); + expect(enemy.hp).toBe(enemy.getMaxHp()); + expect(game.textInterceptor.logs).toContain( + i18next.t("moveTriggers:tookMoveAttack", { + pokemonName: getPokemonNameWithAffix(enemy), + moveName: allMoves[MoveId.FUTURE_SIGHT].name, + }), + ); + expect(typeMock).toHaveLastReturnedWith(PokemonType.NORMAL); + }); + + it.todo("should not apply the user's held items when dealing damage if the user is inactive", async () => { + game.override.startingHeldItems([{ name: "ATTACK_TYPE_BOOSTER", count: 99, type: PokemonType.STEEL }]); + await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.MILOTIC]); + + game.move.use(MoveId.DOOM_DESIRE); + await game.toNextTurn(); + + expectFutureSightActive(); + + await passTurns(1); + + game.doSwitchPokemon(1); + + const powerMock = vi.spyOn(allMoves[MoveId.DOOM_DESIRE], "calculateBattlePower"); + const typeBoostSpy = vi.spyOn(AttackTypeBoosterModifier.prototype, "apply"); + + await game.toNextTurn(); + + expect(powerMock).toHaveLastReturnedWith(120); + expect(typeBoostSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/test/moves/future_sight.test.ts b/test/moves/future_sight.test.ts deleted file mode 100644 index 7de70a88d10..00000000000 --- a/test/moves/future_sight.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { AbilityId } from "#enums/ability-id"; -import { MoveId } from "#enums/move-id"; -import { SpeciesId } from "#enums/species-id"; -import GameManager from "#test/testUtils/gameManager"; -import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; - -describe("Moves - Future Sight", () => { - 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 - .startingLevel(50) - .moveset([MoveId.FUTURE_SIGHT, MoveId.SPLASH]) - .battleStyle("single") - .enemySpecies(SpeciesId.MAGIKARP) - .enemyAbility(AbilityId.STURDY) - .enemyMoveset(MoveId.SPLASH); - }); - - it("hits 2 turns after use, ignores user switch out", async () => { - await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.MILOTIC]); - - game.move.select(MoveId.FUTURE_SIGHT); - await game.toNextTurn(); - game.doSwitchPokemon(1); - await game.toNextTurn(); - game.move.select(MoveId.SPLASH); - await game.toNextTurn(); - - expect(game.scene.getEnemyPokemon()!.isFullHp()).toBe(false); - }); -});