Improve networking

This commit is contained in:
2026-09-25 00:08:01 -03:00
parent 6458bc0440
commit 2a2ccae9ce
18 changed files with 600 additions and 162 deletions
+72
View File
@@ -0,0 +1,72 @@
import { assert, assertEquals } from "@std/assert";
import { PROTOCOL_VERSION } from "$/common/protocol.ts";
import { TICKS_PER_SECOND } from "$/common/constants.ts";
import { test_game } from "./helpers.ts";
Deno.test("a different protocol version is rejected before anything else", async () => {
const { game, take, send, closed } = await test_game("mods");
game.on_connect(1);
send(1, { type: "hello", name: "old", protocol: PROTOCOL_VERSION - 1 });
const [message] = take(1);
assertEquals(message.type, "rejected");
assert(message.reason.includes(`protocol ${PROTOCOL_VERSION}`), message.reason);
assert(closed.has(1));
game.on_connect(2);
send(2, { type: "hello", name: "ancient" });
assertEquals(take(2)[0].type, "rejected");
});
Deno.test("welcome lists what to download, and nothing happens until ready", async () => {
const { game, take, send, join } = await test_game("mods");
join(1, "alice");
take(1);
let joined = 0;
game.mods.after.player_join.for_mod("test").subscribe(() => joined++);
game.on_connect(2);
send(2, { type: "hello", name: "bob", protocol: PROTOCOL_VERSION });
const [welcome] = take(2);
assertEquals(welcome.type, "welcome");
assertEquals(welcome.mods.map((m: { id: string }) => m.id), ["bworld"]);
assert(welcome.atlas.png && welcome.atlas.sha256);
assert(!("players" in welcome) && !("changes" in welcome), "welcome shouldn't have world state");
// not in the world yet: others don't hear about bob, and bob can't act
send(2, { type: "chat", text: "am I here?" });
send(2, { type: "break_block", x: 0, y: 60, z: 0 });
assertEquals(take(1), []);
assertEquals(take(2), []);
assertEquals(joined, 0);
send(2, { type: "ready" });
const messages = take(2);
assertEquals(messages[0].type, "join");
assertEquals(messages[0].name, "bob");
assertEquals(messages[0].players.map((p: { name: string }) => p.name), ["alice"]);
assert(messages.some((m) => m.type === "container" && m.container === "inventory"));
assertEquals(joined, 1);
assert(take(1).some((m) => m.type === "player_join" && m.player.name === "bob"));
// a second ready or hello does nothing
send(2, { type: "ready" });
send(2, { type: "hello", name: "bob", protocol: PROTOCOL_VERSION });
assertEquals(take(2).filter((m) => m.type === "join" || m.type === "welcome"), []);
assertEquals(joined, 1);
});
Deno.test("clients that never get ready are dropped", async () => {
const { game, take, send, closed } = await test_game("mods");
game.on_connect(1);
send(1, { type: "hello", name: "slow", protocol: PROTOCOL_VERSION });
take(1);
for (let i = 0; i < 59 * TICKS_PER_SECOND; i++) game.tick();
assert(!closed.has(1), "dropped too early");
for (let i = 0; i < 2 * TICKS_PER_SECOND; i++) game.tick();
assert(closed.has(1));
assertEquals(take(1)[0].type, "rejected");
// too late now
send(1, { type: "ready" });
assertEquals(take(1), []);
});
+16 -3
View File
@@ -4,6 +4,7 @@ import { load_and_check, load_order } from "$/tools/check_mods.ts";
import type { ServerModSource } from "$/server/game/load_mods.ts";
import { GameServer } from "$/server/game/game_server.ts";
import { start_game } from "$/server/game/load_mods.ts";
import { PROTOCOL_VERSION } from "$/common/protocol.ts";
// mods straight from their source folders, skipping the build: scripts are imported as typescript
export function mod_sources(mods_dir: string): ServerModSource[] {
@@ -20,6 +21,7 @@ export function mod_sources(mods_dir: string): ServerModSource[] {
version: String(mod.manifest?.version),
hash: "source",
data: `mods/${mod.id}/source/data.json`,
sha256: { data: "" },
},
data: {
blocks: mod.blocks.map((b) => b.json),
@@ -37,14 +39,18 @@ export function mod_sources(mods_dir: string): ServerModSource[] {
export async function test_game(mods_dir: string, save?: string, seed = "test-seed") {
EverythingRegistry.clear();
const outbox = new Map<number, unknown[]>();
const closed = new Set<number>();
const host = {
send: (conn: number, data: string) => {
if (!outbox.has(conn)) outbox.set(conn, []);
outbox.get(conn)!.push(JSON.parse(data));
},
close() {},
close(conn: number) {
closed.add(conn);
},
};
const game: GameServer = await start_game(host, save, seed, mod_sources(mods_dir));
const atlas = { png: "atlas.png", json: "atlas.json", sha256: { png: "", json: "" } };
const game: GameServer = await start_game(host, save, seed, atlas, mod_sources(mods_dir));
// deno-lint-ignore no-explicit-any
const take = (conn: number): any[] => {
const messages = outbox.get(conn) ?? [];
@@ -52,7 +58,14 @@ export async function test_game(mods_dir: string, save?: string, seed = "test-se
return messages;
};
const send = (conn: number, message: unknown) => game.on_message(conn, JSON.stringify(message));
return { game, take, send };
// the whole handshake: hello, welcome, ready, join. returns join
const join = (conn: number, name: string) => {
game.on_connect(conn);
send(conn, { type: "hello", name, protocol: PROTOCOL_VERSION });
send(conn, { type: "ready" });
return take(conn).find((m) => m.type === "join");
};
return { game, take, send, join, closed };
}
export function copy_dir(from: string, to: string) {
+7 -7
View File
@@ -5,6 +5,7 @@ import { block_from_json, block_to_json, item_from_json, item_to_json } from "$/
import { generate_raw_chunk } from "$/common/generation.ts";
import { load_worldgen } from "$/common/worldgen_loader.ts";
import { create_mod } from "$/tools/new_mod.ts";
import { PROTOCOL_VERSION } from "$/common/protocol.ts";
import { copy_dir, test_game } from "./helpers.ts";
// mods/bworld plus a mod made from the template, in a temp folder
@@ -59,9 +60,10 @@ Deno.test("a mod made from the template loads and runs", async () => {
);
game.on_connect(1);
send(1, { type: "hello", name: "alice" });
send(1, { type: "hello", name: "alice", protocol: PROTOCOL_VERSION });
const welcome = take(1)[0];
assertEquals(welcome.mods.map((m: Msg) => m.id), ["bworld", "copper_tools"]);
send(1, { type: "ready" });
// the command, by name and by namespaced name
send(1, { type: "chat", text: "/hello" });
@@ -180,9 +182,8 @@ export function setup(ctx: ServerContext) {
}
`,
);
let { game, send, take } = await test_game(dir);
game.on_connect(1);
send(1, { type: "hello", name: "bob" });
let { game, send, take, join } = await test_game(dir);
join(1, "bob");
for (let i = 0; i < 5; i++) game.tick();
game.set_block(0, 100, 0, "copper_tools:example_block");
send(1, { type: "move", x: 0.5, y: 100, z: 1.5, yaw: 0, pitch: 0 });
@@ -190,9 +191,8 @@ export function setup(ctx: ServerContext) {
assert(take(1).some((m) => m.text === "clicks 1, placed at 5"));
const saved = game.save();
({ game, send, take } = await test_game(dir, saved));
game.on_connect(1);
send(1, { type: "hello", name: "bob" });
({ game, send, take, join } = await test_game(dir, saved));
join(1, "bob");
send(1, { type: "use_block", x: 0, y: 100, z: 0, face: "top" });
assert(take(1).some((m) => m.text === "clicks 2, placed at 5"));
Deno.removeSync(dir, { recursive: true });