Improve networking
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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") ?? "";
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 }[];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user