mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-14 15:24:52 +09:00
Merge pull request #941 from bbassie/feature/lenient-mod-version-parsing
Parse mod versions leniently like the game does
This commit is contained in:
16 files changed
+287
-129
No files matched your search
@@ -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.SourceVersion, sha1?: string }) {
|
||||
let filename = lib.ModInfo.filename(mod.name, mod.version);
|
||||
let modInfo = this._controller.modStore.files.get(filename);
|
||||
if (!modInfo) {
|
||||
|
||||
@@ -707,7 +707,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
const release = releases.releases
|
||||
.filter(info => versionRange.testVersion(info.version))
|
||||
.reduce<ModPortalReleaseType | undefined>((max, cur) => (
|
||||
max && lib.integerFullVersion(max.version) > lib.integerFullVersion(cur.version) ? max : cur
|
||||
max && lib.integerSourceVersion(max.version) > lib.integerSourceVersion(cur.version) ? max : cur
|
||||
), undefined);
|
||||
|
||||
if (!release) {
|
||||
|
||||
@@ -763,8 +763,11 @@ export default class Controller {
|
||||
const serialized = rawJson as Static<typeof lib.ModPack.jsonSchema>[];
|
||||
return serialized.map(json => {
|
||||
for (const mod of json.mods) {
|
||||
// migrate: 2.0.0.alpha.22 - json schema now enforces X.Y.Z for mod version, builtins would use X.Y only
|
||||
mod.version = lib.normaliseFullVersion(mod.version);
|
||||
// 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) as lib.SourceVersion;
|
||||
}
|
||||
}
|
||||
return json;
|
||||
});
|
||||
|
||||
@@ -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.isSourceVersion(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));
|
||||
@@ -108,9 +108,13 @@ modCommands.add(new lib.Command({
|
||||
},
|
||||
control: Control
|
||||
) {
|
||||
const searchVersion = lib.normaliseSourceVersion(String(args.factorioVersion));
|
||||
if (searchVersion === undefined) {
|
||||
throw new Error(`Invalid factorio version "${args.factorioVersion}"`);
|
||||
}
|
||||
let response = await control.send(new lib.ModSearchRequest(
|
||||
args.query,
|
||||
lib.normaliseApiVersion(args.factorioVersion as any),
|
||||
lib.normaliseMajorMinorVersion(searchVersion),
|
||||
args.page,
|
||||
args.pageSize,
|
||||
args.sort,
|
||||
@@ -187,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.isSourceVersion(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));
|
||||
@@ -207,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.isSourceVersion(args.modVersion)) {
|
||||
throw new lib.CommandError("mod-version must match format digit.digit.digit");
|
||||
}
|
||||
await control.send(new lib.ModDeleteRequest(args.name, args.modVersion));
|
||||
|
||||
@@ -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.isSourceVersion(version)) {
|
||||
throw new lib.CommandError("version must match the format digit.digit.digit");
|
||||
}
|
||||
if (sha1 && !/^[0-9a-f]{40}$/.test(sha1)) {
|
||||
|
||||
@@ -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, ApiVersion, ModVersionEquality } from "./data";
|
||||
import { ModInfo, ModPack, MajorMinorVersion, ModVersionEquality, SourceVersion } from "./data";
|
||||
|
||||
export interface ModStoreEvents {
|
||||
/** A stored mod was created, updated or deleted */
|
||||
@@ -44,7 +44,7 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
|
||||
super();
|
||||
}
|
||||
|
||||
getMod(name: string, version: FullVersion, sha1?: string) {
|
||||
getMod(name: string, version: SourceVersion, 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<ModStoreEvents> {
|
||||
this.emit("change", modInfo);
|
||||
}
|
||||
|
||||
async loadMod(name: string, version: FullVersion) {
|
||||
async loadMod(name: string, version: SourceVersion) {
|
||||
return await this.loadFile(ModInfo.filename(name, version));
|
||||
}
|
||||
|
||||
async deleteMod(name: string, version: FullVersion) {
|
||||
async deleteMod(name: string, version: SourceVersion) {
|
||||
return await this.deleteFile(ModInfo.filename(name, version));
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
|
||||
private async downloadModsChunk(
|
||||
mods: ModNameVersionPair[],
|
||||
username: string, token: string,
|
||||
factorioVersion: ApiVersion,
|
||||
factorioVersion: MajorMinorVersion,
|
||||
) {
|
||||
const url = new URL("https://mods.factorio.com/api/mods");
|
||||
url.searchParams.set("page_size", "max");
|
||||
@@ -209,7 +209,18 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw Error(`Fetch: ${url} returned ${response.status} ${response.statusText}`);
|
||||
// Surface the portal's own error (e.g. an unsupported version) rather
|
||||
// than a generic status line.
|
||||
let detail = `${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const body = await response.json() as { message?: string };
|
||||
if (body && typeof body.message === "string") {
|
||||
detail = body.message;
|
||||
}
|
||||
} catch {
|
||||
// Response body was not JSON; keep the status line.
|
||||
}
|
||||
throw new Error(`Mod portal request to ${url} failed: ${detail}`);
|
||||
}
|
||||
|
||||
const modReleases = (await response.json() as ModsInfoResponse).results;
|
||||
@@ -236,7 +247,7 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
|
||||
* @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: ApiVersion = "1.1"
|
||||
mods: ModNameVersionPair[], username: string, token: string, factorioVersion: MajorMinorVersion = "1.1"
|
||||
) {
|
||||
const chunkSize = 500;
|
||||
const futures: Promise<void>[] = [];
|
||||
|
||||
@@ -8,13 +8,12 @@ import { findRoot } from "../zip_ops";
|
||||
import { ModRecord } from "./ModPack";
|
||||
|
||||
import {
|
||||
ApiVersion,
|
||||
ApiVersionSchema,
|
||||
FullVersion, FullVersionSchema, integerFullVersion,
|
||||
integerPartialVersion,
|
||||
MajorMinorVersion, MajorMinorVersionSchema, normaliseMajorMinorVersion, integerMajorMinorVersion,
|
||||
SourceVersion, SourceVersionSchema, isSourceVersion, normaliseSourceVersion, integerSourceVersion,
|
||||
ModVersionEquality,
|
||||
} from "./version";
|
||||
|
||||
|
||||
type ModDependencyType = "incompatible" | "optional" | "hidden" | "unordered" | "required";
|
||||
|
||||
export type ModDependencyUnsatisfiedReason = "incompatible" | "missing_dependency" | "wrong_version";
|
||||
@@ -129,16 +128,23 @@ export default class ModInfo {
|
||||
name = "";
|
||||
|
||||
/**
|
||||
* Version of the mod.
|
||||
* Sourced from info.json.
|
||||
* Version of the mod, exactly as it appears in info.json and the mod's file
|
||||
* name. This is the raw, un-normalised version the game reads leniently, so
|
||||
* it is only safe to use for building file names and identifiers; use
|
||||
* {@link integerVersion} to compare it.
|
||||
*/
|
||||
version = "0.0.0" as FullVersion;
|
||||
version = "0.0.0" as SourceVersion;
|
||||
|
||||
/**
|
||||
* Integer representation of the version
|
||||
* Integer representation of the version, normalised the lenient way the
|
||||
* game reads it so it can be compared and sorted.
|
||||
*/
|
||||
get integerVersion() {
|
||||
return integerFullVersion(this.version);
|
||||
return integerSourceVersion(this.version);
|
||||
}
|
||||
|
||||
get normalisedVersion() {
|
||||
return normaliseSourceVersion(this.version);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,17 +178,17 @@ export default class ModInfo {
|
||||
description = "";
|
||||
|
||||
/**
|
||||
* Major version of Factorio this mod supports.
|
||||
* Sourced from info.json.
|
||||
* Major version of Factorio this mod supports, reduced to major.minor.
|
||||
* Sourced from info.json and normalised when read in fromJSON.
|
||||
*/
|
||||
factorioVersion = "0.12" as "0.12" | ApiVersion;
|
||||
factorioVersion = "0.12" as MajorMinorVersion;
|
||||
|
||||
/**
|
||||
* Integer representation of the factorioVersion
|
||||
* @type {number}
|
||||
*/
|
||||
get integerFactorioVersion() {
|
||||
return integerPartialVersion(this.factorioVersion);
|
||||
return integerMajorMinorVersion(this.factorioVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -221,7 +227,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: SourceVersion) {
|
||||
return `${name}_${version}.zip`;
|
||||
}
|
||||
|
||||
@@ -248,16 +254,17 @@ export default class ModInfo {
|
||||
*/
|
||||
isDeleted = false;
|
||||
|
||||
// Content of info.json found in mod files
|
||||
// Content of info.json found in mod files. The version fields use the same
|
||||
// lenient format the game accepts; they are normalised when read in fromJSON.
|
||||
static infoJsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"version": FullVersionSchema,
|
||||
"version": SourceVersionSchema,
|
||||
"title": Type.String(),
|
||||
"author": Type.String(),
|
||||
"contact": Type.Optional(Type.String()),
|
||||
"homepage": Type.Optional(Type.String()),
|
||||
"description": Type.Optional(Type.String()),
|
||||
"factorio_version": Type.Optional(ApiVersionSchema),
|
||||
"factorio_version": Type.Optional(SourceVersionSchema),
|
||||
"dependencies": Type.Optional(Type.Array(Type.String())),
|
||||
});
|
||||
|
||||
@@ -279,13 +286,24 @@ export default class ModInfo {
|
||||
|
||||
// info.json fields
|
||||
if (json.name) { modInfo.name = json.name; }
|
||||
if (json.version) { modInfo.version = json.version; }
|
||||
if (json.version) {
|
||||
if (!isSourceVersion(json.version)) {
|
||||
throw new Error(`Invalid mod version "${json.version}"`);
|
||||
}
|
||||
modInfo.version = json.version;
|
||||
}
|
||||
if (json.title) { modInfo.title = json.title; }
|
||||
if (json.author) { modInfo.author = json.author; }
|
||||
if (json.contact) { modInfo.contact = json.contact; }
|
||||
if (json.homepage) { modInfo.homepage = json.homepage; }
|
||||
if (json.description) { modInfo.description = json.description; }
|
||||
if (json.factorio_version) { modInfo.factorioVersion = json.factorio_version; }
|
||||
if (json.factorio_version) {
|
||||
const factorioVersion = normaliseSourceVersion(json.factorio_version);
|
||||
if (factorioVersion === undefined) {
|
||||
throw new Error(`Invalid factorio_version "${json.factorio_version}"`);
|
||||
}
|
||||
modInfo.factorioVersion = normaliseMajorMinorVersion(factorioVersion);
|
||||
}
|
||||
|
||||
// Parse the dependencies
|
||||
try {
|
||||
@@ -314,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 SourceVersion; }
|
||||
if (this.dependencies.length !== 1 || this.dependencies[0].name !== "base") {
|
||||
json.dependencies = this.dependencySpecifications;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import ModInfo, { ModDependencyUnsatisfiedReason } from "./ModInfo";
|
||||
|
||||
import {
|
||||
PartialVersion, PartialVersionSchema, integerPartialVersion,
|
||||
FullVersion, FullVersionSchema, normaliseFullVersion,
|
||||
SourceVersion, SourceVersionSchema, normaliseFullVersion,
|
||||
} from "./version";
|
||||
|
||||
|
||||
@@ -46,8 +46,8 @@ export interface ModRecord {
|
||||
name: string,
|
||||
/** if mod is to be loaded. */
|
||||
enabled: boolean,
|
||||
/** version of the mod. */
|
||||
version: FullVersion,
|
||||
/** version of the mod, verbatim as it appears in the mod's file name. */
|
||||
version: SourceVersion,
|
||||
/** SHA1 hash of the zip file. */
|
||||
sha1?: string,
|
||||
/** Used inside packages\web_ui\src\components\ModPackViewPage.tsx to define an error type. */
|
||||
@@ -61,7 +61,7 @@ export interface ModRecord {
|
||||
const ModRecordJsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"enabled": Type.Boolean(),
|
||||
"version": FullVersionSchema,
|
||||
"version": SourceVersionSchema,
|
||||
"sha1": Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
@@ -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 SourceVersion;
|
||||
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 },
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import ModPack from "./ModPack";
|
||||
import { JsonString, jsonArray } from "./composites";
|
||||
|
||||
import {
|
||||
FullVersion, FullVersionSchema,
|
||||
ApiVersion, ApiVersionSchema, normaliseApiVersion,
|
||||
MajorMinorVersion, MajorMinorVersionSchema, normaliseMajorMinorVersion,
|
||||
SourceVersion, SourceVersionSchema,
|
||||
ModVersionEquality,
|
||||
} from "./version";
|
||||
|
||||
@@ -119,13 +119,13 @@ export class ModGetRequest {
|
||||
|
||||
constructor(
|
||||
public name: string,
|
||||
public version: FullVersion,
|
||||
public version: SourceVersion,
|
||||
public sha1?: string,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"version": FullVersionSchema,
|
||||
"version": SourceVersionSchema,
|
||||
"sha1": Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
@@ -154,7 +154,7 @@ export class ModSearchRequest {
|
||||
|
||||
constructor(
|
||||
public query: string,
|
||||
public factorioVersion: ApiVersion,
|
||||
public factorioVersion: MajorMinorVersion,
|
||||
public page: number,
|
||||
public pageSize?: number,
|
||||
public sort?: string,
|
||||
@@ -163,7 +163,7 @@ export class ModSearchRequest {
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"query": Type.String(),
|
||||
"factorioVersion": ApiVersionSchema,
|
||||
"factorioVersion": MajorMinorVersionSchema,
|
||||
"page": Type.Integer(),
|
||||
"pageSize": Type.Optional(Type.Integer()),
|
||||
"sort": Type.Optional(Type.String()),
|
||||
@@ -205,9 +205,9 @@ export class ModSearchRequest {
|
||||
|
||||
// Define the structure for the latest release info from the portal
|
||||
export const ModPortalReleaseSchema = Type.Object({
|
||||
version: FullVersionSchema,
|
||||
version: SourceVersionSchema,
|
||||
// Match the structure from ModStore's ModRelease/ModDetails
|
||||
info_json: Type.Object({ factorio_version: ApiVersionSchema }),
|
||||
info_json: Type.Object({ factorio_version: SourceVersionSchema }),
|
||||
released_at: Type.String(), // ISO 8601 date string
|
||||
download_url: Type.String(),
|
||||
file_name: Type.String(),
|
||||
@@ -236,12 +236,12 @@ export class ModPortalGetAllRequest {
|
||||
static permission = "core.mod.search_portal" as const;
|
||||
|
||||
constructor(
|
||||
public factorioVersion: ApiVersion,
|
||||
public factorioVersion: MajorMinorVersion,
|
||||
public hide_deprecated?: boolean,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"factorioVersion": ApiVersionSchema,
|
||||
"factorioVersion": MajorMinorVersionSchema,
|
||||
"hide_deprecated": Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
@@ -276,13 +276,13 @@ export class ModDownloadRequest {
|
||||
|
||||
constructor(
|
||||
public name: string,
|
||||
public version: FullVersion,
|
||||
public version: SourceVersion,
|
||||
public sha1?: string,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"version": FullVersionSchema,
|
||||
"version": SourceVersionSchema,
|
||||
"sha1": Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
@@ -302,12 +302,12 @@ export class ModDeleteRequest {
|
||||
|
||||
constructor(
|
||||
public name: string,
|
||||
public version: FullVersion,
|
||||
public version: SourceVersion,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"version": FullVersionSchema,
|
||||
"version": SourceVersionSchema,
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
@@ -385,12 +385,12 @@ export class ModPortalDownloadRequest {
|
||||
|
||||
constructor(
|
||||
public mods: ModNameVersionPair[],
|
||||
public factorioVersion: ApiVersion,
|
||||
public factorioVersion: MajorMinorVersion,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"mods": Type.Array(ModNameVersionPairSchema),
|
||||
"factorioVersion": ApiVersionSchema,
|
||||
"factorioVersion": MajorMinorVersionSchema,
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
@@ -422,13 +422,13 @@ export class ModDependencyResolveRequest {
|
||||
|
||||
constructor(
|
||||
public mods: ModDependency[],
|
||||
public factorioVersion: ApiVersion,
|
||||
public factorioVersion: MajorMinorVersion,
|
||||
public checkForUpdates: boolean = false,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"mods": Type.Array(ModDependency.jsonSchema),
|
||||
"factorioVersion": ApiVersionSchema,
|
||||
"factorioVersion": MajorMinorVersionSchema,
|
||||
"checkForUpdates": Type.Boolean(),
|
||||
});
|
||||
|
||||
@@ -441,7 +441,7 @@ export class ModDependencyResolveRequest {
|
||||
return new this(
|
||||
[...modPack.mods.values()]
|
||||
.map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)),
|
||||
normaliseApiVersion(modPack.factorioVersion),
|
||||
normaliseMajorMinorVersion(modPack.factorioVersion),
|
||||
checkForUpdates,
|
||||
);
|
||||
}
|
||||
@@ -452,7 +452,7 @@ export class ModDependencyResolveRequest {
|
||||
[...modPack.mods.values()]
|
||||
.filter(mod => mod.enabled)
|
||||
.map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)),
|
||||
normaliseApiVersion(modPack.factorioVersion),
|
||||
normaliseMajorMinorVersion(modPack.factorioVersion),
|
||||
checkForUpdates,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,24 +24,55 @@ export function isVersionEquality(input: string): input is VersionEquality {
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches valid factorio versions accepts by the web API
|
||||
* https://wiki.factorio.com/Mod_portal_API#/api/mods (see version enum)
|
||||
* 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"] as const;
|
||||
export const ApiVersionSchema = Type.Union(ApiVersions.map(v => Type.Literal(v)));
|
||||
export type ApiVersion = Static<typeof ApiVersionSchema>;
|
||||
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;
|
||||
|
||||
export function isApiVersion(input: string): input is ApiVersion {
|
||||
return ApiVersions.includes(input as any);
|
||||
/**
|
||||
* 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 normaliseApiVersion(version: PartialVersion) {
|
||||
const parts = version.split(".");
|
||||
const apiVersion = `${parts[0]}.${parts[1]}`;
|
||||
if (!isApiVersion(apiVersion)) {
|
||||
throw new Error(`Factorio version not supported by mod portal: ${apiVersion}`);
|
||||
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 apiVersion;
|
||||
return `${Number(match[1])}.${Number(match[2])}.${Number(match[3] ?? "0")}` as FullVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,11 +99,38 @@ export function normaliseFullVersion(version: PartialVersion) {
|
||||
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 | `${number}.${number}`;
|
||||
export type PartialVersion = FullVersion | MajorMinorVersion;
|
||||
export const PartialVersionSchema = Type.Unsafe<PartialVersion>(
|
||||
Type.String({ pattern: partialVersionRegExp.source })
|
||||
);
|
||||
@@ -94,8 +152,13 @@ export function integerPartialVersion(version: PartialVersion) {
|
||||
* 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): boolean {
|
||||
export function isReleaseChannel(input: string): input is ReleaseChannel {
|
||||
return input !== "latest" && releaseChannelRegExp.test(input);
|
||||
}
|
||||
|
||||
@@ -103,15 +166,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<TargetVersion>(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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,8 +213,8 @@ export class ModVersionEquality {
|
||||
}
|
||||
}
|
||||
|
||||
testVersion(version: PartialVersion) {
|
||||
return this.testIntegerVersion(integerPartialVersion(version));
|
||||
testVersion(version: SourceVersion) {
|
||||
return this.testIntegerVersion(integerSourceVersion(version));
|
||||
}
|
||||
|
||||
toString() {
|
||||
@@ -213,8 +276,11 @@ export class ModVersionRange {
|
||||
return this.minVersion.testIntegerVersion(other) && this.maxVersion.testIntegerVersion(other);
|
||||
}
|
||||
|
||||
testVersion(version: PartialVersion) {
|
||||
return this.testIntegerVersion(integerPartialVersion(version));
|
||||
testVersion(version: PartialVersion | SourceVersion) {
|
||||
if (isPartialVersion(version)) {
|
||||
return this.testIntegerVersion(integerPartialVersion(version));
|
||||
}
|
||||
return this.testIntegerVersion(integerSourceVersion(version));
|
||||
}
|
||||
|
||||
combineVersion(other: ModVersionEquality) {
|
||||
|
||||
@@ -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<lib.InstanceDetails> = [
|
||||
|
||||
@@ -77,12 +77,12 @@ function SearchModsTable(props: SearchModsTableProps) {
|
||||
const [modResultPageSize, setModResultPageSize] = useState<number>(10);
|
||||
const [modResultCount, setModResultCount] = useState<number>(2);
|
||||
const [modResultSelectedVersion, setModResultSelectedVersion] = useState<Map<string, number>>(new Map());
|
||||
const [factorioVersion, setFactorioVersion] = useState<lib.ApiVersion | null>(null);
|
||||
const [factorioVersion, setFactorioVersion] = useState<lib.MajorMinorVersion | null>(null);
|
||||
|
||||
// Get a valid factorio version
|
||||
useEffect(() => {
|
||||
try {
|
||||
setFactorioVersion(lib.normaliseApiVersion(props.modPack.factorioVersion));
|
||||
setFactorioVersion(lib.normaliseMajorMinorVersion(props.modPack.factorioVersion));
|
||||
} catch (err) {
|
||||
setFactorioVersion(null);
|
||||
}
|
||||
@@ -271,7 +271,7 @@ function DownloadDependenciesButton(props: DownloadDependenciesProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [factorioVersion, setFactorioVersion] = useState<lib.ApiVersion | null>(null);
|
||||
const [factorioVersion, setFactorioVersion] = useState<lib.MajorMinorVersion | null>(null);
|
||||
|
||||
const [mods, setMods] = useState<lib.ModInfo[]>([]); // All resolved mods and dependencies
|
||||
const [missing, setMissing] = useState<lib.ModInfo[]>([]); // Missing mods and dependencies, excluding builtin
|
||||
@@ -295,7 +295,7 @@ function DownloadDependenciesButton(props: DownloadDependenciesProps) {
|
||||
// Get a valid factorio version
|
||||
useEffect(() => {
|
||||
try {
|
||||
setFactorioVersion(lib.normaliseApiVersion(props.modPack.factorioVersion));
|
||||
setFactorioVersion(lib.normaliseMajorMinorVersion(props.modPack.factorioVersion));
|
||||
} catch (err) {
|
||||
setError(new Error(`Invalid factorio version: ${props.modPack.factorioVersion}`));
|
||||
setFactorioVersion(null);
|
||||
@@ -403,7 +403,9 @@ function DownloadDependenciesButton(props: DownloadDependenciesProps) {
|
||||
control.send(
|
||||
new lib.ModPortalDownloadRequest(
|
||||
missing.map(mod => ({
|
||||
name: mod.name, version: new lib.ModVersionEquality("=", mod.version),
|
||||
name: mod.name,
|
||||
// mod.version is the raw version; the equality bound must be canonical.
|
||||
version: new lib.ModVersionEquality("=", mod.normalisedVersion),
|
||||
})),
|
||||
factorioVersion,
|
||||
)
|
||||
@@ -577,8 +579,8 @@ function ModsTable(props: ModsTableProps) {
|
||||
if (mod.enabled && mod.info) {
|
||||
mod.warning = mod.info.checkDependencySatisfaction(mods.filter(m => m.enabled));
|
||||
try {
|
||||
const packFactorioVersion = lib.normaliseApiVersion(props.modPack.factorioVersion);
|
||||
const modFactorioVersion = lib.normaliseApiVersion(mod.info.factorioVersion);
|
||||
const packFactorioVersion = lib.normaliseMajorMinorVersion(props.modPack.factorioVersion);
|
||||
const modFactorioVersion = mod.info.factorioVersion;
|
||||
if (packFactorioVersion !== modFactorioVersion) {
|
||||
mod.warning = "wrong_factorio_version";
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ function SearchModsButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [factorioVersion, setFactorioVersion] = useState<lib.ApiVersion>("2.0");
|
||||
const [factorioVersion, setFactorioVersion] = useState<lib.MajorMinorVersion>("2.0");
|
||||
|
||||
// State for all mods fetched from backend
|
||||
const [allMods, setAllMods] = useState<ModPortalModType[]>([]);
|
||||
@@ -164,13 +164,16 @@ function SearchModsButton() {
|
||||
const handleControllerDownload = (
|
||||
modName: string,
|
||||
modTitle: string | undefined,
|
||||
modVersion: lib.FullVersion | undefined,
|
||||
portalFactorioVersion: lib.ApiVersion,
|
||||
modVersion: lib.SourceVersion | undefined,
|
||||
portalFactorioVersion: lib.MajorMinorVersion,
|
||||
) => {
|
||||
if (modVersion) {
|
||||
control.send(
|
||||
new lib.ModPortalDownloadRequest(
|
||||
[{ name: modName, version: new lib.ModVersionEquality("=", modVersion)}],
|
||||
[{
|
||||
name: modName,
|
||||
version: new lib.ModVersionEquality("=", lib.normaliseSourceVersion(modVersion)),
|
||||
}],
|
||||
portalFactorioVersion
|
||||
)
|
||||
).then(() => {
|
||||
@@ -271,7 +274,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.ApiVersion | undefined;
|
||||
const versionValue = allValues.factorioVersion as lib.MajorMinorVersion | undefined;
|
||||
|
||||
// Only trigger state updates if values actually changed
|
||||
if (nameValue !== searchText) {
|
||||
|
||||
@@ -358,7 +358,7 @@ describe("lib/ModStore", function () {
|
||||
|
||||
await assert.rejects(
|
||||
modStore.downloadMods(modsToDownload, username, token, factorioVersion),
|
||||
/Fetch: https:\/\/mods\.factorio\.com\/api\/mods.* returned 503 Service Unavailable/
|
||||
/Mod portal request to https:\/\/mods\.factorio\.com\/api\/mods.* failed: 503 Service Unavailable/
|
||||
);
|
||||
assert.equal(modStore.files.size, 0, "No mods should be added on API error");
|
||||
});
|
||||
|
||||
@@ -341,6 +341,39 @@ describe("lib/data/ModInfo", function() {
|
||||
dependencies: ["base", ""],
|
||||
}), /my-mod/); // check the mod name is included in the error message
|
||||
});
|
||||
it("should keep the raw mod version but normalise it for comparison", function() {
|
||||
const mod = ModInfo.fromJSON({
|
||||
name: "my-mod",
|
||||
version: " 1.2.3-extra",
|
||||
factorio_version: "1.1.0",
|
||||
});
|
||||
// The version is kept verbatim so it still matches the mod's file name,
|
||||
// but is read leniently the way the game does when compared.
|
||||
assert.equal(mod.version, " 1.2.3-extra");
|
||||
assert.equal(mod.integerVersion, ModInfo.fromJSON({ version: "1.2.3" }).integerVersion);
|
||||
// factorio_version is not part of any file name, so it is reduced to major.minor.
|
||||
assert.equal(mod.factorioVersion, "1.1");
|
||||
});
|
||||
it("should keep leading zeros in the version so the file name matches", function() {
|
||||
// Mirrors the mods.factorio.com/mod/leading-zeros case: the version has
|
||||
// a zero-padded component that the file name keeps verbatim.
|
||||
const mod = ModInfo.fromJSON({ name: "leading-zeros", version: "1.01.0" });
|
||||
assert.equal(mod.version, "1.01.0");
|
||||
assert.equal(mod.filename, "leading-zeros_1.01.0.zip");
|
||||
assert.equal(mod.integerVersion, ModInfo.fromJSON({ version: "1.1.0" }).integerVersion);
|
||||
});
|
||||
it("should throw for an unparseable mod version", function() {
|
||||
assert.throws(
|
||||
() => ModInfo.fromJSON({ name: "my-mod", version: "not-a-version" }),
|
||||
/Invalid mod version/
|
||||
);
|
||||
});
|
||||
it("should throw for an unparseable factorio_version", function() {
|
||||
assert.throws(
|
||||
() => ModInfo.fromJSON({ name: "my-mod", factorio_version: "not-a-version" }),
|
||||
/Invalid factorio_version/
|
||||
);
|
||||
});
|
||||
it("should sort integer mod versions lexicographically", function() {
|
||||
let unsortedVersions = ["1.0.0", "1.1.0", "0.1.0", "3.0.0", "1.2.0", "0.3.1", "0.3.3", "2.1.1", "0.0.1"];
|
||||
let sortedVersions = ["0.0.1", "0.1.0", "0.3.1", "0.3.3", "1.0.0", "1.1.0", "1.2.0", "2.1.1", "3.0.0"];
|
||||
|
||||
@@ -17,36 +17,53 @@ describe("lib/data/version", function() {
|
||||
}
|
||||
});
|
||||
});
|
||||
describe("isApiVersion()", function() {
|
||||
it("should correctly validate input strings", function() {
|
||||
const valid = ["0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0"];
|
||||
for (const test of valid) {
|
||||
assert.equal(lib.isApiVersion(test), true, test);
|
||||
}
|
||||
const invalid = ["0.12", "0.19", "1.2", "2.1", "1.0.0", "1.0.0.0", "0", "", "latest"];
|
||||
describe("normaliseSourceVersion()", function() {
|
||||
it("should normalise lenient version strings the way the game reads them", function() {
|
||||
// Major and minor required, patch optional and defaulting to 0.
|
||||
assert.equal(lib.normaliseSourceVersion("1.1"), "1.1.0");
|
||||
assert.equal(lib.normaliseSourceVersion("1.1.50"), "1.1.50");
|
||||
assert.equal(lib.normaliseSourceVersion("0.18"), "0.18.0");
|
||||
// Leading whitespace and zeros are dropped.
|
||||
assert.equal(lib.normaliseSourceVersion(" 1.1"), "1.1.0");
|
||||
assert.equal(lib.normaliseSourceVersion("01.02.03"), "1.2.3");
|
||||
assert.equal(lib.normaliseSourceVersion("1. 1. 50"), "1.1.50");
|
||||
// Anything after the patch (or a non-numeric patch) is ignored.
|
||||
assert.equal(lib.normaliseSourceVersion("1.1.50-beta"), "1.1.50");
|
||||
assert.equal(lib.normaliseSourceVersion("1.1.x"), "1.1.0");
|
||||
assert.equal(lib.normaliseSourceVersion("1.1.50.60"), "1.1.50");
|
||||
});
|
||||
it("should return undefined when no major.minor can be read", function() {
|
||||
const invalid = ["1", "", "latest", "abc", ".1.1", "x.y"];
|
||||
for (const test of invalid) {
|
||||
assert.equal(lib.isApiVersion(test), false, test);
|
||||
assert.equal(lib.normaliseSourceVersion(test), undefined, test);
|
||||
}
|
||||
});
|
||||
});
|
||||
describe("normaliseApiVersion()", function() {
|
||||
it("should normalise accepted versions", function() {
|
||||
assert.equal(lib.normaliseApiVersion("0.13"), "0.13");
|
||||
assert.equal(lib.normaliseApiVersion("0.13.0"), "0.13");
|
||||
assert.equal(lib.normaliseApiVersion("0.13.1"), "0.13");
|
||||
assert.equal(lib.normaliseApiVersion("1.1"), "1.1");
|
||||
assert.equal(lib.normaliseApiVersion("1.1.0"), "1.1");
|
||||
assert.equal(lib.normaliseApiVersion("1.1.1"), "1.1");
|
||||
assert.equal(lib.normaliseApiVersion("2.0"), "2.0");
|
||||
assert.equal(lib.normaliseApiVersion("2.0.0"), "2.0");
|
||||
assert.equal(lib.normaliseApiVersion("2.0.1"), "2.0");
|
||||
assert.equal(lib.normaliseApiVersion("1.0.0.0"), "1.0");
|
||||
describe("normaliseMajorMinorVersion()", function() {
|
||||
it("should return the major.minor", function() {
|
||||
assert.equal(lib.normaliseMajorMinorVersion("1.1"), "1.1");
|
||||
assert.equal(lib.normaliseMajorMinorVersion("1.1.50"), "1.1");
|
||||
assert.equal(lib.normaliseMajorMinorVersion("0.18.0"), "0.18");
|
||||
});
|
||||
it("should throw for invalid versions", function() {
|
||||
const invalid = ["0.12", "0", "", "latest"];
|
||||
for (const test of invalid) {
|
||||
assert.throws(() => lib.normaliseApiVersion(test), undefined, test);
|
||||
});
|
||||
describe("isMajorMinorVersion()", function() {
|
||||
it("should correctly validate input strings", function() {
|
||||
const valid = ["1.1", "0.18", "2.0", "10.20"];
|
||||
const invalid = ["1", "1.1.0", "1.1.50", "latest", "1.", ".1", ""];
|
||||
for (const version of valid) {
|
||||
assert(lib.isMajorMinorVersion(version), `${version} should be valid`);
|
||||
}
|
||||
for (const version of invalid) {
|
||||
assert(!lib.isMajorMinorVersion(version), `${version} should be invalid`);
|
||||
}
|
||||
});
|
||||
});
|
||||
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() {
|
||||
|
||||
Reference in new issue
Block a user