Add Galaxy cloud saves support #75
6 changed files with 464 additions and 88 deletions
|
@ -63,9 +63,18 @@ import type { Player } from "game/player";
|
||||||
import player, { stringifySave } from "game/player";
|
import player, { stringifySave } from "game/player";
|
||||||
import settings from "game/settings";
|
import settings from "game/settings";
|
||||||
import LZString from "lz-string";
|
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 type { ComponentPublicInstance } from "vue";
|
||||||
import { computed, nextTick, ref, shallowReactive, watch } from "vue";
|
import { computed, nextTick, ref, watch } from "vue";
|
||||||
import Draggable from "vuedraggable";
|
import Draggable from "vuedraggable";
|
||||||
import Select from "./fields/Select.vue";
|
import Select from "./fields/Select.vue";
|
||||||
import Text from "./fields/Text.vue";
|
import Text from "./fields/Text.vue";
|
||||||
|
@ -90,16 +99,8 @@ watch(saveToImport, importedSave => {
|
||||||
if (importedSave) {
|
if (importedSave) {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
try {
|
try {
|
||||||
if (importedSave[0] === "{") {
|
importedSave = decodeSave(importedSave) ?? "";
|
||||||
// plaintext. No processing needed
|
if (importedSave === "") {
|
||||||
} 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 {
|
|
||||||
console.warn("Unable to determine preset encoding", importedSave);
|
console.warn("Unable to determine preset encoding", importedSave);
|
||||||
importingFailed.value = true;
|
importingFailed.value = true;
|
||||||
return;
|
return;
|
||||||
|
@ -139,48 +140,10 @@ let bank = ref(
|
||||||
}, [])
|
}, [])
|
||||||
);
|
);
|
||||||
|
|
||||||
const cachedSaves = shallowReactive<Record<string, LoadablePlayerData | undefined>>({});
|
|
||||||
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
|
// Wipe cache whenever the modal is opened
|
||||||
watch(isOpen, isOpen => {
|
watch(isOpen, isOpen => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
Object.keys(cachedSaves).forEach(key => delete cachedSaves[key]);
|
clearCachedSaves();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -235,18 +198,18 @@ function duplicateSave(id: string) {
|
||||||
function deleteSave(id: string) {
|
function deleteSave(id: string) {
|
||||||
settings.saves = settings.saves.filter((save: string) => save !== id);
|
settings.saves = settings.saves.filter((save: string) => save !== id);
|
||||||
localStorage.removeItem(id);
|
localStorage.removeItem(id);
|
||||||
cachedSaves[id] = undefined;
|
clearCachedSave(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSave(id: string) {
|
function openSave(id: string) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
saves.value[player.id]!.time = player.time;
|
saves.value[player.id]!.time = player.time;
|
||||||
save();
|
save();
|
||||||
cachedSaves[player.id] = undefined;
|
clearCachedSave(player.id);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
loadSave(saves.value[id]!);
|
loadSave(saves.value[id]!);
|
||||||
// Delete cached version in case of opening it again
|
// Delete cached version in case of opening it again
|
||||||
cachedSaves[id] = undefined;
|
clearCachedSave(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function newFromPreset(preset: string) {
|
function newFromPreset(preset: string) {
|
||||||
|
@ -256,16 +219,8 @@ function newFromPreset(preset: string) {
|
||||||
selectedPreset.value = null;
|
selectedPreset.value = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (preset[0] === "{") {
|
preset = decodeSave(preset) ?? "";
|
||||||
// plaintext. No processing needed
|
if (preset === "") {
|
||||||
} 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 {
|
|
||||||
console.warn("Unable to determine preset encoding", preset);
|
console.warn("Unable to determine preset encoding", preset);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -287,7 +242,7 @@ function editSave(id: string, newName: string) {
|
||||||
save();
|
save();
|
||||||
} else {
|
} else {
|
||||||
save(currSave as Player);
|
save(currSave as Player);
|
||||||
cachedSaves[id] = undefined;
|
clearCachedSave(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { Themes } from "data/themes";
|
||||||
import type { CoercableComponent } from "features/feature";
|
import type { CoercableComponent } from "features/feature";
|
||||||
import { globalBus } from "game/events";
|
import { globalBus } from "game/events";
|
||||||
import LZString from "lz-string";
|
import LZString from "lz-string";
|
||||||
import { hardReset } from "util/save";
|
import { decodeSave, hardReset } from "util/save";
|
||||||
import { reactive, watch } from "vue";
|
import { reactive, watch } from "vue";
|
||||||
|
|
||||||
/** The player's settings object. */
|
/** The player's settings object. */
|
||||||
|
@ -78,16 +78,8 @@ export function loadSettings(): void {
|
||||||
try {
|
try {
|
||||||
let item: string | null = localStorage.getItem(projInfo.id);
|
let item: string | null = localStorage.getItem(projInfo.id);
|
||||||
if (item != null && item !== "") {
|
if (item != null && item !== "") {
|
||||||
if (item[0] === "{") {
|
item = decodeSave(item);
|
||||||
// plaintext. No processing needed
|
if (item == null) {
|
||||||
} 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 {
|
|
||||||
console.warn("Unable to determine settings encoding", item);
|
console.warn("Unable to determine settings encoding", item);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
248
src/lib/galaxy.js
Normal file
248
src/lib/galaxy.js
Normal file
|
@ -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<number, { label: string; content: string }>} 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<Record<number, { label: string; content: string }>>} getSaveList - Method to get the save list.
|
||||||
|
* @property {function(number, string, string?): Promise<number>} 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<GalaxyApi>}
|
||||||
|
*/
|
||||||
|
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<number, { accept: function(number):void, reject: function(string?):void }> */
|
||||||
|
const saveCallbacks = {};
|
||||||
|
/** @type Record<number, { accept: function({ content: string; label?: string; slot: number }):void, reject: function(string?):void }> */
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
|
@ -8,6 +8,7 @@ import { useRegisterSW } from "virtual:pwa-register/vue";
|
||||||
import type { App as VueApp } from "vue";
|
import type { App as VueApp } from "vue";
|
||||||
import { createApp, nextTick } from "vue";
|
import { createApp, nextTick } from "vue";
|
||||||
import { useToast } from "vue-toastification";
|
import { useToast } from "vue-toastification";
|
||||||
|
import "util/galaxy";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
/**
|
/**
|
||||||
|
|
134
src/util/galaxy.ts
Normal file
134
src/util/galaxy.ts
Normal file
|
@ -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<GalaxyApi>();
|
||||||
|
export const conflictingSaves = ref<string[]>([]);
|
||||||
|
|
||||||
|
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<Player> & { 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<Player> | 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;
|
||||||
|
});
|
||||||
|
}
|
|
@ -1,10 +1,12 @@
|
||||||
|
import { LoadablePlayerData } from "components/SavesManager.vue";
|
||||||
import projInfo from "data/projInfo.json";
|
import projInfo from "data/projInfo.json";
|
||||||
import { globalBus } from "game/events";
|
import { globalBus } from "game/events";
|
||||||
import type { Player } from "game/player";
|
import type { Player } from "game/player";
|
||||||
import player, { stringifySave } from "game/player";
|
import player, { stringifySave } from "game/player";
|
||||||
import settings, { loadSettings } from "game/settings";
|
import settings, { loadSettings } from "game/settings";
|
||||||
import LZString from "lz-string";
|
import LZString from "lz-string";
|
||||||
import { ref } from "vue";
|
import { ref, shallowReactive } from "vue";
|
||||||
|
import { sync } from "./galaxy";
|
||||||
|
|
||||||
export function setupInitialStore(player: Partial<Player> = {}): Player {
|
export function setupInitialStore(player: Partial<Player> = {}): Player {
|
||||||
return Object.assign(
|
return Object.assign(
|
||||||
|
@ -29,6 +31,7 @@ export function setupInitialStore(player: Partial<Player> = {}): Player {
|
||||||
export function save(playerData?: Player): string {
|
export function save(playerData?: Player): string {
|
||||||
const stringifiedSave = LZString.compressToUTF16(stringifySave(playerData ?? player));
|
const stringifiedSave = LZString.compressToUTF16(stringifySave(playerData ?? player));
|
||||||
localStorage.setItem((playerData ?? player).id, stringifiedSave);
|
localStorage.setItem((playerData ?? player).id, stringifiedSave);
|
||||||
|
sync();
|
||||||
return stringifiedSave;
|
return stringifiedSave;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -42,17 +45,9 @@ export async function load(): Promise<void> {
|
||||||
await loadSave(newSave());
|
await loadSave(newSave());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (save[0] === "{") {
|
save = decodeSave(save);
|
||||||
// plaintext. No processing needed
|
if (save == null) {
|
||||||
} else if (save[0] === "e") {
|
throw "Unable to determine save encoding";
|
||||||
// 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`;
|
|
||||||
}
|
}
|
||||||
const player = JSON.parse(save);
|
const player = JSON.parse(save);
|
||||||
if (player.modID !== projInfo.id) {
|
if (player.modID !== projInfo.id) {
|
||||||
|
@ -67,6 +62,23 @@ export async function load(): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
export function newSave(): Player {
|
||||||
const id = getUniqueID();
|
const id = getUniqueID();
|
||||||
const player = setupInitialStore({ id });
|
const player = setupInitialStore({ id });
|
||||||
|
@ -127,6 +139,40 @@ export async function loadSave(playerObj: Partial<Player>): Promise<void> {
|
||||||
globalBus.emit("onLoad");
|
globalBus.emit("onLoad");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cachedSaves = shallowReactive<Record<string, LoadablePlayerData | undefined>>({});
|
||||||
|
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(() => {
|
setInterval(() => {
|
||||||
if (player.autosave) {
|
if (player.autosave) {
|
||||||
save();
|
save();
|
||||||
|
|
Loading…
Reference in a new issue