Files
Quizalarm/server/quiz-logic.js
T

126 lines
3.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };