209 lines
6.8 KiB
JavaScript
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"),
|
|
});
|
|
}
|