Massive feature update: Solo session recovery, immediate result saving, image proxy, player reconnection (Live), and UI/UX refinements
This commit is contained in:
+9
-2
@@ -9,7 +9,6 @@ async function baserowFetch(endpoint, options = {}) {
|
||||
Authorization: `Token ${baserowToken}`,
|
||||
...(options.headers || {}),
|
||||
};
|
||||
// Only set Content-Type for requests with body
|
||||
if (options.body) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
@@ -42,6 +41,14 @@ async function listRows(tableId, filters = {}) {
|
||||
return all;
|
||||
}
|
||||
|
||||
async function createRow(tableId, row) {
|
||||
console.log(`[baserow] Create row in table ${tableId}`);
|
||||
return baserowFetch(`/database/rows/table/${tableId}/?user_field_names=true`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(row),
|
||||
});
|
||||
}
|
||||
|
||||
async function batchCreateRows(tableId, rows) {
|
||||
if (!rows.length) return;
|
||||
console.log(`[baserow] Batch create ${rows.length} rows in table ${tableId}`);
|
||||
@@ -54,4 +61,4 @@ async function batchCreateRows(tableId, rows) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listRows, batchCreateRows };
|
||||
module.exports = { listRows, createRow, batchCreateRows };
|
||||
+153
-29
@@ -2,9 +2,9 @@ const express = require('express');
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
const { getEnvConfig, readConfig, writeConfig } = require('./config');
|
||||
const { listRows, batchCreateRows } = require('./baserow');
|
||||
const { listRows, createRow, batchCreateRows } = require('./baserow');
|
||||
const { setupWebSocket, getSessions } = require('./live-session');
|
||||
const { sanitizeQuestion, shuffleArray } = require('./quiz-logic');
|
||||
const { extractFieldValue } = require('./quiz-logic');
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
@@ -19,13 +19,77 @@ function requireAdmin(req, res, next) {
|
||||
next();
|
||||
}
|
||||
|
||||
/* ============================================================ */
|
||||
/* Image Proxy */
|
||||
/* ============================================================ */
|
||||
|
||||
const imageCache = new Map();
|
||||
const MAX_CACHE = 50;
|
||||
|
||||
app.get('/api/img', async (req, res) => {
|
||||
const url = req.query.url;
|
||||
if (!url) return res.status(400).send('Missing url parameter');
|
||||
|
||||
let parsed;
|
||||
try { parsed = new URL(url); } catch { return res.status(400).send('Invalid URL'); }
|
||||
|
||||
const { baserowUrl } = getEnvConfig();
|
||||
let allowedHost = '';
|
||||
try { allowedHost = new URL(baserowUrl).host; } catch { }
|
||||
if (parsed.host !== allowedHost) return res.status(403).send('Forbidden');
|
||||
|
||||
if (imageCache.has(url)) {
|
||||
const cached = imageCache.get(url);
|
||||
res.set('Content-Type', cached.contentType);
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
return res.send(cached.buffer);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return res.status(response.status).send('Image fetch failed');
|
||||
const contentType = response.headers.get('content-type') || 'image/png';
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
if (imageCache.size >= MAX_CACHE) {
|
||||
const oldest = imageCache.keys().next().value;
|
||||
imageCache.delete(oldest);
|
||||
}
|
||||
imageCache.set(url, { buffer, contentType });
|
||||
res.set('Content-Type', contentType);
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
res.send(buffer);
|
||||
} catch (e) {
|
||||
console.error(`[img-proxy] Error:`, e.message);
|
||||
res.status(500).send('Proxy error');
|
||||
}
|
||||
});
|
||||
|
||||
/* ============================================================ */
|
||||
/* Helper: rewrite Baserow image URLs to use our proxy */
|
||||
/* ============================================================ */
|
||||
|
||||
function rewriteImageUrls(rows) {
|
||||
return rows.map((row) => {
|
||||
const bild = row['Bild'];
|
||||
if (bild && Array.isArray(bild) && bild.length > 0) {
|
||||
return {
|
||||
...row,
|
||||
Bild: bild.map((img) => ({
|
||||
...img,
|
||||
url: img.url ? `/api/img?url=${encodeURIComponent(img.url)}` : img.url,
|
||||
})),
|
||||
};
|
||||
}
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
/* ============================================================ */
|
||||
/* Public API */
|
||||
/* ============================================================ */
|
||||
|
||||
app.get('/api/sets', (req, res) => {
|
||||
const config = readConfig();
|
||||
console.log(`[api] GET /api/sets — ${(config.sets || []).length} sets`);
|
||||
res.json((config.sets || []).map((s) => ({ id: s.id, name: s.name })));
|
||||
});
|
||||
|
||||
@@ -34,29 +98,103 @@ app.get('/api/sets/:id/questions', async (req, res) => {
|
||||
const config = readConfig();
|
||||
const set = config.sets.find((s) => s.id === req.params.id);
|
||||
if (!set) return res.status(404).json({ error: 'Fragenset nicht gefunden' });
|
||||
console.log(`[api] Loading questions for set "${set.name}" (table ${set.tableId})`);
|
||||
const rows = await listRows(set.tableId);
|
||||
console.log(`[api] Loaded ${rows.length} questions`);
|
||||
if (rows.length > 0) {
|
||||
console.log(`[api] Fields: ${Object.keys(rows[0]).join(', ')}`);
|
||||
}
|
||||
let rows = await listRows(set.tableId);
|
||||
rows = rewriteImageUrls(rows);
|
||||
res.json(rows);
|
||||
} catch (e) {
|
||||
console.error(`[api] Error loading questions:`, e.message);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
/* ---- Solo: Save single answer immediately ---- */
|
||||
app.post('/api/results/single', async (req, res) => {
|
||||
try {
|
||||
const config = readConfig();
|
||||
if (!config.answersTableId) return res.status(400).json({ error: 'Antworten-Tabelle nicht konfiguriert' });
|
||||
await createRow(config.answersTableId, req.body);
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
console.error('[api] Single save error:', e.message);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
/* ---- Solo: Check for incomplete session ---- */
|
||||
app.get('/api/progress/:name/:setId', async (req, res) => {
|
||||
try {
|
||||
const config = readConfig();
|
||||
const set = config.sets.find((s) => s.id === req.params.setId);
|
||||
if (!set) return res.status(404).json({ error: 'Set nicht gefunden' });
|
||||
if (!config.answersTableId) return res.json({ hasProgress: false });
|
||||
|
||||
// Count total questions
|
||||
const questions = await listRows(set.tableId);
|
||||
const totalQuestions = questions.length;
|
||||
|
||||
// Get answers for this user + set
|
||||
const allAnswers = await listRows(config.answersTableId, {
|
||||
Nutzername: req.params.name,
|
||||
Fragenset: set.name,
|
||||
});
|
||||
|
||||
// Filter to Solo only (Modus may be Single Select object)
|
||||
const soloAnswers = allAnswers.filter((a) => {
|
||||
return extractFieldValue(a['Modus'], '') === 'Solo';
|
||||
});
|
||||
|
||||
// Group by Session_ID
|
||||
const bySession = {};
|
||||
soloAnswers.forEach((a) => {
|
||||
const sid = a['Session_ID'] || '';
|
||||
if (!bySession[sid]) bySession[sid] = [];
|
||||
bySession[sid].push(a);
|
||||
});
|
||||
|
||||
// Find the most recent incomplete session
|
||||
let best = null;
|
||||
for (const [sid, answers] of Object.entries(bySession)) {
|
||||
if (answers.length > 0 && answers.length < totalQuestions) {
|
||||
const latest = answers.reduce((max, a) => {
|
||||
const ts = a['Zeitstempel'] || '';
|
||||
return ts > max ? ts : max;
|
||||
}, '');
|
||||
if (!best || latest > best.latest) {
|
||||
best = { sid, answers, latest };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (best) {
|
||||
res.json({
|
||||
hasProgress: true,
|
||||
sessionId: best.sid,
|
||||
answers: best.answers.map((a) => ({
|
||||
Frage_ID: a['Frage_ID'],
|
||||
Antwort: a['Antwort'] || '',
|
||||
Richtig: !!a['Richtig'],
|
||||
Punkte: a['Punkte'] || 0,
|
||||
})),
|
||||
totalAnswered: best.answers.length,
|
||||
totalCorrect: best.answers.filter((a) => a['Richtig']).length,
|
||||
totalQuestions,
|
||||
});
|
||||
} else {
|
||||
res.json({ hasProgress: false, totalQuestions });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[api] Progress error:', e.message);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
/* ---- Batch save (still used by live sessions) ---- */
|
||||
app.post('/api/results', async (req, res) => {
|
||||
try {
|
||||
const config = readConfig();
|
||||
if (!config.answersTableId) return res.status(400).json({ error: 'Antworten-Tabelle nicht konfiguriert' });
|
||||
const answers = req.body.answers || [];
|
||||
console.log(`[api] Saving ${answers.length} results`);
|
||||
await batchCreateRows(config.answersTableId, answers);
|
||||
await batchCreateRows(config.answersTableId, req.body.answers || []);
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
console.error(`[api] Error saving results:`, e.message);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
@@ -68,7 +206,6 @@ app.get('/api/results/:name', async (req, res) => {
|
||||
const rows = await listRows(config.answersTableId, { Nutzername: req.params.name });
|
||||
res.json(rows);
|
||||
} catch (e) {
|
||||
console.error(`[api] Error loading results:`, e.message);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
@@ -86,17 +223,9 @@ app.post('/api/admin/login', (req, res) => {
|
||||
});
|
||||
|
||||
app.get('/api/admin/config', requireAdmin, (req, res) => res.json(readConfig()));
|
||||
|
||||
app.post('/api/admin/config', requireAdmin, (req, res) => {
|
||||
try {
|
||||
writeConfig(req.body);
|
||||
console.log('[api] Config updated');
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
try { writeConfig(req.body); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
app.get('/api/admin/sessions', requireAdmin, (req, res) => res.json(getSessions()));
|
||||
|
||||
app.get('/api/admin/stats', requireAdmin, async (req, res) => {
|
||||
@@ -106,12 +235,10 @@ app.get('/api/admin/stats', requireAdmin, async (req, res) => {
|
||||
const rows = await listRows(config.answersTableId);
|
||||
res.json({ answers: rows });
|
||||
} catch (e) {
|
||||
console.error('[api] Stats error:', e.message);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Debug endpoint to test Baserow connection
|
||||
app.get('/api/admin/debug-baserow', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { baserowUrl, baserowToken } = getEnvConfig();
|
||||
@@ -131,16 +258,13 @@ app.get('/api/admin/debug-baserow', requireAdmin, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/* ---- SPA routes ---- */
|
||||
app.get('/admin', (req, res) => res.sendFile(path.join(__dirname, '..', 'public', 'admin.html')));
|
||||
app.get('/present', (req, res) => res.sendFile(path.join(__dirname, '..', 'public', 'present.html')));
|
||||
app.get('*', (req, res) => res.sendFile(path.join(__dirname, '..', 'public', 'index.html')));
|
||||
|
||||
/* ---- Start ---- */
|
||||
setupWebSocket(server);
|
||||
const { port } = getEnvConfig();
|
||||
server.listen(port, '0.0.0.0', () => {
|
||||
console.log(`[quizalarm] Server laeuft auf Port ${port}`);
|
||||
console.log(`[quizalarm] Baserow URL: ${getEnvConfig().baserowUrl}`);
|
||||
console.log(`[quizalarm] Config: ${JSON.stringify(readConfig())}`);
|
||||
});
|
||||
+75
-122
@@ -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() {
|
||||
|
||||
+3
-16
@@ -1,7 +1,3 @@
|
||||
/**
|
||||
* Normalize text for freitext comparison.
|
||||
* "Villingen-Schwenningen" == "villingen schwenningen" == "villingenschwenningen"
|
||||
*/
|
||||
function normalizeText(text) {
|
||||
if (!text) return '';
|
||||
return text
|
||||
@@ -14,10 +10,6 @@ function normalizeText(text) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the string value from a Baserow Single Select field.
|
||||
* Baserow returns { id: 6, value: "MC", color: "..." } for Single Select.
|
||||
*/
|
||||
function extractFieldValue(field, fallback) {
|
||||
if (!field) return fallback || '';
|
||||
if (typeof field === 'string') return field;
|
||||
@@ -61,9 +53,6 @@ function shuffleArray(arr) {
|
||||
return a;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate score for an answer.
|
||||
*/
|
||||
function calculateScore(question, selected, timeRemaining, totalTime, scoringMode) {
|
||||
if (!selected || selected.length === 0) return { points: 0, correct: false };
|
||||
|
||||
@@ -104,9 +93,6 @@ function calculateScore(question, selected, timeRemaining, totalTime, scoringMod
|
||||
return { points, correct: fullyCorrect };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the correct answer and shuffle options before sending to participants.
|
||||
*/
|
||||
function sanitizeQuestion(question) {
|
||||
const typ = getTyp(question);
|
||||
const sanitized = {
|
||||
@@ -118,9 +104,10 @@ function sanitizeQuestion(question) {
|
||||
kategorie: extractFieldValue(question['Kategorie'], ''),
|
||||
};
|
||||
|
||||
// Rewrite image URL to use proxy
|
||||
const bild = question['Bild'];
|
||||
if (bild && Array.isArray(bild) && bild.length > 0) {
|
||||
sanitized.bild = bild[0].url;
|
||||
if (bild && Array.isArray(bild) && bild.length > 0 && bild[0].url) {
|
||||
sanitized.bild = `/api/img?url=${encodeURIComponent(bild[0].url)}`;
|
||||
}
|
||||
|
||||
if (typ !== 'freitext') {
|
||||
|
||||
Reference in New Issue
Block a user