[READY][Gameplay] #310 Host transition idempotency and error catalog for scoreboard -> next round / finish #320
@@ -1,18 +1,23 @@
|
|||||||
import random
|
import random
|
||||||
|
from datetime import timedelta
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from django.db import transaction
|
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 Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent
|
||||||
from .payloads import (
|
from .payloads import (
|
||||||
build_finish_game_phase_event,
|
build_finish_game_phase_event,
|
||||||
build_finish_game_response,
|
build_finish_game_response,
|
||||||
|
build_lie_started_payload,
|
||||||
|
build_question_shown_payload,
|
||||||
|
build_question_shown_response,
|
||||||
build_reveal_scoreboard_response,
|
build_reveal_scoreboard_response,
|
||||||
build_scoreboard_phase_event,
|
build_scoreboard_phase_event,
|
||||||
build_start_next_round_phase_event,
|
build_start_next_round_phase_event,
|
||||||
build_start_next_round_response,
|
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:
|
def start_next_round(session: GameSession) -> RoundTransitionResult:
|
||||||
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)
|
||||||
|
|||||||
@@ -57,29 +57,29 @@ class LobbyGameplayExtractionTests(TestCase):
|
|||||||
self.assertIs(lobby_views._prepare_mixed_answers, gameplay_services.prepare_mixed_answers)
|
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._resolve_scores, gameplay_services.resolve_scores)
|
||||||
self.assertIs(lobby_views._promote_reveal_to_scoreboard, gameplay_services.promote_reveal_to_scoreboard)
|
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._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_phase_view_model, gameplay_payloads.build_phase_view_model)
|
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_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_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):
|
def test_start_round_view_source_stays_http_thin(self):
|
||||||
source = inspect.getsource(inspect.unwrap(lobby_views.start_round))
|
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("transition = _start_round(session, category_slug)", source)
|
||||||
self.assertIn("_build_start_round_response(session, round_config, round_question)", source)
|
self.assertNotIn("RoundConfig", source)
|
||||||
self.assertNotIn('"round_question": {', source)
|
self.assertNotIn("RoundQuestion", source)
|
||||||
|
self.assertNotIn("build_start_round_response", source)
|
||||||
|
|
||||||
def test_show_question_view_source_stays_http_thin(self):
|
def test_show_question_view_source_stays_http_thin(self):
|
||||||
source = inspect.getsource(inspect.unwrap(lobby_views.show_question))
|
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("transition = _show_question(session)", source)
|
||||||
self.assertIn("_build_question_shown_response(round_question, lie_deadline_iso, round_config.lie_seconds)", source)
|
self.assertNotIn("RoundConfig", source)
|
||||||
self.assertNotIn('"round_question": {', source)
|
self.assertNotIn("RoundQuestion", source)
|
||||||
self.assertNotIn('"round_question_id": round_question.id', source)
|
self.assertNotIn("build_question_shown_response", source)
|
||||||
|
|
||||||
def test_start_next_round_view_source_stays_http_thin(self):
|
def test_start_next_round_view_source_stays_http_thin(self):
|
||||||
source = inspect.getsource(inspect.unwrap(lobby_views.start_next_round))
|
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_response", source)
|
||||||
self.assertNotIn("build_finish_game_phase_event", 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.sync_broadcast_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(
|
||||||
@@ -512,7 +587,7 @@ class StartRoundTests(TestCase):
|
|||||||
self.assertEqual(response.json()["locale"], "en")
|
self.assertEqual(response.json()["locale"], "en")
|
||||||
self.assertEqual(response.json()["error"], "Only host can start round")
|
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):
|
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")
|
self.client.login(username="host", password="secret123")
|
||||||
|
|
||||||
|
|||||||
118
lobby/views.py
118
lobby/views.py
@@ -1,7 +1,7 @@
|
|||||||
import json
|
|
||||||
import random
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import json
|
||||||
|
import random
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.db import IntegrityError, transaction
|
from django.db import IntegrityError, transaction
|
||||||
from django.http import HttpRequest, JsonResponse
|
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.models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent
|
||||||
from fupogfakta.payloads import (
|
from fupogfakta.payloads import (
|
||||||
build_leaderboard as _build_leaderboard,
|
build_leaderboard as _build_leaderboard,
|
||||||
build_lie_started_payload as _build_lie_started_payload,
|
|
||||||
build_phase_view_model as _build_phase_view_model,
|
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_reveal_payload as _build_reveal_payload,
|
||||||
build_round_question_payload as _build_round_question_payload,
|
build_round_question_payload as _build_round_question_payload,
|
||||||
build_scoreboard_phase_event as _build_scoreboard_phase_event,
|
build_scoreboard_phase_event as _build_scoreboard_phase_event,
|
||||||
build_start_round_response as _build_start_round_response,
|
|
||||||
)
|
)
|
||||||
from fupogfakta.services import (
|
from fupogfakta.services import (
|
||||||
finish_game as _finish_game,
|
finish_game as _finish_game,
|
||||||
@@ -27,7 +23,9 @@ from fupogfakta.services import (
|
|||||||
promote_reveal_to_scoreboard as _promote_reveal_to_scoreboard,
|
promote_reveal_to_scoreboard as _promote_reveal_to_scoreboard,
|
||||||
resolve_scores as _resolve_scores,
|
resolve_scores as _resolve_scores,
|
||||||
select_round_question as _select_round_question,
|
select_round_question as _select_round_question,
|
||||||
|
show_question as _show_question,
|
||||||
start_next_round as _start_next_round,
|
start_next_round as _start_next_round,
|
||||||
|
start_round as _start_round,
|
||||||
)
|
)
|
||||||
from realtime.broadcast import sync_broadcast_phase_event
|
from realtime.broadcast import sync_broadcast_phase_event
|
||||||
|
|
||||||
@@ -255,72 +253,23 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
status=403,
|
status=403,
|
||||||
)
|
)
|
||||||
|
|
||||||
if session.status != GameSession.Status.LOBBY:
|
|
||||||
return api_error(
|
|
||||||
request,
|
|
||||||
code="round_start_invalid_phase",
|
|
||||||
status=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
category = Category.objects.get(slug=category_slug, is_active=True)
|
transition = _start_round(session, category_slug)
|
||||||
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:
|
except ValueError as exc:
|
||||||
return api_error(request, code=str(exc), status=400)
|
error_code = str(exc)
|
||||||
|
error_status = {
|
||||||
round_config.save()
|
"category_not_found": 404,
|
||||||
session.status = GameSession.Status.LIE
|
"round_already_configured": 409,
|
||||||
session.save(update_fields=["status"])
|
}.get(error_code, 400)
|
||||||
|
return api_error(request, code=error_code, status=error_status)
|
||||||
lie_started_payload = _build_lie_started_payload(session, round_config, round_question)
|
|
||||||
|
|
||||||
sync_broadcast_phase_event(
|
sync_broadcast_phase_event(
|
||||||
session.code,
|
transition.session.code,
|
||||||
"phase.lie_started",
|
transition.phase_event_name,
|
||||||
lie_started_payload,
|
transition.phase_event_payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
return JsonResponse(
|
return JsonResponse(transition.response_payload, status=201)
|
||||||
_build_start_round_response(session, round_config, round_question),
|
|
||||||
status=201,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@require_POST
|
@require_POST
|
||||||
@@ -344,45 +293,18 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
status=403,
|
status=403,
|
||||||
)
|
)
|
||||||
|
|
||||||
if session.status != GameSession.Status.LIE:
|
|
||||||
return api_error(
|
|
||||||
request,
|
|
||||||
code="show_question_invalid_phase",
|
|
||||||
status=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
round_config = RoundConfig.objects.get(session=session, number=session.current_round)
|
transition = _show_question(session)
|
||||||
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:
|
except ValueError as exc:
|
||||||
return api_error(request, code=str(exc), status=400)
|
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()
|
|
||||||
|
|
||||||
sync_broadcast_phase_event(
|
sync_broadcast_phase_event(
|
||||||
session.code,
|
transition.session.code,
|
||||||
"phase.question_shown",
|
transition.phase_event_name,
|
||||||
_build_question_shown_payload(round_question, lie_deadline_iso, round_config.lie_seconds),
|
transition.phase_event_payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
return JsonResponse(
|
return JsonResponse(transition.response_payload, status=201)
|
||||||
_build_question_shown_response(round_question, lie_deadline_iso, round_config.lie_seconds),
|
|
||||||
status=201,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@require_POST
|
@require_POST
|
||||||
|
|||||||
Reference in New Issue
Block a user