This commit is contained in:
2026-09-24 23:28:01 -03:00
parent 79faa556de
commit bb42dd662e
73 changed files with 2349 additions and 34 deletions
+65
View File
@@ -0,0 +1,65 @@
import { assertEquals } from "@std/assert";
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
import {
block_from_json,
block_to_json,
grid_recipe_from_json,
grid_recipe_to_json,
item_from_json,
item_to_json,
} from "$/common/mod_data.ts";
import { CRAFTING_RECIPES } from "$/server/game/crafting.ts";
import { bworld_data_files } from "$/tools/export_bworld_mod.ts";
// mods/bworld is generated from the typescript definitions until the loader exists, keep them in sync
Deno.test("mods/bworld matches what the game registers", () => {
const expected = new Map(bworld_data_files().map(({ path, content }) => [path, content]));
const actual = new Map<string, unknown>();
for (const folder of ["blocks", "items", "recipes"]) {
for (const file of Deno.readDirSync(`mods/bworld/${folder}`)) {
actual.set(
`${folder}/${file.name}`,
JSON.parse(Deno.readTextFileSync(`mods/bworld/${folder}/${file.name}`)),
);
}
}
assertEquals(
[...actual.keys()].sort(),
[...expected.keys()].sort(),
"files differ, run deno task export-bworld",
);
for (const [path, content] of expected) {
assertEquals(actual.get(path), content, `${path} is out of date, run deno task export-bworld`);
}
});
// what the loader will do with the json has to give back exactly what the game uses now
Deno.test("blocks survive going to json and back", () => {
const items = new Map(EverythingRegistry.entries<ItemRegistry>("items"));
for (const [id, block] of EverythingRegistry.entries<BlockRegistry>("blocks")) {
const has_item = items.get(id)?.block_id === id;
const back = block_from_json(block_to_json(block, has_item));
assertEquals(back.block, without_undefined(block), id);
assertEquals(back.has_item, has_item, id);
}
});
Deno.test("items survive going to json and back", () => {
for (const [id, item] of EverythingRegistry.entries<ItemRegistry>("items")) {
if (item.block_id !== undefined) continue;
const { on_create: _on_create, get_lore: _get_lore, ...data } = item;
assertEquals(item_from_json(item_to_json(id, item)), without_undefined(data), id);
}
});
Deno.test("crafting recipes survive going to json and back", () => {
for (const recipe of CRAFTING_RECIPES) {
const json = grid_recipe_to_json(recipe);
if (json.type !== "shaped") throw new Error("expected a shaped recipe");
assertEquals(grid_recipe_from_json(json), recipe, recipe.result.id);
}
});
function without_undefined<T extends object>(value: T): T {
return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as T;
}
+103
View File
@@ -0,0 +1,103 @@
import { assert, assertEquals, assertThrows } from "@std/assert";
import { check_mods } from "$/tools/check_mods.ts";
import { create_mod } from "$/tools/new_mod.ts";
function temp_mods_dir() {
const dir = Deno.makeTempDirSync({ prefix: "bworld_mods_" });
// most mods depend on the base game
Deno.mkdirSync(`${dir}/bworld`);
for (const entry of ["manifest.json", "blocks", "items", "recipes"]) {
copy(`mods/bworld/${entry}`, `${dir}/bworld/${entry}`);
}
return dir;
}
function copy(from: string, to: string) {
if (Deno.statSync(from).isDirectory) {
Deno.mkdirSync(to, { recursive: true });
for (const entry of Deno.readDirSync(from)) copy(`${from}/${entry.name}`, `${to}/${entry.name}`);
} else {
Deno.copyFileSync(from, to);
}
}
function write_json(path: string, value: unknown) {
Deno.writeTextFileSync(path, JSON.stringify(value));
}
Deno.test("a mod made from the template passes the checks", async () => {
const dir = temp_mods_dir();
create_mod("copper_tools", "Copper Tools", dir);
const manifest = JSON.parse(Deno.readTextFileSync(`${dir}/copper_tools/manifest.json`));
assertEquals(manifest.id, "copper_tools");
assertEquals(manifest.name, "Copper Tools");
assert(!Deno.readTextFileSync(`${dir}/copper_tools/blocks/example_block.json`).includes("example_mod"));
const report = (await check_mods(dir)).find((r) => r.id === "copper_tools")!;
assertEquals(report.errors, []);
assertEquals(report.warnings, []);
Deno.removeSync(dir, { recursive: true });
});
Deno.test("new-mod rejects bad and reserved ids", () => {
const dir = temp_mods_dir();
assertThrows(() => create_mod("Has Spaces", "x", dir));
assertThrows(() => create_mod("bworld", "x", dir));
assertThrows(() => create_mod("engine", "x", dir));
create_mod("once", "x", dir);
assertThrows(() => create_mod("once", "x", dir));
Deno.removeSync(dir, { recursive: true });
});
Deno.test("check-mods finds broken mods", async () => {
const dir = temp_mods_dir();
create_mod("broken", "Broken", dir);
const mod = `${dir}/broken`;
// wrong namespace, a drop that doesn't exist, and a texture that doesn't exist
write_json(`${mod}/blocks/wrong.json`, {
format_version: 1,
block: { id: "other:thing", textures: "broken:nope", drops: "broken:no_such_item" },
});
// defines a block the base game already has
write_json(`${mod}/blocks/dupe.json`, { format_version: 1, block: { id: "broken:x", textures: "bworld:stone" } });
write_json(`${mod}/blocks/stone_copy.json`, {
format_version: 1,
block: { id: "broken:x", textures: "bworld:stone" },
});
// not json
Deno.writeTextFileSync(`${mod}/items/bad.json`, "{ nope");
// uses a letter the key doesn't define
write_json(`${mod}/recipes/bad.json`, {
format_version: 1,
recipe: {
type: "shaped",
pattern: ["AB"],
key: { A: "bworld:stone" },
result: { id: "bworld:stone", count: 1 },
},
});
// wrong texture size
Deno.copyFileSync("assets/sprites/ui.png", `${mod}/textures/huge.png`);
// a script that doesn't typecheck
Deno.writeTextFileSync(`${mod}/scripts/client.ts`, "export function setup(ctx: number) { ctx.nope(); }\n");
// a dependency that isn't installed, and no longer depending on bworld
const manifest = JSON.parse(Deno.readTextFileSync(`${mod}/manifest.json`));
manifest.dependencies = [{ id: "missing_mod", version: "1.0.0" }];
write_json(`${mod}/manifest.json`, manifest);
const report = (await check_mods(dir)).find((r) => r.id === "broken")!;
const has = (list: string[], text: string) =>
assert(list.some((e) => e.includes(text)), `expected "${text}" in\n${list.join("\n")}`);
has(report.errors, `other:thing isn't in this mod's namespace`);
has(report.errors, "item broken:no_such_item doesn't exist");
has(report.errors, "block broken:x is also defined by broken, broken");
has(report.errors, "items/bad.json: isn't valid json");
has(report.errors, `pattern uses "B" but key doesn't define it`);
has(report.errors, "textures/huge.png: is");
has(report.errors, "scripts don't typecheck");
has(report.errors, `depends on "missing_mod", which isn't installed`);
has(report.warnings, "texture broken:nope doesn't exist");
has(report.warnings, `uses bworld:stone but doesn't list "bworld" in dependencies`);
Deno.removeSync(dir, { recursive: true });
});