Express + Socket.IO + SQLite. Veto-Engine mit konfigurierbaren Map-Pools und validierten Abläufen, rollenbasierte Berechtigungen, Server-Lebenszyklus mit Demo-, Docker- und Static-Pool-Adapter (RCON), automatisches Herunterfahren.
93 lines
3.2 KiB
JavaScript
93 lines
3.2 KiB
JavaScript
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;
|
||
}
|