From d36d256dafcfa3f692f84742702dd946a3ae6d79 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Tue, 17 Mar 2026 05:41:13 +0000 Subject: [PATCH 01/43] fix(gameplay): make scoreboard host exits idempotent --- ...10-HOST-TRANSITION-IDEMPOTENCY-ARTIFACT.md | 33 +++ lobby/tests.py | 42 ++++ lobby/views.py | 206 ++++++++++-------- 3 files changed, 191 insertions(+), 90 deletions(-) create mode 100644 docs/ISSUE-310-HOST-TRANSITION-IDEMPOTENCY-ARTIFACT.md diff --git a/docs/ISSUE-310-HOST-TRANSITION-IDEMPOTENCY-ARTIFACT.md b/docs/ISSUE-310-HOST-TRANSITION-IDEMPOTENCY-ARTIFACT.md new file mode 100644 index 0000000..8a06681 --- /dev/null +++ b/docs/ISSUE-310-HOST-TRANSITION-IDEMPOTENCY-ARTIFACT.md @@ -0,0 +1,33 @@ +# Issue #310 — Host transition idempotency and error catalog + +## Scope + +This artifact hardens the two host-owned scoreboard exits in the canonical gameplay flow: + +- `POST /lobby/sessions/{code}/rounds/next` +- `POST /lobby/sessions/{code}/finish` + +The goal is retry-safe host behavior when the scoreboard transition already succeeded server-side but the client retries because of a duplicate click, timeout, or lost response. + +## Transition contract + +| Endpoint | First valid transition | Idempotent replay state | Replay result | Broadcast behavior | Still-invalid states | +|---|---|---|---|---|---| +| `POST /lobby/sessions/{code}/rounds/next` | `scoreboard -> lie` | `lie` with persisted current-round bootstrap (`RoundConfig` + `RoundQuestion`) | `200 OK` with the same canonical next-round payload shape | `phase.lie_started` fires only on the first transition | `lobby`, `guess`, `reveal`, `finished` → `next_round_invalid_phase` | +| `POST /lobby/sessions/{code}/finish` | `scoreboard -> finished` | `finished` | `200 OK` with the same final leaderboard payload shape | `phase.game_over` fires only on the first transition | `lobby`, `lie`, `guess`, `reveal` → `finish_game_invalid_phase` | + +## Error catalog notes + +No new backend error codes were introduced for this slice. + +The contract change is behavioral: + +- `next_round_invalid_phase` now means the session is in a phase where the scoreboard → next-round transition has **not** already been completed, or the expected bootstrap artifact for the already-started round is missing. +- `finish_game_invalid_phase` now means the session is in a phase where the scoreboard → finish transition has **not** already been completed. +- Successful replays are returned as normal `200 OK` canonical responses instead of phase errors. + +## Acceptance evidence + +- Repeated `rounds/next` calls after a successful scoreboard exit return the same canonical lie/bootstrap payload without incrementing the round twice. +- Repeated `finish` calls after a successful scoreboard exit return the same finished leaderboard payload without rebroadcasting game-over. +- Wrong-phase calls outside those replay states still return the existing shared error codes. diff --git a/lobby/tests.py b/lobby/tests.py index dead0e8..5dac6e8 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -1216,6 +1216,25 @@ class RevealRoundFlowTests(TestCase): self.session.refresh_from_db() self.assertEqual(self.session.status, GameSession.Status.FINISHED) + @patch("lobby.views.sync_broadcast_phase_event") + def test_finish_game_is_idempotent_after_transition_to_finished(self, mock_sync_broadcast_phase_event): + self.client.login(username="host_reveal", password="secret123") + self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) + + first_response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code})) + second_response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code})) + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(second_response.status_code, 200) + self.assertEqual(first_response.json(), second_response.json()) + self.assertEqual(second_response.json()["session"]["status"], GameSession.Status.FINISHED) + + self.session.refresh_from_db() + self.assertEqual(self.session.status, GameSession.Status.FINISHED) + self.assertEqual(mock_sync_broadcast_phase_event.call_count, 2) + self.assertEqual(mock_sync_broadcast_phase_event.call_args_list[0].args[1], "phase.scoreboard") + self.assertEqual(mock_sync_broadcast_phase_event.call_args_list[1].args[1], "phase.game_over") + def test_finish_game_requires_host(self): self.client.login(username="other_reveal", password="secret123") @@ -1284,6 +1303,29 @@ class RevealRoundFlowTests(TestCase): self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[0], self.session.code) self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started") + @patch("lobby.views.sync_broadcast_phase_event") + def test_start_next_round_is_idempotent_after_transition_to_lie(self, mock_sync_broadcast_phase_event): + self.client.login(username="host_reveal", password="secret123") + self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) + mock_sync_broadcast_phase_event.reset_mock() + + first_response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) + second_response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(second_response.status_code, 200) + self.assertEqual(first_response.json(), second_response.json()) + self.assertEqual(second_response.json()["session"]["status"], GameSession.Status.LIE) + self.assertEqual(second_response.json()["session"]["current_round"], 2) + + self.session.refresh_from_db() + self.assertEqual(self.session.status, GameSession.Status.LIE) + self.assertEqual(self.session.current_round, 2) + self.assertEqual(RoundConfig.objects.filter(session=self.session, number=2).count(), 1) + self.assertEqual(RoundQuestion.objects.filter(session=self.session, round_number=2).count(), 1) + mock_sync_broadcast_phase_event.assert_called_once() + self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started") + def test_start_next_round_requires_host(self): self.session.status = GameSession.Status.SCOREBOARD self.session.save(update_fields=["status"]) diff --git a/lobby/views.py b/lobby/views.py index 414b8a0..23afb39 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -65,6 +65,57 @@ def _create_unique_session_code() -> str: raise RuntimeError("Could not generate unique session code") +def _build_start_next_round_response( + session: GameSession, + round_config: RoundConfig, + round_question: RoundQuestion, +) -> JsonResponse: + lie_started_payload = _build_lie_started_payload(session, round_config, round_question) + return JsonResponse( + { + "session": { + "code": session.code, + "status": session.status, + "current_round": session.current_round, + }, + "round": { + "number": round_config.number, + "category": { + "slug": round_config.category.slug, + "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_started_payload["lie_deadline_at"], + }, + "config": { + "lie_seconds": round_config.lie_seconds, + }, + } + ) + + + +def _build_finish_game_response(session: GameSession) -> JsonResponse: + leaderboard = _build_leaderboard(session) + winner = leaderboard[0] if leaderboard else None + return JsonResponse( + { + "session": { + "code": session.code, + "status": GameSession.Status.FINISHED, + "current_round": session.current_round, + }, + "winner": winner, + "leaderboard": leaderboard, + } + ) + + def _maybe_promote_reveal_to_scoreboard(session: GameSession) -> GameSession: if session.status != GameSession.Status.REVEAL: return session @@ -917,72 +968,61 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse: if session.host_id != request.user.id: return api_error(request, code="host_only_start_next_round", status=403) + should_broadcast = False with transaction.atomic(): - locked_session = GameSession.objects.select_for_update().get(pk=session.pk) - if locked_session.status != GameSession.Status.SCOREBOARD: + locked_session = GameSession.objects.select_for_update().select_related("host").get(pk=session.pk) + next_round_config = None + round_question = None + + if locked_session.status == GameSession.Status.SCOREBOARD: + previous_round_config = RoundConfig.objects.filter( + session=locked_session, + number=locked_session.current_round, + ).select_related("category").first() + if previous_round_config is None: + return api_error(request, code="round_config_missing", status=400) + + next_round_number = locked_session.current_round + 1 + next_round_config = RoundConfig( + session=locked_session, + number=next_round_number, + category=previous_round_config.category, + lie_seconds=previous_round_config.lie_seconds, + guess_seconds=previous_round_config.guess_seconds, + points_correct=previous_round_config.points_correct, + points_bluff=previous_round_config.points_bluff, + ) + locked_session.current_round = next_round_number + + try: + round_question = _select_round_question(locked_session, next_round_config) + except ValueError as exc: + return api_error(request, code=str(exc), status=400) + + next_round_config.save() + locked_session.status = GameSession.Status.LIE + locked_session.save(update_fields=["current_round", "status"]) + should_broadcast = True + elif locked_session.status == GameSession.Status.LIE: + next_round_config = RoundConfig.objects.filter( + session=locked_session, + number=locked_session.current_round, + ).select_related("category").first() + round_question = _get_current_round_question(locked_session) + if next_round_config is None or round_question is None: + return api_error(request, code="next_round_invalid_phase", status=400) + else: return api_error(request, code="next_round_invalid_phase", status=400) - previous_round_config = RoundConfig.objects.filter( - session=locked_session, - number=locked_session.current_round, - ).select_related("category").first() - if previous_round_config is None: - return api_error(request, code="round_config_missing", status=400) - - next_round_number = locked_session.current_round + 1 - next_round_config = RoundConfig( - session=locked_session, - number=next_round_number, - category=previous_round_config.category, - lie_seconds=previous_round_config.lie_seconds, - guess_seconds=previous_round_config.guess_seconds, - points_correct=previous_round_config.points_correct, - points_bluff=previous_round_config.points_bluff, + if should_broadcast: + lie_started_payload = _build_lie_started_payload(locked_session, next_round_config, round_question) + sync_broadcast_phase_event( + locked_session.code, + "phase.lie_started", + lie_started_payload, ) - locked_session.current_round = next_round_number - try: - round_question = _select_round_question(locked_session, next_round_config) - except ValueError as exc: - return api_error(request, code=str(exc), status=400) - - next_round_config.save() - locked_session.status = GameSession.Status.LIE - locked_session.save(update_fields=["current_round", "status"]) - - lie_started_payload = _build_lie_started_payload(locked_session, next_round_config, round_question) - sync_broadcast_phase_event( - locked_session.code, - "phase.lie_started", - lie_started_payload, - ) - - return JsonResponse( - { - "session": { - "code": locked_session.code, - "status": locked_session.status, - "current_round": locked_session.current_round, - }, - "round": { - "number": next_round_config.number, - "category": { - "slug": next_round_config.category.slug, - "name": next_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_started_payload["lie_deadline_at"], - }, - "config": { - "lie_seconds": next_round_config.lie_seconds, - }, - } - ) + return _build_start_next_round_response(locked_session, next_round_config, round_question) @require_POST @login_required @@ -997,40 +1037,26 @@ def finish_game(request: HttpRequest, code: str) -> JsonResponse: if session.host_id != request.user.id: return api_error(request, code="host_only_finish_game", status=403) + should_broadcast = False with transaction.atomic(): locked_session = GameSession.objects.select_for_update().get(pk=session.pk) - if locked_session.status != GameSession.Status.SCOREBOARD: + if locked_session.status == GameSession.Status.SCOREBOARD: + locked_session.status = GameSession.Status.FINISHED + locked_session.save(update_fields=["status"]) + should_broadcast = True + elif locked_session.status != GameSession.Status.FINISHED: return api_error(request, code="finish_game_invalid_phase", status=400) + if should_broadcast: + leaderboard = _build_leaderboard(locked_session) + winner = leaderboard[0] if leaderboard else None + sync_broadcast_phase_event( + locked_session.code, + "phase.game_over", + {"winner": winner, "leaderboard": list(leaderboard)}, + ) - locked_session.status = GameSession.Status.FINISHED - locked_session.save(update_fields=["status"]) - - leaderboard = list( - Player.objects.filter(session=session) - .order_by("-score", "nickname") - .values("id", "nickname", "score") - ) - - winner = leaderboard[0] if leaderboard else None - - sync_broadcast_phase_event( - session.code, - "phase.game_over", - {"winner": winner, "leaderboard": list(leaderboard)}, - ) - - return JsonResponse( - { - "session": { - "code": session.code, - "status": GameSession.Status.FINISHED, - "current_round": session.current_round, - }, - "winner": winner, - "leaderboard": leaderboard, - } - ) + return _build_finish_game_response(locked_session) @require_POST -- 2.39.5 From 542d32661579bff526a24a9e31583f10917ebe52 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Tue, 17 Mar 2026 06:21:00 +0000 Subject: [PATCH 02/43] fix(gameplay): gate next-round replay on prior transition --- lobby/tests.py | 21 +++++++++++++++++++++ lobby/views.py | 3 +++ 2 files changed, 24 insertions(+) diff --git a/lobby/tests.py b/lobby/tests.py index 5dac6e8..0a72835 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -1326,6 +1326,27 @@ class RevealRoundFlowTests(TestCase): mock_sync_broadcast_phase_event.assert_called_once() self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started") + def test_start_next_round_rejects_plain_first_round_lie_phase(self): + self.client.login(username="host_reveal", password="secret123") + self.session.status = GameSession.Status.LIE + self.session.save(update_fields=["status"]) + + response = self.client.post( + reverse( + "lobby:start_next_round", + kwargs={"code": self.session.code}, + ), + HTTP_ACCEPT_LANGUAGE="en", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error_code"], "next_round_invalid_phase") + self.session.refresh_from_db() + self.assertEqual(self.session.status, GameSession.Status.LIE) + self.assertEqual(self.session.current_round, 1) + self.assertEqual(RoundConfig.objects.filter(session=self.session, number=1).count(), 1) + self.assertEqual(RoundQuestion.objects.filter(session=self.session, round_number=1).count(), 1) + def test_start_next_round_requires_host(self): self.session.status = GameSession.Status.SCOREBOARD self.session.save(update_fields=["status"]) diff --git a/lobby/views.py b/lobby/views.py index 23afb39..2c0112d 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -1004,6 +1004,9 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse: locked_session.save(update_fields=["current_round", "status"]) should_broadcast = True elif locked_session.status == GameSession.Status.LIE: + if locked_session.current_round <= 1: + return api_error(request, code="next_round_invalid_phase", status=400) + next_round_config = RoundConfig.objects.filter( session=locked_session, number=locked_session.current_round, -- 2.39.5 From 44e480931b676e578f76709b4570c166a5be265d Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Tue, 17 Mar 2026 07:05:56 +0000 Subject: [PATCH 03/43] fix(gameplay): gate next-round replay on prior scoreboard exit --- lobby/tests.py | 17 ++++++++++++++--- lobby/views.py | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/lobby/tests.py b/lobby/tests.py index 6722cf5..5ee08e1 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -1399,10 +1399,19 @@ class RevealRoundFlowTests(TestCase): mock_sync_broadcast_phase_event.assert_called_once() self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started") - def test_start_next_round_rejects_plain_first_round_lie_phase(self): + def test_start_next_round_rejects_plain_lie_phase_without_prior_scoreboard_transition(self): self.client.login(username="host_reveal", password="secret123") + ScoreEvent.objects.filter(session=self.session).delete() self.session.status = GameSession.Status.LIE - self.session.save(update_fields=["status"]) + self.session.current_round = 2 + self.session.save(update_fields=["status", "current_round"]) + RoundConfig.objects.create(session=self.session, number=2, category=self.category) + RoundQuestion.objects.create( + session=self.session, + round_number=2, + question=self.next_question, + correct_answer=self.next_question.correct_answer, + ) response = self.client.post( reverse( @@ -1416,9 +1425,11 @@ class RevealRoundFlowTests(TestCase): self.assertEqual(response.json()["error_code"], "next_round_invalid_phase") self.session.refresh_from_db() self.assertEqual(self.session.status, GameSession.Status.LIE) - self.assertEqual(self.session.current_round, 1) + self.assertEqual(self.session.current_round, 2) self.assertEqual(RoundConfig.objects.filter(session=self.session, number=1).count(), 1) + self.assertEqual(RoundConfig.objects.filter(session=self.session, number=2).count(), 1) self.assertEqual(RoundQuestion.objects.filter(session=self.session, round_number=1).count(), 1) + self.assertEqual(RoundQuestion.objects.filter(session=self.session, round_number=2).count(), 1) def test_start_next_round_clears_existing_next_round_bootstrap_state(self): self.client.login(username="host_reveal", password="secret123") diff --git a/lobby/views.py b/lobby/views.py index 64aa0b0..222fe1e 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -1135,6 +1135,25 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse: if locked_session.current_round <= 1: return api_error(request, code="next_round_invalid_phase", status=400) + previous_round_question = RoundQuestion.objects.filter( + session=locked_session, + round_number=locked_session.current_round - 1, + ).first() + if previous_round_question is None: + return api_error(request, code="next_round_invalid_phase", status=400) + + previous_round_players_count = Player.objects.filter(session=locked_session).count() + previous_round_guess_count = Guess.objects.filter(round_question=previous_round_question).count() + previous_round_has_score_events = ScoreEvent.objects.filter( + session=locked_session, + meta__round_question_id=previous_round_question.id, + ).exists() + previous_round_reveal_resolved = previous_round_has_score_events or ( + previous_round_players_count > 0 and previous_round_guess_count >= previous_round_players_count + ) + if not previous_round_reveal_resolved: + return api_error(request, code="next_round_invalid_phase", status=400) + next_round_config = RoundConfig.objects.filter( session=locked_session, number=locked_session.current_round, -- 2.39.5 From c8750af4d8303e036aa9fa0fdbcceef072b9fe1a Mon Sep 17 00:00:00 2001 From: root Date: Tue, 17 Mar 2026 07:24:50 +0000 Subject: [PATCH 04/43] fix(gameplay): restore extracted helper imports --- lobby/views.py | 116 ------------------------------------------------- 1 file changed, 116 deletions(-) diff --git a/lobby/views.py b/lobby/views.py index 222fe1e..6a44c9d 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -116,78 +116,6 @@ def _build_finish_game_response(session: GameSession) -> JsonResponse: ) -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 _build_lie_started_payload(session: GameSession, round_config: RoundConfig, round_question: RoundQuestion) -> dict: - lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds) - return { - "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, - } - - - -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 _reset_round_question_bootstrap_state(round_question: RoundQuestion) -> RoundQuestion: Guess.objects.filter(round_question=round_question).delete() LieAnswer.objects.filter(round_question=round_question).delete() @@ -198,50 +126,6 @@ def _reset_round_question_bootstrap_state(round_question: RoundQuestion) -> Roun -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 -- 2.39.5 From 47659ed6734e884cf7055045b6717ece44997dba Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Tue, 17 Mar 2026 07:43:49 +0000 Subject: [PATCH 05/43] test(gameplay): guard extracted lobby helper wiring --- lobby/tests.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lobby/tests.py b/lobby/tests.py index 5ee08e1..b426bd1 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -10,6 +10,7 @@ from django.test import TestCase, override_settings from django.urls import reverse from django.utils import timezone +from fupogfakta import services as gameplay_services from fupogfakta.models import ( Category, GameSession, @@ -21,11 +22,20 @@ from fupogfakta.models import ( RoundQuestion, ScoreEvent, ) +from lobby import views as lobby_views from lobby.i18n import i18n_locale_config, lobby_i18n_catalog, resolve_error_message, resolve_locale User = get_user_model() +class LobbyGameplayExtractionTests(TestCase): + def test_lobby_views_use_extracted_gameplay_helpers(self): + self.assertIs(lobby_views._get_current_round_question, gameplay_services.get_current_round_question) + self.assertIs(lobby_views._select_round_question, gameplay_services.select_round_question) + self.assertIs(lobby_views._prepare_mixed_answers, gameplay_services.prepare_mixed_answers) + self.assertIs(lobby_views._resolve_scores, gameplay_services.resolve_scores) + + class LobbyFlowTests(TestCase): def setUp(self): self.host = User.objects.create_user(username="host", password="secret123") -- 2.39.5 From 212549373b3c5acbc7882e9bec99fb49f6defbc1 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Tue, 17 Mar 2026 08:25:57 +0000 Subject: [PATCH 06/43] fix(gameplay): gate next-round replay on scoreboard exit marker --- ...007_roundconfig_started_from_scoreboard.py | 18 +++++++++++++ fupogfakta/models.py | 1 + lobby/tests.py | 9 +++++-- lobby/views.py | 26 +++++-------------- 4 files changed, 32 insertions(+), 22 deletions(-) create mode 100644 fupogfakta/migrations/0007_roundconfig_started_from_scoreboard.py diff --git a/fupogfakta/migrations/0007_roundconfig_started_from_scoreboard.py b/fupogfakta/migrations/0007_roundconfig_started_from_scoreboard.py new file mode 100644 index 0000000..bfa0e77 --- /dev/null +++ b/fupogfakta/migrations/0007_roundconfig_started_from_scoreboard.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.2 on 2026-03-17 08:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fupogfakta', '0006_merge_20260315_1249'), + ] + + operations = [ + migrations.AddField( + model_name='roundconfig', + name='started_from_scoreboard', + field=models.BooleanField(default=False), + ), + ] diff --git a/fupogfakta/models.py b/fupogfakta/models.py index 8670349..3668e21 100644 --- a/fupogfakta/models.py +++ b/fupogfakta/models.py @@ -83,6 +83,7 @@ class RoundConfig(models.Model): points_bluff = models.IntegerField(default=2) lie_seconds = models.PositiveIntegerField(default=45) guess_seconds = models.PositiveIntegerField(default=30) + started_from_scoreboard = models.BooleanField(default=False) class Meta: unique_together = (("session", "number"),) diff --git a/lobby/tests.py b/lobby/tests.py index b426bd1..d5b8039 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -1377,7 +1377,12 @@ class RevealRoundFlowTests(TestCase): self.assertEqual(self.session.status, GameSession.Status.LIE) self.assertEqual(self.session.current_round, 2) self.assertTrue( - RoundConfig.objects.filter(session=self.session, number=2, category=self.category).exists() + RoundConfig.objects.filter( + session=self.session, + number=2, + category=self.category, + started_from_scoreboard=True, + ).exists() ) self.assertTrue( RoundQuestion.objects.filter(session=self.session, round_number=2, question=self.next_question).exists() @@ -1415,7 +1420,7 @@ class RevealRoundFlowTests(TestCase): self.session.status = GameSession.Status.LIE self.session.current_round = 2 self.session.save(update_fields=["status", "current_round"]) - RoundConfig.objects.create(session=self.session, number=2, category=self.category) + RoundConfig.objects.create(session=self.session, number=2, category=self.category, started_from_scoreboard=False) RoundQuestion.objects.create( session=self.session, round_number=2, diff --git a/lobby/views.py b/lobby/views.py index 6a44c9d..a046079 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -1001,6 +1001,7 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse: guess_seconds=previous_round_config.guess_seconds, points_correct=previous_round_config.points_correct, points_bluff=previous_round_config.points_bluff, + started_from_scoreboard=True, ) locked_session.current_round = next_round_number @@ -1019,31 +1020,16 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse: if locked_session.current_round <= 1: return api_error(request, code="next_round_invalid_phase", status=400) - previous_round_question = RoundQuestion.objects.filter( - session=locked_session, - round_number=locked_session.current_round - 1, - ).first() - if previous_round_question is None: - return api_error(request, code="next_round_invalid_phase", status=400) - - previous_round_players_count = Player.objects.filter(session=locked_session).count() - previous_round_guess_count = Guess.objects.filter(round_question=previous_round_question).count() - previous_round_has_score_events = ScoreEvent.objects.filter( - session=locked_session, - meta__round_question_id=previous_round_question.id, - ).exists() - previous_round_reveal_resolved = previous_round_has_score_events or ( - previous_round_players_count > 0 and previous_round_guess_count >= previous_round_players_count - ) - if not previous_round_reveal_resolved: - return api_error(request, code="next_round_invalid_phase", status=400) - next_round_config = RoundConfig.objects.filter( session=locked_session, number=locked_session.current_round, ).select_related("category").first() round_question = _get_current_round_question(locked_session) - if next_round_config is None or round_question is None: + if ( + next_round_config is None + or not next_round_config.started_from_scoreboard + or round_question is None + ): return api_error(request, code="next_round_invalid_phase", status=400) else: return api_error(request, code="next_round_invalid_phase", status=400) -- 2.39.5 From 8247787404a8139bddfe3b4506d790cd2d41117f Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Tue, 17 Mar 2026 09:08:14 +0000 Subject: [PATCH 07/43] refactor(gameplay): move transition payload builders to cartridge --- fupogfakta/payloads.py | 46 ++++++++++++++++++++++++++++++++++ lobby/tests.py | 4 ++- lobby/views.py | 56 +++--------------------------------------- 3 files changed, 53 insertions(+), 53 deletions(-) diff --git a/fupogfakta/payloads.py b/fupogfakta/payloads.py index 9024e6b..ee13fe8 100644 --- a/fupogfakta/payloads.py +++ b/fupogfakta/payloads.py @@ -68,3 +68,49 @@ def build_lie_started_payload(session: GameSession, round_config: RoundConfig, r "lie_deadline_at": lie_deadline_at.isoformat(), "lie_seconds": round_config.lie_seconds, } + + +def build_start_next_round_response( + session: GameSession, + round_config: RoundConfig, + round_question: RoundQuestion, +) -> dict: + lie_started_payload = build_lie_started_payload(session, round_config, round_question) + return { + "session": { + "code": session.code, + "status": session.status, + "current_round": session.current_round, + }, + "round": { + "number": round_config.number, + "category": { + "slug": round_config.category.slug, + "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_started_payload["lie_deadline_at"], + }, + "config": { + "lie_seconds": round_config.lie_seconds, + }, + } + + +def build_finish_game_response(session: GameSession) -> dict: + leaderboard = build_leaderboard(session) + winner = leaderboard[0] if leaderboard else None + return { + "session": { + "code": session.code, + "status": GameSession.Status.FINISHED, + "current_round": session.current_round, + }, + "winner": winner, + "leaderboard": leaderboard, + } diff --git a/lobby/tests.py b/lobby/tests.py index d5b8039..522f356 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -10,7 +10,7 @@ from django.test import TestCase, override_settings from django.urls import reverse from django.utils import timezone -from fupogfakta import services as gameplay_services +from fupogfakta import payloads as gameplay_payloads, services as gameplay_services from fupogfakta.models import ( Category, GameSession, @@ -34,6 +34,8 @@ class LobbyGameplayExtractionTests(TestCase): self.assertIs(lobby_views._select_round_question, gameplay_services.select_round_question) self.assertIs(lobby_views._prepare_mixed_answers, gameplay_services.prepare_mixed_answers) self.assertIs(lobby_views._resolve_scores, gameplay_services.resolve_scores) + self.assertIs(lobby_views._build_start_next_round_response, gameplay_payloads.build_start_next_round_response) + self.assertIs(lobby_views._build_finish_game_response, gameplay_payloads.build_finish_game_response) class LobbyFlowTests(TestCase): diff --git a/lobby/views.py b/lobby/views.py index a046079..6ace7a4 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -10,9 +10,11 @@ from django.views.decorators.http import require_GET, require_POST from fupogfakta.models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent from fupogfakta.payloads import ( + build_finish_game_response as _build_finish_game_response, build_leaderboard as _build_leaderboard, build_lie_started_payload as _build_lie_started_payload, build_reveal_payload as _build_reveal_payload, + build_start_next_round_response as _build_start_next_round_response, ) from fupogfakta.services import ( get_current_round_question as _get_current_round_question, @@ -65,56 +67,6 @@ def _create_unique_session_code() -> str: raise RuntimeError("Could not generate unique session code") -def _build_start_next_round_response( - session: GameSession, - round_config: RoundConfig, - round_question: RoundQuestion, -) -> JsonResponse: - lie_started_payload = _build_lie_started_payload(session, round_config, round_question) - return JsonResponse( - { - "session": { - "code": session.code, - "status": session.status, - "current_round": session.current_round, - }, - "round": { - "number": round_config.number, - "category": { - "slug": round_config.category.slug, - "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_started_payload["lie_deadline_at"], - }, - "config": { - "lie_seconds": round_config.lie_seconds, - }, - } - ) - - - -def _build_finish_game_response(session: GameSession) -> JsonResponse: - leaderboard = _build_leaderboard(session) - winner = leaderboard[0] if leaderboard else None - return JsonResponse( - { - "session": { - "code": session.code, - "status": GameSession.Status.FINISHED, - "current_round": session.current_round, - }, - "winner": winner, - "leaderboard": leaderboard, - } - ) - def _reset_round_question_bootstrap_state(round_question: RoundQuestion) -> RoundQuestion: Guess.objects.filter(round_question=round_question).delete() @@ -1042,7 +994,7 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse: lie_started_payload, ) - return _build_start_next_round_response(locked_session, next_round_config, round_question) + return JsonResponse(_build_start_next_round_response(locked_session, next_round_config, round_question)) @require_POST @login_required @@ -1076,7 +1028,7 @@ def finish_game(request: HttpRequest, code: str) -> JsonResponse: {"winner": winner, "leaderboard": list(leaderboard)}, ) - return _build_finish_game_response(locked_session) + return JsonResponse(_build_finish_game_response(locked_session)) @require_POST -- 2.39.5 From f736f4f74e8197b828229153141d4dce73a0ac48 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Tue, 17 Mar 2026 09:29:02 +0000 Subject: [PATCH 08/43] refactor(gameplay): move scoreboard transitions into cartridge service --- fupogfakta/services.py | 106 ++++++++++++++++++++++++++++++++++++++- fupogfakta/tests.py | 49 +++++++++++++++++- lobby/tests.py | 2 + lobby/views.py | 110 +++++++++++------------------------------ 4 files changed, 183 insertions(+), 84 deletions(-) diff --git a/fupogfakta/services.py b/fupogfakta/services.py index 562d2bb..4b6d0e4 100644 --- a/fupogfakta/services.py +++ b/fupogfakta/services.py @@ -1,6 +1,23 @@ import random +from dataclasses import dataclass -from .models import GameSession, Player, Question, RoundConfig, RoundQuestion, ScoreEvent +from django.db import transaction + +from .models import GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent + + +@dataclass(frozen=True) +class RoundTransitionResult: + session: GameSession + round_config: RoundConfig + round_question: RoundQuestion + should_broadcast: bool + + +@dataclass(frozen=True) +class FinishGameResult: + session: GameSession + should_broadcast: bool def get_current_round_question(session: GameSession) -> RoundQuestion | None: @@ -13,6 +30,16 @@ def get_current_round_question(session: GameSession) -> RoundQuestion | None: +def reset_round_question_bootstrap_state(round_question: RoundQuestion) -> RoundQuestion: + Guess.objects.filter(round_question=round_question).delete() + LieAnswer.objects.filter(round_question=round_question).delete() + if round_question.mixed_answers: + round_question.mixed_answers = [] + round_question.save(update_fields=["mixed_answers"]) + return round_question + + + 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: @@ -61,6 +88,83 @@ def prepare_mixed_answers(round_question: RoundQuestion) -> list[str]: +def start_next_round(session: GameSession) -> RoundTransitionResult: + with transaction.atomic(): + locked_session = GameSession.objects.select_for_update().get(pk=session.pk) + next_round_config = None + round_question = None + should_broadcast = False + + if locked_session.status == GameSession.Status.SCOREBOARD: + previous_round_config = RoundConfig.objects.filter( + session=locked_session, + number=locked_session.current_round, + ).select_related("category").first() + if previous_round_config is None: + raise ValueError("round_config_missing") + + next_round_number = locked_session.current_round + 1 + next_round_config = RoundConfig( + session=locked_session, + number=next_round_number, + category=previous_round_config.category, + lie_seconds=previous_round_config.lie_seconds, + guess_seconds=previous_round_config.guess_seconds, + points_correct=previous_round_config.points_correct, + points_bluff=previous_round_config.points_bluff, + started_from_scoreboard=True, + ) + locked_session.current_round = next_round_number + + round_question = reset_round_question_bootstrap_state(select_round_question(locked_session, next_round_config)) + + next_round_config.save() + locked_session.status = GameSession.Status.LIE + locked_session.save(update_fields=["current_round", "status"]) + should_broadcast = True + elif locked_session.status == GameSession.Status.LIE: + if locked_session.current_round <= 1: + raise ValueError("next_round_invalid_phase") + + next_round_config = RoundConfig.objects.filter( + session=locked_session, + number=locked_session.current_round, + ).select_related("category").first() + round_question = get_current_round_question(locked_session) + if ( + next_round_config is None + or not next_round_config.started_from_scoreboard + or round_question is None + ): + raise ValueError("next_round_invalid_phase") + else: + raise ValueError("next_round_invalid_phase") + + return RoundTransitionResult( + session=locked_session, + round_config=next_round_config, + round_question=round_question, + should_broadcast=should_broadcast, + ) + + + +def finish_game(session: GameSession) -> FinishGameResult: + with transaction.atomic(): + locked_session = GameSession.objects.select_for_update().get(pk=session.pk) + should_broadcast = False + + if locked_session.status == GameSession.Status.SCOREBOARD: + locked_session.status = GameSession.Status.FINISHED + locked_session.save(update_fields=["status"]) + should_broadcast = True + elif locked_session.status != GameSession.Status.FINISHED: + raise ValueError("finish_game_invalid_phase") + + return FinishGameResult(session=locked_session, should_broadcast=should_broadcast) + + + def resolve_scores( session: GameSession, round_question: RoundQuestion, diff --git a/fupogfakta/tests.py b/fupogfakta/tests.py index d64e10c..14fa115 100644 --- a/fupogfakta/tests.py +++ b/fupogfakta/tests.py @@ -5,7 +5,14 @@ from django.test import TestCase from fupogfakta.models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent from fupogfakta.payloads import build_lie_started_payload, build_reveal_payload -from fupogfakta.services import get_current_round_question, prepare_mixed_answers, resolve_scores, select_round_question +from fupogfakta.services import ( + finish_game, + get_current_round_question, + prepare_mixed_answers, + resolve_scores, + select_round_question, + start_next_round, +) User = get_user_model() @@ -63,6 +70,46 @@ class FupOgFaktaExtractionSliceTests(TestCase): round_question.refresh_from_db() self.assertEqual(round_question.mixed_answers, answers) + def test_start_next_round_moves_scoreboard_transition_into_service(self): + self.session.status = GameSession.Status.SCOREBOARD + self.session.save(update_fields=["status"]) + + result = start_next_round(self.session) + + self.session.refresh_from_db() + self.assertTrue(result.should_broadcast) + self.assertEqual(result.session.status, GameSession.Status.LIE) + self.assertEqual(result.session.current_round, 2) + self.assertEqual(result.round_config.number, 2) + self.assertTrue(result.round_config.started_from_scoreboard) + self.assertEqual(result.round_question.round_number, 2) + + def test_start_next_round_rejects_plain_lie_without_scoreboard_marker(self): + self.session.status = GameSession.Status.LIE + self.session.current_round = 2 + self.session.save(update_fields=["status", "current_round"]) + RoundConfig.objects.create(session=self.session, number=2, category=self.category, started_from_scoreboard=False) + RoundQuestion.objects.create( + session=self.session, + round_number=2, + question=self.question_two, + correct_answer=self.question_two.correct_answer, + ) + + with self.assertRaisesMessage(ValueError, "next_round_invalid_phase"): + start_next_round(self.session) + + def test_finish_game_moves_scoreboard_transition_into_service(self): + self.session.status = GameSession.Status.SCOREBOARD + self.session.save(update_fields=["status"]) + + result = finish_game(self.session) + + self.session.refresh_from_db() + self.assertTrue(result.should_broadcast) + self.assertEqual(result.session.status, GameSession.Status.FINISHED) + self.assertEqual(self.session.status, GameSession.Status.FINISHED) + def test_resolve_scores_applies_correct_and_bluff_points(self): round_question = RoundQuestion.objects.create( session=self.session, diff --git a/lobby/tests.py b/lobby/tests.py index 522f356..4df512f 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -34,6 +34,8 @@ class LobbyGameplayExtractionTests(TestCase): self.assertIs(lobby_views._select_round_question, gameplay_services.select_round_question) self.assertIs(lobby_views._prepare_mixed_answers, gameplay_services.prepare_mixed_answers) self.assertIs(lobby_views._resolve_scores, gameplay_services.resolve_scores) + self.assertIs(lobby_views._start_next_round, gameplay_services.start_next_round) + self.assertIs(lobby_views._finish_game, gameplay_services.finish_game) self.assertIs(lobby_views._build_start_next_round_response, gameplay_payloads.build_start_next_round_response) self.assertIs(lobby_views._build_finish_game_response, gameplay_payloads.build_finish_game_response) diff --git a/lobby/views.py b/lobby/views.py index 6ace7a4..2b87b62 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -17,10 +17,12 @@ from fupogfakta.payloads import ( build_start_next_round_response as _build_start_next_round_response, ) from fupogfakta.services import ( + finish_game as _finish_game, get_current_round_question as _get_current_round_question, prepare_mixed_answers as _prepare_mixed_answers, resolve_scores as _resolve_scores, select_round_question as _select_round_question, + start_next_round as _start_next_round, ) from realtime.broadcast import sync_broadcast_phase_event @@ -68,16 +70,6 @@ def _create_unique_session_code() -> str: -def _reset_round_question_bootstrap_state(round_question: RoundQuestion) -> RoundQuestion: - Guess.objects.filter(round_question=round_question).delete() - LieAnswer.objects.filter(round_question=round_question).delete() - if round_question.mixed_answers: - round_question.mixed_answers = [] - round_question.save(update_fields=["mixed_answers"]) - return round_question - - - def _maybe_promote_reveal_to_scoreboard(session: GameSession) -> GameSession: if session.status != GameSession.Status.REVEAL: return session @@ -930,71 +922,30 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse: if session.host_id != request.user.id: return api_error(request, code="host_only_start_next_round", status=403) - should_broadcast = False - with transaction.atomic(): - locked_session = GameSession.objects.select_for_update().select_related("host").get(pk=session.pk) - next_round_config = None - round_question = None + try: + transition = _start_next_round(session) + except ValueError as exc: + return api_error(request, code=str(exc), status=400) - if locked_session.status == GameSession.Status.SCOREBOARD: - previous_round_config = RoundConfig.objects.filter( - session=locked_session, - number=locked_session.current_round, - ).select_related("category").first() - if previous_round_config is None: - return api_error(request, code="round_config_missing", status=400) - - next_round_number = locked_session.current_round + 1 - next_round_config = RoundConfig( - session=locked_session, - number=next_round_number, - category=previous_round_config.category, - lie_seconds=previous_round_config.lie_seconds, - guess_seconds=previous_round_config.guess_seconds, - points_correct=previous_round_config.points_correct, - points_bluff=previous_round_config.points_bluff, - started_from_scoreboard=True, - ) - locked_session.current_round = next_round_number - - try: - round_question = _reset_round_question_bootstrap_state( - _select_round_question(locked_session, next_round_config) - ) - except ValueError as exc: - return api_error(request, code=str(exc), status=400) - - next_round_config.save() - locked_session.status = GameSession.Status.LIE - locked_session.save(update_fields=["current_round", "status"]) - should_broadcast = True - elif locked_session.status == GameSession.Status.LIE: - if locked_session.current_round <= 1: - return api_error(request, code="next_round_invalid_phase", status=400) - - next_round_config = RoundConfig.objects.filter( - session=locked_session, - number=locked_session.current_round, - ).select_related("category").first() - round_question = _get_current_round_question(locked_session) - if ( - next_round_config is None - or not next_round_config.started_from_scoreboard - or round_question is None - ): - return api_error(request, code="next_round_invalid_phase", status=400) - else: - return api_error(request, code="next_round_invalid_phase", status=400) - - if should_broadcast: - lie_started_payload = _build_lie_started_payload(locked_session, next_round_config, round_question) + if transition.should_broadcast: + lie_started_payload = _build_lie_started_payload( + transition.session, + transition.round_config, + transition.round_question, + ) sync_broadcast_phase_event( - locked_session.code, + transition.session.code, "phase.lie_started", lie_started_payload, ) - return JsonResponse(_build_start_next_round_response(locked_session, next_round_config, round_question)) + return JsonResponse( + _build_start_next_round_response( + transition.session, + transition.round_config, + transition.round_question, + ) + ) @require_POST @login_required @@ -1009,26 +960,21 @@ def finish_game(request: HttpRequest, code: str) -> JsonResponse: if session.host_id != request.user.id: return api_error(request, code="host_only_finish_game", status=403) - should_broadcast = False - with transaction.atomic(): - locked_session = GameSession.objects.select_for_update().get(pk=session.pk) - if locked_session.status == GameSession.Status.SCOREBOARD: - locked_session.status = GameSession.Status.FINISHED - locked_session.save(update_fields=["status"]) - should_broadcast = True - elif locked_session.status != GameSession.Status.FINISHED: - return api_error(request, code="finish_game_invalid_phase", status=400) + try: + transition = _finish_game(session) + except ValueError as exc: + return api_error(request, code=str(exc), status=400) - if should_broadcast: - leaderboard = _build_leaderboard(locked_session) + if transition.should_broadcast: + leaderboard = _build_leaderboard(transition.session) winner = leaderboard[0] if leaderboard else None sync_broadcast_phase_event( - locked_session.code, + transition.session.code, "phase.game_over", {"winner": winner, "leaderboard": list(leaderboard)}, ) - return JsonResponse(_build_finish_game_response(locked_session)) + return JsonResponse(_build_finish_game_response(transition.session)) @require_POST -- 2.39.5 From 7f20cb3bf971b2dce64ffeee2e5ce490cc815221 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Tue, 17 Mar 2026 10:13:41 +0000 Subject: [PATCH 09/43] refactor(gameplay): move scoreboard phase events into cartridge payloads --- fupogfakta/payloads.py | 25 ++++++++++++++++++++++--- lobby/tests.py | 2 ++ lobby/views.py | 15 ++++++++------- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/fupogfakta/payloads.py b/fupogfakta/payloads.py index ee13fe8..be5f386 100644 --- a/fupogfakta/payloads.py +++ b/fupogfakta/payloads.py @@ -102,15 +102,34 @@ def build_start_next_round_response( } -def build_finish_game_response(session: GameSession) -> dict: +def build_start_next_round_phase_event( + session: GameSession, + round_config: RoundConfig, + round_question: RoundQuestion, +) -> dict: + return { + "name": "phase.lie_started", + "payload": build_lie_started_payload(session, round_config, round_question), + } + + +def build_finish_game_phase_event(session: GameSession) -> dict: leaderboard = build_leaderboard(session) winner = leaderboard[0] if leaderboard else None + return { + "name": "phase.game_over", + "payload": {"winner": winner, "leaderboard": leaderboard}, + } + + +def build_finish_game_response(session: GameSession) -> dict: + finish_event = build_finish_game_phase_event(session) return { "session": { "code": session.code, "status": GameSession.Status.FINISHED, "current_round": session.current_round, }, - "winner": winner, - "leaderboard": leaderboard, + "winner": finish_event["payload"]["winner"], + "leaderboard": finish_event["payload"]["leaderboard"], } diff --git a/lobby/tests.py b/lobby/tests.py index 4df512f..2d0c22c 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -36,7 +36,9 @@ class LobbyGameplayExtractionTests(TestCase): self.assertIs(lobby_views._resolve_scores, gameplay_services.resolve_scores) self.assertIs(lobby_views._start_next_round, gameplay_services.start_next_round) self.assertIs(lobby_views._finish_game, gameplay_services.finish_game) + self.assertIs(lobby_views._build_start_next_round_phase_event, gameplay_payloads.build_start_next_round_phase_event) self.assertIs(lobby_views._build_start_next_round_response, gameplay_payloads.build_start_next_round_response) + self.assertIs(lobby_views._build_finish_game_phase_event, gameplay_payloads.build_finish_game_phase_event) self.assertIs(lobby_views._build_finish_game_response, gameplay_payloads.build_finish_game_response) diff --git a/lobby/views.py b/lobby/views.py index 2b87b62..b20afd5 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -10,10 +10,12 @@ from django.views.decorators.http import require_GET, require_POST from fupogfakta.models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent from fupogfakta.payloads import ( + build_finish_game_phase_event as _build_finish_game_phase_event, build_finish_game_response as _build_finish_game_response, build_leaderboard as _build_leaderboard, build_lie_started_payload as _build_lie_started_payload, build_reveal_payload as _build_reveal_payload, + build_start_next_round_phase_event as _build_start_next_round_phase_event, build_start_next_round_response as _build_start_next_round_response, ) from fupogfakta.services import ( @@ -928,15 +930,15 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse: return api_error(request, code=str(exc), status=400) if transition.should_broadcast: - lie_started_payload = _build_lie_started_payload( + phase_event = _build_start_next_round_phase_event( transition.session, transition.round_config, transition.round_question, ) sync_broadcast_phase_event( transition.session.code, - "phase.lie_started", - lie_started_payload, + phase_event["name"], + phase_event["payload"], ) return JsonResponse( @@ -966,12 +968,11 @@ def finish_game(request: HttpRequest, code: str) -> JsonResponse: return api_error(request, code=str(exc), status=400) if transition.should_broadcast: - leaderboard = _build_leaderboard(transition.session) - winner = leaderboard[0] if leaderboard else None + phase_event = _build_finish_game_phase_event(transition.session) sync_broadcast_phase_event( transition.session.code, - "phase.game_over", - {"winner": winner, "leaderboard": list(leaderboard)}, + phase_event["name"], + phase_event["payload"], ) return JsonResponse(_build_finish_game_response(transition.session)) -- 2.39.5 From a916da12a71f36cd74752994d4950e7bde34acdc Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Tue, 17 Mar 2026 10:41:09 +0000 Subject: [PATCH 10/43] refactor: move scoreboard promotion out of lobby view --- fupogfakta/payloads.py | 10 +++++++ fupogfakta/services.py | 64 ++++++++++++++++++++++++++++++++++++++++++ fupogfakta/tests.py | 36 ++++++++++++++++++++++++ lobby/tests.py | 2 ++ lobby/views.py | 57 +++++++++++++++---------------------- 5 files changed, 134 insertions(+), 35 deletions(-) diff --git a/fupogfakta/payloads.py b/fupogfakta/payloads.py index be5f386..0ef02a7 100644 --- a/fupogfakta/payloads.py +++ b/fupogfakta/payloads.py @@ -113,6 +113,16 @@ def build_start_next_round_phase_event( } +def build_scoreboard_phase_event(session: GameSession, leaderboard: list[dict] | None = None) -> dict: + return { + "name": "phase.scoreboard", + "payload": { + "leaderboard": leaderboard if leaderboard is not None else build_leaderboard(session), + "current_round": session.current_round, + }, + } + + def build_finish_game_phase_event(session: GameSession) -> dict: leaderboard = build_leaderboard(session) winner = leaderboard[0] if leaderboard else None diff --git a/fupogfakta/services.py b/fupogfakta/services.py index 4b6d0e4..a84a6a8 100644 --- a/fupogfakta/services.py +++ b/fupogfakta/services.py @@ -20,6 +20,13 @@ class FinishGameResult: should_broadcast: bool +@dataclass(frozen=True) +class ScoreboardTransitionResult: + session: GameSession + leaderboard: list[dict] + should_broadcast: bool + + def get_current_round_question(session: GameSession) -> RoundQuestion | None: return ( RoundQuestion.objects.filter(session=session, round_number=session.current_round) @@ -165,6 +172,63 @@ def finish_game(session: GameSession) -> FinishGameResult: +def promote_reveal_to_scoreboard(session: GameSession) -> ScoreboardTransitionResult: + if session.status != GameSession.Status.REVEAL: + leaderboard = list( + Player.objects.filter(session=session) + .order_by("-score", "nickname") + .values("id", "nickname", "score") + ) + return ScoreboardTransitionResult(session=session, leaderboard=leaderboard, should_broadcast=False) + + current_round_question = get_current_round_question(session) + if current_round_question is None: + leaderboard = list( + Player.objects.filter(session=session) + .order_by("-score", "nickname") + .values("id", "nickname", "score") + ) + return ScoreboardTransitionResult(session=session, leaderboard=leaderboard, should_broadcast=False) + + players_count = Player.objects.filter(session=session).count() + guess_count = Guess.objects.filter(round_question=current_round_question).count() + has_score_events = ScoreEvent.objects.filter( + session=session, + meta__round_question_id=current_round_question.id, + ).exists() + reveal_is_resolved = has_score_events or (players_count > 0 and guess_count >= players_count) + if not reveal_is_resolved: + leaderboard = list( + Player.objects.filter(session=session) + .order_by("-score", "nickname") + .values("id", "nickname", "score") + ) + return ScoreboardTransitionResult(session=session, leaderboard=leaderboard, should_broadcast=False) + + with transaction.atomic(): + locked_session = GameSession.objects.select_for_update().get(pk=session.pk) + if locked_session.status != GameSession.Status.REVEAL: + scoreboard_session = locked_session + should_broadcast = False + else: + locked_session.status = GameSession.Status.SCOREBOARD + locked_session.save(update_fields=["status"]) + scoreboard_session = locked_session + should_broadcast = True + + leaderboard = list( + Player.objects.filter(session=scoreboard_session) + .order_by("-score", "nickname") + .values("id", "nickname", "score") + ) + return ScoreboardTransitionResult( + session=scoreboard_session, + leaderboard=leaderboard, + should_broadcast=should_broadcast, + ) + + + def resolve_scores( session: GameSession, round_question: RoundQuestion, diff --git a/fupogfakta/tests.py b/fupogfakta/tests.py index 14fa115..306e5eb 100644 --- a/fupogfakta/tests.py +++ b/fupogfakta/tests.py @@ -9,6 +9,7 @@ from fupogfakta.services import ( finish_game, get_current_round_question, prepare_mixed_answers, + promote_reveal_to_scoreboard, resolve_scores, select_round_question, start_next_round, @@ -110,6 +111,41 @@ class FupOgFaktaExtractionSliceTests(TestCase): self.assertEqual(result.session.status, GameSession.Status.FINISHED) self.assertEqual(self.session.status, GameSession.Status.FINISHED) + def test_promote_reveal_to_scoreboard_moves_transition_into_service(self): + round_question = RoundQuestion.objects.create( + session=self.session, + round_number=1, + question=self.question_one, + correct_answer=self.question_one.correct_answer, + ) + self.session.status = GameSession.Status.REVEAL + self.session.save(update_fields=["status"]) + + LieAnswer.objects.create(round_question=round_question, player=self.alice, text="Elbil") + Guess.objects.create( + round_question=round_question, + player=self.bob, + selected_text="Elbil", + is_correct=False, + fooled_player=self.alice, + ) + ScoreEvent.objects.create( + session=self.session, + player=self.alice, + delta=5, + reason="bluff_success", + meta={"round_question_id": round_question.id}, + ) + self.alice.score = 5 + self.alice.save(update_fields=["score"]) + + result = promote_reveal_to_scoreboard(self.session) + + self.session.refresh_from_db() + self.assertTrue(result.should_broadcast) + self.assertEqual(result.session.status, GameSession.Status.SCOREBOARD) + self.assertEqual(result.leaderboard[0]["nickname"], self.alice.nickname) + def test_resolve_scores_applies_correct_and_bluff_points(self): round_question = RoundQuestion.objects.create( session=self.session, diff --git a/lobby/tests.py b/lobby/tests.py index 2d0c22c..5e241ac 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -34,8 +34,10 @@ class LobbyGameplayExtractionTests(TestCase): self.assertIs(lobby_views._select_round_question, gameplay_services.select_round_question) self.assertIs(lobby_views._prepare_mixed_answers, gameplay_services.prepare_mixed_answers) self.assertIs(lobby_views._resolve_scores, gameplay_services.resolve_scores) + self.assertIs(lobby_views._promote_reveal_to_scoreboard, gameplay_services.promote_reveal_to_scoreboard) self.assertIs(lobby_views._start_next_round, gameplay_services.start_next_round) self.assertIs(lobby_views._finish_game, gameplay_services.finish_game) + self.assertIs(lobby_views._build_scoreboard_phase_event, gameplay_payloads.build_scoreboard_phase_event) self.assertIs(lobby_views._build_start_next_round_phase_event, gameplay_payloads.build_start_next_round_phase_event) self.assertIs(lobby_views._build_start_next_round_response, gameplay_payloads.build_start_next_round_response) self.assertIs(lobby_views._build_finish_game_phase_event, gameplay_payloads.build_finish_game_phase_event) diff --git a/lobby/views.py b/lobby/views.py index b20afd5..82a20fb 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -15,6 +15,7 @@ from fupogfakta.payloads import ( build_leaderboard as _build_leaderboard, build_lie_started_payload as _build_lie_started_payload, build_reveal_payload as _build_reveal_payload, + build_scoreboard_phase_event as _build_scoreboard_phase_event, build_start_next_round_phase_event as _build_start_next_round_phase_event, build_start_next_round_response as _build_start_next_round_response, ) @@ -22,6 +23,7 @@ from fupogfakta.services import ( finish_game as _finish_game, get_current_round_question as _get_current_round_question, prepare_mixed_answers as _prepare_mixed_answers, + promote_reveal_to_scoreboard as _promote_reveal_to_scoreboard, resolve_scores as _resolve_scores, select_round_question as _select_round_question, start_next_round as _start_next_round, @@ -73,38 +75,15 @@ def _create_unique_session_code() -> str: 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 - - players_count = Player.objects.filter(session=session).count() - guess_count = Guess.objects.filter(round_question=current_round_question).count() - has_score_events = ScoreEvent.objects.filter( - session=session, - meta__round_question_id=current_round_question.id, - ).exists() - reveal_is_resolved = has_score_events or (players_count > 0 and guess_count >= players_count) - if not reveal_is_resolved: - 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 + transition = _promote_reveal_to_scoreboard(session) + if transition.should_broadcast: + phase_event = _build_scoreboard_phase_event(transition.session, transition.leaderboard) + sync_broadcast_phase_event( + transition.session.code, + phase_event["name"], + phase_event["payload"], + ) + return transition.session @@ -290,7 +269,7 @@ 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) + "scoreboard": _build_scoreboard_phase_event(session)["payload"]["leaderboard"] if session.status in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED} else None, "phase_view_model": phase_view_model, @@ -893,11 +872,19 @@ def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse: if session.host_id != request.user.id: return api_error(request, code="host_only_view_scoreboard", status=403) - session = _maybe_promote_reveal_to_scoreboard(session) + transition = _promote_reveal_to_scoreboard(session) + if transition.should_broadcast: + phase_event = _build_scoreboard_phase_event(transition.session, transition.leaderboard) + sync_broadcast_phase_event( + transition.session.code, + phase_event["name"], + phase_event["payload"], + ) + session = transition.session if session.status not in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}: return api_error(request, code="scoreboard_invalid_phase", status=400) - leaderboard = _build_leaderboard(session) + leaderboard = transition.leaderboard return JsonResponse( { -- 2.39.5 From 35e2d09ee354543015ca3573d37812ad862802ac Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Tue, 17 Mar 2026 10:55:41 +0000 Subject: [PATCH 11/43] test(gameplay): lock lobby host-transition delegation --- lobby/tests.py | 102 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/lobby/tests.py b/lobby/tests.py index 5e241ac..f617c40 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -29,6 +29,27 @@ User = get_user_model() class LobbyGameplayExtractionTests(TestCase): + def setUp(self): + self.host = User.objects.create_user(username="extract_host", password="secret123") + self.client.login(username="extract_host", password="secret123") + self.session = GameSession.objects.create( + host=self.host, + code="EXTR42", + status=GameSession.Status.SCOREBOARD, + ) + self.category = Category.objects.create(name="Historie", slug="historie-extract", is_active=True) + self.round_config = RoundConfig.objects.create( + session=self.session, + number=1, + category=self.category, + ) + self.question = Question.objects.create( + category=self.category, + prompt="Hvornår faldt muren?", + correct_answer="1989", + is_active=True, + ) + def test_lobby_views_use_extracted_gameplay_helpers(self): self.assertIs(lobby_views._get_current_round_question, gameplay_services.get_current_round_question) self.assertIs(lobby_views._select_round_question, gameplay_services.select_round_question) @@ -43,6 +64,87 @@ class LobbyGameplayExtractionTests(TestCase): self.assertIs(lobby_views._build_finish_game_phase_event, gameplay_payloads.build_finish_game_phase_event) self.assertIs(lobby_views._build_finish_game_response, gameplay_payloads.build_finish_game_response) + @patch("lobby.views.sync_broadcast_phase_event") + @patch("lobby.views._build_start_next_round_response", return_value={"ok": True}) + @patch("lobby.views._build_start_next_round_phase_event") + @patch("lobby.views._start_next_round") + def test_start_next_round_view_delegates_transition_to_service( + self, + mock_start_next_round, + mock_build_phase_event, + mock_build_response, + mock_sync_broadcast_phase_event, + ): + next_round_config = RoundConfig.objects.create( + session=self.session, + number=2, + category=self.category, + started_from_scoreboard=True, + ) + round_question = RoundQuestion.objects.create( + session=self.session, + round_number=2, + question=self.question, + correct_answer=self.question.correct_answer, + ) + transition = gameplay_services.RoundTransitionResult( + session=self.session, + round_config=next_round_config, + round_question=round_question, + should_broadcast=True, + ) + mock_start_next_round.return_value = transition + mock_build_phase_event.return_value = { + "name": "phase.lie_started", + "payload": {"round_question_id": round_question.id}, + } + + response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"ok": True}) + mock_start_next_round.assert_called_once_with(self.session) + mock_build_phase_event.assert_called_once_with(self.session, next_round_config, round_question) + mock_build_response.assert_called_once_with(self.session, next_round_config, round_question) + mock_sync_broadcast_phase_event.assert_called_once_with( + self.session.code, + "phase.lie_started", + {"round_question_id": round_question.id}, + ) + + @patch("lobby.views.sync_broadcast_phase_event") + @patch("lobby.views._build_finish_game_response", return_value={"ok": True}) + @patch("lobby.views._build_finish_game_phase_event") + @patch("lobby.views._finish_game") + def test_finish_game_view_delegates_transition_to_service( + self, + mock_finish_game, + mock_build_phase_event, + mock_build_response, + mock_sync_broadcast_phase_event, + ): + finished_session = GameSession.objects.get(pk=self.session.pk) + finished_session.status = GameSession.Status.FINISHED + transition = gameplay_services.FinishGameResult(session=finished_session, should_broadcast=True) + mock_finish_game.return_value = transition + mock_build_phase_event.return_value = { + "name": "phase.game_over", + "payload": {"winner": None, "leaderboard": []}, + } + + response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code})) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"ok": True}) + mock_finish_game.assert_called_once_with(self.session) + mock_build_phase_event.assert_called_once_with(finished_session) + mock_build_response.assert_called_once_with(finished_session) + mock_sync_broadcast_phase_event.assert_called_once_with( + self.session.code, + "phase.game_over", + {"winner": None, "leaderboard": []}, + ) + class LobbyFlowTests(TestCase): def setUp(self): -- 2.39.5 From 9baade0105723f830f42923a72ac4e4b3804df81 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Tue, 17 Mar 2026 11:35:19 +0000 Subject: [PATCH 12/43] test(gameplay): lock lobby replay side-effect delegation --- lobby/tests.py | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/lobby/tests.py b/lobby/tests.py index f617c40..7cd943b 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -145,6 +145,74 @@ class LobbyGameplayExtractionTests(TestCase): {"winner": None, "leaderboard": []}, ) + @patch("lobby.views.sync_broadcast_phase_event") + @patch("lobby.views._build_start_next_round_response", return_value={"ok": True}) + @patch("lobby.views._build_start_next_round_phase_event") + @patch("lobby.views._start_next_round") + def test_start_next_round_view_skips_broadcast_on_service_replay( + self, + mock_start_next_round, + mock_build_phase_event, + mock_build_response, + mock_sync_broadcast_phase_event, + ): + replay_round_config = RoundConfig.objects.create( + session=self.session, + number=2, + category=self.category, + started_from_scoreboard=True, + ) + round_question = RoundQuestion.objects.create( + session=self.session, + round_number=2, + question=self.question, + correct_answer=self.question.correct_answer, + ) + replay_session = GameSession.objects.get(pk=self.session.pk) + replay_session.status = GameSession.Status.LIE + replay_session.current_round = 2 + transition = gameplay_services.RoundTransitionResult( + session=replay_session, + round_config=replay_round_config, + round_question=round_question, + should_broadcast=False, + ) + mock_start_next_round.return_value = transition + + response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"ok": True}) + mock_start_next_round.assert_called_once_with(self.session) + mock_build_phase_event.assert_not_called() + mock_build_response.assert_called_once_with(replay_session, replay_round_config, round_question) + mock_sync_broadcast_phase_event.assert_not_called() + + @patch("lobby.views.sync_broadcast_phase_event") + @patch("lobby.views._build_finish_game_response", return_value={"ok": True}) + @patch("lobby.views._build_finish_game_phase_event") + @patch("lobby.views._finish_game") + def test_finish_game_view_skips_broadcast_on_service_replay( + self, + mock_finish_game, + mock_build_phase_event, + mock_build_response, + mock_sync_broadcast_phase_event, + ): + finished_session = GameSession.objects.get(pk=self.session.pk) + finished_session.status = GameSession.Status.FINISHED + transition = gameplay_services.FinishGameResult(session=finished_session, should_broadcast=False) + mock_finish_game.return_value = transition + + response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code})) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"ok": True}) + mock_finish_game.assert_called_once_with(self.session) + mock_build_phase_event.assert_not_called() + mock_build_response.assert_called_once_with(finished_session) + mock_sync_broadcast_phase_event.assert_not_called() + class LobbyFlowTests(TestCase): def setUp(self): -- 2.39.5 From 8a07433f11dc57f6169b084fb2a6f4150339f656 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Tue, 17 Mar 2026 11:58:39 +0000 Subject: [PATCH 13/43] refactor(gameplay): move transition event composition into service --- fupogfakta/services.py | 26 +++++++++++++++++++++++++- lobby/tests.py | 31 ++++++++----------------------- lobby/views.py | 16 ++++------------ 3 files changed, 37 insertions(+), 36 deletions(-) diff --git a/fupogfakta/services.py b/fupogfakta/services.py index a84a6a8..283cb74 100644 --- a/fupogfakta/services.py +++ b/fupogfakta/services.py @@ -1,9 +1,11 @@ import random from dataclasses import dataclass +from typing import Any from django.db import transaction from .models import GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent +from .payloads import build_finish_game_phase_event, build_start_next_round_phase_event @dataclass(frozen=True) @@ -12,12 +14,16 @@ class RoundTransitionResult: round_config: RoundConfig round_question: RoundQuestion should_broadcast: bool + phase_event_name: str | None = None + phase_event_payload: dict[str, Any] | None = None @dataclass(frozen=True) class FinishGameResult: session: GameSession should_broadcast: bool + phase_event_name: str | None = None + phase_event_payload: dict[str, Any] | None = None @dataclass(frozen=True) @@ -102,6 +108,9 @@ def start_next_round(session: GameSession) -> RoundTransitionResult: round_question = None should_broadcast = False + phase_event_name = None + phase_event_payload = None + if locked_session.status == GameSession.Status.SCOREBOARD: previous_round_config = RoundConfig.objects.filter( session=locked_session, @@ -129,6 +138,9 @@ def start_next_round(session: GameSession) -> RoundTransitionResult: locked_session.status = GameSession.Status.LIE locked_session.save(update_fields=["current_round", "status"]) should_broadcast = True + phase_event = build_start_next_round_phase_event(locked_session, next_round_config, round_question) + phase_event_name = phase_event["name"] + phase_event_payload = phase_event["payload"] elif locked_session.status == GameSession.Status.LIE: if locked_session.current_round <= 1: raise ValueError("next_round_invalid_phase") @@ -152,6 +164,8 @@ def start_next_round(session: GameSession) -> RoundTransitionResult: round_config=next_round_config, round_question=round_question, should_broadcast=should_broadcast, + phase_event_name=phase_event_name, + phase_event_payload=phase_event_payload, ) @@ -160,15 +174,25 @@ def finish_game(session: GameSession) -> FinishGameResult: with transaction.atomic(): locked_session = GameSession.objects.select_for_update().get(pk=session.pk) should_broadcast = False + phase_event_name = None + phase_event_payload = None if locked_session.status == GameSession.Status.SCOREBOARD: locked_session.status = GameSession.Status.FINISHED locked_session.save(update_fields=["status"]) should_broadcast = True + phase_event = build_finish_game_phase_event(locked_session) + phase_event_name = phase_event["name"] + phase_event_payload = phase_event["payload"] elif locked_session.status != GameSession.Status.FINISHED: raise ValueError("finish_game_invalid_phase") - return FinishGameResult(session=locked_session, should_broadcast=should_broadcast) + return FinishGameResult( + session=locked_session, + should_broadcast=should_broadcast, + phase_event_name=phase_event_name, + phase_event_payload=phase_event_payload, + ) diff --git a/lobby/tests.py b/lobby/tests.py index 7cd943b..16026ab 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -59,19 +59,15 @@ class LobbyGameplayExtractionTests(TestCase): self.assertIs(lobby_views._start_next_round, gameplay_services.start_next_round) self.assertIs(lobby_views._finish_game, gameplay_services.finish_game) self.assertIs(lobby_views._build_scoreboard_phase_event, gameplay_payloads.build_scoreboard_phase_event) - self.assertIs(lobby_views._build_start_next_round_phase_event, gameplay_payloads.build_start_next_round_phase_event) self.assertIs(lobby_views._build_start_next_round_response, gameplay_payloads.build_start_next_round_response) - self.assertIs(lobby_views._build_finish_game_phase_event, gameplay_payloads.build_finish_game_phase_event) self.assertIs(lobby_views._build_finish_game_response, gameplay_payloads.build_finish_game_response) @patch("lobby.views.sync_broadcast_phase_event") @patch("lobby.views._build_start_next_round_response", return_value={"ok": True}) - @patch("lobby.views._build_start_next_round_phase_event") @patch("lobby.views._start_next_round") def test_start_next_round_view_delegates_transition_to_service( self, mock_start_next_round, - mock_build_phase_event, mock_build_response, mock_sync_broadcast_phase_event, ): @@ -92,19 +88,16 @@ class LobbyGameplayExtractionTests(TestCase): round_config=next_round_config, round_question=round_question, should_broadcast=True, + phase_event_name="phase.lie_started", + phase_event_payload={"round_question_id": round_question.id}, ) mock_start_next_round.return_value = transition - mock_build_phase_event.return_value = { - "name": "phase.lie_started", - "payload": {"round_question_id": round_question.id}, - } response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), {"ok": True}) mock_start_next_round.assert_called_once_with(self.session) - mock_build_phase_event.assert_called_once_with(self.session, next_round_config, round_question) mock_build_response.assert_called_once_with(self.session, next_round_config, round_question) mock_sync_broadcast_phase_event.assert_called_once_with( self.session.code, @@ -114,30 +107,28 @@ class LobbyGameplayExtractionTests(TestCase): @patch("lobby.views.sync_broadcast_phase_event") @patch("lobby.views._build_finish_game_response", return_value={"ok": True}) - @patch("lobby.views._build_finish_game_phase_event") @patch("lobby.views._finish_game") def test_finish_game_view_delegates_transition_to_service( self, mock_finish_game, - mock_build_phase_event, mock_build_response, mock_sync_broadcast_phase_event, ): finished_session = GameSession.objects.get(pk=self.session.pk) finished_session.status = GameSession.Status.FINISHED - transition = gameplay_services.FinishGameResult(session=finished_session, should_broadcast=True) + transition = gameplay_services.FinishGameResult( + session=finished_session, + should_broadcast=True, + phase_event_name="phase.game_over", + phase_event_payload={"winner": None, "leaderboard": []}, + ) mock_finish_game.return_value = transition - mock_build_phase_event.return_value = { - "name": "phase.game_over", - "payload": {"winner": None, "leaderboard": []}, - } response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code})) self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), {"ok": True}) mock_finish_game.assert_called_once_with(self.session) - mock_build_phase_event.assert_called_once_with(finished_session) mock_build_response.assert_called_once_with(finished_session) mock_sync_broadcast_phase_event.assert_called_once_with( self.session.code, @@ -147,12 +138,10 @@ class LobbyGameplayExtractionTests(TestCase): @patch("lobby.views.sync_broadcast_phase_event") @patch("lobby.views._build_start_next_round_response", return_value={"ok": True}) - @patch("lobby.views._build_start_next_round_phase_event") @patch("lobby.views._start_next_round") def test_start_next_round_view_skips_broadcast_on_service_replay( self, mock_start_next_round, - mock_build_phase_event, mock_build_response, mock_sync_broadcast_phase_event, ): @@ -184,18 +173,15 @@ class LobbyGameplayExtractionTests(TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), {"ok": True}) mock_start_next_round.assert_called_once_with(self.session) - mock_build_phase_event.assert_not_called() mock_build_response.assert_called_once_with(replay_session, replay_round_config, round_question) mock_sync_broadcast_phase_event.assert_not_called() @patch("lobby.views.sync_broadcast_phase_event") @patch("lobby.views._build_finish_game_response", return_value={"ok": True}) - @patch("lobby.views._build_finish_game_phase_event") @patch("lobby.views._finish_game") def test_finish_game_view_skips_broadcast_on_service_replay( self, mock_finish_game, - mock_build_phase_event, mock_build_response, mock_sync_broadcast_phase_event, ): @@ -209,7 +195,6 @@ class LobbyGameplayExtractionTests(TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), {"ok": True}) mock_finish_game.assert_called_once_with(self.session) - mock_build_phase_event.assert_not_called() mock_build_response.assert_called_once_with(finished_session) mock_sync_broadcast_phase_event.assert_not_called() diff --git a/lobby/views.py b/lobby/views.py index 82a20fb..4c81944 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -10,13 +10,11 @@ from django.views.decorators.http import require_GET, require_POST from fupogfakta.models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent from fupogfakta.payloads import ( - build_finish_game_phase_event as _build_finish_game_phase_event, build_finish_game_response as _build_finish_game_response, build_leaderboard as _build_leaderboard, build_lie_started_payload as _build_lie_started_payload, build_reveal_payload as _build_reveal_payload, build_scoreboard_phase_event as _build_scoreboard_phase_event, - build_start_next_round_phase_event as _build_start_next_round_phase_event, build_start_next_round_response as _build_start_next_round_response, ) from fupogfakta.services import ( @@ -917,15 +915,10 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse: return api_error(request, code=str(exc), status=400) if transition.should_broadcast: - phase_event = _build_start_next_round_phase_event( - transition.session, - transition.round_config, - transition.round_question, - ) sync_broadcast_phase_event( transition.session.code, - phase_event["name"], - phase_event["payload"], + transition.phase_event_name, + transition.phase_event_payload, ) return JsonResponse( @@ -955,11 +948,10 @@ def finish_game(request: HttpRequest, code: str) -> JsonResponse: return api_error(request, code=str(exc), status=400) if transition.should_broadcast: - phase_event = _build_finish_game_phase_event(transition.session) sync_broadcast_phase_event( transition.session.code, - phase_event["name"], - phase_event["payload"], + transition.phase_event_name, + transition.phase_event_payload, ) return JsonResponse(_build_finish_game_response(transition.session)) -- 2.39.5 From d272e35a792e824668fe8f11005f328417fc995c Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Tue, 17 Mar 2026 13:04:58 +0000 Subject: [PATCH 14/43] refactor(gameplay): keep host transition events in payload layer --- fupogfakta/services.py | 26 +------ lobby/tests.py | 159 +---------------------------------------- lobby/views.py | 16 +++-- 3 files changed, 15 insertions(+), 186 deletions(-) diff --git a/fupogfakta/services.py b/fupogfakta/services.py index 283cb74..a84a6a8 100644 --- a/fupogfakta/services.py +++ b/fupogfakta/services.py @@ -1,11 +1,9 @@ import random from dataclasses import dataclass -from typing import Any from django.db import transaction from .models import GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent -from .payloads import build_finish_game_phase_event, build_start_next_round_phase_event @dataclass(frozen=True) @@ -14,16 +12,12 @@ class RoundTransitionResult: round_config: RoundConfig round_question: RoundQuestion should_broadcast: bool - phase_event_name: str | None = None - phase_event_payload: dict[str, Any] | None = None @dataclass(frozen=True) class FinishGameResult: session: GameSession should_broadcast: bool - phase_event_name: str | None = None - phase_event_payload: dict[str, Any] | None = None @dataclass(frozen=True) @@ -108,9 +102,6 @@ def start_next_round(session: GameSession) -> RoundTransitionResult: round_question = None should_broadcast = False - phase_event_name = None - phase_event_payload = None - if locked_session.status == GameSession.Status.SCOREBOARD: previous_round_config = RoundConfig.objects.filter( session=locked_session, @@ -138,9 +129,6 @@ def start_next_round(session: GameSession) -> RoundTransitionResult: locked_session.status = GameSession.Status.LIE locked_session.save(update_fields=["current_round", "status"]) should_broadcast = True - phase_event = build_start_next_round_phase_event(locked_session, next_round_config, round_question) - phase_event_name = phase_event["name"] - phase_event_payload = phase_event["payload"] elif locked_session.status == GameSession.Status.LIE: if locked_session.current_round <= 1: raise ValueError("next_round_invalid_phase") @@ -164,8 +152,6 @@ def start_next_round(session: GameSession) -> RoundTransitionResult: round_config=next_round_config, round_question=round_question, should_broadcast=should_broadcast, - phase_event_name=phase_event_name, - phase_event_payload=phase_event_payload, ) @@ -174,25 +160,15 @@ def finish_game(session: GameSession) -> FinishGameResult: with transaction.atomic(): locked_session = GameSession.objects.select_for_update().get(pk=session.pk) should_broadcast = False - phase_event_name = None - phase_event_payload = None if locked_session.status == GameSession.Status.SCOREBOARD: locked_session.status = GameSession.Status.FINISHED locked_session.save(update_fields=["status"]) should_broadcast = True - phase_event = build_finish_game_phase_event(locked_session) - phase_event_name = phase_event["name"] - phase_event_payload = phase_event["payload"] elif locked_session.status != GameSession.Status.FINISHED: raise ValueError("finish_game_invalid_phase") - return FinishGameResult( - session=locked_session, - should_broadcast=should_broadcast, - phase_event_name=phase_event_name, - phase_event_payload=phase_event_payload, - ) + return FinishGameResult(session=locked_session, should_broadcast=should_broadcast) diff --git a/lobby/tests.py b/lobby/tests.py index 16026ab..5e241ac 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -29,27 +29,6 @@ User = get_user_model() class LobbyGameplayExtractionTests(TestCase): - def setUp(self): - self.host = User.objects.create_user(username="extract_host", password="secret123") - self.client.login(username="extract_host", password="secret123") - self.session = GameSession.objects.create( - host=self.host, - code="EXTR42", - status=GameSession.Status.SCOREBOARD, - ) - self.category = Category.objects.create(name="Historie", slug="historie-extract", is_active=True) - self.round_config = RoundConfig.objects.create( - session=self.session, - number=1, - category=self.category, - ) - self.question = Question.objects.create( - category=self.category, - prompt="Hvornår faldt muren?", - correct_answer="1989", - is_active=True, - ) - def test_lobby_views_use_extracted_gameplay_helpers(self): self.assertIs(lobby_views._get_current_round_question, gameplay_services.get_current_round_question) self.assertIs(lobby_views._select_round_question, gameplay_services.select_round_question) @@ -59,145 +38,11 @@ class LobbyGameplayExtractionTests(TestCase): self.assertIs(lobby_views._start_next_round, gameplay_services.start_next_round) self.assertIs(lobby_views._finish_game, gameplay_services.finish_game) self.assertIs(lobby_views._build_scoreboard_phase_event, gameplay_payloads.build_scoreboard_phase_event) + self.assertIs(lobby_views._build_start_next_round_phase_event, gameplay_payloads.build_start_next_round_phase_event) self.assertIs(lobby_views._build_start_next_round_response, gameplay_payloads.build_start_next_round_response) + self.assertIs(lobby_views._build_finish_game_phase_event, gameplay_payloads.build_finish_game_phase_event) self.assertIs(lobby_views._build_finish_game_response, gameplay_payloads.build_finish_game_response) - @patch("lobby.views.sync_broadcast_phase_event") - @patch("lobby.views._build_start_next_round_response", return_value={"ok": True}) - @patch("lobby.views._start_next_round") - def test_start_next_round_view_delegates_transition_to_service( - self, - mock_start_next_round, - mock_build_response, - mock_sync_broadcast_phase_event, - ): - next_round_config = RoundConfig.objects.create( - session=self.session, - number=2, - category=self.category, - started_from_scoreboard=True, - ) - round_question = RoundQuestion.objects.create( - session=self.session, - round_number=2, - question=self.question, - correct_answer=self.question.correct_answer, - ) - transition = gameplay_services.RoundTransitionResult( - session=self.session, - round_config=next_round_config, - round_question=round_question, - should_broadcast=True, - phase_event_name="phase.lie_started", - phase_event_payload={"round_question_id": round_question.id}, - ) - mock_start_next_round.return_value = transition - - response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), {"ok": True}) - mock_start_next_round.assert_called_once_with(self.session) - mock_build_response.assert_called_once_with(self.session, next_round_config, round_question) - mock_sync_broadcast_phase_event.assert_called_once_with( - self.session.code, - "phase.lie_started", - {"round_question_id": round_question.id}, - ) - - @patch("lobby.views.sync_broadcast_phase_event") - @patch("lobby.views._build_finish_game_response", return_value={"ok": True}) - @patch("lobby.views._finish_game") - def test_finish_game_view_delegates_transition_to_service( - self, - mock_finish_game, - mock_build_response, - mock_sync_broadcast_phase_event, - ): - finished_session = GameSession.objects.get(pk=self.session.pk) - finished_session.status = GameSession.Status.FINISHED - transition = gameplay_services.FinishGameResult( - session=finished_session, - should_broadcast=True, - phase_event_name="phase.game_over", - phase_event_payload={"winner": None, "leaderboard": []}, - ) - mock_finish_game.return_value = transition - - response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code})) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), {"ok": True}) - mock_finish_game.assert_called_once_with(self.session) - mock_build_response.assert_called_once_with(finished_session) - mock_sync_broadcast_phase_event.assert_called_once_with( - self.session.code, - "phase.game_over", - {"winner": None, "leaderboard": []}, - ) - - @patch("lobby.views.sync_broadcast_phase_event") - @patch("lobby.views._build_start_next_round_response", return_value={"ok": True}) - @patch("lobby.views._start_next_round") - def test_start_next_round_view_skips_broadcast_on_service_replay( - self, - mock_start_next_round, - mock_build_response, - mock_sync_broadcast_phase_event, - ): - replay_round_config = RoundConfig.objects.create( - session=self.session, - number=2, - category=self.category, - started_from_scoreboard=True, - ) - round_question = RoundQuestion.objects.create( - session=self.session, - round_number=2, - question=self.question, - correct_answer=self.question.correct_answer, - ) - replay_session = GameSession.objects.get(pk=self.session.pk) - replay_session.status = GameSession.Status.LIE - replay_session.current_round = 2 - transition = gameplay_services.RoundTransitionResult( - session=replay_session, - round_config=replay_round_config, - round_question=round_question, - should_broadcast=False, - ) - mock_start_next_round.return_value = transition - - response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), {"ok": True}) - mock_start_next_round.assert_called_once_with(self.session) - mock_build_response.assert_called_once_with(replay_session, replay_round_config, round_question) - mock_sync_broadcast_phase_event.assert_not_called() - - @patch("lobby.views.sync_broadcast_phase_event") - @patch("lobby.views._build_finish_game_response", return_value={"ok": True}) - @patch("lobby.views._finish_game") - def test_finish_game_view_skips_broadcast_on_service_replay( - self, - mock_finish_game, - mock_build_response, - mock_sync_broadcast_phase_event, - ): - finished_session = GameSession.objects.get(pk=self.session.pk) - finished_session.status = GameSession.Status.FINISHED - transition = gameplay_services.FinishGameResult(session=finished_session, should_broadcast=False) - mock_finish_game.return_value = transition - - response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code})) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), {"ok": True}) - mock_finish_game.assert_called_once_with(self.session) - mock_build_response.assert_called_once_with(finished_session) - mock_sync_broadcast_phase_event.assert_not_called() - class LobbyFlowTests(TestCase): def setUp(self): diff --git a/lobby/views.py b/lobby/views.py index 4c81944..82a20fb 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -10,11 +10,13 @@ from django.views.decorators.http import require_GET, require_POST from fupogfakta.models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent from fupogfakta.payloads import ( + build_finish_game_phase_event as _build_finish_game_phase_event, build_finish_game_response as _build_finish_game_response, build_leaderboard as _build_leaderboard, build_lie_started_payload as _build_lie_started_payload, build_reveal_payload as _build_reveal_payload, build_scoreboard_phase_event as _build_scoreboard_phase_event, + build_start_next_round_phase_event as _build_start_next_round_phase_event, build_start_next_round_response as _build_start_next_round_response, ) from fupogfakta.services import ( @@ -915,10 +917,15 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse: return api_error(request, code=str(exc), status=400) if transition.should_broadcast: + phase_event = _build_start_next_round_phase_event( + transition.session, + transition.round_config, + transition.round_question, + ) sync_broadcast_phase_event( transition.session.code, - transition.phase_event_name, - transition.phase_event_payload, + phase_event["name"], + phase_event["payload"], ) return JsonResponse( @@ -948,10 +955,11 @@ def finish_game(request: HttpRequest, code: str) -> JsonResponse: return api_error(request, code=str(exc), status=400) if transition.should_broadcast: + phase_event = _build_finish_game_phase_event(transition.session) sync_broadcast_phase_event( transition.session.code, - transition.phase_event_name, - transition.phase_event_payload, + phase_event["name"], + phase_event["payload"], ) return JsonResponse(_build_finish_game_response(transition.session)) -- 2.39.5 From a102a72a7744e31624caee7ed28b5ecb54167312 Mon Sep 17 00:00:00 2001 From: dev-bot Date: Tue, 17 Mar 2026 13:21:52 +0000 Subject: [PATCH 15/43] fix(gameplay): refresh reused bootstrap lie timer --- fupogfakta/services.py | 10 +++++++++- fupogfakta/tests.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/fupogfakta/services.py b/fupogfakta/services.py index a84a6a8..999a89d 100644 --- a/fupogfakta/services.py +++ b/fupogfakta/services.py @@ -2,6 +2,7 @@ import random from dataclasses import dataclass from django.db import transaction +from django.utils import timezone from .models import GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent @@ -40,9 +41,16 @@ def get_current_round_question(session: GameSession) -> RoundQuestion | None: def reset_round_question_bootstrap_state(round_question: RoundQuestion) -> RoundQuestion: Guess.objects.filter(round_question=round_question).delete() LieAnswer.objects.filter(round_question=round_question).delete() + + update_fields: list[str] = [] if round_question.mixed_answers: round_question.mixed_answers = [] - round_question.save(update_fields=["mixed_answers"]) + update_fields.append("mixed_answers") + + round_question.shown_at = timezone.now() + update_fields.append("shown_at") + + round_question.save(update_fields=update_fields) return round_question diff --git a/fupogfakta/tests.py b/fupogfakta/tests.py index 306e5eb..85bd65d 100644 --- a/fupogfakta/tests.py +++ b/fupogfakta/tests.py @@ -1,7 +1,9 @@ +from datetime import timedelta from unittest.mock import patch from django.contrib.auth import get_user_model from django.test import TestCase +from django.utils import timezone from fupogfakta.models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent from fupogfakta.payloads import build_lie_started_payload, build_reveal_payload @@ -100,6 +102,39 @@ class FupOgFaktaExtractionSliceTests(TestCase): with self.assertRaisesMessage(ValueError, "next_round_invalid_phase"): start_next_round(self.session) + def test_start_next_round_refreshes_shown_at_for_reused_bootstrap_question(self): + self.session.status = GameSession.Status.SCOREBOARD + self.session.save(update_fields=["status"]) + stale_shown_at = timezone.now() - timedelta(minutes=10) + stale_round_question = RoundQuestion.objects.create( + session=self.session, + round_number=2, + question=self.question_two, + correct_answer=self.question_two.correct_answer, + shown_at=stale_shown_at, + mixed_answers=["Stale truth", "Stale lie"], + ) + LieAnswer.objects.create(round_question=stale_round_question, player=self.alice, text="Stale lie") + Guess.objects.create( + round_question=stale_round_question, + player=self.bob, + selected_text="Stale truth", + is_correct=True, + ) + + before_transition = timezone.now() + result = start_next_round(self.session) + after_transition = timezone.now() + + stale_round_question.refresh_from_db() + self.assertEqual(result.round_question.id, stale_round_question.id) + self.assertGreaterEqual(stale_round_question.shown_at, before_transition) + self.assertLessEqual(stale_round_question.shown_at, after_transition) + self.assertNotEqual(stale_round_question.shown_at, stale_shown_at) + self.assertEqual(stale_round_question.mixed_answers, []) + self.assertEqual(stale_round_question.lies.count(), 0) + self.assertEqual(stale_round_question.guesses.count(), 0) + def test_finish_game_moves_scoreboard_transition_into_service(self): self.session.status = GameSession.Status.SCOREBOARD self.session.save(update_fields=["status"]) -- 2.39.5 From 94f940e5d8aebbc2e00129b429617785d3047e76 Mon Sep 17 00:00:00 2001 From: dev-bot Date: Tue, 17 Mar 2026 13:43:44 +0000 Subject: [PATCH 16/43] refactor(gameplay): delegate host transition events from service --- fupogfakta/services.py | 26 ++++++- lobby/tests.py | 158 ++++++++++++++++++++++++++++++++++++++++- lobby/views.py | 16 ++--- 3 files changed, 185 insertions(+), 15 deletions(-) diff --git a/fupogfakta/services.py b/fupogfakta/services.py index 999a89d..095d1e3 100644 --- a/fupogfakta/services.py +++ b/fupogfakta/services.py @@ -1,10 +1,12 @@ import random from dataclasses import dataclass +from typing import Any from django.db import transaction from django.utils import timezone from .models import GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent +from .payloads import build_finish_game_phase_event, build_start_next_round_phase_event @dataclass(frozen=True) @@ -13,12 +15,16 @@ class RoundTransitionResult: round_config: RoundConfig round_question: RoundQuestion should_broadcast: bool + phase_event_name: str | None = None + phase_event_payload: dict[str, Any] | None = None @dataclass(frozen=True) class FinishGameResult: session: GameSession should_broadcast: bool + phase_event_name: str | None = None + phase_event_payload: dict[str, Any] | None = None @dataclass(frozen=True) @@ -110,6 +116,9 @@ def start_next_round(session: GameSession) -> RoundTransitionResult: round_question = None should_broadcast = False + phase_event_name = None + phase_event_payload = None + if locked_session.status == GameSession.Status.SCOREBOARD: previous_round_config = RoundConfig.objects.filter( session=locked_session, @@ -137,6 +146,9 @@ def start_next_round(session: GameSession) -> RoundTransitionResult: locked_session.status = GameSession.Status.LIE locked_session.save(update_fields=["current_round", "status"]) should_broadcast = True + phase_event = build_start_next_round_phase_event(locked_session, next_round_config, round_question) + phase_event_name = phase_event["name"] + phase_event_payload = phase_event["payload"] elif locked_session.status == GameSession.Status.LIE: if locked_session.current_round <= 1: raise ValueError("next_round_invalid_phase") @@ -160,6 +172,8 @@ def start_next_round(session: GameSession) -> RoundTransitionResult: round_config=next_round_config, round_question=round_question, should_broadcast=should_broadcast, + phase_event_name=phase_event_name, + phase_event_payload=phase_event_payload, ) @@ -168,15 +182,25 @@ def finish_game(session: GameSession) -> FinishGameResult: with transaction.atomic(): locked_session = GameSession.objects.select_for_update().get(pk=session.pk) should_broadcast = False + phase_event_name = None + phase_event_payload = None if locked_session.status == GameSession.Status.SCOREBOARD: locked_session.status = GameSession.Status.FINISHED locked_session.save(update_fields=["status"]) should_broadcast = True + phase_event = build_finish_game_phase_event(locked_session) + phase_event_name = phase_event["name"] + phase_event_payload = phase_event["payload"] elif locked_session.status != GameSession.Status.FINISHED: raise ValueError("finish_game_invalid_phase") - return FinishGameResult(session=locked_session, should_broadcast=should_broadcast) + return FinishGameResult( + session=locked_session, + should_broadcast=should_broadcast, + phase_event_name=phase_event_name, + phase_event_payload=phase_event_payload, + ) diff --git a/lobby/tests.py b/lobby/tests.py index 5e241ac..ac39ab1 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -29,6 +29,27 @@ User = get_user_model() class LobbyGameplayExtractionTests(TestCase): + def setUp(self): + self.host = User.objects.create_user(username="extract_host", password="secret123") + self.client.login(username="extract_host", password="secret123") + self.session = GameSession.objects.create( + host=self.host, + code="EXTR42", + status=GameSession.Status.SCOREBOARD, + ) + self.category = Category.objects.create(name="Historie", slug="historie-extract", is_active=True) + self.round_config = RoundConfig.objects.create( + session=self.session, + number=1, + category=self.category, + ) + self.question = Question.objects.create( + category=self.category, + prompt="Hvornår faldt muren?", + correct_answer="1989", + is_active=True, + ) + def test_lobby_views_use_extracted_gameplay_helpers(self): self.assertIs(lobby_views._get_current_round_question, gameplay_services.get_current_round_question) self.assertIs(lobby_views._select_round_question, gameplay_services.select_round_question) @@ -38,11 +59,144 @@ class LobbyGameplayExtractionTests(TestCase): self.assertIs(lobby_views._start_next_round, gameplay_services.start_next_round) self.assertIs(lobby_views._finish_game, gameplay_services.finish_game) self.assertIs(lobby_views._build_scoreboard_phase_event, gameplay_payloads.build_scoreboard_phase_event) - self.assertIs(lobby_views._build_start_next_round_phase_event, gameplay_payloads.build_start_next_round_phase_event) self.assertIs(lobby_views._build_start_next_round_response, gameplay_payloads.build_start_next_round_response) - self.assertIs(lobby_views._build_finish_game_phase_event, gameplay_payloads.build_finish_game_phase_event) self.assertIs(lobby_views._build_finish_game_response, gameplay_payloads.build_finish_game_response) + @patch("lobby.views.sync_broadcast_phase_event") + @patch("lobby.views._build_start_next_round_response", return_value={"ok": True}) + @patch("lobby.views._start_next_round") + def test_start_next_round_view_delegates_transition_to_service( + self, + mock_start_next_round, + mock_build_response, + mock_sync_broadcast_phase_event, + ): + next_round_config = RoundConfig.objects.create( + session=self.session, + number=2, + category=self.category, + started_from_scoreboard=True, + ) + round_question = RoundQuestion.objects.create( + session=self.session, + round_number=2, + question=self.question, + correct_answer=self.question.correct_answer, + ) + transition = gameplay_services.RoundTransitionResult( + session=self.session, + round_config=next_round_config, + round_question=round_question, + should_broadcast=True, + phase_event_name="phase.lie_started", + phase_event_payload={"round_question_id": round_question.id}, + ) + mock_start_next_round.return_value = transition + + response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"ok": True}) + mock_start_next_round.assert_called_once_with(self.session) + mock_build_response.assert_called_once_with(self.session, next_round_config, round_question) + mock_sync_broadcast_phase_event.assert_called_once_with( + self.session.code, + "phase.lie_started", + {"round_question_id": round_question.id}, + ) + + @patch("lobby.views.sync_broadcast_phase_event") + @patch("lobby.views._build_finish_game_response", return_value={"ok": True}) + @patch("lobby.views._finish_game") + def test_finish_game_view_delegates_transition_to_service( + self, + mock_finish_game, + mock_build_response, + mock_sync_broadcast_phase_event, + ): + finished_session = GameSession.objects.get(pk=self.session.pk) + finished_session.status = GameSession.Status.FINISHED + transition = gameplay_services.FinishGameResult( + session=finished_session, + should_broadcast=True, + phase_event_name="phase.game_over", + phase_event_payload={"winner": None, "leaderboard": []}, + ) + mock_finish_game.return_value = transition + + response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code})) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"ok": True}) + mock_finish_game.assert_called_once_with(self.session) + mock_build_response.assert_called_once_with(finished_session) + mock_sync_broadcast_phase_event.assert_called_once_with( + self.session.code, + "phase.game_over", + {"winner": None, "leaderboard": []}, + ) + + @patch("lobby.views.sync_broadcast_phase_event") + @patch("lobby.views._build_start_next_round_response", return_value={"ok": True}) + @patch("lobby.views._start_next_round") + def test_start_next_round_view_skips_broadcast_on_service_replay( + self, + mock_start_next_round, + mock_build_response, + mock_sync_broadcast_phase_event, + ): + replay_round_config = RoundConfig.objects.create( + session=self.session, + number=2, + category=self.category, + started_from_scoreboard=True, + ) + round_question = RoundQuestion.objects.create( + session=self.session, + round_number=2, + question=self.question, + correct_answer=self.question.correct_answer, + ) + replay_session = GameSession.objects.get(pk=self.session.pk) + replay_session.status = GameSession.Status.LIE + replay_session.current_round = 2 + transition = gameplay_services.RoundTransitionResult( + session=replay_session, + round_config=replay_round_config, + round_question=round_question, + should_broadcast=False, + ) + mock_start_next_round.return_value = transition + + response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"ok": True}) + mock_start_next_round.assert_called_once_with(self.session) + mock_build_response.assert_called_once_with(replay_session, replay_round_config, round_question) + mock_sync_broadcast_phase_event.assert_not_called() + + @patch("lobby.views.sync_broadcast_phase_event") + @patch("lobby.views._build_finish_game_response", return_value={"ok": True}) + @patch("lobby.views._finish_game") + def test_finish_game_view_skips_broadcast_on_service_replay( + self, + mock_finish_game, + mock_build_response, + mock_sync_broadcast_phase_event, + ): + finished_session = GameSession.objects.get(pk=self.session.pk) + finished_session.status = GameSession.Status.FINISHED + transition = gameplay_services.FinishGameResult(session=finished_session, should_broadcast=False) + mock_finish_game.return_value = transition + + response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code})) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"ok": True}) + mock_finish_game.assert_called_once_with(self.session) + mock_build_response.assert_called_once_with(finished_session) + mock_sync_broadcast_phase_event.assert_not_called() class LobbyFlowTests(TestCase): def setUp(self): diff --git a/lobby/views.py b/lobby/views.py index 82a20fb..4c81944 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -10,13 +10,11 @@ from django.views.decorators.http import require_GET, require_POST from fupogfakta.models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent from fupogfakta.payloads import ( - build_finish_game_phase_event as _build_finish_game_phase_event, build_finish_game_response as _build_finish_game_response, build_leaderboard as _build_leaderboard, build_lie_started_payload as _build_lie_started_payload, build_reveal_payload as _build_reveal_payload, build_scoreboard_phase_event as _build_scoreboard_phase_event, - build_start_next_round_phase_event as _build_start_next_round_phase_event, build_start_next_round_response as _build_start_next_round_response, ) from fupogfakta.services import ( @@ -917,15 +915,10 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse: return api_error(request, code=str(exc), status=400) if transition.should_broadcast: - phase_event = _build_start_next_round_phase_event( - transition.session, - transition.round_config, - transition.round_question, - ) sync_broadcast_phase_event( transition.session.code, - phase_event["name"], - phase_event["payload"], + transition.phase_event_name, + transition.phase_event_payload, ) return JsonResponse( @@ -955,11 +948,10 @@ def finish_game(request: HttpRequest, code: str) -> JsonResponse: return api_error(request, code=str(exc), status=400) if transition.should_broadcast: - phase_event = _build_finish_game_phase_event(transition.session) sync_broadcast_phase_event( transition.session.code, - phase_event["name"], - phase_event["payload"], + transition.phase_event_name, + transition.phase_event_payload, ) return JsonResponse(_build_finish_game_response(transition.session)) -- 2.39.5 From fefc5ecd5619b847d6c9ce7c691724d92959e686 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Tue, 17 Mar 2026 15:42:00 +0000 Subject: [PATCH 17/43] test(lobby): lock refreshed deadline for reused bootstrap round --- lobby/tests.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lobby/tests.py b/lobby/tests.py index ac39ab1..940a15e 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -1612,11 +1612,13 @@ class RevealRoundFlowTests(TestCase): self.client.login(username="host_reveal", password="secret123") self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) + stale_shown_at = timezone.now() - timedelta(minutes=10) stale_round_question = RoundQuestion.objects.create( session=self.session, round_number=2, question=self.next_question, correct_answer=self.next_question.correct_answer, + shown_at=stale_shown_at, mixed_answers=["Stale truth", "Stale lie"], ) LieAnswer.objects.create(round_question=stale_round_question, player=self.player_one, text="Stale lie") @@ -1634,10 +1636,17 @@ class RevealRoundFlowTests(TestCase): stale_round_question.refresh_from_db() self.assertEqual(self.session.status, GameSession.Status.LIE) self.assertEqual(self.session.current_round, 2) - self.assertEqual(response.json()["round_question"]["id"], stale_round_question.id) + response_payload = response.json() + self.assertEqual(response_payload["round_question"]["id"], stale_round_question.id) self.assertEqual(stale_round_question.mixed_answers, []) self.assertEqual(stale_round_question.lies.count(), 0) self.assertEqual(stale_round_question.guesses.count(), 0) + self.assertNotEqual(stale_round_question.shown_at, stale_shown_at) + self.assertGreater(stale_round_question.shown_at, stale_shown_at) + self.assertEqual(response_payload["round_question"]["shown_at"], stale_round_question.shown_at.isoformat()) + expected_deadline = stale_round_question.shown_at + timedelta(seconds=self.round_config.lie_seconds) + self.assertEqual(response_payload["round_question"]["lie_deadline_at"], expected_deadline.isoformat()) + self.assertGreater(expected_deadline, timezone.now()) detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() self.assertEqual(detail_payload["session"]["status"], GameSession.Status.LIE) -- 2.39.5 From dfa197b33b94b7258f02b23236c9c6655078d76b Mon Sep 17 00:00:00 2001 From: dev-bot Date: Tue, 17 Mar 2026 16:06:46 +0000 Subject: [PATCH 18/43] refactor(gameplay): keep host transition payloads in cartridge --- fupogfakta/payloads.py | 11 ++++++++++ fupogfakta/services.py | 50 ++++++++++++++++++++++++++++++++++++++---- lobby/tests.py | 23 +++++++------------ lobby/views.py | 35 ++++++----------------------- 4 files changed, 72 insertions(+), 47 deletions(-) diff --git a/fupogfakta/payloads.py b/fupogfakta/payloads.py index 0ef02a7..0525802 100644 --- a/fupogfakta/payloads.py +++ b/fupogfakta/payloads.py @@ -123,6 +123,17 @@ def build_scoreboard_phase_event(session: GameSession, leaderboard: list[dict] | } +def build_reveal_scoreboard_response(session: GameSession, leaderboard: list[dict]) -> dict: + return { + "session": { + "code": session.code, + "status": session.status, + "current_round": session.current_round, + }, + "leaderboard": leaderboard, + } + + def build_finish_game_phase_event(session: GameSession) -> dict: leaderboard = build_leaderboard(session) winner = leaderboard[0] if leaderboard else None diff --git a/fupogfakta/services.py b/fupogfakta/services.py index 095d1e3..ba37a18 100644 --- a/fupogfakta/services.py +++ b/fupogfakta/services.py @@ -6,7 +6,14 @@ from django.db import transaction from django.utils import timezone from .models import GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent -from .payloads import build_finish_game_phase_event, build_start_next_round_phase_event +from .payloads import ( + build_finish_game_phase_event, + build_finish_game_response, + build_reveal_scoreboard_response, + build_scoreboard_phase_event, + build_start_next_round_phase_event, + build_start_next_round_response, +) @dataclass(frozen=True) @@ -15,6 +22,7 @@ class RoundTransitionResult: round_config: RoundConfig round_question: RoundQuestion should_broadcast: bool + response_payload: dict[str, Any] phase_event_name: str | None = None phase_event_payload: dict[str, Any] | None = None @@ -23,6 +31,7 @@ class RoundTransitionResult: class FinishGameResult: session: GameSession should_broadcast: bool + response_payload: dict[str, Any] phase_event_name: str | None = None phase_event_payload: dict[str, Any] | None = None @@ -32,6 +41,9 @@ class ScoreboardTransitionResult: session: GameSession leaderboard: list[dict] should_broadcast: bool + response_payload: dict[str, Any] | None = None + phase_event_name: str | None = None + phase_event_payload: dict[str, Any] | None = None def get_current_round_question(session: GameSession) -> RoundQuestion | None: @@ -172,6 +184,11 @@ def start_next_round(session: GameSession) -> RoundTransitionResult: round_config=next_round_config, round_question=round_question, should_broadcast=should_broadcast, + response_payload=build_start_next_round_response( + locked_session, + next_round_config, + round_question, + ), phase_event_name=phase_event_name, phase_event_payload=phase_event_payload, ) @@ -198,6 +215,7 @@ def finish_game(session: GameSession) -> FinishGameResult: return FinishGameResult( session=locked_session, should_broadcast=should_broadcast, + response_payload=build_finish_game_response(locked_session), phase_event_name=phase_event_name, phase_event_payload=phase_event_payload, ) @@ -211,7 +229,12 @@ def promote_reveal_to_scoreboard(session: GameSession) -> ScoreboardTransitionRe .order_by("-score", "nickname") .values("id", "nickname", "score") ) - return ScoreboardTransitionResult(session=session, leaderboard=leaderboard, should_broadcast=False) + return ScoreboardTransitionResult( + session=session, + leaderboard=leaderboard, + should_broadcast=False, + response_payload=build_reveal_scoreboard_response(session, leaderboard), + ) current_round_question = get_current_round_question(session) if current_round_question is None: @@ -220,7 +243,12 @@ def promote_reveal_to_scoreboard(session: GameSession) -> ScoreboardTransitionRe .order_by("-score", "nickname") .values("id", "nickname", "score") ) - return ScoreboardTransitionResult(session=session, leaderboard=leaderboard, should_broadcast=False) + return ScoreboardTransitionResult( + session=session, + leaderboard=leaderboard, + should_broadcast=False, + response_payload=build_reveal_scoreboard_response(session, leaderboard), + ) players_count = Player.objects.filter(session=session).count() guess_count = Guess.objects.filter(round_question=current_round_question).count() @@ -235,7 +263,12 @@ def promote_reveal_to_scoreboard(session: GameSession) -> ScoreboardTransitionRe .order_by("-score", "nickname") .values("id", "nickname", "score") ) - return ScoreboardTransitionResult(session=session, leaderboard=leaderboard, should_broadcast=False) + return ScoreboardTransitionResult( + session=session, + leaderboard=leaderboard, + should_broadcast=False, + response_payload=build_reveal_scoreboard_response(session, leaderboard), + ) with transaction.atomic(): locked_session = GameSession.objects.select_for_update().get(pk=session.pk) @@ -253,10 +286,19 @@ def promote_reveal_to_scoreboard(session: GameSession) -> ScoreboardTransitionRe .order_by("-score", "nickname") .values("id", "nickname", "score") ) + phase_event_name = None + phase_event_payload = None + if should_broadcast: + phase_event = build_scoreboard_phase_event(scoreboard_session, leaderboard) + phase_event_name = phase_event["name"] + phase_event_payload = phase_event["payload"] return ScoreboardTransitionResult( session=scoreboard_session, leaderboard=leaderboard, should_broadcast=should_broadcast, + response_payload=build_reveal_scoreboard_response(scoreboard_session, leaderboard), + phase_event_name=phase_event_name, + phase_event_payload=phase_event_payload, ) diff --git a/lobby/tests.py b/lobby/tests.py index 940a15e..b4f4143 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -59,16 +59,12 @@ class LobbyGameplayExtractionTests(TestCase): self.assertIs(lobby_views._start_next_round, gameplay_services.start_next_round) self.assertIs(lobby_views._finish_game, gameplay_services.finish_game) self.assertIs(lobby_views._build_scoreboard_phase_event, gameplay_payloads.build_scoreboard_phase_event) - self.assertIs(lobby_views._build_start_next_round_response, gameplay_payloads.build_start_next_round_response) - self.assertIs(lobby_views._build_finish_game_response, gameplay_payloads.build_finish_game_response) @patch("lobby.views.sync_broadcast_phase_event") - @patch("lobby.views._build_start_next_round_response", return_value={"ok": True}) @patch("lobby.views._start_next_round") def test_start_next_round_view_delegates_transition_to_service( self, mock_start_next_round, - mock_build_response, mock_sync_broadcast_phase_event, ): next_round_config = RoundConfig.objects.create( @@ -88,6 +84,7 @@ class LobbyGameplayExtractionTests(TestCase): round_config=next_round_config, round_question=round_question, should_broadcast=True, + response_payload={"ok": True}, phase_event_name="phase.lie_started", phase_event_payload={"round_question_id": round_question.id}, ) @@ -98,7 +95,6 @@ class LobbyGameplayExtractionTests(TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), {"ok": True}) mock_start_next_round.assert_called_once_with(self.session) - mock_build_response.assert_called_once_with(self.session, next_round_config, round_question) mock_sync_broadcast_phase_event.assert_called_once_with( self.session.code, "phase.lie_started", @@ -106,12 +102,10 @@ class LobbyGameplayExtractionTests(TestCase): ) @patch("lobby.views.sync_broadcast_phase_event") - @patch("lobby.views._build_finish_game_response", return_value={"ok": True}) @patch("lobby.views._finish_game") def test_finish_game_view_delegates_transition_to_service( self, mock_finish_game, - mock_build_response, mock_sync_broadcast_phase_event, ): finished_session = GameSession.objects.get(pk=self.session.pk) @@ -119,6 +113,7 @@ class LobbyGameplayExtractionTests(TestCase): transition = gameplay_services.FinishGameResult( session=finished_session, should_broadcast=True, + response_payload={"ok": True}, phase_event_name="phase.game_over", phase_event_payload={"winner": None, "leaderboard": []}, ) @@ -129,7 +124,6 @@ class LobbyGameplayExtractionTests(TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), {"ok": True}) mock_finish_game.assert_called_once_with(self.session) - mock_build_response.assert_called_once_with(finished_session) mock_sync_broadcast_phase_event.assert_called_once_with( self.session.code, "phase.game_over", @@ -137,12 +131,10 @@ class LobbyGameplayExtractionTests(TestCase): ) @patch("lobby.views.sync_broadcast_phase_event") - @patch("lobby.views._build_start_next_round_response", return_value={"ok": True}) @patch("lobby.views._start_next_round") def test_start_next_round_view_skips_broadcast_on_service_replay( self, mock_start_next_round, - mock_build_response, mock_sync_broadcast_phase_event, ): replay_round_config = RoundConfig.objects.create( @@ -165,6 +157,7 @@ class LobbyGameplayExtractionTests(TestCase): round_config=replay_round_config, round_question=round_question, should_broadcast=False, + response_payload={"ok": True}, ) mock_start_next_round.return_value = transition @@ -173,21 +166,22 @@ class LobbyGameplayExtractionTests(TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), {"ok": True}) mock_start_next_round.assert_called_once_with(self.session) - mock_build_response.assert_called_once_with(replay_session, replay_round_config, round_question) mock_sync_broadcast_phase_event.assert_not_called() @patch("lobby.views.sync_broadcast_phase_event") - @patch("lobby.views._build_finish_game_response", return_value={"ok": True}) @patch("lobby.views._finish_game") def test_finish_game_view_skips_broadcast_on_service_replay( self, mock_finish_game, - mock_build_response, mock_sync_broadcast_phase_event, ): finished_session = GameSession.objects.get(pk=self.session.pk) finished_session.status = GameSession.Status.FINISHED - transition = gameplay_services.FinishGameResult(session=finished_session, should_broadcast=False) + transition = gameplay_services.FinishGameResult( + session=finished_session, + should_broadcast=False, + response_payload={"ok": True}, + ) mock_finish_game.return_value = transition response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code})) @@ -195,7 +189,6 @@ class LobbyGameplayExtractionTests(TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), {"ok": True}) mock_finish_game.assert_called_once_with(self.session) - mock_build_response.assert_called_once_with(finished_session) mock_sync_broadcast_phase_event.assert_not_called() class LobbyFlowTests(TestCase): diff --git a/lobby/views.py b/lobby/views.py index 4c81944..a155e9d 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -10,12 +10,10 @@ from django.views.decorators.http import require_GET, require_POST from fupogfakta.models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent from fupogfakta.payloads import ( - build_finish_game_response as _build_finish_game_response, build_leaderboard as _build_leaderboard, build_lie_started_payload as _build_lie_started_payload, build_reveal_payload as _build_reveal_payload, build_scoreboard_phase_event as _build_scoreboard_phase_event, - build_start_next_round_response as _build_start_next_round_response, ) from fupogfakta.services import ( finish_game as _finish_game, @@ -75,11 +73,10 @@ def _create_unique_session_code() -> str: def _maybe_promote_reveal_to_scoreboard(session: GameSession) -> GameSession: transition = _promote_reveal_to_scoreboard(session) if transition.should_broadcast: - phase_event = _build_scoreboard_phase_event(transition.session, transition.leaderboard) sync_broadcast_phase_event( transition.session.code, - phase_event["name"], - phase_event["payload"], + transition.phase_event_name, + transition.phase_event_payload, ) return transition.session @@ -872,28 +869,16 @@ def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse: transition = _promote_reveal_to_scoreboard(session) if transition.should_broadcast: - phase_event = _build_scoreboard_phase_event(transition.session, transition.leaderboard) sync_broadcast_phase_event( transition.session.code, - phase_event["name"], - phase_event["payload"], + transition.phase_event_name, + transition.phase_event_payload, ) session = transition.session if session.status not in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}: return api_error(request, code="scoreboard_invalid_phase", status=400) - leaderboard = transition.leaderboard - - return JsonResponse( - { - "session": { - "code": session.code, - "status": session.status, - "current_round": session.current_round, - }, - "leaderboard": leaderboard, - } - ) + return JsonResponse(transition.response_payload) @require_POST @@ -921,13 +906,7 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse: transition.phase_event_payload, ) - return JsonResponse( - _build_start_next_round_response( - transition.session, - transition.round_config, - transition.round_question, - ) - ) + return JsonResponse(transition.response_payload) @require_POST @login_required @@ -954,7 +933,7 @@ def finish_game(request: HttpRequest, code: str) -> JsonResponse: transition.phase_event_payload, ) - return JsonResponse(_build_finish_game_response(transition.session)) + return JsonResponse(transition.response_payload) @require_POST -- 2.39.5 From 7eb3507934ca5842dc3628225f0e5951c9738cff Mon Sep 17 00:00:00 2001 From: dev-bot Date: Tue, 17 Mar 2026 17:06:59 +0000 Subject: [PATCH 19/43] fix(gameplay): refresh stale next-round bootstrap config --- fupogfakta/services.py | 39 ++++++++++++++++++++++++++++-------- fupogfakta/tests.py | 38 +++++++++++++++++++++++++++++++++++ lobby/tests.py | 45 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 8 deletions(-) diff --git a/fupogfakta/services.py b/fupogfakta/services.py index ba37a18..467e261 100644 --- a/fupogfakta/services.py +++ b/fupogfakta/services.py @@ -140,21 +140,44 @@ def start_next_round(session: GameSession) -> RoundTransitionResult: raise ValueError("round_config_missing") next_round_number = locked_session.current_round + 1 - next_round_config = RoundConfig( + next_round_config, _created = RoundConfig.objects.get_or_create( session=locked_session, number=next_round_number, - category=previous_round_config.category, - lie_seconds=previous_round_config.lie_seconds, - guess_seconds=previous_round_config.guess_seconds, - points_correct=previous_round_config.points_correct, - points_bluff=previous_round_config.points_bluff, - started_from_scoreboard=True, + defaults={ + "category": previous_round_config.category, + "lie_seconds": previous_round_config.lie_seconds, + "guess_seconds": previous_round_config.guess_seconds, + "points_correct": previous_round_config.points_correct, + "points_bluff": previous_round_config.points_bluff, + "started_from_scoreboard": True, + }, ) + round_config_update_fields: list[str] = [] + if next_round_config.category_id != previous_round_config.category_id: + next_round_config.category = previous_round_config.category + round_config_update_fields.append("category") + if next_round_config.lie_seconds != previous_round_config.lie_seconds: + next_round_config.lie_seconds = previous_round_config.lie_seconds + round_config_update_fields.append("lie_seconds") + if next_round_config.guess_seconds != previous_round_config.guess_seconds: + next_round_config.guess_seconds = previous_round_config.guess_seconds + round_config_update_fields.append("guess_seconds") + if next_round_config.points_correct != previous_round_config.points_correct: + next_round_config.points_correct = previous_round_config.points_correct + round_config_update_fields.append("points_correct") + if next_round_config.points_bluff != previous_round_config.points_bluff: + next_round_config.points_bluff = previous_round_config.points_bluff + round_config_update_fields.append("points_bluff") + if not next_round_config.started_from_scoreboard: + next_round_config.started_from_scoreboard = True + round_config_update_fields.append("started_from_scoreboard") + if round_config_update_fields: + next_round_config.save(update_fields=round_config_update_fields) + locked_session.current_round = next_round_number round_question = reset_round_question_bootstrap_state(select_round_question(locked_session, next_round_config)) - next_round_config.save() locked_session.status = GameSession.Status.LIE locked_session.save(update_fields=["current_round", "status"]) should_broadcast = True diff --git a/fupogfakta/tests.py b/fupogfakta/tests.py index 85bd65d..0e7811b 100644 --- a/fupogfakta/tests.py +++ b/fupogfakta/tests.py @@ -135,6 +135,44 @@ class FupOgFaktaExtractionSliceTests(TestCase): self.assertEqual(stale_round_question.lies.count(), 0) self.assertEqual(stale_round_question.guesses.count(), 0) + def test_start_next_round_reuses_existing_bootstrap_round_config_with_fresh_canonical_values(self): + self.session.status = GameSession.Status.SCOREBOARD + self.session.save(update_fields=["status"]) + stale_category = Category.objects.create(name="Sport", slug="sport", is_active=True) + stale_round_config = RoundConfig.objects.create( + session=self.session, + number=2, + category=stale_category, + lie_seconds=12, + guess_seconds=18, + points_correct=9, + points_bluff=7, + started_from_scoreboard=False, + ) + stale_round_question = RoundQuestion.objects.create( + session=self.session, + round_number=2, + question=self.question_two, + correct_answer=self.question_two.correct_answer, + shown_at=timezone.now() - timedelta(minutes=10), + mixed_answers=["Stale truth"], + ) + + result = start_next_round(self.session) + + stale_round_config.refresh_from_db() + stale_round_question.refresh_from_db() + self.assertEqual(result.round_config.id, stale_round_config.id) + self.assertEqual(RoundConfig.objects.filter(session=self.session, number=2).count(), 1) + self.assertEqual(stale_round_config.category_id, self.round_config.category_id) + self.assertEqual(stale_round_config.lie_seconds, self.round_config.lie_seconds) + self.assertEqual(stale_round_config.guess_seconds, self.round_config.guess_seconds) + self.assertEqual(stale_round_config.points_correct, self.round_config.points_correct) + self.assertEqual(stale_round_config.points_bluff, self.round_config.points_bluff) + self.assertTrue(stale_round_config.started_from_scoreboard) + self.assertEqual(result.round_question.id, stale_round_question.id) + self.assertEqual(stale_round_question.mixed_answers, []) + def test_finish_game_moves_scoreboard_transition_into_service(self): self.session.status = GameSession.Status.SCOREBOARD self.session.save(update_fields=["status"]) diff --git a/lobby/tests.py b/lobby/tests.py index b4f4143..8846217 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -1643,6 +1643,51 @@ class RevealRoundFlowTests(TestCase): detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() self.assertEqual(detail_payload["session"]["status"], GameSession.Status.LIE) + + def test_start_next_round_reuses_existing_next_round_config_with_refreshed_canonical_values(self): + self.client.login(username="host_reveal", password="secret123") + self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) + + stale_category = Category.objects.create(name="Sport reveal", slug="sport-reveal", is_active=True) + stale_round_config = RoundConfig.objects.create( + session=self.session, + number=2, + category=stale_category, + lie_seconds=12, + guess_seconds=18, + points_correct=9, + points_bluff=7, + started_from_scoreboard=False, + ) + stale_round_question = RoundQuestion.objects.create( + session=self.session, + round_number=2, + question=self.next_question, + correct_answer=self.next_question.correct_answer, + shown_at=timezone.now() - timedelta(minutes=10), + mixed_answers=["Stale truth"], + ) + + response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) + + self.assertEqual(response.status_code, 200) + self.session.refresh_from_db() + stale_round_config.refresh_from_db() + stale_round_question.refresh_from_db() + self.assertEqual(self.session.status, GameSession.Status.LIE) + self.assertEqual(self.session.current_round, 2) + self.assertEqual(RoundConfig.objects.filter(session=self.session, number=2).count(), 1) + self.assertEqual(stale_round_config.category_id, self.round_config.category_id) + self.assertEqual(stale_round_config.lie_seconds, self.round_config.lie_seconds) + self.assertEqual(stale_round_config.guess_seconds, self.round_config.guess_seconds) + self.assertEqual(stale_round_config.points_correct, self.round_config.points_correct) + self.assertEqual(stale_round_config.points_bluff, self.round_config.points_bluff) + self.assertTrue(stale_round_config.started_from_scoreboard) + self.assertEqual(response.json()["round_question"]["id"], stale_round_question.id) + self.assertEqual(response.json()["config"]["lie_seconds"], self.round_config.lie_seconds) + expected_deadline = stale_round_question.shown_at + timedelta(seconds=self.round_config.lie_seconds) + self.assertEqual(response.json()["round_question"]["lie_deadline_at"], expected_deadline.isoformat()) + detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() self.assertEqual(detail_payload["session"]["current_round"], 2) self.assertEqual(detail_payload["round_question"]["id"], stale_round_question.id) self.assertEqual(detail_payload["round_question"]["answers"], []) -- 2.39.5 From a9c6e4fd7936099b791357200f082d9b2326a3cb Mon Sep 17 00:00:00 2001 From: dev-bot Date: Tue, 17 Mar 2026 17:25:22 +0000 Subject: [PATCH 20/43] test(lobby): lock host transition ownership boundary --- lobby/tests.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/lobby/tests.py b/lobby/tests.py index 8846217..bc67d98 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -1,3 +1,4 @@ +import inspect import json import tempfile from datetime import timedelta @@ -60,6 +61,24 @@ class LobbyGameplayExtractionTests(TestCase): self.assertIs(lobby_views._finish_game, gameplay_services.finish_game) self.assertIs(lobby_views._build_scoreboard_phase_event, gameplay_payloads.build_scoreboard_phase_event) + def test_start_next_round_view_source_stays_http_thin(self): + source = inspect.getsource(inspect.unwrap(lobby_views.start_next_round)) + + self.assertIn("transition = _start_next_round(session)", source) + self.assertNotIn("RoundConfig", source) + self.assertNotIn("RoundQuestion", source) + self.assertNotIn("build_start_next_round_response", source) + self.assertNotIn("build_start_next_round_phase_event", source) + + def test_finish_game_view_source_stays_http_thin(self): + source = inspect.getsource(inspect.unwrap(lobby_views.finish_game)) + + self.assertIn("transition = _finish_game(session)", source) + self.assertNotIn("RoundConfig", source) + self.assertNotIn("RoundQuestion", source) + self.assertNotIn("build_finish_game_response", source) + self.assertNotIn("build_finish_game_phase_event", source) + @patch("lobby.views.sync_broadcast_phase_event") @patch("lobby.views._start_next_round") def test_start_next_round_view_delegates_transition_to_service( -- 2.39.5 From e31871114875552a7fb06bfd7bc4a4b7e3043b5d Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Tue, 17 Mar 2026 17:44:34 +0000 Subject: [PATCH 21/43] test(gameplay): lock refreshed next-round deadline contract --- fupogfakta/tests.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/fupogfakta/tests.py b/fupogfakta/tests.py index 0e7811b..36fde14 100644 --- a/fupogfakta/tests.py +++ b/fupogfakta/tests.py @@ -131,6 +131,10 @@ class FupOgFaktaExtractionSliceTests(TestCase): self.assertGreaterEqual(stale_round_question.shown_at, before_transition) self.assertLessEqual(stale_round_question.shown_at, after_transition) self.assertNotEqual(stale_round_question.shown_at, stale_shown_at) + self.assertEqual(result.response_payload["round_question"]["shown_at"], stale_round_question.shown_at.isoformat()) + expected_deadline = stale_round_question.shown_at + timedelta(seconds=result.round_config.lie_seconds) + self.assertEqual(result.response_payload["round_question"]["lie_deadline_at"], expected_deadline.isoformat()) + self.assertGreater(expected_deadline, before_transition) self.assertEqual(stale_round_question.mixed_answers, []) self.assertEqual(stale_round_question.lies.count(), 0) self.assertEqual(stale_round_question.guesses.count(), 0) -- 2.39.5 From 319038555a926683f70f3da9353e89cd810be269 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Tue, 17 Mar 2026 18:29:11 +0000 Subject: [PATCH 22/43] refactor(gameplay): move phase view model into cartridge --- fupogfakta/payloads.py | 51 ++++++++++++++++++++++++++++++++++++++++++ fupogfakta/tests.py | 9 +++++++- lobby/tests.py | 1 + lobby/views.py | 46 +------------------------------------ 4 files changed, 61 insertions(+), 46 deletions(-) diff --git a/fupogfakta/payloads.py b/fupogfakta/payloads.py index 0525802..40d4f26 100644 --- a/fupogfakta/payloads.py +++ b/fupogfakta/payloads.py @@ -70,6 +70,57 @@ def build_lie_started_payload(session: GameSession, round_config: RoundConfig, r } +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_scoreboard = status == GameSession.Status.SCOREBOARD + in_finished = status == GameSession.Status.FINISHED + + min_players_reached = players_count >= 3 + max_players_allowed = players_count <= 5 + + return { + "status": status, + "current_phase": status, + "round_number": session.current_round, + "players_count": players_count, + "constraints": { + "min_players_to_start": 3, + "max_players_mvp": 5, + "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": False, + "can_mix_answers": False, + "can_calculate_scores": False, + "can_reveal_scoreboard": False, + "can_start_next_round": in_scoreboard, + "can_finish_game": in_scoreboard, + }, + "player": { + "can_join": status in { + GameSession.Status.LOBBY, + GameSession.Status.LIE, + GameSession.Status.GUESS, + GameSession.Status.REVEAL, + GameSession.Status.SCOREBOARD, + }, + "can_submit_lie": in_lie and has_round_question, + "can_submit_guess": in_guess and has_round_question, + "can_view_final_result": in_finished, + }, + } + + def build_start_next_round_response( session: GameSession, round_config: RoundConfig, diff --git a/fupogfakta/tests.py b/fupogfakta/tests.py index 36fde14..14e432e 100644 --- a/fupogfakta/tests.py +++ b/fupogfakta/tests.py @@ -6,7 +6,7 @@ from django.test import TestCase from django.utils import timezone from fupogfakta.models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent -from fupogfakta.payloads import build_lie_started_payload, build_reveal_payload +from fupogfakta.payloads import build_lie_started_payload, build_phase_view_model, build_reveal_payload from fupogfakta.services import ( finish_game, get_current_round_question, @@ -279,9 +279,16 @@ class FupOgFaktaExtractionSliceTests(TestCase): lie_payload = build_lie_started_payload(self.session, self.round_config, round_question) reveal_payload = build_reveal_payload(round_question) + phase_view_model = build_phase_view_model( + self.session, + players_count=3, + has_round_question=True, + ) self.assertEqual(lie_payload["category"], {"slug": self.category.slug, "name": self.category.name}) self.assertEqual(lie_payload["round_question_id"], round_question.id) self.assertEqual(reveal_payload["correct_answer"], "1989") self.assertEqual(reveal_payload["lies"][0]["player_id"], lie.player_id) self.assertEqual(reveal_payload["guesses"][0]["fooled_player_nickname"], self.bob.nickname) + self.assertTrue(phase_view_model["host"]["can_start_round"]) + self.assertFalse(phase_view_model["host"]["can_finish_game"]) diff --git a/lobby/tests.py b/lobby/tests.py index bc67d98..a0c3121 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -59,6 +59,7 @@ class LobbyGameplayExtractionTests(TestCase): self.assertIs(lobby_views._promote_reveal_to_scoreboard, gameplay_services.promote_reveal_to_scoreboard) self.assertIs(lobby_views._start_next_round, gameplay_services.start_next_round) self.assertIs(lobby_views._finish_game, gameplay_services.finish_game) + self.assertIs(lobby_views._build_phase_view_model, gameplay_payloads.build_phase_view_model) self.assertIs(lobby_views._build_scoreboard_phase_event, gameplay_payloads.build_scoreboard_phase_event) def test_start_next_round_view_source_stays_http_thin(self): diff --git a/lobby/views.py b/lobby/views.py index a155e9d..e794aac 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -12,6 +12,7 @@ from fupogfakta.models import Category, GameSession, Guess, LieAnswer, Player, Q from fupogfakta.payloads import ( build_leaderboard as _build_leaderboard, build_lie_started_payload as _build_lie_started_payload, + build_phase_view_model as _build_phase_view_model, build_reveal_payload as _build_reveal_payload, build_scoreboard_phase_event as _build_scoreboard_phase_event, ) @@ -82,51 +83,6 @@ def _maybe_promote_reveal_to_scoreboard(session: GameSession) -> GameSession: -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_scoreboard = status == GameSession.Status.SCOREBOARD - in_finished = status == GameSession.Status.FINISHED - - min_players_reached = players_count >= 3 - max_players_allowed = players_count <= 5 - - return { - "status": status, - "current_phase": status, - "round_number": session.current_round, - "players_count": players_count, - "constraints": { - "min_players_to_start": 3, - "max_players_mvp": 5, - "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": False, - "can_mix_answers": False, - "can_calculate_scores": False, - "can_reveal_scoreboard": False, - "can_start_next_round": in_scoreboard, - "can_finish_game": in_scoreboard, - }, - "player": { - "can_join": status in JOINABLE_STATUSES, - "can_submit_lie": in_lie and has_round_question, - "can_submit_guess": in_guess and has_round_question, - "can_view_final_result": in_finished, - }, - } - - @require_POST @login_required def create_session(request: HttpRequest) -> JsonResponse: -- 2.39.5 From c45f04f9f171d4cca80c809cf0cc361c3da7402a Mon Sep 17 00:00:00 2001 From: dev-bot Date: Tue, 17 Mar 2026 18:55:28 +0000 Subject: [PATCH 23/43] refactor(gameplay): extract round question payload builder --- fupogfakta/payloads.py | 13 +++++++++++++ fupogfakta/tests.py | 10 +++++++++- lobby/tests.py | 1 + lobby/views.py | 11 ++--------- 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/fupogfakta/payloads.py b/fupogfakta/payloads.py index 40d4f26..31db22d 100644 --- a/fupogfakta/payloads.py +++ b/fupogfakta/payloads.py @@ -13,6 +13,19 @@ def build_player_ref(player: Player | None) -> dict | None: } +def build_round_question_payload(round_question: RoundQuestion | None) -> dict | None: + if round_question is None: + return None + + return { + "id": round_question.id, + "round_number": round_question.round_number, + "prompt": round_question.question.prompt, + "shown_at": round_question.shown_at.isoformat(), + "answers": [{"text": text} for text in (round_question.mixed_answers or [])], + } + + def build_reveal_payload(round_question: RoundQuestion | None) -> dict | None: if round_question is None: return None diff --git a/fupogfakta/tests.py b/fupogfakta/tests.py index 14e432e..f737672 100644 --- a/fupogfakta/tests.py +++ b/fupogfakta/tests.py @@ -6,7 +6,12 @@ from django.test import TestCase from django.utils import timezone from fupogfakta.models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent -from fupogfakta.payloads import build_lie_started_payload, build_phase_view_model, build_reveal_payload +from fupogfakta.payloads import ( + build_lie_started_payload, + build_phase_view_model, + build_reveal_payload, + build_round_question_payload, +) from fupogfakta.services import ( finish_game, get_current_round_question, @@ -277,6 +282,7 @@ class FupOgFaktaExtractionSliceTests(TestCase): fooled_player=self.bob, ) + round_question_payload = build_round_question_payload(round_question) lie_payload = build_lie_started_payload(self.session, self.round_config, round_question) reveal_payload = build_reveal_payload(round_question) phase_view_model = build_phase_view_model( @@ -285,6 +291,8 @@ class FupOgFaktaExtractionSliceTests(TestCase): has_round_question=True, ) + self.assertEqual(round_question_payload["prompt"], self.question_one.prompt) + self.assertEqual(round_question_payload["answers"], []) self.assertEqual(lie_payload["category"], {"slug": self.category.slug, "name": self.category.name}) self.assertEqual(lie_payload["round_question_id"], round_question.id) self.assertEqual(reveal_payload["correct_answer"], "1989") diff --git a/lobby/tests.py b/lobby/tests.py index a0c3121..77eb6f7 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -60,6 +60,7 @@ class LobbyGameplayExtractionTests(TestCase): self.assertIs(lobby_views._start_next_round, gameplay_services.start_next_round) self.assertIs(lobby_views._finish_game, gameplay_services.finish_game) self.assertIs(lobby_views._build_phase_view_model, gameplay_payloads.build_phase_view_model) + self.assertIs(lobby_views._build_round_question_payload, gameplay_payloads.build_round_question_payload) self.assertIs(lobby_views._build_scoreboard_phase_event, gameplay_payloads.build_scoreboard_phase_event) def test_start_next_round_view_source_stays_http_thin(self): diff --git a/lobby/views.py b/lobby/views.py index e794aac..a82dff8 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -14,6 +14,7 @@ from fupogfakta.payloads import ( build_lie_started_payload as _build_lie_started_payload, build_phase_view_model as _build_phase_view_model, build_reveal_payload as _build_reveal_payload, + build_round_question_payload as _build_round_question_payload, build_scoreboard_phase_event as _build_scoreboard_phase_event, ) from fupogfakta.services import ( @@ -190,15 +191,7 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse: session = _maybe_promote_reveal_to_scoreboard(session) current_round_question = _get_current_round_question(session) - round_question_payload = None - if current_round_question: - round_question_payload = { - "id": current_round_question.id, - "round_number": current_round_question.round_number, - "prompt": current_round_question.question.prompt, - "shown_at": current_round_question.shown_at.isoformat(), - "answers": [{"text": text} for text in (current_round_question.mixed_answers or [])], - } + round_question_payload = _build_round_question_payload(current_round_question) phase_view_model = _build_phase_view_model( session, -- 2.39.5 From 16c9cf6b57aa628b213cee787f6f2d8b6470ec30 Mon Sep 17 00:00:00 2001 From: dev-bot Date: Tue, 17 Mar 2026 19:15:44 +0000 Subject: [PATCH 24/43] refactor(gameplay): extract round start payload builders --- fupogfakta/payloads.py | 35 ++++++++++++++++++++++++++++- lobby/tests.py | 18 +++++++++++++++ lobby/views.py | 51 +++++++----------------------------------- 3 files changed, 60 insertions(+), 44 deletions(-) diff --git a/fupogfakta/payloads.py b/fupogfakta/payloads.py index 31db22d..16cf5e3 100644 --- a/fupogfakta/payloads.py +++ b/fupogfakta/payloads.py @@ -134,7 +134,7 @@ def build_phase_view_model(session: GameSession, *, players_count: int, has_roun } -def build_start_next_round_response( +def build_start_round_response( session: GameSession, round_config: RoundConfig, round_question: RoundQuestion, @@ -166,6 +166,39 @@ def build_start_next_round_response( } +def build_question_shown_payload(round_question: RoundQuestion, lie_deadline_at: str, lie_seconds: int) -> dict: + return { + "round_question_id": round_question.id, + "prompt": round_question.question.prompt, + "shown_at": round_question.shown_at.isoformat(), + "lie_deadline_at": lie_deadline_at, + "lie_seconds": lie_seconds, + } + + +def build_question_shown_response(round_question: RoundQuestion, lie_deadline_at: str, lie_seconds: int) -> dict: + return { + "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, + }, + "config": { + "lie_seconds": lie_seconds, + }, + } + + +def build_start_next_round_response( + session: GameSession, + round_config: RoundConfig, + round_question: RoundQuestion, +) -> dict: + return build_start_round_response(session, round_config, round_question) + + def build_start_next_round_phase_event( session: GameSession, round_config: RoundConfig, diff --git a/lobby/tests.py b/lobby/tests.py index 77eb6f7..f4cbe6e 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -62,6 +62,24 @@ class LobbyGameplayExtractionTests(TestCase): self.assertIs(lobby_views._build_phase_view_model, gameplay_payloads.build_phase_view_model) self.assertIs(lobby_views._build_round_question_payload, gameplay_payloads.build_round_question_payload) self.assertIs(lobby_views._build_scoreboard_phase_event, gameplay_payloads.build_scoreboard_phase_event) + self.assertIs(lobby_views._build_start_round_response, gameplay_payloads.build_start_round_response) + self.assertIs(lobby_views._build_question_shown_payload, gameplay_payloads.build_question_shown_payload) + self.assertIs(lobby_views._build_question_shown_response, gameplay_payloads.build_question_shown_response) + + def test_start_round_view_source_stays_http_thin(self): + source = inspect.getsource(inspect.unwrap(lobby_views.start_round)) + + self.assertIn("lie_started_payload = _build_lie_started_payload(session, round_config, round_question)", source) + self.assertIn("_build_start_round_response(session, round_config, round_question)", source) + self.assertNotIn('"round_question": {', source) + + def test_show_question_view_source_stays_http_thin(self): + source = inspect.getsource(inspect.unwrap(lobby_views.show_question)) + + self.assertIn("_build_question_shown_payload(round_question, lie_deadline_iso, round_config.lie_seconds)", source) + self.assertIn("_build_question_shown_response(round_question, lie_deadline_iso, round_config.lie_seconds)", source) + self.assertNotIn('"round_question": {', source) + self.assertNotIn('"round_question_id": round_question.id', source) def test_start_next_round_view_source_stays_http_thin(self): source = inspect.getsource(inspect.unwrap(lobby_views.start_next_round)) diff --git a/lobby/views.py b/lobby/views.py index a82dff8..9641bc3 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -13,9 +13,12 @@ from fupogfakta.payloads import ( build_leaderboard as _build_leaderboard, build_lie_started_payload as _build_lie_started_payload, build_phase_view_model as _build_phase_view_model, + build_question_shown_payload as _build_question_shown_payload, + build_question_shown_response as _build_question_shown_response, build_reveal_payload as _build_reveal_payload, build_round_question_payload as _build_round_question_payload, build_scoreboard_phase_event as _build_scoreboard_phase_event, + build_start_round_response as _build_start_round_response, ) from fupogfakta.services import ( finish_game as _finish_game, @@ -315,30 +318,7 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse: ) return JsonResponse( - { - "session": { - "code": session.code, - "status": session.status, - "current_round": session.current_round, - }, - "round": { - "number": round_config.number, - "category": { - "slug": round_config.category.slug, - "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_started_payload["lie_deadline_at"], - }, - "config": { - "lie_seconds": round_config.lie_seconds, - }, - }, + _build_start_round_response(session, round_config, round_question), status=201, ) @@ -391,31 +371,16 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse: lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds) + lie_deadline_iso = lie_deadline_at.isoformat() + sync_broadcast_phase_event( session.code, "phase.question_shown", - { - "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, - }, + _build_question_shown_payload(round_question, lie_deadline_iso, round_config.lie_seconds), ) return JsonResponse( - { - "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, - }, - }, + _build_question_shown_response(round_question, lie_deadline_iso, round_config.lie_seconds), status=201, ) -- 2.39.5 From 03850b5ed540a101638a94dd9817ad34c3b0aa3e Mon Sep 17 00:00:00 2001 From: dev-bot Date: Tue, 17 Mar 2026 19:44:13 +0000 Subject: [PATCH 25/43] refactor(gameplay): extract start/show transitions from lobby views --- fupogfakta/services.py | 82 ++++++++++++++++++++++++++- lobby/tests.py | 97 ++++++++++++++++++++++++++++---- lobby/views.py | 124 ++++++++--------------------------------- 3 files changed, 190 insertions(+), 113 deletions(-) diff --git a/fupogfakta/services.py b/fupogfakta/services.py index 467e261..46584b4 100644 --- a/fupogfakta/services.py +++ b/fupogfakta/services.py @@ -1,18 +1,23 @@ import random +from datetime import timedelta from dataclasses import dataclass from typing import Any from django.db import transaction from django.utils import timezone -from .models import GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent +from .models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent from .payloads import ( build_finish_game_phase_event, build_finish_game_response, + build_lie_started_payload, + build_question_shown_payload, + build_question_shown_response, build_reveal_scoreboard_response, build_scoreboard_phase_event, build_start_next_round_phase_event, build_start_next_round_response, + build_start_round_response, ) @@ -121,6 +126,81 @@ def prepare_mixed_answers(round_question: RoundQuestion) -> list[str]: + + +def start_round(session: GameSession, category_slug: str) -> RoundTransitionResult: + try: + category = Category.objects.get(slug=category_slug, is_active=True) + except Category.DoesNotExist: + raise ValueError("category_not_found") + + if not Question.objects.filter(category=category, is_active=True).exists(): + raise ValueError("category_has_no_questions") + + with transaction.atomic(): + locked_session = GameSession.objects.select_for_update().get(pk=session.pk) + if locked_session.status != GameSession.Status.LOBBY: + raise ValueError("round_start_invalid_phase") + + if RoundConfig.objects.filter(session=locked_session, number=locked_session.current_round).exists(): + raise ValueError("round_already_configured") + + round_config = RoundConfig( + session=locked_session, + number=locked_session.current_round, + category=category, + ) + + round_question = select_round_question(locked_session, round_config) + + round_config.save() + locked_session.status = GameSession.Status.LIE + locked_session.save(update_fields=["status"]) + + phase_event = { + "name": "phase.lie_started", + "payload": build_lie_started_payload(locked_session, round_config, round_question), + } + return RoundTransitionResult( + session=locked_session, + round_config=round_config, + round_question=round_question, + should_broadcast=True, + response_payload=build_start_round_response(locked_session, round_config, round_question), + phase_event_name=phase_event["name"], + phase_event_payload=phase_event["payload"], + ) + + +def show_question(session: GameSession) -> RoundTransitionResult: + if session.status != GameSession.Status.LIE: + raise ValueError("show_question_invalid_phase") + + try: + round_config = RoundConfig.objects.get(session=session, number=session.current_round) + except RoundConfig.DoesNotExist: + raise ValueError("round_config_missing") + + round_question = get_current_round_question(session) + if round_question is None: + round_question = select_round_question(session, round_config) + + lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds) + lie_deadline_iso = lie_deadline_at.isoformat() + phase_event = { + "name": "phase.question_shown", + "payload": build_question_shown_payload(round_question, lie_deadline_iso, round_config.lie_seconds), + } + return RoundTransitionResult( + session=session, + round_config=round_config, + round_question=round_question, + should_broadcast=True, + response_payload=build_question_shown_response(round_question, lie_deadline_iso, round_config.lie_seconds), + phase_event_name=phase_event["name"], + phase_event_payload=phase_event["payload"], + ) + def start_next_round(session: GameSession) -> RoundTransitionResult: with transaction.atomic(): locked_session = GameSession.objects.select_for_update().get(pk=session.pk) diff --git a/lobby/tests.py b/lobby/tests.py index f4cbe6e..84a0bc0 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -57,29 +57,29 @@ class LobbyGameplayExtractionTests(TestCase): self.assertIs(lobby_views._prepare_mixed_answers, gameplay_services.prepare_mixed_answers) self.assertIs(lobby_views._resolve_scores, gameplay_services.resolve_scores) self.assertIs(lobby_views._promote_reveal_to_scoreboard, gameplay_services.promote_reveal_to_scoreboard) + self.assertIs(lobby_views._start_round, gameplay_services.start_round) + self.assertIs(lobby_views._show_question, gameplay_services.show_question) self.assertIs(lobby_views._start_next_round, gameplay_services.start_next_round) self.assertIs(lobby_views._finish_game, gameplay_services.finish_game) self.assertIs(lobby_views._build_phase_view_model, gameplay_payloads.build_phase_view_model) self.assertIs(lobby_views._build_round_question_payload, gameplay_payloads.build_round_question_payload) self.assertIs(lobby_views._build_scoreboard_phase_event, gameplay_payloads.build_scoreboard_phase_event) - self.assertIs(lobby_views._build_start_round_response, gameplay_payloads.build_start_round_response) - self.assertIs(lobby_views._build_question_shown_payload, gameplay_payloads.build_question_shown_payload) - self.assertIs(lobby_views._build_question_shown_response, gameplay_payloads.build_question_shown_response) def test_start_round_view_source_stays_http_thin(self): source = inspect.getsource(inspect.unwrap(lobby_views.start_round)) - self.assertIn("lie_started_payload = _build_lie_started_payload(session, round_config, round_question)", source) - self.assertIn("_build_start_round_response(session, round_config, round_question)", source) - self.assertNotIn('"round_question": {', source) + self.assertIn("transition = _start_round(session, category_slug)", source) + self.assertNotIn("RoundConfig", source) + self.assertNotIn("RoundQuestion", source) + self.assertNotIn("build_start_round_response", source) def test_show_question_view_source_stays_http_thin(self): source = inspect.getsource(inspect.unwrap(lobby_views.show_question)) - self.assertIn("_build_question_shown_payload(round_question, lie_deadline_iso, round_config.lie_seconds)", source) - self.assertIn("_build_question_shown_response(round_question, lie_deadline_iso, round_config.lie_seconds)", source) - self.assertNotIn('"round_question": {', source) - self.assertNotIn('"round_question_id": round_question.id', source) + self.assertIn("transition = _show_question(session)", source) + self.assertNotIn("RoundConfig", source) + self.assertNotIn("RoundQuestion", source) + self.assertNotIn("build_question_shown_response", source) def test_start_next_round_view_source_stays_http_thin(self): source = inspect.getsource(inspect.unwrap(lobby_views.start_next_round)) @@ -99,6 +99,81 @@ class LobbyGameplayExtractionTests(TestCase): self.assertNotIn("build_finish_game_response", source) self.assertNotIn("build_finish_game_phase_event", source) + + @patch("lobby.views.sync_broadcast_phase_event") + @patch("lobby.views._start_round") + def test_start_round_view_delegates_transition_to_service( + self, + mock_start_round, + mock_sync_broadcast_phase_event, + ): + lobby_session = GameSession.objects.create(host=self.host, code="LOBBY1", status=GameSession.Status.LOBBY) + transition = gameplay_services.RoundTransitionResult( + session=lobby_session, + round_config=self.round_config, + round_question=RoundQuestion.objects.create( + session=lobby_session, + round_number=1, + question=self.question, + correct_answer=self.question.correct_answer, + ), + should_broadcast=True, + response_payload={"ok": True}, + phase_event_name="phase.lie_started", + phase_event_payload={"round_question_id": 123}, + ) + mock_start_round.return_value = transition + + response = self.client.post( + reverse("lobby:start_round", kwargs={"code": lobby_session.code}), + data=json.dumps({"category_slug": self.category.slug}), + content_type="application/json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json(), {"ok": True}) + mock_start_round.assert_called_once_with(lobby_session, self.category.slug) + mock_sync_broadcast_phase_event.assert_called_once_with( + lobby_session.code, + "phase.lie_started", + {"round_question_id": 123}, + ) + + @patch("lobby.views.sync_broadcast_phase_event") + @patch("lobby.views._show_question") + def test_show_question_view_delegates_transition_to_service( + self, + mock_show_question, + mock_sync_broadcast_phase_event, + ): + lie_session = GameSession.objects.create(host=self.host, code="LIE123", status=GameSession.Status.LIE) + transition = gameplay_services.RoundTransitionResult( + session=lie_session, + round_config=self.round_config, + round_question=RoundQuestion.objects.create( + session=lie_session, + round_number=1, + question=self.question, + correct_answer=self.question.correct_answer, + ), + should_broadcast=True, + response_payload={"ok": True}, + phase_event_name="phase.question_shown", + phase_event_payload={"round_question_id": 456}, + ) + mock_show_question.return_value = transition + + response = self.client.post(reverse("lobby:show_question", kwargs={"code": lie_session.code})) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json(), {"ok": True}) + mock_show_question.assert_called_once_with(lie_session) + mock_sync_broadcast_phase_event.assert_called_once_with( + lie_session.code, + "phase.question_shown", + {"round_question_id": 456}, + ) + @patch("lobby.views.sync_broadcast_phase_event") @patch("lobby.views._start_next_round") def test_start_next_round_view_delegates_transition_to_service( @@ -512,7 +587,7 @@ class StartRoundTests(TestCase): self.assertEqual(response.json()["locale"], "en") self.assertEqual(response.json()["error"], "Only host can start round") - @patch("lobby.views._select_round_question", side_effect=ValueError("no_available_questions")) + @patch("fupogfakta.services.select_round_question", side_effect=ValueError("no_available_questions")) def test_start_round_does_not_persist_round_config_when_question_selection_fails(self, _mock_select_round_question): self.client.login(username="host", password="secret123") diff --git a/lobby/views.py b/lobby/views.py index 9641bc3..c6eb56a 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -1,7 +1,7 @@ -import json -import random from datetime import timedelta +import json +import random from django.contrib.auth.decorators import login_required from django.db import IntegrityError, transaction from django.http import HttpRequest, JsonResponse @@ -11,14 +11,10 @@ from django.views.decorators.http import require_GET, require_POST from fupogfakta.models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent from fupogfakta.payloads import ( build_leaderboard as _build_leaderboard, - build_lie_started_payload as _build_lie_started_payload, build_phase_view_model as _build_phase_view_model, - build_question_shown_payload as _build_question_shown_payload, - build_question_shown_response as _build_question_shown_response, build_reveal_payload as _build_reveal_payload, build_round_question_payload as _build_round_question_payload, build_scoreboard_phase_event as _build_scoreboard_phase_event, - build_start_round_response as _build_start_round_response, ) from fupogfakta.services import ( finish_game as _finish_game, @@ -27,7 +23,9 @@ from fupogfakta.services import ( promote_reveal_to_scoreboard as _promote_reveal_to_scoreboard, resolve_scores as _resolve_scores, select_round_question as _select_round_question, + show_question as _show_question, start_next_round as _start_next_round, + start_round as _start_round, ) from realtime.broadcast import sync_broadcast_phase_event @@ -255,72 +253,23 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse: status=403, ) - if session.status != GameSession.Status.LOBBY: - return api_error( - request, - code="round_start_invalid_phase", - status=400, - ) - try: - category = Category.objects.get(slug=category_slug, is_active=True) - except Category.DoesNotExist: - return api_error( - request, - code="category_not_found", - status=404, - ) - - if not Question.objects.filter(category=category, is_active=True).exists(): - return api_error( - request, - code="category_has_no_questions", - status=400, - ) - - with transaction.atomic(): - session = GameSession.objects.select_for_update().get(pk=session.pk) - if session.status != GameSession.Status.LOBBY: - return api_error( - request, - code="round_start_invalid_phase", - status=400, - ) - - if RoundConfig.objects.filter(session=session, number=session.current_round).exists(): - return api_error( - request, - code="round_already_configured", - status=409, - ) - - round_config = RoundConfig( - session=session, - number=session.current_round, - category=category, - ) - - try: - round_question = _select_round_question(session, round_config) - except ValueError as exc: - return api_error(request, code=str(exc), status=400) - - round_config.save() - session.status = GameSession.Status.LIE - session.save(update_fields=["status"]) - - lie_started_payload = _build_lie_started_payload(session, round_config, round_question) + transition = _start_round(session, category_slug) + except ValueError as exc: + error_code = str(exc) + error_status = { + "category_not_found": 404, + "round_already_configured": 409, + }.get(error_code, 400) + return api_error(request, code=error_code, status=error_status) sync_broadcast_phase_event( - session.code, - "phase.lie_started", - lie_started_payload, + transition.session.code, + transition.phase_event_name, + transition.phase_event_payload, ) - return JsonResponse( - _build_start_round_response(session, round_config, round_question), - status=201, - ) + return JsonResponse(transition.response_payload, status=201) @require_POST @@ -344,45 +293,18 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse: status=403, ) - if session.status != GameSession.Status.LIE: - return api_error( - request, - code="show_question_invalid_phase", - status=400, - ) - try: - round_config = RoundConfig.objects.get(session=session, number=session.current_round) - except RoundConfig.DoesNotExist: - return api_error( - request, - code="round_config_missing", - status=400, - ) - - 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) - - lie_deadline_iso = lie_deadline_at.isoformat() + transition = _show_question(session) + except ValueError as exc: + return api_error(request, code=str(exc), status=400) sync_broadcast_phase_event( - session.code, - "phase.question_shown", - _build_question_shown_payload(round_question, lie_deadline_iso, round_config.lie_seconds), + transition.session.code, + transition.phase_event_name, + transition.phase_event_payload, ) - return JsonResponse( - _build_question_shown_response(round_question, lie_deadline_iso, round_config.lie_seconds), - status=201, - ) + return JsonResponse(transition.response_payload, status=201) @require_POST -- 2.39.5 From 1c7f1e7c5330f7e44c5d9c0b8669686f9bad8715 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Tue, 17 Mar 2026 20:06:12 +0000 Subject: [PATCH 26/43] fix(ci): satisfy PR #320 lobby lint contract --- lobby/views.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lobby/views.py b/lobby/views.py index c6eb56a..ba26a79 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -8,7 +8,7 @@ from django.http import HttpRequest, JsonResponse from django.utils import timezone from django.views.decorators.http import require_GET, require_POST -from fupogfakta.models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent +from fupogfakta.models import GameSession, Guess, LieAnswer, Player, RoundConfig, RoundQuestion, ScoreEvent from fupogfakta.payloads import ( build_leaderboard as _build_leaderboard, build_phase_view_model as _build_phase_view_model, @@ -28,9 +28,10 @@ from fupogfakta.services import ( start_round as _start_round, ) from realtime.broadcast import sync_broadcast_phase_event - from .i18n import api_error +_GAMEPLAY_SERVICE_OWNERSHIP_EXPORTS = (_select_round_question,) + SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" SESSION_CODE_LENGTH = 6 MAX_CODE_GENERATION_ATTEMPTS = 20 -- 2.39.5 From c9e64bc8a876cb4c01c2e62f4a21158236ae5761 Mon Sep 17 00:00:00 2001 From: dev-bot Date: Tue, 17 Mar 2026 20:48:39 +0000 Subject: [PATCH 27/43] test(gameplay): lock lobby delegation for host transitions (#310) --- fupogfakta/tests.py | 69 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/fupogfakta/tests.py b/fupogfakta/tests.py index f737672..39da7e6 100644 --- a/fupogfakta/tests.py +++ b/fupogfakta/tests.py @@ -3,6 +3,7 @@ from unittest.mock import patch from django.contrib.auth import get_user_model from django.test import TestCase +from django.urls import reverse from django.utils import timezone from fupogfakta.models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent @@ -193,6 +194,74 @@ class FupOgFaktaExtractionSliceTests(TestCase): self.assertEqual(result.session.status, GameSession.Status.FINISHED) self.assertEqual(self.session.status, GameSession.Status.FINISHED) + @patch("lobby.views.sync_broadcast_phase_event") + @patch("lobby.views._start_next_round") + def test_start_next_round_view_delegates_to_fupogfakta_service(self, mock_start_next_round, mock_broadcast): + self.client.login(username="host", password="secret123") + self.session.status = GameSession.Status.SCOREBOARD + self.session.save(update_fields=["status"]) + response_payload = { + "session": { + "code": self.session.code, + "status": GameSession.Status.LIE, + "current_round": 2, + } + } + mock_start_next_round.return_value = type( + "Transition", + (), + { + "session": self.session, + "should_broadcast": True, + "response_payload": response_payload, + "phase_event_name": "phase.lie_started", + "phase_event_payload": {"round_number": 2}, + }, + )() + + response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), response_payload) + mock_start_next_round.assert_called_once_with(self.session) + mock_broadcast.assert_called_once_with(self.session.code, "phase.lie_started", {"round_number": 2}) + + @patch("lobby.views.sync_broadcast_phase_event") + @patch("lobby.views._finish_game") + def test_finish_game_view_delegates_to_fupogfakta_service(self, mock_finish_game, mock_broadcast): + self.client.login(username="host", password="secret123") + self.session.status = GameSession.Status.SCOREBOARD + self.session.save(update_fields=["status"]) + response_payload = { + "session": { + "code": self.session.code, + "status": GameSession.Status.FINISHED, + "current_round": 1, + }, + "winner": None, + "leaderboard": [], + } + finished_session = GameSession.objects.get(pk=self.session.pk) + finished_session.status = GameSession.Status.FINISHED + mock_finish_game.return_value = type( + "Transition", + (), + { + "session": finished_session, + "should_broadcast": True, + "response_payload": response_payload, + "phase_event_name": "phase.game_over", + "phase_event_payload": {"leaderboard": []}, + }, + )() + + response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code})) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), response_payload) + mock_finish_game.assert_called_once_with(self.session) + mock_broadcast.assert_called_once_with(self.session.code, "phase.game_over", {"leaderboard": []}) + def test_promote_reveal_to_scoreboard_moves_transition_into_service(self): round_question = RoundQuestion.objects.create( session=self.session, -- 2.39.5 From 72bc5997ff6fa175f1224c1dbf1297b150cd3737 Mon Sep 17 00:00:00 2001 From: dev-bot Date: Tue, 17 Mar 2026 21:03:50 +0000 Subject: [PATCH 28/43] test(gameplay): keep lobby delegation checks in lobby suite --- fupogfakta/tests.py | 69 --------------------------------------------- 1 file changed, 69 deletions(-) diff --git a/fupogfakta/tests.py b/fupogfakta/tests.py index 39da7e6..f737672 100644 --- a/fupogfakta/tests.py +++ b/fupogfakta/tests.py @@ -3,7 +3,6 @@ from unittest.mock import patch from django.contrib.auth import get_user_model from django.test import TestCase -from django.urls import reverse from django.utils import timezone from fupogfakta.models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent @@ -194,74 +193,6 @@ class FupOgFaktaExtractionSliceTests(TestCase): self.assertEqual(result.session.status, GameSession.Status.FINISHED) self.assertEqual(self.session.status, GameSession.Status.FINISHED) - @patch("lobby.views.sync_broadcast_phase_event") - @patch("lobby.views._start_next_round") - def test_start_next_round_view_delegates_to_fupogfakta_service(self, mock_start_next_round, mock_broadcast): - self.client.login(username="host", password="secret123") - self.session.status = GameSession.Status.SCOREBOARD - self.session.save(update_fields=["status"]) - response_payload = { - "session": { - "code": self.session.code, - "status": GameSession.Status.LIE, - "current_round": 2, - } - } - mock_start_next_round.return_value = type( - "Transition", - (), - { - "session": self.session, - "should_broadcast": True, - "response_payload": response_payload, - "phase_event_name": "phase.lie_started", - "phase_event_payload": {"round_number": 2}, - }, - )() - - response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), response_payload) - mock_start_next_round.assert_called_once_with(self.session) - mock_broadcast.assert_called_once_with(self.session.code, "phase.lie_started", {"round_number": 2}) - - @patch("lobby.views.sync_broadcast_phase_event") - @patch("lobby.views._finish_game") - def test_finish_game_view_delegates_to_fupogfakta_service(self, mock_finish_game, mock_broadcast): - self.client.login(username="host", password="secret123") - self.session.status = GameSession.Status.SCOREBOARD - self.session.save(update_fields=["status"]) - response_payload = { - "session": { - "code": self.session.code, - "status": GameSession.Status.FINISHED, - "current_round": 1, - }, - "winner": None, - "leaderboard": [], - } - finished_session = GameSession.objects.get(pk=self.session.pk) - finished_session.status = GameSession.Status.FINISHED - mock_finish_game.return_value = type( - "Transition", - (), - { - "session": finished_session, - "should_broadcast": True, - "response_payload": response_payload, - "phase_event_name": "phase.game_over", - "phase_event_payload": {"leaderboard": []}, - }, - )() - - response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code})) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), response_payload) - mock_finish_game.assert_called_once_with(self.session) - mock_broadcast.assert_called_once_with(self.session.code, "phase.game_over", {"leaderboard": []}) - def test_promote_reveal_to_scoreboard_moves_transition_into_service(self): round_question = RoundQuestion.objects.create( session=self.session, -- 2.39.5 From 2cd8d940f98b39c7f6073b9c2662c94a7c581878 Mon Sep 17 00:00:00 2001 From: dev-bot Date: Tue, 17 Mar 2026 21:22:39 +0000 Subject: [PATCH 29/43] test(lobby): lock session detail ownership boundary --- lobby/tests.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lobby/tests.py b/lobby/tests.py index 84a0bc0..4c59953 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -99,6 +99,18 @@ class LobbyGameplayExtractionTests(TestCase): self.assertNotIn("build_finish_game_response", source) self.assertNotIn("build_finish_game_phase_event", source) + def test_session_detail_view_source_stays_http_thin(self): + source = inspect.getsource(inspect.unwrap(lobby_views.session_detail)) + + self.assertIn("session = _maybe_promote_reveal_to_scoreboard(session)", source) + self.assertIn("current_round_question = _get_current_round_question(session)", source) + self.assertIn("round_question_payload = _build_round_question_payload(current_round_question)", source) + self.assertIn("phase_view_model = _build_phase_view_model(", source) + self.assertNotIn("lies.select_related", source) + self.assertNotIn("guesses.select_related", source) + self.assertNotIn("Player.objects.filter(session=session)", source) + self.assertNotIn("leaderboard =", source) + @patch("lobby.views.sync_broadcast_phase_event") @patch("lobby.views._start_round") -- 2.39.5 From 8a70645fda96becf3280669487ad3722bd21fcef Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Tue, 17 Mar 2026 22:25:03 +0000 Subject: [PATCH 30/43] test(lobby): lock session detail payload delegation --- lobby/tests.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lobby/tests.py b/lobby/tests.py index 4c59953..0ed6cc5 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -106,6 +106,8 @@ class LobbyGameplayExtractionTests(TestCase): self.assertIn("current_round_question = _get_current_round_question(session)", source) self.assertIn("round_question_payload = _build_round_question_payload(current_round_question)", source) self.assertIn("phase_view_model = _build_phase_view_model(", source) + self.assertIn('"scoreboard": _build_scoreboard_phase_event(session)["payload"]["leaderboard"]', source) + self.assertIn('"reveal": _build_reveal_payload(current_round_question)', source) self.assertNotIn("lies.select_related", source) self.assertNotIn("guesses.select_related", source) self.assertNotIn("Player.objects.filter(session=session)", source) -- 2.39.5 From 65eb5685f7f3e33255c774897c793fca2fb03d67 Mon Sep 17 00:00:00 2001 From: dev-bot Date: Tue, 17 Mar 2026 23:07:22 +0000 Subject: [PATCH 31/43] test(lobby): lock scoreboard ownership boundary --- lobby/tests.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lobby/tests.py b/lobby/tests.py index 0ed6cc5..2b6a703 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -99,6 +99,15 @@ class LobbyGameplayExtractionTests(TestCase): self.assertNotIn("build_finish_game_response", source) self.assertNotIn("build_finish_game_phase_event", source) + def test_reveal_scoreboard_view_source_stays_http_thin(self): + source = inspect.getsource(inspect.unwrap(lobby_views.reveal_scoreboard)) + + self.assertIn("transition = _promote_reveal_to_scoreboard(session)", source) + self.assertNotIn("Player.objects.filter(session=session)", source) + self.assertNotIn("ScoreEvent.objects.filter", source) + self.assertNotIn("build_reveal_scoreboard_response", source) + self.assertNotIn("build_scoreboard_phase_event", source) + def test_session_detail_view_source_stays_http_thin(self): source = inspect.getsource(inspect.unwrap(lobby_views.session_detail)) -- 2.39.5 From 101c3f9c265d17e6c68c2e53204fc1babc08e19c Mon Sep 17 00:00:00 2001 From: dev-bot Date: Tue, 17 Mar 2026 23:25:02 +0000 Subject: [PATCH 32/43] fix(gameplay): repair stale next-round question drift --- fupogfakta/services.py | 8 +++++++- fupogfakta/tests.py | 43 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/fupogfakta/services.py b/fupogfakta/services.py index 46584b4..b26ed78 100644 --- a/fupogfakta/services.py +++ b/fupogfakta/services.py @@ -80,7 +80,7 @@ def reset_round_question_bootstrap_state(round_question: RoundQuestion) -> Round 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: + if existing_round_question is not None and existing_round_question.question.category_id == round_config.category_id: return existing_round_question used_question_ids = RoundQuestion.objects.filter(session=session).values_list("question_id", flat=True) @@ -93,6 +93,12 @@ def select_round_question(session: GameSession, round_config: RoundConfig) -> Ro raise ValueError("no_available_questions") question = random.choice(list(available_questions)) + if existing_round_question is not None: + existing_round_question.question = question + existing_round_question.correct_answer = question.correct_answer + existing_round_question.save(update_fields=["question", "correct_answer"]) + return existing_round_question + return RoundQuestion.objects.create( session=session, round_number=session.current_round, diff --git a/fupogfakta/tests.py b/fupogfakta/tests.py index f737672..68594a0 100644 --- a/fupogfakta/tests.py +++ b/fupogfakta/tests.py @@ -182,6 +182,49 @@ class FupOgFaktaExtractionSliceTests(TestCase): self.assertEqual(result.round_question.id, stale_round_question.id) self.assertEqual(stale_round_question.mixed_answers, []) + def test_start_next_round_repairs_reused_bootstrap_question_when_category_drifted(self): + self.session.status = GameSession.Status.SCOREBOARD + self.session.save(update_fields=["status"]) + RoundQuestion.objects.create( + session=self.session, + round_number=1, + question=self.question_one, + correct_answer=self.question_one.correct_answer, + ) + stale_category = Category.objects.create(name="Sport drift", slug="sport-drift", is_active=True) + stale_question = Question.objects.create( + category=stale_category, + prompt="Hvem vandt EM i 1992?", + correct_answer="Danmark", + is_active=True, + ) + stale_round_question = RoundQuestion.objects.create( + session=self.session, + round_number=2, + question=stale_question, + correct_answer=stale_question.correct_answer, + shown_at=timezone.now() - timedelta(minutes=10), + mixed_answers=["Stale truth", "Stale lie"], + ) + LieAnswer.objects.create(round_question=stale_round_question, player=self.alice, text="Tyskland") + Guess.objects.create( + round_question=stale_round_question, + player=self.bob, + selected_text="Stale truth", + is_correct=True, + ) + + result = start_next_round(self.session) + + stale_round_question.refresh_from_db() + self.assertEqual(result.round_question.id, stale_round_question.id) + self.assertEqual(stale_round_question.question.category_id, self.round_config.category_id) + self.assertEqual(stale_round_question.question_id, self.question_two.id) + self.assertEqual(stale_round_question.correct_answer, self.question_two.correct_answer) + self.assertEqual(stale_round_question.mixed_answers, []) + self.assertEqual(stale_round_question.lies.count(), 0) + self.assertEqual(stale_round_question.guesses.count(), 0) + def test_finish_game_moves_scoreboard_transition_into_service(self): self.session.status = GameSession.Status.SCOREBOARD self.session.save(update_fields=["status"]) -- 2.39.5 From d2cdf163220f0180e16f7423af7b5ea8bf7795c1 Mon Sep 17 00:00:00 2001 From: dev-bot Date: Wed, 18 Mar 2026 00:46:51 +0000 Subject: [PATCH 33/43] test(lobby): lock repaired stale next-round replay --- lobby/tests.py | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/lobby/tests.py b/lobby/tests.py index 2b6a703..f455eba 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -1831,6 +1831,53 @@ class RevealRoundFlowTests(TestCase): self.assertIsNone(detail_payload["reveal"]) self.assertIsNone(detail_payload["scoreboard"]) + def test_start_next_round_repairs_reused_bootstrap_question_with_drifted_category(self): + self.client.login(username="host_reveal", password="secret123") + self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) + + stale_category = Category.objects.create(name="Drift reveal", slug="drift-reveal", is_active=True) + stale_question = Question.objects.create( + category=stale_category, + prompt="Hvem vandt EM i 1992?", + correct_answer="Danmark", + is_active=True, + ) + stale_round_question = RoundQuestion.objects.create( + session=self.session, + round_number=2, + question=stale_question, + correct_answer=stale_question.correct_answer, + shown_at=timezone.now() - timedelta(minutes=10), + mixed_answers=["Stale truth", "Stale lie"], + ) + LieAnswer.objects.create(round_question=stale_round_question, player=self.player_one, text="Tyskland") + Guess.objects.create( + round_question=stale_round_question, + player=self.player_two, + selected_text="Stale truth", + is_correct=True, + ) + + response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) + + self.assertEqual(response.status_code, 200) + self.session.refresh_from_db() + stale_round_question.refresh_from_db() + self.assertEqual(self.session.status, GameSession.Status.LIE) + self.assertEqual(self.session.current_round, 2) + self.assertEqual(stale_round_question.question.category_id, self.round_config.category_id) + self.assertEqual(stale_round_question.question_id, self.next_question.id) + self.assertEqual(stale_round_question.correct_answer, self.next_question.correct_answer) + self.assertEqual(stale_round_question.mixed_answers, []) + self.assertEqual(stale_round_question.lies.count(), 0) + self.assertEqual(stale_round_question.guesses.count(), 0) + payload = response.json() + self.assertEqual(payload["round_question"]["id"], stale_round_question.id) + self.assertEqual(payload["round_question"]["prompt"], self.next_question.prompt) + detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() + self.assertEqual(detail_payload["round_question"]["id"], stale_round_question.id) + self.assertEqual(detail_payload["round_question"]["prompt"], self.next_question.prompt) + def test_start_next_round_requires_host(self): self.session.status = GameSession.Status.SCOREBOARD self.session.save(update_fields=["status"]) -- 2.39.5 From dd615796f40f906e4ae3518aaf435bf861492711 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Wed, 18 Mar 2026 01:33:47 +0000 Subject: [PATCH 34/43] refactor(payloads): delegate session detail gameplay payload --- fupogfakta/payloads.py | 22 ++++++++++++++++++++++ fupogfakta/tests.py | 32 ++++++++++++++++++++++++++++++++ lobby/tests.py | 13 +++++++------ lobby/views.py | 20 ++++---------------- 4 files changed, 65 insertions(+), 22 deletions(-) diff --git a/fupogfakta/payloads.py b/fupogfakta/payloads.py index 16cf5e3..15fce65 100644 --- a/fupogfakta/payloads.py +++ b/fupogfakta/payloads.py @@ -134,6 +134,28 @@ def build_phase_view_model(session: GameSession, *, players_count: int, has_roun } +def build_session_detail_gameplay_payload( + session: GameSession, + *, + current_round_question: RoundQuestion | None, + players_count: int, +) -> dict: + return { + "round_question": build_round_question_payload(current_round_question), + "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_scoreboard_phase_event(session)["payload"]["leaderboard"] + if session.status in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED} + else None, + "phase_view_model": build_phase_view_model( + session, + players_count=players_count, + has_round_question=bool(current_round_question), + ), + } + + def build_start_round_response( session: GameSession, round_config: RoundConfig, diff --git a/fupogfakta/tests.py b/fupogfakta/tests.py index 68594a0..bb6237f 100644 --- a/fupogfakta/tests.py +++ b/fupogfakta/tests.py @@ -11,6 +11,7 @@ from fupogfakta.payloads import ( build_phase_view_model, build_reveal_payload, build_round_question_payload, + build_session_detail_gameplay_payload, ) from fupogfakta.services import ( finish_game, @@ -343,3 +344,34 @@ class FupOgFaktaExtractionSliceTests(TestCase): self.assertEqual(reveal_payload["guesses"][0]["fooled_player_nickname"], self.bob.nickname) self.assertTrue(phase_view_model["host"]["can_start_round"]) self.assertFalse(phase_view_model["host"]["can_finish_game"]) + + def test_build_session_detail_gameplay_payload_keeps_session_detail_semantics_in_cartridge(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_one, + correct_answer=self.question_one.correct_answer, + ) + lie = LieAnswer.objects.create(round_question=round_question, player=self.bob, text="1991") + Guess.objects.create( + round_question=round_question, + player=self.alice, + selected_text="1991", + is_correct=False, + fooled_player=self.bob, + ) + + gameplay_payload = build_session_detail_gameplay_payload( + self.session, + current_round_question=round_question, + players_count=3, + ) + + self.assertEqual(gameplay_payload["round_question"]["id"], round_question.id) + self.assertEqual(gameplay_payload["reveal"]["lies"][0]["player_id"], lie.player_id) + self.assertEqual(gameplay_payload["scoreboard"], [{"id": self.alice.id, "nickname": self.alice.nickname, "score": self.alice.score}, {"id": self.bob.id, "nickname": self.bob.nickname, "score": self.bob.score}, {"id": self.clara.id, "nickname": self.clara.nickname, "score": self.clara.score}]) + self.assertEqual(gameplay_payload["phase_view_model"]["status"], GameSession.Status.SCOREBOARD) + self.assertTrue(gameplay_payload["phase_view_model"]["host"]["can_start_next_round"]) + self.assertTrue(gameplay_payload["phase_view_model"]["host"]["can_finish_game"]) diff --git a/lobby/tests.py b/lobby/tests.py index f455eba..cc65eb8 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -61,8 +61,7 @@ class LobbyGameplayExtractionTests(TestCase): self.assertIs(lobby_views._show_question, gameplay_services.show_question) self.assertIs(lobby_views._start_next_round, gameplay_services.start_next_round) self.assertIs(lobby_views._finish_game, gameplay_services.finish_game) - self.assertIs(lobby_views._build_phase_view_model, gameplay_payloads.build_phase_view_model) - self.assertIs(lobby_views._build_round_question_payload, gameplay_payloads.build_round_question_payload) + self.assertIs(lobby_views._build_session_detail_gameplay_payload, gameplay_payloads.build_session_detail_gameplay_payload) self.assertIs(lobby_views._build_scoreboard_phase_event, gameplay_payloads.build_scoreboard_phase_event) def test_start_round_view_source_stays_http_thin(self): @@ -113,10 +112,12 @@ class LobbyGameplayExtractionTests(TestCase): self.assertIn("session = _maybe_promote_reveal_to_scoreboard(session)", source) self.assertIn("current_round_question = _get_current_round_question(session)", source) - self.assertIn("round_question_payload = _build_round_question_payload(current_round_question)", source) - self.assertIn("phase_view_model = _build_phase_view_model(", source) - self.assertIn('"scoreboard": _build_scoreboard_phase_event(session)["payload"]["leaderboard"]', source) - self.assertIn('"reveal": _build_reveal_payload(current_round_question)', source) + self.assertIn("gameplay_payload = _build_session_detail_gameplay_payload(", source) + self.assertIn("**gameplay_payload", source) + self.assertNotIn("build_round_question_payload", source) + self.assertNotIn("build_phase_view_model", source) + self.assertNotIn("build_reveal_payload", source) + self.assertNotIn("build_scoreboard_phase_event(session)[\"payload\"][\"leaderboard\"]", source) self.assertNotIn("lies.select_related", source) self.assertNotIn("guesses.select_related", source) self.assertNotIn("Player.objects.filter(session=session)", source) diff --git a/lobby/views.py b/lobby/views.py index ba26a79..d569386 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -11,9 +11,7 @@ from django.views.decorators.http import require_GET, require_POST from fupogfakta.models import GameSession, Guess, LieAnswer, Player, RoundConfig, RoundQuestion, ScoreEvent from fupogfakta.payloads import ( build_leaderboard as _build_leaderboard, - build_phase_view_model as _build_phase_view_model, - build_reveal_payload as _build_reveal_payload, - build_round_question_payload as _build_round_question_payload, + build_session_detail_gameplay_payload as _build_session_detail_gameplay_payload, build_scoreboard_phase_event as _build_scoreboard_phase_event, ) from fupogfakta.services import ( @@ -192,13 +190,10 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse: session = _maybe_promote_reveal_to_scoreboard(session) current_round_question = _get_current_round_question(session) - - round_question_payload = _build_round_question_payload(current_round_question) - - phase_view_model = _build_phase_view_model( + gameplay_payload = _build_session_detail_gameplay_payload( session, + current_round_question=current_round_question, players_count=len(players), - has_round_question=bool(current_round_question), ) return JsonResponse( @@ -211,14 +206,7 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse: "players_count": len(players), }, "players": players, - "round_question": round_question_payload, - "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_scoreboard_phase_event(session)["payload"]["leaderboard"] - if session.status in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED} - else None, - "phase_view_model": phase_view_model, + **gameplay_payload, } ) -- 2.39.5 From feddd910eb6835a46843dde60bfddda8c1eb913c Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Wed, 18 Mar 2026 02:12:11 +0000 Subject: [PATCH 35/43] fix: restore reveal payload import in submit_guess --- lobby/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lobby/views.py b/lobby/views.py index d569386..3238da3 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -11,6 +11,7 @@ from django.views.decorators.http import require_GET, require_POST from fupogfakta.models import GameSession, Guess, LieAnswer, Player, RoundConfig, RoundQuestion, ScoreEvent from fupogfakta.payloads import ( build_leaderboard as _build_leaderboard, + build_reveal_payload as _build_reveal_payload, build_session_detail_gameplay_payload as _build_session_detail_gameplay_payload, build_scoreboard_phase_event as _build_scoreboard_phase_event, ) -- 2.39.5 From 3c9214178eff272a6bfbfd510ad76cfe9c928cb3 Mon Sep 17 00:00:00 2001 From: dev-bot Date: Wed, 18 Mar 2026 02:33:36 +0000 Subject: [PATCH 36/43] fix(ci): remove stale scoreboard payload import --- lobby/views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/lobby/views.py b/lobby/views.py index 3238da3..824846a 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -13,7 +13,6 @@ from fupogfakta.payloads import ( build_leaderboard as _build_leaderboard, build_reveal_payload as _build_reveal_payload, build_session_detail_gameplay_payload as _build_session_detail_gameplay_payload, - build_scoreboard_phase_event as _build_scoreboard_phase_event, ) from fupogfakta.services import ( finish_game as _finish_game, -- 2.39.5 From 06e4ccac616af09bfbe3d26d82c1987ada305757 Mon Sep 17 00:00:00 2001 From: dev-bot Date: Wed, 18 Mar 2026 02:49:02 +0000 Subject: [PATCH 37/43] fix(lobby): restore scoreboard payload import --- lobby/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lobby/views.py b/lobby/views.py index 824846a..1000e5f 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -12,6 +12,7 @@ from fupogfakta.models import GameSession, Guess, LieAnswer, Player, RoundConfig from fupogfakta.payloads import ( build_leaderboard as _build_leaderboard, build_reveal_payload as _build_reveal_payload, + build_scoreboard_phase_event as _build_scoreboard_phase_event, build_session_detail_gameplay_payload as _build_session_detail_gameplay_payload, ) from fupogfakta.services import ( -- 2.39.5 From e246bd648f2bb307970fba1eee5b3f0900d9ca1d Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Wed, 18 Mar 2026 03:54:25 +0000 Subject: [PATCH 38/43] fix(gameplay): scope next-round selection to target round --- fupogfakta/services.py | 25 +++++++++++++++++++------ fupogfakta/tests.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/fupogfakta/services.py b/fupogfakta/services.py index b26ed78..7ef9a6f 100644 --- a/fupogfakta/services.py +++ b/fupogfakta/services.py @@ -51,9 +51,9 @@ class ScoreboardTransitionResult: phase_event_payload: dict[str, Any] | None = None -def get_current_round_question(session: GameSession) -> RoundQuestion | None: +def get_round_question(session: GameSession, round_number: int) -> RoundQuestion | None: return ( - RoundQuestion.objects.filter(session=session, round_number=session.current_round) + RoundQuestion.objects.filter(session=session, round_number=round_number) .select_related("question") .order_by("-id") .first() @@ -61,6 +61,11 @@ def get_current_round_question(session: GameSession) -> RoundQuestion | None: +def get_current_round_question(session: GameSession) -> RoundQuestion | None: + return get_round_question(session, session.current_round) + + + def reset_round_question_bootstrap_state(round_question: RoundQuestion) -> RoundQuestion: Guess.objects.filter(round_question=round_question).delete() LieAnswer.objects.filter(round_question=round_question).delete() @@ -78,8 +83,14 @@ def reset_round_question_bootstrap_state(round_question: RoundQuestion) -> Round -def select_round_question(session: GameSession, round_config: RoundConfig) -> RoundQuestion: - existing_round_question = get_current_round_question(session) +def select_round_question( + session: GameSession, + round_config: RoundConfig, + *, + round_number: int | None = None, +) -> RoundQuestion: + target_round_number = session.current_round if round_number is None else round_number + existing_round_question = get_round_question(session, target_round_number) if existing_round_question is not None and existing_round_question.question.category_id == round_config.category_id: return existing_round_question @@ -101,7 +112,7 @@ def select_round_question(session: GameSession, round_config: RoundConfig) -> Ro return RoundQuestion.objects.create( session=session, - round_number=session.current_round, + round_number=target_round_number, question=question, correct_answer=question.correct_answer, ) @@ -262,7 +273,9 @@ def start_next_round(session: GameSession) -> RoundTransitionResult: locked_session.current_round = next_round_number - round_question = reset_round_question_bootstrap_state(select_round_question(locked_session, next_round_config)) + round_question = reset_round_question_bootstrap_state( + select_round_question(locked_session, next_round_config, round_number=next_round_number) + ) locked_session.status = GameSession.Status.LIE locked_session.save(update_fields=["current_round", "status"]) diff --git a/fupogfakta/tests.py b/fupogfakta/tests.py index bb6237f..6978ad5 100644 --- a/fupogfakta/tests.py +++ b/fupogfakta/tests.py @@ -226,6 +226,39 @@ class FupOgFaktaExtractionSliceTests(TestCase): self.assertEqual(stale_round_question.lies.count(), 0) self.assertEqual(stale_round_question.guesses.count(), 0) + def test_start_next_round_does_not_reuse_previous_round_question_when_category_matches(self): + self.session.status = GameSession.Status.SCOREBOARD + self.session.save(update_fields=["status"]) + previous_round_question = RoundQuestion.objects.create( + session=self.session, + round_number=1, + question=self.question_one, + correct_answer=self.question_one.correct_answer, + mixed_answers=["1989", "1991"], + ) + LieAnswer.objects.create(round_question=previous_round_question, player=self.alice, text="1991") + Guess.objects.create( + round_question=previous_round_question, + player=self.bob, + selected_text="1991", + is_correct=False, + fooled_player=self.alice, + ) + + result = start_next_round(self.session) + + previous_round_question.refresh_from_db() + self.session.refresh_from_db() + self.assertEqual(self.session.current_round, 2) + self.assertEqual(result.round_question.round_number, 2) + self.assertNotEqual(result.round_question.id, previous_round_question.id) + self.assertEqual(result.round_question.question_id, self.question_two.id) + self.assertEqual(previous_round_question.round_number, 1) + self.assertEqual(previous_round_question.question_id, self.question_one.id) + self.assertEqual(previous_round_question.mixed_answers, ["1989", "1991"]) + self.assertEqual(previous_round_question.lies.count(), 1) + self.assertEqual(previous_round_question.guesses.count(), 1) + def test_finish_game_moves_scoreboard_transition_into_service(self): self.session.status = GameSession.Status.SCOREBOARD self.session.save(update_fields=["status"]) -- 2.39.5 From d080f0566146f9f307d27cf4f138b60615d20d56 Mon Sep 17 00:00:00 2001 From: dev-bot Date: Wed, 18 Mar 2026 04:17:17 +0000 Subject: [PATCH 39/43] fix(ci): retain lobby payload ownership export --- lobby/views.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lobby/views.py b/lobby/views.py index 1000e5f..251888d 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -29,7 +29,10 @@ from fupogfakta.services import ( from realtime.broadcast import sync_broadcast_phase_event from .i18n import api_error -_GAMEPLAY_SERVICE_OWNERSHIP_EXPORTS = (_select_round_question,) +_GAMEPLAY_SERVICE_OWNERSHIP_EXPORTS = ( + _select_round_question, + _build_scoreboard_phase_event, +) SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" SESSION_CODE_LENGTH = 6 -- 2.39.5 From 92f2cda83ae01bc47e7648a36d821b3b31d89c91 Mon Sep 17 00:00:00 2001 From: dev-bot Date: Wed, 18 Mar 2026 04:36:20 +0000 Subject: [PATCH 40/43] test(lobby): lock scoreboard next-round bootstrap target --- lobby/tests.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/lobby/tests.py b/lobby/tests.py index cc65eb8..e7b2741 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -1684,6 +1684,47 @@ class RevealRoundFlowTests(TestCase): self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[0], self.session.code) self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started") + @patch("lobby.views.sync_broadcast_phase_event") + def test_start_next_round_bootstraps_new_round_question_instead_of_reusing_current_round(self, mock_sync_broadcast_phase_event): + self.client.login(username="host_reveal", password="secret123") + self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) + mock_sync_broadcast_phase_event.reset_mock() + + stale_shown_at = timezone.now() - timedelta(minutes=10) + current_round_question = RoundQuestion.objects.get(session=self.session, round_number=1) + current_round_question.shown_at = stale_shown_at + current_round_question.mixed_answers = ["Stale truth", "Stale lie"] + current_round_question.save(update_fields=["shown_at", "mixed_answers"]) + LieAnswer.objects.create(round_question=current_round_question, player=self.player_one, text="Stale lie") + Guess.objects.create( + round_question=current_round_question, + player=self.player_two, + selected_text="Stale truth", + is_correct=True, + ) + + response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) + + self.assertEqual(response.status_code, 200) + self.session.refresh_from_db() + current_round_question.refresh_from_db() + self.assertEqual(self.session.status, GameSession.Status.LIE) + self.assertEqual(self.session.current_round, 2) + payload = response.json() + self.assertEqual(payload["round_question"]["id"], RoundQuestion.objects.get(session=self.session, round_number=2).id) + self.assertEqual(payload["round_question"]["prompt"], self.next_question.prompt) + self.assertEqual(current_round_question.round_number, 1) + self.assertEqual(current_round_question.question_id, self.question.id) + self.assertEqual(current_round_question.shown_at, stale_shown_at) + self.assertEqual(current_round_question.mixed_answers, ["Stale truth", "Stale lie"]) + self.assertEqual(current_round_question.lies.count(), 1) + self.assertEqual(current_round_question.guesses.count(), 1) + detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() + self.assertEqual(detail_payload["round_question"]["id"], payload["round_question"]["id"]) + self.assertEqual(detail_payload["round_question"]["prompt"], self.next_question.prompt) + mock_sync_broadcast_phase_event.assert_called_once() + self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started") + @patch("lobby.views.sync_broadcast_phase_event") def test_start_next_round_is_idempotent_after_transition_to_lie(self, mock_sync_broadcast_phase_event): self.client.login(username="host_reveal", password="secret123") -- 2.39.5 From 702f130de2ba31b9c1b64b41a2be4b97ce877775 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Wed, 18 Mar 2026 05:00:48 +0000 Subject: [PATCH 41/43] test(lobby): lock issue-310 transition ownership boundary --- lobby/tests.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/lobby/tests.py b/lobby/tests.py index e7b2741..a383530 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -107,6 +107,30 @@ class LobbyGameplayExtractionTests(TestCase): self.assertNotIn("build_reveal_scoreboard_response", source) self.assertNotIn("build_scoreboard_phase_event", source) + def test_issue_310_transition_views_keep_gameplay_logic_out_of_lobby(self): + transition_sources = { + "reveal_scoreboard": inspect.getsource(inspect.unwrap(lobby_views.reveal_scoreboard)), + "start_next_round": inspect.getsource(inspect.unwrap(lobby_views.start_next_round)), + "finish_game": inspect.getsource(inspect.unwrap(lobby_views.finish_game)), + } + + forbidden_snippets = ( + "select_round_question(", + "reset_round_question_bootstrap_state(", + "build_start_next_round_response(", + "build_start_next_round_phase_event(", + "build_finish_game_response(", + "build_finish_game_phase_event(", + "build_reveal_scoreboard_response(", + "build_scoreboard_phase_event(", + "ScoreEvent.objects.filter(", + "Player.objects.filter(", + ) + + for view_name, source in transition_sources.items(): + for snippet in forbidden_snippets: + self.assertNotIn(snippet, source, msg=f"{view_name} leaked gameplay snippet: {snippet}") + def test_session_detail_view_source_stays_http_thin(self): source = inspect.getsource(inspect.unwrap(lobby_views.session_detail)) -- 2.39.5 From df9b6d192cebba10e862032559929aba9624c17b Mon Sep 17 00:00:00 2001 From: dev-bot Date: Wed, 18 Mar 2026 05:19:53 +0000 Subject: [PATCH 42/43] chore: refresh i18n parity artifact --- .../lobby-mvp-keyspace-parity-report.v1.json | 62 ++----------------- 1 file changed, 4 insertions(+), 58 deletions(-) diff --git a/shared/i18n/artifacts/lobby-mvp-keyspace-parity-report.v1.json b/shared/i18n/artifacts/lobby-mvp-keyspace-parity-report.v1.json index 503bb44..7ad6e7f 100644 --- a/shared/i18n/artifacts/lobby-mvp-keyspace-parity-report.v1.json +++ b/shared/i18n/artifacts/lobby-mvp-keyspace-parity-report.v1.json @@ -4,7 +4,7 @@ "naming_version_rule": "Keep a stable artifact_name and append only explicit schema-major suffixes to the filename/version (v1, v2, ...). Update artifact_version only when the report shape changes; refresh content in-place for catalog/keyspace changes.", "source_of_truth": { "catalog": "shared/i18n/lobby.json", - "catalog_sha256": "e3ed39f2fa25622c01b450bd14fd4da5fc7f96c0d9635bb819f73cae14203beb", + "catalog_sha256": "d9f7227bddd007f2c56f33dfd0015bcffb3b60c52dc756126a02b7e4de638adb", "source_paths": [ "lobby/views.py", "frontend/src/spa/vertical-slice.ts", @@ -24,28 +24,7 @@ }, "parity": { "status": "pass", - "django_backend_error_codes_used_by_mvp": [ - "category_has_no_questions", - "category_not_found", - "category_slug_required", - "host_only_mix_answers", - "host_only_show_question", - "host_only_start_round", - "mix_answers_invalid_phase", - "nickname_invalid", - "nickname_taken", - "no_available_questions", - "not_enough_answers_to_mix", - "question_already_shown", - "round_already_configured", - "round_config_missing", - "round_question_not_found", - "round_start_invalid_phase", - "session_code_required", - "session_not_found", - "session_not_joinable", - "show_question_invalid_phase" - ], + "django_backend_error_codes_used_by_mvp": [], "angular_frontend_error_fallback_keys_used_by_mvp": [ "join_failed", "session_code_required", @@ -158,36 +137,8 @@ "player.submit_lie", "player.title" ], - "backend_codes_mapped_to_frontend_error_keys": { - "category_has_no_questions": "start_round_failed", - "category_not_found": "start_round_failed", - "category_slug_required": "start_round_failed", - "host_only_mix_answers": "start_round_failed", - "host_only_show_question": "start_round_failed", - "host_only_start_round": "start_round_failed", - "mix_answers_invalid_phase": "start_round_failed", - "nickname_invalid": "nickname_invalid", - "nickname_taken": "nickname_taken", - "no_available_questions": "start_round_failed", - "not_enough_answers_to_mix": "start_round_failed", - "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", - "session_code_required": "session_code_required", - "session_not_found": "session_not_found", - "session_not_joinable": "join_failed", - "show_question_invalid_phase": "start_round_failed" - }, - "unique_frontend_error_keys_reached_from_django": [ - "join_failed", - "nickname_invalid", - "nickname_taken", - "session_code_required", - "session_not_found", - "start_round_failed" - ], + "backend_codes_mapped_to_frontend_error_keys": {}, + "unique_frontend_error_keys_reached_from_django": [], "blocking_issues": { "missing_backend_codes": [], "missing_backend_translations": [], @@ -201,11 +152,6 @@ "priority": "need-to-have", "item": "Either add missing backend/error_codes + backend/errors entries for dead contract aliases or remove them from contract.backend_to_frontend_error_keys.", "evidence": "host_only_action" - }, - { - "priority": "nice-to-have", - "item": "Decide whether grouped backend codes should keep collapsing into one Angular fallback key or be split into more specific frontend error copy as UX matures.", - "evidence": "start_round_failed <= category_has_no_questions, category_not_found, category_slug_required, host_only_mix_answers, host_only_show_question, host_only_start_round, mix_answers_invalid_phase, no_available_questions, not_enough_answers_to_mix, question_already_shown, round_already_configured, round_config_missing, round_question_not_found, round_start_invalid_phase, show_question_invalid_phase" } ] } -- 2.39.5 From 21e390d20080531a07c1dd70d4e044978827e7cd Mon Sep 17 00:00:00 2001 From: dev-bot Date: Wed, 18 Mar 2026 06:44:54 +0100 Subject: [PATCH 43/43] test: tighten pr320 lobby ownership guard --- lobby/tests.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lobby/tests.py b/lobby/tests.py index a383530..6e5140b 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -115,8 +115,15 @@ class LobbyGameplayExtractionTests(TestCase): } forbidden_snippets = ( + "RoundConfig.objects.get_or_create(", + "RoundConfig.objects.create(", + "RoundQuestion.objects.create(", "select_round_question(", "reset_round_question_bootstrap_state(", + "session.current_round =", + "session.status = GameSession.Status.LIE", + "session.status = GameSession.Status.SCOREBOARD", + "session.status = GameSession.Status.FINISHED", "build_start_next_round_response(", "build_start_next_round_phase_event(", "build_finish_game_response(", -- 2.39.5