diff --git a/MODS.md b/MODS.md index 9aa3fc9..0302319 100644 --- a/MODS.md +++ b/MODS.md @@ -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:`), 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` diff --git a/build.ts b/build.ts index ff9a6fa..aff341c 100644 --- a/build.ts +++ b/build.ts @@ -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:) and every mod's (:) -async function build_atlas(mods: LoadedMod[]) { +// one atlas with the engine's textures (engine:) and every mod's (:). +// named by its hash, so clients can cache it forever and download it from other origins +async function build_atlas(mods: LoadedMod[]): Promise { const textures = new Map(); 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 { 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 | 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/// 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 }; @@ -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 diff --git a/client/confirm_mods.ts b/client/confirm_mods.ts new file mode 100644 index 0000000..7ad1c76 --- /dev/null +++ b/client/confirm_mods.ts @@ -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 { + 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); + }); +} diff --git a/client/handshake.ts b/client/handshake.ts new file mode 100644 index 0000000..9c2eea2 --- /dev/null +++ b/client/handshake.ts @@ -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; +export type Join = Extract; + +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(...types: T[]): Promise> { + 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; + } + if (this.closed) { + throw new HandshakeError("The server closed the connection"); + } + await new Promise((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((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 { + 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> { + 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): 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, + }; +} + +// 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 + } +} diff --git a/client/main.ts b/client/main.ts index b514cc9..4422fef 100644 --- a/client/main.ts +++ b/client/main.ts @@ -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 { + 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); } diff --git a/client/mods.ts b/client/mods.ts index 8a1125f..459e7f1 100644 --- a/client/mods.ts +++ b/client/mods.ts @@ -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"); } diff --git a/client/network.ts b/client/network.ts index f15e394..4692f8a 100644 --- a/client/network.ts +++ b/client/network.ts @@ -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(); - // 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) { - 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 { - 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") ?? ""; } diff --git a/client/renderer/textures.ts b/client/renderer/textures.ts index 60b89f3..7d0b829 100644 --- a/client/renderer/textures.ts +++ b/client/renderer/textures.ts @@ -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]); diff --git a/common/mod_loader.ts b/common/mod_loader.ts index e59b43c..847d1e7 100644 --- a/common/mod_loader.ts +++ b/common/mod_loader.ts @@ -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 { diff --git a/common/protocol.ts b/common/protocol.ts index cbea30f..41c5ec1 100644 --- a/common/protocol.ts +++ b/common/protocol.ts @@ -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 }; diff --git a/server/game/game_server.ts b/server/game/game_server.ts index f34005f..0c5d9df 100644 --- a/server/game/game_server.ts +++ b/server/game/game_server.ts @@ -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(); + // connections that got welcome and are downloading mods, by conn + #pending = new Map(); #saved_players: Record = {}; #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 }, diff --git a/server/game/host_protocol.ts b/server/game/host_protocol.ts index a226496..659ec12 100644 --- a/server/game/host_protocol.ts +++ b/server/game/host_protocol.ts @@ -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 }[]; } diff --git a/server/game/load_mods.ts b/server/game/load_mods.ts index 3db2cef..9177a4d 100644 --- a/server/game/load_mods.ts +++ b/server/game/load_mods.ts @@ -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 { 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, diff --git a/server/game/worker.ts b/server/game/worker.ts index 13eebe5..e8a27c5 100644 --- a/server/game/worker.ts +++ b/server/game/worker.ts @@ -54,6 +54,7 @@ async function init(message: Extract) { }, message.save, message.default_seed, + message.atlas, message.mods.map((mod) => ({ listing: mod.listing, data: mod.data, diff --git a/server/main.ts b/server/main.ts index f4b6e47..00756e9 100644 --- a/server/main.ts +++ b/server/main.ts @@ -70,7 +70,7 @@ game.onmessage = (event: MessageEvent) => { }; // 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["mods"] { +function read_mods(): Pick, "mods" | "atlas"> { let index: ServerModIndex; try { index = JSON.parse(Deno.readTextFileSync(`${SERVER_MODS_DIR}/index.json`)); @@ -78,12 +78,15 @@ function read_mods(): Extract["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); }); diff --git a/tests/handshake_test.ts b/tests/handshake_test.ts new file mode 100644 index 0000000..7299d3e --- /dev/null +++ b/tests/handshake_test.ts @@ -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), []); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts index 0bda5cd..194a20f 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -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(); + const closed = new Set(); 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) { diff --git a/tests/mod_loading_test.ts b/tests/mod_loading_test.ts index 78f2aba..2d58191 100644 --- a/tests/mod_loading_test.ts +++ b/tests/mod_loading_test.ts @@ -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 });