Server actually ticks

This commit is contained in:
2026-09-25 13:25:25 -03:00
parent 2a2ccae9ce
commit dfabe40e7a
18 changed files with 763 additions and 79 deletions
+116
View File
@@ -0,0 +1,116 @@
import { assert, assertEquals } from "@std/assert";
import { GameLoop, LoopClock } from "$/server/game/game_loop.ts";
// a clock tests move by hand. ticks can move it too, to pretend they were slow
class FakeClock implements LoopClock {
time = 0;
#timers: { at: number; fn: () => void; id: number }[] = [];
#next_id = 1;
now() {
return this.time;
}
schedule(fn: () => void, ms: number) {
const id = this.#next_id++;
this.#timers.push({ at: this.time + ms, fn, id });
return id;
}
clear(handle: unknown) {
this.#timers = this.#timers.filter((t) => t.id !== handle);
}
// runs timers in order until `ms` have passed
advance(ms: number) {
const end = this.time + ms;
while (true) {
this.#timers.sort((a, b) => a.at - b.at);
const timer = this.#timers[0];
if (!timer || timer.at > end) break;
this.#timers.shift();
this.time = Math.max(this.time, timer.at);
timer.fn();
}
this.time = end;
}
}
Deno.test("runs 20 ticks a second", () => {
const clock = new FakeClock();
let ticks = 0;
const loop = new GameLoop(() => ticks++, 20, clock);
loop.start();
clock.advance(10_000);
// the first tick runs right away
assertEquals(ticks, 201);
assertEquals(loop.stats.tps, 20);
loop.stop();
clock.advance(1000);
assertEquals(ticks, 201);
});
Deno.test("a slow tick is made up for, so game time keeps up", () => {
const clock = new FakeClock();
let ticks = 0;
const loop = new GameLoop(
() => {
ticks++;
// tick 10 takes 200 ms, four ticks' worth
if (ticks === 10) clock.time += 200;
},
20,
clock,
);
loop.start();
clock.advance(2000);
assertEquals(ticks, 41);
assertEquals(loop.stats.skipped, 0);
});
Deno.test("falling too far behind skips ticks and warns", () => {
const clock = new FakeClock();
const warnings: string[] = [];
const warn = console.warn;
console.warn = (message: string) => warnings.push(message);
try {
let ticks = 0;
const loop = new GameLoop(
() => {
ticks++;
// one 2 second hiccup, forty ticks' worth
if (ticks === 5) clock.time += 2000;
},
20,
clock,
);
loop.start();
clock.advance(5000);
// it catches up ten, skips the rest and carries on at 20 per second
assert(loop.stats.skipped >= 25 && loop.stats.skipped <= 31, `skipped ${loop.stats.skipped}`);
assertEquals(loop.stats.tps, 20);
assertEquals(warnings.length, 1);
assert(warnings[0].startsWith("Can't keep up"), warnings[0]);
} finally {
console.warn = warn;
}
});
Deno.test("a tick that throws doesn't stop the loop", () => {
const clock = new FakeClock();
let ticks = 0;
const error = console.error;
console.error = () => {};
try {
const loop = new GameLoop(
() => {
ticks++;
if (ticks === 3) throw new Error("oops");
},
20,
clock,
);
loop.start();
clock.advance(1000);
assertEquals(ticks, 21);
} finally {
console.error = error;
}
});
+5 -1
View File
@@ -83,7 +83,11 @@ Deno.test("a mod made from the template loads and runs", async () => {
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!"));
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
+229
View File
@@ -0,0 +1,229 @@
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 });
});