Compare commits

...

78 commits

Author SHA1 Message Date
2b861c3fcf Fix Links.vue checking startNode twice instead of both nodes
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m21s
Run Tests / test (push) Successful in 2m56s
Thanks escapee for reporting the issue!
2024-10-17 16:50:55 +00:00
9debfe6fb4 Unref hotkey descriptions
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m29s
Run Tests / test (push) Successful in 3m5s
2024-10-17 16:28:04 +00:00
9f25d7f58f Fix more modal paths
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m45s
Run Tests / test (push) Successful in 2m50s
2024-10-17 16:17:32 +00:00
239ae7c94a Update saves bank path 2024-10-17 16:17:32 +00:00
2d28be84a9 Add modal to take a mental health break 2024-10-17 16:17:32 +00:00
c6389317d0 Version Bump
All checks were successful
Run Tests / test (pull_request) Successful in 2m0s
Build and Deploy / build-and-deploy (push) Successful in 58s
Run Tests / test (push) Successful in 2m4s
2024-03-29 00:49:58 -05:00
b98f6db1c4 Move printFormula to Formula.stringify and add tests for it
All checks were successful
Run Tests / test (pull_request) Successful in 2m1s
Build and Deploy / build-and-deploy (push) Successful in 54s
Run Tests / test (push) Successful in 2m3s
2024-03-29 00:24:31 -05:00
563eaa7539 Lint
All checks were successful
Run Tests / test (pull_request) Successful in 2m3s
Build and Deploy / build-and-deploy (push) Successful in 53s
Run Tests / test (push) Successful in 2m4s
2024-03-29 00:19:57 -05:00
b88fa68874 Fix extends syntax
Some checks failed
Run Tests / test (pull_request) Failing after 2m4s
2024-03-28 23:40:47 -05:00
90d0307cf0 Add hotkey tests, make them pass
Includes updating vitest and supporting hotkeys with both ctrl+shift
2024-03-28 23:40:46 -05:00
dfb14acc6e Allow null and undefined values in persistent refs
Some checks failed
Build and Deploy / build-and-deploy (push) Successful in 57s
Run Tests / test (push) Failing after 2m6s
2024-03-29 04:39:56 +00:00
c30724d907 Make disabled elements not eat up mouse events
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 55s
Run Tests / test (push) Successful in 2m5s
Browsers make disabled elements eat events rather than let them bubble up
This allows containers with disabled elements to have listeners still
(Used specifically so after the board rewrite, purchased upgrades can still be dragged)
2024-03-08 12:19:52 +00:00
660528ea00 Update package-lock.json to new commit hash
All checks were successful
Run Tests / test (pull_request) Successful in 2m6s
Build and Deploy / build-and-deploy (push) Successful in 57s
Run Tests / test (push) Successful in 2m8s
2024-03-05 22:47:29 -06:00
b855139ab4 Fixed issues until it fully worked on Galaxy proper 2024-03-05 22:47:29 -06:00
c85bca110b Sync cloud saves every minute 2024-03-05 22:47:29 -06:00
d237201339 Update sdk 2024-03-05 22:47:29 -06:00
389e8ad1e1 Move unofficial sdk to repo 2024-03-05 22:47:28 -06:00
f6dec5c614 Sync deleting saves to cloud 2024-03-05 22:47:28 -06:00
af4229ebdd Attention grabber on saves manager when active save isn't synced 2024-03-05 22:47:28 -06:00
7a6f249f58 Add warning to saves manager when not all saves are synced 2024-03-05 22:47:28 -06:00
5c6ea01990 Add synced icon to saves in saves manager 2024-03-05 22:47:28 -06:00
f970b658ff Add "Keep Both" button 2024-03-05 22:47:28 -06:00
ece7ed2923 Add save conflict resolver 2024-03-05 22:47:27 -06:00
cfba55d2c6 Add galaxy api 2024-03-05 22:47:11 -06:00
b2d7a9ea1d Merge pull request 'Add eslint workflow action and CONTRIBUTING.md that says to lint first' (#66) from thepaperpilot/Profectus:feat/enforce-eslint into main
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 57s
Run Tests / test (push) Successful in 2m3s
Reviewed-on: #66
Reviewed-by: Escapee <escapee@incremental.social>
2024-03-06 04:45:20 +00:00
df9ba59a1a Merge branch 'main' into feat/enforce-eslint
All checks were successful
Run Tests / test (pull_request) Successful in 2m3s
2024-03-04 01:26:29 +00:00
escapee
b40d4bef32 Allow both cases in shift+hotkeys
All checks were successful
Run Tests / test (pull_request) Successful in 2m0s
Build and Deploy / build-and-deploy (push) Successful in 55s
Run Tests / test (push) Successful in 1m56s
2024-02-21 19:21:18 +00:00
f7a8fbbb11 Lint
All checks were successful
Run Tests / test (pull_request) Successful in 2m12s
2024-02-20 22:38:49 -06:00
5f8e35478d Merge remote-tracking branch 'upstream/main' into feat/enforce-eslint 2024-02-20 22:37:17 -06:00
64fad5c74a PR Feedback
Some checks failed
Run Tests / test (pull_request) Failing after 2m6s
2024-02-20 22:17:01 -06:00
1f22f506dd Add tests for tree reset propagation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 57s
Run Tests / test (push) Successful in 2m1s
2024-02-21 04:15:49 +00:00
d3faec6a66 Add Nodes to the text that can disappear in projEntry
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 58s
Run Tests / test (push) Successful in 2m3s
2024-02-21 04:08:59 +00:00
a39e65852d Remove unused imports
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m1s
Run Tests / test (push) Successful in 2m0s
2024-02-21 01:25:52 +00:00
1e2b20a70f PR feedback 2024-02-21 01:25:52 +00:00
2e0e221010 Made modifier typing a lot less nasty 2024-02-21 01:25:52 +00:00
4092cd6d56 Add regression test for modifier.getFormula respecting enabled 2024-02-21 01:25:52 +00:00
fa2d7cb53a Merge branch 'main' into feat/enforce-eslint
Some checks failed
Run Tests / test (pull_request) Failing after 2m1s
2024-02-18 02:17:01 +00:00
143b0773e7 Add eslint workflow action and CONTRIBUTING.md that says to lint first
Some checks failed
Run Tests / test (pull_request) Failing after 2m2s
2024-02-17 20:16:00 -06:00
cba79df80d Merge pull request 'Fix branchedResetPropagation' (#57) from nif/Profectus-Niffix:main into main
Some checks failed
Build and Deploy / build-and-deploy (push) Successful in 59s
Run Tests / test (push) Failing after 2m2s
Reviewed-on: #57
2024-02-14 17:39:06 +00:00
04a5e963ab Merge branch 'main' into main
Some checks failed
Run Tests / test (pull_request) Failing after 2m5s
2024-02-14 17:38:53 +00:00
263c951cf8 Requested changes
Some checks failed
Run Tests / test (pull_request) Has been cancelled
2024-02-14 15:56:18 +00:00
1b809a9550 Merge pull request 'Keep disabled modifiers when making formulas' (#56) from escapee/profectus-template:main into main
Some checks failed
Build and Deploy / build-and-deploy (push) Successful in 57s
Run Tests / test (push) Failing after 2m0s
Reviewed-on: #56
2024-02-13 05:18:01 +00:00
nif
5e32fa4985 Fix branchedResetPropagation
Some checks failed
Run Tests / test (pull_request) Has been cancelled
BREAKING CHANGE - Forces branches to be directed

Signed-off-by: nif <nif@incremental.social>
2024-02-12 19:46:31 +00:00
Seth Posner
cf6265d8ce Keep disabled modifiers when making formulas
Some checks failed
Run Tests / test (pull_request) Has been cancelled
2024-02-12 07:58:39 -08:00
4f807aaf96 Merge pull request 'Add forgejo workflows support' (#53) from thepaperpilot/Profectus:main into main
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m1s
Run Tests / test (push) Successful in 2m5s
Reviewed-on: #53
2024-02-11 19:43:37 +00:00
ffc42a5745 Merge pull request 'Fix reset button using currentAt when not yet gaining' (#25) from sethposner/profectus-template:main into main
Reviewed-on: #25
2023-11-13 22:29:49 +00:00
Seth Posner
8811996f64 Add tests confirming low-input conversion values 2023-11-13 14:10:00 -08:00
Seth Posner
7750a3368d Swap logic for nextAt display 2023-11-13 14:09:48 -08:00
2495dc9783 Implement forgejo actions workflows (#24)
Reviewed-on: #24
Co-authored-by: thepaperpilot <thepaperpilot@gmail.com>
Co-committed-by: thepaperpilot <thepaperpilot@gmail.com>
2023-11-05 17:00:28 +00:00
e66daad7a2 Merge pull request 'Implement isRednered' (#23) from thepaperpilot/Profectus:feature/is-rendered into main
Reviewed-on: #23
2023-11-05 16:59:37 +00:00
8065f8efa4 Approve install
All checks were successful
Run Tests / test (pull_request) Successful in 2m14s
2023-11-05 10:17:23 -06:00
e9283b5cca Update repos first 2023-11-05 10:16:38 -06:00
2c615ea524 Typo 2023-11-05 10:07:06 -06:00
953cd8047e Install rsync the normal way 2023-11-05 10:06:26 -06:00
766c600a70 Were the rsync logs wrong 2023-11-05 09:57:05 -06:00
c1d0b7eec6 Fully qualify >.< 2023-11-05 09:55:52 -06:00
52b500c9d8 setup rsync 2023-11-05 09:49:02 -06:00
aabb0a1bba Setup node 2023-11-05 09:13:56 -06:00
005bf5da9a Try alpine instead 2023-11-05 09:11:10 -06:00
7330a6bda4 Switch image to ubuntu:latest 2023-11-05 09:09:47 -06:00
65ff440e25 Fully qualify pages deploy action 2023-11-05 08:42:39 -06:00
acf1d24c15 Changed one of the wrong runs-on 2023-11-05 08:27:35 -06:00
d16bb55c3c Update runs-on 2023-11-05 07:46:30 -06:00
312cab1347 Rename workflow files 2023-11-05 07:42:28 -06:00
6d148da260 Add forgejo workflows 2023-11-05 07:28:43 -06:00
a5204106aa Forgot to comment the other signature 2023-10-11 21:44:02 -05:00
0cccf7aecc Add isRendered utility 2023-10-11 21:39:01 -05:00
d0fffd3b89 Update test.yml 2023-06-09 15:52:26 -05:00
3fe0311331 Try to allow actions to be run manually 2023-06-09 15:52:22 -05:00
eee5ac3e2d Fix passthroughs for inversions and make more operations invertible 2023-05-30 22:54:46 -05:00
9edda4d957 Make links ignore pointer events 2023-05-22 21:41:53 -05:00
6ad08c4052 Fix camelCase props not working on links 2023-05-21 17:27:29 -05:00
e0f1296b35 Rename "The Paper Pilot Community" to "Profectus & Friends" 2023-05-20 21:56:46 -05:00
c8ba77b89b Fix Direction.Left bars 2023-05-20 21:24:59 -05:00
63dcad4c12 Fix requirements tests 2023-05-20 08:30:07 -05:00
d6c9f95851 Fix error about pinnable tooltips 2023-05-20 08:28:27 -05:00
210c2290f0
Fix #9 2023-05-19 10:12:24 -05:00
e896fd84cf Change formula testing values to hopefully catch any other miscalculations 2023-05-19 08:04:20 -05:00
64 changed files with 2808 additions and 689 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

@ -0,0 +1,31 @@
name: Build and Deploy
on:
push:
branches:
- 'main'
workflow_dispatch:
jobs:
build-and-deploy:
if: github.repository != 'profectus-engine/Profectus' # Don't build placeholder mod on main repo
runs-on: docker
steps:
- name: Setup RSync
run: |
apt-get update
apt-get install -y rsync
- name: Checkout 🛎️
uses: actions/checkout@v2
with:
submodules: recursive
- name: Install and Build 🔧 # This example project is built using npm and outputs the result to the 'build' folder. Replace with the commands required to build your project, or remove this step entirely if your site is pre-built.
run: |
npm ci
npm run build
- name: Deploy 🚀
uses: https://github.com/JamesIves/github-pages-deploy-action@v4.2.5
with:
branch: pages # The branch the action should deploy to.
folder: dist # The folder the action should deploy.

View file

@ -0,0 +1,22 @@
name: Run Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: docker
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Use Node.js 16.x
uses: actions/setup-node@v3
with:
node-version: 16.x
- run: npm ci
- run: npm run build --if-present
- run: npm test
- run: npm run lint

View file

@ -3,6 +3,7 @@ on:
push: push:
branches: branches:
- 'main' - 'main'
workflow_dispatch:
jobs: jobs:
build-and-deploy: build-and-deploy:
if: github.repository != 'profectus-engine/Profectus' # Don't build placeholder mod on main repo if: github.repository != 'profectus-engine/Profectus' # Don't build placeholder mod on main repo

View file

@ -1,11 +1,11 @@
name: Build and Deploy name: Run Tests
on: on:
push: push:
branches: [ main ] branches: [ main ]
pull_request: pull_request:
branches: [ main ] branches: [ main ]
jobs: jobs:
build: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@ -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

@ -6,8 +6,10 @@
<Nav v-if="useHeader" /> <Nav v-if="useHeader" />
<Game /> <Game />
<TPS v-if="unref(showTPS)" /> <TPS v-if="unref(showTPS)" />
<AddictionWarning />
<GameOverScreen /> <GameOverScreen />
<NaNScreen /> <NaNScreen />
<CloudSaveResolver />
<component :is="gameComponent" /> <component :is="gameComponent" />
</div> </div>
</template> </template>
@ -16,14 +18,16 @@
<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 AddictionWarning from "components/modals/AddictionWarning.vue";
import CloudSaveResolver from "components/modals/CloudSaveResolver.vue";
import GameOverScreen from "components/modals/GameOverScreen.vue";
import NaNScreen from "components/modals/NaNScreen.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 NaNScreen from "./components/NaNScreen.vue";
import Nav from "./components/Nav.vue"; import Nav from "./components/Nav.vue";
import TPS from "./components/TPS.vue"; import TPS from "./components/TPS.vue";
import projInfo from "./data/projInfo.json"; import projInfo from "./data/projInfo.json";

View file

@ -15,9 +15,7 @@
<a :href="discordLink" target="_blank">{{ discordName }}</a> <a :href="discordLink" target="_blank">{{ discordName }}</a>
</li> </li>
<li> <li>
<a href="https://discord.gg/yJ4fjnjU54" target="_blank" <a href="https://discord.gg/yJ4fjnjU54" target="_blank">Profectus & Friends</a>
>The Paper Pilot Community</a
>
</li> </li>
<li> <li>
<a href="https://discord.gg/F3xveHV" target="_blank">The Modding Tree</a> <a href="https://discord.gg/F3xveHV" target="_blank">The Modding Tree</a>
@ -38,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()">
@ -55,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()">
@ -82,9 +80,7 @@
<a :href="discordLink" target="_blank">{{ discordName }}</a> <a :href="discordLink" target="_blank">{{ discordName }}</a>
</li> </li>
<li> <li>
<a href="https://discord.gg/yJ4fjnjU54" target="_blank" <a href="https://discord.gg/yJ4fjnjU54" target="_blank">Profectus & Friends</a>
>The Paper Pilot Community</a
>
</li> </li>
<li> <li>
<a href="https://discord.gg/F3xveHV" target="_blank">The Modding Tree</a> <a href="https://discord.gg/F3xveHV" target="_blank">The Modding Tree</a>
@ -102,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 "./modals/Info.vue";
import Options from "./Options.vue"; import Options from "./modals/Options.vue";
import SavesManager from "./SavesManager.vue"; import SavesManager from "./modals/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);
@ -121,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>
@ -268,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,83 @@
<template>
<Modal v-model="isOpen" v-bind="$attrs">
<template v-slot:header>
<div class="vga-modal-header">
<h2>Kindly consider taking a break.</h2>
</div>
</template>
<template v-slot:body>
<p>
You've been actively enjoying this game for awhile recently - and it's great that
you've been having a good time! That said, there are dangers to games like these that you should be aware of:
</p>
<p>
While incremental games can be fun and even healthy in certain contexts, they can
exacerbate video game addiction even more than other genres. If you feel like
playing incremental games is taking priority over other things in your life, or
manipulating your sleep schedule, it may be prudent to seek help.
</p>
<p>
<h4>Resources:</h4>
<span>
<a style="display: inline" href="https://www.samhsa.gov/" target="_blank">
SAMHSA
</a>
(<a style="display: inline" href="tel:1-800-662-4357">1-800-662-HELP</a>)
</span>
<br />
<a href="https://www.reddit.com/r/StopGaming/">r/StopGaming</a>
</p>
</template>
<template v-slot:footer>
<div class="vga-footer">
<button @click="neverShow" class="button">Never show this again</button>
<button @click="isOpen = false" class="button">Close</button>
</div>
</template>
</Modal>
<SavesManager ref="savesManager" />
</template>
<script setup lang="ts">
import projInfo from "data/projInfo.json";
import settings from "game/settings";
import state from "game/state";
import { ref, watchEffect } from "vue";
import Modal from "./Modal.vue";
const isOpen = ref(false);
watchEffect(() => {
if (
projInfo.disableHealthWarning === false &&
settings.showHealthWarning &&
state.mouseActivity.filter(i => i).length > 6
) {
isOpen.value = true;
}
});
function neverShow() {
settings.showHealthWarning = false;
isOpen.value = false;
}
</script>
<style scoped>
.vga-modal-header {
padding-top: 10px;
margin-left: 10px;
}
.vga-footer {
display: flex;
justify-content: flex-end;
}
.vga-footer button {
margin: 0 10px;
}
p {
margin-bottom: 10px;
}
</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 { 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 Modal from "./Modal.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

@ -37,14 +37,14 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import Modal from "components/Modal.vue";
import { hasWon } from "data/projEntry"; import { hasWon } from "data/projEntry";
import projInfo from "data/projInfo.json"; import projInfo from "data/projInfo.json";
import player from "game/player"; import player from "game/player";
import { formatTime } from "util/bignum"; import { formatTime } from "util/bignum";
import { loadSave, newSave } from "util/save"; import { loadSave, newSave } from "util/save";
import { computed, toRef } from "vue"; import { computed, toRef } from "vue";
import Toggle from "./fields/Toggle.vue"; import Toggle from "../fields/Toggle.vue";
import Modal from "./Modal.vue";
const { title, logo, discordName, discordLink, versionNumber, versionTitle } = projInfo; const { title, logo, discordName, discordLink, versionNumber, versionTitle } = projInfo;

View file

@ -38,7 +38,7 @@
target="_blank" target="_blank"
> >
<span class="material-icons info-modal-discord">discord</span> <span class="material-icons info-modal-discord">discord</span>
The Paper Pilot Community Profectus & Friends
</a> </a>
</div> </div>
<div> <div>
@ -60,7 +60,6 @@
</template> </template>
<script setup lang="tsx"> <script setup lang="tsx">
import Modal from "components/Modal.vue";
import type Changelog from "data/Changelog.vue"; import type Changelog from "data/Changelog.vue";
import projInfo from "data/projInfo.json"; import projInfo from "data/projInfo.json";
import { jsx } from "features/feature"; import { jsx } from "features/feature";
@ -69,6 +68,7 @@ import { infoComponents } from "game/settings";
import { formatTime } from "util/bignum"; import { formatTime } from "util/bignum";
import { coerceComponent, render } from "util/vue"; import { coerceComponent, render } from "util/vue";
import { computed, ref, toRefs, unref } from "vue"; import { computed, ref, toRefs, unref } from "vue";
import Modal from "./Modal.vue";
const { title, logo, author, discordName, discordLink, versionNumber, versionTitle } = projInfo; const { title, logo, author, discordName, discordLink, versionNumber, versionTitle } = projInfo;

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>
@ -41,10 +42,12 @@
<script setup lang="ts"> <script setup lang="ts">
import type { FeatureNode } from "game/layers"; import type { FeatureNode } from "game/layers";
import { computed, ref, toRefs, unref } from "vue"; import { computed, ref, toRefs, unref } from "vue";
import Context from "./Context.vue"; 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() {
emit("update:modelValue", false); if (unref(props.preventClosing) !== true) {
emit("update:modelValue", false);
}
} }
const isAnimating = ref(false); const isAnimating = ref(false);

View file

@ -19,7 +19,7 @@
class="nan-modal-discord-link" class="nan-modal-discord-link"
> >
<span class="material-icons nan-modal-discord">discord</span> <span class="material-icons nan-modal-discord">discord</span>
{{ discordName || "The Paper Pilot Community" }} {{ discordName || "Profectus & Friends" }}
</a> </a>
</div> </div>
<br /> <br />
@ -46,7 +46,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import Modal from "components/Modal.vue";
import projInfo from "data/projInfo.json"; import projInfo from "data/projInfo.json";
import player from "game/player"; import player from "game/player";
import state from "game/state"; import state from "game/state";
@ -54,7 +53,8 @@ import type { DecimalSource } from "util/bignum";
import Decimal, { format } from "util/bignum"; 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 Modal from "./Modal.vue";
import SavesManager from "./SavesManager.vue"; import SavesManager from "./SavesManager.vue";
const { discordName, discordLink } = projInfo; const { discordName, discordLink } = projInfo;

View file

@ -14,6 +14,7 @@
<Toggle :title="unthrottledTitle" v-model="unthrottled" /> <Toggle :title="unthrottledTitle" v-model="unthrottled" />
<Toggle v-if="projInfo.enablePausing" :title="isPausedTitle" v-model="isPaused" /> <Toggle v-if="projInfo.enablePausing" :title="isPausedTitle" v-model="isPaused" />
<Toggle :title="offlineProdTitle" v-model="offlineProd" /> <Toggle :title="offlineProdTitle" v-model="offlineProd" />
<Toggle :title="showHealthWarningTitle" v-model="showHealthWarning" v-if="!projInfo.disableHealthWarning" />
<Toggle :title="autosaveTitle" v-model="autosave" /> <Toggle :title="autosaveTitle" v-model="autosave" />
<FeedbackButton v-if="!autosave" class="button save-button" @click="save()">Manually save</FeedbackButton> <FeedbackButton v-if="!autosave" class="button save-button" @click="save()">Manually save</FeedbackButton>
</div> </div>
@ -28,20 +29,20 @@
</template> </template>
<script setup lang="tsx"> <script setup lang="tsx">
import Modal from "components/Modal.vue";
import projInfo from "data/projInfo.json"; import projInfo from "data/projInfo.json";
import { save } from "util/save";
import rawThemes from "data/themes"; import rawThemes from "data/themes";
import { jsx } from "features/feature"; import { jsx } from "features/feature";
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 settings, { settingFields } from "game/settings"; import settings, { settingFields } from "game/settings";
import { camelToTitle, Direction } from "util/common"; import { camelToTitle, Direction } from "util/common";
import { save } from "util/save";
import { coerceComponent, render } from "util/vue"; import { coerceComponent, render } from "util/vue";
import { computed, ref, toRefs } from "vue"; import { computed, ref, toRefs } from "vue";
import Select from "./fields/Select.vue"; import FeedbackButton from "../fields/FeedbackButton.vue";
import Toggle from "./fields/Toggle.vue"; import Select from "../fields/Select.vue";
import FeedbackButton from "./fields/FeedbackButton.vue"; import Toggle from "../fields/Toggle.vue";
import Modal from "./Modal.vue";
const isOpen = ref(false); const isOpen = ref(false);
const currentTab = ref("behaviour"); const currentTab = ref("behaviour");
@ -72,7 +73,7 @@ const settingFieldsComponent = computed(() => {
return coerceComponent(jsx(() => (<>{settingFields.map(render)}</>))); return coerceComponent(jsx(() => (<>{settingFields.map(render)}</>)));
}); });
const { showTPS, theme, unthrottled, alignUnits } = toRefs(settings); const { showTPS, theme, unthrottled, alignUnits, showHealthWarning } = toRefs(settings);
const { autosave, offlineProd } = toRefs(player); const { autosave, offlineProd } = toRefs(player);
const isPaused = computed({ const isPaused = computed({
get() { get() {
@ -91,10 +92,16 @@ const unthrottledTitle = jsx(() => (
)); ));
const offlineProdTitle = jsx(() => ( const offlineProdTitle = jsx(() => (
<span class="option-title"> <span class="option-title">
Offline Production<Tooltip display="Save-specific" direction={Direction.Right}>*</Tooltip> Offline production<Tooltip display="Save-specific" direction={Direction.Right}>*</Tooltip>
<desc>Simulate production that occurs while the game is closed.</desc> <desc>Simulate production that occurs while the game is closed.</desc>
</span> </span>
)); ));
const showHealthWarningTitle = jsx(() => (
<span class="option-title">
Show videogame addiction warning
<desc>Show a helpful warning after playing for a long time about video game addiction and encouraging you to take a break.</desc>
</span>
));
const autosaveTitle = jsx(() => ( const autosaveTitle = jsx(() => (
<span class="option-title"> <span class="option-title">
Autosave<Tooltip display="Save-specific" direction={Direction.Right}>*</Tooltip> Autosave<Tooltip display="Save-specific" direction={Direction.Right}>*</Tooltip>

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"
@ -57,18 +60,28 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import Modal from "components/Modal.vue";
import projInfo from "data/projInfo.json"; import projInfo from "data/projInfo.json";
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 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 { galaxy, syncedSaves } from "util/galaxy";
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 Modal from "./Modal.vue";
import Save from "./Save.vue"; import Save from "./Save.vue";
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;
@ -125,7 +130,7 @@ watch(saveToImport, importedSave => {
} }
}); });
let bankContext = import.meta.globEager("./../../saves/*.txt", { as: "raw" }); let bankContext = import.meta.globEager("./../../../saves/*.txt", { as: "raw" });
let bank = ref( let bank = ref(
Object.keys(bankContext).reduce((acc: Array<{ label: string; value: string }>, curr) => { Object.keys(bankContext).reduce((acc: Array<{ label: string; value: string }>, curr) => {
acc.push({ acc.push({
@ -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

@ -19,7 +19,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import Modal from "components/Modal.vue"; import Modal from "components/modals/Modal.vue";
import { ref } from "vue"; import { ref } from "vue";
const isOpen = ref(false); const isOpen = ref(false);

View file

@ -9,6 +9,7 @@ import { Resource, displayResource } from "features/resources/resource";
import type { GenericTree, GenericTreeNode, TreeNode, TreeNodeOptions } from "features/trees/tree"; import type { GenericTree, GenericTreeNode, TreeNode, TreeNodeOptions } from "features/trees/tree";
import { createTreeNode } from "features/trees/tree"; import { createTreeNode } from "features/trees/tree";
import type { GenericFormula } from "game/formulas/types"; import type { GenericFormula } from "game/formulas/types";
import { BaseLayer } from "game/layers";
import type { Modifier } from "game/modifiers"; import type { Modifier } from "game/modifiers";
import type { Persistent } from "game/persistence"; import type { Persistent } from "game/persistence";
import { DefaultValue, persistent } from "game/persistence"; import { DefaultValue, persistent } from "game/persistence";
@ -133,8 +134,8 @@ export function createResetButton<T extends ClickableOptions & ResetButtonOption
{unref(resetButton.conversion.buyMax) ? "Next:" : "Req:"}{" "} {unref(resetButton.conversion.buyMax) ? "Next:" : "Req:"}{" "}
{displayResource( {displayResource(
resetButton.conversion.baseResource, resetButton.conversion.baseResource,
!unref(resetButton.conversion.buyMax) || !unref(resetButton.conversion.buyMax) &&
Decimal.lt(unref(resetButton.conversion.actualGain), 1) Decimal.gte(unref(resetButton.conversion.actualGain), 1)
? unref(resetButton.conversion.currentAt) ? unref(resetButton.conversion.currentAt)
: unref(resetButton.conversion.nextAt) : unref(resetButton.conversion.nextAt)
)}{" "} )}{" "}
@ -485,3 +486,22 @@ export function createFormulaPreview(
return <>{formatSmall(formula.evaluate())}</>; return <>{formatSmall(formula.evaluate())}</>;
}); });
} }
/**
* Utility function for getting a computed boolean for whether or not a given feature is currently rendered in the DOM.
* Note it will have a true value even if the feature is off screen.
* @param layer The layer the feature appears within
* @param id The ID of the feature
*/
export function isRendered(layer: BaseLayer, id: string): ComputedRef<boolean>;
/**
* Utility function for getting a computed boolean for whether or not a given feature is currently rendered in the DOM.
* Note it will have a true value even if the feature is off screen.
* @param layer The layer the feature appears within
* @param feature The feature that may be rendered
*/
export function isRendered(layer: BaseLayer, feature: { id: string }): ComputedRef<boolean>;
export function isRendered(layer: BaseLayer, idOrFeature: string | { id: string }) {
const id = typeof idOrFeature === "string" ? idOrFeature : idOrFeature.id;
return computed(() => id in layer.nodes.value);
}

View file

@ -37,7 +37,7 @@ const layer = createLayer(id, function (this: BaseLayer) {
color, color,
reset reset
})); }));
addTooltip(treeNode, { const tooltip = addTooltip(treeNode, {
display: createResourceTooltip(points), display: createResourceTooltip(points),
pinnable: true pinnable: true
}); });
@ -58,6 +58,7 @@ const layer = createLayer(id, function (this: BaseLayer) {
name, name,
color, color,
points, points,
tooltip,
display: jsx(() => ( display: jsx(() => (
<> <>
<MainDisplay resource={points} color={color} /> <MainDisplay resource={points} color={color} />

View file

@ -1,3 +1,4 @@
import Node from "components/Node.vue";
import Spacer from "components/layout/Spacer.vue"; import Spacer from "components/layout/Spacer.vue";
import { jsx } from "features/feature"; import { jsx } from "features/feature";
import { createResource, trackBest, trackOOMPS, trackTotal } from "features/resources/resource"; import { createResource, trackBest, trackOOMPS, trackTotal } from "features/resources/resource";
@ -48,19 +49,35 @@ export const main = createLayer("main", function (this: BaseLayer) {
links: tree.links, links: tree.links,
display: jsx(() => ( 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 ? ( {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} ) : null}
{player.offlineTime != null && player.offlineTime !== 0 ? ( {player.offlineTime != null && player.offlineTime !== 0 ? (
<div>Offline Time: {formatTime(player.offlineTime)}</div> <div>
Offline Time: {formatTime(player.offlineTime)}
<Node id="offline" />
</div>
) : null} ) : null}
<div> <div>
{Decimal.lt(points.value, "1e1000") ? <span>You have </span> : null} {Decimal.lt(points.value, "1e1000") ? <span>You have </span> : null}
<h2>{format(points.value)}</h2> <h2>{format(points.value)}</h2>
{Decimal.lt(points.value, "1e1e6") ? <span> points</span> : null} {Decimal.lt(points.value, "1e1e6") ? <span> points</span> : null}
</div> </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 /> <Spacer />
{render(tree)} {render(tree)}
</> </>

View file

@ -88,6 +88,10 @@
"type": "string", "type": "string",
"enum": ["base64", "lz", "plain"], "enum": ["base64", "lz", "plain"],
"description": "The encoding to use when exporting to the clipboard. Plain-text is fast to generate but is easiest for the player to manipulate and cheat with. Base 64 is slightly slower and the string will be longer but will offer a small barrier to people trying to cheat. LZ-String is the slowest method, but produces the smallest strings and still offers a small barrier to those trying to cheat. Some sharing platforms like pastebin may automatically delete base64 encoded text, and some sites might not support all the characters used in lz-string exports." "description": "The encoding to use when exporting to the clipboard. Plain-text is fast to generate but is easiest for the player to manipulate and cheat with. Base 64 is slightly slower and the string will be longer but will offer a small barrier to people trying to cheat. LZ-String is the slowest method, but produces the smallest strings and still offers a small barrier to those trying to cheat. Some sharing platforms like pastebin may automatically delete base64 encoded text, and some sites might not support all the characters used in lz-string exports."
},
"disableHealthWarning": {
"type": "boolean",
"description": "Whether or not to disable the health warning that appears to the player after excessive playtime (activity during 6 of the last 8 hours). If left enabled, the player will still be able to individually turn off the health warning in settings or by clicking \"Do not show again\" in the warning itself."
} }
} }
} }

View file

@ -22,5 +22,6 @@
"maxTickLength": 3600, "maxTickLength": 3600,
"offlineLimit": 1, "offlineLimit": 1,
"enablePausing": true, "enablePausing": true,
"exportEncoding": "base64" "exportEncoding": "base64",
"disableHealthWarning": false
} }

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

@ -120,7 +120,7 @@ export default defineComponent({
barStyle.clipPath = `inset(0% ${normalizedProgress.value}% 0% 0%)`; barStyle.clipPath = `inset(0% ${normalizedProgress.value}% 0% 0%)`;
break; break;
case Direction.Left: case Direction.Left:
barStyle.clipPath = `inset(0% 0% 0% ${normalizedProgress.value} + '%)`; barStyle.clipPath = `inset(0% 0% 0% ${normalizedProgress.value}%)`;
break; break;
case Direction.Default: case Direction.Default:
barStyle.clipPath = "inset(0% 50% 0% 0%)"; barStyle.clipPath = "inset(0% 50% 0% 0%)";

View file

@ -1,7 +1,7 @@
<template> <template>
<line <line
class="link" class="link"
v-bind="link" v-bind="linkProps"
:class="{ pulsing: link.pulsing }" :class="{ pulsing: link.pulsing }"
:x1="startPosition.x" :x1="startPosition.x"
:y1="startPosition.y" :y1="startPosition.y"
@ -12,6 +12,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { BoardNode, BoardNodeLink } from "features/boards/board"; import type { BoardNode, BoardNodeLink } from "features/boards/board";
import { kebabifyObject } from "util/vue";
import { computed, toRefs, unref } from "vue"; import { computed, toRefs, unref } from "vue";
const _props = defineProps<{ const _props = defineProps<{
@ -49,11 +50,14 @@ const endPosition = computed(() => {
} }
return position; return position;
}); });
const linkProps = computed(() => kebabifyObject(_props.link as unknown as Record<string, unknown>));
</script> </script>
<style scoped> <style scoped>
.link { .link {
transition-duration: 0s; transition-duration: 0s;
pointer-events: none;
} }
.link.pulsing { .link.pulsing {

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];
if (hotkey && unref(hotkey.enabled)) { if (hotkey && unref(hotkey.enabled)) {
e.preventDefault(); e.preventDefault();
hotkey.onPress(); hotkey.onPress();
@ -128,7 +142,7 @@ registerInfoComponent(
<div style="column-count: 2"> <div style="column-count: 2">
{keys.map(hotkey => ( {keys.map(hotkey => (
<div> <div>
<Hotkey hotkey={hotkey as GenericHotkey} /> {hotkey?.description} <Hotkey hotkey={hotkey as GenericHotkey} /> {unref(hotkey?.description)}
</div> </div>
))} ))}
</div> </div>

View file

@ -2,7 +2,7 @@
<line <line
stroke-width="15px" stroke-width="15px"
stroke="white" stroke="white"
v-bind="link" v-bind="linkProps"
:x1="startPosition.x" :x1="startPosition.x"
:y1="startPosition.y" :y1="startPosition.y"
:x2="endPosition.x" :x2="endPosition.x"
@ -13,6 +13,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Link } from "features/links/links"; import type { Link } from "features/links/links";
import type { FeatureNode } from "game/layers"; import type { FeatureNode } from "game/layers";
import { kebabifyObject } from "util/vue";
import { computed, toRefs } from "vue"; import { computed, toRefs } from "vue";
const _props = defineProps<{ const _props = defineProps<{
@ -54,4 +55,6 @@ const endPosition = computed(() => {
} }
return position; return position;
}); });
const linkProps = computed(() => kebabifyObject(_props.link as unknown as Record<string, unknown>));
</script> </script>

View file

@ -36,7 +36,7 @@ onMounted(() => (boundingRect.value = resizeListener.value?.getBoundingClientRec
const validLinks = computed(() => { const validLinks = computed(() => {
const n = nodes.value; const n = nodes.value;
return ( return (
links.value?.filter(link => n[link.startNode.id]?.rect && n[link.startNode.id]?.rect) ?? [] links.value?.filter(link => n[link.startNode.id]?.rect && n[link.endNode.id]?.rect) ?? []
); );
}); });
</script> </script>

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,
@ -95,18 +95,6 @@ export function addTooltip<T extends TooltipOptions>(
} }
nextTick(() => { nextTick(() => {
if (options.pinnable) {
if ("pinned" in element) {
console.error(
"Cannot add pinnable tooltip to element that already has a property called 'pinned'"
);
options.pinnable = false;
deletePersistent(options.pinned as Persistent<boolean>);
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(element as any).pinned = options.pinned;
}
}
const elementComponent = element[Component]; const elementComponent = element[Component];
element[Component] = TooltipComponent as GenericComponent; element[Component] = TooltipComponent as GenericComponent;
const elementGatherProps = element[GatherProps].bind(element); const elementGatherProps = element[GatherProps].bind(element);

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,
@ -224,7 +224,7 @@ export interface BaseTree {
id: string; id: string;
/** The link objects for each of the branches of the tree. */ /** The link objects for each of the branches of the tree. */
links: Ref<Link[]>; 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; reset: (node: GenericTreeNode) => void;
/** A flag that is true while the reset is still propagating through the tree. */ /** A flag that is true while the reset is still propagating through the tree. */
isResetting: Ref<boolean>; isResetting: Ref<boolean>;
@ -338,34 +338,21 @@ export const branchedResetPropagation = function (
tree: GenericTree, tree: GenericTree,
resettingNode: GenericTreeNode resettingNode: GenericTreeNode
): void { ): void {
const visitedNodes = [resettingNode]; const links = unref(tree.branches);
let currentNodes = [resettingNode]; if (links == null) return;
if (tree.branches != null) { const reset: GenericTreeNode[] = [];
const branches = unref(tree.branches); let current = [resettingNode];
while (currentNodes.length > 0) { while (current.length !== 0) {
const nextNodes: GenericTreeNode[] = []; const next: GenericTreeNode[] = [];
currentNodes.forEach(node => { for (const node of current) {
branches for (const link of links.filter(link => link.startNode === node)) {
.filter(branch => branch.startNode === node || branch.endNode === node) if ([...reset, ...current].includes(link.endNode)) continue;
.map(branch => { next.push(link.endNode);
if (branch.startNode === node) { link.endNode.reset?.reset();
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);
} }
reset.push(...current);
current = next;
} }
}; };

View file

@ -2,6 +2,7 @@ import type { Settings } from "game/settings";
import { createNanoEvents } from "nanoevents"; import { createNanoEvents } from "nanoevents";
import type { App } from "vue"; import type { App } from "vue";
import type { GenericLayer } from "./layers"; import type { GenericLayer } from "./layers";
import state from "./state";
/** All types of events able to be sent or emitted from the global event bus. */ /** All types of events able to be sent or emitted from the global event bus. */
export interface GlobalEvents { export interface GlobalEvents {
@ -59,3 +60,7 @@ if ("fonts" in document) {
// JSDom doesn't add document.fonts, and Object.defineProperty doesn't seem to work on document // JSDom doesn't add document.fonts, and Object.defineProperty doesn't seem to work on document
document.fonts.onloadingdone = () => globalBus.emit("fontsLoaded"); document.fonts.onloadingdone = () => globalBus.emit("fontsLoaded");
} }
document.onmousemove = function () {
state.mouseActivity[state.mouseActivity.length - 1] = true;
};

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(
@ -345,19 +370,35 @@ export abstract class InternalFormula<T extends [FormulaSource] | FormulaSource[
public static sgn = InternalFormula.sign; public static sgn = InternalFormula.sign;
public static round(value: FormulaSource) { public static round(value: FormulaSource) {
return new Formula({ inputs: [value], evaluate: Decimal.round }); return new Formula({
inputs: [value],
evaluate: Decimal.round,
invert: ops.invertPassthrough
});
} }
public static floor(value: FormulaSource) { public static floor(value: FormulaSource) {
return new Formula({ inputs: [value], evaluate: Decimal.floor }); return new Formula({
inputs: [value],
evaluate: Decimal.floor,
invert: ops.invertPassthrough
});
} }
public static ceil(value: FormulaSource) { public static ceil(value: FormulaSource) {
return new Formula({ inputs: [value], evaluate: Decimal.ceil }); return new Formula({
inputs: [value],
evaluate: Decimal.ceil,
invert: ops.invertPassthrough
});
} }
public static trunc(value: FormulaSource) { public static trunc(value: FormulaSource) {
return new Formula({ inputs: [value], evaluate: Decimal.trunc }); return new Formula({
inputs: [value],
evaluate: Decimal.trunc,
invert: ops.invertPassthrough
});
} }
public static add<T extends GenericFormula>(value: T, other: FormulaSource): T; public static add<T extends GenericFormula>(value: T, other: FormulaSource): T;
@ -459,7 +500,7 @@ export abstract class InternalFormula<T extends [FormulaSource] | FormulaSource[
return new Formula({ return new Formula({
inputs: [value, min, max], inputs: [value, min, max],
evaluate: Decimal.clamp, evaluate: Decimal.clamp,
invert: ops.passthrough as InvertFunction<[FormulaSource, FormulaSource, FormulaSource]> invert: ops.invertPassthrough
}); });
} }
@ -862,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
@ -1386,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

@ -1,6 +1,12 @@
import Decimal, { DecimalSource } from "util/bignum"; import Decimal, { DecimalSource } from "util/bignum";
import Formula, { hasVariable, unrefFormulaSource } from "./formulas"; import Formula, { hasVariable, unrefFormulaSource } from "./formulas";
import { FormulaSource, GenericFormula, InvertFunction, SubstitutionStack } from "./types"; import {
FormulaSource,
GenericFormula,
InvertFunction,
InvertibleFormula,
SubstitutionStack
} from "./types";
const ln10 = Decimal.ln(10); const ln10 = Decimal.ln(10);
@ -8,6 +14,15 @@ export function passthrough<T extends GenericFormula | DecimalSource>(value: T):
return value; return value;
} }
export function invertPassthrough(value: DecimalSource, ...inputs: FormulaSource[]) {
const variable = inputs.find(input => hasVariable(input)) as InvertibleFormula | undefined;
if (variable == null) {
console.error("Could not invert due to no input being a variable");
return 0;
}
return variable.invert(value);
}
export function invertNeg(value: DecimalSource, lhs: FormulaSource) { export function invertNeg(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) { if (hasVariable(lhs)) {
return lhs.invert(Decimal.neg(value)); return lhs.invert(Decimal.neg(value));
@ -537,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)) {
@ -561,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)) {
@ -611,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;
} }
@ -107,3 +107,7 @@ export async function startGameLoop() {
intervalID = setInterval(update, 50); intervalID = setInterval(update, 50);
} }
} }
setInterval(() => {
state.mouseActivity = [...state.mouseActivity.slice(-7), false];
}, 1000 * 60 * 60);

View file

@ -1,4 +1,4 @@
import Modal from "components/Modal.vue"; import Modal from "components/modals/Modal.vue";
import type { import type {
CoercableComponent, CoercableComponent,
JSXFunction, JSXFunction,

View file

@ -4,7 +4,7 @@ import { jsx } from "features/feature";
import settings from "game/settings"; import settings from "game/settings";
import type { DecimalSource } from "util/bignum"; import type { DecimalSource } from "util/bignum";
import Decimal, { formatSmall } 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 type { Computable, ProcessedComputable } from "util/computed";
import { convertComputable } from "util/computed"; import { convertComputable } from "util/computed";
import { createLazyProxy } from "util/proxies"; import { createLazyProxy } from "util/proxies";
@ -38,16 +38,11 @@ export interface Modifier {
description?: ProcessedComputable<CoercableComponent>; description?: ProcessedComputable<CoercableComponent>;
} }
/** /** Utility type that represents the output of all modifiers that represent a single operation. */
* 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 OperationModifier<T> = WithRequired<
*/ Modifier,
export type ModifierFromOptionalParams<T, S> = undefined extends T "invert" | "getFormula" | Extract<RequiredKeys<T>, keyof Modifier>
? 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">;
/** An object that configures an additive modifier via {@link createAdditiveModifier}. */ /** An object that configures an additive modifier via {@link createAdditiveModifier}. */
export interface AdditiveModifierOptions { export interface AdditiveModifierOptions {
@ -65,9 +60,9 @@ export interface AdditiveModifierOptions {
* Create a modifier that adds some value to the input value. * Create a modifier that adds some value to the input value.
* @param optionsFunc Additive modifier options. * @param optionsFunc Additive modifier options.
*/ */
export function createAdditiveModifier<T extends AdditiveModifierOptions>( export function createAdditiveModifier<T extends AdditiveModifierOptions, S = OperationModifier<T>>(
optionsFunc: OptionsFunc<T> optionsFunc: OptionsFunc<T>
): ModifierFromOptionalParams<T["description"], T["enabled"]> { ) {
return createLazyProxy(feature => { return createLazyProxy(feature => {
const { addend, description, enabled, smallerIsBetter } = optionsFunc.call( const { addend, description, enabled, smallerIsBetter } = optionsFunc.call(
feature, feature,
@ -111,7 +106,7 @@ export function createAdditiveModifier<T extends AdditiveModifierOptions>(
</div> </div>
)) ))
}; };
}) as unknown as ModifierFromOptionalParams<T["description"], T["enabled"]>; }) as S;
} }
/** An object that configures an multiplicative modifier via {@link createMultiplicativeModifier}. */ /** 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. * Create a modifier that multiplies the input value by some value.
* @param optionsFunc Multiplicative modifier options. * @param optionsFunc Multiplicative modifier options.
*/ */
export function createMultiplicativeModifier<T extends MultiplicativeModifierOptions>( export function createMultiplicativeModifier<
optionsFunc: OptionsFunc<T> T extends MultiplicativeModifierOptions,
): ModifierFromOptionalParams<T["description"], T["enabled"]> { S = OperationModifier<T>
>(optionsFunc: OptionsFunc<T>) {
return createLazyProxy(feature => { return createLazyProxy(feature => {
const { multiplier, description, enabled, smallerIsBetter } = optionsFunc.call( const { multiplier, description, enabled, smallerIsBetter } = optionsFunc.call(
feature, feature,
@ -175,7 +171,7 @@ export function createMultiplicativeModifier<T extends MultiplicativeModifierOpt
</div> </div>
)) ))
}; };
}) as unknown as ModifierFromOptionalParams<T["description"], T["enabled"]>; }) as S;
} }
/** An object that configures an exponential modifier via {@link createExponentialModifier}. */ /** 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. * Create a modifier that raises the input value to the power of some value.
* @param optionsFunc Exponential modifier options. * @param optionsFunc Exponential modifier options.
*/ */
export function createExponentialModifier<T extends ExponentialModifierOptions>( export function createExponentialModifier<
optionsFunc: OptionsFunc<T> T extends ExponentialModifierOptions,
): ModifierFromOptionalParams<T["description"], T["enabled"]> { S = OperationModifier<T>
>(optionsFunc: OptionsFunc<T>) {
return createLazyProxy(feature => { return createLazyProxy(feature => {
const { exponent, description, enabled, supportLowNumbers, smallerIsBetter } = const { exponent, description, enabled, supportLowNumbers, smallerIsBetter } =
optionsFunc.call(feature, feature); optionsFunc.call(feature, feature);
@ -263,7 +260,7 @@ export function createExponentialModifier<T extends ExponentialModifierOptions>(
</div> </div>
)) ))
}; };
}) as unknown as ModifierFromOptionalParams<T["description"], T["enabled"]>; }) as S;
} }
/** /**
@ -274,11 +271,9 @@ export function createExponentialModifier<T extends ExponentialModifierOptions>(
* @see {@link createModifierSection}. * @see {@link createModifierSection}.
*/ */
export function createSequentialModifier< export function createSequentialModifier<
T extends Modifier[], T extends Modifier,
S = T extends WithRequired<Modifier, "invert">[] S = WithRequired<Modifier, Extract<RequiredKeys<T>, keyof Modifier>>
? WithRequired<Modifier, "description" | "invert"> >(modifiersFunc: () => T[]) {
: Omit<WithRequired<Modifier, "description">, "invert">
>(modifiersFunc: () => T): S {
return createLazyProxy(() => { return createLazyProxy(() => {
const modifiers = modifiersFunc(); const modifiers = modifiersFunc();
@ -296,10 +291,14 @@ export function createSequentialModifier<
: undefined, : undefined,
getFormula: modifiers.every(m => m.getFormula != null) getFormula: modifiers.every(m => m.getFormula != null)
? (gain: FormulaSource) => ? (gain: FormulaSource) =>
modifiers modifiers.reduce((acc, curr) => {
.filter(m => unref(m.enabled) !== false) 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 // 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, : undefined,
enabled: modifiers.some(m => m.enabled != null) enabled: modifiers.some(m => m.enabled != null)
? computed(() => modifiers.filter(m => unref(m.enabled) !== false).length > 0) ? computed(() => modifiers.filter(m => unref(m.enabled) !== false).length > 0)
@ -317,7 +316,7 @@ export function createSequentialModifier<
)) ))
: undefined : undefined
}; };
}) as unknown as S; }) as S;
} }
/** An object that configures a modifier section via {@link createModifierSection}. */ /** An object that configures a modifier section via {@link createModifierSection}. */

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

@ -223,6 +223,8 @@ export function createCostRequirement<T extends CostRequirementOptions>(
req.resource.value, req.resource.value,
unref(req.cost as ProcessedComputable<DecimalSource>) unref(req.cost as ProcessedComputable<DecimalSource>)
) )
? 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. */
@ -20,6 +20,8 @@ export interface Settings {
unthrottled: boolean; unthrottled: boolean;
/** Whether to align modifiers to the unit. */ /** Whether to align modifiers to the unit. */
alignUnits: boolean; alignUnits: boolean;
/** Whether or not to show a video game health warning after playing excessively. */
showHealthWarning: boolean;
} }
const state = reactive<Partial<Settings>>({ const state = reactive<Partial<Settings>>({
@ -28,7 +30,8 @@ const state = reactive<Partial<Settings>>({
showTPS: true, showTPS: true,
theme: Themes.Nordic, theme: Themes.Nordic,
unthrottled: false, unthrottled: false,
alignUnits: false alignUnits: false,
showHealthWarning: true
}); });
watch( watch(
@ -56,12 +59,15 @@ declare global {
export default window.settings = state as Settings; export default window.settings = state as Settings;
/** A function that erases all player settings, including all saves. */ /** A function that erases all player settings, including all saves. */
export const hardResetSettings = (window.hardResetSettings = () => { export const hardResetSettings = (window.hardResetSettings = () => {
const settings = { // Only partial because of any properties that are only added during the loadSettings event.
const settings: Partial<Settings> = {
active: "", active: "",
saves: [], saves: [],
showTPS: true, showTPS: true,
theme: Themes.Nordic, theme: Themes.Nordic,
alignUnits: false unthrottled: false,
alignUnits: false,
showHealthWarning: true
}; };
globalBus.emit("loadSettings", settings); globalBus.emit("loadSettings", settings);
Object.assign(state, settings); Object.assign(state, settings);
@ -78,16 +84,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

@ -6,6 +6,8 @@ import type { Persistent } from "./persistence";
export interface Transient { export interface Transient {
/** A list of the duration, in ms, of the last 10 game ticks. Used for calculating TPS. */ /** A list of the duration, in ms, of the last 10 game ticks. Used for calculating TPS. */
lastTenTicks: number[]; lastTenTicks: number[];
/** A list of bools represnting which of the last few hours had mouse activity. */
mouseActivity: boolean[];
/** Whether or not a NaN value has been detected and undealt with. */ /** Whether or not a NaN value has been detected and undealt with. */
hasNaN: boolean; hasNaN: boolean;
/** The location within the player save data object of the NaN value. */ /** The location within the player save data object of the NaN value. */
@ -25,6 +27,7 @@ declare global {
/** The global transient state object. */ /** The global transient state object. */
export default window.state = shallowReactive<Transient>({ export default window.state = shallowReactive<Transient>({
lastTenTicks: [], lastTenTicks: [],
mouseActivity: [false],
hasNaN: false, hasNaN: false,
NaNPath: [], NaNPath: [],
errors: reactive([]) errors: reactive([])

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);

View file

@ -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 WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };
export type ArrayElements<T extends ReadonlyArray<unknown>> = T extends ReadonlyArray<infer S> export type ArrayElements<T extends ReadonlyArray<unknown>> = T extends ReadonlyArray<infer S>
@ -12,6 +20,11 @@ export function camelToTitle(camel: string): string {
return title; return title;
} }
export function camelToKebab(camel: string) {
// Split off first character so function works on upper camel (pascal) case
return (camel[0] + camel.slice(1).replace(/[A-Z]/g, c => `-${c}`)).toLowerCase();
}
export function isFunction<T, S extends ReadonlyArray<unknown>, R>( export function isFunction<T, S extends ReadonlyArray<unknown>, R>(
functionOrValue: ((...args: S) => T) | R functionOrValue: ((...args: S) => T) | R
): functionOrValue is (...args: S) => T { ): functionOrValue is (...args: S) => T {

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

@ -0,0 +1,185 @@
import { LoadablePlayerData } from "components/modals/SavesManager.vue";
import player, { Player, stringifySave } from "game/player";
import settings from "game/settings";
import LZString from "lz-string";
import { GalaxyApi, initGalaxy } from "unofficial-galaxy-sdk";
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/modals/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

@ -21,6 +21,7 @@ import {
unref, unref,
watchEffect watchEffect
} from "vue"; } from "vue";
import { camelToKebab } from "./common";
export function coerceComponent( export function coerceComponent(
component: CoercableComponent, component: CoercableComponent,
@ -190,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);
}); });
@ -241,3 +242,10 @@ export function trackHover(element: VueFeature): Ref<boolean> {
return isHovered; return isHovered;
} }
export function kebabifyObject(object: Record<string, unknown>) {
return Object.keys(object).reduce((acc, curr) => {
acc[camelToKebab(curr)] = object[curr];
return acc;
}, {} as Record<string, unknown>);
}

View file

@ -47,6 +47,10 @@ describe("Creating conversion", () => {
baseResource.value = Decimal.pow(100, 2).times(10).add(1); baseResource.value = Decimal.pow(100, 2).times(10).add(1);
expect(unref(conversion.currentGain)).compare_tolerance(100); expect(unref(conversion.currentGain)).compare_tolerance(100);
}); });
test("Zero", () => {
baseResource.value = Decimal.dZero;
expect(unref(conversion.currentGain)).compare_tolerance(0);
});
}); });
describe("Calculates actualGain correctly", () => { describe("Calculates actualGain correctly", () => {
let conversion: GenericConversion; let conversion: GenericConversion;
@ -69,6 +73,10 @@ describe("Creating conversion", () => {
baseResource.value = Decimal.pow(100, 2).times(10).add(1); baseResource.value = Decimal.pow(100, 2).times(10).add(1);
expect(unref(conversion.actualGain)).compare_tolerance(100); expect(unref(conversion.actualGain)).compare_tolerance(100);
}); });
test("Zero", () => {
baseResource.value = Decimal.dZero;
expect(unref(conversion.actualGain)).compare_tolerance(0);
});
}); });
describe("Calculates currentAt correctly", () => { describe("Calculates currentAt correctly", () => {
let conversion: GenericConversion; let conversion: GenericConversion;
@ -95,6 +103,10 @@ describe("Creating conversion", () => {
Decimal.pow(100, 2).times(10) Decimal.pow(100, 2).times(10)
); );
}); });
test("Zero", () => {
baseResource.value = Decimal.dZero;
expect(unref(conversion.currentAt)).compare_tolerance(0);
});
}); });
describe("Calculates nextAt correctly", () => { describe("Calculates nextAt correctly", () => {
let conversion: GenericConversion; let conversion: GenericConversion;
@ -117,6 +129,10 @@ describe("Creating conversion", () => {
baseResource.value = Decimal.pow(100, 2).times(10).add(1); baseResource.value = Decimal.pow(100, 2).times(10).add(1);
expect(unref(conversion.nextAt)).compare_tolerance(Decimal.pow(101, 2).times(10)); 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", () => { test("Converts correctly", () => {
const conversion = createCumulativeConversion(() => ({ const conversion = createCumulativeConversion(() => ({
@ -193,6 +209,10 @@ describe("Creating conversion", () => {
baseResource.value = Decimal.pow(100, 2).times(10).add(1); baseResource.value = Decimal.pow(100, 2).times(10).add(1);
expect(unref(conversion.currentGain)).compare_tolerance(100); expect(unref(conversion.currentGain)).compare_tolerance(100);
}); });
test("Zero", () => {
baseResource.value = Decimal.dZero;
expect(unref(conversion.currentGain)).compare_tolerance(1);
});
}); });
describe("Calculates actualGain correctly", () => { describe("Calculates actualGain correctly", () => {
let conversion: GenericConversion; let conversion: GenericConversion;
@ -216,6 +236,10 @@ describe("Creating conversion", () => {
baseResource.value = Decimal.pow(100, 2).times(10).add(1); baseResource.value = Decimal.pow(100, 2).times(10).add(1);
expect(unref(conversion.actualGain)).compare_tolerance(99); expect(unref(conversion.actualGain)).compare_tolerance(99);
}); });
test("Zero", () => {
baseResource.value = Decimal.dZero;
expect(unref(conversion.actualGain)).compare_tolerance(0);
});
}); });
describe("Calculates currentAt correctly", () => { describe("Calculates currentAt correctly", () => {
let conversion: GenericConversion; let conversion: GenericConversion;
@ -243,6 +267,10 @@ describe("Creating conversion", () => {
Decimal.pow(100, 2).times(10) 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", () => { describe("Calculates nextAt correctly", () => {
let conversion: GenericConversion; let conversion: GenericConversion;
@ -266,6 +294,10 @@ describe("Creating conversion", () => {
baseResource.value = Decimal.pow(100, 2).times(10).add(1); baseResource.value = Decimal.pow(100, 2).times(10).add(1);
expect(unref(conversion.nextAt)).compare_tolerance(Decimal.pow(101, 2).times(10)); 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", () => { test("Converts correctly", () => {
const conversion = createIndependentConversion(() => ({ const conversion = createIndependentConversion(() => ({

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

111
tests/features/tree.test.ts Normal file
View 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);
});
});

View file

@ -13,9 +13,13 @@ import { InvertibleIntegralFormula } from "game/formulas/types";
type FormulaFunctions = keyof GenericFormula & keyof typeof Formula & keyof typeof Decimal; type FormulaFunctions = keyof GenericFormula & keyof typeof Formula & keyof typeof Decimal;
const testValues = [-1, "0", Decimal.dOne] as const; const testValues = [-2, "0", new Decimal(10.5)] as const;
const invertibleZeroParamFunctionNames = [ const invertibleZeroParamFunctionNames = [
"round",
"floor",
"ceil",
"trunc",
"neg", "neg",
"recip", "recip",
"log10", "log10",
@ -48,10 +52,6 @@ const invertibleZeroParamFunctionNames = [
const nonInvertibleZeroParamFunctionNames = [ const nonInvertibleZeroParamFunctionNames = [
"abs", "abs",
"sign", "sign",
"round",
"floor",
"ceil",
"trunc",
"pLog10", "pLog10",
"absLog10", "absLog10",
"factorial", "factorial",
@ -85,6 +85,10 @@ const integrableZeroParamFunctionNames = [
] as const; ] as const;
const nonIntegrableZeroParamFunctionNames = [ const nonIntegrableZeroParamFunctionNames = [
...nonInvertibleZeroParamFunctionNames, ...nonInvertibleZeroParamFunctionNames,
"round",
"floor",
"ceil",
"trunc",
"lambertw", "lambertw",
"ssqrt" "ssqrt"
] as const; ] as const;
@ -488,18 +492,18 @@ describe("Inverting", () => {
}); });
test("Inverting nested formulas", () => { test("Inverting nested formulas", () => {
const formula = Formula.add(variable, constant).times(constant); const formula = Formula.add(variable, constant).times(constant).floor();
expect(formula.invert(100)).compare_tolerance(0); expect(formula.invert(100)).compare_tolerance(0);
}); });
describe("Inverting with non-invertible sections", () => { describe("Inverting with non-invertible sections", () => {
test("Non-invertible constant", () => { test("Non-invertible constant", () => {
const formula = Formula.add(variable, constant.ceil()); const formula = Formula.add(variable, constant.sign());
expect(formula.isInvertible()).toBe(true); expect(formula.isInvertible()).toBe(true);
expect(() => formula.invert(10)).not.toLogError(); expect(() => formula.invert(10)).not.toLogError();
}); });
test("Non-invertible variable", () => { test("Non-invertible variable", () => {
const formula = Formula.add(variable.ceil(), constant); const formula = Formula.add(variable.sign(), constant);
expect(formula.isInvertible()).toBe(false); expect(formula.isInvertible()).toBe(false);
expect(() => formula.invert(10)).toLogError(); expect(() => formula.invert(10)).toLogError();
}); });
@ -1271,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)`
); );
}); });
@ -133,14 +133,14 @@ describe("Exponential Modifiers", () =>
testModifiers(createExponentialModifier, "exponent", Decimal.pow)); testModifiers(createExponentialModifier, "exponent", Decimal.pow));
describe("Sequential Modifiers", () => { describe("Sequential Modifiers", () => {
function createModifier( function createModifier<T extends Partial<ModifierConstructorOptions>>(
value: Computable<DecimalSource>, value: Computable<DecimalSource>,
options: Partial<ModifierConstructorOptions> = {} options?: T
): WithRequired<Modifier, "invert" | "getFormula"> { ) {
return createSequentialModifier(() => [ return createSequentialModifier(() => [
createAdditiveModifier(() => ({ ...options, addend: value })), createAdditiveModifier(() => ({ ...(options ?? {}), addend: value })),
createMultiplicativeModifier(() => ({ ...options, multiplier: value })), createMultiplicativeModifier(() => ({ ...(options ?? {}), multiplier: value })),
createExponentialModifier(() => ({ ...options, exponent: value })) createExponentialModifier(() => ({ ...(options ?? {}), exponent: value }))
]); ]);
} }
@ -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)`
); );
}); });
@ -199,6 +199,17 @@ describe("Sequential Modifiers", () => {
// So long as one is true or undefined, enable should be true // So long as one is true or undefined, enable should be true
expect(unref(modifier.enabled)).toBe(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", () => { describe("applies smallerIsBetter correctly", () => {

View file

@ -83,7 +83,7 @@ describe("Creating cost requirement", () => {
cost: 10, cost: 10,
cumulativeCost: false cumulativeCost: false
})); }));
expect(unref(requirement.requirementMet)).toBe(true); expect(unref(requirement.requirementMet)).toBe(1);
}); });
test("Requirement not met when not meeting the cost", () => { test("Requirement not met when not meeting the cost", () => {
@ -92,7 +92,7 @@ describe("Creating cost requirement", () => {
cost: 100, cost: 100,
cumulativeCost: false cumulativeCost: false
})); }));
expect(unref(requirement.requirementMet)).toBe(false); expect(unref(requirement.requirementMet)).toBe(0);
}); });
describe("canMaximize works correctly", () => { describe("canMaximize works correctly", () => {

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 // eslint-disable-next-line @typescript-eslint/no-empty-interface
namespace Vi { interface Assertion extends CustomMatchers {}
// eslint-disable-next-line @typescript-eslint/no-empty-interface // eslint-disable-next-line @typescript-eslint/no-empty-interface
interface Assertion extends CustomMatchers {} interface AsymmetricMatchersContaining extends CustomMatchers {}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface AsymmetricMatchersContaining extends CustomMatchers {}
}
} }
expect.extend({ expect.extend({