diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index 7dceb299..12e46e77 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -707,7 +707,7 @@ export default class ControlConnection extends BaseConnection { const release = releases.releases .filter(info => versionRange.testVersion(info.version)) .reduce((max, cur) => ( - max && lib.integerFullVersion(max.version) > lib.integerFullVersion(cur.version) ? max : cur + max && lib.integerGameVersion(max.version) > lib.integerGameVersion(cur.version) ? max : cur ), undefined); if (!release) { diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index 6dfbe643..a081737a 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -763,8 +763,13 @@ export default class Controller { const serialized = rawJson as Static[]; return serialized.map(json => { for (const mod of json.mods) { - // migrate: 2.0.0.alpha.22 - json schema now enforces X.Y.Z for mod version, builtins would use X.Y only - mod.version = lib.normaliseFullVersion(mod.version); + // Older mod packs recorded builtins with a major.minor version; pad + // those to major.minor.patch. Raw versions the game reads leniently + // (e.g. with leading zeros) are kept verbatim so they still match the + // mod's file name. + if (lib.isPartialVersion(mod.version)) { + mod.version = lib.normaliseFullVersion(mod.version); + } } return json; }); diff --git a/packages/lib/src/data/ModInfo.ts b/packages/lib/src/data/ModInfo.ts index f797cee2..8a4d1885 100644 --- a/packages/lib/src/data/ModInfo.ts +++ b/packages/lib/src/data/ModInfo.ts @@ -8,9 +8,8 @@ import { findRoot } from "../zip_ops"; import { ModRecord } from "./ModPack"; import { - FullVersion, integerFullVersion, MajorMinorVersion, normaliseMajorMinorVersion, integerMajorMinorVersion, - GameVersion, GameVersionSchema, normaliseGameVersion, + GameVersion, GameVersionSchema, normaliseGameVersion, integerGameVersion, ModVersionEquality, } from "./version"; @@ -129,21 +128,19 @@ export default class ModInfo { name = ""; /** - * Version of the mod. - * Sourced from info.json. + * Version of the mod, exactly as it appears in info.json and the mod's file + * name. This is the raw, un-normalised version the game reads leniently, so + * it is only safe to use for building file names and identifiers; use + * {@link integerVersion} to compare it. */ version = "0.0.0" as GameVersion; /** - * Type safe accessor for this.version - */ - safeVersion = "0.0.0" as FullVersion; - - /** - * Integer representation of the version + * Integer representation of the version, normalised the lenient way the + * game reads it so it can be compared and sorted. */ get integerVersion() { - return integerFullVersion(this.safeVersion); + return integerGameVersion(this.version); } /** @@ -177,22 +174,17 @@ export default class ModInfo { description = ""; /** - * Major version of Factorio this mod supports. - * Sourced from info.json. + * Major version of Factorio this mod supports, reduced to major.minor. + * Sourced from info.json and normalised when read in fromJSON. */ - factorioVersion = "0.12" as GameVersion; - - /** - * Type safe accessor for this.factorioVersion - */ - safeFactorioVersion = "0.0" as MajorMinorVersion; + factorioVersion = "0.12" as MajorMinorVersion; /** * Integer representation of the factorioVersion * @type {number} */ get integerFactorioVersion() { - return integerMajorMinorVersion(this.safeFactorioVersion); + return integerMajorMinorVersion(this.factorioVersion); } /** @@ -291,12 +283,12 @@ export default class ModInfo { // info.json fields if (json.name) { modInfo.name = json.name; } if (json.version) { - const version = normaliseGameVersion(json.version); - if (version === undefined) { + // Validate that the raw version can be read, but keep it verbatim so + // it still matches the mod's file name on disk and the portal. + if (normaliseGameVersion(json.version) === undefined) { throw new Error(`Invalid mod version "${json.version}"`); } modInfo.version = json.version; - modInfo.safeVersion = version; } if (json.title) { modInfo.title = json.title; } if (json.author) { modInfo.author = json.author; } @@ -308,8 +300,7 @@ export default class ModInfo { if (factorioVersion === undefined) { throw new Error(`Invalid factorio_version "${json.factorio_version}"`); } - modInfo.factorioVersion = json.factorio_version; - modInfo.safeFactorioVersion = normaliseMajorMinorVersion(factorioVersion); + modInfo.factorioVersion = normaliseMajorMinorVersion(factorioVersion); } // Parse the dependencies diff --git a/packages/lib/src/data/ModPack.ts b/packages/lib/src/data/ModPack.ts index d8ddd4ae..56bcfaa8 100644 --- a/packages/lib/src/data/ModPack.ts +++ b/packages/lib/src/data/ModPack.ts @@ -12,7 +12,7 @@ import ModInfo, { ModDependencyUnsatisfiedReason } from "./ModInfo"; import { PartialVersion, PartialVersionSchema, integerPartialVersion, - FullVersion, FullVersionSchema, normaliseFullVersion, + GameVersion, GameVersionSchema, normaliseFullVersion, } from "./version"; @@ -46,8 +46,8 @@ export interface ModRecord { name: string, /** if mod is to be loaded. */ enabled: boolean, - /** version of the mod. */ - version: FullVersion, + /** version of the mod, verbatim as it appears in the mod's file name. */ + version: GameVersion, /** SHA1 hash of the zip file. */ sha1?: string, /** Used inside packages\web_ui\src\components\ModPackViewPage.tsx to define an error type. */ @@ -61,7 +61,7 @@ export interface ModRecord { const ModRecordJsonSchema = Type.Object({ "name": Type.String(), "enabled": Type.Boolean(), - "version": FullVersionSchema, + "version": GameVersionSchema, "sha1": Type.Optional(Type.String()), }); diff --git a/packages/lib/src/data/version.ts b/packages/lib/src/data/version.ts index 8fd4af4d..b063b0c1 100644 --- a/packages/lib/src/data/version.ts +++ b/packages/lib/src/data/version.ts @@ -37,8 +37,16 @@ export const ApiVersions = ["0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0 * not anchored at the end. */ const gameVersionRegExp = /^\s*(\d+)\.\s*(\d+)(?:\.\s*(\d+))?/; -declare const gameVersionSymbol: unique symbol; -export type GameVersion = string & { [gameVersionSymbol]: void }; +/** + * A raw, un-normalised version string exactly as the game reads it, such as a + * mod's version taken from info.json or a mod file name. It is deliberately a + * plain string alias so that any of the stricter, normalised version types + * ({@link FullVersion}, {@link MajorMinorVersion}, {@link PartialVersion}) widen + * into it for free, while a GameVersion can not be used where a normalised + * version is required without going through {@link normaliseGameVersion} or + * {@link integerGameVersion} first. + */ +export type GameVersion = string; export const GameVersionSchema = Type.Unsafe( Type.String({ pattern: gameVersionRegExp.source }) ); @@ -63,6 +71,21 @@ export function normaliseGameVersion(input: string): FullVersion | undefined { return `${Number(match[1])}.${Number(match[2])}.${Number(match[3] ?? "0")}` as FullVersion; } +/** + * Integer representation of a raw {@link GameVersion}, normalised the same + * lenient way the game reads it. Suitable for comparing versions that may carry + * leading zeros, whitespace or trailing content. + * + * @throws if the input can not be read as a version. + */ +export function integerGameVersion(version: GameVersion) { + const normalised = normaliseGameVersion(version); + if (normalised === undefined) { + throw new Error(`Invalid version "${version}"`); + } + return integerFullVersion(normalised); +} + /** * Matches valid mod versions, where all parts are specified. */ @@ -201,8 +224,8 @@ export class ModVersionEquality { } } - testVersion(version: PartialVersion) { - return this.testIntegerVersion(integerPartialVersion(version)); + testVersion(version: GameVersion) { + return this.testIntegerVersion(integerGameVersion(version)); } toString() { @@ -264,8 +287,8 @@ export class ModVersionRange { return this.minVersion.testIntegerVersion(other) && this.maxVersion.testIntegerVersion(other); } - testVersion(version: PartialVersion) { - return this.testIntegerVersion(integerPartialVersion(version)); + testVersion(version: GameVersion) { + return this.testIntegerVersion(integerGameVersion(version)); } combineVersion(other: ModVersionEquality) { diff --git a/packages/web_ui/src/components/ModPackViewPage.tsx b/packages/web_ui/src/components/ModPackViewPage.tsx index 94063c63..ac7822cd 100644 --- a/packages/web_ui/src/components/ModPackViewPage.tsx +++ b/packages/web_ui/src/components/ModPackViewPage.tsx @@ -403,7 +403,9 @@ function DownloadDependenciesButton(props: DownloadDependenciesProps) { control.send( new lib.ModPortalDownloadRequest( missing.map(mod => ({ - name: mod.name, version: new lib.ModVersionEquality("=", mod.version), + name: mod.name, + // mod.version is the raw version; the equality bound must be canonical. + version: new lib.ModVersionEquality("=", lib.normaliseGameVersion(mod.version)!), })), factorioVersion, ) diff --git a/packages/web_ui/src/components/ModsPage.tsx b/packages/web_ui/src/components/ModsPage.tsx index 0ad4f27c..3b13f7c9 100644 --- a/packages/web_ui/src/components/ModsPage.tsx +++ b/packages/web_ui/src/components/ModsPage.tsx @@ -170,7 +170,7 @@ function SearchModsButton() { if (modVersion) { control.send( new lib.ModPortalDownloadRequest( - [{ name: modName, version: new lib.ModVersionEquality("=", modVersion)}], + [{ name: modName, version: new lib.ModVersionEquality("=", lib.normaliseGameVersion(modVersion)!)}], portalFactorioVersion ) ).then(() => { diff --git a/test/lib/data/ModInfo.js b/test/lib/data/ModInfo.js index 1a31bb75..752cbaea 100644 --- a/test/lib/data/ModInfo.js +++ b/test/lib/data/ModInfo.js @@ -341,15 +341,27 @@ describe("lib/data/ModInfo", function() { dependencies: ["base", ""], }), /my-mod/); // check the mod name is included in the error message }); - it("should normalise lenient version fields the way the game reads them", function() { + it("should keep the raw mod version but normalise it for comparison", function() { const mod = ModInfo.fromJSON({ name: "my-mod", version: " 1.2.3-extra", factorio_version: "1.1.0", }); - assert.equal(mod.version, "1.2.3"); + // The version is kept verbatim so it still matches the mod's file name, + // but is read leniently the way the game does when compared. + assert.equal(mod.version, " 1.2.3-extra"); + assert.equal(mod.integerVersion, ModInfo.fromJSON({ version: "1.2.3" }).integerVersion); + // factorio_version is not part of any file name, so it is reduced to major.minor. assert.equal(mod.factorioVersion, "1.1"); }); + it("should keep leading zeros in the version so the file name matches", function() { + // Mirrors the mods.factorio.com/mod/leading-zeros case: the version has + // a zero-padded component that the file name keeps verbatim. + const mod = ModInfo.fromJSON({ name: "leading-zeros", version: "1.01.0" }); + assert.equal(mod.version, "1.01.0"); + assert.equal(mod.filename, "leading-zeros_1.01.0.zip"); + assert.equal(mod.integerVersion, ModInfo.fromJSON({ version: "1.1.0" }).integerVersion); + }); it("should throw for an unparseable mod version", function() { assert.throws( () => ModInfo.fromJSON({ name: "my-mod", version: "not-a-version" }),