// 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", ".ogg": "audio/ogg", ".ttf": "font/ttf", ".woff": "font/woff", ".woff2": "font/woff2", ".ico": "image/x-icon", }; const TICK_MS = 50; 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"); } } // Builds the whole server without listening. `listen()` resolves once the port // is bound and yields the bound address. export function startServer({ port = 27015, bind = "127.0.0.1", log = console.log } = {}) { 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); 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 "configure_game": if (connection.name !== gameServer.adminName) return; gameServer.configureGame(message.setup || {}); for (const peer of connections.values()) { peer.send(JSON.stringify({ t: "game_configured" })); } break; case "claim_civ": gameServer.claimCiv(connection.id, message.civ); break; case "order": gameServer.handleOrder(connection.id, message.order || {}); break; default: break; } } wsServer.on("connection", (connection) => { connections.set(connection.id, connection); connection.name = ""; 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 frame, the clock strikes an hour // every SECONDS_PER_HOUR real seconds, and one snapshot is broadcast per frame // in which the state actually changed. Exposed as `step` so tests can drive // the clock deterministically. let hourAccumulator = 0; function step(seconds) { gameServer.advanceMovement(seconds / SECONDS_PER_HOUR); hourAccumulator += seconds; while (hourAccumulator >= SECONDS_PER_HOUR) { hourAccumulator -= SECONDS_PER_HOUR; gameServer.tickHour(); } 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"), }); }