refactor(gameplay): move transition event composition into service
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m36s
CI / test-and-quality (push) Successful in 3m37s

This commit is contained in:
2026-03-17 11:58:39 +00:00
parent 9baade0105
commit 8a07433f11
3 changed files with 37 additions and 36 deletions

View File

@@ -1,9 +1,11 @@
import random import random
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any
from django.db import transaction from django.db import transaction
from .models import GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent 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) @dataclass(frozen=True)
@@ -12,12 +14,16 @@ class RoundTransitionResult:
round_config: RoundConfig round_config: RoundConfig
round_question: RoundQuestion round_question: RoundQuestion
should_broadcast: bool should_broadcast: bool
phase_event_name: str | None = None
phase_event_payload: dict[str, Any] | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
class FinishGameResult: class FinishGameResult:
session: GameSession session: GameSession
should_broadcast: bool should_broadcast: bool
phase_event_name: str | None = None
phase_event_payload: dict[str, Any] | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -102,6 +108,9 @@ def start_next_round(session: GameSession) -> RoundTransitionResult:
round_question = None round_question = None
should_broadcast = False should_broadcast = False
phase_event_name = None
phase_event_payload = None
if locked_session.status == GameSession.Status.SCOREBOARD: if locked_session.status == GameSession.Status.SCOREBOARD:
previous_round_config = RoundConfig.objects.filter( previous_round_config = RoundConfig.objects.filter(
session=locked_session, session=locked_session,
@@ -129,6 +138,9 @@ def start_next_round(session: GameSession) -> RoundTransitionResult:
locked_session.status = GameSession.Status.LIE locked_session.status = GameSession.Status.LIE
locked_session.save(update_fields=["current_round", "status"]) locked_session.save(update_fields=["current_round", "status"])
should_broadcast = True 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: elif locked_session.status == GameSession.Status.LIE:
if locked_session.current_round <= 1: if locked_session.current_round <= 1:
raise ValueError("next_round_invalid_phase") raise ValueError("next_round_invalid_phase")
@@ -152,6 +164,8 @@ def start_next_round(session: GameSession) -> RoundTransitionResult:
round_config=next_round_config, round_config=next_round_config,
round_question=round_question, round_question=round_question,
should_broadcast=should_broadcast, 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(): with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk) locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
should_broadcast = False should_broadcast = False
phase_event_name = None
phase_event_payload = None
if locked_session.status == GameSession.Status.SCOREBOARD: if locked_session.status == GameSession.Status.SCOREBOARD:
locked_session.status = GameSession.Status.FINISHED locked_session.status = GameSession.Status.FINISHED
locked_session.save(update_fields=["status"]) locked_session.save(update_fields=["status"])
should_broadcast = True 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: elif locked_session.status != GameSession.Status.FINISHED:
raise ValueError("finish_game_invalid_phase") 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,
)

View File

@@ -59,19 +59,15 @@ class LobbyGameplayExtractionTests(TestCase):
self.assertIs(lobby_views._start_next_round, gameplay_services.start_next_round) 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._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_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_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) self.assertIs(lobby_views._build_finish_game_response, gameplay_payloads.build_finish_game_response)
@patch("lobby.views.sync_broadcast_phase_event") @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_response", return_value={"ok": True})
@patch("lobby.views._build_start_next_round_phase_event")
@patch("lobby.views._start_next_round") @patch("lobby.views._start_next_round")
def test_start_next_round_view_delegates_transition_to_service( def test_start_next_round_view_delegates_transition_to_service(
self, self,
mock_start_next_round, mock_start_next_round,
mock_build_phase_event,
mock_build_response, mock_build_response,
mock_sync_broadcast_phase_event, mock_sync_broadcast_phase_event,
): ):
@@ -92,19 +88,16 @@ class LobbyGameplayExtractionTests(TestCase):
round_config=next_round_config, round_config=next_round_config,
round_question=round_question, round_question=round_question,
should_broadcast=True, 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_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})) response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code}))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"ok": True}) self.assertEqual(response.json(), {"ok": True})
mock_start_next_round.assert_called_once_with(self.session) 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_build_response.assert_called_once_with(self.session, next_round_config, round_question)
mock_sync_broadcast_phase_event.assert_called_once_with( mock_sync_broadcast_phase_event.assert_called_once_with(
self.session.code, self.session.code,
@@ -114,30 +107,28 @@ class LobbyGameplayExtractionTests(TestCase):
@patch("lobby.views.sync_broadcast_phase_event") @patch("lobby.views.sync_broadcast_phase_event")
@patch("lobby.views._build_finish_game_response", return_value={"ok": True}) @patch("lobby.views._build_finish_game_response", return_value={"ok": True})
@patch("lobby.views._build_finish_game_phase_event")
@patch("lobby.views._finish_game") @patch("lobby.views._finish_game")
def test_finish_game_view_delegates_transition_to_service( def test_finish_game_view_delegates_transition_to_service(
self, self,
mock_finish_game, mock_finish_game,
mock_build_phase_event,
mock_build_response, mock_build_response,
mock_sync_broadcast_phase_event, mock_sync_broadcast_phase_event,
): ):
finished_session = GameSession.objects.get(pk=self.session.pk) finished_session = GameSession.objects.get(pk=self.session.pk)
finished_session.status = GameSession.Status.FINISHED 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_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})) response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code}))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"ok": True}) self.assertEqual(response.json(), {"ok": True})
mock_finish_game.assert_called_once_with(self.session) 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_build_response.assert_called_once_with(finished_session)
mock_sync_broadcast_phase_event.assert_called_once_with( mock_sync_broadcast_phase_event.assert_called_once_with(
self.session.code, self.session.code,
@@ -147,12 +138,10 @@ class LobbyGameplayExtractionTests(TestCase):
@patch("lobby.views.sync_broadcast_phase_event") @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_response", return_value={"ok": True})
@patch("lobby.views._build_start_next_round_phase_event")
@patch("lobby.views._start_next_round") @patch("lobby.views._start_next_round")
def test_start_next_round_view_skips_broadcast_on_service_replay( def test_start_next_round_view_skips_broadcast_on_service_replay(
self, self,
mock_start_next_round, mock_start_next_round,
mock_build_phase_event,
mock_build_response, mock_build_response,
mock_sync_broadcast_phase_event, mock_sync_broadcast_phase_event,
): ):
@@ -184,18 +173,15 @@ class LobbyGameplayExtractionTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"ok": True}) self.assertEqual(response.json(), {"ok": True})
mock_start_next_round.assert_called_once_with(self.session) 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_build_response.assert_called_once_with(replay_session, replay_round_config, round_question)
mock_sync_broadcast_phase_event.assert_not_called() mock_sync_broadcast_phase_event.assert_not_called()
@patch("lobby.views.sync_broadcast_phase_event") @patch("lobby.views.sync_broadcast_phase_event")
@patch("lobby.views._build_finish_game_response", return_value={"ok": True}) @patch("lobby.views._build_finish_game_response", return_value={"ok": True})
@patch("lobby.views._build_finish_game_phase_event")
@patch("lobby.views._finish_game") @patch("lobby.views._finish_game")
def test_finish_game_view_skips_broadcast_on_service_replay( def test_finish_game_view_skips_broadcast_on_service_replay(
self, self,
mock_finish_game, mock_finish_game,
mock_build_phase_event,
mock_build_response, mock_build_response,
mock_sync_broadcast_phase_event, mock_sync_broadcast_phase_event,
): ):
@@ -209,7 +195,6 @@ class LobbyGameplayExtractionTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"ok": True}) self.assertEqual(response.json(), {"ok": True})
mock_finish_game.assert_called_once_with(self.session) 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_build_response.assert_called_once_with(finished_session)
mock_sync_broadcast_phase_event.assert_not_called() mock_sync_broadcast_phase_event.assert_not_called()

View File

@@ -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.models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent
from fupogfakta.payloads import ( 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_finish_game_response as _build_finish_game_response,
build_leaderboard as _build_leaderboard, build_leaderboard as _build_leaderboard,
build_lie_started_payload as _build_lie_started_payload, build_lie_started_payload as _build_lie_started_payload,
build_reveal_payload as _build_reveal_payload, build_reveal_payload as _build_reveal_payload,
build_scoreboard_phase_event as _build_scoreboard_phase_event, 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, build_start_next_round_response as _build_start_next_round_response,
) )
from fupogfakta.services import ( 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) return api_error(request, code=str(exc), status=400)
if transition.should_broadcast: if transition.should_broadcast:
phase_event = _build_start_next_round_phase_event(
transition.session,
transition.round_config,
transition.round_question,
)
sync_broadcast_phase_event( sync_broadcast_phase_event(
transition.session.code, transition.session.code,
phase_event["name"], transition.phase_event_name,
phase_event["payload"], transition.phase_event_payload,
) )
return JsonResponse( return JsonResponse(
@@ -955,11 +948,10 @@ def finish_game(request: HttpRequest, code: str) -> JsonResponse:
return api_error(request, code=str(exc), status=400) return api_error(request, code=str(exc), status=400)
if transition.should_broadcast: if transition.should_broadcast:
phase_event = _build_finish_game_phase_event(transition.session)
sync_broadcast_phase_event( sync_broadcast_phase_event(
transition.session.code, transition.session.code,
phase_event["name"], transition.phase_event_name,
phase_event["payload"], transition.phase_event_payload,
) )
return JsonResponse(_build_finish_game_response(transition.session)) return JsonResponse(_build_finish_game_response(transition.session))