fix(gameplay): harden scoreboard next-round bootstrap refs #300
All checks were successful
CI / test-and-quality (push) Successful in 2m45s

This commit is contained in:
2026-03-16 12:56:58 +00:00
parent f58e852246
commit 8a8ac54a73
3 changed files with 172 additions and 3 deletions

View File

@@ -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

View File

@@ -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(

View File

@@ -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,
},