Files
bworld/tests/bmod_test.ts
T
2026-09-26 17:33:07 -03:00

217 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// .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");
});