diff --git a/src/data/daily-run.ts b/src/data/daily-run.ts index 776dff1bf46..b86442fac0f 100644 --- a/src/data/daily-run.ts +++ b/src/data/daily-run.ts @@ -1,42 +1,36 @@ -import { pokerogueApi } from "#api/pokerogue-api"; import { globalScene } from "#app/global-scene"; import { speciesStarterCosts } from "#balance/starters"; import type { PokemonSpeciesForm } from "#data/pokemon-species"; import { PokemonSpecies } from "#data/pokemon-species"; import { BiomeId } from "#enums/biome-id"; +import { MoveId } from "#enums/move-id"; import { PartyMemberStrength } from "#enums/party-member-strength"; import { SpeciesId } from "#enums/species-id"; -import type { Starter } from "#types/save-data"; -import { randSeedGauss, randSeedInt, randSeedItem } from "#utils/common"; +import type { Starter, StarterMoveset } from "#types/save-data"; +import { isBetween, randSeedGauss, randSeedInt, randSeedItem } from "#utils/common"; import { getEnumValues } from "#utils/enums"; import { getPokemonSpecies, getPokemonSpeciesForm } from "#utils/pokemon-utils"; +import { chunkString } from "#utils/strings"; export interface DailyRunConfig { seed: number; starters: Starter; } +type StarterTuple = [Starter, Starter, Starter]; -export function fetchDailyRunSeed(): Promise { - return new Promise((resolve, _reject) => { - pokerogueApi.daily.getSeed().then(dailySeed => { - resolve(dailySeed); - }); - }); -} - -export function getDailyRunStarters(seed: string): Starter[] { +export function getDailyRunStarters(seed: string): StarterTuple { const starters: Starter[] = []; globalScene.executeWithSeedOffset( () => { - const startingLevel = globalScene.gameMode.getStartingLevel(); - const eventStarters = getDailyEventSeedStarters(seed); if (eventStarters != null) { starters.push(...eventStarters); return; } + // TODO: explain this math + const startingLevel = globalScene.gameMode.getStartingLevel(); const starterCosts: number[] = []; starterCosts.push(Math.min(Math.round(3.5 + Math.abs(randSeedGauss(1))), 8)); starterCosts.push(randSeedInt(9 - starterCosts[0], 1)); @@ -57,9 +51,12 @@ export function getDailyRunStarters(seed: string): Starter[] { seed, ); - return starters; + setDailyRunEventStarterMovesets(seed, starters as StarterTuple); + + return starters as StarterTuple; } +// TODO: Refactor this unmaintainable mess function getDailyRunStarter(starterSpeciesForm: PokemonSpeciesForm, startingLevel: number): Starter { const starterSpecies = starterSpeciesForm instanceof PokemonSpecies ? starterSpeciesForm : getPokemonSpecies(starterSpeciesForm.speciesId); @@ -169,30 +166,83 @@ export function isDailyEventSeed(seed: string): boolean { return globalScene.gameMode.isDaily && seed.length > 24; } +/** + * The length of a single numeric Move ID string. + * Must be updated whenever the `MoveId` enum gets a new digit! + */ +const MOVE_ID_STRING_LENGTH = 4; + +const MOVE_ID_SEED_REGEX = /(?<=\/moves)((?:\d{4}){0,4})(?:,((?:\d{4}){0,4}))?(?:,((?:\d{4}){0,4}))?/; + +/** + * Perform moveset post-processing on Daily run starters. \ + * If the seed matches {@linkcode MOVE_ID_SEED_REGEX}, + * the extracted Move IDs will be used to populate the starters' moveset instead. + * @param seed - The daily run seed + * @param starters - The previously generated starters; will have movesets mutated in place + */ +function setDailyRunEventStarterMovesets(seed: string, starters: StarterTuple): void { + const moveMatch: readonly string[] = MOVE_ID_SEED_REGEX.exec(seed)?.slice(1) ?? []; + if (moveMatch.length === 0) { + return; + } + + if (!isBetween(moveMatch.length, 1, 3)) { + console.error( + "Invalid custom seeded moveset used for daily run seed!\nSeed: %s\nMatch contents: %s", + seed, + moveMatch, + ); + return; + } + + const moveIds = getEnumValues(MoveId); + for (const [i, moveStr] of moveMatch.entries()) { + if (!moveStr) { + // Fallback for empty capture groups from omitted entries + continue; + } + const starter = starters[i]; + const parsedMoveIds = chunkString(moveStr, MOVE_ID_STRING_LENGTH).map(m => Number.parseInt(m) as MoveId); + + if (parsedMoveIds.some(f => !moveIds.includes(f))) { + console.error("Invalid move IDs used for custom daily run seed moveset on starter %d:", i, parsedMoveIds); + continue; + } + + starter.moveset = parsedMoveIds as StarterMoveset; + } +} + /** * Expects the seed to contain `/starters\d{18}/` * where the digits alternate between 4 digits for the species ID and 2 digits for the form index * (left padded with `0`s as necessary). * @returns An array of {@linkcode Starter}s, or `null` if no valid match. */ -export function getDailyEventSeedStarters(seed: string): Starter[] | null { +// TODO: Rework this setup into JSON or similar - this is quite hard to maintain +export function getDailyEventSeedStarters(seed: string): StarterTuple | null { if (!isDailyEventSeed(seed)) { return null; } const starters: Starter[] = []; - const match = /starters(\d{4})(\d{2})(\d{4})(\d{2})(\d{4})(\d{2})/g.exec(seed); + const speciesMatch = /starters(\d{4})(\d{2})(\d{4})(\d{2})(\d{4})(\d{2})/g.exec(seed)?.slice(1); - if (!match || match.length !== 7) { + if (!speciesMatch || speciesMatch.length !== 6) { return null; } - for (let i = 1; i < match.length; i += 2) { - const speciesId = Number.parseInt(match[i]) as SpeciesId; - const formIndex = Number.parseInt(match[i + 1]); + // TODO: Move these to server-side validation + const speciesIds = getEnumValues(SpeciesId); - if (!getEnumValues(SpeciesId).includes(speciesId)) { - console.warn("Invalid species ID used for custom daily run seed starter:", speciesId); + // generate each starter in turn + for (let i = 0; i < 3; i++) { + const speciesId = Number.parseInt(speciesMatch[2 * i]) as SpeciesId; + const formIndex = Number.parseInt(speciesMatch[2 * i + 1]); + + if (!speciesIds.includes(speciesId)) { + console.error("Invalid species ID used for custom daily run seed starter:", speciesId); return null; } @@ -202,7 +252,7 @@ export function getDailyEventSeedStarters(seed: string): Starter[] | null { starters.push(starter); } - return starters; + return starters as StarterTuple; } /** diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 5d50865798e..6f9d8b53249 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -5849,19 +5849,27 @@ export class PlayerPokemon extends Pokemon { } } - tryPopulateMoveset(moveset: StarterMoveset): boolean { + /** + * Attempt to populate this Pokemon's moveset based on those from a Starter + * @param moveset - The {@linkcode StarterMoveset} to use; will override corresponding slots + * of this Pokemon's moveset + * @param ignoreValidate - Whether to ignore validating the passed-in moveset; default `false` + */ + tryPopulateMoveset(moveset: StarterMoveset, ignoreValidate = false): void { + // TODO: Why do we need to re-validate starter movesets after picking them? if ( - !this.getSpeciesForm().validateStarterMoveset( + !ignoreValidate + && !this.getSpeciesForm().validateStarterMoveset( moveset, globalScene.gameData.starterData[this.species.getRootSpeciesId()].eggMoves, ) ) { - return false; + return; } - this.moveset = moveset.map(m => new PokemonMove(m)); - - return true; + moveset.forEach((m, i) => { + this.moveset[i] = new PokemonMove(m); + }); } /** diff --git a/src/phases/select-starter-phase.ts b/src/phases/select-starter-phase.ts index e923efaa678..70cfcb95ae5 100644 --- a/src/phases/select-starter-phase.ts +++ b/src/phases/select-starter-phase.ts @@ -71,7 +71,9 @@ export class SelectStarterPhase extends Phase { starter.ivs, starter.nature, ); - starter.moveset && starterPokemon.tryPopulateMoveset(starter.moveset); + if (starter.moveset) { + starterPokemon.tryPopulateMoveset(starter.moveset); + } if (starter.passive) { starterPokemon.passive = true; } diff --git a/src/phases/title-phase.ts b/src/phases/title-phase.ts index bc530d6f0b0..a18be85374f 100644 --- a/src/phases/title-phase.ts +++ b/src/phases/title-phase.ts @@ -1,3 +1,4 @@ +import { pokerogueApi } from "#api/pokerogue-api"; import { loggedInUser } from "#app/account"; import { GameMode, getGameMode } from "#app/game-mode"; import { timedEventManager } from "#app/global-event-manager"; @@ -5,7 +6,7 @@ import { globalScene } from "#app/global-scene"; import Overrides from "#app/overrides"; import { Phase } from "#app/phase"; import { bypassLogin } from "#constants/app-constants"; -import { fetchDailyRunSeed, getDailyRunStarters } from "#data/daily-run"; +import { getDailyRunStarters } from "#data/daily-run"; import { modifierTypes } from "#data/data-lists"; import { Gender } from "#data/gender"; import { BattleType } from "#enums/battle-type"; @@ -218,6 +219,7 @@ export class TitlePhase extends Phase { const starters = getDailyRunStarters(seed); const startingLevel = globalScene.gameMode.getStartingLevel(); + // TODO: Dedupe this const party = globalScene.getPlayerParty(); const loadPokemonAssets: Promise[] = []; for (const starter of starters) { @@ -237,6 +239,11 @@ export class TitlePhase extends Phase { starter.nature, ); starterPokemon.setVisible(false); + if (starter.moveset) { + // avoid validating daily run starter movesets which are pre-populated already + starterPokemon.tryPopulateMoveset(starter.moveset, true); + } + party.push(starterPokemon); loadPokemonAssets.push(starterPokemon.loadAssets()); } @@ -279,7 +286,8 @@ export class TitlePhase extends Phase { // If Online, calls seed fetch from db to generate daily run. If Offline, generates a daily run based on current date. if (!bypassLogin || isLocalServerConnected) { - fetchDailyRunSeed() + pokerogueApi.daily + .getSeed() .then(seed => { if (seed) { generateDaily(seed); diff --git a/src/utils/common.ts b/src/utils/common.ts index 58deccb4d80..056358dd87c 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -473,7 +473,7 @@ export function getLocalizedSpriteKey(baseKey: string) { } /** - * Check if a number is **inclusively** between two numbers + * Check if a number is **inclusively** between two numbers. * @param num - the number to check * @param min - the minimum value (inclusive) * @param max - the maximum value (inclusive) diff --git a/src/utils/strings.ts b/src/utils/strings.ts index b4b2498fe9d..7c13bbff0dd 100644 --- a/src/utils/strings.ts +++ b/src/utils/strings.ts @@ -11,7 +11,7 @@ const SPLIT_LOWER_UPPER_RE = /([\p{Ll}\d])(\p{Lu})/gu; const SPLIT_UPPER_UPPER_RE = /(\p{Lu})([\p{Lu}][\p{Ll}])/gu; /** Regexp involved with stripping non-word delimiters from the result. */ const DELIM_STRIP_REGEXP = /[-_ ]+/giu; -// The replacement value for splits. +/** The replacement value for splits. */ const SPLIT_REPLACE_VALUE = "$1\0$2"; /** @@ -57,8 +57,6 @@ function trimFromStartAndEnd(str: string, charToTrim: string): string { return str.slice(start, end); } -// #endregion Split String code - /** * Capitalize the first letter of a string. * @param str - The string whose first letter is to be capitalized @@ -179,3 +177,26 @@ export function toPascalSnakeCase(str: string) { .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join("_"); } +// #endregion Split String code + +/** + * Chunk a string into an array, creating a new element every `length` characters. + * @param str - The string to chunk + * @param length - The length of each chunk; should be a non-negative integer + * @returns The result of splitting `str` after every instance of `length` characters. + * @example + * ```ts + * console.log(chunkString("123456789abc", 4)); // Output: ["1234", "5678", "9abc"] + * console.log(chunkString("1234567890", 4)); // Output: ["1234", "5678", "90"] + * ``` + */ +export function chunkString(str: string, length: number): string[] { + const numChunks = Math.ceil(str.length / length); + const chunks = new Array(numChunks); + + for (let i = 0; i < numChunks; i++) { + chunks[i] = str.substring(i * length, (i + 1) * length); + } + + return chunks; +} diff --git a/test/daily-mode.test.ts b/test/daily-mode.test.ts index 34a8da80478..e5284906318 100644 --- a/test/daily-mode.test.ts +++ b/test/daily-mode.test.ts @@ -5,6 +5,7 @@ import { SpeciesId } from "#enums/species-id"; import { UiMode } from "#enums/ui-mode"; import { MapModifier } from "#modifiers/modifier"; import { GameManager } from "#test/test-utils/game-manager"; +import { stringifyEnumArray } from "#test/test-utils/string-utils"; import { ModifierSelectUiHandler } from "#ui/modifier-select-ui-handler"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -20,7 +21,6 @@ describe("Daily Mode", () => { beforeEach(() => { game = new GameManager(phaserGame); - vi.spyOn(pokerogueApi.daily, "getSeed").mockResolvedValue("test-seed"); }); afterEach(() => { @@ -28,6 +28,7 @@ describe("Daily Mode", () => { }); it("should initialize properly", async () => { + vi.spyOn(pokerogueApi.daily, "getSeed").mockResolvedValue("test-seed"); await game.dailyMode.startBattle(); const party = game.scene.getPlayerParty(); @@ -36,7 +37,57 @@ describe("Daily Mode", () => { expect(pkm.level).toBe(20); expect(pkm.moveset.length).toBeGreaterThan(0); }); - expect(game.scene.getModifiers(MapModifier).length).toBeGreaterThan(0); + expect(game.scene.getModifiers(MapModifier).length).toBe(1); + }); + + describe("Custom Seeds", () => { + 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(), + ]); + }); + + 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]); + }); }); });