Added quiz features

This commit is contained in:
m.razaghi
2026-08-08 17:44:09 +03:30
parent 768a0aaeb0
commit 01d6584e71
6 changed files with 543 additions and 0 deletions

View File

@@ -0,0 +1,200 @@
<?php
namespace App\Http\Controllers\api;
use App\Http\Controllers\Controller;
use App\Models\Question;
use App\Models\PvpMatch;
use App\Traits\BaseApiResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class PvPQuizController extends Controller
{
use BaseApiResponse;
/**
* STEP 1: Join the Matchmaking Queue
*/
public function findMatch(Request $request)
{
$userId = auth()->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)
];
})
], 'موفق', 'نتایج نهایی مسابقه دریافت شد.');
}
}

View File

@@ -0,0 +1,207 @@
<?php
namespace App\Http\Controllers\api;
use App\Http\Controllers\Controller;
use App\Models\Quiz;
use App\Models\QuizCategory;
use App\Models\QuizSubject;
use App\Models\UserQuizAttempt;
use App\Models\UserQuizAnswer;
use App\Models\Question;
use App\Traits\BaseApiResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class QuizController extends Controller
{
use BaseApiResponse;
/**
* 1. Get all active Categories
*/
public function categories()
{
$categories = QuizCategory::where('is_active', true)->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, 'موفق', 'نتیجه آزمون با موفقیت دریافت شد');
}
}

47
app/Models/PvpMatch.php Normal file
View File

@@ -0,0 +1,47 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class PvpMatch extends Model
{
use HasFactory;
protected $fillable = [
'status',
'started_at',
'ended_at',
'winner_id',
];
protected $casts = [
'started_at' => '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');
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class PvpMatchAnswer extends Model
{
use HasFactory;
protected $fillable = [
'pvp_match_id',
'user_id',
'question_id',
'selected_option_id',
'is_correct',
'time_taken',
'points_earned',
];
protected $casts = [
'is_correct' => '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');
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class PvpMatchPlayer extends Model
{
use HasFactory;
protected $fillable = [
'pvp_match_id',
'user_id',
'score',
];
protected $casts = [
'score' => 'float',
];
public function match()
{
return $this->belongsTo(PvpMatch::class, 'pvp_match_id');
}
public function user()
{
return $this->belongsTo(User::class, 'user_id');
}
}

View File

@@ -20,6 +20,8 @@ use App\Http\Controllers\api\VersionController;
use App\Http\Controllers\api\VolumController; use App\Http\Controllers\api\VolumController;
use App\Http\Controllers\SuggestionController; use App\Http\Controllers\SuggestionController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use App\Http\Controllers\api\QuizController;
use App\Http\Controllers\api\PvPQuizController;
Route::prefix('v1')->group(function () { Route::prefix('v1')->group(function () {
Route::post('login', [AuthController::class, 'login']); 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::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']);
}); });