From 5238a0fbb384661bac3e4e0ae839da909cfa6db8 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:02:52 +0000 Subject: [PATCH 1/4] Report missing base game icons when exporting from headless Factorio The headless build has no graphics, so export-data found none of the base game's icon files. Plain icons were skipped silently and layered icons were drawn blank and counted as exported. Skip a layered icon when any of its layers is missing, and log one warning with the number of base game icon files that were not found. Add host.factorio_graphics_directory to read builtin mod graphics from a full install when the instance runs the headless build. Fixes #1068 Co-Authored-By: Claude Opus 5.5 --- docs/configuration.md | 9 ++ packages/host/src/Instance.ts | 2 +- packages/host/src/export.ts | 122 +++++++++++++----- packages/lib/src/config/definitions.ts | 9 ++ .../base/graphics/icons/graphics-icon.png | Bin 0 -> 206 bytes test/host/export.js | 66 +++++++++- 6 files changed, 171 insertions(+), 37 deletions(-) create mode 100644 test/file/factorio-graphics/base/graphics/icons/graphics-icon.png diff --git a/docs/configuration.md b/docs/configuration.md index 1818a357..b7064174 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -339,6 +339,15 @@ This can either point to a Factorio server directory, or to a directory containi Defaults to "factorio". +### host.factorio_graphics_directory + +Directory to read the base game's icons from when exporting data. +The headless build of Factorio has no graphics, so instances running it export none of the base game's icons unless this points to a full install of Factorio. +Like `host.factorio_directory` this can either point to a Factorio directory, or to a directory containing multiple versions, in which case the latest one matching the major and minor version of the instance is used. + +Defaults to null. + + ### host.mods_directory Directory the host stores mods downloaded from the controller in. diff --git a/packages/host/src/Instance.ts b/packages/host/src/Instance.ts index f70080d3..7596a8fc 100644 --- a/packages/host/src/Instance.ts +++ b/packages/host/src/Instance.ts @@ -1469,7 +1469,7 @@ end`.replace(/\r?\n/g, " "); this.logger.info("Exporting data ....."); await this.syncMods(); - let zip = await exportData(this.server); + let zip = await exportData(this.server, this._host.config.get("host.factorio_graphics_directory")); let url = new URL(this._host.config.get("host.controller_url")); url.pathname += "api/upload-export"; diff --git a/packages/host/src/export.ts b/packages/host/src/export.ts index cb7f73d5..9554e5c5 100644 --- a/packages/host/src/export.ts +++ b/packages/host/src/export.ts @@ -6,7 +6,7 @@ import yazl from "yazl"; import * as lib from "@clusterio/lib"; import * as libBuildMod from "@clusterio/lib/dist/node/build_mod.js"; -import type { FactorioServer } from "./server.js"; +import { findVersion, type FactorioServer } from "./server.js"; import { definesPrototypesFallback } from "./export_fallback.js"; interface SimpleIconSpecification { @@ -91,6 +91,13 @@ async function loadZip(server: FactorioServer, modVersions: Map, return modZip; } +/** Where icon files are read from during an export. */ +interface ExportFiles { + zipCache: ZipCache; + /** Data dir of a full install to fall back to for builtin mod files. */ + graphicsDataDir?: string; +} + function closeZipCache(zipCache: ZipCache) { for (const { zip } of zipCache.values()) { zip.close(); @@ -103,7 +110,7 @@ function closeZipCache(zipCache: ZipCache) { * * @param server - The server to load the file from. * @param modVersions - Mapping of mod to version used. - * @param zipCache - Mod zips opened by this export. + * @param files - Where to read files from. * @param modPath - Factorio style path to the file to load. * @returns The content of the file or null if not found. * @internal @@ -111,7 +118,7 @@ function closeZipCache(zipCache: ZipCache) { async function loadFile( server: FactorioServer, modVersions: Map, - zipCache: ZipCache, + files: ExportFiles, modPath: string, ) { let match = /^__([^\/]+)__\/(.*)$/.exec(modPath); @@ -124,19 +131,25 @@ async function loadFile( const builtinModNames = ["core", ...lib.ModPack.getBuiltinModNames(server.version!)]; if (builtinModNames.includes(mod)) { - try { - return await fs.readFile(server.dataPath(mod, filePath)); - } catch (err: any) { - if (err.code === "ENOENT") { - return null; - } - throw err; + const dataDirs = [server.dataPath()]; + if (files.graphicsDataDir) { + dataDirs.push(files.graphicsDataDir); } + for (const dataDir of dataDirs) { + try { + return await fs.readFile(path.join(dataDir, mod, filePath)); + } catch (err: any) { + if (err.code !== "ENOENT") { + throw err; + } + } + } + return null; } let modZip; try { - modZip = await loadZip(server, modVersions, zipCache, mod); + modZip = await loadZip(server, modVersions, files.zipCache, mod); } catch (err: any) { if (err.code === "ENOENT") { return null; @@ -152,7 +165,7 @@ type IconCache = Map; async function loadIcon( server: FactorioServer, modVersions: Map, - zipCache: ZipCache, + files: ExportFiles, iconPath: string, iconSize: number, iconMipmaps: number, @@ -160,7 +173,7 @@ async function loadIcon( ) { let icon = iconCache.get(iconPath); if (icon === undefined) { - let fileContent = await loadFile(server, modVersions, zipCache, iconPath); + let fileContent = await loadFile(server, modVersions, files, iconPath); if (fileContent) { icon = await Jimp.fromBuffer(fileContent) as Image; icon.crop({ x: 0, y: 0, w: iconSize, h: iconSize }); @@ -176,12 +189,12 @@ async function loadIcon( async function loadSimpleIcon( server: FactorioServer, modVersions: Map, - zipCache: ZipCache, + files: ExportFiles, item: SimpleIconSpecification, size: number, iconCache: IconCache, ): Promise { - let icon = await loadIcon(server, modVersions, zipCache, item.icon, item.icon_size ?? 64, 0, iconCache); + let icon = await loadIcon(server, modVersions, files, item.icon, item.icon_size ?? 64, 0, iconCache); if (icon) { let iconScale = size / (item.icon_size ?? 64); if (iconScale !== 1) { @@ -195,11 +208,11 @@ async function loadSimpleIcon( async function loadLayeredIcon( server: FactorioServer, modVersions: Map, - zipCache: ZipCache, + files: ExportFiles, item: LayeredIconSpecification, size: number, iconCache: IconCache, -): Promise { +): Promise { let baseLayerSize = (item.icons[0].icon_size || item.icon_size) ?? 64; let icon = new Jimp({ width: size, height: size }); @@ -212,11 +225,12 @@ async function loadLayeredIcon( for (let layer of item.icons) { let layerSize = (layer.icon_size || item.icon_size) ?? 64; let iconLayer = await loadIcon( - server, modVersions, zipCache, layer.icon, layerSize, layer.icon_mipmaps || 0, iconCache, + server, modVersions, files, layer.icon, layerSize, layer.icon_mipmaps || 0, iconCache, ); + // A partial icon would be wrong, skip it like a missing simple icon if (!iconLayer) { - continue; + return null; } iconLayer = iconLayer.clone(); @@ -331,7 +345,7 @@ async function growSheet(state: SheetState) { async function exportIcon( server: FactorioServer, modVersions: Map, - zipCache: ZipCache, + files: ExportFiles, state: SheetState, prototype: PrototypeWithIcon, ) { @@ -342,9 +356,11 @@ async function exportIcon( if (prototype.icons) { icon = await loadLayeredIcon( - server, modVersions, zipCache, prototype, SHEET_ICON_SIZE, state.iconCache, + server, modVersions, files, prototype, SHEET_ICON_SIZE, state.iconCache, ); - iconPos = state.pos; + if (icon) { + iconPos = state.pos; + } iconPath = prototype.icons[0].icon; } else if (prototype.icon) { @@ -352,7 +368,7 @@ async function exportIcon( iconPath = prototype.icon; if (iconPos === undefined) { icon = await loadSimpleIcon( - server, modVersions, zipCache, prototype, SHEET_ICON_SIZE, state.iconCache, + server, modVersions, files, prototype, SHEET_ICON_SIZE, state.iconCache, ); if (icon) { iconPos = state.pos; @@ -388,7 +404,7 @@ async function exportIcon( * * @param server - The server to load icons from. * @param modVersions - Mapping of mod name to version. - * @param zipCache - Mod zips opened by this export. + * @param files - Where to read icon files from. * @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). @@ -398,7 +414,7 @@ async function exportIcon( async function exportIcons( server: FactorioServer, modVersions: Map, - zipCache: ZipCache, + files: ExportFiles, prototypes: PrototypeWithIcon[], baseName: string, state: SheetState, @@ -407,7 +423,7 @@ async function exportIcons( state.metadata[baseName] ??= metadata; let count = 0; for (let prototype of prototypes) { - const icon = await exportIcon(server, modVersions, zipCache, state, prototype); + const icon = await exportIcon(server, modVersions, files, state, prototype); if (icon) { count += 1; } @@ -498,19 +514,57 @@ async function exportLocale( * Export locale, icons and metadata for the given factorio server * * @param server - The server to export the data from. + * @param graphicsDirectory - + * Full Factorio install, or directory of installs, to read builtin mod + * graphics from when the server's install lacks them. * @returns zip file with exported data. */ -export async function exportData(server: FactorioServer) { - const zipCache: ZipCache = new Map(); +export async function exportData(server: FactorioServer, graphicsDirectory?: string | null) { + const files: ExportFiles = { zipCache: new Map() }; + if (graphicsDirectory) { + const majorMinor = server.version!.replace(/\.\d+$/, "") as lib.MajorMinorVersion; + [files.graphicsDataDir] = await findVersion(graphicsDirectory, majorMinor); + } try { - return await buildExport(server, zipCache); + return await buildExport(server, files); } finally { // Free up the zip files loaded during the export. - closeZipCache(zipCache); + closeZipCache(files.zipCache); } } -async function buildExport(server: FactorioServer, zipCache: ZipCache) { +/** + * Warn about builtin mod icon files that could not be found + * + * @param server - The server the export ran on. + * @param files - Where icon files were read from. + * @param iconCache - Icons loaded by the export, null for missing files. + * @internal + */ +function warnMissingBuiltinIcons(server: FactorioServer, files: ExportFiles, iconCache: IconCache) { + const builtinModNames = ["core", ...lib.ModPack.getBuiltinModNames(server.version!)]; + let missing = 0; + for (const [iconPath, icon] of iconCache) { + const mod = /^__([^\/]+)__\//.exec(iconPath)?.[1]; + if (icon === null && mod && builtinModNames.includes(mod)) { + missing += 1; + } + } + if (!missing) { + return; + } + + const dirs = [server.dataPath(), files.graphicsDataDir].filter(Boolean).join(" or "); + const hint = files.graphicsDataDir + ? "Check that host.factorio_graphics_directory points to a full Factorio install" + : "Set host.factorio_graphics_directory to a full Factorio install to read them from there"; + server._logger.warn( + `${missing} icon files of the base game were not found in ${dirs}, icons using them were not exported. ` + + `The headless build of Factorio has no graphics. ${hint}, or export the data on a host with a full install.` + ); +} + +async function buildExport(server: FactorioServer, files: ExportFiles) { await generateExportMod(server); let settings: lib.ExportSettings = {}; @@ -590,7 +644,7 @@ async function buildExport(server: FactorioServer, zipCache: ZipCache) { } } - let locale = await exportLocale(server, modVersions, zipCache, modOrder, "en"); + let locale = await exportLocale(server, modVersions, files.zipCache, modOrder, "en"); let zip = new yazl.ZipFile(); const addJson = (name: string, value: unknown) => zip.addBuffer(Buffer.from(JSON.stringify(value)), name); @@ -611,9 +665,10 @@ async function buildExport(server: FactorioServer, zipCache: ZipCache) { if (prototypesWithIcons.length === 0) { continue; } - const count = await exportIcons(server, modVersions, zipCache, prototypesWithIcons, baseName, state); + const count = await exportIcons(server, modVersions, files, prototypesWithIcons, baseName, state); server._logger.info(`Exported ${count} ${baseName} icons`); } + warnMissingBuiltinIcons(server, files, state.iconCache); // Crop sheet to actual used height and write single spritesheet + metadata. const usedRows = Math.ceil(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE)); @@ -632,3 +687,4 @@ async function buildExport(server: FactorioServer, zipCache: ZipCache) { export const _exportLocale = exportLocale; export const _loadSimpleIcon = loadSimpleIcon; export const _loadLayeredIcon = loadLayeredIcon; +export const _warnMissingBuiltinIcons = warnMissingBuiltinIcons; diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index 5d19897a..93a7a43e 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -324,6 +324,7 @@ export interface HostConfigFields { "host.name": string; "host.id": number; "host.factorio_directory": string; + "host.factorio_graphics_directory": string | null; "host.mods_directory": string; "host.instances_directory": string; "host.controller_url": string; @@ -372,6 +373,14 @@ export class HostConfig extends classes.Config { initialValue: "factorio", validator: validators.filePath, }, + "host.factorio_graphics_directory": { + description: + "Path to a full Factorio install, or directory of installs, to read base game icons from when " + + "exporting data. Needed when running the headless build, which has no graphics.", + type: "string", + optional: true, + validator: validators.optional(validators.filePath), + }, "host.mods_directory": { title: "Mods Directory", description: "Path to directory where mods for instances are cached.", diff --git a/test/file/factorio-graphics/base/graphics/icons/graphics-icon.png b/test/file/factorio-graphics/base/graphics/icons/graphics-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..b2f06cccc1810f3be8ee688a20407b7a133fd628 GIT binary patch literal 206 zcmeAS@N?(olHy`uVBq!ia0vp^2|($9&iw7G06K< zeecwRz)9r=e{MWB=w0C!qP$A*YDbof=_08>I&pt7R538u=|BHz1au&Sr>mdKI;Vst E07RZjv;Y7A literal 0 HcmV?d00001 diff --git a/test/host/export.js b/test/host/export.js index 43109532..c371145f 100644 --- a/test/host/export.js +++ b/test/host/export.js @@ -2,7 +2,9 @@ import assert from "node:assert/strict"; import path from "node:path"; import { FactorioServer } from "@clusterio/host/dist/node/src/server.js"; -import { _exportLocale, _loadLayeredIcon, _loadSimpleIcon } from "@clusterio/host/dist/node/src/export.js"; +import { + _exportLocale, _loadLayeredIcon, _loadSimpleIcon, _warnMissingBuiltinIcons, +} from "@clusterio/host/dist/node/src/export.js"; describe("host/src/export", function() { @@ -30,20 +32,37 @@ describe("host/src/export", function() { // test-icon.png is a red 64px icon followed by a blue 32px mipmap const red = 0xff0000ff; + // Stands in for the data dir of a full install + const graphicsDataDir = path.join("test", "file", "factorio-graphics"); describe("loadSimpleIcon()", function() { it("defaults icon_size to 64", async function() { const icon = await _loadSimpleIcon( - testServer, new Map(), new Map(), { icon: "__base__/graphics/icons/test-icon.png" }, 32, new Map() + testServer, new Map(), { zipCache: new Map() }, + { icon: "__base__/graphics/icons/test-icon.png" }, 32, new Map(), ); assert.equal(icon.bitmap.width, 32); assert.equal(icon.bitmap.height, 32); assert.equal(icon.getPixelColor(31, 16), red); }); + it("returns null when the icon file is missing", async function() { + const icon = await _loadSimpleIcon( + testServer, new Map(), { zipCache: new Map() }, + { icon: "__base__/graphics/icons/graphics-icon.png" }, 32, new Map(), + ); + assert.equal(icon, null); + }); + it("falls back to graphicsDataDir for builtin mod files", async function() { + const icon = await _loadSimpleIcon( + testServer, new Map(), { zipCache: new Map(), graphicsDataDir }, + { icon: "__base__/graphics/icons/graphics-icon.png" }, 32, new Map(), + ); + assert.equal(icon.getPixelColor(31, 16), red); + }); }); describe("loadLayeredIcon()", function() { it("defaults icon_size to 64", async function() { const icon = await _loadLayeredIcon( - testServer, new Map(), new Map(), + testServer, new Map(), { zipCache: new Map() }, { icons: [{ icon: "__base__/graphics/icons/test-icon.png" }] }, 32, new Map(), ); assert.equal(icon.bitmap.width, 32); @@ -51,5 +70,46 @@ describe("host/src/export", function() { assert.equal(icon.getPixelColor(0, 0), red); assert.equal(icon.getPixelColor(31, 31), red); }); + it("returns null when a layer is missing", async function() { + const icon = await _loadLayeredIcon( + testServer, new Map(), { zipCache: new Map() }, + { icons: [ + { icon: "__base__/graphics/icons/test-icon.png" }, + { icon: "__base__/graphics/icons/missing.png" }, + ] }, 32, new Map(), + ); + assert.equal(icon, null); + }); + }); + describe("warnMissingBuiltinIcons()", function() { + let warnings; + let logger; + beforeEach(function() { + warnings = []; + logger = testServer._logger; + testServer._logger = { warn: msg => warnings.push(msg) }; + }); + afterEach(function() { + testServer._logger = logger; + }); + + it("warns once with the number of missing builtin files", function() { + _warnMissingBuiltinIcons(testServer, { zipCache: new Map() }, new Map([ + ["__base__/graphics/icons/a.png", null], + ["__core__/graphics/icons/b.png", null], + ["__base__/graphics/icons/c.png", {}], + ["__some_mod__/graphics/icons/d.png", null], + ])); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /^2 icon files of the base game were not found/); + assert.match(warnings[0], /headless/); + }); + it("does not warn when no builtin files are missing", function() { + _warnMissingBuiltinIcons(testServer, { zipCache: new Map() }, new Map([ + ["__base__/graphics/icons/c.png", {}], + ["__some_mod__/graphics/icons/d.png", null], + ])); + assert.deepEqual(warnings, []); + }); }); }); From 46c61532014ebaa39c5403b53814198526156964 Mon Sep 17 00:00:00 2001 From: Danielv123 Date: Sun, 1 Nov 2026 18:33:10 +0100 Subject: [PATCH 2/4] Export without the graphics directory when it has no matching install If host.factorio_graphics_directory had no install matching the major and minor version of the instance, or did not exist, findVersion threw and the whole export failed. Without the setting the same instance exports everything except the base game icons, so it now logs a warning that names the setting and exports without it. --- docs/configuration.md | 1 + packages/host/src/export.ts | 29 +++++++++++++++++++++++++++-- test/host/export.js | 22 +++++++++++++++++++++- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index b6c46453..77516d26 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -363,6 +363,7 @@ Defaults to "factorio". Directory to read the base game's icons from when exporting data. The headless build of Factorio has no graphics, so instances running it export none of the base game's icons unless this points to a full install of Factorio. Like `host.factorio_directory` this can either point to a Factorio directory, or to a directory containing multiple versions, in which case the latest one matching the major and minor version of the instance is used. +If there is no install matching the instance, a warning is logged and the data is exported without it. Defaults to null. diff --git a/packages/host/src/export.ts b/packages/host/src/export.ts index 511ab231..cbc386db 100644 --- a/packages/host/src/export.ts +++ b/packages/host/src/export.ts @@ -523,6 +523,31 @@ async function exportLocale( return mergedLocales; } +/** + * Find the data dir to read builtin mod graphics from + * + * @param server - The server the export runs on. + * @param graphicsDirectory - Full Factorio install, or directory of installs. + * @returns + * Data dir of the install matching the major and minor version of the + * server, or undefined if there is none. + * @internal + */ +async function findGraphicsDataDir(server: FactorioServer, graphicsDirectory: string) { + const majorMinor = server.version!.replace(/\.\d+$/, "") as lib.MajorMinorVersion; + try { + const [dataDir] = await findVersion(graphicsDirectory, majorMinor); + return dataDir; + } catch (err: any) { + // Without it the export still has everything but the base game icons + server._logger.warn( + `Unable to use host.factorio_graphics_directory for Factorio ${majorMinor}, exporting without it: ` + + `${err.message}` + ); + return undefined; + } +} + /** * Export locale, icons and metadata for the given factorio server * @@ -535,8 +560,7 @@ async function exportLocale( export async function exportData(server: FactorioServer, graphicsDirectory?: string | null) { const files: ExportFiles = { zipCache: new Map() }; if (graphicsDirectory) { - const majorMinor = server.version!.replace(/\.\d+$/, "") as lib.MajorMinorVersion; - [files.graphicsDataDir] = await findVersion(graphicsDirectory, majorMinor); + files.graphicsDataDir = await findGraphicsDataDir(server, graphicsDirectory); } try { return await buildExport(server, files); @@ -700,6 +724,7 @@ async function buildExport(server: FactorioServer, files: ExportFiles) { export const _createSheetState = createSheetState; export const _exportIcons = exportIcons; export const _exportLocale = exportLocale; +export const _findGraphicsDataDir = findGraphicsDataDir; export const _loadSimpleIcon = loadSimpleIcon; export const _loadLayeredIcon = loadLayeredIcon; export const _warnMissingBuiltinIcons = warnMissingBuiltinIcons; diff --git a/test/host/export.js b/test/host/export.js index 1bc5a4a3..c2755c1b 100644 --- a/test/host/export.js +++ b/test/host/export.js @@ -3,7 +3,8 @@ import path from "node:path"; import { FactorioServer } from "@clusterio/host/dist/node/src/server.js"; import { - _createSheetState, _exportIcons, _exportLocale, _loadLayeredIcon, _loadSimpleIcon, _warnMissingBuiltinIcons, + _createSheetState, _exportIcons, _exportLocale, _findGraphicsDataDir, _loadLayeredIcon, _loadSimpleIcon, + _warnMissingBuiltinIcons, } from "@clusterio/host/dist/node/src/export.js"; @@ -121,6 +122,25 @@ describe("host/src/export", function() { assert.equal(icon, null); }); }); + describe("findGraphicsDataDir()", function() { + const installs = path.join("test", "file", "factorio"); + it("returns the data dir of the install matching the server version", async function() { + const dataDir = await _findGraphicsDataDir(testServer, installs); + assert.equal(dataDir, path.join(installs, "0.1.2", "data")); + assert.deepEqual(warnings, []); + }); + it("warns and returns undefined when no install matches the server version", async function() { + const server = Object.create(testServer, { version: { value: "0.2.5" } }); + assert.equal(await _findGraphicsDataDir(server, installs), undefined); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /^Unable to use host\.factorio_graphics_directory for Factorio 0\.2, /); + }); + it("warns and returns undefined when the directory does not exist", async function() { + assert.equal(await _findGraphicsDataDir(testServer, path.join("test", "file", "missing")), undefined); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /^Unable to use host\.factorio_graphics_directory for Factorio 0\.1, .*ENOENT/); + }); + }); describe("warnMissingBuiltinIcons()", function() { it("warns once with the number of missing builtin files", function() { _warnMissingBuiltinIcons(testServer, { zipCache: new Map() }, new Map([ From 37d74ca2a6cd550fbf499037804c1b511cda36c1 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:53:05 +0000 Subject: [PATCH 3/4] Find a full install in host.factorio_directory instead of a setting Per review, drop host.factorio_graphics_directory. findVersion takes a headless option that filters installs by build, telling them apart by data/base/graphics/icons. When the instance runs the headless build the export looks for a full install of the same major.minor version in the host's factorio directory. Server startup is unchanged. If none is found the export still runs, and the reason is included in the missing icons warning. Co-Authored-By: Claude Opus 5.5 --- docs/configuration.md | 11 +--- packages/host/src/Instance.ts | 2 +- packages/host/src/export.ts | 52 +++++++++--------- packages/host/src/server.ts | 44 ++++++++++++++- packages/lib/src/config/definitions.ts | 9 --- .../base/graphics/icons/graphics-icon.png | Bin .../factorio-mixed/full/data/changelog.txt | 3 + .../headless/data/changelog.txt | 3 + test/host/export.js | 46 +++++++++++----- test/host/server.js | 27 +++++++++ 10 files changed, 132 insertions(+), 65 deletions(-) rename test/file/{factorio-graphics => factorio-mixed/full/data}/base/graphics/icons/graphics-icon.png (100%) create mode 100644 test/file/factorio-mixed/full/data/changelog.txt create mode 100644 test/file/factorio-mixed/headless/data/changelog.txt diff --git a/docs/configuration.md b/docs/configuration.md index 77516d26..89650ca9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -354,20 +354,11 @@ Defaults to a random 31 bit number. Directory to look for the Factorio server(s) in. This can either point to a Factorio server directory, or to a directory containing multiple versions of the Factorio server. +The headless build has no graphics, so when exporting data from an instance running it the base game's icons are read from a full install of the same major and minor version in this directory instead, if there is one. Defaults to "factorio". -### host.factorio_graphics_directory - -Directory to read the base game's icons from when exporting data. -The headless build of Factorio has no graphics, so instances running it export none of the base game's icons unless this points to a full install of Factorio. -Like `host.factorio_directory` this can either point to a Factorio directory, or to a directory containing multiple versions, in which case the latest one matching the major and minor version of the instance is used. -If there is no install matching the instance, a warning is logged and the data is exported without it. - -Defaults to null. - - ### host.mods_directory Directory the host stores mods downloaded from the controller in. diff --git a/packages/host/src/Instance.ts b/packages/host/src/Instance.ts index 3dc7f6e6..6cee6a7b 100644 --- a/packages/host/src/Instance.ts +++ b/packages/host/src/Instance.ts @@ -1479,7 +1479,7 @@ end`.replace(/\r?\n/g, " "); this.logger.info("Exporting data ....."); await this.syncMods(); - let zip = await exportData(this.server, this._host.config.get("host.factorio_graphics_directory")); + let zip = await exportData(this.server); let url = new URL(this._host.config.get("host.controller_url")); url.pathname += "api/upload-export"; diff --git a/packages/host/src/export.ts b/packages/host/src/export.ts index cbc386db..8be4843a 100644 --- a/packages/host/src/export.ts +++ b/packages/host/src/export.ts @@ -10,7 +10,7 @@ import yazl from "yazl"; import * as lib from "@clusterio/lib"; import * as libBuildMod from "@clusterio/lib/dist/node/build_mod.js"; -import { findVersion, type FactorioServer } from "./server.js"; +import { findVersion, isHeadless, type FactorioServer } from "./server.js"; import { definesPrototypesFallback } from "./export_fallback.js"; interface SimpleIconSpecification { @@ -100,6 +100,8 @@ interface ExportFiles { zipCache: ZipCache; /** Data dir of a full install to fall back to for builtin mod files. */ graphicsDataDir?: string; + /** Why no full install was found for a headless server. */ + graphicsError?: string; } function closeZipCache(zipCache: ZipCache) { @@ -524,43 +526,36 @@ async function exportLocale( } /** - * Find the data dir to read builtin mod graphics from + * Find a full install to read builtin mod graphics from * * @param server - The server the export runs on. - * @param graphicsDirectory - Full Factorio install, or directory of installs. - * @returns - * Data dir of the install matching the major and minor version of the - * server, or undefined if there is none. + * @param files - Updated with the data dir found, or why there is none. * @internal */ -async function findGraphicsDataDir(server: FactorioServer, graphicsDirectory: string) { +async function findGraphicsDataDir(server: FactorioServer, files: ExportFiles) { const majorMinor = server.version!.replace(/\.\d+$/, "") as lib.MajorMinorVersion; try { - const [dataDir] = await findVersion(graphicsDirectory, majorMinor); - return dataDir; + [files.graphicsDataDir] = await findVersion(server._factorioDir, majorMinor, { headless: false }); } catch (err: any) { // Without it the export still has everything but the base game icons - server._logger.warn( - `Unable to use host.factorio_graphics_directory for Factorio ${majorMinor}, exporting without it: ` + - `${err.message}` - ); - return undefined; + files.graphicsError = err.message; } } /** * Export locale, icons and metadata for the given factorio server * + * If the server runs the headless build, builtin mod graphics are read + * from a full install of the same major and minor version found next to + * it, if there is one. + * * @param server - The server to export the data from. - * @param graphicsDirectory - - * Full Factorio install, or directory of installs, to read builtin mod - * graphics from when the server's install lacks them. * @returns zip file with exported data. */ -export async function exportData(server: FactorioServer, graphicsDirectory?: string | null) { +export async function exportData(server: FactorioServer) { const files: ExportFiles = { zipCache: new Map() }; - if (graphicsDirectory) { - files.graphicsDataDir = await findGraphicsDataDir(server, graphicsDirectory); + if (await isHeadless(server.dataPath())) { + await findGraphicsDataDir(server, files); } try { return await buildExport(server, files); @@ -592,13 +587,16 @@ function warnMissingBuiltinIcons(server: FactorioServer, files: ExportFiles, ico } const dirs = [server.dataPath(), files.graphicsDataDir].filter(Boolean).join(" or "); - const hint = files.graphicsDataDir - ? "Check that host.factorio_graphics_directory points to a full Factorio install" - : "Set host.factorio_graphics_directory to a full Factorio install to read them from there"; - server._logger.warn( - `${missing} icon files of the base game were not found in ${dirs}, icons using them were not exported. ` + - `The headless build of Factorio has no graphics. ${hint}, or export the data on a host with a full install.` - ); + let message = + `${missing} icon files of the base game were not found in ${dirs}, icons using them were not exported.`; + if (files.graphicsError !== undefined) { + const majorMinor = server.version!.replace(/\.\d+$/, ""); + message += + ` The headless build of Factorio has no graphics and no full install was found (${files.graphicsError}).` + + ` Add a full install of Factorio ${majorMinor} to host.factorio_directory to read them from there,` + + " or export the data on a host with a full install."; + } + server._logger.warn(message); } async function buildExport(server: FactorioServer, files: ExportFiles) { diff --git a/packages/host/src/server.ts b/packages/host/src/server.ts index df3e85f7..a3abe32b 100644 --- a/packages/host/src/server.ts +++ b/packages/host/src/server.ts @@ -262,15 +262,34 @@ export async function checkForUpdates( * @param targetVersion - * Version to look for, supports the special value "latest" for the * latest version available. + * @param options - Optional parameters. + * @param options.headless - + * Only consider headless installs if true, or only full installs if + * false. * @returns Array with path to data dir and version found. */ export async function findVersion( factorioDir: string, targetVersion: lib.TargetVersion, + options: { headless?: boolean } = {}, ): Promise<[string, lib.FullVersion]> { + let kind = ""; + if (options.headless !== undefined) { + kind = options.headless ? "headless " : "full "; + } + const wrongKind = async (dir: string) => ( + options.headless !== undefined && await isHeadless(path.join(dir, "data")) !== options.headless + ); + // Check if this is a direct installation dir const directVersion = await getFactorioVersion(factorioDir); if (directVersion !== null) { + if (await wrongKind(factorioDir)) { + throw new Error( + `Unable to find a ${kind}install of Factorio ${targetVersion}: ${factorioDir} is a direct ` + + `(single-version) install of the ${options.headless ? "full" : "headless"} build.` + ); + } if (targetVersion === "latest" || directVersion === targetVersion || directVersion.startsWith(targetVersion)) { return [path.join(factorioDir, "data"), directVersion]; } @@ -287,7 +306,7 @@ export async function findVersion( // Avoid any fancy directory detection in order to follow symlinks const fullPath = path.join(factorioDir, name); const version = await getFactorioVersion(fullPath); - if (version === null) { + if (version === null || await wrongKind(fullPath)) { continue; } @@ -299,7 +318,7 @@ export async function findVersion( } if (!versions.size) { - throw new Error(`Unable to find any Factorio install in ${factorioDir}`); + throw new Error(`Unable to find any ${kind}Factorio install in ${factorioDir}`); } if (lib.isPartialVersion(targetVersion)) { @@ -315,7 +334,26 @@ export async function findVersion( return [path.join(factorioDir, versions.get(latest)!, "data"), latest]; } - throw new Error(`Unable to find Factorio version ${targetVersion}`); + throw new Error(`Unable to find ${kind}Factorio version ${targetVersion}`); +} + +/** + * Check if a Factorio install is the headless build + * + * The headless build has no icons, which is what tells it apart. + * + * @param dataDir - Path to the data dir of the install. + */ +export async function isHeadless(dataDir: string) { + try { + await fs.access(path.join(dataDir, "base", "graphics", "icons")); + return false; + } catch (err: any) { + if (err.code === "ENOENT" || err.code === "ENOTDIR") { + return true; + } + throw err; + } } /** diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index 4296f31d..b28db160 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -340,7 +340,6 @@ export interface HostConfigFields { "host.name": string; "host.id": number; "host.factorio_directory": string; - "host.factorio_graphics_directory": string | null; "host.mods_directory": string; "host.instances_directory": string; "host.controller_url": string; @@ -389,14 +388,6 @@ export class HostConfig extends classes.Config { initialValue: "factorio", validator: validators.filePath, }, - "host.factorio_graphics_directory": { - description: - "Path to a full Factorio install, or directory of installs, to read base game icons from when " + - "exporting data. Needed when running the headless build, which has no graphics.", - type: "string", - optional: true, - validator: validators.optional(validators.filePath), - }, "host.mods_directory": { title: "Mods Directory", description: "Path to directory where mods for instances are cached.", diff --git a/test/file/factorio-graphics/base/graphics/icons/graphics-icon.png b/test/file/factorio-mixed/full/data/base/graphics/icons/graphics-icon.png similarity index 100% rename from test/file/factorio-graphics/base/graphics/icons/graphics-icon.png rename to test/file/factorio-mixed/full/data/base/graphics/icons/graphics-icon.png diff --git a/test/file/factorio-mixed/full/data/changelog.txt b/test/file/factorio-mixed/full/data/changelog.txt new file mode 100644 index 00000000..2b12ff4b --- /dev/null +++ b/test/file/factorio-mixed/full/data/changelog.txt @@ -0,0 +1,3 @@ +--------------------------------------------------------------------------------------------------- +Version: 0.1.2 +About: Dummy changelog.txt for FactorioServer tests diff --git a/test/file/factorio-mixed/headless/data/changelog.txt b/test/file/factorio-mixed/headless/data/changelog.txt new file mode 100644 index 00000000..2b12ff4b --- /dev/null +++ b/test/file/factorio-mixed/headless/data/changelog.txt @@ -0,0 +1,3 @@ +--------------------------------------------------------------------------------------------------- +Version: 0.1.2 +About: Dummy changelog.txt for FactorioServer tests diff --git a/test/host/export.js b/test/host/export.js index c2755c1b..0d7a2806 100644 --- a/test/host/export.js +++ b/test/host/export.js @@ -74,7 +74,7 @@ describe("host/src/export", function() { }); // Stands in for the data dir of a full install - const graphicsDataDir = path.join("test", "file", "factorio-graphics"); + const graphicsDataDir = path.join("test", "file", "factorio-mixed", "full", "data"); describe("loadSimpleIcon()", function() { it("defaults icon_size to 64", async function() { const icon = await _loadSimpleIcon( @@ -124,21 +124,29 @@ describe("host/src/export", function() { }); describe("findGraphicsDataDir()", function() { const installs = path.join("test", "file", "factorio"); - it("returns the data dir of the install matching the server version", async function() { - const dataDir = await _findGraphicsDataDir(testServer, installs); - assert.equal(dataDir, path.join(installs, "0.1.2", "data")); - assert.deepEqual(warnings, []); + const withInstalls = (factorioDir, version = testServer.version) => Object.create(testServer, { + _factorioDir: { value: factorioDir }, version: { value: version }, }); - it("warns and returns undefined when no install matches the server version", async function() { - const server = Object.create(testServer, { version: { value: "0.2.5" } }); - assert.equal(await _findGraphicsDataDir(server, installs), undefined); - assert.equal(warnings.length, 1); - assert.match(warnings[0], /^Unable to use host\.factorio_graphics_directory for Factorio 0\.2, /); + it("finds a full install matching the major and minor version of the server", async function() { + const files = { zipCache: new Map() }; + await _findGraphicsDataDir(withInstalls(path.join("test", "file", "factorio"), "0.1.1"), files); + assert.deepEqual(files, { zipCache: new Map(), graphicsDataDir: path.join(installs, "0.1.2", "data") }); }); - it("warns and returns undefined when the directory does not exist", async function() { - assert.equal(await _findGraphicsDataDir(testServer, path.join("test", "file", "missing")), undefined); - assert.equal(warnings.length, 1); - assert.match(warnings[0], /^Unable to use host\.factorio_graphics_directory for Factorio 0\.1, .*ENOENT/); + it("skips headless installs of the same version", async function() { + const files = { zipCache: new Map() }; + await _findGraphicsDataDir(withInstalls(path.join("test", "file", "factorio-mixed")), files); + assert.equal(files.graphicsDataDir, graphicsDataDir); + }); + it("records why when there is no full install", async function() { + const files = { zipCache: new Map() }; + await _findGraphicsDataDir(withInstalls(installs, "0.2.5"), files); + assert.equal(files.graphicsDataDir, undefined); + assert.equal(files.graphicsError, "Unable to find full Factorio version 0.2"); + }); + it("records why when the directory does not exist", async function() { + const files = { zipCache: new Map() }; + await _findGraphicsDataDir(withInstalls(path.join("test", "file", "missing")), files); + assert.match(files.graphicsError, /ENOENT/); }); }); describe("warnMissingBuiltinIcons()", function() { @@ -151,7 +159,15 @@ describe("host/src/export", function() { ])); assert.equal(warnings.length, 1); assert.match(warnings[0], /^2 icon files of the base game were not found/); - assert.match(warnings[0], /headless/); + assert.doesNotMatch(warnings[0], /headless/); + }); + it("explains the headless build when no full install was found", function() { + _warnMissingBuiltinIcons(testServer, { zipCache: new Map(), graphicsError: "Not found" }, new Map([ + ["__base__/graphics/icons/a.png", null], + ])); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /has no graphics and no full install was found \(Not found\)/); + assert.match(warnings[0], /Add a full install of Factorio 0\.1 to host\.factorio_directory/); }); it("does not warn when no builtin files are missing", function() { _warnMissingBuiltinIcons(testServer, { zipCache: new Map() }, new Map([ diff --git a/test/host/server.js b/test/host/server.js index da6169a9..a1cc1c33 100644 --- a/test/host/server.js +++ b/test/host/server.js @@ -105,6 +105,33 @@ describe("host/server", function() { ); }); }); + describe("headless option", function() { + const mixedDir = path.join("test", "file", "factorio-mixed"); + it("should find only full installs when false", async function() { + const [dir] = await hostServer.findVersion(mixedDir, "0.1.2", { headless: false }); + assert.equal(dir, path.join(mixedDir, "full", "data")); + }); + it("should find only headless installs when true", async function() { + const [dir] = await hostServer.findVersion(mixedDir, "0.1.2", { headless: true }); + assert.equal(dir, path.join(mixedDir, "headless", "data")); + }); + it("should reject a direct install of the other build", async function() { + const installDir = path.join(mixedDir, "headless"); + await assert.rejects( + hostServer.findVersion(installDir, "0.1.2", { headless: false }), + new Error( + `Unable to find a full install of Factorio 0.1.2: ${installDir} is a direct ` + + "(single-version) install of the headless build." + ) + ); + }); + it("should reject if no install of that build has the version", async function() { + await assert.rejects( + hostServer.findVersion(path.join("test", "file", "factorio"), "0.1.2", { headless: true }), + new Error("Unable to find headless Factorio version 0.1.2") + ); + }); + }); }); describe("_listFactorioVersions()", function() { From f8aafe9265e4240b5e5bf2b365ae5991407ee67d Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:44:43 +0000 Subject: [PATCH 4/4] Address review on findVersion and the export graphics lookup findVersion takes headless as a plain optional argument, the helper is checkInstallType, the warning is checkMissingBuiltinIcons and the full install lookup is inlined into exportData. Co-Authored-By: Claude Opus 5.5 --- packages/host/src/export.ts | 35 ++++++++++++---------------------- packages/host/src/server.ts | 19 +++++++++---------- test/host/export.js | 38 +++++-------------------------------- test/host/server.js | 15 ++++++++++----- 4 files changed, 36 insertions(+), 71 deletions(-) diff --git a/packages/host/src/export.ts b/packages/host/src/export.ts index 8be4843a..e77ff46e 100644 --- a/packages/host/src/export.ts +++ b/packages/host/src/export.ts @@ -97,6 +97,7 @@ async function loadZip(server: FactorioServer, modVersions: Map, /** Where icon files are read from during an export. */ interface ExportFiles { + /** Mod zips opened during the export, closed when it ends. */ zipCache: ZipCache; /** Data dir of a full install to fall back to for builtin mod files. */ graphicsDataDir?: string; @@ -525,23 +526,6 @@ async function exportLocale( return mergedLocales; } -/** - * Find a full install to read builtin mod graphics from - * - * @param server - The server the export runs on. - * @param files - Updated with the data dir found, or why there is none. - * @internal - */ -async function findGraphicsDataDir(server: FactorioServer, files: ExportFiles) { - const majorMinor = server.version!.replace(/\.\d+$/, "") as lib.MajorMinorVersion; - try { - [files.graphicsDataDir] = await findVersion(server._factorioDir, majorMinor, { headless: false }); - } catch (err: any) { - // Without it the export still has everything but the base game icons - files.graphicsError = err.message; - } -} - /** * Export locale, icons and metadata for the given factorio server * @@ -555,7 +539,13 @@ async function findGraphicsDataDir(server: FactorioServer, files: ExportFiles) { export async function exportData(server: FactorioServer) { const files: ExportFiles = { zipCache: new Map() }; if (await isHeadless(server.dataPath())) { - await findGraphicsDataDir(server, files); + const majorMinor = server.version!.replace(/\.\d+$/, "") as lib.MajorMinorVersion; + try { + [files.graphicsDataDir] = await findVersion(server._factorioDir, majorMinor, false); + } catch (err: any) { + // Without it the export still has everything but the base game icons + files.graphicsError = err.message; + } } try { return await buildExport(server, files); @@ -566,14 +556,14 @@ export async function exportData(server: FactorioServer) { } /** - * Warn about builtin mod icon files that could not be found + * Warn if any builtin mod icon files could not be found * * @param server - The server the export ran on. * @param files - Where icon files were read from. * @param iconCache - Icons loaded by the export, null for missing files. * @internal */ -function warnMissingBuiltinIcons(server: FactorioServer, files: ExportFiles, iconCache: IconCache) { +function checkMissingBuiltinIcons(server: FactorioServer, files: ExportFiles, iconCache: IconCache) { const builtinModNames = ["core", ...lib.ModPack.getBuiltinModNames(server.version!)]; let missing = 0; for (const [iconPath, icon] of iconCache) { @@ -703,7 +693,7 @@ async function buildExport(server: FactorioServer, files: ExportFiles) { const count = await exportIcons(server, modVersions, files, prototypesWithIcons, baseName, state); server._logger.info(`Exported ${count} ${baseName} icons`); } - warnMissingBuiltinIcons(server, files, state.iconCache); + checkMissingBuiltinIcons(server, files, state.iconCache); // Crop sheet to actual used height and write single spritesheet + metadata. const usedRows = Math.ceil(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE)); @@ -722,7 +712,6 @@ async function buildExport(server: FactorioServer, files: ExportFiles) { export const _createSheetState = createSheetState; export const _exportIcons = exportIcons; export const _exportLocale = exportLocale; -export const _findGraphicsDataDir = findGraphicsDataDir; export const _loadSimpleIcon = loadSimpleIcon; export const _loadLayeredIcon = loadLayeredIcon; -export const _warnMissingBuiltinIcons = warnMissingBuiltinIcons; +export const _checkMissingBuiltinIcons = checkMissingBuiltinIcons; diff --git a/packages/host/src/server.ts b/packages/host/src/server.ts index a3abe32b..1e1526a5 100644 --- a/packages/host/src/server.ts +++ b/packages/host/src/server.ts @@ -262,8 +262,7 @@ export async function checkForUpdates( * @param targetVersion - * Version to look for, supports the special value "latest" for the * latest version available. - * @param options - Optional parameters. - * @param options.headless - + * @param headless - * Only consider headless installs if true, or only full installs if * false. * @returns Array with path to data dir and version found. @@ -271,23 +270,23 @@ export async function checkForUpdates( export async function findVersion( factorioDir: string, targetVersion: lib.TargetVersion, - options: { headless?: boolean } = {}, + headless?: boolean, ): Promise<[string, lib.FullVersion]> { let kind = ""; - if (options.headless !== undefined) { - kind = options.headless ? "headless " : "full "; + if (headless !== undefined) { + kind = headless ? "headless " : "full "; } - const wrongKind = async (dir: string) => ( - options.headless !== undefined && await isHeadless(path.join(dir, "data")) !== options.headless + const checkInstallType = async (dir: string) => ( + headless === undefined || await isHeadless(path.join(dir, "data")) === headless ); // Check if this is a direct installation dir const directVersion = await getFactorioVersion(factorioDir); if (directVersion !== null) { - if (await wrongKind(factorioDir)) { + if (!await checkInstallType(factorioDir)) { throw new Error( `Unable to find a ${kind}install of Factorio ${targetVersion}: ${factorioDir} is a direct ` + - `(single-version) install of the ${options.headless ? "full" : "headless"} build.` + `(single-version) install of the ${headless ? "full" : "headless"} build.` ); } if (targetVersion === "latest" || directVersion === targetVersion || directVersion.startsWith(targetVersion)) { @@ -306,7 +305,7 @@ export async function findVersion( // Avoid any fancy directory detection in order to follow symlinks const fullPath = path.join(factorioDir, name); const version = await getFactorioVersion(fullPath); - if (version === null || await wrongKind(fullPath)) { + if (version === null || !await checkInstallType(fullPath)) { continue; } diff --git a/test/host/export.js b/test/host/export.js index 0d7a2806..b5f12579 100644 --- a/test/host/export.js +++ b/test/host/export.js @@ -3,8 +3,7 @@ import path from "node:path"; import { FactorioServer } from "@clusterio/host/dist/node/src/server.js"; import { - _createSheetState, _exportIcons, _exportLocale, _findGraphicsDataDir, _loadLayeredIcon, _loadSimpleIcon, - _warnMissingBuiltinIcons, + _checkMissingBuiltinIcons, _createSheetState, _exportIcons, _exportLocale, _loadLayeredIcon, _loadSimpleIcon, } from "@clusterio/host/dist/node/src/export.js"; @@ -122,36 +121,9 @@ describe("host/src/export", function() { assert.equal(icon, null); }); }); - describe("findGraphicsDataDir()", function() { - const installs = path.join("test", "file", "factorio"); - const withInstalls = (factorioDir, version = testServer.version) => Object.create(testServer, { - _factorioDir: { value: factorioDir }, version: { value: version }, - }); - it("finds a full install matching the major and minor version of the server", async function() { - const files = { zipCache: new Map() }; - await _findGraphicsDataDir(withInstalls(path.join("test", "file", "factorio"), "0.1.1"), files); - assert.deepEqual(files, { zipCache: new Map(), graphicsDataDir: path.join(installs, "0.1.2", "data") }); - }); - it("skips headless installs of the same version", async function() { - const files = { zipCache: new Map() }; - await _findGraphicsDataDir(withInstalls(path.join("test", "file", "factorio-mixed")), files); - assert.equal(files.graphicsDataDir, graphicsDataDir); - }); - it("records why when there is no full install", async function() { - const files = { zipCache: new Map() }; - await _findGraphicsDataDir(withInstalls(installs, "0.2.5"), files); - assert.equal(files.graphicsDataDir, undefined); - assert.equal(files.graphicsError, "Unable to find full Factorio version 0.2"); - }); - it("records why when the directory does not exist", async function() { - const files = { zipCache: new Map() }; - await _findGraphicsDataDir(withInstalls(path.join("test", "file", "missing")), files); - assert.match(files.graphicsError, /ENOENT/); - }); - }); - describe("warnMissingBuiltinIcons()", function() { + describe("checkMissingBuiltinIcons()", function() { it("warns once with the number of missing builtin files", function() { - _warnMissingBuiltinIcons(testServer, { zipCache: new Map() }, new Map([ + _checkMissingBuiltinIcons(testServer, { zipCache: new Map() }, new Map([ ["__base__/graphics/icons/a.png", null], ["__core__/graphics/icons/b.png", null], ["__base__/graphics/icons/c.png", {}], @@ -162,7 +134,7 @@ describe("host/src/export", function() { assert.doesNotMatch(warnings[0], /headless/); }); it("explains the headless build when no full install was found", function() { - _warnMissingBuiltinIcons(testServer, { zipCache: new Map(), graphicsError: "Not found" }, new Map([ + _checkMissingBuiltinIcons(testServer, { zipCache: new Map(), graphicsError: "Not found" }, new Map([ ["__base__/graphics/icons/a.png", null], ])); assert.equal(warnings.length, 1); @@ -170,7 +142,7 @@ describe("host/src/export", function() { assert.match(warnings[0], /Add a full install of Factorio 0\.1 to host\.factorio_directory/); }); it("does not warn when no builtin files are missing", function() { - _warnMissingBuiltinIcons(testServer, { zipCache: new Map() }, new Map([ + _checkMissingBuiltinIcons(testServer, { zipCache: new Map() }, new Map([ ["__base__/graphics/icons/c.png", {}], ["__some_mod__/graphics/icons/d.png", null], ])); diff --git a/test/host/server.js b/test/host/server.js index a1cc1c33..043aae0c 100644 --- a/test/host/server.js +++ b/test/host/server.js @@ -105,20 +105,25 @@ describe("host/server", function() { ); }); }); - describe("headless option", function() { + describe("headless argument", function() { const mixedDir = path.join("test", "file", "factorio-mixed"); it("should find only full installs when false", async function() { - const [dir] = await hostServer.findVersion(mixedDir, "0.1.2", { headless: false }); + const [dir] = await hostServer.findVersion(mixedDir, "0.1.2", false); assert.equal(dir, path.join(mixedDir, "full", "data")); }); it("should find only headless installs when true", async function() { - const [dir] = await hostServer.findVersion(mixedDir, "0.1.2", { headless: true }); + const [dir] = await hostServer.findVersion(mixedDir, "0.1.2", true); assert.equal(dir, path.join(mixedDir, "headless", "data")); }); + it("should skip installs of the other build when matching a partial version", async function() { + const installDir = path.join("test", "file", "factorio"); + const [dir, version] = await hostServer.findVersion(installDir, "0.1", false); + assert.deepEqual([dir, version], [path.join(installDir, "0.1.2", "data"), "0.1.2"]); + }); it("should reject a direct install of the other build", async function() { const installDir = path.join(mixedDir, "headless"); await assert.rejects( - hostServer.findVersion(installDir, "0.1.2", { headless: false }), + hostServer.findVersion(installDir, "0.1.2", false), new Error( `Unable to find a full install of Factorio 0.1.2: ${installDir} is a direct ` + "(single-version) install of the headless build." @@ -127,7 +132,7 @@ describe("host/server", function() { }); it("should reject if no install of that build has the version", async function() { await assert.rejects( - hostServer.findVersion(path.join("test", "file", "factorio"), "0.1.2", { headless: true }), + hostServer.findVersion(path.join("test", "file", "factorio"), "0.1.2", true), new Error("Unable to find headless Factorio version 0.1.2") ); });