Add Galaxy cloud saves support #64

Closed
thepaperpilot wants to merge 14 commits from thepaperpilot:feat/cloud-saves into main
13 changed files with 628 additions and 110 deletions

5
package-lock.json generated
View file

@ -23,6 +23,7 @@
"is-plain-object": "^5.0.0", "is-plain-object": "^5.0.0",
"lz-string": "^1.4.4", "lz-string": "^1.4.4",
"nanoevents": "^6.0.2", "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": "^2.9.12",
"vite-plugin-pwa": "^0.12.0", "vite-plugin-pwa": "^0.12.0",
"vite-tsconfig-paths": "^3.5.0", "vite-tsconfig-paths": "^3.5.0",
@ -6878,6 +6879,10 @@
"node": ">= 4.0.0" "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": { "node_modules/upath": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz",

View file

@ -27,6 +27,7 @@
"is-plain-object": "^5.0.0", "is-plain-object": "^5.0.0",
"lz-string": "^1.4.4", "lz-string": "^1.4.4",
"nanoevents": "^6.0.2", "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": "^2.9.12",
"vite-plugin-pwa": "^0.12.0", "vite-plugin-pwa": "^0.12.0",
"vite-tsconfig-paths": "^3.5.0", "vite-tsconfig-paths": "^3.5.0",

View file

@ -8,6 +8,7 @@
<TPS v-if="unref(showTPS)" /> <TPS v-if="unref(showTPS)" />
<GameOverScreen /> <GameOverScreen />
<NaNScreen /> <NaNScreen />
<CloudSaveResolver />
<component :is="gameComponent" /> <component :is="gameComponent" />
</div> </div>
</template> </template>
@ -16,10 +17,11 @@
<script setup lang="tsx"> <script setup lang="tsx">
import "@fontsource/roboto-mono"; import "@fontsource/roboto-mono";
import Error from "components/Error.vue"; import Error from "components/Error.vue";
import CloudSaveResolver from "components/saves/CloudSaveResolver.vue";
import { jsx } from "features/feature"; import { jsx } from "features/feature";
import state from "game/state"; import state from "game/state";
import { coerceComponent, render } from "util/vue"; import { coerceComponent, render } from "util/vue";
import { CSSProperties, watch } from "vue"; import type { CSSProperties } from "vue";
import { computed, toRef, unref } from "vue"; import { computed, toRef, unref } from "vue";
import Game from "./components/Game.vue"; import Game from "./components/Game.vue";
import GameOverScreen from "./components/GameOverScreen.vue"; import GameOverScreen from "./components/GameOverScreen.vue";

View file

@ -4,6 +4,7 @@
name="modal" name="modal"
@before-enter="isAnimating = true" @before-enter="isAnimating = true"
@after-leave="isAnimating = false" @after-leave="isAnimating = false"
appear
> >
<div <div
class="modal-mask" class="modal-mask"
@ -12,7 +13,7 @@
v-bind="$attrs" v-bind="$attrs"
> >
<div class="modal-wrapper"> <div class="modal-wrapper">
<div class="modal-container"> <div class="modal-container" :width="width">
<div class="modal-header"> <div class="modal-header">
<slot name="header" :shown="isOpen"> default header </slot> <slot name="header" :shown="isOpen"> default header </slot>
</div> </div>
@ -45,6 +46,8 @@ import Context from "./Context.vue";
const _props = defineProps<{ const _props = defineProps<{
modelValue: boolean; modelValue: boolean;
preventClosing?: boolean;
width?: string;
}>(); }>();
const props = toRefs(_props); const props = toRefs(_props);
const emit = defineEmits<{ const emit = defineEmits<{
@ -53,8 +56,10 @@ const emit = defineEmits<{
const isOpen = computed(() => unref(props.modelValue) || isAnimating.value); const isOpen = computed(() => unref(props.modelValue) || isAnimating.value);
function close() { function close() {
if (unref(props.preventClosing) !== true) {
emit("update:modelValue", false); emit("update:modelValue", false);
} }
}
const isAnimating = ref(false); const isAnimating = ref(false);

View file

@ -55,7 +55,7 @@ import Decimal, { format } from "util/bignum";
import type { ComponentPublicInstance } from "vue"; import type { ComponentPublicInstance } from "vue";
import { computed, ref, toRef, watch } from "vue"; import { computed, ref, toRef, watch } from "vue";
import Toggle from "./fields/Toggle.vue"; import Toggle from "./fields/Toggle.vue";
import SavesManager from "./SavesManager.vue"; import SavesManager from "./saves/SavesManager.vue";
const { discordName, discordLink } = projInfo; const { discordName, discordLink } = projInfo;
const autosave = ref(true); const autosave = ref(true);

View file

@ -36,7 +36,7 @@
</div> </div>
<div @click="savesManager?.open()"> <div @click="savesManager?.open()">
<Tooltip display="Saves" :direction="Direction.Down" xoffset="-20px"> <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> </Tooltip>
</div> </div>
<div @click="options?.open()"> <div @click="options?.open()">
@ -53,7 +53,7 @@
</div> </div>
<div @click="savesManager?.open()"> <div @click="savesManager?.open()">
<Tooltip display="Saves" :direction="Direction.Right"> <Tooltip display="Saves" :direction="Direction.Right">
<span class="material-icons">library_books</span> <span class="material-icons" :class="{ needsSync }">library_books</span>
</Tooltip> </Tooltip>
</div> </div>
<div @click="options?.open()"> <div @click="options?.open()">
@ -98,12 +98,14 @@
import Changelog from "data/Changelog.vue"; import Changelog from "data/Changelog.vue";
import projInfo from "data/projInfo.json"; import projInfo from "data/projInfo.json";
import Tooltip from "features/tooltips/Tooltip.vue"; import Tooltip from "features/tooltips/Tooltip.vue";
import settings from "game/settings";
import { Direction } from "util/common"; import { Direction } from "util/common";
import { galaxy, syncedSaves } from "util/galaxy";
import type { ComponentPublicInstance } from "vue"; import type { ComponentPublicInstance } from "vue";
import { ref } from "vue"; import { computed, ref } from "vue";
import Info from "./Info.vue"; import Info from "./Info.vue";
import Options from "./Options.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 info = ref<ComponentPublicInstance<typeof Info> | null>(null);
const savesManager = ref<ComponentPublicInstance<typeof SavesManager> | null>(null); const savesManager = ref<ComponentPublicInstance<typeof SavesManager> | null>(null);
@ -117,6 +119,10 @@ const { useHeader, banner, title, discordName, discordLink, versionNumber } = pr
function openDiscord() { function openDiscord() {
window.open(discordLink, "mywindow"); window.open(discordLink, "mywindow");
} }
const needsSync = computed(
() => galaxy.value?.loggedIn === true && !syncedSaves.value.includes(settings.active)
);
</script> </script>
<style scoped> <style scoped>
@ -264,4 +270,32 @@ function openDiscord() {
color: var(--foreground); color: var(--foreground);
text-shadow: none; 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> </style>

View file

@ -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>

View file

@ -1,7 +1,7 @@
<template> <template>
<div class="save" :class="{ active: isActive }"> <div class="save" :class="{ active: isActive, readonly }">
<div class="handle material-icons">drag_handle</div> <div class="handle material-icons" v-if="readonly !== true">drag_handle</div>
<div class="actions" v-if="!isEditing"> <div class="actions" v-if="!isEditing && readonly !== true">
<FeedbackButton <FeedbackButton
@click="emit('export')" @click="emit('export')"
class="button" class="button"
@ -40,7 +40,7 @@
</Tooltip> </Tooltip>
</DangerButton> </DangerButton>
</div> </div>
<div class="actions" v-else> <div class="actions" v-else-if="readonly !== true">
<button @click="changeName" class="button"> <button @click="changeName" class="button">
<Tooltip display="Save" :direction="Direction.Left" class="info"> <Tooltip display="Save" :direction="Direction.Left" class="info">
<span class="material-icons">check</span> <span class="material-icons">check</span>
@ -53,12 +53,17 @@
</button> </button>
</div> </div>
<div class="details" v-if="save.error == undefined && !isEditing"> <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> <h3>{{ save.name }}</h3>
</button> </button>
<span class="save-version">v{{ save.modVersion }}</span <span class="save-version">v{{ save.modVersion }}</span
><br /> ><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>
<div class="details" v-else-if="save.error == undefined && isEditing"> <div class="details" v-else-if="save.error == undefined && isEditing">
<Text v-model="newName" class="editname" @submit="changeName" /> <Text v-model="newName" class="editname" @submit="changeName" />
@ -73,16 +78,18 @@
import Tooltip from "features/tooltips/Tooltip.vue"; import Tooltip from "features/tooltips/Tooltip.vue";
import player from "game/player"; import player from "game/player";
import { Direction } from "util/common"; import { Direction } from "util/common";
import { computed, ref, toRefs, watch } from "vue"; import { computed, ref, toRefs, unref, watch } from "vue";
import DangerButton from "./fields/DangerButton.vue"; import DangerButton from "../fields/DangerButton.vue";
import FeedbackButton from "./fields/FeedbackButton.vue"; import FeedbackButton from "../fields/FeedbackButton.vue";
import Text from "./fields/Text.vue"; import Text from "../fields/Text.vue";
import type { LoadablePlayerData } from "./SavesManager.vue"; import type { LoadablePlayerData } from "./SavesManager.vue";
import { galaxy, syncedSaves } from "util/galaxy";
const _props = defineProps<{ const _props = defineProps<{
save: LoadablePlayerData; save: LoadablePlayerData;
readonly?: boolean;
}>(); }>();
const { save } = toRefs(_props); const { save, readonly } = toRefs(_props);
const emit = defineEmits<{ const emit = defineEmits<{
(e: "export"): void; (e: "export"): void;
(e: "open"): void; (e: "open"): void;
@ -106,10 +113,18 @@ const newName = ref("");
watch(isEditing, () => (newName.value = save.value.name ?? "")); 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(() => const currentTime = computed(() =>
isActive.value ? player.time : (save.value != null && save.value.time) ?? 0 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() { function changeName() {
emit("editName", newName.value); emit("editName", newName.value);
@ -139,6 +154,13 @@ function changeName() {
padding-left: 0; padding-left: 0;
} }
.open:disabled {
cursor: inherit;
color: var(--foreground);
opacity: 1;
pointer-events: none;
}
.handle { .handle {
flex-grow: 0; flex-grow: 0;
margin-right: 8px; margin-right: 8px;
@ -152,6 +174,10 @@ function changeName() {
margin-right: 80px; margin-right: 80px;
} }
.save.readonly .details {
margin-right: 0;
}
.error { .error {
font-size: 0.8em; font-size: 0.8em;
color: var(--danger); color: var(--danger);
@ -176,6 +202,17 @@ function changeName() {
.editname { .editname {
margin: 0; margin: 0;
} }
.time {
font-size: small;
}
.synced {
font-size: 100%;
margin-right: 0.5em;
vertical-align: middle;
cursor: default;
}
</style> </style>
<style> <style>
@ -201,4 +238,8 @@ function changeName() {
.save .field { .save .field {
margin: 0; margin: 0;
} }
.details > .tooltip-container {
display: inline;
}
</style> </style>

View file

@ -4,6 +4,9 @@
<h2>Saves Manager</h2> <h2>Saves Manager</h2>
</template> </template>
<template #body="{ shown }"> <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 <Draggable
:list="settings.saves" :list="settings.saves"
handle=".handle" handle=".handle"
@ -63,13 +66,23 @@ 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";
import Save from "./Save.vue"; import Save from "./Save.vue";
import { galaxy, syncedSaves } from "util/galaxy";
export type LoadablePlayerData = Omit<Partial<Player>, "id"> & { id: string; error?: unknown }; export type LoadablePlayerData = Omit<Partial<Player>, "id"> & { id: string; error?: unknown };
@ -90,16 +103,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 +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 // 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();
} }
}); });
@ -191,6 +158,10 @@ const saves = computed(() =>
}, {}) }, {})
); );
const showNotSyncedWarning = computed(
() => galaxy.value?.loggedIn === true && settings.saves.length < syncedSaves.value.length
);
function exportSave(id: string) { function exportSave(id: string) {
let saveToExport; let saveToExport;
if (player.id === id) { if (player.id === id) {
@ -233,20 +204,37 @@ function duplicateSave(id: string) {
} }
function deleteSave(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); 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 +244,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 +267,7 @@ function editSave(id: string, newName: string) {
save(); save();
} else { } else {
save(currSave as Player); save(currSave as Player);
cachedSaves[id] = undefined; clearCachedSave(id);
} }
} }
} }

View file

@ -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;
} }

View file

@ -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 {
/** /**

185
src/util/galaxy.ts Normal file
View file

@ -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;
}

View file

@ -1,10 +1,11 @@
import { LoadablePlayerData } from "components/saves/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";
export function setupInitialStore(player: Partial<Player> = {}): Player { export function setupInitialStore(player: Partial<Player> = {}): Player {
return Object.assign( return Object.assign(
@ -42,17 +43,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 +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 { export function newSave(): Player {
const id = getUniqueID(); const id = getUniqueID();
const player = setupInitialStore({ id }); const player = setupInitialStore({ id });
@ -127,6 +137,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();