Merge remote-tracking branch 'root/main'
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 55s
Run Tests / test (push) Successful in 2m5s

This commit is contained in:
escapee 2024-04-01 17:05:50 -07:00
commit 91378ad4b0
40 changed files with 2282 additions and 556 deletions

View file

@ -27,6 +27,13 @@ module.exports = {
allowNullableObject: true, allowNullableObject: true,
allowNullableBoolean: true allowNullableBoolean: true
} }
],
"eqeqeq": [
"error",
"always",
{
"null": "never"
}
] ]
}, },
globals: { globals: {

View file

@ -19,3 +19,4 @@ jobs:
- run: npm ci - run: npm ci
- run: npm run build --if-present - run: npm run build --if-present
- run: npm test - run: npm test
- run: npm run lint

View file

@ -19,3 +19,4 @@ jobs:
- run: npm ci - run: npm ci
- run: npm run build --if-present - run: npm run build --if-present
- run: npm test - run: npm test
- run: npm run lint

View file

@ -1,7 +1,7 @@
{ {
"vitest.commandLine": "npx vitest", "vitest.commandLine": "npx vitest",
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": true "source.fixAll.eslint": "explicit"
}, },
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"git.ignoreLimitWarning": true, "git.ignoreLimitWarning": true,

View file

@ -6,6 +6,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.6.2] - 2024-04-01
### Added
- Export save button in error boundaries
- isRendered utility function
- Automatic galaxy.click cloud saves support
- Support for null and undefined in persistent refs
### Changes
- round, floor, ceil, trunc, and add now invert as no-ops
- "The Paper Pilot Community" renamed to "Profectus & Friends"
- Updated CI etc. to work with Forgejo
- Improved modifier typing
- Rename `printFormula` to `Formula.stringify`
### Fixed
- Hotkeys not working correctly with most combinations of modifiers
- Reset button using `currentAt` when not gaining
- Formulas not using modifiers that are disabled initially
- branchedResetPropagation logic being incorrect
- Fixed default elementsd in the main layer not updating Context when being added or removed
- Board links props not working in camelCase
- Board links absorbing pointer events
- Thrown errors not appearing in console
- Disabled elements would eat mouse events
- Fixed cost requirement without formula counting as being able to afford infinite purchases rather than just one
- Pinnable tooltips causing innocuous console error
- Bars with direction as "Left" wouldn't appear correctly
### Documentation
- Clarified expected progress values for board nodes
- Added CONTRIBUTING.md and enforce eslint on all PRs
### Tests
- Update formula test cases
- Tree reset propagation
Contributors: thepaperpilot, escapee, nif
## [0.6.1] - 2023-05-17 ## [0.6.1] - 2023-05-17
### Added ### Added
- Error boundaries around each layer, and errors now display on the page when in development - Error boundaries around each layer, and errors now display on the page when in development

31
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,31 @@
# Contributing to Profectus
Thank you for considering contributing to Profectus! We appreciate your interest in improving our project. Please take a moment to review the following guidelines to streamline the contribution process.
## Getting Started
For detailed instructions on setting up local development environment, please refer to the [Setup Guide](https://moddingtree.com/guide/getting-started/setup).
## Issue Reporting
If you encounter a bug or have a suggestion for improvement, please open an issue on Incremental Social. Provide as much detail as possible, including an example repo or steps to reproduce the issue if applicable.
## Contributing
Make sure to open your PR on [Incremental Social](https://code.incremental.social/profectus/Profectus) - the GitHub repo is just a mirror!
### Code Review
All PRs must be reviewed and approved by at least one of the project maintainers before merging. Please be patient during the review process and be open to feedback.
### Testing
Ensure that your changes pass all existing tests and, if applicable, add new tests to cover the changes you've made. Run `npm run test` to run all the tests.
### Code Style
We use ESLint and Prettier to enforce consistent code style throughout the project. Before submitting a PR, run `npm run lint:fix` to automatically fix any linting issues.
## License
By contributing to Profectus, you agree that your contributions will be licensed under the project's [LICENSE](./LICENSE).

1729
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{ {
"name": "profectus", "name": "profectus",
"version": "0.6.1", "version": "0.6.2",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "vite", "start": "vite",
@ -9,7 +9,9 @@
"preview": "vite preview", "preview": "vite preview",
"test": "vitest run", "test": "vitest run",
"testw": "vitest", "testw": "vitest",
"serve": "vite preview --host" "serve": "vite preview --host",
"lint": "eslint src --max-warnings 0",
"lint:fix": "eslint --fix --max-warnings 0 src"
}, },
"dependencies": { "dependencies": {
"@fontsource/material-icons": "^4.5.4", "@fontsource/material-icons": "^4.5.4",
@ -27,6 +29,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",
@ -48,7 +51,7 @@
"jsdom": "^20.0.0", "jsdom": "^20.0.0",
"prettier": "^2.5.1", "prettier": "^2.5.1",
"typescript": "^5.0.2", "typescript": "^5.0.2",
"vitest": "^0.29.3", "vitest": "^1.3.1",
"vue-tsc": "^0.38.1" "vue-tsc": "^0.38.1"
}, },
"engines": { "engines": {

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,7 +56,9 @@ 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

@ -208,7 +208,7 @@ export function createAchievement<T extends AchievementOptions>(
unref(achievement.earned) && unref(achievement.earned) &&
!( !(
display != null && display != null &&
typeof display == "object" && typeof display === "object" &&
"optionsDisplay" in (display as Record<string, unknown>) "optionsDisplay" in (display as Record<string, unknown>)
) )
) { ) {

View file

@ -31,7 +31,7 @@ import { coerceComponent, isCoercableComponent, render } from "util/vue";
import { computed, Ref, ref, unref } from "vue"; import { computed, Ref, ref, unref } from "vue";
import { BarOptions, createBar, GenericBar } from "./bars/bar"; import { BarOptions, createBar, GenericBar } from "./bars/bar";
import { ClickableOptions } from "./clickables/clickable"; import { ClickableOptions } from "./clickables/clickable";
import { Decorator, GenericDecorator } from "./decorators/common"; import { GenericDecorator } from "./decorators/common";
/** A symbol used to identify {@link Action} features. */ /** A symbol used to identify {@link Action} features. */
export const ActionType = Symbol("Action"); export const ActionType = Symbol("Action");

View file

@ -92,7 +92,7 @@ export function setDefault<T, K extends keyof T>(
key: K, key: K,
value: T[K] value: T[K]
): asserts object is Exclude<T, K> & Required<Pick<T, K>> { ): asserts object is Exclude<T, K> & Required<Pick<T, K>> {
if (object[key] === undefined && value != undefined) { if (object[key] == null && value != null) {
object[key] = value; object[key] = value;
} }
} }
@ -135,7 +135,7 @@ export function excludeFeatures(obj: Record<string, unknown>, ...types: symbol[]
if (value != null && typeof value === "object") { if (value != null && typeof value === "object") {
if ( if (
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
typeof (value as Record<string, any>).type == "symbol" && typeof (value as Record<string, any>).type === "symbol" &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
!types.includes((value as Record<string, any>).type) !types.includes((value as Record<string, any>).type)
) { ) {

View file

@ -128,7 +128,7 @@ function getCellHandler(id: string): ProxyHandler<GenericGrid> {
if (isFunction(prop)) { if (isFunction(prop)) {
return () => prop.call(receiver, id, target.getState(id)); return () => prop.call(receiver, id, target.getState(id));
} }
if (prop != undefined || typeof key === "symbol") { if (prop != null || typeof key === "symbol") {
return prop; return prop;
} }
@ -145,7 +145,7 @@ function getCellHandler(id: string): ProxyHandler<GenericGrid> {
cache[key] = computed(() => prop.call(receiver, id, target.getState(id))); cache[key] = computed(() => prop.call(receiver, id, target.getState(id)));
} }
return cache[key].value; return cache[key].value;
} else if (prop != undefined) { } else if (prop != null) {
return unref(prop); return unref(prop);
} }
@ -153,7 +153,7 @@ function getCellHandler(id: string): ProxyHandler<GenericGrid> {
prop = (target as any)[`on${key}`]; prop = (target as any)[`on${key}`];
if (isFunction(prop)) { if (isFunction(prop)) {
return () => prop.call(receiver, id, target.getState(id)); return () => prop.call(receiver, id, target.getState(id));
} else if (prop != undefined) { } else if (prop != null) {
return prop; return prop;
} }
@ -318,7 +318,7 @@ export function createGrid<T extends GridOptions>(
return grid.id + "-" + cell; return grid.id + "-" + cell;
}; };
grid.getState = function (this: GenericGrid, cell: string | number) { grid.getState = function (this: GenericGrid, cell: string | number) {
if (this.cellState.value[cell] != undefined) { if (this.cellState.value[cell] != null) {
return cellState.value[cell]; return cellState.value[cell];
} }
return this.cells[cell].startState; return this.cells[cell].startState;

View file

@ -99,16 +99,30 @@ document.onkeydown = function (e) {
if (hasWon.value && !player.keepGoing) { if (hasWon.value && !player.keepGoing) {
return; return;
} }
let key = e.key; const keysToCheck: string[] = [e.key];
if (uppercaseNumbers.includes(key)) { if (e.shiftKey && e.ctrlKey) {
key = "shift+" + uppercaseNumbers.indexOf(key); keysToCheck.splice(0, 1);
keysToCheck.push("ctrl+shift+" + e.key.toUpperCase());
keysToCheck.push("shift+ctrl+" + e.key.toUpperCase());
if (uppercaseNumbers.includes(e.key)) {
keysToCheck.push("ctrl+shift+" + uppercaseNumbers.indexOf(e.key));
keysToCheck.push("shift+ctrl+" + uppercaseNumbers.indexOf(e.key));
} else {
keysToCheck.push("ctrl+shift+" + e.key.toLowerCase());
keysToCheck.push("shift+ctrl+" + e.key.toLowerCase());
}
} else if (uppercaseNumbers.includes(e.key)) {
keysToCheck.push("shift+" + e.key);
keysToCheck.push("shift+" + uppercaseNumbers.indexOf(e.key));
} else if (e.shiftKey) { } else if (e.shiftKey) {
key = "shift+" + key; keysToCheck.push("shift+" + e.key.toUpperCase());
keysToCheck.push("shift+" + e.key.toLowerCase());
} else if (e.ctrlKey) {
// remove e.key since the key doesn't change based on ctrl being held or not
keysToCheck.splice(0, 1);
keysToCheck.push("ctrl+" + e.key);
} }
if (e.ctrlKey) { const hotkey = hotkeys[keysToCheck.find(key => key in hotkeys) ?? ""];
key = "ctrl+" + key;
}
const hotkey = hotkeys[key] ?? hotkeys[key.toLowerCase()];
if (hotkey && unref(hotkey.enabled)) { if (hotkey && unref(hotkey.enabled)) {
e.preventDefault(); e.preventDefault();
hotkey.onPress(); hotkey.onPress();

View file

@ -1,6 +1,6 @@
import type { CoercableComponent, GenericComponent, Replace, StyleValue } from "features/feature"; import type { CoercableComponent, GenericComponent, Replace, StyleValue } from "features/feature";
import { Component, GatherProps, setDefault } from "features/feature"; import { Component, GatherProps, setDefault } from "features/feature";
import { deletePersistent, Persistent, persistent } from "game/persistence"; import { persistent } from "game/persistence";
import { Direction } from "util/common"; import { Direction } from "util/common";
import type { import type {
Computable, Computable,

View file

@ -1,4 +1,4 @@
import { Decorator, GenericDecorator } from "features/decorators/common"; import { GenericDecorator } from "features/decorators/common";
import type { import type {
CoercableComponent, CoercableComponent,
GenericComponent, GenericComponent,
@ -342,15 +342,15 @@ export const branchedResetPropagation = function (
if (links == null) return; if (links == null) return;
const reset: GenericTreeNode[] = []; const reset: GenericTreeNode[] = [];
let current = [resettingNode]; let current = [resettingNode];
while (current.length != 0) { while (current.length !== 0) {
const next: GenericTreeNode[] = []; const next: GenericTreeNode[] = [];
for (const node of current) { for (const node of current) {
for (const link of links.filter(link => link.startNode === node)) { for (const link of links.filter(link => link.startNode === node)) {
if ([...reset, ...current].includes(link.endNode)) continue if ([...reset, ...current].includes(link.endNode)) continue;
next.push(link.endNode); next.push(link.endNode);
link.endNode.reset?.reset(); link.endNode.reset?.reset();
} }
}; }
reset.push(...current); reset.push(...current);
current = next; current = next;
} }

View file

@ -56,6 +56,7 @@ export abstract class InternalFormula<T extends [FormulaSource] | FormulaSource[
protected readonly internalIntegrate: IntegrateFunction<T> | undefined; protected readonly internalIntegrate: IntegrateFunction<T> | undefined;
protected readonly internalIntegrateInner: IntegrateFunction<T> | undefined; protected readonly internalIntegrateInner: IntegrateFunction<T> | undefined;
protected readonly applySubstitution: SubstitutionFunction<T> | undefined; protected readonly applySubstitution: SubstitutionFunction<T> | undefined;
protected readonly description: string | undefined;
protected readonly internalVariables: number; protected readonly internalVariables: number;
public readonly innermostVariable: ProcessedComputable<DecimalSource> | undefined; public readonly innermostVariable: ProcessedComputable<DecimalSource> | undefined;
@ -85,6 +86,7 @@ export abstract class InternalFormula<T extends [FormulaSource] | FormulaSource[
this.internalIntegrate = readonlyProperties.internalIntegrate; this.internalIntegrate = readonlyProperties.internalIntegrate;
this.internalIntegrateInner = readonlyProperties.internalIntegrateInner; this.internalIntegrateInner = readonlyProperties.internalIntegrateInner;
this.applySubstitution = readonlyProperties.applySubstitution; this.applySubstitution = readonlyProperties.applySubstitution;
this.description = options.description;
} }
private setupVariable({ private setupVariable({
@ -216,6 +218,25 @@ export abstract class InternalFormula<T extends [FormulaSource] | FormulaSource[
return new Formula({ variable: value }); return new Formula({ variable: value });
} }
/**
* Stringifies the formula so it's more easy to read in the console
* @param formula The formula source to print, used for mapping inputs
*/
public static stringify(formula: FormulaSource): string {
if (formula instanceof InternalFormula) {
if (formula.description != null) {
return formula.description;
}
if (formula.internalEvaluate == null) {
return formula.hasVariable() ? "x" : format(formula.inputs[0] ?? 0);
}
return `${formula.internalEvaluate.name}(${formula.inputs
.map(Formula.stringify)
.join(", ")})`;
}
return format(unref(formula));
}
// TODO add integration support to step-wise functions // TODO add integration support to step-wise functions
/** /**
* Creates a step-wise formula. After {@link start} the formula will have an additional modifier. * Creates a step-wise formula. After {@link start} the formula will have an additional modifier.
@ -256,7 +277,9 @@ export abstract class InternalFormula<T extends [FormulaSource] | FormulaSource[
return new Formula({ return new Formula({
inputs: [value], inputs: [value],
evaluate: evalStep, evaluate: evalStep,
invert: formula.isInvertible() && formula.hasVariable() ? invertStep : undefined invert: formula.isInvertible() && formula.hasVariable() ? invertStep : undefined,
// Can't do anything more descriptive, due to formula's input always being a variable
description: "indeterminate"
}); });
} }
@ -309,7 +332,9 @@ export abstract class InternalFormula<T extends [FormulaSource] | FormulaSource[
return new Formula({ return new Formula({
inputs: [value], inputs: [value],
evaluate: evalStep, evaluate: evalStep,
invert: formula.isInvertible() && formula.hasVariable() ? invertStep : undefined invert: formula.isInvertible() && formula.hasVariable() ? invertStep : undefined,
// Can't do anything more descriptive, due to formula's input always being a variable
description: "indeterminate"
}); });
} }
public static conditional( public static conditional(
@ -878,6 +903,10 @@ export abstract class InternalFormula<T extends [FormulaSource] | FormulaSource[
}); });
} }
public stringify() {
return Formula.stringify(this);
}
public step( public step(
start: Computable<DecimalSource>, start: Computable<DecimalSource>,
formulaModifier: (value: InvertibleIntegralFormula) => GenericFormula formulaModifier: (value: InvertibleIntegralFormula) => GenericFormula
@ -1402,28 +1431,6 @@ export function findNonInvertible(formula: GenericFormula): GenericFormula | nul
return null; return null;
} }
/**
* Stringifies a formula so it's more easy to read in the console
* @param formula The formula to print
*/
export function printFormula(formula: FormulaSource): string {
if (formula instanceof InternalFormula) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return formula.internalEvaluate == null
? formula.hasVariable()
? "x"
: formula.inputs[0] ?? 0
: // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
formula.internalEvaluate.name +
"(" +
formula.inputs.map(printFormula).join(", ") +
")";
}
return format(unref(formula));
}
/** /**
* Utility for calculating the maximum amount of purchases possible with a given formula and resource. If {@link cumulativeCost} is changed to false, the calculation will be much faster with higher numbers. * Utility for calculating the maximum amount of purchases possible with a given formula and resource. If {@link cumulativeCost} is changed to false, the calculation will be much faster with higher numbers.
* @param formula The formula to use for calculating buy max from * @param formula The formula to use for calculating buy max from

View file

@ -552,7 +552,9 @@ export function tetrate(
export function invertTetrate( export function invertTetrate(
value: DecimalSource, value: DecimalSource,
base: FormulaSource, base: FormulaSource,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
height: FormulaSource, height: FormulaSource,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
payload: FormulaSource payload: FormulaSource
) { ) {
if (hasVariable(base)) { if (hasVariable(base)) {
@ -576,6 +578,7 @@ export function invertIteratedExp(
value: DecimalSource, value: DecimalSource,
lhs: FormulaSource, lhs: FormulaSource,
height: FormulaSource, height: FormulaSource,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
payload: FormulaSource payload: FormulaSource
) { ) {
if (hasVariable(lhs)) { if (hasVariable(lhs)) {
@ -626,6 +629,7 @@ export function invertLayeradd(
value: DecimalSource, value: DecimalSource,
lhs: FormulaSource, lhs: FormulaSource,
diff: FormulaSource, diff: FormulaSource,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
base: FormulaSource base: FormulaSource
) { ) {
if (hasVariable(lhs)) { if (hasVariable(lhs)) {

View file

@ -37,9 +37,13 @@ type SubstitutionFunction<T> = (
...inputs: T ...inputs: T
) => GenericFormula; ) => GenericFormula;
type VariableFormulaOptions = { variable: ProcessedComputable<DecimalSource> }; type VariableFormulaOptions = {
variable: ProcessedComputable<DecimalSource>;
description?: string;
};
type ConstantFormulaOptions = { type ConstantFormulaOptions = {
inputs: [FormulaSource]; inputs: [FormulaSource];
description?: string;
}; };
type GeneralFormulaOptions<T extends [FormulaSource] | FormulaSource[]> = { type GeneralFormulaOptions<T extends [FormulaSource] | FormulaSource[]> = {
inputs: T; inputs: T;
@ -48,6 +52,7 @@ type GeneralFormulaOptions<T extends [FormulaSource] | FormulaSource[]> = {
integrate?: IntegrateFunction<T>; integrate?: IntegrateFunction<T>;
integrateInner?: IntegrateFunction<T>; integrateInner?: IntegrateFunction<T>;
applySubstitution?: SubstitutionFunction<T>; applySubstitution?: SubstitutionFunction<T>;
description?: string;
}; };
type FormulaOptions<T extends [FormulaSource] | FormulaSource[]> = type FormulaOptions<T extends [FormulaSource] | FormulaSource[]> =
| VariableFormulaOptions | VariableFormulaOptions
@ -63,6 +68,7 @@ type InternalFormulaProperties<T extends [FormulaSource] | FormulaSource[]> = {
internalIntegrateInner?: IntegrateFunction<T>; internalIntegrateInner?: IntegrateFunction<T>;
applySubstitution?: SubstitutionFunction<T>; applySubstitution?: SubstitutionFunction<T>;
innermostVariable?: ProcessedComputable<DecimalSource>; innermostVariable?: ProcessedComputable<DecimalSource>;
description?: string;
}; };
type SubstitutionStack = ((value: GenericFormula) => GenericFormula)[] | undefined; type SubstitutionStack = ((value: GenericFormula) => GenericFormula)[] | undefined;

View file

@ -43,7 +43,7 @@ function update() {
loadingSave.value = false; loadingSave.value = false;
// Add offline time if any // Add offline time if any
if (player.offlineTime != undefined) { if (player.offlineTime != null) {
if (Decimal.gt(player.offlineTime, projInfo.offlineLimit * 3600)) { if (Decimal.gt(player.offlineTime, projInfo.offlineLimit * 3600)) {
player.offlineTime = projInfo.offlineLimit * 3600; player.offlineTime = projInfo.offlineLimit * 3600;
} }
@ -63,7 +63,7 @@ function update() {
diff = Math.min(diff, projInfo.maxTickLength); diff = Math.min(diff, projInfo.maxTickLength);
// Apply dev speed // Apply dev speed
if (player.devSpeed != undefined) { if (player.devSpeed != null) {
diff *= player.devSpeed; diff *= player.devSpeed;
} }

View file

@ -62,6 +62,8 @@ export type State =
| number | number
| boolean | boolean
| DecimalSource | DecimalSource
| null
| undefined
| { [key: string]: State } | { [key: string]: State }
| { [key: number]: State }; | { [key: number]: State };
@ -227,7 +229,7 @@ export function noPersist<T extends Persistent<S>, S extends State>(persistent:
if (key === PersistentState) { if (key === PersistentState) {
return false; return false;
} }
if (key == SkipPersistence) { if (key === SkipPersistence) {
return true; return true;
} }
return Reflect.has(target, key); return Reflect.has(target, key);
@ -279,7 +281,7 @@ globalBus.on("addLayer", (layer: GenericLayer, saveData: Record<string, unknown>
// Handle SaveDataPath // Handle SaveDataPath
const newPath = [layer.id, ...path, key]; const newPath = [layer.id, ...path, key];
if ( if (
value[SaveDataPath] != undefined && value[SaveDataPath] != null &&
JSON.stringify(newPath) !== JSON.stringify(value[SaveDataPath]) JSON.stringify(newPath) !== JSON.stringify(value[SaveDataPath])
) { ) {
console.error( console.error(

View file

@ -64,7 +64,8 @@ export default window.player = player;
/** Convert a player save data object into a JSON string. Unwraps refs. */ /** Convert a player save data object into a JSON string. Unwraps refs. */
export function stringifySave(player: Player): string { export function stringifySave(player: Player): string {
return JSON.stringify(player, (key, value) => unref(value)); // Convert undefineds into nulls for proper parsing
return JSON.stringify(player, (key, value) => unref(value) ?? null);
} }
declare global { declare global {

View file

@ -222,7 +222,9 @@ export function createCostRequirement<T extends CostRequirementOptions>(
Decimal.gte( Decimal.gte(
req.resource.value, req.resource.value,
unref(req.cost as ProcessedComputable<DecimalSource>) unref(req.cost as ProcessedComputable<DecimalSource>)
) ? 1 : 0 )
? 1
: 0
); );
} }

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

@ -66,3 +66,7 @@ ul {
.Vue-Toastification__toast { .Vue-Toastification__toast {
margin: unset; margin: unset;
} }
:disabled {
pointer-events: none;
}

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

View file

@ -26,7 +26,7 @@ export function exponentialFormat(num: DecimalSource, precision: number, mantiss
} }
export function commaFormat(num: DecimalSource, precision: number): string { export function commaFormat(num: DecimalSource, precision: number): string {
if (num === null || num === undefined) { if (num == null) {
return "NaN"; return "NaN";
} }
num = new Decimal(num); num = new Decimal(num);
@ -36,12 +36,12 @@ export function commaFormat(num: DecimalSource, precision: number): string {
const init = num.toStringWithDecimalPlaces(precision); const init = num.toStringWithDecimalPlaces(precision);
const portions = init.split("."); const portions = init.split(".");
portions[0] = portions[0].replace(/(\d)(?=(\d\d\d)+(?!\d))/g, "$1,"); portions[0] = portions[0].replace(/(\d)(?=(\d\d\d)+(?!\d))/g, "$1,");
if (portions.length == 1) return portions[0]; if (portions.length === 1) return portions[0];
return portions[0] + "." + portions[1]; return portions[0] + "." + portions[1];
} }
export function regularFormat(num: DecimalSource, precision: number): string { export function regularFormat(num: DecimalSource, precision: number): string {
if (num === null || num === undefined) { if (num == null) {
return "NaN"; return "NaN";
} }
num = new Decimal(num); num = new Decimal(num);

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 == null) {
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 });
@ -109,7 +119,7 @@ export async function loadSave(playerObj: Partial<Player>): Promise<void> {
playerObj.time && playerObj.time &&
playerObj.devSpeed !== 0 playerObj.devSpeed !== 0
) { ) {
if (playerObj.offlineTime == undefined) playerObj.offlineTime = 0; if (playerObj.offlineTime == null) playerObj.offlineTime = 0;
playerObj.offlineTime += Math.min( playerObj.offlineTime += Math.min(
playerObj.offlineTime + (Date.now() - playerObj.time) / 1000, playerObj.offlineTime + (Date.now() - playerObj.time) / 1000,
projInfo.offlineLimit * 3600 projInfo.offlineLimit * 3600
@ -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();

View file

@ -191,7 +191,7 @@ export function computeOptionalComponent(
watchEffect(() => { watchEffect(() => {
const currComponent = unwrapRef(component); const currComponent = unwrapRef(component);
comp.value = comp.value =
currComponent == "" || currComponent == null currComponent === "" || currComponent == null
? null ? null
: coerceComponent(currComponent, defaultWrapper); : coerceComponent(currComponent, defaultWrapper);
}); });

View file

@ -0,0 +1,100 @@
import { createHotkey, hotkeys } from "features/hotkey";
import { afterEach, describe, expect, onTestFailed, test } from "vitest";
import { Ref, ref } from "vue";
import "../utils";
function createSuccessHotkey(key: string, triggered: Ref<boolean>) {
hotkeys[key] = createHotkey(() => ({
description: "",
key: key,
onPress: () => (triggered.value = true)
}));
}
function createFailHotkey(key: string) {
hotkeys[key] = createHotkey(() => ({
description: "Fail test",
key,
onPress: () => expect(true).toBe(false)
}));
}
function mockKeypress(key: string, shiftKey = false, ctrlKey = false) {
const event = new KeyboardEvent("keydown", { key, shiftKey, ctrlKey });
expect(document.dispatchEvent(event)).toBe(true);
return event;
}
function testHotkey(pass: string, fail: string, key: string, shiftKey = false, ctrlKey = false) {
const triggered = ref(false);
createSuccessHotkey(pass, triggered);
createFailHotkey(fail);
mockKeypress(key, shiftKey, ctrlKey);
expect(triggered.value).toBe(true);
}
describe("Hotkeys fire correctly", () => {
afterEach(() => {
Object.keys(hotkeys).forEach(key => delete hotkeys[key]);
});
test("Lower case letters", () => testHotkey("a", "A", "a"));
test.each([["A"], ["shift+a"], ["shift+A"]])("Upper case letters using %s as key", key => {
testHotkey(key, "a", "A", true);
});
describe.each([
[0, ")"],
[1, "!"],
[2, "@"],
[3, "#"],
[4, "$"],
[5, "%"],
[6, "^"],
[7, "&"],
[8, "*"],
[9, "("]
])("Handle number %i and it's 'capital', %s", (number, symbol) => {
test("Triggering number", () =>
testHotkey(number.toString(), symbol, number.toString(), true));
test.each([symbol, `shift+${number}`, `shift+${symbol}`])(
"Triggering symbol using %s as key",
key => testHotkey(key, number.toString(), symbol, true)
);
});
test("Ctrl modifier", () => testHotkey("ctrl+a", "a", "a", false, true));
test.each(["shift+ctrl+a", "ctrl+shift+a", "shift+ctrl+A", "ctrl+shift+A"])(
"Shift and Ctrl modifiers using %s as key",
key => {
const triggered = ref(false);
createSuccessHotkey(key, triggered);
createFailHotkey("a");
createFailHotkey("A");
createFailHotkey("shift+A");
createFailHotkey("shift+a");
createFailHotkey("ctrl+a");
createFailHotkey("ctrl+A");
mockKeypress("a", true, true);
expect(triggered.value).toBe(true);
}
);
test.each(["shift+ctrl+1", "ctrl+shift+1", "shift+ctrl+!", "ctrl+shift+!"])(
"Shift and Ctrl modifiers using %s as key",
key => {
const triggered = ref(false);
createSuccessHotkey(key, triggered);
createFailHotkey("1");
createFailHotkey("!");
createFailHotkey("shift+1");
createFailHotkey("shift+!");
createFailHotkey("ctrl+1");
createFailHotkey("ctrl+!");
mockKeypress("!", true, true);
expect(triggered.value).toBe(true);
}
);
});

View file

@ -155,7 +155,7 @@ describe("Formula Equality Checking", () => {
describe("Formula aliases", () => { describe("Formula aliases", () => {
function testAliases<T extends FormulaFunctions>( function testAliases<T extends FormulaFunctions>(
aliases: T[], aliases: T[],
args: Parameters<typeof Formula[T]> args: Parameters<(typeof Formula)[T]>
) { ) {
describe(aliases[0], () => { describe(aliases[0], () => {
let formula: GenericFormula; let formula: GenericFormula;
@ -250,7 +250,7 @@ describe("Creating Formulas", () => {
function checkFormula<T extends FormulaFunctions>( function checkFormula<T extends FormulaFunctions>(
functionName: T, functionName: T,
args: Readonly<Parameters<typeof Formula[T]>> args: Readonly<Parameters<(typeof Formula)[T]>>
) { ) {
let formula: GenericFormula; let formula: GenericFormula;
beforeAll(() => { beforeAll(() => {
@ -274,7 +274,7 @@ describe("Creating Formulas", () => {
// It's a lot of tests, but I'd rather be exhaustive // It's a lot of tests, but I'd rather be exhaustive
function testFormulaCall<T extends FormulaFunctions>( function testFormulaCall<T extends FormulaFunctions>(
functionName: T, functionName: T,
args: Readonly<Parameters<typeof Formula[T]>> args: Readonly<Parameters<(typeof Formula)[T]>>
) { ) {
if ((functionName === "slog" || functionName === "layeradd") && args[0] === -1) { if ((functionName === "slog" || functionName === "layeradd") && args[0] === -1) {
// These cases in particular take a long time, so skip them // These cases in particular take a long time, so skip them
@ -1275,3 +1275,16 @@ describe("Buy Max", () => {
}); });
}); });
}); });
describe("Stringifies", () => {
test("Nested formula", () => {
const variable = Formula.variable(ref(0));
expect(variable.add(5).pow(Formula.constant(10)).stringify()).toBe(
"pow(add(x, 5.00), 10.00)"
);
});
test("Indeterminate", () => {
expect(Formula.if(10, true, f => f.add(5)).stringify()).toBe("indeterminate");
expect(Formula.step(10, 5, f => f.add(5)).stringify()).toBe("indeterminate");
});
});

View file

@ -1,5 +1,5 @@
import { CoercableComponent, JSXFunction } from "features/feature"; import { CoercableComponent, JSXFunction } from "features/feature";
import Formula, { printFormula } from "game/formulas/formulas"; import Formula from "game/formulas/formulas";
import { import {
createAdditiveModifier, createAdditiveModifier,
createExponentialModifier, createExponentialModifier,
@ -52,7 +52,7 @@ function testModifiers<
expect(modifier.invert(operation(10, 5))).compare_tolerance(10)); expect(modifier.invert(operation(10, 5))).compare_tolerance(10));
test("getFormula returns the right formula", () => { test("getFormula returns the right formula", () => {
const value = ref(10); const value = ref(10);
expect(printFormula(modifier.getFormula(Formula.variable(value)))).toBe( expect(modifier.getFormula(Formula.variable(value)).stringify()).toBe(
`${operation.name}(x, 5.00)` `${operation.name}(x, 5.00)`
); );
}); });
@ -156,7 +156,7 @@ describe("Sequential Modifiers", () => {
expect(modifier.invert(Decimal.add(10, 5).times(5).pow(5))).compare_tolerance(10)); expect(modifier.invert(Decimal.add(10, 5).times(5).pow(5))).compare_tolerance(10));
test("getFormula returns the right formula", () => { test("getFormula returns the right formula", () => {
const value = ref(10); const value = ref(10);
expect(printFormula(modifier.getFormula(Formula.variable(value)))).toBe( expect(modifier.getFormula(Formula.variable(value)).stringify()).toBe(
`pow(mul(add(x, 5.00), 5.00), 5.00)` `pow(mul(add(x, 5.00), 5.00), 5.00)`
); );
}); });

View file

@ -6,14 +6,11 @@ interface CustomMatchers<R = unknown> {
toLogError(): R; toLogError(): R;
} }
declare global { declare module "vitest" {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Vi {
// eslint-disable-next-line @typescript-eslint/no-empty-interface // eslint-disable-next-line @typescript-eslint/no-empty-interface
interface Assertion extends CustomMatchers {} interface Assertion extends CustomMatchers {}
// eslint-disable-next-line @typescript-eslint/no-empty-interface // eslint-disable-next-line @typescript-eslint/no-empty-interface
interface AsymmetricMatchersContaining extends CustomMatchers {} interface AsymmetricMatchersContaining extends CustomMatchers {}
}
} }
expect.extend({ expect.extend({