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,365 @@
"use strict";
/**
* 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
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.GameClientSDK = void 0;
exports.getGameClientSDK = getGameClientSDK;
exports.destroyGameClientSDK = destroyGameClientSDK;
const GameDataHandler_1 = require("../kit/GameDataHandler");
const MockData_1 = require("./MockData");
const DataValidator_1 = require("./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
// =============================================================================
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 = (0, GameDataHandler_1.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 (!GameDataHandler_1.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 = (0, MockData_1.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 && GameDataHandler_1.GAME_CODES[payload.game_code]) {
this.params.gameCode = payload.game_code;
}
// Validate data structure
const validation = (0, DataValidator_1.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 = (0, GameDataHandler_1.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;
}
}
}
exports.GameClientSDK = GameClientSDK;
// =============================================================================
// FACTORY
// =============================================================================
let clientInstance = null;
/**
* Get or create GameClientSDK instance
*/
function getGameClientSDK(config) {
if (!clientInstance) {
clientInstance = new GameClientSDK(config);
}
return clientInstance;
}
/**
* Destroy client instance
*/
function destroyGameClientSDK() {
clientInstance?.destroy();
clientInstance = null;
}
//# sourceMappingURL=GameClientSDK.js.map