From 8a8ac54a738e463ab8a0b3a0d0df5637e9303e53 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Mon, 16 Mar 2026 12:56:58 +0000 Subject: [PATCH] fix(gameplay): harden scoreboard next-round bootstrap refs #300 --- ...OREBOARD-NEXT-ROUND-BOOTSTRAP-INVARIANT.md | 25 ++++ lobby/tests.py | 124 +++++++++++++++++- lobby/views.py | 26 ++++ 3 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 docs/ISSUE-300-SCOREBOARD-NEXT-ROUND-BOOTSTRAP-INVARIANT.md diff --git a/docs/ISSUE-300-SCOREBOARD-NEXT-ROUND-BOOTSTRAP-INVARIANT.md b/docs/ISSUE-300-SCOREBOARD-NEXT-ROUND-BOOTSTRAP-INVARIANT.md new file mode 100644 index 0000000..a2fd6a9 --- /dev/null +++ b/docs/ISSUE-300-SCOREBOARD-NEXT-ROUND-BOOTSTRAP-INVARIANT.md @@ -0,0 +1,25 @@ +# Issue #300 — scoreboard -> next-round bootstrap invariant + +Refs: #300, parent #287 + +## Hardened invariant + +`POST /lobby/sessions/{code}/rounds/next` now treats `scoreboard -> next round` as a deterministic backend-owned bootstrap boundary: + +- stale pre-created `RoundConfig` / `RoundQuestion` rows for the next round are deleted before bootstrap +- dependent lies / guesses for that stale next-round question are cleared with it +- `current_round` increments exactly once when a fresh next-round question can be selected +- the response makes reset semantics explicit with: + - `round_bootstrap.active_submissions = { lies: 0, guesses: 0 }` + - `round_question.answers = []` + - `submission_progress = { lies_submitted: 0, guesses_submitted: 0, players_expected: N }` + - `reveal = null` + - `leaderboard = null` + +## Regression coverage + +Targeted Django tests lock: + +1. scoreboard -> next-round bootstrap returns explicit reset semantics +2. stale future round artifacts are removed before the next round is exposed +3. two consecutive round bootstraps do not leak prior-round state into the next round diff --git a/lobby/tests.py b/lobby/tests.py index dead0e8..cc1b5b2 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -1122,6 +1122,12 @@ class RevealRoundFlowTests(TestCase): correct_answer="Stockholm", is_active=True, ) + self.followup_question = Question.objects.create( + category=self.category, + prompt="Hvad er Norges hovedstad?", + correct_answer="Oslo", + is_active=True, + ) self.round_config = RoundConfig.objects.create(session=self.session, number=1, category=self.category) self.round_question = RoundQuestion.objects.create( session=self.session, @@ -1268,7 +1274,22 @@ class RevealRoundFlowTests(TestCase): self.assertEqual(payload["session"]["status"], GameSession.Status.LIE) self.assertEqual(payload["session"]["current_round"], 2) self.assertEqual(payload["round"]["category"]["slug"], self.category.slug) - self.assertEqual(payload["round_question"]["prompt"], self.next_question.prompt) + self.assertEqual(payload["round_bootstrap"], { + "round_number": 2, + "round_question": None, + "active_submissions": {"lies": 0, "guesses": 0}, + "reveal": None, + "leaderboard": None, + }) + self.assertIn(payload["round_question"]["prompt"], {self.next_question.prompt, self.followup_question.prompt}) + self.assertEqual(payload["round_question"]["answers"], []) + self.assertEqual(payload["submission_progress"], { + "lies_submitted": 0, + "guesses_submitted": 0, + "players_expected": 2, + }) + self.assertIsNone(payload["reveal"]) + self.assertIsNone(payload["leaderboard"]) self.assertEqual(payload["config"]["lie_seconds"], self.round_config.lie_seconds) self.session.refresh_from_db() @@ -1277,13 +1298,109 @@ class RevealRoundFlowTests(TestCase): self.assertTrue( RoundConfig.objects.filter(session=self.session, number=2, category=self.category).exists() ) - self.assertTrue( - RoundQuestion.objects.filter(session=self.session, round_number=2, question=self.next_question).exists() + self.assertEqual( + RoundQuestion.objects.filter(session=self.session, round_number=2).count(), + 1, + ) + self.assertIn( + RoundQuestion.objects.get(session=self.session, round_number=2).question_id, + {self.next_question.id, self.followup_question.id}, ) mock_sync_broadcast_phase_event.assert_called_once() 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_stale_future_round_artifacts(self): + self.client.login(username="host_reveal", password="secret123") + self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) + + stale_round_config = RoundConfig.objects.create( + session=self.session, + number=2, + category=self.category, + ) + 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=["Stockholm", "Göteborg"], + ) + LieAnswer.objects.create(round_question=stale_round_question, player=self.player_one, text="Göteborg") + Guess.objects.create( + round_question=stale_round_question, + player=self.player_two, + selected_text="Göteborg", + fooled_player=self.player_one, + is_correct=False, + ) + + response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) + + self.assertEqual(response.status_code, 200) + self.assertFalse(RoundConfig.objects.filter(pk=stale_round_config.pk).exists()) + self.assertFalse(RoundQuestion.objects.filter(pk=stale_round_question.pk).exists()) + self.assertEqual(LieAnswer.objects.filter(round_question=stale_round_question).count(), 0) + self.assertEqual(Guess.objects.filter(round_question=stale_round_question).count(), 0) + + payload = response.json() + self.assertEqual(payload["session"]["current_round"], 2) + self.assertIn(payload["round_question"]["prompt"], {self.next_question.prompt, self.followup_question.prompt}) + self.assertEqual(payload["submission_progress"], { + "lies_submitted": 0, + "guesses_submitted": 0, + "players_expected": 2, + }) + self.assertIsNone(payload["reveal"]) + self.assertIsNone(payload["leaderboard"]) + + @patch("lobby.views.sync_broadcast_phase_event") + def test_two_consecutive_round_bootstraps_do_not_leak_prior_round_artifacts(self, mock_sync_broadcast_phase_event): + self.client.login(username="host_reveal", password="secret123") + self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) + first_transition = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) + + self.assertEqual(first_transition.status_code, 200) + self.session.refresh_from_db() + self.assertEqual(self.session.current_round, 2) + self.assertEqual(self.session.status, GameSession.Status.LIE) + + round_two_question = RoundQuestion.objects.get(session=self.session, round_number=2) + round_two_question.mixed_answers = [self.next_question.correct_answer, "Bergen"] + round_two_question.save(update_fields=["mixed_answers"]) + LieAnswer.objects.create(round_question=round_two_question, player=self.player_one, text="Bergen") + Guess.objects.create( + round_question=round_two_question, + player=self.player_two, + selected_text="Bergen", + fooled_player=self.player_one, + is_correct=False, + ) + self.session.status = GameSession.Status.SCOREBOARD + self.session.save(update_fields=["status"]) + mock_sync_broadcast_phase_event.reset_mock() + + second_transition = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) + + self.assertEqual(second_transition.status_code, 200) + payload = second_transition.json() + self.assertEqual(payload["session"]["current_round"], 3) + self.assertEqual(payload["session"]["status"], GameSession.Status.LIE) + self.assertIn(payload["round_question"]["prompt"], {self.next_question.prompt, self.followup_question.prompt}) + self.assertNotEqual(payload["round_question"]["prompt"], round_two_question.question.prompt) + self.assertEqual(payload["round_question"]["answers"], []) + self.assertEqual(payload["submission_progress"], { + "lies_submitted": 0, + "guesses_submitted": 0, + "players_expected": 2, + }) + self.assertIsNone(payload["reveal"]) + self.assertIsNone(payload["leaderboard"]) + self.assertEqual(LieAnswer.objects.filter(round_question__round_number=3).count(), 0) + self.assertEqual(Guess.objects.filter(round_question__round_number=3).count(), 0) + mock_sync_broadcast_phase_event.assert_called_once() + self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started") + def test_start_next_round_requires_host(self): self.session.status = GameSession.Status.SCOREBOARD self.session.save(update_fields=["status"]) @@ -1350,6 +1467,7 @@ class RevealRoundFlowTests(TestCase): self.client.login(username="host_reveal", password="secret123") self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) self.next_question.delete() + self.followup_question.delete() response = self.client.post( reverse( diff --git a/lobby/views.py b/lobby/views.py index 31024fa..f0954e7 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -169,6 +169,22 @@ def _build_lie_started_payload(session: GameSession, round_config: RoundConfig, +def _reset_next_round_bootstrap(session: GameSession, round_number: int) -> dict: + RoundQuestion.objects.filter(session=session, round_number=round_number).delete() + RoundConfig.objects.filter(session=session, number=round_number).delete() + return { + "round_number": round_number, + "round_question": None, + "active_submissions": { + "lies": 0, + "guesses": 0, + }, + "reveal": None, + "leaderboard": None, + } + + + def _prepare_mixed_answers(round_question: RoundQuestion) -> list[str]: deduped_answers = list(round_question.mixed_answers or []) if deduped_answers: @@ -1104,6 +1120,7 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse: return api_error(request, code="round_config_missing", status=400) next_round_number = locked_session.current_round + 1 + round_bootstrap = _reset_next_round_bootstrap(locked_session, next_round_number) next_round_config = RoundConfig( session=locked_session, number=next_round_number, @@ -1145,13 +1162,22 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse: "name": next_round_config.category.name, }, }, + "round_bootstrap": round_bootstrap, "round_question": { "id": round_question.id, "prompt": round_question.question.prompt, "round_number": round_question.round_number, "shown_at": round_question.shown_at.isoformat(), "lie_deadline_at": lie_started_payload["lie_deadline_at"], + "answers": [], }, + "submission_progress": { + "lies_submitted": 0, + "guesses_submitted": 0, + "players_expected": Player.objects.filter(session=locked_session).count(), + }, + "reveal": None, + "leaderboard": None, "config": { "lie_seconds": next_round_config.lie_seconds, },