From 2e27c4f49919ab327ded60a6926cdec1cf8f4f09 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:08:09 +0100 Subject: [PATCH] Maintain strict tagging of GameVersion --- packages/controller/src/Controller.ts | 8 ++--- packages/ctl/src/commands_mod.ts | 6 ++-- packages/ctl/src/commands_mod_pack.ts | 2 +- packages/lib/src/data/ModInfo.ts | 14 ++++---- packages/lib/src/data/ModPack.ts | 14 ++++---- packages/lib/src/data/version.ts | 34 +++++++------------ .../web_ui/src/components/ModPackViewPage.tsx | 2 +- packages/web_ui/src/components/ModsPage.tsx | 2 +- 8 files changed, 37 insertions(+), 45 deletions(-) diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index a081737a..3b72dcca 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -763,12 +763,10 @@ export default class Controller { const serialized = rawJson as Static[]; return serialized.map(json => { for (const mod of json.mods) { - // 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. + // 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); + mod.version = lib.normaliseFullVersion(mod.version) as lib.GameVersion; } } return json; diff --git a/packages/ctl/src/commands_mod.ts b/packages/ctl/src/commands_mod.ts index 343c6953..169b8fb5 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.isGameVersion(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)); @@ -191,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.isGameVersion(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)); @@ -211,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.isGameVersion(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..6d3fdfa1 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.isGameVersion(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/data/ModInfo.ts b/packages/lib/src/data/ModInfo.ts index 8a4d1885..f5ca2fa3 100644 --- a/packages/lib/src/data/ModInfo.ts +++ b/packages/lib/src/data/ModInfo.ts @@ -8,8 +8,8 @@ import { findRoot } from "../zip_ops"; import { ModRecord } from "./ModPack"; import { - MajorMinorVersion, normaliseMajorMinorVersion, integerMajorMinorVersion, - GameVersion, GameVersionSchema, normaliseGameVersion, integerGameVersion, + MajorMinorVersion, MajorMinorVersionSchema, normaliseMajorMinorVersion, integerMajorMinorVersion, + GameVersion, GameVersionSchema, isGameVersion, normaliseGameVersion, integerGameVersion, ModVersionEquality, } from "./version"; @@ -143,6 +143,10 @@ export default class ModInfo { return integerGameVersion(this.version); } + get normalisedVersion() { + return normaliseGameVersion(this.version); + } + /** * Display name of the mod. * Sourced from info.json. @@ -283,9 +287,7 @@ export default class ModInfo { // info.json fields if (json.name) { modInfo.name = json.name; } if (json.version) { - // 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) { + if (!isGameVersion(json.version)) { throw new Error(`Invalid mod version "${json.version}"`); } modInfo.version = json.version; @@ -330,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 GameVersion; } 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 56bcfaa8..6e77644f 100644 --- a/packages/lib/src/data/ModPack.ts +++ b/packages/lib/src/data/ModPack.ts @@ -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 GameVersion; + 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/version.ts b/packages/lib/src/data/version.ts index b063b0c1..0b17b3ba 100644 --- a/packages/lib/src/data/version.ts +++ b/packages/lib/src/data/version.ts @@ -37,16 +37,13 @@ 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+))?/; + /** * 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. + * mod's version taken from info.json or a mod file name. */ -export type GameVersion = string; +declare const gameVersionSymbol: unique symbol; +export type GameVersion = string & { [gameVersionSymbol]: void }; export const GameVersionSchema = Type.Unsafe( Type.String({ pattern: gameVersionRegExp.source }) ); @@ -63,7 +60,10 @@ export function isGameVersion(input: string): input is GameVersion { * @returns The normalised full version, or undefined if no major and minor * could be read. */ -export function normaliseGameVersion(input: string): FullVersion | undefined { +export function normaliseGameVersion(input: GameVersion): FullVersion; +export function normaliseGameVersion(input: string): FullVersion | undefined; + +export function normaliseGameVersion(input: string | GameVersion): FullVersion | undefined { const match = gameVersionRegExp.exec(input); if (match === null) { return undefined; @@ -71,19 +71,8 @@ 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); + return integerFullVersion(normaliseGameVersion(version)); } /** @@ -287,7 +276,10 @@ export class ModVersionRange { return this.minVersion.testIntegerVersion(other) && this.maxVersion.testIntegerVersion(other); } - testVersion(version: GameVersion) { + testVersion(version: PartialVersion | GameVersion) { + if (isPartialVersion(version)) { + return this.testIntegerVersion(integerPartialVersion(version)); + } return this.testIntegerVersion(integerGameVersion(version)); } diff --git a/packages/web_ui/src/components/ModPackViewPage.tsx b/packages/web_ui/src/components/ModPackViewPage.tsx index ac7822cd..937450e2 100644 --- a/packages/web_ui/src/components/ModPackViewPage.tsx +++ b/packages/web_ui/src/components/ModPackViewPage.tsx @@ -405,7 +405,7 @@ function DownloadDependenciesButton(props: DownloadDependenciesProps) { missing.map(mod => ({ name: mod.name, // mod.version is the raw version; the equality bound must be canonical. - version: new lib.ModVersionEquality("=", lib.normaliseGameVersion(mod.version)!), + version: new lib.ModVersionEquality("=", mod.normalisedVersion), })), factorioVersion, ) diff --git a/packages/web_ui/src/components/ModsPage.tsx b/packages/web_ui/src/components/ModsPage.tsx index 3b13f7c9..7d2e8e92 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("=", lib.normaliseGameVersion(modVersion)!)}], + [{ name: modName, version: new lib.ModVersionEquality("=", lib.normaliseGameVersion(modVersion))}], portalFactorioVersion ) ).then(() => {