126 lines
3.8 KiB
JavaScript
126 lines
3.8 KiB
JavaScript
/**
|
||
* 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 }; |