"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.GamePlayer = exports.SessionErrorCodes = void 0; const jsx_runtime_1 = require("react/jsx-runtime"); const react_1 = require("react"); const useGameIframeSDK_1 = require("../../useGameIframeSDK"); const api_1 = require("../api"); const mappers_1 = require("../mappers"); // Session Error Codes (synced with backend) exports.SessionErrorCodes = { SESSION_NOT_STARTED: -60, SESSION_ENDED: -61, SESSION_COMPLETED: -62, SESSION_TIMEOUT: -63, SESSION_NOT_FOUND: -64, SESSION_ALREADY_ANSWERED: -65, }; const GamePlayer = ({ apiConfig, assignmentId, studentId, className, style, debug = false, onComplete, onError, onGameLoaded, extraGameData, onLog, onSessionStatus, renderLoading, renderError, loadingTimeout = 30000 }) => { const iframeRef = (0, react_1.useRef)(null); const [gameUrl, setGameUrl] = (0, react_1.useState)(''); const [gameState, setGameState] = (0, react_1.useState)(null); const [loading, setLoading] = (0, react_1.useState)(true); const [error, setError] = (0, react_1.useState)(null); const [hasLoadedGame, setHasLoadedGame] = (0, react_1.useState)(false); const [apiClient] = (0, react_1.useState)(() => new api_1.GameApiClient(apiConfig)); // Refs to prevent infinite loops const sendLeaderboardRef = (0, react_1.useRef)(null); const hasInitRef = (0, react_1.useRef)(false); // Track if init has been called const callbacksRef = (0, react_1.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 = (0, react_1.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 === exports.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 === exports.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 === exports.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 === exports.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 = (0, react_1.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 = (0, react_1.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 = (0, react_1.useCallback)(async (data) => { try { if (onLog) onLog(`[SDK RECV] Answer Raw: ${JSON.stringify(data)}`, 'info'); const report = (0, mappers_1.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 = (0, react_1.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 } = (0, useGameIframeSDK_1.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 (0, react_1.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 (0, react_1.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 = (0, mappers_1.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 (0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: renderError(error, retryInit) }); } // Default error UI return ((0, jsx_runtime_1.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: [(0, jsx_runtime_1.jsx)("div", { style: { fontSize: '3rem', marginBottom: '1rem' }, children: error.type === 'network' ? '🌐' : error.type === 'timeout' ? '⏱️' : error.type === 'session' ? '🔒' : error.type === 'not_started' ? '📅' : '⚠️' }), (0, jsx_runtime_1.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' }), (0, jsx_runtime_1.jsx)("p", { style: { fontSize: '1rem', color: '#6c757d', marginBottom: '1.5rem', maxWidth: '500px' }, children: error.message }), error.canRetry && ((0, jsx_runtime_1.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 && ((0, jsx_runtime_1.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 (0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: renderLoading() }); } // Default loading UI return ((0, jsx_runtime_1.jsxs)("div", { className: "game-player-loading", style: { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', backgroundColor: '#f8f9fa', ...style }, children: [(0, jsx_runtime_1.jsx)("div", { style: { width: '50px', height: '50px', border: '5px solid #e9ecef', borderTop: '5px solid #007bff', borderRadius: '50%', animation: 'spin 1s linear infinite', marginBottom: '1rem' } }), (0, jsx_runtime_1.jsx)("p", { style: { fontSize: '1.125rem', color: '#6c757d', fontWeight: '500' }, children: "\u0110ang t\u1EA3i tr\u00F2 ch\u01A1i..." }), (0, jsx_runtime_1.jsx)("style", { children: ` @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } ` })] })); } // 3. Game Iframe return ((0, jsx_runtime_1.jsx)("iframe", { ref: iframeRef, src: gameUrl, className: className, style: { width: '100%', height: '100%', border: 'none', ...style }, allowFullScreen: true })); }; exports.GamePlayer = GamePlayer; //# sourceMappingURL=GamePlayer.js.map