const NAME_KEY = "spill.pong.lobbyName"; export function getSavedLobbyName() { try { return window.localStorage.getItem(NAME_KEY) || ""; } catch { return ""; } } export function saveLobbyName(name) { try { window.localStorage.setItem(NAME_KEY, name); } catch { // Ignore storage failures. } } export function getDefaultLobbyState() { return { connected: false, connecting: false, selfId: null, selfName: "", users: [], error: "", notices: [], reconnecting: false, incomingInvite: null, outgoingInvite: null, currentMatch: null }; } function wsUrlFromWindow() { const { protocol, host } = window.location; const wsProtocol = protocol === "https:" ? "wss:" : "ws:"; return `${wsProtocol}//${host}/ws`; } export function createLobbyClient({ onState, onNotice, onError, onMatchSignal } = {}) { let ws = null; let state = getDefaultLobbyState(); let reconnectTimer = null; let manualDisconnect = false; let suppressNextCloseReconnect = false; let lastRequestedName = ""; let reconnectAttempt = 0; let pingTimer = null; function clearReconnectTimer() { if (reconnectTimer) { window.clearTimeout(reconnectTimer); reconnectTimer = null; } } function clearPingTimer() { if (pingTimer) { window.clearInterval(pingTimer); pingTimer = null; } } function emit(patch) { state = { ...state, ...patch }; onState?.(state); } function addNotice(text, kind = "info") { const item = { id: crypto.randomUUID(), text, kind, ts: Date.now() }; const notices = [item, ...state.notices].slice(0, 6); emit({ notices }); onNotice?.(item); } function send(payload) { if (!ws || ws.readyState !== WebSocket.OPEN) return false; ws.send(JSON.stringify(payload)); return true; } function startPingLoop() { clearPingTimer(); pingTimer = window.setInterval(() => { send({ type: "ping" }); }, 15000); } function scheduleReconnect() { if (manualDisconnect) return; if (!lastRequestedName) return; clearReconnectTimer(); reconnectAttempt += 1; const delayMs = Math.min(5000, 600 * 2 ** Math.min(reconnectAttempt, 3)); emit({ reconnecting: true, connecting: true, connected: false }); addNotice(`Tilkobling mistet. Prøver igjen om ${Math.round(delayMs / 1000)}s`, "warn"); reconnectTimer = window.setTimeout(() => { connect(lastRequestedName); }, delayMs); } function handleMessage(message) { switch (message.type) { case "connected": return; case "hello_ok": emit({ connected: true, connecting: false, reconnecting: false, selfId: message.selfId, selfName: message.name, error: "" }); reconnectAttempt = 0; addNotice(`Pålogget som ${message.name}`); return; case "lobby_state": emit({ users: Array.isArray(message.users) ? message.users : [] }); return; case "invite_sent": emit({ outgoingInvite: { inviteId: message.inviteId, toId: message.toId, toName: message.toName } }); addNotice(`Invitasjon sendt til ${message.toName}`); return; case "invite_received": emit({ incomingInvite: { inviteId: message.inviteId, fromId: message.fromId, fromName: message.fromName } }); addNotice(`Invitasjon fra ${message.fromName}`); return; case "invite_replied": emit({ incomingInvite: null }); return; case "invite_declined": emit({ outgoingInvite: null }); addNotice(`${message.byName || "Motstander"} avslo invitasjonen`, "warn"); return; case "invite_canceled": if (state.incomingInvite?.inviteId === message.inviteId) { emit({ incomingInvite: null }); addNotice("Invitasjonen ble trukket tilbake", "warn"); } if (state.outgoingInvite?.inviteId === message.inviteId) { emit({ outgoingInvite: null }); } return; case "match_started": case "match_created": case "match_ready_state": emit({ incomingInvite: null, outgoingInvite: null, currentMatch: { matchId: message.matchId, opponent: message.opponent, role: message.role, ready: message.ready || { self: false, opponent: false }, started: Boolean(message.started) } }); if (message.type === "match_created") { addNotice(`Match opprettet mot ${message.opponent?.name || "motstander"}`); } if (message.type === "match_started") { addNotice(`Kamp startet mot ${message.opponent?.name || "motstander"}`); } return; case "match_ended": emit({ currentMatch: null }); addNotice( message.reason === "disconnect" ? "Motstander mistet forbindelsen" : "Kamp avsluttet", "warn" ); return; case "match_signal": onMatchSignal?.(message); return; case "error": emit({ error: message.message || "Feil" }); onError?.(message); return; default: return; } } function connect(name, url = wsUrlFromWindow()) { const cleanName = String(name || "").trim(); if (!cleanName) { emit({ error: "Skriv inn et navn først" }); return; } if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { suppressNextCloseReconnect = true; ws.close(); } manualDisconnect = false; lastRequestedName = cleanName; clearReconnectTimer(); emit({ connecting: true, reconnecting: reconnectAttempt > 0, connected: false, error: "", selfName: cleanName }); saveLobbyName(cleanName); ws = new WebSocket(url); ws.addEventListener("open", () => { startPingLoop(); send({ type: "hello", name: cleanName }); }); ws.addEventListener("message", (event) => { try { handleMessage(JSON.parse(event.data)); } catch { emit({ error: "Ugyldig svar fra server" }); } }); ws.addEventListener("close", () => { clearPingTimer(); const lostActiveMatch = Boolean(state.currentMatch); emit({ connecting: false, connected: false, selfId: null, users: [], reconnecting: false, incomingInvite: null, outgoingInvite: null, currentMatch: null }); if (lostActiveMatch) { addNotice("Match avsluttet fordi forbindelsen ble brutt", "warn"); } if (suppressNextCloseReconnect) { suppressNextCloseReconnect = false; return; } scheduleReconnect(); }); ws.addEventListener("error", () => { emit({ error: "Kunne ikke koble til lobby-server" }); }); } function disconnect() { manualDisconnect = true; reconnectAttempt = 0; suppressNextCloseReconnect = false; clearReconnectTimer(); clearPingTimer(); ws?.close(); ws = null; emit({ reconnecting: false }); } function invite(toId) { emit({ error: "" }); send({ type: "invite_send", toId }); } function replyInvite(inviteId, accept) { emit({ error: "" }); send({ type: "invite_reply", inviteId, accept }); if (!accept) emit({ incomingInvite: null }); } function leaveMatch() { send({ type: "leave_match" }); } function setReady(ready) { send({ type: "match_ready", ready: Boolean(ready) }); } function sendMatchSignal(payload) { return send({ type: "match_signal", payload }); } return { connect, disconnect, invite, replyInvite, leaveMatch, setReady, sendMatchSignal, getState() { return state; } }; }