mirror of
https://github.com/pagefaultgames/pokerogue.git
synced 2025-08-08 00:19:29 +02:00
Merge branch 'beta' into test-cleanup-2
This commit is contained in:
commit
bdf7f8236c
@ -1,6 +1,5 @@
|
||||
import type { ArenaTagTypeMap } from "#data/arena-tag";
|
||||
import type { ArenaTagType } from "#enums/arena-tag-type";
|
||||
import type { NonFunctionProperties } from "./type-helpers";
|
||||
|
||||
/** Subset of {@linkcode ArenaTagType}s that apply some negative effect to pokemon that switch in ({@link https://bulbapedia.bulbagarden.net/wiki/List_of_moves_that_cause_entry_hazards#List_of_traps | entry hazards} and Imprison. */
|
||||
export type ArenaTrapTagType =
|
||||
@ -10,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;
|
||||
|
||||
@ -30,13 +26,13 @@ export type NonSerializableArenaTagType = ArenaTagType.NONE | TurnProtectArenaTa
|
||||
export type SerializableArenaTagType = Exclude<ArenaTagType, NonSerializableArenaTagType>;
|
||||
|
||||
/**
|
||||
* Type-safe representation of the serializable data of an ArenaTag
|
||||
* Type-safe representation of an arbitrary, serialized Arena Tag
|
||||
*/
|
||||
export type ArenaTagTypeData = NonFunctionProperties<
|
||||
export type ArenaTagTypeData = Parameters<
|
||||
ArenaTagTypeMap[keyof {
|
||||
[K in keyof ArenaTagTypeMap as K extends SerializableArenaTagType ? K : never]: ArenaTagTypeMap[K];
|
||||
}]
|
||||
>;
|
||||
}]["loadTag"]
|
||||
>[0];
|
||||
|
||||
/** Dummy, typescript-only declaration to ensure that
|
||||
* {@linkcode ArenaTagTypeMap} has a map for all ArenaTagTypes.
|
||||
|
@ -108,6 +108,15 @@ export type SerializableBattlerTagType = keyof {
|
||||
*/
|
||||
export type NonSerializableBattlerTagType = Exclude<BattlerTagType, SerializableBattlerTagType>;
|
||||
|
||||
/**
|
||||
* Type-safe representation of an arbitrary, serialized Battler Tag
|
||||
*/
|
||||
export type BattlerTagTypeData = Parameters<
|
||||
BattlerTagTypeMap[keyof {
|
||||
[K in keyof BattlerTagTypeMap as K extends SerializableBattlerTagType ? K : never]: BattlerTagTypeMap[K];
|
||||
}]["loadTag"]
|
||||
>[0];
|
||||
|
||||
/**
|
||||
* Dummy, typescript-only declaration to ensure that
|
||||
* {@linkcode BattlerTagTypeMap} has an entry for all `BattlerTagType`s.
|
||||
|
@ -1,18 +1,14 @@
|
||||
import type { ObjectValues } from "#types/type-helpers";
|
||||
|
||||
/** Union type accepting any TS Enum or `const object`, with or without reverse mapping. */
|
||||
export type EnumOrObject = Record<string | number, string | number>;
|
||||
|
||||
/**
|
||||
* Utility type to extract the enum values from a `const object`,
|
||||
* or convert an `enum` interface produced by `typeof Enum` into the union type representing its values.
|
||||
*/
|
||||
export type EnumValues<E> = E[keyof E];
|
||||
|
||||
/**
|
||||
* Generic type constraint representing a TS numeric enum with reverse mappings.
|
||||
* @example
|
||||
* TSNumericEnum<typeof WeatherType>
|
||||
*/
|
||||
export type TSNumericEnum<T extends EnumOrObject> = number extends EnumValues<T> ? T : never;
|
||||
export type TSNumericEnum<T extends EnumOrObject> = number extends ObjectValues<T> ? T : never;
|
||||
|
||||
/** Generic type constraint representing a non reverse-mapped TS enum or `const object`. */
|
||||
export type NormalEnum<T extends EnumOrObject> = Exclude<T, TSNumericEnum<T>>;
|
@ -6,8 +6,6 @@
|
||||
import type { AbAttr } from "#abilities/ability";
|
||||
// biome-ignore-end lint/correctness/noUnusedImports: Used in a tsdoc comment
|
||||
|
||||
import type { EnumValues } from "#types/enum-types";
|
||||
|
||||
/**
|
||||
* Exactly matches the type of the argument, preventing adding additional properties.
|
||||
*
|
||||
@ -37,16 +35,25 @@ export type Mutable<T> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Type helper to obtain the keys associated with a given value inside a `const object`.
|
||||
* Type helper to obtain the keys associated with a given value inside an object.
|
||||
* @typeParam O - The type of the object
|
||||
* @typeParam V - The type of one of O's values
|
||||
*/
|
||||
export type InferKeys<O extends Record<keyof any, unknown>, V extends EnumValues<O>> = {
|
||||
export type InferKeys<O extends object, V extends ObjectValues<O>> = {
|
||||
[K in keyof O]: O[K] extends V ? K : never;
|
||||
}[keyof O];
|
||||
|
||||
/**
|
||||
* Type helper that matches any `Function` type. Equivalent to `Function`, but will not raise a warning from Biome.
|
||||
* Utility type to obtain the values of a given object. \
|
||||
* Functions similar to `keyof E`, except producing the values instead of the keys.
|
||||
* @remarks
|
||||
* This can be used to convert an `enum` interface produced by `typeof Enum` into the union type representing its members.
|
||||
*/
|
||||
export type ObjectValues<E extends object> = E[keyof E];
|
||||
|
||||
/**
|
||||
* Type helper that matches any `Function` type.
|
||||
* Equivalent to `Function`, but will not raise a warning from Biome.
|
||||
*/
|
||||
export type AnyFn = (...args: any[]) => any;
|
||||
|
||||
@ -65,6 +72,7 @@ export type NonFunctionProperties<T> = {
|
||||
|
||||
/**
|
||||
* Type helper to extract out non-function properties from a type, recursively applying to nested properties.
|
||||
* This can be used to mimic the effects of JSON serialization and de-serialization on a given type.
|
||||
*/
|
||||
export type NonFunctionPropertiesRecursive<Class> = {
|
||||
[K in keyof Class as Class[K] extends AnyFn ? never : K]: Class[K] extends Array<infer U>
|
@ -3,6 +3,7 @@
|
||||
import type { Pokemon } from "#field/pokemon";
|
||||
import type { ModifierConstructorMap } from "#modifiers/modifier";
|
||||
import type { ModifierType, WeightedModifierType } from "#modifiers/modifier-type";
|
||||
import type { ObjectValues } from "#types/type-helpers";
|
||||
|
||||
export type ModifierTypeFunc = () => ModifierType;
|
||||
export type WeightedModifierTypeWeightFunc = (party: Pokemon[], rerollCount?: number) => number;
|
||||
@ -19,7 +20,7 @@ export type ModifierInstanceMap = {
|
||||
/**
|
||||
* Union type of all modifier constructors.
|
||||
*/
|
||||
export type ModifierClass = ModifierConstructorMap[keyof ModifierConstructorMap];
|
||||
export type ModifierClass = ObjectValues<ModifierConstructorMap>;
|
||||
|
||||
/**
|
||||
* Union type of all modifier names as strings.
|
||||
|
@ -1,4 +1,5 @@
|
||||
import type { PhaseConstructorMap } from "#app/phase-manager";
|
||||
import type { ObjectValues } from "#types/type-helpers";
|
||||
|
||||
// Intentionally export the types of everything in phase-manager, as this file is meant to be
|
||||
// the centralized place for type definitions for the phase system.
|
||||
@ -17,7 +18,7 @@ export type PhaseMap = {
|
||||
/**
|
||||
* Union type of all phase constructors.
|
||||
*/
|
||||
export type PhaseClass = PhaseConstructorMap[keyof PhaseConstructorMap];
|
||||
export type PhaseClass = ObjectValues<PhaseConstructorMap>;
|
||||
|
||||
/**
|
||||
* Union type of all phase names as strings.
|
||||
|
@ -657,9 +657,7 @@ export class BattleScene extends SceneBase {
|
||||
).then(() => loadMoveAnimAssets(defaultMoves, true)),
|
||||
this.initStarterColors(),
|
||||
]).then(() => {
|
||||
this.phaseManager.pushNew("LoginPhase");
|
||||
this.phaseManager.pushNew("TitlePhase");
|
||||
|
||||
this.phaseManager.toTitleScreen(true);
|
||||
this.phaseManager.shiftPhase();
|
||||
});
|
||||
}
|
||||
@ -1269,13 +1267,12 @@ export class BattleScene extends SceneBase {
|
||||
duration: 250,
|
||||
ease: "Sine.easeInOut",
|
||||
onComplete: () => {
|
||||
this.phaseManager.clearPhaseQueue();
|
||||
|
||||
this.ui.freeUIData();
|
||||
this.uiContainer.remove(this.ui, true);
|
||||
this.uiContainer.destroy();
|
||||
this.children.removeAll(true);
|
||||
this.game.domContainer.innerHTML = "";
|
||||
// TODO: `launchBattle` calls `reset(false, false, true)`
|
||||
this.launchBattle();
|
||||
},
|
||||
});
|
||||
|
@ -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,58 +10,75 @@ 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, NonFunctionProperties } from "#types/type-helpers";
|
||||
import { BooleanHolder, isNullOrUndefined, NumberHolder, toDmgValue } from "#utils/common";
|
||||
import type { Mutable } from "#types/type-helpers";
|
||||
import { BooleanHolder, NumberHolder, toDmgValue } from "#utils/common";
|
||||
import i18next from "i18next";
|
||||
|
||||
/*
|
||||
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
|
||||
- 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
|
||||
|
||||
Any arena tag that persists across turns *must* extend from `SerializableArenaTag` in the class definition signature.
|
||||
|
||||
Serializable ArenaTags have strict rules for their fields.
|
||||
These rules ensure that only the data necessary to reconstruct the tag is serialized, and that the
|
||||
session loader is able to deserialize saved tags correctly.
|
||||
|
||||
If the data is static (i.e. it is always the same for all instances of the class, such as the
|
||||
type that is weakened by Mud Sport/Water Sport), then it must not be defined as a field, and must
|
||||
instead be defined as a getter.
|
||||
A static property is also acceptable, though static properties are less ergonomic with inheritance.
|
||||
|
||||
If the data is mutable (i.e. it can change over the course of the tag's lifetime), then it *must*
|
||||
be defined as a field, and it must be set in the `loadTag` method.
|
||||
Such fields cannot be marked as `private/protected`, as if they were, typescript would omit them from
|
||||
types that are based off of the class, namely, `ArenaTagTypeData`. It is preferrable to trade the
|
||||
type-safety of private/protected fields for the type safety when deserializing arena tags from save data.
|
||||
|
||||
For data that is mutable only within a turn (e.g. SuppressAbilitiesTag's beingRemoved field),
|
||||
where it does not make sense to be serialized, the field should use ES2020's private field syntax (a `#` prepended to the field name).
|
||||
If the field should be accessible outside of the class, then a public getter should be used.
|
||||
*/
|
||||
/**
|
||||
* @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 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
|
||||
*
|
||||
* Any arena tag that persists across turns *must* extend from `SerializableArenaTag` in the class definition signature.
|
||||
*
|
||||
* Serializable ArenaTags have strict rules for their fields.
|
||||
* These rules ensure that only the data necessary to reconstruct the tag is serialized, and that the
|
||||
* session loader is able to deserialize saved tags correctly.
|
||||
*
|
||||
* If the data is static (i.e. it is always the same for all instances of the class, such as the
|
||||
* type that is weakened by Mud Sport/Water Sport), then it must not be defined as a field, and must
|
||||
* instead be defined as a getter.
|
||||
* A static property is also acceptable, though static properties are less ergonomic with inheritance.
|
||||
*
|
||||
* If the data is mutable (i.e. it can change over the course of the tag's lifetime), then it *must*
|
||||
* be defined as a field, and it must be set in the `loadTag` method.
|
||||
* Such fields cannot be marked as `private`/`protected`; if they were, Typescript would omit them from
|
||||
* types that are based off of the class, namely, `ArenaTagTypeData`. It is preferrable to trade the
|
||||
* type-safety of private/protected fields for the type safety when deserializing arena tags from save data.
|
||||
*
|
||||
* For data that is mutable only within a turn (e.g. SuppressAbilitiesTag's beingRemoved field),
|
||||
* where it does not make sense to be serialized, the field should use ES2020's
|
||||
* [private field syntax](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Private_elements#private_fields).
|
||||
* If the field should be accessible outside of the class, then a public getter should be used.
|
||||
*
|
||||
* If any new serializable fields *are* added, then the class *must* override the
|
||||
* `loadTag` method to set the new fields. Its signature *must* match the example below,
|
||||
* ```
|
||||
* class ExampleTag extends SerializableArenaTag {
|
||||
* // Example, if we add 2 new fields that should be serialized:
|
||||
* public a: string;
|
||||
* public b: number;
|
||||
* // Then we must also define a loadTag method with one of the following signatures
|
||||
* public override loadTag(source: BaseArenaTag & Pick<ExampleTag, "tagType" | "a" | "b"): void;
|
||||
* public override loadTag<const T extends this>(source: BaseArenaTag & Pick<T, "tagType" | "a" | "b">): void;
|
||||
* public override loadTag(source: NonFunctionProperties<ExampleTag>): void;
|
||||
* }
|
||||
* ```
|
||||
* Notes
|
||||
* - If the class has any subclasses, then the second form of `loadTag` *must* be used.
|
||||
* - The third form *must not* be used if the class has any getters, as typescript would expect such fields to be
|
||||
* present in `source`.
|
||||
*/
|
||||
|
||||
/** Interface containing the serializable fields of ArenaTagData. */
|
||||
interface BaseArenaTag {
|
||||
@ -141,9 +162,9 @@ export abstract class ArenaTag implements BaseArenaTag {
|
||||
/**
|
||||
* When given a arena tag or json representing one, load the data for it.
|
||||
* This is meant to be inherited from by any arena tag with custom attributes
|
||||
* @param source - The {@linkcode BaseArenaTag} being loaded
|
||||
* @param source - The arena tag being loaded
|
||||
*/
|
||||
loadTag(source: BaseArenaTag): void {
|
||||
loadTag<const T extends this>(source: BaseArenaTag & Pick<T, "tagType">): void {
|
||||
this.turnCount = source.turnCount;
|
||||
this.sourceMove = source.sourceMove;
|
||||
this.sourceId = source.sourceId;
|
||||
@ -604,56 +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);
|
||||
}
|
||||
}
|
||||
|
||||
override loadTag(source: NonFunctionProperties<WishTag>): 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.
|
||||
*/
|
||||
@ -813,7 +784,7 @@ export abstract class ArenaTrapTag extends SerializableArenaTag {
|
||||
: Phaser.Math.Linear(0, 1 / Math.pow(2, this.layers), Math.min(pokemon.getHpRatio(), 0.5) * 2);
|
||||
}
|
||||
|
||||
loadTag(source: NonFunctionProperties<ArenaTrapTag>): void {
|
||||
public loadTag<T extends this>(source: BaseArenaTag & Pick<T, "tagType" | "layers" | "maxLayers">): void {
|
||||
super.loadTag(source);
|
||||
this.layers = source.layers;
|
||||
this.maxLayers = source.maxLayers;
|
||||
@ -1126,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,
|
||||
@ -1581,7 +1510,7 @@ export class SuppressAbilitiesTag extends SerializableArenaTag {
|
||||
this.#beingRemoved = false;
|
||||
}
|
||||
|
||||
public override loadTag(source: NonFunctionProperties<SuppressAbilitiesTag>): void {
|
||||
public override loadTag(source: BaseArenaTag & Pick<SuppressAbilitiesTag, "tagType" | "sourceCount">): void {
|
||||
super.loadTag(source);
|
||||
(this as Mutable<this>).sourceCount = source.sourceCount;
|
||||
}
|
||||
@ -1663,7 +1592,6 @@ export function getArenaTag(
|
||||
turnCount: number,
|
||||
sourceMove: MoveId | undefined,
|
||||
sourceId: number | undefined,
|
||||
targetIndex?: BattlerIndex,
|
||||
side: ArenaTagSide = ArenaTagSide.BOTH,
|
||||
): ArenaTag | null {
|
||||
switch (tagType) {
|
||||
@ -1689,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:
|
||||
@ -1739,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;
|
||||
}
|
||||
@ -1765,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;
|
||||
|
@ -69,6 +69,24 @@ import { BooleanHolder, coerceArray, getFrameMs, isNullOrUndefined, NumberHolder
|
||||
* be declared as a public readonly propety. Then, in the `loadTag` method (or any method inside the class that needs to adjust the property)
|
||||
* use `(this as Mutable<this>).propertyName = value;`
|
||||
* These rules ensure that Typescript is aware of the shape of the serialized version of the class.
|
||||
*
|
||||
* If any new serializable fields *are* added, then the class *must* override the
|
||||
* `loadTag` method to set the new fields. Its signature *must* match the example below:
|
||||
* ```
|
||||
* class ExampleTag extends SerializableBattlerTag {
|
||||
* // Example, if we add 2 new fields that should be serialized:
|
||||
* public a: string;
|
||||
* public b: number;
|
||||
* // Then we must also define a loadTag method with one of the following signatures
|
||||
* public override loadTag(source: BaseBattlerTag & Pick<ExampleTag, "tagType" | "a" | "b"): void;
|
||||
* public override loadTag<const T extends this>(source: BaseBattlerTag & Pick<T, "tagType" | "a" | "b">): void;
|
||||
* public override loadTag(source: NonFunctionProperties<ExampleTag>): void;
|
||||
* }
|
||||
* ```
|
||||
* Notes
|
||||
* - If the class has any subclasses, then the second form of `loadTag` *must* be used.
|
||||
* - The third form *must not* be used if the class has any getters, as typescript would expect such fields to be
|
||||
* present in `source`.
|
||||
*/
|
||||
|
||||
/** Interface containing the serializable fields of BattlerTag */
|
||||
@ -168,7 +186,7 @@ export class BattlerTag implements BaseBattlerTag {
|
||||
* Should be inherited from by any battler tag with custom attributes.
|
||||
* @param source - An object containing the fields needed to reconstruct this tag.
|
||||
*/
|
||||
loadTag(source: BaseBattlerTag): void {
|
||||
public loadTag<const T extends this>(source: BaseBattlerTag & Pick<T, "tagType">): void {
|
||||
this.turnCount = source.turnCount;
|
||||
this.sourceMove = source.sourceMove;
|
||||
this.sourceId = source.sourceId;
|
||||
@ -386,7 +404,7 @@ export class DisabledTag extends MoveRestrictionBattlerTag {
|
||||
});
|
||||
}
|
||||
|
||||
override loadTag(source: NonFunctionProperties<DisabledTag>): void {
|
||||
public override loadTag(source: BaseBattlerTag & Pick<DisabledTag, "tagType" | "moveId">): void {
|
||||
super.loadTag(source);
|
||||
(this as Mutable<this>).moveId = source.moveId;
|
||||
}
|
||||
@ -437,7 +455,7 @@ export class GorillaTacticsTag extends MoveRestrictionBattlerTag {
|
||||
* Loads the Gorilla Tactics Battler Tag along with its unique class variable moveId
|
||||
* @param source - Object containing the fields needed to reconstruct this tag.
|
||||
*/
|
||||
override loadTag(source: NonFunctionProperties<GorillaTacticsTag>): void {
|
||||
public override loadTag(source: BaseBattlerTag & Pick<GorillaTacticsTag, "tagType" | "moveId">): void {
|
||||
super.loadTag(source);
|
||||
(this as Mutable<GorillaTacticsTag>).moveId = source.moveId;
|
||||
}
|
||||
@ -971,6 +989,12 @@ export class InfatuatedTag extends SerializableBattlerTag {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Battler tag for the "Seeded" effect applied by {@linkcode MoveId.LEECH_SEED | Leech Seed} and
|
||||
* {@linkcode MoveId.SAPPY_SEED | Sappy Seed}
|
||||
*
|
||||
* @sealed
|
||||
*/
|
||||
export class SeedTag extends SerializableBattlerTag {
|
||||
public override readonly tagType = BattlerTagType.SEEDED;
|
||||
public readonly sourceIndex: BattlerIndex;
|
||||
@ -983,7 +1007,7 @@ export class SeedTag extends SerializableBattlerTag {
|
||||
* When given a battler tag or json representing one, load the data for it.
|
||||
* @param source - An object containing the fields needed to reconstruct this tag.
|
||||
*/
|
||||
override loadTag(source: NonFunctionProperties<SeedTag>): void {
|
||||
public override loadTag(source: BaseBattlerTag & Pick<SeedTag, "tagType" | "sourceIndex">): void {
|
||||
super.loadTag(source);
|
||||
(this as Mutable<this>).sourceIndex = source.sourceIndex;
|
||||
}
|
||||
@ -1194,6 +1218,7 @@ export class FrenzyTag extends SerializableBattlerTag {
|
||||
/**
|
||||
* Applies the effects of {@linkcode MoveId.ENCORE} onto the target Pokemon.
|
||||
* Encore forces the target Pokemon to use its most-recent move for 3 turns.
|
||||
* @sealed
|
||||
*/
|
||||
export class EncoreTag extends MoveRestrictionBattlerTag {
|
||||
public override readonly tagType = BattlerTagType.ENCORE;
|
||||
@ -1210,7 +1235,7 @@ export class EncoreTag extends MoveRestrictionBattlerTag {
|
||||
);
|
||||
}
|
||||
|
||||
override loadTag(source: NonFunctionProperties<EncoreTag>): void {
|
||||
public override loadTag(source: NonFunctionProperties<EncoreTag>): void {
|
||||
super.loadTag(source);
|
||||
this.moveId = source.moveId;
|
||||
}
|
||||
@ -2085,7 +2110,7 @@ export class HighestStatBoostTag extends AbilityBattlerTag {
|
||||
* When given a battler tag or json representing one, load the data for it.
|
||||
* @param source - An object containing the fields needed to reconstruct this tag.
|
||||
*/
|
||||
loadTag(source: NonFunctionProperties<HighestStatBoostTag>): void {
|
||||
public override loadTag<T extends this>(source: BaseBattlerTag & Pick<T, "tagType" | "stat" | "multiplier">): void {
|
||||
super.loadTag(source);
|
||||
this.stat = source.stat as Stat;
|
||||
this.multiplier = source.multiplier;
|
||||
@ -2564,6 +2589,7 @@ export class IceFaceBlockDamageTag extends FormBlockDamageTag {
|
||||
/**
|
||||
* Battler tag indicating a Tatsugiri with {@link https://bulbapedia.bulbagarden.net/wiki/Commander_(Ability) | Commander}
|
||||
* has entered the tagged Pokemon's mouth.
|
||||
* @sealed
|
||||
*/
|
||||
export class CommandedTag extends SerializableBattlerTag {
|
||||
public override readonly tagType = BattlerTagType.COMMANDED;
|
||||
@ -2607,6 +2633,7 @@ export class CommandedTag extends SerializableBattlerTag {
|
||||
* - Stat changes on removal of (all) stacks.
|
||||
* - Removing stacks decreases DEF and SPDEF, independently, by one stage for each stack that successfully changed
|
||||
* the stat when added.
|
||||
* @sealed
|
||||
*/
|
||||
export class StockpilingTag extends SerializableBattlerTag {
|
||||
public override readonly tagType = BattlerTagType.STOCKPILING;
|
||||
@ -2632,7 +2659,7 @@ export class StockpilingTag extends SerializableBattlerTag {
|
||||
}
|
||||
};
|
||||
|
||||
override loadTag(source: NonFunctionProperties<StockpilingTag>): void {
|
||||
public override loadTag(source: NonFunctionProperties<StockpilingTag>): void {
|
||||
super.loadTag(source);
|
||||
this.stockpiledCount = source.stockpiledCount || 0;
|
||||
this.statChangeCounts = {
|
||||
@ -2979,7 +3006,7 @@ export class AutotomizedTag extends SerializableBattlerTag {
|
||||
this.onAdd(pokemon);
|
||||
}
|
||||
|
||||
loadTag(source: NonFunctionProperties<AutotomizedTag>): void {
|
||||
public override loadTag(source: NonFunctionProperties<AutotomizedTag>): void {
|
||||
super.loadTag(source);
|
||||
this.autotomizeCount = source.autotomizeCount;
|
||||
}
|
||||
@ -3129,7 +3156,7 @@ export class SubstituteTag extends SerializableBattlerTag {
|
||||
* When given a battler tag or json representing one, load the data for it.
|
||||
* @param source - An object containing the necessary properties to load the tag
|
||||
*/
|
||||
override loadTag(source: NonFunctionProperties<SubstituteTag>): void {
|
||||
public override loadTag(source: BaseBattlerTag & Pick<SubstituteTag, "tagType" | "hp">): void {
|
||||
super.loadTag(source);
|
||||
this.hp = source.hp;
|
||||
}
|
||||
|
@ -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,
|
||||
@ -423,9 +425,8 @@ export abstract class Move implements Localizable {
|
||||
|
||||
/**
|
||||
* Sets the {@linkcode MoveFlags.MAKES_CONTACT} flag for the calling Move
|
||||
* @param setFlag Default `true`, set to `false` if the move doesn't make contact
|
||||
* @see {@linkcode AbilityId.STATIC}
|
||||
* @returns The {@linkcode Move} that called this function
|
||||
* @param setFlag - Whether the move should make contact; default `true`
|
||||
* @returns `this`
|
||||
*/
|
||||
makesContact(setFlag: boolean = true): this {
|
||||
this.setFlag(MoveFlags.MAKES_CONTACT, setFlag);
|
||||
@ -3123,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())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -3188,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
|
||||
*/
|
||||
@ -3576,8 +3633,7 @@ export class CutHpStatStageBoostAttr extends StatStageChangeAttr {
|
||||
/**
|
||||
* Attribute implementing the stat boosting effect of {@link https://bulbapedia.bulbagarden.net/wiki/Order_Up_(move) | Order Up}.
|
||||
* If the user has a Pokemon with {@link https://bulbapedia.bulbagarden.net/wiki/Commander_(Ability) | Commander} in their mouth,
|
||||
* one of the user's stats are increased by 1 stage, depending on the "commanding" Pokemon's form. This effect does not respect
|
||||
* effect chance, but Order Up itself may be boosted by Sheer Force.
|
||||
* one of the user's stats are increased by 1 stage, depending on the "commanding" Pokemon's form.
|
||||
*/
|
||||
export class OrderUpStatBoostAttr extends MoveEffectAttr {
|
||||
constructor() {
|
||||
@ -9205,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)
|
||||
@ -9228,7 +9287,7 @@ export function initMoves() {
|
||||
new SelfStatusMove(MoveId.STOCKPILE, PokemonType.NORMAL, -1, 20, -1, 0, 3)
|
||||
.condition(user => (user.getTag(StockpilingTag)?.stockpiledCount ?? 0) < 3)
|
||||
.attr(AddBattlerTagAttr, BattlerTagType.STOCKPILING, true),
|
||||
new AttackMove(MoveId.SPIT_UP, PokemonType.NORMAL, MoveCategory.SPECIAL, -1, -1, 10, -1, 0, 3)
|
||||
new AttackMove(MoveId.SPIT_UP, PokemonType.NORMAL, MoveCategory.SPECIAL, -1, 100, 10, -1, 0, 3)
|
||||
.condition(hasStockpileStacksCondition)
|
||||
.attr(SpitUpPowerAttr, 100)
|
||||
.attr(RemoveBattlerTagAttr, [ BattlerTagType.STOCKPILING ], true),
|
||||
@ -9293,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)
|
||||
@ -9471,7 +9530,7 @@ export function initMoves() {
|
||||
new AttackMove(MoveId.SAND_TOMB, PokemonType.GROUND, MoveCategory.PHYSICAL, 35, 85, 15, -1, 0, 3)
|
||||
.attr(TrapAttr, BattlerTagType.SAND_TOMB)
|
||||
.makesContact(false),
|
||||
new AttackMove(MoveId.SHEER_COLD, PokemonType.ICE, MoveCategory.SPECIAL, 200, 20, 5, -1, 0, 3)
|
||||
new AttackMove(MoveId.SHEER_COLD, PokemonType.ICE, MoveCategory.SPECIAL, 200, 30, 5, -1, 0, 3)
|
||||
.attr(IceNoEffectTypeAttr)
|
||||
.attr(OneHitKOAttr)
|
||||
.attr(SheerColdAccuracyAttr),
|
||||
@ -9543,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)
|
||||
@ -10393,7 +10455,7 @@ export function initMoves() {
|
||||
.attr(RemoveBattlerTagAttr, [ BattlerTagType.FLYING, BattlerTagType.FLOATING, BattlerTagType.TELEKINESIS ])
|
||||
.makesContact(false)
|
||||
.target(MoveTarget.ALL_NEAR_ENEMIES),
|
||||
new AttackMove(MoveId.THOUSAND_WAVES, PokemonType.GROUND, MoveCategory.PHYSICAL, 90, 100, 10, -1, 0, 6)
|
||||
new AttackMove(MoveId.THOUSAND_WAVES, PokemonType.GROUND, MoveCategory.PHYSICAL, 90, 100, 10, 100, 0, 6)
|
||||
.attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, false, 1, 1, true)
|
||||
.makesContact(false)
|
||||
.target(MoveTarget.ALL_NEAR_ENEMIES),
|
||||
@ -10928,7 +10990,8 @@ export function initMoves() {
|
||||
new StatusMove(MoveId.LIFE_DEW, PokemonType.WATER, -1, 10, -1, 0, 8)
|
||||
.attr(HealAttr, 0.25, true, false)
|
||||
.target(MoveTarget.USER_AND_ALLIES)
|
||||
.ignoresProtect(),
|
||||
.ignoresProtect()
|
||||
.triageMove(),
|
||||
new SelfStatusMove(MoveId.OBSTRUCT, PokemonType.DARK, 100, 10, -1, 4, 8)
|
||||
.attr(ProtectAttr, BattlerTagType.OBSTRUCT)
|
||||
.condition(failIfLastCondition),
|
||||
@ -11006,7 +11069,8 @@ export function initMoves() {
|
||||
new StatusMove(MoveId.JUNGLE_HEALING, PokemonType.GRASS, -1, 10, -1, 0, 8)
|
||||
.attr(HealAttr, 0.25, true, false)
|
||||
.attr(HealStatusEffectAttr, false, getNonVolatileStatusEffects())
|
||||
.target(MoveTarget.USER_AND_ALLIES),
|
||||
.target(MoveTarget.USER_AND_ALLIES)
|
||||
.triageMove(),
|
||||
new AttackMove(MoveId.WICKED_BLOW, PokemonType.DARK, MoveCategory.PHYSICAL, 75, 100, 5, -1, 0, 8)
|
||||
.attr(CritOnlyAttr)
|
||||
.punchingMove(),
|
||||
@ -11234,7 +11298,7 @@ export function initMoves() {
|
||||
.makesContact(false),
|
||||
new AttackMove(MoveId.LUMINA_CRASH, PokemonType.PSYCHIC, MoveCategory.SPECIAL, 80, 100, 10, 100, 0, 9)
|
||||
.attr(StatStageChangeAttr, [ Stat.SPDEF ], -2),
|
||||
new AttackMove(MoveId.ORDER_UP, PokemonType.DRAGON, MoveCategory.PHYSICAL, 80, 100, 10, 100, 0, 9)
|
||||
new AttackMove(MoveId.ORDER_UP, PokemonType.DRAGON, MoveCategory.PHYSICAL, 80, 100, 10, -1, 0, 9)
|
||||
.attr(OrderUpStatBoostAttr)
|
||||
.makesContact(false),
|
||||
new AttackMove(MoveId.JET_PUNCH, PokemonType.WATER, MoveCategory.PHYSICAL, 60, 100, 15, -1, 1, 9)
|
||||
@ -11417,7 +11481,7 @@ export function initMoves() {
|
||||
.attr(IvyCudgelTypeAttr)
|
||||
.attr(HighCritAttr)
|
||||
.makesContact(false),
|
||||
new ChargingAttackMove(MoveId.ELECTRO_SHOT, PokemonType.ELECTRIC, MoveCategory.SPECIAL, 130, 100, 10, 100, 0, 9)
|
||||
new ChargingAttackMove(MoveId.ELECTRO_SHOT, PokemonType.ELECTRIC, MoveCategory.SPECIAL, 130, 100, 10, -1, 0, 9)
|
||||
.chargeText(i18next.t("moveTriggers:absorbedElectricity", { pokemonName: "{USER}" }))
|
||||
.chargeAttr(StatStageChangeAttr, [ Stat.SPATK ], 1, true)
|
||||
.chargeAttr(WeatherInstantChargeAttr, [ WeatherType.RAIN, WeatherType.HEAVY_RAIN ]),
|
||||
|
70
src/data/positional-tags/load-positional-tag.ts
Normal file
70
src/data/positional-tags/load-positional-tag.ts
Normal 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>;
|
55
src/data/positional-tags/positional-tag-manager.ts
Normal file
55
src/data/positional-tags/positional-tag-manager.ts
Normal 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;
|
||||
}
|
||||
}
|
174
src/data/positional-tags/positional-tag.ts
Normal file
174
src/data/positional-tags/positional-tag.ts
Normal 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();
|
||||
}
|
||||
}
|
@ -1,3 +1,5 @@
|
||||
import type { ObjectValues } from "#types/type-helpers";
|
||||
|
||||
/**
|
||||
* Not to be confused with an Ability Attribute.
|
||||
* This is an object literal storing the slot that an ability can occupy.
|
||||
@ -8,4 +10,4 @@ export const AbilityAttr = Object.freeze({
|
||||
ABILITY_HIDDEN: 4,
|
||||
});
|
||||
|
||||
export type AbilityAttr = typeof AbilityAttr[keyof typeof AbilityAttr];
|
||||
export type AbilityAttr = ObjectValues<typeof AbilityAttr>;
|
@ -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",
|
||||
|
@ -1,3 +1,5 @@
|
||||
import type { ObjectValues } from "#types/type-helpers";
|
||||
|
||||
export const DexAttr = Object.freeze({
|
||||
NON_SHINY: 1n,
|
||||
SHINY: 2n,
|
||||
@ -8,4 +10,4 @@ export const DexAttr = Object.freeze({
|
||||
VARIANT_3: 64n,
|
||||
DEFAULT_FORM: 128n,
|
||||
});
|
||||
export type DexAttr = typeof DexAttr[keyof typeof DexAttr];
|
||||
export type DexAttr = ObjectValues<typeof DexAttr>;
|
||||
|
@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Enum representation of the phase types held by implementations of {@linkcode PhasePriorityQueue}
|
||||
* Enum representation of the phase types held by implementations of {@linkcode PhasePriorityQueue}.
|
||||
*/
|
||||
// TODO: We currently assume these are in order
|
||||
export enum DynamicPhaseType {
|
||||
POST_SUMMON
|
||||
}
|
||||
|
@ -1,7 +1,9 @@
|
||||
import type { ObjectValues } from "#types/type-helpers";
|
||||
|
||||
export const GachaType = Object.freeze({
|
||||
MOVE: 0,
|
||||
LEGENDARY: 1,
|
||||
SHINY: 2
|
||||
});
|
||||
|
||||
export type GachaType = typeof GachaType[keyof typeof GachaType];
|
||||
export type GachaType = ObjectValues<typeof GachaType>;
|
||||
|
@ -1,3 +1,5 @@
|
||||
import type { ObjectValues } from "#types/type-helpers";
|
||||
|
||||
/** The result of a hit check calculation */
|
||||
export const HitCheckResult = {
|
||||
/** Hit checks haven't been evaluated yet in this pass */
|
||||
@ -20,4 +22,4 @@ export const HitCheckResult = {
|
||||
ERROR: 8,
|
||||
} as const;
|
||||
|
||||
export type HitCheckResult = typeof HitCheckResult[keyof typeof HitCheckResult];
|
||||
export type HitCheckResult = ObjectValues<typeof HitCheckResult>;
|
||||
|
@ -4,15 +4,19 @@
|
||||
*/
|
||||
export enum MoveFlags {
|
||||
NONE = 0,
|
||||
/**
|
||||
* Whether the move makes contact.
|
||||
* Set by default on all contact moves, and unset by default on all special moves.
|
||||
*/
|
||||
MAKES_CONTACT = 1 << 0,
|
||||
IGNORE_PROTECT = 1 << 1,
|
||||
/**
|
||||
* Sound-based moves have the following effects:
|
||||
* - Pokemon with the {@linkcode AbilityId.SOUNDPROOF Soundproof Ability} are unaffected by other Pokemon's sound-based moves.
|
||||
* - Pokemon affected by {@linkcode MoveId.THROAT_CHOP Throat Chop} cannot use sound-based moves for two turns.
|
||||
* - Sound-based moves used by a Pokemon with {@linkcode AbilityId.LIQUID_VOICE Liquid Voice} become Water-type moves.
|
||||
* - Sound-based moves used by a Pokemon with {@linkcode AbilityId.PUNK_ROCK Punk Rock} are boosted by 30%. Pokemon with Punk Rock also take half damage from sound-based moves.
|
||||
* - All sound-based moves (except Howl) can hit Pokemon behind an active {@linkcode MoveId.SUBSTITUTE Substitute}.
|
||||
* - Pokemon with the {@linkcode AbilityId.SOUNDPROOF | Soundproof} Ability are unaffected by other Pokemon's sound-based moves.
|
||||
* - Pokemon affected by {@linkcode MoveId.THROAT_CHOP | Throat Chop} cannot use sound-based moves for two turns.
|
||||
* - Sound-based moves used by a Pokemon with {@linkcode AbilityId.LIQUID_VOICE | Liquid Voice} become Water-type moves.
|
||||
* - Sound-based moves used by a Pokemon with {@linkcode AbilityId.PUNK_ROCK | Punk Rock} are boosted by 30%. Pokemon with Punk Rock also take half damage from sound-based moves.
|
||||
* - All sound-based moves (except Howl) can hit Pokemon behind an active {@linkcode MoveId.SUBSTITUTE | Substitute}.
|
||||
*
|
||||
* cf https://bulbapedia.bulbagarden.net/wiki/Sound-based_move
|
||||
*/
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
10
src/enums/positional-tag-type.ts
Normal file
10
src/enums/positional-tag-type.ts
Normal 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",
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -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,
|
||||
@ -242,6 +244,21 @@ export class PhaseManager {
|
||||
this.dynamicPhaseTypes = [PostSummonPhase];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all previously set phases, then add a new {@linkcode TitlePhase} to transition to the title screen.
|
||||
* @param addLogin - Whether to add a new {@linkcode LoginPhase} before the {@linkcode TitlePhase}
|
||||
* (but reset everything else).
|
||||
* Default `false`
|
||||
*/
|
||||
public toTitleScreen(addLogin = false): void {
|
||||
this.clearAllPhases();
|
||||
|
||||
if (addLogin) {
|
||||
this.unshiftNew("LoginPhase");
|
||||
}
|
||||
this.unshiftNew("TitlePhase");
|
||||
}
|
||||
|
||||
/* Phase Functions */
|
||||
getCurrentPhase(): Phase | null {
|
||||
return this.currentPhase;
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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));
|
||||
|
21
src/phases/positional-tag-phase.ts
Normal file
21
src/phases/positional-tag-phase.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -24,10 +24,11 @@ export class SelectStarterPhase extends Phase {
|
||||
globalScene.ui.setMode(UiMode.STARTER_SELECT, (starters: Starter[]) => {
|
||||
globalScene.ui.clearText();
|
||||
globalScene.ui.setMode(UiMode.SAVE_SLOT, SaveSlotUiMode.SAVE, (slotId: number) => {
|
||||
// If clicking cancel, back out to title screen
|
||||
if (slotId === -1) {
|
||||
globalScene.phaseManager.clearPhaseQueue();
|
||||
globalScene.phaseManager.pushNew("TitlePhase");
|
||||
return this.end();
|
||||
globalScene.phaseManager.toTitleScreen();
|
||||
this.end();
|
||||
return;
|
||||
}
|
||||
globalScene.sessionSlotId = slotId;
|
||||
this.initBattle(starters);
|
||||
|
@ -114,11 +114,11 @@ export class TitlePhase extends Phase {
|
||||
});
|
||||
}
|
||||
}
|
||||
// Cancel button = back to title
|
||||
options.push({
|
||||
label: i18next.t("menu:cancel"),
|
||||
handler: () => {
|
||||
globalScene.phaseManager.clearPhaseQueue();
|
||||
globalScene.phaseManager.pushNew("TitlePhase");
|
||||
globalScene.phaseManager.toTitleScreen();
|
||||
super.end();
|
||||
return true;
|
||||
},
|
||||
@ -191,11 +191,12 @@ export class TitlePhase extends Phase {
|
||||
initDailyRun(): void {
|
||||
globalScene.ui.clearText();
|
||||
globalScene.ui.setMode(UiMode.SAVE_SLOT, SaveSlotUiMode.SAVE, (slotId: number) => {
|
||||
globalScene.phaseManager.clearPhaseQueue();
|
||||
if (slotId === -1) {
|
||||
globalScene.phaseManager.pushNew("TitlePhase");
|
||||
return super.end();
|
||||
globalScene.phaseManager.toTitleScreen();
|
||||
super.end();
|
||||
return;
|
||||
}
|
||||
globalScene.phaseManager.clearPhaseQueue();
|
||||
globalScene.sessionSlotId = slotId;
|
||||
|
||||
const generateDaily = (seed: string) => {
|
||||
|
@ -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");
|
||||
|
||||
/*
|
||||
|
@ -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 ?? [];
|
||||
}
|
||||
}
|
||||
|
@ -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 = [];
|
||||
|
@ -382,8 +382,7 @@ export class GameChallengesUiHandler extends UiHandler {
|
||||
this.cursorObj?.setVisible(true);
|
||||
this.updateChallengeArrows(this.startCursor.visible);
|
||||
} else {
|
||||
globalScene.phaseManager.clearPhaseQueue();
|
||||
globalScene.phaseManager.pushNew("TitlePhase");
|
||||
globalScene.phaseManager.toTitleScreen();
|
||||
globalScene.phaseManager.getCurrentPhase()?.end();
|
||||
}
|
||||
success = true;
|
||||
|
@ -4303,7 +4303,10 @@ export class StarterSelectUiHandler extends MessageUiHandler {
|
||||
return true;
|
||||
}
|
||||
|
||||
tryExit(): boolean {
|
||||
/**
|
||||
* Attempt to back out of the starter selection screen into the appropriate parent modal
|
||||
*/
|
||||
tryExit(): void {
|
||||
this.blockInput = true;
|
||||
const ui = this.getUi();
|
||||
|
||||
@ -4317,12 +4320,13 @@ export class StarterSelectUiHandler extends MessageUiHandler {
|
||||
UiMode.CONFIRM,
|
||||
() => {
|
||||
ui.setMode(UiMode.STARTER_SELECT);
|
||||
globalScene.phaseManager.clearPhaseQueue();
|
||||
if (globalScene.gameMode.isChallenge) {
|
||||
// Non-challenge modes go directly back to title, while challenge modes go to the selection screen.
|
||||
if (!globalScene.gameMode.isChallenge) {
|
||||
globalScene.phaseManager.toTitleScreen();
|
||||
} else {
|
||||
globalScene.phaseManager.clearPhaseQueue();
|
||||
globalScene.phaseManager.pushNew("SelectChallengePhase");
|
||||
globalScene.phaseManager.pushNew("EncounterPhase");
|
||||
} else {
|
||||
globalScene.phaseManager.pushNew("TitlePhase");
|
||||
}
|
||||
this.clearText();
|
||||
globalScene.phaseManager.getCurrentPhase()?.end();
|
||||
@ -4333,8 +4337,6 @@ export class StarterSelectUiHandler extends MessageUiHandler {
|
||||
19,
|
||||
);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
tryStart(manualTrigger = false): boolean {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import type { EnumOrObject, EnumValues, NormalEnum, TSNumericEnum } from "#app/@types/enum-types";
|
||||
import type { InferKeys } from "#app/@types/type-helpers";
|
||||
import type { EnumOrObject, NormalEnum, TSNumericEnum } from "#types/enum-types";
|
||||
import type { InferKeys, ObjectValues } from "#types/type-helpers";
|
||||
|
||||
/**
|
||||
* Return the string keys of an Enum object, excluding reverse-mapped numbers.
|
||||
@ -61,7 +61,7 @@ export function getEnumValues<E extends EnumOrObject>(enumType: TSNumericEnum<E>
|
||||
* If multiple keys map to the same value, the first one (in insertion order) will be retrieved,
|
||||
* but the return type will be the union of ALL their corresponding keys.
|
||||
*/
|
||||
export function enumValueToKey<T extends EnumOrObject, V extends EnumValues<T>>(
|
||||
export function enumValueToKey<T extends EnumOrObject, V extends ObjectValues<T>>(
|
||||
object: NormalEnum<T>,
|
||||
val: V,
|
||||
): InferKeys<T, V> {
|
||||
|
389
test/moves/delayed-attack.test.ts
Normal file
389
test/moves/delayed-attack.test.ts
Normal 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");
|
||||
});
|
@ -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.field.getEnemyPokemon().isFullHp()).toBe(false);
|
||||
});
|
||||
});
|
@ -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.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);
|
||||
});
|
||||
|
||||
|
@ -65,23 +65,4 @@ describe("Moves - Order Up", () => {
|
||||
affectedStats.forEach(st => expect(dondozo.getStatStage(st)).toBe(st === stat ? 3 : 2));
|
||||
},
|
||||
);
|
||||
|
||||
it("should be boosted by Sheer Force while still applying a stat boost", async () => {
|
||||
game.override.passiveAbility(AbilityId.SHEER_FORCE).starterForms({ [SpeciesId.TATSUGIRI]: 0 });
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.TATSUGIRI, SpeciesId.DONDOZO]);
|
||||
|
||||
const [tatsugiri, dondozo] = game.scene.getPlayerField();
|
||||
|
||||
expect(game.scene.triggerPokemonBattleAnim).toHaveBeenLastCalledWith(tatsugiri, PokemonAnimType.COMMANDER_APPLY);
|
||||
expect(dondozo.getTag(BattlerTagType.COMMANDED)).toBeDefined();
|
||||
|
||||
game.move.select(MoveId.ORDER_UP, 1, BattlerIndex.ENEMY);
|
||||
expect(game.scene.currentBattle.turnCommands[0]?.skip).toBeTruthy();
|
||||
|
||||
await game.phaseInterceptor.to("BerryPhase", false);
|
||||
|
||||
expect(dondozo.waveData.abilitiesApplied.has(AbilityId.SHEER_FORCE)).toBeTruthy();
|
||||
expect(dondozo.getStatStage(Stat.ATK)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
183
test/moves/wish.test.ts
Normal file
183
test/moves/wish.test.ts
Normal 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);
|
||||
});
|
||||
});
|
@ -103,12 +103,9 @@ export class GameManager {
|
||||
if (!firstTimeScene) {
|
||||
this.scene.reset(false, true);
|
||||
(this.scene.ui.handlers[UiMode.STARTER_SELECT] as StarterSelectUiHandler).clearStarterPreferences();
|
||||
this.scene.phaseManager.clearAllPhases();
|
||||
|
||||
// Must be run after phase interceptor has been initialized.
|
||||
|
||||
this.scene.phaseManager.pushNew("LoginPhase");
|
||||
this.scene.phaseManager.pushNew("TitlePhase");
|
||||
this.scene.phaseManager.toTitleScreen(true);
|
||||
this.scene.phaseManager.shiftPhase();
|
||||
|
||||
this.gameWrapper.scene = this.scene;
|
||||
|
@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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");
|
||||
|
@ -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],
|
||||
|
@ -1,5 +1,6 @@
|
||||
import type { EnumOrObject, EnumValues, NormalEnum, TSNumericEnum } from "#app/@types/enum-types";
|
||||
import type { enumValueToKey, getEnumKeys, getEnumValues } from "#app/utils/enums";
|
||||
import type { EnumOrObject, NormalEnum, TSNumericEnum } from "#types/enum-types";
|
||||
import type { ObjectValues } from "#types/type-helpers";
|
||||
import { describe, expectTypeOf, it } from "vitest";
|
||||
|
||||
enum testEnumNum {
|
||||
@ -16,21 +17,33 @@ const testObjNum = { testON1: 1, testON2: 2 } as const;
|
||||
|
||||
const testObjString = { testOS1: "apple", testOS2: "banana" } as const;
|
||||
|
||||
describe("Enum Type Helpers", () => {
|
||||
describe("EnumValues", () => {
|
||||
it("should go from enum object type to value type", () => {
|
||||
expectTypeOf<EnumValues<typeof testEnumNum>>().toEqualTypeOf<testEnumNum>();
|
||||
expectTypeOf<EnumValues<typeof testEnumNum>>().branded.toEqualTypeOf<1 | 2>();
|
||||
interface testObject {
|
||||
key_1: "1";
|
||||
key_2: "2";
|
||||
key_3: "3";
|
||||
}
|
||||
|
||||
expectTypeOf<EnumValues<typeof testEnumString>>().toEqualTypeOf<testEnumString>();
|
||||
expectTypeOf<EnumValues<typeof testEnumString>>().toEqualTypeOf<testEnumString.testS1 | testEnumString.testS2>();
|
||||
expectTypeOf<EnumValues<typeof testEnumString>>().toMatchTypeOf<"apple" | "banana">();
|
||||
describe("Enum Type Helpers", () => {
|
||||
describe("ObjectValues", () => {
|
||||
it("should produce a union of an object's values", () => {
|
||||
expectTypeOf<ObjectValues<testObject>>().toEqualTypeOf<"1" | "2" | "3">();
|
||||
});
|
||||
|
||||
it("should go from enum object type to value type", () => {
|
||||
expectTypeOf<ObjectValues<typeof testEnumNum>>().toEqualTypeOf<testEnumNum>();
|
||||
expectTypeOf<ObjectValues<typeof testEnumNum>>().branded.toEqualTypeOf<1 | 2>();
|
||||
|
||||
expectTypeOf<ObjectValues<typeof testEnumString>>().toEqualTypeOf<testEnumString>();
|
||||
expectTypeOf<ObjectValues<typeof testEnumString>>().toEqualTypeOf<
|
||||
testEnumString.testS1 | testEnumString.testS2
|
||||
>();
|
||||
|
||||
expectTypeOf<ObjectValues<typeof testEnumString>>().toExtend<"apple" | "banana">();
|
||||
});
|
||||
|
||||
it("should produce union of const object values as type", () => {
|
||||
expectTypeOf<EnumValues<typeof testObjNum>>().toEqualTypeOf<1 | 2>();
|
||||
|
||||
expectTypeOf<EnumValues<typeof testObjString>>().toEqualTypeOf<"apple" | "banana">();
|
||||
expectTypeOf<ObjectValues<typeof testObjNum>>().toEqualTypeOf<1 | 2>();
|
||||
expectTypeOf<ObjectValues<typeof testObjString>>().toEqualTypeOf<"apple" | "banana">();
|
||||
});
|
||||
});
|
||||
|
||||
@ -38,7 +51,6 @@ describe("Enum Type Helpers", () => {
|
||||
it("should match numeric enums", () => {
|
||||
expectTypeOf<TSNumericEnum<typeof testEnumNum>>().toEqualTypeOf<typeof testEnumNum>();
|
||||
});
|
||||
|
||||
it("should not match string enums or const objects", () => {
|
||||
expectTypeOf<TSNumericEnum<typeof testEnumString>>().toBeNever();
|
||||
expectTypeOf<TSNumericEnum<typeof testObjNum>>().toBeNever();
|
||||
@ -59,19 +71,19 @@ describe("Enum Type Helpers", () => {
|
||||
|
||||
describe("EnumOrObject", () => {
|
||||
it("should match any enum or const object", () => {
|
||||
expectTypeOf<typeof testEnumNum>().toMatchTypeOf<EnumOrObject>();
|
||||
expectTypeOf<typeof testEnumString>().toMatchTypeOf<EnumOrObject>();
|
||||
expectTypeOf<typeof testObjNum>().toMatchTypeOf<EnumOrObject>();
|
||||
expectTypeOf<typeof testObjString>().toMatchTypeOf<EnumOrObject>();
|
||||
expectTypeOf<typeof testEnumNum>().toExtend<EnumOrObject>();
|
||||
expectTypeOf<typeof testEnumString>().toExtend<EnumOrObject>();
|
||||
expectTypeOf<typeof testObjNum>().toExtend<EnumOrObject>();
|
||||
expectTypeOf<typeof testObjString>().toExtend<EnumOrObject>();
|
||||
});
|
||||
|
||||
it("should not match an enum value union w/o typeof", () => {
|
||||
expectTypeOf<testEnumNum>().not.toMatchTypeOf<EnumOrObject>();
|
||||
expectTypeOf<testEnumString>().not.toMatchTypeOf<EnumOrObject>();
|
||||
expectTypeOf<testEnumNum>().not.toExtend<EnumOrObject>();
|
||||
expectTypeOf<testEnumString>().not.toExtend<EnumOrObject>();
|
||||
});
|
||||
|
||||
it("should be equivalent to `TSNumericEnum | NormalEnum`", () => {
|
||||
expectTypeOf<EnumOrObject>().branded.toEqualTypeOf<TSNumericEnum<EnumOrObject> | NormalEnum<EnumOrObject>>();
|
||||
expectTypeOf<EnumOrObject>().toEqualTypeOf<TSNumericEnum<EnumOrObject> | NormalEnum<EnumOrObject>>();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -80,6 +92,7 @@ describe("Enum Functions", () => {
|
||||
describe("getEnumKeys", () => {
|
||||
it("should retrieve keys of numeric enum", () => {
|
||||
expectTypeOf<typeof getEnumKeys<typeof testEnumNum>>().returns.toEqualTypeOf<("testN1" | "testN2")[]>();
|
||||
expectTypeOf<typeof getEnumKeys<typeof testObjNum>>().returns.toEqualTypeOf<("testON1" | "testON2")[]>();
|
||||
});
|
||||
});
|
||||
|
||||
|
29
test/types/positional-tags.test-d.ts
Normal file
29
test/types/positional-tags.test-d.ts
Normal 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>();
|
||||
});
|
||||
});
|
@ -49,7 +49,7 @@
|
||||
"./system/*.ts"
|
||||
],
|
||||
"#trainers/*": ["./data/trainers/*.ts"],
|
||||
"#types/*": ["./@types/*.ts", "./typings/phaser/*.ts"],
|
||||
"#types/*": ["./@types/helpers/*.ts", "./@types/*.ts", "./typings/phaser/*.ts"],
|
||||
"#ui/*": ["./ui/battle-info/*.ts", "./ui/settings/*.ts", "./ui/*.ts"],
|
||||
"#utils/*": ["./utils/*.ts"],
|
||||
"#data/*": ["./data/pokemon-forms/*.ts", "./data/pokemon/*.ts", "./data/*.ts"],
|
||||
|
Loading…
Reference in New Issue
Block a user