diff --git a/package-lock.json b/package-lock.json index f747006..d3c50e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "is-plain-object": "^5.0.0", "lz-string": "^1.4.4", "nanoevents": "^6.0.2", + "unofficial-galaxy-sdk": "git+https://code.incremental.social/thepaperpilot/unofficial-galaxy-sdk.git#1.0.1", "vite": "^2.9.12", "vite-plugin-pwa": "^0.12.0", "vite-tsconfig-paths": "^3.5.0", @@ -6878,6 +6879,10 @@ "node": ">= 4.0.0" } }, + "node_modules/unofficial-galaxy-sdk": { + "version": "1.0", + "resolved": "git+https://code.incremental.social/thepaperpilot/unofficial-galaxy-sdk.git#97d6da6636a2fc38c14aa893d4b336ccc22314af" + }, "node_modules/upath": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", diff --git a/package.json b/package.json index f0ce56b..eeeecf0 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "is-plain-object": "^5.0.0", "lz-string": "^1.4.4", "nanoevents": "^6.0.2", + "unofficial-galaxy-sdk": "git+https://code.incremental.social/thepaperpilot/unofficial-galaxy-sdk.git#1.0.1", "vite": "^2.9.12", "vite-plugin-pwa": "^0.12.0", "vite-tsconfig-paths": "^3.5.0", 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 @@ <TPS v-if="unref(showTPS)" /> <GameOverScreen /> <NaNScreen /> + <CloudSaveResolver /> <component :is="gameComponent" /> </div> </template> @@ -16,10 +17,11 @@ <script setup lang="tsx"> import "@fontsource/roboto-mono"; import Error from "components/Error.vue"; +import CloudSaveResolver from "components/saves/CloudSaveResolver.vue"; import { jsx } from "features/feature"; import state from "game/state"; import { coerceComponent, render } from "util/vue"; -import { CSSProperties } from "vue"; +import type { 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/components/Modal.vue b/src/components/Modal.vue index 9fd5f06..95f3ec8 100644 --- a/src/components/Modal.vue +++ b/src/components/Modal.vue @@ -4,6 +4,7 @@ name="modal" @before-enter="isAnimating = true" @after-leave="isAnimating = false" + appear > <div class="modal-mask" @@ -12,7 +13,7 @@ v-bind="$attrs" > <div class="modal-wrapper"> - <div class="modal-container"> + <div class="modal-container" :width="width"> <div class="modal-header"> <slot name="header" :shown="isOpen"> default header </slot> </div> @@ -45,6 +46,8 @@ import Context from "./Context.vue"; const _props = defineProps<{ modelValue: boolean; + preventClosing?: boolean; + width?: string; }>(); const props = toRefs(_props); const emit = defineEmits<{ @@ -53,7 +56,9 @@ const emit = defineEmits<{ const isOpen = computed(() => unref(props.modelValue) || isAnimating.value); function close() { - emit("update:modelValue", false); + if (unref(props.preventClosing) !== true) { + emit("update:modelValue", false); + } } const isAnimating = ref(false); diff --git a/src/components/NaNScreen.vue b/src/components/NaNScreen.vue index f9c7b6f..9967085 100644 --- a/src/components/NaNScreen.vue +++ b/src/components/NaNScreen.vue @@ -55,7 +55,7 @@ import Decimal, { format } from "util/bignum"; import type { ComponentPublicInstance } from "vue"; import { computed, ref, toRef, watch } from "vue"; import Toggle from "./fields/Toggle.vue"; -import SavesManager from "./SavesManager.vue"; +import SavesManager from "./saves/SavesManager.vue"; const { discordName, discordLink } = projInfo; const autosave = ref(true); diff --git a/src/components/Nav.vue b/src/components/Nav.vue index c5d0026..e2cc798 100644 --- a/src/components/Nav.vue +++ b/src/components/Nav.vue @@ -36,7 +36,7 @@ </div> <div @click="savesManager?.open()"> <Tooltip display="Saves" :direction="Direction.Down" xoffset="-20px"> - <span class="material-icons">library_books</span> + <span class="material-icons" :class="{ needsSync }">library_books</span> </Tooltip> </div> <div @click="options?.open()"> @@ -53,7 +53,7 @@ </div> <div @click="savesManager?.open()"> <Tooltip display="Saves" :direction="Direction.Right"> - <span class="material-icons">library_books</span> + <span class="material-icons" :class="{ needsSync }">library_books</span> </Tooltip> </div> <div @click="options?.open()"> @@ -98,12 +98,14 @@ import Changelog from "data/Changelog.vue"; import projInfo from "data/projInfo.json"; import Tooltip from "features/tooltips/Tooltip.vue"; +import settings from "game/settings"; import { Direction } from "util/common"; +import { galaxy, syncedSaves } from "util/galaxy"; import type { ComponentPublicInstance } from "vue"; -import { ref } from "vue"; +import { computed, ref } from "vue"; import Info from "./Info.vue"; import Options from "./Options.vue"; -import SavesManager from "./SavesManager.vue"; +import SavesManager from "./saves/SavesManager.vue"; const info = ref<ComponentPublicInstance<typeof Info> | null>(null); const savesManager = ref<ComponentPublicInstance<typeof SavesManager> | null>(null); @@ -117,6 +119,10 @@ const { useHeader, banner, title, discordName, discordLink, versionNumber } = pr function openDiscord() { window.open(discordLink, "mywindow"); } + +const needsSync = computed( + () => galaxy.value?.loggedIn === true && !syncedSaves.value.includes(settings.active) +); </script> <style scoped> @@ -264,4 +270,32 @@ function openDiscord() { color: var(--foreground); text-shadow: none; } + +.needsSync { + color: var(--danger); + animation: 4s wiggle ease infinite; +} + +@keyframes wiggle { + 0% { + transform: rotate(-3deg); + box-shadow: 0 2px 2px #0003; + } + 5% { + transform: rotate(20deg); + } + 10% { + transform: rotate(-15deg); + } + 15% { + transform: rotate(5deg); + } + 20% { + transform: rotate(-1deg); + } + 25% { + transform: rotate(0); + box-shadow: 0 2px 2px #0003; + } +} </style> diff --git a/src/components/saves/CloudSaveResolver.vue b/src/components/saves/CloudSaveResolver.vue new file mode 100644 index 0000000..9a2b823 --- /dev/null +++ b/src/components/saves/CloudSaveResolver.vue @@ -0,0 +1,228 @@ +<template> + <Modal v-model="isOpen" width="960px" ref="modal" :prevent-closing="true"> + <template v-slot:header> + <div class="cloud-saves-modal-header"> + <h2>Cloud {{ pluralizedSave }} loaded!</h2> + </div> + </template> + <template v-slot:body> + <div> + Upon loading, your cloud {{ pluralizedSave }} + {{ conflictingSaves.length > 1 ? "appear" : "appears" }} to be out of sync with your + local {{ pluralizedSave }}. Which + {{ pluralizedSave }} + do you want to keep? + </div> + <br /> + <div + v-for="(conflict, i) in unref(conflictingSaves)" + :key="conflict.id" + class="conflict-container" + > + <div @click="selectCloud(i)" :class="{ selected: selectedSaves[i] === 'cloud' }"> + <h2> + Cloud + <span + v-if="(conflict.cloud.time ?? 0) > (conflict.local.time ?? 0)" + class="note" + >(more recent)</span + > + <span + v-if=" + (conflict.cloud.timePlayed ?? 0) > (conflict.local.timePlayed ?? 0) + " + class="note" + >(more playtime)</span + > + </h2> + <Save :save="conflict.cloud" :readonly="true" /> + </div> + <div @click="selectLocal(i)" :class="{ selected: selectedSaves[i] === 'local' }"> + <h2> + Local + <span + v-if="(conflict.cloud.time ?? 0) <= (conflict.local.time ?? 0)" + class="note" + >(more recent)</span + > + <span + v-if=" + (conflict.cloud.timePlayed ?? 0) <= (conflict.local.timePlayed ?? 0) + " + class="note" + >(more playtime)</span + > + </h2> + <Save :save="conflict.local" :readonly="true" /> + </div> + <div + @click="selectBoth(i)" + :class="{ selected: selectedSaves[i] === 'both' }" + style="flex-basis: 30%" + > + <h2>Both</h2> + <div class="save">Keep Both</div> + </div> + </div> + </template> + <template v-slot:footer> + <div class="cloud-saves-footer"> + <button @click="close" class="button">Confirm</button> + </div> + </template> + </Modal> +</template> + +<script setup lang="ts"> +import Modal from "components/Modal.vue"; +import { stringifySave } from "game/player"; +import settings from "game/settings"; +import LZString from "lz-string"; +import { conflictingSaves, galaxy } from "util/galaxy"; +import { getUniqueID, save, setupInitialStore } from "util/save"; +import { ComponentPublicInstance, computed, ref, unref, watch } from "vue"; +import Save from "./Save.vue"; + +const isOpen = ref(false); +// True means replacing local save with cloud save +const selectedSaves = ref<("cloud" | "local" | "both")[]>([]); + +const pluralizedSave = computed(() => (conflictingSaves.value.length > 1 ? "saves" : "save")); + +const modal = ref<ComponentPublicInstance<typeof Modal> | null>(null); + +watch( + () => conflictingSaves.value.length > 0, + shouldOpen => { + if (shouldOpen) { + selectedSaves.value = conflictingSaves.value.map(({ local, cloud }) => { + return (local.time ?? 0) < (cloud.time ?? 0) ? "cloud" : "local"; + }); + isOpen.value = true; + } + }, + { immediate: true } +); + +watch( + () => modal.value?.isOpen, + open => { + if (open === false) { + conflictingSaves.value = []; + } + } +); + +function selectLocal(index: number) { + selectedSaves.value[index] = "local"; +} + +function selectCloud(index: number) { + selectedSaves.value[index] = "cloud"; +} + +function selectBoth(index: number) { + selectedSaves.value[index] = "both"; +} + +function close() { + for (let i = 0; i < selectedSaves.value.length; i++) { + const { slot, local, cloud } = conflictingSaves.value[i]; + switch (selectedSaves.value[i]) { + case "local": + // Replace cloud save with local + galaxy.value + ?.save( + slot, + LZString.compressToUTF16(stringifySave(setupInitialStore(local))), + cloud.name + ) + .catch(console.error); + break; + case "cloud": + // Replace local save with cloud + save(setupInitialStore(cloud)); + break; + case "both": + // Get a new save ID for the cloud save, and sync the local one to the cloud + const id = getUniqueID(); + save({ ...setupInitialStore(cloud), id }); + settings.saves.push(id); + galaxy.value + ?.save( + slot, + LZString.compressToUTF16(stringifySave(setupInitialStore(local))), + cloud.name + ) + .catch(console.error); + break; + } + } + isOpen.value = false; +} +</script> + +<style scoped> +.cloud-saves-modal-header { + padding: 10px 0; + margin-left: 10px; +} + +.cloud-saves-footer { + display: flex; + justify-content: flex-end; +} + +.cloud-saves-footer button { + margin: 0 10px; +} + +.conflict-container { + display: flex; +} + +.conflict-container > * { + flex-basis: 50%; + display: flex; + flex-flow: column; + margin: 0; +} + +.conflict-container + .conflict-container { + margin-top: 1em; +} + +.conflict-container h2 { + display: flex; + flex-flow: column wrap; + height: 1.5em; + margin: 0; +} + +.note { + font-size: x-small; + opacity: 0.7; + margin-right: 1em; +} + +.save { + border: solid 4px var(--outline); + padding: 4px; + background: var(--raised-background); + margin: var(--feature-margin); + display: flex; + align-items: center; + min-height: 30px; + height: 100%; +} +</style> + +<style> +.conflict-container .save { + cursor: pointer; +} + +.conflict-container .selected .save { + border-color: var(--bought); +} +</style> diff --git a/src/components/Save.vue b/src/components/saves/Save.vue similarity index 75% rename from src/components/Save.vue rename to src/components/saves/Save.vue index 77d2988..8c1809b 100644 --- a/src/components/Save.vue +++ b/src/components/saves/Save.vue @@ -1,7 +1,7 @@ <template> - <div class="save" :class="{ active: isActive }"> - <div class="handle material-icons">drag_handle</div> - <div class="actions" v-if="!isEditing"> + <div class="save" :class="{ active: isActive, readonly }"> + <div class="handle material-icons" v-if="readonly !== true">drag_handle</div> + <div class="actions" v-if="!isEditing && readonly !== true"> <FeedbackButton @click="emit('export')" class="button" @@ -40,7 +40,7 @@ </Tooltip> </DangerButton> </div> - <div class="actions" v-else> + <div class="actions" v-else-if="readonly !== true"> <button @click="changeName" class="button"> <Tooltip display="Save" :direction="Direction.Left" class="info"> <span class="material-icons">check</span> @@ -53,12 +53,17 @@ </button> </div> <div class="details" v-if="save.error == undefined && !isEditing"> - <button class="button open" @click="emit('open')"> + <Tooltip display="Synced!" :direction="Direction.Right" v-if="synced" + ><span class="material-icons synced">cloud</span></Tooltip + > + <button class="button open" @click="emit('open')" :disabled="readonly"> <h3>{{ save.name }}</h3> </button> <span class="save-version">v{{ save.modVersion }}</span ><br /> - <div v-if="currentTime">Last played {{ dateFormat.format(currentTime) }}</div> + <div v-if="currentTime" class="time"> + Last played {{ dateFormat.format(currentTime) }} + </div> </div> <div class="details" v-else-if="save.error == undefined && isEditing"> <Text v-model="newName" class="editname" @submit="changeName" /> @@ -73,16 +78,18 @@ import Tooltip from "features/tooltips/Tooltip.vue"; import player from "game/player"; import { Direction } from "util/common"; -import { computed, ref, toRefs, watch } from "vue"; -import DangerButton from "./fields/DangerButton.vue"; -import FeedbackButton from "./fields/FeedbackButton.vue"; -import Text from "./fields/Text.vue"; +import { computed, ref, toRefs, unref, watch } from "vue"; +import DangerButton from "../fields/DangerButton.vue"; +import FeedbackButton from "../fields/FeedbackButton.vue"; +import Text from "../fields/Text.vue"; import type { LoadablePlayerData } from "./SavesManager.vue"; +import { galaxy, syncedSaves } from "util/galaxy"; const _props = defineProps<{ save: LoadablePlayerData; + readonly?: boolean; }>(); -const { save } = toRefs(_props); +const { save, readonly } = toRefs(_props); const emit = defineEmits<{ (e: "export"): void; (e: "open"): void; @@ -106,10 +113,18 @@ const newName = ref(""); watch(isEditing, () => (newName.value = save.value.name ?? "")); -const isActive = computed(() => save.value != null && save.value.id === player.id); +const isActive = computed( + () => save.value != null && save.value.id === player.id && !unref(readonly) +); const currentTime = computed(() => isActive.value ? player.time : (save.value != null && save.value.time) ?? 0 ); +const synced = computed( + () => + !unref(readonly) && + galaxy.value?.loggedIn === true && + syncedSaves.value.includes(save.value.id) +); function changeName() { emit("editName", newName.value); @@ -139,6 +154,13 @@ function changeName() { padding-left: 0; } +.open:disabled { + cursor: inherit; + color: var(--foreground); + opacity: 1; + pointer-events: none; +} + .handle { flex-grow: 0; margin-right: 8px; @@ -152,6 +174,10 @@ function changeName() { margin-right: 80px; } +.save.readonly .details { + margin-right: 0; +} + .error { font-size: 0.8em; color: var(--danger); @@ -176,6 +202,17 @@ function changeName() { .editname { margin: 0; } + +.time { + font-size: small; +} + +.synced { + font-size: 100%; + margin-right: 0.5em; + vertical-align: middle; + cursor: default; +} </style> <style> @@ -201,4 +238,8 @@ function changeName() { .save .field { margin: 0; } + +.details > .tooltip-container { + display: inline; +} </style> diff --git a/src/components/SavesManager.vue b/src/components/saves/SavesManager.vue similarity index 70% rename from src/components/SavesManager.vue rename to src/components/saves/SavesManager.vue index b1bf7e0..4edb2e0 100644 --- a/src/components/SavesManager.vue +++ b/src/components/saves/SavesManager.vue @@ -4,6 +4,9 @@ <h2>Saves Manager</h2> </template> <template #body="{ shown }"> + <div v-if="showNotSyncedWarning" style="color: var(--danger)"> + Not all saves are synced! You may need to delete stale saves. + </div> <Draggable :list="settings.saves" handle=".handle" @@ -63,13 +66,23 @@ 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"; +import Select from "../fields/Select.vue"; +import Text from "../fields/Text.vue"; import Save from "./Save.vue"; +import { galaxy, syncedSaves } from "util/galaxy"; export type LoadablePlayerData = Omit<Partial<Player>, "id"> & { id: string; error?: unknown }; @@ -90,16 +103,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 +144,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 watch(isOpen, isOpen => { if (isOpen) { - Object.keys(cachedSaves).forEach(key => delete cachedSaves[key]); + clearCachedSaves(); } }); @@ -191,6 +158,10 @@ const saves = computed(() => }, {}) ); +const showNotSyncedWarning = computed( + () => galaxy.value?.loggedIn === true && settings.saves.length < syncedSaves.value.length +); + function exportSave(id: string) { let saveToExport; if (player.id === id) { @@ -233,20 +204,37 @@ function duplicateSave(id: string) { } function deleteSave(id: string) { + if (galaxy.value?.loggedIn === true) { + galaxy.value.getSaveList().then(list => { + const slot = Object.keys(list).find(slot => { + const content = list[slot as unknown as number].content; + try { + if (JSON.parse(content).id === id) { + return true; + } + } catch (e) { + return false; + } + }); + if (slot != null) { + galaxy.value?.save(parseInt(slot), "", "").catch(console.error); + } + }); + } 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 +244,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 +267,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/main.css b/src/main.css index 60188bd..f84ba5f 100644 --- a/src/main.css +++ b/src/main.css @@ -66,3 +66,7 @@ ul { .Vue-Toastification__toast { margin: unset; } + +:disabled { + pointer-events: none; +} 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..384ed3d --- /dev/null +++ b/src/util/galaxy.ts @@ -0,0 +1,185 @@ +import { LoadablePlayerData } from "components/saves/SavesManager.vue"; +import player, { Player, stringifySave } from "game/player"; +import settings from "game/settings"; +import { GalaxyApi, initGalaxy } from "unofficial-galaxy-sdk"; +import LZString from "lz-string"; +import { ref } from "vue"; +import { decodeSave, loadSave, save, setupInitialStore } from "./save"; + +export const galaxy = ref<GalaxyApi>(); +export const conflictingSaves = ref< + { id: string; local: LoadablePlayerData; cloud: LoadablePlayerData; slot: number }[] +>([]); +export const syncedSaves = ref<string[]>([]); + +export function sync() { + if (galaxy.value?.loggedIn !== true) { + return; + } + if (conflictingSaves.value.length > 0) { + // Pause syncing while resolving conflicted saves + return; + } + galaxy.value + .getSaveList() + .then(syncSaves) + .then(list => { + syncedSaves.value = list.map(s => s.content.id); + }) + .catch(console.error); +} + +// Setup Galaxy API +initGalaxy({ + supportsSaving: true, + supportsSaveManager: true, + onLoggedInChanged +}) + .then(g => { + galaxy.value = g; + onLoggedInChanged(g); + }) + .catch(console.error); + +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); + syncedSaves.value = saves.map(s => s.content.id); + + // If our current save has under 2 minutes of playtime, load the cloud save with the most recent time. + if (player.timePlayed < 120 * 1000 && saves.length > 0) { + const longestSave = saves.reduce((acc, curr) => + acc.content.time < curr.content.time ? curr : acc + ); + loadSave(longestSave.content); + } + }) + .catch(console.error); + + setInterval(sync, 60 * 1000); +} + +function syncSaves( + list: Record< + number, + { + label: string; + content: string; + } + > +) { + const savesToUpload = new Set(settings.saves.slice()); + const availableSlots = new Set(new Array(11).fill(0).map((_, i) => i)); + const saves = ( + Object.keys(list) + .map(slot => { + const { label, content } = list[slot as unknown as number]; + try { + return { + slot: parseInt(slot), + label, + content: JSON.parse(decodeSave(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; + } + availableSlots.delete(cloudSave.slot); + const localSaveId = settings.saves.find(id => id === cloudSave.content.id); + if (localSaveId == undefined) { + settings.saves.push(cloudSave.content.id); + save(setupInitialStore(cloudSave.content)); + } else { + savesToUpload.delete(localSaveId); + 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 2 minutes, just use the newer save (very unlikely to be coincidence) + // Otherwise, ask the player + if (timePlayedDiff < 120 * 1000 && timeDiff < 120 * 1000) { + if (localSave.time < cloudSave.content.time) { + save(setupInitialStore(cloudSave.content)); + if (settings.active === localSaveId) { + loadSave(cloudSave.content); + } + } else { + galaxy.value + ?.save( + cloudSave.slot, + LZString.compressToUTF16( + stringifySave(setupInitialStore(localSave)) + ), + localSave.name ?? cloudSave.label + ) + .catch(console.error); + // Update cloud save content for the return value + cloudSave.content = localSave as Player; + } + } else { + conflictingSaves.value.push({ + id: localSaveId, + cloud: cloudSave.content, + local: localSave as LoadablePlayerData, + slot: cloudSave.slot + }); + } + } catch (e) { + return false; + } + } + return true; + }); + + savesToUpload.forEach(id => { + try { + if (availableSlots.size > 0) { + const localSave = localStorage.getItem(id) ?? ""; + const parsedLocalSave = JSON.parse(decodeSave(localSave) ?? ""); + const slot = availableSlots.values().next().value; + galaxy.value + ?.save(slot, localSave, parsedLocalSave.name) + .then(() => syncedSaves.value.push(parsedLocalSave.id)) + .catch(console.error); + availableSlots.delete(slot); + } + } catch (e) {} + }); + + return saves; +} diff --git a/src/util/save.ts b/src/util/save.ts index 1578732..86c7691 100644 --- a/src/util/save.ts +++ b/src/util/save.ts @@ -1,10 +1,11 @@ +import { LoadablePlayerData } from "components/saves/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"; export function setupInitialStore(player: Partial<Player> = {}): Player { return Object.assign( @@ -42,17 +43,9 @@ export async function load(): Promise<void> { 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 +60,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 { const id = getUniqueID(); const player = setupInitialStore({ id }); @@ -127,6 +137,40 @@ export async function loadSave(playerObj: Partial<Player>): Promise<void> { 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(() => { if (player.autosave) { save();