From 076fb933dc6d5c38292eb593c7908b9c3d1dd445 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Fri, 6 Mar 2026 13:56:53 +0100 Subject: [PATCH] Share structured data types in export Place shared types of the export functionality into lib and use in the web interface to make sure the data is not incorrectly handled. Resolves #526 --- packages/controller/src/routes.ts | 2 +- packages/host/src/export.ts | 23 ++-- packages/lib/browser.ts | 1 + packages/lib/index.ts | 1 + packages/lib/src/data/ModPack.ts | 7 +- packages/lib/src/export.ts | 119 +++++++++++++++++++++ packages/lib/tsconfig.browser.json | 1 + packages/web_ui/src/model/item_metadata.ts | 20 ++-- plugins/subspace_storage/web/index.tsx | 4 +- 9 files changed, 143 insertions(+), 35 deletions(-) create mode 100644 packages/lib/src/export.ts diff --git a/packages/controller/src/routes.ts b/packages/controller/src/routes.ts index a47ceb2d..fc700b71 100644 --- a/packages/controller/src/routes.ts +++ b/packages/controller/src/routes.ts @@ -246,7 +246,7 @@ async function uploadExport(req: Request, res: Response) { ]; let assets: any = {}; - let settingPrototypes = {}; + let settingPrototypes: lib.ExportSettings = {}; for (let filePath of exportFiles) { let file = zip.file(filePath); if (!file) { diff --git a/packages/host/src/export.ts b/packages/host/src/export.ts index 719e6e96..7c12a04d 100644 --- a/packages/host/src/export.ts +++ b/packages/host/src/export.ts @@ -8,11 +8,6 @@ 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 { icons?: never; icon: string; @@ -33,11 +28,9 @@ interface LayeredIconSpecification { icon_mipmaps?: number; } type IconSpecification = LayeredIconSpecification | SimpleIconSpecification; -type ItemPrototype = Prototype & IconSpecification; +type ItemPrototype = lib.FactorioPrototypeBase & IconSpecification; -type Prototypes = Record> - /** * Generate the export mod needed for exportData * @@ -326,7 +319,7 @@ const entityTypes = new Set([ "agricultural-tower", "market", "lane-splitter", ]); -function filterPrototypes(prototypes: Prototypes, allowedTypes: Set): ItemPrototype[] { +function filterPrototypes(prototypes: lib.FactorioPrototypes, allowedTypes: Set): ItemPrototype[] { return Object.entries(prototypes) .filter(([type]) => allowedTypes.has(type)) .flatMap(([_, typePrototypes]) => Object.values(typePrototypes) as ItemPrototype[]) @@ -336,7 +329,7 @@ function filterPrototypes(prototypes: Prototypes, allowedTypes: Set): It /** Shared state for building a unified spritesheet across all categories. */ interface SheetState { sheet: Jimp; - metadata: Map; + metadata: Map; iconCache: IconCache; simpleIcons: Map; pos: number; @@ -514,12 +507,12 @@ 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[] = []; - 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] = {}; } @@ -598,7 +591,7 @@ export async function exportData(server: FactorioServer) { 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/locale.json", JSON.stringify([...locale.entries()] satisfies lib.ExportLocale)); // Build a single unified spritesheet across all prototype categories. const state = await createSheetState(); @@ -616,7 +609,7 @@ export async function exportData(server: FactorioServer) { 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()])); + zip.file("export/metadata.json", JSON.stringify([...state.metadata.entries()] satisfies lib.ExportMetadata)); server._logger.info(`Export complete: ${state.metadata.size} icons on ${usedRows} row(s)`); diff --git a/packages/lib/browser.ts b/packages/lib/browser.ts index 66b3d5c1..aa0273ea 100644 --- a/packages/lib/browser.ts +++ b/packages/lib/browser.ts @@ -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"; diff --git a/packages/lib/index.ts b/packages/lib/index.ts index d8240ffe..3319cc89 100644 --- a/packages/lib/index.ts +++ b/packages/lib/index.ts @@ -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"; diff --git a/packages/lib/src/data/ModPack.ts b/packages/lib/src/data/ModPack.ts index df3adf7d..29daf36d 100644 --- a/packages/lib/src/data/ModPack.ts +++ b/packages/lib/src/data/ModPack.ts @@ -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, 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); } } diff --git a/packages/lib/src/export.ts b/packages/lib/src/export.ts new file mode 100644 index 00000000..71f3a18b --- /dev/null +++ b/packages/lib/src/export.ts @@ -0,0 +1,119 @@ +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 = Record> + +export type ExportSettings = FactorioPrototypes; +export type ExportPrototypes = FactorioPrototypes; +export type ExportLocale = [string, string][]; +export interface ExportMetadataEntry { + x: number, + y: number, + size: number, + category: string, + path?: string, +} +export type ExportMetadata = [string, ExportMetadataEntry][]; diff --git a/packages/lib/tsconfig.browser.json b/packages/lib/tsconfig.browser.json index 97bcd427..8a87bca4 100644 --- a/packages/lib/tsconfig.browser.json +++ b/packages/lib/tsconfig.browser.json @@ -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", diff --git a/packages/web_ui/src/model/item_metadata.ts b/packages/web_ui/src/model/item_metadata.ts index a170566e..4235546d 100644 --- a/packages/web_ui/src/model/item_metadata.ts +++ b/packages/web_ui/src/model/item_metadata.ts @@ -1,18 +1,10 @@ import { useEffect, useState } from "react"; import { useExportManifest } from "./export_manifest"; +import { ExportMetadataEntry } from "@clusterio/lib"; -type Metadata = { - x: number; - y: number; - size: number; - category: string; - localised_name?: any[]; - path?: string; -}; - -const metadataCaches = new Map>(); +const metadataCaches = new Map>(); let cssInjected = false; /** @@ -20,9 +12,9 @@ let cssInjected = false; * 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 { +function useSpriteMetadata(category: string): Map { const exportManifest = useExportManifest(); - const [metadata, setMetadata] = useState>( + const [metadata, setMetadata] = useState>( metadataCaches.get(category) ?? new Map() ); @@ -41,7 +33,7 @@ function useSpriteMetadata(category: string): Map { return; } - const data: [string, Metadata][] = await response.json(); + const data: [string, ExportMetadataEntry][] = await response.json(); if (!cssInjected) { const sheetUrl = `${staticRoot}static/${exportManifest.assets["spritesheet"]}`; @@ -62,7 +54,7 @@ function useSpriteMetadata(category: string): Map { cssInjected = true; } - const cache = new Map(); + const cache = new Map(); for (const [key, meta] of data) { if (meta.category === category) { const name = key.includes(".") ? key.slice(key.indexOf(".") + 1) : key; diff --git a/plugins/subspace_storage/web/index.tsx b/plugins/subspace_storage/web/index.tsx index 0ad1941f..0ddb3b56 100644 --- a/plugins/subspace_storage/web/index.tsx +++ b/plugins/subspace_storage/web/index.tsx @@ -41,9 +41,9 @@ function StoragePage() { function getLocaleName(itemName: string) { let localeName = itemName; let meta = itemMetadata.get(itemName); - if (meta && meta.localised_name) { + if (meta && (meta as any).localised_name) { // TODO: implement the locale to name conversion. - localeName = locale.get(meta.localised_name[0])!; + localeName = locale.get((meta as any).localised_name[0])!; } else { for (let section of ["item-name", "entity-name", "fluid-name", "equipment-name"]) { let name = locale.get(`${section}.${itemName}`);