refactor(gameplay): move scoreboard transitions into cartridge service
All checks were successful
CI / test-and-quality (push) Successful in 3m27s
CI / test-and-quality (pull_request) Successful in 3m28s

This commit is contained in:
2026-03-17 09:29:02 +00:00
parent 8247787404
commit f736f4f74e
4 changed files with 183 additions and 84 deletions

View File

@@ -1,6 +1,23 @@
import random import random
from dataclasses import dataclass
from .models import GameSession, Player, Question, RoundConfig, RoundQuestion, ScoreEvent from django.db import transaction
from .models import GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent
@dataclass(frozen=True)
class RoundTransitionResult:
session: GameSession
round_config: RoundConfig
round_question: RoundQuestion
should_broadcast: bool
@dataclass(frozen=True)
class FinishGameResult:
session: GameSession
should_broadcast: bool
def get_current_round_question(session: GameSession) -> RoundQuestion | None: def get_current_round_question(session: GameSession) -> RoundQuestion | None:
@@ -13,6 +30,16 @@ def get_current_round_question(session: GameSession) -> RoundQuestion | None:
def reset_round_question_bootstrap_state(round_question: RoundQuestion) -> RoundQuestion:
Guess.objects.filter(round_question=round_question).delete()
LieAnswer.objects.filter(round_question=round_question).delete()
if round_question.mixed_answers:
round_question.mixed_answers = []
round_question.save(update_fields=["mixed_answers"])
return round_question
def select_round_question(session: GameSession, round_config: RoundConfig) -> RoundQuestion: def select_round_question(session: GameSession, round_config: RoundConfig) -> RoundQuestion:
existing_round_question = get_current_round_question(session) existing_round_question = get_current_round_question(session)
if existing_round_question is not None: if existing_round_question is not None:
@@ -61,6 +88,83 @@ def prepare_mixed_answers(round_question: RoundQuestion) -> list[str]:
def start_next_round(session: GameSession) -> RoundTransitionResult:
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
next_round_config = None
round_question = None
should_broadcast = False
if locked_session.status == GameSession.Status.SCOREBOARD:
previous_round_config = RoundConfig.objects.filter(
session=locked_session,
number=locked_session.current_round,
).select_related("category").first()
if previous_round_config is None:
raise ValueError("round_config_missing")
next_round_number = locked_session.current_round + 1
next_round_config = RoundConfig(
session=locked_session,
number=next_round_number,
category=previous_round_config.category,
lie_seconds=previous_round_config.lie_seconds,
guess_seconds=previous_round_config.guess_seconds,
points_correct=previous_round_config.points_correct,
points_bluff=previous_round_config.points_bluff,
started_from_scoreboard=True,
)
locked_session.current_round = next_round_number
round_question = reset_round_question_bootstrap_state(select_round_question(locked_session, next_round_config))
next_round_config.save()
locked_session.status = GameSession.Status.LIE
locked_session.save(update_fields=["current_round", "status"])
should_broadcast = True
elif locked_session.status == GameSession.Status.LIE:
if locked_session.current_round <= 1:
raise ValueError("next_round_invalid_phase")
next_round_config = RoundConfig.objects.filter(
session=locked_session,
number=locked_session.current_round,
).select_related("category").first()
round_question = get_current_round_question(locked_session)
if (
next_round_config is None
or not next_round_config.started_from_scoreboard
or round_question is None
):
raise ValueError("next_round_invalid_phase")
else:
raise ValueError("next_round_invalid_phase")
return RoundTransitionResult(
session=locked_session,
round_config=next_round_config,
round_question=round_question,
should_broadcast=should_broadcast,
)
def finish_game(session: GameSession) -> FinishGameResult:
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
should_broadcast = False
if locked_session.status == GameSession.Status.SCOREBOARD:
locked_session.status = GameSession.Status.FINISHED
locked_session.save(update_fields=["status"])
should_broadcast = True
elif locked_session.status != GameSession.Status.FINISHED:
raise ValueError("finish_game_invalid_phase")
return FinishGameResult(session=locked_session, should_broadcast=should_broadcast)
def resolve_scores( def resolve_scores(
session: GameSession, session: GameSession,
round_question: RoundQuestion, round_question: RoundQuestion,

View File

@@ -5,7 +5,14 @@ from django.test import TestCase
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 build_lie_started_payload, build_reveal_payload from fupogfakta.payloads import build_lie_started_payload, build_reveal_payload
from fupogfakta.services import get_current_round_question, prepare_mixed_answers, resolve_scores, select_round_question from fupogfakta.services import (
finish_game,
get_current_round_question,
prepare_mixed_answers,
resolve_scores,
select_round_question,
start_next_round,
)
User = get_user_model() User = get_user_model()
@@ -63,6 +70,46 @@ class FupOgFaktaExtractionSliceTests(TestCase):
round_question.refresh_from_db() round_question.refresh_from_db()
self.assertEqual(round_question.mixed_answers, answers) self.assertEqual(round_question.mixed_answers, answers)
def test_start_next_round_moves_scoreboard_transition_into_service(self):
self.session.status = GameSession.Status.SCOREBOARD
self.session.save(update_fields=["status"])
result = start_next_round(self.session)
self.session.refresh_from_db()
self.assertTrue(result.should_broadcast)
self.assertEqual(result.session.status, GameSession.Status.LIE)
self.assertEqual(result.session.current_round, 2)
self.assertEqual(result.round_config.number, 2)
self.assertTrue(result.round_config.started_from_scoreboard)
self.assertEqual(result.round_question.round_number, 2)
def test_start_next_round_rejects_plain_lie_without_scoreboard_marker(self):
self.session.status = GameSession.Status.LIE
self.session.current_round = 2
self.session.save(update_fields=["status", "current_round"])
RoundConfig.objects.create(session=self.session, number=2, category=self.category, started_from_scoreboard=False)
RoundQuestion.objects.create(
session=self.session,
round_number=2,
question=self.question_two,
correct_answer=self.question_two.correct_answer,
)
with self.assertRaisesMessage(ValueError, "next_round_invalid_phase"):
start_next_round(self.session)
def test_finish_game_moves_scoreboard_transition_into_service(self):
self.session.status = GameSession.Status.SCOREBOARD
self.session.save(update_fields=["status"])
result = finish_game(self.session)
self.session.refresh_from_db()
self.assertTrue(result.should_broadcast)
self.assertEqual(result.session.status, GameSession.Status.FINISHED)
self.assertEqual(self.session.status, GameSession.Status.FINISHED)
def test_resolve_scores_applies_correct_and_bluff_points(self): def test_resolve_scores_applies_correct_and_bluff_points(self):
round_question = RoundQuestion.objects.create( round_question = RoundQuestion.objects.create(
session=self.session, session=self.session,

View File

@@ -34,6 +34,8 @@ class LobbyGameplayExtractionTests(TestCase):
self.assertIs(lobby_views._select_round_question, gameplay_services.select_round_question) 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._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._start_next_round, gameplay_services.start_next_round)
self.assertIs(lobby_views._finish_game, gameplay_services.finish_game)
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_response, gameplay_payloads.build_finish_game_response) self.assertIs(lobby_views._build_finish_game_response, gameplay_payloads.build_finish_game_response)

View File

@@ -17,10 +17,12 @@ from fupogfakta.payloads import (
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 (
finish_game as _finish_game,
get_current_round_question as _get_current_round_question, get_current_round_question as _get_current_round_question,
prepare_mixed_answers as _prepare_mixed_answers, prepare_mixed_answers as _prepare_mixed_answers,
resolve_scores as _resolve_scores, resolve_scores as _resolve_scores,
select_round_question as _select_round_question, select_round_question as _select_round_question,
start_next_round as _start_next_round,
) )
from realtime.broadcast import sync_broadcast_phase_event from realtime.broadcast import sync_broadcast_phase_event
@@ -68,16 +70,6 @@ def _create_unique_session_code() -> str:
def _reset_round_question_bootstrap_state(round_question: RoundQuestion) -> RoundQuestion:
Guess.objects.filter(round_question=round_question).delete()
LieAnswer.objects.filter(round_question=round_question).delete()
if round_question.mixed_answers:
round_question.mixed_answers = []
round_question.save(update_fields=["mixed_answers"])
return round_question
def _maybe_promote_reveal_to_scoreboard(session: GameSession) -> GameSession: def _maybe_promote_reveal_to_scoreboard(session: GameSession) -> GameSession:
if session.status != GameSession.Status.REVEAL: if session.status != GameSession.Status.REVEAL:
return session return session
@@ -930,71 +922,30 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse:
if session.host_id != request.user.id: if session.host_id != request.user.id:
return api_error(request, code="host_only_start_next_round", status=403) return api_error(request, code="host_only_start_next_round", status=403)
should_broadcast = False
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().select_related("host").get(pk=session.pk)
next_round_config = None
round_question = None
if locked_session.status == GameSession.Status.SCOREBOARD:
previous_round_config = RoundConfig.objects.filter(
session=locked_session,
number=locked_session.current_round,
).select_related("category").first()
if previous_round_config is None:
return api_error(request, code="round_config_missing", status=400)
next_round_number = locked_session.current_round + 1
next_round_config = RoundConfig(
session=locked_session,
number=next_round_number,
category=previous_round_config.category,
lie_seconds=previous_round_config.lie_seconds,
guess_seconds=previous_round_config.guess_seconds,
points_correct=previous_round_config.points_correct,
points_bluff=previous_round_config.points_bluff,
started_from_scoreboard=True,
)
locked_session.current_round = next_round_number
try: try:
round_question = _reset_round_question_bootstrap_state( transition = _start_next_round(session)
_select_round_question(locked_session, next_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)
next_round_config.save() if transition.should_broadcast:
locked_session.status = GameSession.Status.LIE lie_started_payload = _build_lie_started_payload(
locked_session.save(update_fields=["current_round", "status"]) transition.session,
should_broadcast = True transition.round_config,
elif locked_session.status == GameSession.Status.LIE: transition.round_question,
if locked_session.current_round <= 1: )
return api_error(request, code="next_round_invalid_phase", status=400)
next_round_config = RoundConfig.objects.filter(
session=locked_session,
number=locked_session.current_round,
).select_related("category").first()
round_question = _get_current_round_question(locked_session)
if (
next_round_config is None
or not next_round_config.started_from_scoreboard
or round_question is None
):
return api_error(request, code="next_round_invalid_phase", status=400)
else:
return api_error(request, code="next_round_invalid_phase", status=400)
if should_broadcast:
lie_started_payload = _build_lie_started_payload(locked_session, next_round_config, round_question)
sync_broadcast_phase_event( sync_broadcast_phase_event(
locked_session.code, transition.session.code,
"phase.lie_started", "phase.lie_started",
lie_started_payload, lie_started_payload,
) )
return JsonResponse(_build_start_next_round_response(locked_session, next_round_config, round_question)) return JsonResponse(
_build_start_next_round_response(
transition.session,
transition.round_config,
transition.round_question,
)
)
@require_POST @require_POST
@login_required @login_required
@@ -1009,26 +960,21 @@ def finish_game(request: HttpRequest, code: str) -> JsonResponse:
if session.host_id != request.user.id: if session.host_id != request.user.id:
return api_error(request, code="host_only_finish_game", status=403) return api_error(request, code="host_only_finish_game", status=403)
should_broadcast = False try:
with transaction.atomic(): transition = _finish_game(session)
locked_session = GameSession.objects.select_for_update().get(pk=session.pk) except ValueError as exc:
if locked_session.status == GameSession.Status.SCOREBOARD: return api_error(request, code=str(exc), status=400)
locked_session.status = GameSession.Status.FINISHED
locked_session.save(update_fields=["status"])
should_broadcast = True
elif locked_session.status != GameSession.Status.FINISHED:
return api_error(request, code="finish_game_invalid_phase", status=400)
if should_broadcast: if transition.should_broadcast:
leaderboard = _build_leaderboard(locked_session) leaderboard = _build_leaderboard(transition.session)
winner = leaderboard[0] if leaderboard else None winner = leaderboard[0] if leaderboard else None
sync_broadcast_phase_event( sync_broadcast_phase_event(
locked_session.code, transition.session.code,
"phase.game_over", "phase.game_over",
{"winner": winner, "leaderboard": list(leaderboard)}, {"winner": winner, "leaderboard": list(leaderboard)},
) )
return JsonResponse(_build_finish_game_response(locked_session)) return JsonResponse(_build_finish_game_response(transition.session))
@require_POST @require_POST