@clusterio/host
Version:
Implementation of Clusterio host server
526 lines (459 loc) • 14.6 kB
text/typescript
// Export of item icons and locale
import fs from "fs-extra";
import path from "path";
import Jimp from "jimp";
import JSZip from "jszip";
import * as lib from "@clusterio/lib";
import * as libBuildMod from "@clusterio/lib/dist/node/build_mod";
import type { FactorioServer } from "./server";
interface Prototype {
[index: string]: unknown,
type: string,
name: string,
}
interface SimpleIconSpecification {
icon: string;
icon_size?: number;
icon_mipmaps?: number;
}
interface IconLayer {
icon: string;
icon_size?: number;
tint?: lib.FactorioColor,
shift?: [number, number],
scale?: number,
icon_mipmaps?: number,
}
interface LayeredIconSpecification {
icons: IconLayer[];
icon_size?: number;
icon_mipmaps?: number;
}
type IconSpecification = LayeredIconSpecification | SimpleIconSpecification;
type ItemPrototype = Prototype & IconSpecification;
type Prototypes = Record<string, Record<string, Prototype>>
/**
* Generate the export mod needed for exportData
*
* Packs the lua export mod located in lua/export into the mods folder for
* the server given, with dependencies generated for all the mods present in
* the server's mods folder.
*
* @param server -
* The server to generate the export mod for.
*/
async function generateExportMod(server: FactorioServer) {
let dependencies = [];
let splitter = /^(.*)_(\d+\.\d+\.\d+)(\.zip)?$/;
for (let entry of await fs.readdir(server.writePath("mods"))) {
let match = splitter.exec(entry);
if (match && match[1] !== "export") {
dependencies.push(`? ${match[1]}`);
}
}
await libBuildMod.build({
clean: false,
build: true,
pack: true,
sourceDir: path.join(__dirname, "..", "..", "..", "lua", "export"),
outputDir: server.writePath("mods"),
bumpPatch: false,
factorioVersion: server.version?.replace(/\.\d+$/, ""),
dependencies,
});
}
let zipCache = new Map();
async function loadZip(server: FactorioServer, modVersions: Map<string, string>, mod: string) {
let modVersion = modVersions.get(mod);
if (!modVersion) {
throw new Error(`Got path for unknown mod ${mod}`);
}
let zipPath = server.writePath("mods", `${mod}_${modVersion}.zip`);
let zip = zipCache.get(zipPath);
if (!zip) {
zip = await JSZip.loadAsync(await fs.readFile(zipPath));
zipCache.set(zipPath, zip);
}
return zip.folder(lib.findRoot(zip));
}
/**
* Load the given Factorio file path into a Buffer
*
* @param server - The server to load the file from.
* @param modVersions - Mapping of mod to version used.
* @param modPath - Factorio style path to the file to load.
* @returns The content of the file or null if not found.
* @internal
*/
async function loadFile(server: FactorioServer, modVersions: Map<string, string>, modPath: string) {
let match = /^__([^\/]+)__\/(.*)$/.exec(modPath);
if (!match) {
throw new Error(`Bad mod path ${match}`);
}
let [, mod, filePath] = match;
const builtinModNames = ["core", ...lib.ModPack.getBuiltinModNames(server.version!)];
if (builtinModNames.includes(mod)) {
try {
return await fs.readFile(server.dataPath(mod, filePath));
} catch (err: any) {
if (err.code === "ENOENT") {
return null;
}
throw err;
}
}
let zip;
try {
zip = await loadZip(server, modVersions, mod);
} catch (err: any) {
if (err.code === "ENOENT") {
return null;
}
throw err;
}
let file = zip.file(filePath);
if (!file) {
return null;
}
return await file.async("nodebuffer");
}
type IconCache = Map<string, Jimp | null>;
async function loadIcon(
server: FactorioServer,
modVersions: Map<string, string>,
iconPath: string,
iconSize: number,
iconMipmaps: number,
iconCache: IconCache,
) {
let icon = iconCache.get(iconPath);
if (icon === undefined) {
let fileContent = await loadFile(server, modVersions, iconPath);
if (fileContent) {
icon = await Jimp.read(fileContent);
icon.crop(0, 0, iconSize, iconSize);
iconCache.set(iconPath, icon);
} else {
icon = null;
server._logger.warn(`${iconPath} not found`);
}
iconCache.set(iconPath, icon);
}
return icon;
}
async function loadSimpleIcon(
server: FactorioServer,
modVersions: Map<string, string>,
item: SimpleIconSpecification,
size: number,
iconCache: IconCache,
) {
let icon = await loadIcon(server, modVersions, item.icon, item.icon_size ?? 64, 0, iconCache);
if (icon) {
let iconScale = size / (item.icon_size ?? 64);
if (iconScale !== 1) {
icon = icon.clone();
icon.scale(iconScale);
}
}
return icon;
}
async function loadLayeredIcon(
server: FactorioServer,
modVersions: Map<string, string>,
item: LayeredIconSpecification,
size: number,
iconCache: IconCache,
) {
let baseLayerSize = (item.icons[0].icon_size || item.icon_size) ?? 64;
let icon = await Jimp.create(size, size);
// The scaling factor of the base layer
let baseLayerScale = item.icons[0].scale || 32 / baseLayerSize;
// The size in pixels of one unit
let baseUnit = size / (baseLayerSize * baseLayerScale);
for (let layer of item.icons) {
let layerSize = (layer.icon_size || item.icon_size) ?? 64;
let iconLayer = await loadIcon(server, modVersions, layer.icon, layerSize, layer.icon_mipmaps || 0, iconCache);
if (!iconLayer) {
continue;
}
iconLayer = iconLayer.clone();
let tint: lib.Color;
if (layer.tint) {
tint = lib.normalizeColor(layer.tint);
} else {
tint = { r: 1, b: 1, g: 1, a: 1 };
}
let layerScale = layer.scale || 32 / layerSize;
let [xs, ys] = layer.shift || [0, 0];
let realScale = layerScale * baseUnit;
let sizeShift = (size - layerSize * realScale) / 2;
xs = xs * baseUnit + sizeShift;
ys = ys * baseUnit + sizeShift;
if (realScale !== 1) {
iconLayer.scale(realScale);
}
// eslint-disable-next-line no-loop-func
iconLayer.scan(0, 0, iconLayer.bitmap.width, iconLayer.bitmap.height, (x, y, sidx) => {
x += xs;
y += ys;
if (x < 0 || x >= size || y < 0 || y >= size) {
return;
}
let sdata = iconLayer!.bitmap.data;
let ddata = icon.bitmap.data;
let didx = icon.getPixelIndex(x, y);
let sa = sdata[sidx + 3] / 255;
let da = ddata[didx + 3] / 255;
let db = da * (1 - sa * tint.a);
let cb = sa + da * (1 - sa);
ddata[didx + 0] = Math.min(255, (sdata[sidx + 0] * sa * tint.r + ddata[didx + 0] * db) / cb);
ddata[didx + 1] = Math.min(255, (sdata[sidx + 1] * sa * tint.g + ddata[didx + 1] * db) / cb);
ddata[didx + 2] = Math.min(255, (sdata[sidx + 2] * sa * tint.b + ddata[didx + 2] * db) / cb);
ddata[didx + 3] = Math.min(255, sdata[sidx + 3] + ddata[didx + 3] * (1 - sa));
});
}
return icon;
}
function fixIcons(item: ItemPrototype) {
const icons = item.icons;
if (typeof icons === "object" && !(icons instanceof Array) && icons !== null) {
// It's possible to specify icons as an object with arbitrary keys
// and the game will still accept it, cast the value to array if
// this is the case.
item = { ...item };
item.icons = Object.values(icons);
}
return item;
}
const itemTypes = new Set([
"item",
"ammo",
"capsule",
"gun",
"item-with-entity-data",
"item-with-label",
"item-with-inventory",
"blueprint-book",
"item-with-tags",
"selection-tool",
"blueprint",
"copy-paste-tool",
"deconstruction-item",
"upgrade-item",
"module",
"rail-planner",
"spidertron-remote",
"tool",
"armor",
"mining-tool",
"repair-tool",
// XXX Bad hack to get icons for fluids. These should be treated as a
// separate namespace from items as they may be named the same as items.
"fluid",
// TODO consider adding virtual signals and recipes
]);
function filterItems(prototypes: Prototypes): ItemPrototype[] {
return Object.entries(prototypes)
.filter(([type, _]) => itemTypes.has(type))
.flatMap(([_, typePrototypes]) => Object.values(typePrototypes) as ItemPrototype[])
.map(fixIcons)
;
}
/**
* Export item icons and data
*
* Assembles and packs the icons for the item prototypes given into a single
* spritesheet and json file with meta data.
*
* @param server -
* The server to generate the export mod for.
* @param modVersions -
* Mapping of mod name to versions to get icons from.
* @param items - Array of item prototypes.
* @return Item spritesheet and metadata.
* @internal
*/
async function exportItems(server: FactorioServer, modVersions: Map<string, string>, items: ItemPrototype[]) {
// Size to render icons at
let size = 32;
// Width of spritesheet
let width = 1024;
let rows = Math.ceil(items.length / (width / size));
let iconSheet = await Jimp.create(width, rows * size);
let itemData = new Map();
let pos = 0;
let iconCache: IconCache = new Map();
let simpleIcons = new Map();
for (let item of items) {
let icon: Jimp | null = null;
let iconPos: number | undefined;
if (item.icons) {
icon = await loadLayeredIcon(server, modVersions, item as LayeredIconSpecification, size, iconCache);
iconPos = pos;
} else {
iconPos = simpleIcons.get(item.icon);
if (iconPos === undefined) {
icon = await loadSimpleIcon(server, modVersions, item as SimpleIconSpecification, size, iconCache);
if (icon) {
iconPos = pos;
simpleIcons.set(item.icon, pos);
}
}
}
if (iconPos !== undefined) {
itemData.set(item.name, {
x: iconPos * size % width,
y: Math.floor(iconPos / (width / size)) * size,
size,
localised_name: item.localised_name,
localised_description: item.localised_description,
});
}
if (icon) {
iconSheet.composite(icon, pos * size % width, Math.floor(pos / (width / size)) * size);
pos += 1;
}
}
iconSheet.crop(0, 0, width, Math.ceil(pos / (width / size)) * size);
return { iconSheet, itemData };
}
/**
* Exports the locale files for the base game and the given mods
*
* Parses and merges all the locales for the all the mods given through
* `modVersions` and `modOrder`.
*
* @param server - The server to export the locale from.
* @param modVersions - Mapping of mod name to version to export locale from.
* @param modOrder - Load order of the mods.
* @param languageCode - Language to export locale for.
* @returns merged locale information
* @internal
*/
async function exportLocale(
server: FactorioServer,
modVersions: Map<string, string>,
modOrder: string[],
languageCode: string
) {
let mergedLocales = new Map<string, string>();
function mergeLocale(locale: Record<string, string | Record<string, string>>) {
for (let [category, entries] of Object.entries(locale)) {
if (typeof entries === "string") {
mergedLocales.set(category, entries);
} else {
for (let [key, value] of Object.entries(entries)) {
mergedLocales.set(`${category}.${key}`, value);
}
}
}
}
const builtinModNames = [
// Filter if on builtin mods so "core" does not need to be specified in modOrder
"core", ...lib.ModPack.getBuiltinModNames(server.version!).filter(mod => modOrder.includes(mod)),
];
for (const builtinModName of builtinModNames) {
const localeFilePath = server.dataPath(builtinModName, "locale", languageCode, `${builtinModName}.cfg`);
mergeLocale(lib.parse(await fs.readFile(localeFilePath, "utf8")));
}
const builtinModNamesExport = ["export", ...builtinModNames];
for (let mod of modOrder) {
if (builtinModNamesExport.includes(mod)) {
continue;
}
let zip;
try {
zip = await loadZip(server, modVersions, mod);
} catch (err: any) {
if (err.code === "ENOENT") {
continue;
}
throw err;
}
for (let file of zip.file(new RegExp(`locale\\/${languageCode}\\/.*\\.cfg`))) {
let content = await file.async("nodebuffer");
mergeLocale(lib.parse(content.toString("utf8")));
}
}
return mergedLocales;
}
/**
* Export the locale and item icons for the given factorio server
*
* @param server - The server to export the data from.
* @returns zip file with exported data.
*/
export async function exportData(server: FactorioServer) {
await generateExportMod(server);
let settings: Prototypes = {};
let prototypes: Prototypes = {};
let modVersions = new Map();
let modOrder: string[] = [];
function add(obj: Prototypes, prototype: Prototype) {
if (!Object.prototype.hasOwnProperty.call(obj, prototype.type)) {
obj[prototype.type] = {};
}
obj[prototype.type][prototype.name] = prototype;
}
server.on("ipc-prototype_export", data => add(prototypes, data));
server.on("ipc-settings_export", data => add(settings, data));
server.on("ipc-mod_setting_mod", ({ name, mod }) => {
for (let type of Object.values(settings)) {
if (Object.prototype.hasOwnProperty.call(type, name)) {
type[name].mod = mod;
return;
}
}
server._logger.error(`Unable to find ${name} in settings prototypes`);
});
server.on("ipc-mod_list", data => { modVersions = new Map(Object.entries(data)); });
server.on("output", parsed => {
if (parsed.format === "seconds" && parsed.type === "generic") {
let match = /^Checksum of (.*): \d+$/.exec(parsed.message);
if (match) {
modOrder.push(match[1]);
}
}
});
try {
await server.startScenario("base/freeplay");
await server.kill();
} finally {
await fs.unlink(server.writePath("mods", "export_0.0.0.zip"));
}
if (!Object.keys(prototypes).length) {
throw new Error("No prototypes got exported");
}
// Some mod authors put leading zeros into the versions of their zip files.
let splitter = /^(.*)_(\d+)\.(\d+)\.(\d+)\.zip?$/;
for (let entry of await fs.readdir(server.writePath("mods"))) {
let match = splitter.exec(entry);
if (!match) {
continue;
}
let modVersion = `${match[2]}.${match[3]}.${match[4]}`;
let normalizedVersion =
`${Number.parseInt(match[2], 10)}.${Number.parseInt(match[3], 10)}.${Number.parseInt(match[4], 10)}`
;
if (modVersion === normalizedVersion) {
continue;
}
if (modVersions.get(match[1]) === normalizedVersion) {
modVersions.set(match[1], modVersion);
}
}
let { iconSheet, itemData } = await exportItems(server, modVersions, filterItems(prototypes));
let locale = await exportLocale(server, modVersions, modOrder, "en");
// Free up the memory used by zip files loaded during the export.
zipCache.clear();
let zip = new JSZip();
zip.file("export/settings.json", JSON.stringify(settings));
zip.file("export/prototypes.json", JSON.stringify(prototypes));
zip.file("export/item-spritesheet.png", await iconSheet.getBufferAsync(Jimp.MIME_PNG));
zip.file("export/item-metadata.json", JSON.stringify([...itemData.entries()]));
zip.file("export/locale.json", JSON.stringify([...locale.entries()]));
return zip;
}
// For testing only
export const _exportLocale = exportLocale;