From 143b0773e7d23b55effd4fafc4752edc61d5e773 Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Sat, 17 Feb 2024 20:16:00 -0600 Subject: [PATCH 01/22] Add eslint workflow action and CONTRIBUTING.md that says to lint first --- .forgejo/workflows/test.yaml | 1 + .github/workflows/test.yml | 1 + .vscode/settings.json | 2 +- CONTRIBUTING.md | 31 +++++++++++++++++++++++++++++++ package.json | 4 +++- src/App.vue | 2 +- src/features/action.tsx | 2 +- src/features/tooltips/tooltip.ts | 2 +- src/features/trees/tree.ts | 2 +- src/game/formulas/operations.ts | 4 ++++ src/game/modifiers.tsx | 15 +++++++++++---- src/game/requirements.tsx | 4 +++- 12 files changed, 59 insertions(+), 11 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/.forgejo/workflows/test.yaml b/.forgejo/workflows/test.yaml index 33df8d8..7c48ad6 100644 --- a/.forgejo/workflows/test.yaml +++ b/.forgejo/workflows/test.yaml @@ -19,3 +19,4 @@ jobs: - run: npm ci - run: npm run build --if-present - run: npm test + - run: npm run lint diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c41d085..8d6b548 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,3 +19,4 @@ jobs: - run: npm ci - run: npm run build --if-present - run: npm test + - run: npm run lint diff --git a/.vscode/settings.json b/.vscode/settings.json index d46602a..65fe597 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ { "vitest.commandLine": "npx vitest", "editor.codeActionsOnSave": { - "source.fixAll.eslint": true + "source.fixAll.eslint": "explicit" }, "editor.defaultFormatter": "esbenp.prettier-vscode", "git.ignoreLimitWarning": true, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..818ba84 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,31 @@ +# Contributing to Profectus + +Thank you for considering contributing to Profectus! We appreciate your interest in improving our project. Please take a moment to review the following guidelines to streamline the contribution process. + +## Getting Started + +For detailed instructions on setting up local development environment, please refer to the [Setup Guide](https://moddingtree.com/guide/getting-started/setup). + +## Contributing + +Make sure to open your PR on Incremental Social - the GitHub repo is just a mirror! + +### Code Review + +All PRs must be reviewed and approved by at least one of the project maintainers before merging. Please be patient during the review process and be open to feedback. + +### Testing + +Ensure that your changes pass all existing tests and, if applicable, add new tests to cover the changes you've made. Run `npm run test` to run all the tests. + +## Code Style + +We use ESLint and Prettier to enforce consistent code style throughout the project. Before submitting a PR, run `npm run lint:fix` to automatically fix any linting issues. + +## Issue Reporting + +If you encounter a bug or have a suggestion for improvement, please open an issue on Incremental Social. Provide as much detail as possible, including an example repo or steps to reproduce the issue if applicable. + +## License + +By contributing to Profectus, you agree that your contributions will be licensed under the project's [LICENSE](./LICENSE). diff --git a/package.json b/package.json index 3c1c415..f0ce56b 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "preview": "vite preview", "test": "vitest run", "testw": "vitest", - "serve": "vite preview --host" + "serve": "vite preview --host", + "lint": "eslint src --max-warnings 0", + "lint:fix": "eslint --fix --max-warnings 0 src" }, "dependencies": { "@fontsource/material-icons": "^4.5.4", diff --git a/src/App.vue b/src/App.vue index 6a365ef..40e21de 100644 --- a/src/App.vue +++ b/src/App.vue @@ -19,7 +19,7 @@ import Error from "components/Error.vue"; import { jsx } from "features/feature"; import state from "game/state"; import { coerceComponent, render } from "util/vue"; -import { CSSProperties, watch } from "vue"; +import { CSSProperties } from "vue"; import { computed, toRef, unref } from "vue"; import Game from "./components/Game.vue"; import GameOverScreen from "./components/GameOverScreen.vue"; diff --git a/src/features/action.tsx b/src/features/action.tsx index 1fbb8d3..2919a9e 100644 --- a/src/features/action.tsx +++ b/src/features/action.tsx @@ -31,7 +31,7 @@ import { coerceComponent, isCoercableComponent, render } from "util/vue"; import { computed, Ref, ref, unref } from "vue"; import { BarOptions, createBar, GenericBar } from "./bars/bar"; import { ClickableOptions } from "./clickables/clickable"; -import { Decorator, GenericDecorator } from "./decorators/common"; +import { GenericDecorator } from "./decorators/common"; /** A symbol used to identify {@link Action} features. */ export const ActionType = Symbol("Action"); diff --git a/src/features/tooltips/tooltip.ts b/src/features/tooltips/tooltip.ts index 54d782c..8d65efd 100644 --- a/src/features/tooltips/tooltip.ts +++ b/src/features/tooltips/tooltip.ts @@ -1,6 +1,6 @@ import type { CoercableComponent, GenericComponent, Replace, StyleValue } from "features/feature"; import { Component, GatherProps, setDefault } from "features/feature"; -import { deletePersistent, Persistent, persistent } from "game/persistence"; +import { persistent } from "game/persistence"; import { Direction } from "util/common"; import type { Computable, diff --git a/src/features/trees/tree.ts b/src/features/trees/tree.ts index da77f60..6d09fc9 100644 --- a/src/features/trees/tree.ts +++ b/src/features/trees/tree.ts @@ -1,4 +1,4 @@ -import { Decorator, GenericDecorator } from "features/decorators/common"; +import { GenericDecorator } from "features/decorators/common"; import type { CoercableComponent, GenericComponent, diff --git a/src/game/formulas/operations.ts b/src/game/formulas/operations.ts index 586319e..7210cfb 100644 --- a/src/game/formulas/operations.ts +++ b/src/game/formulas/operations.ts @@ -552,7 +552,9 @@ export function tetrate( export function invertTetrate( value: DecimalSource, base: FormulaSource, + // eslint-disable-next-line @typescript-eslint/no-unused-vars height: FormulaSource, + // eslint-disable-next-line @typescript-eslint/no-unused-vars payload: FormulaSource ) { if (hasVariable(base)) { @@ -576,6 +578,7 @@ export function invertIteratedExp( value: DecimalSource, lhs: FormulaSource, height: FormulaSource, + // eslint-disable-next-line @typescript-eslint/no-unused-vars payload: FormulaSource ) { if (hasVariable(lhs)) { @@ -626,6 +629,7 @@ export function invertLayeradd( value: DecimalSource, lhs: FormulaSource, diff: FormulaSource, + // eslint-disable-next-line @typescript-eslint/no-unused-vars base: FormulaSource ) { if (hasVariable(lhs)) { diff --git a/src/game/modifiers.tsx b/src/game/modifiers.tsx index 1ee3905..2d2ccdf 100644 --- a/src/game/modifiers.tsx +++ b/src/game/modifiers.tsx @@ -296,10 +296,17 @@ export function createSequentialModifier< : undefined, getFormula: modifiers.every(m => m.getFormula != null) ? (gain: FormulaSource) => - modifiers - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - .reduce((acc, curr) => Formula.if(acc, curr.enabled ?? true, - acc => curr.getFormula!(acc), acc => acc), gain) + modifiers.reduce( + (acc, curr) => + Formula.if( + acc, + curr.enabled ?? true, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + acc => curr.getFormula!(acc), + acc => acc + ), + gain + ) : undefined, enabled: modifiers.some(m => m.enabled != null) ? computed(() => modifiers.filter(m => unref(m.enabled) !== false).length > 0) diff --git a/src/game/requirements.tsx b/src/game/requirements.tsx index ea82a64..363fccd 100644 --- a/src/game/requirements.tsx +++ b/src/game/requirements.tsx @@ -222,7 +222,9 @@ export function createCostRequirement( Decimal.gte( req.resource.value, unref(req.cost as ProcessedComputable) - ) ? 1 : 0 + ) + ? 1 + : 0 ); } From 64fad5c74af04b9547482b94aa47c746d9a4cb89 Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Tue, 20 Feb 2024 22:16:20 -0600 Subject: [PATCH 02/22] PR Feedback --- CONTRIBUTING.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 818ba84..4fc4ea1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,9 +6,13 @@ Thank you for considering contributing to Profectus! We appreciate your interest For detailed instructions on setting up local development environment, please refer to the [Setup Guide](https://moddingtree.com/guide/getting-started/setup). +## Issue Reporting + +If you encounter a bug or have a suggestion for improvement, please open an issue on Incremental Social. Provide as much detail as possible, including an example repo or steps to reproduce the issue if applicable. + ## Contributing -Make sure to open your PR on Incremental Social - the GitHub repo is just a mirror! +Make sure to open your PR on [Incremental Social](https://code.incremental.social/profectus/Profectus) - the GitHub repo is just a mirror! ### Code Review @@ -18,14 +22,10 @@ All PRs must be reviewed and approved by at least one of the project maintainers Ensure that your changes pass all existing tests and, if applicable, add new tests to cover the changes you've made. Run `npm run test` to run all the tests. -## Code Style +### Code Style We use ESLint and Prettier to enforce consistent code style throughout the project. Before submitting a PR, run `npm run lint:fix` to automatically fix any linting issues. -## Issue Reporting - -If you encounter a bug or have a suggestion for improvement, please open an issue on Incremental Social. Provide as much detail as possible, including an example repo or steps to reproduce the issue if applicable. - ## License By contributing to Profectus, you agree that your contributions will be licensed under the project's [LICENSE](./LICENSE). From f7a8fbbb110e6c1fe742a56d2cd2b54842e2d262 Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Tue, 20 Feb 2024 22:38:49 -0600 Subject: [PATCH 03/22] Lint --- src/features/trees/tree.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/trees/tree.ts b/src/features/trees/tree.ts index f47d690..5a03189 100644 --- a/src/features/trees/tree.ts +++ b/src/features/trees/tree.ts @@ -346,11 +346,11 @@ export const branchedResetPropagation = function ( const next: GenericTreeNode[] = []; for (const node of current) { for (const link of links.filter(link => link.startNode === node)) { - if ([...reset, ...current].includes(link.endNode)) continue + if ([...reset, ...current].includes(link.endNode)) continue; next.push(link.endNode); link.endNode.reset?.reset(); } - }; + } reset.push(...current); current = next; } From cfba55d2c6b686fea119895402b3ebb6003c851b Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Fri, 16 Feb 2024 13:17:40 -0600 Subject: [PATCH 04/22] Add galaxy api --- src/components/SavesManager.vue | 85 +++-------- src/game/settings.ts | 14 +- src/lib/galaxy.js | 248 ++++++++++++++++++++++++++++++++ src/main.ts | 1 + src/util/galaxy.ts | 134 +++++++++++++++++ src/util/save.ts | 70 +++++++-- 6 files changed, 464 insertions(+), 88 deletions(-) create mode 100644 src/lib/galaxy.js create mode 100644 src/util/galaxy.ts diff --git a/src/components/SavesManager.vue b/src/components/SavesManager.vue index b1bf7e0..b0b0f73 100644 --- a/src/components/SavesManager.vue +++ b/src/components/SavesManager.vue @@ -63,9 +63,18 @@ import type { Player } from "game/player"; import player, { stringifySave } from "game/player"; import settings from "game/settings"; import LZString from "lz-string"; -import { getUniqueID, loadSave, newSave, save } from "util/save"; +import { + clearCachedSave, + clearCachedSaves, + decodeSave, + getCachedSave, + getUniqueID, + loadSave, + newSave, + save +} from "util/save"; import type { ComponentPublicInstance } from "vue"; -import { computed, nextTick, ref, shallowReactive, watch } from "vue"; +import { computed, nextTick, ref, watch } from "vue"; import Draggable from "vuedraggable"; import Select from "./fields/Select.vue"; import Text from "./fields/Text.vue"; @@ -90,16 +99,8 @@ watch(saveToImport, importedSave => { if (importedSave) { nextTick(() => { try { - if (importedSave[0] === "{") { - // plaintext. No processing needed - } else if (importedSave[0] === "e") { - // Assumed to be base64, which starts with e - importedSave = decodeURIComponent(escape(atob(importedSave))); - } else if (importedSave[0] === "ᯡ") { - // Assumed to be lz, which starts with ᯡ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - importedSave = LZString.decompressFromUTF16(importedSave)!; - } else { + importedSave = decodeSave(importedSave) ?? ""; + if (importedSave === "") { console.warn("Unable to determine preset encoding", importedSave); importingFailed.value = true; return; @@ -139,48 +140,10 @@ let bank = ref( }, []) ); -const cachedSaves = shallowReactive>({}); -function getCachedSave(id: string) { - if (cachedSaves[id] == null) { - let save = localStorage.getItem(id); - if (save == null) { - cachedSaves[id] = { error: `Save doesn't exist in localStorage`, id }; - } else if (save === "dW5kZWZpbmVk") { - cachedSaves[id] = { error: `Save is undefined`, id }; - } else { - try { - if (save[0] === "{") { - // plaintext. No processing needed - } else if (save[0] === "e") { - // Assumed to be base64, which starts with e - save = decodeURIComponent(escape(atob(save))); - } else if (save[0] === "ᯡ") { - // Assumed to be lz, which starts with ᯡ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - save = LZString.decompressFromUTF16(save)!; - } else { - console.warn("Unable to determine preset encoding", save); - importingFailed.value = true; - cachedSaves[id] = { error: "Unable to determine preset encoding", id }; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return cachedSaves[id]!; - } - cachedSaves[id] = { ...JSON.parse(save), id }; - } catch (error) { - cachedSaves[id] = { error, id }; - console.warn( - `SavesManager: Failed to load info about save with id ${id}:\n${error}\n${save}` - ); - } - } - } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return cachedSaves[id]!; -} // Wipe cache whenever the modal is opened watch(isOpen, isOpen => { if (isOpen) { - Object.keys(cachedSaves).forEach(key => delete cachedSaves[key]); + clearCachedSaves(); } }); @@ -235,18 +198,18 @@ function duplicateSave(id: string) { function deleteSave(id: string) { settings.saves = settings.saves.filter((save: string) => save !== id); localStorage.removeItem(id); - cachedSaves[id] = undefined; + clearCachedSave(id); } function openSave(id: string) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion saves.value[player.id]!.time = player.time; save(); - cachedSaves[player.id] = undefined; + clearCachedSave(player.id); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion loadSave(saves.value[id]!); // Delete cached version in case of opening it again - cachedSaves[id] = undefined; + clearCachedSave(id); } function newFromPreset(preset: string) { @@ -256,16 +219,8 @@ function newFromPreset(preset: string) { selectedPreset.value = null; }); - if (preset[0] === "{") { - // plaintext. No processing needed - } else if (preset[0] === "e") { - // Assumed to be base64, which starts with e - preset = decodeURIComponent(escape(atob(preset))); - } else if (preset[0] === "ᯡ") { - // Assumed to be lz, which starts with ᯡ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - preset = LZString.decompressFromUTF16(preset)!; - } else { + preset = decodeSave(preset) ?? ""; + if (preset === "") { console.warn("Unable to determine preset encoding", preset); return; } @@ -287,7 +242,7 @@ function editSave(id: string, newName: string) { save(); } else { save(currSave as Player); - cachedSaves[id] = undefined; + clearCachedSave(id); } } } diff --git a/src/game/settings.ts b/src/game/settings.ts index 0748d68..6f3a435 100644 --- a/src/game/settings.ts +++ b/src/game/settings.ts @@ -3,7 +3,7 @@ import { Themes } from "data/themes"; import type { CoercableComponent } from "features/feature"; import { globalBus } from "game/events"; import LZString from "lz-string"; -import { hardReset } from "util/save"; +import { decodeSave, hardReset } from "util/save"; import { reactive, watch } from "vue"; /** The player's settings object. */ @@ -78,16 +78,8 @@ export function loadSettings(): void { try { let item: string | null = localStorage.getItem(projInfo.id); if (item != null && item !== "") { - if (item[0] === "{") { - // plaintext. No processing needed - } else if (item[0] === "e") { - // Assumed to be base64, which starts with e - item = decodeURIComponent(escape(atob(item))); - } else if (item[0] === "ᯡ") { - // Assumed to be lz, which starts with ᯡ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - item = LZString.decompressFromUTF16(item)!; - } else { + item = decodeSave(item); + if (item == null) { console.warn("Unable to determine settings encoding", item); return; } diff --git a/src/lib/galaxy.js b/src/lib/galaxy.js new file mode 100644 index 0000000..11db155 --- /dev/null +++ b/src/lib/galaxy.js @@ -0,0 +1,248 @@ +/** + * The Galaxy API defines actions and responses for interacting with the Galaxy platform. + * + * @typedef {object} SupportsAction + * @property {"supports"} action - The action type. + * @property {boolean} saving - If your game auto-saves or allows the user to make/load game saves from within the UI. + * @property {boolean} save_manager - If your game has a complete save manager integrated into it. + */ + +/** + * The save list action sends a retrieval request to Galaxy to get the player's cloud save list. + * + * @typedef {object} SaveListAction + * @property {"save_list"} action - The action type. + */ + +/** + * The save action creates a cloud save and puts it into a certain save slot. + * + * @typedef {object} SaveAction + * @property {"save"} action - The action type. + * @property {number} slot - The save slot number. Must be an integer between 0 and 10, inclusive. + * @property {string} [label] - The optional label of the save file. + * @property {string} data - The actual save data. + */ + +/** + * The load action sends a retrieval request to Galaxy to get the cloud save data inside a certain save slot. + * + * @typedef {object} LoadAction + * @property {"load"} action - The action type. + * @property {number} slot - The save slot number. + */ + +/** + * The Galaxy action can be one of SupportsAction, SaveListAction, SaveAction, or LoadAction. + * + * @typedef {SupportsAction | SaveListAction | SaveAction | LoadAction} GalaxyAction + */ + +/** + * The info response is sent when the page loads. + * + * @typedef {object} InfoResponse + * @property {"info"} type - The response type. + * @property {boolean} galaxy - Whether you're talking to Galaxy. + * @property {number} api_version - The version of the API. + * @property {string} theme_preference - The player's theme preference. + * @property {boolean} logged_in - Whether the player is logged in. + */ + +/** + * The save list response is requested by the save_list action. + * + * @typedef {object} SaveListResponse + * @property {"save_list"} type - The response type. + * @property {Record} list - A list of saves. + * @property {boolean} error - Whether the action encountered an error. + * @property {("no_account" | "server_error")} [message] - Reason for the error. + */ + +/** + * The save content response is requested by the load action. + * + * @typedef {object} SaveContentResponse + * @property {"save_content"} type - The response type. + * @property {boolean} error - Whether the action encountered an error. + * @property {("no_account" | "empty_slot" | "invalid_slot" | "server_error")} [message] - Reason for the error. + * @property {number} slot - The save slot number. + * @property {string} [label] - The save's label. + * @property {string} [content] - The save's actual data. + */ + +/** + * The saved response is requested by the save action. + * + * @typedef {object} SavedResponse + * @property {"saved"} type - The response type. + * @property {boolean} error - Whether the action encountered an error. + * @property {number} slot - The save slot number. + * @property {("no_account" | "too_big" | "invalid_slot" | "server_error")} [message] - Reason for the error. + */ + +/** + * The GalaxyResponse can be one of InfoResponse, SaveListResponse, SaveContentResponse, or SavedResponse. + * + * @typedef {InfoResponse | SaveListResponse | SaveContentResponse | SavedResponse} GalaxyResponse + */ + +/** + * The GalaxyApi interface defines methods and properties for interacting with the Galaxy platform. + * + * @typedef {object} GalaxyApi + * @property {string[]} acceptedOrigins - Accepted origins. + * @property {boolean} [supportsSaving] - Whether saving is supported. + * @property {boolean} [supportsSaveManager] - Whether save manager is supported. + * @property {boolean} [ignoreApiVersion] - Whether to ignore API version. + * @property {function(GalaxyApi): void} [onLoggedInChanged] - Function to handle logged in changes. + * @property {string} origin - Origin of the API. + * @property {number} apiVersion - Version of the API. + * @property {boolean} loggedIn - Whether the player is logged in. + * @property {function(GalaxyAction): void} postMessage - Method to post a message. + * @property {function(): Promise>} getSaveList - Method to get the save list. + * @property {function(number, string, string?): Promise} save - Method to save data. + * @property {function(number): Promise<{ content: string; label?: string; slot: number }>} load - Method to load data. + */ + + +/** + * Initialize the Galaxy API. + * @param {Object} [options] - An object of options that configure the API + * @param {string[]} [options.acceptedOrigins] - A list of domains that the API trusts messages from. Defaults to `['https://galaxy.click']`. + * @param {boolean} [options.supportsSaving] - Indicates to Galaxy that this game supports saving. Defaults to false. + * @param {boolean} [options.supportsSaveManager] - Indicates to Galaxy that this game supports a saves manager. Defaults to false. + * @param {boolean} [options.ignoreApiVersion] - Ignores the api_version property received from Galaxy. By default this value is false, meaning if an unknown API version is encountered, the API will fail to initialize. + * @param {(galaxy: GalaxyApi) => void} [options.onLoggedInChanged] - A callback for when the logged in status of the player changes after the initialization. + * @returns {Promise} + */ +export function initGalaxy({ + acceptedOrigins, + supportsSaving, + supportsSaveManager, + ignoreApiVersion, + onLoggedInChanged +}) { + return new Promise((accept, reject) => { + acceptedOrigins = acceptedOrigins ?? ["https://galaxy.click"]; + if (acceptedOrigins.includes(window.origin)) { + // Callbacks to resolve promises + /** @type function(SaveListResponse["list"]):void */ + let saveListAccept, + /** @type function(string?):void */ + saveListReject; + /** @type Record */ + const saveCallbacks = {}; + /** @type Record */ + const loadCallbacks = {}; + + /** @type GalaxyApi */ + const galaxy = { + acceptedOrigins, + supportsSaving, + supportsSaveManager, + ignoreApiVersion, + onLoggedInChanged, + origin: window.origin, + apiVersion: 0, + loggedIn: false, + postMessage: function (message) { + window.top?.postMessage(message, galaxy.origin); + }, + getSaveList: function () { + if (saveListAccept != null || saveListReject != null) { + return Promise.reject("save_list action already in progress."); + } + galaxy.postMessage({ action: "save_list" }); + return new Promise((accept, reject) => { + saveListAccept = accept; + saveListReject = reject; + }); + }, + save: function (slot, content, label) { + if (slot in saveCallbacks) { + return Promise.reject(`save action for slot ${slot} already in progress.`); + } + galaxy.postMessage({ action: "save", slot, content, label }); + return new Promise((accept, reject) => { + saveCallbacks[slot] = { accept, reject }; + }); + }, + load: function (slot) { + if (slot in loadCallbacks) { + return Promise.reject(`load action for slot ${slot} already in progress.`); + } + galaxy.postMessage({ action: "load", slot }); + return new Promise((accept, reject) => { + loadCallbacks[slot] = { accept, reject }; + }); + } + }; + + window.addEventListener("message", e => { + if (e.origin === galaxy.origin) { + console.log("Received message from Galaxy", e.data); + /** @type GalaxyResponse */ + const data = e.data; + + switch (data.type) { + case "info": { + const { galaxy: isGalaxy, api_version, logged_in } = data; + // Ignoring isGalaxy check in case other accepted origins send it as false + if (api_version !== 1 && galaxy.ignoreApiVersion !== true) { + reject(`API version not recognized: ${api_version}`); + } else { + // Info responses may be sent again if the information gets updated + // Specifically, we care if logged_in gets changed + // We can use the api_version to determine if this is the first + // info response or a new one. + const firstInfoResponse = galaxy.apiVersion === 0; + galaxy.apiVersion = api_version; + galaxy.loggedIn = logged_in; + galaxy.origin = e.origin; + if (firstInfoResponse) { + accept(galaxy); + } else { + galaxy.onLoggedInChanged?.(galaxy); + } + } + break; + } + case "save_list": { + const { list, error, message } = data; + if (error === true) { + saveListReject(message); + } else { + saveListAccept(list); + } + saveListAccept = saveListReject = null; + break; + } + case "save_content": { + const { content, label, slot, error, message } = data; + if (error === true) { + loadCallbacks[slot]?.reject(message); + } else { + loadCallbacks[slot]?.accept({ slot, content, label }); + } + delete loadCallbacks[slot]; + break; + } + case "saved": { + const { slot, error, message } = data; + if (error === true) { + saveCallbacks[slot]?.reject(message); + } else { + saveCallbacks[slot]?.accept(slot); + } + delete saveCallbacks[slot]; + break; + } + } + } + }); + } else { + reject(`Project is not running on an accepted origin: ${window.origin}`); + } + }); +} diff --git a/src/main.ts b/src/main.ts index e416fa5..3b5de9f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,6 +8,7 @@ import { useRegisterSW } from "virtual:pwa-register/vue"; import type { App as VueApp } from "vue"; import { createApp, nextTick } from "vue"; import { useToast } from "vue-toastification"; +import "util/galaxy"; declare global { /** diff --git a/src/util/galaxy.ts b/src/util/galaxy.ts new file mode 100644 index 0000000..544ec3d --- /dev/null +++ b/src/util/galaxy.ts @@ -0,0 +1,134 @@ +import player, { Player } from "game/player"; +import settings from "game/settings"; +import { GalaxyApi, initGalaxy } from "lib/galaxy"; +import { decodeSave, loadSave, save } from "./save"; +import { setupInitialStore } from "./save"; +import { ref } from "vue"; + +export const galaxy = ref(); +export const conflictingSaves = ref([]); + +export function sync() { + if (galaxy.value == null || !galaxy.value.loggedIn) { + return; + } + if (conflictingSaves.value.length > 0) { + // Pause syncing while resolving conflicted saves + return; + } + galaxy.value.getSaveList().then(syncSaves); +} + +// Setup Galaxy API +initGalaxy({ + supportsSaving: true, + supportsSaveManager: true, + onLoggedInChanged +}).then(g => { + galaxy.value = g; + onLoggedInChanged(g); +}); + +function onLoggedInChanged(g: GalaxyApi) { + if (g.loggedIn !== true) { + return; + } + if (conflictingSaves.value.length > 0) { + // Pause syncing while resolving conflicted saves + return; + } + + g.getSaveList().then(list => { + const saves = syncSaves(list); + + // If our current save has under a minute of playtime, load the cloud save with the most recent time. + if (player.timePlayed < 60 && saves.length > 0) { + const longestSave = saves.reduce((acc, curr) => + acc.content.time < curr.content.time ? curr : acc + ); + loadSave(longestSave.content); + } + }); +} + +function syncSaves( + list: Record< + number, + { + label: string; + content: string; + } + > +) { + return ( + Object.keys(list) + .map(slot => { + const { label, content } = list[slot as unknown as number]; + try { + return { slot: parseInt(slot), label, content: JSON.parse(content) }; + } catch (e) { + return null; + } + }) + .filter( + n => + n != null && + typeof n.content.id === "string" && + typeof n.content.time === "number" && + typeof n.content.timePlayed === "number" + ) as { + slot: number; + label?: string; + content: Partial & { id: string; time: number; timePlayed: number }; + }[] + ).filter(cloudSave => { + if (cloudSave.label != null) { + cloudSave.content.name = cloudSave.label; + } + const localSaveId = settings.saves.find(id => id === cloudSave.content.id); + if (localSaveId == undefined) { + settings.saves.push(cloudSave.content.id); + save(setupInitialStore(cloudSave.content)); + } else { + try { + const localSave = JSON.parse( + decodeSave(localStorage.getItem(localSaveId) ?? "") ?? "" + ) as Partial | null; + if (localSave == null) { + return false; + } + localSave.id = localSaveId; + localSave.time = localSave.time ?? 0; + localSave.timePlayed = localSave.timePlayed ?? 0; + + const timePlayedDiff = Math.abs( + localSave.timePlayed - cloudSave.content.timePlayed + ); + const timeDiff = Math.abs(localSave.time - cloudSave.content.time); + // If their last played time and total time played are both within a minute, just use the newer save (very unlikely to be coincidence) + // Otherwise, ask the player + if (timePlayedDiff < 60 && timeDiff < 60) { + if (localSave.time < cloudSave.content.time) { + save(setupInitialStore(cloudSave.content)); + if (settings.active === localSaveId) { + loadSave(cloudSave.content); + } + } else { + galaxy.value?.save( + cloudSave.slot, + JSON.stringify(cloudSave.content), + cloudSave.label ?? null + ); + // Update cloud save content for the return value + cloudSave.content = localSave as Player; + } + } else { + conflictingSaves.value.push(localSaveId); + } + } catch (e) { + return false; + } + } + return true; + }); +} diff --git a/src/util/save.ts b/src/util/save.ts index 4137e5b..9d51016 100644 --- a/src/util/save.ts +++ b/src/util/save.ts @@ -1,10 +1,12 @@ +import { LoadablePlayerData } from "components/SavesManager.vue"; import projInfo from "data/projInfo.json"; import { globalBus } from "game/events"; import type { Player } from "game/player"; import player, { stringifySave } from "game/player"; import settings, { loadSettings } from "game/settings"; import LZString from "lz-string"; -import { ref } from "vue"; +import { ref, shallowReactive } from "vue"; +import { sync } from "./galaxy"; export function setupInitialStore(player: Partial = {}): Player { return Object.assign( @@ -29,6 +31,7 @@ export function setupInitialStore(player: Partial = {}): Player { export function save(playerData?: Player): string { const stringifiedSave = LZString.compressToUTF16(stringifySave(playerData ?? player)); localStorage.setItem((playerData ?? player).id, stringifiedSave); + sync(); return stringifiedSave; } @@ -42,17 +45,9 @@ export async function load(): Promise { await loadSave(newSave()); return; } - if (save[0] === "{") { - // plaintext. No processing needed - } else if (save[0] === "e") { - // Assumed to be base64, which starts with e - save = decodeURIComponent(escape(atob(save))); - } else if (save[0] === "ᯡ") { - // Assumed to be lz, which starts with ᯡ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - save = LZString.decompressFromUTF16(save)!; - } else { - throw `Unable to determine save encoding`; + save = decodeSave(save); + if (save == null) { + throw "Unable to determine save encoding"; } const player = JSON.parse(save); if (player.modID !== projInfo.id) { @@ -67,6 +62,23 @@ export async function load(): Promise { } } +export function decodeSave(save: string) { + if (save[0] === "{") { + // plaintext. No processing needed + } else if (save[0] === "e") { + // Assumed to be base64, which starts with e + save = decodeURIComponent(escape(atob(save))); + } else if (save[0] === "ᯡ") { + // Assumed to be lz, which starts with ᯡ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + save = LZString.decompressFromUTF16(save)!; + } else { + console.warn("Unable to determine preset encoding", save); + return null; + } + return save; +} + export function newSave(): Player { const id = getUniqueID(); const player = setupInitialStore({ id }); @@ -127,6 +139,40 @@ export async function loadSave(playerObj: Partial): Promise { globalBus.emit("onLoad"); } +const cachedSaves = shallowReactive>({}); +export function getCachedSave(id: string) { + if (cachedSaves[id] == null) { + let save = localStorage.getItem(id); + if (save == null) { + cachedSaves[id] = { error: `Save doesn't exist in localStorage`, id }; + } else if (save === "dW5kZWZpbmVk") { + cachedSaves[id] = { error: `Save is undefined`, id }; + } else { + try { + save = decodeSave(save); + if (save == null) { + console.warn("Unable to determine preset encoding", save); + cachedSaves[id] = { error: "Unable to determine preset encoding", id }; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return cachedSaves[id]!; + } + cachedSaves[id] = { ...JSON.parse(save), id }; + } catch (error) { + cachedSaves[id] = { error, id }; + console.warn(`Failed to load info about save with id ${id}:\n${error}\n${save}`); + } + } + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return cachedSaves[id]!; +} +export function clearCachedSaves() { + Object.keys(cachedSaves).forEach(key => delete cachedSaves[key]); +} +export function clearCachedSave(id: string) { + cachedSaves[id] = undefined; +} + setInterval(() => { if (player.autosave) { save(); From ece7ed2923ec29cdb4661918cfa964340bc8a8b3 Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Sat, 17 Feb 2024 10:23:18 -0600 Subject: [PATCH 05/22] Add save conflict resolver --- src/App.vue | 4 +- src/components/Modal.vue | 9 +- src/components/NaNScreen.vue | 2 +- src/components/Nav.vue | 2 +- src/components/saves/CloudSaveResolver.vue | 186 ++++++++++++++++++++ src/components/{ => saves}/Save.vue | 44 +++-- src/components/{ => saves}/SavesManager.vue | 4 +- src/util/galaxy.ts | 84 ++++++--- src/util/save.ts | 2 +- 9 files changed, 291 insertions(+), 46 deletions(-) create mode 100644 src/components/saves/CloudSaveResolver.vue rename src/components/{ => saves}/Save.vue (81%) rename src/components/{ => saves}/SavesManager.vue (99%) diff --git a/src/App.vue b/src/App.vue index 40e21de..7e7a0aa 100644 --- a/src/App.vue +++ b/src/App.vue @@ -8,6 +8,7 @@ + @@ -16,10 +17,11 @@ + + + + diff --git a/src/components/Save.vue b/src/components/saves/Save.vue similarity index 81% rename from src/components/Save.vue rename to src/components/saves/Save.vue index 77d2988..d8af7d0 100644 --- a/src/components/Save.vue +++ b/src/components/saves/Save.vue @@ -1,7 +1,7 @@