mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-12 07:04:52 +09:00
322 lines
10 KiB
TypeScript
322 lines
10 KiB
TypeScript
// 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<typeof VersionEqualitySchema>;
|
|
|
|
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<SourceVersion>(
|
|
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<FullVersion>(
|
|
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<MajorMinorVersion>(
|
|
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<PartialVersion>(
|
|
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<ReleaseChannel>(
|
|
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<typeof this.jsonSchema>) {
|
|
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");
|
|
}
|
|
}
|
|
}
|