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

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

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,82 @@
/**
* Game API Client Kit
* Standardized API client for communicating with Game Backend
*/
export class GameApiClient {
constructor(config) {
this.config = config;
}
async request(method, endpoint, body) {
const url = `${this.config.baseUrl}${endpoint}`;
const headers = {
'Content-Type': 'application/json',
...(this.config.getHeaders ? this.config.getHeaders() : {})
};
try {
const res = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined
});
if (!res.ok) {
const errorBody = await res.text();
let errorMessage = `API Error ${res.status}: ${res.statusText}`;
let errorCode;
try {
const jsonError = JSON.parse(errorBody);
// Capture error code from response
if (jsonError.code !== undefined) {
errorCode = jsonError.code;
}
if (jsonError.message)
errorMessage += ` - ${jsonError.message}`;
else if (jsonError.error)
errorMessage += ` - ${jsonError.error}`;
}
catch (e) {
if (errorBody && errorBody.length < 200)
errorMessage += ` - ${errorBody}`;
}
// Throw error object with code and message
const error = new Error(errorMessage);
error.code = errorCode;
error.httpStatus = res.status;
throw error;
}
return await res.json();
}
catch (error) {
console.error('[GameApiClient] Request failed:', error);
throw error;
}
}
async getGameWithProgress(assignmentId, studentId, refresh = false) {
return this.request('GET', `/submissions/live/init/${assignmentId}/${studentId}${refresh ? '?refresh=1' : ''}`);
}
async startLiveSession(assignmentId, studentId, refresh = false) {
return this.request('POST', `/submissions/live/start${refresh ? '?refresh=1' : ''}`, {
assignment_id: assignmentId,
student_id: studentId
});
}
async submitAnswer(assignmentId, studentId, questionId, answer, timeSpent = 5, isTimeout = false) {
return this.request('POST', '/submissions/live/answer', {
assignment_id: assignmentId,
student_id: studentId,
question_id: questionId,
selected_answer: answer,
time_spent: timeSpent,
is_timeout: isTimeout
});
}
async completeSession(assignmentId, studentId) {
return this.request('POST', '/submissions/live/complete', {
assignment_id: assignmentId,
student_id: studentId
});
}
async getLeaderboard(assignmentId, studentId) {
return this.request('GET', `/submissions/leaderboard/${assignmentId}?student_id=${studentId}`);
}
}
//# sourceMappingURL=api.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"api.js","sourceRoot":"","sources":["../../../src/kit/api.ts"],"names":[],"mappings":"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"}

View File

@@ -0,0 +1,6 @@
export * from './mappers';
export * from './api';
export * from './GameDataHandler';
export * from './react/GamePlayer';
export * from './react/GameTestPlayer';
//# sourceMappingURL=index.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/kit/index.ts"],"names":[],"mappings":"AAAA,cAAc,WAAW,CAAC;AAC1B,cAAc,OAAO,CAAC;AACtB,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,wBAAwB,CAAC"}

View File

@@ -0,0 +1,75 @@
/**
* Mappers Kit
* Helper functions to transform data between Client App and Game Iframe
*/
export function prepareCompletedQuestions(answeredQuestions) {
return (answeredQuestions || []).map(a => ({
id: a.id || a.questionId,
result: (a.isCorrect || a.result === 1) ? 1 : 0,
}));
}
export function createGamePayload(options) {
const { gameId, userId, gameData, answeredQuestions = [], endTimeIso } = options;
const completed_question_ids = prepareCompletedQuestions(answeredQuestions);
// Ưu tiên lấy field .questions hoặc .data, hoặc dùng chính gameData nếu nó là mảng
let data = [];
if (Array.isArray(gameData)) {
data = gameData;
}
else if (gameData && Array.isArray(gameData.questions)) {
data = gameData.questions;
}
else if (gameData && Array.isArray(gameData.data)) {
data = gameData.data;
}
const payload = {
game_id: gameId,
user_id: userId,
data: data,
completed_question_ids: completed_question_ids,
// Merge các field metadata khác
...(typeof gameData === 'object' && !Array.isArray(gameData) ? gameData : {}),
// Merge extraData
...(options.extraData || {})
};
// Inject end_time_iso (absolute timestamp for accurate sync)
if (endTimeIso) {
payload.end_time_iso = endTimeIso;
}
return payload;
}
export function createLeaderboardPayload(apiData) {
const topPlayers = apiData.topPlayers || [];
const userRank = apiData.userRank || null;
return {
top_players: topPlayers.map((p) => ({
rank: p.rank,
name: p.name || p.studentName || p.user_id,
score: p.score ?? p.finalScore ?? 0,
student_id: p.studentId || p.userId,
time_spent: p.timeSpent ?? p.time_spent ?? 0,
completed_at: p.completedAt
})),
user_rank: userRank ? {
rank: userRank.rank,
name: userRank.name || userRank.studentName,
score: userRank.score ?? userRank.finalScore ?? 0,
student_id: userRank.studentId || userRank.userId,
time_spent: userRank.timeSpent ?? userRank.time_spent ?? 0,
completed_at: userRank.completedAt
} : null,
};
}
export function normalizeAnswerReport(data) {
// Simplified per user request
// Input: { question_id: "Q1", result: 1, choice: "2" }
return {
question_id: data.question_id || data.questionId || data.id,
choice: data.choice ?? data.selected_answer ?? data.selectedAnswer,
result: data.result ?? (data.is_correct ? 1 : 0),
is_correct: !!(data.result === 1 || data.is_correct === true),
time_spent: data.time_spent ?? 5,
is_timeout: !!data.is_timeout
};
}
//# sourceMappingURL=mappers.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"mappers.js","sourceRoot":"","sources":["../../../src/kit/mappers.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAoBH,MAAM,UAAU,yBAAyB,CAAC,iBAAwB;IAC9D,OAAO,CAAC,iBAAiB,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;QACvC,EAAE,EAAE,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,UAAU;QACxB,MAAM,EAAE,CAAC,CAAC,CAAC,SAAS,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;KAClD,CAAC,CAAC,CAAC;AACR,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,OAA2B;IACzD,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,iBAAiB,GAAG,EAAE,EAAE,UAAU,EAAE,GAAG,OAAO,CAAC;IACjF,MAAM,sBAAsB,GAAG,yBAAyB,CAAC,iBAAiB,CAAC,CAAC;IAE5E,mFAAmF;IACnF,IAAI,IAAI,GAAU,EAAE,CAAC;IACrB,IAAI,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC1B,IAAI,GAAG,QAAQ,CAAC;IACpB,CAAC;SAAM,IAAI,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC;QACvD,IAAI,GAAG,QAAQ,CAAC,SAAS,CAAC;IAC9B,CAAC;SAAM,IAAI,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;QAClD,IAAI,GAAG,QAAQ,CAAC,IAAI,CAAC;IACzB,CAAC;IAED,MAAM,OAAO,GAAsB;QAC/B,OAAO,EAAE,MAAM;QACf,OAAO,EAAE,MAAM;QACf,IAAI,EAAE,IAAI;QACV,sBAAsB,EAAE,sBAAsB;QAC9C,gCAAgC;QAChC,GAAG,CAAC,OAAO,QAAQ,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC;QAC7E,kBAAkB;QAClB,GAAG,CAAC,OAAO,CAAC,SAAS,IAAI,EAAE,CAAC;KAC/B,CAAC;IAEF,6DAA6D;IAC7D,IAAI,UAAU,EAAE,CAAC;QACb,OAAO,CAAC,YAAY,GAAG,UAAU,CAAC;IACtC,CAAC;IAED,OAAO,OAAO,CAAC;AACnB,CAAC;AAED,MAAM,UAAU,wBAAwB,CAAC,OAAY;IACjD,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,IAAI,EAAE,CAAC;IAC5C,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,IAAI,IAAI,CAAC;IAE1C,OAAO;QACH,WAAW,EAAE,UAAU,CAAC,GAAG,CAAC,CAAC,CAAM,EAAE,EAAE,CAAC,CAAC;YACrC,IAAI,EAAE,CAAC,CAAC,IAAI;YACZ,IAAI,EAAE,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,WAAW,IAAI,CAAC,CAAC,OAAO;YAC1C,KAAK,EAAE,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,UAAU,IAAI,CAAC;YACnC,UAAU,EAAE,CAAC,CAAC,SAAS,IAAI,CAAC,CAAC,MAAM;YACnC,UAAU,EAAE,CAAC,CAAC,SAAS,IAAI,CAAC,CAAC,UAAU,IAAI,CAAC;YAC5C,YAAY,EAAE,CAAC,CAAC,WAAW;SAC9B,CAAC,CAAC;QACH,SAAS,EAAE,QAAQ,CAAC,CAAC,CAAC;YAClB,IAAI,EAAE,QAAQ,CAAC,IAAI;YACnB,IAAI,EAAE,QAAQ,CAAC,IAAI,IAAI,QAAQ,CAAC,WAAW;YAC3C,KAAK,EAAE,QAAQ,CAAC,KAAK,IAAI,QAAQ,CAAC,UAAU,IAAI,CAAC;YACjD,UAAU,EAAE,QAAQ,CAAC,SAAS,IAAI,QAAQ,CAAC,MAAM;YACjD,UAAU,EAAE,QAAQ,CAAC,SAAS,IAAI,QAAQ,CAAC,UAAU,IAAI,CAAC;YAC1D,YAAY,EAAE,QAAQ,CAAC,WAAW;SACrC,CAAC,CAAC,CAAC,IAAI;KACX,CAAC;AACN,CAAC;AAED,MAAM,UAAU,qBAAqB,CAAC,IAAS;IAC3C,8BAA8B;IAC9B,uDAAuD;IACvD,OAAO;QACH,WAAW,EAAE,IAAI,CAAC,WAAW,IAAI,IAAI,CAAC,UAAU,IAAI,IAAI,CAAC,EAAE;QAC3D,MAAM,EAAE,IAAI,CAAC,MAAM,IAAI,IAAI,CAAC,eAAe,IAAI,IAAI,CAAC,cAAc;QAClE,MAAM,EAAE,IAAI,CAAC,MAAM,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QAChD,UAAU,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,IAAI,IAAI,CAAC,UAAU,KAAK,IAAI,CAAC;QAC7D,UAAU,EAAE,IAAI,CAAC,UAAU,IAAI,CAAC;QAChC,UAAU,EAAE,CAAC,CAAC,IAAI,CAAC,UAAU;KAChC,CAAC;AACN,CAAC"}

View File

@@ -0,0 +1,579 @@
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect, useState, useRef, useCallback } from 'react';
import { useGameIframeSDK } from '../../useGameIframeSDK';
import { GameApiClient } from '../api';
import { createGamePayload, normalizeAnswerReport } from '../mappers';
// Session Error Codes (synced with backend)
export const SessionErrorCodes = {
SESSION_NOT_STARTED: -60,
SESSION_ENDED: -61,
SESSION_COMPLETED: -62,
SESSION_TIMEOUT: -63,
SESSION_NOT_FOUND: -64,
SESSION_ALREADY_ANSWERED: -65,
};
export const GamePlayer = ({ apiConfig, assignmentId, studentId, className, style, debug = false, onComplete, onError, onGameLoaded, extraGameData, onLog, onSessionStatus, renderLoading, renderError, loadingTimeout = 30000 }) => {
const iframeRef = useRef(null);
const [gameUrl, setGameUrl] = useState('');
const [gameState, setGameState] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [hasLoadedGame, setHasLoadedGame] = useState(false);
const [apiClient] = useState(() => new GameApiClient(apiConfig));
// Refs to prevent infinite loops
const sendLeaderboardRef = useRef(null);
const hasInitRef = useRef(false); // Track if init has been called
const callbacksRef = useRef({ onGameLoaded, onLog, onSessionStatus, onError }); // Stable callback refs
// Update callback refs on each render
callbacksRef.current = { onGameLoaded, onLog, onSessionStatus, onError };
// Helper: Create structured error
const createError = useCallback((err) => {
const errorMsg = err?.message || err?.error || String(err);
const errorCode = err?.code; // Error code from API response
// Check error code first (more reliable than message parsing)
if (errorCode !== undefined) {
// Session not started
if (errorCode === SessionErrorCodes.SESSION_NOT_STARTED) {
return {
type: 'not_started',
code: errorCode,
message: errorMsg || 'Bài tập chưa bắt đầu',
details: err,
canRetry: false
};
}
// Session ended
if (errorCode === SessionErrorCodes.SESSION_ENDED) {
return {
type: 'session',
code: errorCode,
message: errorMsg || 'Bài tập đã kết thúc',
details: err,
canRetry: false
};
}
// Session completed
if (errorCode === SessionErrorCodes.SESSION_COMPLETED) {
return {
type: 'session',
code: errorCode,
message: errorMsg || 'Bạn đã hoàn thành bài tập này rồi',
details: err,
canRetry: false
};
}
// Session timeout
if (errorCode === SessionErrorCodes.SESSION_TIMEOUT) {
return {
type: 'timeout',
code: errorCode,
message: errorMsg || 'Đã hết thời gian làm bài',
details: err,
canRetry: false
};
}
}
// Fallback: Parse error message
// API errors
if (errorMsg.includes('API Error') || errorMsg.includes('Failed to fetch')) {
// Check if it contains session-related message
if (errorMsg.includes('chưa bắt đầu')) {
return {
type: 'not_started',
message: 'Bài tập chưa bắt đầu',
details: err,
canRetry: false
};
}
return {
type: 'api',
message: errorMsg,
details: err,
canRetry: true
};
}
// Network errors
if (errorMsg.includes('network') || errorMsg.includes('ECONNREFUSED')) {
return {
type: 'network',
message: 'Lỗi kết nối mạng. Vui lòng kiểm tra internet.',
details: err,
canRetry: true
};
}
// Session errors (message-based fallback)
if (errorMsg.includes('chưa bắt đầu')) {
return {
type: 'not_started',
message: errorMsg || 'Bài tập chưa bắt đầu',
details: err,
canRetry: false
};
}
if (errorMsg.includes('hết thời gian') || errorMsg.includes('đã kết thúc') ||
errorMsg.includes('đã hoàn thành') || errorMsg.includes('session')) {
return {
type: 'session',
message: errorMsg,
details: err,
canRetry: false
};
}
// Timeout errors
if (errorMsg.includes('timeout') || errorMsg.includes('timed out')) {
return {
type: 'timeout',
message: 'Kết nối quá lâu. Vui lòng thử lại.',
details: err,
canRetry: true
};
}
return {
type: 'unknown',
message: errorMsg || 'Đã xảy ra lỗi không xác định',
details: err,
canRetry: true
};
}, []);
// Helper: Handle error with callback
const handleError = useCallback((err) => {
const gameError = createError(err);
setError(gameError);
setLoading(false);
if (onError) {
onError(gameError);
}
if (onLog) {
onLog(`[SDK ERR] ${gameError.type}: ${gameError.message}`, 'error');
}
}, [createError, onError, onLog]);
// Retry function
const retryInit = useCallback(() => {
if (callbacksRef.current.onLog)
callbacksRef.current.onLog('[SDK] Retrying initialization...', 'info');
hasInitRef.current = false; // Allow re-init
setError(null);
setLoading(true);
setGameUrl('');
setGameState(null);
}, []);
// Define Handlers
const handleAnswer = useCallback(async (data) => {
try {
if (onLog)
onLog(`[SDK RECV] Answer Raw: ${JSON.stringify(data)}`, 'info');
const report = normalizeAnswerReport(data);
if (onLog)
onLog(`[SDK] Processed Answer: ID ${report.question_id} | Choice: ${report.choice} | ${report.is_correct ? 'Correct' : 'Wrong'}`, 'info');
if (onLog)
onLog(`[API REQ] Submitting Answer ID ${report.question_id}...`, 'info');
const res = await apiClient.submitAnswer(assignmentId, studentId, report.question_id, report.choice, report.time_spent, report.is_timeout);
if (onLog)
onLog(`[API RES] Submit Result: ${JSON.stringify(res)}`, 'success');
}
catch (err) {
console.error('[GamePlayer] Submit error:', err);
if (onLog)
onLog(`[API ERR] Submit Failed: ${err.message}`, 'error');
}
}, [apiClient, assignmentId, studentId, onLog]);
const handleFinalResult = useCallback(async (data) => {
try {
if (onLog)
onLog(`[SDK RECV] Final Result Raw: ${JSON.stringify(data)}`, 'info');
if (onLog)
onLog('[SDK] Game Complete. Calling API...', 'info');
const res = await apiClient.completeSession(assignmentId, studentId);
if (onLog)
onLog(`[API RES] Session Completed. Score: ${res.data?.finalScore}`, 'success');
// Auto-refresh leaderboard after completion to ensure user rank is included
if (onLog)
onLog('[SDK] Auto-refreshing leaderboard after completion...', 'info');
await new Promise(resolve => setTimeout(resolve, 500)); // Small delay for backend to process
try {
const lbRes = await apiClient.getLeaderboard(assignmentId, studentId);
if (onLog)
onLog(`[API RES] Post-completion Leaderboard: ${JSON.stringify(lbRes)}`, 'success');
if (lbRes.success && lbRes.data && sendLeaderboardRef.current) {
const leaderboardData = {
top_players: (lbRes.data.topPlayers || []).map((p) => ({
rank: p.rank,
name: p.name || p.studentName || p.userId,
score: p.score ?? p.finalScore ?? 0,
student_id: p.studentId || p.userId,
time_spent: p.timeSpent ?? p.time_spent ?? 0,
completed_at: p.completedAt
})),
user_rank: lbRes.data.userRank ? {
rank: lbRes.data.userRank.rank,
name: lbRes.data.userRank.name || lbRes.data.userRank.studentName,
score: lbRes.data.userRank.score ?? lbRes.data.userRank.finalScore ?? 0,
student_id: lbRes.data.userRank.studentId || lbRes.data.userRank.userId,
time_spent: lbRes.data.userRank.timeSpent ?? lbRes.data.userRank.time_spent ?? 0,
completed_at: lbRes.data.userRank.completedAt
} : null
};
if (onLog)
onLog(`[SDK SEND] Updated Leaderboard: ${JSON.stringify(leaderboardData)}`, 'info');
sendLeaderboardRef.current(leaderboardData);
}
}
catch (lbErr) {
if (onLog)
onLog(`[API ERR] Leaderboard refresh failed: ${lbErr.message}`, 'warning');
}
if (onComplete)
onComplete(res);
}
catch (err) {
console.error('[GamePlayer] Complete error:', err);
if (onLog)
onLog(`[API ERR] Complete Failed: ${err.message}`, 'error');
if (onError)
onError(err);
}
}, [apiClient, assignmentId, studentId, onComplete, onError, onLog]);
// SDK Hook
const { isReady, sendGameData, sendLeaderboard } = useGameIframeSDK({
iframeRef,
iframeOrigin: '*',
debug,
onGameReady: () => {
if (onLog)
onLog('[SDK RECV] Iframe Ready', 'success');
},
onAnswerReport: handleAnswer,
onFinalResult: handleFinalResult,
onLeaderboardRequest: async (top) => {
try {
if (onLog)
onLog(`[SDK RECV] Leaderboard Request Raw: top=${top}`, 'info');
if (onLog)
onLog(`[API REQ] Fetching Leaderboard (top ${top})...`, 'info');
const res = await apiClient.getLeaderboard(assignmentId, studentId);
if (onLog)
onLog(`[API RES] Leaderboard Raw: ${JSON.stringify(res)}`, 'success');
if (res.success && res.data) {
const leaderboardData = {
top_players: (res.data.topPlayers || []).map((p) => ({
rank: p.rank,
name: p.name || p.studentName || p.userId,
score: p.score ?? p.finalScore ?? 0,
student_id: p.studentId || p.userId,
time_spent: p.timeSpent ?? p.time_spent ?? 0,
completed_at: p.completedAt
})),
user_rank: res.data.userRank ? {
rank: res.data.userRank.rank,
name: res.data.userRank.name || res.data.userRank.studentName,
score: res.data.userRank.score ?? res.data.userRank.finalScore ?? 0,
student_id: res.data.userRank.studentId || res.data.userRank.userId,
time_spent: res.data.userRank.timeSpent ?? res.data.userRank.time_spent ?? 0,
completed_at: res.data.userRank.completedAt
} : null
};
if (onLog)
onLog(`[SDK SEND] Leaderboard Payload: ${JSON.stringify(leaderboardData)}`, 'info');
const sent = sendLeaderboard(leaderboardData);
if (onLog)
onLog(sent ? '[SDK] Leaderboard sent successfully' : '[SDK ERR] Failed to send leaderboard', sent ? 'success' : 'error');
}
}
catch (err) {
console.error('[GamePlayer] Leaderboard error:', err);
if (onLog)
onLog(`[API ERR] Leaderboard Failed: ${err.message}`, 'error');
}
}
});
// Update ref after hook
sendLeaderboardRef.current = sendLeaderboard;
// 1. Fetch Game Data & Start Session
useEffect(() => {
let mounted = true;
let loadingTimeoutId;
const initGame = async () => {
try {
setLoading(true);
setError(null);
if (onLog)
onLog('[SDK] Initializing Game...', 'info');
// Set loading timeout
loadingTimeoutId = setTimeout(() => {
if (mounted && loading) {
if (onLog)
onLog('[SDK WARN] Loading taking longer than expected...', 'warning');
}
}, loadingTimeout);
// getGameWithProgress đã tự động tạo/restore session trong backend
// thông qua getOrCreateSubmissionCache - không cần gọi startLiveSession riêng
const res = await apiClient.getGameWithProgress(assignmentId, studentId);
if (!res.success || !res.data) {
throw new Error(res.message || 'Failed to load game');
}
if (mounted) {
// Check session status TRƯỚC để quyết định có load game hay không
const session = res.data.session;
const progress = res.data.progress;
// Case 1: Already completed
if (res.data.isFinished || session?.completed) {
// CHỈ set error nếu CHƯA load game (lần đầu vào)
if (!hasLoadedGame) {
const errorObj = {
type: 'session',
message: 'Bạn đã hoàn thành bài tập này rồi',
details: { score: progress?.score, answered: progress?.answered, total: progress?.total },
canRetry: false
};
setError(errorObj);
setLoading(false);
clearTimeout(loadingTimeoutId);
if (onError)
onError(errorObj);
if (onLog)
onLog(`[SDK] ${errorObj.message}`, 'warning');
}
// Luôn gọi callback (cho cả 2 trường hợp)
if (onSessionStatus) {
onSessionStatus({
type: 'completed',
message: 'Bạn đã hoàn thành bài tập này',
data: {
answered: progress?.answered || 0,
total: progress?.total || 0,
score: progress?.score || 0
}
});
}
// Nếu CHƯA load game → STOP
// Nếu ĐÃ load game → tiếp tục (cho game hiển thị leaderboard)
if (!hasLoadedGame) {
return; // ❌ STOP - không load game
}
}
// Case 2: Session timeout
if (session && session.remainingTime !== null && session.remainingTime <= 0) {
// CHỈ set error nếu CHƯA load game
if (!hasLoadedGame) {
const errorObj = {
type: 'timeout',
message: 'Thời gian làm bài đã hết',
details: { answered: progress?.answered, total: progress?.total },
canRetry: false
};
setError(errorObj);
setLoading(false);
clearTimeout(loadingTimeoutId);
if (onError)
onError(errorObj);
if (onLog)
onLog(`[SDK] ${errorObj.message}`, 'warning');
}
// Luôn gọi callback
if (onSessionStatus) {
onSessionStatus({
type: 'timeout',
message: 'Thời gian làm bài đã hết',
data: {
answered: progress?.answered || 0,
total: progress?.total || 0,
score: progress?.score || 0,
remainingTime: 0
}
});
}
// Nếu CHƯA load game → STOP
if (!hasLoadedGame) {
return; // ❌ STOP - không load game
}
}
// Case 3: Valid session - LOAD game
setGameState(res.data);
setGameUrl(res.data.gameUrl);
setLoading(false);
setHasLoadedGame(true); // ✅ Đánh dấu đã load game
clearTimeout(loadingTimeoutId);
if (onGameLoaded)
onGameLoaded(res.data);
if (onLog)
onLog(`[API RES] Game Loaded: ${res.data.title || res.data.gameType || assignmentId}`, 'success');
// Notify session status for valid sessions
if (session && onSessionStatus) {
// Resumed session (có câu đã trả lời)
if (progress && progress.answered > 0) {
onSessionStatus({
type: 'resumed',
message: `Tiếp tục làm bài (Đã làm ${progress.answered}/${progress.total} câu)`,
data: {
answered: progress.answered,
total: progress.total,
score: progress.score,
remainingTime: session.remainingTime,
startedAt: session.startedAt
}
});
}
// New session
else {
onSessionStatus({
type: 'new',
message: 'Bắt đầu làm bài mới',
data: {
answered: 0,
total: progress?.total || 0,
score: 0,
remainingTime: session.remainingTime,
startedAt: session.startedAt
}
});
}
}
// Log session info
if (session && onLog) {
const sessionInfo = `Session started at ${session.startedAt}, remaining: ${session.remainingTime}s`;
onLog(`[SDK] ${sessionInfo}`, 'info');
}
}
}
catch (err) {
console.error('[GamePlayer] Init error:', err);
if (mounted) {
handleError(err);
}
}
};
if (assignmentId && studentId && !hasInitRef.current) {
hasInitRef.current = true; // Prevent re-init
initGame();
}
return () => {
mounted = false;
hasInitRef.current = false; // ✅ Reset for StrictMode remount
if (loadingTimeoutId)
clearTimeout(loadingTimeoutId);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignmentId, studentId]); // ✅ Chỉ re-init khi assignmentId hoặc studentId thay đổi
// 2. Auto Send Data when Ready
useEffect(() => {
if (isReady && gameState) {
// Get expiresAt from session (absolute timestamp for accurate sync)
const getEndTimeIso = () => {
const session = gameState.session;
if (!session)
return undefined;
// Ưu tiên dùng expiresAt
if (session.expiresAt) {
return session.expiresAt;
}
// Fallback: tính từ startedAt + duration
if (session.startedAt && session.duration) {
const startTimeMs = new Date(session.startedAt).getTime();
const expiresAtMs = startTimeMs + (session.duration * 1000);
return new Date(expiresAtMs).toISOString();
}
return undefined;
};
const payload = createGamePayload({
gameId: gameState.id,
userId: studentId,
gameData: gameState.jsonData,
answeredQuestions: gameState.completed_question_ids,
extraData: extraGameData,
endTimeIso: getEndTimeIso() // ✅ Absolute timestamp
});
if (onLog)
onLog(`[SDK SEND] Game Payload: ${JSON.stringify(payload)}`, 'info');
sendGameData(payload);
}
}, [isReady, gameState, studentId, sendGameData, extraGameData, onLog]);
// ===== RENDER =====
// 1. Error State
if (error) {
if (renderError) {
return _jsx(_Fragment, { children: renderError(error, retryInit) });
}
// Default error UI
return (_jsxs("div", { className: "game-player-error", style: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
padding: '2rem',
textAlign: 'center',
backgroundColor: '#f8f9fa',
...style
}, children: [_jsx("div", { style: {
fontSize: '3rem',
marginBottom: '1rem'
}, children: error.type === 'network' ? '🌐' :
error.type === 'timeout' ? '⏱️' :
error.type === 'session' ? '🔒' :
error.type === 'not_started' ? '📅' : '⚠️' }), _jsx("h2", { style: {
fontSize: '1.5rem',
fontWeight: 'bold',
marginBottom: '0.5rem',
color: error.type === 'not_started' ? '#fd7e14' : '#dc3545'
}, children: error.type === 'network' ? 'Lỗi Kết Nối' :
error.type === 'timeout' ? 'Hết Giờ Làm Bài' :
error.type === 'session' ? 'Phiên Làm Bài Đã Kết Thúc' :
error.type === 'not_started' ? 'Bài Tập Chưa Bắt Đầu' :
'Đã Xảy Ra Lỗi' }), _jsx("p", { style: {
fontSize: '1rem',
color: '#6c757d',
marginBottom: '1.5rem',
maxWidth: '500px'
}, children: error.message }), error.canRetry && (_jsx("button", { onClick: retryInit, style: {
padding: '0.75rem 2rem',
fontSize: '1rem',
fontWeight: 'bold',
color: '#fff',
backgroundColor: '#007bff',
border: 'none',
borderRadius: '0.5rem',
cursor: 'pointer',
transition: 'background-color 0.2s'
}, onMouseEnter: (e) => e.currentTarget.style.backgroundColor = '#0056b3', onMouseLeave: (e) => e.currentTarget.style.backgroundColor = '#007bff', children: "\uD83D\uDD04 Th\u1EED L\u1EA1i" })), !error.canRetry && (_jsx("p", { style: {
fontSize: '0.875rem',
color: '#6c757d',
marginTop: '1rem'
}, children: "Vui l\u00F2ng li\u00EAn h\u1EC7 gi\u00E1o vi\u00EAn ho\u1EB7c quay l\u1EA1i trang ch\u1EE7" }))] }));
}
// 2. Loading State
if (loading || !gameUrl) {
if (renderLoading) {
return _jsx(_Fragment, { children: renderLoading() });
}
// Default loading UI
return (_jsxs("div", { className: "game-player-loading", style: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
backgroundColor: '#f8f9fa',
...style
}, children: [_jsx("div", { style: {
width: '50px',
height: '50px',
border: '5px solid #e9ecef',
borderTop: '5px solid #007bff',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
marginBottom: '1rem'
} }), _jsx("p", { style: {
fontSize: '1.125rem',
color: '#6c757d',
fontWeight: '500'
}, children: "\u0110ang t\u1EA3i tr\u00F2 ch\u01A1i..." }), _jsx("style", { children: `
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
` })] }));
}
// 3. Game Iframe
return (_jsx("iframe", { ref: iframeRef, src: gameUrl, className: className, style: { width: '100%', height: '100%', border: 'none', ...style }, allowFullScreen: true }));
};
//# sourceMappingURL=GamePlayer.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,112 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useRef, useEffect, useState } from 'react';
import { useGameIframeSDK } from '../../useGameIframeSDK';
/**
* GameTestPlayer - Component test đơn giản
*
* Chỉ load game data vào iframe, KHÔNG gọi API
* Dùng để test game iframe locally
*
* @example
* ```tsx
* <GameTestPlayer
* gameUrl="http://localhost:3000/game"
* gameData={[
* { id: 1, question: "What is 2+2?", options: ["3","4","5"], answer: "4" },
* { id: 2, question: "Capital of France?", options: ["London","Paris","Berlin"], answer: "Paris" }
* ]}
* debug={true}
* onLog={(msg, type) => console.log(`[${type}] ${msg}`)}
* onAnswer={(data) => console.log('Answer:', data)}
* onComplete={(result) => console.log('Complete:', result)}
* />
* ```
*/
export const GameTestPlayer = ({ gameUrl, gameData, userId = 'test_user', gameId = 'test_game', extraData, endTimeIso, className, style, debug = false, onAnswer, onComplete, onLog, onLeaderboardRequest, mockLeaderboard }) => {
const iframeRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
// SDK Hook
const { isReady, sendGameData, sendLeaderboard } = useGameIframeSDK({
iframeRef,
iframeOrigin: '*',
debug,
onGameReady: () => {
if (onLog)
onLog('[TEST] Iframe Ready', 'success');
setIsLoading(false);
},
onAnswerReport: (data) => {
if (onLog)
onLog(`[TEST] Answer: ${JSON.stringify(data)}`, 'info');
if (onAnswer)
onAnswer(data);
},
onFinalResult: (result) => {
if (onLog)
onLog(`[TEST] Complete: ${JSON.stringify(result)}`, 'success');
if (onComplete)
onComplete(result);
},
onLeaderboardRequest: (top) => {
if (onLog)
onLog(`[TEST] Leaderboard Request: top=${top}`, 'info');
if (onLeaderboardRequest)
onLeaderboardRequest(top);
// Auto send mock leaderboard if provided
if (mockLeaderboard) {
if (onLog)
onLog(`[TEST] Sending mock leaderboard`, 'info');
sendLeaderboard(mockLeaderboard);
}
}
});
// Auto send game data when ready
useEffect(() => {
if (isReady && gameData) {
const payload = {
game_id: String(gameId),
user_id: userId,
data: gameData,
completed_question_ids: [],
...(extraData || {})
};
if (endTimeIso) {
payload.end_time_iso = endTimeIso;
}
if (onLog)
onLog(`[TEST] Sending Game Data: ${gameData.length} items`, 'info');
sendGameData(payload);
}
}, [isReady, gameData, gameId, userId, extraData, endTimeIso, sendGameData, onLog]);
return (_jsxs("div", { style: { position: 'relative', width: '100%', height: '100%', ...style }, children: [isLoading && (_jsx("div", { style: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255,255,255,0.9)',
zIndex: 10
}, children: _jsxs("div", { style: { textAlign: 'center' }, children: [_jsx("div", { style: {
width: '40px',
height: '40px',
border: '4px solid #e9ecef',
borderTop: '4px solid #007bff',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
margin: '0 auto 1rem'
} }), _jsx("p", { style: { color: '#6c757d' }, children: "Loading game..." }), _jsx("style", { children: `
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
` })] }) })), _jsx("iframe", { ref: iframeRef, src: gameUrl, className: className, style: {
width: '100%',
height: '100%',
border: 'none'
}, allowFullScreen: true })] }));
};
export default GameTestPlayer;
//# sourceMappingURL=GameTestPlayer.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"GameTestPlayer.js","sourceRoot":"","sources":["../../../../src/kit/react/GameTestPlayer.tsx"],"names":[],"mappings":"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"}