import { isArray } from "@vue/shared"; import { globalBus } from "game/events"; import type { GenericLayer } from "game/layers"; 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, WritableComputedRef } from "vue"; import { computed, isReactive, isRef, ref } from "vue"; import player from "./player"; import state from "./state"; /** * A symbol used in {@link Persistent} objects. * @see {@link Persistent[PersistentState]} */ export const PersistentState = Symbol("PersistentState"); /** * A symbol used in {@link Persistent} objects. * @see {@link Persistent[DefaultValue]} */ export const DefaultValue = Symbol("DefaultValue"); /** * A symbol used in {@link Persistent} objects. * @see {@link Persistent[StackTrace]} */ export const StackTrace = Symbol("StackTrace"); /** * A symbol used in {@link Persistent} objects. * @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. * - Decimals aren't allowed because we'd need to know to parse them back. * - DecimalSources are allowed because the string is a valid value for them */ export type State = | string | number | boolean | DecimalSource | { [key: string]: State } | { [key: number]: State }; /** * A {@link Ref} that has been augmented with properties to allow it to be saved and loaded within the player save data object. */ export type Persistent = Ref & { value: T; /** A flag that this is a persistent property. Typically a circular reference. */ [PersistentState]: Ref; /** The value the ref should be set to in a fresh save, or when updating an old save to the current version. */ [DefaultValue]: T; /** The stack trace of where the persistent ref was created. This is used for debugging purposes when a persistent ref is created but not placed in its layer object. */ [StackTrace]: string; /** * This is a flag that can be set once the option func is evaluated, to mark that a persistent ref should _not_ be saved to the player save data object. * @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]: NonPersistent; /** * 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; }; export type NonPersistent = WritableComputedRef & { [DefaultValue]: T }; function getStackTrace() { return ( new Error().stack ?.split("\n") .slice(3, 5) .map(line => line.trim()) .join("\n") ?? "" ); } function checkNaNAndWrite(persistent: Persistent, value: T) { // Decimal is smart enough to return false on things that aren't supposed to be numbers if (Decimal.isNaN(value as DecimalSource)) { if (!state.hasNaN) { player.autosave = false; state.hasNaN = true; state.NaNPath = persistent[SaveDataPath]; state.NaNPersistent = persistent as Persistent; } console.error( `Attempted to save NaN value to`, persistent[SaveDataPath]?.join("."), persistent ); throw "Attempted to set NaN value. See above for details"; } persistent[PersistentState].value = value; } /** * Create a persistent ref, which can be saved and loaded. * All (non-deleted) persistent refs must be included somewhere within the layer object returned by that layer's options func. * @param defaultValue The value the persistent ref should start at on fresh saves or when reset. */ export function persistent(defaultValue: T | Ref): Persistent { const persistentState: Ref = isRef(defaultValue) ? defaultValue : (ref(defaultValue) as Ref); if (isRef(defaultValue)) { defaultValue = defaultValue.value; } const nonPersistent = computed({ get() { return persistentState.value; }, set(value) { checkNaNAndWrite(persistent, value); } }) as NonPersistent; nonPersistent[DefaultValue] = defaultValue; // We're trying to mock a vue ref, which means the type expects a private [RefSymbol] property that we can't access, but the actual implementation of isRef just checks for `__v_isRef` const persistent = { get value() { return persistentState.value as T; }, set value(value: T) { checkNaNAndWrite(persistent, value); }, __v_isRef: true, [PersistentState]: persistentState, [DefaultValue]: defaultValue, [StackTrace]: getStackTrace(), [Deleted]: false, [NonPersistent]: nonPersistent, [SaveDataPath]: undefined } as unknown as Persistent; if (addingLayers.length === 0) { console.warn( "Creating a persistent ref outside of a layer. This is not officially supported", persistent, "\nCreated at:\n" + persistent[StackTrace] ); } else { persistentRefs[addingLayers[addingLayers.length - 1]].add(persistent); } return persistent; } /** * Type guard for whether an arbitrary value is a persistent ref * @param value The value that may or may not be a persistent ref */ export function isPersistent(value: unknown): value is Persistent { return value != null && 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, 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. * As a result, it must create any persistent refs it _might_ need. * This function can then be called after the options func is evaluated to mark the persistent ref to not be saved or loaded. */ export function deletePersistent(persistent: Persistent) { if (addingLayers.length === 0) { console.warn("Deleting a persistent ref outside of a layer. Ignoring...", persistent); } else { persistentRefs[addingLayers[addingLayers.length - 1]].delete(persistent); } persistent[Deleted] = true; } globalBus.on("addLayer", (layer: GenericLayer, saveData: Record) => { const features: { type: typeof Symbol }[] = []; const handleObject = (obj: Record, path: string[] = []): boolean => { let foundPersistent = false; Object.keys(obj).forEach(key => { let value = obj[key]; if (value != null && typeof value === "object") { 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[Deleted]) { console.warn( "Deleted persistent ref present in returned object. Ignoring...", value, "\nCreated at:\n" + value[StackTrace] ); return; } 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>((acc, curr) => { if (!(curr in acc)) { acc[curr] = {}; } return acc[curr] as Record; }, saveData); // Cache currently saved value const savedValue = persistentState[key]; // Add ref to save data persistentState[key] = value[PersistentState]; // Load previously saved value if (isReactive(persistentState)) { if (savedValue != null) { persistentState[key] = savedValue; } else { persistentState[key] = value[DefaultValue]; } } else { if (savedValue != null) { (persistentState[key] as Ref).value = savedValue; } else { (persistentState[key] as Ref).value = value[DefaultValue]; } } } else if ( !(value instanceof Decimal) && !isRef(value) && // eslint-disable-next-line @typescript-eslint/no-explicit-any !features.includes(value as { type: typeof Symbol }) ) { if (typeof (value as { type: typeof Symbol }).type === "symbol") { features.push(value as { type: typeof Symbol }); } // Continue traversing const foundPersistentInChild = handleObject(value as Record, [ ...path, key ]); // Show warning for persistent values inside arrays // TODO handle arrays better if (foundPersistentInChild) { if (isArray(value) && !isArray(obj)) { console.warn( "Found array that contains persistent values when adding layer. Keep in mind changing the order of elements in the array will mess with existing player saves.", ProxyState in obj ? (obj as Record)[ProxyState] : obj, key ); } else { foundPersistent = true; } } } } }); return foundPersistent; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any handleObject((layer as any)[ProxyState]); persistentRefs[layer.id].forEach(persistent => { if (persistent[Deleted]) { return; } console.error( `Created persistent ref in ${layer.id} without registering it to the layer! Make sure to include everything persistent in the returned object`, persistent, "\nCreated at:\n" + persistent[StackTrace] ); }); persistentRefs[layer.id].clear(); });