/** * Sena SDK Constructor * @param {Object} config - Configuration object for the SDK * @param {Object} config.data - Quiz data containing question, options, and answer */ function SenaSDK(gid = "G2510S1T30") { // Initialize data this.data = null; this.correctAnswer = null; this.gameCode = gid; // Initialize properties this.timeLimit = 0; this.shuffle = true; // tracking time in game this.startTime = 0; this.endTime = 0; // Initialize Web Speech API this.speechSynthesis = window.speechSynthesis; this.currentUtterance = null; this.voiceSettings = { lang: "en-US", rate: 1.0, // Speed (0.1 to 10) pitch: 1.0, // Pitch (0 to 2) volume: 1.0, // Volume (0 to 1) }; // Multi-question support (from postMessage) this.list = []; this.currentQuestion = null; this.level = 0; this.totalQuestions = 0; this.userResults = []; this.gameID = null; this.userId = null; this.postMessageDataLoaded = false; this.isMatchingGame = false; // true nếu là game matching (G3xxx) // PostMessage listener tracking this._postMessageListenerRegistered = false; this._waitingForPostMessage = false; this._postMessageTimeout = null; this._loadCallback = null; // Game interaction tracking - để biết có thể reload data không this._gameStartedByUser = false; // true khi user đã tương tác (click, drag, etc.) this._dataLoadedFromServer = false; // true nếu data từ server (không phải postMessage) // ========== MODE SUPPORT ========== // 'live' - Chờ vô hạn cho postMessage từ server (production) // 'preview' - Timeout 5s rồi fallback sample (testing với data thật) // 'dev' - Load sample ngay lập tức (development) this.mode = "preview"; // Default mode this.role = "student"; // Default role } /** * Shuffle array using Fisher-Yates algorithm * @param {Array} array - Array to shuffle */ SenaSDK.prototype.shuffleArray = function (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]]; } }; /** * Load data từ postMessage (SERVER_PUSH_DATA) * Game type được xác định từ gameCode: * - G1xxx = Quiz (trắc nghiệm) * - G2xxx = Sort (sắp xếp) * - G3xxx = Match (nối cặp) -> forward to tdv_sdk * - G4xxx = Fill (điền từ) * - G5xxx = Custom * * Data format: * { * gameCode: "G1400S1T30", // Bắt buộc - xác định loại game * data: { request, question, options: [] }, * answer: "..." * } */ SenaSDK.prototype.loadFromPostMessage = function (inputJson, callback) { let self = this; if (!inputJson) { console.error("🎮 Sena SDK: No data in postMessage"); if (callback) callback(false); return false; } console.log("📦 Sena SDK: Loading from PostMessage:", inputJson); // ========== DETECT GAME TYPE TỪ GAMECODE ========== let gameCode = inputJson.gameCode || self.gameCode; let gameCategory = gameCode.charAt(1); // G[category]xxx - 1=Quiz, 2=Sort, 3=Match, 4=Fill, 5=Custom console.log("🎮 Sena SDK: GameCode:", gameCode, "| Category:", gameCategory); // ========== MATCHING GAME (G3xxx) ========== // Chuyển tiếp cho tdv_sdk xử lý if (gameCategory === "3") { console.log( "🎯 Sena SDK: Detected MATCHING GAME (G3xxx), forwarding to tdv_sdk...", ); // Lưu thông tin cơ bản self.gameCode = gameCode; self.gameID = gameCode; self.postMessageDataLoaded = true; self.isMatchingGame = true; self._dataLoadedFromServer = false; // Data từ postMessage, không phải server // Forward data cho tdv_sdk nếu có if (window.tdv_sdk) { window.tdv_sdk.loadFromPostMessage(inputJson, callback); console.log("✅ Sena SDK: Forwarded to tdv_sdk"); } else { console.warn("⚠️ Sena SDK: tdv_sdk not found, storing data locally"); self.data = inputJson.data; self.correctAnswer = inputJson.answer; if (callback) callback(true); } return true; } // ========== QUIZ / SORT / FILL / CUSTOM (G1, G2, G4, G5) ========== // Single question format: có gameCode và data (không phải array) let isSingleQuestion = inputJson.gameCode && inputJson.data && !Array.isArray(inputJson.data); if (isSingleQuestion) { // ========== SINGLE QUESTION (SenaSDK format) ========== console.log("🎯 Sena SDK: Detected SINGLE QUESTION format"); self.gameCode = inputJson.gameCode || self.gameCode; self.gameID = inputJson.gameCode; // Parse game code để lấy settings self._parseGameCode(); // Set data trực tiếp self.data = { question: inputJson.data.question || "", request: inputJson.data.request || "", options: inputJson.data.options || [], image: inputJson.data.image || "", audio: inputJson.data.audio || "", hint: inputJson.data.hint || null, }; // --- [UPDATE G5] Khởi tạo Master List cho G5 --- if (self.gameType === 5 && self.data && self.data.options) { // Lưu trữ danh sách gốc self.masterList = [...self.data.options]; // Tính tổng số level self.totalLevels = Math.ceil(self.masterList.length / self.itemCount); self.currentLevel = 0; // Load Level 1 ngay lập tức để self.data.options chỉ chứa 6 card đầu self.loadLevelG5(1); } self.correctAnswer = inputJson.answer; // Cũng set vào list để hỗ trợ multi-question API self.list = [ { id: "Q1", question: inputJson.data.question, request: inputJson.data.request, options: inputJson.data.options, answer: inputJson.answer, image: inputJson.data.image, audio: inputJson.data.audio, }, ]; self.totalQuestions = 1; self.level = 0; self.currentQuestion = self.list[0]; self.userResults = []; // [UPDATE G4] Process G4 Data if (self.gameType === 4) self._processG4Data(); console.log( "✅ Sena SDK: Single question loaded -", inputJson.description || inputJson.data.question, ); } else { // ========== MULTI-QUESTION (Exam mode) ========== console.log("📋 Sena SDK: Detected MULTI-QUESTION format"); // Lưu thông tin game self.gameID = inputJson.game_id || "quiz_game"; self.userId = inputJson.user_id || "guest"; // Lưu danh sách câu hỏi self.list = inputJson.data || inputJson.questions || []; self.totalQuestions = self.list.length; // Xử lý thời gian if (inputJson.total_time) { self.timeLimit = parseInt(inputJson.total_time, 10); } if (inputJson.end_time_iso) { let endTimeDate = new Date(inputJson.end_time_iso); let now = new Date(); self.timeLimit = Math.max(0, Math.floor((endTimeDate - now) / 1000)); } // Xử lý resume (câu hỏi đã hoàn thành) let completedData = inputJson.completed_question_ids || []; self.userResults = []; let resumeLevel = 0; for (let i = 0; i < self.list.length; i++) { let done = completedData.find( (item) => (item.id || item) === self.list[i].id, ); if (done) { self.userResults.push({ id: self.list[i].id, result: done.result !== undefined ? done.result : 0, }); resumeLevel = i + 1; } else { resumeLevel = i; break; } } self.level = Math.min(resumeLevel, self.totalQuestions - 1); // Load câu hỏi đầu tiên và convert sang định dạng SenaSDK self._loadCurrentQuestionToData(); console.log("✅ Sena SDK: Loaded", self.totalQuestions, "questions"); } self.postMessageDataLoaded = true; if (callback) callback(true); return true; }; /** * [UPDATED] Parse game code để lấy settings * Cho phép G1-G9 (thay vì G1-G5) */ SenaSDK.prototype._parseGameCode = function () { let self = this; const gameCode = self.gameCode || "G4410S1T30"; // G4 mẫu // Regex hỗ trợ G1-G9 const regex = /^G([1-9])(\d{1,2})([0-2])([0-2])(?:S([0-1]))?(?:T(\d+))?$/; let match = gameCode.match(regex); if (match) { self.gameType = parseInt(match[1], 10); self.itemCount = parseInt(match[2], 10); self.questionType = parseInt(match[3], 10); self.optionType = parseInt(match[4], 10); const shuffleFlag = match[5] !== undefined ? match[5] : "1"; const timeStr = match[6] !== undefined ? match[6] : "0"; self.shuffle = shuffleFlag === "1"; // --- [UPDATE G5] Logic Time Per Card --- if (self.gameType === 5) { self.timePerCard = parseInt(timeStr, 10); // T5 = 5s mỗi card self.timeLimit = 0; // G5 không giới hạn tổng thời gian } else { self.timeLimit = parseInt(timeStr, 10); self.timePerCard = 0; } } }; /** * Load câu hỏi hiện tại vào this.data */ SenaSDK.prototype._loadCurrentQuestionToData = function () { let self = this; if (self.list.length > 0 && self.level < self.list.length) { self.currentQuestion = self.list[self.level]; // Convert sang định dạng SenaSDK chuẩn self.data = { question: self.currentQuestion.question || "", request: self.currentQuestion.request || "", options: self.currentQuestion.options || [], image: self.currentQuestion.image || "", audio: self.currentQuestion.audio || "", }; self.correctAnswer = self.currentQuestion.answer; } }; /** * Chuyển sang câu hỏi tiếp theo (multi-question mode) */ SenaSDK.prototype.nextQuestion = function () { let self = this; if (self.level < self.totalQuestions - 1) { self.level++; self._loadCurrentQuestionToData(); console.log( "🎮 Sena SDK: Next Question:", self.level + 1, "/", self.totalQuestions, ); return true; } console.log("🎮 Sena SDK: No more questions"); return false; }; /** * Lấy số câu hỏi hiện tại (1-indexed) */ SenaSDK.prototype.getCurrentNumber = function () { return this.level + 1; }; /** * Lấy tổng số câu hỏi */ SenaSDK.prototype.getTotalQuestions = function () { return this.totalQuestions || 1; }; // Settings cho postMessage waiting SenaSDK.prototype.POSTMESSAGE_TIMEOUT_MS = 1000; // 5 giây (chỉ dùng cho preview mode) SenaSDK.prototype.load = function (callback, template = "G2510S1T30") { let self = this; // *** AUTO-REGISTER postMessage listener (chỉ 1 lần) *** if (!self._postMessageListenerRegistered) { self.registerPostMessageListener(); self._postMessageListenerRegistered = true; } // Nếu đã có data từ postMessage, không cần load lại if (self.postMessageDataLoaded && self.list.length > 0) { console.log("🎮 Sena SDK: Data already loaded from postMessage"); if (callback) callback(true); return; } // Nếu đã có data (từ bất kỳ nguồn nào), không cần load lại if (self.data && self.data.options) { console.log("🎮 Sena SDK: Data already available"); if (callback) callback(true); return; } // ========== GET URL PARAMETERS ========== const urlParams = new URLSearchParams(window.location.search); // Get LID (game code) from URL const LID = urlParams.get("LID"); if (LID) { self.gameCode = LID; } // Get MODE from URL: ?mode=live | ?mode=preview | ?mode=dev const urlMode = urlParams.get("mode"); if (urlMode && ["live", "preview", "dev"].includes(urlMode.toLowerCase())) { self.mode = urlMode.toLowerCase(); } // THÊM 2 DÒNG NÀY: Lấy role từ URL const urlRole = urlParams.get("role"); if (urlRole) self.role = urlRole.toLowerCase(); console.log( "🎮 Sena SDK: Mode =", self.mode.toUpperCase(), "| Role =", self.role || "student", "| GameCode =", self.gameCode, ); // Lưu callback để gọi sau self._loadCallback = callback; // ========== MODE-BASED LOADING ========== switch (self.mode) { case "live": // LIVE MODE: Chờ vô hạn cho postMessage từ server self._waitingForPostMessage = true; console.log( "⏳ Sena SDK: [LIVE MODE] Waiting for server data via postMessage (no timeout)...", ); // Không set timeout - chờ vô hạn // Gửi GAME_READY để server biết game sẵn sàng nhận data self._sendGameReady(); break; case "dev": // DEV MODE: Load sample data ngay lập tức console.log("🔧 Sena SDK: [DEV MODE] Loading sample data immediately..."); self._waitingForPostMessage = false; self._loadFromServer(callback, template); break; case "preview": default: // PREVIEW MODE: Timeout 5s rồi fallback sample self._waitingForPostMessage = true; console.log( "⏳ Sena SDK: [PREVIEW MODE] Waiting for postMessage (" + self.POSTMESSAGE_TIMEOUT_MS / 1000 + "s timeout)...", ); // Set timeout để fallback load từ server self._postMessageTimeout = setTimeout(function () { if (self._waitingForPostMessage && !self.postMessageDataLoaded) { console.warn( "⚠️ Sena SDK: No postMessage received, fallback to sample data...", ); self._loadFromServer(self._loadCallback, template); } }, self.POSTMESSAGE_TIMEOUT_MS); break; } }; /** * Gửi GAME_READY message cho parent window * Để server biết game đã sẵn sàng nhận data */ SenaSDK.prototype._sendGameReady = function () { let self = this; window.parent.postMessage( { type: "GAME_READY", payload: { game_id: self.gameID || self.gameCode, game_code: self.gameCode, mode: self.mode, }, }, "*", ); console.log("📤 Sena SDK: Sent GAME_READY to parent"); }; /** * Fallback: Load data từ server khi không có postMessage */ SenaSDK.prototype._loadFromServer = function ( callback, template = "G2510S1T30", ) { let self = this; self._waitingForPostMessage = false; const validation = self.validateGameCode(self.gameCode); if (!validation.valid) { console.warn("🎮 Sena SDK: " + validation.error); } console.log("📡 Sena SDK: Fallback loading from server..."); fetch(`https://senaai.tech/sample/${self.gameCode}.json`) .then((response) => response.json()) .then((data) => { self.data = data.data; self.correctAnswer = data.answer || null; const gameCode = self.gameCode || template; // FIX: Regex chấp nhận 2 chữ số cho Count const strictRegex = /^G([1-9])(\d{1,2})([0-2])([0-2])(?:S([0-1]))?(?:T(\d+))?$/; let match = gameCode.match(strictRegex); if (match) { self.gameType = parseInt(match[1], 10); self.itemCount = parseInt(match[2], 10); self.questionType = parseInt(match[3], 10); self.optionType = parseInt(match[4], 10); const shuffleFlag = match[5] !== undefined ? match[5] : "1"; const timeStr = match[6] !== undefined ? match[6] : "0"; self.shuffle = shuffleFlag === "1"; self.timeLimit = parseInt(timeStr, 10); console.log("🎮 Sena SDK: ✓ Valid game code parsed:", { type: self.gameType, count: self.itemCount, time: self.timeLimit, }); } else { console.error( "🎮 Sena SDK: ✗ Cannot parse game code (Strict match failed):", gameCode, ); // Fallback safe mode self.shuffle = true; // Nếu load được file json thì ta cứ tin tưởng lấy time trong đó nếu có, hoặc default 30s self.timeLimit = 30; } // Mục đích: Tráo vị trí data ngay lập tức sau khi load xong if (self.shuffle && self.data && self.data.options) { self.shuffleArray(self.data.options); console.log("🎮 Sena SDK: Data shuffled immediately on load"); } // --- [UPDATE G5] Khởi tạo Master List cho G5 --- if (self.gameType === 5 && self.data && self.data.options) { // Lưu trữ danh sách gốc self.masterList = [...self.data.options]; // Tính tổng số level self.totalLevels = Math.ceil(self.masterList.length / self.itemCount); self.currentLevel = 0; // Load Level 1 ngay lập tức để self.data.options chỉ chứa 6 card đầu self.loadLevelG5(1); } // [UPDATE G4] Process G4 Data if (self.gameType === 4) self._processG4Data(); console.log("🎮 Sena SDK: Data loaded for", self.gameCode); self._dataLoadedFromServer = true; if (callback) callback(true); }) .catch((error) => { console.error("🎮 Sena SDK: Error loading data:", error); if (callback) callback(false); }); }; /** * Validate game code format * @param {string} code - Game code to validate * @returns {Object} Validation result with valid flag and parsed values */ SenaSDK.prototype.validateGameCode = function (code) { code = code || this.gameCode; // Format: G[type][count][qType][oType]S[shuffle]T[time] // type: 1=Quiz, 2=Sort, 3=Match/Memory, 4=Fill, 5=Custom // count: 2-9 // qType/oType: 0=Text, 1=Image, 2=Audio const regex = /^G([1-9])(\d{1,2})([0-2])([0-2])(?:S([0-1]))?(?:T(\d+))?$/; const match = code.match(regex); if (!match) { // Check what's wrong let error = "Invalid game code format"; const looseMatch = code.match(/^G([0-9])(\d{1,2})([0-9])([0-9])/); if (looseMatch) { const issues = []; // QUAN TRỌNG: Sửa '5' thành '9' ở dòng dưới đây if (looseMatch[1] < "1" || looseMatch[1] > "9") issues.push("type must be 1-9"); if (looseMatch[2] < "2" || looseMatch[2] > "9") issues.push("count must be 2-9"); if (looseMatch[3] > "2") issues.push("qType must be 0-2 (0=Text, 1=Image, 2=Audio)"); if (looseMatch[4] > "2") issues.push("oType must be 0-2 (0=Text, 1=Image, 2=Audio)"); if (issues.length > 0) error = issues.join(", "); } return { valid: false, error: error, code: code }; } return { valid: true, code: code, type: parseInt(match[1], 10), count: parseInt(match[2], 10), qType: parseInt(match[3], 10), oType: parseInt(match[4], 10), shuffle: match[5] !== "0", time: match[6] ? parseInt(match[6], 10) : 0, description: this.getGameCodeDescription(match), }; }; /** * Get human-readable description of game code */ SenaSDK.prototype.getGameCodeDescription = function (match) { if (!match) return ""; const types = { 1: "Quiz", 2: "Sort/Sequence", 3: "Match/Memory", 4: "Fill", 5: "Custom", }; const contentTypes = ["Text", "Image", "Audio"]; const gameType = types[match[1]] || "Unknown"; const qType = contentTypes[parseInt(match[3])] || "Unknown"; const oType = contentTypes[parseInt(match[4])] || "Unknown"; return `${gameType}: ${match[2]} items, ${qType} → ${oType}`; }; /** * Generate comprehensive developer guide based on game code * @returns {string} Developer guide with implementation instructions */ SenaSDK.prototype.guide = function () { let self = this; const gameCode = self.gameCode || "G2510S1T30"; const data = self.data || {}; /** * Regex giải thích: * ^G([1-5])([2-9])([0-2])([0-2]) : Bắt buộc (Loại, Số lượng, Q, O) * (?:S([0-1]))? : Không bắt buộc, mặc định S1 * (?:T(\d+))? : Không bắt buộc, mặc định T0 */ const regex = /^G([1-9])([2-9])([0-2])([0-2])(?:S([0-1]))?(?:T(\d+))?$/; const match = gameCode.match(regex); if (!match) return "Mã game không hợp lệ! Định dạng chuẩn: Gxxxx hoặc GxxxxSxTxx"; const category = match[1]; const count = match[2]; const qIdx = match[3]; const oIdx = match[4]; const shuffle = match[5] !== undefined ? match[5] : "1"; const time = match[6] !== undefined ? match[6] : "0"; const types = { 0: "Text", 1: "Image", 2: "Audio" }; let guide = ""; // Header guide += `╔════════════════════════════════════════════════════════════════════════════╗\n`; guide += `║ SENA SDK - DEVELOPER GUIDE: ${gameCode.padEnd(37)}║\n`; guide += `╚════════════════════════════════════════════════════════════════════════════╝\n\n`; // Game Analysis guide += `📊 GAME ANALYSIS\n`; guide += `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`; let gameName = "", instruction = "", displayMode = ""; switch (category) { case "1": gameName = "Quiz (Trắc nghiệm)"; instruction = `Người dùng chọn 1 đáp án đúng trong ${count} options`; displayMode = `Question: ${types[qIdx]} → Options: ${types[oIdx]}`; break; case "2": if (qIdx === "0" && oIdx === "0") { gameName = "Sort Word (Sắp xếp từ)"; instruction = `Sắp xếp ${count} từ/ký tự thành chuỗi hoàn chỉnh`; } else { gameName = "Sequences (Sắp xếp chuỗi)"; instruction = `Sắp xếp ${count} items theo đúng thứ tự`; } displayMode = `Hint: ${types[qIdx]} → Items: ${types[oIdx]}`; break; case "3": if (qIdx === oIdx) { gameName = "Memory Card (Trí nhớ)"; instruction = `Lật và ghép ${count} cặp thẻ giống nhau`; } else { gameName = "Matching (Nối cặp)"; instruction = `Nối ${count} items từ 2 nhóm với nhau`; } displayMode = `Group A: ${types[qIdx]} ↔ Group B: ${types[oIdx]}`; break; } guide += `Game Type : ${gameName}\n`; guide += `Objective : ${instruction}\n`; guide += `Display Mode : ${displayMode}\n`; guide += `Items Count : ${count}\n`; guide += `Shuffle : ${shuffle === "1" ? "YES (call sdk.start() to shuffle)" : "NO (S0)"}\n`; guide += `Time Limit : ${time === "0" ? "Unlimited" : time + " seconds"}\n`; if (data.hint && data.hint.type) { guide += `Hint Type : ${data.hint.type}\n`; guide += `Hint Count : ${Array.isArray(data.hint.value) ? data.hint.value.length : "1"}\n`; } guide += `\n🔧 IMPLEMENTATION STEPS\n`; guide += `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n`; // Step 1: Initialize guide += `1️⃣ INITIALIZE SDK\n`; guide += ` var sdk = new SenaSDK('${gameCode}');\n`; guide += ` sdk.load(function(success) {\n`; guide += ` if (success) sdk.start();\n`; guide += ` });\n\n`; // Step 2: Display based on game type guide += `2️⃣ DISPLAY UI\n`; if (category === "1") { // Quiz guide += ` // Display Question\n`; if (qIdx === "0") { guide += ` var questionText = sdk.getQuestionValue();\n`; guide += ` displayText(questionText); // Show text\n`; } else if (qIdx === "1") { guide += ` var questionImg = sdk.getQuestionValue();\n`; guide += ` displayImage(questionImg); // Show image URL\n`; } else if (qIdx === "2") { guide += ` sdk.playVoice('question'); // Auto play audio\n`; } guide += `\n // Display Options\n`; guide += ` var optionsCount = sdk.getOptionsCount(); // ${count} items\n`; guide += ` for (var i = 0; i < optionsCount; i++) {\n`; if (oIdx === "0") { guide += ` var optionText = sdk.getOptionsValue(i).text;\n`; guide += ` createButton(optionText, i); // Show text button\n`; } else if (oIdx === "1") { guide += ` var optionImg = sdk.getOptionsValue(i).image;\n`; guide += ` createImageButton(optionImg, i); // Show image button\n`; } else if (oIdx === "2") { guide += ` createAudioButton(i); // Button to play audio\n`; guide += ` // onClick: sdk.playVoice('option' + (i+1));\n`; } guide += ` }\n`; } else if (category === "2") { // Sort/Sequences guide += ` // Display Hint (if exists)\n`; if (qIdx === "0") { guide += ` var hintText = sdk.getQuestionValue() || sdk.getRequestValue();\n`; guide += ` if (hintText) displayHint(hintText);\n`; } else if (qIdx === "1") { guide += ` var hintImg = sdk.getQuestionValue();\n`; guide += ` if (hintImg) displayHintImage(hintImg);\n`; } guide += `\n // Display Draggable Items\n`; guide += ` var itemsCount = sdk.getOptionsCount();\n`; guide += ` for (var i = 0; i < itemsCount; i++) {\n`; if (oIdx === "0") { guide += ` var itemText = sdk.getOptionsValue(i).text;\n`; guide += ` createDraggableText(itemText, i);\n`; } else if (oIdx === "1") { guide += ` var itemImg = sdk.getOptionsValue(i).image;\n`; guide += ` createDraggableImage(itemImg, i);\n`; } guide += ` }\n`; guide += ` // User drags to reorder items\n`; } else if (category === "3") { // Memory/Matching guide += ` var itemsCount = sdk.getOptionsCount();\n`; if (qIdx === oIdx) { guide += ` // Memory Card - Create pairs\n`; guide += ` var allCards = []; // Duplicate items for pairs\n`; guide += ` for (var i = 0; i < itemsCount; i++) {\n`; guide += ` allCards.push(sdk.getOptionsValue(i));\n`; guide += ` allCards.push(sdk.getOptionsValue(i)); // Duplicate\n`; guide += ` }\n`; guide += ` shuffleArray(allCards);\n`; guide += ` // Create face-down cards, flip on click\n`; } else { guide += ` // Matching - Create two groups\n`; guide += ` for (var i = 0; i < itemsCount; i++) {\n`; if (qIdx === "0") { guide += ` var leftText = sdk.getOptionsValue(i).text;\n`; guide += ` createLeftItem(leftText, i);\n`; } else if (qIdx === "1") { guide += ` var leftImg = sdk.getOptionsValue(i).image;\n`; guide += ` createLeftItem(leftImg, i);\n`; } if (oIdx === "0") { guide += ` var rightText = sdk.getOptionsValue(i).text; // Matching pair\n`; guide += ` createRightItem(rightText, i);\n`; } else if (oIdx === "1") { guide += ` var rightImg = sdk.getOptionsValue(i).image;\n`; guide += ` createRightItem(rightImg, i);\n`; } guide += ` }\n`; guide += ` // User draws lines to match pairs\n`; } } // Step 3: Handle Hint if (data.hint && data.hint.type) { guide += `\n3️⃣ HANDLE HINT (Optional)\n`; guide += ` var hintType = sdk.getHintType(); // "${data.hint.type}"\n`; if (data.hint.type === "display") { guide += ` var hintCount = sdk.getHintCount();\n`; guide += ` for (var i = 0; i < hintCount; i++) {\n`; guide += ` var hintItem = sdk.getHintValue(i);\n`; guide += ` displayHintItem(hintItem, i); // Show each hint\n`; guide += ` }\n`; } else if (data.hint.type === "audio") { guide += ` var hintAudio = sdk.getHintValue();\n`; guide += ` createHintButton(hintAudio); // Play audio hint\n`; } else if (data.hint.type === "text") { guide += ` var hintText = sdk.getHintValue();\n`; guide += ` displayHintText(hintText);\n`; } } // Step 4: Check Answer const stepNum = data.hint ? "4️⃣" : "3️⃣"; guide += `\n${stepNum} CHECK ANSWER\n`; if (category === "1") { guide += ` // User clicks an option\n`; guide += ` function onOptionClick(selectedIndex) {\n`; guide += ` var userAnswer = sdk.getOptionsValue(selectedIndex).text;\n`; guide += ` var result = sdk.end(userAnswer, function(isCorrect) {\n`; guide += ` if (isCorrect) showSuccess();\n`; guide += ` else showError();\n`; guide += ` });\n`; guide += ` }\n`; } else if (category === "2") { guide += ` // User finishes sorting\n`; guide += ` function onSubmitOrder(orderedArray) {\n`; guide += ` var answerStr = orderedArray.map(item => item.text).join('|');\n`; guide += ` sdk.end(answerStr, function(isCorrect) {\n`; guide += ` if (isCorrect) showSuccess();\n`; guide += ` else showCorrectOrder();\n`; guide += ` });\n`; guide += ` }\n`; } else if (category === "3") { guide += ` // User completes all matches/pairs\n`; guide += ` function onAllMatched(matchedPairs) {\n`; guide += ` var answerStr = matchedPairs.map(p => p.text).join('|');\n`; guide += ` sdk.end(answerStr, function(isCorrect) {\n`; guide += ` showResult(isCorrect);\n`; guide += ` });\n`; guide += ` }\n`; } // Step 5: Timer if (time !== "0") { const nextStep = data.hint ? "5️⃣" : "4️⃣"; guide += `\n${nextStep} TIMER COUNTDOWN\n`; guide += ` var timeLimit = sdk.timeLimit; // ${time} seconds\n`; guide += ` startCountdown(timeLimit);\n`; guide += ` // If time runs out before sdk.end(), user fails\n`; } // API Reference guide += `\n\n📚 KEY API METHODS\n`; guide += `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`; guide += `sdk.load(callback, template) - Load data from server\n`; guide += `sdk.start() - Start game, shuffle if needed\n`; guide += `sdk.getQuestionValue() - Get question text/image/audio\n`; guide += `sdk.getQuestionType() - Get question type (text/image/audio)\n`; guide += `sdk.getOptionsCount() - Get number of options\n`; guide += `sdk.getOptionsValue(index) - Get option object at index\n`; guide += `sdk.getOptionsType() - Get options type\n`; guide += `sdk.getHintType() - Get hint type\n`; guide += `sdk.getHintValue(index) - Get hint value/array item\n`; guide += `sdk.getHintCount() - Get hint items count\n`; guide += `sdk.playVoice(type) - Play TTS (question/option1/hint)\n`; guide += `sdk.end(answer, callback) - Check answer & return result\n`; guide += `sdk.timeLimit - Time limit in seconds\n`; guide += `sdk.shuffle - Whether to shuffle options\n`; guide += `\n\n💡 TIPS\n`; guide += `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`; if (shuffle === "1") { guide += `• Options are shuffled after sdk.start() - display in new order\n`; } if (time !== "0") { guide += `• Implement timer UI and auto-submit when time expires\n`; } guide += `• Use Web Speech API: sdk.playVoice() for TTS in English\n`; guide += `• Multiple answers format: "answer1|answer2|answer3"\n`; guide += `• sdk.end() returns: {isCorrect, duration, correctAnswer, userAnswer}\n`; guide += `\n${"═".repeat(76)}\n`; return guide; }; /** * Get the question text/url * @returns {string} Question, request text, or URL */ SenaSDK.prototype.getQuestionValue = function () { var q = String(this.data.question || "").trim(); // Đã bỏ chặn URL để có thể lấy link ảnh/audio return q; }; /** * Get the question type * @returns {string} Question type (text, image, audio) */ SenaSDK.prototype.getQuestionType = function () { let self = this; // based on game code, determine question type const gameCode = self.gameCode || "G2510S1T30"; const regex = /^G([1-9])([2-9])([0-2])([0-2])(?:S([0-1]))?(?:T(\d+))?$/; const match = gameCode.match(regex); if (match) { const qIdx = match[3]; const types = { 0: "text", 1: "image", 2: "audio" }; return types[qIdx] || "text"; } return "text"; }; /** * [UPDATE] Get the question image URL * Dùng cho G4 khi cần hiển thị song song cả Text (getQuestionValue) và Image * @returns {string} Image URL */ SenaSDK.prototype.getQuestionImage = function () { if (this.data && this.data.image) { return String(this.data.image).trim(); } return ""; }; /** * Get the request value * @returns {string} Request text */ SenaSDK.prototype.getRequestValue = function () { if (this.data && this.data.request && this.data.request !== "") { return this.data.request; } else { return ""; } }; /** * Get the request type (same as question type) * @returns {string} Request type (text, image, audio) */ SenaSDK.prototype.getRequestType = function () { return this.getQuestionType(); }; /** * Get total number of options * @returns {number} Number of options */ SenaSDK.prototype.getOptionsCount = function () { if (this.data && this.data.options) { return this.data.options.length; } return 0; }; /** * Get options type based on game code * @returns {string} Options type (text, image, audio) */ SenaSDK.prototype.getOptionsType = function () { let self = this; // based on game code, determine options type const gameCode = self.gameCode || "G2510S1T30"; const regex = /^G([1-9])([2-9])([0-2])([0-2])(?:S([0-1]))?(?:T(\d+))?$/; const match = gameCode.match(regex); if (match) { const oIdx = match[4]; const types = { 0: "text", 1: "image", 2: "audio" }; return types[oIdx] || "text"; } return "text"; }; /** * Get option value by index based on options type from game code * @param {number} index - Option index * @returns {string} Option value (text, image URL, or audio URL based on game code) */ SenaSDK.prototype.getOptionsValue = function (index) { return this.data.options[index]; }; /** * Get hint type * @returns {string} Hint type (e.g., 'display', 'audio', 'text', or empty string if no hint) */ SenaSDK.prototype.getHintType = function () { if (this.data && this.data.hint && this.data.hint.type) { return this.data.hint.type; } return ""; }; /** * Get hint count (number of elements if hint is an array, particularly for display type) * @returns {number} Number of elements in hint array, or 1 if not an array, or 0 if no hint */ SenaSDK.prototype.getHintCount = function () { const hintValue = this.getHintValue(); if (hintValue === null) { return 0; } if (Array.isArray(hintValue)) { return hintValue.length; } return 1; }; /** * Get hint value * @param {number} index - Optional index for array hints (display type) * @returns {*} Hint value (string, array element, or null if no hint) */ SenaSDK.prototype.getHintValue = function (index) { if (this.data && this.data.hint && this.data.hint.value !== undefined) { const hintValue = this.data.hint.value; const hintType = this.getHintType(); // If hint type is display and value is array, return specific index if (hintType === "display" && Array.isArray(hintValue)) { if (index !== undefined && index >= 0 && index < hintValue.length) { return hintValue[index]; } // If no index provided or invalid, return the whole array return hintValue; } // For audio or text type, return the value directly return hintValue; } return null; }; /** * Start the quiz - resets index and shuffles options */ SenaSDK.prototype.start = function () { let self = this; // Nếu là matching game, forward sang tdv_sdk.start() if (self.isMatchingGame) { console.log("🎮 Sena SDK: Matching game - forwarding start() to tdv_sdk"); if (window.tdv_sdk && typeof window.tdv_sdk.start === "function") { window.tdv_sdk.start(); } return; } // Quiz/Sort/Fill games self.curIndex = 0; if (self.shuffle && self.data && self.data.options) { self.shuffleArray(self.data.options); } self.startTime = Date.now(); // Additional logic for tracking can be added here if needed }; /** * Đánh dấu user đã tương tác với game * Gọi hàm này từ Construct khi user click/drag lần đầu * Sau khi gọi, postMessage muộn sẽ bị bỏ qua */ SenaSDK.prototype.markUserInteraction = function () { if (!this._gameStartedByUser) { this._gameStartedByUser = true; console.log( "🎮 Sena SDK: User interaction detected - late postMessage will be ignored", ); // Cũng đánh dấu cho tdv_sdk nếu là matching game if (this.isMatchingGame && window.tdv_sdk) { window.tdv_sdk._gameStartedByUser = true; } } }; /** * Kiểm tra xem có thể reload data không (user chưa tương tác) */ SenaSDK.prototype.canReloadData = function () { return !this._gameStartedByUser; }; /** * End the game and check answer * [UPDATE] Support Unordered Answers & Auto-cleanup empty strings */ SenaSDK.prototype.end = function (answer, callback) { let self = this; // ========== MATCHING GAME: Forward to tdv_sdk ========== if (self.isMatchingGame && window.tdv_sdk) { console.log("🎮 Sena SDK: Matching game - forwarding end() to tdv_sdk"); // Lấy kết quả từ tdv_sdk let isCorrect = window.tdv_sdk.isCorrect() === 1; let duration = window.tdv_sdk.getTimeSpent(); let correctCount = window.tdv_sdk.getCorrectCount(); let totalPairs = window.tdv_sdk.pairCount; console.log(`Time spent in game: ${duration} seconds`); console.log( `Result: ${correctCount}/${totalPairs} correct - ${isCorrect ? "CORRECT" : "INCORRECT"}`, ); if (callback) callback(isCorrect); return { isCorrect: isCorrect, duration: duration, correctCount: correctCount, totalPairs: totalPairs, }; } // ========== QUIZ/SORT/FILL GAMES ========== self.endTime = Date.now(); const duration = (self.endTime - self.startTime) / 1000; // 1. CLEANUP INPUT: Tách chuỗi, Xóa khoảng trắng, Chuyển thường, LỌC BỎ RỖNG // .filter(a => a) sẽ loại bỏ ngay cái đuôi "" do dấu | thừa tạo ra const userAnswers = answer.includes("|") ? answer .split("|") .map((a) => a.trim().toLowerCase()) .filter((a) => a) : [answer.trim().toLowerCase()].filter((a) => a); // 2. GET CORRECT ANSWERS let correctAnswers = []; if (self.correctAnswer) { if (Array.isArray(self.correctAnswer)) { correctAnswers = self.correctAnswer.map((a) => (typeof a === "object" ? a.text || "" : String(a)).trim().toLowerCase(), ); } else { let str = typeof self.correctAnswer === "object" ? self.correctAnswer.text : String(self.correctAnswer); correctAnswers = str.includes("|") ? str.split("|").map((a) => a.trim().toLowerCase()) : [str.trim().toLowerCase()]; } } // 3. COMPARE // Nếu là Game Type 2 (Sort) thì giữ nguyên thứ tự, nếu không thì sort (unordered) const isStrictOrder = self.gameType === 2; const finalUser = isStrictOrder ? [...userAnswers] : [...userAnswers].sort(); const finalCorrect = isStrictOrder ? [...correctAnswers] : [...correctAnswers].sort(); let isCorrect = false; // Helper check file name for URL matching const getFileName = (url) => { if (!url.startsWith("http")) return url; try { return url.split("/").pop().split("?")[0]; } catch (e) { return url; } }; if (finalUser.length === finalCorrect.length) { isCorrect = finalUser.every((uVal, index) => { let cVal = finalCorrect[index]; if (uVal === cVal) return true; // Fuzzy match cho URL (so sánh tên file ảnh) if (uVal.startsWith("http") || cVal.startsWith("http")) { return getFileName(uVal) === getFileName(cVal); } return false; }); } // ----------------------------------------------------------- // [BƯỚC 1] Kiểm tra Time Limit TRƯỚC (Sửa biến isCorrect) // ----------------------------------------------------------- // THÊM ĐIỀU KIỆN: Nếu là teacher thì bỏ qua kiểm tra thời gian if ( self.role !== "teacher" && self.timeLimit > 0 && duration > self.timeLimit ) { isCorrect = false; // CHỈ sửa biến boolean, KHÔNG gọi result.isCorrect console.log("🎮 Sena SDK: Time Limit Exceeded -> Result set to False"); } // ----------------------------------------------------------- // [BƯỚC 2] Sau đó mới tạo biến result (Dùng isCorrect đã chốt) // ----------------------------------------------------------- const result = { isCorrect: isCorrect, // Lúc này isCorrect đã được xử lý xong xuôi duration: duration, correctAnswer: correctAnswers.join(" | "), userAnswer: userAnswers.join(" | "), }; // ----------------------------------------------------------- // [BƯỚC 3] Log và Return // ----------------------------------------------------------- console.log(`Time spent: ${duration}s`); console.log( `Result: ${isCorrect ? "CORRECT" : "INCORRECT"} (User: ${result.userAnswer} vs Correct: ${result.correctAnswer})`, ); if (callback) callback(result.isCorrect); return result; // Return full object for debug }; SenaSDK.prototype.playVoice = function (type) { let self = this; // type: 'question', 'optionA', 'optionB', ... // if type is options, get corresponding option text like option0 -> index 0 let textToSpeak = ""; if (type.startsWith("option")) { const optionIndex = parseInt(type.slice(6)) - 1; textToSpeak = self.getOptionsValue(optionIndex); } else if (type === "question") { textToSpeak = self.getQuestionValue(); } else if (type === "request") { textToSpeak = self.getRequestValue(); } else if (type === "hint") { const hintValue = self.getHintValue(); if (typeof hintValue === "string") { textToSpeak = hintValue; } } if (textToSpeak == "") return; if (window["audioPlayer"]) { window["audioPlayer"].pause(); window["audioPlayer"].src = textToSpeak; window["audioPlayer"].play(); } else { window["audioPlayer"] = new Audio(textToSpeak); window["audioPlayer"].play(); } }; SenaSDK.prototype.helper = {}; SenaSDK.prototype.helper.CalcObjectPositions = function ( n, objectWidth, margin, maxWidth, ) { let self = this; self.positions = []; const totalWidth = n * objectWidth + (n - 1) * margin; const startX = (maxWidth - totalWidth) / 2; let positions = []; for (let i = 0; i < n; i++) { positions.push(startX + i * (objectWidth + margin)); } positions.map((pos) => pos + objectWidth / 2); // Adjusting to center the objects self.positions = positions; }; SenaSDK.prototype.helper.getPosXbyIndex = function (index) { let self = this; if (index < 0 || index >= self.positions.length) { return null; // Return null if index is out of bounds } return self.positions[index]; }; /** * Đăng ký listener nhận data qua postMessage * Gọi hàm này sau khi tạo instance SenaSDK */ SenaSDK.prototype.registerPostMessageListener = function () { let self = this; window.addEventListener("message", function (event) { if (!event.data || !event.data.type) return; switch (event.data.type) { case "SERVER_PUSH_DATA": console.log( "📥 Sena SDK: Received SERVER_PUSH_DATA", event.data.jsonData, ); // Hủy timeout fallback nếu đang chờ if (self._postMessageTimeout) { clearTimeout(self._postMessageTimeout); self._postMessageTimeout = null; } self._waitingForPostMessage = false; // ========== LATE ARRIVAL HANDLING ========== // Nếu đã load từ server NHƯNG user chưa tương tác → cho phép override if (self._dataLoadedFromServer && !self._gameStartedByUser) { console.log( "🔄 Sena SDK: PostMessage arrived late but user hasn't started - RELOADING DATA", ); // Reset state trước khi load mới self.postMessageDataLoaded = false; self._dataLoadedFromServer = false; } // Nếu user đã tương tác → bỏ qua postMessage muộn if (self._gameStartedByUser) { console.log( "⚠️ Sena SDK: PostMessage arrived late but user already playing - IGNORED", ); return; } // Load data từ postMessage self.loadFromPostMessage(event.data.jsonData); // Nếu là matching game, cũng forward cho tdv_sdk listener let gameCode = event.data.jsonData && event.data.jsonData.gameCode; if (gameCode && gameCode.charAt(1) === "3" && window.tdv_sdk) { // Hủy timeout của tdv_sdk nếu có if (window.tdv_sdk._postMessageTimeout) { clearTimeout(window.tdv_sdk._postMessageTimeout); window.tdv_sdk._postMessageTimeout = null; } window.tdv_sdk._waitingForPostMessage = false; // Gọi callback của tdv_sdk nếu có if (window.tdv_sdk._loadCallback) { window.tdv_sdk._loadCallback(true); window.tdv_sdk._loadCallback = null; } } // Gọi callback nếu có (từ hàm load()) if (self._loadCallback) { self._loadCallback(true); self._loadCallback = null; } break; case "SYNC_TIME": if (event.data.end_time_iso) { let endTimeDate = new Date(event.data.end_time_iso); let now = new Date(); self.timeLimit = Math.max(0, Math.floor((endTimeDate - now) / 1000)); console.log( "🎮 Sena SDK: Time synced, remaining:", self.timeLimit, "s", ); // Sync cho tdv_sdk nếu có if (window.tdv_sdk) { window.tdv_sdk.timeLimit = self.timeLimit; window.tdv_sdk.endTime = endTimeDate; } } break; // ========== NEW MESSAGE TYPES (theo README protocol) ========== case "SDK_DATA_READY": // Server gửi sanitized data cho game render (LIVE mode) console.log("📥 Sena SDK: Received SDK_DATA_READY", event.data.payload); self._handleSdkDataReady(event.data.payload); break; case "SDK_PUSH_DATA": // Preview mode: Game push data để SDK sanitize console.log("📥 Sena SDK: Received SDK_PUSH_DATA", event.data.payload); if (event.data.payload && event.data.payload.items) { // Convert items to our format và load self._handleSdkPushData(event.data.payload.items); } break; case "SDK_ANSWER_RESULT": // Server trả về kết quả verify answer console.log( "📥 Sena SDK: Received SDK_ANSWER_RESULT", event.data.payload, ); self._lastAnswerResult = event.data.payload; // Trigger callback nếu có if (self._answerResultCallback) { self._answerResultCallback(event.data.payload); self._answerResultCallback = null; } break; case "SEQUENCE_SYNC": console.log("📥 Sena SDK: Received SEQUENCE_SYNC", event.data); if (event.data.uuid === self.uuid) { console.log("🔄 Sena SDK: Own message echoed back, processing..."); } if (typeof self.onCustomMessage === "function") { self.onCustomMessage(event.data.data, event.data.uuid); } break; case "SDK_ERROR": // Server gửi error console.error("❌ Sena SDK: Received SDK_ERROR", event.data.payload); break; } }); console.log("🎮 Sena SDK: PostMessage listener registered"); console.log( " Supported types: SERVER_PUSH_DATA, SDK_DATA_READY, SDK_PUSH_DATA, SYNC_TIME", ); // Đăng ký tdv_sdk listener nếu có (không cần gọi registerPostMessageListener riêng) if (window.tdv_sdk && !window.tdv_sdk._postMessageListenerRegistered) { window.tdv_sdk._postMessageListenerRegistered = true; console.log("🎮 Sena SDK: tdv_sdk will receive forwarded data"); } }; /** * Handle SDK_DATA_READY message (LIVE mode - server gửi sanitized data) */ SenaSDK.prototype._handleSdkDataReady = function (payload) { let self = this; if (!payload || !payload.items) { console.error("🎮 Sena SDK: SDK_DATA_READY missing items"); return; } // Hủy timeout nếu có if (self._postMessageTimeout) { clearTimeout(self._postMessageTimeout); self._postMessageTimeout = null; } self._waitingForPostMessage = false; // Convert SDK format to SenaSDK format let items = payload.items; if (items.length === 1) { // Single question let item = items[0]; self.data = { question: item.question || "", request: item.question || "", options: item.options ? item.options.map((o) => o.text || o.audio || o) : [], }; self.gameID = item.id; } else { // Multi-question self.list = items.map((item, idx) => ({ id: item.id || idx, question: item.question || "", request: item.question || "", options: item.options ? item.options.map((o) => o.text || o.audio || o) : [], answer: null, // Server keeps answer })); self.totalQuestions = items.length; self.level = payload.completed_count || 0; self._loadCurrentQuestionToData(); } self.postMessageDataLoaded = true; console.log("✅ Sena SDK: SDK_DATA_READY processed -", items.length, "items"); // Gọi callback if (self._loadCallback) { self._loadCallback(true); self._loadCallback = null; } }; /** * Handle SDK_PUSH_DATA message (PREVIEW mode - game push data với answer) */ SenaSDK.prototype._handleSdkPushData = function (items) { let self = this; if (!items || items.length === 0) { console.error("🎮 Sena SDK: SDK_PUSH_DATA missing items"); return; } // Hủy timeout nếu có if (self._postMessageTimeout) { clearTimeout(self._postMessageTimeout); self._postMessageTimeout = null; } self._waitingForPostMessage = false; if (items.length === 1) { // Single question let item = items[0]; self.data = { question: item.question || "", request: item.question || "", options: item.options ? item.options.map((o) => o.text || o.audio || o) : [], }; self.correctAnswer = item.answer; self.gameID = item.id; } else { // Multi-question với answer self.list = items.map((item, idx) => ({ id: item.id || idx, question: item.question || "", request: item.question || "", options: item.options ? item.options.map((o) => o.text || o.audio || o) : [], answer: item.answer, })); self.totalQuestions = items.length; self.level = 0; self._loadCurrentQuestionToData(); } self.postMessageDataLoaded = true; console.log("✅ Sena SDK: SDK_PUSH_DATA processed -", items.length, "items"); // Gọi callback if (self._loadCallback) { self._loadCallback(true); self._loadCallback = null; } }; /** * Gửi SDK_CHECK_ANSWER cho server verify (LIVE mode) */ SenaSDK.prototype.checkAnswerViaServer = function ( questionId, choice, callback, ) { let self = this; self._answerResultCallback = callback; window.parent.postMessage( { type: "SDK_CHECK_ANSWER", payload: { question_id: questionId, choice: choice, time_spent: self.getTimeSpent ? self.getTimeSpent() * 1000 : 0, }, }, "*", ); console.log("📤 Sena SDK: Sent SDK_CHECK_ANSWER for", questionId); }; /** * Get time spent in seconds */ SenaSDK.prototype.getTimeSpent = function () { if (!this.startTime) return 0; return Math.floor((Date.now() - this.startTime) / 1000); }; /** * Submit đáp án cho multi-question mode * @param {string} selectedText - Đáp án người dùng chọn * @param {boolean} isTimeout - True nếu hết giờ * @returns {Object} Result object {isCorrect, result} */ SenaSDK.prototype.play = function (selectedText, isTimeout) { let self = this; // Nếu không có list (single question mode), dùng end() if (!self.currentQuestion && self.data) { let result = { isCorrect: false, result: 0 }; self.end(selectedText, function (isCorrect) { result.isCorrect = isCorrect; result.result = isCorrect ? 1 : 0; }); return result.result; } if (!self.currentQuestion) return 0; let isActuallyTimeout = isTimeout === true || String(isTimeout) === "true"; // Kiểm tra đã trả lời câu này chưa let alreadyAnswered = self.userResults.find( (r) => r.id === self.currentQuestion.id, ); if (alreadyAnswered) { return alreadyAnswered.result; } let isCorrect = false; let userChoice = null; if (isActuallyTimeout) { isCorrect = false; userChoice = null; } else { userChoice = String(selectedText).trim(); let correctAnswer = String(self.currentQuestion.answer).trim(); isCorrect = userChoice.toLowerCase() === correctAnswer.toLowerCase(); } let resultValue = isCorrect ? 1 : 0; let report = { question_id: self.currentQuestion.id, result: resultValue, choice: userChoice, is_timeout: isActuallyTimeout, }; self.userResults.push({ id: report.question_id, result: report.result }); console.log("🎮 Sena SDK: Answer Report:", report); // Gửi kết quả cho parent window window.parent.postMessage({ type: "ANSWER_REPORT", data: report }, "*"); return resultValue; }; /** * Kết thúc bài thi và gửi kết quả (multi-question mode) */ SenaSDK.prototype.submitResults = function () { let self = this; // Loại bỏ duplicate results let uniqueResults = []; let seenIds = {}; for (let i = self.userResults.length - 1; i >= 0; i--) { if (!seenIds[self.userResults[i].id]) { uniqueResults.unshift(self.userResults[i]); seenIds[self.userResults[i].id] = true; } } let correctCount = uniqueResults.filter((r) => r.result === 1).length; let totalScore = self.totalQuestions > 0 ? Math.round((correctCount / self.totalQuestions) * 100) / 10 : 0; let timeSpent = Math.floor((Date.now() - self.startTime) / 1000); let finalData = { game_id: self.gameID, user_id: self.userId, score: totalScore, time_spent: timeSpent, correct_count: correctCount, total_questions: self.totalQuestions, details: uniqueResults, }; console.log( "🎮 Sena SDK: Final Result - Score:", totalScore, "Time Spent:", timeSpent, "s", ); // Gửi kết quả cho parent window window.parent.postMessage({ type: "FINAL_RESULT", data: finalData }, "*"); return finalData; }; // Export for different module systems if (typeof module !== "undefined" && module.exports) { module.exports = SenaSDK; } else if (typeof define === "function" && define.amd) { define([], function () { return SenaSDK; }); } else { window.SenaSDK = SenaSDK; } /** * [NEW] Get Target Name for G9 (Memory Shuffle) * Lấy tên đáp án đúng (Ví dụ: "cat") */ SenaSDK.prototype.getTargetName = function () { // Với G9, correctAnswer sẽ lưu target name if (this.correctAnswer) { // Nếu correctAnswer là object (trường hợp hiếm), lấy text if (typeof this.correctAnswer === "object" && this.correctAnswer.text) { return this.correctAnswer.text; } return String(this.correctAnswer); } return ""; }; /** * [NEW] Helpers for Card Object (G9) */ SenaSDK.prototype.getCardName = function (index) { if (this.data && this.data.options && this.data.options[index]) { let opt = this.data.options[index]; return opt.name || opt.text || ""; } return ""; }; SenaSDK.prototype.getCardImage = function (index) { if (this.data && this.data.options && this.data.options[index]) { let opt = this.data.options[index]; return opt.image || ""; } return ""; }; SenaSDK.prototype.getCardAudio = function (index) { if (this.data && this.data.options && this.data.options[index]) { let opt = this.data.options[index]; return opt.audio || ""; } return ""; }; /** * [UPDATE G5] Load data cho level cụ thể (Phân trang tự nhiên) * Logic mới: Lấy vừa đủ số lượng còn lại, không lặp lại (wrap-around) data cũ. */ SenaSDK.prototype.loadLevelG5 = function (levelIndex) { let self = this; if (self.gameType !== 5 || !self.masterList) return false; self.currentLevel = levelIndex; let count = self.itemCount; // Số card tối đa mỗi trang (VD: 6) window.Sena_TotalLevels = Math.ceil(self.masterList.length / count); let startIndex = (levelIndex - 1) * count; // --- LOGIC MỚI: CẮT DATA (SLICING) --- // Tính điểm kết thúc: Nếu vượt quá độ dài list thì lấy độ dài list (không wrap) let endIndex = Math.min(startIndex + count, self.masterList.length); // Cắt danh sách card cho level hiện tại let levelOptions = self.masterList.slice(startIndex, endIndex); // Gán vào data.options để C2 render self.data.options = levelOptions; console.log( `🎮 Sena SDK: Loaded Level ${levelIndex} (G5) with ${levelOptions.length} cards`, ); return true; }; /** * [NEW G5] Lấy thông tin Level */ SenaSDK.prototype.getTotalLevels = function () { return this.totalLevels || 1; }; SenaSDK.prototype.getTimePerCard = function () { if (this.timePerCard === undefined) { this._parseGameCode(); } if (this.timePerCard && this.timePerCard > 0) { return this.timePerCard; } return 5; }; SenaSDK.prototype.getCardType = function (index) { // Ưu tiên 1: Lấy từ data.options (G4, G1, G2 đang chạy trên grid hiện tại) if (this.data && this.data.options && this.data.options[index]) { return this.data.options[index].type || "text"; } // Ưu tiên 2: Fallback cho G5 (Master List) if (this.masterList && this.masterList[index]) { return this.masterList[index].type || "text"; } // Mặc định return "text"; }; // [UPDATE G4] Xử lý data đặc thù cho Memory Card: Fill blank và Xử lý thẻ lẻ (Orphan) SenaSDK.prototype._processG4Data = function () { let self = this; if (!self.data.options) self.data.options = []; // BƯỚC 1: Xử lý thẻ lẻ (Sanitize Data) ngay tại nguồn // Đếm số lượng xuất hiện của từng cặp tên let counts = {}; self.data.options.forEach((item) => { if (item.type !== "blank" && item.name) { counts[item.name] = (counts[item.name] || 0) + 1; } }); // Duyệt lại và biến những thẻ có số lượng < 2 thành blank self.data.options.forEach((item) => { if (item.type !== "blank" && item.name) { if (counts[item.name] < 2) { console.log("🎮 Sena SDK: Orphan card detected & removed:", item.name); item.type = "blank"; item.name = "blank"; // Xóa tên để tránh logic game bắt nhầm item.image = ""; // Xóa ảnh item.id = "blank_sanitized"; } } }); // BƯỚC 2: Fill thêm thẻ blank cho đủ 9 ô (Logic cũ) while (self.data.options.length < 9) { self.data.options.push({ id: "blank_" + self.data.options.length, type: "blank", name: "blank", value: -1, image: "", }); } // BƯỚC 3: Shuffle (Trộn bài) if (self.shuffle) { self.shuffleArray(self.data.options); } }; // [UPDATE G4] Hàm lấy ID SenaSDK.prototype.getCardID = function (index) { if (this.data && this.data.options && this.data.options[index]) { return this.data.options[index].id || ""; } return ""; }; // [UPDATE G4] Hàm Check Pair (Logic tạm thời ở Client cho Mock) SenaSDK.prototype.checkPair = function (idx1, idx2, callback) { let self = this; // Validate index let card1 = self.data.options[idx1]; let card2 = self.data.options[idx2]; if (!card1 || !card2) { if (callback) callback(false); return; } // Logic so sánh: Name giống nhau VÀ ID khác nhau (tránh click 2 lần 1 thẻ) VÀ không phải blank let isMatch = false; if (card1.type !== "blank" && card2.type !== "blank") { if (card1.id !== card2.id) { // Check ko phải chính nó // So sánh name (ví dụ: "dog" == "dog") if ( card1.name && card2.name && card1.name.toLowerCase() === card2.name.toLowerCase() ) { isMatch = true; } } } console.log( `🎮 Sena SDK: Check Pair [${idx1}] vs [${idx2}] -> ${isMatch ? "MATCH" : "WRONG"}`, ); // [TODO] Sau này sẽ thay đoạn này bằng postMessage lên Server verify if (callback) callback(isMatch); }; /** * [NEW v2.2] Gửi Custom Data lên Parent Window * @param {Object} data - Object chứa 5 trường data1 -> data5 */ SenaSDK.prototype.sendMessageToParent = function (data) { let self = this; // Tự động tạo UUID cho session nếu chưa có if (!self.uuid) { self.uuid = "session-" + Date.now() + "-" + Math.floor(Math.random() * 10000); } // Đóng gói payload đúng chuẩn tài liệu v2.2 let payload = { type: "SEQUENCE_SYNC", uuid: self.uuid, data: data, timestamp: Date.now(), }; console.log("📤 Sena SDK: Sending SEQUENCE_SYNC to parent:", payload); // Gửi lên Parent Window (Backend/Iframe parent) window.parent.postMessage(payload, "*"); }; if (typeof module !== "undefined" && module.exports) { module.exports = SenaSDK; } else if (typeof define === "function" && define.amd) { define([], function () { return SenaSDK; }); } else { window.SenaSDK = SenaSDK; }