feat(lobby): align canonical round flow for issue 287
Some checks failed
CI / test-and-quality (push) Failing after 10s
CI / test-and-quality (pull_request) Failing after 10s

This commit is contained in:
2026-03-16 01:00:07 +00:00
parent a2c60749f8
commit ab08dc2b6d
3 changed files with 120 additions and 46 deletions

View File

@@ -19,6 +19,7 @@ from fupogfakta.models import (
Question,
RoundConfig,
RoundQuestion,
ScoreEvent,
)
from lobby.i18n import i18n_locale_config, lobby_i18n_catalog, resolve_error_message, resolve_locale
@@ -947,6 +948,33 @@ class RevealRoundFlowTests(TestCase):
self.session = GameSession.objects.create(host=self.host, code="RVL123", status=GameSession.Status.REVEAL)
self.player_one = Player.objects.create(session=self.session, nickname="Luna", score=9)
self.player_two = Player.objects.create(session=self.session, nickname="Mads", score=3)
self.category = Category.objects.create(name="Reveal", slug="reveal", is_active=True)
self.question = Question.objects.create(
category=self.category,
prompt="Hvad er Danmarks hovedstad?",
correct_answer="København",
is_active=True,
)
self.next_question = Question.objects.create(
category=self.category,
prompt="Hvad er Sveriges hovedstad?",
correct_answer="Stockholm",
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,
round_number=1,
question=self.question,
correct_answer=self.question.correct_answer,
)
ScoreEvent.objects.create(
session=self.session,
player=self.player_one,
delta=5,
reason="guess_correct",
meta={"round_question_id": self.round_question.id},
)
@patch("lobby.views.sync_broadcast_phase_event")
def test_host_can_get_reveal_scoreboard(self, mock_sync_broadcast_phase_event):
@@ -1062,9 +1090,10 @@ class RevealRoundFlowTests(TestCase):
self.assertEqual(response.json()["error"], "Game can only be finished from scoreboard phase")
@patch("lobby.views.sync_broadcast_phase_event")
def test_host_can_start_next_round_from_scoreboard(self, _mock_sync_broadcast_phase_event):
def test_host_can_start_next_round_from_scoreboard(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}))
mock_sync_broadcast_phase_event.reset_mock()
response = self.client.post(
reverse(
@@ -1075,12 +1104,24 @@ class RevealRoundFlowTests(TestCase):
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["session"]["status"], GameSession.Status.LOBBY)
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["config"]["lie_seconds"], self.round_config.lie_seconds)
self.session.refresh_from_db()
self.assertEqual(self.session.status, GameSession.Status.LOBBY)
self.assertEqual(self.session.status, GameSession.Status.LIE)
self.assertEqual(self.session.current_round, 2)
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()
)
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_requires_host(self):
self.session.status = GameSession.Status.SCOREBOARD

View File

@@ -155,6 +155,20 @@ def _select_round_question(session: GameSession, round_config: RoundConfig) -> R
def _build_lie_started_payload(session: GameSession, round_config: RoundConfig, round_question: RoundQuestion) -> dict:
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
return {
"round_number": session.current_round,
"category": {"slug": round_config.category.slug, "name": round_config.category.name},
"round_question_id": round_question.id,
"prompt": round_question.question.prompt,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_deadline_at.isoformat(),
"lie_seconds": round_config.lie_seconds,
}
def _prepare_mixed_answers(round_question: RoundQuestion) -> list[str]:
deduped_answers = list(round_question.mixed_answers or [])
if deduped_answers:
@@ -532,20 +546,12 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
session.status = GameSession.Status.LIE
session.save(update_fields=["status"])
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
lie_started_payload = _build_lie_started_payload(session, round_config, round_question)
sync_broadcast_phase_event(
session.code,
"phase.lie_started",
{
"round_number": session.current_round,
"category": {"slug": round_config.category.slug, "name": round_config.category.name},
"round_question_id": round_question.id,
"prompt": round_question.question.prompt,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_deadline_at.isoformat(),
"lie_seconds": round_config.lie_seconds,
},
lie_started_payload,
)
return JsonResponse(
@@ -567,7 +573,7 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
"prompt": round_question.question.prompt,
"round_number": round_question.round_number,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_deadline_at.isoformat(),
"lie_deadline_at": lie_started_payload["lie_deadline_at"],
},
"config": {
"lie_seconds": round_config.lie_seconds,
@@ -1026,37 +1032,18 @@ def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse:
if session.host_id != request.user.id:
return api_error(request, code="host_only_view_scoreboard", status=403)
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status not in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD}:
return api_error(request, code="scoreboard_invalid_phase", status=400)
promoted_to_scoreboard = locked_session.status == GameSession.Status.REVEAL
if promoted_to_scoreboard:
locked_session.status = GameSession.Status.SCOREBOARD
locked_session.save(update_fields=["status"])
leaderboard = list(
Player.objects.filter(session=session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
if promoted_to_scoreboard:
sync_broadcast_phase_event(
session.code,
"phase.scoreboard",
{"leaderboard": list(leaderboard), "current_round": locked_session.current_round},
)
session = _maybe_promote_reveal_to_scoreboard(session)
if session.status not in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}:
return api_error(request, code="scoreboard_invalid_phase", status=400)
leaderboard = _build_leaderboard(session)
return JsonResponse(
{
"session": {
"code": session.code,
"status": locked_session.status,
"current_round": locked_session.current_round,
"status": session.status,
"current_round": session.current_round,
},
"leaderboard": leaderboard,
}
@@ -1081,18 +1068,63 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse:
if locked_session.status != GameSession.Status.SCOREBOARD:
return api_error(request, code="next_round_invalid_phase", status=400)
previous_round_config = RoundConfig.objects.filter(
session=locked_session,
number=locked_session.current_round,
).select_related("category").first()
if previous_round_config is None:
return api_error(request, code="round_config_missing", status=400)
locked_session.current_round += 1
locked_session.status = GameSession.Status.LOBBY
next_round_config = RoundConfig.objects.create(
session=locked_session,
number=locked_session.current_round,
category=previous_round_config.category,
lie_seconds=previous_round_config.lie_seconds,
guess_seconds=previous_round_config.guess_seconds,
points_correct=previous_round_config.points_correct,
points_bluff=previous_round_config.points_bluff,
)
try:
round_question = _select_round_question(locked_session, next_round_config)
except ValueError as exc:
return api_error(request, code=str(exc), status=400)
locked_session.status = GameSession.Status.LIE
locked_session.save(update_fields=["current_round", "status"])
lie_started_payload = _build_lie_started_payload(locked_session, next_round_config, round_question)
sync_broadcast_phase_event(
locked_session.code,
"phase.lie_started",
lie_started_payload,
)
return JsonResponse(
{
"session": {
"code": session.code,
"status": GameSession.Status.LOBBY,
"code": locked_session.code,
"status": locked_session.status,
"current_round": locked_session.current_round,
}
},
"round": {
"number": next_round_config.number,
"category": {
"slug": next_round_config.category.slug,
"name": next_round_config.category.name,
},
},
"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"],
},
"config": {
"lie_seconds": next_round_config.lie_seconds,
},
}
)