const http = require("http"); const fs = require("fs"); const path = require("path"); const crypto = require("crypto"); const { WebSocketServer } = require("ws"); const PORT = Number(process.env.PORT || 8080); const MAX_PLAYERS = 8; const ROOT = __dirname; const HIGHSCORE_FILE = path.join(ROOT, "highscores.json"); const CONTENT_TYPES = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".json": "application/json; charset=utf-8", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".ico": "image/x-icon", }; const clientsBySocket = new Map(); const clientsById = new Map(); const highscoreByName = new Map(); const lobby = { phase: "lobby", // lobby | game players: [], // [{id,name,joinedAt,lastSeenAt}] hostId: null, roster: [], // active game roster [{id,name,joinedAt}] }; function now() { return Date.now(); } function makeId() { return crypto.randomUUID ? crypto.randomUUID() : `${now()}-${Math.floor(Math.random() * 1e6)}`; } function defaultName(id) { return `P-${id.slice(0, 4).toUpperCase()}`; } function normalizeName(value, fallback) { if (typeof value !== "string") { return fallback; } const normalized = value .replace(/\s+/g, " ") .trim() .slice(0, 20); return normalized || fallback; } function highscoreKey(name) { const normalized = normalizeName(name, ""); return normalized ? normalized.toLowerCase() : ""; } function normalizeHighscores(entries) { return [...entries] .filter((entry) => entry && typeof entry.name === "string") .map((entry) => ({ name: normalizeName(entry.name, "Unknown"), kills: Math.max(0, Math.floor(Number(entry.kills) || 0)), wins: Math.max(0, Math.floor(Number(entry.wins) || 0)), longestAlive: Math.max(0, Number(entry.longestAlive) || 0), updatedAt: Number.isFinite(entry.updatedAt) ? entry.updatedAt : 0, })) .sort( (a, b) => b.wins - a.wins || b.kills - a.kills || b.longestAlive - a.longestAlive || b.updatedAt - a.updatedAt || a.name.localeCompare(b.name), ) .slice(0, 12) .map((entry) => ({ name: entry.name, kills: entry.kills, wins: entry.wins, longestAlive: Math.round(entry.longestAlive * 10) / 10, })); } function getHighscores() { return normalizeHighscores(Array.from(highscoreByName.values())); } function loadHighscores() { try { if (!fs.existsSync(HIGHSCORE_FILE)) { return; } const raw = fs.readFileSync(HIGHSCORE_FILE, "utf8"); const parsed = JSON.parse(raw); const list = Array.isArray(parsed) ? normalizeHighscores(parsed) : []; for (const row of list) { const key = highscoreKey(row.name); if (key) { highscoreByName.set(key, { ...row, updatedAt: now() }); } } } catch { // ignore load failures } } function saveHighscores() { try { fs.writeFileSync(HIGHSCORE_FILE, `${JSON.stringify(getHighscores(), null, 2)}\n`); } catch { // ignore write failures } } function resolvePlayerName(clientId) { const active = clientsById.get(clientId); if (active && active.name) { return active.name; } const lobbyPlayer = lobby.players.find((player) => player.id === clientId); if (lobbyPlayer && lobbyPlayer.name) { return lobbyPlayer.name; } const rosterPlayer = lobby.roster.find((player) => player.id === clientId); if (rosterPlayer && rosterPlayer.name) { return rosterPlayer.name; } return defaultName(clientId); } function applyRoundReport(report) { if (!report || typeof report !== "object") { return false; } const killsByClientId = report.kills && typeof report.kills === "object" ? report.kills : {}; const winnerClientId = typeof report.winnerId === "string" ? report.winnerId : ""; const aliveTimesByClientId = report.aliveTimes && typeof report.aliveTimes === "object" ? report.aliveTimes : {}; let changed = false; const updateEntry = (clientId, mutator) => { if (!clientId || typeof mutator !== "function") { return; } const name = normalizeName(resolvePlayerName(clientId), ""); const key = highscoreKey(name); if (!key) { return; } const existing = highscoreByName.get(key) || { name, kills: 0, wins: 0, longestAlive: 0, updatedAt: 0 }; existing.name = name; existing.kills = Math.max(0, Math.floor(Number(existing.kills) || 0)); existing.wins = Math.max(0, Math.floor(Number(existing.wins) || 0)); existing.longestAlive = Math.max(0, Number(existing.longestAlive) || 0); if (!mutator(existing)) { return; } existing.updatedAt = now(); highscoreByName.set(key, existing); changed = true; }; for (const [clientId, value] of Object.entries(killsByClientId)) { const kills = Math.floor(Number(value)); if (!clientId || !Number.isFinite(kills) || kills <= 0 || kills > 99) { continue; } updateEntry(clientId, (entry) => { entry.kills += kills; return true; }); } if (winnerClientId) { updateEntry(winnerClientId, (entry) => { entry.wins += 1; return true; }); } for (const [clientId, value] of Object.entries(aliveTimesByClientId)) { const aliveSeconds = Number(value); if (!clientId || !Number.isFinite(aliveSeconds) || aliveSeconds < 0 || aliveSeconds > 3600) { continue; } updateEntry(clientId, (entry) => { if (aliveSeconds <= entry.longestAlive) { return false; } entry.longestAlive = aliveSeconds; return true; }); } return changed; } function safePath(urlPath) { const clean = decodeURIComponent(urlPath.split("?")[0]); const target = clean === "/" ? "/index.html" : clean; const resolved = path.resolve(ROOT, `.${target}`); if (!resolved.startsWith(ROOT)) { return null; } return resolved; } function send(ws, payload) { if (ws.readyState === ws.OPEN) { ws.send(JSON.stringify(payload)); } } function broadcast(payload, filterFn = null) { for (const [ws, client] of clientsBySocket.entries()) { if (filterFn && !filterFn(client)) { continue; } send(ws, payload); } } function normalizePlayers(players) { return [...players] .sort((a, b) => a.joinedAt - b.joinedAt) .map((p) => ({ id: p.id, name: p.name, joinedAt: p.joinedAt })); } function ensureHost() { if (lobby.players.length === 0) { lobby.hostId = null; return; } const hasHost = lobby.players.some((p) => p.id === lobby.hostId); if (!hasHost) { lobby.hostId = normalizePlayers(lobby.players)[0].id; } } function broadcastLobbyState(reason = null) { ensureHost(); broadcast({ type: "lobby_state", phase: lobby.phase, hostId: lobby.hostId, players: normalizePlayers(lobby.players), highscores: getHighscores(), reason, }); } function broadcastHighscoreUpdate() { broadcast({ type: "highscore_update", highscores: getHighscores(), }); } function endMatch(reason = "Match ended") { lobby.phase = "lobby"; lobby.roster = []; broadcastLobbyState(reason); } function startMatch() { const ordered = normalizePlayers(lobby.players); if (ordered.length < 2) { return; } lobby.phase = "game"; lobby.roster = ordered.slice(0, MAX_PLAYERS); broadcast({ type: "lobby_start", hostId: lobby.hostId, roster: lobby.roster, highscores: getHighscores(), }); } function removePlayer(clientId, voluntaryLeave = false) { lobby.players = lobby.players.filter((p) => p.id !== clientId); if (lobby.phase === "game") { const rosterIndex = lobby.roster.findIndex((p) => p.id === clientId); if (rosterIndex !== -1) { const wasHost = clientId === lobby.hostId; lobby.roster.splice(rosterIndex, 1); if (wasHost) { if (voluntaryLeave && lobby.roster.length > 0) { lobby.hostId = lobby.roster[0].id; broadcast({ type: "game_player_left", playerId: clientId }); broadcastLobbyState("Host left the round. New host assigned."); return; } endMatch(voluntaryLeave ? "Host left the round. Back to lobby." : "Host disconnected. Back to lobby."); return; } if (!voluntaryLeave) { endMatch("A player disconnected. Back to lobby."); return; } broadcast({ type: "game_player_left", playerId: clientId }); broadcastLobbyState("A player left the round."); return; } } ensureHost(); broadcastLobbyState(); } function addOrRefreshPlayer(client) { const existing = lobby.players.find((p) => p.id === client.id); if (existing) { existing.lastSeenAt = now(); existing.name = client.name; return; } lobby.players.push({ id: client.id, name: client.name, joinedAt: now(), lastSeenAt: now(), }); ensureHost(); } function handleMessage(client, msg) { if (!msg || typeof msg !== "object" || typeof msg.type !== "string") { return; } if (msg.type === "hello") { client.name = normalizeName(msg.name, client.name); send(client.ws, { type: "welcome", clientId: client.id, name: client.name, highscores: getHighscores() }); return; } if (msg.type === "set_name") { client.name = normalizeName(msg.name, client.name); const existing = lobby.players.find((p) => p.id === client.id); if (existing) { existing.name = client.name; existing.lastSeenAt = now(); if (lobby.phase === "game") { const inRoster = lobby.roster.find((p) => p.id === client.id); if (inRoster) { inRoster.name = client.name; } } broadcastLobbyState(); } send(client.ws, { type: "welcome", clientId: client.id, name: client.name, highscores: getHighscores() }); return; } if (msg.type === "lobby_join") { addOrRefreshPlayer(client); broadcastLobbyState(); return; } if (msg.type === "lobby_leave") { removePlayer(client.id, true); return; } if (msg.type === "lobby_start") { if (lobby.phase !== "lobby") { return; } if (client.id !== lobby.hostId) { send(client.ws, { type: "error", message: "Only the host can start the match." }); return; } if (lobby.players.length < 2) { send(client.ws, { type: "error", message: "At least 2 players are required to start a match." }); return; } startMatch(); return; } if (msg.type === "lobby_next_round") { if (lobby.phase !== "game") { return; } if (client.id !== lobby.hostId) { send(client.ws, { type: "error", message: "Only the host can start the next round." }); return; } if (lobby.players.length < 2) { send(client.ws, { type: "error", message: "At least 2 players are required to start a match." }); return; } startMatch(); return; } if (msg.type === "lobby_kill_report") { if (lobby.phase !== "game") { return; } if (client.id !== lobby.hostId) { send(client.ws, { type: "error", message: "Only the host can submit round stats." }); return; } if (applyRoundReport(msg)) { saveHighscores(); broadcastHighscoreUpdate(); } return; } if (msg.type === "player_input") { if (lobby.phase !== "game") { return; } if (client.id === lobby.hostId) { return; } const hostClient = clientsById.get(lobby.hostId); if (!hostClient) { return; } send(hostClient.ws, { type: "player_input", playerId: client.id, input: msg.input || { dir: null, bomb: false }, ts: now(), }); return; } if (msg.type === "game_snapshot") { if (lobby.phase !== "game") { return; } if (client.id !== lobby.hostId) { return; } const rosterIds = new Set(lobby.roster.map((p) => p.id)); broadcast( { type: "game_snapshot", snapshot: msg.snapshot, ts: now(), }, (target) => rosterIds.has(target.id) && target.id !== lobby.hostId, ); return; } if (msg.type === "ping") { addOrRefreshPlayer(client); } } loadHighscores(); const server = http.createServer((req, res) => { const filePath = safePath(req.url || "/"); if (!filePath) { res.writeHead(403).end("Forbidden"); return; } fs.readFile(filePath, (err, data) => { if (err) { res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); res.end("Not Found"); return; } const ext = path.extname(filePath).toLowerCase(); const type = CONTENT_TYPES[ext] || "application/octet-stream"; res.writeHead(200, { "Content-Type": type }); res.end(data); }); }); const wss = new WebSocketServer({ noServer: true }); server.on("upgrade", (req, socket, head) => { if (req.url !== "/ws") { socket.destroy(); return; } wss.handleUpgrade(req, socket, head, (ws) => { wss.emit("connection", ws, req); }); }); wss.on("connection", (ws) => { const id = makeId(); const client = { id, name: defaultName(id), ws, }; clientsBySocket.set(ws, client); clientsById.set(client.id, client); send(ws, { type: "welcome", clientId: client.id, name: client.name, highscores: getHighscores() }); ws.on("message", (data) => { let msg; try { msg = JSON.parse(data.toString()); } catch { return; } handleMessage(client, msg); }); ws.on("close", () => { clientsBySocket.delete(ws); clientsById.delete(client.id); removePlayer(client.id, false); }); ws.on("error", () => { // no-op }); }); server.listen(PORT, () => { // eslint-disable-next-line no-console console.log(`GPT Bomber server running on http://0.0.0.0:${PORT}`); });