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"];