Implement main game as a mod

This commit is contained in:
2026-09-24 23:49:34 -03:00
parent bb42dd662e
commit 6458bc0440
131 changed files with 1810 additions and 786 deletions
+213
View File
@@ -0,0 +1,213 @@
import { assert, assertEquals } from "@std/assert";
import { AIR, CHUNK_HEIGHT } from "$/common/constants.ts";
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
import { block_from_json, block_to_json, item_from_json, item_to_json } from "$/common/mod_data.ts";
import { generate_raw_chunk } from "$/common/generation.ts";
import { load_worldgen } from "$/common/worldgen_loader.ts";
import { create_mod } from "$/tools/new_mod.ts";
import { copy_dir, test_game } from "./helpers.ts";
// mods/bworld plus a mod made from the template, in a temp folder
function mods_with_template() {
const dir = Deno.makeTempDirSync({ prefix: "bworld_loading_" });
copy_dir("mods/bworld", `${dir}/bworld`);
create_mod("copper_tools", "Copper Tools", dir);
return dir;
}
// deno-lint-ignore no-explicit-any
type Msg = any;
const inventory_of = (messages: Msg[]) =>
messages.filter((m) => m.type === "container" && m.container === "inventory").at(-1)?.items ?? [];
Deno.test("the base game loads from mods/bworld", async () => {
const { game } = await test_game("mods");
assertEquals(EverythingRegistry.entries("blocks").length, 18);
// 11 items plus the item forms of the 16 blocks that have one
assertEquals(EverythingRegistry.entries("items").length, 27);
assertEquals(game.recipes.shaped.length, 5);
assertEquals(game.recipes.furnace.size, 6);
assertEquals(game.recipes.fuel.size, 2);
assertEquals(EverythingRegistry.get<BlockRegistry>("blocks", "bworld:water")?.replaceable, true);
// still has its code until it's a component
assert(EverythingRegistry.get<ItemRegistry>("items", "bworld:watering_can")?.on_create);
});
Deno.test("mods/bworld json survives going to the registry and back", async () => {
await test_game("mods");
for (const file of Deno.readDirSync("mods/bworld/blocks")) {
const { block: json } = JSON.parse(Deno.readTextFileSync(`mods/bworld/blocks/${file.name}`));
const { block, has_item } = block_from_json(json);
assertEquals(block_to_json(block, has_item), json, file.name);
}
for (const file of Deno.readDirSync("mods/bworld/items")) {
const { item: json } = JSON.parse(Deno.readTextFileSync(`mods/bworld/items/${file.name}`));
assertEquals(item_to_json(json.id, item_from_json(json)), json, file.name);
}
});
Deno.test("a mod made from the template loads and runs", async () => {
const dir = mods_with_template();
const { game, take, send } = await test_game(dir);
// data
assert(EverythingRegistry.get("blocks", "copper_tools:example_block"));
assert(EverythingRegistry.get("items", "copper_tools:example_item"));
assertEquals(
EverythingRegistry.get<ItemRegistry>("items", "copper_tools:example_item")?.lore,
"Four of these make an example block.",
);
game.on_connect(1);
send(1, { type: "hello", name: "alice" });
const welcome = take(1)[0];
assertEquals(welcome.mods.map((m: Msg) => m.id), ["bworld", "copper_tools"]);
// the command, by name and by namespaced name
send(1, { type: "chat", text: "/hello" });
send(1, { type: "chat", text: "/copper_tools:hello" });
const hellos = take(1).filter((m) => m.type === "chat" && m.text === "Hello alice!");
assertEquals(hellos.length, 2);
// place the example block and right click it: the component answers and nothing gets placed on top
send(1, { type: "chat", text: "/give copper_tools:example_block" });
const inventory = inventory_of(take(1));
send(1, { type: "select_slot", slot: inventory.findIndex((i: Msg) => i?.id === "copper_tools:example_block") });
let y = CHUNK_HEIGHT - 1;
while (game.world.get_block_nid(1, y, 1) === AIR) y--;
send(1, { type: "move", x: 2.5, y: y + 1, z: 2.5, yaw: 0, pitch: 0 });
send(1, { type: "use_block", x: 1, y, z: 1, face: "top" });
assertEquals(game.world.get_block_id(1, y + 1, 1), "copper_tools:example_block");
take(1);
send(1, { type: "use_block", x: 1, y: y + 1, z: 1, face: "top" });
const messages = take(1);
assert(messages.some((m) => m.type === "chat" && m.text === "You found the example block!"));
assertEquals(game.world.get_block_id(1, y + 2, 1), "bworld:air");
// the shaped recipe: 4 example items in a square
send(1, { type: "chat", text: "/give copper_tools:example_item 4" });
const items_slot = inventory_of(take(1)).findIndex((i: Msg) => i?.id === "copper_tools:example_item");
send(1, { type: "click", container: "inventory", index: items_slot, button: 0 });
for (const slot of [0, 1, 3, 4]) send(1, { type: "click", container: "crafting", index: slot, button: 2 });
const crafting = take(1).filter((m) => m.container === "crafting").at(-1).items;
assertEquals(crafting[9]?.id, "copper_tools:example_block");
Deno.removeSync(dir, { recursive: true });
});
Deno.test("the template's worldgen feature places boulders, the same way every time", async () => {
const dir = mods_with_template();
const { game } = await test_game(dir, undefined, "boulder-seed");
const worldgen = game.world.worldgen!;
assertEquals(worldgen.features.map((f) => f.id), ["copper_tools:boulders"]);
// compare against generation without mods: boulders are extra stone on top of the surface
let boulders = 0;
for (let cx = 0; cx < 20; cx++) {
const plain = generate_raw_chunk(cx, 0, "boulder-seed", game.world.block_ids);
const modded = generate_raw_chunk(cx, 0, "boulder-seed", game.world.block_ids, worldgen);
const differences = plain.blocks.filter((b, i) => b !== modded.blocks[i]).length;
if (differences > 0) boulders++;
assert(differences <= 1, `chunk ${cx} changed ${differences} blocks`);
}
assert(boulders >= 1 && boulders <= 8, `${boulders} of 20 chunks got a boulder, expected about 2`);
// loading the script again (like a client does) gives the same terrain
const again = await load_worldgen(
[{
mod: "copper_tools",
url: new URL(`file://${Deno.realPathSync(dir)}/copper_tools/scripts/worldgen.ts`).href,
}],
[],
);
for (let cx = 0; cx < 20; cx++) {
assertEquals(
generate_raw_chunk(cx, 0, "boulder-seed", game.world.block_ids, again).blocks,
generate_raw_chunk(cx, 0, "boulder-seed", game.world.block_ids, worldgen).blocks,
);
}
Deno.removeSync(dir, { recursive: true });
});
Deno.test("mod ores generate into the block they replace", async () => {
const dir = mods_with_template();
Deno.mkdirSync(`${dir}/copper_tools/worldgen`);
Deno.writeTextFileSync(
`${dir}/copper_tools/worldgen/ores.json`,
JSON.stringify({
format_version: 1,
ores: [{
id: "copper_tools:example_block",
replaces: "bworld:stone",
min_y: 0,
max_y: 60,
scale: 0.1,
threshold: 0.5,
}],
}),
);
const { game } = await test_game(dir);
const ore = game.world.block_ids["copper_tools:example_block"];
let found = 0;
for (let cx = 0; cx < 4; cx++) {
const blocks = generate_raw_chunk(cx, 0, "test-seed", game.world.block_ids, game.world.worldgen).blocks;
blocks.forEach((b, i) => {
if (b === ore) {
found++;
assert(Math.floor(i / 256) <= 60, "ore above max_y");
}
});
}
assert(found > 0, "no ore generated");
Deno.removeSync(dir, { recursive: true });
});
Deno.test("mod storage and block data are saved with the world", async () => {
const dir = mods_with_template();
// a server script that uses storage and block data
Deno.writeTextFileSync(
`${dir}/copper_tools/scripts/server.ts`,
`import type { ServerContext } from "bworld/server";
export function setup(ctx: ServerContext) {
ctx.components.register_block("copper_tools:announce", {
on_create(block) { block.data = { placed_at: ctx.system.current_tick }; },
on_interact(block, _params, player) {
ctx.storage.set("clicks", (ctx.storage.get<number>("clicks") ?? 0) + 1);
player.send_message(\`clicks \${ctx.storage.get("clicks")}, placed at \${block.data.placed_at}\`);
return true;
},
});
}
`,
);
let { game, send, take } = await test_game(dir);
game.on_connect(1);
send(1, { type: "hello", name: "bob" });
for (let i = 0; i < 5; i++) game.tick();
game.set_block(0, 100, 0, "copper_tools:example_block");
send(1, { type: "move", x: 0.5, y: 100, z: 1.5, yaw: 0, pitch: 0 });
send(1, { type: "use_block", x: 0, y: 100, z: 0, face: "top" });
assert(take(1).some((m) => m.text === "clicks 1, placed at 5"));
const saved = game.save();
({ game, send, take } = await test_game(dir, saved));
game.on_connect(1);
send(1, { type: "hello", name: "bob" });
send(1, { type: "use_block", x: 0, y: 100, z: 0, face: "top" });
assert(take(1).some((m) => m.text === "clicks 2, placed at 5"));
Deno.removeSync(dir, { recursive: true });
});
Deno.test("broken mods stop the game from starting", async () => {
const dir = mods_with_template();
// the block uses a component the script no longer registers
Deno.writeTextFileSync(`${dir}/copper_tools/scripts/server.ts`, "export function setup() {}\n");
let error = "";
try {
await test_game(dir);
} catch (e) {
error = (e as Error).message;
}
assert(error.includes("uses component copper_tools:announce, which nothing registered"), error);
Deno.removeSync(dir, { recursive: true });
});