mirror of
https://github.com/pagefaultgames/pokerogue.git
synced 2025-12-16 06:45:24 +01:00
* 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>
334 lines
10 KiB
TypeScript
334 lines
10 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 { 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): 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);
|
|
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;
|
|
|
|
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.
|
|
*/
|
|
// 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 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 `/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;
|
|
}
|