From 925ddeaae47336a4e86c2308b89bdb4f23b33c3e Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Sun, 22 Jun 2025 15:44:09 -0400 Subject: [PATCH 1/4] Moved Enum functions to own file; added type helpers for enums --- src/@types/enum-types.ts | 54 +++++++++++++++++++++++++++++++ src/battle-scene.ts | 2 +- src/battle.ts | 2 +- src/data/balance/biomes.ts | 7 ++-- src/data/balance/egg-moves.ts | 23 +++++++++---- src/data/battle-anims.ts | 15 +++------ src/data/daily-run.ts | 3 +- src/data/dialogue.ts | 1 + src/data/moves/move.ts | 3 +- src/field/pokemon.ts | 2 +- src/inputs-controller.ts | 2 +- src/loading-scene.ts | 3 +- src/modifier/modifier-type.ts | 11 ++----- src/phases/move-phase.ts | 3 +- src/system/game-data.ts | 3 +- src/ui/daily-run-scoreboard.ts | 3 +- src/ui/egg-gacha-ui-handler.ts | 7 ++-- src/ui/menu-ui-handler.ts | 27 +++++++--------- src/ui/pokedex-page-ui-handler.ts | 6 ++-- src/ui/summary-ui-handler.ts | 2 +- src/utils/common.ts | 43 +++++------------------- src/utils/enums.ts | 51 +++++++++++++++++++++++++++++ 22 files changed, 175 insertions(+), 98 deletions(-) create mode 100644 src/@types/enum-types.ts create mode 100644 src/utils/enums.ts diff --git a/src/@types/enum-types.ts b/src/@types/enum-types.ts new file mode 100644 index 00000000000..eb2145ed71f --- /dev/null +++ b/src/@types/enum-types.ts @@ -0,0 +1,54 @@ +/** Union type accepting any TS Enum or `const object`, with or without reverse mapping. */ +export type EnumOrObject = Record; + +/** + * Utility type to extract the enum values from a `const object`, + * or convert an `enum` object produced by `typeof Enum` into its literal value union. + */ +export type EnumValues = E[keyof E]; + +/** + * Generic type constraint representing a TS numeric enum with reverse mappings. + * @example + * TSNumericEnum + */ +// NB: this works because `EnumValues` returns the underlying Enum union type. +export type TSNumericEnum = number extends EnumValues ? T : never; + +/** Generic type constraint representing a non reverse-mapped TS enum or `const object`. */ +export type NormalEnum = Exclude>; + +// ### Type check tests + +enum testEnumNum { + testN1 = 1, + testN2 = 2, +} + +enum testEnumString { + testS1 = "apple", + testS2 = "banana", +} + +const testObjNum = { testON1: 1, testON2: 2 } as const; + +const testObjString = { testOS1: "apple", testOS2: "banana" } as const; + +testEnumNum satisfies EnumOrObject; +testEnumString satisfies EnumOrObject; +testObjNum satisfies EnumOrObject; +testObjString satisfies EnumOrObject; + +// @ts-expect-error - This is intentionally supposed to fail as an example +testEnumNum satisfies NormalEnum; +testEnumString satisfies NormalEnum; +testObjNum satisfies NormalEnum; +testObjString satisfies NormalEnum; + +testEnumNum satisfies TSNumericEnum; +// @ts-expect-error - This is intentionally supposed to fail as an example +testEnumString satisfies TSNumericEnum; +// @ts-expect-error - This is intentionally supposed to fail as an example +testObjNum satisfies TSNumericEnum; +// @ts-expect-error - This is intentionally supposed to fail as an example +testObjString satisfies TSNumericEnum; diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 784c3ce8334..301e054ea2f 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -10,7 +10,6 @@ import { fixedInt, getIvsFromId, randSeedInt, - getEnumValues, randomString, NumberHolder, shiftCharCodes, @@ -19,6 +18,7 @@ import { BooleanHolder, type Constructor, } from "#app/utils/common"; +import { getEnumValues } from "./utils/enums"; import { deepMergeSpriteData } from "#app/utils/data"; import type { Modifier, ModifierPredicate, TurnHeldItemTransferModifier } from "./modifier/modifier"; import { diff --git a/src/battle.ts b/src/battle.ts index 45373402e12..4b6b776058f 100644 --- a/src/battle.ts +++ b/src/battle.ts @@ -2,7 +2,6 @@ import { globalScene } from "#app/global-scene"; import type { Command } from "#enums/command"; import { randomString, - getEnumValues, NumberHolder, randSeedInt, shiftCharCodes, @@ -10,6 +9,7 @@ import { randInt, randSeedFloat, } from "#app/utils/common"; +import { getEnumValues } from "./utils/enums"; import Trainer from "./field/trainer"; import { TrainerVariant } from "#enums/trainer-variant"; import type { GameMode } from "./game-mode"; diff --git a/src/data/balance/biomes.ts b/src/data/balance/biomes.ts index 713ab9637ab..70186a1ed87 100644 --- a/src/data/balance/biomes.ts +++ b/src/data/balance/biomes.ts @@ -1,5 +1,6 @@ import { PokemonType } from "#enums/pokemon-type"; -import { randSeedInt, getEnumValues } from "#app/utils/common"; +import { randSeedInt } from "#app/utils/common"; +import { getEnumValues } from "#app/utils/enums"; import type { SpeciesFormEvolution } from "#app/data/balance/pokemon-evolutions"; import { pokemonEvolutions } from "#app/data/balance/pokemon-evolutions"; import i18next from "i18next"; @@ -7708,10 +7709,10 @@ export function initBiomes() { const traverseBiome = (biome: BiomeId, depth: number) => { if (biome === BiomeId.END) { - const biomeList = Object.keys(BiomeId).filter(key => !Number.isNaN(Number(key))); + const biomeList = getEnumValues(BiomeId) biomeList.pop(); // Removes BiomeId.END from the list const randIndex = randSeedInt(biomeList.length, 1); // Will never be BiomeId.TOWN - biome = BiomeId[biomeList[randIndex]]; + biome = biomeList[randIndex]; } const linkedBiomes: (BiomeId | [ BiomeId, number ])[] = Array.isArray(biomeLinks[biome]) ? biomeLinks[biome] as (BiomeId | [ BiomeId, number ])[] diff --git a/src/data/balance/egg-moves.ts b/src/data/balance/egg-moves.ts index fa89e558ba7..1c5e6714711 100644 --- a/src/data/balance/egg-moves.ts +++ b/src/data/balance/egg-moves.ts @@ -1,8 +1,8 @@ import { allMoves } from "../data-lists"; -import { getEnumKeys, getEnumValues } from "#app/utils/common"; +import { getEnumKeys, getEnumValues } from "#app/utils/enums"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; - +import { toReadableString } from "#app/utils/common"; export const speciesEggMoves = { [SpeciesId.BULBASAUR]: [ MoveId.SAPPY_SEED, MoveId.MALIGNANT_CHAIN, MoveId.EARTH_POWER, MoveId.MATCHA_GOTCHA ], @@ -584,17 +584,24 @@ export const speciesEggMoves = { [SpeciesId.BLOODMOON_URSALUNA]: [ MoveId.NASTY_PLOT, MoveId.ROCK_POLISH, MoveId.SANDSEAR_STORM, MoveId.BOOMBURST ] }; +/** + * Parse a CSV-separated list of Egg Moves (such as one sourced from a Google Sheets) + * into code able to form the `speciesEggMoves` const object as above. + * @param content - The CSV-formatted string to convert into code. + */ +// TODO: Move this into the scripts folder and stop running it on initialization function parseEggMoves(content: string): void { let output = ""; const speciesNames = getEnumKeys(SpeciesId); const speciesValues = getEnumValues(SpeciesId); + const moveNames = allMoves.map(m => m.name.replace(/ \([A-Z]\)$/, "").toLowerCase()); const lines = content.split(/\n/g); for (const line of lines) { const cols = line.split(",").slice(0, 5); - const moveNames = allMoves.map(m => m.name.replace(/ \([A-Z]\)$/, "").toLowerCase()); - const enumSpeciesName = cols[0].toUpperCase().replace(/[ -]/g, "_"); + const enumSpeciesName = cols[0].toUpperCase().replace(/[ -]/g, "_") as keyof typeof SpeciesId; + // TODO: This should use reverse mapping instead of `indexOf` const species = speciesValues[speciesNames.indexOf(enumSpeciesName)]; const eggMoves: MoveId[] = []; @@ -602,14 +609,16 @@ function parseEggMoves(content: string): void { for (let m = 0; m < 4; m++) { const moveName = cols[m + 1].trim(); const moveIndex = moveName !== "N/A" ? moveNames.indexOf(moveName.toLowerCase()) : -1; - eggMoves.push(moveIndex > -1 ? moveIndex as MoveId : MoveId.NONE); - if (moveIndex === -1) { console.warn(moveName, "could not be parsed"); } + + eggMoves.push(moveIndex > -1 ? moveIndex as MoveId : MoveId.NONE); } - if (eggMoves.find(m => m !== MoveId.NONE)) { + if (eggMoves.every(m => m === MoveId.NONE)) { + console.warn(`Species ${toReadableString(SpeciesId[species])} could not be parsed, excluding from output...`) + } else { output += `[SpeciesId.${SpeciesId[species]}]: [ ${eggMoves.map(m => `MoveId.${MoveId[m]}`).join(", ")} ],\n`; } } diff --git a/src/data/battle-anims.ts b/src/data/battle-anims.ts index bed17fb0ebc..f0055bc9fdc 100644 --- a/src/data/battle-anims.ts +++ b/src/data/battle-anims.ts @@ -2,15 +2,8 @@ import { globalScene } from "#app/global-scene"; import { allMoves } from "#app/data/data-lists"; import { MoveFlags } from "#enums/MoveFlags"; import type Pokemon from "#app/field/pokemon"; -import { - type nil, - getFrameMs, - getEnumKeys, - getEnumValues, - animationFileName, - coerceArray, - isNullOrUndefined, -} from "#app/utils/common"; +import { type nil, getFrameMs, animationFileName, coerceArray, isNullOrUndefined } from "#app/utils/common"; +import { getEnumKeys, getEnumValues } from "#app/utils/enums"; import type { BattlerIndex } from "#enums/battler-index"; import { MoveId } from "#enums/move-id"; import Phaser from "phaser"; @@ -1402,10 +1395,10 @@ export class EncounterBattleAnim extends BattleAnim { export async function populateAnims() { const commonAnimNames = getEnumKeys(CommonAnim).map(k => k.toLowerCase()); const commonAnimMatchNames = commonAnimNames.map(k => k.replace(/_/g, "")); - const commonAnimIds = getEnumValues(CommonAnim) as CommonAnim[]; + const commonAnimIds = getEnumValues(CommonAnim); const chargeAnimNames = getEnumKeys(ChargeAnim).map(k => k.toLowerCase()); const chargeAnimMatchNames = chargeAnimNames.map(k => k.replace(/_/g, " ")); - const chargeAnimIds = getEnumValues(ChargeAnim) as ChargeAnim[]; + const chargeAnimIds = getEnumValues(ChargeAnim); const commonNamePattern = /name: (?:Common:)?(Opp )?(.*)/; const moveNameToId = {}; for (const move of getEnumValues(MoveId).slice(1)) { diff --git a/src/data/daily-run.ts b/src/data/daily-run.ts index e869ba7f28f..8bf7c5c828a 100644 --- a/src/data/daily-run.ts +++ b/src/data/daily-run.ts @@ -3,7 +3,8 @@ import type { SpeciesId } from "#enums/species-id"; import { globalScene } from "#app/global-scene"; import { PlayerPokemon } from "#app/field/pokemon"; import type { Starter } from "#app/ui/starter-select-ui-handler"; -import { randSeedGauss, randSeedInt, randSeedItem, getEnumValues } from "#app/utils/common"; +import { randSeedGauss, randSeedInt, randSeedItem } from "#app/utils/common"; +import { getEnumValues } from "#app/utils/enums"; import type { PokemonSpeciesForm } from "#app/data/pokemon-species"; import PokemonSpecies, { getPokemonSpeciesForm } from "#app/data/pokemon-species"; import { getPokemonSpecies } from "#app/utils/pokemon-utils"; diff --git a/src/data/dialogue.ts b/src/data/dialogue.ts index 6bb96f0efb2..cc023a24ab0 100644 --- a/src/data/dialogue.ts +++ b/src/data/dialogue.ts @@ -1744,6 +1744,7 @@ export function getCharVariantFromDialogue(message: string): string { } export function initTrainerTypeDialogue(): void { + // TODO: this should not be using `Object.Keys` const trainerTypes = Object.keys(trainerTypeDialogue).map(t => Number.parseInt(t) as TrainerType); for (const trainerType of trainerTypes) { const messages = trainerTypeDialogue[trainerType]; diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index f94c59bb463..aa91a1c8179 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -27,7 +27,8 @@ import { } from "../status-effect"; import { getTypeDamageMultiplier } from "../type"; import { PokemonType } from "#enums/pokemon-type"; -import { BooleanHolder, NumberHolder, isNullOrUndefined, toDmgValue, randSeedItem, randSeedInt, getEnumValues, toReadableString, type Constructor, randSeedFloat } from "#app/utils/common"; +import { BooleanHolder, NumberHolder, isNullOrUndefined, toDmgValue, randSeedItem, randSeedInt, toReadableString, type Constructor, randSeedFloat } from "#app/utils/common"; +import { getEnumValues } from "#app/utils/enums"; import { WeatherType } from "#enums/weather-type"; import type { ArenaTrapTag } from "../arena-tag"; import { WeakenMoveTypeTag } from "../arena-tag"; diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 5b88ae0867b..f46dca33751 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -25,7 +25,6 @@ import { BooleanHolder, randSeedItem, isNullOrUndefined, - getEnumValues, toDmgValue, fixedInt, rgbaToInt, @@ -38,6 +37,7 @@ import { randSeedIntRange, coerceArray, } from "#app/utils/common"; +import { getEnumValues } from "#app/utils/enums"; import type { TypeDamageMultiplier } from "#app/data/type"; import { getTypeDamageMultiplier, getTypeRgb } from "#app/data/type"; import { PokemonType } from "#enums/pokemon-type"; diff --git a/src/inputs-controller.ts b/src/inputs-controller.ts index 388802f467e..8f8c1a96fc0 100644 --- a/src/inputs-controller.ts +++ b/src/inputs-controller.ts @@ -1,5 +1,5 @@ import Phaser from "phaser"; -import { getEnumValues } from "#app/utils/common"; +import { getEnumValues } from "./utils/enums"; import { deepCopy } from "#app/utils/data"; import pad_generic from "./configs/inputs/pad_generic"; import pad_unlicensedSNES from "./configs/inputs/pad_unlicensedSNES"; diff --git a/src/loading-scene.ts b/src/loading-scene.ts index f67d19e1027..0c242b7344a 100644 --- a/src/loading-scene.ts +++ b/src/loading-scene.ts @@ -4,7 +4,8 @@ import CacheBustedLoaderPlugin from "#app/plugins/cache-busted-loader-plugin"; import { SceneBase } from "#app/scene-base"; import { WindowVariant, getWindowVariantSuffix } from "#app/ui/ui-theme"; import { isMobile } from "#app/touch-controls"; -import { localPing, getEnumValues, hasAllLocalizedSprites, getEnumKeys } from "#app/utils/common"; +import { localPing, hasAllLocalizedSprites } from "#app/utils/common"; +import { getEnumValues, getEnumKeys } from "./utils/enums"; import { initPokemonPrevolutions, initPokemonStarters } from "#app/data/balance/pokemon-evolutions"; import { initBiomes } from "#app/data/balance/biomes"; import { initEggMoves } from "#app/data/balance/egg-moves"; diff --git a/src/modifier/modifier-type.ts b/src/modifier/modifier-type.ts index fcbe6b66a4e..6c5be4399ce 100644 --- a/src/modifier/modifier-type.ts +++ b/src/modifier/modifier-type.ts @@ -103,15 +103,8 @@ import { getVoucherTypeIcon, getVoucherTypeName, VoucherType } from "#app/system import type { PokemonMoveSelectFilter, PokemonSelectFilter } from "#app/ui/party-ui-handler"; import PartyUiHandler from "#app/ui/party-ui-handler"; import { getModifierTierTextTint } from "#app/ui/text"; -import { - formatMoney, - getEnumKeys, - getEnumValues, - isNullOrUndefined, - NumberHolder, - padInt, - randSeedInt, -} from "#app/utils/common"; +import { formatMoney, isNullOrUndefined, NumberHolder, padInt, randSeedInt } from "#app/utils/common"; +import { getEnumKeys, getEnumValues } from "#app/utils/enums"; import { BattlerTagType } from "#enums/battler-tag-type"; import { BerryType } from "#enums/berry-type"; import { MoveId } from "#enums/move-id"; diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 2e94b085948..2df82caccdd 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -19,7 +19,8 @@ import { MoveResult } from "#enums/move-result"; import { getPokemonNameWithAffix } from "#app/messages"; import Overrides from "#app/overrides"; import { BattlePhase } from "#app/phases/battle-phase"; -import { enumValueToKey, NumberHolder } from "#app/utils/common"; +import { NumberHolder } from "#app/utils/common"; +import { enumValueToKey } from "#app/utils/enums"; import { AbilityId } from "#enums/ability-id"; import { ArenaTagType } from "#enums/arena-tag-type"; import { BattlerTagType } from "#enums/battler-tag-type"; diff --git a/src/system/game-data.ts b/src/system/game-data.ts index d5d4256f7d0..0c6c6decd14 100644 --- a/src/system/game-data.ts +++ b/src/system/game-data.ts @@ -9,7 +9,8 @@ import type PokemonSpecies from "#app/data/pokemon-species"; import { getPokemonSpecies } from "#app/utils/pokemon-utils"; import { allSpecies } from "#app/data/data-lists"; import { speciesStarterCosts } from "#app/data/balance/starters"; -import { randInt, getEnumKeys, isLocal, executeIf, fixedInt, randSeedItem, NumberHolder } from "#app/utils/common"; +import { randInt, isLocal, executeIf, fixedInt, randSeedItem, NumberHolder } from "#app/utils/common"; +import { getEnumKeys } from "#app/utils/enums"; import Overrides from "#app/overrides"; import PokemonData from "#app/system/pokemon-data"; import PersistentModifierData from "#app/system/modifier-data"; diff --git a/src/ui/daily-run-scoreboard.ts b/src/ui/daily-run-scoreboard.ts index c069c6fffd7..4cf4e924182 100644 --- a/src/ui/daily-run-scoreboard.ts +++ b/src/ui/daily-run-scoreboard.ts @@ -1,6 +1,7 @@ import i18next from "i18next"; import { globalScene } from "#app/global-scene"; -import { getEnumKeys, executeIf } from "#app/utils/common"; +import { executeIf } from "#app/utils/common"; +import { getEnumKeys } from "#app/utils/enums"; import { TextStyle, addTextObject } from "./text"; import { WindowVariant, addWindow } from "./ui-theme"; import { pokerogueApi } from "#app/plugins/api/pokerogue-api"; diff --git a/src/ui/egg-gacha-ui-handler.ts b/src/ui/egg-gacha-ui-handler.ts index 7ff3a1b65ee..d99671157bc 100644 --- a/src/ui/egg-gacha-ui-handler.ts +++ b/src/ui/egg-gacha-ui-handler.ts @@ -1,7 +1,8 @@ import { UiMode } from "#enums/ui-mode"; import { TextStyle, addTextObject, getEggTierTextTint, getTextStyleOptions } from "./text"; import MessageUiHandler from "./message-ui-handler"; -import { getEnumValues, getEnumKeys, fixedInt, randSeedShuffle } from "#app/utils/common"; +import { fixedInt, randSeedShuffle } from "#app/utils/common"; +import { getEnumValues, getEnumKeys } from "#app/utils/enums"; import type { IEggOptions } from "../data/egg"; import { Egg, getLegendaryGachaSpeciesForTimestamp } from "../data/egg"; import { VoucherType, getVoucherTypeIcon } from "../system/voucher"; @@ -299,7 +300,7 @@ export default class EggGachaUiHandler extends MessageUiHandler { this.eggGachaContainer.add(this.eggGachaOptionsContainer); - new Array(getEnumKeys(VoucherType).length).fill(null).map((_, i) => { + getEnumValues(VoucherType).forEach((voucher, i) => { const container = globalScene.add.container(globalScene.game.canvas.width / 6 - 56 * i, 0); const bg = addWindow(0, 0, 56, 22); @@ -312,7 +313,7 @@ export default class EggGachaUiHandler extends MessageUiHandler { this.voucherCountLabels.push(countLabel); - const iconImage = getVoucherTypeIcon(i as VoucherType); + const iconImage = getVoucherTypeIcon(voucher); const icon = globalScene.add.sprite(-19, 2, "items", iconImage); icon.setOrigin(0, 0); diff --git a/src/ui/menu-ui-handler.ts b/src/ui/menu-ui-handler.ts index 5ab1d4f9e96..52cdba140a3 100644 --- a/src/ui/menu-ui-handler.ts +++ b/src/ui/menu-ui-handler.ts @@ -2,7 +2,8 @@ import { bypassLogin } from "#app/global-vars/bypass-login"; import { globalScene } from "#app/global-scene"; import { TextStyle, addTextObject, getTextStyleOptions } from "./text"; import { UiMode } from "#enums/ui-mode"; -import { getEnumKeys, isLocal, fixedInt, sessionIdKey } from "#app/utils/common"; +import { isLocal, fixedInt, sessionIdKey } from "#app/utils/common"; +import { getEnumValues } from "#app/utils/enums"; import { isBeta } from "#app/utils/utility-vars"; import { getCookie } from "#app/utils/cookies"; import { addWindow, WindowVariant } from "./ui-theme"; @@ -76,11 +77,9 @@ export default class MenuUiHandler extends MessageUiHandler { { condition: bypassLogin, options: [MenuOptions.LOG_OUT] }, ]; - this.menuOptions = getEnumKeys(MenuOptions) - .map(m => Number.parseInt(MenuOptions[m]) as MenuOptions) - .filter(m => { - return !this.excludedMenus().some(exclusion => exclusion.condition && exclusion.options.includes(m)); - }); + this.menuOptions = getEnumValues(MenuOptions).filter(m => { + return !this.excludedMenus().some(exclusion => exclusion.condition && exclusion.options.includes(m)); + }); } setup(): void { @@ -131,11 +130,9 @@ export default class MenuUiHandler extends MessageUiHandler { { condition: bypassLogin, options: [MenuOptions.LOG_OUT] }, ]; - this.menuOptions = getEnumKeys(MenuOptions) - .map(m => Number.parseInt(MenuOptions[m]) as MenuOptions) - .filter(m => { - return !this.excludedMenus().some(exclusion => exclusion.condition && exclusion.options.includes(m)); - }); + this.menuOptions = getEnumValues(MenuOptions).filter(m => { + return !this.excludedMenus().some(exclusion => exclusion.condition && exclusion.options.includes(m)); + }); this.optionSelectText = addTextObject( 0, @@ -511,11 +508,9 @@ export default class MenuUiHandler extends MessageUiHandler { this.render(); super.show(args); - this.menuOptions = getEnumKeys(MenuOptions) - .map(m => Number.parseInt(MenuOptions[m]) as MenuOptions) - .filter(m => { - return !this.excludedMenus().some(exclusion => exclusion.condition && exclusion.options.includes(m)); - }); + this.menuOptions = getEnumValues(MenuOptions).filter(m => { + return !this.excludedMenus().some(exclusion => exclusion.condition && exclusion.options.includes(m)); + }); this.menuContainer.setVisible(true); this.setCursor(0); diff --git a/src/ui/pokedex-page-ui-handler.ts b/src/ui/pokedex-page-ui-handler.ts index 9ec74e70b23..bb883d6670a 100644 --- a/src/ui/pokedex-page-ui-handler.ts +++ b/src/ui/pokedex-page-ui-handler.ts @@ -58,7 +58,7 @@ import { toReadableString, } from "#app/utils/common"; import type { Nature } from "#enums/nature"; -import { getEnumKeys } from "#app/utils/common"; +import { getEnumValues } from "#app/utils/enums"; import { speciesTmMoves } from "#app/data/balance/tms"; import type { BiomeTierTod } from "#app/data/balance/biomes"; import { BiomePoolTier, catchableSpecies } from "#app/data/balance/biomes"; @@ -603,7 +603,7 @@ export default class PokedexPageUiHandler extends MessageUiHandler { this.menuContainer.setVisible(false); - this.menuOptions = getEnumKeys(MenuOptions).map(m => Number.parseInt(MenuOptions[m]) as MenuOptions); + this.menuOptions = getEnumValues(MenuOptions); this.optionSelectText = addBBCodeTextObject( 0, @@ -707,7 +707,7 @@ export default class PokedexPageUiHandler extends MessageUiHandler { this.starterAttributes = this.initStarterPrefs(); - this.menuOptions = getEnumKeys(MenuOptions).map(m => Number.parseInt(MenuOptions[m]) as MenuOptions); + this.menuOptions = getEnumValues(MenuOptions); this.menuContainer.setVisible(true); diff --git a/src/ui/summary-ui-handler.ts b/src/ui/summary-ui-handler.ts index f108faf1646..6a9418c2587 100644 --- a/src/ui/summary-ui-handler.ts +++ b/src/ui/summary-ui-handler.ts @@ -6,13 +6,13 @@ import { getLocalizedSpriteKey, rgbHexToRgba, padInt, - getEnumValues, fixedInt, isNullOrUndefined, toReadableString, formatStat, getShinyDescriptor, } from "#app/utils/common"; +import { getEnumValues } from "#app/utils/enums"; import type { PlayerPokemon } from "#app/field/pokemon"; import type { PokemonMove } from "#app/data/moves/pokemon-move"; import { getStarterValueFriendshipCap, speciesStarterCosts } from "#app/data/balance/starters"; diff --git a/src/utils/common.ts b/src/utils/common.ts index 4bf51730148..3f13d40c82c 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -8,11 +8,18 @@ export type nil = null | undefined; export const MissingTextureKey = "__MISSING"; +// TODO: Draft tests for these utility functions +/** + * Convert a `snake_case` string in any capitalization (such as one from an enum reverse mapping) + * into a readable `Title Case` version. + * @param str - The snake case string to be converted. + * @returns The result of converting `str` into title case. + */ export function toReadableString(str: string): string { return str .replace(/_/g, " ") .split(" ") - .map(s => `${s.slice(0, 1)}${s.slice(1).toLowerCase()}`) + .map(s => capitalizeFirstLetter(s.toLowerCase())) .join(" "); } @@ -257,18 +264,6 @@ export function formatStat(stat: number, forHp = false): string { return formatLargeNumber(stat, forHp ? 100000 : 1000000); } -export function getEnumKeys(enumType: any): string[] { - return Object.values(enumType) - .filter(v => Number.isNaN(Number.parseInt(v!.toString()))) - .map(v => v!.toString()); -} - -export function getEnumValues(enumType: any): number[] { - return Object.values(enumType) - .filter(v => !Number.isNaN(Number.parseInt(v!.toString()))) - .map(v => Number.parseInt(v!.toString())); -} - export function executeIf(condition: boolean, promiseFunc: () => Promise): Promise { return condition ? promiseFunc() : new Promise(resolve => resolve(null)); } @@ -620,25 +615,3 @@ export function coerceArray(input: T): T extends any[] ? T : [T]; export function coerceArray(input: T): T | [T] { return Array.isArray(input) ? input : [input]; } - -/** - * Returns the name of the key that matches the enum [object] value. - * @param input - The enum [object] to check - * @param val - The value to get the key of - * @returns The name of the key with the specified value - * @example - * const thing = { - * one: 1, - * two: 2, - * } as const; - * console.log(enumValueToKey(thing, thing.two)); // output: "two" - * @throws An `Error` if an invalid enum value is passed to the function - */ -export function enumValueToKey>(input: T, val: T[keyof T]): keyof T { - for (const [key, value] of Object.entries(input)) { - if (val === value) { - return key as keyof T; - } - } - throw new Error(`Invalid value passed to \`enumValueToKey\`! Value: ${val}`); -} diff --git a/src/utils/enums.ts b/src/utils/enums.ts new file mode 100644 index 00000000000..dcc3ce1d335 --- /dev/null +++ b/src/utils/enums.ts @@ -0,0 +1,51 @@ +// biome-ignore lint/correctness/noUnusedImports: Used for a JSDoc comment +import type { EnumOrObject, EnumValues, TSNumericEnum, NormalEnum } from "#app/@types/enum-types"; + +/** + * Return the string keys of an Enum object, excluding reverse-mapped numbers. + * @param enumType - The numeric enum to retrieve keys for. + * @returns An ordered array of all of `enumType`'s string keys. + * @remarks + * To retrieve the keys of a {@linkcode NormalEnum}, use {@linkcode Object.keys} instead. + */ +export function getEnumKeys(enumType: TSNumericEnum): (keyof E)[] { + // All enum values are either normal or reverse mapped, so we can retrieve the keys by filtering out all strings. + return Object.values(enumType).filter(v => typeof v === "string"); +} + +/** + * Return the numeric values of a numeric Enum object, excluding reverse-mapped strings. + * @param enumType - The enum object to retrieve keys for. + * @returns An ordered array of all of `enumType`'s number values. + * @remarks + * To retrieve the keys of a {@linkcode NormalEnum}, use {@linkcode Object.values} instead. + */ +// NB: This does not use `EnumValues` due to variable highlighting in IDEs. +export function getEnumValues(enumType: TSNumericEnum): E[keyof E][] { + return Object.values(enumType).filter(v => typeof v !== "string") as E[keyof E][]; +} + +/** + * Return the name of the key that matches the given Enum value. + * Can be used to emulate Typescript reverse mapping for `const object`s or string enums. + * @param object - The {@linkcode EnumOrObject} to check + * @param val - The value to get the key of. + * @returns The name of the key with the specified value. + * @example + * const thing = { + * one: 1, + * two: 2, + * } as const; + * console.log(enumValueToKey(thing, thing.two)); // output: "two" + * @throws Error if an invalid enum value is passed to the function + * @remarks + * If multiple keys map to the same value, the first one (in insertion order) will be retrieved. + */ +export function enumValueToKey(object: T, val: T[keyof T]): keyof T { + for (const [key, value] of Object.entries(object)) { + if (val === value) { + return key as keyof T; + } + } + throw new Error(`Invalid value passed to \`enumValueToKey\`! Value: ${val}`); +} From 903c86a9f6cc23c5aabe83f75ce9c670fb6d1443 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Sun, 22 Jun 2025 16:18:10 -0400 Subject: [PATCH 2/4] Cleaned up some code --- src/@types/enum-types.ts | 2 +- src/battle-scene.ts | 3 ++ src/data/daily-run.ts | 1 + .../encounters/berries-abound-encounter.ts | 5 +-- .../encounters/bug-type-superfan-encounter.ts | 2 ++ .../encounters/clowning-around-encounter.ts | 1 + .../teleporting-hijinks-encounter.ts | 1 + .../encounters/weird-dream-encounter.ts | 2 ++ .../mystery-encounter-option.ts | 2 ++ .../mystery-encounters/mystery-encounter.ts | 2 ++ .../utils/encounter-phase-utils.ts | 2 ++ .../utils/encounter-pokemon-utils.ts | 4 +++ src/field/arena.ts | 2 ++ src/field/trainer.ts | 5 ++- src/modifier/modifier-type.ts | 7 ++-- src/phases/select-biome-phase.ts | 1 + src/utils/common.ts | 1 + src/utils/enums.ts | 32 +++++++++++++++---- 18 files changed, 62 insertions(+), 13 deletions(-) diff --git a/src/@types/enum-types.ts b/src/@types/enum-types.ts index eb2145ed71f..4abf36b4226 100644 --- a/src/@types/enum-types.ts +++ b/src/@types/enum-types.ts @@ -3,7 +3,7 @@ export type EnumOrObject = Record; /** * Utility type to extract the enum values from a `const object`, - * or convert an `enum` object produced by `typeof Enum` into its literal value union. + * or convert an `enum` object produced by `typeof Enum` into the union type representing its values. */ export type EnumValues = E[keyof E]; diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 301e054ea2f..3c598a9363c 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -2137,6 +2137,7 @@ export default class BattleScene extends SceneBase { ), ] : allSpecies.filter(s => s.isCatchable()); + // TODO: should this use `randSeedItem`? return filteredSpecies[randSeedInt(filteredSpecies.length)]; } @@ -2162,6 +2163,7 @@ export default class BattleScene extends SceneBase { } } + // TODO: should this use `randSeedItem`? return biomes[randSeedInt(biomes.length)]; } @@ -3681,6 +3683,7 @@ export default class BattleScene extends SceneBase { console.log("No Mystery Encounters found, falling back to Mysterious Challengers."); return allMysteryEncounters[MysteryEncounterType.MYSTERIOUS_CHALLENGERS]; } + // TODO: should this use `randSeedItem`? encounter = availableEncounters[randSeedInt(availableEncounters.length)]; // New encounter object to not dirty flags encounter = new MysteryEncounter(encounter); diff --git a/src/data/daily-run.ts b/src/data/daily-run.ts index 8bf7c5c828a..960de3497a3 100644 --- a/src/data/daily-run.ts +++ b/src/data/daily-run.ts @@ -166,5 +166,6 @@ export function getDailyStartingBiome(): BiomeId { } // Fallback in case something went wrong + // TODO: should this use `randSeedItem`? return biomes[randSeedInt(biomes.length)]; } diff --git a/src/data/mystery-encounters/encounters/berries-abound-encounter.ts b/src/data/mystery-encounters/encounters/berries-abound-encounter.ts index d86a8439804..e55188fc3c4 100644 --- a/src/data/mystery-encounters/encounters/berries-abound-encounter.ts +++ b/src/data/mystery-encounters/encounters/berries-abound-encounter.ts @@ -15,7 +15,7 @@ import type { BerryModifierType, ModifierTypeOption } from "#app/modifier/modifi import { regenerateModifierPoolThresholds } from "#app/modifier/modifier-type"; import { modifierTypes } from "#app/data/data-lists"; import { ModifierPoolType } from "#enums/modifier-pool-type"; -import { randSeedInt } from "#app/utils/common"; +import { randSeedItem } from "#app/utils/common"; import { BattlerTagType } from "#enums/battler-tag-type"; import { MysteryEncounterType } from "#enums/mystery-encounter-type"; import { globalScene } from "#app/global-scene"; @@ -38,6 +38,7 @@ import i18next from "#app/plugins/i18n"; import { BerryType } from "#enums/berry-type"; import { PERMANENT_STATS, Stat } from "#enums/stat"; import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/constants"; +import { getEnumValues } from "#app/utils/enums"; /** the i18n namespace for the encounter */ const namespace = "mysteryEncounters/berriesAbound"; @@ -311,7 +312,7 @@ export const BerriesAboundEncounter: MysteryEncounter = MysteryEncounterBuilder. .build(); function tryGiveBerry(prioritizedPokemon?: PlayerPokemon) { - const berryType = randSeedInt(Object.keys(BerryType).filter(s => !Number.isNaN(Number(s))).length) as BerryType; + const berryType = randSeedItem(getEnumValues(BerryType)); const berry = generateModifierType(modifierTypes.BERRY, [berryType]) as BerryModifierType; const party = globalScene.getPlayerParty(); diff --git a/src/data/mystery-encounters/encounters/bug-type-superfan-encounter.ts b/src/data/mystery-encounters/encounters/bug-type-superfan-encounter.ts index df06f40c159..8c10a4f2b2f 100644 --- a/src/data/mystery-encounters/encounters/bug-type-superfan-encounter.ts +++ b/src/data/mystery-encounters/encounters/bug-type-superfan-encounter.ts @@ -292,6 +292,7 @@ export const BugTypeSuperfanEncounter: MysteryEncounter = MysteryEncounterBuilde // Init the moves available for tutor const moveTutorOptions: PokemonMove[] = []; + // TODO: should this use `randSeedItem`? moveTutorOptions.push(new PokemonMove(PHYSICAL_TUTOR_MOVES[randSeedInt(PHYSICAL_TUTOR_MOVES.length)])); moveTutorOptions.push(new PokemonMove(SPECIAL_TUTOR_MOVES[randSeedInt(SPECIAL_TUTOR_MOVES.length)])); moveTutorOptions.push(new PokemonMove(STATUS_TUTOR_MOVES[randSeedInt(STATUS_TUTOR_MOVES.length)])); @@ -389,6 +390,7 @@ export const BugTypeSuperfanEncounter: MysteryEncounter = MysteryEncounterBuilde specialOptions.push(rareFormChangeModifier); } if (specialOptions.length > 0) { + // TODO: should this use `randSeedItem`? modifierOptions.push(specialOptions[randSeedInt(specialOptions.length)]); } diff --git a/src/data/mystery-encounters/encounters/clowning-around-encounter.ts b/src/data/mystery-encounters/encounters/clowning-around-encounter.ts index 818318bb499..a8b780667d4 100644 --- a/src/data/mystery-encounters/encounters/clowning-around-encounter.ts +++ b/src/data/mystery-encounters/encounters/clowning-around-encounter.ts @@ -139,6 +139,7 @@ export const ClowningAroundEncounter: MysteryEncounter = MysteryEncounterBuilder clownConfig.partyTemplateFunc = null; // Overrides party template func if it exists // Generate random ability for Blacephalon from pool + // TODO: should this use `randSeedItem`? const ability = RANDOM_ABILITY_POOL[randSeedInt(RANDOM_ABILITY_POOL.length)]; encounter.setDialogueToken("ability", allAbilities[ability].name); encounter.misc = { ability }; diff --git a/src/data/mystery-encounters/encounters/teleporting-hijinks-encounter.ts b/src/data/mystery-encounters/encounters/teleporting-hijinks-encounter.ts index 9c9232612c6..4a99fc69ae2 100644 --- a/src/data/mystery-encounters/encounters/teleporting-hijinks-encounter.ts +++ b/src/data/mystery-encounters/encounters/teleporting-hijinks-encounter.ts @@ -190,6 +190,7 @@ async function doBiomeTransitionDialogueAndBattleInit() { // Calculate new biome (cannot be current biome) const filteredBiomes = BIOME_CANDIDATES.filter(b => globalScene.arena.biomeType !== b); + // TODO: should this use `randSeedItem`? const newBiome = filteredBiomes[randSeedInt(filteredBiomes.length)]; // Show dialogue and transition biome diff --git a/src/data/mystery-encounters/encounters/weird-dream-encounter.ts b/src/data/mystery-encounters/encounters/weird-dream-encounter.ts index 1ba756c7f5d..3316c844287 100644 --- a/src/data/mystery-encounters/encounters/weird-dream-encounter.ts +++ b/src/data/mystery-encounters/encounters/weird-dream-encounter.ts @@ -314,6 +314,7 @@ export const WeirdDreamEncounter: MysteryEncounter = MysteryEncounterBuilder.wit // One random pokemon will get its passive unlocked const passiveDisabledPokemon = globalScene.getPlayerParty().filter(p => !p.passive); if (passiveDisabledPokemon?.length > 0) { + // TODO: should this use `randSeedItem`? const enablePassiveMon = passiveDisabledPokemon[randSeedInt(passiveDisabledPokemon.length)]; enablePassiveMon.passive = true; enablePassiveMon.updateInfo(true); @@ -479,6 +480,7 @@ async function doNewTeamPostProcess(transformations: PokemonTransformation[]) { // One random pokemon will get its passive unlocked const passiveDisabledPokemon = globalScene.getPlayerParty().filter(p => !p.passive); if (passiveDisabledPokemon?.length > 0) { + // TODO: should this use `randSeedItem`? const enablePassiveMon = passiveDisabledPokemon[randSeedInt(passiveDisabledPokemon.length)]; enablePassiveMon.passive = true; await enablePassiveMon.updateInfo(true); diff --git a/src/data/mystery-encounters/mystery-encounter-option.ts b/src/data/mystery-encounters/mystery-encounter-option.ts index 53b53392bb8..999d448e305 100644 --- a/src/data/mystery-encounters/mystery-encounter-option.ts +++ b/src/data/mystery-encounters/mystery-encounter-option.ts @@ -145,11 +145,13 @@ export default class MysteryEncounterOption implements IMysteryEncounterOption { } if (truePrimaryPool.length > 0) { // always choose from the non-overlapping pokemon first + // TODO: should this use `randSeedItem`? this.primaryPokemon = truePrimaryPool[randSeedInt(truePrimaryPool.length)]; return true; } // if there are multiple overlapping pokemon, we're okay - just choose one and take it out of the supporting pokemon pool if (overlap.length > 1 || this.secondaryPokemon.length - overlap.length >= 1) { + // TODO: should this use `randSeedItem`? this.primaryPokemon = overlap[randSeedInt(overlap.length)]; this.secondaryPokemon = this.secondaryPokemon.filter(supp => supp !== this.primaryPokemon); return true; diff --git a/src/data/mystery-encounters/mystery-encounter.ts b/src/data/mystery-encounters/mystery-encounter.ts index fa97a7f4d40..bc0f56b4925 100644 --- a/src/data/mystery-encounters/mystery-encounter.ts +++ b/src/data/mystery-encounters/mystery-encounter.ts @@ -385,6 +385,7 @@ export default class MysteryEncounter implements IMysteryEncounter { // If there are multiple overlapping pokemon, we're okay - just choose one and take it out of the primary pokemon pool if (overlap.length > 1 || this.secondaryPokemon.length - overlap.length >= 1) { // is this working? + // TODO: should this use `randSeedItem`? this.primaryPokemon = overlap[randSeedInt(overlap.length, 0)]; this.secondaryPokemon = this.secondaryPokemon.filter(supp => supp !== this.primaryPokemon); return true; @@ -395,6 +396,7 @@ export default class MysteryEncounter implements IMysteryEncounter { return false; } // this means we CAN have the same pokemon be a primary and secondary pokemon, so just choose any qualifying one randomly. + // TODO: should this use `randSeedItem`? this.primaryPokemon = qualified[randSeedInt(qualified.length, 0)]; return true; } diff --git a/src/data/mystery-encounters/utils/encounter-phase-utils.ts b/src/data/mystery-encounters/utils/encounter-phase-utils.ts index f698f636cac..12328026712 100644 --- a/src/data/mystery-encounters/utils/encounter-phase-utils.ts +++ b/src/data/mystery-encounters/utils/encounter-phase-utils.ts @@ -1099,8 +1099,10 @@ export function calculateMEAggregateStats(baseSpawnWeight: number) { if (biomes! && biomes.length > 0) { const specialBiomes = biomes.filter(b => alwaysPickTheseBiomes.includes(b)); if (specialBiomes.length > 0) { + // TODO: should this use `randSeedItem`? currentBiome = specialBiomes[randSeedInt(specialBiomes.length)]; } else { + // TODO: should this use `randSeedItem`? currentBiome = biomes[randSeedInt(biomes.length)]; } } diff --git a/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts b/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts index f6ac5b0d38b..32a4aa26915 100644 --- a/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts +++ b/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts @@ -111,20 +111,24 @@ export function getRandomPlayerPokemon( // If there is only 1 legal/unfainted mon left, select from fainted legal mons const faintedLegalMons = party.filter(p => (!isAllowed || p.isAllowedInChallenge()) && p.isFainted()); if (faintedLegalMons.length > 0) { + // TODO: should this use `randSeedItem`? chosenIndex = randSeedInt(faintedLegalMons.length); chosenPokemon = faintedLegalMons[chosenIndex]; } } if (!chosenPokemon && fullyLegalMons.length > 0) { + // TODO: should this use `randSeedItem`? chosenIndex = randSeedInt(fullyLegalMons.length); chosenPokemon = fullyLegalMons[chosenIndex]; } if (!chosenPokemon && isAllowed && allowedOnlyMons.length > 0) { + // TODO: should this use `randSeedItem`? chosenIndex = randSeedInt(allowedOnlyMons.length); chosenPokemon = allowedOnlyMons[chosenIndex]; } if (!chosenPokemon) { // If no other options worked, returns fully random + // TODO: should this use `randSeedItem`? chosenIndex = randSeedInt(party.length); chosenPokemon = party[chosenIndex]; } diff --git a/src/field/arena.ts b/src/field/arena.ts index 8d7e5037852..55f0d6b4f3c 100644 --- a/src/field/arena.ts +++ b/src/field/arena.ts @@ -152,6 +152,7 @@ export class Arena { if (!tierPool.length) { ret = globalScene.randomSpecies(waveIndex, level); } else { + // TODO: should this use `randSeedItem`? const entry = tierPool[randSeedInt(tierPool.length)]; let species: SpeciesId; if (typeof entry === "number") { @@ -163,6 +164,7 @@ export class Arena { if (level >= levelThreshold) { const speciesIds = entry[levelThreshold]; if (speciesIds.length > 1) { + // TODO: should this use `randSeedItem`? species = speciesIds[randSeedInt(speciesIds.length)]; } else { species = speciesIds[0]; diff --git a/src/field/trainer.ts b/src/field/trainer.ts index 8ac896e2717..a4d125eb9da 100644 --- a/src/field/trainer.ts +++ b/src/field/trainer.ts @@ -421,7 +421,8 @@ export default class Trainer extends Phaser.GameObjects.Container { // If useNewSpeciesPool is true, we need to generate a new species from the new species pool, otherwise we generate a random species let species = useNewSpeciesPool - ? getPokemonSpecies(newSpeciesPool[Math.floor(randSeedInt(newSpeciesPool.length))]) + ? // TODO: should this use `randSeedItem`? + getPokemonSpecies(newSpeciesPool[Math.floor(randSeedInt(newSpeciesPool.length))]) : template.isSameSpecies(index) && index > offset ? getPokemonSpecies( battle.enemyParty[offset].species.getTrainerSpeciesForLevel( @@ -619,6 +620,8 @@ export default class Trainer extends Phaser.GameObjects.Container { if (maxScorePartyMemberIndexes.length > 1) { let rand: number; + // TODO: should this use `randSeedItem`? + globalScene.executeWithSeedOffset( () => (rand = randSeedInt(maxScorePartyMemberIndexes.length)), globalScene.currentBattle.turn << 2, diff --git a/src/modifier/modifier-type.ts b/src/modifier/modifier-type.ts index 6c5be4399ce..eff5e084ec7 100644 --- a/src/modifier/modifier-type.ts +++ b/src/modifier/modifier-type.ts @@ -103,7 +103,7 @@ import { getVoucherTypeIcon, getVoucherTypeName, VoucherType } from "#app/system import type { PokemonMoveSelectFilter, PokemonSelectFilter } from "#app/ui/party-ui-handler"; import PartyUiHandler from "#app/ui/party-ui-handler"; import { getModifierTierTextTint } from "#app/ui/text"; -import { formatMoney, isNullOrUndefined, NumberHolder, padInt, randSeedInt } from "#app/utils/common"; +import { formatMoney, isNullOrUndefined, NumberHolder, padInt, randSeedInt, randSeedItem } from "#app/utils/common"; import { getEnumKeys, getEnumValues } from "#app/utils/enums"; import { BattlerTagType } from "#enums/battler-tag-type"; import { BerryType } from "#enums/berry-type"; @@ -1558,6 +1558,7 @@ class TmModifierTypeGenerator extends ModifierTypeGenerator { if (!tierUniqueCompatibleTms.length) { return null; } + // TODO: should this use `randSeedItem`? const randTmIndex = randSeedInt(tierUniqueCompatibleTms.length); return new TmModifierType(tierUniqueCompatibleTms[randTmIndex]); }); @@ -1611,6 +1612,7 @@ class EvolutionItemModifierTypeGenerator extends ModifierTypeGenerator { return null; } + // TODO: should this use `randSeedItem`? return new EvolutionItemModifierType(evolutionItemPool[randSeedInt(evolutionItemPool.length)]!); // TODO: is the bang correct? }); } @@ -1696,6 +1698,7 @@ export class FormChangeItemModifierTypeGenerator extends ModifierTypeGenerator { return null; } + // TODO: should this use `randSeedItem`? return new FormChangeItemModifierType(formChangeItemPool[randSeedInt(formChangeItemPool.length)]); }); } @@ -1966,7 +1969,7 @@ const modifierTypeInitObj = Object.freeze({ if (pregenArgs && pregenArgs.length === 1 && pregenArgs[0] in Nature) { return new PokemonNatureChangeModifierType(pregenArgs[0] as Nature); } - return new PokemonNatureChangeModifierType(randSeedInt(getEnumValues(Nature).length) as Nature); + return new PokemonNatureChangeModifierType(randSeedItem(getEnumValues(Nature))); }), MYSTICAL_ROCK: () => diff --git a/src/phases/select-biome-phase.ts b/src/phases/select-biome-phase.ts index e8b4946b6d1..679d159bc01 100644 --- a/src/phases/select-biome-phase.ts +++ b/src/phases/select-biome-phase.ts @@ -56,6 +56,7 @@ export class SelectBiomePhase extends BattlePhase { delay: 1000, }); } else { + // TODO: should this use `randSeedItem`? setNextBiome(biomes[randSeedInt(biomes.length)]); } } else if (biomeLinks.hasOwnProperty(currentBiome)) { diff --git a/src/utils/common.ts b/src/utils/common.ts index 3f13d40c82c..c9029f0fdde 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -9,6 +9,7 @@ export type nil = null | undefined; export const MissingTextureKey = "__MISSING"; // TODO: Draft tests for these utility functions +// TODO: Break up this file /** * Convert a `snake_case` string in any capitalization (such as one from an enum reverse mapping) * into a readable `Title Case` version. diff --git a/src/utils/enums.ts b/src/utils/enums.ts index dcc3ce1d335..1e1e924fe01 100644 --- a/src/utils/enums.ts +++ b/src/utils/enums.ts @@ -1,15 +1,23 @@ -// biome-ignore lint/correctness/noUnusedImports: Used for a JSDoc comment import type { EnumOrObject, EnumValues, TSNumericEnum, NormalEnum } from "#app/@types/enum-types"; /** * Return the string keys of an Enum object, excluding reverse-mapped numbers. * @param enumType - The numeric enum to retrieve keys for. * @returns An ordered array of all of `enumType`'s string keys. + * @example + * enum fruit { + * apple = 1, + * banana = 2, + * cherry = 3, + * orange = 12, + * }; + * + * console.log(getEnumKeys(fruit)); // output: ["apple", "banana", "cherry", "orange"] * @remarks * To retrieve the keys of a {@linkcode NormalEnum}, use {@linkcode Object.keys} instead. */ export function getEnumKeys(enumType: TSNumericEnum): (keyof E)[] { - // All enum values are either normal or reverse mapped, so we can retrieve the keys by filtering out all strings. + // All enum values are either normal numbers or reverse mapped strings, so we can retrieve the keys by filtering out numbers. return Object.values(enumType).filter(v => typeof v === "string"); } @@ -17,10 +25,20 @@ export function getEnumKeys(enumType: TSNumericEnum): * Return the numeric values of a numeric Enum object, excluding reverse-mapped strings. * @param enumType - The enum object to retrieve keys for. * @returns An ordered array of all of `enumType`'s number values. + * @example + * enum fruit { + * apple = 1, + * banana = 2, + * cherry = 3, + * orange = 12, + * }; + * + * console.log(getEnumValues(fruit)); // output: [1, 2, 3, 12] + * * @remarks * To retrieve the keys of a {@linkcode NormalEnum}, use {@linkcode Object.values} instead. */ -// NB: This does not use `EnumValues` due to variable highlighting in IDEs. +// NB: This does not use `EnumValues` as it messes with variable highlighting in IDEs. export function getEnumValues(enumType: TSNumericEnum): E[keyof E][] { return Object.values(enumType).filter(v => typeof v !== "string") as E[keyof E][]; } @@ -28,7 +46,7 @@ export function getEnumValues(enumType: TSNumericEnum /** * Return the name of the key that matches the given Enum value. * Can be used to emulate Typescript reverse mapping for `const object`s or string enums. - * @param object - The {@linkcode EnumOrObject} to check + * @param object - The {@linkcode NormalEnum} to check. * @param val - The value to get the key of. * @returns The name of the key with the specified value. * @example @@ -36,15 +54,15 @@ export function getEnumValues(enumType: TSNumericEnum * one: 1, * two: 2, * } as const; - * console.log(enumValueToKey(thing, thing.two)); // output: "two" + * console.log(enumValueToKey(thing, 2)); // output: "two" * @throws Error if an invalid enum value is passed to the function * @remarks * If multiple keys map to the same value, the first one (in insertion order) will be retrieved. */ -export function enumValueToKey(object: T, val: T[keyof T]): keyof T { +export function enumValueToKey(object: NormalEnum, val: EnumValues): keyof T { for (const [key, value] of Object.entries(object)) { if (val === value) { - return key as keyof T; + return key; } } throw new Error(`Invalid value passed to \`enumValueToKey\`! Value: ${val}`); From acf57b7a57e8630351269d116b24bdc53a94003d Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Mon, 23 Jun 2025 22:31:26 -0400 Subject: [PATCH 3/4] WIP --- src/@types/enum-types.ts | 38 +----------- src/utils/enums.ts | 7 ++- test/types/enum-types.test-d.ts | 101 ++++++++++++++++++++++++++++++++ vitest.config.ts | 10 +++- 4 files changed, 114 insertions(+), 42 deletions(-) create mode 100644 test/types/enum-types.test-d.ts diff --git a/src/@types/enum-types.ts b/src/@types/enum-types.ts index 4abf36b4226..84df0a96505 100644 --- a/src/@types/enum-types.ts +++ b/src/@types/enum-types.ts @@ -3,7 +3,7 @@ export type EnumOrObject = Record; /** * Utility type to extract the enum values from a `const object`, - * or convert an `enum` object produced by `typeof Enum` into the union type representing its values. + * or convert an `enum` interface produced by `typeof Enum` into the union type representing its values. */ export type EnumValues = E[keyof E]; @@ -12,43 +12,7 @@ export type EnumValues = E[keyof E]; * @example * TSNumericEnum */ -// NB: this works because `EnumValues` returns the underlying Enum union type. export type TSNumericEnum = number extends EnumValues ? T : never; /** Generic type constraint representing a non reverse-mapped TS enum or `const object`. */ export type NormalEnum = Exclude>; - -// ### Type check tests - -enum testEnumNum { - testN1 = 1, - testN2 = 2, -} - -enum testEnumString { - testS1 = "apple", - testS2 = "banana", -} - -const testObjNum = { testON1: 1, testON2: 2 } as const; - -const testObjString = { testOS1: "apple", testOS2: "banana" } as const; - -testEnumNum satisfies EnumOrObject; -testEnumString satisfies EnumOrObject; -testObjNum satisfies EnumOrObject; -testObjString satisfies EnumOrObject; - -// @ts-expect-error - This is intentionally supposed to fail as an example -testEnumNum satisfies NormalEnum; -testEnumString satisfies NormalEnum; -testObjNum satisfies NormalEnum; -testObjString satisfies NormalEnum; - -testEnumNum satisfies TSNumericEnum; -// @ts-expect-error - This is intentionally supposed to fail as an example -testEnumString satisfies TSNumericEnum; -// @ts-expect-error - This is intentionally supposed to fail as an example -testObjNum satisfies TSNumericEnum; -// @ts-expect-error - This is intentionally supposed to fail as an example -testObjString satisfies TSNumericEnum; diff --git a/src/utils/enums.ts b/src/utils/enums.ts index 1e1e924fe01..7193d014ccc 100644 --- a/src/utils/enums.ts +++ b/src/utils/enums.ts @@ -38,7 +38,7 @@ export function getEnumKeys(enumType: TSNumericEnum): * @remarks * To retrieve the keys of a {@linkcode NormalEnum}, use {@linkcode Object.values} instead. */ -// NB: This does not use `EnumValues` as it messes with variable highlighting in IDEs. +// NB: This intentionally does not use `EnumValues` as using `E[keyof E]` leads to improved variable highlighting in IDEs. export function getEnumValues(enumType: TSNumericEnum): E[keyof E][] { return Object.values(enumType).filter(v => typeof v !== "string") as E[keyof E][]; } @@ -59,7 +59,10 @@ export function getEnumValues(enumType: TSNumericEnum * @remarks * If multiple keys map to the same value, the first one (in insertion order) will be retrieved. */ -export function enumValueToKey(object: NormalEnum, val: EnumValues): keyof T { +export function enumValueToKey>( + object: NormalEnum, + val: V, +): keyof T { for (const [key, value] of Object.entries(object)) { if (val === value) { return key; diff --git a/test/types/enum-types.test-d.ts b/test/types/enum-types.test-d.ts new file mode 100644 index 00000000000..eb1621596bf --- /dev/null +++ b/test/types/enum-types.test-d.ts @@ -0,0 +1,101 @@ +import type { EnumOrObject, EnumValues, TSNumericEnum, NormalEnum } from "#app/@types/enum-types"; + +import type { getEnumKeys, getEnumValues } from "#app/utils/enums"; +import { enumValueToKey } from "#app/utils/enums"; + +import { expectTypeOf, describe, it } from "vitest"; + +enum testEnumNum { + testN1 = 1, + testN2 = 2, +} + +enum testEnumString { + testS1 = "apple", + testS2 = "banana", +} + +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>().toEqualTypeOf(); + expectTypeOf>().branded.toEqualTypeOf<1 | 2>(); + + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toMatchTypeOf<"apple" | "banana">(); + }); + + it("should produce union of const object values as type", () => { + expectTypeOf>().toEqualTypeOf<1 | 2>(); + + expectTypeOf>().toEqualTypeOf<"apple" | "banana">(); + }); + }); + + describe("TSNumericEnum", () => { + it("should match numeric enums", () => { + expectTypeOf>().toEqualTypeOf(); + }); + + it("should not match string enums or const objects", () => { + expectTypeOf>().toBeNever(); + expectTypeOf>().toBeNever(); + expectTypeOf>().toBeNever(); + }); + }); + + describe("NormalEnum", () => { + it("should match string enums or const objects", () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + it("should not match numeric enums", () => { + expectTypeOf>().toBeNever(); + }); + }); + + describe("EnumOrObject", () => { + it("should match any enum or const object", () => { + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + }); + + it("should not match an enum value union w/o typeof", () => { + expectTypeOf().not.toMatchTypeOf(); + expectTypeOf().not.toMatchTypeOf(); + }); + + it("should be equivalent to `TSNumericEnum | NormalEnum`", () => { + expectTypeOf().branded.toEqualTypeOf | NormalEnum>(); + }); + }); +}); + +describe("Enum Functions", () => { + describe("getEnumKeys", () => { + it("should retrieve keys of numeric enum", () => { + expectTypeOf>().returns.toEqualTypeOf<("testN1" | "testN2")[]>(); + }); + }); + + describe("getEnumValues", () => { + it("should retrieve values of numeric enum", () => { + expectTypeOf>().returns.branded.toEqualTypeOf<(1 | 2)[]>(); + }); + }); + + describe("enumValueToKey", () => { + it("should retrieve values for a given key", () => { + // @ts-expect-error oopsie + expectTypeOf(enumValueToKey(testEnumString, testEnumString.testS1)).toEqualTypeOf<"testS1">(); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index c781bde97ed..14c8f0508eb 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -28,19 +28,23 @@ export default defineProject(({ mode }) => ({ } }, }, - environment: "jsdom" as const, + environment: "jsdom", environmentOptions: { jsdom: { resources: "usable", }, }, + typecheck: { + tsconfig: "tsconfig.json", + include: ["./test/types/**/*.{test,spec}{-|.}d.ts"], + }, threads: false, trace: true, restoreMocks: true, watch: false, coverage: { - provider: "istanbul" as const, - reportsDirectory: "coverage" as const, + provider: "istanbul", + reportsDirectory: "coverage", reporters: ["text-summary", "html"], }, name: "main", From 489295f2c0bb0b51afa8548fe8bad0e5979a5c14 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Mon, 23 Jun 2025 22:50:21 -0400 Subject: [PATCH 4/4] Fiexd up tests --- src/@types/type-helpers.ts | 19 +++++++++++++++---- src/utils/enums.ts | 22 ++++++++++++---------- test/types/enum-types.test-d.ts | 12 +++++++++--- 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/src/@types/type-helpers.ts b/src/@types/type-helpers.ts index 2d00b1faf4a..375d8f64de3 100644 --- a/src/@types/type-helpers.ts +++ b/src/@types/type-helpers.ts @@ -2,8 +2,10 @@ * A collection of custom utility types that aid in type checking and ensuring strict type conformity */ -// biome-ignore lint/correctness/noUnusedImports: Used in a tsdoc comment +// biome-ignore-start lint/correctness/noUnusedImports: Used in a tsdoc comment +import type { EnumValues } from "#app/@types/enum-types"; import type { AbAttr } from "./ability-types"; +// biome-ignore-end lint/correctness/noUnusedImports: Used in a tsdoc comment /** * Exactly matches the type of the argument, preventing adding additional properties. @@ -11,7 +13,7 @@ import type { AbAttr } from "./ability-types"; * ⚠️ Should never be used with `extends`, as this will nullify the exactness of the type. * * As an example, used to ensure that the parameters of {@linkcode AbAttr.canApply} and {@linkcode AbAttr.getTriggerMessage} are compatible with - * the type of the apply method + * the type of its {@linkcode AbAttr.apply | apply} method. * * @typeParam T - The type to match exactly */ @@ -26,9 +28,18 @@ export type Exact = { export type Closed = X; /** - * Remove `readonly` from all properties of the provided type - * @typeParam T - The type to make mutable + * Remove `readonly` from all properties of the provided type. + * @typeParam T - The type to make mutable. */ export type Mutable = { -readonly [P in keyof T]: T[P]; }; + +/** + * Type helper to obtain the keys associated with a given value inside a `const object`. + * @typeParam O - The type of the object + * @typeParam V - The type of one of O's values + */ +export type InferKeys, V extends EnumValues> = { + [K in keyof O]: O[K] extends V ? K : never; +}[keyof O]; diff --git a/src/utils/enums.ts b/src/utils/enums.ts index 7193d014ccc..c13b5dc780e 100644 --- a/src/utils/enums.ts +++ b/src/utils/enums.ts @@ -1,9 +1,10 @@ import type { EnumOrObject, EnumValues, TSNumericEnum, NormalEnum } from "#app/@types/enum-types"; +import type { InferKeys } from "#app/@types/type-helpers"; /** * Return the string keys of an Enum object, excluding reverse-mapped numbers. - * @param enumType - The numeric enum to retrieve keys for. - * @returns An ordered array of all of `enumType`'s string keys. + * @param enumType - The numeric enum to retrieve keys for + * @returns An ordered array of all of `enumType`'s string keys * @example * enum fruit { * apple = 1, @@ -23,8 +24,8 @@ export function getEnumKeys(enumType: TSNumericEnum): /** * Return the numeric values of a numeric Enum object, excluding reverse-mapped strings. - * @param enumType - The enum object to retrieve keys for. - * @returns An ordered array of all of `enumType`'s number values. + * @param enumType - The enum object to retrieve keys for + * @returns An ordered array of all of `enumType`'s number values * @example * enum fruit { * apple = 1, @@ -46,9 +47,9 @@ export function getEnumValues(enumType: TSNumericEnum /** * Return the name of the key that matches the given Enum value. * Can be used to emulate Typescript reverse mapping for `const object`s or string enums. - * @param object - The {@linkcode NormalEnum} to check. - * @param val - The value to get the key of. - * @returns The name of the key with the specified value. + * @param object - The {@linkcode NormalEnum} to check + * @param val - The value to get the key of + * @returns The name of the key with the specified value * @example * const thing = { * one: 1, @@ -57,15 +58,16 @@ export function getEnumValues(enumType: TSNumericEnum * console.log(enumValueToKey(thing, 2)); // output: "two" * @throws Error if an invalid enum value is passed to the function * @remarks - * If multiple keys map to the same value, the first one (in insertion order) will be retrieved. + * 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>( object: NormalEnum, val: V, -): keyof T { +): InferKeys { for (const [key, value] of Object.entries(object)) { if (val === value) { - return key; + return key as InferKeys; } } throw new Error(`Invalid value passed to \`enumValueToKey\`! Value: ${val}`); diff --git a/test/types/enum-types.test-d.ts b/test/types/enum-types.test-d.ts index eb1621596bf..bdc7146d41a 100644 --- a/test/types/enum-types.test-d.ts +++ b/test/types/enum-types.test-d.ts @@ -1,7 +1,7 @@ import type { EnumOrObject, EnumValues, TSNumericEnum, NormalEnum } from "#app/@types/enum-types"; import type { getEnumKeys, getEnumValues } from "#app/utils/enums"; -import { enumValueToKey } from "#app/utils/enums"; +import type { enumValueToKey } from "#app/utils/enums"; import { expectTypeOf, describe, it } from "vitest"; @@ -94,8 +94,14 @@ describe("Enum Functions", () => { describe("enumValueToKey", () => { it("should retrieve values for a given key", () => { - // @ts-expect-error oopsie - expectTypeOf(enumValueToKey(testEnumString, testEnumString.testS1)).toEqualTypeOf<"testS1">(); + expectTypeOf< + typeof enumValueToKey + >().returns.toEqualTypeOf<"testS1">(); + expectTypeOf>().returns.toEqualTypeOf< + "testS1" | "testS2" + >(); + expectTypeOf>().returns.toEqualTypeOf<"testON1">(); + expectTypeOf>().returns.toEqualTypeOf<"testON1" | "testON2">(); }); }); });