diff --git a/TODO.md b/TODO.md index b643fdd..b7c6b68 100644 --- a/TODO.md +++ b/TODO.md @@ -58,7 +58,7 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo - [x] Spørgsmål vises -> alle skriver løgn inden X sek - [x] System blander korrekt svar + løgne - [x] Guessfase: alle gætter inden Z sek -- [ ] Pointudregning (konfigurerbar pr. runde) +- [x] Pointudregning (konfigurerbar pr. runde) - [ ] Scoreboard + næste spørgsmål/runde - [ ] Slutresultat diff --git a/lobby/tests.py b/lobby/tests.py index e8aaa05..9a1980e 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -443,3 +443,106 @@ class GuessSubmissionTests(TestCase): self.assertEqual(response.status_code, 400) self.assertEqual(response.json()["error"], "Guess submission window has closed") + + + +class ScoreCalculationTests(TestCase): + def setUp(self): + self.host = User.objects.create_user(username="host_score", password="secret123") + self.other_user = User.objects.create_user(username="other_score", password="secret123") + self.session = GameSession.objects.create(host=self.host, code="SC0RE1", status=GameSession.Status.GUESS) + self.category = Category.objects.create(name="Sport", slug="sport", is_active=True) + self.question = Question.objects.create( + category=self.category, + prompt="Hvilken sport spiller man i Wimbledon?", + correct_answer="Tennis", + is_active=True, + ) + RoundConfig.objects.create( + session=self.session, + number=1, + category=self.category, + points_correct=5, + points_bluff=2, + ) + self.round_question = RoundQuestion.objects.create( + session=self.session, + round_number=1, + question=self.question, + correct_answer="Tennis", + ) + self.player_one = Player.objects.create(session=self.session, nickname="Luna") + self.player_two = Player.objects.create(session=self.session, nickname="Mads") + self.player_three = Player.objects.create(session=self.session, nickname="Nora") + + def test_host_can_calculate_scores_and_transition_to_reveal(self): + Guess.objects.create(round_question=self.round_question, player=self.player_one, selected_text="Tennis", is_correct=True) + Guess.objects.create( + round_question=self.round_question, + player=self.player_two, + selected_text="Padel", + is_correct=False, + fooled_player=self.player_three, + ) + Guess.objects.create( + round_question=self.round_question, + player=self.player_three, + selected_text="Padel", + is_correct=False, + fooled_player=self.player_three, + ) + + self.client.login(username="host_score", password="secret123") + response = self.client.post( + reverse( + "lobby:calculate_scores", + kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, + ) + ) + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["session"]["status"], GameSession.Status.REVEAL) + self.assertEqual(payload["events_created"], 2) + + self.player_one.refresh_from_db() + self.player_three.refresh_from_db() + self.session.refresh_from_db() + + self.assertEqual(self.player_one.score, 5) + self.assertEqual(self.player_three.score, 4) + self.assertEqual(self.session.status, GameSession.Status.REVEAL) + + def test_calculate_scores_requires_host(self): + self.client.login(username="other_score", password="secret123") + + response = self.client.post( + reverse( + "lobby:calculate_scores", + kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, + ) + ) + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["error"], "Only host can calculate scores") + + def test_calculate_scores_rejects_duplicate_calculation(self): + Guess.objects.create(round_question=self.round_question, player=self.player_one, selected_text="Tennis", is_correct=True) + + self.client.login(username="host_score", password="secret123") + first = self.client.post( + reverse( + "lobby:calculate_scores", + kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, + ) + ) + second = self.client.post( + reverse( + "lobby:calculate_scores", + kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, + ) + ) + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 409) + self.assertEqual(second.json()["error"], "Scores already calculated for this round question") diff --git a/lobby/urls.py b/lobby/urls.py index 9f5484c..5ebead3 100644 --- a/lobby/urls.py +++ b/lobby/urls.py @@ -25,5 +25,10 @@ urlpatterns = [ views.submit_guess, name="submit_guess", ), + path( + "sessions//questions//scores/calculate", + views.calculate_scores, + name="calculate_scores", + ), ] diff --git a/lobby/views.py b/lobby/views.py index 1746211..329083c 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -17,6 +17,7 @@ from fupogfakta.models import ( Question, RoundConfig, RoundQuestion, + ScoreEvent, ) SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" @@ -496,3 +497,113 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso }, status=201, ) + + +@require_POST +@login_required +def calculate_scores(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse: + session_code = code.strip().upper() + + try: + session = GameSession.objects.get(code=session_code) + except GameSession.DoesNotExist: + return JsonResponse({"error": "Session not found"}, status=404) + + if session.host_id != request.user.id: + return JsonResponse({"error": "Only host can calculate scores"}, status=403) + + already_calculated = ScoreEvent.objects.filter( + session=session, + meta__round_question_id=round_question_id, + ).exists() + if already_calculated: + return JsonResponse({"error": "Scores already calculated for this round question"}, status=409) + + if session.status != GameSession.Status.GUESS: + return JsonResponse({"error": "Scores can only be calculated in guess phase"}, status=400) + + try: + round_question = RoundQuestion.objects.get( + pk=round_question_id, + session=session, + round_number=session.current_round, + ) + except RoundQuestion.DoesNotExist: + return JsonResponse({"error": "Round question not found"}, status=404) + + try: + round_config = RoundConfig.objects.get(session=session, number=round_question.round_number) + except RoundConfig.DoesNotExist: + return JsonResponse({"error": "Round config missing"}, status=400) + + guesses = list(round_question.guesses.select_related("player")) + if not guesses: + return JsonResponse({"error": "No guesses submitted for this round question"}, status=400) + + bluff_counts = {} + for guess in guesses: + if guess.fooled_player_id: + bluff_counts[guess.fooled_player_id] = bluff_counts.get(guess.fooled_player_id, 0) + 1 + + with transaction.atomic(): + locked_session = GameSession.objects.select_for_update().get(pk=session.pk) + if locked_session.status != GameSession.Status.GUESS: + return JsonResponse({"error": "Scores can only be calculated in guess phase"}, status=400) + + score_events = [] + + for guess in guesses: + if guess.is_correct: + guess.player.score += round_config.points_correct + guess.player.save(update_fields=["score"]) + score_events.append( + ScoreEvent( + session=session, + player=guess.player, + delta=round_config.points_correct, + reason="guess_correct", + meta={"round_question_id": round_question.id, "guess_id": guess.id}, + ) + ) + + for player_id, fooled_count in bluff_counts.items(): + delta = fooled_count * round_config.points_bluff + player = Player.objects.get(pk=player_id, session=session) + player.score += delta + player.save(update_fields=["score"]) + score_events.append( + ScoreEvent( + session=session, + player=player, + delta=delta, + reason="bluff_success", + meta={"round_question_id": round_question.id, "fooled_count": fooled_count}, + ) + ) + + ScoreEvent.objects.bulk_create(score_events) + + locked_session.status = GameSession.Status.REVEAL + locked_session.save(update_fields=["status"]) + + leaderboard = list( + Player.objects.filter(session=session) + .order_by("-score", "nickname") + .values("id", "nickname", "score") + ) + + return JsonResponse( + { + "session": { + "code": session.code, + "status": GameSession.Status.REVEAL, + "current_round": session.current_round, + }, + "round_question": { + "id": round_question.id, + "round_number": round_question.round_number, + }, + "events_created": len(score_events), + "leaderboard": leaderboard, + } + )