// Helper functions for dealing with factorio version numbers import { Static, Type } from "@sinclair/typebox"; /** * Integer representation of a version string. * * Suitable for sorting and comparing version numbers which may have different * number of leading zeros in them. */ export type IntegerVersion = number & { [integerVersionSymbol]: void }; declare const integerVersionSymbol: unique symbol; /** * Allowed version equalities as defined within mod info json * https://lua-api.factorio.com/latest/auxiliary/mod-structure.html#Dependency */ const versionEqualities = ["<", "<=", "=", ">=", ">"] as const; export const VersionEqualitySchema = Type.Union(versionEqualities.map(v => Type.Literal(v))); export type VersionEquality = Static; export function isVersionEquality(input: string): input is VersionEquality { return versionEqualities.includes(input as any); } /** * 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", "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 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 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 `${Number(match[1])}.${Number(match[2])}.${Number(match[3] ?? "0")}` as FullVersion; } /** * Matches valid mod versions, where all parts are specified. */ const fullVersionRegExp = /^\d+\.\d+\.\d+$/; export type FullVersion = `${number}.${number}.${number}`; export const FullVersionSchema = Type.Unsafe( Type.String({ pattern: fullVersionRegExp.source }) ); export function isFullVersion(input: string): input is FullVersion { return fullVersionRegExp.test(input); } export function integerFullVersion(version: FullVersion) { const [major, minor, sub] = version.split(".").map(n => Number.parseInt(n, 10)); // Can't use bitwise here because this is 48-bits. return major * 0x100000000 + minor * 0x10000 + sub as IntegerVersion; } export function normaliseFullVersion(version: PartialVersion) { const parts = version.split("."); 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 | MajorMinorVersion; export const PartialVersionSchema = Type.Unsafe( Type.String({ pattern: partialVersionRegExp.source }) ); export function isPartialVersion(input: string): input is PartialVersion { return partialVersionRegExp.test(input); } export function integerPartialVersion(version: PartialVersion) { const [major, minor, sub] = version.split(".").map(n => Number.parseInt(n, 10)); // Can't use bitwise here because this is 48-bits. sub is optional and defaults to 0. return major * 0x100000000 + minor * 0x10000 + (sub || 0) as IntegerVersion; } /** * Matches a release channel name from the latest-releases API, such as * "stable" or "experimental". Excludes "latest", which is handled separately. * The set of channels is dynamic, so any lowercase identifier is accepted here * 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): input is ReleaseChannel { return input !== "latest" && releaseChannelRegExp.test(input); } /** * Matches valid factorio target versions: a partial version, "latest", or a * release channel name (e.g. "stable" or "experimental"). */ export type TargetVersion = PartialVersion | ReleaseChannel | "latest"; export const TargetVersionSchema = Type.Union([ PartialVersionSchema, ReleaseChannelSchema, Type.Literal("latest"), ]); export function isTargetVersion(input: string): input is TargetVersion { return input === "latest" || releaseChannelRegExp.test(input) || partialVersionRegExp.test(input); } /** * Represents a mod version paired with an equality which can be tested against */ export class ModVersionEquality { public integerVersion: IntegerVersion; constructor( public equality: VersionEquality, public version: PartialVersion, ) { this.integerVersion = integerPartialVersion(version); if (!isVersionEquality(this.equality)) { throw new Error("Invalid equality"); } if (!isPartialVersion(this.version)) { throw new Error("Invalid version"); } } testIntegerVersion(other: IntegerVersion) { switch (this.equality) { case "<": return other < this.integerVersion; case "<=": return other <= this.integerVersion; case "=": return other === this.integerVersion; case ">=": return other >= this.integerVersion; case ">": return other > this.integerVersion; default: throw new Error("unreachable"); } } testVersion(version: SourceVersion) { return this.testIntegerVersion(integerSourceVersion(version)); } toString() { return `${this.equality} ${this.version}`; } static fromString(version: string) { const parts = version.split(" "); return parts.length === 1 ? this.fromParts("=", parts[0]) : this.fromParts(parts[0], parts[1]); } static fromParts(equality: string, version: string) { if (!isVersionEquality(equality)) { throw new Error(`Invalid version equality "${equality}"`); } if (!isPartialVersion(version)) { throw new Error(`Invalid version string "${version}"`); } return new this(equality, version); } static jsonSchema = Type.String(); static fromJSON(json: Static) { return this.fromString(json); } toJSON() { return this.toString(); } } /** * Represents a range of mod versions which can be tested against */ export class ModVersionRange { constructor( public minVersion = new ModVersionEquality(">=", "0.0.0"), public maxVersion = new ModVersionEquality("<=", "65535.65535.65535"), ) { if (minVersion.equality === "<" || minVersion.equality === "<=") { throw new Error("Minimum version can not use < or <="); } if (maxVersion.equality === ">" || maxVersion.equality === ">=") { throw new Error("Maximum version can not use > or >="); } } get valid() { return this.minVersion.testIntegerVersion(this.maxVersion.integerVersion) && this.maxVersion.testIntegerVersion(this.minVersion.integerVersion); } invalidate() { this.maxVersion = new ModVersionEquality(">", "0.0.0"); } testIntegerVersion(other: IntegerVersion) { return this.minVersion.testIntegerVersion(other) && this.maxVersion.testIntegerVersion(other); } testVersion(version: PartialVersion | SourceVersion) { if (isPartialVersion(version)) { return this.testIntegerVersion(integerPartialVersion(version)); } return this.testIntegerVersion(integerSourceVersion(version)); } combineVersion(other: ModVersionEquality) { switch (other.equality) { case "<": if (other.integerVersion <= this.maxVersion.integerVersion) { this.maxVersion = other; } break; case "<=": if (other.integerVersion < this.maxVersion.integerVersion) { this.maxVersion = other; } break; case "=": if (this.testIntegerVersion(other.integerVersion)) { this.minVersion = other; this.maxVersion = other; } else { // Make invalid as it could not be satisfied this.invalidate(); } break; case ">=": if (other.integerVersion > this.minVersion.integerVersion) { this.minVersion = other; } break; case ">": if (other.integerVersion >= this.minVersion.integerVersion) { this.minVersion = other; } break; default: throw new Error("unreachable"); } } }