diff --git a/src/@types/battler-tags.ts b/src/@types/battler-tags.ts index 211eb25113d..bdd69fdf76c 100644 --- a/src/@types/battler-tags.ts +++ b/src/@types/battler-tags.ts @@ -126,6 +126,11 @@ export type BattlerTagTypeData = Parameters< }]["loadTag"] >[0]; +/** + * Subset of {@linkcode BattlerTagType}s whose associated `BattlerTag` adds a serializable `moveId` field + */ +export type BattlerTagTypeWithMoveId = BattlerTagType.DISABLED | BattlerTagType.GORILLA_TACTICS | BattlerTagType.ENCORE; + /** * Dummy, typescript-only declaration to ensure that * {@linkcode BattlerTagTypeMap} has an entry for all `BattlerTagType`s. diff --git a/src/@types/helpers/schema-helpers.ts b/src/@types/helpers/schema-helpers.ts new file mode 100644 index 00000000000..9787481f5f8 --- /dev/null +++ b/src/@types/helpers/schema-helpers.ts @@ -0,0 +1,48 @@ +import type { ObjectValues } from "#types/type-helpers"; +import type { z } from "zod"; + +/** + * Fake a discriminated union type for Zod Schemas. + * In essence, allows a particular field that is really a union + * to coerce to a zod type that emits a UNION of schemas. + * + * @typeParam Choices - The set of enum values that the union can take. + * @typeParam BaseZodSchema - The base zod schema to extend. + * @typeParam FieldName - The name of the field that acts as the discriminator. **Must be a string literal** + * + * @remarks + * There are times where we want to allow a field in the zod schema to take one of + * a set of literal values, but we want to treat it as a discriminated union. + * + * For instance, if we have a zod schema like this: + * z.object({ + * field: z.literal(["Foo", "Bar"]) + * }) + * + * then pass it into some function which expects field to be either "Foo" or "Bar", + * then it would not work. This is because + * { field: "Foo" | "Bar" } + * would not be considered compatible with + * { field: "Foo" } | { field: "Bar"} + * + * While it is possible to make use of Zod's discriminated union, this + * ends up defining way too many additional types, which is not readable, + * and is substantially harder to maintain. + * + * Instead, this type helper can be used as a type assertion on the actual zod schema. + * Note that it *does* require defining a base schema separately, which does not isnclude + * the discriminator field. + * + * This is a hacky way to get around typescript's limitations. + */ +export type DiscriminatedUnionFake< + Choices extends string | number, + BaseZodSchema extends Record, + FieldName extends string, +> = ObjectValues<{ + [Choice in Choices]: z.ZodObject< + BaseZodSchema & { + [F in FieldName]: z.ZodLiteral; + } + >; +}>; diff --git a/src/@types/helpers/type-helpers.ts b/src/@types/helpers/type-helpers.ts index 37f97fcf08c..68a82124a7a 100644 --- a/src/@types/helpers/type-helpers.ts +++ b/src/@types/helpers/type-helpers.ts @@ -44,12 +44,20 @@ export type InferKeys> = { }[keyof O]; /** - * Utility type to obtain the values of a given object. \ - * Functions similar to `keyof E`, except producing the values instead of the keys. + * Type helper to construct a union type of the type of each field in an object. + * + * @typeParam T - The type of the object to extract values from. + * * @remarks - * This can be used to convert an `enum` interface produced by `typeof Enum` into the union type representing its members. + * Can be used to convert an `enum` interface produced by `typeof Enum` into the union type representing its members. + * + * @example + * ```ts + * type oneThruThree = ObjectValues<{ a: 1, b: 2, c: 3 }> + * // ^? 1 | 2 | 3 + * ``` */ -export type ObjectValues = E[keyof E]; +export type ObjectValues = T[keyof T]; /** * Type helper that matches any `Function` type. @@ -64,6 +72,7 @@ export type AnyFn = (...args: any[]) => any; * Useful to produce a type that is roughly the same as the type of `{... obj}`, where `obj` is an instance of `T`. * A couple of differences: * - Private and protected properties are not included. + * - Accessors with getters *are* included * - Nested properties are not recursively extracted. For this, use {@linkcode NonFunctionPropertiesRecursive} */ export type NonFunctionProperties = { diff --git a/src/system/schemas/arena/arena-tag.ts b/src/system/schemas/arena/arena-tag.ts index e69de29bb2d..92d9d185b68 100644 --- a/src/system/schemas/arena/arena-tag.ts +++ b/src/system/schemas/arena/arena-tag.ts @@ -0,0 +1,111 @@ +// biome-ignore-start lint/correctness/noUnusedImports: used in tsdoc comment +import { type ArenaTrapTag, loadArenaTag, type SerializableArenaTag } from "#data/arena-tag"; +import type { ArenaTagSide } from "#enums/arena-tag-side"; +import { ArenaTagType } from "#enums/arena-tag-type"; +// biome-ignore-end lint/correctness/noUnusedImports: end + +import { Z$NonNegativeInt, Z$PositiveInt } from "#system/schemas/common"; +import type { ArenaTrapTagType, SerializableArenaTagType } from "#types/arena-tags"; +import type { DiscriminatedUnionFake } from "#types/schema-helpers"; +import { z } from "zod"; + +/** + * Zod schema for {@linkcode ArenaTagSide} as of version 1.10 + * + * @remarks + * - `0`: BOTH + * - `1`: PLAYER + * - `2`: ENEMY + */ +export const Z$ArenaTagSide = z.literal([0, 1, 2]); + +/** + * The base shape of an arena tag, consisting of all of the fields other than + * Zod schema for {@linkcode SerializableArenaTag} as of version 1.10 + */ +const Z$BaseArenaTag = z.object({ + turnCount: z.int(), + sourceMove: Z$NonNegativeInt.optional().catch(undefined), + sourceId: z.int().or(z.undefined()).catch(undefined), + side: Z$ArenaTagSide, +}); + +// #region typescript-hackery to extract out the proper zod schema type + +/** + * Arena tag type that has no extra additional fields. + */ +type BasicArenaTag = + | Exclude + | ArenaTagType.NONE; + +//#endregion: typescript-hackery + +/** + * Zod enum for arena tags with no additional properties. + * If a new ArenaTagType that has no additional properties is added, + * this MUST be updated to include it. + */ +const Z$BaseArenaTagEnum = z.literal([ + ArenaTagType.NONE, + ArenaTagType.MUD_SPORT, + ArenaTagType.WATER_SPORT, + ArenaTagType.MIST, + ArenaTagType.TRICK_ROOM, + ArenaTagType.GRAVITY, + ArenaTagType.REFLECT, + ArenaTagType.LIGHT_SCREEN, + ArenaTagType.AURORA_VEIL, + ArenaTagType.TAILWIND, + ArenaTagType.HAPPY_HOUR, + ArenaTagType.SAFEGUARD, + ArenaTagType.NO_CRIT, + ArenaTagType.FIRE_GRASS_PLEDGE, + ArenaTagType.WATER_FIRE_PLEDGE, + ArenaTagType.GRASS_WATER_PLEDGE, + ArenaTagType.FAIRY_LOCK, +]) satisfies z.ZodLiteral; + +/** + * Zod schema for the subset of {@linkcode ArenaTagType}s + * that add no additional serializable fields. + */ +const Z$PlainArenaTag = z.object({ + ...Z$BaseArenaTag.shape, + tagType: Z$BaseArenaTagEnum, +}) as DiscriminatedUnionFake; + +const Z$BaseTrapTag = /** __@PURE__ */ z.object({ + ...Z$BaseArenaTag.shape, + layers: z.int().min(1).max(3).catch(1), + maxLayers: z.int().min(1).max(3), +}); + +/** + * Zod schema for {@linkcode ArenaTrapTag} as of version 1.10 + */ +const Z$ArenaTrapTag = /** __@PURE__ */ z.object({ + ...Z$BaseTrapTag.shape, + tagType: z.literal([ + ArenaTagType.STICKY_WEB, + ArenaTagType.SPIKES, + ArenaTagType.TOXIC_SPIKES, + ArenaTagType.STEALTH_ROCK, + ArenaTagType.IMPRISON, + ] satisfies ArenaTrapTagType[]), +}) as DiscriminatedUnionFake; + +/** + * Zod schema for {@linkcode ArenaTagType.NEUTRALIZING_GAS} as of version 1.10 + */ +const Z$SuppressAbilitiesTag = /** __@PURE__ */ z.object({ + ...Z$BaseArenaTag.shape, + tagType: z.literal(ArenaTagType.NEUTRALIZING_GAS), + sourceCount: Z$PositiveInt, +}); + +/** + * Zod schema for {@linkcode SerializableArenaTag}s as of version 1.10, + * also permitting "NoneTag". + */ +export const Z$ArenaTag = z.discriminatedUnion("tagType", [Z$ArenaTrapTag, Z$SuppressAbilitiesTag, Z$PlainArenaTag]); diff --git a/src/system/schemas/pokemon/battler-tag.ts b/src/system/schemas/pokemon/battler-tag.ts index 4a43f0038ee..4203b054b18 100644 --- a/src/system/schemas/pokemon/battler-tag.ts +++ b/src/system/schemas/pokemon/battler-tag.ts @@ -1,5 +1,13 @@ +import { BattlerTagType } from "#enums/battler-tag-type"; import { Z$NonNegativeInt, Z$PositiveInt } from "#system/schemas/common"; import { Z$BattlerIndex } from "#system/schemas/pokemon/battler-index"; +import { Z$Stat } from "#system/schemas/pokemon/pokemon-stats"; +import type { + BattlerTagTypeWithMoveId, + HighestStatBoostTagType, + SerializableBattlerTagType, +} from "#types/battler-tags"; +import type { DiscriminatedUnionFake } from "#types/schema-helpers"; import { z } from "zod"; /* @@ -7,107 +15,89 @@ Schemas for battler tags are a bit more cumbersome, as we need to have schemas for each subclass that has a different shape. */ +type BasicBattlerTag = Exclude; + /** - * Zod schema for the {@linkcode BattlerTagType} enum as of version 1.10 + * Zod enum of {@linkcode BattlerTagType}s whose associated `BattlerTag` adds no + * additional fields that are serialized. + * */ -const Z$BattlerTagType = z.literal([ - "NONE", - "RECHARGING", - "FLINCHED", - "INTERRUPTED", - "CONFUSED", - "INFATUATED", - "SEEDED", - "NIGHTMARE", - "FRENZY", - "CHARGING", - "ENCORE", - "HELPING_HAND", - "INGRAIN", - "OCTOLOCK", - "AQUA_RING", - "DROWSY", - "TRAPPED", - "BIND", - "WRAP", - "FIRE_SPIN", - "WHIRLPOOL", - "CLAMP", - "SAND_TOMB", - "MAGMA_STORM", - "SNAP_TRAP", - "THUNDER_CAGE", - "INFESTATION", - "PROTECTED", - "SPIKY_SHIELD", - "KINGS_SHIELD", - "OBSTRUCT", - "SILK_TRAP", - "BANEFUL_BUNKER", - "BURNING_BULWARK", - "ENDURING", - "STURDY", - "PERISH_SONG", - "TRUANT", - "SLOW_START", - "PROTOSYNTHESIS", - "QUARK_DRIVE", - "FLYING", - "UNDERGROUND", - "UNDERWATER", - "HIDDEN", - "FIRE_BOOST", - "CRIT_BOOST", - "ALWAYS_CRIT", - "IGNORE_ACCURACY", - "BYPASS_SLEEP", - "IGNORE_FLYING", - "SALT_CURED", - "CURSED", - "CHARGED", - "ROOSTED", - "FLOATING", - "MINIMIZED", - "DESTINY_BOND", - "CENTER_OF_ATTENTION", - "ICE_FACE", - "DISGUISE", - "STOCKPILING", - "RECEIVE_DOUBLE_DAMAGE", - "ALWAYS_GET_HIT", - "DISABLED", - "SUBSTITUTE", - "IGNORE_GHOST", - "IGNORE_DARK", - "GULP_MISSILE_ARROKUDA", - "GULP_MISSILE_PIKACHU", - "BEAK_BLAST_CHARGING", - "SHELL_TRAP", - "DRAGON_CHEER", - "NO_RETREAT", - "GORILLA_TACTICS", - "UNBURDEN", - "THROAT_CHOPPED", - "TAR_SHOT", - "BURNED_UP", - "DOUBLE_SHOCKED", - "AUTOTOMIZED", - "MYSTERY_ENCOUNTER_POST_SUMMON", - "POWER_TRICK", - "HEAL_BLOCK", - "TORMENT", - "TAUNT", - "IMPRISON", - "SYRUP_BOMB", - "ELECTRIFIED", - "TELEKINESIS", - "COMMANDED", - "GRUDGE", - "PSYCHO_SHIFT", - "ENDURE_TOKEN", - "POWDER", - "MAGIC_COAT", -]); +const Z$BaseBattlerTags = /** @__PURE__ */ z.literal([ + BattlerTagType.RECHARGING, + BattlerTagType.CONFUSED, + BattlerTagType.INFATUATED, + BattlerTagType.SEEDED, + BattlerTagType.NIGHTMARE, + BattlerTagType.FRENZY, + BattlerTagType.CHARGING, + BattlerTagType.ENCORE, + BattlerTagType.INGRAIN, + BattlerTagType.OCTOLOCK, + BattlerTagType.AQUA_RING, + BattlerTagType.DROWSY, + BattlerTagType.TRAPPED, + BattlerTagType.BIND, + BattlerTagType.WRAP, + BattlerTagType.FIRE_SPIN, + BattlerTagType.WHIRLPOOL, + BattlerTagType.CLAMP, + BattlerTagType.SAND_TOMB, + BattlerTagType.MAGMA_STORM, + BattlerTagType.SNAP_TRAP, + BattlerTagType.THUNDER_CAGE, + BattlerTagType.INFESTATION, + BattlerTagType.STURDY, + BattlerTagType.PERISH_SONG, + BattlerTagType.TRUANT, + BattlerTagType.SLOW_START, + BattlerTagType.PROTOSYNTHESIS, + BattlerTagType.QUARK_DRIVE, + BattlerTagType.FLYING, + BattlerTagType.UNDERGROUND, + BattlerTagType.UNDERWATER, + BattlerTagType.HIDDEN, + BattlerTagType.FIRE_BOOST, + BattlerTagType.CRIT_BOOST, + BattlerTagType.ALWAYS_CRIT, + BattlerTagType.IGNORE_ACCURACY, + BattlerTagType.BYPASS_SLEEP, + BattlerTagType.IGNORE_FLYING, + BattlerTagType.SALT_CURED, + BattlerTagType.CURSED, + BattlerTagType.CHARGED, + BattlerTagType.FLOATING, + BattlerTagType.MINIMIZED, + BattlerTagType.DESTINY_BOND, + BattlerTagType.ICE_FACE, + BattlerTagType.DISGUISE, + BattlerTagType.STOCKPILING, + BattlerTagType.RECEIVE_DOUBLE_DAMAGE, + BattlerTagType.ALWAYS_GET_HIT, + BattlerTagType.DISABLED, + BattlerTagType.SUBSTITUTE, + BattlerTagType.IGNORE_GHOST, + BattlerTagType.IGNORE_DARK, + BattlerTagType.GULP_MISSILE_ARROKUDA, + BattlerTagType.GULP_MISSILE_PIKACHU, + BattlerTagType.DRAGON_CHEER, + BattlerTagType.NO_RETREAT, + BattlerTagType.GORILLA_TACTICS, + BattlerTagType.UNBURDEN, + BattlerTagType.THROAT_CHOPPED, + BattlerTagType.TAR_SHOT, + BattlerTagType.BURNED_UP, + BattlerTagType.DOUBLE_SHOCKED, + BattlerTagType.AUTOTOMIZED, + BattlerTagType.POWER_TRICK, + BattlerTagType.HEAL_BLOCK, + BattlerTagType.TORMENT, + BattlerTagType.TAUNT, + BattlerTagType.IMPRISON, + BattlerTagType.SYRUP_BOMB, + BattlerTagType.TELEKINESIS, + BattlerTagType.COMMANDED, + BattlerTagType.GRUDGE, +] satisfies SerializableBattlerTagType[]); /** * Zod schema for {@linkcode BattlerTagLapseType} as of version 1.10 @@ -122,21 +112,72 @@ const Z$BattlerTagType = z.literal([ * - `7`: After Hit * - `8`: Custom */ -const Z$BattlerTagLapseType = z.literal([0, 1, 2, 3, 4, 5, 6, 7, 8]); +const Z$BattlerTagLapseType = /** @__PURE__ */ z.literal([0, 1, 2, 3, 4, 5, 6, 7, 8]); -// DamagingTrapTag may have a `commonAnim` field, though it's always instantiated. -const Z$BattlerTag = z.object({ - tagType: Z$BattlerTagType, - lapseTypes: z.array(Z$BattlerTagLapseType), +const Z$BaseBattlerTag = /** @__PURE__ */ z.object({ turnCount: Z$PositiveInt, // Source move can be `none` for tags not applied by move, so allow `0` here. - sourceMove: Z$NonNegativeInt, + sourceMove: Z$NonNegativeInt.optional().catch(undefined), sourceId: z.int().optional().catch(undefined), - isBatonPassable: z.boolean(), }); -export const Z$SeedTag = z.object({ - ...Z$BattlerTag.shape, - seedType: z.literal("SEEDED"), +const Z$BaseTagWithMoveId = z.object({ + ...Z$BaseBattlerTag.shape, + moveId: Z$PositiveInt, +}); + +/** Subset of battler tags that have a moveID field */ +const Z$TagWithMoveId = /** @__PURE__ */ z.object({ + ...Z$BaseTagWithMoveId.shape, + tagType: z.literal([BattlerTagType.DISABLED, BattlerTagType.GORILLA_TACTICS, BattlerTagType.ENCORE]), + moveId: Z$PositiveInt, +}) as DiscriminatedUnionFake< + BattlerTagType.DISABLED | BattlerTagType.GORILLA_TACTICS | BattlerTagType.ENCORE, + typeof Z$TagWithMoveId.shape, + "tagType" +>; + +const Z$SeedTag = /** @__PURE__ */ z.object({ + ...Z$BaseBattlerTag.shape, + tagType: z.literal(BattlerTagType.SEEDED), sourceIndex: Z$BattlerIndex, }); + +const Z$BaseHighestStatBoostTag = /** @__PURE__ */ z.object({ + ...Z$BaseBattlerTag.shape, + stat: Z$Stat, + multiplier: z.number(), +}); + +const Z$HighestStatBoostTag = /** @__PURE__ */ z.object({ + ...Z$BaseHighestStatBoostTag.shape, + tagType: z.literal([BattlerTagType.QUARK_DRIVE, BattlerTagType.PROTOSYNTHESIS] satisfies HighestStatBoostTagType[]), +}) as DiscriminatedUnionFake; + +const Z$CommandedTag = /** @__PURE__ */ z.object({ + ...Z$BaseBattlerTag.shape, + tagType: z.literal(BattlerTagType.COMMANDED), + tatsugiriFormKey: z.string().catch("curly"), +}); + +const Z$StockpilingTag = /** @__PURE__ */ z.object({ + ...Z$BaseBattlerTag.shape, + tagType: z.literal(BattlerTagType.STOCKPILING), + stockpiledCount: Z$PositiveInt.catch(1), + statChangeCounts: z.object({ + [2]: z.int().min(-6).max(6).catch(0), // Defense + [4]: z.int().min(-6).max(6).catch(0), // Special Defense + }), +}); + +const Z$AutotomizedTag = /** @__PURE__ */ z.object({ + ...Z$BaseBattlerTag.shape, + tagType: z.literal(BattlerTagType.AUTOTOMIZED), + autotomizeCount: Z$PositiveInt.catch(1), +}); + +const Z$SubstituteTag = /** @__PURE__ */ z.object({ + ...Z$BaseBattlerTag.shape, + tagType: z.literal(BattlerTagType.SUBSTITUTE), + hp: Z$PositiveInt, +}); diff --git a/src/system/schemas/pokemon/pokemon-stats.ts b/src/system/schemas/pokemon/pokemon-stats.ts index cb855525990..e1e1cf1930b 100644 --- a/src/system/schemas/pokemon/pokemon-stats.ts +++ b/src/system/schemas/pokemon/pokemon-stats.ts @@ -1,6 +1,23 @@ +// biome-ignore lint/correctness/noUnusedImports: Used in a tsdoc comment +import type { Stat } from "#enums/stat"; import { Z$PositiveInt } from "#schemas/common"; import { z } from "zod"; +/** + * Schema for {@linkcode Stat}, as of version 1.10 + * + * @remarks + * - `0`: Hp + * - `1`: Attack + * - `2`: Defense + * - `3`: Special Attack + * - `4`: Special Defense + * - `5`: Speed + * - `6`: Accuracy + * - `7`: Evasion + */ +export const Z$Stat = /** @__PURE__ */ z.literal([0, 1, 2, 3, 4, 5, 6, 7]); + /** * Schema for a Pokémon's Individual Values (IVs), as of version 1.10 * @@ -8,7 +25,11 @@ import { z } from "zod"; * - Each IV is an integer between 0 and 31, inclusive. * - Malformed values parse to 15 by default. */ -export const Z$IV = z.int().min(0).max(31).catch(15); +export const Z$IV = /** @__PURE__ */ z + .int() + .min(0) + .max(31) + .catch(15); /** * Schema for a set of 6 Pokémon IVs, as of version 1.10 @@ -16,7 +37,9 @@ export const Z$IV = z.int().min(0).max(31).catch(15); * @remarks * Malformed IV sets default to [15, 15, 15, 15, 15, 15]. */ -export const Z$IVSet = z.tuple([Z$IV, Z$IV, Z$IV, Z$IV, Z$IV, Z$IV]).catch([15, 15, 15, 15, 15, 15]); +export const Z$IVSet = /** @__PURE__ */ z + .tuple([Z$IV, Z$IV, Z$IV, Z$IV, Z$IV, Z$IV]) + .catch([15, 15, 15, 15, 15, 15]); /** * Schema for a Pokémon's stats, as of version 1.10 @@ -27,7 +50,7 @@ export const Z$IVSet = z.tuple([Z$IV, Z$IV, Z$IV, Z$IV, Z$IV, Z$IV]).catch([15, * - [4]: Special Defense, * - [5]: Speed */ -export const Z$StatSet = z.tuple([ +export const Z$StatSet = /** @__PURE__ */ z.tuple([ Z$PositiveInt, // HP Z$PositiveInt, // Attack Z$PositiveInt, // Defense