230 lines
8.6 KiB
TypeScript
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 });
|
|
});
|