const { WebSocket, WebSocketServer } = require('ws'); const crypto = require('crypto'); const { getEnvConfig, readConfig } = require('./config'); const { listRows, batchCreateRows } = require('./baserow'); const { calculateScore, sanitizeQuestion, parseCorrectAnswers } = require('./quiz-logic'); const sessions = new Map(); function generateCode() { const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; let code = ''; for (let i = 0; i < 6; i++) code += chars[Math.floor(Math.random() * chars.length)]; return code; } function sanitizeCode(input) { // Remove everything except letters and numbers, uppercase return (input || '').replace(/[^a-zA-Z0-9]/g, '').toUpperCase(); } function generatePresentToken() { return crypto.randomBytes(24).toString('hex'); } function send(ws, data) { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(data)); } function broadcastAll(session, data, exclude) { for (const [, p] of session.players) if (p.ws !== exclude) send(p.ws, data); for (const ws of session.admins) if (ws !== exclude) send(ws, data); for (const ws of session.presents) if (ws !== exclude) send(ws, data); } function broadcastAdmins(session, data) { for (const ws of session.admins) send(ws, data); for (const ws of session.presents) send(ws, data); } function playerList(session) { return Array.from(session.players.keys()); } function leaderboard(session) { return Array.from(session.players.entries()) .map(([name, p]) => ({ name, score: p.score })) .sort((a, b) => b.score - a.score) .map((e, i) => ({ ...e, rank: i + 1 })); } function sessionInfo(s) { return { code: s.code, setName: s.setName, status: s.status, options: s.options, questionCount: s.questions.length, currentQuestionIndex: s.currentQuestionIndex, playerCount: s.players.size, players: playerList(s), leaderboard: leaderboard(s), presentToken: s.presentToken, }; } function shuffle(arr) { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } function isAdmin(token) { return token === getEnvConfig().adminPassword; } function isValidPresentToken(token, code) { const s = sessions.get(code); return s && s.presentToken === token; } function setupWebSocket(server) { const wss = new WebSocketServer({ server }); wss.on('connection', (ws) => { ws.isAlive = true; ws.on('pong', () => (ws.isAlive = true)); ws.on('message', (raw) => { try { handleMessage(ws, JSON.parse(raw)); } catch (e) { console.error('[ws] Error:', e); send(ws, { type: 'error', message: 'Ungueltige Nachricht' }); } }); ws.on('close', () => handleDisconnect(ws)); }); setInterval(() => { wss.clients.forEach((ws) => { if (!ws.isAlive) return ws.terminate(); ws.isAlive = false; ws.ping(); }); }, 30000); } async function handleMessage(ws, msg) { switch (msg.type) { case 'admin-create': return adminCreate(ws, msg); case 'admin-join': return adminJoin(ws, msg); case 'admin-start': return adminStart(ws, msg); case 'admin-show-result': return adminShowResult(ws, msg); case 'admin-next': return adminNext(ws, msg); case 'admin-end': return adminEnd(ws, msg); case 'present-join': return presentJoin(ws, msg); case 'join': return playerJoin(ws, msg); case 'answer': return playerAnswer(ws, msg); default: send(ws, { type: 'error', message: 'Unbekannter Typ' }); } } /* ---- Admin ---- */ async function adminCreate(ws, msg) { if (!isAdmin(msg.token)) return send(ws, { type: 'error', message: 'Nicht autorisiert' }); const config = readConfig(); const set = config.sets.find((s) => s.id === msg.setId); if (!set) return send(ws, { type: 'error', message: 'Fragenset nicht gefunden' }); let questions; try { questions = await listRows(set.tableId); } catch (e) { return send(ws, { type: 'error', message: 'Fehler: ' + e.message }); } if (!questions.length) return send(ws, { type: 'error', message: 'Keine Fragen gefunden' }); const opts = { shuffle: msg.options.shuffle !== false, scoring: msg.options.scoring || 'binary', timeLimit: parseInt(msg.options.timeLimit) || 30, }; if (opts.shuffle) questions = shuffle(questions); // Custom or auto-generated code let code; if (msg.customCode) { code = sanitizeCode(msg.customCode); if (code.length < 3) return send(ws, { type: 'error', message: 'Code muss mindestens 3 Zeichen lang sein' }); if (code.length > 20) return send(ws, { type: 'error', message: 'Code darf maximal 20 Zeichen lang sein' }); if (sessions.has(code)) return send(ws, { type: 'error', message: `Code "${code}" ist bereits vergeben` }); } else { code = generateCode(); // Ensure uniqueness while (sessions.has(code)) code = generateCode(); } const presentToken = generatePresentToken(); const session = { code, sessionId: 'LIVE-' + Date.now().toString(36).toUpperCase(), setId: set.id, setName: set.name, tableId: set.tableId, options: opts, questions, currentQuestionIndex: -1, status: 'waiting', players: new Map(), admins: new Set([ws]), presents: new Set(), currentAnswers: new Map(), questionStartTime: null, timer: null, currentSanitized: null, results: [], presentToken, }; sessions.set(code, session); ws._qa = { code, role: 'admin' }; console.log(`[live] Session created: ${code} (${set.name}, ${questions.length} questions)`); send(ws, { type: 'session-created', code, setName: set.name, questionCount: questions.length, options: opts, presentToken, }); } function adminJoin(ws, msg) { if (!isAdmin(msg.token)) return send(ws, { type: 'error', message: 'Nicht autorisiert' }); const code = sanitizeCode(msg.code); const s = sessions.get(code); if (!s) return send(ws, { type: 'error', message: `Session "${code}" nicht gefunden` }); s.admins.add(ws); ws._qa = { code, role: 'admin' }; send(ws, { type: 'admin-joined', session: sessionInfo(s) }); } function presentJoin(ws, msg) { const code = sanitizeCode(msg.code); if (!isAdmin(msg.token) && !isValidPresentToken(msg.token, code)) { return send(ws, { type: 'error', message: 'Nicht autorisiert' }); } const s = sessions.get(code); if (!s) return send(ws, { type: 'error', message: 'Session nicht gefunden' }); s.presents.add(ws); ws._qa = { code, role: 'present' }; send(ws, { type: 'present-joined', session: sessionInfo(s) }); } function adminStart(ws, msg) { if (!isAdmin(msg.token)) return; const s = sessions.get(sanitizeCode(msg.code)); if (!s || s.status !== 'waiting') return; s.status = 'active'; broadcastAll(s, { type: 'game-start', totalQuestions: s.questions.length }); sendNextQuestion(s); } function adminShowResult(ws, msg) { if (!isAdmin(msg.token)) return; const s = sessions.get(sanitizeCode(msg.code)); if (!s || s.status !== 'active') return; showResult(s); } function adminNext(ws, msg) { if (!isAdmin(msg.token)) return; const s = sessions.get(sanitizeCode(msg.code)); if (!s || s.status !== 'showing-result') return; sendNextQuestion(s); } async function adminEnd(ws, msg) { if (!isAdmin(msg.token)) return; const s = sessions.get(sanitizeCode(msg.code)); if (!s) return; await endSession(s); } /* ---- Player ---- */ function playerJoin(ws, msg) { const code = sanitizeCode(msg.code); const s = sessions.get(code); if (!s) return send(ws, { type: 'error', message: 'Session nicht gefunden. Pruefe den Code.' }); const name = (msg.name || '').trim(); if (!name) return send(ws, { type: 'error', message: 'Name darf nicht leer sein' }); if (s.players.has(name)) { const existing = s.players.get(name); if (existing.ws && existing.ws.readyState === WebSocket.OPEN) { return send(ws, { type: 'error', message: 'Name bereits online. Bitte waehle einen anderen.' }); } existing.ws = ws; ws._qa = { code, role: 'player', name }; send(ws, { type: 'joined', session: { code: s.code, setName: s.setName, playerCount: s.players.size, players: playerList(s) }, }); if (s.status === 'active' && s.currentSanitized) { const tl = s.options.scoring === 'time' ? s.options.timeLimit : 0; send(ws, { type: 'game-start', totalQuestions: s.questions.length }); send(ws, { type: 'question', index: s.currentQuestionIndex, total: s.questions.length, question: s.currentSanitized, timeLimit: tl, }); if (s.currentAnswers.has(name)) { send(ws, { type: 'answer-received' }); } } else if (s.status === 'showing-result') { send(ws, { type: 'game-start', totalQuestions: s.questions.length }); } else if (s.status === 'ended') { send(ws, { type: 'game-end', leaderboard: leaderboard(s), sessionId: s.sessionId }); } broadcastAll(s, { type: 'player-joined', name, playerCount: s.players.size, players: playerList(s) }, ws); return; } if (s.status !== 'waiting') { return send(ws, { type: 'error', message: 'Session laeuft bereits. Nur bestehende Teilnehmer koennen wieder beitreten.' }); } s.players.set(name, { ws, score: 0, answers: [] }); ws._qa = { code, role: 'player', name }; send(ws, { type: 'joined', session: { code: s.code, setName: s.setName, playerCount: s.players.size, players: playerList(s) }, }); broadcastAll(s, { type: 'player-joined', name, playerCount: s.players.size, players: playerList(s) }, ws); } function playerAnswer(ws, msg) { const qa = ws._qa; if (!qa || qa.role !== 'player') return; const s = sessions.get(qa.code); if (!s || s.status !== 'active') return; if (s.currentAnswers.has(qa.name)) return; const elapsed = (Date.now() - s.questionStartTime) / 1000; const tl = s.options.scoring === 'time' ? s.options.timeLimit : 0; const remaining = tl > 0 ? Math.max(0, tl - elapsed) : 0; if (tl > 0 && remaining <= 0) return; s.currentAnswers.set(qa.name, { answers: msg.answers || [], timeRemaining: remaining }); broadcastAdmins(s, { type: 'answer-count', count: s.currentAnswers.size, total: s.players.size }); send(ws, { type: 'answer-received' }); if (s.currentAnswers.size >= s.players.size) showResult(s); } /* ---- Game flow ---- */ function sendNextQuestion(s) { s.currentQuestionIndex++; if (s.currentQuestionIndex >= s.questions.length) return endSession(s); s.status = 'active'; s.currentAnswers.clear(); s.questionStartTime = Date.now(); const q = s.questions[s.currentQuestionIndex]; const tl = s.options.scoring === 'time' ? s.options.timeLimit : 0; const sanitized = sanitizeQuestion(q); s.currentSanitized = sanitized; broadcastAll(s, { type: 'question', index: s.currentQuestionIndex, total: s.questions.length, question: sanitized, timeLimit: tl, }); clearTimeout(s.timer); if (tl > 0) { s.timer = setTimeout(() => { if (s.status === 'active') broadcastAll(s, { type: 'time-up' }); }, tl * 1000); } } function showResult(s) { clearTimeout(s.timer); s.status = 'showing-result'; const q = s.questions[s.currentQuestionIndex]; const correct = parseCorrectAnswers(q); const tl = s.options.scoring === 'time' ? s.options.timeLimit : 0; const stats = {}; ['A', 'B', 'C', 'D'].forEach((k) => { if (q[`Antwort ${k}`]) stats[k] = 0; }); for (const [name, ans] of s.currentAnswers) { const player = s.players.get(name); const result = calculateScore(q, ans.answers, ans.timeRemaining, tl, s.options.scoring); player.score += result.points; player.answers.push({ qId: q.id, idx: s.currentQuestionIndex, answers: ans.answers, correct: result.correct, points: result.points }); ans.answers.forEach((a) => { if (stats[a] !== undefined) stats[a]++; }); s.results.push({ Nutzername: name, Frage_ID: q.id, Fragenset: s.setName, Antwort: ans.answers.join(','), Richtig: result.correct, Punkte: result.points, Session_ID: s.sessionId, Modus: 'Live', Zeitstempel: new Date().toISOString(), }); } for (const [name, player] of s.players) { if (!s.currentAnswers.has(name)) { player.answers.push({ qId: q.id, idx: s.currentQuestionIndex, answers: [], correct: false, points: 0 }); s.results.push({ Nutzername: name, Frage_ID: q.id, Fragenset: s.setName, Antwort: '', Richtig: false, Punkte: 0, Session_ID: s.sessionId, Modus: 'Live', Zeitstempel: new Date().toISOString(), }); } } const lb = leaderboard(s); for (const [name, player] of s.players) { const last = player.answers[player.answers.length - 1]; send(player.ws, { type: 'question-result', correctAnswer: correct, stats, leaderboard: lb, yourResult: { correct: last.correct, points: last.points, totalScore: player.score }, }); } broadcastAdmins(s, { type: 'question-result', correctAnswer: correct, stats, leaderboard: lb }); } async function endSession(s) { clearTimeout(s.timer); s.status = 'ended'; const lb = leaderboard(s); broadcastAll(s, { type: 'game-end', leaderboard: lb, sessionId: s.sessionId }); try { const config = readConfig(); if (config.answersTableId && s.results.length) { await batchCreateRows(config.answersTableId, s.results); console.log(`[live] Session ${s.code}: ${s.results.length} Ergebnisse gespeichert`); } } catch (e) { console.error('[live] Speicherfehler:', e.message); } setTimeout(() => sessions.delete(s.code), 300000); } function handleDisconnect(ws) { const qa = ws._qa; if (!qa) return; const s = sessions.get(qa.code); if (!s) return; if (qa.role === 'admin') s.admins.delete(ws); else if (qa.role === 'present') s.presents.delete(ws); } function getSessions() { return Array.from(sessions.values()).map(sessionInfo); } module.exports = { setupWebSocket, getSessions };