diff --git a/scripts/daily-seed/constants.js b/scripts/daily-seed/constants.js index edb80ba2cf7e..390c6c622d72 100644 --- a/scripts/daily-seed/constants.js +++ b/scripts/daily-seed/constants.js @@ -34,6 +34,9 @@ export const EDIT_OPTIONS = /** @type {const} */ ([ "biome", "luck", "forced waves", + "trainer manipulation", + "challenges", + "mystery encounters", "starting money", "seed", "edit", @@ -49,6 +52,8 @@ export const BOSS_OPTIONS = /** @type {const} */ ([ "nature", "ability", "passive", + "segments", + "catchable", "finish", ]); @@ -58,7 +63,8 @@ export const STARTER_OPTIONS = /** @type {const} */ ([ "variant", "moveset", "nature", - "abilityIndex", + "ability", + "passive", "finish", ]); diff --git a/scripts/daily-seed/main.js b/scripts/daily-seed/main.js index aed5fd48fcaa..5e09a1b32bcf 100644 --- a/scripts/daily-seed/main.js +++ b/scripts/daily-seed/main.js @@ -19,7 +19,17 @@ import { toTitleCase } from "../helpers/casing.js"; import { promptOverwrite, writeFileSafe } from "../helpers/file.js"; import { EDIT_OPTIONS } from "./constants.js"; import { promptBoss } from "./prompts/boss.js"; -import { promptBiome, promptEdit, promptForcedWaves, promptLuck, promptMoney, promptSeed } from "./prompts/general.js"; +import { + promptBiome, + promptChallenges, + promptEdit, + promptForcedWaves, + promptLuck, + promptMoney, + promptMysteryEncounters, + promptSeed, + promptTrainerManipulation, +} from "./prompts/general.js"; import { promptStarters } from "./prompts/starter.js"; /** @@ -33,7 +43,8 @@ const rootDir = join(import.meta.dirname, "..", ".."); /** * @import {BossConfig} from "./prompts/boss.js" * @import {StarterConfig} from "./prompts/starter.js" - * @import {ForcedWaveConfig} from "./prompts/general.js" + * @import {ForcedWaveConfig, DailyTrainerManipulation, DailyEventChallenge} from "./prompts/general.js" + * @import {DailyEventMysteryEncounter} from "./prompts/general.js" */ /** @@ -48,6 +59,9 @@ const rootDir = join(import.meta.dirname, "..", ".."); * biome?: number, * luck?: number, * forcedWaves?: ForcedWaveConfig[], + * trainerManipulations?: DailyTrainerManipulation[], + * challenges?: DailyEventChallenge[], + * mysteryEncounters?: DailyEventMysteryEncounter[], * startingMoney?: number, * seed: string * }} @@ -58,6 +72,8 @@ const customSeedConfig = { biome: undefined, luck: undefined, forcedWaves: undefined, + trainerManipulations: undefined, + challenges: undefined, startingMoney: undefined, seed: "", }; @@ -137,6 +153,15 @@ async function handleAnswer(answer) { case "forced waves": customSeedConfig.forcedWaves = await promptForcedWaves(); break; + case "trainer manipulation": + customSeedConfig.trainerManipulations = await promptTrainerManipulation(); + break; + case "challenges": + customSeedConfig.challenges = await promptChallenges(); + break; + case "mystery encounters": + customSeedConfig.mysteryEncounters = await promptMysteryEncounters(); + break; case "starting money": customSeedConfig.startingMoney = await promptMoney(); break; diff --git a/scripts/daily-seed/prompts/boss.js b/scripts/daily-seed/prompts/boss.js index c6da6e9c4e6d..c1f9283c2905 100644 --- a/scripts/daily-seed/prompts/boss.js +++ b/scripts/daily-seed/prompts/boss.js @@ -5,7 +5,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { select } from "@inquirer/prompts"; +import { confirm, select } from "@inquirer/prompts"; import chalk from "chalk"; import { toCamelCase, toTitleCase } from "../../helpers/casing.js"; import { BOSS_OPTIONS } from "../constants.js"; @@ -14,6 +14,7 @@ import { promptFormIndex, promptMoveset, promptNature, + promptSegments, promptSpeciesId, promptVariant, } from "./pokemon.js"; @@ -28,7 +29,9 @@ import { * moveset?: number[], * nature?: number, * ability?: number, - * passive?: number + * passive?: number, + * segments?: number, + * catchable?: boolean, * }} BossConfig - The config for a single boss pokemon. * */ @@ -45,6 +48,8 @@ let bossConfig = { nature: undefined, ability: undefined, passive: undefined, + segments: undefined, + catchable: undefined, }; /** @@ -99,6 +104,15 @@ async function promptBossOptions() { case "passive": bossConfig.passive = await promptAbility(true); break; + case "segments": + bossConfig.segments = await promptSegments(); + break; + case "catchable": + bossConfig.catchable = await confirm({ + message: "Should the boss be catchable?", + default: false, + }); + break; case "finish": return bossConfig; } diff --git a/scripts/daily-seed/prompts/general.js b/scripts/daily-seed/prompts/general.js index a866a20f4f82..e9195e919373 100644 --- a/scripts/daily-seed/prompts/general.js +++ b/scripts/daily-seed/prompts/general.js @@ -10,6 +10,8 @@ import { Ajv } from "ajv"; import chalk from "chalk"; import customDailyRunSchema from "../../../src/data/daily-seed/schema.json" with { type: "json" }; import { BIOMES } from "../../enums/biomes.js"; +import { CHALLENGES } from "../../enums/challenges.js"; +import { MYSTERY_ENCOUNTERS } from "../../enums/mystery-encounters.js"; import { toTitleCase, toUpperSnakeCase } from "../../helpers/casing.js"; import { BIOME_POOL_TIERS } from "../constants.js"; import { promptSpeciesId } from "./pokemon.js"; @@ -26,6 +28,27 @@ import { promptSpeciesId } from "./pokemon.js"; * }} ForcedWaveConfig */ +/** + * @typedef {{ + * waveIndex: number, + * isTrainer: boolean, + * }} DailyTrainerManipulation + */ + +/** + * @typedef {{ + * id: number, + * value: number, + * }} DailyEventChallenge + */ + +/** + * @typedef {{ + * waveIndex: number, + * type: number, + * }} DailyEventMysteryEncounter + */ + const ajv = new Ajv({ allErrors: true, }); @@ -196,3 +219,135 @@ export async function promptForcedWaves() { } return forcedWaves; } + +/** + * Prompt the user to enter a list of trainer manipulations. + * @returns {Promise} A Promise that resolves with the list of trainer manipulations. + */ +export async function promptTrainerManipulation() { + /** @type {DailyTrainerManipulation[]} */ + const trainerManipulations = []; + + async function addTrainerManipulation() { + const waveIndex = await number({ + message: "Please enter the wave to manipulate.\nPressing ENTER will end the prompt early.", + min: 1, + max: 49, + validate: value => { + if (trainerManipulations.some(wave => wave.waveIndex === value)) { + return chalk.red.bold("Wave already manipulated!"); + } + return true; + }, + }); + if (!waveIndex) { + return; + } + + const isTrainer = await confirm({ + message: "Should the wave be a trainer?", + default: false, + }); + + trainerManipulations.push({ waveIndex, isTrainer }); + + await addTrainerManipulation(); + } + + await addTrainerManipulation(); + if (trainerManipulations.length === 0) { + return; + } + return trainerManipulations; +} + +/** + * Prompt the user to enter a list of challenges. + * @returns {Promise} A Promise that resolves with the list of challenges. + */ +export async function promptChallenges() { + /** @type {DailyEventChallenge[]} */ + const challenges = []; + const challengeNames = Object.keys(CHALLENGES).map(toTitleCase); + challengeNames.unshift("Finish"); + + async function addChallenge() { + const challenge = await search({ + message: "Please enter the challenge to add.\nPressing ENTER will end the prompt early.", + source: term => { + if (!term) { + return challengeNames; + } + return challengeNames.filter(id => id.toLowerCase().includes(term.toLowerCase())); + }, + }); + if (challenge === "Finish") { + return; + } + + const value = await number({ + message: `Please enter the value for ${challenge}. This is NOT validted atm.`, + min: 0, + required: true, + }); + + const challengeId = CHALLENGES[/** @type {keyof typeof CHALLENGES} */ (toUpperSnakeCase(challenge))]; + challenges.push({ id: challengeId, value }); + challengeNames.splice(challengeNames.indexOf(challenge), 1); + await addChallenge(); + } + await addChallenge(); + + if (challenges.length === 0) { + return; + } + return challenges; +} + +/** + * Prompt the user to enter a list of mystery encounters. + * @returns {Promise} A Promise that resolves with the list of mystery encounters. + */ +export async function promptMysteryEncounters() { + /** @type {DailyEventMysteryEncounter[]} */ + const mysteryEncounters = []; + + async function addMysteryEncounter() { + const waveIndex = await number({ + message: "Please enter the wave to force a mystery encounter.\nPressing ENTER will end the prompt early.", + min: 1, + max: 49, + validate: value => { + if (mysteryEncounters.some(wave => wave.waveIndex === value)) { + return chalk.red.bold("Wave already has a mystery encounter!"); + } + return true; + }, + }); + if (!waveIndex) { + return; + } + + const type = await search({ + message: "Please select the mystery encounter to force.", + source: term => { + if (!term) { + return Object.keys(MYSTERY_ENCOUNTERS).map(toTitleCase); + } + return Object.keys(MYSTERY_ENCOUNTERS) + .map(toTitleCase) + .filter(id => id.toLowerCase().includes(term.toLowerCase())); + }, + }); + + const typeId = MYSTERY_ENCOUNTERS[/** @type {keyof typeof MYSTERY_ENCOUNTERS} */ (toUpperSnakeCase(type))]; + mysteryEncounters.push({ waveIndex, type: typeId }); + await addMysteryEncounter(); + } + + await addMysteryEncounter(); + if (mysteryEncounters.length === 0) { + return; + } + return mysteryEncounters; +} diff --git a/scripts/daily-seed/prompts/pokemon.js b/scripts/daily-seed/prompts/pokemon.js index 1ffa72cd551b..4d87f943523f 100644 --- a/scripts/daily-seed/prompts/pokemon.js +++ b/scripts/daily-seed/prompts/pokemon.js @@ -18,7 +18,6 @@ import { toTitleCase, toUpperSnakeCase } from "../../helpers/casing.js"; /** * Prompt the user to enter a speciesId. - * @see {@linkcode SPECIES_IDS} for a list of valid `SpeciesId`s. * @returns {Promise} A Promise that resolves with the chosen `SpeciesId`. */ export async function promptSpeciesId() { @@ -131,8 +130,6 @@ export async function promptMoveset() { * Prompt the user to enter an ability. * @param {boolean} [passive=false] (Default `false`) Whether to prompt for a passive ability. * @returns {Promise} A Promise that resolves with the chosen ability. - * @remarks - * This is boss only for now, since the option for setting any ability is not yet implemented. */ export async function promptAbility(passive = false) { const abilityName = await search({ @@ -150,16 +147,14 @@ export async function promptAbility(passive = false) { } /** - * Prompt the user to enter an ability index. - * @returns {Promise} A Promise that resolves with the chosen ability index. - * @remarks This is starter only for now. + * Prompt the user to enter the number of segments for the boss fight. + * @returns {Promise} A Promise that resolves with the chosen number of segments. */ -// TODO: Validate the ability index & list the actual ability names based on main repo data -export async function promptAbilityIndex() { +export async function promptSegments() { return await number({ - message: `Please enter the starter's ability index.`, - min: 0, - max: 2, + message: "Please enter the number of segments for the boss fight.", + min: 1, + default: 5, required: true, }); } diff --git a/scripts/daily-seed/prompts/starter.js b/scripts/daily-seed/prompts/starter.js index ea14cc8c7baf..6a8fef2456c1 100644 --- a/scripts/daily-seed/prompts/starter.js +++ b/scripts/daily-seed/prompts/starter.js @@ -9,7 +9,7 @@ import { number, select } from "@inquirer/prompts"; import { toCamelCase, toTitleCase } from "../../helpers/casing.js"; import { STARTER_OPTIONS } from "../constants.js"; import { - promptAbilityIndex, + promptAbility, promptFormIndex, promptMoveset, promptNature, @@ -26,7 +26,8 @@ import { * variant?: Variant, * moveset?: number[], * nature?: number, - * abilityIndex?: number, + * ability?: number, + * passive?: number * }} StarterConfig */ @@ -99,8 +100,11 @@ async function promptStarterOptions(starterConfig) { case "nature": starterConfig.nature = await promptNature(); break; - case "abilityIndex": - starterConfig.abilityIndex = await promptAbilityIndex(); + case "ability": + starterConfig.ability = await promptAbility(); + break; + case "passive": + starterConfig.passive = await promptAbility(true); break; case "finish": // Re-add all used options for next starter diff --git a/scripts/enums/challenges.js b/scripts/enums/challenges.js new file mode 100644 index 000000000000..713ea546930e --- /dev/null +++ b/scripts/enums/challenges.js @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: 2026 Pagefault Games + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * A mapping of challenge names to their corresponding IDs. + * @enum {number} + */ +export const CHALLENGES = { + SINGLE_GENERATION: 0, + SINGLE_TYPE: 1, + LOWER_MAX_STARTER_COST: 2, + LOWER_STARTER_POINTS: 3, + FRESH_START: 4, + INVERSE_BATTLE: 5, + FLIP_STAT: 6, + LIMITED_CATCH: 7, + LIMITED_SUPPORT: 8, + HARDCORE: 9, + PASSIVES: 10, +}; diff --git a/scripts/enums/mystery-encounters.js b/scripts/enums/mystery-encounters.js new file mode 100644 index 000000000000..263b4e40a802 --- /dev/null +++ b/scripts/enums/mystery-encounters.js @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2026 Pagefault Games + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * A mapping of mystery encounter names to their corresponding IDs. + * @enum {number} + */ +export const MYSTERY_ENCOUNTERS = { + MYSTERIOUS_CHALLENGERS: 0, + MYSTERIOUS_CHEST: 1, + DARK_DEAL: 2, + FIGHT_OR_FLIGHT: 3, + SLUMBERING_SNORLAX: 4, + TRAINING_SESSION: 5, + DEPARTMENT_STORE_SALE: 6, + SHADY_VITAMIN_DEALER: 7, + FIELD_TRIP: 8, + SAFARI_ZONE: 9, + LOST_AT_SEA: 10, + FIERY_FALLOUT: 11, + THE_STRONG_STUFF: 12, + THE_POKEMON_SALESMAN: 13, + AN_OFFER_YOU_CANT_REFUSE: 14, + DELIBIRDY: 15, + ABSOLUTE_AVARICE: 16, + A_TRAINERS_TEST: 17, + TRASH_TO_TREASURE: 18, + BERRIES_ABOUND: 19, + CLOWNING_AROUND: 20, + PART_TIMER: 21, + DANCING_LESSONS: 22, + WEIRD_DREAM: 23, + THE_WINSTRATE_CHALLENGE: 24, + TELEPORTING_HIJINKS: 25, + BUG_TYPE_SUPERFAN: 26, + FUN_AND_GAMES: 27, + UNCOMMON_BREED: 28, + GLOBAL_TRADE_SYSTEM: 29, + THE_EXPERT_POKEMON_BREEDER: 30, +}; diff --git a/src/@types/daily-run.ts b/src/@types/daily-run.ts index 90db4055e7ce..7148572727d3 100644 --- a/src/@types/daily-run.ts +++ b/src/@types/daily-run.ts @@ -1,6 +1,8 @@ import type { AbilityId } from "#enums/ability-id"; import type { BiomeId } from "#enums/biome-id"; import type { BiomePoolTier } from "#enums/biome-pool-tier"; +import type { Challenges } from "#enums/challenges"; +import type { MysteryEncounterType } from "#enums/mystery-encounter-type"; import type { Nature } from "#enums/nature"; import type { SpeciesId } from "#enums/species-id"; import type { Variant } from "#sprites/variant"; @@ -19,7 +21,8 @@ export interface DailySeedStarter { variant?: Variant | undefined; moveset?: StarterMoveset | undefined; nature?: Nature | undefined; - abilityIndex?: number | undefined; + ability?: AbilityId | undefined; + passive?: AbilityId | undefined; } type DailySeedStarterTuple = TupleRange<1, 6, DailySeedStarter>; @@ -38,6 +41,8 @@ export interface DailySeedBoss { nature?: Nature | undefined; ability?: AbilityId | undefined; passive?: AbilityId | undefined; + segments?: number | undefined; + catchable?: boolean | undefined; } /** @@ -49,6 +54,9 @@ export interface DailySeedBoss { * speciesId: SpeciesId.MEW, * }; * ``` + * @privateRemarks + * When updating this interface, also update: + * - `src/data/daily-seed/schema.json` */ export type DailyForcedWave = | { @@ -64,6 +72,27 @@ export type DailyForcedWave = hiddenAbility?: boolean | undefined; }; +/** + * Configuration to manipulate on what waves a trainer spawns for a custom seed. + * @privateRemarks + * When updating this interface, also update: + * - `src/data/daily-seed/schema.json` + */ +export interface DailyTrainerManipulation { + waveIndex: number; + isTrainer: boolean; +} + +export interface DailyEventChallenge { + id: Challenges; + value: number; +} + +export interface DailyEventMysteryEncounter { + waveIndex: number; + type: MysteryEncounterType; +} + /** * Configuration for a custom daily run seed. * @privateRemarks @@ -77,6 +106,9 @@ export interface CustomDailyRunConfig { luck?: number; startingMoney?: number; forcedWaves?: DailyForcedWave[]; + trainerManipulations?: DailyTrainerManipulation[]; + challenges?: DailyEventChallenge[]; + mysteryEncounters?: DailyEventMysteryEncounter[]; /** The actual seed used for the daily run. */ seed: string; } @@ -88,5 +120,8 @@ export interface SerializedDailyRunConfig { boss?: DailySeedBoss | undefined; luck?: number | undefined; forcedWaves?: DailyForcedWave[] | undefined; + trainerManipulations?: DailyTrainerManipulation[] | undefined; + challenges?: DailyEventChallenge[] | undefined; + mysteryEncounters?: DailyEventMysteryEncounter[] | undefined; seed: string; } diff --git a/src/@types/events.ts b/src/@types/events.ts index 6932dfa77e19..ef1ce0212259 100644 --- a/src/@types/events.ts +++ b/src/@types/events.ts @@ -1,5 +1,4 @@ import type { BiomeId } from "#enums/biome-id"; -import type { Challenges } from "#enums/challenges"; import type { EventType } from "#enums/event-type"; import type { ClassicFixedBossWaves } from "#enums/fixed-boss-waves"; import type { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; @@ -38,12 +37,23 @@ export interface EventWaveReward { } export type EventMusicReplacement = readonly [string, string]; +export type EventSpriteReplacement = readonly [string, string]; -export interface EventChallenge { - readonly challenge: Challenges; - readonly value: number; +export interface EventSpriteOptions { + /** + * An Array of tuples [source, target] for replacing pokemon sprites during events. + * Format for both source and target is "speciesId[/formIndex]", where formIndex is optional and defaults to 0 if not provided. + */ + readonly replacements: readonly EventSpriteReplacement[]; + /** + * If true, any species not explicitly listed in the replacements array will be replaced with a random species. + * @defaultValue false + */ + readonly fillRandom?: boolean; } +export type EventTextReplacement = readonly [string, string]; + export type EventWeatherPools = Readonly>>; export type EventTerrainPools = Readonly>>; @@ -67,6 +77,7 @@ export interface TimedEvent extends EventBanner { readonly classicWaveRewards?: readonly EventWaveReward[]; // Rival battle rewards readonly trainerShinyChance?: number; // Odds over 65536 of trainer mon generating as shiny readonly music?: readonly EventMusicReplacement[]; - readonly dailyRunChallenges?: readonly EventChallenge[]; + readonly sprites?: EventSpriteOptions; + readonly textReplacements?: readonly EventTextReplacement[]; readonly dailyRunStartingItems?: readonly ModifierTypeKeys[]; } diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 63a4ef7f0e67..57161e74c3f7 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -27,6 +27,7 @@ import { STARTING_WAVE } from "#balance/misc"; import { pokemonPrevolutions } from "#balance/pokemon-evolutions"; import { FRIENDSHIP_GAIN_FROM_BATTLE } from "#balance/starters"; import { initCommonAnims, initMoveAnim, loadCommonAnimAssets, loadMoveAnimAssets } from "#data/battle-anims"; +import { getDailyMysteryEncounter } from "#data/daily-seed/daily-run"; import { allMoves, allSpecies, biomeDepths, modifierTypes } from "#data/data-lists"; import { battleSpecDialogue } from "#data/dialogue"; import type { SpeciesFormChangeTrigger } from "#data/form-change-triggers"; @@ -1875,6 +1876,9 @@ export class BattleScene extends SceneBase { } if (this.gameMode.isDaily && this.gameMode.isWaveFinal(waveIndex)) { + if (this.gameMode.dailyConfig?.boss?.segments != null) { + return this.gameMode.dailyConfig.boss.segments; + } return 5; } @@ -3614,6 +3618,9 @@ export class BattleScene extends SceneBase { * @returns Whether a Mystery Encounter should be generated. */ private isWaveMysteryEncounter(battleType: BattleType, waveIndex: number): boolean { + if (getDailyMysteryEncounter(waveIndex) != null) { + return true; + } if (!this.isMysteryEncounterValidForWave(battleType, waveIndex)) { return false; } @@ -3691,6 +3698,8 @@ export class BattleScene extends SceneBase { } else if (canBypass) { encounter = allMysteryEncounters[encounterType ?? -1]; return encounter; + } else if (getDailyMysteryEncounter(this.currentBattle.waveIndex) != null) { + encounter = allMysteryEncounters[getDailyMysteryEncounter(this.currentBattle.waveIndex)!]; } else { encounter = encounterType != null ? allMysteryEncounters[encounterType] : null; } diff --git a/src/data/balance/timed-events.ts b/src/data/balance/timed-events.ts index 7cec881b2440..56a8b887fab0 100644 --- a/src/data/balance/timed-events.ts +++ b/src/data/balance/timed-events.ts @@ -1,5 +1,4 @@ import { BiomeId } from "#enums/biome-id"; -import { Challenges } from "#enums/challenges"; import { EventType } from "#enums/event-type"; import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; import { MysteryEncounterType } from "#enums/mystery-encounter-type"; @@ -337,12 +336,13 @@ export const timedEvents: readonly TimedEvent[] = [ ["title", "title_afd"], ["battle_rival_3", "battle_rival_3_afd"], ], - dailyRunChallenges: [ - { - challenge: Challenges.INVERSE_BATTLE, - value: 1, - }, - ], + // This has been moved to custom seeds. Keeping this here to keep track of what the event did. + // dailyRunChallenges: [ + // { + // challenge: Challenges.INVERSE_BATTLE, + // value: 1, + // }, + // ], }, { name: "Shining Spring", diff --git a/src/data/daily-seed/daily-run.ts b/src/data/daily-seed/daily-run.ts index f1d8dc788e1f..d77cc17289ab 100644 --- a/src/data/daily-seed/daily-run.ts +++ b/src/data/daily-seed/daily-run.ts @@ -5,8 +5,10 @@ import { speciesStarterCosts } from "#balance/starters"; import type { PokemonSpecies } from "#data/pokemon-species"; import { BiomeId } from "#enums/biome-id"; import type { BiomePoolTier } from "#enums/biome-pool-tier"; +import { Challenges } from "#enums/challenges"; import { EvoLevelThresholdKind } from "#enums/evo-level-threshold-kind"; import { MoveId } from "#enums/move-id"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; import { PartyMemberStrength } from "#enums/party-member-strength"; import type { SpeciesId } from "#enums/species-id"; import type { DailySeedBoss } from "#types/daily-run"; @@ -224,6 +226,11 @@ export function getDailyForcedWaveSpecies(waveIndex: number): PokemonSpecies | n return getPokemonSpecies(forcedWave.speciesId); } +/** + * Get the biome pool tier for a forced wave for custom daily run. + * @param waveIndex - The wave index to check + * @returns The {@linkcode BiomePoolTier} to use, or `null` if there is no forced wave for the given index. + */ export function getDailyForcedWaveBiomePoolTier(waveIndex: number): BiomePoolTier | null { if (!isDailyEventSeed()) { return null; @@ -242,6 +249,11 @@ export function getDailyForcedWaveBiomePoolTier(waveIndex: number): BiomePoolTie return forcedWave.tier; } +/** + * Check if the current wave should have the hidden ability in a custom daily run. + * @param waveIndex - The wave index to check + * @returns Whether the wave should have the hidden ability. + */ export function isDailyForcedWaveHiddenAbility(): boolean { if (!isDailyEventSeed()) { return false; @@ -262,6 +274,67 @@ export function isDailyForcedWaveHiddenAbility(): boolean { return forcedWave.hiddenAbility ?? false; } +/** + * Check if the current wave should be a trainer battle in a custom daily run. + * @param waveIndex - The wave index to check + * @returns The {@linkcode DailyTrainerManipulation} to use, or `null` if there is no forced wave for the given index. + */ +export function getDailyTrainerManipulation(waveIndex: number): boolean | null { + if (!isDailyEventSeed()) { + return null; + } + const trainerManipulation = globalScene.gameMode.dailyConfig?.trainerManipulations?.find( + w => w.waveIndex === waveIndex, + ); + if (trainerManipulation == null) { + return null; + } + + return trainerManipulation.isTrainer; +} + +/** + * Starts the challenges for a custom daily run. + */ +export function startDailyEventChallenges(): void { + if (!isDailyEventSeed()) { + return; + } + + const { dailyConfig } = globalScene.gameMode; + + for (const dailyChallenge of dailyConfig?.challenges ?? []) { + if (!getEnumValues(Challenges).includes(dailyChallenge.id)) { + console.warn("Invalid challenge ID used for custom daily run seed:", dailyChallenge.id); + continue; + } + globalScene.gameMode.setChallengeValue(dailyChallenge.id, dailyChallenge.value); + } +} + +/** + * Get the {@linkcode MysteryEncounterType} for a custom daily run. + * @param waveIndex - The wave index to check + * @returns The {@linkcode MysteryEncounterType} to use, or `null` if there is no forced wave for the given index. + */ +export function getDailyMysteryEncounter(waveIndex: number): MysteryEncounterType | null { + if (!isDailyEventSeed()) { + return null; + } + + const mysteryEncounter = globalScene.gameMode.dailyConfig?.mysteryEncounters?.find(w => w.waveIndex === waveIndex); + if (mysteryEncounter == null) { + return null; + } + + if (!getEnumValues(MysteryEncounterType).includes(mysteryEncounter.type)) { + console.warn("Invalid mystery encounter type used for custom daily run seed:", mysteryEncounter.type); + return null; + } + + return mysteryEncounter.type; +} + /** * Sets a custom starting biome for the daily run if specified in the config. * @see {@linkcode CustomDailyRunConfig} diff --git a/src/data/daily-seed/daily-seed-utils.ts b/src/data/daily-seed/daily-seed-utils.ts index 104839c47ee0..fabfa5167559 100644 --- a/src/data/daily-seed/daily-seed-utils.ts +++ b/src/data/daily-seed/daily-seed-utils.ts @@ -67,12 +67,16 @@ export function getSerializedDailyRunConfig(): SerializedDailyRunConfig | undefi return; } - const { seed, boss, luck, forcedWaves } = globalScene.gameMode.dailyConfig; + const { seed, boss, luck, forcedWaves, trainerManipulations, challenges, mysteryEncounters } = + globalScene.gameMode.dailyConfig; return { seed, boss, luck, forcedWaves, + trainerManipulations, + challenges, + mysteryEncounters, } satisfies SerializedDailyRunConfig; } @@ -114,9 +118,15 @@ export function validateDailyStarterConfig(config: DailySeedStarter): DailySeedS config.nature = undefined; } - if (config.abilityIndex != null && !isBetween(config.abilityIndex, 0, 2)) { - console.warn("Invalid ability index used for custom daily run seed starter:", config.abilityIndex); - config.abilityIndex = undefined; + const abilityIds = getEnumValues(AbilityId); + if (config.ability != null && !abilityIds.includes(config.ability)) { + console.warn("Invalid ability used for custom daily run seed starter:", config.ability); + config.ability = undefined; + } + + if (config.passive != null && !abilityIds.includes(config.passive)) { + console.warn("Invalid passive used for custom daily run seed starter:", config.passive); + config.passive = undefined; } return config; @@ -178,6 +188,11 @@ export function validateDailyBossConfig(config: DailySeedBoss): DailySeedBoss | config.passive = undefined; } + if (config.segments != null && config.segments < 1) { + console.warn("Invalid number of segments used for custom daily run seed boss:", config.segments); + config.segments = undefined; + } + return config; } @@ -193,7 +208,7 @@ export function getDailyRunStarter(species: PokemonSpecies, config?: DailySeedSt const pokemon = globalScene.addPlayerPokemon( species, startingLevel, - config?.abilityIndex, + undefined, config?.formIndex, undefined, isShiny, diff --git a/src/data/daily-seed/schema.json b/src/data/daily-seed/schema.json index 0e8687610707..cb6beb198fa2 100644 --- a/src/data/daily-seed/schema.json +++ b/src/data/daily-seed/schema.json @@ -24,8 +24,11 @@ "nature": { "$ref": "#/$defs/nature" }, - "abilityIndex": { - "$ref": "#/$defs/abilityIndex" + "ability": { + "$ref": "#/$defs/ability" + }, + "passive": { + "$ref": "#/$defs/passive" } }, "required": ["speciesId"], @@ -58,6 +61,13 @@ }, "passive": { "$ref": "#/$defs/passive" + }, + "segments": { + "type": "integer", + "minimum": 1 + }, + "catchable": { + "type": "boolean" } }, "required": ["speciesId"], @@ -82,6 +92,24 @@ "$ref": "#/$defs/forcedWave" } }, + "trainerManipulations": { + "type": "array", + "items": { + "$ref": "#/$defs/trainerManipulation" + } + }, + "challenges": { + "type": "array", + "items": { + "$ref": "#/$defs/challenge" + } + }, + "mysteryEncounters": { + "type": "array", + "items": { + "$ref": "#/$defs/mysteryEncounter" + } + }, "seed": { "type": "string" }, @@ -114,11 +142,6 @@ "type": "integer", "exclusiveMinimum": 0 }, - "abilityIndex": { - "type": "integer", - "minimum": 0, - "maximum": 2 - }, "moveset": { "type": "array", "items": { @@ -163,6 +186,52 @@ } ], "additionalProperties": false + }, + "trainerManipulation": { + "type": "object", + "properties": { + "waveIndex": { + "type": "integer", + "minimum": 1, + "maximum": 49 + }, + "isTrainer": { + "type": "boolean" + } + }, + "required": ["waveIndex", "isTrainer"], + "additionalProperties": false + }, + "challenge": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 0 + }, + "value": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["id", "value"], + "additionalProperties": false + }, + "mysteryEncounter": { + "type": "object", + "properties": { + "waveIndex": { + "type": "integer", + "minimum": 1, + "maximum": 49 + }, + "type": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["waveIndex", "type"], + "additionalProperties": false } }, "required": ["seed"], diff --git a/src/data/pokemon-species.ts b/src/data/pokemon-species.ts index 89ce0393912a..1343f7e4eb6d 100644 --- a/src/data/pokemon-species.ts +++ b/src/data/pokemon-species.ts @@ -1,6 +1,7 @@ import { determineEnemySpecies } from "#app/ai/ai-species-gen"; import type { AnySound } from "#app/battle-scene"; import type { GameMode } from "#app/game-mode"; +import { timedEventManager } from "#app/global-event-manager"; import { globalScene } from "#app/global-scene"; import { speciesEggMoves } from "#balance/egg-moves"; import { starterPassiveAbilities } from "#balance/passives"; @@ -31,7 +32,7 @@ import type { LevelMoves } from "#types/pokemon-level-moves"; import type { StarterMoveset } from "#types/save-data"; import type { EvolutionLevel, EvolutionLevelWithThreshold } from "#types/species-gen-types"; import { randSeedFloat, randSeedGauss } from "#utils/common"; -import { getPokemonSpecies } from "#utils/pokemon-utils"; +import { getPokemonSpecies, getPokemonSpeciesForm } from "#utils/pokemon-utils"; import { toCamelCase, toPascalCase } from "#utils/strings"; import { argbFromRgba, QuantizerCelebi, rgbaFromArgb } from "@material/material-color-utilities"; import i18next from "i18next"; @@ -338,7 +339,17 @@ export abstract class PokemonSpeciesForm { && female && ![SpeciesFormKey.MEGA, SpeciesFormKey.GIGANTAMAX].includes(formSpriteKey as SpeciesFormKey); - return `${showGenderDiffs ? "female__" : ""}${this.speciesId}${formSpriteKey ? `-${formSpriteKey}` : ""}`; + let spriteKey = `${showGenderDiffs ? "female__" : ""}${this.speciesId}${formSpriteKey ? `-${formSpriteKey}` : ""}`; + + const replacement = timedEventManager.getEventSpriteReplacement(this.speciesId, formIndex); + if (replacement) { + const replacementFormSpriteKey = getPokemonSpecies(replacement.speciesId).forms[ + replacement.formIndex + ]?.getFormSpriteKey(replacement.formIndex); + spriteKey = `${replacement.speciesId}${replacementFormSpriteKey ? `-${replacementFormSpriteKey}` : ""}`; + } + + return spriteKey; } /** Compute the sprite ID of the pokemon form. */ @@ -363,7 +374,7 @@ export abstract class PokemonSpeciesForm { * @param formIndex optional form index for pokemon with different forms * @returns species id if no additional forms, index with formkey if a pokemon with a form */ - getVariantDataIndex(formIndex?: number) { + getVariantDataIndex(formIndex?: number): string | number { let formkey: string | null = null; let variantDataIndex: number | string = this.speciesId; const species = getPokemonSpecies(this.speciesId); @@ -373,6 +384,17 @@ export abstract class PokemonSpeciesForm { variantDataIndex = `${this.speciesId}-${formkey}`; } } + + const replacement = timedEventManager.getEventSpriteReplacement(this.speciesId, formIndex); + if (replacement) { + formkey = species.forms[replacement.formIndex]?.getFormSpriteKey(replacement.formIndex); + if (formkey) { + variantDataIndex = `${replacement.speciesId}-${formkey}`; + } else { + variantDataIndex = replacement.speciesId; + } + } + return variantDataIndex; } @@ -380,7 +402,12 @@ export abstract class PokemonSpeciesForm { const variantDataIndex = this.getVariantDataIndex(formIndex); const isVariant = shiny && variantData[variantDataIndex] && variant !== undefined && variantData[variantDataIndex][variant]; - return `pokemon_icons_${this.generation}${isVariant ? "v" : ""}`; + + const replacementSpecies = timedEventManager.getEventSpriteReplacement(this.speciesId, formIndex); + const generation = replacementSpecies + ? getPokemonSpeciesForm(replacementSpecies.speciesId, replacementSpecies.formIndex).generation + : this.generation; + return `pokemon_icons_${generation}${isVariant ? "v" : ""}`; } getIconId(female: boolean, formIndex?: number, shiny?: boolean, variant?: number): string { @@ -389,9 +416,14 @@ export abstract class PokemonSpeciesForm { } const variantDataIndex = this.getVariantDataIndex(formIndex); + const replacement = timedEventManager.getEventSpriteReplacement(this.speciesId, formIndex); let ret = this.speciesId.toString(); + if (replacement) { + ret = replacement.speciesId.toString(); + } + const isVariant = shiny && variantData[variantDataIndex] && variant !== undefined && variantData[variantDataIndex][variant]; @@ -417,6 +449,11 @@ export abstract class PokemonSpeciesForm { } let formSpriteKey = this.getFormSpriteKey(formIndex); + if (replacement) { + formSpriteKey = getPokemonSpeciesForm(replacement.speciesId, replacement.formIndex).getFormSpriteKey( + replacement.formIndex, + ); + } if (formSpriteKey) { switch (this.speciesId) { case SpeciesId.DUDUNSPARCE: @@ -442,6 +479,13 @@ export abstract class PokemonSpeciesForm { getCryKey(formIndex?: number): string { let speciesId = this.speciesId; + + const override = timedEventManager.getEventSpriteReplacement(this.speciesId, formIndex); + if (override) { + speciesId = override.speciesId; + formIndex = override.formIndex; + } + if (this.speciesId > 2000) { switch (this.speciesId) { case SpeciesId.GALAR_SLOWPOKE: diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index e76401442a9b..4b876c8ddb97 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -2228,6 +2228,14 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { return false; } + if ( + globalScene.gameMode.isDaily + && this.customPokemonData.passive != null + && this.customPokemonData.passive !== -1 + ) { + return true; + } + const hasPassive = new BooleanHolder(this.passive); applyChallenges(ChallengeType.PASSIVE_ACCESS, this, hasPassive); @@ -6507,8 +6515,11 @@ export class EnemyPokemon extends Pokemon { this.luck = (this.shiny ? this.variant + 1 : 0) + (this.fusionShiny ? this.fusionVariant + 1 : 0); - this.applyCustomDailyConfig(); - this.applyCustomDailyBossConfig(); + if (isDailyFinalBoss()) { + this.applyCustomDailyBossConfig(); + } else { + this.applyCustomDailyConfig(); + } if (this.hasTrainer() && globalScene.currentBattle) { const { waveIndex } = globalScene.currentBattle; diff --git a/src/game-mode.ts b/src/game-mode.ts index 1eca6c30ecf8..cb2e2befeecf 100644 --- a/src/game-mode.ts +++ b/src/game-mode.ts @@ -8,6 +8,7 @@ import { getDailyForcedWaveSpecies, getDailyStartingBiome, getDailyStartingMoney, + getDailyTrainerManipulation, } from "#data/daily-seed/daily-run"; import { parseDailySeed } from "#data/daily-seed/daily-seed-utils"; import { allSpecies } from "#data/data-lists"; @@ -208,6 +209,10 @@ export class GameMode implements GameModeConfig { // Daily spawns trainers on floors 5, 15, 20, 25, 30, 35, 40, and 45 if (this.isDaily) { + const trainerManipulation = getDailyTrainerManipulation(waveIndex); + if (trainerManipulation != null) { + return trainerManipulation; + } return waveIndex % 10 === 5 || (!(waveIndex % 10) && waveIndex > 10 && !this.isWaveFinal(waveIndex)); } if (waveIndex % 30 === (offsetGym ? 0 : 20) && !this.isWaveFinal(waveIndex)) { diff --git a/src/overrides.ts b/src/overrides.ts index 1448058bb7cb..0aff0ac8a4a0 100644 --- a/src/overrides.ts +++ b/src/overrides.ts @@ -6,6 +6,8 @@ import { AbilityId } from "#enums/ability-id"; import { BattleType } from "#enums/battle-type"; import { BerryType } from "#enums/berry-type"; import { BiomeId } from "#enums/biome-id"; +import { BiomePoolTier } from "#enums/biome-pool-tier"; +import { Challenges } from "#enums/challenges"; import { EggTier } from "#enums/egg-type"; import { FormChangeItem } from "#enums/form-change-item"; import { MoveId } from "#enums/move-id"; @@ -31,7 +33,7 @@ import type { IntClosedRange, TupleOf } from "type-fest"; /** * This comment block exists to prevent IDEs from automatically removing unused imports * {@linkcode BerryType}, {@linkcode EvolutionItem}, {@linkcode FormChangeItem} - * {@linkcode Stat}, {@linkcode PokemonType} + * {@linkcode Stat}, {@linkcode PokemonType} {@linkcode Challenges} {@linkcode BiomePoolTier} */ /** diff --git a/src/phases/command-phase.ts b/src/phases/command-phase.ts index b8b25871d22a..ad659013f508 100644 --- a/src/phases/command-phase.ts +++ b/src/phases/command-phase.ts @@ -3,6 +3,8 @@ import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; import { speciesStarterCosts } from "#balance/starters"; import { TrappedTag } from "#data/battler-tags"; +import { getDailyEventSeedBoss } from "#data/daily-seed/daily-run"; +import { isDailyFinalBoss } from "#data/daily-seed/daily-seed-utils"; import { AbilityId } from "#enums/ability-id"; import { ArenaTagSide } from "#enums/arena-tag-side"; import { ArenaTagType } from "#enums/arena-tag-type"; @@ -368,6 +370,7 @@ export class CommandPhase extends FieldPhase { .some(p => p.isActive() && !dexData[p.species.speciesId].caughtAttr); const missingMultipleStarters = gameData.getStarterCount(d => !!d.caughtAttr) < Object.keys(speciesStarterCosts).length - 1; + const isCatchableDailyBoss = isDailyFinalBoss() && (getDailyEventSeedBoss()?.catchable ?? false); if (biomeId === BiomeId.END && battleType === BattleType.WILD) { if ( @@ -381,7 +384,7 @@ export class CommandPhase extends FieldPhase { (isClassic && isClassicFinalBoss && missingMultipleStarters) || (isFullFreshStart && isClassicFinalBoss) || (isEndless && isEndlessMinorBoss) - || isDaily + || (isDaily && !isCatchableDailyBoss) ) { // Uncatchable final boss in classic, endless and daily this.queueShowText("battle:noPokeballForceFinalBoss"); @@ -422,6 +425,7 @@ export class CommandPhase extends FieldPhase { const isChallengeActive = globalScene.gameMode.hasAnyChallenges(); const isFinalBoss = globalScene.gameMode.isBattleClassicFinalBoss(globalScene.currentBattle.waveIndex); + const isCatchableDailyBoss = isDailyFinalBoss() && (getDailyEventSeedBoss()?.catchable ?? false); const numBallTypes = 5; if (cursor < numBallTypes) { @@ -441,7 +445,7 @@ export class CommandPhase extends FieldPhase { return false; } // When facing any other boss, Master Ball can always be used, and we use the standard message. - if (cursor < PokeballType.MASTER_BALL) { + if (isCatchableDailyBoss || cursor < PokeballType.MASTER_BALL) { this.queueShowText("battle:noPokeballStrong"); return false; } diff --git a/src/phases/title-phase.ts b/src/phases/title-phase.ts index 3acddae611a0..7d6fcfe1d486 100644 --- a/src/phases/title-phase.ts +++ b/src/phases/title-phase.ts @@ -6,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 { getDailyRunStarters } from "#data/daily-seed/daily-run"; +import { getDailyRunStarters, startDailyEventChallenges } from "#data/daily-seed/daily-run"; import { modifierTypes } from "#data/data-lists"; import { Gender } from "#data/gender"; import { BattleType } from "#enums/battle-type"; @@ -228,11 +228,12 @@ export class TitlePhase extends Phase { const generateDaily = (seed: string) => { globalScene.gameMode = getGameMode(GameModes.DAILY); - // Daily runs don't support all challenges yet (starter select restrictions aren't considered) - timedEventManager.startEventChallenges(); seed = globalScene.gameMode.trySetCustomDailyConfig(seed); + // Daily runs don't support all challenges yet (starter select restrictions aren't considered) + startDailyEventChallenges(); + globalScene.setSeed(seed); globalScene.resetSeed(); @@ -244,7 +245,7 @@ export class TitlePhase extends Phase { // TODO: Dedupe this const party = globalScene.getPlayerParty(); const loadPokemonAssets: Promise[] = []; - for (const starter of starters) { + for (const [index, starter] of starters.entries()) { const species = getPokemonSpecies(starter.speciesId); const starterFormIndex = starter.formIndex; const starterGender = @@ -266,6 +267,14 @@ export class TitlePhase extends Phase { starterPokemon.tryPopulateMoveset(starter.moveset, true); } + const customStarterConfig = globalScene.gameMode.dailyConfig?.starters?.[index]; + if (customStarterConfig?.ability != null) { + starterPokemon.customPokemonData.ability = customStarterConfig.ability; + } + if (customStarterConfig?.passive != null) { + starterPokemon.customPokemonData.passive = customStarterConfig.passive; + } + party.push(starterPokemon); loadPokemonAssets.push(starterPokemon.loadAssets()); } diff --git a/src/plugins/i18n.ts b/src/plugins/i18n.ts index 971b5ee306a9..4cc6cd5e86a6 100644 --- a/src/plugins/i18n.ts +++ b/src/plugins/i18n.ts @@ -1,3 +1,4 @@ +import { timedEventManager } from "#app/global-event-manager"; import { getCachedUrl } from "#utils/fetch-utils"; import { toKebabCase } from "#utils/strings"; import i18next from "i18next"; @@ -227,3 +228,18 @@ await i18next ); //#endregion + +//#region Event Proxy + +if (timedEventManager.hasEventTextReplacement()) { + console.warn("Event text replacements are active."); + i18next.t = new Proxy(i18next.t.bind(i18next), { + apply(target, _, args: [key: string, options?: any]) { + const key = timedEventManager.getEventTextReplacement(args[0]); + args[0] = key; + return target(...args); + }, + }); +} + +//#endregion diff --git a/src/timed-event-manager.ts b/src/timed-event-manager.ts index ca041a8694c3..5e1a79aca449 100644 --- a/src/timed-event-manager.ts +++ b/src/timed-event-manager.ts @@ -1,14 +1,17 @@ -import { globalScene } from "#app/global-scene"; import { SHINY_CATCH_RATE_MULTIPLIER } from "#balance/rates"; import { CLASSIC_CANDY_FRIENDSHIP_MULTIPLIER } from "#balance/starters"; +import { allSpecies } from "#data/data-lists"; import type { PokemonSpeciesFilter } from "#data/pokemon-species"; import type { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; import type { MysteryEncounterType } from "#enums/mystery-encounter-type"; import type { SpeciesId } from "#enums/species-id"; import type { ModifierTypeKeys } from "#modifiers/modifier-type"; import type { EventEncounter, EventMysteryEncounterTier, EventWeatherPools, TimedEvent } from "#types/events"; +import { randSeedShuffle } from "#utils/common"; import { getPokemonSpecies } from "#utils/pokemon-utils"; +import i18next from "i18next"; import { timedEvents } from "./data/balance/timed-events"; +import { globalScene } from "./global-scene"; export class TimedEventManager { /** @@ -16,6 +19,7 @@ export class TimedEventManager { * Used to disable events in testing. */ private disabled: boolean; + private cachedReplacementMap: Map | null = null; isActive(event: TimedEvent) { if (this.disabled) { @@ -204,13 +208,99 @@ export class TimedEventManager { return bgm; } + public getEventSpriteReplacement( + species: SpeciesId, + formIndex = 0, + ): { + speciesId: SpeciesId; + formIndex: number; + } | null { + const event = this.activeEvent(); + if (!event) { + return null; + } + const sprites = event?.sprites; + if (!sprites) { + return null; + } + const eventSpriteReplacements = sprites.replacements; + const fillRandom = sprites.fillRandom ?? false; + + for (const esr of eventSpriteReplacements) { + const [sourceSpeciesIdStr, sourceFormIndexStr] = esr[0].split("/"); + if (sourceSpeciesIdStr === species.toString() && (sourceFormIndexStr ?? "0") === formIndex.toString()) { + const [targetSpeciesIdStr, targetFormIndexStr] = esr[1].split("/"); + return { speciesId: Number(targetSpeciesIdStr) as SpeciesId, formIndex: Number(targetFormIndexStr ?? "0") }; + } + } + + if (fillRandom) { + // Multiply by 100000 to avoid collisions + const key = species * 100_000 + formIndex; + if (!this.cachedReplacementMap) { + this.fillRandomSpriteReplacements(); + } + return this.cachedReplacementMap!.get(key) ?? null; + } + return null; + } + /** - * Activate any challenges on {@linkcode globalScene.gameMode} for the currently active event + * Assign each species/form pair a random other species/form pair for sprite replacement. */ - startEventChallenges(): void { - for (const eventChal of this.activeEvent()?.dailyRunChallenges ?? []) { - globalScene.gameMode.setChallengeValue(eventChal.challenge, eventChal.value); + private fillRandomSpriteReplacements() { + if (!this.cachedReplacementMap) { + this.cachedReplacementMap = new Map(); + const allPairs: { speciesId: SpeciesId; formIndex: number }[] = []; + for (const species of allSpecies) { + const formCount = species.forms.length || 1; + for (let f = 0; f < formCount; f++) { + allPairs.push({ speciesId: species.speciesId, formIndex: f }); + } + } + globalScene.executeWithSeedOffset( + () => { + const shuffled = randSeedShuffle([...allPairs]); + for (let i = 0; i < allPairs.length; i++) { + const sourceKey = allPairs[i].speciesId * 100_000 + allPairs[i].formIndex; + this.cachedReplacementMap!.set(sourceKey, shuffled[i]); + } + }, + 0, + this.activeEvent()!.name, + ); + } + } + + /** + * Return the key replacement for the given i18n key if it exists in the active event, otherwise return the original key. + * @param key The i18n key to check for a replacement + * @returns The replacement key if it exists, otherwise the original key + */ + public getEventTextReplacement(key: string): string { + const event = this.activeEvent(); + if (!event || !event.textReplacements) { + return key; + } + for (const [source, target] of event.textReplacements) { + if (key === source && i18next.exists(target)) { + return target; + } + } + return key; + } + + /** + * Check if the current active event has any text replacements. \ + * This is used to determine wheater the i18next proxy should be loaded. + * @returns Whether the active event has text replacements + */ + public hasEventTextReplacement(): boolean { + const event = this.activeEvent(); + if (!event) { + return false; } + return event.textReplacements != null && event.textReplacements.length > 0; } getEventDailyStartingItems(): readonly ModifierTypeKeys[] {