anomaly-packer
Version:
Anomaly Packer is a utility package for STALKER Anomaly creators to help them develop addons at speed with TypeScript's type-safety and game-oriented build tools.
545 lines (544 loc) • 23.6 kB
JavaScript
import { r as __exportAll } from "./rolldown-runtime-DXgyhg3a.mjs";
import { t as zipBuild } from "./zip-DxzDwQVl.mjs";
import c from "chalk";
import { existsSync, readFileSync } from "fs";
import fs from "fs/promises";
import iconv from "iconv-lite";
import path from "path";
import { pathToFileURL } from "url";
import { XMLBuilder } from "fast-xml-parser";
import { renderToString } from "react-dom/server";
import jsxToJson from "simplified-jsx-to-ast";
import * as tstl from "typescript-to-lua";
import { stringify } from "ini";
//#region src/texts/character.ts
/** @deprecated NOT IMPLEMENTED */
function specificCharacter(character) {
return "...";
}
//#endregion
//#region src/texts/_xml.ts
const builder = new XMLBuilder({
ignoreAttributes: false,
attributeNamePrefix: "@_",
format: true,
indentBy: " ",
suppressEmptyNode: true,
preserveOrder: true
});
/** Serializes nodes to a headerless, indented XML string with self-closing empty elements. */
function buildXml(nodes) {
return String(builder.build(nodes)).trim() + "\n";
}
/** `<tag ...attrs>children</tag>`, self-closing when it has no children. */
function element(tag, attrs, children) {
const node = { [tag]: children };
const bag = buildAttrs(attrs);
if (bag) node[":@"] = bag;
return node;
}
/** `<tag>value</tag>`, self-closing when the value stringifies to empty. */
function leaf(tag, value) {
const text = String(value);
return { [tag]: text === "" ? [] : [{ "#text": text }] };
}
/** Bare text content placed among an element's children. */
function textNode(value) {
return { "#text": value };
}
/** Repeated `<tag>` elements from a value that may be a scalar, an array, or absent — the successor to toXmlStructure. */
function field(tag, value) {
if (value === void 0) return [];
return (Array.isArray(value) ? value : [value]).map((item) => leaf(tag, item));
}
function buildAttrs(attrs) {
if (!attrs) return void 0;
const bag = {};
for (const [key, value] of Object.entries(attrs)) if (value !== void 0) bag["@_" + key] = String(value);
return Object.keys(bag).length ? bag : void 0;
}
//#endregion
//#region src/texts/dialog.ts
function dialog(dialog) {
const children = [
...field("precondition", dialog.precondition),
...field("has_info", dialog.has_info),
...field("dont_has_info", dialog.dont_has_info),
...field("give_info", dialog.give_info),
...field("disable_info", dialog.disable_info),
...field("init_func", dialog.init_func),
...field("action", dialog.action)
];
if (dialog.phrases) children.push(element("phrase_list", void 0, dialog.phrases.map((phr) => element("phrase", { id: phr.id }, [
...field("precondition", phr.precondition),
...field("has_info", phr.has_info),
...field("dont_has_info", phr.dont_has_info),
...field("text", phr.text),
...field("script_text", phr.script_text),
...field("give_info", phr.give_info),
...field("disable_info", phr.disable_info),
...field("action", phr.action),
...field("is_final", phr.is_final),
...field("next", phr.next)
]))));
return buildXml([element("dialog", { id: dialog.id }, children)]);
}
//#endregion
//#region src/util.ts
function objectEntries(obj) {
return Object.entries(obj);
}
//#endregion
//#region src/texts/dltx.ts
/** Delete a section with the use of `!!` prefix */
function deleteSection({ sectionName }) {
let output = "";
output += "!![" + sectionName + "]\n";
return output;
}
/**
* Override a section with the use of `!` prefix.
* - add or override an entry - no prefix,
* - delete an entry with `!`,
* - add to array with `>`,
* - delete from array with `<`,
*/
function override(ltx, sort = true, align = true) {
let output = "";
output += "![" + ltx.sectionName + "]";
if (ltx._with && ltx._with.length) output += ":" + ltx._with.join(", ");
if (!ltx.entries) return output;
output += "\n";
const entries = objectEntries(ltx.entries);
if (sort) entries.sort(([k1], [k2]) => k1.localeCompare(k2));
const [longestKey] = entries.toSorted(([k1], [k2]) => k2.length - k1.length)[0];
for (const [k, v] of entries) {
if (k.startsWith("!")) output += k;
else {
output += (align ? k.padEnd(longestKey.length) : k) + " = ";
if (Array.isArray(v)) output += v.join(", ");
else if (v === null) output += "nil";
else if (v === void 0) output += "";
else output += v;
}
output += "\n";
}
return output;
}
/** Create a section if it does not exist or override if it does with the use of `@` prefix */
function createOrOverride(ltx, sort = true, align = true) {
let output = "";
output += "@[" + ltx.sectionName + "]";
if (ltx._with && ltx._with.length) output += ":" + ltx._with.join(", ");
if (!ltx.entries) return output;
output += "\n";
const entries = objectEntries(ltx.entries);
if (sort) entries.sort(([k1], [k2]) => k1.localeCompare(k2));
const [longestKey] = entries.toSorted(([k1], [k2]) => k2.length - k1.length)[0];
for (const [k, v] of entries) {
output += (align ? k.padEnd(longestKey.length) : k) + " = ";
if (Array.isArray(v)) output += v.join(", ");
else if (v === null) output += "nil";
else output += v;
output += "\n";
}
return output;
}
const dltx = {
createOrOverride,
override,
deleteSection
};
//#endregion
//#region src/texts/include.ts
function include(path) {
return `#include "${path}"`;
}
//#endregion
//#region src/texts/info-portions.ts
function infoPortions(portions) {
return buildXml([element("game_information_portions", void 0, objectEntries(portions).map(([id, pe]) => element("info_portion", { id }, pe ? [
...field("action", pe.action),
...field("actor_dialog", pe.actor_dialog),
...field("article", pe.article),
...field("dialog", pe.dialog),
...field("disable", pe.disable)
] : [])))]);
}
//#endregion
//#region src/texts/localization.ts
/** Every localization declares exactly the same keys: `eng` is the source of truth (its keys and values are inferred), and every other language you provide is forced to mirror its key set exactly — a missing or stray translation is a compile error. The languages you pass stay present on the result (no spurious `undefined`). */
function bilingual(dict) {
return dict;
}
/** Wraps a color name in the game's inline color code, e.g. `color('d_cyan')` → `%c[d_cyan]`. Everything after it is painted until the next code. */
function color(name) {
return `%c[${name}]`;
}
//#endregion
//#region src/texts/ltx.ts
/**
* Render a single ltx section to string. Bind it to a schema via the type argument to get
* section-name autocomplete and per-field value checking — either a registered path key
* (`ltx<'plugins\\my_addon.ltx'>({...})`) or a section-map type directly
* (`ltx<MyAddonIni>({...})`). Called without the type argument, it falls back to the loose
* untyped schema.
* @param sort sort entries alphabetically (`true` by default)
* @param align aligns table vertically, resuling in "=" signs appear under each other (`true` by default)
* @returns ltx table as string
*/
function ltx(ltx, sort = true, align = true) {
let output = "";
output += "[" + ltx.section + "]";
if (ltx.with && ltx.with.length) output += ":" + ltx.with.join(", ");
if (!ltx.entries) return output;
output += "\n";
const entries = objectEntries(ltx.entries);
if (sort) entries.sort(([k1], [k2]) => k1.localeCompare(k2));
const [longestKey] = entries.toSorted(([k1], [k2]) => k2.length - k1.length)[0];
for (const [k, v] of entries) {
output += (align ? k.padEnd(longestKey.length) : k) + " = ";
if (Array.isArray(v)) output += v.join(", ");
else if (v === null) output += "nil";
else output += v;
output += "\n";
}
return output;
}
/**
* Bind {@link ltx} to a schema once, dropping the per-call type argument: `const f =
* t.forFile<MyAddonIni>()` (or a registered path key) then `f.ltx({ section, entries })`.
* Purely additive — the standalone `t.ltx` (with or without its own type argument) still
* works, so schema binding stays opt-in at every level.
*/
function forFile() {
return { ltx: (arg, sort = true, align = true) => ltx(arg, sort, align) };
}
ltx.f = function(name, ...args) {
return name + (args.length ? "(" + args.join(":") + ")" : "");
};
//#endregion
//#region src/texts/translation.ts
function translations(translation, idOverride) {
return buildXml([element("string_table", void 0, objectEntries(translation).map(([id, text]) => element("string", { id: idOverride ? idOverride(id) : id }, [leaf("text", text ?? "")])))]);
}
//#endregion
//#region src/texts/ui.ts
/**
* Convert HEX color code to RGBA object
* @example
* ```ts
* const clr = hexToRgba('#2dd4c982')
* const alpha = clr!.a // 51
* ```
*/
function hexToRgba(hex) {
const result = /^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i.exec(hex);
if (result) {
const r = parseInt(result[1], 16);
const g = parseInt(result[2], 16);
const b = parseInt(result[3], 16);
const _a = parseInt(result[4], 16);
return {
r,
g,
b,
a: Math.round((isNaN(_a) ? 255 : _a) / 255 * 100)
};
}
return null;
}
function jsxToXml(tree) {
return buildXml(astToNodes(jsxToJson(renderToString(tree))));
}
function astToNodes(ast) {
if (typeof ast === "string") return ast === "" ? [] : [textNode(ast)];
const children = normalizeChildren(ast.children);
return ast.type === "Fragment" ? children : [element(ast.type, ast.props, children)];
}
/** simplified-jsx-to-ast types children as an array, but a text-only Fragment hands back the raw string, so both shapes are normalized here. */
function normalizeChildren(children) {
if (typeof children === "string") return children === "" ? [] : [textNode(children)];
return children.flatMap(astToNodes);
}
const ui = {
jsxToXml,
hexToRgba
};
//#endregion
//#region src/texts/index.ts
var texts_exports = /* @__PURE__ */ __exportAll({
bilingual: () => bilingual,
color: () => color,
dialog: () => dialog,
dltx: () => dltx,
forFile: () => forFile,
include: () => include,
infoPortions: () => infoPortions,
ltx: () => ltx,
specificCharacter: () => specificCharacter,
translations: () => translations,
ui: () => ui
});
//#endregion
//#region src/header.ts
/**
* The credit block prepended to every generated `.script`. The engine loads these files as
* plain Lua, so a leading comment costs nothing at runtime and makes a shipped addon
* traceable back to its source — worth having when a script ends up in someone's gamedata
* folder with no other context.
*
* Every field comes from the addon's own `package.json` and is omitted when absent, so a
* package with nothing but a name produces just the first line.
*/
const PACKAGE_URL = "https://github.com/piscopancer/anomaly-packer";
/** Anomaly Packer's own version, read from its `package.json` — `src/header.ts` and the bundled `dist/index.mjs` both sit one level below it. Stamped into the first line so a shipped script says which packer produced it, not just that a packer did. */
function readSelfVersion() {
return readPackageJson(path.join(import.meta.dirname, "..")).version;
}
/** `16.07.2026` — the format Anomaly's own configs and changelogs use. */
function formatDate(date) {
const pad = (n) => String(n).padStart(2, "0");
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()}`;
}
function readAuthor(author) {
if (!author) return void 0;
return typeof author === "string" ? author : author.name;
}
function readRepository(repository) {
if (!repository) return void 0;
return (typeof repository === "string" ? repository : repository.url)?.replace(/^git\+/, "").replace(/\.git$/, "");
}
/** Reads the addon's `package.json`, tolerating its absence — the header is a nicety, not a
* requirement, and a missing or malformed manifest must never fail a build. */
function readPackageJson(cwd) {
const file = path.join(cwd, "package.json");
if (!existsSync(file)) return {};
try {
return JSON.parse(readFileSync(file, "utf8"));
} catch {
return {};
}
}
function buildHeader(cwd = process.cwd(), now = /* @__PURE__ */ new Date()) {
const pkg = readPackageJson(cwd);
const selfVersion = readSelfVersion();
const fields = [
["Author", readAuthor(pkg.author)],
["Version", pkg.version],
["Created at", formatDate(now)],
["Source", readRepository(pkg.repository)]
];
return [`-- This script was generated with Anomaly Packer${selfVersion ? ` v${selfVersion}` : ""} (${PACKAGE_URL})`, ...fields.filter(([, value]) => value).map(([label, value]) => `-- ${label}: ${value}`)].join("\n") + "\n\n";
}
//#endregion
//#region src/transpilation.ts
/**
* Importable runtime modules shipped with Anomaly Packer, mapped from their `import` specifier to the runtime that provides them at runtime. `template` is the source `.script` in {@link ./runtime} to copy; `suffix` is appended to the addon id to form the per-addon flat script name `<addonId>__<suffix>`. When a transpiled script requires one of these, its `require(...)` is rewritten to that cross-script global and the template is copied into the build under that name. The double underscore keeps the technical part visible while sorting the file right next to the addon's own scripts.
*/
const runtimeModules = {
"anomaly-packer/mcm": {
template: "__anomaly_packer_mcm",
suffix: "ap_mcm"
},
"anomaly-packer/class": {
template: "__anomaly_packer_class",
suffix: "ap_class"
}
};
/** The flat Anomaly script name a registered source file is built to: the entry `index` becomes the bare addon id, every other short name is prefixed with it (`mcm` -> `<addonId>_mcm`). */
function scriptBuildName(addonId, sourceFileName) {
return sourceFileName === "index" ? addonId : `${addonId}_${sourceFileName}`;
}
function transpile(scripts, addonId) {
const transpiledFiles = [];
const runtimes = /* @__PURE__ */ new Map();
const header = buildHeader();
const gamedataTsconfig = process.cwd() + "/gamedata/tsconfig.json";
const scriptsTsconfig = process.cwd() + "/gamedata/scripts/tsconfig.json";
tstl.transpileProject(existsSync(gamedataTsconfig) ? gamedataTsconfig : scriptsTsconfig, {
luaTarget: tstl.LuaTarget.LuaJIT,
luaLibImport: tstl.LuaLibImportKind.Inline,
extension: ".script",
noHeader: true
}, (buildFileName, text) => {
buildFileName = path.basename(buildFileName).replace(".script", "");
const regScript = scripts.find((sourceFileName) => buildFileName === sourceFileName);
if (regScript) transpiledFiles.push({
sourceFileName: regScript,
buildFileName: scriptBuildName(addonId, regScript),
buildFileText: header + linkRuntimes(modifyLua(text), addonId, runtimes)
});
});
return {
scripts: transpiledFiles,
runtimes,
header
};
}
/** Rewrites `require("<runtime module>")` into the Anomaly cross-script global that provides it, recording which runtimes the build now needs. tstl emits a deterministic `require("<specifier>")` for `@noResolution` modules — with path separators turned into dots — so matching the exact call is precise, not a heuristic. */
function linkRuntimes(lua, addonId, runtimes) {
for (const [specifier, { template, suffix }] of Object.entries(runtimeModules)) {
const requireCall = `require("${specifier.replaceAll("/", ".")}")`;
if (lua.includes(requireCall)) {
const global = `${addonId}__${suffix}`;
lua = lua.split(requireCall).join(global);
runtimes.set(template, global);
}
}
return lua;
}
/** tstl wraps a module in an ES-like shell (____exports table, local declarations, __TS__ lib helpers) — Anomaly expects a flat script of global functions, so we unwrap it. Order matters: strip ____exports before globalizing, otherwise "local ____exports = {}" loses its "local" and stops matching. */
function modifyLua(lua) {
lua = removeExports(lua);
lua = dropTopLevelForwardDeclarations(lua);
lua = globalizeTopLevel(lua);
lua = stripTsHelperPrefix(lua);
lua = reindent(lua);
return lua;
}
/**
* Halves the transpiler's fixed four-space indent to two, matching how Anomaly's own scripts
* are written — generated files sit beside hand-written ones and should not look foreign.
*
* Only the run of leading spaces is touched, so indentation inside string literals (which
* begins after a quote, never at the start of a line) is left alone.
*/
function reindent(lua) {
return lua.split("\n").map((line) => {
const indent = line.length - line.trimStart().length;
return indent ? " ".repeat(indent / 2) + line.slice(indent) : line;
}).join("\n");
}
/** Drops tstl's top-level forward declarations — `local name` or `local a, b, c` with no initializer, which it emits to hoist a function used before its definition (or a lualib class group like `local Error, RangeError, ...`). Left alone they would survive {@link globalizeTopLevel} as a bare `name` / `a, b, c` line, which is not a valid Lua statement and breaks the whole script on load. Globals need no forward declaration, so the line can simply be removed. Anchored to column 0 and requires the whole line to be `local` + identifiers (no `=`, no `(`), so real declarations like `local x = 1` and `local function f(` are untouched. */
function dropTopLevelForwardDeclarations(lua) {
return lua.replaceAll(/^local \w[\w, ]*$\n?/gm, "");
}
/** Drops the "__TS__" prefix from tstl runtime helpers (both their definitions and call sites). Restricted to identifier characters so it never reaches into string literals or comments. */
function stripTsHelperPrefix(lua) {
return lua.replaceAll(/__TS__(\w+)\(/g, "$1(");
}
/** Removes "local" from top-level declarations so the engine can reach them as globals. Anchored to the start of a line (no indentation) so nested locals inside function bodies stay local; only genuine "[[ ]]" multi-line strings with a line beginning in "local " could be affected. */
function globalizeTopLevel(lua) {
return lua.replaceAll(/^local (function |\w)/gm, "$1");
}
/** Unwraps the tstl "____exports" module table: exported functions become global declarations, the table and its trailing "return" are dropped. */
function removeExports(lua) {
return lua.replace(/____exports\.(\w+)\s*=\s*function\s*\(/g, "function $1(").replaceAll(/____exports\./g, "").replace(/local\s+____exports\s*=\s*\{\s*\}\n?/, "").replace(/\n?return ____exports\s*$/, "");
}
//#endregion
//#region src/pack.ts
/**
* Extensions whose contents are text the engine reads in win1251. Everything else in a
* gamedata tree — textures, sounds, meshes — is binary and must be copied unchanged.
*/
const TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
".ltx",
".xml",
".script",
".txt",
".seq",
".lua",
".json",
".md"
]);
async function pack(options) {
console.log("");
const outDirName = options.build?.outDirName ?? "build";
const cwd = process.cwd();
const buildGamedataPath = path.join(cwd, outDirName, "gamedata");
if (!existsSync(path.join(cwd, "gamedata"))) {
console.error("gamedata directory must reside in the root of the project, otherwise there is nothing to pack");
return;
} else {
await fs.rm(buildGamedataPath, {
force: true,
recursive: true
});
await fs.mkdir(buildGamedataPath, { recursive: true });
console.log("Reading " + c.bold.white("gamedata ") + c.reset("directory..."));
const scriptsDirPresent = existsSync(path.join(cwd, "gamedata/scripts"));
console.log(c.bold.white("scripts") + c.reset(` directory detected. Transpiling scripts...`));
const transpiled = scriptsDirPresent && options.scripts ? await transpile(options.scripts, options.addonId) : null;
await thisRecursiveShit(path.join(cwd, "gamedata"), buildGamedataPath, transpiled?.scripts ?? null);
console.log(c.cyan.bold("Scripts ") + c.cyan("were transpiled"));
if (transpiled && transpiled.runtimes.size) {
const scriptsBuildPath = path.join(buildGamedataPath, "scripts");
await fs.mkdir(scriptsBuildPath, { recursive: true });
for (const [template, global] of transpiled.runtimes) {
const runtimeLua = await fs.readFile(path.join(import.meta.dirname, "runtime", `${template}.script`), "utf8");
await fs.writeFile(path.join(scriptsBuildPath, `${global}.script`), iconv.encode(transpiled.header + runtimeLua, "win1251"));
}
console.log(c.cyan.bold("Runtime ") + c.cyan(`scripts linked (${[...transpiled.runtimes.values()].join(", ")})`));
}
}
console.log("");
}
async function thisRecursiveShit(sourcePath, buildPath, allTranspiled) {
const dirItems = await fs.readdir(sourcePath);
for (const item of dirItems) {
const curSourcePath = path.join(sourcePath, item);
const curBuildPath = path.join(buildPath, item);
const itemStat = await fs.stat(curSourcePath);
if (itemStat.isDirectory()) {
if (curBuildPath.includes(path.join("gamedata", "scripts")) && !curBuildPath.endsWith(path.join("gamedata", "scripts"))) continue;
await fs.mkdir(curBuildPath);
await thisRecursiveShit(curSourcePath, curBuildPath, allTranspiled);
} else if (itemStat.isFile()) {
if (item === "tsconfig.json" && sourcePath.endsWith("gamedata")) continue;
if (item.endsWith(".d.ts")) continue;
const ext = path.extname(item);
if (ext === ".ts" || ext === ".tsx") {
const fileName = item.substring(0, item.length - ext.length);
if (allTranspiled && sourcePath.includes(path.join("gamedata", "scripts"))) {
for (const transpiled of allTranspiled) if (fileName === transpiled.sourceFileName) await fs.writeFile(path.join(buildPath, transpiled.buildFileName + ".script"), iconv.encode(transpiled.buildFileText, "win1251"));
} else {
const textScript = await import(pathToFileURL(curSourcePath).href + "?t=" + Date.now());
try {
const text = await textScript.default(texts_exports);
const extension = textScript.extension ?? "xml";
const output = Array.isArray(text) ? text.join("\n") : String(text);
await fs.writeFile(path.join(buildPath, fileName + `.${extension}`), iconv.encode(output, "win1251"));
} catch (e) {
console.error("Script at %s does not have a default export or contains an error. This file will not appear in the build", curSourcePath);
console.log(c.italic.gray(e.message));
}
}
} else {
if (curBuildPath.includes(path.join("gamedata", "scripts")) && ext !== ".script") continue;
if (TEXT_EXTENSIONS.has(ext.toLowerCase())) {
const content = await fs.readFile(curSourcePath);
await fs.writeFile(curBuildPath, iconv.encode(content.toString("utf8"), "win1251"));
} else await fs.copyFile(curSourcePath, curBuildPath);
}
}
}
}
//#endregion
//#region src/meta.ts
/**
* Fallbacks for the keys MO2 needs present for a mod to register at all. An author who only
* cares about, say, `version` and `url` should not have to hand-write stub `modid`/`category`
* lines, so these fill in unless overridden. `version` defaults to `1.0.0` rather than being
* left blank, since an empty version reads as "unmanaged" in MO2's pane.
*/
const DEFAULTS = {
modid: 0,
version: "1.0.0",
category: 0
};
/**
* The `meta.ini` content for the given `[General]` fields, serialized by `ini` rather than by
* hand so escaping and quoting match a real ini parser. MO2's required keys ({@link DEFAULTS})
* are filled in when the caller omits them. Keys explicitly set to `undefined` are dropped, so
* passing `{ modid: undefined }` opts out of that default rather than emitting a blank line.
*/
function buildMetaIniContent(general = {}) {
const merged = {
...DEFAULTS,
...general
};
return stringify({ General: Object.fromEntries(Object.entries(merged).filter(([, value]) => value !== void 0)) });
}
//#endregion
export { bilingual, buildMetaIniContent, color, dialog, dltx, forFile, include, infoPortions, ltx, pack, specificCharacter, translations, ui, zipBuild };