Server actually ticks
This commit is contained in:
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
Reference in New Issue
Block a user