From 7c0332f95f040bdc2f80dd9a3d3bb7e10097ab73 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Mon, 16 Mar 2026 15:21:17 +0000 Subject: [PATCH] fix(gameplay): harden scoreboard to round bootstrap invariants (#300) --- lobby/tests.py | 112 +++++++++++++++++++++++++++++++++++++++++++++++++ lobby/views.py | 14 ++++++- 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/lobby/tests.py b/lobby/tests.py index dead0e8..5b8ac4b 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -867,6 +867,79 @@ class CanonicalRoundFlowTests(TestCase): }, ) + def test_canonical_round_flow_bootstraps_second_round_without_first_round_carry_over(self): + self.client.login(username="host_canonical", password="secret123") + extra_question = Question.objects.create( + category=self.category, + prompt="Hvem malede Mona Lisa?", + correct_answer="Da Vinci", + is_active=True, + ) + + start_response = self.client.post( + reverse("lobby:start_round", kwargs={"code": self.session.code}), + data={"category_slug": self.category.slug}, + content_type="application/json", + ) + self.assertEqual(start_response.status_code, 201) + first_round_question_id = start_response.json()["round_question"]["id"] + first_round_prompt = start_response.json()["round_question"]["prompt"] + first_round_correct_answer = RoundQuestion.objects.get(pk=first_round_question_id).correct_answer + second_question = extra_question if first_round_prompt == self.question.prompt else self.question + + final_lie_response = None + for index, player in enumerate(self.players, start=1): + lie_response = self.client.post( + reverse("lobby:submit_lie", kwargs={"code": self.session.code, "round_question_id": first_round_question_id}), + data={"player_id": player.id, "session_token": player.session_token, "text": f"Første løgn {index}"}, + content_type="application/json", + ) + self.assertEqual(lie_response.status_code, 201) + final_lie_response = lie_response + + self.assertIsNotNone(final_lie_response) + + for player, selected_text in zip( + self.players, + [first_round_correct_answer, first_round_correct_answer, first_round_correct_answer], + strict=True, + ): + guess_response = self.client.post( + reverse("lobby:submit_guess", kwargs={"code": self.session.code, "round_question_id": first_round_question_id}), + data={"player_id": player.id, "session_token": player.session_token, "selected_text": selected_text}, + content_type="application/json", + ) + self.assertEqual(guess_response.status_code, 201) + + scoreboard_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() + self.assertEqual(scoreboard_payload["session"]["status"], GameSession.Status.SCOREBOARD) + self.assertEqual(scoreboard_payload["round_question"]["id"], first_round_question_id) + self.assertIsNotNone(scoreboard_payload["reveal"]) + self.assertIsNotNone(scoreboard_payload["scoreboard"]) + self.assertGreaterEqual(len(scoreboard_payload["reveal"]["guesses"]), 1) + + next_round_response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) + self.assertEqual(next_round_response.status_code, 200) + self.assertEqual(next_round_response.json()["session"]["status"], GameSession.Status.LIE) + self.assertEqual(next_round_response.json()["session"]["current_round"], 2) + + detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() + self.assertEqual(detail_payload["session"]["status"], GameSession.Status.LIE) + self.assertEqual(detail_payload["session"]["current_round"], 2) + self.assertEqual(detail_payload["phase_view_model"]["current_phase"], GameSession.Status.LIE) + self.assertIsNone(detail_payload["reveal"]) + self.assertIsNone(detail_payload["scoreboard"]) + self.assertEqual(detail_payload["round_question"]["round_number"], 2) + self.assertNotEqual(detail_payload["round_question"]["id"], first_round_question_id) + self.assertEqual(detail_payload["round_question"]["prompt"], second_question.prompt) + self.assertEqual(detail_payload["round_question"]["answers"], []) + + round_two_question = RoundQuestion.objects.get(session=self.session, round_number=2) + self.assertEqual(round_two_question.question, second_question) + self.assertEqual(round_two_question.lies.count(), 0) + self.assertEqual(round_two_question.guesses.count(), 0) + self.assertEqual(round_two_question.mixed_answers, []) + @patch("lobby.views.sync_broadcast_phase_event") @patch("lobby.views._resolve_scores") @patch("lobby.views.GameSession.objects.get") @@ -1284,6 +1357,45 @@ class RevealRoundFlowTests(TestCase): self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[0], self.session.code) self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started") + def test_start_next_round_clears_existing_next_round_bootstrap_state(self): + self.client.login(username="host_reveal", password="secret123") + self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) + + stale_round_question = RoundQuestion.objects.create( + session=self.session, + round_number=2, + question=self.next_question, + correct_answer=self.next_question.correct_answer, + mixed_answers=["Stale truth", "Stale lie"], + ) + LieAnswer.objects.create(round_question=stale_round_question, player=self.player_one, text="Stale lie") + Guess.objects.create( + round_question=stale_round_question, + player=self.player_two, + selected_text="Stale truth", + is_correct=True, + ) + + response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) + + self.assertEqual(response.status_code, 200) + self.session.refresh_from_db() + stale_round_question.refresh_from_db() + self.assertEqual(self.session.status, GameSession.Status.LIE) + self.assertEqual(self.session.current_round, 2) + self.assertEqual(response.json()["round_question"]["id"], stale_round_question.id) + self.assertEqual(stale_round_question.mixed_answers, []) + self.assertEqual(stale_round_question.lies.count(), 0) + self.assertEqual(stale_round_question.guesses.count(), 0) + + detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() + self.assertEqual(detail_payload["session"]["status"], GameSession.Status.LIE) + self.assertEqual(detail_payload["session"]["current_round"], 2) + self.assertEqual(detail_payload["round_question"]["id"], stale_round_question.id) + self.assertEqual(detail_payload["round_question"]["answers"], []) + self.assertIsNone(detail_payload["reveal"]) + self.assertIsNone(detail_payload["scoreboard"]) + def test_start_next_round_requires_host(self): self.session.status = GameSession.Status.SCOREBOARD self.session.save(update_fields=["status"]) diff --git a/lobby/views.py b/lobby/views.py index 31024fa..affbeed 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -193,6 +193,16 @@ def _prepare_mixed_answers(round_question: RoundQuestion) -> list[str]: +def _reset_round_question_bootstrap_state(round_question: RoundQuestion) -> RoundQuestion: + Guess.objects.filter(round_question=round_question).delete() + LieAnswer.objects.filter(round_question=round_question).delete() + if round_question.mixed_answers: + round_question.mixed_answers = [] + round_question.save(update_fields=["mixed_answers"]) + return round_question + + + def _resolve_scores(session: GameSession, round_question: RoundQuestion, round_config: RoundConfig) -> tuple[list[ScoreEvent], list[dict]]: guesses = list(round_question.guesses.select_related("player")) if not guesses: @@ -1116,7 +1126,9 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse: locked_session.current_round = next_round_number try: - round_question = _select_round_question(locked_session, next_round_config) + round_question = _reset_round_question_bootstrap_state( + _select_round_question(locked_session, next_round_config) + ) except ValueError as exc: return api_error(request, code=str(exc), status=400)