forked from profectus/Profectus
Merge branch 'main' into feature/query-params
This commit is contained in:
commit
7088cff783
31 changed files with 916 additions and 188 deletions
|
@ -19,3 +19,4 @@ jobs:
|
|||
- run: npm ci
|
||||
- run: npm run build --if-present
|
||||
- run: npm test
|
||||
- run: npm run lint
|
||||
|
|
1
.github/workflows/test.yml
vendored
1
.github/workflows/test.yml
vendored
|
@ -19,3 +19,4 @@ jobs:
|
|||
- run: npm ci
|
||||
- run: npm run build --if-present
|
||||
- run: npm test
|
||||
- run: npm run lint
|
||||
|
|
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"vitest.commandLine": "npx vitest",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"git.ignoreLimitWarning": true,
|
||||
|
|
31
CONTRIBUTING.md
Normal file
31
CONTRIBUTING.md
Normal 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).
|
5
package-lock.json
generated
5
package-lock.json
generated
|
@ -23,6 +23,7 @@
|
|||
"is-plain-object": "^5.0.0",
|
||||
"lz-string": "^1.4.4",
|
||||
"nanoevents": "^6.0.2",
|
||||
"unofficial-galaxy-sdk": "git+https://code.incremental.social/thepaperpilot/unofficial-galaxy-sdk.git#1.0.1",
|
||||
"vite": "^2.9.12",
|
||||
"vite-plugin-pwa": "^0.12.0",
|
||||
"vite-tsconfig-paths": "^3.5.0",
|
||||
|
@ -6878,6 +6879,10 @@
|
|||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unofficial-galaxy-sdk": {
|
||||
"version": "1.0",
|
||||
"resolved": "git+https://code.incremental.social/thepaperpilot/unofficial-galaxy-sdk.git#97d6da6636a2fc38c14aa893d4b336ccc22314af"
|
||||
},
|
||||
"node_modules/upath": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz",
|
||||
|
|
|
@ -9,7 +9,9 @@
|
|||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"testw": "vitest",
|
||||
"serve": "vite preview --host"
|
||||
"serve": "vite preview --host",
|
||||
"lint": "eslint src --max-warnings 0",
|
||||
"lint:fix": "eslint --fix --max-warnings 0 src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/material-icons": "^4.5.4",
|
||||
|
@ -27,6 +29,7 @@
|
|||
"is-plain-object": "^5.0.0",
|
||||
"lz-string": "^1.4.4",
|
||||
"nanoevents": "^6.0.2",
|
||||
"unofficial-galaxy-sdk": "git+https://code.incremental.social/thepaperpilot/unofficial-galaxy-sdk.git#1.0.1",
|
||||
"vite": "^2.9.12",
|
||||
"vite-plugin-pwa": "^0.12.0",
|
||||
"vite-tsconfig-paths": "^3.5.0",
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
<TPS v-if="unref(showTPS)" />
|
||||
<GameOverScreen />
|
||||
<NaNScreen />
|
||||
<CloudSaveResolver />
|
||||
<component :is="gameComponent" />
|
||||
</div>
|
||||
</template>
|
||||
|
@ -16,10 +17,11 @@
|
|||
<script setup lang="tsx">
|
||||
import "@fontsource/roboto-mono";
|
||||
import Error from "components/Error.vue";
|
||||
import CloudSaveResolver from "components/saves/CloudSaveResolver.vue";
|
||||
import { jsx } from "features/feature";
|
||||
import state from "game/state";
|
||||
import { coerceComponent, render } from "util/vue";
|
||||
import { CSSProperties, watch } from "vue";
|
||||
import type { CSSProperties } from "vue";
|
||||
import { computed, toRef, unref } from "vue";
|
||||
import Game from "./components/Game.vue";
|
||||
import GameOverScreen from "./components/GameOverScreen.vue";
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
name="modal"
|
||||
@before-enter="isAnimating = true"
|
||||
@after-leave="isAnimating = false"
|
||||
appear
|
||||
>
|
||||
<div
|
||||
class="modal-mask"
|
||||
|
@ -12,7 +13,7 @@
|
|||
v-bind="$attrs"
|
||||
>
|
||||
<div class="modal-wrapper">
|
||||
<div class="modal-container">
|
||||
<div class="modal-container" :width="width">
|
||||
<div class="modal-header">
|
||||
<slot name="header" :shown="isOpen"> default header </slot>
|
||||
</div>
|
||||
|
@ -45,6 +46,8 @@ import Context from "./Context.vue";
|
|||
|
||||
const _props = defineProps<{
|
||||
modelValue: boolean;
|
||||
preventClosing?: boolean;
|
||||
width?: string;
|
||||
}>();
|
||||
const props = toRefs(_props);
|
||||
const emit = defineEmits<{
|
||||
|
@ -53,7 +56,9 @@ const emit = defineEmits<{
|
|||
|
||||
const isOpen = computed(() => unref(props.modelValue) || isAnimating.value);
|
||||
function close() {
|
||||
emit("update:modelValue", false);
|
||||
if (unref(props.preventClosing) !== true) {
|
||||
emit("update:modelValue", false);
|
||||
}
|
||||
}
|
||||
|
||||
const isAnimating = ref(false);
|
||||
|
|
|
@ -55,7 +55,7 @@ import Decimal, { format } from "util/bignum";
|
|||
import type { ComponentPublicInstance } from "vue";
|
||||
import { computed, ref, toRef, watch } from "vue";
|
||||
import Toggle from "./fields/Toggle.vue";
|
||||
import SavesManager from "./SavesManager.vue";
|
||||
import SavesManager from "./saves/SavesManager.vue";
|
||||
|
||||
const { discordName, discordLink } = projInfo;
|
||||
const autosave = ref(true);
|
||||
|
|
|
@ -36,7 +36,7 @@
|
|||
</div>
|
||||
<div @click="savesManager?.open()">
|
||||
<Tooltip display="Saves" :direction="Direction.Down" xoffset="-20px">
|
||||
<span class="material-icons">library_books</span>
|
||||
<span class="material-icons" :class="{ needsSync }">library_books</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div @click="options?.open()">
|
||||
|
@ -53,7 +53,7 @@
|
|||
</div>
|
||||
<div @click="savesManager?.open()">
|
||||
<Tooltip display="Saves" :direction="Direction.Right">
|
||||
<span class="material-icons">library_books</span>
|
||||
<span class="material-icons" :class="{ needsSync }">library_books</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div @click="options?.open()">
|
||||
|
@ -98,12 +98,14 @@
|
|||
import Changelog from "data/Changelog.vue";
|
||||
import projInfo from "data/projInfo.json";
|
||||
import Tooltip from "features/tooltips/Tooltip.vue";
|
||||
import settings from "game/settings";
|
||||
import { Direction } from "util/common";
|
||||
import { galaxy, syncedSaves } from "util/galaxy";
|
||||
import type { ComponentPublicInstance } from "vue";
|
||||
import { ref } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import Info from "./Info.vue";
|
||||
import Options from "./Options.vue";
|
||||
import SavesManager from "./SavesManager.vue";
|
||||
import SavesManager from "./saves/SavesManager.vue";
|
||||
|
||||
const info = ref<ComponentPublicInstance<typeof Info> | null>(null);
|
||||
const savesManager = ref<ComponentPublicInstance<typeof SavesManager> | null>(null);
|
||||
|
@ -117,6 +119,10 @@ const { useHeader, banner, title, discordName, discordLink, versionNumber } = pr
|
|||
function openDiscord() {
|
||||
window.open(discordLink, "mywindow");
|
||||
}
|
||||
|
||||
const needsSync = computed(
|
||||
() => galaxy.value?.loggedIn === true && !syncedSaves.value.includes(settings.active)
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
@ -264,4 +270,32 @@ function openDiscord() {
|
|||
color: var(--foreground);
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.needsSync {
|
||||
color: var(--danger);
|
||||
animation: 4s wiggle ease infinite;
|
||||
}
|
||||
|
||||
@keyframes wiggle {
|
||||
0% {
|
||||
transform: rotate(-3deg);
|
||||
box-shadow: 0 2px 2px #0003;
|
||||
}
|
||||
5% {
|
||||
transform: rotate(20deg);
|
||||
}
|
||||
10% {
|
||||
transform: rotate(-15deg);
|
||||
}
|
||||
15% {
|
||||
transform: rotate(5deg);
|
||||
}
|
||||
20% {
|
||||
transform: rotate(-1deg);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(0);
|
||||
box-shadow: 0 2px 2px #0003;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
228
src/components/saves/CloudSaveResolver.vue
Normal file
228
src/components/saves/CloudSaveResolver.vue
Normal 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>
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div class="save" :class="{ active: isActive }">
|
||||
<div class="handle material-icons">drag_handle</div>
|
||||
<div class="actions" v-if="!isEditing">
|
||||
<div class="save" :class="{ active: isActive, readonly }">
|
||||
<div class="handle material-icons" v-if="readonly !== true">drag_handle</div>
|
||||
<div class="actions" v-if="!isEditing && readonly !== true">
|
||||
<FeedbackButton
|
||||
@click="emit('export')"
|
||||
class="button"
|
||||
|
@ -40,7 +40,7 @@
|
|||
</Tooltip>
|
||||
</DangerButton>
|
||||
</div>
|
||||
<div class="actions" v-else>
|
||||
<div class="actions" v-else-if="readonly !== true">
|
||||
<button @click="changeName" class="button">
|
||||
<Tooltip display="Save" :direction="Direction.Left" class="info">
|
||||
<span class="material-icons">check</span>
|
||||
|
@ -53,12 +53,17 @@
|
|||
</button>
|
||||
</div>
|
||||
<div class="details" v-if="save.error == undefined && !isEditing">
|
||||
<button class="button open" @click="emit('open')">
|
||||
<Tooltip display="Synced!" :direction="Direction.Right" v-if="synced"
|
||||
><span class="material-icons synced">cloud</span></Tooltip
|
||||
>
|
||||
<button class="button open" @click="emit('open')" :disabled="readonly">
|
||||
<h3>{{ save.name }}</h3>
|
||||
</button>
|
||||
<span class="save-version">v{{ save.modVersion }}</span
|
||||
><br />
|
||||
<div v-if="currentTime">Last played {{ dateFormat.format(currentTime) }}</div>
|
||||
<div v-if="currentTime" class="time">
|
||||
Last played {{ dateFormat.format(currentTime) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="details" v-else-if="save.error == undefined && isEditing">
|
||||
<Text v-model="newName" class="editname" @submit="changeName" />
|
||||
|
@ -73,16 +78,18 @@
|
|||
import Tooltip from "features/tooltips/Tooltip.vue";
|
||||
import player from "game/player";
|
||||
import { Direction } from "util/common";
|
||||
import { computed, ref, toRefs, watch } from "vue";
|
||||
import DangerButton from "./fields/DangerButton.vue";
|
||||
import FeedbackButton from "./fields/FeedbackButton.vue";
|
||||
import Text from "./fields/Text.vue";
|
||||
import { computed, ref, toRefs, unref, watch } from "vue";
|
||||
import DangerButton from "../fields/DangerButton.vue";
|
||||
import FeedbackButton from "../fields/FeedbackButton.vue";
|
||||
import Text from "../fields/Text.vue";
|
||||
import type { LoadablePlayerData } from "./SavesManager.vue";
|
||||
import { galaxy, syncedSaves } from "util/galaxy";
|
||||
|
||||
const _props = defineProps<{
|
||||
save: LoadablePlayerData;
|
||||
readonly?: boolean;
|
||||
}>();
|
||||
const { save } = toRefs(_props);
|
||||
const { save, readonly } = toRefs(_props);
|
||||
const emit = defineEmits<{
|
||||
(e: "export"): void;
|
||||
(e: "open"): void;
|
||||
|
@ -106,10 +113,18 @@ const newName = ref("");
|
|||
|
||||
watch(isEditing, () => (newName.value = save.value.name ?? ""));
|
||||
|
||||
const isActive = computed(() => save.value != null && save.value.id === player.id);
|
||||
const isActive = computed(
|
||||
() => save.value != null && save.value.id === player.id && !unref(readonly)
|
||||
);
|
||||
const currentTime = computed(() =>
|
||||
isActive.value ? player.time : (save.value != null && save.value.time) ?? 0
|
||||
);
|
||||
const synced = computed(
|
||||
() =>
|
||||
!unref(readonly) &&
|
||||
galaxy.value?.loggedIn === true &&
|
||||
syncedSaves.value.includes(save.value.id)
|
||||
);
|
||||
|
||||
function changeName() {
|
||||
emit("editName", newName.value);
|
||||
|
@ -139,6 +154,13 @@ function changeName() {
|
|||
padding-left: 0;
|
||||
}
|
||||
|
||||
.open:disabled {
|
||||
cursor: inherit;
|
||||
color: var(--foreground);
|
||||
opacity: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.handle {
|
||||
flex-grow: 0;
|
||||
margin-right: 8px;
|
||||
|
@ -152,6 +174,10 @@ function changeName() {
|
|||
margin-right: 80px;
|
||||
}
|
||||
|
||||
.save.readonly .details {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: 0.8em;
|
||||
color: var(--danger);
|
||||
|
@ -176,6 +202,17 @@ function changeName() {
|
|||
.editname {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
.synced {
|
||||
font-size: 100%;
|
||||
margin-right: 0.5em;
|
||||
vertical-align: middle;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
|
@ -201,4 +238,8 @@ function changeName() {
|
|||
.save .field {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.details > .tooltip-container {
|
||||
display: inline;
|
||||
}
|
||||
</style>
|
|
@ -4,6 +4,9 @@
|
|||
<h2>Saves Manager</h2>
|
||||
</template>
|
||||
<template #body="{ shown }">
|
||||
<div v-if="showNotSyncedWarning" style="color: var(--danger)">
|
||||
Not all saves are synced! You may need to delete stale saves.
|
||||
</div>
|
||||
<Draggable
|
||||
:list="settings.saves"
|
||||
handle=".handle"
|
||||
|
@ -63,13 +66,23 @@ import type { Player } from "game/player";
|
|||
import player, { stringifySave } from "game/player";
|
||||
import settings from "game/settings";
|
||||
import LZString from "lz-string";
|
||||
import { getUniqueID, loadSave, newSave, save } from "util/save";
|
||||
import {
|
||||
clearCachedSave,
|
||||
clearCachedSaves,
|
||||
decodeSave,
|
||||
getCachedSave,
|
||||
getUniqueID,
|
||||
loadSave,
|
||||
newSave,
|
||||
save
|
||||
} from "util/save";
|
||||
import type { ComponentPublicInstance } from "vue";
|
||||
import { computed, nextTick, ref, shallowReactive, watch } from "vue";
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import Draggable from "vuedraggable";
|
||||
import Select from "./fields/Select.vue";
|
||||
import Text from "./fields/Text.vue";
|
||||
import Select from "../fields/Select.vue";
|
||||
import Text from "../fields/Text.vue";
|
||||
import Save from "./Save.vue";
|
||||
import { galaxy, syncedSaves } from "util/galaxy";
|
||||
|
||||
export type LoadablePlayerData = Omit<Partial<Player>, "id"> & { id: string; error?: unknown };
|
||||
|
||||
|
@ -90,16 +103,8 @@ watch(saveToImport, importedSave => {
|
|||
if (importedSave) {
|
||||
nextTick(() => {
|
||||
try {
|
||||
if (importedSave[0] === "{") {
|
||||
// plaintext. No processing needed
|
||||
} else if (importedSave[0] === "e") {
|
||||
// Assumed to be base64, which starts with e
|
||||
importedSave = decodeURIComponent(escape(atob(importedSave)));
|
||||
} else if (importedSave[0] === "ᯡ") {
|
||||
// Assumed to be lz, which starts with ᯡ
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
importedSave = LZString.decompressFromUTF16(importedSave)!;
|
||||
} else {
|
||||
importedSave = decodeSave(importedSave) ?? "";
|
||||
if (importedSave === "") {
|
||||
console.warn("Unable to determine preset encoding", importedSave);
|
||||
importingFailed.value = true;
|
||||
return;
|
||||
|
@ -139,48 +144,10 @@ let bank = ref(
|
|||
}, [])
|
||||
);
|
||||
|
||||
const cachedSaves = shallowReactive<Record<string, LoadablePlayerData | undefined>>({});
|
||||
function getCachedSave(id: string) {
|
||||
if (cachedSaves[id] == null) {
|
||||
let save = localStorage.getItem(id);
|
||||
if (save == null) {
|
||||
cachedSaves[id] = { error: `Save doesn't exist in localStorage`, id };
|
||||
} else if (save === "dW5kZWZpbmVk") {
|
||||
cachedSaves[id] = { error: `Save is undefined`, id };
|
||||
} else {
|
||||
try {
|
||||
if (save[0] === "{") {
|
||||
// plaintext. No processing needed
|
||||
} else if (save[0] === "e") {
|
||||
// Assumed to be base64, which starts with e
|
||||
save = decodeURIComponent(escape(atob(save)));
|
||||
} else if (save[0] === "ᯡ") {
|
||||
// Assumed to be lz, which starts with ᯡ
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
save = LZString.decompressFromUTF16(save)!;
|
||||
} else {
|
||||
console.warn("Unable to determine preset encoding", save);
|
||||
importingFailed.value = true;
|
||||
cachedSaves[id] = { error: "Unable to determine preset encoding", id };
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return cachedSaves[id]!;
|
||||
}
|
||||
cachedSaves[id] = { ...JSON.parse(save), id };
|
||||
} catch (error) {
|
||||
cachedSaves[id] = { error, id };
|
||||
console.warn(
|
||||
`SavesManager: Failed to load info about save with id ${id}:\n${error}\n${save}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return cachedSaves[id]!;
|
||||
}
|
||||
// Wipe cache whenever the modal is opened
|
||||
watch(isOpen, isOpen => {
|
||||
if (isOpen) {
|
||||
Object.keys(cachedSaves).forEach(key => delete cachedSaves[key]);
|
||||
clearCachedSaves();
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -191,6 +158,10 @@ const saves = computed(() =>
|
|||
}, {})
|
||||
);
|
||||
|
||||
const showNotSyncedWarning = computed(
|
||||
() => galaxy.value?.loggedIn === true && settings.saves.length < syncedSaves.value.length
|
||||
);
|
||||
|
||||
function exportSave(id: string) {
|
||||
let saveToExport;
|
||||
if (player.id === id) {
|
||||
|
@ -233,20 +204,37 @@ function duplicateSave(id: string) {
|
|||
}
|
||||
|
||||
function deleteSave(id: string) {
|
||||
if (galaxy.value?.loggedIn === true) {
|
||||
galaxy.value.getSaveList().then(list => {
|
||||
const slot = Object.keys(list).find(slot => {
|
||||
const content = list[slot as unknown as number].content;
|
||||
try {
|
||||
if (JSON.parse(content).id === id) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (slot != null) {
|
||||
galaxy.value?.save(parseInt(slot), "", "").catch(console.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
settings.saves = settings.saves.filter((save: string) => save !== id);
|
||||
localStorage.removeItem(id);
|
||||
cachedSaves[id] = undefined;
|
||||
clearCachedSave(id);
|
||||
}
|
||||
|
||||
function openSave(id: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
saves.value[player.id]!.time = player.time;
|
||||
save();
|
||||
cachedSaves[player.id] = undefined;
|
||||
clearCachedSave(player.id);
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
loadSave(saves.value[id]!);
|
||||
// Delete cached version in case of opening it again
|
||||
cachedSaves[id] = undefined;
|
||||
clearCachedSave(id);
|
||||
}
|
||||
|
||||
function newFromPreset(preset: string) {
|
||||
|
@ -256,16 +244,8 @@ function newFromPreset(preset: string) {
|
|||
selectedPreset.value = null;
|
||||
});
|
||||
|
||||
if (preset[0] === "{") {
|
||||
// plaintext. No processing needed
|
||||
} else if (preset[0] === "e") {
|
||||
// Assumed to be base64, which starts with e
|
||||
preset = decodeURIComponent(escape(atob(preset)));
|
||||
} else if (preset[0] === "ᯡ") {
|
||||
// Assumed to be lz, which starts with ᯡ
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
preset = LZString.decompressFromUTF16(preset)!;
|
||||
} else {
|
||||
preset = decodeSave(preset) ?? "";
|
||||
if (preset === "") {
|
||||
console.warn("Unable to determine preset encoding", preset);
|
||||
return;
|
||||
}
|
||||
|
@ -287,7 +267,7 @@ function editSave(id: string, newName: string) {
|
|||
save();
|
||||
} else {
|
||||
save(currSave as Player);
|
||||
cachedSaves[id] = undefined;
|
||||
clearCachedSave(id);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -134,8 +134,8 @@ export function createResetButton<T extends ClickableOptions & ResetButtonOption
|
|||
{unref(resetButton.conversion.buyMax) ? "Next:" : "Req:"}{" "}
|
||||
{displayResource(
|
||||
resetButton.conversion.baseResource,
|
||||
!unref(resetButton.conversion.buyMax) ||
|
||||
Decimal.lt(unref(resetButton.conversion.actualGain), 1)
|
||||
!unref(resetButton.conversion.buyMax) &&
|
||||
Decimal.gte(unref(resetButton.conversion.actualGain), 1)
|
||||
? unref(resetButton.conversion.currentAt)
|
||||
: unref(resetButton.conversion.nextAt)
|
||||
)}{" "}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import Node from "components/Node.vue";
|
||||
import Spacer from "components/layout/Spacer.vue";
|
||||
import { jsx } from "features/feature";
|
||||
import { createResource, trackBest, trackOOMPS, trackTotal } from "features/resources/resource";
|
||||
|
@ -48,19 +49,35 @@ export const main = createLayer("main", function (this: BaseLayer) {
|
|||
links: tree.links,
|
||||
display: jsx(() => (
|
||||
<>
|
||||
{player.devSpeed === 0 ? <div>Game Paused</div> : null}
|
||||
{player.devSpeed === 0 ? (
|
||||
<div>
|
||||
Game Paused
|
||||
<Node id="paused" />
|
||||
</div>
|
||||
) : null}
|
||||
{player.devSpeed != null && player.devSpeed !== 0 && player.devSpeed !== 1 ? (
|
||||
<div>Dev Speed: {format(player.devSpeed)}x</div>
|
||||
<div>
|
||||
Dev Speed: {format(player.devSpeed)}x
|
||||
<Node id="devspeed" />
|
||||
</div>
|
||||
) : null}
|
||||
{player.offlineTime != null && player.offlineTime !== 0 ? (
|
||||
<div>Offline Time: {formatTime(player.offlineTime)}</div>
|
||||
<div>
|
||||
Offline Time: {formatTime(player.offlineTime)}
|
||||
<Node id="offline" />
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
{Decimal.lt(points.value, "1e1000") ? <span>You have </span> : null}
|
||||
<h2>{format(points.value)}</h2>
|
||||
{Decimal.lt(points.value, "1e1e6") ? <span> points</span> : null}
|
||||
</div>
|
||||
{Decimal.gt(pointGain.value, 0) ? <div>({oomps.value})</div> : null}
|
||||
{Decimal.gt(pointGain.value, 0) ? (
|
||||
<div>
|
||||
({oomps.value})
|
||||
<Node id="oomps" />
|
||||
</div>
|
||||
) : null}
|
||||
<Spacer />
|
||||
{render(tree)}
|
||||
</>
|
||||
|
|
|
@ -31,7 +31,7 @@ import { coerceComponent, isCoercableComponent, render } from "util/vue";
|
|||
import { computed, Ref, ref, unref } from "vue";
|
||||
import { BarOptions, createBar, GenericBar } from "./bars/bar";
|
||||
import { ClickableOptions } from "./clickables/clickable";
|
||||
import { Decorator, GenericDecorator } from "./decorators/common";
|
||||
import { GenericDecorator } from "./decorators/common";
|
||||
|
||||
/** A symbol used to identify {@link Action} features. */
|
||||
export const ActionType = Symbol("Action");
|
||||
|
|
|
@ -108,7 +108,7 @@ document.onkeydown = function (e) {
|
|||
if (e.ctrlKey) {
|
||||
key = "ctrl+" + key;
|
||||
}
|
||||
const hotkey = hotkeys[key];
|
||||
const hotkey = hotkeys[key] ?? hotkeys[key.toLowerCase()];
|
||||
if (hotkey && unref(hotkey.enabled)) {
|
||||
e.preventDefault();
|
||||
hotkey.onPress();
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import type { CoercableComponent, GenericComponent, Replace, StyleValue } from "features/feature";
|
||||
import { Component, GatherProps, setDefault } from "features/feature";
|
||||
import { deletePersistent, Persistent, persistent } from "game/persistence";
|
||||
import { persistent } from "game/persistence";
|
||||
import { Direction } from "util/common";
|
||||
import type {
|
||||
Computable,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Decorator, GenericDecorator } from "features/decorators/common";
|
||||
import { GenericDecorator } from "features/decorators/common";
|
||||
import type {
|
||||
CoercableComponent,
|
||||
GenericComponent,
|
||||
|
@ -224,7 +224,7 @@ export interface BaseTree {
|
|||
id: string;
|
||||
/** The link objects for each of the branches of the tree. */
|
||||
links: Ref<Link[]>;
|
||||
/** Cause a reset on this node and propagate it through the tree according to {@link resetPropagation}. */
|
||||
/** Cause a reset on this node and propagate it through the tree according to {@link TreeOptions.resetPropagation}. */
|
||||
reset: (node: GenericTreeNode) => void;
|
||||
/** A flag that is true while the reset is still propagating through the tree. */
|
||||
isResetting: Ref<boolean>;
|
||||
|
@ -338,34 +338,21 @@ export const branchedResetPropagation = function (
|
|||
tree: GenericTree,
|
||||
resettingNode: GenericTreeNode
|
||||
): void {
|
||||
const visitedNodes = [resettingNode];
|
||||
let currentNodes = [resettingNode];
|
||||
if (tree.branches != null) {
|
||||
const branches = unref(tree.branches);
|
||||
while (currentNodes.length > 0) {
|
||||
const nextNodes: GenericTreeNode[] = [];
|
||||
currentNodes.forEach(node => {
|
||||
branches
|
||||
.filter(branch => branch.startNode === node || branch.endNode === node)
|
||||
.map(branch => {
|
||||
if (branch.startNode === node) {
|
||||
return branch.endNode;
|
||||
}
|
||||
return branch.startNode;
|
||||
})
|
||||
.filter(node => !visitedNodes.includes(node))
|
||||
.forEach(node => {
|
||||
// Check here instead of in the filter because this check's results may
|
||||
// change as we go through each node
|
||||
if (!nextNodes.includes(node)) {
|
||||
nextNodes.push(node);
|
||||
node.reset?.reset();
|
||||
}
|
||||
});
|
||||
});
|
||||
currentNodes = nextNodes;
|
||||
visitedNodes.push(...currentNodes);
|
||||
const links = unref(tree.branches);
|
||||
if (links == null) return;
|
||||
const reset: GenericTreeNode[] = [];
|
||||
let current = [resettingNode];
|
||||
while (current.length != 0) {
|
||||
const next: GenericTreeNode[] = [];
|
||||
for (const node of current) {
|
||||
for (const link of links.filter(link => link.startNode === node)) {
|
||||
if ([...reset, ...current].includes(link.endNode)) continue;
|
||||
next.push(link.endNode);
|
||||
link.endNode.reset?.reset();
|
||||
}
|
||||
}
|
||||
reset.push(...current);
|
||||
current = next;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -552,7 +552,9 @@ export function tetrate(
|
|||
export function invertTetrate(
|
||||
value: DecimalSource,
|
||||
base: FormulaSource,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
height: FormulaSource,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
payload: FormulaSource
|
||||
) {
|
||||
if (hasVariable(base)) {
|
||||
|
@ -576,6 +578,7 @@ export function invertIteratedExp(
|
|||
value: DecimalSource,
|
||||
lhs: FormulaSource,
|
||||
height: FormulaSource,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
payload: FormulaSource
|
||||
) {
|
||||
if (hasVariable(lhs)) {
|
||||
|
@ -626,6 +629,7 @@ export function invertLayeradd(
|
|||
value: DecimalSource,
|
||||
lhs: FormulaSource,
|
||||
diff: FormulaSource,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
base: FormulaSource
|
||||
) {
|
||||
if (hasVariable(lhs)) {
|
||||
|
|
|
@ -4,7 +4,7 @@ import { jsx } from "features/feature";
|
|||
import settings from "game/settings";
|
||||
import type { DecimalSource } from "util/bignum";
|
||||
import Decimal, { formatSmall } from "util/bignum";
|
||||
import type { WithRequired } from "util/common";
|
||||
import type { RequiredKeys, WithRequired } from "util/common";
|
||||
import type { Computable, ProcessedComputable } from "util/computed";
|
||||
import { convertComputable } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
|
@ -38,16 +38,11 @@ export interface Modifier {
|
|||
description?: ProcessedComputable<CoercableComponent>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility type used to narrow down a modifier type that will have a description and/or enabled property based on optional parameters, T and S (respectively).
|
||||
*/
|
||||
export type ModifierFromOptionalParams<T, S> = undefined extends T
|
||||
? undefined extends S
|
||||
? Omit<WithRequired<Modifier, "invert" | "getFormula">, "description" | "enabled">
|
||||
: Omit<WithRequired<Modifier, "invert" | "enabled" | "getFormula">, "description">
|
||||
: undefined extends S
|
||||
? Omit<WithRequired<Modifier, "invert" | "description" | "getFormula">, "enabled">
|
||||
: WithRequired<Modifier, "invert" | "enabled" | "description" | "getFormula">;
|
||||
/** Utility type that represents the output of all modifiers that represent a single operation. */
|
||||
export type OperationModifier<T> = WithRequired<
|
||||
Modifier,
|
||||
"invert" | "getFormula" | Extract<RequiredKeys<T>, keyof Modifier>
|
||||
>;
|
||||
|
||||
/** An object that configures an additive modifier via {@link createAdditiveModifier}. */
|
||||
export interface AdditiveModifierOptions {
|
||||
|
@ -65,9 +60,9 @@ export interface AdditiveModifierOptions {
|
|||
* Create a modifier that adds some value to the input value.
|
||||
* @param optionsFunc Additive modifier options.
|
||||
*/
|
||||
export function createAdditiveModifier<T extends AdditiveModifierOptions>(
|
||||
export function createAdditiveModifier<T extends AdditiveModifierOptions, S = OperationModifier<T>>(
|
||||
optionsFunc: OptionsFunc<T>
|
||||
): ModifierFromOptionalParams<T["description"], T["enabled"]> {
|
||||
) {
|
||||
return createLazyProxy(feature => {
|
||||
const { addend, description, enabled, smallerIsBetter } = optionsFunc.call(
|
||||
feature,
|
||||
|
@ -111,7 +106,7 @@ export function createAdditiveModifier<T extends AdditiveModifierOptions>(
|
|||
</div>
|
||||
))
|
||||
};
|
||||
}) as unknown as ModifierFromOptionalParams<T["description"], T["enabled"]>;
|
||||
}) as S;
|
||||
}
|
||||
|
||||
/** An object that configures an multiplicative modifier via {@link createMultiplicativeModifier}. */
|
||||
|
@ -130,9 +125,10 @@ export interface MultiplicativeModifierOptions {
|
|||
* Create a modifier that multiplies the input value by some value.
|
||||
* @param optionsFunc Multiplicative modifier options.
|
||||
*/
|
||||
export function createMultiplicativeModifier<T extends MultiplicativeModifierOptions>(
|
||||
optionsFunc: OptionsFunc<T>
|
||||
): ModifierFromOptionalParams<T["description"], T["enabled"]> {
|
||||
export function createMultiplicativeModifier<
|
||||
T extends MultiplicativeModifierOptions,
|
||||
S = OperationModifier<T>
|
||||
>(optionsFunc: OptionsFunc<T>) {
|
||||
return createLazyProxy(feature => {
|
||||
const { multiplier, description, enabled, smallerIsBetter } = optionsFunc.call(
|
||||
feature,
|
||||
|
@ -175,7 +171,7 @@ export function createMultiplicativeModifier<T extends MultiplicativeModifierOpt
|
|||
</div>
|
||||
))
|
||||
};
|
||||
}) as unknown as ModifierFromOptionalParams<T["description"], T["enabled"]>;
|
||||
}) as S;
|
||||
}
|
||||
|
||||
/** An object that configures an exponential modifier via {@link createExponentialModifier}. */
|
||||
|
@ -196,9 +192,10 @@ export interface ExponentialModifierOptions {
|
|||
* Create a modifier that raises the input value to the power of some value.
|
||||
* @param optionsFunc Exponential modifier options.
|
||||
*/
|
||||
export function createExponentialModifier<T extends ExponentialModifierOptions>(
|
||||
optionsFunc: OptionsFunc<T>
|
||||
): ModifierFromOptionalParams<T["description"], T["enabled"]> {
|
||||
export function createExponentialModifier<
|
||||
T extends ExponentialModifierOptions,
|
||||
S = OperationModifier<T>
|
||||
>(optionsFunc: OptionsFunc<T>) {
|
||||
return createLazyProxy(feature => {
|
||||
const { exponent, description, enabled, supportLowNumbers, smallerIsBetter } =
|
||||
optionsFunc.call(feature, feature);
|
||||
|
@ -263,7 +260,7 @@ export function createExponentialModifier<T extends ExponentialModifierOptions>(
|
|||
</div>
|
||||
))
|
||||
};
|
||||
}) as unknown as ModifierFromOptionalParams<T["description"], T["enabled"]>;
|
||||
}) as S;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -274,11 +271,9 @@ export function createExponentialModifier<T extends ExponentialModifierOptions>(
|
|||
* @see {@link createModifierSection}.
|
||||
*/
|
||||
export function createSequentialModifier<
|
||||
T extends Modifier[],
|
||||
S = T extends WithRequired<Modifier, "invert">[]
|
||||
? WithRequired<Modifier, "description" | "invert">
|
||||
: Omit<WithRequired<Modifier, "description">, "invert">
|
||||
>(modifiersFunc: () => T): S {
|
||||
T extends Modifier,
|
||||
S = WithRequired<Modifier, Extract<RequiredKeys<T>, keyof Modifier>>
|
||||
>(modifiersFunc: () => T[]) {
|
||||
return createLazyProxy(() => {
|
||||
const modifiers = modifiersFunc();
|
||||
|
||||
|
@ -296,10 +291,14 @@ export function createSequentialModifier<
|
|||
: undefined,
|
||||
getFormula: modifiers.every(m => m.getFormula != null)
|
||||
? (gain: FormulaSource) =>
|
||||
modifiers
|
||||
.filter(m => unref(m.enabled) !== false)
|
||||
modifiers.reduce((acc, curr) => {
|
||||
if (curr.enabled == null || curr.enabled === true) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return curr.getFormula!(acc);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
.reduce((acc, curr) => curr.getFormula!(acc), gain)
|
||||
return Formula.if(acc, curr.enabled, acc => curr.getFormula!(acc));
|
||||
}, gain)
|
||||
: undefined,
|
||||
enabled: modifiers.some(m => m.enabled != null)
|
||||
? computed(() => modifiers.filter(m => unref(m.enabled) !== false).length > 0)
|
||||
|
@ -317,7 +316,7 @@ export function createSequentialModifier<
|
|||
))
|
||||
: undefined
|
||||
};
|
||||
}) as unknown as S;
|
||||
}) as S;
|
||||
}
|
||||
|
||||
/** An object that configures a modifier section via {@link createModifierSection}. */
|
||||
|
|
|
@ -222,7 +222,9 @@ export function createCostRequirement<T extends CostRequirementOptions>(
|
|||
Decimal.gte(
|
||||
req.resource.value,
|
||||
unref(req.cost as ProcessedComputable<DecimalSource>)
|
||||
) ? 1 : 0
|
||||
)
|
||||
? 1
|
||||
: 0
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ import { Themes } from "data/themes";
|
|||
import type { CoercableComponent } from "features/feature";
|
||||
import { globalBus } from "game/events";
|
||||
import LZString from "lz-string";
|
||||
import { hardReset } from "util/save";
|
||||
import { decodeSave, hardReset } from "util/save";
|
||||
import { reactive, watch } from "vue";
|
||||
|
||||
/** The player's settings object. */
|
||||
|
@ -78,16 +78,8 @@ export function loadSettings(): void {
|
|||
try {
|
||||
let item: string | null = localStorage.getItem(projInfo.id);
|
||||
if (item != null && item !== "") {
|
||||
if (item[0] === "{") {
|
||||
// plaintext. No processing needed
|
||||
} else if (item[0] === "e") {
|
||||
// Assumed to be base64, which starts with e
|
||||
item = decodeURIComponent(escape(atob(item)));
|
||||
} else if (item[0] === "ᯡ") {
|
||||
// Assumed to be lz, which starts with ᯡ
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
item = LZString.decompressFromUTF16(item)!;
|
||||
} else {
|
||||
item = decodeSave(item);
|
||||
if (item == null) {
|
||||
console.warn("Unable to determine settings encoding", item);
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -66,3 +66,7 @@ ul {
|
|||
.Vue-Toastification__toast {
|
||||
margin: unset;
|
||||
}
|
||||
|
||||
:disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import { useRegisterSW } from "virtual:pwa-register/vue";
|
|||
import type { App as VueApp } from "vue";
|
||||
import { createApp, nextTick } from "vue";
|
||||
import { useToast } from "vue-toastification";
|
||||
import "util/galaxy";
|
||||
|
||||
declare global {
|
||||
/**
|
||||
|
|
|
@ -1,3 +1,11 @@
|
|||
export type RequiredKeys<T> = {
|
||||
[K in keyof T]-?: NonNullable<unknown> extends Pick<T, K> ? never : K;
|
||||
}[keyof T];
|
||||
export type OptionalKeys<T> = {
|
||||
[K in keyof T]-?: NonNullable<unknown> extends Pick<T, K> ? K : never;
|
||||
}[keyof T];
|
||||
|
||||
export type OmitOptional<T> = Pick<T, RequiredKeys<T>>;
|
||||
export type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };
|
||||
|
||||
export type ArrayElements<T extends ReadonlyArray<unknown>> = T extends ReadonlyArray<infer S>
|
||||
|
|
185
src/util/galaxy.ts
Normal file
185
src/util/galaxy.ts
Normal 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;
|
||||
}
|
|
@ -1,10 +1,11 @@
|
|||
import { LoadablePlayerData } from "components/saves/SavesManager.vue";
|
||||
import projInfo from "data/projInfo.json";
|
||||
import { globalBus } from "game/events";
|
||||
import type { Player } from "game/player";
|
||||
import player, { stringifySave } from "game/player";
|
||||
import settings from "game/settings";
|
||||
import LZString from "lz-string";
|
||||
import { ref } from "vue";
|
||||
import { ref, shallowReactive } from "vue";
|
||||
|
||||
export function setupInitialStore(player: Partial<Player> = {}): Player {
|
||||
return Object.assign(
|
||||
|
@ -39,17 +40,9 @@ export async function load(): Promise<void> {
|
|||
await loadSave(newSave());
|
||||
return;
|
||||
}
|
||||
if (save[0] === "{") {
|
||||
// plaintext. No processing needed
|
||||
} else if (save[0] === "e") {
|
||||
// Assumed to be base64, which starts with e
|
||||
save = decodeURIComponent(escape(atob(save)));
|
||||
} else if (save[0] === "ᯡ") {
|
||||
// Assumed to be lz, which starts with ᯡ
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
save = LZString.decompressFromUTF16(save)!;
|
||||
} else {
|
||||
throw `Unable to determine save encoding`;
|
||||
save = decodeSave(save);
|
||||
if (save == null) {
|
||||
throw "Unable to determine save encoding";
|
||||
}
|
||||
const player = JSON.parse(save);
|
||||
if (player.modID !== projInfo.id) {
|
||||
|
@ -64,6 +57,23 @@ export async function load(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
export function decodeSave(save: string) {
|
||||
if (save[0] === "{") {
|
||||
// plaintext. No processing needed
|
||||
} else if (save[0] === "e") {
|
||||
// Assumed to be base64, which starts with e
|
||||
save = decodeURIComponent(escape(atob(save)));
|
||||
} else if (save[0] === "ᯡ") {
|
||||
// Assumed to be lz, which starts with ᯡ
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
save = LZString.decompressFromUTF16(save)!;
|
||||
} else {
|
||||
console.warn("Unable to determine preset encoding", save);
|
||||
return null;
|
||||
}
|
||||
return save;
|
||||
}
|
||||
|
||||
export function newSave(): Player {
|
||||
const id = getUniqueID();
|
||||
const player = setupInitialStore({ id });
|
||||
|
@ -124,6 +134,40 @@ export async function loadSave(playerObj: Partial<Player>): Promise<void> {
|
|||
globalBus.emit("onLoad");
|
||||
}
|
||||
|
||||
const cachedSaves = shallowReactive<Record<string, LoadablePlayerData | undefined>>({});
|
||||
export function getCachedSave(id: string) {
|
||||
if (cachedSaves[id] == null) {
|
||||
let save = localStorage.getItem(id);
|
||||
if (save == null) {
|
||||
cachedSaves[id] = { error: `Save doesn't exist in localStorage`, id };
|
||||
} else if (save === "dW5kZWZpbmVk") {
|
||||
cachedSaves[id] = { error: `Save is undefined`, id };
|
||||
} else {
|
||||
try {
|
||||
save = decodeSave(save);
|
||||
if (save == null) {
|
||||
console.warn("Unable to determine preset encoding", save);
|
||||
cachedSaves[id] = { error: "Unable to determine preset encoding", id };
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return cachedSaves[id]!;
|
||||
}
|
||||
cachedSaves[id] = { ...JSON.parse(save), id };
|
||||
} catch (error) {
|
||||
cachedSaves[id] = { error, id };
|
||||
console.warn(`Failed to load info about save with id ${id}:\n${error}\n${save}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return cachedSaves[id]!;
|
||||
}
|
||||
export function clearCachedSaves() {
|
||||
Object.keys(cachedSaves).forEach(key => delete cachedSaves[key]);
|
||||
}
|
||||
export function clearCachedSave(id: string) {
|
||||
cachedSaves[id] = undefined;
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
if (player.autosave) {
|
||||
save();
|
||||
|
|
|
@ -47,6 +47,10 @@ describe("Creating conversion", () => {
|
|||
baseResource.value = Decimal.pow(100, 2).times(10).add(1);
|
||||
expect(unref(conversion.currentGain)).compare_tolerance(100);
|
||||
});
|
||||
test("Zero", () => {
|
||||
baseResource.value = Decimal.dZero;
|
||||
expect(unref(conversion.currentGain)).compare_tolerance(0);
|
||||
});
|
||||
});
|
||||
describe("Calculates actualGain correctly", () => {
|
||||
let conversion: GenericConversion;
|
||||
|
@ -69,6 +73,10 @@ describe("Creating conversion", () => {
|
|||
baseResource.value = Decimal.pow(100, 2).times(10).add(1);
|
||||
expect(unref(conversion.actualGain)).compare_tolerance(100);
|
||||
});
|
||||
test("Zero", () => {
|
||||
baseResource.value = Decimal.dZero;
|
||||
expect(unref(conversion.actualGain)).compare_tolerance(0);
|
||||
});
|
||||
});
|
||||
describe("Calculates currentAt correctly", () => {
|
||||
let conversion: GenericConversion;
|
||||
|
@ -95,6 +103,10 @@ describe("Creating conversion", () => {
|
|||
Decimal.pow(100, 2).times(10)
|
||||
);
|
||||
});
|
||||
test("Zero", () => {
|
||||
baseResource.value = Decimal.dZero;
|
||||
expect(unref(conversion.currentAt)).compare_tolerance(0);
|
||||
});
|
||||
});
|
||||
describe("Calculates nextAt correctly", () => {
|
||||
let conversion: GenericConversion;
|
||||
|
@ -117,6 +129,10 @@ describe("Creating conversion", () => {
|
|||
baseResource.value = Decimal.pow(100, 2).times(10).add(1);
|
||||
expect(unref(conversion.nextAt)).compare_tolerance(Decimal.pow(101, 2).times(10));
|
||||
});
|
||||
test("Zero", () => {
|
||||
baseResource.value = Decimal.dZero;
|
||||
expect(unref(conversion.nextAt)).compare_tolerance(Decimal.dTen);
|
||||
});
|
||||
});
|
||||
test("Converts correctly", () => {
|
||||
const conversion = createCumulativeConversion(() => ({
|
||||
|
@ -193,6 +209,10 @@ describe("Creating conversion", () => {
|
|||
baseResource.value = Decimal.pow(100, 2).times(10).add(1);
|
||||
expect(unref(conversion.currentGain)).compare_tolerance(100);
|
||||
});
|
||||
test("Zero", () => {
|
||||
baseResource.value = Decimal.dZero;
|
||||
expect(unref(conversion.currentGain)).compare_tolerance(1);
|
||||
});
|
||||
});
|
||||
describe("Calculates actualGain correctly", () => {
|
||||
let conversion: GenericConversion;
|
||||
|
@ -216,6 +236,10 @@ describe("Creating conversion", () => {
|
|||
baseResource.value = Decimal.pow(100, 2).times(10).add(1);
|
||||
expect(unref(conversion.actualGain)).compare_tolerance(99);
|
||||
});
|
||||
test("Zero", () => {
|
||||
baseResource.value = Decimal.dZero;
|
||||
expect(unref(conversion.actualGain)).compare_tolerance(0);
|
||||
});
|
||||
});
|
||||
describe("Calculates currentAt correctly", () => {
|
||||
let conversion: GenericConversion;
|
||||
|
@ -243,6 +267,10 @@ describe("Creating conversion", () => {
|
|||
Decimal.pow(100, 2).times(10)
|
||||
);
|
||||
});
|
||||
test("Zero", () => {
|
||||
baseResource.value = Decimal.dZero;
|
||||
expect(unref(conversion.currentAt)).compare_tolerance(Decimal.pow(1, 2).times(10));
|
||||
});
|
||||
});
|
||||
describe("Calculates nextAt correctly", () => {
|
||||
let conversion: GenericConversion;
|
||||
|
@ -266,6 +294,10 @@ describe("Creating conversion", () => {
|
|||
baseResource.value = Decimal.pow(100, 2).times(10).add(1);
|
||||
expect(unref(conversion.nextAt)).compare_tolerance(Decimal.pow(101, 2).times(10));
|
||||
});
|
||||
test("Zero", () => {
|
||||
baseResource.value = Decimal.dZero;
|
||||
expect(unref(conversion.nextAt)).compare_tolerance(Decimal.pow(2, 2).times(10));
|
||||
});
|
||||
});
|
||||
test("Converts correctly", () => {
|
||||
const conversion = createIndependentConversion(() => ({
|
||||
|
|
111
tests/features/tree.test.ts
Normal file
111
tests/features/tree.test.ts
Normal file
|
@ -0,0 +1,111 @@
|
|||
import { beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { Ref, ref } from "vue";
|
||||
import "../utils";
|
||||
import {
|
||||
createTree,
|
||||
createTreeNode,
|
||||
defaultResetPropagation,
|
||||
invertedResetPropagation,
|
||||
branchedResetPropagation
|
||||
} from "features/trees/tree";
|
||||
import { createReset, GenericReset } from "features/reset";
|
||||
|
||||
describe("Reset propagation", () => {
|
||||
let shouldReset: Ref<boolean>, shouldNotReset: Ref<boolean>;
|
||||
let goodReset: GenericReset, badReset: GenericReset;
|
||||
beforeAll(() => {
|
||||
shouldReset = ref(false);
|
||||
shouldNotReset = ref(false);
|
||||
goodReset = createReset(() => ({
|
||||
thingsToReset: [],
|
||||
onReset() {
|
||||
shouldReset.value = true;
|
||||
}
|
||||
}));
|
||||
badReset = createReset(() => ({
|
||||
thingsToReset: [],
|
||||
onReset() {
|
||||
shouldNotReset.value = true;
|
||||
}
|
||||
}));
|
||||
});
|
||||
beforeEach(() => {
|
||||
shouldReset.value = false;
|
||||
shouldNotReset.value = false;
|
||||
});
|
||||
test("No resets", () => {
|
||||
expect(() => {
|
||||
const a = createTreeNode(() => ({}));
|
||||
const b = createTreeNode(() => ({}));
|
||||
const c = createTreeNode(() => ({}));
|
||||
const tree = createTree(() => ({
|
||||
nodes: [[a], [b], [c]]
|
||||
}));
|
||||
tree.reset(a);
|
||||
}).not.toThrowError();
|
||||
});
|
||||
|
||||
test("Do not propagate resets", () => {
|
||||
const a = createTreeNode(() => ({ reset: badReset }));
|
||||
const b = createTreeNode(() => ({ reset: badReset }));
|
||||
const c = createTreeNode(() => ({ reset: badReset }));
|
||||
const tree = createTree(() => ({
|
||||
nodes: [[a], [b], [c]]
|
||||
}));
|
||||
tree.reset(b);
|
||||
expect(shouldNotReset.value).toBe(false);
|
||||
});
|
||||
|
||||
test("Default propagation", () => {
|
||||
const a = createTreeNode(() => ({ reset: goodReset }));
|
||||
const b = createTreeNode(() => ({}));
|
||||
const c = createTreeNode(() => ({ reset: badReset }));
|
||||
const tree = createTree(() => ({
|
||||
nodes: [[a], [b], [c]],
|
||||
resetPropagation: defaultResetPropagation
|
||||
}));
|
||||
tree.reset(b);
|
||||
expect(shouldReset.value).toBe(true);
|
||||
expect(shouldNotReset.value).toBe(false);
|
||||
});
|
||||
|
||||
test("Inverted propagation", () => {
|
||||
const a = createTreeNode(() => ({ reset: badReset }));
|
||||
const b = createTreeNode(() => ({}));
|
||||
const c = createTreeNode(() => ({ reset: goodReset }));
|
||||
const tree = createTree(() => ({
|
||||
nodes: [[a], [b], [c]],
|
||||
resetPropagation: invertedResetPropagation
|
||||
}));
|
||||
tree.reset(b);
|
||||
expect(shouldReset.value).toBe(true);
|
||||
expect(shouldNotReset.value).toBe(false);
|
||||
});
|
||||
|
||||
test("Branched propagation", () => {
|
||||
const a = createTreeNode(() => ({ reset: badReset }));
|
||||
const b = createTreeNode(() => ({}));
|
||||
const c = createTreeNode(() => ({ reset: goodReset }));
|
||||
const tree = createTree(() => ({
|
||||
nodes: [[a, b, c]],
|
||||
resetPropagation: branchedResetPropagation,
|
||||
branches: [{ startNode: b, endNode: c }]
|
||||
}));
|
||||
tree.reset(b);
|
||||
expect(shouldReset.value).toBe(true);
|
||||
expect(shouldNotReset.value).toBe(false);
|
||||
});
|
||||
|
||||
test("Branched propagation not bi-directional", () => {
|
||||
const a = createTreeNode(() => ({ reset: badReset }));
|
||||
const b = createTreeNode(() => ({}));
|
||||
const c = createTreeNode(() => ({ reset: badReset }));
|
||||
const tree = createTree(() => ({
|
||||
nodes: [[a, b, c]],
|
||||
resetPropagation: branchedResetPropagation,
|
||||
branches: [{ startNode: c, endNode: b }]
|
||||
}));
|
||||
tree.reset(b);
|
||||
expect(shouldNotReset.value).toBe(false);
|
||||
});
|
||||
});
|
|
@ -133,14 +133,14 @@ describe("Exponential Modifiers", () =>
|
|||
testModifiers(createExponentialModifier, "exponent", Decimal.pow));
|
||||
|
||||
describe("Sequential Modifiers", () => {
|
||||
function createModifier(
|
||||
function createModifier<T extends Partial<ModifierConstructorOptions>>(
|
||||
value: Computable<DecimalSource>,
|
||||
options: Partial<ModifierConstructorOptions> = {}
|
||||
): WithRequired<Modifier, "invert" | "getFormula"> {
|
||||
options?: T
|
||||
) {
|
||||
return createSequentialModifier(() => [
|
||||
createAdditiveModifier(() => ({ ...options, addend: value })),
|
||||
createMultiplicativeModifier(() => ({ ...options, multiplier: value })),
|
||||
createExponentialModifier(() => ({ ...options, exponent: value }))
|
||||
createAdditiveModifier(() => ({ ...(options ?? {}), addend: value })),
|
||||
createMultiplicativeModifier(() => ({ ...(options ?? {}), multiplier: value })),
|
||||
createExponentialModifier(() => ({ ...(options ?? {}), exponent: value }))
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -199,6 +199,17 @@ describe("Sequential Modifiers", () => {
|
|||
// So long as one is true or undefined, enable should be true
|
||||
expect(unref(modifier.enabled)).toBe(true);
|
||||
});
|
||||
test("respects enabled", () => {
|
||||
const value = ref(10);
|
||||
const enabled = ref(false);
|
||||
const modifier = createSequentialModifier(() => [
|
||||
createMultiplicativeModifier(() => ({ multiplier: 5, enabled }))
|
||||
]);
|
||||
const formula = modifier.getFormula(Formula.variable(value));
|
||||
expect(formula.evaluate()).compare_tolerance(value.value);
|
||||
enabled.value = true;
|
||||
expect(formula.evaluate()).not.compare_tolerance(value.value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applies smallerIsBetter correctly", () => {
|
||||
|
|
Loading…
Reference in a new issue