From dfe52df7bbadc03ee9ca030838170d9c12d1d661 Mon Sep 17 00:00:00 2001 From: Loi Nguyen <1948922+lntutor@users.noreply.github.com> Date: Mon, 20 Jul 2026 02:04:35 +0700 Subject: [PATCH 1/2] Show informational mod recommendations --- packages/lib/src/data/ModPack.ts | 91 ++++++++++++++++++- packages/lib/src/data/index.ts | 4 +- .../web_ui/src/components/ModPackViewPage.tsx | 67 +++++++++----- test/lib/data/ModPack.js | 82 ++++++++++++++++- 4 files changed, 218 insertions(+), 26 deletions(-) diff --git a/packages/lib/src/data/ModPack.ts b/packages/lib/src/data/ModPack.ts index 4968980d..1db62177 100644 --- a/packages/lib/src/data/ModPack.ts +++ b/packages/lib/src/data/ModPack.ts @@ -12,7 +12,7 @@ import ModInfo, { ModDependencyUnsatisfiedReason } from "./ModInfo"; import { PartialVersion, PartialVersionSchema, integerPartialVersion, - SourceVersion, SourceVersionSchema, normaliseFullVersion, + SourceVersion, SourceVersionSchema, normaliseFullVersion, integerSourceVersion, } from "./version"; @@ -32,6 +32,10 @@ export interface ModSetting { value: boolean | number | string | ModSettingColor; } +export type ModRecordAdvisory = + | { type: "recommended_dependency", sourceModName: string } + | { type: "update_available", version: SourceVersion }; + const ModSettingJsonSchema = Type.Object({ "value": Type.Union([Type.Boolean(), Type.Number(), Type.String(), ModSettingColor]), }); @@ -56,6 +60,91 @@ export interface ModRecord { warning?: ModDependencyUnsatisfiedReason | "wrong_factorio_version", /** Used inside packages\web_ui\src\components\ModPackViewPage.tsx when there is no error. */ info?: ModInfo, + /** Non-blocking recommendations displayed by the Web UI. */ + advisories?: ModRecordAdvisory[], +} + +type AnnotatedModRecord = ModRecord & { advisories: ModRecordAdvisory[] }; + +/** + * Finds newer versions already installed on the controller for mods in a pack. + */ +export function getInstalledModUpdates(mods: ModRecord[], installedMods: Iterable) { + const updates = new Map(); + + for (const mod of mods) { + if (!mod.info) { + continue; + } + + for (const installedMod of installedMods) { + if ( + installedMod.name !== mod.name + || installedMod.factorioVersion !== mod.info.factorioVersion + || installedMod.integerVersion <= integerSourceVersion(mod.version) + ) { + continue; + } + + const existingUpdate = updates.get(mod.name); + if (!existingUpdate || installedMod.integerVersion > integerSourceVersion(existingUpdate)) { + updates.set(mod.name, installedMod.version); + } + } + } + + return updates; +} + +export function applyModRecordAdvisories( + mods: ModRecord[], + availableUpdates: ReadonlyMap = new Map(), +) { + const annotatedMods: AnnotatedModRecord[] = mods.map(mod => ({ ...mod, advisories: [] })); + const modsByName = new Map(annotatedMods.map(mod => [mod.name, mod])); + + for (const sourceMod of annotatedMods) { + if (!sourceMod.enabled || !sourceMod.info) { + continue; + } + + for (const dependency of sourceMod.info.dependencies) { + if (!dependency.recommended) { + continue; + } + + const targetMod = modsByName.get(dependency.name); + if ( + !targetMod + || targetMod.enabled + || dependency.version && !dependency.version.testVersion(targetMod.version) + ) { + continue; + } + + if (!targetMod.advisories.some( + advisory => advisory.type === "recommended_dependency" + && advisory.sourceModName === sourceMod.name + )) { + targetMod.advisories.push({ + type: "recommended_dependency", + sourceModName: sourceMod.name, + }); + } + } + } + + for (const mod of annotatedMods) { + const availableVersion = availableUpdates.get(mod.name); + if (availableVersion && integerSourceVersion(availableVersion) > integerSourceVersion(mod.version)) { + mod.advisories.push({ type: "update_available", version: availableVersion }); + } + if (mod.advisories.length === 0) { + delete (mod as ModRecord).advisories; + } + } + + return annotatedMods; } const ModRecordJsonSchema = Type.Object({ diff --git a/packages/lib/src/data/index.ts b/packages/lib/src/data/index.ts index d93dc7d2..a8ca8a96 100644 --- a/packages/lib/src/data/index.ts +++ b/packages/lib/src/data/index.ts @@ -5,8 +5,8 @@ */ export { default as ExportManifest } from "./ExportManifest"; export { default as ModInfo, ModDependency } from "./ModInfo"; -export { ModSettingColor } from "./ModPack"; -export type { ModSetting, ModRecord } from "./ModPack"; +export { ModSettingColor, getInstalledModUpdates, applyModRecordAdvisories } from "./ModPack"; +export type { ModSetting, ModRecord, ModRecordAdvisory } from "./ModPack"; export { default as ModPack } from "./ModPack"; export { default as ModuleInfo } from "./ModuleInfo"; export { default as Permission } from "./Permission"; diff --git a/packages/web_ui/src/components/ModPackViewPage.tsx b/packages/web_ui/src/components/ModPackViewPage.tsx index 937450e2..c00a4315 100644 --- a/packages/web_ui/src/components/ModPackViewPage.tsx +++ b/packages/web_ui/src/components/ModPackViewPage.tsx @@ -11,6 +11,7 @@ import { import { ExportOutlined, FileUnknownOutlined, FileExclamationOutlined, FileSyncOutlined, CloseOutlined, DeleteOutlined, ToolOutlined, PlusOutlined, CloudSyncOutlined, CloudDownloadOutlined, + LikeOutlined, } from "@ant-design/icons"; import type { SorterResult, FilterValue, TableCurrentDataSource } from "antd/es/table/interface"; @@ -545,7 +546,7 @@ function ModsTable(props: ModsTableProps) { } } - const mods = [...props.modPack.mods.values(), ...deletedMods.values()].map( + let mods = [...props.modPack.mods.values(), ...deletedMods.values()].map( (mod: lib.ModRecord): lib.ModRecord => { if (props.builtInModNames.includes(mod.name)) { return { @@ -590,6 +591,7 @@ function ModsTable(props: ModsTableProps) { } } } + mods = lib.applyModRecordAdvisories(mods, lib.getInstalledModUpdates(mods, modInfos.values())); async function fixDependencyIssues(mod: lib.ModRecord) { if (!mod.info) { @@ -766,27 +768,48 @@ function ModsTable(props: ModsTableProps) { { title: "Name", key: "name", - render: (_, mod: lib.ModRecord) => <> - {mod.error === "missing" && - {" "} - } - {mod.error === "bad_checksum" && - {" "} - } - {mod.warning === "incompatible" && - {" "} - } - {mod.warning === "missing_dependency" && - {" "} - } - {mod.warning === "wrong_version" && - {" "} - } - {mod.warning === "wrong_factorio_version" && - {" "} - } - {mod.info?.title || mod.name} - , + render: (_, mod: lib.ModRecord) => { + const recommendedBy = mod.advisories + ?.filter(advisory => advisory.type === "recommended_dependency") + .map(advisory => advisory.sourceModName) ?? []; + const update = mod.advisories + ?.find(advisory => advisory.type === "update_available"); + return <> + {mod.error === "missing" && + {" "} + } + {mod.error === "bad_checksum" && + {" "} + } + {mod.warning === "incompatible" && + {" "} + } + {mod.warning === "missing_dependency" && + {" "} + } + {mod.warning === "wrong_version" && + {" "} + } + {mod.warning === "wrong_factorio_version" && + {" "} + } + {recommendedBy.length > 0 && + {" "} + } + {update?.type === "update_available" && + {" "} + } + {mod.info?.title || mod.name} + ; + }, defaultSortOrder: "ascend", sorter: (a, b) => strcmp(a.name, b.name), }, diff --git a/test/lib/data/ModPack.js b/test/lib/data/ModPack.js index 5000845b..b813374b 100644 --- a/test/lib/data/ModPack.js +++ b/test/lib/data/ModPack.js @@ -3,7 +3,7 @@ const assert = require("assert").strict; const zlib = require("zlib"); const lib = require("@clusterio/lib"); -const { ModPack } = lib; +const { ModInfo, ModPack, getInstalledModUpdates, applyModRecordAdvisories } = lib; describe("lib/data/ModPack", function() { @@ -421,4 +421,84 @@ describe("lib/data/ModPack", function() { }); }); }); + + describe("applyModRecordAdvisories()", function() { + it("finds newer installed versions for the same Factorio version", function() { + const current = ModInfo.fromJSON({ + name: "source", + version: "1.0.0", + factorio_version: "2.0", + }); + const newer = ModInfo.fromJSON({ + name: "source", + version: "1.1.0", + factorio_version: "2.0", + }); + const otherFactorioVersion = ModInfo.fromJSON({ + name: "source", + version: "2.0.0", + factorio_version: "1.1", + }); + + const updates = getInstalledModUpdates([ + { name: "source", enabled: true, version: "1.0.0", info: current }, + ], [current, newer, otherFactorioVersion]); + + assert.deepEqual([...updates], [["source", "1.1.0"]]); + }); + + it("marks compatible disabled recommended dependencies", function() { + const sourceInfo = ModInfo.fromJSON({ + name: "source", + version: "1.0.0", + dependencies: ["+ quality >= 2.0.0", "+ stale >= 2.0.0", "+ active"], + }); + const otherInfo = ModInfo.fromJSON({ + name: "other", + version: "1.0.0", + dependencies: ["+ quality"], + }); + const mods = [ + { name: "source", enabled: true, version: "1.0.0", info: sourceInfo }, + { name: "other", enabled: true, version: "1.0.0", info: otherInfo }, + { name: "ignored", enabled: false, version: "1.0.0", info: otherInfo }, + { name: "quality", enabled: false, version: "2.0.0" }, + { name: "stale", enabled: false, version: "1.0.0" }, + { name: "active", enabled: true, version: "1.0.0" }, + ]; + + const annotated = applyModRecordAdvisories(mods); + + assert.deepEqual(annotated[3].advisories, [ + { type: "recommended_dependency", sourceModName: "source" }, + { type: "recommended_dependency", sourceModName: "other" }, + ]); + assert.equal(annotated[4].advisories, undefined); + assert.equal(annotated[5].advisories, undefined); + assert.equal(mods[3].advisories, undefined); + }); + + it("marks only newer resolved updates and preserves other advisories", function() { + const sourceInfo = ModInfo.fromJSON({ + name: "source", + version: "1.0.0", + dependencies: ["+ quality"], + }); + const mods = [ + { name: "source", enabled: true, version: "1.0.0", info: sourceInfo }, + { name: "quality", enabled: false, version: "2.0.0" }, + ]; + + const annotated = applyModRecordAdvisories(mods, new Map([ + ["source", "1.0.0"], + ["quality", "2.1.0"], + ])); + + assert.deepEqual(annotated[0].advisories, undefined); + assert.deepEqual(annotated[1].advisories, [ + { type: "recommended_dependency", sourceModName: "source" }, + { type: "update_available", version: "2.1.0" }, + ]); + }); + }); }); From 74bd19c0632b6e336581333e0ee97cdba28757af Mon Sep 17 00:00:00 2001 From: Loi Nguyen Date: Sun, 26 Jul 2026 17:26:17 +0700 Subject: [PATCH 2/2] Remove redundant recommended-dependency duplicate guard Address review feedback: modsByName is built from annotatedMods so the advisories array always exists, and each source mod is iterated once with no duplicate dependencies, so the existence check can never fail. Push the recommended_dependency advisory directly. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01G8b9dEaJtpvydzz4ttM7JH --- packages/lib/src/data/ModPack.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/lib/src/data/ModPack.ts b/packages/lib/src/data/ModPack.ts index 1db62177..33251845 100644 --- a/packages/lib/src/data/ModPack.ts +++ b/packages/lib/src/data/ModPack.ts @@ -122,15 +122,10 @@ export function applyModRecordAdvisories( continue; } - if (!targetMod.advisories.some( - advisory => advisory.type === "recommended_dependency" - && advisory.sourceModName === sourceMod.name - )) { - targetMod.advisories.push({ - type: "recommended_dependency", - sourceModName: sourceMod.name, - }); - } + targetMod.advisories.push({ + type: "recommended_dependency", + sourceModName: sourceMod.name, + }); } }