450 lines
17 KiB
JavaScript
450 lines
17 KiB
JavaScript
"use strict";
|
|
/**
|
|
* GameDataHandler - Data Sanitizer & Scorer
|
|
*
|
|
* Game Codes:
|
|
* - G001: Quiz text-text
|
|
* - G002: Quiz audio-text (audio question, text answer)
|
|
* - G003: Quiz text-audio (text question, audio answer)
|
|
* - G004: Quiz image-text (image question, text answer)
|
|
* - G005: Quiz text-image (text question, image answer)
|
|
*
|
|
* - G110: Sequence Word - no audio
|
|
* - G111: Sequence Word - có audio, missing_letter_count từ item
|
|
* - G112: Sequence Word - có audio, missing_letter_count từ item
|
|
* - G113: Sequence Word - có audio, missing_letter_count từ item
|
|
*
|
|
* - G120: Sequence Sentence - no audio
|
|
* - G121: Sequence Sentence - có audio, missing_letter_count từ item
|
|
* - G122: Sequence Sentence - có audio, missing_letter_count từ item
|
|
* - G123: Sequence Sentence - có audio, missing_letter_count từ item
|
|
*/
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.GAME_CODES = void 0;
|
|
exports.getGameCategory = getGameCategory;
|
|
exports.sanitizeForClient = sanitizeForClient;
|
|
exports.checkAnswer = checkAnswer;
|
|
// Game code metadata
|
|
exports.GAME_CODES = {
|
|
// Quiz
|
|
G001: { name: 'Quiz Text-Text', category: 'quiz', hasAudio: false, hasImage: false },
|
|
G002: { name: 'Quiz Audio-Text', category: 'quiz', hasAudio: true, hasImage: false },
|
|
G003: { name: 'Quiz Text-Audio', category: 'quiz', hasAudio: true, hasImage: false },
|
|
G004: { name: 'Quiz Image-Text', category: 'quiz', hasAudio: false, hasImage: true },
|
|
G005: { name: 'Quiz Text-Image', category: 'quiz', hasAudio: false, hasImage: true },
|
|
// Sequence Word
|
|
G110: { name: 'Sequence Word', category: 'sequence_word', hasAudio: false },
|
|
G111: { name: 'Sequence Word Audio', category: 'sequence_word', hasAudio: true },
|
|
G112: { name: 'Sequence Word Audio', category: 'sequence_word', hasAudio: true },
|
|
G113: { name: 'Sequence Word Audio', category: 'sequence_word', hasAudio: true },
|
|
// Sequence Sentence
|
|
G120: { name: 'Sequence Sentence', category: 'sequence_sentence', hasAudio: false },
|
|
G121: { name: 'Sequence Sentence Audio', category: 'sequence_sentence', hasAudio: true },
|
|
G122: { name: 'Sequence Sentence Audio', category: 'sequence_sentence', hasAudio: true },
|
|
G123: { name: 'Sequence Sentence Audio', category: 'sequence_sentence', hasAudio: true },
|
|
};
|
|
function getGameCategory(code) {
|
|
return exports.GAME_CODES[code]?.category || 'unknown';
|
|
}
|
|
// =============================================================================
|
|
// SANITIZE DATA FOR CLIENT (REMOVE ANSWERS)
|
|
// =============================================================================
|
|
/**
|
|
* Sanitize game data before sending to iframe
|
|
* CRITICAL: Never send answers/correct data to client
|
|
*/
|
|
function sanitizeForClient(code, items) {
|
|
if (!Array.isArray(items))
|
|
return [];
|
|
switch (code) {
|
|
// ===== QUIZ VARIANTS =====
|
|
case 'G001': // Quiz text-text
|
|
return sanitizeQuizTextText(items);
|
|
case 'G002': // Quiz audio-text
|
|
return sanitizeQuizAudioText(items);
|
|
case 'G003': // Quiz text-audio
|
|
return sanitizeQuizTextAudio(items);
|
|
case 'G004': // Quiz image-text
|
|
return sanitizeQuizImageText(items);
|
|
case 'G005': // Quiz text-image
|
|
return sanitizeQuizTextImage(items);
|
|
// ===== SEQUENCE WORD VARIANTS =====
|
|
case 'G110': // Sequence word
|
|
return sanitizeSequenceWord(items);
|
|
case 'G111': // Sequence word
|
|
return sanitizeSequenceWord(items);
|
|
case 'G112': // Sequence word
|
|
return sanitizeSequenceWord(items);
|
|
case 'G113': // Sequence word
|
|
return sanitizeSequenceWord(items);
|
|
// ===== SEQUENCE SENTENCE VARIANTS =====
|
|
case 'G120': // Sequence sentence
|
|
return sanitizeSequenceSentence(items);
|
|
case 'G121': // Sequence sentence
|
|
return sanitizeSequenceSentence(items);
|
|
case 'G122': // Sequence sentence
|
|
return sanitizeSequenceSentence(items);
|
|
case 'G123': // Sequence sentence
|
|
return sanitizeSequenceSentence(items);
|
|
default:
|
|
console.warn(`[GameDataHandler] Unknown game code: ${code}, returning raw data`);
|
|
return items;
|
|
}
|
|
}
|
|
// =============================================================================
|
|
// QUIZ SANITIZERS
|
|
// =============================================================================
|
|
/**
|
|
* G001: Quiz Text-Text
|
|
* Client receives: id, question, options (shuffled)
|
|
* Client does NOT receive: answer
|
|
*/
|
|
function sanitizeQuizTextText(items) {
|
|
return items.map(item => {
|
|
// Normalize options to {text: string}
|
|
const options = (item.options || []).map((o) => {
|
|
if (typeof o === 'string') {
|
|
return { text: o };
|
|
}
|
|
if (o && typeof o === 'object') {
|
|
return { text: String(o.text ?? '') };
|
|
}
|
|
return { text: String(o ?? '') };
|
|
});
|
|
// Shuffle to hide answer position
|
|
shuffleArray(options);
|
|
// Save shuffled text order for SDK to resolve index
|
|
const shuffledTexts = options.map((o) => String(o.text ?? ''));
|
|
return {
|
|
id: item.id,
|
|
question: item.question,
|
|
options: options,
|
|
__shuffledOptions: shuffledTexts, // SDK internal
|
|
};
|
|
});
|
|
}
|
|
/**
|
|
* G002: Quiz Audio-Text
|
|
* Client receives: id, question (audio URL), options (shuffled)
|
|
* Client does NOT receive: answer
|
|
*/
|
|
function sanitizeQuizAudioText(items) {
|
|
return items.map(item => {
|
|
const options = (item.options || []).map((o) => {
|
|
if (typeof o === 'string') {
|
|
return { text: o };
|
|
}
|
|
if (o && typeof o === 'object') {
|
|
return { text: String(o.text ?? '') };
|
|
}
|
|
return { text: String(o ?? '') };
|
|
});
|
|
shuffleArray(options);
|
|
const shuffledTexts = options.map((o) => String(o.text ?? ''));
|
|
return {
|
|
id: item.id,
|
|
question: item.audio || item.audio_url,
|
|
options: options,
|
|
__shuffledOptions: shuffledTexts,
|
|
};
|
|
});
|
|
}
|
|
/**
|
|
* G003: Quiz Text-Audio
|
|
* Client receives: id, question (text), options (audio URLs shuffled)
|
|
* Client does NOT receive: answer
|
|
*/
|
|
function sanitizeQuizTextAudio(items) {
|
|
return items.map(item => {
|
|
const options = (item.options || []).map((o) => {
|
|
if (typeof o === 'string') {
|
|
return { audio: o };
|
|
}
|
|
if (o && typeof o === 'object') {
|
|
const audioUrl = o.audio || o.audio_url || '';
|
|
return { audio: String(audioUrl) };
|
|
}
|
|
return { audio: String(o ?? '') };
|
|
});
|
|
shuffleArray(options);
|
|
const shuffledAudios = options.map((o) => String(o.audio ?? ''));
|
|
return {
|
|
id: item.id,
|
|
question: item.question,
|
|
options: options,
|
|
__shuffledOptions: shuffledAudios,
|
|
};
|
|
});
|
|
}
|
|
/**
|
|
* G004: Quiz Image-Text
|
|
* Client receives: id, image_url, question (hint), options (shuffled)
|
|
* Client does NOT receive: answer
|
|
*/
|
|
function sanitizeQuizImageText(items) {
|
|
return items.map(item => {
|
|
const options = (item.options || []).map((o) => {
|
|
if (typeof o === 'string') {
|
|
return { text: o };
|
|
}
|
|
if (o && typeof o === 'object') {
|
|
return { text: String(o.text ?? '') };
|
|
}
|
|
return { text: String(o ?? '') };
|
|
});
|
|
shuffleArray(options);
|
|
const shuffledTexts = options.map((o) => String(o.text ?? ''));
|
|
return {
|
|
id: item.id,
|
|
image_url: item.image_url,
|
|
question: item.question,
|
|
options: options,
|
|
__shuffledOptions: shuffledTexts,
|
|
};
|
|
});
|
|
}
|
|
/**
|
|
* G005: Quiz Text-Image
|
|
* Client receives: id, question (text), options (image URLs shuffled)
|
|
* Client does NOT receive: answer
|
|
*/
|
|
function sanitizeQuizTextImage(items) {
|
|
return items.map(item => {
|
|
const options = (item.options || []).map((o) => {
|
|
if (typeof o === 'string') {
|
|
return { image_url: o };
|
|
}
|
|
if (o && typeof o === 'object') {
|
|
const imageUrl = o.image_url || o.image || '';
|
|
return { image_url: String(imageUrl) };
|
|
}
|
|
return { image_url: String(o ?? '') };
|
|
});
|
|
shuffleArray(options);
|
|
const shuffledUrls = options.map((o) => String(o.image_url ?? ''));
|
|
return {
|
|
id: item.id,
|
|
question: item.question,
|
|
options: options, // Each option has {image_url: ...}
|
|
__shuffledOptions: shuffledUrls,
|
|
};
|
|
});
|
|
}
|
|
// =============================================================================
|
|
// SEQUENCE WORD SANITIZERS
|
|
// =============================================================================
|
|
/**
|
|
* Sequence Word (G110-G113)
|
|
* Client receives: id, question (array with blanks), options (missing letters shuffled), audio_url (optional)
|
|
* Client does NOT receive: word, parts, answer, missing_letter_count
|
|
*
|
|
* Logic:
|
|
* 1. Read missing_letter_count from item (count of letters to blank out)
|
|
* 2. Randomly select positions to blank
|
|
* 3. question: array with blanks at selected positions
|
|
* 4. options: extracted missing letters (shuffled)
|
|
*/
|
|
function sanitizeSequenceWord(items) {
|
|
return items.map(item => {
|
|
const parts = item.answer || item.parts || [];
|
|
const missingCount = item.missing_letter_count || 0;
|
|
if (missingCount === 0 || parts.length === 0) {
|
|
// No missing - all visible
|
|
return {
|
|
id: item.id,
|
|
question: [...parts],
|
|
options: [],
|
|
...(item.audio_url && { audio_url: item.audio_url })
|
|
};
|
|
}
|
|
// Randomly select which positions to blank
|
|
const allIndices = Array.from({ length: parts.length }, (_, i) => i);
|
|
const blankIndices = new Set();
|
|
const count = Math.min(missingCount, parts.length);
|
|
while (blankIndices.size < count) {
|
|
const randomIdx = Math.floor(Math.random() * allIndices.length);
|
|
const actualIdx = allIndices[randomIdx];
|
|
blankIndices.add(actualIdx);
|
|
allIndices.splice(randomIdx, 1);
|
|
}
|
|
// Build question array with blanks at random positions
|
|
const question = parts.map((p, i) => blankIndices.has(i) ? "" : String(p));
|
|
// Extract missing letters and shuffle
|
|
const missingLetters = Array.from(blankIndices).map(i => String(parts[i]));
|
|
shuffleArray(missingLetters);
|
|
const result = {
|
|
id: item.id,
|
|
question, // e.g. ["H", "", "L", "", "O"]
|
|
options: missingLetters, // e.g. ["L", "E"] - shuffled
|
|
__shuffledOptions: [...missingLetters] // SDK internal: to resolve indices
|
|
};
|
|
if (item.audio_url) {
|
|
result.audio_url = item.audio_url;
|
|
}
|
|
// CRITICAL: Do NOT send word, parts, answer, missing_letter_count
|
|
return result;
|
|
});
|
|
}
|
|
// =============================================================================
|
|
// SEQUENCE SENTENCE SANITIZERS
|
|
// =============================================================================
|
|
/**
|
|
* Sequence Sentence (G120-G123)
|
|
* Client receives: id, question (array with blanks), options (missing words shuffled), audio_url (optional)
|
|
* Client does NOT receive: sentence, parts, answer, missing_letter_count
|
|
*
|
|
* Logic: Same as Sequence Word
|
|
* 1. Read missing_letter_count from item
|
|
* 2. Randomly select positions to blank
|
|
* 3. question: array with blanks
|
|
* 4. options: extracted missing words (shuffled)
|
|
*/
|
|
function sanitizeSequenceSentence(items) {
|
|
return items.map(item => {
|
|
const parts = item.answer || item.parts || [];
|
|
const missingCount = item.missing_letter_count || 0;
|
|
if (missingCount === 0 || parts.length === 0) {
|
|
// No missing - all visible
|
|
return {
|
|
id: item.id,
|
|
question: [...parts],
|
|
options: [],
|
|
...(item.audio_url && { audio_url: item.audio_url })
|
|
};
|
|
}
|
|
// Randomly select which positions to blank
|
|
const allIndices = Array.from({ length: parts.length }, (_, i) => i);
|
|
const blankIndices = new Set();
|
|
const count = Math.min(missingCount, parts.length);
|
|
while (blankIndices.size < count) {
|
|
const randomIdx = Math.floor(Math.random() * allIndices.length);
|
|
const actualIdx = allIndices[randomIdx];
|
|
blankIndices.add(actualIdx);
|
|
allIndices.splice(randomIdx, 1);
|
|
}
|
|
// Build question array with blanks at random positions
|
|
const question = parts.map((p, i) => blankIndices.has(i) ? "" : String(p));
|
|
// Extract missing words and shuffle
|
|
const missingWords = Array.from(blankIndices).map(i => String(parts[i]));
|
|
shuffleArray(missingWords);
|
|
const result = {
|
|
id: item.id,
|
|
question, // e.g. ["I", "", "reading", ""]
|
|
options: missingWords, // e.g. ["love", "books"] - shuffled
|
|
__shuffledOptions: [...missingWords] // SDK internal
|
|
};
|
|
if (item.audio_url) {
|
|
result.audio_url = item.audio_url;
|
|
}
|
|
// CRITICAL: Do NOT send sentence, parts, answer, missing_letter_count
|
|
return result;
|
|
});
|
|
}
|
|
/**
|
|
* Check if player's answer is correct
|
|
*
|
|
* @param code - Game code (G001, G110, etc.)
|
|
* @param originalItem - Original item from server (has answer field!)
|
|
* @param playerAnswer - Player's answer (text for quiz, array for sequence)
|
|
*/
|
|
function checkAnswer(code, originalItem, playerAnswer) {
|
|
switch (code) {
|
|
// ===== QUIZ VARIANTS (G001-G005) =====
|
|
case 'G001': // Quiz Text-Text
|
|
case 'G002': // Quiz Audio-Text
|
|
case 'G003': // Quiz Text-Audio
|
|
case 'G004': // Quiz Image-Text
|
|
case 'G005': // Quiz Text-Image
|
|
return checkQuizAnswer(originalItem, playerAnswer);
|
|
// ===== SEQUENCE WORD VARIANTS (G110-G113) =====
|
|
case 'G110': // Sequence Word
|
|
case 'G111': // Sequence Word Audio
|
|
case 'G112': // Sequence Word Audio
|
|
case 'G113': // Sequence Word Audio
|
|
return checkSequenceAnswer(originalItem, playerAnswer);
|
|
// ===== SEQUENCE SENTENCE VARIANTS (G120-G123) =====
|
|
case 'G120': // Sequence Sentence
|
|
case 'G121': // Sequence Sentence Audio
|
|
case 'G122': // Sequence Sentence Audio
|
|
case 'G123': // Sequence Sentence Audio
|
|
return checkSequenceAnswer(originalItem, playerAnswer);
|
|
default:
|
|
console.warn(`[GameDataHandler] Unknown game code for scoring: ${code}`);
|
|
return { isCorrect: false, score: 0 };
|
|
}
|
|
}
|
|
// Quiz Scoring
|
|
function checkQuizAnswer(item, answerChoice) {
|
|
const correctAnswer = String(item.answer || '');
|
|
if (!correctAnswer) {
|
|
return { isCorrect: false, score: 0, feedback: 'No correct answer defined' };
|
|
}
|
|
let playerAnswerText;
|
|
if (typeof answerChoice === 'number') {
|
|
// Index: resolve from original options
|
|
if (Array.isArray(item.options)) {
|
|
const v = item.options[answerChoice];
|
|
if (typeof v === 'string') {
|
|
playerAnswerText = v;
|
|
}
|
|
else if (v && typeof v === 'object' && 'text' in v) {
|
|
playerAnswerText = String(v.text ?? '');
|
|
}
|
|
else {
|
|
playerAnswerText = String(v ?? '');
|
|
}
|
|
}
|
|
else {
|
|
return { isCorrect: false, score: 0, feedback: 'Invalid question options' };
|
|
}
|
|
}
|
|
else {
|
|
// Direct text answer
|
|
if (answerChoice && typeof answerChoice === 'object' && 'text' in answerChoice) {
|
|
playerAnswerText = String(answerChoice.text ?? '');
|
|
}
|
|
else {
|
|
playerAnswerText = String(answerChoice ?? '');
|
|
}
|
|
}
|
|
const isCorrect = playerAnswerText.toLowerCase().trim() === correctAnswer.toLowerCase().trim();
|
|
return {
|
|
isCorrect,
|
|
score: isCorrect ? 1 : 0,
|
|
feedback: isCorrect
|
|
? `✅ Correct! "${playerAnswerText}" matches answer "${correctAnswer}"`
|
|
: `❌ Wrong. You chose "${playerAnswerText}" but correct answer is "${correctAnswer}"`
|
|
};
|
|
}
|
|
// Sequence Scoring
|
|
function checkSequenceAnswer(item, answer) {
|
|
const correctOrder = item.answer || item.parts;
|
|
if (!Array.isArray(answer) || !Array.isArray(correctOrder)) {
|
|
return { isCorrect: false, score: 0 };
|
|
}
|
|
const isCorrect = arraysEqual(answer, correctOrder);
|
|
return {
|
|
isCorrect,
|
|
score: isCorrect ? 1 : 0,
|
|
};
|
|
}
|
|
// =============================================================================
|
|
// UTILITIES
|
|
// =============================================================================
|
|
function shuffleArray(array) {
|
|
for (let i = array.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[array[i], array[j]] = [array[j], array[i]];
|
|
}
|
|
}
|
|
function arraysEqual(a, b) {
|
|
if (a.length !== b.length)
|
|
return false;
|
|
return a.every((val, idx) => {
|
|
if (typeof val === 'string' && typeof b[idx] === 'string') {
|
|
return val.toLowerCase().trim() === b[idx].toLowerCase().trim();
|
|
}
|
|
return val === b[idx];
|
|
});
|
|
}
|
|
//# sourceMappingURL=GameDataHandler.js.map
|