Compare commits
1 Commits
dev/issue-
...
dev/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a8ac54a73 |
25
docs/ISSUE-300-SCOREBOARD-NEXT-ROUND-BOOTSTRAP-INVARIANT.md
Normal file
25
docs/ISSUE-300-SCOREBOARD-NEXT-ROUND-BOOTSTRAP-INVARIANT.md
Normal 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
|
||||
124
lobby/tests.py
124
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(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user