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
+23 -14
View File
@@ -826,25 +826,36 @@ server's atlas, which gets a hash-named URL the same way.
```
client server
│ hello { name, protocol } │
├────────────────────────────────────────►│
│ welcome { seed, atlas, mods[] } │ mods[i] = { id, name, version, hash,
│◄────────────────────────────────────────┤ data, client?, worldgen? } (urls)
├────────────────────────────────────────►│ a different protocol gets rejected { reason }
│ welcome { protocol, seed, atlas, │ mods[i] = { id, name, version, hash,
│ mods[] } │ data, client?, worldgen?, sha256 }
│◄────────────────────────────────────────┤ atlas = { png, json, sha256 } (paths on the server)
│ │
│ [cross-origin: confirm screen] │
│ fetch data, atlas, scripts │
│ check hashes, register data, │
│ run client setup(), start workers │
│ download the atlas and every mod file, │
│ check each one's sha256, register data │
│ in the listed order, run client │
│ setup(), start chunk workers │
│ │
│ ready │
├────────────────────────────────────────►│
│ join { players, changes, inventory } │ player_join fires on the server here
│ join { id, name, players, changes, │ the player is created here, player_join fires,
│ spawn, selected_slot } │ and their inventory follows as container messages
│◄────────────────────────────────────────┤
```
- The client registers the base game, then each mod's data **in the order the server lists them**.
- The client registers each mod's data **in the order the server lists them**.
- Every file is checked against its SHA-256 before it's used, and scripts are imported from the checked bytes (as
`blob:` URLs), so what runs is exactly what was checked. The client code is in `client/handshake.ts` and
`client/mods.ts`.
- If any download, hash check or `setup` fails, the client disconnects and shows which mod failed. It never joins with
some mods missing.
- The `protocol` in `hello` is the game's protocol version. A mismatch is rejected before any mod code is downloaded.
- The `protocol` in `hello` is the game's protocol version (`PROTOCOL_VERSION` in `common/protocol.ts`). A mismatch is
rejected before anything is downloaded.
- Until `ready`, the connection isn't a player: other players don't see it, and anything it sends besides `ready` is
ignored. A client that doesn't send `ready` within 60 seconds is rejected.
- Mod files and the atlas are served with `Access-Control-Allow-Origin: *` and cached for a year, since their paths
change whenever their content does.
- Leaving a server reloads the page, so one server's mod code never stays loaded while playing on another.
## Security
@@ -1107,8 +1118,8 @@ loading mods (the base game and the template), their scripts and worldgen, saves
## Implementation plan
Steps 1–4 are done and 5, 6 and 9 mostly, enough that a mod made from the template loads and runs. Each step keeps the
game working:
Steps 1–5 are done and 6 and 9 mostly, enough that a mod made from the template loads and runs. Each step keeps the game
working:
1. **Game server core.** Move `client/generation.ts` to `common/` (it already only needs constants and the rng package)
and have the server generate terrain. Move world state, chunks, tile data and player inventories into a game server
@@ -1123,9 +1134,7 @@ game working:
build the combined atlas (textures are currently all named `bworld:<file>`), bundle scripts per side, and write
hashed output to `build/mods/` and `server_mods/`. _Done._
5. **Delivery.** Split `welcome` into `welcome` / `ready` / `join`. Clients download, verify and run mods before
joining. Add the confirm screen for cross-origin servers and CORS headers on the server. _Partly done:_ `welcome`
lists the mods and clients download and run them before creating the world, but the server doesn't wait for `ready`
(its messages just queue up meanwhile), hashes aren't checked, and there's no confirm screen or CORS yet.
joining. Add the confirm screen for cross-origin servers and CORS headers on the server. _Done._
6. **Server scripts.** Components, events, commands, system, storage and `ctx.recipes`. Move `FURNACE_RECIPES` and
`FUEL_VALUES` into the recipe registry. _Mostly done_ (`server/game/mod_runtime.ts`). Not yet: `on_click` (clients
don't report left clicks on blocks), item `on_use` (there's no item use action), `world.get_state` / `set_state`
+28 -12
View File
@@ -1,7 +1,7 @@
import { copy } from "@std/fs";
import { createCanvas, loadImage } from "@gfx/canvas-wasm";
import { ENGINE_TEXTURE_DIR, load_and_check, load_order, LoadedMod } from "./tools/check_mods.ts";
import type { ModData, ModListing } from "./common/mod_loader.ts";
import type { AtlasListing, ModData, ModListing } from "./common/mod_loader.ts";
// all overridable so tests can build somewhere else
const BUILD_FOLDER = Deno.env.get("BUILD_DIR") ?? "build";
@@ -11,6 +11,7 @@ const SERVER_MODS_FOLDER = Deno.env.get("SERVER_MODS_DIR") ?? "server_mods";
// what the server reads at startup, see server/main.ts
export interface ServerModIndex {
atlas: AtlasListing;
mods: { listing: ModListing; server?: string }[];
}
@@ -74,8 +75,9 @@ function calculate_atlas_size(count: number) {
};
}
// one atlas with the engine's textures (engine:<file>) and every mod's (<mod>:<file>)
async function build_atlas(mods: LoadedMod[]) {
// one atlas with the engine's textures (engine:<file>) and every mod's (<mod>:<file>).
// named by its hash, so clients can cache it forever and download it from other origins
async function build_atlas(mods: LoadedMod[]): Promise<AtlasListing> {
const textures = new Map<string, string>();
for (const entry of Deno.readDirSync(ENGINE_TEXTURE_DIR)) {
if (entry.isFile && entry.name.endsWith(".png")) {
@@ -115,8 +117,13 @@ async function build_atlas(mods: LoadedMod[]) {
atlas_info[id] = { x: column, y: row };
}
Deno.writeFileSync(`${BUILD_FOLDER}/assets/sprites/textures.png`, canvas.toBuffer());
Deno.writeTextFileSync(`${BUILD_FOLDER}/assets/sprites/textures.json`, JSON.stringify(atlas_info));
const png = new Uint8Array(canvas.toBuffer());
const json = new TextEncoder().encode(JSON.stringify(atlas_info));
const hashes = { png: await sha256(png), json: await sha256(json) };
const name = `assets/sprites/textures.${(hashes.png + hashes.json).slice(0, 12)}`;
Deno.writeFileSync(`${BUILD_FOLDER}/${name}.png`, png);
Deno.writeFileSync(`${BUILD_FOLDER}/${name}.json`, json);
return { png: `${name}.png`, json: `${name}.json`, sha256: hashes };
}
async function build_sprites(mods: LoadedMod[]) {
@@ -125,16 +132,16 @@ async function build_sprites(mods: LoadedMod[]) {
await copy(`assets/sprites/${entry.name}`, `${BUILD_FOLDER}/assets/sprites/${entry.name}`);
}
}
await build_atlas(mods);
return await build_atlas(mods);
}
async function build_assets(mods: LoadedMod[]) {
async function build_assets(mods: LoadedMod[]): Promise<AtlasListing> {
Deno.mkdirSync(`${BUILD_FOLDER}/assets`, { recursive: true });
await copy("assets/ASSETS.md", `${BUILD_FOLDER}/assets/ASSETS.md`);
await build_fonts();
Deno.mkdirSync(`${BUILD_FOLDER}/assets/sprites`, { recursive: true });
await build_sprites(mods);
return await build_sprites(mods);
}
// every mod in mods/, checked and sorted so dependencies load first. a broken mod fails the whole build,
@@ -156,15 +163,21 @@ async function bundle_script(path: string, platform: "browser" | "deno"): Promis
return result.outputFiles[0].text();
}
async function sha256(bytes: Uint8Array<ArrayBuffer> | string) {
const data = typeof bytes === "string" ? new TextEncoder().encode(bytes) : bytes;
const digest = await crypto.subtle.digest("SHA-256", data);
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
async function short_hash(parts: string[]) {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(parts.join("\0")));
return [...new Uint8Array(digest)].slice(0, 6).map((b) => b.toString(16).padStart(2, "0")).join("");
}
// build/mods/<id>/<hash>/ gets what players download, server_mods/ the server scripts
async function build_mods(mods: LoadedMod[]) {
async function build_mods(mods: LoadedMod[], atlas: AtlasListing) {
clear_folder(SERVER_MODS_FOLDER);
const index: ServerModIndex = { mods: [] };
const index: ServerModIndex = { atlas, mods: [] };
for (const mod of mods) {
const manifest = mod.manifest as { name: string; version: string; scripts?: Record<string, string> };
@@ -193,14 +206,17 @@ async function build_mods(mods: LoadedMod[]) {
version: manifest.version,
hash,
data: `${public_dir}/data.json`,
sha256: { data: await sha256(data_json) },
};
Deno.writeTextFileSync(`${BUILD_FOLDER}/${listing.data}`, data_json);
if (client) {
listing.client = `${public_dir}/client.js`;
listing.sha256.client = await sha256(client);
Deno.writeTextFileSync(`${BUILD_FOLDER}/${listing.client}`, client);
}
if (worldgen) {
listing.worldgen = `${public_dir}/worldgen.js`;
listing.sha256.worldgen = await sha256(worldgen);
Deno.writeTextFileSync(`${BUILD_FOLDER}/${listing.worldgen}`, worldgen);
}
@@ -234,9 +250,9 @@ async function build() {
const now = performance.now();
clear_folder(BUILD_FOLDER);
const mods = load_mods();
await build_assets(mods);
const atlas = await build_assets(mods);
await build_client();
await build_mods(mods);
await build_mods(mods, atlas);
console.log(`Built in ${(performance.now() - now).toFixed(2)}ms`);
} catch (e) {
// no index means the server refuses to start, instead of running without some mods
+50
View File
@@ -0,0 +1,50 @@
import type { ServerAddress, Welcome } from "./handshake.ts";
// asks before running a server's mods when the page came from somewhere else, see "Security" in MODS.md.
// resolves with whether the player wants to join
export function confirm_mods(address: ServerAddress, welcome: Welcome): Promise<boolean> {
return new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.style.cssText =
"position:fixed;inset:0;display:flex;align-items:center;justify-content:center;padding:16px;" +
"background:rgba(0,0,0,0.85);color:white;font:16px system-ui,sans-serif;";
const panel = document.createElement("div");
panel.style.cssText = "max-width:480px;display:flex;flex-direction:column;gap:12px;";
const title = document.createElement("div");
title.style.cssText = "font-size:20px;";
title.textContent = `Join ${address.base.host}?`;
const warning = document.createElement("div");
warning.style.cssText = "opacity:0.8;";
warning.textContent = "This server runs these mods in your browser. They can do anything this page can, " +
"including reading what it saved. Only join servers you trust.";
const list = document.createElement("ul");
list.style.cssText = "margin:0;padding-left:20px;";
for (const mod of welcome.mods) {
const item = document.createElement("li");
item.textContent = `${mod.name} ${mod.version} (${mod.id})`;
list.append(item);
}
const buttons = document.createElement("div");
buttons.style.cssText = "display:flex;gap:8px;justify-content:flex-end;";
const button = (label: string, answer: boolean) => {
const element = document.createElement("button");
element.textContent = label;
element.style.cssText = "font:inherit;padding:6px 16px;cursor:pointer;";
element.addEventListener("click", () => {
overlay.remove();
resolve(answer);
});
return element;
};
buttons.append(button("Cancel", false), button("Join", true));
panel.append(title, warning, list, buttons);
overlay.append(panel);
document.body.append(overlay);
});
}
+191
View File
@@ -0,0 +1,191 @@
// connecting to a server and getting what it needs before joining, see "Delivery to clients" in MODS.md:
// hello -> welcome, download and check everything, ready -> join
import type { AtlasListing } from "$/common/mod_loader.ts";
import { ClientMessage, PROTOCOL_VERSION, ServerMessage } from "$/common/protocol.ts";
const CONNECT_TIMEOUT_MS = 5000;
const TRUST_KEY_PREFIX = "bworld:trusted:";
export type Welcome = Extract<ServerMessage, { type: "welcome" }>;
export type Join = Extract<ServerMessage, { type: "join" }>;
export interface ServerAddress {
ws_url: string;
// where the server's files are, mod paths are relative to this
base: URL;
// the page came from somewhere else than the server, so its mods need the player's ok
cross_origin: boolean;
}
// something went wrong in a way the player should see
export class HandshakeError extends Error {}
export function server_address(page = new URL(location.href)): ServerAddress {
// ?server=host:port, otherwise the server that served the page
const host = page.searchParams.get("server") ?? page.host;
const secure = page.protocol === "https:";
const base = new URL(`${secure ? "https" : "http"}://${host}/`);
return {
ws_url: `${secure ? "wss" : "ws"}://${host}/ws`,
base,
cross_origin: base.origin !== page.origin,
};
}
// a socket whose messages all land in one queue, so none get lost between the handshake and the game
export class ServerSocket {
socket: WebSocket;
messages: ServerMessage[] = [];
closed = false;
#waiters: (() => void)[] = [];
constructor(socket: WebSocket) {
this.socket = socket;
socket.addEventListener("message", (event) => {
this.messages.push(JSON.parse(event.data));
this.#wake();
});
socket.addEventListener("close", () => {
this.closed = true;
this.#wake();
});
}
send(message: ClientMessage) {
if (this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(message));
}
}
close() {
this.socket.close();
}
// takes the first message of one of these types out of the queue, waiting for it if needed
async next<T extends ServerMessage["type"]>(...types: T[]): Promise<Extract<ServerMessage, { type: T }>> {
while (true) {
const index = this.messages.findIndex((m) => (types as string[]).includes(m.type));
if (index !== -1) {
return this.messages.splice(index, 1)[0] as Extract<ServerMessage, { type: T }>;
}
if (this.closed) {
throw new HandshakeError("The server closed the connection");
}
await new Promise<void>((resolve) => this.#waiters.push(resolve));
}
}
#wake() {
for (const waiter of this.#waiters.splice(0)) waiter();
}
}
export async function connect(
address: ServerAddress,
name: string,
): Promise<{ socket: ServerSocket; welcome: Welcome }> {
const socket = await new Promise<WebSocket>((resolve, reject) => {
let ws: WebSocket;
try {
ws = new WebSocket(address.ws_url);
} catch {
reject(new HandshakeError(`Couldn't connect to the server at ${address.ws_url}`));
return;
}
const timeout = setTimeout(() => {
ws.close();
reject(new HandshakeError(`The server at ${address.ws_url} didn't answer`));
}, CONNECT_TIMEOUT_MS);
ws.addEventListener("open", () => {
clearTimeout(timeout);
resolve(ws);
});
ws.addEventListener("error", () => {
clearTimeout(timeout);
reject(new HandshakeError(`Couldn't connect to the server at ${address.ws_url}`));
});
});
const server = new ServerSocket(socket);
server.send({ type: "hello", name, protocol: PROTOCOL_VERSION });
const answer = await server.next("welcome", "rejected");
if (answer.type === "rejected") {
server.close();
throw new HandshakeError(answer.reason);
}
return { socket: server, welcome: answer };
}
// tell the server everything's loaded and wait to be let in
export async function join(socket: ServerSocket): Promise<Join> {
socket.send({ type: "ready" });
const answer = await socket.next("join", "rejected");
if (answer.type === "rejected") {
socket.close();
throw new HandshakeError(answer.reason);
}
return answer;
}
// downloads a file and checks it's the one the server listed. use the bytes returned, never fetch it again
export async function fetch_verified(url: URL, sha256: string, what: string): Promise<Uint8Array<ArrayBuffer>> {
let response: Response;
try {
response = await fetch(url);
} catch {
throw new HandshakeError(`Couldn't download ${what} from ${url}`);
}
if (!response.ok) {
throw new HandshakeError(`Couldn't download ${what} (${response.status})`);
}
const bytes = new Uint8Array(await response.arrayBuffer());
const digest = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
if (digest !== sha256) {
throw new HandshakeError(`${what} doesn't match what the server listed`);
}
return bytes;
}
// a url for code that was already downloaded and checked, so importing it can't fetch something else
export function code_url(bytes: Uint8Array<ArrayBuffer>): string {
return URL.createObjectURL(new Blob([bytes], { type: "text/javascript" }));
}
export async function load_atlas(address: ServerAddress, atlas: AtlasListing) {
const [png, json] = await Promise.all([
fetch_verified(new URL(atlas.png, address.base), atlas.sha256.png, "the texture atlas"),
fetch_verified(new URL(atlas.json, address.base), atlas.sha256.json, "the texture atlas"),
]);
return {
image: await createImageBitmap(new Blob([png], { type: "image/png" })),
regions: JSON.parse(new TextDecoder().decode(json)) as Record<string, { x: number; y: number }>,
};
}
// players ok a cross-origin server's mods once, per server and exact mod versions
function trust_key(address: ServerAddress) {
return TRUST_KEY_PREFIX + address.base.origin;
}
function mod_versions(welcome: Welcome) {
return welcome.mods.map((mod) => `${mod.id}@${mod.hash}`).sort().join(",");
}
export function is_trusted(address: ServerAddress, welcome: Welcome): boolean {
try {
return localStorage.getItem(trust_key(address)) === mod_versions(welcome);
} catch {
return false;
}
}
export function remember_trust(address: ServerAddress, welcome: Welcome) {
try {
localStorage.setItem(trust_key(address), mod_versions(welcome));
} catch {
// private windows and blocked storage just ask again next time
}
}
+39 -20
View File
@@ -1,8 +1,11 @@
import { AssetManager } from "./assets.ts";
import { ClientWorld } from "./client_world.ts";
import { InputManager } from "./input_manager.ts";
import { Connection, get_player_name, get_server_url } from "./network.ts";
import { begin_drawing, clear_background, end_drawing, init_font, init_window } from "./renderer/mod.ts";
import { Connection, get_player_name } from "./network.ts";
import { connect, HandshakeError, is_trusted, join, load_atlas, remember_trust, server_address } from "./handshake.ts";
import { confirm_mods } from "./confirm_mods.ts";
import { ModLoadError } from "$/common/mod_loader.ts";
import { begin_drawing, clear_background, end_drawing, init_font, init_window, load_texture } from "./renderer/mod.ts";
import { is_stopped, show_fatal_error } from "./fatal.ts";
import { load_client_mods, set_mods_world } from "./mods.ts";
@@ -76,9 +79,6 @@ InputManager.initialize(canvas);
AssetManager.instance.load("bworld:assets_text", "/assets/ASSETS.md");
AssetManager.instance.load("bworld:textures", "/assets/sprites/textures.png");
AssetManager.instance.load("bworld:textures_info", "/assets/sprites/textures.json");
AssetManager.instance.load("bworld:player", "/assets/sprites/player.png");
AssetManager.instance.load("bworld:roguelike", "/assets/sprites/roguelike.png");
AssetManager.instance.load("bworld:ui", "/assets/sprites/ui.png");
@@ -90,24 +90,37 @@ await AssetManager.instance.load_all();
init_font();
// the game only runs against a server, it owns the world and everything in it
const connection = await Connection.open(get_server_url(), get_player_name());
let mods_loaded = false;
if (connection) {
console.log(`Connected to ${get_server_url()}`);
// the game only runs against a server, it owns the world and everything in it. see "Delivery to clients" in MODS.md
async function join_server(): Promise<Connection> {
const address = server_address();
const { socket, welcome } = await connect(address, get_player_name());
console.log(`Connected to ${address.ws_url}`);
try {
// blocks, items and textures all come from mods, so this happens before anything else
await load_client_mods(connection.mods);
console.log(`Mods: ${connection.mods.map((mod) => `${mod.id} ${mod.version}`).join(", ") || "none"}`);
mods_loaded = true;
if (address.cross_origin && !is_trusted(address, welcome)) {
if (!await confirm_mods(address, welcome)) {
throw new HandshakeError(`You didn't join ${address.base.host}`);
}
remember_trust(address, welcome);
}
// textures, blocks and items all come from the server's mods, so this happens before anything else
const atlas = await load_atlas(address, welcome.atlas);
AssetManager.instance.assets["bworld:textures"] = load_texture(atlas.image);
AssetManager.instance.assets["bworld:textures_info"] = atlas.regions;
await load_client_mods(welcome.mods, address.base);
console.log(`Mods: ${welcome.mods.map((mod) => `${mod.id} ${mod.version}`).join(", ") || "none"}`);
const joined = await join(socket);
return new Connection(socket, welcome, joined);
} catch (e) {
console.error(e);
show_fatal_error(`Couldn't load the server's mods: ${e instanceof Error ? e.message : e}`);
connection.socket.close();
socket.close();
throw e;
}
}
if (connection && mods_loaded) {
try {
const connection = await join_server();
const client_world = new ClientWorld(connection);
set_mods_world(client_world);
client_world.add_chat("Connected to the server");
@@ -116,6 +129,12 @@ if (connection && mods_loaded) {
loop.start();
console.log("Game started");
} else if (!connection) {
show_fatal_error(`Couldn't connect to the server at ${get_server_url()}`);
} catch (e) {
console.error(e);
const message = e instanceof HandshakeError
? e.message
: e instanceof ModLoadError
? `Couldn't load the server's mods: ${e.message}`
: `Something went wrong joining the server: ${e instanceof Error ? e.message : e}`;
show_fatal_error(message);
}
+26 -15
View File
@@ -8,6 +8,7 @@ import type { OreJson } from "$/common/mod_data.ts";
import { AIR_ID } from "$/common/protocol.ts";
import { Position } from "$/common/components/position.ts";
import type { ClientWorld } from "./client_world.ts";
import { code_url, fetch_verified } from "./handshake.ts";
// what the chunk workers need to generate the same world as the server
export const worldgen_mods: { scripts: { mod: string; url: string }[]; ores: OreJson[] } = { scripts: [], ores: [] };
@@ -19,26 +20,36 @@ export function set_mods_world(client_world: ClientWorld) {
world = client_world;
}
export async function load_client_mods(listings: ModListing[]) {
const site = new URL("/", location.href);
const datas = await Promise.all(listings.map(async (listing) => {
const response = await fetch(new URL(listing.data, site));
if (!response.ok) {
throw new ModLoadError(listing.id, `couldn't download its data (${response.status})`);
}
return { id: listing.id, data: await response.json() as ModData };
// downloads every mod's files and checks them against the hashes the server listed, then registers the data and
// runs the client scripts. everything a mod runs is imported from the checked bytes, never fetched twice
export async function load_client_mods(listings: ModListing[], base: URL) {
const downloads = await Promise.all(listings.map(async (listing) => {
const get = async (path: string | undefined, sha256: string | undefined, what: string) => {
if (!path) return undefined;
try {
return await fetch_verified(new URL(path, base), sha256 ?? "", what);
} catch (e) {
throw new ModLoadError(listing.id, (e as Error).message);
}
};
const [data, client, worldgen] = await Promise.all([
get(listing.data, listing.sha256.data, "its data"),
get(listing.client, listing.sha256.client, "its client script"),
get(listing.worldgen, listing.sha256.worldgen, "its worldgen script"),
]);
return { listing, data: JSON.parse(new TextDecoder().decode(data)) as ModData, client, worldgen };
}));
const recipes = register_mod_data(datas);
const recipes = register_mod_data(downloads.map(({ listing, data }) => ({ id: listing.id, data })));
worldgen_mods.ores = recipes.ores;
worldgen_mods.scripts = listings.flatMap((listing) =>
listing.worldgen ? [{ mod: listing.id, url: new URL(listing.worldgen, site).href }] : []
worldgen_mods.scripts = downloads.flatMap(({ listing, worldgen }) =>
worldgen ? [{ mod: listing.id, url: code_url(worldgen) }] : []
);
for (const listing of listings) {
if (!listing.client) continue;
const module = await import(new URL(listing.client, site).href);
for (const { listing, client } of downloads) {
if (!client) continue;
const module = await import(code_url(client));
if (typeof module.setup !== "function") {
throw new ModLoadError(listing.id, "the client script doesn't export a setup function");
}
+19 -65
View File
@@ -1,7 +1,6 @@
import { BlockChange, ClientMessage, PlayerInfo, ServerMessage } from "$/common/protocol.ts";
import type { ModListing } from "$/common/mod_loader.ts";
const CONNECT_TIMEOUT_MS = 3000;
import type { Join, ServerSocket, Welcome } from "./handshake.ts";
export interface RemotePlayer extends PlayerInfo {
// where we draw them, eased towards x/y/z so movement isnt choppy
@@ -12,7 +11,7 @@ export interface RemotePlayer extends PlayerInfo {
}
export class Connection {
socket: WebSocket;
#server: ServerSocket;
id: string;
name: string;
seed: string;
@@ -22,35 +21,31 @@ export class Connection {
selected_slot: number;
players = new Map<string, RemotePlayer>();
// handled by the network system inside the game loop, not whenever the socket feels like it
incoming: ServerMessage[] = [];
closed = false;
constructor(socket: WebSocket, welcome: Extract<ServerMessage, { type: "welcome" }>) {
this.socket = socket;
this.id = welcome.id;
this.name = welcome.name;
constructor(server: ServerSocket, welcome: Welcome, join: Join) {
this.#server = server;
this.id = join.id;
this.name = join.name;
this.seed = welcome.seed;
this.mods = welcome.mods;
this.initial_changes = welcome.changes;
this.spawn = welcome.spawn;
this.selected_slot = welcome.selected_slot;
for (const player of welcome.players) {
this.initial_changes = join.changes;
this.spawn = join.spawn;
this.selected_slot = join.selected_slot;
for (const player of join.players) {
this.add_player(player);
}
}
socket.addEventListener("message", (event) => {
this.incoming.push(JSON.parse(event.data));
});
socket.addEventListener("close", () => {
this.closed = true;
});
// handled by the network system inside the game loop, not whenever the socket feels like it
get incoming(): ServerMessage[] {
return this.#server.messages;
}
get closed() {
return this.#server.closed;
}
send(message: ClientMessage) {
if (this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(message));
}
this.#server.send(message);
}
add_player(player: PlayerInfo) {
@@ -62,40 +57,6 @@ export class Connection {
color: color_from_name(player.name),
});
}
static open(url: string, name: string): Promise<Connection | undefined> {
return new Promise((resolve) => {
let socket: WebSocket;
try {
socket = new WebSocket(url);
} catch {
resolve(undefined);
return;
}
const timeout = setTimeout(() => {
socket.close();
resolve(undefined);
}, CONNECT_TIMEOUT_MS);
socket.addEventListener("open", () => {
socket.send(JSON.stringify({ type: "hello", name } satisfies ClientMessage));
});
socket.addEventListener("message", (event) => {
const message: ServerMessage = JSON.parse(event.data);
if (message.type === "welcome") {
clearTimeout(timeout);
resolve(new Connection(socket, message));
}
}, { once: true });
socket.addEventListener("error", () => {
clearTimeout(timeout);
resolve(undefined);
});
});
}
}
function color_from_name(name: string): [number, number, number] {
@@ -122,13 +83,6 @@ function color_from_name(name: string): [number, number, number] {
return [r + m, g + m, b + m];
}
export function get_server_url(): string {
const params = new URLSearchParams(location.search);
const server = params.get("server");
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${server ?? location.host}/ws`;
}
export function get_player_name(): string {
return new URLSearchParams(location.search).get("name") ?? "";
}
+1 -1
View File
@@ -9,7 +9,7 @@ import {
} from "./core.ts";
import { Texture } from "./types.ts";
export function load_texture(image: HTMLImageElement): Texture {
export function load_texture(image: HTMLImageElement | ImageBitmap): Texture {
const texture = create_texture(image.width, image.height);
device.queue.copyExternalImageToTexture({ source: image }, { texture }, [image.width, image.height]);
+11 -1
View File
@@ -20,15 +20,25 @@ export interface ModData {
ores: OreJson[];
}
// a mod as the server lists it to clients, in load order. paths are relative to the site root
// a mod as the server lists it to clients, in load order. paths are relative to the server's root
export interface ModListing {
id: string;
name: string;
version: string;
// short hash of everything public, the folder it's served from
hash: string;
data: string;
client?: string;
worldgen?: string;
// sha-256 in hex of each file, clients check these before using them
sha256: { data: string; client?: string; worldgen?: string };
}
// the texture atlas with every mod's textures, built by the server
export interface AtlasListing {
png: string;
json: string;
sha256: { png: string; json: string };
}
export class RecipeBook {
+20 -5
View File
@@ -1,10 +1,14 @@
// messages sent between the client and the server, as json over a websocket
import type { Faces } from "./constants.ts";
import type { ItemData } from "./inventory.ts";
import type { ModListing } from "./mod_loader.ts";
import type { AtlasListing, ModListing } from "./mod_loader.ts";
export const AIR_ID = "bworld:air";
// bump when a client and server of different versions can't play together.
// the server rejects a different version before the client downloads anything
export const PROTOCOL_VERSION = 1;
export interface PlayerInfo {
id: string;
name: string;
@@ -44,7 +48,9 @@ export interface ScreenLayout {
// clients send what the player is trying to do, the server decides what happens
export type ClientMessage =
| { type: "hello"; name: string }
| { type: "hello"; name: string; protocol: number }
// sent once the client has downloaded, checked and loaded everything in welcome
| { type: "ready" }
| { type: "move"; x: number; y: number; z: number; yaw: number; pitch: number }
| { type: "break_block"; x: number; y: number; z: number }
// right click on a block: interact with it, or place the held block against `face`
@@ -56,14 +62,23 @@ export type ClientMessage =
| { type: "chat"; text: string };
export type ServerMessage =
// what to download before joining, see "Delivery to clients" in MODS.md
| {
type: "welcome";
protocol: number;
seed: string;
atlas: AtlasListing;
// in load order, the client loads them before joining the world
mods: ModListing[];
}
// the connection is closed right after
| { type: "rejected"; reason: string }
// answers ready, the player is in the world from here
| {
type: "join";
id: string;
// the server may change the name asked for, like alice to alice2
name: string;
seed: string;
// in load order, the client loads them before joining the world
mods: ModListing[];
players: PlayerInfo[];
changes: BlockChange[];
spawn: { x: number; y: number; z: number; yaw: number; pitch: number };
+63 -7
View File
@@ -18,9 +18,10 @@ import {
CRAFTING_RESULT_SLOT,
MAX_CHAT_LENGTH,
MAX_NAME_LENGTH,
PROTOCOL_VERSION,
ServerMessage,
} from "$/common/protocol.ts";
import type { ModListing, RecipeBook } from "$/common/mod_loader.ts";
import type { AtlasListing, ModListing, RecipeBook } from "$/common/mod_loader.ts";
import type { WorldgenSetup } from "$/common/generation.ts";
import { BLOCK_BEHAVIORS } from "./blocks.ts";
import { ModRuntime, run_guarded } from "./mod_runtime.ts";
@@ -34,6 +35,8 @@ const EYE_HEIGHT = 1.69;
// tiles further than this many chunks from every player don't tick
const SIMULATION_DISTANCE = 6;
const MAX_GIVE = 64 * 36;
// how long a client gets between welcome and ready to download and load everything
const READY_TIMEOUT_TICKS = 60 * TICKS_PER_SECOND;
// everything the game server needs from whatever runs it
export interface GameHost {
@@ -63,6 +66,7 @@ export interface SavedWorld {
// the loaded mods, see load_mods.ts
export interface GameMods {
listings: ModListing[];
atlas: AtlasListing;
recipes: RecipeBook;
worldgen?: WorldgenSetup;
runtime: ModRuntime;
@@ -72,6 +76,8 @@ export class GameServer {
world: ServerWorld;
#host: GameHost;
#players = new Map<number, ServerPlayer>();
// connections that got welcome and are downloading mods, by conn
#pending = new Map<number, { name: unknown; since: number }>();
#saved_players: Record<string, SavedPlayer> = {};
#tick = 0;
#mods: GameMods;
@@ -108,6 +114,7 @@ export class GameServer {
on_connect(_conn: number) {}
on_disconnect(conn: number) {
this.#pending.delete(conn);
const player = this.#players.get(conn);
if (!player) {
return;
@@ -135,9 +142,7 @@ export class GameServer {
const player = this.#players.get(conn);
if (!player) {
if (message.type === "hello") {
this.#join(conn, message.name);
}
this.#handshake(conn, message);
return;
}
@@ -182,6 +187,13 @@ export class GameServer {
this.mods.tick();
for (const [conn, pending] of this.#pending) {
if (this.#tick - pending.since > READY_TIMEOUT_TICKS) {
this.#pending.delete(conn);
this.#reject(conn, "Took too long to load the server's mods");
}
}
for (const player of this.#players.values()) {
this.#sync(player);
}
@@ -308,6 +320,52 @@ export class GameServer {
// messages
// hello -> welcome, then ready -> join. see "Delivery to clients" in MODS.md
#handshake(conn: number, message: ClientMessage) {
if (message.type === "hello") {
if (this.#pending.has(conn)) {
return;
}
if (message.protocol !== PROTOCOL_VERSION) {
this.#reject(
conn,
`This server needs a game version with protocol ${PROTOCOL_VERSION}, yours has ${
message.protocol ?? "none"
}. Reload the page to update.`,
);
return;
}
this.#pending.set(conn, { name: message.name, since: this.#tick });
this.#host.send(
conn,
JSON.stringify(
{
type: "welcome",
protocol: PROTOCOL_VERSION,
seed: this.world.seed,
atlas: this.#mods.atlas,
mods: this.#mods.listings,
} satisfies ServerMessage,
),
);
return;
}
if (message.type === "ready") {
const pending = this.#pending.get(conn);
if (!pending) {
return;
}
this.#pending.delete(conn);
this.#join(conn, pending.name);
}
}
#reject(conn: number, reason: string) {
this.#host.send(conn, JSON.stringify({ type: "rejected", reason } satisfies ServerMessage));
this.#host.close(conn);
}
#join(conn: number, raw_name: unknown) {
const player = new ServerPlayer(conn, this.#unique_name(raw_name));
const saved = this.#saved_players[player.name];
@@ -316,11 +374,9 @@ export class GameServer {
}
this.#send(player, {
type: "welcome",
type: "join",
id: player.id,
name: player.name,
seed: this.world.seed,
mods: this.#mods.listings,
players: [...this.#players.values()].map((p) => p.info()),
changes: this.world.all_changes(),
spawn: { x: player.x, y: player.y, z: player.z, yaw: player.yaw, pitch: player.pitch },
+2 -1
View File
@@ -1,4 +1,4 @@
import type { ModData, ModListing } from "$/common/mod_loader.ts";
import type { AtlasListing, ModData, ModListing } from "$/common/mod_loader.ts";
// messages between server/main.ts (the host) and the game server worker
@@ -7,6 +7,7 @@ export type HostToGame =
type: "init";
save: string | undefined;
default_seed: string;
atlas: AtlasListing;
// in load order, with the scripts' code since the worker can't read files
mods: { listing: ModListing; data: ModData; server_code?: string; worldgen_code?: string }[];
}
+3 -1
View File
@@ -1,7 +1,7 @@
// starts a game server with its mods: registers their data, then imports worldgen and server scripts.
// the worker loads built mods, tests can load mods straight from their source folders
import { register_mod_data } from "$/common/mod_loader.ts";
import type { ModData, ModListing } from "$/common/mod_loader.ts";
import type { AtlasListing, ModData, ModListing } from "$/common/mod_loader.ts";
import { load_worldgen } from "$/common/worldgen_loader.ts";
import { add_base_item_hooks } from "./blocks.ts";
import { GameHost, GameServer } from "./game_server.ts";
@@ -19,6 +19,7 @@ export async function start_game(
host: GameHost,
save: string | undefined,
default_seed: string,
atlas: AtlasListing,
mods: ServerModSource[],
): Promise<GameServer> {
const recipes = register_mod_data(mods.map((mod) => ({ id: mod.listing.id, data: mod.data })));
@@ -32,6 +33,7 @@ export async function start_game(
const runtime = new ModRuntime();
const game = new GameServer(host, save, default_seed, {
listings: mods.map((mod) => mod.listing),
atlas,
recipes,
worldgen,
runtime,
+1
View File
@@ -54,6 +54,7 @@ async function init(message: Extract<HostToGame, { type: "init" }>) {
},
message.save,
message.default_seed,
message.atlas,
message.mods.map((mod) => ({
listing: mod.listing,
data: mod.data,
+28 -10
View File
@@ -70,7 +70,7 @@ game.onmessage = (event: MessageEvent<GameToHost>) => {
};
// what deno task build made. no index means the build failed or never ran, and the game never runs without its mods
function read_mods(): Extract<HostToGame, { type: "init" }>["mods"] {
function read_mods(): Pick<Extract<HostToGame, { type: "init" }>, "mods" | "atlas"> {
let index: ServerModIndex;
try {
index = JSON.parse(Deno.readTextFileSync(`${SERVER_MODS_DIR}/index.json`));
@@ -78,12 +78,15 @@ function read_mods(): Extract<HostToGame, { type: "init" }>["mods"] {
console.error(`No ${SERVER_MODS_DIR}/index.json, run deno task build first (it also reports mod errors)`);
Deno.exit(1);
}
return index.mods.map(({ listing, server }) => ({
listing,
data: JSON.parse(Deno.readTextFileSync(`${STATIC_ROOT}/${listing.data}`)),
server_code: server ? Deno.readTextFileSync(server) : undefined,
worldgen_code: listing.worldgen ? Deno.readTextFileSync(`${STATIC_ROOT}/${listing.worldgen}`) : undefined,
}));
return {
atlas: index.atlas,
mods: index.mods.map(({ listing, server }) => ({
listing,
data: JSON.parse(Deno.readTextFileSync(`${STATIC_ROOT}/${listing.data}`)),
server_code: server ? Deno.readTextFileSync(server) : undefined,
worldgen_code: listing.worldgen ? Deno.readTextFileSync(`${STATIC_ROOT}/${listing.worldgen}`) : undefined,
})),
};
}
game.onerror = (event) => {
@@ -91,10 +94,25 @@ game.onerror = (event) => {
Deno.exit(1);
};
const mods = read_mods();
const { mods, atlas } = read_mods();
console.log(`Mods: ${mods.map((mod) => `${mod.listing.id} ${mod.listing.version}`).join(", ") || "none"}`);
const save = read_world();
post({ type: "init", save, default_seed: Deno.env.get("SEED") ?? crypto.randomUUID(), mods });
post({ type: "init", save, default_seed: Deno.env.get("SEED") ?? crypto.randomUUID(), atlas, mods });
// mod files and the atlas are named by their hash, so they never change and pages on other origins can load them
async function serve_static(req: Request, url: URL) {
const response = await serveDir(req, { fsRoot: STATIC_ROOT, quiet: true });
const shared = url.pathname.startsWith("/mods/") || url.pathname.startsWith("/assets/");
if (shared && response.ok) {
const headers = new Headers(response.headers);
headers.set("Access-Control-Allow-Origin", "*");
if (url.pathname.startsWith("/mods/") || /^\/assets\/sprites\/textures\.[0-9a-f]+\./.test(url.pathname)) {
headers.set("Cache-Control", "public, max-age=31536000, immutable");
}
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
}
return response;
}
function handle_socket(socket: WebSocket) {
const conn = next_conn++;
@@ -147,5 +165,5 @@ Deno.serve({ port: PORT, onListen: ({ port }) => console.log(`bworld server on h
return Response.redirect(new URL("/client/", url), 302);
}
return serveDir(req, { fsRoot: STATIC_ROOT, quiet: true });
return serve_static(req, url);
});
+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 });