gpt-bomber/server.js

341 lines
7.5 KiB
JavaScript

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 = 4;
const ROOT = __dirname;
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 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 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),
reason,
});
}
function endMatch(reason = "Match ended") {
lobby.phase = "lobby";
lobby.roster = [];
broadcastLobbyState(reason);
}
function startMatch() {
const ordered = normalizePlayers(lobby.players);
if (ordered.length < 1) {
return;
}
lobby.phase = "game";
lobby.roster = ordered.slice(0, MAX_PLAYERS);
broadcast({
type: "lobby_start",
hostId: lobby.hostId,
roster: lobby.roster,
});
}
function removePlayer(clientId) {
lobby.players = lobby.players.filter((p) => p.id !== clientId);
if (lobby.phase === "game") {
const inRoster = lobby.roster.some((p) => p.id === clientId);
if (inRoster) {
endMatch("A player disconnected. Back to lobby.");
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 });
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 });
return;
}
if (msg.type === "lobby_join") {
addOrRefreshPlayer(client);
broadcastLobbyState();
return;
}
if (msg.type === "lobby_leave") {
removePlayer(client.id);
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;
}
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;
}
startMatch();
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);
}
}
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 });
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);
});
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}`);
});