Files
sentence1/G102-sequence/sdk/package/dist/kit/react/GamePlayer.js
lubukhu 65fd0158a3
All checks were successful
Deploy to Production / deploy (push) Successful in 8s
up
2026-01-24 13:35:11 +07:00

583 lines
28 KiB
JavaScript

"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