up
All checks were successful
Deploy to Production / deploy (push) Successful in 8s

This commit is contained in:
lubukhu
2026-01-24 13:35:11 +07:00
parent 6c3e93636e
commit 65fd0158a3
145 changed files with 10262 additions and 0 deletions

View File

@@ -0,0 +1,444 @@
/**
* 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
*/
// Game code metadata
export const 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 },
};
export function getGameCategory(code) {
return 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
*/
export 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)
*/
export 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