From 4d5d6b56a9e2db484bd20e2714964c8281de3a7b Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Sun, 20 Jul 2025 16:54:26 -0400 Subject: [PATCH] Refactored FS to use a positional tag manager --- src/data/arena-tag.ts | 129 +----------- src/data/moves/move.ts | 30 ++- .../positional-tags/positional-tag-manager.ts | 61 ++++++ src/data/positional-tags/positional-tag.ts | 184 ++++++++++++++++++ src/enums/arena-tag-type.ts | 1 - src/enums/positional-tag-type.ts | 9 + src/field/arena.ts | 16 +- src/phase-manager.ts | 2 + src/phases/attempt-run-phase.ts | 5 - src/phases/move-phase.ts | 2 - src/phases/positional-tag-phase.ts | 21 ++ src/phases/turn-end-phase.ts | 2 - src/phases/turn-start-phase.ts | 4 + src/system/arena-data.ts | 11 ++ src/system/game-data.ts | 2 + test/moves/delayed_attack.test.ts | 51 +++-- test/testUtils/phaseInterceptor.ts | 2 + 17 files changed, 352 insertions(+), 180 deletions(-) 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 diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index 0a2662f632e..60e3abdd2ef 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -1,4 +1,6 @@ import { applyAbAttrs, applyOnGainAbAttrs, applyOnLoseAbAttrs } from "#abilities/apply-ab-attrs"; +/** biome-ignore lint/correctness/noUnusedImports: Type-only import for doc comment */ +import type { BattlerTag } from "#app/data/battler-tags"; import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; import { CommonBattleAnim } from "#data/battle-anims"; @@ -13,7 +15,6 @@ import { MoveCategory } from "#enums/MoveCategory"; import { MoveTarget } from "#enums/MoveTarget"; import { CommonAnim } from "#enums/move-anims-common"; import { MoveId } from "#enums/move-id"; -import { MoveUseMode } from "#enums/move-use-mode"; import { PokemonType } from "#enums/pokemon-type"; import { Stat } from "#enums/stat"; import { StatusEffect } from "#enums/status-effect"; @@ -22,9 +23,6 @@ import type { Pokemon } from "#field/pokemon"; import { BooleanHolder, NumberHolder, toDmgValue } from "#utils/common"; import i18next from "i18next"; -/** biome-ignore lint/correctness/noUnusedImports: Type-only import for doc comment */ -import type { BattlerTag } from "#app/data/battler-tags"; - /** * An {@linkcode ArenaTag} represents a semi-persistent effect affecting a given side of the field. * Unlike {@linkcode BattlerTag}s (which are tied to individual {@linkcode Pokemon}), `ArenaTag`s function independently of @@ -886,127 +884,6 @@ class ToxicSpikesTag extends ArenaTrapTag { } } -/** - * Interface representing a delayed attack command. - * @see {@linkcode DelayedAttackTag} - */ -interface DelayedAttack { - /** The {@linkcode PID | Pokemon.id} of the {@linkcode Pokemon} initiating the attack. */ - sourceId: number; - /** The {@linkcode MoveId} that was used to trigger the delayed attack. */ - move: MoveId; - /** The {@linkcode BattlerIndex} of the attack's target. */ - targetIndex: BattlerIndex; - /** - * The number of turns left until activation. - * The attack will trigger once its turn count reaches 0, at which point it is removed. - */ - turnsLeft: number; -} - -/** - * Arena Tag to manage execution of delayed attacks, such as {@linkcode MoveId.FUTURE_SIGHT} or {@linkcode MoveId.DOOM_DESIRE}. - * Delayed attacks do nothing for the first 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 ArenaTag { - /** Contains all queued delayed attacks on the field */ - private delayedAttacks: DelayedAttack[] = []; - - constructor() { - super(ArenaTagType.DELAYED_ATTACK, 0); - } - - override loadTag(source: ArenaTag | any): void { - super.loadTag(source); - this.delayedAttacks = source.delayedAttacks; - } - - /** - * Queue a delayed attack to be used in some indeterminate number of turns. - * @param source - The {@linkcode Pokemon} using the move - * @param move - The {@linkcode MoveId} being used - * @param targetIndex - The {@linkcode BattlerIndex} being targeted - * @param turnCount - The number of turns to delay the attack (_including the turn the move is used_); default `3` - */ - public queueAttack(source: Pokemon, move: MoveId, targetIndex: BattlerIndex, turnCount = 3): void { - this.delayedAttacks.push({ sourceId: source.id, move, targetIndex, turnsLeft: turnCount }); - } - - /** - * Check whether a delayed attack can be queued against the given target. - * @param targetIndex - The {@linkcode BattlerIndex} of the target Pokemon - * @returns Whether another delayed attack can be successfully added. - */ - public canAddAttack(targetIndex: BattlerIndex): boolean { - return this.delayedAttacks.every(atk => atk.targetIndex !== targetIndex); - } - - /** - * Tick down all existing delayed attacks, activating them if their timers have elapsed. - * @returns Whether this tag should remain (at least 1 delayed attack still active). - */ - override lapse(_arena: Arena): boolean { - for (const attack of this.delayedAttacks) { - const source = globalScene.getPokemonById(attack.sourceId); - const target: Pokemon | undefined = globalScene.getField()[attack.targetIndex]; - - if (--attack.turnsLeft > 0) { - // attack still cooking - continue; - } - - if (!source || !target || source === target || target.isFainted()) { - // source/target either nonexistent or the exact same pokemon; silently mark for deletion - // TODO: move into an overriddable method if wish is made into a delayed attack - attack.turnsLeft = -1; - continue; - } - - this.triggerAttack(attack); - } - - return this.removeDoneAttacks(); - } - - /** - * Remove all finished attacks from the current queue. - * @returns Whether at least 1 attack has not finished triggering. - */ - private removeDoneAttacks(): boolean { - this.delayedAttacks = this.delayedAttacks.filter(a => a.turnsLeft > 0); - return this.delayedAttacks.length > 0; - } - - /** - * Trigger a delayed attack's effects prior to being removed. - * @param attack - The {@linkcode DelayedAttack} being activated - */ - protected triggerAttack(attack: DelayedAttack): void { - const source = globalScene.getPokemonById(attack.sourceId)!; - const target = globalScene.getField()[attack.targetIndex]; - - source.turnData.extraTurns++; - globalScene.phaseManager.queueMessage( - i18next.t("moveTriggers:tookMoveAttack", { - pokemonName: getPokemonNameWithAffix(target), - moveName: allMoves[attack.move].name, - }), - ); - - globalScene.phaseManager.unshiftNew( - "MoveEffectPhase", - attack.sourceId, - [attack.targetIndex], - allMoves[attack.move], - MoveUseMode.TRANSPARENT, - ); - } - - /** Override on remove func to do nothing. */ - override onRemove(_arena: Arena): void {} -} - /** * Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Stealth_Rock_(move) Stealth Rock}. * Applies up to 1 layer of Stealth Rocks, dealing percentage-based damage to any Pokémon @@ -1662,8 +1539,6 @@ export function getArenaTag( return new SpikesTag(sourceId, side); case ArenaTagType.TOXIC_SPIKES: return new ToxicSpikesTag(sourceId, side); - case ArenaTagType.DELAYED_ATTACK: - return new DelayedAttackTag(); case ArenaTagType.WISH: return new WishTag(turnCount, sourceId, side); case ArenaTagType.STEALTH_ROCK: diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 72125cf1b25..880a0091a41 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/MoveFlags"; import { MoveTarget } from "#enums/MoveTarget"; import { MultiHitType } from "#enums/MultiHitType"; import { PokemonType } from "#enums/pokemon-type"; +import { PositionalTagType } from "#enums/positional-tag-type"; import { SpeciesId } from "#enums/species-id"; import { BATTLE_STATS, @@ -3134,11 +3136,8 @@ export class DelayedAttackAttr extends OverrideMoveEffectAttr { } getCondition(): MoveConditionFunc { - return (_user, target, _move) => { - // Check the arena if another delayed attack is active and hitting the same slot - const delayedTag = globalScene.arena.getTag(DelayedAttackTag); - return delayedTag?.canAddAttack(target.getBattlerIndex()) ?? true; - } + // 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 { @@ -3159,18 +3158,13 @@ export class DelayedAttackAttr extends OverrideMoveEffectAttr { user.pushMoveHistory({move: move.id, targets: [target.getBattlerIndex()], result: MoveResult.OTHER, useMode: useMode, turn: globalScene.currentBattle.turn}) - // Add a Delayed Attack tag to the arena if it doesn't already exist and queue up an extra attack. - // TODO: Remove unused params once signature is tweaked to make more sense (none of these get used) - globalScene.arena.addTag(ArenaTagType.DELAYED_ATTACK, 123, 69, 420); - - // Queue an attack on the added (or existing) tag - const delayedAttackTag = globalScene.arena.getTag(DelayedAttackTag) - if (!delayedAttackTag) { - console.warn("Delayed attack tag not present!") - return false; - } - - delayedAttackTag.queueAttack(user, move.id, target.getBattlerIndex()); + // 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; } } @@ -9207,7 +9201,6 @@ export function initMoves() { .ignoresProtect() /* * Should not apply abilities or held items if user is off the field - * Triggered move phase occurs after Electrify tag is removed */ .edgeCase(), new AttackMove(MoveId.ROCK_SMASH, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 40, 100, 15, 50, 0, 2) @@ -9549,7 +9542,6 @@ export function initMoves() { .ignoresProtect() /* * Should not apply abilities or held items if user is off the field - * Triggered move phase occurs after Electrify tag is removed */ .edgeCase(), new AttackMove(MoveId.PSYCHO_BOOST, PokemonType.PSYCHIC, MoveCategory.SPECIAL, 140, 90, 5, -1, 0, 3) 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..5c16b925337 --- /dev/null +++ b/src/data/positional-tags/positional-tag-manager.ts @@ -0,0 +1,61 @@ +import { + loadPositionalTag, + type PositionalTag, + type SerializedPositionalTag, +} 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"; + +/** 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[] = []; + + /** + * 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. + */ + public addTag(tag: SerializedPositionalTag): 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 + * @param sourceMove - The {@linkcode MoveId} causing the attack + * @returns Whether the tag can be added. + */ + public canAddTag(tagType: PositionalTagType, targetIndex: BattlerIndex, sourceMove: MoveId): boolean { + return !this.tags.some(t => t.tagType === tagType && t.overlapsWith(targetIndex, sourceMove)); + } + + /** + * 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]()) + */ + triggerAllTags(): void { + for (const tag of this.tags) { + if (--tag.turnCount > 0) { + // tag still cooking + continue; + } + + // Check for silent removal + if (tag.shouldDisappear()) { + tag.turnCount = -1; + } + + tag.trigger(); + } + } +} diff --git a/src/data/positional-tags/positional-tag.ts b/src/data/positional-tags/positional-tag.ts new file mode 100644 index 00000000000..7ec9a654ed9 --- /dev/null +++ b/src/data/positional-tags/positional-tag.ts @@ -0,0 +1,184 @@ +// biome-ignore-start lint/correctness/noUnusedImports: TSDoc +import { globalScene } from "#app/global-scene"; +import { getPokemonNameWithAffix } from "#app/messages"; +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 i18next from "i18next"; + +/** + * Serialized representation of a {@linkcode PositionalTag}. + */ +export interface SerializedPositionalTag { + /** + * This tag's {@linkcode PositionalTagType | type}. + * Tags with similar types are considered "the same" for the purposes of overlaps. + */ + tagType: PositionalTagType; + /** + * 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. + * 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. + */ + turnCount: number; + /** + * The {@linkcode BattlerIndex} of the Pokemon targeted by the 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 SerializedPositionalTag { + /** + * This tag's {@linkcode PositionalTagType | type}. + */ + public abstract tagType: PositionalTagType; + public abstract lapseType: PositionalTagLapseType; + public abstract sourceId: number; + + // 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, + ) {} + + /** Trigger this tag's effects prior to removal. */ + public abstract trigger(): void; + + /** + * 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. + */ + abstract shouldDisappear(): boolean; + + /** + * Check whether this {@linkcode PositionalTag} would overlap with another one. + * @param targetIndex - The {@linkcode BattlerIndex} being targeted + * @param sourceMove - The {@linkcode MoveId} causing the attack + * @returns Whether this tag would overlap with a newly created one. + */ + public overlapsWith(targetIndex: BattlerIndex, _sourceMove: MoveId): boolean { + return this.targetIndex === targetIndex; + } +} + +/** + * 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; + + override trigger(): void { + const source = globalScene.getPokemonById(this.sourceId)!; + const target = globalScene.getField()[this.targetIndex]; + + source.turnData.extraTurns++; + globalScene.phaseManager.queueMessage( + i18next.t("moveTriggers:tookMoveAttack", { + pokemonName: getPokemonNameWithAffix(target), + moveName: allMoves[this.sourceMove].name, + }), + ); + + globalScene.phaseManager.unshiftNew( + "MoveEffectPhase", + this.sourceId, + [this.targetIndex], + allMoves[this.sourceMove], + MoveUseMode.TRANSPARENT, + ); + } + + override shouldDisappear(): boolean { + const source = globalScene.getPokemonById(this.sourceId); + const target = globalScene.getField()[this.targetIndex]; + // 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); +} + +/** Const object mapping tag types to their constructors. */ +const positionalTagConstructorMap = { + [PositionalTagType.DELAYED_ATTACK]: DelayedAttackTag, +} satisfies Record>; + +/** 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]>; +}; diff --git a/src/enums/arena-tag-type.ts b/src/enums/arena-tag-type.ts index b0d273a493e..6ad7f61df66 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", - DELAYED_ATTACK = "DELAYED_ATTACK", WISH = "WISH", STEALTH_ROCK = "STEALTH_ROCK", STICKY_WEB = "STICKY_WEB", diff --git a/src/enums/positional-tag-type.ts b/src/enums/positional-tag-type.ts new file mode 100644 index 00000000000..5953e6d93f4 --- /dev/null +++ b/src/enums/positional-tag-type.ts @@ -0,0 +1,9 @@ +/** + * 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", +} diff --git a/src/field/arena.ts b/src/field/arena.ts index 94b95c79261..29802d22770 100644 --- a/src/field/arena.ts +++ b/src/field/arena.ts @@ -7,6 +7,9 @@ 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"; +// biome-ignore lint/correctness/noUnusedImports: TSDoc +import type { PositionalTag } from "#data/positional-tags/positional-tag"; +import { PositionalTagManager } from "#data/positional-tags/positional-tag-manager"; import { getTerrainClearMessage, getTerrainStartMessage, Terrain, TerrainType } from "#data/terrain"; import { getLegendaryWeatherContinuesMessage, @@ -37,11 +40,18 @@ 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; - public playerTerasUsed: number; + public playerTerasUsed = 0; /** * Saves the number of times a party pokemon faints during a arena encounter. * {@linkcode globalScene.currentBattle.enemyFaints} is the corresponding faint counter for the enemy (this resets every wave). @@ -57,11 +67,9 @@ export class Arena { constructor(biome: BiomeId, bgm: string, playerFaints = 0) { this.biomeType = biome; - this.tags = []; this.bgm = bgm; this.trainerPool = biomeTrainerPools[biome]; this.updatePoolsForTimeOfDay(); - this.playerTerasUsed = 0; this.playerFaints = playerFaints; } diff --git a/src/phase-manager.ts b/src/phase-manager.ts index 7c1f2986593..0a80029175f 100644 --- a/src/phase-manager.ts +++ b/src/phase-manager.ts @@ -60,6 +60,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"; @@ -170,6 +171,7 @@ const PHASES = Object.freeze({ PokemonAnimPhase, PokemonHealPhase, PokemonTransformPhase, + PositionalTagPhase, PostGameOverPhase, PostSummonPhase, PostTurnStatusEffectPhase, diff --git a/src/phases/attempt-run-phase.ts b/src/phases/attempt-run-phase.ts index a4ef064d0e8..a59667bdd4e 100644 --- a/src/phases/attempt-run-phase.ts +++ b/src/phases/attempt-run-phase.ts @@ -6,7 +6,6 @@ import { StatusEffect } from "#enums/status-effect"; import { FieldPhase } from "#phases/field-phase"; import { NumberHolder } from "#utils/common"; import i18next from "i18next"; -import { ArenaTagType } from "#enums/arena-tag-type"; export class AttemptRunPhase extends FieldPhase { public readonly phaseName = "AttemptRunPhase"; @@ -43,10 +42,6 @@ export class AttemptRunPhase extends FieldPhase { globalScene.clearEnemyHeldItemModifiers(); - // clear all queued delayed attacks (e.g. from Future Sight) - // TODO: review if this is necessary - globalScene.arena.removeTag(ArenaTagType.DELAYED_ATTACK); - enemyField.forEach(enemyPokemon => { enemyPokemon.hideInfo().then(() => enemyPokemon.destroy()); enemyPokemon.hp = 0; diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 4c615bc63f7..34f1fa215cc 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"; diff --git a/src/phases/positional-tag-phase.ts b/src/phases/positional-tag-phase.ts new file mode 100644 index 00000000000..06c29e12647 --- /dev/null +++ b/src/phases/positional-tag-phase.ts @@ -0,0 +1,21 @@ +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 + +/** + * 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.triggerAllTags(); + super.end(); + return; + } +} diff --git a/src/phases/turn-end-phase.ts b/src/phases/turn-end-phase.ts index d54d08898b5..ce3b2958c23 100644 --- a/src/phases/turn-end-phase.ts +++ b/src/phases/turn-end-phase.ts @@ -61,8 +61,6 @@ export class TurnEndPhase extends FieldPhase { this.executeForAll(handlePokemon); - // TODO: This needs to be moved up earlier to allow Future Sight to be affected by - // Electrify before the latter is removed globalScene.arena.lapseTags(); if (globalScene.arena.weather && !globalScene.arena.weather.lapse()) { diff --git a/src/phases/turn-start-phase.ts b/src/phases/turn-start-phase.ts index 0fc126801ec..9f2d9f00c0d 100644 --- a/src/phases/turn-start-phase.ts +++ b/src/phases/turn-start-phase.ts @@ -219,12 +219,16 @@ export class TurnStartPhase extends FieldPhase { } } + // 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 9d15ab50fcc..bac0d3f8587 100644 --- a/src/system/arena-data.ts +++ b/src/system/arena-data.ts @@ -1,5 +1,10 @@ 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 { Terrain } from "#data/terrain"; import { Weather } from "#data/weather"; import type { BiomeId } from "#enums/biome-id"; @@ -10,6 +15,7 @@ export class ArenaData { public weather: Weather | null; public terrain: Terrain | null; public tags: ArenaTag[]; + public positionalTags: PositionalTag[] = []; public playerTerasUsed: number; constructor(source: Arena | any) { @@ -31,5 +37,10 @@ export class ArenaData { if (source.tags) { this.tags = source.tags.map(t => loadArenaTag(t)); } + + this.positionalTags = + (sourceArena ? sourceArena.positionalTagManager.tags : source.positionalTags)?.map((t: SerializedPositionalTag) => + loadPositionalTag(t), + ) ?? []; } } diff --git a/src/system/game-data.ts b/src/system/game-data.ts index 6abb5518d1c..f7d84de680c 100644 --- a/src/system/game-data.ts +++ b/src/system/game-data.ts @@ -1096,6 +1096,8 @@ export class GameData { } } + globalScene.arena.positionalTagManager.tags = sessionData.arena.positionalTags; + 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 index 8a47c04d4da..e9675bef3cb 100644 --- a/test/moves/delayed_attack.test.ts +++ b/test/moves/delayed_attack.test.ts @@ -1,20 +1,19 @@ -import { BattlerIndex } from "#enums/battler-index"; -import { allMoves } from "#app/data/data-lists"; -import { DelayedAttackTag } from "#app/data/arena-tag"; -import { allAbilities } from "#app/data/data-lists"; -import { RandomMoveAttr } from "#app/data/moves/move"; -import { MoveResult } from "#enums/move-result"; +import { 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"; import { MoveId } from "#enums/move-id"; +import { MoveResult } from "#enums/move-result"; import { PokemonType } from "#enums/pokemon-type"; import { SpeciesId } from "#enums/species-id"; -import GameManager from "#test/testUtils/gameManager"; +import { GameManager } from "#test/testUtils/gameManager"; import i18next from "i18next"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { BattleType } from "#enums/battle-type"; describe("Moves - Delayed Attacks", () => { let phaserGame: Phaser.Game; @@ -41,35 +40,42 @@ describe("Moves - Delayed Attacks", () => { }); /** - * Wait until a number of turns have passed. + * Wait until a number of turns have passed and a delayed attack has struck. * @param numTurns - Number of turns to pass. - * @returns: A Promise that resolves once the specified number of turns has elapsed. + * @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): Promise { + 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]) { game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2); } - await game.toNextTurn(); + } + 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` + * @returns The queued tags. */ - function expectFutureSightActive(numAttacks = 1) { - const tag = game.scene.arena.getTag(DelayedAttackTag)!; - expect(tag).toBeDefined(); - expect(tag["delayedAttacks"]).toHaveLength(numAttacks); + function expectFutureSightActive(numAttacks = 1): DelayedAttackTag[] { + const delayedAttacks = game.scene.arena.positionalTagManager["tags"].filter(t => t instanceof DelayedAttackTag)!; + expect(delayedAttacks).toHaveLength(numAttacks); + return delayedAttacks; } 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) + game.override.battleType(BattleType.TRAINER); await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.MILOTIC]); game.move.use(move); @@ -136,7 +142,7 @@ describe("Moves - Delayed Attacks", () => { 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() + await game.toEndOfTurn(); expectFutureSightActive(2); expect(enemy1.hp).toBe(enemy1.getMaxHp()); @@ -144,7 +150,12 @@ describe("Moves - Delayed Attacks", () => { expect(karp.getLastXMoves()[0].result).toBe(MoveResult.OTHER); expect(feebas.getLastXMoves()[0].result).toBe(MoveResult.OTHER); - await passTurns(2); + await passTurns(2, false); + + // Both attacks have + expectFutureSightActive(0); + + await game.toEndOfTurn(); expect(enemy1.hp).toBeLessThan(enemy1.getMaxHp()); expect(enemy2.hp).toBeLessThan(enemy2.getMaxHp()); @@ -274,7 +285,7 @@ describe("Moves - Delayed Attacks", () => { }); // TODO: this is not implemented - it.todo("should not apply Shell Bell recovery, even if user is on field") + 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 () => { diff --git a/test/testUtils/phaseInterceptor.ts b/test/testUtils/phaseInterceptor.ts index cdf151c01af..1d077633724 100644 --- a/test/testUtils/phaseInterceptor.ts +++ b/test/testUtils/phaseInterceptor.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],