From 97a6c328380c40b3cc6402d82cb3688109c42bb1 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:24:43 +0000 Subject: [PATCH 1/8] Parse mod versions leniently like the game does The mod portal "ApiVersion" enum was validated strictly when loading a mod's info.json, which rejected versions the game itself accepts. Versions are now normalised the same lenient way the game reads them: leading whitespace is allowed, the major and minor are required, the patch is optional, any trailing content is ignored, and numbers are read as integers. This applies to both a mod's version and its factorio_version, validated by a regex schema rather than the fixed enum. The enum is no longer used to validate versions at all - the mod portal is the source of truth, so its error is now surfaced when a version is unsupported. The known-version list kept for the web interface fallback gains 2.1. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ctl/src/commands_mod.ts | 6 +- packages/lib/src/ModStore.ts | 21 +++++-- packages/lib/src/data/ModInfo.ts | 31 ++++++++--- packages/lib/src/data/messages_mod.ts | 25 +++++---- packages/lib/src/data/version.ts | 55 +++++++++++++------ .../web_ui/src/components/ModPackViewPage.tsx | 12 ++-- packages/web_ui/src/components/ModsPage.tsx | 6 +- test/lib/ModStore.test.js | 2 +- test/lib/data/ModInfo.js | 15 +++++ test/lib/data/version.test.js | 49 ++++++++--------- 10 files changed, 141 insertions(+), 81 deletions(-) diff --git a/packages/ctl/src/commands_mod.ts b/packages/ctl/src/commands_mod.ts index a58a9f42..365e62d0 100644 --- a/packages/ctl/src/commands_mod.ts +++ b/packages/ctl/src/commands_mod.ts @@ -108,9 +108,13 @@ modCommands.add(new lib.Command({ }, control: Control ) { + const searchVersion = lib.normaliseGameVersion(String(args.factorioVersion)); + if (searchVersion === undefined) { + throw new Error(`Invalid factorio version "${args.factorioVersion}"`); + } let response = await control.send(new lib.ModSearchRequest( args.query, - lib.normaliseApiVersion(args.factorioVersion as any), + lib.majorMinorVersion(searchVersion), args.page, args.pageSize, args.sort, diff --git a/packages/lib/src/ModStore.ts b/packages/lib/src/ModStore.ts index 13e82716..3bcead88 100644 --- a/packages/lib/src/ModStore.ts +++ b/packages/lib/src/ModStore.ts @@ -5,7 +5,7 @@ import { Static } from "@sinclair/typebox"; import { logger } from "./logging"; import { downloadFile, safeOutputFile } from "./file_ops"; import { ModPortalReleaseSchema, ModPortalDetailsSchema, ModNameVersionPair } from "./data/messages_mod"; -import { ModInfo, ModPack, FullVersion, ApiVersion, ModVersionEquality } from "./data"; +import { ModInfo, ModPack, FullVersion, PartialVersion, majorMinorVersion, ModVersionEquality } from "./data"; export interface ModStoreEvents { /** A stored mod was created, updated or deleted */ @@ -198,18 +198,29 @@ export default class ModStore extends events.EventEmitter { private async downloadModsChunk( mods: ModNameVersionPair[], username: string, token: string, - factorioVersion: ApiVersion, + factorioVersion: PartialVersion, ) { const url = new URL("https://mods.factorio.com/api/mods"); url.searchParams.set("page_size", "max"); - url.searchParams.set("version", factorioVersion); + url.searchParams.set("version", majorMinorVersion(factorioVersion)); const response = await fetch(url, { method: "POST", body: new URLSearchParams({ namelist: mods.map(m => m.name).join(",") }), }); if (response.status !== 200) { - throw Error(`Fetch: ${url} returned ${response.status} ${response.statusText}`); + // Surface the portal's own error (e.g. an unsupported version) rather + // than a generic status line. + let detail = `${response.status} ${response.statusText}`; + try { + const body = await response.json() as { message?: string }; + if (body && typeof body.message === "string") { + detail = body.message; + } + } catch { + // Response body was not JSON; keep the status line. + } + throw new Error(`Mod portal request to ${url} failed: ${detail}`); } const modReleases = (await response.json() as ModsInfoResponse).results; @@ -236,7 +247,7 @@ export default class ModStore extends events.EventEmitter { * @param factorioVersion - Factorio version to filter for (mods are not guaranteed to work between Middle versions) */ async downloadMods( - mods: ModNameVersionPair[], username: string, token: string, factorioVersion: ApiVersion = "1.1" + mods: ModNameVersionPair[], username: string, token: string, factorioVersion: PartialVersion = "1.1" ) { const chunkSize = 500; const futures: Promise[] = []; diff --git a/packages/lib/src/data/ModInfo.ts b/packages/lib/src/data/ModInfo.ts index 83fdf384..5d1003ed 100644 --- a/packages/lib/src/data/ModInfo.ts +++ b/packages/lib/src/data/ModInfo.ts @@ -8,10 +8,10 @@ import { findRoot } from "../zip_ops"; import { ModRecord } from "./ModPack"; import { - ApiVersion, - ApiVersionSchema, - FullVersion, FullVersionSchema, integerFullVersion, + FullVersion, integerFullVersion, integerPartialVersion, + GameVersionSchema, normaliseGameVersion, majorMinorVersion, + PartialVersion, ModVersionEquality, } from "./version"; @@ -175,7 +175,7 @@ export default class ModInfo { * Major version of Factorio this mod supports. * Sourced from info.json. */ - factorioVersion = "0.12" as "0.12" | ApiVersion; + factorioVersion = "0.12" as PartialVersion; /** * Integer representation of the factorioVersion @@ -248,16 +248,17 @@ export default class ModInfo { */ isDeleted = false; - // Content of info.json found in mod files + // Content of info.json found in mod files. The version fields use the same + // lenient format the game accepts; they are normalised when read in fromJSON. static infoJsonSchema = Type.Object({ "name": Type.String(), - "version": FullVersionSchema, + "version": GameVersionSchema, "title": Type.String(), "author": Type.String(), "contact": Type.Optional(Type.String()), "homepage": Type.Optional(Type.String()), "description": Type.Optional(Type.String()), - "factorio_version": Type.Optional(ApiVersionSchema), + "factorio_version": Type.Optional(GameVersionSchema), "dependencies": Type.Optional(Type.Array(Type.String())), }); @@ -279,13 +280,25 @@ export default class ModInfo { // info.json fields if (json.name) { modInfo.name = json.name; } - if (json.version) { modInfo.version = json.version; } + if (json.version) { + const version = normaliseGameVersion(json.version); + if (version === undefined) { + throw new Error(`Invalid mod version "${json.version}"`); + } + modInfo.version = version; + } if (json.title) { modInfo.title = json.title; } if (json.author) { modInfo.author = json.author; } if (json.contact) { modInfo.contact = json.contact; } if (json.homepage) { modInfo.homepage = json.homepage; } if (json.description) { modInfo.description = json.description; } - if (json.factorio_version) { modInfo.factorioVersion = json.factorio_version; } + if (json.factorio_version) { + const factorioVersion = normaliseGameVersion(json.factorio_version); + if (factorioVersion === undefined) { + throw new Error(`Invalid factorio_version "${json.factorio_version}"`); + } + modInfo.factorioVersion = majorMinorVersion(factorioVersion); + } // Parse the dependencies try { diff --git a/packages/lib/src/data/messages_mod.ts b/packages/lib/src/data/messages_mod.ts index 096eace5..4ec61adf 100644 --- a/packages/lib/src/data/messages_mod.ts +++ b/packages/lib/src/data/messages_mod.ts @@ -5,7 +5,8 @@ import { JsonString, jsonArray } from "./composites"; import { FullVersion, FullVersionSchema, - ApiVersion, ApiVersionSchema, normaliseApiVersion, + PartialVersion, PartialVersionSchema, + GameVersionSchema, ModVersionEquality, } from "./version"; @@ -154,7 +155,7 @@ export class ModSearchRequest { constructor( public query: string, - public factorioVersion: ApiVersion, + public factorioVersion: PartialVersion, public page: number, public pageSize?: number, public sort?: string, @@ -163,7 +164,7 @@ export class ModSearchRequest { static jsonSchema = Type.Object({ "query": Type.String(), - "factorioVersion": ApiVersionSchema, + "factorioVersion": PartialVersionSchema, "page": Type.Integer(), "pageSize": Type.Optional(Type.Integer()), "sort": Type.Optional(Type.String()), @@ -207,7 +208,7 @@ export class ModSearchRequest { export const ModPortalReleaseSchema = Type.Object({ version: FullVersionSchema, // Match the structure from ModStore's ModRelease/ModDetails - info_json: Type.Object({ factorio_version: ApiVersionSchema }), + info_json: Type.Object({ factorio_version: GameVersionSchema }), released_at: Type.String(), // ISO 8601 date string download_url: Type.String(), file_name: Type.String(), @@ -236,12 +237,12 @@ export class ModPortalGetAllRequest { static permission = "core.mod.search_portal" as const; constructor( - public factorioVersion: ApiVersion, + public factorioVersion: PartialVersion, public hide_deprecated?: boolean, ) { } static jsonSchema = Type.Object({ - "factorioVersion": ApiVersionSchema, + "factorioVersion": PartialVersionSchema, "hide_deprecated": Type.Optional(Type.Boolean()), }); @@ -385,12 +386,12 @@ export class ModPortalDownloadRequest { constructor( public mods: ModNameVersionPair[], - public factorioVersion: ApiVersion, + public factorioVersion: PartialVersion, ) { } static jsonSchema = Type.Object({ "mods": Type.Array(ModNameVersionPairSchema), - "factorioVersion": ApiVersionSchema, + "factorioVersion": PartialVersionSchema, }); static fromJSON(json: Static) { @@ -422,13 +423,13 @@ export class ModDependencyResolveRequest { constructor( public mods: ModDependency[], - public factorioVersion: ApiVersion, + public factorioVersion: PartialVersion, public checkForUpdates: boolean = false, ) { } static jsonSchema = Type.Object({ "mods": Type.Array(ModDependency.jsonSchema), - "factorioVersion": ApiVersionSchema, + "factorioVersion": PartialVersionSchema, "checkForUpdates": Type.Boolean(), }); @@ -441,7 +442,7 @@ export class ModDependencyResolveRequest { return new this( [...modPack.mods.values()] .map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)), - normaliseApiVersion(modPack.factorioVersion), + modPack.factorioVersion, checkForUpdates, ); } @@ -452,7 +453,7 @@ export class ModDependencyResolveRequest { [...modPack.mods.values()] .filter(mod => mod.enabled) .map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)), - normaliseApiVersion(modPack.factorioVersion), + modPack.factorioVersion, checkForUpdates, ); } diff --git a/packages/lib/src/data/version.ts b/packages/lib/src/data/version.ts index 63297106..230af793 100644 --- a/packages/lib/src/data/version.ts +++ b/packages/lib/src/data/version.ts @@ -24,25 +24,11 @@ export function isVersionEquality(input: string): input is VersionEquality { } /** - * Matches valid factorio versions accepts by the web API - * https://wiki.factorio.com/Mod_portal_API#/api/mods (see version enum) + * Known Factorio major versions, offered as fallback options in the web + * interface before the live version list has been fetched. Versions are no + * longer validated against this list; the mod portal is the source of truth. */ -export const ApiVersions = ["0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0"] as const; -export const ApiVersionSchema = Type.Union(ApiVersions.map(v => Type.Literal(v))); -export type ApiVersion = Static; - -export function isApiVersion(input: string): input is ApiVersion { - return ApiVersions.includes(input as any); -} - -export function normaliseApiVersion(version: PartialVersion) { - const parts = version.split("."); - const apiVersion = `${parts[0]}.${parts[1]}`; - if (!isApiVersion(apiVersion)) { - throw new Error(`Factorio version not supported by mod portal: ${apiVersion}`); - } - return apiVersion; -} +export const ApiVersions = ["0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0", "2.1"] as const; /** * Matches valid mod versions, where all parts are specified. @@ -68,6 +54,31 @@ export function normaliseFullVersion(version: PartialVersion) { return (parts.length === 3 ? version : `${version}.0`) as FullVersion; } +/** + * Matches a version string the same lenient way the game reads it: each number + * may be preceded by whitespace, the major and minor are required, the patch is + * optional, and any trailing content is ignored. The pattern is intentionally + * not anchored at the end. + */ +const gameVersionRegExp = /^\s*\d+\.\s*\d+(?:\.\s*\d+)?/; +export const GameVersionSchema = Type.String({ pattern: gameVersionRegExp.source }); + +/** + * Normalise a version string the same lenient way the game reads it. Numbers + * are read as integers, so leading whitespace and zeros are dropped and any + * content after the patch is ignored. + * + * @returns The normalised full version, or undefined if no major and minor + * could be read. + */ +export function normaliseGameVersion(input: string): FullVersion | undefined { + const match = /^\s*(\d+)\.\s*(\d+)(?:\.\s*(\d+))?/.exec(input); + if (match === null) { + return undefined; + } + return `${Number(match[1])}.${Number(match[2])}.${Number(match[3] ?? "0")}` as FullVersion; +} + /** * Matches valid factorio versions and mod dependencies specifications where 2 or 3 parts are specified. */ @@ -87,6 +98,14 @@ export function integerPartialVersion(version: PartialVersion) { return major * 0x100000000 + minor * 0x10000 + (sub || 0) as IntegerVersion; } +/** + * The major.minor of a version, as used by the mod portal's version filter. + */ +export function majorMinorVersion(version: PartialVersion) { + const [major, minor] = version.split("."); + return `${major}.${minor}` as `${number}.${number}`; +} + /** * Matches valid factorio target versions, this is the same as partial but also accepts "latest". */ diff --git a/packages/web_ui/src/components/ModPackViewPage.tsx b/packages/web_ui/src/components/ModPackViewPage.tsx index d2640d18..391caf05 100644 --- a/packages/web_ui/src/components/ModPackViewPage.tsx +++ b/packages/web_ui/src/components/ModPackViewPage.tsx @@ -77,12 +77,12 @@ function SearchModsTable(props: SearchModsTableProps) { const [modResultPageSize, setModResultPageSize] = useState(10); const [modResultCount, setModResultCount] = useState(2); const [modResultSelectedVersion, setModResultSelectedVersion] = useState>(new Map()); - const [factorioVersion, setFactorioVersion] = useState(null); + const [factorioVersion, setFactorioVersion] = useState(null); // Get a valid factorio version useEffect(() => { try { - setFactorioVersion(lib.normaliseApiVersion(props.modPack.factorioVersion)); + setFactorioVersion(lib.majorMinorVersion(props.modPack.factorioVersion)); } catch (err) { setFactorioVersion(null); } @@ -271,7 +271,7 @@ function DownloadDependenciesButton(props: DownloadDependenciesProps) { const [open, setOpen] = useState(false); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); - const [factorioVersion, setFactorioVersion] = useState(null); + const [factorioVersion, setFactorioVersion] = useState(null); const [mods, setMods] = useState([]); // All resolved mods and dependencies const [missing, setMissing] = useState([]); // Missing mods and dependencies, excluding builtin @@ -295,7 +295,7 @@ function DownloadDependenciesButton(props: DownloadDependenciesProps) { // Get a valid factorio version useEffect(() => { try { - setFactorioVersion(lib.normaliseApiVersion(props.modPack.factorioVersion)); + setFactorioVersion(lib.majorMinorVersion(props.modPack.factorioVersion)); } catch (err) { setError(new Error(`Invalid factorio version: ${props.modPack.factorioVersion}`)); setFactorioVersion(null); @@ -577,8 +577,8 @@ function ModsTable(props: ModsTableProps) { if (mod.enabled && mod.info) { mod.warning = mod.info.checkDependencySatisfaction(mods.filter(m => m.enabled)); try { - const packFactorioVersion = lib.normaliseApiVersion(props.modPack.factorioVersion); - const modFactorioVersion = lib.normaliseApiVersion(mod.info.factorioVersion); + const packFactorioVersion = lib.majorMinorVersion(props.modPack.factorioVersion); + const modFactorioVersion = lib.majorMinorVersion(mod.info.factorioVersion); if (packFactorioVersion !== modFactorioVersion) { mod.warning = "wrong_factorio_version"; } diff --git a/packages/web_ui/src/components/ModsPage.tsx b/packages/web_ui/src/components/ModsPage.tsx index e32bb3c0..222595a4 100644 --- a/packages/web_ui/src/components/ModsPage.tsx +++ b/packages/web_ui/src/components/ModsPage.tsx @@ -147,7 +147,7 @@ function SearchModsButton() { const [open, setOpen] = useState(false); const [form] = Form.useForm(); const [searchText, setSearchText] = useState(""); - const [factorioVersion, setFactorioVersion] = useState("2.0"); + const [factorioVersion, setFactorioVersion] = useState("2.0"); // State for all mods fetched from backend const [allMods, setAllMods] = useState([]); @@ -165,7 +165,7 @@ function SearchModsButton() { modName: string, modTitle: string | undefined, modVersion: lib.FullVersion | undefined, - portalFactorioVersion: lib.ApiVersion, + portalFactorioVersion: lib.PartialVersion, ) => { if (modVersion) { control.send( @@ -271,7 +271,7 @@ function SearchModsButton() { // Update search text and factorio version from form const handleSearch = (changedValues: any, allValues: any) => { const nameValue = allValues.name; - const versionValue = allValues.factorioVersion as lib.ApiVersion | undefined; + const versionValue = allValues.factorioVersion as lib.PartialVersion | undefined; // Only trigger state updates if values actually changed if (nameValue !== searchText) { diff --git a/test/lib/ModStore.test.js b/test/lib/ModStore.test.js index 9c140b3a..8f2037a3 100644 --- a/test/lib/ModStore.test.js +++ b/test/lib/ModStore.test.js @@ -358,7 +358,7 @@ describe("lib/ModStore", function () { await assert.rejects( modStore.downloadMods(modsToDownload, username, token, factorioVersion), - /Fetch: https:\/\/mods\.factorio\.com\/api\/mods.* returned 503 Service Unavailable/ + /Mod portal request to https:\/\/mods\.factorio\.com\/api\/mods.* failed: 503 Service Unavailable/ ); assert.equal(modStore.files.size, 0, "No mods should be added on API error"); }); diff --git a/test/lib/data/ModInfo.js b/test/lib/data/ModInfo.js index 590068cc..1d9531d8 100644 --- a/test/lib/data/ModInfo.js +++ b/test/lib/data/ModInfo.js @@ -341,6 +341,21 @@ describe("lib/data/ModInfo", function() { dependencies: ["base", ""], }), /my-mod/); // check the mod name is included in the error message }); + it("should normalise lenient version fields the way the game reads them", function() { + const mod = ModInfo.fromJSON({ + name: "my-mod", + version: " 1.2.3-extra", + factorio_version: "1.1.0", + }); + assert.equal(mod.version, "1.2.3"); + assert.equal(mod.factorioVersion, "1.1"); + }); + it("should throw for an unparseable mod version", function() { + assert.throws( + () => ModInfo.fromJSON({ name: "my-mod", version: "not-a-version" }), + /Invalid mod version/ + ); + }); it("should sort integer mod versions lexicographically", function() { let unsortedVersions = ["1.0.0", "1.1.0", "0.1.0", "3.0.0", "1.2.0", "0.3.1", "0.3.3", "2.1.1", "0.0.1"]; let sortedVersions = ["0.0.1", "0.1.0", "0.3.1", "0.3.3", "1.0.0", "1.1.0", "1.2.0", "2.1.1", "3.0.0"]; diff --git a/test/lib/data/version.test.js b/test/lib/data/version.test.js index 39cf0e03..c9fb7986 100644 --- a/test/lib/data/version.test.js +++ b/test/lib/data/version.test.js @@ -17,36 +17,33 @@ describe("lib/data/version", function() { } }); }); - describe("isApiVersion()", function() { - it("should correctly validate input strings", function() { - const valid = ["0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0"]; - for (const test of valid) { - assert.equal(lib.isApiVersion(test), true, test); - } - const invalid = ["0.12", "0.19", "1.2", "2.1", "1.0.0", "1.0.0.0", "0", "", "latest"]; + describe("normaliseGameVersion()", function() { + it("should normalise lenient version strings the way the game reads them", function() { + // Major and minor required, patch optional and defaulting to 0. + assert.equal(lib.normaliseGameVersion("1.1"), "1.1.0"); + assert.equal(lib.normaliseGameVersion("1.1.50"), "1.1.50"); + assert.equal(lib.normaliseGameVersion("0.18"), "0.18.0"); + // Leading whitespace and zeros are dropped. + assert.equal(lib.normaliseGameVersion(" 1.1"), "1.1.0"); + assert.equal(lib.normaliseGameVersion("01.02.03"), "1.2.3"); + assert.equal(lib.normaliseGameVersion("1. 1. 50"), "1.1.50"); + // Anything after the patch (or a non-numeric patch) is ignored. + assert.equal(lib.normaliseGameVersion("1.1.50-beta"), "1.1.50"); + assert.equal(lib.normaliseGameVersion("1.1.x"), "1.1.0"); + assert.equal(lib.normaliseGameVersion("1.1.50.60"), "1.1.50"); + }); + it("should return undefined when no major.minor can be read", function() { + const invalid = ["1", "", "latest", "abc", ".1.1", "x.y"]; for (const test of invalid) { - assert.equal(lib.isApiVersion(test), false, test); + assert.equal(lib.normaliseGameVersion(test), undefined, test); } }); }); - describe("normaliseApiVersion()", function() { - it("should normalise accepted versions", function() { - assert.equal(lib.normaliseApiVersion("0.13"), "0.13"); - assert.equal(lib.normaliseApiVersion("0.13.0"), "0.13"); - assert.equal(lib.normaliseApiVersion("0.13.1"), "0.13"); - assert.equal(lib.normaliseApiVersion("1.1"), "1.1"); - assert.equal(lib.normaliseApiVersion("1.1.0"), "1.1"); - assert.equal(lib.normaliseApiVersion("1.1.1"), "1.1"); - assert.equal(lib.normaliseApiVersion("2.0"), "2.0"); - assert.equal(lib.normaliseApiVersion("2.0.0"), "2.0"); - assert.equal(lib.normaliseApiVersion("2.0.1"), "2.0"); - assert.equal(lib.normaliseApiVersion("1.0.0.0"), "1.0"); - }); - it("should throw for invalid versions", function() { - const invalid = ["0.12", "0", "", "latest"]; - for (const test of invalid) { - assert.throws(() => lib.normaliseApiVersion(test), undefined, test); - } + describe("majorMinorVersion()", function() { + it("should return the major.minor", function() { + assert.equal(lib.majorMinorVersion("1.1"), "1.1"); + assert.equal(lib.majorMinorVersion("1.1.50"), "1.1"); + assert.equal(lib.majorMinorVersion("0.18.0"), "0.18"); }); }); describe("isFullVersion()", function() { From 7c2793752d92caede2c92bb02dcb1682f7efa9fa Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:12:13 +0000 Subject: [PATCH 2/8] Add MajorMinorVersion type and dedupe the game version regex Address review feedback: introduce a MajorMinorVersion type with isMajorMinorVersion, rename majorMinorVersion to normaliseMajorMinorVersion, and type ModInfo.factorioVersion as MajorMinorVersion so already-reduced values are not re-reduced (ModPackViewPage uses mod.info.factorioVersion directly). Reuse the single gameVersionRegExp in both GameVersionSchema and normaliseGameVersion instead of duplicating the pattern. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ctl/src/commands_mod.ts | 2 +- packages/lib/src/ModStore.ts | 4 +-- packages/lib/src/data/ModInfo.ts | 8 +++--- packages/lib/src/data/version.ts | 25 ++++++++++++++----- .../web_ui/src/components/ModPackViewPage.tsx | 9 ++++--- test/lib/data/version.test.js | 20 ++++++++++++--- 6 files changed, 47 insertions(+), 21 deletions(-) diff --git a/packages/ctl/src/commands_mod.ts b/packages/ctl/src/commands_mod.ts index 365e62d0..343c6953 100644 --- a/packages/ctl/src/commands_mod.ts +++ b/packages/ctl/src/commands_mod.ts @@ -114,7 +114,7 @@ modCommands.add(new lib.Command({ } let response = await control.send(new lib.ModSearchRequest( args.query, - lib.majorMinorVersion(searchVersion), + lib.normaliseMajorMinorVersion(searchVersion), args.page, args.pageSize, args.sort, diff --git a/packages/lib/src/ModStore.ts b/packages/lib/src/ModStore.ts index 3bcead88..023fc528 100644 --- a/packages/lib/src/ModStore.ts +++ b/packages/lib/src/ModStore.ts @@ -5,7 +5,7 @@ import { Static } from "@sinclair/typebox"; import { logger } from "./logging"; import { downloadFile, safeOutputFile } from "./file_ops"; import { ModPortalReleaseSchema, ModPortalDetailsSchema, ModNameVersionPair } from "./data/messages_mod"; -import { ModInfo, ModPack, FullVersion, PartialVersion, majorMinorVersion, ModVersionEquality } from "./data"; +import { ModInfo, ModPack, FullVersion, PartialVersion, normaliseMajorMinorVersion, ModVersionEquality } from "./data"; export interface ModStoreEvents { /** A stored mod was created, updated or deleted */ @@ -202,7 +202,7 @@ export default class ModStore extends events.EventEmitter { ) { const url = new URL("https://mods.factorio.com/api/mods"); url.searchParams.set("page_size", "max"); - url.searchParams.set("version", majorMinorVersion(factorioVersion)); + url.searchParams.set("version", normaliseMajorMinorVersion(factorioVersion)); const response = await fetch(url, { method: "POST", body: new URLSearchParams({ namelist: mods.map(m => m.name).join(",") }), diff --git a/packages/lib/src/data/ModInfo.ts b/packages/lib/src/data/ModInfo.ts index 5d1003ed..458459c7 100644 --- a/packages/lib/src/data/ModInfo.ts +++ b/packages/lib/src/data/ModInfo.ts @@ -10,8 +10,8 @@ import { ModRecord } from "./ModPack"; import { FullVersion, integerFullVersion, integerPartialVersion, - GameVersionSchema, normaliseGameVersion, majorMinorVersion, - PartialVersion, + GameVersionSchema, normaliseGameVersion, normaliseMajorMinorVersion, + MajorMinorVersion, ModVersionEquality, } from "./version"; @@ -175,7 +175,7 @@ export default class ModInfo { * Major version of Factorio this mod supports. * Sourced from info.json. */ - factorioVersion = "0.12" as PartialVersion; + factorioVersion = "0.12" as MajorMinorVersion; /** * Integer representation of the factorioVersion @@ -297,7 +297,7 @@ export default class ModInfo { if (factorioVersion === undefined) { throw new Error(`Invalid factorio_version "${json.factorio_version}"`); } - modInfo.factorioVersion = majorMinorVersion(factorioVersion); + modInfo.factorioVersion = normaliseMajorMinorVersion(factorioVersion); } // Parse the dependencies diff --git a/packages/lib/src/data/version.ts b/packages/lib/src/data/version.ts index 230af793..dc6c925f 100644 --- a/packages/lib/src/data/version.ts +++ b/packages/lib/src/data/version.ts @@ -60,7 +60,7 @@ export function normaliseFullVersion(version: PartialVersion) { * optional, and any trailing content is ignored. The pattern is intentionally * not anchored at the end. */ -const gameVersionRegExp = /^\s*\d+\.\s*\d+(?:\.\s*\d+)?/; +const gameVersionRegExp = /^\s*(\d+)\.\s*(\d+)(?:\.\s*(\d+))?/; export const GameVersionSchema = Type.String({ pattern: gameVersionRegExp.source }); /** @@ -72,7 +72,7 @@ export const GameVersionSchema = Type.String({ pattern: gameVersionRegExp.source * could be read. */ export function normaliseGameVersion(input: string): FullVersion | undefined { - const match = /^\s*(\d+)\.\s*(\d+)(?:\.\s*(\d+))?/.exec(input); + const match = gameVersionRegExp.exec(input); if (match === null) { return undefined; } @@ -83,7 +83,7 @@ export function normaliseGameVersion(input: string): FullVersion | undefined { * Matches valid factorio versions and mod dependencies specifications where 2 or 3 parts are specified. */ const partialVersionRegExp = /^\d+\.\d+(?:\.\d+)?$/; -export type PartialVersion = FullVersion | `${number}.${number}`; +export type PartialVersion = FullVersion | MajorMinorVersion; export const PartialVersionSchema = Type.Unsafe( Type.String({ pattern: partialVersionRegExp.source }) ); @@ -99,11 +99,24 @@ export function integerPartialVersion(version: PartialVersion) { } /** - * The major.minor of a version, as used by the mod portal's version filter. + * Matches a major.minor version, as used by the mod portal's version filter. */ -export function majorMinorVersion(version: PartialVersion) { +const majorMinorVersionRegExp = /^\d+\.\d+$/; +export type MajorMinorVersion = `${number}.${number}`; +export const MajorMinorVersionSchema = Type.Unsafe( + Type.String({ pattern: majorMinorVersionRegExp.source }) +); + +export function isMajorMinorVersion(input: string): input is MajorMinorVersion { + return majorMinorVersionRegExp.test(input); +} + +/** + * Reduce a version to its major.minor, as used by the mod portal's version filter. + */ +export function normaliseMajorMinorVersion(version: PartialVersion): MajorMinorVersion { const [major, minor] = version.split("."); - return `${major}.${minor}` as `${number}.${number}`; + return `${major}.${minor}` as MajorMinorVersion; } /** diff --git a/packages/web_ui/src/components/ModPackViewPage.tsx b/packages/web_ui/src/components/ModPackViewPage.tsx index 391caf05..4a823081 100644 --- a/packages/web_ui/src/components/ModPackViewPage.tsx +++ b/packages/web_ui/src/components/ModPackViewPage.tsx @@ -82,7 +82,7 @@ function SearchModsTable(props: SearchModsTableProps) { // Get a valid factorio version useEffect(() => { try { - setFactorioVersion(lib.majorMinorVersion(props.modPack.factorioVersion)); + setFactorioVersion(lib.normaliseMajorMinorVersion(props.modPack.factorioVersion)); } catch (err) { setFactorioVersion(null); } @@ -295,7 +295,7 @@ function DownloadDependenciesButton(props: DownloadDependenciesProps) { // Get a valid factorio version useEffect(() => { try { - setFactorioVersion(lib.majorMinorVersion(props.modPack.factorioVersion)); + setFactorioVersion(lib.normaliseMajorMinorVersion(props.modPack.factorioVersion)); } catch (err) { setError(new Error(`Invalid factorio version: ${props.modPack.factorioVersion}`)); setFactorioVersion(null); @@ -577,8 +577,9 @@ function ModsTable(props: ModsTableProps) { if (mod.enabled && mod.info) { mod.warning = mod.info.checkDependencySatisfaction(mods.filter(m => m.enabled)); try { - const packFactorioVersion = lib.majorMinorVersion(props.modPack.factorioVersion); - const modFactorioVersion = lib.majorMinorVersion(mod.info.factorioVersion); + const packFactorioVersion = lib.normaliseMajorMinorVersion(props.modPack.factorioVersion); + // mod.info.factorioVersion is already a MajorMinorVersion, no need to re-reduce it. + const modFactorioVersion = mod.info.factorioVersion; if (packFactorioVersion !== modFactorioVersion) { mod.warning = "wrong_factorio_version"; } diff --git a/test/lib/data/version.test.js b/test/lib/data/version.test.js index c9fb7986..6fc05005 100644 --- a/test/lib/data/version.test.js +++ b/test/lib/data/version.test.js @@ -39,11 +39,23 @@ describe("lib/data/version", function() { } }); }); - describe("majorMinorVersion()", function() { + describe("normaliseMajorMinorVersion()", function() { it("should return the major.minor", function() { - assert.equal(lib.majorMinorVersion("1.1"), "1.1"); - assert.equal(lib.majorMinorVersion("1.1.50"), "1.1"); - assert.equal(lib.majorMinorVersion("0.18.0"), "0.18"); + assert.equal(lib.normaliseMajorMinorVersion("1.1"), "1.1"); + assert.equal(lib.normaliseMajorMinorVersion("1.1.50"), "1.1"); + assert.equal(lib.normaliseMajorMinorVersion("0.18.0"), "0.18"); + }); + }); + describe("isMajorMinorVersion()", function() { + it("should correctly validate input strings", function() { + const valid = ["1.1", "0.18", "2.0", "10.20"]; + const invalid = ["1", "1.1.0", "1.1.50", "latest", "1.", ".1", ""]; + for (const version of valid) { + assert(lib.isMajorMinorVersion(version), `${version} should be valid`); + } + for (const version of invalid) { + assert(!lib.isMajorMinorVersion(version), `${version} should be invalid`); + } }); }); describe("isFullVersion()", function() { From 5b1f7d796df3e23340cb900f7ce88b72ff9cb312 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:49:59 +0000 Subject: [PATCH 3/8] Make ModInfo.jsonSchema strict about the major.minor factorio_version The stored ModInfo always normalises factorio_version to a major.minor (see fromJSON), so the serialised schema can use the strict MajorMinorVersionSchema while infoJsonSchema keeps the lenient GameVersionSchema for raw info.json. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/lib/src/data/ModInfo.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/lib/src/data/ModInfo.ts b/packages/lib/src/data/ModInfo.ts index 458459c7..7f65b50c 100644 --- a/packages/lib/src/data/ModInfo.ts +++ b/packages/lib/src/data/ModInfo.ts @@ -11,7 +11,7 @@ import { FullVersion, integerFullVersion, integerPartialVersion, GameVersionSchema, normaliseGameVersion, normaliseMajorMinorVersion, - MajorMinorVersion, + MajorMinorVersion, MajorMinorVersionSchema, ModVersionEquality, } from "./version"; @@ -266,6 +266,9 @@ export default class ModInfo { static jsonSchema = Type.Object({ ...this.infoJsonSchema.properties, + // The stored value is always normalised to a strict major.minor (see fromJSON), + // unlike the lenient info.json schema above. + "factorio_version": Type.Optional(MajorMinorVersionSchema), "size": Type.Optional(Type.Integer()), "mtime_ms": Type.Optional(Type.Number()), "sha1": Type.Optional(Type.String()), From 5d20f8fc84f69e26a4286ddaeb099953b69da370 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Mon, 29 Jun 2026 23:20:37 +0000 Subject: [PATCH 4/8] Revert strict ModInfo.jsonSchema and cover the factorio_version error The strict major.minor jsonSchema did not improve any consuming type (the factorioVersion field is already typed) and only complicated the fromJSON signature, so revert it (and drop the now-unused MajorMinorVersionSchema). Add a test for the unparseable factorio_version branch in fromJSON. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/lib/src/data/ModInfo.ts | 12 ++---------- packages/lib/src/data/version.ts | 3 --- test/lib/data/ModInfo.js | 6 ++++++ 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/packages/lib/src/data/ModInfo.ts b/packages/lib/src/data/ModInfo.ts index dc164d65..458459c7 100644 --- a/packages/lib/src/data/ModInfo.ts +++ b/packages/lib/src/data/ModInfo.ts @@ -11,7 +11,7 @@ import { FullVersion, integerFullVersion, integerPartialVersion, GameVersionSchema, normaliseGameVersion, normaliseMajorMinorVersion, - MajorMinorVersion, MajorMinorVersionSchema, + MajorMinorVersion, ModVersionEquality, } from "./version"; @@ -266,9 +266,6 @@ export default class ModInfo { static jsonSchema = Type.Object({ ...this.infoJsonSchema.properties, - // The stored value is always normalised to a strict major.minor (see fromJSON), - // unlike the lenient info.json schema above. - "factorio_version": Type.Optional(MajorMinorVersionSchema), "size": Type.Optional(Type.Integer()), "mtime_ms": Type.Optional(Type.Number()), "sha1": Type.Optional(Type.String()), @@ -278,12 +275,7 @@ export default class ModInfo { static validate = libSchema.compile(this.jsonSchema as any); - // Accepts the lenient info.json version fields (normalised below) plus the stored - // extras; the strict jsonSchema describes the serialised form produced by toJSON. - static fromJSON(json: - Static - & Pick, "size" | "mtime_ms" | "sha1" | "updated_at_ms" | "is_deleted"> - ) { + static fromJSON(json: Static) { const modInfo = new this(); // info.json fields diff --git a/packages/lib/src/data/version.ts b/packages/lib/src/data/version.ts index 9b04f8b2..cdd575d9 100644 --- a/packages/lib/src/data/version.ts +++ b/packages/lib/src/data/version.ts @@ -103,9 +103,6 @@ export function integerPartialVersion(version: PartialVersion) { */ const majorMinorVersionRegExp = /^\d+\.\d+$/; export type MajorMinorVersion = `${number}.${number}`; -export const MajorMinorVersionSchema = Type.Unsafe( - Type.String({ pattern: majorMinorVersionRegExp.source }) -); export function isMajorMinorVersion(input: string): input is MajorMinorVersion { return majorMinorVersionRegExp.test(input); diff --git a/test/lib/data/ModInfo.js b/test/lib/data/ModInfo.js index 1d9531d8..1a31bb75 100644 --- a/test/lib/data/ModInfo.js +++ b/test/lib/data/ModInfo.js @@ -356,6 +356,12 @@ describe("lib/data/ModInfo", function() { /Invalid mod version/ ); }); + it("should throw for an unparseable factorio_version", function() { + assert.throws( + () => ModInfo.fromJSON({ name: "my-mod", factorio_version: "not-a-version" }), + /Invalid factorio_version/ + ); + }); it("should sort integer mod versions lexicographically", function() { let unsortedVersions = ["1.0.0", "1.1.0", "0.1.0", "3.0.0", "1.2.0", "0.3.1", "0.3.3", "2.1.1", "0.0.1"]; let sortedVersions = ["0.0.1", "0.1.0", "0.3.1", "0.3.3", "1.0.0", "1.1.0", "1.2.0", "2.1.1", "3.0.0"]; From 523293d4d924f3b894f41ad8e1c7d51e2d58305e Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:39:08 +0100 Subject: [PATCH 5/8] Manual adjustments to GameVersion and ReleaseChannel types Through the creation of this commit, it was discovered that ModInfo.version must be typed as GameVersion due to file names. This results in wide spread propagation of the unsafe type. The full impact including on ModRecord and ModVersion.testVersion have no been implemented until a path forward is decided. --- packages/controller/src/BaseConnection.ts | 2 +- packages/lib/src/ModStore.ts | 14 +-- packages/lib/src/data/ModInfo.ts | 32 ++++-- packages/lib/src/data/messages_mod.ts | 39 ++++--- packages/lib/src/data/version.ts | 102 +++++++++++------- .../web_ui/src/components/InstanceList.tsx | 7 +- .../web_ui/src/components/ModPackViewPage.tsx | 5 +- packages/web_ui/src/components/ModsPage.tsx | 8 +- test/lib/data/version.test.js | 8 ++ 9 files changed, 129 insertions(+), 88 deletions(-) diff --git a/packages/controller/src/BaseConnection.ts b/packages/controller/src/BaseConnection.ts index ba6ba39c..19cc47a6 100644 --- a/packages/controller/src/BaseConnection.ts +++ b/packages/controller/src/BaseConnection.ts @@ -80,7 +80,7 @@ export default class BaseConnection extends lib.Link { return modPack; } - getMod(mod: { name: string, version: lib.FullVersion, sha1?: string }) { + getMod(mod: { name: string, version: lib.GameVersion, sha1?: string }) { let filename = lib.ModInfo.filename(mod.name, mod.version); let modInfo = this._controller.modStore.files.get(filename); if (!modInfo) { diff --git a/packages/lib/src/ModStore.ts b/packages/lib/src/ModStore.ts index 023fc528..3192ec55 100644 --- a/packages/lib/src/ModStore.ts +++ b/packages/lib/src/ModStore.ts @@ -5,7 +5,7 @@ import { Static } from "@sinclair/typebox"; import { logger } from "./logging"; import { downloadFile, safeOutputFile } from "./file_ops"; import { ModPortalReleaseSchema, ModPortalDetailsSchema, ModNameVersionPair } from "./data/messages_mod"; -import { ModInfo, ModPack, FullVersion, PartialVersion, normaliseMajorMinorVersion, ModVersionEquality } from "./data"; +import { ModInfo, ModPack, MajorMinorVersion, ModVersionEquality, GameVersion } from "./data"; export interface ModStoreEvents { /** A stored mod was created, updated or deleted */ @@ -44,7 +44,7 @@ export default class ModStore extends events.EventEmitter { super(); } - getMod(name: string, version: FullVersion, sha1?: string) { + getMod(name: string, version: GameVersion, sha1?: string) { const mod = this.files.get(ModInfo.filename(name, version)); if (!mod || sha1 && sha1 !== mod.sha1) { return undefined; @@ -86,11 +86,11 @@ export default class ModStore extends events.EventEmitter { this.emit("change", modInfo); } - async loadMod(name: string, version: FullVersion) { + async loadMod(name: string, version: GameVersion) { return await this.loadFile(ModInfo.filename(name, version)); } - async deleteMod(name: string, version: FullVersion) { + async deleteMod(name: string, version: GameVersion) { return await this.deleteFile(ModInfo.filename(name, version)); } @@ -198,11 +198,11 @@ export default class ModStore extends events.EventEmitter { private async downloadModsChunk( mods: ModNameVersionPair[], username: string, token: string, - factorioVersion: PartialVersion, + factorioVersion: MajorMinorVersion, ) { const url = new URL("https://mods.factorio.com/api/mods"); url.searchParams.set("page_size", "max"); - url.searchParams.set("version", normaliseMajorMinorVersion(factorioVersion)); + url.searchParams.set("version", factorioVersion); const response = await fetch(url, { method: "POST", body: new URLSearchParams({ namelist: mods.map(m => m.name).join(",") }), @@ -247,7 +247,7 @@ export default class ModStore extends events.EventEmitter { * @param factorioVersion - Factorio version to filter for (mods are not guaranteed to work between Middle versions) */ async downloadMods( - mods: ModNameVersionPair[], username: string, token: string, factorioVersion: PartialVersion = "1.1" + mods: ModNameVersionPair[], username: string, token: string, factorioVersion: MajorMinorVersion = "1.1" ) { const chunkSize = 500; const futures: Promise[] = []; diff --git a/packages/lib/src/data/ModInfo.ts b/packages/lib/src/data/ModInfo.ts index 458459c7..f797cee2 100644 --- a/packages/lib/src/data/ModInfo.ts +++ b/packages/lib/src/data/ModInfo.ts @@ -9,12 +9,12 @@ import { ModRecord } from "./ModPack"; import { FullVersion, integerFullVersion, - integerPartialVersion, - GameVersionSchema, normaliseGameVersion, normaliseMajorMinorVersion, - MajorMinorVersion, + MajorMinorVersion, normaliseMajorMinorVersion, integerMajorMinorVersion, + GameVersion, GameVersionSchema, normaliseGameVersion, ModVersionEquality, } from "./version"; + type ModDependencyType = "incompatible" | "optional" | "hidden" | "unordered" | "required"; export type ModDependencyUnsatisfiedReason = "incompatible" | "missing_dependency" | "wrong_version"; @@ -132,13 +132,18 @@ export default class ModInfo { * Version of the mod. * Sourced from info.json. */ - version = "0.0.0" as FullVersion; + version = "0.0.0" as GameVersion; + + /** + * Type safe accessor for this.version + */ + safeVersion = "0.0.0" as FullVersion; /** * Integer representation of the version */ get integerVersion() { - return integerFullVersion(this.version); + return integerFullVersion(this.safeVersion); } /** @@ -175,14 +180,19 @@ export default class ModInfo { * Major version of Factorio this mod supports. * Sourced from info.json. */ - factorioVersion = "0.12" as MajorMinorVersion; + factorioVersion = "0.12" as GameVersion; + + /** + * Type safe accessor for this.factorioVersion + */ + safeFactorioVersion = "0.0" as MajorMinorVersion; /** * Integer representation of the factorioVersion * @type {number} */ get integerFactorioVersion() { - return integerPartialVersion(this.factorioVersion); + return integerMajorMinorVersion(this.safeFactorioVersion); } /** @@ -221,7 +231,7 @@ export default class ModInfo { * @param version - Mod's version * @returns string containing {name}_{version}.zip */ - static filename(name: string, version: FullVersion) { + static filename(name: string, version: GameVersion) { return `${name}_${version}.zip`; } @@ -285,7 +295,8 @@ export default class ModInfo { if (version === undefined) { throw new Error(`Invalid mod version "${json.version}"`); } - modInfo.version = version; + modInfo.version = json.version; + modInfo.safeVersion = version; } if (json.title) { modInfo.title = json.title; } if (json.author) { modInfo.author = json.author; } @@ -297,7 +308,8 @@ export default class ModInfo { if (factorioVersion === undefined) { throw new Error(`Invalid factorio_version "${json.factorio_version}"`); } - modInfo.factorioVersion = normaliseMajorMinorVersion(factorioVersion); + modInfo.factorioVersion = json.factorio_version; + modInfo.safeFactorioVersion = normaliseMajorMinorVersion(factorioVersion); } // Parse the dependencies diff --git a/packages/lib/src/data/messages_mod.ts b/packages/lib/src/data/messages_mod.ts index 4ec61adf..17def204 100644 --- a/packages/lib/src/data/messages_mod.ts +++ b/packages/lib/src/data/messages_mod.ts @@ -4,9 +4,8 @@ import ModPack from "./ModPack"; import { JsonString, jsonArray } from "./composites"; import { - FullVersion, FullVersionSchema, - PartialVersion, PartialVersionSchema, - GameVersionSchema, + MajorMinorVersion, MajorMinorVersionSchema, normaliseMajorMinorVersion, + GameVersion, GameVersionSchema, ModVersionEquality, } from "./version"; @@ -120,13 +119,13 @@ export class ModGetRequest { constructor( public name: string, - public version: FullVersion, + public version: GameVersion, public sha1?: string, ) { } static jsonSchema = Type.Object({ "name": Type.String(), - "version": FullVersionSchema, + "version": GameVersionSchema, "sha1": Type.Optional(Type.String()), }); @@ -155,7 +154,7 @@ export class ModSearchRequest { constructor( public query: string, - public factorioVersion: PartialVersion, + public factorioVersion: MajorMinorVersion, public page: number, public pageSize?: number, public sort?: string, @@ -164,7 +163,7 @@ export class ModSearchRequest { static jsonSchema = Type.Object({ "query": Type.String(), - "factorioVersion": PartialVersionSchema, + "factorioVersion": MajorMinorVersionSchema, "page": Type.Integer(), "pageSize": Type.Optional(Type.Integer()), "sort": Type.Optional(Type.String()), @@ -206,7 +205,7 @@ export class ModSearchRequest { // Define the structure for the latest release info from the portal export const ModPortalReleaseSchema = Type.Object({ - version: FullVersionSchema, + version: GameVersionSchema, // Match the structure from ModStore's ModRelease/ModDetails info_json: Type.Object({ factorio_version: GameVersionSchema }), released_at: Type.String(), // ISO 8601 date string @@ -237,12 +236,12 @@ export class ModPortalGetAllRequest { static permission = "core.mod.search_portal" as const; constructor( - public factorioVersion: PartialVersion, + public factorioVersion: MajorMinorVersion, public hide_deprecated?: boolean, ) { } static jsonSchema = Type.Object({ - "factorioVersion": PartialVersionSchema, + "factorioVersion": MajorMinorVersionSchema, "hide_deprecated": Type.Optional(Type.Boolean()), }); @@ -277,13 +276,13 @@ export class ModDownloadRequest { constructor( public name: string, - public version: FullVersion, + public version: GameVersion, public sha1?: string, ) { } static jsonSchema = Type.Object({ "name": Type.String(), - "version": FullVersionSchema, + "version": GameVersionSchema, "sha1": Type.Optional(Type.String()), }); @@ -303,12 +302,12 @@ export class ModDeleteRequest { constructor( public name: string, - public version: FullVersion, + public version: GameVersion, ) { } static jsonSchema = Type.Object({ "name": Type.String(), - "version": FullVersionSchema, + "version": GameVersionSchema, }); static fromJSON(json: Static) { @@ -386,12 +385,12 @@ export class ModPortalDownloadRequest { constructor( public mods: ModNameVersionPair[], - public factorioVersion: PartialVersion, + public factorioVersion: MajorMinorVersion, ) { } static jsonSchema = Type.Object({ "mods": Type.Array(ModNameVersionPairSchema), - "factorioVersion": PartialVersionSchema, + "factorioVersion": MajorMinorVersionSchema, }); static fromJSON(json: Static) { @@ -423,13 +422,13 @@ export class ModDependencyResolveRequest { constructor( public mods: ModDependency[], - public factorioVersion: PartialVersion, + public factorioVersion: MajorMinorVersion, public checkForUpdates: boolean = false, ) { } static jsonSchema = Type.Object({ "mods": Type.Array(ModDependency.jsonSchema), - "factorioVersion": PartialVersionSchema, + "factorioVersion": MajorMinorVersionSchema, "checkForUpdates": Type.Boolean(), }); @@ -442,7 +441,7 @@ export class ModDependencyResolveRequest { return new this( [...modPack.mods.values()] .map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)), - modPack.factorioVersion, + normaliseMajorMinorVersion(modPack.factorioVersion), checkForUpdates, ); } @@ -453,7 +452,7 @@ export class ModDependencyResolveRequest { [...modPack.mods.values()] .filter(mod => mod.enabled) .map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)), - modPack.factorioVersion, + normaliseMajorMinorVersion(modPack.factorioVersion), checkForUpdates, ); } diff --git a/packages/lib/src/data/version.ts b/packages/lib/src/data/version.ts index cdd575d9..8fd4af4d 100644 --- a/packages/lib/src/data/version.ts +++ b/packages/lib/src/data/version.ts @@ -30,6 +30,39 @@ export function isVersionEquality(input: string): input is VersionEquality { */ export const ApiVersions = ["0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0", "2.1"] as const; +/** + * Matches a version string the same lenient way the game reads it: each number + * may be preceded by whitespace, the major and minor are required, the patch is + * optional, and any trailing content is ignored. The pattern is intentionally + * not anchored at the end. + */ +const gameVersionRegExp = /^\s*(\d+)\.\s*(\d+)(?:\.\s*(\d+))?/; +declare const gameVersionSymbol: unique symbol; +export type GameVersion = string & { [gameVersionSymbol]: void }; +export const GameVersionSchema = Type.Unsafe( + Type.String({ pattern: gameVersionRegExp.source }) +); + +export function isGameVersion(input: string): input is GameVersion { + return gameVersionRegExp.test(input); +} + +/** + * Normalise a version string the same lenient way the game reads it. Numbers + * are read as integers, so leading whitespace and zeros are dropped and any + * content after the patch is ignored. + * + * @returns The normalised full version, or undefined if no major and minor + * could be read. + */ +export function normaliseGameVersion(input: string): FullVersion | undefined { + const match = gameVersionRegExp.exec(input); + if (match === null) { + return undefined; + } + return `${Number(match[1])}.${Number(match[2])}.${Number(match[3] ?? "0")}` as FullVersion; +} + /** * Matches valid mod versions, where all parts are specified. */ @@ -55,28 +88,30 @@ export function normaliseFullVersion(version: PartialVersion) { } /** - * Matches a version string the same lenient way the game reads it: each number - * may be preceded by whitespace, the major and minor are required, the patch is - * optional, and any trailing content is ignored. The pattern is intentionally - * not anchored at the end. + * Matches a major.minor version, as used by the mod portal's version filter. */ -const gameVersionRegExp = /^\s*(\d+)\.\s*(\d+)(?:\.\s*(\d+))?/; -export const GameVersionSchema = Type.String({ pattern: gameVersionRegExp.source }); +const majorMinorVersionRegExp = /^\d+\.\d+$/; +export type MajorMinorVersion = `${number}.${number}`; +export const MajorMinorVersionSchema = Type.Unsafe( + Type.String({ pattern: majorMinorVersionRegExp.source }) +); + +export function isMajorMinorVersion(input: string): input is MajorMinorVersion { + return majorMinorVersionRegExp.test(input); +} + +export function integerMajorMinorVersion(version: MajorMinorVersion) { + const [major, minor] = version.split(".").map(n => Number.parseInt(n, 10)); + // Can't use bitwise here because this is 48-bits. sub is always 0 so is omitted. + return major * 0x100000000 + minor * 0x10000 as IntegerVersion; +} /** - * Normalise a version string the same lenient way the game reads it. Numbers - * are read as integers, so leading whitespace and zeros are dropped and any - * content after the patch is ignored. - * - * @returns The normalised full version, or undefined if no major and minor - * could be read. + * Reduce a version to its major.minor, as used by the mod portal's version filter. */ -export function normaliseGameVersion(input: string): FullVersion | undefined { - const match = gameVersionRegExp.exec(input); - if (match === null) { - return undefined; - } - return `${Number(match[1])}.${Number(match[2])}.${Number(match[3] ?? "0")}` as FullVersion; +export function normaliseMajorMinorVersion(version: PartialVersion): MajorMinorVersion { + const [major, minor] = version.split("."); + return `${major}.${minor}` as MajorMinorVersion; } /** @@ -98,24 +133,6 @@ export function integerPartialVersion(version: PartialVersion) { return major * 0x100000000 + minor * 0x10000 + (sub || 0) as IntegerVersion; } -/** - * Matches a major.minor version, as used by the mod portal's version filter. - */ -const majorMinorVersionRegExp = /^\d+\.\d+$/; -export type MajorMinorVersion = `${number}.${number}`; - -export function isMajorMinorVersion(input: string): input is MajorMinorVersion { - return majorMinorVersionRegExp.test(input); -} - -/** - * Reduce a version to its major.minor, as used by the mod portal's version filter. - */ -export function normaliseMajorMinorVersion(version: PartialVersion): MajorMinorVersion { - const [major, minor] = version.split("."); - return `${major}.${minor}` as MajorMinorVersion; -} - /** * Matches a release channel name from the latest-releases API, such as * "stable" or "experimental". Excludes "latest", which is handled separately. @@ -123,8 +140,13 @@ export function normaliseMajorMinorVersion(version: PartialVersion): MajorMinorV * and resolved against the live API when the instance starts. */ const releaseChannelRegExp = /^[a-z][a-z0-9-]*$/; +declare const releaseChannelSymbol: unique symbol; +export type ReleaseChannel = string & { [releaseChannelSymbol]: void }; +export const ReleaseChannelSchema = Type.Unsafe( + Type.String({ pattern: releaseChannelRegExp.source }) +); -export function isReleaseChannel(input: string): boolean { +export function isReleaseChannel(input: string): input is ReleaseChannel { return input !== "latest" && releaseChannelRegExp.test(input); } @@ -132,15 +154,15 @@ export function isReleaseChannel(input: string): boolean { * Matches valid factorio target versions: a partial version, "latest", or a * release channel name (e.g. "stable" or "experimental"). */ -export type TargetVersion = PartialVersion | "latest"; +export type TargetVersion = PartialVersion | ReleaseChannel | "latest"; export const TargetVersionSchema = Type.Union([ PartialVersionSchema, + ReleaseChannelSchema, Type.Literal("latest"), - Type.Unsafe(Type.String({ pattern: releaseChannelRegExp.source })), ]); export function isTargetVersion(input: string): input is TargetVersion { - return input === "latest" || isReleaseChannel(input) || partialVersionRegExp.test(input); + return input === "latest" || releaseChannelRegExp.test(input) || partialVersionRegExp.test(input); } /** diff --git a/packages/web_ui/src/components/InstanceList.tsx b/packages/web_ui/src/components/InstanceList.tsx index 630d349e..449a7eb7 100644 --- a/packages/web_ui/src/components/InstanceList.tsx +++ b/packages/web_ui/src/components/InstanceList.tsx @@ -37,10 +37,11 @@ export default function InstanceList(props: InstanceListProps) { } function integerFactorioVersionOrDefault(instance: lib.InstanceDetails) { - if (instance.factorioVersion === undefined || instance.factorioVersion === "latest") { - return -1; + const v = instance.factorioVersion; + if (v && lib.isPartialVersion(v)) { + return lib.integerPartialVersion(v); } - return lib.integerPartialVersion(instance.factorioVersion); + return -1; } let columns: ColumnsType = [ diff --git a/packages/web_ui/src/components/ModPackViewPage.tsx b/packages/web_ui/src/components/ModPackViewPage.tsx index 598447ff..94063c63 100644 --- a/packages/web_ui/src/components/ModPackViewPage.tsx +++ b/packages/web_ui/src/components/ModPackViewPage.tsx @@ -77,7 +77,7 @@ function SearchModsTable(props: SearchModsTableProps) { const [modResultPageSize, setModResultPageSize] = useState(10); const [modResultCount, setModResultCount] = useState(2); const [modResultSelectedVersion, setModResultSelectedVersion] = useState>(new Map()); - const [factorioVersion, setFactorioVersion] = useState(null); + const [factorioVersion, setFactorioVersion] = useState(null); // Get a valid factorio version useEffect(() => { @@ -271,7 +271,7 @@ function DownloadDependenciesButton(props: DownloadDependenciesProps) { const [open, setOpen] = useState(false); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); - const [factorioVersion, setFactorioVersion] = useState(null); + const [factorioVersion, setFactorioVersion] = useState(null); const [mods, setMods] = useState([]); // All resolved mods and dependencies const [missing, setMissing] = useState([]); // Missing mods and dependencies, excluding builtin @@ -578,7 +578,6 @@ function ModsTable(props: ModsTableProps) { mod.warning = mod.info.checkDependencySatisfaction(mods.filter(m => m.enabled)); try { const packFactorioVersion = lib.normaliseMajorMinorVersion(props.modPack.factorioVersion); - // mod.info.factorioVersion is already a MajorMinorVersion, no need to re-reduce it. const modFactorioVersion = mod.info.factorioVersion; if (packFactorioVersion !== modFactorioVersion) { mod.warning = "wrong_factorio_version"; diff --git a/packages/web_ui/src/components/ModsPage.tsx b/packages/web_ui/src/components/ModsPage.tsx index 222595a4..0ad4f27c 100644 --- a/packages/web_ui/src/components/ModsPage.tsx +++ b/packages/web_ui/src/components/ModsPage.tsx @@ -147,7 +147,7 @@ function SearchModsButton() { const [open, setOpen] = useState(false); const [form] = Form.useForm(); const [searchText, setSearchText] = useState(""); - const [factorioVersion, setFactorioVersion] = useState("2.0"); + const [factorioVersion, setFactorioVersion] = useState("2.0"); // State for all mods fetched from backend const [allMods, setAllMods] = useState([]); @@ -164,8 +164,8 @@ function SearchModsButton() { const handleControllerDownload = ( modName: string, modTitle: string | undefined, - modVersion: lib.FullVersion | undefined, - portalFactorioVersion: lib.PartialVersion, + modVersion: lib.GameVersion | undefined, + portalFactorioVersion: lib.MajorMinorVersion, ) => { if (modVersion) { control.send( @@ -271,7 +271,7 @@ function SearchModsButton() { // Update search text and factorio version from form const handleSearch = (changedValues: any, allValues: any) => { const nameValue = allValues.name; - const versionValue = allValues.factorioVersion as lib.PartialVersion | undefined; + const versionValue = allValues.factorioVersion as lib.MajorMinorVersion | undefined; // Only trigger state updates if values actually changed if (nameValue !== searchText) { diff --git a/test/lib/data/version.test.js b/test/lib/data/version.test.js index 6fc05005..433fb8f3 100644 --- a/test/lib/data/version.test.js +++ b/test/lib/data/version.test.js @@ -58,6 +58,14 @@ describe("lib/data/version", function() { } }); }); + describe("integerMajorMinorVersion()", function() { + it("should sort versions lexicographically", function() { + let unsortedVersions = ["1.0", "1.1", "0.1", "3.0", "1.2", "0.3", "2.1"]; + let sortedVersions = ["0.1", "0.3", "1.0", "1.1", "1.2", "2.1", "3.0"]; + unsortedVersions.sort((a, b) => lib.integerMajorMinorVersion(a) - lib.integerMajorMinorVersion(b)); + assert.deepEqual(unsortedVersions, sortedVersions); + }); + }); describe("isFullVersion()", function() { it("should correctly validate input strings", function() { const valid = ["0.12.0", "0.13.1", "0.17.99", "00.000.0001", "1.1.110"]; From 8029ad3b2d84c776aea332c461e9016b738f8b36 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:54:06 +0000 Subject: [PATCH 6/8] Resolve GameVersion type as a plain-string supertype Cooldude's GameVersion brand was disjoint from the clean version types in both directions, forcing safeVersion/safeFactorioVersion companions and breaking widening from FullVersion at file-name call sites. Make GameVersion a plain string alias: the raw, un-normalised version as the game reads it. The stricter template-literal types (FullVersion, MajorMinorVersion, PartialVersion) now widen into it for free, while a GameVersion still can't be used where a normalised version is required without going through normaliseGameVersion/integerGameVersion. Runtime validation stays in GameVersionSchema. - Drop safeVersion/safeFactorioVersion; ModInfo.version stays raw (verbatim so it matches the mod's file name), integerVersion normalises via the new integerGameVersion. factorioVersion reverts to a normalised MajorMinorVersion since it is never part of a file name. - ModRecord.version becomes a GameVersion so mod packs round-trip the exact file-name version. - ModVersionEquality.testVersion / ModVersionRange.testVersion accept a raw GameVersion and normalise it; the equality bound stays strictly validated. - Migration only pads clean partial versions; raw versions are kept verbatim. - Cover the leading-zeros case: version kept verbatim in the file name while comparing equal to its normalised form. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/controller/src/ControlConnection.ts | 2 +- packages/controller/src/Controller.ts | 9 +++- packages/lib/src/data/ModInfo.ts | 41 ++++++++----------- packages/lib/src/data/ModPack.ts | 8 ++-- packages/lib/src/data/version.ts | 35 +++++++++++++--- .../web_ui/src/components/ModPackViewPage.tsx | 4 +- packages/web_ui/src/components/ModsPage.tsx | 2 +- test/lib/data/ModInfo.js | 16 +++++++- 8 files changed, 75 insertions(+), 42 deletions(-) diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index 7dceb299..12e46e77 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -707,7 +707,7 @@ export default class ControlConnection extends BaseConnection { const release = releases.releases .filter(info => versionRange.testVersion(info.version)) .reduce((max, cur) => ( - max && lib.integerFullVersion(max.version) > lib.integerFullVersion(cur.version) ? max : cur + max && lib.integerGameVersion(max.version) > lib.integerGameVersion(cur.version) ? max : cur ), undefined); if (!release) { diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index 6dfbe643..a081737a 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -763,8 +763,13 @@ export default class Controller { const serialized = rawJson as Static[]; return serialized.map(json => { for (const mod of json.mods) { - // migrate: 2.0.0.alpha.22 - json schema now enforces X.Y.Z for mod version, builtins would use X.Y only - mod.version = lib.normaliseFullVersion(mod.version); + // Older mod packs recorded builtins with a major.minor version; pad + // those to major.minor.patch. Raw versions the game reads leniently + // (e.g. with leading zeros) are kept verbatim so they still match the + // mod's file name. + if (lib.isPartialVersion(mod.version)) { + mod.version = lib.normaliseFullVersion(mod.version); + } } return json; }); diff --git a/packages/lib/src/data/ModInfo.ts b/packages/lib/src/data/ModInfo.ts index f797cee2..8a4d1885 100644 --- a/packages/lib/src/data/ModInfo.ts +++ b/packages/lib/src/data/ModInfo.ts @@ -8,9 +8,8 @@ import { findRoot } from "../zip_ops"; import { ModRecord } from "./ModPack"; import { - FullVersion, integerFullVersion, MajorMinorVersion, normaliseMajorMinorVersion, integerMajorMinorVersion, - GameVersion, GameVersionSchema, normaliseGameVersion, + GameVersion, GameVersionSchema, normaliseGameVersion, integerGameVersion, ModVersionEquality, } from "./version"; @@ -129,21 +128,19 @@ export default class ModInfo { name = ""; /** - * Version of the mod. - * Sourced from info.json. + * Version of the mod, exactly as it appears in info.json and the mod's file + * name. This is the raw, un-normalised version the game reads leniently, so + * it is only safe to use for building file names and identifiers; use + * {@link integerVersion} to compare it. */ version = "0.0.0" as GameVersion; /** - * Type safe accessor for this.version - */ - safeVersion = "0.0.0" as FullVersion; - - /** - * Integer representation of the version + * Integer representation of the version, normalised the lenient way the + * game reads it so it can be compared and sorted. */ get integerVersion() { - return integerFullVersion(this.safeVersion); + return integerGameVersion(this.version); } /** @@ -177,22 +174,17 @@ export default class ModInfo { description = ""; /** - * Major version of Factorio this mod supports. - * Sourced from info.json. + * Major version of Factorio this mod supports, reduced to major.minor. + * Sourced from info.json and normalised when read in fromJSON. */ - factorioVersion = "0.12" as GameVersion; - - /** - * Type safe accessor for this.factorioVersion - */ - safeFactorioVersion = "0.0" as MajorMinorVersion; + factorioVersion = "0.12" as MajorMinorVersion; /** * Integer representation of the factorioVersion * @type {number} */ get integerFactorioVersion() { - return integerMajorMinorVersion(this.safeFactorioVersion); + return integerMajorMinorVersion(this.factorioVersion); } /** @@ -291,12 +283,12 @@ export default class ModInfo { // info.json fields if (json.name) { modInfo.name = json.name; } if (json.version) { - const version = normaliseGameVersion(json.version); - if (version === undefined) { + // Validate that the raw version can be read, but keep it verbatim so + // it still matches the mod's file name on disk and the portal. + if (normaliseGameVersion(json.version) === undefined) { throw new Error(`Invalid mod version "${json.version}"`); } modInfo.version = json.version; - modInfo.safeVersion = version; } if (json.title) { modInfo.title = json.title; } if (json.author) { modInfo.author = json.author; } @@ -308,8 +300,7 @@ export default class ModInfo { if (factorioVersion === undefined) { throw new Error(`Invalid factorio_version "${json.factorio_version}"`); } - modInfo.factorioVersion = json.factorio_version; - modInfo.safeFactorioVersion = normaliseMajorMinorVersion(factorioVersion); + modInfo.factorioVersion = normaliseMajorMinorVersion(factorioVersion); } // Parse the dependencies diff --git a/packages/lib/src/data/ModPack.ts b/packages/lib/src/data/ModPack.ts index d8ddd4ae..56bcfaa8 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, - FullVersion, FullVersionSchema, normaliseFullVersion, + GameVersion, GameVersionSchema, normaliseFullVersion, } from "./version"; @@ -46,8 +46,8 @@ export interface ModRecord { name: string, /** if mod is to be loaded. */ enabled: boolean, - /** version of the mod. */ - version: FullVersion, + /** version of the mod, verbatim as it appears in the mod's file name. */ + version: GameVersion, /** SHA1 hash of the zip file. */ sha1?: string, /** Used inside packages\web_ui\src\components\ModPackViewPage.tsx to define an error type. */ @@ -61,7 +61,7 @@ export interface ModRecord { const ModRecordJsonSchema = Type.Object({ "name": Type.String(), "enabled": Type.Boolean(), - "version": FullVersionSchema, + "version": GameVersionSchema, "sha1": Type.Optional(Type.String()), }); diff --git a/packages/lib/src/data/version.ts b/packages/lib/src/data/version.ts index 8fd4af4d..b063b0c1 100644 --- a/packages/lib/src/data/version.ts +++ b/packages/lib/src/data/version.ts @@ -37,8 +37,16 @@ export const ApiVersions = ["0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0 * not anchored at the end. */ const gameVersionRegExp = /^\s*(\d+)\.\s*(\d+)(?:\.\s*(\d+))?/; -declare const gameVersionSymbol: unique symbol; -export type GameVersion = string & { [gameVersionSymbol]: void }; +/** + * A raw, un-normalised version string exactly as the game reads it, such as a + * mod's version taken from info.json or a mod file name. It is deliberately a + * plain string alias so that any of the stricter, normalised version types + * ({@link FullVersion}, {@link MajorMinorVersion}, {@link PartialVersion}) widen + * into it for free, while a GameVersion can not be used where a normalised + * version is required without going through {@link normaliseGameVersion} or + * {@link integerGameVersion} first. + */ +export type GameVersion = string; export const GameVersionSchema = Type.Unsafe( Type.String({ pattern: gameVersionRegExp.source }) ); @@ -63,6 +71,21 @@ export function normaliseGameVersion(input: string): FullVersion | undefined { return `${Number(match[1])}.${Number(match[2])}.${Number(match[3] ?? "0")}` as FullVersion; } +/** + * Integer representation of a raw {@link GameVersion}, normalised the same + * lenient way the game reads it. Suitable for comparing versions that may carry + * leading zeros, whitespace or trailing content. + * + * @throws if the input can not be read as a version. + */ +export function integerGameVersion(version: GameVersion) { + const normalised = normaliseGameVersion(version); + if (normalised === undefined) { + throw new Error(`Invalid version "${version}"`); + } + return integerFullVersion(normalised); +} + /** * Matches valid mod versions, where all parts are specified. */ @@ -201,8 +224,8 @@ export class ModVersionEquality { } } - testVersion(version: PartialVersion) { - return this.testIntegerVersion(integerPartialVersion(version)); + testVersion(version: GameVersion) { + return this.testIntegerVersion(integerGameVersion(version)); } toString() { @@ -264,8 +287,8 @@ export class ModVersionRange { return this.minVersion.testIntegerVersion(other) && this.maxVersion.testIntegerVersion(other); } - testVersion(version: PartialVersion) { - return this.testIntegerVersion(integerPartialVersion(version)); + testVersion(version: GameVersion) { + return this.testIntegerVersion(integerGameVersion(version)); } combineVersion(other: ModVersionEquality) { diff --git a/packages/web_ui/src/components/ModPackViewPage.tsx b/packages/web_ui/src/components/ModPackViewPage.tsx index 94063c63..ac7822cd 100644 --- a/packages/web_ui/src/components/ModPackViewPage.tsx +++ b/packages/web_ui/src/components/ModPackViewPage.tsx @@ -403,7 +403,9 @@ function DownloadDependenciesButton(props: DownloadDependenciesProps) { control.send( new lib.ModPortalDownloadRequest( missing.map(mod => ({ - name: mod.name, version: new lib.ModVersionEquality("=", mod.version), + name: mod.name, + // mod.version is the raw version; the equality bound must be canonical. + version: new lib.ModVersionEquality("=", lib.normaliseGameVersion(mod.version)!), })), factorioVersion, ) diff --git a/packages/web_ui/src/components/ModsPage.tsx b/packages/web_ui/src/components/ModsPage.tsx index 0ad4f27c..3b13f7c9 100644 --- a/packages/web_ui/src/components/ModsPage.tsx +++ b/packages/web_ui/src/components/ModsPage.tsx @@ -170,7 +170,7 @@ function SearchModsButton() { if (modVersion) { control.send( new lib.ModPortalDownloadRequest( - [{ name: modName, version: new lib.ModVersionEquality("=", modVersion)}], + [{ name: modName, version: new lib.ModVersionEquality("=", lib.normaliseGameVersion(modVersion)!)}], portalFactorioVersion ) ).then(() => { diff --git a/test/lib/data/ModInfo.js b/test/lib/data/ModInfo.js index 1a31bb75..752cbaea 100644 --- a/test/lib/data/ModInfo.js +++ b/test/lib/data/ModInfo.js @@ -341,15 +341,27 @@ describe("lib/data/ModInfo", function() { dependencies: ["base", ""], }), /my-mod/); // check the mod name is included in the error message }); - it("should normalise lenient version fields the way the game reads them", function() { + it("should keep the raw mod version but normalise it for comparison", function() { const mod = ModInfo.fromJSON({ name: "my-mod", version: " 1.2.3-extra", factorio_version: "1.1.0", }); - assert.equal(mod.version, "1.2.3"); + // The version is kept verbatim so it still matches the mod's file name, + // but is read leniently the way the game does when compared. + assert.equal(mod.version, " 1.2.3-extra"); + assert.equal(mod.integerVersion, ModInfo.fromJSON({ version: "1.2.3" }).integerVersion); + // factorio_version is not part of any file name, so it is reduced to major.minor. assert.equal(mod.factorioVersion, "1.1"); }); + it("should keep leading zeros in the version so the file name matches", function() { + // Mirrors the mods.factorio.com/mod/leading-zeros case: the version has + // a zero-padded component that the file name keeps verbatim. + const mod = ModInfo.fromJSON({ name: "leading-zeros", version: "1.01.0" }); + assert.equal(mod.version, "1.01.0"); + assert.equal(mod.filename, "leading-zeros_1.01.0.zip"); + assert.equal(mod.integerVersion, ModInfo.fromJSON({ version: "1.1.0" }).integerVersion); + }); it("should throw for an unparseable mod version", function() { assert.throws( () => ModInfo.fromJSON({ name: "my-mod", version: "not-a-version" }), From 2e27c4f49919ab327ded60a6926cdec1cf8f4f09 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:08:09 +0100 Subject: [PATCH 7/8] Maintain strict tagging of GameVersion --- packages/controller/src/Controller.ts | 8 ++--- packages/ctl/src/commands_mod.ts | 6 ++-- packages/ctl/src/commands_mod_pack.ts | 2 +- packages/lib/src/data/ModInfo.ts | 14 ++++---- packages/lib/src/data/ModPack.ts | 14 ++++---- packages/lib/src/data/version.ts | 34 +++++++------------ .../web_ui/src/components/ModPackViewPage.tsx | 2 +- packages/web_ui/src/components/ModsPage.tsx | 2 +- 8 files changed, 37 insertions(+), 45 deletions(-) diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index a081737a..3b72dcca 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -763,12 +763,10 @@ export default class Controller { const serialized = rawJson as Static[]; return serialized.map(json => { for (const mod of json.mods) { - // Older mod packs recorded builtins with a major.minor version; pad - // those to major.minor.patch. Raw versions the game reads leniently - // (e.g. with leading zeros) are kept verbatim so they still match the - // mod's file name. + // migrate: 2.0.0-alpha.22 - old buildins used X.Y rather than X.Y.Z + // All other versions are kept verbatim so they still match the mod's file name. if (lib.isPartialVersion(mod.version)) { - mod.version = lib.normaliseFullVersion(mod.version); + mod.version = lib.normaliseFullVersion(mod.version) as lib.GameVersion; } } return json; diff --git a/packages/ctl/src/commands_mod.ts b/packages/ctl/src/commands_mod.ts index 343c6953..169b8fb5 100644 --- a/packages/ctl/src/commands_mod.ts +++ b/packages/ctl/src/commands_mod.ts @@ -20,7 +20,7 @@ modCommands.add(new lib.Command({ yargs.positional("mod-version", { describe: "Version of the mod", type: "string" }); }], handler: async function(args: { name: string, modVersion: string }, control: Control) { - if (!lib.isFullVersion(args.modVersion)) { + if (!lib.isGameVersion(args.modVersion)) { throw new lib.CommandError("mod-version must match format digit.digit.digit"); } let modInfo = await control.send(new lib.ModGetRequest(args.name, args.modVersion)); @@ -191,7 +191,7 @@ modCommands.add(new lib.Command({ yargs.positional("mod-version", { describe: "Version of mod to download", type: "string" }); }], handler: async function(args: { name: string, modVersion: string }, control: Control) { - if (!lib.isFullVersion(args.modVersion)) { + if (!lib.isGameVersion(args.modVersion)) { throw new lib.CommandError("mod-version must match format digit.digit.digit"); } let streamId = await control.send(new lib.ModDownloadRequest(args.name, args.modVersion)); @@ -211,7 +211,7 @@ modCommands.add(new lib.Command({ yargs.positional("mod-version", { describe: "Version of mod to delete", type: "string" }); }], handler: async function(args: { name: string, modVersion: string }, control: Control) { - if (!lib.isFullVersion(args.modVersion)) { + if (!lib.isGameVersion(args.modVersion)) { throw new lib.CommandError("mod-version must match format digit.digit.digit"); } await control.send(new lib.ModDeleteRequest(args.name, args.modVersion)); diff --git a/packages/ctl/src/commands_mod_pack.ts b/packages/ctl/src/commands_mod_pack.ts index 0374be2c..6d3fdfa1 100644 --- a/packages/ctl/src/commands_mod_pack.ts +++ b/packages/ctl/src/commands_mod_pack.ts @@ -132,7 +132,7 @@ function setModPackMods(modPack: lib.ModPack, mods: string[] | undefined) { if (!version) { throw new lib.CommandError("Added mod must be formatted as name:version or name:version:sha1"); } - if (!lib.isFullVersion(version)) { + if (!lib.isGameVersion(version)) { throw new lib.CommandError("version must match the format digit.digit.digit"); } if (sha1 && !/^[0-9a-f]{40}$/.test(sha1)) { diff --git a/packages/lib/src/data/ModInfo.ts b/packages/lib/src/data/ModInfo.ts index 8a4d1885..f5ca2fa3 100644 --- a/packages/lib/src/data/ModInfo.ts +++ b/packages/lib/src/data/ModInfo.ts @@ -8,8 +8,8 @@ import { findRoot } from "../zip_ops"; import { ModRecord } from "./ModPack"; import { - MajorMinorVersion, normaliseMajorMinorVersion, integerMajorMinorVersion, - GameVersion, GameVersionSchema, normaliseGameVersion, integerGameVersion, + MajorMinorVersion, MajorMinorVersionSchema, normaliseMajorMinorVersion, integerMajorMinorVersion, + GameVersion, GameVersionSchema, isGameVersion, normaliseGameVersion, integerGameVersion, ModVersionEquality, } from "./version"; @@ -143,6 +143,10 @@ export default class ModInfo { return integerGameVersion(this.version); } + get normalisedVersion() { + return normaliseGameVersion(this.version); + } + /** * Display name of the mod. * Sourced from info.json. @@ -283,9 +287,7 @@ export default class ModInfo { // info.json fields if (json.name) { modInfo.name = json.name; } if (json.version) { - // Validate that the raw version can be read, but keep it verbatim so - // it still matches the mod's file name on disk and the portal. - if (normaliseGameVersion(json.version) === undefined) { + if (!isGameVersion(json.version)) { throw new Error(`Invalid mod version "${json.version}"`); } modInfo.version = json.version; @@ -330,7 +332,7 @@ export default class ModInfo { if (this.contact) { json.contact = this.contact; } if (this.homepage) { json.homepage = this.homepage; } if (this.description) { json.description = this.description; } - if (this.factorioVersion !== "0.12") { json.factorio_version = this.factorioVersion; } + if (this.factorioVersion !== "0.12") { json.factorio_version = this.factorioVersion as GameVersion; } if (this.dependencies.length !== 1 || this.dependencies[0].name !== "base") { json.dependencies = this.dependencySpecifications; } diff --git a/packages/lib/src/data/ModPack.ts b/packages/lib/src/data/ModPack.ts index 56bcfaa8..6e77644f 100644 --- a/packages/lib/src/data/ModPack.ts +++ b/packages/lib/src/data/ModPack.ts @@ -434,24 +434,24 @@ export default class ModPack { * @return Built in mods for the given version */ static getBuiltinMods(factorioVersion: PartialVersion) { - factorioVersion = normaliseFullVersion(factorioVersion); + const version = normaliseFullVersion(factorioVersion) as GameVersion; + const integerVersion = integerPartialVersion(factorioVersion); let defaultMods: ModRecord[] = [ // "core" not included because core cannot be disabled - { name: "base", enabled: true, version: factorioVersion }, + { name: "base", enabled: true, version: version }, ]; - const integerVersion = integerPartialVersion(factorioVersion); if (integerVersion >= integerPartialVersion("1.2")) { defaultMods = defaultMods.concat([ - { name: "elevated-rails", enabled: false, version: factorioVersion }, - { name: "quality", enabled: false, version: factorioVersion }, - { name: "space-age", enabled: false, version: factorioVersion }, + { name: "elevated-rails", enabled: false, version: version }, + { name: "quality", enabled: false, version: version }, + { name: "space-age", enabled: false, version: version }, ]); } if (integerVersion >= integerPartialVersion("2.1")) { defaultMods = defaultMods.concat([ - { name: "recycler", enabled: false, version: factorioVersion }, + { name: "recycler", enabled: false, version: version }, ]); } diff --git a/packages/lib/src/data/version.ts b/packages/lib/src/data/version.ts index b063b0c1..0b17b3ba 100644 --- a/packages/lib/src/data/version.ts +++ b/packages/lib/src/data/version.ts @@ -37,16 +37,13 @@ export const ApiVersions = ["0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0 * not anchored at the end. */ const gameVersionRegExp = /^\s*(\d+)\.\s*(\d+)(?:\.\s*(\d+))?/; + /** * A raw, un-normalised version string exactly as the game reads it, such as a - * mod's version taken from info.json or a mod file name. It is deliberately a - * plain string alias so that any of the stricter, normalised version types - * ({@link FullVersion}, {@link MajorMinorVersion}, {@link PartialVersion}) widen - * into it for free, while a GameVersion can not be used where a normalised - * version is required without going through {@link normaliseGameVersion} or - * {@link integerGameVersion} first. + * mod's version taken from info.json or a mod file name. */ -export type GameVersion = string; +declare const gameVersionSymbol: unique symbol; +export type GameVersion = string & { [gameVersionSymbol]: void }; export const GameVersionSchema = Type.Unsafe( Type.String({ pattern: gameVersionRegExp.source }) ); @@ -63,7 +60,10 @@ export function isGameVersion(input: string): input is GameVersion { * @returns The normalised full version, or undefined if no major and minor * could be read. */ -export function normaliseGameVersion(input: string): FullVersion | undefined { +export function normaliseGameVersion(input: GameVersion): FullVersion; +export function normaliseGameVersion(input: string): FullVersion | undefined; + +export function normaliseGameVersion(input: string | GameVersion): FullVersion | undefined { const match = gameVersionRegExp.exec(input); if (match === null) { return undefined; @@ -71,19 +71,8 @@ export function normaliseGameVersion(input: string): FullVersion | undefined { return `${Number(match[1])}.${Number(match[2])}.${Number(match[3] ?? "0")}` as FullVersion; } -/** - * Integer representation of a raw {@link GameVersion}, normalised the same - * lenient way the game reads it. Suitable for comparing versions that may carry - * leading zeros, whitespace or trailing content. - * - * @throws if the input can not be read as a version. - */ export function integerGameVersion(version: GameVersion) { - const normalised = normaliseGameVersion(version); - if (normalised === undefined) { - throw new Error(`Invalid version "${version}"`); - } - return integerFullVersion(normalised); + return integerFullVersion(normaliseGameVersion(version)); } /** @@ -287,7 +276,10 @@ export class ModVersionRange { return this.minVersion.testIntegerVersion(other) && this.maxVersion.testIntegerVersion(other); } - testVersion(version: GameVersion) { + testVersion(version: PartialVersion | GameVersion) { + if (isPartialVersion(version)) { + return this.testIntegerVersion(integerPartialVersion(version)); + } return this.testIntegerVersion(integerGameVersion(version)); } diff --git a/packages/web_ui/src/components/ModPackViewPage.tsx b/packages/web_ui/src/components/ModPackViewPage.tsx index ac7822cd..937450e2 100644 --- a/packages/web_ui/src/components/ModPackViewPage.tsx +++ b/packages/web_ui/src/components/ModPackViewPage.tsx @@ -405,7 +405,7 @@ function DownloadDependenciesButton(props: DownloadDependenciesProps) { missing.map(mod => ({ name: mod.name, // mod.version is the raw version; the equality bound must be canonical. - version: new lib.ModVersionEquality("=", lib.normaliseGameVersion(mod.version)!), + version: new lib.ModVersionEquality("=", mod.normalisedVersion), })), factorioVersion, ) diff --git a/packages/web_ui/src/components/ModsPage.tsx b/packages/web_ui/src/components/ModsPage.tsx index 3b13f7c9..7d2e8e92 100644 --- a/packages/web_ui/src/components/ModsPage.tsx +++ b/packages/web_ui/src/components/ModsPage.tsx @@ -170,7 +170,7 @@ function SearchModsButton() { if (modVersion) { control.send( new lib.ModPortalDownloadRequest( - [{ name: modName, version: new lib.ModVersionEquality("=", lib.normaliseGameVersion(modVersion)!)}], + [{ name: modName, version: new lib.ModVersionEquality("=", lib.normaliseGameVersion(modVersion))}], portalFactorioVersion ) ).then(() => { From 573274875cb8c98ed03e65c349934f7b869f527b Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:27:05 +0100 Subject: [PATCH 8/8] Rename to `SourceVersion` --- packages/controller/src/BaseConnection.ts | 2 +- packages/controller/src/ControlConnection.ts | 2 +- packages/controller/src/Controller.ts | 2 +- packages/ctl/src/commands_mod.ts | 8 ++--- packages/ctl/src/commands_mod_pack.ts | 2 +- packages/lib/src/ModStore.ts | 8 ++--- packages/lib/src/data/ModInfo.ts | 20 +++++------ packages/lib/src/data/ModPack.ts | 8 ++--- packages/lib/src/data/messages_mod.ts | 18 +++++----- packages/lib/src/data/version.ts | 38 ++++++++++---------- packages/web_ui/src/components/ModsPage.tsx | 7 ++-- test/lib/data/version.test.js | 22 ++++++------ 12 files changed, 70 insertions(+), 67 deletions(-) diff --git a/packages/controller/src/BaseConnection.ts b/packages/controller/src/BaseConnection.ts index 19cc47a6..76949eb8 100644 --- a/packages/controller/src/BaseConnection.ts +++ b/packages/controller/src/BaseConnection.ts @@ -80,7 +80,7 @@ export default class BaseConnection extends lib.Link { return modPack; } - getMod(mod: { name: string, version: lib.GameVersion, sha1?: string }) { + getMod(mod: { name: string, version: lib.SourceVersion, sha1?: string }) { let filename = lib.ModInfo.filename(mod.name, mod.version); let modInfo = this._controller.modStore.files.get(filename); if (!modInfo) { diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index 12e46e77..293f28d0 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -707,7 +707,7 @@ export default class ControlConnection extends BaseConnection { const release = releases.releases .filter(info => versionRange.testVersion(info.version)) .reduce((max, cur) => ( - max && lib.integerGameVersion(max.version) > lib.integerGameVersion(cur.version) ? max : cur + max && lib.integerSourceVersion(max.version) > lib.integerSourceVersion(cur.version) ? max : cur ), undefined); if (!release) { diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index 3b72dcca..8051a0cc 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -766,7 +766,7 @@ export default class Controller { // migrate: 2.0.0-alpha.22 - old buildins used X.Y rather than X.Y.Z // All other versions are kept verbatim so they still match the mod's file name. if (lib.isPartialVersion(mod.version)) { - mod.version = lib.normaliseFullVersion(mod.version) as lib.GameVersion; + mod.version = lib.normaliseFullVersion(mod.version) as lib.SourceVersion; } } return json; diff --git a/packages/ctl/src/commands_mod.ts b/packages/ctl/src/commands_mod.ts index 169b8fb5..83f8873a 100644 --- a/packages/ctl/src/commands_mod.ts +++ b/packages/ctl/src/commands_mod.ts @@ -20,7 +20,7 @@ modCommands.add(new lib.Command({ yargs.positional("mod-version", { describe: "Version of the mod", type: "string" }); }], handler: async function(args: { name: string, modVersion: string }, control: Control) { - if (!lib.isGameVersion(args.modVersion)) { + if (!lib.isSourceVersion(args.modVersion)) { throw new lib.CommandError("mod-version must match format digit.digit.digit"); } let modInfo = await control.send(new lib.ModGetRequest(args.name, args.modVersion)); @@ -108,7 +108,7 @@ modCommands.add(new lib.Command({ }, control: Control ) { - const searchVersion = lib.normaliseGameVersion(String(args.factorioVersion)); + const searchVersion = lib.normaliseSourceVersion(String(args.factorioVersion)); if (searchVersion === undefined) { throw new Error(`Invalid factorio version "${args.factorioVersion}"`); } @@ -191,7 +191,7 @@ modCommands.add(new lib.Command({ yargs.positional("mod-version", { describe: "Version of mod to download", type: "string" }); }], handler: async function(args: { name: string, modVersion: string }, control: Control) { - if (!lib.isGameVersion(args.modVersion)) { + if (!lib.isSourceVersion(args.modVersion)) { throw new lib.CommandError("mod-version must match format digit.digit.digit"); } let streamId = await control.send(new lib.ModDownloadRequest(args.name, args.modVersion)); @@ -211,7 +211,7 @@ modCommands.add(new lib.Command({ yargs.positional("mod-version", { describe: "Version of mod to delete", type: "string" }); }], handler: async function(args: { name: string, modVersion: string }, control: Control) { - if (!lib.isGameVersion(args.modVersion)) { + if (!lib.isSourceVersion(args.modVersion)) { throw new lib.CommandError("mod-version must match format digit.digit.digit"); } await control.send(new lib.ModDeleteRequest(args.name, args.modVersion)); diff --git a/packages/ctl/src/commands_mod_pack.ts b/packages/ctl/src/commands_mod_pack.ts index 6d3fdfa1..a4af8a90 100644 --- a/packages/ctl/src/commands_mod_pack.ts +++ b/packages/ctl/src/commands_mod_pack.ts @@ -132,7 +132,7 @@ function setModPackMods(modPack: lib.ModPack, mods: string[] | undefined) { if (!version) { throw new lib.CommandError("Added mod must be formatted as name:version or name:version:sha1"); } - if (!lib.isGameVersion(version)) { + if (!lib.isSourceVersion(version)) { throw new lib.CommandError("version must match the format digit.digit.digit"); } if (sha1 && !/^[0-9a-f]{40}$/.test(sha1)) { diff --git a/packages/lib/src/ModStore.ts b/packages/lib/src/ModStore.ts index 3192ec55..625ae253 100644 --- a/packages/lib/src/ModStore.ts +++ b/packages/lib/src/ModStore.ts @@ -5,7 +5,7 @@ import { Static } from "@sinclair/typebox"; import { logger } from "./logging"; import { downloadFile, safeOutputFile } from "./file_ops"; import { ModPortalReleaseSchema, ModPortalDetailsSchema, ModNameVersionPair } from "./data/messages_mod"; -import { ModInfo, ModPack, MajorMinorVersion, ModVersionEquality, GameVersion } from "./data"; +import { ModInfo, ModPack, MajorMinorVersion, ModVersionEquality, SourceVersion } from "./data"; export interface ModStoreEvents { /** A stored mod was created, updated or deleted */ @@ -44,7 +44,7 @@ export default class ModStore extends events.EventEmitter { super(); } - getMod(name: string, version: GameVersion, sha1?: string) { + getMod(name: string, version: SourceVersion, sha1?: string) { const mod = this.files.get(ModInfo.filename(name, version)); if (!mod || sha1 && sha1 !== mod.sha1) { return undefined; @@ -86,11 +86,11 @@ export default class ModStore extends events.EventEmitter { this.emit("change", modInfo); } - async loadMod(name: string, version: GameVersion) { + async loadMod(name: string, version: SourceVersion) { return await this.loadFile(ModInfo.filename(name, version)); } - async deleteMod(name: string, version: GameVersion) { + async deleteMod(name: string, version: SourceVersion) { return await this.deleteFile(ModInfo.filename(name, version)); } diff --git a/packages/lib/src/data/ModInfo.ts b/packages/lib/src/data/ModInfo.ts index f5ca2fa3..14416f54 100644 --- a/packages/lib/src/data/ModInfo.ts +++ b/packages/lib/src/data/ModInfo.ts @@ -9,7 +9,7 @@ import { ModRecord } from "./ModPack"; import { MajorMinorVersion, MajorMinorVersionSchema, normaliseMajorMinorVersion, integerMajorMinorVersion, - GameVersion, GameVersionSchema, isGameVersion, normaliseGameVersion, integerGameVersion, + SourceVersion, SourceVersionSchema, isSourceVersion, normaliseSourceVersion, integerSourceVersion, ModVersionEquality, } from "./version"; @@ -133,18 +133,18 @@ export default class ModInfo { * it is only safe to use for building file names and identifiers; use * {@link integerVersion} to compare it. */ - version = "0.0.0" as GameVersion; + version = "0.0.0" as SourceVersion; /** * Integer representation of the version, normalised the lenient way the * game reads it so it can be compared and sorted. */ get integerVersion() { - return integerGameVersion(this.version); + return integerSourceVersion(this.version); } get normalisedVersion() { - return normaliseGameVersion(this.version); + return normaliseSourceVersion(this.version); } /** @@ -227,7 +227,7 @@ export default class ModInfo { * @param version - Mod's version * @returns string containing {name}_{version}.zip */ - static filename(name: string, version: GameVersion) { + static filename(name: string, version: SourceVersion) { return `${name}_${version}.zip`; } @@ -258,13 +258,13 @@ export default class ModInfo { // lenient format the game accepts; they are normalised when read in fromJSON. static infoJsonSchema = Type.Object({ "name": Type.String(), - "version": GameVersionSchema, + "version": SourceVersionSchema, "title": Type.String(), "author": Type.String(), "contact": Type.Optional(Type.String()), "homepage": Type.Optional(Type.String()), "description": Type.Optional(Type.String()), - "factorio_version": Type.Optional(GameVersionSchema), + "factorio_version": Type.Optional(SourceVersionSchema), "dependencies": Type.Optional(Type.Array(Type.String())), }); @@ -287,7 +287,7 @@ export default class ModInfo { // info.json fields if (json.name) { modInfo.name = json.name; } if (json.version) { - if (!isGameVersion(json.version)) { + if (!isSourceVersion(json.version)) { throw new Error(`Invalid mod version "${json.version}"`); } modInfo.version = json.version; @@ -298,7 +298,7 @@ export default class ModInfo { if (json.homepage) { modInfo.homepage = json.homepage; } if (json.description) { modInfo.description = json.description; } if (json.factorio_version) { - const factorioVersion = normaliseGameVersion(json.factorio_version); + const factorioVersion = normaliseSourceVersion(json.factorio_version); if (factorioVersion === undefined) { throw new Error(`Invalid factorio_version "${json.factorio_version}"`); } @@ -332,7 +332,7 @@ export default class ModInfo { if (this.contact) { json.contact = this.contact; } if (this.homepage) { json.homepage = this.homepage; } if (this.description) { json.description = this.description; } - if (this.factorioVersion !== "0.12") { json.factorio_version = this.factorioVersion as GameVersion; } + if (this.factorioVersion !== "0.12") { json.factorio_version = this.factorioVersion as SourceVersion; } if (this.dependencies.length !== 1 || this.dependencies[0].name !== "base") { json.dependencies = this.dependencySpecifications; } diff --git a/packages/lib/src/data/ModPack.ts b/packages/lib/src/data/ModPack.ts index 6e77644f..4968980d 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, - GameVersion, GameVersionSchema, normaliseFullVersion, + SourceVersion, SourceVersionSchema, normaliseFullVersion, } from "./version"; @@ -47,7 +47,7 @@ export interface ModRecord { /** if mod is to be loaded. */ enabled: boolean, /** version of the mod, verbatim as it appears in the mod's file name. */ - version: GameVersion, + version: SourceVersion, /** SHA1 hash of the zip file. */ sha1?: string, /** Used inside packages\web_ui\src\components\ModPackViewPage.tsx to define an error type. */ @@ -61,7 +61,7 @@ export interface ModRecord { const ModRecordJsonSchema = Type.Object({ "name": Type.String(), "enabled": Type.Boolean(), - "version": GameVersionSchema, + "version": SourceVersionSchema, "sha1": Type.Optional(Type.String()), }); @@ -434,7 +434,7 @@ export default class ModPack { * @return Built in mods for the given version */ static getBuiltinMods(factorioVersion: PartialVersion) { - const version = normaliseFullVersion(factorioVersion) as GameVersion; + const version = normaliseFullVersion(factorioVersion) as SourceVersion; const integerVersion = integerPartialVersion(factorioVersion); let defaultMods: ModRecord[] = [ // "core" not included because core cannot be disabled diff --git a/packages/lib/src/data/messages_mod.ts b/packages/lib/src/data/messages_mod.ts index 17def204..4d5a09e3 100644 --- a/packages/lib/src/data/messages_mod.ts +++ b/packages/lib/src/data/messages_mod.ts @@ -5,7 +5,7 @@ import { JsonString, jsonArray } from "./composites"; import { MajorMinorVersion, MajorMinorVersionSchema, normaliseMajorMinorVersion, - GameVersion, GameVersionSchema, + SourceVersion, SourceVersionSchema, ModVersionEquality, } from "./version"; @@ -119,13 +119,13 @@ export class ModGetRequest { constructor( public name: string, - public version: GameVersion, + public version: SourceVersion, public sha1?: string, ) { } static jsonSchema = Type.Object({ "name": Type.String(), - "version": GameVersionSchema, + "version": SourceVersionSchema, "sha1": Type.Optional(Type.String()), }); @@ -205,9 +205,9 @@ export class ModSearchRequest { // Define the structure for the latest release info from the portal export const ModPortalReleaseSchema = Type.Object({ - version: GameVersionSchema, + version: SourceVersionSchema, // Match the structure from ModStore's ModRelease/ModDetails - info_json: Type.Object({ factorio_version: GameVersionSchema }), + info_json: Type.Object({ factorio_version: SourceVersionSchema }), released_at: Type.String(), // ISO 8601 date string download_url: Type.String(), file_name: Type.String(), @@ -276,13 +276,13 @@ export class ModDownloadRequest { constructor( public name: string, - public version: GameVersion, + public version: SourceVersion, public sha1?: string, ) { } static jsonSchema = Type.Object({ "name": Type.String(), - "version": GameVersionSchema, + "version": SourceVersionSchema, "sha1": Type.Optional(Type.String()), }); @@ -302,12 +302,12 @@ export class ModDeleteRequest { constructor( public name: string, - public version: GameVersion, + public version: SourceVersion, ) { } static jsonSchema = Type.Object({ "name": Type.String(), - "version": GameVersionSchema, + "version": SourceVersionSchema, }); static fromJSON(json: Static) { diff --git a/packages/lib/src/data/version.ts b/packages/lib/src/data/version.ts index 0b17b3ba..5844fb94 100644 --- a/packages/lib/src/data/version.ts +++ b/packages/lib/src/data/version.ts @@ -36,20 +36,24 @@ export const ApiVersions = ["0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0 * optional, and any trailing content is ignored. The pattern is intentionally * not anchored at the end. */ -const gameVersionRegExp = /^\s*(\d+)\.\s*(\d+)(?:\.\s*(\d+))?/; +const sourceVersionRegExp = /^\s*(\d+)\.\s*(\d+)(?:\.\s*(\d+))?/; /** * A raw, un-normalised version string exactly as the game reads it, such as a * mod's version taken from info.json or a mod file name. */ -declare const gameVersionSymbol: unique symbol; -export type GameVersion = string & { [gameVersionSymbol]: void }; -export const GameVersionSchema = Type.Unsafe( - Type.String({ pattern: gameVersionRegExp.source }) +declare const sourceVersionSymbol: unique symbol; +export type SourceVersion = string & { [sourceVersionSymbol]: void }; +export const SourceVersionSchema = Type.Unsafe( + Type.String({ pattern: sourceVersionRegExp.source }) ); -export function isGameVersion(input: string): input is GameVersion { - return gameVersionRegExp.test(input); +export function isSourceVersion(input: string): input is SourceVersion { + return sourceVersionRegExp.test(input); +} + +export function integerSourceVersion(version: SourceVersion) { + return integerFullVersion(normaliseSourceVersion(version)); } /** @@ -60,21 +64,17 @@ export function isGameVersion(input: string): input is GameVersion { * @returns The normalised full version, or undefined if no major and minor * could be read. */ -export function normaliseGameVersion(input: GameVersion): FullVersion; -export function normaliseGameVersion(input: string): FullVersion | undefined; +export function normaliseSourceVersion(input: SourceVersion): FullVersion; +export function normaliseSourceVersion(input: string): FullVersion | undefined; -export function normaliseGameVersion(input: string | GameVersion): FullVersion | undefined { - const match = gameVersionRegExp.exec(input); +export function normaliseSourceVersion(input: string | SourceVersion): FullVersion | undefined { + const match = sourceVersionRegExp.exec(input); if (match === null) { return undefined; } return `${Number(match[1])}.${Number(match[2])}.${Number(match[3] ?? "0")}` as FullVersion; } -export function integerGameVersion(version: GameVersion) { - return integerFullVersion(normaliseGameVersion(version)); -} - /** * Matches valid mod versions, where all parts are specified. */ @@ -213,8 +213,8 @@ export class ModVersionEquality { } } - testVersion(version: GameVersion) { - return this.testIntegerVersion(integerGameVersion(version)); + testVersion(version: SourceVersion) { + return this.testIntegerVersion(integerSourceVersion(version)); } toString() { @@ -276,11 +276,11 @@ export class ModVersionRange { return this.minVersion.testIntegerVersion(other) && this.maxVersion.testIntegerVersion(other); } - testVersion(version: PartialVersion | GameVersion) { + testVersion(version: PartialVersion | SourceVersion) { if (isPartialVersion(version)) { return this.testIntegerVersion(integerPartialVersion(version)); } - return this.testIntegerVersion(integerGameVersion(version)); + return this.testIntegerVersion(integerSourceVersion(version)); } combineVersion(other: ModVersionEquality) { diff --git a/packages/web_ui/src/components/ModsPage.tsx b/packages/web_ui/src/components/ModsPage.tsx index 7d2e8e92..8d76cc78 100644 --- a/packages/web_ui/src/components/ModsPage.tsx +++ b/packages/web_ui/src/components/ModsPage.tsx @@ -164,13 +164,16 @@ function SearchModsButton() { const handleControllerDownload = ( modName: string, modTitle: string | undefined, - modVersion: lib.GameVersion | undefined, + modVersion: lib.SourceVersion | undefined, portalFactorioVersion: lib.MajorMinorVersion, ) => { if (modVersion) { control.send( new lib.ModPortalDownloadRequest( - [{ name: modName, version: new lib.ModVersionEquality("=", lib.normaliseGameVersion(modVersion))}], + [{ + name: modName, + version: new lib.ModVersionEquality("=", lib.normaliseSourceVersion(modVersion)), + }], portalFactorioVersion ) ).then(() => { diff --git a/test/lib/data/version.test.js b/test/lib/data/version.test.js index 433fb8f3..92bba3e9 100644 --- a/test/lib/data/version.test.js +++ b/test/lib/data/version.test.js @@ -17,25 +17,25 @@ describe("lib/data/version", function() { } }); }); - describe("normaliseGameVersion()", function() { + describe("normaliseSourceVersion()", function() { it("should normalise lenient version strings the way the game reads them", function() { // Major and minor required, patch optional and defaulting to 0. - assert.equal(lib.normaliseGameVersion("1.1"), "1.1.0"); - assert.equal(lib.normaliseGameVersion("1.1.50"), "1.1.50"); - assert.equal(lib.normaliseGameVersion("0.18"), "0.18.0"); + assert.equal(lib.normaliseSourceVersion("1.1"), "1.1.0"); + assert.equal(lib.normaliseSourceVersion("1.1.50"), "1.1.50"); + assert.equal(lib.normaliseSourceVersion("0.18"), "0.18.0"); // Leading whitespace and zeros are dropped. - assert.equal(lib.normaliseGameVersion(" 1.1"), "1.1.0"); - assert.equal(lib.normaliseGameVersion("01.02.03"), "1.2.3"); - assert.equal(lib.normaliseGameVersion("1. 1. 50"), "1.1.50"); + assert.equal(lib.normaliseSourceVersion(" 1.1"), "1.1.0"); + assert.equal(lib.normaliseSourceVersion("01.02.03"), "1.2.3"); + assert.equal(lib.normaliseSourceVersion("1. 1. 50"), "1.1.50"); // Anything after the patch (or a non-numeric patch) is ignored. - assert.equal(lib.normaliseGameVersion("1.1.50-beta"), "1.1.50"); - assert.equal(lib.normaliseGameVersion("1.1.x"), "1.1.0"); - assert.equal(lib.normaliseGameVersion("1.1.50.60"), "1.1.50"); + assert.equal(lib.normaliseSourceVersion("1.1.50-beta"), "1.1.50"); + assert.equal(lib.normaliseSourceVersion("1.1.x"), "1.1.0"); + assert.equal(lib.normaliseSourceVersion("1.1.50.60"), "1.1.50"); }); it("should return undefined when no major.minor can be read", function() { const invalid = ["1", "", "latest", "abc", ".1.1", "x.y"]; for (const test of invalid) { - assert.equal(lib.normaliseGameVersion(test), undefined, test); + assert.equal(lib.normaliseSourceVersion(test), undefined, test); } }); });