fix(gameplay): restore scoreboard phase error contract
Some checks failed
CI / test-and-quality (push) Failing after 2m30s
CI / test-and-quality (pull_request) Failing after 2m32s

This commit is contained in:
2026-03-15 08:52:35 +00:00
parent 97b366d1e9
commit 8fa39adc2b
4 changed files with 193 additions and 24 deletions

View File

@@ -782,7 +782,8 @@ class RevealRoundFlowTests(TestCase):
self.player_one = Player.objects.create(session=self.session, nickname="Luna", score=9) 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.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") self.client.login(username="host_reveal", password="secret123")
response = self.client.get( response = self.client.get(
@@ -796,6 +797,17 @@ class RevealRoundFlowTests(TestCase):
payload = response.json() payload = response.json()
self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD) self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD)
self.assertEqual([item["nickname"] for item in payload["leaderboard"]], ["Luna", "Mads"]) 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.session.refresh_from_db()
self.assertEqual(self.session.status, GameSession.Status.SCOREBOARD) self.assertEqual(self.session.status, GameSession.Status.SCOREBOARD)
@@ -807,11 +819,16 @@ class RevealRoundFlowTests(TestCase):
reverse( reverse(
"lobby:reveal_scoreboard", "lobby:reveal_scoreboard",
kwargs={"code": self.session.code}, kwargs={"code": self.session.code},
) ),
HTTP_ACCEPT_LANGUAGE="fr",
) )
self.assertEqual(response.status_code, 403) 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): def test_reveal_scoreboard_is_idempotent_in_scoreboard_phase(self):
self.session.status = GameSession.Status.SCOREBOARD self.session.status = GameSession.Status.SCOREBOARD
@@ -825,7 +842,8 @@ class RevealRoundFlowTests(TestCase):
self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD) self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD)
self.assertEqual([item["nickname"] for item in payload["leaderboard"]], ["Luna", "Mads"]) 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.login(username="host_reveal", password="secret123")
self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code}))
@@ -852,11 +870,16 @@ class RevealRoundFlowTests(TestCase):
reverse( reverse(
"lobby:finish_game", "lobby:finish_game",
kwargs={"code": self.session.code}, kwargs={"code": self.session.code},
) ),
HTTP_ACCEPT_LANGUAGE="da",
) )
self.assertEqual(response.status_code, 403) 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): def test_finish_game_rejects_wrong_phase(self):
self.client.login(username="host_reveal", password="secret123") self.client.login(username="host_reveal", password="secret123")
@@ -867,13 +890,19 @@ class RevealRoundFlowTests(TestCase):
reverse( reverse(
"lobby:finish_game", "lobby:finish_game",
kwargs={"code": self.session.code}, kwargs={"code": self.session.code},
) ),
HTTP_ACCEPT_LANGUAGE="fr",
) )
self.assertEqual(response.status_code, 400) 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.login(username="host_reveal", password="secret123")
self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) 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.status, GameSession.Status.LOBBY)
self.assertEqual(self.session.current_round, 2) 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") self.client.login(username="host_reveal", password="secret123")
first_response = self.client.get( first_response = self.client.get(
@@ -914,6 +964,7 @@ class RevealRoundFlowTests(TestCase):
self.assertEqual(first_response.json()["session"]["status"], GameSession.Status.SCOREBOARD) self.assertEqual(first_response.json()["session"]["status"], GameSession.Status.SCOREBOARD)
self.assertEqual(second_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([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): def test_start_next_round_rejects_wrong_phase(self):
self.client.login(username="host_reveal", password="secret123") self.client.login(username="host_reveal", password="secret123")
@@ -924,11 +975,16 @@ class RevealRoundFlowTests(TestCase):
reverse( reverse(
"lobby:start_next_round", "lobby:start_next_round",
kwargs={"code": self.session.code}, kwargs={"code": self.session.code},
) ),
HTTP_ACCEPT_LANGUAGE="da",
) )
self.assertEqual(response.status_code, 400) 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): class UiScreenTests(TestCase):
def setUp(self): def setUp(self):

View File

@@ -19,6 +19,7 @@ from fupogfakta.models import (
RoundQuestion, RoundQuestion,
ScoreEvent, ScoreEvent,
) )
from realtime.broadcast import sync_broadcast_phase_event
from .i18n import api_error, lobby_i18n_errors from .i18n import api_error, lobby_i18n_errors
@@ -707,17 +708,30 @@ def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse:
try: try:
session = GameSession.objects.get(code=session_code) session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist: 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: 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(): with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk) locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status not in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD}: 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.status = GameSession.Status.SCOREBOARD
locked_session.save(update_fields=["status"]) locked_session.save(update_fields=["status"])
@@ -727,6 +741,13 @@ def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse:
.values("id", "nickname", "score") .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( return JsonResponse(
{ {
"session": { "session": {
@@ -747,15 +768,27 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse:
try: try:
session = GameSession.objects.get(code=session_code) session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist: 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: 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(): with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk) locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status != GameSession.Status.SCOREBOARD: 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.current_round += 1
locked_session.status = GameSession.Status.LOBBY locked_session.status = GameSession.Status.LOBBY
@@ -779,15 +812,27 @@ def finish_game(request: HttpRequest, code: str) -> JsonResponse:
try: try:
session = GameSession.objects.get(code=session_code) session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist: 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: 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(): with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk) locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status != GameSession.Status.SCOREBOARD: 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.status = GameSession.Status.FINISHED
locked_session.save(update_fields=["status"]) locked_session.save(update_fields=["status"])

20
realtime/broadcast.py Normal file
View File

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

View File

@@ -39,6 +39,18 @@
"unknown": { "unknown": {
"en": "Action failed. Refresh status and try again.", "en": "Action failed. Refresh status and try again.",
"da": "Handlingen fejlede. Opdater status og prøv igen." "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": { "ui": {
@@ -296,7 +308,13 @@
"not_enough_answers_to_mix": "not_enough_answers_to_mix", "not_enough_answers_to_mix": "not_enough_answers_to_mix",
"host_only_start_round": "host_only_start_round", "host_only_start_round": "host_only_start_round",
"host_only_show_question": "host_only_show_question", "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": { "errors": {
"session_code_required": { "session_code_required": {
@@ -378,6 +396,30 @@
"host_only_mix_answers": { "host_only_mix_answers": {
"en": "Only host can mix answers", "en": "Only host can mix answers",
"da": "Kun værten kan blande svar" "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", "no_available_questions": "start_round_failed",
"mix_answers_invalid_phase": "start_round_failed", "mix_answers_invalid_phase": "start_round_failed",
"round_question_not_found": "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"
} }
} }
} }