From 91ff34c4c5fb0f14f50e791907c53c7924bc1376 Mon Sep 17 00:00:00 2001 From: Jasper Meggitt Date: Tue, 2 Jun 2020 10:48:30 -0700 Subject: [PATCH] Add helper state from yorg3 --- gulp/webpack.config.js | 2 + gulp/webpack.production.config.js | 2 + src/css/main.scss | 1 + src/css/states/mods.scss | 109 ++++++++++++++++ src/js/application.js | 2 + src/js/core/backend.js | 193 ++++++++++++++++++++++++++++ src/js/core/backend_errors.js | 37 ++++++ src/js/core/config.js | 1 + src/js/core/mod_manager.js | 9 +- src/js/globals.d.ts | 1 + src/js/states/mods.js | 4 +- src/js/states/preload.js | 17 ++- src/js/states/sync_savegame_mods.js | 121 +++++++++++++++++ translations/base-en.yaml | 10 ++ 14 files changed, 503 insertions(+), 6 deletions(-) create mode 100644 src/css/states/mods.scss create mode 100644 src/js/core/backend.js create mode 100644 src/js/core/backend_errors.js create mode 100644 src/js/states/sync_savegame_mods.js diff --git a/gulp/webpack.config.js b/gulp/webpack.config.js index 274cc3ef..e7d9876d 100644 --- a/gulp/webpack.config.js +++ b/gulp/webpack.config.js @@ -45,6 +45,8 @@ module.exports = ({ watch = false, standalone = false }) => { G_BUILD_COMMIT_HASH: JSON.stringify(utils.getRevision()), G_BUILD_VERSION: JSON.stringify(utils.getVersion()), G_ALL_UI_IMAGES: JSON.stringify(utils.getAllResourceImages()), + // TODO: Get API endpoint from tobspr + G_API_ENDPOINT: JSON.stringify(lzString.compressToEncodedURIComponent("http://localhost:8000")), }), new CircularDependencyPlugin({ diff --git a/gulp/webpack.production.config.js b/gulp/webpack.production.config.js index 14c6598a..85e32b36 100644 --- a/gulp/webpack.production.config.js +++ b/gulp/webpack.production.config.js @@ -38,6 +38,8 @@ module.exports = ({ G_BUILD_COMMIT_HASH: JSON.stringify(utils.getRevision()), G_BUILD_VERSION: JSON.stringify(utils.getVersion()), G_ALL_UI_IMAGES: JSON.stringify(utils.getAllResourceImages()), + // TODO: Get API endpoint from tobspr + G_API_ENDPOINT: JSON.stringify(lzString.compressToEncodedURIComponent("https://api.shapez.io/v1")), }; return { diff --git a/src/css/main.scss b/src/css/main.scss index 46fdab9c..1e8c824a 100644 --- a/src/css/main.scss +++ b/src/css/main.scss @@ -28,6 +28,7 @@ @import "states/about"; @import "states/mobile_warning"; @import "states/changelog"; +@import "states/mods"; @import "ingame_hud/buildings_toolbar"; @import "ingame_hud/building_placer"; diff --git a/src/css/states/mods.scss b/src/css/states/mods.scss new file mode 100644 index 00000000..3ddd15d7 --- /dev/null +++ b/src/css/states/mods.scss @@ -0,0 +1,109 @@ +#state_ModsState { + + .mainContent { + pointer-events: all; + } + + .devHint { + font-size: calc(13px*var(--ui-scale)); + line-height: calc(17px*var(--ui-scale)); + font-weight: 400; + letter-spacing: .04em; + color: #aaa; + + .moddingDocsLink { + pointer-events: all; + cursor: pointer; + color: #26c6da; + } + } + + .mod { + padding: calc(14px*var(--ui-scale)); + display: grid; + grid-template-columns: auto auto 1fr auto; + grid-gap: calc(5px*var(--ui-scale)); + grid-column-gap: calc(15px*var(--ui-scale)); + + .title { + font-size: calc(19px*var(--ui-scale)); + line-height: calc(21px*var(--ui-scale)); + font-weight: 400; + grid-column: 1 / 4; + grid-row: 1 / 2; + letter-spacing: .04em; + + .version { + font-size: calc(13px*var(--ui-scale)); + line-height: calc(17px*var(--ui-scale)); + font-weight: 400; + letter-spacing: .04em; + color: #aaa; + } + } + + .author { + grid-column: 1 / 2; + grid-row: 2 / 3; + color: #aaa; + } + + .website { + font-size: calc(13px*var(--ui-scale)); + line-height: calc(17px*var(--ui-scale)); + font-weight: 400; + grid-column: 2 / 3; + grid-row: 2 / 3; + pointer-events: all; + cursor: pointer; + color: #26c6da; + } + + .description { + grid-column: 1 / 5; + grid-row: 3 / 4; + border-top: 1px solid; + border-top-width: calc(1px*var(--ui-scale)); + padding-top: calc(5px*var(--ui-scale)); + } + + .installedText, button.installMod, button.uninstallMod { + grid-column: 4 / 5; + grid-row: 1 / 3; + align-self: start; + } + + .author, .gameChangingHint { + font-size: calc(13px*var(--ui-scale)); + line-height: calc(17px*var(--ui-scale)); + font-weight: 400; + letter-spacing: .04em; + } + + .gameChangingHint { + grid-column: 1 / 5; + grid-row: 4 / 5; + color: #ef5072; + border-top: 1px solid; + border-top-width: calc(1px*var(--ui-scale)); + padding-top: calc(5px*var(--ui-scale)); + } + + .installed { + opacity: .5; + } + + .installCount, .installedText { + font-size: calc(13px*var(--ui-scale)); + line-height: calc(17px*var(--ui-scale)); + font-weight: 400; + letter-spacing: .04em; + color: #aaa; + } + + .installedText { + text-transform: uppercase; + justify-self: end; + } + } +} \ No newline at end of file diff --git a/src/js/application.js b/src/js/application.js index fa23a982..be32a57c 100644 --- a/src/js/application.js +++ b/src/js/application.js @@ -38,6 +38,7 @@ import { PreloadState } from "./states/preload"; import { SettingsState } from "./states/settings"; import { ModsState } from "./states/mods"; import { ModManager } from "./core/mod_manager"; +import { BackendAPI } from "./core/backend"; const logger = createLogger("application"); @@ -72,6 +73,7 @@ export class Application { this.savegameMgr = new SavegameManager(this); this.inputMgr = new InputDistributor(this); this.backgroundResourceLoader = new BackgroundResourcesLoader(this); + this.api = new BackendAPI(this); this.modManager = new ModManager(this); // Platform dependent stuff diff --git a/src/js/core/backend.js b/src/js/core/backend.js new file mode 100644 index 00000000..8f6cf527 --- /dev/null +++ b/src/js/core/backend.js @@ -0,0 +1,193 @@ +/* typehints:start */ +import { Application } from "../application"; +/* typehints:end */ + +import { JSON_stringify } from "./builtins"; +import { globalError, globalLog, globalWarn } from "./logging"; +import { decodeHashedString } from "./sensitive_utils.encrypt"; +import { globalConfig } from "./config"; +import { BACKEND_ERRORS } from "./backend_errors"; +import { RequestChannel } from "./request_channel"; + +export class BackendAPI { + constructor(app) { + /** @type {Application} */ + this.app = app; + this.url = decodeHashedString(G_API_ENDPOINT); + + // For testing + if (G_IS_DEV && window.location.hostname !== "localhost") { + this.url = "http://172.0.0.1:8000"; + } + + this.runningRequest = null; + this.requestChannel = new RequestChannel(); + } + + /** + * Retrieves the list of mods + * + * Route: /mods/gallery + * @returns {Promise>} + */ + fetchModGallery() { + return this.performRequest("GET", "/mods/gallery").then(res => { + if (!res.mods) { + throw BACKEND_ERRORS.badResponse; + } + return res.mods; + }); + } + + /** + * Retrieves the list of mods + * + * Route: /mods/track-download/:id + * @returns {Promise>} + */ + trackModDownload(id) { + return this.performRequest("POST", "/mods/track-download/" + id); + } + + /** + * Formats an endpoint like '/user/profile' to a full url + * @param {string} endpoint + */ + getEndpointUrl(endpoint) { + assertAlways(endpoint.startsWith("/"), "Endpoint must start with '/'"); + return this.url + endpoint; + } + + /** + * Internal fetch helper + * @param {string} method + * @param {string} endpoint + * @param {Object.} parameters + */ + internalFetch(method, endpoint, parameters) { + if (G_IS_DEV && globalConfig.debug.alwaysOffline) { + return Promise.reject("offline"); + } + + return ( + fetch(this.getEndpointUrl(endpoint), { + method: method, + mode: "cors", + cache: "no-cache", + referrer: "no-referrer", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + "X-App-Version": G_BUILD_VERSION, + }, + credentials: "omit", + body: method === "POST" ? JSON_stringify(parameters) : undefined, + }) + // Catch network errors / bad status codes + .catch(err => { + globalLog(this, "Network error:", err); + throw BACKEND_ERRORS.networkError; + }) + + // Check if the response was good + .then(response => { + if (!response.ok) { + return response.json().then( + data => { + if (data.error) { + globalWarn(this, "API request error:", data); + throw data.error; + } + globalWarn(this, "API response has no error payload:", data); + throw BACKEND_ERRORS.unknownError; + }, + err => { + globalError(this, "API bad json:", err); + throw BACKEND_ERRORS.badResponse; + } + ); + } + return Promise.resolve(response); + }) + + // JSON parsing + .then(response => { + try { + return response.json(); + } catch (err) { + globalError(this, "API response json parsing error:", err); + throw BACKEND_ERRORS.badResponse; + } + }) + + // Check if error flag is set + .then(data => { + if (data.error) { + globalWarn(this, "API sent error:", data.error); + const str = new String(data.error); + // @ts-ignore + str.originalError = data; + throw str; + } + return data; + }) + ); + } + + /** + * Performs a request + * @param {string} method + * @param {string} endpoint + * @param {Object.=} parameters + */ + performRequest(method, endpoint, parameters = null) { + if (this.runningRequest) { + globalWarn( + this, + "Request to", + endpoint, + "queried while request to", + this.runningRequest, + "did not finish yet!" + ); + } + assertAlways(method === "POST" || method === "GET", "Invalid mode"); + + globalLog(this, "🔗 " + method + " " + endpoint); + this.runningRequest = endpoint; + + return this.requestChannel.watch( + new Promise((resolve, reject) => { + let timedOut = false; + let timeout = setTimeout(() => { + globalWarn("api", "Request to", endpoint, "timed out"); + timedOut = true; + this.runningRequest = null; + reject(BACKEND_ERRORS.networkError); + }, 30000); + const request = this.internalFetch(method, endpoint, parameters); + + request.then( + res => { + if (timedOut) { + globalWarn("api", "Request finished but already timed out"); + } else { + this.runningRequest = null; + clearTimeout(timeout); + resolve(res); + } + }, + err => { + if (timedOut) { + globalWarn("api", "Request finished with error but already timed out"); + } else { + this.runningRequest = null; + clearTimeout(timeout); + reject(err); + } + } + ); + }) + ); + } +} diff --git a/src/js/core/backend_errors.js b/src/js/core/backend_errors.js new file mode 100644 index 00000000..4f9da8f2 --- /dev/null +++ b/src/js/core/backend_errors.js @@ -0,0 +1,37 @@ +import { T } from "../translations"; + +/** + * Translates error codes like "invalid-auth-key" into their translations (E.g. "Invalid Auth Key"). + * If the code is not known or an arbitrary string, returns the code + * @param {string} err The error code + * @returns {string} Translated code + */ +export function tryTranslateBackendError(err) { + // TODO: Add errors to base-en.yaml + // return "
" + (T.backend.errors[err] || err) + "
"; + return "
☹" + err + "
"; +} + +export const BACKEND_ERRORS = { + // Frontend errors + networkError: "network-error", + offline: "offline", + badResponse: "bad-response", + rateLimited: "rate-limited", + + // Backend errors + unsupportedVersion: "unsupported-version", + unknownError: "unknown-error", + unauthenticated: "unauthenticated", + invalidRequestSchema: "invalid-request-schema", + internalServerError: "internal-server-error", + databaseError: "database-error", + notFound: "not-found", + serverOverloaded: "server-overloaded", + + // Binary checksum stuff + failedToDecompress: "failed-to-decompress", + checksumMissing: "checksum-missing", + checksumMismatch: "checksum-mismatch", + failedToParse: "failed-to-parse", +}; diff --git a/src/js/core/config.js b/src/js/core/config.js index 05555793..4cfeca2f 100644 --- a/src/js/core/config.js +++ b/src/js/core/config.js @@ -109,6 +109,7 @@ export const globalConfig = { // instantBelts: true, // instantProcessors: true, // instantMiners: true, + // alwaysOffline: true, // renderForTrailer: true, /* dev:end */ diff --git a/src/js/core/mod_manager.js b/src/js/core/mod_manager.js index cfaa62c5..76227637 100644 --- a/src/js/core/mod_manager.js +++ b/src/js/core/mod_manager.js @@ -1,6 +1,6 @@ import { ReadWriteProxy } from "./read_write_proxy"; import { ExplainedResult } from "./explained_result"; -import { globalError, globalLog, globalWarn } from "./logging"; +import { globalError, globalWarn } from "./logging"; import { ModApi } from "./mod_api"; import { queryParamOptions } from "./query_parameters"; @@ -272,10 +272,13 @@ export class ModManager extends ReadWriteProxy { this.currentData.mods.push(mod); - // TODO: Check if this use of trackUiClick is okay. // Track download in the background return this.writeAsync() - .then(() => this.app.analytics.trackUiClick("mod/" + id)) + .then(() => + this.app.api + .trackModDownload(id) + .catch(err => globalWarn(this, "Failed to track mod download:", err)) + ) .then(() => (this.needsRestart = true)) .then(() => null); } diff --git a/src/js/globals.d.ts b/src/js/globals.d.ts index dc2246d0..977e8ff3 100644 --- a/src/js/globals.d.ts +++ b/src/js/globals.d.ts @@ -7,6 +7,7 @@ declare function assertAlways(condition: boolean | object | string, ...errorMess declare const abstract: void; +declare const G_API_ENDPOINT: string; declare const G_APP_ENVIRONMENT: string; declare const G_HAVE_ASSERT: boolean; declare const G_BUILD_TIME: number; diff --git a/src/js/states/mods.js b/src/js/states/mods.js index e4e5a1fe..d8877a89 100644 --- a/src/js/states/mods.js +++ b/src/js/states/mods.js @@ -37,10 +37,10 @@ export class ModsState extends TextualGameState { - ${T.mods.installedMods} + ${T.mods.installedMods}
- ${T.mods.modsBrowser} + ${T.mods.modsBrowser}
`; } diff --git a/src/js/states/preload.js b/src/js/states/preload.js index 29d5bdd4..11decbf8 100644 --- a/src/js/states/preload.js +++ b/src/js/states/preload.js @@ -1,5 +1,5 @@ import { GameState } from "../core/game_state"; -import { createLogger } from "../core/logging"; +import { createLogger, globalError } from "../core/logging"; import { findNiceValue, waitNextFrame } from "../core/utils"; import { cachebust } from "../core/cachebust"; import { PlatformWrapperImplBrowser } from "../platform/browser/wrapper"; @@ -173,6 +173,21 @@ export class PreloadState extends GameState { }); }) + .then(() => this.setStatus("Initializing mods")) + .then(() => { + return this.app.modManager.initialize().catch(err => { + globalError(this, "Failed to initialize mods:", err); + return new Promise(resolve => { + const { ok } = this.dialogs.showWarning( + T.global.error, + T.dialogs.modLoadFailDialog.content + "

" + err + "", + ["ok:good"] + ); + ok.add(resolve); + }); + }); + }) + .then(() => this.setStatus("Downloading resources")) .then(() => { return this.app.backgroundResourceLoader.getPromiseForBareGame(); diff --git a/src/js/states/sync_savegame_mods.js b/src/js/states/sync_savegame_mods.js new file mode 100644 index 00000000..3913e1c8 --- /dev/null +++ b/src/js/states/sync_savegame_mods.js @@ -0,0 +1,121 @@ +import { TextualGameState } from "../core/textual_game_state"; +import { T } from "../translations"; +import { Savegame } from "../savegame/savegame"; + +export class SyncSavegameModsState extends TextualGameState { + constructor() { + super("SyncSavegameModsState"); + } + + getMainContentHTML() { + return ` + + +
+ ${T.syncSavegameMods.desc} + +
+ +
+ ${T.syncSavegameMods.savegameMods} +
+ Mymod@1.0.0 +
+ +
+ +
+ ${T.syncSavegameMods.yourMods} +
+ Mymod@1.0.1 +
+
+
+ +
+ + +
+
+ `; + } + + getShowDiamonds() { + return false; + } + + onEnter(payload) { + /** @type {Savegame} */ + const savegame = payload.savegame; + const nextStateId = payload.nextStateId; + const nextStatePayload = payload.nextStatePayload; + + this.targetMods = savegame.getInstalledMods(); + + if (this.app.modManager.needsRestart) { + this.containerElement.classList.add("loading"); + const { cancel, restart } = this.dialogs.showWarning( + T.syncSavegameMods.dialog.needs_restart.title, + T.syncSavegameMods.dialog.needs_restart.desc, + this.app.platformWrapper.getSupportsRestart() + ? ["cancel:bad", "restart:misc"] + : ["cancel:bad"] + ); + cancel.add(() => this.moveToState("MainMenuState")); + if (restart) { + restart.add(() => this.app.platformWrapper.performRestart()); + } + return; + } + + const renderMods = (mods, targetId) => { + const targetElement = this.containerElement.querySelector(targetId); + + const filteredMods = mods.filter(mod => mod.is_game_changing); + + if (filteredMods.length > 0) { + targetElement.innerHTML = filteredMods + .map(m => "" + m.name + "@" + m.version + "") + .join(""); + } else { + targetElement.innerHTML = `${T.syncSavegameMods.no_game_changing_mods}`; + } + }; + + renderMods(this.app.modManager.getMods(), ".installedMods .modsList"); + renderMods(savegame.getInstalledMods(), ".savegameMods .modsList"); + + this.trackClicks(this.htmlElement.querySelector("button.cancel"), this.onBackButton); + this.trackClicks(this.htmlElement.querySelector("button.syncMods"), this.doSyncMods); + } + + doSyncMods() { + const closeLoading = this.dialogs.showLoadingDialog(); + + this.app.modManager.syncModsFromSavegame(this.targetMods).then( + () => { + closeLoading(); + const { cancel, restart } = this.dialogs.showWarning( + T.syncSavegameMods.dialog_synced.title, + T.syncSavegameMods.dialog_synced.desc, + this.app.platformWrapper.getSupportsRestart() + ? ["cancel:bad", "restart:misc"] + : ["cancel:bad"] + ); + cancel.add(this.onBackButton, this); + if (restart) { + restart.add(() => this.app.platformWrapper.performRestart()); + } + }, + err => { + closeLoading(); + const { ok } = this.dialogs.showWarning(T.global.error, err); + ok.add(this.onBackButton, this); + } + ); + } + + onBackButton() { + this.moveToState("SingleplayerOverviewState"); + } +} diff --git a/translations/base-en.yaml b/translations/base-en.yaml index 7361d7de..52162102 100644 --- a/translations/base-en.yaml +++ b/translations/base-en.yaml @@ -97,6 +97,12 @@ mods: title: Mod Removed desc: The mod has successfully beeen removed and will get unloaded after a restart. +syncSavegameMods: + desc: You need + + savegameMods: Savegame Mods + yourMods: Loaded Mods + syncMods: Sync Mods demoBanners: # This is the "advertisement" shown in the main menu and other various places @@ -243,6 +249,10 @@ dialogs: title: New Marker desc: Give it a meaningful name + modLoadFailDialog: + content: >- + Failed to initialize mods: + ingame: # This is shown in the top left corner and displays useful keybindings in # every situation