@tts-tools/savefile
Version:
Module to extract a savefile from Tabletop Simulator into multiple files.
244 lines (201 loc) • 7.31 kB
text/typescript
import Big from "big.js";
import { mkdirSync, readFileSync } from "fs";
import stringify, { Element } from "json-stable-stringify";
import { writeFile, writeJson } from "./io";
import { ChildObjectsFile, ContentsFile, StatesFile } from "./model/tool";
import { SaveFile, TTSObject } from "./model/tts";
import { unbundleSave } from "./unbundle";
const HANDLED_KEYS = [
"LuaScript",
"LuaScriptState",
"XmlUI",
"ContainedObjects",
"ObjectStates",
"States",
"ChildObjects",
];
const FLOATING_MARKER = ">>floating-point<<";
const DEFAULT_ROUNDING = 4;
/**
* Available options for [[extractSave]].
*/
export interface Options {
/** The path where the save file will be extracted to. */
output: string;
/** If set, floating point values will be rounded to the 4th decimal point. */
normalize?: boolean | number;
withState?: boolean;
metadataField?: string;
contentsPath?: string;
statesPath?: string;
childrenPath?: string;
keyOrder?: string[];
}
const state = {
files: new Map<string, Map<string, number>>(),
};
export const readSave = (path: string): SaveFile => {
let content = readFileSync(path, { encoding: "utf-8" });
content = content.replace(/^(\s*"[\w]+": )(-?\d+(?:\.\d+(?:[eE]-\d+)?)?)($|,)/gm, `$1"${FLOATING_MARKER}$2"$3`);
return JSON.parse(content) as SaveFile;
};
/**
* Extracts the given `saveFile`, by splitting the data into a nested directory structure.
* It also returns an unbundled version of the save file.
*
* @param saveFile The save file to extract.
* @param options The [[Options]] to use.
* @returns The unbundled/normalized version of the save file
*/
export const extractSave = (saveFile: SaveFile, options: Options): SaveFile => {
const unbundledSave = unbundleSave(saveFile);
writeExtractedSave(unbundledSave, options);
return unbundledSave;
};
export const writeExtractedSave = (saveFile: SaveFile, options: Options) => {
clearState();
mkdirSync(options.output, { recursive: true });
extractScripts(saveFile, options.output, options);
extractContent(saveFile.ObjectStates, options.output + "/", options);
extractData(saveFile, options.output, options);
};
export const writeExtractedObject = (object: TTSObject, options: Options) => {
clearState();
const objectPath = `${options.output}/${getDirectoryName(object)}`;
mkdirSync(objectPath, { recursive: true });
extractObject(object, objectPath, options);
};
const clearState = () => {
state.files.clear();
};
/**
* @param object The object to Extract
* @param path Current nested path where files for this object will be placed at
*/
const extractObject = (object: TTSObject, path: string, options: Options) => {
mkdirSync(path, { recursive: true });
extractScripts(object, path, options);
if (object.ContainedObjects) {
extractContent(object.ContainedObjects, path, options);
}
extractStates(object, path, options);
extractChildren(object, path, options);
extractData(object, path, options);
};
const extractScripts = (object: TTSObject | SaveFile, path: string, options: Options) => {
if (object.LuaScript) {
writeFile(`${path}/Script.ttslua`, object.LuaScript);
}
if (object.LuaScriptState && options.withState) {
writeFile(`${path}/State.txt`, object.LuaScriptState);
}
if (options.metadataField) {
const metadata = object[options.metadataField];
if (metadata) {
writeFile(`${path}/Metadata.toml`, metadata);
}
}
if (object.XmlUI) {
writeFile(`${path}/UI.xml`, object.XmlUI);
}
};
const extractContent = (objects: TTSObject[], path: string, options: Options) => {
const contents: ContentsFile = [];
objects.forEach((object) => {
const contentSubPath = options.contentsPath || ".";
const objectDirectory = getFreeDirectoryName(object, `${path}/${contentSubPath}`);
const contentsPath = `${contentSubPath}/${objectDirectory}`;
contents.push({
path: contentsPath,
});
extractObject(object, `${path}/${contentsPath}`, options);
});
writeJson(`${path}/Contents.json`, contents);
};
const extractStates = (object: TTSObject, path: string, options: Options) => {
if (!object.States) {
return;
}
const states: StatesFile = {};
Object.entries(object.States).forEach(([id, state]) => {
const statesSubPath = options.statesPath || ".";
const objectDirectory = getDirectoryName(state);
const statePath = `${statesSubPath}/${id}-${objectDirectory}`;
states[id] = {
path: statePath,
};
extractObject(state, `${path}/${statePath}`, options);
});
writeJson(`${path}/States.json`, states);
};
const extractChildren = (object: TTSObject, path: string, options: Options) => {
if (!object.ChildObjects) {
return;
}
const childObjects: ChildObjectsFile = [];
object.ChildObjects.forEach((child) => {
const childrenSubPath = options.childrenPath || ".";
const objectDirectory = getDirectoryName(child);
const childPath = `${childrenSubPath}/${objectDirectory}`;
childObjects.push({
path: childPath,
});
extractObject(child, `${path}/${childPath}`, options);
});
writeJson(`${path}/Children.json`, childObjects);
};
const extractData = (object: TTSObject | SaveFile, path: string, options: Options) => {
const replacer = (key: string, value: any) => dataReplacer(key, value, options);
let dataContent;
if (options.keyOrder) {
dataContent = stringify(object, {
replacer: replacer,
space: 2,
cmp: (a, b) => keyOrderer(a, b, options.keyOrder!),
});
} else {
dataContent = JSON.stringify(object, replacer, 2);
}
dataContent = dataContent.replace(new RegExp(`"${FLOATING_MARKER}([^"]+)"`, "g"), "$1");
writeFile(`${path}/Data.json`, dataContent);
};
const dataReplacer = (key: string, value: any, options: Options) => {
if (HANDLED_KEYS.includes(key) || key === options.metadataField) {
return undefined;
}
if (options.normalize && typeof value === "string" && value.startsWith(FLOATING_MARKER)) {
const roundTo = typeof options.normalize === "number" ? options.normalize : DEFAULT_ROUNDING;
const actualValue = value.slice(FLOATING_MARKER.length);
const numericValue = Big(actualValue).round(roundTo);
return `${FLOATING_MARKER}${numericValue}`;
}
return value;
};
const keyOrderer = (a: Element, b: Element, keyOrder: string[]) => {
const aOrder = keyOrder.indexOf(a.key);
const bOrder = keyOrder.indexOf(b.key);
if (aOrder > -1) {
return bOrder == -1 ? -1 : aOrder > bOrder ? 1 : -1;
}
return bOrder == -1 ? a.key.localeCompare(b.key) : 1;
};
const getDirectoryName = (object: TTSObject): string => {
const baseName = object.Nickname && object.Nickname.length > 0 ? object.Nickname : object.Name;
return `${baseName}.${object.GUID}`.replace(/[^\w \^&'@{}\[\],$=!\-#()%\.+~_]/g, "-");
};
const getFreeDirectoryName = (object: TTSObject, path: string): string => {
let objectPath = getDirectoryName(object);
let subFiles = state.files.get(path);
if (!subFiles) {
subFiles = new Map();
state.files.set(path, subFiles);
}
const existing = subFiles.get(objectPath);
if (existing) {
subFiles.set(objectPath, existing + 1);
objectPath += `.${existing}`;
} else {
subFiles.set(objectPath, 1);
}
return objectPath;
};