diff --git a/packages/controller/src/BaseConnection.ts b/packages/controller/src/BaseConnection.ts index ba6ba39c..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.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) { diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index 7dceb299..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.integerFullVersion(max.version) > lib.integerFullVersion(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 6dfbe643..8051a0cc 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -763,8 +763,11 @@ 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); + // 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; }); diff --git a/packages/ctl/src/commands_mod.ts b/packages/ctl/src/commands_mod.ts index a58a9f42..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.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)); diff --git a/packages/ctl/src/commands_mod_pack.ts b/packages/ctl/src/commands_mod_pack.ts index 0374be2c..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.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)) { diff --git a/packages/lib/src/ModStore.ts b/packages/lib/src/ModStore.ts index 13e82716..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, 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 { 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 { 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 { 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 { }); 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: 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 83fdf384..14416f54 100644 --- a/packages/lib/src/data/ModInfo.ts +++ b/packages/lib/src/data/ModInfo.ts @@ -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; } diff --git a/packages/lib/src/data/ModPack.ts b/packages/lib/src/data/ModPack.ts index d8ddd4ae..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, - 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 }, ]); } diff --git a/packages/lib/src/data/messages_mod.ts b/packages/lib/src/data/messages_mod.ts index 096eace5..4d5a09e3 100644 --- a/packages/lib/src/data/messages_mod.ts +++ b/packages/lib/src/data/messages_mod.ts @@ -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) { @@ -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) { @@ -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, ); } diff --git a/packages/lib/src/data/version.ts b/packages/lib/src/data/version.ts index 22ffdcd1..5844fb94 100644 --- a/packages/lib/src/data/version.ts +++ b/packages/lib/src/data/version.ts @@ -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; +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( + 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( + 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( 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( + 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(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) { 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 b6327a72..937450e2 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.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(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.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"; } diff --git a/packages/web_ui/src/components/ModsPage.tsx b/packages/web_ui/src/components/ModsPage.tsx index e32bb3c0..8d76cc78 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,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) { 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..752cbaea 100644 --- a/test/lib/data/ModInfo.js +++ b/test/lib/data/ModInfo.js @@ -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"]; diff --git a/test/lib/data/version.test.js b/test/lib/data/version.test.js index 39cf0e03..92bba3e9 100644 --- a/test/lib/data/version.test.js +++ b/test/lib/data/version.test.js @@ -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() {