Files
bworld/server/main.ts
T
2026-09-24 18:34:16 -03:00

257 lines
6.7 KiB
TypeScript

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";
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 BLOCK_ID_PATTERN = /^[a-z0-9_]+:[a-z0-9_]+$/;
interface SavedWorld {
seed: string;
changes: BlockChange[];
}
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;
}
}
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;
}
}
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 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;
switch (message.type) {
case "move": {
const { x, y, z, yaw, pitch } = message;
if (![x, y, z, yaw, pitch].every(is_number)) {
return;
}
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);
break;
}
case "chat": {
if (typeof message.text !== "string") {
return;
}
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;
}
}
}
function handle_socket(socket: WebSocket) {
const client: Client = { socket };
socket.addEventListener("open", () => {
clients.add(client);
});
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);
});
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)`);
}
});
}
setInterval(() => world.save(WORLD_FILE), SAVE_INTERVAL_MS);
function shutdown() {
console.log("Saving world...");
world.save(WORLD_FILE);
Deno.exit(0);
}
Deno.addSignalListener("SIGINT", shutdown);
if (Deno.build.os !== "windows") {
Deno.addSignalListener("SIGTERM", shutdown);
}
Deno.serve({ port: PORT, onListen: ({ port }) => console.log(`bworld server on http://localhost:${port}/`) }, (req) => {
const url = new URL(req.url);
if (url.pathname === "/ws") {
if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") {
return new Response("Expected a websocket", { status: 426 });
}
const { socket, response } = Deno.upgradeWebSocket(req);
handle_socket(socket);
return response;
}
if (url.pathname === "/") {
return Response.redirect(new URL("/client/", url), 302);
}
return serveDir(req, { fsRoot: STATIC_ROOT, quiet: true });
});