From 2495dc9783f4d6f5bce1a8cdc6b87567e7baa27d Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Sun, 5 Nov 2023 17:00:28 +0000 Subject: [PATCH 01/30] Implement forgejo actions workflows (#24) Reviewed-on: https://code.incremental.social/profectus/Profectus/pulls/24 Co-authored-by: thepaperpilot Co-committed-by: thepaperpilot --- .forgejo/workflows/deploy.yaml | 31 +++++++++++++++++++++++++++++++ .forgejo/workflows/test.yaml | 21 +++++++++++++++++++++ .github/workflows/test.yml | 2 +- 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 .forgejo/workflows/deploy.yaml create mode 100644 .forgejo/workflows/test.yaml diff --git a/.forgejo/workflows/deploy.yaml b/.forgejo/workflows/deploy.yaml new file mode 100644 index 0000000..bc7210a --- /dev/null +++ b/.forgejo/workflows/deploy.yaml @@ -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. diff --git a/.forgejo/workflows/test.yaml b/.forgejo/workflows/test.yaml new file mode 100644 index 0000000..33df8d8 --- /dev/null +++ b/.forgejo/workflows/test.yaml @@ -0,0 +1,21 @@ +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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index de6f71a..c41d085 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,7 +5,7 @@ on: pull_request: branches: [ main ] jobs: - build: + test: runs-on: ubuntu-latest steps: -- 2.45.2 From 7750a3368d12be01f4543ee2583d985b05d228d3 Mon Sep 17 00:00:00 2001 From: Seth Posner Date: Mon, 13 Nov 2023 14:09:48 -0800 Subject: [PATCH 02/30] Swap logic for nextAt display --- src/data/common.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/data/common.tsx b/src/data/common.tsx index 4be7004..c1286a2 100644 --- a/src/data/common.tsx +++ b/src/data/common.tsx @@ -134,8 +134,8 @@ export function createResetButton Date: Mon, 13 Nov 2023 14:10:00 -0800 Subject: [PATCH 03/30] Add tests confirming low-input conversion values --- tests/features/conversions.test.ts | 32 ++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/features/conversions.test.ts b/tests/features/conversions.test.ts index 09d6569..58e2e99 100644 --- a/tests/features/conversions.test.ts +++ b/tests/features/conversions.test.ts @@ -47,6 +47,10 @@ describe("Creating conversion", () => { baseResource.value = Decimal.pow(100, 2).times(10).add(1); expect(unref(conversion.currentGain)).compare_tolerance(100); }); + test("Zero", () => { + baseResource.value = Decimal.dZero; + expect(unref(conversion.currentGain)).compare_tolerance(0); + }); }); describe("Calculates actualGain correctly", () => { let conversion: GenericConversion; @@ -69,6 +73,10 @@ describe("Creating conversion", () => { baseResource.value = Decimal.pow(100, 2).times(10).add(1); expect(unref(conversion.actualGain)).compare_tolerance(100); }); + test("Zero", () => { + baseResource.value = Decimal.dZero; + expect(unref(conversion.actualGain)).compare_tolerance(0); + }); }); describe("Calculates currentAt correctly", () => { let conversion: GenericConversion; @@ -95,6 +103,10 @@ describe("Creating conversion", () => { Decimal.pow(100, 2).times(10) ); }); + test("Zero", () => { + baseResource.value = Decimal.dZero; + expect(unref(conversion.currentAt)).compare_tolerance(0); + }); }); describe("Calculates nextAt correctly", () => { let conversion: GenericConversion; @@ -117,6 +129,10 @@ describe("Creating conversion", () => { baseResource.value = Decimal.pow(100, 2).times(10).add(1); expect(unref(conversion.nextAt)).compare_tolerance(Decimal.pow(101, 2).times(10)); }); + test("Zero", () => { + baseResource.value = Decimal.dZero; + expect(unref(conversion.nextAt)).compare_tolerance(Decimal.dTen); + }); }); test("Converts correctly", () => { const conversion = createCumulativeConversion(() => ({ @@ -193,6 +209,10 @@ describe("Creating conversion", () => { baseResource.value = Decimal.pow(100, 2).times(10).add(1); expect(unref(conversion.currentGain)).compare_tolerance(100); }); + test("Zero", () => { + baseResource.value = Decimal.dZero; + expect(unref(conversion.currentGain)).compare_tolerance(1); + }); }); describe("Calculates actualGain correctly", () => { let conversion: GenericConversion; @@ -216,6 +236,10 @@ describe("Creating conversion", () => { baseResource.value = Decimal.pow(100, 2).times(10).add(1); expect(unref(conversion.actualGain)).compare_tolerance(99); }); + test("Zero", () => { + baseResource.value = Decimal.dZero; + expect(unref(conversion.actualGain)).compare_tolerance(0); + }); }); describe("Calculates currentAt correctly", () => { let conversion: GenericConversion; @@ -243,6 +267,10 @@ describe("Creating conversion", () => { Decimal.pow(100, 2).times(10) ); }); + test("Zero", () => { + baseResource.value = Decimal.dZero; + expect(unref(conversion.currentAt)).compare_tolerance(Decimal.pow(1, 2).times(10)); + }); }); describe("Calculates nextAt correctly", () => { let conversion: GenericConversion; @@ -266,6 +294,10 @@ describe("Creating conversion", () => { baseResource.value = Decimal.pow(100, 2).times(10).add(1); expect(unref(conversion.nextAt)).compare_tolerance(Decimal.pow(101, 2).times(10)); }); + test("Zero", () => { + baseResource.value = Decimal.dZero; + expect(unref(conversion.nextAt)).compare_tolerance(Decimal.pow(2, 2).times(10)); + }); }); test("Converts correctly", () => { const conversion = createIndependentConversion(() => ({ -- 2.45.2 From ed9a1bb6938e5115a243aedae6349a42d4ef2721 Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Sun, 11 Feb 2024 13:40:10 -0600 Subject: [PATCH 04/30] Added /new and query param handlers --- src/game/routing.ts | 68 +++++++++++++++++++++++++++++++++++++++++++++ src/main.ts | 15 ++++++++-- src/util/save.ts | 5 +--- 3 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 src/game/routing.ts diff --git a/src/game/routing.ts b/src/game/routing.ts new file mode 100644 index 0000000..693c1d6 --- /dev/null +++ b/src/game/routing.ts @@ -0,0 +1,68 @@ +import { globalBus } from "game/events"; +import { DecimalSource } from "util/bignum"; +import { Ref } from "vue"; +import player from "./player"; + +// https://stackoverflow.com/questions/2090551/parse-query-string-in-javascript +function parseQuery(queryString = window.location.search) { + const query: Record = {}; + const pairs = (queryString[0] === "?" ? queryString.substring(1) : queryString).split("&"); + for (let i = 0; i < pairs.length; i++) { + const pair = pairs[i].split("="); + query[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || ""); + } + return query; +} +const params = parseQuery(); + +/** + * Register a handler to be called when creating new saves based on a query param + * @param key The query param to regster + * @param handler The callback function when the query param is present + * @param newSavesOnly If set to true, only call the handler on the /new path + */ +export function registerQueryParam( + key: string, + handler: (value: string) => void, + newSavesOnly?: boolean +): void; +/** + * Register a ref to have its value set based on a query param + * @param key The query param to regster + * @param ref The ref to set the value of + * @param newSavesOnly If set to true, only overwrite values on the /new path + * @see {@link numberHandler}. + */ +export function registerQueryParam( + key: string, + ref: Ref, + newSavesOnly?: boolean +): void; +export function registerQueryParam( + key: string, + handlerOrRef: ((value: string) => void) | Ref, + newSavesOnly = false +) { + globalBus.on("onLoad", () => { + if (newSavesOnly && player.timePlayed > 0) { + return; + } + if (key in params) { + if (typeof handlerOrRef === "function") { + handlerOrRef(params[key]); + } else { + if (typeof handlerOrRef.value === "boolean") { + (handlerOrRef.value as boolean) = params[key].toLowerCase() === "true"; + } else { + (handlerOrRef.value as string | DecimalSource) = params[key]; + } + } + } + }); +} + +export function numberHandler(ref: Ref) { + return function (value: string) { + ref.value = parseFloat(value); + }; +} diff --git a/src/main.ts b/src/main.ts index e416fa5..1e24deb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,7 +3,8 @@ import App from "App.vue"; import projInfo from "data/projInfo.json"; import "game/notifications"; import state from "game/state"; -import { load } from "util/save"; +import { loadSettings } from "game/settings"; +import { load, loadSave, newSave } from "util/save"; import { useRegisterSW } from "virtual:pwa-register/vue"; import type { App as VueApp } from "vue"; import { createApp, nextTick } from "vue"; @@ -59,7 +60,17 @@ requestAnimationFrame(async () => { "font-weight: bold; font-size: 24px; color: #A3BE8C; background: #2E3440; padding: 4px 8px; border-radius: 8px;", "padding: 4px;" ); - await load(); + + // Load global settings + loadSettings(); + + if (window.location.pathname === "/new") { + await loadSave(newSave()); + } else { + await load(); + } + window.history.replaceState({}, document.title, "/"); + const { globalBus } = await import("./game/events"); const { startGameLoop } = await import("./game/gameLoop"); diff --git a/src/util/save.ts b/src/util/save.ts index 4137e5b..3f7da26 100644 --- a/src/util/save.ts +++ b/src/util/save.ts @@ -2,7 +2,7 @@ import projInfo from "data/projInfo.json"; import { globalBus } from "game/events"; import type { Player } from "game/player"; import player, { stringifySave } from "game/player"; -import settings, { loadSettings } from "game/settings"; +import settings from "game/settings"; import LZString from "lz-string"; import { ref } from "vue"; @@ -33,9 +33,6 @@ export function save(playerData?: Player): string { } export async function load(): Promise { - // Load global settings - loadSettings(); - try { let save = localStorage.getItem(settings.active); if (save == null) { -- 2.45.2 From cf6265d8ce78fcaec62f79689269a1e495c736cd Mon Sep 17 00:00:00 2001 From: Seth Posner Date: Mon, 12 Feb 2024 07:58:39 -0800 Subject: [PATCH 05/30] Keep disabled modifiers when making formulas --- src/game/modifiers.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/game/modifiers.tsx b/src/game/modifiers.tsx index b65e7fc..1ee3905 100644 --- a/src/game/modifiers.tsx +++ b/src/game/modifiers.tsx @@ -297,9 +297,9 @@ export function createSequentialModifier< getFormula: modifiers.every(m => m.getFormula != null) ? (gain: FormulaSource) => modifiers - .filter(m => unref(m.enabled) !== false) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - .reduce((acc, curr) => curr.getFormula!(acc), gain) + .reduce((acc, curr) => Formula.if(acc, curr.enabled ?? true, + acc => curr.getFormula!(acc), acc => acc), gain) : undefined, enabled: modifiers.some(m => m.enabled != null) ? computed(() => modifiers.filter(m => unref(m.enabled) !== false).length > 0) -- 2.45.2 From 5e32fa4985d71991165d97b0c33b84e8b4b892b5 Mon Sep 17 00:00:00 2001 From: nif Date: Mon, 12 Feb 2024 19:46:31 +0000 Subject: [PATCH 06/30] Fix branchedResetPropagation BREAKING CHANGE - Forces branches to be directed Signed-off-by: nif --- src/features/trees/tree.ts | 43 +++++++++++++------------------------- 1 file changed, 15 insertions(+), 28 deletions(-) diff --git a/src/features/trees/tree.ts b/src/features/trees/tree.ts index da77f60..37b5ee6 100644 --- a/src/features/trees/tree.ts +++ b/src/features/trees/tree.ts @@ -338,34 +338,21 @@ export const branchedResetPropagation = function ( tree: GenericTree, resettingNode: GenericTreeNode ): void { - const visitedNodes = [resettingNode]; - let currentNodes = [resettingNode]; - if (tree.branches != null) { - const branches = unref(tree.branches); - while (currentNodes.length > 0) { - const nextNodes: GenericTreeNode[] = []; - currentNodes.forEach(node => { - branches - .filter(branch => branch.startNode === node || branch.endNode === node) - .map(branch => { - if (branch.startNode === node) { - return branch.endNode; - } - return branch.startNode; - }) - .filter(node => !visitedNodes.includes(node)) - .forEach(node => { - // Check here instead of in the filter because this check's results may - // change as we go through each node - if (!nextNodes.includes(node)) { - nextNodes.push(node); - node.reset?.reset(); - } - }); - }); - currentNodes = nextNodes; - visitedNodes.push(...currentNodes); - } + const links = unref(tree.branches); + if (links === undefined) return; + let reset: GenericTreeNode[] = []; + let current = [resettingNode]; + while (current.length != 0) { + let next: GenericTreeNode[] = []; + for (let node of current) { + for (let link of links.filter(link => link.startNode === node)) { + if ([...reset, ...current].includes(link.endNode)) continue + next.push(link.endNode); + link.endNode.reset?.reset(); + } + }; + reset = reset.concat(current); + current = next; } }; -- 2.45.2 From 263c951cf85a0c1e156ff9c77f04e58e63dc47a1 Mon Sep 17 00:00:00 2001 From: TJCgames Date: Wed, 14 Feb 2024 15:56:18 +0000 Subject: [PATCH 07/30] Requested changes --- src/features/trees/tree.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/features/trees/tree.ts b/src/features/trees/tree.ts index 37b5ee6..8ec317f 100644 --- a/src/features/trees/tree.ts +++ b/src/features/trees/tree.ts @@ -339,19 +339,19 @@ export const branchedResetPropagation = function ( resettingNode: GenericTreeNode ): void { const links = unref(tree.branches); - if (links === undefined) return; - let reset: GenericTreeNode[] = []; + if (links == null) return; + const reset: GenericTreeNode[] = []; let current = [resettingNode]; while (current.length != 0) { - let next: GenericTreeNode[] = []; - for (let node of current) { - for (let link of links.filter(link => link.startNode === node)) { + const next: GenericTreeNode[] = []; + for (const node of current) { + for (const link of links.filter(link => link.startNode === node)) { if ([...reset, ...current].includes(link.endNode)) continue next.push(link.endNode); link.endNode.reset?.reset(); } }; - reset = reset.concat(current); + reset.push(...current); current = next; } }; -- 2.45.2 From 143b0773e7d23b55effd4fafc4752edc61d5e773 Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Sat, 17 Feb 2024 20:16:00 -0600 Subject: [PATCH 08/30] Add eslint workflow action and CONTRIBUTING.md that says to lint first --- .forgejo/workflows/test.yaml | 1 + .github/workflows/test.yml | 1 + .vscode/settings.json | 2 +- CONTRIBUTING.md | 31 +++++++++++++++++++++++++++++++ package.json | 4 +++- src/App.vue | 2 +- src/features/action.tsx | 2 +- src/features/tooltips/tooltip.ts | 2 +- src/features/trees/tree.ts | 2 +- src/game/formulas/operations.ts | 4 ++++ src/game/modifiers.tsx | 15 +++++++++++---- src/game/requirements.tsx | 4 +++- 12 files changed, 59 insertions(+), 11 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/.forgejo/workflows/test.yaml b/.forgejo/workflows/test.yaml index 33df8d8..7c48ad6 100644 --- a/.forgejo/workflows/test.yaml +++ b/.forgejo/workflows/test.yaml @@ -19,3 +19,4 @@ jobs: - run: npm ci - run: npm run build --if-present - run: npm test + - run: npm run lint diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c41d085..8d6b548 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,3 +19,4 @@ jobs: - run: npm ci - run: npm run build --if-present - run: npm test + - run: npm run lint diff --git a/.vscode/settings.json b/.vscode/settings.json index d46602a..65fe597 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ { "vitest.commandLine": "npx vitest", "editor.codeActionsOnSave": { - "source.fixAll.eslint": true + "source.fixAll.eslint": "explicit" }, "editor.defaultFormatter": "esbenp.prettier-vscode", "git.ignoreLimitWarning": true, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..818ba84 --- /dev/null +++ b/CONTRIBUTING.md @@ -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). + +## Contributing + +Make sure to open your PR on Incremental Social - 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. + +## 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. + +## License + +By contributing to Profectus, you agree that your contributions will be licensed under the project's [LICENSE](./LICENSE). diff --git a/package.json b/package.json index 3c1c415..f0ce56b 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "preview": "vite preview", "test": "vitest run", "testw": "vitest", - "serve": "vite preview --host" + "serve": "vite preview --host", + "lint": "eslint src --max-warnings 0", + "lint:fix": "eslint --fix --max-warnings 0 src" }, "dependencies": { "@fontsource/material-icons": "^4.5.4", diff --git a/src/App.vue b/src/App.vue index 6a365ef..40e21de 100644 --- a/src/App.vue +++ b/src/App.vue @@ -19,7 +19,7 @@ import Error from "components/Error.vue"; import { jsx } from "features/feature"; import state from "game/state"; import { coerceComponent, render } from "util/vue"; -import { CSSProperties, watch } from "vue"; +import { CSSProperties } from "vue"; import { computed, toRef, unref } from "vue"; import Game from "./components/Game.vue"; import GameOverScreen from "./components/GameOverScreen.vue"; diff --git a/src/features/action.tsx b/src/features/action.tsx index 1fbb8d3..2919a9e 100644 --- a/src/features/action.tsx +++ b/src/features/action.tsx @@ -31,7 +31,7 @@ import { coerceComponent, isCoercableComponent, render } from "util/vue"; import { computed, Ref, ref, unref } from "vue"; import { BarOptions, createBar, GenericBar } from "./bars/bar"; import { ClickableOptions } from "./clickables/clickable"; -import { Decorator, GenericDecorator } from "./decorators/common"; +import { GenericDecorator } from "./decorators/common"; /** A symbol used to identify {@link Action} features. */ export const ActionType = Symbol("Action"); diff --git a/src/features/tooltips/tooltip.ts b/src/features/tooltips/tooltip.ts index 54d782c..8d65efd 100644 --- a/src/features/tooltips/tooltip.ts +++ b/src/features/tooltips/tooltip.ts @@ -1,6 +1,6 @@ import type { CoercableComponent, GenericComponent, Replace, StyleValue } from "features/feature"; import { Component, GatherProps, setDefault } from "features/feature"; -import { deletePersistent, Persistent, persistent } from "game/persistence"; +import { persistent } from "game/persistence"; import { Direction } from "util/common"; import type { Computable, diff --git a/src/features/trees/tree.ts b/src/features/trees/tree.ts index da77f60..6d09fc9 100644 --- a/src/features/trees/tree.ts +++ b/src/features/trees/tree.ts @@ -1,4 +1,4 @@ -import { Decorator, GenericDecorator } from "features/decorators/common"; +import { GenericDecorator } from "features/decorators/common"; import type { CoercableComponent, GenericComponent, diff --git a/src/game/formulas/operations.ts b/src/game/formulas/operations.ts index 586319e..7210cfb 100644 --- a/src/game/formulas/operations.ts +++ b/src/game/formulas/operations.ts @@ -552,7 +552,9 @@ export function tetrate( export function invertTetrate( value: DecimalSource, base: FormulaSource, + // eslint-disable-next-line @typescript-eslint/no-unused-vars height: FormulaSource, + // eslint-disable-next-line @typescript-eslint/no-unused-vars payload: FormulaSource ) { if (hasVariable(base)) { @@ -576,6 +578,7 @@ export function invertIteratedExp( value: DecimalSource, lhs: FormulaSource, height: FormulaSource, + // eslint-disable-next-line @typescript-eslint/no-unused-vars payload: FormulaSource ) { if (hasVariable(lhs)) { @@ -626,6 +629,7 @@ export function invertLayeradd( value: DecimalSource, lhs: FormulaSource, diff: FormulaSource, + // eslint-disable-next-line @typescript-eslint/no-unused-vars base: FormulaSource ) { if (hasVariable(lhs)) { diff --git a/src/game/modifiers.tsx b/src/game/modifiers.tsx index 1ee3905..2d2ccdf 100644 --- a/src/game/modifiers.tsx +++ b/src/game/modifiers.tsx @@ -296,10 +296,17 @@ export function createSequentialModifier< : undefined, getFormula: modifiers.every(m => m.getFormula != null) ? (gain: FormulaSource) => - modifiers - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - .reduce((acc, curr) => Formula.if(acc, curr.enabled ?? true, - acc => curr.getFormula!(acc), acc => acc), gain) + modifiers.reduce( + (acc, curr) => + Formula.if( + acc, + curr.enabled ?? true, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + acc => curr.getFormula!(acc), + acc => acc + ), + gain + ) : undefined, enabled: modifiers.some(m => m.enabled != null) ? computed(() => modifiers.filter(m => unref(m.enabled) !== false).length > 0) diff --git a/src/game/requirements.tsx b/src/game/requirements.tsx index ea82a64..363fccd 100644 --- a/src/game/requirements.tsx +++ b/src/game/requirements.tsx @@ -222,7 +222,9 @@ export function createCostRequirement( Decimal.gte( req.resource.value, unref(req.cost as ProcessedComputable) - ) ? 1 : 0 + ) + ? 1 + : 0 ); } -- 2.45.2 From 4092cd6d56de40d55bbce9df9c9246bdf863a2b2 Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Tue, 13 Feb 2024 06:48:56 -0600 Subject: [PATCH 09/30] Add regression test for modifier.getFormula respecting enabled --- src/game/modifiers.tsx | 18 +++++++++++++----- tests/game/modifiers.test.ts | 14 ++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/game/modifiers.tsx b/src/game/modifiers.tsx index 1ee3905..d80b45a 100644 --- a/src/game/modifiers.tsx +++ b/src/game/modifiers.tsx @@ -276,8 +276,12 @@ export function createExponentialModifier( export function createSequentialModifier< T extends Modifier[], S = T extends WithRequired[] - ? WithRequired - : Omit, "invert"> + ? T extends WithRequired[] + ? WithRequired + : Omit, "invert"> + : T extends WithRequired[] + ? WithRequired + : Omit, "invert"> >(modifiersFunc: () => T): S { return createLazyProxy(() => { const modifiers = modifiersFunc(); @@ -296,10 +300,14 @@ export function createSequentialModifier< : undefined, getFormula: modifiers.every(m => m.getFormula != null) ? (gain: FormulaSource) => - modifiers + modifiers.reduce((acc, curr) => { + if (curr.enabled == null || curr.enabled === true) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return curr.getFormula!(acc); + } // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - .reduce((acc, curr) => Formula.if(acc, curr.enabled ?? true, - acc => curr.getFormula!(acc), acc => acc), gain) + return Formula.if(acc, curr.enabled, acc => curr.getFormula!(acc)); + }, gain) : undefined, enabled: modifiers.some(m => m.enabled != null) ? computed(() => modifiers.filter(m => unref(m.enabled) !== false).length > 0) diff --git a/tests/game/modifiers.test.ts b/tests/game/modifiers.test.ts index d5e186d..fdf0f67 100644 --- a/tests/game/modifiers.test.ts +++ b/tests/game/modifiers.test.ts @@ -199,6 +199,20 @@ describe("Sequential Modifiers", () => { // So long as one is true or undefined, enable should be true expect(unref(modifier.enabled)).toBe(true); }); + test("respects enabled", () => { + const value = ref(10); + const enabled = ref(false); + const modifier = createSequentialModifier(() => [ + createMultiplicativeModifier(() => ({ multiplier: 5, enabled })) + ]); + expect(modifier.getFormula(Formula.variable(value)).evaluate()).compare_tolerance( + value.value + ); + enabled.value = true; + expect(modifier.getFormula(Formula.variable(value)).evaluate()).not.compare_tolerance( + value.value + ); + }); }); describe("applies smallerIsBetter correctly", () => { -- 2.45.2 From 2e0e221010ba4d0d792acafec14383962e804824 Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Tue, 20 Feb 2024 08:32:03 -0600 Subject: [PATCH 10/30] Made modifier typing a lot less nasty --- src/game/modifiers.tsx | 55 +++++++++++++++--------------------- src/util/common.ts | 8 ++++++ tests/game/modifiers.test.ts | 12 ++++---- 3 files changed, 37 insertions(+), 38 deletions(-) diff --git a/src/game/modifiers.tsx b/src/game/modifiers.tsx index d80b45a..ada19dc 100644 --- a/src/game/modifiers.tsx +++ b/src/game/modifiers.tsx @@ -4,7 +4,7 @@ import { jsx } from "features/feature"; import settings from "game/settings"; import type { DecimalSource } from "util/bignum"; import Decimal, { formatSmall } from "util/bignum"; -import type { WithRequired } from "util/common"; +import type { OmitOptional, OptionalKeys, RequiredKeys, WithRequired } from "util/common"; import type { Computable, ProcessedComputable } from "util/computed"; import { convertComputable } from "util/computed"; import { createLazyProxy } from "util/proxies"; @@ -38,16 +38,11 @@ export interface Modifier { description?: ProcessedComputable; } -/** - * Utility type used to narrow down a modifier type that will have a description and/or enabled property based on optional parameters, T and S (respectively). - */ -export type ModifierFromOptionalParams = undefined extends T - ? undefined extends S - ? Omit, "description" | "enabled"> - : Omit, "description"> - : undefined extends S - ? Omit, "enabled"> - : WithRequired; +/** Utility type that represents the output of all modifiers that represent a single operation. */ +export type OperationModifier = WithRequired< + Modifier, + "invert" | "getFormula" | Extract, keyof Modifier> +>; /** An object that configures an additive modifier via {@link createAdditiveModifier}. */ export interface AdditiveModifierOptions { @@ -65,9 +60,9 @@ export interface AdditiveModifierOptions { * Create a modifier that adds some value to the input value. * @param optionsFunc Additive modifier options. */ -export function createAdditiveModifier( +export function createAdditiveModifier>( optionsFunc: OptionsFunc -): ModifierFromOptionalParams { +) { return createLazyProxy(feature => { const { addend, description, enabled, smallerIsBetter } = optionsFunc.call( feature, @@ -111,7 +106,7 @@ export function createAdditiveModifier( )) }; - }) as unknown as ModifierFromOptionalParams; + }) as S; } /** An object that configures an multiplicative modifier via {@link createMultiplicativeModifier}. */ @@ -130,9 +125,10 @@ export interface MultiplicativeModifierOptions { * Create a modifier that multiplies the input value by some value. * @param optionsFunc Multiplicative modifier options. */ -export function createMultiplicativeModifier( - optionsFunc: OptionsFunc -): ModifierFromOptionalParams { +export function createMultiplicativeModifier< + T extends MultiplicativeModifierOptions, + S = OperationModifier +>(optionsFunc: OptionsFunc) { return createLazyProxy(feature => { const { multiplier, description, enabled, smallerIsBetter } = optionsFunc.call( feature, @@ -175,7 +171,7 @@ export function createMultiplicativeModifier )) }; - }) as unknown as ModifierFromOptionalParams; + }) as S; } /** An object that configures an exponential modifier via {@link createExponentialModifier}. */ @@ -196,9 +192,10 @@ export interface ExponentialModifierOptions { * Create a modifier that raises the input value to the power of some value. * @param optionsFunc Exponential modifier options. */ -export function createExponentialModifier( - optionsFunc: OptionsFunc -): ModifierFromOptionalParams { +export function createExponentialModifier< + T extends ExponentialModifierOptions, + S = OperationModifier +>(optionsFunc: OptionsFunc) { return createLazyProxy(feature => { const { exponent, description, enabled, supportLowNumbers, smallerIsBetter } = optionsFunc.call(feature, feature); @@ -263,7 +260,7 @@ export function createExponentialModifier( )) }; - }) as unknown as ModifierFromOptionalParams; + }) as S; } /** @@ -274,15 +271,9 @@ export function createExponentialModifier( * @see {@link createModifierSection}. */ export function createSequentialModifier< - T extends Modifier[], - S = T extends WithRequired[] - ? T extends WithRequired[] - ? WithRequired - : Omit, "invert"> - : T extends WithRequired[] - ? WithRequired - : Omit, "invert"> ->(modifiersFunc: () => T): S { + T extends Modifier, + S = WithRequired, keyof Modifier>> +>(modifiersFunc: () => T[]) { return createLazyProxy(() => { const modifiers = modifiersFunc(); @@ -325,7 +316,7 @@ export function createSequentialModifier< )) : undefined }; - }) as unknown as S; + }) as S; } /** An object that configures a modifier section via {@link createModifierSection}. */ diff --git a/src/util/common.ts b/src/util/common.ts index dbbe233..00847e6 100644 --- a/src/util/common.ts +++ b/src/util/common.ts @@ -1,3 +1,11 @@ +export type RequiredKeys = { + [K in keyof T]-?: NonNullable extends Pick ? never : K; +}[keyof T]; +export type OptionalKeys = { + [K in keyof T]-?: NonNullable extends Pick ? K : never; +}[keyof T]; + +export type OmitOptional = Pick>; export type WithRequired = T & { [P in K]-?: T[P] }; export type ArrayElements> = T extends ReadonlyArray diff --git a/tests/game/modifiers.test.ts b/tests/game/modifiers.test.ts index fdf0f67..3b2812f 100644 --- a/tests/game/modifiers.test.ts +++ b/tests/game/modifiers.test.ts @@ -133,14 +133,14 @@ describe("Exponential Modifiers", () => testModifiers(createExponentialModifier, "exponent", Decimal.pow)); describe("Sequential Modifiers", () => { - function createModifier( + function createModifier>( value: Computable, - options: Partial = {} - ): WithRequired { + options?: T + ) { return createSequentialModifier(() => [ - createAdditiveModifier(() => ({ ...options, addend: value })), - createMultiplicativeModifier(() => ({ ...options, multiplier: value })), - createExponentialModifier(() => ({ ...options, exponent: value })) + createAdditiveModifier(() => ({ ...(options ?? {}), addend: value })), + createMultiplicativeModifier(() => ({ ...(options ?? {}), multiplier: value })), + createExponentialModifier(() => ({ ...(options ?? {}), exponent: value })) ]); } -- 2.45.2 From 1e2b20a70ff09eafe4ec1555f4e8b8fb0c7835a2 Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Tue, 20 Feb 2024 15:10:59 +0000 Subject: [PATCH 11/30] PR feedback --- tests/game/modifiers.test.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/game/modifiers.test.ts b/tests/game/modifiers.test.ts index 3b2812f..dd019e1 100644 --- a/tests/game/modifiers.test.ts +++ b/tests/game/modifiers.test.ts @@ -205,13 +205,10 @@ describe("Sequential Modifiers", () => { const modifier = createSequentialModifier(() => [ createMultiplicativeModifier(() => ({ multiplier: 5, enabled })) ]); - expect(modifier.getFormula(Formula.variable(value)).evaluate()).compare_tolerance( - value.value - ); + const formula = modifier.getFormula(Formula.variable(value)); + expect(formula.evaluate()).compare_tolerance(value.value); enabled.value = true; - expect(modifier.getFormula(Formula.variable(value)).evaluate()).not.compare_tolerance( - value.value - ); + expect(formula.evaluate()).not.compare_tolerance(value.value); }); }); -- 2.45.2 From a39e65852d196a122bc598121012edb5a6d2ca42 Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Tue, 20 Feb 2024 19:23:14 -0600 Subject: [PATCH 12/30] Remove unused imports --- src/game/modifiers.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/game/modifiers.tsx b/src/game/modifiers.tsx index ada19dc..55efccb 100644 --- a/src/game/modifiers.tsx +++ b/src/game/modifiers.tsx @@ -4,7 +4,7 @@ import { jsx } from "features/feature"; import settings from "game/settings"; import type { DecimalSource } from "util/bignum"; import Decimal, { formatSmall } from "util/bignum"; -import type { OmitOptional, OptionalKeys, RequiredKeys, WithRequired } from "util/common"; +import type { RequiredKeys, WithRequired } from "util/common"; import type { Computable, ProcessedComputable } from "util/computed"; import { convertComputable } from "util/computed"; import { createLazyProxy } from "util/proxies"; -- 2.45.2 From d3faec6a6629d241a9d95f2321ad6f5436e5c130 Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Sat, 17 Feb 2024 19:04:55 -0600 Subject: [PATCH 13/30] Add Nodes to the text that can disappear in projEntry --- src/data/projEntry.tsx | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/data/projEntry.tsx b/src/data/projEntry.tsx index e4640b6..f69ac8b 100644 --- a/src/data/projEntry.tsx +++ b/src/data/projEntry.tsx @@ -1,3 +1,4 @@ +import Node from "components/Node.vue"; import Spacer from "components/layout/Spacer.vue"; import { jsx } from "features/feature"; import { createResource, trackBest, trackOOMPS, trackTotal } from "features/resources/resource"; @@ -48,19 +49,35 @@ export const main = createLayer("main", function (this: BaseLayer) { links: tree.links, display: jsx(() => ( <> - {player.devSpeed === 0 ?
Game Paused
: null} + {player.devSpeed === 0 ? ( +
+ Game Paused + +
+ ) : null} {player.devSpeed != null && player.devSpeed !== 0 && player.devSpeed !== 1 ? ( -
Dev Speed: {format(player.devSpeed)}x
+
+ Dev Speed: {format(player.devSpeed)}x + +
) : null} {player.offlineTime != null && player.offlineTime !== 0 ? ( -
Offline Time: {formatTime(player.offlineTime)}
+
+ Offline Time: {formatTime(player.offlineTime)} + +
) : null}
{Decimal.lt(points.value, "1e1000") ? You have : null}

{format(points.value)}

{Decimal.lt(points.value, "1e1e6") ? points : null}
- {Decimal.gt(pointGain.value, 0) ?
({oomps.value})
: null} + {Decimal.gt(pointGain.value, 0) ? ( +
+ ({oomps.value}) + +
+ ) : null} {render(tree)} -- 2.45.2 From 1f22f506dd978a352a62a50a7a95209da3a30344 Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Tue, 13 Feb 2024 08:25:32 -0600 Subject: [PATCH 14/30] Add tests for tree reset propagation --- src/features/trees/tree.ts | 2 +- tests/features/tree.test.ts | 111 ++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 tests/features/tree.test.ts diff --git a/src/features/trees/tree.ts b/src/features/trees/tree.ts index 8ec317f..4e43bbf 100644 --- a/src/features/trees/tree.ts +++ b/src/features/trees/tree.ts @@ -224,7 +224,7 @@ export interface BaseTree { id: string; /** The link objects for each of the branches of the tree. */ links: Ref; - /** Cause a reset on this node and propagate it through the tree according to {@link resetPropagation}. */ + /** Cause a reset on this node and propagate it through the tree according to {@link TreeOptions.resetPropagation}. */ reset: (node: GenericTreeNode) => void; /** A flag that is true while the reset is still propagating through the tree. */ isResetting: Ref; diff --git a/tests/features/tree.test.ts b/tests/features/tree.test.ts new file mode 100644 index 0000000..206988f --- /dev/null +++ b/tests/features/tree.test.ts @@ -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, shouldNotReset: Ref; + 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); + }); +}); -- 2.45.2 From 64fad5c74af04b9547482b94aa47c746d9a4cb89 Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Tue, 20 Feb 2024 22:16:20 -0600 Subject: [PATCH 15/30] PR Feedback --- CONTRIBUTING.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 818ba84..4fc4ea1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,9 +6,13 @@ Thank you for considering contributing to Profectus! We appreciate your interest 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 - the GitHub repo is just a mirror! +Make sure to open your PR on [Incremental Social](https://code.incremental.social/profectus/Profectus) - the GitHub repo is just a mirror! ### Code Review @@ -18,14 +22,10 @@ All PRs must be reviewed and approved by at least one of the project maintainers 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 +### 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. -## 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. - ## License By contributing to Profectus, you agree that your contributions will be licensed under the project's [LICENSE](./LICENSE). -- 2.45.2 From f7a8fbbb110e6c1fe742a56d2cd2b54842e2d262 Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Tue, 20 Feb 2024 22:38:49 -0600 Subject: [PATCH 16/30] Lint --- src/features/trees/tree.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/trees/tree.ts b/src/features/trees/tree.ts index f47d690..5a03189 100644 --- a/src/features/trees/tree.ts +++ b/src/features/trees/tree.ts @@ -346,11 +346,11 @@ export const branchedResetPropagation = function ( const next: GenericTreeNode[] = []; for (const node of current) { for (const link of links.filter(link => link.startNode === node)) { - if ([...reset, ...current].includes(link.endNode)) continue + if ([...reset, ...current].includes(link.endNode)) continue; next.push(link.endNode); link.endNode.reset?.reset(); } - }; + } reset.push(...current); current = next; } -- 2.45.2 From b40d4bef32ee6e9947e2a4b2399951cc4ba252d1 Mon Sep 17 00:00:00 2001 From: escapee Date: Wed, 21 Feb 2024 11:13:17 -0800 Subject: [PATCH 17/30] Allow both cases in shift+hotkeys --- src/features/hotkey.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/hotkey.tsx b/src/features/hotkey.tsx index 51fafbb..80eebbd 100644 --- a/src/features/hotkey.tsx +++ b/src/features/hotkey.tsx @@ -108,7 +108,7 @@ document.onkeydown = function (e) { if (e.ctrlKey) { key = "ctrl+" + key; } - const hotkey = hotkeys[key]; + const hotkey = hotkeys[key] ?? hotkeys[key.toLowerCase()]; if (hotkey && unref(hotkey.enabled)) { e.preventDefault(); hotkey.onPress(); -- 2.45.2 From cfba55d2c6b686fea119895402b3ebb6003c851b Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Fri, 16 Feb 2024 13:17:40 -0600 Subject: [PATCH 18/30] Add galaxy api --- src/components/SavesManager.vue | 85 +++-------- src/game/settings.ts | 14 +- src/lib/galaxy.js | 248 ++++++++++++++++++++++++++++++++ src/main.ts | 1 + src/util/galaxy.ts | 134 +++++++++++++++++ src/util/save.ts | 70 +++++++-- 6 files changed, 464 insertions(+), 88 deletions(-) create mode 100644 src/lib/galaxy.js create mode 100644 src/util/galaxy.ts diff --git a/src/components/SavesManager.vue b/src/components/SavesManager.vue index b1bf7e0..b0b0f73 100644 --- a/src/components/SavesManager.vue +++ b/src/components/SavesManager.vue @@ -63,9 +63,18 @@ import type { Player } from "game/player"; import player, { stringifySave } from "game/player"; import settings from "game/settings"; import LZString from "lz-string"; -import { getUniqueID, loadSave, newSave, save } from "util/save"; +import { + clearCachedSave, + clearCachedSaves, + decodeSave, + getCachedSave, + getUniqueID, + loadSave, + newSave, + save +} from "util/save"; import type { ComponentPublicInstance } from "vue"; -import { computed, nextTick, ref, shallowReactive, watch } from "vue"; +import { computed, nextTick, ref, watch } from "vue"; import Draggable from "vuedraggable"; import Select from "./fields/Select.vue"; import Text from "./fields/Text.vue"; @@ -90,16 +99,8 @@ watch(saveToImport, importedSave => { if (importedSave) { nextTick(() => { try { - if (importedSave[0] === "{") { - // plaintext. No processing needed - } else if (importedSave[0] === "e") { - // Assumed to be base64, which starts with e - importedSave = decodeURIComponent(escape(atob(importedSave))); - } else if (importedSave[0] === "ᯡ") { - // Assumed to be lz, which starts with ᯡ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - importedSave = LZString.decompressFromUTF16(importedSave)!; - } else { + importedSave = decodeSave(importedSave) ?? ""; + if (importedSave === "") { console.warn("Unable to determine preset encoding", importedSave); importingFailed.value = true; return; @@ -139,48 +140,10 @@ let bank = ref( }, []) ); -const cachedSaves = shallowReactive>({}); -function getCachedSave(id: string) { - if (cachedSaves[id] == null) { - let save = localStorage.getItem(id); - if (save == null) { - cachedSaves[id] = { error: `Save doesn't exist in localStorage`, id }; - } else if (save === "dW5kZWZpbmVk") { - cachedSaves[id] = { error: `Save is undefined`, id }; - } else { - try { - if (save[0] === "{") { - // plaintext. No processing needed - } else if (save[0] === "e") { - // Assumed to be base64, which starts with e - save = decodeURIComponent(escape(atob(save))); - } else if (save[0] === "ᯡ") { - // Assumed to be lz, which starts with ᯡ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - save = LZString.decompressFromUTF16(save)!; - } else { - console.warn("Unable to determine preset encoding", save); - importingFailed.value = true; - cachedSaves[id] = { error: "Unable to determine preset encoding", id }; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return cachedSaves[id]!; - } - cachedSaves[id] = { ...JSON.parse(save), id }; - } catch (error) { - cachedSaves[id] = { error, id }; - console.warn( - `SavesManager: Failed to load info about save with id ${id}:\n${error}\n${save}` - ); - } - } - } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return cachedSaves[id]!; -} // Wipe cache whenever the modal is opened watch(isOpen, isOpen => { if (isOpen) { - Object.keys(cachedSaves).forEach(key => delete cachedSaves[key]); + clearCachedSaves(); } }); @@ -235,18 +198,18 @@ function duplicateSave(id: string) { function deleteSave(id: string) { settings.saves = settings.saves.filter((save: string) => save !== id); localStorage.removeItem(id); - cachedSaves[id] = undefined; + clearCachedSave(id); } function openSave(id: string) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion saves.value[player.id]!.time = player.time; save(); - cachedSaves[player.id] = undefined; + clearCachedSave(player.id); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion loadSave(saves.value[id]!); // Delete cached version in case of opening it again - cachedSaves[id] = undefined; + clearCachedSave(id); } function newFromPreset(preset: string) { @@ -256,16 +219,8 @@ function newFromPreset(preset: string) { selectedPreset.value = null; }); - if (preset[0] === "{") { - // plaintext. No processing needed - } else if (preset[0] === "e") { - // Assumed to be base64, which starts with e - preset = decodeURIComponent(escape(atob(preset))); - } else if (preset[0] === "ᯡ") { - // Assumed to be lz, which starts with ᯡ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - preset = LZString.decompressFromUTF16(preset)!; - } else { + preset = decodeSave(preset) ?? ""; + if (preset === "") { console.warn("Unable to determine preset encoding", preset); return; } @@ -287,7 +242,7 @@ function editSave(id: string, newName: string) { save(); } else { save(currSave as Player); - cachedSaves[id] = undefined; + clearCachedSave(id); } } } diff --git a/src/game/settings.ts b/src/game/settings.ts index 0748d68..6f3a435 100644 --- a/src/game/settings.ts +++ b/src/game/settings.ts @@ -3,7 +3,7 @@ import { Themes } from "data/themes"; import type { CoercableComponent } from "features/feature"; import { globalBus } from "game/events"; import LZString from "lz-string"; -import { hardReset } from "util/save"; +import { decodeSave, hardReset } from "util/save"; import { reactive, watch } from "vue"; /** The player's settings object. */ @@ -78,16 +78,8 @@ export function loadSettings(): void { try { let item: string | null = localStorage.getItem(projInfo.id); if (item != null && item !== "") { - if (item[0] === "{") { - // plaintext. No processing needed - } else if (item[0] === "e") { - // Assumed to be base64, which starts with e - item = decodeURIComponent(escape(atob(item))); - } else if (item[0] === "ᯡ") { - // Assumed to be lz, which starts with ᯡ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - item = LZString.decompressFromUTF16(item)!; - } else { + item = decodeSave(item); + if (item == null) { console.warn("Unable to determine settings encoding", item); return; } diff --git a/src/lib/galaxy.js b/src/lib/galaxy.js new file mode 100644 index 0000000..11db155 --- /dev/null +++ b/src/lib/galaxy.js @@ -0,0 +1,248 @@ +/** + * The Galaxy API defines actions and responses for interacting with the Galaxy platform. + * + * @typedef {object} SupportsAction + * @property {"supports"} action - The action type. + * @property {boolean} saving - If your game auto-saves or allows the user to make/load game saves from within the UI. + * @property {boolean} save_manager - If your game has a complete save manager integrated into it. + */ + +/** + * The save list action sends a retrieval request to Galaxy to get the player's cloud save list. + * + * @typedef {object} SaveListAction + * @property {"save_list"} action - The action type. + */ + +/** + * The save action creates a cloud save and puts it into a certain save slot. + * + * @typedef {object} SaveAction + * @property {"save"} action - The action type. + * @property {number} slot - The save slot number. Must be an integer between 0 and 10, inclusive. + * @property {string} [label] - The optional label of the save file. + * @property {string} data - The actual save data. + */ + +/** + * The load action sends a retrieval request to Galaxy to get the cloud save data inside a certain save slot. + * + * @typedef {object} LoadAction + * @property {"load"} action - The action type. + * @property {number} slot - The save slot number. + */ + +/** + * The Galaxy action can be one of SupportsAction, SaveListAction, SaveAction, or LoadAction. + * + * @typedef {SupportsAction | SaveListAction | SaveAction | LoadAction} GalaxyAction + */ + +/** + * The info response is sent when the page loads. + * + * @typedef {object} InfoResponse + * @property {"info"} type - The response type. + * @property {boolean} galaxy - Whether you're talking to Galaxy. + * @property {number} api_version - The version of the API. + * @property {string} theme_preference - The player's theme preference. + * @property {boolean} logged_in - Whether the player is logged in. + */ + +/** + * The save list response is requested by the save_list action. + * + * @typedef {object} SaveListResponse + * @property {"save_list"} type - The response type. + * @property {Record} list - A list of saves. + * @property {boolean} error - Whether the action encountered an error. + * @property {("no_account" | "server_error")} [message] - Reason for the error. + */ + +/** + * The save content response is requested by the load action. + * + * @typedef {object} SaveContentResponse + * @property {"save_content"} type - The response type. + * @property {boolean} error - Whether the action encountered an error. + * @property {("no_account" | "empty_slot" | "invalid_slot" | "server_error")} [message] - Reason for the error. + * @property {number} slot - The save slot number. + * @property {string} [label] - The save's label. + * @property {string} [content] - The save's actual data. + */ + +/** + * The saved response is requested by the save action. + * + * @typedef {object} SavedResponse + * @property {"saved"} type - The response type. + * @property {boolean} error - Whether the action encountered an error. + * @property {number} slot - The save slot number. + * @property {("no_account" | "too_big" | "invalid_slot" | "server_error")} [message] - Reason for the error. + */ + +/** + * The GalaxyResponse can be one of InfoResponse, SaveListResponse, SaveContentResponse, or SavedResponse. + * + * @typedef {InfoResponse | SaveListResponse | SaveContentResponse | SavedResponse} GalaxyResponse + */ + +/** + * The GalaxyApi interface defines methods and properties for interacting with the Galaxy platform. + * + * @typedef {object} GalaxyApi + * @property {string[]} acceptedOrigins - Accepted origins. + * @property {boolean} [supportsSaving] - Whether saving is supported. + * @property {boolean} [supportsSaveManager] - Whether save manager is supported. + * @property {boolean} [ignoreApiVersion] - Whether to ignore API version. + * @property {function(GalaxyApi): void} [onLoggedInChanged] - Function to handle logged in changes. + * @property {string} origin - Origin of the API. + * @property {number} apiVersion - Version of the API. + * @property {boolean} loggedIn - Whether the player is logged in. + * @property {function(GalaxyAction): void} postMessage - Method to post a message. + * @property {function(): Promise>} getSaveList - Method to get the save list. + * @property {function(number, string, string?): Promise} save - Method to save data. + * @property {function(number): Promise<{ content: string; label?: string; slot: number }>} load - Method to load data. + */ + + +/** + * Initialize the Galaxy API. + * @param {Object} [options] - An object of options that configure the API + * @param {string[]} [options.acceptedOrigins] - A list of domains that the API trusts messages from. Defaults to `['https://galaxy.click']`. + * @param {boolean} [options.supportsSaving] - Indicates to Galaxy that this game supports saving. Defaults to false. + * @param {boolean} [options.supportsSaveManager] - Indicates to Galaxy that this game supports a saves manager. Defaults to false. + * @param {boolean} [options.ignoreApiVersion] - Ignores the api_version property received from Galaxy. By default this value is false, meaning if an unknown API version is encountered, the API will fail to initialize. + * @param {(galaxy: GalaxyApi) => void} [options.onLoggedInChanged] - A callback for when the logged in status of the player changes after the initialization. + * @returns {Promise} + */ +export function initGalaxy({ + acceptedOrigins, + supportsSaving, + supportsSaveManager, + ignoreApiVersion, + onLoggedInChanged +}) { + return new Promise((accept, reject) => { + acceptedOrigins = acceptedOrigins ?? ["https://galaxy.click"]; + if (acceptedOrigins.includes(window.origin)) { + // Callbacks to resolve promises + /** @type function(SaveListResponse["list"]):void */ + let saveListAccept, + /** @type function(string?):void */ + saveListReject; + /** @type Record */ + const saveCallbacks = {}; + /** @type Record */ + const loadCallbacks = {}; + + /** @type GalaxyApi */ + const galaxy = { + acceptedOrigins, + supportsSaving, + supportsSaveManager, + ignoreApiVersion, + onLoggedInChanged, + origin: window.origin, + apiVersion: 0, + loggedIn: false, + postMessage: function (message) { + window.top?.postMessage(message, galaxy.origin); + }, + getSaveList: function () { + if (saveListAccept != null || saveListReject != null) { + return Promise.reject("save_list action already in progress."); + } + galaxy.postMessage({ action: "save_list" }); + return new Promise((accept, reject) => { + saveListAccept = accept; + saveListReject = reject; + }); + }, + save: function (slot, content, label) { + if (slot in saveCallbacks) { + return Promise.reject(`save action for slot ${slot} already in progress.`); + } + galaxy.postMessage({ action: "save", slot, content, label }); + return new Promise((accept, reject) => { + saveCallbacks[slot] = { accept, reject }; + }); + }, + load: function (slot) { + if (slot in loadCallbacks) { + return Promise.reject(`load action for slot ${slot} already in progress.`); + } + galaxy.postMessage({ action: "load", slot }); + return new Promise((accept, reject) => { + loadCallbacks[slot] = { accept, reject }; + }); + } + }; + + window.addEventListener("message", e => { + if (e.origin === galaxy.origin) { + console.log("Received message from Galaxy", e.data); + /** @type GalaxyResponse */ + const data = e.data; + + switch (data.type) { + case "info": { + const { galaxy: isGalaxy, api_version, logged_in } = data; + // Ignoring isGalaxy check in case other accepted origins send it as false + if (api_version !== 1 && galaxy.ignoreApiVersion !== true) { + reject(`API version not recognized: ${api_version}`); + } else { + // Info responses may be sent again if the information gets updated + // Specifically, we care if logged_in gets changed + // We can use the api_version to determine if this is the first + // info response or a new one. + const firstInfoResponse = galaxy.apiVersion === 0; + galaxy.apiVersion = api_version; + galaxy.loggedIn = logged_in; + galaxy.origin = e.origin; + if (firstInfoResponse) { + accept(galaxy); + } else { + galaxy.onLoggedInChanged?.(galaxy); + } + } + break; + } + case "save_list": { + const { list, error, message } = data; + if (error === true) { + saveListReject(message); + } else { + saveListAccept(list); + } + saveListAccept = saveListReject = null; + break; + } + case "save_content": { + const { content, label, slot, error, message } = data; + if (error === true) { + loadCallbacks[slot]?.reject(message); + } else { + loadCallbacks[slot]?.accept({ slot, content, label }); + } + delete loadCallbacks[slot]; + break; + } + case "saved": { + const { slot, error, message } = data; + if (error === true) { + saveCallbacks[slot]?.reject(message); + } else { + saveCallbacks[slot]?.accept(slot); + } + delete saveCallbacks[slot]; + break; + } + } + } + }); + } else { + reject(`Project is not running on an accepted origin: ${window.origin}`); + } + }); +} diff --git a/src/main.ts b/src/main.ts index e416fa5..3b5de9f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,6 +8,7 @@ import { useRegisterSW } from "virtual:pwa-register/vue"; import type { App as VueApp } from "vue"; import { createApp, nextTick } from "vue"; import { useToast } from "vue-toastification"; +import "util/galaxy"; declare global { /** diff --git a/src/util/galaxy.ts b/src/util/galaxy.ts new file mode 100644 index 0000000..544ec3d --- /dev/null +++ b/src/util/galaxy.ts @@ -0,0 +1,134 @@ +import player, { Player } from "game/player"; +import settings from "game/settings"; +import { GalaxyApi, initGalaxy } from "lib/galaxy"; +import { decodeSave, loadSave, save } from "./save"; +import { setupInitialStore } from "./save"; +import { ref } from "vue"; + +export const galaxy = ref(); +export const conflictingSaves = ref([]); + +export function sync() { + if (galaxy.value == null || !galaxy.value.loggedIn) { + return; + } + if (conflictingSaves.value.length > 0) { + // Pause syncing while resolving conflicted saves + return; + } + galaxy.value.getSaveList().then(syncSaves); +} + +// Setup Galaxy API +initGalaxy({ + supportsSaving: true, + supportsSaveManager: true, + onLoggedInChanged +}).then(g => { + galaxy.value = g; + onLoggedInChanged(g); +}); + +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); + + // If our current save has under a minute of playtime, load the cloud save with the most recent time. + if (player.timePlayed < 60 && saves.length > 0) { + const longestSave = saves.reduce((acc, curr) => + acc.content.time < curr.content.time ? curr : acc + ); + loadSave(longestSave.content); + } + }); +} + +function syncSaves( + list: Record< + number, + { + label: string; + content: string; + } + > +) { + return ( + Object.keys(list) + .map(slot => { + const { label, content } = list[slot as unknown as number]; + try { + return { slot: parseInt(slot), label, content: JSON.parse(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 & { id: string; time: number; timePlayed: number }; + }[] + ).filter(cloudSave => { + if (cloudSave.label != null) { + cloudSave.content.name = cloudSave.label; + } + const localSaveId = settings.saves.find(id => id === cloudSave.content.id); + if (localSaveId == undefined) { + settings.saves.push(cloudSave.content.id); + save(setupInitialStore(cloudSave.content)); + } else { + try { + const localSave = JSON.parse( + decodeSave(localStorage.getItem(localSaveId) ?? "") ?? "" + ) as Partial | 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 a minute, just use the newer save (very unlikely to be coincidence) + // Otherwise, ask the player + if (timePlayedDiff < 60 && timeDiff < 60) { + if (localSave.time < cloudSave.content.time) { + save(setupInitialStore(cloudSave.content)); + if (settings.active === localSaveId) { + loadSave(cloudSave.content); + } + } else { + galaxy.value?.save( + cloudSave.slot, + JSON.stringify(cloudSave.content), + cloudSave.label ?? null + ); + // Update cloud save content for the return value + cloudSave.content = localSave as Player; + } + } else { + conflictingSaves.value.push(localSaveId); + } + } catch (e) { + return false; + } + } + return true; + }); +} diff --git a/src/util/save.ts b/src/util/save.ts index 4137e5b..9d51016 100644 --- a/src/util/save.ts +++ b/src/util/save.ts @@ -1,10 +1,12 @@ +import { LoadablePlayerData } from "components/SavesManager.vue"; import projInfo from "data/projInfo.json"; import { globalBus } from "game/events"; import type { Player } from "game/player"; import player, { stringifySave } from "game/player"; import settings, { loadSettings } from "game/settings"; import LZString from "lz-string"; -import { ref } from "vue"; +import { ref, shallowReactive } from "vue"; +import { sync } from "./galaxy"; export function setupInitialStore(player: Partial = {}): Player { return Object.assign( @@ -29,6 +31,7 @@ export function setupInitialStore(player: Partial = {}): Player { export function save(playerData?: Player): string { const stringifiedSave = LZString.compressToUTF16(stringifySave(playerData ?? player)); localStorage.setItem((playerData ?? player).id, stringifiedSave); + sync(); return stringifiedSave; } @@ -42,17 +45,9 @@ export async function load(): Promise { await loadSave(newSave()); return; } - if (save[0] === "{") { - // plaintext. No processing needed - } else if (save[0] === "e") { - // Assumed to be base64, which starts with e - save = decodeURIComponent(escape(atob(save))); - } else if (save[0] === "ᯡ") { - // Assumed to be lz, which starts with ᯡ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - save = LZString.decompressFromUTF16(save)!; - } else { - throw `Unable to determine save encoding`; + save = decodeSave(save); + if (save == null) { + throw "Unable to determine save encoding"; } const player = JSON.parse(save); if (player.modID !== projInfo.id) { @@ -67,6 +62,23 @@ export async function load(): Promise { } } +export function decodeSave(save: string) { + if (save[0] === "{") { + // plaintext. No processing needed + } else if (save[0] === "e") { + // Assumed to be base64, which starts with e + save = decodeURIComponent(escape(atob(save))); + } else if (save[0] === "ᯡ") { + // Assumed to be lz, which starts with ᯡ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + save = LZString.decompressFromUTF16(save)!; + } else { + console.warn("Unable to determine preset encoding", save); + return null; + } + return save; +} + export function newSave(): Player { const id = getUniqueID(); const player = setupInitialStore({ id }); @@ -127,6 +139,40 @@ export async function loadSave(playerObj: Partial): Promise { globalBus.emit("onLoad"); } +const cachedSaves = shallowReactive>({}); +export function getCachedSave(id: string) { + if (cachedSaves[id] == null) { + let save = localStorage.getItem(id); + if (save == null) { + cachedSaves[id] = { error: `Save doesn't exist in localStorage`, id }; + } else if (save === "dW5kZWZpbmVk") { + cachedSaves[id] = { error: `Save is undefined`, id }; + } else { + try { + save = decodeSave(save); + if (save == null) { + console.warn("Unable to determine preset encoding", save); + cachedSaves[id] = { error: "Unable to determine preset encoding", id }; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return cachedSaves[id]!; + } + cachedSaves[id] = { ...JSON.parse(save), id }; + } catch (error) { + cachedSaves[id] = { error, id }; + console.warn(`Failed to load info about save with id ${id}:\n${error}\n${save}`); + } + } + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return cachedSaves[id]!; +} +export function clearCachedSaves() { + Object.keys(cachedSaves).forEach(key => delete cachedSaves[key]); +} +export function clearCachedSave(id: string) { + cachedSaves[id] = undefined; +} + setInterval(() => { if (player.autosave) { save(); -- 2.45.2 From ece7ed2923ec29cdb4661918cfa964340bc8a8b3 Mon Sep 17 00:00:00 2001 From: thepaperpilot Date: Sat, 17 Feb 2024 10:23:18 -0600 Subject: [PATCH 19/30] Add save conflict resolver --- src/App.vue | 4 +- src/components/Modal.vue | 9 +- src/components/NaNScreen.vue | 2 +- src/components/Nav.vue | 2 +- src/components/saves/CloudSaveResolver.vue | 186 ++++++++++++++++++++ src/components/{ => saves}/Save.vue | 44 +++-- src/components/{ => saves}/SavesManager.vue | 4 +- src/util/galaxy.ts | 84 ++++++--- src/util/save.ts | 2 +- 9 files changed, 291 insertions(+), 46 deletions(-) create mode 100644 src/components/saves/CloudSaveResolver.vue rename src/components/{ => saves}/Save.vue (81%) rename src/components/{ => saves}/SavesManager.vue (99%) diff --git a/src/App.vue b/src/App.vue index 40e21de..7e7a0aa 100644 --- a/src/App.vue +++ b/src/App.vue @@ -8,6 +8,7 @@ + @@ -16,10 +17,11 @@ + + + + diff --git a/src/components/Save.vue b/src/components/saves/Save.vue similarity index 81% rename from src/components/Save.vue rename to src/components/saves/Save.vue index 77d2988..d8af7d0 100644 --- a/src/components/Save.vue +++ b/src/components/saves/Save.vue @@ -1,7 +1,7 @@