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) <noreply@anthropic.com>
This commit is contained in:
bbassieandClaude Opus 4.8 committed 2026-06-26 16:24:43 +00:00
1 parent ce8bddad8f
commit 97a6c32838
10 files changed
+141 -81

No files matched your search

+5 -1
View File
@@ -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,
+16 -5
View File
@@ -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<ModStoreEvents> {
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<ModStoreEvents> {
* @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<void>[] = [];
+22 -9
View File
@@ -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 {
+13 -12
View File
@@ -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<typeof this.jsonSchema>) {
@@ -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,
);
}
+37 -18
View File
@@ -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<typeof ApiVersionSchema>;
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".
*/
@@ -77,12 +77,12 @@ function SearchModsTable(props: SearchModsTableProps) {
const [modResultPageSize, setModResultPageSize] = useState<number>(10);
const [modResultCount, setModResultCount] = useState<number>(2);
const [modResultSelectedVersion, setModResultSelectedVersion] = useState<Map<string, number>>(new Map());
const [factorioVersion, setFactorioVersion] = useState<lib.ApiVersion | null>(null);
const [factorioVersion, setFactorioVersion] = useState<lib.PartialVersion | null>(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<Error | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [factorioVersion, setFactorioVersion] = useState<lib.ApiVersion | null>(null);
const [factorioVersion, setFactorioVersion] = useState<lib.PartialVersion | null>(null);
const [mods, setMods] = useState<lib.ModInfo[]>([]); // All resolved mods and dependencies
const [missing, setMissing] = useState<lib.ModInfo[]>([]); // 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";
}
+3 -3
View File
@@ -147,7 +147,7 @@ function SearchModsButton() {
const [open, setOpen] = useState(false);
const [form] = Form.useForm();
const [searchText, setSearchText] = useState("");
const [factorioVersion, setFactorioVersion] = useState<lib.ApiVersion>("2.0");
const [factorioVersion, setFactorioVersion] = useState<lib.PartialVersion>("2.0");
// State for all mods fetched from backend
const [allMods, setAllMods] = useState<ModPortalModType[]>([]);
@@ -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) {
+1 -1
View File
@@ -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");
});
+15
View File
@@ -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"];
+23 -26
View File
@@ -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() {