Merge pull request #941 from bbassie/feature/lenient-mod-version-parsing

Parse mod versions leniently like the game does
This commit is contained in:
Cooldude2606 authored and GitHub committed 2026-07-02 00:50:06 +01:00
commit 43238feada
16 files changed
+287 -129

No files matched your search

+1 -1
View File
@@ -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.SourceVersion, sha1?: string }) {
let filename = lib.ModInfo.filename(mod.name, mod.version);
let modInfo = this._controller.modStore.files.get(filename);
if (!modInfo) {
+1 -1
View File
@@ -707,7 +707,7 @@ export default class ControlConnection extends BaseConnection {
const release = releases.releases
.filter(info => versionRange.testVersion(info.version))
.reduce<ModPortalReleaseType | undefined>((max, cur) => (
max && lib.integerFullVersion(max.version) > lib.integerFullVersion(cur.version) ? max : cur
max && lib.integerSourceVersion(max.version) > lib.integerSourceVersion(cur.version) ? max : cur
), undefined);
if (!release) {
+5 -2
View File
@@ -763,8 +763,11 @@ export default class Controller {
const serialized = rawJson as Static<typeof lib.ModPack.jsonSchema>[];
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);
// 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.SourceVersion;
}
}
return json;
});
+8 -4
View File
@@ -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.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,9 +108,13 @@ modCommands.add(new lib.Command({
},
control: Control
) {
const searchVersion = lib.normaliseSourceVersion(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.normaliseMajorMinorVersion(searchVersion),
args.page,
args.pageSize,
args.sort,
@@ -187,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.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));
@@ -207,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.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));
+1 -1
View File
@@ -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.isSourceVersion(version)) {
throw new lib.CommandError("version must match the format digit.digit.digit");
}
if (sha1 && !/^[0-9a-f]{40}$/.test(sha1)) {
+18 -7
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, 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<ModStoreEvents> {
super();
}
getMod(name: string, version: FullVersion, 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<ModStoreEvents> {
this.emit("change", modInfo);
}
async loadMod(name: string, version: FullVersion) {
async loadMod(name: string, version: SourceVersion) {
return await this.loadFile(ModInfo.filename(name, version));
}
async deleteMod(name: string, version: FullVersion) {
async deleteMod(name: string, version: SourceVersion) {
return await this.deleteFile(ModInfo.filename(name, version));
}
@@ -198,7 +198,7 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
private async downloadModsChunk(
mods: ModNameVersionPair[],
username: string, token: string,
factorioVersion: ApiVersion,
factorioVersion: MajorMinorVersion,
) {
const url = new URL("https://mods.factorio.com/api/mods");
url.searchParams.set("page_size", "max");
@@ -209,7 +209,18 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
});
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: MajorMinorVersion = "1.1"
) {
const chunkSize = 500;
const futures: Promise<void>[] = [];
+38 -20
View File
@@ -8,13 +8,12 @@ import { findRoot } from "../zip_ops";
import { ModRecord } from "./ModPack";
import {
ApiVersion,
ApiVersionSchema,
FullVersion, FullVersionSchema, integerFullVersion,
integerPartialVersion,
MajorMinorVersion, MajorMinorVersionSchema, normaliseMajorMinorVersion, integerMajorMinorVersion,
SourceVersion, SourceVersionSchema, isSourceVersion, normaliseSourceVersion, integerSourceVersion,
ModVersionEquality,
} from "./version";
type ModDependencyType = "incompatible" | "optional" | "hidden" | "unordered" | "required";
export type ModDependencyUnsatisfiedReason = "incompatible" | "missing_dependency" | "wrong_version";
@@ -129,16 +128,23 @@ 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 FullVersion;
version = "0.0.0" as SourceVersion;
/**
* 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.version);
return integerSourceVersion(this.version);
}
get normalisedVersion() {
return normaliseSourceVersion(this.version);
}
/**
@@ -172,17 +178,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 "0.12" | ApiVersion;
factorioVersion = "0.12" as MajorMinorVersion;
/**
* Integer representation of the factorioVersion
* @type {number}
*/
get integerFactorioVersion() {
return integerPartialVersion(this.factorioVersion);
return integerMajorMinorVersion(this.factorioVersion);
}
/**
@@ -221,7 +227,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: SourceVersion) {
return `${name}_${version}.zip`;
}
@@ -248,16 +254,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": 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(ApiVersionSchema),
"factorio_version": Type.Optional(SourceVersionSchema),
"dependencies": Type.Optional(Type.Array(Type.String())),
});
@@ -279,13 +286,24 @@ export default class ModInfo {
// info.json fields
if (json.name) { modInfo.name = json.name; }
if (json.version) { modInfo.version = json.version; }
if (json.version) {
if (!isSourceVersion(json.version)) {
throw new Error(`Invalid mod version "${json.version}"`);
}
modInfo.version = json.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 = normaliseSourceVersion(json.factorio_version);
if (factorioVersion === undefined) {
throw new Error(`Invalid factorio_version "${json.factorio_version}"`);
}
modInfo.factorioVersion = normaliseMajorMinorVersion(factorioVersion);
}
// Parse the dependencies
try {
@@ -314,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 SourceVersion; }
if (this.dependencies.length !== 1 || this.dependencies[0].name !== "base") {
json.dependencies = this.dependencySpecifications;
}
+11 -11
View File
@@ -12,7 +12,7 @@ import ModInfo, { ModDependencyUnsatisfiedReason } from "./ModInfo";
import {
PartialVersion, PartialVersionSchema, integerPartialVersion,
FullVersion, FullVersionSchema, normaliseFullVersion,
SourceVersion, SourceVersionSchema, 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: 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": FullVersionSchema,
"version": SourceVersionSchema,
"sha1": Type.Optional(Type.String()),
});
@@ -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 SourceVersion;
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 },
]);
}
+20 -20
View File
@@ -4,8 +4,8 @@ import ModPack from "./ModPack";
import { JsonString, jsonArray } from "./composites";
import {
FullVersion, FullVersionSchema,
ApiVersion, ApiVersionSchema, normaliseApiVersion,
MajorMinorVersion, MajorMinorVersionSchema, normaliseMajorMinorVersion,
SourceVersion, SourceVersionSchema,
ModVersionEquality,
} from "./version";
@@ -119,13 +119,13 @@ export class ModGetRequest {
constructor(
public name: string,
public version: FullVersion,
public version: SourceVersion,
public sha1?: string,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"version": FullVersionSchema,
"version": SourceVersionSchema,
"sha1": Type.Optional(Type.String()),
});
@@ -154,7 +154,7 @@ export class ModSearchRequest {
constructor(
public query: string,
public factorioVersion: ApiVersion,
public factorioVersion: MajorMinorVersion,
public page: number,
public pageSize?: number,
public sort?: string,
@@ -163,7 +163,7 @@ export class ModSearchRequest {
static jsonSchema = Type.Object({
"query": Type.String(),
"factorioVersion": ApiVersionSchema,
"factorioVersion": MajorMinorVersionSchema,
"page": Type.Integer(),
"pageSize": Type.Optional(Type.Integer()),
"sort": 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: FullVersionSchema,
version: SourceVersionSchema,
// Match the structure from ModStore's ModRelease/ModDetails
info_json: Type.Object({ factorio_version: ApiVersionSchema }),
info_json: Type.Object({ factorio_version: SourceVersionSchema }),
released_at: Type.String(), // ISO 8601 date string
download_url: Type.String(),
file_name: Type.String(),
@@ -236,12 +236,12 @@ export class ModPortalGetAllRequest {
static permission = "core.mod.search_portal" as const;
constructor(
public factorioVersion: ApiVersion,
public factorioVersion: MajorMinorVersion,
public hide_deprecated?: boolean,
) { }
static jsonSchema = Type.Object({
"factorioVersion": ApiVersionSchema,
"factorioVersion": MajorMinorVersionSchema,
"hide_deprecated": Type.Optional(Type.Boolean()),
});
@@ -276,13 +276,13 @@ export class ModDownloadRequest {
constructor(
public name: string,
public version: FullVersion,
public version: SourceVersion,
public sha1?: string,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"version": FullVersionSchema,
"version": SourceVersionSchema,
"sha1": Type.Optional(Type.String()),
});
@@ -302,12 +302,12 @@ export class ModDeleteRequest {
constructor(
public name: string,
public version: FullVersion,
public version: SourceVersion,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"version": FullVersionSchema,
"version": SourceVersionSchema,
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
@@ -385,12 +385,12 @@ export class ModPortalDownloadRequest {
constructor(
public mods: ModNameVersionPair[],
public factorioVersion: ApiVersion,
public factorioVersion: MajorMinorVersion,
) { }
static jsonSchema = Type.Object({
"mods": Type.Array(ModNameVersionPairSchema),
"factorioVersion": ApiVersionSchema,
"factorioVersion": MajorMinorVersionSchema,
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
@@ -422,13 +422,13 @@ export class ModDependencyResolveRequest {
constructor(
public mods: ModDependency[],
public factorioVersion: ApiVersion,
public factorioVersion: MajorMinorVersion,
public checkForUpdates: boolean = false,
) { }
static jsonSchema = Type.Object({
"mods": Type.Array(ModDependency.jsonSchema),
"factorioVersion": ApiVersionSchema,
"factorioVersion": MajorMinorVersionSchema,
"checkForUpdates": Type.Boolean(),
});
@@ -441,7 +441,7 @@ export class ModDependencyResolveRequest {
return new this(
[...modPack.mods.values()]
.map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)),
normaliseApiVersion(modPack.factorioVersion),
normaliseMajorMinorVersion(modPack.factorioVersion),
checkForUpdates,
);
}
@@ -452,7 +452,7 @@ export class ModDependencyResolveRequest {
[...modPack.mods.values()]
.filter(mod => mod.enabled)
.map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)),
normaliseApiVersion(modPack.factorioVersion),
normaliseMajorMinorVersion(modPack.factorioVersion),
checkForUpdates,
);
}
+88 -22
View File
@@ -24,24 +24,55 @@ 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 const ApiVersions = ["0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0", "2.1"] as const;
export function isApiVersion(input: string): input is ApiVersion {
return ApiVersions.includes(input as any);
/**
* 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 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 sourceVersionSymbol: unique symbol;
export type SourceVersion = string & { [sourceVersionSymbol]: void };
export const SourceVersionSchema = Type.Unsafe<SourceVersion>(
Type.String({ pattern: sourceVersionRegExp.source })
);
export function isSourceVersion(input: string): input is SourceVersion {
return sourceVersionRegExp.test(input);
}
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}`);
export function integerSourceVersion(version: SourceVersion) {
return integerFullVersion(normaliseSourceVersion(version));
}
/**
* 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 normaliseSourceVersion(input: SourceVersion): FullVersion;
export function normaliseSourceVersion(input: string): FullVersion | undefined;
export function normaliseSourceVersion(input: string | SourceVersion): FullVersion | undefined {
const match = sourceVersionRegExp.exec(input);
if (match === null) {
return undefined;
}
return apiVersion;
return `${Number(match[1])}.${Number(match[2])}.${Number(match[3] ?? "0")}` as FullVersion;
}
/**
@@ -68,11 +99,38 @@ export function normaliseFullVersion(version: PartialVersion) {
return (parts.length === 3 ? version : `${version}.0`) as FullVersion;
}
/**
* Matches a major.minor version, as used by the mod portal's version filter.
*/
const majorMinorVersionRegExp = /^\d+\.\d+$/;
export type MajorMinorVersion = `${number}.${number}`;
export const MajorMinorVersionSchema = Type.Unsafe<MajorMinorVersion>(
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;
}
/**
* 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 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<PartialVersion>(
Type.String({ pattern: partialVersionRegExp.source })
);
@@ -94,8 +152,13 @@ export function integerPartialVersion(version: PartialVersion) {
* 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<ReleaseChannel>(
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);
}
@@ -103,15 +166,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<TargetVersion>(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);
}
/**
@@ -150,8 +213,8 @@ export class ModVersionEquality {
}
}
testVersion(version: PartialVersion) {
return this.testIntegerVersion(integerPartialVersion(version));
testVersion(version: SourceVersion) {
return this.testIntegerVersion(integerSourceVersion(version));
}
toString() {
@@ -213,8 +276,11 @@ export class ModVersionRange {
return this.minVersion.testIntegerVersion(other) && this.maxVersion.testIntegerVersion(other);
}
testVersion(version: PartialVersion) {
return this.testIntegerVersion(integerPartialVersion(version));
testVersion(version: PartialVersion | SourceVersion) {
if (isPartialVersion(version)) {
return this.testIntegerVersion(integerPartialVersion(version));
}
return this.testIntegerVersion(integerSourceVersion(version));
}
combineVersion(other: ModVersionEquality) {
@@ -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<lib.InstanceDetails> = [
@@ -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.MajorMinorVersion | null>(null);
// Get a valid factorio version
useEffect(() => {
try {
setFactorioVersion(lib.normaliseApiVersion(props.modPack.factorioVersion));
setFactorioVersion(lib.normaliseMajorMinorVersion(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.MajorMinorVersion | 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.normaliseMajorMinorVersion(props.modPack.factorioVersion));
} catch (err) {
setError(new Error(`Invalid factorio version: ${props.modPack.factorioVersion}`));
setFactorioVersion(null);
@@ -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("=", mod.normalisedVersion),
})),
factorioVersion,
)
@@ -577,8 +579,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.normaliseMajorMinorVersion(props.modPack.factorioVersion);
const modFactorioVersion = mod.info.factorioVersion;
if (packFactorioVersion !== modFactorioVersion) {
mod.warning = "wrong_factorio_version";
}
+8 -5
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.MajorMinorVersion>("2.0");
// State for all mods fetched from backend
const [allMods, setAllMods] = useState<ModPortalModType[]>([]);
@@ -164,13 +164,16 @@ function SearchModsButton() {
const handleControllerDownload = (
modName: string,
modTitle: string | undefined,
modVersion: lib.FullVersion | undefined,
portalFactorioVersion: lib.ApiVersion,
modVersion: lib.SourceVersion | undefined,
portalFactorioVersion: lib.MajorMinorVersion,
) => {
if (modVersion) {
control.send(
new lib.ModPortalDownloadRequest(
[{ name: modName, version: new lib.ModVersionEquality("=", modVersion)}],
[{
name: modName,
version: new lib.ModVersionEquality("=", lib.normaliseSourceVersion(modVersion)),
}],
portalFactorioVersion
)
).then(() => {
@@ -271,7 +274,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.MajorMinorVersion | 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");
});
+33
View File
@@ -341,6 +341,39 @@ describe("lib/data/ModInfo", function() {
dependencies: ["base", ""],
}), /my-mod/); // check the mod name is included in the error message
});
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",
});
// 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" }),
/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"];
+41 -24
View File
@@ -17,36 +17,53 @@ 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("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.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.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.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.isApiVersion(test), false, test);
assert.equal(lib.normaliseSourceVersion(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");
describe("normaliseMajorMinorVersion()", function() {
it("should return the major.minor", function() {
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");
});
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("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("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() {