Fix NaN detection

Also removes the proxy around player and cleaned up types
This commit is contained in:
thepaperpilot 2022-12-28 09:03:51 -06:00
parent 8c8f7f7904
commit f5a25b2c2d
9 changed files with 130 additions and 158 deletions

View file

@ -14,9 +14,12 @@
</div> </div>
<br /> <br />
<div> <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> <span class="material-icons nan-modal-discord">discord</span>
{{ discordName }} {{ discordName || "The Paper Pilot Community" }}
</a> </a>
</div> </div>
<br /> <br />
@ -50,49 +53,51 @@ import state from "game/state";
import type { DecimalSource } from "util/bignum"; import type { DecimalSource } from "util/bignum";
import Decimal, { format } from "util/bignum"; import Decimal, { format } from "util/bignum";
import type { ComponentPublicInstance } from "vue"; 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 Toggle from "./fields/Toggle.vue";
import SavesManager from "./SavesManager.vue"; import SavesManager from "./SavesManager.vue";
const { discordName, discordLink } = projInfo; const { discordName, discordLink } = projInfo;
const autosave = toRef(player, "autosave"); const autosave = ref(true);
const isPaused = ref(true);
const hasNaN = toRef(state, "hasNaN"); const hasNaN = toRef(state, "hasNaN");
const savesManager = ref<ComponentPublicInstance<typeof SavesManager> | null>(null); const savesManager = ref<ComponentPublicInstance<typeof SavesManager> | null>(null);
const path = computed(() => state.NaNPath?.join(".")); watch(hasNaN, hasNaN => {
const property = computed(() => state.NaNPath?.slice(-1)[0]); if (hasNaN) {
const previous = computed<DecimalSource | null>(() => { autosave.value = player.autosave;
if (state.NaNReceiver && property.value != null) { isPaused.value = player.devSpeed === 0;
return state.NaNReceiver[property.value] as DecimalSource; } else {
} player.autosave = autosave.value;
return null; player.devSpeed = isPaused.value ? 0 : null;
});
const isPaused = computed({
get() {
return player.devSpeed === 0;
},
set(value: boolean) {
player.devSpeed = value ? null : 0;
} }
}); });
const path = computed(() => state.NaNPath?.join("."));
const previous = computed<DecimalSource | null>(() => {
if (state.NaNPersistent != null) {
return state.NaNPersistent.value;
}
return null;
});
function setZero() { function setZero() {
if (state.NaNReceiver && property.value != null) { if (state.NaNPersistent != null) {
state.NaNReceiver[property.value] = new Decimal(0); state.NaNPersistent.value = new Decimal(0);
state.hasNaN = false; state.hasNaN = false;
} }
} }
function setOne() { function setOne() {
if (state.NaNReceiver && property.value != null) { if (state.NaNPersistent) {
state.NaNReceiver[property.value] = new Decimal(1); state.NaNPersistent.value = new Decimal(1);
state.hasNaN = false; state.hasNaN = false;
} }
} }
function ignore() { function ignore() {
if (state.NaNReceiver && property.value != null) { if (state.NaNPersistent) {
state.NaNReceiver[property.value] = new Decimal(NaN); state.NaNPersistent.value = new Decimal(NaN);
state.hasNaN = false; state.hasNaN = false;
} }
} }

View file

@ -59,11 +59,10 @@
<script setup lang="ts"> <script setup lang="ts">
import Modal from "components/Modal.vue"; import Modal from "components/Modal.vue";
import projInfo from "data/projInfo.json"; 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 player, { stringifySave } from "game/player";
import settings from "game/settings"; import settings from "game/settings";
import LZString from "lz-string"; import LZString from "lz-string";
import { ProxyState } from "util/proxies";
import { getUniqueID, loadSave, newSave, save } from "util/save"; import { getUniqueID, loadSave, newSave, save } from "util/save";
import type { ComponentPublicInstance } from "vue"; import type { ComponentPublicInstance } from "vue";
import { computed, nextTick, ref, shallowReactive, watch } 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 Text from "./fields/Text.vue";
import Save from "./Save.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 isOpen = ref(false);
const modal = ref<ComponentPublicInstance<typeof Modal> | null>(null); const modal = ref<ComponentPublicInstance<typeof Modal> | null>(null);
@ -195,7 +194,7 @@ const saves = computed(() =>
function exportSave(id: string) { function exportSave(id: string) {
let saveToExport; let saveToExport;
if (player.id === id) { if (player.id === id) {
saveToExport = stringifySave(player[ProxyState]); saveToExport = stringifySave(player);
} else { } else {
saveToExport = JSON.stringify(saves.value[id]); saveToExport = JSON.stringify(saves.value[id]);
} }
@ -228,7 +227,7 @@ function duplicateSave(id: string) {
} }
const playerData = { ...saves.value[id], id: getUniqueID() }; const playerData = { ...saves.value[id], id: getUniqueID() };
save(playerData as PlayerData); save(playerData as Player);
settings.saves.push(playerData.id); settings.saves.push(playerData.id);
} }
@ -272,7 +271,7 @@ function newFromPreset(preset: string) {
} }
const playerData = JSON.parse(preset); const playerData = JSON.parse(preset);
playerData.id = getUniqueID(); playerData.id = getUniqueID();
save(playerData as PlayerData); save(playerData as Player);
settings.saves.push(playerData.id); settings.saves.push(playerData.id);
@ -287,7 +286,7 @@ function editSave(id: string, newName: string) {
player.name = newName; player.name = newName;
save(); save();
} else { } else {
save(currSave as PlayerData); save(currSave as Player);
cachedSaves[id] = undefined; cachedSaves[id] = undefined;
} }
} }

View file

@ -6,7 +6,7 @@ import { branchedResetPropagation, createTree } from "features/trees/tree";
import { globalBus } from "game/events"; import { globalBus } from "game/events";
import type { BaseLayer, GenericLayer } from "game/layers"; import type { BaseLayer, GenericLayer } from "game/layers";
import { createLayer } 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 player from "game/player";
import type { DecimalSource } from "util/bignum"; import type { DecimalSource } from "util/bignum";
import Decimal, { format, formatTime } 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 = ( export const getInitialLayers = (
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */ /* eslint-disable-next-line @typescript-eslint/no-unused-vars */
player: Partial<PlayerData> player: Partial<Player>
): Array<GenericLayer> => [main, prestige]; ): Array<GenericLayer> => [main, prestige];
/** /**
@ -97,7 +97,7 @@ export const hasWon = computed(() => {
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
export function fixOldSave( export function fixOldSave(
oldVersion: string | undefined, oldVersion: string | undefined,
player: Partial<PlayerData> player: Partial<Player>
// eslint-disable-next-line @typescript-eslint/no-empty-function // eslint-disable-next-line @typescript-eslint/no-empty-function
): void {} ): void {}
/* eslint-enable @typescript-eslint/no-unused-vars */ /* eslint-enable @typescript-eslint/no-unused-vars */

View file

@ -7,6 +7,8 @@ import Decimal from "util/bignum";
import { ProxyState } from "util/proxies"; import { ProxyState } from "util/proxies";
import type { Ref, WritableComputedRef } from "vue"; import type { Ref, WritableComputedRef } from "vue";
import { computed, isReactive, isRef, ref } from "vue"; import { computed, isReactive, isRef, ref } from "vue";
import player from "./player";
import state from "./state";
/** /**
* A symbol used in {@link Persistent} objects. * 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. * 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> & { export type Persistent<T extends State = State> = Ref<T> & {
value: T;
/** A flag that this is a persistent property. Typically a circular reference. */ /** A flag that this is a persistent property. Typically a circular reference. */
[PersistentState]: Ref<T>; [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. */ /** 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. * 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. * 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. * @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> { export function persistent<T extends State>(defaultValue: T | Ref<T>): Persistent<T> {
const persistent = ( const persistentState: Ref<T> = isRef(defaultValue)
isRef(defaultValue) ? defaultValue : (ref<T>(defaultValue) as unknown) ? defaultValue
) as Persistent<T>; : (ref<T>(defaultValue) as Ref<T>);
persistent[PersistentState] = persistent; if (isRef(defaultValue)) {
persistent[DefaultValue] = isRef(defaultValue) ? defaultValue.value : defaultValue; defaultValue = defaultValue.value;
persistent[StackTrace] = getStackTrace(); }
persistent[Deleted] = false;
const nonPersistent: Partial<NonPersistent<T>> = computed({ const nonPersistent = computed({
get() { get() {
return persistent.value; return persistentState.value;
}, },
set(value) { set(value) {
persistent.value = value; checkNaNAndWrite(persistent, value);
} }
}); }) as NonPersistent<T>;
nonPersistent[DefaultValue] = persistent[DefaultValue]; nonPersistent[DefaultValue] = defaultValue;
persistent[NonPersistent] = nonPersistent as NonPersistent<T>;
persistent[SaveDataPath] = undefined; // 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) { if (addingLayers.length === 0) {
console.warn( console.warn(
@ -125,7 +162,7 @@ export function persistent<T extends State>(defaultValue: T | Ref<T>): Persisten
persistentRefs[addingLayers[addingLayers.length - 1]].add(persistent); persistentRefs[addingLayers[addingLayers.length - 1]].add(persistent);
} }
return persistent as Persistent<T>; return persistent;
} }
/** /**

View file

@ -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 type { Ref } from "vue";
import transientState from "./state"; import { reactive, unref } from "vue";
/** The player save data object. */ /** The player save data object. */
export interface PlayerData { export interface Player {
/** The ID of this save. */ /** The ID of this save. */
id: string; id: string;
/** A multiplier for time passing. Set to 0 when the game is paused. */ /** 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>>; 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. */ /** A layer's save data. Automatically unwraps refs. */
export type LayerData<T> = { export type LayerData<T> = {
[P in keyof T]?: T[P] extends (infer U)[] [P in keyof T]?: T[P] extends (infer U)[]
@ -52,7 +44,7 @@ export type LayerData<T> = {
: T[P]; : T[P];
}; };
const state = reactive<PlayerData>({ const player = reactive<Player>({
id: "", id: "",
devSpeed: null, devSpeed: null,
name: "", name: "",
@ -68,90 +60,16 @@ const state = reactive<PlayerData>({
layers: {} layers: {}
}); });
export default window.player = player;
/** Convert a player save data object into a JSON string. Unwraps refs. */ /** 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)); 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 { declare global {
/** Augment the window object so the player can be accessed from the console. */ /** Augment the window object so the player can be accessed from the console. */
interface Window { interface Window {
player: Player; player: Player;
} }
} }
/** The player save data object. */
export default window.player = new Proxy(
{ [ProxyState]: state, [ProxyPath]: ["player"] },
playerHandler
) as Player;

View file

@ -1,4 +1,6 @@
import type { DecimalSource } from "util/bignum";
import { shallowReactive } from "vue"; import { shallowReactive } from "vue";
import type { Persistent } from "./persistence";
/** An object of global data that is not persistent. */ /** An object of global data that is not persistent. */
export interface Transient { export interface Transient {
@ -8,8 +10,8 @@ export interface Transient {
hasNaN: boolean; hasNaN: boolean;
/** The location within the player save data object of the NaN value. */ /** The location within the player save data object of the NaN value. */
NaNPath?: string[]; NaNPath?: string[];
/** The parent object of the NaN value. */ /** The ref that was being set to NaN. */
NaNReceiver?: Record<string, unknown>; NaNPersistent?: Persistent<DecimalSource>;
} }
declare global { declare global {

View file

@ -1,6 +1,7 @@
import type { JSXFunction } from "features/feature";
import { isFunction } from "util/common";
import type { Ref } from "vue"; import type { Ref } from "vue";
import { computed } from "vue"; import { computed } from "vue";
import { isFunction } from "util/common";
export const DoNotCache = Symbol("DoNotCache"); export const DoNotCache = Symbol("DoNotCache");
@ -32,21 +33,22 @@ export function processComputable<T, S extends keyof ComputableKeysOf<T>>(
key: S key: S
): asserts obj is T & { [K in S]: ProcessedComputable<UnwrapComputableType<T[S]>> } { ): asserts obj is T & { [K in S]: ProcessedComputable<UnwrapComputableType<T[S]>> } {
const computable = obj[key]; const computable = obj[key];
// eslint-disable-next-line @typescript-eslint/no-explicit-any if (
if (isFunction(computable) && computable.length === 0 && !(computable as any)[DoNotCache]) { isFunction(computable) &&
computable.length === 0 &&
!(computable as unknown as JSXFunction)[DoNotCache]
) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
obj[key] = computed(computable.bind(obj)); obj[key] = computed(computable.bind(obj));
} else if (isFunction(computable)) { } else if (isFunction(computable)) {
obj[key] = computable.bind(obj) as unknown as T[S]; obj[key] = computable.bind(obj) as unknown as T[S];
// eslint-disable-next-line @typescript-eslint/no-explicit-any (obj[key] as unknown as JSXFunction)[DoNotCache] = true;
(obj[key] as any)[DoNotCache] = true;
} }
} }
export function convertComputable<T>(obj: Computable<T>): ProcessedComputable<T> { export function convertComputable<T>(obj: Computable<T>): ProcessedComputable<T> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any if (isFunction(obj) && !(obj as unknown as JSXFunction)[DoNotCache]) {
if (isFunction(obj) && !(obj as any)[DoNotCache]) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
obj = computed(obj); obj = computed(obj);

View file

@ -1,11 +1,11 @@
import type { Persistent } from "game/persistence";
import { NonPersistent } from "game/persistence"; import { NonPersistent } from "game/persistence";
import Decimal from "util/bignum"; import Decimal from "util/bignum";
export const ProxyState = Symbol("ProxyState"); export const ProxyState = Symbol("ProxyState");
export const ProxyPath = Symbol("ProxyPath"); export const ProxyPath = Symbol("ProxyPath");
// eslint-disable-next-line @typescript-eslint/no-explicit-any export type ProxiedWithState<T> = NonNullable<T> extends Record<PropertyKey, unknown>
export type ProxiedWithState<T> = NonNullable<T> extends Record<PropertyKey, any>
? NonNullable<T> extends Decimal ? NonNullable<T> extends Decimal
? T ? T
: { : {
@ -16,6 +16,18 @@ export type ProxiedWithState<T> = NonNullable<T> extends Record<PropertyKey, any
} }
: T; : 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 // Takes a function that returns an object and pretends to be that object
// Note that the object is lazily calculated // Note that the object is lazily calculated
export function createLazyProxy<T extends object, S extends T>( export function createLazyProxy<T extends object, S extends T>(

View file

@ -1,13 +1,12 @@
import projInfo from "data/projInfo.json"; import projInfo from "data/projInfo.json";
import { globalBus } from "game/events"; 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 player, { stringifySave } from "game/player";
import settings, { loadSettings } from "game/settings"; import settings, { loadSettings } from "game/settings";
import LZString from "lz-string"; import LZString from "lz-string";
import { ProxyState } from "util/proxies";
import { ref } from "vue"; import { ref } from "vue";
export function setupInitialStore(player: Partial<PlayerData> = {}): Player { export function setupInitialStore(player: Partial<Player> = {}): Player {
return Object.assign( return Object.assign(
{ {
id: `${projInfo.id}-0`, id: `${projInfo.id}-0`,
@ -27,11 +26,9 @@ export function setupInitialStore(player: Partial<PlayerData> = {}): Player {
) as Player; ) as Player;
} }
export function save(playerData?: PlayerData): string { export function save(playerData?: Player): string {
const stringifiedSave = LZString.compressToUTF16( const stringifiedSave = LZString.compressToUTF16(stringifySave(playerData ?? player));
stringifySave(playerData ?? player[ProxyState]) localStorage.setItem((playerData ?? player).id, stringifiedSave);
);
localStorage.setItem((playerData ?? player[ProxyState]).id, stringifiedSave);
return 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 id = getUniqueID();
const player = setupInitialStore({ id }); const player = setupInitialStore({ id });
save(player); save(player);
@ -91,7 +88,7 @@ export function getUniqueID(): string {
export const loadingSave = ref(false); 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); console.info("Loading save", playerObj);
loadingSave.value = true; loadingSave.value = true;
const { layers, removeLayer, addLayer } = await import("game/layers"); const { layers, removeLayer, addLayer } = await import("game/layers");