import { Server } from 'socket.io'; /** * Echtzeit-Kanal. Clients senden nie Aktionen über den Socket – alle Änderungen laufen über die * REST-API. Der Socket liefert nach jeder Änderung den vollständigen, rollenabhängigen Match-Zustand. * Dadurch ist ein Client nach Neuladen oder Verbindungsabbruch sofort wieder synchron. */ export function attachRealtime(httpServer, { service, config }) { const io = new Server(httpServer, { path: '/socket.io', cors: { origin: config.publicUrl, credentials: false }, connectionStateRecovery: { maxDisconnectionDuration: 2 * 60_000 }, }); // matchId -> Map(socketId -> presenceKey) const presence = new Map(); service.presence = (matchId) => new Set((presence.get(matchId) ?? new Map()).values()); const sendState = (socket) => { const { matchId, token } = socket.data; try { const viewer = service.resolveViewer(matchId, token); socket.emit('match:state', service.snapshot(matchId, viewer)); // Rolle kann sich geändert haben (z. B. Spieler entfernt, neuer Captain) – Anwesenheit nachziehen. presence.get(matchId)?.set(socket.id, viewer.playerId ?? `socket:${socket.id}`); } catch (err) { socket.emit('match:error', { error: err.message, code: err.code ?? 'error' }); } }; let overviewTimer = null; const broadcastOverview = () => { clearTimeout(overviewTimer); overviewTimer = setTimeout(() => io.to('overview').emit('overview', { matches: service.listMatches() }), 150); }; const pending = new Map(); const broadcastMatch = (matchId) => { // Mehrere Änderungen innerhalb kurzer Zeit werden zu einem Update zusammengefasst. if (pending.has(matchId)) return; pending.set(matchId, setTimeout(async () => { pending.delete(matchId); const sockets = await io.in(`match:${matchId}`).fetchSockets(); sockets.forEach(sendState); }, 50)); }; service.on('changed', (matchId) => { broadcastMatch(matchId); broadcastOverview(); }); io.on('connection', (socket) => { socket.on('overview:subscribe', () => { socket.join('overview'); socket.emit('overview', { matches: service.listMatches() }); }); socket.on('match:subscribe', ({ matchId, token } = {}) => { if (typeof matchId !== 'string') return; for (const room of socket.rooms) if (room.startsWith('match:')) leave(room.slice(6)); socket.data = { matchId, token: typeof token === 'string' ? token : null }; try { service.getMatch(matchId); } catch (err) { socket.emit('match:error', { error: err.message, code: err.code }); return; } socket.join(`match:${matchId}`); if (!presence.has(matchId)) presence.set(matchId, new Map()); service.touch(matchId); sendState(socket); broadcastMatch(matchId); // Online-Status für alle aktualisieren }); const leave = (matchId) => { socket.leave(`match:${matchId}`); const map = presence.get(matchId); if (map?.delete(socket.id)) { if (map.size === 0) presence.delete(matchId); service.touch(matchId); broadcastMatch(matchId); } }; socket.on('disconnect', () => { if (socket.data?.matchId) leave(socket.data.matchId); }); }); return io; }