[Feature] Allow setting movesets via custom daily seed (#6712)

* Clean up Daily Run custom seed gen; add moveset post-processing

* Remove redundant `fetchDailyRunSeed` function

---------

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
This commit is contained in:
Bertie690 2025-10-30 12:15:20 -04:00 committed by GitHub
parent 20615bcd21
commit 24e9dcdadc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 179 additions and 39 deletions

View File

@ -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<string | null> {
return new Promise<string | null>((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;
}
/**

View File

@ -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);
});
}
/**

View File

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

View File

@ -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<void>[] = [];
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);

View File

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

View File

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

View File

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