diff --git a/.env.development b/.env.development index e4e5053016f..d2f5934cee7 100644 --- a/.env.development +++ b/.env.development @@ -1,4 +1,4 @@ -VITE_BYPASS_LOGIN=1 +VITE_BYPASS_LOGIN=0 VITE_BYPASS_TUTORIAL=0 VITE_SERVER_URL=http://localhost:8001 VITE_DISCORD_CLIENT_ID=1234567890 diff --git a/src/account.ts b/src/account.ts index c6d2f85489a..38895366e98 100644 --- a/src/account.ts +++ b/src/account.ts @@ -1,9 +1,10 @@ import { bypassLogin } from "./battle-scene"; +import { api } from "./plugins/api/api"; import * as Utils from "./utils"; export interface UserInfo { username: string; - lastSessionSlot: integer; + lastSessionSlot: number; discordId: string; googleId: string; hasAdminRole: boolean; @@ -43,15 +44,14 @@ export function updateUserInfo(): Promise<[boolean, integer]> { }); return resolve([ true, 200 ]); } - Utils.apiFetch("account/info", true).then(response => { - if (!response.ok) { - resolve([ false, response.status ]); + api.getAccountInfo().then((accountInfoOrStatus) => { + if (typeof accountInfoOrStatus === "number") { + resolve([ false, accountInfoOrStatus ]); return; + } else { + loggedInUser = accountInfoOrStatus; + resolve([ true, 200 ]); } - return response.json(); - }).then(jsonResponse => { - loggedInUser = jsonResponse; - resolve([ true, 200 ]); }).catch(err => { console.error(err); resolve([ false, 500 ]); diff --git a/src/constants.ts b/src/constants.ts index 0b1261ad814..5779813dffc 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -3,3 +3,6 @@ export const PLAYER_PARTY_MAX_SIZE: number = 6; /** Whether to use seasonal splash messages in general */ export const USE_SEASONAL_SPLASH_MESSAGES: boolean = false; + +/** Name of the session ID cookie */ +export const SESSION_ID_COOKIE_NAME: string = "pokerogue_sessionId"; diff --git a/src/data/daily-run.ts b/src/data/daily-run.ts index 0decab63f4f..7d049f6d51c 100644 --- a/src/data/daily-run.ts +++ b/src/data/daily-run.ts @@ -6,6 +6,7 @@ import { Starter } from "#app/ui/starter-select-ui-handler"; import * as Utils from "#app/utils"; import PokemonSpecies, { PokemonSpeciesForm, getPokemonSpecies, getPokemonSpeciesForm } from "#app/data/pokemon-species"; import { speciesStarterCosts } from "#app/data/balance/starters"; +import { api } from "#app/plugins/api/api"; export interface DailyRunConfig { seed: integer; @@ -14,14 +15,9 @@ export interface DailyRunConfig { export function fetchDailyRunSeed(): Promise { return new Promise((resolve, reject) => { - Utils.apiFetch("daily/seed").then(response => { - if (!response.ok) { - resolve(null); - return; - } - return response.text(); - }).then(seed => resolve(seed ?? null)) - .catch(err => reject(err)); + api.getDailySeed().then(dailySeed => { + resolve(dailySeed); + }).catch(err => reject(err)); // TODO: does this ever reject with the api class? }); } diff --git a/src/overrides.ts b/src/overrides.ts index 852961db8d7..095e2156377 100644 --- a/src/overrides.ts +++ b/src/overrides.ts @@ -31,7 +31,12 @@ import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; * } * ``` */ -const overrides = {} satisfies Partial>; +const overrides = { + STARTING_WAVE_OVERRIDE: 199, + STARTING_BIOME_OVERRIDE: Biome.END, + OPP_LEVEL_OVERRIDE: 1, + STARTING_LEVEL_OVERRIDE: 999, +} satisfies Partial>; /** * If you need to add Overrides values for local testing do that inside {@linkcode overrides} diff --git a/src/phases/game-over-phase.ts b/src/phases/game-over-phase.ts index 9c444fc40f0..0d6df7443d4 100644 --- a/src/phases/game-over-phase.ts +++ b/src/phases/game-over-phase.ts @@ -23,6 +23,7 @@ import * as Utils from "#app/utils"; import { PlayerGender } from "#enums/player-gender"; import { TrainerType } from "#enums/trainer-type"; import i18next from "i18next"; +import { api } from "#app/plugins/api/api"; export class GameOverPhase extends BattlePhase { private victory: boolean; @@ -176,10 +177,9 @@ export class GameOverPhase extends BattlePhase { If Online, execute apiFetch as intended If Offline, execute offlineNewClear(), a localStorage implementation of newClear daily run checks */ if (this.victory) { - if (!Utils.isLocal) { - Utils.apiFetch(`savedata/session/newclear?slot=${this.scene.sessionSlotId}&clientSessionId=${clientSessionId}`, true) - .then(response => response.json()) - .then(newClear => doGameOver(newClear)); + if (!Utils.isLocal || Utils.isLocalServerConnected) { + api.newclearSession(this.scene.sessionSlotId, clientSessionId) + .then((isNewClear) => doGameOver(!!isNewClear)); } else { this.scene.gameData.offlineNewClear(this.scene).then(result => { doGameOver(result); diff --git a/src/phases/title-phase.ts b/src/phases/title-phase.ts index b23a5ec0c89..4777a70124d 100644 --- a/src/phases/title-phase.ts +++ b/src/phases/title-phase.ts @@ -243,7 +243,7 @@ export class TitlePhase extends Phase { }; // If Online, calls seed fetch from db to generate daily run. If Offline, generates a daily run based on current date. - if (!Utils.isLocal) { + if (!Utils.isLocal || Utils.isLocalServerConnected) { fetchDailyRunSeed().then(seed => { if (seed) { generateDaily(seed); diff --git a/src/plugins/api/api.ts b/src/plugins/api/api.ts new file mode 100644 index 00000000000..ed7a516b522 --- /dev/null +++ b/src/plugins/api/api.ts @@ -0,0 +1,293 @@ +import { loggedInUser } from "#app/account"; +import { SESSION_ID_COOKIE_NAME } from "#app/constants"; +import { getCookie, removeCookie, setCookie } from "#app/utils"; +import type { AccountInfoResponse } from "./models/AccountInfo"; +import type { AccountLoginRequest, AccountLoginResponse } from "./models/AccountLogin"; +import type { TitleStatsResponse } from "./models/TitleStats"; +import type { VerifySavedataResponse } from "./models/VerifySavedata"; + +type DataType = "json" | "form-urlencoded"; + +export class Api { + //#region Fields + + private readonly base: string; + + //#region Public + + constructor(base: string) { + this.base = base; + } + + /** + * Request game title-stats. + */ + public async getGameTitleStats() { + try { + const response = await this.doGet("/game/titlestats"); + return (await response.json()) as TitleStatsResponse; + } catch (err) { + console.warn("Could not get game title stats!", err); + return null; + } + } + + /** + * Request the {@linkcode AccountInfoResponse | UserInfo} of the logged in user. + * The user is identified by the {@linkcode SESSION_ID_COOKIE_NAME | session cookie}. + */ + public async getAccountInfo() { + try { + const response = await this.doGet("/account/info"); + + if (response.ok) { + return (await response.json()) as AccountInfoResponse; + } else { + console.warn("Could not get account info!", response.status, response.statusText); + return response.status; + } + } catch (err) { + console.warn("Could not get account info!", err); + return 500; + } + } + + /** + * Send a login request. + * Sets the session cookie on success. + * @param username The account username. + * @param password The account password. + */ + public async login(username: string, password: string) { + try { + const response = await this.doPost( + "/account/login", + { + username, + password, + }, + "form-urlencoded" + ); + + if (response.ok) { + const loginResponse = (await response.json()) as AccountLoginResponse; + setCookie(SESSION_ID_COOKIE_NAME, loginResponse.token); + return true; + } + } catch (err) { + console.warn("Could not login!", err); + } + + return false; + } + + /** + * Send a logout request. + * **Always** (no matter if failed or not) removes the session cookie. + */ + public async logout() { + try { + const response = await this.doGet("/account/logout"); + + if (!response.ok) { + throw new Error(`${response.status}: ${response.statusText}`); + } + } catch (err) { + console.error("Log out failed!", err); + } + + removeCookie(SESSION_ID_COOKIE_NAME); // we are always clearing the cookie. + } + + /** + * Request the daily-run seed. + * @returns The active daily-run seed as `string`. + */ + public async getDailySeed() { + try { + const response = await this.doGet("/daily/seed"); + return response.text(); + } catch (err) { + console.warn("Could not get daily-run seed!", err); + return null; + } + } + + /** + * Mark a save-session as cleared. + * @param slot The save-session slot to clear. + * @param sessionId The save-session ID to clear. + * @returns The raw savedata as `string`. + */ + public async newclearSession(slot: number, sessionId: string) { + try { + const params = new URLSearchParams(); + params.append("slot", String(slot)); + params.append("clientSessionId", sessionId); + + const response = await this.doGet(`/savedata/session/newclear?${params}`); + const json = await response.json(); + + return Boolean(json); + } catch (err) { + console.warn("Could not newclear session!", err); + return false; + } + } + + public async getSystemSavedata(sessionId: string) { + try { + const params = new URLSearchParams(); + params.append("clientSessionId", sessionId); + + const response = await this.doGet(`/savedata/system/get?${params}`); + const rawSavedata = await response.text(); + + return rawSavedata; + } catch (err) { + console.warn("Could not get system savedata!", err); + return null; + } + } + + /** + * Verify if the session is valid. + * If not the {@linkcode SystemSaveData} is returned. + * @param sessionId The savedata session ID + * @returns A {@linkcode SystemSaveData} if NOT valid, otherwise `null`. + */ + public async verifySystemSavedata(sessionId: string) { + try { + const params = new URLSearchParams(); + params.append("clientSessionId", sessionId); + const response = await this.doGet(`/savedata/system/verify?${params}`); + + if (response.ok) { + const verifySavedata = (await response.json()) as VerifySavedataResponse; + + if (!verifySavedata.valid) { + return verifySavedata.systemData; + } + } + } catch (err) { + console.warn("Could not verify system savedata!", err); + } + + return null; + } + + /** + * Get a session savedata. + * @param slotId The slot ID to load + * @param sessionId The session ID + * @returns The session as `string` + */ + public async getSessionSavedata(slotId: number, sessionId: string) { + try { + const params = new URLSearchParams(); + params.append("slot", String(slotId)); + params.append("clientSessionId", sessionId); + + const response = await this.doGet(`/savedata/session/get?${params}`); + + return await response.text(); + } catch (err) { + console.warn("Could not get session savedata!", err); + return null; + } + } + + /** + * Delete a session savedata slot. + * @param slotId The slot ID to load + * @param sessionId The session ID + * @returns The session as `string` + */ + public async deleteSessionSavedata(slotId: number, sessionId: string) { + try { + const params = new URLSearchParams(); + params.append("slot", String(slotId)); + params.append("clientSessionId", sessionId); + + const response = await this.doGet(`/savedata/session/delete?${params}`); + + if (response.ok) { + if (loggedInUser) { + loggedInUser.lastSessionSlot = -1; // TODO: is the bang correct? + } + + localStorage.removeItem(`sessionData${slotId > 0 ? slotId : ""}_${loggedInUser?.username}`); + } else { + return await response.text(); + } + + } catch (err) { + console.warn("Could not get session savedata!", err); + return "Unknown error"; + } + } + + //#region Private + + /** + * Send a GET request. + * @param path The path to send the request to. + */ + private async doGet(path: string) { + return this.doFetch(path, { method: "GET" }); + } + + /** + * Send a POST request. + * @param path THe path to send the request to. + * @param bodyData The body-data to send. + * @param dataType The data-type of the {@linkcode bodyData}. + */ + private async doPost>(path: string, bodyData: D, dataType: DataType = "json") { + let body: string = ""; + const headers: HeadersInit = {}; + + if (dataType === "json") { + body = JSON.stringify(bodyData); + headers["Content-Type"] = "application/json"; + } else if (dataType === "form-urlencoded") { + body = new URLSearchParams(Object.entries(bodyData).map(([k, v]) => [k, v.toString()])).toString(); + headers["Content-Type"] = "application/x-www-form-urlencoded"; + } else { + console.warn(`Unsupported data type: ${dataType}`); + body = String(bodyData); + headers["Content-Type"] = "text/plain"; + } + + return await this.doFetch(path, { method: "POST", body, headers }); + } + + /** + * A generic request helper. + * @param path The path to send the request to. + * @param config The request {@linkcode RequestInit | Configuration}. + */ + private async doFetch(path: string, config: RequestInit): Promise { + config.headers = { + ...config.headers, + Authorization: getCookie(SESSION_ID_COOKIE_NAME), + "Content-Type": config.headers?.["Content-Type"] ?? "application/json", + }; + + console.log(`Sending ${config.method ?? "GET"} request to: `, this.base + path, config); + + return await fetch(this.base + path, config); + } + + private async isLocalMode(): Promise { + return ( + ((window.location.hostname === "localhost" || + /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/.test(window.location.hostname)) && + window.location.port !== "") || + window.location.hostname === "" + ); + } + //#endregion +} + +export const api = new Api(import.meta.env.VITE_SERVER_URL ?? "http://localhost:80001"); diff --git a/src/plugins/api/models/AccountInfo.ts b/src/plugins/api/models/AccountInfo.ts new file mode 100644 index 00000000000..13e81eb1526 --- /dev/null +++ b/src/plugins/api/models/AccountInfo.ts @@ -0,0 +1,4 @@ +import type { UserInfo } from "#app/account"; +import type { BaseApiResponse } from "./BaseApiResponse"; + +export interface AccountInfoResponse extends BaseApiResponse, UserInfo {} diff --git a/src/plugins/api/models/AccountLogin.ts b/src/plugins/api/models/AccountLogin.ts new file mode 100644 index 00000000000..a3767898621 --- /dev/null +++ b/src/plugins/api/models/AccountLogin.ts @@ -0,0 +1,10 @@ +import type { BaseApiResponse } from "./BaseApiResponse"; + +export interface AccountLoginRequest { + username: string; + password: string; +} + +export interface AccountLoginResponse extends BaseApiResponse { + token: string; +} diff --git a/src/plugins/api/models/BaseApiResponse.ts b/src/plugins/api/models/BaseApiResponse.ts new file mode 100644 index 00000000000..4e023aea1f1 --- /dev/null +++ b/src/plugins/api/models/BaseApiResponse.ts @@ -0,0 +1,6 @@ +export interface BaseApiResponse { + error?: { + code: number; + message: string; + }; +} diff --git a/src/plugins/api/models/ClientSession.ts b/src/plugins/api/models/ClientSession.ts new file mode 100644 index 00000000000..dccc2bc639c --- /dev/null +++ b/src/plugins/api/models/ClientSession.ts @@ -0,0 +1,3 @@ +import type { GameData } from "#app/system/game-data"; + +export interface ClientSessionResponse extends GameData {} diff --git a/src/plugins/api/models/ErrorResponse.ts b/src/plugins/api/models/ErrorResponse.ts new file mode 100644 index 00000000000..c85a9e8716e --- /dev/null +++ b/src/plugins/api/models/ErrorResponse.ts @@ -0,0 +1,4 @@ +export interface ErrorResponse { + code: number; + message: string; +} diff --git a/src/plugins/api/models/TitleStats.ts b/src/plugins/api/models/TitleStats.ts new file mode 100644 index 00000000000..79755b23a54 --- /dev/null +++ b/src/plugins/api/models/TitleStats.ts @@ -0,0 +1,4 @@ +export interface TitleStatsResponse { + playerCount: number; + battleCount: number; +} diff --git a/src/plugins/api/models/VerifySavedata.ts b/src/plugins/api/models/VerifySavedata.ts new file mode 100644 index 00000000000..afbaad949f2 --- /dev/null +++ b/src/plugins/api/models/VerifySavedata.ts @@ -0,0 +1,6 @@ +import type { SystemSaveData } from "#app/system/game-data"; + +export interface VerifySavedataResponse { + valid: boolean; + systemData: SystemSaveData; +} diff --git a/src/system/game-data.ts b/src/system/game-data.ts index b597a1b9aad..667a76de25b 100644 --- a/src/system/game-data.ts +++ b/src/system/game-data.ts @@ -50,6 +50,7 @@ import { applySessionDataPatches, applySettingsDataPatches, applySystemDataPatch import { MysteryEncounterSaveData } from "#app/data/mystery-encounters/mystery-encounter-save-data"; import { MysteryEncounterType } from "#enums/mystery-encounter-type"; import { PokerogueApiClearSessionData } from "#app/@types/pokerogue-api"; +import { api } from "#app/plugins/api/api"; export const defaultStarterSpecies: Species[] = [ Species.BULBASAUR, Species.CHARMANDER, Species.SQUIRTLE, @@ -431,23 +432,22 @@ export class GameData { } if (!bypassLogin) { - Utils.apiFetch(`savedata/system/get?clientSessionId=${clientSessionId}`, true) - .then(response => response.text()) - .then(response => { - if (!response.length || response[0] !== "{") { - if (response.startsWith("sql: no rows in result set")) { + api.getSystemSavedata(clientSessionId) + .then(saveDataOrErr => { + if (!saveDataOrErr || saveDataOrErr.length === 0 || saveDataOrErr[0] !== "{") { + if (saveDataOrErr?.startsWith("sql: no rows in result set")) { this.scene.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 (response.indexOf("Too many connections") > -1) { + } else if (saveDataOrErr?.includes("Too many connections")) { this.scene.queueMessage("Too many people are trying to connect and the server is overloaded. Please try again later.", null, true); return resolve(false); } - console.error(response); + console.error(saveDataOrErr); return resolve(false); } const cachedSystem = localStorage.getItem(`data_${loggedInUser?.username}`); - this.initSystem(response, cachedSystem ? AES.decrypt(cachedSystem, saveKey).toString(enc.Utf8) : undefined).then(resolve); + 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? @@ -707,12 +707,11 @@ export class GameData { return true; } - const response = await Utils.apiFetch(`savedata/system/verify?clientSessionId=${clientSessionId}`, true) - .then(response => response.json()); + const systemData = api.verifySystemSavedata(clientSessionId); - if (!response.valid) { + if (systemData) { this.scene.clearPhaseQueue(); - this.scene.unshiftPhase(new ReloadSessionPhase(this.scene, JSON.stringify(response.systemData))); + this.scene.unshiftPhase(new ReloadSessionPhase(this.scene, JSON.stringify(systemData))); this.clearLocalData(); return false; } @@ -987,10 +986,9 @@ export class GameData { }; if (!bypassLogin && !localStorage.getItem(`sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`)) { - Utils.apiFetch(`savedata/session/get?slot=${slotId}&clientSessionId=${clientSessionId}`, true) - .then(response => response.text()) + api.getSessionSavedata(slotId, clientSessionId) .then(async response => { - if (!response.length || response[0] !== "{") { + if (!response && response?.length === 0 || response?.[0] !== "{") { console.error(response); return resolve(null); } @@ -1136,14 +1134,7 @@ export class GameData { if (success !== null && !success) { return resolve(false); } - Utils.apiFetch(`savedata/session/delete?slot=${slotId}&clientSessionId=${clientSessionId}`, true).then(response => { - if (response.ok) { - loggedInUser!.lastSessionSlot = -1; // TODO: is the bang correct? - localStorage.removeItem(`sessionData${this.scene.sessionSlotId ? this.scene.sessionSlotId : ""}_${loggedInUser?.username}`); - resolve(true); - } - return response.text(); - }).then(error => { + api.deleteSessionSavedata(slotId, clientSessionId).then(error => { if (error) { if (error.startsWith("session out of date")) { this.scene.clearPhaseQueue(); diff --git a/src/ui/login-form-ui-handler.ts b/src/ui/login-form-ui-handler.ts index 631b2e50b02..84ae57cda7a 100644 --- a/src/ui/login-form-ui-handler.ts +++ b/src/ui/login-form-ui-handler.ts @@ -7,6 +7,7 @@ import BattleScene from "#app/battle-scene"; import { addTextObject, TextStyle } from "./text"; import { addWindow } from "./ui-theme"; import { OptionSelectItem } from "#app/ui/abstact-option-select-ui-handler"; +import { api } from "#app/plugins/api/api"; interface BuildInteractableImageOpts { scale?: number; @@ -132,21 +133,16 @@ export default class LoginFormUiHandler extends FormModalUiHandler { if (!this.inputs[0].text) { return onFail(i18next.t("menu:emptyUsername")); } - Utils.apiPost("account/login", `username=${encodeURIComponent(this.inputs[0].text)}&password=${encodeURIComponent(this.inputs[1].text)}`, "application/x-www-form-urlencoded") - .then(response => { - if (!response.ok) { - return response.text(); - } - return response.json(); - }) - .then(response => { - if (response.hasOwnProperty("token")) { - Utils.setCookie(Utils.sessionIdKey, response.token); - originalLoginAction && originalLoginAction(); - } else { - onFail(response); - } - }); + + const [usernameInput, passwordInput] = this.inputs; + + api.login(usernameInput.text, passwordInput.text).then(isSuccess => { + if (isSuccess) { + originalLoginAction && originalLoginAction(); + } else { + onFail("Invalid username or password"); // TODO: print actual server error here! + } + }); }; return true; diff --git a/src/ui/menu-ui-handler.ts b/src/ui/menu-ui-handler.ts index 0af527e518f..a8100401c4b 100644 --- a/src/ui/menu-ui-handler.ts +++ b/src/ui/menu-ui-handler.ts @@ -13,6 +13,7 @@ import { GameDataType } from "#enums/game-data-type"; import BgmBar from "#app/ui/bgm-bar"; import AwaitableUiHandler from "./awaitable-ui-handler"; import { SelectModifierPhase } from "#app/phases/select-modifier-phase"; +import { api } from "#app/plugins/api/api"; enum MenuOptions { GAME_SETTINGS, @@ -586,11 +587,7 @@ export default class MenuUiHandler extends MessageUiHandler { success = true; const doLogout = () => { ui.setMode(Mode.LOADING, { - buttonActions: [], fadeOut: () => Utils.apiFetch("account/logout", true).then(res => { - if (!res.ok) { - console.error(`Log out failed (${res.status}: ${res.statusText})`); - } - Utils.removeCookie(Utils.sessionIdKey); + buttonActions: [], fadeOut: () => api.logout().then(() => { updateUserInfo().then(() => this.scene.reset(true, true)); }) }); diff --git a/src/utils.ts b/src/utils.ts index 7a0def1a950..7e5c043c37b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,7 @@ import { MoneyFormat } from "#enums/money-format"; import { Moves } from "#enums/moves"; import i18next from "i18next"; +import { api } from "./plugins/api/api"; export type nil = null | undefined; @@ -323,12 +324,10 @@ export function getCookie(cName: string): string { * with a GET request to verify if a server is running, * sets isLocalServerConnected based on results */ -export function localPing() { +export async function localPing() { if (isLocal) { - apiFetch("game/titlestats") - .then(resolved => isLocalServerConnected = true, - rejected => isLocalServerConnected = false - ); + const titleStats = await api.getGameTitleStats(); + isLocalServerConnected = !!titleStats; } }