From fb274077c233a30a3d24679684cb6e04fe319e45 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Fri, 31 Oct 2025 19:01:33 -0400 Subject: [PATCH] Reworked regex to use `matchAll`, added error handling + tests --- src/data/daily-run.ts | 92 +++++++++++++++++++++-------- src/utils/pokemon-utils.ts | 8 +-- test/daily-mode.test.ts | 115 ++++++++++++++++++++++++------------- 3 files changed, 145 insertions(+), 70 deletions(-) diff --git a/src/data/daily-run.ts b/src/data/daily-run.ts index c52bb164f4b..9daabc01529 100644 --- a/src/data/daily-run.ts +++ b/src/data/daily-run.ts @@ -68,9 +68,10 @@ function getDailyRunStarter(starterSpeciesForm: PokemonSpeciesForm, startingLeve undefined, formIndex, undefined, - variant !== undefined ? true : undefined, + variant != null, variant, ); + console.log(`%c${pokemon.shiny} ${variant} ${variant != null}`, "color:blue"); const starter: Starter = { speciesId: starterSpecies.speciesId, shiny: pokemon.shiny, @@ -180,7 +181,11 @@ export function isDailyEventSeed(seed: string): boolean { * Must be updated whenever the `MoveId` enum gets a new digit! */ const MOVE_ID_STRING_LENGTH = 4; - +/** + * The regex literal used to parse daily run custom movesets. + * @privateRemarks + * Intentionally does not use the `g` flag to avoid altering `lastIndex` after each match. + */ const MOVE_ID_SEED_REGEX = /(?<=\/moves)((?:\d{4}){0,4})(?:,((?:\d{4}){0,4}))?(?:,((?:\d{4}){0,4}))?/; /** @@ -223,53 +228,90 @@ function setDailyRunEventStarterMovesets(seed: string, starters: StarterTuple): } } +/** The regex literal string used to extract the content of the "starters" block of Daily Run custom seeds. */ +const STARTER_SEED_PREFIX_REGEX = /\/starters(.*?)(?:\/|$)/; /** - * Expects the seed to contain `starters` followed by 3 `s{\d{4}}` for the starters. The 4 digits are the species ID. \ - * Each starter can optionally be followed by `f{\d{2}}` for the form index and `v{\d{2}}` for the variant. \ - * The order of `f` and `v` does not matter. - * @example `/starterss0003f01s0025v01s0150f02v02` + * The regex literal used to parse daily run custom starter information for a single starter. \ + * Contains a 4-digit species ID, as well as an optional 2-digit form index and 1-digit variant. + * + * If either of form index or variant are omitted, the starter will default to its species' base form/ + * not be shiny, respectively. + */ +const STARTER_SEED_MATCH_REGEX = /(?:s(?\d{4}))(?:f(?
\d{2}))?(?:v(?\d))?/g; + +/** + * Parse a custom daily run seed into a set of pre-defined starters. + * @see {@linkcode STARTER_SEED_MATCH_REGEX} * @param seed - The daily run seed - * @returns An array of {@linkcode Starter}s, or `null` if no valid match. + * @returns An array of {@linkcode Starter}s, or `null` if it did not match. */ // TODO: Rework this setup into JSON or similar - this is quite hard to maintain function getDailyEventSeedStarters(seed: string): StarterTuple | null { - const speciesRegex = i => - `(?s\\d{4})(?:(?f\\d{2})(?v\\d{2})?|(?v\\d{2})(?f\\d{2})?)?`; + if (!isDailyEventSeed(seed)) { + return null; + } - const matcher = new RegExp(`starters${speciesRegex(1)}${speciesRegex(2)}${speciesRegex(3)}`); + const seedAfterPrefix = seed.split(STARTER_SEED_PREFIX_REGEX)[1] as string | undefined; + if (!seedAfterPrefix) { + return null; + } - const speciesConfigurations = matcher.exec(seed)?.groups; + const speciesConfigurations = [...seedAfterPrefix.matchAll(STARTER_SEED_MATCH_REGEX)]; - if (!speciesConfigurations) { + if (speciesConfigurations.length !== 3) { + // TODO: Remove legacy fallback code after next hotfix version - this is needed for Oct 31's daily to function const legacyStarters = getDailyEventSeedStartersLegacy(seed); - if (legacyStarters != null) { - console.log("Using legacy starter parsing for daily run seed."); + if (legacyStarters == null) { return legacyStarters; } - console.error("Invalid starters used for custom daily run seed!"); + console.error("Invalid starters used for custom daily run seed!", seed); return null; } const speciesIds = getEnumValues(SpeciesId); - const starters: Starter[] = []; - for (let i = 0; i < 3; i++) { - const speciesId = Number.parseInt(speciesConfigurations[`species${i + 1}`].slice(1)) as SpeciesId; - const formIndex = Number.parseInt(speciesConfigurations[`form${i + 1}`]?.slice(1) ?? "00"); - let variant: Variant | undefined = Number.parseInt(speciesConfigurations[`variant${i + 1}`]?.slice(1)) as Variant; - if (!speciesIds.includes(speciesId)) { - console.error("Invalid species ID used for custom daily run seed starter:", speciesId); + for (const [i, match] of speciesConfigurations.entries()) { + const { groups } = match; + if (!groups) { + console.error("Invalid seed used for custom daily run starter:", match); + return null; + } + + const { species: speciesStr, form: formStr, variant: variantStr } = groups; + + const speciesId = Number.parseInt(speciesStr) as SpeciesId; + + // NB: We check the parsed integer here to exclude SpeciesID.NONE as well as invalid values; + // other fields only check the string to permit 0 as valid inputs + if (!speciesId || !speciesIds.includes(speciesId)) { + console.error("Invalid species ID used for custom daily run starter:", speciesStr); return null; } const starterSpecies = getPokemonSpecies(speciesId); - if (Number.isNaN(variant) || variant > 2 || (!starterSpecies.hasVariants() && variant !== 0)) { - console.error("Invalid variant used for custom daily run seed starter:", variant); + // Omitted form index = use base form + const starterForm = formStr ? starterSpecies.forms[Number.parseInt(formStr)] : starterSpecies; + + if (!starterForm) { + console.log(starterSpecies.name); + console.error("Invalid form index used for custom daily run starter:", formStr); + return null; + } + + // Get and validate variant + let variant = (variantStr ? Number.parseInt(variantStr) : undefined) as Variant | undefined; + if (!isBetween(variant ?? 0, 0, 2)) { + console.error("Variant used for custom daily run seed starter out of bounds:", variantStr); + return null; + } + + // Fall back to default variant if none exists + if (!starterSpecies.hasVariants() && !!variant) { + console.warn("Variant for custom daily run seed starter does not exist, using base variant...", variant); variant = undefined; } - const starterForm = getPokemonSpeciesForm(speciesId, formIndex); const startingLevel = globalScene.gameMode.getStartingLevel(); const starter = getDailyRunStarter(starterForm, startingLevel, variant); starters.push(starter); diff --git a/src/utils/pokemon-utils.ts b/src/utils/pokemon-utils.ts index e3c8d8eab68..f1716487b34 100644 --- a/src/utils/pokemon-utils.ts +++ b/src/utils/pokemon-utils.ts @@ -118,11 +118,9 @@ export function getFusedSpeciesName(speciesAName: string, speciesBName: string): } export function getPokemonSpeciesForm(species: SpeciesId, formIndex: number): PokemonSpeciesForm { - const retSpecies: PokemonSpecies = - species >= 2000 - ? allSpecies.find(s => s.speciesId === species)! // TODO: is the bang correct? - : allSpecies[species - 1]; - if (formIndex < retSpecies.forms?.length) { + const retSpecies: PokemonSpecies = getPokemonSpecies(species); + + if (formIndex < retSpecies.forms.length) { return retSpecies.forms[formIndex]; } return retSpecies; diff --git a/test/daily-mode.test.ts b/test/daily-mode.test.ts index e5284906318..6ab61cc0ed0 100644 --- a/test/daily-mode.test.ts +++ b/test/daily-mode.test.ts @@ -21,6 +21,8 @@ describe("Daily Mode", () => { beforeEach(() => { game = new GameManager(phaserGame); + + game.override.disableShinies = false; }); afterEach(() => { @@ -41,52 +43,85 @@ describe("Daily Mode", () => { }); describe("Custom Seeds", () => { - it("should support custom moves", async () => { - vi.spyOn(pokerogueApi.daily, "getSeed").mockResolvedValue("/moves0001000200030004,03320006,01300919"); - await game.dailyMode.startBattle(); + describe("Moves", () => { + it("should support custom moves", async () => { + vi.spyOn(pokerogueApi.daily, "getSeed").mockResolvedValue("/moves0001000200030004,03320006,01300919"); + await game.dailyMode.startBattle(); - const [moves1, moves2, moves3] = game.scene.getPlayerParty().map(p => p.moveset.map(pm => pm.moveId)); - expect(moves1, stringifyEnumArray(MoveId, moves1)).toEqual([ - MoveId.POUND, - MoveId.KARATE_CHOP, - MoveId.DOUBLE_SLAP, - MoveId.COMET_PUNCH, - ]); - expect(moves2, stringifyEnumArray(MoveId, moves2)).toEqual([ - MoveId.AERIAL_ACE, - MoveId.PAY_DAY, - expect.anything(), // make sure it doesn't replace normal moveset gen - expect.anything(), - ]); - expect(moves3, stringifyEnumArray(MoveId, moves3)).toEqual([ - MoveId.SKULL_BASH, - MoveId.MALIGNANT_CHAIN, - expect.anything(), - expect.anything(), - ]); + const [moves1, moves2, moves3] = game.scene.getPlayerParty().map(p => p.moveset.map(pm => pm.moveId)); + expect(moves1, stringifyEnumArray(MoveId, moves1)).toEqual([ + MoveId.POUND, + MoveId.KARATE_CHOP, + MoveId.DOUBLE_SLAP, + MoveId.COMET_PUNCH, + ]); + expect(moves2, stringifyEnumArray(MoveId, moves2)).toEqual([ + MoveId.AERIAL_ACE, + MoveId.PAY_DAY, + expect.anything(), // make sure it doesn't replace normal moveset gen + expect.anything(), + ]); + expect(moves3, stringifyEnumArray(MoveId, moves3)).toEqual([ + MoveId.SKULL_BASH, + MoveId.MALIGNANT_CHAIN, + expect.anything(), + expect.anything(), + ]); + }); + + it("should allow omitting movesets for some starters", async () => { + vi.spyOn(pokerogueApi.daily, "getSeed").mockResolvedValue("/moves0001000200030004"); + await game.dailyMode.startBattle(); + + const [moves1, moves2, moves3] = game.scene.getPlayerParty().map(p => p.moveset.map(pm => pm.moveId)); + expect(moves1, stringifyEnumArray(MoveId, moves1)).toEqual([ + MoveId.POUND, + MoveId.KARATE_CHOP, + MoveId.DOUBLE_SLAP, + MoveId.COMET_PUNCH, + ]); + expect(moves2, "was not a random moveset").toHaveLength(4); + expect(moves3, "was not a random moveset").toHaveLength(4); + }); + + it("should skip invalid move IDs", async () => { + vi.spyOn(pokerogueApi.daily, "getSeed").mockResolvedValue("/moves9999,,0919"); + await game.dailyMode.startBattle(); + + const moves = game.field.getPlayerPokemon().moveset.map(pm => pm.moveId); + expect(moves, "invalid move was in moveset").not.toContain(MoveId[9999]); + }); }); - it("should allow omitting movesets for some starters", async () => { - vi.spyOn(pokerogueApi.daily, "getSeed").mockResolvedValue("/moves0001000200030004"); - await game.dailyMode.startBattle(); + describe("Starters", () => { + it("should support custom species IDs", async () => { + vi.spyOn(pokerogueApi.daily, "getSeed").mockResolvedValue("foo/starterss0001s0113s1024"); + await game.dailyMode.startBattle(); - const [moves1, moves2, moves3] = game.scene.getPlayerParty().map(p => p.moveset.map(pm => pm.moveId)); - expect(moves1, stringifyEnumArray(MoveId, moves1)).toEqual([ - MoveId.POUND, - MoveId.KARATE_CHOP, - MoveId.DOUBLE_SLAP, - MoveId.COMET_PUNCH, - ]); - expect(moves2, "was not a random moveset").toHaveLength(4); - expect(moves3, "was not a random moveset").toHaveLength(4); - }); + const party = game.scene.getPlayerParty().map(p => p.species.speciesId); + expect(party, stringifyEnumArray(SpeciesId, party)).toEqual([ + SpeciesId.BULBASAUR, + SpeciesId.CHANSEY, + SpeciesId.TERAPAGOS, + ]); + }); - it("should skip invalid move IDs", async () => { - vi.spyOn(pokerogueApi.daily, "getSeed").mockResolvedValue("/moves9999,,0919"); - await game.dailyMode.startBattle(); + it("should support custom forms and variants", async () => { + vi.spyOn(pokerogueApi.daily, "getSeed").mockResolvedValue("/starterss0006f01v2s0113v0s1024f02"); + await game.dailyMode.startBattle(); - const moves = game.field.getPlayerPokemon().moveset.map(pm => pm.moveId); - expect(moves, "invalid move was in moveset").not.toContain(MoveId[9999]); + const party = game.scene.getPlayerParty().map(p => ({ + speciesId: p.species.speciesId, + variant: p.getVariant(), + form: p.formIndex, + shiny: p.isShiny(), + })); + expect(party).toEqual([ + { speciesId: SpeciesId.CHARIZARD, variant: 2, form: 1, shiny: true }, + { speciesId: SpeciesId.CHANSEY, variant: 0, form: 0, shiny: true }, + { speciesId: SpeciesId.TERAPAGOS, variant: expect.anything(), form: 2, shiny: false }, + ]); + }); }); }); });