pokerogue/src/data/daily-run.ts
2025-10-31 19:14:19 -04:00

464 lines
15 KiB
TypeScript

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 { Variant } from "#sprites/variant";
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 getDailyRunStarters(seed: string): StarterTuple {
const starters: Starter[] = [];
globalScene.executeWithSeedOffset(
() => {
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));
starterCosts.push(10 - (starterCosts[0] + starterCosts[1]));
for (const cost of starterCosts) {
const costSpecies = Object.keys(speciesStarterCosts)
.map(s => Number.parseInt(s) as SpeciesId) // TODO: Remove
.filter(s => speciesStarterCosts[s] === cost);
const randPkmSpecies = getPokemonSpecies(randSeedItem(costSpecies));
const starterSpecies = getPokemonSpecies(
randPkmSpecies.getTrainerSpeciesForLevel(startingLevel, true, PartyMemberStrength.STRONGER),
);
starters.push(getDailyRunStarter(starterSpecies, startingLevel));
}
},
0,
seed,
);
setDailyRunEventStarterMovesets(seed, starters as StarterTuple);
return starters as StarterTuple;
}
// TODO: Refactor this unmaintainable mess
function getDailyRunStarter(starterSpeciesForm: PokemonSpeciesForm, startingLevel: number, variant?: Variant): Starter {
const starterSpecies =
starterSpeciesForm instanceof PokemonSpecies ? starterSpeciesForm : getPokemonSpecies(starterSpeciesForm.speciesId);
const formIndex = starterSpeciesForm instanceof PokemonSpecies ? undefined : starterSpeciesForm.formIndex;
const pokemon = globalScene.addPlayerPokemon(
starterSpecies,
startingLevel,
undefined,
formIndex,
undefined,
variant != null,
variant,
);
const starter: Starter = {
speciesId: starterSpecies.speciesId,
shiny: pokemon.shiny,
variant: pokemon.variant,
formIndex: pokemon.formIndex,
ivs: pokemon.ivs,
abilityIndex: pokemon.abilityIndex,
passive: false,
nature: pokemon.getNature(),
pokerus: pokemon.pokerus,
};
pokemon.destroy();
return starter;
}
interface BiomeWeights {
[key: number]: number;
}
// Initially weighted by amount of exits each biome has
// Town and End are set to 0 however
// And some other biomes were balanced +1/-1 based on average size of the total daily.
const dailyBiomeWeights: BiomeWeights = {
[BiomeId.CAVE]: 3,
[BiomeId.LAKE]: 3,
[BiomeId.PLAINS]: 3,
[BiomeId.SNOWY_FOREST]: 3,
[BiomeId.SWAMP]: 3, // 2 -> 3
[BiomeId.TALL_GRASS]: 3, // 2 -> 3
[BiomeId.ABYSS]: 2, // 3 -> 2
[BiomeId.RUINS]: 2,
[BiomeId.BADLANDS]: 2,
[BiomeId.BEACH]: 2,
[BiomeId.CONSTRUCTION_SITE]: 2,
[BiomeId.DESERT]: 2,
[BiomeId.DOJO]: 2, // 3 -> 2
[BiomeId.FACTORY]: 2,
[BiomeId.FAIRY_CAVE]: 2,
[BiomeId.FOREST]: 2,
[BiomeId.GRASS]: 2, // 1 -> 2
[BiomeId.MEADOW]: 2,
[BiomeId.MOUNTAIN]: 2, // 3 -> 2
[BiomeId.SEA]: 2,
[BiomeId.SEABED]: 2,
[BiomeId.SLUM]: 2,
[BiomeId.TEMPLE]: 2, // 3 -> 2
[BiomeId.VOLCANO]: 2,
[BiomeId.GRAVEYARD]: 1,
[BiomeId.ICE_CAVE]: 1,
[BiomeId.ISLAND]: 1,
[BiomeId.JUNGLE]: 1,
[BiomeId.LABORATORY]: 1,
[BiomeId.METROPOLIS]: 1,
[BiomeId.POWER_PLANT]: 1,
[BiomeId.SPACE]: 1,
[BiomeId.WASTELAND]: 1,
[BiomeId.TOWN]: 0,
[BiomeId.END]: 0,
};
export function getDailyStartingBiome(): BiomeId {
const eventBiome = getDailyEventSeedBiome(globalScene.seed);
if (eventBiome != null) {
return eventBiome;
}
const biomes = getEnumValues(BiomeId).filter(b => b !== BiomeId.TOWN && b !== BiomeId.END);
let totalWeight = 0;
const biomeThresholds: number[] = [];
for (const biome of biomes) {
// Keep track of the total weight
totalWeight += dailyBiomeWeights[biome];
// Keep track of each biomes cumulative weight
biomeThresholds.push(totalWeight);
}
const randInt = randSeedInt(totalWeight);
for (let i = 0; i < biomes.length; i++) {
if (randInt < biomeThresholds[i]) {
return biomes[i];
}
}
// Fallback in case something went wrong
// TODO: should this use `randSeedItem`?
return biomes[randSeedInt(biomes.length)];
}
/**
* If this is Daily Mode and the seed is longer than a default seed
* then it has been modified and could contain a custom event seed. \
* Default seeds are always exactly 24 characters.
* @returns `true` if it is a Daily Event Seed.
*/
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;
/**
* 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}))?/;
/**
* 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;
}
}
/** The regex literal string used to extract the content of the "starters" block of Daily Run custom seeds. */
const STARTER_SEED_PREFIX_REGEX = /\/starters(.*?)(?:\/|$)/;
/**
* 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(?<species>\d{4}))(?:f(?<form>\d{2}))?(?:v(?<variant>\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 it did not match.
*/
// TODO: Rework this setup into JSON or similar - this is quite hard to maintain
function getDailyEventSeedStarters(seed: string): StarterTuple | null {
if (!isDailyEventSeed(seed)) {
return null;
}
const seedAfterPrefix = seed.split(STARTER_SEED_PREFIX_REGEX)[1] as string | undefined;
if (!seedAfterPrefix) {
return null;
}
const speciesConfigurations = [...seedAfterPrefix.matchAll(STARTER_SEED_MATCH_REGEX)];
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) {
return legacyStarters;
}
console.error("Invalid starters used for custom daily run seed!", seed);
return null;
}
const speciesIds = getEnumValues(SpeciesId);
const starters: Starter[] = [];
for (const match of speciesConfigurations) {
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);
// 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 startingLevel = globalScene.gameMode.getStartingLevel();
const starter = getDailyRunStarter(starterForm, startingLevel, variant);
starters.push(starter);
}
return starters as StarterTuple;
}
/**
* 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.
*/
// TODO: Can be removed after october 31st 2025
function getDailyEventSeedStartersLegacy(seed: string): StarterTuple | null {
if (!isDailyEventSeed(seed)) {
return null;
}
const starters: Starter[] = [];
const speciesMatch = /starters(\d{4})(\d{2})(\d{4})(\d{2})(\d{4})(\d{2})/g.exec(seed)?.slice(1);
if (!speciesMatch || speciesMatch.length !== 6) {
return null;
}
// TODO: Move these to server-side validation
const speciesIds = getEnumValues(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;
}
const starterForm = getPokemonSpeciesForm(speciesId, formIndex);
const startingLevel = globalScene.gameMode.getStartingLevel();
const starter = getDailyRunStarter(starterForm, startingLevel);
starters.push(starter);
}
return starters as StarterTuple;
}
/**
* Expects the seed to contain `/boss\d{4}\d{2}/`
* where the first 4 digits are the species ID and the next 2 digits are the form index
* (left padded with `0`s as necessary).
* @returns A {@linkcode PokemonSpeciesForm} to be used for the boss, or `null` if no valid match.
*/
export function getDailyEventSeedBoss(seed: string): PokemonSpeciesForm | null {
if (!isDailyEventSeed(seed)) {
return null;
}
const match = /boss(\d{4})(\d{2})/g.exec(seed);
if (!match || match.length !== 3) {
return null;
}
const speciesId = Number.parseInt(match[1]) as SpeciesId;
const formIndex = Number.parseInt(match[2]);
if (!getEnumValues(SpeciesId).includes(speciesId)) {
console.warn("Invalid species ID used for custom daily run seed boss:", speciesId);
return null;
}
const starterForm = getPokemonSpeciesForm(speciesId, formIndex);
return starterForm;
}
/**
* Expects the seed to contain `/boss\d{4}\d{2}\d{2}/`
* where the first 4 digits are the species ID, the next 2 digits are the form index, and the last 2 digits are the variant.
* Only the last 2 digits matter for the variant, and it is clamped to 0-2.
* (left padded with `0`s as necessary).
* @returns A {@linkcode Variant} to be used for the boss, or `null` if no valid match.
*/
export function getDailyEventSeedBossVariant(seed: string): Variant | null {
if (!isDailyEventSeed(seed)) {
return null;
}
const match = /boss\d{6}(\d{2})/g.exec(seed);
if (!match || match.length !== 2) {
return null;
}
const variant = Number.parseInt(match[1]) as Variant;
if (variant > 2) {
return null;
}
return variant;
}
/**
* Expects the seed to contain `/biome\d{2}/` where the 2 digits are a biome ID (left padded with `0` if necessary).
* @returns The biome to use or `null` if no valid match.
*/
export function getDailyEventSeedBiome(seed: string): BiomeId | null {
if (!isDailyEventSeed(seed)) {
return null;
}
const match = /biome(\d{2})/g.exec(seed);
if (!match || match.length !== 2) {
return null;
}
const startingBiome = Number.parseInt(match[1]) as BiomeId;
if (!getEnumValues(BiomeId).includes(startingBiome)) {
console.warn("Invalid biome ID used for custom daily run seed:", startingBiome);
return null;
}
return startingBiome;
}
/**
* Expects the seed to contain `/luck\d{2}/` where the 2 digits are a number between `0` and `14`
* (left padded with `0` if necessary).
* @returns The custom luck value or `null` if no valid match.
*/
export function getDailyEventSeedLuck(seed: string): number | null {
if (!isDailyEventSeed(seed)) {
return null;
}
const match = /luck(\d{2})/g.exec(seed);
if (!match || match.length !== 2) {
return null;
}
const luck = Number.parseInt(match[1]);
if (luck < 0 || luck > 14) {
console.warn("Invalid luck value used for custom daily run seed:", luck);
return null;
}
return luck;
}