From f7b87f3d1e330f4fa20c85fc116f5021b4ce60ab Mon Sep 17 00:00:00 2001 From: Bertie690 <136088738+Bertie690@users.noreply.github.com> Date: Thu, 31 Jul 2025 00:43:06 -0400 Subject: [PATCH] [Move Bug] Fully implemented Future Sight, Doom Desire; fixed Wish Double battle oversight (#5862) * Mostly implemented Future Sight/Doom Desire * Fixed a few docs * Fixed com * Update magic_guard.test.ts * Update documentation * Update documentation on arena-tag.ts * Update arena-tag.ts docs * Update arena-tag.ts * Update turn-end-phase.ts * Update move.ts documentation * Fixed tpyo * Update move.ts documentation * Add assorted TODO test cases * Refactored FS to use a positional tag manager * Added strong typing to the manager, finished save load stufff * Fixed locales + tests * Fixed tests and documentation * sh Fixed tests for good * Fixed MEP * Reverted overrides changse * Fixed issues with merging * Fixed locales update & heal block test * Fixed wish tests * Fixed test typo * Fixed wish test flaking out due to speed ties * Fixed tests fr fr * actually fixed tests bc i'm stupid * Fixed tests for real * Remove locales update * Update arena-tag.ts Co-authored-by: Dean <69436131+emdeann@users.noreply.github.com> * Update move.ts Co-authored-by: Dean <69436131+emdeann@users.noreply.github.com> * Update arena-tag.ts Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> * Applied review suggestions and added a _wee_ bit more docs * fixed wish condition * Applied kev's reviews * Minor nits * Minor formatting change in `heal-block.test.ts` --------- Co-authored-by: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Co-authored-by: Dean <69436131+emdeann@users.noreply.github.com> Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> --- src/@types/arena-tags.ts | 3 - src/data/arena-tag.ts | 128 +----- src/data/moves/move.ts | 134 ++++-- .../positional-tags/load-positional-tag.ts | 70 ++++ .../positional-tags/positional-tag-manager.ts | 55 +++ src/data/positional-tags/positional-tag.ts | 174 ++++++++ src/enums/arena-tag-type.ts | 3 - src/enums/move-use-mode.ts | 79 ++-- src/enums/positional-tag-type.ts | 10 + src/field/arena.ts | 51 ++- src/phase-manager.ts | 2 + src/phases/move-effect-phase.ts | 33 +- src/phases/move-phase.ts | 17 - src/phases/positional-tag-phase.ts | 21 + src/phases/turn-start-phase.ts | 4 + src/system/arena-data.ts | 7 + src/system/game-data.ts | 5 + test/moves/delayed-attack.test.ts | 389 ++++++++++++++++++ test/moves/future-sight.test.ts | 45 -- test/moves/heal-block.test.ts | 26 +- test/moves/wish.test.ts | 183 ++++++++ test/test-utils/helpers/field-helper.ts | 12 +- test/test-utils/helpers/move-helper.ts | 12 +- test/test-utils/phase-interceptor.ts | 2 + test/types/positional-tags.test-d.ts | 29 ++ 25 files changed, 1175 insertions(+), 319 deletions(-) create mode 100644 src/data/positional-tags/load-positional-tag.ts create mode 100644 src/data/positional-tags/positional-tag-manager.ts create mode 100644 src/data/positional-tags/positional-tag.ts create mode 100644 src/enums/positional-tag-type.ts create mode 100644 src/phases/positional-tag-phase.ts create mode 100644 test/moves/delayed-attack.test.ts delete mode 100644 test/moves/future-sight.test.ts create mode 100644 test/moves/wish.test.ts create mode 100644 test/types/positional-tags.test-d.ts diff --git a/src/@types/arena-tags.ts b/src/@types/arena-tags.ts index cfdce4350da..afcc8a0f924 100644 --- a/src/@types/arena-tags.ts +++ b/src/@types/arena-tags.ts @@ -9,9 +9,6 @@ export type ArenaTrapTagType = | ArenaTagType.STEALTH_ROCK | ArenaTagType.IMPRISON; -/** Subset of {@linkcode ArenaTagType}s that are considered delayed attacks */ -export type ArenaDelayedAttackTagType = ArenaTagType.FUTURE_SIGHT | ArenaTagType.DOOM_DESIRE; - /** Subset of {@linkcode ArenaTagType}s that create {@link https://bulbapedia.bulbagarden.net/wiki/Category:Screen-creating_moves | screens}. */ export type ArenaScreenTagType = ArenaTagType.REFLECT | ArenaTagType.LIGHT_SCREEN | ArenaTagType.AURORA_VEIL; diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index b24fd17e0e3..9f2a5e09667 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -1,3 +1,7 @@ +/** biome-ignore-start lint/correctness/noUnusedImports: TSDoc imports */ +import type { BattlerTag } from "#app/data/battler-tags"; +/** biome-ignore-end lint/correctness/noUnusedImports: TSDoc imports */ + import { applyAbAttrs, applyOnGainAbAttrs, applyOnLoseAbAttrs } from "#abilities/apply-ab-attrs"; import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; @@ -6,35 +10,32 @@ import { allMoves } from "#data/data-lists"; import { AbilityId } from "#enums/ability-id"; import { ArenaTagSide } from "#enums/arena-tag-side"; import { ArenaTagType } from "#enums/arena-tag-type"; -import type { BattlerIndex } from "#enums/battler-index"; import { BattlerTagType } from "#enums/battler-tag-type"; import { HitResult } from "#enums/hit-result"; import { CommonAnim } from "#enums/move-anims-common"; import { MoveCategory } from "#enums/move-category"; import { MoveId } from "#enums/move-id"; import { MoveTarget } from "#enums/move-target"; -import { MoveUseMode } from "#enums/move-use-mode"; import { PokemonType } from "#enums/pokemon-type"; import { Stat } from "#enums/stat"; import { StatusEffect } from "#enums/status-effect"; import type { Arena } from "#field/arena"; import type { Pokemon } from "#field/pokemon"; import type { - ArenaDelayedAttackTagType, ArenaScreenTagType, ArenaTagTypeData, ArenaTrapTagType, SerializableArenaTagType, } from "#types/arena-tags"; import type { Mutable } from "#types/type-helpers"; -import { BooleanHolder, isNullOrUndefined, NumberHolder, toDmgValue } from "#utils/common"; +import { BooleanHolder, NumberHolder, toDmgValue } from "#utils/common"; import i18next from "i18next"; /** * @module * ArenaTags are are meant for effects that are tied to the arena (as opposed to a specific pokemon). * Examples include (but are not limited to) - * - Cross-turn effects that persist even if the user/target switches out, such as Wish, Future Sight, and Happy Hour + * - Cross-turn effects that persist even if the user/target switches out, such as Happy Hour * - Effects that are applied to a specific side of the field, such as Crafty Shield, Reflect, and Spikes * - Field-Effects, like Gravity and Trick Room * @@ -624,58 +625,6 @@ export class NoCritTag extends SerializableArenaTag { } } -/** - * Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Wish_(move) | Wish}. - * Heals the Pokémon in the user's position the turn after Wish is used. - */ -class WishTag extends SerializableArenaTag { - // The following fields are meant to be inwardly mutable, but outwardly immutable. - readonly battlerIndex: BattlerIndex; - readonly healHp: number; - readonly sourceName: string; - // End inwardly mutable fields - - public readonly tagType = ArenaTagType.WISH; - - constructor(turnCount: number, sourceId: number | undefined, side: ArenaTagSide) { - super(turnCount, MoveId.WISH, sourceId, side); - } - - onAdd(_arena: Arena): void { - const source = this.getSourcePokemon(); - if (!source) { - console.warn(`Failed to get source Pokemon for WishTag on add message; id: ${this.sourceId}`); - return; - } - - (this as Mutable).sourceName = getPokemonNameWithAffix(source); - (this as Mutable).healHp = toDmgValue(source.getMaxHp() / 2); - (this as Mutable).battlerIndex = source.getBattlerIndex(); - } - - onRemove(_arena: Arena): void { - const target = globalScene.getField()[this.battlerIndex]; - if (target?.isActive(true)) { - globalScene.phaseManager.queueMessage( - // TODO: Rename key as it triggers on activation - i18next.t("arenaTag:wishTagOnAdd", { - pokemonNameWithAffix: this.sourceName, - }), - ); - globalScene.phaseManager.unshiftNew("PokemonHealPhase", target.getBattlerIndex(), this.healHp, null, true, false); - } - } - - public override loadTag( - source: BaseArenaTag & Pick, - ): void { - super.loadTag(source); - (this as Mutable).battlerIndex = source.battlerIndex; - (this as Mutable).healHp = source.healHp; - (this as Mutable).sourceName = source.sourceName; - } -} - /** * Abstract class to implement weakened moves of a specific type. */ @@ -1148,48 +1097,6 @@ class StickyWebTag 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. - */ -export class DelayedAttackTag extends SerializableArenaTag { - public targetIndex: BattlerIndex; - public readonly tagType: ArenaDelayedAttackTagType; - - constructor( - tagType: ArenaTagType.DOOM_DESIRE | ArenaTagType.FUTURE_SIGHT, - sourceMove: MoveId | undefined, - sourceId: number | undefined, - targetIndex: BattlerIndex, - side: ArenaTagSide = ArenaTagSide.BOTH, - ) { - super(3, sourceMove, sourceId, side); - this.tagType = tagType; - this.targetIndex = targetIndex; - this.side = side; - } - - lapse(arena: Arena): boolean { - const ret = super.lapse(arena); - - 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? - } - - return ret; - } - - onRemove(_arena: Arena): void {} -} - /** * Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Trick_Room_(move) Trick Room}. * Reverses the Speed stats for all Pokémon on the field as long as this arena tag is up, @@ -1685,7 +1592,6 @@ export function getArenaTag( turnCount: number, sourceMove: MoveId | undefined, sourceId: number | undefined, - targetIndex?: BattlerIndex, side: ArenaTagSide = ArenaTagSide.BOTH, ): ArenaTag | null { switch (tagType) { @@ -1711,14 +1617,6 @@ 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: - if (isNullOrUndefined(targetIndex)) { - return null; // If missing target index, no tag is created - } - return new DelayedAttackTag(tagType, sourceMove, sourceId, targetIndex, side); - case ArenaTagType.WISH: - return new WishTag(turnCount, sourceId, side); case ArenaTagType.STEALTH_ROCK: return new StealthRockTag(sourceId, side); case ArenaTagType.STICKY_WEB: @@ -1761,16 +1659,9 @@ export function getArenaTag( * @param source - An arena tag * @returns The valid arena tag */ -export function loadArenaTag(source: (ArenaTag | ArenaTagTypeData) & { targetIndex?: BattlerIndex }): ArenaTag { +export function loadArenaTag(source: ArenaTag | ArenaTagTypeData): ArenaTag { const tag = - getArenaTag( - source.tagType, - source.turnCount, - source.sourceMove, - source.sourceId, - source.targetIndex, - source.side, - ) ?? new NoneTag(); + getArenaTag(source.tagType, source.turnCount, source.sourceMove, source.sourceId, source.side) ?? new NoneTag(); tag.loadTag(source); return tag; } @@ -1787,9 +1678,6 @@ export type ArenaTagTypeMap = { [ArenaTagType.CRAFTY_SHIELD]: CraftyShieldTag; [ArenaTagType.NO_CRIT]: NoCritTag; [ArenaTagType.TOXIC_SPIKES]: ToxicSpikesTag; - [ArenaTagType.FUTURE_SIGHT]: DelayedAttackTag; - [ArenaTagType.DOOM_DESIRE]: DelayedAttackTag; - [ArenaTagType.WISH]: WishTag; [ArenaTagType.STEALTH_ROCK]: StealthRockTag; [ArenaTagType.STICKY_WEB]: StickyWebTag; [ArenaTagType.TRICK_ROOM]: TrickRoomTag; diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 8557768bc03..bde5f2977d8 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -25,6 +25,7 @@ import { getBerryEffectFunc } from "#data/berry"; import { applyChallenges } from "#data/challenge"; import { allAbilities, allMoves } from "#data/data-lists"; import { SpeciesFormChangeRevertWeatherFormTrigger } from "#data/form-change-triggers"; +import { DelayedAttackTag } from "#data/positional-tags/positional-tag"; import { getNonVolatileStatusEffects, getStatusEffectHealText, @@ -54,6 +55,7 @@ import { MoveFlags } from "#enums/move-flags"; import { MoveTarget } from "#enums/move-target"; import { MultiHitType } from "#enums/multi-hit-type"; import { PokemonType } from "#enums/pokemon-type"; +import { PositionalTagType } from "#enums/positional-tag-type"; import { SpeciesId } from "#enums/species-id"; import { BATTLE_STATS, @@ -3122,54 +3124,110 @@ 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 other effects of this move. + * @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. + */ + public override apply(_user: Pokemon, _target: Pokemon, _move: Move, _args: [overridden: BooleanHolder, useMode: MoveUseMode]): boolean { return true; } } +/** Abstract class for moves that add {@linkcode PositionalTag}s to the field. */ +abstract class AddPositionalTagAttr extends OverrideMoveEffectAttr { + protected abstract readonly tagType: PositionalTagType; + + public override getCondition(): MoveConditionFunc { + // Check the arena if another similar positional tag is active and affecting the same slot + return (_user, target, move) => globalScene.arena.positionalTagManager.canAddTag(this.tagType, target.getBattlerIndex()) + } +} + /** - * 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 with a {@linkcode DelayedAttackTag}, + * activating against the given slot after the given turn count has 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}}` will be populated with the user's name. + */ + 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) { - return true; + public override apply(user: Pokemon, target: Pokemon, move: Move, args: [overridden: BooleanHolder, useMode: MoveUseMode]): boolean { + const useMode = args[1]; + if (useMode === MoveUseMode.DELAYED_ATTACK) { + // don't trigger if already queueing an indirect attack + return false; } - 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, + { pokemonName: getPokemonNameWithAffix(user) } + ) + ) + user.pushMoveHistory({move: move.id, targets: [target.getBattlerIndex()], result: MoveResult.OTHER, useMode, turn: globalScene.currentBattle.turn}) + user.pushMoveHistory({move: move.id, targets: [target.getBattlerIndex()], result: MoveResult.OTHER, useMode, turn: globalScene.currentBattle.turn}) + // Queue up an attack on the given slot. + globalScene.arena.positionalTagManager.addTag({ + tagType: PositionalTagType.DELAYED_ATTACK, + sourceId: user.id, + targetIndex: target.getBattlerIndex(), + sourceMove: move.id, + turnCount: 3 + }) return true; } + + public override getCondition(): MoveConditionFunc { + // Check the arena if another similar attack is active and affecting the same slot + return (_user, target) => globalScene.arena.positionalTagManager.canAddTag(PositionalTagType.DELAYED_ATTACK, target.getBattlerIndex()) + } +} + +/** + * Attribute to queue a {@linkcode WishTag} to activate in 2 turns. + * The tag whill heal + */ +export class WishAttr extends MoveEffectAttr { + public override apply(user: Pokemon, target: Pokemon, _move: Move): boolean { + globalScene.arena.positionalTagManager.addTag({ + tagType: PositionalTagType.WISH, + healHp: toDmgValue(user.getMaxHp() / 2), + targetIndex: target.getBattlerIndex(), + turnCount: 2, + pokemonName: getPokemonNameWithAffix(user), + }); + return true; + } + + public override getCondition(): MoveConditionFunc { + // Check the arena if another wish is active and affecting the same slot + return (_user, target) => globalScene.arena.positionalTagManager.canAddTag(PositionalTagType.WISH, target.getBattlerIndex()) + } } /** @@ -3187,8 +3245,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 */ @@ -9203,9 +9261,12 @@ 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 + */ + .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) @@ -9291,8 +9352,8 @@ export function initMoves() { .ignoresSubstitute() .attr(AbilityCopyAttr), new SelfStatusMove(MoveId.WISH, PokemonType.NORMAL, -1, 10, -1, 0, 3) - .triageMove() - .attr(AddArenaTagAttr, ArenaTagType.WISH, 2, true), + .attr(WishAttr) + .triageMove(), new SelfStatusMove(MoveId.ASSIST, PokemonType.NORMAL, -1, 20, -1, 0, 3) .attr(RandomMovesetMoveAttr, invalidAssistMoves, true), new SelfStatusMove(MoveId.INGRAIN, PokemonType.GRASS, -1, 20, -1, 0, 3) @@ -9541,9 +9602,12 @@ 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 + */ + .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/data/positional-tags/load-positional-tag.ts b/src/data/positional-tags/load-positional-tag.ts new file mode 100644 index 00000000000..ef3609d93e7 --- /dev/null +++ b/src/data/positional-tags/load-positional-tag.ts @@ -0,0 +1,70 @@ +import { DelayedAttackTag, type PositionalTag, WishTag } from "#data/positional-tags/positional-tag"; +import { PositionalTagType } from "#enums/positional-tag-type"; +import type { ObjectValues } from "#types/type-helpers"; +import type { Constructor } from "#utils/common"; + +/** + * Load the attributes of a {@linkcode PositionalTag}. + * @param tagType - The {@linkcode PositionalTagType} to create + * @param args - The arguments needed to instantize the given tag + * @returns The newly created tag. + * @remarks + * This function does not perform any checking if the added tag is valid. + */ +export function loadPositionalTag({ + tagType, + ...args +}: serializedPosTagMap[T]): posTagInstanceMap[T]; +/** + * Load the attributes of a {@linkcode PositionalTag}. + * @param tag - The {@linkcode SerializedPositionalTag} to instantiate + * @returns The newly created tag. + * @remarks + * This function does not perform any checking if the added tag is valid. + */ +export function loadPositionalTag(tag: SerializedPositionalTag): PositionalTag; +export function loadPositionalTag({ + tagType, + ...rest +}: serializedPosTagMap[T]): posTagInstanceMap[T] { + // Note: We need 2 type assertions here: + // 1 because TS doesn't narrow the type of TagClass correctly based on `T`. + // It converts it into `new (DelayedAttackTag | WishTag) => DelayedAttackTag & WishTag` + const tagClass = posTagConstructorMap[tagType] as new (args: posTagParamMap[T]) => posTagInstanceMap[T]; + // 2 because TS doesn't narrow the type of `rest` correctly + // (from `Omit into `posTagParamMap[T]`) + return new tagClass(rest as unknown as posTagParamMap[T]); +} + +/** Const object mapping tag types to their constructors. */ +const posTagConstructorMap = Object.freeze({ + [PositionalTagType.DELAYED_ATTACK]: DelayedAttackTag, + [PositionalTagType.WISH]: WishTag, +}) satisfies { + // NB: This `satisfies` block ensures that all tag types have corresponding entries in the map. + [k in PositionalTagType]: Constructor; +}; + +/** Type mapping positional tag types to their constructors. */ +type posTagMap = typeof posTagConstructorMap; + +/** Type mapping all positional tag types to their instances. */ +type posTagInstanceMap = { + [k in PositionalTagType]: InstanceType; +}; + +/** Type mapping all positional tag types to their constructors' parameters. */ +type posTagParamMap = { + [k in PositionalTagType]: ConstructorParameters[0]; +}; + +/** + * Type mapping all positional tag types to their constructors' parameters, alongside the `tagType` selector. + * Equivalent to their serialized representations. + */ +export type serializedPosTagMap = { + [k in PositionalTagType]: posTagParamMap[k] & { tagType: k }; +}; + +/** Union type containing all serialized {@linkcode PositionalTag}s. */ +export type SerializedPositionalTag = ObjectValues; diff --git a/src/data/positional-tags/positional-tag-manager.ts b/src/data/positional-tags/positional-tag-manager.ts new file mode 100644 index 00000000000..7bf4d4995c6 --- /dev/null +++ b/src/data/positional-tags/positional-tag-manager.ts @@ -0,0 +1,55 @@ +import { loadPositionalTag } from "#data/positional-tags/load-positional-tag"; +import type { PositionalTag } from "#data/positional-tags/positional-tag"; +import type { BattlerIndex } from "#enums/battler-index"; +import type { PositionalTagType } from "#enums/positional-tag-type"; + +/** A manager for the {@linkcode PositionalTag}s in the arena. */ +export class PositionalTagManager { + /** + * Array containing all pending unactivated {@linkcode PositionalTag}s, + * sorted by order of creation (oldest first). + */ + public tags: PositionalTag[] = []; + + /** + * Add a new {@linkcode PositionalTag} to the arena. + * @remarks + * This function does not perform any checking if the added tag is valid. + */ + public addTag(tag: Parameters>[0]): void { + this.tags.push(loadPositionalTag(tag)); + } + + /** + * Check whether a new {@linkcode PositionalTag} can be added to the battlefield. + * @param tagType - The {@linkcode PositionalTagType} being created + * @param targetIndex - The {@linkcode BattlerIndex} being targeted + * @returns Whether the tag can be added. + */ + public canAddTag(tagType: PositionalTagType, targetIndex: BattlerIndex): boolean { + return !this.tags.some(t => t.tagType === tagType && t.targetIndex === targetIndex); + } + + /** + * Decrement turn counts of and trigger all pending {@linkcode PositionalTag}s on field. + * @remarks + * If multiple tags trigger simultaneously, they will activate in order of **initial creation**, regardless of current speed order. + * (Source: [Smogon]()) + */ + public activateAllTags(): void { + const leftoverTags: PositionalTag[] = []; + for (const tag of this.tags) { + // Check for silent removal, immediately removing invalid tags. + if (--tag.turnCount > 0) { + // tag still cooking + leftoverTags.push(tag); + continue; + } + + if (tag.shouldTrigger()) { + tag.trigger(); + } + } + this.tags = leftoverTags; + } +} diff --git a/src/data/positional-tags/positional-tag.ts b/src/data/positional-tags/positional-tag.ts new file mode 100644 index 00000000000..77ca6f0e9eb --- /dev/null +++ b/src/data/positional-tags/positional-tag.ts @@ -0,0 +1,174 @@ +import { globalScene } from "#app/global-scene"; +import { getPokemonNameWithAffix } from "#app/messages"; +// biome-ignore-start lint/correctness/noUnusedImports: TSDoc +import type { ArenaTag } from "#data/arena-tag"; +// biome-ignore-end lint/correctness/noUnusedImports: TSDoc +import { allMoves } from "#data/data-lists"; +import type { BattlerIndex } from "#enums/battler-index"; +import type { MoveId } from "#enums/move-id"; +import { MoveUseMode } from "#enums/move-use-mode"; +import { PositionalTagType } from "#enums/positional-tag-type"; +import type { Pokemon } from "#field/pokemon"; +import i18next from "i18next"; + +/** + * Baseline arguments used to construct all {@linkcode PositionalTag}s, + * the contents of which are serialized and used to construct new tags. \ + * Does not contain the `tagType` parameter (which is used to select the proper class constructor during tag loading). + * @privateRemarks + * All {@linkcode PositionalTag}s are intended to implement a sub-interface of this containing their respective parameters, + * and should refrain from adding extra serializable fields not contained in said interface. + * This ensures that all tags truly "become" their respective interfaces when converted to and from JSON. + */ +export interface PositionalTagBaseArgs { + /** + * The number of turns remaining until this tag's activation. \ + * Decremented by 1 at the end of each turn until reaching 0, at which point it will + * {@linkcode PositionalTag.trigger | trigger} the tag's effects and be removed. + */ + turnCount: number; + /** + * The {@linkcode BattlerIndex} targeted by this effect. + */ + targetIndex: BattlerIndex; +} + +/** + * A {@linkcode PositionalTag} is a variant of an {@linkcode ArenaTag} that targets a single *slot* of the battlefield. + * Each tag can last one or more turns, triggering various effects on removal. + * Multiple tags of the same kind can stack with one another, provided they are affecting different targets. + */ +export abstract class PositionalTag implements PositionalTagBaseArgs { + /** This tag's {@linkcode PositionalTagType | type} */ + public abstract readonly tagType: PositionalTagType; + // These arguments have to be public to implement the interface, but are functionally private + // outside this and the tag manager. + // Left undocumented to inherit doc comments from the interface + public turnCount: number; + public readonly targetIndex: BattlerIndex; + + constructor({ turnCount, targetIndex }: PositionalTagBaseArgs) { + this.turnCount = turnCount; + this.targetIndex = targetIndex; + } + + /** Trigger this tag's effects prior to removal. */ + public abstract trigger(): void; + + /** + * Check whether this tag should be allowed to {@linkcode trigger} and activate its effects + * upon its duration elapsing. + * @returns Whether this tag should be allowed to trigger prior to being removed. + */ + public abstract shouldTrigger(): boolean; + + /** + * Get the {@linkcode Pokemon} currently targeted by this tag. + * @returns The {@linkcode Pokemon} located in this tag's target position, or `undefined` if none exist in it. + */ + protected getTarget(): Pokemon | undefined { + return globalScene.getField()[this.targetIndex]; + } +} + +/** Interface containing additional properties used to construct a {@linkcode DelayedAttackTag}. */ +interface DelayedAttackArgs extends PositionalTagBaseArgs { + /** + * The {@linkcode Pokemon.id | PID} of the {@linkcode Pokemon} having created this effect. + */ + sourceId: number; + /** The {@linkcode MoveId} that created this attack. */ + sourceMove: MoveId; +} + +/** + * 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 several turns after use (including the turn the move is used), + * triggering against a certain slot after the turn count has elapsed. + */ +export class DelayedAttackTag extends PositionalTag implements DelayedAttackArgs { + public override readonly tagType = PositionalTagType.DELAYED_ATTACK; + public readonly sourceMove: MoveId; + public readonly sourceId: number; + + constructor({ sourceId, turnCount, targetIndex, sourceMove }: DelayedAttackArgs) { + super({ turnCount, targetIndex }); + this.sourceId = sourceId; + this.sourceMove = sourceMove; + } + + public override trigger(): void { + // Bangs are justified as the `shouldTrigger` method will queue the tag for removal + // if the source or target no longer exist + const source = globalScene.getPokemonById(this.sourceId)!; + const target = this.getTarget()!; + + source.turnData.extraTurns++; + globalScene.phaseManager.queueMessage( + i18next.t("moveTriggers:tookMoveAttack", { + pokemonName: getPokemonNameWithAffix(target), + moveName: allMoves[this.sourceMove].name, + }), + ); + + globalScene.phaseManager.unshiftNew( + "MoveEffectPhase", + this.sourceId, // TODO: Find an alternate method of passing the source pokemon without a source ID + [this.targetIndex], + allMoves[this.sourceMove], + MoveUseMode.DELAYED_ATTACK, + ); + } + + public override shouldTrigger(): boolean { + const source = globalScene.getPokemonById(this.sourceId); + const target = this.getTarget(); + // Silently disappear if either source or target are missing or happen to be the same pokemon + // (i.e. targeting oneself) + // We also need to check for fainted targets as they don't technically leave the field until _after_ the turn ends + return !!source && !!target && source !== target && !target.isFainted(); + } +} + +/** Interface containing arguments used to construct a {@linkcode WishTag}. */ +interface WishArgs extends PositionalTagBaseArgs { + /** The amount of {@linkcode Stat.HP | HP} to heal; set to 50% of the user's max HP during move usage. */ + healHp: number; + /** The name of the {@linkcode Pokemon} having created the tag. */ + pokemonName: string; +} + +/** + * Tag to implement {@linkcode MoveId.WISH | Wish}. + */ +export class WishTag extends PositionalTag implements WishArgs { + public override readonly tagType = PositionalTagType.WISH; + + public readonly pokemonName: string; + public readonly healHp: number; + + constructor({ turnCount, targetIndex, healHp, pokemonName }: WishArgs) { + super({ turnCount, targetIndex }); + this.healHp = healHp; + this.pokemonName = pokemonName; + } + + public override trigger(): void { + // TODO: Rename this locales key - wish shows a message on REMOVAL, not addition + globalScene.phaseManager.queueMessage( + i18next.t("arenaTag:wishTagOnAdd", { + pokemonNameWithAffix: this.pokemonName, + }), + ); + + globalScene.phaseManager.unshiftNew("PokemonHealPhase", this.targetIndex, this.healHp, null, true, false); + } + + public override shouldTrigger(): boolean { + // Disappear if no target or target is fainted. + // The source need not exist at the time of activation (since all we need is a simple message) + // TODO: Verify whether Wish shows a message if the Pokemon it would affect is KO'd on the turn of activation + const target = this.getTarget(); + return !!target && !target.isFainted(); + } +} diff --git a/src/enums/arena-tag-type.ts b/src/enums/arena-tag-type.ts index 214826993b3..7f9364395c0 100644 --- a/src/enums/arena-tag-type.ts +++ b/src/enums/arena-tag-type.ts @@ -15,9 +15,6 @@ export enum ArenaTagType { SPIKES = "SPIKES", TOXIC_SPIKES = "TOXIC_SPIKES", MIST = "MIST", - FUTURE_SIGHT = "FUTURE_SIGHT", - DOOM_DESIRE = "DOOM_DESIRE", - WISH = "WISH", STEALTH_ROCK = "STEALTH_ROCK", STICKY_WEB = "STICKY_WEB", TRICK_ROOM = "TRICK_ROOM", diff --git a/src/enums/move-use-mode.ts b/src/enums/move-use-mode.ts index 1bb9d6374b7..13ea5248853 100644 --- a/src/enums/move-use-mode.ts +++ b/src/enums/move-use-mode.ts @@ -1,5 +1,7 @@ import type { PostDancingMoveAbAttr } from "#abilities/ability"; +import type { DelayedAttackAttr } from "#app/@types/move-types"; import type { BattlerTagLapseType } from "#enums/battler-tag-lapse-type"; +import type { ObjectValues } from "#types/type-helpers"; /** * Enum representing all the possible means through which a given move can be executed. @@ -59,11 +61,20 @@ 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**. + * @todo Consider other means of implementing FS/DD than this - we currently only use it + * to prevent pushing to move history and avoid re-delaying the attack portion + */ + DELAYED_ATTACK: 6 } as const; -export type MoveUseMode = (typeof MoveUseMode)[keyof typeof MoveUseMode]; +export type MoveUseMode = ObjectValues; // # HELPER FUNCTIONS // Please update the markdown tables if any new `MoveUseMode`s get added. @@ -75,13 +86,14 @@ export type MoveUseMode = (typeof MoveUseMode)[keyof typeof MoveUseMode]; * @remarks * This function is equivalent to the following truth table: * - * | Use Type | Returns | - * |------------------------------------|---------| - * | {@linkcode MoveUseMode.NORMAL} | `false` | - * | {@linkcode MoveUseMode.IGNORE_PP} | `false` | - * | {@linkcode MoveUseMode.INDIRECT} | `true` | - * | {@linkcode MoveUseMode.FOLLOW_UP} | `true` | - * | {@linkcode MoveUseMode.REFLECTED} | `true` | + * | Use Type | Returns | + * |----------------------------------------|---------| + * | {@linkcode MoveUseMode.NORMAL} | `false` | + * | {@linkcode MoveUseMode.IGNORE_PP} | `false` | + * | {@linkcode MoveUseMode.INDIRECT} | `true` | + * | {@linkcode MoveUseMode.FOLLOW_UP} | `true` | + * | {@linkcode MoveUseMode.REFLECTED} | `true` | + * | {@linkcode MoveUseMode.DELAYED_ATTACK} | `true` | */ export function isVirtual(useMode: MoveUseMode): boolean { return useMode >= MoveUseMode.INDIRECT @@ -95,13 +107,14 @@ export function isVirtual(useMode: MoveUseMode): boolean { * @remarks * This function is equivalent to the following truth table: * - * | Use Type | Returns | - * |------------------------------------|---------| - * | {@linkcode MoveUseMode.NORMAL} | `false` | - * | {@linkcode MoveUseMode.IGNORE_PP} | `false` | - * | {@linkcode MoveUseMode.INDIRECT} | `false` | - * | {@linkcode MoveUseMode.FOLLOW_UP} | `true` | - * | {@linkcode MoveUseMode.REFLECTED} | `true` | + * | Use Type | Returns | + * |----------------------------------------|---------| + * | {@linkcode MoveUseMode.NORMAL} | `false` | + * | {@linkcode MoveUseMode.IGNORE_PP} | `false` | + * | {@linkcode MoveUseMode.INDIRECT} | `false` | + * | {@linkcode MoveUseMode.FOLLOW_UP} | `true` | + * | {@linkcode MoveUseMode.REFLECTED} | `true` | + * | {@linkcode MoveUseMode.DELAYED_ATTACK} | `true` | */ export function isIgnoreStatus(useMode: MoveUseMode): boolean { return useMode >= MoveUseMode.FOLLOW_UP; @@ -115,13 +128,14 @@ export function isIgnoreStatus(useMode: MoveUseMode): boolean { * @remarks * This function is equivalent to the following truth table: * - * | Use Type | Returns | - * |------------------------------------|---------| - * | {@linkcode MoveUseMode.NORMAL} | `false` | - * | {@linkcode MoveUseMode.IGNORE_PP} | `true` | - * | {@linkcode MoveUseMode.INDIRECT} | `true` | - * | {@linkcode MoveUseMode.FOLLOW_UP} | `true` | - * | {@linkcode MoveUseMode.REFLECTED} | `true` | + * | Use Type | Returns | + * |----------------------------------------|---------| + * | {@linkcode MoveUseMode.NORMAL} | `false` | + * | {@linkcode MoveUseMode.IGNORE_PP} | `true` | + * | {@linkcode MoveUseMode.INDIRECT} | `true` | + * | {@linkcode MoveUseMode.FOLLOW_UP} | `true` | + * | {@linkcode MoveUseMode.REFLECTED} | `true` | + * | {@linkcode MoveUseMode.DELAYED_ATTACK} | `true` | */ export function isIgnorePP(useMode: MoveUseMode): boolean { return useMode >= MoveUseMode.IGNORE_PP; @@ -136,14 +150,15 @@ export function isIgnorePP(useMode: MoveUseMode): boolean { * @remarks * This function is equivalent to the following truth table: * - * | Use Type | Returns | - * |------------------------------------|---------| - * | {@linkcode MoveUseMode.NORMAL} | `false` | - * | {@linkcode MoveUseMode.IGNORE_PP} | `false` | - * | {@linkcode MoveUseMode.INDIRECT} | `false` | - * | {@linkcode MoveUseMode.FOLLOW_UP} | `false` | - * | {@linkcode MoveUseMode.REFLECTED} | `true` | + * | Use Type | Returns | + * |----------------------------------------|---------| + * | {@linkcode MoveUseMode.NORMAL} | `false` | + * | {@linkcode MoveUseMode.IGNORE_PP} | `false` | + * | {@linkcode MoveUseMode.INDIRECT} | `false` | + * | {@linkcode MoveUseMode.FOLLOW_UP} | `false` | + * | {@linkcode MoveUseMode.REFLECTED} | `true` | + * | {@linkcode MoveUseMode.DELAYED_ATTACK} | `false` | */ export function isReflected(useMode: MoveUseMode): boolean { return useMode === MoveUseMode.REFLECTED; -} +} \ No newline at end of file diff --git a/src/enums/positional-tag-type.ts b/src/enums/positional-tag-type.ts new file mode 100644 index 00000000000..254503d0de6 --- /dev/null +++ b/src/enums/positional-tag-type.ts @@ -0,0 +1,10 @@ +/** + * Enum representing all positional tag types. + * @privateRemarks + * When adding new tag types, please update `positionalTagConstructorMap` in `src/data/positionalTags` + * with the new tag type. + */ +export enum PositionalTagType { + DELAYED_ATTACK = "DELAYED_ATTACK", + WISH = "WISH", +} diff --git a/src/field/arena.ts b/src/field/arena.ts index 8f27ddb22e9..484450cc5df 100644 --- a/src/field/arena.ts +++ b/src/field/arena.ts @@ -1,3 +1,7 @@ +// biome-ignore-start lint/correctness/noUnusedImports: TSDoc imports +import type { PositionalTag } from "#data/positional-tags/positional-tag"; +// biome-ignore-end lint/correctness/noUnusedImports: TSDoc imports + import { applyAbAttrs } from "#abilities/apply-ab-attrs"; import { globalScene } from "#app/global-scene"; import Overrides from "#app/overrides"; @@ -7,6 +11,7 @@ import type { ArenaTag } from "#data/arena-tag"; import { ArenaTrapTag, getArenaTag } from "#data/arena-tag"; import { SpeciesFormChangeRevertWeatherFormTrigger, SpeciesFormChangeWeatherTrigger } from "#data/form-change-triggers"; import type { PokemonSpecies } from "#data/pokemon-species"; +import { PositionalTagManager } from "#data/positional-tags/positional-tag-manager"; import { getTerrainClearMessage, getTerrainStartMessage, Terrain, TerrainType } from "#data/terrain"; import { getLegendaryWeatherContinuesMessage, @@ -38,7 +43,14 @@ export class Arena { public biomeType: BiomeId; public weather: Weather | null; public terrain: Terrain | null; - public tags: ArenaTag[]; + /** All currently-active {@linkcode ArenaTag}s on both sides of the field. */ + public tags: ArenaTag[] = []; + /** + * All currently-active {@linkcode PositionalTag}s on both sides of the field, + * sorted by tag type. + */ + public positionalTagManager: PositionalTagManager = new PositionalTagManager(); + public bgm: string; public ignoreAbilities: boolean; public ignoringEffectSource: BattlerIndex | null; @@ -58,7 +70,6 @@ export class Arena { constructor(biome: BiomeId, bgm: string, playerFaints = 0) { this.biomeType = biome; - this.tags = []; this.bgm = bgm; this.trainerPool = biomeTrainerPools[biome]; this.updatePoolsForTimeOfDay(); @@ -676,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, @@ -693,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) { @@ -708,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); @@ -724,10 +734,19 @@ 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 constructor of the {@linkcode ArenaTag} to retrieve + * @returns The existing {@linkcode ArenaTag}, or `undefined` if not present. + * @overload + */ + getTag(tagType: Constructor | AbstractConstructor): T | undefined; getTag(tagType: ArenaTagType | Constructor | AbstractConstructor): ArenaTag | undefined { return this.getTagOnSide(tagType, ArenaTagSide.BOTH); } diff --git a/src/phase-manager.ts b/src/phase-manager.ts index c56afe446ed..37edeae7e42 100644 --- a/src/phase-manager.ts +++ b/src/phase-manager.ts @@ -61,6 +61,7 @@ import { PartyHealPhase } from "#phases/party-heal-phase"; import { PokemonAnimPhase } from "#phases/pokemon-anim-phase"; import { PokemonHealPhase } from "#phases/pokemon-heal-phase"; import { PokemonTransformPhase } from "#phases/pokemon-transform-phase"; +import { PositionalTagPhase } from "#phases/positional-tag-phase"; import { PostGameOverPhase } from "#phases/post-game-over-phase"; import { PostSummonPhase } from "#phases/post-summon-phase"; import { PostTurnStatusEffectPhase } from "#phases/post-turn-status-effect-phase"; @@ -172,6 +173,7 @@ const PHASES = Object.freeze({ PokemonAnimPhase, PokemonHealPhase, PokemonTransformPhase, + PositionalTagPhase, PostGameOverPhase, PostSummonPhase, PostTurnStatusEffectPhase, diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index b1cb9e48d51..c57e0f6cead 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -19,7 +19,7 @@ import { MoveFlags } from "#enums/move-flags"; import { MoveId } from "#enums/move-id"; import { MoveResult } from "#enums/move-result"; import { MoveTarget } from "#enums/move-target"; -import { isReflected, isVirtual, MoveUseMode } from "#enums/move-use-mode"; +import { isReflected, MoveUseMode } from "#enums/move-use-mode"; import { PokemonType } from "#enums/pokemon-type"; import type { Pokemon } from "#field/pokemon"; import { @@ -244,43 +244,19 @@ export class MoveEffectPhase extends PokemonPhase { globalScene.currentBattle.lastPlayerInvolved = this.fieldIndex; } - const isDelayedAttack = this.move.hasAttr("DelayedAttackAttr"); - /** If the user was somehow removed from the field and it's not a delayed attack, end this phase */ - if (!user.isOnField()) { - if (!isDelayedAttack) { - super.end(); - return; - } - if (!user.scene) { - /* - * This happens if the Pokemon that used the delayed attack gets caught and released - * on the turn the attack would have triggered. Having access to the global scene - * in the future may solve this entirely, so for now we just cancel the hit - */ - super.end(); - return; - } - } + 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) // only used for Future Sight, calling and Pledge moves. // TODO: change if any other move effect overrides are introduced - applyMoveAttrs( - "OverrideMoveEffectAttr", - user, - this.getFirstTarget() ?? null, - move, - overridden, - isVirtual(this.useMode), - ); + applyMoveAttrs("OverrideMoveEffectAttr", user, this.getFirstTarget() ?? null, move, overridden, this.useMode); // If other effects were overriden, stop this phase before they can be applied if (overridden.value) { @@ -355,7 +331,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.DELAYED_ATTACK) { user.pushMoveHistory(this.moveHistoryEntry); applyAbAttrs("ExecutedMoveAbAttr", { pokemon: user }); } @@ -663,6 +639,7 @@ export class MoveEffectPhase extends PokemonPhase { /** @returns The {@linkcode Pokemon} using this phase's invoked move */ public getUserPokemon(): Pokemon | null { + // TODO: Make this purely a battler index if (this.battlerIndex > BattlerIndex.ENEMY_2) { return globalScene.getPokemonById(this.battlerIndex); } diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 82bb6b153ef..cd7c7a8f48f 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -2,14 +2,12 @@ import { applyAbAttrs } from "#abilities/apply-ab-attrs"; import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; import Overrides from "#app/overrides"; -import type { DelayedAttackTag } from "#data/arena-tag"; import { CenterOfAttentionTag } from "#data/battler-tags"; import { SpeciesFormChangePreMoveTrigger } from "#data/form-change-triggers"; import { getStatusEffectActivationText, getStatusEffectHealText } from "#data/status-effect"; import { getTerrainBlockMessage } from "#data/terrain"; import { getWeatherBlockMessage } from "#data/weather"; import { AbilityId } from "#enums/ability-id"; -import { ArenaTagType } from "#enums/arena-tag-type"; import { BattlerIndex } from "#enums/battler-index"; import { BattlerTagLapseType } from "#enums/battler-tag-lapse-type"; import { BattlerTagType } from "#enums/battler-tag-type"; @@ -297,21 +295,6 @@ export class MovePhase extends BattlePhase { // form changes happen even before we know that the move wll execute. globalScene.triggerPokemonFormChange(this.pokemon, SpeciesFormChangePreMoveTrigger); - // Check the player side arena if another delayed attack is active and hitting the same slot. - if (move.hasAttr("DelayedAttackAttr")) { - const currentTargetIndex = targets[0].getBattlerIndex(); - const delayedAttackHittingSameSlot = globalScene.arena.tags.some( - tag => - (tag.tagType === ArenaTagType.FUTURE_SIGHT || tag.tagType === ArenaTagType.DOOM_DESIRE) && - (tag as DelayedAttackTag).targetIndex === currentTargetIndex, - ); - - if (delayedAttackHittingSameSlot) { - this.failMove(true); - return; - } - } - // Check if the move has any attributes that can interrupt its own use **before** displaying text. // TODO: This should not rely on direct return values let failed = move.getAttrs("PreUseInterruptAttr").some(attr => attr.apply(this.pokemon, targets[0], move)); diff --git a/src/phases/positional-tag-phase.ts b/src/phases/positional-tag-phase.ts new file mode 100644 index 00000000000..dec572273c5 --- /dev/null +++ b/src/phases/positional-tag-phase.ts @@ -0,0 +1,21 @@ +// biome-ignore-start lint/correctness/noUnusedImports: TSDocs +import type { PositionalTag } from "#data/positional-tags/positional-tag"; +import type { TurnEndPhase } from "#phases/turn-end-phase"; +// biome-ignore-end lint/correctness/noUnusedImports: TSDocs + +import { globalScene } from "#app/global-scene"; +import { Phase } from "#app/phase"; + +/** + * Phase to trigger all pending post-turn {@linkcode PositionalTag}s. + * Occurs before {@linkcode TurnEndPhase} to allow for proper electrify timing. + */ +export class PositionalTagPhase extends Phase { + public readonly phaseName = "PositionalTagPhase"; + + public override start(): void { + globalScene.arena.positionalTagManager.activateAllTags(); + super.end(); + return; + } +} diff --git a/src/phases/turn-start-phase.ts b/src/phases/turn-start-phase.ts index 9fdac65bb10..9c53a333ed0 100644 --- a/src/phases/turn-start-phase.ts +++ b/src/phases/turn-start-phase.ts @@ -220,12 +220,16 @@ export class TurnStartPhase extends FieldPhase { } phaseManager.pushNew("CheckInterludePhase"); + // TODO: Re-order these phases to be consistent with mainline turn order: + // https://www.smogon.com/forums/threads/sword-shield-battle-mechanics-research.3655528/page-64#post-9244179 + phaseManager.pushNew("WeatherEffectPhase"); phaseManager.pushNew("BerryPhase"); /** Add a new phase to check who should be taking status damage */ phaseManager.pushNew("CheckStatusEffectPhase", moveOrder); + phaseManager.pushNew("PositionalTagPhase"); phaseManager.pushNew("TurnEndPhase"); /* diff --git a/src/system/arena-data.ts b/src/system/arena-data.ts index c0ad4a25024..b2a04f96a55 100644 --- a/src/system/arena-data.ts +++ b/src/system/arena-data.ts @@ -1,5 +1,6 @@ import type { ArenaTag } from "#data/arena-tag"; import { loadArenaTag, SerializableArenaTag } from "#data/arena-tag"; +import type { SerializedPositionalTag } from "#data/positional-tags/load-positional-tag"; import { Terrain } from "#data/terrain"; import { Weather } from "#data/weather"; import type { BiomeId } from "#enums/biome-id"; @@ -12,6 +13,7 @@ export interface SerializedArenaData { weather: NonFunctionProperties | null; terrain: NonFunctionProperties | null; tags?: ArenaTagTypeData[]; + positionalTags: SerializedPositionalTag[]; playerTerasUsed?: number; } @@ -20,6 +22,7 @@ export class ArenaData { public weather: Weather | null; public terrain: Terrain | null; public tags: ArenaTag[]; + public positionalTags: SerializedPositionalTag[] = []; public playerTerasUsed: number; constructor(source: Arena | SerializedArenaData) { @@ -37,11 +40,15 @@ export class ArenaData { this.biome = source.biomeType; this.weather = source.weather; this.terrain = source.terrain; + // The assertion here is ok - we ensure that all tags are inside the `posTagConstructorMap` map, + // and that all `PositionalTags` will become their respective interfaces when serialized and de-serialized. + this.positionalTags = (source.positionalTagManager.tags as unknown as SerializedPositionalTag[]) ?? []; return; } this.biome = source.biome; this.weather = source.weather ? new Weather(source.weather.weatherType, source.weather.turnsLeft) : null; this.terrain = source.terrain ? new Terrain(source.terrain.terrainType, source.terrain.turnsLeft) : null; + this.positionalTags = source.positionalTags ?? []; } } diff --git a/src/system/game-data.ts b/src/system/game-data.ts index b148ed4e2ba..d899afa19ef 100644 --- a/src/system/game-data.ts +++ b/src/system/game-data.ts @@ -16,6 +16,7 @@ import { allMoves, allSpecies } from "#data/data-lists"; import type { Egg } from "#data/egg"; import { pokemonFormChanges } from "#data/pokemon-forms"; import type { PokemonSpecies } from "#data/pokemon-species"; +import { loadPositionalTag } from "#data/positional-tags/load-positional-tag"; import { TerrainType } from "#data/terrain"; import { AbilityAttr } from "#enums/ability-attr"; import { BattleType } from "#enums/battle-type"; @@ -1096,6 +1097,10 @@ export class GameData { } } + globalScene.arena.positionalTagManager.tags = sessionData.arena.positionalTags.map(tag => + loadPositionalTag(tag), + ); + if (globalScene.modifiers.length) { console.warn("Existing modifiers not cleared on session load, deleting..."); globalScene.modifiers = []; diff --git a/test/moves/delayed-attack.test.ts b/test/moves/delayed-attack.test.ts new file mode 100644 index 00000000000..e8cf2871626 --- /dev/null +++ b/test/moves/delayed-attack.test.ts @@ -0,0 +1,389 @@ +import { getPokemonNameWithAffix } from "#app/messages"; +import { AttackTypeBoosterModifier } from "#app/modifier/modifier"; +import { allMoves } from "#data/data-lists"; +import { AbilityId } from "#enums/ability-id"; +import { BattleType } from "#enums/battle-type"; +import { BattlerIndex } from "#enums/battler-index"; +import { MoveId } from "#enums/move-id"; +import { MoveResult } from "#enums/move-result"; +import { PokemonType } from "#enums/pokemon-type"; +import { PositionalTagType } from "#enums/positional-tag-type"; +import { SpeciesId } from "#enums/species-id"; +import { Stat } from "#enums/stat"; +import { GameManager } from "#test/test-utils/game-manager"; +import i18next from "i18next"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +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. + * @param toEndOfTurn - Whether to advance to the `TurnEndPhase` (`true`) or the `PositionalTagPhase` (`false`); + * default `true` + * @returns A Promise that resolves once the specified number of turns has elapsed + * and the specified phase has been reached. + */ + async function passTurns(numTurns: number, toEndOfTurn = true): Promise { + for (let i = 0; i < numTurns; i++) { + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER); + if (game.scene.getPlayerField()[1]?.isActive()) { + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2); + } + await game.move.forceEnemyMove(MoveId.SPLASH); + if (game.scene.getEnemyField()[1]?.isActive()) { + await game.move.forceEnemyMove(MoveId.SPLASH); + } + await game.phaseInterceptor.to("PositionalTagPhase"); + } + if (toEndOfTurn) { + await game.toEndOfTurn(); + } + } + + /** + * 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 delayedAttacks = game.scene.arena.positionalTagManager["tags"].filter( + t => t.tagType === PositionalTagType.DELAYED_ATTACK, + ); + expect(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(); + + await passTurns(1); + + expectFutureSightActive(0); + 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 called by other moves", async () => { + await game.classicMode.startBattle([SpeciesId.BRONZONG]); + + game.move.use(MoveId.METRONOME); + game.move.forceMetronomeMove(MoveId.FUTURE_SIGHT); + await game.toNextTurn(); + + expectFutureSightActive(); + const enemy = game.field.getEnemyPokemon(); + expect(enemy.hp).toBe(enemy.getMaxHp()); + + await passTurns(2); + + expectFutureSightActive(0); + 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 trigger multiple pending attacks in order of creation, even if that order changes later on", async () => { + game.override.battleStyle("double"); + await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS]); + + const [alomomola, blissey] = game.scene.getField(); + + const oldOrder = game.field.getSpeedOrder(); + + game.move.use(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER, BattlerIndex.ENEMY); + game.move.use(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2); + await game.move.forceEnemyMove(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER); + await game.move.forceEnemyMove(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER_2); + // Ensure that the moves are used deterministically in speed order (for speed ties) + await game.setTurnOrder(oldOrder.map(p => p.getBattlerIndex())); + await game.toNextTurn(); + + expectFutureSightActive(4); + + // Lower speed to change turn order + alomomola.setStatStage(Stat.SPD, 6); + blissey.setStatStage(Stat.SPD, -6); + + const newOrder = game.field.getSpeedOrder(); + expect(newOrder).not.toEqual(oldOrder); + + await passTurns(2, false); + + // All attacks have concluded at this point, unshifting new `MoveEffectPhase`s to the queue. + expectFutureSightActive(0); + + const MEPs = game.scene.phaseManager.phaseQueue.filter(p => p.is("MoveEffectPhase")); + expect(MEPs).toHaveLength(4); + expect(MEPs.map(mep => mep.getPokemon())).toEqual(oldOrder); + }); + + 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.MILOTIC]); + + const [karp, feebas, milotic] = game.scene.getPlayerParty(); + + game.move.use(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER, BattlerIndex.PLAYER_2); + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2); + await game.toNextTurn(); + + expectFutureSightActive(1); + + // Milotic / Feebas // Karp + game.doSwitchPokemon(2); + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2); + await game.toNextTurn(); + + expect(game.scene.getPlayerParty()).toEqual([milotic, feebas, karp]); + + // Milotic / Karp // Feebas + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER); + game.doSwitchPokemon(2); + + await passTurns(1); + + expect(game.scene.getPlayerParty()).toEqual([milotic, karp, feebas]); + + 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 move is used", 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(); + + const attack = game.scene.arena.positionalTagManager.tags.find( + t => t.tagType === PositionalTagType.DELAYED_ATTACK, + )!; + expect(attack).toBeDefined(); + expect(attack.targetIndex).toBe(enemy1.getBattlerIndex()); + + 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 slot is vacant 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); + + game.move.use(MoveId.SPLASH); + await game.killPokemon(enemy2); + await game.toNextTurn(); + + game.move.use(MoveId.SPLASH); + await game.toNextTurn(); + + expectFutureSightActive(0); + 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, + }), + ); + }); + + it("should consider type changes at moment of execution while ignoring redirection", async () => { + game.override.battleStyle("double"); + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); + + // fake left enemy having lightning rod + const [enemy1, enemy2] = game.scene.getEnemyField(); + game.field.mockAbility(enemy1, AbilityId.LIGHTNING_ROD); + + game.move.use(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER, BattlerIndex.ENEMY_2); + await game.toNextTurn(); + + expectFutureSightActive(1); + + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER); + await game.toNextTurn(); + + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER); + await game.move.forceEnemyMove(MoveId.ELECTRIFY, BattlerIndex.PLAYER); + await game.phaseInterceptor.to("PositionalTagPhase"); + 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.toEndOfTurn(); + + 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: this is not implemented + it.todo("should not apply Shell Bell recovery, even if user is on field"); + + // 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 typeMock = vi.spyOn(game.field.getPlayerPokemon(), "getMoveType"); + const powerMock = vi.spyOn(allMoves[MoveId.DOOM_DESIRE], "calculateBattlePower"); + + await game.toNextTurn(); + + // Player Normalize was not applied due to being off field + 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[MoveId.DOOM_DESIRE].name, + }), + ); + expect(typeMock).toHaveLastReturnedWith(PokemonType.STEEL); + expect(powerMock).toHaveLastReturnedWith(150); + }); + + 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.PSYCHIC }]); + await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.MILOTIC]); + + game.move.use(MoveId.FUTURE_SIGHT); + await game.toNextTurn(); + + expectFutureSightActive(); + + await passTurns(1); + + game.doSwitchPokemon(1); + + const powerMock = vi.spyOn(allMoves[MoveId.FUTURE_SIGHT], "calculateBattlePower"); + const typeBoostSpy = vi.spyOn(AttackTypeBoosterModifier.prototype, "apply"); + + await game.toNextTurn(); + + expect(powerMock).toHaveLastReturnedWith(120); + expect(typeBoostSpy).not.toHaveBeenCalled(); + }); + + // TODO: Implement and move to a power spot's test file + it.todo("Should activate ally's power spot when switched in during single battles"); +}); diff --git a/test/moves/future-sight.test.ts b/test/moves/future-sight.test.ts deleted file mode 100644 index 53e93412570..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/test-utils/game-manager"; -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); - }); -}); diff --git a/test/moves/heal-block.test.ts b/test/moves/heal-block.test.ts index 4e4a9355467..fc814fda4bc 100644 --- a/test/moves/heal-block.test.ts +++ b/test/moves/heal-block.test.ts @@ -1,9 +1,8 @@ import { AbilityId } from "#enums/ability-id"; -import { ArenaTagSide } from "#enums/arena-tag-side"; -import { ArenaTagType } from "#enums/arena-tag-type"; import { BattlerIndex } from "#enums/battler-index"; import { BattlerTagType } from "#enums/battler-tag-type"; import { MoveId } from "#enums/move-id"; +import { PositionalTagType } from "#enums/positional-tag-type"; import { SpeciesId } from "#enums/species-id"; import { WeatherType } from "#enums/weather-type"; import { GameManager } from "#test/test-utils/game-manager"; @@ -68,22 +67,25 @@ describe("Moves - Heal Block", () => { expect(enemy.isFullHp()).toBe(false); }); - it("should stop delayed heals, such as from Wish", async () => { + it("should prevent Wish from restoring HP", async () => { await game.classicMode.startBattle([SpeciesId.CHARIZARD]); - const player = game.scene.getPlayerPokemon()!; + const player = game.field.getPlayerPokemon()!; - player.damageAndUpdate(player.getMaxHp() - 1); + player.hp = 1; - game.move.select(MoveId.WISH); - await game.phaseInterceptor.to("TurnEndPhase"); + game.move.use(MoveId.WISH); + await game.toNextTurn(); - expect(game.scene.arena.getTagOnSide(ArenaTagType.WISH, ArenaTagSide.PLAYER)).toBeDefined(); - while (game.scene.arena.getTagOnSide(ArenaTagType.WISH, ArenaTagSide.PLAYER)) { - game.move.select(MoveId.SPLASH); - await game.phaseInterceptor.to("TurnEndPhase"); - } + expect(game.scene.arena.positionalTagManager.tags.filter(t => t.tagType === PositionalTagType.WISH)) // + .toHaveLength(1); + game.move.use(MoveId.SPLASH); + await game.toNextTurn(); + + // wish triggered, but did NOT heal the player + expect(game.scene.arena.positionalTagManager.tags.filter(t => t.tagType === PositionalTagType.WISH)) // + .toHaveLength(0); expect(player.hp).toBe(1); }); diff --git a/test/moves/wish.test.ts b/test/moves/wish.test.ts new file mode 100644 index 00000000000..147c598106b --- /dev/null +++ b/test/moves/wish.test.ts @@ -0,0 +1,183 @@ +import { getPokemonNameWithAffix } from "#app/messages"; +import { AbilityId } from "#enums/ability-id"; +import { BattlerIndex } from "#enums/battler-index"; +import { MoveId } from "#enums/move-id"; +import { MoveResult } from "#enums/move-result"; +import { PositionalTagType } from "#enums/positional-tag-type"; +import { SpeciesId } from "#enums/species-id"; +import { Stat } from "#enums/stat"; +import { GameManager } from "#test/test-utils/game-manager"; +import { toDmgValue } from "#utils/common"; +import i18next from "i18next"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("Move - Wish", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .ability(AbilityId.BALL_FETCH) + .battleStyle("single") + .criticalHits(false) + .enemySpecies(SpeciesId.MAGIKARP) + .enemyAbility(AbilityId.BALL_FETCH) + .enemyMoveset(MoveId.SPLASH) + .startingLevel(100) + .enemyLevel(100); + }); + + /** + * Expect that wish is active with the specified number of attacks. + * @param numAttacks - The number of wish instances that should be queued; default `1` + */ + function expectWishActive(numAttacks = 1) { + const wishes = game.scene.arena.positionalTagManager["tags"].filter(t => t.tagType === PositionalTagType.WISH); + expect(wishes).toHaveLength(numAttacks); + } + + it("should heal the Pokemon in the current slot for 50% of the user's maximum HP", async () => { + await game.classicMode.startBattle([SpeciesId.ALOMOMOLA, SpeciesId.BLISSEY]); + + const [alomomola, blissey] = game.scene.getPlayerParty(); + alomomola.hp = 1; + blissey.hp = 1; + + game.move.use(MoveId.WISH); + await game.toNextTurn(); + + expectWishActive(); + + game.doSwitchPokemon(1); + await game.toEndOfTurn(); + + expectWishActive(0); + expect(game.textInterceptor.logs).toContain( + i18next.t("arenaTag:wishTagOnAdd", { + pokemonNameWithAffix: getPokemonNameWithAffix(alomomola), + }), + ); + expect(alomomola.hp).toBe(1); + expect(blissey.hp).toBe(toDmgValue(alomomola.getMaxHp() / 2) + 1); + }); + + it("should work if the user has full HP, but not if it already has an active Wish", async () => { + await game.classicMode.startBattle([SpeciesId.ALOMOMOLA, SpeciesId.BLISSEY]); + + const alomomola = game.field.getPlayerPokemon(); + alomomola.hp = 1; + + game.move.use(MoveId.WISH); + await game.toNextTurn(); + + expectWishActive(); + + game.move.use(MoveId.WISH); + await game.toEndOfTurn(); + + expect(alomomola.hp).toBe(toDmgValue(alomomola.getMaxHp() / 2) + 1); + expect(alomomola.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + }); + + it("should function independently of Future Sight", async () => { + await game.classicMode.startBattle([SpeciesId.ALOMOMOLA, SpeciesId.BLISSEY]); + + const [alomomola, blissey] = game.scene.getPlayerParty(); + alomomola.hp = 1; + blissey.hp = 1; + + game.move.use(MoveId.WISH); + await game.move.forceEnemyMove(MoveId.FUTURE_SIGHT); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.toNextTurn(); + + expectWishActive(1); + }); + + it("should work in double battles and trigger in order of creation", async () => { + game.override.battleStyle("double"); + await game.classicMode.startBattle([SpeciesId.ALOMOMOLA, SpeciesId.BLISSEY]); + + const [alomomola, blissey, karp1, karp2] = game.scene.getField(); + alomomola.hp = 1; + blissey.hp = 1; + + vi.spyOn(karp1, "getNameToRender").mockReturnValue("Karp 1"); + vi.spyOn(karp2, "getNameToRender").mockReturnValue("Karp 2"); + + const oldOrder = game.field.getSpeedOrder(); + + game.move.use(MoveId.WISH, BattlerIndex.PLAYER); + game.move.use(MoveId.WISH, BattlerIndex.PLAYER_2); + await game.move.forceEnemyMove(MoveId.WISH); + await game.move.forceEnemyMove(MoveId.WISH); + // Ensure that the wishes are used deterministically in speed order (for speed ties) + await game.setTurnOrder(oldOrder.map(p => p.getBattlerIndex())); + await game.toNextTurn(); + + expectWishActive(4); + + // Lower speed to change turn order + alomomola.setStatStage(Stat.SPD, 6); + blissey.setStatStage(Stat.SPD, -6); + + const newOrder = game.field.getSpeedOrder(); + expect(newOrder).not.toEqual(oldOrder); + + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER); + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2); + await game.phaseInterceptor.to("PositionalTagPhase"); + + // all wishes have activated and added healing phases + expectWishActive(0); + + const healPhases = game.scene.phaseManager.phaseQueue.filter(p => p.is("PokemonHealPhase")); + expect(healPhases).toHaveLength(4); + expect.soft(healPhases.map(php => php.getPokemon())).toEqual(oldOrder); + + await game.toEndOfTurn(); + + expect(alomomola.hp).toBe(toDmgValue(alomomola.getMaxHp() / 2) + 1); + expect(blissey.hp).toBe(toDmgValue(blissey.getMaxHp() / 2) + 1); + }); + + it("should vanish and not play message if slot is empty", async () => { + game.override.battleStyle("double"); + await game.classicMode.startBattle([SpeciesId.ALOMOMOLA, SpeciesId.BLISSEY]); + + const [alomomola, blissey] = game.scene.getPlayerParty(); + alomomola.hp = 1; + blissey.hp = 1; + + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER); + game.move.use(MoveId.WISH, BattlerIndex.PLAYER_2); + await game.toNextTurn(); + + expectWishActive(); + + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER); + game.move.use(MoveId.MEMENTO, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2); + await game.toEndOfTurn(); + + // Wish went away without doing anything + expectWishActive(0); + expect(game.textInterceptor.logs).not.toContain( + i18next.t("arenaTag:wishTagOnAdd", { + pokemonNameWithAffix: getPokemonNameWithAffix(blissey), + }), + ); + expect(alomomola.hp).toBe(1); + }); +}); diff --git a/test/test-utils/helpers/field-helper.ts b/test/test-utils/helpers/field-helper.ts index 35ca853d049..2d8fd8ee701 100644 --- a/test/test-utils/helpers/field-helper.ts +++ b/test/test-utils/helpers/field-helper.ts @@ -5,7 +5,6 @@ import type { globalScene } from "#app/global-scene"; import type { Ability } from "#abilities/ability"; import { allAbilities } from "#data/data-lists"; import type { AbilityId } from "#enums/ability-id"; -import type { BattlerIndex } from "#enums/battler-index"; import type { PokemonType } from "#enums/pokemon-type"; import { Stat } from "#enums/stat"; import type { EnemyPokemon, PlayerPokemon, Pokemon } from "#field/pokemon"; @@ -45,18 +44,21 @@ export class FieldHelper extends GameManagerHelper { } /** - * @returns The {@linkcode BattlerIndex | indexes} of Pokemon on the field in order of decreasing Speed. + * Helper function to return all on-field {@linkcode Pokemon} in speed order (fastest first). + * @returns An array containing all {@linkcode Pokemon} on the field in order of descending Speed. * Speed ties are returned in increasing order of index. * * @remarks * This does not account for Trick Room as it does not modify the _speed_ of Pokemon on the field, * only their turn order. */ - public getSpeedOrder(): BattlerIndex[] { + public getSpeedOrder(): Pokemon[] { return this.game.scene .getField(true) - .sort((pA, pB) => pB.getEffectiveStat(Stat.SPD) - pA.getEffectiveStat(Stat.SPD)) - .map(p => p.getBattlerIndex()); + .sort( + (pA, pB) => + pB.getEffectiveStat(Stat.SPD) - pA.getEffectiveStat(Stat.SPD) || pA.getBattlerIndex() - pB.getBattlerIndex(), + ); } /** diff --git a/test/test-utils/helpers/move-helper.ts b/test/test-utils/helpers/move-helper.ts index 15605edf31d..041df916cbf 100644 --- a/test/test-utils/helpers/move-helper.ts +++ b/test/test-utils/helpers/move-helper.ts @@ -325,10 +325,16 @@ export class MoveHelper extends GameManagerHelper { } /** - * Force the move used by Metronome to be a specific move. - * @param move - The move to force metronome to use - * @param once - If `true`, uses {@linkcode MockInstance#mockReturnValueOnce} when mocking, else uses {@linkcode MockInstance#mockReturnValue}. + * Force the next move(s) used by Metronome to be a specific move. \ + * Triggers during the next upcoming {@linkcode MoveEffectPhase} that Metronome is used. + * @param move - The move to force Metronome to call + * @param once - If `true`, mocks the return value exactly once; default `false` * @returns The spy that for Metronome that was mocked (Usually unneeded). + * @example + * ```ts + * game.move.use(MoveId.METRONOME); + * game.move.forceMetronomeMove(MoveId.FUTURE_SIGHT); // Can be in any order + * ``` */ public forceMetronomeMove(move: MoveId, once = false): MockInstance { const spy = vi.spyOn(allMoves[MoveId.METRONOME].getAttrs("RandomMoveAttr")[0], "getMoveOverride"); diff --git a/test/test-utils/phase-interceptor.ts b/test/test-utils/phase-interceptor.ts index e0675a722f9..50de7e9f047 100644 --- a/test/test-utils/phase-interceptor.ts +++ b/test/test-utils/phase-interceptor.ts @@ -37,6 +37,7 @@ import { NextEncounterPhase } from "#phases/next-encounter-phase"; import { PartyExpPhase } from "#phases/party-exp-phase"; import { PartyHealPhase } from "#phases/party-heal-phase"; import { PokemonTransformPhase } from "#phases/pokemon-transform-phase"; +import { PositionalTagPhase } from "#phases/positional-tag-phase"; import { PostGameOverPhase } from "#phases/post-game-over-phase"; import { PostSummonPhase } from "#phases/post-summon-phase"; import { QuietFormChangePhase } from "#phases/quiet-form-change-phase"; @@ -142,6 +143,7 @@ export class PhaseInterceptor { [LevelCapPhase, this.startPhase], [AttemptRunPhase, this.startPhase], [SelectBiomePhase, this.startPhase], + [PositionalTagPhase, this.startPhase], [PokemonTransformPhase, this.startPhase], [MysteryEncounterPhase, this.startPhase], [MysteryEncounterOptionSelectedPhase, this.startPhase], diff --git a/test/types/positional-tags.test-d.ts b/test/types/positional-tags.test-d.ts new file mode 100644 index 00000000000..a75cc291764 --- /dev/null +++ b/test/types/positional-tags.test-d.ts @@ -0,0 +1,29 @@ +import type { SerializedPositionalTag, serializedPosTagMap } from "#data/positional-tags/load-positional-tag"; +import type { DelayedAttackTag, WishTag } from "#data/positional-tags/positional-tag"; +import type { PositionalTagType } from "#enums/positional-tag-type"; +import type { Mutable, NonFunctionPropertiesRecursive } from "#types/type-helpers"; +import { describe, expectTypeOf, it } from "vitest"; + +// Needed to get around properties being readonly in certain classes +type NonFunctionMutable = Mutable>; + +describe("serializedPositionalTagMap", () => { + it("should contain representations of each tag's serialized form", () => { + expectTypeOf().branded.toEqualTypeOf< + NonFunctionMutable + >(); + expectTypeOf().branded.toEqualTypeOf>(); + }); +}); + +describe("SerializedPositionalTag", () => { + it("should accept a union of all serialized tag forms", () => { + expectTypeOf().branded.toEqualTypeOf< + NonFunctionMutable | NonFunctionMutable + >(); + }); + it("should accept a union of all unserialized tag forms", () => { + expectTypeOf().toExtend(); + expectTypeOf().toExtend(); + }); +});