359 lines
12 KiB
JavaScript
359 lines
12 KiB
JavaScript
/**
|
|
* 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
|