refactor(gameplay): keep host transition payloads in cartridge
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m37s
CI / test-and-quality (push) Successful in 3m38s

This commit is contained in:
2026-03-17 16:06:46 +00:00
parent fefc5ecd56
commit dfa197b33b
4 changed files with 72 additions and 47 deletions

View File

@@ -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: def build_finish_game_phase_event(session: GameSession) -> dict:
leaderboard = build_leaderboard(session) leaderboard = build_leaderboard(session)
winner = leaderboard[0] if leaderboard else None winner = leaderboard[0] if leaderboard else None

View File

@@ -6,7 +6,14 @@ from django.db import transaction
from django.utils import timezone from django.utils import timezone
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 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) @dataclass(frozen=True)
@@ -15,6 +22,7 @@ class RoundTransitionResult:
round_config: RoundConfig round_config: RoundConfig
round_question: RoundQuestion round_question: RoundQuestion
should_broadcast: bool should_broadcast: bool
response_payload: dict[str, Any]
phase_event_name: str | None = None phase_event_name: str | None = None
phase_event_payload: dict[str, Any] | None = None phase_event_payload: dict[str, Any] | None = None
@@ -23,6 +31,7 @@ class RoundTransitionResult:
class FinishGameResult: class FinishGameResult:
session: GameSession session: GameSession
should_broadcast: bool should_broadcast: bool
response_payload: dict[str, Any]
phase_event_name: str | None = None phase_event_name: str | None = None
phase_event_payload: dict[str, Any] | None = None phase_event_payload: dict[str, Any] | None = None
@@ -32,6 +41,9 @@ class ScoreboardTransitionResult:
session: GameSession session: GameSession
leaderboard: list[dict] leaderboard: list[dict]
should_broadcast: bool 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: 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_config=next_round_config,
round_question=round_question, round_question=round_question,
should_broadcast=should_broadcast, 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_name=phase_event_name,
phase_event_payload=phase_event_payload, phase_event_payload=phase_event_payload,
) )
@@ -198,6 +215,7 @@ def finish_game(session: GameSession) -> FinishGameResult:
return FinishGameResult( return FinishGameResult(
session=locked_session, session=locked_session,
should_broadcast=should_broadcast, should_broadcast=should_broadcast,
response_payload=build_finish_game_response(locked_session),
phase_event_name=phase_event_name, phase_event_name=phase_event_name,
phase_event_payload=phase_event_payload, phase_event_payload=phase_event_payload,
) )
@@ -211,7 +229,12 @@ def promote_reveal_to_scoreboard(session: GameSession) -> ScoreboardTransitionRe
.order_by("-score", "nickname") .order_by("-score", "nickname")
.values("id", "nickname", "score") .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) current_round_question = get_current_round_question(session)
if current_round_question is None: if current_round_question is None:
@@ -220,7 +243,12 @@ def promote_reveal_to_scoreboard(session: GameSession) -> ScoreboardTransitionRe
.order_by("-score", "nickname") .order_by("-score", "nickname")
.values("id", "nickname", "score") .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() players_count = Player.objects.filter(session=session).count()
guess_count = Guess.objects.filter(round_question=current_round_question).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") .order_by("-score", "nickname")
.values("id", "nickname", "score") .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(): 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)
@@ -253,10 +286,19 @@ def promote_reveal_to_scoreboard(session: GameSession) -> ScoreboardTransitionRe
.order_by("-score", "nickname") .order_by("-score", "nickname")
.values("id", "nickname", "score") .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( return ScoreboardTransitionResult(
session=scoreboard_session, session=scoreboard_session,
leaderboard=leaderboard, leaderboard=leaderboard,
should_broadcast=should_broadcast, 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,
) )

View File

@@ -59,16 +59,12 @@ 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_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.sync_broadcast_phase_event")
@patch("lobby.views._build_start_next_round_response", return_value={"ok": True})
@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_response,
mock_sync_broadcast_phase_event, mock_sync_broadcast_phase_event,
): ):
next_round_config = RoundConfig.objects.create( next_round_config = RoundConfig.objects.create(
@@ -88,6 +84,7 @@ 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,
response_payload={"ok": True},
phase_event_name="phase.lie_started", phase_event_name="phase.lie_started",
phase_event_payload={"round_question_id": round_question.id}, 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.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_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,
"phase.lie_started", "phase.lie_started",
@@ -106,12 +102,10 @@ 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._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_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)
@@ -119,6 +113,7 @@ class LobbyGameplayExtractionTests(TestCase):
transition = gameplay_services.FinishGameResult( transition = gameplay_services.FinishGameResult(
session=finished_session, session=finished_session,
should_broadcast=True, should_broadcast=True,
response_payload={"ok": True},
phase_event_name="phase.game_over", phase_event_name="phase.game_over",
phase_event_payload={"winner": None, "leaderboard": []}, phase_event_payload={"winner": None, "leaderboard": []},
) )
@@ -129,7 +124,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_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,
"phase.game_over", "phase.game_over",
@@ -137,12 +131,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._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_response,
mock_sync_broadcast_phase_event, mock_sync_broadcast_phase_event,
): ):
replay_round_config = RoundConfig.objects.create( replay_round_config = RoundConfig.objects.create(
@@ -165,6 +157,7 @@ class LobbyGameplayExtractionTests(TestCase):
round_config=replay_round_config, round_config=replay_round_config,
round_question=round_question, round_question=round_question,
should_broadcast=False, should_broadcast=False,
response_payload={"ok": True},
) )
mock_start_next_round.return_value = transition mock_start_next_round.return_value = transition
@@ -173,21 +166,22 @@ 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_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._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_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=False) transition = gameplay_services.FinishGameResult(
session=finished_session,
should_broadcast=False,
response_payload={"ok": True},
)
mock_finish_game.return_value = transition mock_finish_game.return_value = transition
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}))
@@ -195,7 +189,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_response.assert_called_once_with(finished_session)
mock_sync_broadcast_phase_event.assert_not_called() mock_sync_broadcast_phase_event.assert_not_called()
class LobbyFlowTests(TestCase): class LobbyFlowTests(TestCase):

View File

@@ -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.models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent
from fupogfakta.payloads import ( from fupogfakta.payloads import (
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_response as _build_start_next_round_response,
) )
from fupogfakta.services import ( from fupogfakta.services import (
finish_game as _finish_game, finish_game as _finish_game,
@@ -75,11 +73,10 @@ def _create_unique_session_code() -> str:
def _maybe_promote_reveal_to_scoreboard(session: GameSession) -> GameSession: def _maybe_promote_reveal_to_scoreboard(session: GameSession) -> GameSession:
transition = _promote_reveal_to_scoreboard(session) transition = _promote_reveal_to_scoreboard(session)
if transition.should_broadcast: if transition.should_broadcast:
phase_event = _build_scoreboard_phase_event(transition.session, transition.leaderboard)
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 transition.session return transition.session
@@ -872,28 +869,16 @@ def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse:
transition = _promote_reveal_to_scoreboard(session) transition = _promote_reveal_to_scoreboard(session)
if transition.should_broadcast: if transition.should_broadcast:
phase_event = _build_scoreboard_phase_event(transition.session, transition.leaderboard)
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,
) )
session = transition.session session = transition.session
if session.status not in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}: if session.status not in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}:
return api_error(request, code="scoreboard_invalid_phase", status=400) return api_error(request, code="scoreboard_invalid_phase", status=400)
leaderboard = transition.leaderboard return JsonResponse(transition.response_payload)
return JsonResponse(
{
"session": {
"code": session.code,
"status": session.status,
"current_round": session.current_round,
},
"leaderboard": leaderboard,
}
)
@require_POST @require_POST
@@ -921,13 +906,7 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse:
transition.phase_event_payload, transition.phase_event_payload,
) )
return JsonResponse( return JsonResponse(transition.response_payload)
_build_start_next_round_response(
transition.session,
transition.round_config,
transition.round_question,
)
)
@require_POST @require_POST
@login_required @login_required
@@ -954,7 +933,7 @@ def finish_game(request: HttpRequest, code: str) -> JsonResponse:
transition.phase_event_payload, transition.phase_event_payload,
) )
return JsonResponse(_build_finish_game_response(transition.session)) return JsonResponse(transition.response_payload)
@require_POST @require_POST