// Node entry point: serves the client and shared modules as static files and // hosts the authoritative game over a WebSocket. Run with: // // node server/server.js [--port 27015] [--bind 127.0.0.1] // // The browser client connects to ws(s):///ws on the same origin. The // wiring is exposed through startServer() so the test suite can boot the same // server on an ephemeral port. import { createServer } from "node:http"; import { readFile, stat } from "node:fs/promises"; import { dirname, extname, join, normalize, sep } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { WebSocketServer } from "./websocket.js"; import { GameServer } from "./game_server.js"; import { SECONDS_PER_HOUR } from "../shared/game_clock.js"; const ROOT = normalize(join(dirname(fileURLToPath(import.meta.url)), "..")); const MIME = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json; charset=utf-8", ".png": "image/png", ".svg": "image/svg+xml", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".wav": "audio/wav", ".mp3": "audio/mpeg", ".ogg": "audio/ogg", ".flac": "audio/flac", ".ttf": "font/ttf", ".woff": "font/woff", ".woff2": "font/woff2", ".ico": "image/x-icon", }; const TICK_MS = 50; // Movement still simulates every tick, but snapshots are broadcast at most this // often. A full snapshot is hundreds of KiB, and sending one per 50ms tick made // every client parse, allocate and repaint at 20Hz; the browser dead-reckons // movement between snapshots, so a lower rate is invisible in play. const SNAPSHOT_INTERVAL_S = 0.1; async function serveStatic(request, response) { try { const url = new URL(request.url, "http://localhost"); const pathname = decodeURIComponent(url.pathname); if (pathname === "/") { response.writeHead(302, { Location: "/client/index.html" }); response.end(); return; } const safe = normalize(pathname).replace(/^(\.\.[/\\])+/, ""); const filePath = join(ROOT, safe); if (!filePath.startsWith(ROOT + sep) && filePath !== ROOT) { response.writeHead(403).end("Forbidden"); return; } const info = await stat(filePath).catch(() => null); if (!info || !info.isFile()) { response.writeHead(404, { "Content-Type": "text/plain" }).end("Not found"); return; } const body = await readFile(filePath); response.writeHead(200, { "Content-Type": MIME[extname(filePath).toLowerCase()] || "application/octet-stream", "Cache-Control": "no-cache", }); response.end(body); } catch { response.writeHead(500, { "Content-Type": "text/plain" }).end("Server error"); } } // Whether a peer address is the local machine. Handles the IPv4-mapped IPv6 // form Node reports for a v4 socket ("::ffff:127.0.0.1") as well as plain v4/v6. export function isLoopback(address) { if (!address) return false; const value = String(address).replace(/^::ffff:/, ""); return value === "::1" || value === "localhost" || value.startsWith("127."); } // Whether a connection came from a page served on the local machine. The socket // has to be loopback and the browser's Origin header (which page scripts cannot // forge) has to name a loopback host, so a proxied remote client whose socket // looks local is still treated as remote. export function isLocalRequest(remoteAddress, origin) { if (!isLoopback(remoteAddress)) return false; let host; try { host = new URL(String(origin)).hostname; } catch { return false; } return isLoopback(host.replace(/^\[|\]$/g, "")); } // Builds the whole server without listening. `listen()` resolves once the port // is bound and yields the bound address. A game is configured here, before any // player connects: the server owns one persistent world and clients only join // it (`seed`, `testing` and `civilisations` exist for tests and operators). export function startServer({ port = 27015, bind = "127.0.0.1", log = console.log, seed = 0, testing = false, civilisations = [], mapConfig = null, warmupDays = 0, } = {}) { const httpServer = createServer(serveStatic); const wsServer = new WebSocketServer(httpServer, { path: "/ws" }); const connections = new Map(); const network = { getPeerIds() { return Array.from(connections.keys()); }, send(peerId, message) { const connection = connections.get(peerId); if (connection) connection.send(JSON.stringify(message)); }, }; const gameServer = new GameServer(network); if (warmupDays > 0) log(`settling the world for ${warmupDays} days...`); gameServer.configureGame({ seed, player_civ: 0, civilisations, testing, mapConfig, warmupDays }); if (warmupDays > 0) log(`world settled at day ${warmupDays}`); function broadcastPlayers() { const players = {}; for (const [peerId, connection] of connections) { if (!connection.name) continue; players[peerId] = { id: peerId, name: connection.name, admin: connection.name === gameServer.adminName, }; } const message = JSON.stringify({ t: "players", players }); for (const connection of connections.values()) connection.send(message); } function handleMessage(connection, message) { switch (message.t) { case "request_games": connection.send(JSON.stringify({ t: "game_list", games: gameServer.gameList() })); break; case "login": { const result = gameServer.authenticate(connection.id, message.name, message.password); if (!result.ok) { connection.send(JSON.stringify({ t: "login_rejected", reason: result.reason })); connection.close(1000); return; } connection.name = result.name; connection.send(JSON.stringify({ t: "assigned", playerId: connection.id, admin: result.admin, name: result.name, })); broadcastPlayers(); gameServer.onPeerJoined(connection.id, result.name); break; } case "claim_civ": gameServer.claimCiv(connection.id, message.civ); break; case "order": { const order = message.order || {}; // The free shortcut is only honoured for a page on the local machine, // whatever the game's testing flag says. if (order.free && !isLocalRequest(connection.remoteAddress, connection.origin)) { order.free = false; } gameServer.enqueueOrder(connection.id, order); break; } default: break; } } wsServer.on("connection", (connection) => { connections.set(connection.id, connection); connection.name = ""; gameServer.setPeerLocal( connection.id, isLocalRequest(connection.remoteAddress, connection.origin) ); connection.on("message", (raw) => { let message; try { message = JSON.parse(raw); } catch { return; } handleMessage(connection, message); }); connection.on("close", () => { connections.delete(connection.id); gameServer.onPeerLeft(connection.id); broadcastPlayers(); }); }); // Fixed-step loop: movement advances every tick and the clock strikes an hour // every SECONDS_PER_HOUR real seconds. The state is only broadcast once per // SNAPSHOT_INTERVAL_S however often it changed, so a game with moving units // does not flood clients with full snapshots. Exposed as `step` so tests can // drive the clock deterministically. let hourAccumulator = 0; let snapshotAccumulator = 0; function step(seconds) { // Drain queued player orders first, under a small time budget, so a burst // from several players never runs every path search in one tick. gameServer.processOrders(); gameServer.advanceMovement(seconds / SECONDS_PER_HOUR); hourAccumulator += seconds; while (hourAccumulator >= SECONDS_PER_HOUR) { hourAccumulator -= SECONDS_PER_HOUR; gameServer.tickHour(); } snapshotAccumulator += seconds; if (snapshotAccumulator >= SNAPSHOT_INTERVAL_S) { snapshotAccumulator = 0; gameServer.flushIfDirty(); } } const timer = setInterval(() => step(TICK_MS / 1000), TICK_MS); const listen = new Promise((resolve, reject) => { httpServer.once("error", reject); httpServer.listen(port, bind, () => { const address = httpServer.address(); log(`Battle for 'Tismo listening on http://${bind}:${address.port}/ (ws://${bind}:${address.port}/ws)`); resolve(address); }); }); function close() { clearInterval(timer); for (const connection of connections.values()) connection.close(1001); connections.clear(); httpServer.close(); } return { httpServer, wsServer, gameServer, network, connections, listen, step, close }; } function argValue(name, fallback) { const index = process.argv.indexOf(name); if (index >= 0 && index + 1 < process.argv.length) return process.argv[index + 1]; return fallback; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { startServer({ port: Number(argValue("--port", process.env.PORT || 27015)), bind: argValue("--bind", process.env.BIND || "127.0.0.1"), seed: Number(argValue("--seed", process.env.SEED || 0)) | 0, // Free, instant orders for local play; remote peers still have their `free` // flag stripped in the order handler. testing: process.argv.includes("--testing"), // Settle the world this many days before serving, so the game opens with // its economy established. `--warmup 0` skips it. warmupDays: Number(argValue("--warmup", process.env.WARMUP || 150)) | 0, }); }