[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>
This commit is contained in:
Bertie690 2025-07-31 00:43:06 -04:00 committed by GitHub
parent 12acaa9590
commit f7b87f3d1e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1175 additions and 319 deletions

View File

@ -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;

View File

@ -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<this>).sourceName = getPokemonNameWithAffix(source);
(this as Mutable<this>).healHp = toDmgValue(source.getMaxHp() / 2);
(this as Mutable<this>).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<const T extends this>(
source: BaseArenaTag & Pick<T, "tagType" | "healHp" | "sourceName" | "battlerIndex">,
): void {
super.loadTag(source);
(this as Mutable<this>).battlerIndex = source.battlerIndex;
(this as Mutable<this>).healHp = source.healHp;
(this as Mutable<this>).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;

View File

@ -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<PositionalTagType.DELAYED_ATTACK>({
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<PositionalTagType.WISH>({
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)

View File

@ -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<T extends PositionalTagType>({
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<T extends PositionalTagType>({
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<serializedPosTagParamMap[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 {
// NB: This `satisfies` block ensures that all tag types have corresponding entries in the map.
[k in PositionalTagType]: Constructor<PositionalTag & { tagType: k }>;
};
/** 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<posTagMap[k]>;
};
/** Type mapping all positional tag types to their constructors' parameters. */
type posTagParamMap = {
[k in PositionalTagType]: ConstructorParameters<posTagMap[k]>[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<serializedPosTagMap>;

View File

@ -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<T extends PositionalTagType = never>(tag: Parameters<typeof loadPositionalTag<T>>[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](<https://www.smogon.com/forums/threads/sword-shield-battle-mechanics-research.3655528/page-64#post-9244179>))
*/
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;
}
}

View File

@ -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();
}
}

View File

@ -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",

View File

@ -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<typeof MoveUseMode>;
// # 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;
}
}

View File

@ -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",
}

View File

@ -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<T extends ArenaTag>(tagType: Constructor<T> | AbstractConstructor<T>): T | undefined;
getTag(tagType: ArenaTagType | Constructor<ArenaTag> | AbstractConstructor<ArenaTag>): ArenaTag | undefined {
return this.getTagOnSide(tagType, ArenaTagSide.BOTH);
}

View File

@ -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,

View File

@ -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);
}

View File

@ -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));

View File

@ -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;
}
}

View File

@ -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");
/*

View File

@ -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<Weather> | null;
terrain: NonFunctionProperties<Terrain> | 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 ?? [];
}
}

View File

@ -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 = [];

View File

@ -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<void> {
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");
});

View File

@ -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);
});
});

View File

@ -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);
});

183
test/moves/wish.test.ts Normal file
View File

@ -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);
});
});

View File

@ -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(),
);
}
/**

View File

@ -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");

View File

@ -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],

View File

@ -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<T> = Mutable<NonFunctionPropertiesRecursive<T>>;
describe("serializedPositionalTagMap", () => {
it("should contain representations of each tag's serialized form", () => {
expectTypeOf<serializedPosTagMap[PositionalTagType.DELAYED_ATTACK]>().branded.toEqualTypeOf<
NonFunctionMutable<DelayedAttackTag>
>();
expectTypeOf<serializedPosTagMap[PositionalTagType.WISH]>().branded.toEqualTypeOf<NonFunctionMutable<WishTag>>();
});
});
describe("SerializedPositionalTag", () => {
it("should accept a union of all serialized tag forms", () => {
expectTypeOf<SerializedPositionalTag>().branded.toEqualTypeOf<
NonFunctionMutable<DelayedAttackTag> | NonFunctionMutable<WishTag>
>();
});
it("should accept a union of all unserialized tag forms", () => {
expectTypeOf<WishTag>().toExtend<SerializedPositionalTag>();
expectTypeOf<DelayedAttackTag>().toExtend<SerializedPositionalTag>();
});
});