diff --git a/src/components/NaNScreen.vue b/src/components/NaNScreen.vue index 76a7b75..511d1b6 100644 --- a/src/components/NaNScreen.vue +++ b/src/components/NaNScreen.vue @@ -14,9 +14,12 @@ </div> <br /> <div> - <a :href="discordLink" class="nan-modal-discord-link"> + <a + :href="discordLink || 'https://discord.gg/WzejVAx'" + class="nan-modal-discord-link" + > <span class="material-icons nan-modal-discord">discord</span> - {{ discordName }} + {{ discordName || "The Paper Pilot Community" }} </a> </div> <br /> @@ -50,49 +53,51 @@ import state from "game/state"; import type { DecimalSource } from "util/bignum"; import Decimal, { format } from "util/bignum"; import type { ComponentPublicInstance } from "vue"; -import { computed, ref, toRef } from "vue"; +import { computed, ref, toRef, watch } from "vue"; import Toggle from "./fields/Toggle.vue"; import SavesManager from "./SavesManager.vue"; const { discordName, discordLink } = projInfo; -const autosave = toRef(player, "autosave"); +const autosave = ref(true); +const isPaused = ref(true); const hasNaN = toRef(state, "hasNaN"); const savesManager = ref<ComponentPublicInstance<typeof SavesManager> | null>(null); -const path = computed(() => state.NaNPath?.join(".")); -const property = computed(() => state.NaNPath?.slice(-1)[0]); -const previous = computed<DecimalSource | null>(() => { - if (state.NaNReceiver && property.value != null) { - return state.NaNReceiver[property.value] as DecimalSource; - } - return null; -}); -const isPaused = computed({ - get() { - return player.devSpeed === 0; - }, - set(value: boolean) { - player.devSpeed = value ? null : 0; +watch(hasNaN, hasNaN => { + if (hasNaN) { + autosave.value = player.autosave; + isPaused.value = player.devSpeed === 0; + } else { + player.autosave = autosave.value; + player.devSpeed = isPaused.value ? 0 : null; } }); +const path = computed(() => state.NaNPath?.join(".")); +const previous = computed<DecimalSource | null>(() => { + if (state.NaNPersistent != null) { + return state.NaNPersistent.value; + } + return null; +}); + function setZero() { - if (state.NaNReceiver && property.value != null) { - state.NaNReceiver[property.value] = new Decimal(0); + if (state.NaNPersistent != null) { + state.NaNPersistent.value = new Decimal(0); state.hasNaN = false; } } function setOne() { - if (state.NaNReceiver && property.value != null) { - state.NaNReceiver[property.value] = new Decimal(1); + if (state.NaNPersistent) { + state.NaNPersistent.value = new Decimal(1); state.hasNaN = false; } } function ignore() { - if (state.NaNReceiver && property.value != null) { - state.NaNReceiver[property.value] = new Decimal(NaN); + if (state.NaNPersistent) { + state.NaNPersistent.value = new Decimal(NaN); state.hasNaN = false; } } diff --git a/src/components/SavesManager.vue b/src/components/SavesManager.vue index 37b4d5b..91edd40 100644 --- a/src/components/SavesManager.vue +++ b/src/components/SavesManager.vue @@ -59,11 +59,10 @@ <script setup lang="ts"> import Modal from "components/Modal.vue"; import projInfo from "data/projInfo.json"; -import type { PlayerData } from "game/player"; +import type { Player } from "game/player"; import player, { stringifySave } from "game/player"; import settings from "game/settings"; import LZString from "lz-string"; -import { ProxyState } from "util/proxies"; import { getUniqueID, loadSave, newSave, save } from "util/save"; import type { ComponentPublicInstance } from "vue"; import { computed, nextTick, ref, shallowReactive, watch } from "vue"; @@ -72,7 +71,7 @@ import Select from "./fields/Select.vue"; import Text from "./fields/Text.vue"; import Save from "./Save.vue"; -export type LoadablePlayerData = Omit<Partial<PlayerData>, "id"> & { id: string; error?: unknown }; +export type LoadablePlayerData = Omit<Partial<Player>, "id"> & { id: string; error?: unknown }; const isOpen = ref(false); const modal = ref<ComponentPublicInstance<typeof Modal> | null>(null); @@ -195,7 +194,7 @@ const saves = computed(() => function exportSave(id: string) { let saveToExport; if (player.id === id) { - saveToExport = stringifySave(player[ProxyState]); + saveToExport = stringifySave(player); } else { saveToExport = JSON.stringify(saves.value[id]); } @@ -228,7 +227,7 @@ function duplicateSave(id: string) { } const playerData = { ...saves.value[id], id: getUniqueID() }; - save(playerData as PlayerData); + save(playerData as Player); settings.saves.push(playerData.id); } @@ -272,7 +271,7 @@ function newFromPreset(preset: string) { } const playerData = JSON.parse(preset); playerData.id = getUniqueID(); - save(playerData as PlayerData); + save(playerData as Player); settings.saves.push(playerData.id); @@ -287,7 +286,7 @@ function editSave(id: string, newName: string) { player.name = newName; save(); } else { - save(currSave as PlayerData); + save(currSave as Player); cachedSaves[id] = undefined; } } diff --git a/src/data/projEntry.tsx b/src/data/projEntry.tsx index f930d3a..e4640b6 100644 --- a/src/data/projEntry.tsx +++ b/src/data/projEntry.tsx @@ -6,7 +6,7 @@ import { branchedResetPropagation, createTree } from "features/trees/tree"; import { globalBus } from "game/events"; import type { BaseLayer, GenericLayer } from "game/layers"; import { createLayer } from "game/layers"; -import type { PlayerData } from "game/player"; +import type { Player } from "game/player"; import player from "game/player"; import type { DecimalSource } from "util/bignum"; import Decimal, { format, formatTime } from "util/bignum"; @@ -79,7 +79,7 @@ export const main = createLayer("main", function (this: BaseLayer) { */ export const getInitialLayers = ( /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ - player: Partial<PlayerData> + player: Partial<Player> ): Array<GenericLayer> => [main, prestige]; /** @@ -97,7 +97,7 @@ export const hasWon = computed(() => { /* eslint-disable @typescript-eslint/no-unused-vars */ export function fixOldSave( oldVersion: string | undefined, - player: Partial<PlayerData> + player: Partial<Player> // eslint-disable-next-line @typescript-eslint/no-empty-function ): void {} /* eslint-enable @typescript-eslint/no-unused-vars */ diff --git a/src/game/persistence.ts b/src/game/persistence.ts index 01e4c96..9ad0634 100644 --- a/src/game/persistence.ts +++ b/src/game/persistence.ts @@ -7,6 +7,8 @@ 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. @@ -56,6 +58,7 @@ export type 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<T extends State = State> = Ref<T> & { + value: T; /** A flag that this is a persistent property. Typically a circular reference. */ [PersistentState]: Ref<T>; /** The value the ref should be set to in a fresh save, or when updating an old save to the current version. */ @@ -89,31 +92,65 @@ function getStackTrace() { ); } +function checkNaNAndWrite<T extends State>(persistent: Persistent<T>, 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<DecimalSource>; + } + 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<T extends State>(defaultValue: T | Ref<T>): Persistent<T> { - const persistent = ( - isRef(defaultValue) ? defaultValue : (ref<T>(defaultValue) as unknown) - ) as Persistent<T>; + const persistentState: Ref<T> = isRef(defaultValue) + ? defaultValue + : (ref<T>(defaultValue) as Ref<T>); - persistent[PersistentState] = persistent; - persistent[DefaultValue] = isRef(defaultValue) ? defaultValue.value : defaultValue; - persistent[StackTrace] = getStackTrace(); - persistent[Deleted] = false; - const nonPersistent: Partial<NonPersistent<T>> = computed({ + if (isRef(defaultValue)) { + defaultValue = defaultValue.value; + } + + const nonPersistent = computed({ get() { - return persistent.value; + return persistentState.value; }, set(value) { - persistent.value = value; + checkNaNAndWrite(persistent, value); } - }); - nonPersistent[DefaultValue] = persistent[DefaultValue]; - persistent[NonPersistent] = nonPersistent as NonPersistent<T>; - persistent[SaveDataPath] = undefined; + }) as NonPersistent<T>; + 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<T>; if (addingLayers.length === 0) { console.warn( @@ -125,7 +162,7 @@ export function persistent<T extends State>(defaultValue: T | Ref<T>): Persisten persistentRefs[addingLayers[addingLayers.length - 1]].add(persistent); } - return persistent as Persistent<T>; + return persistent; } /** diff --git a/src/game/player.ts b/src/game/player.ts index 6271629..5d1142e 100644 --- a/src/game/player.ts +++ b/src/game/player.ts @@ -1,13 +1,8 @@ -import { isPlainObject } from "is-plain-object"; -import Decimal from "util/bignum"; -import type { ProxiedWithState } from "util/proxies"; -import { ProxyPath, ProxyState } from "util/proxies"; -import { reactive, unref } from "vue"; import type { Ref } from "vue"; -import transientState from "./state"; +import { reactive, unref } from "vue"; /** The player save data object. */ -export interface PlayerData { +export interface Player { /** The ID of this save. */ id: string; /** A multiplier for time passing. Set to 0 when the game is paused. */ @@ -36,9 +31,6 @@ export interface PlayerData { layers: Record<string, LayerData<unknown>>; } -/** The proxied player that is used to track NaN values. */ -export type Player = ProxiedWithState<PlayerData>; - /** A layer's save data. Automatically unwraps refs. */ export type LayerData<T> = { [P in keyof T]?: T[P] extends (infer U)[] @@ -52,7 +44,7 @@ export type LayerData<T> = { : T[P]; }; -const state = reactive<PlayerData>({ +const player = reactive<Player>({ id: "", devSpeed: null, name: "", @@ -68,90 +60,16 @@ const state = reactive<PlayerData>({ layers: {} }); +export default window.player = player; + /** Convert a player save data object into a JSON string. Unwraps refs. */ -export function stringifySave(player: PlayerData): string { +export function stringifySave(player: Player): string { return JSON.stringify(player, (key, value) => unref(value)); } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const playerHandler: ProxyHandler<Record<PropertyKey, any>> = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - get(target: Record<PropertyKey, any>, key: PropertyKey): any { - if (key === ProxyState || key === ProxyPath) { - return target[key]; - } - - const value = target[ProxyState][key]; - if (key !== "value" && (isPlainObject(value) || Array.isArray(value))) { - if (value !== target[key]?.[ProxyState]) { - const path = [...target[ProxyPath], key]; - target[key] = new Proxy({ [ProxyState]: value, [ProxyPath]: path }, playerHandler); - } - return target[key]; - } - - return value; - }, - set( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - target: Record<PropertyKey, any>, - property: PropertyKey, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - value: any, - receiver: ProxyConstructor - ): boolean { - if ( - !transientState.hasNaN && - ((typeof value === "number" && isNaN(value)) || - (value instanceof Decimal && - (isNaN(value.sign) || isNaN(value.layer) || isNaN(value.mag)))) - ) { - const currentValue = target[ProxyState][property]; - if ( - !( - (typeof currentValue === "number" && isNaN(currentValue)) || - (currentValue instanceof Decimal && - (isNaN(currentValue.sign) || - isNaN(currentValue.layer) || - isNaN(currentValue.mag))) - ) - ) { - state.autosave = false; - transientState.hasNaN = true; - transientState.NaNPath = [...target[ProxyPath], property]; - transientState.NaNReceiver = receiver as unknown as Record<string, unknown>; - console.error( - `Attempted to set NaN value`, - [...target[ProxyPath], property], - target[ProxyState] - ); - throw "Attempted to set NaN value. See above for details"; - } - } - target[ProxyState][property] = value; - return true; - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ownKeys(target: Record<PropertyKey, any>) { - return Reflect.ownKeys(target[ProxyState]); - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - has(target: Record<PropertyKey, any>, key: string) { - return Reflect.has(target[ProxyState], key); - }, - getOwnPropertyDescriptor(target, key) { - return Object.getOwnPropertyDescriptor(target[ProxyState], key); - } -}; - declare global { /** Augment the window object so the player can be accessed from the console. */ interface Window { player: Player; } } -/** The player save data object. */ -export default window.player = new Proxy( - { [ProxyState]: state, [ProxyPath]: ["player"] }, - playerHandler -) as Player; diff --git a/src/game/state.ts b/src/game/state.ts index bda0b6a..db71c7b 100644 --- a/src/game/state.ts +++ b/src/game/state.ts @@ -1,4 +1,6 @@ +import type { DecimalSource } from "util/bignum"; import { shallowReactive } from "vue"; +import type { Persistent } from "./persistence"; /** An object of global data that is not persistent. */ export interface Transient { @@ -8,8 +10,8 @@ export interface Transient { hasNaN: boolean; /** The location within the player save data object of the NaN value. */ NaNPath?: string[]; - /** The parent object of the NaN value. */ - NaNReceiver?: Record<string, unknown>; + /** The ref that was being set to NaN. */ + NaNPersistent?: Persistent<DecimalSource>; } declare global { diff --git a/src/util/computed.ts b/src/util/computed.ts index c940b1a..8a9ad62 100644 --- a/src/util/computed.ts +++ b/src/util/computed.ts @@ -1,6 +1,7 @@ +import type { JSXFunction } from "features/feature"; +import { isFunction } from "util/common"; import type { Ref } from "vue"; import { computed } from "vue"; -import { isFunction } from "util/common"; export const DoNotCache = Symbol("DoNotCache"); @@ -32,21 +33,22 @@ export function processComputable<T, S extends keyof ComputableKeysOf<T>>( key: S ): asserts obj is T & { [K in S]: ProcessedComputable<UnwrapComputableType<T[S]>> } { const computable = obj[key]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if (isFunction(computable) && computable.length === 0 && !(computable as any)[DoNotCache]) { + if ( + isFunction(computable) && + computable.length === 0 && + !(computable as unknown as JSXFunction)[DoNotCache] + ) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore obj[key] = computed(computable.bind(obj)); } else if (isFunction(computable)) { obj[key] = computable.bind(obj) as unknown as T[S]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (obj[key] as any)[DoNotCache] = true; + (obj[key] as unknown as JSXFunction)[DoNotCache] = true; } } export function convertComputable<T>(obj: Computable<T>): ProcessedComputable<T> { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if (isFunction(obj) && !(obj as any)[DoNotCache]) { + if (isFunction(obj) && !(obj as unknown as JSXFunction)[DoNotCache]) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore obj = computed(obj); diff --git a/src/util/proxies.ts b/src/util/proxies.ts index 9dad138..174378b 100644 --- a/src/util/proxies.ts +++ b/src/util/proxies.ts @@ -1,11 +1,11 @@ +import type { Persistent } from "game/persistence"; import { NonPersistent } from "game/persistence"; import Decimal from "util/bignum"; export const ProxyState = Symbol("ProxyState"); export const ProxyPath = Symbol("ProxyPath"); -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type ProxiedWithState<T> = NonNullable<T> extends Record<PropertyKey, any> +export type ProxiedWithState<T> = NonNullable<T> extends Record<PropertyKey, unknown> ? NonNullable<T> extends Decimal ? T : { @@ -16,6 +16,18 @@ export type ProxiedWithState<T> = NonNullable<T> extends Record<PropertyKey, any } : T; +export type Proxied<T> = NonNullable<T> extends Record<PropertyKey, unknown> + ? NonNullable<T> extends Persistent<infer S> + ? NonPersistent<S> + : NonNullable<T> extends Decimal + ? T + : { + [K in keyof T]: Proxied<T[K]>; + } & { + [ProxyState]: T; + } + : T; + // Takes a function that returns an object and pretends to be that object // Note that the object is lazily calculated export function createLazyProxy<T extends object, S extends T>( diff --git a/src/util/save.ts b/src/util/save.ts index e43d34b..2955fd1 100644 --- a/src/util/save.ts +++ b/src/util/save.ts @@ -1,13 +1,12 @@ import projInfo from "data/projInfo.json"; import { globalBus } from "game/events"; -import type { Player, PlayerData } from "game/player"; +import type { Player } from "game/player"; import player, { stringifySave } from "game/player"; import settings, { loadSettings } from "game/settings"; import LZString from "lz-string"; -import { ProxyState } from "util/proxies"; import { ref } from "vue"; -export function setupInitialStore(player: Partial<PlayerData> = {}): Player { +export function setupInitialStore(player: Partial<Player> = {}): Player { return Object.assign( { id: `${projInfo.id}-0`, @@ -27,11 +26,9 @@ export function setupInitialStore(player: Partial<PlayerData> = {}): Player { ) as Player; } -export function save(playerData?: PlayerData): string { - const stringifiedSave = LZString.compressToUTF16( - stringifySave(playerData ?? player[ProxyState]) - ); - localStorage.setItem((playerData ?? player[ProxyState]).id, stringifiedSave); +export function save(playerData?: Player): string { + const stringifiedSave = LZString.compressToUTF16(stringifySave(playerData ?? player)); + localStorage.setItem((playerData ?? player).id, stringifiedSave); return stringifiedSave; } @@ -70,7 +67,7 @@ export async function load(): Promise<void> { } } -export function newSave(): PlayerData { +export function newSave(): Player { const id = getUniqueID(); const player = setupInitialStore({ id }); save(player); @@ -91,7 +88,7 @@ export function getUniqueID(): string { export const loadingSave = ref(false); -export async function loadSave(playerObj: Partial<PlayerData>): Promise<void> { +export async function loadSave(playerObj: Partial<Player>): Promise<void> { console.info("Loading save", playerObj); loadingSave.value = true; const { layers, removeLayer, addLayer } = await import("game/layers");