New modding system

This commit is contained in:
2026-09-26 17:33:07 -03:00
parent 86d5c39eab
commit 243ff52063
25 changed files with 1089 additions and 512 deletions
+111
View File
@@ -0,0 +1,111 @@
// the server's mods: every .bmod in server_mods/, checked, sorted so dependencies load first, and with the copy
// players download (the same mod without its server script) ready to serve
import { join } from "@std/path";
import { type Bmod, BMOD_EXTENSION, client_copy, read_bmod, write_bmod } from "$/common/bmod.ts";
import { check_references, load_order, type LoadedMod } from "$/common/mod_check.ts";
import type { ModListing } from "$/common/mod_loader.ts";
export interface ServerMod {
bmod: Bmod;
listing: ModListing;
// what's served at listing.file
client_bytes: Uint8Array<ArrayBuffer>;
}
export class ModsError extends Error {
constructor(problems: string[]) {
super(`Mods have errors:\n${problems.map((p) => ` ${p}`).join("\n")}`);
this.name = "ModsError";
}
}
// engine_textures are the engine: texture ids, for checking the textures mods use. a mod with problems stops the
// whole server from starting, the game never runs with some mods missing
export async function load_bmods(dir: string, engine_textures: Iterable<string>): Promise<ServerMod[]> {
let files: string[];
try {
files = [...Deno.readDirSync(dir)]
.filter((entry) => entry.isFile && entry.name.endsWith(BMOD_EXTENSION))
.map((entry) => entry.name)
.sort();
} catch (e) {
if (e instanceof Deno.errors.NotFound) return [];
throw e;
}
const problems: string[] = [];
const by_id = new Map<string, { file: string; bmod: Bmod }>();
for (const file of files) {
try {
const bmod = read_bmod(Deno.readFileSync(join(dir, file)), file);
const other = by_id.get(bmod.manifest.id);
if (other) {
problems.push(`${file} and ${other.file} are both the mod "${bmod.manifest.id}"`);
continue;
}
by_id.set(bmod.manifest.id, { file, bmod });
} catch (e) {
problems.push((e as Error).message);
}
}
// the same checks check-mods runs on mod folders
const loaded = [...by_id.values()].map(({ file, bmod }) => as_loaded_mod(file, bmod));
check_references(loaded, engine_textures);
for (const mod of loaded) {
const dependencies = (mod.manifest?.dependencies ?? []) as { id: string }[];
for (const { id } of dependencies) {
if (!by_id.has(id)) mod.report.errors.push(`depends on "${id}", which isn't in ${dir}`);
}
problems.push(...mod.report.errors.map((error) => `${mod.dir}: ${error}`));
for (const warning of mod.report.warnings) console.warn(`${mod.dir}: ${warning}`);
}
let ordered: LoadedMod[] = [];
try {
ordered = load_order(loaded);
} catch (e) {
problems.push((e as Error).message);
}
if (problems.length > 0) throw new ModsError(problems);
return await Promise.all(ordered.map(async (mod) => {
const bmod = by_id.get(mod.id)!.bmod;
const client_bytes = write_bmod(client_copy(bmod));
const sha256 = await hex_sha256(client_bytes);
return {
bmod,
client_bytes,
listing: {
id: bmod.manifest.id,
name: bmod.manifest.name,
version: bmod.manifest.version,
sha256,
file: `mods/${sha256}${BMOD_EXTENSION}`,
size: client_bytes.length,
},
};
}));
}
// in the shape the checks in common/mod_check.ts take
function as_loaded_mod(file: string, bmod: Bmod): LoadedMod {
const entries = <T>(list: T[]) => list.map((json) => ({ file: `${file} data.json`, json }));
return {
id: bmod.manifest.id,
dir: file,
report: { id: bmod.manifest.id, errors: [], warnings: [] },
manifest: bmod.manifest as unknown as Record<string, unknown>,
blocks: entries(bmod.data.blocks),
models: entries(bmod.data.models),
items: entries(bmod.data.items),
recipes: entries(bmod.data.recipes),
ores: entries(bmod.data.ores),
textures: [...bmod.textures.keys()],
texture_files: new Map(),
};
}
async function hex_sha256(bytes: Uint8Array<ArrayBuffer>) {
const digest = await crypto.subtle.digest("SHA-256", bytes);
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
}