isaacscript-common
Version:
Helper functions and features for IsaacScript mods.
746 lines (664 loc) • 23.5 kB
text/typescript
import { DefaultMap } from "../classes/DefaultMap";
import { SAVE_DATA_MANAGER_DEBUG } from "../classes/features/other/saveDataManager/constants";
import { SerializationBrand } from "../enums/private/SerializationBrand";
import { SerializationType } from "../enums/SerializationType";
import { isSerializationBrand } from "../serialization";
import type { AnyClass } from "../types/AnyClass";
import type { TSTLClass } from "../types/TSTLClass";
import { isArray } from "./array";
import { getIsaacAPIClassName } from "./isaacAPIClass";
import { log } from "./log";
import {
copyIsaacAPIClass,
deserializeIsaacAPIClass,
isCopyableIsaacAPIClass,
isSerializedIsaacAPIClass,
serializeIsaacAPIClass,
} from "./serialization";
import { sortTwoDimensionalArray } from "./sort";
import {
getTSTLClassName,
isDefaultMap,
isTSTLMap,
isTSTLSet,
newTSTLClass,
} from "./tstlClass";
import { asString, isNumber, isPrimitive } from "./types";
import { assertDefined, getTraversalDescription } from "./utils";
/**
* `deepCopy` is a semi-generic deep cloner. It will recursively copy all of the values so that none
* of the nested references remain.
*
* `deepCopy` is used by the IsaacScript save data manager to make a backup of your variables, so
* that it can restore them to the default values at the beginning of a new room, floor, or run.
*
* `deepCopy` supports the following object types:
*
* - Primitives (i.e. strings, numbers, and booleans)
* - Basic TSTL objects (which are the same thing as Lua tables)
* - TSTL `Map`
* - TSTL `Set`
* - TSTL classes
* - `DefaultMap`
* - Isaac `BitSet128` objects
* - Isaac `Color` objects
* - Isaac `KColor` objects
* - Isaac `RNG` objects
* - Isaac `Vector` objects
*
* It does not support:
* - objects with values of `null` (since that transpiles to `nil`)
* - other Isaac API objects such as `EntityPtr` (that have a type of "userdata")
*
* @param value The primitive or object to copy.
* @param serializationType Optional. Has 3 possible values. Can copy objects as-is, or can
* serialize objects to Lua tables, or can deserialize Lua tables to
* objects. Default is `SerializationType.NONE`.
* @param traversalDescription Optional. Used to track the current key that we are operating on for
* debugging purposes. Default is an empty string.
* @param classConstructors Optional. A Lua table that maps the name of a user-defined TSTL class to
* its corresponding constructor. If the `deepCopy` function finds any
* user-defined TSTL classes when recursively iterating through the given
* object, it will use this map to instantiate a new class. Default is an
* empty Lua table.
* @param insideMap Optional. Tracks whether the deep copy function is in the process of recursively
* copying a TSTL Map. Default is false.
*/
export function deepCopy<T>(
// An overload describing the trivial case of a normal copy. (T --> T)
value: T,
serializationType?: SerializationType.NONE,
traversalDescription?: string,
classConstructors?: LuaMap<string, AnyClass>,
insideMap?: boolean,
): T;
export function deepCopy(
// An overload describing the serialization/deserialization case.
value: unknown,
serializationType: SerializationType,
traversalDescription?: string,
classConstructors?: LuaMap<string, AnyClass>,
insideMap?: boolean,
): unknown; // The return types for serialization/deserialization are non-trivial, so we do not annotate them.
export function deepCopy(
value: unknown,
serializationType = SerializationType.NONE,
traversalDescription = "",
classConstructors = new LuaMap<string, AnyClass>(),
insideMap = false,
): unknown {
if (SAVE_DATA_MANAGER_DEBUG) {
let logString = `deepCopy is operating on: ${traversalDescription}`;
if (serializationType === SerializationType.SERIALIZE) {
logString += " (serializing)";
} else if (serializationType === SerializationType.DESERIALIZE) {
logString += " (deserializing)";
}
logString += `: ${value}`;
log(logString);
}
const valueType = type(value);
switch (valueType) {
// First, handling the trivial case of primitives.
case "nil":
case "boolean":
case "number":
case "string": {
return value;
}
// Second, handle values that cannot be serialized.
case "function":
case "thread": {
if (serializationType === SerializationType.SERIALIZE) {
error(
`The deep copy function does not support serialization of "${traversalDescription}", since it is type: ${valueType}`,
);
}
if (serializationType === SerializationType.DESERIALIZE) {
error(
`The deep copy function does not support deserialization of "${traversalDescription}", since it is type: ${valueType}`,
);
}
// We cannot copy this, so simply return the reference.
return value;
}
case "table": {
const luaMap = value as LuaMap<AnyNotNil, unknown>;
return deepCopyTable(
luaMap,
serializationType,
traversalDescription,
classConstructors,
insideMap,
);
}
case "userdata": {
return deepCopyUserdata(value, serializationType, traversalDescription);
}
}
}
function deepCopyTable(
luaMap: LuaMap<AnyNotNil, unknown>,
serializationType: SerializationType,
traversalDescription: string,
classConstructors: LuaMap<string, AnyClass>,
insideMap: boolean,
): unknown {
// First, handle the cases of TSTL classes or serialized TSTL classes.
if (isDefaultMap(luaMap) || luaMap.has(SerializationBrand.DEFAULT_MAP)) {
return deepCopyDefaultMap(
luaMap,
serializationType,
traversalDescription,
classConstructors,
insideMap,
);
}
if (isTSTLMap(luaMap) || luaMap.has(SerializationBrand.MAP)) {
return deepCopyMap(
luaMap,
serializationType,
traversalDescription,
classConstructors,
insideMap,
);
}
if (isTSTLSet(luaMap) || luaMap.has(SerializationBrand.SET)) {
return deepCopySet(
luaMap,
serializationType,
traversalDescription,
classConstructors,
insideMap,
);
}
const className = getTSTLClassName(luaMap);
if (className === "WeakMap") {
error(
`The deep copy function does not support copying the "WeakMap" class for: ${traversalDescription}`,
);
}
if (className === "WeakSet") {
error(
`The deep copy function does not support copying the "WeakSet" class for: ${traversalDescription}`,
);
}
if (className !== undefined || luaMap.has(SerializationBrand.TSTL_CLASS)) {
return deepCopyTSTLClass(
luaMap as TSTLClass,
serializationType,
traversalDescription,
classConstructors,
insideMap,
);
}
// This is not a TSTL Map/Set/class. If it has a metatable, abort.
checkMetatable(luaMap, traversalDescription);
// Handle the special case of serialized Isaac API classes.
if (
isSerializedIsaacAPIClass(luaMap)
&& serializationType === SerializationType.DESERIALIZE
) {
return deserializeIsaacAPIClass(luaMap);
}
// Handle the special case of an array.
if (isArray(luaMap)) {
return deepCopyArray(
luaMap,
serializationType,
traversalDescription,
classConstructors,
insideMap,
);
}
// Base case: copy a normal Lua table
return deepCopyNormalLuaTable(
luaMap,
serializationType,
traversalDescription,
classConstructors,
insideMap,
);
}
function deepCopyDefaultMap(
defaultMap: DefaultMap<AnyNotNil, unknown> | LuaMap<AnyNotNil, unknown>,
serializationType: SerializationType,
traversalDescription: string,
classConstructors: LuaMap<string, AnyClass>,
insideMap: boolean,
): ReadonlyMap<AnyNotNil, unknown> | Readonly<LuaMap<AnyNotNil, unknown>> {
if (SAVE_DATA_MANAGER_DEBUG) {
log("deepCopy is copying a DefaultMap.");
}
const constructorArg = isDefaultMap(defaultMap)
? defaultMap.getConstructorArg()
: undefined; // The undefined case is handled explicitly in the "getNewDefaultMap" function.
// First, handle the special case of serializing a DefaultMap instantiated with a factory
// function. If this is the case, then we cannot serialize it (because there is no way to
// serialize a function).
if (
serializationType === SerializationType.SERIALIZE
&& !isPrimitive(constructorArg)
) {
if (insideMap) {
// The case of a DefaultMap within another map is complicated. Unlike a DefaultMap attached to
// a "normal" object, the `merge` function will have no reference to the factory function that
// was used to instantiate it. Thus, there is no way to copy this object. In this case, we
// throw a run-time error to immediately alert the end-user that their data structure is
// invalid.
error(
"Failed to deep copy a DefaultMap because it was instantiated with a factory function and was also inside of an array, map, or set. For more information, see: https://isaacscript.github.io/main/gotchas#failed-to-deep-copy-a-defaultmap",
);
} else {
// In most cases, the DefaultMap will be attached to a normal table element. In this case, if
// we serialize it as a normal `Map`, then everything will work out fine, because the `merge`
// function only needs to copy the values (and not instantiate the object itself).
return deepCopyMap(
defaultMap,
serializationType,
traversalDescription,
classConstructors,
insideMap,
);
}
}
const newDefaultMap = getNewDefaultMap(
defaultMap,
serializationType,
traversalDescription,
constructorArg,
);
insideMap = true;
const { entries, convertedNumberKeysToStrings } = getCopiedEntries(
defaultMap,
serializationType,
traversalDescription,
classConstructors,
insideMap,
);
if (convertedNumberKeysToStrings) {
// Differentiating between the two types looks superfluous but is necessary for TSTL to produce
// the proper set method call.
if (isDefaultMap(newDefaultMap)) {
newDefaultMap.set(SerializationBrand.OBJECT_WITH_NUMBER_KEYS, "");
} else {
newDefaultMap.set(SerializationBrand.OBJECT_WITH_NUMBER_KEYS, "");
}
}
for (const [key, value] of entries) {
// Differentiating between the two types looks superfluous but is necessary for TSTL to produce
// the proper set method call.
if (isDefaultMap(newDefaultMap)) {
newDefaultMap.set(key, value);
} else {
newDefaultMap.set(key, value);
}
}
return newDefaultMap;
}
/**
* The new copied default map with either be a TSTL `DefaultMap` class or a Lua table, depending on
* whether we are serializing.
*/
function getNewDefaultMap(
defaultMap: DefaultMap<AnyNotNil, unknown> | LuaMap<AnyNotNil, unknown>,
serializationType: SerializationType,
traversalDescription: string,
constructorArg: unknown,
): DefaultMap<AnyNotNil, unknown> | LuaMap<AnyNotNil, unknown> {
switch (serializationType) {
case SerializationType.NONE: {
// eslint-disable-next-line isaacscript/no-invalid-default-map
return new DefaultMap(constructorArg);
}
case SerializationType.SERIALIZE: {
// Since we are serializing, the new object will be a Lua table. (At this point, we already
// handled the special case of a DefaultMap instantiated with a factory function.)
const newDefaultMap = new LuaMap<AnyNotNil, unknown>();
newDefaultMap.set(SerializationBrand.DEFAULT_MAP, "");
newDefaultMap.set(SerializationBrand.DEFAULT_MAP_VALUE, constructorArg);
return newDefaultMap;
}
case SerializationType.DESERIALIZE: {
if (isDefaultMap(defaultMap)) {
error(
`Failed to deserialize a default map of "${traversalDescription}", since it was not a Lua table.`,
);
}
const defaultMapValue = defaultMap.get(
SerializationBrand.DEFAULT_MAP_VALUE,
);
assertDefined(
defaultMapValue,
`Failed to deserialize a default map of "${traversalDescription}", since there was no serialization brand of: ${SerializationBrand.DEFAULT_MAP_VALUE}`,
);
// eslint-disable-next-line isaacscript/no-invalid-default-map
return new DefaultMap(defaultMapValue);
}
}
}
function deepCopyMap(
map: ReadonlyMap<AnyNotNil, unknown> | Readonly<LuaMap<AnyNotNil, unknown>>,
serializationType: SerializationType,
traversalDescription: string,
classConstructors: LuaMap<string, AnyClass>,
insideMap: boolean,
): ReadonlyMap<AnyNotNil, unknown> | Readonly<LuaMap<AnyNotNil, unknown>> {
if (SAVE_DATA_MANAGER_DEBUG) {
log("deepCopy is copying a Map.");
}
let newMap: Map<AnyNotNil, unknown> | LuaMap<AnyNotNil, unknown>;
if (serializationType === SerializationType.SERIALIZE) {
// Since we are serializing, the new object will be a Lua table.
newMap = new LuaMap<AnyNotNil, unknown>();
newMap.set(SerializationBrand.MAP, "");
} else {
newMap = new Map();
}
insideMap = true;
const { entries, convertedNumberKeysToStrings } = getCopiedEntries(
map,
serializationType,
traversalDescription,
classConstructors,
insideMap,
);
if (convertedNumberKeysToStrings) {
// Differentiating between the two types looks superfluous but is necessary for TSTL to produce
// the proper set method call.
if (isTSTLMap(newMap)) {
newMap.set(SerializationBrand.OBJECT_WITH_NUMBER_KEYS, "");
} else {
newMap.set(SerializationBrand.OBJECT_WITH_NUMBER_KEYS, "");
}
}
for (const [key, value] of entries) {
// Differentiating between the two types looks superfluous but is necessary for TSTL to produce
// the proper set method call.
if (isTSTLMap(newMap)) {
newMap.set(key, value);
} else {
newMap.set(key, value);
}
}
return newMap;
}
function deepCopySet(
set: ReadonlySet<AnyNotNil> | Readonly<LuaMap<AnyNotNil, unknown>>,
serializationType: SerializationType,
traversalDescription: string,
classConstructors: LuaMap<string, AnyClass>,
insideMap: boolean,
): ReadonlySet<AnyNotNil> | Readonly<LuaMap<AnyNotNil, string>> {
if (SAVE_DATA_MANAGER_DEBUG) {
log("deepCopy is copying a Set.");
}
let newSet: Set<AnyNotNil> | LuaMap<AnyNotNil, string>;
if (serializationType === SerializationType.SERIALIZE) {
// For serialization purposes, we represent a `Set` as a table with keys that match the
// keys/values in the Set and values of an empty string.
newSet = new LuaMap<AnyNotNil, string>();
newSet.set(SerializationBrand.SET, "");
} else {
newSet = new Set();
}
const { entries, convertedNumberKeysToStrings } = getCopiedEntries(
set,
serializationType,
traversalDescription,
classConstructors,
insideMap,
);
if (convertedNumberKeysToStrings) {
// Differentiating between the two types looks superfluous but is necessary for TSTL to produce
// the proper set method call.
if (isTSTLSet(newSet)) {
// We should never be serializing an object of type `Set`.
error(
"The deep copy function cannot convert number keys to strings for a Set.",
);
} else {
newSet.set(SerializationBrand.OBJECT_WITH_NUMBER_KEYS, "");
}
}
for (const [key] of entries) {
// Differentiating between the two types looks superfluous but is necessary for TSTL to produce
// the proper set method call.
if (isTSTLSet(newSet)) {
newSet.add(key);
} else {
newSet.set(key, "");
}
}
return newSet;
}
function deepCopyTSTLClass(
tstlClass: TSTLClass,
serializationType: SerializationType,
traversalDescription: string,
classConstructors: LuaMap<string, AnyClass>,
insideMap: boolean,
) {
if (SAVE_DATA_MANAGER_DEBUG) {
log("deepCopy is copying a TSTL class.");
}
let newClass: TSTLClass | LuaMap<AnyNotNil, unknown>;
switch (serializationType) {
case SerializationType.NONE: {
// We can use the class constructor from the old class.
newClass = newTSTLClass(tstlClass);
break;
}
case SerializationType.SERIALIZE: {
newClass = new LuaMap<AnyNotNil, unknown>();
// We brand it with the name of the class so that we can run the corresponding constructor
// during deserialization.
const tstlClassName = getTSTLClassName(tstlClass);
if (tstlClassName !== undefined) {
newClass.set(SerializationBrand.TSTL_CLASS, tstlClassName);
}
break;
}
case SerializationType.DESERIALIZE: {
const tstlClassName = tstlClass.get(SerializationBrand.TSTL_CLASS) as
| string
| undefined;
assertDefined(
tstlClassName,
"Failed to deserialize a TSTL class since the brand did not contain the class name.",
);
const classConstructor = classConstructors.get(tstlClassName);
assertDefined(
classConstructor,
`Failed to deserialize a TSTL class since there was no constructor registered for a class name of "${tstlClassName}". If this mod is using the save data manager, it must register the class constructor with the "saveDataManagerRegisterClass" method.`,
);
// eslint-disable-next-line new-cap
newClass = new classConstructor() as TSTLClass;
break;
}
}
const { entries, convertedNumberKeysToStrings } = getCopiedEntries(
tstlClass,
serializationType,
traversalDescription,
classConstructors,
insideMap,
);
if (convertedNumberKeysToStrings) {
newClass.set(SerializationBrand.OBJECT_WITH_NUMBER_KEYS, "");
}
for (const [key, value] of entries) {
newClass.set(key, value);
}
return newClass;
}
function deepCopyArray(
array: readonly unknown[],
serializationType: SerializationType,
traversalDescription: string,
classConstructors: LuaMap<string, AnyClass>,
insideMap: boolean,
): readonly unknown[] {
if (SAVE_DATA_MANAGER_DEBUG) {
log("deepCopy is copying an array.");
}
const newArray: unknown[] = [];
for (const value of array) {
const newValue = deepCopy(
value,
serializationType,
traversalDescription,
classConstructors,
insideMap,
);
newArray.push(newValue);
}
return newArray;
}
function deepCopyNormalLuaTable(
luaMap: LuaMap<AnyNotNil, unknown>,
serializationType: SerializationType,
traversalDescription: string,
classConstructors: LuaMap<string, AnyClass>,
insideMap: boolean,
) {
if (SAVE_DATA_MANAGER_DEBUG) {
log("deepCopy is copying a normal Lua table.");
}
const newTable = new LuaMap<AnyNotNil, unknown>();
const { entries, convertedNumberKeysToStrings } = getCopiedEntries(
luaMap,
serializationType,
traversalDescription,
classConstructors,
insideMap,
);
if (convertedNumberKeysToStrings) {
newTable.set(SerializationBrand.OBJECT_WITH_NUMBER_KEYS, "");
}
for (const [key, value] of entries) {
newTable.set(key, value);
}
return newTable;
}
/**
* Recursively clones the object's entries, automatically converting number keys to strings, if
* necessary.
*
* This should work on objects/tables, maps, sets, default maps, and classes.
*/
function getCopiedEntries(
object: unknown,
serializationType: SerializationType,
traversalDescription: string,
classConstructors: LuaMap<string, AnyClass>,
insideMap: boolean,
): {
entries: Array<[key: AnyNotNil, value: unknown]>;
convertedNumberKeysToStrings: boolean;
} {
// First, shallow copy the entries. We cannot use "pairs" to iterate over a `Map` or `Set`. We
// cannot use "[...pairs(object)]", as it results in a run-time error.
const entries: Array<[key: AnyNotNil, value: unknown]> = [];
if (isTSTLMap(object) || isTSTLSet(object) || isDefaultMap(object)) {
for (const [key, value] of object.entries()) {
entries.push([key, value]);
}
} else {
for (const [key, value] of pairs(object)) {
entries.push([key, value]);
}
}
if (SAVE_DATA_MANAGER_DEBUG) {
entries.sort(sortTwoDimensionalArray);
}
// During serialization, we brand some Lua tables with a special identifier to signify that it has
// keys that should be deserialized to numbers.
const convertStringKeysToNumbers =
serializationType === SerializationType.DESERIALIZE
&& entries.some(
([key]) => key === asString(SerializationBrand.OBJECT_WITH_NUMBER_KEYS),
);
const hasNumberKeys = entries.some(([key]) => isNumber(key));
const convertNumberKeysToStrings =
serializationType === SerializationType.SERIALIZE && hasNumberKeys;
// Second, deep copy the entries.
const copiedEntries: Array<[key: AnyNotNil, value: unknown]> = [];
for (const [key, value] of entries) {
// When deserializing, we do not need to copy the serialization brands that are used to denote
// the object type.
if (isSerializationBrand(key)) {
continue;
}
traversalDescription = getTraversalDescription(key, traversalDescription);
const newValue = deepCopy(
value,
serializationType,
traversalDescription,
classConstructors,
insideMap,
);
let keyToUse = key;
if (convertStringKeysToNumbers) {
const numberKey = tonumber(key);
if (numberKey !== undefined) {
keyToUse = numberKey;
}
}
if (convertNumberKeysToStrings) {
keyToUse = tostring(key);
}
copiedEntries.push([keyToUse, newValue]);
}
return {
entries: copiedEntries,
convertedNumberKeysToStrings: convertNumberKeysToStrings,
};
}
/**
* Lua tables can have metatables, which make writing a generic deep cloner impossible. The deep
* copy function will refuse to copy a table type that has a metatable, outside of specifically
* supported TSTL objects.
*/
function checkMetatable(
luaMap: LuaMap<AnyNotNil, unknown>,
traversalDescription: string,
) {
const metatable = getmetatable(luaMap);
if (metatable === undefined) {
return;
}
const tableDescription =
traversalDescription === ""
? "the table to copy"
: `"${traversalDescription}"`;
error(
`The deepCopy function detected that ${tableDescription} has a metatable. Copying tables with metatables is not supported, unless they are explicitly handled by the save data manager. (e.g. TypeScriptToLua Maps, TypeScriptToLua Sets, etc.)`,
);
}
/** Isaac API classes are of type "userdata". End-user code cannot create userdata. */
function deepCopyUserdata(
value: unknown,
serializationType: SerializationType,
traversalDescription: string,
) {
if (!isCopyableIsaacAPIClass(value)) {
const className = getIsaacAPIClassName(value) ?? "Unknown";
error(
`The deep copy function does not support serializing "${traversalDescription}", since it is an Isaac API class of type: ${className}`,
);
}
switch (serializationType) {
case SerializationType.NONE: {
return copyIsaacAPIClass(value);
}
case SerializationType.SERIALIZE: {
return serializeIsaacAPIClass(value);
}
case SerializationType.DESERIALIZE: {
return error(
`The deep copy function can not deserialize "${traversalDescription}", since it is userdata.`,
);
}
}
}