diff --git a/docs/ISSUE-287-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md b/docs/ISSUE-287-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md new file mode 100644 index 0000000..2cc8eeb --- /dev/null +++ b/docs/ISSUE-287-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md @@ -0,0 +1,21 @@ +# Issue #287 — Canonical round-flow backend artifact + +## State-transition matrix + +| Trigger | From | To | Server-owned effect | +|---|---|---|---| +| `POST /lobby/sessions/{code}/rounds/start` | `lobby` | `lie` | Opretter `RoundConfig`, vælger/låser konkret `RoundQuestion`, eksponerer prompt + lie-deadline i samme svar | +| Sidste gyldige `submit_lie` for aktivt spørgsmål | `lie` | `guess` | Dedupe/shuffle `correct_answer + lies`, persisterer `mixed_answers`, broadcaster `phase.guess_started` | +| Sidste gyldige `submit_guess` for aktivt spørgsmål | `guess` | `reveal` | Beregner score deterministisk, persisterer `ScoreEvent` + opdaterede `Player.score`, returnerer canonical reveal payload | +| Første canonical state-read efter resolved reveal (`session_detail`) | `reveal` | `scoreboard` | Promoverer scoreboard som state, broadcaster `phase.scoreboard`, eksponerer leaderboard + readiness | +| `POST /lobby/sessions/{code}/next` | `scoreboard` | `lobby` | Increment round counter | +| `POST /lobby/sessions/{code}/finish` | `scoreboard` | `finished` | Fryser slutresultat og returnerer final leaderboard | + +## Flow-log (happy path) + +1. Host starter runde med kategori. +2. Server vælger straks spørgsmål og går i `lie`. +3. Spillere sender løgne; sidste submission auto-advancer til `guess`. +4. Spillere sender gæt; sidste submission auto-advancer til `reveal` og scorer runden. +5. Næste `session_detail`-read promoverer resolved reveal til `scoreboard` uden host-knap. +6. Host kan nu kun vælge `next round` eller `finish game`. diff --git a/lobby/management/commands/smoke_staging.py b/lobby/management/commands/smoke_staging.py index f5b1701..c388f9e 100644 --- a/lobby/management/commands/smoke_staging.py +++ b/lobby/management/commands/smoke_staging.py @@ -70,12 +70,9 @@ class Command(BaseCommand): if start_res.status_code != 201: raise CommandError(f"start_round failed: {start_res.status_code}") - show_res = host_client.post(f"/lobby/sessions/{code}/questions/show", content_type="application/json") - if show_res.status_code != 201: - raise CommandError(f"show_question failed: {show_res.status_code}") - - round_question_id = show_res.json()["round_question"]["id"] + round_question_id = start_res.json()["round_question"]["id"] + answers = [] for player in players: nick = player["nickname"] lie_res = Client().post( @@ -91,17 +88,17 @@ class Command(BaseCommand): ) if lie_res.status_code != 201: raise CommandError(f"submit_lie failed for {nick}: {lie_res.status_code}") + if lie_res.json().get("answers"): + answers = lie_res.json()["answers"] - mix_res = host_client.post( - f"/lobby/sessions/{code}/questions/{round_question_id}/answers/mix", - content_type="application/json", - ) - if mix_res.status_code != 200: - raise CommandError(f"mix_answers failed: {mix_res.status_code}") - - answers = mix_res.json().get("answers", []) if not answers: - raise CommandError("mix_answers returned empty answers") + detail_res = host_client.get(f"/lobby/sessions/{code}") + if detail_res.status_code != 200: + raise CommandError(f"session_detail after lies failed: {detail_res.status_code}") + answers = detail_res.json().get("round_question", {}).get("answers", []) + + if not answers: + raise CommandError("canonical lie->guess transition returned empty answers") for player in players: nick = player["nickname"] @@ -120,16 +117,11 @@ class Command(BaseCommand): if guess_res.status_code != 201: raise CommandError(f"submit_guess failed for {nick}: {guess_res.status_code}") - calc_res = host_client.post( - f"/lobby/sessions/{code}/questions/{round_question_id}/scores/calculate", - content_type="application/json", - ) - if calc_res.status_code != 200: - raise CommandError(f"calculate_scores failed: {calc_res.status_code}") - - board_res = host_client.get(f"/lobby/sessions/{code}/scoreboard") - if board_res.status_code != 200: - raise CommandError(f"reveal_scoreboard failed: {board_res.status_code}") + detail_res = host_client.get(f"/lobby/sessions/{code}") + if detail_res.status_code != 200: + raise CommandError(f"session_detail after guesses failed: {detail_res.status_code}") + if detail_res.json()["session"]["status"] != GameSession.Status.SCOREBOARD: + raise CommandError("canonical guess->reveal->scoreboard transition did not reach scoreboard") finish_res = host_client.post(f"/lobby/sessions/{code}/finish", content_type="application/json") if finish_res.status_code != 200: @@ -148,12 +140,10 @@ class Command(BaseCommand): "create_session", "join_players", "start_round", - "show_question", "submit_lies", - "mix_answers", + "auto_guess_transition", "submit_guesses", - "calculate_scores", - "reveal_scoreboard", + "auto_reveal_to_scoreboard", "finish_game", ], } diff --git a/lobby/tests.py b/lobby/tests.py index c537595..3b0e72d 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -204,11 +204,14 @@ class StartRoundTests(TestCase): self.assertEqual(body["session"]["status"], GameSession.Status.LIE) self.assertEqual(body["round"]["number"], 1) self.assertEqual(body["round"]["category"]["slug"], self.category.slug) + self.assertEqual(body["round_question"]["prompt"], "Hvilket år faldt muren?") + self.assertIn("lie_deadline_at", body["round_question"]) self.session.refresh_from_db() self.assertEqual(self.session.status, GameSession.Status.LIE) round_config = RoundConfig.objects.get(session=self.session, number=1) self.assertEqual(round_config.category, self.category) + self.assertTrue(RoundQuestion.objects.filter(session=self.session, round_number=1).exists()) def test_host_start_round_uses_normalized_session_code_from_path(self): self.client.login(username="host", password="secret123") @@ -715,6 +718,76 @@ class GuessSubmissionTests(TestCase): self.assertEqual(response.json()["error"], "Invalid player session token") +class CanonicalRoundFlowTests(TestCase): + def setUp(self): + self.host = User.objects.create_user(username="host_canonical", password="secret123") + self.session = GameSession.objects.create(host=self.host, code="CN2871") + self.category = Category.objects.create(name="Kanon", slug="kanon", is_active=True) + self.question = Question.objects.create( + category=self.category, + prompt="Hvem skrev Hamlet?", + correct_answer="Shakespeare", + is_active=True, + ) + self.players = [ + Player.objects.create(session=self.session, nickname="Luna"), + Player.objects.create(session=self.session, nickname="Mads"), + Player.objects.create(session=self.session, nickname="Nora"), + ] + + def test_canonical_round_flow_auto_advances_from_start_to_scoreboard(self): + self.client.login(username="host_canonical", password="secret123") + + start_response = self.client.post( + reverse("lobby:start_round", kwargs={"code": self.session.code}), + data={"category_slug": self.category.slug}, + content_type="application/json", + ) + self.assertEqual(start_response.status_code, 201) + round_question_id = start_response.json()["round_question"]["id"] + self.assertEqual(start_response.json()["session"]["status"], GameSession.Status.LIE) + + lie_responses = [] + for index, player in enumerate(self.players, start=1): + lie_responses.append( + self.client.post( + reverse("lobby:submit_lie", kwargs={"code": self.session.code, "round_question_id": round_question_id}), + data={"player_id": player.id, "session_token": player.session_token, "text": f"Løgn {index}"}, + content_type="application/json", + ) + ) + + self.assertTrue(all(response.status_code == 201 for response in lie_responses)) + self.assertEqual(lie_responses[-1].json()["session"]["status"], GameSession.Status.GUESS) + self.assertTrue(lie_responses[-1].json()["phase_transition"]["auto_advanced"]) + self.assertGreaterEqual(len(lie_responses[-1].json()["answers"]), 2) + + guess_targets = ["Shakespeare", "Løgn 1", "Shakespeare"] + guess_responses = [] + for player, selected_text in zip(self.players, guess_targets, strict=True): + guess_responses.append( + self.client.post( + reverse("lobby:submit_guess", kwargs={"code": self.session.code, "round_question_id": round_question_id}), + data={"player_id": player.id, "session_token": player.session_token, "selected_text": selected_text}, + content_type="application/json", + ) + ) + + self.assertTrue(all(response.status_code == 201 for response in guess_responses)) + self.assertEqual(guess_responses[-1].json()["session"]["status"], GameSession.Status.REVEAL) + self.assertTrue(guess_responses[-1].json()["phase_transition"]["auto_advanced"]) + self.assertIsNotNone(guess_responses[-1].json()["reveal"]) + + detail_response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})) + self.assertEqual(detail_response.status_code, 200) + payload = detail_response.json() + self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD) + self.assertEqual(payload["phase_view_model"]["current_phase"], GameSession.Status.SCOREBOARD) + self.assertTrue(payload["phase_view_model"]["readiness"]["scoreboard_ready"]) + self.assertEqual([entry["nickname"] for entry in payload["scoreboard"]], ["Luna", "Nora", "Mads"]) + self.assertEqual(payload["reveal"]["correct_answer"], "Shakespeare") + + class ScoreCalculationTests(TestCase): def setUp(self): self.host = User.objects.create_user(username="host_score", password="secret123") @@ -1502,8 +1575,8 @@ class SessionDetailRoundQuestionTests(TestCase): 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.assertTrue(reveal_payload["phase_view_model"]["readiness"]["scoreboard_ready"]) + self.assertTrue(scoreboard_payload["phase_view_model"]["readiness"]["scoreboard_ready"]) self.assertFalse(reveal_payload["phase_view_model"]["host"]["can_start_next_round"]) self.assertTrue(scoreboard_payload["phase_view_model"]["host"]["can_start_next_round"]) @@ -1530,7 +1603,10 @@ class SessionDetailPhaseViewModelTests(TestCase): self.assertTrue(phase["constraints"]["min_players_reached"]) self.assertTrue(phase["constraints"]["max_players_allowed"]) self.assertTrue(phase["host"]["can_start_round"]) + self.assertEqual(phase["current_phase"], GameSession.Status.LOBBY) self.assertFalse(phase["host"]["can_show_question"]) + self.assertFalse(phase["readiness"]["question_ready"]) + self.assertFalse(phase["readiness"]["scoreboard_ready"]) self.assertTrue(phase["player"]["can_join"]) self.assertFalse(phase["player"]["can_submit_lie"]) self.assertFalse(phase["player"]["can_submit_guess"]) @@ -1555,7 +1631,8 @@ class SessionDetailPhaseViewModelTests(TestCase): lie_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() lie_phase = lie_payload["phase_view_model"] self.assertFalse(lie_phase["host"]["can_show_question"]) - self.assertTrue(lie_phase["host"]["can_mix_answers"]) + self.assertFalse(lie_phase["host"]["can_mix_answers"]) + self.assertTrue(lie_phase["readiness"]["question_ready"]) self.assertTrue(lie_phase["player"]["can_submit_lie"]) self.assertFalse(lie_phase["player"]["can_submit_guess"]) @@ -1563,8 +1640,9 @@ class SessionDetailPhaseViewModelTests(TestCase): self.session.save(update_fields=["status"]) guess_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() guess_phase = guess_payload["phase_view_model"] - self.assertTrue(guess_phase["host"]["can_mix_answers"]) - self.assertTrue(guess_phase["host"]["can_calculate_scores"]) + self.assertFalse(guess_phase["host"]["can_mix_answers"]) + self.assertFalse(guess_phase["host"]["can_calculate_scores"]) + self.assertFalse(guess_phase["readiness"]["scoreboard_ready"]) self.assertFalse(guess_phase["player"]["can_submit_lie"]) self.assertTrue(guess_phase["player"]["can_submit_guess"]) @@ -1573,7 +1651,8 @@ class SessionDetailPhaseViewModelTests(TestCase): self.session.save(update_fields=["status"]) 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.assertFalse(reveal_phase["host"]["can_reveal_scoreboard"]) + self.assertTrue(reveal_phase["readiness"]["scoreboard_ready"]) 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"]) @@ -1583,6 +1662,7 @@ class SessionDetailPhaseViewModelTests(TestCase): 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["readiness"]["scoreboard_ready"]) 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"]) @@ -1620,12 +1700,10 @@ class SmokeStagingCommandTests(TestCase): "create_session", "join_players", "start_round", - "show_question", "submit_lies", - "mix_answers", + "auto_guess_transition", "submit_guesses", - "calculate_scores", - "reveal_scoreboard", + "auto_reveal_to_scoreboard", "finish_game", ], ) diff --git a/lobby/views.py b/lobby/views.py index 7899c85..497c235 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -112,6 +112,152 @@ def _build_reveal_payload(round_question: RoundQuestion | None) -> dict | None: +def _build_leaderboard(session: GameSession) -> list[dict]: + return list( + Player.objects.filter(session=session) + .order_by("-score", "nickname") + .values("id", "nickname", "score") + ) + + + +def _get_current_round_question(session: GameSession) -> RoundQuestion | None: + return ( + RoundQuestion.objects.filter(session=session, round_number=session.current_round) + .select_related("question") + .order_by("-id") + .first() + ) + + + +def _select_round_question(session: GameSession, round_config: RoundConfig) -> RoundQuestion: + existing_round_question = _get_current_round_question(session) + if existing_round_question is not None: + return existing_round_question + + used_question_ids = RoundQuestion.objects.filter(session=session).values_list("question_id", flat=True) + available_questions = Question.objects.filter( + category=round_config.category, + is_active=True, + ).exclude(pk__in=used_question_ids) + + if not available_questions.exists(): + raise ValueError("no_available_questions") + + question = random.choice(list(available_questions)) + return RoundQuestion.objects.create( + session=session, + round_number=session.current_round, + question=question, + correct_answer=question.correct_answer, + ) + + + +def _prepare_mixed_answers(round_question: RoundQuestion) -> list[str]: + deduped_answers = list(round_question.mixed_answers or []) + if deduped_answers: + return deduped_answers + + lie_texts = list(round_question.lies.values_list("text", flat=True)) + seen = set() + for text in [round_question.correct_answer, *lie_texts]: + normalized = text.strip().casefold() + if not normalized or normalized in seen: + continue + seen.add(normalized) + deduped_answers.append(text.strip()) + + if len(deduped_answers) < 2: + raise ValueError("not_enough_answers_to_mix") + + random.shuffle(deduped_answers) + round_question.mixed_answers = deduped_answers + round_question.save(update_fields=["mixed_answers"]) + return deduped_answers + + + +def _resolve_scores(session: GameSession, round_question: RoundQuestion, round_config: RoundConfig) -> tuple[list[ScoreEvent], list[dict]]: + guesses = list(round_question.guesses.select_related("player")) + if not guesses: + raise ValueError("no_guesses_submitted") + + bluff_counts: dict[int, int] = {} + for guess in guesses: + if guess.fooled_player_id: + bluff_counts[guess.fooled_player_id] = bluff_counts.get(guess.fooled_player_id, 0) + 1 + + score_events = [] + + for guess in guesses: + if guess.is_correct: + guess.player.score += round_config.points_correct + guess.player.save(update_fields=["score"]) + score_events.append( + ScoreEvent( + session=session, + player=guess.player, + delta=round_config.points_correct, + reason="guess_correct", + meta={"round_question_id": round_question.id, "guess_id": guess.id}, + ) + ) + + for player_id, fooled_count in bluff_counts.items(): + delta = fooled_count * round_config.points_bluff + player = Player.objects.get(pk=player_id, session=session) + player.score += delta + player.save(update_fields=["score"]) + score_events.append( + ScoreEvent( + session=session, + player=player, + delta=delta, + reason="bluff_success", + meta={"round_question_id": round_question.id, "fooled_count": fooled_count}, + ) + ) + + ScoreEvent.objects.bulk_create(score_events) + return score_events, _build_leaderboard(session) + + + +def _maybe_promote_reveal_to_scoreboard(session: GameSession) -> GameSession: + if session.status != GameSession.Status.REVEAL: + return session + + current_round_question = _get_current_round_question(session) + if current_round_question is None: + return session + + has_round_scores = ScoreEvent.objects.filter( + session=session, + meta__round_question_id=current_round_question.id, + ).exists() + if not has_round_scores: + return session + + with transaction.atomic(): + locked_session = GameSession.objects.select_for_update().get(pk=session.pk) + if locked_session.status != GameSession.Status.REVEAL: + return locked_session + locked_session.status = GameSession.Status.SCOREBOARD + locked_session.save(update_fields=["status"]) + + leaderboard = _build_leaderboard(session) + sync_broadcast_phase_event( + session.code, + "phase.scoreboard", + {"leaderboard": list(leaderboard), "current_round": session.current_round}, + ) + session.refresh_from_db(fields=["status"]) + return session + + + def _build_phase_view_model(session: GameSession, *, players_count: int, has_round_question: bool) -> dict: status = session.status in_lobby = status == GameSession.Status.LOBBY @@ -126,6 +272,7 @@ def _build_phase_view_model(session: GameSession, *, players_count: int, has_rou return { "status": status, + "current_phase": status, "round_number": session.current_round, "players_count": players_count, "constraints": { @@ -134,12 +281,17 @@ def _build_phase_view_model(session: GameSession, *, players_count: int, has_rou "min_players_reached": min_players_reached, "max_players_allowed": max_players_allowed, }, + "readiness": { + "question_ready": has_round_question, + "scoreboard_ready": status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}, + "can_advance_to_next_round": in_scoreboard, + }, "host": { "can_start_round": in_lobby and min_players_reached and max_players_allowed, - "can_show_question": in_lie and not has_round_question, - "can_mix_answers": in_lie or in_guess, - "can_calculate_scores": in_guess, - "can_reveal_scoreboard": in_reveal, + "can_show_question": False, + "can_mix_answers": False, + "can_calculate_scores": False, + "can_reveal_scoreboard": False, "can_start_next_round": in_scoreboard, "can_finish_game": in_scoreboard, }, @@ -256,12 +408,8 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse: ) ) - current_round_question = ( - RoundQuestion.objects.filter(session=session, round_number=session.current_round) - .select_related("question") - .order_by("-id") - .first() - ) + session = _maybe_promote_reveal_to_scoreboard(session) + current_round_question = _get_current_round_question(session) round_question_payload = None if current_round_question: @@ -293,6 +441,9 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse: "reveal": _build_reveal_payload(current_round_question) if session.status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD} and current_round_question else None, + "scoreboard": _build_leaderboard(session) + if session.status in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED} + else None, "phase_view_model": phase_view_model, } ) @@ -373,15 +524,26 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse: status=409, ) + try: + round_question = _select_round_question(session, round_config) + except ValueError as exc: + return api_error(request, code=str(exc), status=400) + session.status = GameSession.Status.LIE session.save(update_fields=["status"]) + lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds) + sync_broadcast_phase_event( session.code, "phase.lie_started", { "round_number": session.current_round, "category": {"slug": round_config.category.slug, "name": round_config.category.name}, + "round_question_id": round_question.id, + "prompt": round_question.question.prompt, + "shown_at": round_question.shown_at.isoformat(), + "lie_deadline_at": lie_deadline_at.isoformat(), "lie_seconds": round_config.lie_seconds, }, ) @@ -400,6 +562,16 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse: "name": round_config.category.name, }, }, + "round_question": { + "id": round_question.id, + "prompt": round_question.question.prompt, + "round_number": round_question.round_number, + "shown_at": round_question.shown_at.isoformat(), + "lie_deadline_at": lie_deadline_at.isoformat(), + }, + "config": { + "lie_seconds": round_config.lie_seconds, + }, }, status=201, ) @@ -442,33 +614,14 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse: status=400, ) - if RoundQuestion.objects.filter(session=session, round_number=session.current_round).exists(): - return api_error( - request, - code="question_already_shown", - status=409, - ) - - used_question_ids = RoundQuestion.objects.filter(session=session).values_list("question_id", flat=True) - available_questions = Question.objects.filter( - category=round_config.category, - is_active=True, - ).exclude(pk__in=used_question_ids) - - if not available_questions.exists(): - return api_error( - request, - code="no_available_questions", - status=400, - ) - - question = random.choice(list(available_questions)) - round_question = RoundQuestion.objects.create( - session=session, - round_number=session.current_round, - question=question, - correct_answer=question.correct_answer, - ) + existing_round_question = _get_current_round_question(session) + if existing_round_question is not None: + round_question = existing_round_question + else: + try: + round_question = _select_round_question(session, round_config) + except ValueError as exc: + return api_error(request, code=str(exc), status=400) lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds) @@ -477,7 +630,7 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse: "phase.question_shown", { "round_question_id": round_question.id, - "prompt": question.prompt, + "prompt": round_question.question.prompt, "shown_at": round_question.shown_at.isoformat(), "lie_deadline_at": lie_deadline_at.isoformat(), "lie_seconds": round_config.lie_seconds, @@ -488,7 +641,7 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse: { "round_question": { "id": round_question.id, - "prompt": question.prompt, + "prompt": round_question.question.prompt, "round_number": round_question.round_number, "shown_at": round_question.shown_at.isoformat(), "lie_deadline_at": lie_deadline_at.isoformat(), @@ -558,6 +711,31 @@ def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonR except IntegrityError: return api_error(request, code="lie_already_submitted", status=409) + players_count = Player.objects.filter(session=session).count() + lie_count = LieAnswer.objects.filter(round_question=round_question).count() + session_status = session.status + mixed_answers_payload = None + + if players_count > 0 and lie_count >= players_count: + try: + mixed_answers = _prepare_mixed_answers(round_question) + except ValueError as exc: + return api_error(request, code=str(exc), status=400) + + session.status = GameSession.Status.GUESS + session.save(update_fields=["status"]) + session_status = session.status + mixed_answers_payload = [{"text": text} for text in mixed_answers] + sync_broadcast_phase_event( + session.code, + "phase.guess_started", + { + "round_question_id": round_question.id, + "answers": mixed_answers_payload, + "guess_seconds": round_config.guess_seconds, + }, + ) + return JsonResponse( { "lie": { @@ -570,6 +748,18 @@ def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonR "window": { "lie_deadline_at": lie_deadline_at.isoformat(), }, + "session": { + "code": session.code, + "status": session_status, + "current_round": session.current_round, + }, + "phase_transition": { + "current_phase": session_status, + "lies_submitted": lie_count, + "players_expected": players_count, + "auto_advanced": session_status == GameSession.Status.GUESS, + }, + "answers": mixed_answers_payload, }, status=201, ) @@ -626,27 +816,10 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json locked_round_question = RoundQuestion.objects.select_for_update().get(pk=round_question.pk) - deduped_answers = list(locked_round_question.mixed_answers or []) - if not deduped_answers: - lie_texts = list(locked_round_question.lies.values_list("text", flat=True)) - seen = set() - for text in [locked_round_question.correct_answer, *lie_texts]: - normalized = text.strip().casefold() - if not normalized or normalized in seen: - continue - seen.add(normalized) - deduped_answers.append(text.strip()) - - if len(deduped_answers) < 2: - return api_error( - request, - code="not_enough_answers_to_mix", - status=400, - ) - - random.shuffle(deduped_answers) - locked_round_question.mixed_answers = deduped_answers - locked_round_question.save(update_fields=["mixed_answers"]) + try: + deduped_answers = _prepare_mixed_answers(locked_round_question) + except ValueError as exc: + return api_error(request, code=str(exc), status=400) if locked_session.status == GameSession.Status.LIE: locked_session.status = GameSession.Status.GUESS @@ -769,6 +942,43 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso except IntegrityError: return api_error(request, code="guess_already_submitted", status=409) + players_count = Player.objects.filter(session=session).count() + guess_count = Guess.objects.filter(round_question=round_question).count() + session_status = session.status + reveal_payload = None + leaderboard = None + + if players_count > 0 and guess_count >= players_count: + already_calculated = ScoreEvent.objects.filter( + session=session, + meta__round_question_id=round_question.id, + ).exists() + if not already_calculated: + score_events, leaderboard = _resolve_scores(session, round_question, round_config) + else: + score_events = list( + ScoreEvent.objects.filter(session=session, meta__round_question_id=round_question.id).select_related("player") + ) + leaderboard = _build_leaderboard(session) + + session.status = GameSession.Status.REVEAL + session.save(update_fields=["status"]) + session_status = session.status + reveal_payload = _build_reveal_payload(round_question) + score_deltas = [ + {"player_id": ev.player_id, "delta": ev.delta, "reason": ev.reason} + for ev in score_events + ] + sync_broadcast_phase_event( + session.code, + "phase.scores_calculated", + { + "round_question_id": round_question.id, + "score_deltas": score_deltas, + "leaderboard": list(leaderboard), + }, + ) + return JsonResponse( { "guess": { @@ -783,6 +993,19 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso "window": { "guess_deadline_at": guess_deadline_at.isoformat(), }, + "session": { + "code": session.code, + "status": session_status, + "current_round": session.current_round, + }, + "phase_transition": { + "current_phase": session_status, + "guesses_submitted": guess_count, + "players_expected": players_count, + "auto_advanced": session_status == GameSession.Status.REVEAL, + }, + "reveal": reveal_payload, + "leaderboard": leaderboard, }, status=201, )