Server authority
This commit is contained in:
+59
-189
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user