@@ -73,16 +78,18 @@
import Tooltip from "features/tooltips/Tooltip.vue";
import player from "game/player";
import { Direction } from "util/common";
-import { computed, ref, toRefs, watch } from "vue";
-import DangerButton from "./fields/DangerButton.vue";
-import FeedbackButton from "./fields/FeedbackButton.vue";
-import Text from "./fields/Text.vue";
+import { computed, ref, toRefs, unref, watch } from "vue";
+import DangerButton from "../fields/DangerButton.vue";
+import FeedbackButton from "../fields/FeedbackButton.vue";
+import Text from "../fields/Text.vue";
import type { LoadablePlayerData } from "./SavesManager.vue";
+import { galaxy, syncedSaves } from "util/galaxy";
const _props = defineProps<{
save: LoadablePlayerData;
+ readonly?: boolean;
}>();
-const { save } = toRefs(_props);
+const { save, readonly } = toRefs(_props);
const emit = defineEmits<{
(e: "export"): void;
(e: "open"): void;
@@ -106,10 +113,18 @@ const newName = ref("");
watch(isEditing, () => (newName.value = save.value.name ?? ""));
-const isActive = computed(() => save.value != null && save.value.id === player.id);
+const isActive = computed(
+ () => save.value != null && save.value.id === player.id && !unref(readonly)
+);
const currentTime = computed(() =>
isActive.value ? player.time : (save.value != null && save.value.time) ?? 0
);
+const synced = computed(
+ () =>
+ !unref(readonly) &&
+ galaxy.value?.loggedIn === true &&
+ syncedSaves.value.includes(save.value.id)
+);
function changeName() {
emit("editName", newName.value);
@@ -139,6 +154,13 @@ function changeName() {
padding-left: 0;
}
+.open:disabled {
+ cursor: inherit;
+ color: var(--foreground);
+ opacity: 1;
+ pointer-events: none;
+}
+
.handle {
flex-grow: 0;
margin-right: 8px;
@@ -152,6 +174,10 @@ function changeName() {
margin-right: 80px;
}
+.save.readonly .details {
+ margin-right: 0;
+}
+
.error {
font-size: 0.8em;
color: var(--danger);
@@ -176,6 +202,17 @@ function changeName() {
.editname {
margin: 0;
}
+
+.time {
+ font-size: small;
+}
+
+.synced {
+ font-size: 100%;
+ margin-right: 0.5em;
+ vertical-align: middle;
+ cursor: default;
+}
diff --git a/src/components/SavesManager.vue b/src/components/saves/SavesManager.vue
similarity index 70%
rename from src/components/SavesManager.vue
rename to src/components/saves/SavesManager.vue
index b1bf7e0..4edb2e0 100644
--- a/src/components/SavesManager.vue
+++ b/src/components/saves/SavesManager.vue
@@ -4,6 +4,9 @@
Saves Manager
+
+ Not all saves are synced! You may need to delete stale saves.
+
, "id"> & { id: string; error?: unknown };
@@ -90,16 +103,8 @@ watch(saveToImport, importedSave => {
if (importedSave) {
nextTick(() => {
try {
- if (importedSave[0] === "{") {
- // plaintext. No processing needed
- } else if (importedSave[0] === "e") {
- // Assumed to be base64, which starts with e
- importedSave = decodeURIComponent(escape(atob(importedSave)));
- } else if (importedSave[0] === "ᯡ") {
- // Assumed to be lz, which starts with ᯡ
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- importedSave = LZString.decompressFromUTF16(importedSave)!;
- } else {
+ importedSave = decodeSave(importedSave) ?? "";
+ if (importedSave === "") {
console.warn("Unable to determine preset encoding", importedSave);
importingFailed.value = true;
return;
@@ -139,48 +144,10 @@ let bank = ref(
}, [])
);
-const cachedSaves = shallowReactive>({});
-function getCachedSave(id: string) {
- if (cachedSaves[id] == null) {
- let save = localStorage.getItem(id);
- if (save == null) {
- cachedSaves[id] = { error: `Save doesn't exist in localStorage`, id };
- } else if (save === "dW5kZWZpbmVk") {
- cachedSaves[id] = { error: `Save is undefined`, id };
- } else {
- try {
- if (save[0] === "{") {
- // plaintext. No processing needed
- } else if (save[0] === "e") {
- // Assumed to be base64, which starts with e
- save = decodeURIComponent(escape(atob(save)));
- } else if (save[0] === "ᯡ") {
- // Assumed to be lz, which starts with ᯡ
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- save = LZString.decompressFromUTF16(save)!;
- } else {
- console.warn("Unable to determine preset encoding", save);
- importingFailed.value = true;
- cachedSaves[id] = { error: "Unable to determine preset encoding", id };
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- return cachedSaves[id]!;
- }
- cachedSaves[id] = { ...JSON.parse(save), id };
- } catch (error) {
- cachedSaves[id] = { error, id };
- console.warn(
- `SavesManager: Failed to load info about save with id ${id}:\n${error}\n${save}`
- );
- }
- }
- }
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- return cachedSaves[id]!;
-}
// Wipe cache whenever the modal is opened
watch(isOpen, isOpen => {
if (isOpen) {
- Object.keys(cachedSaves).forEach(key => delete cachedSaves[key]);
+ clearCachedSaves();
}
});
@@ -191,6 +158,10 @@ const saves = computed(() =>
}, {})
);
+const showNotSyncedWarning = computed(
+ () => galaxy.value?.loggedIn === true && settings.saves.length < syncedSaves.value.length
+);
+
function exportSave(id: string) {
let saveToExport;
if (player.id === id) {
@@ -233,20 +204,37 @@ function duplicateSave(id: string) {
}
function deleteSave(id: string) {
+ if (galaxy.value?.loggedIn === true) {
+ galaxy.value.getSaveList().then(list => {
+ const slot = Object.keys(list).find(slot => {
+ const content = list[slot as unknown as number].content;
+ try {
+ if (JSON.parse(content).id === id) {
+ return true;
+ }
+ } catch (e) {
+ return false;
+ }
+ });
+ if (slot != null) {
+ galaxy.value?.save(parseInt(slot), "", "").catch(console.error);
+ }
+ });
+ }
settings.saves = settings.saves.filter((save: string) => save !== id);
localStorage.removeItem(id);
- cachedSaves[id] = undefined;
+ clearCachedSave(id);
}
function openSave(id: string) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
saves.value[player.id]!.time = player.time;
save();
- cachedSaves[player.id] = undefined;
+ clearCachedSave(player.id);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
loadSave(saves.value[id]!);
// Delete cached version in case of opening it again
- cachedSaves[id] = undefined;
+ clearCachedSave(id);
}
function newFromPreset(preset: string) {
@@ -256,16 +244,8 @@ function newFromPreset(preset: string) {
selectedPreset.value = null;
});
- if (preset[0] === "{") {
- // plaintext. No processing needed
- } else if (preset[0] === "e") {
- // Assumed to be base64, which starts with e
- preset = decodeURIComponent(escape(atob(preset)));
- } else if (preset[0] === "ᯡ") {
- // Assumed to be lz, which starts with ᯡ
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- preset = LZString.decompressFromUTF16(preset)!;
- } else {
+ preset = decodeSave(preset) ?? "";
+ if (preset === "") {
console.warn("Unable to determine preset encoding", preset);
return;
}
@@ -287,7 +267,7 @@ function editSave(id: string, newName: string) {
save();
} else {
save(currSave as Player);
- cachedSaves[id] = undefined;
+ clearCachedSave(id);
}
}
}
diff --git a/src/features/achievements/achievement.tsx b/src/features/achievements/achievement.tsx
index 9bf454f..286e159 100644
--- a/src/features/achievements/achievement.tsx
+++ b/src/features/achievements/achievement.tsx
@@ -208,7 +208,7 @@ export function createAchievement(
unref(achievement.earned) &&
!(
display != null &&
- typeof display == "object" &&
+ typeof display === "object" &&
"optionsDisplay" in (display as Record)
)
) {
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/feature.ts b/src/features/feature.ts
index 429629a..e829303 100644
--- a/src/features/feature.ts
+++ b/src/features/feature.ts
@@ -92,7 +92,7 @@ export function setDefault(
key: K,
value: T[K]
): asserts object is Exclude & Required> {
- if (object[key] === undefined && value != undefined) {
+ if (object[key] == null && value != null) {
object[key] = value;
}
}
@@ -135,7 +135,7 @@ export function excludeFeatures(obj: Record, ...types: symbol[]
if (value != null && typeof value === "object") {
if (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- typeof (value as Record).type == "symbol" &&
+ typeof (value as Record).type === "symbol" &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
!types.includes((value as Record).type)
) {
diff --git a/src/features/grids/grid.ts b/src/features/grids/grid.ts
index 454bbfc..ca2e3f7 100644
--- a/src/features/grids/grid.ts
+++ b/src/features/grids/grid.ts
@@ -128,7 +128,7 @@ function getCellHandler(id: string): ProxyHandler {
if (isFunction(prop)) {
return () => prop.call(receiver, id, target.getState(id));
}
- if (prop != undefined || typeof key === "symbol") {
+ if (prop != null || typeof key === "symbol") {
return prop;
}
@@ -145,7 +145,7 @@ function getCellHandler(id: string): ProxyHandler {
cache[key] = computed(() => prop.call(receiver, id, target.getState(id)));
}
return cache[key].value;
- } else if (prop != undefined) {
+ } else if (prop != null) {
return unref(prop);
}
@@ -153,7 +153,7 @@ function getCellHandler(id: string): ProxyHandler {
prop = (target as any)[`on${key}`];
if (isFunction(prop)) {
return () => prop.call(receiver, id, target.getState(id));
- } else if (prop != undefined) {
+ } else if (prop != null) {
return prop;
}
@@ -318,7 +318,7 @@ export function createGrid(
return grid.id + "-" + cell;
};
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 this.cells[cell].startState;
diff --git a/src/features/hotkey.tsx b/src/features/hotkey.tsx
index 80eebbd..eedbe4f 100644
--- a/src/features/hotkey.tsx
+++ b/src/features/hotkey.tsx
@@ -99,16 +99,30 @@ document.onkeydown = function (e) {
if (hasWon.value && !player.keepGoing) {
return;
}
- let key = e.key;
- if (uppercaseNumbers.includes(key)) {
- key = "shift+" + uppercaseNumbers.indexOf(key);
+ const keysToCheck: string[] = [e.key];
+ if (e.shiftKey && e.ctrlKey) {
+ 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) {
- 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) {
- key = "ctrl+" + key;
- }
- const hotkey = hotkeys[key] ?? hotkeys[key.toLowerCase()];
+ const hotkey = hotkeys[keysToCheck.find(key => key in hotkeys) ?? ""];
if (hotkey && unref(hotkey.enabled)) {
e.preventDefault();
hotkey.onPress();
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 4e43bbf..58c9db5 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,
@@ -342,15 +342,15 @@ export const branchedResetPropagation = function (
if (links == null) return;
const reset: GenericTreeNode[] = [];
let current = [resettingNode];
- while (current.length != 0) {
+ while (current.length !== 0) {
const next: GenericTreeNode[] = [];
for (const node of current) {
for (const link of links.filter(link => link.startNode === node)) {
- if ([...reset, ...current].includes(link.endNode)) continue
+ if ([...reset, ...current].includes(link.endNode)) continue;
next.push(link.endNode);
link.endNode.reset?.reset();
}
- };
+ }
reset.push(...current);
current = next;
}
diff --git a/src/game/formulas/formulas.ts b/src/game/formulas/formulas.ts
index 7f88d0c..ac1145f 100644
--- a/src/game/formulas/formulas.ts
+++ b/src/game/formulas/formulas.ts
@@ -56,6 +56,7 @@ export abstract class InternalFormula | undefined;
protected readonly internalIntegrateInner: IntegrateFunction | undefined;
protected readonly applySubstitution: SubstitutionFunction | undefined;
+ protected readonly description: string | undefined;
protected readonly internalVariables: number;
public readonly innermostVariable: ProcessedComputable | undefined;
@@ -85,6 +86,7 @@ export abstract class InternalFormula,
formulaModifier: (value: InvertibleIntegralFormula) => GenericFormula
@@ -1402,28 +1431,6 @@ export function findNonInvertible(formula: GenericFormula): GenericFormula | nul
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.
* @param formula The formula to use for calculating buy max from
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/formulas/types.d.ts b/src/game/formulas/types.d.ts
index 88efd92..cc185a9 100644
--- a/src/game/formulas/types.d.ts
+++ b/src/game/formulas/types.d.ts
@@ -37,9 +37,13 @@ type SubstitutionFunction = (
...inputs: T
) => GenericFormula;
-type VariableFormulaOptions = { variable: ProcessedComputable };
+type VariableFormulaOptions = {
+ variable: ProcessedComputable;
+ description?: string;
+};
type ConstantFormulaOptions = {
inputs: [FormulaSource];
+ description?: string;
};
type GeneralFormulaOptions = {
inputs: T;
@@ -48,6 +52,7 @@ type GeneralFormulaOptions = {
integrate?: IntegrateFunction;
integrateInner?: IntegrateFunction;
applySubstitution?: SubstitutionFunction;
+ description?: string;
};
type FormulaOptions =
| VariableFormulaOptions
@@ -63,6 +68,7 @@ type InternalFormulaProperties = {
internalIntegrateInner?: IntegrateFunction;
applySubstitution?: SubstitutionFunction;
innermostVariable?: ProcessedComputable;
+ description?: string;
};
type SubstitutionStack = ((value: GenericFormula) => GenericFormula)[] | undefined;
diff --git a/src/game/gameLoop.ts b/src/game/gameLoop.ts
index b001dd6..9f5c5ca 100644
--- a/src/game/gameLoop.ts
+++ b/src/game/gameLoop.ts
@@ -43,7 +43,7 @@ function update() {
loadingSave.value = false;
// Add offline time if any
- if (player.offlineTime != undefined) {
+ if (player.offlineTime != null) {
if (Decimal.gt(player.offlineTime, projInfo.offlineLimit * 3600)) {
player.offlineTime = projInfo.offlineLimit * 3600;
}
@@ -63,7 +63,7 @@ function update() {
diff = Math.min(diff, projInfo.maxTickLength);
// Apply dev speed
- if (player.devSpeed != undefined) {
+ if (player.devSpeed != null) {
diff *= player.devSpeed;
}
diff --git a/src/game/persistence.ts b/src/game/persistence.ts
index e760411..4e7704d 100644
--- a/src/game/persistence.ts
+++ b/src/game/persistence.ts
@@ -62,6 +62,8 @@ export type State =
| number
| boolean
| DecimalSource
+ | null
+ | undefined
| { [key: string]: State }
| { [key: number]: State };
@@ -227,7 +229,7 @@ export function noPersist, S extends State>(persistent:
if (key === PersistentState) {
return false;
}
- if (key == SkipPersistence) {
+ if (key === SkipPersistence) {
return true;
}
return Reflect.has(target, key);
@@ -279,7 +281,7 @@ globalBus.on("addLayer", (layer: GenericLayer, saveData: Record
// Handle SaveDataPath
const newPath = [layer.id, ...path, key];
if (
- value[SaveDataPath] != undefined &&
+ value[SaveDataPath] != null &&
JSON.stringify(newPath) !== JSON.stringify(value[SaveDataPath])
) {
console.error(
diff --git a/src/game/player.ts b/src/game/player.ts
index 5d1142e..322e4db 100644
--- a/src/game/player.ts
+++ b/src/game/player.ts
@@ -64,7 +64,8 @@ export default window.player = player;
/** Convert a player save data object into a JSON string. Unwraps refs. */
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 {
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
);
}
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/main.css b/src/main.css
index 60188bd..f84ba5f 100644
--- a/src/main.css
+++ b/src/main.css
@@ -66,3 +66,7 @@ ul {
.Vue-Toastification__toast {
margin: unset;
}
+
+:disabled {
+ pointer-events: none;
+}
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/break_eternity.ts b/src/util/break_eternity.ts
index dd6aed7..6734ece 100644
--- a/src/util/break_eternity.ts
+++ b/src/util/break_eternity.ts
@@ -26,7 +26,7 @@ export function exponentialFormat(num: DecimalSource, precision: number, mantiss
}
export function commaFormat(num: DecimalSource, precision: number): string {
- if (num === null || num === undefined) {
+ if (num == null) {
return "NaN";
}
num = new Decimal(num);
@@ -36,12 +36,12 @@ export function commaFormat(num: DecimalSource, precision: number): string {
const init = num.toStringWithDecimalPlaces(precision);
const portions = init.split(".");
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];
}
export function regularFormat(num: DecimalSource, precision: number): string {
- if (num === null || num === undefined) {
+ if (num == null) {
return "NaN";
}
num = new Decimal(num);
diff --git a/src/util/galaxy.ts b/src/util/galaxy.ts
new file mode 100644
index 0000000..fdefe57
--- /dev/null
+++ b/src/util/galaxy.ts
@@ -0,0 +1,185 @@
+import { LoadablePlayerData } from "components/saves/SavesManager.vue";
+import player, { Player, stringifySave } from "game/player";
+import settings from "game/settings";
+import { GalaxyApi, initGalaxy } from "unofficial-galaxy-sdk";
+import LZString from "lz-string";
+import { ref } from "vue";
+import { decodeSave, loadSave, save, setupInitialStore } from "./save";
+
+export const galaxy = ref();
+export const conflictingSaves = ref<
+ { id: string; local: LoadablePlayerData; cloud: LoadablePlayerData; slot: number }[]
+>([]);
+export const syncedSaves = ref([]);
+
+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 & { 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 | 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;
+}
diff --git a/src/util/save.ts b/src/util/save.ts
index 4137e5b..86c7691 100644
--- a/src/util/save.ts
+++ b/src/util/save.ts
@@ -1,10 +1,11 @@
+import { LoadablePlayerData } from "components/saves/SavesManager.vue";
import projInfo from "data/projInfo.json";
import { globalBus } from "game/events";
import type { Player } from "game/player";
import player, { stringifySave } from "game/player";
import settings, { loadSettings } from "game/settings";
import LZString from "lz-string";
-import { ref } from "vue";
+import { ref, shallowReactive } from "vue";
export function setupInitialStore(player: Partial = {}): Player {
return Object.assign(
@@ -42,17 +43,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 +60,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 });
@@ -109,7 +119,7 @@ export async function loadSave(playerObj: Partial): Promise {
playerObj.time &&
playerObj.devSpeed !== 0
) {
- if (playerObj.offlineTime == undefined) playerObj.offlineTime = 0;
+ if (playerObj.offlineTime == null) playerObj.offlineTime = 0;
playerObj.offlineTime += Math.min(
playerObj.offlineTime + (Date.now() - playerObj.time) / 1000,
projInfo.offlineLimit * 3600
@@ -127,6 +137,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();
diff --git a/src/util/vue.tsx b/src/util/vue.tsx
index 3ba5f06..5f05618 100644
--- a/src/util/vue.tsx
+++ b/src/util/vue.tsx
@@ -191,7 +191,7 @@ export function computeOptionalComponent(
watchEffect(() => {
const currComponent = unwrapRef(component);
comp.value =
- currComponent == "" || currComponent == null
+ currComponent === "" || currComponent == null
? null
: coerceComponent(currComponent, defaultWrapper);
});
diff --git a/tests/features/hotkey.test.ts b/tests/features/hotkey.test.ts
new file mode 100644
index 0000000..f62dfc8
--- /dev/null
+++ b/tests/features/hotkey.test.ts
@@ -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) {
+ 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);
+ }
+ );
+});
diff --git a/tests/game/formulas.test.ts b/tests/game/formulas.test.ts
index 98bb82d..b21c2e0 100644
--- a/tests/game/formulas.test.ts
+++ b/tests/game/formulas.test.ts
@@ -155,7 +155,7 @@ describe("Formula Equality Checking", () => {
describe("Formula aliases", () => {
function testAliases(
aliases: T[],
- args: Parameters
+ args: Parameters<(typeof Formula)[T]>
) {
describe(aliases[0], () => {
let formula: GenericFormula;
@@ -250,7 +250,7 @@ describe("Creating Formulas", () => {
function checkFormula(
functionName: T,
- args: Readonly>
+ args: Readonly>
) {
let formula: GenericFormula;
beforeAll(() => {
@@ -274,7 +274,7 @@ describe("Creating Formulas", () => {
// It's a lot of tests, but I'd rather be exhaustive
function testFormulaCall(
functionName: T,
- args: Readonly>
+ args: Readonly>
) {
if ((functionName === "slog" || functionName === "layeradd") && args[0] === -1) {
// These cases in particular take a long time, so skip them
@@ -1275,3 +1275,16 @@ describe("Buy Max", () => {
});
});
});
+
+describe("Stringifies", () => {
+ test("Nested formula", () => {
+ const variable = Formula.variable(ref(0));
+ expect(variable.add(5).pow(Formula.constant(10)).stringify()).toBe(
+ "pow(add(x, 5.00), 10.00)"
+ );
+ });
+ test("Indeterminate", () => {
+ expect(Formula.if(10, true, f => f.add(5)).stringify()).toBe("indeterminate");
+ expect(Formula.step(10, 5, f => f.add(5)).stringify()).toBe("indeterminate");
+ });
+});
diff --git a/tests/game/modifiers.test.ts b/tests/game/modifiers.test.ts
index dd019e1..6cba0d0 100644
--- a/tests/game/modifiers.test.ts
+++ b/tests/game/modifiers.test.ts
@@ -1,5 +1,5 @@
import { CoercableComponent, JSXFunction } from "features/feature";
-import Formula, { printFormula } from "game/formulas/formulas";
+import Formula from "game/formulas/formulas";
import {
createAdditiveModifier,
createExponentialModifier,
@@ -52,7 +52,7 @@ function testModifiers<
expect(modifier.invert(operation(10, 5))).compare_tolerance(10));
test("getFormula returns the right formula", () => {
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)`
);
});
@@ -156,7 +156,7 @@ describe("Sequential Modifiers", () => {
expect(modifier.invert(Decimal.add(10, 5).times(5).pow(5))).compare_tolerance(10));
test("getFormula returns the right formula", () => {
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)`
);
});
diff --git a/tests/utils.ts b/tests/utils.ts
index 4252eac..34dd471 100644
--- a/tests/utils.ts
+++ b/tests/utils.ts
@@ -6,14 +6,11 @@ interface CustomMatchers {
toLogError(): R;
}
-declare global {
- // eslint-disable-next-line @typescript-eslint/no-namespace
- namespace Vi {
- // eslint-disable-next-line @typescript-eslint/no-empty-interface
- interface Assertion extends CustomMatchers {}
- // eslint-disable-next-line @typescript-eslint/no-empty-interface
- interface AsymmetricMatchersContaining extends CustomMatchers {}
- }
+declare module "vitest" {
+ // eslint-disable-next-line @typescript-eslint/no-empty-interface
+ interface Assertion extends CustomMatchers {}
+ // eslint-disable-next-line @typescript-eslint/no-empty-interface
+ interface AsymmetricMatchersContaining extends CustomMatchers {}
}
expect.extend({