Merge pull request #952 from lntutor/feat/mod-info-recommendations-949

Show informational mod recommendations
This commit is contained in:
Cooldude2606 authored and GitHub committed 2026-07-26 12:34:09 +01:00
commit d7eb647c09
4 files changed
+213 -26

No files matched your search

+85 -1
View File
@@ -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,86 @@ 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<ModInfo>) {
const updates = new Map<string, SourceVersion>();
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<string, SourceVersion> = 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;
}
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({
+2 -2
View File
@@ -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";
@@ -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" && <Tooltip title="Mod is missing from storage.">
<FileUnknownOutlined style={{ color: "#a61d24" }} />{" "}
</Tooltip>}
{mod.error === "bad_checksum" && <Tooltip title="Mod checksum mismatch.">
<FileExclamationOutlined style={{ color: "#a61d24" }} />{" "}
</Tooltip>}
{mod.warning === "incompatible" && <Tooltip title="Mod is incompatible with another.">
<FileExclamationOutlined style={{ color: "#dd5e14" }} />{" "}
</Tooltip>}
{mod.warning === "missing_dependency" && <Tooltip title="Mod is missing a dependency.">
<FileUnknownOutlined style={{ color: "#dd5e14" }} />{" "}
</Tooltip>}
{mod.warning === "wrong_version" && <Tooltip title="Mod has wrong dependency version added.">
<FileSyncOutlined style={{ color: "#dd5e14" }} />{" "}
</Tooltip>}
{mod.warning === "wrong_factorio_version" && <Tooltip title="Mod has wrong factorio version.">
<FileSyncOutlined style={{ color: "#dd5e14" }} />{" "}
</Tooltip>}
{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" && <Tooltip title="Mod is missing from storage.">
<FileUnknownOutlined style={{ color: "#a61d24" }} />{" "}
</Tooltip>}
{mod.error === "bad_checksum" && <Tooltip title="Mod checksum mismatch.">
<FileExclamationOutlined style={{ color: "#a61d24" }} />{" "}
</Tooltip>}
{mod.warning === "incompatible" && <Tooltip title="Mod is incompatible with another.">
<FileExclamationOutlined style={{ color: "#dd5e14" }} />{" "}
</Tooltip>}
{mod.warning === "missing_dependency" && <Tooltip title="Mod is missing a dependency.">
<FileUnknownOutlined style={{ color: "#dd5e14" }} />{" "}
</Tooltip>}
{mod.warning === "wrong_version" && <Tooltip
title="Mod has wrong dependency version added."
>
<FileSyncOutlined style={{ color: "#dd5e14" }} />{" "}
</Tooltip>}
{mod.warning === "wrong_factorio_version" && <Tooltip
title="Mod has wrong factorio version."
>
<FileSyncOutlined style={{ color: "#dd5e14" }} />{" "}
</Tooltip>}
{recommendedBy.length > 0 && <Tooltip
title={`Recommended by ${recommendedBy.join(", ")}, but currently disabled.`}
>
<LikeOutlined style={{ color: "#0958d9" }} />{" "}
</Tooltip>}
{update?.type === "update_available" && <Tooltip
title={`A newer compatible version (${update.version}) is available.`}
>
<CloudSyncOutlined style={{ color: "#0958d9" }} />{" "}
</Tooltip>}
{mod.info?.title || mod.name}
</>;
},
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a.name, b.name),
},
+81 -1
View File
@@ -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" },
]);
});
});
});