/** * GameClientSDK - SDK dành cho Game Iframe * * Sử dụng trong game để: * - Tự động xác định mode (preview/live) từ URL * - Nhận data từ parent (preview) hoặc fetch API (live) * - Verify answers locally * - Report results về parent */ import { checkAnswer, sanitizeForClient, GAME_CODES } from '../kit/GameDataHandler'; import { getMockData } from './MockData'; import { validateGameData } from './DataValidator'; class SimpleEventEmitter { constructor() { this.handlers = new Map(); } on(event, handler) { if (!this.handlers.has(event)) { this.handlers.set(event, new Set()); } this.handlers.get(event).add(handler); return () => this.off(event, handler); } off(event, handler) { this.handlers.get(event)?.delete(handler); } emit(event, data) { this.handlers.get(event)?.forEach(handler => { try { handler(data); } catch (err) { console.error(`[GameClientSDK] Error in ${String(event)} handler:`, err); } }); } } // ============================================================================= // GAME CLIENT SDK // ============================================================================= export class GameClientSDK extends SimpleEventEmitter { constructor(config = {}) { super(); // Data storage this.originalItems = new Map(); // Có đáp án this.sanitizedItems = []; // Không có đáp án this.userAnswers = new Map(); this.isInitialized = false; this.startTime = 0; this.config = { debug: config.debug ?? false, apiBaseUrl: config.apiBaseUrl ?? '', getAuthHeaders: config.getAuthHeaders ?? (() => ({})), }; // Parse URL params this.params = this.parseURLParams(); this.mode = this.params.mode; this.log('info', 'SDK created', { mode: this.mode, params: this.params }); // Emit mode detected this.emit('modeDetected', { mode: this.mode, params: this.params }); // Setup message listener this.setupMessageListener(); // Auto-initialize based on mode this.initialize(); } // ========================================================================== // PUBLIC API // ========================================================================== /** * Get current mode */ getMode() { return this.mode; } /** * Get URL params */ getParams() { return { ...this.params }; } /** * Get game code */ getGameCode() { return this.params.gameCode; } /** * Get sanitized items (safe for rendering) */ getItems() { return this.sanitizedItems; } /** * Submit an answer and get verification result */ submitAnswer(questionId, choice) { const originalItem = this.originalItems.get(questionId); if (!originalItem) { this.log('warn', `Item not found: ${questionId}`); return { isCorrect: false, score: 0, feedback: 'Question not found' }; } // Verify using GameDataHandler const result = checkAnswer(this.params.gameCode, originalItem, choice); // Store user answer const timeSpent = Date.now() - (this.userAnswers.size === 0 ? this.startTime : Date.now()); this.userAnswers.set(questionId, { choice, result: result.isCorrect ? 1 : 0, time: timeSpent, }); // Report to parent this.sendAnswerReport(questionId, choice, result.isCorrect ? 1 : 0, timeSpent); this.log('info', `Answer submitted: ${questionId}`, { choice, result }); return result; } /** * Get final result */ getFinalResult() { const details = Array.from(this.userAnswers.entries()).map(([id, data]) => ({ question_id: id, choice: data.choice, result: data.result, time_spent: data.time, })); const correct = details.filter(d => d.result === 1).length; const total = this.originalItems.size; return { score: total > 0 ? Math.round((correct / total) * 100) : 0, total, correct, wrong: total - correct, details, }; } /** * Report final result to parent */ reportFinalResult(result) { const finalResult = result ?? this.getFinalResult(); window.parent.postMessage({ type: 'FINAL_RESULT', data: finalResult, }, '*'); this.log('info', 'Final result reported', finalResult); } /** * Request leaderboard from parent */ requestLeaderboard(top = 10) { window.parent.postMessage({ type: 'GET_LEADERBOARD', data: { top }, }, '*'); } /** * Cleanup */ destroy() { window.removeEventListener('message', this.handleMessage); this.originalItems.clear(); this.sanitizedItems = []; this.userAnswers.clear(); this.log('info', 'SDK destroyed'); } // ========================================================================== // PRIVATE METHODS // ========================================================================== parseURLParams() { const searchParams = new URLSearchParams(window.location.search); const mode = (searchParams.get('mode') || 'preview'); const gameCode = (searchParams.get('game_code') || 'G001'); const gameId = searchParams.get('game_id') || undefined; const lid = searchParams.get('lid') || undefined; const studentId = searchParams.get('student_id') || undefined; // Validate mode if (mode !== 'preview' && mode !== 'live' && mode !== 'dev') { this.log('warn', `Invalid mode: ${mode}, defaulting to preview`); } // Validate game code if (!GAME_CODES[gameCode]) { this.log('warn', `Unknown game code: ${gameCode}`); } return { mode, gameCode, gameId, lid, studentId }; } setupMessageListener() { this.handleMessage = this.handleMessage.bind(this); window.addEventListener('message', this.handleMessage); } handleMessage(event) { const { type, jsonData, leaderboardData } = event.data || {}; this.log('debug', 'Message received', { type, hasData: !!jsonData }); switch (type) { case 'SERVER_PUSH_DATA': if (jsonData) { this.handleDataReceived(jsonData); } break; case 'SERVER_PUSH_LEADERBOARD': if (leaderboardData) { this.log('info', 'Leaderboard received', leaderboardData); // Could emit event here } break; } } async initialize() { // Send GAME_READY immediately this.sendGameReady(); if (this.mode === 'dev') { // Dev mode: load mock data immediately this.log('info', 'DEV MODE: Loading mock data...'); this.loadMockData(); } else if (this.mode === 'live') { // Live mode: fetch data from API await this.fetchLiveData(); } else { // Preview mode: wait for postMessage this.log('info', 'Preview mode: waiting for SERVER_PUSH_DATA...'); } } /** * Load mock data for dev mode */ loadMockData() { const mockData = getMockData(this.params.gameCode); if (!mockData) { this.emit('error', { message: `No mock data available for game code: ${this.params.gameCode}` }); return; } this.log('info', `Loaded mock data for ${this.params.gameCode}`); this.handleDataReceived(mockData); } sendGameReady() { window.parent.postMessage({ type: 'GAME_READY' }, '*'); this.emit('ready', undefined); this.log('info', 'GAME_READY sent'); } async fetchLiveData() { const { gameId, lid } = this.params; if (!gameId || !lid) { this.emit('error', { message: 'Live mode requires game_id and lid' }); return; } if (!this.config.apiBaseUrl) { this.emit('error', { message: 'Live mode requires apiBaseUrl' }); return; } try { const url = `${this.config.apiBaseUrl}/games/${gameId}?lid=${lid}`; const headers = { 'Content-Type': 'application/json', ...this.config.getAuthHeaders(), }; this.log('info', `Fetching live data: ${url}`); const response = await fetch(url, { headers }); if (!response.ok) { throw new Error(`API Error: ${response.status}`); } const data = await response.json(); this.handleDataReceived(data); } catch (error) { this.log('error', 'Failed to fetch live data', error); this.emit('error', { message: 'Failed to fetch game data', error }); } } handleDataReceived(payload) { this.startTime = Date.now(); // Update game code if provided if (payload.game_code && GAME_CODES[payload.game_code]) { this.params.gameCode = payload.game_code; } // Validate data structure const validation = validateGameData(this.params.gameCode, payload); if (!validation.valid) { this.log('error', 'Data validation failed', validation.errors); this.emit('validationError', { validation }); // Continue anyway to allow partial rendering } if (validation.warnings.length > 0) { this.log('warn', 'Data validation warnings', validation.warnings); } // Extract items from various payload formats const items = payload.data || payload.items || payload.questions || []; const resumeData = payload.completed_question_ids || []; // Store original items (with answers) this.originalItems.clear(); items.forEach((item) => { if (item.id) { this.originalItems.set(item.id, item); } }); // Sanitize for client (remove answers) this.sanitizedItems = sanitizeForClient(this.params.gameCode, items); this.isInitialized = true; this.log('info', `Data received: ${items.length} items, ${resumeData.length} completed`); // Emit event with validation result this.emit('dataReceived', { items: this.sanitizedItems, resumeData, validation, }); } sendAnswerReport(questionId, choice, result, timeSpent) { window.parent.postMessage({ type: 'ANSWER_REPORT', data: { question_id: questionId, question_index: Array.from(this.originalItems.keys()).indexOf(questionId), choice, result, time_spent: timeSpent, }, }, '*'); } log(level, message, data) { if (!this.config.debug && level === 'debug') return; const prefix = `[GameClientSDK:${this.mode}]`; switch (level) { case 'debug': case 'info': console.log(prefix, message, data ?? ''); break; case 'warn': console.warn(prefix, message, data ?? ''); break; case 'error': console.error(prefix, message, data ?? ''); break; } } } // ============================================================================= // FACTORY // ============================================================================= let clientInstance = null; /** * Get or create GameClientSDK instance */ export function getGameClientSDK(config) { if (!clientInstance) { clientInstance = new GameClientSDK(config); } return clientInstance; } /** * Destroy client instance */ export function destroyGameClientSDK() { clientInstance?.destroy(); clientInstance = null; } //# sourceMappingURL=GameClientSDK.js.map