From a916da12a71f36cd74752994d4950e7bde34acdc Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Tue, 17 Mar 2026 10:41:09 +0000 Subject: [PATCH] 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( {