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() {