From 5e6b06f009ce34d759864911af948428d654989d Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:30:32 +0000 Subject: [PATCH 1/2] Filter dependency resolution by the mod pack's Factorio version The dependency resolver picked the newest release satisfying the version range regardless of which Factorio version it targeted, so a 2.0 mod pack could end up with 2.1 dependencies. Only consider portal releases and local mods whose factorio_version matches the mod pack. Fixes #965 Co-Authored-By: Claude Fable 5 --- packages/controller/src/ControlConnection.ts | 15 +++- test/controller/ControlConnection.test.js | 73 ++++++++++++++++++++ 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index 6baf2fe4..c9c9f8b0 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -713,9 +713,14 @@ export default class ControlConnection extends BaseConnection { return null; } - // Select the latest matching version + // Select the latest matching version for the mod pack's Factorio version const release = releases.releases - .filter(info => versionRange.testVersion(info.version)) + .filter(info => ( + versionRange.testVersion(info.version) + && lib.normaliseMajorMinorVersion( + lib.normaliseSourceVersion(info.info_json.factorio_version) + ) === factorioVersion + )) .reduce((max, cur) => ( max && lib.integerSourceVersion(max.version) > lib.integerSourceVersion(cur.version) ? max : cur ), undefined); @@ -797,7 +802,11 @@ export default class ControlConnection extends BaseConnection { // Check if a local version fits the requirements let candidate = localInfos - .filter(info => (info.name === mod.name && versionRange.testVersion(info.version))) + .filter(info => ( + info.name === mod.name + && info.factorioVersion === factorioVersion + && versionRange.testVersion(info.version) + )) .reduce((max, cur) => ( max && max.integerVersion > cur.integerVersion ? max : cur ), undefined); diff --git a/test/controller/ControlConnection.test.js b/test/controller/ControlConnection.test.js index 90483a08..f0cbccb7 100644 --- a/test/controller/ControlConnection.test.js +++ b/test/controller/ControlConnection.test.js @@ -1,5 +1,6 @@ "use strict"; const assert = require("assert").strict; +const lib = require("@clusterio/lib"); const { ControlConnection } = require("@clusterio/controller"); describe("controller/src/ControlConnection", function() { @@ -39,4 +40,76 @@ describe("controller/src/ControlConnection", function() { assert.equal(mockController.stopped, false); }); }); + + describe(".handleModDependencyResolveRequest()", function() { + const originalFetchModReleases = lib.ModStore.fetchModReleases; + const portal = new Map(); + let localMods; + + function release(version, factorioVersion, dependencies = []) { + return { + version, sha1: "0".repeat(40), + info_json: { factorio_version: factorioVersion, dependencies }, + }; + } + + before(function() { + lib.ModStore.fetchModReleases = async (name) => { + const releases = portal.get(name); + if (!releases) { + throw new Error("Fetch: returned 404 Not Found"); + } + return { name, owner: "owner", title: name, releases }; + }; + }); + after(function() { + lib.ModStore.fetchModReleases = originalFetchModReleases; + }); + beforeEach(function() { + portal.clear(); + localMods = []; + }); + + async function resolve(mods, factorioVersion, checkForUpdates = false) { + const response = await ControlConnection.prototype.handleModDependencyResolveRequest.call( + { _controller: { modStore: { mods: () => localMods } } }, + new lib.ModDependencyResolveRequest( + mods.map(mod => new lib.ModDependency(mod)), factorioVersion, checkForUpdates, + ), + ); + return new Map(response.dependencies.map(mod => [mod.name, mod])); + } + + it("only selects portal releases matching the mod pack's Factorio version", async function() { + portal.set("root", [release("1.0.0", "2.0", ["dep"]), release("1.1.0", "2.1", ["dep"])]); + portal.set("dep", [ + release("1.0.0", "1.1"), + release("2.0.0", "2.0"), + release("3.0.0", "2.1"), + ]); + assert.equal((await resolve(["root = 1.0.0"], "2.0")).get("dep").version, "2.0.0"); + assert.equal((await resolve(["root = 1.1.0"], "2.1")).get("dep").version, "3.0.0"); + }); + + it("only selects local mods matching the mod pack's Factorio version", async function() { + portal.set("root", [release("1.0.0", "2.0", ["dep"])]); + portal.set("dep", [release("2.0.0", "2.0")]); + localMods = [ + lib.ModInfo.fromJSON({ name: "dep", version: "3.0.0", factorio_version: "2.1" }), + lib.ModInfo.fromJSON({ name: "dep", version: "1.0.0", factorio_version: "2.0" }), + ]; + assert.equal((await resolve(["root = 1.0.0"], "2.0")).get("dep").version, "1.0.0"); + assert.equal((await resolve(["root = 1.0.0"], "2.0", true)).get("dep").version, "2.0.0"); + }); + + it("reports dependencies without a matching Factorio version as not found", async function() { + portal.set("root", [release("1.0.0", "2.0", ["dep"])]); + portal.set("dep", [release("3.0.0", "2.1")]); + const response = await ControlConnection.prototype.handleModDependencyResolveRequest.call( + { _controller: { modStore: { mods: () => localMods } } }, + new lib.ModDependencyResolveRequest([new lib.ModDependency("root = 1.0.0")], "2.0", false), + ); + assert.equal(response.errors.get("dep"), "notFound"); + }); + }); }); From 5e633ac2f1011198d1f3c3702d1e1b9fe3fbfcad Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:54:19 +0000 Subject: [PATCH 2/2] Move dependency resolver tests to test/messages/mod.test.js Also update the existing local mod fixtures to carry a factorio_version and drop enable-all-feature-flags from the live expectations: the 2.0 releases of pyalternativeenergy only list it as optional, it was only pulled in before because 2.1 releases were being selected. Co-Authored-By: Claude Fable 5 --- test/controller/ControlConnection.test.js | 73 ----------------------- test/messages/mod.test.js | 72 +++++++++++++++++++++- 2 files changed, 69 insertions(+), 76 deletions(-) diff --git a/test/controller/ControlConnection.test.js b/test/controller/ControlConnection.test.js index f0cbccb7..90483a08 100644 --- a/test/controller/ControlConnection.test.js +++ b/test/controller/ControlConnection.test.js @@ -1,6 +1,5 @@ "use strict"; const assert = require("assert").strict; -const lib = require("@clusterio/lib"); const { ControlConnection } = require("@clusterio/controller"); describe("controller/src/ControlConnection", function() { @@ -40,76 +39,4 @@ describe("controller/src/ControlConnection", function() { assert.equal(mockController.stopped, false); }); }); - - describe(".handleModDependencyResolveRequest()", function() { - const originalFetchModReleases = lib.ModStore.fetchModReleases; - const portal = new Map(); - let localMods; - - function release(version, factorioVersion, dependencies = []) { - return { - version, sha1: "0".repeat(40), - info_json: { factorio_version: factorioVersion, dependencies }, - }; - } - - before(function() { - lib.ModStore.fetchModReleases = async (name) => { - const releases = portal.get(name); - if (!releases) { - throw new Error("Fetch: returned 404 Not Found"); - } - return { name, owner: "owner", title: name, releases }; - }; - }); - after(function() { - lib.ModStore.fetchModReleases = originalFetchModReleases; - }); - beforeEach(function() { - portal.clear(); - localMods = []; - }); - - async function resolve(mods, factorioVersion, checkForUpdates = false) { - const response = await ControlConnection.prototype.handleModDependencyResolveRequest.call( - { _controller: { modStore: { mods: () => localMods } } }, - new lib.ModDependencyResolveRequest( - mods.map(mod => new lib.ModDependency(mod)), factorioVersion, checkForUpdates, - ), - ); - return new Map(response.dependencies.map(mod => [mod.name, mod])); - } - - it("only selects portal releases matching the mod pack's Factorio version", async function() { - portal.set("root", [release("1.0.0", "2.0", ["dep"]), release("1.1.0", "2.1", ["dep"])]); - portal.set("dep", [ - release("1.0.0", "1.1"), - release("2.0.0", "2.0"), - release("3.0.0", "2.1"), - ]); - assert.equal((await resolve(["root = 1.0.0"], "2.0")).get("dep").version, "2.0.0"); - assert.equal((await resolve(["root = 1.1.0"], "2.1")).get("dep").version, "3.0.0"); - }); - - it("only selects local mods matching the mod pack's Factorio version", async function() { - portal.set("root", [release("1.0.0", "2.0", ["dep"])]); - portal.set("dep", [release("2.0.0", "2.0")]); - localMods = [ - lib.ModInfo.fromJSON({ name: "dep", version: "3.0.0", factorio_version: "2.1" }), - lib.ModInfo.fromJSON({ name: "dep", version: "1.0.0", factorio_version: "2.0" }), - ]; - assert.equal((await resolve(["root = 1.0.0"], "2.0")).get("dep").version, "1.0.0"); - assert.equal((await resolve(["root = 1.0.0"], "2.0", true)).get("dep").version, "2.0.0"); - }); - - it("reports dependencies without a matching Factorio version as not found", async function() { - portal.set("root", [release("1.0.0", "2.0", ["dep"])]); - portal.set("dep", [release("3.0.0", "2.1")]); - const response = await ControlConnection.prototype.handleModDependencyResolveRequest.call( - { _controller: { modStore: { mods: () => localMods } } }, - new lib.ModDependencyResolveRequest([new lib.ModDependency("root = 1.0.0")], "2.0", false), - ); - assert.equal(response.errors.get("dep"), "notFound"); - }); - }); }); diff --git a/test/messages/mod.test.js b/test/messages/mod.test.js index f782d5fc..aee89b74 100644 --- a/test/messages/mod.test.js +++ b/test/messages/mod.test.js @@ -384,8 +384,10 @@ describe("messages/mod", function() { } }); it("prefers the locally installed version if matching", async function() { - controller.modStore.addMod(lib.ModInfo.fromJSON({ name: "foo", version: "1.0.0" })); for (const factorioVersion of factorioVersions) { + controller.modStore.addMod(lib.ModInfo.fromJSON({ + name: "foo", version: "1.0.0", factorio_version: factorioVersion, + })); setPortalModRelease("root", "1.0.0", factorioVersion, ["foo"]); setPortalModRelease("foo", "2.0.0", factorioVersion, []); @@ -402,8 +404,10 @@ describe("messages/mod", function() { } }); it("prefers the mod portal version when checking for updates", async function() { - controller.modStore.addMod(lib.ModInfo.fromJSON({ name: "foo", version: "1.0.0" })); for (const factorioVersion of factorioVersions) { + controller.modStore.addMod(lib.ModInfo.fromJSON({ + name: "foo", version: "1.0.0", factorio_version: factorioVersion, + })); setPortalModRelease("root", "1.0.0", factorioVersion, ["foo"]); setPortalModRelease("foo", "2.0.0", factorioVersion, []); @@ -455,6 +459,68 @@ describe("messages/mod", function() { assert.deepEqual(depIds, new Set(["root_1.0.0", "foo_2.0.0", "bar_1.5.0"])); } }); + it("only selects portal releases matching the factorio version", async function() { + setPortalModRelease("root", "1.0.0", "2.0", ["foo"]); + ModReleases.set("foo", { + name: "foo", + releases: [{ + version: "1.0.0", info_json: { factorio_version: "1.1", dependencies: [] }, + }, { + version: "2.0.0", info_json: { factorio_version: "2.0", dependencies: [] }, + }, { + version: "3.0.0", info_json: { factorio_version: "2.1", dependencies: [] }, + }], + }); + + const result = await controlConnection.handleModDependencyResolveRequest( + new lib.ModDependencyResolveRequest([new ModDependency("root")], "2.0") + ); + + assert.deepEqual(result.errors, new Map([ + ["base", "notFound"], + ])); + + const depIds = new Set(result.dependencies.map(mod => mod.id)); + assert.deepEqual(depIds, new Set(["root_1.0.0", "foo_2.0.0"])); + }); + it("only selects local mods matching the factorio version", async function() { + controller.modStore.addMod(lib.ModInfo.fromJSON({ + name: "foo", version: "1.0.0", factorio_version: "2.0", + })); + controller.modStore.addMod(lib.ModInfo.fromJSON({ + name: "foo", version: "3.0.0", factorio_version: "2.1", + })); + setPortalModRelease("root", "1.0.0", "2.0", ["foo"]); + setPortalModRelease("foo", "2.0.0", "2.0", []); + + let result = await controlConnection.handleModDependencyResolveRequest( + new lib.ModDependencyResolveRequest([new ModDependency("root")], "2.0") + ); + let depIds = new Set(result.dependencies.map(mod => mod.id)); + assert.deepEqual(depIds, new Set(["root_1.0.0", "foo_1.0.0"])); + + result = await controlConnection.handleModDependencyResolveRequest( + new lib.ModDependencyResolveRequest([new ModDependency("root")], "2.0", true) + ); + depIds = new Set(result.dependencies.map(mod => mod.id)); + assert.deepEqual(depIds, new Set(["root_1.0.0", "foo_2.0.0"])); + }); + it("highlights dependencies with no release for the factorio version", async function() { + setPortalModRelease("root", "1.0.0", "2.0", ["foo"]); + setPortalModRelease("foo", "1.0.0", "2.1", []); + + const result = await controlConnection.handleModDependencyResolveRequest( + new lib.ModDependencyResolveRequest([new ModDependency("root")], "2.0") + ); + + assert.deepEqual(result.errors, new Map([ + ["base", "notFound"], + ["foo", "notFound"], + ])); + + assert.equal(result.dependencies.length, 1); + assert.equal(result.dependencies[0].id, "root_1.0.0"); + }); it("rejects when a network error occurs", async function() { lib.ModStore.fetchModReleases = function() { throw new Error("Mock network error"); @@ -484,7 +550,7 @@ describe("messages/mod", function() { "pyalienlife", "pyalienlifegraphics", "pyalienlifegraphics2", "pyalienlifegraphics3", "pycoalprocessing", "pycoalprocessinggraphics", "pyfusionenergy", "pyfusionenergygraphics", "pypetroleumhandling", "pypetroleumhandlinggraphics", "pyrawores", "pyraworesgraphics", - "pyhightech", "pyhightechgraphics", "pyindustry", "pyindustrygraphics", "enable-all-feature-flags", + "pyhightech", "pyhightechgraphics", "pyindustry", "pyindustrygraphics", ])); }); });