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:
bbassieandClaude Opus 4.8 committed 2026-07-01 13:54:06 +00:00
1 parent 523293d4d9
commit 8029ad3b2d
8 files changed
+75 -42

No files matched your search

+1 -1
View File
@@ -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) {
+7 -2
View File
@@ -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;
});
+16 -25
View File
@@ -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
+4 -4
View File
@@ -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()),
});
+29 -6
View File
@@ -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,
)
+1 -1
View File
@@ -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(() => {
+14 -2
View File
@@ -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" }),