Merge pull request #875 from Hornwitser/refactor-export

Refactor export
This commit is contained in:
Hornwitser authored and GitHub committed 2026-03-19 21:55:04 +01:00
commit 7701010fee
27 files changed
+1002 -465

No files matched your search

+3 -2
View File
@@ -241,12 +241,13 @@ async function uploadExport(req: Request, res: Response) {
"export/settings.json",
"export/prototypes.json",
"export/locale.json",
"export/defines.json",
"export/spritesheet.png",
"export/metadata.json",
];
let assets: any = {};
let settingPrototypes = {};
let settingPrototypes: lib.ExportSettings = {};
for (let filePath of exportFiles) {
let file = zip.file(filePath);
if (!file) {
@@ -695,12 +696,12 @@ export const webRoutes = [
"/instances/:id/view",
"/mods",
"/mods/mod-packs/:id/view",
"/mods/mod-packs/:id/icon-reference",
"/users",
"/users/:id/view",
"/roles",
"/roles/:id/view",
"/plugins",
"/plugins/:name/view",
"/icon-reference",
"/error",
];
+1
View File
@@ -9,4 +9,5 @@ script.on_init(function()
for name, mod_setting in pairs(mod_settings) do
send_json("mod_setting_mod", { name = name, mod = mod_setting.mod })
end
send_json("defines", defines)
end)
+6 -6
View File
@@ -23,26 +23,26 @@
"dependencies": {
"@clusterio/lib": "workspace:*",
"@sinclair/typebox": "catalog:",
"set-blocking": "catalog:",
"fs-extra": "catalog:",
"jimp": "^1.6.0",
"jszip": "catalog:",
"winston": "catalog:",
"winston-daily-rotate-file": "catalog:",
"jimp": "^0.22.8",
"pidusage": "^3.0.2",
"rcon-client": "^4.2.5",
"semver": "^7.7.1",
"set-blocking": "catalog:",
"winston": "catalog:",
"winston-daily-rotate-file": "catalog:",
"yargs": "^17.7.2"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/node": "catalog:",
"@types/fs-extra": "catalog:",
"@types/set-blocking": "catalog:",
"@types/node": "catalog:",
"@types/pidusage": "^2.0.2",
"@types/semver": "^7.5.0",
"@types/set-blocking": "catalog:",
"@types/yargs": "^17.0.33",
"typescript": "catalog:"
}
+132 -167
View File
@@ -1,19 +1,16 @@
// Export of item icons and locale
import fs from "fs-extra";
import path from "path";
import Jimp from "jimp";
import { Jimp, JimpMime, type JimpInstance } 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";
import { definesPrototypesFallback } from "./export_fallback";
interface Prototype {
[index: string]: unknown,
type: string,
name: string,
}
interface SimpleIconSpecification {
icons?: never;
icon: string;
icon_size?: number;
icon_mipmaps?: number;
@@ -32,11 +29,9 @@ interface LayeredIconSpecification {
icon_mipmaps?: number;
}
type IconSpecification = LayeredIconSpecification | SimpleIconSpecification;
type ItemPrototype = Prototype & IconSpecification;
type PrototypeWithIcon = lib.FactorioPrototypeBase & IconSpecification;
type Prototypes = Record<string, Record<string, Prototype>>
/**
* Generate the export mod needed for exportData
*
@@ -69,7 +64,7 @@ async function generateExportMod(server: FactorioServer) {
});
}
let zipCache = new Map();
let zipCache = new Map<string, JSZip>();
async function loadZip(server: FactorioServer, modVersions: Map<string, string>, mod: string) {
let modVersion = modVersions.get(mod);
if (!modVersion) {
@@ -83,7 +78,7 @@ async function loadZip(server: FactorioServer, modVersions: Map<string, string>,
zipCache.set(zipPath, zip);
}
return zip.folder(lib.findRoot(zip));
return zip.folder(lib.findRoot(zip))!;
}
/**
@@ -134,7 +129,8 @@ async function loadFile(server: FactorioServer, modVersions: Map<string, string>
return await file.async("nodebuffer");
}
type IconCache = Map<string, Jimp | null>;
type Image = JimpInstance;
type IconCache = Map<string, Image | null>;
async function loadIcon(
server: FactorioServer,
modVersions: Map<string, string>,
@@ -147,8 +143,8 @@ async function loadIcon(
if (icon === undefined) {
let fileContent = await loadFile(server, modVersions, iconPath);
if (fileContent) {
icon = await Jimp.read(fileContent);
icon.crop(0, 0, iconSize, iconSize);
icon = await Jimp.fromBuffer(fileContent) as Image;
icon.crop({ x: 0, y: 0, w: iconSize, h: iconSize });
iconCache.set(iconPath, icon);
} else {
icon = null;
@@ -184,7 +180,7 @@ async function loadLayeredIcon(
iconCache: IconCache,
) {
let baseLayerSize = (item.icons[0].icon_size || item.icon_size) ?? 64;
let icon = await Jimp.create(size, size);
let icon = new Jimp({ width: size, height: size });
// The scaling factor of the base layer
let baseLayerScale = item.icons[0].scale || 32 / baseLayerSize;
@@ -246,7 +242,7 @@ async function loadLayeredIcon(
return icon;
}
function fixIcons(item: ItemPrototype) {
function fixIcons(item: PrototypeWithIcon) {
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
@@ -258,84 +254,28 @@ function fixIcons(item: ItemPrototype) {
return item;
}
/** Get the primary icon path from a prototype (first layer for layered, .icon for simple). */
function getPrimaryIconPath(item: ItemPrototype): string | undefined {
if (item.icons) {
return (item as LayeredIconSpecification).icons[0]?.icon;
}
return (item as SimpleIconSpecification).icon;
}
const itemTypes = new Set([
const prototypesWithIcon = [
"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",
]);
"recipe",
"virtual-signal",
"technology",
"space-location",
"quality",
"entity",
] as const;
const recipeTypes = new Set(["recipe"]);
const signalTypes = new Set(["virtual-signal"]);
const technologyTypes = new Set(["technology"]);
const planetTypes = new Set(["planet", "space-location"]);
const qualityTypes = new Set(["quality"]);
// Entity types to export. Entities whose name matches an already-exported
const entityTypes = new Set([
"assembling-machine", "furnace", "inserter", "transport-belt", "underground-belt",
"splitter", "loader", "loader-1x1", "mining-drill", "container", "logistic-container",
"electric-pole", "pipe", "pipe-to-ground", "pump", "storage-tank", "boiler",
"generator", "solar-panel", "accumulator", "reactor", "heat-pipe", "lab", "beacon",
"roboport", "lamp", "radar", "wall", "gate", "land-mine", "turret", "ammo-turret",
"electric-turret", "fluid-turret", "artillery-turret", "rocket-silo", "car",
"spider-vehicle", "locomotive", "cargo-wagon", "fluid-wagon", "artillery-wagon",
"train-stop", "rail-signal", "rail-chain-signal", "straight-rail", "curved-rail-a",
"curved-rail-b", "half-diagonal-rail", "elevated-straight-rail", "elevated-curved-rail-a",
"elevated-curved-rail-b", "elevated-half-diagonal-rail", "rail-ramp", "rail-support",
"space-platform-hub", "cargo-bay", "asteroid-collector", "thruster", "cargo-pod",
"cargo-landing-pad", "asteroid",
"construction-robot", "logistic-robot", "combat-robot", "capture-robot",
"unit", "unit-spawner", "spider-unit", "segmented-unit",
"tree", "plant", "fish", "resource", "cliff", "simple-entity",
"simple-entity-with-owner", "simple-entity-with-force",
"arithmetic-combinator", "decider-combinator", "constant-combinator",
"selector-combinator", "programmable-speaker", "power-switch", "display-panel",
"character",
"offshore-pump", "valve", "lightning-attractor",
"fusion-generator", "fusion-reactor", "burner-generator",
"agricultural-tower", "market", "lane-splitter",
]);
function filterPrototypes(prototypes: Prototypes, allowedTypes: Set<string>): ItemPrototype[] {
function filterAndFixPrototypesWithIcons(prototypes: lib.FactorioPrototypes, allowedTypes: Set<string>) {
return Object.entries(prototypes)
.filter(([type]) => allowedTypes.has(type))
.flatMap(([_, typePrototypes]) => Object.values(typePrototypes) as ItemPrototype[])
.flatMap(([_, typePrototypes]) => Object.values(typePrototypes) as PrototypeWithIcon[])
.map(fixIcons);
}
/** Shared state for building a unified spritesheet across all categories. */
interface SheetState {
sheet: Jimp;
metadata: Map<string, { x: number, y: number, size: number, category: string, path?: string }>;
sheet: Image;
metadata: Record<string, lib.ExportMetadataEntry[]>;
iconCache: IconCache;
simpleIcons: Map<string, number>;
pos: number;
@@ -346,8 +286,8 @@ const SHEET_WIDTH = 1024;
async function createSheetState(): Promise<SheetState> {
return {
sheet: await Jimp.create(SHEET_WIDTH, SHEET_ICON_SIZE),
metadata: new Map(),
sheet: new Jimp({ width: SHEET_WIDTH, height: SHEET_ICON_SIZE }),
metadata: Object.create(null),
iconCache: new Map(),
simpleIcons: new Map(),
pos: 0,
@@ -359,79 +299,98 @@ async function growSheet(state: SheetState) {
const row = Math.floor(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE));
const neededHeight = (row + 1) * SHEET_ICON_SIZE;
if (neededHeight > state.sheet.bitmap.height) {
const extended = await Jimp.create(SHEET_WIDTH, neededHeight);
const extended = new Jimp({ width: SHEET_WIDTH, height: neededHeight });
extended.composite(state.sheet, 0, 0);
(state.sheet as any).bitmap = (extended as any).bitmap;
}
}
async function exportIcon(
server: FactorioServer,
modVersions: Map<string, string>,
state: SheetState,
prototype: PrototypeWithIcon,
) {
let icon: Image | null = null;
let iconPos: number | undefined;
let iconData: lib.ExportMetadataEntry["icon"];
let iconPath: string | undefined;
if (prototype.icons) {
icon = await loadLayeredIcon(
server, modVersions, prototype, SHEET_ICON_SIZE, state.iconCache,
);
iconPos = state.pos;
iconPath = prototype.icons[0].icon;
} else if (prototype.icon) {
iconPos = state.simpleIcons.get(prototype.icon);
iconPath = prototype.icon;
if (iconPos === undefined) {
icon = await loadSimpleIcon(
server, modVersions, prototype, SHEET_ICON_SIZE, state.iconCache,
);
if (icon) {
iconPos = state.pos;
state.simpleIcons.set(prototype.icon, state.pos);
}
}
}
if (iconPos !== undefined) {
iconData = {
x: iconPos * SHEET_ICON_SIZE % SHEET_WIDTH,
y: Math.floor(iconPos / (SHEET_WIDTH / SHEET_ICON_SIZE)) * SHEET_ICON_SIZE,
size: SHEET_ICON_SIZE,
path: iconPath,
};
}
if (icon) {
await growSheet(state);
state.sheet.composite(
icon,
state.pos * SHEET_ICON_SIZE % SHEET_WIDTH,
Math.floor(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE)) * SHEET_ICON_SIZE,
);
state.pos += 1;
}
return iconData;
}
/**
* Export item icons for a single category onto the shared spritesheet.
* Export prototype icons for a single base prototype onto the shared spritesheet.
*
* @param server - The server to load icons from.
* @param modVersions - Mapping of mod name to version.
* @param items - Array of item prototypes to export.
* @param category - Category name for metadata.
* @param prototypes - Array of prototypes with icon to export for this base type.
* @param baseName - name of base type (used in metadata).
* @param state - Shared spritesheet state (mutated in place).
* @returns Number of icons exported for this category.
* @returns Number of icons exported for this base type.
* @internal
*/
async function exportItems(
async function exportIcons(
server: FactorioServer,
modVersions: Map<string, string>,
items: ItemPrototype[],
category: string,
prototypes: PrototypeWithIcon[],
baseName: string,
state: SheetState,
) {
const metadata = state.metadata[baseName] ?? [];
state.metadata[baseName] ??= metadata;
let count = 0;
for (let item of items) {
// Skip prototypes with no icon data at all
if (!item.icons && !item.icon) {
continue;
}
let icon: Jimp | null = null;
let iconPos: number | undefined;
if (item.icons) {
icon = await loadLayeredIcon(
server, modVersions, item as LayeredIconSpecification, SHEET_ICON_SIZE, state.iconCache,
);
iconPos = state.pos;
} else {
iconPos = state.simpleIcons.get(item.icon as string);
if (iconPos === undefined) {
icon = await loadSimpleIcon(
server, modVersions, item as SimpleIconSpecification, SHEET_ICON_SIZE, state.iconCache,
);
if (icon) {
iconPos = state.pos;
state.simpleIcons.set(item.icon as string, state.pos);
}
}
}
if (iconPos !== undefined) {
const iconPath = getPrimaryIconPath(item);
state.metadata.set(`${category}.${item.name}`, {
x: iconPos * SHEET_ICON_SIZE % SHEET_WIDTH,
y: Math.floor(iconPos / (SHEET_WIDTH / SHEET_ICON_SIZE)) * SHEET_ICON_SIZE,
size: SHEET_ICON_SIZE,
category,
...(iconPath ? { path: iconPath } : {}),
});
}
for (let prototype of prototypes) {
const icon = await exportIcon(server, modVersions, state, prototype);
if (icon) {
await growSheet(state);
state.sheet.composite(
icon,
state.pos * SHEET_ICON_SIZE % SHEET_WIDTH,
Math.floor(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE)) * SHEET_ICON_SIZE,
);
state.pos += 1;
count += 1;
}
metadata.push({
name: prototype.name,
type: prototype.type,
localised_name: prototype.localised_name,
icon,
});
}
return count;
@@ -505,7 +464,7 @@ async function exportLocale(
}
/**
* Export the locale and item icons for the given factorio server
* Export locale, icons and metadata for the given factorio server
*
* @param server - The server to export the data from.
* @returns zip file with exported data.
@@ -513,12 +472,13 @@ async function exportLocale(
export async function exportData(server: FactorioServer) {
await generateExportMod(server);
let settings: Prototypes = {};
let prototypes: Prototypes = {};
let settings: lib.ExportSettings = {};
let prototypes: lib.ExportPrototypes = {};
let modVersions = new Map();
let modOrder: string[] = [];
let defines: lib.FactorioDefines | undefined;
function add(obj: Prototypes, prototype: Prototype) {
function add(obj: lib.FactorioPrototypes, prototype: lib.FactorioPrototypeBase) {
if (!Object.prototype.hasOwnProperty.call(obj, prototype.type)) {
obj[prototype.type] = {};
}
@@ -537,6 +497,7 @@ export async function exportData(server: FactorioServer) {
server._logger.error(`Unable to find ${name} in settings prototypes`);
});
server.on("ipc-mod_list", data => { modVersions = new Map(Object.entries(data)); });
server.on("ipc-defines", data => { defines = data; });
server.on("output", parsed => {
if (parsed.format === "seconds" && parsed.type === "generic") {
let match = /^Checksum of (.*): \d+$/.exec(parsed.message);
@@ -557,6 +518,15 @@ export async function exportData(server: FactorioServer) {
throw new Error("No prototypes got exported");
}
if (!defines) {
throw new Error("No defines got exported");
}
// compat with versions prior to 1.1.31
if (!defines.prototypes) {
defines.prototypes = definesPrototypesFallback;
}
// 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"))) {
@@ -579,45 +549,40 @@ export async function exportData(server: FactorioServer) {
}
}
const categories: { name: string, types: Set<string> }[] = [
{ name: "item", types: itemTypes },
{ name: "recipe", types: recipeTypes },
{ name: "signal", types: signalTypes },
{ name: "technology", types: technologyTypes },
{ name: "planet", types: planetTypes },
{ name: "quality", types: qualityTypes },
{ name: "entity", types: entityTypes },
];
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/locale.json", JSON.stringify([...locale.entries()]));
zip.file("export/settings.json", JSON.stringify(settings satisfies lib.ExportSettings));
zip.file("export/prototypes.json", JSON.stringify(prototypes satisfies lib.ExportPrototypes));
zip.file("export/locale.json", JSON.stringify([...locale.entries()] satisfies lib.ExportLocale));
zip.file("export/defines.json", JSON.stringify(defines satisfies lib.ExportDefines));
// Build a single unified spritesheet across all prototype categories.
// Build a single unified spritesheet across all prototypes with icons.
const state = await createSheetState();
for (const category of categories) {
const categoryItems = filterPrototypes(prototypes, category.types);
if (categoryItems.length === 0) {
for (const baseName of prototypesWithIcon) {
if (!Object.prototype.hasOwnProperty.call(defines.prototypes, baseName)) {
continue;
}
const count = await exportItems(server, modVersions, categoryItems, category.name, state);
server._logger.info(`Exported ${count} ${category.name} icons`);
const subTypeNames = new Set(Object.keys(defines.prototypes[baseName]));
const prototypesWithIcons = filterAndFixPrototypesWithIcons(prototypes, subTypeNames);
if (prototypesWithIcons.length === 0) {
continue;
}
const count = await exportIcons(server, modVersions, prototypesWithIcons, baseName, state);
server._logger.info(`Exported ${count} ${baseName} icons`);
}
// Crop sheet to actual used height and write single spritesheet + metadata.
const usedRows = Math.ceil(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE));
state.sheet.crop(0, 0, SHEET_WIDTH, Math.max(usedRows * SHEET_ICON_SIZE, 1));
zip.file("export/spritesheet.png", await state.sheet.getBufferAsync(Jimp.MIME_PNG));
zip.file("export/metadata.json", JSON.stringify([...state.metadata.entries()]));
state.sheet.crop({ x: 0, y: 0, w: SHEET_WIDTH, h: Math.max(usedRows * SHEET_ICON_SIZE, 1) });
zip.file("export/spritesheet.png", await state.sheet.getBuffer(JimpMime.png));
zip.file("export/metadata.json", JSON.stringify(state.metadata satisfies lib.ExportMetadata));
server._logger.info(`Export complete: ${state.metadata.size} icons on ${usedRows} row(s)`);
server._logger.info(`Export complete: ${state.pos} icons on ${usedRows} row(s)`);
return zip;
}
+294
View File
@@ -0,0 +1,294 @@
// defines.prototypes from Factorio 1.1.31, the first version that has this.
export const definesPrototypesFallback = {
"achievement": {
"achievement": 0,
"build-entity-achievement": 0,
"combat-robot-count": 0,
"construct-with-robots-achievement": 0,
"deconstruct-with-robots-achievement": 0,
"deliver-by-robots-achievement": 0,
"dont-build-entity-achievement": 0,
"dont-craft-manually-achievement": 0,
"dont-use-entity-in-energy-production-achievement": 0,
"finish-the-game-achievement": 0,
"group-attack-achievement": 0,
"kill-achievement": 0,
"player-damaged-achievement": 0,
"produce-achievement": 0,
"produce-per-hour-achievement": 0,
"research-achievement": 0,
"train-path-achievement": 0,
},
"ambient-sound": {
"ambient-sound": 0,
},
"ammo-category": {
"ammo-category": 0,
},
"animation": {
"animation": 0,
},
"autoplace-control": {
"autoplace-control": 0,
},
"custom-input": {
"custom-input": 0,
},
"damage-type": {
"damage-type": 0,
},
"decorative": {
"optimized-decorative": 0,
},
"editor-controller": {
"editor-controller": 0,
},
"entity": {
"accumulator": 0,
"ammo-turret": 0,
"arithmetic-combinator": 0,
"arrow": 0,
"artillery-flare": 0,
"artillery-projectile": 0,
"artillery-turret": 0,
"artillery-wagon": 0,
"assembling-machine": 0,
"beacon": 0,
"beam": 0,
"boiler": 0,
"burner-generator": 0,
"car": 0,
"cargo-wagon": 0,
"character": 0,
"character-corpse": 0,
"cliff": 0,
"combat-robot": 0,
"constant-combinator": 0,
"construction-robot": 0,
"container": 0,
"corpse": 0,
"curved-rail": 0,
"decider-combinator": 0,
"deconstructible-tile-proxy": 0,
"electric-energy-interface": 0,
"electric-pole": 0,
"electric-turret": 0,
"entity-ghost": 0,
"explosion": 0,
"fire": 0,
"fish": 0,
"flame-thrower-explosion": 0,
"fluid-turret": 0,
"fluid-wagon": 0,
"flying-text": 0,
"furnace": 0,
"gate": 0,
"generator": 0,
"heat-interface": 0,
"heat-pipe": 0,
"highlight-box": 0,
"infinity-container": 0,
"infinity-pipe": 0,
"inserter": 0,
"item-entity": 0,
"item-request-proxy": 0,
"lab": 0,
"lamp": 0,
"land-mine": 0,
"leaf-particle": 0,
"linked-belt": 0,
"linked-container": 0,
"loader": 0,
"loader-1x1": 0,
"locomotive": 0,
"logistic-container": 0,
"logistic-robot": 0,
"market": 0,
"mining-drill": 0,
"offshore-pump": 0,
"particle": 0,
"particle-source": 0,
"pipe": 0,
"pipe-to-ground": 0,
"player-port": 0,
"power-switch": 0,
"programmable-speaker": 0,
"projectile": 0,
"pump": 0,
"radar": 0,
"rail-chain-signal": 0,
"rail-remnants": 0,
"rail-signal": 0,
"reactor": 0,
"resource": 0,
"roboport": 0,
"rocket-silo": 0,
"rocket-silo-rocket": 0,
"rocket-silo-rocket-shadow": 0,
"simple-entity": 0,
"simple-entity-with-force": 0,
"simple-entity-with-owner": 0,
"smoke": 0,
"smoke-with-trigger": 0,
"solar-panel": 0,
"speech-bubble": 0,
"spider-leg": 0,
"spider-vehicle": 0,
"splitter": 0,
"sticker": 0,
"storage-tank": 0,
"straight-rail": 0,
"stream": 0,
"tile-ghost": 0,
"train-stop": 0,
"transport-belt": 0,
"tree": 0,
"turret": 0,
"underground-belt": 0,
"unit": 0,
"unit-spawner": 0,
"wall": 0,
},
"equipment": {
"active-defense-equipment": 0,
"battery-equipment": 0,
"belt-immunity-equipment": 0,
"energy-shield-equipment": 0,
"generator-equipment": 0,
"movement-bonus-equipment": 0,
"night-vision-equipment": 0,
"roboport-equipment": 0,
"solar-panel-equipment": 0,
},
"equipment-category": {
"equipment-category": 0,
},
"equipment-grid": {
"equipment-grid": 0,
},
"fluid": {
"fluid": 0,
},
"font": {
"font": 0,
},
"fuel-category": {
"fuel-category": 0,
},
"god-controller": {
"god-controller": 0,
},
"gui-style": {
"gui-style": 0,
},
"item": {
"ammo": 0,
"armor": 0,
"blueprint": 0,
"blueprint-book": 0,
"capsule": 0,
"copy-paste-tool": 0,
"deconstruction-item": 0,
"gun": 0,
"item": 0,
"item-with-entity-data": 0,
"item-with-inventory": 0,
"item-with-label": 0,
"item-with-tags": 0,
"mining-tool": 0,
"module": 0,
"rail-planner": 0,
"repair-tool": 0,
"selection-tool": 0,
"spidertron-remote": 0,
"tool": 0,
"upgrade-item": 0,
},
"item-group": {
"item-group": 0,
},
"item-subgroup": {
"item-subgroup": 0,
},
"map-gen-presets": {
"map-gen-presets": 0,
},
"map-settings": {
"map-settings": 0,
},
"module-category": {
"module-category": 0,
},
"mouse-cursor": {
"mouse-cursor": 0,
},
"noise-expression": {
"noise-expression": 0,
},
"noise-layer": {
"noise-layer": 0,
},
"particle": {
"optimized-particle": 0,
},
"recipe": {
"recipe": 0,
},
"recipe-category": {
"recipe-category": 0,
},
"resource-category": {
"resource-category": 0,
},
"shortcut": {
"shortcut": 0,
},
"sound": {
"sound": 0,
},
"spectator-controller": {
"spectator-controller": 0,
},
"sprite": {
"sprite": 0,
},
"technology": {
"technology": 0,
},
"tile": {
"tile": 0,
},
"tile-effect": {
"tile-effect": 0,
},
"tips-and-tricks-item": {
"tips-and-tricks-item": 0,
},
"tips-and-tricks-item-category": {
"tips-and-tricks-item-category": 0,
},
"trigger-target-type": {
"trigger-target-type": 0,
},
"trivial-smoke": {
"trivial-smoke": 0,
},
"tutorial": {
"tutorial": 0,
},
"utility-constants": {
"utility-constants": 0,
},
"utility-sounds": {
"utility-sounds": 0,
},
"utility-sprites": {
"utility-sprites": 0,
},
"virtual-signal": {
"virtual-signal": 0,
},
"wind-sound": {
"wind-sound": 0,
},
};
+2
View File
@@ -2,6 +2,7 @@ export * from "./src/api";
export * from "./src/config";
export * from "./src/data";
export * from "./src/errors";
export * from "./src/export";
export * from "./src/factorio/exchange_string";
export * from "./src/helpers";
export * from "./src/ini";
@@ -16,6 +17,7 @@ export * from "./src/subscriptions";
export { default as ExponentialBackoff } from "./src/ExponentialBackoff";
export { default as RateLimiter } from "./src/RateLimiter";
export { default as ValueCache } from "./src/ValueCache";
export { default as isDeepStrictEqual } from "./src/is_deep_strict_equal";
import { checkSingletonImport } from "./src/check_singleton_import";
checkSingletonImport(import.meta.url);
+2
View File
@@ -9,6 +9,7 @@ export * from "./src/config";
export * from "./src/data";
export * from "./src/database";
export * from "./src/errors";
export * from "./src/export";
export * from "./src/external";
export * from "./src/factorio";
export * from "./src/file_ops";
@@ -38,6 +39,7 @@ export { default as ExponentialBackoff } from "./src/ExponentialBackoff";
export { default as ModStore } from "./src/ModStore";
export { default as RateLimiter } from "./src/RateLimiter";
export { default as ValueCache } from "./src/ValueCache";
export { default as isDeepStrictEqual } from "./src/is_deep_strict_equal";
import { checkSingletonImport } from "./src/check_singleton_import";
checkSingletonImport(__filename);
+4 -3
View File
@@ -5,6 +5,7 @@ import * as libSchema from "../schema";
import * as libLuaTools from "../lua_tools";
import ExportManifest from "./ExportManifest";
import type { ExportSettings } from "../export";
import type { Logger } from "../logging";
import ModInfo, { ModDependencyUnsatisfiedReason } from "./ModInfo";
@@ -357,7 +358,7 @@ export default class ModPack {
* Setting prototypes exported from the game.
* @param logger - Logger used to report warnings on.
*/
fillDefaultSettings(settingPrototypes: Record<string, object>, logger: Logger) {
fillDefaultSettings(settingPrototypes: ExportSettings, logger: Logger) {
const knownTypes = ["bool-setting", "int-setting", "double-setting", "string-setting", "color-setting"];
let prototypes = Object.entries(settingPrototypes)
.filter(([type, _]) => knownTypes.includes(type))
@@ -370,7 +371,7 @@ export default class ModPack {
logger.warn(`Ignoring ${prototype.name} with unknown setting_type ${settingType}`);
continue;
}
if (prototype.default_value === undefined) {
if ((prototype as any).default_value === undefined) {
logger.warn(`Ignoring ${prototype.name} with missing default_value`);
continue;
}
@@ -389,7 +390,7 @@ export default class ModPack {
if (type === "color-setting") {
value = libLuaTools.normalizeColor(value as object);
}
this.settings[settingType].set(prototype.name, { value });
this.settings[settingType].set(prototype.name, { value } as ModSetting);
}
}
+129
View File
@@ -0,0 +1,129 @@
import { FactorioColor } from "./lua_tools";
/**
* A localised string.
* @see [LocalisedString](https://lua-api.factorio.com/stable/types/LocalisedString.html)
*/
export type FactorioLocalisedString = string | [string, ...FactorioLocalisedString[]];
/**
* Shared properties of all prototypes.
* @see [PrototypeBase](https://lua-api.factorio.com/stable/types/PrototypeBase.html)
*/
export interface FactorioPrototypeBase {
[index: string]: unknown,
type: string,
name: string,
order?: string,
localised_name?: FactorioLocalisedString,
localised_description?: FactorioLocalisedString,
factoriopedia_description?: FactorioLocalisedString,
subgroup?: string,
hidden?: boolean,
hidden_in_factoriopedia?: boolean,
parameter?: boolean,
factoriopedia_simulation?: unknown,
}
/**
* Stage the mod setting is part of.
* @see [settings_type property](https://wiki.factorio.com/Tutorial:Mod_settings#The_setting_type_property)
*/
export type FactorioModSettingType = "startup" | "runtime-global" | "runtime-per-user";
/**
* Boolean mod setting prototype.
* @see [bool-setting](https://wiki.factorio.com/Tutorial:Mod_settings#bool-setting)
*/
export interface FactorioBoolSettingPrototype extends FactorioPrototypeBase{
type: "bool-setting",
setting_type?: FactorioModSettingType,
default_value: boolean,
forced_value?: boolean,
}
/**
* Integer mod setting prototype.
* @see [int-setting](https://wiki.factorio.com/Tutorial:Mod_settings#int-setting)
*/
export interface FactorioIntSettingPrototype extends FactorioPrototypeBase{
type: "int-setting",
setting_type?: FactorioModSettingType,
default_value: number,
minimum_value?: number,
maximum_value?: number,
allowed_values?: number[],
}
/**
* Real number mod setting prototype.
* @see [double-setting](https://wiki.factorio.com/Tutorial:Mod_settings#double-setting)
*/
export interface FactorioDoubleSettingPrototype extends FactorioPrototypeBase{
type: "double-setting",
setting_type?: FactorioModSettingType,
default_value: number,
minimum_value?: number,
maximum_value?: number,
allowed_values?: number[],
}
/**
* String mod setting prototype.
* @see [string-setting](https://wiki.factorio.com/Tutorial:Mod_settings#string-setting)
*/
export interface FactorioStringSettingPrototype extends FactorioPrototypeBase{
type: "string-setting",
setting_type?: FactorioModSettingType,
default_value: string,
allow_black?: boolean,
auto_trim?: string,
allowed_values?: string[],
}
/**
* Color mod setting prototype.
* @see [color-setting](https://wiki.factorio.com/Tutorial:Mod_settings#color-setting)
*/
export interface FactorioColorSettingPrototype extends FactorioPrototypeBase{
type: "color-setting",
setting_type?: FactorioModSettingType,
default_value: FactorioColor,
forced_value?: boolean,
}
export type FactorioModSettingPrototype =
| FactorioBoolSettingPrototype
| FactorioIntSettingPrototype
| FactorioDoubleSettingPrototype
| FactorioStringSettingPrototype
| FactorioColorSettingPrototype
;
/**
* A list of prototypes as it appears in Factorio's Lua API.
* @see [Data.raw](https://lua-api.factorio.com/stable/types/Data.html#raw)
*/
export type FactorioPrototypes<Prototype = FactorioPrototypeBase> = Record<string, Record<string, Prototype>>
export type FactorioDefinesValue = number | string | { [index: string]: FactorioDefinesValue };
export type FactorioDefines = {
[index: string]: FactorioDefinesValue,
prototypes: Record<string, Record<string, number>>,
};
export type ExportSettings = FactorioPrototypes<FactorioModSettingPrototype>;
export type ExportPrototypes = FactorioPrototypes;
export type ExportLocale = [string, string][];
export interface ExportMetadataEntry {
name: string,
type: string,
localised_name?: FactorioLocalisedString,
icon?: {
x: number,
y: number,
size: number,
path?: string,
},
}
export type ExportMetadata = Record<string, ExportMetadataEntry[]>;
export type ExportDefines = FactorioDefines;
+1
View File
@@ -10,6 +10,7 @@
"./src/check_singleton_import.ts",
"./src/datastore.ts",
"./src/errors.ts",
"./src/export.ts",
"./src/factorio/exchange_string.ts",
"./src/helpers.ts",
"./src/hash.ts",
@@ -0,0 +1,11 @@
import { PrototypeMetadataEntry } from "../store/export_prototype_metadata_store";
export default function FactorioIcon(props: { modPackId: number | undefined, prototype?: PrototypeMetadataEntry }) {
return <span
className="factorio-icon"
data-mod-pack={props.modPackId}
data-type={props.prototype?.base_type}
data-name={props.prototype?.name}
data-unknown={props.prototype ? undefined : "true"}
/>;
}
@@ -1,109 +1,56 @@
import React, { useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { Card, Input, Space, Table, Tag, Typography } from "antd";
import {
useItemMetadata,
useRecipeMetadata,
useSignalMetadata,
useTechnologyMetadata,
usePlanetMetadata,
useQualityMetadata,
useEntityMetadata,
} from "../model/item_metadata";
import { useExportManifest } from "../model/export_manifest";
import { useExportPrototypeMetadata } from "../model/export_prototype_metadata";
import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import FactorioIcon from "./FactorioIcon";
import { useParams } from "react-router-dom";
import { useModPack } from "../model/mod_pack";
import { PrototypeMetadataEntry } from "../store/export_prototype_metadata_store";
const { Text } = Typography;
type IconEntry = {
key: string;
category: string;
name: string;
cssClass: string;
size: number;
path?: string;
};
function filterPrototypes(
prototypes: Map<string, Map<string, PrototypeMetadataEntry>> | undefined,
filter: string
) {
const query = filter.trim().toLowerCase();
return new Map(
[...prototypes ?? []].map(([baseType, entries]) => [
baseType,
new Map([...entries.entries()].filter(([name, entry]) => (
`${entry.base_type}.${name}`.includes(query)
|| `${entry.type}.${name}`.includes(query)
))),
]),
);
}
const CATEGORIES = [
"item", "recipe", "signal", "technology", "planet", "quality", "entity",
] as const;
type Category = typeof CATEGORIES[number];
const CATEGORY_LABELS: Record<Category, string> = {
item: "Item",
recipe: "Recipe",
signal: "Signal",
technology: "Technology",
planet: "Planet",
quality: "Quality",
entity: "Entity",
};
function useAllIconEntries() {
const item = useItemMetadata();
const recipe = useRecipeMetadata();
const signal = useSignalMetadata();
const technology = useTechnologyMetadata();
const planet = usePlanetMetadata();
const quality = useQualityMetadata();
const entity = useEntityMetadata();
const byCategory: Record<Category, Map<string, { size: number; path?: string }>> = {
item, recipe, signal, technology, planet, quality, entity,
};
return useMemo(() => {
const entries: IconEntry[] = [];
for (const category of CATEGORIES) {
const names = [...byCategory[category].keys()].sort();
for (const name of names) {
const meta = byCategory[category].get(name)!;
entries.push({
key: `${category}:${name}`,
category,
name,
cssClass: `${category}-${CSS.escape(name)}`,
size: meta.size,
path: meta.path,
});
}
}
return entries;
}, [item, recipe, signal, technology, planet, quality, entity]);
function countPrototypes(
prototypes: Map<string, Map<string, PrototypeMetadataEntry>> | undefined,
) {
return prototypes ? [...prototypes.values()].reduce((a, e) => a + e.size, 0) : 0;
}
export default function IconReferencePage() {
let params = useParams();
let modPackId = Number(params.id);
const [filter, setFilter] = useState("");
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const allEntries = useAllIconEntries();
const [modPack] = useModPack(modPackId);
const manifest = modPack?.exportManifest;
const prototypes = useExportPrototypeMetadata(modPack);
const filtered = useMemo(() => filterPrototypes(prototypes, filter), [prototypes, filter]);
const groups = useMemo(() => (
[...filtered]
.map(([baseType, entries]) => ({ baseType, entries: [...entries.values()] }))
.filter(group => group.entries.length)
), [filtered]);
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) {
return allEntries;
}
return allEntries.filter(e => e.name.toLowerCase().includes(q) || e.cssClass.toLowerCase().includes(q)
);
}, [allEntries, filter]);
const groups = useMemo(() => {
const map = new Map<string, IconEntry[]>();
for (const cat of CATEGORIES) {
map.set(cat, []);
}
for (const entry of filtered) {
map.get(entry.category)!.push(entry);
}
return CATEGORIES.map(cat => ({ category: cat, entries: map.get(cat)! }))
.filter(g => g.entries.length > 0);
}, [filtered]);
const visibleCount = filtered.length;
const totalCount = allEntries.length;
const visibleCount = countPrototypes(filtered);
const totalCount = countPrototypes(prototypes);
const autoExpand = visibleCount <= 20;
const isExpanded = (category: string) => autoExpand || expandedGroups.has(category);
@@ -123,11 +70,10 @@ export default function IconReferencePage() {
const columns = [
{
title: "Icon",
dataIndex: "cssClass",
key: "icon",
width: 48,
render: (_: string, record: IconEntry) => (
<div className={record.cssClass} style={{ imageRendering: "pixelated" }} />
render: (prototype: PrototypeMetadataEntry) => (
<FactorioIcon modPackId={modPackId} prototype={prototype}/>
),
},
{
@@ -138,19 +84,9 @@ export default function IconReferencePage() {
<Text style={{ fontSize: 12 }}>{name}</Text>
),
},
{
title: "Usage",
dataIndex: "cssClass",
key: "usage",
render: (cssClass: string) => (
<Text code style={{ fontSize: 11, color: "#90ee90" }}>
{`<div class="${cssClass}" />`}
</Text>
),
},
{
title: "Path",
dataIndex: "path",
dataIndex: ["icon", "path"],
key: "path",
render: (p: string | undefined) => (p
? <Text type="secondary" style={{ fontSize: 11 }}>{p}</Text>
@@ -158,13 +94,17 @@ export default function IconReferencePage() {
},
];
const manifest = useExportManifest();
let nav = [
{ name: "Mods", path: "/mods" },
{ name: "Mod Packs" },
{ name: modPack?.name ?? String(modPackId), path: `/mods/mod-packs/${modPackId}/view` },
{ name: "Icon Reference" },
];
return (
<PageLayout nav={[{ name: "Icon Reference" }]}>
<PageLayout nav={nav}>
<PageHeader title="Icon Reference" />
<div style={{ width: "80%", margin: "0 auto" }}>
{!manifest && (
{!modPack?.exportManifest && (
<Card>
<Text type="secondary">
No export data available. Run export-data on an instance to generate icons.
@@ -180,7 +120,7 @@ export default function IconReferencePage() {
</div>
)}
<div>
<Text type="secondary">Icons: </Text>
<Text type="secondary">Prototypes: </Text>
<Text>{totalCount}</Text>
</div>
</div>
@@ -188,7 +128,7 @@ export default function IconReferencePage() {
<Card>
<Space style={{ marginBottom: 16, display: "flex", alignItems: "center" }}>
<Input
placeholder="Filter by name or class…"
placeholder="Filter by type or name…"
value={filter}
onChange={e => setFilter(e.target.value)}
allowClear
@@ -200,13 +140,12 @@ export default function IconReferencePage() {
: `${visibleCount} / ${totalCount} icons`}
</Text>
</Space>
{groups.map(({ category, entries }) => {
const label = CATEGORY_LABELS[category as Category] ?? category;
const expanded = isExpanded(category);
{groups.map(({ baseType, entries }) => {
const expanded = isExpanded(baseType);
return (
<div key={category} style={{ marginBottom: 8 }}>
<div key={baseType} style={{ marginBottom: 8 }}>
<div
onClick={() => toggleGroup(category)}
onClick={() => toggleGroup(baseType)}
style={{
display: "flex",
alignItems: "center",
@@ -222,13 +161,13 @@ export default function IconReferencePage() {
<span style={{ fontSize: 10, color: "#888", width: 12 }}>
{expanded ? "▾" : "►"}
</span>
<Tag style={{ margin: 0 }}>{label}</Tag>
<Tag style={{ margin: 0 }}>{baseType}</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>
{entries.length}
</Text>
</div>
{expanded && (
<Table<IconEntry>
<Table<PrototypeMetadataEntry>
columns={columns}
dataSource={entries}
rowKey="key"
@@ -27,6 +27,7 @@ import PageLayout from "./PageLayout";
import SectionHeader from "./SectionHeader";
import ModDetails from "./ModDetails";
import InputPartialVersion from "./InputPartialVersion";
import Link from "./Link";
const { logger } = lib;
const { Text } = Typography;
@@ -1334,6 +1335,12 @@ export default function ModPackViewPage() {
prototypes={prototypes}
locale={locale}
/>
<Typography.Title level={4}>
Inspect exported data
</Typography.Title>
<Link to={`/mods/mod-packs/${modPackId}/icon-reference`}>
Icon reference
</Link>
</Form>
<div
+16 -4
View File
@@ -4,11 +4,21 @@ export { default as notify, notifyErrorHandler } from "./util/notify";
export { default as BaseWebPlugin } from "./BaseWebPlugin";
export * from "./util/websocket";
export * from "./model/instance";
export * from "./model/host";
export * from "./model/locale";
export * from "./model/item_metadata";
export * from "./model/account";
export * from "./model/export_locale";
export * from "./model/export_prototype_metadata";
export * from "./model/host";
export * from "./model/instance";
export * from "./model/is_dragging";
export * from "./model/mod_pack";
export * from "./model/mods";
export * from "./model/roles";
export * from "./model/saves";
export * from "./model/system";
export * from "./model/user";
export * from "./store/export_locale_stores";
export * from "./store/export_prototype_metadata_store";
export { default as ControlContext } from "./components/ControlContext";
@@ -30,3 +40,5 @@ export { default as SavesList } from "./components/SavesList";
export { default as SectionHeader } from "./components/SectionHeader";
export { default as Link } from "./components/Link";
export { default as FactorioIcon } from "./components/FactorioIcon";
@@ -0,0 +1,13 @@
import type { ModPack } from "@clusterio/lib";
import { useCallback, useSyncExternalStore } from "react";
import { exportLocaleStore } from "../store/export_locale_stores";
export function useExportLocale(modPack: ModPack | undefined) {
const localePath = modPack?.exportManifest?.assets["locale"];
const subscribe = useCallback(
(callback: () => void) => exportLocaleStore.subscribe(modPack?.id, localePath, callback),
[modPack?.id, localePath],
);
return useSyncExternalStore(subscribe, () => exportLocaleStore.getSnapshot(modPack?.id));
}
@@ -1,27 +0,0 @@
import { useEffect, useContext, useState } from "react";
import * as lib from "@clusterio/lib";
import ControlContext from "../components/ControlContext";
let exportManifestCache: lib.ExportManifest|null = null;
let loaded = false;
export function useExportManifest(): lib.ExportManifest|null {
const control = useContext(ControlContext);
let [exportManifest, setExportManifest] = useState<lib.ExportManifest|null>(exportManifestCache);
useEffect(() => {
async function load() {
let modPack = await control.send(new lib.ModPackGetDefaultRequest());
if (modPack.exportManifest) {
exportManifestCache = modPack.exportManifest;
setExportManifest(exportManifestCache);
}
loaded = true;
}
if (!loaded) {
load();
}
}, []);
return exportManifest;
}
@@ -0,0 +1,17 @@
import { useCallback, useSyncExternalStore } from "react";
import { ModPack } from "@clusterio/lib";
import { exportPrototypeMetadataStore } from "../store/export_prototype_metadata_store";
/**
* Load the unified spritesheet metadata and inject CSS rules for every icon.
* Required for {@link FactorioIcon}.
*/
export function useExportPrototypeMetadata(modPack: ModPack | undefined) {
const subscribe = useCallback(
(callback: () => void) => exportPrototypeMetadataStore.subscribe(modPack, callback),
[modPack]
);
return useSyncExternalStore(subscribe, () => exportPrototypeMetadataStore.getSnapshot(modPack?.id));
}
@@ -1,90 +0,0 @@
import { useEffect, useState } from "react";
import { useExportManifest } from "./export_manifest";
type Metadata = {
x: number;
y: number;
size: number;
category: string;
localised_name?: any[];
path?: string;
};
const metadataCaches = new Map<string, Map<string, Metadata>>();
let cssInjected = false;
/**
* Load the unified spritesheet metadata and inject CSS rules for every icon.
* CSS class name: `.{category}-{CSS.escape(name)}`
* e.g. `.item-iron-plate`, `.recipe-iron-plate`, `.signal-signal-red`, `.planet-nauvis`
*/
function useSpriteMetadata(category: string): Map<string, Metadata> {
const exportManifest = useExportManifest();
const [metadata, setMetadata] = useState<Map<string, Metadata>>(
metadataCaches.get(category) ?? new Map()
);
useEffect(() => {
async function load() {
if (
!exportManifest
|| !exportManifest.assets["metadata"]
|| !exportManifest.assets["spritesheet"]
) {
return;
}
const response = await fetch(`${staticRoot}static/${exportManifest.assets["metadata"]}`);
if (!response.ok) {
return;
}
const data: [string, Metadata][] = await response.json();
if (!cssInjected) {
const sheetUrl = `${staticRoot}static/${exportManifest.assets["spritesheet"]}`;
const style = document.createElement("style");
document.head.appendChild(style);
for (const [key, meta] of data) {
const name = key.includes(".") ? key.slice(key.indexOf(".") + 1) : key;
style.sheet!.insertRule(`\
.${meta.category}-${CSS.escape(name)} {
background-image: url("${sheetUrl}");
background-repeat: no-repeat;
background-position: -${meta.x}px -${meta.y}px;
height: ${meta.size}px;
width: ${meta.size}px;
}`
);
}
cssInjected = true;
}
const cache = new Map<string, Metadata>();
for (const [key, meta] of data) {
if (meta.category === category) {
const name = key.includes(".") ? key.slice(key.indexOf(".") + 1) : key;
cache.set(name, meta);
}
}
metadataCaches.set(category, cache);
setMetadata(cache);
}
if (!metadataCaches.has(category)) {
load();
}
}, [exportManifest]);
return metadata;
}
export function useItemMetadata() { return useSpriteMetadata("item"); }
export function useRecipeMetadata() { return useSpriteMetadata("recipe"); }
export function useSignalMetadata() { return useSpriteMetadata("signal"); }
export function useTechnologyMetadata() { return useSpriteMetadata("technology"); }
export function usePlanetMetadata() { return useSpriteMetadata("planet"); }
export function useQualityMetadata() { return useSpriteMetadata("quality"); }
export function useEntityMetadata() { return useSpriteMetadata("entity"); }
-29
View File
@@ -1,29 +0,0 @@
import React, { useEffect, useState } from "react";
import { useExportManifest } from "./export_manifest";
let localeCache: Map<string, string>|null = null;
export function useLocale(): Map<string, string> {
let exportManifest = useExportManifest();
let [locale, setLocale] = useState<Map<string, string>>(localeCache || new Map());
useEffect(() => {
async function load() {
if (!exportManifest?.assets["locale"]) {
return;
}
let response = await fetch(`${staticRoot}static/${exportManifest.assets["locale"]}`);
if (response.ok) {
let data = await response.json();
localeCache = new Map(data);
setLocale(localeCache);
}
}
if (!localeCache) {
load();
}
}, [exportManifest]);
return locale;
}
+10
View File
@@ -1,5 +1,7 @@
import { useCallback, useContext, useSyncExternalStore } from "react";
import ControlContext from "../components/ControlContext";
import { defaultModPackStore } from "../store/default_mod_pack_store";
export function useModPack(id?: number) {
const [modPacks, synced] = useModPacks();
@@ -11,3 +13,11 @@ export function useModPacks() {
const subscribe = useCallback((callback: () => void) => control.modPacks.subscribe(callback), [control]);
return useSyncExternalStore(subscribe, () => control.modPacks.getSnapshot());
}
export function useDefaultModPack() {
const control = useContext(ControlContext);
const subscribe = useCallback(
(callback: () => void) => defaultModPackStore.subscribe(control, callback), [control],
);
return useSyncExternalStore(subscribe, () => defaultModPackStore.getSnapshot());
}
+7 -4
View File
@@ -18,6 +18,8 @@ import PluginViewPage from "./components/PluginViewPage";
import ErrorPage from "./components/ErrorPage";
import IconReferencePage from "./components/IconReferencePage";
// When adding or editing page paths here the corresponding webRoutes entry
// in packages/controller/src/routes.ts also needs to be updated.
export const pages: PluginPage[] = [
{
path: "/controller",
@@ -57,6 +59,11 @@ export const pages: PluginPage[] = [
sidebarPath: "/mods",
content: <ModPackViewPage />,
},
{
path: "/mods/mod-packs/:id/icon-reference",
sidebarPath: "/mods",
content: <IconReferencePage />,
},
{
path: "/users",
sidebarName: "Users",
@@ -89,10 +96,6 @@ export const pages: PluginPage[] = [
sidebarPath: "/plugins",
content: <PluginViewPage />,
},
{
path: "/icon-reference",
content: <IconReferencePage />,
},
{
path: "/error",
content: <ErrorPage throw error={new Error("Example Error Message")} />,
@@ -0,0 +1,51 @@
import { ModPackGetDefaultRequest, type ModPack, isDeepStrictEqual } from "@clusterio/lib";
import type { Control } from "../util/websocket";
// TODO: replace this super duper expensive method with listening to a default mod pack id event.
class DefaultModPackStore {
interval: undefined | ReturnType<typeof setInterval>;
modPack: undefined | ModPack;
callbacks: (() => void)[] = [];
fetchDefaultModPack(control: Control) {
control.send(new ModPackGetDefaultRequest()).then(modPack => {
if (!isDeepStrictEqual(this.modPack, modPack)) {
this.modPack = modPack;
for (const callback of this.callbacks) {
callback();
}
}
}).catch(() => {
if (this.modPack !== undefined) {
this.modPack = undefined;
for (const callback of this.callbacks) {
callback();
}
}
});
}
subscribe(control: Control, callback: () => void) {
if (this.callbacks.length === 0) {
const update = () => { this.fetchDefaultModPack(control); };
update();
this.interval = setInterval(update, 60e3);
}
this.callbacks.push(callback);
return () => {
const index = this.callbacks.indexOf(callback);
if (index !== -1) {
this.callbacks.splice(index, 1);
}
if (this.callbacks.length === 0) {
clearInterval(this.interval);
this.interval = undefined;
}
};
}
getSnapshot() {
return this.modPack;
}
};
export const defaultModPackStore = new DefaultModPackStore();
@@ -0,0 +1,72 @@
import { ExportLocale, logger } from "@clusterio/lib";
import notify from "../util/notify";
interface ExportLocaleCacheEntry {
callbacks: (() => void)[];
localePath: string | undefined;
data: Map<string, string>;
}
class ExportLocaleStore {
cache = new Map<number, ExportLocaleCacheEntry>();
_emptyMap = new Map();
async loadLocale(modPackId: number, cacheEntry: ExportLocaleCacheEntry) {
if (!cacheEntry.localePath) {
cacheEntry.data = new Map();
for (const callback of cacheEntry.callbacks) {
callback();
}
return;
}
let response = await fetch(`${staticRoot}static/${cacheEntry.localePath}`);
if (response.ok) {
cacheEntry.data = new Map(await response.json() as ExportLocale);
for (const callback of cacheEntry.callbacks) {
callback();
}
} else {
notify(
`Failed to locale for mod pack ${modPackId}, server returned: ` +
`${response.status} ${response.statusText}`
);
}
}
subscribe(modPackId: number | undefined, localePath: string | undefined, callback: () => void) {
if (modPackId === undefined || !localePath) {
return () => {};
}
let cacheEntry = this.cache.get(modPackId);
if (!cacheEntry) {
cacheEntry = {
callbacks: [],
localePath: undefined,
data: new Map(),
};
this.cache.set(modPackId, cacheEntry);
}
if (cacheEntry.localePath !== localePath) {
cacheEntry.localePath = localePath;
this.loadLocale(modPackId, cacheEntry).catch(logger.error);
}
cacheEntry.callbacks.push(callback);
return () => {
const index = cacheEntry.callbacks.indexOf(callback);
if (index !== -1) {
cacheEntry.callbacks.splice(index, 1);
}
};
}
getSnapshot(modPackId: number | undefined): ReadonlyMap<string, string> {
if (modPackId === undefined) {
return this._emptyMap;
}
return this.cache.get(modPackId)?.data ?? this._emptyMap;
}
}
export const exportLocaleStore = new ExportLocaleStore();
@@ -0,0 +1,139 @@
import { ExportMetadata, ExportMetadataEntry, logger, ModPack } from "@clusterio/lib";
import unknownIconUrl from "../images/unknown-item.png";
import notify from "../util/notify";
export type PrototypeMetadataEntry = ExportMetadataEntry & { base_type: string };
interface MetadataCacheEntry {
callbacks: (() => void)[],
metadataPath: string | undefined,
spritesheetPath: string | undefined,
metadata: Map<string, Map<string, PrototypeMetadataEntry>>,
styleSheet: CSSStyleSheet,
}
class ExportPrototypeMetadataStore {
cache = new Map<number, MetadataCacheEntry>();
async loadMetadata(modPackId: number, cacheEntry: MetadataCacheEntry) {
if (!cacheEntry.metadataPath) {
// eslint-disable-next-line node/no-sync
cacheEntry.styleSheet.replaceSync("");
cacheEntry.metadata = new Map();
for (const callback of cacheEntry.callbacks) {
callback();
}
return;
}
const response = await fetch(`${staticRoot}static/${cacheEntry.metadataPath}`);
if (!response.ok) {
notify(
`Failed to fetch prototype metadata for mod pack ${modPackId}, server returned: ` +
`${response.status} ${response.statusText}`
);
// eslint-disable-next-line node/no-sync
cacheEntry.styleSheet.replaceSync("");
cacheEntry.metadata = new Map();
for (const callback of cacheEntry.callbacks) {
callback();
}
return;
}
const metadata = await response.json() as ExportMetadata;
const spriteUrl = `${staticRoot}static/${cacheEntry.spritesheetPath}`;
const styleContent: string[] = [];
styleContent.push(`\
.factorio-icon\
[data-mod-pack="${modPackId}"] {
background-image: url("${spriteUrl}");
}`);
for (const [baseName, prototypes] of Object.entries(metadata)) {
for (const prototype of prototypes) {
const icon = prototype.icon;
if (!icon) {
styleContent.push(`\
.factorio-icon\
[data-mod-pack="${modPackId}"]\
[data-type="${baseName}"]\
[data-name="${CSS.escape(prototype.name)}"] {
background-image: url("${unknownIconUrl}");
height: 32px;
width: 32px;
}`);
continue;
}
styleContent.push(`\
.factorio-icon\
[data-mod-pack="${modPackId}"]\
[data-type="${baseName}"]\
[data-name="${CSS.escape(prototype.name)}"] {
background-position: -${icon.x}px -${icon.y}px;
height: ${icon.size}px;
width: ${icon.size}px;
}`);
}
}
// eslint-disable-next-line node/no-sync
cacheEntry.styleSheet.replaceSync(styleContent.join("\n"));
cacheEntry.metadata = new Map(
Object.entries(metadata)
.map(([baseName, entries]) => [
baseName,
new Map(
entries.map(entry => [entry.name, { base_type: baseName, ...entry }])
),
])
);
for (const callback of cacheEntry.callbacks) {
callback();
}
}
subscribe(modPack: ModPack | undefined, callback: () => void) {
if (!modPack) {
return () => {};
}
const assets = modPack.exportManifest?.assets;
let cacheEntry = this.cache.get(modPack.id);
if (!cacheEntry) {
const styleSheet = new CSSStyleSheet();
document.adoptedStyleSheets.push(styleSheet);
cacheEntry = {
callbacks: [],
spritesheetPath: undefined,
metadataPath: undefined,
metadata: new Map(),
styleSheet,
};
this.cache.set(modPack.id, cacheEntry);
}
if (
cacheEntry.metadataPath !== assets?.metadata
|| cacheEntry.spritesheetPath !== assets?.spritesheet
) {
cacheEntry.metadataPath = assets?.metadata;
cacheEntry.spritesheetPath = assets?.spritesheet;
this.loadMetadata(modPack.id, cacheEntry).catch(logger.error);
}
cacheEntry.callbacks.push(callback);
return () => {
const index = cacheEntry.callbacks.indexOf(callback);
if (index !== -1) {
cacheEntry.callbacks.splice(index, 1);
}
};
}
getSnapshot(modPackId: number | undefined) {
if (modPackId === undefined) {
return undefined;
}
return this.cache.get(modPackId)?.metadata;
}
}
export const exportPrototypeMetadataStore = new ExportPrototypeMetadataStore();
+8 -1
View File
@@ -320,7 +320,14 @@ body {
font-family: monospace;
}
.factorio-icon.item-unknown-item {
.factorio-icon {
display: inline-block;
vertical-align: middle;
background-repeat: no-repeat;
}
/* data-mod-pack might not be present, but we need the specificity to win over it if it is */
.factorio-icon:is([data-mod-pack],*)[data-unknown="true"] {
background: url("./images/unknown-item.png");
height: 32px;
width: 32px;
+18 -10
View File
@@ -1,10 +1,9 @@
import React, { useContext, useEffect, useState } from "react";
import { useContext, useEffect, useState } from "react";
import { Input, Table, Typography } from "antd";
import * as lib from "@clusterio/lib";
import {
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext,
notifyErrorHandler, useItemMetadata, useLocale,
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
useExportLocale, useExportPrototypeMetadata, useDefaultModPack, FactorioIcon,
} from "@clusterio/web_ui";
import { GetStorageRequest, Item, SetStorageSubscriptionRequest, UpdateStorageEvent } from "../messages";
@@ -32,18 +31,25 @@ function useStorage(control: Control) {
function StoragePage() {
let control = useContext(ControlContext);
let locale = useLocale();
let itemMetadata = useItemMetadata();
const modPack = useDefaultModPack();
let locale = useExportLocale(modPack);
const prototypes = useExportPrototypeMetadata(modPack);
let itemMetadata = prototypes?.get("item");
let fluidMetadata = prototypes?.get("fluid");
let storage = useStorage(control);
type ItemFilter = ([name, item]: [string, Item]) => boolean;
let [filter, setFilter] = useState<null | ItemFilter>(null);
function getLocaleName(itemName: string) {
let localeName = itemName;
let meta = itemMetadata.get(itemName);
let meta = itemMetadata?.get(itemName) ?? fluidMetadata?.get(itemName);
if (meta && meta.localised_name) {
// TODO: implement the locale to name conversion.
localeName = locale.get(meta.localised_name[0])!;
if (typeof meta.localised_name === "string") {
localeName = meta.localised_name;
} else {
localeName = locale.get(meta.localised_name[0])!;
}
} else {
for (let section of ["item-name", "entity-name", "fluid-name", "equipment-name"]) {
let name = locale.get(`${section}.${itemName}`);
@@ -81,6 +87,7 @@ function StoragePage() {
/>
</Paragraph>
<Table
className="subspace-storage-storage"
columns={[
{
title: "Resource",
@@ -94,10 +101,11 @@ function StoragePage() {
},
render: (_, item) => {
let localeName = getLocaleName(item[1].name);
let hasMeta = itemMetadata.get(item[1].name);
let name = item[1].name;
const prototype = itemMetadata?.get(name) ?? fluidMetadata?.get(name);
return <>
<span className={`factorio-icon item-${hasMeta ? item[1].name : "unknown-item"}`}/>
<FactorioIcon modPackId={modPack?.id} prototype={prototype} />
{localeName}
</>;
},
+1 -3
View File
@@ -1,5 +1,3 @@
.factorio-icon {
display: inline-block;
vertical-align: middle;
.subspace-storage-storage .factorio-icon {
margin-right: 0.5em;
}