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
+1 -3
View File
@@ -24,7 +24,7 @@ import {
PROTOCOL_VERSION,
ServerMessage,
} from "$/common/protocol.ts";
import type { AtlasListing, ModListing, RecipeBook } from "$/common/mod_loader.ts";
import type { ModListing, RecipeBook } from "$/common/mod_loader.ts";
import type { WorldgenSetup } from "$/common/generation.ts";
import { CYCLE_TICKS, format_clock, time_of_day, TIMES_OF_DAY } from "$/common/time.ts";
import { ModRuntime, run_guarded } from "./mod_runtime.ts";
@@ -91,7 +91,6 @@ interface SavedTile {
// the loaded mods, see load_mods.ts
export interface GameMods {
listings: ModListing[];
atlas: AtlasListing;
recipes: RecipeBook;
worldgen?: WorldgenSetup;
runtime: ModRuntime;
@@ -485,7 +484,6 @@ export class GameServer {
type: "welcome",
protocol: PROTOCOL_VERSION,
seed: this.world.seed,
atlas: this.#mods.atlas,
mods: this.#mods.listings,
} satisfies ServerMessage,
),
+1 -2
View File
@@ -1,4 +1,4 @@
import type { AtlasListing, ModData, ModListing } from "$/common/mod_loader.ts";
import type { ModData, ModListing } from "$/common/mod_loader.ts";
// messages between server/main.ts (the host) and the game server worker
@@ -7,7 +7,6 @@ export type HostToGame =
type: "init";
save: string | undefined;
default_seed: string;
atlas: AtlasListing;
// in load order, with the scripts' code since the worker can't read files
mods: { listing: ModListing; data: ModData; server_code?: string; worldgen_code?: string }[];
}
+1 -3
View File
@@ -1,7 +1,7 @@
// starts a game server with its mods: registers their data, then imports worldgen and server scripts.
// the worker loads built mods, tests can load mods straight from their source folders
import { register_mod_data } from "$/common/mod_loader.ts";
import type { AtlasListing, ModData, ModListing } from "$/common/mod_loader.ts";
import type { ModData, ModListing } from "$/common/mod_loader.ts";
import { load_worldgen } from "$/common/worldgen_loader.ts";
import { GameHost, GameServer } from "./game_server.ts";
import { ModRuntime } from "./mod_runtime.ts";
@@ -18,7 +18,6 @@ export async function start_game(
host: GameHost,
save: string | undefined,
default_seed: string,
atlas: AtlasListing,
mods: ServerModSource[],
): Promise<GameServer> {
const recipes = register_mod_data(mods.map((mod) => ({ id: mod.listing.id, data: mod.data })));
@@ -31,7 +30,6 @@ export async function start_game(
const runtime = new ModRuntime();
const game = new GameServer(host, save, default_seed, {
listings: mods.map((mod) => mod.listing),
atlas,
recipes,
worldgen,
runtime,
-1
View File
@@ -54,7 +54,6 @@ async function init(message: Extract<HostToGame, { type: "init" }>) {
},
message.save,
message.default_seed,
message.atlas,
message.mods.map((mod) => ({
listing: mod.listing,
data: mod.data,
+35 -38
View File
@@ -1,20 +1,20 @@
// the part of a game server that has permissions: files and networking. the game itself runs in a worker with none.
// server/main.ts runs it as a dedicated server, and the desktop app runs it for singleplayer
import { serveDir } from "@std/http/file-server";
import { isAbsolute, join } from "@std/path";
import { join } from "@std/path";
import { GameToHost, HostToGame } from "./game/host_protocol.ts";
import type { ServerModIndex } from "../build.ts";
import { load_bmods } from "./load_bmods.ts";
const MAX_MESSAGE_SIZE = 4096;
// in build/, see build.ts
export const ENGINE_TEXTURES_INDEX = "assets/textures/index.json";
const SHUTDOWN_TIMEOUT_MS = 5000;
export interface HostOptions {
// what deno task build made: the client, assets and mods players download
// what deno task build made: the client and the engine's assets
build_dir: string;
// the server scripts, with index.json
// the .bmod files to load
server_mods_dir: string;
// what the paths in server_mods/index.json are relative to, the folder the build ran in
root: string;
world_file: string;
seed?: string;
// the game can't go on, like the worker crashing
@@ -27,9 +27,12 @@ export interface Host {
shutdown(): Promise<void>;
}
export function start_host(options: HostOptions): Host {
const mods = read_mods(options);
console.log(`Mods: ${mods.mods.map((mod) => `${mod.listing.id} ${mod.listing.version}`).join(", ") || "none"}`);
// throws when the mods have problems, saying what they are
export async function start_host(options: HostOptions): Promise<Host> {
const mods = await load_bmods(options.server_mods_dir, engine_texture_ids(options.build_dir));
console.log(`Mods: ${mods.map((mod) => `${mod.listing.id} ${mod.listing.version}`).join(", ") || "none"}`);
// what players download, by path
const downloads = new Map(mods.map((mod) => [`/${mod.listing.file}`, mod.client_bytes]));
const game = new Worker(new URL("./game/worker.ts", import.meta.url), {
type: "module",
@@ -78,7 +81,12 @@ export function start_host(options: HostOptions): Host {
type: "init",
save: read_world(options.world_file),
default_seed: options.seed ?? crypto.randomUUID(),
...mods,
mods: mods.map(({ listing, bmod }) => ({
listing,
data: bmod.data,
server_code: bmod.scripts.server,
worldgen_code: bmod.scripts.worldgen,
})),
});
function handle_socket(socket: WebSocket) {
@@ -117,6 +125,17 @@ export function start_host(options: HostOptions): Host {
if (url.pathname === "/") {
return Response.redirect(new URL("/client/", url), 302);
}
const download = downloads.get(url.pathname);
if (download) {
// named by its hash, so it never changes and pages on other origins can cache it forever
return new Response(download, {
headers: {
"content-type": "application/zip",
"access-control-allow-origin": "*",
"cache-control": "public, max-age=31536000, immutable",
},
});
}
return serve_static(options.build_dir, req, url);
},
@@ -155,40 +174,18 @@ function write_world(file: string, data: string) {
Deno.renameSync(`${file}.tmp`, file);
}
// what deno task build made. no index means the build failed or never ran, and the game never runs without its mods
function read_mods(options: HostOptions): Pick<Extract<HostToGame, { type: "init" }>, "mods" | "atlas"> {
let index: ServerModIndex;
try {
index = JSON.parse(Deno.readTextFileSync(join(options.server_mods_dir, "index.json")));
} catch {
throw new Error(
`No ${options.server_mods_dir}/index.json, run deno task build first (it also reports mod errors)`,
);
}
const from_root = (path: string) => isAbsolute(path) ? path : join(options.root, path);
return {
atlas: index.atlas,
mods: index.mods.map(({ listing, server }) => ({
listing,
data: JSON.parse(Deno.readTextFileSync(join(options.build_dir, listing.data))),
server_code: server ? Deno.readTextFileSync(from_root(server)) : undefined,
worldgen_code: listing.worldgen
? Deno.readTextFileSync(join(options.build_dir, listing.worldgen))
: undefined,
})),
};
// the engine's textures, listed by the build next to the client's assets. engine:missing is drawn by code
export function engine_texture_ids(build_dir: string): string[] {
const names: string[] = JSON.parse(Deno.readTextFileSync(join(build_dir, ENGINE_TEXTURES_INDEX)));
return ["engine:missing", ...names.map((name) => `engine:${name}`)];
}
// mod files and the atlas are named by their hash, so they never change and pages on other origins can load them
// the client and the engine's assets. pages on other origins load the engine's textures from here too
export async function serve_static(build_dir: string, req: Request, url: URL) {
const response = await serveDir(req, { fsRoot: build_dir, quiet: true });
const shared = url.pathname.startsWith("/mods/") || url.pathname.startsWith("/assets/");
if (shared && response.ok) {
if (url.pathname.startsWith("/assets/") && response.ok) {
const headers = new Headers(response.headers);
headers.set("Access-Control-Allow-Origin", "*");
if (url.pathname.startsWith("/mods/") || /^\/assets\/sprites\/textures\.[0-9a-f]+\./.test(url.pathname)) {
headers.set("Cache-Control", "public, max-age=31536000, immutable");
}
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
}
return response;
+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("");
}
+1 -2
View File
@@ -5,10 +5,9 @@ const PORT = Number(Deno.env.get("PORT") ?? 8000);
let host: Host;
try {
host = start_host({
host = await start_host({
build_dir: Deno.env.get("BUILD_DIR") ?? "build",
server_mods_dir: Deno.env.get("SERVER_MODS_DIR") ?? "server_mods",
root: ".",
world_file: Deno.env.get("WORLD_FILE") ?? "world.json",
seed: Deno.env.get("SEED"),
on_fatal(message) {