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 { PROTOCOL_VERSION } from "$/common/protocol.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("blocks", "bworld:water")?.replaceable, true); // still has its code until it's a component assert(EverythingRegistry.get("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("items", "copper_tools:example_item")?.lore, "Four of these make an example block.", ); game.on_connect(1); send(1, { type: "hello", name: "alice", protocol: PROTOCOL_VERSION }); const welcome = take(1)[0]; assertEquals(welcome.mods.map((m: Msg) => m.id), ["bworld", "copper_tools"]); send(1, { type: "ready" }); // 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.startsWith("You found the example block!"))); // it counts its age with on_second for (let i = 0; i < 3 * 20; i++) game.tick(); send(1, { type: "use_block", x: 1, y: y + 1, z: 1, face: "top" }); assert(take(1).some((m) => m.text === "You found the example block! It's been here for 3 seconds.")); 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("clicks") ?? 0) + 1); player.send_message(\`clicks \${ctx.storage.get("clicks")}, placed at \${block.data.placed_at}\`); return true; }, }); } `, ); let { game, send, take, join } = await test_game(dir); join(1, "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, join } = await test_game(dir, saved)); join(1, "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 }); });