mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-14 15:24:52 +09:00
Resolve GameVersion type as a plain-string supertype
Cooldude's GameVersion brand was disjoint from the clean version types in both directions, forcing safeVersion/safeFactorioVersion companions and breaking widening from FullVersion at file-name call sites. Make GameVersion a plain string alias: the raw, un-normalised version as the game reads it. The stricter template-literal types (FullVersion, MajorMinorVersion, PartialVersion) now widen into it for free, while a GameVersion still can't be used where a normalised version is required without going through normaliseGameVersion/integerGameVersion. Runtime validation stays in GameVersionSchema. - Drop safeVersion/safeFactorioVersion; ModInfo.version stays raw (verbatim so it matches the mod's file name), integerVersion normalises via the new integerGameVersion. factorioVersion reverts to a normalised MajorMinorVersion since it is never part of a file name. - ModRecord.version becomes a GameVersion so mod packs round-trip the exact file-name version. - ModVersionEquality.testVersion / ModVersionRange.testVersion accept a raw GameVersion and normalise it; the equality bound stays strictly validated. - Migration only pads clean partial versions; raw versions are kept verbatim. - Cover the leading-zeros case: version kept verbatim in the file name while comparing equal to its normalised form. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
523293d4d9
commit
8029ad3b2d
8 files changed
+75
-42
No files matched your search
@@ -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.integerGameVersion(max.version) > lib.integerGameVersion(cur.version) ? max : cur
|
||||
), undefined);
|
||||
|
||||
if (!release) {
|
||||
|
||||
@@ -763,8 +763,13 @@ 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);
|
||||
// Older mod packs recorded builtins with a major.minor version; pad
|
||||
// those to major.minor.patch. Raw versions the game reads leniently
|
||||
// (e.g. with leading zeros) are kept verbatim so they still match the
|
||||
// mod's file name.
|
||||
if (lib.isPartialVersion(mod.version)) {
|
||||
mod.version = lib.normaliseFullVersion(mod.version);
|
||||
}
|
||||
}
|
||||
return json;
|
||||
});
|
||||
|
||||
@@ -8,9 +8,8 @@ import { findRoot } from "../zip_ops";
|
||||
import { ModRecord } from "./ModPack";
|
||||
|
||||
import {
|
||||
FullVersion, integerFullVersion,
|
||||
MajorMinorVersion, normaliseMajorMinorVersion, integerMajorMinorVersion,
|
||||
GameVersion, GameVersionSchema, normaliseGameVersion,
|
||||
GameVersion, GameVersionSchema, normaliseGameVersion, integerGameVersion,
|
||||
ModVersionEquality,
|
||||
} from "./version";
|
||||
|
||||
@@ -129,21 +128,19 @@ 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 GameVersion;
|
||||
|
||||
/**
|
||||
* Type safe accessor for this.version
|
||||
*/
|
||||
safeVersion = "0.0.0" as FullVersion;
|
||||
|
||||
/**
|
||||
* 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.safeVersion);
|
||||
return integerGameVersion(this.version);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -177,22 +174,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 GameVersion;
|
||||
|
||||
/**
|
||||
* Type safe accessor for this.factorioVersion
|
||||
*/
|
||||
safeFactorioVersion = "0.0" as MajorMinorVersion;
|
||||
factorioVersion = "0.12" as MajorMinorVersion;
|
||||
|
||||
/**
|
||||
* Integer representation of the factorioVersion
|
||||
* @type {number}
|
||||
*/
|
||||
get integerFactorioVersion() {
|
||||
return integerMajorMinorVersion(this.safeFactorioVersion);
|
||||
return integerMajorMinorVersion(this.factorioVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -291,12 +283,12 @@ export default class ModInfo {
|
||||
// info.json fields
|
||||
if (json.name) { modInfo.name = json.name; }
|
||||
if (json.version) {
|
||||
const version = normaliseGameVersion(json.version);
|
||||
if (version === undefined) {
|
||||
// Validate that the raw version can be read, but keep it verbatim so
|
||||
// it still matches the mod's file name on disk and the portal.
|
||||
if (normaliseGameVersion(json.version) === undefined) {
|
||||
throw new Error(`Invalid mod version "${json.version}"`);
|
||||
}
|
||||
modInfo.version = json.version;
|
||||
modInfo.safeVersion = version;
|
||||
}
|
||||
if (json.title) { modInfo.title = json.title; }
|
||||
if (json.author) { modInfo.author = json.author; }
|
||||
@@ -308,8 +300,7 @@ export default class ModInfo {
|
||||
if (factorioVersion === undefined) {
|
||||
throw new Error(`Invalid factorio_version "${json.factorio_version}"`);
|
||||
}
|
||||
modInfo.factorioVersion = json.factorio_version;
|
||||
modInfo.safeFactorioVersion = normaliseMajorMinorVersion(factorioVersion);
|
||||
modInfo.factorioVersion = normaliseMajorMinorVersion(factorioVersion);
|
||||
}
|
||||
|
||||
// Parse the dependencies
|
||||
|
||||
@@ -12,7 +12,7 @@ import ModInfo, { ModDependencyUnsatisfiedReason } from "./ModInfo";
|
||||
|
||||
import {
|
||||
PartialVersion, PartialVersionSchema, integerPartialVersion,
|
||||
FullVersion, FullVersionSchema, normaliseFullVersion,
|
||||
GameVersion, GameVersionSchema, 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: GameVersion,
|
||||
/** 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": GameVersionSchema,
|
||||
"sha1": Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
|
||||
@@ -37,8 +37,16 @@ export const ApiVersions = ["0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0
|
||||
* not anchored at the end.
|
||||
*/
|
||||
const gameVersionRegExp = /^\s*(\d+)\.\s*(\d+)(?:\.\s*(\d+))?/;
|
||||
declare const gameVersionSymbol: unique symbol;
|
||||
export type GameVersion = string & { [gameVersionSymbol]: void };
|
||||
/**
|
||||
* 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. It is deliberately a
|
||||
* plain string alias so that any of the stricter, normalised version types
|
||||
* ({@link FullVersion}, {@link MajorMinorVersion}, {@link PartialVersion}) widen
|
||||
* into it for free, while a GameVersion can not be used where a normalised
|
||||
* version is required without going through {@link normaliseGameVersion} or
|
||||
* {@link integerGameVersion} first.
|
||||
*/
|
||||
export type GameVersion = string;
|
||||
export const GameVersionSchema = Type.Unsafe<GameVersion>(
|
||||
Type.String({ pattern: gameVersionRegExp.source })
|
||||
);
|
||||
@@ -63,6 +71,21 @@ export function normaliseGameVersion(input: string): FullVersion | undefined {
|
||||
return `${Number(match[1])}.${Number(match[2])}.${Number(match[3] ?? "0")}` as FullVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Integer representation of a raw {@link GameVersion}, normalised the same
|
||||
* lenient way the game reads it. Suitable for comparing versions that may carry
|
||||
* leading zeros, whitespace or trailing content.
|
||||
*
|
||||
* @throws if the input can not be read as a version.
|
||||
*/
|
||||
export function integerGameVersion(version: GameVersion) {
|
||||
const normalised = normaliseGameVersion(version);
|
||||
if (normalised === undefined) {
|
||||
throw new Error(`Invalid version "${version}"`);
|
||||
}
|
||||
return integerFullVersion(normalised);
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches valid mod versions, where all parts are specified.
|
||||
*/
|
||||
@@ -201,8 +224,8 @@ export class ModVersionEquality {
|
||||
}
|
||||
}
|
||||
|
||||
testVersion(version: PartialVersion) {
|
||||
return this.testIntegerVersion(integerPartialVersion(version));
|
||||
testVersion(version: GameVersion) {
|
||||
return this.testIntegerVersion(integerGameVersion(version));
|
||||
}
|
||||
|
||||
toString() {
|
||||
@@ -264,8 +287,8 @@ export class ModVersionRange {
|
||||
return this.minVersion.testIntegerVersion(other) && this.maxVersion.testIntegerVersion(other);
|
||||
}
|
||||
|
||||
testVersion(version: PartialVersion) {
|
||||
return this.testIntegerVersion(integerPartialVersion(version));
|
||||
testVersion(version: GameVersion) {
|
||||
return this.testIntegerVersion(integerGameVersion(version));
|
||||
}
|
||||
|
||||
combineVersion(other: ModVersionEquality) {
|
||||
|
||||
@@ -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("=", lib.normaliseGameVersion(mod.version)!),
|
||||
})),
|
||||
factorioVersion,
|
||||
)
|
||||
|
||||
@@ -170,7 +170,7 @@ function SearchModsButton() {
|
||||
if (modVersion) {
|
||||
control.send(
|
||||
new lib.ModPortalDownloadRequest(
|
||||
[{ name: modName, version: new lib.ModVersionEquality("=", modVersion)}],
|
||||
[{ name: modName, version: new lib.ModVersionEquality("=", lib.normaliseGameVersion(modVersion)!)}],
|
||||
portalFactorioVersion
|
||||
)
|
||||
).then(() => {
|
||||
|
||||
@@ -341,15 +341,27 @@ describe("lib/data/ModInfo", function() {
|
||||
dependencies: ["base", ""],
|
||||
}), /my-mod/); // check the mod name is included in the error message
|
||||
});
|
||||
it("should normalise lenient version fields the way the game reads them", function() {
|
||||
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",
|
||||
});
|
||||
assert.equal(mod.version, "1.2.3");
|
||||
// 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" }),
|
||||
|
||||
Reference in new issue
Block a user