import { assert, assertEquals } from "@std/assert"; import { copy_dir, test_game } from "./helpers.ts"; // a mod that uses every part of the server api worth testing, and writes down what happened in its storage const SERVER_SCRIPT = ` import type { ServerContext } from "bworld/server"; export function setup(ctx: ServerContext) { const log = (key: string, value: unknown = 1) => { const all = ctx.storage.get>("log") ?? {}; (all[key] ??= []).push(value); ctx.storage.set("log", all); }; ctx.components.register_block("testmod:ticker", { on_create(block) { block.data = { name: "ticker" }; }, on_tick(block, _params, dt) { log("tick_" + block.x, dt); }, on_second(block, _params, dt) { log("second_" + block.x, dt); }, }); ctx.components.register_block("testmod:lazy", { on_tick() { log("lazy_tick"); }, }); ctx.components.register_block("testmod:broken", { on_create(block) { block.data = {}; }, on_tick() { throw new Error("broken on purpose"); }, on_click(block, _params, player) { log("click", [block.id, player.name]); }, }); ctx.components.register_item("testmod:wand", { on_use(item, params: { power: number }, player) { log("use", [item.id, params.power, player.name]); }, }); ctx.system.run_timeout(() => log("timeout", ctx.system.current_tick), 5); const interval = ctx.system.run_interval(() => { log("interval", ctx.system.current_tick); if (ctx.system.current_tick >= 30) ctx.system.clear_run(interval); }, 10); ctx.events.after.tick.subscribe(() => log("tick_event")); ctx.events.after.player_join.subscribe(({ player }) => { player.inventory.on_change((slot) => log("inventory", slot)); }); ctx.commands.register("state", { description: "", usage: "", run([x, y, z, name, value], player) { const [bx, by, bz] = [Number(x), Number(y), Number(z)]; try { if (value !== undefined) ctx.world.set_state(bx, by, bz, name, Number(value)); player.send_message("state " + ctx.world.get_state(bx, by, bz, name)); } catch (e) { player.send_message("error " + (e as Error).message); } }, }); } `; const block = (id: string, extra: Record = {}) => JSON.stringify({ format_version: 1, block: { id, textures: "bworld:stone", mining: { toughness: 1 }, ...extra } }); function test_mods() { const dir = Deno.makeTempDirSync({ prefix: "bworld_scripts_" }); copy_dir("mods/bworld", `${dir}/bworld`); const mod = `${dir}/testmod`; Deno.mkdirSync(`${mod}/blocks`, { recursive: true }); Deno.mkdirSync(`${mod}/items`); Deno.mkdirSync(`${mod}/scripts`); Deno.writeTextFileSync( `${mod}/manifest.json`, JSON.stringify({ format_version: 1, id: "testmod", name: "Test", version: "1.0.0", dependencies: [{ id: "bworld", version: "*" }], scripts: { server: "scripts/server.ts" }, }), ); Deno.writeTextFileSync( `${mod}/blocks/ticker.json`, block("testmod:ticker", { components: { "testmod:ticker": {} } }), ); Deno.writeTextFileSync(`${mod}/blocks/lazy.json`, block("testmod:lazy", { components: { "testmod:lazy": {} } })); Deno.writeTextFileSync( `${mod}/blocks/broken.json`, block("testmod:broken", { components: { "testmod:broken": {} } }), ); Deno.writeTextFileSync( `${mod}/blocks/lamp.json`, block("testmod:lamp", { states: [{ name: "power", bits: 3, default: 2 }, { name: "lit", bits: 1, default: 0 }], }), ); Deno.writeTextFileSync( `${mod}/items/wand.json`, JSON.stringify({ format_version: 1, item: { id: "testmod:wand", texture: "bworld:stone", places: "bworld:stone", components: { "testmod:wand": { power: 7 } }, }, }), ); Deno.writeTextFileSync(`${mod}/scripts/server.ts`, SERVER_SCRIPT); return dir; } async function setup(save?: string) { const dir = test_mods(); const game = await test_game(dir, save); game.join(1, "alice"); game.send(1, { type: "move", x: 0.5, y: 100, z: 0.5, yaw: 0, pitch: 0 }); game.take(1); const log = () => (game.game.mods.storage.testmod?.log ?? {}) as Record; return { ...game, dir, log }; } Deno.test("components tick 20 times a second and on_second once, only with block data and near players", async () => { const { game, log, dir } = await setup(); const error = console.error; console.error = () => {}; try { game.set_block(2, 100, 2, "testmod:ticker"); game.set_block(3, 100, 2, "testmod:lazy"); game.set_block(4, 100, 2, "testmod:broken"); // 20 chunks away from the only player game.set_block(16 * 20, 100, 0, "testmod:ticker"); for (let i = 0; i < 40; i++) game.tick(); } finally { console.error = error; } assertEquals(log()["tick_2"]?.length, 40); assert(log()["tick_2"].every((dt) => dt === 1 / 20)); assertEquals(log()["second_2"], [1, 1]); assertEquals(log()["lazy_tick"], undefined, "blocks without data don't tick"); assertEquals(log()[`tick_${16 * 20}`], undefined, "blocks far from every player don't tick"); Deno.removeSync(dir, { recursive: true }); }); Deno.test("timers and the tick event run on the game loop's ticks", async () => { const { game, log, dir } = await setup(); for (let i = 0; i < 50; i++) game.tick(); assertEquals(log()["timeout"], [5]); assertEquals(log()["interval"], [10, 20, 30]); assertEquals(log()["tick_event"]?.length, 50); Deno.removeSync(dir, { recursive: true }); }); Deno.test("hitting a block runs on_click, using an item runs on_use instead of placing it", async () => { const { game, send, take, log, dir } = await setup(); game.set_block(1, 99, 1, "testmod:broken"); send(1, { type: "hit_block", x: 1, y: 99, z: 1 }); assertEquals(log()["click"], [["testmod:broken", "alice"]]); send(1, { type: "chat", text: "/give testmod:wand" }); const inventory = take(1).filter((m) => m.container === "inventory").at(-1).items; send(1, { type: "select_slot", slot: inventory.findIndex((i: { id: string } | null) => i?.id === "testmod:wand") }); send(1, { type: "use_item" }); assertEquals(log()["use"], [["testmod:wand", 7, "alice"]]); // on a block: used, not placed (even though it can place stone) send(1, { type: "use_block", x: 1, y: 99, z: 1, face: "top" }); assertEquals(log()["use"]?.length, 2); assertEquals(game.world.get_block_id(1, 100, 1), "bworld:air"); Deno.removeSync(dir, { recursive: true }); }); Deno.test("block states start at their defaults, can be changed, and are synced and saved", async () => { const { game, send, take, dir } = await setup(); const state = (args: string) => { send(1, { type: "chat", text: `/state ${args}` }); return take(1).filter((m) => m.type === "chat").map((m) => m.text).at(-1); }; game.set_block(5, 100, 5, "testmod:lamp"); take(1); assertEquals(state("5 100 5 power"), "state 2"); assertEquals(state("5 100 5 lit"), "state 0"); send(1, { type: "chat", text: "/state 5 100 5 power 5" }); const change = take(1).find((m) => m.type === "set_block"); assertEquals(change.id, "testmod:lamp"); assertEquals(change.state, 5, "other clients get the new state"); assertEquals(state("5 100 5 lit 1"), "state 1"); assertEquals(state("5 100 5 power"), "state 5"); assert(state("5 100 5 power 8").startsWith('error testmod:lamp state "power" has 3 bits')); assert(state("5 100 5 color").startsWith('error testmod:lamp has no state "color"')); // saved and loaded, and new players get it with the world's changes const saved = game.save(); Deno.removeSync(dir, { recursive: true }); const again = await setup(saved); assertEquals(again.game.world.get_block_value(5, 100, 5) >>> 16, 5 | (1 << 3)); const joined = again.join(2, "bob"); assert(joined.changes.some((c: unknown[]) => c[0] === 5 && c[3] === "testmod:lamp" && c[4] === (5 | (1 << 3)))); Deno.removeSync(again.dir, { recursive: true }); }); Deno.test("inventory on_change fires for the slots that changed", async () => { const { game, send, log, dir } = await setup(); send(1, { type: "chat", text: "/give bworld:stone 3" }); send(1, { type: "chat", text: "/give bworld:dirt" }); game.tick(); assertEquals(log()["inventory"], [0, 1]); game.tick(); assertEquals(log()["inventory"], [0, 1], "nothing changed since"); Deno.removeSync(dir, { recursive: true }); }); Deno.test("/tps reports the loop, and mods can't take engine commands", async () => { const { send, take, dir } = await setup(); send(1, { type: "chat", text: "/tps" }); assertEquals(take(1).at(-1).text, "The game loop isn't running"); Deno.removeSync(dir, { recursive: true }); const clash = test_mods(); Deno.writeTextFileSync( `${clash}/testmod/scripts/server.ts`, SERVER_SCRIPT.replace('ctx.commands.register("state"', 'ctx.commands.register("tps"'), ); let error = ""; try { await test_game(clash); } catch (e) { error = (e as Error).message; } assert(error.includes("/tps belongs to the engine"), error); Deno.removeSync(clash, { recursive: true }); });