Commit 5f4e8e15 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Update : Question Analytics is Live

parent aff49eaa
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="RiderAndroidProjectSystem" />
</component>
</project>
\ No newline at end of file
fileFormatVersion: 2
guid: 745e9c17df962b24a80a69d5da8e5d38
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
fileFormatVersion: 2
guid: d308d9efe86ef6242a75802e1f37de49
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
fileFormatVersion: 2
guid: e5036f96e3c15ea49b96f7ee989dd3c1
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
fileFormatVersion: 2
guid: 9e9f7f46a1ba34c338eb95b193ae1327
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
fileFormatVersion: 2
guid: b18b93d4b5d00384ba417df18aeac5a3
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
fileFormatVersion: 2
guid: 92a80e6f6cd90464b8f87b98fc72999a
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
......@@ -72,6 +72,7 @@ namespace com.al_arcade.cs
// ─── BaseGameManager implementation ──────────────────────────────────
protected override string GameTypeKey => "cs";
protected override int TotalQuestionsCount => _questions?.Length ?? 0;
protected override IEnumerator FetchQuestions(Action<string> onError)
{
......@@ -136,6 +137,15 @@ namespace com.al_arcade.cs
protected override IEnumerator OnTimeUp()
{
if (_currentIndex < _questions.Length)
{
var q = _questions[_currentIndex];
int elapsed = GetQuestionElapsedMs();
var attempt = BuildAttempt(q.id, false, elapsed);
attempt.isTimeout = 1;
ReportAttempt(attempt);
}
_state = CsGameState.Complete;
yield return LoseSequence();
}
......@@ -268,6 +278,7 @@ namespace com.al_arcade.cs
yield return new WaitForSeconds(sentenceShowDelay);
SpawnWords(question);
StartQuestionTimer();
yield return new WaitForSeconds(1.3f);
_state = CsGameState.WaitingForWordClick;
......@@ -313,6 +324,13 @@ namespace com.al_arcade.cs
_deltaChangeInSize++;
AdjustTimer(CsPrefabBuilder.Instance.correctAnswerBonusTime);
int elapsed = GetQuestionElapsedMs();
var attempt = BuildAttempt(question.id, true, elapsed);
attempt.selectedAnswer = question.correct_answer;
attempt.correctAnswer = question.correct_answer;
attempt.hintUsed = _wrongClicks > 0 ? 1 : 0;
ReportAttempt(attempt);
int points = Mathf.Max(100 - _wrongClicks * 15, 25);
if (_streak >= 3) points += (_streak - 2) * 25;
_score += points;
......
......@@ -46,6 +46,7 @@ namespace com.al_arcade.mcq
private int _bestStreak;
private List<McqGateController> _activeGates = new();
private int _correctGateIndex = -1;
private int _selectedGateIndex = -1;
private Camera _mainCamera;
private bool _isTicking;
......@@ -72,6 +73,7 @@ namespace com.al_arcade.mcq
}
protected override string GameTypeKey => "mcq";
protected override int TotalQuestionsCount => _questions?.Length ?? 0;
protected override IEnumerator FetchQuestions(Action<string> onError)
{
......@@ -139,6 +141,15 @@ namespace com.al_arcade.mcq
protected override IEnumerator OnTimeUp()
{
if (_currentIndex < _questions.Length)
{
var q = _questions[_currentIndex];
int elapsed = GetQuestionElapsedMs();
var attempt = BuildAttempt(q.id, false, elapsed);
attempt.isTimeout = 1;
ReportAttempt(attempt);
}
_state = McqGameState.GameOver;
StopPlayerAndCompetitor();
StopAllCoroutines();
......@@ -265,6 +276,7 @@ namespace com.al_arcade.mcq
_state = McqGameState.WaitingForAnswer;
SpawnGates(question);
StartQuestionTimer();
bool answered = false;
bool wasCorrect = false;
......@@ -273,6 +285,7 @@ namespace com.al_arcade.mcq
{
if (_state == McqGameState.GameOver) return;
answered = true;
_selectedGateIndex = idx;
wasCorrect = idx == _correctGateIndex;
}
......@@ -332,6 +345,13 @@ namespace com.al_arcade.mcq
{
if (_state == McqGameState.GameOver) yield break;
var q = _questions[_currentIndex];
int elapsed = GetQuestionElapsedMs();
var attempt = BuildAttempt(q.id, correct, elapsed);
attempt.selectedAnswer = (_selectedGateIndex + 1).ToString();
attempt.correctAnswer = (_correctGateIndex + 1).ToString();
ReportAttempt(attempt);
if (correct)
{
_correctCount++;
......
......@@ -6,6 +6,8 @@ using UnityEngine.Events;
namespace com.al_arcade.shared
{
public enum DeviceCategory { mobile, tablet, desktop, unknown }
/// <summary>
/// Abstract base class for all game managers (TF, MCQ, CS, ...).
/// Handles: singleton wiring, score/streak/counts, timer, API loading
......@@ -34,6 +36,8 @@ namespace com.al_arcade.shared
protected int _totalAsked;
protected DateTime gameStartTime;
protected string _sessionId;
protected float _questionStartTime;
// ─── Shared UnityEvents ──────────────────────────────────────────────
[Header("Base Events")]
......@@ -153,6 +157,7 @@ namespace com.al_arcade.shared
OnHideLoading();
yield return OnBeforeBeginGameplay();
_sessionId = Guid.NewGuid().ToString("N");
gameStartTime = DateTime.Now;
_timeLeft = maxTimePerQuestion;
......@@ -239,6 +244,57 @@ namespace com.al_arcade.shared
public int WrongCount => _wrongCount;
public int CurrentQuestionIndex => _currentIndex;
// ─── Analytics ────────────────────────────────────────────────────────
protected void StartQuestionTimer()
{
_questionStartTime = Time.realtimeSinceStartup;
}
protected int GetQuestionElapsedMs()
{
return Mathf.RoundToInt((Time.realtimeSinceStartup - _questionStartTime) * 1000f);
}
protected void ReportAttempt(AttemptData attempt)
{
var api = SSApiManager.EnsureInstance();
StartCoroutine(api.ReportAttempt(attempt));
}
protected AttemptData BuildAttempt(int questionId, bool correct, int timeMs)
{
var attempt = new AttemptData(GameTypeKey, questionId, _sessionId, correct, timeMs);
attempt.playerStreak = _streak;
attempt.playerScoreAtTime = _score;
attempt.roundInGame = _currentIndex + 1;
attempt.totalRounds = TotalQuestionsCount;
var user = UserService.Instance?.CurrentUser;
if (user != null)
attempt.playerId = user.Id;
attempt.deviceType = GetDeviceType();
attempt.os = SystemInfo.operatingSystem;
attempt.clientVersion = Application.version;
attempt.screenWidth = Screen.width;
attempt.screenHeight = Screen.height;
return attempt;
}
protected virtual int TotalQuestionsCount => 0;
private static string GetDeviceType()
{
#if UNITY_IOS || UNITY_ANDROID
float diagonal = Mathf.Sqrt(Screen.width * Screen.width + Screen.height * Screen.height) / Screen.dpi;
return diagonal >= 7f ? "tablet" : "mobile";
#else
return "desktop";
#endif
}
// ─── Challenge mode ───────────────────────────────────────────────────
private ChallengeManager _challengeManager;
private bool _challengeChecked;
......
......@@ -49,6 +49,7 @@ namespace com.al_arcade.tf
// ─── BaseGameManager implementation ──────────────────────────────────
protected override string GameTypeKey => "tf";
protected override int TotalQuestionsCount => _questions?.Length ?? 0;
protected override IEnumerator FetchQuestions(Action<string> onError)
{
......@@ -164,6 +165,7 @@ namespace com.al_arcade.tf
_waitingForAnswer = true;
_pendingAnswer = -1;
_state = TfGameState.Playing;
StartQuestionTimer();
if (handController != null) handController.SetReady(true);
......@@ -183,6 +185,12 @@ namespace com.al_arcade.tf
bool playerSaidTrue = _pendingAnswer == 1;
bool isCorrect = playerSaidTrue == q.is_true;
int elapsed = GetQuestionElapsedMs();
var attempt = BuildAttempt(q.id, isCorrect, elapsed);
attempt.selectedAnswer = playerSaidTrue ? "true" : "false";
attempt.correctAnswer = q.is_true ? "true" : "false";
ReportAttempt(attempt);
if (isCorrect)
{
_correctCount++;
......@@ -256,6 +264,15 @@ namespace com.al_arcade.tf
// ─── End sequences ────────────────────────────────────────────────────
private IEnumerator HandleTimeUp()
{
if (_currentIndex < _questions.Length)
{
var q = _questions[_currentIndex];
int elapsed = GetQuestionElapsedMs();
var attempt = BuildAttempt(q.id, false, elapsed);
attempt.isTimeout = 1;
ReportAttempt(attempt);
}
_state = TfGameState.GameOver;
yield return LoseSequence();
}
......
<Solution>
<Project Path="Unity.Timeline.csproj" />
<Project Path="Unity.VisualEffectGraph.Editor.csproj" />
<Project Path="Unity.RenderPipelines.GPUDriven.Runtime.csproj" />
<Project Path="Unity.PlasticSCM.Editor.csproj" />
<Project Path="Assembly-CSharp.csproj" />
<Project Path="Unity.ShaderGraph.Editor.csproj" />
<Project Path="Unity.RenderPipelines.Core.Runtime.csproj" />
<Project Path="Unity.Mathematics.csproj" />
<Project Path="Unity.Timeline.Editor.csproj" />
<Project Path="Unity.Cinemachine.Editor.csproj" />
<Project Path="Unity.Multiplayer.Center.Editor.csproj" />
<Project Path="Unity.RenderPipelines.Core.Editor.csproj" />
<Project Path="UnityEngine.UI.csproj" />
<Project Path="Unity.RenderPipelines.Universal.2D.Runtime.csproj" />
<Project Path="Unity.Collections.csproj" />
<Project Path="Unity.VisualScripting.Core.Editor.csproj" />
<Project Path="Unity.RenderPipelines.Universal.Editor.csproj" />
<Project Path="Unity.PerformanceTesting.Editor.csproj" />
<Project Path="Unity.Splines.csproj" />
<Project Path="UnityEngine.TestRunner.csproj" />
<Project Path="Unity.Burst.csproj" />
<Project Path="Unity.VisualScripting.Flow.Editor.csproj" />
<Project Path="Unity.Recorder.Editor.csproj" />
<Project Path="Unity.VisualStudio.Editor.csproj" />
<Project Path="Unity.InputSystem.csproj" />
<Project Path="PPv2URPConverters.csproj" />
<Project Path="LightSide.UniText.csproj" />
<Project Path="UniTask.csproj" />
<Project Path="Unity.Searcher.Editor.csproj" />
<Project Path="UnityEditor.TestRunner.csproj" />
<Project Path="Unity.VisualScripting.Core.csproj" />
<Project Path="Unity.RenderPipelines.Universal.Runtime.csproj" />
<Project Path="Unity.UnifiedRayTracing.Runtime.csproj" />
<Project Path="Unity.AI.Navigation.Editor.ConversionSystem.csproj" />
<Project Path="Unity.VisualScripting.State.Editor.csproj" />
<Project Path="Unity.VisualEffectGraph.Runtime.csproj" />
<Project Path="Unity.Burst.CodeGen.csproj" />
<Project Path="Unity.Cinemachine.csproj" />
<Project Path="Unity.Postprocessing.Editor.csproj" />
<Project Path="FlatKit.Utils.Editor.csproj" />
<Project Path="Unity.VisualScripting.Flow.csproj" />
<Project Path="Unity.Splines.Editor.csproj" />
<Project Path="Unity.PerformanceTesting.csproj" />
<Project Path="Unity.TextMeshPro.csproj" />
<Project Path="UniTask.Linq.csproj" />
<Project Path="Unity.TextMeshPro.Editor.csproj" />
<Project Path="ExternAttributes.Editor.csproj" />
<Project Path="Unity.Burst.Editor.csproj" />
<Project Path="Nobi.UiRoundedCorners.Editor.csproj" />
<Project Path="Unity.Rider.Editor.csproj" />
<Project Path="Unity.VisualScripting.State.csproj" />
<Project Path="Unity.AI.Navigation.Updater.csproj" />
<Project Path="UnityEditor.UI.csproj" />
<Project Path="Unity.RenderPipelines.Universal.2D.Editor.Overrides.csproj" />
<Project Path="Unity.Postprocessing.Runtime.csproj" />
<Project Path="CFXRRuntime.csproj" />
<Project Path="LightSide.UniText.Editor.csproj" />
<Project Path="Unity.2D.Sprite.Editor.csproj" />
<Project Path="NuGetForUnity.csproj" />
<Project Path="Unity.AI.Navigation.Editor.csproj" />
<Project Path="CFXRDemo.csproj" />
<Project Path="Unity.Recorder.csproj" />
<Project Path="CFXREditor.csproj" />
<Project Path="Unity.Multiplayer.Center.Common.csproj" />
<Project Path="Assembly-CSharp-firstpass.csproj" />
<Project Path="Unity.InputSystem.ForUI.csproj" />
<Project Path="Unity.AI.Navigation.csproj" />
<Project Path="Unity.Collections.CodeGen.csproj" />
<Project Path="Assembly-CSharp-Editor.csproj" />
<Project Path="Unity.Mathematics.Editor.csproj" />
<Project Path="Unity.VisualScripting.SettingsProvider.Editor.csproj" />
<Project Path="UnityEditor.UI.Analytics.csproj" />
<Project Path="UniTask.TextMeshPro.csproj" />
<Project Path="Unity.Settings.Editor.csproj" />
<Project Path="Unity.InputSystem.TestFramework.csproj" />
<Project Path="Unity.ShaderGraph.Utilities.csproj" />
<Project Path="Unity.VisualScripting.Shared.Editor.csproj" />
<Project Path="Unity.Recorder.Base.csproj" />
<Project Path="KinoBloom.Runtime.csproj" />
<Project Path="Nobi.UiRoundedCorners.csproj" />
<Project Path="Unity.CollabProxy.Editor.csproj" />
<Project Path="CFXR.WelcomeScreen.csproj" />
<Project Path="Unity.RenderPipeline.Universal.ShaderLibrary.csproj" />
<Project Path="UniTask.Editor.csproj" />
<Project Path="ToonyColorsPro.Demo.Editor.csproj" />
<Project Path="Unity.Bindings.OpenImageIO.Editor.csproj" />
<Project Path="Unity.InputSystem.DocCodeSamples.csproj" />
<Project Path="Unity.RenderPipelines.Universal.Shaders.csproj" />
<Project Path="Unity.RenderPipelines.Core.Runtime.Shared.csproj" />
<Project Path="Unity.InternalAPIEngineBridge.004.csproj" />
<Project Path="Unity.RenderPipelines.Core.Editor.Shared.csproj" />
<Project Path="Unity.VisualScripting.DocCodeExamples.csproj" />
<Project Path="Unity.PlasticSCM.Editor.Entities.csproj" />
<Project Path="UniTask.Addressables.csproj" />
<Project Path="Unity.Rendering.LightTransport.Editor.csproj" />
<Project Path="Unity.Collections.Editor.csproj" />
<Project Path="UniTask.DOTween.csproj" />
<Project Path="Unity.RenderPipelines.Universal.Config.Runtime.csproj" />
<Project Path="Unity.RenderPipelines.ShaderGraph.ShaderGraphLibrary.csproj" />
<Project Path="Unity.RenderPipelines.Core.ShaderLibrary.csproj" />
</Solution>
......@@ -66,14 +66,14 @@
"url": "https://packages.unity.com"
},
"com.unity.collections": {
"version": "2.6.5",
"version": "2.6.2",
"depth": 1,
"source": "registry",
"dependencies": {
"com.unity.burst": "1.8.27",
"com.unity.burst": "1.8.23",
"com.unity.mathematics": "1.3.2",
"com.unity.test-framework": "1.4.6",
"com.unity.nuget.mono-cecil": "1.11.6",
"com.unity.nuget.mono-cecil": "1.11.5",
"com.unity.test-framework.performance": "3.0.3"
},
"url": "https://packages.unity.com"
......@@ -217,7 +217,7 @@
}
},
"com.unity.splines": {
"version": "2.8.4",
"version": "2.8.2",
"depth": 1,
"source": "registry",
"dependencies": {
......
<?php
/**
* ================================================================
* Science Street · Question Bank API v3.0
* Unity / Game Clients talk to this file ONLY
* DB: ssbook_main · New Schema (Curriculum → Subject → Chapter → Questions)
* ================================================================
*
* ENDPOINTS:
* ──────────────────────────────────────────────
* TAXONOMY
* ping Health check
* get_curricula List all active curricula
* get_grades List all grades
* get_terms List all terms
* get_subjects List subjects (filter: curriculum_id)
* get_chapters List chapters (filter: subject_id, grade_id, term_id)
* get_taxonomy_tree Full tree: curriculum → subjects → chapters (with counts)
*
* QUESTIONS
* get_mcq Fetch MCQ questions
* get_tf Fetch True/False questions
* get_cs Fetch Correct-the-Sentence questions
* get_mixed Fetch mixed question types in one call
*
* ANALYTICS (Game → Server)
* report_attempt Report a single question attempt (heatmap data)
* report_batch Report multiple attempts in one call
* report_question Player flags a question as broken/wrong/etc.
*
* STATS (Read-only)
* get_question_stats Get analytics for a specific question
*
* ================================================================
*/
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Session-Id');
error_reporting(0);
// Handle CORS preflight
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
// ── Config ───────────────────────────────────────────────
define('DB_HOST', 'srv-captain--mysql-db');
define('DB_USER', 'root');
define('DB_PASS', 'Alarcade123#');
define('DB_NAME', 'ssbook_main');
define('API_VERSION', '3.0');
define('MAX_QUESTIONS', 50);
define('DEFAULT_QUESTIONS', 10);
// ── DB ───────────────────────────────────────────────────
function db(): PDO {
static $pdo = null;
if (!$pdo) {
$pdo = new PDO(
'mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=utf8mb4',
DB_USER, DB_PASS,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
}
return $pdo;
}
function out(array $data): void {
$data['api_version'] = API_VERSION;
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function err(string $msg, int $code = 400): void {
http_response_code($code);
out(['success' => false, 'error' => $msg]);
}
function reqInt(string $key, int $default = 0): int {
return (int)($_REQUEST[$key] ?? $default);
}
function reqStr(string $key, string $default = ''): string {
return trim($_REQUEST[$key] ?? $default);
}
function reqBool(string $key, bool $default = true): bool {
$v = $_REQUEST[$key] ?? ($default ? '1' : '0');
return $v !== '0' && $v !== 'false' && $v !== '';
}
// ── Shared: build WHERE + params for question filtering ──
function buildQuestionFilter(string $tableAlias = 'q'): array {
$where = "{$tableAlias}.is_active = 1";
$params = [];
// Direct chapter
$chid = reqInt('chapter_id');
if ($chid) {
$where .= " AND {$tableAlias}.chapter_id = ?";
$params[] = $chid;
return [$where, $params]; // chapter is the most specific, skip the rest
}
// Curriculum filter (needs JOIN chain)
$cuid = reqInt('curriculum_id');
if ($cuid) {
$where .= ' AND s.curriculum_id = ?';
$params[] = $cuid;
}
// Subject
$sid = reqInt('subject_id');
if ($sid) {
$where .= ' AND ch.subject_id = ?';
$params[] = $sid;
}
// Grade
$gid = reqInt('grade_id');
if ($gid) {
$where .= ' AND ch.grade_id = ?';
$params[] = $gid;
}
// Term
$tid = reqInt('term_id');
if ($tid) {
$where .= ' AND ch.term_id = ?';
$params[] = $tid;
}
// Difficulty
$diff = reqStr('difficulty');
if ($diff && in_array($diff, ['easy', 'medium', 'hard'])) {
$where .= " AND {$tableAlias}.difficulty = ?";
$params[] = $diff;
}
// Bloom level
$bloom = reqStr('bloom_level');
if ($bloom && in_array($bloom, ['remember', 'understand', 'apply', 'analyze', 'evaluate', 'create'])) {
$where .= " AND {$tableAlias}.bloom_level = ?";
$params[] = $bloom;
}
// Verified only
$verified = reqStr('verified_only');
if ($verified === '1') {
$where .= " AND {$tableAlias}.is_verified = 1";
}
return [$where, $params];
}
// ── Router ───────────────────────────────────────────────
$action = reqStr('action');
if (!$action) err('action parameter is required');
try {
switch ($action) {
// ════════════════════════════════════════════════════════
// PING — Health Check
// ════════════════════════════════════════════════════════
case 'ping': {
// Quick DB check
$dbOk = false;
try { db()->query('SELECT 1'); $dbOk = true; } catch (Throwable $e) {}
out([
'success' => true,
'message' => 'Science Street Question Bank API OK',
'version' => API_VERSION,
'db' => $dbOk ? 'connected' : 'error',
'time' => date('Y-m-d H:i:s'),
]);
}
// ════════════════════════════════════════════════════════
// TAXONOMY: Curricula
// Params: (none)
// Returns: list of active curricula
// ════════════════════════════════════════════════════════
case 'get_curricula': {
$rows = db()->query("
SELECT c.id, c.code, c.name_ar, c.name_en, c.question_lang,
(SELECT COUNT(DISTINCT s2.id) FROM subjects s2 WHERE s2.curriculum_id = c.id AND s2.is_active = 1) AS subject_count
FROM curricula c
WHERE c.is_active = 1
ORDER BY c.sort_order, c.name_ar
")->fetchAll();
out(['success' => true, 'count' => count($rows), 'curricula' => $rows]);
}
// ════════════════════════════════════════════════════════
// TAXONOMY: Grades
// Params: (none)
// Returns: all grades
// ════════════════════════════════════════════════════════
case 'get_grades': {
$rows = db()->query("
SELECT id, name_ar, name_en, sort_order
FROM grades ORDER BY sort_order, name_ar
")->fetchAll();
out(['success' => true, 'count' => count($rows), 'grades' => $rows]);
}
// ════════════════════════════════════════════════════════
// TAXONOMY: Terms
// Params: (none)
// Returns: all terms/semesters
// ════════════════════════════════════════════════════════
case 'get_terms': {
$rows = db()->query("
SELECT id, name_ar, name_en, sort_order
FROM terms ORDER BY sort_order, name_ar
")->fetchAll();
out(['success' => true, 'count' => count($rows), 'terms' => $rows]);
}
// ════════════════════════════════════════════════════════
// TAXONOMY: Subjects
// Params: curriculum_id (optional)
// Returns: subjects with curriculum info
// ════════════════════════════════════════════════════════
case 'get_subjects': {
$cuid = reqInt('curriculum_id');
$where = 's.is_active = 1';
$params = [];
if ($cuid) {
$where .= ' AND s.curriculum_id = ?';
$params[] = $cuid;
}
$s = db()->prepare("
SELECT s.id, s.name_ar, s.name_en, s.icon, s.color_hex,
c.id AS curriculum_id, c.code AS curriculum_code,
c.name_ar AS curriculum_ar, c.name_en AS curriculum_en,
c.question_lang
FROM subjects s
JOIN curricula c ON s.curriculum_id = c.id AND c.is_active = 1
WHERE {$where}
ORDER BY c.sort_order, s.sort_order, s.name_ar
");
$s->execute($params);
$rows = $s->fetchAll();
out(['success' => true, 'count' => count($rows), 'subjects' => $rows]);
}
// ════════════════════════════════════════════════════════
// TAXONOMY: Chapters
// Params: subject_id, grade_id, term_id (all optional but recommended)
// Returns: chapters with question counts
// ════════════════════════════════════════════════════════
case 'get_chapters': {
$sid = reqInt('subject_id');
$gid = reqInt('grade_id');
$tid = reqInt('term_id');
$where = 'ch.is_active = 1';
$params = [];
if ($sid) { $where .= ' AND ch.subject_id = ?'; $params[] = $sid; }
if ($gid) { $where .= ' AND ch.grade_id = ?'; $params[] = $gid; }
if ($tid) { $where .= ' AND ch.term_id = ?'; $params[] = $tid; }
$s = db()->prepare("
SELECT ch.id, ch.name_ar, ch.name_en,
s.id AS subject_id, s.name_ar AS subject_ar, s.name_en AS subject_en,
g.id AS grade_id, g.name_ar AS grade_ar, g.name_en AS grade_en,
t.id AS term_id, t.name_ar AS term_ar, t.name_en AS term_en,
c.id AS curriculum_id, c.code AS curriculum_code, c.question_lang,
(SELECT COUNT(*) FROM mcq_questions mq WHERE mq.chapter_id = ch.id AND mq.is_active = 1) AS mcq_count,
(SELECT COUNT(*) FROM tf_questions tq WHERE tq.chapter_id = ch.id AND tq.is_active = 1) AS tf_count,
(SELECT COUNT(*) FROM cs_questions cq WHERE cq.chapter_id = ch.id AND cq.is_active = 1) AS cs_count
FROM chapters ch
JOIN subjects s ON ch.subject_id = s.id
JOIN grades g ON ch.grade_id = g.id
JOIN terms t ON ch.term_id = t.id
JOIN curricula c ON s.curriculum_id = c.id
WHERE {$where}
ORDER BY c.sort_order, s.sort_order, g.sort_order, t.sort_order, ch.sort_order
");
$s->execute($params);
$rows = $s->fetchAll();
// Cast counts to int
foreach ($rows as &$r) {
$r['mcq_count'] = (int)$r['mcq_count'];
$r['tf_count'] = (int)$r['tf_count'];
$r['cs_count'] = (int)$r['cs_count'];
$r['total_questions'] = $r['mcq_count'] + $r['tf_count'] + $r['cs_count'];
}
out(['success' => true, 'count' => count($rows), 'chapters' => $rows]);
}
// ════════════════════════════════════════════════════════
// TAXONOMY: Full Tree
// Params: curriculum_id (optional)
// Returns: nested curriculum → subjects → grade/term combos with question counts
// Useful for game menu / selection screens
// ════════════════════════════════════════════════════════
case 'get_taxonomy_tree': {
$cuid = reqInt('curriculum_id');
$cWhere = 'c.is_active = 1';
$cParams = [];
if ($cuid) { $cWhere .= ' AND c.id = ?'; $cParams[] = $cuid; }
$curStmt = db()->prepare("SELECT id, code, name_ar, name_en, question_lang FROM curricula c WHERE {$cWhere} ORDER BY sort_order");
$curStmt->execute($cParams);
$curricula = $curStmt->fetchAll();
$subStmt = db()->prepare("SELECT id, name_ar, name_en, icon, color_hex FROM subjects WHERE curriculum_id = ? AND is_active = 1 ORDER BY sort_order");
$chStmt = db()->prepare("
SELECT ch.id, ch.name_ar, ch.name_en, ch.grade_id, ch.term_id,
g.name_ar AS grade_ar, g.name_en AS grade_en,
t.name_ar AS term_ar, t.name_en AS term_en,
(SELECT COUNT(*) FROM mcq_questions WHERE chapter_id = ch.id AND is_active = 1) AS mcq,
(SELECT COUNT(*) FROM tf_questions WHERE chapter_id = ch.id AND is_active = 1) AS tf,
(SELECT COUNT(*) FROM cs_questions WHERE chapter_id = ch.id AND is_active = 1) AS cs
FROM chapters ch
JOIN grades g ON ch.grade_id = g.id
JOIN terms t ON ch.term_id = t.id
WHERE ch.subject_id = ? AND ch.is_active = 1
ORDER BY g.sort_order, t.sort_order, ch.sort_order
");
foreach ($curricula as &$cur) {
$subStmt->execute([$cur['id']]);
$cur['subjects'] = $subStmt->fetchAll();
foreach ($cur['subjects'] as &$sub) {
$chStmt->execute([$sub['id']]);
$chapters = $chStmt->fetchAll();
foreach ($chapters as &$ch) {
$ch['mcq'] = (int)$ch['mcq'];
$ch['tf'] = (int)$ch['tf'];
$ch['cs'] = (int)$ch['cs'];
$ch['total'] = $ch['mcq'] + $ch['tf'] + $ch['cs'];
}
$sub['chapters'] = $chapters;
$sub['total_questions'] = array_sum(array_column($chapters, 'total'));
}
$cur['total_questions'] = array_sum(array_column($cur['subjects'], 'total_questions'));
}
out(['success' => true, 'tree' => $curricula]);
}
// ════════════════════════════════════════════════════════
// GET MCQ QUESTIONS
// ─────────────────────────────────────────────────────
// Filters (all optional, combinable):
// curriculum_id, subject_id, grade_id, term_id,
// chapter_id (most specific — skips other taxonomy filters),
// difficulty (easy|medium|hard),
// bloom_level (remember|understand|apply|analyze|evaluate|create),
// verified_only (1 = only verified questions),
// count (default 10, max 50),
// shuffle (default 1)
//
// Returns: questions with full taxonomy, images, audio,
// explanation, time_limit, analytics summary
// ════════════════════════════════════════════════════════
case 'get_mcq': {
$count = min(reqInt('count', DEFAULT_QUESTIONS), MAX_QUESTIONS);
$shuffle = reqBool('shuffle');
[$where, $params] = buildQuestionFilter('q');
$order = $shuffle ? 'RAND()' : 'q.id';
$s = db()->prepare("
SELECT
q.id,
q.question_text,
q.answer1, q.answer2, q.answer3, q.answer4,
q.explanation,
q.difficulty,
q.bloom_level,
q.time_limit_s,
q.question_image, q.answer1_image, q.answer2_image,
q.answer3_image, q.answer4_image,
q.explanation_image,
q.question_audio,
q.source,
ch.id AS chapter_id,
ch.name_ar AS chapter_ar, ch.name_en AS chapter_en,
s.id AS subject_id,
s.name_ar AS subject_ar, s.name_en AS subject_en,
g.id AS grade_id,
g.name_ar AS grade_ar, g.name_en AS grade_en,
t.id AS term_id,
t.name_ar AS term_ar, t.name_en AS term_en,
c.id AS curriculum_id,
c.code AS curriculum_code,
c.question_lang,
qs.accuracy_rate,
qs.avg_time_ms,
qs.difficulty_rating AS auto_difficulty
FROM mcq_questions q
JOIN chapters ch ON q.chapter_id = ch.id
JOIN subjects s ON ch.subject_id = s.id
JOIN grades g ON ch.grade_id = g.id
JOIN terms t ON ch.term_id = t.id
JOIN curricula c ON s.curriculum_id = c.id
LEFT JOIN question_stats qs
ON qs.question_type = 'mcq' AND qs.question_id = q.id
WHERE {$where}
ORDER BY {$order}
LIMIT " . (int)$count
);
$s->execute($params);
$questions = $s->fetchAll();
if (empty($questions)) err('لا توجد أسئلة متاحة', 404);
// Cast types for Unity
foreach ($questions as &$q) {
$q['time_limit_s'] = $q['time_limit_s'] !== null ? (int)$q['time_limit_s'] : null;
$q['accuracy_rate'] = $q['accuracy_rate'] !== null ? (float)$q['accuracy_rate'] : null;
$q['avg_time_ms'] = $q['avg_time_ms'] !== null ? (int)$q['avg_time_ms'] : null;
$q['auto_difficulty'] = $q['auto_difficulty'] !== null ? (float)$q['auto_difficulty'] : null;
}
out([
'success' => true,
'type' => 'mcq',
'count' => count($questions),
'questions' => $questions,
]);
}
// ════════════════════════════════════════════════════════
// GET TF (True/False) QUESTIONS
// Same filtering as MCQ
// ════════════════════════════════════════════════════════
case 'get_tf': {
$count = min(reqInt('count', DEFAULT_QUESTIONS), MAX_QUESTIONS);
$shuffle = reqBool('shuffle');
[$where, $params] = buildQuestionFilter('q');
$order = $shuffle ? 'RAND()' : 'q.id';
$s = db()->prepare("
SELECT
q.id,
q.question_text,
q.is_true,
q.explanation,
q.difficulty,
q.bloom_level,
q.time_limit_s,
q.question_image,
q.question_audio,
q.explanation_image,
q.source,
ch.id AS chapter_id,
ch.name_ar AS chapter_ar, ch.name_en AS chapter_en,
s.id AS subject_id,
s.name_ar AS subject_ar, s.name_en AS subject_en,
g.id AS grade_id,
g.name_ar AS grade_ar, g.name_en AS grade_en,
t.id AS term_id,
t.name_ar AS term_ar, t.name_en AS term_en,
c.id AS curriculum_id,
c.code AS curriculum_code,
c.question_lang,
qs.accuracy_rate,
qs.avg_time_ms,
qs.difficulty_rating AS auto_difficulty
FROM tf_questions q
JOIN chapters ch ON q.chapter_id = ch.id
JOIN subjects s ON ch.subject_id = s.id
JOIN grades g ON ch.grade_id = g.id
JOIN terms t ON ch.term_id = t.id
JOIN curricula c ON s.curriculum_id = c.id
LEFT JOIN question_stats qs
ON qs.question_type = 'tf' AND qs.question_id = q.id
WHERE {$where}
ORDER BY {$order}
LIMIT " . (int)$count
);
$s->execute($params);
$questions = $s->fetchAll();
if (empty($questions)) err('لا توجد أسئلة متاحة', 404);
foreach ($questions as &$q) {
$q['is_true'] = (bool)(int)$q['is_true'];
$q['time_limit_s'] = $q['time_limit_s'] !== null ? (int)$q['time_limit_s'] : null;
$q['accuracy_rate'] = $q['accuracy_rate'] !== null ? (float)$q['accuracy_rate'] : null;
$q['avg_time_ms'] = $q['avg_time_ms'] !== null ? (int)$q['avg_time_ms'] : null;
$q['auto_difficulty'] = $q['auto_difficulty'] !== null ? (float)$q['auto_difficulty'] : null;
}
out([
'success' => true,
'type' => 'tf',
'count' => count($questions),
'questions' => $questions,
]);
}
// ════════════════════════════════════════════════════════
// GET CS (Correct the Sentence) QUESTIONS
// Returns words array + options array per question
// ════════════════════════════════════════════════════════
case 'get_cs': {
$count = min(reqInt('count', DEFAULT_QUESTIONS), MAX_QUESTIONS);
$shuffle = reqBool('shuffle');
[$where, $params] = buildQuestionFilter('q');
$order = $shuffle ? 'RAND()' : 'q.id';
$s = db()->prepare("
SELECT
q.id,
q.instruction_text,
q.explanation,
q.difficulty,
q.bloom_level,
q.time_limit_s,
q.question_image,
q.explanation_image,
q.source,
ch.id AS chapter_id,
ch.name_ar AS chapter_ar, ch.name_en AS chapter_en,
s.id AS subject_id,
s.name_ar AS subject_ar, s.name_en AS subject_en,
g.id AS grade_id,
g.name_ar AS grade_ar, g.name_en AS grade_en,
t.id AS term_id,
t.name_ar AS term_ar, t.name_en AS term_en,
c.id AS curriculum_id,
c.code AS curriculum_code,
c.question_lang,
qs.accuracy_rate,
qs.avg_time_ms,
qs.difficulty_rating AS auto_difficulty
FROM cs_questions q
JOIN chapters ch ON q.chapter_id = ch.id
JOIN subjects s ON ch.subject_id = s.id
JOIN grades g ON ch.grade_id = g.id
JOIN terms t ON ch.term_id = t.id
JOIN curricula c ON s.curriculum_id = c.id
LEFT JOIN question_stats qs
ON qs.question_type = 'cs' AND qs.question_id = q.id
WHERE {$where}
ORDER BY {$order}
LIMIT " . (int)$count
);
$s->execute($params);
$questions = $s->fetchAll();
if (empty($questions)) err('لا توجد أسئلة متاحة', 404);
// Prepare child queries
$wStmt = db()->prepare("
SELECT word_text, position, is_distractor
FROM cs_words WHERE question_id = ? ORDER BY position
");
$oStmt = db()->prepare("
SELECT option_text, is_correct
FROM cs_options WHERE question_id = ?
");
foreach ($questions as &$q) {
$q['time_limit_s'] = $q['time_limit_s'] !== null ? (int)$q['time_limit_s'] : null;
$q['accuracy_rate'] = $q['accuracy_rate'] !== null ? (float)$q['accuracy_rate'] : null;
$q['avg_time_ms'] = $q['avg_time_ms'] !== null ? (int)$q['avg_time_ms'] : null;
$q['auto_difficulty'] = $q['auto_difficulty'] !== null ? (float)$q['auto_difficulty'] : null;
// Words
$wStmt->execute([$q['id']]);
$words = $wStmt->fetchAll();
foreach ($words as &$w) {
$w['position'] = (int)$w['position'];
$w['is_distractor'] = (bool)(int)$w['is_distractor'];
}
$q['words'] = $words;
// Options
$oStmt->execute([$q['id']]);
$opts = $oStmt->fetchAll();
foreach ($opts as &$o) {
$o['is_correct'] = (bool)(int)$o['is_correct'];
}
if ($shuffle) shuffle($opts);
$q['options'] = $opts;
// Convenience fields
$distractor = array_filter($words, fn($w) => $w['is_distractor']);
$correct = array_filter($opts, fn($o) => $o['is_correct']);
$q['distractor_word'] = $distractor ? array_values($distractor)[0]['word_text'] : null;
$q['correct_answer'] = $correct ? array_values($correct)[0]['option_text'] : null;
// Full sentence text for display
$q['sentence'] = implode(' ', array_column($words, 'word_text'));
}
out([
'success' => true,
'type' => 'cs',
'count' => count($questions),
'questions' => $questions,
]);
}
// ════════════════════════════════════════════════════════
// GET MIXED QUESTIONS
// Returns a mix of MCQ, TF, CS in one call.
// Params: same filters + mcq_count, tf_count, cs_count
// Useful for games that mix question types.
// ════════════════════════════════════════════════════════
case 'get_mixed': {
$mcqCount = min(reqInt('mcq_count', 4), MAX_QUESTIONS);
$tfCount = min(reqInt('tf_count', 3), MAX_QUESTIONS);
$csCount = min(reqInt('cs_count', 3), MAX_QUESTIONS);
// Temporarily set count for sub-calls
$results = ['mcq' => [], 'tf' => [], 'cs' => []];
// MCQ
if ($mcqCount > 0) {
$_REQUEST['count'] = $mcqCount;
[$w, $p] = buildQuestionFilter('q');
$order = reqBool('shuffle') ? 'RAND()' : 'q.id';
$s = db()->prepare("
SELECT q.id, q.question_text, q.answer1, q.answer2, q.answer3, q.answer4,
q.explanation, q.difficulty, q.time_limit_s,
q.question_image, q.answer1_image, q.answer2_image, q.answer3_image, q.answer4_image,
ch.name_ar AS chapter_ar, g.name_ar AS grade_ar,
c.code AS curriculum_code, c.question_lang
FROM mcq_questions q
JOIN chapters ch ON q.chapter_id = ch.id
JOIN subjects s ON ch.subject_id = s.id
JOIN grades g ON ch.grade_id = g.id
JOIN terms t ON ch.term_id = t.id
JOIN curricula c ON s.curriculum_id = c.id
WHERE {$w} ORDER BY {$order} LIMIT " . (int)$mcqCount
);
$s->execute($p);
$rows = $s->fetchAll();
foreach ($rows as &$r) {
$r['_type'] = 'mcq';
$r['time_limit_s'] = $r['time_limit_s'] !== null ? (int)$r['time_limit_s'] : null;
}
$results['mcq'] = $rows;
}
// TF
if ($tfCount > 0) {
$_REQUEST['count'] = $tfCount;
[$w, $p] = buildQuestionFilter('q');
$order = reqBool('shuffle') ? 'RAND()' : 'q.id';
$s = db()->prepare("
SELECT q.id, q.question_text, q.is_true,
q.explanation, q.difficulty, q.time_limit_s,
q.question_image,
ch.name_ar AS chapter_ar, g.name_ar AS grade_ar,
c.code AS curriculum_code, c.question_lang
FROM tf_questions q
JOIN chapters ch ON q.chapter_id = ch.id
JOIN subjects s ON ch.subject_id = s.id
JOIN grades g ON ch.grade_id = g.id
JOIN terms t ON ch.term_id = t.id
JOIN curricula c ON s.curriculum_id = c.id
WHERE {$w} ORDER BY {$order} LIMIT " . (int)$tfCount
);
$s->execute($p);
$rows = $s->fetchAll();
foreach ($rows as &$r) {
$r['_type'] = 'tf';
$r['is_true'] = (bool)(int)$r['is_true'];
$r['time_limit_s'] = $r['time_limit_s'] !== null ? (int)$r['time_limit_s'] : null;
}
$results['tf'] = $rows;
}
// CS
if ($csCount > 0) {
$_REQUEST['count'] = $csCount;
[$w, $p] = buildQuestionFilter('q');
$order = reqBool('shuffle') ? 'RAND()' : 'q.id';
$s = db()->prepare("
SELECT q.id, q.instruction_text, q.explanation, q.difficulty, q.time_limit_s,
ch.name_ar AS chapter_ar, g.name_ar AS grade_ar,
c.code AS curriculum_code, c.question_lang
FROM cs_questions q
JOIN chapters ch ON q.chapter_id = ch.id
JOIN subjects s ON ch.subject_id = s.id
JOIN grades g ON ch.grade_id = g.id
JOIN terms t ON ch.term_id = t.id
JOIN curricula c ON s.curriculum_id = c.id
WHERE {$w} ORDER BY {$order} LIMIT " . (int)$csCount
);
$s->execute($p);
$csRows = $s->fetchAll();
$wStmt = db()->prepare("SELECT word_text, position, is_distractor FROM cs_words WHERE question_id=? ORDER BY position");
$oStmt = db()->prepare("SELECT option_text, is_correct FROM cs_options WHERE question_id=?");
foreach ($csRows as &$r) {
$r['_type'] = 'cs';
$r['time_limit_s'] = $r['time_limit_s'] !== null ? (int)$r['time_limit_s'] : null;
$wStmt->execute([$r['id']]);
$words = $wStmt->fetchAll();
foreach ($words as &$w) { $w['is_distractor'] = (bool)(int)$w['is_distractor']; $w['position'] = (int)$w['position']; }
$oStmt->execute([$r['id']]);
$opts = $oStmt->fetchAll();
foreach ($opts as &$o) $o['is_correct'] = (bool)(int)$o['is_correct'];
if (reqBool('shuffle')) shuffle($opts);
$r['words'] = $words;
$r['options'] = $opts;
$r['sentence'] = implode(' ', array_column($words, 'word_text'));
$dist = array_filter($words, fn($w) => $w['is_distractor']);
$corr = array_filter($opts, fn($o) => $o['is_correct']);
$r['distractor_word'] = $dist ? array_values($dist)[0]['word_text'] : null;
$r['correct_answer'] = $corr ? array_values($corr)[0]['option_text'] : null;
}
$results['cs'] = $csRows;
}
$total = count($results['mcq']) + count($results['tf']) + count($results['cs']);
if ($total === 0) err('لا توجد أسئلة متاحة', 404);
// Optionally interleave all into one array
$all = [];
foreach ($results as $arr) foreach ($arr as $q) $all[] = $q;
if (reqBool('shuffle')) shuffle($all);
out([
'success' => true,
'total' => $total,
'by_type' => $results,
'interleaved'=> $all,
]);
}
// ════════════════════════════════════════════════════════
// REPORT ATTEMPT — Single question attempt
// Called by game client AFTER each answer.
// Method: POST
// Body (form or JSON):
// question_type mcq|tf|cs REQUIRED
// question_id int REQUIRED
// session_id string REQUIRED
// is_correct 0|1 REQUIRED
// time_taken_ms int (milliseconds) REQUIRED
// is_skipped 0|1 optional (default 0)
// is_timeout 0|1 optional (default 0)
// selected_answer string optional
// correct_answer string optional
// room_id string optional
// player_id string optional
// round_in_game int optional
// total_rounds int optional
// player_streak int optional
// player_score_at_time int optional
// players_in_room int optional
// answer_rank int optional
// hint_used 0|1 optional
// power_up_used string optional
// device_type mobile|tablet|desktop|unknown optional
// os string optional
// client_version string optional
// screen_width int optional
// screen_height int optional
// ════════════════════════════════════════════════════════
case 'report_attempt': {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') err('POST required', 405);
// Parse JSON body if Content-Type is json
$input = $_POST;
if (empty($input) || empty($input['question_type'])) {
$raw = file_get_contents('php://input');
$json = json_decode($raw, true);
if (is_array($json)) $input = $json;
}
$qtype = $input['question_type'] ?? '';
$qid = (int)($input['question_id'] ?? 0);
$sessionId = trim($input['session_id'] ?? '');
$isCorrect = (int)($input['is_correct'] ?? 0);
$timeMs = (int)($input['time_taken_ms'] ?? 0);
if (!in_array($qtype, ['mcq', 'tf', 'cs'])) err('question_type must be mcq, tf, or cs');
if (!$qid) err('question_id required');
if (!$sessionId) err('session_id required');
$stmt = db()->prepare("
INSERT INTO question_attempts (
question_type, question_id, session_id,
is_correct, is_skipped, is_timeout,
selected_answer, correct_answer, time_taken_ms,
room_id, player_id,
round_in_game, total_rounds,
player_streak, player_score_at_time,
players_in_room, answer_rank,
hint_used, power_up_used,
device_type, os, client_version,
screen_width, screen_height
) VALUES (
?, ?, ?,
?, ?, ?,
?, ?, ?,
?, ?,
?, ?,
?, ?,
?, ?,
?, ?,
?, ?, ?,
?, ?
)
");
$deviceType = $input['device_type'] ?? 'unknown';
if (!in_array($deviceType, ['mobile', 'tablet', 'desktop', 'unknown'])) $deviceType = 'unknown';
$stmt->execute([
$qtype,
$qid,
$sessionId,
$isCorrect ? 1 : 0,
(int)($input['is_skipped'] ?? 0) ? 1 : 0,
(int)($input['is_timeout'] ?? 0) ? 1 : 0,
$input['selected_answer'] ?? null,
$input['correct_answer'] ?? null,
$timeMs,
$input['room_id'] ?? null,
$input['player_id'] ?? null,
($input['round_in_game'] ?? null) !== null ? (int)$input['round_in_game'] : null,
($input['total_rounds'] ?? null) !== null ? (int)$input['total_rounds'] : null,
(int)($input['player_streak'] ?? 0),
(int)($input['player_score_at_time'] ?? 0),
($input['players_in_room'] ?? null) !== null ? (int)$input['players_in_room'] : null,
($input['answer_rank'] ?? null) !== null ? (int)$input['answer_rank'] : null,
(int)($input['hint_used'] ?? 0) ? 1 : 0,
$input['power_up_used'] ?? null,
$deviceType,
$input['os'] ?? null,
$input['client_version'] ?? null,
($input['screen_width'] ?? null) !== null ? (int)$input['screen_width'] : null,
($input['screen_height'] ?? null) !== null ? (int)$input['screen_height'] : null,
]);
$attemptId = (int)db()->lastInsertId();
// ── Quick-update aggregated stats (incremental) ──
// This is a lightweight inline aggregation. For production at scale,
// replace with a queue/cron worker that processes batches.
try {
$existing = db()->prepare("
SELECT id, times_served, times_correct, times_wrong, times_skipped, times_timeout,
avg_time_ms, first_served_at
FROM question_stats
WHERE question_type = ? AND question_id = ?
");
$existing->execute([$qtype, $qid]);
$stat = $existing->fetch();
if ($stat) {
$served = (int)$stat['times_served'] + 1;
$correct = (int)$stat['times_correct'] + ($isCorrect ? 1 : 0);
$wrong = (int)$stat['times_wrong'] + (!$isCorrect && !(int)($input['is_skipped'] ?? 0) && !(int)($input['is_timeout'] ?? 0) ? 1 : 0);
$skipped = (int)$stat['times_skipped'] + ((int)($input['is_skipped'] ?? 0) ? 1 : 0);
$timeout = (int)$stat['times_timeout'] + ((int)($input['is_timeout'] ?? 0) ? 1 : 0);
// Running average for time
$oldAvg = (int)$stat['avg_time_ms'];
$newAvg = $oldAvg + (int)(($timeMs - $oldAvg) / $served);
$answered = $correct + $wrong;
$accRate = $answered > 0 ? round($correct / $answered, 4) : null;
$completionR = $served > 0 ? round($answered / $served, 4) : null;
$diffRating = $accRate !== null ? round(1.0 - $accRate, 4) : null;
$skipRate = $served > 0 ? round($skipped / $served, 4) : null;
$timeoutRate = $served > 0 ? round($timeout / $served, 4) : null;
db()->prepare("
UPDATE question_stats SET
times_served = ?, times_correct = ?, times_wrong = ?,
times_skipped = ?, times_timeout = ?,
avg_time_ms = ?, accuracy_rate = ?,
completion_rate = ?, difficulty_rating = ?,
skip_rate = ?, timeout_rate = ?,
last_served_at = NOW()
WHERE id = ?
")->execute([
$served, $correct, $wrong, $skipped, $timeout,
$newAvg, $accRate, $completionR, $diffRating,
$skipRate, $timeoutRate,
$stat['id'],
]);
} else {
// First ever attempt for this question
$isWrong = !$isCorrect && !(int)($input['is_skipped'] ?? 0) && !(int)($input['is_timeout'] ?? 0) ? 1 : 0;
$isSkipped = (int)($input['is_skipped'] ?? 0) ? 1 : 0;
$isTimeout = (int)($input['is_timeout'] ?? 0) ? 1 : 0;
$answered = ($isCorrect ? 1 : 0) + $isWrong;
$accRate = $answered > 0 ? round(($isCorrect ? 1 : 0) / $answered, 4) : null;
db()->prepare("
INSERT INTO question_stats (
question_type, question_id,
times_served, times_correct, times_wrong,
times_skipped, times_timeout,
avg_time_ms, accuracy_rate,
completion_rate, difficulty_rating,
skip_rate, timeout_rate,
first_served_at, last_served_at
) VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
")->execute([
$qtype, $qid,
$isCorrect ? 1 : 0,
$isWrong,
$isSkipped,
$isTimeout,
$timeMs,
$accRate,
$answered > 0 ? round($answered / 1, 4) : null,
$accRate !== null ? round(1.0 - $accRate, 4) : null,
round($isSkipped / 1, 4),
round($isTimeout / 1, 4),
]);
}
} catch (Throwable $e) {
// Stats update failure should not break the attempt recording
// Log silently in production
}
// ── Update daily stats ──
try {
$today = date('Y-m-d');
$ds = db()->prepare("
SELECT id, times_served, times_correct, times_wrong,
times_skipped, times_timeout
FROM question_daily_stats
WHERE question_type = ? AND question_id = ? AND stat_date = ?
");
$ds->execute([$qtype, $qid, $today]);
$daily = $ds->fetch();
if ($daily) {
$isWrong2 = !$isCorrect && !(int)($input['is_skipped'] ?? 0) && !(int)($input['is_timeout'] ?? 0) ? 1 : 0;
db()->prepare("
UPDATE question_daily_stats SET
times_served = times_served + 1,
times_correct = times_correct + ?,
times_wrong = times_wrong + ?,
times_skipped = times_skipped + ?,
times_timeout = times_timeout + ?,
avg_time_ms = ROUND((COALESCE(avg_time_ms,0) * (times_served - 1) + ?) / times_served)
WHERE id = ?
")->execute([
$isCorrect ? 1 : 0,
$isWrong2,
(int)($input['is_skipped'] ?? 0) ? 1 : 0,
(int)($input['is_timeout'] ?? 0) ? 1 : 0,
$timeMs,
$daily['id'],
]);
} else {
$isWrong2 = !$isCorrect && !(int)($input['is_skipped'] ?? 0) && !(int)($input['is_timeout'] ?? 0) ? 1 : 0;
db()->prepare("
INSERT INTO question_daily_stats (
question_type, question_id, stat_date,
times_served, times_correct, times_wrong,
times_skipped, times_timeout, avg_time_ms, unique_players
) VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, 1)
")->execute([
$qtype, $qid, $today,
$isCorrect ? 1 : 0,
$isWrong2,
(int)($input['is_skipped'] ?? 0) ? 1 : 0,
(int)($input['is_timeout'] ?? 0) ? 1 : 0,
$timeMs,
]);
}
} catch (Throwable $e) { /* silent */ }
// ── Update MCQ answer distribution ──
if ($qtype === 'mcq' && !empty($input['selected_answer'])) {
try {
$ansIdx = (int)$input['selected_answer'];
if ($ansIdx >= 1 && $ansIdx <= 4) {
$mad = db()->prepare("
SELECT id, times_selected FROM mcq_answer_distribution
WHERE question_id = ? AND answer_index = ?
");
$mad->execute([$qid, $ansIdx]);
$row = $mad->fetch();
if ($row) {
db()->prepare("
UPDATE mcq_answer_distribution SET times_selected = times_selected + 1 WHERE id = ?
")->execute([$row['id']]);
} else {
db()->prepare("
INSERT INTO mcq_answer_distribution (question_id, answer_index, times_selected, avg_time_ms)
VALUES (?, ?, 1, ?)
")->execute([$qid, $ansIdx, $timeMs]);
}
}
} catch (Throwable $e) { /* silent */ }
}
out([
'success' => true,
'attempt_id' => $attemptId,
'recorded' => true,
]);
}
// ════════════════════════════════════════════════════════
// REPORT BATCH — Multiple attempts in one POST
// Body (JSON): { "attempts": [ {same fields as report_attempt}, ... ] }
// ════════════════════════════════════════════════════════
case 'report_batch': {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') err('POST required', 405);
$raw = file_get_contents('php://input');
$body = json_decode($raw, true);
if (!is_array($body) || empty($body['attempts'])) {
err('JSON body with "attempts" array required');
}
$attempts = $body['attempts'];
if (!is_array($attempts) || count($attempts) > 200) {
err('attempts must be an array with max 200 items');
}
$recorded = 0;
$errors = [];
foreach ($attempts as $i => $attempt) {
// Re-use the single report logic by faking $_POST
$prevPost = $_POST;
$_POST = $attempt;
$_REQUEST = array_merge($_REQUEST, ['action' => 'report_attempt']);
try {
// Inline the insert (simplified version)
$qtype = $attempt['question_type'] ?? '';
$qid = (int)($attempt['question_id'] ?? 0);
$sessionId = trim($attempt['session_id'] ?? '');
if (!in_array($qtype, ['mcq', 'tf', 'cs']) || !$qid || !$sessionId) {
$errors[] = "Item {$i}: missing required fields";
continue;
}
$deviceType = $attempt['device_type'] ?? 'unknown';
if (!in_array($deviceType, ['mobile', 'tablet', 'desktop', 'unknown'])) $deviceType = 'unknown';
db()->prepare("
INSERT INTO question_attempts (
question_type, question_id, session_id,
is_correct, is_skipped, is_timeout,
selected_answer, correct_answer, time_taken_ms,
room_id, player_id,
round_in_game, total_rounds,
player_streak, player_score_at_time,
players_in_room, answer_rank,
hint_used, power_up_used,
device_type, os, client_version,
screen_width, screen_height
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
")->execute([
$qtype, $qid, $sessionId,
(int)($attempt['is_correct'] ?? 0) ? 1 : 0,
(int)($attempt['is_skipped'] ?? 0) ? 1 : 0,
(int)($attempt['is_timeout'] ?? 0) ? 1 : 0,
$attempt['selected_answer'] ?? null,
$attempt['correct_answer'] ?? null,
(int)($attempt['time_taken_ms'] ?? 0),
$attempt['room_id'] ?? null,
$attempt['player_id'] ?? null,
isset($attempt['round_in_game']) ? (int)$attempt['round_in_game'] : null,
isset($attempt['total_rounds']) ? (int)$attempt['total_rounds'] : null,
(int)($attempt['player_streak'] ?? 0),
(int)($attempt['player_score_at_time'] ?? 0),
isset($attempt['players_in_room']) ? (int)$attempt['players_in_room'] : null,
isset($attempt['answer_rank']) ? (int)$attempt['answer_rank'] : null,
(int)($attempt['hint_used'] ?? 0) ? 1 : 0,
$attempt['power_up_used'] ?? null,
$deviceType,
$attempt['os'] ?? null,
$attempt['client_version'] ?? null,
isset($attempt['screen_width']) ? (int)$attempt['screen_width'] : null,
isset($attempt['screen_height']) ? (int)$attempt['screen_height'] : null,
]);
$recorded++;
} catch (Throwable $e) {
$errors[] = "Item {$i}: " . $e->getMessage();
}
$_POST = $prevPost;
}
out([
'success' => true,
'recorded' => $recorded,
'total' => count($attempts),
'errors' => $errors,
]);
}
// ════════════════════════════════════════════════════════
// REPORT QUESTION — Player flags a question
// Method: POST
// Params:
// question_type mcq|tf|cs REQUIRED
// question_id int REQUIRED
// reason string (enum) REQUIRED
// detail string optional
// session_id string optional
// reporter_id string optional
//
// Reason enum: wrong_answer, unclear, duplicate,
// offensive, too_easy, too_hard, typo, other
// ════════════════════════════════════════════════════════
case 'report_question': {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') err('POST required', 405);
$input = $_POST;
if (empty($input) || empty($input['question_type'])) {
$raw = file_get_contents('php://input');
$json = json_decode($raw, true);
if (is_array($json)) $input = $json;
}
$qtype = $input['question_type'] ?? '';
$qid = (int)($input['question_id'] ?? 0);
$reason = $input['reason'] ?? '';
if (!in_array($qtype, ['mcq', 'tf', 'cs'])) err('Invalid question_type');
if (!$qid) err('question_id required');
$validReasons = ['wrong_answer', 'unclear', 'duplicate', 'offensive', 'too_easy', 'too_hard', 'typo', 'other'];
if (!in_array($reason, $validReasons)) {
err('Invalid reason. Must be one of: ' . implode(', ', $validReasons));
}
db()->prepare("
INSERT INTO question_reports (
question_type, question_id, session_id, reporter_id,
reason, detail
) VALUES (?, ?, ?, ?, ?, ?)
")->execute([
$qtype, $qid,
$input['session_id'] ?? null,
$input['reporter_id'] ?? null,
$reason,
$input['detail'] ?? null,
]);
out([
'success' => true,
'report_id' => (int)db()->lastInsertId(),
'message' => 'شكراً لبلاغك! سيتم مراجعته.',
]);
}
// ════════════════════════════════════════════════════════
// GET QUESTION STATS — Analytics for a specific question
// Params: question_type, question_id
// Returns: aggregated stats + answer distribution (MCQ)
// ════════════════════════════════════════════════════════
case 'get_question_stats': {
$qtype = reqStr('question_type');
$qid = reqInt('question_id');
if (!in_array($qtype, ['mcq', 'tf', 'cs'])) err('Invalid question_type');
if (!$qid) err('question_id required');
$s = db()->prepare("SELECT * FROM question_stats WHERE question_type = ? AND question_id = ?");
$s->execute([$qtype, $qid]);
$stats = $s->fetch();
if (!$stats) {
out([
'success' => true,
'stats' => null,
'message' => 'No analytics data yet for this question',
]);
}
// Cast numeric fields
$intFields = ['times_served', 'times_correct', 'times_wrong', 'times_skipped', 'times_timeout',
'avg_time_ms', 'median_time_ms', 'p10_time_ms', 'p90_time_ms',
'fastest_time_ms', 'slowest_time_ms', 'correct_avg_time_ms', 'wrong_avg_time_ms'];
foreach ($intFields as $f) {
if (isset($stats[$f]) && $stats[$f] !== null) $stats[$f] = (int)$stats[$f];
}
$floatFields = ['accuracy_rate', 'completion_rate', 'difficulty_rating', 'discrimination_index',
'hint_usage_rate', 'timeout_rate', 'skip_rate', 'stddev_time_ms',
'avg_streak_on_correct', 'avg_streak_on_wrong', 'first_answer_correct_rate'];
foreach ($floatFields as $f) {
if (isset($stats[$f]) && $stats[$f] !== null) $stats[$f] = (float)$stats[$f];
}
// MCQ: get answer distribution
$distribution = null;
if ($qtype === 'mcq') {
$d = db()->prepare("
SELECT answer_index, times_selected, avg_time_ms,
selected_by_top_quartile, selected_by_bottom_quartile
FROM mcq_answer_distribution
WHERE question_id = ?
ORDER BY answer_index
");
$d->execute([$qid]);
$distribution = $d->fetchAll();
foreach ($distribution as &$row) {
$row['answer_index'] = (int)$row['answer_index'];
$row['times_selected'] = (int)$row['times_selected'];
$row['avg_time_ms'] = $row['avg_time_ms'] !== null ? (int)$row['avg_time_ms'] : null;
$row['selected_by_top_quartile'] = (int)$row['selected_by_top_quartile'];
$row['selected_by_bottom_quartile']= (int)$row['selected_by_bottom_quartile'];
}
}
// Recent daily trend (last 7 days)
$trend = db()->prepare("
SELECT stat_date, times_served, times_correct, times_wrong, avg_time_ms
FROM question_daily_stats
WHERE question_type = ? AND question_id = ?
ORDER BY stat_date DESC LIMIT 7
");
$trend->execute([$qtype, $qid]);
$dailyTrend = $trend->fetchAll();
foreach ($dailyTrend as &$d) {
$d['times_served'] = (int)$d['times_served'];
$d['times_correct'] = (int)$d['times_correct'];
$d['times_wrong'] = (int)$d['times_wrong'];
$d['avg_time_ms'] = $d['avg_time_ms'] !== null ? (int)$d['avg_time_ms'] : null;
}
$result = [
'success' => true,
'stats' => $stats,
'daily_trend' => array_reverse($dailyTrend),
];
if ($distribution !== null) {
$result['answer_distribution'] = $distribution;
}
out($result);
}
// ════════════════════════════════════════════════════════
// UNKNOWN ACTION
// ════════════════════════════════════════════════════════
default:
err("Unknown action: {$action}", 404);
}
} catch (Throwable $e) {
err('Server error: ' . $e->getMessage(), 500);
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment