418 lines
14 KiB
JavaScript
418 lines
14 KiB
JavaScript
const { WebSocket, WebSocketServer } = require('ws');
|
|
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 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 broadcastPlayers(session, data) {
|
|
for (const [, p] of session.players) send(p.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),
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* WebSocket setup */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
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] Message 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);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Message router */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
async function handleMessage(ws, msg) {
|
|
console.log(`[ws] Received: ${msg.type}`);
|
|
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: ' + msg.type });
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Admin handlers */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
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);
|
|
console.log(`[live] Loaded ${questions.length} questions for set "${set.name}"`);
|
|
if (questions.length > 0) {
|
|
console.log(`[live] Sample fields:`, Object.keys(questions[0]).join(', '));
|
|
}
|
|
} catch (e) {
|
|
console.error('[live] Baserow error:', e.message);
|
|
return send(ws, { type: 'error', message: 'Fehler beim Laden der Fragen: ' + e.message });
|
|
}
|
|
if (!questions.length) return send(ws, { type: 'error', message: 'Keine Fragen in dieser Tabelle 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);
|
|
|
|
const code = generateCode();
|
|
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,
|
|
results: [],
|
|
};
|
|
|
|
sessions.set(code, session);
|
|
ws._qa = { code, role: 'admin' };
|
|
|
|
send(ws, {
|
|
type: 'session-created',
|
|
code,
|
|
setName: set.name,
|
|
questionCount: questions.length,
|
|
options: opts,
|
|
});
|
|
}
|
|
|
|
function adminJoin(ws, msg) {
|
|
if (!isAdmin(msg.token)) return send(ws, { type: 'error', message: 'Nicht autorisiert' });
|
|
const s = sessions.get(msg.code);
|
|
if (!s) return send(ws, { type: 'error', message: 'Session nicht gefunden' });
|
|
s.admins.add(ws);
|
|
ws._qa = { code: msg.code, role: 'admin' };
|
|
send(ws, { type: 'admin-joined', session: sessionInfo(s) });
|
|
}
|
|
|
|
function presentJoin(ws, msg) {
|
|
if (!isAdmin(msg.token)) return send(ws, { type: 'error', message: 'Nicht autorisiert' });
|
|
const s = sessions.get(msg.code);
|
|
if (!s) return send(ws, { type: 'error', message: 'Session nicht gefunden' });
|
|
s.presents.add(ws);
|
|
ws._qa = { code: msg.code, role: 'present' };
|
|
send(ws, { type: 'present-joined', session: sessionInfo(s) });
|
|
}
|
|
|
|
function adminStart(ws, msg) {
|
|
if (!isAdmin(msg.token)) return;
|
|
const s = sessions.get(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(msg.code);
|
|
if (!s || s.status !== 'active') return;
|
|
console.log(`[live] Admin forced show result for session ${s.code}`);
|
|
showResult(s);
|
|
}
|
|
|
|
function adminNext(ws, msg) {
|
|
if (!isAdmin(msg.token)) return;
|
|
const s = sessions.get(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(msg.code);
|
|
if (!s) return;
|
|
await endSession(s);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Player handlers */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
function playerJoin(ws, msg) {
|
|
const s = sessions.get(msg.code);
|
|
if (!s) return send(ws, { type: 'error', message: 'Session nicht gefunden. Pruefe den Code.' });
|
|
if (s.status !== 'waiting') return send(ws, { type: 'error', message: 'Session laeuft bereits oder ist beendet.' });
|
|
|
|
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;
|
|
} else {
|
|
s.players.set(name, { ws, score: 0, answers: [] });
|
|
}
|
|
|
|
ws._qa = { code: msg.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);
|
|
|
|
console.log(`[live] Session ${s.code}: Sending question ${s.currentQuestionIndex + 1}/${s.questions.length}`);
|
|
|
|
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 in Baserow gespeichert`);
|
|
} else {
|
|
console.log(`[live] Session ${s.code}: Keine Antworten-Tabelle konfiguriert oder keine Ergebnisse`);
|
|
}
|
|
} catch (e) {
|
|
console.error('[live] Fehler beim Speichern:', e.message);
|
|
}
|
|
|
|
setTimeout(() => sessions.delete(s.code), 300000);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Disconnect */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
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);
|
|
else if (qa.role === 'player') {
|
|
broadcastAll(s, { type: 'player-left', name: qa.name, playerCount: s.players.size, players: playerList(s) });
|
|
}
|
|
}
|
|
|
|
function getSessions() {
|
|
return Array.from(sessions.values()).map(sessionInfo);
|
|
}
|
|
|
|
module.exports = { setupWebSocket, getSessions }; |