Skip to content
Open
6 changes: 6 additions & 0 deletions electron/src/mods/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
},
};
Expand Down
33 changes: 30 additions & 3 deletions electron/src/mods/metadata.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Range from "semver/classes/range.js";
import SemVer from "semver/classes/semver.js";
import { z } from "zod";

Expand All @@ -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(),
Expand All @@ -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<typeof ModMetadata>;
export type IpcModMetadata = Omit<ModMetadata, "version"> & {
export type IpcModMetadata = Omit<ModMetadata, "version" | "dependencies"> & {
version: string;
dependencies: (Omit<ModMetadata["dependencies"][number], "version"> & {
version: string;
})[];
};
57 changes: 13 additions & 44 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
"css-mqpacker": "^7.0.0",
"cssnano": "^4.1.10",
Expand Down
6 changes: 6 additions & 0 deletions src/js/mods/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
12 changes: 11 additions & 1 deletion src/js/mods/mod_metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,6 +23,8 @@ export interface ModMetadata {
savegameResident: boolean;
website?: string;
source?: string;
dependencies: ModDependency[];
loadOrder: "beforeAll" | "afterAll";
}

export type ModSource = "user" | "distro" | "dev";
Expand All @@ -33,6 +42,7 @@ export interface ModInfo {
mod: Mod;
}

export interface FrozenModMetadata extends Readonly<Omit<ModMetadata, "authors">> {
export interface FrozenModMetadata extends Readonly<Omit<ModMetadata, "authors" | "dependencies">> {
authors: ReadonlyArray<Readonly<ModAuthor>>;
dependencies: ReadonlyArray<Readonly<ModDependency>>;
}
74 changes: 74 additions & 0 deletions src/js/mods/mod_sorter.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]> = 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));
}
9 changes: 7 additions & 2 deletions src/js/mods/modloader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ 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 = new Logger("mods");

export type LoadedModEntry = { entry: ModQueueEntry; mod: Mod };

export class ModLoader {
private readonly mods = new Map<string, ModInfo>();

Expand Down Expand Up @@ -79,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,
Expand Down