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" } } }