import http from "node:http"; import { randomUUID } from "node:crypto"; import { WebSocketServer } from "ws"; const PORT = Number(process.env.PORT || 8787); const HOST = process.env.HOST || "0.0.0.0"; const clients = new Map(); // clientId -> { ws, name, status, matchId } const socketToClientId = new WeakMap(); const invites = new Map(); // inviteId -> { id, fromId, toId, status, createdAt } const matches = new Map(); // matchId -> { id, aId, bId, createdAt, startedAt, ready: { [clientId]: boolean } } function nowIso() { return new Date().toISOString(); } function send(ws, payload) { if (ws.readyState !== 1) return; ws.send(JSON.stringify(payload)); } function getUserView(clientId) { const client = clients.get(clientId); if (!client) return null; return { id: clientId, name: client.name, status: client.status, matchId: client.matchId || null }; } function broadcastLobbyState() { const users = [...clients.keys()] .map(getUserView) .filter(Boolean) .sort((a, b) => a.name.localeCompare(b.name, "no", { sensitivity: "base" })); for (const { ws } of clients.values()) { send(ws, { type: "lobby_state", users, ts: nowIso() }); } } function sanitizeName(input) { const text = String(input || "").trim().replace(/\s+/g, " "); return text.slice(0, 24); } function isNameTaken(name, exceptClientId = null) { const lower = name.toLocaleLowerCase("no"); for (const [id, client] of clients) { if (id === exceptClientId) continue; if (client.name.toLocaleLowerCase("no") === lower) return true; } return false; } function resetBusyStatuses(clientIds) { for (const clientId of clientIds) { const client = clients.get(clientId); if (!client) continue; if (client.status !== "in_match") { client.status = "idle"; client.matchId = null; } } } function cancelInvitesForClient(clientId) { const affectedClientIds = new Set([clientId]); for (const [inviteId, invite] of invites) { if (invite.status !== "pending") continue; if (invite.fromId !== clientId && invite.toId !== clientId) continue; invite.status = "canceled"; const otherId = invite.fromId === clientId ? invite.toId : invite.fromId; affectedClientIds.add(otherId); const other = clients.get(otherId); if (other) { send(other.ws, { type: "invite_canceled", inviteId, by: clientId, ts: nowIso() }); } } resetBusyStatuses(affectedClientIds); } function endMatch(matchId, reason = "left") { const match = matches.get(matchId); if (!match) return; matches.delete(matchId); for (const id of [match.aId, match.bId]) { const client = clients.get(id); if (!client) continue; client.matchId = null; client.status = "idle"; send(client.ws, { type: "match_ended", matchId, reason, ts: nowIso() }); } } function getMatchView(match, forClientId) { const opponentId = match.aId === forClientId ? match.bId : match.aId; const opponent = getUserView(opponentId); return { matchId: match.id, opponent: opponent ? { id: opponent.id, name: opponent.name } : null, role: match.aId === forClientId ? "host" : "guest", ready: { self: Boolean(match.ready?.[forClientId]), opponent: Boolean(match.ready?.[opponentId]) }, started: Boolean(match.startedAt) }; } function cleanupClient(clientId) { const client = clients.get(clientId); if (!client) return; cancelInvitesForClient(clientId); if (client.matchId) endMatch(client.matchId, "disconnect"); clients.delete(clientId); broadcastLobbyState(); } function requireClient(ws) { const clientId = socketToClientId.get(ws); const client = clientId ? clients.get(clientId) : null; return clientId && client ? { clientId, client } : null; } function onHello(ws, message) { const name = sanitizeName(message.name); if (!name) { send(ws, { type: "error", code: "invalid_name", message: "Ugyldig navn" }); return; } const existing = requireClient(ws); if (existing) { if (isNameTaken(name, existing.clientId)) { send(ws, { type: "error", code: "name_taken", message: "Navnet er opptatt" }); return; } existing.client.name = name; send(ws, { type: "hello_ok", selfId: existing.clientId, name }); broadcastLobbyState(); return; } if (isNameTaken(name)) { send(ws, { type: "error", code: "name_taken", message: "Navnet er opptatt" }); return; } const clientId = randomUUID(); clients.set(clientId, { ws, name, status: "idle", matchId: null }); socketToClientId.set(ws, clientId); send(ws, { type: "hello_ok", selfId: clientId, name }); broadcastLobbyState(); } function onInviteSend(ws, message) { const ctx = requireClient(ws); if (!ctx) { send(ws, { type: "error", code: "not_authenticated", message: "Send hello først" }); return; } const { clientId, client } = ctx; const toId = String(message.toId || ""); if (!toId || toId === clientId) { send(ws, { type: "error", code: "invalid_target", message: "Ugyldig motstander" }); return; } const target = clients.get(toId); if (!target) { send(ws, { type: "error", code: "target_offline", message: "Motstander er ikke pålogget" }); return; } if (client.status !== "idle" || target.status !== "idle") { send(ws, { type: "error", code: "target_busy", message: "En av spillerne er opptatt" }); return; } const duplicatePending = [...invites.values()].find( (invite) => invite.status === "pending" && ((invite.fromId === clientId && invite.toId === toId) || (invite.fromId === toId && invite.toId === clientId)) ); if (duplicatePending) { send(ws, { type: "error", code: "invite_exists", message: "Invitasjon finnes allerede" }); return; } const inviteId = randomUUID(); invites.set(inviteId, { id: inviteId, fromId: clientId, toId, status: "pending", createdAt: nowIso() }); client.status = "inviting"; target.status = "invited"; send(client.ws, { type: "invite_sent", inviteId, toId, toName: target.name, ts: nowIso() }); send(target.ws, { type: "invite_received", inviteId, fromId: clientId, fromName: client.name, ts: nowIso() }); broadcastLobbyState(); } function onInviteReply(ws, message) { const ctx = requireClient(ws); if (!ctx) { send(ws, { type: "error", code: "not_authenticated", message: "Send hello først" }); return; } const inviteId = String(message.inviteId || ""); const accept = Boolean(message.accept); const invite = invites.get(inviteId); if (!invite || invite.status !== "pending") { send(ws, { type: "error", code: "invite_missing", message: "Invitasjonen finnes ikke" }); return; } if (invite.toId !== ctx.clientId) { send(ws, { type: "error", code: "not_invited", message: "Du kan ikke svare på denne" }); return; } const fromClient = clients.get(invite.fromId); const toClient = clients.get(invite.toId); if (!fromClient || !toClient) { invite.status = "canceled"; resetBusyStatuses([invite.fromId, invite.toId]); broadcastLobbyState(); return; } if (!accept) { invite.status = "declined"; fromClient.status = "idle"; toClient.status = "idle"; send(fromClient.ws, { type: "invite_declined", inviteId, byId: invite.toId, byName: toClient.name, ts: nowIso() }); send(toClient.ws, { type: "invite_replied", inviteId, accept: false, ts: nowIso() }); broadcastLobbyState(); return; } invite.status = "accepted"; const matchId = randomUUID(); matches.set(matchId, { id: matchId, aId: invite.fromId, bId: invite.toId, createdAt: nowIso(), startedAt: null, ready: { [invite.fromId]: false, [invite.toId]: false } }); fromClient.status = "in_match"; toClient.status = "in_match"; fromClient.matchId = matchId; toClient.matchId = matchId; // Cancel any other pending invites involving these two players. for (const otherInvite of invites.values()) { if (otherInvite.id === inviteId || otherInvite.status !== "pending") continue; if ( [otherInvite.fromId, otherInvite.toId].includes(invite.fromId) || [otherInvite.fromId, otherInvite.toId].includes(invite.toId) ) { otherInvite.status = "canceled"; } } const match = matches.get(matchId); send(fromClient.ws, { type: "match_created", ...getMatchView(match, invite.fromId), ts: nowIso() }); send(toClient.ws, { type: "match_created", ...getMatchView(match, invite.toId), ts: nowIso() }); broadcastLobbyState(); } function onLeaveMatch(ws) { const ctx = requireClient(ws); if (!ctx) return; if (!ctx.client.matchId) return; endMatch(ctx.client.matchId, "left"); broadcastLobbyState(); } function onMatchReady(ws, message) { const ctx = requireClient(ws); if (!ctx) return; if (!ctx.client.matchId) { send(ws, { type: "error", code: "no_match", message: "Du er ikke i en match" }); return; } const match = matches.get(ctx.client.matchId); if (!match) return; match.ready[ctx.clientId] = Boolean(message.ready); const a = clients.get(match.aId); const b = clients.get(match.bId); if (!a || !b) return; send(a.ws, { type: "match_ready_state", ...getMatchView(match, match.aId), ts: nowIso() }); send(b.ws, { type: "match_ready_state", ...getMatchView(match, match.bId), ts: nowIso() }); if (!match.startedAt && match.ready[match.aId] && match.ready[match.bId]) { match.startedAt = nowIso(); send(a.ws, { type: "match_started", ...getMatchView(match, match.aId), ts: nowIso() }); send(b.ws, { type: "match_started", ...getMatchView(match, match.bId), ts: nowIso() }); } } function onMatchSignal(ws, message) { const ctx = requireClient(ws); if (!ctx) return; if (!ctx.client.matchId) return; const match = matches.get(ctx.client.matchId); if (!match || !match.startedAt) return; const opponentId = match.aId === ctx.clientId ? match.bId : match.aId; const opponent = clients.get(opponentId); if (!opponent) return; send(opponent.ws, { type: "match_signal", matchId: match.id, fromId: ctx.clientId, payload: message.payload ?? null, ts: nowIso() }); } function onMessage(ws, raw) { let message; try { message = JSON.parse(String(raw)); } catch { send(ws, { type: "error", code: "bad_json", message: "Ugyldig JSON" }); return; } if (!message || typeof message !== "object") { send(ws, { type: "error", code: "bad_message", message: "Ugyldig melding" }); return; } switch (message.type) { case "hello": onHello(ws, message); break; case "invite_send": onInviteSend(ws, message); break; case "invite_reply": onInviteReply(ws, message); break; case "leave_match": onLeaveMatch(ws); break; case "match_ready": onMatchReady(ws, message); break; case "match_signal": onMatchSignal(ws, message); break; case "ping": send(ws, { type: "pong", ts: nowIso() }); break; default: send(ws, { type: "error", code: "unknown_type", message: "Ukjent meldingstype" }); } } const server = http.createServer((req, res) => { if (req.url === "/healthz") { res.writeHead(200, { "content-type": "text/plain" }); res.end("ok\n"); return; } res.writeHead(404, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "not_found" })); }); const wss = new WebSocketServer({ noServer: true }); server.on("upgrade", (req, socket, head) => { if (!req.url || !req.url.startsWith("/ws")) { socket.destroy(); return; } wss.handleUpgrade(req, socket, head, (ws) => { wss.emit("connection", ws, req); }); }); wss.on("connection", (ws) => { send(ws, { type: "connected", ts: nowIso() }); ws.on("message", (data) => onMessage(ws, data)); ws.on("close", () => { const clientId = socketToClientId.get(ws); if (clientId) cleanupClient(clientId); }); ws.on("error", () => { const clientId = socketToClientId.get(ws); if (clientId) cleanupClient(clientId); }); }); server.listen(PORT, HOST, () => { console.log(`Lobby listening on http://${HOST}:${PORT}`); });