Files
Battle-for-Tismo/server/server.js
T
2026-09-16 19:59:18 +02:00

209 lines
6.8 KiB
JavaScript

// 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)://<host>/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 "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"),
});
}