Manual adjustments to GameVersion and ReleaseChannel types

Through the creation of this commit, it was discovered that ModInfo.version must be typed as GameVersion due to file names. This results in wide spread propagation of the unsafe type.  The full impact including on ModRecord and ModVersion.testVersion have no been implemented until a path forward is decided.
This commit is contained in:
Cooldude2606 committed 2026-06-30 23:39:08 +01:00
1 parent 5d20f8fc84
commit 523293d4d9
9 files changed
+129 -88

No files matched your search

+1 -1
View File
@@ -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.GameVersion, sha1?: string }) {
let filename = lib.ModInfo.filename(mod.name, mod.version);
let modInfo = this._controller.modStore.files.get(filename);
if (!modInfo) {
+7 -7
View File
@@ -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, PartialVersion, normaliseMajorMinorVersion, ModVersionEquality } from "./data";
import { ModInfo, ModPack, MajorMinorVersion, ModVersionEquality, GameVersion } 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: GameVersion, 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: GameVersion) {
return await this.loadFile(ModInfo.filename(name, version));
}
async deleteMod(name: string, version: FullVersion) {
async deleteMod(name: string, version: GameVersion) {
return await this.deleteFile(ModInfo.filename(name, version));
}
@@ -198,11 +198,11 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
private async downloadModsChunk(
mods: ModNameVersionPair[],
username: string, token: string,
factorioVersion: PartialVersion,
factorioVersion: MajorMinorVersion,
) {
const url = new URL("https://mods.factorio.com/api/mods");
url.searchParams.set("page_size", "max");
url.searchParams.set("version", normaliseMajorMinorVersion(factorioVersion));
url.searchParams.set("version", factorioVersion);
const response = await fetch(url, {
method: "POST",
body: new URLSearchParams({ namelist: mods.map(m => m.name).join(",") }),
@@ -247,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: PartialVersion = "1.1"
mods: ModNameVersionPair[], username: string, token: string, factorioVersion: MajorMinorVersion = "1.1"
) {
const chunkSize = 500;
const futures: Promise<void>[] = [];
+22 -10
View File
@@ -9,12 +9,12 @@ import { ModRecord } from "./ModPack";
import {
FullVersion, integerFullVersion,
integerPartialVersion,
GameVersionSchema, normaliseGameVersion, normaliseMajorMinorVersion,
MajorMinorVersion,
MajorMinorVersion, normaliseMajorMinorVersion, integerMajorMinorVersion,
GameVersion, GameVersionSchema, normaliseGameVersion,
ModVersionEquality,
} from "./version";
type ModDependencyType = "incompatible" | "optional" | "hidden" | "unordered" | "required";
export type ModDependencyUnsatisfiedReason = "incompatible" | "missing_dependency" | "wrong_version";
@@ -132,13 +132,18 @@ export default class ModInfo {
* Version of the mod.
* Sourced from info.json.
*/
version = "0.0.0" as FullVersion;
version = "0.0.0" as GameVersion;
/**
* Type safe accessor for this.version
*/
safeVersion = "0.0.0" as FullVersion;
/**
* Integer representation of the version
*/
get integerVersion() {
return integerFullVersion(this.version);
return integerFullVersion(this.safeVersion);
}
/**
@@ -175,14 +180,19 @@ export default class ModInfo {
* Major version of Factorio this mod supports.
* Sourced from info.json.
*/
factorioVersion = "0.12" as MajorMinorVersion;
factorioVersion = "0.12" as GameVersion;
/**
* Type safe accessor for this.factorioVersion
*/
safeFactorioVersion = "0.0" as MajorMinorVersion;
/**
* Integer representation of the factorioVersion
* @type {number}
*/
get integerFactorioVersion() {
return integerPartialVersion(this.factorioVersion);
return integerMajorMinorVersion(this.safeFactorioVersion);
}
/**
@@ -221,7 +231,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: GameVersion) {
return `${name}_${version}.zip`;
}
@@ -285,7 +295,8 @@ export default class ModInfo {
if (version === undefined) {
throw new Error(`Invalid mod version "${json.version}"`);
}
modInfo.version = version;
modInfo.version = json.version;
modInfo.safeVersion = version;
}
if (json.title) { modInfo.title = json.title; }
if (json.author) { modInfo.author = json.author; }
@@ -297,7 +308,8 @@ export default class ModInfo {
if (factorioVersion === undefined) {
throw new Error(`Invalid factorio_version "${json.factorio_version}"`);
}
modInfo.factorioVersion = normaliseMajorMinorVersion(factorioVersion);
modInfo.factorioVersion = json.factorio_version;
modInfo.safeFactorioVersion = normaliseMajorMinorVersion(factorioVersion);
}
// Parse the dependencies
+19 -20
View File
@@ -4,9 +4,8 @@ import ModPack from "./ModPack";
import { JsonString, jsonArray } from "./composites";
import {
FullVersion, FullVersionSchema,
PartialVersion, PartialVersionSchema,
GameVersionSchema,
MajorMinorVersion, MajorMinorVersionSchema, normaliseMajorMinorVersion,
GameVersion, GameVersionSchema,
ModVersionEquality,
} from "./version";
@@ -120,13 +119,13 @@ export class ModGetRequest {
constructor(
public name: string,
public version: FullVersion,
public version: GameVersion,
public sha1?: string,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"version": FullVersionSchema,
"version": GameVersionSchema,
"sha1": Type.Optional(Type.String()),
});
@@ -155,7 +154,7 @@ export class ModSearchRequest {
constructor(
public query: string,
public factorioVersion: PartialVersion,
public factorioVersion: MajorMinorVersion,
public page: number,
public pageSize?: number,
public sort?: string,
@@ -164,7 +163,7 @@ export class ModSearchRequest {
static jsonSchema = Type.Object({
"query": Type.String(),
"factorioVersion": PartialVersionSchema,
"factorioVersion": MajorMinorVersionSchema,
"page": Type.Integer(),
"pageSize": Type.Optional(Type.Integer()),
"sort": Type.Optional(Type.String()),
@@ -206,7 +205,7 @@ export class ModSearchRequest {
// Define the structure for the latest release info from the portal
export const ModPortalReleaseSchema = Type.Object({
version: FullVersionSchema,
version: GameVersionSchema,
// Match the structure from ModStore's ModRelease/ModDetails
info_json: Type.Object({ factorio_version: GameVersionSchema }),
released_at: Type.String(), // ISO 8601 date string
@@ -237,12 +236,12 @@ export class ModPortalGetAllRequest {
static permission = "core.mod.search_portal" as const;
constructor(
public factorioVersion: PartialVersion,
public factorioVersion: MajorMinorVersion,
public hide_deprecated?: boolean,
) { }
static jsonSchema = Type.Object({
"factorioVersion": PartialVersionSchema,
"factorioVersion": MajorMinorVersionSchema,
"hide_deprecated": Type.Optional(Type.Boolean()),
});
@@ -277,13 +276,13 @@ export class ModDownloadRequest {
constructor(
public name: string,
public version: FullVersion,
public version: GameVersion,
public sha1?: string,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"version": FullVersionSchema,
"version": GameVersionSchema,
"sha1": Type.Optional(Type.String()),
});
@@ -303,12 +302,12 @@ export class ModDeleteRequest {
constructor(
public name: string,
public version: FullVersion,
public version: GameVersion,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"version": FullVersionSchema,
"version": GameVersionSchema,
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
@@ -386,12 +385,12 @@ export class ModPortalDownloadRequest {
constructor(
public mods: ModNameVersionPair[],
public factorioVersion: PartialVersion,
public factorioVersion: MajorMinorVersion,
) { }
static jsonSchema = Type.Object({
"mods": Type.Array(ModNameVersionPairSchema),
"factorioVersion": PartialVersionSchema,
"factorioVersion": MajorMinorVersionSchema,
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
@@ -423,13 +422,13 @@ export class ModDependencyResolveRequest {
constructor(
public mods: ModDependency[],
public factorioVersion: PartialVersion,
public factorioVersion: MajorMinorVersion,
public checkForUpdates: boolean = false,
) { }
static jsonSchema = Type.Object({
"mods": Type.Array(ModDependency.jsonSchema),
"factorioVersion": PartialVersionSchema,
"factorioVersion": MajorMinorVersionSchema,
"checkForUpdates": Type.Boolean(),
});
@@ -442,7 +441,7 @@ export class ModDependencyResolveRequest {
return new this(
[...modPack.mods.values()]
.map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)),
modPack.factorioVersion,
normaliseMajorMinorVersion(modPack.factorioVersion),
checkForUpdates,
);
}
@@ -453,7 +452,7 @@ export class ModDependencyResolveRequest {
[...modPack.mods.values()]
.filter(mod => mod.enabled)
.map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)),
modPack.factorioVersion,
normaliseMajorMinorVersion(modPack.factorioVersion),
checkForUpdates,
);
}
+62 -40
View File
@@ -30,6 +30,39 @@ export function isVersionEquality(input: string): input is VersionEquality {
*/
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 gameVersionRegExp = /^\s*(\d+)\.\s*(\d+)(?:\.\s*(\d+))?/;
declare const gameVersionSymbol: unique symbol;
export type GameVersion = string & { [gameVersionSymbol]: void };
export const GameVersionSchema = Type.Unsafe<GameVersion>(
Type.String({ pattern: gameVersionRegExp.source })
);
export function isGameVersion(input: string): input is GameVersion {
return gameVersionRegExp.test(input);
}
/**
* 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 normaliseGameVersion(input: string): FullVersion | undefined {
const match = gameVersionRegExp.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.
*/
@@ -55,28 +88,30 @@ export function normaliseFullVersion(version: PartialVersion) {
}
/**
* 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.
* Matches a major.minor version, as used by the mod portal's version filter.
*/
const gameVersionRegExp = /^\s*(\d+)\.\s*(\d+)(?:\.\s*(\d+))?/;
export const GameVersionSchema = Type.String({ pattern: gameVersionRegExp.source });
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;
}
/**
* 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.
* Reduce a version to its major.minor, as used by the mod portal's version filter.
*/
export function normaliseGameVersion(input: string): FullVersion | undefined {
const match = gameVersionRegExp.exec(input);
if (match === null) {
return undefined;
}
return `${Number(match[1])}.${Number(match[2])}.${Number(match[3] ?? "0")}` as FullVersion;
export function normaliseMajorMinorVersion(version: PartialVersion): MajorMinorVersion {
const [major, minor] = version.split(".");
return `${major}.${minor}` as MajorMinorVersion;
}
/**
@@ -98,24 +133,6 @@ export function integerPartialVersion(version: PartialVersion) {
return major * 0x100000000 + minor * 0x10000 + (sub || 0) as IntegerVersion;
}
/**
* Matches a major.minor version, as used by the mod portal's version filter.
*/
const majorMinorVersionRegExp = /^\d+\.\d+$/;
export type MajorMinorVersion = `${number}.${number}`;
export function isMajorMinorVersion(input: string): input is MajorMinorVersion {
return majorMinorVersionRegExp.test(input);
}
/**
* 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 a release channel name from the latest-releases API, such as
* "stable" or "experimental". Excludes "latest", which is handled separately.
@@ -123,8 +140,13 @@ export function normaliseMajorMinorVersion(version: PartialVersion): MajorMinorV
* 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);
}
@@ -132,15 +154,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);
}
/**
@@ -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,7 +77,7 @@ 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.PartialVersion | null>(null);
const [factorioVersion, setFactorioVersion] = useState<lib.MajorMinorVersion | null>(null);
// Get a valid factorio version
useEffect(() => {
@@ -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.PartialVersion | 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
@@ -578,7 +578,6 @@ function ModsTable(props: ModsTableProps) {
mod.warning = mod.info.checkDependencySatisfaction(mods.filter(m => m.enabled));
try {
const packFactorioVersion = lib.normaliseMajorMinorVersion(props.modPack.factorioVersion);
// mod.info.factorioVersion is already a MajorMinorVersion, no need to re-reduce it.
const modFactorioVersion = mod.info.factorioVersion;
if (packFactorioVersion !== modFactorioVersion) {
mod.warning = "wrong_factorio_version";
+4 -4
View File
@@ -147,7 +147,7 @@ function SearchModsButton() {
const [open, setOpen] = useState(false);
const [form] = Form.useForm();
const [searchText, setSearchText] = useState("");
const [factorioVersion, setFactorioVersion] = useState<lib.PartialVersion>("2.0");
const [factorioVersion, setFactorioVersion] = useState<lib.MajorMinorVersion>("2.0");
// State for all mods fetched from backend
const [allMods, setAllMods] = useState<ModPortalModType[]>([]);
@@ -164,8 +164,8 @@ function SearchModsButton() {
const handleControllerDownload = (
modName: string,
modTitle: string | undefined,
modVersion: lib.FullVersion | undefined,
portalFactorioVersion: lib.PartialVersion,
modVersion: lib.GameVersion | undefined,
portalFactorioVersion: lib.MajorMinorVersion,
) => {
if (modVersion) {
control.send(
@@ -271,7 +271,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.PartialVersion | undefined;
const versionValue = allValues.factorioVersion as lib.MajorMinorVersion | undefined;
// Only trigger state updates if values actually changed
if (nameValue !== searchText) {
+8
View File
@@ -58,6 +58,14 @@ describe("lib/data/version", function() {
}
});
});
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() {
it("should correctly validate input strings", function() {
const valid = ["0.12.0", "0.13.1", "0.17.99", "00.000.0001", "1.1.110"];