fix(gameplay): restore scoreboard phase error contract
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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"])
|
||||
|
||||
20
realtime/broadcast.py
Normal file
20
realtime/broadcast.py
Normal 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)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user