From 51d846eef0d1b8a3268a026cdd9431db2f51be6c Mon Sep 17 00:00:00 2001 From: cody-ferguson <92231243+cody-ferguson@users.noreply.github.com> Date: Tue, 16 Sep 2025 15:53:25 -0500 Subject: [PATCH 01/10] Add dependencies to metadata --- electron/src/mods/loader.ts | 6 ++++++ electron/src/mods/metadata.ts | 33 ++++++++++++++++++++++++++++++--- src/js/mods/mod_metadata.ts | 12 +++++++++++- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/electron/src/mods/loader.ts b/electron/src/mods/loader.ts index d18f571e53..d80fd636bd 100644 --- a/electron/src/mods/loader.ts +++ b/electron/src/mods/loader.ts @@ -43,6 +43,12 @@ class Mod { disabled: this.disabled, metadata: { ...this.metadata, + dependencies: this.metadata.dependencies.map(dependency => ({ + id: dependency.id, + loadOrder: dependency.loadOrder, + optional: dependency.optional, + version: dependency.version.format(), + })), version: this.metadata.version.format(), }, }; diff --git a/electron/src/mods/metadata.ts b/electron/src/mods/metadata.ts index 623bd5bed7..933e00f674 100644 --- a/electron/src/mods/metadata.ts +++ b/electron/src/mods/metadata.ts @@ -1,3 +1,4 @@ +import Range from "semver/classes/range.js"; import SemVer from "semver/classes/semver.js"; import { z } from "zod"; @@ -13,10 +14,24 @@ const semver = z.string().transform((str, ctx) => { } }); -// TBD: dependencies, icons, readme +export const semverRange = z.string().transform((str, ctx) => { + try { + return new Range(str); + } catch { + ctx.addIssue({ + code: "custom", + message: "Not a valid SemVer range string", + }); + return z.NEVER; + } +}); + +const ModID = z.string().regex(/^[a-z0-9][a-z0-9_-]{0,48}[a-z0-9]$/g); + +// TBD: icons, readme export const ModMetadata = z.object({ format: z.literal(1), - id: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,48}[a-z0-9]$/g), + id: ModID, entry: z.string().nonempty(), name: z.string().nonempty(), description: z.ostring(), @@ -30,9 +45,21 @@ export const ModMetadata = z.object({ savegameResident: z.boolean().default(true), website: z.string().url().optional(), source: z.string().url().optional(), + dependencies: z + .object({ + id: ModID, + version: semverRange.default("*"), + optional: z.boolean().default(false), + loadOrder: z.enum(["before", "after"]).default("after"), + }) + .array(), + loadOrder: z.enum(["beforeAll", "afterAll"]).optional(), }); export type ModMetadata = z.infer; -export type IpcModMetadata = Omit & { +export type IpcModMetadata = Omit & { version: string; + dependencies: (Omit & { + version: string; + })[]; }; diff --git a/src/js/mods/mod_metadata.ts b/src/js/mods/mod_metadata.ts index cd1987d06f..6f4ac88b4a 100644 --- a/src/js/mods/mod_metadata.ts +++ b/src/js/mods/mod_metadata.ts @@ -5,6 +5,13 @@ export interface ModAuthor { website?: string; } +export interface ModDependency { + id: string; + version: string; + optional: boolean; + loadOrder: "before" | "after"; +} + export interface ModMetadata { // format: 1; id: string; @@ -16,6 +23,8 @@ export interface ModMetadata { savegameResident: boolean; website?: string; source?: string; + dependencies: ModDependency[]; + loadOrder: "beforeAll" | "afterAll"; } export type ModSource = "user" | "distro" | "dev"; @@ -33,6 +42,7 @@ export interface ModInfo { mod: Mod; } -export interface FrozenModMetadata extends Readonly> { +export interface FrozenModMetadata extends Readonly> { authors: ReadonlyArray>; + dependencies: ReadonlyArray>; } From f1f52f8cd1b3c6ce23eeb22e75db42283f156419 Mon Sep 17 00:00:00 2001 From: cody-ferguson <92231243+cody-ferguson@users.noreply.github.com> Date: Tue, 16 Sep 2025 15:56:44 -0500 Subject: [PATCH 02/10] add semver to renderer --- package-lock.json | 57 +++++++++++------------------------------------ package.json | 4 +++- 2 files changed, 16 insertions(+), 45 deletions(-) diff --git a/package-lock.json b/package-lock.json index cd61430dd9..1c71900025 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ "ajv": "^6.10.2", "clipboard-copy": "^3.1.0", "debounce-promise": "^3.1.2", - "howler": "^2.1.2" + "howler": "^2.1.2", + "semver": "^7.7.2" }, "devDependencies": { "@electron/packager": "^18.3.6", @@ -25,6 +26,7 @@ "@types/gulp": "^4.0.9", "@types/gulp-htmlmin": "^1.3.32", "@types/node": "^22.14.0", + "@types/semver": "^7.7.1", "browser-sync": "^2.27.10", "circular-dependency-plugin": "^5.2.2", "css-mqpacker": "^7.0.0", @@ -1520,6 +1522,13 @@ "@types/node": "*" } }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/uglify-js": { "version": "3.17.1", "resolved": "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.17.1.tgz", @@ -1853,22 +1862,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@typescript-eslint/utils": { "version": "8.29.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.29.1.tgz", @@ -12199,19 +12192,6 @@ "node": ">=0.10.0" } }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/make-dir": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", @@ -15969,14 +15949,10 @@ } }, "node_modules/semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", - "dev": true, + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, "bin": { "semver": "bin/semver.js" }, @@ -18980,13 +18956,6 @@ "node": ">=10" } }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, "node_modules/yaml": { "version": "1.10.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", diff --git a/package.json b/package.json index b411733e35..4e0f29b621 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "ajv": "^6.10.2", "clipboard-copy": "^3.1.0", "debounce-promise": "^3.1.2", - "howler": "^2.1.2" + "howler": "^2.1.2", + "semver": "^7.7.2" }, "devDependencies": { "@electron/packager": "^18.3.6", @@ -36,6 +37,7 @@ "@types/gulp": "^4.0.9", "@types/gulp-htmlmin": "^1.3.32", "@types/node": "^22.14.0", + "@types/semver": "^7.7.1", "browser-sync": "^2.27.10", "circular-dependency-plugin": "^5.2.2", "css-mqpacker": "^7.0.0", From 018a2a73be97fbf4363d5363bd8b8ad54b0951ec Mon Sep 17 00:00:00 2001 From: cody-ferguson <92231243+cody-ferguson@users.noreply.github.com> Date: Tue, 16 Sep 2025 15:58:43 -0500 Subject: [PATCH 03/10] Add dependency sorter --- src/js/mods/mod_sorter.ts | 74 +++++++++++++++++++++++++++++++++++++++ src/js/mods/modloader.ts | 2 ++ 2 files changed, 76 insertions(+) create mode 100644 src/js/mods/mod_sorter.ts diff --git a/src/js/mods/mod_sorter.ts b/src/js/mods/mod_sorter.ts new file mode 100644 index 0000000000..649574b96e --- /dev/null +++ b/src/js/mods/mod_sorter.ts @@ -0,0 +1,74 @@ +import { Logger } from "@/core/logging"; +import type { LoadedModEntry } from "./modloader"; +import satisfies from "semver/functions/satisfies"; + +const LOG = new Logger("mod_sorter"); + +export function sortMods(loadedMods: LoadedModEntry[]): LoadedModEntry[] { + const initialGraph = Object.fromEntries( + loadedMods.map(mod => [mod.entry.metadata.id, mod.entry.metadata]) + ); + + const loadOrderGraph: Record = Object.fromEntries( + Object.keys(initialGraph).map(id => [id, []]) + ); + + for (const [modID, metadata] of Object.entries(initialGraph)) { + const deps = metadata.dependencies; + for (const depInfo of deps) { + if (!depInfo.optional && initialGraph[depInfo.id] === undefined) { + LOG.warn(`${modID} has a dependency on ${depInfo.id}, which is not installed.`); + } else if (depInfo.optional && initialGraph[depInfo.id] === undefined) { + LOG.warn(`${modID} has an optional dependency on ${depInfo.id}, which is not installed.`); + continue; + } + const depMetadata = initialGraph[depInfo.id]!; + if (!satisfies(depInfo.version, depMetadata.version)) { + LOG.warn( + `${modID} has a dependency on ${depInfo.id}@${depInfo.version}, which does not match the version installed: ${depMetadata.version}` + ); + } + + if (depInfo.loadOrder === "after") { + loadOrderGraph[modID]!.push(depInfo.id); + } else if (depInfo.loadOrder === "before") { + loadOrderGraph[depInfo.id]!.push(modID); + } + } + if (metadata.loadOrder === "afterAll") { + loadOrderGraph[modID] = Object.keys(loadOrderGraph).filter(id => id !== modID); + continue; + } else if (metadata.loadOrder === "beforeAll") { + for (const id of Object.keys(loadOrderGraph)) { + if (id === modID || loadOrderGraph[id]!.includes(modID)) continue; + loadOrderGraph[id]!.push(modID); + } + continue; + } + } + + const keys = Object.keys(loadOrderGraph); + const used = new Set(); + const sortedModIds = []; + let i: number; + let item: string; + let length: number; + + do { + length = keys.length; + i = 0; + while (i < keys.length) { + if (loadOrderGraph[keys[i]!]!.every(Set.prototype.has, used)) { + item = keys.splice(i, 1)[0]; + sortedModIds.push(item); + used.add(item); + continue; + } + i++; + } + } while (keys.length && keys.length !== length); + + LOG.debug(sortedModIds); + + return sortedModIds.map(id => loadedMods.find(mod => id === mod.entry.metadata.id)); +} diff --git a/src/js/mods/modloader.ts b/src/js/mods/modloader.ts index f071ed2774..974171cbe7 100644 --- a/src/js/mods/modloader.ts +++ b/src/js/mods/modloader.ts @@ -8,6 +8,8 @@ import { MOD_SIGNALS } from "./mod_signals"; const LOG = createLogger("mods"); +export type LoadedModEntry = { entry: ModQueueEntry; mod: Mod }; + export class ModLoader { private readonly mods = new Map(); From a45829c06b9e5ab47fdef5df4c896ad777212fac Mon Sep 17 00:00:00 2001 From: cody-ferguson <92231243+cody-ferguson@users.noreply.github.com> Date: Tue, 16 Sep 2025 15:59:14 -0500 Subject: [PATCH 04/10] Freeze dependency metadata --- src/js/mods/mod.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/js/mods/mod.ts b/src/js/mods/mod.ts index bc1b958265..1540e18edb 100644 --- a/src/js/mods/mod.ts +++ b/src/js/mods/mod.ts @@ -13,6 +13,12 @@ function freezeMetadata(metadata: ModMetadata): FrozenModMetadata { } Object.freeze(metadata.authors); + + for (const dependency of metadata.dependencies) { + Object.freeze(dependency); + } + + Object.freeze(metadata.dependencies); return Object.freeze(metadata); } From e2fa9dd20f5354ee1949a0603da99ce71c361991 Mon Sep 17 00:00:00 2001 From: cody-ferguson <92231243+codeman4033@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:29:54 -0600 Subject: [PATCH 05/10] Actually sort mods. Turns out there's been a bug. I forgot to add the lines to sort the mods in the initMods method. --- src/js/mods/modloader.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/js/mods/modloader.ts b/src/js/mods/modloader.ts index 974171cbe7..313e31f5ca 100644 --- a/src/js/mods/modloader.ts +++ b/src/js/mods/modloader.ts @@ -1,12 +1,13 @@ import { GLOBAL_APP } from "@/core/globals"; import { SavegameStoredMods } from "@/savegame/savegame_typedefs"; -import { createLogger } from "../core/logging"; +import { Logger } from "../core/logging"; import { DisabledMod } from "./disabled_mod"; import { Mod, ModConstructor } from "./mod"; import { ModInfo, ModMetadata, ModQueueEntry } from "./mod_metadata"; import { MOD_SIGNALS } from "./mod_signals"; +import { sortMods } from "./mod_sorter"; -const LOG = createLogger("mods"); +const LOG = new Logger("mods"); export type LoadedModEntry = { entry: ModQueueEntry; mod: Mod }; @@ -81,13 +82,15 @@ export class ModLoader { const queue: ModQueueEntry[] = await ipcRenderer.invoke("get-mods"); // Mods can be parsed and constructed in parallel - const loadedMods = await Promise.all( + const loadedMods: LoadedModEntry[] = await Promise.all( queue.map(async e => ({ entry: e, mod: await this.loadMod(e) })) ); + const sortedMods = sortMods(loadedMods); + // Initialize all mods sequentially // TODO: Also collect early errors from the main process - for (const { entry, mod } of loadedMods) { + for (const { entry, mod } of sortedMods) { this.mods.set(mod.id, { source: entry.source, file: entry.file, From 7ef6cac49807bbdf174edb24cafc4013d0d20cd0 Mon Sep 17 00:00:00 2001 From: cody-ferguson <92231243+cody-ferguson@users.noreply.github.com> Date: Tue, 16 Sep 2025 15:53:25 -0500 Subject: [PATCH 06/10] Add dependencies to metadata --- electron/src/mods/loader.ts | 6 ++++++ electron/src/mods/metadata.ts | 33 ++++++++++++++++++++++++++++++--- src/js/mods/mod_metadata.ts | 12 +++++++++++- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/electron/src/mods/loader.ts b/electron/src/mods/loader.ts index d18f571e53..d80fd636bd 100644 --- a/electron/src/mods/loader.ts +++ b/electron/src/mods/loader.ts @@ -43,6 +43,12 @@ class Mod { disabled: this.disabled, metadata: { ...this.metadata, + dependencies: this.metadata.dependencies.map(dependency => ({ + id: dependency.id, + loadOrder: dependency.loadOrder, + optional: dependency.optional, + version: dependency.version.format(), + })), version: this.metadata.version.format(), }, }; diff --git a/electron/src/mods/metadata.ts b/electron/src/mods/metadata.ts index 623bd5bed7..933e00f674 100644 --- a/electron/src/mods/metadata.ts +++ b/electron/src/mods/metadata.ts @@ -1,3 +1,4 @@ +import Range from "semver/classes/range.js"; import SemVer from "semver/classes/semver.js"; import { z } from "zod"; @@ -13,10 +14,24 @@ const semver = z.string().transform((str, ctx) => { } }); -// TBD: dependencies, icons, readme +export const semverRange = z.string().transform((str, ctx) => { + try { + return new Range(str); + } catch { + ctx.addIssue({ + code: "custom", + message: "Not a valid SemVer range string", + }); + return z.NEVER; + } +}); + +const ModID = z.string().regex(/^[a-z0-9][a-z0-9_-]{0,48}[a-z0-9]$/g); + +// TBD: icons, readme export const ModMetadata = z.object({ format: z.literal(1), - id: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,48}[a-z0-9]$/g), + id: ModID, entry: z.string().nonempty(), name: z.string().nonempty(), description: z.ostring(), @@ -30,9 +45,21 @@ export const ModMetadata = z.object({ savegameResident: z.boolean().default(true), website: z.string().url().optional(), source: z.string().url().optional(), + dependencies: z + .object({ + id: ModID, + version: semverRange.default("*"), + optional: z.boolean().default(false), + loadOrder: z.enum(["before", "after"]).default("after"), + }) + .array(), + loadOrder: z.enum(["beforeAll", "afterAll"]).optional(), }); export type ModMetadata = z.infer; -export type IpcModMetadata = Omit & { +export type IpcModMetadata = Omit & { version: string; + dependencies: (Omit & { + version: string; + })[]; }; diff --git a/src/js/mods/mod_metadata.ts b/src/js/mods/mod_metadata.ts index cd1987d06f..6f4ac88b4a 100644 --- a/src/js/mods/mod_metadata.ts +++ b/src/js/mods/mod_metadata.ts @@ -5,6 +5,13 @@ export interface ModAuthor { website?: string; } +export interface ModDependency { + id: string; + version: string; + optional: boolean; + loadOrder: "before" | "after"; +} + export interface ModMetadata { // format: 1; id: string; @@ -16,6 +23,8 @@ export interface ModMetadata { savegameResident: boolean; website?: string; source?: string; + dependencies: ModDependency[]; + loadOrder: "beforeAll" | "afterAll"; } export type ModSource = "user" | "distro" | "dev"; @@ -33,6 +42,7 @@ export interface ModInfo { mod: Mod; } -export interface FrozenModMetadata extends Readonly> { +export interface FrozenModMetadata extends Readonly> { authors: ReadonlyArray>; + dependencies: ReadonlyArray>; } From 94d5cf212d2b16abcce388fb0c9c444e67372d7e Mon Sep 17 00:00:00 2001 From: cody-ferguson <92231243+cody-ferguson@users.noreply.github.com> Date: Tue, 16 Sep 2025 15:56:44 -0500 Subject: [PATCH 07/10] add semver to renderer --- package-lock.json | 57 +++++++++++------------------------------------ package.json | 4 +++- 2 files changed, 16 insertions(+), 45 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4690ba7945..1a008bf6b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "@msgpack/msgpack": "^3.1.2", "ajv": "^6.10.2", "debounce-promise": "^3.1.2", - "howler": "^2.1.2" + "howler": "^2.1.2", + "semver": "^7.7.2" }, "devDependencies": { "@electron/packager": "^18.3.6", @@ -24,6 +25,7 @@ "@types/gulp": "^4.0.9", "@types/gulp-htmlmin": "^1.3.32", "@types/node": "^22.14.0", + "@types/semver": "^7.7.1", "browser-sync": "^2.27.10", "circular-dependency-plugin": "^5.2.2", "css-mqpacker": "^7.0.0", @@ -1519,6 +1521,13 @@ "@types/node": "*" } }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/uglify-js": { "version": "3.17.1", "resolved": "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.17.1.tgz", @@ -1852,22 +1861,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@typescript-eslint/utils": { "version": "8.29.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.29.1.tgz", @@ -12232,19 +12225,6 @@ "node": ">=0.10.0" } }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/make-dir": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", @@ -16010,14 +15990,10 @@ } }, "node_modules/semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", - "dev": true, + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, "bin": { "semver": "bin/semver.js" }, @@ -19021,13 +18997,6 @@ "node": ">=10" } }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, "node_modules/yaml": { "version": "1.10.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", diff --git a/package.json b/package.json index ff8be08445..12fda23f51 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "@msgpack/msgpack": "^3.1.2", "ajv": "^6.10.2", "debounce-promise": "^3.1.2", - "howler": "^2.1.2" + "howler": "^2.1.2", + "semver": "^7.7.2" }, "devDependencies": { "@electron/packager": "^18.3.6", @@ -35,6 +36,7 @@ "@types/gulp": "^4.0.9", "@types/gulp-htmlmin": "^1.3.32", "@types/node": "^22.14.0", + "@types/semver": "^7.7.1", "browser-sync": "^2.27.10", "circular-dependency-plugin": "^5.2.2", "css-mqpacker": "^7.0.0", From e492c3f0d0b658e0853d1f536d374ef76c768ced Mon Sep 17 00:00:00 2001 From: cody-ferguson <92231243+cody-ferguson@users.noreply.github.com> Date: Tue, 16 Sep 2025 15:58:43 -0500 Subject: [PATCH 08/10] Add dependency sorter --- src/js/mods/mod_sorter.ts | 74 +++++++++++++++++++++++++++++++++++++++ src/js/mods/modloader.ts | 2 ++ 2 files changed, 76 insertions(+) create mode 100644 src/js/mods/mod_sorter.ts diff --git a/src/js/mods/mod_sorter.ts b/src/js/mods/mod_sorter.ts new file mode 100644 index 0000000000..649574b96e --- /dev/null +++ b/src/js/mods/mod_sorter.ts @@ -0,0 +1,74 @@ +import { Logger } from "@/core/logging"; +import type { LoadedModEntry } from "./modloader"; +import satisfies from "semver/functions/satisfies"; + +const LOG = new Logger("mod_sorter"); + +export function sortMods(loadedMods: LoadedModEntry[]): LoadedModEntry[] { + const initialGraph = Object.fromEntries( + loadedMods.map(mod => [mod.entry.metadata.id, mod.entry.metadata]) + ); + + const loadOrderGraph: Record = Object.fromEntries( + Object.keys(initialGraph).map(id => [id, []]) + ); + + for (const [modID, metadata] of Object.entries(initialGraph)) { + const deps = metadata.dependencies; + for (const depInfo of deps) { + if (!depInfo.optional && initialGraph[depInfo.id] === undefined) { + LOG.warn(`${modID} has a dependency on ${depInfo.id}, which is not installed.`); + } else if (depInfo.optional && initialGraph[depInfo.id] === undefined) { + LOG.warn(`${modID} has an optional dependency on ${depInfo.id}, which is not installed.`); + continue; + } + const depMetadata = initialGraph[depInfo.id]!; + if (!satisfies(depInfo.version, depMetadata.version)) { + LOG.warn( + `${modID} has a dependency on ${depInfo.id}@${depInfo.version}, which does not match the version installed: ${depMetadata.version}` + ); + } + + if (depInfo.loadOrder === "after") { + loadOrderGraph[modID]!.push(depInfo.id); + } else if (depInfo.loadOrder === "before") { + loadOrderGraph[depInfo.id]!.push(modID); + } + } + if (metadata.loadOrder === "afterAll") { + loadOrderGraph[modID] = Object.keys(loadOrderGraph).filter(id => id !== modID); + continue; + } else if (metadata.loadOrder === "beforeAll") { + for (const id of Object.keys(loadOrderGraph)) { + if (id === modID || loadOrderGraph[id]!.includes(modID)) continue; + loadOrderGraph[id]!.push(modID); + } + continue; + } + } + + const keys = Object.keys(loadOrderGraph); + const used = new Set(); + const sortedModIds = []; + let i: number; + let item: string; + let length: number; + + do { + length = keys.length; + i = 0; + while (i < keys.length) { + if (loadOrderGraph[keys[i]!]!.every(Set.prototype.has, used)) { + item = keys.splice(i, 1)[0]; + sortedModIds.push(item); + used.add(item); + continue; + } + i++; + } + } while (keys.length && keys.length !== length); + + LOG.debug(sortedModIds); + + return sortedModIds.map(id => loadedMods.find(mod => id === mod.entry.metadata.id)); +} diff --git a/src/js/mods/modloader.ts b/src/js/mods/modloader.ts index f071ed2774..974171cbe7 100644 --- a/src/js/mods/modloader.ts +++ b/src/js/mods/modloader.ts @@ -8,6 +8,8 @@ import { MOD_SIGNALS } from "./mod_signals"; const LOG = createLogger("mods"); +export type LoadedModEntry = { entry: ModQueueEntry; mod: Mod }; + export class ModLoader { private readonly mods = new Map(); From bfdf4a80b6b086697033d99c4b6d064bed66e5e8 Mon Sep 17 00:00:00 2001 From: cody-ferguson <92231243+cody-ferguson@users.noreply.github.com> Date: Tue, 16 Sep 2025 15:59:14 -0500 Subject: [PATCH 09/10] Freeze dependency metadata --- src/js/mods/mod.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/js/mods/mod.ts b/src/js/mods/mod.ts index bc1b958265..1540e18edb 100644 --- a/src/js/mods/mod.ts +++ b/src/js/mods/mod.ts @@ -13,6 +13,12 @@ function freezeMetadata(metadata: ModMetadata): FrozenModMetadata { } Object.freeze(metadata.authors); + + for (const dependency of metadata.dependencies) { + Object.freeze(dependency); + } + + Object.freeze(metadata.dependencies); return Object.freeze(metadata); } From 7d57fb9b71ac3745be9a8e0b146c875562256f48 Mon Sep 17 00:00:00 2001 From: cody-ferguson <92231243+codeman4033@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:29:54 -0600 Subject: [PATCH 10/10] Actually sort mods. Turns out there's been a bug. I forgot to add the lines to sort the mods in the initMods method. --- src/js/mods/modloader.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/js/mods/modloader.ts b/src/js/mods/modloader.ts index 974171cbe7..313e31f5ca 100644 --- a/src/js/mods/modloader.ts +++ b/src/js/mods/modloader.ts @@ -1,12 +1,13 @@ import { GLOBAL_APP } from "@/core/globals"; import { SavegameStoredMods } from "@/savegame/savegame_typedefs"; -import { createLogger } from "../core/logging"; +import { Logger } from "../core/logging"; import { DisabledMod } from "./disabled_mod"; import { Mod, ModConstructor } from "./mod"; import { ModInfo, ModMetadata, ModQueueEntry } from "./mod_metadata"; import { MOD_SIGNALS } from "./mod_signals"; +import { sortMods } from "./mod_sorter"; -const LOG = createLogger("mods"); +const LOG = new Logger("mods"); export type LoadedModEntry = { entry: ModQueueEntry; mod: Mod }; @@ -81,13 +82,15 @@ export class ModLoader { const queue: ModQueueEntry[] = await ipcRenderer.invoke("get-mods"); // Mods can be parsed and constructed in parallel - const loadedMods = await Promise.all( + const loadedMods: LoadedModEntry[] = await Promise.all( queue.map(async e => ({ entry: e, mod: await this.loadMod(e) })) ); + const sortedMods = sortMods(loadedMods); + // Initialize all mods sequentially // TODO: Also collect early errors from the main process - for (const { entry, mod } of loadedMods) { + for (const { entry, mod } of sortedMods) { this.mods.set(mod.id, { source: entry.source, file: entry.file,