diff --git a/lobby/tests.py b/lobby/tests.py index bb6f6d5..081fa1c 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -788,6 +788,88 @@ class CanonicalRoundFlowTests(TestCase): self.assertEqual([entry["nickname"] for entry in payload["scoreboard"]], ["Luna", "Nora", "Mads"]) self.assertEqual(payload["reveal"]["correct_answer"], "Shakespeare") + @patch("lobby.views.sync_broadcast_phase_event") + @patch("lobby.views._resolve_scores") + @patch("lobby.views.GameSession.objects.get") + def test_submit_guess_skips_rescore_when_locked_session_is_already_revealing( + self, + mock_session_get, + mock_resolve_scores, + mock_sync_broadcast, + ): + round_config = RoundConfig.objects.create( + session=self.session, + number=1, + category=self.category, + points_correct=5, + points_bluff=2, + ) + round_question = RoundQuestion.objects.create( + session=self.session, + round_number=1, + question=self.question, + correct_answer="Shakespeare", + ) + LieAnswer.objects.create(round_question=round_question, player=self.players[0], text="Marlowe") + Guess.objects.create( + round_question=round_question, + player=self.players[0], + selected_text="Shakespeare", + is_correct=True, + ) + Guess.objects.create( + round_question=round_question, + player=self.players[1], + selected_text="Marlowe", + is_correct=False, + fooled_player=self.players[0], + ) + self.players[0].score = round_config.points_correct + round_config.points_bluff + self.players[0].save(update_fields=["score"]) + ScoreEvent.objects.create( + session=self.session, + player=self.players[0], + delta=round_config.points_correct, + reason="guess_correct", + meta={"round_question_id": round_question.id, "guess_id": 1}, + ) + ScoreEvent.objects.create( + session=self.session, + player=self.players[0], + delta=round_config.points_bluff, + reason="bluff_success", + meta={"round_question_id": round_question.id, "fooled_count": 1}, + ) + self.session.status = GameSession.Status.REVEAL + self.session.save(update_fields=["status"]) + + stale_session = GameSession( + pk=self.session.pk, + host=self.host, + code=self.session.code, + status=GameSession.Status.GUESS, + current_round=self.session.current_round, + ) + mock_session_get.return_value = stale_session + + response = self.client.post( + reverse("lobby:submit_guess", kwargs={"code": self.session.code, "round_question_id": round_question.id}), + data={ + "player_id": self.players[2].id, + "session_token": self.players[2].session_token, + "selected_text": "Shakespeare", + }, + content_type="application/json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json()["session"]["status"], GameSession.Status.REVEAL) + self.assertTrue(response.json()["phase_transition"]["auto_advanced"]) + self.assertIsNotNone(response.json()["reveal"]) + mock_resolve_scores.assert_not_called() + mock_sync_broadcast.assert_not_called() + self.assertEqual(ScoreEvent.objects.filter(session=self.session, meta__round_question_id=round_question.id).count(), 2) + class ScoreCalculationTests(TestCase): def setUp(self): diff --git a/lobby/views.py b/lobby/views.py index 7ab41ee..804404d 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -954,35 +954,59 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso leaderboard = None if players_count > 0 and guess_count >= players_count: - already_calculated = ScoreEvent.objects.filter( - session=session, - meta__round_question_id=round_question.id, - ).exists() - if not already_calculated: - score_events, leaderboard = _resolve_scores(session, round_question, round_config) - else: - score_events = list( - ScoreEvent.objects.filter(session=session, meta__round_question_id=round_question.id).select_related("player") - ) - leaderboard = _build_leaderboard(session) + score_events = [] + should_broadcast_scores = False + + with transaction.atomic(): + locked_session = GameSession.objects.select_for_update().get(pk=session.pk) + + if locked_session.status == GameSession.Status.GUESS: + already_calculated = ScoreEvent.objects.filter( + session=locked_session, + meta__round_question_id=round_question.id, + ).exists() + if not already_calculated: + score_events, leaderboard = _resolve_scores(locked_session, round_question, round_config) + should_broadcast_scores = True + else: + score_events = list( + ScoreEvent.objects.filter( + session=locked_session, + meta__round_question_id=round_question.id, + ).select_related("player") + ) + leaderboard = _build_leaderboard(locked_session) + + locked_session.status = GameSession.Status.REVEAL + locked_session.save(update_fields=["status"]) + + elif locked_session.status == GameSession.Status.REVEAL: + score_events = list( + ScoreEvent.objects.filter( + session=locked_session, + meta__round_question_id=round_question.id, + ).select_related("player") + ) + leaderboard = _build_leaderboard(locked_session) + + session_status = locked_session.status - session.status = GameSession.Status.REVEAL - session.save(update_fields=["status"]) - session_status = session.status reveal_payload = _build_reveal_payload(round_question) - score_deltas = [ - {"player_id": ev.player_id, "delta": ev.delta, "reason": ev.reason} - for ev in score_events - ] - sync_broadcast_phase_event( - session.code, - "phase.scores_calculated", - { - "round_question_id": round_question.id, - "score_deltas": score_deltas, - "leaderboard": list(leaderboard), - }, - ) + + if should_broadcast_scores: + score_deltas = [ + {"player_id": ev.player_id, "delta": ev.delta, "reason": ev.reason} + for ev in score_events + ] + sync_broadcast_phase_event( + session.code, + "phase.scores_calculated", + { + "round_question_id": round_question.id, + "score_deltas": score_deltas, + "leaderboard": list(leaderboard), + }, + ) return JsonResponse( {