Massive feature update: Solo session recovery, immediate result saving, image proxy, player reconnection (Live), and UI/UX refinements

This commit is contained in:
2026-04-16 17:53:12 +02:00
parent 247cca0803
commit 4b6e43ba65
8 changed files with 625 additions and 436 deletions
+75 -122
View File
@@ -27,10 +27,6 @@ function broadcastAdmins(session, 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());
}
@@ -44,15 +40,9 @@ function leaderboard(session) {
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),
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),
};
}
@@ -69,27 +59,17 @@ 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' });
}
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();
@@ -99,98 +79,54 @@ function setupWebSocket(server) {
}, 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 });
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 handlers */
/* ------------------------------------------------------------------ */
/* ---- 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);
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' });
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);
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: [],
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: [],
};
sessions.set(code, session);
ws._qa = { code, role: 'admin' };
send(ws, {
type: 'session-created',
code,
setName: set.name,
questionCount: questions.length,
options: opts,
});
send(ws, { type: 'session-created', code, setName: set.name, questionCount: questions.length, options: opts });
}
function adminJoin(ws, msg) {
@@ -224,7 +160,6 @@ 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);
}
@@ -242,31 +177,65 @@ async function adminEnd(ws, msg) {
await endSession(s);
}
/* ------------------------------------------------------------------ */
/* Player handlers */
/* ------------------------------------------------------------------ */
/* ---- Player ---- */
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.' });
if (!s) return send(ws, { type: 'error', message: 'Session nicht gefunden.' });
const name = (msg.name || '').trim();
if (!name) return send(ws, { type: 'error', message: 'Name darf nicht leer sein' });
// --- Reconnect existing player ---
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.' });
}
// Reconnect
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) },
});
// Catch up to current game state
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 });
// They'll see the next question when master clicks next
} 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;
}
// --- New player: only allowed in waiting room ---
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: msg.code, role: 'player', name };
send(ws, { type: 'joined', session: { code: s.code, setName: s.setName, playerCount: s.players.size, players: playerList(s) } });
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);
}
@@ -280,7 +249,6 @@ function playerAnswer(ws, msg) {
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 });
@@ -290,9 +258,7 @@ function playerAnswer(ws, msg) {
if (s.currentAnswers.size >= s.players.size) showResult(s);
}
/* ------------------------------------------------------------------ */
/* Game flow */
/* ------------------------------------------------------------------ */
/* ---- Game flow ---- */
function sendNextQuestion(s) {
s.currentQuestionIndex++;
@@ -305,15 +271,11 @@ function sendNextQuestion(s) {
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}`);
s.currentSanitized = sanitized; // Store for reconnecting players
broadcastAll(s, {
type: 'question',
index: s.currentQuestionIndex,
total: s.questions.length,
question: sanitized,
timeLimit: tl,
type: 'question', index: s.currentQuestionIndex, total: s.questions.length,
question: sanitized, timeLimit: tl,
});
clearTimeout(s.timer);
@@ -383,32 +345,23 @@ async function endSession(s) {
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`);
console.log(`[live] Session ${s.code}: ${s.results.length} Ergebnisse gespeichert`);
}
} catch (e) {
console.error('[live] Fehler beim Speichern:', e.message);
console.error('[live] Speicherfehler:', 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) });
}
// Players stay in the session (they can reconnect)
}
function getSessions() {