Server authority

This commit is contained in:
2026-09-24 19:34:07 -03:00
parent 1bef94ce0c
commit 79faa556de
75 changed files with 2247 additions and 1632 deletions
+59 -189
View File
@@ -1,235 +1,105 @@
import { serveDir } from "@std/http/file-server";
import { CHUNK_HEIGHT } from "$/common/constants.ts";
import {
BlockChange,
ClientMessage,
MAX_CHAT_LENGTH,
MAX_NAME_LENGTH,
PlayerInfo,
ServerMessage,
} from "$/common/protocol.ts";
import { GameToHost, HostToGame } from "./game/host_protocol.ts";
const PORT = Number(Deno.env.get("PORT") ?? 8000);
const WORLD_FILE = Deno.env.get("WORLD_FILE") ?? "world.json";
const STATIC_ROOT = "build";
const SAVE_INTERVAL_MS = 30_000;
const MAX_MESSAGE_SIZE = 4096;
// how far from a player a block can be changed, a bit more than the client's reach
const MAX_REACH = 8;
const SHUTDOWN_TIMEOUT_MS = 5000;
const BLOCK_ID_PATTERN = /^[a-z0-9_]+:[a-z0-9_]+$/;
// the host only does files and networking, the game runs in a worker with no permissions
const game = new Worker(new URL("./game/worker.ts", import.meta.url), {
type: "module",
deno: { permissions: "none" },
} as WorkerOptions);
interface SavedWorld {
seed: string;
changes: BlockChange[];
const sockets = new Map<number, WebSocket>();
let next_conn = 1;
function post(message: HostToGame) {
game.postMessage(message);
}
interface Client {
socket: WebSocket;
player?: PlayerInfo;
}
// the server doesnt generate terrain, clients do that from the seed
// we only keep track of what players changed on top of it
class ServerWorld {
seed: string;
changes = new Map<string, BlockChange>();
dirty = false;
constructor(seed: string) {
this.seed = seed;
}
set_block(x: number, y: number, z: number, id: string) {
this.changes.set(`${x},${y},${z}`, [x, y, z, id]);
this.dirty = true;
}
static load(path: string): ServerWorld {
try {
const saved: SavedWorld = JSON.parse(Deno.readTextFileSync(path));
const world = new ServerWorld(saved.seed);
for (const [x, y, z, id] of saved.changes) {
world.changes.set(`${x},${y},${z}`, [x, y, z, id]);
}
console.log(`Loaded ${path} (${world.changes.size} block changes)`);
return world;
} catch (e) {
if (!(e instanceof Deno.errors.NotFound)) {
throw e;
}
const world = new ServerWorld(Deno.env.get("SEED") ?? crypto.randomUUID());
world.dirty = true;
console.log(`Created new world with seed ${world.seed}`);
return world;
function read_world(): string | undefined {
try {
return Deno.readTextFileSync(WORLD_FILE);
} catch (e) {
if (e instanceof Deno.errors.NotFound) {
return undefined;
}
}
save(path: string) {
if (!this.dirty) {
return;
}
const saved: SavedWorld = { seed: this.seed, changes: [...this.changes.values()] };
// write then rename so a crash mid write doesnt eat the world
Deno.writeTextFileSync(`${path}.tmp`, JSON.stringify(saved));
Deno.renameSync(`${path}.tmp`, path);
this.dirty = false;
throw e;
}
}
const world = ServerWorld.load(WORLD_FILE);
const clients = new Set<Client>();
function send(client: Client, message: ServerMessage) {
if (client.socket.readyState === WebSocket.OPEN) {
client.socket.send(JSON.stringify(message));
}
function write_world(data: string) {
// write then rename so a crash mid write doesnt eat the world
Deno.writeTextFileSync(`${WORLD_FILE}.tmp`, data);
Deno.renameSync(`${WORLD_FILE}.tmp`, WORLD_FILE);
}
function broadcast(message: ServerMessage, except?: Client) {
const data = JSON.stringify(message);
for (const client of clients) {
if (client !== except && client.player && client.socket.readyState === WebSocket.OPEN) {
client.socket.send(data);
}
}
}
function is_number(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function is_int(value: unknown): value is number {
return Number.isInteger(value);
}
function clean_name(name: unknown): string {
const cleaned = typeof name === "string" ? name.replace(/[^A-Za-z0-9_]/g, "").slice(0, MAX_NAME_LENGTH) : "";
const base = cleaned || `player${Math.floor(Math.random() * 10000)}`;
// make it unique
const taken = new Set([...clients].map((c) => c.player?.name));
let final = base;
let i = 2;
while (taken.has(final)) {
final = `${base}${i}`;
i += 1;
}
return final;
}
function handle_message(client: Client, message: ClientMessage) {
if (!client.player) {
if (message.type !== "hello") {
return;
}
const player: PlayerInfo = {
id: crypto.randomUUID(),
name: clean_name(message.name),
x: 0,
y: 100,
z: 0,
yaw: 0,
pitch: 0,
};
send(client, {
type: "welcome",
id: player.id,
seed: world.seed,
players: [...clients].flatMap((c) => c.player ? [c.player] : []),
changes: [...world.changes.values()],
});
client.player = player;
broadcast({ type: "player_join", player }, client);
broadcast({ type: "chat", text: `${player.name} joined` });
console.log(`${player.name} joined (${clients.size} online)`);
return;
}
const player = client.player;
game.onmessage = (event: MessageEvent<GameToHost>) => {
const message = event.data;
switch (message.type) {
case "move": {
const { x, y, z, yaw, pitch } = message;
if (![x, y, z, yaw, pitch].every(is_number)) {
return;
case "ready":
console.log(`World ${WORLD_FILE} ready, seed ${message.seed}`);
break;
case "send": {
const socket = sockets.get(message.conn);
if (socket?.readyState === WebSocket.OPEN) {
socket.send(message.data);
}
Object.assign(player, { x, y, z, yaw, pitch });
broadcast({ type: "player_move", id: player.id, x, y, z, yaw, pitch }, client);
break;
}
case "set_block": {
const { x, y, z, id } = message;
if (!is_int(x) || !is_int(y) || !is_int(z) || y < 0 || y >= CHUNK_HEIGHT) {
return;
}
if (typeof id !== "string" || !BLOCK_ID_PATTERN.test(id)) {
return;
}
const dx = x + 0.5 - player.x;
const dy = y + 0.5 - (player.y + 1.69);
const dz = z + 0.5 - player.z;
if (dx * dx + dy * dy + dz * dz > MAX_REACH * MAX_REACH) {
return;
}
world.set_block(x, y, z, id);
broadcast({ type: "set_block", x, y, z, id }, client);
case "close":
sockets.get(message.conn)?.close();
break;
}
case "chat": {
if (typeof message.text !== "string") {
return;
case "save":
write_world(message.data);
if (message.final) {
console.log("World saved");
Deno.exit(0);
}
const text = message.text.trim().slice(0, MAX_CHAT_LENGTH);
if (text.length === 0) {
return;
}
console.log(`<${player.name}> ${text}`);
broadcast({ type: "chat", from: player.name, text });
break;
}
}
}
};
game.onerror = (event) => {
console.error("Game server crashed:", event.message);
Deno.exit(1);
};
const save = read_world();
post({ type: "init", save, default_seed: Deno.env.get("SEED") ?? crypto.randomUUID() });
function handle_socket(socket: WebSocket) {
const client: Client = { socket };
const conn = next_conn++;
socket.addEventListener("open", () => {
clients.add(client);
sockets.set(conn, socket);
post({ type: "connect", conn });
});
socket.addEventListener("message", (event) => {
if (typeof event.data !== "string" || event.data.length > MAX_MESSAGE_SIZE) {
return;
}
let message: ClientMessage;
try {
message = JSON.parse(event.data);
} catch {
return;
}
if (typeof message !== "object" || message === null) {
return;
}
handle_message(client, message);
post({ type: "message", conn, data: event.data });
});
socket.addEventListener("close", () => {
clients.delete(client);
if (client.player) {
broadcast({ type: "player_leave", id: client.player.id });
broadcast({ type: "chat", text: `${client.player.name} left` });
console.log(`${client.player.name} left (${clients.size} online)`);
if (sockets.delete(conn)) {
post({ type: "disconnect", conn });
}
});
}
setInterval(() => world.save(WORLD_FILE), SAVE_INTERVAL_MS);
function shutdown() {
console.log("Saving world...");
world.save(WORLD_FILE);
Deno.exit(0);
post({ type: "shutdown" });
setTimeout(() => {
console.error("Game server didn't save in time");
Deno.exit(1);
}, SHUTDOWN_TIMEOUT_MS);
}
Deno.addSignalListener("SIGINT", shutdown);
if (Deno.build.os !== "windows") {