[Misc] Improve type signatures of serialized arena/battler tags (#6180)

* Improve type signatures of serialized arena/battler tags

* Minor adjustments to tsdocs from code review

Co-authored-by: Bertie690 <136088738+Bertie690@users.noreply.github.com>

---------

Co-authored-by: Bertie690 <136088738+Bertie690@users.noreply.github.com>
This commit is contained in:
Sirz Benjie 2025-07-30 20:59:14 -06:00 committed by GitHub
parent 1ae69a4183
commit 9475505cd2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 105 additions and 48 deletions

View File

@ -1,6 +1,5 @@
import type { ArenaTagTypeMap } from "#data/arena-tag";
import type { ArenaTagType } from "#enums/arena-tag-type";
import type { NonFunctionProperties } from "#types/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 =
@ -30,13 +29,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.

View File

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

View File

@ -26,38 +26,58 @@ import type {
ArenaTrapTagType,
SerializableArenaTagType,
} from "#types/arena-tags";
import type { Mutable, NonFunctionProperties } from "#types/type-helpers";
import type { Mutable } from "#types/type-helpers";
import { BooleanHolder, isNullOrUndefined, 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 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`; 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 +161,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;
@ -646,7 +666,9 @@ class WishTag extends SerializableArenaTag {
}
}
override loadTag(source: NonFunctionProperties<WishTag>): void {
public override loadTag<const T extends this>(
source: BaseArenaTag & Pick<T, "tagType" | "healHp" | "sourceName" | "battlerIndex">,
): void {
super.loadTag(source);
(this as Mutable<this>).battlerIndex = source.battlerIndex;
(this as Mutable<this>).healHp = source.healHp;
@ -813,7 +835,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;
@ -1581,7 +1603,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;
}

View File

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