Files
bworld/tests/server_scripts_test.ts
T
2026-09-25 13:25:25 -03:00

230 lines
8.6 KiB
TypeScript

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<Record<string, unknown[]>>("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<string, unknown> = {}) =>
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<string, unknown[]>;
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 });
});