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