New modding system
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
// .bmod files: packing mods, reading them back, and the server loading a folder of them
|
||||
import { assert, assertEquals, assertRejects, assertStringIncludes, assertThrows } from "@std/assert";
|
||||
import { zipSync } from "fflate";
|
||||
import { atlas_layout } from "$/client/atlas.ts";
|
||||
import { read_bmod, write_bmod } from "$/common/bmod.ts";
|
||||
import { load_and_check } from "$/tools/check_mods.ts";
|
||||
import { pack_mod } from "$/tools/pack_mod.ts";
|
||||
import { create_mod } from "$/tools/new_mod.ts";
|
||||
import { load_bmods } from "$/server/load_bmods.ts";
|
||||
import { start_host } from "$/server/host.ts";
|
||||
import { copy_dir } from "./helpers.ts";
|
||||
|
||||
const ENGINE_TEXTURES = ["engine:missing", ...Array.from({ length: 9 }, (_, i) => `engine:break_${i}`)];
|
||||
|
||||
// mods/bworld and one made from the template, packed into a folder of .bmod files
|
||||
async function packed_mods() {
|
||||
const sources = Deno.makeTempDirSync({ prefix: "bworld_bmod_src_" });
|
||||
copy_dir("mods/bworld", `${sources}/bworld`);
|
||||
create_mod("copper_tools", "Copper Tools", sources);
|
||||
const mods = load_and_check(sources);
|
||||
for (const mod of mods) assertEquals(mod.report.errors, [], mod.id);
|
||||
|
||||
const folder = Deno.makeTempDirSync({ prefix: "bworld_bmod_" });
|
||||
for (const mod of mods) Deno.writeFileSync(`${folder}/${mod.id}.bmod`, await pack_mod(mod));
|
||||
return { sources, folder, mods };
|
||||
}
|
||||
|
||||
Deno.test("packing a mod keeps its data, scripts, textures and credits, and gives the same bytes every time", async () => {
|
||||
const { sources, folder, mods } = await packed_mods();
|
||||
const template = mods.find((mod) => mod.id === "copper_tools")!;
|
||||
const bytes = Deno.readFileSync(`${folder}/copper_tools.bmod`);
|
||||
assertEquals(await pack_mod(template), bytes);
|
||||
|
||||
const bmod = read_bmod(bytes, "copper_tools.bmod");
|
||||
assertEquals(bmod.manifest.id, "copper_tools");
|
||||
assertEquals(bmod.data.blocks.map((b) => b.id), template.blocks.map((b) => b.json.id));
|
||||
assertEquals(bmod.data.recipes, template.recipes.map((r) => r.json));
|
||||
assertEquals([...bmod.textures.keys()].sort(), [...template.textures].sort());
|
||||
assertEquals(Object.keys(bmod.scripts).sort(), ["client", "server", "worldgen"]);
|
||||
assertStringIncludes(bmod.scripts.server!, "setup");
|
||||
assert(bmod.credits);
|
||||
|
||||
const bworld = read_bmod(Deno.readFileSync(`${folder}/bworld.bmod`), "bworld.bmod");
|
||||
assertEquals(bworld.data.blocks.length, 21);
|
||||
assertEquals(Object.keys(bworld.scripts), ["server"]);
|
||||
|
||||
Deno.removeSync(sources, { recursive: true });
|
||||
Deno.removeSync(folder, { recursive: true });
|
||||
});
|
||||
|
||||
Deno.test("broken .bmod files are turned away, saying why", () => {
|
||||
const text = (value: unknown) => new TextEncoder().encode(JSON.stringify(value));
|
||||
const manifest = { format_version: 1, id: "broken", name: "Broken", version: "1.0.0" };
|
||||
const zip = (files: Record<string, Uint8Array>) => zipSync(files);
|
||||
|
||||
assertThrows(() => read_bmod(new TextEncoder().encode("not a zip"), "a.bmod"), Error, "isn't a readable .bmod");
|
||||
assertThrows(() => read_bmod(zip({ "data.json": text({}) }), "b.bmod"), Error, "manifest.json is missing");
|
||||
assertThrows(
|
||||
() =>
|
||||
read_bmod(
|
||||
zip({
|
||||
"manifest.json": text(manifest),
|
||||
"data.json": text({ blocks: [{ id: "other:thing", textures: "broken:x" }, { id: "broken:y" }] }),
|
||||
}),
|
||||
"c.bmod",
|
||||
),
|
||||
Error,
|
||||
"other:thing isn't in this mod's namespace",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
read_bmod(
|
||||
zip({
|
||||
"manifest.json": text({ ...manifest, scripts: { server: "scripts/server.js" } }),
|
||||
"data.json": text({}),
|
||||
}),
|
||||
"d.bmod",
|
||||
),
|
||||
Error,
|
||||
"scripts.server scripts/server.js isn't in the file",
|
||||
);
|
||||
// a texture that isn't 16x16
|
||||
const ui = Deno.readFileSync("assets/sprites/ui.png");
|
||||
assertThrows(
|
||||
() =>
|
||||
read_bmod(
|
||||
zip({ "manifest.json": text(manifest), "data.json": text({}), "textures/big.png": ui }),
|
||||
"e.bmod",
|
||||
),
|
||||
Error,
|
||||
"textures must be 16×16",
|
||||
);
|
||||
// too much once unzipped, without unzipping it all
|
||||
const huge = new Uint8Array(65 * 1024 * 1024);
|
||||
assertThrows(
|
||||
() => read_bmod(zip({ "manifest.json": text(manifest), "data.json": text({}), "big.bin": huge }), "f.bmod"),
|
||||
Error,
|
||||
"unzips to more than",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("the server loads every .bmod in its folder, dependencies first, and players get them without server scripts", async () => {
|
||||
const { sources, folder } = await packed_mods();
|
||||
const mods = await load_bmods(folder, ENGINE_TEXTURES);
|
||||
assertEquals(mods.map((mod) => mod.listing.id), ["bworld", "copper_tools"]);
|
||||
|
||||
for (const mod of mods) {
|
||||
assertEquals(mod.listing.file, `mods/${mod.listing.sha256}.bmod`);
|
||||
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", mod.client_bytes));
|
||||
assertEquals([...digest].map((b) => b.toString(16).padStart(2, "0")).join(""), mod.listing.sha256);
|
||||
const copy = read_bmod(mod.client_bytes, mod.listing.file);
|
||||
assertEquals(copy.scripts.server, undefined, "server scripts never go to players");
|
||||
assertEquals(copy.data, mod.bmod.data);
|
||||
}
|
||||
assert(mods[1].bmod.scripts.server, "the server still has it");
|
||||
|
||||
// the same mod twice, and a mod whose dependency isn't there
|
||||
Deno.copyFileSync(`${folder}/copper_tools.bmod`, `${folder}/copper_tools_again.bmod`);
|
||||
await assertRejects(() => load_bmods(folder, ENGINE_TEXTURES), Error, 'are both the mod "copper_tools"');
|
||||
Deno.removeSync(`${folder}/copper_tools_again.bmod`);
|
||||
Deno.removeSync(`${folder}/bworld.bmod`);
|
||||
await assertRejects(() => load_bmods(folder, ENGINE_TEXTURES), Error, `depends on "bworld"`);
|
||||
|
||||
Deno.removeSync(sources, { recursive: true });
|
||||
Deno.removeSync(folder, { recursive: true });
|
||||
});
|
||||
|
||||
Deno.test("the host serves each mod's player copy by its hash, and a game made from .bmod files plays", async () => {
|
||||
const { sources, folder } = await packed_mods();
|
||||
const build = Deno.makeTempDirSync({ prefix: "bworld_build_" });
|
||||
Deno.mkdirSync(`${build}/assets/textures`, { recursive: true });
|
||||
Deno.writeTextFileSync(
|
||||
`${build}/assets/textures/index.json`,
|
||||
JSON.stringify(ENGINE_TEXTURES.slice(1).map((id) => id.split(":")[1])),
|
||||
);
|
||||
const world = `${build}/world.json`;
|
||||
|
||||
const host = await start_host({
|
||||
build_dir: build,
|
||||
server_mods_dir: folder,
|
||||
world_file: world,
|
||||
seed: "bmod-seed",
|
||||
on_fatal: (message) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
});
|
||||
const listings = (await load_bmods(folder, ENGINE_TEXTURES)).map((mod) => mod.listing);
|
||||
for (const listing of listings) {
|
||||
const response = await host.handle(new Request(`http://localhost/${listing.file}`));
|
||||
assertEquals(response.status, 200);
|
||||
assertEquals(response.headers.get("access-control-allow-origin"), "*");
|
||||
const bmod = read_bmod(new Uint8Array(await response.arrayBuffer()), listing.file);
|
||||
assertEquals(bmod.manifest.id, listing.id);
|
||||
}
|
||||
assertEquals((await host.handle(new Request("http://localhost/mods/nope.bmod"))).status, 404);
|
||||
|
||||
// the game worker loaded both mods, including the template's server script, and saves
|
||||
await host.shutdown();
|
||||
const saved = JSON.parse(Deno.readTextFileSync(world));
|
||||
assertEquals(saved.seed, "bmod-seed");
|
||||
|
||||
for (const dir of [sources, folder, build]) Deno.removeSync(dir, { recursive: true });
|
||||
});
|
||||
|
||||
Deno.test("a mod with its own deno.json is bundled with it", async () => {
|
||||
const sources = Deno.makeTempDirSync({ prefix: "bworld_bmod_project_" });
|
||||
copy_dir("mods/bworld", `${sources}/bworld`);
|
||||
const mod = `${sources}/project`;
|
||||
Deno.mkdirSync(`${mod}/scripts/lib`, { recursive: true });
|
||||
Deno.writeTextFileSync(
|
||||
`${mod}/manifest.json`,
|
||||
JSON.stringify({
|
||||
format_version: 1,
|
||||
id: "project",
|
||||
name: "Project",
|
||||
version: "1.0.0",
|
||||
scripts: { server: "scripts/server.ts" },
|
||||
}),
|
||||
);
|
||||
// only the mod's own import map knows "lib/"
|
||||
Deno.writeTextFileSync(`${mod}/deno.json`, JSON.stringify({ imports: { "lib/": "./scripts/lib/" } }));
|
||||
Deno.writeTextFileSync(`${mod}/scripts/lib/greeting.ts`, `export const GREETING = "hello from a deno project";\n`);
|
||||
Deno.writeTextFileSync(
|
||||
`${mod}/scripts/server.ts`,
|
||||
`import { GREETING } from "lib/greeting.ts";\n// deno-lint-ignore no-explicit-any\nexport function setup(ctx: any) {\n\tctx.log(GREETING);\n}\n`,
|
||||
);
|
||||
|
||||
const project = load_and_check(sources).find((m) => m.id === "project")!;
|
||||
assertEquals(project.report.errors, []);
|
||||
const bmod = read_bmod(await pack_mod(project), "project.bmod");
|
||||
assertStringIncludes(bmod.scripts.server!, "hello from a deno project");
|
||||
Deno.removeSync(sources, { recursive: true });
|
||||
});
|
||||
|
||||
Deno.test("the atlas puts the missing texture first and the rest in order", () => {
|
||||
const layout = atlas_layout(["b:two", "a:one", "engine:break_0", "a:one"]);
|
||||
assertEquals(layout.size, 32);
|
||||
assertEquals(layout.regions, {
|
||||
"engine:missing": { x: 0, y: 0 },
|
||||
"a:one": { x: 1, y: 0 },
|
||||
"b:two": { x: 0, y: 1 },
|
||||
"engine:break_0": { x: 1, y: 1 },
|
||||
});
|
||||
assertEquals(atlas_layout(Array.from({ length: 100 }, (_, i) => `m:t${i}`)).size, 256);
|
||||
});
|
||||
|
||||
// write_bmod without anything optional still makes a file read_bmod takes
|
||||
Deno.test("a mod with only data packs and reads", () => {
|
||||
const bmod = {
|
||||
manifest: { format_version: 1, id: "tiny", name: "Tiny", version: "1.0.0" },
|
||||
data: { blocks: [], models: [], items: [], recipes: [], ores: [] },
|
||||
scripts: {},
|
||||
textures: new Map(),
|
||||
};
|
||||
assertEquals(read_bmod(write_bmod(bmod), "tiny.bmod").manifest.id, "tiny");
|
||||
});
|
||||
@@ -30,7 +30,7 @@ Deno.test("welcome lists what to download, and nothing happens until ready", asy
|
||||
const [welcome] = take(2);
|
||||
assertEquals(welcome.type, "welcome");
|
||||
assertEquals(welcome.mods.map((m: { id: string }) => m.id), ["bworld"]);
|
||||
assert(welcome.atlas.png && welcome.atlas.sha256);
|
||||
assert(welcome.mods.every((m: { sha256: string; file: string }) => m.sha256 && m.file));
|
||||
assert(!("players" in welcome) && !("changes" in welcome), "welcome shouldn't have world state");
|
||||
|
||||
// not in the world yet: others don't hear about bob, and bob can't act
|
||||
|
||||
+4
-5
@@ -19,9 +19,9 @@ export function mod_sources(mods_dir: string): ServerModSource[] {
|
||||
id: mod.id,
|
||||
name: String(mod.manifest?.name),
|
||||
version: String(mod.manifest?.version),
|
||||
hash: "source",
|
||||
data: `mods/${mod.id}/source/data.json`,
|
||||
sha256: { data: "" },
|
||||
sha256: "source",
|
||||
file: `mods/${mod.id}.bmod`,
|
||||
size: 0,
|
||||
},
|
||||
data: {
|
||||
blocks: mod.blocks.map((b) => b.json),
|
||||
@@ -50,8 +50,7 @@ export async function test_game(mods_dir: string, save?: string, seed = "test-se
|
||||
closed.add(conn);
|
||||
},
|
||||
};
|
||||
const atlas = { png: "atlas.png", json: "atlas.json", sha256: { png: "", json: "" } };
|
||||
const game: GameServer = await start_game(host, save, seed, atlas, mod_sources(mods_dir));
|
||||
const game: GameServer = await start_game(host, save, seed, mod_sources(mods_dir));
|
||||
// deno-lint-ignore no-explicit-any
|
||||
const take = (conn: number): any[] => {
|
||||
const messages = outbox.get(conn) ?? [];
|
||||
|
||||
Reference in New Issue
Block a user