refactor(gameplay): extract start/show transitions from lobby views
Some checks failed
CI / test-and-quality (push) Failing after 11s
CI / test-and-quality (pull_request) Failing after 12s

This commit is contained in:
2026-03-17 19:44:13 +00:00
parent 16c9cf6b57
commit 03850b5ed5
3 changed files with 190 additions and 113 deletions

View File

@@ -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)

View File

@@ -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")

View File

@@ -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