import i18next from "i18next"; import type { PokeballCounts } from "#app/battle-scene"; import { bypassLogin } from "#app/battle-scene"; import { globalScene } from "#app/global-scene"; import type { EnemyPokemon, PlayerPokemon } from "#app/field/pokemon"; import type Pokemon from "#app/field/pokemon"; import { pokemonPrevolutions } from "#app/data/balance/pokemon-evolutions"; import type PokemonSpecies from "#app/data/pokemon-species"; import { allSpecies, getPokemonSpecies } from "#app/data/pokemon-species"; import { speciesStarterCosts } from "#app/data/balance/starters"; import * as Utils from "#app/utils"; import Overrides from "#app/overrides"; import PokemonData from "#app/system/pokemon-data"; import PersistentModifierData from "#app/system/modifier-data"; import ArenaData from "#app/system/arena-data"; import { Unlockables } from "#app/system/unlockables"; import { GameModes, getGameMode } from "#app/game-mode"; import { BattleType } from "#app/battle"; import TrainerData from "#app/system/trainer-data"; import { trainerConfigs } from "#app/data/trainer-config"; import { resetSettings, setSetting, SettingKeys } from "#app/system/settings/settings"; import { achvs } from "#app/system/achv"; import EggData from "#app/system/egg-data"; import type { Egg } from "#app/data/egg"; import { vouchers, VoucherType } from "#app/system/voucher"; import { AES, enc } from "crypto-js"; import { Mode } from "#app/ui/ui"; import { clientSessionId, loggedInUser, updateUserInfo } from "#app/account"; import { Nature } from "#enums/nature"; import { GameStats } from "#app/system/game-stats"; import { Tutorial } from "#app/tutorial"; import { speciesEggMoves } from "#app/data/balance/egg-moves"; import { allMoves } from "#app/data/move"; import { TrainerVariant } from "#app/field/trainer"; import type { Variant } from "#app/data/variant"; import { setSettingGamepad, SettingGamepad, settingGamepadDefaults } from "#app/system/settings/settings-gamepad"; import type { SettingKeyboard } from "#app/system/settings/settings-keyboard"; import { setSettingKeyboard } from "#app/system/settings/settings-keyboard"; import { TagAddedEvent, TerrainChangedEvent, WeatherChangedEvent } from "#app/events/arena"; import * as Modifier from "#app/modifier/modifier"; import { StatusEffect } from "#enums/status-effect"; import ChallengeData from "#app/system/challenge-data"; import { Device } from "#enums/devices"; import { GameDataType } from "#enums/game-data-type"; import type { Moves } from "#enums/moves"; import { PlayerGender } from "#enums/player-gender"; import { Species } from "#enums/species"; import { applyChallenges, ChallengeType } from "#app/data/challenge"; import { WeatherType } from "#enums/weather-type"; import { TerrainType } from "#app/data/terrain"; import { ReloadSessionPhase } from "#app/phases/reload-session-phase"; import { RUN_HISTORY_LIMIT } from "#app/ui/run-history-ui-handler"; import { applySessionVersionMigration, applySystemVersionMigration, applySettingsVersionMigration } from "./version_migration/version_converter"; import { MysteryEncounterSaveData } from "#app/data/mystery-encounters/mystery-encounter-save-data"; import type { MysteryEncounterType } from "#enums/mystery-encounter-type"; import { pokerogueApi } from "#app/plugins/api/pokerogue-api"; import { ArenaTrapTag } from "#app/data/arena-tag"; export const defaultStarterSpecies: Species[] = [ Species.BULBASAUR, Species.CHARMANDER, Species.SQUIRTLE, Species.CHIKORITA, Species.CYNDAQUIL, Species.TOTODILE, Species.TREECKO, Species.TORCHIC, Species.MUDKIP, Species.TURTWIG, Species.CHIMCHAR, Species.PIPLUP, Species.SNIVY, Species.TEPIG, Species.OSHAWOTT, Species.CHESPIN, Species.FENNEKIN, Species.FROAKIE, Species.ROWLET, Species.LITTEN, Species.POPPLIO, Species.GROOKEY, Species.SCORBUNNY, Species.SOBBLE, Species.SPRIGATITO, Species.FUECOCO, Species.QUAXLY ]; const saveKey = "x0i2O7WRiANTqPmZ"; // Temporary; secure encryption is not yet necessary export function getDataTypeKey(dataType: GameDataType, slotId: number = 0): string { switch (dataType) { case GameDataType.SYSTEM: return "data"; case GameDataType.SESSION: let ret = "sessionData"; if (slotId) { ret += slotId; } return ret; case GameDataType.SETTINGS: return "settings"; case GameDataType.TUTORIALS: return "tutorials"; case GameDataType.SEEN_DIALOGUES: return "seenDialogues"; case GameDataType.RUN_HISTORY: return "runHistoryData"; } } export function encrypt(data: string, bypassLogin: boolean): string { return (bypassLogin ? (data: string) => btoa(data) : (data: string) => AES.encrypt(data, saveKey))(data) as unknown as string; // TODO: is this correct? } export function decrypt(data: string, bypassLogin: boolean): string { return (bypassLogin ? (data: string) => atob(data) : (data: string) => AES.decrypt(data, saveKey).toString(enc.Utf8))(data); } export interface SystemSaveData { trainerId: number; secretId: number; gender: PlayerGender; dexData: DexData; starterData: StarterData; gameStats: GameStats; unlocks: Unlocks; achvUnlocks: AchvUnlocks; voucherUnlocks: VoucherUnlocks; voucherCounts: VoucherCounts; eggs: EggData[]; gameVersion: string; timestamp: number; eggPity: number[]; unlockPity: number[]; } export interface SessionSaveData { seed: string; playTime: number; gameMode: GameModes; party: PokemonData[]; enemyParty: PokemonData[]; modifiers: PersistentModifierData[]; enemyModifiers: PersistentModifierData[]; arena: ArenaData; pokeballCounts: PokeballCounts; money: number; score: number; waveIndex: number; battleType: BattleType; trainer: TrainerData; gameVersion: string; timestamp: number; challenges: ChallengeData[]; mysteryEncounterType: MysteryEncounterType | -1; // Only defined when current wave is ME, mysteryEncounterSaveData: MysteryEncounterSaveData; /** * Counts the amount of pokemon fainted in your party during the current arena encounter. */ playerFaints: number; } interface Unlocks { [key: number]: boolean; } interface AchvUnlocks { [key: string]: number } interface VoucherUnlocks { [key: string]: number } export interface VoucherCounts { [type: string]: number; } export interface DexData { [key: number]: DexEntry } export interface DexEntry { seenAttr: bigint; caughtAttr: bigint; natureAttr: number, seenCount: number; caughtCount: number; hatchedCount: number; ivs: number[]; } export const DexAttr = { NON_SHINY: 1n, SHINY: 2n, MALE: 4n, FEMALE: 8n, DEFAULT_VARIANT: 16n, VARIANT_2: 32n, VARIANT_3: 64n, DEFAULT_FORM: 128n }; export interface DexAttrProps { shiny: boolean; female: boolean; variant: Variant; formIndex: number; } export const AbilityAttr = { ABILITY_1: 1, ABILITY_2: 2, ABILITY_HIDDEN: 4 }; export type RunHistoryData = Record; export interface RunEntry { entry: SessionSaveData; isVictory: boolean; /*Automatically set to false at the moment - implementation TBD*/ isFavorite: boolean; } export type StarterMoveset = [ Moves ] | [ Moves, Moves ] | [ Moves, Moves, Moves ] | [ Moves, Moves, Moves, Moves ]; export interface StarterFormMoveData { [key: number]: StarterMoveset } export interface StarterMoveData { [key: number]: StarterMoveset | StarterFormMoveData } export interface StarterAttributes { nature?: number; ability?: number; variant?: number; form?: number; female?: boolean; shiny?: boolean; favorite?: boolean; nickname?: string; } export interface StarterPreferences { [key: number]: StarterAttributes; } // the latest data saved/loaded for the Starter Preferences. Required to reduce read/writes. Initialize as "{}", since this is the default value and no data needs to be stored if present. // if they ever add private static variables, move this into StarterPrefs const StarterPrefers_DEFAULT : string = "{}"; let StarterPrefers_private_latest : string = StarterPrefers_DEFAULT; // This is its own class as StarterPreferences... // - don't need to be loaded on startup // - isn't stored with other data // - don't require to be encrypted // - shouldn't require calls outside of the starter selection export class StarterPrefs { // called on starter selection show once static load(): StarterPreferences { return JSON.parse( StarterPrefers_private_latest = (localStorage.getItem(`starterPrefs_${loggedInUser?.username}`) || StarterPrefers_DEFAULT) ); } // called on starter selection clear, always static save(prefs: StarterPreferences): void { const pStr : string = JSON.stringify(prefs); if (pStr !== StarterPrefers_private_latest) { // something changed, store the update localStorage.setItem(`starterPrefs_${loggedInUser?.username}`, pStr); // update the latest prefs StarterPrefers_private_latest = pStr; } } } export interface StarterDataEntry { moveset: StarterMoveset | StarterFormMoveData | null; eggMoves: number; candyCount: number; friendship: number; abilityAttr: number; passiveAttr: number; valueReduction: number; classicWinCount: number; } export interface StarterData { [key: number]: StarterDataEntry } export interface TutorialFlags { [key: string]: boolean } export interface SeenDialogues { [key: string]: boolean; } const systemShortKeys = { seenAttr: "$sa", caughtAttr: "$ca", natureAttr: "$na", seenCount: "$s", caughtCount: "$c", hatchedCount: "$hc", ivs: "$i", moveset: "$m", eggMoves: "$em", candyCount: "$x", friendship: "$f", abilityAttr: "$a", passiveAttr: "$pa", valueReduction: "$vr", classicWinCount: "$wc" }; export class GameData { public trainerId: number; public secretId: number; public gender: PlayerGender; public dexData: DexData; private defaultDexData: DexData | null; public starterData: StarterData; public gameStats: GameStats; public runHistory: RunHistoryData; public unlocks: Unlocks; public achvUnlocks: AchvUnlocks; public voucherUnlocks: VoucherUnlocks; public voucherCounts: VoucherCounts; public eggs: Egg[]; public eggPity: number[]; public unlockPity: number[]; constructor() { this.loadSettings(); this.loadGamepadSettings(); this.loadMappingConfigs(); this.trainerId = Utils.randInt(65536); this.secretId = Utils.randInt(65536); this.starterData = {}; this.gameStats = new GameStats(); this.runHistory = {}; this.unlocks = { [Unlockables.ENDLESS_MODE]: false, [Unlockables.MINI_BLACK_HOLE]: false, [Unlockables.SPLICED_ENDLESS_MODE]: false, [Unlockables.EVIOLITE]: false }; this.achvUnlocks = {}; this.voucherUnlocks = {}; this.voucherCounts = { [VoucherType.REGULAR]: 0, [VoucherType.PLUS]: 0, [VoucherType.PREMIUM]: 0, [VoucherType.GOLDEN]: 0 }; this.eggs = []; this.eggPity = [ 0, 0, 0, 0 ]; this.unlockPity = [ 0, 0, 0, 0 ]; this.initDexData(); this.initStarterData(); } public getSystemSaveData(): SystemSaveData { return { trainerId: this.trainerId, secretId: this.secretId, gender: this.gender, dexData: this.dexData, starterData: this.starterData, gameStats: this.gameStats, unlocks: this.unlocks, achvUnlocks: this.achvUnlocks, voucherUnlocks: this.voucherUnlocks, voucherCounts: this.voucherCounts, eggs: this.eggs.map(e => new EggData(e)), gameVersion: globalScene.game.config.gameVersion, timestamp: new Date().getTime(), eggPity: this.eggPity.slice(0), unlockPity: this.unlockPity.slice(0) }; } /** * Checks if an `Unlockable` has been unlocked. * @param unlockable The Unlockable to check * @returns `true` if the player has unlocked this `Unlockable` or an override has enabled it */ public isUnlocked(unlockable: Unlockables): boolean { if (Overrides.ITEM_UNLOCK_OVERRIDE.includes(unlockable)) { return true; } return this.unlocks[unlockable]; } public saveSystem(): Promise { return new Promise(resolve => { globalScene.ui.savingIcon.show(); const data = this.getSystemSaveData(); const maxIntAttrValue = 0x80000000; const systemData = JSON.stringify(data, (k: any, v: any) => typeof v === "bigint" ? v <= maxIntAttrValue ? Number(v) : v.toString() : v); localStorage.setItem(`data_${loggedInUser?.username}`, encrypt(systemData, bypassLogin)); if (!bypassLogin) { pokerogueApi.savedata.system.update({ clientSessionId }, systemData) .then(error => { globalScene.ui.savingIcon.hide(); if (error) { if (error.startsWith("session out of date")) { globalScene.clearPhaseQueue(); globalScene.unshiftPhase(new ReloadSessionPhase()); } console.error(error); return resolve(false); } resolve(true); }); } else { globalScene.ui.savingIcon.hide(); resolve(true); } }); } public loadSystem(): Promise { return new Promise(resolve => { console.log("Client Session:", clientSessionId); if (bypassLogin && !localStorage.getItem(`data_${loggedInUser?.username}`)) { return resolve(false); } if (!bypassLogin) { pokerogueApi.savedata.system.get({ clientSessionId }) .then(saveDataOrErr => { if (!saveDataOrErr || saveDataOrErr.length === 0 || saveDataOrErr[0] !== "{") { if (saveDataOrErr?.startsWith("sql: no rows in result set")) { globalScene.queueMessage("Save data could not be found. If this is a new account, you can safely ignore this message.", null, true); return resolve(true); } else if (saveDataOrErr?.includes("Too many connections")) { globalScene.queueMessage("Too many people are trying to connect and the server is overloaded. Please try again later.", null, true); return resolve(false); } console.error(saveDataOrErr); return resolve(false); } const cachedSystem = localStorage.getItem(`data_${loggedInUser?.username}`); this.initSystem(saveDataOrErr, cachedSystem ? AES.decrypt(cachedSystem, saveKey).toString(enc.Utf8) : undefined).then(resolve); }); } else { this.initSystem(decrypt(localStorage.getItem(`data_${loggedInUser?.username}`)!, bypassLogin)).then(resolve); // TODO: is this bang correct? } }); } public initSystem(systemDataStr: string, cachedSystemDataStr?: string): Promise { return new Promise(resolve => { try { let systemData = this.parseSystemData(systemDataStr); if (cachedSystemDataStr) { const cachedSystemData = this.parseSystemData(cachedSystemDataStr); if (cachedSystemData.timestamp > systemData.timestamp) { console.debug("Use cached system"); systemData = cachedSystemData; systemDataStr = cachedSystemDataStr; } else { this.clearLocalData(); } } console.debug(systemData); localStorage.setItem(`data_${loggedInUser?.username}`, encrypt(systemDataStr, bypassLogin)); const lsItemKey = `runHistoryData_${loggedInUser?.username}`; const lsItem = localStorage.getItem(lsItemKey); if (!lsItem) { localStorage.setItem(lsItemKey, ""); } applySystemVersionMigration(systemData); this.trainerId = systemData.trainerId; this.secretId = systemData.secretId; this.gender = systemData.gender; this.saveSetting(SettingKeys.Player_Gender, systemData.gender === PlayerGender.FEMALE ? 1 : 0); if (!systemData.starterData) { this.initStarterData(); if (systemData["starterMoveData"]) { const starterMoveData = systemData["starterMoveData"]; for (const s of Object.keys(starterMoveData)) { this.starterData[s].moveset = starterMoveData[s]; } } if (systemData["starterEggMoveData"]) { const starterEggMoveData = systemData["starterEggMoveData"]; for (const s of Object.keys(starterEggMoveData)) { this.starterData[s].eggMoves = starterEggMoveData[s]; } } this.migrateStarterAbilities(systemData, this.starterData); const starterIds = Object.keys(this.starterData).map(s => parseInt(s) as Species); for (const s of starterIds) { this.starterData[s].candyCount += systemData.dexData[s].caughtCount; this.starterData[s].candyCount += systemData.dexData[s].hatchedCount * 2; if (systemData.dexData[s].caughtAttr & DexAttr.SHINY) { this.starterData[s].candyCount += 4; } } } else { this.starterData = systemData.starterData; } if (systemData.gameStats) { this.gameStats = systemData.gameStats; } if (systemData.unlocks) { for (const key of Object.keys(systemData.unlocks)) { if (this.unlocks.hasOwnProperty(key)) { this.unlocks[key] = systemData.unlocks[key]; } } } if (systemData.achvUnlocks) { for (const a of Object.keys(systemData.achvUnlocks)) { if (achvs.hasOwnProperty(a)) { this.achvUnlocks[a] = systemData.achvUnlocks[a]; } } } if (systemData.voucherUnlocks) { for (const v of Object.keys(systemData.voucherUnlocks)) { if (vouchers.hasOwnProperty(v)) { this.voucherUnlocks[v] = systemData.voucherUnlocks[v]; } } } if (systemData.voucherCounts) { Utils.getEnumKeys(VoucherType).forEach(key => { const index = VoucherType[key]; this.voucherCounts[index] = systemData.voucherCounts[index] || 0; }); } this.eggs = systemData.eggs ? systemData.eggs.map(e => e.toEgg()) : []; this.eggPity = systemData.eggPity ? systemData.eggPity.slice(0) : [ 0, 0, 0, 0 ]; this.unlockPity = systemData.unlockPity ? systemData.unlockPity.slice(0) : [ 0, 0, 0, 0 ]; this.dexData = Object.assign(this.dexData, systemData.dexData); this.consolidateDexData(this.dexData); this.defaultDexData = null; resolve(true); } catch (err) { console.error(err); resolve(false); } }); } /** * Retrieves current run history data, organized by time stamp. * At the moment, only retrievable from locale cache */ async getRunHistoryData(): Promise { if (!Utils.isLocal) { /** * Networking Code DO NOT DELETE! * Note: Might have to be migrated to `pokerogue-api.ts` * const response = await Utils.apiFetch("savedata/runHistory", true); const data = await response.json(); */ const lsItemKey = `runHistoryData_${loggedInUser?.username}`; const lsItem = localStorage.getItem(lsItemKey); if (lsItem) { const cachedResponse = lsItem; if (cachedResponse) { const runHistory = JSON.parse(decrypt(cachedResponse, bypassLogin)); return runHistory; } return {}; // check to see whether cachedData or serverData is more up-to-date /** * Networking Code DO NOT DELETE! * if ( Object.keys(cachedRHData).length >= Object.keys(data).length ) { return cachedRHData; } */ } else { localStorage.setItem(`runHistoryData_${loggedInUser?.username}`, ""); return {}; } } else { const lsItemKey = `runHistoryData_${loggedInUser?.username}`; const lsItem = localStorage.getItem(lsItemKey); if (lsItem) { const cachedResponse = lsItem; if (cachedResponse) { const runHistory : RunHistoryData = JSON.parse(decrypt(cachedResponse, bypassLogin)); return runHistory; } return {}; } else { localStorage.setItem(`runHistoryData_${loggedInUser?.username}`, ""); return {}; } } } /** * Saves a new entry to Run History * @param runEntry: most recent SessionSaveData of the run * @param isVictory: result of the run * Arbitrary limit of 25 runs per player - Will delete runs, starting with the oldest one, if needed */ async saveRunHistory(runEntry : SessionSaveData, isVictory: boolean): Promise { const runHistoryData = await this.getRunHistoryData(); // runHistoryData should always return run history or {} empty object let timestamps = Object.keys(runHistoryData).map(Number); // Arbitrary limit of 25 entries per user --> Can increase or decrease while (timestamps.length >= RUN_HISTORY_LIMIT ) { const oldestTimestamp = (Math.min.apply(Math, timestamps)).toString(); delete runHistoryData[oldestTimestamp]; timestamps = Object.keys(runHistoryData).map(Number); } const timestamp = (runEntry.timestamp).toString(); runHistoryData[timestamp] = { entry: runEntry, isVictory: isVictory, isFavorite: false, }; localStorage.setItem(`runHistoryData_${loggedInUser?.username}`, encrypt(JSON.stringify(runHistoryData), bypassLogin)); /** * Networking Code DO NOT DELETE * if (!Utils.isLocal) { try { await Utils.apiPost("savedata/runHistory", JSON.stringify(runHistoryData), undefined, true); return true; } catch (err) { console.log("savedata/runHistory POST failed : ", err); return false; } } NOTE: should be adopted to `pokerogue-api.ts` */ return true; } parseSystemData(dataStr: string): SystemSaveData { return JSON.parse(dataStr, (k: string, v: any) => { if (k === "gameStats") { return new GameStats(v); } else if (k === "eggs") { const ret: EggData[] = []; if (v === null) { v = []; } for (const e of v) { ret.push(new EggData(e)); } return ret; } return k.endsWith("Attr") && ![ "natureAttr", "abilityAttr", "passiveAttr" ].includes(k) ? BigInt(v ?? 0) : v; }) as SystemSaveData; } convertSystemDataStr(dataStr: string, shorten: boolean = false): string { if (!shorten) { // Account for past key oversight dataStr = dataStr.replace(/\$pAttr/g, "$pa"); } dataStr = dataStr.replace(/"trainerId":\d+/g, `"trainerId":${this.trainerId}`); dataStr = dataStr.replace(/"secretId":\d+/g, `"secretId":${this.secretId}`); const fromKeys = shorten ? Object.keys(systemShortKeys) : Object.values(systemShortKeys); const toKeys = shorten ? Object.values(systemShortKeys) : Object.keys(systemShortKeys); for (const k in fromKeys) { dataStr = dataStr.replace(new RegExp(`${fromKeys[k].replace("$", "\\$")}`, "g"), toKeys[k]); } return dataStr; } public async verify(): Promise { if (bypassLogin) { return true; } const systemData = await pokerogueApi.savedata.system.verify({ clientSessionId }); if (systemData) { globalScene.clearPhaseQueue(); globalScene.unshiftPhase(new ReloadSessionPhase(JSON.stringify(systemData))); this.clearLocalData(); return false; } return true; } public clearLocalData(): void { if (bypassLogin) { return; } localStorage.removeItem(`data_${loggedInUser?.username}`); for (let s = 0; s < 5; s++) { localStorage.removeItem(`sessionData${s ? s : ""}_${loggedInUser?.username}`); } } /** * Saves a setting to localStorage * @param setting string ideally of SettingKeys * @param valueIndex index of the setting's option * @returns true */ public saveSetting(setting: string, valueIndex: number): boolean { let settings: object = {}; if (localStorage.hasOwnProperty("settings")) { settings = JSON.parse(localStorage.getItem("settings")!); // TODO: is this bang correct? } setSetting(setting, valueIndex); settings[setting] = valueIndex; settings["gameVersion"] = globalScene.game.config.gameVersion; localStorage.setItem("settings", JSON.stringify(settings)); return true; } /** * Saves the mapping configurations for a specified device. * * @param deviceName - The name of the device for which the configurations are being saved. * @param config - The configuration object containing custom mapping details. * @returns `true` if the configurations are successfully saved. */ public saveMappingConfigs(deviceName: string, config): boolean { const key = deviceName.toLowerCase(); // Convert the gamepad name to lowercase to use as a key let mappingConfigs: object = {}; // Initialize an empty object to hold the mapping configurations if (localStorage.hasOwnProperty("mappingConfigs")) {// Check if 'mappingConfigs' exists in localStorage mappingConfigs = JSON.parse(localStorage.getItem("mappingConfigs")!); // TODO: is this bang correct? } // Parse the existing 'mappingConfigs' from localStorage if (!mappingConfigs[key]) { mappingConfigs[key] = {}; } // If there is no configuration for the given key, create an empty object for it mappingConfigs[key].custom = config.custom; // Assign the custom configuration to the mapping configuration for the given key localStorage.setItem("mappingConfigs", JSON.stringify(mappingConfigs)); // Save the updated mapping configurations back to localStorage return true; // Return true to indicate the operation was successful } /** * Loads the mapping configurations from localStorage and injects them into the input controller. * * @returns `true` if the configurations are successfully loaded and injected; `false` if no configurations are found in localStorage. * * @remarks * This method checks if the 'mappingConfigs' entry exists in localStorage. If it does not exist, the method returns `false`. * If 'mappingConfigs' exists, it parses the configurations and injects each configuration into the input controller * for the corresponding gamepad or device key. The method then returns `true` to indicate success. */ public loadMappingConfigs(): boolean { if (!localStorage.hasOwnProperty("mappingConfigs")) {// Check if 'mappingConfigs' exists in localStorage return false; } // If 'mappingConfigs' does not exist, return false const mappingConfigs = JSON.parse(localStorage.getItem("mappingConfigs")!); // Parse the existing 'mappingConfigs' from localStorage // TODO: is this bang correct? for (const key of Object.keys(mappingConfigs)) {// Iterate over the keys of the mapping configurations globalScene.inputController.injectConfig(key, mappingConfigs[key]); } // Inject each configuration into the input controller for the corresponding key return true; // Return true to indicate the operation was successful } public resetMappingToFactory(): boolean { if (!localStorage.hasOwnProperty("mappingConfigs")) {// Check if 'mappingConfigs' exists in localStorage return false; } // If 'mappingConfigs' does not exist, return false localStorage.removeItem("mappingConfigs"); globalScene.inputController.resetConfigs(); return true; // TODO: is `true` the correct return value? } /** * Saves a gamepad setting to localStorage. * * @param setting - The gamepad setting to save. * @param valueIndex - The index of the value to set for the gamepad setting. * @returns `true` if the setting is successfully saved. * * @remarks * This method initializes an empty object for gamepad settings if none exist in localStorage. * It then updates the setting in the current scene and iterates over the default gamepad settings * to update the specified setting with the new value. Finally, it saves the updated settings back * to localStorage and returns `true` to indicate success. */ public saveControlSetting(device: Device, localStoragePropertyName: string, setting: SettingGamepad|SettingKeyboard, settingDefaults, valueIndex: number): boolean { let settingsControls: object = {}; // Initialize an empty object to hold the gamepad settings if (localStorage.hasOwnProperty(localStoragePropertyName)) { // Check if 'settingsControls' exists in localStorage settingsControls = JSON.parse(localStorage.getItem(localStoragePropertyName)!); // Parse the existing 'settingsControls' from localStorage // TODO: is this bang correct? } if (device === Device.GAMEPAD) { setSettingGamepad(setting as SettingGamepad, valueIndex); } else if (device === Device.KEYBOARD) { setSettingKeyboard(setting as SettingKeyboard, valueIndex); } Object.keys(settingDefaults).forEach(s => { // Iterate over the default gamepad settings if (s === setting) {// If the current setting matches, update its value settingsControls[s] = valueIndex; } }); localStorage.setItem(localStoragePropertyName, JSON.stringify(settingsControls)); // Save the updated gamepad settings back to localStorage return true; // Return true to indicate the operation was successful } /** * Loads Settings from local storage if available * @returns true if succesful, false if not */ private loadSettings(): boolean { resetSettings(); if (!localStorage.hasOwnProperty("settings")) { return false; } const settings = JSON.parse(localStorage.getItem("settings")!); // TODO: is this bang correct? applySettingsVersionMigration(settings); for (const setting of Object.keys(settings)) { setSetting(setting, settings[setting]); } return true; // TODO: is `true` the correct return value? } private loadGamepadSettings(): boolean { Object.values(SettingGamepad).map(setting => setting as SettingGamepad).forEach(setting => setSettingGamepad(setting, settingGamepadDefaults[setting])); if (!localStorage.hasOwnProperty("settingsGamepad")) { return false; } const settingsGamepad = JSON.parse(localStorage.getItem("settingsGamepad")!); // TODO: is this bang correct? for (const setting of Object.keys(settingsGamepad)) { setSettingGamepad(setting as SettingGamepad, settingsGamepad[setting]); } return true; // TODO: is `true` the correct return value? } public saveTutorialFlag(tutorial: Tutorial, flag: boolean): boolean { const key = getDataTypeKey(GameDataType.TUTORIALS); let tutorials: object = {}; if (localStorage.hasOwnProperty(key)) { tutorials = JSON.parse(localStorage.getItem(key)!); // TODO: is this bang correct? } Object.keys(Tutorial).map(t => t as Tutorial).forEach(t => { const key = Tutorial[t]; if (key === tutorial) { tutorials[key] = flag; } else { tutorials[key] ??= false; } }); localStorage.setItem(key, JSON.stringify(tutorials)); return true; } public getTutorialFlags(): TutorialFlags { const key = getDataTypeKey(GameDataType.TUTORIALS); const ret: TutorialFlags = {}; Object.values(Tutorial).map(tutorial => tutorial as Tutorial).forEach(tutorial => ret[Tutorial[tutorial]] = false); if (!localStorage.hasOwnProperty(key)) { return ret; } const tutorials = JSON.parse(localStorage.getItem(key)!); // TODO: is this bang correct? for (const tutorial of Object.keys(tutorials)) { ret[tutorial] = tutorials[tutorial]; } return ret; } public saveSeenDialogue(dialogue: string): boolean { const key = getDataTypeKey(GameDataType.SEEN_DIALOGUES); const dialogues: object = this.getSeenDialogues(); dialogues[dialogue] = true; localStorage.setItem(key, JSON.stringify(dialogues)); console.log("Dialogue saved as seen:", dialogue); return true; } public getSeenDialogues(): SeenDialogues { const key = getDataTypeKey(GameDataType.SEEN_DIALOGUES); const ret: SeenDialogues = {}; if (!localStorage.hasOwnProperty(key)) { return ret; } const dialogues = JSON.parse(localStorage.getItem(key)!); // TODO: is this bang correct? for (const dialogue of Object.keys(dialogues)) { ret[dialogue] = dialogues[dialogue]; } return ret; } public getSessionSaveData(): SessionSaveData { return { seed: globalScene.seed, playTime: globalScene.sessionPlayTime, gameMode: globalScene.gameMode.modeId, party: globalScene.getPlayerParty().map(p => new PokemonData(p)), enemyParty: globalScene.getEnemyParty().map(p => new PokemonData(p)), modifiers: globalScene.findModifiers(() => true).map(m => new PersistentModifierData(m, true)), enemyModifiers: globalScene.findModifiers(() => true, false).map(m => new PersistentModifierData(m, false)), arena: new ArenaData(globalScene.arena), pokeballCounts: globalScene.pokeballCounts, money: Math.floor(globalScene.money), score: globalScene.score, waveIndex: globalScene.currentBattle.waveIndex, battleType: globalScene.currentBattle.battleType, trainer: globalScene.currentBattle.battleType === BattleType.TRAINER ? new TrainerData(globalScene.currentBattle.trainer) : null, gameVersion: globalScene.game.config.gameVersion, timestamp: new Date().getTime(), challenges: globalScene.gameMode.challenges.map(c => new ChallengeData(c)), mysteryEncounterType: globalScene.currentBattle.mysteryEncounter?.encounterType ?? -1, mysteryEncounterSaveData: globalScene.mysteryEncounterSaveData, playerFaints: globalScene.arena.playerFaints } as SessionSaveData; } getSession(slotId: number): Promise { return new Promise(async (resolve, reject) => { if (slotId < 0) { return resolve(null); } const handleSessionData = async (sessionDataStr: string) => { try { const sessionData = this.parseSessionData(sessionDataStr); resolve(sessionData); } catch (err) { reject(err); return; } }; if (!bypassLogin && !localStorage.getItem(`sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`)) { pokerogueApi.savedata.session.get({ slot: slotId, clientSessionId }) .then(async response => { if (!response || response?.length === 0 || response?.[0] !== "{") { console.error(response); return resolve(null); } localStorage.setItem(`sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`, encrypt(response, bypassLogin)); await handleSessionData(response); }); } else { const sessionData = localStorage.getItem(`sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`); if (sessionData) { await handleSessionData(decrypt(sessionData, bypassLogin)); } else { return resolve(null); } } }); } loadSession(slotId: number, sessionData?: SessionSaveData): Promise { return new Promise(async (resolve, reject) => { try { const initSessionFromData = async (sessionData: SessionSaveData) => { console.debug(sessionData); globalScene.gameMode = getGameMode(sessionData.gameMode || GameModes.CLASSIC); if (sessionData.challenges) { globalScene.gameMode.challenges = sessionData.challenges.map(c => c.toChallenge()); } globalScene.setSeed(sessionData.seed || globalScene.game.config.seed[0]); globalScene.resetSeed(); console.log("Seed:", globalScene.seed); globalScene.sessionPlayTime = sessionData.playTime || 0; globalScene.lastSavePlayTime = 0; const loadPokemonAssets: Promise[] = []; const party = globalScene.getPlayerParty(); party.splice(0, party.length); for (const p of sessionData.party) { const pokemon = p.toPokemon() as PlayerPokemon; pokemon.setVisible(false); loadPokemonAssets.push(pokemon.loadAssets()); party.push(pokemon); } Object.keys(globalScene.pokeballCounts).forEach((key: string) => { globalScene.pokeballCounts[key] = sessionData.pokeballCounts[key] || 0; }); if (Overrides.POKEBALL_OVERRIDE.active) { globalScene.pokeballCounts = Overrides.POKEBALL_OVERRIDE.pokeballs; } globalScene.money = Math.floor(sessionData.money || 0); globalScene.updateMoneyText(); if (globalScene.money > this.gameStats.highestMoney) { this.gameStats.highestMoney = globalScene.money; } globalScene.score = sessionData.score; globalScene.updateScoreText(); globalScene.mysteryEncounterSaveData = new MysteryEncounterSaveData(sessionData.mysteryEncounterSaveData); globalScene.newArena(sessionData.arena.biome, sessionData.playerFaints); const battleType = sessionData.battleType || 0; const trainerConfig = sessionData.trainer ? trainerConfigs[sessionData.trainer.trainerType] : null; const mysteryEncounterType = sessionData.mysteryEncounterType !== -1 ? sessionData.mysteryEncounterType : undefined; const battle = globalScene.newBattle(sessionData.waveIndex, battleType, sessionData.trainer, battleType === BattleType.TRAINER ? trainerConfig?.doubleOnly || sessionData.trainer?.variant === TrainerVariant.DOUBLE : sessionData.enemyParty.length > 1, mysteryEncounterType)!; // TODO: is this bang correct? battle.enemyLevels = sessionData.enemyParty.map(p => p.level); globalScene.arena.init(); sessionData.enemyParty.forEach((enemyData, e) => { const enemyPokemon = enemyData.toPokemon(battleType, e, sessionData.trainer?.variant === TrainerVariant.DOUBLE) as EnemyPokemon; battle.enemyParty[e] = enemyPokemon; if (battleType === BattleType.WILD) { battle.seenEnemyPartyMemberIds.add(enemyPokemon.id); } loadPokemonAssets.push(enemyPokemon.loadAssets()); }); globalScene.arena.weather = sessionData.arena.weather; globalScene.arena.eventTarget.dispatchEvent(new WeatherChangedEvent(WeatherType.NONE, globalScene.arena.weather?.weatherType!, globalScene.arena.weather?.turnsLeft!)); // TODO: is this bang correct? globalScene.arena.terrain = sessionData.arena.terrain; globalScene.arena.eventTarget.dispatchEvent(new TerrainChangedEvent(TerrainType.NONE, globalScene.arena.terrain?.terrainType!, globalScene.arena.terrain?.turnsLeft!)); // TODO: is this bang correct? globalScene.arena.playerTerasUsed = sessionData.arena.playerTerasUsed; globalScene.arena.tags = sessionData.arena.tags; if (globalScene.arena.tags) { for (const tag of globalScene.arena.tags) { if (tag instanceof ArenaTrapTag) { const { tagType, side, turnCount, layers, maxLayers } = tag as ArenaTrapTag; globalScene.arena.eventTarget.dispatchEvent(new TagAddedEvent(tagType, side, turnCount, layers, maxLayers)); } else { globalScene.arena.eventTarget.dispatchEvent(new TagAddedEvent(tag.tagType, tag.side, tag.turnCount)); } } } for (const modifierData of sessionData.modifiers) { const modifier = modifierData.toModifier(Modifier[modifierData.className]); if (modifier) { globalScene.addModifier(modifier, true); } } globalScene.updateModifiers(true); for (const enemyModifierData of sessionData.enemyModifiers) { const modifier = enemyModifierData.toModifier(Modifier[enemyModifierData.className]); if (modifier) { globalScene.addEnemyModifier(modifier, true); } } globalScene.updateModifiers(false); Promise.all(loadPokemonAssets).then(() => resolve(true)); }; if (sessionData) { initSessionFromData(sessionData); } else { this.getSession(slotId) .then(data => data && initSessionFromData(data)) .catch(err => { reject(err); return; }); } } catch (err) { reject(err); return; } }); } /** * Delete the session data at the given slot when overwriting a save file * For deleting the session of a finished run, use {@linkcode tryClearSession} * @param slotId the slot to clear * @returns Promise with result `true` if the session was deleted successfully, `false` otherwise */ deleteSession(slotId: number): Promise { return new Promise(resolve => { if (bypassLogin) { localStorage.removeItem(`sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`); return resolve(true); } updateUserInfo().then(success => { if (success !== null && !success) { return resolve(false); } pokerogueApi.savedata.session.delete({ slot: slotId, clientSessionId }).then(error => { if (error) { if (error.startsWith("session out of date")) { globalScene.clearPhaseQueue(); globalScene.unshiftPhase(new ReloadSessionPhase()); } console.error(error); resolve(false); } else { if (loggedInUser) { loggedInUser.lastSessionSlot = -1; } localStorage.removeItem(`sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`); resolve(true); } }); }); }); } /* Defines a localStorage item 'daily' to check on clears, offline implementation of savedata/newclear API If a GameModes clear other than Daily is checked, newClear = true as usual If a Daily mode is cleared, checks if it was already cleared before, based on seed, and returns true only to new daily clear runs */ offlineNewClear(): Promise { return new Promise(resolve => { const sessionData = this.getSessionSaveData(); const seed = sessionData.seed; let daily: string[] = []; if (sessionData.gameMode === GameModes.DAILY) { if (localStorage.hasOwnProperty("daily")) { daily = JSON.parse(atob(localStorage.getItem("daily")!)); // TODO: is this bang correct? if (daily.includes(seed)) { return resolve(false); } else { daily.push(seed); localStorage.setItem("daily", btoa(JSON.stringify(daily))); return resolve(true); } } else { daily.push(seed); localStorage.setItem("daily", btoa(JSON.stringify(daily))); return resolve(true); } } else { return resolve(true); } }); } /** * Attempt to clear session data after the end of a run * After session data is removed, attempt to update user info so the menu updates * To delete an unfinished run instead, use {@linkcode deleteSession} */ async tryClearSession(slotId: number): Promise<[success: boolean, newClear: boolean]> { let result: [boolean, boolean] = [ false, false ]; if (bypassLogin) { localStorage.removeItem(`sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`); result = [ true, true ]; } else { const sessionData = this.getSessionSaveData(); const { trainerId } = this; const jsonResponse = await pokerogueApi.savedata.session.clear({ slot: slotId, trainerId, clientSessionId }, sessionData); if (!jsonResponse?.error) { result = [ true, jsonResponse?.success ?? false ]; if (loggedInUser) { loggedInUser!.lastSessionSlot = -1; } localStorage.removeItem(`sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`); } else { if (jsonResponse && jsonResponse.error?.startsWith("session out of date")) { globalScene.clearPhaseQueue(); globalScene.unshiftPhase(new ReloadSessionPhase()); } console.error(jsonResponse); result = [ false, false ]; } } await updateUserInfo(); return result; } parseSessionData(dataStr: string): SessionSaveData { const sessionData = JSON.parse(dataStr, (k: string, v: any) => { if (k === "party" || k === "enemyParty") { const ret: PokemonData[] = []; if (v === null) { v = []; } for (const pd of v) { ret.push(new PokemonData(pd)); } return ret; } if (k === "trainer") { return v ? new TrainerData(v) : null; } if (k === "modifiers" || k === "enemyModifiers") { const player = k === "modifiers"; const ret: PersistentModifierData[] = []; if (v === null) { v = []; } for (const md of v) { if (md?.className === "ExpBalanceModifier") { // Temporarily limit EXP Balance until it gets reworked md.stackCount = Math.min(md.stackCount, 4); } if (md instanceof Modifier.EnemyAttackStatusEffectChanceModifier && md.effect === StatusEffect.FREEZE || md.effect === StatusEffect.SLEEP) { continue; } ret.push(new PersistentModifierData(md, player)); } return ret; } if (k === "arena") { return new ArenaData(v); } if (k === "challenges") { const ret: ChallengeData[] = []; if (v === null) { v = []; } for (const c of v) { ret.push(new ChallengeData(c)); } return ret; } if (k === "mysteryEncounterType") { return v as MysteryEncounterType; } if (k === "mysteryEncounterSaveData") { return new MysteryEncounterSaveData(v); } return v; }) as SessionSaveData; applySessionVersionMigration(sessionData); return sessionData; } saveAll(skipVerification: boolean = false, sync: boolean = false, useCachedSession: boolean = false, useCachedSystem: boolean = false): Promise { return new Promise(resolve => { Utils.executeIf(!skipVerification, updateUserInfo).then(success => { if (success !== null && !success) { return resolve(false); } if (sync) { globalScene.ui.savingIcon.show(); } const sessionData = useCachedSession ? this.parseSessionData(decrypt(localStorage.getItem(`sessionData${globalScene.sessionSlotId ? globalScene.sessionSlotId : ""}_${loggedInUser?.username}`)!, bypassLogin)) // TODO: is this bang correct? : this.getSessionSaveData(); const maxIntAttrValue = 0x80000000; const systemData = useCachedSystem ? this.parseSystemData(decrypt(localStorage.getItem(`data_${loggedInUser?.username}`)!, bypassLogin)) : this.getSystemSaveData(); // TODO: is this bang correct? const request = { system: systemData, session: sessionData, sessionSlotId: globalScene.sessionSlotId, clientSessionId: clientSessionId }; localStorage.setItem(`data_${loggedInUser?.username}`, encrypt(JSON.stringify(systemData, (k: any, v: any) => typeof v === "bigint" ? v <= maxIntAttrValue ? Number(v) : v.toString() : v), bypassLogin)); localStorage.setItem(`sessionData${globalScene.sessionSlotId ? globalScene.sessionSlotId : ""}_${loggedInUser?.username}`, encrypt(JSON.stringify(sessionData), bypassLogin)); console.debug("Session data saved"); if (!bypassLogin && sync) { pokerogueApi.savedata.updateAll(request) .then(error => { if (sync) { globalScene.lastSavePlayTime = 0; globalScene.ui.savingIcon.hide(); } if (error) { if (error.startsWith("session out of date")) { globalScene.clearPhaseQueue(); globalScene.unshiftPhase(new ReloadSessionPhase()); } console.error(error); return resolve(false); } resolve(true); }); } else { this.verify().then(success => { globalScene.ui.savingIcon.hide(); resolve(success); }); } }); }); } public tryExportData(dataType: GameDataType, slotId: number = 0): Promise { return new Promise(resolve => { const dataKey: string = `${getDataTypeKey(dataType, slotId)}_${loggedInUser?.username}`; const handleData = (dataStr: string) => { switch (dataType) { case GameDataType.SYSTEM: dataStr = this.convertSystemDataStr(dataStr, true); break; } const encryptedData = AES.encrypt(dataStr, saveKey); const blob = new Blob([ encryptedData.toString() ], { type: "text/json" }); const link = document.createElement("a"); link.href = window.URL.createObjectURL(blob); link.download = `${dataKey}.prsv`; link.click(); link.remove(); }; if (!bypassLogin && dataType < GameDataType.SETTINGS) { let promise: Promise = Promise.resolve(null); if (dataType === GameDataType.SYSTEM) { promise = pokerogueApi.savedata.system.get({ clientSessionId }); } else if (dataType === GameDataType.SESSION) { promise = pokerogueApi.savedata.session.get({ slot: slotId, clientSessionId }); } promise.then(response => { if (!response?.length || response[0] !== "{") { console.error(response); resolve(false); return; } handleData(response); resolve(true); }); } else { const data = localStorage.getItem(dataKey); if (data) { handleData(decrypt(data, bypassLogin)); } resolve(!!data); } }); } public importData(dataType: GameDataType, slotId: number = 0): void { const dataKey = `${getDataTypeKey(dataType, slotId)}_${loggedInUser?.username}`; let saveFile: any = document.getElementById("saveFile"); if (saveFile) { saveFile.remove(); } saveFile = document.createElement("input"); saveFile.id = "saveFile"; saveFile.type = "file"; saveFile.accept = ".prsv"; saveFile.style.display = "none"; saveFile.addEventListener("change", e => { const reader = new FileReader(); reader.onload = (_ => { return e => { let dataName: string; let dataStr = AES.decrypt(e.target?.result?.toString()!, saveKey).toString(enc.Utf8); // TODO: is this bang correct? let valid = false; try { dataName = GameDataType[dataType].toLowerCase(); switch (dataType) { case GameDataType.SYSTEM: dataStr = this.convertSystemDataStr(dataStr); const systemData = this.parseSystemData(dataStr); valid = !!systemData.dexData && !!systemData.timestamp; break; case GameDataType.SESSION: const sessionData = this.parseSessionData(dataStr); valid = !!sessionData.party && !!sessionData.enemyParty && !!sessionData.timestamp; break; case GameDataType.RUN_HISTORY: const data = JSON.parse(dataStr); const keys = Object.keys(data); dataName = i18next.t("menuUiHandler:RUN_HISTORY").toLowerCase(); keys.forEach((key) => { const entryKeys = Object.keys(data[key]); valid = [ "isFavorite", "isVictory", "entry" ].every(v => entryKeys.includes(v)) && entryKeys.length === 3; }); break; case GameDataType.SETTINGS: case GameDataType.TUTORIALS: valid = true; break; } } catch (ex) { console.error(ex); } const displayError = (error: string) => globalScene.ui.showText(error, null, () => globalScene.ui.showText("", 0), Utils.fixedInt(1500)); dataName = dataName!; // tell TS compiler that dataName is defined! if (!valid) { return globalScene.ui.showText(`Your ${dataName} data could not be loaded. It may be corrupted.`, null, () => globalScene.ui.showText("", 0), Utils.fixedInt(1500)); } globalScene.ui.showText(`Your ${dataName} data will be overridden and the page will reload. Proceed?`, null, () => { globalScene.ui.setOverlayMode(Mode.CONFIRM, () => { localStorage.setItem(dataKey, encrypt(dataStr, bypassLogin)); if (!bypassLogin && dataType < GameDataType.SETTINGS) { updateUserInfo().then(success => { if (!success[0]) { return displayError(`Could not contact the server. Your ${dataName} data could not be imported.`); } const { trainerId, secretId } = this; let updatePromise: Promise; if (dataType === GameDataType.SESSION) { updatePromise = pokerogueApi.savedata.session.update({ slot: slotId, trainerId, secretId, clientSessionId }, dataStr); } else { updatePromise = pokerogueApi.savedata.system.update({ trainerId, secretId, clientSessionId }, dataStr); } updatePromise .then(error => { if (error) { console.error(error); return displayError(`An error occurred while updating ${dataName} data. Please contact the administrator.`); } window.location = window.location; }); }); } else { window.location = window.location; } }, () => { globalScene.ui.revertMode(); globalScene.ui.showText("", 0); }, false, -98); }); }; })((e.target as any).files[0]); reader.readAsText((e.target as any).files[0]); } ); saveFile.click(); } private initDexData(): void { const data: DexData = {}; for (const species of allSpecies) { data[species.speciesId] = { seenAttr: 0n, caughtAttr: 0n, natureAttr: 0, seenCount: 0, caughtCount: 0, hatchedCount: 0, ivs: [ 0, 0, 0, 0, 0, 0 ] }; } const defaultStarterAttr = DexAttr.NON_SHINY | DexAttr.MALE | DexAttr.FEMALE | DexAttr.DEFAULT_VARIANT | DexAttr.DEFAULT_FORM; const defaultStarterNatures: Nature[] = []; globalScene.executeWithSeedOffset(() => { const neutralNatures = [ Nature.HARDY, Nature.DOCILE, Nature.SERIOUS, Nature.BASHFUL, Nature.QUIRKY ]; for (let s = 0; s < defaultStarterSpecies.length; s++) { defaultStarterNatures.push(Utils.randSeedItem(neutralNatures)); } }, 0, "default"); for (let ds = 0; ds < defaultStarterSpecies.length; ds++) { const entry = data[defaultStarterSpecies[ds]] as DexEntry; entry.seenAttr = defaultStarterAttr; entry.caughtAttr = defaultStarterAttr; entry.natureAttr = 1 << (defaultStarterNatures[ds] + 1); for (const i in entry.ivs) { entry.ivs[i] = 15; } } this.defaultDexData = Object.assign({}, data); this.dexData = data; } private initStarterData(): void { const starterData: StarterData = {}; const starterSpeciesIds = Object.keys(speciesStarterCosts).map(k => parseInt(k) as Species); for (const speciesId of starterSpeciesIds) { starterData[speciesId] = { moveset: null, eggMoves: 0, candyCount: 0, friendship: 0, abilityAttr: defaultStarterSpecies.includes(speciesId) ? AbilityAttr.ABILITY_1 : 0, passiveAttr: 0, valueReduction: 0, classicWinCount: 0 }; } this.starterData = starterData; } setPokemonSeen(pokemon: Pokemon, incrementCount: boolean = true, trainer: boolean = false): void { // Some Mystery Encounters block updates to these stats if (globalScene.currentBattle?.isBattleMysteryEncounter() && globalScene.currentBattle.mysteryEncounter?.preventGameStatsUpdates) { return; } const dexEntry = this.dexData[pokemon.species.speciesId]; dexEntry.seenAttr |= pokemon.getDexAttr(); if (incrementCount) { dexEntry.seenCount++; this.gameStats.pokemonSeen++; if (!trainer && pokemon.species.subLegendary) { this.gameStats.subLegendaryPokemonSeen++; } else if (!trainer && pokemon.species.legendary) { this.gameStats.legendaryPokemonSeen++; } else if (!trainer && pokemon.species.mythical) { this.gameStats.mythicalPokemonSeen++; } if (!trainer && pokemon.isShiny()) { this.gameStats.shinyPokemonSeen++; } } } /** * * @param pokemon * @param incrementCount * @param fromEgg * @param showMessage * @returns `true` if Pokemon catch unlocked a new starter, `false` if Pokemon catch did not unlock a starter */ setPokemonCaught(pokemon: Pokemon, incrementCount: boolean = true, fromEgg: boolean = false, showMessage: boolean = true): Promise { // If incrementCount === false (not a catch scenario), only update the pokemon's dex data if the Pokemon has already been marked as caught in dex // Prevents form changes, nature changes, etc. from unintentionally updating the dex data of a "rental" pokemon const speciesRootForm = pokemon.species.getRootSpeciesId(); if (!incrementCount && !globalScene.gameData.dexData[speciesRootForm].caughtAttr) { return Promise.resolve(false); } else { return this.setPokemonSpeciesCaught(pokemon, pokemon.species, incrementCount, fromEgg, showMessage); } } /** * * @param pokemon * @param species * @param incrementCount * @param fromEgg * @param showMessage * @returns `true` if Pokemon catch unlocked a new starter, `false` if Pokemon catch did not unlock a starter */ setPokemonSpeciesCaught(pokemon: Pokemon, species: PokemonSpecies, incrementCount: boolean = true, fromEgg: boolean = false, showMessage: boolean = true): Promise { return new Promise(resolve => { const dexEntry = this.dexData[species.speciesId]; const caughtAttr = dexEntry.caughtAttr; const formIndex = pokemon.formIndex; const dexAttr = pokemon.getDexAttr(); pokemon.formIndex = formIndex; // Mark as caught dexEntry.caughtAttr |= dexAttr; // Unlock ability if (speciesStarterCosts.hasOwnProperty(species.speciesId)) { this.starterData[species.speciesId].abilityAttr |= pokemon.abilityIndex !== 1 || pokemon.species.ability2 ? 1 << pokemon.abilityIndex : AbilityAttr.ABILITY_HIDDEN; } // Unlock nature dexEntry.natureAttr |= 1 << (pokemon.nature + 1); const hasPrevolution = pokemonPrevolutions.hasOwnProperty(species.speciesId); const newCatch = !caughtAttr; const hasNewAttr = (caughtAttr & dexAttr) !== dexAttr; if (incrementCount) { if (!fromEgg) { dexEntry.caughtCount++; this.gameStats.pokemonCaught++; if (pokemon.species.subLegendary) { this.gameStats.subLegendaryPokemonCaught++; } else if (pokemon.species.legendary) { this.gameStats.legendaryPokemonCaught++; } else if (pokemon.species.mythical) { this.gameStats.mythicalPokemonCaught++; } if (pokemon.isShiny()) { this.gameStats.shinyPokemonCaught++; } } else { dexEntry.hatchedCount++; this.gameStats.pokemonHatched++; if (pokemon.species.subLegendary) { this.gameStats.subLegendaryPokemonHatched++; } else if (pokemon.species.legendary) { this.gameStats.legendaryPokemonHatched++; } else if (pokemon.species.mythical) { this.gameStats.mythicalPokemonHatched++; } if (pokemon.isShiny()) { this.gameStats.shinyPokemonHatched++; } } if (!hasPrevolution && (!globalScene.gameMode.isDaily || hasNewAttr || fromEgg)) { this.addStarterCandy(species, (1 * (pokemon.isShiny() ? 5 * (1 << (pokemon.variant ?? 0)) : 1)) * (fromEgg || pokemon.isBoss() ? 2 : 1)); } } const checkPrevolution = (newStarter: boolean) => { if (hasPrevolution) { const prevolutionSpecies = pokemonPrevolutions[species.speciesId]; this.setPokemonSpeciesCaught(pokemon, getPokemonSpecies(prevolutionSpecies), incrementCount, fromEgg, showMessage).then(result => resolve(result)); } else { resolve(newStarter); } }; if (newCatch && speciesStarterCosts.hasOwnProperty(species.speciesId)) { if (!showMessage) { resolve(true); return; } globalScene.playSound("level_up_fanfare"); globalScene.ui.showText(i18next.t("battle:addedAsAStarter", { pokemonName: species.name }), null, () => checkPrevolution(true), null, true); } else { checkPrevolution(false); } }); } incrementRibbonCount(species: PokemonSpecies, forStarter: boolean = false): number { const speciesIdToIncrement: Species = species.getRootSpeciesId(forStarter); if (!this.starterData[speciesIdToIncrement].classicWinCount) { this.starterData[speciesIdToIncrement].classicWinCount = 0; } if (!this.starterData[speciesIdToIncrement].classicWinCount) { globalScene.gameData.gameStats.ribbonsOwned++; } const ribbonsInStats: number = globalScene.gameData.gameStats.ribbonsOwned; if (ribbonsInStats >= 100) { globalScene.validateAchv(achvs._100_RIBBONS); } if (ribbonsInStats >= 75) { globalScene.validateAchv(achvs._75_RIBBONS); } if (ribbonsInStats >= 50) { globalScene.validateAchv(achvs._50_RIBBONS); } if (ribbonsInStats >= 25) { globalScene.validateAchv(achvs._25_RIBBONS); } if (ribbonsInStats >= 10) { globalScene.validateAchv(achvs._10_RIBBONS); } return ++this.starterData[speciesIdToIncrement].classicWinCount; } /** * Adds a candy to the player's game data for a given {@linkcode PokemonSpecies}. * Will do nothing if the player does not have the Pokemon owned in their system save data. * @param species * @param count */ addStarterCandy(species: PokemonSpecies, count: number): void { // Only gain candies if the Pokemon has already been marked as caught in dex (ignore "rental" pokemon) const speciesRootForm = species.getRootSpeciesId(); if (globalScene.gameData.dexData[speciesRootForm].caughtAttr) { globalScene.candyBar.showStarterSpeciesCandy(species.speciesId, count); this.starterData[species.speciesId].candyCount += count; } } /** * * @param species * @param eggMoveIndex * @param showMessage Default true. If true, will display message for unlocked egg move * @param prependSpeciesToMessage Default false. If true, will change message from "X Egg Move Unlocked!" to "Bulbasaur X Egg Move Unlocked!" */ setEggMoveUnlocked(species: PokemonSpecies, eggMoveIndex: number, showMessage: boolean = true, prependSpeciesToMessage: boolean = false): Promise { return new Promise(resolve => { const speciesId = species.speciesId; if (!speciesEggMoves.hasOwnProperty(speciesId) || !speciesEggMoves[speciesId][eggMoveIndex]) { resolve(false); return; } if (!this.starterData[speciesId].eggMoves) { this.starterData[speciesId].eggMoves = 0; } const value = 1 << eggMoveIndex; if (this.starterData[speciesId].eggMoves & value) { resolve(false); return; } this.starterData[speciesId].eggMoves |= value; if (!showMessage) { resolve(true); return; } globalScene.playSound("level_up_fanfare"); const moveName = allMoves[speciesEggMoves[speciesId][eggMoveIndex]].name; let message = prependSpeciesToMessage ? species.getName() + " " : ""; message += eggMoveIndex === 3 ? i18next.t("egg:rareEggMoveUnlock", { moveName: moveName }) : i18next.t("egg:eggMoveUnlock", { moveName: moveName }); globalScene.ui.showText(message, null, () => resolve(true), null, true); }); } /** * Checks whether the root species of a given {@PokemonSpecies} has been unlocked in the dex */ isRootSpeciesUnlocked(species: PokemonSpecies): boolean { return !!this.dexData[species.getRootSpeciesId()]?.caughtAttr; } /** * Unlocks the given {@linkcode Nature} for a {@linkcode PokemonSpecies} and its prevolutions. * Will fail silently if root species has not been unlocked */ unlockSpeciesNature(species: PokemonSpecies, nature: Nature): void { if (!this.isRootSpeciesUnlocked(species)) { return; } //recursively unlock nature for species and prevolutions const _unlockSpeciesNature = (speciesId: Species) => { this.dexData[speciesId].natureAttr |= 1 << (nature + 1); if (pokemonPrevolutions.hasOwnProperty(speciesId)) { _unlockSpeciesNature(pokemonPrevolutions[speciesId]); } }; _unlockSpeciesNature(species.speciesId); } updateSpeciesDexIvs(speciesId: Species, ivs: number[]): void { let dexEntry: DexEntry; do { dexEntry = globalScene.gameData.dexData[speciesId]; const dexIvs = dexEntry.ivs; for (let i = 0; i < dexIvs.length; i++) { if (dexIvs[i] < ivs[i]) { dexIvs[i] = ivs[i]; } } if (dexIvs.filter(iv => iv === 31).length === 6) { globalScene.validateAchv(achvs.PERFECT_IVS); } } while (pokemonPrevolutions.hasOwnProperty(speciesId) && (speciesId = pokemonPrevolutions[speciesId])); } getSpeciesCount(dexEntryPredicate: (entry: DexEntry) => boolean): number { const dexKeys = Object.keys(this.dexData); let speciesCount = 0; for (const s of dexKeys) { if (dexEntryPredicate(this.dexData[s])) { speciesCount++; } } return speciesCount; } getStarterCount(dexEntryPredicate: (entry: DexEntry) => boolean): number { const starterKeys = Object.keys(speciesStarterCosts); let starterCount = 0; for (const s of starterKeys) { const starterDexEntry = this.dexData[s]; if (dexEntryPredicate(starterDexEntry)) { starterCount++; } } return starterCount; } getSpeciesDefaultDexAttr(species: PokemonSpecies, _forSeen: boolean = false, optimistic: boolean = false): bigint { let ret = 0n; const dexEntry = this.dexData[species.speciesId]; const attr = dexEntry.caughtAttr; if (optimistic) { if (attr & DexAttr.SHINY) { ret |= DexAttr.SHINY; if (attr & DexAttr.VARIANT_3) { ret |= DexAttr.VARIANT_3; } else if (attr & DexAttr.VARIANT_2) { ret |= DexAttr.VARIANT_2; } else { ret |= DexAttr.DEFAULT_VARIANT; } } else { ret |= DexAttr.NON_SHINY; ret |= DexAttr.DEFAULT_VARIANT; } } else { // Default to non shiny. Fallback to shiny if it's the only thing that's unlocked ret |= (attr & DexAttr.NON_SHINY || !(attr & DexAttr.SHINY)) ? DexAttr.NON_SHINY : DexAttr.SHINY; if (attr & DexAttr.DEFAULT_VARIANT) { ret |= DexAttr.DEFAULT_VARIANT; } else if (attr & DexAttr.VARIANT_2) { ret |= DexAttr.VARIANT_2; } else if (attr & DexAttr.VARIANT_3) { ret |= DexAttr.VARIANT_3; } else { ret |= DexAttr.DEFAULT_VARIANT; } } ret |= attr & DexAttr.MALE || !(attr & DexAttr.FEMALE) ? DexAttr.MALE : DexAttr.FEMALE; ret |= this.getFormAttr(this.getFormIndex(attr)); return ret; } getSpeciesDexAttrProps(species: PokemonSpecies, dexAttr: bigint): DexAttrProps { const shiny = !(dexAttr & DexAttr.NON_SHINY); const female = !(dexAttr & DexAttr.MALE); let variant: Variant = 0; if (dexAttr & DexAttr.DEFAULT_VARIANT) { variant = 0; } else if (dexAttr & DexAttr.VARIANT_2) { variant = 1; } else if (dexAttr & DexAttr.VARIANT_3) { variant = 2; } const formIndex = this.getFormIndex(dexAttr); return { shiny, female, variant, formIndex }; } getStarterSpeciesDefaultAbilityIndex(species: PokemonSpecies): number { const abilityAttr = this.starterData[species.speciesId].abilityAttr; return abilityAttr & AbilityAttr.ABILITY_1 ? 0 : !species.ability2 || abilityAttr & AbilityAttr.ABILITY_2 ? 1 : 2; } getSpeciesDefaultNature(species: PokemonSpecies): Nature { const dexEntry = this.dexData[species.speciesId]; for (let n = 0; n < 25; n++) { if (dexEntry.natureAttr & (1 << (n + 1))) { return n as Nature; } } return 0 as Nature; } getSpeciesDefaultNatureAttr(species: PokemonSpecies): number { return 1 << (this.getSpeciesDefaultNature(species)); } getDexAttrLuck(dexAttr: bigint): number { return dexAttr & DexAttr.SHINY ? dexAttr & DexAttr.VARIANT_3 ? 3 : dexAttr & DexAttr.VARIANT_2 ? 2 : 1 : 0; } getNaturesForAttr(natureAttr: number = 0): Nature[] { const ret: Nature[] = []; for (let n = 0; n < 25; n++) { if (natureAttr & (1 << (n + 1))) { ret.push(n); } } return ret; } getSpeciesStarterValue(speciesId: Species): number { const baseValue = speciesStarterCosts[speciesId]; let value = baseValue; const decrementValue = (value: number) => { if (value > 1) { value--; } else { value /= 2; } return value; }; for (let v = 0; v < this.starterData[speciesId].valueReduction; v++) { value = decrementValue(value); } const cost = new Utils.NumberHolder(value); applyChallenges(globalScene.gameMode, ChallengeType.STARTER_COST, speciesId, cost); return cost.value; } getFormIndex(attr: bigint): number { if (!attr || attr < DexAttr.DEFAULT_FORM) { return 0; } let f = 0; while (!(attr & this.getFormAttr(f))) { f++; } return f; } getFormAttr(formIndex: number): bigint { return BigInt(1) << BigInt(7 + formIndex); } consolidateDexData(dexData: DexData): void { for (const k of Object.keys(dexData)) { const entry = dexData[k] as DexEntry; if (!entry.hasOwnProperty("hatchedCount")) { entry.hatchedCount = 0; } if (!entry.hasOwnProperty("natureAttr") || (entry.caughtAttr && !entry.natureAttr)) { entry.natureAttr = this.defaultDexData?.[k].natureAttr || (1 << Utils.randInt(25, 1)); } } } migrateStarterAbilities(systemData: SystemSaveData, initialStarterData?: StarterData): void { const starterIds = Object.keys(this.starterData).map(s => parseInt(s) as Species); const starterData = initialStarterData || systemData.starterData; const dexData = systemData.dexData; for (const s of starterIds) { const dexAttr = dexData[s].caughtAttr; starterData[s].abilityAttr = (dexAttr & DexAttr.DEFAULT_VARIANT ? AbilityAttr.ABILITY_1 : 0) | (dexAttr & DexAttr.VARIANT_2 ? AbilityAttr.ABILITY_2 : 0) | (dexAttr & DexAttr.VARIANT_3 ? AbilityAttr.ABILITY_HIDDEN : 0); if (dexAttr) { if (!(dexAttr & DexAttr.DEFAULT_VARIANT)) { dexData[s].caughtAttr ^= DexAttr.DEFAULT_VARIANT; } if (dexAttr & DexAttr.VARIANT_2) { dexData[s].caughtAttr ^= DexAttr.VARIANT_2; } if (dexAttr & DexAttr.VARIANT_3) { dexData[s].caughtAttr ^= DexAttr.VARIANT_3; } } } } }