[Dev] Updated enum utils to have strong typing (#6024)

* Updated enum utils to refuse non-enum values;
added strong typing on return values

* Moved Enum functions to own file; added type helpers for enums

* Cleaned up some code

* Fixed up tests

* Fix training-session-encounter.ts
This commit is contained in:
Bertie690 2025-07-15 10:14:02 +02:00 committed by GitHub
parent 2e06b10f40
commit e05d85977e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 322 additions and 106 deletions

18
src/@types/enum-types.ts Normal file
View File

@ -0,0 +1,18 @@
/** Union type accepting any TS Enum or `const object`, with or without reverse mapping. */
export type EnumOrObject = Record<string | number, string | number>;
/**
* Utility type to extract the enum values from a `const object`,
* or convert an `enum` interface produced by `typeof Enum` into the union type representing its values.
*/
export type EnumValues<E> = E[keyof E];
/**
* Generic type constraint representing a TS numeric enum with reverse mappings.
* @example
* TSNumericEnum<typeof WeatherType>
*/
export type TSNumericEnum<T extends EnumOrObject> = number extends EnumValues<T> ? T : never;
/** Generic type constraint representing a non reverse-mapped TS enum or `const object`. */
export type NormalEnum<T extends EnumOrObject> = Exclude<T, TSNumericEnum<T>>;

View File

@ -2,8 +2,11 @@
* 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
import type { AbAttr } from "#types/ability-types";
// biome-ignore-start lint/correctness/noUnusedImports: Used in a tsdoc comment
import type { AbAttr } from "#abilities/ability";
// biome-ignore-end lint/correctness/noUnusedImports: Used in a tsdoc comment
import type { EnumValues } from "#types/enum-types";
/**
* Exactly matches the type of the argument, preventing adding additional properties.
@ -11,7 +14,7 @@ import type { AbAttr } from "#types/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 +29,18 @@ export type Exact<T> = {
export type Closed<X> = 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<T> = {
-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<O extends Record<keyof any, unknown>, V extends EnumValues<O>> = {
[K in keyof O]: O[K] extends V ? K : never;
}[keyof O];

View File

@ -140,7 +140,6 @@ import {
type Constructor,
fixedInt,
formatMoney,
getEnumValues,
getIvsFromId,
isBetween,
isNullOrUndefined,
@ -150,6 +149,7 @@ import {
shiftCharCodes,
} from "#utils/common";
import { deepMergeSpriteData } from "#utils/data";
import { getEnumValues } from "#utils/enums";
import { getModifierPoolForType, getModifierType } from "#utils/modifier-utils";
import { getPokemonSpecies } from "#utils/pokemon-utils";
import i18next from "i18next";
@ -2178,6 +2178,7 @@ export class BattleScene extends SceneBase {
),
]
: allSpecies.filter(s => s.isCatchable());
// TODO: should this use `randSeedItem`?
return filteredSpecies[randSeedInt(filteredSpecies.length)];
}
@ -2203,6 +2204,7 @@ export class BattleScene extends SceneBase {
}
}
// TODO: should this use `randSeedItem`?
return biomes[randSeedInt(biomes.length)];
}
@ -3726,6 +3728,7 @@ export 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);

View File

@ -26,7 +26,6 @@ import { MusicPreference } from "#system/settings";
import { trainerConfigs } from "#trainers/trainer-config";
import type { TurnMove } from "#types/turn-move";
import {
getEnumValues,
NumberHolder,
randInt,
randomString,
@ -35,6 +34,7 @@ import {
randSeedItem,
shiftCharCodes,
} from "#utils/common";
import { getEnumValues } from "#utils/enums";
export interface TurnCommand {
command: Command;

View File

@ -5,7 +5,8 @@ import { PokemonType } from "#enums/pokemon-type";
import { SpeciesId } from "#enums/species-id";
import { TimeOfDay } from "#enums/time-of-day";
import { TrainerType } from "#enums/trainer-type";
import { getEnumValues, randSeedInt } from "#utils/common";
import { randSeedInt } from "#utils/common";
import { getEnumValues } from "#utils/enums";
import i18next from "i18next";
export function getBiomeName(biome: BiomeId | -1) {
@ -7707,10 +7708,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 ])[]

View File

@ -1,8 +1,8 @@
import { allMoves } from "#data/data-lists";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { getEnumKeys, getEnumValues } from "#utils/common";
import { toReadableString } from "#utils/common";
import { getEnumKeys, getEnumValues } from "#utils/enums";
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`;
}
}

View File

@ -7,15 +7,8 @@ import { MoveFlags } from "#enums/MoveFlags";
import { AnimBlendType, AnimFocus, AnimFrameTarget, ChargeAnim, CommonAnim } from "#enums/move-anims-common";
import { MoveId } from "#enums/move-id";
import type { Pokemon } from "#field/pokemon";
import {
animationFileName,
coerceArray,
getEnumKeys,
getEnumValues,
getFrameMs,
isNullOrUndefined,
type nil,
} from "#utils/common";
import { animationFileName, coerceArray, getFrameMs, isNullOrUndefined, type nil } from "#utils/common";
import { getEnumKeys, getEnumValues } from "#utils/enums";
import Phaser from "phaser";
export class AnimConfig {
@ -1406,10 +1399,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)) {

View File

@ -8,7 +8,8 @@ import { PartyMemberStrength } from "#enums/party-member-strength";
import type { SpeciesId } from "#enums/species-id";
import { PlayerPokemon } from "#field/pokemon";
import type { Starter } from "#ui/starter-select-ui-handler";
import { getEnumValues, randSeedGauss, randSeedInt, randSeedItem } from "#utils/common";
import { randSeedGauss, randSeedInt, randSeedItem } from "#utils/common";
import { getEnumValues } from "#utils/enums";
import { getPokemonSpecies } from "#utils/pokemon-utils";
export interface DailyRunConfig {
@ -165,5 +166,6 @@ export function getDailyStartingBiome(): BiomeId {
}
// Fallback in case something went wrong
// TODO: should this use `randSeedItem`?
return biomes[randSeedInt(biomes.length)];
}

View File

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

View File

@ -87,7 +87,8 @@ import type { AttackMoveResult } from "#types/attack-move-result";
import type { Localizable } from "#types/locales";
import type { ChargingMove, MoveAttrMap, MoveAttrString, MoveClassMap, MoveKindString } from "#types/move-types";
import type { TurnMove } from "#types/turn-move";
import { BooleanHolder, type Constructor, getEnumValues, isNullOrUndefined, NumberHolder, randSeedFloat, randSeedInt, randSeedItem, toDmgValue, toReadableString } from "#utils/common";
import { BooleanHolder, type Constructor, isNullOrUndefined, NumberHolder, randSeedFloat, randSeedInt, randSeedItem, toDmgValue, toReadableString } from "#utils/common";
import { getEnumValues } from "#utils/enums";
import i18next from "i18next";
/**

View File

@ -36,7 +36,8 @@ import { MysteryEncounterBuilder } from "#mystery-encounters/mystery-encounter";
import { MysteryEncounterOptionBuilder } from "#mystery-encounters/mystery-encounter-option";
import i18next from "#plugins/i18n";
import { PokemonData } from "#system/pokemon-data";
import { randSeedInt } from "#utils/common";
import { randSeedItem } from "#utils/common";
import { getEnumValues } from "#utils/enums";
/** the i18n namespace for the encounter */
const namespace = "mysteryEncounters/berriesAbound";
@ -310,7 +311,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();

View File

@ -289,6 +289,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)]));
@ -386,6 +387,7 @@ export const BugTypeSuperfanEncounter: MysteryEncounter = MysteryEncounterBuilde
specialOptions.push(rareFormChangeModifier);
}
if (specialOptions.length > 0) {
// TODO: should this use `randSeedItem`?
modifierOptions.push(specialOptions[randSeedInt(specialOptions.length)]);
}

View File

@ -138,6 +138,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 };

View File

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

View File

@ -28,7 +28,8 @@ import { MysteryEncounterOptionBuilder } from "#mystery-encounters/mystery-encou
import { PokemonData } from "#system/pokemon-data";
import type { HeldModifierConfig } from "#types/held-modifier-config";
import type { OptionSelectItem } from "#ui/abstact-option-select-ui-handler";
import { getEnumValues, isNullOrUndefined, randSeedShuffle } from "#utils/common";
import { isNullOrUndefined, randSeedShuffle } from "#utils/common";
import { getEnumValues } from "#utils/enums";
import i18next from "i18next";
/** The i18n namespace for the encounter */

View File

@ -305,6 +305,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);
@ -466,6 +467,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);

View File

@ -144,11 +144,13 @@ export 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;

View File

@ -381,6 +381,7 @@ export 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;
@ -391,6 +392,7 @@ export 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;
}

View File

@ -1098,8 +1098,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)];
}
}

View File

@ -110,20 +110,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];
}

View File

@ -143,6 +143,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") {
@ -154,6 +155,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];

View File

@ -155,7 +155,6 @@ import {
coerceArray,
deltaRgb,
fixedInt,
getEnumValues,
getIvsFromId,
isBetween,
isNullOrUndefined,
@ -169,6 +168,7 @@ import {
rgbToHsv,
toDmgValue,
} from "#utils/common";
import { getEnumValues } from "#utils/enums";
import { getPokemonSpecies } from "#utils/pokemon-utils";
import { argbFromRgba, QuantizerCelebi, rgbaFromArgb } from "@material/material-color-utilities";
import i18next from "i18next";

View File

@ -420,7 +420,8 @@ export 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(
@ -618,6 +619,8 @@ export 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,

View File

@ -15,8 +15,8 @@ import type { SettingKeyboard } from "#system/settings-keyboard";
import { MoveTouchControlsHandler } from "#ui/move-touch-controls-handler";
import type { SettingsGamepadUiHandler } from "#ui/settings-gamepad-ui-handler";
import type { SettingsKeyboardUiHandler } from "#ui/settings-keyboard-ui-handler";
import { getEnumValues } from "#utils/common";
import { deepCopy } from "#utils/data";
import { getEnumValues } from "#utils/enums";
import Phaser from "phaser";
export interface DeviceMapping {

View File

@ -21,7 +21,8 @@ import { initAchievements } from "#system/achv";
import { initVouchers } from "#system/voucher";
import { initStatsKeys } from "#ui/game-stats-ui-handler";
import { getWindowVariantSuffix, WindowVariant } from "#ui/ui-theme";
import { getEnumValues, hasAllLocalizedSprites, localPing } from "#utils/common";
import { hasAllLocalizedSprites, localPing } from "#utils/common";
import { getEnumValues } from "#utils/enums";
import i18next from "i18next";
export class LoadingScene extends SceneBase {

View File

@ -116,15 +116,8 @@ import type { ModifierTypeFunc, WeightedModifierTypeWeightFunc } from "#types/mo
import type { PokemonMoveSelectFilter, PokemonSelectFilter } from "#ui/party-ui-handler";
import { PartyUiHandler } from "#ui/party-ui-handler";
import { getModifierTierTextTint } from "#ui/text";
import {
formatMoney,
getEnumKeys,
getEnumValues,
isNullOrUndefined,
NumberHolder,
padInt,
randSeedInt,
} from "#utils/common";
import { formatMoney, isNullOrUndefined, NumberHolder, padInt, randSeedInt, randSeedItem } from "#utils/common";
import { getEnumKeys, getEnumValues } from "#utils/enums";
import { getModifierPoolForType, getModifierType } from "#utils/modifier-utils";
import i18next from "i18next";
@ -1524,6 +1517,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]);
});
@ -1577,6 +1571,7 @@ class EvolutionItemModifierTypeGenerator extends ModifierTypeGenerator {
return null;
}
// TODO: should this use `randSeedItem`?
return new EvolutionItemModifierType(evolutionItemPool[randSeedInt(evolutionItemPool.length)]!); // TODO: is the bang correct?
});
}
@ -1662,6 +1657,7 @@ export class FormChangeItemModifierTypeGenerator extends ModifierTypeGenerator {
return null;
}
// TODO: should this use `randSeedItem`?
return new FormChangeItemModifierType(formChangeItemPool[randSeedInt(formChangeItemPool.length)]);
});
}
@ -1932,7 +1928,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: () =>

View File

@ -26,7 +26,8 @@ import { applyMoveAttrs } from "#moves/apply-attrs";
import { frenzyMissFunc } from "#moves/move-utils";
import type { PokemonMove } from "#moves/pokemon-move";
import { BattlePhase } from "#phases/battle-phase";
import { enumValueToKey, NumberHolder } from "#utils/common";
import { NumberHolder } from "#utils/common";
import { enumValueToKey } from "#utils/enums";
import i18next from "i18next";
export class MovePhase extends BattlePhase {

View File

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

View File

@ -62,8 +62,9 @@ import { VoucherType, vouchers } from "#system/voucher";
import { trainerConfigs } from "#trainers/trainer-config";
import type { DexData, DexEntry } from "#types/dex-data";
import { RUN_HISTORY_LIMIT } from "#ui/run-history-ui-handler";
import { executeIf, fixedInt, getEnumKeys, isLocal, NumberHolder, randInt, randSeedItem } from "#utils/common";
import { executeIf, fixedInt, isLocal, NumberHolder, randInt, randSeedItem } from "#utils/common";
import { decrypt, encrypt } from "#utils/data";
import { getEnumKeys } from "#utils/enums";
import { getPokemonSpecies } from "#utils/pokemon-utils";
import { AES, enc } from "crypto-js";
import i18next from "i18next";

View File

@ -2,7 +2,8 @@ import { pokerogueApi } from "#api/pokerogue-api";
import { globalScene } from "#app/global-scene";
import { addTextObject, TextStyle } from "#ui/text";
import { addWindow, WindowVariant } from "#ui/ui-theme";
import { executeIf, getEnumKeys } from "#utils/common";
import { executeIf } from "#utils/common";
import { getEnumKeys } from "#utils/enums";
import i18next from "i18next";
export interface RankingEntry {

View File

@ -11,7 +11,8 @@ import { getVoucherTypeIcon, VoucherType } from "#system/voucher";
import { MessageUiHandler } from "#ui/message-ui-handler";
import { addTextObject, getEggTierTextTint, getTextStyleOptions, TextStyle } from "#ui/text";
import { addWindow } from "#ui/ui-theme";
import { fixedInt, getEnumValues, randSeedShuffle } from "#utils/common";
import { fixedInt, randSeedShuffle } from "#utils/common";
import { getEnumValues } from "#utils/enums";
import { getPokemonSpecies } from "#utils/pokemon-utils";
import i18next from "i18next";

View File

@ -13,8 +13,9 @@ import { BgmBar } from "#ui/bgm-bar";
import { MessageUiHandler } from "#ui/message-ui-handler";
import { addTextObject, getTextStyleOptions, TextStyle } from "#ui/text";
import { addWindow, WindowVariant } from "#ui/ui-theme";
import { fixedInt, getEnumKeys, isLocal, sessionIdKey } from "#utils/common";
import { fixedInt, isLocal, sessionIdKey } from "#utils/common";
import { getCookie } from "#utils/cookies";
import { getEnumValues } from "#utils/enums";
import { isBeta } from "#utils/utility-vars";
import i18next from "i18next";
@ -76,11 +77,9 @@ export 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 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 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);

View File

@ -55,13 +55,13 @@ import { addBBCodeTextObject, addTextObject, getTextColor, getTextStyleOptions,
import { addWindow } from "#ui/ui-theme";
import {
BooleanHolder,
getEnumKeys,
getLocalizedSpriteKey,
isNullOrUndefined,
padInt,
rgbHexToRgba,
toReadableString,
} from "#utils/common";
import { getEnumValues } from "#utils/enums";
import { getPokemonSpecies } from "#utils/pokemon-utils";
import { argbFromRgba } from "@material/material-color-utilities";
import i18next from "i18next";
@ -640,7 +640,7 @@ export 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,
@ -744,7 +744,7 @@ export 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);

View File

@ -29,7 +29,6 @@ import { UiHandler } from "#ui/ui-handler";
import {
fixedInt,
formatStat,
getEnumValues,
getLocalizedSpriteKey,
getShinyDescriptor,
isNullOrUndefined,
@ -37,6 +36,7 @@ import {
rgbHexToRgba,
toReadableString,
} from "#utils/common";
import { getEnumValues } from "#utils/enums";
import { argbFromRgba } from "@material/material-color-utilities";
import i18next from "i18next";

View File

@ -8,11 +8,19 @@ 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.
* @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(" ");
}
@ -273,18 +281,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<T>(condition: boolean, promiseFunc: () => Promise<T>): Promise<T | null> {
return condition ? promiseFunc() : new Promise<T | null>(resolve => resolve(null));
}
@ -644,25 +640,3 @@ export function coerceArray<T>(input: T): T extends any[] ? T : [T];
export function coerceArray<T>(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<T extends Record<string, string | number>>(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}`);
}

74
src/utils/enums.ts Normal file
View File

@ -0,0 +1,74 @@
import type { EnumOrObject, EnumValues, NormalEnum, TSNumericEnum } 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
* @example
* enum fruit {
* apple = 1,
* banana = 2,
* cherry = 3,
* orange = 12,
* };
*
* console.log(getEnumKeys<typeof fruit>(fruit)); // output: ["apple", "banana", "cherry", "orange"]
* @remarks
* To retrieve the keys of a {@linkcode NormalEnum}, use {@linkcode Object.keys} instead.
*/
export function getEnumKeys<E extends EnumOrObject>(enumType: TSNumericEnum<E>): (keyof E)[] {
// 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");
}
/**
* 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<typeof fruit>(fruit)); // output: [1, 2, 3, 12]
*
* @remarks
* To retrieve the keys of a {@linkcode NormalEnum}, use {@linkcode Object.values} instead.
*/
// NB: This intentionally does not use `EnumValues<E>` as using `E[keyof E]` leads to improved variable highlighting in IDEs.
export function getEnumValues<E extends EnumOrObject>(enumType: TSNumericEnum<E>): 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 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,
* two: 2,
* } as const;
* 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,
* but the return type will be the union of ALL their corresponding keys.
*/
export function enumValueToKey<T extends EnumOrObject, V extends EnumValues<T>>(
object: NormalEnum<T>,
val: V,
): InferKeys<T, V> {
for (const [key, value] of Object.entries(object)) {
if (val === value) {
return key as InferKeys<T, V>;
}
}
throw new Error(`Invalid value passed to \`enumValueToKey\`! Value: ${val}`);
}

View File

@ -0,0 +1,104 @@
import type { EnumOrObject, EnumValues, NormalEnum, TSNumericEnum } from "#app/@types/enum-types";
import type { enumValueToKey, getEnumKeys, getEnumValues } from "#app/utils/enums";
import { describe, expectTypeOf, 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<EnumValues<typeof testEnumNum>>().toEqualTypeOf<testEnumNum>();
expectTypeOf<EnumValues<typeof testEnumNum>>().branded.toEqualTypeOf<1 | 2>();
expectTypeOf<EnumValues<typeof testEnumString>>().toEqualTypeOf<testEnumString>();
expectTypeOf<EnumValues<typeof testEnumString>>().toEqualTypeOf<testEnumString.testS1 | testEnumString.testS2>();
expectTypeOf<EnumValues<typeof testEnumString>>().toMatchTypeOf<"apple" | "banana">();
});
it("should produce union of const object values as type", () => {
expectTypeOf<EnumValues<typeof testObjNum>>().toEqualTypeOf<1 | 2>();
expectTypeOf<EnumValues<typeof testObjString>>().toEqualTypeOf<"apple" | "banana">();
});
});
describe("TSNumericEnum", () => {
it("should match numeric enums", () => {
expectTypeOf<TSNumericEnum<typeof testEnumNum>>().toEqualTypeOf<typeof testEnumNum>();
});
it("should not match string enums or const objects", () => {
expectTypeOf<TSNumericEnum<typeof testEnumString>>().toBeNever();
expectTypeOf<TSNumericEnum<typeof testObjNum>>().toBeNever();
expectTypeOf<TSNumericEnum<typeof testObjString>>().toBeNever();
});
});
describe("NormalEnum", () => {
it("should match string enums or const objects", () => {
expectTypeOf<NormalEnum<typeof testEnumString>>().toEqualTypeOf<typeof testEnumString>();
expectTypeOf<NormalEnum<typeof testObjNum>>().toEqualTypeOf<typeof testObjNum>();
expectTypeOf<NormalEnum<typeof testObjString>>().toEqualTypeOf<typeof testObjString>();
});
it("should not match numeric enums", () => {
expectTypeOf<NormalEnum<typeof testEnumNum>>().toBeNever();
});
});
describe("EnumOrObject", () => {
it("should match any enum or const object", () => {
expectTypeOf<typeof testEnumNum>().toMatchTypeOf<EnumOrObject>();
expectTypeOf<typeof testEnumString>().toMatchTypeOf<EnumOrObject>();
expectTypeOf<typeof testObjNum>().toMatchTypeOf<EnumOrObject>();
expectTypeOf<typeof testObjString>().toMatchTypeOf<EnumOrObject>();
});
it("should not match an enum value union w/o typeof", () => {
expectTypeOf<testEnumNum>().not.toMatchTypeOf<EnumOrObject>();
expectTypeOf<testEnumString>().not.toMatchTypeOf<EnumOrObject>();
});
it("should be equivalent to `TSNumericEnum | NormalEnum`", () => {
expectTypeOf<EnumOrObject>().branded.toEqualTypeOf<TSNumericEnum<EnumOrObject> | NormalEnum<EnumOrObject>>();
});
});
});
describe("Enum Functions", () => {
describe("getEnumKeys", () => {
it("should retrieve keys of numeric enum", () => {
expectTypeOf<typeof getEnumKeys<typeof testEnumNum>>().returns.toEqualTypeOf<("testN1" | "testN2")[]>();
});
});
describe("getEnumValues", () => {
it("should retrieve values of numeric enum", () => {
expectTypeOf<typeof getEnumValues<typeof testEnumNum>>().returns.branded.toEqualTypeOf<(1 | 2)[]>();
});
});
describe("enumValueToKey", () => {
it("should retrieve values for a given key", () => {
expectTypeOf<
typeof enumValueToKey<typeof testEnumString, testEnumString.testS1>
>().returns.toEqualTypeOf<"testS1">();
expectTypeOf<typeof enumValueToKey<typeof testEnumString, testEnumString>>().returns.toEqualTypeOf<
"testS1" | "testS2"
>();
expectTypeOf<typeof enumValueToKey<typeof testObjNum, 1>>().returns.toEqualTypeOf<"testON1">();
expectTypeOf<typeof enumValueToKey<typeof testObjNum, 1 | 2>>().returns.toEqualTypeOf<"testON1" | "testON2">();
});
});
});

View File

@ -34,6 +34,10 @@ export default defineProject(({ mode }) => ({
resources: "usable",
},
},
typecheck: {
tsconfig: "tsconfig.json",
include: ["./test/types/**/*.{test,spec}{-|.}d.ts"],
},
threads: false,
trace: true,
restoreMocks: true,