diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index 60e3abdd2ef..b88b0934230 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -8,7 +8,6 @@ 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 { MoveCategory } from "#enums/MoveCategory"; @@ -536,45 +535,6 @@ export class NoCritTag extends ArenaTag { } } -/** - * 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 ArenaTag { - private battlerIndex: BattlerIndex; - private triggerMessage: string; - private healHp: number; - - constructor(turnCount: number, sourceId: number, side: ArenaTagSide) { - super(ArenaTagType.WISH, 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; - } - - super.onAdd(_arena); - this.healHp = toDmgValue(source.getMaxHp() / 2); - - globalScene.phaseManager.queueMessage( - i18next.t("arenaTag:wishTagOnAdd", { - pokemonNameWithAffix: getPokemonNameWithAffix(source), - }), - ); - } - - onRemove(_arena: Arena): void { - const target = globalScene.getField()[this.battlerIndex]; - if (target?.isActive(true)) { - globalScene.phaseManager.queueMessage(this.triggerMessage); - globalScene.phaseManager.unshiftNew("PokemonHealPhase", target.getBattlerIndex(), this.healHp, null, true, false); - } - } -} - /** * Abstract class to implement weakened moves of a specific type. */ @@ -1539,8 +1499,6 @@ export function getArenaTag( return new SpikesTag(sourceId, side); case ArenaTagType.TOXIC_SPIKES: return new ToxicSpikesTag(sourceId, side); - case ArenaTagType.WISH: - return new WishTag(turnCount, sourceId, side); case ArenaTagType.STEALTH_ROCK: return new StealthRockTag(sourceId, side); case ArenaTagType.STICKY_WEB: diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 880a0091a41..6a3ab753484 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -3105,7 +3105,7 @@ export class OverrideMoveEffectAttr extends MoveAttr { * @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 + * `[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. */ @@ -3114,6 +3114,16 @@ export class OverrideMoveEffectAttr extends MoveAttr { } } +/** 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(), move.id) + } +} + /** * 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}, @@ -3135,16 +3145,11 @@ export class DelayedAttackAttr extends OverrideMoveEffectAttr { this.chargeText = chargeKey; } - getCondition(): MoveConditionFunc { - // Check the arena if another delayed attack is active and hitting the same slot - return (_user, target, move) => globalScene.arena.positionalTagManager.canAddTag(PositionalTagType.DELAYED_ATTACK, target.getBattlerIndex(), move.id) - } - 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; + return false; } const overridden = args[0]; @@ -3159,14 +3164,34 @@ export class DelayedAttackAttr extends OverrideMoveEffectAttr { user.pushMoveHistory({move: move.id, targets: [target.getBattlerIndex()], result: MoveResult.OTHER, useMode: useMode, turn: globalScene.currentBattle.turn}) // Queue up an attack on the given slot. - globalScene.arena.positionalTagManager.addTag({ + globalScene.arena.positionalTagManager.addTag({ tagType: PositionalTagType.DELAYED_ATTACK, sourceId: user.id, targetIndex: target.getBattlerIndex(), sourceMove: move.id, - turnCount: 3}) + 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, move) => globalScene.arena.positionalTagManager.canAddTag(PositionalTagType.DELAYED_ATTACK, target.getBattlerIndex(), move.id) + } +} + +export class WishAttr extends MoveEffectAttr { + apply(user: Pokemon, target: Pokemon, _move: Move): boolean { + globalScene.arena.positionalTagManager.addTag({tagType: PositionalTagType.WISH, sourceId: user.id, healHp: toDmgValue(user.getMaxHp() / 2), targetIndex: target.getBattlerIndex(), + turnCount: 2, + }); + return true; + } + + public override getCondition(): MoveConditionFunc { + // Check the arena if another similar attack is active and affecting the same slot + return (_user, target, move) => globalScene.arena.positionalTagManager.canAddTag(PositionalTagType.WISH, target.getBattlerIndex(), move.id) + } } /** @@ -9288,8 +9313,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) 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..db05f7e35bd --- /dev/null +++ b/src/data/positional-tags/load-positional-tag.ts @@ -0,0 +1,63 @@ +import { DelayedAttackTag, type PositionalTag, WishTag } from "#data/positional-tags/positional-tag"; +import { PositionalTagType } from "#enums/positional-tag-type"; +import type { EnumValues } from "#types/enum-types"; +import type { Constructor } from "#utils/common"; + +/** + * Add a new {@linkcode PositionalTag} to the arena. + * @param tagType - The {@linkcode PositionalTagType} to create + * @param args - The arguments needed to instantize the given tag + * @remarks + * This function does not perform any checking if the added tag is valid. + */ +export function loadPositionalTag({ + tagType, + ...args +}: serializedPosTagParamMap[T]): posTagInstanceMap[T]; +/** + * Add a new {@linkcode PositionalTag} to the arena. + * @param tag - The {@linkcode SerializedPositionalTag} to instantiate + * @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 +}: serializedPosTagParamMap[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 `Omit<{tagType: T} & posTagParamMap[T], "tagType"> 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 { + [k in PositionalTagType]: Constructor; +}; + +/** Type mapping 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. */ +type serializedPosTagParamMap = { + [k in PositionalTagType]: posTagParamMap[k] & { tagType: k }; +}; + +/** Union type containing all serialized {@linkcode PositionalTag}s. */ +export type SerializedPositionalTag = EnumValues; diff --git a/src/data/positional-tags/positional-tag-manager.ts b/src/data/positional-tags/positional-tag-manager.ts index 5c16b925337..2de800b11f0 100644 --- a/src/data/positional-tags/positional-tag-manager.ts +++ b/src/data/positional-tags/positional-tag-manager.ts @@ -1,8 +1,5 @@ -import { - loadPositionalTag, - type PositionalTag, - type SerializedPositionalTag, -} from "#data/positional-tags/positional-tag"; +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 { MoveId } from "#enums/move-id"; import type { PositionalTagType } from "#enums/positional-tag-type"; @@ -10,19 +7,14 @@ 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. */ - private tags: PositionalTag[] = []; + public tags: PositionalTag[] = []; /** - * Add a new {@linkcode SerializedPositionalTag} to the arena. - * @param tagType - The {@linkcode PositionalTagType} to create - * @param sourceId - The {@linkcode Pokemon.id | PID} of the Pokemon adding the tag - * @param sourceMove - The {@linkcode MoveId} causing the attack - * @param turnCount - The number of turns to delay the effect (_including the current turn_). - * @param targetIndex - The {@linkcode BattlerIndex} being targeted + * 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: SerializedPositionalTag): void { + public addTag(tag: Parameters>[0]): void { this.tags.push(loadPositionalTag(tag)); } @@ -40,19 +32,21 @@ export class PositionalTagManager { /** * Decrement turn counts of and activate all pending {@linkcode PositionalTag}s on field. * @remarks - * If multiple tags trigger simultaneously, they will activate **in order of initial creation**. - * (source: [Smogon]()) + * If multiple tags trigger simultaneously, they will activate **in order of initial creation**, NOT speed order. + * (Source: [Smogon]()) */ - triggerAllTags(): void { + public triggerAllTags(): void { + const leftoverTags: PositionalTag[] = []; for (const tag of this.tags) { - if (--tag.turnCount > 0) { - // tag still cooking + // Check for silent removal, immediately removing tags that. + if (!tag.shouldDisappear()) { continue; } - // Check for silent removal - if (tag.shouldDisappear()) { - tag.turnCount = -1; + if (--tag.turnCount > 0) { + // tag still cooking + leftoverTags.push(tag); + continue; } tag.trigger(); diff --git a/src/data/positional-tags/positional-tag.ts b/src/data/positional-tags/positional-tag.ts index 7ec9a654ed9..08728680f43 100644 --- a/src/data/positional-tags/positional-tag.ts +++ b/src/data/positional-tags/positional-tag.ts @@ -1,36 +1,31 @@ -// biome-ignore-start lint/correctness/noUnusedImports: TSDoc 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 { PositionalTagLapseType } from "#enums/positional-tag-lapse-type"; import { PositionalTagType } from "#enums/positional-tag-type"; -import type { Constructor } from "#utils/common"; +import type { Pokemon } from "#field/pokemon"; import i18next from "i18next"; /** - * Serialized representation of a {@linkcode PositionalTag}. + * Baseline arguments used to construct all {@linkcode PositionalTag}s. + * Does not contain the `tagType` parameter (which is used to select the proper class constructor to use). */ -export interface SerializedPositionalTag { - /** - * This tag's {@linkcode PositionalTagType | type}. - * Tags with similar types are considered "the same" for the purposes of overlaps. - */ - tagType: PositionalTagType; +export interface PositionalTagBaseArgs { /** * The {@linkcode Pokemon.id | PID} of the {@linkcode Pokemon} having created the effect. */ sourceId: number; /** - * The {@linkcode MoveId} that created this effect. - */ - sourceMove: MoveId; - /** - * The number of turns remaining until activation. + * The number of turns remaining until activation. \ * Decremented by 1 at the end of each turn until reaching 0, at which point it will {@linkcode trigger} and be removed. - * If set to any number `<0` manually, will be silently removed at the end of the next turn without activating. + * @remarks + * If this is set to any number `<0` manually (such as through the effects of {@linkcode PositionalTag.shouldDisappear | shouldDisappear}), + * this tag will be silently removed at the end of the next turn _without activating any effects_. */ turnCount: number; /** @@ -44,21 +39,18 @@ export interface SerializedPositionalTag { * 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 SerializedPositionalTag { - /** - * This tag's {@linkcode PositionalTagType | type}. - */ - public abstract tagType: PositionalTagType; - public abstract lapseType: PositionalTagLapseType; - public abstract sourceId: number; +export abstract class PositionalTag implements PositionalTagBaseArgs { + public abstract readonly tagType: PositionalTagType; + // These arguments have to be public to implement the interface, but are functionally private. + public sourceId: number; + public turnCount: number; + public targetIndex: BattlerIndex; - // These have to be public to implement the interface, but are functionally private. - constructor( - public sourceId: number, - public sourceMove: MoveId, - public turnCount: number, - public targetIndex: BattlerIndex, - ) {} + constructor({ sourceId, turnCount, targetIndex }: PositionalTagBaseArgs) { + this.sourceId = sourceId; + this.turnCount = turnCount; + this.targetIndex = targetIndex; + } /** Trigger this tag's effects prior to removal. */ public abstract trigger(): void; @@ -66,7 +58,8 @@ export abstract class PositionalTag implements SerializedPositionalTag { /** * Check whether this tag should be removed without triggering. * @returns Whether this tag should disappear. - * By default, requires that the attack's turn count is less than or equal to 0. + * @privateRemarks + * Silent removal is accomplished by setting the attack's turn count to -1. */ abstract shouldDisappear(): boolean; @@ -79,20 +72,34 @@ export abstract class PositionalTag implements SerializedPositionalTag { public overlapsWith(targetIndex: BattlerIndex, _sourceMove: MoveId): boolean { return this.targetIndex === targetIndex; } + + public getTarget(): Pokemon | undefined { + return globalScene.getField()[this.targetIndex]; + } +} + +interface DelayedAttackArgs extends PositionalTagBaseArgs { + /** 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}. + * 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 { - public override tagType = PositionalTagType.DELAYED_ATTACK; - public override lapseType = PositionalTagLapseType.TURN_END; +export class DelayedAttackTag extends PositionalTag implements DelayedAttackArgs { + public override readonly tagType = PositionalTagType.DELAYED_ATTACK; + public sourceMove: MoveId; + + constructor({ sourceId, turnCount, targetIndex, sourceMove }: DelayedAttackArgs) { + super({ sourceId, turnCount, targetIndex }); + this.sourceMove = sourceMove; + } override trigger(): void { const source = globalScene.getPokemonById(this.sourceId)!; - const target = globalScene.getField()[this.targetIndex]; + const target = this.getTarget()!; source.turnData.extraTurns++; globalScene.phaseManager.queueMessage( @@ -113,72 +120,35 @@ export class DelayedAttackTag extends PositionalTag { override shouldDisappear(): boolean { const source = globalScene.getPokemonById(this.sourceId); - const target = globalScene.getField()[this.targetIndex]; + const target = this.getTarget(); // Silently disappear if either source or target are missing or happen to be the same pokemon // (i.e. targeting oneself) return !source || !target || source === target || target.isFainted(); } } -/** - * Add a new {@linkcode PositionalTag} to the arena. - * @param tag - The {@linkcode SerializedPositionalTag} corresponding to the tag being added - * @remarks - * This function does not perform any checking if the added tag is valid. - */ -export function loadPositionalTag( - tag: SerializedPositionalTag, -): InstanceType<(typeof positionalTagConstructorMap)[(typeof tag)["tagType"]]>; -/** - * Add a new {@linkcode PositionalTag} to the arena. - * @param tagType - The {@linkcode PositionalTagType} to create - * @param sourceId - The {@linkcode Pokemon.id | PID} of the Pokemon adding the tag - * @param sourceMove - The {@linkcode MoveId} causing the attack - * @param turnCount - The number of turns to delay the effect (_including the current turn_). - * @param targetIndex - The {@linkcode BattlerIndex} being targeted - * @remarks - * This function does not perform any checking if the added tag is valid. - */ -export function loadPositionalTag({ - tagType, - sourceId, - sourceMove, - turnCount, - targetIndex, -}: { - tagType: T; - sourceId: number; - sourceMove: MoveId; - turnCount: number; - targetIndex: BattlerIndex; -}): tagInstanceMap[T]; -/** - * Add a new {@linkcode SerializedPositionalTag} to the arena. - * @param tagType - The {@linkcode PositionalTagType} to create - * @param sourceId - The {@linkcode Pokemon.id | PID} of the Pokemon adding the tag - * @param sourceMove - The {@linkcode MoveId} causing the attack - * @param turnCount - The number of turns to delay the effect (_including the current turn_). - * @param targetIndex - The {@linkcode BattlerIndex} being targeted - * @remarks - * This function does not perform any checking if the added tag is valid. - */ -export function loadPositionalTag({ - tagType, - sourceId, - sourceMove, - turnCount, - targetIndex, -}: SerializedPositionalTag): PositionalTag { - const tagClass = positionalTagConstructorMap[tagType]; - return new tagClass(sourceId, sourceMove, turnCount, targetIndex); +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; } -/** Const object mapping tag types to their constructors. */ -const positionalTagConstructorMap = { - [PositionalTagType.DELAYED_ATTACK]: DelayedAttackTag, -} satisfies Record>; +/** + * Tag to implement {@linkcode MoveId.WISH | Wish}. + */ +export class WishTag extends PositionalTag implements WishArgs { + public override readonly tagType = PositionalTagType.WISH; -/** Type mapping {@linkcode PositionalTagType}s to instances of their corresponding {@linkcode PositionalTag}s. */ -export type tagInstanceMap = { - [k in keyof typeof positionalTagConstructorMap]: InstanceType<(typeof positionalTagConstructorMap)[k]>; -}; + public healHp: number; + constructor({ sourceId, turnCount, targetIndex, healHp }: WishArgs) { + super({ sourceId, turnCount, targetIndex }); + this.healHp = healHp; + } + + public trigger(): void { + globalScene.phaseManager.unshiftNew("PokemonHealPhase", this.targetIndex, this.healHp, null, true, false); + } + + public shouldDisappear(): boolean { + return !!this.getTarget(); + } +} diff --git a/src/enums/arena-tag-type.ts b/src/enums/arena-tag-type.ts index 6ad7f61df66..0b8e7c353ea 100644 --- a/src/enums/arena-tag-type.ts +++ b/src/enums/arena-tag-type.ts @@ -5,7 +5,6 @@ export enum ArenaTagType { SPIKES = "SPIKES", TOXIC_SPIKES = "TOXIC_SPIKES", MIST = "MIST", - 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 2db7fe081c3..2e46ccbb04e 100644 --- a/src/enums/move-use-mode.ts +++ b/src/enums/move-use-mode.ts @@ -1,6 +1,7 @@ import type { PostDancingMoveAbAttr } from "#abilities/ability"; -import type { BattlerTagLapseType } from "#enums/battler-tag-lapse-type"; import type { DelayedAttackAttr } from "#app/@types/move-types"; +import type { BattlerTagLapseType } from "#enums/battler-tag-lapse-type"; +import type { EnumValues } from "#types/enum-types"; /** * Enum representing all the possible means through which a given move can be executed. @@ -71,7 +72,7 @@ export const MoveUseMode = { TRANSPARENT: 6 } as const; -export type MoveUseMode = (typeof MoveUseMode)[keyof typeof MoveUseMode]; +export type MoveUseMode = EnumValues; // # HELPER FUNCTIONS // Please update the markdown tables if any new `MoveUseMode`s get added. diff --git a/src/enums/positional-tag-type.ts b/src/enums/positional-tag-type.ts index 5953e6d93f4..254503d0de6 100644 --- a/src/enums/positional-tag-type.ts +++ b/src/enums/positional-tag-type.ts @@ -6,4 +6,5 @@ */ export enum PositionalTagType { DELAYED_ATTACK = "DELAYED_ATTACK", + WISH = "WISH", } diff --git a/src/phases/positional-tag-phase.ts b/src/phases/positional-tag-phase.ts index 06c29e12647..21a5ff9381b 100644 --- a/src/phases/positional-tag-phase.ts +++ b/src/phases/positional-tag-phase.ts @@ -2,7 +2,6 @@ import { globalScene } from "#app/global-scene"; import { Phase } from "#app/phase"; // biome-ignore-start lint/correctness/noUnusedImports: TSDocs import type { PositionalTag } from "#data/positional-tags/positional-tag"; -import { PositionalTagLapseType } from "#enums/positional-tag-lapse-type"; import type { TurnEndPhase } from "#phases/turn-end-phase"; // biome-ignore-end lint/correctness/noUnusedImports: TSDocs diff --git a/src/system/arena-data.ts b/src/system/arena-data.ts index bac0d3f8587..7b00255aaed 100644 --- a/src/system/arena-data.ts +++ b/src/system/arena-data.ts @@ -1,10 +1,6 @@ import type { ArenaTag } from "#data/arena-tag"; import { loadArenaTag } from "#data/arena-tag"; -import { - loadPositionalTag, - type PositionalTag, - type SerializedPositionalTag, -} from "#data/positional-tags/positional-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"; @@ -15,7 +11,7 @@ export class ArenaData { public weather: Weather | null; public terrain: Terrain | null; public tags: ArenaTag[]; - public positionalTags: PositionalTag[] = []; + public positionalTags: SerializedPositionalTag[] = []; public playerTerasUsed: number; constructor(source: Arena | any) { @@ -38,9 +34,6 @@ export class ArenaData { this.tags = source.tags.map(t => loadArenaTag(t)); } - this.positionalTags = - (sourceArena ? sourceArena.positionalTagManager.tags : source.positionalTags)?.map((t: SerializedPositionalTag) => - loadPositionalTag(t), - ) ?? []; + this.positionalTags = (sourceArena ? sourceArena.positionalTagManager.tags : source.positionalTags) ?? []; } } diff --git a/src/system/game-data.ts b/src/system/game-data.ts index f7d84de680c..b643e6604d1 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,7 +1097,9 @@ export class GameData { } } - globalScene.arena.positionalTagManager.tags = sessionData.arena.positionalTags; + 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..."); diff --git a/test/moves/delayed_attack.test.ts b/test/moves/delayed_attack.test.ts index e9675bef3cb..a6000d02977 100644 --- a/test/moves/delayed_attack.test.ts +++ b/test/moves/delayed_attack.test.ts @@ -2,7 +2,6 @@ import { DelayedAttackTag } from "#app/data/positional-tags/positional-tag"; import { getPokemonNameWithAffix } from "#app/messages"; import { AttackTypeBoosterModifier } from "#app/modifier/modifier"; import { allMoves } from "#data/data-lists"; -import { RandomMoveAttr } from "#data/moves/move"; import { AbilityId } from "#enums/ability-id"; import { BattleType } from "#enums/battle-type"; import { BattlerIndex } from "#enums/battler-index"; @@ -50,9 +49,13 @@ describe("Moves - Delayed Attacks", () => { 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.currentBattle.double && game.scene.getPlayerField()[1]) { + if (game.scene.getPlayerField()[1]) { game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2); } + await game.move.forceEnemyMove(MoveId.SPLASH); + if (game.scene.getEnemyField()[1]) { + await game.move.forceEnemyMove(MoveId.SPLASH); + } } await game.phaseInterceptor.to("PositionalTagPhase"); if (toEndOfTurn) { @@ -63,12 +66,10 @@ describe("Moves - Delayed Attacks", () => { /** * 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` - * @returns The queued tags. */ - function expectFutureSightActive(numAttacks = 1): DelayedAttackTag[] { + function expectFutureSightActive(numAttacks = 1) { const delayedAttacks = game.scene.arena.positionalTagManager["tags"].filter(t => t instanceof DelayedAttackTag)!; expect(delayedAttacks).toHaveLength(numAttacks); - return delayedAttacks; } it.each<{ name: string; move: MoveId }>([ @@ -117,18 +118,17 @@ describe("Moves - Delayed Attacks", () => { 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); + 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()); - expectFutureSightActive(); - await passTurns(2); expect(enemy.hp).toBeLessThan(enemy.getMaxHp()); @@ -150,10 +150,7 @@ describe("Moves - Delayed Attacks", () => { expect(karp.getLastXMoves()[0].result).toBe(MoveResult.OTHER); expect(feebas.getLastXMoves()[0].result).toBe(MoveResult.OTHER); - await passTurns(2, false); - - // Both attacks have - expectFutureSightActive(0); + await passTurns(2); await game.toEndOfTurn(); @@ -161,6 +158,33 @@ describe("Moves - Delayed Attacks", () => { 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]); + + 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); + const usageOrder = game.field.getSpeedOrder(); + await game.toNextTurn(); + + expectFutureSightActive(4); + + game.move.use(MoveId.TAILWIND); + game.move.use(MoveId.COTTON_SPORE); + await passTurns(1, false); + + expect(game.field.getSpeedOrder()).not.toEqual(usageOrder); + + // 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["battlerIndex"])).toEqual(usageOrder); + }); + 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]); @@ -242,18 +266,13 @@ describe("Moves - Delayed Attacks", () => { ); }); - // TODO: ArenaTags currently procs 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 while ignoring redirection", async () => { + 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); - // 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(); @@ -264,14 +283,14 @@ describe("Moves - Delayed Attacks", () => { game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER); await game.move.forceEnemyMove(MoveId.ELECTRIFY, BattlerIndex.PLAYER); - await game.phaseInterceptor.to("TurnEndPhase"); + 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.toNextTurn(); + await game.toEndOfTurn(); expect(enemy1.hp).toBe(enemy1.getMaxHp()); expect(enemy2.hp).toBeLessThan(enemy2.getMaxHp()); @@ -337,4 +356,7 @@ describe("Moves - Delayed Attacks", () => { 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/heal_block.test.ts b/test/moves/heal_block.test.ts index 0f3ff0df683..d80f3ab3741 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/testUtils/gameManager"; @@ -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.find(t => t.tagType === PositionalTagType.WISH)).toHaveLength(1); + game.move.use(MoveId.SPLASH); + await game.toNextTurn(); + game.move.use(MoveId.SPLASH); + await game.toNextTurn(); + + // wish triggered, but did NOT heal the player + expect(game.scene.arena.positionalTagManager.tags.find(t => t.tagType === PositionalTagType.WISH)).toHaveLength(0); expect(player.hp).toBe(1); }); diff --git a/test/testUtils/helpers/moveHelper.ts b/test/testUtils/helpers/moveHelper.ts index 98a1c1664b4..c7c62fba87b 100644 --- a/test/testUtils/helpers/moveHelper.ts +++ b/test/testUtils/helpers/moveHelper.ts @@ -309,10 +309,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");