551 lines
14 KiB
JavaScript
551 lines
14 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 = 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}`);
|
|
});
|