Files

456 lines
12 KiB
JavaScript

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}`);
});