diff --git a/lobby/tests.py b/lobby/tests.py index 5b01b5d..7850a30 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -782,7 +782,8 @@ class RevealRoundFlowTests(TestCase): 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) - def test_host_can_get_reveal_scoreboard(self): + @patch("lobby.views.sync_broadcast_phase_event") + def test_host_can_get_reveal_scoreboard(self, mock_sync_broadcast_phase_event): self.client.login(username="host_reveal", password="secret123") response = self.client.get( @@ -796,6 +797,17 @@ class RevealRoundFlowTests(TestCase): payload = response.json() self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD) self.assertEqual([item["nickname"] for item in payload["leaderboard"]], ["Luna", "Mads"]) + mock_sync_broadcast_phase_event.assert_called_once_with( + self.session.code, + "phase.scoreboard", + { + "leaderboard": [ + {"id": self.player_one.id, "nickname": "Luna", "score": 9}, + {"id": self.player_two.id, "nickname": "Mads", "score": 3}, + ], + "current_round": 1, + }, + ) self.session.refresh_from_db() self.assertEqual(self.session.status, GameSession.Status.SCOREBOARD) @@ -807,11 +819,16 @@ class RevealRoundFlowTests(TestCase): reverse( "lobby:reveal_scoreboard", kwargs={"code": self.session.code}, - ) + ), + HTTP_ACCEPT_LANGUAGE="fr", ) self.assertEqual(response.status_code, 403) - self.assertEqual(response.json()["error"], "Only host can view scoreboard") + self.assertEqual(response.json(), { + "error": "Only host can view scoreboard", + "error_code": "host_only_view_scoreboard", + "locale": "en", + }) def test_reveal_scoreboard_is_idempotent_in_scoreboard_phase(self): self.session.status = GameSession.Status.SCOREBOARD @@ -825,7 +842,8 @@ class RevealRoundFlowTests(TestCase): self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD) self.assertEqual([item["nickname"] for item in payload["leaderboard"]], ["Luna", "Mads"]) - def test_host_can_finish_game_from_scoreboard(self): + @patch("lobby.views.sync_broadcast_phase_event") + def test_host_can_finish_game_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})) @@ -852,11 +870,16 @@ class RevealRoundFlowTests(TestCase): reverse( "lobby:finish_game", kwargs={"code": self.session.code}, - ) + ), + HTTP_ACCEPT_LANGUAGE="da", ) self.assertEqual(response.status_code, 403) - self.assertEqual(response.json()["error"], "Only host can finish game") + self.assertEqual(response.json(), { + "error": "Kun værten kan afslutte spillet", + "error_code": "host_only_finish_game", + "locale": "da", + }) def test_finish_game_rejects_wrong_phase(self): self.client.login(username="host_reveal", password="secret123") @@ -867,13 +890,19 @@ class RevealRoundFlowTests(TestCase): reverse( "lobby:finish_game", kwargs={"code": self.session.code}, - ) + ), + HTTP_ACCEPT_LANGUAGE="fr", ) self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error"], "Game can only be finished from scoreboard phase") + self.assertEqual(response.json(), { + "error": "Game can only be finished from scoreboard phase", + "error_code": "finish_game_invalid_phase", + "locale": "en", + }) - def test_host_can_start_next_round_from_scoreboard(self): + @patch("lobby.views.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})) @@ -893,7 +922,28 @@ class RevealRoundFlowTests(TestCase): self.assertEqual(self.session.status, GameSession.Status.LOBBY) self.assertEqual(self.session.current_round, 2) - def test_reveal_scoreboard_allows_repeated_reads_after_promotion(self): + def test_start_next_round_requires_host(self): + self.session.status = GameSession.Status.SCOREBOARD + self.session.save(update_fields=["status"]) + self.client.login(username="other_reveal", password="secret123") + + response = self.client.post( + reverse( + "lobby:start_next_round", + kwargs={"code": self.session.code}, + ), + HTTP_ACCEPT_LANGUAGE="fr", + ) + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json(), { + "error": "Only host can start next round", + "error_code": "host_only_start_next_round", + "locale": "en", + }) + + @patch("lobby.views.sync_broadcast_phase_event") + def test_reveal_scoreboard_allows_repeated_reads_after_promotion(self, mock_sync_broadcast_phase_event): self.client.login(username="host_reveal", password="secret123") first_response = self.client.get( @@ -914,6 +964,7 @@ class RevealRoundFlowTests(TestCase): self.assertEqual(first_response.json()["session"]["status"], GameSession.Status.SCOREBOARD) self.assertEqual(second_response.json()["session"]["status"], GameSession.Status.SCOREBOARD) self.assertEqual([item["nickname"] for item in second_response.json()["leaderboard"]], ["Luna", "Mads"]) + self.assertEqual(mock_sync_broadcast_phase_event.call_count, 1) def test_start_next_round_rejects_wrong_phase(self): self.client.login(username="host_reveal", password="secret123") @@ -924,11 +975,16 @@ class RevealRoundFlowTests(TestCase): reverse( "lobby:start_next_round", kwargs={"code": self.session.code}, - ) + ), + HTTP_ACCEPT_LANGUAGE="da", ) self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error"], "Next round can only start from scoreboard phase") + self.assertEqual(response.json(), { + "error": "Næste runde kan kun starte fra scoreboard-fasen", + "error_code": "next_round_invalid_phase", + "locale": "da", + }) class UiScreenTests(TestCase): def setUp(self): diff --git a/lobby/views.py b/lobby/views.py index e8ba0f1..7bce5eb 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -19,6 +19,7 @@ from fupogfakta.models import ( RoundQuestion, ScoreEvent, ) +from realtime.broadcast import sync_broadcast_phase_event from .i18n import api_error, lobby_i18n_errors @@ -707,17 +708,30 @@ def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse: try: session = GameSession.objects.get(code=session_code) except GameSession.DoesNotExist: - return JsonResponse({"error": "Session not found"}, status=404) + return api_error( + request, + key=ERROR_CODES.get("session_not_found", "session_not_found"), + status=404, + ) if session.host_id != request.user.id: - return JsonResponse({"error": "Only host can view scoreboard"}, status=403) + return api_error( + request, + key=ERROR_CODES.get("host_only_view_scoreboard", "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 JsonResponse({"error": "Scoreboard is only available in reveal/scoreboard phase"}, status=400) + return api_error( + request, + key=ERROR_CODES.get("scoreboard_invalid_phase", "scoreboard_invalid_phase"), + status=400, + ) - if locked_session.status == GameSession.Status.REVEAL: + 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"]) @@ -727,6 +741,13 @@ def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse: .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}, + ) + return JsonResponse( { "session": { @@ -747,15 +768,27 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse: try: session = GameSession.objects.get(code=session_code) except GameSession.DoesNotExist: - return JsonResponse({"error": "Session not found"}, status=404) + return api_error( + request, + key=ERROR_CODES.get("session_not_found", "session_not_found"), + status=404, + ) if session.host_id != request.user.id: - return JsonResponse({"error": "Only host can start next round"}, status=403) + return api_error( + request, + key=ERROR_CODES.get("host_only_start_next_round", "host_only_start_next_round"), + status=403, + ) with transaction.atomic(): locked_session = GameSession.objects.select_for_update().get(pk=session.pk) if locked_session.status != GameSession.Status.SCOREBOARD: - return JsonResponse({"error": "Next round can only start from scoreboard phase"}, status=400) + return api_error( + request, + key=ERROR_CODES.get("next_round_invalid_phase", "next_round_invalid_phase"), + status=400, + ) locked_session.current_round += 1 locked_session.status = GameSession.Status.LOBBY @@ -779,15 +812,27 @@ def finish_game(request: HttpRequest, code: str) -> JsonResponse: try: session = GameSession.objects.get(code=session_code) except GameSession.DoesNotExist: - return JsonResponse({"error": "Session not found"}, status=404) + return api_error( + request, + key=ERROR_CODES.get("session_not_found", "session_not_found"), + status=404, + ) if session.host_id != request.user.id: - return JsonResponse({"error": "Only host can finish game"}, status=403) + return api_error( + request, + key=ERROR_CODES.get("host_only_finish_game", "host_only_finish_game"), + status=403, + ) with transaction.atomic(): locked_session = GameSession.objects.select_for_update().get(pk=session.pk) if locked_session.status != GameSession.Status.SCOREBOARD: - return JsonResponse({"error": "Game can only be finished from scoreboard phase"}, status=400) + return api_error( + request, + key=ERROR_CODES.get("finish_game_invalid_phase", "finish_game_invalid_phase"), + status=400, + ) locked_session.status = GameSession.Status.FINISHED locked_session.save(update_fields=["status"]) diff --git a/realtime/broadcast.py b/realtime/broadcast.py new file mode 100644 index 0000000..95d1a5d --- /dev/null +++ b/realtime/broadcast.py @@ -0,0 +1,20 @@ +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer + + +async def broadcast_phase_event(session_code: str, event_type: str, payload: dict) -> None: + """Send a phase event to all WebSocket clients connected to a game session.""" + channel_layer = get_channel_layer() + group_name = f"game_{session_code.upper()}" + await channel_layer.group_send( + group_name, + { + "type": "phase_event", + "payload": {"type": event_type, **payload}, + }, + ) + + +def sync_broadcast_phase_event(session_code: str, event_type: str, payload: dict) -> None: + """Sync wrapper for calling broadcast_phase_event from synchronous Django views.""" + async_to_sync(broadcast_phase_event)(session_code, event_type, payload) diff --git a/shared/i18n/lobby.json b/shared/i18n/lobby.json index c7cf10f..ab47fb8 100644 --- a/shared/i18n/lobby.json +++ b/shared/i18n/lobby.json @@ -39,6 +39,18 @@ "unknown": { "en": "Action failed. Refresh status and try again.", "da": "Handlingen fejlede. Opdater status og prøv igen." + }, + "scoreboard_failed": { + "en": "Could not load scoreboard. Refresh the session and try again.", + "da": "Kunne ikke indlæse scoreboardet. Opdater sessionen og prøv igen." + }, + "next_round_failed": { + "en": "Could not start next round. Refresh the session and try again.", + "da": "Kunne ikke starte næste runde. Opdater sessionen og prøv igen." + }, + "finish_game_failed": { + "en": "Could not finish game. Refresh the session and try again.", + "da": "Kunne ikke afslutte spillet. Opdater sessionen og prøv igen." } }, "ui": { @@ -296,7 +308,13 @@ "not_enough_answers_to_mix": "not_enough_answers_to_mix", "host_only_start_round": "host_only_start_round", "host_only_show_question": "host_only_show_question", - "host_only_mix_answers": "host_only_mix_answers" + "host_only_mix_answers": "host_only_mix_answers", + "host_only_view_scoreboard": "host_only_view_scoreboard", + "scoreboard_invalid_phase": "scoreboard_invalid_phase", + "host_only_start_next_round": "host_only_start_next_round", + "next_round_invalid_phase": "next_round_invalid_phase", + "host_only_finish_game": "host_only_finish_game", + "finish_game_invalid_phase": "finish_game_invalid_phase" }, "errors": { "session_code_required": { @@ -378,6 +396,30 @@ "host_only_mix_answers": { "en": "Only host can mix answers", "da": "Kun værten kan blande svar" + }, + "host_only_view_scoreboard": { + "en": "Only host can view scoreboard", + "da": "Kun værten kan se scoreboardet" + }, + "scoreboard_invalid_phase": { + "en": "Scoreboard is only available in reveal/scoreboard phase", + "da": "Scoreboard er kun tilgængeligt i reveal-/scoreboard-fasen" + }, + "host_only_start_next_round": { + "en": "Only host can start next round", + "da": "Kun værten kan starte næste runde" + }, + "next_round_invalid_phase": { + "en": "Next round can only start from scoreboard phase", + "da": "Næste runde kan kun starte fra scoreboard-fasen" + }, + "host_only_finish_game": { + "en": "Only host can finish game", + "da": "Kun værten kan afslutte spillet" + }, + "finish_game_invalid_phase": { + "en": "Game can only be finished from scoreboard phase", + "da": "Spillet kan kun afsluttes fra scoreboard-fasen" } } }, @@ -416,7 +458,13 @@ "no_available_questions": "start_round_failed", "mix_answers_invalid_phase": "start_round_failed", "round_question_not_found": "start_round_failed", - "not_enough_answers_to_mix": "start_round_failed" + "not_enough_answers_to_mix": "start_round_failed", + "host_only_view_scoreboard": "scoreboard_failed", + "scoreboard_invalid_phase": "scoreboard_failed", + "host_only_start_next_round": "next_round_failed", + "next_round_invalid_phase": "next_round_failed", + "host_only_finish_game": "finish_game_failed", + "finish_game_invalid_phase": "finish_game_failed" } } }