diff --git a/app/Http/Controllers/api/PvPQuizController.php b/app/Http/Controllers/api/PvPQuizController.php new file mode 100644 index 0000000..4f0e7e3 --- /dev/null +++ b/app/Http/Controllers/api/PvPQuizController.php @@ -0,0 +1,200 @@ +id(); + + // 1. Check if anyone else is waiting in the cache queue + $waitingPlayer = Cache::get('pvp_waiting_player'); + + // Ensure we don't match the user with themselves + if ($waitingPlayer && $waitingPlayer != $userId) { + + // MATCH FOUND! Remove the waiting player from the queue + Cache::forget('pvp_waiting_player'); + + // Create the Game Room + $match = PvpMatch::create([ + 'status' => 'in_progress', + 'started_at' => now(), + ]); + + // Add both players to the match + $match->players()->createMany([ + ['user_id' => $waitingPlayer, 'score' => 0], + ['user_id' => $userId, 'score' => 0], + ]); + + // Select 5 random multiple-choice questions + $questions = Question::where('type', 'multiple_choice') + ->inRandomOrder() + ->limit(5) + ->pluck('id'); + + // Attach questions to the match + $match->questions()->attach($questions); + + // TODO (Next Step): Fire a WebSocket Event here to tell Player 1 the match started! + + return $this->success([ + 'match_id' => $match->id, + 'status' => 'matched', + 'opponent_id' => $waitingPlayer, + 'questions' => $match->questions()->with('options')->get()->makeHidden(['solution_explanation'])->each(function($q) { + $q->options->each->makeHidden(['is_correct']); + }) + ], 'موفق', 'حریف پیدا شد! بازی شروع می‌شود.'); + } + + // NO MATCH FOUND YET. Put this user in the waiting queue for 30 seconds. + Cache::put('pvp_waiting_player', $userId, 30); + + return $this->success([ + 'status' => 'waiting' + ], 'در حال جستجو', 'در حال یافتن حریف...'); + } + + /** + * STEP 2: Submit Answer for a single question (Fastest Finger Logic) + */ + public function submitAnswer(Request $request, PvpMatch $match) + { + $request->validate([ + 'question_id' => 'required|exists:questions,id', + 'selected_option_id' => 'required|exists:question_options,id', + 'time_taken_seconds' => 'required|numeric|min:0.1|max:15', // Max 15 seconds to answer + ]); + + $player = $match->players()->where('user_id', auth()->id())->firstOrFail(); + $question = Question::with('options')->find($request->question_id); + + $isCorrect = false; + $pointsEarned = 0; + + // Check if correct + $correctOption = $question->options->where('is_correct', true)->first(); + if ($correctOption && $correctOption->id == $request->selected_option_id) { + $isCorrect = true; + + // MATH: Speed Bonus Calculation + // If base score is 10, and you answer in 2 seconds (out of 15), you get a bonus! + $baseScore = 10; + $maxTime = 15; + $speedBonus = ($maxTime - $request->time_taken_seconds) * 0.5; // Half a point for every second saved + + $pointsEarned = $baseScore + $speedBonus; + } + + // Save the answer + $match->answers()->create([ + 'user_id' => auth()->id(), + 'question_id' => $question->id, + 'selected_option_id' => $request->selected_option_id, + 'is_correct' => $isCorrect, + 'time_taken' => $request->time_taken_seconds, + 'points_earned' => $pointsEarned + ]); + + // Update total score for the player + $player->increment('score', $pointsEarned); + + // ========================================== + // CHECK IF THE MATCH IS OVER + // ========================================== + $totalQuestions = $match->questions()->count(); // Should be 5 + $totalExpectedAnswers = $totalQuestions * 2; // 5 questions * 2 players = 10 answers + + // Count how many answers have been submitted in this match globally + $currentTotalAnswers = $match->answers()->count(); + + $isMatchOver = false; + + if ($currentTotalAnswers >= $totalExpectedAnswers) { + // The final answer was just submitted! End the game. + $this->finalizeMatch($match); + $isMatchOver = true; + } + + return $this->success([ + 'is_correct' => $isCorrect, + 'points_earned' => round($pointsEarned, 2), + 'current_total_score' => round($player->score, 2), + 'is_match_over' => $isMatchOver + ], 'موفق', 'پاسخ ثبت شد.'); + } + + /** + * STEP 3: Finalize the Match and Declare Winner (Internal Helper) + */ + private function finalizeMatch(PvpMatch $match) + { + // Get both players, ordered by the highest score first + $players = $match->players()->orderByDesc('score')->get(); + + $player1 = $players->first(); // The one with the higher score + $player2 = $players->last(); // The one with the lower score + + $winnerId = null; + + // Check if it's a draw, or if someone actually won + if ($player1->score > $player2->score) { + $winnerId = $player1->user_id; + } + // If scores are exactly equal, winnerId stays null (Draw/Tie) + + // Close the room + $match->update([ + 'status' => 'completed', + 'ended_at' => now(), + 'winner_id' => $winnerId + ]); + + // TODO (Next Step): Fire a WebSocket Event here to push the "Game Over" screen! + } + + /** + * STEP 4: Get Final Match Results + */ + public function matchResult(PvpMatch $match) + { + // Ensure user was actually in this match + if (!$match->players()->where('user_id', auth()->id())->exists()) { + return response()->json(['message' => 'دسترسی غیرمجاز'], 403); + } + + $match->load([ + 'players.user:id,name', // Load player names + 'winner:id,name' // Load winner name + ]); + + return $this->success([ + 'match_id' => $match->id, + 'status' => $match->status, + 'winner_id' => $match->winner_id, + 'winner_name' => $match->winner ? $match->winner->name : 'مساوی (Draw)', + 'players' => $match->players->map(function ($p) { + return [ + 'user_id' => $p->user_id, + 'name' => optional($p->user)->name, + 'final_score' => round($p->score, 2) + ]; + }) + ], 'موفق', 'نتایج نهایی مسابقه دریافت شد.'); + } +} diff --git a/app/Http/Controllers/api/QuizController.php b/app/Http/Controllers/api/QuizController.php new file mode 100644 index 0000000..506e720 --- /dev/null +++ b/app/Http/Controllers/api/QuizController.php @@ -0,0 +1,207 @@ +get(); + return $this->success($categories, 'موفق', 'لیست دسته‌بندی‌ها دریافت شد'); + } + + /** + * 2. Get all Subjects + */ + public function subjects() + { + $subjects = QuizSubject::all(); + return $this->success($subjects, 'موفق', 'لیست دروس دریافت شد'); + } + + /** + * 3. List Quizzes (Filterable by Category or Subject) + */ + public function index(Request $request) + { + $query = Quiz::where('is_published', true)->with('category'); + + if ($request->filled('category_id')) { + $query->where('category_id', $request->category_id); + } + + if ($request->filled('subject_id')) { + $query->whereHas('questions', function ($q) use ($request) { + $q->where('subject_id', $request->subject_id); + }); + } + + $quizzes = $query->latest()->get(); + + return $this->success($quizzes, 'موفق', 'لیست آزمون‌ها دریافت شد'); + } + + /** + * 4. Start a Quiz & Fetch Questions (SECURELY) + */ + public function start(Quiz $quiz) + { + if (!$quiz->is_published) { + return response()->json(['message' => 'این آزمون در دسترس نیست.'], 403); + } + + $attempt = UserQuizAttempt::create([ + 'user_id' => auth()->id(), + 'quiz_id' => $quiz->id, + 'started_at' => now(), + 'status' => 'in_progress' + ]); + + $quiz->load(['questions' => function($q) { + $q->orderBy('order', 'asc'); + }, 'questions.options']); + + // SECURITY: Hide the correct answers and explanations before sending to mobile app + $quiz->questions->each(function ($question) { + $question->makeHidden(['solution_explanation']); + $question->options->each->makeHidden(['is_correct']); + }); + + $data = [ + 'attempt_id' => $attempt->id, + 'quiz' => $quiz + ]; + + return $this->success($data, 'موفق', 'آزمون با موفقیت شروع شد'); + } + + /** + * 5. Submit the Quiz & Calculate Score + */ + public function submit(Request $request, UserQuizAttempt $attempt) + { + if ($attempt->user_id !== auth()->id() || $attempt->status !== 'in_progress') { + return response()->json(['message' => 'دسترسی غیرمجاز یا آزمون قبلاً پایان یافته است.'], 403); + } + + $request->validate([ + 'answers' => 'required|array', + 'answers.*.question_id' => 'required|exists:questions,id', + 'answers.*.selected_option_id' => 'nullable|exists:question_options,id', + 'answers.*.text_answer' => 'nullable|string', + ]); + + $quiz = $attempt->quiz; + + $totalScore = 0.00; + $correctCount = 0; + $wrongCount = 0; + $unansweredCount = 0; + $needsGrading = false; + + DB::beginTransaction(); + try { + foreach ($request->answers as $ans) { + $question = Question::with('options')->find($ans['question_id']); + + $isCorrect = false; + $earnedScore = 0.00; + + // Handle Multiple Choice + if ($question->type === 'multiple_choice') { + if (empty($ans['selected_option_id'])) { + $unansweredCount++; + $isCorrect = null; + } else { + $correctOption = $question->options->where('is_correct', true)->first(); + + if ($correctOption && $correctOption->id == $ans['selected_option_id']) { + $isCorrect = true; + $earnedScore = $question->score; + $totalScore += $earnedScore; + $correctCount++; + } else { + $isCorrect = false; + if ($quiz->has_negative_score) { + $earnedScore = -($question->score / 3); + $totalScore += $earnedScore; + } + $wrongCount++; + } + } + } + // Handle Descriptive + else if ($question->type === 'descriptive') { + $needsGrading = true; + $isCorrect = null; + } + + UserQuizAnswer::create([ + 'attempt_id' => $attempt->id, + 'question_id' => $question->id, + 'selected_option_id' => $ans['selected_option_id'] ?? null, + 'text_answer' => $ans['text_answer'] ?? null, + 'is_correct' => $isCorrect, + 'earned_score' => $earnedScore, + ]); + } + + $attempt->update([ + 'completed_at' => now(), + 'total_score' => $totalScore, + 'correct_answers' => $correctCount, + 'wrong_answers' => $wrongCount, + 'unanswered' => $unansweredCount, + 'status' => $needsGrading ? 'pending_grading' : 'completed', + ]); + + DB::commit(); + + $data = [ + 'attempt_id' => $attempt->id, + 'status' => $attempt->status + ]; + + return $this->success($data, 'موفق', 'آزمون با موفقیت ثبت شد.'); + + } catch (\Exception $e) { + DB::rollBack(); + return response()->json(['message' => 'خطا در ثبت اطلاعات آزمون.'], 500); + } + } + + /** + * 6. Fetch Results + */ + public function result(UserQuizAttempt $attempt) + { + if ($attempt->user_id !== auth()->id()) { + return response()->json(['message' => 'غیرمجاز'], 403); + } + + $attempt->load([ + 'quiz.category', + 'answers.question.options', + 'answers.question.subject', + 'answers.selectedOption' + ]); + + return $this->success($attempt, 'موفق', 'نتیجه آزمون با موفقیت دریافت شد'); + } +} diff --git a/app/Models/PvpMatch.php b/app/Models/PvpMatch.php new file mode 100644 index 0000000..8533d7c --- /dev/null +++ b/app/Models/PvpMatch.php @@ -0,0 +1,47 @@ + 'datetime', + 'ended_at' => 'datetime', + ]; + + // The two players in this match + public function players() + { + return $this->hasMany(PvpMatchPlayer::class, 'pvp_match_id'); + } + + // All answers submitted in this match + public function answers() + { + return $this->hasMany(PvpMatchAnswer::class, 'pvp_match_id'); + } + + // The 5 random questions assigned to this match via pivot table + public function questions() + { + return $this->belongsToMany(Question::class, 'pvp_match_questions', 'pvp_match_id', 'question_id'); + } + + // The user who won + public function winner() + { + return $this->belongsTo(User::class, 'winner_id'); + } +} diff --git a/app/Models/PvpMatchAnswer.php b/app/Models/PvpMatchAnswer.php new file mode 100644 index 0000000..05223de --- /dev/null +++ b/app/Models/PvpMatchAnswer.php @@ -0,0 +1,47 @@ + 'boolean', + 'time_taken' => 'float', + 'points_earned' => 'float', + ]; + + public function match() + { + return $this->belongsTo(PvpMatch::class, 'pvp_match_id'); + } + + public function user() + { + return $this->belongsTo(User::class, 'user_id'); + } + + public function question() + { + return $this->belongsTo(Question::class, 'question_id'); + } + + public function selectedOption() + { + return $this->belongsTo(QuestionOption::class, 'selected_option_id'); + } +} diff --git a/app/Models/PvpMatchPlayer.php b/app/Models/PvpMatchPlayer.php new file mode 100644 index 0000000..608524b --- /dev/null +++ b/app/Models/PvpMatchPlayer.php @@ -0,0 +1,31 @@ + 'float', + ]; + + public function match() + { + return $this->belongsTo(PvpMatch::class, 'pvp_match_id'); + } + + public function user() + { + return $this->belongsTo(User::class, 'user_id'); + } +} diff --git a/routes/api.php b/routes/api.php index 809c70f..9c90e5d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -20,6 +20,8 @@ use App\Http\Controllers\api\VersionController; use App\Http\Controllers\api\VolumController; use App\Http\Controllers\SuggestionController; use Illuminate\Support\Facades\Route; +use App\Http\Controllers\api\QuizController; +use App\Http\Controllers\api\PvPQuizController; Route::prefix('v1')->group(function () { Route::post('login', [AuthController::class, 'login']); @@ -148,5 +150,14 @@ Route::prefix('v1')->middleware('auth:sanctum')->group(function () { Route::post('/{id}/reply', [\App\Http\Controllers\api\TicketApiController::class, 'reply']); }); + Route::get('/quiz/categories', [QuizController::class, 'categories']); + Route::get('/quiz/subjects', [QuizController::class, 'subjects']); + Route::get('/quizzes', [QuizController::class, 'index']); + // Start, Submit, Result flow + Route::get('/quizzes/{quiz}/start', [QuizController::class, 'start']); + Route::post('/quiz-attempts/{attempt}/submit', [QuizController::class, 'submit']); + Route::get('/quiz-attempts/{attempt}/result', [QuizController::class, 'result']); + + Route::get('/pvp-matches/{match}/result', [PvPQuizController::class, 'matchResult']); });