Fix save data object redundancy

Note: Requires the use of noPersist quite a bit, but the console will tell you when you missed a spot
Also required breaking up events.ts due to cyclical dependencies
This commit is contained in:
thepaperpilot 2022-12-05 22:53:46 -06:00
parent 14cf9f81bb
commit c07298df9c
6 changed files with 217 additions and 127 deletions

View file

@ -1,5 +1,5 @@
import { globalBus } from "game/events";
import type { State } from "game/persistence";
import { NonPersistent, Persistent, State } from "game/persistence";
import { persistent } from "game/persistence";
import type { DecimalSource } from "util/bignum";
import Decimal, { format, formatWhole } from "util/bignum";
@ -13,18 +13,38 @@ export interface Resource<T = DecimalSource> extends Ref<T> {
small?: boolean;
}
export function createResource<T extends State>(
defaultValue: T,
displayName?: string,
precision?: number,
small?: boolean | undefined
): Resource<T> & Persistent<T> & { [NonPersistent]: Resource<T> };
export function createResource<T extends State>(
defaultValue: Ref<T>,
displayName?: string,
precision?: number,
small?: boolean | undefined
): Resource<T>;
export function createResource<T extends State>(
defaultValue: T | Ref<T>,
displayName = "points",
precision = 0,
small: boolean | undefined = undefined
): Resource<T> {
) {
const resource: Partial<Resource<T>> = isRef(defaultValue)
? defaultValue
: persistent(defaultValue);
resource.displayName = displayName;
resource.precision = precision;
resource.small = small;
if (!isRef(defaultValue)) {
const nonPersistentResource = (resource as Persistent<T>)[
NonPersistent
] as unknown as Resource<T>;
nonPersistentResource.displayName = displayName;
nonPersistentResource.precision = precision;
nonPersistentResource.small = small;
}
return resource as Resource<T>;
}

View file

@ -1,12 +1,6 @@
import projInfo from "data/projInfo.json";
import player from "game/player";
import type { Settings } from "game/settings";
import settings from "game/settings";
import state from "game/state";
import { createNanoEvents } from "nanoevents";
import Decimal from "util/bignum";
import type { App, Ref } from "vue";
import { watch } from "vue";
import type { App } from "vue";
import type { GenericLayer } from "./layers";
/** All types of events able to be sent or emitted from the global event bus. */
@ -60,102 +54,4 @@ export interface GlobalEvents {
/** A global event bus for hooking into {@link GlobalEvents}. */
export const globalBus = createNanoEvents<GlobalEvents>();
let intervalID: NodeJS.Timer | null = null;
// Not imported immediately due to dependency cycles
// This gets set during startGameLoop(), and will only be used in the update function
let hasWon: null | Ref<boolean> = null;
function update() {
const now = Date.now();
let diff = (now - player.time) / 1e3;
player.time = now;
const trueDiff = diff;
state.lastTenTicks.push(trueDiff);
if (state.lastTenTicks.length > 10) {
state.lastTenTicks = state.lastTenTicks.slice(1);
}
// Stop here if the game is paused on the win screen
if (hasWon?.value && !player.keepGoing) {
return;
}
// Stop here if the player had a NaN value
if (state.hasNaN) {
return;
}
diff = Math.max(diff, 0);
if (player.devSpeed === 0) {
return;
}
// Add offline time if any
if (player.offlineTime != undefined) {
if (Decimal.gt(player.offlineTime, projInfo.offlineLimit * 3600)) {
player.offlineTime = projInfo.offlineLimit * 3600;
}
if (Decimal.gt(player.offlineTime, 0) && player.devSpeed !== 0) {
const offlineDiff = Math.max(player.offlineTime / 10, diff);
player.offlineTime = player.offlineTime - offlineDiff;
diff += offlineDiff;
} else if (player.devSpeed === 0) {
player.offlineTime += diff;
}
if (!player.offlineProd || Decimal.lt(player.offlineTime, 0)) {
player.offlineTime = null;
}
}
// Cap at max tick length
diff = Math.min(diff, projInfo.maxTickLength);
// Apply dev speed
if (player.devSpeed != undefined) {
diff *= player.devSpeed;
}
if (!Number.isFinite(diff)) {
diff = 1e308;
}
// Update
if (Decimal.eq(diff, 0)) {
return;
}
player.timePlayed += diff;
if (!Number.isFinite(player.timePlayed)) {
player.timePlayed = 1e308;
}
globalBus.emit("update", diff, trueDiff);
if (settings.unthrottled) {
requestAnimationFrame(update);
if (intervalID != null) {
clearInterval(intervalID);
intervalID = null;
}
} else if (intervalID == null) {
intervalID = setInterval(update, 50);
}
}
/** Starts the game loop for the project, which updates the game in ticks. */
export async function startGameLoop() {
hasWon = (await import("data/projEntry")).hasWon;
watch(hasWon, hasWon => {
if (hasWon) {
globalBus.emit("gameWon");
}
});
if (settings.unthrottled) {
requestAnimationFrame(update);
} else {
intervalID = setInterval(update, 50);
}
}
document.fonts.onloadingdone = () => globalBus.emit("fontsLoaded");

106
src/game/gameLoop.ts Normal file
View file

@ -0,0 +1,106 @@
import projInfo from "data/projInfo.json";
import { globalBus } from "game/events";
import settings from "game/settings";
import Decimal from "util/bignum";
import type { Ref } from "vue";
import { watch } from "vue";
import player from "./player";
import state from "./state";
let intervalID: NodeJS.Timer | null = null;
// Not imported immediately due to dependency cycles
// This gets set during startGameLoop(), and will only be used in the update function
let hasWon: null | Ref<boolean> = null;
function update() {
const now = Date.now();
let diff = (now - player.time) / 1e3;
player.time = now;
const trueDiff = diff;
state.lastTenTicks.push(trueDiff);
if (state.lastTenTicks.length > 10) {
state.lastTenTicks = state.lastTenTicks.slice(1);
}
// Stop here if the game is paused on the win screen
if (hasWon?.value && !player.keepGoing) {
return;
}
// Stop here if the player had a NaN value
if (state.hasNaN) {
return;
}
diff = Math.max(diff, 0);
if (player.devSpeed === 0) {
return;
}
// Add offline time if any
if (player.offlineTime != undefined) {
if (Decimal.gt(player.offlineTime, projInfo.offlineLimit * 3600)) {
player.offlineTime = projInfo.offlineLimit * 3600;
}
if (Decimal.gt(player.offlineTime, 0) && player.devSpeed !== 0) {
const offlineDiff = Math.max(player.offlineTime / 10, diff);
player.offlineTime = player.offlineTime - offlineDiff;
diff += offlineDiff;
} else if (player.devSpeed === 0) {
player.offlineTime += diff;
}
if (!player.offlineProd || Decimal.lt(player.offlineTime, 0)) {
player.offlineTime = null;
}
}
// Cap at max tick length
diff = Math.min(diff, projInfo.maxTickLength);
// Apply dev speed
if (player.devSpeed != undefined) {
diff *= player.devSpeed;
}
if (!Number.isFinite(diff)) {
diff = 1e308;
}
// Update
if (Decimal.eq(diff, 0)) {
return;
}
player.timePlayed += diff;
if (!Number.isFinite(player.timePlayed)) {
player.timePlayed = 1e308;
}
globalBus.emit("update", diff, trueDiff);
if (settings.unthrottled) {
requestAnimationFrame(update);
if (intervalID != null) {
clearInterval(intervalID);
intervalID = null;
}
} else if (intervalID == null) {
intervalID = setInterval(update, 50);
}
}
/** Starts the game loop for the project, which updates the game in ticks. */
export async function startGameLoop() {
hasWon = (await import("data/projEntry")).hasWon;
watch(hasWon, hasWon => {
if (hasWon) {
globalBus.emit("gameWon");
}
});
if (settings.unthrottled) {
requestAnimationFrame(update);
} else {
intervalID = setInterval(update, 50);
}
}

View file

@ -1,3 +1,4 @@
import { identifier } from "@babel/types";
import { isArray } from "@vue/shared";
import { globalBus } from "game/events";
import type { GenericLayer } from "game/layers";
@ -5,8 +6,8 @@ import { addingLayers, persistentRefs } from "game/layers";
import type { DecimalSource } from "util/bignum";
import Decimal from "util/bignum";
import { ProxyState } from "util/proxies";
import type { Ref } from "vue";
import { isReactive, isRef, ref } from "vue";
import type { Ref, WritableComputedRef } from "vue";
import { computed, isReactive, isRef, ref } from "vue";
/**
* A symbol used in {@link Persistent} objects.
@ -28,6 +29,16 @@ export const StackTrace = Symbol("StackTrace");
* @see {@link Persistent[Deleted]}
*/
export const Deleted = Symbol("Deleted");
/**
* A symbol used in {@link Persistent} objects.
* @see {@link Persistent[NonPersistent]}
*/
export const NonPersistent = Symbol("NonPersistent");
/**
* A symbol used in {@link Persistent} objects.
* @see {@link Persistent[SaveDataPath]}
*/
export const SaveDataPath = Symbol("SaveDataPath");
/**
* This is a union of things that should be safely stringifiable without needing special processes or knowing what to load them in as.
@ -57,6 +68,14 @@ export type Persistent<T extends State = State> = Ref<T> & {
* @see {@link deletePersistent} for marking a persistent ref as deleted.
*/
[Deleted]: boolean;
/**
* A non-persistent ref that just reads and writes ot the persistent ref. Used for passing to other features without duplicating the persistent ref in the constructed save data object.
*/
[NonPersistent]: WritableComputedRef<T>;
/**
* The path this persistent appears in within the save data object. Predominantly used to ensure it's only placed in there one time.
*/
[SaveDataPath]: string[] | undefined;
};
function getStackTrace() {
@ -83,6 +102,15 @@ export function persistent<T extends State>(defaultValue: T | Ref<T>): Persisten
persistent[DefaultValue] = isRef(defaultValue) ? defaultValue.value : defaultValue;
persistent[StackTrace] = getStackTrace();
persistent[Deleted] = false;
persistent[NonPersistent] = computed({
get() {
return persistent.value;
},
set(value) {
persistent.value = value;
}
});
persistent[SaveDataPath] = undefined;
if (addingLayers.length === 0) {
console.warn(
@ -97,6 +125,25 @@ export function persistent<T extends State>(defaultValue: T | Ref<T>): Persisten
return persistent as Persistent<T>;
}
/**
* Type guard for whether an arbitrary value is a persistent ref
* @param value The value that may or may not be a persistent ref
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function isPersistent(value: any): value is Persistent {
return value && typeof value === "object" && PersistentState in value;
}
/**
* Unwraps the non-persistent ref inside of persistent refs, to be passed to other features without duplicating values in the save data object.
* @param persistent The persistent ref to unwrap
*/
export function noPersist<T extends Persistent<S>, S extends State>(
persistent: T
): T[typeof NonPersistent] {
return persistent[NonPersistent];
}
/**
* Mark a {@link Persistent} as deleted, so it won't be saved and loaded.
* Since persistent refs must be created during a layer's options func, features can not create persistent refs after evaluating their own options funcs.
@ -117,24 +164,40 @@ globalBus.on("addLayer", (layer: GenericLayer, saveData: Record<string, unknown>
const handleObject = (obj: Record<string, unknown>, path: string[] = []): boolean => {
let foundPersistent = false;
Object.keys(obj).forEach(key => {
const value = obj[key];
let value = obj[key];
if (value && typeof value === "object") {
if (PersistentState in value) {
if (ProxyState in value) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value = (value as any)[ProxyState] as object;
}
if (isPersistent(value)) {
foundPersistent = true;
if ((value as Persistent)[Deleted]) {
if (value[Deleted]) {
console.warn(
"Deleted persistent ref present in returned object. Ignoring...",
value,
"\nCreated at:\n" + (value as Persistent)[StackTrace]
"\nCreated at:\n" + value[StackTrace]
);
return;
}
persistentRefs[layer.id].delete(
ProxyState in value
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
((value as any)[ProxyState] as Persistent)
: (value as Persistent)
);
persistentRefs[layer.id].delete(value);
// Handle SaveDataPath
const newPath = [layer.id, ...path, key];
if (
value[SaveDataPath] != undefined &&
JSON.stringify(newPath) !== JSON.stringify(value[SaveDataPath])
) {
console.error(
`Persistent ref is being saved to \`${newPath.join(
"."
)}\` when it's already present at \`${value[SaveDataPath].join(
"."
)}\`. This can cause unexpected behavior when loading saves between updates.`,
value
);
}
value[SaveDataPath] = newPath;
// Construct save path if it doesn't exist
const persistentState = path.reduce<Record<string, unknown>>((acc, curr) => {
@ -147,21 +210,19 @@ globalBus.on("addLayer", (layer: GenericLayer, saveData: Record<string, unknown>
// Cache currently saved value
const savedValue = persistentState[key];
// Add ref to save data
persistentState[key] = (value as Persistent)[PersistentState];
persistentState[key] = value[PersistentState];
// Load previously saved value
if (isReactive(persistentState)) {
if (savedValue != null) {
persistentState[key] = savedValue;
} else {
persistentState[key] = (value as Persistent)[DefaultValue];
persistentState[key] = value[DefaultValue];
}
} else {
if (savedValue != null) {
(persistentState[key] as Ref<unknown>).value = savedValue;
} else {
(persistentState[key] as Ref<unknown>).value = (value as Persistent)[
DefaultValue
];
(persistentState[key] as Ref<unknown>).value = value[DefaultValue];
}
}
} else if (
@ -200,7 +261,8 @@ globalBus.on("addLayer", (layer: GenericLayer, saveData: Record<string, unknown>
});
return foundPersistent;
};
handleObject(layer);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
handleObject((layer as any)[ProxyState]);
persistentRefs[layer.id].forEach(persistent => {
console.error(
`Created persistent ref in ${layer.id} without registering it to the layer! Make sure to include everything persistent in the returned object`,

View file

@ -36,7 +36,8 @@ requestAnimationFrame(async () => {
"padding: 4px;"
);
await load();
const { globalBus, startGameLoop } = await import("./game/events");
const { globalBus } = await import("./game/events");
const { startGameLoop } = await import("./game/gameLoop");
// Create Vue
const vue = (window.vue = createApp(App));

View file

@ -1,3 +1,4 @@
import { NonPersistent } from "game/persistence";
import Decimal from "util/bignum";
export const ProxyState = Symbol("ProxyState");
@ -37,7 +38,11 @@ export function createLazyProxy<T extends object, S extends T>(
return calculateObj();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (calculateObj() as any)[key];
const val = (calculateObj() as any)[key];
if (val && typeof val === "object" && NonPersistent in val) {
return val[NonPersistent];
}
return val;
},
set(target, key, value) {
// TODO give warning about this? It should only be done with caution