From 173cc8f2d92c6541c31f14c77b87d884d31dc086 Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Fri, 13 Mar 2026 19:34:05 +0000 Subject: [PATCH 1/9] fix(gameplay): align scoreboard phase contract --- .../src/app/api-contract-smoke.spec.ts | 4 +- .../host/host-shell.component.spec.ts | 4 +- frontend/tests/angular-api-client.test.ts | 4 +- .../0005_gamesession_scoreboard_status.py | 26 ++ fupogfakta/models.py | 1 + lobby/tests.py | 139 +++++++- lobby/views.py | 239 ++++++------- shared/i18n/lobby.json | 326 +++++++++++++----- 8 files changed, 508 insertions(+), 235 deletions(-) create mode 100644 fupogfakta/migrations/0005_gamesession_scoreboard_status.py diff --git a/frontend/angular/src/app/api-contract-smoke.spec.ts b/frontend/angular/src/app/api-contract-smoke.spec.ts index 7be1d6a..166c272 100644 --- a/frontend/angular/src/app/api-contract-smoke.spec.ts +++ b/frontend/angular/src/app/api-contract-smoke.spec.ts @@ -50,7 +50,7 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => { if (url === '/lobby/sessions/ABCD12/scoreboard') { return { - session: { code: 'ABCD12', status: 'reveal', current_round: 1 }, + session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 }, leaderboard: [ { id: 9, nickname: 'Maja', score: 200 }, { id: 10, nickname: 'Bo', score: 150 } @@ -104,7 +104,7 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => { if (url === '/lobby/sessions/ABCD12/questions/77/scores/calculate') { expect(body).toEqual({}); return { - session: { code: 'ABCD12', status: 'reveal', current_round: 1 }, + session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 }, round_question: { id: 77, round_number: 1 }, events_created: 2, leaderboard: [ diff --git a/frontend/angular/src/app/features/host/host-shell.component.spec.ts b/frontend/angular/src/app/features/host/host-shell.component.spec.ts index 626db73..e29ff47 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.spec.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.spec.ts @@ -142,13 +142,13 @@ describe('HostShellComponent gameplay wiring', () => { .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 }))) .mockResolvedValueOnce( jsonResponse(200, { - session: { code: 'ABCD12', status: 'reveal', current_round: 1 }, + session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 }, round_question: { id: 77, round_number: 1 }, events_created: 2, leaderboard: [{ id: 1, nickname: 'Luna', score: 320 }], }) ) - .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('reveal', { roundQuestionId: 77 }))); + .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('scoreboard', { roundQuestionId: 77 }))); vi.stubGlobal('fetch', fetchMock); diff --git a/frontend/tests/angular-api-client.test.ts b/frontend/tests/angular-api-client.test.ts index 82c9336..c0b9eb5 100644 --- a/frontend/tests/angular-api-client.test.ts +++ b/frontend/tests/angular-api-client.test.ts @@ -210,7 +210,7 @@ describe('createAngularApiClient', () => { const get = vi.fn(async (url: string) => { if (url === '/lobby/sessions/ABCD12/scoreboard') { return { - session: { code: 'ABCD12', status: 'reveal', current_round: 1 }, + session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 }, leaderboard: [ { id: 2, nickname: 'Maja', score: 11 }, { id: 3, nickname: 'Bo', score: 7 } @@ -245,7 +245,7 @@ describe('createAngularApiClient', () => { if (url === '/lobby/sessions/ABCD12/questions/77/scores/calculate') { expect(body).toEqual({}); return { - session: { code: 'ABCD12', status: 'reveal', current_round: 1 }, + session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 }, round_question: { id: 77, round_number: 1 }, events_created: 3, leaderboard: [{ id: 2, nickname: 'Maja', score: 11 }] diff --git a/fupogfakta/migrations/0005_gamesession_scoreboard_status.py b/fupogfakta/migrations/0005_gamesession_scoreboard_status.py new file mode 100644 index 0000000..d49f650 --- /dev/null +++ b/fupogfakta/migrations/0005_gamesession_scoreboard_status.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("fupogfakta", "0004_player_session_token"), + ] + + operations = [ + migrations.AlterField( + model_name="gamesession", + name="status", + field=models.CharField( + choices=[ + ("lobby", "Lobby"), + ("lie", "Løgnfase"), + ("guess", "Gættefase"), + ("reveal", "Reveal"), + ("scoreboard", "Scoreboard"), + ("finished", "Afsluttet"), + ], + default="lobby", + max_length=16, + ), + ), + ] diff --git a/fupogfakta/models.py b/fupogfakta/models.py index 6a5a39d..8670349 100644 --- a/fupogfakta/models.py +++ b/fupogfakta/models.py @@ -42,6 +42,7 @@ class GameSession(models.Model): LIE = "lie", "Løgnfase" GUESS = "guess", "Gættefase" REVEAL = "reveal", "Reveal" + SCOREBOARD = "scoreboard", "Scoreboard" FINISHED = "finished", "Afsluttet" host = models.ForeignKey(User, on_delete=models.PROTECT, related_name="hosted_sessions") diff --git a/lobby/tests.py b/lobby/tests.py index 6b156f3..b68e85e 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -401,7 +401,9 @@ class LieSubmissionTests(TestCase): ) self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error"], "session_token is required") + self.assertEqual(response.json()["error_code"], "session_token_required") + self.assertEqual(response.json()["locale"], "en") + self.assertEqual(response.json()["error"], "Session token is required.") def test_submit_lie_rejects_invalid_session_token(self): round_question = RoundQuestion.objects.create( @@ -582,7 +584,9 @@ class GuessSubmissionTests(TestCase): ) self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error"], "Guess submission is only allowed in guess phase") + self.assertEqual(response.json()["error_code"], "guess_submission_invalid_phase") + self.assertEqual(response.json()["locale"], "en") + self.assertEqual(response.json()["error"], "Guess submission is only allowed in guess phase.") def test_submit_guess_rejects_unknown_answer(self): response = self.client.post( @@ -641,7 +645,7 @@ class GuessSubmissionTests(TestCase): ) self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error"], "session_token is required") + self.assertEqual(response.json()["error"], "Session token is required.") def test_submit_guess_rejects_invalid_session_token(self): response = self.client.post( @@ -686,7 +690,9 @@ class ScoreCalculationTests(TestCase): self.player_two = Player.objects.create(session=self.session, nickname="Mads") self.player_three = Player.objects.create(session=self.session, nickname="Nora") - def test_host_can_calculate_scores_and_transition_to_reveal(self): + def test_host_can_calculate_scores_and_transition_to_scoreboard(self): + LieAnswer.objects.create(round_question=self.round_question, player=self.player_three, text="Padel") + Guess.objects.create(round_question=self.round_question, player=self.player_one, selected_text="Tennis", is_correct=True) Guess.objects.create( round_question=self.round_question, @@ -713,8 +719,52 @@ class ScoreCalculationTests(TestCase): self.assertEqual(response.status_code, 200) payload = response.json() - self.assertEqual(payload["session"]["status"], GameSession.Status.REVEAL) + self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD) self.assertEqual(payload["events_created"], 2) + self.assertEqual(payload["reveal"]["round_question_id"], self.round_question.id) + self.assertEqual(payload["reveal"]["correct_answer"], "Tennis") + self.assertEqual( + payload["reveal"]["lies"], + [ + { + "player_id": self.player_three.id, + "nickname": "Nora", + "text": "Padel", + "created_at": payload["reveal"]["lies"][0]["created_at"], + } + ], + ) + self.assertEqual( + payload["reveal"]["guesses"], + [ + { + "player_id": self.player_one.id, + "nickname": "Luna", + "selected_text": "Tennis", + "is_correct": True, + "created_at": payload["reveal"]["guesses"][0]["created_at"], + "fooled_player_id": None, + }, + { + "player_id": self.player_two.id, + "nickname": "Mads", + "selected_text": "Padel", + "is_correct": False, + "created_at": payload["reveal"]["guesses"][1]["created_at"], + "fooled_player_id": self.player_three.id, + "fooled_player_nickname": "Nora", + }, + { + "player_id": self.player_three.id, + "nickname": "Nora", + "selected_text": "Padel", + "is_correct": False, + "created_at": payload["reveal"]["guesses"][2]["created_at"], + "fooled_player_id": self.player_three.id, + "fooled_player_nickname": "Nora", + }, + ], + ) self.player_one.refresh_from_db() self.player_three.refresh_from_db() @@ -722,7 +772,7 @@ class ScoreCalculationTests(TestCase): self.assertEqual(self.player_one.score, 5) self.assertEqual(self.player_three.score, 4) - self.assertEqual(self.session.status, GameSession.Status.REVEAL) + self.assertEqual(self.session.status, GameSession.Status.SCOREBOARD) def test_calculate_scores_requires_host(self): self.client.login(username="other_score", password="secret123") @@ -735,7 +785,7 @@ class ScoreCalculationTests(TestCase): ) self.assertEqual(response.status_code, 403) - self.assertEqual(response.json()["error"], "Only host can calculate scores") + self.assertEqual(response.json()["error"], "Only the host can calculate scores.") def test_calculate_scores_rejects_duplicate_calculation(self): Guess.objects.create(round_question=self.round_question, player=self.player_one, selected_text="Tennis", is_correct=True) @@ -756,14 +806,14 @@ class ScoreCalculationTests(TestCase): self.assertEqual(first.status_code, 200) self.assertEqual(second.status_code, 409) - self.assertEqual(second.json()["error"], "Scores already calculated for this round question") + self.assertEqual(second.json()["error"], "Scores have already been calculated for this round question.") class RevealRoundFlowTests(TestCase): def setUp(self): self.host = User.objects.create_user(username="host_reveal", password="secret123") self.other_user = User.objects.create_user(username="other_reveal", password="secret123") - self.session = GameSession.objects.create(host=self.host, code="RVL123", status=GameSession.Status.REVEAL) + self.session = GameSession.objects.create(host=self.host, code="RVL123", status=GameSession.Status.SCOREBOARD) 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) @@ -779,7 +829,7 @@ class RevealRoundFlowTests(TestCase): self.assertEqual(response.status_code, 200) payload = response.json() - self.assertEqual(payload["session"]["status"], GameSession.Status.REVEAL) + self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD) self.assertEqual([item["nickname"] for item in payload["leaderboard"]], ["Luna", "Mads"]) def test_reveal_scoreboard_requires_host(self): @@ -793,9 +843,9 @@ class RevealRoundFlowTests(TestCase): ) self.assertEqual(response.status_code, 403) - self.assertEqual(response.json()["error"], "Only host can view scoreboard") + self.assertEqual(response.json()["error"], "Only the host can view the scoreboard.") - def test_host_can_finish_game_from_reveal(self): + def test_host_can_finish_game_from_scoreboard(self): self.client.login(username="host_reveal", password="secret123") response = self.client.post( @@ -825,7 +875,7 @@ class RevealRoundFlowTests(TestCase): ) self.assertEqual(response.status_code, 403) - self.assertEqual(response.json()["error"], "Only host can finish game") + self.assertEqual(response.json()["error"], "Only the host can finish the game.") def test_finish_game_rejects_wrong_phase(self): self.client.login(username="host_reveal", password="secret123") @@ -840,9 +890,9 @@ class RevealRoundFlowTests(TestCase): ) self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error"], "Game can only be finished from reveal phase") + self.assertEqual(response.json()["error"], "Game can only be finished from scoreboard phase.") - def test_host_can_start_next_round_from_reveal(self): + def test_host_can_start_next_round_from_scoreboard(self): self.client.login(username="host_reveal", password="secret123") response = self.client.post( @@ -874,7 +924,7 @@ class RevealRoundFlowTests(TestCase): ) self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error"], "Next round can only start from reveal phase") + self.assertEqual(response.json()["error"], "Next round can only start from scoreboard phase.") class UiScreenTests(TestCase): def setUp(self): @@ -1149,7 +1199,51 @@ class SessionDetailRoundQuestionTests(TestCase): self.assertEqual(payload["round_question"]["id"], round_question.id) self.assertEqual(payload["round_question"]["prompt"], self.question.prompt) + def test_session_detail_includes_canonical_reveal_payload_in_reveal_phase(self): + self.session.status = GameSession.Status.REVEAL + self.session.save(update_fields=["status"]) + round_question = RoundQuestion.objects.create( + session=self.session, + round_number=1, + question=self.question, + correct_answer=self.question.correct_answer, + ) + liar = Player.objects.create(session=self.session, nickname="Løgnhals") + guesser = Player.objects.create(session=self.session, nickname="Detektiv") + correct_player = Player.objects.create(session=self.session, nickname="Sandhed") + LieAnswer.objects.create(round_question=round_question, player=liar, text="Tesla") + Guess.objects.create( + round_question=round_question, + player=guesser, + selected_text="Tesla", + is_correct=False, + fooled_player=liar, + ) + Guess.objects.create( + round_question=round_question, + player=correct_player, + selected_text="Edison", + is_correct=True, + ) + response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})) + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["reveal"]["round_question_id"], round_question.id) + self.assertEqual(payload["reveal"]["correct_answer"], "Edison") + self.assertEqual(payload["reveal"]["lies"][0]["player_id"], liar.id) + self.assertEqual(payload["reveal"]["lies"][0]["nickname"], "Løgnhals") + self.assertEqual(payload["reveal"]["lies"][0]["text"], "Tesla") + self.assertEqual(payload["reveal"]["guesses"][0]["player_id"], guesser.id) + self.assertEqual(payload["reveal"]["guesses"][0]["selected_text"], "Tesla") + self.assertFalse(payload["reveal"]["guesses"][0]["is_correct"]) + self.assertEqual(payload["reveal"]["guesses"][0]["fooled_player_id"], liar.id) + self.assertEqual(payload["reveal"]["guesses"][0]["fooled_player_nickname"], "Løgnhals") + self.assertEqual(payload["reveal"]["guesses"][1]["player_id"], correct_player.id) + self.assertEqual(payload["reveal"]["guesses"][1]["selected_text"], "Edison") + self.assertTrue(payload["reveal"]["guesses"][1]["is_correct"]) + self.assertIsNone(payload["reveal"]["guesses"][1]["fooled_player_id"]) class SessionDetailPhaseViewModelTests(TestCase): @@ -1218,10 +1312,19 @@ class SessionDetailPhaseViewModelTests(TestCase): reveal_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() reveal_phase = reveal_payload["phase_view_model"] self.assertTrue(reveal_phase["host"]["can_reveal_scoreboard"]) - self.assertTrue(reveal_phase["host"]["can_start_next_round"]) - self.assertTrue(reveal_phase["host"]["can_finish_game"]) + self.assertFalse(reveal_phase["host"]["can_start_next_round"]) + self.assertFalse(reveal_phase["host"]["can_finish_game"]) self.assertFalse(reveal_phase["player"]["can_view_final_result"]) + self.session.status = GameSession.Status.SCOREBOARD + self.session.save(update_fields=["status"]) + scoreboard_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() + scoreboard_phase = scoreboard_payload["phase_view_model"] + self.assertFalse(scoreboard_phase["host"]["can_reveal_scoreboard"]) + self.assertTrue(scoreboard_phase["host"]["can_start_next_round"]) + self.assertTrue(scoreboard_phase["host"]["can_finish_game"]) + self.assertFalse(scoreboard_phase["player"]["can_view_final_result"]) + self.session.status = GameSession.Status.FINISHED self.session.save(update_fields=["status"]) finished_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() diff --git a/lobby/views.py b/lobby/views.py index 0282fb2..b0747f2 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -34,6 +34,14 @@ JOINABLE_STATUSES = { ERROR_CODES = lobby_i18n_errors() +def _error_key(code: str) -> str: + return ERROR_CODES.get(code, code) + + +def _api_error(request: HttpRequest, *, code: str, status: int) -> JsonResponse: + return api_error(request, key=_error_key(code), status=status) + + def _json_body(request: HttpRequest) -> dict: if not request.body: return {} @@ -61,12 +69,61 @@ def _create_unique_session_code() -> str: raise RuntimeError("Could not generate unique session code") +def _build_player_ref(player: Player | None) -> dict | None: + if player is None: + return None + + return { + "player_id": player.id, + "nickname": player.nickname, + } + + + +def _build_reveal_payload(round_question: RoundQuestion | None) -> dict | None: + if round_question is None: + return None + + lies = [ + { + **_build_player_ref(lie.player), + "text": lie.text, + "created_at": lie.created_at.isoformat(), + } + for lie in round_question.lies.select_related("player").order_by("created_at", "id") + ] + + guesses = [] + for guess in round_question.guesses.select_related("player", "fooled_player").order_by("created_at", "id"): + guess_payload = { + **_build_player_ref(guess.player), + "selected_text": guess.selected_text, + "is_correct": guess.is_correct, + "created_at": guess.created_at.isoformat(), + "fooled_player_id": guess.fooled_player_id, + } + if guess.fooled_player is not None: + guess_payload["fooled_player_nickname"] = guess.fooled_player.nickname + guesses.append(guess_payload) + + return { + "round_question_id": round_question.id, + "round_number": round_question.round_number, + "prompt": round_question.question.prompt, + "correct_answer": round_question.correct_answer, + "lies": lies, + "guesses": guesses, + } + + + def _build_phase_view_model(session: GameSession, *, players_count: int, has_round_question: bool) -> dict: status = session.status in_lobby = status == GameSession.Status.LOBBY in_lie = status == GameSession.Status.LIE in_guess = status == GameSession.Status.GUESS in_reveal = status == GameSession.Status.REVEAL + in_scoreboard = status == GameSession.Status.SCOREBOARD in_finished = status == GameSession.Status.FINISHED min_players_reached = players_count >= 3 @@ -88,8 +145,8 @@ def _build_phase_view_model(session: GameSession, *, players_count: int, has_rou "can_mix_answers": in_lie or in_guess, "can_calculate_scores": in_guess, "can_reveal_scoreboard": in_reveal, - "can_start_next_round": in_reveal, - "can_finish_game": in_reveal, + "can_start_next_round": in_scoreboard, + "can_finish_game": in_scoreboard, }, "player": { "can_join": status in JOINABLE_STATUSES, @@ -127,41 +184,21 @@ def join_session(request: HttpRequest) -> JsonResponse: nickname = str(payload.get("nickname", "")).strip() if not code: - return api_error( - request, - key=ERROR_CODES.get("session_code_required", "session_code_required"), - status=400, - ) + return _api_error(request, code="session_code_required", status=400) if len(nickname) < 2 or len(nickname) > 40: - return api_error( - request, - key=ERROR_CODES.get("nickname_invalid", "nickname_invalid"), - status=400, - ) + return _api_error(request, code="nickname_invalid", status=400) try: session = GameSession.objects.get(code=code) except GameSession.DoesNotExist: - return api_error( - request, - key=ERROR_CODES.get("session_not_found", "session_not_found"), - status=404, - ) + return _api_error(request, code="session_not_found", status=404) if session.status not in JOINABLE_STATUSES: - return api_error( - request, - key=ERROR_CODES.get("session_not_joinable", "session_not_joinable"), - status=400, - ) + return _api_error(request, code="session_not_joinable", status=400) if Player.objects.filter(session=session, nickname__iexact=nickname).exists(): - return api_error( - request, - key=ERROR_CODES.get("nickname_taken", "nickname_taken"), - status=409, - ) + return _api_error(request, code="nickname_taken", status=409) player = Player.objects.create(session=session, nickname=nickname) @@ -189,11 +226,7 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse: try: session = GameSession.objects.get(code=session_code) except GameSession.DoesNotExist: - return api_error( - request, - key=ERROR_CODES.get("session_not_found", "session_not_found"), - status=404, - ) + return _api_error(request, code="session_not_found", status=404) players = list( session.players.order_by("nickname").values( @@ -238,6 +271,9 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse: }, "players": players, "round_question": round_question_payload, + "reveal": _build_reveal_payload(current_round_question) + if session.status == GameSession.Status.REVEAL and current_round_question + else None, "phase_view_model": phase_view_model, } ) @@ -250,22 +286,14 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse: category_slug = str(payload.get("category_slug", "")).strip() if not category_slug: - return api_error( - request, - key=ERROR_CODES.get("category_slug_required", "category_slug_required"), - status=400, - ) + return _api_error(request, code="category_slug_required", status=400) session_code = _normalize_session_code(code) try: session = GameSession.objects.get(code=session_code) except GameSession.DoesNotExist: - return api_error( - request, - key=ERROR_CODES.get("session_not_found", "session_not_found"), - status=404, - ) + return _api_error(request, code="session_not_found", status=404) if session.host_id != request.user.id: return api_error( @@ -348,11 +376,7 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse: try: session = GameSession.objects.get(code=session_code) except GameSession.DoesNotExist: - return api_error( - request, - key=ERROR_CODES.get("session_not_found", "session_not_found"), - status=404, - ) + return _api_error(request, code="session_not_found", status=404) if session.host_id != request.user.id: return api_error( @@ -434,29 +458,29 @@ def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonR lie_text = str(payload.get("text", "")).strip() if not player_id: - return JsonResponse({"error": "player_id is required"}, status=400) + return _api_error(request, code="player_id_required", status=400) if not session_token: - return JsonResponse({"error": "session_token is required"}, status=400) + return _api_error(request, code="session_token_required", status=400) if not lie_text or len(lie_text) > 255: - return JsonResponse({"error": "text must be between 1 and 255 characters"}, status=400) + return _api_error(request, code="lie_text_invalid", status=400) try: session = GameSession.objects.get(code=session_code) except GameSession.DoesNotExist: - return JsonResponse({"error": "Session not found"}, status=404) + return _api_error(request, code="session_not_found", status=404) if session.status != GameSession.Status.LIE: - return JsonResponse({"error": "Lie submission is only allowed in lie phase"}, status=400) + return _api_error(request, code="lie_submission_invalid_phase", status=400) try: player = Player.objects.get(pk=player_id, session=session) except Player.DoesNotExist: - return JsonResponse({"error": "Player not found in session"}, status=404) + return _api_error(request, code="player_not_found_in_session", status=404) if player.session_token != session_token: - return JsonResponse({"error": "Invalid player session token"}, status=403) + return _api_error(request, code="invalid_player_session_token", status=403) try: round_question = RoundQuestion.objects.get( @@ -465,21 +489,21 @@ def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonR round_number=session.current_round, ) except RoundQuestion.DoesNotExist: - return JsonResponse({"error": "Round question not found"}, status=404) + return _api_error(request, code="round_question_not_found", status=404) try: round_config = RoundConfig.objects.get(session=session, number=round_question.round_number) except RoundConfig.DoesNotExist: - return JsonResponse({"error": "Round config missing"}, status=400) + return _api_error(request, code="round_config_missing", status=400) lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds) if timezone.now() > lie_deadline_at: - return JsonResponse({"error": "Lie submission window has closed"}, status=400) + return _api_error(request, code="lie_submission_window_closed", status=400) try: lie = LieAnswer.objects.create(round_question=round_question, player=player, text=lie_text) except IntegrityError: - return JsonResponse({"error": "Lie already submitted for this player"}, status=409) + return _api_error(request, code="lie_already_submitted", status=409) return JsonResponse( { @@ -505,25 +529,13 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json try: session = GameSession.objects.get(code=session_code) except GameSession.DoesNotExist: - return api_error( - request, - key=ERROR_CODES.get("session_not_found", "session_not_found"), - status=404, - ) + return _api_error(request, code="session_not_found", status=404) if session.host_id != request.user.id: - return api_error( - request, - key=ERROR_CODES.get("host_only_mix_answers", "host_only_mix_answers"), - status=403, - ) + return _api_error(request, code="host_only_mix_answers", status=403) if session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}: - return api_error( - request, - key=ERROR_CODES.get("mix_answers_invalid_phase", "mix_answers_invalid_phase"), - status=400, - ) + return _api_error(request, code="mix_answers_invalid_phase", status=400) try: round_question = RoundQuestion.objects.get( @@ -532,11 +544,7 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json round_number=session.current_round, ) except RoundQuestion.DoesNotExist: - return api_error( - request, - key=ERROR_CODES.get("round_question_not_found", "round_question_not_found"), - status=404, - ) + return _api_error(request, code="round_question_not_found", status=404) with transaction.atomic(): locked_session = GameSession.objects.select_for_update().get(pk=session.pk) @@ -561,11 +569,7 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json deduped_answers.append(text.strip()) if len(deduped_answers) < 2: - return api_error( - request, - key=ERROR_CODES.get("not_enough_answers_to_mix", "not_enough_answers_to_mix"), - status=400, - ) + return _api_error(request, code="not_enough_answers_to_mix", status=400) random.shuffle(deduped_answers) locked_round_question.mixed_answers = deduped_answers @@ -601,29 +605,29 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso selected_text = str(payload.get("selected_text", "")).strip() if not player_id: - return JsonResponse({"error": "player_id is required"}, status=400) + return _api_error(request, code="player_id_required", status=400) if not session_token: - return JsonResponse({"error": "session_token is required"}, status=400) + return _api_error(request, code="session_token_required", status=400) if not selected_text or len(selected_text) > 255: - return JsonResponse({"error": "selected_text must be between 1 and 255 characters"}, status=400) + return _api_error(request, code="selected_text_invalid", status=400) try: session = GameSession.objects.get(code=session_code) except GameSession.DoesNotExist: - return JsonResponse({"error": "Session not found"}, status=404) + return _api_error(request, code="session_not_found", status=404) if session.status != GameSession.Status.GUESS: - return JsonResponse({"error": "Guess submission is only allowed in guess phase"}, status=400) + return _api_error(request, code="guess_submission_invalid_phase", status=400) try: player = Player.objects.get(pk=player_id, session=session) except Player.DoesNotExist: - return JsonResponse({"error": "Player not found in session"}, status=404) + return _api_error(request, code="player_not_found_in_session", status=404) if player.session_token != session_token: - return JsonResponse({"error": "Invalid player session token"}, status=403) + return _api_error(request, code="invalid_player_session_token", status=403) try: round_question = RoundQuestion.objects.get( @@ -632,18 +636,18 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso round_number=session.current_round, ) except RoundQuestion.DoesNotExist: - return JsonResponse({"error": "Round question not found"}, status=404) + return _api_error(request, code="round_question_not_found", status=404) try: round_config = RoundConfig.objects.get(session=session, number=round_question.round_number) except RoundConfig.DoesNotExist: - return JsonResponse({"error": "Round config missing"}, status=400) + return _api_error(request, code="round_config_missing", status=400) guess_deadline_at = round_question.shown_at + timedelta( seconds=round_config.lie_seconds + round_config.guess_seconds ) if timezone.now() > guess_deadline_at: - return JsonResponse({"error": "Guess submission window has closed"}, status=400) + return _api_error(request, code="guess_submission_window_closed", status=400) allowed_answers = { round_question.correct_answer.strip().casefold(), @@ -656,7 +660,7 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso selected_normalized = selected_text.casefold() if selected_normalized not in allowed_answers: - return JsonResponse({"error": "Selected answer is not part of this round"}, status=400) + return _api_error(request, code="selected_answer_invalid", status=400) correct_normalized = round_question.correct_answer.strip().casefold() fooled_player_id = None @@ -674,7 +678,7 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso fooled_player_id=fooled_player_id, ) except IntegrityError: - return JsonResponse({"error": "Guess already submitted for this player"}, status=409) + return _api_error(request, code="guess_already_submitted", status=409) return JsonResponse( { @@ -705,13 +709,13 @@ 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, code="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, code="host_only_view_scoreboard", status=403) - if session.status != GameSession.Status.REVEAL: - return JsonResponse({"error": "Scoreboard is only available in reveal phase"}, status=400) + if session.status != GameSession.Status.SCOREBOARD: + return _api_error(request, code="scoreboard_invalid_phase", status=400) leaderboard = list( Player.objects.filter(session=session) @@ -739,15 +743,15 @@ 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, code="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, code="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.REVEAL: - return JsonResponse({"error": "Next round can only start from reveal phase"}, status=400) + if locked_session.status != GameSession.Status.SCOREBOARD: + return _api_error(request, code="start_next_round_invalid_phase", status=400) locked_session.current_round += 1 locked_session.status = GameSession.Status.LOBBY @@ -771,15 +775,15 @@ 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, code="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, code="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.REVEAL: - return JsonResponse({"error": "Game can only be finished from reveal phase"}, status=400) + if locked_session.status != GameSession.Status.SCOREBOARD: + return _api_error(request, code="finish_game_invalid_phase", status=400) locked_session.status = GameSession.Status.FINISHED locked_session.save(update_fields=["status"]) @@ -813,20 +817,20 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) -> try: session = GameSession.objects.get(code=session_code) except GameSession.DoesNotExist: - return JsonResponse({"error": "Session not found"}, status=404) + return _api_error(request, code="session_not_found", status=404) if session.host_id != request.user.id: - return JsonResponse({"error": "Only host can calculate scores"}, status=403) + return _api_error(request, code="host_only_calculate_scores", status=403) already_calculated = ScoreEvent.objects.filter( session=session, meta__round_question_id=round_question_id, ).exists() if already_calculated: - return JsonResponse({"error": "Scores already calculated for this round question"}, status=409) + return _api_error(request, code="scores_already_calculated", status=409) if session.status != GameSession.Status.GUESS: - return JsonResponse({"error": "Scores can only be calculated in guess phase"}, status=400) + return _api_error(request, code="calculate_scores_invalid_phase", status=400) try: round_question = RoundQuestion.objects.get( @@ -835,16 +839,16 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) -> round_number=session.current_round, ) except RoundQuestion.DoesNotExist: - return JsonResponse({"error": "Round question not found"}, status=404) + return _api_error(request, code="round_question_not_found", status=404) try: round_config = RoundConfig.objects.get(session=session, number=round_question.round_number) except RoundConfig.DoesNotExist: - return JsonResponse({"error": "Round config missing"}, status=400) + return _api_error(request, code="round_config_missing", status=400) guesses = list(round_question.guesses.select_related("player")) if not guesses: - return JsonResponse({"error": "No guesses submitted for this round question"}, status=400) + return _api_error(request, code="no_guesses_submitted", status=400) bluff_counts = {} for guess in guesses: @@ -854,7 +858,7 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) -> with transaction.atomic(): locked_session = GameSession.objects.select_for_update().get(pk=session.pk) if locked_session.status != GameSession.Status.GUESS: - return JsonResponse({"error": "Scores can only be calculated in guess phase"}, status=400) + return _api_error(request, code="calculate_scores_invalid_phase", status=400) score_events = [] @@ -889,7 +893,7 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) -> ScoreEvent.objects.bulk_create(score_events) - locked_session.status = GameSession.Status.REVEAL + locked_session.status = GameSession.Status.SCOREBOARD locked_session.save(update_fields=["status"]) leaderboard = list( @@ -902,13 +906,14 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) -> { "session": { "code": session.code, - "status": GameSession.Status.REVEAL, + "status": GameSession.Status.SCOREBOARD, "current_round": session.current_round, }, "round_question": { "id": round_question.id, "round_number": round_question.round_number, }, + "reveal": _build_reveal_payload(round_question), "events_created": len(score_events), "leaderboard": leaderboard, } diff --git a/shared/i18n/lobby.json b/shared/i18n/lobby.json index c7cf10f..6485467 100644 --- a/shared/i18n/lobby.json +++ b/shared/i18n/lobby.json @@ -277,36 +277,203 @@ }, "backend": { "error_codes": { - "session_code_required": "session_code_required", + "calculate_scores_invalid_phase": "calculate_scores_invalid_phase", + "category_has_no_questions": "category_has_no_questions", + "category_not_found": "category_not_found", + "category_slug_required": "category_slug_required", + "finish_game_invalid_phase": "finish_game_invalid_phase", + "guess_already_submitted": "guess_already_submitted", + "guess_submission_invalid_phase": "guess_submission_invalid_phase", + "guess_submission_window_closed": "guess_submission_window_closed", + "host_only_calculate_scores": "host_only_calculate_scores", + "host_only_finish_game": "host_only_finish_game", + "host_only_mix_answers": "host_only_mix_answers", + "host_only_show_question": "host_only_show_question", + "host_only_start_next_round": "host_only_start_next_round", + "host_only_start_round": "host_only_start_round", + "host_only_view_scoreboard": "host_only_view_scoreboard", + "invalid_player_session_token": "invalid_player_session_token", + "lie_already_submitted": "lie_already_submitted", + "lie_submission_invalid_phase": "lie_submission_invalid_phase", + "lie_submission_window_closed": "lie_submission_window_closed", + "lie_text_invalid": "lie_text_invalid", + "mix_answers_invalid_phase": "mix_answers_invalid_phase", "nickname_invalid": "nickname_invalid", + "nickname_taken": "nickname_taken", + "no_available_questions": "no_available_questions", + "no_guesses_submitted": "no_guesses_submitted", + "not_enough_answers_to_mix": "not_enough_answers_to_mix", + "player_id_required": "player_id_required", + "player_not_found_in_session": "player_not_found_in_session", + "question_already_shown": "question_already_shown", + "round_already_configured": "round_already_configured", + "round_config_missing": "round_config_missing", + "round_question_not_found": "round_question_not_found", + "round_start_invalid_phase": "round_start_invalid_phase", + "scoreboard_invalid_phase": "scoreboard_invalid_phase", + "scores_already_calculated": "scores_already_calculated", + "selected_answer_invalid": "selected_answer_invalid", + "selected_text_invalid": "selected_text_invalid", + "session_code_required": "session_code_required", "session_not_found": "session_not_found", "session_not_joinable": "session_not_joinable", - "nickname_taken": "nickname_taken", - "category_slug_required": "category_slug_required", - "category_not_found": "category_not_found", - "round_start_invalid_phase": "round_start_invalid_phase", - "round_already_configured": "round_already_configured", - "category_has_no_questions": "category_has_no_questions", + "session_token_required": "session_token_required", "show_question_invalid_phase": "show_question_invalid_phase", - "round_config_missing": "round_config_missing", - "question_already_shown": "question_already_shown", - "no_available_questions": "no_available_questions", - "mix_answers_invalid_phase": "mix_answers_invalid_phase", - "round_question_not_found": "round_question_not_found", - "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" + "start_next_round_invalid_phase": "start_next_round_invalid_phase" }, "errors": { - "session_code_required": { - "en": "Session code is required", - "da": "Sessionskode er påkrævet" + "calculate_scores_invalid_phase": { + "en": "Scores can only be calculated in guess phase.", + "da": "Score kan kun udregnes i gættefasen." + }, + "category_has_no_questions": { + "en": "Category has no active questions", + "da": "Kategorien har ingen aktive spørgsmål" + }, + "category_not_found": { + "en": "Category not found", + "da": "Kategori blev ikke fundet" + }, + "category_slug_required": { + "en": "category_slug is required", + "da": "category_slug er påkrævet" + }, + "finish_game_invalid_phase": { + "en": "Game can only be finished from scoreboard phase.", + "da": "Spillet kan kun afsluttes fra scoreboard-fasen." + }, + "guess_already_submitted": { + "en": "Guess has already been submitted for this player.", + "da": "Gættet er allerede indsendt for denne spiller." + }, + "guess_submission_invalid_phase": { + "en": "Guess submission is only allowed in guess phase.", + "da": "Gæt kan kun sendes i gættefasen." + }, + "guess_submission_window_closed": { + "en": "Guess submission window has closed.", + "da": "Vinduet for gætindsendelse er lukket." + }, + "host_only_calculate_scores": { + "en": "Only the host can calculate scores.", + "da": "Kun værten kan udregne score." + }, + "host_only_finish_game": { + "en": "Only the host can finish the game.", + "da": "Kun værten kan afslutte spillet." + }, + "host_only_mix_answers": { + "en": "Only host can mix answers", + "da": "Kun værten kan blande svar" + }, + "host_only_show_question": { + "en": "Only host can show question", + "da": "Kun værten kan vise spørgsmålet" + }, + "host_only_start_next_round": { + "en": "Only the host can start the next round.", + "da": "Kun værten kan starte næste runde." + }, + "host_only_start_round": { + "en": "Only host can start round", + "da": "Kun værten kan starte runden" + }, + "host_only_view_scoreboard": { + "en": "Only the host can view the scoreboard.", + "da": "Kun værten kan se scoreboardet." + }, + "invalid_player_session_token": { + "en": "Player session token is invalid.", + "da": "Spillerens session-token er ugyldigt." + }, + "lie_already_submitted": { + "en": "Lie has already been submitted for this player.", + "da": "Løgnen er allerede indsendt for denne spiller." + }, + "lie_submission_invalid_phase": { + "en": "Lie submission is only allowed in lie phase.", + "da": "Løgn kan kun sendes i løgnefasen." + }, + "lie_submission_window_closed": { + "en": "Lie submission window has closed.", + "da": "Vinduet for løgnindsendelse er lukket." + }, + "lie_text_invalid": { + "en": "Text must be between 1 and 255 characters.", + "da": "Tekst skal være mellem 1 og 255 tegn." + }, + "mix_answers_invalid_phase": { + "en": "Answers can only be mixed in lie or guess phase", + "da": "Svar kan kun blandes i løgne- eller gættefasen" }, "nickname_invalid": { "en": "Nickname must be between 2 and 40 characters", "da": "Kaldenavn skal være mellem 2 og 40 tegn" }, + "nickname_taken": { + "en": "Nickname already taken", + "da": "Kaldenavnet er allerede taget" + }, + "no_available_questions": { + "en": "No available questions in category", + "da": "Ingen tilgængelige spørgsmål i kategorien" + }, + "no_guesses_submitted": { + "en": "No guesses have been submitted for this round question.", + "da": "Der er ikke indsendt gæt for dette rundespørgsmål." + }, + "not_enough_answers_to_mix": { + "en": "Not enough answers to mix", + "da": "Ikke nok svar at blande" + }, + "player_id_required": { + "en": "Player id is required.", + "da": "Spiller-id er påkrævet." + }, + "player_not_found_in_session": { + "en": "Player was not found in this session.", + "da": "Spilleren blev ikke fundet i denne session." + }, + "question_already_shown": { + "en": "Question already shown for this round", + "da": "Spørgsmålet er allerede vist for denne runde" + }, + "round_already_configured": { + "en": "Round already configured", + "da": "Runden er allerede konfigureret" + }, + "round_config_missing": { + "en": "Round config missing", + "da": "Rundekonfiguration mangler" + }, + "round_question_not_found": { + "en": "Round question not found", + "da": "Rundespørgsmål blev ikke fundet" + }, + "round_start_invalid_phase": { + "en": "Round can only be started from lobby", + "da": "Runden kan kun startes fra lobbyen" + }, + "scoreboard_invalid_phase": { + "en": "Scoreboard is only available in scoreboard phase.", + "da": "Scoreboard er kun tilgængeligt i scoreboard-fasen." + }, + "scores_already_calculated": { + "en": "Scores have already been calculated for this round question.", + "da": "Score er allerede udregnet for dette rundespørgsmål." + }, + "selected_answer_invalid": { + "en": "Selected answer is not part of this round.", + "da": "Det valgte svar er ikke en del af denne runde." + }, + "selected_text_invalid": { + "en": "Selected text must be between 1 and 255 characters.", + "da": "Valgt tekst skal være mellem 1 og 255 tegn." + }, + "session_code_required": { + "en": "Session code is required", + "da": "Sessionskode er påkrævet" + }, "session_not_found": { "en": "Session not found", "da": "Session blev ikke fundet" @@ -315,69 +482,17 @@ "en": "Session is not joinable", "da": "Sessionen kan ikke joine nu" }, - "nickname_taken": { - "en": "Nickname already taken", - "da": "Kaldenavnet er allerede taget" - }, - "category_slug_required": { - "en": "category_slug is required", - "da": "category_slug er påkrævet" - }, - "category_not_found": { - "en": "Category not found", - "da": "Kategori blev ikke fundet" - }, - "round_start_invalid_phase": { - "en": "Round can only be started from lobby", - "da": "Runden kan kun startes fra lobbyen" - }, - "round_already_configured": { - "en": "Round already configured", - "da": "Runden er allerede konfigureret" - }, - "category_has_no_questions": { - "en": "Category has no active questions", - "da": "Kategorien har ingen aktive spørgsmål" + "session_token_required": { + "en": "Session token is required.", + "da": "Session-token er påkrævet." }, "show_question_invalid_phase": { "en": "Question can only be shown in lie phase", "da": "Spørgsmålet kan kun vises i løgnefasen" }, - "round_config_missing": { - "en": "Round config missing", - "da": "Rundekonfiguration mangler" - }, - "question_already_shown": { - "en": "Question already shown for this round", - "da": "Spørgsmålet er allerede vist for denne runde" - }, - "no_available_questions": { - "en": "No available questions in category", - "da": "Ingen tilgængelige spørgsmål i kategorien" - }, - "mix_answers_invalid_phase": { - "en": "Answers can only be mixed in lie or guess phase", - "da": "Svar kan kun blandes i løgne- eller gættefasen" - }, - "round_question_not_found": { - "en": "Round question not found", - "da": "Rundespørgsmål blev ikke fundet" - }, - "not_enough_answers_to_mix": { - "en": "Not enough answers to mix", - "da": "Ikke nok svar at blande" - }, - "host_only_start_round": { - "en": "Only host can start round", - "da": "Kun værten kan starte runden" - }, - "host_only_show_question": { - "en": "Only host can show question", - "da": "Kun værten kan vise spørgsmålet" - }, - "host_only_mix_answers": { - "en": "Only host can mix answers", - "da": "Kun værten kan blande svar" + "start_next_round_invalid_phase": { + "en": "Next round can only start from scoreboard phase.", + "da": "Næste runde kan kun starte fra scoreboard-fasen." } } }, @@ -396,27 +511,50 @@ "fallback": "Use default locale when requested locale is unsupported or key translation is missing." }, "backend_to_frontend_error_keys": { - "session_code_required": "session_code_required", + "calculate_scores_invalid_phase": "unknown", + "category_has_no_questions": "start_round_failed", + "category_not_found": "start_round_failed", + "category_slug_required": "start_round_failed", + "finish_game_invalid_phase": "unknown", + "guess_already_submitted": "unknown", + "guess_submission_invalid_phase": "unknown", + "guess_submission_window_closed": "unknown", + "host_only_action": "start_round_failed", + "host_only_calculate_scores": "unknown", + "host_only_finish_game": "unknown", + "host_only_mix_answers": "start_round_failed", + "host_only_show_question": "start_round_failed", + "host_only_start_next_round": "unknown", + "host_only_start_round": "start_round_failed", + "host_only_view_scoreboard": "unknown", + "invalid_player_session_token": "unknown", + "lie_already_submitted": "unknown", + "lie_submission_invalid_phase": "unknown", + "lie_submission_window_closed": "unknown", + "lie_text_invalid": "unknown", + "mix_answers_invalid_phase": "start_round_failed", "nickname_invalid": "nickname_invalid", + "nickname_taken": "nickname_taken", + "no_available_questions": "start_round_failed", + "no_guesses_submitted": "unknown", + "not_enough_answers_to_mix": "start_round_failed", + "player_id_required": "unknown", + "player_not_found_in_session": "unknown", + "question_already_shown": "start_round_failed", + "round_already_configured": "start_round_failed", + "round_config_missing": "start_round_failed", + "round_question_not_found": "start_round_failed", + "round_start_invalid_phase": "start_round_failed", + "scoreboard_invalid_phase": "unknown", + "scores_already_calculated": "unknown", + "selected_answer_invalid": "unknown", + "selected_text_invalid": "unknown", + "session_code_required": "session_code_required", "session_not_found": "session_not_found", "session_not_joinable": "join_failed", - "nickname_taken": "nickname_taken", - "category_slug_required": "start_round_failed", - "category_not_found": "start_round_failed", - "round_start_invalid_phase": "start_round_failed", - "round_already_configured": "start_round_failed", - "host_only_start_round": "start_round_failed", - "host_only_show_question": "start_round_failed", - "host_only_mix_answers": "start_round_failed", - "host_only_action": "start_round_failed", - "category_has_no_questions": "start_round_failed", + "session_token_required": "unknown", "show_question_invalid_phase": "start_round_failed", - "round_config_missing": "start_round_failed", - "question_already_shown": "start_round_failed", - "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" + "start_next_round_invalid_phase": "unknown" } } } -- 2.39.5 From 3f20f259029d7336af7f61b7df12750004218931 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Sun, 15 Mar 2026 11:46:30 +0000 Subject: [PATCH 2/9] fix: expose canonical reveal payload in scoreboard detail --- lobby/tests.py | 41 +++++++++++++++++++++++++++++++++++++++++ lobby/views.py | 2 +- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/lobby/tests.py b/lobby/tests.py index b68e85e..140a831 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -1245,6 +1245,47 @@ class SessionDetailRoundQuestionTests(TestCase): self.assertTrue(payload["reveal"]["guesses"][1]["is_correct"]) self.assertIsNone(payload["reveal"]["guesses"][1]["fooled_player_id"]) + def test_session_detail_includes_canonical_reveal_payload_in_scoreboard_phase(self): + self.session.status = GameSession.Status.SCOREBOARD + self.session.save(update_fields=["status"]) + round_question = RoundQuestion.objects.create( + session=self.session, + round_number=1, + question=self.question, + correct_answer=self.question.correct_answer, + ) + liar = Player.objects.create(session=self.session, nickname="Løgnhals") + guesser = Player.objects.create(session=self.session, nickname="Detektiv") + correct_player = Player.objects.create(session=self.session, nickname="Sandhed") + LieAnswer.objects.create(round_question=round_question, player=liar, text="Tesla") + Guess.objects.create( + round_question=round_question, + player=guesser, + selected_text="Tesla", + is_correct=False, + fooled_player=liar, + ) + Guess.objects.create( + round_question=round_question, + player=correct_player, + selected_text="Edison", + is_correct=True, + ) + + response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})) + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD) + self.assertEqual(payload["reveal"]["round_question_id"], round_question.id) + self.assertEqual(payload["reveal"]["correct_answer"], "Edison") + self.assertEqual(payload["reveal"]["lies"][0]["player_id"], liar.id) + self.assertEqual(payload["reveal"]["guesses"][0]["player_id"], guesser.id) + self.assertEqual(payload["reveal"]["guesses"][0]["fooled_player_id"], liar.id) + self.assertEqual(payload["reveal"]["guesses"][0]["fooled_player_nickname"], "Løgnhals") + self.assertTrue(payload["reveal"]["guesses"][1]["is_correct"]) + self.assertIsNone(payload["reveal"]["guesses"][1]["fooled_player_id"]) + class SessionDetailPhaseViewModelTests(TestCase): def setUp(self): diff --git a/lobby/views.py b/lobby/views.py index b0747f2..2648a3e 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -272,7 +272,7 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse: "players": players, "round_question": round_question_payload, "reveal": _build_reveal_payload(current_round_question) - if session.status == GameSession.Status.REVEAL and current_round_question + if session.status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD} and current_round_question else None, "phase_view_model": phase_view_model, } -- 2.39.5 From a80b1ee3541a4013707f374c360924562c53bcbf Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Sun, 15 Mar 2026 11:54:39 +0000 Subject: [PATCH 3/9] test(gameplay): align guess error contract assertions --- lobby/tests.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lobby/tests.py b/lobby/tests.py index 140a831..c6c967d 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -599,7 +599,8 @@ class GuessSubmissionTests(TestCase): ) self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error"], "Selected answer is not part of this round") + self.assertEqual(response.json()["error_code"], "selected_answer_invalid") + self.assertEqual(response.json()["error"], "Selected answer is not part of this round.") def test_submit_guess_rejects_duplicate_submission(self): Guess.objects.create(round_question=self.round_question, player=self.player, selected_text="Mars", is_correct=True) @@ -614,7 +615,8 @@ class GuessSubmissionTests(TestCase): ) self.assertEqual(response.status_code, 409) - self.assertEqual(response.json()["error"], "Guess already submitted for this player") + self.assertEqual(response.json()["error_code"], "guess_already_submitted") + self.assertEqual(response.json()["error"], "Guess has already been submitted for this player.") def test_submit_guess_rejects_after_deadline(self): self.round_question.shown_at = timezone.now() - timedelta(seconds=76) @@ -630,7 +632,8 @@ class GuessSubmissionTests(TestCase): ) self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error"], "Guess submission window has closed") + self.assertEqual(response.json()["error_code"], "guess_submission_window_closed") + self.assertEqual(response.json()["error"], "Guess submission window has closed.") @@ -658,7 +661,8 @@ class GuessSubmissionTests(TestCase): ) self.assertEqual(response.status_code, 403) - self.assertEqual(response.json()["error"], "Invalid player session token") + self.assertEqual(response.json()["error_code"], "invalid_player_session_token") + self.assertEqual(response.json()["error"], "Player session token is invalid.") class ScoreCalculationTests(TestCase): -- 2.39.5 From f0e87eb98877601f4c4fa285ce1833258ac6e6d1 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Sun, 15 Mar 2026 12:29:14 +0000 Subject: [PATCH 4/9] feat: expose canonical reveal payload in SPA refs #289 parent #287 --- .../src/app/api-contract-smoke.spec.ts | 20 ++++ .../host/host-shell.component.spec.ts | 80 ++++++++++++++- .../app/features/host/host-shell.component.ts | 30 ++++-- .../player/player-shell.component.spec.ts | 97 ++++++++++++++++++- .../features/player/player-shell.component.ts | 32 ++++-- frontend/src/api/mappers.ts | 51 ++++++++++ frontend/src/api/types.ts | 27 ++++++ 7 files changed, 323 insertions(+), 14 deletions(-) diff --git a/frontend/angular/src/app/api-contract-smoke.spec.ts b/frontend/angular/src/app/api-contract-smoke.spec.ts index 166c272..78a5fff 100644 --- a/frontend/angular/src/app/api-contract-smoke.spec.ts +++ b/frontend/angular/src/app/api-contract-smoke.spec.ts @@ -19,6 +19,24 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => { shown_at: '2026-03-01T18:00:00Z', answers: [{ text: 'A' }, { text: 'B' }] }, + reveal: { + round_question_id: 77, + round_number: 1, + prompt: 'Q?', + correct_answer: 'A', + lies: [{ player_id: 2, nickname: 'Maja', text: 'B', created_at: '2026-03-01T18:00:05Z' }], + guesses: [ + { + player_id: 3, + nickname: 'Bo', + selected_text: 'B', + is_correct: false, + fooled_player_id: 2, + fooled_player_nickname: 'Maja', + created_at: '2026-03-01T18:00:15Z' + } + ] + }, phase_view_model: { status: 'lobby', round_number: 1, @@ -172,6 +190,8 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => { expect(session.data.session.code).toBe('ABCD12'); expect(session.data.phase_view_model.host.can_start_next_round).toBe(true); expect(session.data.phase_view_model.player.can_submit_guess).toBe(true); + expect(session.data.reveal?.correct_answer).toBe('A'); + expect(session.data.reveal?.guesses[0].fooled_player_nickname).toBe('Maja'); } expect((await client.joinSession({ code: ' abcd12 ', nickname: ' Maja ' })).ok).toBe(true); diff --git a/frontend/angular/src/app/features/host/host-shell.component.spec.ts b/frontend/angular/src/app/features/host/host-shell.component.spec.ts index e29ff47..b6f54b8 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.spec.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.spec.ts @@ -12,7 +12,26 @@ function jsonResponse(status: number, body: unknown) { } as unknown as Response; } -function sessionDetailPayload(status: string, options?: { roundQuestionId?: number | null }) { +function sessionDetailPayload( + status: string, + options?: { + roundQuestionId?: number | null; + reveal?: { + correct_answer: string; + prompt?: string; + lies?: Array<{ player_id: number; nickname: string; text: string; created_at?: string }>; + guesses?: Array<{ + player_id: number; + nickname: string; + selected_text: string; + is_correct: boolean; + fooled_player_id: number | null; + fooled_player_nickname?: string; + created_at?: string; + }>; + } | null; + } +) { const roundQuestionId = options?.roundQuestionId ?? 41; return { @@ -37,6 +56,23 @@ function sessionDetailPayload(status: string, options?: { roundQuestionId?: numb { id: 1, nickname: 'Host', score: 0, is_connected: true }, { id: 2, nickname: 'Mads', score: 120, is_connected: true }, ], + reveal: + options?.reveal === undefined || options?.reveal === null + ? null + : { + round_question_id: roundQuestionId, + round_number: 1, + prompt: options.reveal.prompt ?? 'Q?', + correct_answer: options.reveal.correct_answer, + lies: (options.reveal.lies ?? []).map((lie) => ({ + ...lie, + created_at: lie.created_at ?? '2026-01-01T00:00:05Z', + })), + guesses: (options.reveal.guesses ?? []).map((guess) => ({ + ...guess, + created_at: guess.created_at ?? '2026-01-01T00:00:10Z', + })), + }, phase_view_model: { status, round_number: 1, @@ -101,6 +137,48 @@ describe('HostShellComponent gameplay wiring', () => { expect(component.loading).toBe(false); }); + it('hydrates canonical reveal payload in reveal phase', async () => { + const fetchMock: FetchMock = vi.fn().mockResolvedValue( + jsonResponse( + 200, + sessionDetailPayload('reveal', { + roundQuestionId: 77, + reveal: { + correct_answer: 'Mercury', + lies: [{ player_id: 2, nickname: 'Mads', text: 'Venus' }], + guesses: [ + { + player_id: 3, + nickname: 'Luna', + selected_text: 'Venus', + is_correct: false, + fooled_player_id: 2, + fooled_player_nickname: 'Mads', + }, + ], + }, + }) + ) + ); + + vi.stubGlobal('fetch', fetchMock); + + const component = new HostShellComponent(); + component.sessionCode = 'ABCD12'; + + await component.refreshSession(); + + expect(component.session?.reveal?.correct_answer).toBe('Mercury'); + expect(component.session?.reveal?.lies[0]).toMatchObject({ player_id: 2, nickname: 'Mads', text: 'Venus' }); + expect(component.session?.reveal?.guesses[0]).toMatchObject({ + player_id: 3, + nickname: 'Luna', + selected_text: 'Venus', + fooled_player_id: 2, + fooled_player_nickname: 'Mads', + }); + }); + it('captures scoreboard error for retry path', async () => { const fetchMock: FetchMock = vi.fn().mockResolvedValue(jsonResponse(500, { error: 'Scoreboard unavailable' })); diff --git a/frontend/angular/src/app/features/host/host-shell.component.ts b/frontend/angular/src/app/features/host/host-shell.component.ts index 4253051..36b431d 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.ts @@ -3,15 +3,11 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { createApiClient } from '../../../../../src/api/client'; -import type { FinishGameResponse, ScoreboardResponse } from '../../../../../src/api/types'; +import type { FinishGameResponse, ScoreboardResponse, SessionDetailResponse } from '../../../../../src/api/types'; import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n'; -interface SessionDetail { - session: { code: string; status: string; current_round: number }; - round_question: { id: number; prompt: string; answers: Array<{ text: string }> } | null; - players: Array<{ id: number; nickname: string; score: number }>; -} +type SessionDetail = SessionDetailResponse; type LeaderboardEntry = ScoreboardResponse['leaderboard'][number]; type LeaderboardResponse = FinishGameResponse; @@ -52,6 +48,28 @@ type LeaderboardResponse = FinishGameResponse;
  • {{ p.nickname }}: {{ p.score }}
+
+

Reveal

+

Korrekt svar: {{ session.reveal.correct_answer }}

+

Spørgsmål: {{ session.reveal.prompt }}

+
+ Løgne +
    +
  • {{ lie.nickname }} løj: {{ lie.text }}
  • +
+
+
+ Gæt +
    +
  • + {{ guess.nickname }} valgte {{ guess.selected_text }} + · korrekt + · narret af {{ guess.fooled_player_nickname }} + · forkert +
  • +
+
+
{{ scoreboardPayload }}

{{ copy('host.final_leaderboard') }}

diff --git a/frontend/angular/src/app/features/player/player-shell.component.spec.ts b/frontend/angular/src/app/features/player/player-shell.component.spec.ts index 5bd1d1d..bbb092e 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.spec.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.spec.ts @@ -13,7 +13,28 @@ function jsonResponse(status: number, body: unknown) { } as unknown as Response; } -function sessionDetailPayload(status: string, options?: { answers?: string[]; players?: Array<{ id: number; nickname: string; score: number }>; roundQuestionId?: number | null }) { +function sessionDetailPayload( + status: string, + options?: { + answers?: string[]; + players?: Array<{ id: number; nickname: string; score: number }>; + roundQuestionId?: number | null; + reveal?: { + correct_answer: string; + prompt?: string; + lies?: Array<{ player_id: number; nickname: string; text: string; created_at?: string }>; + guesses?: Array<{ + player_id: number; + nickname: string; + selected_text: string; + is_correct: boolean; + fooled_player_id: number | null; + fooled_player_nickname?: string; + created_at?: string; + }>; + } | null; + } +) { const answers = options?.answers ?? []; const roundQuestionId = options?.roundQuestionId ?? 11; @@ -39,6 +60,23 @@ function sessionDetailPayload(status: string, options?: { answers?: string[]; pl ...player, is_connected: true, })), + reveal: + options?.reveal === undefined || options?.reveal === null + ? null + : { + round_question_id: roundQuestionId, + round_number: 1, + prompt: options.reveal.prompt ?? 'Q?', + correct_answer: options.reveal.correct_answer, + lies: (options.reveal.lies ?? []).map((lie) => ({ + ...lie, + created_at: lie.created_at ?? '2026-01-01T00:00:05Z', + })), + guesses: (options.reveal.guesses ?? []).map((guess) => ({ + ...guess, + created_at: guess.created_at ?? '2026-01-01T00:00:10Z', + })), + }, phase_view_model: { status, round_number: 1, @@ -158,6 +196,63 @@ describe('PlayerShellComponent gameplay wiring', () => { expect(component.finalLeaderboard.map((entry) => entry.nickname)).toEqual(['Luna', 'Mads']); }); + it('hydrates canonical reveal payload after guess -> reveal', async () => { + const fetchMock: FetchMock = vi.fn().mockResolvedValue( + jsonResponse( + 200, + sessionDetailPayload('reveal', { + answers: ['A', 'B'], + reveal: { + correct_answer: 'A', + lies: [{ player_id: 3, nickname: 'Løgnhals', text: 'B' }], + guesses: [ + { + player_id: 9, + nickname: 'Detektiv', + selected_text: 'B', + is_correct: false, + fooled_player_id: 3, + fooled_player_nickname: 'Løgnhals', + }, + { + player_id: 10, + nickname: 'Sandhed', + selected_text: 'A', + is_correct: true, + fooled_player_id: null, + }, + ], + }, + }) + ) + ); + + vi.stubGlobal('fetch', fetchMock); + + const component = new PlayerShellComponent(); + component.sessionCode = 'ABCD12'; + + await component.refreshSession(); + + expect(component.session?.reveal?.correct_answer).toBe('A'); + expect(component.session?.reveal?.lies[0]).toMatchObject({ player_id: 3, nickname: 'Løgnhals', text: 'B' }); + expect(component.session?.reveal?.guesses[0]).toMatchObject({ + player_id: 9, + nickname: 'Detektiv', + selected_text: 'B', + is_correct: false, + fooled_player_id: 3, + fooled_player_nickname: 'Løgnhals', + }); + expect(component.session?.reveal?.guesses[1]).toMatchObject({ + player_id: 10, + nickname: 'Sandhed', + selected_text: 'A', + is_correct: true, + fooled_player_id: null, + }); + }); + it('surfaces guess submit error and retries with selected answer payload', async () => { const fetchMock: FetchMock = vi .fn() diff --git a/frontend/angular/src/app/features/player/player-shell.component.ts b/frontend/angular/src/app/features/player/player-shell.component.ts index 393723a..d7f485e 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.ts @@ -3,15 +3,12 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { createApiClient } from '../../../../../src/api/client'; +import type { SessionDetailResponse } from '../../../../../src/api/types'; import { createSessionContextStore } from '../../../../../src/spa/session-context-store'; import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n'; -interface SessionDetail { - session: { code: string; status: string; current_round: number }; - round_question: { id: number; prompt: string; answers: Array<{ text: string }> } | null; - players: Array<{ id: number; nickname: string; score: number }>; -} +type SessionDetail = SessionDetailResponse; type ConnectionState = 'online' | 'reconnecting' | 'offline'; type LoadingTransition = 'refresh' | 'join' | 'submit-lie' | 'submit-guess' | null; @@ -90,6 +87,29 @@ function resolveLocalStorage(): Storage | undefined { +
+

Reveal

+

Korrekt svar: {{ session.reveal.correct_answer }}

+

Spørgsmål: {{ session.reveal.prompt }}

+
+ Løgne +
    +
  • {{ lie.nickname }} løj: {{ lie.text }}
  • +
+
+
+ Gæt +
    +
  • + {{ guess.nickname }} valgte {{ guess.selected_text }} + · korrekt + · narret af {{ guess.fooled_player_nickname }} + · forkert +
  • +
+
+
+

{{ copy('player.final_leaderboard') }}

    @@ -261,7 +281,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { } const activeElements = document.querySelectorAll('audio,video') as - | NodeListOf + | NodeListOf | GuardableMediaElement[] | undefined; diff --git a/frontend/src/api/mappers.ts b/frontend/src/api/mappers.ts index 538a264..1944ec3 100644 --- a/frontend/src/api/mappers.ts +++ b/frontend/src/api/mappers.ts @@ -102,6 +102,56 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse { const host = asRecord(phase.host, 'session_detail.phase_view_model.host'); const player = asRecord(phase.player, 'session_detail.phase_view_model.player'); + const revealRaw = root.reveal; + let reveal: SessionDetailResponse['reveal'] = null; + if (revealRaw !== null && revealRaw !== undefined) { + const revealRecord = asRecord(revealRaw, 'session_detail.reveal'); + const liesRaw = revealRecord.lies; + const guessesRaw = revealRecord.guesses; + if (!Array.isArray(liesRaw)) { + throw new Error('Invalid API contract: expected array at session_detail.reveal.lies'); + } + if (!Array.isArray(guessesRaw)) { + throw new Error('Invalid API contract: expected array at session_detail.reveal.guesses'); + } + + reveal = { + round_question_id: readNumber(revealRecord, 'round_question_id', 'session_detail.reveal'), + round_number: readNumber(revealRecord, 'round_number', 'session_detail.reveal'), + prompt: readString(revealRecord, 'prompt', 'session_detail.reveal'), + correct_answer: readString(revealRecord, 'correct_answer', 'session_detail.reveal'), + lies: liesRaw.map((lie, index) => { + const record = asRecord(lie, `session_detail.reveal.lies[${index}]`); + return { + player_id: readNumber(record, 'player_id', `session_detail.reveal.lies[${index}]`), + nickname: readString(record, 'nickname', `session_detail.reveal.lies[${index}]`), + text: readString(record, 'text', `session_detail.reveal.lies[${index}]`), + created_at: readString(record, 'created_at', `session_detail.reveal.lies[${index}]`) + }; + }), + guesses: guessesRaw.map((guess, index) => { + const record = asRecord(guess, `session_detail.reveal.guesses[${index}]`); + const fooledPlayerId = record.fooled_player_id; + if (fooledPlayerId !== null && !isNumber(fooledPlayerId)) { + throw new Error(`Invalid API contract: expected number|null at session_detail.reveal.guesses[${index}].fooled_player_id`); + } + const fooledPlayerNickname = record.fooled_player_nickname; + if (fooledPlayerNickname !== undefined && !isString(fooledPlayerNickname)) { + throw new Error(`Invalid API contract: expected string at session_detail.reveal.guesses[${index}].fooled_player_nickname`); + } + return { + player_id: readNumber(record, 'player_id', `session_detail.reveal.guesses[${index}]`), + nickname: readString(record, 'nickname', `session_detail.reveal.guesses[${index}]`), + selected_text: readString(record, 'selected_text', `session_detail.reveal.guesses[${index}]`), + is_correct: readBoolean(record, 'is_correct', `session_detail.reveal.guesses[${index}]`), + fooled_player_id: fooledPlayerId, + ...(fooledPlayerNickname === undefined ? {} : { fooled_player_nickname: fooledPlayerNickname }), + created_at: readString(record, 'created_at', `session_detail.reveal.guesses[${index}]`) + }; + }) + }; + } + return { session: { code: readString(session, 'code', 'session_detail.session'), @@ -129,6 +179,7 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse { }; }), round_question: roundQuestion, + reveal, phase_view_model: { status: readString(phase, 'status', 'session_detail.phase_view_model'), round_number: readNumber(phase, 'round_number', 'session_detail.phase_view_model'), diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 982928d..5a9a13a 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -57,10 +57,37 @@ export interface PhaseViewModel { }; } +export interface RevealLie { + player_id: number; + nickname: string; + text: string; + created_at: string; +} + +export interface RevealGuess { + player_id: number; + nickname: string; + selected_text: string; + is_correct: boolean; + fooled_player_id: number | null; + fooled_player_nickname?: string; + created_at: string; +} + +export interface RevealPayload { + round_question_id: number; + round_number: number; + prompt: string; + correct_answer: string; + lies: RevealLie[]; + guesses: RevealGuess[]; +} + export interface SessionDetailResponse { session: SessionSummary; players: SessionPlayer[]; round_question: SessionRoundQuestion | null; + reveal: RevealPayload | null; phase_view_model: PhaseViewModel; } -- 2.39.5 From 6dcd5e5f03a5e97cf6fb60ebda175b1a5ad523af Mon Sep 17 00:00:00 2001 From: root Date: Sun, 15 Mar 2026 12:46:13 +0000 Subject: [PATCH 5/9] test(lobby): align lie submission assertions with i18n errors --- lobby/tests.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lobby/tests.py b/lobby/tests.py index c6c967d..0721738 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -360,7 +360,9 @@ class LieSubmissionTests(TestCase): ) self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error"], "Lie submission window has closed") + self.assertEqual(response.json()["error_code"], "lie_submission_window_closed") + self.assertEqual(response.json()["locale"], "en") + self.assertEqual(response.json()["error"], "Lie submission window has closed.") def test_submit_lie_rejects_duplicate_submission(self): round_question = RoundQuestion.objects.create( @@ -381,7 +383,9 @@ class LieSubmissionTests(TestCase): ) self.assertEqual(response.status_code, 409) - self.assertEqual(response.json()["error"], "Lie already submitted for this player") + self.assertEqual(response.json()["error_code"], "lie_already_submitted") + self.assertEqual(response.json()["locale"], "en") + self.assertEqual(response.json()["error"], "Lie has already been submitted for this player.") def test_submit_lie_requires_session_token(self): round_question = RoundQuestion.objects.create( @@ -423,7 +427,9 @@ class LieSubmissionTests(TestCase): ) self.assertEqual(response.status_code, 403) - self.assertEqual(response.json()["error"], "Invalid player session token") + self.assertEqual(response.json()["error_code"], "invalid_player_session_token") + self.assertEqual(response.json()["locale"], "en") + self.assertEqual(response.json()["error"], "Player session token is invalid.") class MixAnswersTests(TestCase): def setUp(self): -- 2.39.5 From 207c934b483850719fb57698ee364fee4d5808a4 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Sun, 15 Mar 2026 13:01:21 +0000 Subject: [PATCH 6/9] test(lobby): cover legacy scoreboard host gating --- lobby/tests.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lobby/tests.py b/lobby/tests.py index 90a7878..d69562f 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -1312,6 +1312,17 @@ class UiScreenTests(TestCase): 'var HOST_SHELL_ROUTES={lobby:"lobby",lie:"lie",guess:"guess",reveal:"reveal",scoreboard:"scoreboard",finished:"finished"};', ) + def test_host_screen_template_gates_next_round_and_finish_on_scoreboard_phase(self): + self.client.login(username="host_ui", password="secret123") + + response = self.client.get(reverse("lobby:host_screen")) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'if(nextRoundBtn){nextRoundBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="scoreboard";}') + self.assertContains(response, 'if(finishGameBtn){finishGameBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="scoreboard";}') + self.assertNotContains(response, 'if(nextRoundBtn){nextRoundBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}') + self.assertNotContains(response, 'if(finishGameBtn){finishGameBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}') + @override_settings(USE_SPA_UI=True) def test_host_screen_deeplink_normalizes_redundant_slashes_when_feature_flag_enabled(self): self.client.login(username="host_ui", password="secret123") -- 2.39.5 From 076ca4ebbb7fd227ea4679f2146e329109fc3e6a Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Sun, 15 Mar 2026 13:27:25 +0000 Subject: [PATCH 7/9] test(gameplay): lock canonical reveal payload across scoreboard --- frontend/tests/angular-api-client.test.ts | 77 +++++++++++++++++++++++ lobby/tests.py | 37 +++++++++++ 2 files changed, 114 insertions(+) diff --git a/frontend/tests/angular-api-client.test.ts b/frontend/tests/angular-api-client.test.ts index c0b9eb5..d0f2aef 100644 --- a/frontend/tests/angular-api-client.test.ts +++ b/frontend/tests/angular-api-client.test.ts @@ -206,6 +206,83 @@ describe('createAngularApiClient', () => { } }); + it('keeps canonical reveal payload stable when session detail is already in scoreboard phase', async () => { + const get = vi.fn(async (url: string) => { + if (url === '/lobby/sessions/ABCD12') { + return { + session: { code: 'ABCD12', status: 'scoreboard', host_id: 1, current_round: 1, players_count: 2 }, + players: [ + { id: 2, nickname: 'Maja', score: 10, is_connected: true }, + { id: 3, nickname: 'Bo', score: 7, is_connected: true } + ], + round_question: { + id: 77, + round_number: 1, + prompt: 'Q?', + shown_at: '2026-03-01T18:00:00Z', + answers: [{ text: 'A' }, { text: 'B' }] + }, + reveal: { + round_question_id: 77, + round_number: 1, + prompt: 'Q?', + correct_answer: 'A', + lies: [{ player_id: 2, nickname: 'Maja', text: 'B', created_at: '2026-03-01T18:00:05Z' }], + guesses: [ + { + player_id: 3, + nickname: 'Bo', + selected_text: 'B', + is_correct: false, + fooled_player_id: 2, + fooled_player_nickname: 'Maja', + created_at: '2026-03-01T18:00:15Z' + } + ] + }, + phase_view_model: { + status: 'scoreboard', + round_number: 1, + players_count: 2, + constraints: { + min_players_to_start: 2, + max_players_mvp: 8, + min_players_reached: true, + max_players_allowed: true + }, + host: { + can_start_round: false, + can_show_question: false, + can_mix_answers: false, + can_calculate_scores: false, + can_reveal_scoreboard: false, + can_start_next_round: true, + can_finish_game: true + }, + player: { + can_join: true, + can_submit_lie: false, + can_submit_guess: false, + can_view_final_result: false + } + } + } as T; + } + throw { status: 404, error: { error: 'Not found' } }; + }); + + const client = createAngularApiClient({ get, post: vi.fn() } as unknown as AngularHttpClientLike); + const session = await client.getSession('abcd12'); + + expect(session.ok).toBe(true); + if (session.ok) { + expect(session.data.session.status).toBe('scoreboard'); + expect(session.data.reveal?.guesses[0].fooled_player_nickname).toBe('Maja'); + expect(session.data.phase_view_model.host.can_start_next_round).toBe(true); + expect(session.data.phase_view_model.host.can_finish_game).toBe(true); + } + }); + it('maps host/player gameplay endpoints through typed response mappers', async () => { const get = vi.fn(async (url: string) => { if (url === '/lobby/sessions/ABCD12/scoreboard') { diff --git a/lobby/tests.py b/lobby/tests.py index d69562f..15deaca 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -1463,11 +1463,48 @@ class SessionDetailRoundQuestionTests(TestCase): self.assertEqual(payload["reveal"]["round_question_id"], round_question.id) self.assertEqual(payload["reveal"]["correct_answer"], "Edison") self.assertEqual(payload["reveal"]["lies"][0]["player_id"], liar.id) + self.assertEqual(payload["reveal"]["lies"][0]["nickname"], "Løgnhals") + self.assertEqual(payload["reveal"]["lies"][0]["text"], "Tesla") self.assertEqual(payload["reveal"]["guesses"][0]["player_id"], guesser.id) + self.assertEqual(payload["reveal"]["guesses"][0]["selected_text"], "Tesla") self.assertEqual(payload["reveal"]["guesses"][0]["fooled_player_id"], liar.id) self.assertEqual(payload["reveal"]["guesses"][0]["fooled_player_nickname"], "Løgnhals") self.assertTrue(payload["reveal"]["guesses"][1]["is_correct"]) + self.assertEqual(payload["reveal"]["guesses"][1]["selected_text"], "Edison") self.assertIsNone(payload["reveal"]["guesses"][1]["fooled_player_id"]) + self.assertIsNone(payload["reveal"]["guesses"][1].get("fooled_player_nickname")) + + def test_session_detail_preserves_canonical_reveal_payload_across_reveal_and_scoreboard(self): + round_question = RoundQuestion.objects.create( + session=self.session, + round_number=1, + question=self.question, + correct_answer=self.question.correct_answer, + ) + liar = Player.objects.create(session=self.session, nickname="Løgnhals") + guesser = Player.objects.create(session=self.session, nickname="Detektiv") + LieAnswer.objects.create(round_question=round_question, player=liar, text="Tesla") + Guess.objects.create( + round_question=round_question, + player=guesser, + selected_text="Tesla", + is_correct=False, + fooled_player=liar, + ) + + self.session.status = GameSession.Status.REVEAL + self.session.save(update_fields=["status"]) + reveal_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() + + self.session.status = GameSession.Status.SCOREBOARD + self.session.save(update_fields=["status"]) + scoreboard_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() + + self.assertEqual(reveal_payload["reveal"], scoreboard_payload["reveal"]) + self.assertTrue(reveal_payload["phase_view_model"]["host"]["can_reveal_scoreboard"]) + self.assertFalse(scoreboard_payload["phase_view_model"]["host"]["can_reveal_scoreboard"]) + self.assertFalse(reveal_payload["phase_view_model"]["host"]["can_start_next_round"]) + self.assertTrue(scoreboard_payload["phase_view_model"]["host"]["can_start_next_round"]) class SessionDetailPhaseViewModelTests(TestCase): -- 2.39.5 From e8883e803b92786ac7e83840837b3c865eb3965f Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Sun, 15 Mar 2026 14:24:42 +0000 Subject: [PATCH 8/9] fix: preserve reveal before scoreboard --- frontend/angular/src/app/api-contract-smoke.spec.ts | 2 +- .../src/app/features/host/host-shell.component.spec.ts | 4 ++-- lobby/tests.py | 6 +++--- lobby/views.py | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/frontend/angular/src/app/api-contract-smoke.spec.ts b/frontend/angular/src/app/api-contract-smoke.spec.ts index 8801e59..63b72b5 100644 --- a/frontend/angular/src/app/api-contract-smoke.spec.ts +++ b/frontend/angular/src/app/api-contract-smoke.spec.ts @@ -122,7 +122,7 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => { if (url === '/lobby/sessions/ABCD12/questions/77/scores/calculate') { expect(body).toEqual({}); return { - session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 }, + session: { code: 'ABCD12', status: 'reveal', current_round: 1 }, round_question: { id: 77, round_number: 1 }, events_created: 2, reveal: { diff --git a/frontend/angular/src/app/features/host/host-shell.component.spec.ts b/frontend/angular/src/app/features/host/host-shell.component.spec.ts index 18ff658..82fb35f 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.spec.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.spec.ts @@ -220,13 +220,13 @@ describe('HostShellComponent gameplay wiring', () => { .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 }))) .mockResolvedValueOnce( jsonResponse(200, { - session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 }, + session: { code: 'ABCD12', status: 'reveal', current_round: 1 }, round_question: { id: 77, round_number: 1 }, events_created: 2, leaderboard: [{ id: 1, nickname: 'Luna', score: 320 }], }) ) - .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('scoreboard', { roundQuestionId: 77 }))); + .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('reveal', { roundQuestionId: 77 }))); vi.stubGlobal('fetch', fetchMock); diff --git a/lobby/tests.py b/lobby/tests.py index 15deaca..4322a32 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -744,7 +744,7 @@ class ScoreCalculationTests(TestCase): self.player_two = Player.objects.create(session=self.session, nickname="Mads") self.player_three = Player.objects.create(session=self.session, nickname="Nora") - def test_host_can_calculate_scores_and_transition_to_scoreboard(self): + def test_host_can_calculate_scores_and_transition_to_reveal(self): LieAnswer.objects.create(round_question=self.round_question, player=self.player_three, text="Padel") Guess.objects.create(round_question=self.round_question, player=self.player_one, selected_text="Tennis", is_correct=True) @@ -773,7 +773,7 @@ class ScoreCalculationTests(TestCase): self.assertEqual(response.status_code, 200) payload = response.json() - self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD) + self.assertEqual(payload["session"]["status"], GameSession.Status.REVEAL) self.assertEqual(payload["events_created"], 2) self.assertEqual(payload["reveal"]["round_question_id"], self.round_question.id) self.assertEqual(payload["reveal"]["correct_answer"], "Tennis") @@ -826,7 +826,7 @@ class ScoreCalculationTests(TestCase): self.assertEqual(self.player_one.score, 5) self.assertEqual(self.player_three.score, 4) - self.assertEqual(self.session.status, GameSession.Status.SCOREBOARD) + self.assertEqual(self.session.status, GameSession.Status.REVEAL) def test_calculate_scores_requires_host(self): self.client.login(username="other_score", password="secret123") diff --git a/lobby/views.py b/lobby/views.py index b1464cd..7899c85 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -1006,7 +1006,7 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) -> ScoreEvent.objects.bulk_create(score_events) - locked_session.status = GameSession.Status.SCOREBOARD + locked_session.status = GameSession.Status.REVEAL locked_session.save(update_fields=["status"]) leaderboard = list( @@ -1034,7 +1034,7 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) -> { "session": { "code": session.code, - "status": GameSession.Status.SCOREBOARD, + "status": GameSession.Status.REVEAL, "current_round": session.current_round, }, "round_question": { -- 2.39.5 From 49257af0b0ae77212b00a64ef5eea723a30337eb Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Sun, 15 Mar 2026 15:28:28 +0000 Subject: [PATCH 9/9] fix(frontend): align session detail contract in tests --- frontend/src/api/client.ts | 10 +++++----- frontend/tests/gameplay-phase-machine.test.ts | 2 ++ frontend/tests/vertical-slice.test.ts | 11 ++++++++++- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 397365c..65ef550 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -4,8 +4,8 @@ import { mapHealthResponse, mapJoinSessionResponse, mapMixAnswersResponse, - mapNextRoundResponse, mapScoreboardResponse, + mapStartNextRoundResponse, mapSessionDetailResponse, mapShowQuestionResponse, mapStartRoundResponse, @@ -20,8 +20,8 @@ import type { JoinSessionRequest, JoinSessionResponse, MixAnswersResponse, - NextRoundResponse, ScoreboardResponse, + StartNextRoundResponse, SessionDetailResponse, ShowQuestionResponse, StartRoundRequest, @@ -41,7 +41,7 @@ export interface ApiClient { mixAnswers(code: string, roundQuestionId: number): Promise>; calculateScores(code: string, roundQuestionId: number): Promise>; getScoreboard(code: string): Promise>; - startNextRound(code: string): Promise>; + startNextRound(code: string): Promise>; finishGame(code: string): Promise>; submitLie(code: string, roundQuestionId: number, payload: SubmitLieRequest): Promise>; submitGuess(code: string, roundQuestionId: number, payload: SubmitGuessRequest): Promise>; @@ -167,10 +167,10 @@ export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch): mapScoreboardResponse ), startNextRound: (code: string) => - request( + request( `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/rounds/next`, 'POST', - mapNextRoundResponse, + mapStartNextRoundResponse, {} ), finishGame: (code: string) => diff --git a/frontend/tests/gameplay-phase-machine.test.ts b/frontend/tests/gameplay-phase-machine.test.ts index 2d71a7b..420d822 100644 --- a/frontend/tests/gameplay-phase-machine.test.ts +++ b/frontend/tests/gameplay-phase-machine.test.ts @@ -40,6 +40,7 @@ describe('gameplay phase machine skeleton', () => { session: { code: 'ABCD12', status: 'lie', host_id: 1, current_round: 1, players_count: 3 }, players: [], round_question: null, + reveal: null, phase_view_model: { status: 'lie', round_number: 1, @@ -74,6 +75,7 @@ describe('gameplay phase machine skeleton', () => { session: { code: 'ABCD12', status: 'finished', host_id: 1, current_round: 1, players_count: 3 }, players: [], round_question: null, + reveal: null, phase_view_model: { status: 'finished', round_number: 1, diff --git a/frontend/tests/vertical-slice.test.ts b/frontend/tests/vertical-slice.test.ts index 81544ee..f1c94f8 100644 --- a/frontend/tests/vertical-slice.test.ts +++ b/frontend/tests/vertical-slice.test.ts @@ -16,6 +16,7 @@ function makeApiMock(overrides?: Partial): ApiClient { session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 3 }, players: [], round_question: null, + reveal: null, phase_view_model: { status: 'lobby', round_number: 1, @@ -56,7 +57,15 @@ function makeApiMock(overrides?: Partial): ApiClient { session: { code: 'ABCD12', status: 'lie', current_round: 1 }, round: { number: 1, category: { slug: 'history', name: 'History' } } } - }) + }), + showQuestion: vi.fn(), + mixAnswers: vi.fn(), + calculateScores: vi.fn(), + getScoreboard: vi.fn(), + startNextRound: vi.fn(), + finishGame: vi.fn(), + submitLie: vi.fn(), + submitGuess: vi.fn() }; return { ...base, ...overrides }; -- 2.39.5