Initial: Quizalarm Quiz-Plattform
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
const { getEnvConfig } = require('./config');
|
||||
|
||||
async function baserowFetch(endpoint, options = {}) {
|
||||
const { baserowUrl, baserowToken } = getEnvConfig();
|
||||
const url = `${baserowUrl}/api${endpoint}`;
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Token ${baserowToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`Baserow ${res.status}: ${body.substring(0, 200)}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function listRows(tableId, filters = {}) {
|
||||
let all = [];
|
||||
let page = 1;
|
||||
while (true) {
|
||||
let url = `/database/rows/table/${tableId}/?user_field_names=true&size=200&page=${page}`;
|
||||
for (const [field, value] of Object.entries(filters)) {
|
||||
url += `&filter__${encodeURIComponent(field)}__equal=${encodeURIComponent(value)}`;
|
||||
}
|
||||
const data = await baserowFetch(url);
|
||||
all = all.concat(data.results || []);
|
||||
if (!data.next) break;
|
||||
page++;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
async function batchCreateRows(tableId, rows) {
|
||||
if (!rows.length) return;
|
||||
// Baserow limit: 200 per batch
|
||||
for (let i = 0; i < rows.length; i += 200) {
|
||||
const chunk = rows.slice(i, i + 200);
|
||||
await baserowFetch(`/database/rows/table/${tableId}/batch/?user_field_names=true`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ items: chunk }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listRows, batchCreateRows };
|
||||
@@ -0,0 +1,33 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const CONFIG_PATH = process.env.CONFIG_PATH || '/data/config.json';
|
||||
|
||||
function getEnvConfig() {
|
||||
return {
|
||||
adminPassword: process.env.ADMIN_PASSWORD || 'admin',
|
||||
baserowUrl: (process.env.BASEROW_URL || 'http://baserow:80').replace(/\/+$/, ''),
|
||||
baserowToken: process.env.BASEROW_TOKEN || '',
|
||||
port: parseInt(process.env.PORT || '7849', 10),
|
||||
};
|
||||
}
|
||||
|
||||
function readConfig() {
|
||||
const defaults = { sets: [], answersTableId: null };
|
||||
try {
|
||||
if (fs.existsSync(CONFIG_PATH)) {
|
||||
return { ...defaults, ...JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[config] Fehler beim Lesen:', e.message);
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
||||
function writeConfig(data) {
|
||||
const dir = path.dirname(CONFIG_PATH);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(CONFIG_PATH, JSON.stringify(data, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
module.exports = { getEnvConfig, readConfig, writeConfig };
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
const { getEnvConfig, readConfig, writeConfig } = require('./config');
|
||||
const { listRows, batchCreateRows } = require('./baserow');
|
||||
const { setupWebSocket, getSessions } = require('./live-session');
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
|
||||
app.use(express.json({ limit: '5mb' }));
|
||||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||
|
||||
/* ---- Auth middleware ---- */
|
||||
function requireAdmin(req, res, next) {
|
||||
if (req.headers['x-admin-token'] !== getEnvConfig().adminPassword) {
|
||||
return res.status(401).json({ error: 'Nicht autorisiert' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/* ============================================================ */
|
||||
/* Public API */
|
||||
/* ============================================================ */
|
||||
|
||||
app.get('/api/sets', (req, res) => {
|
||||
const config = readConfig();
|
||||
res.json(config.sets.map((s) => ({ id: s.id, name: s.name })));
|
||||
});
|
||||
|
||||
app.get('/api/sets/:id/questions', async (req, res) => {
|
||||
try {
|
||||
const config = readConfig();
|
||||
const set = config.sets.find((s) => s.id === req.params.id);
|
||||
if (!set) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const rows = await listRows(set.tableId);
|
||||
res.json(rows);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/results', async (req, res) => {
|
||||
try {
|
||||
const config = readConfig();
|
||||
if (!config.answersTableId) return res.status(400).json({ error: 'Antworten-Tabelle nicht konfiguriert' });
|
||||
await batchCreateRows(config.answersTableId, req.body.answers);
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/results/:name', async (req, res) => {
|
||||
try {
|
||||
const config = readConfig();
|
||||
if (!config.answersTableId) return res.json([]);
|
||||
const rows = await listRows(config.answersTableId, { Nutzername: req.params.name });
|
||||
res.json(rows);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
/* ============================================================ */
|
||||
/* Admin API */
|
||||
/* ============================================================ */
|
||||
|
||||
app.post('/api/admin/login', (req, res) => {
|
||||
if (req.body.password === getEnvConfig().adminPassword) {
|
||||
res.json({ success: true, token: getEnvConfig().adminPassword });
|
||||
} else {
|
||||
res.status(401).json({ error: 'Falsches Passwort' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/config', requireAdmin, (req, res) => res.json(readConfig()));
|
||||
|
||||
app.post('/api/admin/config', requireAdmin, (req, res) => {
|
||||
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) => {
|
||||
try {
|
||||
const config = readConfig();
|
||||
if (!config.answersTableId) return res.json({ answers: [] });
|
||||
const rows = await listRows(config.answersTableId);
|
||||
res.json({ answers: rows });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
/* ---- 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] Laeuft auf Port ${port}`));
|
||||
@@ -0,0 +1,409 @@
|
||||
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();
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
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) {
|
||||
send(ws, { type: 'error', message: 'Ungueltige Nachricht' });
|
||||
}
|
||||
});
|
||||
ws.on('close', () => handleDisconnect(ws));
|
||||
});
|
||||
|
||||
// Heartbeat every 30 s
|
||||
setInterval(() => {
|
||||
wss.clients.forEach((ws) => {
|
||||
if (!ws.isAlive) return ws.terminate();
|
||||
ws.isAlive = false;
|
||||
ws.ping();
|
||||
});
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Message router */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
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-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 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
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 beim Laden: ' + 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: [],
|
||||
};
|
||||
|
||||
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 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' });
|
||||
if (s.status !== 'waiting') return send(ws, { type: 'error', message: 'Session laeuft bereits' });
|
||||
|
||||
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; // Reconnect
|
||||
} 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; // Already answered
|
||||
|
||||
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; // Time up
|
||||
|
||||
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;
|
||||
|
||||
broadcastAll(s, {
|
||||
type: 'question',
|
||||
index: s.currentQuestionIndex,
|
||||
total: s.questions.length,
|
||||
question: sanitizeQuestion(q),
|
||||
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;
|
||||
|
||||
// Answer distribution
|
||||
const stats = {};
|
||||
['A', 'B', 'C', 'D'].forEach((k) => { if (q[`Antwort ${k}`]) stats[k] = 0; });
|
||||
|
||||
// Score for each player who answered
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
||||
// Players who didn't answer
|
||||
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);
|
||||
|
||||
// Send personal results to players
|
||||
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(`[quizalarm] Session ${s.code}: ${s.results.length} Ergebnisse gespeichert`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[quizalarm] 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) });
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Exports */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function getSessions() {
|
||||
return Array.from(sessions.values()).map(sessionInfo);
|
||||
}
|
||||
|
||||
module.exports = { setupWebSocket, getSessions };
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Normalize text for freitext comparison.
|
||||
* Removes special characters, lowercases, collapses whitespace.
|
||||
* "Villingen-Schwenningen" == "villingen schwenningen" == "villingenschwenningen"
|
||||
*/
|
||||
function normalizeText(text) {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[äÄ]/g, 'ae')
|
||||
.replace(/[öÖ]/g, 'oe')
|
||||
.replace(/[üÜ]/g, 'ue')
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(/[^a-z0-9]/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function checkFreitext(userAnswer, correctAnswer) {
|
||||
return normalizeText(userAnswer) === normalizeText(correctAnswer);
|
||||
}
|
||||
|
||||
function parseCorrectAnswers(question) {
|
||||
const raw = (question['Richtige Antwort'] || '').trim();
|
||||
const typ = (question['Typ'] || 'MC').toLowerCase();
|
||||
if (typ === 'freitext') return [raw];
|
||||
return raw
|
||||
.split(',')
|
||||
.map((s) => s.trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function countOptions(question) {
|
||||
let n = 0;
|
||||
if (question['Antwort A']) n++;
|
||||
if (question['Antwort B']) n++;
|
||||
if (question['Antwort C']) n++;
|
||||
if (question['Antwort D']) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate score for an answer.
|
||||
* @param {object} question – Baserow row
|
||||
* @param {string[]} selected – e.g. ['A','C'] or ['Berlin']
|
||||
* @param {number} timeRemaining – seconds left (0 if no timer)
|
||||
* @param {number} totalTime – total seconds (0 if binary)
|
||||
* @param {string} scoringMode – 'binary' | 'time'
|
||||
* @returns {{ points: number, correct: boolean }}
|
||||
*/
|
||||
function calculateScore(question, selected, timeRemaining, totalTime, scoringMode) {
|
||||
if (!selected || selected.length === 0) return { points: 0, correct: false };
|
||||
|
||||
// Base points
|
||||
let basis;
|
||||
if (scoringMode === 'time' && totalTime > 0) {
|
||||
const ratio = Math.max(0, Math.min(1, timeRemaining / totalTime));
|
||||
basis = 500 + Math.round(ratio * 500);
|
||||
} else {
|
||||
basis = 1000;
|
||||
}
|
||||
|
||||
const typ = (question['Typ'] || 'MC').toLowerCase();
|
||||
const correctAnswers = parseCorrectAnswers(question);
|
||||
|
||||
// Freitext
|
||||
if (typ === 'freitext') {
|
||||
const ok = checkFreitext(selected[0], correctAnswers[0]);
|
||||
return { points: ok ? basis : 0, correct: ok };
|
||||
}
|
||||
|
||||
// MC / Wahr-Falsch
|
||||
const correctSet = new Set(correctAnswers);
|
||||
const totalCorrect = correctSet.size;
|
||||
const totalIncorrect = countOptions(question) - totalCorrect;
|
||||
|
||||
let rightPicked = 0;
|
||||
let wrongPicked = 0;
|
||||
for (const s of selected) {
|
||||
if (correctSet.has(s.toUpperCase())) rightPicked++;
|
||||
else wrongPicked++;
|
||||
}
|
||||
|
||||
let points = (rightPicked / Math.max(totalCorrect, 1)) * basis;
|
||||
if (totalIncorrect > 0) {
|
||||
points -= (wrongPicked / totalIncorrect) * (basis / 2);
|
||||
}
|
||||
points = Math.max(0, Math.round(points));
|
||||
|
||||
const fullyCorrect = rightPicked === totalCorrect && wrongPicked === 0;
|
||||
return { points, correct: fullyCorrect };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the correct answer from a question before sending to participants.
|
||||
*/
|
||||
function sanitizeQuestion(question) {
|
||||
const typ = (question['Typ'] || 'MC').toLowerCase();
|
||||
const sanitized = {
|
||||
id: question.id,
|
||||
frage: question['Frage'] || '',
|
||||
typ: question['Typ'] || 'MC',
|
||||
bild: null,
|
||||
antworten: [],
|
||||
kategorie: question['Kategorie'] || '',
|
||||
};
|
||||
|
||||
// Bild (Baserow file field → array of objects)
|
||||
const bild = question['Bild'];
|
||||
if (bild && Array.isArray(bild) && bild.length > 0) {
|
||||
sanitized.bild = bild[0].url;
|
||||
}
|
||||
|
||||
if (typ === 'freitext') {
|
||||
// no options
|
||||
} else {
|
||||
const keys = ['A', 'B', 'C', 'D'];
|
||||
for (const k of keys) {
|
||||
const val = question[`Antwort ${k}`];
|
||||
if (val) sanitized.antworten.push({ key: k, text: val });
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
module.exports = { calculateScore, sanitizeQuestion, parseCorrectAnswers, normalizeText };
|
||||
Reference in New Issue
Block a user