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
+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;