// .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) => 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"); });