456 lines
12 KiB
JavaScript
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}`);
|
|
});
|