diff --git a/Pangolivia.API/Api/DTOs/QuestionScoresDto.cs b/Pangolivia.API/Api/DTOs/QuestionScoresDto.cs index 88fb0d9..6390520 100644 --- a/Pangolivia.API/Api/DTOs/QuestionScoresDto.cs +++ b/Pangolivia.API/Api/DTOs/QuestionScoresDto.cs @@ -12,4 +12,5 @@ public record PlayerQuestionScoresDto public int UserId { get; init; } public required string Username { get; init; } public double Score { get; init; } -} + public int TotalScore { get; init; } +} \ No newline at end of file diff --git a/Pangolivia.API/GameEngine/GameSession.cs b/Pangolivia.API/GameEngine/GameSession.cs index 3f141e6..a8718a7 100644 --- a/Pangolivia.API/GameEngine/GameSession.cs +++ b/Pangolivia.API/GameEngine/GameSession.cs @@ -13,11 +13,17 @@ public class GameSession public QuizModel Quiz { get; } private int CurrentQuestionIndex = -1; + private DateTime QuestionStartTime; public CancellationTokenSource? QuestionCts { get; set; } private GameStatus Status { get; set; } = GameStatus.NotStarted; public string? HostConnectionId { get; set; } public readonly ConcurrentDictionary Players; // Made public for easier access from GameManager + // Scoring Constants + private const int QuestionTimeSeconds = 10; + private const int MaxScorePerQuestion = 1000; + private const int MinScorePerQuestion = 500; + public GameSession(int id, string name, int hostUserId, string hostUsername, QuizModel quiz) // Add hostUsername { @@ -134,6 +140,7 @@ public QuestionForPlayerDto AdvanceQuestion() CurrentQuestionIndex++; Status = GameStatus.ActiveQuestion; + QuestionStartTime = DateTime.UtcNow; QuestionModel currentQuestion = questions.ElementAt(CurrentQuestionIndex); @@ -145,7 +152,7 @@ public QuestionForPlayerDto AdvanceQuestion() currentQuestion.Answer4, }; - // Fisher–Yates shuffle + // Fisher Yates shuffle for (int i = answers.Count - 1; i > 0; i--) { int j = Random.Shared.Next(i + 1); @@ -180,7 +187,12 @@ public void AnswerQuestionForPlayer(int playerId, string answer) throw new InvalidOperationException("Player entry is null for this game session."); } - player.AnswerToCurrentQuestion = answer; + // Only record the first answer from the player for the current question + if (player.AnswerToCurrentQuestion == null) + { + player.AnswerToCurrentQuestion = answer; + player.AnswerTime = DateTime.UtcNow; + } } /// @@ -211,16 +223,28 @@ public QuestionScoresDto EndQuestionRound() foreach (Player player in Players.Values) { - int scoreIncrement = 0; - if ( + int scoreIncrement = 0; // Default score is 0 for incorrect/no answer + bool isCorrect = player.AnswerToCurrentQuestion != null && player.AnswerToCurrentQuestion.Equals( correctAnswer, StringComparison.OrdinalIgnoreCase - ) - ) + ); + + // Calculate score only if the answer is correct + if (isCorrect && player.AnswerTime.HasValue) { - scoreIncrement = 1; + var timeTaken = (player.AnswerTime.Value - QuestionStartTime).TotalSeconds; + + // Clamp timeTaken to be within the question duration to handle any latency issues + timeTaken = Math.Max(0, Math.Min(timeTaken, QuestionTimeSeconds)); + + // Linear score decay formula: Score = Max - (Range * (TimeElapsed / TotalTime)) + double scoreRatio = timeTaken / QuestionTimeSeconds; + double scoreRange = MaxScorePerQuestion - MinScorePerQuestion; + double rawScore = MaxScorePerQuestion - (scoreRange * scoreRatio); + + scoreIncrement = (int)Math.Round(rawScore); player.AddPoints(scoreIncrement); } @@ -230,6 +254,7 @@ public QuestionScoresDto EndQuestionRound() UserId = player.UserId, Username = player.Username, Score = scoreIncrement, + TotalScore = player.CurrentScore } ); @@ -278,4 +303,4 @@ enum GameStatus Pending, ActiveQuestion, Ended, -} +} \ No newline at end of file diff --git a/Pangolivia.API/GameEngine/Player.cs b/Pangolivia.API/GameEngine/Player.cs index 66f62cf..31862d5 100644 --- a/Pangolivia.API/GameEngine/Player.cs +++ b/Pangolivia.API/GameEngine/Player.cs @@ -9,6 +9,7 @@ public class Player public string Username { get; } public string ConnectionId { get; set; } public string? AnswerToCurrentQuestion { get; set; } + public DateTime? AnswerTime { get; set; } public int CurrentScore { get; private set; } public Player(UserDto user, string connectionId) diff --git a/Pangolivia.Frontend/src/contexts/SignalRContext.tsx b/Pangolivia.Frontend/src/contexts/SignalRContext.tsx index 0a305b7..b43a24c 100644 --- a/Pangolivia.Frontend/src/contexts/SignalRContext.tsx +++ b/Pangolivia.Frontend/src/contexts/SignalRContext.tsx @@ -26,7 +26,7 @@ export interface QuestionForPlayer { export interface RoundResults { question: string answer: string - playerScores: { userId: number; username: string; score: number }[] + playerScores: { userId: number; username: string; score: number; totalScore: number }[] } export interface FinalGameRecord { diff --git a/Pangolivia.Frontend/src/pages/Game/components/RoundResults.tsx b/Pangolivia.Frontend/src/pages/Game/components/RoundResults.tsx index e6d1e92..b64523a 100644 --- a/Pangolivia.Frontend/src/pages/Game/components/RoundResults.tsx +++ b/Pangolivia.Frontend/src/pages/Game/components/RoundResults.tsx @@ -1,4 +1,11 @@ -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { useMemo } from 'react'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; import { CheckCircle, XCircle } from 'lucide-react'; import type { RoundResults } from '@/contexts/SignalRContext'; @@ -8,23 +15,67 @@ interface RoundResultsProps { } export function RoundResults({ results, currentUserId }: RoundResultsProps) { - const myScoreThisRound = results.playerScores.find(p => p.userId === currentUserId)?.score ?? 0; + const myScoreThisRound = results.playerScores.find((p) => p.userId === currentUserId)?.score ?? 0; + + const leaderboard = useMemo(() => { + // Defensively sort by totalScore, defaulting to 0 if undefined + return [...results.playerScores].sort((a, b) => (b.totalScore ?? 0) - (a.totalScore ?? 0)); + }, [results]); return (
- - + + {myScoreThisRound > 0 ? ( ) : ( )} - {myScoreThisRound > 0 ? "Correct!" : "Incorrect!"} + + {myScoreThisRound > 0 ? 'Correct!' : 'Incorrect!'} + The correct answer was:

{results.answer}

-

Waiting for the next question...

+
+

Leaderboard

+
+ {leaderboard.map((player, index) => { + const roundScore = player.score ?? 0; + const totalScore = player.totalScore ?? 0; + + return ( +
+
+ + {index + 1} + +

{player.username}

+
+
+

{totalScore.toLocaleString()}

+ {roundScore > 0 && ( +

+ +{roundScore.toLocaleString()} +

+ )} +
+
+ ); + })} +
+
+

+ Waiting for the next question... +

diff --git a/Pangolivia.Tests/GameEngine/GameSessionTests.cs b/Pangolivia.Tests/GameEngine/GameSessionTests.cs index 278e9e2..ea883cd 100644 --- a/Pangolivia.Tests/GameEngine/GameSessionTests.cs +++ b/Pangolivia.Tests/GameEngine/GameSessionTests.cs @@ -1,5 +1,6 @@ using Pangolivia.API.DTOs; using Pangolivia.API.GameEngine; +using Pangolivia.API.Models; // Add this using directive for QuizModel and QuestionModel namespace Pangolivia.Tests.GameEngine; @@ -12,12 +13,19 @@ public void Constructor_InitializesProperties() var quiz = new QuizModel { Id = 123, Questions = new List() }; // Act - var session = new GameSession(id: 1, name: "test-session", hostUserId: 42, "host", quiz: quiz); + var session = new GameSession( + id: 1, + name: "test-session", + hostUserId: 42, + hostUsername: "host", + quiz: quiz + ); // Assert Assert.Equal(1, session.Id); Assert.Equal("test-session", session.Name); Assert.Equal(42, session.HostUserId); + Assert.Equal("host", session.HostUsername); Assert.Same(quiz, session.Quiz); Assert.False(session.HasGameStarted()); } @@ -26,20 +34,24 @@ public void Constructor_InitializesProperties() public void Constructor_Throws_WhenNameIsNull() { var quiz = new QuizModel { Id = 1, Questions = new List() }; - Assert.Throws(() => new GameSession(id: 1, name: null!, hostUserId: 1, "host", quiz: quiz)); + Assert.Throws( + () => new GameSession(id: 1, name: null!, hostUserId: 1, hostUsername: "host", quiz: quiz) + ); } [Fact] public void Constructor_Throws_WhenQuizIsNull() { - Assert.Throws(() => new GameSession(id: 1, name: "name", hostUserId: 1, "host", quiz: null!)); + Assert.Throws( + () => new GameSession(id: 1, name: "name", hostUserId: 1, hostUsername: "host", quiz: null!) + ); } [Fact] public void HasGameStarted_ReturnsFalse_WhenNoQuestionStarted() { var quiz = new QuizModel { Id = 1, Questions = new List() }; - var session = new GameSession(id: 1, name: "s", hostUserId: 1, "host", quiz: quiz); + var session = new GameSession(id: 1, name: "s", hostUserId: 1, hostUsername: "host", quiz: quiz); Assert.False(session.HasGameStarted()); } @@ -59,7 +71,7 @@ public void HasGameStarted_ReturnsTrue_WhenGameStarted() Answer4 = "22", }; var quiz = new QuizModel { Id = 1, Questions = new List { question } }; - var session = new GameSession(id: 1, name: "s", hostUserId: 1, "host", quiz: quiz); + var session = new GameSession(id: 1, name: "s", hostUserId: 1, hostUsername: "host", quiz: quiz); // Act session.Start(); @@ -73,7 +85,7 @@ public void RegisterPlayer_AddsPlayerSuccessfully() { // Arrange var quiz = new QuizModel { Id = 1, Questions = new List() }; - var session = new GameSession(id: 1, name: "s", hostUserId: 1, "host", quiz: quiz); + var session = new GameSession(id: 1, name: "s", hostUserId: 1, hostUsername: "host", quiz: quiz); var user = new UserDto { Id = 7, Username = "player7" }; // Act @@ -88,12 +100,14 @@ public void RegisterPlayer_Throws_WhenDuplicate() { // Arrange var quiz = new QuizModel { Id = 1, Questions = new List() }; - var session = new GameSession(id: 1, name: "s", hostUserId: 1, "host", quiz: quiz); + var session = new GameSession(id: 1, name: "s", hostUserId: 1, hostUsername: "host", quiz: quiz); var user = new UserDto { Id = 7, Username = "player7" }; session.RegisterPlayer(user, connectionId: "conn-1"); // Act & Assert - Assert.Throws(() => session.RegisterPlayer(user, connectionId: "conn-2")); + Assert.Throws( + () => session.RegisterPlayer(user, connectionId: "conn-2") + ); } [Fact] @@ -101,7 +115,7 @@ public void AdvanceQuestion_Throws_WhenNoQuestions() { // Arrange var quiz = new QuizModel { Id = 1, Questions = new List() }; - var session = new GameSession(id: 1, name: "s", hostUserId: 1, "host", quiz: quiz); + var session = new GameSession(id: 1, name: "s", hostUserId: 1, hostUsername: "host", quiz: quiz); session.Start(); // Act & Assert @@ -123,7 +137,7 @@ public void AdvanceQuestion_ReturnsQuestionWithShuffledAnswers() Answer4 = "Berlin", }; var quiz = new QuizModel { Id = 1, Questions = new List { question } }; - var session = new GameSession(id: 1, name: "s", hostUserId: 1, "host", quiz: quiz); + var session = new GameSession(id: 1, name: "s", hostUserId: 1, hostUsername: "host", quiz: quiz); session.Start(); Assert.True(session.HasNextQuestion()); @@ -155,11 +169,11 @@ public void AdvanceQuestion_Throws_WhenAlreadyActiveQuestion() Answer4 = "4", }; var quiz = new QuizModel { Id = 1, Questions = new List { question } }; - var session = new GameSession(id: 1, name: "s", hostUserId: 1, "host", quiz: quiz); + var session = new GameSession(id: 1, name: "s", hostUserId: 1, hostUsername: "host", quiz: quiz); session.Start(); // Act - QuestionForPlayerDto dto = session.AdvanceQuestion(); + session.AdvanceQuestion(); // Assert Assert.Throws(() => session.AdvanceQuestion()); @@ -180,7 +194,7 @@ public void AdvanceQuestion_Throws_WhenGameEnded() Answer4 = "d", }; var quiz = new QuizModel { Id = 1, Questions = new List { question } }; - var session = new GameSession(id: 1, name: "s", hostUserId: 1, "host", quiz: quiz); + var session = new GameSession(id: 1, name: "s", hostUserId: 1, hostUsername: "host", quiz: quiz); var user = new UserDto { Id = 1, Username = "u" }; session.RegisterPlayer(user, connectionId: "c"); session.EndGameAndGetFinalGameRecord(); @@ -190,7 +204,7 @@ public void AdvanceQuestion_Throws_WhenGameEnded() } [Fact] - public void AnswerQuestionForPlayer_SetsAnswerSuccessfully() + public async Task AnswerQuestionForPlayer_CalculatesScoreBasedOnTime() { // Arrange var question = new QuestionModel @@ -204,23 +218,25 @@ public void AnswerQuestionForPlayer_SetsAnswerSuccessfully() Answer4 = "22", }; var quiz = new QuizModel { Id = 1, Questions = new List { question } }; - var session = new GameSession(id: 1, name: "s", hostUserId: 1, "host", quiz: quiz); + var session = new GameSession(id: 1, name: "s", hostUserId: 1, hostUsername: "host", quiz: quiz); var user1 = new UserDto { Id = 99, Username = "p99" }; var user2 = new UserDto { Id = 100, Username = "p100" }; session.RegisterPlayer(user1, connectionId: "c99"); session.RegisterPlayer(user2, connectionId: "c100"); session.Start(); - QuestionForPlayerDto dto = session.AdvanceQuestion(); + session.AdvanceQuestion(); // Act + await Task.Delay(2000); // Simulate user1 taking ~2 seconds session.AnswerQuestionForPlayer(user1.Id, "4"); - session.AnswerQuestionForPlayer(user2.Id, "5"); + session.AnswerQuestionForPlayer(user2.Id, "5"); // Incorrect answer QuestionScoresDto roundResult = session.EndQuestionRound(); // Assert PlayerQuestionScoresDto p1 = roundResult.PlayerScores.Single(ps => ps.UserId == user1.Id); PlayerQuestionScoresDto p2 = roundResult.PlayerScores.Single(ps => ps.UserId == user2.Id); - Assert.Equal(1, p1.Score); + // Score for ~2s delay: 1000 - ((1000-500) * (~2/10)) = ~900 + Assert.InRange(p1.Score, 899, 900); Assert.Equal(0, p2.Score); } @@ -229,14 +245,16 @@ public void AnswerQuestionForPlayer_Throws_WhenPlayerNotRegistered() { // Arrange var quiz = new QuizModel { Id = 1, Questions = new List() }; - var session = new GameSession(id: 1, name: "s", hostUserId: 1, "host", quiz: quiz); + var session = new GameSession(id: 1, name: "s", hostUserId: 1, hostUsername: "host", quiz: quiz); // Act & Assert - Assert.Throws(() => session.AnswerQuestionForPlayer(playerId: 999, answer: "x")); + Assert.Throws( + () => session.AnswerQuestionForPlayer(playerId: 999, answer: "x") + ); } [Fact] - public void EndQuestionRound_ReturnsCorrectAnswerAndPlayerScores() + public async Task EndQuestionRound_ReturnsCorrectAnswerAndPlayerScores() { // Arrange var question = new QuestionModel @@ -251,7 +269,7 @@ public void EndQuestionRound_ReturnsCorrectAnswerAndPlayerScores() }; var quiz = new QuizModel { Id = 1, Questions = new List { question } }; - var session = new GameSession(id: 1, name: "s", hostUserId: 1, "host", quiz: quiz); + var session = new GameSession(id: 1, name: "s", hostUserId: 1, hostUsername: "host", quiz: quiz); var alice = new UserDto { Id = 1, Username = "alice" }; var bob = new UserDto { Id = 2, Username = "bob" }; @@ -259,7 +277,8 @@ public void EndQuestionRound_ReturnsCorrectAnswerAndPlayerScores() session.RegisterPlayer(bob, connectionId: "b"); session.Start(); - QuestionForPlayerDto dto = session.AdvanceQuestion(); + session.AdvanceQuestion(); + await Task.Delay(5000); // Simulate Alice taking ~5 seconds session.AnswerQuestionForPlayer(alice.Id, "10"); session.AnswerQuestionForPlayer(bob.Id, "11"); @@ -270,8 +289,11 @@ public void EndQuestionRound_ReturnsCorrectAnswerAndPlayerScores() Assert.Equal(question.CorrectAnswer, result.Answer); var a = result.PlayerScores.Single(ps => ps.UserId == alice.Id); var b = result.PlayerScores.Single(ps => ps.UserId == bob.Id); - Assert.Equal(1, a.Score); + // Score for ~5s delay: 1000 - ((1000-500) * (~5/10)) = ~750 + Assert.InRange(a.Score, 749, 750); + Assert.InRange(a.TotalScore, 749, 750); // Total score after first round Assert.Equal(0, b.Score); + Assert.Equal(0, b.TotalScore); } [Fact] @@ -279,14 +301,14 @@ public void EndQuestionRound_Throws_WhenNoActiveQuestion() { // Arrange var quiz = new QuizModel { Id = 1, Questions = new List() }; - var session = new GameSession(id: 1, name: "s", hostUserId: 1, "host", quiz: quiz); + var session = new GameSession(id: 1, name: "s", hostUserId: 1, hostUsername: "host", quiz: quiz); // Act & Assert Assert.Throws(() => session.EndQuestionRound()); } [Fact] - public void EndGameAndGetFinalGameRecord_AwardsPointsAndReturnsFinalScores() + public async Task EndGameAndGetFinalGameRecord_AwardsPointsAndReturnsFinalScores() { // Arrange var question1 = new QuestionModel @@ -312,44 +334,59 @@ public void EndGameAndGetFinalGameRecord_AwardsPointsAndReturnsFinalScores() }; var quiz = new QuizModel { Id = 1, Questions = new List { question1, question2 } }; - var session = new GameSession(id: 1, name: "s", hostUserId: 1, "host", quiz: quiz); + var session = new GameSession(id: 1, name: "s", hostUserId: 1, hostUsername: "host", quiz: quiz); var alice = new UserDto { Id = 1, Username = "alice" }; var bob = new UserDto { Id = 2, Username = "bob" }; session.RegisterPlayer(alice, connectionId: "a"); session.RegisterPlayer(bob, connectionId: "b"); - // Round 1 + // --- Round 1 --- session.Start(); - QuestionForPlayerDto dto = session.AdvanceQuestion(); + session.AdvanceQuestion(); + await Task.Delay(2000); // Alice answers after ~2s session.AnswerQuestionForPlayer(alice.Id, "5"); - session.AnswerQuestionForPlayer(bob.Id, "4"); + session.AnswerQuestionForPlayer(bob.Id, "4"); // Bob answers wrong var round1 = session.EndQuestionRound(); var r1a = round1.PlayerScores.Single(ps => ps.UserId == alice.Id); var r1b = round1.PlayerScores.Single(ps => ps.UserId == bob.Id); - Assert.Equal(1, r1a.Score); + + // Assert round 1 scores (with tolerance for timing) + Assert.InRange(r1a.Score, 899, 900); // Score for ~2s delay Assert.Equal(0, r1b.Score); + Assert.Equal(r1a.Score, r1a.TotalScore); // Total score is just round 1 score + Assert.Equal(0, r1b.TotalScore); - // Round 2 + // --- Round 2 --- Assert.True(session.HasNextQuestion()); session.AdvanceQuestion(); - session.AnswerQuestionForPlayer(alice.Id, "6"); + await Task.Delay(1000); // Bob answers after ~1s session.AnswerQuestionForPlayer(bob.Id, "6"); + await Task.Delay(3000); // Alice answers after a total of ~4s + session.AnswerQuestionForPlayer(alice.Id, "6"); var round2 = session.EndQuestionRound(); var r2a = round2.PlayerScores.Single(ps => ps.UserId == alice.Id); var r2b = round2.PlayerScores.Single(ps => ps.UserId == bob.Id); - Assert.Equal(1, r2a.Score); - Assert.Equal(1, r2b.Score); - // Act + // Assert round 2 scores (with tolerance for timing) + Assert.InRange(r2a.Score, 799, 800); // Score for ~4s delay + Assert.InRange(r2b.Score, 949, 950); // Score for ~1s delay + + // Assert total scores are correctly accumulated + Assert.Equal(r1a.TotalScore + r2a.Score, r2a.TotalScore); + Assert.Equal(r1b.TotalScore + r2b.Score, r2b.TotalScore); + + // --- Act --- var final = session.EndGameAndGetFinalGameRecord(); - // Assert + // --- Assert Final Scores --- var fa = final.PlayerScores.Single(p => p.UserId == alice.Id); var fb = final.PlayerScores.Single(p => p.UserId == bob.Id); - Assert.Equal(2, fa.Score); - Assert.Equal(1, fb.Score); + + // The final score should just be the total score from the last round + Assert.Equal(r2a.TotalScore, fa.Score); + Assert.Equal(r2b.TotalScore, fb.Score); } -} +} \ No newline at end of file