Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
S
SSBookMinigames
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Administrator
SSBookMinigames
Commits
5f4e8e15
Commit
5f4e8e15
authored
Apr 12, 2026
by
Mahmoud Aglan
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Update : Question Analytics is Live
parent
aff49eaa
Changes
14
Hide whitespace changes
Inline
Side-by-side
Showing
14 changed files
with
1491 additions
and
46 deletions
+1491
-46
AndroidProjectSystem.xml
...ect/.idea/.idea.My project/.idea/AndroidProjectSystem.xml
+6
-0
Setup_Scene_Built-in.unitypackage.meta
... Machine Pack Demo/Setup_Scene_Built-in.unitypackage.meta
+0
-7
Setup_Scene_URP.unitypackage.meta
...ctory Machine Pack Demo/Setup_Scene_URP.unitypackage.meta
+0
-7
ItemIcons_(x2).unitypackage.meta
...nent/Icon_ItemIcons_(x2)/ItemIcons_(x2).unitypackage.meta
+0
-7
PictoIcons_(x2).unitypackage.meta
...nt/Icon_PictoIcons_(x2)/PictoIcons_(x2).unitypackage.meta
+0
-7
Cartoon FX FREE (old legacy effects).unitypackage.meta
...ts/Cartoon FX FREE (old legacy effects).unitypackage.meta
+0
-7
URP_ExtractMe.unitypackage.meta
...t/Assets/PolygonSciFiCity/URP_ExtractMe.unitypackage.meta
+0
-7
CsGameManager.cs
My project/Assets/ScienceStreet/CS/Scripts/CsGameManager.cs
+18
-0
McqGameManager.cs
...roject/Assets/ScienceStreet/MCQ/Scripts/McqGameManager.cs
+20
-0
BaseGameManager.cs
...ct/Assets/ScienceStreet/Shared/Scripts/BaseGameManager.cs
+56
-0
TfGameManager.cs
My project/Assets/ScienceStreet/TF/Scripts/TfGameManager.cs
+17
-0
My project.slnx
My project/My project.slnx
+77
-0
packages-lock.json
My project/Packages/packages-lock.json
+4
-4
api.txt
My project/api.txt
+1293
-0
No files found.
My project/.idea/.idea.My project/.idea/AndroidProjectSystem.xml
0 → 100644
View file @
5f4e8e15
<?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
My project/Assets/EKstudio/LowPoly Factory Machine Pack Demo/Setup_Scene_Built-in.unitypackage.meta
deleted
100644 → 0
View file @
aff49eaa
fileFormatVersion: 2
guid: 745e9c17df962b24a80a69d5da8e5d38
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
My project/Assets/EKstudio/LowPoly Factory Machine Pack Demo/Setup_Scene_URP.unitypackage.meta
deleted
100644 → 0
View file @
aff49eaa
fileFormatVersion: 2
guid: d308d9efe86ef6242a75802e1f37de49
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
My project/Assets/GUI PRO Kit - Simple Casual/Sprite/Component/Icon_ItemIcons_(x2)/ItemIcons_(x2).unitypackage.meta
deleted
100644 → 0
View file @
aff49eaa
fileFormatVersion: 2
guid: e5036f96e3c15ea49b96f7ee989dd3c1
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
My project/Assets/GUI PRO Kit - Simple Casual/Sprite/Component/Icon_PictoIcons_(x2)/PictoIcons_(x2).unitypackage.meta
deleted
100644 → 0
View file @
aff49eaa
fileFormatVersion: 2
guid: 9e9f7f46a1ba34c338eb95b193ae1327
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
My project/Assets/JMO Assets/Cartoon FX FREE (old legacy effects).unitypackage.meta
deleted
100644 → 0
View file @
aff49eaa
fileFormatVersion: 2
guid: b18b93d4b5d00384ba417df18aeac5a3
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
My project/Assets/PolygonSciFiCity/URP_ExtractMe.unitypackage.meta
deleted
100644 → 0
View file @
aff49eaa
fileFormatVersion: 2
guid: 92a80e6f6cd90464b8f87b98fc72999a
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
My project/Assets/ScienceStreet/CS/Scripts/CsGameManager.cs
View file @
5f4e8e15
...
...
@@ -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
;
...
...
My project/Assets/ScienceStreet/MCQ/Scripts/McqGameManager.cs
View file @
5f4e8e15
...
...
@@ -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
++;
...
...
My project/Assets/ScienceStreet/Shared/Scripts/BaseGameManager.cs
View file @
5f4e8e15
...
...
@@ -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
;
...
...
My project/Assets/ScienceStreet/TF/Scripts/TfGameManager.cs
View file @
5f4e8e15
...
...
@@ -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
();
}
...
...
My project/My project.slnx
View file @
5f4e8e15
<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>
My project/Packages/packages-lock.json
View file @
5f4e8e15
...
...
@@ -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.2
7
"
,
"com.unity.burst"
:
"1.8.2
3
"
,
"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"
:
{
...
...
My project/api.txt
0 → 100644
View file @
5f4e8e15
<?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
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment