Files
PairOrNotPair/PairOrNotPair/sena_sdk.js
Đặng Minh Quang 683fe53ddd
All checks were successful
Deploy to Production / deploy (push) Successful in 8s
deploy cicd
2026-02-27 15:32:51 +07:00

1919 lines
61 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
self._parseGameCode(); // Thêm dòng này để SDK đọc đuôi thời gian T30
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 / 6);
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 / 6);
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 = 6; // Khóa cứng max 6 card/trang cho G5
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;
}