diff --git a/packages/controller/src/routes.ts b/packages/controller/src/routes.ts index a47ceb2d..b9fba89b 100644 --- a/packages/controller/src/routes.ts +++ b/packages/controller/src/routes.ts @@ -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", ]; diff --git a/packages/host/lua/export/control.lua b/packages/host/lua/export/control.lua index 1833a4cc..ae895a8c 100644 --- a/packages/host/lua/export/control.lua +++ b/packages/host/lua/export/control.lua @@ -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) diff --git a/packages/host/package.json b/packages/host/package.json index ee8bbfd6..bae9248f 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -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:" } diff --git a/packages/host/src/export.ts b/packages/host/src/export.ts index 4368c7e4..0a401b42 100644 --- a/packages/host/src/export.ts +++ b/packages/host/src/export.ts @@ -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> - /** * Generate the export mod needed for exportData * @@ -69,7 +64,7 @@ async function generateExportMod(server: FactorioServer) { }); } -let zipCache = new Map(); +let zipCache = new Map(); async function loadZip(server: FactorioServer, modVersions: Map, mod: string) { let modVersion = modVersions.get(mod); if (!modVersion) { @@ -83,7 +78,7 @@ async function loadZip(server: FactorioServer, modVersions: Map, 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 return await file.async("nodebuffer"); } -type IconCache = Map; +type Image = JimpInstance; +type IconCache = Map; async function loadIcon( server: FactorioServer, modVersions: Map, @@ -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): ItemPrototype[] { +function filterAndFixPrototypesWithIcons(prototypes: lib.FactorioPrototypes, allowedTypes: Set) { 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; + sheet: Image; + metadata: Record; iconCache: IconCache; simpleIcons: Map; pos: number; @@ -346,8 +286,8 @@ const SHEET_WIDTH = 1024; async function createSheetState(): Promise { 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, + 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, - 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 }[] = [ - { 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; } diff --git a/packages/host/src/export_fallback.ts b/packages/host/src/export_fallback.ts new file mode 100644 index 00000000..75b03163 --- /dev/null +++ b/packages/host/src/export_fallback.ts @@ -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, + }, +}; diff --git a/packages/lib/browser.ts b/packages/lib/browser.ts index 66b3d5c1..1d7bc9c9 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"; @@ -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); diff --git a/packages/lib/index.ts b/packages/lib/index.ts index d8240ffe..48ed7462 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"; @@ -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); 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..10f06089 --- /dev/null +++ b/packages/lib/src/export.ts @@ -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 = Record> +export type FactorioDefinesValue = number | string | { [index: string]: FactorioDefinesValue }; +export type FactorioDefines = { + [index: string]: FactorioDefinesValue, + prototypes: Record>, +}; + +export type ExportSettings = FactorioPrototypes; +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; +export type ExportDefines = FactorioDefines; 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/components/FactorioIcon.tsx b/packages/web_ui/src/components/FactorioIcon.tsx new file mode 100644 index 00000000..a7cd1487 --- /dev/null +++ b/packages/web_ui/src/components/FactorioIcon.tsx @@ -0,0 +1,11 @@ +import { PrototypeMetadataEntry } from "../store/export_prototype_metadata_store"; + +export default function FactorioIcon(props: { modPackId: number | undefined, prototype?: PrototypeMetadataEntry }) { + return ; +} diff --git a/packages/web_ui/src/components/IconReferencePage.tsx b/packages/web_ui/src/components/IconReferencePage.tsx index bfb896bf..de5263b0 100644 --- a/packages/web_ui/src/components/IconReferencePage.tsx +++ b/packages/web_ui/src/components/IconReferencePage.tsx @@ -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> | 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 = { - 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> = { - 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> | 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>(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(); - 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) => ( -
+ render: (prototype: PrototypeMetadataEntry) => ( + ), }, { @@ -138,19 +84,9 @@ export default function IconReferencePage() { {name} ), }, - { - title: "Usage", - dataIndex: "cssClass", - key: "usage", - render: (cssClass: string) => ( - - {`
`} - - ), - }, { title: "Path", - dataIndex: "path", + dataIndex: ["icon", "path"], key: "path", render: (p: string | undefined) => (p ? {p} @@ -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 ( - +
- {!manifest && ( + {!modPack?.exportManifest && ( No export data available. Run export-data on an instance to generate icons. @@ -180,7 +120,7 @@ export default function IconReferencePage() {
)}
- Icons: + Prototypes: {totalCount}
@@ -188,7 +128,7 @@ export default function IconReferencePage() { setFilter(e.target.value)} allowClear @@ -200,13 +140,12 @@ export default function IconReferencePage() { : `${visibleCount} / ${totalCount} icons`}
- {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 ( -
+
toggleGroup(category)} + onClick={() => toggleGroup(baseType)} style={{ display: "flex", alignItems: "center", @@ -222,13 +161,13 @@ export default function IconReferencePage() { {expanded ? "▾" : "►"} - {label} + {baseType} {entries.length}
{expanded && ( - + columns={columns} dataSource={entries} rowKey="key" diff --git a/packages/web_ui/src/components/ModPackViewPage.tsx b/packages/web_ui/src/components/ModPackViewPage.tsx index 8cccaf24..caea5202 100644 --- a/packages/web_ui/src/components/ModPackViewPage.tsx +++ b/packages/web_ui/src/components/ModPackViewPage.tsx @@ -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} /> + + Inspect exported data + + + Icon reference +
void) => exportLocaleStore.subscribe(modPack?.id, localePath, callback), + [modPack?.id, localePath], + ); + return useSyncExternalStore(subscribe, () => exportLocaleStore.getSnapshot(modPack?.id)); +} diff --git a/packages/web_ui/src/model/export_manifest.ts b/packages/web_ui/src/model/export_manifest.ts deleted file mode 100644 index 5ec39b28..00000000 --- a/packages/web_ui/src/model/export_manifest.ts +++ /dev/null @@ -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(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; -} diff --git a/packages/web_ui/src/model/export_prototype_metadata.ts b/packages/web_ui/src/model/export_prototype_metadata.ts new file mode 100644 index 00000000..3b1e1fe1 --- /dev/null +++ b/packages/web_ui/src/model/export_prototype_metadata.ts @@ -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)); +} diff --git a/packages/web_ui/src/model/item_metadata.ts b/packages/web_ui/src/model/item_metadata.ts deleted file mode 100644 index a170566e..00000000 --- a/packages/web_ui/src/model/item_metadata.ts +++ /dev/null @@ -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>(); -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 { - const exportManifest = useExportManifest(); - const [metadata, setMetadata] = useState>( - 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(); - 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"); } diff --git a/packages/web_ui/src/model/locale.ts b/packages/web_ui/src/model/locale.ts deleted file mode 100644 index 95b136ef..00000000 --- a/packages/web_ui/src/model/locale.ts +++ /dev/null @@ -1,29 +0,0 @@ -import React, { useEffect, useState } from "react"; - -import { useExportManifest } from "./export_manifest"; - - -let localeCache: Map|null = null; -export function useLocale(): Map { - let exportManifest = useExportManifest(); - let [locale, setLocale] = useState>(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; -} diff --git a/packages/web_ui/src/model/mod_pack.ts b/packages/web_ui/src/model/mod_pack.ts index 927a9a75..a3b63bb2 100644 --- a/packages/web_ui/src/model/mod_pack.ts +++ b/packages/web_ui/src/model/mod_pack.ts @@ -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()); +} diff --git a/packages/web_ui/src/pages.tsx b/packages/web_ui/src/pages.tsx index f3f36a35..f2187c45 100644 --- a/packages/web_ui/src/pages.tsx +++ b/packages/web_ui/src/pages.tsx @@ -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: , }, + { + path: "/mods/mod-packs/:id/icon-reference", + sidebarPath: "/mods", + content: , + }, { path: "/users", sidebarName: "Users", @@ -89,10 +96,6 @@ export const pages: PluginPage[] = [ sidebarPath: "/plugins", content: , }, - { - path: "/icon-reference", - content: , - }, { path: "/error", content: , diff --git a/packages/web_ui/src/store/default_mod_pack_store.ts b/packages/web_ui/src/store/default_mod_pack_store.ts new file mode 100644 index 00000000..d7d88184 --- /dev/null +++ b/packages/web_ui/src/store/default_mod_pack_store.ts @@ -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; + 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(); diff --git a/packages/web_ui/src/store/export_locale_stores.ts b/packages/web_ui/src/store/export_locale_stores.ts new file mode 100644 index 00000000..8d3c6d17 --- /dev/null +++ b/packages/web_ui/src/store/export_locale_stores.ts @@ -0,0 +1,72 @@ +import { ExportLocale, logger } from "@clusterio/lib"; +import notify from "../util/notify"; + + +interface ExportLocaleCacheEntry { + callbacks: (() => void)[]; + localePath: string | undefined; + data: Map; +} + +class ExportLocaleStore { + cache = new Map(); + _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 { + if (modPackId === undefined) { + return this._emptyMap; + } + return this.cache.get(modPackId)?.data ?? this._emptyMap; + } +} +export const exportLocaleStore = new ExportLocaleStore(); diff --git a/packages/web_ui/src/store/export_prototype_metadata_store.ts b/packages/web_ui/src/store/export_prototype_metadata_store.ts new file mode 100644 index 00000000..59169fd3 --- /dev/null +++ b/packages/web_ui/src/store/export_prototype_metadata_store.ts @@ -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>, + styleSheet: CSSStyleSheet, +} + +class ExportPrototypeMetadataStore { + cache = new Map(); + 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(); diff --git a/packages/web_ui/src/style.css b/packages/web_ui/src/style.css index 937c4cd9..59be1d2b 100644 --- a/packages/web_ui/src/style.css +++ b/packages/web_ui/src/style.css @@ -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; diff --git a/plugins/subspace_storage/web/index.tsx b/plugins/subspace_storage/web/index.tsx index 0ad1941f..63f669d7 100644 --- a/plugins/subspace_storage/web/index.tsx +++ b/plugins/subspace_storage/web/index.tsx @@ -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); 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() { /> { 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 <> - + {localeName} ; }, diff --git a/plugins/subspace_storage/web/style.css b/plugins/subspace_storage/web/style.css index d96377fc..09460697 100644 --- a/plugins/subspace_storage/web/style.css +++ b/plugins/subspace_storage/web/style.css @@ -1,5 +1,3 @@ -.factorio-icon { - display: inline-block; - vertical-align: middle; +.subspace-storage-storage .factorio-icon { margin-right: 0.5em; }