From 523293d4d924f3b894f41ad8e1c7d51e2d58305e Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:39:08 +0100 Subject: [PATCH] Manual adjustments to GameVersion and ReleaseChannel types Through the creation of this commit, it was discovered that ModInfo.version must be typed as GameVersion due to file names. This results in wide spread propagation of the unsafe type. The full impact including on ModRecord and ModVersion.testVersion have no been implemented until a path forward is decided. --- packages/controller/src/BaseConnection.ts | 2 +- packages/lib/src/ModStore.ts | 14 +-- packages/lib/src/data/ModInfo.ts | 32 ++++-- packages/lib/src/data/messages_mod.ts | 39 ++++--- packages/lib/src/data/version.ts | 102 +++++++++++------- .../web_ui/src/components/InstanceList.tsx | 7 +- .../web_ui/src/components/ModPackViewPage.tsx | 5 +- packages/web_ui/src/components/ModsPage.tsx | 8 +- test/lib/data/version.test.js | 8 ++ 9 files changed, 129 insertions(+), 88 deletions(-) diff --git a/packages/controller/src/BaseConnection.ts b/packages/controller/src/BaseConnection.ts index ba6ba39c..19cc47a6 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.GameVersion, 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/lib/src/ModStore.ts b/packages/lib/src/ModStore.ts index 023fc528..3192ec55 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, PartialVersion, normaliseMajorMinorVersion, ModVersionEquality } from "./data"; +import { ModInfo, ModPack, MajorMinorVersion, ModVersionEquality, GameVersion } 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: GameVersion, 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: GameVersion) { return await this.loadFile(ModInfo.filename(name, version)); } - async deleteMod(name: string, version: FullVersion) { + async deleteMod(name: string, version: GameVersion) { return await this.deleteFile(ModInfo.filename(name, version)); } @@ -198,11 +198,11 @@ export default class ModStore extends events.EventEmitter { private async downloadModsChunk( mods: ModNameVersionPair[], username: string, token: string, - factorioVersion: PartialVersion, + factorioVersion: MajorMinorVersion, ) { const url = new URL("https://mods.factorio.com/api/mods"); url.searchParams.set("page_size", "max"); - url.searchParams.set("version", normaliseMajorMinorVersion(factorioVersion)); + url.searchParams.set("version", factorioVersion); const response = await fetch(url, { method: "POST", body: new URLSearchParams({ namelist: mods.map(m => m.name).join(",") }), @@ -247,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: PartialVersion = "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 458459c7..f797cee2 100644 --- a/packages/lib/src/data/ModInfo.ts +++ b/packages/lib/src/data/ModInfo.ts @@ -9,12 +9,12 @@ import { ModRecord } from "./ModPack"; import { FullVersion, integerFullVersion, - integerPartialVersion, - GameVersionSchema, normaliseGameVersion, normaliseMajorMinorVersion, - MajorMinorVersion, + MajorMinorVersion, normaliseMajorMinorVersion, integerMajorMinorVersion, + GameVersion, GameVersionSchema, normaliseGameVersion, ModVersionEquality, } from "./version"; + type ModDependencyType = "incompatible" | "optional" | "hidden" | "unordered" | "required"; export type ModDependencyUnsatisfiedReason = "incompatible" | "missing_dependency" | "wrong_version"; @@ -132,13 +132,18 @@ export default class ModInfo { * Version of the mod. * Sourced from info.json. */ - version = "0.0.0" as FullVersion; + version = "0.0.0" as GameVersion; + + /** + * Type safe accessor for this.version + */ + safeVersion = "0.0.0" as FullVersion; /** * Integer representation of the version */ get integerVersion() { - return integerFullVersion(this.version); + return integerFullVersion(this.safeVersion); } /** @@ -175,14 +180,19 @@ export default class ModInfo { * Major version of Factorio this mod supports. * Sourced from info.json. */ - factorioVersion = "0.12" as MajorMinorVersion; + factorioVersion = "0.12" as GameVersion; + + /** + * Type safe accessor for this.factorioVersion + */ + safeFactorioVersion = "0.0" as MajorMinorVersion; /** * Integer representation of the factorioVersion * @type {number} */ get integerFactorioVersion() { - return integerPartialVersion(this.factorioVersion); + return integerMajorMinorVersion(this.safeFactorioVersion); } /** @@ -221,7 +231,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: GameVersion) { return `${name}_${version}.zip`; } @@ -285,7 +295,8 @@ export default class ModInfo { if (version === undefined) { throw new Error(`Invalid mod version "${json.version}"`); } - modInfo.version = version; + modInfo.version = json.version; + modInfo.safeVersion = version; } if (json.title) { modInfo.title = json.title; } if (json.author) { modInfo.author = json.author; } @@ -297,7 +308,8 @@ export default class ModInfo { if (factorioVersion === undefined) { throw new Error(`Invalid factorio_version "${json.factorio_version}"`); } - modInfo.factorioVersion = normaliseMajorMinorVersion(factorioVersion); + modInfo.factorioVersion = json.factorio_version; + modInfo.safeFactorioVersion = normaliseMajorMinorVersion(factorioVersion); } // Parse the dependencies diff --git a/packages/lib/src/data/messages_mod.ts b/packages/lib/src/data/messages_mod.ts index 4ec61adf..17def204 100644 --- a/packages/lib/src/data/messages_mod.ts +++ b/packages/lib/src/data/messages_mod.ts @@ -4,9 +4,8 @@ import ModPack from "./ModPack"; import { JsonString, jsonArray } from "./composites"; import { - FullVersion, FullVersionSchema, - PartialVersion, PartialVersionSchema, - GameVersionSchema, + MajorMinorVersion, MajorMinorVersionSchema, normaliseMajorMinorVersion, + GameVersion, GameVersionSchema, ModVersionEquality, } from "./version"; @@ -120,13 +119,13 @@ export class ModGetRequest { constructor( public name: string, - public version: FullVersion, + public version: GameVersion, public sha1?: string, ) { } static jsonSchema = Type.Object({ "name": Type.String(), - "version": FullVersionSchema, + "version": GameVersionSchema, "sha1": Type.Optional(Type.String()), }); @@ -155,7 +154,7 @@ export class ModSearchRequest { constructor( public query: string, - public factorioVersion: PartialVersion, + public factorioVersion: MajorMinorVersion, public page: number, public pageSize?: number, public sort?: string, @@ -164,7 +163,7 @@ export class ModSearchRequest { static jsonSchema = Type.Object({ "query": Type.String(), - "factorioVersion": PartialVersionSchema, + "factorioVersion": MajorMinorVersionSchema, "page": Type.Integer(), "pageSize": Type.Optional(Type.Integer()), "sort": Type.Optional(Type.String()), @@ -206,7 +205,7 @@ export class ModSearchRequest { // Define the structure for the latest release info from the portal export const ModPortalReleaseSchema = Type.Object({ - version: FullVersionSchema, + version: GameVersionSchema, // Match the structure from ModStore's ModRelease/ModDetails info_json: Type.Object({ factorio_version: GameVersionSchema }), released_at: Type.String(), // ISO 8601 date string @@ -237,12 +236,12 @@ export class ModPortalGetAllRequest { static permission = "core.mod.search_portal" as const; constructor( - public factorioVersion: PartialVersion, + public factorioVersion: MajorMinorVersion, public hide_deprecated?: boolean, ) { } static jsonSchema = Type.Object({ - "factorioVersion": PartialVersionSchema, + "factorioVersion": MajorMinorVersionSchema, "hide_deprecated": Type.Optional(Type.Boolean()), }); @@ -277,13 +276,13 @@ export class ModDownloadRequest { constructor( public name: string, - public version: FullVersion, + public version: GameVersion, public sha1?: string, ) { } static jsonSchema = Type.Object({ "name": Type.String(), - "version": FullVersionSchema, + "version": GameVersionSchema, "sha1": Type.Optional(Type.String()), }); @@ -303,12 +302,12 @@ export class ModDeleteRequest { constructor( public name: string, - public version: FullVersion, + public version: GameVersion, ) { } static jsonSchema = Type.Object({ "name": Type.String(), - "version": FullVersionSchema, + "version": GameVersionSchema, }); static fromJSON(json: Static) { @@ -386,12 +385,12 @@ export class ModPortalDownloadRequest { constructor( public mods: ModNameVersionPair[], - public factorioVersion: PartialVersion, + public factorioVersion: MajorMinorVersion, ) { } static jsonSchema = Type.Object({ "mods": Type.Array(ModNameVersionPairSchema), - "factorioVersion": PartialVersionSchema, + "factorioVersion": MajorMinorVersionSchema, }); static fromJSON(json: Static) { @@ -423,13 +422,13 @@ export class ModDependencyResolveRequest { constructor( public mods: ModDependency[], - public factorioVersion: PartialVersion, + public factorioVersion: MajorMinorVersion, public checkForUpdates: boolean = false, ) { } static jsonSchema = Type.Object({ "mods": Type.Array(ModDependency.jsonSchema), - "factorioVersion": PartialVersionSchema, + "factorioVersion": MajorMinorVersionSchema, "checkForUpdates": Type.Boolean(), }); @@ -442,7 +441,7 @@ export class ModDependencyResolveRequest { return new this( [...modPack.mods.values()] .map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)), - modPack.factorioVersion, + normaliseMajorMinorVersion(modPack.factorioVersion), checkForUpdates, ); } @@ -453,7 +452,7 @@ export class ModDependencyResolveRequest { [...modPack.mods.values()] .filter(mod => mod.enabled) .map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)), - modPack.factorioVersion, + normaliseMajorMinorVersion(modPack.factorioVersion), checkForUpdates, ); } diff --git a/packages/lib/src/data/version.ts b/packages/lib/src/data/version.ts index cdd575d9..8fd4af4d 100644 --- a/packages/lib/src/data/version.ts +++ b/packages/lib/src/data/version.ts @@ -30,6 +30,39 @@ export function isVersionEquality(input: string): input is VersionEquality { */ export const ApiVersions = ["0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0", "2.1"] as const; +/** + * Matches a version string the same lenient way the game reads it: each number + * may be preceded by whitespace, the major and minor are required, the patch is + * optional, and any trailing content is ignored. The pattern is intentionally + * not anchored at the end. + */ +const gameVersionRegExp = /^\s*(\d+)\.\s*(\d+)(?:\.\s*(\d+))?/; +declare const gameVersionSymbol: unique symbol; +export type GameVersion = string & { [gameVersionSymbol]: void }; +export const GameVersionSchema = Type.Unsafe( + Type.String({ pattern: gameVersionRegExp.source }) +); + +export function isGameVersion(input: string): input is GameVersion { + return gameVersionRegExp.test(input); +} + +/** + * Normalise a version string the same lenient way the game reads it. Numbers + * are read as integers, so leading whitespace and zeros are dropped and any + * content after the patch is ignored. + * + * @returns The normalised full version, or undefined if no major and minor + * could be read. + */ +export function normaliseGameVersion(input: string): FullVersion | undefined { + const match = gameVersionRegExp.exec(input); + if (match === null) { + return undefined; + } + return `${Number(match[1])}.${Number(match[2])}.${Number(match[3] ?? "0")}` as FullVersion; +} + /** * Matches valid mod versions, where all parts are specified. */ @@ -55,28 +88,30 @@ export function normaliseFullVersion(version: PartialVersion) { } /** - * 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. + * Matches a major.minor version, as used by the mod portal's version filter. */ -const gameVersionRegExp = /^\s*(\d+)\.\s*(\d+)(?:\.\s*(\d+))?/; -export const GameVersionSchema = Type.String({ pattern: gameVersionRegExp.source }); +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; +} /** - * 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. + * Reduce a version to its major.minor, as used by the mod portal's version filter. */ -export function normaliseGameVersion(input: string): FullVersion | undefined { - const match = gameVersionRegExp.exec(input); - if (match === null) { - return undefined; - } - return `${Number(match[1])}.${Number(match[2])}.${Number(match[3] ?? "0")}` as FullVersion; +export function normaliseMajorMinorVersion(version: PartialVersion): MajorMinorVersion { + const [major, minor] = version.split("."); + return `${major}.${minor}` as MajorMinorVersion; } /** @@ -98,24 +133,6 @@ export function integerPartialVersion(version: PartialVersion) { return major * 0x100000000 + minor * 0x10000 + (sub || 0) as IntegerVersion; } -/** - * Matches a major.minor version, as used by the mod portal's version filter. - */ -const majorMinorVersionRegExp = /^\d+\.\d+$/; -export type MajorMinorVersion = `${number}.${number}`; - -export function isMajorMinorVersion(input: string): input is MajorMinorVersion { - return majorMinorVersionRegExp.test(input); -} - -/** - * 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 a release channel name from the latest-releases API, such as * "stable" or "experimental". Excludes "latest", which is handled separately. @@ -123,8 +140,13 @@ export function normaliseMajorMinorVersion(version: PartialVersion): MajorMinorV * 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); } @@ -132,15 +154,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); } /** 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 598447ff..94063c63 100644 --- a/packages/web_ui/src/components/ModPackViewPage.tsx +++ b/packages/web_ui/src/components/ModPackViewPage.tsx @@ -77,7 +77,7 @@ 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(() => { @@ -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 @@ -578,7 +578,6 @@ function ModsTable(props: ModsTableProps) { mod.warning = mod.info.checkDependencySatisfaction(mods.filter(m => m.enabled)); try { const packFactorioVersion = lib.normaliseMajorMinorVersion(props.modPack.factorioVersion); - // mod.info.factorioVersion is already a MajorMinorVersion, no need to re-reduce it. 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 222595a4..0ad4f27c 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,8 +164,8 @@ function SearchModsButton() { const handleControllerDownload = ( modName: string, modTitle: string | undefined, - modVersion: lib.FullVersion | undefined, - portalFactorioVersion: lib.PartialVersion, + modVersion: lib.GameVersion | undefined, + portalFactorioVersion: lib.MajorMinorVersion, ) => { if (modVersion) { control.send( @@ -271,7 +271,7 @@ function SearchModsButton() { // Update search text and factorio version from form const handleSearch = (changedValues: any, allValues: any) => { const nameValue = allValues.name; - const versionValue = allValues.factorioVersion as lib.PartialVersion | 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/data/version.test.js b/test/lib/data/version.test.js index 6fc05005..433fb8f3 100644 --- a/test/lib/data/version.test.js +++ b/test/lib/data/version.test.js @@ -58,6 +58,14 @@ describe("lib/data/version", function() { } }); }); + 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() { it("should correctly validate input strings", function() { const valid = ["0.12.0", "0.13.1", "0.17.99", "00.000.0001", "1.1.110"];